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
35 changes: 33 additions & 2 deletions scripts/verify-ai-notes-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,37 @@ assert(route.includes("response_format: { type: \"json_object\" }"), "Enhance ro
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");
assert(
route.includes('from "@/lib/ai/ask-series-answer"') && !/function getTextFromOpenRouter/.test(route),
"Enhance route must reuse the shared fence-aware getTextFromOpenRouter so markdown-fenced provider JSON still parses"
);

const suggestionsRoute = read("src/app/api/meetings/[meetingId]/suggestions/route.ts");
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");
assert(
suggestionsRoute.includes('from "@/lib/ai/ask-series-answer"'),
"Suggestions route must reuse the shared fence-aware OpenRouter text helper"
);
assert(
!/function getTextFromOpenRouter/.test(suggestionsRoute),
"Suggestions route must not redefine a non-fence-aware getTextFromOpenRouter; reuse the shared one so markdown-fenced provider JSON still parses"
);
assert(
suggestionsRoute.includes("Do not wrap it in markdown fences"),
"Suggestions prompt must forbid markdown-fenced output"
);
assert(
suggestionsRoute.includes('return {"suggestions": []}') || suggestionsRoute.includes('{\\"suggestions\\": []}'),
"Suggestions prompt must instruct an empty array when nothing qualifies"
);
assert(
suggestionsRoute.includes("verbatim") && suggestionsRoute.includes("Never guess"),
"Suggestions prompt must forbid invented owners and require verbatim evidence"
);

const askSeriesRoute = read("src/app/api/series/[seriesId]/ask/route.ts");
assert(askSeriesRoute.includes("getAiModel"), "Ask series route must resolve the model from config (getAiModel)");
Expand All @@ -127,7 +150,15 @@ await esbuild.build({
logLevel: "silent",
});

const { parseAskSeriesAnswer, stripJsonFences } = await import(pathToFileURL(bundledParser).href);
const { parseAskSeriesAnswer, stripJsonFences, getTextFromOpenRouter } = await import(pathToFileURL(bundledParser).href);

// The suggestions route shares this extractor; fenced provider JSON must round-trip to parseable text.
assert(
getTextFromOpenRouter({
choices: [{ message: { content: "```json\n{\"suggestions\":[]}\n```" } }],
}) === '{"suggestions":[]}',
"Shared getTextFromOpenRouter must unwrap fenced suggestions JSON"
);

assert(
stripJsonFences("```json\n{\"a\":1}\n```") === '{"a":1}',
Expand Down
24 changes: 3 additions & 21 deletions src/app/api/meetings/[meetingId]/enhance-notes/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +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";
import { getTextFromOpenRouter } from "@/lib/ai/ask-series-answer";
import { getAiModel } from "@/lib/ai/model";

const OPENROUTER_MODEL = getAiModel();
Expand Down Expand Up @@ -69,27 +69,10 @@ function buildPrompt(input: {
input.transcript || "(not provided)",
"",
"Existing open context:",
JSON.stringify({ issues: input.issues, decisions: input.decisions }, null, 2),
JSON.stringify({ issues: input.issues, decisions: input.decisions }),
].join("\n");
}

function getTextFromOpenRouter(data: unknown) {
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) => {
if (!part || typeof part !== "object" || !("text" in part)) return "";
return typeof part.text === "string" ? part.text : "";
})
.filter(Boolean)
.join("\n");
}
return "";
}

async function getOpenRouterData(prompt: string, apiKey: string) {
const providerResponse = await fetch(OPENROUTER_URL, {
method: "POST",
Expand Down Expand Up @@ -185,8 +168,7 @@ export async function POST(

let parsed: AiNotes;
try {
const text = stripJsonFences(getTextFromOpenRouter(providerData));
parsed = notesSchema.parse(JSON.parse(text));
parsed = notesSchema.parse(JSON.parse(getTextFromOpenRouter(providerData)));
} catch {
return NextResponse.json(
{ error: "AI provider returned invalid notes.", request_id: requestId },
Expand Down
53 changes: 24 additions & 29 deletions src/app/api/meetings/[meetingId]/suggestions/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
import { createClient } from "@/lib/supabase/server";
import { getTextFromOpenRouter } from "@/lib/ai/ask-series-answer";
import { getAiModel } from "@/lib/ai/model";

const OPENROUTER_MODEL = getAiModel();
Expand All @@ -25,29 +26,6 @@ const suggestionsSchema = z.object({
suggestions: z.array(suggestionSchema).default([]),
});

function getTextFromOpenRouter(data: unknown) {
const parsed = z
.object({
choices: z.array(
z.object({
message: z.object({
content: z.union([
z.string(),
z.array(z.object({ text: z.string().optional() }).passthrough()),
]),
}),
}).passthrough()
).min(1),
})
.passthrough()
.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");
}

function buildPrompt(input: {
title: string;
seriesName: string;
Expand All @@ -56,12 +34,29 @@ function buildPrompt(input: {
transcript: string | null;
}) {
return [
"Extract reviewable accountability suggestions for Minutia, an Outstanding Issues Log.",
"Return strict JSON with a suggestions array.",
"Each suggestion must include category, title, details, owner_name, due_date, confidence, and source_excerpt.",
"Allowed categories: action, decision, info, risk, blocker.",
"Only suggest durable records that a facilitator should review. Do not invent owners or due dates.",
"Use source_excerpt to quote the smallest supporting phrase from the notes or transcript.",
"You extract reviewable accountability suggestions for Minutia, an Outstanding Issues Log.",
"A facilitator reviews every suggestion before it enters a permanent record, so omitting a weak item is always better than inventing one.",
"",
"OUTPUT CONTRACT",
'Return only a single JSON object of the form {"suggestions": [ ... ]}.',
"Do not wrap it in markdown fences. Do not add commentary, explanations, or text before or after the JSON.",
"If nothing in the notes or transcript qualifies, return {\"suggestions\": []}.",
"",
"Each suggestion object must have exactly these fields:",
"- category: one of action, decision, info, risk, blocker.",
" action = a task someone must do. decision = a choice that was made.",
" risk = a possible future problem. blocker = something currently preventing progress. info = a durable fact worth tracking.",
"- title: concise imperative summary, max 120 characters, no trailing punctuation.",
"- details: one or two sentences of supporting context, or \"\" if none.",
"- owner_name: copy a person's name verbatim only if the source explicitly assigns them. Never guess. Use \"\" when unassigned.",
"- due_date: an explicit calendar date as YYYY-MM-DD, only if the source states one. Never infer from relative phrasing. Use null otherwise.",
"- confidence: 0 to 1. Use 0.9+ when explicitly stated and owned, 0.5 to 0.8 when implied, and omit any item you would score below 0.4.",
"- source_excerpt: a verbatim quote copied from the notes or transcript that supports this item. Do not paraphrase. Keep it under 160 characters.",
"",
"RULES",
"Only surface durable items a facilitator should track. Skip greetings, small talk, and resolved chatter.",
"Emit at most one suggestion per distinct item; do not duplicate.",
"Prefer fewer, high-signal suggestions over many speculative ones.",
"",
`Series: ${input.seriesName}`,
`Meeting: ${input.title}`,
Expand Down
Loading