feat(article): support regenerating failed articles
This commit is contained in:
@@ -408,7 +408,6 @@ func (s *ArticleImitationService) executeGeneration(ctx context.Context, job art
|
||||
|
||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||
ArticleID: job.ArticleID,
|
||||
VersionNo: 1,
|
||||
Title: title,
|
||||
HTMLContent: "",
|
||||
MarkdownContent: content,
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type RegenerateArticleResponse struct {
|
||||
ArticleID int64 `json:"article_id"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
}
|
||||
|
||||
type articleRegenerationRecord struct {
|
||||
ArticleID int64
|
||||
SourceType string
|
||||
TemplateID *int64
|
||||
KolPromptID *int64
|
||||
PromptRuleID *int64
|
||||
GenerateStatus string
|
||||
WizardStateJSON []byte
|
||||
SourceTaskID int64
|
||||
TaskType string
|
||||
RequestHash *string
|
||||
InputParamsJSON []byte
|
||||
InputParams map[string]interface{}
|
||||
}
|
||||
|
||||
func (s *ArticleService) Regenerate(ctx context.Context, articleID int64) (*RegenerateArticleResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
if s == nil || s.llm == nil || s.rabbitMQ == nil {
|
||||
return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is not configured")
|
||||
}
|
||||
if err := s.llm.Validate(); err != nil {
|
||||
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
|
||||
}
|
||||
|
||||
quotaRepo := repository.NewQuotaRepository(s.pool)
|
||||
balance, err := quotaRepo.GetCurrentBalance(ctx, actor.TenantID, "article_generation")
|
||||
if err != nil || balance < 1 {
|
||||
return nil, response.ErrForbidden(40301, "quota_insufficient", "generation quota insufficient, please upgrade your plan")
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin regeneration tx: %w", err)
|
||||
}
|
||||
defer rollbackGenerationTx(tx)
|
||||
|
||||
record, err := loadArticleForRegeneration(ctx, tx, actor.TenantID, articleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateArticleRegeneration(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
quotaTx := repository.NewQuotaRepository(tx)
|
||||
auditTx := repository.NewAuditRepository(tx)
|
||||
|
||||
newBalance := balance - 1
|
||||
reason := "generation_reserve"
|
||||
referenceType := "generation_task"
|
||||
if _, err := quotaTx.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
QuotaType: "article_generation",
|
||||
Delta: -1,
|
||||
BalanceAfter: newBalance,
|
||||
Reason: &reason,
|
||||
ReferenceType: &referenceType,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("reserve regeneration quota: %w", err)
|
||||
}
|
||||
|
||||
reservationID, err := quotaTx.CreateQuotaReservation(ctx, repository.QuotaReservationInput{
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
QuotaType: "article_generation",
|
||||
ResourceType: "article",
|
||||
ResourceID: nil,
|
||||
ReservedAmount: 1,
|
||||
ExpireAt: time.Now().Add(time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create regeneration reservation: %w", err)
|
||||
}
|
||||
if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil {
|
||||
return nil, fmt.Errorf("update regeneration reservation resource: %w", err)
|
||||
}
|
||||
|
||||
title := regenerationInitialTitle(record)
|
||||
if err := updateGeneratedArticleState(ctx, tx, articleID, actor.TenantID, title, "generating", record.WizardStateJSON); err != nil {
|
||||
return nil, fmt.Errorf("mark article regenerating: %w", err)
|
||||
}
|
||||
|
||||
requestHash := record.RequestHash
|
||||
if requestHash == nil {
|
||||
if hash := strings.TrimSpace(extractString(record.InputParams, "rendered_prompt_hash")); hash != "" {
|
||||
requestHash = &hash
|
||||
}
|
||||
}
|
||||
|
||||
taskID, err := auditTx.CreateGenerationTask(ctx, repository.GenerationTaskInput{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: &articleID,
|
||||
QuotaReservationID: &reservationID,
|
||||
TaskType: record.TaskType,
|
||||
RequestHash: requestHash,
|
||||
InputParamsJSON: record.InputParamsJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create regeneration task: %w", err)
|
||||
}
|
||||
|
||||
if record.TaskType == kolGenerationTaskType {
|
||||
if _, err := auditTx.CreateTaskRecord(ctx, repository.TaskRecordInput{
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
TaskType: kolGenerationTaskRecordType,
|
||||
ResourceType: kolGenerationTaskRecordScope,
|
||||
ResourceID: taskID,
|
||||
Status: "queued",
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("create kol regeneration task record: %w", err)
|
||||
}
|
||||
if err := createKolUsageForRegeneration(ctx, tx, actor.TenantID, taskID, record.InputParams); err != nil {
|
||||
return nil, fmt.Errorf("create kol regeneration usage: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit regeneration task: %w", err)
|
||||
}
|
||||
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
||||
|
||||
task := ClaimedGenerationTask{
|
||||
ID: taskID,
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
ArticleID: articleID,
|
||||
QuotaReservationID: reservationID,
|
||||
TaskType: record.TaskType,
|
||||
InputParams: cloneInputParams(record.InputParams),
|
||||
}
|
||||
if err := publishArticleGenerationTask(ctx, s.rabbitMQ, articleGenerationTaskEnvelope{
|
||||
TaskID: taskID,
|
||||
TaskType: record.TaskType,
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: articleID,
|
||||
}); err != nil {
|
||||
RecordGenerationTaskEvent(GenerationTaskEventQueueFailure, GenerationTaskLogContext{
|
||||
TaskID: taskID,
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
ArticleID: articleID,
|
||||
ReservationID: reservationID,
|
||||
TaskType: record.TaskType,
|
||||
Stage: "queue_enqueue",
|
||||
StatusBefore: "queued",
|
||||
StatusAfter: "failed",
|
||||
Result: GenerationTaskResultFailure,
|
||||
})
|
||||
s.failRegenerationQueue(context.Background(), task, title, err)
|
||||
return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry")
|
||||
}
|
||||
|
||||
runtimeCfg := s.runtimeConfig()
|
||||
if runtimeCfg.StreamEnabled && s.streams != nil {
|
||||
s.streams.Start(articleID, taskID, title)
|
||||
}
|
||||
|
||||
return &RegenerateArticleResponse{ArticleID: articleID, TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
func loadArticleForRegeneration(ctx context.Context, tx pgx.Tx, tenantID, articleID int64) (*articleRegenerationRecord, error) {
|
||||
record := &articleRegenerationRecord{}
|
||||
var templateID, kolPromptID, promptRuleID sql.NullInt64
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT id, source_type, template_id, kol_prompt_id, prompt_rule_id, generate_status, wizard_state_json
|
||||
FROM articles
|
||||
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||
FOR UPDATE
|
||||
`, articleID, tenantID).Scan(
|
||||
&record.ArticleID,
|
||||
&record.SourceType,
|
||||
&templateID,
|
||||
&kolPromptID,
|
||||
&promptRuleID,
|
||||
&record.GenerateStatus,
|
||||
&record.WizardStateJSON,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to fetch article")
|
||||
}
|
||||
record.TemplateID = nullableSQLInt64(templateID)
|
||||
record.KolPromptID = nullableSQLInt64(kolPromptID)
|
||||
record.PromptRuleID = nullableSQLInt64(promptRuleID)
|
||||
|
||||
var requestHash sql.NullString
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, task_type, request_hash, input_params_json
|
||||
FROM generation_tasks
|
||||
WHERE article_id = $1
|
||||
AND tenant_id = $2
|
||||
AND status = 'failed'
|
||||
ORDER BY (COALESCE(input_params_json, '{}'::jsonb) = '{}'::jsonb), created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`, articleID, tenantID).Scan(
|
||||
&record.SourceTaskID,
|
||||
&record.TaskType,
|
||||
&requestHash,
|
||||
&record.InputParamsJSON,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, response.ErrConflict(40914, "generation_task_missing", "failed generation task is missing")
|
||||
}
|
||||
return nil, response.ErrInternal(50010, "query_failed", "failed to fetch generation task")
|
||||
}
|
||||
if requestHash.Valid {
|
||||
record.RequestHash = &requestHash.String
|
||||
}
|
||||
if len(record.InputParamsJSON) == 0 {
|
||||
record.InputParamsJSON = []byte("{}")
|
||||
}
|
||||
params := map[string]interface{}{}
|
||||
if err := json.Unmarshal(record.InputParamsJSON, ¶ms); err != nil {
|
||||
return nil, response.ErrConflict(40915, "generation_params_invalid", "failed generation params are invalid")
|
||||
}
|
||||
record.InputParams = params
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func validateArticleRegeneration(record *articleRegenerationRecord) error {
|
||||
if record == nil {
|
||||
return response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||
}
|
||||
if record.GenerateStatus != "failed" {
|
||||
return response.ErrConflict(40913, "article_not_regeneratable", "article cannot be regenerated in its current state")
|
||||
}
|
||||
|
||||
switch record.SourceType {
|
||||
case "template":
|
||||
if record.TaskType == "template" && record.TemplateID != nil {
|
||||
return nil
|
||||
}
|
||||
case "custom_generation":
|
||||
if record.TaskType == "custom_generation" {
|
||||
return nil
|
||||
}
|
||||
case articleImitationSourceType:
|
||||
if record.TaskType == articleImitationTaskType {
|
||||
return nil
|
||||
}
|
||||
case kolGenerationTaskType:
|
||||
if record.TaskType == kolGenerationTaskType {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return response.ErrConflict(40913, "article_not_regeneratable", "article cannot be regenerated in its current state")
|
||||
}
|
||||
|
||||
func regenerationInitialTitle(record *articleRegenerationRecord) string {
|
||||
if record == nil {
|
||||
return "Generated Article"
|
||||
}
|
||||
|
||||
switch record.TaskType {
|
||||
case "template":
|
||||
return resolveArticleTitle(record.InputParams, "")
|
||||
case "custom_generation":
|
||||
if title := strings.TrimSpace(extractString(record.InputParams, "task_name")); title != "" {
|
||||
return title
|
||||
}
|
||||
return promptRuleGeneratingTitlePlaceholder
|
||||
case articleImitationTaskType:
|
||||
return buildImitationInitialTitle(extractString(record.InputParams, "source_title"))
|
||||
case kolGenerationTaskType:
|
||||
if title := strings.TrimSpace(extractString(record.InputParams, "prompt_name")); title != "" {
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
if title := strings.TrimSpace(extractString(record.InputParams, "title")); title != "" {
|
||||
return title
|
||||
}
|
||||
return "Generated Article"
|
||||
}
|
||||
|
||||
func (s *ArticleService) failRegenerationQueue(ctx context.Context, task ClaimedGenerationTask, title string, taskErr error) {
|
||||
if task.TaskType == kolGenerationTaskType {
|
||||
_ = HandleKolGenerationTaskFailure(ctx, s.pool, s.cache, task, "queue_enqueue", taskErr)
|
||||
} else {
|
||||
_ = HandleClaimedGenerationTaskFailure(ctx, s.pool, nil, nil, task, taskErr)
|
||||
}
|
||||
|
||||
runtimeCfg := s.runtimeConfig()
|
||||
if runtimeCfg.StreamEnabled && s.streams != nil {
|
||||
s.streams.Fail(task.ArticleID, task.ID, title, formatGenerationFailure("queue_enqueue", taskErr))
|
||||
}
|
||||
}
|
||||
|
||||
func createKolUsageForRegeneration(ctx context.Context, tx pgx.Tx, tenantID, taskID int64, params map[string]interface{}) error {
|
||||
packageID := int64(extractInt(params, "package_id"))
|
||||
promptID := int64(extractInt(params, "prompt_id"))
|
||||
if packageID <= 0 || promptID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
promptRevisionNo := extractInt(params, "prompt_revision_no")
|
||||
if promptRevisionNo <= 0 {
|
||||
promptRevisionNo = 1
|
||||
}
|
||||
subscriptionID := optionalInt64FromParams(params, "subscription_id")
|
||||
subscriptionPromptID := optionalInt64FromParams(params, "subscription_prompt_id")
|
||||
variablesSnapshot, err := marshalOptionalJSONField(params, "variables")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
schemaSnapshot, err := marshalOptionalJSONField(params, "schema_snapshot")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
renderedPromptHash := strings.TrimSpace(extractString(params, "rendered_prompt_hash"))
|
||||
var renderedPromptHashPtr *string
|
||||
if renderedPromptHash != "" {
|
||||
renderedPromptHashPtr = &renderedPromptHash
|
||||
}
|
||||
|
||||
_, err = repository.NewKolUsageRepository(tx).Create(ctx, repository.CreateKolUsageLogInput{
|
||||
TenantID: tenantID,
|
||||
SubscriptionID: subscriptionID,
|
||||
SubscriptionPromptID: subscriptionPromptID,
|
||||
PackageID: packageID,
|
||||
PromptID: promptID,
|
||||
PromptRevisionNo: promptRevisionNo,
|
||||
GenerationTaskID: &taskID,
|
||||
VariablesSnapshot: variablesSnapshot,
|
||||
SchemaSnapshot: schemaSnapshot,
|
||||
RenderedPromptHash: renderedPromptHashPtr,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func marshalOptionalJSONField(params map[string]interface{}, key string) ([]byte, error) {
|
||||
if len(params) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
value, ok := params[key]
|
||||
if !ok || value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
func optionalInt64FromParams(params map[string]interface{}, key string) *int64 {
|
||||
value := int64(extractInt(params, key))
|
||||
if value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func nullableSQLInt64(value sql.NullInt64) *int64 {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
result := value.Int64
|
||||
return &result
|
||||
}
|
||||
@@ -27,19 +27,28 @@ import (
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type ArticleService struct {
|
||||
pool *pgxpool.Pool
|
||||
auditLogs *auditlog.AsyncWriter
|
||||
objectStorage objectstorage.Client
|
||||
cache sharedcache.Cache
|
||||
cacheGroup singleflight.Group
|
||||
pool *pgxpool.Pool
|
||||
auditLogs *auditlog.AsyncWriter
|
||||
objectStorage objectstorage.Client
|
||||
llm llm.Client
|
||||
rabbitMQ *rabbitmq.Client
|
||||
streams *stream.GenerationHub
|
||||
cfg generationRuntimeConfig
|
||||
configProvider runtimeConfigProvider
|
||||
cache sharedcache.Cache
|
||||
cacheGroup singleflight.Group
|
||||
}
|
||||
|
||||
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService {
|
||||
@@ -51,6 +60,37 @@ func (s *ArticleService) WithCache(c sharedcache.Cache) *ArticleService {
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ArticleService) WithGenerationRuntime(
|
||||
llmClient llm.Client,
|
||||
rabbitMQClient *rabbitmq.Client,
|
||||
streams *stream.GenerationHub,
|
||||
cfg config.GenerationConfig,
|
||||
llmMaxOutputTokens int64,
|
||||
) *ArticleService {
|
||||
s.llm = llmClient
|
||||
s.rabbitMQ = rabbitMQClient
|
||||
s.streams = streams
|
||||
s.cfg = generationRuntimeFromConfig(cfg, config.LLMConfig{MaxOutputTokens: llmMaxOutputTokens})
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ArticleService) WithConfigProvider(provider runtimeConfigProvider) *ArticleService {
|
||||
s.configProvider = provider
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ArticleService) runtimeConfig() generationRuntimeConfig {
|
||||
if s != nil && s.configProvider != nil {
|
||||
if cfg := s.configProvider.Current(); cfg != nil {
|
||||
return generationRuntimeFromConfig(cfg.Generation, cfg.LLM)
|
||||
}
|
||||
}
|
||||
if s == nil {
|
||||
return generationRuntimeFromConfig(config.GenerationConfig{}, config.LLMConfig{})
|
||||
}
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
const maxArticleImageSizeBytes = 10 * 1024 * 1024
|
||||
|
||||
var supportedArticleImageExtensions = map[string]string{
|
||||
|
||||
@@ -569,7 +569,6 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job
|
||||
|
||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||
ArticleID: job.ArticleID,
|
||||
VersionNo: 1,
|
||||
Title: title,
|
||||
HTMLContent: "",
|
||||
MarkdownContent: content,
|
||||
|
||||
@@ -349,11 +349,11 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
||||
AND template_id = $3
|
||||
AND source_type = 'template'
|
||||
AND deleted_at IS NULL
|
||||
AND generate_status = 'draft'
|
||||
AND generate_status IN ('draft', 'failed')
|
||||
FOR UPDATE
|
||||
`, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
|
||||
if err != nil {
|
||||
return nil, response.ErrConflict(40913, "draft_not_editable", "draft is not editable")
|
||||
return nil, response.ErrConflict(40913, "article_not_regeneratable", "article cannot be regenerated in its current state")
|
||||
}
|
||||
if err := updateGeneratedArticleState(ctx, tx, articleID, actor.TenantID, initialTitle, "generating", wizardStateJSON); err != nil {
|
||||
return nil, fmt.Errorf("mark draft generating: %w", err)
|
||||
@@ -536,7 +536,6 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
||||
|
||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||
ArticleID: job.ArticleID,
|
||||
VersionNo: 1,
|
||||
Title: title,
|
||||
HTMLContent: "",
|
||||
MarkdownContent: content,
|
||||
|
||||
@@ -51,7 +51,25 @@ func (r *articleRepository) CreateArticle(ctx context.Context, input CreateArtic
|
||||
})
|
||||
}
|
||||
|
||||
func NextArticleVersionNo(ctx context.Context, db generated.DBTX, articleID int64) (int, error) {
|
||||
var nextVersionNo int
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(version_no), 0) + 1
|
||||
FROM article_versions
|
||||
WHERE article_id = $1
|
||||
`, articleID).Scan(&nextVersionNo)
|
||||
return nextVersionNo, err
|
||||
}
|
||||
|
||||
func (r *articleRepository) CreateArticleVersion(ctx context.Context, input CreateArticleVersionInput) (int64, error) {
|
||||
if input.VersionNo <= 0 {
|
||||
nextVersionNo, err := NextArticleVersionNo(ctx, r.db, input.ArticleID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
input.VersionNo = nextVersionNo
|
||||
}
|
||||
|
||||
previous, err := loadLatestArticleVersionContent(ctx, r.db, input.ArticleID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -43,7 +43,16 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
|
||||
).WithConfigProvider(a.ConfigStore)
|
||||
|
||||
return &ArticleHandler{
|
||||
svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage).WithCache(a.Cache),
|
||||
svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage).
|
||||
WithCache(a.Cache).
|
||||
WithGenerationRuntime(
|
||||
a.LLM,
|
||||
a.RabbitMQ,
|
||||
a.GenerationStreams,
|
||||
a.Config().Generation,
|
||||
a.Config().LLM.MaxOutputTokens,
|
||||
).
|
||||
WithConfigProvider(a.ConfigStore),
|
||||
selectionOptimize: app.NewArticleSelectionOptimizeService(
|
||||
a.DB,
|
||||
a.LLM,
|
||||
@@ -188,6 +197,22 @@ func (h *ArticleHandler) GenerateImitation(c *gin.Context) {
|
||||
response.SuccessWithStatus(c, http.StatusCreated, data)
|
||||
}
|
||||
|
||||
func (h *ArticleHandler) Regenerate(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.svc.Regenerate(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.SuccessWithStatus(c, http.StatusCreated, data)
|
||||
}
|
||||
|
||||
func (h *ArticleHandler) Create(c *gin.Context) {
|
||||
var req app.CreateArticleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -157,6 +157,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
articles.POST("", artHandler.Create)
|
||||
articles.POST("/generate-from-rule", artHandler.GenerateFromRule)
|
||||
articles.POST("/imitations/generate", artHandler.GenerateImitation)
|
||||
articles.POST("/:id/regenerate", artHandler.Regenerate)
|
||||
articles.POST("/:id/images", artHandler.UploadImage)
|
||||
articles.POST("/:id/selection-optimize/stream", artHandler.OptimizeSelectionStream)
|
||||
articles.GET("/:id", artHandler.Detail)
|
||||
|
||||
@@ -168,7 +168,6 @@ func (w *KolGenerationWorker) Process(ctx context.Context, task tenantapp.Claime
|
||||
|
||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||
ArticleID: task.ArticleID,
|
||||
VersionNo: 1,
|
||||
Title: title,
|
||||
HTMLContent: "",
|
||||
MarkdownContent: content,
|
||||
|
||||
Reference in New Issue
Block a user