380 lines
18 KiB
Go
380 lines
18 KiB
Go
package extraction
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"data-terminal-cloud/internal/audit"
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
"data-terminal-cloud/internal/outbox"
|
||
|
|
"data-terminal-cloud/internal/saas"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
)
|
||
|
|
|
||
|
|
const directStoryTenant = "10000000-0000-0000-0000-000000000001"
|
||
|
|
const directStoryOwner = "10000000-0000-0000-0000-000000000101"
|
||
|
|
|
||
|
|
type directStoryGeneratorFunc func(context.Context, GenerationInput) (GenerationResult, error)
|
||
|
|
|
||
|
|
func (f directStoryGeneratorFunc) Generate(ctx context.Context, input GenerationInput) (GenerationResult, error) {
|
||
|
|
return f(ctx, input)
|
||
|
|
}
|
||
|
|
|
||
|
|
type directStoryFixture struct {
|
||
|
|
t *testing.T
|
||
|
|
pool *pgxpool.Pool
|
||
|
|
subject, publicSubject, device, session string
|
||
|
|
service *saas.Service
|
||
|
|
owner identity.Principal
|
||
|
|
}
|
||
|
|
|
||
|
|
func newDirectStoryFixture(t *testing.T) *directStoryFixture {
|
||
|
|
t.Helper()
|
||
|
|
if os.Getenv("TEST_DATABASE_URL") == "" {
|
||
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
||
|
|
}
|
||
|
|
pool, err := pgxpool.New(context.Background(), os.Getenv("TEST_DATABASE_URL"))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err := pool.Ping(context.Background()); err != nil {
|
||
|
|
pool.Close()
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
f := &directStoryFixture{t: t, pool: pool, subject: uuid.NewString(), publicSubject: uuid.NewString(), device: uuid.NewString(), session: uuid.NewString(),
|
||
|
|
service: saas.NewService(pool, audit.NewRepository(), nil, nil),
|
||
|
|
owner: identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}}
|
||
|
|
t.Cleanup(f.cleanup)
|
||
|
|
f.exec(`INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name) VALUES($1::uuid,$2::uuid,$3::uuid,'10000000-0000-0000-0000-000000000201','DIRECT 故事合成测试')`, f.subject, f.publicSubject, directStoryTenant)
|
||
|
|
f.exec(`INSERT INTO device(id,tenant_id,serial_digest,model,credential_ref) VALUES($1::uuid,$2::uuid,$3,'direct-story-test','synthetic')`, f.device, directStoryTenant, strings.ReplaceAll(uuid.NewString()+uuid.NewString(), "-", ""))
|
||
|
|
f.exec(`INSERT INTO memory_user_state(subject_id,tenant_id) VALUES($1::uuid,$2::uuid)`, f.subject, directStoryTenant)
|
||
|
|
f.exec(`INSERT INTO memory_access_grant(subject_id,user_id,can_read,can_write,sensitive_read) VALUES($1::uuid,$2::uuid,true,true,true)`, f.subject, directStoryOwner)
|
||
|
|
f.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,kind,'direct-story-test','synthetic',$3::uuid,now()-interval '1 hour'
|
||
|
|
FROM unnest(ARRAY['story_extraction','transcript_storage','personalized_memory']) kind`, directStoryTenant, f.subject, directStoryOwner)
|
||
|
|
f.exec(`INSERT INTO call_session(id,device_id,tenant_id,subject_id,boot_id,hook_event_id,call_generation,binding_generation,budget_snapshot,
|
||
|
|
call_state,transcript_state,transcript_revision,created_at,ended_at)
|
||
|
|
VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,'synthetic',1,1,'{"protocol":"DIRECT-CHAT-1","story_extraction_allowed":true}',
|
||
|
|
'ENDED','COMPLETE',1,now()-interval '10 minutes',now())`, f.session, f.device, directStoryTenant, f.subject, uuid.NewString())
|
||
|
|
f.exec(`INSERT INTO call_utterance(session_id,utterance_id,source_revision,speaker,text_content,interrupted,source_at) VALUES
|
||
|
|
($1::uuid,'user-final',1,'USER','我年轻时在河边小学教书,每天走五里山路去上课。记得有一次下大雨,我们一起把课本搬到楼上,后来孩子们给我送来自己采的野花,我特别高兴。',false,now()-interval '2 minutes'),
|
||
|
|
($1::uuid,'assistant-final',1,'ASSISTANT','助手虚构内容:我曾经驾驶飞船登上月球。',false,now()-interval '1 minute'),
|
||
|
|
($1::uuid,'unknown-final',1,'UNKNOWN','未知说话人内容',false,now())`, f.session)
|
||
|
|
return f
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *directStoryFixture) exec(sql string, args ...any) {
|
||
|
|
f.t.Helper()
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
if _, err := f.pool.Exec(ctx, sql, args...); err != nil {
|
||
|
|
f.t.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *directStoryFixture) event(revision int) outbox.Event {
|
||
|
|
return outbox.Event{AggregateID: f.session, EventType: DirectStoryEvent, Payload: []byte(fmt.Sprintf(`{"generation_version":%d,"privacy_epoch":0}`, revision))}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *directStoryFixture) processor(g Generator) *Processor {
|
||
|
|
f.t.Helper()
|
||
|
|
p, err := NewProcessor(f.pool, g)
|
||
|
|
if err != nil {
|
||
|
|
f.t.Fatal(err)
|
||
|
|
}
|
||
|
|
return p
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *directStoryFixture) handle(p *Processor, revision int) {
|
||
|
|
f.t.Helper()
|
||
|
|
if err := p.HandleDirectStory(context.Background(), f.event(revision)); err != nil {
|
||
|
|
f.t.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *directStoryFixture) story(revision int) saas.Story {
|
||
|
|
f.t.Helper()
|
||
|
|
var publicID string
|
||
|
|
if err := f.pool.QueryRow(context.Background(), `SELECT public_id::text FROM story_draft WHERE call_session_id=$1::uuid AND generation_version=$2`, f.session, revision).Scan(&publicID); err != nil {
|
||
|
|
f.t.Fatal(err)
|
||
|
|
}
|
||
|
|
story, err := f.service.GetStory(context.Background(), f.owner, publicID)
|
||
|
|
if err != nil {
|
||
|
|
f.t.Fatal(err)
|
||
|
|
}
|
||
|
|
return story
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *directStoryFixture) assertCounts(stories, extractions int) {
|
||
|
|
f.t.Helper()
|
||
|
|
var actualStories, actualExtractions int
|
||
|
|
if err := f.pool.QueryRow(context.Background(), `SELECT (SELECT count(*) FROM story_draft WHERE call_session_id=$1::uuid),
|
||
|
|
(SELECT count(*) FROM call_story_extraction WHERE session_id=$1::uuid)`, f.session).Scan(&actualStories, &actualExtractions); err != nil {
|
||
|
|
f.t.Fatal(err)
|
||
|
|
}
|
||
|
|
if actualStories != stories || actualExtractions != extractions {
|
||
|
|
f.t.Fatalf("stories=%d extractions=%d; want %d/%d", actualStories, actualExtractions, stories, extractions)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *directStoryFixture) appendFinal(revision int) {
|
||
|
|
f.exec(`INSERT INTO call_utterance(session_id,utterance_id,source_revision,speaker,text_content,interrupted,source_at)
|
||
|
|
VALUES($1::uuid,$2,$3,'USER','后来我们给学校修好了屋顶,孩子们再也不用淋雨。',false,now())`, f.session, fmt.Sprintf("late-%d", revision), revision)
|
||
|
|
f.exec(`UPDATE call_session SET transcript_revision=$2 WHERE id=$1::uuid`, f.session, revision)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *directStoryFixture) cleanup() {
|
||
|
|
defer f.pool.Close()
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
for _, sql := range []string{
|
||
|
|
`DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM curated_memory WHERE subject_id=$1::uuid) OR aggregate_id IN (SELECT id FROM call_session WHERE subject_id=$1::uuid)`,
|
||
|
|
`DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM story_draft WHERE subject_id=$1::uuid)`,
|
||
|
|
`DELETE FROM content_review_signal WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM memory_index_entry WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM memory_projection_job WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM memory_projection WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM memory_source_suppression WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM memory_deletion_ledger WHERE subject_id=$1::uuid`,
|
||
|
|
`UPDATE curated_memory SET current_version_id=NULL WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM memory_version WHERE memory_id IN (SELECT id FROM curated_memory WHERE subject_id=$1::uuid)`,
|
||
|
|
`DELETE FROM curated_memory WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM fact_candidate WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM story_source WHERE story_revision_id IN (SELECT sr.id FROM story_revision sr JOIN story_draft sd ON sd.id=sr.story_id WHERE sd.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 call_story_extraction WHERE session_id IN (SELECT id FROM call_session WHERE subject_id=$1::uuid)`,
|
||
|
|
`DELETE FROM call_command WHERE session_id IN (SELECT id FROM call_session WHERE subject_id=$1::uuid)`,
|
||
|
|
`DELETE FROM call_utterance WHERE session_id IN (SELECT id FROM call_session WHERE subject_id=$1::uuid)`,
|
||
|
|
`DELETE FROM call_session WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM privacy_request WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM consent_record WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM memory_access_grant WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM memory_user_state WHERE subject_id=$1::uuid`,
|
||
|
|
`DELETE FROM subject_profile WHERE id=$1::uuid`,
|
||
|
|
} {
|
||
|
|
if _, err := f.pool.Exec(ctx, sql, f.subject); err != nil {
|
||
|
|
f.t.Errorf("fixture cleanup: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if _, err := f.pool.Exec(ctx, `DELETE FROM device WHERE id=$1::uuid`, f.device); err != nil {
|
||
|
|
f.t.Errorf("fixture device cleanup: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDirectStoryDraftReviewAndSave(t *testing.T) {
|
||
|
|
f := newDirectStoryFixture(t)
|
||
|
|
calls := 0
|
||
|
|
p := f.processor(directStoryGeneratorFunc(func(ctx context.Context, input GenerationInput) (GenerationResult, error) {
|
||
|
|
calls++
|
||
|
|
if strings.Contains(input.Content, "助手") || strings.Contains(input.Content, "未知") || !strings.Contains(input.Content, "河边小学") {
|
||
|
|
t.Fatalf("unexpected model input: %q", input.Content)
|
||
|
|
}
|
||
|
|
return (DeterministicGenerator{}).Generate(ctx, input)
|
||
|
|
}))
|
||
|
|
f.handle(p, 1)
|
||
|
|
f.handle(p, 1)
|
||
|
|
if calls != 1 {
|
||
|
|
t.Fatalf("duplicate event called model %d times", calls)
|
||
|
|
}
|
||
|
|
f.assertCounts(1, 1)
|
||
|
|
story := f.story(1)
|
||
|
|
if story.Status != "CANDIDATE" || story.ConversationID != "" || story.CallSessionID != f.session || len(story.Sources) != 1 || len(story.Facts) != 1 {
|
||
|
|
t.Fatalf("unexpected story: %+v", story)
|
||
|
|
}
|
||
|
|
source := story.Sources[0]
|
||
|
|
if source.CallSessionID != f.session || source.UtteranceID != "user-final" || source.SourceRevision == nil || *source.SourceRevision != 1 || source.Speaker != "subject" {
|
||
|
|
t.Fatalf("unexpected DIRECT source: %+v", source)
|
||
|
|
}
|
||
|
|
stories, err := f.service.ListSubjectStories(context.Background(), f.owner, f.publicSubject, "CANDIDATE")
|
||
|
|
if err != nil || len(stories) != 1 || stories[0].ID != story.ID {
|
||
|
|
t.Fatalf("story inbox=%+v err=%v", stories, err)
|
||
|
|
}
|
||
|
|
const edited = "我年轻时在河边小学教书,每天走五里山路,给孩子们上语文课。"
|
||
|
|
revised, err := f.service.ReviseStory(context.Background(), f.owner, story.ID, edited, "核对原文", uuid.NewString(), story.Version, "")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if revised.Status != "IN_REVIEW" || revised.RevisionNo != 2 || len(revised.Sources) != 1 || revised.Sources[0].UtteranceID != "user-final" {
|
||
|
|
t.Fatalf("revised story lost source: %+v", revised)
|
||
|
|
}
|
||
|
|
confirmed, memory, err := f.service.ConfirmStory(context.Background(), f.owner, revised.ID, "已审核", uuid.NewString(), revised.Version)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if confirmed.Status != "CONFIRMED" || memory.Status != "ACTIVE" || memory.Content != edited {
|
||
|
|
t.Fatalf("save result: story=%+v memory=%+v", confirmed, memory)
|
||
|
|
}
|
||
|
|
refs, _ := json.Marshal(memory.SourceRefs)
|
||
|
|
if !strings.Contains(string(refs), f.session) || !strings.Contains(string(refs), "user-final") || strings.Contains(string(refs), "assistant-final") {
|
||
|
|
t.Fatalf("saved source refs=%s", refs)
|
||
|
|
}
|
||
|
|
f.handle(p, 1)
|
||
|
|
if calls != 1 {
|
||
|
|
t.Fatalf("confirmed replay called model %d times", calls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDirectStoryAuthorizationGates(t *testing.T) {
|
||
|
|
for _, gate := range []string{"snapshot_denied", "no_story_consent", "no_transcript_consent", "late_consent", "old_privacy_epoch", "delete_request", "old_event_revision", "old_event_epoch"} {
|
||
|
|
t.Run(gate, func(t *testing.T) {
|
||
|
|
f := newDirectStoryFixture(t)
|
||
|
|
event := f.event(1)
|
||
|
|
switch gate {
|
||
|
|
case "snapshot_denied":
|
||
|
|
f.exec(`UPDATE call_session SET budget_snapshot=budget_snapshot||'{"story_extraction_allowed":false}'::jsonb WHERE id=$1::uuid`, f.session)
|
||
|
|
case "no_story_consent":
|
||
|
|
f.exec(`DELETE FROM consent_record WHERE subject_id=$1::uuid AND consent_type='story_extraction'`, f.subject)
|
||
|
|
case "no_transcript_consent":
|
||
|
|
f.exec(`DELETE FROM consent_record WHERE subject_id=$1::uuid AND consent_type='transcript_storage'`, f.subject)
|
||
|
|
case "late_consent":
|
||
|
|
f.exec(`UPDATE consent_record SET granted_at=now() WHERE subject_id=$1::uuid AND consent_type='story_extraction'`, f.subject)
|
||
|
|
case "old_privacy_epoch":
|
||
|
|
f.exec(`UPDATE memory_user_state SET privacy_epoch=1 WHERE subject_id=$1::uuid`, f.subject)
|
||
|
|
case "delete_request":
|
||
|
|
f.exec(`INSERT INTO privacy_request(tenant_id,subject_id,request_type,requested_by,idempotency_key,request_digest) VALUES($1::uuid,$2::uuid,'DELETE',$3::uuid,$4,$5)`, directStoryTenant, f.subject, directStoryOwner, uuid.NewString(), strings.Repeat("a", 64))
|
||
|
|
// Match epochs to prove DELETE is independently disqualifying.
|
||
|
|
f.exec(`UPDATE call_session SET privacy_epoch=(SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$1::uuid) WHERE id=$2::uuid`, f.subject, f.session)
|
||
|
|
event.Payload = []byte(`{"generation_version":1,"privacy_epoch":1}`)
|
||
|
|
case "old_event_revision":
|
||
|
|
f.exec(`UPDATE call_session SET transcript_revision=2 WHERE id=$1::uuid`, f.session)
|
||
|
|
case "old_event_epoch":
|
||
|
|
event.Payload = []byte(`{"generation_version":1,"privacy_epoch":1}`)
|
||
|
|
}
|
||
|
|
p := f.processor(directStoryGeneratorFunc(func(context.Context, GenerationInput) (GenerationResult, error) {
|
||
|
|
t.Fatal("unauthorized or stale event reached model")
|
||
|
|
return GenerationResult{}, nil
|
||
|
|
}))
|
||
|
|
if err := p.HandleDirectStory(context.Background(), event); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
f.assertCounts(0, 0)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDirectStoryLateFinalPreservesHumanReview(t *testing.T) {
|
||
|
|
f := newDirectStoryFixture(t)
|
||
|
|
calls := 0
|
||
|
|
p := f.processor(directStoryGeneratorFunc(func(ctx context.Context, input GenerationInput) (GenerationResult, error) {
|
||
|
|
calls++
|
||
|
|
return (DeterministicGenerator{}).Generate(ctx, input)
|
||
|
|
}))
|
||
|
|
f.handle(p, 1)
|
||
|
|
f.appendFinal(2)
|
||
|
|
f.handle(p, 2)
|
||
|
|
f.assertCounts(2, 2)
|
||
|
|
var oldStatus, factStatus string
|
||
|
|
if err := f.pool.QueryRow(context.Background(), `SELECT sd.status,fc.status FROM story_draft sd JOIN fact_candidate fc ON fc.story_id=sd.id WHERE sd.call_session_id=$1::uuid AND sd.generation_version=1`, f.session).Scan(&oldStatus, &factStatus); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if oldStatus != "SUPERSEDED" || factStatus != "SUPERSEDED" {
|
||
|
|
t.Fatalf("untouched candidate not replaced: %s/%s", oldStatus, factStatus)
|
||
|
|
}
|
||
|
|
story := f.story(2)
|
||
|
|
if len(story.Sources) != 2 {
|
||
|
|
t.Fatalf("late final source count=%d", len(story.Sources))
|
||
|
|
}
|
||
|
|
const human = "人工审核后的河边小学故事。"
|
||
|
|
revised, err := f.service.ReviseStory(context.Background(), f.owner, story.ID, human, "核对", uuid.NewString(), story.Version, "")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
f.appendFinal(3)
|
||
|
|
f.handle(p, 3)
|
||
|
|
f.handle(p, 3)
|
||
|
|
f.assertCounts(2, 3)
|
||
|
|
if calls != 2 {
|
||
|
|
t.Fatalf("late final after human review called model: %d", calls)
|
||
|
|
}
|
||
|
|
after := f.story(2)
|
||
|
|
if after.Status != "IN_REVIEW" || after.Content != human || after.Version != revised.Version {
|
||
|
|
t.Fatalf("human review overwritten: %+v", after)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDirectStoryModelResultRevalidated(t *testing.T) {
|
||
|
|
for _, change := range []string{"revoke_story_consent", "revise_transcript", "human_review"} {
|
||
|
|
t.Run(change, func(t *testing.T) {
|
||
|
|
f := newDirectStoryFixture(t)
|
||
|
|
revision := 1
|
||
|
|
var old saas.Story
|
||
|
|
if change == "human_review" {
|
||
|
|
f.handle(f.processor(DeterministicGenerator{}), 1)
|
||
|
|
old = f.story(1)
|
||
|
|
f.appendFinal(2)
|
||
|
|
revision = 2
|
||
|
|
}
|
||
|
|
p := f.processor(directStoryGeneratorFunc(func(ctx context.Context, input GenerationInput) (GenerationResult, error) {
|
||
|
|
// A synchronous callback represents state changing while the model is
|
||
|
|
// in flight and also proves no database transaction locks span it.
|
||
|
|
switch change {
|
||
|
|
case "revoke_story_consent":
|
||
|
|
f.exec(`UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='story_extraction'`, f.subject)
|
||
|
|
case "revise_transcript":
|
||
|
|
f.exec(`UPDATE call_utterance SET text_content='更正后的回忆',source_revision=2 WHERE session_id=$1::uuid AND utterance_id='user-final'`, f.session)
|
||
|
|
f.exec(`UPDATE call_session SET transcript_revision=2 WHERE id=$1::uuid`, f.session)
|
||
|
|
case "human_review":
|
||
|
|
reviewCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
if _, err := f.service.ReviseStory(reviewCtx, f.owner, old.ID, "保留人工修订内容", "审核", uuid.NewString(), old.Version, ""); err != nil {
|
||
|
|
return GenerationResult{}, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return (DeterministicGenerator{}).Generate(ctx, input)
|
||
|
|
}))
|
||
|
|
f.handle(p, revision)
|
||
|
|
if change == "human_review" {
|
||
|
|
f.assertCounts(1, 2)
|
||
|
|
if story := f.story(1); story.Status != "IN_REVIEW" || story.Content != "保留人工修订内容" {
|
||
|
|
t.Fatalf("model overwrote review: %+v", story)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
f.assertCounts(0, 0)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDirectStoryReviewRejectsChangedSources(t *testing.T) {
|
||
|
|
for _, change := range []string{"revision_changed", "source_deleted", "speaker_changed", "consent_withdrawn"} {
|
||
|
|
t.Run(change, func(t *testing.T) {
|
||
|
|
f := newDirectStoryFixture(t)
|
||
|
|
f.handle(f.processor(DeterministicGenerator{}), 1)
|
||
|
|
story := f.story(1)
|
||
|
|
switch change {
|
||
|
|
case "revision_changed":
|
||
|
|
f.exec(`UPDATE call_utterance SET source_revision=2,text_content='已更正的转写' WHERE session_id=$1::uuid AND utterance_id='user-final'`, f.session)
|
||
|
|
case "source_deleted":
|
||
|
|
f.exec(`UPDATE call_utterance SET text_content='[deleted]' WHERE session_id=$1::uuid AND utterance_id='user-final'`, f.session)
|
||
|
|
case "speaker_changed":
|
||
|
|
f.exec(`UPDATE call_utterance SET speaker='ASSISTANT' WHERE session_id=$1::uuid AND utterance_id='user-final'`, f.session)
|
||
|
|
case "consent_withdrawn":
|
||
|
|
f.exec(`UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='story_extraction'`, f.subject)
|
||
|
|
}
|
||
|
|
ctx := context.Background()
|
||
|
|
if _, err := f.service.GetStory(ctx, f.owner, story.ID); !errors.Is(err, saas.ErrAccessDenied) {
|
||
|
|
t.Fatalf("stale source remains readable: %v", err)
|
||
|
|
}
|
||
|
|
if _, _, err := f.service.ConfirmStory(ctx, f.owner, story.ID, "审核", uuid.NewString(), story.Version); !errors.Is(err, saas.ErrAccessDenied) {
|
||
|
|
t.Fatalf("stale source was confirmable: %v", err)
|
||
|
|
}
|
||
|
|
var saved int
|
||
|
|
if err := f.pool.QueryRow(ctx, `SELECT count(*) FROM curated_memory WHERE subject_id=$1::uuid`, f.subject).Scan(&saved); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if saved != 0 {
|
||
|
|
t.Fatalf("unavailable source saved %d memories", saved)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|