From 72908e588f09bb9bc6de597ca87591f3f73a102f Mon Sep 17 00:00:00 2001 From: markjohnsonbanatao88 Date: Thu, 2 Jul 2026 19:09:39 +0000 Subject: [PATCH] Implement real adaptive-profile extraction + fix confidence_score 400 Makes the AU/real_life adaptive learning loop actually work instead of writing empty stub profiles. - Add deterministic adaptive-profile-extractor (no model calls / embeddings): classifies memory events into preferences, facts, decisions, open loops, risks and assigns a bounded confidence. - Wire refresh_adaptive_profiles to extract real content and upsert a populated versioned profile (was an empty "refresh requested" stub). - Fix hybrid retrieval 400: select confidence_score (the real Phase 5D column) instead of the non-existent `confidence`; map it back so ranking still uses the stored score. - Surface the extracted operating profile in get_adaptive_context (writing_rules/business_rules/decisions/facts + summary/confidence), additive so existing fields are unchanged. - Additive migration: add supersedes_profile_id/superseded_at to memory_profiles so versioned profile writes succeed on re-runs. - Tests prove the full loop: extract -> confidence -> save -> refresh -> retrieve via get_adaptive_context, plus the confidence_score fix. Co-Authored-By: Claude Opus 4.8 --- .../adaptive-chatgpt-context-service.ts | 51 +++++- lib/services/adaptive-profile-extractor.ts | 128 +++++++++++++++ .../memory-hybrid-retrieval-service.ts | 4 +- lib/services/memory-profile-service.ts | 53 +++++++ lib/services/pandora-mcp-tools.ts | 4 +- ...00_adaptive_profile_versioning_columns.sql | 15 ++ tests/unit/adaptive-profile-loop.test.ts | 150 ++++++++++++++++++ 7 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 lib/services/adaptive-profile-extractor.ts create mode 100644 supabase/migrations/20260702000000_adaptive_profile_versioning_columns.sql create mode 100644 tests/unit/adaptive-profile-loop.test.ts diff --git a/lib/services/adaptive-chatgpt-context-service.ts b/lib/services/adaptive-chatgpt-context-service.ts index 8b02eb2..0b302f0 100644 --- a/lib/services/adaptive-chatgpt-context-service.ts +++ b/lib/services/adaptive-chatgpt-context-service.ts @@ -1,4 +1,53 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { MemoryBridgeDbClient, MemoryBridgeNamespace } from "@/lib/services/memory-bridge-service"; import { getHybridMemoryContext } from "@/lib/services/memory-hybrid-retrieval-service"; -export async function buildAdaptiveChatGptContext(client:MemoryBridgeDbClient,input:{user_id:string;namespace:MemoryBridgeNamespace;query?:string;current_task?:string;max_items?:number}){ const ctx=await getHybridMemoryContext(client,input); return {identity_context:"Private Pandora memory context for Joven; keep real_life and au namespaces separate.",answer_style:"Blunt, execution-focused, concise but complete. Do not overpraise. Separate coded, deployed, connected, authenticated, tool-discovered, tool-called successfully, and fully proven.",current_priorities:ctx.latest_context_pack?.key_points??[],active_projects:ctx.project_context,risk_warnings:ctx.risk_warnings,relationship_loops:ctx.open_loops.filter((l:any)=>String(l.loop_type).includes("relationship")),business_rules:[],technical_rules:["Do not call a task done without verification.","Do not save or expose secrets."],writing_rules:input.namespace==="au"?ctx.adaptive_profile:[],decision_rules:["Ask for review before saving sensitive/private memory.","Keep public read/write disabled."],do_not_forget:ctx.recent_events,do_not_do:["Do not retrieve across users or namespaces.","Do not store raw secrets.","Do not use public memory reads/writes."],retrieval_hints:ctx.retrieval_reasoning_summary,warnings:ctx.warnings,updated_at:new Date().toISOString()}; } + +// Flatten a profile array field (facts/preferences/...) into plain text lines for the +// ChatGPT-facing context. Items may be strings or { text, ... } objects. +function profileLines(items: unknown): string[] { + return (Array.isArray(items) ? items : []) + .map((item: any) => (typeof item === "string" ? item : String(item?.text ?? ""))) + .filter((text) => text.trim().length > 0); +} + +export async function buildAdaptiveChatGptContext( + client: MemoryBridgeDbClient, + input: { user_id: string; namespace: MemoryBridgeNamespace; query?: string; current_task?: string; max_items?: number }, +) { + const ctx = await getHybridMemoryContext(client, input); + // Latest active operating profile produced by refresh_adaptive_profiles. + const operating = (ctx.adaptive_profile ?? [])[0] ?? null; + const preferences = profileLines(operating?.preferences); + const facts = profileLines(operating?.facts); + const decisions = profileLines(operating?.decisions); + return { + identity_context: "Private Pandora memory context for Joven; keep real_life and au namespaces separate.", + answer_style: + "Blunt, execution-focused, concise but complete. Do not overpraise. Separate coded, deployed, connected, authenticated, tool-discovered, tool-called successfully, and fully proven.", + current_priorities: ctx.latest_context_pack?.key_points ?? [], + active_projects: ctx.project_context, + risk_warnings: ctx.risk_warnings, + relationship_loops: ctx.open_loops.filter((l: any) => String(l.loop_type).includes("relationship")), + // Extracted profile preferences surface as the namespace-appropriate rule set. + business_rules: input.namespace === "real_life" ? preferences : [], + technical_rules: ["Do not call a task done without verification.", "Do not save or expose secrets."], + writing_rules: input.namespace === "au" ? preferences : [], + decision_rules: [ + ...decisions, + "Ask for review before saving sensitive/private memory.", + "Keep public read/write disabled.", + ], + // Extracted durable facts are surfaced alongside recent events. + do_not_forget: [...facts, ...ctx.recent_events], + do_not_do: [ + "Do not retrieve across users or namespaces.", + "Do not store raw secrets.", + "Do not use public memory reads/writes.", + ], + adaptive_profile_summary: operating?.summary ?? null, + adaptive_profile_confidence: operating?.confidence ?? null, + retrieval_hints: ctx.retrieval_reasoning_summary, + warnings: ctx.warnings, + updated_at: new Date().toISOString(), + }; +} diff --git a/lib/services/adaptive-profile-extractor.ts b/lib/services/adaptive-profile-extractor.ts new file mode 100644 index 0000000..7a5bbeb --- /dev/null +++ b/lib/services/adaptive-profile-extractor.ts @@ -0,0 +1,128 @@ +import type { MemoryBridgeNamespace } from "@/lib/services/memory-bridge-service"; +import { classifyContent, clamp01 } from "@/lib/services/memory-usefulness-scoring-service"; + +// Deterministic adaptive-profile extraction. +// +// Turns a user's namespace-scoped memory events into a structured operating profile +// (preferences, facts, decisions, open loops, risks) with a deterministic confidence. +// Intentionally model-free and embedding-free: every output is a pure function of the +// events' text/importance/sensitivity, so it is safe to run without PANDORA_ENABLE_MODEL_CALLS +// or PANDORA_ENABLE_EMBEDDINGS. Reuses the Phase 5D deterministic content classifier. + +export type AdaptiveProfileSourceEvent = { + id?: string | null; + source?: string | null; + extracted_summary?: string | null; + raw_text?: string | null; + importance?: number | null; + sensitivity?: string | null; + status?: string | null; + created_at?: string | null; + memory_type?: string | null; +}; + +export type AdaptiveProfileItem = { text: string; event_id: string | null; importance: number | null }; + +export type ExtractedAdaptiveProfile = { + summary: string; + facts: AdaptiveProfileItem[]; + preferences: AdaptiveProfileItem[]; + patterns: AdaptiveProfileItem[]; + risks: AdaptiveProfileItem[]; + open_loops: AdaptiveProfileItem[]; + decisions: AdaptiveProfileItem[]; + evidence_refs: Array<{ event_id: string | null; source: string | null }>; + confidence: number; + event_count: number; +}; + +const DECISION_RE = /\b(decided|decision|approved|chose|choosing|merged|agreed|confirm(?:ed)?)\b/i; +const RISK_RE = /\b(risk|danger|concern|blocker|warning|do not|don't|avoid|must not|never|boundary|consent)\b/i; + +function eventText(event: AdaptiveProfileSourceEvent): string { + return String(event.extracted_summary ?? event.raw_text ?? "").replace(/\s+/g, " ").trim(); +} + +function clip(text: string, max = 240): string { + return text.length > max ? text.slice(0, max) : text; +} + +export function extractAdaptiveProfile( + events: AdaptiveProfileSourceEvent[], + namespace: MemoryBridgeNamespace, +): ExtractedAdaptiveProfile { + const facts: AdaptiveProfileItem[] = []; + const preferences: AdaptiveProfileItem[] = []; + const patterns: AdaptiveProfileItem[] = []; + const risks: AdaptiveProfileItem[] = []; + const openLoops: AdaptiveProfileItem[] = []; + const decisions: AdaptiveProfileItem[] = []; + const evidenceRefs: Array<{ event_id: string | null; source: string | null }> = []; + const seen = new Set(); + + for (const event of events) { + if (event.status === "archived") continue; + const raw = eventText(event); + if (!raw) continue; + const key = raw.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + + const item: AdaptiveProfileItem = { text: clip(raw), event_id: event.id ?? null, importance: event.importance ?? null }; + evidenceRefs.push({ event_id: event.id ?? null, source: event.source ?? null }); + + const category = classifyContent({ + text: raw, + memory_type: event.memory_type ?? null, + importance: event.importance ?? null, + source: event.source ?? null, + }); + const sensitive = event.sensitivity === "high" || event.sensitivity === "private"; + const isRisk = RISK_RE.test(raw) || sensitive; + const isDecision = DECISION_RE.test(raw); + + if (isRisk) risks.push(item); + if (isDecision) decisions.push(item); + + switch (category) { + case "durable_preference": + preferences.push(item); + break; + case "production_fact": + facts.push(item); + break; + case "task_state": + openLoops.push(item); + break; + default: + if (!isRisk && !isDecision) patterns.push(item); + break; + } + } + + const evidenceCount = evidenceRefs.length; + const preferenceShare = evidenceCount ? preferences.length / evidenceCount : 0; + // Deterministic confidence: grows with evidence volume and preference share, capped at 0.95. + const confidence = evidenceCount === 0 + ? 0.4 + : clamp01(0.5 + Math.min(evidenceCount, 10) * 0.03 + preferenceShare * 0.15); + + const summary = evidenceCount === 0 + ? `No ${namespace} memories available to build an adaptive profile.` + : `Adaptive ${namespace} operating profile from ${evidenceCount} memories: ` + + `${preferences.length} preferences, ${facts.length} facts, ${decisions.length} decisions, ` + + `${openLoops.length} open loops, ${risks.length} risks.`; + + return { + summary, + facts, + preferences, + patterns, + risks, + open_loops: openLoops, + decisions, + evidence_refs: evidenceRefs, + confidence, + event_count: evidenceCount, + }; +} diff --git a/lib/services/memory-hybrid-retrieval-service.ts b/lib/services/memory-hybrid-retrieval-service.ts index dd0b076..ec952b4 100644 --- a/lib/services/memory-hybrid-retrieval-service.ts +++ b/lib/services/memory-hybrid-retrieval-service.ts @@ -3,8 +3,8 @@ import type { MemoryBridgeDbClient, MemoryBridgeNamespace } from "@/lib/services import { rankMemoriesByRetrievalWeight } from "@/lib/services/memory-usefulness-scoring-service"; export async function getHybridMemoryContext(client:MemoryBridgeDbClient,input:{user_id:string;namespace:MemoryBridgeNamespace;query?:string;current_task?:string;max_items?:number;include_semantic?:boolean;include_profiles?:boolean;include_recent?:boolean;include_open_loops?:boolean},env:Partial=process.env){ const max=Math.min(Math.max(input.max_items??8,1),Number(env.PANDORA_RETRIEVAL_MAX_ITEMS??20)); const [packs,events,profiles,loops]=await Promise.all([ client.from("memory_context_packs").select("*").eq("user_id",input.user_id).eq("namespace",input.namespace).eq("status","active").order("created_at",{ascending:false}).limit(1) as unknown as Promise, - client.from("memory_events").select("id,source,source_ref,extracted_summary,raw_text,importance,sensitivity,status,created_at,updated_at,confidence,retrieval_weight,retrieval_count,positive_feedback_count,negative_feedback_count,superseded_by_memory_id").eq("user_id",input.user_id).eq("namespace",input.namespace).neq("status","archived").order("created_at",{ascending:false}).limit(max) as unknown as Promise, + client.from("memory_events").select("id,source,source_ref,extracted_summary,raw_text,importance,sensitivity,status,created_at,updated_at,confidence_score,retrieval_weight,retrieval_count,positive_feedback_count,negative_feedback_count,superseded_by_memory_id").eq("user_id",input.user_id).eq("namespace",input.namespace).neq("status","archived").order("created_at",{ascending:false}).limit(max) as unknown as Promise, input.include_profiles!==false ? client.from("memory_profiles").select("*").eq("user_id",input.user_id).eq("namespace",input.namespace).eq("status","active").order("updated_at",{ascending:false}).limit(max) as unknown as Promise : Promise.resolve({data:[]}), input.include_open_loops!==false ? client.from("memory_open_loops").select("*").eq("user_id",input.user_id).eq("namespace",input.namespace).eq("status","open").order("updated_at",{ascending:false}).limit(max) as unknown as Promise : Promise.resolve({data:[]})]); const warnings:string[]=[]; if(!(packs.data??[])[0]) warnings.push("no_active_context_pack"); if(env.PANDORA_ENABLE_SEMANTIC_RETRIEVAL!=="true") warnings.push("semantic_retrieval_disabled"); if(env.PANDORA_ENABLE_EMBEDDINGS!=="true") warnings.push("embeddings_disabled"); if(env.PANDORA_ENABLE_MODEL_CALLS!=="true") warnings.push("model_calls_disabled"); - return {namespace:input.namespace,current_task:input.current_task??null,adaptive_profile:(profiles.data??[]).filter((p:any)=>p.profile_type==="operating_profile"),style_profile:(profiles.data??[]).filter((p:any)=>p.profile_type==="style_profile"),project_context:(profiles.data??[]).filter((p:any)=>p.profile_type==="project_profile"),people_context:(profiles.data??[]).filter((p:any)=>p.profile_type==="person_profile"),risk_warnings:[...(profiles.data??[]).filter((p:any)=>p.profile_type==="risk_profile"),...(loops.data??[])],open_loops:loops.data??[],latest_context_pack:(packs.data??[])[0]??null,recent_events:rankMemoriesByRetrievalWeight((events.data??[]).map((e:any)=>({...e,text:e.extracted_summary??e.raw_text??""}))).map((e:any)=>({...e,text:undefined,raw_text:undefined,summary:e.extracted_summary??String(e.raw_text??"").slice(0,240)})),semantic_matches:[],retrieval_reasoning_summary:"Hybrid retrieval used active packs, recent events, active profiles, open loops, and gated semantic matches.",warnings}; } + return {namespace:input.namespace,current_task:input.current_task??null,adaptive_profile:(profiles.data??[]).filter((p:any)=>p.profile_type==="operating_profile"),style_profile:(profiles.data??[]).filter((p:any)=>p.profile_type==="style_profile"),project_context:(profiles.data??[]).filter((p:any)=>p.profile_type==="project_profile"),people_context:(profiles.data??[]).filter((p:any)=>p.profile_type==="person_profile"),risk_warnings:[...(profiles.data??[]).filter((p:any)=>p.profile_type==="risk_profile"),...(loops.data??[])],open_loops:loops.data??[],latest_context_pack:(packs.data??[])[0]??null,recent_events:rankMemoriesByRetrievalWeight((events.data??[]).map((e:any)=>({...e,text:e.extracted_summary??e.raw_text??"",confidence:e.confidence_score}))).map((e:any)=>({...e,text:undefined,raw_text:undefined,summary:e.extracted_summary??String(e.raw_text??"").slice(0,240)})),semantic_matches:[],retrieval_reasoning_summary:"Hybrid retrieval used active packs, recent events, active profiles, open loops, and gated semantic matches.",warnings}; } diff --git a/lib/services/memory-profile-service.ts b/lib/services/memory-profile-service.ts index b637cf6..c6377ee 100644 --- a/lib/services/memory-profile-service.ts +++ b/lib/services/memory-profile-service.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { MemoryBridgeDbClient, MemoryBridgeNamespace } from "@/lib/services/memory-bridge-service"; +import { extractAdaptiveProfile, type AdaptiveProfileSourceEvent } from "@/lib/services/adaptive-profile-extractor"; export type MemoryProfileInput = { user_id: string; @@ -62,6 +63,58 @@ export async function upsertVersionedMemoryProfile(client: MemoryBridgeDbClient, export async function upsertProfileFromMemoryEvents(client: MemoryBridgeDbClient, input: { user_id: string; namespace: MemoryBridgeNamespace; profile_type: string; subject_key: string; summary: string; evidence_refs: unknown[]; dry_run?: boolean }) { return upsertVersionedMemoryProfile(client, { ...input, title: input.subject_key, confidence: input.evidence_refs.length ? 0.65 : 0.4 }); } + +// Reads the user's namespace-scoped memory events, extracts a structured adaptive profile +// deterministically (no model calls / embeddings), and upserts a versioned profile row. +// This is what makes refresh_adaptive_profiles actually populate real profile content +// instead of writing an empty stub. +export async function refreshAdaptiveProfileFromEvents( + client: MemoryBridgeDbClient, + input: { user_id: string; namespace: MemoryBridgeNamespace; profile_type?: string; subject_key?: string; dry_run?: boolean; max_events?: number }, +) { + const limit = Math.min(Math.max(input.max_events ?? 200, 1), 500); + const read = await (client + .from("memory_events") + .select("id,source,source_ref,extracted_summary,raw_text,importance,sensitivity,status,created_at") + .eq("user_id", input.user_id) + .eq("namespace", input.namespace) + .neq("status", "archived") + .order("created_at", { ascending: false }) + .limit(limit) as any as Promise<{ data: AdaptiveProfileSourceEvent[] | null; error: { message: string } | null }>); + const emptyCounts = { event_count: 0, preferences: 0, facts: 0, decisions: 0, open_loops: 0, risks: 0 }; + if (read.error) { + return { ok: false, dry_run: !!input.dry_run, blockers: ["event_read_failed"], warnings: [read.error.message], next_step: "Check memory_events RLS and schema.", extracted: emptyCounts }; + } + const extracted = extractAdaptiveProfile(read.data ?? [], input.namespace); + const result = await upsertVersionedMemoryProfile(client, { + user_id: input.user_id, + namespace: input.namespace, + profile_type: input.profile_type ?? "operating_profile", + subject_key: input.subject_key ?? "global", + title: input.subject_key ?? "global", + summary: extracted.summary, + facts: extracted.facts, + preferences: extracted.preferences, + patterns: extracted.patterns, + risks: extracted.risks, + open_loops: extracted.open_loops, + decisions: extracted.decisions, + evidence_refs: extracted.evidence_refs, + confidence: extracted.confidence, + dry_run: input.dry_run, + }); + return { + ...result, + extracted: { + event_count: extracted.event_count, + preferences: extracted.preferences.length, + facts: extracted.facts.length, + decisions: extracted.decisions.length, + open_loops: extracted.open_loops.length, + risks: extracted.risks.length, + }, + }; +} export const updateOperatingProfile = upsertProfileFromMemoryEvents; export const updateStyleProfile = upsertProfileFromMemoryEvents; export const updateRiskProfile = upsertProfileFromMemoryEvents; diff --git a/lib/services/pandora-mcp-tools.ts b/lib/services/pandora-mcp-tools.ts index 960c41d..4fb74e7 100644 --- a/lib/services/pandora-mcp-tools.ts +++ b/lib/services/pandora-mcp-tools.ts @@ -10,7 +10,7 @@ import { createCandidatesFromSession } from "@/lib/services/memory-candidate-ser import { getHybridMemoryContext } from "@/lib/services/memory-hybrid-retrieval-service"; import { createSessionDigest } from "@/lib/services/memory-session-digest-service"; import { getOpenLoops } from "@/lib/services/memory-open-loop-service"; -import { upsertProfileFromMemoryEvents } from "@/lib/services/memory-profile-service"; +import { refreshAdaptiveProfileFromEvents } from "@/lib/services/memory-profile-service"; import { inferMemoryNamespace } from "@/lib/services/memory-autopilot-policy-service"; import { disabledPostAnswer, runPostAnswerTurn, runPreAnswerTurn } from "@/lib/services/memory-adaptive-turn-service"; @@ -37,7 +37,7 @@ export async function getAdaptiveContextTool(client: MemoryBridgeDbClient, princ export async function analyzeMemoryCandidatesTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { const gate=requireMcpCaptureEnabled(env); if(!gate.ok)return gate; const input=analyzeMemoryCandidatesInputSchema.parse(rawInput); const data=await createCandidatesFromSession(client,{user_id:principal.userId,namespace:input.namespace,source:input.source??"mcp_adaptive_analyze",source_ref:input.source_ref,text:input.text,mode:input.mode}); const audit=await auditPandoraMcpToolCall(client,{principal,tool:"mcp.analyze_memory_candidates",namespace:input.namespace}); return {...data,warnings:[...(data.warnings??[]),...warning(audit)]}; } export async function semanticMemorySearchTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown) { const input=memoryContextInputSchema.parse(rawInput); const data=await getHybridMemoryContext(client,{user_id:principal.userId,namespace:input.namespace,query:input.query,current_task:input.current_task,max_items:input.max_items}); const audit=await auditPandoraMcpToolCall(client,{principal,tool:"mcp.semantic_memory_search",namespace:input.namespace}); return {...data,warnings:[...data.warnings,...warning(audit)]}; } export async function createSessionDigestTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { const captureGate=requireMcpCaptureEnabled(env); if(!captureGate.ok)return captureGate; const distillGate=requireMcpDistillationEnabled(env); if(!distillGate.ok)return distillGate; const input=sessionDigestInputSchema.parse(rawInput); const data=await createSessionDigest(client,{user_id:principal.userId,namespace:input.namespace,source:input.source??"mcp_session",source_ref:input.source_ref,transcript_or_summary:input.transcript_or_summary,auto_capture:input.auto_capture,update_profiles:input.update_profiles,distill:input.distill}); const audit=await auditPandoraMcpToolCall(client,{principal,tool:"mcp.create_session_digest",namespace:input.namespace}); return {...data,warnings:[...(data.warnings??[]),...warning(audit)]}; } -export async function refreshAdaptiveProfilesTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { const input=z.object({namespace:namespaceSchema,profile_types:z.array(z.string()).optional(),subject_keys:z.array(z.string()).optional(),dry_run:z.boolean().optional()}).parse(rawInput); if(!input.dry_run){ const captureGate=requireMcpCaptureEnabled(env); if(!captureGate.ok)return captureGate; const distillGate=requireMcpDistillationEnabled(env); if(!distillGate.ok)return distillGate; } const result=await upsertProfileFromMemoryEvents(client,{user_id:principal.userId,namespace:input.namespace,profile_type:input.profile_types?.[0]??"operating_profile",subject_key:input.subject_keys?.[0]??"global",summary:"MCP adaptive profile refresh requested.",evidence_refs:[],dry_run:input.dry_run}); const audit=await auditPandoraMcpToolCall(client,{principal,tool:"mcp.refresh_adaptive_profiles",namespace:input.namespace}); return {result,warnings:warning(audit)}; } +export async function refreshAdaptiveProfilesTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { const input=z.object({namespace:namespaceSchema,profile_types:z.array(z.string()).optional(),subject_keys:z.array(z.string()).optional(),dry_run:z.boolean().optional()}).parse(rawInput); if(!input.dry_run){ const captureGate=requireMcpCaptureEnabled(env); if(!captureGate.ok)return captureGate; const distillGate=requireMcpDistillationEnabled(env); if(!distillGate.ok)return distillGate; } const result=await refreshAdaptiveProfileFromEvents(client,{user_id:principal.userId,namespace:input.namespace,profile_type:input.profile_types?.[0]??"operating_profile",subject_key:input.subject_keys?.[0]??"global",dry_run:input.dry_run}); const audit=await auditPandoraMcpToolCall(client,{principal,tool:"mcp.refresh_adaptive_profiles",namespace:input.namespace}); return {result,warnings:warning(audit)}; } export async function getOpenLoopsTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown) { const input=z.object({namespace:namespaceSchema}).parse(rawInput); const loops=await getOpenLoops(client,principal.userId,input.namespace); const audit=await auditPandoraMcpToolCall(client,{principal,tool:"mcp.get_open_loops",namespace:input.namespace}); return {open_loops:loops.data??[],warnings:[...(loops.error?[loops.error.message]:[]),...warning(audit)]}; } export async function captureAdaptiveMemoryTool(client: MemoryBridgeDbClient, principal: Extract, rawInput: unknown, env: Partial = process.env) { return analyzeMemoryCandidatesTool(client,principal,rawInput,env); } diff --git a/supabase/migrations/20260702000000_adaptive_profile_versioning_columns.sql b/supabase/migrations/20260702000000_adaptive_profile_versioning_columns.sql new file mode 100644 index 0000000..45b255d --- /dev/null +++ b/supabase/migrations/20260702000000_adaptive_profile_versioning_columns.sql @@ -0,0 +1,15 @@ +-- Adaptive profile versioning columns. +-- Fully additive: no table dropped, no column removed, no row deleted. +-- +-- upsertVersionedMemoryProfile records a supersession chain when it refreshes an +-- adaptive profile (mark the previous active version superseded, insert the new active +-- version pointing back at it). The phase_4c memory_profiles table shipped without the +-- two chain columns, so a non-dry profile refresh failed with a PostgREST 400 +-- ("column does not exist") on re-runs. These columns close that gap. + +alter table public.memory_profiles add column if not exists supersedes_profile_id uuid; +alter table public.memory_profiles add column if not exists superseded_at timestamptz; + +create index if not exists memory_profiles_supersedes_idx + on public.memory_profiles(supersedes_profile_id) + where supersedes_profile_id is not null; diff --git a/tests/unit/adaptive-profile-loop.test.ts b/tests/unit/adaptive-profile-loop.test.ts new file mode 100644 index 0000000..1733e76 --- /dev/null +++ b/tests/unit/adaptive-profile-loop.test.ts @@ -0,0 +1,150 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it } from "vitest"; +import { extractAdaptiveProfile } from "@/lib/services/adaptive-profile-extractor"; +import { refreshAdaptiveProfileFromEvents } from "@/lib/services/memory-profile-service"; +import { getHybridMemoryContext } from "@/lib/services/memory-hybrid-retrieval-service"; +import { buildAdaptiveChatGptContext } from "@/lib/services/adaptive-chatgpt-context-service"; + +// Minimal in-memory MemoryBridgeDbClient: chainable, awaitable (thenable), and +// supports .single(). Reads return the configured rows; inserts/updates/selects are spied. +type TableConfig = { rows?: any[]; onInsert?: (row: any) => void; onUpdate?: (patch: any) => void; onSelect?: (cols: string) => void }; +function fakeClient(tables: Record) { + return { + from(table: string) { + const cfg = tables[table] ?? {}; + let inserted: any[] | null = null; + const withIds = (rows: any[]) => rows.map((r, i) => ({ id: r?.id ?? `fake-${table}-${i}`, ...r })); + const builder: any = { + select(cols?: string) { if (cols) cfg.onSelect?.(cols); return builder; }, + insert(row: any) { inserted = Array.isArray(row) ? row : [row]; cfg.onInsert?.(row); return builder; }, + update(patch: any) { cfg.onUpdate?.(patch); return builder; }, + eq() { return builder; }, + neq() { return builder; }, + order() { return builder; }, + limit() { return builder; }, + range() { return Promise.resolve({ data: cfg.rows ?? [], error: null }); }, + single() { + const row = inserted ? withIds(inserted)[0] : (cfg.rows?.[0] ?? null); + return Promise.resolve({ data: row, error: null }); + }, + then(resolve: any, reject: any) { + const data = inserted ? withIds(inserted) : (cfg.rows ?? []); + return Promise.resolve({ data, error: null }).then(resolve, reject); + }, + }; + return builder; + }, + } as any; +} + +const USER = "64110799-da61-445d-a7b3-57f3d0c7e411"; + +const auEvents = [ + { id: "e-pref", source: "chatgpt_user_direct", raw_text: "I prefer blunt, execution-focused answers and I hate overpraise.", importance: 9, sensitivity: "low", status: "captured" }, + { id: "e-fact", source: "chatgpt_user_direct", raw_text: "PLP is deployed to production on Vercel.", importance: 8, sensitivity: "medium", status: "captured" }, + { id: "e-decision", source: "chatgpt_user_direct", raw_text: "We decided to use a fictional alias for explicit scenes.", importance: 9, sensitivity: "low", status: "captured" }, + { id: "e-canon", source: "chatgpt_user_direct", raw_text: "In this AU the tone is a psychological erotic thriller.", importance: 7, sensitivity: "low", status: "captured" }, + { id: "e-archived", source: "chatgpt_user_direct", raw_text: "Old superseded note that must be ignored.", importance: 3, sensitivity: "low", status: "archived" }, +]; + +describe("adaptive profile learning loop", () => { + it("extracts canon/preference/business fact/decision and assigns a bounded confidence", () => { + const extracted = extractAdaptiveProfile(auEvents, "au"); + + // preference / business fact / decision / canon-pattern each land in the right bucket + expect(extracted.preferences.map((p) => p.event_id)).toContain("e-pref"); + expect(extracted.facts.map((f) => f.event_id)).toContain("e-fact"); + expect(extracted.decisions.map((d) => d.event_id)).toContain("e-decision"); + expect(extracted.patterns.map((p) => p.event_id)).toContain("e-canon"); + + // archived events are excluded and evidence is source-backed + expect(extracted.event_count).toBe(4); + expect(extracted.evidence_refs.every((r) => r.event_id !== "e-archived")).toBe(true); + + // deterministic confidence in (0, 1], and above the empty-profile floor of 0.4 + expect(extracted.confidence).toBeGreaterThan(0.4); + expect(extracted.confidence).toBeLessThanOrEqual(1); + expect(extracted.summary).toContain("Adaptive au operating profile from 4 memories"); + }); + + it("refresh_adaptive_profiles saves a populated versioned profile row (non-dry)", async () => { + let insertedProfile: any = null; + const client = fakeClient({ + memory_events: { rows: auEvents }, + memory_profiles: { rows: [], onInsert: (row) => { insertedProfile = row; } }, + }); + + const result = await refreshAdaptiveProfileFromEvents(client, { user_id: USER, namespace: "au", dry_run: false }); + + expect(result.ok).toBe(true); + expect(result.dry_run).toBe(false); + // The saved row is real, not an empty stub. + expect(insertedProfile).not.toBeNull(); + expect(insertedProfile.status).toBe("active"); + expect(insertedProfile.version).toBe(1); + expect(insertedProfile.profile_type).toBe("operating_profile"); + expect(insertedProfile.preferences.length).toBeGreaterThan(0); + expect(insertedProfile.facts.length).toBeGreaterThan(0); + expect(insertedProfile.decisions.length).toBeGreaterThan(0); + expect(insertedProfile.confidence).toBeGreaterThan(0.4); + expect(insertedProfile.summary).toContain("Adaptive au operating profile"); + expect(result.extracted.event_count).toBe(4); + }); + + it("dry-run refresh previews the profile without writing", async () => { + let wrote = false; + const client = fakeClient({ + memory_events: { rows: auEvents }, + memory_profiles: { rows: [], onInsert: () => { wrote = true; } }, + }); + const result = await refreshAdaptiveProfileFromEvents(client, { user_id: USER, namespace: "au", dry_run: true }); + expect(result.ok).toBe(true); + expect(result.dry_run).toBe(true); + expect(wrote).toBe(false); + if (result.ok) expect(result.profile.preferences.length).toBeGreaterThan(0); + }); + + it("get_adaptive_context returns the newly extracted adaptive profile data", async () => { + const operatingProfile = { + id: "profile-1", + profile_type: "operating_profile", + subject_key: "global", + summary: "Adaptive au operating profile from 4 memories: 1 preferences, 1 facts, 1 decisions, 0 open loops, 0 risks.", + preferences: [{ text: "I prefer blunt, execution-focused answers and I hate overpraise.", event_id: "e-pref" }], + facts: [{ text: "PLP is deployed to production on Vercel.", event_id: "e-fact" }], + decisions: [{ text: "We decided to use a fictional alias for explicit scenes.", event_id: "e-decision" }], + confidence: 0.66, + status: "active", + }; + const client = fakeClient({ memory_profiles: { rows: [operatingProfile] } }); + + const ctx = await buildAdaptiveChatGptContext(client, { user_id: USER, namespace: "au" }); + + // extracted preference surfaces as an au writing rule + expect(ctx.writing_rules).toContain("I prefer blunt, execution-focused answers and I hate overpraise."); + // extracted fact surfaces in do_not_forget + expect(ctx.do_not_forget).toContain("PLP is deployed to production on Vercel."); + // extracted decision surfaces in decision_rules, alongside the static defaults + expect(ctx.decision_rules).toContain("We decided to use a fictional alias for explicit scenes."); + expect(ctx.decision_rules).toContain("Keep public read/write disabled."); + // profile summary + confidence surface + expect(ctx.adaptive_profile_summary).toBe(operatingProfile.summary); + expect(ctx.adaptive_profile_confidence).toBe(0.66); + // existing retrieval behavior intact: no active pack still warns + expect(ctx.warnings).toContain("no_active_context_pack"); + }); + + it("hybrid retrieval selects confidence_score (not the missing `confidence` column)", async () => { + let eventsSelect = ""; + const client = fakeClient({ + memory_events: { rows: auEvents, onSelect: (cols) => { eventsSelect = cols; } }, + memory_profiles: { rows: [] }, + }); + + await getHybridMemoryContext(client, { user_id: USER, namespace: "au" }); + + expect(eventsSelect).toContain("confidence_score"); + // must NOT request the bare `confidence` column that caused the 400 + expect(eventsSelect).not.toContain(",confidence,"); + }); +});