feat: filter meaningful conversations with local model

This commit is contained in:
Codex Local
2026-08-30 15:20:08 +08:00
parent 89f6641331
commit 522785eda8
18 changed files with 451 additions and 45 deletions
+129 -33
View File
@@ -23,16 +23,19 @@ type GenerationInput struct {
}
type GenerationResult struct {
Summary string `json:"summary"`
OpenTopic string `json:"open_topic"`
StoryTitle string `json:"story_title"`
StoryContent string `json:"story_content"`
FactContent string `json:"fact_content"`
Keywords []string `json:"keywords"`
Confidence float64 `json:"confidence"`
ModelRef string `json:"-"`
PromptVersion string `json:"-"`
AllowStory bool `json:"-"`
Meaningful bool `json:"meaningful"`
StoryWorthy bool `json:"story_worthy"`
DecisionReason string `json:"decision_reason"`
Summary string `json:"summary"`
OpenTopic string `json:"open_topic"`
StoryTitle string `json:"story_title"`
StoryContent string `json:"story_content"`
FactContent string `json:"fact_content"`
Keywords []string `json:"keywords"`
Confidence float64 `json:"confidence"`
ModelRef string `json:"-"`
PromptVersion string `json:"-"`
AllowStory bool `json:"-"`
}
type Generator interface {
@@ -46,17 +49,33 @@ func (DeterministicGenerator) Generate(_ context.Context, input GenerationInput)
if content == "" {
return GenerationResult{}, errors.New("extraction content is required")
}
meaningful, storyWorthy, reason := deterministicContentDecision(content)
if !meaningful {
return GenerationResult{
Meaningful: false, StoryWorthy: false, DecisionReason: reason,
Confidence: 0.9, ModelRef: "local-deterministic-extractor", PromptVersion: "content-value-v1",
}, nil
}
storyTitle, storyContent, factContent := "", "", ""
if storyWorthy {
storyTitle = "关于“" + truncateRunes(content, 24) + "”的回忆"
storyContent = content
factContent = content
}
return GenerationResult{
Summary: "长者在本次通话中谈到:" + content,
OpenTopic: "下次可以继续询问长者刚才提到的经历,并让长者自行补充人物、时间和地点。",
StoryTitle: "关于“" + truncateRunes(content, 24) + "”的回忆",
StoryContent: content,
FactContent: content,
Keywords: deterministicKeywords(content),
Confidence: 0.9,
ModelRef: "local-deterministic-extractor",
PromptVersion: "story-v1",
AllowStory: true,
Meaningful: true,
StoryWorthy: storyWorthy,
DecisionReason: reason,
Summary: "长者在本次通话中谈到:" + content,
OpenTopic: "下次可以继续询问长者刚才提到的经历,并让长者自行补充人物、时间和地点。",
StoryTitle: storyTitle,
StoryContent: storyContent,
FactContent: factContent,
Keywords: deterministicKeywords(content),
Confidence: 0.9,
ModelRef: "local-deterministic-extractor",
PromptVersion: "content-value-deterministic-v2",
AllowStory: storyWorthy,
}, nil
}
@@ -67,12 +86,15 @@ func sensitiveGeneration(decision contentpolicy.Decision) GenerationResult {
}
reason := safePolicyToken(decision.Reason)
return GenerationResult{
Summary: "本次通话包含需要人工谨慎处理的敏感信息,系统未生成普通故事卡片。",
OpenTopic: "如确有必要处理本次敏感内容,应由有权限人员在受控流程中单独核对。",
Confidence: 1,
ModelRef: modelRef,
PromptVersion: "summary-sensitive-filter-v1:" + reason,
AllowStory: false,
Meaningful: true,
StoryWorthy: false,
DecisionReason: "safety_policy_blocked_story",
Summary: "本次通话包含需要人工谨慎处理的敏感信息,系统未生成普通故事卡片。",
OpenTopic: "如确有必要处理本次敏感内容,应由有权限人员在受控流程中单独核对。",
Confidence: 1,
ModelRef: modelRef,
PromptVersion: "summary-sensitive-filter-v1:" + reason,
AllowStory: false,
}
}
@@ -133,7 +155,7 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
requestBody := map[string]any{
"model": g.model,
"messages": []map[string]string{
{"role": "system", "content": "你是长者口述内容整理助手。只依据输入字幕生成可人工核对的候选,不补充未出现的事实,不把推断写成事实。必须返回JSON对象,字段为summary、keywords、open_topic、story_title、story_content、fact_content、confidence。summary概括本次内容,keywords是1至8个简短关键词字符串组成的数组,open_topic给出一个温和的后续话题,story和fact保留原意,confidence取0到1。"},
{"role": "system", "content": contentValuePrompt},
{"role": "user", "content": content},
},
"temperature": g.temperature,
@@ -186,30 +208,75 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
return GenerationResult{}, outbox.Retryable(fmt.Errorf("decode Ark structured content: %w", err))
}
result.Summary = strings.TrimSpace(result.Summary)
result.DecisionReason = strings.TrimSpace(result.DecisionReason)
result.OpenTopic = strings.TrimSpace(result.OpenTopic)
result.StoryTitle = strings.TrimSpace(result.StoryTitle)
result.StoryContent = strings.TrimSpace(result.StoryContent)
result.FactContent = strings.TrimSpace(result.FactContent)
result.Keywords = normalizedKeywords(result.Keywords)
normalizeDecisionContent(&result)
if !validGeneratedContent(result) {
return GenerationResult{}, outbox.Retryable(errors.New("Ark structured content failed validation"))
}
result.ModelRef = g.provider + ":" + g.model
result.PromptVersion = "story-" + g.provider + "-json-v2"
result.AllowStory = true
result.PromptVersion = "content-value-" + g.provider + "-json-v3"
result.AllowStory = result.Meaningful && result.StoryWorthy
return result, nil
}
func normalizeDecisionContent(result *GenerationResult) {
if !result.Meaningful {
result.StoryWorthy = false
result.Summary = ""
result.OpenTopic = ""
result.StoryTitle = ""
result.StoryContent = ""
result.FactContent = ""
result.Keywords = nil
return
}
if !result.StoryWorthy {
result.StoryTitle = ""
result.StoryContent = ""
result.FactContent = ""
}
}
func validGeneratedContent(result GenerationResult) bool {
return result.Summary != "" && len([]rune(result.Summary)) <= 2000 &&
result.OpenTopic != "" && len([]rune(result.OpenTopic)) <= 1000 &&
result.StoryTitle != "" && len([]rune(result.StoryTitle)) <= 300 &&
if result.Confidence < 0 || result.Confidence > 1 || result.DecisionReason == "" || len([]rune(result.DecisionReason)) > 500 {
return false
}
if !result.Meaningful {
return !result.StoryWorthy && result.Summary == "" && result.OpenTopic == "" && result.StoryTitle == "" && result.StoryContent == "" && result.FactContent == "" && len(result.Keywords) == 0
}
if result.Summary == "" || len([]rune(result.Summary)) > 2000 ||
result.OpenTopic == "" || len([]rune(result.OpenTopic)) > 1000 ||
len(result.Keywords) < 1 || len(result.Keywords) > 8 {
return false
}
if !result.StoryWorthy {
return result.StoryTitle == "" && result.StoryContent == "" && result.FactContent == ""
}
return result.StoryTitle != "" && len([]rune(result.StoryTitle)) <= 300 &&
result.StoryContent != "" && len([]rune(result.StoryContent)) <= 20000 &&
result.FactContent != "" && len([]rune(result.FactContent)) <= 5000 &&
len(result.Keywords) >= 1 && len(result.Keywords) <= 8 &&
result.Confidence >= 0 && result.Confidence <= 1
}
const contentValuePrompt = `你是长者聊天内容筛选与整理助手。只依据输入对话判断和整理,不补充未出现的事实,不把推断写成事实。
先做两级判断:
1. meaningful:对话是否包含值得保留的信息。只有问候、寒暄、嗯啊、测试设备、重复句、零散且无法理解的短句,必须判为false并舍弃,不生成摘要。
2. story_worthy:仅在meaningful为true时判断是否值得形成故事。具体的个人经历或回忆,且人物、时间、地点、事件、行动、感受中至少出现两类,或形成了清楚的事件脉络,才判为true。普通偏好、单个事实或简短近况可以总结,但story_worthy应为false。
必须只返回JSON对象,字段为meaningful、story_worthy、decision_reason、summary、keywords、open_topic、story_title、story_content、fact_content、confidence。
- decision_reason用一句简短中文说明判断依据。
- meaningful=false时,除decision_reason和confidence外,其余文本必须为空字符串,keywords必须为空数组,story_worthy必须为false。
- meaningful=true时,summary概括有意义内容,keywords为1至8个简短关键词,open_topic给出一个温和且不重复追问的后续话题。
- story_worthy=false时,story_title、story_content、fact_content必须为空字符串。
- story_worthy=true时,story_title和story_content忠实整理原话,fact_content只写对话中可核对的事实。
- confidence取0到1。`
func normalizedKeywords(values []string) []string {
result := make([]string, 0, min(len(values), 8))
seen := make(map[string]struct{}, len(values))
@@ -255,3 +322,32 @@ func deterministicKeywords(content string) []string {
}
return []string{string(runes)}
}
func deterministicContentDecision(content string) (meaningful, storyWorthy bool, reason string) {
compact := strings.Map(func(character rune) rune {
if unicode.IsSpace(character) || strings.ContainsRune(",。!?;、,.!?;::\"“”‘’()()-", character) {
return -1
}
return character
}, content)
for _, label := range []string{"长者", "豆包", "助手", "用户", "agent", "subject"} {
compact = strings.ReplaceAll(compact, label, "")
}
for _, phrase := range []string{"你好", "您好", "在吗", "嗯", "啊", "哦", "好的", "知道了", "没事", "再见", "拜拜", "听得到吗", "能听见吗", "测试"} {
compact = strings.ReplaceAll(compact, phrase, "")
}
if len([]rune(compact)) < 12 {
return false, false, "内容仅为寒暄、确认或零散短句"
}
storySignals := 0
for _, phrase := range []string{"小时候", "年轻时", "那年", "当时", "后来", "以前", "记得", "有一次", "我们", "我和", "去了", "发生", "因为", "结果", "高兴", "难过", "想起"} {
if strings.Contains(content, phrase) {
storySignals++
}
}
storyWorthy = len([]rune(compact)) >= 48 && storySignals >= 2
if storyWorthy {
return true, true, "包含较完整的个人经历与事件脉络"
}
return true, false, "包含可保留的信息,但尚不足以形成完整故事"
}