Files
geo/server/internal/tenant/app/monitoring_answer_llm_parse.go
T

174 lines
5.6 KiB
Go
Raw Normal View History

2026-04-12 09:56:18 +08:00
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"
}
}