Files
geo/server/internal/tenant/app/article_imitation_service.go
T
root 65e9b7e293
Deployment Config CI / Deployment Config (push) Successful in 24s
Backend CI / Backend (push) Successful in 14m12s
harden article generation reliability
2026-05-05 23:43:10 +08:00

758 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
"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
cfg generationRuntimeConfig
configProvider runtimeConfigProvider
cache sharedcache.Cache
logger *zap.Logger
}
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 {
return &ArticleImitationService{
pool: pool,
llm: llmClient,
rabbitMQ: rabbitMQClient,
knowledge: knowledge,
streams: streams,
cfg: generationRuntimeFromConfig(cfg, config.LLMConfig{MaxOutputTokens: llmMaxOutputTokens}),
}
}
func (s *ArticleImitationService) WithCache(c sharedcache.Cache) *ArticleImitationService {
s.cache = c
return s
}
func (s *ArticleImitationService) WithConfigProvider(provider runtimeConfigProvider) *ArticleImitationService {
s.configProvider = provider
return s
}
func (s *ArticleImitationService) WithLogger(logger *zap.Logger) *ArticleImitationService {
s.logger = logger
return s
}
func (s *ArticleImitationService) 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
}
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 {
RecordGenerationTaskEvent(GenerationTaskEventQueueFailure, articleImitationJobLogContext(job, "queue_enqueue", "queued", "failed", GenerationTaskResultFailure))
s.failGeneration(context.Background(), job, "queue_enqueue", 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, 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)
if err := auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
ID: job.TaskID,
TenantID: job.TenantID,
Status: "running",
StartedAt: &now,
}); err != nil {
s.failGeneration(ctx, job, "task_status_running", err)
return
}
RecordGenerationTaskEvent(GenerationTaskEventTransition, articleImitationJobLogContext(job, "task_status_running", "", GenerationTaskStatusRunning, GenerationTaskResultSuccess))
LogGenerationTaskInfo(s.logger, "article generation task marked running", articleImitationJobLogContext(job, "task_status_running", "", GenerationTaskStatusRunning, GenerationTaskResultSuccess))
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)
runtimeCfg := s.runtimeConfig()
req := llm.GenerateRequest{
Prompt: prompt,
Timeout: runtimeCfg.ArticleTimeout,
MaxOutputTokens: runtimeCfg.MaxOutputTokens,
}
if job.WebSearch.Enabled {
req.WebSearch = &job.WebSearch
}
result, err := s.llm.Generate(ctx, req, func(delta string) {
if runtimeCfg.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 runtimeCfg.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()
logCtx := articleImitationJobLogContext(job, stage, "", "failed", GenerationTaskResultFailure)
RecordGenerationTaskEvent(GenerationTaskEventFailure, logCtx)
LogGenerationTaskWarn(s.logger, "imitation generation failed", logCtx, zap.Error(genErr))
title := strings.TrimSpace(job.InitialTitle)
if title == "" {
title = articleImitationFallbackTitle
}
auditRepo := repository.NewAuditRepository(s.pool)
articleRepo := repository.NewArticleRepository(s.pool)
quotaRepo := repository.NewQuotaRepository(s.pool)
var cleanupErr error
if err := auditRepo.UpdateGenerationTaskStatus(cleanupCtx, repository.GenerationTaskStatusInput{
ID: job.TaskID,
TenantID: job.TenantID,
Status: "failed",
ErrorMessage: &errMsg,
CompletedAt: &now,
}); err != nil && s.logger != nil {
s.logger.Warn("imitation generation task failed status update failed",
zap.Error(err),
zap.Int64("task_id", job.TaskID),
zap.Int64("article_id", job.ArticleID),
zap.String("stage", stage),
)
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("update generation task failed status: %w", err))
}
if job.ArticleID > 0 {
if err := articleRepo.UpdateArticleGenerateStatus(cleanupCtx, job.ArticleID, job.TenantID, "failed"); err != nil && s.logger != nil {
s.logger.Warn("imitation generation article failed status update failed",
zap.Error(err),
zap.Int64("task_id", job.TaskID),
zap.Int64("article_id", job.ArticleID),
zap.String("stage", stage),
)
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("update article failed status: %w", err))
}
_, _ = 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 {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("load quota balance for refund: %w", balanceErr))
} else {
reason := "generation_refund"
referenceType := "generation_task"
taskReferenceID := job.TaskID
if _, err := 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,
}); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("insert generation refund ledger: %w", err))
}
}
if err := quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("refund generation reservation: %w", err))
}
}
if job.ArticleID > 0 {
invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID)
}
if cleanupErr != nil {
RecordGenerationTaskEvent(GenerationTaskEventCleanupFailure, logCtx)
LogGenerationTaskError(s.logger, "imitation generation failure cleanup failed", logCtx, zap.Error(cleanupErr))
}
runtimeCfg := s.runtimeConfig()
if runtimeCfg.StreamEnabled && s.streams != nil && job.ArticleID > 0 {
s.streams.Fail(job.ArticleID, job.TaskID, title, errMsg)
}
}
func articleImitationJobLogContext(job articleImitationJob, stage, statusBefore, statusAfter, result string) GenerationTaskLogContext {
return GenerationTaskLogContext{
TaskID: job.TaskID,
TenantID: job.TenantID,
OperatorID: job.OperatorID,
ArticleID: job.ArticleID,
ReservationID: job.ReservationID,
TaskType: articleImitationTaskType,
Stage: stage,
StatusBefore: statusBefore,
StatusAfter: statusAfter,
Result: result,
}
}
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])
}