diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74b519a..8f1fe63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Verify runtime config contracts run: pnpm test:runtime-config + - name: Verify query contracts + run: pnpm test:query-contracts + - name: Verify CI workflow contracts run: pnpm test:ci-workflows diff --git a/AGENTS.md b/AGENTS.md index 9cbae1f..85ca30b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,4 +15,49 @@ - Preserve core app behavior and generic self-host behavior when removing hosted/VPS surfaces. - Prefer small, test-backed changes. +- Do not put test stubs in production code. Production route handlers, services, and app code must never branch on test-only env vars or fake provider responses. Keep stubs, fixtures, and provider fakes in tests, local harnesses, or pure contract verifiers only. - Run targeted scans before committing OSS changes. + + +# GitNexus - Code Intelligence + +This project is indexed by GitNexus as **minutia** (4111 symbols, 7801 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol - callers, callees, which execution flows it participates in - use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace - use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/minutia/context` | Codebase overview, check index freshness | +| `gitnexus://repo/minutia/clusters` | All functional areas | +| `gitnexus://repo/minutia/processes` | All execution flows | +| `gitnexus://repo/minutia/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/e2e/regression/ai-notes.spec.ts b/e2e/regression/ai-notes.spec.ts index 2a180d9..40ab609 100644 --- a/e2e/regression/ai-notes.spec.ts +++ b/e2e/regression/ai-notes.spec.ts @@ -1,13 +1,10 @@ import { randomUUID } from "node:crypto"; import { test, expect, type APIRequestContext } from "@playwright/test"; -import { MEETINGS, SERIES, waitForApp } from "./seed-data"; +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 HAS_TEST_OPENROUTER_RESPONSE = !!process.env.MINUTIA_TEST_OPENROUTER_RESPONSE; -const HAS_TEST_AI_SUGGESTIONS_RESPONSE = !!process.env.MINUTIA_TEST_AI_SUGGESTIONS_RESPONSE; -const HAS_TEST_SERIES_ASK_RESPONSE = !!process.env.MINUTIA_TEST_SERIES_ASK_RESPONSE; const TEST_USER_ID = "00000000-0000-0000-0000-000000000001"; function serviceHeaders(prefer = "return=representation") { @@ -114,54 +111,6 @@ test.describe("AI notes", () => { } }); - test("real endpoint stores structured OpenRouter output", async ({ - page, - request, - }) => { - test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated AI notes data"); - test.skip( - !HAS_OPENROUTER_KEY || !HAS_TEST_OPENROUTER_RESPONSE, - "Requires test OpenRouter fixture" - ); - - const fixture = await createAiNotesFixture(request); - - try { - await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`); - await waitForApp(page); - - const response = await page.request.post( - `/api/meetings/${fixture.meetingId}/enhance-notes`, - { data: { mode: "preview" }, timeout: 20_000 } - ); - expect(response.status()).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - ai_notes: { - summary: ["Alice owns onboarding and launch scope stays small."], - action_items: ["Alice owns onboarding by Friday."], - decisions: ["Keep launch scope small."], - risks: ["Support queue may spike."], - }, - model: "minimax/minimax-m3", - prompt_version: "ai-notes-v1", - }); - - const rows = await rest( - request, - `meetings?id=eq.${fixture.meetingId}&select=raw_notes_markdown,ai_notes_markdown,ai_notes_model,ai_notes_prompt_version` - ); - expect(rows[0]).toMatchObject({ - 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("## Summary"); - expect(rows[0].ai_notes_markdown).toContain("Alice owns onboarding"); - } finally { - await deleteSeries(request, fixture.seriesId); - } - }); - test("preserves raw notes and applies generated notes after preview", async ({ page, request, @@ -278,78 +227,6 @@ test.describe("AI notes", () => { } }); - test("generates reviewable suggestions, accepts an edited issue, and rejects a decision", async ({ - page, - request, - }) => { - test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated AI notes data"); - test.skip( - !HAS_OPENROUTER_KEY || !HAS_TEST_AI_SUGGESTIONS_RESPONSE, - "Requires test AI suggestions fixture" - ); - - const fixture = await createAiNotesFixture(request); - - try { - await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`); - await waitForApp(page); - await expect(page.getByRole("heading", { name: /AI notes session/ })).toBeVisible({ timeout: 20_000 }); - - await page.getByRole("button", { name: "Review AI suggestions" }).click(); - await expect(page.getByRole("region", { name: "AI suggestions" })).toBeVisible(); - await expect(page.getByLabel("Suggestion title").first()).toHaveValue("Alice owns onboarding by Friday"); - await expect(page.getByText("Decision: Keep launch scope small")).toBeVisible(); - - await page.getByLabel("Suggestion title").first().fill("Alice owns the onboarding checklist"); - await page.getByLabel("Suggestion owner").first().fill("Alice"); - await page.getByRole("button", { name: "Accept suggestion" }).first().click(); - await expect(page.getByText("Accepted into tracked work.")).toBeVisible(); - - await page.getByRole("button", { name: "Reject suggestion" }).first().click(); - await expect(page.getByText("Rejected.")).toBeVisible(); - - const issues = await rest( - request, - `issues?series_id=eq.${fixture.seriesId}&select=title,owner_name,source,ai_confidence,category,due_date` - ); - expect(issues).toEqual([ - expect.objectContaining({ - title: "Alice owns the onboarding checklist", - owner_name: "Alice", - source: "ai_suggested", - category: "action", - due_date: "2026-06-05", - }), - ]); - expect(issues[0].ai_confidence).toBeGreaterThanOrEqual(0.8); - - const decisions = await rest( - request, - `decisions?series_id=eq.${fixture.seriesId}&select=title,source` - ); - expect(decisions).toEqual([]); - - const suggestions = await rest( - request, - `meeting_ai_suggestions?meeting_id=eq.${fixture.meetingId}&select=title,status,created_issue_id,created_decision_id&order=created_at.asc` - ); - expect(suggestions).toEqual([ - expect.objectContaining({ - title: "Alice owns the onboarding checklist", - status: "accepted", - }), - expect.objectContaining({ - title: "Keep launch scope small", - status: "rejected", - created_decision_id: null, - }), - ]); - expect(suggestions[0].created_issue_id).toBeTruthy(); - } finally { - await deleteSeries(request, fixture.seriesId); - } - }); - test("shows an empty suggestions state", async ({ page, request }) => { test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated AI notes data"); @@ -396,37 +273,6 @@ test.describe("Ask this series", () => { }); }); - test("answers a series question with citations", async ({ page }) => { - test.skip( - !HAS_OPENROUTER_KEY || !HAS_TEST_SERIES_ASK_RESPONSE, - "Requires test Ask this series fixture" - ); - - await page.goto(`/series/${SERIES.platformStandup}`); - await waitForApp(page); - - await page.getByLabel("Ask this series question").fill("What did we decide about CI/CD?"); - const askResponse = page.waitForResponse( - (response) => - response.url().includes(`/api/series/${SERIES.platformStandup}/ask`) && - response.request().method() === "POST", - { timeout: 30_000 } - ); - await page.getByRole("button", { name: "Ask series" }).click(); - await expect((await askResponse).ok()).toBeTruthy(); - - const answer = page.getByRole("region", { name: "Series answer" }); - await expect(answer).toBeVisible({ timeout: 20_000 }); - await expect(answer.getByText("Use GitHub Actions for CI/CD")).toBeVisible(); - await expect(answer.getByText("Sources")).toBeVisible(); - await expect( - answer.getByRole("link", { name: /Platform Standup #2/ }) - ).toHaveAttribute( - "href", - `/series/${SERIES.platformStandup}/meetings/${MEETINGS.standup2}` - ); - }); - test("shows unsupported answers without citations", async ({ page }) => { await page.route("**/api/series/*/ask", async (route) => { await route.fulfill({ diff --git a/package.json b/package.json index a55b711..0cccdbc 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "deploy:env": "node scripts/generate-self-host-env.mjs", "test:oss-boundaries": "node scripts/verify-oss-boundaries.mjs", "test:runtime-config": "node --test scripts/verify-runtime-config.test.mjs", + "test:query-contracts": "node scripts/verify-query-contracts.mjs", "test:ci-workflows": "node scripts/verify-ci-playwright-shards.mjs", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", diff --git a/scripts/verify-ai-notes-contract.mjs b/scripts/verify-ai-notes-contract.mjs index bb65a84..2dbb9ac 100644 --- a/scripts/verify-ai-notes-contract.mjs +++ b/scripts/verify-ai-notes-contract.mjs @@ -1,5 +1,8 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; +import * as esbuild from "esbuild"; const root = process.cwd(); @@ -99,6 +102,58 @@ assert(askSeriesRoute.includes("AI_API_KEY"), "Ask series route must support AI_ assert(askSeriesRoute.includes("https://openrouter.ai/api/v1/chat/completions"), "Ask series route must call OpenRouter chat completions"); assert(askSeriesRoute.includes("The source context does not prove the answer."), "Ask series route must include unsupported-answer guard copy"); +const askSeriesParserPath = "src/lib/ai/ask-series-answer.ts"; +assert(exists(askSeriesParserPath), "Missing Ask this series provider parser module"); + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "minutia-ai-contract-")); +const bundledParser = path.join(tempDir, "ask-series-answer.mjs"); +await esbuild.build({ + entryPoints: [askSeriesParserPath], + outfile: bundledParser, + bundle: true, + platform: "node", + format: "esm", + logLevel: "silent", +}); + +const { parseAskSeriesAnswer } = await import(pathToFileURL(bundledParser).href); +const sparseAnswer = parseAskSeriesAnswer({ + providerData: { + choices: [ + { + message: { + content: JSON.stringify({ + answer: "Use GitHub Actions for CI/CD.", + citations: [ + { + source_id: "20000000-0000-0000-0000-000000000002", + quote: "Use GitHub Actions for CI/CD", + }, + ], + unsupported: false, + }), + }, + }, + ], + }, + seriesId: "10000000-0000-0000-0000-000000000001", + meetings: [ + { + id: "20000000-0000-0000-0000-000000000002", + title: "Platform Standup #2", + }, + ], + issues: [], + decisions: [], +}); +assert(sparseAnswer.unsupported === false, "Sparse provider citations should stay supported"); +assert(sparseAnswer.citations[0]?.type === "notes", "Sparse meeting citations should resolve to notes"); +assert( + sparseAnswer.citations[0]?.href === + "/series/10000000-0000-0000-0000-000000000001/meetings/20000000-0000-0000-0000-000000000002", + "Sparse meeting citations should link to the source meeting" +); + const meetingDetail = read("src/app/(app)/series/[id]/meetings/[meetingId]/meeting-detail-content.tsx"); for (const copy of [ "Enhance notes", diff --git a/scripts/verify-query-contracts.mjs b/scripts/verify-query-contracts.mjs new file mode 100644 index 0000000..018db32 --- /dev/null +++ b/scripts/verify-query-contracts.mjs @@ -0,0 +1,18 @@ +import fs from "node:fs"; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +const useSeries = fs.readFileSync("src/lib/hooks/use-series.ts", "utf8"); + +assert( + !useSeries.includes("setInterval(refresh, 3000)") && + !useSeries.includes("setInterval(refresh, 2_000)") && + !useSeries.includes("setInterval(refresh, 2000)"), + "Series detail realtime must not poll every few seconds" +); + +console.log("Query contracts verified"); diff --git a/src/app/api/meetings/[meetingId]/enhance-notes/route.ts b/src/app/api/meetings/[meetingId]/enhance-notes/route.ts index d2bb47c..1d79b8d 100644 --- a/src/app/api/meetings/[meetingId]/enhance-notes/route.ts +++ b/src/app/api/meetings/[meetingId]/enhance-notes/route.ts @@ -82,18 +82,6 @@ function getTextFromOpenRouter(data: unknown) { } async function getOpenRouterData(prompt: string, apiKey: string) { - if (process.env.MINUTIA_TEST_OPENROUTER_RESPONSE) { - return { - choices: [ - { - message: { - content: process.env.MINUTIA_TEST_OPENROUTER_RESPONSE, - }, - }, - ], - }; - } - const providerResponse = await fetch(OPENROUTER_URL, { method: "POST", headers: { diff --git a/src/app/api/meetings/[meetingId]/suggestions/route.ts b/src/app/api/meetings/[meetingId]/suggestions/route.ts index 6e97a08..8ee302f 100644 --- a/src/app/api/meetings/[meetingId]/suggestions/route.ts +++ b/src/app/api/meetings/[meetingId]/suggestions/route.ts @@ -75,18 +75,6 @@ function buildPrompt(input: { } async function getOpenRouterData(prompt: string, apiKey: string) { - if (process.env.MINUTIA_TEST_AI_SUGGESTIONS_RESPONSE) { - return { - choices: [ - { - message: { - content: process.env.MINUTIA_TEST_AI_SUGGESTIONS_RESPONSE, - }, - }, - ], - }; - } - const providerResponse = await fetch(OPENROUTER_URL, { method: "POST", headers: { diff --git a/src/app/api/series/[seriesId]/ask/route.ts b/src/app/api/series/[seriesId]/ask/route.ts index 5773b98..9a0e8b9 100644 --- a/src/app/api/series/[seriesId]/ask/route.ts +++ b/src/app/api/series/[seriesId]/ask/route.ts @@ -1,62 +1,16 @@ 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"; const OPENROUTER_MODEL = "minimax/minimax-m3"; const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; const PROMPT_VERSION = "ask-series-v1"; -const UNSUPPORTED_ANSWER = "The source context does not prove the answer."; -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 requestSchema = z.object({ question: z.string().trim().min(1).max(1000), }); -const citationSchema = z.object({ - type: z.enum(["meeting", "issue", "decision", "notes"]), - source_id: uuidLikeSchema, - title: z.string().min(1).max(300), - meeting_id: uuidLikeSchema.nullable().default(null), - meeting_title: z.string().nullable().default(null), -}); - -const answerSchema = z.object({ - answer: z.string().trim().min(1).max(4000), - citations: z.array(citationSchema).default([]), - unsupported: z.boolean().default(false), -}); - -type AskSeriesAnswer = z.infer; -type AskSeriesCitation = z.infer & { - href: string; - label: string; -}; - -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: { question: string; series: { id: string; name: string; description: string | null }; @@ -111,18 +65,6 @@ function buildPrompt(input: { } async function getOpenRouterData(prompt: string, apiKey: string) { - if (process.env.MINUTIA_TEST_SERIES_ASK_RESPONSE) { - return { - choices: [ - { - message: { - content: process.env.MINUTIA_TEST_SERIES_ASK_RESPONSE, - }, - }, - ], - }; - } - const providerResponse = await fetch(OPENROUTER_URL, { method: "POST", headers: { @@ -151,62 +93,6 @@ async function getOpenRouterData(prompt: string, apiKey: string) { return providerResponse.json(); } -function citationHref(citation: z.infer) { - if (citation.type === "issue") return `/issues/${citation.source_id}`; - const meetingId = citation.type === "meeting" || citation.type === "notes" - ? citation.source_id - : citation.meeting_id; - return meetingId ? `meetings/${meetingId}` : ""; -} - -function citationLabel(citation: z.infer) { - const sourceLabel = citation.meeting_title || citation.title; - return citation.type === "notes" ? `Notes: ${sourceLabel}` : sourceLabel; -} - -function normalizeAnswer(input: { - parsed: AskSeriesAnswer; - seriesId: string; - meetingIds: Set; - issueIds: Set; - decisionIds: Set; -}) { - const validCitations: AskSeriesCitation[] = []; - - for (const citation of input.parsed.citations) { - const valid = - (citation.type === "meeting" && input.meetingIds.has(citation.source_id)) || - (citation.type === "notes" && input.meetingIds.has(citation.source_id)) || - (citation.type === "issue" && input.issueIds.has(citation.source_id)) || - (citation.type === "decision" && input.decisionIds.has(citation.source_id)); - if (!valid) continue; - - const relativeHref = citationHref(citation); - if (!relativeHref) continue; - validCitations.push({ - ...citation, - href: relativeHref.startsWith("/issues") - ? relativeHref - : `/series/${input.seriesId}/${relativeHref}`, - label: citationLabel(citation), - }); - } - - if (input.parsed.unsupported || validCitations.length === 0) { - return { - answer: UNSUPPORTED_ANSWER, - citations: [], - unsupported: true, - }; - } - - return { - answer: input.parsed.answer, - citations: validCitations, - unsupported: false, - }; -} - export async function POST( request: NextRequest, { params }: { params: Promise<{ seriesId: string }> } @@ -293,9 +179,26 @@ export async function POST( ); } - let parsed: AskSeriesAnswer; + let normalized; try { - parsed = answerSchema.parse(JSON.parse(getTextFromOpenRouter(providerData))); + normalized = parseAskSeriesAnswer({ + providerData, + seriesId, + meetings: (meetings ?? []).map((meeting) => ({ + id: meeting.id, + title: meeting.title, + })), + issues: (issues ?? []).map((issue) => ({ + id: issue.id, + title: issue.title, + meeting_id: issue.raised_in_meeting_id, + })), + decisions: (decisions ?? []).map((decision) => ({ + id: decision.id, + title: decision.title, + meeting_id: decision.meeting_id, + })), + }); } catch { return NextResponse.json( { error: "AI provider returned an invalid answer.", request_id: requestId }, @@ -303,14 +206,6 @@ export async function POST( ); } - const normalized = normalizeAnswer({ - parsed, - seriesId, - meetingIds: new Set((meetings ?? []).map((meeting) => meeting.id)), - issueIds: new Set((issues ?? []).map((issue) => issue.id)), - decisionIds: new Set((decisions ?? []).map((decision) => decision.id)), - }); - return NextResponse.json({ ...normalized, model: OPENROUTER_MODEL, diff --git a/src/lib/ai/ask-series-answer.ts b/src/lib/ai/ask-series-answer.ts new file mode 100644 index 0000000..e839898 --- /dev/null +++ b/src/lib/ai/ask-series-answer.ts @@ -0,0 +1,178 @@ +import { z } from "zod"; + +export const ASK_SERIES_UNSUPPORTED_ANSWER = "The source context does not prove the answer."; + +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 providerAnswerSchema = z.object({ + answer: z.string().trim().min(1).max(4000), + citations: z.array(providerCitationSchema).default([]), + unsupported: z.boolean().default(false), +}); + +const openRouterTextSchema = 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(); + +type ProviderCitation = z.infer; + +type SourceSummary = { + id: string; + title: string; + meeting_id?: string | null; + meeting_title?: string | null; +}; + +export type AskSeriesCitation = { + type: "meeting" | "issue" | "decision" | "notes"; + source_id: string; + title: string; + meeting_id: string | null; + meeting_title: string | null; + href: string; + label: string; +}; + +export type AskSeriesParsedAnswer = { + answer: string; + citations: AskSeriesCitation[]; + unsupported: boolean; +}; + +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"); +} + +function citationHref(input: { + type: AskSeriesCitation["type"]; + sourceId: string; + meetingId: string | null; +}) { + if (input.type === "issue") return `/issues/${input.sourceId}`; + const meetingId = input.type === "meeting" || input.type === "notes" + ? input.sourceId + : input.meetingId; + return meetingId ? `meetings/${meetingId}` : ""; +} + +function citationLabel(citation: Pick) { + const sourceLabel = citation.meeting_title || citation.title; + return citation.type === "notes" ? `Notes: ${sourceLabel}` : sourceLabel; +} + +function resolveCitation(input: { + citation: ProviderCitation; + seriesId: string; + meetings: Map; + issues: Map; + decisions: Map; +}): AskSeriesCitation | null { + const inferredType = + input.citation.type ?? + (input.meetings.has(input.citation.source_id) + ? "notes" + : input.issues.has(input.citation.source_id) + ? "issue" + : input.decisions.has(input.citation.source_id) + ? "decision" + : null); + if (!inferredType) return null; + + const source = + inferredType === "meeting" || inferredType === "notes" + ? input.meetings.get(input.citation.source_id) + : inferredType === "issue" + ? input.issues.get(input.citation.source_id) + : input.decisions.get(input.citation.source_id); + if (!source) return null; + + const title = input.citation.title || source.title; + const meeting_id = input.citation.meeting_id ?? source.meeting_id ?? null; + const meeting_title = input.citation.meeting_title ?? source.meeting_title ?? null; + const relativeHref = citationHref({ + type: inferredType, + sourceId: input.citation.source_id, + meetingId: meeting_id, + }); + if (!relativeHref) return null; + + const citation: AskSeriesCitation = { + type: inferredType, + source_id: input.citation.source_id, + title, + meeting_id, + meeting_title, + href: relativeHref.startsWith("/issues") + ? relativeHref + : `/series/${input.seriesId}/${relativeHref}`, + label: citationLabel({ type: inferredType, title, meeting_title }), + }; + + return citation; +} + +export function parseAskSeriesAnswer(input: { + providerData: unknown; + seriesId: string; + meetings: SourceSummary[]; + issues: SourceSummary[]; + decisions: SourceSummary[]; +}): AskSeriesParsedAnswer { + const parsed = providerAnswerSchema.parse(JSON.parse(getTextFromOpenRouter(input.providerData))); + const meetings = new Map(input.meetings.map((source) => [source.id, source])); + const issues = new Map(input.issues.map((source) => [source.id, source])); + 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, + }) + ) + .filter((citation): citation is AskSeriesCitation => citation !== null); + + if (parsed.unsupported || citations.length === 0) { + return { + answer: ASK_SERIES_UNSUPPORTED_ANSWER, + citations: [], + unsupported: true, + }; + } + + return { + answer: parsed.answer, + citations, + unsupported: false, + }; +} diff --git a/src/lib/hooks/use-series.ts b/src/lib/hooks/use-series.ts index 434dfbd..6f839a2 100644 --- a/src/lib/hooks/use-series.ts +++ b/src/lib/hooks/use-series.ts @@ -150,10 +150,8 @@ export function useSeriesRealtime(seriesId: string) { refresh ) .subscribe(); - const interval = window.setInterval(refresh, 3000); return () => { - window.clearInterval(interval); void supabase.removeChannel(channel); }; }, [queryClient, seriesId]);