feat: add natural elder story companion flow
This commit is contained in:
@@ -57,17 +57,19 @@ func (DeterministicGenerator) Generate(_ context.Context, input GenerationInput)
|
||||
}, nil
|
||||
}
|
||||
storyTitle, storyContent, factContent := "", "", ""
|
||||
openTopic := ""
|
||||
if storyWorthy {
|
||||
storyTitle = "关于“" + truncateRunes(content, 24) + "”的回忆"
|
||||
storyContent = content
|
||||
factContent = content
|
||||
openTopic = "如果长者愿意,下次可以从刚才经历里一个尚未讲清的具体细节自然接着聊。"
|
||||
}
|
||||
return GenerationResult{
|
||||
Meaningful: true,
|
||||
StoryWorthy: storyWorthy,
|
||||
DecisionReason: reason,
|
||||
Summary: "长者在本次通话中谈到:" + content,
|
||||
OpenTopic: "下次可以继续询问长者刚才提到的经历,并让长者自行补充人物、时间和地点。",
|
||||
OpenTopic: openTopic,
|
||||
StoryTitle: storyTitle,
|
||||
StoryContent: storyContent,
|
||||
FactContent: factContent,
|
||||
@@ -90,7 +92,7 @@ func sensitiveGeneration(decision contentpolicy.Decision) GenerationResult {
|
||||
StoryWorthy: false,
|
||||
DecisionReason: "safety_policy_blocked_story",
|
||||
Summary: "本次通话包含需要人工谨慎处理的敏感信息,系统未生成普通故事卡片。",
|
||||
OpenTopic: "如确有必要处理本次敏感内容,应由有权限人员在受控流程中单独核对。",
|
||||
OpenTopic: "",
|
||||
Confidence: 1,
|
||||
ModelRef: modelRef,
|
||||
PromptVersion: "summary-sensitive-filter-v1:" + reason,
|
||||
@@ -291,7 +293,7 @@ func validGeneratedContent(result GenerationResult) bool {
|
||||
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([]rune(result.OpenTopic)) > 1000 ||
|
||||
len(result.Keywords) < 1 || len(result.Keywords) > 8 {
|
||||
return false
|
||||
}
|
||||
@@ -313,7 +315,7 @@ const contentValuePrompt = `你是长者聊天内容筛选与整理助手。只
|
||||
必须只返回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给出一个温和且不重复追问的后续话题。
|
||||
- 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。`
|
||||
|
||||
@@ -63,6 +63,21 @@ func TestCompatibleGeneratorKeepsMeaningfulNonStoryWithoutDraft(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibleGeneratorAllowsMeaningfulContentWithoutForcedOpenTopic(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"meaningful":true,"story_worthy":false,"decision_reason":"记录了明确的生活偏好,但没有自然的未完话题","summary":"长者喜欢安静地听越剧","keywords":["越剧","偏好"],"open_topic":"","story_title":"","story_content":"","fact_content":"","confidence":0.88}`}}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
generator, err := NewCompatibleGenerator("openai_compatible", server.URL+"/v1", "local-key", "qwen-test", 0.1, 512, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := generator.Generate(context.Background(), GenerationInput{Content: "长者:我平时喜欢安静地听越剧。"})
|
||||
if err != nil || !result.Meaningful || result.OpenTopic != "" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibleGeneratorClearsContradictoryStoryFields(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"meaningful":true,"story_worthy":false,"decision_reason":"只是近况","summary":"今天胃口不错","keywords":["胃口"],"open_topic":"最近喜欢吃什么?","story_title":"模型误填标题","story_content":"模型误填故事","fact_content":"模型误填事实","confidence":0.8}`}}}})
|
||||
|
||||
@@ -96,7 +96,10 @@ func (p *Processor) Handle(ctx context.Context, event outbox.Event) error {
|
||||
// not cycle through retry and dead-letter queues forever.
|
||||
return nil
|
||||
}
|
||||
content := joinedConversationContent(snapshot.turns)
|
||||
// Only the subject's final turns can become a summary, story, fact, or
|
||||
// continuation cue. Agent turns may contain questions or generated wording
|
||||
// and must never be promoted into the subject's personal history.
|
||||
content := joinedSubjectContent(sourceTurns)
|
||||
generated, err := p.generateSafely(ctx, content, snapshot.tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -250,14 +253,13 @@ func subjectTurns(turns []turn) []turn {
|
||||
return items
|
||||
}
|
||||
|
||||
func joinedConversationContent(turns []turn) string {
|
||||
func joinedSubjectContent(turns []turn) string {
|
||||
parts := make([]string, 0, len(turns))
|
||||
for _, item := range turns {
|
||||
label := "豆包"
|
||||
if item.speaker == "subject" {
|
||||
label = "长者"
|
||||
if item.speaker != "subject" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, label+":"+item.text)
|
||||
parts = append(parts, "长者:"+item.text)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
@@ -284,11 +286,13 @@ func storeGeneration(ctx context.Context, tx pgx.Tx, conversationID string, snap
|
||||
ON CONFLICT (conversation_id,summary_type,generation_version) DO NOTHING`, snapshot.tenantID, snapshot.subjectID, conversationID, summaryWithKeywords(generated.Summary, generated.Keywords), sourceJSON, generated.ModelRef, generated.PromptVersion, generationVersion, generated.Confidence); err != nil {
|
||||
return fmt.Errorf("store conversation summary: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO conversation_summary (tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,confidence,status)
|
||||
VALUES ($1::uuid,$2::uuid,$3::uuid,'open_topic',$4,$5::jsonb,$6,$7,$8,$9,'READY')
|
||||
ON CONFLICT (conversation_id,summary_type,generation_version) DO NOTHING`, snapshot.tenantID, snapshot.subjectID, conversationID, generated.OpenTopic, sourceJSON, generated.ModelRef, generated.PromptVersion, generationVersion, generated.Confidence); err != nil {
|
||||
return fmt.Errorf("store open topic: %w", err)
|
||||
if strings.TrimSpace(generated.OpenTopic) != "" {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO conversation_summary (tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,confidence,status)
|
||||
VALUES ($1::uuid,$2::uuid,$3::uuid,'open_topic',$4,$5::jsonb,$6,$7,$8,$9,'READY')
|
||||
ON CONFLICT (conversation_id,summary_type,generation_version) DO NOTHING`, snapshot.tenantID, snapshot.subjectID, conversationID, generated.OpenTopic, sourceJSON, generated.ModelRef, generated.PromptVersion, generationVersion, generated.Confidence); err != nil {
|
||||
return fmt.Errorf("store open topic: %w", err)
|
||||
}
|
||||
}
|
||||
if !generated.AllowStory {
|
||||
return tx.Commit(ctx)
|
||||
|
||||
@@ -2,6 +2,7 @@ package extraction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"data-terminal-cloud/internal/contentpolicy"
|
||||
@@ -41,3 +42,17 @@ func TestGenerateSafelyBlocksModelOutputBeforePersistence(t *testing.T) {
|
||||
t.Fatalf("result=%+v generator_calls=%d classifier_calls=%d", result, generator.calls, classifier.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinedSubjectContentCannotPromoteAgentClaims(t *testing.T) {
|
||||
content := joinedSubjectContent([]turn{
|
||||
{speaker: "agent", text: "您年轻时一定在铁路上工作过。"},
|
||||
{speaker: "subject", text: "没有,我以前在学校教书。"},
|
||||
{speaker: "agent", text: "那您一定教了四十年。"},
|
||||
})
|
||||
if strings.Contains(content, "铁路") || strings.Contains(content, "四十年") {
|
||||
t.Fatalf("agent claims entered extraction content: %q", content)
|
||||
}
|
||||
if content != "长者:没有,我以前在学校教书。" {
|
||||
t.Fatalf("subject extraction content=%q", content)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user