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:
2026-04-25 21:36:44 +08:00
parent 46d0a7aea0
commit 40d9a6cc63
29 changed files with 2678 additions and 120 deletions
+11
View File
@@ -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,
+41
View File
@@ -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])
}
+34 -2
View File
@@ -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":
+3
View File
@@ -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"`
+49
View File
@@ -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