250 lines
10 KiB
Go
250 lines
10 KiB
Go
package vikingmemory
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"data-terminal-cloud/internal/audit"
|
||
|
|
"data-terminal-cloud/internal/callmemory"
|
||
|
|
"data-terminal-cloud/internal/deviceauth"
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
"data-terminal-cloud/internal/saas"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestManagedUploadPreservesScopeAndOnlyExtractsManagedEvents(t *testing.T) {
|
||
|
|
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
var p map[string]any
|
||
|
|
json.NewDecoder(r.Body).Decode(&p)
|
||
|
|
if p["session_id"] != "c_test" || p["resource_id"] != "mem-test" || p["store_file"] != false {
|
||
|
|
t.Fatal("wrong upload destination")
|
||
|
|
}
|
||
|
|
types := p["extract_memory_type"].([]any)
|
||
|
|
if len(types) != 1 || types[0] != "event_v1" {
|
||
|
|
t.Fatal("independent profile extraction enabled")
|
||
|
|
}
|
||
|
|
meta := p["metadata"].(map[string]any)
|
||
|
|
if meta["default_user_id"] != "elder-a" || meta["default_assistant_id"] != ManagedAssistantID {
|
||
|
|
t.Fatal("wrong elder")
|
||
|
|
}
|
||
|
|
fmt.Fprint(w, `{"code":0}`)
|
||
|
|
})
|
||
|
|
if e := c.AddManagedSession(context.Background(), "c_test", "elder-a", []Message{{Role: "user", RoleID: "elder-a", Content: "茶"}}, 1); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestManagedTranscriptToMemoryAndNextCall(t *testing.T) {
|
||
|
|
db := os.Getenv("TEST_DATABASE_URL")
|
||
|
|
if db == "" {
|
||
|
|
t.Skip("TEST_DATABASE_URL required")
|
||
|
|
}
|
||
|
|
ctx := context.Background()
|
||
|
|
p, e := pgxpool.New(ctx, db)
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
defer p.Close()
|
||
|
|
exec := func(q string, args ...any) {
|
||
|
|
t.Helper()
|
||
|
|
if _, e := p.Exec(ctx, q, args...); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
subject, elder, device, sid := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||
|
|
tenant := "10000000-0000-0000-0000-000000000001"
|
||
|
|
exec(`INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status) VALUES($1::uuid,$2::uuid,$3::uuid,'10000000-0000-0000-0000-000000000201','managed-test','active')`, subject, elder, tenant)
|
||
|
|
exec(`INSERT INTO consent_record(tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref) SELECT tenant_id,$1::uuid,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref FROM consent_record WHERE subject_id='10000000-0000-0000-0000-000000000401' AND withdrawn_at IS NULL`, subject)
|
||
|
|
exec(`INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) VALUES($1::uuid,$2::uuid,1,'{"voice_chat":true}','[]')`, tenant, subject)
|
||
|
|
exec(`INSERT INTO memory_user_state(subject_id,tenant_id) VALUES($1::uuid,$2::uuid)`, subject, tenant)
|
||
|
|
exec(`INSERT INTO device(id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,activation_state) VALUES($1::uuid,$2::uuid,$3,'test','test','registered','local-dev://test','ACTIVE')`, device, tenant, strings.Repeat("a", 28)+strings.ReplaceAll(device, "-", ""))
|
||
|
|
exec(`INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason,generation) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000201',$3::uuid,'10000000-0000-0000-0000-000000000101','test',1)`, tenant, device, subject)
|
||
|
|
exec(`INSERT INTO subject_voice_agent(subject_id,bot_name,bot_id,status) VALUES($1::uuid,$2,$2,'READY')`, subject, "test-"+subject)
|
||
|
|
exec(`INSERT INTO device_activation_job(device_id,tenant_id,provider_device_name,provider_bot_id,status,binding_generation,config_version,sdk_registered_at) VALUES($1::uuid,$2::uuid,$3,'bot','READY',1,1,now())`, device, tenant, "test-"+device)
|
||
|
|
exec(`INSERT INTO call_session(id,tenant_id,subject_id,device_id,boot_id,hook_event_id,call_generation,binding_generation,call_state,transcript_state,ended_at,memory_mode,transcript_revision,budget_snapshot) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$1::uuid,$1,1,1,'ENDED','PARTIAL',now(),'HOT_ONLY',2,'{"protocol":"DIRECT-CHAT-1"}')`, sid, tenant, subject, device)
|
||
|
|
exec(`INSERT INTO call_utterance(session_id,utterance_id,source_revision,speaker,text_content,interrupted,source_at) VALUES($1::uuid,'u',0,'USER','我喜欢淡茶',false,now()),($1::uuid,'a',0,'ASSISTANT','记住了',false,now()+interval '1 second')`, sid)
|
||
|
|
user := UserID(tenant, subject)
|
||
|
|
remote := SessionID(sid, 0)
|
||
|
|
uploads := 0
|
||
|
|
fail := true
|
||
|
|
deleted := false
|
||
|
|
cloudText := "喜欢淡茶"
|
||
|
|
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
var in map[string]any
|
||
|
|
json.NewDecoder(r.Body).Decode(&in)
|
||
|
|
switch {
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/session/add"):
|
||
|
|
uploads++
|
||
|
|
if fail {
|
||
|
|
w.WriteHeader(503)
|
||
|
|
fmt.Fprint(w, `{"code":9}`)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
msgs := in["messages"].([]any)
|
||
|
|
if len(msgs) != 2 || in["session_id"] != remote {
|
||
|
|
t.Fatal("lost or duplicated transcript")
|
||
|
|
}
|
||
|
|
for _, msg := range msgs {
|
||
|
|
v := msg.(map[string]any)
|
||
|
|
if v["role"] == "user" && v["role_id"] != user {
|
||
|
|
t.Fatal("wrong upload elder")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
fmt.Fprint(w, `{"code":0}`)
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/search"):
|
||
|
|
f := in["filter"].(map[string]any)
|
||
|
|
if f["user_id"].([]any)[0] != user || f["session_id"].([]any)[0] != remote {
|
||
|
|
t.Fatal("unscoped search")
|
||
|
|
}
|
||
|
|
items := []any{map[string]any{"id": "foreign", "user_id": []string{"other-elder"}, "assistant_id": []string{ManagedAssistantID}, "session_id": remote, "memory_info": map[string]any{"summary": "不能串人"}}}
|
||
|
|
if !deleted {
|
||
|
|
items = append(items, map[string]any{"id": "event-one", "user_id": []string{user}, "assistant_id": []string{ManagedAssistantID}, "session_id": remote, "memory_info": map[string]any{"summary": cloudText}})
|
||
|
|
}
|
||
|
|
json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"result_list": items}})
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/update"):
|
||
|
|
if in["event_id"] != "event-one" {
|
||
|
|
t.Fatal("wrong update target")
|
||
|
|
}
|
||
|
|
cloudText = in["memory_info"].(map[string]any)["summary"].(string)
|
||
|
|
fmt.Fprint(w, `{"code":0}`)
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/delete"):
|
||
|
|
if in["event_id"] != "event-one" {
|
||
|
|
t.Fatal("wrong delete target")
|
||
|
|
}
|
||
|
|
deleted = true
|
||
|
|
fmt.Fprint(w, `{"code":0}`)
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/batch_delete"):
|
||
|
|
f := in["filter"].(map[string]any)
|
||
|
|
if f["user_id"].([]any)[0] != user || f["assistant_id"].([]any)[0] != ManagedAssistantID {
|
||
|
|
t.Fatal("unscoped withdrawal")
|
||
|
|
}
|
||
|
|
deleted = true
|
||
|
|
fmt.Fprint(w, `{"code":0}`)
|
||
|
|
default:
|
||
|
|
t.Fatal("unexpected endpoint", r.URL.Path)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
m := NewManaged(p, c)
|
||
|
|
s := callmemory.NewService(p, nil)
|
||
|
|
s.ConfigureManagedSync(m.SyncNext)
|
||
|
|
s.ConfigureProjection(m)
|
||
|
|
due := func() { exec(`UPDATE call_memory_sync SET next_attempt_at=now() WHERE session_id=$1::uuid`, sid) }
|
||
|
|
if e = m.SyncNext(ctx); e == nil {
|
||
|
|
t.Fatal("failed upload reported successful")
|
||
|
|
}
|
||
|
|
fail = false
|
||
|
|
due()
|
||
|
|
if e = m.SyncNext(ctx); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
due()
|
||
|
|
if e = m.SyncNext(ctx); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
due()
|
||
|
|
if e = m.SyncNext(ctx); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if uploads != 2 {
|
||
|
|
t.Fatal("acknowledged upload repeated", uploads)
|
||
|
|
}
|
||
|
|
var n int
|
||
|
|
var id string
|
||
|
|
if e = p.QueryRow(ctx, `SELECT count(*),min(public_id::text) FROM curated_memory WHERE subject_id=$1::uuid`, subject).Scan(&n, &id); e != nil || n != 1 {
|
||
|
|
t.Fatal("import duplicate or foreign memory", n, e)
|
||
|
|
}
|
||
|
|
if e = s.ProjectNext(ctx); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
// A provider correction may refresh an untouched auto-memory.
|
||
|
|
cloudText = "喜欢不加糖的淡茶"
|
||
|
|
due()
|
||
|
|
if e = m.SyncNext(ctx); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
var contextText string
|
||
|
|
s.ConfigureDirect(callmemory.DirectOptions{CallbackURL: "https://example.com", CallbackSecret: []byte(strings.Repeat("k", 32)), Prepare: func(_ context.Context, gotSubject, bot, session, text string) error {
|
||
|
|
if gotSubject != subject {
|
||
|
|
t.Fatal("wrong preparation elder")
|
||
|
|
}
|
||
|
|
contextText = text
|
||
|
|
return nil
|
||
|
|
}})
|
||
|
|
next := uuid.NewString()
|
||
|
|
d := deviceauth.Identity{ID: device, TenantID: tenant, BindingGeneration: 1}
|
||
|
|
if _, e = s.DirectRequest(ctx, d, "start", callmemory.DirectInput{SessionID: next}); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if !strings.Contains(contextText, "淡茶") || strings.Contains(contextText, "串人") {
|
||
|
|
t.Fatal("next call did not receive scoped memory")
|
||
|
|
}
|
||
|
|
if _, e = s.DirectRequest(ctx, d, "end", callmemory.DirectInput{SessionID: next, Reason: "hangup", SDKStopped: true}); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||
|
|
portal := saas.NewService(p, audit.NewRepository(), nil, nil)
|
||
|
|
visible, e := portal.ListSubjectMemories(ctx, principal, elder, "")
|
||
|
|
if e != nil || len(visible) != 1 || visible[0].ConfirmedBy != "" {
|
||
|
|
t.Fatal("mini-program cannot list automatically extracted memory", e, len(visible))
|
||
|
|
}
|
||
|
|
text := "喜欢温开水"
|
||
|
|
if _, e = portal.UpdateMemory(ctx, principal, id, "managed-test-edit", 2, saas.UpdateMemoryInput{Content: &text, Reason: "更正测试"}); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if e = s.ProjectNext(ctx); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if cloudText != text {
|
||
|
|
t.Fatal("edit not projected")
|
||
|
|
}
|
||
|
|
if e = s.ProjectNext(ctx); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
due()
|
||
|
|
if e = m.SyncNext(ctx); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if e = p.QueryRow(ctx, `SELECT count(*) FROM call_utterance WHERE session_id=$1::uuid`, sid).Scan(&n); e != nil || n != 2 {
|
||
|
|
t.Fatal("chat records lost", e)
|
||
|
|
}
|
||
|
|
// Use the actual user deletion API; repeated import cannot resurrect it.
|
||
|
|
if _, e = portal.RequestMemoryDeletion(ctx, principal, id, "测试删除", "managed-test-delete", 3); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
due()
|
||
|
|
if e = m.SyncNext(ctx); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
var status string
|
||
|
|
if e = p.QueryRow(ctx, `SELECT status FROM curated_memory WHERE public_id=$1::uuid`, id).Scan(&status); e != nil || status != "PENDING_DELETE" {
|
||
|
|
t.Fatal("deleted record resurrected", status, e)
|
||
|
|
}
|
||
|
|
// Exact deletion projection and its confirmed absence; no second model involved.
|
||
|
|
r := callmemory.ProjectionRecord{ID: id, Subject: subject, Tenant: tenant, Status: "PENDING_DELETE", Policy: "NEVER"}
|
||
|
|
if _, e = m.Project(ctx, r, "", "", false); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if !deleted {
|
||
|
|
t.Fatal("remote deletion missing")
|
||
|
|
}
|
||
|
|
result, e := m.Project(ctx, r, "", "", true)
|
||
|
|
if e != nil || !result.Visible {
|
||
|
|
t.Fatal("cleanup acknowledgement missing", e)
|
||
|
|
}
|
||
|
|
deleted = false
|
||
|
|
exec(`UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='personalized_memory'`, subject)
|
||
|
|
due()
|
||
|
|
if e = m.SyncNext(ctx); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if !deleted {
|
||
|
|
t.Fatal("withdrawal did not clean managed cloud scope")
|
||
|
|
}
|
||
|
|
}
|