@@ -0,0 +1,189 @@
|
||||
// Package vikingmemory integrates Volcano's managed memory collection, not OpenViking.
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
)
|
||||
|
||||
const AssistantID = "platform_companion_v1"
|
||||
|
||||
type Options struct {
|
||||
BaseURL, ResourceID, APIKey, EventType, ProfileType string
|
||||
Timeout time.Duration
|
||||
}
|
||||
type Client struct {
|
||||
options Options
|
||||
http *http.Client
|
||||
}
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
RoleID string `json:"role_id"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
UserID []string `json:"user_id"`
|
||||
AssistantID []string `json:"assistant_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Score float64 `json:"score"`
|
||||
MemoryInfo map[string]json.RawMessage `json:"memory_info"`
|
||||
Content string `json:"-"`
|
||||
}
|
||||
|
||||
func NewClient(options Options) (*Client, error) {
|
||||
if options.BaseURL == "" {
|
||||
options.BaseURL = "https://api-knowledgebase.mlp.cn-beijing.volces.com"
|
||||
}
|
||||
u, e := url.Parse(options.BaseURL)
|
||||
if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return nil, errors.New("Viking requires an HTTPS endpoint")
|
||||
}
|
||||
if options.ResourceID == "" || strings.TrimSpace(options.APIKey) == "" || options.EventType == "" || options.ProfileType == "" || options.Timeout <= 0 {
|
||||
return nil, errors.New("Viking resource, key, event/profile types and timeout are required")
|
||||
}
|
||||
options.BaseURL = strings.TrimRight(options.BaseURL, "/")
|
||||
options.APIKey = strings.TrimSpace(options.APIKey)
|
||||
return &Client{options: options, http: &http.Client{Timeout: options.Timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
|
||||
}
|
||||
func UserID(tenantID, subjectID string) string {
|
||||
digest := sha256.Sum256([]byte(tenantID + ":" + subjectID))
|
||||
return "u_" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
func SessionID(conversationID string, batch int) string {
|
||||
return "c_" + strings.ReplaceAll(conversationID, "-", "") + fmt.Sprintf("_%d", batch)
|
||||
}
|
||||
func (c *Client) call(ctx context.Context, path string, payload map[string]any, target any) error {
|
||||
payload["resource_id"] = c.options.ResourceID
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.options.BaseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.options.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return outbox.Retryable(errors.New("Viking request unavailable"))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Code *int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
err = json.NewDecoder(io.LimitReader(resp.Body, 2*1024*1024)).Decode(&result)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 || err != nil || result.Code == nil || *result.Code != 0 {
|
||||
code := -1
|
||||
if result.Code != nil {
|
||||
code = *result.Code
|
||||
}
|
||||
failure := fmt.Errorf("Viking %s HTTP %d code %d", path, resp.StatusCode, code)
|
||||
if resp.StatusCode == 429 || resp.StatusCode >= 500 || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
|
||||
return outbox.Retryable(failure)
|
||||
}
|
||||
return failure
|
||||
}
|
||||
if target != nil {
|
||||
if err := json.Unmarshal(result.Data, target); err != nil {
|
||||
return errors.New("Viking returned invalid data")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *Client) AddSession(ctx context.Context, sessionID, userID string, messages []Message, when int64) error {
|
||||
if sessionID == "" || userID == "" || len(messages) == 0 || len(messages) > 100 {
|
||||
return errors.New("invalid Viking session")
|
||||
}
|
||||
return c.call(ctx, "/api/memory/session/add", map[string]any{"session_id": sessionID, "messages": messages, "metadata": map[string]any{"default_user_id": userID, "default_assistant_id": AssistantID, "time": when}, "extract_memory_type": []string{c.options.EventType, c.options.ProfileType}}, nil)
|
||||
}
|
||||
func (c *Client) Search(ctx context.Context, userID, query string, limit int) ([]Item, error) {
|
||||
if userID == "" || limit < 1 || limit > 5 {
|
||||
return nil, errors.New("Viking user and bounded limit are required")
|
||||
}
|
||||
items := []Item{}
|
||||
remaining := 800
|
||||
for _, kind := range []struct{ path, rule string }{{"profile", c.options.ProfileType}, {"event", c.options.EventType}} {
|
||||
payload := map[string]any{"filter": map[string]any{"user_id": []string{userID}, "assistant_id": []string{AssistantID}, "memory_type": []string{kind.rule}}, "limit": limit}
|
||||
if strings.TrimSpace(query) != "" {
|
||||
payload["query"] = query
|
||||
}
|
||||
var data struct {
|
||||
Items []Item `json:"result_list"`
|
||||
}
|
||||
if err := c.call(ctx, "/api/memory/"+kind.path+"/search", payload, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range data.Items {
|
||||
if !contains(item.UserID, userID) || !contains(item.AssistantID, AssistantID) {
|
||||
continue
|
||||
}
|
||||
parts := []string{}
|
||||
for _, key := range []string{"summary", "user_profile"} {
|
||||
raw := item.MemoryInfo[key]
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(raw, &text) != nil {
|
||||
text = string(raw)
|
||||
}
|
||||
if text != "" && text != "{}" {
|
||||
parts = append(parts, text)
|
||||
}
|
||||
}
|
||||
content := []rune(strings.Join(parts, ";"))
|
||||
if len(content) > 400 {
|
||||
content = content[:400]
|
||||
}
|
||||
if len(content) > remaining {
|
||||
content = content[:remaining]
|
||||
}
|
||||
if len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
remaining -= len(content)
|
||||
item.Content = string(content)
|
||||
items = append(items, item)
|
||||
if len(items) >= limit || remaining <= 0 {
|
||||
return items, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func contains(values []string, expected string) bool {
|
||||
for _, v := range values {
|
||||
if v == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Client) DeleteUser(ctx context.Context, userID string) error {
|
||||
if userID == "" {
|
||||
return errors.New("Viking deletion requires an exact user")
|
||||
}
|
||||
for _, kind := range []string{"event", "profile"} {
|
||||
if err := c.call(ctx, "/api/memory/"+kind+"/batch_delete", map[string]any{"delete_type": "full", "filter": map[string]any{"user_id": []string{userID}, "assistant_id": []string{AssistantID}}}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testClient(t *testing.T, handler http.HandlerFunc) *Client {
|
||||
t.Helper()
|
||||
server := httptest.NewTLSServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
c, err := NewClient(Options{BaseURL: server.URL, ResourceID: "mem-test", APIKey: "test-secret", EventType: "event_v1", ProfileType: "profile_v1", Timeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.http = server.Client()
|
||||
return c
|
||||
}
|
||||
func TestSearchBindsUserAndRejectsForeignResults(t *testing.T) {
|
||||
calls := 0
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
var input map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&input)
|
||||
filter := input["filter"].(map[string]any)
|
||||
if filter["user_id"].([]any)[0] != "alice" || filter["assistant_id"].([]any)[0] != AssistantID || input["resource_id"] != "mem-test" {
|
||||
t.Errorf("incorrect scope: %v", input)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-secret" {
|
||||
t.Error("missing auth")
|
||||
}
|
||||
fmt.Fprint(w, `{"code":0,"data":{"result_list":[{"id":"foreign","user_id":["bob"],"assistant_id":["platform_companion_v1"],"memory_info":{"summary":"must not return"}},{"id":"ours","user_id":["alice"],"assistant_id":["platform_companion_v1"],"memory_info":{"summary":"喜欢茶"}}]}}`)
|
||||
})
|
||||
items, err := c.Search(context.Background(), "alice", "茶", 2)
|
||||
if err != nil || len(items) != 2 || items[0].ID != "ours" || calls != 2 {
|
||||
t.Fatalf("items=%v calls=%d err=%v", items, calls, err)
|
||||
}
|
||||
if _, err = c.Search(context.Background(), "", "", 1); err == nil {
|
||||
t.Fatal("empty identity accepted")
|
||||
}
|
||||
}
|
||||
func TestVikingErrorsDoNotLeakServerBodyOrKey(t *testing.T) {
|
||||
for _, status := range []int{200, 401, 429, 503} {
|
||||
t.Run(fmt.Sprint(status), func(t *testing.T) {
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprint(w, `{"code":900,"message":"test-secret private transcript"}`)
|
||||
})
|
||||
err := c.AddSession(context.Background(), "c_test", "alice", []Message{{Role: "user", Content: "test"}}, 1)
|
||||
if err == nil || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "transcript") {
|
||||
t.Fatalf("unsafe error %v", err)
|
||||
}
|
||||
if outbox.IsRetryable(err) != (status != 401) {
|
||||
t.Fatalf("retry classification %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestStableNamespacedIdentityAndSessionIDs(t *testing.T) {
|
||||
if UserID("a", "x") == UserID("b", "x") || UserID("a", "x") == UserID("a", "y") {
|
||||
t.Fatal("identity collision")
|
||||
}
|
||||
id := SessionID("12345678-1234-1234-1234-123456789abc", 1)
|
||||
if !regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,127}$`).MatchString(id) || id == SessionID("12345678-1234-1234-1234-123456789abc", 0) {
|
||||
t.Fatal(id)
|
||||
}
|
||||
}
|
||||
func TestDeleteUsesExactUserForBothMemoryTypes(t *testing.T) {
|
||||
calls := 0
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
var input map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&input)
|
||||
f := input["filter"].(map[string]any)
|
||||
if f["user_id"].([]any)[0] != "alice" {
|
||||
t.Error("unscoped delete")
|
||||
}
|
||||
fmt.Fprint(w, `{"code":0}`)
|
||||
})
|
||||
if err := c.DeleteUser(context.Background(), ""); err == nil || calls != 0 {
|
||||
t.Fatal("empty deletion accepted")
|
||||
}
|
||||
if err := c.DeleteUser(context.Background(), "alice"); err != nil || calls != 2 {
|
||||
t.Fatal(err, calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const SyncEvent = "conversation.viking_sync_requested"
|
||||
const eligibleSQL = `SELECT EXISTS(SELECT 1 FROM subject_profile sp WHERE sp.id=$2::uuid AND sp.tenant_id=$1::uuid AND sp.status='active'
|
||||
AND NOT EXISTS(SELECT 1 FROM privacy_request pr WHERE pr.tenant_id=sp.tenant_id AND pr.subject_id=sp.id AND pr.request_type='DELETE')
|
||||
AND EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=sp.tenant_id AND cr.subject_id=sp.id AND cr.consent_type='personalized_memory' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))
|
||||
AND (NOT EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=sp.tenant_id AND cr.subject_id=sp.id AND cr.consent_type='transcript_storage')
|
||||
OR EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=sp.tenant_id AND cr.subject_id=sp.id AND cr.consent_type='transcript_storage' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))))`
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
client *Client
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, client *Client) *Service {
|
||||
return &Service{pool: pool, client: client}
|
||||
}
|
||||
func (s *Service) Eligible(ctx context.Context, tenant, subject string) (bool, error) {
|
||||
var ok bool
|
||||
err := s.pool.QueryRow(ctx, eligibleSQL, tenant, subject).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
func (s *Service) Search(ctx context.Context, tenant, subject, query string, limit int) ([]Item, error) {
|
||||
ok, err := s.Eligible(ctx, tenant, subject)
|
||||
if err != nil || !ok {
|
||||
return []Item{}, err
|
||||
}
|
||||
items, err := s.client.Search(ctx, UserID(tenant, subject), query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Recheck after network I/O so a concurrent withdrawal cannot expose results.
|
||||
ok, err = s.Eligible(ctx, tenant, subject)
|
||||
if err != nil || !ok {
|
||||
return []Item{}, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
func (s *Service) PrepareForDevice(ctx context.Context, tenant, device string) (string, string, error) {
|
||||
var subject string
|
||||
err := s.pool.QueryRow(ctx, `SELECT b.subject_id::text FROM device_binding b JOIN device d ON d.id=b.device_id AND d.tenant_id=b.tenant_id WHERE b.device_id=$1::uuid AND b.tenant_id=$2::uuid AND b.unbound_at IS NULL AND d.status='registered'`, device, tenant).Scan(&subject)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// Empty query retrieves recent memories and the user profile at session start.
|
||||
items, err := s.Search(ctx, tenant, subject, "", 4)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
parts := []string{}
|
||||
for _, item := range items {
|
||||
parts = append(parts, item.Content)
|
||||
}
|
||||
return subject, strings.Join(parts, "\n"), nil
|
||||
}
|
||||
func (s *Service) Enqueue(ctx context.Context, event outbox.Event) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
var tenant string
|
||||
var generation int64
|
||||
err = tx.QueryRow(ctx, `SELECT c.tenant_id::text,COALESCE(max(ct.sequence_no),0) FROM conversation c LEFT JOIN conversation_turn ct ON ct.conversation_id=c.id WHERE c.id=$1::uuid AND c.status IN ('COMPLETED','FAILED') GROUP BY c.tenant_id`, event.AggregateID).Scan(&tenant, &generation)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if generation == 0 {
|
||||
return nil
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]int64{"generation_version": generation})
|
||||
key := fmt.Sprintf("viking-v1:%s:%d", event.AggregateID, generation)
|
||||
_, err = outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenant, AggregateType: "conversation", AggregateID: event.AggregateID, EventType: SyncEvent, DeduplicationKey: &key, Payload: payload})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) Handle(ctx context.Context, event outbox.Event) error {
|
||||
var tenant, subject, publicID string
|
||||
var when time.Time
|
||||
var frozen []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT c.tenant_id::text,c.subject_id::text,c.public_id::text,COALESCE(c.started_at,c.created_at),cs.effective_consents FROM conversation c JOIN consent_snapshot cs ON cs.id=c.consent_snapshot_id AND cs.tenant_id=c.tenant_id WHERE c.id=$1::uuid AND c.status IN ('COMPLETED','FAILED')`, event.AggregateID).Scan(&tenant, &subject, &publicID, &when, &frozen)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
consents := map[string]bool{}
|
||||
if json.Unmarshal(frozen, &consents) != nil || !consents["personalized_memory"] || !consents["transcript_storage"] {
|
||||
return nil
|
||||
}
|
||||
ok, err := s.Eligible(ctx, tenant, subject)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
// Serialize external writes and erasure per user, including retries from another worker.
|
||||
conn, err := s.pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Release()
|
||||
user := UserID(tenant, subject)
|
||||
if _, err = conn.Exec(ctx, `SELECT pg_advisory_lock(hashtextextended($1,0))`, user); err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtextextended($1,0))`, user)
|
||||
ok, err = s.Eligible(ctx, tenant, subject)
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
rows, err := conn.Query(ctx, `SELECT speaker,final_text,start_at FROM conversation_turn WHERE conversation_id=$1::uuid ORDER BY sequence_no,id`, event.AggregateID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages := []Message{}
|
||||
hasUser, hasAssistant := false, false
|
||||
for rows.Next() {
|
||||
var speaker, text string
|
||||
var at time.Time
|
||||
if err = rows.Scan(&speaker, &text, &at); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
role, id := "", user
|
||||
if speaker == "subject" {
|
||||
role = "user"
|
||||
hasUser = true
|
||||
} else if speaker == "agent" {
|
||||
role = "assistant"
|
||||
id = AssistantID
|
||||
hasAssistant = true
|
||||
}
|
||||
if role != "" && strings.TrimSpace(text) != "" {
|
||||
messages = append(messages, Message{Role: role, Content: text, RoleID: id, Time: at.UnixMilli()})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasUser || !hasAssistant || len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
serialized, _ := json.Marshal(messages)
|
||||
sum := sha256.Sum256(serialized)
|
||||
digest := hex.EncodeToString(sum[:])
|
||||
var already bool
|
||||
err = conn.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM audit_event WHERE tenant_id=$1::uuid AND resource_id=$2::uuid AND action='memory.viking.session_sync' AND detail->>'digest'=$3 AND detail->>'collection'=$4)`, tenant, publicID, digest, s.client.options.ResourceID).Scan(&already)
|
||||
if err != nil || already {
|
||||
return err
|
||||
}
|
||||
for start := 0; start < len(messages); start += 100 {
|
||||
end := min(start+100, len(messages))
|
||||
if err = s.client.AddSession(ctx, SessionID(publicID, start/100), user, messages[start:end], when.UnixMilli()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ok, err = s.Eligible(ctx, tenant, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return s.client.DeleteUser(ctx, user)
|
||||
}
|
||||
detail, _ := json.Marshal(map[string]any{"digest": digest, "collection": s.client.options.ResourceID, "message_count": len(messages), "user_id": user})
|
||||
_, err = conn.Exec(ctx, `INSERT INTO audit_event(tenant_id,actor_type,action,resource_type,resource_id,result,detail) VALUES($1::uuid,'system','memory.viking.session_sync','conversation',$2::uuid,'success',$3::jsonb)`, tenant, publicID, detail)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) DeleteSubject(ctx context.Context, tenant, subject string) error {
|
||||
if tenant == "" || subject == "" {
|
||||
return errors.New("Viking erasure requires tenant and subject")
|
||||
}
|
||||
conn, err := s.pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Release()
|
||||
user := UserID(tenant, subject)
|
||||
if _, err = conn.Exec(ctx, `SELECT pg_advisory_lock(hashtextextended($1,0))`, user); err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtextextended($1,0))`, user)
|
||||
return s.client.DeleteUser(ctx, user)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionSyncDedupAndConsent(t *testing.T) {
|
||||
db := os.Getenv("TEST_DATABASE_URL")
|
||||
if db == "" {
|
||||
t.Skip("TEST_DATABASE_URL required")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
tenant := "10000000-0000-0000-0000-000000000001"
|
||||
subject := "10000000-0000-0000-0000-000000000401"
|
||||
cs, csPublic, cx, cxPublic, conv, convPublic := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString()
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
pool.Exec(ctx, `DELETE FROM outbox_event WHERE aggregate_id=$1::uuid`, conv)
|
||||
pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, convPublic)
|
||||
pool.Exec(ctx, `DELETE FROM conversation_turn WHERE conversation_id=$1::uuid`, conv)
|
||||
pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, conv)
|
||||
pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, cx)
|
||||
pool.Exec(ctx, `DELETE FROM consent_snapshot WHERE id=$1::uuid`, cs)
|
||||
}()
|
||||
exec(`INSERT INTO consent_snapshot(id,public_id,tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,1,'{"personalized_memory":true,"transcript_storage":true,"story_extraction":true}','[]')`, cs, csPublic, tenant, subject)
|
||||
exec(`INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,1,$5::uuid,'[]','viking-test','s2s_hybrid',$6,'test')`, cx, cxPublic, tenant, subject, cs, strings.Repeat("a", 64))
|
||||
exec(`INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,'10000000-0000-0000-0000-000000000201',$4::uuid,'10000000-0000-0000-0000-000000000501',$5::uuid,$6::uuid,$7,'s2s_hybrid','transcript_only','COMPLETED',now())`, conv, convPublic, tenant, subject, cs, cx, "viking-"+conv)
|
||||
exec(`INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type) VALUES($1::uuid,$2::uuid,$3::uuid,$4,1,'subject',now(),now(),'我喜欢茶','provider'),($1::uuid,$2::uuid,$3::uuid,$5,2,'agent',now(),now(),'记住了','provider')`, tenant, subject, conv, "u"+conv, "a"+conv)
|
||||
calls := 0
|
||||
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
var payload map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&payload)
|
||||
if r.URL.Path != "/api/memory/session/add" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
metadata := payload["metadata"].(map[string]any)
|
||||
if metadata["default_user_id"] != UserID(tenant, subject) || len(payload["messages"].([]any)) != 2 {
|
||||
t.Errorf("unexpected payload %v", payload)
|
||||
}
|
||||
fmt.Fprint(w, `{"code":0}`)
|
||||
})
|
||||
service := NewService(pool, client)
|
||||
event := outbox.Event{AggregateID: conv, Payload: []byte(`{"generation_version":2}`)}
|
||||
if err := service.Enqueue(ctx, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.Enqueue(ctx, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var queued int
|
||||
pool.QueryRow(ctx, `SELECT count(*) FROM outbox_event WHERE aggregate_id=$1::uuid AND event_type=$2`, conv, SyncEvent).Scan(&queued)
|
||||
if queued != 1 {
|
||||
t.Fatalf("queued=%d", queued)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := service.Handle(ctx, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("duplicate upstream writes=%d", calls)
|
||||
}
|
||||
// A late assistant correction must replace this session using the same ID.
|
||||
exec(`UPDATE conversation_turn SET final_text='好的,我会记住' WHERE conversation_id=$1::uuid AND speaker='agent'`, conv)
|
||||
if err := service.Handle(ctx, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatal(calls)
|
||||
}
|
||||
exec(`UPDATE consent_snapshot SET effective_consents='{"transcript_storage":true}' WHERE id=$1::uuid`, cs)
|
||||
exec(`UPDATE conversation_turn SET final_text='撤回后不可写入' WHERE conversation_id=$1::uuid AND speaker='agent'`, conv)
|
||||
if err := service.Handle(ctx, event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatal("frozen consent ignored")
|
||||
}
|
||||
// Unknown or cross-tenant identities cannot retrieve or call the provider.
|
||||
items, err := service.Search(ctx, uuid.NewString(), subject, "茶", 2)
|
||||
if err != nil || len(items) != 0 || calls != 2 {
|
||||
t.Fatalf("scope gate items=%v err=%v calls=%d", items, err, calls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user