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 40ab609..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") { @@ -111,6 +113,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: EXPECTED_MODEL, + 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: EXPECTED_MODEL, + 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, @@ -149,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", }), @@ -186,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 2dbb9ac..672654e 100644 --- a/scripts/verify-ai-notes-contract.mjs +++ b/scripts/verify-ai-notes-contract.mjs @@ -82,21 +82,32 @@ 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"); +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"); +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"); @@ -116,7 +127,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 +174,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..2f7b569 100644 --- a/src/app/api/meetings/[meetingId]/enhance-notes/route.ts +++ b/src/app/api/meetings/[meetingId]/enhance-notes/route.ts @@ -1,8 +1,10 @@ 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"; @@ -51,6 +53,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 +74,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 +123,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 +145,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 +185,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/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/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); 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 + ); +}