Files
geo/server/internal/tenant/app/article_imitation_service.go
T
root ba2f117265 feat(knowledge): multi-query retrieval with LLM query rewrite
Add a shared structured query builder (knowledge_query.go) and switch
all generation paths to it, so retrieval queries carry task intent
(type, brand, region, keywords, key points) instead of a raw prompt.

ResolveContext now rewrites the query into multiple sub-questions via
the URL-markdown model, embeds and searches each, and merges/dedupes
candidates before rerank; falls back to parsed fallback questions when
rewrite is unavailable or returns too few. Resolve logs gain query
list, count, and rewrite stage/model for observability.

- knowledge_query.go/_test.go: BuildKnowledgeQuery + intent normalization
- knowledge_service.go: per-query embed/search loop, query rewrite, logs
- template_prompt/template_service/prompt_generate/article_imitation/
  kol_generation_worker: adopt the shared query builder
- generation_observability: richer task error logging
2026-06-02 14:50:12 +08:00

746 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"`
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)
brandID, err := requireCurrentBrandID(ctx)
if err != nil {
return nil, err
}
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)
inputParams["brand_id"] = brandID
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, brand_id, source_type, generate_status, publish_status, wizard_state_json)
VALUES ($1, $2, $3, 'generating', 'unpublished', $4)
RETURNING id
`, actor.TenantID, brandID, 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 := sanitizeGeneratedArticleMarkdown(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, GenerationTaskErrorFields(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 ""
}
intent := knowledgeQueryIntent{
TaskType: "仿写文章",
Title: extractString(params, "source_title"),
Topic: firstNonEmptyText(extractString(params, "source_title"), extractString(params, "extra_requirements")),
BrandName: extractString(params, "brand_name"),
Region: extractString(params, "region"),
PrimaryKeyword: firstNonEmptyText(extractString(params, "source_title"), knowledgeQueryKeywordText(extractStringList(params["keywords"], 16))),
Keywords: mergeKnowledgeQueryKeywords(params["keywords"]),
KeyPoints: extractString(params, "preserve_points"),
Extra: strings.Join([]string{extractString(params, "avoid_points"), extractString(params, "extra_requirements")}, "\n"),
}
return buildKnowledgeQueryInputText(intent)
}
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])
}