174 lines
5.6 KiB
Go
174 lines
5.6 KiB
Go
|
|
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"
|
|||
|
|
}
|
|||
|
|
}
|