-
Notifications
You must be signed in to change notification settings - Fork 1
Add retrieval evals and supersede old master packs on distill #127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| # people_map — next fix design note (not yet implemented) | ||
|
|
||
| ## Problem | ||
| `extractPeopleMentions` in `lib/services/memory-distillation-service.ts` treats any capitalized | ||
| token-sequence as a person, then filters with a hand-maintained stoplist | ||
| (`PERSON_NAME_STOPWORDS`). PRs #123/#124 added dedup, alias canonicalization, caps, and a growing | ||
| stoplist — but a stoplist can never enumerate every capitalized non-name word. Live `people_map` | ||
| still surfaces junk like `Money`, `Strong`, `Clean`, `Boracay`, `Codex`, `Build`, `Googlebot`, | ||
| `Uploaded Janine Tan`, `Ong Messenger`. | ||
|
|
||
| **This is whack-a-mole. Do not add more stoplist words.** | ||
|
|
||
| ## Decision | ||
| The correct next step is a **known-people whitelist (Option A)** — deterministic, no model calls, | ||
| no embeddings, no gated intelligence. NER (Option B) is deferred because it implies model behavior | ||
| (`PANDORA_ENABLE_MODEL_CALLS`, gated). | ||
|
|
||
| ## Proposed shape (for a future PR) | ||
| 1. **Static config `config/known-people.json`** (per-namespace), e.g.: | ||
| ```json | ||
| { "au": { "canonical": ["Janine Tan", "Mang Jun", "Joven Del Rosario"], "aliases": { "Janine": "Janine Tan", "Jana": "Jana", "Jan Jan": "Janine Tan" } }, | ||
| "real_life": { "canonical": ["Joven", "Patty"], "aliases": {} } } | ||
| ``` | ||
| 2. `extractPeopleMentions` becomes whitelist-first: | ||
| - a matched capitalized span is a **person** only if it (or an alias) is in the namespace's | ||
| `canonical`/`aliases` set; | ||
| - everything else goes to a new **`candidate_entities`** array (name + event_ids), *not* | ||
| `people_map`, so unknown capitalized nouns are surfaced for review but never asserted as people; | ||
| - keep existing dedup, alias canonicalization, and caps. | ||
| 3. Optionally seed the whitelist from confirmed `memory_profiles` of type `person_profile` | ||
| (subject_key = confirmed person) so the whitelist grows only from reviewed data. | ||
| 4. Tests: whitelisted names appear in `people_map`; unknown nouns go to `candidate_entities`; | ||
| aliases canonicalize; AU/real_life whitelists stay separate. | ||
|
|
||
| ## Why not implement now | ||
| There is no existing static known-people config to reuse, and inventing the whitelist contents is a | ||
| data/product decision (who counts as a tracked person, especially given AU fictionalization rules and | ||
| real-person privacy boundaries). Blocked on that decision; documented here so the next session can | ||
| pick it up without re-deriving it. Future NER remains gated. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -147,6 +147,21 @@ export async function createContextPack(client: MemoryBridgeDbClient, pack: Omit | |
| if (!pf.ok) return pf; | ||
| const result = await client.from<MemoryContextPack>("memory_context_packs").insert({ ...pack, user_id: pf.data.userId, namespace: pf.data.namespace, status: pack.status ?? "active" }).select("*").single(); | ||
| if (result.error || !result.data) return { ok: false, blockers: ["context_pack_write_failed"], warnings: [result.error?.message ?? "unknown context pack write failure"], next_step: "Check memory_context_packs schema and RLS." }; | ||
| // Supersede-on-distill: keep exactly one active pack per (user, namespace, pack_type). Status-only | ||
| // and reversible — archives older active packs, never deletes rows, never touches memory_events, | ||
| // never crosses namespace or pack_type. Best-effort: a failure here only adds a warning. | ||
| const warnings: string[] = []; | ||
| if (result.data.status === "active") { | ||
| const superseded = await (client.from("memory_context_packs") | ||
| .update({ status: "archived", updated_at: new Date().toISOString() }) | ||
| .eq("user_id", pf.data.userId) | ||
| .eq("namespace", pf.data.namespace) | ||
| .eq("pack_type", result.data.pack_type) | ||
| .eq("status", "active") | ||
| .neq("id", result.data.id) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two distills for the same user/namespace/pack_type run concurrently, each insert returns an active row and then this update archives every other active row, including the other request's newly inserted pack. An interleaving like A insert, B insert, A archives B, then B archives A leaves no active pack while both callers returned an active result. Please make the supersede operation transactional/serialized or restrict it to rows older than the inserted pack so concurrent distills cannot retire each other. Useful? React with 👍 / 👎. |
||
| .select("id") as unknown as Promise<{ data: unknown[] | null; error: { message: string } | null }>); | ||
| if (superseded.error) warnings.push(`supersede_prior_packs_failed: ${superseded.error.message}`); | ||
| } | ||
| await audit(client, { userId: pf.data.userId, namespace: pf.data.namespace, action: "memory_context_pack_distilled", table: "memory_context_packs", recordId: result.data.id, metadata: { packId: result.data.id, packType: result.data.pack_type } }); | ||
| return { ok: true, data: result.data, blockers: [], warnings: [] }; | ||
| return { ok: true, data: result.data, blockers: [], warnings }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| /* eslint-disable @typescript-eslint/no-explicit-any */ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { getMemoryContextTool, getLatestContextPackTool } from "@/lib/services/pandora-mcp-tools"; | ||
| import { buildAdaptiveChatGptContext } from "@/lib/services/adaptive-chatgpt-context-service"; | ||
| import { createContextPack } from "@/lib/services/memory-bridge-service"; | ||
| import { runMcpAction } from "@/lib/services/pandora-mcp-action-envelope"; | ||
|
|
||
| // A small namespace-aware in-memory client: reads filter the store by the recorded eq/neq | ||
| // predicates, inserts assign an id, updates patch matching rows (and are recorded in `ops`). | ||
| type Row = Record<string, any>; | ||
| function makeClient(store: Record<string, Row[]>, ops: any[] = []) { | ||
| let counter = 0; | ||
| return { | ||
| from(table: string) { | ||
| const eqs: Record<string, any> = {}; | ||
| const neqs: Record<string, any> = {}; | ||
| let mode: "select" | "insert" | "update" = "select"; | ||
| let inserted: Row[] = []; | ||
| let patch: Row | null = null; | ||
| let limitN: number | undefined; | ||
| const src = () => (store[table] ??= []); | ||
| const matches = (row: Row) => | ||
| Object.entries(eqs).every(([k, v]) => row[k] === v) && Object.entries(neqs).every(([k, v]) => row[k] !== v); | ||
| const rows = () => { | ||
| const filtered = src().filter(matches); | ||
| return limitN != null ? filtered.slice(0, limitN) : filtered; | ||
| }; | ||
| const builder: any = { | ||
| select() { return builder; }, | ||
| insert(value: Row | Row[]) { mode = "insert"; inserted = (Array.isArray(value) ? value : [value]).map((r) => ({ id: r.id ?? `gen-${++counter}`, ...r })); return builder; }, | ||
| update(value: Row) { mode = "update"; patch = value; return builder; }, | ||
| eq(col: string, val: any) { eqs[col] = val; return builder; }, | ||
| neq(col: string, val: any) { neqs[col] = val; return builder; }, | ||
| order() { return builder; }, | ||
| limit(n: number) { limitN = n; return builder; }, | ||
| single() { | ||
| if (mode === "insert") { src().push(...inserted); return Promise.resolve({ data: inserted[0] ?? null, error: null }); } | ||
| return Promise.resolve({ data: rows()[0] ?? null, error: null }); | ||
| }, | ||
| then(resolve: any, reject: any) { | ||
| let data: any; | ||
| if (mode === "insert") { src().push(...inserted); data = inserted; } | ||
| else if (mode === "update") { | ||
| const affected = src().filter(matches); | ||
| for (const row of affected) Object.assign(row, patch); | ||
| ops.push({ table, patch, eqs: { ...eqs }, neqs: { ...neqs }, affected: affected.map((r) => r.id) }); | ||
| data = affected; | ||
| } else data = rows(); | ||
| return Promise.resolve({ data, error: null }).then(resolve, reject); | ||
| }, | ||
| }; | ||
| return builder; | ||
| }, | ||
| } as any; | ||
| } | ||
|
|
||
| const USER = "u-64110799"; | ||
| const mcpPrincipal = { ok: true as const, authType: "mcp_bearer_token" as const, userId: USER }; | ||
|
|
||
| function pack(namespace: string, extra: Row = {}): Row { | ||
| return { id: `pack-${namespace}`, user_id: USER, namespace, pack_type: "master", status: "active", title: "Pandora master context pack", summary: "", key_points: [], active_projects: [], people_map: [], decisions: [], risks: [], open_loops: [], generated_from_event_ids: [], created_at: "2026-07-03T00:00:00Z", ...extra }; | ||
| } | ||
| function evt(namespace: string, id: string, raw_text: string): Row { | ||
| return { id, user_id: USER, namespace, source: "chatgpt_user_direct", raw_text, extracted_summary: raw_text, status: "captured", importance: 8, sensitivity: "low", created_at: "2026-07-03T00:00:00Z" }; | ||
| } | ||
|
|
||
| function seededStore(): Record<string, Row[]> { | ||
| return { | ||
| memory_context_packs: [ | ||
| pack("au", { summary: "AU FICTIONALIZATION RULE — Janine Tan / Mang Jun canon", key_points: [{ point: "AU CONSENT / FICTIONALIZATION RULE — Janine Tan", event_id: "au1", source: "chatgpt_user_direct", importance: 10 }] }), | ||
| pack("real_life", { summary: "PANDORA MEMORY ROADMAP + PLP Pueblo La Perla Boracay", key_points: [{ point: "PANDORA MEMORY ROADMAP — user asked to save", event_id: "rl1", source: "chatgpt_user_direct", importance: 9 }] }), | ||
| ], | ||
| memory_events: [ | ||
| evt("au", "au1", "AU FICTIONALIZATION RULE — Janine Tan is a fictionalized character; Mang Jun archetype."), | ||
| evt("real_life", "rl1", "PANDORA MEMORY ROADMAP — user asked to save. PLP Pueblo La Perla Boracay resort project status."), | ||
| ], | ||
| memory_profiles: [], | ||
| memory_open_loops: [], | ||
| }; | ||
| } | ||
|
|
||
| describe("retrieval eval (#11)", () => { | ||
| it("AU query returns AU context and excludes real_life/PLP content", async () => { | ||
| const result = await getMemoryContextTool(makeClient(seededStore()), mcpPrincipal, { namespace: "au" }); | ||
| const text = JSON.stringify(result); | ||
| expect(result.namespace).toBe("au"); | ||
| expect(text).toContain("Janine"); | ||
| expect(text).toContain("FICTIONALIZATION"); // AU canon retrievable from AU | ||
| expect(text).not.toMatch(/PLP|Pueblo|ROADMAP/); | ||
| }); | ||
|
|
||
| it("real_life query returns Pandora/PLP context and excludes AU story content", async () => { | ||
| const result = await getMemoryContextTool(makeClient(seededStore()), mcpPrincipal, { namespace: "real_life" }); | ||
| const text = JSON.stringify(result); | ||
| expect(result.namespace).toBe("real_life"); | ||
| expect(text).toContain("ROADMAP"); // roadmap retrievable from real_life | ||
| expect(text).toMatch(/PLP|Pueblo/); | ||
| expect(text).not.toContain("Janine"); | ||
| }); | ||
|
|
||
| it("routing through the MCP boundary adds controlled envelope fields", async () => { | ||
| const client = makeClient(seededStore()); | ||
| const content = await runMcpAction(() => getMemoryContextTool(client, mcpPrincipal, { namespace: "au" })); | ||
| const body = JSON.parse(content.content[0].text); // must be valid JSON | ||
| expect(body.ok).toBe(true); | ||
| expect(body.request_id).toMatch(/^req_/); | ||
| expect(body.fallback_used).toBe(false); | ||
| expect(body.namespace).toBe("au"); | ||
| }); | ||
|
|
||
| it("falls back to latest context pack (fallback_used=true) when the primary read fails", async () => { | ||
| const client = makeClient(seededStore()); | ||
| const content = await runMcpAction( | ||
| () => Promise.reject(new Error("context_read_failed: primary down")), | ||
| { fallback: () => getLatestContextPackTool(client, mcpPrincipal, { namespace: "au" }) }, | ||
| ); | ||
| const body = JSON.parse(content.content[0].text); | ||
| expect(body.ok).toBe(true); | ||
| expect(body.fallback_used).toBe(true); | ||
| expect(body.warnings).toContain("primary_failed:context_read_failed"); | ||
| expect(JSON.stringify(body)).not.toContain("Pueblo"); // still AU-scoped | ||
| }); | ||
|
|
||
| it("caps the context payload (no giant/stream-breaking response)", async () => { | ||
| const store = seededStore(); | ||
| store.memory_context_packs[0].people_map = [{ name: "Janine Tan", event_ids: Array.from({ length: 800 }, (_, i) => `au-evt-${i}`), notes: ["n"] }]; | ||
| const result: any = await getMemoryContextTool(makeClient(store), mcpPrincipal, { namespace: "au" }); | ||
| expect(JSON.stringify(result.context_pack).length).toBeLessThanOrEqual(12000); | ||
| }); | ||
|
|
||
| it("adaptive context surfaces the refreshed operating profile (summary/confidence/preferences)", async () => { | ||
| const store = seededStore(); | ||
| store.memory_profiles = [{ | ||
| id: "prof-au", user_id: USER, namespace: "au", profile_type: "operating_profile", subject_key: "global", status: "active", | ||
| summary: "Adaptive au operating profile from 11 memories: 6 preferences", confidence: 0.88, | ||
| preferences: [{ text: "AU FICTIONALIZATION RULE — use a fictional alias", event_id: "au1" }], facts: [], decisions: [], | ||
| }]; | ||
| const ctx: any = await buildAdaptiveChatGptContext(makeClient(store), { user_id: USER, namespace: "au" }); | ||
| expect(ctx.adaptive_profile_summary).toContain("Adaptive au operating profile"); | ||
| expect(ctx.adaptive_profile_confidence).toBe(0.88); | ||
| expect(ctx.writing_rules.join(" ")).toContain("FICTIONALIZATION"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("supersede-on-distill", () => { | ||
| const bridgePrincipal = { ok: true as const, userId: USER, createdBy: USER, authType: "bridge_token" as const, operator: true }; | ||
| const runtime = { config: { memoryCaptureApiEnabled: false, memoryContextApiEnabled: true, memoryDistillationEnabled: true }, gates: { memoryCaptureApiEnabled: { envVar: "PANDORA_ENABLE_MCP_CAPTURE" }, memoryContextApiEnabled: { envVar: "PANDORA_ENABLE_MCP" }, memoryDistillationEnabled: { envVar: "PANDORA_ENABLE_MCP_DISTILLATION" } } } as any; | ||
| const newPack = (namespace: string) => ({ namespace, user_id: USER, pack_type: "master" as const, title: "t", summary: "s", key_points: [], active_projects: [], people_map: [], decisions: [], risks: [], open_loops: [], generated_from_event_ids: [] }); | ||
|
|
||
| it("archives older active master packs for the same namespace but keeps the newest active", async () => { | ||
| const store: Record<string, Row[]> = { | ||
| memory_context_packs: [ | ||
| { id: "au-old", user_id: USER, namespace: "au", pack_type: "master", status: "active" }, | ||
| { id: "au-daily", user_id: USER, namespace: "au", pack_type: "daily", status: "active" }, | ||
| { id: "rl-old", user_id: USER, namespace: "real_life", pack_type: "master", status: "active" }, | ||
| ], | ||
| audit_logs: [], | ||
| }; | ||
| const ops: any[] = []; | ||
| const result: any = await createContextPack(makeClient(store, ops), newPack("au") as any, bridgePrincipal, runtime); | ||
| expect(result.ok).toBe(true); | ||
|
|
||
| const auMasters = store.memory_context_packs.filter((p) => p.namespace === "au" && p.pack_type === "master"); | ||
| const activeAuMasters = auMasters.filter((p) => p.status === "active"); | ||
| expect(activeAuMasters).toHaveLength(1); // exactly one active au master (the new one) | ||
| expect(activeAuMasters[0].id).toBe(result.data.id); | ||
| expect(store.memory_context_packs.find((p) => p.id === "au-old")!.status).toBe("archived"); | ||
|
|
||
| // did NOT touch other pack types or the other namespace | ||
| expect(store.memory_context_packs.find((p) => p.id === "au-daily")!.status).toBe("active"); | ||
| expect(store.memory_context_packs.find((p) => p.id === "rl-old")!.status).toBe("active"); | ||
|
|
||
| // status-only archive, never a delete | ||
| expect(store.memory_context_packs.find((p) => p.id === "au-old")).toBeDefined(); | ||
| const supersedeOp = ops.find((o) => o.table === "memory_context_packs" && o.patch?.status === "archived"); | ||
| expect(supersedeOp).toBeDefined(); | ||
| expect(supersedeOp.eqs.status).toBe("active"); | ||
| expect(supersedeOp.eqs.pack_type).toBe("master"); | ||
| expect(supersedeOp.neqs.id).toBe(result.data.id); | ||
| }); | ||
|
|
||
| it("distilling real_life does not affect AU packs", async () => { | ||
| const store: Record<string, Row[]> = { | ||
| memory_context_packs: [ | ||
| { id: "au-keep", user_id: USER, namespace: "au", pack_type: "master", status: "active" }, | ||
| { id: "rl-old", user_id: USER, namespace: "real_life", pack_type: "master", status: "active" }, | ||
| ], | ||
| audit_logs: [], | ||
| }; | ||
| await createContextPack(makeClient(store), newPack("real_life") as any, bridgePrincipal, runtime); | ||
| expect(store.memory_context_packs.find((p) => p.id === "au-keep")!.status).toBe("active"); | ||
| expect(store.memory_context_packs.find((p) => p.id === "rl-old")!.status).toBe("archived"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this update archives any prior active packs, it mutates persisted
memory_context_packsrows without an audit entry for those archived record ids; the only audit write below records the newly inserted pack's id. That leaves the supersede status changes outside the repo's audit-backed memory-write trail and makes admin audit/readback unable to prove which packs were retired. Please append audit rows for the affected ids, ideally in the same transactional boundary as the archive update.Useful? React with 👍 / 👎.