93 lines
6.2 KiB
Go
93 lines
6.2 KiB
Go
package callmemory
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
func addCallSourceStory(t *testing.T, f fixture, session, utterance string, generation int) (string, string) {
|
||
|
|
t.Helper()
|
||
|
|
story, revision, fact := uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||
|
|
f.exec(t, `INSERT INTO story_draft(id,public_id,tenant_id,subject_id,call_session_id,call_privacy_epoch,title,generation_version) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,$4::uuid,0,'private story',$5)`, story, f.tenant, f.subject, session, generation)
|
||
|
|
f.exec(t, `INSERT INTO story_revision(id,story_id,revision_no,content,author_type,edit_reason) VALUES($1::uuid,$2::uuid,1,'private story content','model','private edit reason')`, revision, story)
|
||
|
|
f.exec(t, `INSERT INTO story_source(story_revision_id,call_session_id,call_utterance_id,call_source_revision,source_role) VALUES($1::uuid,$2::uuid,$3,1,'subject')`, revision, session, utterance)
|
||
|
|
f.exec(t, `UPDATE story_draft SET current_revision_id=$2::uuid WHERE id=$1::uuid`, story, revision)
|
||
|
|
f.exec(t, `INSERT INTO fact_candidate(id,tenant_id,subject_id,story_id,fact_type,content,statement_basis,source_turn_ids,generation_version) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'event','private fact','subject_statement',jsonb_build_array($5::text),$6)`, fact, f.tenant, f.subject, story, utterance, generation)
|
||
|
|
f.exec(t, `INSERT INTO fact_candidate_revision(fact_candidate_id,version_no,fact_type,content,sensitivity,status,edit_reason) VALUES($1::uuid,1,'event','private historical fact','normal','CANDIDATE','private edit reason')`, fact)
|
||
|
|
return story, revision
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestCallSourceDeletionRedactsLinkedStoryAndPreservesUnrelatedTurns(t *testing.T) {
|
||
|
|
for _, refType := range []string{"utterance", "story", "session"} {
|
||
|
|
t.Run(refType, func(t *testing.T) {
|
||
|
|
f := setup(t)
|
||
|
|
ctx := context.Background()
|
||
|
|
memory, _ := f.add(t)
|
||
|
|
call, _, _ := f.call(t)
|
||
|
|
for _, utterance := range []string{"removed", "kept"} {
|
||
|
|
f.exec(t, `INSERT INTO call_utterance(session_id,utterance_id,source_revision,speaker,text_content,interrupted,source_at) VALUES($1::uuid,$2,1,'USER',$2,false,now())`, call.SessionID, utterance)
|
||
|
|
}
|
||
|
|
f.exec(t, `INSERT INTO call_text_fragment(session_id,utterance_id,source_revision,part_index,part_count,speaker,text_content,content_sha256,is_final,source_at) VALUES($1::uuid,'fragment-only',1,0,2,'USER','private partial text',repeat('a',64),true,now())`, call.SessionID)
|
||
|
|
f.exec(t, `INSERT INTO call_event(session_id,event_id,seq,event_type,digest,payload) VALUES($1::uuid,'source-test-event',1,'TRANSCRIPT_FINAL',repeat('a',64),'{"payload":{"utterance_id":"removed","text":"private event text"}}'::jsonb)`, call.SessionID)
|
||
|
|
story, revision := addCallSourceStory(t, f, call.SessionID, "removed", 1)
|
||
|
|
unrelatedStory, _ := addCallSourceStory(t, f, call.SessionID, "kept", 2)
|
||
|
|
// Both utterances occur in the first story. Deleting just one source
|
||
|
|
// invalidates that story, without erasing its other original source.
|
||
|
|
if refType == "utterance" {
|
||
|
|
f.exec(t, `INSERT INTO story_source(story_revision_id,call_session_id,call_utterance_id,call_source_revision,source_role) VALUES($1::uuid,$2::uuid,'kept',1,'subject')`, revision, call.SessionID)
|
||
|
|
}
|
||
|
|
refs := []map[string]any{{"session_id": call.SessionID, "utterance_id": "removed"}}
|
||
|
|
if refType == "story" {
|
||
|
|
refs = []map[string]any{{"type": "story", "story_id": story, "revision_no": 1}}
|
||
|
|
} else if refType == "session" {
|
||
|
|
refs = []map[string]any{{"session_id": call.SessionID}}
|
||
|
|
}
|
||
|
|
f.exec(t, `INSERT INTO memory_source_suppression(subject_id,memory_id,source_refs,privacy_epoch,scope) SELECT subject_id,id,$2::jsonb,0,'SOURCE_AND_DERIVED' FROM curated_memory WHERE public_id=$1::uuid`, memory, jsonBytes(refs))
|
||
|
|
tx, err := f.p.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer tx.Rollback(ctx)
|
||
|
|
if err = f.s.cleanSources(ctx, tx, f.subject, jsonBytes([]string{memory}), "SOURCE_AND_DERIVED"); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err = tx.Commit(ctx); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
var removed, kept, title, status, content, fact, historicalFact string
|
||
|
|
var editReason *string
|
||
|
|
if err = f.p.QueryRow(ctx, `SELECT text_content FROM call_utterance WHERE session_id=$1::uuid AND utterance_id='removed'`, call.SessionID).Scan(&removed); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err = f.p.QueryRow(ctx, `SELECT text_content FROM call_utterance WHERE session_id=$1::uuid AND utterance_id='kept'`, call.SessionID).Scan(&kept); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err = f.p.QueryRow(ctx, `SELECT sd.title,sd.status,sr.content,fc.content,fcr.content,sr.edit_reason FROM story_draft sd JOIN story_revision sr ON sr.id=sd.current_revision_id JOIN fact_candidate fc ON fc.story_id=sd.id JOIN fact_candidate_revision fcr ON fcr.fact_candidate_id=fc.id WHERE sd.id=$1::uuid`, story).Scan(&title, &status, &content, &fact, &historicalFact, &editReason); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if removed != "[deleted]" || title != "[deleted]" || status != "SUPERSEDED" || content != "[deleted]" || fact != "[deleted]" || historicalFact != "[deleted]" || editReason != nil {
|
||
|
|
t.Fatalf("source or derived content survived: %q %q %q %q %q %q %v", removed, title, status, content, fact, historicalFact, editReason)
|
||
|
|
}
|
||
|
|
var unrelatedStatus, fragment, event string
|
||
|
|
if err = f.p.QueryRow(ctx, `SELECT status FROM story_draft WHERE id=$1::uuid`, unrelatedStory).Scan(&unrelatedStatus); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err = f.p.QueryRow(ctx, `SELECT text_content FROM call_text_fragment WHERE session_id=$1::uuid`, call.SessionID).Scan(&fragment); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err = f.p.QueryRow(ctx, `SELECT payload::text FROM call_event WHERE session_id=$1::uuid`, call.SessionID).Scan(&event); err != nil || event != `{"redacted": true}` {
|
||
|
|
t.Fatalf("source event survived deletion: %q %v", event, err)
|
||
|
|
}
|
||
|
|
if refType == "session" {
|
||
|
|
if kept != "[deleted]" || unrelatedStatus != "SUPERSEDED" || fragment != "[deleted]" {
|
||
|
|
t.Fatalf("session deletion left a source, story or partial transcript: %q %q %q", kept, unrelatedStatus, fragment)
|
||
|
|
}
|
||
|
|
} else if kept != "kept" || unrelatedStatus != "CANDIDATE" || fragment != "private partial text" {
|
||
|
|
t.Fatalf("unrelated source or story changed: %q %q %q", kept, unrelatedStatus, fragment)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|