diff --git a/apps/expo/src/app/article-detail.tsx b/apps/expo/src/app/article-detail.tsx index 2c35d06d..81ce8cbd 100644 --- a/apps/expo/src/app/article-detail.tsx +++ b/apps/expo/src/app/article-detail.tsx @@ -283,7 +283,10 @@ export default function ArticleDetailScreen() { ? ({ kind: "bill", ...rawBrief } as ArticleBrief) : rawBrief; const billBrief = brief?.kind === "bill" ? brief : null; - const narrativeBrief = brief?.kind === "court_case" ? brief : null; + const narrativeBrief = + brief?.kind === "court_case" || brief?.kind === "government_action" + ? brief + : null; const activeContent = mode === "explainer" ? content.articleContent : content.originalContent; diff --git a/apps/expo/src/components/ui/NarrativeBrief.tsx b/apps/expo/src/components/ui/NarrativeBrief.tsx index 79ecc8e1..1629d3c5 100644 --- a/apps/expo/src/components/ui/NarrativeBrief.tsx +++ b/apps/expo/src/components/ui/NarrativeBrief.tsx @@ -9,8 +9,8 @@ import { colors, fontBody, fontDisplay, hair, planes } from "~/styles"; import { Icon } from "./Icon"; export interface NarrativeBriefData { - kind: "court_case"; - presentation: "court_case"; + kind: "court_case" | "government_action"; + presentation: "court_case" | "executive_action" | "ceremonial"; badge: string; hook: string; facts: { @@ -69,15 +69,27 @@ export function NarrativeBrief({ onViewSource?: (quote: BriefQuote) => void; }) { const [termsOpen, setTermsOpen] = useState(false); + const heading = + data.presentation === "court_case" + ? "Why this case matters" + : data.presentation === "ceremonial" + ? "What this proclamation means" + : "What this action does"; + const icon = + data.presentation === "court_case" + ? "scale" + : data.presentation === "ceremonial" + ? "flag" + : "doc"; return ( - + - Why this case matters + {heading} {data.badge} @@ -172,7 +184,9 @@ export function NarrativeBrief({ ) : null} - {dualLens ? {dualLens} : null} + {data.presentation !== "ceremonial" && dualLens ? ( + {dualLens} + ) : null} ); } diff --git a/apps/scraper/src/retroactive-briefs.ts b/apps/scraper/src/retroactive-briefs.ts index 3c7fcfcd..a73f89dc 100644 --- a/apps/scraper/src/retroactive-briefs.ts +++ b/apps/scraper/src/retroactive-briefs.ts @@ -1,6 +1,6 @@ /** - * Backfill structured briefs for bills and court cases that predate the brief - * pipeline, or whose brief is stale relative to the source contentHash. + * Backfill structured briefs for bills, court cases, and government actions + * that predate the brief pipeline or have stale source content. * * Mirrors `retroactive-lenses.ts`. Existing long-form analysis is passed to * the content-specific generator as context, while quotes are still verified @@ -9,14 +9,20 @@ import yargs from "yargs"; import { hideBin } from "yargs/helpers"; -import { and, desc, eq, isNotNull, isNull, ne, or } from "@acme/db"; +import { and, desc, eq, inArray, isNotNull, isNull, ne, or } from "@acme/db"; import { db } from "@acme/db/client"; -import { Bill, ContentBrief, CourtCase } from "@acme/db/schema"; +import { + Bill, + ContentBrief, + CourtCase, + GovernmentContent, +} from "@acme/db/schema"; import { AIRateLimitError } from "./utils/ai/text-generation.js"; import { upsertBillBrief, upsertCourtCaseBrief, + upsertGovernmentActionBrief, } from "./utils/db/operations.js"; import { createLogger } from "./utils/log.js"; @@ -46,7 +52,27 @@ interface CourtBriefCandidate { aiGeneratedArticle: string | null; } -type BriefCandidate = BillBriefCandidate | CourtBriefCandidate; +interface GovernmentBriefCandidate { + type: "government_content"; + id: string; + contentHash: string; + title: string; + documentType: string; + description: string | null; + fullText: string; + aiGeneratedArticle: string | null; +} + +type BriefCandidate = + | BillBriefCandidate + | CourtBriefCandidate + | GovernmentBriefCandidate; + +function candidateIdentifier(candidate: BriefCandidate): string { + if (candidate.type === "bill") return candidate.billNumber; + if (candidate.type === "court_case") return candidate.caseNumber; + return candidate.documentType; +} async function findBills(limit: number): Promise { const rows = await db @@ -126,6 +152,58 @@ async function findCourtCases(limit: number): Promise { ); } +async function findGovernmentContent( + limit: number, +): Promise { + const rows = await db + .select({ + id: GovernmentContent.id, + contentHash: GovernmentContent.contentHash, + title: GovernmentContent.title, + documentType: GovernmentContent.type, + description: GovernmentContent.description, + fullText: GovernmentContent.fullText, + aiGeneratedArticle: GovernmentContent.aiGeneratedArticle, + }) + .from(GovernmentContent) + .leftJoin( + ContentBrief, + and( + eq(ContentBrief.contentType, "government_content"), + eq(ContentBrief.contentId, GovernmentContent.id), + ), + ) + .where( + and( + isNotNull(GovernmentContent.fullText), + inArray(GovernmentContent.type, [ + "Executive Order", + "Memorandum", + "Proclamation", + "Presidential Proclamation", + ]), + or( + isNull(ContentBrief.id), + ne(ContentBrief.contentHash, GovernmentContent.contentHash), + ), + ), + ) + .orderBy(desc(GovernmentContent.createdAt)) + .limit(limit); + + return rows.flatMap((row) => + row.fullText + ? [ + { + ...row, + type: "government_content" as const, + fullText: row.fullText, + }, + ] + : [], + ); +} + const argv = await yargs(hideBin(process.argv)) .option("limit", { alias: "l", @@ -140,7 +218,7 @@ const argv = await yargs(hideBin(process.argv)) describe: "List candidates without generating briefs", }) .option("type", { - choices: ["bill", "court_case", "all"] as const, + choices: ["bill", "court_case", "government_content", "all"] as const, default: "all" as const, describe: "Content type to backfill", }) @@ -154,8 +232,15 @@ const argv = await yargs(hideBin(process.argv)) .parse(); const candidates: BriefCandidate[] = [ - ...(argv.type === "court_case" ? [] : await findBills(argv.limit)), - ...(argv.type === "bill" ? [] : await findCourtCases(argv.limit)), + ...(argv.type === "all" || argv.type === "bill" + ? await findBills(argv.limit) + : []), + ...(argv.type === "all" || argv.type === "court_case" + ? await findCourtCases(argv.limit) + : []), + ...(argv.type === "all" || argv.type === "government_content" + ? await findGovernmentContent(argv.limit) + : []), ].slice(0, argv.limit); logger.info(`Found ${candidates.length} missing/stale brief candidate(s)`); @@ -164,35 +249,47 @@ let failed = 0; for (const candidate of candidates) { if (argv.dryRun) { - const identifier = - candidate.type === "bill" ? candidate.billNumber : candidate.caseNumber; - logger.info(`[dry run] ${identifier}: ${candidate.title}`); + logger.info( + `[dry run] ${candidateIdentifier(candidate)}: ${candidate.title}`, + ); continue; } try { - const generated = - candidate.type === "bill" - ? await upsertBillBrief({ - contentId: candidate.id, - contentHash: candidate.contentHash, - title: candidate.title, - billNumber: candidate.billNumber, - url: candidate.url, - fullText: candidate.fullText, - status: candidate.status, - priorArticle: candidate.aiGeneratedArticle, - }) - : await upsertCourtCaseBrief({ - contentId: candidate.id, - contentHash: candidate.contentHash, - title: candidate.title, - court: candidate.court, - caseNumber: candidate.caseNumber, - fullText: candidate.fullText, - status: candidate.status, - priorArticle: candidate.aiGeneratedArticle, - }); + let generated: boolean; + if (candidate.type === "bill") { + generated = await upsertBillBrief({ + contentId: candidate.id, + contentHash: candidate.contentHash, + title: candidate.title, + billNumber: candidate.billNumber, + url: candidate.url, + fullText: candidate.fullText, + status: candidate.status, + priorArticle: candidate.aiGeneratedArticle, + }); + } else if (candidate.type === "court_case") { + generated = await upsertCourtCaseBrief({ + contentId: candidate.id, + contentHash: candidate.contentHash, + title: candidate.title, + court: candidate.court, + caseNumber: candidate.caseNumber, + fullText: candidate.fullText, + status: candidate.status, + priorArticle: candidate.aiGeneratedArticle, + }); + } else { + generated = await upsertGovernmentActionBrief({ + contentId: candidate.id, + contentHash: candidate.contentHash, + title: candidate.title, + documentType: candidate.documentType, + description: candidate.description, + fullText: candidate.fullText, + priorArticle: candidate.aiGeneratedArticle, + }); + } if (generated) processed++; else failed++; } catch (error) { @@ -202,9 +299,7 @@ for (const candidate of candidates) { break; } failed++; - const identifier = - candidate.type === "bill" ? candidate.billNumber : candidate.caseNumber; - logger.error(`Failed brief for ${identifier}`, error); + logger.error(`Failed brief for ${candidateIdentifier(candidate)}`, error); } } diff --git a/apps/scraper/src/utils/ai/government-action-brief.test.ts b/apps/scraper/src/utils/ai/government-action-brief.test.ts new file mode 100644 index 00000000..ea7750be --- /dev/null +++ b/apps/scraper/src/utils/ai/government-action-brief.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + generateGovernmentActionBrief, + isCeremonialGovernmentContent, + isGovernmentActionDocumentType, +} from "./government-action-brief.js"; + +test("recognizes commemorative proclamations without classifying substantive orders", () => { + assert.equal( + isCeremonialGovernmentContent( + "Proclamation on Independence Day", + "Proclamation", + ), + true, + ); + assert.equal( + isCeremonialGovernmentContent( + "National Wildfire Preparedness Month", + "Presidential Proclamation", + ), + true, + ); + assert.equal( + isCeremonialGovernmentContent( + "Proclamation Restricting Entry of Certain Foreign Nationals", + "Proclamation", + ), + false, + ); + assert.equal( + isCeremonialGovernmentContent( + "Executive Order Establishing a National Preparedness Day", + "Executive Order", + ), + false, + ); +}); + +test("limits structured government briefs to presidential actions", () => { + assert.equal(isGovernmentActionDocumentType("Executive Order"), true); + assert.equal(isGovernmentActionDocumentType("Memorandum"), true); + assert.equal(isGovernmentActionDocumentType("Proclamation"), true); + assert.equal(isGovernmentActionDocumentType("News Article"), false); +}); + +test("ceremonial briefs are deterministic and do not require an LLM provider", async () => { + const brief = await generateGovernmentActionBrief({ + title: "Proclamation on Independence Day", + documentType: "Proclamation", + description: "Recognizes July 4 as Independence Day.", + fullText: "I hereby proclaim July 4 as Independence Day.", + }); + + assert.equal(brief?.presentation, "ceremonial"); + assert.equal(brief?.verifiedQuotes, 0); + assert.match(brief?.hook ?? "", /ceremonial recognition/i); +}); diff --git a/apps/scraper/src/utils/ai/government-action-brief.ts b/apps/scraper/src/utils/ai/government-action-brief.ts new file mode 100644 index 00000000..e0bed9a5 --- /dev/null +++ b/apps/scraper/src/utils/ai/government-action-brief.ts @@ -0,0 +1,188 @@ +import { APICallError, generateText, Output, RetryError } from "ai"; + +import type { + GovernmentActionBrief, + GovernmentActionBriefRecord, +} from "@acme/validators"; +import { + GOVERNMENT_ACTION_BRIEF_VERSION, + GovernmentActionBriefSchema, +} from "@acme/validators"; + +import { trackLLMUsage } from "../costs.js"; +import { createLogger } from "../log.js"; +import { verifyCourtCaseBriefQuotes } from "./court-case-brief.js"; +import { getTextLlm } from "./provider.js"; +import { + AIRateLimitError, + rateLimitHit, + setRateLimitHit, +} from "./text-generation.js"; + +const logger = createLogger("government-action-brief"); +const SOURCE_WINDOW = 24_000; + +function isRateLimitError(error: unknown): boolean { + if (error instanceof APICallError) return error.statusCode === 429; + if (error instanceof RetryError) return isRateLimitError(error.lastError); + return ( + error instanceof Error && + /429|rate limit|resource_exhausted|quota/i.test(error.message) + ); +} + +export function isCeremonialGovernmentContent( + title: string, + documentType: string, +): boolean { + return ( + /proclamation/i.test(documentType) && + /\b(?:day|week|month|anniversary|commemor\w*|honor\w*|recogniz\w*|celebrat\w*)\b/i.test( + title, + ) + ); +} + +export function isGovernmentActionDocumentType(documentType: string): boolean { + return /^(?:executive order|memorandum|(?:presidential )?proclamation)$/i.test( + documentType.trim(), + ); +} + +function buildCeremonialBrief(args: { + title: string; + documentType: string; + description?: string | null; +}): GovernmentActionBrief { + const subject = args.description?.trim() || args.title; + return { + badge: "PROCLAMATION", + hook: `${subject} This is **a ceremonial recognition and public call to awareness**; it does not by itself create a new law, spending program, or requirement for the public.`, + facts: [ + { label: "Document", value: args.documentType }, + { label: "Legal effect", value: "No new mandate" }, + ], + terms: [], + sections: [ + { + title: "What it recognizes", + items: [ + { + text: `The proclamation uses the presidency's public platform to **recognize ${args.title.replace(/^Proclamation (?:on|for) /i, "")}** and encourage awareness or voluntary action.`, + }, + ], + }, + { + title: "What it does not do", + items: [ + { + text: "The designation does **not appropriate money, rewrite regulations, or require anyone to participate** unless the official text separately orders a concrete action.", + }, + ], + }, + ], + unknowns: [], + }; +} + +function buildPrompt(args: { + title: string; + documentType: string; + description?: string | null; + fullText: string; + priorArticle?: string | null; +}): string { + return `You are a nonpartisan civic analyst writing a structured brief about a +presidential ${args.documentType}. Write for an average citizen using short, +coherent sentences and familiar words. Define unavoidable government terms. +Mark two or three short key phrases in the hook and one important phrase in +every other prose item with **double asterisks**. Never bold a whole sentence. + +Distinguish an instruction to federal agencies from a law passed by Congress. +Do not say the document directly requires companies, states, or members of the +public to act unless its legal mechanism actually does so. Separate goals from +binding directions, and separate an announced spending priority from money +Congress has approved. + +Use sections appropriate to this document, normally: +- "What the President directed" +- "How it takes effect" +- "Who it reaches" +- "What it does not do" when readers could confuse it with legislation + +Every example must explain the causal link: name the concrete directive, who +must carry it out, and what a person or organization would experience. Do not +manufacture an effect, controversy, motive, or funding source. + +Every quote must be an exact unedited span from the official source below. +Include fewer facts or quotes rather than inventing them. Put only genuinely +unresolved implementation details in "unknowns". + +Document: ${args.documentType} — ${args.title} +Description: ${args.description ?? "not provided"} +${ + args.priorArticle + ? `Existing analysis (use for context, but quote only the official source):\n${args.priorArticle.slice(0, 6000)}\n` + : "" +} +Official source: +${args.fullText.slice(0, SOURCE_WINDOW)}`; +} + +export async function generateGovernmentActionBrief(args: { + title: string; + documentType: string; + description?: string | null; + fullText: string; + priorArticle?: string | null; +}): Promise | null> { + const ceremonial = isCeremonialGovernmentContent( + args.title, + args.documentType, + ); + if (ceremonial) { + return { + ...buildCeremonialBrief(args), + kind: "government_action", + presentation: "ceremonial", + version: GOVERNMENT_ACTION_BRIEF_VERSION, + verifiedQuotes: 0, + }; + } + if (rateLimitHit) throw new AIRateLimitError(); + + try { + const { output, usage } = await generateText({ + model: getTextLlm(), + output: Output.object({ schema: GovernmentActionBriefSchema }), + prompt: buildPrompt(args), + }); + trackLLMUsage(usage.inputTokens, usage.outputTokens); + const verified = verifyCourtCaseBriefQuotes(output, args.fullText); + if (verified.dropped > 0) { + logger.warn( + `Government brief for "${args.title}": dropped ${verified.dropped} unverified quote(s)`, + ); + } + return { + ...verified.brief, + kind: "government_action", + presentation: "executive_action", + version: GOVERNMENT_ACTION_BRIEF_VERSION, + verifiedQuotes: verified.verified, + }; + } catch (error) { + if (isRateLimitError(error)) { + setRateLimitHit(true); + throw new AIRateLimitError(); + } + logger.warn( + `Government brief generation failed for "${args.title}"`, + error, + ); + return null; + } +} diff --git a/apps/scraper/src/utils/db/operations.ts b/apps/scraper/src/utils/db/operations.ts index 18954345..44b65fa8 100644 --- a/apps/scraper/src/utils/db/operations.ts +++ b/apps/scraper/src/utils/db/operations.ts @@ -7,7 +7,11 @@ import { CourtCase, GovernmentContent, } from "@acme/db/schema"; -import { isCurrentBillBrief, isCurrentCourtCaseBrief } from "@acme/validators"; +import { + isCurrentBillBrief, + isCurrentCourtCaseBrief, + isCurrentGovernmentActionBrief, +} from "@acme/validators"; import type { NewItemLimiter } from "../new-item-limit.js"; import type { @@ -17,6 +21,11 @@ import type { } from "../types.js"; import { generateBillBrief } from "../ai/bill-brief.js"; import { generateCourtCaseBrief } from "../ai/court-case-brief.js"; +import { + generateGovernmentActionBrief, + isCeremonialGovernmentContent, + isGovernmentActionDocumentType, +} from "../ai/government-action-brief.js"; import { generateImageSearchKeywords } from "../ai/image-keywords.js"; import { getTextModelVersion } from "../ai/provider.js"; import { @@ -130,6 +139,9 @@ export async function upsertContent( const title = input.data.title; const url = input.data.url; const sourceDescription = input.data.description; + const ceremonialGovernmentContent = + input.type === "government_content" && + isCeremonialGovernmentContent(title, input.data.type); const hasUsableText = isUsableSourceText(fullText); if (!hasUsableText && fullText) { @@ -184,11 +196,18 @@ export async function upsertContent( ); } + if (ceremonialGovernmentContent) { + shouldGenerateSummary = false; + shouldGenerateArticle = false; + shouldGenerateImage = false; + } + // New items beyond the run's daily budget skip AI enrichment. Bills that // need a generated description are deferred before insertion; other content // can persist raw and look like "needs backfill" work next run. const budgetExhausted = progressKind === "new" && + !ceremonialGovernmentContent && options?.newItemLimiter !== undefined && !options.newItemLimiter.tryConsume(); if (budgetExhausted) { @@ -424,7 +443,7 @@ export async function upsertContent( } // Generate and cache dual-lens perspectives - if (hasUsableText && result?.id) { + if (hasUsableText && result?.id && !ceremonialGovernmentContent) { await upsertContentLens( result.id, input.type, @@ -459,6 +478,21 @@ export async function upsertContent( status: input.data.status, priorArticle: aiGeneratedArticle, }); + } else if ( + hasUsableText && + result?.id && + input.type === "government_content" && + isGovernmentActionDocumentType(input.data.type) + ) { + await upsertGovernmentActionBrief({ + contentId: result.id, + contentHash: newContentHash, + title, + documentType: input.data.type, + description: input.data.description, + fullText: fullText!, + priorArticle: aiGeneratedArticle, + }); } } catch (error) { if (error instanceof AIRateLimitError) { @@ -470,7 +504,7 @@ export async function upsertContent( } } - if (fullText && !budgetExhausted) { + if (fullText && !budgetExhausted && !ceremonialGovernmentContent) { try { const videoSource = input.type === "bill" @@ -743,3 +777,76 @@ export async function upsertCourtCaseBrief(args: { logger.success(`Cached court brief for ${args.caseNumber}`); return true; } + +/** Generate or refresh a cached executive-action or ceremonial brief. */ +export async function upsertGovernmentActionBrief(args: { + contentId: string; + contentHash: string; + title: string; + documentType: string; + description?: string | null; + fullText: string; + priorArticle?: string | null; +}): Promise { + const [existing] = await db + .select({ + contentHash: ContentBrief.contentHash, + brief: ContentBrief.brief, + }) + .from(ContentBrief) + .where( + and( + eq(ContentBrief.contentId, args.contentId), + eq(ContentBrief.contentType, "government_content"), + ), + ) + .limit(1); + + if ( + !forceAIRegeneration && + existing?.contentHash === args.contentHash && + isCurrentGovernmentActionBrief(existing.brief) + ) { + logger.debug(`Government brief already cached for "${args.title}"`); + return true; + } + + const generated = await generateGovernmentActionBrief(args); + if (!generated) { + logger.warn( + `Government brief generation returned null for "${args.title}"`, + ); + return false; + } + + const modelVersion = + generated.presentation === "ceremonial" + ? "source-only:ceremonial-v1" + : `${getTextModelVersion()}:government-action-v1`; + const brief = { + ...generated, + generatedAt: new Date().toISOString(), + modelVersion, + }; + await db + .insert(ContentBrief) + .values({ + contentId: args.contentId, + contentType: "government_content", + contentHash: args.contentHash, + brief, + modelVersion, + }) + .onConflictDoUpdate({ + target: [ContentBrief.contentType, ContentBrief.contentId], + set: { + contentHash: args.contentHash, + brief, + modelVersion, + updatedAt: new Date(), + }, + }); + + logger.success(`Cached government brief for "${args.title}"`); + return true; +} diff --git a/packages/api/src/router/content.ts b/packages/api/src/router/content.ts index eaf94177..63208e48 100644 --- a/packages/api/src/router/content.ts +++ b/packages/api/src/router/content.ts @@ -650,6 +650,7 @@ export const contentRouter = { originalContent: c.fullText ?? "Full text not available", url: c.url, lensData: await getLensData(c.id, "government_content"), + brief: await getBrief(c.id, "government_content"), }, ]); if (!result) { diff --git a/packages/db/seed.ts b/packages/db/seed.ts index b400f405..168ba878 100644 --- a/packages/db/seed.ts +++ b/packages/db/seed.ts @@ -1,7 +1,11 @@ import { createHash } from "node:crypto"; import { and, eq, inArray, sql } from "drizzle-orm"; -import type { BillBriefRecord, CourtCaseBriefRecord } from "@acme/validators"; +import type { + BillBriefRecord, + CourtCaseBriefRecord, + GovernmentActionBriefRecord, +} from "@acme/validators"; import { clampBillDescription } from "./src/bill-description"; import { db } from "./src/client"; @@ -332,6 +336,141 @@ The administration wants lower rates to boost the housing market and economic gr versions: [], })); +const governmentBriefs: (Omit< + GovernmentActionBriefRecord, + "generatedAt" | "modelVersion" +> | null)[] = [ + { + kind: "government_action", + presentation: "executive_action", + version: 1, + verifiedQuotes: 0, + badge: "EXECUTIVE ORDER", + hook: "The order tells federal agencies to strengthen how they protect government systems and respond to cyberattacks. It would require **new security practices inside federal agencies** and could add **incident-reporting duties through agency rules and contracts**.", + facts: [ + { label: "Document", value: "Executive order" }, + { label: "Primary actors", value: "Federal agencies" }, + ], + terms: [ + { + term: "Zero-trust security", + plain: + "A security model that **checks each user and device instead of trusting them automatically**.", + }, + ], + sections: [ + { + title: "What the President directed", + items: [ + { + text: "Federal agencies must **change how they verify access and share cyberattack information** across the government.", + }, + ], + }, + { + title: "How it takes effect", + items: [ + { + text: "Agency leaders would carry out the order through **technology changes, federal contracts, and follow-up rules**.", + }, + ], + }, + { + title: "What it does not do", + items: [ + { + text: "The order does **not itself pass a new cybersecurity law or guarantee new funding from Congress**.", + }, + ], + }, + ], + unknowns: [ + "The excerpt does not establish **the final cost or exact reporting rule for every private operator**.", + ], + }, + { + kind: "government_action", + presentation: "executive_action", + version: 1, + verifiedQuotes: 0, + badge: "MEMORANDUM", + hook: "The memorandum directs the Education Department to improve the system federal student-loan borrowers use for billing, repayment, and support. The practical changes depend on **what the department builds and requires from servicers**, rather than taking effect for borrowers immediately.", + facts: [ + { label: "Document", value: "Memorandum" }, + { label: "Lead agency", value: "Education Dept." }, + ], + terms: [ + { + term: "Loan servicer", + plain: + "A company hired to **send bills, process payments, and answer borrower questions** for federal loans.", + }, + ], + sections: [ + { + title: "What the President directed", + items: [ + { + text: "The Education Department must **redesign federal loan servicing and improve how borrowers get help**.", + }, + ], + }, + { + title: "Who it reaches", + items: [ + { + text: "Borrowers would experience changes only after the department and its contractors **put new systems and service standards into practice**.", + }, + ], + }, + { + title: "What it does not do", + items: [ + { + text: "The memorandum does **not cancel student debt or change each borrower's loan balance by itself**.", + }, + ], + }, + ], + unknowns: [ + "The source excerpt does not provide **a launch date for the new borrower experience**.", + ], + }, + { + kind: "government_action", + presentation: "ceremonial", + version: 1, + verifiedQuotes: 0, + badge: "PROCLAMATION", + hook: "The proclamation recognizes May 2025 as National Wildfire Preparedness Month and encourages communities to prepare. This is **a ceremonial recognition and public call to awareness**; it does not by itself create a new law, spending program, or public requirement.", + facts: [ + { label: "Document", value: "Proclamation" }, + { label: "Legal effect", value: "No new mandate" }, + ], + terms: [], + sections: [ + { + title: "What it recognizes", + items: [ + { + text: "The proclamation uses the presidency's public platform to **focus attention on wildfire preparation during May**.", + }, + ], + }, + { + title: "What it does not do", + items: [ + { + text: "The designation does **not appropriate money, rewrite building codes, or require anyone to participate**.", + }, + ], + }, + ], + unknowns: [], + }, + null, +]; + const courtCases = [ { caseNumber: "23-1234", @@ -1069,6 +1208,65 @@ async function seed() { }); console.log(` ${insertedGov.length} government content items inserted`); + const seededGovernmentContent = await db + .select({ + id: GovernmentContent.id, + url: GovernmentContent.url, + contentHash: GovernmentContent.contentHash, + }) + .from(GovernmentContent) + .where( + inArray( + GovernmentContent.url, + govContent.map((content) => content.url), + ), + ); + + console.log("Inserting government action briefs..."); + const governmentBriefRecords = seededGovernmentContent.flatMap((content) => { + const fixtureIndex = govContent.findIndex( + (fixture) => fixture.url === content.url, + ); + const brief = governmentBriefs[fixtureIndex]; + return brief + ? [ + { + contentType: "government_content" as const, + contentId: content.id, + contentHash: content.contentHash, + brief: { + ...brief, + generatedAt: now.toISOString(), + modelVersion: + brief.presentation === "ceremonial" + ? "source-only:ceremonial-v1" + : "seed", + }, + modelVersion: + brief.presentation === "ceremonial" + ? "source-only:ceremonial-v1" + : "seed", + }, + ] + : []; + }); + const insertedGovernmentBriefs = await db + .insert(ContentBrief) + .values(governmentBriefRecords) + .onConflictDoUpdate({ + target: [ContentBrief.contentType, ContentBrief.contentId], + set: { + contentHash: sql`excluded.content_hash`, + brief: sql`excluded.brief`, + modelVersion: sql`excluded.model_version`, + updatedAt: now, + }, + }) + .returning({ id: ContentBrief.id }); + console.log( + ` ${insertedGovernmentBriefs.length} government briefs inserted or refreshed`, + ); + console.log("Inserting court cases..."); const insertedCases = await db .insert(CourtCase) diff --git a/packages/validators/src/content-brief.ts b/packages/validators/src/content-brief.ts index 805f62b3..262489af 100644 --- a/packages/validators/src/content-brief.ts +++ b/packages/validators/src/content-brief.ts @@ -9,6 +9,7 @@ import { } from "./bill-brief"; export const COURT_CASE_BRIEF_VERSION = 1; +export const GOVERNMENT_ACTION_BRIEF_VERSION = 1; export const NarrativeBriefItemSchema = z.object({ text: z @@ -59,6 +60,32 @@ export const CourtCaseBriefSchema = z.object({ }); export type CourtCaseBrief = z.infer; +// Executive actions use the same display primitives but receive distinct +// editorial instructions; they are never forced into court terminology. +export const GovernmentActionBriefSchema = CourtCaseBriefSchema.extend({ + hook: z + .string() + .trim() + .min(60) + .max(420) + .describe( + "A coherent 2–3 sentence explanation of what the presidential action directs, who must carry it out, and its most important limitation. Bold two or three short key phrases.", + ), + unknowns: z + .array( + z + .string() + .trim() + .min(20) + .max(240) + .describe( + "A complete sentence explaining an implementation detail the official action does not settle. Bold one short key phrase.", + ), + ) + .max(3), +}); +export type GovernmentActionBrief = z.infer; + export const CourtCaseBriefRecordSchema = CourtCaseBriefSchema.extend({ kind: z.literal("court_case"), presentation: z.literal("court_case"), @@ -69,9 +96,23 @@ export const CourtCaseBriefRecordSchema = CourtCaseBriefSchema.extend({ }); export type CourtCaseBriefRecord = z.infer; +export const GovernmentActionBriefRecordSchema = + GovernmentActionBriefSchema.extend({ + kind: z.literal("government_action"), + presentation: z.enum(["executive_action", "ceremonial"]), + version: z.literal(GOVERNMENT_ACTION_BRIEF_VERSION), + verifiedQuotes: z.number().int().min(0), + generatedAt: z.string(), + modelVersion: z.string(), + }); +export type GovernmentActionBriefRecord = z.infer< + typeof GovernmentActionBriefRecordSchema +>; + export const StoredContentBriefRecordSchema = z.union([ BillBriefRecordSchema, CourtCaseBriefRecordSchema, + GovernmentActionBriefRecordSchema, ]); export type StoredContentBriefRecord = z.infer< typeof StoredContentBriefRecordSchema @@ -79,7 +120,8 @@ export type StoredContentBriefRecord = z.infer< export type ContentBriefRecord = | ({ kind: "bill" } & z.infer) - | CourtCaseBriefRecord; + | CourtCaseBriefRecord + | GovernmentActionBriefRecord; export function parseContentBriefRecord( value: unknown, @@ -88,9 +130,16 @@ export function parseContentBriefRecord( if (bill) return { kind: "bill", ...bill }; const courtCase = CourtCaseBriefRecordSchema.safeParse(value); - return courtCase.success ? courtCase.data : null; + if (courtCase.success) return courtCase.data; + + const governmentAction = GovernmentActionBriefRecordSchema.safeParse(value); + return governmentAction.success ? governmentAction.data : null; } export function isCurrentCourtCaseBrief(value: unknown): boolean { return CourtCaseBriefRecordSchema.safeParse(value).success; } + +export function isCurrentGovernmentActionBrief(value: unknown): boolean { + return GovernmentActionBriefRecordSchema.safeParse(value).success; +}