diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2588b97..f603496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: - name: Verify OpenRouter client run: pnpm test:ai-client + - name: Verify carry-over logic + run: pnpm test:ai-carryover + - name: Build (includes typecheck) run: pnpm build env: diff --git a/e2e/regression/carryover-briefing.spec.ts b/e2e/regression/carryover-briefing.spec.ts new file mode 100644 index 0000000..ab1b3e5 --- /dev/null +++ b/e2e/regression/carryover-briefing.spec.ts @@ -0,0 +1,245 @@ +import { randomUUID } from "node:crypto"; +import { test, expect, type APIRequestContext } from "@playwright/test"; +import { 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 TEST_USER_ID = "00000000-0000-0000-0000-000000000001"; + +function serviceHeaders(prefer = "return=representation") { + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!key) throw new Error("SUPABASE_SERVICE_ROLE_KEY is required for this test"); + return { + apikey: key, + Authorization: `Bearer ${key}`, + "Content-Type": "application/json", + Prefer: prefer, + }; +} + +async function rest( + request: APIRequestContext, + path: string, + options: Parameters[1] = {} +) { + const response = await request.fetch(`${SUPABASE_URL}/rest/v1/${path}`, { + ...options, + headers: { + ...serviceHeaders(), + ...(options.headers ?? {}), + }, + }); + expect(response.ok()).toBeTruthy(); + return response.status() === 204 ? null : response.json(); +} + +async function deleteSeries(request: APIRequestContext, id: string) { + await rest(request, `meeting_series?id=eq.${id}`, { + method: "DELETE", + headers: serviceHeaders("return=minimal"), + }); +} + +// Series with an upcoming meeting and two open issues carried in: +// one overdue with no owner, one owned and on track. +async function createCarryoverFixture(request: APIRequestContext) { + const stamp = Date.now(); + const seriesId = randomUUID(); + const meetingId = randomUUID(); + + await rest(request, "meeting_series", { + method: "POST", + data: { + id: seriesId, + name: `Carry-over coverage ${stamp}`, + description: "Created by carry-over briefing coverage.", + cadence: "weekly", + default_attendees: ["Alice", "Bob"], + owner_id: TEST_USER_ID, + }, + }); + + await rest(request, "meetings", { + method: "POST", + data: { + id: meetingId, + series_id: seriesId, + sequence_number: 2, + title: `Carry-over session ${stamp}`, + date: "2026-12-01", + attendees: ["Alice", "Bob"], + status: "upcoming", + }, + }); + + await rest(request, "issues", { + method: "POST", + data: { + id: randomUUID(), + series_id: seriesId, + raised_in_meeting_id: meetingId, + title: `Overdue rollout ${stamp}`, + description: "Overdue carry-over item with no owner.", + category: "action", + status: "open", + priority: "high", + owner_name: null, + due_date: "2026-01-01", + source: "manual", + }, + }); + + await rest(request, "issues", { + method: "POST", + data: { + id: randomUUID(), + series_id: seriesId, + raised_in_meeting_id: meetingId, + title: `Owned follow-up ${stamp}`, + description: "Owned carry-over item on track.", + category: "risk", + status: "in_progress", + priority: "medium", + owner_name: "Alice", + due_date: "2026-12-15", + source: "manual", + }, + }); + + return { seriesId, meetingId }; +} + +test.describe("Carry-over briefing", () => { + test("returns 503 from the real endpoint when OpenRouter is not configured", async ({ + page, + request, + }) => { + test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated carry-over data"); + test.skip(HAS_OPENROUTER_KEY, "Requires OpenRouter to be unconfigured"); + + const fixture = await createCarryoverFixture(request); + + try { + await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`); + await waitForApp(page); + + const response = await page.request.post( + `/api/meetings/${fixture.meetingId}/carryover-briefing`, + { data: {}, timeout: 20_000 } + ); + expect(response.status()).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + error: "Carry-over briefing is not configured.", + }); + } finally { + await deleteSeries(request, fixture.seriesId); + } + }); + + test("generates a briefing with deterministic counts through the backend route", async ({ + page, + request, + }) => { + test.setTimeout(90_000); + test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated carry-over data"); + test.skip(!HAS_OPENROUTER_KEY, "Requires OpenRouter for backend carry-over coverage"); + + const fixture = await createCarryoverFixture(request); + + try { + const response = await page.request.post( + `/api/meetings/${fixture.meetingId}/carryover-briefing`, + { data: {}, timeout: 60_000 } + ); + expect(response.status()).toBe(200); + + const payload = await response.json(); + expect(payload).toMatchObject({ + prompt_version: "carryover-briefing-v1", + issues_count: 2, + overdue_count: 1, + no_owner_count: 1, + }); + expect(typeof payload.briefing_markdown).toBe("string"); + expect(payload.briefing_markdown.length).toBeGreaterThan(0); + } finally { + await deleteSeries(request, fixture.seriesId); + } + }); + + test("renders the carry-over panel in the upcoming meeting view", async ({ page, request }) => { + test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated carry-over data"); + + const fixture = await createCarryoverFixture(request); + + try { + await page.route("**/api/meetings/*/carryover-briefing", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + briefing_markdown: "2 open items, 1 overdue. Overdue rollout has no owner.", + overdue_count: 1, + no_owner_count: 1, + issues_count: 2, + model: "test-model", + prompt_version: "carryover-briefing-v1", + }), + }); + }); + + await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`); + await waitForApp(page); + + await expect(page.getByRole("heading", { name: "Carry-over briefing" })).toBeVisible({ + timeout: 20_000, + }); + await page.getByRole("button", { name: "Generate briefing" }).click(); + await expect(page.getByText("2 open items, 1 overdue.")).toBeVisible(); + } finally { + await deleteSeries(request, fixture.seriesId); + } + }); + + test("shows an error state when the briefing fails", async ({ page, request }) => { + test.skip(!HAS_SERVICE_ROLE, "Requires service role setup for isolated carry-over data"); + + const fixture = await createCarryoverFixture(request); + + try { + await page.route("**/api/meetings/*/carryover-briefing", async (route) => { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ error: "Carry-over briefing is not configured." }), + }); + }); + + await page.goto(`/series/${fixture.seriesId}/meetings/${fixture.meetingId}`); + await waitForApp(page); + + await expect(page.getByRole("heading", { name: "Carry-over briefing" })).toBeVisible({ + timeout: 20_000, + }); + await page.getByRole("button", { name: "Generate briefing" }).click(); + await expect(page.getByText("Carry-over briefing is not configured.")).toBeVisible(); + } finally { + await deleteSeries(request, fixture.seriesId); + } + }); +}); + +test.describe("Carry-over briefing auth", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test("rejects unauthenticated briefing requests before checking provider config", async ({ + request, + }) => { + const response = await request.post( + `/api/meetings/${randomUUID()}/carryover-briefing`, + { data: {} } + ); + expect(response.status()).toBe(401); + }); +}); diff --git a/package.json b/package.json index d8762c9..d16995b 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "test:ci-workflows": "node scripts/verify-ci-playwright-shards.mjs", "test:ai-contracts": "node scripts/verify-ai-notes-contract.mjs", "test:ai-client": "node --test scripts/verify-openrouter-client.test.mjs", + "test:ai-carryover": "node --test scripts/verify-carryover.test.mjs", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:ui": "playwright test --ui", diff --git a/scripts/verify-ai-notes-contract.mjs b/scripts/verify-ai-notes-contract.mjs index 3dca4e9..383791e 100644 --- a/scripts/verify-ai-notes-contract.mjs +++ b/scripts/verify-ai-notes-contract.mjs @@ -293,4 +293,34 @@ for (const copy of [ assert(seriesDetail.includes(copy), `Series detail missing Ask UI copy: ${copy}`); } +// Carry-over briefing (OIL-native pre-meeting intelligence). +assert( + exists("src/app/api/meetings/[meetingId]/carryover-briefing/route.ts"), + "Missing carry-over briefing API route" +); +const carryoverRoute = read("src/app/api/meetings/[meetingId]/carryover-briefing/route.ts"); +assertSharedClient(carryoverRoute, "Carryover briefing"); +assert(carryoverRoute.includes("carryover-briefing-v1"), "Carryover briefing route must declare a prompt version"); +assert( + carryoverRoute.includes("Do not invent owners, dates, or resolutions"), + "Carryover briefing prompt must forbid invented content" +); +assert( + carryoverRoute.includes("Do not wrap it in markdown fences"), + "Carryover briefing prompt must forbid fenced output" +); +assert( + carryoverRoute.includes("summarizeCarryover"), + "Carryover briefing must derive counts from the deterministic summary, not the model" +); + +assert( + meetingDetail.includes("CarryoverBriefingPanel"), + "Upcoming meeting view must mount the carry-over briefing panel" +); +const carryoverPanel = read("src/components/minutia/carryover-briefing-panel.tsx"); +for (const copy of ["Carry-over briefing", "Generate briefing"]) { + assert(carryoverPanel.includes(copy), `Carry-over panel missing UI copy: ${copy}`); +} + console.log("AI notes contract verified"); diff --git a/scripts/verify-carryover.test.mjs b/scripts/verify-carryover.test.mjs new file mode 100644 index 0000000..dd6cada --- /dev/null +++ b/scripts/verify-carryover.test.mjs @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +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"; + +// Bundle the pure carry-over logic so node:test can exercise it (repo pattern). +const root = process.cwd(); +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "minutia-carryover-")); +const bundled = path.join(tempDir, "carryover.mjs"); +await esbuild.build({ + entryPoints: ["src/lib/ai/carryover.ts"], + outfile: bundled, + bundle: true, + platform: "node", + format: "esm", + logLevel: "silent", + absWorkingDir: root, +}); +const { summarizeCarryover, parseCarryoverBriefing } = await import(pathToFileURL(bundled).href); + +const TODAY = new Date("2026-06-06T00:00:00Z"); + +const ISSUES = [ + // Overdue, owned, very old -> stale. + { issue_number: 1, title: "Ship CI", category: "action", status: "open", owner_name: "Sam", due_date: "2026-06-01", created_at: "2026-01-01T00:00:00Z" }, + // Future due, no owner, fresh. + { issue_number: 2, title: "Draft RFC", category: "info", status: "open", owner_name: null, due_date: "2026-12-01", created_at: "2026-06-05T00:00:00Z" }, + // No due date, owned, old -> stale. + { issue_number: 3, title: "Coverage gap", category: "risk", status: "in_progress", owner_name: "Lee", due_date: null, created_at: "2026-05-01T00:00:00Z" }, +]; + +test("summarizeCarryover flags overdue items and orders them first", () => { + const summary = summarizeCarryover(ISSUES, TODAY); + assert.equal(summary.total, 3); + assert.equal(summary.issues[0].issue_number, 1); + assert.equal(summary.issues[0].overdue, true); + assert.equal(summary.overdue_count, 1); +}); + +test("summarizeCarryover counts items with no owner", () => { + const summary = summarizeCarryover(ISSUES, TODAY); + assert.equal(summary.no_owner_count, 1); +}); + +test("summarizeCarryover computes days_open and flags stale items", () => { + const summary = summarizeCarryover(ISSUES, TODAY); + const first = summary.issues.find((i) => i.issue_number === 1); + assert.ok(first.days_open > 150, `expected >150 days open, got ${first.days_open}`); + // Issue 1 (~156d) and issue 3 (~36d) are stale; issue 2 (~1d) is not. + assert.equal(summary.stale_count, 2); +}); + +test("summarizeCarryover orders non-overdue by due date with nulls last", () => { + const summary = summarizeCarryover(ISSUES, TODAY); + assert.deepEqual(summary.issues.map((i) => i.issue_number), [1, 2, 3]); +}); + +test("summarizeCarryover returns zeros for an empty list", () => { + const summary = summarizeCarryover([], TODAY); + assert.deepEqual(summary, { total: 0, overdue_count: 0, no_owner_count: 0, stale_count: 0, issues: [] }); +}); + +test("parseCarryoverBriefing tolerates markdown-fenced provider JSON", () => { + const providerData = { + choices: [ + { + message: { + content: "```json\n" + JSON.stringify({ + briefing_markdown: "3 open items, 1 overdue.", + overdue_count: 1, + no_owner_count: 1, + }) + "\n```", + }, + }, + ], + }; + const parsed = parseCarryoverBriefing(providerData); + assert.equal(parsed.briefing_markdown, "3 open items, 1 overdue."); + assert.equal(parsed.overdue_count, 1); + assert.equal(parsed.no_owner_count, 1); +}); diff --git a/src/app/(app)/series/[id]/meetings/[meetingId]/meeting-detail-content.tsx b/src/app/(app)/series/[id]/meetings/[meetingId]/meeting-detail-content.tsx index d5da309..0856237 100644 --- a/src/app/(app)/series/[id]/meetings/[meetingId]/meeting-detail-content.tsx +++ b/src/app/(app)/series/[id]/meetings/[meetingId]/meeting-detail-content.tsx @@ -32,6 +32,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Textarea } from "@/components/ui/textarea"; import { ShareButton } from "@/components/minutia/share-button"; import { SendMeetingNotesButton } from "@/components/minutia/send-meeting-notes-button"; +import { CarryoverBriefingPanel } from "@/components/minutia/carryover-briefing-panel"; import { ArrowLeft, Square, Play, Check, X, Copy, CheckCheck, Sparkles, Loader2, ListChecks, FileText, CheckSquare, Gavel, AlertTriangle, Ban, RotateCcw, HelpCircle } from "lucide-react"; import { cn } from "@/lib/utils"; import { formatShortDate } from "@/lib/date-utils"; @@ -1317,7 +1318,7 @@ export function MeetingDetailContent({ -
+
+
+ +
+ {meeting.attendees && meeting.attendees.length > 0 && (

diff --git a/src/app/api/meetings/[meetingId]/carryover-briefing/route.ts b/src/app/api/meetings/[meetingId]/carryover-briefing/route.ts new file mode 100644 index 0000000..7e54009 --- /dev/null +++ b/src/app/api/meetings/[meetingId]/carryover-briefing/route.ts @@ -0,0 +1,153 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { callOpenRouter, getOpenRouterApiKey } from "@/lib/ai/openrouter"; +import { + summarizeCarryover, + parseCarryoverBriefing, + type CarryoverIssue, + type CarryoverSummary, +} from "@/lib/ai/carryover"; + +const PROMPT_VERSION = "carryover-briefing-v1"; +const SYSTEM_PROMPT = "You write concise pre-meeting carry-over briefings. Return valid JSON only."; + +function buildPrompt(input: { + seriesName: string; + meetingTitle: string; + summary: CarryoverSummary; +}) { + const items = input.summary.issues.slice(0, 12).map((issue) => ({ + issue: issue.issue_number, + title: issue.title, + category: issue.category, + status: issue.status, + owner: issue.owner_name ?? null, + due_date: issue.due_date, + overdue: issue.overdue, + days_open: issue.days_open, + })); + + return [ + "Write a pre-meeting carry-over briefing for a Minutia recurring meeting series.", + "A facilitator reads this to know what slipped before the meeting starts.", + "", + "OUTPUT CONTRACT", + 'Return only a single JSON object: {"briefing_markdown": "...", "overdue_count": N, "no_owner_count": N}.', + "Do not wrap it in markdown fences. Do not add any text before or after the JSON.", + "", + "briefing_markdown rules:", + "- 3 to 6 sentences of concise markdown.", + "- Lead with the count of open items and how many are overdue.", + "- Name up to 5 highest-priority items with their owner and due date.", + "- Call out items with no owner explicitly.", + "- Flag items open a long time as stale.", + "Do not invent owners, dates, or resolutions. Use only the data provided.", + "", + `Series: ${input.seriesName}`, + `Upcoming meeting: ${input.meetingTitle}`, + `Totals: ${input.summary.total} open, ${input.summary.overdue_count} overdue, ${input.summary.no_owner_count} without an owner, ${input.summary.stale_count} stale.`, + "", + "Open issues (already ranked, overdue first):", + JSON.stringify(items), + ].join("\n"); +} + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ meetingId: string }> } +) { + const requestId = crypto.randomUUID(); + const { meetingId } = await params; + + 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 = getOpenRouterApiKey(); + if (!apiKey) { + return NextResponse.json( + { error: "Carry-over briefing is not configured.", request_id: requestId }, + { status: 503 } + ); + } + + const { data: meeting, error } = await supabase + .from("meetings") + .select("*, series:meeting_series!inner(name)") + .eq("id", meetingId) + .single(); + if (error || !meeting) { + return NextResponse.json({ error: "Meeting not found", request_id: requestId }, { status: 404 }); + } + + const { data: issues, error: issuesError } = await supabase + .from("issues") + .select("issue_number,title,category,status,priority,owner_name,due_date,created_at") + .eq("series_id", meeting.series_id) + .not("status", "in", "(resolved,dropped)") + .order("due_date", { ascending: true, nullsFirst: false }) + .limit(30); + if (issuesError) { + return NextResponse.json( + { error: "Failed to load carry-over issues.", request_id: requestId }, + { status: 500 } + ); + } + + const summary = summarizeCarryover((issues ?? []) as CarryoverIssue[], new Date()); + + // Nothing open means nothing to brief: skip the provider call entirely. + if (summary.total === 0) { + return NextResponse.json({ + briefing_markdown: "", + overdue_count: 0, + no_owner_count: 0, + issues_count: 0, + model: null, + prompt_version: PROMPT_VERSION, + request_id: requestId, + }); + } + + const prompt = buildPrompt({ + seriesName: meeting.series?.name ?? "Untitled series", + meetingTitle: meeting.title, + summary, + }); + + let providerData: unknown; + let model: string; + try { + ({ data: providerData, model } = await callOpenRouter({ apiKey, system: SYSTEM_PROMPT, prompt })); + } catch { + return NextResponse.json( + { error: "AI provider request failed.", request_id: requestId }, + { status: 502 } + ); + } + + let parsed; + try { + parsed = parseCarryoverBriefing(providerData); + } catch { + return NextResponse.json( + { error: "AI provider returned an invalid briefing.", request_id: requestId }, + { status: 502 } + ); + } + + // Counts come from our deterministic summary, not the model's claims. + return NextResponse.json({ + briefing_markdown: parsed.briefing_markdown, + overdue_count: summary.overdue_count, + no_owner_count: summary.no_owner_count, + issues_count: summary.total, + model, + prompt_version: PROMPT_VERSION, + request_id: requestId, + }); +} diff --git a/src/components/minutia/carryover-briefing-panel.tsx b/src/components/minutia/carryover-briefing-panel.tsx new file mode 100644 index 0000000..d42e421 --- /dev/null +++ b/src/components/minutia/carryover-briefing-panel.tsx @@ -0,0 +1,104 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Sparkles, Loader2 } from "lucide-react"; + +type Briefing = { + briefing_markdown: string; + overdue_count: number; + no_owner_count: number; + issues_count: number; +}; + +function Stat({ label, value, tone }: { label: string; value: number; tone?: "danger" | "warn" }) { + const toneClass = tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : "text-ink"; + return ( + + {value} + {label} + + ); +} + +export function CarryoverBriefingPanel({ + meetingId, + issueCount, +}: { + meetingId: string; + issueCount: number; +}) { + const [briefing, setBriefing] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + // Nothing carried over means nothing to brief. + if (issueCount === 0) return null; + + async function handleGenerate() { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/meetings/${meetingId}/carryover-briefing`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + setError(payload.error ?? "Carry-over briefing could not be generated."); + return; + } + setBriefing(payload as Briefing); + } catch { + setError("Carry-over briefing could not be generated."); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ +

Carry-over briefing

+
+ {!briefing && ( + + )} +
+ +

+ {issueCount} open {issueCount === 1 ? "item" : "items"} carried from earlier meetings. +

+ + {error &&

{error}

} + + {briefing && ( +
+
+ + + +
+

+ {briefing.briefing_markdown} +

+ +
+ )} +
+ ); +} diff --git a/src/lib/ai/carryover.ts b/src/lib/ai/carryover.ts new file mode 100644 index 0000000..78a259c --- /dev/null +++ b/src/lib/ai/carryover.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; +import { getTextFromOpenRouter } from "./ask-series-answer"; + +// Pure carry-over logic: turn a series' open issues into a ranked, scored +// accountability summary. The AI only narrates this; the numbers are computed +// here so they are deterministic and testable. +const DAY_MS = 86_400_000; +const STALE_DAYS = 30; + +export type CarryoverIssue = { + issue_number: number; + title: string; + category: string; + status: string; + priority?: string; + owner_name: string | null; + due_date: string | null; // YYYY-MM-DD + created_at: string; // ISO timestamp +}; + +export type RankedCarryoverIssue = CarryoverIssue & { + days_open: number; + overdue: boolean; +}; + +export type CarryoverSummary = { + total: number; + overdue_count: number; + no_owner_count: number; + stale_count: number; + issues: RankedCarryoverIssue[]; +}; + +function toDateOnly(date: Date) { + return date.toISOString().slice(0, 10); +} + +export function summarizeCarryover(issues: CarryoverIssue[], today: Date): CarryoverSummary { + const todayStr = toDateOnly(today); + + const ranked: RankedCarryoverIssue[] = issues.map((issue) => ({ + ...issue, + overdue: issue.due_date != null && issue.due_date < todayStr, + days_open: Math.max( + 0, + Math.floor((today.getTime() - new Date(issue.created_at).getTime()) / DAY_MS) + ), + })); + + ranked.sort((a, b) => { + if (a.overdue !== b.overdue) return a.overdue ? -1 : 1; + if (a.due_date !== b.due_date) { + if (a.due_date == null) return 1; + if (b.due_date == null) return -1; + return a.due_date < b.due_date ? -1 : 1; + } + return b.days_open - a.days_open; // staler first + }); + + return { + total: ranked.length, + overdue_count: ranked.filter((issue) => issue.overdue).length, + no_owner_count: ranked.filter((issue) => !issue.owner_name).length, + stale_count: ranked.filter((issue) => issue.days_open >= STALE_DAYS).length, + issues: ranked, + }; +} + +const briefingSchema = z.object({ + briefing_markdown: z.string().default(""), + overdue_count: z.number().int().min(0).default(0), + no_owner_count: z.number().int().min(0).default(0), +}); + +export type CarryoverBriefing = z.infer; + +export function parseCarryoverBriefing(providerData: unknown): CarryoverBriefing { + return briefingSchema.parse(JSON.parse(getTextFromOpenRouter(providerData))); +}