package app import ( "context" "encoding/json" "fmt" "strings" "time" sharedllm "github.com/geo-platform/tenant-api/internal/shared/llm" ) const ( monitoringAnswerParseLLMModel = "doubao-seed-2-0-lite-260215" monitoringAnswerParseLLMTimeout = 30 * time.Second monitoringAnswerParseLLMMaxOutputTokens = 600 ) var monitoringAnswerParseLLMSchema = []byte(`{ "type": "object", "additionalProperties": false, "required": [ "brand_mentioned", "brand_mention_position", "first_recommended", "sentiment_label", "matched_brand_terms" ], "properties": { "brand_mentioned": { "type": "boolean" }, "brand_mention_position": { "type": "string", "enum": ["top1", "mentioned", "not_mentioned"] }, "first_recommended": { "type": "boolean" }, "sentiment_label": { "type": "string", "enum": ["positive", "neutral", "negative", "unknown"] }, "matched_brand_terms": { "type": "array", "items": { "type": "string" } } } }`) type monitoringAnswerLLMParsePayload struct { BrandMentioned bool `json:"brand_mentioned"` BrandMentionPosition string `json:"brand_mention_position"` FirstRecommended bool `json:"first_recommended"` SentimentLabel string `json:"sentiment_label"` MatchedBrandTerms []string `json:"matched_brand_terms"` } func parseMonitoringAnswerWithLLM( ctx context.Context, client sharedllm.Client, answer string, brandName string, ) (monitoringAnswerParseSummary, error) { answer = strings.TrimSpace(answer) brandName = strings.TrimSpace(brandName) if answer == "" { return monitoringAnswerParseSummary{}, fmt.Errorf("answer is empty") } if brandName == "" { return monitoringAnswerParseSummary{}, fmt.Errorf("brand name is empty") } result, err := client.Generate(ctx, sharedllm.GenerateRequest{ Model: monitoringAnswerParseLLMModel, Prompt: buildMonitoringAnswerParsePrompt(answer, brandName), Timeout: monitoringAnswerParseLLMTimeout, MaxOutputTokens: monitoringAnswerParseLLMMaxOutputTokens, ResponseFormat: &sharedllm.ResponseFormat{ Type: sharedllm.ResponseFormatTypeJSONSchema, Name: "monitoring_answer_parse", Description: "Parse brand mention signals from an AI answer for brand monitoring.", SchemaJSON: monitoringAnswerParseLLMSchema, Strict: true, }, }, nil) if err != nil { return monitoringAnswerParseSummary{}, err } return decodeMonitoringAnswerLLMParseResult(result.Content) } func buildMonitoringAnswerParsePrompt(answer string, brandName string) string { var builder strings.Builder builder.WriteString("你是品牌监测解析助手。\n") builder.WriteString("请只基于给定回答内容,判断目标品牌在回答中的提及、排序、推荐与情感,不要猜测回答外的信息。\n") builder.WriteString("\n判断规则:\n") builder.WriteString("1. brand_mentioned:回答中是否明确提到目标品牌,或可明确判断是在指代该品牌。\n") builder.WriteString("2. brand_mention_position:\n") builder.WriteString(" - top1:目标品牌被排在第一位,或被明确表述为首选/最推荐。\n") builder.WriteString(" - mentioned:目标品牌被提到,但不是第一位。\n") builder.WriteString(" - not_mentioned:未提到目标品牌。\n") builder.WriteString("3. first_recommended:是否明确把目标品牌作为首选推荐。\n") builder.WriteString("4. sentiment_label:结合品牌相关表述判断 positive / neutral / negative / unknown。\n") builder.WriteString("5. matched_brand_terms:把回答里实际出现、并用于指代该品牌的词语原样列出;没有就返回空数组。\n") builder.WriteString("\n只返回 JSON。\n") builder.WriteString("\n目标品牌:") builder.WriteString(brandName) builder.WriteString("\n回答内容:\n") builder.WriteString(answer) return builder.String() } func decodeMonitoringAnswerLLMParseResult(raw string) (monitoringAnswerParseSummary, error) { var lastErr error for _, candidate := range extractJSONCandidates(raw) { var payload monitoringAnswerLLMParsePayload if err := json.Unmarshal([]byte(candidate), &payload); err != nil { lastErr = err continue } return monitoringAnswerParseSummary{ BrandMentioned: payload.BrandMentioned, BrandMentionPosition: normalizeMonitoringBrandMentionPosition(payload.BrandMentionPosition, payload.BrandMentioned), FirstRecommended: payload.FirstRecommended, SentimentLabel: normalizeMonitoringSentiment(payload.SentimentLabel, payload.BrandMentioned), MatchedBrandTerms: normalizeMonitoringBrandTermList(payload.MatchedBrandTerms), }, nil } if lastErr == nil { lastErr = fmt.Errorf("empty content") } return monitoringAnswerParseSummary{}, fmt.Errorf("decode monitoring answer parse result: %w", lastErr) } func normalizeMonitoringBrandMentionPosition(value string, brandMentioned bool) string { switch strings.ToLower(strings.TrimSpace(value)) { case "top1": if !brandMentioned { return "not_mentioned" } return "top1" case "mentioned": if !brandMentioned { return "not_mentioned" } return "mentioned" case "not_mentioned": return "not_mentioned" default: if brandMentioned { return "mentioned" } return "not_mentioned" } } func normalizeMonitoringSentiment(value string, brandMentioned bool) string { switch strings.ToLower(strings.TrimSpace(value)) { case "positive", "neutral", "negative": return strings.ToLower(strings.TrimSpace(value)) case "unknown": return "unknown" default: if brandMentioned { return "neutral" } return "unknown" } }