package app import ( "context" "fmt" "strings" "time" "unicode/utf8" "github.com/jackc/pgx/v5" "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/llm" "github.com/geo-platform/tenant-api/internal/shared/response" ) const ( maxOptimizeInstructionChars = 800 maxOptimizeMarkdownContextLen = 6000 minOptimizeSelectedTextRunes = 2 optimizeSelectionTimeout = 90 * time.Second ) type ArticleSelectionOptimizeRequest struct { Title string `json:"title"` MarkdownContent string `json:"markdown_content"` SelectedText string `json:"selected_text"` Instruction string `json:"instruction"` } type ArticleSelectionOptimizeResult struct { Content string `json:"content"` Model string `json:"model"` } type ArticleSelectionOptimizeService struct { pool *pgxpool.Pool llm llm.Client defaultMaxOutTokens int64 configProvider runtimeConfigProvider cache sharedcache.Cache } func NewArticleSelectionOptimizeService( pool *pgxpool.Pool, llmClient llm.Client, defaultMaxOutTokens int64, ) *ArticleSelectionOptimizeService { return &ArticleSelectionOptimizeService{ pool: pool, llm: llmClient, defaultMaxOutTokens: defaultMaxOutTokens, } } func (s *ArticleSelectionOptimizeService) WithCache(c sharedcache.Cache) *ArticleSelectionOptimizeService { s.cache = c return s } func (s *ArticleSelectionOptimizeService) WithConfigProvider(provider runtimeConfigProvider) *ArticleSelectionOptimizeService { s.configProvider = provider return s } func (s *ArticleSelectionOptimizeService) ValidateRequest( ctx context.Context, articleID int64, req ArticleSelectionOptimizeRequest, ) error { if err := s.validateLLM(); err != nil { return err } if err := validateOptimizeSelectionPayload(req); err != nil { return err } actor := auth.MustActor(ctx) brandID, err := requireCurrentBrandID(ctx) if err != nil { return err } return s.ensureArticleEditable(ctx, articleID, actor.TenantID, brandID) } func (s *ArticleSelectionOptimizeService) OptimizeSelection( ctx context.Context, articleID int64, req ArticleSelectionOptimizeRequest, onDelta func(string), ) (*ArticleSelectionOptimizeResult, error) { actor := auth.MustActor(ctx) resourceType := "article" reservation, err := ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{ TenantID: actor.TenantID, OperatorID: actor.UserID, UsageType: AIUsageTypeArticleSelectionOptimize, ResourceType: &resourceType, ResourceID: &articleID, MeteredText: req.SelectedText, Metadata: map[string]any{ "article_id": articleID, "instruction": strings.TrimSpace(req.Instruction), "title_length": countAIPointChars(req.Title), }, }) if err != nil { return nil, err } result, err := s.llm.Generate( ctx, llm.GenerateRequest{ Prompt: buildOptimizeSelectionPrompt(req), Timeout: optimizeSelectionTimeout, MaxOutputTokens: s.resolveMaxOutputTokens(req.SelectedText), }, onDelta, ) if err != nil { refundCtx, cancel := newGenerationCleanupContext() _ = RefundAIPoints(refundCtx, s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, err.Error()) cancel() return nil, response.ErrInternal(50019, "article_selection_optimize_failed", err.Error()) } content := strings.TrimSpace(result.Content) if content == "" { refundCtx, cancel := newGenerationCleanupContext() _ = RefundAIPoints(refundCtx, s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, "optimized content is empty") cancel() return nil, response.ErrInternal(50019, "article_selection_optimize_failed", "optimized content is empty") } completeCtx, cancel := newGenerationCleanupContext() if err := CompleteAIPoints(completeCtx, s.pool, actor.TenantID, *reservation, result.Model); err != nil { cancel() return nil, response.ErrInternal(50019, "article_selection_optimize_failed", "failed to confirm ai points usage") } cancel() return &ArticleSelectionOptimizeResult{ Content: content, Model: result.Model, }, nil } func (s *ArticleSelectionOptimizeService) validateLLM() error { if err := s.llm.Validate(); err != nil { return response.ErrServiceUnavailable(50304, "llm_unavailable", "llm provider is not configured") } return nil } func (s *ArticleSelectionOptimizeService) ensureArticleEditable( ctx context.Context, articleID int64, tenantID int64, brandID int64, ) error { var generateStatus string err := s.pool.QueryRow( ctx, `SELECT generate_status FROM articles WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL`, articleID, tenantID, brandID, ).Scan(&generateStatus) if err != nil { if err == pgx.ErrNoRows { return response.ErrNotFound(40411, "article_not_found", "article not found") } return response.ErrInternal(50020, "article_lookup_failed", "failed to query article state") } if generateStatus != "completed" { return response.ErrConflict(40913, "article_not_optimizable", "only completed articles can be optimized") } return nil } func (s *ArticleSelectionOptimizeService) resolveMaxOutputTokens(selectedText string) int64 { limit := s.defaultMaxOutTokens if s != nil && s.configProvider != nil { if cfg := s.configProvider.Current(); cfg != nil { limit = cfg.LLM.MaxOutputTokens } } if limit <= 0 { limit = 1200 } estimated := int64(utf8.RuneCountInString(strings.TrimSpace(selectedText))*4 + 240) if estimated < 256 { estimated = 256 } if estimated > limit { return limit } return estimated } func validateOptimizeSelectionPayload(req ArticleSelectionOptimizeRequest) error { selectedText := strings.TrimSpace(req.SelectedText) if utf8.RuneCountInString(selectedText) < minOptimizeSelectedTextRunes { return response.ErrBadRequest(40019, "selected_text_required", "selected_text is required") } instruction := strings.TrimSpace(req.Instruction) if instruction == "" { return response.ErrBadRequest(40020, "optimize_instruction_required", "instruction is required") } if len([]rune(instruction)) > maxOptimizeInstructionChars { return response.ErrBadRequest( 40021, "optimize_instruction_too_long", fmt.Sprintf("instruction cannot exceed %d characters", maxOptimizeInstructionChars), ) } return nil } func buildOptimizeSelectionPrompt(req ArticleSelectionOptimizeRequest) string { title := sanitizeOptimizePromptSection(req.Title, 300) markdownContent := sanitizeOptimizePromptSection(req.MarkdownContent, maxOptimizeMarkdownContextLen) selectedText := sanitizeOptimizePromptSection(req.SelectedText, 2000) instruction := sanitizeOptimizePromptSection(req.Instruction, maxOptimizeInstructionChars) var builder strings.Builder builder.WriteString("你是一名资深中文内容编辑。请根据用户要求,优化一段文章选中文本。\n") builder.WriteString("请严格遵守以下规则:\n") builder.WriteString("1. 只输出优化后的正文内容,不要输出解释、标题、引号、前后缀说明或 Markdown 代码块。\n") builder.WriteString("2. 保持原文事实、结论、人物、时间、数字和专有名词准确,不得编造信息。\n") builder.WriteString("3. 优先遵循用户给出的优化要求,同时让表达与整篇文章风格保持自然一致。\n") builder.WriteString("4. 如果原文是一段话,默认输出一段话;除非用户明确要求改成其他结构。\n") builder.WriteString("5. 不要复述“以下是优化结果”等说明性文字。\n") if title != "" { builder.WriteString("\n文章标题:\n") builder.WriteString(title) builder.WriteString("\n") } if markdownContent != "" { builder.WriteString("\n文章草稿(用于参考语气和上下文,可能不是最终定稿):\n") builder.WriteString(markdownContent) builder.WriteString("\n") } builder.WriteString("\n待优化原文:\n") builder.WriteString(selectedText) builder.WriteString("\n") builder.WriteString("\n用户的优化要求:\n") builder.WriteString(instruction) builder.WriteString("\n") builder.WriteString("\n现在请直接输出优化后的文本。") return builder.String() } func sanitizeOptimizePromptSection(value string, maxRunes int) string { normalized := strings.TrimSpace(value) if normalized == "" { return "" } normalized = strings.ReplaceAll(normalized, "\u0000", "") normalized = strings.ReplaceAll(normalized, "\r\n", "\n") normalized = strings.ReplaceAll(normalized, "\r", "\n") runes := []rune(normalized) if maxRunes > 0 && len(runes) > maxRunes { return strings.TrimSpace(string(runes[:maxRunes])) + "\n...[内容已截断]" } return normalized }