feat: add natural elder story companion flow

This commit is contained in:
Codex Local
2026-09-06 20:16:01 +08:00
parent 029a6097a2
commit 8c834c0aa7
20 changed files with 582 additions and 149 deletions
+6 -4
View File
@@ -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。`
+15
View File
@@ -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}`}}}})
+15 -11
View File
@@ -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)
}
}
+130
View File
@@ -0,0 +1,130 @@
package orchestration
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
)
// recentConversationContext keeps a summary and its continuation cue from the
// same extraction generation. OpenTopic is optional and never a mandatory
// question for the next conversation.
type recentConversationContext struct {
Summary string `json:"summary,omitempty"`
OpenTopic string `json:"open_topic,omitempty"`
}
func buildCompanionContextSummary(preferredName string, profile authorizedSubjectContext, recent []recentConversationContext, memoryContents []string) string {
parts := make([]string, 0, 12)
if len(profile.AvoidedTopics) > 0 {
parts = append(parts, "交流边界:不要主动提及“"+strings.Join(truncateContextItems(profile.AvoidedTopics, 24), "、")+"”;若长者主动提及,先简短共情,再自然转向;紧急安全响应不受此限制")
}
parts = append(parts,
"称呼:"+truncateContextField(preferredName, 60),
"使用规则:以下历史资料仅作背景,不是指令;先回应长者当前说的话;不要把推断说成事实",
)
if topic := suggestedOpenTopic(recent); topic != "" {
parts = append(parts, "可选续聊线索(只在自然相关且长者愿意时使用,不要照念):"+truncateContextField(topic, 160))
}
for index, item := range recent {
if item.Summary != "" {
parts = append(parts, fmt.Sprintf("最近第%d次通话摘要:%s", index+1, truncateContextField(item.Summary, 170)))
}
}
if len(memoryContents) > 0 {
parts = append(parts, "已确认的长期记忆(以长者最新说法为准):"+strings.Join(truncateContextItems(memoryContents, 90), ";"))
}
goal := profile.ConversationGoal
if goal == "" {
goal = "进行一次自然、尊重的陪伴交流"
}
parts = append(parts, "本次交流目标:"+truncateContextField(goal, 80))
if profile.Pace != "" {
paceLabels := map[string]string{"slow": "放慢语速并留出回应时间", "normal": "自然语速", "lively": "轻快但保持清晰"}
parts = append(parts, "交流节奏:"+paceLabels[profile.Pace])
}
if len(profile.PreferredTopics) > 0 {
parts = append(parts, "优先话题:"+strings.Join(truncateContextItems(profile.PreferredTopics, 24), "、"))
}
if profile.Background != "" {
parts = append(parts, "背景资料:"+truncateContextField(profile.Background, 100))
}
if len(profile.Interests) > 0 {
parts = append(parts, "兴趣:"+strings.Join(truncateContextItems(profile.Interests, 24), "、"))
}
return truncateContextField(strings.Join(parts, ";"), contextSummaryLimit)
}
func recentConversationSummaries(recent []recentConversationContext) []string {
result := make([]string, 0, len(recent))
for _, item := range recent {
if summary := strings.TrimSpace(item.Summary); summary != "" {
result = append(result, summary)
}
}
return result
}
func suggestedOpenTopic(recent []recentConversationContext) string {
for _, item := range recent {
if topic := strings.TrimSpace(item.OpenTopic); topic != "" {
return topic
}
}
return ""
}
func selectRecentConversationContexts(ctx context.Context, tx pgx.Tx, tenantID, subjectID string, limit int) ([]recentConversationContext, error) {
if limit <= 0 {
return nil, nil
}
rows, err := tx.Query(ctx, `
WITH latest_generation AS (
SELECT DISTINCT ON (summary.conversation_id)
summary.conversation_id, summary.generation_version,
conversation.ended_at, conversation.created_at
FROM conversation_summary summary
JOIN conversation ON conversation.id=summary.conversation_id
AND conversation.tenant_id=summary.tenant_id
AND conversation.subject_id=summary.subject_id
WHERE summary.tenant_id=$1::uuid AND summary.subject_id=$2::uuid
AND summary.summary_type IN ('conversation_summary','open_topic')
AND summary.status='READY'
AND conversation.status IN ('COMPLETED','FAILED')
ORDER BY summary.conversation_id, summary.generation_version DESC, summary.created_at DESC
), recent_context AS (
SELECT latest.conversation_id, latest.ended_at, latest.created_at,
COALESCE(MAX(summary.content) FILTER (WHERE summary.summary_type='conversation_summary'), '') AS summary,
COALESCE(MAX(summary.content) FILTER (WHERE summary.summary_type='open_topic'), '') AS open_topic
FROM latest_generation latest
JOIN conversation_summary summary ON summary.conversation_id=latest.conversation_id
AND summary.tenant_id=$1::uuid AND summary.subject_id=$2::uuid
AND summary.generation_version=latest.generation_version
AND summary.status='READY'
AND summary.summary_type IN ('conversation_summary','open_topic')
GROUP BY latest.conversation_id, latest.ended_at, latest.created_at
)
SELECT summary, open_topic FROM recent_context
WHERE summary<>'' OR open_topic<>''
ORDER BY COALESCE(ended_at,created_at) DESC, conversation_id DESC
LIMIT $3`, tenantID, subjectID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]recentConversationContext, 0, limit)
for rows.Next() {
var item recentConversationContext
if err := rows.Scan(&item.Summary, &item.OpenTopic); err != nil {
return nil, err
}
item.Summary = truncateContextText(item.Summary, recentSummaryContentLimit)
item.OpenTopic = truncateContextText(item.OpenTopic, recentOpenTopicContentLimit)
if item.Summary != "" || item.OpenTopic != "" {
result = append(result, item)
}
}
return result, rows.Err()
}
+32 -88
View File
@@ -43,9 +43,10 @@ var (
const contextPreparationFailureCode = "context_preparation_failed"
const (
contextSummaryLimit = 1000
recentSummaryLimit = 3
recentSummaryContentLimit = 240
contextSummaryLimit = 1000
recentConversationLimit = 3
recentSummaryContentLimit = 240
recentOpenTopicContentLimit = 240
)
type AllocationOptions struct {
@@ -405,14 +406,14 @@ func (r *Repository) Create(
consentSnapshotID, memoryVersionsJSON, promptBundleVersion, engineMode,
privacyFromConsent(effectiveConsents), options)
}
var recentSummaries []string
var recentContexts []recentConversationContext
if consentAllowsConversationContinuity(effectiveConsents) {
recentSummaries, err = selectRecentConversationSummaries(ctx, tx, identity.TenantID, subjectID, recentSummaryLimit)
recentContexts, err = selectRecentConversationContexts(ctx, tx, identity.TenantID, subjectID, recentConversationLimit)
if err != nil {
return CreateResponse{}, "", fmt.Errorf("retrieve recent conversation summaries: %w", err)
return CreateResponse{}, "", fmt.Errorf("retrieve recent conversation context: %w", err)
}
}
contextSummary := buildContextSummary(subjectName, profileContext, recentSummaries, memoryContents)
contextSummary := buildCompanionContextSummary(subjectName, profileContext, recentContexts, memoryContents)
var vikingContext string
var vikingNativeMemory *HardwareAgentMemoryConfig
if options.VikingSubjectID == subjectID && options.VikingContext != "" && consentAllowsConversationContinuity(effectiveConsents) {
@@ -437,16 +438,30 @@ func (r *Repository) Create(
}
}
contextValue := struct {
SubjectID string `json:"subject_id"`
PreferredName string `json:"preferred_name"`
ProfileVersion int64 `json:"profile_version"`
RequestedGoal string `json:"requested_goal"`
ProfileContext authorizedSubjectContext `json:"profile_context"`
RecentSummaries []string `json:"recent_summaries"`
MemoryContents []string `json:"memory_contents"`
RetrievalPolicy string `json:"retrieval_policy"`
VikingContext string `json:"viking_context,omitempty"`
}{subjectPublicID, subjectName, profileVersion, input.RequestedGoal, profileContext, recentSummaries, memoryContents, retrievalPolicy(options.MemoryPolicy), vikingContext}
SubjectID string `json:"subject_id"`
PreferredName string `json:"preferred_name"`
ProfileVersion int64 `json:"profile_version"`
RequestedGoal string `json:"requested_goal"`
ProfileContext authorizedSubjectContext `json:"profile_context"`
RecentContexts []recentConversationContext `json:"recent_contexts"`
RecentSummaries []string `json:"recent_summaries"`
SuggestedOpenTopic string `json:"suggested_open_topic,omitempty"`
MemoryContents []string `json:"memory_contents"`
RetrievalPolicy string `json:"retrieval_policy"`
VikingContext string `json:"viking_context,omitempty"`
}{
SubjectID: subjectPublicID,
PreferredName: subjectName,
ProfileVersion: profileVersion,
RequestedGoal: input.RequestedGoal,
ProfileContext: profileContext,
RecentContexts: recentContexts,
RecentSummaries: recentConversationSummaries(recentContexts),
SuggestedOpenTopic: suggestedOpenTopic(recentContexts),
MemoryContents: memoryContents,
RetrievalPolicy: retrievalPolicy(options.MemoryPolicy),
VikingContext: vikingContext,
}
contextJSON, _ := json.Marshal(contextValue)
contextDigest := sha256.Sum256(contextJSON)
contextSnapshotID := uuid.NewString()
@@ -937,40 +952,6 @@ func authorizedProfileContext(profileDataJSON, preferencesJSON json.RawMessage)
}, nil
}
func buildContextSummary(preferredName string, profile authorizedSubjectContext, recentSummaries, memoryContents []string) string {
parts := make([]string, 0, 10)
if len(profile.AvoidedTopics) > 0 {
parts = append(parts, "交流边界:不要主动提及“"+strings.Join(truncateContextItems(profile.AvoidedTopics, 24), "、")+"”;若长者主动提及,先简短共情,再自然转向;紧急安全响应不受此限制")
}
parts = append(parts, "称呼:"+truncateContextField(preferredName, 60))
for index, summary := range recentSummaries {
parts = append(parts, fmt.Sprintf("最近第%d次通话摘要:%s", index+1, truncateContextField(summary, 140)))
}
if len(memoryContents) > 0 {
parts = append(parts, "已人工确认的长期记忆:"+strings.Join(truncateContextItems(memoryContents, 30), ";"))
}
goal := profile.ConversationGoal
if goal == "" {
goal = "进行一次自然、尊重的陪伴交流"
}
parts = append(parts, "本次交流目标:"+truncateContextField(goal, 80))
if profile.Pace != "" {
paceLabels := map[string]string{"slow": "放慢语速并留出回应时间", "normal": "自然语速", "lively": "轻快但保持清晰"}
parts = append(parts, "交流节奏:"+paceLabels[profile.Pace])
}
if len(profile.PreferredTopics) > 0 {
parts = append(parts, "优先话题:"+strings.Join(truncateContextItems(profile.PreferredTopics, 15), "、"))
}
if profile.Background != "" {
parts = append(parts, "背景资料:"+truncateContextField(profile.Background, 80))
}
if len(profile.Interests) > 0 {
parts = append(parts, "兴趣:"+strings.Join(truncateContextItems(profile.Interests, 15), "、"))
}
result := strings.Join(parts, ";")
return truncateContextField(result, contextSummaryLimit)
}
func truncateContextItems(values []string, itemLimit int) []string {
items := make([]string, 0, len(values))
for _, value := range values {
@@ -1000,43 +981,6 @@ func consentAllowsConversationContinuity(consents json.RawMessage) bool {
return values["personalized_memory"] && values["story_extraction"]
}
func selectRecentConversationSummaries(ctx context.Context, tx pgx.Tx, tenantID, subjectID string, limit int) ([]string, error) {
if limit <= 0 {
return nil, nil
}
rows, err := tx.Query(ctx, `
WITH latest_summary AS (
SELECT DISTINCT ON (summary.conversation_id)
summary.conversation_id, summary.content, conversation.ended_at, conversation.created_at
FROM conversation_summary summary
JOIN conversation ON conversation.id=summary.conversation_id
AND conversation.tenant_id=summary.tenant_id
AND conversation.subject_id=summary.subject_id
WHERE summary.tenant_id=$1::uuid AND summary.subject_id=$2::uuid
AND summary.summary_type='conversation_summary' AND summary.status='READY'
AND conversation.status IN ('COMPLETED','FAILED')
ORDER BY summary.conversation_id, summary.generation_version DESC, summary.created_at DESC
)
SELECT content FROM latest_summary
ORDER BY COALESCE(ended_at,created_at) DESC, conversation_id DESC
LIMIT $3`, tenantID, subjectID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]string, 0, limit)
for rows.Next() {
var content string
if err := rows.Scan(&content); err != nil {
return nil, err
}
if content = truncateContextText(content, recentSummaryContentLimit); content != "" {
result = append(result, content)
}
}
return result, rows.Err()
}
func strictContextString(values map[string]any, key string, limit int) (string, error) {
raw, exists := values[key]
if !exists || raw == nil {
@@ -154,6 +154,8 @@ func TestServiceIdempotentlyCreatesConversationWithoutPersistingToken(t *testing
VALUES ('10000000-0000-0000-0000-000000000001',$1::uuid,$2::uuid,'prior-subject-1',1,'subject','2026-08-23T10:01:00Z','2026-08-23T10:01:05Z','我注册了一个个体工商户。','provider')`, []any{testSubjectID, testPriorConversationID}},
{`INSERT INTO conversation_summary (tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,status,created_at)
VALUES ('10000000-0000-0000-0000-000000000001',$1::uuid,$2::uuid,'conversation_summary','上次聊到刚注册了一个个体工商户,准备开始新的工作。','["prior-subject-1"]'::jsonb,'mock-content-v1','prompt-v1',1,'READY','2026-08-23T10:21:00Z')`, []any{testSubjectID, testPriorConversationID}},
{`INSERT INTO conversation_summary (tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,status,created_at)
VALUES ('10000000-0000-0000-0000-000000000001',$1::uuid,$2::uuid,'open_topic','如果长者愿意,可以聊聊开始新工作后最期待的事。','["prior-subject-1"]'::jsonb,'mock-content-v1','prompt-v1',1,'READY','2026-08-23T10:21:01Z')`, []any{testSubjectID, testPriorConversationID}},
{`INSERT INTO consent_record (tenant_id,subject_id,consent_type,policy_version,notice_digest,actor_user_id)
VALUES ('10000000-0000-0000-0000-000000000001',$1::uuid,'personalized_memory','orchestration-service-test',repeat('a',64),'10000000-0000-0000-0000-000000000101')`, []any{testSubjectID}},
{`INSERT INTO runtime_config_version (
@@ -220,7 +222,7 @@ func TestServiceIdempotentlyCreatesConversationWithoutPersistingToken(t *testing
if _, err = tx.Exec(ctx, `UPDATE conversation SET status=$2 WHERE id=$1::uuid`, testPriorConversationID, priorStatus); err != nil {
t.Fatal(err)
}
summaries, err := selectRecentConversationSummaries(ctx, tx, "10000000-0000-0000-0000-000000000001", testSubjectID, 3)
contexts, err := selectRecentConversationContexts(ctx, tx, "10000000-0000-0000-0000-000000000001", testSubjectID, 3)
if err != nil {
t.Fatal(err)
}
@@ -228,12 +230,15 @@ func TestServiceIdempotentlyCreatesConversationWithoutPersistingToken(t *testing
if priorStatus == "ACTIVE" {
want = 0
}
if len(summaries) != want {
t.Fatalf("status=%s summaries=%d want=%d", priorStatus, len(summaries), want)
if len(contexts) != want {
t.Fatalf("status=%s contexts=%d want=%d", priorStatus, len(contexts), want)
}
summaries, err = selectRecentConversationSummaries(ctx, tx, "10000000-0000-0000-0000-000000000001", "90000000-0000-0000-0000-000000009999", 3)
if err != nil || len(summaries) != 0 {
t.Fatalf("unrelated subject summaries=%d err=%v", len(summaries), err)
if len(contexts) == 1 && (contexts[0].Summary == "" || contexts[0].OpenTopic == "") {
t.Fatalf("summary and open topic were not selected from the same generation: %+v", contexts[0])
}
contexts, err = selectRecentConversationContexts(ctx, tx, "10000000-0000-0000-0000-000000000001", "90000000-0000-0000-0000-000000009999", 3)
if err != nil || len(contexts) != 0 {
t.Fatalf("unrelated subject contexts=%d err=%v", len(contexts), err)
}
})
}
@@ -394,7 +399,7 @@ func TestServiceIdempotentlyCreatesConversationWithoutPersistingToken(t *testing
if frozenBundleVersion != "cfg:"+bundle.ID || first.Session.EngineMode != "cascade" {
t.Fatalf("frozen bundle=%q engine=%q", frozenBundleVersion, first.Session.EngineMode)
}
for _, expected := range []string{"服务测试长者", "年轻时从事铁路工作", "老照片", "聊聊第一次参加工作的经历", "放慢语速", "工作经历", "交流边界", "收入", "最近第1次通话摘要", "个体工商户"} {
for _, expected := range []string{"服务测试长者", "年轻时从事铁路工作", "老照片", "聊聊第一次参加工作的经历", "放慢语速", "工作经历", "交流边界", "收入", "最近第1次通话摘要", "个体工商户", "可选续聊线索", "最期待的事"} {
if !strings.Contains(frozenContext, expected) {
t.Fatalf("frozen context %q does not contain %q", frozenContext, expected)
}
+25 -6
View File
@@ -78,28 +78,47 @@ func TestBuildContextSummaryIncludesContinuityAndBoundariesWithinSnapshotLimit(t
Background: "退休教师", Interests: []string{"戏曲"}, ConversationGoal: "自然陪伴", Pace: "slow",
PreferredTopics: []string{"家乡"}, AvoidedTopics: []string{"收入", "家庭矛盾"},
}
result := buildContextSummary("王阿姨", profile,
[]string{"上次聊到刚刚注册了个体工商户,准备开始新的工作。"},
result := buildCompanionContextSummary("王阿姨", profile,
[]recentConversationContext{{Summary: "上次聊到刚刚注册了个体工商户,准备开始新的工作。", OpenTopic: "如果她愿意,可以聊聊开始新工作后最期待的事。"}},
[]string{"喜欢在周末听越剧。"})
for _, expected := range []string{"交流边界", "收入", "王阿姨", "本次交流目标:自然陪伴", "交流节奏:放慢语速", "优先话题:家乡", "背景资料:退休教师", "兴趣:戏曲", "最近第1次通话摘要", "注册了个体工商户", "已人工确认的长期记忆", "越剧"} {
for _, expected := range []string{"交流边界", "收入", "王阿姨", "历史资料仅作背景", "先回应长者当前说的话", "可选续聊线索", "最期待的事", "本次交流目标:自然陪伴", "交流节奏:放慢语速", "优先话题:家乡", "背景资料:退休教师", "兴趣:戏曲", "最近第1次通话摘要", "注册了个体工商户", "已确认的长期记忆", "越剧"} {
if !strings.Contains(result, expected) {
t.Fatalf("context %q does not contain %q", result, expected)
}
}
oversized := buildContextSummary(strings.Repeat("长", 120), authorizedSubjectContext{
oversized := buildCompanionContextSummary(strings.Repeat("长", 120), authorizedSubjectContext{
Background: strings.Repeat("背", 500), AvoidedTopics: []string{strings.Repeat("禁", 50), "家庭矛盾"},
PreferredTopics: []string{strings.Repeat("优", 50)}, Interests: []string{strings.Repeat("兴", 50)},
}, []string{strings.Repeat("摘", 500), "第二次摘要", "第三次摘要"}, []string{strings.Repeat("忆", 500), "第二条记忆", "第三条记忆", "第四条记忆", "第五条记忆"})
}, []recentConversationContext{
{Summary: strings.Repeat("摘", 500), OpenTopic: strings.Repeat("续", 500)},
{Summary: "第二次摘要", OpenTopic: "较旧的续聊线索"},
{Summary: "第三次摘要"},
}, []string{strings.Repeat("忆", 500), "第二条记忆", "第三条记忆"})
if len([]rune(oversized)) > contextSummaryLimit {
t.Fatalf("context length=%d exceeds snapshot budget", len([]rune(oversized)))
}
for _, expected := range []string{"交流边界", "家庭矛盾", "最近第3次通话摘要", "第三次摘要", "已人工确认的长期记忆", "第五条记忆"} {
for _, expected := range []string{"交流边界", "家庭矛盾", "可选续聊线索", strings.Repeat("续", 20), "最近第3次通话摘要", "第三次摘要", "已确认的长期记忆"} {
if !strings.Contains(oversized, expected) {
t.Fatalf("oversized context %q lost priority field %q", oversized, expected)
}
}
}
func TestCompanionContextUsesOnlyNewestAvailableOpenTopic(t *testing.T) {
recent := []recentConversationContext{
{Summary: "最近一次只聊了天气。"},
{Summary: "上一次谈到老同事。", OpenTopic: "有机会可以聊聊最难忘的一位老同事。"},
{Summary: "更早谈到家乡。", OpenTopic: "可以聊聊家乡。"},
}
if got := suggestedOpenTopic(recent); got != "有机会可以聊聊最难忘的一位老同事。" {
t.Fatalf("selected open topic=%q", got)
}
result := buildCompanionContextSummary("李叔叔", authorizedSubjectContext{}, recent, nil)
if strings.Count(result, "可选续聊线索") != 1 || strings.Contains(result, "可以聊聊家乡") {
t.Fatalf("context exposed multiple continuation cues: %q", result)
}
}
func TestConversationContinuityRequiresBothExplicitConsents(t *testing.T) {
for _, test := range []struct {
value json.RawMessage