Files

285 lines
13 KiB
Go
Raw Permalink Normal View History

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, &current, &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)
}