286 lines
8.7 KiB
Go
286 lines
8.7 KiB
Go
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
|
|
}
|