feat(imitation): add article imitation create flow
Introduce 仿写创作: new list and create views, backend imitation service and prompt templates, worker task routing for imitation jobs, and one-click rewrite triggers from question citation sources. Surface source article URL/title on article list/detail, and restrict failed articles to delete-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,16 @@ func main() {
|
||||
app.Config.LLM.MaxOutputTokens,
|
||||
).WithCache(app.Cache)
|
||||
|
||||
imitationSvc := tenantapp.NewArticleImitationService(
|
||||
app.DB,
|
||||
app.LLM,
|
||||
nil,
|
||||
knowledgeSvc,
|
||||
app.GenerationStreams,
|
||||
generationCfg,
|
||||
app.Config.LLM.MaxOutputTokens,
|
||||
).WithCache(app.Cache)
|
||||
|
||||
kolWorker := generateworker.NewKolGenerationWorker(
|
||||
app.DB,
|
||||
app.LLM,
|
||||
@@ -82,6 +92,7 @@ func main() {
|
||||
app.RabbitMQ,
|
||||
templateSvc,
|
||||
promptRuleSvc,
|
||||
imitationSvc,
|
||||
kolWorker,
|
||||
app.Logger,
|
||||
app.Config.Generation,
|
||||
|
||||
@@ -208,6 +208,47 @@ runtime:
|
||||
2. 标题使用一级标题,并由 AI 根据正文内容自行生成,不要直接复用任务名称。
|
||||
3. 不要输出你的思考过程、解释或致歉。
|
||||
4. 保持客观中立,避免排名、绝对化承诺、权威背书、夸大宣传和刻意贬损同行。
|
||||
article_imitation_prompt_template: |
|
||||
你是一名资深内容策略编辑,请基于给定源文章做高质量仿写创作。
|
||||
|
||||
核心要求:
|
||||
- 保留源文章中的事实信息、论证逻辑和可复用观点,但必须重写标题、结构、表达和段落组织。
|
||||
- 不要逐句同义替换,不要复制源文的独特措辞,不要编造源文、知识库和补充要求之外的事实。
|
||||
- 标题也必须仿写改写:第一行必须是新的一级标题,需仿照源标题的信息焦点、受众意图和表达节奏,但不能直接复用源标题,也不能只做简单词语替换。
|
||||
- 如果给出了地域、品牌、目标读者或内容目标,标题和正文都要自然贴合这些上下文。
|
||||
%s
|
||||
|
||||
输出格式:Markdown。第一行必须是重写后的一级标题,正文结构清晰,可直接进入文章编辑器。
|
||||
|
||||
## 源文章
|
||||
%s
|
||||
|
||||
## 仿写设置
|
||||
%s
|
||||
|
||||
如果某项设置为空,请根据源文章和行业常识自行选择最贴合的写法。
|
||||
|
||||
%s
|
||||
|
||||
## 源文章内容
|
||||
%s
|
||||
|
||||
请开始输出仿写后的完整文章。
|
||||
article_imitation_locale_instructions:
|
||||
zh-CN: "输出语言:中文简体。"
|
||||
en-US: "Output language: English."
|
||||
article_imitation_setting_labels:
|
||||
industry: "行业/场景"
|
||||
brand_name: "品牌/主体"
|
||||
region: "地域"
|
||||
target_audience: "目标读者"
|
||||
content_goal: "内容目标"
|
||||
tone: "语气风格"
|
||||
length_goal: "篇幅要求"
|
||||
keywords: "关键词"
|
||||
preserve_points: "必须保留/强调"
|
||||
avoid_points: "需要规避"
|
||||
extra_requirements: "其他要求"
|
||||
knowledge_prompt_intro_lines:
|
||||
- "以下为可参考的知识库内容,只用于补充事实背景、术语定义、产品信息或表达素材:"
|
||||
- "- 优先吸收并改写,但地址、电话、官网、邮箱、营业时间、价格、日期,以及主营业务、经营范围、服务范围、核心产品等业务事实如需引用,必须严格依据原文,不得写成知识库里没有的业务内容。"
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"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/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"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/prompts"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
articleImitationTaskType = "imitation"
|
||||
articleImitationSourceType = "imitation"
|
||||
articleImitationFallbackTitle = "仿写文章生成中"
|
||||
maxImitationSourceContentRunes = 24000
|
||||
defaultImitationWebSearchLimit = int32(5)
|
||||
)
|
||||
|
||||
type ArticleImitationService struct {
|
||||
pool *pgxpool.Pool
|
||||
llm llm.Client
|
||||
rabbitMQ *rabbitmq.Client
|
||||
knowledge *KnowledgeService
|
||||
streams *stream.GenerationHub
|
||||
streamEnabled bool
|
||||
articleTimeout time.Duration
|
||||
maxOutputTokens int64
|
||||
cache sharedcache.Cache
|
||||
}
|
||||
|
||||
type GenerateImitationRequest struct {
|
||||
SourceURL string `json:"source_url" binding:"required"`
|
||||
SourceTitle string `json:"source_title"`
|
||||
Locale string `json:"locale"`
|
||||
Industry string `json:"industry"`
|
||||
BrandName string `json:"brand_name"`
|
||||
Region string `json:"region"`
|
||||
TargetAudience string `json:"target_audience"`
|
||||
ContentGoal string `json:"content_goal"`
|
||||
Tone string `json:"tone"`
|
||||
LengthGoal string `json:"length_goal"`
|
||||
Keywords []string `json:"keywords"`
|
||||
PreservePoints string `json:"preserve_points"`
|
||||
AvoidPoints string `json:"avoid_points"`
|
||||
ExtraRequirements string `json:"extra_requirements"`
|
||||
EnableWebSearch bool `json:"enable_web_search"`
|
||||
WebSearchLimit *int32 `json:"web_search_limit"`
|
||||
KnowledgeGroupIDs []int64 `json:"knowledge_group_ids"`
|
||||
}
|
||||
|
||||
type GenerateImitationResponse struct {
|
||||
ArticleID int64 `json:"article_id"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
}
|
||||
|
||||
type articleImitationJob struct {
|
||||
OperatorID int64
|
||||
TenantID int64
|
||||
ArticleID int64
|
||||
TaskID int64
|
||||
ReservationID int64
|
||||
InputParams map[string]interface{}
|
||||
InitialTitle string
|
||||
WebSearch llm.WebSearchOptions
|
||||
}
|
||||
|
||||
func NewArticleImitationService(
|
||||
pool *pgxpool.Pool,
|
||||
llmClient llm.Client,
|
||||
rabbitMQClient *rabbitmq.Client,
|
||||
knowledge *KnowledgeService,
|
||||
streams *stream.GenerationHub,
|
||||
cfg config.GenerationConfig,
|
||||
llmMaxOutputTokens int64,
|
||||
) *ArticleImitationService {
|
||||
articleTimeout := cfg.ArticleTimeout
|
||||
if articleTimeout <= 0 {
|
||||
articleTimeout = 8 * time.Minute
|
||||
}
|
||||
|
||||
return &ArticleImitationService{
|
||||
pool: pool,
|
||||
llm: llmClient,
|
||||
rabbitMQ: rabbitMQClient,
|
||||
knowledge: knowledge,
|
||||
streams: streams,
|
||||
streamEnabled: cfg.StreamEnabled,
|
||||
articleTimeout: articleTimeout,
|
||||
maxOutputTokens: llmMaxOutputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ArticleImitationService) WithCache(c sharedcache.Cache) *ArticleImitationService {
|
||||
s.cache = c
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ArticleImitationService) Generate(ctx context.Context, req GenerateImitationRequest) (*GenerateImitationResponse, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
if err := s.llm.Validate(); err != nil {
|
||||
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
|
||||
}
|
||||
|
||||
sourceURL, err := normalizeImitationSourceURL(req.SourceURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
inputParams := buildImitationInputParams(sourceURL, req)
|
||||
inputJSON, _ := json.Marshal(inputParams)
|
||||
initialTitle := buildImitationInitialTitle(req.SourceTitle)
|
||||
wizardStateJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"title": initialTitle,
|
||||
"source_url": sourceURL,
|
||||
"source_title": strings.TrimSpace(req.SourceTitle),
|
||||
"current_step": 0,
|
||||
})
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to begin generation transaction")
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback(ctx)
|
||||
}()
|
||||
|
||||
quotaTx := repository.NewQuotaRepository(tx)
|
||||
auditTx := repository.NewAuditRepository(tx)
|
||||
|
||||
newBalance := balance - 1
|
||||
reason := "generation_reserve"
|
||||
referenceType := "generation_task"
|
||||
_, err = quotaTx.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
QuotaType: "article_generation",
|
||||
Delta: -1,
|
||||
BalanceAfter: newBalance,
|
||||
Reason: &reason,
|
||||
ReferenceType: &referenceType,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to reserve generation quota")
|
||||
}
|
||||
|
||||
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, response.ErrInternal(50012, "create_failed", "failed to create quota reservation")
|
||||
}
|
||||
|
||||
var articleID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO articles (tenant_id, source_type, generate_status, publish_status, wizard_state_json)
|
||||
VALUES ($1, $2, 'generating', 'unpublished', $3)
|
||||
RETURNING id
|
||||
`, actor.TenantID, articleImitationSourceType, wizardStateJSON).Scan(&articleID)
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to create imitation article")
|
||||
}
|
||||
|
||||
if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to update quota reservation resource")
|
||||
}
|
||||
|
||||
taskID, err := auditTx.CreateGenerationTask(ctx, repository.GenerationTaskInput{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: &articleID,
|
||||
QuotaReservationID: &reservationID,
|
||||
TaskType: articleImitationTaskType,
|
||||
InputParamsJSON: inputJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to create generation task")
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50012, "create_failed", "failed to commit generation task")
|
||||
}
|
||||
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
||||
|
||||
job := articleImitationJob{
|
||||
OperatorID: actor.UserID,
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: articleID,
|
||||
TaskID: taskID,
|
||||
ReservationID: reservationID,
|
||||
InputParams: inputParams,
|
||||
InitialTitle: initialTitle,
|
||||
WebSearch: llm.WebSearchOptions{
|
||||
Enabled: req.EnableWebSearch,
|
||||
},
|
||||
}
|
||||
if req.WebSearchLimit != nil && *req.WebSearchLimit > 0 {
|
||||
job.WebSearch.Limit = *req.WebSearchLimit
|
||||
} else if req.EnableWebSearch {
|
||||
job.WebSearch.Limit = defaultImitationWebSearchLimit
|
||||
}
|
||||
|
||||
if err := publishArticleGenerationTask(ctx, s.rabbitMQ, articleGenerationTaskEnvelope{
|
||||
TaskID: taskID,
|
||||
TaskType: articleImitationTaskType,
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: articleID,
|
||||
}); err != nil {
|
||||
s.failGeneration(context.Background(), job, "queue_enqueue", err)
|
||||
return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry")
|
||||
}
|
||||
|
||||
if s.streamEnabled && s.streams != nil {
|
||||
s.streams.Start(articleID, taskID, initialTitle)
|
||||
}
|
||||
|
||||
return &GenerateImitationResponse{ArticleID: articleID, TaskID: taskID}, nil
|
||||
}
|
||||
|
||||
func (s *ArticleImitationService) ProcessQueuedTask(ctx context.Context, task ClaimedGenerationTask) {
|
||||
inputParams := cloneInputParams(task.InputParams)
|
||||
job := articleImitationJob{
|
||||
OperatorID: task.OperatorID,
|
||||
TenantID: task.TenantID,
|
||||
ArticleID: task.ArticleID,
|
||||
TaskID: task.ID,
|
||||
ReservationID: task.QuotaReservationID,
|
||||
InputParams: inputParams,
|
||||
InitialTitle: buildImitationInitialTitle(extractString(inputParams, "source_title")),
|
||||
WebSearch: llm.WebSearchOptions{
|
||||
Enabled: extractBool(inputParams, "enable_web_search"),
|
||||
},
|
||||
}
|
||||
if limit := extractInt(inputParams, "web_search_limit"); limit > 0 {
|
||||
job.WebSearch.Limit = int32(limit)
|
||||
} else if job.WebSearch.Enabled {
|
||||
job.WebSearch.Limit = defaultImitationWebSearchLimit
|
||||
}
|
||||
|
||||
s.executeGeneration(ctx, job)
|
||||
}
|
||||
|
||||
func (s *ArticleImitationService) HandleTaskFailure(ctx context.Context, task ClaimedGenerationTask, stage string, taskErr error) {
|
||||
job := articleImitationJob{
|
||||
OperatorID: task.OperatorID,
|
||||
TenantID: task.TenantID,
|
||||
ArticleID: task.ArticleID,
|
||||
TaskID: task.ID,
|
||||
ReservationID: task.QuotaReservationID,
|
||||
InputParams: cloneInputParams(task.InputParams),
|
||||
InitialTitle: buildImitationInitialTitle(extractString(task.InputParams, "source_title")),
|
||||
}
|
||||
s.failGeneration(ctx, job, stage, taskErr)
|
||||
}
|
||||
|
||||
func (s *ArticleImitationService) executeGeneration(ctx context.Context, job articleImitationJob) {
|
||||
now := time.Now()
|
||||
title := strings.TrimSpace(job.InitialTitle)
|
||||
if title == "" {
|
||||
title = articleImitationFallbackTitle
|
||||
}
|
||||
|
||||
auditRepo := repository.NewAuditRepository(s.pool)
|
||||
_ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||
ID: job.TaskID,
|
||||
TenantID: job.TenantID,
|
||||
Status: "running",
|
||||
StartedAt: &now,
|
||||
})
|
||||
|
||||
sourceURL := strings.TrimSpace(extractString(job.InputParams, "source_url"))
|
||||
if sourceURL == "" {
|
||||
s.failGeneration(ctx, job, "task_load", fmt.Errorf("source_url is required"))
|
||||
return
|
||||
}
|
||||
if s.knowledge == nil {
|
||||
s.failGeneration(ctx, job, "source_fetch", fmt.Errorf("knowledge service is not configured"))
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := s.knowledge.parseWebsiteKnowledge(ctx, sourceURL)
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job, "source_fetch", err)
|
||||
return
|
||||
}
|
||||
|
||||
sourceContent := truncateRunes(strings.TrimSpace(parsed.Markdown), maxImitationSourceContentRunes)
|
||||
if sourceContent == "" {
|
||||
sourceContent = truncateRunes(strings.TrimSpace(parsed.Text), maxImitationSourceContentRunes)
|
||||
}
|
||||
if sourceContent == "" {
|
||||
s.failGeneration(ctx, job, "source_fetch", fmt.Errorf("source article content is empty"))
|
||||
return
|
||||
}
|
||||
|
||||
knowledgePrompt := ""
|
||||
if knowledgeGroupIDs := extractKnowledgeGroupIDs(job.InputParams["knowledge_group_ids"]); len(knowledgeGroupIDs) > 0 {
|
||||
if s.knowledge == nil {
|
||||
s.failGeneration(ctx, job, "knowledge_resolve", fmt.Errorf("knowledge service is not configured"))
|
||||
return
|
||||
}
|
||||
knowledgeContext, resolveErr := s.knowledge.ResolveContext(
|
||||
ctx,
|
||||
job.TenantID,
|
||||
knowledgeGroupIDs,
|
||||
buildImitationKnowledgeQuery(job.InputParams),
|
||||
)
|
||||
if resolveErr != nil {
|
||||
s.failGeneration(ctx, job, "knowledge_resolve", resolveErr)
|
||||
return
|
||||
}
|
||||
knowledgePrompt = s.knowledge.RenderPromptSection(knowledgeContext)
|
||||
}
|
||||
|
||||
prompt := buildImitationGenerationPrompt(job.InputParams, sourceContent, knowledgePrompt)
|
||||
req := llm.GenerateRequest{
|
||||
Prompt: prompt,
|
||||
Timeout: s.articleTimeout,
|
||||
MaxOutputTokens: s.maxOutputTokens,
|
||||
}
|
||||
if job.WebSearch.Enabled {
|
||||
req.WebSearch = &job.WebSearch
|
||||
}
|
||||
|
||||
result, err := s.llm.Generate(ctx, req, func(delta string) {
|
||||
if s.streamEnabled && s.streams != nil && delta != "" {
|
||||
s.streams.AppendDelta(job.ArticleID, job.TaskID, title, delta)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job, "llm_generate", err)
|
||||
return
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(result.Content)
|
||||
if content == "" {
|
||||
s.failGeneration(ctx, job, "llm_generate", fmt.Errorf("模型返回空内容"))
|
||||
return
|
||||
}
|
||||
|
||||
title = resolveGeneratedImitationTitle(content, job.InputParams)
|
||||
wordCount := estimateWordCount(content)
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
s.failGeneration(ctx, job, "persist_begin_tx", err)
|
||||
return
|
||||
}
|
||||
defer rollbackGenerationTx(tx)
|
||||
|
||||
failWithRollback := func(stage string, err error) {
|
||||
rollbackGenerationTx(tx)
|
||||
s.failGeneration(ctx, job, stage, err)
|
||||
}
|
||||
|
||||
articleRepo := repository.NewArticleRepository(tx)
|
||||
quotaRepo := repository.NewQuotaRepository(tx)
|
||||
auditTx := repository.NewAuditRepository(tx)
|
||||
|
||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||
ArticleID: job.ArticleID,
|
||||
VersionNo: 1,
|
||||
Title: title,
|
||||
HTMLContent: "",
|
||||
MarkdownContent: content,
|
||||
WordCount: wordCount,
|
||||
SourceLabel: result.Model,
|
||||
})
|
||||
if err != nil {
|
||||
failWithRollback("persist_create_version", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := articleRepo.UpdateArticleCurrentVersion(ctx, job.ArticleID, job.TenantID, versionID); err != nil {
|
||||
failWithRollback("persist_current_version", err)
|
||||
return
|
||||
}
|
||||
if err := articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "completed"); err != nil {
|
||||
failWithRollback("persist_article_status", err)
|
||||
return
|
||||
}
|
||||
|
||||
completed := time.Now()
|
||||
if err := auditTx.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
||||
ID: job.TaskID,
|
||||
TenantID: job.TenantID,
|
||||
Status: "completed",
|
||||
StartedAt: &now,
|
||||
CompletedAt: &completed,
|
||||
}); err != nil {
|
||||
failWithRollback("persist_task_status", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := quotaRepo.ConfirmReservation(ctx, job.ReservationID, job.TenantID); err != nil {
|
||||
failWithRollback("confirm_reservation", err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
failWithRollback("persist_commit", err)
|
||||
return
|
||||
}
|
||||
|
||||
cleanupCtx, cancel := newGenerationCleanupContext()
|
||||
defer cancel()
|
||||
invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID)
|
||||
|
||||
if s.streamEnabled && s.streams != nil {
|
||||
s.streams.Complete(job.ArticleID, job.TaskID, title, content)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ArticleImitationService) failGeneration(ctx context.Context, job articleImitationJob, stage string, genErr error) {
|
||||
cleanupCtx, cancel := newGenerationCleanupContext()
|
||||
defer cancel()
|
||||
|
||||
errMsg := formatGenerationFailure(stage, genErr)
|
||||
now := time.Now()
|
||||
title := strings.TrimSpace(job.InitialTitle)
|
||||
if title == "" {
|
||||
title = articleImitationFallbackTitle
|
||||
}
|
||||
|
||||
auditRepo := repository.NewAuditRepository(s.pool)
|
||||
articleRepo := repository.NewArticleRepository(s.pool)
|
||||
quotaRepo := repository.NewQuotaRepository(s.pool)
|
||||
|
||||
_ = auditRepo.UpdateGenerationTaskStatus(cleanupCtx, repository.GenerationTaskStatusInput{
|
||||
ID: job.TaskID,
|
||||
TenantID: job.TenantID,
|
||||
Status: "failed",
|
||||
ErrorMessage: &errMsg,
|
||||
CompletedAt: &now,
|
||||
})
|
||||
if job.ArticleID > 0 {
|
||||
_ = articleRepo.UpdateArticleGenerateStatus(cleanupCtx, job.ArticleID, job.TenantID, "failed")
|
||||
_, _ = s.pool.Exec(cleanupCtx, `
|
||||
UPDATE articles
|
||||
SET wizard_state_json = jsonb_set(COALESCE(wizard_state_json, '{}'::jsonb), '{title}', to_jsonb($1::text), true),
|
||||
updated_at = NOW()
|
||||
WHERE id = $2 AND tenant_id = $3
|
||||
`, title, job.ArticleID, job.TenantID)
|
||||
}
|
||||
|
||||
if job.ReservationID > 0 {
|
||||
balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, job.TenantID, "article_generation")
|
||||
if balanceErr == nil {
|
||||
reason := "generation_refund"
|
||||
referenceType := "generation_task"
|
||||
taskReferenceID := job.TaskID
|
||||
_, _ = quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
|
||||
TenantID: job.TenantID,
|
||||
OperatorID: job.OperatorID,
|
||||
QuotaType: "article_generation",
|
||||
Delta: 1,
|
||||
BalanceAfter: balance + 1,
|
||||
Reason: &reason,
|
||||
ReferenceType: &referenceType,
|
||||
ReferenceID: &taskReferenceID,
|
||||
})
|
||||
}
|
||||
_ = quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID)
|
||||
}
|
||||
|
||||
if job.ArticleID > 0 {
|
||||
invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID)
|
||||
}
|
||||
|
||||
if s.streamEnabled && s.streams != nil && job.ArticleID > 0 {
|
||||
s.streams.Fail(job.ArticleID, job.TaskID, title, errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeImitationSourceURL(raw string) (string, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", response.ErrBadRequest(40001, "source_url_required", "source_url is required")
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", response.ErrBadRequest(40001, "invalid_source_url", "source_url must be a valid URL")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", response.ErrBadRequest(40001, "invalid_source_url", "source_url must use http or https")
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func buildImitationInputParams(sourceURL string, req GenerateImitationRequest) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"source_url": sourceURL,
|
||||
"source_title": strings.TrimSpace(req.SourceTitle),
|
||||
"locale": normalizeImitationLocale(req.Locale),
|
||||
"industry": strings.TrimSpace(req.Industry),
|
||||
"brand_name": strings.TrimSpace(req.BrandName),
|
||||
"region": strings.TrimSpace(req.Region),
|
||||
"target_audience": strings.TrimSpace(req.TargetAudience),
|
||||
"content_goal": strings.TrimSpace(req.ContentGoal),
|
||||
"tone": strings.TrimSpace(req.Tone),
|
||||
"length_goal": strings.TrimSpace(req.LengthGoal),
|
||||
"keywords": normalizeImitationKeywords(req.Keywords),
|
||||
"preserve_points": strings.TrimSpace(req.PreservePoints),
|
||||
"avoid_points": strings.TrimSpace(req.AvoidPoints),
|
||||
"extra_requirements": strings.TrimSpace(req.ExtraRequirements),
|
||||
"enable_web_search": req.EnableWebSearch,
|
||||
"knowledge_group_ids": normalizeInt64IDs(req.KnowledgeGroupIDs),
|
||||
}
|
||||
if req.WebSearchLimit != nil && *req.WebSearchLimit > 0 {
|
||||
params["web_search_limit"] = *req.WebSearchLimit
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func buildImitationInitialTitle(sourceTitle string) string {
|
||||
title := strings.TrimSpace(sourceTitle)
|
||||
if title == "" {
|
||||
return articleImitationFallbackTitle
|
||||
}
|
||||
return "仿写:" + title
|
||||
}
|
||||
|
||||
func buildImitationGenerationPrompt(params map[string]interface{}, sourceContent string, knowledgePrompt string) string {
|
||||
locale := normalizeImitationLocale(extractString(params, "locale"))
|
||||
sourceTitle := strings.TrimSpace(extractString(params, "source_title"))
|
||||
sourceURL := strings.TrimSpace(extractString(params, "source_url"))
|
||||
keywords := extractStringList(params["keywords"], 16)
|
||||
knowledgePrompt = strings.TrimSpace(knowledgePrompt)
|
||||
|
||||
var sourceMeta strings.Builder
|
||||
if sourceTitle != "" {
|
||||
appendRawPromptLine(&sourceMeta, "标题", sourceTitle)
|
||||
}
|
||||
if sourceURL != "" {
|
||||
appendRawPromptLine(&sourceMeta, "URL", sourceURL)
|
||||
}
|
||||
|
||||
var settings strings.Builder
|
||||
appendPromptLine(&settings, "industry", extractString(params, "industry"))
|
||||
appendPromptLine(&settings, "brand_name", extractString(params, "brand_name"))
|
||||
appendPromptLine(&settings, "region", extractString(params, "region"))
|
||||
appendPromptLine(&settings, "target_audience", extractString(params, "target_audience"))
|
||||
appendPromptLine(&settings, "content_goal", extractString(params, "content_goal"))
|
||||
appendPromptLine(&settings, "tone", extractString(params, "tone"))
|
||||
appendPromptLine(&settings, "length_goal", extractString(params, "length_goal"))
|
||||
if len(keywords) > 0 {
|
||||
appendPromptLine(&settings, "keywords", strings.Join(keywords, "、"))
|
||||
}
|
||||
appendPromptLine(&settings, "preserve_points", extractString(params, "preserve_points"))
|
||||
appendPromptLine(&settings, "avoid_points", extractString(params, "avoid_points"))
|
||||
appendPromptLine(&settings, "extra_requirements", extractString(params, "extra_requirements"))
|
||||
|
||||
knowledgeSection := ""
|
||||
if knowledgePrompt != "" {
|
||||
knowledgeSection = "## 知识库参考\n" + knowledgePrompt
|
||||
}
|
||||
|
||||
return prompts.ArticleImitationPrompt(
|
||||
locale,
|
||||
sourceMeta.String(),
|
||||
settings.String(),
|
||||
knowledgeSection,
|
||||
sourceContent,
|
||||
)
|
||||
}
|
||||
|
||||
func buildImitationKnowledgeQuery(params map[string]interface{}) string {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := make([]string, 0, 8)
|
||||
appendValue := func(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
parts = append(parts, value)
|
||||
}
|
||||
|
||||
appendValue(extractString(params, "source_title"))
|
||||
appendValue(extractString(params, "industry"))
|
||||
appendValue(extractString(params, "brand_name"))
|
||||
appendValue(extractString(params, "region"))
|
||||
appendValue(extractString(params, "target_audience"))
|
||||
appendValue(extractString(params, "content_goal"))
|
||||
appendValue(extractString(params, "tone"))
|
||||
appendValue(strings.Join(extractStringList(params["keywords"], 16), "\n"))
|
||||
appendValue(extractString(params, "extra_requirements"))
|
||||
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func appendPromptLine(b *strings.Builder, label, value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString("- " + prompts.ArticleImitationSettingLabel(label) + ":" + value + "\n")
|
||||
}
|
||||
|
||||
func appendRawPromptLine(b *strings.Builder, label, value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString("- " + label + ":" + value + "\n")
|
||||
}
|
||||
|
||||
func resolveGeneratedImitationTitle(markdown string, params map[string]interface{}) string {
|
||||
for _, line := range strings.Split(markdown, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "#") {
|
||||
title := strings.TrimSpace(strings.TrimLeft(line, "#"))
|
||||
if title != "" {
|
||||
return title
|
||||
}
|
||||
}
|
||||
}
|
||||
return buildImitationInitialTitle(extractString(params, "source_title"))
|
||||
}
|
||||
|
||||
func normalizeImitationLocale(value string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(value), "en-US") {
|
||||
return "en-US"
|
||||
}
|
||||
return "zh-CN"
|
||||
}
|
||||
|
||||
func normalizeImitationKeywords(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
keywords := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(item)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
keywords = append(keywords, item)
|
||||
if len(keywords) >= 16 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
|
||||
func truncateRunes(value string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxRunes {
|
||||
return value
|
||||
}
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
@@ -94,6 +94,8 @@ type ArticleListItem struct {
|
||||
GenerationErrorMessage *string `json:"generation_error_message"`
|
||||
WordCount int `json:"word_count"`
|
||||
SourceLabel *string `json:"source_label"`
|
||||
SourceURL *string `json:"source_url,omitempty"`
|
||||
SourceTitle *string `json:"source_title,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -127,6 +129,8 @@ type ArticleDetailResponse struct {
|
||||
MarkdownContent *string `json:"markdown_content"`
|
||||
WordCount int `json:"word_count"`
|
||||
SourceLabel *string `json:"source_label"`
|
||||
SourceURL *string `json:"source_url,omitempty"`
|
||||
SourceTitle *string `json:"source_title,omitempty"`
|
||||
VersionNo *int `json:"version_no"`
|
||||
GenerationErrorMessage *string `json:"generation_error_message"`
|
||||
WizardState map[string]interface{} `json:"wizard_state,omitempty"`
|
||||
@@ -205,7 +209,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
||||
argIdx++
|
||||
}
|
||||
if params.Keyword != nil {
|
||||
countQuery += ` AND COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'`
|
||||
countQuery += ` AND (
|
||||
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'
|
||||
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_title', ''), NULLIF(gt.input_params_json ->> 'source_title', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'
|
||||
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_url', ''), NULLIF(gt.input_params_json ->> 'source_url', '')) ILIKE '%' || $` + strconv.Itoa(argIdx) + ` || '%'
|
||||
)`
|
||||
countArgs = append(countArgs, *params.Keyword)
|
||||
argIdx++
|
||||
}
|
||||
@@ -274,7 +282,11 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
||||
listIdx++
|
||||
}
|
||||
if params.Keyword != nil {
|
||||
listQuery += ` AND COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'`
|
||||
listQuery += ` AND (
|
||||
COALESCE(av.title, NULLIF(a.wizard_state_json ->> 'title', ''), NULLIF(gt.input_params_json ->> 'title', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'
|
||||
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_title', ''), NULLIF(gt.input_params_json ->> 'source_title', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'
|
||||
OR COALESCE(NULLIF(a.wizard_state_json ->> 'source_url', ''), NULLIF(gt.input_params_json ->> 'source_url', '')) ILIKE '%' || $` + strconv.Itoa(listIdx) + ` || '%'
|
||||
)`
|
||||
listArgs = append(listArgs, *params.Keyword)
|
||||
listIdx++
|
||||
}
|
||||
@@ -311,6 +323,7 @@ func (s *ArticleService) loadArticles(ctx context.Context, tenantID int64, param
|
||||
item.SourceType = dbSourceType
|
||||
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
|
||||
items = append(items, item)
|
||||
}
|
||||
return &ArticleListResponse{Items: items, Total: total, Page: params.Page, PageSize: params.PageSize}, nil
|
||||
@@ -361,6 +374,7 @@ func (s *ArticleService) loadArticleDetail(ctx context.Context, tenantID, articl
|
||||
}
|
||||
item.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||
item.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||
item.SourceURL, item.SourceTitle = resolveArticleImitationSourceInfo(dbSourceType, wizardStateJSON, inputParamsJSON)
|
||||
item.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
||||
item.CoverImageAssetID = resolveArticleCoverImageAssetID(wizardStateJSON)
|
||||
if item.MarkdownContent != nil || item.CoverImageAssetID != nil {
|
||||
@@ -1080,6 +1094,24 @@ func resolveArticleGenerationMode(sourceType string, inputParamsJSON []byte) *st
|
||||
return &mode
|
||||
}
|
||||
|
||||
func resolveArticleImitationSourceInfo(sourceType string, wizardStateJSON, inputParamsJSON []byte) (*string, *string) {
|
||||
if sourceType != articleImitationSourceType {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sourceURL := strings.TrimSpace(extractStringFromJSONPayload(wizardStateJSON, "source_url"))
|
||||
if sourceURL == "" {
|
||||
sourceURL = strings.TrimSpace(extractStringFromJSONPayload(inputParamsJSON, "source_url"))
|
||||
}
|
||||
|
||||
sourceTitle := strings.TrimSpace(extractStringFromJSONPayload(wizardStateJSON, "source_title"))
|
||||
if sourceTitle == "" {
|
||||
sourceTitle = strings.TrimSpace(extractStringFromJSONPayload(inputParamsJSON, "source_title"))
|
||||
}
|
||||
|
||||
return nilIfEmptyString(sourceURL), nilIfEmptyString(sourceTitle)
|
||||
}
|
||||
|
||||
func nilIfEmptyString(value string) *string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
|
||||
@@ -241,7 +241,7 @@ func (s *TemplateService) SaveDraft(ctx context.Context, templateID int64, req S
|
||||
AND template_id = $4
|
||||
AND source_type = 'template'
|
||||
AND deleted_at IS NULL
|
||||
AND generate_status IN ('draft', 'failed')
|
||||
AND generate_status = 'draft'
|
||||
RETURNING id
|
||||
`, stateJSON, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
|
||||
if err != nil {
|
||||
@@ -333,7 +333,7 @@ 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 IN ('draft', 'failed')
|
||||
AND generate_status = 'draft'
|
||||
FOR UPDATE
|
||||
`, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
|
||||
if err != nil {
|
||||
@@ -630,6 +630,8 @@ func generationFailureStageLabel(stage string) string {
|
||||
return "AI 正文生成失败"
|
||||
case "knowledge_resolve":
|
||||
return "知识库检索失败"
|
||||
case "source_fetch":
|
||||
return "源文章抓取失败"
|
||||
case "persist_begin_tx":
|
||||
return "写入生成结果事务启动失败"
|
||||
case "persist_create_version":
|
||||
|
||||
@@ -48,6 +48,9 @@ type runtimePromptsConfig struct {
|
||||
PromptRuleTargetPlatformSupplementFormat string `yaml:"prompt_rule_target_platform_supplement_format"`
|
||||
PromptRuleSupplementHeading string `yaml:"prompt_rule_supplement_heading"`
|
||||
PromptRuleOutputRequirementsSection string `yaml:"prompt_rule_output_requirements_section"`
|
||||
ArticleImitationPromptTemplate string `yaml:"article_imitation_prompt_template"`
|
||||
ArticleImitationLocaleInstructions map[string]string `yaml:"article_imitation_locale_instructions"`
|
||||
ArticleImitationSettingLabels map[string]string `yaml:"article_imitation_setting_labels"`
|
||||
KnowledgePromptIntroLines []string `yaml:"knowledge_prompt_intro_lines"`
|
||||
KnowledgeSnippetLabelFormat string `yaml:"knowledge_snippet_label_format"`
|
||||
KnowledgeWebsiteMarkdown knowledgeWebsiteMarkdownConfig `yaml:"knowledge_website_markdown"`
|
||||
|
||||
@@ -136,6 +136,55 @@ func PromptRuleOutputRequirementsSection() string {
|
||||
return trimPrompt(loadRuntimePrompts().PromptRuleOutputRequirementsSection)
|
||||
}
|
||||
|
||||
func ArticleImitationPrompt(locale, sourceMeta, settingsBlock, knowledgeSection, sourceContent string) string {
|
||||
runtime := loadRuntimePrompts()
|
||||
template := trimPrompt(runtime.ArticleImitationPromptTemplate)
|
||||
if template == "" {
|
||||
template = `你是一名资深内容策略编辑,请基于给定源文章做高质量仿写创作。
|
||||
%s
|
||||
|
||||
## 源文章
|
||||
%s
|
||||
|
||||
## 仿写设置
|
||||
%s
|
||||
|
||||
%s
|
||||
|
||||
## 源文章内容
|
||||
%s
|
||||
|
||||
请开始输出仿写后的完整文章。`
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf(
|
||||
template,
|
||||
ArticleImitationLocaleInstruction(locale),
|
||||
strings.TrimSpace(sourceMeta),
|
||||
strings.TrimSpace(settingsBlock),
|
||||
strings.TrimSpace(knowledgeSection),
|
||||
strings.TrimSpace(sourceContent),
|
||||
))
|
||||
}
|
||||
|
||||
func ArticleImitationLocaleInstruction(locale string) string {
|
||||
instructions := loadRuntimePrompts().ArticleImitationLocaleInstructions
|
||||
if value, ok := instructions[strings.TrimSpace(locale)]; ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(locale), "en-US") {
|
||||
return "Output language: English."
|
||||
}
|
||||
return "输出语言:中文简体。"
|
||||
}
|
||||
|
||||
func ArticleImitationSettingLabel(key string) string {
|
||||
labels := loadRuntimePrompts().ArticleImitationSettingLabels
|
||||
if value, ok := labels[strings.TrimSpace(key)]; ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
return strings.TrimSpace(key)
|
||||
}
|
||||
|
||||
func KnowledgePromptIntroLines() []string {
|
||||
lines := loadRuntimePrompts().KnowledgePromptIntroLines
|
||||
return append([]string(nil), lines...)
|
||||
|
||||
@@ -22,6 +22,7 @@ type ArticleHandler struct {
|
||||
svc *app.ArticleService
|
||||
selectionOptimize *app.ArticleSelectionOptimizeService
|
||||
promptGenerateSvc *app.PromptRuleGenerationService
|
||||
imitationSvc *app.ArticleImitationService
|
||||
streams *stream.GenerationHub
|
||||
streamEnabled bool
|
||||
assets *AssetHandler
|
||||
@@ -57,6 +58,15 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
|
||||
a.Config.Generation,
|
||||
a.Config.LLM.MaxOutputTokens,
|
||||
).WithCache(a.Cache),
|
||||
imitationSvc: app.NewArticleImitationService(
|
||||
a.DB,
|
||||
a.LLM,
|
||||
a.RabbitMQ,
|
||||
knowledgeSvc,
|
||||
a.GenerationStreams,
|
||||
a.Config.Generation,
|
||||
a.Config.LLM.MaxOutputTokens,
|
||||
).WithCache(a.Cache),
|
||||
streams: a.GenerationStreams,
|
||||
streamEnabled: a.Config.Generation.StreamEnabled,
|
||||
assets: NewAssetHandler(a),
|
||||
@@ -162,6 +172,22 @@ func (h *ArticleHandler) GenerateFromRule(c *gin.Context) {
|
||||
response.SuccessWithStatus(c, http.StatusCreated, data)
|
||||
}
|
||||
|
||||
func (h *ArticleHandler) GenerateImitation(c *gin.Context) {
|
||||
var req app.GenerateImitationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.imitationSvc.Generate(c.Request.Context(), req)
|
||||
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 {
|
||||
|
||||
@@ -149,6 +149,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
articles.GET("", artHandler.List)
|
||||
articles.POST("", artHandler.Create)
|
||||
articles.POST("/generate-from-rule", artHandler.GenerateFromRule)
|
||||
articles.POST("/imitations/generate", artHandler.GenerateImitation)
|
||||
articles.POST("/:id/images", artHandler.UploadImage)
|
||||
articles.POST("/:id/selection-optimize/stream", artHandler.OptimizeSelectionStream)
|
||||
articles.GET("/:id", artHandler.Detail)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -28,6 +29,7 @@ type ArticleGenerationWorker struct {
|
||||
rabbitMQ *rabbitmq.Client
|
||||
templateService *tenantapp.TemplateService
|
||||
promptRuleService *tenantapp.PromptRuleGenerationService
|
||||
imitationService *tenantapp.ArticleImitationService
|
||||
kolWorker *KolGenerationWorker
|
||||
logger *zap.Logger
|
||||
retryInterval time.Duration
|
||||
@@ -41,6 +43,7 @@ func NewArticleGenerationWorker(
|
||||
rabbitMQClient *rabbitmq.Client,
|
||||
templateService *tenantapp.TemplateService,
|
||||
promptRuleService *tenantapp.PromptRuleGenerationService,
|
||||
imitationService *tenantapp.ArticleImitationService,
|
||||
kolWorker *KolGenerationWorker,
|
||||
logger *zap.Logger,
|
||||
cfg config.GenerationConfig,
|
||||
@@ -60,6 +63,7 @@ func NewArticleGenerationWorker(
|
||||
rabbitMQ: rabbitMQClient,
|
||||
templateService: templateService,
|
||||
promptRuleService: promptRuleService,
|
||||
imitationService: imitationService,
|
||||
kolWorker: kolWorker,
|
||||
logger: logger,
|
||||
retryInterval: defaultArticleGenerationWorkerRetryInterval,
|
||||
@@ -145,11 +149,7 @@ func (w *ArticleGenerationWorker) handleDelivery(parent context.Context, deliver
|
||||
return
|
||||
}
|
||||
if task.ID > 0 {
|
||||
if task.TaskType == kolGenerationTaskType && w.kolWorker != nil {
|
||||
w.kolWorker.HandleTaskFailure(ctx, task, "task_load", err)
|
||||
} else {
|
||||
_ = tenantapp.HandleClaimedGenerationTaskFailure(ctx, w.pool, w.templateService, w.promptRuleService, task, err)
|
||||
}
|
||||
w.handleTaskFailure(ctx, task, "task_load", err)
|
||||
if ackErr := delivery.Ack(false); ackErr != nil && w.logger != nil {
|
||||
w.logger.Warn("article generation ack failed after task load failure",
|
||||
zap.Error(ackErr),
|
||||
@@ -172,25 +172,28 @@ func (w *ArticleGenerationWorker) handleDelivery(parent context.Context, deliver
|
||||
return
|
||||
}
|
||||
|
||||
task.TaskType = strings.TrimSpace(task.TaskType)
|
||||
switch {
|
||||
case task.ArticleID <= 0 || task.QuotaReservationID <= 0:
|
||||
if task.TaskType == kolGenerationTaskType && w.kolWorker != nil {
|
||||
w.kolWorker.HandleTaskFailure(ctx, task, "task_load", fmt.Errorf("generation task references are incomplete"))
|
||||
} else {
|
||||
_ = tenantapp.HandleClaimedGenerationTaskFailure(ctx, w.pool, w.templateService, w.promptRuleService, task, fmt.Errorf("generation task references are incomplete"))
|
||||
}
|
||||
w.handleTaskFailure(ctx, task, "task_load", fmt.Errorf("generation task references are incomplete"))
|
||||
case task.TaskType == "template":
|
||||
w.templateService.ProcessQueuedTask(ctx, task)
|
||||
case task.TaskType == "custom_generation":
|
||||
w.promptRuleService.ProcessQueuedTask(ctx, task)
|
||||
case task.TaskType == "imitation":
|
||||
if w.imitationService != nil {
|
||||
w.imitationService.ProcessQueuedTask(ctx, task)
|
||||
} else {
|
||||
w.handleTaskFailure(ctx, task, "task_load", fmt.Errorf("imitation generation worker is not configured"))
|
||||
}
|
||||
case task.TaskType == kolGenerationTaskType:
|
||||
if w.kolWorker != nil {
|
||||
w.kolWorker.Process(ctx, task)
|
||||
} else {
|
||||
_ = tenantapp.HandleClaimedGenerationTaskFailure(ctx, w.pool, w.templateService, w.promptRuleService, task, fmt.Errorf("kol generation worker is not configured"))
|
||||
w.handleTaskFailure(ctx, task, "task_load", fmt.Errorf("kol generation worker is not configured"))
|
||||
}
|
||||
default:
|
||||
_ = tenantapp.HandleClaimedGenerationTaskFailure(ctx, w.pool, w.templateService, w.promptRuleService, task, fmt.Errorf("unsupported generation task type: %s", task.TaskType))
|
||||
w.handleTaskFailure(ctx, task, "task_load", fmt.Errorf("unsupported generation task type: %s", task.TaskType))
|
||||
}
|
||||
|
||||
if err := delivery.Ack(false); err != nil && w.logger != nil {
|
||||
@@ -202,6 +205,18 @@ func (w *ArticleGenerationWorker) handleDelivery(parent context.Context, deliver
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ArticleGenerationWorker) handleTaskFailure(ctx context.Context, task tenantapp.ClaimedGenerationTask, stage string, taskErr error) {
|
||||
task.TaskType = strings.TrimSpace(task.TaskType)
|
||||
switch {
|
||||
case task.TaskType == kolGenerationTaskType && w.kolWorker != nil:
|
||||
w.kolWorker.HandleTaskFailure(ctx, task, stage, taskErr)
|
||||
case task.TaskType == "imitation" && w.imitationService != nil:
|
||||
w.imitationService.HandleTaskFailure(ctx, task, stage, taskErr)
|
||||
default:
|
||||
_ = tenantapp.HandleClaimedGenerationTaskFailure(ctx, w.pool, w.templateService, w.promptRuleService, task, taskErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *ArticleGenerationWorker) loadTask(ctx context.Context, taskID int64) (tenantapp.ClaimedGenerationTask, bool, error) {
|
||||
var (
|
||||
task tenantapp.ClaimedGenerationTask
|
||||
|
||||
Reference in New Issue
Block a user