Files
geo/server/internal/tenant/app/article_imitation_service.go
T
root 00eb514efc
Deployment Config CI / Deployment Config (push) Successful in 25s
Frontend CI / Frontend (push) Successful in 2m51s
Backend CI / Backend (push) Successful in 14m47s
feat(imitation): simplify form fields and make brand_name required
Remove industry, target_audience, content_goal, tone, and length_goal from the imitation form; brand_name is now mandatory with autocomplete from brand library, keywords are pre-populated from the selected brand's search keywords, and output length is hardcoded to ~2000 characters in the prompt.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-20 02:16:45 +08:00

745 lines
24 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"`
BrandName string `json:"brand_name"`
Region string `json:"region"`
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
}
if strings.TrimSpace(req.BrandName) == "" {
return nil, response.ErrBadRequest(40001, "brand_name_required", "brand_name is required")
}
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,
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")
refunded, err := quotaRepo.RefundPendingReservation(cleanupCtx, job.ReservationID, job.TenantID)
if err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("refund generation reservation: %w", err))
} else if refunded {
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 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),
"brand_name": strings.TrimSpace(req.BrandName),
"region": strings.TrimSpace(req.Region),
"length_goal": "2000字左右",
"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, "brand_name", extractString(params, "brand_name"))
appendPromptLine(&settings, "region", extractString(params, "region"))
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, "brand_name"))
appendValue(extractString(params, "region"))
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])
}