Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 49 additions & 2 deletions e2e/regression/ai-notes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
}),
Expand Down Expand Up @@ -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");
Expand Down
79 changes: 75 additions & 4 deletions scripts/verify-ai-notes-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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: [
Expand Down Expand Up @@ -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",
Expand Down
36 changes: 21 additions & 15 deletions src/app/api/meetings/[meetingId]/enhance-notes/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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.",
"",
Expand All @@ -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");
}
Expand Down Expand Up @@ -114,14 +123,19 @@ export async function POST(
const requestId = crypto.randomUUID();
const { meetingId } = await params;

let body: z.infer<typeof requestSchema>;
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) {
Expand All @@ -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)")
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/meetings/[meetingId]/suggestions/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
3 changes: 2 additions & 1 deletion src/app/api/series/[seriesId]/ask/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Loading
Loading