package ai import ( "context" "errors" "strconv" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/inference" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Memory is the per tenant episodic and semantic memory, stored in the tenant's own database (ai_memory) // and therefore physically isolated from every other tenant. Episodic memory records what happened (a // craft note about a turn); semantic memory holds a recallable fact with an embedding. No demographic // data may enter memory: every write is eeo guarded. Memory about a candidate carries the candidate id, // so the right to erasure wipes it (ForgetCandidate), the same way anonymize wipes the resume vector. type Memory struct { db *tenant.DB client *inference.Client } // NewMemory builds the per tenant memory over the tenant database and the inference client (for semantic // recall embeddings). func NewMemory(db *tenant.DB, client *inference.Client) *Memory { return &Memory{db: db, client: client} } // Store writes one memory. candidateID may be nil for a non candidate craft note. content is eeo guarded. // A semantic memory is embedded so it can be recalled by similarity. func (m *Memory) Store(ctx context.Context, candidateID *uuid.UUID, kind, content string) error { if m.db == nil { return errors.New("ai: no database") } if kind != "episodic" && kind != "semantic" { return errors.New("ai: memory kind must be episodic or semantic") } if err := ForbidEEO(content); err != nil { return err } var vecLit any if kind == "semantic" && m.client != nil { emb, err := m.client.Embed(ctx, []string{content}) if err != nil { return err } vecLit = vectorLiteral(emb.Embeddings[0]) } return m.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, "INSERT INTO ai_memory (organization_id, candidate_id, kind, content, embedding) "+ "VALUES (NULLIF(current_setting('app.current_org_id', true),'')::uuid, $1, $2, $3, $4::vector)", candidateID, kind, content, vecLit) return err }) } // ForgetCandidate is the erasure hook: it deletes every memory about a candidate, so anonymize leaves no // semantic trace in memory. It is called from the right to erasure path alongside the candidate // anonymize. Idempotent. func (m *Memory) ForgetCandidate(ctx context.Context, candidateID uuid.UUID) error { if m.db == nil { return errors.New("ai: no database") } return m.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, "DELETE FROM ai_memory WHERE candidate_id = $1", candidateID) return err }) } // Recall returns the semantically closest memories to a query (RLS scoped, eeo guarded). It is the // retrieval side of memory augmented generation. func (m *Memory) Recall(ctx context.Context, query string, limit int) ([]string, error) { if m.db == nil || m.client == nil { return nil, errors.New("ai: memory recall needs a database and a client") } if err := ForbidEEO(query); err != nil { return nil, err } if limit <= 0 || limit > 50 { limit = 5 } emb, err := m.client.Embed(ctx, []string{query}) if err != nil { return nil, err } lit := vectorLiteral(emb.Embeddings[0]) var out []string err = m.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { rows, err := tx.Query(ctx, "SELECT content FROM ai_memory WHERE embedding IS NOT NULL ORDER BY embedding <=> $1::vector LIMIT $2", lit, limit) if err != nil { return err } defer rows.Close() for rows.Next() { var c string if err := rows.Scan(&c); err != nil { return err } out = append(out, c) } return rows.Err() }) return out, err } // vectorLiteral formats a 384 dim vector as a pgvector text literal for the $::vector cast. func vectorLiteral(v []float32) string { b := make([]byte, 0, len(v)*8+2) b = append(b, '[') for i, x := range v { if i > 0 { b = append(b, ',') } b = strconv.AppendFloat(b, float64(x), 'f', -1, 32) } b = append(b, ']') return string(b) }