Skip to content
Draft
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
5 changes: 4 additions & 1 deletion apps/expo/src/app/article-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 19 additions & 5 deletions apps/expo/src/components/ui/NarrativeBrief.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 (
<View>
<View style={[s.hook, { borderLeftColor: accent }]}>
<View style={s.hookHead}>
<View style={[s.iconTile, { backgroundColor: `${accent}22` }]}>
<Icon name="scale" size={20} color={accent} />
<Icon name={icon} size={20} color={accent} />
</View>
<Text style={s.hookTitle}>Why this case matters</Text>
<Text style={s.hookTitle}>{heading}</Text>
<View style={[s.badge, { borderColor: `${accent}88` }]}>
<Text style={[s.badgeText, { color: accent }]}>{data.badge}</Text>
</View>
Expand Down Expand Up @@ -172,7 +184,9 @@ export function NarrativeBrief({
</View>
) : null}

{dualLens ? <View style={s.lens}>{dualLens}</View> : null}
{data.presentation !== "ceremonial" && dualLens ? (
<View style={s.lens}>{dualLens}</View>
) : null}
</View>
);
}
Expand Down
167 changes: 131 additions & 36 deletions apps/scraper/src/retroactive-briefs.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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";

Expand Down Expand Up @@ -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<BillBriefCandidate[]> {
const rows = await db
Expand Down Expand Up @@ -126,6 +152,58 @@ async function findCourtCases(limit: number): Promise<CourtBriefCandidate[]> {
);
}

async function findGovernmentContent(
limit: number,
): Promise<GovernmentBriefCandidate[]> {
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",
Expand All @@ -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",
})
Expand All @@ -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)`);

Expand All @@ -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) {
Expand All @@ -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);
}
}

Expand Down
59 changes: 59 additions & 0 deletions apps/scraper/src/utils/ai/government-action-brief.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading