feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -4,21 +4,21 @@ package vikingmemory
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/memoryidentity"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
)
|
||||
|
||||
const AssistantID = "platform_companion_v1"
|
||||
const AssistantID = memoryidentity.AssistantID
|
||||
|
||||
type Options struct {
|
||||
BaseURL, ResourceID, CollectionName, APIKey, EventType, ProfileType string
|
||||
@@ -60,8 +60,7 @@ func NewClient(options Options) (*Client, error) {
|
||||
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[:])
|
||||
return memoryidentity.UserID(tenantID, subjectID)
|
||||
}
|
||||
func SessionID(conversationID string, batch int) string {
|
||||
return "c_" + strings.ReplaceAll(conversationID, "-", "") + fmt.Sprintf("_%d", batch)
|
||||
@@ -94,7 +93,12 @@ func (c *Client) call(ctx context.Context, path string, payload map[string]any,
|
||||
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) {
|
||||
// A valid nonzero business response requires classification/operator review.
|
||||
// Only an unreadable successful response has an uncertain write outcome.
|
||||
if resp.StatusCode == http.StatusTooManyRequests || code == 1000029 {
|
||||
return outbox.RetryAfter(failure, vikingRetryDelay(resp.Header.Get("Retry-After"), time.Now()))
|
||||
}
|
||||
if resp.StatusCode >= 500 || (resp.StatusCode >= 200 && resp.StatusCode < 300 && (err != nil || result.Code == nil)) {
|
||||
return outbox.Retryable(failure)
|
||||
}
|
||||
return failure
|
||||
@@ -106,6 +110,18 @@ func (c *Client) call(ctx context.Context, path string, payload map[string]any,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func vikingRetryDelay(header string, now time.Time) time.Duration {
|
||||
// Rate limits without a usable Retry-After get a full cooldown minute.
|
||||
// This prevents the short generic retry schedule exhausting the job.
|
||||
if seconds, err := strconv.ParseInt(strings.TrimSpace(header), 10, 64); err == nil && seconds > 0 {
|
||||
return time.Duration(min(seconds, int64((24*time.Hour)/time.Second))) * time.Second
|
||||
}
|
||||
if deadline, err := http.ParseTime(header); err == nil && deadline.After(now) {
|
||||
return min(deadline.Sub(now), 24*time.Hour)
|
||||
}
|
||||
return time.Minute
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestVikingErrorsDoNotLeakServerBodyOrKey(t *testing.T) {
|
||||
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) {
|
||||
if outbox.IsRetryable(err) != (status == 429 || status >= 500) {
|
||||
t.Fatalf("retry classification %v", err)
|
||||
}
|
||||
})
|
||||
@@ -92,3 +92,40 @@ func TestDeleteUsesExactUserForBothMemoryTypes(t *testing.T) {
|
||||
t.Fatal(err, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVikingDeletionRateLimitPreservesCooldownAndFailure(t *testing.T) {
|
||||
for _, status := range []int{http.StatusTooManyRequests, http.StatusOK} {
|
||||
t.Run(fmt.Sprint(status), func(t *testing.T) {
|
||||
calls := 0
|
||||
c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.Header().Set("Retry-After", "120")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprint(w, `{"code":1000029,"message":"private provider body"}`)
|
||||
})
|
||||
err := c.DeleteUser(context.Background(), "alice")
|
||||
if err == nil || !outbox.IsRetryable(err) || outbox.RetryDelay(err) != 2*time.Minute || calls != 1 {
|
||||
t.Fatalf("rate limit must remain an incomplete deletion: calls=%d delay=%s err=%v", calls, outbox.RetryDelay(err), err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "private") {
|
||||
t.Fatal("provider body leaked into retained error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVikingRetryDelayParsesSecondsAndDates(t *testing.T) {
|
||||
now := time.Date(2026, 10, 4, 14, 0, 0, 0, time.UTC)
|
||||
for _, tt := range []struct {
|
||||
header string
|
||||
want time.Duration
|
||||
}{
|
||||
{"", time.Minute}, {"invalid", time.Minute}, {"-1", time.Minute},
|
||||
{"120", 2 * time.Minute}, {now.Add(90 * time.Second).Format(http.TimeFormat), 90 * time.Second},
|
||||
{now.Add(-time.Minute).Format(http.TimeFormat), time.Minute}, {"9223372036854775807", 24 * time.Hour},
|
||||
} {
|
||||
if got := vikingRetryDelay(tt.header, now); got != tt.want {
|
||||
t.Errorf("Retry-After %q = %s, want %s", tt.header, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"data-terminal-cloud/internal/callmemory"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChatMemories returns untruncated native-chat records and checks scope again on
|
||||
// every provider result. Memoir extraction and projection assistants are excluded.
|
||||
func (c *Client) ChatMemories(ctx context.Context, user, kind, query string, limit int, start, end int64) ([]callmemory.VikingRecord, bool, error) {
|
||||
if user == "" || kind != "event" && kind != "profile" || limit < 1 || limit > 5000 {
|
||||
return nil, false, errors.New("invalid chat memory scope")
|
||||
}
|
||||
rule := c.options.EventType
|
||||
if kind == "profile" {
|
||||
rule = c.options.ProfileType
|
||||
}
|
||||
filter := map[string]any{"user_id": []string{user}, "assistant_id": []string{AssistantID}, "memory_type": []string{rule}}
|
||||
if start > 0 {
|
||||
filter["start_time"] = start
|
||||
}
|
||||
if end > 0 {
|
||||
filter["end_time"] = end
|
||||
}
|
||||
payload := map[string]any{"filter": filter, "limit": limit}
|
||||
if query != "" {
|
||||
payload["query"] = query
|
||||
}
|
||||
var data struct {
|
||||
Items []struct {
|
||||
Item
|
||||
Time int64 `json:"time"`
|
||||
Types []string `json:"memory_type"`
|
||||
} `json:"result_list"`
|
||||
}
|
||||
if e := c.call(ctx, "/api/memory/"+kind+"/search", payload, &data); e != nil {
|
||||
return nil, false, e
|
||||
}
|
||||
out := []callmemory.VikingRecord{}
|
||||
limited := len(data.Items) >= limit
|
||||
for _, item := range data.Items {
|
||||
// Shared records cannot be deleted through an individual elder's console.
|
||||
if item.ID == "" || len(item.UserID) != 1 || item.UserID[0] != user || len(item.AssistantID) != 1 || item.AssistantID[0] != AssistantID || !contains(item.Types, rule) {
|
||||
// A filtered or malformed response cannot prove a deleted ID is absent.
|
||||
limited = true
|
||||
continue
|
||||
}
|
||||
content := []string{}
|
||||
for _, key := range []string{"summary", "user_profile"} {
|
||||
if raw := item.MemoryInfo[key]; len(raw) > 0 {
|
||||
var text string
|
||||
if json.Unmarshal(raw, &text) != nil {
|
||||
text = string(raw)
|
||||
}
|
||||
if strings.TrimSpace(text) != "" {
|
||||
content = append(content, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(content) == 0 {
|
||||
b, _ := json.MarshalIndent(item.MemoryInfo, "", " ")
|
||||
content = append(content, string(b))
|
||||
}
|
||||
r := callmemory.VikingRecord{ID: item.ID, Kind: kind, Time: item.Time, SessionID: item.SessionID, Content: strings.Join(content, "\n"), MemoryInfo: item.MemoryInfo}
|
||||
raw, e := json.Marshal(r)
|
||||
if e != nil {
|
||||
return nil, false, e
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
r.Digest = hex.EncodeToString(sum[:])
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, limited, nil
|
||||
}
|
||||
func (c *Client) DeleteChatMemory(ctx context.Context, kind, id string) error {
|
||||
if (kind != "event" && kind != "profile") || id == "" {
|
||||
return errors.New("invalid chat memory deletion")
|
||||
}
|
||||
return c.call(ctx, "/api/memory/"+kind+"/delete", map[string]any{kind + "_id": id}, nil)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConsoleScopeFullContentAndProviderFilters(t *testing.T) {
|
||||
long := strings.Repeat("完整正文", 500)
|
||||
var calls []map[string]any
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var p map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&p)
|
||||
calls = append(calls, p)
|
||||
if r.URL.Path == "/api/memory/event/delete" {
|
||||
if p["event_id"] != "owned" {
|
||||
t.Error(p)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0}`))
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/api/memory/event/search" {
|
||||
t.Error(r.URL.Path)
|
||||
}
|
||||
items := []map[string]any{}
|
||||
for _, scope := range []struct{ id, user, assistant string }{{"owned", "user-a", AssistantID}, {"foreign-user", "user-b", AssistantID}, {"memoir", "user-a", ManagedAssistantID}} {
|
||||
items = append(items, map[string]any{"id": scope.id, "user_id": []string{scope.user}, "assistant_id": []string{scope.assistant}, "memory_type": []string{"event_v1"}, "time": int64(123000), "memory_info": map[string]any{"summary": long, "original_messages": []string{"合成原文"}}})
|
||||
}
|
||||
items = append(items, map[string]any{"id": "shared", "user_id": []string{"user-a", "user-b"}, "assistant_id": []string{AssistantID}, "memory_type": []string{"event_v1"}})
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"result_list": items}})
|
||||
}))
|
||||
defer server.Close()
|
||||
c, e := NewClient(Options{BaseURL: server.URL, ResourceID: "synthetic", APIKey: "fake-key", EventType: "event_v1", ProfileType: "profile_v1", Timeout: 1000000000})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
c.http = server.Client()
|
||||
records, limited, e := c.ChatMemories(context.Background(), "user-a", "event", "家乡", 100, 10, 20)
|
||||
if e != nil || !limited || len(records) != 1 || records[0].Content != long || len(records[0].Digest) != 64 {
|
||||
t.Fatal(records, limited, e)
|
||||
}
|
||||
filter := calls[0]["filter"].(map[string]any)
|
||||
if filter["start_time"] != float64(10) || filter["end_time"] != float64(20) || calls[0]["query"] != "家乡" || calls[0]["resource_id"] != "synthetic" {
|
||||
t.Fatal(calls)
|
||||
}
|
||||
if e = c.DeleteChatMemory(context.Background(), "event", records[0].ID); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = c.DeleteChatMemory(context.Background(), "unexpected", "owned"); e == nil || len(calls) != 2 {
|
||||
t.Fatal("invalid kind reached provider")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/callmemory"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Managed struct {
|
||||
pool *pgxpool.Pool
|
||||
client *Client
|
||||
}
|
||||
|
||||
func NewManaged(p *pgxpool.Pool, c *Client) *Managed { return &Managed{p, c} }
|
||||
|
||||
// Upload only ended calls with the currently authorized, retained transcript.
|
||||
// A late final subtitle creates another revision; the stable remote session ID
|
||||
// replaces that session instead of accumulating duplicate uploads on retry.
|
||||
func (m *Managed) SyncNext(ctx context.Context) error {
|
||||
_, err := m.pool.Exec(ctx, `INSERT INTO call_memory_sync(session_id,transcript_revision)
|
||||
SELECT id,transcript_revision FROM call_session c WHERE budget_snapshot->>'protocol'='DIRECT-CHAT-1'
|
||||
AND ended_at IS NOT NULL AND transcript_state IN ('PARTIAL','COMPLETE') AND transcript_revision>0 AND memory_mode<>'OFF'
|
||||
AND managed_call_memory_authorized(subject_id) ON CONFLICT DO NOTHING`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := m.pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Release()
|
||||
var sid, tenant, subject, state string
|
||||
var revision int64
|
||||
var uploaded *time.Time
|
||||
err = conn.QueryRow(ctx, `SELECT j.session_id::text,c.tenant_id::text,c.subject_id::text,j.transcript_revision,j.state,j.uploaded_at
|
||||
FROM call_memory_sync j JOIN call_session c ON c.id=j.session_id
|
||||
WHERE (j.state<>'SKIPPED' OR (j.uploaded_at IS NOT NULL AND NOT managed_call_memory_authorized(c.subject_id))) AND j.next_attempt_at<=now() ORDER BY j.next_attempt_at LIMIT 1`).Scan(&sid, &tenant, &subject, &revision, &state, &uploaded)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := UserID(tenant, subject)
|
||||
var locked bool
|
||||
if err = conn.QueryRow(ctx, `SELECT pg_try_advisory_lock(hashtextextended($1,0))`, user).Scan(&locked); err != nil || !locked {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
c, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if _, e := conn.Exec(c, `SELECT pg_advisory_unlock(hashtextextended($1,0))`, user); e != nil {
|
||||
conn.Conn().Close(c)
|
||||
}
|
||||
}()
|
||||
// Re-read after the lock: another worker may already have handled this job.
|
||||
err = conn.QueryRow(ctx, `SELECT j.state,j.uploaded_at FROM call_memory_sync j JOIN call_session c ON c.id=j.session_id WHERE j.session_id=$1::uuid AND j.transcript_revision=$2 AND (j.state<>'SKIPPED' OR (j.uploaded_at IS NOT NULL AND NOT managed_call_memory_authorized(c.subject_id))) AND j.next_attempt_at<=now()`, sid, revision).Scan(&state, &uploaded)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var allowed, suppressed bool
|
||||
var current int64
|
||||
err = conn.QueryRow(ctx, `SELECT managed_call_memory_authorized(c.subject_id),c.transcript_revision,
|
||||
EXISTS(SELECT 1 FROM memory_source_suppression s CROSS JOIN LATERAL jsonb_array_elements(s.source_refs) r WHERE s.subject_id=c.subject_id AND r->>'session_id'=c.id::text)
|
||||
FROM call_session c WHERE id=$1::uuid`, sid).Scan(&allowed, ¤t, &suppressed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
skip := func() error {
|
||||
_, e := conn.Exec(ctx, `UPDATE call_memory_sync SET state='SKIPPED',last_error=NULL,next_attempt_at=now()+interval '5 minutes' WHERE session_id=$1::uuid AND transcript_revision=$2`, sid, revision)
|
||||
return e
|
||||
}
|
||||
if !allowed {
|
||||
// A withdrawal must also remove managed provider data; do not merely hide it.
|
||||
if err = m.client.deleteManagedUser(ctx, user); err != nil {
|
||||
return m.retry(ctx, conn, sid, revision, "VIKING_CLEANUP_FAILED")
|
||||
}
|
||||
return skip()
|
||||
}
|
||||
if revision != current || suppressed {
|
||||
return skip()
|
||||
}
|
||||
remote := SessionID(sid, 0)
|
||||
if uploaded == nil {
|
||||
rows, e := conn.Query(ctx, `SELECT speaker,text_content,source_at FROM call_utterance WHERE session_id=$1::uuid ORDER BY source_at,utterance_id`, sid)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
messages := []Message{}
|
||||
hasUser := false
|
||||
hasAssistant := false
|
||||
for rows.Next() {
|
||||
var speaker, text string
|
||||
var at time.Time
|
||||
if e = rows.Scan(&speaker, &text, &at); e != nil {
|
||||
rows.Close()
|
||||
return e
|
||||
}
|
||||
if strings.TrimSpace(text) == "" || text == "[deleted]" {
|
||||
continue
|
||||
}
|
||||
role, id := "", user
|
||||
if speaker == "USER" {
|
||||
role = "user"
|
||||
hasUser = true
|
||||
} else if speaker == "ASSISTANT" {
|
||||
role = "assistant"
|
||||
id = ManagedAssistantID
|
||||
hasAssistant = true
|
||||
}
|
||||
if role != "" {
|
||||
messages = append(messages, Message{Role: role, RoleID: id, Content: text, Time: at.UnixMilli()})
|
||||
}
|
||||
}
|
||||
e = rows.Err()
|
||||
rows.Close()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !hasUser || !hasAssistant {
|
||||
return skip()
|
||||
}
|
||||
if len(messages) > 1000 {
|
||||
return m.retry(ctx, conn, sid, revision, "TRANSCRIPT_TOO_LARGE")
|
||||
}
|
||||
if e = m.client.AddManagedSession(ctx, remote, user, messages, messages[0].Time); e != nil {
|
||||
return m.retry(ctx, conn, sid, revision, "VIKING_UPLOAD_FAILED")
|
||||
}
|
||||
_, err = conn.Exec(ctx, `UPDATE call_memory_sync SET state='UPLOADED',uploaded_at=now(),attempts=attempts+1,last_error=NULL,next_attempt_at=now()+interval '10 seconds' WHERE session_id=$1::uuid AND transcript_revision=$2`, sid, revision)
|
||||
return err
|
||||
}
|
||||
items, err := m.client.ManagedEvents(ctx, user, remote)
|
||||
if err != nil {
|
||||
return m.retry(ctx, conn, sid, revision, "VIKING_RETRIEVAL_FAILED")
|
||||
}
|
||||
if err = m.importEvents(ctx, conn, sid, tenant, subject, revision, items); err != nil {
|
||||
return err
|
||||
}
|
||||
// Extraction is asynchronous. Keep polling, including after an empty result;
|
||||
// an accepted upload is not proof that extraction completed or found a fact.
|
||||
delay := 15
|
||||
if time.Since(*uploaded) > 10*time.Minute {
|
||||
delay = 300
|
||||
}
|
||||
if len(items) > 0 {
|
||||
state = "SYNCED"
|
||||
} else {
|
||||
state = "UPLOADED"
|
||||
}
|
||||
_, err = conn.Exec(ctx, `UPDATE call_memory_sync SET state=$3,last_error=NULL,next_attempt_at=now()+make_interval(secs=>$4) WHERE session_id=$1::uuid AND transcript_revision=$2`, sid, revision, state, delay)
|
||||
return err
|
||||
}
|
||||
func (m *Managed) retry(ctx context.Context, conn *pgxpool.Conn, sid string, revision int64, code string) error {
|
||||
_, e := conn.Exec(ctx, `UPDATE call_memory_sync SET attempts=attempts+1,last_error=$3,next_attempt_at=now()+make_interval(secs=>LEAST(300,15*(attempts+1))) WHERE session_id=$1::uuid AND transcript_revision=$2`, sid, revision, code)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return errors.New(code)
|
||||
}
|
||||
func (m *Managed) importEvents(ctx context.Context, conn *pgxpool.Conn, sid, tenant, subject string, revision int64, items []Item) error {
|
||||
tx, e := conn.Begin(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
var allowed bool
|
||||
e = tx.QueryRow(ctx, `SELECT managed_call_memory_authorized(us.subject_id) AND c.transcript_revision=$3 AND NOT EXISTS(SELECT 1 FROM memory_source_suppression s CROSS JOIN LATERAL jsonb_array_elements(s.source_refs) r WHERE s.subject_id=us.subject_id AND r->>'session_id'=$2) FROM memory_user_state us JOIN call_session c ON c.subject_id=us.subject_id WHERE us.subject_id=$1::uuid AND c.id=$2::uuid FOR UPDATE OF us`, subject, sid, revision).Scan(&allowed)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !allowed {
|
||||
return nil
|
||||
}
|
||||
for _, item := range items {
|
||||
sum := sha256.Sum256([]byte(item.Content))
|
||||
digest := hex.EncodeToString(sum[:])
|
||||
id := uuid.NewSHA1(uuid.NameSpaceOID, []byte("viking-managed:"+m.client.options.ResourceID+":"+subject+":"+item.ID)).String()
|
||||
var localVersion, importedVersion int64
|
||||
var status, policy, previousDigest string
|
||||
e = tx.QueryRow(ctx, `SELECT cm.version,cm.status,cm.proactive_policy,o.imported_version,o.provider_digest FROM curated_memory cm JOIN managed_memory_origin o ON o.memory_id=cm.id WHERE cm.id=$1::uuid FOR UPDATE OF cm`, id).Scan(&localVersion, &status, &policy, &importedVersion, &previousDigest)
|
||||
exists := e == nil
|
||||
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
|
||||
return e
|
||||
}
|
||||
// Only untouched automatic records may follow a provider correction.
|
||||
// Never overwrite a user's edit, disable, or deletion.
|
||||
if exists && (localVersion != importedVersion || status != "ACTIVE" || policy != "ALLOWED" || previousDigest == digest) {
|
||||
continue
|
||||
}
|
||||
var refs []byte
|
||||
if e = tx.QueryRow(ctx, `SELECT jsonb_build_array(jsonb_build_object('type','provider_memory','session_id',$1::text,'provider_id',$2::text)) || COALESCE(jsonb_agg(jsonb_build_object('type','user_turn','session_id',session_id::text,'utterance_id',utterance_id,'source_revision',source_revision)),'[]'::jsonb) FROM call_utterance WHERE session_id=$1::uuid`, sid, item.ID).Scan(&refs); e != nil {
|
||||
return e
|
||||
}
|
||||
if !exists {
|
||||
if _, e = tx.Exec(ctx, `INSERT INTO curated_memory(id,public_id,tenant_id,subject_id,status,memory_type,proactive_policy,sensitivity,entity_key,statement_basis,source_time) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'ACTIVE','EVENT','ALLOWED','normal',$4,'MODEL_EXTRACTED',now())`, id, tenant, subject, "viking:"+item.ID); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
vid := uuid.NewString()
|
||||
if _, e = tx.Exec(ctx, `INSERT INTO memory_version(id,memory_id,version_no,content,source_refs,change_reason,content_digest) VALUES($1::uuid,$2::uuid,$6,$3,$4::jsonb,'viking-session-extraction',$5)`, vid, id, item.Content, refs, digest, localVersion+1); e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e = tx.Exec(ctx, `UPDATE curated_memory SET current_version_id=$2::uuid,version=$3,updated_at=now() WHERE id=$1::uuid`, id, vid, localVersion+1); e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e = tx.Exec(ctx, `INSERT INTO managed_memory_origin(memory_id,subject_id,session_id,provider_id,provider_digest,imported_version) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$5,$6) ON CONFLICT(memory_id) DO UPDATE SET provider_digest=EXCLUDED.provider_digest,imported_version=EXCLUDED.imported_version`, id, subject, sid, item.ID, digest, localVersion+1); e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e = tx.Exec(ctx, `INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'pgvector','PENDING')`, tenant, subject, id, vid); e != nil {
|
||||
return e
|
||||
}
|
||||
dedupe := "memory-index-v1:" + vid
|
||||
payload, _ := json.Marshal(map[string]string{"memory_version_id": vid})
|
||||
if _, e = outbox.NewStore(m.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenant, AggregateType: "curated_memory", AggregateID: id, EventType: "memory.index_requested", DeduplicationKey: &dedupe, Payload: payload}); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// The platform mirror is the serving source for pure S2S. Native MemoryConfig
|
||||
// stays off, so cloud extraction cannot bypass local edits or deletions.
|
||||
func (m *Managed) Project(ctx context.Context, r callmemory.ProjectionRecord, priorKind, priorID string, reconcile bool) (callmemory.ProjectionResult, error) {
|
||||
result := callmemory.ProjectionResult{Kind: "event", ID: r.ID, Visible: true}
|
||||
var sid, id string
|
||||
err := m.pool.QueryRow(ctx, `SELECT o.session_id::text,o.provider_id FROM managed_memory_origin o JOIN curated_memory cm ON cm.id=o.memory_id WHERE cm.public_id=$1::uuid AND cm.subject_id=$2::uuid AND cm.tenant_id=$3::uuid`, r.ID, r.Subject, r.Tenant).Scan(&sid, &id)
|
||||
// Manually authored platform memories do not require a provider mirror to serve.
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
var direct bool
|
||||
if e := m.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM call_session WHERE subject_id=$1::uuid AND budget_snapshot->>'protocol'='DIRECT-CHAT-1')`, r.Subject).Scan(&direct); e != nil {
|
||||
return result, e
|
||||
}
|
||||
if !direct {
|
||||
return NewProjectionAdapter(m.pool, m.client).Project(ctx, r, priorKind, priorID, reconcile)
|
||||
}
|
||||
result.Kind = "local"
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.ID = id
|
||||
items, err := m.client.ManagedEvents(ctx, UserID(r.Tenant, r.Subject), SessionID(sid, 0))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.ID != id {
|
||||
continue
|
||||
}
|
||||
serve := r.Status == "ACTIVE" && r.Policy == "ALLOWED" && (r.Kind != "OPEN_ITEM" || r.ItemStatus == "OPEN")
|
||||
if !serve {
|
||||
err = m.client.call(ctx, "/api/memory/event/delete", map[string]any{"event_id": id}, nil)
|
||||
} else if item.Content != r.Content {
|
||||
// original_messages is returned by search but rejected by event/update.
|
||||
err = m.client.call(ctx, "/api/memory/event/update", map[string]any{"event_id": id, "memory_info": map[string]string{"summary": r.Content}}, nil)
|
||||
} else {
|
||||
return result, nil
|
||||
}
|
||||
result.Visible = false
|
||||
return result, err
|
||||
}
|
||||
// Absence means provider cleanup is complete. Local records remain authoritative.
|
||||
return result, nil
|
||||
}
|
||||
func (c *Client) deleteManagedUser(ctx context.Context, user string) error {
|
||||
if user == "" {
|
||||
return errors.New("managed user required")
|
||||
}
|
||||
return c.call(ctx, "/api/memory/event/batch_delete", map[string]any{"delete_type": "full", "filter": map[string]any{"user_id": []string{user}, "assistant_id": []string{ManagedAssistantID}}}, nil)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Events are the managed unit: profiles are not independently regenerated from
|
||||
// a deleted/corrected event. Every event is mirrored into the editable platform
|
||||
// store before it can be used in a voice prompt.
|
||||
const ManagedAssistantID = "voicelink_managed_v1"
|
||||
|
||||
func (c *Client) AddManagedSession(ctx context.Context, session, user string, messages []Message, at int64) error {
|
||||
if session == "" || user == "" || len(messages) == 0 || len(messages) > 1000 {
|
||||
return errors.New("invalid managed session")
|
||||
}
|
||||
return c.call(ctx, "/api/memory/session/add", map[string]any{
|
||||
"session_id": session, "messages": messages, "store_file": false,
|
||||
"metadata": map[string]any{"default_user_id": user, "default_assistant_id": ManagedAssistantID, "time": at},
|
||||
"extract_memory_type": []string{c.options.EventType},
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) ManagedEvents(ctx context.Context, user, session string) ([]Item, error) {
|
||||
if user == "" || session == "" {
|
||||
return nil, errors.New("managed scope required")
|
||||
}
|
||||
var data struct {
|
||||
Items []Item `json:"result_list"`
|
||||
}
|
||||
err := c.call(ctx, "/api/memory/event/search", map[string]any{"filter": map[string]any{
|
||||
"user_id": []string{user}, "assistant_id": []string{ManagedAssistantID}, "session_id": []string{session}, "memory_type": []string{c.options.EventType},
|
||||
}, "limit": 1000}, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data.Items) >= 1000 {
|
||||
return nil, errors.New("managed result limit reached")
|
||||
}
|
||||
out := []Item{}
|
||||
for _, item := range data.Items {
|
||||
if item.ID == "" || !contains(item.UserID, user) || !contains(item.AssistantID, ManagedAssistantID) || item.SessionID != session {
|
||||
continue
|
||||
}
|
||||
var summary string
|
||||
if json.Unmarshal(item.MemoryInfo["summary"], &summary) != nil {
|
||||
continue
|
||||
}
|
||||
summary = strings.TrimSpace(summary)
|
||||
if summary == "" || len([]rune(summary)) > 4000 {
|
||||
continue
|
||||
}
|
||||
item.Content = summary
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/callmemory"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const ProjectionAssistantID = "platform_companion_v2"
|
||||
|
||||
type ProjectionAdapter struct {
|
||||
pool *pgxpool.Pool
|
||||
client *Client
|
||||
}
|
||||
type projectionSchema struct {
|
||||
EventType string `json:"event_type"`
|
||||
ProfileType string `json:"profile_type"`
|
||||
IDField string `json:"memory_id_field"`
|
||||
VersionField string `json:"record_version_field"`
|
||||
OperationField string `json:"operation_key_field"`
|
||||
EventContent string `json:"event_content_field"`
|
||||
ProfileContent string `json:"profile_content_field"`
|
||||
ProfileGroups bool `json:"profile_groups_verified"`
|
||||
}
|
||||
|
||||
func NewProjectionAdapter(pool *pgxpool.Pool, client *Client) *ProjectionAdapter {
|
||||
return &ProjectionAdapter{pool, client}
|
||||
}
|
||||
func (a *ProjectionAdapter) schema(ctx context.Context) (projectionSchema, error) {
|
||||
var p projectionSchema
|
||||
var raw []byte
|
||||
e := a.pool.QueryRow(ctx, `SELECT projection_schema FROM call_provider_profile WHERE published AND capabilities->>'H08'='true' AND COALESCE(evidence->>'H08','')<>'' ORDER BY created_at DESC LIMIT 1`).Scan(&raw)
|
||||
if e != nil {
|
||||
return p, &callmemory.Error{Status: 409, Code: "PROJECTION_SCHEMA_UNVERIFIED"}
|
||||
}
|
||||
if e = json.Unmarshal(raw, &p); e != nil {
|
||||
return p, e
|
||||
}
|
||||
if p.EventType == "" || p.IDField == "" || p.VersionField == "" || p.OperationField == "" || p.EventContent == "" {
|
||||
return p, &callmemory.Error{Status: 409, Code: "PROJECTION_SCHEMA_UNVERIFIED"}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Project uses provider CRUD APIs, never AddSession. An uncertain add is only
|
||||
// reconciled by its persistent operation key; absence in a query is not permission
|
||||
// to create again. A full 5000-item result cannot prove absence and is blocked.
|
||||
func (a *ProjectionAdapter) Project(ctx context.Context, r callmemory.ProjectionRecord, priorKind, priorID string, reconcileOnly bool) (callmemory.ProjectionResult, error) {
|
||||
out := callmemory.ProjectionResult{}
|
||||
p, e := a.schema(ctx)
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
kind := "event"
|
||||
rule := p.EventType
|
||||
field := p.EventContent
|
||||
if r.Kind == "PROFILE" {
|
||||
if p.ProfileType == "" || p.ProfileContent == "" || !p.ProfileGroups {
|
||||
return out, &callmemory.Error{Status: 409, Code: "PROFILE_PROJECTION_UNVERIFIED"}
|
||||
}
|
||||
kind = "profile"
|
||||
rule = p.ProfileType
|
||||
field = p.ProfileContent
|
||||
}
|
||||
if priorID != "" && priorKind != "" && priorKind != kind {
|
||||
return out, &callmemory.Error{Status: 409, Code: "PROJECTION_KIND_CHANGE_REQUIRES_CLEANUP"}
|
||||
}
|
||||
user := UserID(r.Tenant, r.Subject)
|
||||
operation := r.ID + ":" + strconv.FormatInt(r.Version, 10)
|
||||
serve := r.Status == "ACTIVE" && r.Policy == "ALLOWED" && (r.Kind != "OPEN_ITEM" || r.ItemStatus == "OPEN")
|
||||
items, complete, e := a.list(ctx, kind, rule, user)
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
matches := []Item{}
|
||||
for _, item := range items {
|
||||
var id string
|
||||
_ = json.Unmarshal(item.MemoryInfo[p.IDField], &id)
|
||||
if id == r.ID {
|
||||
matches = append(matches, item)
|
||||
}
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return out, &callmemory.Error{Status: 409, Code: "DUPLICATE_PROVIDER_PROJECTION"}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
priorID = matches[0].ID
|
||||
out.ID = priorID
|
||||
out.Kind = kind
|
||||
var version int64
|
||||
_ = json.Unmarshal(matches[0].MemoryInfo[p.VersionField], &version)
|
||||
var content string
|
||||
_ = json.Unmarshal(matches[0].MemoryInfo[field], &content)
|
||||
if serve && version == r.Version && content == r.Content {
|
||||
out.Visible = true
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
if !serve {
|
||||
if reconcileOnly && priorID == "" && len(matches) == 0 {
|
||||
return out, errors.New("uncertain create must be reconciled before proving deletion")
|
||||
}
|
||||
if len(matches) == 0 && complete {
|
||||
out.Kind = kind
|
||||
out.ID = priorID
|
||||
out.Visible = true
|
||||
return out, nil
|
||||
}
|
||||
if priorID == "" {
|
||||
return out, &callmemory.Error{Status: 409, Code: "PROVIDER_ABSENCE_NOT_PROVEN"}
|
||||
}
|
||||
e = a.client.call(ctx, "/api/memory/"+kind+"/delete", map[string]any{kind + "_id": priorID}, nil)
|
||||
if e != nil {
|
||||
return out, projectionError(e)
|
||||
}
|
||||
out.ID = priorID
|
||||
out.Kind = kind
|
||||
return out, nil
|
||||
}
|
||||
if reconcileOnly && priorID == "" {
|
||||
return out, errors.New("provider add outcome remains unknown")
|
||||
}
|
||||
if !complete && priorID == "" {
|
||||
return out, &callmemory.Error{Status: 409, Code: "PROVIDER_SCOPE_TOO_LARGE"}
|
||||
}
|
||||
info := map[string]any{p.IDField: r.ID, p.VersionField: r.Version, p.OperationField: operation, field: r.Content}
|
||||
payload := map[string]any{"memory_info": info}
|
||||
path := "/api/memory/" + kind + "/update"
|
||||
if priorID != "" {
|
||||
payload[kind+"_id"] = priorID
|
||||
} else {
|
||||
path = "/api/memory/" + kind + "/add"
|
||||
payload[kind+"_type"] = rule
|
||||
payload["user_id"] = user
|
||||
payload["assistant_id"] = ProjectionAssistantID
|
||||
if kind == "profile" {
|
||||
payload["group_id"] = r.ID
|
||||
payload["is_upsert"] = false
|
||||
} else {
|
||||
payload["update_profiles"] = []string{}
|
||||
}
|
||||
}
|
||||
var data map[string]json.RawMessage
|
||||
e = a.client.call(ctx, path, payload, &data)
|
||||
if e != nil {
|
||||
return out, projectionError(e)
|
||||
}
|
||||
if priorID == "" {
|
||||
_ = json.Unmarshal(data[kind+"_id"], &priorID)
|
||||
if strings.TrimSpace(priorID) == "" {
|
||||
return out, errors.New("provider acknowledgement has no object identity")
|
||||
}
|
||||
}
|
||||
out.Kind = kind
|
||||
out.ID = priorID
|
||||
out.Visible = false
|
||||
return out, nil
|
||||
}
|
||||
func (a *ProjectionAdapter) list(ctx context.Context, kind, rule, user string) ([]Item, bool, error) {
|
||||
var data struct {
|
||||
Items []Item `json:"result_list"`
|
||||
}
|
||||
e := a.client.call(ctx, "/api/memory/"+kind+"/search", map[string]any{"filter": map[string]any{"user_id": []string{user}, "assistant_id": []string{ProjectionAssistantID}, "memory_type": []string{rule}}, "limit": 5000}, &data)
|
||||
if e != nil {
|
||||
return nil, false, projectionError(e)
|
||||
}
|
||||
for _, item := range data.Items {
|
||||
if item.ID == "" || !contains(item.UserID, user) || !contains(item.AssistantID, ProjectionAssistantID) {
|
||||
return nil, false, &callmemory.Error{Status: 409, Code: "PROVIDER_SCOPE_VIOLATION"}
|
||||
}
|
||||
}
|
||||
return data.Items, len(data.Items) < 5000, nil
|
||||
}
|
||||
func projectionError(e error) error {
|
||||
if outbox.IsRetryable(e) {
|
||||
return e
|
||||
}
|
||||
return &callmemory.Error{Status: 409, Code: "PROVIDER_SCHEMA_OR_AUTH_REJECTED", Message: fmt.Sprintf("Provider rejected projection: %T", e)}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package vikingmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"data-terminal-cloud/internal/callmemory"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCuratedProjectionUnknownCreateNeverDuplicatesOrProvesDeletion(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL required")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, e := pgxpool.New(ctx, url)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer pool.Close()
|
||||
id := "test-" + uuid.NewString()
|
||||
defer pool.Exec(ctx, `DELETE FROM call_provider_profile WHERE id=$1`, id)
|
||||
_, e = pool.Exec(ctx, `INSERT INTO call_provider_profile(id,protocol_version,sdk_version,bot_id,model_version,transport,audio_profile,vendor_template,field_mapping,capabilities,evidence,projection_schema,published) VALUES($1,'CALL-MEMORY-1.1','test','test','test','RTC','{}','{}','{}','{"H08":true}','{"H08":"synthetic test only"}','{"event_type":"curated","memory_id_field":"memory_id","record_version_field":"record_version","operation_key_field":"operation_key","event_content_field":"content"}',true)`, id)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
adds := 0
|
||||
var stored map[string]any
|
||||
visible := false
|
||||
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, "/search"):
|
||||
result := []any{}
|
||||
if visible && stored != nil {
|
||||
result = append(result, map[string]any{"id": "event-1", "user_id": []string{UserID("t", "s")}, "assistant_id": []string{ProjectionAssistantID}, "memory_info": stored})
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"result_list": result}})
|
||||
case strings.HasSuffix(r.URL.Path, "/add"):
|
||||
adds++
|
||||
stored = in["memory_info"].(map[string]any)
|
||||
fmt.Fprint(w, `{"code":0,"data":{}}`)
|
||||
default:
|
||||
t.Error("unexpected external operation", r.URL.Path)
|
||||
http.Error(w, "unexpected", 400)
|
||||
}
|
||||
})
|
||||
a := NewProjectionAdapter(pool, c)
|
||||
record := callmemory.ProjectionRecord{Tenant: "t", Subject: "s", ID: uuid.NewString(), Kind: "EVENT", Content: "喜欢喝茶", Policy: "ALLOWED", Status: "ACTIVE", Version: 1}
|
||||
_, e = a.Project(ctx, record, "", "", false)
|
||||
if e == nil || adds != 1 {
|
||||
t.Fatal("missing add identity not treated as unknown", e, adds)
|
||||
}
|
||||
_, e = a.Project(ctx, record, "", "", true)
|
||||
if e == nil || adds != 1 {
|
||||
t.Fatal("uncertain create duplicated", e, adds)
|
||||
}
|
||||
record.Status = "PENDING_DELETE"
|
||||
_, e = a.Project(ctx, record, "", "", true)
|
||||
if e == nil {
|
||||
t.Fatal("empty search falsely proved deletion of uncertain write")
|
||||
}
|
||||
visible = true
|
||||
record.Status = "ACTIVE"
|
||||
result, e := a.Project(ctx, record, "", "", true)
|
||||
if e != nil || !result.Visible || adds != 1 {
|
||||
t.Fatal("failed to reconcile original event", result, e, adds)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
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 memory_user_state mus WHERE mus.subject_id=sp.id)
|
||||
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')
|
||||
@@ -241,5 +242,8 @@ func (s *Service) DeleteSubject(ctx context.Context, tenant, subject string) err
|
||||
return err
|
||||
}
|
||||
defer conn.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtextextended($1,0))`, user)
|
||||
if err := s.client.deleteManagedUser(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.client.DeleteUser(ctx, user)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user