From 3dc90bb6978c484d44a9f66da5ffc7931c35d56d Mon Sep 17 00:00:00 2001 From: Pratik Bodkhe Date: Fri, 5 Jun 2026 21:36:14 +0900 Subject: [PATCH 1/2] fix: tolerate fenced JSON and bare-string citations from AI provider minimax-m3 wraps responses in markdown code fences and returns Ask-this-series citations as bare id strings, which made /ask and /enhance-notes 502 on parse. Strip code fences before JSON.parse (shared helper), coerce string citations to objects, and drop malformed citations instead of rejecting the whole answer. Adds contract coverage for fenced JSON, string citations, and dropped citations. --- e2e/regression/ai-notes.spec.ts | 45 +++++++++++++ scripts/verify-ai-notes-contract.mjs | 66 ++++++++++++++++++- .../[meetingId]/enhance-notes/route.ts | 33 ++++++---- src/lib/ai/ask-series-answer.ts | 55 ++++++++++------ 4 files changed, 165 insertions(+), 34 deletions(-) diff --git a/e2e/regression/ai-notes.spec.ts b/e2e/regression/ai-notes.spec.ts index 40ab609..6e8df48 100644 --- a/e2e/regression/ai-notes.spec.ts +++ b/e2e/regression/ai-notes.spec.ts @@ -111,6 +111,51 @@ test.describe("AI notes", () => { } }); + test("generates notes through the authenticated backend route with OpenRouter", async ({ + page, + request, + }) => { + test.setTimeout(90_000); + test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated AI notes data"); + test.skip(!HAS_OPENROUTER_KEY, "Requires OpenRouter for backend AI notes coverage"); + + const fixture = await createAiNotesFixture(request); + + try { + const response = await page.request.post( + `/api/meetings/${fixture.meetingId}/enhance-notes`, + { data: { mode: "preview" }, timeout: 60_000 } + ); + expect(response.status()).toBe(200); + + const payload = await response.json(); + expect(payload).toMatchObject({ + model: "minimax/minimax-m3", + prompt_version: "ai-notes-v1", + }); + expect(payload.ai_notes_markdown).toContain("##"); + expect( + Object.values(payload.ai_notes).some( + (items) => Array.isArray(items) && items.length > 0 + ) + ).toBe(true); + + const rows = await rest( + request, + `meetings?id=eq.${fixture.meetingId}&select=notes_markdown,raw_notes_markdown,ai_notes_markdown,ai_notes_model,ai_notes_prompt_version` + ); + expect(rows[0]).toMatchObject({ + notes_markdown: fixture.rawNotes, + raw_notes_markdown: fixture.rawNotes, + ai_notes_model: "minimax/minimax-m3", + ai_notes_prompt_version: "ai-notes-v1", + }); + expect(rows[0].ai_notes_markdown).toContain("##"); + } finally { + await deleteSeries(request, fixture.seriesId); + } + }); + test("preserves raw notes and applies generated notes after preview", async ({ page, request, diff --git a/scripts/verify-ai-notes-contract.mjs b/scripts/verify-ai-notes-contract.mjs index 2dbb9ac..ba73c3d 100644 --- a/scripts/verify-ai-notes-contract.mjs +++ b/scripts/verify-ai-notes-contract.mjs @@ -87,7 +87,11 @@ assert(route.includes("minimax/minimax-m3"), "Enhance route must use minimax/min assert(route.includes("OPENROUTER_API_KEY"), "Enhance route must read OPENROUTER_API_KEY"); assert(route.includes("AI_API_KEY"), "Enhance route must support AI_API_KEY fallback"); assert(route.includes("https://openrouter.ai/api/v1/chat/completions"), "Enhance route must call OpenRouter chat completions"); +assert(route.includes("response_format: { type: \"json_object\" }"), "Enhance route must request OpenRouter JSON mode"); +assert(route.includes("Return only the JSON object"), "Enhance prompt must forbid non-JSON wrapper text"); +assert(route.includes("Do not invent owners, dates, or decisions"), "Enhance prompt must forbid invented accountability details"); assert(route.includes("ai_notes: parsed"), "Enhance route must return structured AI notes JSON"); +assert(route.includes("stripJsonFences"), "Enhance route must strip markdown code fences before parsing provider JSON"); const suggestionsRoute = read("src/app/api/meetings/[meetingId]/suggestions/route.ts"); assert(suggestionsRoute.includes("minimax/minimax-m3"), "Suggestions route must use minimax/minimax-m3"); @@ -116,7 +120,16 @@ await esbuild.build({ logLevel: "silent", }); -const { parseAskSeriesAnswer } = await import(pathToFileURL(bundledParser).href); +const { parseAskSeriesAnswer, stripJsonFences } = await import(pathToFileURL(bundledParser).href); + +assert( + stripJsonFences("```json\n{\"a\":1}\n```") === '{"a":1}', + "stripJsonFences must unwrap fenced provider JSON" +); +assert( + stripJsonFences('{"a":1}') === '{"a":1}', + "stripJsonFences must leave bare JSON untouched" +); const sparseAnswer = parseAskSeriesAnswer({ providerData: { choices: [ @@ -154,6 +167,57 @@ assert( "Sparse meeting citations should link to the source meeting" ); +// Model returns markdown-fenced JSON with bare-string citations (observed with minimax-m3). +const fencedAnswer = parseAskSeriesAnswer({ + providerData: { + choices: [ + { + message: { + content: + "```json\n" + + JSON.stringify({ + answer: "New tests need to be added; John is running a Claude POC.", + citations: ["20000000-0000-0000-0000-000000000002"], + unsupported: false, + }) + + "\n```", + }, + }, + ], + }, + seriesId: "10000000-0000-0000-0000-000000000001", + meetings: [{ id: "20000000-0000-0000-0000-000000000002", title: "1:1 with John #7" }], + issues: [], + decisions: [], +}); +assert(fencedAnswer.unsupported === false, "Fenced JSON should parse and stay supported"); +assert( + fencedAnswer.citations[0]?.source_id === "20000000-0000-0000-0000-000000000002", + "Bare-string citations should resolve to their source" +); + +// A malformed citation must be dropped, not throw the whole answer away. +const mixedAnswer = parseAskSeriesAnswer({ + providerData: { + choices: [ + { + message: { + content: JSON.stringify({ + answer: "Coverage gap remains open.", + citations: ["not-a-uuid", "20000000-0000-0000-0000-000000000002"], + unsupported: false, + }), + }, + }, + ], + }, + seriesId: "10000000-0000-0000-0000-000000000001", + meetings: [{ id: "20000000-0000-0000-0000-000000000002", title: "1:1 with John #7" }], + issues: [], + decisions: [], +}); +assert(mixedAnswer.citations.length === 1, "Malformed citations should be dropped, not throw"); + const meetingDetail = read("src/app/(app)/series/[id]/meetings/[meetingId]/meeting-detail-content.tsx"); for (const copy of [ "Enhance notes", diff --git a/src/app/api/meetings/[meetingId]/enhance-notes/route.ts b/src/app/api/meetings/[meetingId]/enhance-notes/route.ts index 1d79b8d..80c47b2 100644 --- a/src/app/api/meetings/[meetingId]/enhance-notes/route.ts +++ b/src/app/api/meetings/[meetingId]/enhance-notes/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { z } from "zod"; import { createClient } from "@/lib/supabase/server"; +import { stripJsonFences } from "@/lib/ai/ask-series-answer"; const OPENROUTER_MODEL = "minimax/minimax-m3"; const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; @@ -51,6 +52,8 @@ function buildPrompt(input: { return [ "You enhance recurring meeting notes for Minutia, an Outstanding Issues Log.", "Return strict JSON with these array fields: summary, action_items, decisions, risks, blockers, follow_ups, open_questions.", + "Return only the JSON object. Do not wrap it in markdown fences or add commentary.", + "Each field must be an array of concise strings. Use [] when there is no evidence for a field.", "Do not invent owners, dates, or decisions. If uncertain, put the uncertainty in open_questions.", "Prefer concise, accountable wording.", "", @@ -70,11 +73,16 @@ function buildPrompt(input: { } function getTextFromOpenRouter(data: unknown) { - const content = (data as any)?.choices?.[0]?.message?.content; + const content = (data as { + choices?: { message?: { content?: unknown } }[]; + })?.choices?.[0]?.message?.content; if (typeof content === "string") return content; if (Array.isArray(content)) { return content - .map((part) => typeof part?.text === "string" ? part.text : "") + .map((part) => { + if (!part || typeof part !== "object" || !("text" in part)) return ""; + return typeof part.text === "string" ? part.text : ""; + }) .filter(Boolean) .join("\n"); } @@ -114,14 +122,19 @@ export async function POST( const requestId = crypto.randomUUID(); const { meetingId } = await params; - let body: z.infer; try { - body = requestSchema.parse(await request.json()); + requestSchema.parse(await request.json()); } catch { return NextResponse.json({ error: "Invalid request body", request_id: requestId }, { status: 400 }); } - void body; + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Not authenticated", request_id: requestId }, { status: 401 }); + } const apiKey = process.env.OPENROUTER_API_KEY || process.env.AI_API_KEY; if (!apiKey) { @@ -131,14 +144,6 @@ export async function POST( ); } - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) { - return NextResponse.json({ error: "Not authenticated", request_id: requestId }, { status: 401 }); - } - const { data: meeting, error } = await supabase .from("meetings") .select("*, series:meeting_series!inner(name), issues:issues!raised_in_meeting_id(title,status,owner_name,category), decisions(title,rationale)") @@ -179,7 +184,7 @@ export async function POST( let parsed: AiNotes; try { - const text = getTextFromOpenRouter(providerData); + const text = stripJsonFences(getTextFromOpenRouter(providerData)); parsed = notesSchema.parse(JSON.parse(text)); } catch { return NextResponse.json( diff --git a/src/lib/ai/ask-series-answer.ts b/src/lib/ai/ask-series-answer.ts index e839898..498c536 100644 --- a/src/lib/ai/ask-series-answer.ts +++ b/src/lib/ai/ask-series-answer.ts @@ -6,18 +6,21 @@ const uuidLikeSchema = z.string().regex( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i ); -const providerCitationSchema = z.object({ - type: z.enum(["meeting", "issue", "decision", "notes"]).optional(), - source_id: uuidLikeSchema, - title: z.string().min(1).max(300).optional(), - meeting_id: uuidLikeSchema.nullable().optional(), - meeting_title: z.string().nullable().optional(), - quote: z.string().optional(), -}); +const providerCitationSchema = z.preprocess( + (value) => (typeof value === "string" ? { source_id: value } : value), + z.object({ + type: z.enum(["meeting", "issue", "decision", "notes"]).optional(), + source_id: uuidLikeSchema, + title: z.string().min(1).max(300).optional(), + meeting_id: uuidLikeSchema.nullable().optional(), + meeting_title: z.string().nullable().optional(), + quote: z.string().optional(), + }) +); const providerAnswerSchema = z.object({ answer: z.string().trim().min(1).max(4000), - citations: z.array(providerCitationSchema).default([]), + citations: z.array(z.unknown()).default([]), unsupported: z.boolean().default(false), }); @@ -61,13 +64,22 @@ export type AskSeriesParsedAnswer = { unsupported: boolean; }; +export function stripJsonFences(text: string) { + const trimmed = text.trim(); + const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); + return (fenced ? fenced[1] : trimmed).trim(); +} + export function getTextFromOpenRouter(data: unknown) { const parsed = openRouterTextSchema.safeParse(data); if (!parsed.success) return ""; const content = parsed.data.choices[0].message.content; - if (typeof content === "string") return content; - return content.map((part) => part.text ?? "").filter(Boolean).join("\n"); + const text = + typeof content === "string" + ? content + : content.map((part) => part.text ?? "").filter(Boolean).join("\n"); + return stripJsonFences(text); } function citationHref(input: { @@ -151,14 +163,19 @@ export function parseAskSeriesAnswer(input: { const decisions = new Map(input.decisions.map((source) => [source.id, source])); const citations = parsed.citations - .map((citation) => - resolveCitation({ - citation, - seriesId: input.seriesId, - meetings, - issues, - decisions, - }) + .map((raw) => providerCitationSchema.safeParse(raw)) + .flatMap((result) => + result.success + ? [ + resolveCitation({ + citation: result.data, + seriesId: input.seriesId, + meetings, + issues, + decisions, + }), + ] + : [] ) .filter((citation): citation is AskSeriesCitation => citation !== null); From c6d6970a831f5d703af60c0705115ec29c91f4db Mon Sep 17 00:00:00 2001 From: Pratik Bodkhe Date: Fri, 5 Jun 2026 21:59:58 +0900 Subject: [PATCH 2/2] feat: drive AI model through config, default google/gemini-3.1-flash-lite The three AI routes hardcoded minimax/minimax-m3 and ignored the AI_MODEL env that infra already plumbed. Add getAiModel() (OPENROUTER_MODEL || AI_MODEL || default) and use it everywhere; set the default and .env/compose to google/gemini-3.1-flash-lite. Contract + e2e assert config-driven resolution. --- .env.example | 2 +- docker-compose.yml | 2 +- e2e/regression/ai-notes.spec.ts | 10 ++++++---- scripts/verify-ai-notes-contract.mjs | 13 ++++++++++--- .../api/meetings/[meetingId]/enhance-notes/route.ts | 3 ++- .../api/meetings/[meetingId]/suggestions/route.ts | 3 ++- src/app/api/series/[seriesId]/ask/route.ts | 3 ++- src/lib/ai/model.ts | 11 +++++++++++ 8 files changed, 35 insertions(+), 12 deletions(-) create mode 100644 src/lib/ai/model.ts diff --git a/.env.example b/.env.example index c681902..22d0efc 100644 --- a/.env.example +++ b/.env.example @@ -90,7 +90,7 @@ POSTGRES_PORT=5432 # Leave empty to disable AI features entirely. OPENROUTER_API_KEY= AI_API_KEY= -AI_MODEL=minimax/minimax-m3 +AI_MODEL=google/gemini-3.1-flash-lite # --------------------------------------------------------------------------- # Optional: Auth Configuration diff --git a/docker-compose.yml b/docker-compose.yml index bd40eb4..b6b170c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,7 +50,7 @@ services: - EMAIL_FROM=${EMAIL_FROM:-noreply@localhost} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - AI_API_KEY=${AI_API_KEY:-} - - AI_MODEL=${AI_MODEL:-claude-sonnet-4-6} + - AI_MODEL=${AI_MODEL:-google/gemini-3.1-flash-lite} depends_on: supabase-kong: condition: service_healthy diff --git a/e2e/regression/ai-notes.spec.ts b/e2e/regression/ai-notes.spec.ts index 6e8df48..7f8b841 100644 --- a/e2e/regression/ai-notes.spec.ts +++ b/e2e/regression/ai-notes.spec.ts @@ -5,6 +5,8 @@ import { SERIES, waitForApp } from "./seed-data"; const SUPABASE_URL = process.env.SUPABASE_URL ?? "http://127.0.0.1:54321"; const HAS_SERVICE_ROLE = !!process.env.SUPABASE_SERVICE_ROLE_KEY; const HAS_OPENROUTER_KEY = !!process.env.OPENROUTER_API_KEY; +const EXPECTED_MODEL = + process.env.OPENROUTER_MODEL || process.env.AI_MODEL || "google/gemini-3.1-flash-lite"; const TEST_USER_ID = "00000000-0000-0000-0000-000000000001"; function serviceHeaders(prefer = "return=representation") { @@ -130,7 +132,7 @@ test.describe("AI notes", () => { const payload = await response.json(); expect(payload).toMatchObject({ - model: "minimax/minimax-m3", + model: EXPECTED_MODEL, prompt_version: "ai-notes-v1", }); expect(payload.ai_notes_markdown).toContain("##"); @@ -147,7 +149,7 @@ test.describe("AI notes", () => { expect(rows[0]).toMatchObject({ notes_markdown: fixture.rawNotes, raw_notes_markdown: fixture.rawNotes, - ai_notes_model: "minimax/minimax-m3", + ai_notes_model: EXPECTED_MODEL, ai_notes_prompt_version: "ai-notes-v1", }); expect(rows[0].ai_notes_markdown).toContain("##"); @@ -194,7 +196,7 @@ test.describe("AI notes", () => { "## Risks", "- Support queue may spike after launch." ].join("\n"), - model: "minimax/minimax-m3", + model: EXPECTED_MODEL, prompt_version: "ai-notes-v1", generated_at: "2026-06-04T00:00:00.000Z", }), @@ -231,7 +233,7 @@ test.describe("AI notes", () => { ); expect(rows[0]).toMatchObject({ raw_notes_markdown: fixture.rawNotes, - ai_notes_model: "minimax/minimax-m3", + ai_notes_model: EXPECTED_MODEL, ai_notes_prompt_version: "ai-notes-v1", }); expect(rows[0].notes_markdown).toContain("## Summary"); diff --git a/scripts/verify-ai-notes-contract.mjs b/scripts/verify-ai-notes-contract.mjs index ba73c3d..672654e 100644 --- a/scripts/verify-ai-notes-contract.mjs +++ b/scripts/verify-ai-notes-contract.mjs @@ -82,8 +82,13 @@ assert( "Missing Ask this series API route" ); +const modelConfig = read("src/lib/ai/model.ts"); +assert(modelConfig.includes('"google/gemini-3.1-flash-lite"'), "AI model default must be google/gemini-3.1-flash-lite"); +assert(modelConfig.includes("AI_MODEL"), "AI model must be configurable via AI_MODEL"); + const route = read("src/app/api/meetings/[meetingId]/enhance-notes/route.ts"); -assert(route.includes("minimax/minimax-m3"), "Enhance route must use minimax/minimax-m3"); +assert(route.includes("getAiModel"), "Enhance route must resolve the model from config (getAiModel)"); +assert(!route.includes('"minimax/minimax-m3"'), "Enhance route must not hardcode a model"); assert(route.includes("OPENROUTER_API_KEY"), "Enhance route must read OPENROUTER_API_KEY"); assert(route.includes("AI_API_KEY"), "Enhance route must support AI_API_KEY fallback"); assert(route.includes("https://openrouter.ai/api/v1/chat/completions"), "Enhance route must call OpenRouter chat completions"); @@ -94,13 +99,15 @@ assert(route.includes("ai_notes: parsed"), "Enhance route must return structured assert(route.includes("stripJsonFences"), "Enhance route must strip markdown code fences before parsing provider JSON"); const suggestionsRoute = read("src/app/api/meetings/[meetingId]/suggestions/route.ts"); -assert(suggestionsRoute.includes("minimax/minimax-m3"), "Suggestions route must use minimax/minimax-m3"); +assert(suggestionsRoute.includes("getAiModel"), "Suggestions route must resolve the model from config (getAiModel)"); +assert(!suggestionsRoute.includes('"minimax/minimax-m3"'), "Suggestions route must not hardcode a model"); assert(suggestionsRoute.includes("OPENROUTER_API_KEY"), "Suggestions route must read OPENROUTER_API_KEY"); assert(suggestionsRoute.includes("AI_API_KEY"), "Suggestions route must support AI_API_KEY fallback"); assert(suggestionsRoute.includes("https://openrouter.ai/api/v1/chat/completions"), "Suggestions route must call OpenRouter chat completions"); const askSeriesRoute = read("src/app/api/series/[seriesId]/ask/route.ts"); -assert(askSeriesRoute.includes("minimax/minimax-m3"), "Ask series route must use minimax/minimax-m3"); +assert(askSeriesRoute.includes("getAiModel"), "Ask series route must resolve the model from config (getAiModel)"); +assert(!askSeriesRoute.includes('"minimax/minimax-m3"'), "Ask series route must not hardcode a model"); assert(askSeriesRoute.includes("OPENROUTER_API_KEY"), "Ask series route must read OPENROUTER_API_KEY"); assert(askSeriesRoute.includes("AI_API_KEY"), "Ask series route must support AI_API_KEY fallback"); assert(askSeriesRoute.includes("https://openrouter.ai/api/v1/chat/completions"), "Ask series route must call OpenRouter chat completions"); diff --git a/src/app/api/meetings/[meetingId]/enhance-notes/route.ts b/src/app/api/meetings/[meetingId]/enhance-notes/route.ts index 80c47b2..2f7b569 100644 --- a/src/app/api/meetings/[meetingId]/enhance-notes/route.ts +++ b/src/app/api/meetings/[meetingId]/enhance-notes/route.ts @@ -2,8 +2,9 @@ import { NextResponse, type NextRequest } from "next/server"; import { z } from "zod"; import { createClient } from "@/lib/supabase/server"; import { stripJsonFences } from "@/lib/ai/ask-series-answer"; +import { getAiModel } from "@/lib/ai/model"; -const OPENROUTER_MODEL = "minimax/minimax-m3"; +const OPENROUTER_MODEL = getAiModel(); const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; const PROMPT_VERSION = "ai-notes-v1"; diff --git a/src/app/api/meetings/[meetingId]/suggestions/route.ts b/src/app/api/meetings/[meetingId]/suggestions/route.ts index 8ee302f..6d7ee17 100644 --- a/src/app/api/meetings/[meetingId]/suggestions/route.ts +++ b/src/app/api/meetings/[meetingId]/suggestions/route.ts @@ -1,8 +1,9 @@ import { NextResponse, type NextRequest } from "next/server"; import { z } from "zod"; import { createClient } from "@/lib/supabase/server"; +import { getAiModel } from "@/lib/ai/model"; -const OPENROUTER_MODEL = "minimax/minimax-m3"; +const OPENROUTER_MODEL = getAiModel(); const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; const PROMPT_VERSION = "ai-suggestions-v1"; diff --git a/src/app/api/series/[seriesId]/ask/route.ts b/src/app/api/series/[seriesId]/ask/route.ts index 9a0e8b9..dbee7c0 100644 --- a/src/app/api/series/[seriesId]/ask/route.ts +++ b/src/app/api/series/[seriesId]/ask/route.ts @@ -2,8 +2,9 @@ import { NextResponse, type NextRequest } from "next/server"; import { z } from "zod"; import { parseAskSeriesAnswer } from "@/lib/ai/ask-series-answer"; import { createClient } from "@/lib/supabase/server"; +import { getAiModel } from "@/lib/ai/model"; -const OPENROUTER_MODEL = "minimax/minimax-m3"; +const OPENROUTER_MODEL = getAiModel(); const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; const PROMPT_VERSION = "ask-series-v1"; diff --git a/src/lib/ai/model.ts b/src/lib/ai/model.ts new file mode 100644 index 0000000..ac38f56 --- /dev/null +++ b/src/lib/ai/model.ts @@ -0,0 +1,11 @@ +// Resolve the AI model from runtime config. +// Configure with OPENROUTER_MODEL (or AI_MODEL); falls back to a sensible default. +const DEFAULT_AI_MODEL = "google/gemini-3.1-flash-lite"; + +export function getAiModel() { + return ( + process.env.OPENROUTER_MODEL?.trim() || + process.env.AI_MODEL?.trim() || + DEFAULT_AI_MODEL + ); +}