package extraction import ( "context" "data-terminal-cloud/internal/outbox" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "os" "strings" "testing" ) func TestEventComparisonRejectsUnknownStoryAndPreservesNewFacts(t *testing.T) { in := GenerationInput{PreviousStories: []PreviousStory{{ID: "old", Content: "我1970年进厂。"}}} r := GenerationResult{Meaningful: true, Stories: []EventStory{{Title: "进厂", Content: "进厂的故事", FactContent: "1970年进厂", MatchID: "other"}}} if normalizeEvents(&r, in) == nil { t.Fatal("unknown matched story accepted") } r.Stories[0].MatchID = "old" r.Stories[0].NewDetails = []string{"我1970年进厂。", "娘把杯子洗好了。"} if e := normalizeEvents(&r, in); e != nil || len(r.Stories[0].NewDetails) != 1 { t.Fatalf("result=%v err=%v", r, e) } } func TestDailyEventsExcludePlansHabitsAndPast(t *testing.T) { r := GenerationResult{DailyEvents: []DailyEvent{ {Title: "晚饭", Content: "今天做了白菜炖土豆。", TimeScope: "today_actual"}, {Title: "明天上班", Content: "明天去厂里。", TimeScope: "future"}, {Title: "做手套", Content: "前几天答应做手套,还没动手。", TimeScope: "past"}, {Title: "习惯", Content: "通常下班会喝水。", TimeScope: "habit"}, }} if e := normalizeEvents(&r, GenerationInput{}); e != nil || len(r.DailyEvents) != 1 || r.DailyEvents[0].Title != "晚饭" { t.Fatalf("daily=%v err=%v", r.DailyEvents, e) } } func TestImportedEventsSplitDeduplicateAndSupplement(t *testing.T) { if os.Getenv("TEST_DATABASE_URL") == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, e := pgxpool.New(ctx, os.Getenv("TEST_DATABASE_URL")) if e != nil { t.Fatal(e) } defer pool.Close() subject := uuid.NewString() tenant := "10000000-0000-0000-0000-000000000001" user := "10000000-0000-0000-0000-000000000101" exec := func(sql string, args ...any) { t.Helper() if _, e := pool.Exec(ctx, sql, args...); e != nil { t.Fatal(e) } } exec(`INSERT INTO subject_profile(id,tenant_id,household_id,preferred_name) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000201','回忆录测试')`, subject, tenant) exec(`INSERT INTO consent_record(tenant_id,subject_id,consent_type,policy_version,notice_digest,actor_user_id,granted_at) SELECT $1::uuid,$2::uuid,k,'test','test',$3::uuid,now()-interval '1 hour' FROM unnest(ARRAY['transcript_storage','story_extraction']) k`, tenant, subject, user) t.Cleanup(func() { for _, sql := range []string{`DELETE FROM story_generation_event WHERE story_id IN(SELECT id FROM story_draft WHERE subject_id=$1::uuid)`, `DELETE FROM fact_candidate WHERE subject_id=$1::uuid`, `UPDATE story_draft SET current_revision_id=NULL WHERE subject_id=$1::uuid`, `DELETE FROM story_revision WHERE story_id IN(SELECT id FROM story_draft WHERE subject_id=$1::uuid)`, `DELETE FROM story_draft WHERE subject_id=$1::uuid`, `DELETE FROM chat_daily_event WHERE subject_id=$1::uuid`, `DELETE FROM memoir_import_turn WHERE import_id IN(SELECT id FROM memoir_chat_import WHERE subject_id=$1::uuid)`, `DELETE FROM memoir_chat_import WHERE subject_id=$1::uuid`, `DELETE FROM consent_record WHERE subject_id=$1::uuid`, `DELETE FROM subject_profile WHERE id=$1::uuid`} { _, _ = pool.Exec(ctx, sql, subject) } }) makeImport := func(text string) string { id := uuid.NewString() exec(`INSERT INTO memoir_chat_import(id,tenant_id,subject_id,created_by,title,transcript_digest)VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'test','test')`, id, tenant, subject, user) exec(`INSERT INTO memoir_import_turn(import_id,sequence_no,speaker,content)VALUES($1::uuid,1,'subject',$2),($1::uuid,2,'agent','助手编造:开过飞船')`, id, text) return id } mode := 0 generator := directStoryGeneratorFunc(func(_ context.Context, in GenerationInput) (GenerationResult, error) { if strings.Contains(in.Content, "飞船") { t.Fatal("assistant speech included") } sid := strings.Split(strings.Split(in.Content, "]")[0], "[")[1] stories := []EventStory{{Title: "进厂第一天", Content: "1970年我第一次进厂。", FactContent: "1970年进厂", EventTime: "1970年", SourceIDs: []string{sid}}, {Title: "结婚搬家", Content: "1973年我搬了家。", FactContent: "1973年搬家", EventTime: "1973年", SourceIDs: []string{sid}}} if mode > 0 { stories = stories[:1] for _, old := range in.PreviousStories { if old.Title == "进厂第一天" { stories[0].MatchID = old.ID } } } if mode == 2 { stories[0].NewDetails = []string{"第一天下班围裙上沾满线头。"} } return GenerationResult{Meaningful: true, StoryWorthy: true, AllowStory: true, Confidence: 0.9, ModelRef: "test", PromptVersion: "events-test", Stories: stories, DailyEvents: []DailyEvent{{Title: "今天晚饭", Content: "今天做了白菜炖土豆。", SourceIDs: []string{sid}}}}, nil }) p, _ := NewProcessor(pool, generator) run := func(id string) { t.Helper() if e := p.HandleImport(ctx, outbox.Event{AggregateID: id}); e != nil { t.Fatal(e) } } first := makeImport("1970年我进厂,1973年搬家,今天吃了晚饭。") run(first) run(first) mode = 1 run(makeImport("1970年我进厂。")) var count int var version int64 var content string if e = pool.QueryRow(ctx, `SELECT count(*) FROM story_draft WHERE subject_id=$1::uuid`, subject).Scan(&count); e != nil || count != 2 { t.Fatalf("count=%d err=%v", count, e) } mode = 2 run(makeImport("1970年我进厂,第一天下班围裙上沾满线头。")) if e = pool.QueryRow(ctx, `SELECT sd.version,sr.content FROM story_draft sd JOIN story_revision sr ON sr.id=sd.current_revision_id WHERE sd.subject_id=$1::uuid AND sd.title='进厂第一天'`, subject).Scan(&version, &content); e != nil || version != 2 || !strings.Contains(content, "1970") || !strings.Contains(content, "线头") { t.Fatalf("version=%d content=%s err=%v", version, content, e) } // A merged story becomes unavailable if any contributing original is erased. exec(`UPDATE memoir_import_turn SET content='[deleted]' WHERE import_id=$1::uuid AND speaker='subject'`, first) var available bool if e = pool.QueryRow(ctx, `SELECT call_story_available(id) FROM story_draft WHERE subject_id=$1::uuid AND title='进厂第一天'`, subject).Scan(&available); e != nil || available { t.Fatalf("erased source still available: %v %v", available, e) } }