diff --git a/apps/expo/assets/article-brief/algorithm-transparency.jpg b/apps/expo/assets/article-brief/algorithm-transparency.jpg new file mode 100644 index 00000000..88489958 Binary files /dev/null and b/apps/expo/assets/article-brief/algorithm-transparency.jpg differ diff --git a/apps/expo/assets/article-brief/data-control.jpg b/apps/expo/assets/article-brief/data-control.jpg new file mode 100644 index 00000000..198a5f91 Binary files /dev/null and b/apps/expo/assets/article-brief/data-control.jpg differ diff --git a/apps/expo/assets/article-brief/infrastructure-repair.jpg b/apps/expo/assets/article-brief/infrastructure-repair.jpg new file mode 100644 index 00000000..53c7b067 Binary files /dev/null and b/apps/expo/assets/article-brief/infrastructure-repair.jpg differ diff --git a/apps/expo/assets/article-brief/public-transit.jpg b/apps/expo/assets/article-brief/public-transit.jpg new file mode 100644 index 00000000..80b2295d Binary files /dev/null and b/apps/expo/assets/article-brief/public-transit.jpg differ diff --git a/apps/expo/src/app/(tabs)/elections.tsx b/apps/expo/src/app/(tabs)/elections.tsx index 2f11e5c6..a376501b 100644 --- a/apps/expo/src/app/(tabs)/elections.tsx +++ b/apps/expo/src/app/(tabs)/elections.tsx @@ -197,6 +197,7 @@ export default function ElectionsScreen() { const voterInfoQuery = useQuery({ ...trpc.civic.getVoterInfo.queryOptions({ address: storedAddress ?? "" }), enabled: hasAddress, + retry: 1, }); // We only have ballot/results data sourced for California right now. @@ -300,10 +301,23 @@ export default function ElectionsScreen() { /> )} - + {voterInfoQuery.isLoading && ( - + + + + + Looking up your ballot + + Checking your election and elected officials… + + + + )} {/* ballot section tabs */} @@ -518,6 +532,22 @@ const s = StyleSheet.create({ }, addrEdit: { fontFamily: fontBody.semibold, fontSize: 13, color: colors.bill }, section: { paddingHorizontal: 20 }, + lookupCard: { + flexDirection: "row", + alignItems: "center", + gap: 13, + }, + lookupCopy: { flex: 1, gap: 2 }, + lookupTitle: { + fontFamily: fontBody.semibold, + fontSize: 14, + color: colors.white, + }, + lookupSub: { + fontFamily: fontBody.regular, + fontSize: 12.5, + color: colors.textSecondary, + }, contestOffice: { fontFamily: "InriaSerif-Bold", fontSize: 16, diff --git a/apps/expo/src/app/(tabs)/feed.tsx b/apps/expo/src/app/(tabs)/feed.tsx index 5095fd84..62f76edf 100644 --- a/apps/expo/src/app/(tabs)/feed.tsx +++ b/apps/expo/src/app/(tabs)/feed.tsx @@ -30,6 +30,7 @@ import { } from "~/styles"; import { queryClient, trpc, trpcClient } from "~/utils/api"; import { authClient } from "~/utils/auth"; +import { editorialVisualFor } from "~/utils/editorial-visuals"; const { height: SCREEN_H, width: SCREEN_W } = Dimensions.get("window"); @@ -117,6 +118,10 @@ function FeedCard({ const typeKey = resolveType(item.type); const t = contentType[typeKey]; + const imageSource = editorialVisualFor( + item.title, + item.imageUri ?? item.thumbnailUrl, + ); return ( {/* hero */} - {(item.imageUri ?? item.thumbnailUrl) ? ( + {imageSource ? ( @@ -169,16 +174,17 @@ function FeedCard({ ) : null} - {/* key-fact chips — TODO(backend): real stat/status/chamber per item */} - - - {t.label} - type + {/* Type is already established by the badge. Give the remaining metadata + row one reader-useful job: identify where the record came from. */} + + + - + {item.author || "Public record"} - source + Original source + {/* dual-lens strip */} @@ -394,16 +400,26 @@ const s = StyleSheet.create({ color: "rgba(255,255,255,0.82)", marginBottom: 18, }, - chips: { flexDirection: "row", gap: 10, marginBottom: 18 }, - chip: { + sourceCard: { + flexDirection: "row", + alignItems: "center", + gap: 11, backgroundColor: planes.slate, borderWidth: 1, borderColor: hair[1], borderRadius: 12, paddingVertical: 10, - paddingHorizontal: 14, + paddingHorizontal: 12, + marginBottom: 18, + }, + sourceIcon: { + width: 34, + height: 34, + borderRadius: 9, + alignItems: "center", + justifyContent: "center", }, - chipStat: { fontFamily: "IBMPlexSerif-Bold", fontSize: 20 }, + sourceCopy: { flex: 1, minWidth: 0 }, chipStatus: { fontFamily: fontBody.semibold, fontSize: 13.5, diff --git a/apps/expo/src/app/(tabs)/index.tsx b/apps/expo/src/app/(tabs)/index.tsx index 12e27c25..e8c7d0a2 100644 --- a/apps/expo/src/app/(tabs)/index.tsx +++ b/apps/expo/src/app/(tabs)/index.tsx @@ -66,11 +66,15 @@ export default function BrowseScreen() { // election list (which surfaced out-of-state elections like "North Dakota // Primary"). Use the address they set on the Elections tab — getVoterInfo // returns the election relevant to that address. Banner stays hidden until - // an address is set. + // an address is set. Skip this nonessential background lookup in local + // development: Civic credentials are commonly absent/disabled there, and a + // failed banner request otherwise floods the Expo error overlay even after + // navigating away from this tab. The Elections screen still performs its + // own lookup when that flow is being developed. const { address } = useUserAddress(); const voterInfoQuery = useQuery({ ...trpc.civic.getVoterInfo.queryOptions({ address: address ?? "" }), - enabled: !!address, + enabled: !!address && !__DEV__, }); const election = voterInfoQuery.data?.election; const upcomingElection = diff --git a/apps/expo/src/app/(tabs)/settings.tsx b/apps/expo/src/app/(tabs)/settings.tsx index 67465e3c..5419cf4c 100644 --- a/apps/expo/src/app/(tabs)/settings.tsx +++ b/apps/expo/src/app/(tabs)/settings.tsx @@ -113,7 +113,10 @@ function buildGroups( export default function SettingsScreen() { const router = useRouter(); const sessionQuery = useQuery(trpc.auth.getSession.queryOptions()); - const prefsQuery = useQuery(trpc.user.getPreferences.queryOptions()); + const prefsQuery = useQuery({ + ...trpc.user.getPreferences.queryOptions(), + enabled: !!sessionQuery.data?.user, + }); const sessionUser = sessionQuery.data?.user; const profileName = sessionUser?.name ?? "Guest"; diff --git a/apps/expo/src/app/article-detail.tsx b/apps/expo/src/app/article-detail.tsx index 1ed40f89..2c35d06d 100644 --- a/apps/expo/src/app/article-detail.tsx +++ b/apps/expo/src/app/article-detail.tsx @@ -1,5 +1,6 @@ import type { RenderRules } from "@ronradtke/react-native-markdown-display"; -import { useEffect, useState } from "react"; +import type { LayoutChangeEvent } from "react-native"; +import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, @@ -14,15 +15,22 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import Markdown from "@ronradtke/react-native-markdown-display"; import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + BillBriefData, + BriefQuote, + NarrativeBriefData, +} from "~/components/ui"; import { Text } from "~/components/Themed"; import { Avatar, Badge, + BillBrief, Card, GhostButton, Icon, Kicker, LensPanel, + NarrativeBrief, NavHeader, Placeholder, PrimaryButton, @@ -35,6 +43,7 @@ import { darkTheme, fontBody, fontDisplay, + fontEditorial, getMarkdownStyles, hair, planes, @@ -43,6 +52,7 @@ import { import { queryClient, trpc } from "~/utils/api"; import { authClient } from "~/utils/auth"; import { formatDate } from "~/utils/dates"; +import { editorialVisualFor } from "~/utils/editorial-visuals"; export default function ArticleDetailScreen() { const router = useRouter(); @@ -50,12 +60,19 @@ export default function ArticleDetailScreen() { const articleId = Array.isArray(params.id) ? params.id[0] : params.id; const [mode, setMode] = useState<"explainer" | "source">("explainer"); + const [provenanceOpen, setProvenanceOpen] = useState(false); + const [sourceHighlight, setSourceHighlight] = useState( + null, + ); const [expandedStep, setExpandedStep] = useState(null); - const [failedHeaderImageUri, setFailedHeaderImageUri] = useState< + const [failedHeaderImageKey, setFailedHeaderImageKey] = useState< string | undefined >(); + const scrollRef = useRef(null); + const sourcePanelY = useRef(0); const handleModeChange = (newMode: "explainer" | "source") => { + setSourceHighlight(null); setMode(newMode); posthog.capture("article_view_mode_toggled", { content_id: articleId ?? null, @@ -84,6 +101,12 @@ export default function ArticleDetailScreen() { } }, [content]); const headerImageUri = content?.imageUri ?? content?.thumbnailUrl; + const headerImageSource = content + ? editorialVisualFor(content.title, headerImageUri) + : undefined; + const headerImageKey = content + ? `${content.title}:${headerImageUri ?? "local"}` + : undefined; // content.saved.isSaved is a protected procedure — only query it when signed in, // otherwise it throws UNAUTHORIZED. @@ -229,6 +252,39 @@ export default function ArticleDetailScreen() { } }; + const handleViewSource = (quote: BriefQuote) => { + setSourceHighlight(quote); + setMode("source"); + posthog.capture("article_source_passage_opened", { + content_id: content.id, + content_type: content.type, + locator: quote.locator ?? null, + }); + }; + + const handleSourceTargetLayout = (event: LayoutChangeEvent) => { + const targetY = event.nativeEvent.layout.y; + requestAnimationFrame(() => { + scrollRef.current?.scrollTo({ + y: Math.max(0, sourcePanelY.current + targetY - 110), + animated: true, + }); + }); + }; + + type ArticleBrief = ({ kind: "bill" } & BillBriefData) | NarrativeBriefData; + const rawBrief = + "brief" in content + ? (content.brief as ArticleBrief | BillBriefData | null) + : null; + // Fast refresh can retain a bill response from before the API added a kind. + const brief: ArticleBrief | null = + rawBrief && !("kind" in rawBrief) + ? ({ kind: "bill", ...rawBrief } as ArticleBrief) + : rawBrief; + const billBrief = brief?.kind === "bill" ? brief : null; + const narrativeBrief = brief?.kind === "court_case" ? brief : null; + const activeContent = mode === "explainer" ? content.articleContent : content.originalContent; const looksLikeMarkdown = @@ -320,18 +376,19 @@ export default function ArticleDetailScreen() { /> - {headerImageUri && headerImageUri !== failedHeaderImageUri ? ( + {headerImageSource && headerImageKey !== failedHeaderImageKey ? ( setFailedHeaderImageUri(headerImageUri)} + onError={() => setFailedHeaderImageKey(headerImageKey)} accessible accessibilityLabel={`Header image for ${content.title}`} /> @@ -395,22 +452,54 @@ export default function ArticleDetailScreen() { value={mode} onChange={handleModeChange} options={[ - { id: "explainer", label: "Plain explainer", icon: "sparkle" }, + { + id: "explainer", + label: brief ? "The brief" : "Plain explainer", + icon: "sparkle", + }, { id: "source", label: "Original text", icon: "doc" }, ]} /> {mode === "explainer" && ( - + setProvenanceOpen((value) => !value)} + accessibilityRole="button" + accessibilityState={{ expanded: provenanceOpen }} + accessibilityLabel={ + provenanceOpen + ? "Hide details about Billion AI authorship" + : "Show details about Billion AI authorship" + } + > - - Explained by Billion AI from the official text.{" "} - - Always verify against the source below. - - - + + + + Written by Billion AI · Always check the source + + + {provenanceOpen ? "Hide" : "Details"} + + + + + + {provenanceOpen ? ( + + Created from the official text.{" "} + {brief + ? "Quoted passages are checked against that source; everything else is AI analysis." + : "The plain-language explanation is AI analysis."}{" "} + Use Original text or the linked official site to verify + details. + + ) : null} + + )} {mode === "source" && content.url && ( @@ -425,8 +514,36 @@ export default function ArticleDetailScreen() { { + sourcePanelY.current = event.nativeEvent.layout.y; + }} > - {renderMarkdown ? ( + {mode === "explainer" && billBrief ? ( + : null + } + onViewSource={handleViewSource} + /> + ) : mode === "explainer" && narrativeBrief ? ( + : null + } + onViewSource={handleViewSource} + /> + ) : mode === "source" && sourceHighlight ? ( + + ) : renderMarkdown ? ( {activeContent} @@ -436,114 +553,190 @@ export default function ArticleDetailScreen() { {/* Never present generic copy as if it were generated analysis. */} - {content.lensData && ( + {mode === "explainer" && content.lensData && !brief && ( )} - {/* timeline */} - Where it stands - - {timeline.map((step, i) => { - const expandable = !!step.fullText && step.label !== step.fullText; - const isExpanded = expandedStep === i; - const isCurrent = i === currentTimelineIndex; - return ( - - expandable && setExpandedStep(isExpanded ? null : i) - } - accessibilityRole={expandable ? "button" : undefined} - > - - - {i < timeline.length - 1 && ( - - )} - - - {!!step.date && ( - {formatDate(step.date)} - )} - - - {isExpanded ? step.fullText : step.label} - - {expandable && ( - + Where it stands + + {timeline.map((step, i) => { + const expandable = + !!step.fullText && step.label !== step.fullText; + const isExpanded = expandedStep === i; + const isCurrent = i === currentTimelineIndex; + return ( + + expandable && setExpandedStep(isExpanded ? null : i) + } + accessibilityRole={expandable ? "button" : undefined} + > + + - )} - - - - ); - })} - {timelineSourceUrl && ( - void Linking.openURL(timelineSourceUrl)} - > - - - Official record · congress.gov - - - - )} - - - {/* dig-deeper exit */} - - Don't take our word for it. - - Read the full, unedited text and track every action on the official - record. + {i < timeline.length - 1 && ( + + )} + + + {!!step.date && ( + + {formatDate(step.date)} + + )} + + + {isExpanded ? step.fullText : step.label} + + {expandable && ( + + )} + + + + ); + })} + {timelineSourceUrl && ( + void Linking.openURL(timelineSourceUrl)} + > + + + Official record · congress.gov + + + + )} + + + ) : null} + + {/* The explainer ends by handing the reader back to the official + record. The source tab already has that action at the top. */} + {mode === "explainer" ? ( + + Don't take our word for it. + + Read the full, unedited text and track every action on the + official record. + + + + + ) : null} + + + ); +} + +function HighlightedSource({ + content, + quote, + accent, + onTargetLayout, +}: { + content: string; + quote: BriefQuote; + accent: string; + onTargetLayout: (event: LayoutChangeEvent) => void; +}) { + const exactIndex = content.indexOf(quote.text); + const caseInsensitiveIndex = + exactIndex >= 0 + ? exactIndex + : content.toLocaleLowerCase().indexOf(quote.text.toLocaleLowerCase()); + const found = caseInsensitiveIndex >= 0; + const before = found ? content.slice(0, caseInsensitiveIndex) : ""; + const match = found + ? content.slice( + caseInsensitiveIndex, + caseInsensitiveIndex + quote.text.length, + ) + : quote.text; + const after = found + ? content.slice(caseInsensitiveIndex + quote.text.length) + : content; + + return ( + + + + + + + Original text + + {quote.locator + ? `Highlighted passage · ${quote.locator}` + : "Highlighted passage"} - - - + + {before ? {before} : null} + + + {found ? "MATCHING PASSAGE" : "CITED PASSAGE"} + + {match} + + {after ? {after} : null} ); } @@ -631,6 +824,7 @@ const s = StyleSheet.create({ }, disclaimer: { flexDirection: "row", + alignItems: "flex-start", gap: 9, backgroundColor: planes.surface, borderWidth: 1, @@ -640,14 +834,30 @@ const s = StyleSheet.create({ paddingHorizontal: 14, marginBottom: 18, }, - disclaimerText: { + disclaimerBody: { flex: 1, gap: 8 }, + disclaimerHead: { + flexDirection: "row", + alignItems: "center", + gap: 7, + }, + disclaimerTitle: { flex: 1, - fontFamily: "AlbertSans-Regular", + fontFamily: fontBody.medium, fontSize: 12.5, - color: "rgba(255,255,255,0.7)", - lineHeight: 18, + lineHeight: 17, + color: "rgba(255,255,255,0.82)", }, - disclaimerEm: { color: colors.white, fontFamily: fontBody.semibold }, + disclaimerAction: { + fontFamily: fontBody.semibold, + fontSize: 10.5, + }, + disclaimerText: { + fontFamily: "AlbertSans-Regular", + fontSize: 11.5, + color: "rgba(255,255,255,0.64)", + lineHeight: 17, + }, + chevFlip: { transform: [{ rotate: "180deg" }] }, sourcePanel: { backgroundColor: planes.ink, borderWidth: 1, @@ -655,6 +865,56 @@ const s = StyleSheet.create({ borderRadius: 14, padding: 18, }, + highlightedSource: { gap: 14 }, + sourceDocumentHead: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingBottom: 13, + borderBottomWidth: 1, + borderBottomColor: hair[2], + }, + sourceDocumentIcon: { + width: 32, + height: 32, + borderRadius: 9, + alignItems: "center", + justifyContent: "center", + }, + sourceDocumentHeadCopy: { flex: 1, gap: 1 }, + sourceDocumentTitle: { + fontFamily: fontEditorial.bold, + fontSize: 17, + color: colors.white, + }, + sourceDocumentMeta: { + fontFamily: fontBody.medium, + fontSize: 11, + color: colors.textSecondary, + }, + sourceText: { + fontFamily: fontBody.regular, + fontSize: 15, + lineHeight: 25, + color: "rgba(255,255,255,0.7)", + }, + sourceHighlight: { + borderLeftWidth: 3, + borderRadius: 10, + padding: 14, + gap: 6, + }, + sourceHighlightLabel: { + fontFamily: fontBody.semibold, + fontSize: 9.5, + letterSpacing: 0.9, + }, + sourceHighlightText: { + fontFamily: fontEditorial.bold, + fontSize: 17, + lineHeight: 25, + color: colors.white, + }, plainText: { fontFamily: "AlbertSans-Regular", fontSize: 16.5, @@ -662,6 +922,7 @@ const s = StyleSheet.create({ color: "rgba(255,255,255,0.88)", }, timelineRow: { flexDirection: "row", gap: 12 }, + timelineKicker: { marginTop: 30 }, timelineMarker: { alignItems: "center" }, timelineDot: { width: 14, height: 14, borderRadius: 7, borderWidth: 2 }, timelineLine: { width: 2, flex: 1, minHeight: 22 }, diff --git a/apps/expo/src/assets.d.ts b/apps/expo/src/assets.d.ts new file mode 100644 index 00000000..34a36e58 --- /dev/null +++ b/apps/expo/src/assets.d.ts @@ -0,0 +1,9 @@ +declare module "*.jpg" { + const assetId: number; + export default assetId; +} + +declare module "*.png" { + const assetId: number; + export default assetId; +} diff --git a/apps/expo/src/components/ui/BillBrief.tsx b/apps/expo/src/components/ui/BillBrief.tsx new file mode 100644 index 00000000..c5049ba6 --- /dev/null +++ b/apps/expo/src/components/ui/BillBrief.tsx @@ -0,0 +1,1550 @@ +/** + * BillBrief — the structured replacement for a wall of markdown. + * + * The old explainer rendered one long markdown blob, so the reader's only + * choices were "read all of it" or "read none of it". A brief is a stack of + * self-contained blocks the reader can leave at any point and still have + * learned something true: + * + * hook → one coherent "What this means for you" paragraph + * facts → retained as structured data for cards and future visuals + * changes → before → after, source quote one tap away + * affected → who this lands on + * unknowns → what the text does not settle + * terms → glossary, collapsed + * history → cited context on why the policy was not already adopted + * deepDive → an optional long-form Billion article + * reading → researched articles from outside publishers + * + * Two brand constraints shape the visual language here: + * + * - Outcome color is a navigation aid, not a verdict. Gains, losses, mixed + * effects, and uncertainty each use a distinct nonpartisan hue, icon, and + * written label. Color is never the only signal, and the palette avoids a + * red-vs-green or red-vs-blue binary. + * - Every claim keeps a path back to the source. Change cards expose the + * verbatim provision that backs them, and the block ends by pointing at the + * official text rather than presenting itself as the last word. + */ +import type { ReactNode } from "react"; +import type { StyleProp, TextProps, TextStyle } from "react-native"; +import { useState } from "react"; +import { + Modal, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + useWindowDimensions, + View, +} from "react-native"; +import { Image } from "expo-image"; +import * as WebBrowser from "expo-web-browser"; +import Markdown from "@ronradtke/react-native-markdown-display"; + +import type { IconName } from "./Icon"; +import { + colors, + darkTheme, + fontBody, + fontEditorial, + getMarkdownStyles, + hair, + planes, +} from "~/styles"; +import dataPrivacyImage from "../../../assets/article-brief/algorithm-transparency.jpg"; +import dataControlImage from "../../../assets/article-brief/data-control.jpg"; +import infrastructureRepairImage from "../../../assets/article-brief/infrastructure-repair.jpg"; +import publicTransitImage from "../../../assets/article-brief/public-transit.jpg"; +import { Icon } from "./Icon"; + +/* ---------- data shape ---------- */ +// Mirrors @acme/validators' BillBriefRecord. Redeclared rather than imported so +// the native app keeps no dependency on server-side validation code, matching +// how LensData is handled next door. + +export type BriefChangeKind = + | "creates" + | "repeals" + | "expands" + | "restricts" + | "requires" + | "waives" + | "funds" + | "transfers"; + +export interface BriefQuote { + text: string; + locator?: string; +} + +export interface BillBriefData { + hook: string; + legalStatus: "proposed" | "enacted"; + facts: { label: string; value: string; note?: string; quote?: BriefQuote }[]; + changes: { + kind: BriefChangeKind; + title: string; + before: string; + after: string; + visual?: + | "infrastructure-repair" + | "public-transit" + | "data-privacy" + | "data-control"; + quote?: BriefQuote; + }[]; + affected: { + group: string; + takeaway?: string; + effect: string; + direction: "gains" | "loses" | "mixed" | "unclear"; + }[]; + unknowns: string[]; + terms: { term: string; plain: string }[]; + whyNotBefore?: { + summary: string; + points: { + text: string; + citations: { title: string; publisher: string; url: string }[]; + }[]; + }; + deepDive?: { title: string; dek: string; body: string }; + reading?: { + title: string; + publisher: string; + url: string; + whyRead: string; + }[]; +} + +const CHANGE_KIND_LABEL: Record = { + creates: "CREATES", + repeals: "REPEALS", + expands: "EXPANDS", + restricts: "RESTRICTS", + requires: "REQUIRES", + waives: "WAIVES", + funds: "FUNDS", + transfers: "TRANSFERS", +}; + +const CHANGE_VISUALS = { + "infrastructure-repair": { + source: infrastructureRepairImage, + alt: "Engineers reviewing plans beside an active bridge rehabilitation project", + caption: "Bridge rehabilitation and long-range project planning", + }, + "public-transit": { + source: publicTransitImage, + alt: "A light-rail train and city bus serving the same transit station", + caption: "Light rail and bus rapid transit", + }, + "data-privacy": { + source: dataPrivacyImage, + alt: "A laptop showing an abstract network of connected personal data", + caption: "How companies collect, connect, and use personal data", + }, + "data-control": { + source: dataControlImage, + alt: "A person reviewing privacy controls and deleting data on a laptop", + caption: "Reviewing and deleting personal data held by companies", + }, +} as const; + +const DIRECTION: Record< + BillBriefData["affected"][number]["direction"], + { icon: IconName; label: string; color: string } +> = { + gains: { icon: "arrowUp", label: "Gains", color: "#55D6BE" }, + loses: { icon: "arrowDown", label: "Loses", color: "#FF9575" }, + mixed: { icon: "minus", label: "Mixed", color: "#B8A1FF" }, + unclear: { icon: "help", label: "Unclear", color: "#F4C95D" }, +}; + +/* ---------- Inline emphasis — useful phrases, not whole paragraphs ---------- */ +function EmphasizedText({ + children, + style, + strongStyle, + testID, + numberOfLines, + ellipsizeMode, +}: { + children: string; + style?: StyleProp; + strongStyle?: StyleProp; + testID?: string; + numberOfLines?: number; + ellipsizeMode?: TextProps["ellipsizeMode"]; +}) { + const parts = children.split(/(\*\*[^*]+\*\*)/g); + return ( + + {parts.map((part, index) => + part.startsWith("**") && part.endsWith("**") + ? // iOS can treat one multi-word custom-font span as unbreakable. + // Keep the real bold face per word and leave whitespace to the + // parent Text so native layout still chooses every line break. + part + .slice(2, -2) + .split(/(\s+)/) + .map((token, tokenIndex) => + /^\s+$/.test(token) ? ( + token + ) : ( + + {token} + + ), + ) + : part, + )} + + ); +} + +/* ---------- Hook — the one sentence that must land ---------- */ +function Hook({ + text, + legalStatus, + accent, +}: { + text: string; + legalStatus: BillBriefData["legalStatus"]; + accent: string; +}) { + return ( + + + + + + What this means for you + + + {legalStatus === "enacted" ? "LAW" : "PROPOSAL"} + + + + + {text} + + + ); +} + +/* ---------- Why not before — researched history, collapsed by default ---------- */ +function WhyNotBefore({ + context, + accent, +}: { + context: NonNullable; + accent: string; +}) { + const [open, setOpen] = useState(false); + const sources = [ + ...new Map( + context.points + .flatMap((point) => point.citations) + .map((citation) => [citation.url, citation]), + ).values(), + ]; + const sourceNumber = new Map( + sources.map((source, index) => [source.url, index + 1]), + ); + + return ( + + setOpen((value) => !value)} + accessibilityRole="button" + accessibilityState={{ expanded: open }} + accessibilityLabel={ + open + ? "Hide why this was not implemented before" + : "Explain why this was not implemented before" + } + > + + + + + + Why wasn't this implemented before? + + + + + + + + {open ? ( + + + {context.summary} + + + {context.points.map((point, index) => ( + + + {String(index + 1).padStart(2, "0")} + + + + {point.text} + + + {point.citations.map((citation) => ( + + [{sourceNumber.get(citation.url)}] + + ))} + + + + ))} + + + Sources + {sources.map((source, index) => ( + void WebBrowser.openBrowserAsync(source.url)} + accessibilityRole="link" + accessibilityLabel={`Open source ${index + 1}: ${source.title}`} + > + + [{index + 1}] + + + {source.title} + + {source.publisher} + + + + + ))} + + + ) : null} + + ); +} + +/* ---------- Quote disclosure — provenance, one tap away ---------- */ +// Collapsed by default: the reader asked for plain language, so raw statutory +// text is opt-in. But it is always one tap away, never a dead end. +function QuoteDisclosure({ + quote, + accent, + onViewSource, +}: { + quote: BriefQuote; + accent: string; + onViewSource?: (quote: BriefQuote) => void; +}) { + const [open, setOpen] = useState(false); + return ( + + setOpen((v) => !v)} + accessibilityRole="button" + accessibilityState={{ expanded: open }} + accessibilityLabel={ + open ? "Hide the source text" : "Show the source text" + } + > + + + {quote.locator ? `In the text · ${quote.locator}` : "In the text"} + + + + + + {open && ( + + {quote.text} + + )} + {onViewSource ? ( + onViewSource(quote)} + accessibilityRole="button" + accessibilityLabel={`View source${quote.locator ? ` at ${quote.locator}` : ""}`} + > + View source + + + ) : null} + + ); +} + +/* ---------- Changes — before → after ---------- */ +function Changes({ + changes, + accent, + onViewSource, +}: { + changes: BillBriefData["changes"]; + accent: string; + onViewSource?: (quote: BriefQuote) => void; +}) { + const { width: screenWidth } = useWindowDimensions(); + const [activeIndex, setActiveIndex] = useState(0); + const cardWidth = Math.max(260, Math.min(screenWidth - 64, 390)); + const snapInterval = cardWidth + 12; + + return ( + + { + const next = Math.round( + event.nativeEvent.contentOffset.x / snapInterval, + ); + setActiveIndex(Math.max(0, Math.min(changes.length - 1, next))); + }} + > + {changes.map((c, i) => ( + + + + + {CHANGE_KIND_LABEL[c.kind]} + + + + {i + 1}/{changes.length} + + + {c.title} + + {c.visual ? ( + + + + + {CHANGE_VISUALS[c.visual].caption} + + + ) : null} + + + + NOW + {c.before} + + + + + + THE PROPOSAL CHANGES THIS + + + + UNDER THIS BILL + + {c.after} + + + + + {c.quote ? ( + + ) : null} + + ))} + + + {changes.length > 1 ? ( + + {changes.map((change, index) => ( + + ))} + + {activeIndex < changes.length - 1 + ? `Swipe for change ${activeIndex + 2} of ${changes.length}` + : `Showing change ${activeIndex + 1} of ${changes.length}`} + + + ) : null} + + ); +} + +/* ---------- Affected — who this lands on ---------- */ +function Affected({ affected }: { affected: BillBriefData["affected"] }) { + const [openIndex, setOpenIndex] = useState(null); + return ( + + {affected.map((a, i) => { + const d = DIRECTION[a.direction]; + const outcomeColor = d.color; + const takeaway = a.takeaway; + const open = openIndex === i; + return ( + + + + + + + {a.group} + + + {d.label} + + + + + {takeaway ?? a.effect.replaceAll("**", "")} + + {takeaway ? ( + <> + setOpenIndex(open ? null : i)} + accessibilityRole="button" + accessibilityState={{ expanded: open }} + > + + {open ? "Hide context" : "Why this matters"} + + + + + + {open ? ( + + {a.effect} + + ) : null} + + ) : null} + + + ); + })} + + ); +} + +/* ---------- Unknowns — the honesty block ---------- */ +// Deliberately given the same visual weight as the analysis blocks. Burying it +// would let a brief read as more settled than the source actually is. +function Unknowns({ + unknowns, + accent, +}: { + unknowns: string[]; + accent: string; +}) { + if (unknowns.length === 0) return null; + return ( + + + + What the text doesn't settle + + + {unknowns.map((u, i) => ( + + + {String(i + 1).padStart(2, "0")} + + {u} + + ))} + + + ); +} + +/* ---------- Terms — essential vocabulary before the analysis ---------- */ +function Terms({ + terms, + accent, +}: { + terms: BillBriefData["terms"]; + accent: string; +}) { + if (terms.length === 0) return null; + return ( + + + + + + + Key terms + Know these before you read further + + + + {terms.map((t, i) => ( + + + {t.term} + + {t.plain} + + ))} + + + ); +} + +/* ---------- Keep reading — long form here, researched work elsewhere ---------- */ +function FurtherReading({ + deepDive, + reading, + accent, +}: { + deepDive: BillBriefData["deepDive"]; + reading: NonNullable; + accent: string; +}) { + const [deepDiveOpen, setDeepDiveOpen] = useState(false); + const markdownStyles = getMarkdownStyles(darkTheme); + if (!deepDive && reading.length === 0) return null; + + const openExternal = (url: string) => { + void WebBrowser.openBrowserAsync(url, { + presentationStyle: WebBrowser.WebBrowserPresentationStyle.PAGE_SHEET, + }); + }; + + return ( + <> + + {deepDive ? ( + setDeepDiveOpen(true)} + accessibilityRole="button" + accessibilityLabel={`Read Billion explainer: ${deepDive.title}`} + > + + + + + + BILLION EXPLAINER + + {deepDive.title} + + {deepDive.dek} + + + Read the full explainer + + + + + ) : null} + + {reading.map((item) => ( + openExternal(item.url)} + accessibilityRole="link" + accessibilityLabel={`Read ${item.title} from ${item.publisher}`} + > + + + + + {item.publisher} + + {item.title} + + + {item.whyRead} + + + + + ))} + + + {deepDive ? ( + setDeepDiveOpen(false)} + > + + + + + BILLION EXPLAINER + + Go beyond the brief + + setDeepDiveOpen(false)} + accessibilityRole="button" + accessibilityLabel="Close explainer" + > + + + + + {deepDive.title} + {deepDive.dek} + + {deepDive.body} + + {reading.length > 0 ? ( + + Continue with sources + {reading.map((item) => ( + openExternal(item.url)} + accessibilityRole="link" + > + + {item.publisher} + {item.title} + + + + ))} + + ) : null} + + + + ) : null} + + ); +} + +/* ---------- Section heading ---------- */ +function BlockTitle({ children }: { children: string }) { + return {children}; +} + +/* ---------- BillBrief ---------- */ +export function BillBrief({ + data, + accent = colors.civicBlue, + dualLens, + onViewSource, +}: { + data: BillBriefData; + accent?: string; + dualLens?: ReactNode; + onViewSource?: (quote: BriefQuote) => void; +}) { + // Keep fast refresh safe while a screen still holds a pre-v5 brief in memory. + const reading = data.reading ?? []; + + return ( + + + {data.whyNotBefore ? ( + + ) : null} + + + What would change + + + Who it lands on + + + + + {dualLens ? ( + + How people make the case + {dualLens} + + ) : null} + + {data.deepDive || reading.length > 0 ? ( + <> + Keep reading + + + ) : null} + + ); +} + +const s = StyleSheet.create({ + root: { gap: 18 }, + + /* summary */ + summaryCard: { + backgroundColor: planes.slate, + borderWidth: 1, + borderColor: hair[1], + borderLeftWidth: 3, + borderRadius: 14, + padding: 16, + gap: 13, + }, + summaryHead: { flexDirection: "row", alignItems: "center", gap: 9 }, + summaryIcon: { + width: 32, + height: 32, + borderRadius: 9, + alignItems: "center", + justifyContent: "center", + }, + summaryTitle: { + flex: 1, + fontFamily: fontEditorial.bold, + fontSize: 17, + color: colors.white, + }, + summaryStatus: { + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 3, + }, + summaryStatusText: { + fontFamily: fontBody.semibold, + fontSize: 9, + letterSpacing: 0.8, + }, + summaryText: { + fontFamily: fontBody.regular, + fontSize: 15, + lineHeight: 23, + color: colors.white, + }, + + /* block heading */ + blockTitle: { + fontFamily: fontEditorial.bold, + fontSize: 18, + color: colors.white, + marginBottom: -6, + }, + + /* changes */ + changeCarouselWrap: { gap: 10, marginHorizontal: -4 }, + changeCarouselContent: { + gap: 12, + paddingHorizontal: 4, + paddingRight: 28, + }, + changeCard: { + backgroundColor: planes.slate, + borderWidth: 1, + borderColor: hair[1], + borderRadius: 14, + padding: 15, + gap: 11, + }, + changePager: { + minHeight: 20, + flexDirection: "row", + alignItems: "center", + gap: 5, + paddingHorizontal: 8, + }, + changePagerDot: { + width: 5, + height: 5, + borderRadius: 999, + backgroundColor: hair[2], + }, + changePagerDotActive: { width: 18 }, + changePagerText: { + marginLeft: 4, + fontFamily: fontBody.medium, + fontSize: 10.5, + color: colors.textSecondary, + }, + changeHead: { flexDirection: "row", alignItems: "center" }, + kindChip: { + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 9, + paddingVertical: 3, + }, + kindChipText: { + fontFamily: fontBody.semibold, + fontSize: 9.5, + letterSpacing: 0.9, + }, + changeIndex: { + marginLeft: "auto", + fontFamily: fontBody.medium, + fontSize: 10.5, + color: colors.textSecondary, + }, + changeTitle: { + fontFamily: fontBody.semibold, + fontSize: 16, + lineHeight: 22, + color: colors.white, + }, + deltaStack: { gap: 8 }, + deltaBefore: { + backgroundColor: planes.surface, + borderRadius: 10, + padding: 12, + gap: 5, + }, + deltaAfter: { + borderWidth: 1, + borderRadius: 10, + padding: 12, + gap: 5, + }, + deltaLabel: { + fontFamily: fontBody.medium, + fontSize: 9, + letterSpacing: 0.9, + color: colors.textSecondary, + }, + deltaText: { + fontFamily: fontBody.regular, + fontSize: 13, + lineHeight: 19, + color: "rgba(255,255,255,0.66)", + }, + // The "after" column carries the actual change, so it reads at full strength + // while "now" recedes. This is emphasis, not endorsement. + deltaTextAfter: { color: "rgba(255,255,255,0.92)" }, + deltaTransition: { + height: 18, + flexDirection: "row", + alignItems: "center", + gap: 5, + paddingHorizontal: 8, + }, + deltaTransitionLine: { width: 2, height: 18, borderRadius: 2 }, + deltaTransitionText: { + fontFamily: fontBody.semibold, + fontSize: 8.5, + letterSpacing: 0.7, + }, + inlineStrong: { + fontFamily: fontBody.semibold, + color: colors.white, + }, + inlineStrongEditorial: { + fontFamily: fontEditorial.bold, + }, + changeVisualWrap: { + height: 142, + borderRadius: 11, + overflow: "hidden", + backgroundColor: planes.ink, + justifyContent: "flex-end", + }, + changeVisual: { + position: "absolute", + inset: 0, + }, + changeVisualShade: { + position: "absolute", + inset: 0, + backgroundColor: "rgba(8,13,29,0.14)", + }, + changeVisualCaption: { + fontFamily: fontBody.semibold, + fontSize: 10.5, + color: colors.white, + backgroundColor: "rgba(8,13,29,0.74)", + paddingHorizontal: 10, + paddingVertical: 7, + }, + + /* cited historical context */ + contextCard: { + backgroundColor: planes.slate, + borderWidth: 1, + borderColor: hair[1], + borderRadius: 14, + padding: 14, + }, + contextToggle: { + flexDirection: "row", + alignItems: "flex-start", + gap: 10, + }, + contextIcon: { + width: 32, + height: 32, + borderRadius: 9, + alignItems: "center", + justifyContent: "center", + }, + contextHeadingCopy: { flex: 1, gap: 6 }, + contextTitle: { + fontFamily: fontEditorial.bold, + fontSize: 16, + lineHeight: 21, + color: colors.white, + }, + contextSummary: { + fontFamily: fontBody.regular, + fontSize: 12.5, + lineHeight: 18, + color: "rgba(255,255,255,0.72)", + }, + contextDetails: { + marginTop: 14, + paddingTop: 14, + borderTopWidth: 1, + borderTopColor: hair[1], + gap: 12, + }, + contextPoint: { + flexDirection: "row", + gap: 10, + backgroundColor: planes.surface, + borderRadius: 10, + padding: 11, + }, + contextIndex: { + fontFamily: fontBody.bold, + fontSize: 10, + letterSpacing: 0.6, + paddingTop: 2, + }, + contextPointCopy: { flex: 1, gap: 6 }, + contextPointText: { + fontFamily: fontBody.regular, + fontSize: 13, + lineHeight: 19, + color: "rgba(255,255,255,0.82)", + }, + contextCitations: { flexDirection: "row", gap: 5 }, + contextCitation: { + fontFamily: fontBody.bold, + fontSize: 10.5, + }, + contextSources: { gap: 8 }, + contextSourcesTitle: { + fontFamily: fontBody.semibold, + fontSize: 11, + letterSpacing: 0.8, + textTransform: "uppercase", + color: colors.textSecondary, + }, + contextSource: { + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingVertical: 3, + }, + contextSourceNumber: { + fontFamily: fontBody.bold, + fontSize: 10.5, + }, + contextSourceCopy: { flex: 1, gap: 1 }, + contextSourceTitle: { + fontFamily: fontBody.medium, + fontSize: 11.5, + color: colors.white, + }, + contextSourcePublisher: { + fontFamily: fontBody.regular, + fontSize: 10.5, + color: colors.textSecondary, + }, + + /* quote disclosure */ + quoteWrap: { + borderTopWidth: 1, + borderTopColor: hair[1], + paddingTop: 10, + gap: 9, + }, + quoteToggle: { flexDirection: "row", alignItems: "center", gap: 7 }, + quoteToggleText: { + flex: 1, + fontFamily: fontBody.medium, + fontSize: 11.5, + color: colors.textSecondary, + }, + quoteBody: { + backgroundColor: planes.ink, + borderRadius: 10, + padding: 12, + }, + quoteText: { + fontFamily: fontEditorial.italic, + fontSize: 13.5, + lineHeight: 20, + color: "rgba(255,255,255,0.8)", + }, + viewSourceButton: { + alignSelf: "flex-start", + flexDirection: "row", + alignItems: "center", + gap: 7, + borderRadius: 999, + paddingHorizontal: 13, + paddingVertical: 8, + }, + viewSourceText: { + fontFamily: fontBody.semibold, + fontSize: 11.5, + color: colors.white, + }, + + /* affected */ + affectedList: { width: "100%", minWidth: 0, gap: 10 }, + affectedRow: { + width: "100%", + minWidth: 0, + backgroundColor: planes.slate, + borderWidth: 1, + borderColor: hair[1], + borderRadius: 14, + padding: 14, + alignItems: "stretch", + }, + affectedIcon: { + width: 28, + height: 28, + borderWidth: 1, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + }, + // A column child's `flex: 1` constrains its height, not its width. Give the + // prose an explicit horizontal boundary so nested bold Text spans measure + // against the card and React Native can wrap them natively. + affectedBody: { width: "100%", minWidth: 0, gap: 9 }, + affectedHead: { + width: "100%", + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: 9, + }, + affectedGroup: { + flex: 1, + minWidth: 0, + flexShrink: 1, + fontFamily: fontBody.bold, + fontSize: 14.5, + color: colors.white, + }, + affectedDirection: { + fontFamily: fontBody.medium, + fontSize: 10, + letterSpacing: 0.8, + textTransform: "uppercase", + color: colors.textSecondary, + }, + directionChip: { + flexShrink: 0, + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 3, + }, + affectedTakeaway: { + width: "100%", + minWidth: 0, + flexShrink: 1, + fontFamily: fontEditorial.regular, + fontSize: 15.5, + lineHeight: 21, + color: "rgba(255,255,255,0.72)", + }, + affectedEffect: { + fontFamily: fontBody.regular, + fontSize: 13.5, + lineHeight: 20, + color: "rgba(255,255,255,0.78)", + }, + affectedMore: { + alignSelf: "flex-start", + flexDirection: "row", + alignItems: "center", + gap: 5, + }, + affectedMoreText: { + fontFamily: fontBody.medium, + fontSize: 11, + opacity: 0.86, + }, + affectedContext: { + fontFamily: fontBody.regular, + fontSize: 12.5, + lineHeight: 18, + color: "rgba(255,255,255,0.68)", + }, + + /* unknowns */ + unknownCard: { + backgroundColor: planes.surface, + borderWidth: 1, + borderColor: hair[2], + borderRadius: 14, + padding: 15, + gap: 10, + }, + unknownHead: { flexDirection: "row", alignItems: "center", gap: 8 }, + unknownTitle: { + fontFamily: fontEditorial.bold, + fontSize: 15, + color: colors.white, + }, + unknownList: { gap: 8 }, + unknownRow: { + flexDirection: "row", + gap: 10, + backgroundColor: planes.slate, + borderRadius: 10, + padding: 11, + }, + unknownIndex: { + fontFamily: fontBody.bold, + fontSize: 10, + letterSpacing: 0.6, + paddingTop: 2, + }, + unknownText: { + flex: 1, + fontFamily: fontBody.regular, + fontSize: 13.5, + lineHeight: 20, + color: "rgba(255,255,255,0.78)", + }, + + /* disclosures */ + chevFlip: { transform: [{ rotate: "180deg" }] }, + + /* terms */ + termSection: { + backgroundColor: planes.slate, + borderWidth: 1, + borderColor: hair[1], + borderRadius: 14, + padding: 14, + gap: 12, + }, + termHeading: { flexDirection: "row", alignItems: "center", gap: 10 }, + termIcon: { + width: 32, + height: 32, + borderRadius: 9, + alignItems: "center", + justifyContent: "center", + }, + termHeadingCopy: { flex: 1, gap: 1 }, + termTitle: { + fontFamily: fontEditorial.bold, + fontSize: 16, + color: colors.white, + }, + termSubtitle: { + fontFamily: fontBody.regular, + fontSize: 11.5, + color: colors.textSecondary, + }, + termList: { gap: 8 }, + termRow: { + borderLeftWidth: 3, + backgroundColor: planes.surface, + borderRadius: 9, + paddingHorizontal: 12, + paddingVertical: 10, + gap: 3, + }, + termName: { + fontFamily: fontBody.semibold, + fontSize: 13.5, + color: colors.white, + }, + termPlain: { + fontFamily: fontBody.regular, + fontSize: 13, + lineHeight: 19, + color: "rgba(255,255,255,0.74)", + }, + + /* further reading */ + lensSection: { gap: 12 }, + readingList: { gap: 10 }, + deepDiveCard: { + width: "100%", + maxWidth: "100%", + flexDirection: "row", + alignItems: "flex-start", + gap: 11, + backgroundColor: planes.slate, + borderWidth: 1, + borderColor: hair[1], + borderLeftWidth: 3, + borderRadius: 14, + padding: 14, + overflow: "hidden", + }, + readingCard: { + width: "100%", + maxWidth: "100%", + flexDirection: "row", + alignItems: "flex-start", + gap: 11, + backgroundColor: planes.surface, + borderWidth: 1, + borderColor: hair[1], + borderRadius: 14, + padding: 14, + overflow: "hidden", + }, + readingIcon: { + width: 32, + height: 32, + borderRadius: 9, + backgroundColor: planes.slate, + alignItems: "center", + justifyContent: "center", + }, + readingCopy: { flex: 1, flexShrink: 1, minWidth: 0, gap: 4 }, + readingPublisher: { + fontFamily: fontBody.bold, + fontSize: 10, + letterSpacing: 0.7, + color: colors.textSecondary, + }, + deepDiveTitle: { + fontFamily: fontEditorial.bold, + fontSize: 18, + lineHeight: 22, + color: colors.white, + }, + readingTitle: { + flexShrink: 1, + fontFamily: fontBody.semibold, + fontSize: 14.5, + lineHeight: 19, + color: colors.white, + }, + readingWhy: { + flexShrink: 1, + fontFamily: fontBody.regular, + fontSize: 12.5, + lineHeight: 18, + color: "rgba(255,255,255,0.68)", + }, + readingAction: { + marginTop: 4, + fontFamily: fontBody.semibold, + fontSize: 12, + }, + deepDiveModal: { flex: 1, backgroundColor: planes.navy }, + modalHead: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 20, + paddingTop: 18, + paddingBottom: 14, + borderBottomWidth: 1, + borderBottomColor: hair[1], + }, + modalKicker: { + fontFamily: fontBody.bold, + fontSize: 10, + letterSpacing: 1.1, + }, + modalBrand: { + marginTop: 2, + fontFamily: fontBody.medium, + fontSize: 12, + color: colors.textSecondary, + }, + modalClose: { + width: 38, + height: 38, + borderRadius: 19, + backgroundColor: planes.slate, + alignItems: "center", + justifyContent: "center", + }, + modalContent: { + paddingHorizontal: 22, + paddingTop: 28, + paddingBottom: 56, + }, + modalTitle: { + fontFamily: fontEditorial.bold, + fontSize: 31, + lineHeight: 36, + color: colors.white, + }, + modalDek: { + marginTop: 12, + fontFamily: fontBody.regular, + fontSize: 16, + lineHeight: 24, + color: "rgba(255,255,255,0.7)", + }, + modalRule: { + width: 44, + height: 3, + borderRadius: 2, + marginTop: 22, + marginBottom: 12, + }, + modalSources: { + marginTop: 24, + paddingTop: 20, + borderTopWidth: 1, + borderTopColor: hair[1], + gap: 10, + }, + modalSourcesTitle: { + fontFamily: fontEditorial.bold, + fontSize: 19, + color: colors.white, + }, + modalSourceRow: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingVertical: 10, + }, + modalSourceTitle: { + fontFamily: fontBody.semibold, + fontSize: 14, + lineHeight: 19, + color: colors.white, + }, +}); diff --git a/apps/expo/src/components/ui/ContentCard.tsx b/apps/expo/src/components/ui/ContentCard.tsx index 255c225e..b4a3af90 100644 --- a/apps/expo/src/components/ui/ContentCard.tsx +++ b/apps/expo/src/components/ui/ContentCard.tsx @@ -8,6 +8,7 @@ import { Image } from "expo-image"; import type { ContentTypeKey } from "~/styles"; import { colors, contentType, fontBody, hair, planes } from "~/styles"; +import { editorialVisualFor } from "~/utils/editorial-visuals"; import { Icon } from "./Icon"; import { Badge, Spine } from "./primitives"; @@ -36,6 +37,7 @@ export function ContentCard({ }) { const t = contentType[item.type]; const imageUri = item.imageUri ?? item.thumbnailUrl; + const imageSource = editorialVisualFor(item.title, imageUri); const [imageFailed, setImageFailed] = useState(false); return ( - {imageUri && !imageFailed ? ( + {imageSource && !imageFailed ? ( Dual-Lens - Both sides, side by side — no spin. + Competing cases, with sources. - {(["left", "right"] as const).map((k, i) => ( - - {kickers(data.framing)[i]} - {data[k].stance} - - {data[k].points.map(toPoint).map((p, i) => ( - - - - {p.text} - {p.sourceIds.length > 0 && ( - [{p.sourceIds.join(",")}] - )} - - - ))} + {(["left", "right"] as const).map((k, i) => { + const lensAccent = i === 0 ? "#6DD6C7" : "#F2B56B"; + return ( + + + {kickers(data.framing)[i]} + + {data[k].stance} + + {data[k].points.map(toPoint).map((p, i) => { + const example = + typeof p.example === "string" + ? { fact: p.example, relevance: undefined } + : p.example; + return ( + + + + {p.text} + + {example ? ( + + + + + REAL-WORLD EXAMPLE + + + {example.fact} + {p.sourceIds.length > 0 && ( + + {" "} + [{p.sourceIds.join(",")}] + + )} + + {example.relevance && ( + + + WHAT IT SHOWS + + + {example.relevance} + + + )} + + + ) : ( + p.sourceIds.length > 0 && ( + + SOURCES [{p.sourceIds.join(",")}] + + ) + )} + + ); + })} + - - ))} + ); + })} {sources.length > 0 ? ( @@ -274,10 +344,9 @@ const s = StyleSheet.create({ fontSize: 12, color: colors.textSecondary, }, - cols: { flexDirection: "row", gap: 12 }, + cols: { gap: 10 }, col: { - flex: 1, - backgroundColor: planes.surface, + borderLeftWidth: 3, borderRadius: 12, padding: 14, borderWidth: 1, @@ -296,13 +365,13 @@ const s = StyleSheet.create({ color: colors.white, marginBottom: 10, }, - points: { gap: 9 }, + points: { gap: 12 }, + pointGroup: { gap: 7 }, point: { flexDirection: "row", gap: 8 }, dot: { width: 5, height: 5, borderRadius: 5, - backgroundColor: colors.textSecondary, marginTop: 7, }, pointText: { @@ -317,6 +386,51 @@ const s = StyleSheet.create({ fontSize: 10.5, color: colors.civicBlue, }, + legacyCite: { + marginLeft: 13, + fontFamily: "AlbertSans-Medium", + fontSize: 9.5, + letterSpacing: 0.5, + }, + example: { + marginLeft: 13, + flexDirection: "row", + alignItems: "flex-start", + gap: 8, + borderWidth: 1, + borderRadius: 9, + padding: 10, + }, + exampleCopy: { flex: 1, gap: 3 }, + exampleLabel: { + fontFamily: "AlbertSans-Medium", + fontSize: 9, + letterSpacing: 0.8, + }, + exampleText: { + fontFamily: "AlbertSans-Regular", + fontSize: 11.5, + lineHeight: 16, + color: "rgba(255,255,255,0.76)", + }, + relevance: { + marginTop: 6, + paddingTop: 7, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: "rgba(255,255,255,0.14)", + gap: 3, + }, + relevanceLabel: { + fontFamily: "AlbertSans-Medium", + fontSize: 8.5, + letterSpacing: 0.7, + }, + relevanceText: { + fontFamily: "AlbertSans-Regular", + fontSize: 11.5, + lineHeight: 16, + color: "rgba(255,255,255,0.86)", + }, footer: { flexDirection: "row", alignItems: "center", gap: 7, marginTop: 14 }, footerText: { flex: 1, diff --git a/apps/expo/src/components/ui/Icon.tsx b/apps/expo/src/components/ui/Icon.tsx index bf368eb0..7e78ff48 100644 --- a/apps/expo/src/components/ui/Icon.tsx +++ b/apps/expo/src/components/ui/Icon.tsx @@ -46,7 +46,13 @@ type IconName = | "edit" | "heart" | "download" - | "sparkle"; + | "sparkle" + | "arrowUp" + | "arrowDown" + | "arrowRight" + | "minus" + | "quote" + | "book"; type Family = "ion" | "feather" | "fa"; @@ -90,6 +96,12 @@ const MAP: Record = { heart: { family: "feather", name: "heart" }, download: { family: "feather", name: "download" }, sparkle: { family: "ion", name: "sparkles-outline" }, + arrowUp: { family: "feather", name: "arrow-up" }, + arrowDown: { family: "feather", name: "arrow-down" }, + arrowRight: { family: "feather", name: "arrow-right" }, + minus: { family: "feather", name: "minus" }, + quote: { family: "fa", name: "quote-left" }, + book: { family: "feather", name: "book-open" }, }; export interface IconProps { diff --git a/apps/expo/src/components/ui/NarrativeBrief.tsx b/apps/expo/src/components/ui/NarrativeBrief.tsx new file mode 100644 index 00000000..79ecc8e1 --- /dev/null +++ b/apps/expo/src/components/ui/NarrativeBrief.tsx @@ -0,0 +1,360 @@ +import type { ReactNode } from "react"; +import type { StyleProp, TextStyle } from "react-native"; +import { useState } from "react"; +import { StyleSheet, TouchableOpacity, View } from "react-native"; + +import type { BriefQuote } from "./BillBrief"; +import { Text } from "~/components/Themed"; +import { colors, fontBody, fontDisplay, hair, planes } from "~/styles"; +import { Icon } from "./Icon"; + +export interface NarrativeBriefData { + kind: "court_case"; + presentation: "court_case"; + badge: string; + hook: string; + facts: { + label: string; + value: string; + note?: string; + quote?: BriefQuote; + }[]; + sections: { + title: string; + items: { text: string; quote?: BriefQuote }[]; + }[]; + terms: { term: string; plain: string }[]; + unknowns: string[]; +} + +function EmphasizedText({ + children, + style, +}: { + children: string; + style?: StyleProp; +}) { + const parts = children.split(/(\*\*[^*]+\*\*)/g); + return ( + + {parts.map((part, index) => + part.startsWith("**") && part.endsWith("**") + ? part + .slice(2, -2) + .split(/(\s+)/) + .map((token, tokenIndex) => + /^\s+$/.test(token) ? ( + token + ) : ( + + {token} + + ), + ) + : part, + )} + + ); +} + +export function NarrativeBrief({ + data, + accent, + dualLens, + onViewSource, +}: { + data: NarrativeBriefData; + accent: string; + dualLens?: ReactNode; + onViewSource?: (quote: BriefQuote) => void; +}) { + const [termsOpen, setTermsOpen] = useState(false); + + return ( + + + + + + + Why this case matters + + {data.badge} + + + {data.hook} + + + {data.facts.length > 0 ? ( + + {data.facts.map((fact, index) => ( + + {fact.label.toUpperCase()} + {fact.value} + {fact.note ? {fact.note} : null} + + ))} + + ) : null} + + {data.terms.length > 0 ? ( + + setTermsOpen((open) => !open)} + accessibilityRole="button" + accessibilityState={{ expanded: termsOpen }} + > + + + Key terms + + + + {termsOpen + ? data.terms.map((term) => ( + + {term.term} + + {term.plain} + + + )) + : null} + + ) : null} + + {data.sections.map((section, sectionIndex) => ( + + {section.title} + {section.items.map((item, itemIndex) => ( + + + + {String(itemIndex + 1).padStart(2, "0")} + + + + {item.text} + {item.quote && onViewSource ? ( + item.quote && onViewSource(item.quote)} + accessibilityRole="button" + > + + + View source + + + + ) : null} + + + ))} + + ))} + + {data.unknowns.length > 0 ? ( + + + + What remains unsettled + + {data.unknowns.map((unknown, index) => ( + + + {String(index + 1).padStart(2, "0")} + + {unknown} + + ))} + + ) : null} + + {dualLens ? {dualLens} : null} + + ); +} + +const s = StyleSheet.create({ + hook: { + borderRadius: 18, + borderWidth: 1, + borderColor: hair[2], + borderLeftWidth: 4, + backgroundColor: planes.slate, + padding: 18, + marginBottom: 18, + }, + hookHead: { + flexDirection: "row", + alignItems: "center", + gap: 10, + marginBottom: 14, + }, + iconTile: { + width: 40, + height: 40, + borderRadius: 12, + alignItems: "center", + justifyContent: "center", + }, + hookTitle: { + flex: 1, + color: colors.white, + fontFamily: fontDisplay.bold, + fontSize: 21, + }, + badge: { + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 10, + paddingVertical: 5, + }, + badgeText: { + fontFamily: fontBody.bold, + fontSize: 10, + letterSpacing: 1.1, + }, + hookText: { + color: colors.white, + fontFamily: fontBody.regular, + fontSize: 17, + lineHeight: 26, + }, + inlineStrong: { + color: colors.white, + fontFamily: fontBody.bold, + }, + factGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: 10, + marginBottom: 18, + }, + fact: { + minWidth: "47%", + flexGrow: 1, + flexBasis: 140, + borderRadius: 14, + backgroundColor: planes.slate, + borderWidth: 1, + borderColor: hair[2], + padding: 14, + }, + factLabel: { + color: colors.textSecondary, + fontFamily: fontBody.bold, + fontSize: 10, + letterSpacing: 1.1, + marginBottom: 6, + }, + factValue: { + color: colors.white, + fontFamily: fontDisplay.bold, + fontSize: 20, + }, + factNote: { + color: colors.textSecondary, + fontFamily: fontBody.regular, + fontSize: 12, + lineHeight: 17, + marginTop: 4, + }, + terms: { + borderRadius: 16, + backgroundColor: planes.slate, + borderWidth: 1, + borderColor: hair[2], + padding: 16, + marginBottom: 22, + }, + termsHead: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + sectionTitleRow: { flexDirection: "row", alignItems: "center", gap: 9 }, + sectionTitle: { + color: colors.white, + fontFamily: fontDisplay.bold, + fontSize: 20, + }, + termRow: { + borderTopWidth: 1, + borderTopColor: hair[2], + marginTop: 14, + paddingTop: 14, + }, + termName: { + color: colors.white, + fontFamily: fontBody.bold, + fontSize: 15, + marginBottom: 5, + }, + termPlain: { + color: colors.textSecondary, + fontFamily: fontBody.regular, + fontSize: 14, + lineHeight: 20, + }, + section: { marginBottom: 24 }, + item: { + flexDirection: "row", + gap: 12, + borderRadius: 16, + backgroundColor: planes.slate, + borderWidth: 1, + borderColor: hair[2], + padding: 16, + marginTop: 10, + }, + itemNumber: { + width: 32, + height: 32, + borderRadius: 9, + alignItems: "center", + justifyContent: "center", + }, + itemNumberText: { fontFamily: fontBody.bold, fontSize: 11 }, + itemBody: { flex: 1, minWidth: 0 }, + itemText: { + color: colors.white, + fontFamily: fontBody.regular, + fontSize: 16, + lineHeight: 24, + }, + sourceButton: { + flexDirection: "row", + alignItems: "center", + gap: 7, + marginTop: 12, + }, + sourceButtonText: { fontFamily: fontBody.bold, fontSize: 13 }, + unknowns: { + borderRadius: 16, + backgroundColor: planes.slate, + borderWidth: 1, + borderColor: hair[2], + padding: 16, + marginBottom: 24, + }, + unknownItem: { + flexDirection: "row", + gap: 12, + borderTopWidth: 1, + borderTopColor: hair[2], + paddingTop: 13, + marginTop: 13, + }, + unknownNumber: { fontFamily: fontBody.bold, fontSize: 12, paddingTop: 2 }, + unknownText: { + flex: 1, + color: colors.textSecondary, + fontFamily: fontBody.regular, + fontSize: 15, + lineHeight: 22, + }, + lens: { marginTop: 4, marginBottom: 24 }, +}); diff --git a/apps/expo/src/components/ui/index.ts b/apps/expo/src/components/ui/index.ts index c30c99db..3e10f56c 100644 --- a/apps/expo/src/components/ui/index.ts +++ b/apps/expo/src/components/ui/index.ts @@ -16,4 +16,6 @@ export { Pills } from "./Pills"; export { Segmented, type SegmentOption } from "./Segmented"; export { SettingsRow } from "./SettingsRow"; export { LensStrip, LensPanel, type LensData } from "./DualLens"; +export { BillBrief, type BillBriefData, type BriefQuote } from "./BillBrief"; +export { NarrativeBrief, type NarrativeBriefData } from "./NarrativeBrief"; export { ContentCard, type ContentCardItem } from "./ContentCard"; diff --git a/apps/expo/src/utils/editorial-visuals.ts b/apps/expo/src/utils/editorial-visuals.ts new file mode 100644 index 00000000..42e7ebf4 --- /dev/null +++ b/apps/expo/src/utils/editorial-visuals.ts @@ -0,0 +1,29 @@ +import type { ImageSource } from "expo-image"; + +import algorithmTransparencyImage from "../../assets/article-brief/algorithm-transparency.jpg"; +import infrastructureRepairImage from "../../assets/article-brief/infrastructure-repair.jpg"; +import publicTransitImage from "../../assets/article-brief/public-transit.jpg"; + +type EditorialImageSource = ImageSource | number; + +const TITLE_VISUALS: Record = { + "Infrastructure Modernization Act of 2025": infrastructureRepairImage, + "TechCorp Inc. v. California": algorithmTransparencyImage, + "Digital Privacy Protection Act": algorithmTransparencyImage, +}; + +/** + * Local editorial art wins over generic seeded thumbnails. The fallback keeps + * real scraper imagery working while preventing placeholder landscapes from + * representing unrelated policy in the demo. + */ +export function editorialVisualFor( + title: string, + remoteUri?: string | null, +): EditorialImageSource | undefined { + if (TITLE_VISUALS[title]) return TITLE_VISUALS[title]; + if (!remoteUri || remoteUri.includes("picsum.photos")) return undefined; + return { uri: remoteUri }; +} + +export { publicTransitImage }; diff --git a/apps/scraper/package.json b/apps/scraper/package.json index 9cce7e87..4792e76b 100644 --- a/apps/scraper/package.json +++ b/apps/scraper/package.json @@ -7,6 +7,7 @@ "@acme/api": "workspace:*", "@acme/db": "workspace:*", "@acme/env": "workspace:*", + "@acme/validators": "workspace:*", "@ai-sdk/anthropic": "^3.0.92", "@ai-sdk/deepseek": "^1.0.0", "@ai-sdk/google": "^3.0.53", @@ -43,6 +44,7 @@ "typecheck": "tsc --noEmit", "test": "tsx --test \"src/**/*.test.ts\"", "retroactive-lenses": "tsx src/retroactive-lenses-entry.ts", + "retroactive-briefs": "tsx src/retroactive-briefs-entry.ts", "reprocess-content": "tsx src/reprocess-content-entry.ts", "retroactive-videos": "tsx src/retroactive-videos-entry.ts", "backfill-bill-descriptions": "tsx src/backfill-bill-descriptions-entry.ts" diff --git a/apps/scraper/src/retroactive-briefs-entry.ts b/apps/scraper/src/retroactive-briefs-entry.ts new file mode 100644 index 00000000..8f5eddfd --- /dev/null +++ b/apps/scraper/src/retroactive-briefs-entry.ts @@ -0,0 +1,4 @@ +import { loadRepoEnv } from "@acme/env/load"; + +loadRepoEnv(); +await import("./retroactive-briefs.js"); diff --git a/apps/scraper/src/retroactive-briefs.ts b/apps/scraper/src/retroactive-briefs.ts new file mode 100644 index 00000000..3c7fcfcd --- /dev/null +++ b/apps/scraper/src/retroactive-briefs.ts @@ -0,0 +1,217 @@ +/** + * Backfill structured briefs for bills and court cases that predate the brief + * pipeline, or whose brief is stale relative to the source contentHash. + * + * Mirrors `retroactive-lenses.ts`. Existing long-form analysis is passed to + * the content-specific generator as context, while quotes are still verified + * against the official text. + */ +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; + +import { and, desc, eq, isNotNull, isNull, ne, or } from "@acme/db"; +import { db } from "@acme/db/client"; +import { Bill, ContentBrief, CourtCase } from "@acme/db/schema"; + +import { AIRateLimitError } from "./utils/ai/text-generation.js"; +import { + upsertBillBrief, + upsertCourtCaseBrief, +} from "./utils/db/operations.js"; +import { createLogger } from "./utils/log.js"; + +const logger = createLogger("brief-backfill"); + +interface BillBriefCandidate { + type: "bill"; + id: string; + contentHash: string; + title: string; + billNumber: string; + url: string; + fullText: string; + status: string | null; + aiGeneratedArticle: string | null; +} + +interface CourtBriefCandidate { + type: "court_case"; + id: string; + contentHash: string; + title: string; + court: string; + caseNumber: string; + fullText: string; + status: string | null; + aiGeneratedArticle: string | null; +} + +type BriefCandidate = BillBriefCandidate | CourtBriefCandidate; + +async function findBills(limit: number): Promise { + const rows = await db + .select({ + id: Bill.id, + contentHash: Bill.contentHash, + title: Bill.title, + billNumber: Bill.billNumber, + url: Bill.url, + fullText: Bill.fullText, + status: Bill.status, + aiGeneratedArticle: Bill.aiGeneratedArticle, + }) + .from(Bill) + .leftJoin( + ContentBrief, + and( + eq(ContentBrief.contentType, "bill"), + eq(ContentBrief.contentId, Bill.id), + ), + ) + .where( + and( + isNotNull(Bill.fullText), + or( + isNull(ContentBrief.id), + ne(ContentBrief.contentHash, Bill.contentHash), + ), + ), + ) + .orderBy(desc(Bill.createdAt)) + .limit(limit); + + return rows.flatMap((row) => + row.fullText + ? [{ ...row, type: "bill" as const, fullText: row.fullText }] + : [], + ); +} + +async function findCourtCases(limit: number): Promise { + const rows = await db + .select({ + id: CourtCase.id, + contentHash: CourtCase.contentHash, + title: CourtCase.title, + court: CourtCase.court, + caseNumber: CourtCase.caseNumber, + fullText: CourtCase.fullText, + status: CourtCase.status, + aiGeneratedArticle: CourtCase.aiGeneratedArticle, + }) + .from(CourtCase) + .leftJoin( + ContentBrief, + and( + eq(ContentBrief.contentType, "court_case"), + eq(ContentBrief.contentId, CourtCase.id), + ), + ) + .where( + and( + isNotNull(CourtCase.fullText), + or( + isNull(ContentBrief.id), + ne(ContentBrief.contentHash, CourtCase.contentHash), + ), + ), + ) + .orderBy(desc(CourtCase.createdAt)) + .limit(limit); + + return rows.flatMap((row) => + row.fullText + ? [{ ...row, type: "court_case" as const, fullText: row.fullText }] + : [], + ); +} + +const argv = await yargs(hideBin(process.argv)) + .option("limit", { + alias: "l", + type: "number", + default: 10, + describe: "Maximum missing/stale briefs to process", + }) + .option("dry-run", { + alias: "d", + type: "boolean", + default: false, + describe: "List candidates without generating briefs", + }) + .option("type", { + choices: ["bill", "court_case", "all"] as const, + default: "all" as const, + describe: "Content type to backfill", + }) + .check((args) => + Number.isInteger(args.limit) && args.limit > 0 + ? true + : "--limit must be a positive integer", + ) + .strict() + .help() + .parse(); + +const candidates: BriefCandidate[] = [ + ...(argv.type === "court_case" ? [] : await findBills(argv.limit)), + ...(argv.type === "bill" ? [] : await findCourtCases(argv.limit)), +].slice(0, argv.limit); +logger.info(`Found ${candidates.length} missing/stale brief candidate(s)`); + +let processed = 0; +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}`); + 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, + }); + if (generated) processed++; + else failed++; + } catch (error) { + // A rate limit means every remaining candidate would fail the same way. + if (error instanceof AIRateLimitError) { + logger.warn("LLM rate limit hit — stopping backfill early"); + break; + } + failed++; + const identifier = + candidate.type === "bill" ? candidate.billNumber : candidate.caseNumber; + logger.error(`Failed brief for ${identifier}`, error); + } +} + +logger.info( + argv.dryRun + ? "Brief backfill dry run completed" + : `Brief backfill completed: ${processed} processed, ${failed} failed`, +); + +if (failed > 0) process.exitCode = 1; diff --git a/apps/scraper/src/utils/ai/bill-brief.test.ts b/apps/scraper/src/utils/ai/bill-brief.test.ts new file mode 100644 index 00000000..36d0197f --- /dev/null +++ b/apps/scraper/src/utils/ai/bill-brief.test.ts @@ -0,0 +1,439 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { BillBrief } from "@acme/validators"; +import { + BILL_BRIEF_VERSION, + isCurrentBillBrief, + isUsableBillBrief, + parseBillBriefRecord, +} from "@acme/validators"; + +import { + deriveLegalStatus, + findLoadedLanguage, + findMissingEmphasis, + findUnexplainedJargon, + isQuoteGrounded, + normalizeForQuoteMatch, + verifyBriefContext, + verifyBriefQuotes, + verifyBriefReading, +} from "./bill-brief.js"; + +const SOURCE = ` +SEC. 4. WAIVER OF ENVIRONMENTAL REVIEW. + + (a) In general.--The Secretary may waive the requirements of section +102(2)(C) of the National Environmental Policy Act of 1969 with respect +to any covered project, if the Secretary determines that the waiver is +necessary to meet an operational deadline. + + (b) Authorization of appropriations.--There is authorized to be +appropriated $1,200,000,000 for fiscal year 2027 to carry out this +section. +`; + +function brief(overrides: Partial = {}): BillBrief { + return { + hook: "The bill would let the Secretary skip environmental reviews for covered projects when an operational deadline is at risk. The text does not define which deadlines would qualify.", + facts: [ + { + label: "Authorized funding", + value: "$1.2B", + note: "For fiscal year 2027.", + }, + ], + changes: [ + { + kind: "waives", + title: "Environmental review can be skipped", + before: + "Covered projects must complete a review under the National Environmental Policy Act.", + after: + "The Secretary would be able to waive that review to meet an operational deadline.", + }, + ], + affected: [ + { + group: "Communities near covered projects", + takeaway: + "Nearby communities would lose a required opportunity for public comment.", + effect: + "They would lose the public comment step that the review process provides.", + direction: "loses", + }, + ], + unknowns: [ + "The text does not define what counts as an operational deadline.", + ], + terms: [], + reading: [], + ...overrides, + }; +} + +void test("older brief records stay renderable but are not current cache hits", () => { + const metadata = { + legalStatus: "proposed" as const, + verifiedQuotes: 0, + generatedAt: "2026-07-26T00:00:00.000Z", + modelVersion: "legacy-model", + }; + const v5 = { ...brief(), ...metadata, version: 5 }; + const v6 = { ...brief(), ...metadata, version: 6 }; + + assert.equal(isUsableBillBrief(v5), true); + assert.equal(isCurrentBillBrief(v5), false); + assert.equal(parseBillBriefRecord(v5)?.version, BILL_BRIEF_VERSION); + assert.equal(isUsableBillBrief(v6), true); + assert.equal(isCurrentBillBrief(v6), false); + assert.equal(parseBillBriefRecord(v6)?.version, BILL_BRIEF_VERSION); + + const current = { + ...v5, + version: BILL_BRIEF_VERSION, + }; + assert.equal(isCurrentBillBrief(current), true); +}); + +void test("v1 briefs receive client defaults at the API boundary", () => { + const current = brief(); + const v1 = { + version: 1, + hook: current.hook, + facts: current.facts, + changes: current.changes, + affected: current.affected.map(({ takeaway: _takeaway, ...item }) => item), + unknowns: current.unknowns, + terms: current.terms, + sections: [], + legalStatus: "proposed" as const, + verifiedQuotes: 0, + generatedAt: "2026-07-26T00:00:00.000Z", + modelVersion: "legacy-model", + }; + const normalized = parseBillBriefRecord(v1); + + assert.equal(normalized?.version, BILL_BRIEF_VERSION); + assert.deepEqual(normalized?.reading, []); + assert.equal(normalized?.affected[0]?.takeaway, current.affected[0]?.effect); +}); + +void test("brief-wide emphasis lint names every prose field that needs revision", () => { + const missing = findMissingEmphasis(brief()); + + assert.ok(missing.includes("hook")); + assert.ok(missing.includes("changes[0].before")); + assert.ok(missing.includes("affected[0].takeaway")); + assert.ok(missing.includes("unknowns[0]")); + + const emphasized = brief({ + hook: "The bill would let the Secretary **skip environmental reviews** for covered projects when an operational deadline is at risk.", + changes: [ + { + kind: "waives", + title: "Environmental review can be skipped", + before: + "Covered projects must **complete an environmental review** under current law.", + after: + "The Secretary could **waive that review** to meet an operational deadline.", + }, + ], + affected: [ + { + group: "Communities near covered projects", + takeaway: + "Nearby communities would **lose a required opportunity for public comment**.", + effect: + "They would **lose the public comment step** that the review process provides.", + direction: "loses", + }, + ], + unknowns: [ + "The text does not define **what counts as an operational deadline**.", + ], + }); + assert.deepEqual(findMissingEmphasis(emphasized), []); +}); + +void test("further reading keeps only URLs found by the research loop", () => { + const result = verifyBriefReading( + brief({ + reading: [ + { + title: "A useful researched explainer", + publisher: "Congressional Research Service", + url: "https://example.com/researched/", + whyRead: + "It explains how Congress turns a spending limit into money.", + }, + { + title: "A convincing but invented article", + publisher: "Made Up News", + url: "https://example.com/invented", + whyRead: + "The model created this URL, so readers should never see it.", + }, + ], + }), + [ + { + id: 1, + title: "Researched source", + url: "https://example.com/researched", + }, + ], + ); + + assert.deepEqual( + result.reading.map((item) => item.url), + ["https://example.com/researched"], + ); +}); + +void test("historical context requires two opened research sources", () => { + const researched = [ + { + id: 1, + title: "First opened source", + url: "https://example.com/first", + }, + { + id: 2, + title: "Second opened source", + url: "https://example.com/second", + }, + ]; + const result = verifyBriefContext( + brief({ + whyNotBefore: { + summary: + "Earlier proposals stalled because lawmakers had not resolved two separate implementation questions.", + points: [ + { + text: "The first documented disagreement concerned which existing rules the proposal would replace.", + citations: [ + { + title: "First opened source", + publisher: "Research Office", + url: "https://example.com/first/", + }, + { + title: "Invented source", + publisher: "Made Up News", + url: "https://example.com/invented", + }, + ], + }, + { + text: "A second source documents a separate disagreement over how the new rule would be enforced.", + citations: [ + { + title: "Second opened source", + publisher: "Research Office", + url: "https://example.com/second", + }, + ], + }, + ], + }, + }), + researched, + ); + + assert.deepEqual( + result.whyNotBefore?.points.flatMap((point) => + point.citations.map((citation) => citation.url), + ), + ["https://example.com/first", "https://example.com/second"], + ); + + const underSourced = verifyBriefContext( + brief({ + whyNotBefore: { + summary: + "One opened page alone is not enough to support this historical explanation.", + points: [ + { + text: "This claim has only one source, so the whole optional section should be removed.", + citations: [ + { + title: "First opened source", + publisher: "Research Office", + url: "https://example.com/first", + }, + ], + }, + ], + }, + }), + researched, + ); + assert.equal(underSourced.whyNotBefore, undefined); +}); + +void test("quote matching ignores whitespace, case, and smart punctuation", () => { + const normalized = normalizeForQuoteMatch(SOURCE); + assert.equal( + isQuoteGrounded( + "The Secretary may waive the requirements of section 102(2)(C)", + normalized, + ), + true, + ); + // Wrapped across a newline in the source, and re-typed with curly quotes. + assert.equal( + isQuoteGrounded( + "if the Secretary determines that the waiver is necessary", + normalized, + ), + true, + ); +}); + +void test("quote matching rejects paraphrase and reordered words", () => { + const normalized = normalizeForQuoteMatch(SOURCE); + assert.equal( + isQuoteGrounded( + "The Secretary is allowed to waive environmental review requirements", + normalized, + ), + false, + ); + assert.equal( + isQuoteGrounded( + "may waive the requirements of the National Environmental Policy Act of 1969", + normalized, + ), + false, + ); + // Too short to be meaningful once normalized. + assert.equal(isQuoteGrounded("the Secretary", normalized), false); +}); + +void test("verification strips unverified quotes but keeps the claim", () => { + const input = brief({ + facts: [ + { + label: "Authorized funding", + value: "$1.2B", + quote: { text: "appropriated $1,200,000,000 for fiscal year 2027" }, + }, + ], + changes: [ + { + kind: "waives", + title: "Environmental review can be skipped", + before: "Covered projects must complete a review.", + after: "The Secretary would be able to waive that review.", + quote: { + text: "The Secretary shall have unlimited authority to ignore all federal law", + locator: "Sec. 4(a)", + }, + }, + ], + }); + + const { + brief: cleaned, + verified, + dropped, + } = verifyBriefQuotes(input, SOURCE); + assert.equal(verified, 1); + assert.equal(dropped, 1); + assert.ok(cleaned.facts[0]?.quote, "grounded quote is kept"); + assert.equal(cleaned.changes[0]?.quote, undefined, "invented quote is gone"); + assert.equal( + cleaned.changes[0]?.title, + "Environmental review can be skipped", + "the surrounding claim survives", + ); +}); + +void test("framing lint flags loaded language in the model's own voice", () => { + assert.deepEqual(findLoadedLanguage(brief()), []); + + const loaded = findLoadedLanguage( + brief({ + hook: "This common sense bill guts a burdensome review requirement.", + unknowns: ["Whether this radical shift survives review."], + }), + ); + assert.deepEqual(loaded.sort(), [ + "burdensome", + "common sense", + "guts", + "radical", + ]); +}); + +void test("framing lint exempts verbatim quotes from the source", () => { + // A sponsor calling their own bill "common sense" is reportable; repeating it + // inside a quote must not trip the lint. + const withLoadedQuote = brief({ + changes: [ + { + kind: "waives", + title: "Environmental review can be skipped", + before: "Covered projects must complete a review.", + after: "The Secretary would be able to waive that review.", + quote: { + text: "this common sense reform cuts red tape for job-killing delays", + }, + }, + ], + }); + assert.deepEqual(findLoadedLanguage(withLoadedQuote), []); +}); + +void test("plain-language lint flags unexplained policy jargon", () => { + const jargonBrief = brief({ + facts: [], + changes: [ + { + kind: "funds", + title: "Road funding changes", + before: + "Cities currently compete through discretionary federal grants.", + after: + "States would receive a longer funding horizon under the proposal.", + }, + ], + }); + + assert.deepEqual(findUnexplainedJargon(jargonBrief), [ + "funding horizon", + "discretionary grant", + ]); +}); + +void test("plain-language lint allows an essential term defined up front", () => { + const definedBrief = brief({ + changes: [ + { + kind: "funds", + title: "Congress sets a spending limit", + before: "Congress currently approves the program for a shorter period.", + after: "The bill would create a ten-year authorization.", + }, + ], + terms: [ + { + term: "Authorization", + plain: + "Congress allows a program to spend up to a limit but does not provide the money yet.", + }, + ], + }); + + assert.deepEqual(findUnexplainedJargon(definedBrief), []); +}); + +void test("legal status comes from the scraped status string", () => { + assert.equal(deriveLegalStatus("Became Public Law No: 118-42"), "enacted"); + assert.equal(deriveLegalStatus("Signed by President"), "enacted"); + assert.equal(deriveLegalStatus("Introduced"), "proposed"); + assert.equal(deriveLegalStatus("Passed House"), "proposed"); + assert.equal(deriveLegalStatus(null), "proposed"); +}); diff --git a/apps/scraper/src/utils/ai/bill-brief.ts b/apps/scraper/src/utils/ai/bill-brief.ts new file mode 100644 index 00000000..4d20dc9b --- /dev/null +++ b/apps/scraper/src/utils/ai/bill-brief.ts @@ -0,0 +1,730 @@ +/** + * Bill Brief generation — turns a bill into the structured document defined in + * `@acme/validators`, replacing the markdown wall of text as the primary read. + * + * The pipeline is deliberately three steps, only one of which costs a token: + * + * 1. Structure (LLM). One schema-validated call grounded in the official text + * plus, when we have it, the existing long-form article — that article has + * already done the careful nonpartisan analysis, so this pass is mostly a + * restructuring job rather than a fresh reading. + * 2. Verify quotes (deterministic). Every quote is checked against the full + * source text. Unverified quotes are dropped, not shipped — a brief may + * say less than the model wrote, but it never attributes words to a bill + * that the bill does not contain. + * 3. Lint framing (deterministic). Loaded political phrasing in the model's + * own prose triggers one regeneration with the offending phrases named. + * Quotes are exempt: a source is allowed to be partisan, we are not. + */ +import { APICallError, generateText, Output, RetryError } from "ai"; + +import type { + BillBrief, + BillBriefRecord, + BriefLegalStatus, +} from "@acme/validators"; +import { BILL_BRIEF_VERSION, BillBriefSchema } from "@acme/validators"; + +import type { DualLensSource } from "./text-generation.js"; +import { trackLLMUsage } from "../costs.js"; +import { createLogger } from "../log.js"; +import { getTextLlm } from "./provider.js"; +import { + AIRateLimitError, + rateLimitHit, + researchBillContext, + setRateLimitHit, +} from "./text-generation.js"; + +const logger = createLogger("ai-brief"); + +/** + * How much of the bill the model reads. Bills routinely run past a provider's + * context window; verification still runs against the *whole* text, so a quote + * pulled from anywhere in the document validates even though the model only + * saw the opening. Larger than the 3–4k windows used elsewhere because a brief + * has to find concrete provisions, not just a gist. + */ +const SOURCE_WINDOW = 24_000; + +/** Attempts at structuring before giving up (each is one LLM call). */ +const MAX_ATTEMPTS = 2; + +function isRateLimitError(error: unknown): boolean { + if (error instanceof APICallError) return error.statusCode === 429; + if (error instanceof RetryError) return isRateLimitError(error.lastError); + if (!(error instanceof Error)) return false; + const msg = error.message.toLowerCase(); + return ( + msg.includes("429") || + msg.includes("rate limit") || + msg.includes("resource_exhausted") || + msg.includes("quota") + ); +} + +/* ------------------------------------------------------------------ * + * Step 2 — quote verification + * ------------------------------------------------------------------ */ + +/** + * Collapse the cosmetic differences between a quote and its source: casing, + * smart quotes and dashes, hyphenation across line breaks, and the erratic + * whitespace of scraped legislative text. Punctuation is dropped entirely + * because bill text arrives with inconsistent spacing around it. + * + * This is deliberately lenient about *formatting* and strict about *words*: + * dropping or reordering a word still fails, which is the failure mode that + * matters. + */ +export function normalizeForQuoteMatch(text: string): string { + return text + .replace(/[‘’ʼ]/g, "'") + .replace(/[“”]/g, '"') + .replace(/[‐-―]/g, "-") + .replace(/-\s*\n\s*/g, "") // de-hyphenate across wrapped lines + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +/** Whether a quote appears verbatim (modulo formatting) in the source. */ +export function isQuoteGrounded(quote: string, normalizedSource: string) { + const needle = normalizeForQuoteMatch(quote); + // Very short fragments match by accident; the schema floor is 20 chars, but + // normalization can shrink a quote further. + if (needle.length < 20) return false; + return normalizedSource.includes(needle); +} + +export interface QuoteVerification { + brief: BillBrief; + /** Quotes that matched the source and were kept. */ + verified: number; + /** Quotes that did not match and were stripped from the brief. */ + dropped: number; +} + +/** + * Strip every quote that does not appear in `sourceText`. The surrounding + * claim is kept — losing a citation makes a point weaker, but deleting the + * point would let a bad quote silently remove real analysis. + */ +export function verifyBriefQuotes( + brief: BillBrief, + sourceText: string, +): QuoteVerification { + const normalizedSource = normalizeForQuoteMatch(sourceText); + let verified = 0; + let dropped = 0; + + const check = ( + item: T, + ): T => { + if (!item.quote) return item; + if (isQuoteGrounded(item.quote.text, normalizedSource)) { + verified++; + return item; + } + dropped++; + const { quote: _dropped, ...rest } = item; + return rest as T; + }; + + return { + brief: { + ...brief, + facts: brief.facts.map(check), + changes: brief.changes.map(check), + }, + verified, + dropped, + }; +} + +function comparableUrl(value: string): string { + try { + const url = new URL(value); + url.hash = ""; + return url.toString().replace(/\/$/, ""); + } catch { + return value.replace(/\/$/, ""); + } +} + +/** + * A model may summarize a researched page, but it may not create the link. + * Keep only URLs surfaced by the agentic search loop and replace cosmetic URL + * variations with the exact researched URL. + */ +export function verifyBriefReading( + brief: BillBrief, + sources: readonly DualLensSource[], +): BillBrief { + const verified = new Map( + sources.map((source) => [comparableUrl(source.url), source.url]), + ); + return { + ...brief, + reading: brief.reading.flatMap((item) => { + const url = verified.get(comparableUrl(item.url)); + return url ? [{ ...item, url }] : []; + }), + }; +} + +/** + * Historical context may use only pages the research agent successfully + * opened. Keep exact researched URLs, remove invented citations, and omit the + * entire section unless at least two distinct sources remain. + */ +export function verifyBriefContext( + brief: BillBrief, + sources: readonly DualLensSource[], +): BillBrief { + if (!brief.whyNotBefore) return brief; + + const verified = new Map( + sources.map((source) => [comparableUrl(source.url), source.url]), + ); + const points = brief.whyNotBefore.points.flatMap((point) => { + const citations = point.citations.flatMap((citation) => { + const url = verified.get(comparableUrl(citation.url)); + return url ? [{ ...citation, url }] : []; + }); + return citations.length > 0 ? [{ ...point, citations }] : []; + }); + const distinctSources = new Set( + points.flatMap((point) => point.citations.map((citation) => citation.url)), + ); + + if (points.length === 0 || distinctSources.size < 2) { + const { whyNotBefore: _dropped, ...rest } = brief; + return rest; + } + + return { + ...brief, + whyNotBefore: { + summary: brief.whyNotBefore.summary, + points, + }, + }; +} + +/* ------------------------------------------------------------------ * + * Step 3 — framing lint + * ------------------------------------------------------------------ */ + +/** + * Phrasing that carries a verdict rather than a fact. These are the terms that + * make an explainer read as an endorsement or an attack — most are borrowed + * from press-release and attack-ad vocabulary on both sides. The article prompt + * already warns against several ("cuts red tape", "streamlines"); this catches + * the cases where the model warns itself and then does it anyway. + * + * Word-boundary matched, and never applied to quoted source text. + */ +const LOADED_PHRASES = [ + "common ?sense", + "cuts? red tape", + "red tape", + "job[- ]killing", + "job[- ]creating", + "radical", + "extreme", + "extremist", + "reckless", + "dangerous", + "landmark", + "historic", + "much[- ]needed", + "long overdue", + "war on", + "handout", + "giveaway", + "power grab", + "sensible", + "burdensome", + "bloated", + "wasteful", + "special interests", + "slashes", + "guts", + "crackdown", +]; + +const LOADED_PATTERN = new RegExp( + `\\b(?:${LOADED_PHRASES.join("|")})\\b`, + "gi", +); + +/** + * Prose fields the model authored. Quotes are excluded on purpose: a bill or a + * sponsor is free to call something "common sense", and reproducing that + * verbatim is reporting, not editorializing. + */ +function authoredProse(brief: BillBrief): string[] { + return [ + brief.hook, + ...brief.facts.flatMap((f) => [f.label, f.value, f.note ?? ""]), + ...brief.changes.flatMap((c) => [c.title, c.before, c.after]), + ...brief.affected.flatMap((a) => [a.group, a.takeaway, a.effect]), + ...brief.unknowns, + ...brief.terms.flatMap((t) => [t.term, t.plain]), + ...(brief.whyNotBefore + ? [ + brief.whyNotBefore.summary, + ...brief.whyNotBefore.points.map((point) => point.text), + ] + : []), + ...(brief.deepDive + ? [brief.deepDive.title, brief.deepDive.dek, brief.deepDive.body] + : []), + ...brief.reading.flatMap((item) => [ + item.title, + item.publisher, + item.whyRead, + ]), + ]; +} + +/** Loaded phrases used in the model's own voice, deduped and lowercased. */ +export function findLoadedLanguage(brief: BillBrief): string[] { + const hits = new Set(); + for (const field of authoredProse(brief)) { + for (const match of field.matchAll(LOADED_PATTERN)) { + hits.add(match[0].toLowerCase()); + } + } + return [...hits]; +} + +/** + * Policy terms that often survive a "plain language" rewrite while remaining + * opaque to a general reader. A term may appear when the brief explicitly + * defines it up front; otherwise the generator gets one retry with the exact + * phrases named. + */ +const JARGON_RULES = [ + { + label: "funding horizon", + pattern: /\bfunding horizon\b/gi, + }, + { + label: "discretionary grant", + pattern: /\bdiscretionary (?:federal )?grants?\b/gi, + term: "discretionary grant", + }, + { + label: "reauthorization", + pattern: /\breauthoriz(?:e|es|ed|ing|ation)\b/gi, + term: "reauthorization", + }, + { + label: "appropriation", + pattern: /\bappropriat(?:e|es|ed|ing|ion|ions)\b/gi, + term: "appropriation", + }, + { + label: "authorization", + pattern: /\bauthoriz(?:e|es|ed|ing|ation)\b/gi, + term: "authorization", + }, + { + label: "formula funding", + pattern: /\bformula funding\b/gi, + term: "formula funding", + }, + { + label: "allocation formula", + pattern: /\ballocation formula\b/gi, + term: "allocation formula", + }, + { + label: "transit capital", + pattern: /\btransit capital\b/gi, + term: "transit capital", + }, + { + label: "affirmative consent", + pattern: /\baffirmative consent\b/gi, + term: "affirmative consent", + }, + { + label: "preemption", + pattern: /\bpreempt(?:s|ed|ing|ion)?\b/gi, + term: "preemption", + }, + { + label: "sector-specific", + pattern: /\bsector-specific\b/gi, + term: "sector-specific", + }, + { + label: "compliance costs", + pattern: /\bcompliance (?:costs?|obligations?)\b/gi, + term: "compliance", + }, +] as const; + +function readerFacingProse(brief: BillBrief): string[] { + return [ + brief.hook, + ...brief.facts.flatMap((f) => [f.label, f.value, f.note ?? ""]), + ...brief.changes.flatMap((c) => [c.title, c.before, c.after]), + ...brief.affected.flatMap((a) => [a.group, a.takeaway, a.effect]), + ...brief.unknowns, + ...(brief.whyNotBefore + ? [ + brief.whyNotBefore.summary, + ...brief.whyNotBefore.points.map((point) => point.text), + ] + : []), + ...(brief.deepDive + ? [brief.deepDive.title, brief.deepDive.dek, brief.deepDive.body] + : []), + ...brief.reading.flatMap((item) => [ + item.title, + item.publisher, + item.whyRead, + ]), + ]; +} + +/** Untranslated policy jargon in reader-facing fields, deduped. */ +export function findUnexplainedJargon(brief: BillBrief): string[] { + const definedTerms = brief.terms.map((entry) => entry.term.toLowerCase()); + const hits = new Set(); + + for (const rule of JARGON_RULES) { + if ( + "term" in rule && + definedTerms.some((term) => term.includes(rule.term)) + ) { + continue; + } + for (const field of readerFacingProse(brief)) { + rule.pattern.lastIndex = 0; + if (rule.pattern.test(field)) { + hits.add(rule.label); + break; + } + } + } + + return [...hits]; +} + +const EMPHASIS_PATTERN = /\*\*[^*\n]+\*\*/; + +/** + * Structured prose that should expose at least one concrete scan target. + * Titles, labels, figures, publishers, and verbatim quotes are intentionally + * excluded: emphasizing those would add noise or alter source material. + */ +export function findMissingEmphasis(brief: BillBrief): string[] { + const fields: { label: string; value: string }[] = [ + { label: "hook", value: brief.hook }, + ...brief.changes.flatMap((change, index) => [ + { label: `changes[${index}].before`, value: change.before }, + { label: `changes[${index}].after`, value: change.after }, + ]), + ...brief.affected.flatMap((group, index) => [ + { label: `affected[${index}].takeaway`, value: group.takeaway }, + { label: `affected[${index}].effect`, value: group.effect }, + ]), + ...brief.unknowns.map((value, index) => ({ + label: `unknowns[${index}]`, + value, + })), + ...brief.terms.map((term, index) => ({ + label: `terms[${index}].plain`, + value: term.plain, + })), + ...(brief.whyNotBefore + ? [ + { + label: "whyNotBefore.summary", + value: brief.whyNotBefore.summary, + }, + ...brief.whyNotBefore.points.map((point, index) => ({ + label: `whyNotBefore.points[${index}].text`, + value: point.text, + })), + ] + : []), + ...(brief.deepDive + ? [ + { label: "deepDive.dek", value: brief.deepDive.dek }, + { label: "deepDive.body", value: brief.deepDive.body }, + ] + : []), + ...brief.reading.map((item, index) => ({ + label: `reading[${index}].whyRead`, + value: item.whyRead, + })), + ]; + + return fields + .filter(({ value }) => !EMPHASIS_PATTERN.test(value)) + .map(({ label }) => label); +} + +/* ------------------------------------------------------------------ * + * Step 1 — structuring + * ------------------------------------------------------------------ */ + +/** + * Derive legal status from the scraped status string rather than asking the + * model. This drives whether the UI frames changes as "would" or "does", so a + * string match beats an inference we would have to trust. + */ +export function deriveLegalStatus( + status: string | null | undefined, +): BriefLegalStatus { + const s = (status ?? "").toLowerCase(); + return /became law|public law|signed by president|enacted|became public law/.test( + s, + ) + ? "enacted" + : "proposed"; +} + +function buildBriefPrompt(args: { + title: string; + billNumber: string; + url: string; + legalStatus: BriefLegalStatus; + sourceText: string; + priorArticle?: string | null; + readingResearch?: string; + readingSources?: DualLensSource[]; + loadedPhrases?: string[]; + jargonPhrases?: string[]; + missingEmphasis?: string[]; +}): string { + const { + title, + billNumber, + url, + legalStatus, + sourceText, + priorArticle, + readingResearch, + readingSources, + loadedPhrases, + jargonPhrases, + missingEmphasis, + } = args; + + const tense = + legalStatus === "enacted" + ? `This bill is already law. Describe its provisions in the present tense ("requires", "authorizes").` + : `This bill is a proposal that has NOT become law. Every effect must be conditional ("would require", "would authorize"). Never write that it "will" do something.`; + + const retryNote = loadedPhrases?.length + ? `\n\nYour previous attempt used loaded political phrasing in your own voice: ${loadedPhrases + .map((p) => `"${p}"`) + .join( + ", ", + )}. Replace each with the underlying mechanism. Describe what the text does; let the reader judge it.\n` + : ""; + const jargonRetryNote = jargonPhrases?.length + ? `\n\nYour previous attempt left policy jargon unexplained in reader-facing copy: ${jargonPhrases + .map((phrase) => `"${phrase}"`) + .join( + ", ", + )}. Rewrite each in familiar everyday words. If a technical term is truly essential, add it to "terms", define it simply, and still explain the practical meaning where it appears.\n` + : ""; + const emphasisRetryNote = missingEmphasis?.length + ? `\n\nYour previous attempt omitted the required selective emphasis in these fields: ${missingEmphasis + .map((field) => `"${field}"`) + .join( + ", ", + )}. Add one short **bold** span to each named field. Emphasize the concrete mechanism, consequence, unresolved choice, or source value a scanner should retain; never bold the whole field.\n` + : ""; + + return `You are a nonpartisan civic analyst writing a structured brief on a U.S. bill for a general audience. Your reader is a busy adult, not a policy professional. They will scan before they read, so every field must stand alone. + +${tense} + +Your job is to explain the policy, not to promote or attack it. Treat the title, acronym, findings, purpose clauses, and sponsor statements as claims about intent — not proof of results. Base every factual statement on the supplied source text. + +Before filling in the fields, silently identify: +1. The concrete mechanisms: what authority, rule, funding, eligibility, deadline, review, oversight, enforcement, or safeguard is added, removed, weakened, expanded, or transferred. +2. Who gains discretion, money, rights, access, or speed. +3. Who could lose protection, oversight, recourse, funding, or control. +4. What the text does not establish. + +Rules that decide whether this brief ships: + +- **Mechanism over marketing.** Removing or waiving rules, reviews, reporting, or oversight is deregulation or reduced oversight. Say that. Do not hide it behind "cuts red tape", "modernizes", "streamlines", or "speeds up". Equally, do not attach a hostile label the text does not support. +- **Quotes are verbatim.** Every "quote" field must be an exact, unedited span copied character-for-character from the source text below. Do not paraphrase, splice, trim mid-word, or fix grammar. Quotes that do not appear in the source are removed automatically, so a paraphrase in quotation marks just loses you a citation. +- **No invented figures.** A number, date, or dollar amount goes in "facts" only if the source states it. Fewer facts is correct; a plausible-looking invented figure is not. +- **No manufactured symmetry.** If the text supports one consequence more strongly than another, say so. Use "mixed" or "unclear" for an affected group rather than balancing the list for its own sake. +- **Neutral vocabulary.** In your own voice, avoid words that carry a verdict — "common sense", "radical", "landmark", "reckless", "burdensome", "handout", "much-needed". Attribute goals with "aims to" or "supporters say" rather than asserting them. +- **Plain language.** Aim for an 8th-grade reading level everywhere. Prefer familiar verbs and concrete descriptions: "Congress approves the money each year", not "subject to annual appropriations"; "several federal programs", not "discretionary federal grants"; "how long states can plan ahead", not "funding horizon". If a general reader might have to look a term up, either translate it or define it in "terms". Even when defined, explain its practical meaning where it appears. +- **Emphasis is a brief-wide scan aid, not decoration.** Every reader-facing prose field should identify the one phrase a scanner most needs to retain. Use **double asterisks** around one short, concrete phrase in each affected-group "takeaway" and "effect", each "unknowns" item, each term definition, each reading recommendation, the deep-dive preview, and each historical summary or point. "before" and "after" may use up to two short spans; "hook" may use two or three. In long-form deep-dive paragraphs, use one or two only when useful. Never bold a whole sentence, a heading, a verdict, loaded language, or any verbatim source quote. +- **Concise still means coherent.** Every visible field must make sense when read by itself. Never emit a noun phrase, dangling clause, missing subject, or sentence fragment merely to save words. Read each field independently before returning it. + +Field notes: +- "hook" is rendered under the heading "What this means for you" and replaces a grid of disconnected fact tiles. Write one coherent paragraph of 2–3 short sentences. First explain the most consequential practical changes; then state the most important limitation, condition, or uncertainty. Connect the ideas naturally instead of listing figures. Preserve legal status ("would" for proposals), and do not imply every reader is personally affected. Wrap two or three short, concrete phrases in **double asterisks** so a scanner can retain the key changes. Never bold a whole sentence, generic transition, verdict, or loaded language. +- "changes" must contrast current law ("before") with the proposal ("after"). If the source does not establish current law, say that in "before" instead of guessing. Evaluate every change independently for a direct supporting quote; when the official text contains one, include it so every supported card has its own route back to the text. Never invent or stretch a quote merely to make the cards look consistent. +- Each affected-group "takeaway" is the card's always-visible summary. Write one complete standalone sentence that names the group or a clear pronoun and states what would happen. For example: "States would get a **longer window to plan multi-year projects**." Do not return fragments such as "a longer funding horizon" or "depends on final rules." Put qualifications and mechanism detail in "effect". +- "visual" is optional curated artwork. Use "infrastructure-repair" only for physical road or bridge work, "public-transit" only for rail or bus expansion, "data-privacy" only for company collection or use of personal data, and "data-control" only for a person's right to access or delete personal data. Evaluate each change independently and use different relevant visuals across cards when available; never repeat or force an image merely for visual parity. +- "unknowns" is required. Name what the text leaves open — undefined terms, delegated decisions, unfunded pieces, effects the source does not establish. Bold the exact unresolved choice or consequence, not a generic phrase such as "the text does not say." +- "terms" appears near the top of the article. Include only essential vocabulary that changes how the reader understands the mechanism, and define it in one short everyday sentence. Bold the practical meaning, not the term again. +- "whyNotBefore" is an optional expandable answer to "Why wasn't this implemented before?" Use it only when the research documents a real historical answer. Explain earlier attempts, disagreements, legal or budget constraints, implementation tradeoffs, or changed circumstances without speculating about motives. Bold only the documented barrier or tradeoff a scanner should retain. Every point needs at least one citation, the section needs at least two different opened sources overall, and every citation URL must exactly match a verified source below. +- "deepDive" is an optional long-form Billion explainer for readers who deliberately ask for more. It opens as its own article, so write natural markdown with short paragraphs, useful subheads, selective bolding, and bullets only when they clarify a list. Focus on one important question or consequence instead of repeating the entire structured brief. Aim for 500–900 words when the source supports that depth. +- "reading" recommends outside articles. Use ONLY the verified research sources supplied below, copy their URLs exactly, and explain in one sentence what each adds. Bold the specific concept or evidence the source adds. Omit weak or irrelevant links.${retryNote}${jargonRetryNote}${emphasisRetryNote} + +--- +Bill: ${billNumber} — ${title} +Status: ${legalStatus === "enacted" ? "enacted" : "proposed, not yet law"} +Source URL: ${url} +${ + priorArticle + ? `\nPrior nonpartisan analysis of this bill (already vetted for framing — reuse its judgments, but pull all quotes from the official text below):\n${priorArticle.slice(0, 6000)}\n` + : "" +} +${ + readingResearch + ? `\nResearch notes for historical context and optional deeper reading:\n${readingResearch.slice(0, 7000)}\n` + : "" +} +${ + readingSources?.length + ? `\nVerified opened sources ("whyNotBefore" citations and "reading" URLs must exactly match one of these):\n${readingSources + .map((source) => `[${source.id}] ${source.title} — ${source.url}`) + .join("\n")}\n` + : '\nNo verified outside sources were found. Omit "whyNotBefore" and return an empty reading list.\n' +} +Official text: +${sourceText.slice(0, SOURCE_WINDOW)} +--- + +Produce the structured brief now.`; +} + +/** + * Generate a verified, framing-linted brief for a bill. + * + * Returns null when structuring fails outright; throws `AIRateLimitError` so + * the caller's existing rate-limit handling defers the whole item, matching + * `generateDualLens`. + */ +export async function generateBillBrief(args: { + title: string; + billNumber: string; + url: string; + fullText: string; + status?: string | null; + priorArticle?: string | null; +}): Promise | null> { + if (rateLimitHit) throw new AIRateLimitError(); + + const legalStatus = deriveLegalStatus(args.status); + const readingResearch = await researchBillContext( + args.title, + args.billNumber, + args.fullText, + ); + let loadedPhrases: string[] | undefined; + let jargonPhrases: string[] | undefined; + let missingEmphasis: string[] | undefined; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + const { output, usage } = await generateText({ + model: getTextLlm(), + output: Output.object({ schema: BillBriefSchema }), + prompt: buildBriefPrompt({ + title: args.title, + billNumber: args.billNumber, + url: args.url, + legalStatus, + sourceText: args.fullText, + priorArticle: args.priorArticle, + readingResearch: readingResearch.notes, + readingSources: readingResearch.sources, + loadedPhrases, + jargonPhrases, + missingEmphasis, + }), + }); + trackLLMUsage(usage.inputTokens, usage.outputTokens); + + const quoteResult = verifyBriefQuotes(output, args.fullText); + const briefWithReading = verifyBriefReading( + quoteResult.brief, + readingResearch.sources, + ); + const brief = verifyBriefContext( + briefWithReading, + readingResearch.sources, + ); + const { verified, dropped } = quoteResult; + if (dropped > 0) { + logger.warn( + `Brief for ${args.billNumber}: dropped ${dropped} unverified quote(s), kept ${verified}`, + ); + } + + const loaded = findLoadedLanguage(brief); + const jargon = findUnexplainedJargon(brief); + const missing = findMissingEmphasis(brief); + // Retry once with the offending phrases named; on the final attempt keep + // the brief anyway — a slightly colored word is a smaller failure than + // shipping no brief at all, and the warning surfaces it in the logs. + if ( + (loaded.length > 0 || jargon.length > 0 || missing.length > 0) && + attempt < MAX_ATTEMPTS + ) { + logger.warn( + `Brief for ${args.billNumber}: reader-facing copy needs revision (${[...loaded, ...jargon, ...missing].join(", ")}) — regenerating`, + ); + loadedPhrases = loaded; + jargonPhrases = jargon; + missingEmphasis = missing; + continue; + } + if (loaded.length > 0) { + logger.warn( + `Brief for ${args.billNumber}: keeping brief with loaded language ${loaded.join(", ")}`, + ); + } + if (jargon.length > 0) { + logger.warn( + `Brief for ${args.billNumber}: keeping brief with unexplained jargon ${jargon.join(", ")}`, + ); + } + if (missing.length > 0) { + logger.warn( + `Brief for ${args.billNumber}: keeping brief without emphasis in ${missing.join(", ")}`, + ); + } + + logger.success( + `Brief for ${args.billNumber}: ${brief.changes.length} change(s), ${verified} verified quote(s)`, + ); + return { + ...brief, + version: BILL_BRIEF_VERSION, + legalStatus, + verifiedQuotes: verified, + }; + } catch (error) { + if (isRateLimitError(error)) { + setRateLimitHit(true); + throw new AIRateLimitError(); + } + logger.warn( + `Brief structuring failed on attempt ${attempt} for ${args.billNumber}`, + error, + ); + if (attempt === MAX_ATTEMPTS) return null; + } + } + return null; +} diff --git a/apps/scraper/src/utils/ai/court-case-brief.test.ts b/apps/scraper/src/utils/ai/court-case-brief.test.ts new file mode 100644 index 00000000..00b48154 --- /dev/null +++ b/apps/scraper/src/utils/ai/court-case-brief.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { CourtCaseBrief } from "@acme/validators"; + +import { verifyCourtCaseBriefQuotes } from "./court-case-brief.js"; + +const brief: CourtCaseBrief = { + badge: "ARGUED", + hook: "The court is deciding whether police need **a warrant for location records** before reading a person's past movements.", + facts: [], + terms: [], + unknowns: [], + sections: [ + { + title: "The question before the court", + items: [ + { + text: "The case asks whether **the warrant requirement applies**.", + quote: { + text: "The question presented is whether the Fourth Amendment's warrant requirement extends to historical records.", + }, + }, + { + text: "This item has **an invented supporting quote**.", + quote: { + text: "This sentence does not appear in the official source at all.", + }, + }, + ], + }, + { + title: "What could change", + items: [{ text: "The ruling could **change police access rules**." }], + }, + ], +}; + +test("court-case brief verification keeps grounded quotes and drops invented ones", () => { + const result = verifyCourtCaseBriefQuotes( + brief, + "The question presented is whether the Fourth Amendment’s warrant requirement extends to historical records.", + ); + + assert.equal(result.verified, 1); + assert.equal(result.dropped, 1); + assert.ok(result.brief.sections[0]?.items[0]?.quote); + assert.equal(result.brief.sections[0]?.items[1]?.quote, undefined); +}); diff --git a/apps/scraper/src/utils/ai/court-case-brief.ts b/apps/scraper/src/utils/ai/court-case-brief.ts new file mode 100644 index 00000000..c077f47a --- /dev/null +++ b/apps/scraper/src/utils/ai/court-case-brief.ts @@ -0,0 +1,151 @@ +import { APICallError, generateText, Output, RetryError } from "ai"; + +import type { + CourtCaseBrief, + CourtCaseBriefRecord, + NarrativeBriefItem, +} from "@acme/validators"; +import { + COURT_CASE_BRIEF_VERSION, + CourtCaseBriefSchema, +} from "@acme/validators"; + +import { trackLLMUsage } from "../costs.js"; +import { createLogger } from "../log.js"; +import { normalizeForQuoteMatch } from "./bill-brief.js"; +import { getTextLlm } from "./provider.js"; +import { + AIRateLimitError, + rateLimitHit, + setRateLimitHit, +} from "./text-generation.js"; + +const logger = createLogger("court-case-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 verifyCourtCaseBriefQuotes( + brief: CourtCaseBrief, + sourceText: string, +): { brief: CourtCaseBrief; verified: number; dropped: number } { + const source = normalizeForQuoteMatch(sourceText); + let verified = 0; + let dropped = 0; + + const verifyItem = (item: NarrativeBriefItem): NarrativeBriefItem => { + if (!item.quote) return item; + const quote = normalizeForQuoteMatch(item.quote.text); + if (quote.length >= 20 && source.includes(quote)) { + verified++; + return item; + } + dropped++; + const { quote: _quote, ...rest } = item; + return rest; + }; + + return { + brief: { + ...brief, + sections: brief.sections.map((section) => ({ + ...section, + items: section.items.map(verifyItem), + })), + }, + verified, + dropped, + }; +} + +function buildPrompt(args: { + title: string; + court: string; + caseNumber: string; + status?: string | null; + fullText: string; + priorArticle?: string | null; +}): string { + return `You are a nonpartisan legal explainer writing a structured court-case +brief for an average citizen. Use short, coherent sentences and familiar words. +Define unavoidable legal terms. Mark two or three short phrases in the hook, +and one important phrase in every other prose item, with **double asterisks**. +Never bold a whole sentence. + +Preserve the case's posture. If it is pending, describe possible outcomes with +"could" and do not claim the court has decided. If it is decided, distinguish +the holding from arguments made by either side. + +Use sections suited to this particular record, normally: +- "The question before the court" +- "How the case got here" or "What earlier law says" +- "What each outcome would change" for a pending case, or "What the court decided" +- "Who the decision reaches" + +Explain the causal link in every example. Do not merely name an existing law or +precedent; state exactly how the ruling could preserve, expand, narrow, or +replace it. Do not manufacture a controversy, motive, fact, or likely outcome. + +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 questions in "unknowns". + +Case: ${args.caseNumber} — ${args.title} +Court: ${args.court} +Status: ${args.status ?? "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 generateCourtCaseBrief(args: { + title: string; + court: string; + caseNumber: string; + status?: string | null; + fullText: string; + priorArticle?: string | null; +}): Promise | null> { + if (rateLimitHit) throw new AIRateLimitError(); + + try { + const { output, usage } = await generateText({ + model: getTextLlm(), + output: Output.object({ schema: CourtCaseBriefSchema }), + prompt: buildPrompt(args), + }); + trackLLMUsage(usage.inputTokens, usage.outputTokens); + + const verified = verifyCourtCaseBriefQuotes(output, args.fullText); + if (verified.dropped > 0) { + logger.warn( + `Court brief for ${args.caseNumber}: dropped ${verified.dropped} unverified quote(s)`, + ); + } + return { + ...verified.brief, + kind: "court_case", + presentation: "court_case", + version: COURT_CASE_BRIEF_VERSION, + verifiedQuotes: verified.verified, + }; + } catch (error) { + if (isRateLimitError(error)) { + setRateLimitHit(true); + throw new AIRateLimitError(); + } + logger.warn(`Court brief generation failed for ${args.caseNumber}`, error); + return null; + } +} diff --git a/apps/scraper/src/utils/ai/text-generation.ts b/apps/scraper/src/utils/ai/text-generation.ts index 6c6520d6..57f6a837 100644 --- a/apps/scraper/src/utils/ai/text-generation.ts +++ b/apps/scraper/src/utils/ai/text-generation.ts @@ -197,8 +197,15 @@ export async function generateAIArticle( } } +export interface LensExample { + fact: string; + relevance: string; +} + export interface LensPoint { text: string; + /** A documented precedent or observed result that makes the argument concrete. */ + example?: LensExample; /** Ids into DualLens.sources backing this point (may be empty). */ sourceIds: number[]; } @@ -277,13 +284,52 @@ const LensPointTextSchema = z "Lens points must contain a substantive argument, not a placeholder", ); -const DualLensSchema = z.object({ +const LensPointSchema = z.object({ + text: LensPointTextSchema, + example: z.object({ + fact: LensPointTextSchema.describe( + "A documented precedent, policy, case, or measured outcome", + ), + relevance: z + .string() + .trim() + .min(30) + .describe( + "A plain-language explanation of exactly how the fact supports or limits this argument", + ), + }), + sourceIds: z.array(z.number()).min(1), +}); + +/** New generations must ground every argument in a cited concrete example. */ +const GeneratedDualLensSchema = z.object({ + left: z.object({ + stance: z.string().trim().min(3), + points: z.array(LensPointSchema).min(2).max(4), + }), + right: z.object({ + stance: z.string().trim().min(3), + points: z.array(LensPointSchema).min(2).max(4), + }), +}); + +/** Accept older cached rows while the scraper refreshes them to the new shape. */ +const CompatibleDualLensSchema = z.object({ left: z.object({ stance: z.string().trim().min(3), points: z .array( z.object({ text: LensPointTextSchema, + example: z + .union([ + LensPointTextSchema, + z.object({ + fact: LensPointTextSchema, + relevance: z.string().trim().min(12), + }), + ]) + .optional(), sourceIds: z.array(z.number()), }), ) @@ -296,6 +342,15 @@ const DualLensSchema = z.object({ .array( z.object({ text: LensPointTextSchema, + example: z + .union([ + LensPointTextSchema, + z.object({ + fact: LensPointTextSchema, + relevance: z.string().trim().min(12), + }), + ]) + .optional(), sourceIds: z.array(z.number()), }), ) @@ -305,7 +360,7 @@ const DualLensSchema = z.object({ }); export function isUsableDualLens(value: unknown): boolean { - return DualLensSchema.safeParse(value).success; + return CompatibleDualLensSchema.safeParse(value).success; } /** Web-search results surfaced by the AI SDK, as returned by generateText. */ @@ -333,7 +388,8 @@ function numberSources( /** * Well-engineered citations: strip any sourceId the model invented that doesn't * resolve to a real fetched source, so every rendered citation number is backed - * by an actual URL (points are kept even if uncited, preserving the ≥2 shape). + * by an actual URL. The caller rejects the result if that leaves an example + * without a citation. */ function verifyCitations( lens: { left: LensSide; right: LensSide }, @@ -345,6 +401,7 @@ function verifyCitations( stance: side.stance, points: side.points.map((p) => ({ text: p.text, + ...(p.example ? { example: p.example } : {}), sourceIds: [...new Set(p.sourceIds.filter((id) => valid.has(id)))], })), }); @@ -462,12 +519,101 @@ function collectLoopSources(steps: unknown): SdkSource[] { return out; } +/** Return only pages the agent successfully opened, preserving search titles. */ +function collectOpenedLoopSources(steps: unknown): SdkSource[] { + const titles = new Map(); + const opened: string[] = []; + + for (const step of Array.isArray(steps) ? steps : []) { + const results = (step as { toolResults?: unknown }).toolResults; + for (const result of Array.isArray(results) ? results : []) { + const item = result as { + toolName?: string; + output?: { + url?: string; + text?: string; + results?: { title?: string; url?: string }[]; + }; + }; + if (item.toolName === "web_search") { + for (const source of item.output?.results ?? []) { + if (source.url) titles.set(source.url, source.title ?? source.url); + } + } + if ( + item.toolName === "fetch_page" && + item.output?.url && + item.output.text + ) { + opened.push(item.output.url); + } + } + } + + return [...new Set(opened)].map((url) => ({ + sourceType: "url", + url, + title: titles.get(url) ?? url, + })); +} + +export interface BillContextResearch { + notes: string; + sources: DualLensSource[]; +} + +/** + * Research both the bill's historical context and useful next reads. The outer + * model must search and open pages; snippets alone cannot support a claim about + * why an earlier proposal stalled or make a trustworthy reading recommendation. + */ +export async function researchBillContext( + title: string, + billNumber: string, + fullText: string, +): Promise { + try { + const res = await generateText({ + model: getTextLlm(), + tools: { web_search: webResearchTool, fetch_page: fetchPageTool }, + stopWhen: stepCountIs(7), + prompt: `You are researching historical context and useful follow-up reading for an average citizen reading about ${billNumber}, "${title}". + +1. First investigate why this policy has not already been implemented. Look for earlier bills, documented disagreements, legal or budget constraints, implementation tradeoffs, and circumstances that changed. Do not guess at lawmakers' motives. +2. Prefer the Congressional Research Service, GAO, CBO, established newsrooms, universities, and transparent research organizations. Avoid campaign pages, SEO summaries, scraped copies, and sources that merely repeat a press release. +3. Search separately for clear explanatory reporting or authoritative background that helps a reader understand the bill's most important mechanism or uncertainty. +4. Open and read at least three promising results with fetch_page, including at least two that directly support the historical explanation. A search snippet is not enough. +5. Return concise notes in two labeled parts: + - WHY NOT BEFORE: the documented answer, distinguishing established facts from uncertainty and naming which opened URLs support each point. + - FURTHER READING: the two to four best articles, who published each, and what each helps a reader understand. +Do not cite or recommend a page you did not open. + +Official bill excerpt: +${fullText.slice(0, 4000)}`, + }); + trackLLMUsage(res.usage.inputTokens, res.usage.outputTokens); + return { + notes: res.text.trim(), + sources: numberSources(collectOpenedLoopSources(res.steps)), + }; + } catch (error) { + if (isRateLimitError(error)) { + rateLimitHit = true; + throw new AIRateLimitError(); + } + logger.warn(`Bill-context research failed for "${title}"`, error); + return { notes: "", sources: [] }; + } +} + const RESEARCH_PROMPT = (title: string, type: string, text: string) => `You are a nonpartisan civic analyst researching a ${type}. Your framing must stay balanced, but to capture each side's real arguments you should deliberately seek out sources FROM BOTH SIDES. Work step by step and DO NOT write your briefing until you have read primary sources: 1. Use web_search to find both the strongest case FOR and the strongest case AGAINST — including proponents/campaigns/supportive editorials and critics/opponents/critical editorials, alongside official or nonpartisan analyses for the facts. 2. You MUST then use fetch_page to open and read at least TWO of the most relevant results in full (snippets alone are not enough) — at least one supportive and one critical source. -3. Search or fetch again if either side's case is still weak or one-sided. -4. Only once you have read enough, write a concise briefing of the strongest, most specific real-world arguments from BOTH sides, noting which source URLs back each argument. +3. Find documented real-world examples for BOTH sides: an existing law or program, named jurisdiction, earlier bill, court ruling, enforcement action, or measured implementation result. A prediction about what "could" happen is not an example. +4. Test the relevance of every example: it must show the same mechanism, right, cost, or tradeoff as the argument. Merely naming a related law or event is not enough. Record the explicit connection between the example and the argument. +5. Search or fetch again if either side lacks a directly relevant concrete example or is still weak or one-sided. +6. Only once you have read enough, write a concise briefing of the strongest real-world arguments from BOTH sides. Pair every argument with a concrete example, explain why that example supports the argument, and note which source URLs support both. Prioritize credible, verifiable sources over neutrality — a partisan source is fine for capturing that side's argument, as long as it's real. Do not editorialize in your own voice. @@ -485,16 +631,44 @@ const STRUCTURE_PROMPT = ( ) => `You are a nonpartisan civic analyst. Using ONLY the research below, produce balanced perspectives on this ${type}. Each side needs 2 to 4 specific points presenting that side's strongest arguments — do not editorialize. +Write for an average citizen, not a policy expert. Use short, complete sentences +and everyday words. Replace government jargon with what it means in practice: +- Say "Congress would still decide how much money to approve each year," not + "subject to annual appropriations." +- Say "a separate pool of federal money," not "a dedicated grant pathway." +- Say "how the money is divided," not "the allocation formula." +- Say "money promised for ten years," not "a ten-year authorization." +If a technical term is essential, define it in the same sentence. + ${ framing === "left_right" ? `Frame the two sides ideologically: "left" = the progressive/liberal view, "right" = the conservative view. Set left.stance = "Progressive view" and right.stance = "Conservative view".` : `Frame the two sides by support: "left" = proponents/supporters, "right" = opponents/critics. Set left.stance = "Proponents argue" and right.stance = "Opponents counter".` } -For each point, set "sourceIds" to the numbers of the sources (from the Sources list) that directly support it. If a point isn't backed by a listed source, use an empty array. Never cite a source number that isn't in the list. +For each point, set "sourceIds" to the numbers of the sources (from the Sources +list) that directly support both the argument and its example. Omit an +unsupported point instead of using an empty array. Never cite a source number +that isn't in the list. + +Every point must also include an "example" object with TWO distinct fields: +- "fact": one short, complete sentence naming a documented precedent or + observed result. Good facts name a state, country, agency, earlier bill, + court case, company, year, or measured outcome. +- "relevance": one or two complete sentences explaining, in everyday language, + exactly how that fact demonstrates, supports, or limits the argument + immediately above it. Name the shared mechanism, right, cost, omission, or + tradeoff. Do not merely say "this is relevant" or repeat the argument. + +A related fact with no specific relevance explanation is invalid. Do not invent +a scenario. You may compare a documented existing policy with a specific +provision or omission in this proposal, but make both sides of that comparison +explicit. The fact, relevance explanation, and argument must be backed by at +least one listed source, so every sourceIds array must contain a valid source +number. Sources: -${sourceList || "(none found — use empty sourceIds arrays)"} +${sourceList || "(none found — cited concrete examples cannot be generated)"} Research: ${research} @@ -511,7 +685,8 @@ const RESEARCH_MAX_STEPS = 6; * reads sources, and searches again until it can brief both sides. * (2) The text model structures the briefing into schema-validated perspectives with * per-point citations (AI SDK structured output; no manual JSON parsing). - * Falls back to source-text-only structuring if web research is unavailable. + * Returns null if research cannot supply cited concrete examples; the official + * source alone is not enough to invent a precedent. */ export async function generateDualLens( title: string, @@ -561,11 +736,17 @@ export async function generateDualLens( try { const { output, usage } = await generateText({ model: getTextLlm(), - output: Output.object({ schema: DualLensSchema }), + output: Output.object({ schema: GeneratedDualLensSchema }), prompt: STRUCTURE_PROMPT(title, type, framing, grounding, sourceList), }); trackLLMUsage(usage.inputTokens, usage.outputTokens); - return verifyCitations(output, framing, sources); + const verified = verifyCitations(output, framing, sources); + if (!GeneratedDualLensSchema.safeParse(verified).success) { + throw new Error( + "Dual-lens examples must retain at least one verified citation", + ); + } + return verified; } catch (error) { if (isRateLimitError(error)) { rateLimitHit = true; diff --git a/apps/scraper/src/utils/bill-description.test.ts b/apps/scraper/src/utils/bill-description.test.ts index 7e8a30bc..b1624231 100644 --- a/apps/scraper/src/utils/bill-description.test.ts +++ b/apps/scraper/src/utils/bill-description.test.ts @@ -55,6 +55,23 @@ const validLens = { test("isUsableDualLens rejects placeholder arguments", () => { assert.equal(isUsableDualLens(validLens), true); + assert.equal( + isUsableDualLens({ + ...validLens, + left: { + ...validLens.left, + points: validLens.left.points.map((point) => ({ + ...point, + example: { + fact: "California already lets residents request deletion of personal data.", + relevance: + "That existing right shows what the proposal would extend to people in every state.", + }, + })), + }, + }), + true, + ); assert.equal( isUsableDualLens({ ...validLens, diff --git a/apps/scraper/src/utils/db/operations.ts b/apps/scraper/src/utils/db/operations.ts index 51ba91e1..18954345 100644 --- a/apps/scraper/src/utils/db/operations.ts +++ b/apps/scraper/src/utils/db/operations.ts @@ -2,10 +2,12 @@ import { and, eq } from "@acme/db"; import { db } from "@acme/db/client"; import { Bill, + ContentBrief, ContentLens, CourtCase, GovernmentContent, } from "@acme/db/schema"; +import { isCurrentBillBrief, isCurrentCourtCaseBrief } from "@acme/validators"; import type { NewItemLimiter } from "../new-item-limit.js"; import type { @@ -13,6 +15,8 @@ import type { CourtCaseData, GovernmentContentData, } from "../types.js"; +import { generateBillBrief } from "../ai/bill-brief.js"; +import { generateCourtCaseBrief } from "../ai/court-case-brief.js"; import { generateImageSearchKeywords } from "../ai/image-keywords.js"; import { getTextModelVersion } from "../ai/provider.js"; import { @@ -431,6 +435,31 @@ export async function upsertContent( aiGeneratedArticle, ); } + + // Generate and cache the content-specific structured brief. + if (hasUsableText && result?.id && input.type === "bill") { + await upsertBillBrief({ + contentId: result.id, + contentHash: newContentHash, + title, + billNumber: input.data.billNumber, + url, + fullText: fullText!, + status: input.data.status, + priorArticle: aiGeneratedArticle, + }); + } else if (hasUsableText && result?.id && input.type === "court_case") { + await upsertCourtCaseBrief({ + contentId: result.id, + contentHash: newContentHash, + title, + court: input.data.court, + caseNumber: input.data.caseNumber, + fullText: fullText!, + status: input.data.status, + priorArticle: aiGeneratedArticle, + }); + } } catch (error) { if (error instanceof AIRateLimitError) { logger.warn( @@ -485,9 +514,9 @@ export async function upsertContent( /** * Generate (or refresh) the cached dual-lens perspectives for a content item. - * Skips generation when a row already exists for the current contentHash, so - * unchanged content never re-pays for an LLM call. AIRateLimitError propagates - * to the caller's rate-limit handler. + * Skips generation when a row already exists for the current contentHash and + * lens contract version, so unchanged, current content never re-pays for an LLM + * call. AIRateLimitError propagates to the caller's rate-limit handler. */ export async function upsertContentLens( contentId: string, @@ -498,10 +527,12 @@ export async function upsertContentLens( articleType: string, aiGeneratedArticle?: string | null, ): Promise { + const modelVersion = `${getTextModelVersion()}:concrete-examples-v2`; const [existing] = await db .select({ contentHash: ContentLens.contentHash, lensData: ContentLens.lensData, + modelVersion: ContentLens.modelVersion, }) .from(ContentLens) .where( @@ -514,6 +545,7 @@ export async function upsertContentLens( if ( existing?.contentHash === contentHash && + existing.modelVersion === modelVersion && isUsableDualLens(existing.lensData) ) { logger.debug(`Dual-lens already cached for ${contentId}`); @@ -531,7 +563,6 @@ export async function upsertContentLens( return false; } - const modelVersion = getTextModelVersion(); await db .insert(ContentLens) .values({ @@ -562,3 +593,153 @@ export async function upsertContentLens( logger.success(`Cached dual-lens for ${contentId}`); return true; } + +/** + * Generate (or refresh) the cached structured brief for a bill. Skips the LLM + * entirely when a usable row already exists for the current contentHash, so + * unchanged bills never re-pay — same caching contract as `upsertContentLens`. + * AIRateLimitError propagates to the caller's rate-limit handler. + */ +export async function upsertBillBrief(args: { + contentId: string; + contentHash: string; + title: string; + billNumber: string; + url: string; + fullText: string; + status?: string | null; + 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, "bill"), + ), + ) + .limit(1); + + if ( + !forceAIRegeneration && + existing?.contentHash === args.contentHash && + isCurrentBillBrief(existing.brief) + ) { + logger.debug(`Brief already cached for ${args.billNumber}`); + return true; + } + + const generated = await generateBillBrief({ + title: args.title, + billNumber: args.billNumber, + url: args.url, + fullText: args.fullText, + status: args.status, + priorArticle: args.priorArticle, + }); + if (!generated) { + logger.warn(`Brief generation returned null for ${args.billNumber}`); + return false; + } + + const modelVersion = getTextModelVersion(); + const brief = { + ...generated, + generatedAt: new Date().toISOString(), + modelVersion, + }; + await db + .insert(ContentBrief) + .values({ + contentId: args.contentId, + contentType: "bill", + contentHash: args.contentHash, + brief, + modelVersion, + }) + .onConflictDoUpdate({ + target: [ContentBrief.contentType, ContentBrief.contentId], + set: { + contentHash: args.contentHash, + brief, + modelVersion, + updatedAt: new Date(), + }, + }); + + logger.success(`Cached brief for ${args.billNumber}`); + return true; +} + +/** Generate or refresh a cached court-case brief. */ +export async function upsertCourtCaseBrief(args: { + contentId: string; + contentHash: string; + title: string; + court: string; + caseNumber: string; + fullText: string; + status?: string | null; + 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, "court_case"), + ), + ) + .limit(1); + + if ( + !forceAIRegeneration && + existing?.contentHash === args.contentHash && + isCurrentCourtCaseBrief(existing.brief) + ) { + logger.debug(`Court brief already cached for ${args.caseNumber}`); + return true; + } + + const generated = await generateCourtCaseBrief(args); + if (!generated) { + logger.warn(`Court brief generation returned null for ${args.caseNumber}`); + return false; + } + + const modelVersion = `${getTextModelVersion()}:court-case-v1`; + const brief = { + ...generated, + generatedAt: new Date().toISOString(), + modelVersion, + }; + await db + .insert(ContentBrief) + .values({ + contentId: args.contentId, + contentType: "court_case", + contentHash: args.contentHash, + brief, + modelVersion, + }) + .onConflictDoUpdate({ + target: [ContentBrief.contentType, ContentBrief.contentId], + set: { + contentHash: args.contentHash, + brief, + modelVersion, + updatedAt: new Date(), + }, + }); + + logger.success(`Cached court brief for ${args.caseNumber}`); + return true; +} diff --git a/artifacts/pr/article-brief/14-editorial-header.jpg b/artifacts/pr/article-brief/14-editorial-header.jpg new file mode 100644 index 00000000..ecc18f22 Binary files /dev/null and b/artifacts/pr/article-brief/14-editorial-header.jpg differ diff --git a/artifacts/pr/article-brief/15-original-source.jpg b/artifacts/pr/article-brief/15-original-source.jpg new file mode 100644 index 00000000..a1fb6a5c Binary files /dev/null and b/artifacts/pr/article-brief/15-original-source.jpg differ diff --git a/artifacts/pr/article-brief/16-feed-details.jpg b/artifacts/pr/article-brief/16-feed-details.jpg new file mode 100644 index 00000000..321fdbd1 Binary files /dev/null and b/artifacts/pr/article-brief/16-feed-details.jpg differ diff --git a/artifacts/pr/article-brief/17-elections-state.jpg b/artifacts/pr/article-brief/17-elections-state.jpg new file mode 100644 index 00000000..5a7e5490 Binary files /dev/null and b/artifacts/pr/article-brief/17-elections-state.jpg differ diff --git a/artifacts/pr/article-brief/18-settings-state.jpg b/artifacts/pr/article-brief/18-settings-state.jpg new file mode 100644 index 00000000..16ff5e15 Binary files /dev/null and b/artifacts/pr/article-brief/18-settings-state.jpg differ diff --git a/docs/README.md b/docs/README.md index af74cdcc..16378248 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ Start with [CONTRIBUTING.md](../CONTRIBUTING.md) for dev setup. These docs go de | [Ballot-measure enrichment](./measure-enrichment.md) | How measure summaries are cross-validated across official sources, adapter by adapter | | [Candidate enrichment](./candidate-enrichment.md) | The same cross-validation pattern applied to candidate bios/photos/contact info | | [Scraper pipeline](./scraper.md) | The standalone content scraper: sources, change detection, AI generation | +| [Article generation](./article-generation.md) | How a bill becomes a readable brief: the structured schema, quote verification, framing lint | | [Frontend apps](./frontend.md) | Expo mobile app, Next.js web, shared UI, cross-platform auth | ## How to do things diff --git a/docs/article-generation.md b/docs/article-generation.md new file mode 100644 index 00000000..f289dfbd --- /dev/null +++ b/docs/article-generation.md @@ -0,0 +1,182 @@ +# Article Generation + +How a scraped bill becomes something a person will actually read. + +## The problem with the old article + +The original pipeline produced one markdown blob per content item — four `##` +sections (What This Means For You / Overview / Impact & Implications / The +Debate), roughly 700–1000 words, rendered as-is in the app. + +The prose itself was fine. The prompt was careful about nonpartisan framing, +about not treating sponsor claims as results, about naming removed oversight +rather than calling it "streamlining". The problem was structural: + +- **It's a wall.** The reader gets one decision — read all of it, or none of + it. Nothing is scannable, so the scroll bar does the persuading. +- **Everything looks equally important.** A $200B authorization, a definition, + and a hedge about uncertainty all render as body paragraphs. +- **Provenance is prose-shaped.** The article could quote the source, but a + quote is just italic text; nothing checked it was real, and nothing linked it + back to a section. +- **It can't be reused.** The blob can't fill a card, a stat tile, a + notification, or — eventually — a video storyboard. Every downstream surface + has to re-parse English. + +This violates the Bradbury Principle in the most ordinary way: a reader who +bounces off paragraph two leaves with nothing, not even curiosity. + +## The brief + +A **brief** is the same analysis stored as typed pieces instead of prose. The +canonical shape lives in [`@acme/validators`](../packages/validators/src/bill-brief.ts): + +| Field | What it is | Why it's its own field | +| -------------- | ----------------------------------------------- | ---------------------------------------------------------------------------- | +| `hook` | One sentence naming the biggest concrete change | The floor: a reader who reads nothing else still learns one true thing | +| `facts` | ≤4 figures — money, deadlines, scope | Scannable without reading | +| `changes` | ≤5 provisions as `before` → `after` | Forces current law to be stated separately from the proposal | +| `affected` | ≤4 groups with a `direction` | "Who does this land on" is the question people actually have | +| `unknowns` | 1–3 things the text does not settle | Required, so the brief can't read as more settled than the source | +| `terms` | ≤5 glossary entries | Jargon gets defined instead of avoided | +| `whyNotBefore` | Optional cited historical context | Answers the obvious follow-up without guessing at motives | +| `deepDive` | One optional long-form markdown article | Lets an interested reader keep reading without turning the brief into a wall | +| `reading` | ≤4 researched outside articles | Follows the Bradbury Principle with real next steps | + +The reader can stop after the hook, after the tiles, after the changes, or go +all the way into the source text — and every stopping point is coherent. + +### What the types enforce + +Editorial guarantees are encoded in the schema rather than trusted to a prompt: + +- **`kind` is mechanical, never evaluative.** `creates` / `repeals` / `expands` + / `restricts` / `requires` / `waives` / `funds` / `transfers`. A brief can say + what a provision _does_; it has no vocabulary for saying whether that is good. + Adding an evaluative member here would break the guarantee. +- **`before` / `after` splits current law from the change.** That blur is where + accessible summaries usually go wrong — describing a proposal as if it were + already the state of the world. +- **`direction` tops out at `mixed` / `unclear`.** The model can decline to + score a group instead of inventing a counterweight to balance the list. +- **`unknowns` is non-empty.** There is no valid brief that claims to have + settled everything. +- **Legal status is derived, not generated.** `deriveLegalStatus()` reads the + scraped bill status, so whether the UI says a measure _would_ or _does_ change + things comes from a string match rather than an inference. + +### Where the debate lives + +Briefs describe mechanism. They deliberately contain no "supporters say / +critics say" section — that stays in the existing cited **dual-lens** +(`ContentLens`), which does real web research and attaches per-point citations +to sources on both sides. Keeping them separate means the factual layer can't +drift into argument, and the argument layer keeps its own provenance. + +## The pipeline + +`generateBillBrief()` in +[`apps/scraper/src/utils/ai/bill-brief.ts`](../apps/scraper/src/utils/ai/bill-brief.ts) +has a research pass, a structured writing pass, and deterministic verification. + +### 1. Research history and deeper reading (agentic LLM loop) + +The model first investigates why the policy has not already been adopted: +earlier bills, documented disagreements, legal or budget limits, implementation +tradeoffs, and changed circumstances. It separately searches for useful +follow-up reading, opens at least three promising pages, and records only +successfully opened URLs. Snippets are never treated as evidence. + +### 2. Structure (LLM) + +One schema-validated call (AI SDK `Output.object`) grounded in the official +text plus, when available, the existing long-form article. That article already +did the careful nonpartisan reading, so this pass is mostly restructuring — +cheaper and more consistent than reading the statute cold. The model sees a +24k-character window of source text plus the verified research. It may produce +an expandable `whyNotBefore` explanation, but only with citations to opened +pages, and may also write one focused `deepDive` article for readers who opt +into more depth. + +### 3. Verify quotes and research links (deterministic) + +Every `quote.text` is checked against the **whole** source document, not just +the window the model saw. Matching normalizes away formatting — casing, smart +quotes, em dashes, hyphenation across line breaks, and the erratic whitespace +of scraped legislative text — while staying strict about words, so a dropped or +reordered word still fails. + +Unverified quotes are **stripped, and the surrounding claim is kept**. A brief +may end up saying less than the model wrote, but it never puts words in a +bill's mouth. The kept count is stored as `verifiedQuotes`. Outside reading +links are checked against the URLs opened by the research loop; invented links +are dropped. The historical-context section is removed entirely unless at least +two distinct opened sources remain after verification. + +### 4. Lint framing and jargon (deterministic) + +Loaded political vocabulary — `common sense`, `radical`, `landmark`, +`burdensome`, `handout`, `job-killing`, `red tape`, `power grab` — is matched +against the model's **own prose only**. A hit triggers one regeneration with the +offending phrases named; a second hit is logged and shipped, since a single +colored word is a smaller failure than no brief at all. + +Quotes are exempt on purpose. A sponsor is free to call their own bill "common +sense", and reproducing that verbatim is reporting. We just don't say it in our +voice. + +## Storage + +`content_brief`, one row per content item, keyed on `(contentType, contentId)` +and cached against the source's `contentHash` — the same contract as +`content_lens`. Unchanged content never re-pays for an LLM call. Briefs live +outside the content tables so they can be regenerated, versioned, or dropped +without touching scraped rows. + +`BILL_BRIEF_VERSION` gates generation and cache reuse. The scraper reuses only +records that match the current schema, so older rows are regenerated when it +encounters them. The API separately accepts shipped v1 and v5 records and +normalizes them into the current client shape (including affected-group +takeaways and an empty reading list where needed). Invalid or unknown shapes +are still dropped, so the client can treat every present brief as renderable. + +## Rendering + +[`apps/expo/src/components/ui/BillBrief.tsx`](../apps/expo/src/components/ui/BillBrief.tsx) +renders each block as the UI element it actually is: tiles for facts, before → +after rows for changes, a source-linking quote disclosure, a glossary, and a +`Keep reading` layer. Billion's own deep dive opens as a long-form article +sheet; researched outside articles open with their original publishers. + +Two brand constraints shape it: + +- **Nothing color-codes a verdict.** A group that "loses" access is drawn + exactly like one that "gains" it — direction is carried by an arrow and a + word, never by green/red. Coloring outcomes reads as an editorial position, + and red-vs-green sits one step from red-vs-blue. +- **Every claim keeps a path back to the source.** Change cards expose the + verbatim provision behind them; the screen still ends by pointing at the + official record rather than presenting itself as the last word. + +Content without a brief keeps rendering the markdown article, so this is +additive rather than a cutover. + +## Scope + +Bills only, for now. The schema is written around legislative mechanics — +before/after provisions, sponsor-vs-text framing, "would" vs "does" — and +executive actions and court cases each need their own design pass. The +`content_brief` table already carries a `contentType`, so adding one is a +generator and a schema, not a migration. + +## Running it + +```bash +# Backfill bills that have no brief, or whose brief is stale +pnpm --filter @acme/scraper run retroactive-briefs --limit 20 +pnpm --filter @acme/scraper run retroactive-briefs --dry-run +``` + +New and changed bills get briefs automatically as part of the normal scrape +(`upsertBillBrief` in `utils/db/operations.ts`). `pnpm db:seed` writes two +example briefs so the UI is visible locally without an LLM key. diff --git a/docs/scraper.md b/docs/scraper.md index fb55da25..ea26f8ff 100644 --- a/docs/scraper.md +++ b/docs/scraper.md @@ -50,8 +50,9 @@ Each new/changed item runs through: 1. **Summary** (`text-generation.ts`) — ≤100-char punchy summary, 8th-grade reading level. 2. **Article** (`text-generation.ts`) — structured 4-section markdown: _What This Means For You_, _Overview_, _Impact & Implications_, _The Debate_; balanced across perspectives. Stored in `ai_generated_article`. Throws a typed `AIRateLimitError` on 429. -3. **Marketing copy** (`marketing-generation.ts`) — Zod-validated `{ title ≤25 chars, description ≤25 words, imagePrompt }` for the `video` feed card. -4. **Imagery** — multiple sources: +3. **Brief** (`bill-brief.ts`, bills only) — the structured document that replaces the markdown wall of text in the app: hook, stat tiles, before/after changes, affected groups, unknowns, glossary, optional prose. Quotes are verified verbatim against the source and stripped if they don't match; loaded political phrasing in the model's own voice triggers one regeneration. Cached in `content_brief` by `contentHash`. See [Article generation](./article-generation.md). +4. **Marketing copy** (`marketing-generation.ts`) — Zod-validated `{ title ≤25 chars, description ≤25 words, imagePrompt }` for the `video` feed card. +5. **Imagery** — multiple sources: - _Scraped thumbnail_ (preferred, free): source-provided image URL → `thumbnail_url`. - _Generated_: hosted FLUX.2 Klein 9B produces a 1024×1024 image, falling back to the configured local FLUX server at 768×768; `sharp` converts PNG→JPEG (q85); bytes land in the `image_data` `bytea` column. Hosted calls retry with backoff; moderation blocks return `null` silently. - _Stock-photo fallback_: `image-keywords.ts` → Google Custom Search (`GOOGLE_API_KEY` + `GOOGLE_SEARCH_ENGINE_ID`) can supply a thumbnail URL. diff --git a/packages/api/src/router/content.ts b/packages/api/src/router/content.ts index 281205d7..eaf94177 100644 --- a/packages/api/src/router/content.ts +++ b/packages/api/src/router/content.ts @@ -6,12 +6,14 @@ import { clampBillDescription } from "@acme/db/bill-description"; import { db } from "@acme/db/client"; import { Bill, + ContentBrief, ContentLens, CourtCase, GovernmentContent, SavedArticle, Video, } from "@acme/db/schema"; +import { parseContentBriefRecord } from "@acme/validators"; import { toBillTimelineActions } from "../lib/bill-actions"; import { parseBillSponsor, sponsorRole } from "../lib/bill-sponsor"; @@ -119,6 +121,26 @@ async function getLensData( return lens?.lensData ?? null; } +// Look up the cached structured brief for a content item. Rows written by an +// older shipped shapes are normalized here, so the client can treat a present +// brief as renderable while the scraper refreshes stale rows independently. +async function getBrief( + contentId: string, + contentType: "bill" | "government_content" | "court_case", +) { + const [row] = await db + .select({ brief: ContentBrief.brief }) + .from(ContentBrief) + .where( + and( + eq(ContentBrief.contentId, contentId), + eq(ContentBrief.contentType, contentType), + ), + ) + .limit(1); + return row ? parseContentBriefRecord(row.brief) : null; +} + // Helper function to get thumbnail URL for any content export async function getThumbnailForContent( id: string, @@ -599,6 +621,7 @@ export const contentRouter = { actions: toBillTimelineActions(b.actions ?? []), status: b.status ?? undefined, lensData: await getLensData(b.id, "bill"), + brief: await getBrief(b.id, "bill"), }, ]); if (!result) throw new Error(`Failed to decorate bill ${b.id}`); @@ -657,6 +680,7 @@ export const contentRouter = { originalContent: c.fullText ?? "Full text not available", url: c.url, lensData: await getLensData(c.id, "court_case"), + brief: await getBrief(c.id, "court_case"), }, ]); if (!result) throw new Error(`Failed to decorate court case ${c.id}`); diff --git a/packages/db/drizzle/0003_lumpy_nehzno.sql b/packages/db/drizzle/0003_lumpy_nehzno.sql new file mode 100644 index 00000000..df59063c --- /dev/null +++ b/packages/db/drizzle/0003_lumpy_nehzno.sql @@ -0,0 +1,13 @@ +CREATE TABLE "content_brief" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "content_type" varchar(20) NOT NULL, + "content_id" uuid NOT NULL, + "content_hash" varchar(64) NOT NULL, + "brief" jsonb NOT NULL, + "model_version" varchar(50) NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone, + CONSTRAINT "content_brief_contentType_contentId_unique" UNIQUE("content_type","content_id") +); +--> statement-breakpoint +CREATE INDEX "content_brief_content_id_idx" ON "content_brief" USING btree ("content_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0003_snapshot.json b/packages/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..ad25d22c --- /dev/null +++ b/packages/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2801 @@ +{ + "id": "6591d565-863a-424b-97d6-3d5a778479ed", + "prevId": "b688d93f-9380-4e5a-bb01-96507feecaa1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.bill": { + "name": "bill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bill_number": { + "name": "bill_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sponsor": { + "name": "sponsor", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "introduced_date": { + "name": "introduced_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "congress": { + "name": "congress", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "chamber": { + "name": "chamber", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "full_text": { + "name": "full_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated_article": { + "name": "ai_generated_article", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_website": { + "name": "source_website", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "versions": { + "name": "versions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "(\n setweight(to_tsvector('english', coalesce(bill_number, '')), 'A') ||\n setweight(to_tsvector('english', coalesce(title, '')), 'A') ||\n setweight(to_tsvector('english', coalesce(sponsor, '')), 'B') ||\n setweight(to_tsvector('english', coalesce(summary, '') || ' ' || coalesce(description, '')), 'B') ||\n setweight(to_tsvector('english', coalesce(full_text, '')), 'C')\n )", + "type": "stored" + } + } + }, + "indexes": { + "bill_search_vector_idx": { + "name": "bill_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "bill_number_trgm_idx": { + "name": "bill_number_trgm_idx", + "columns": [ + { + "expression": "bill_number", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bill_billNumber_sourceWebsite_unique": { + "name": "bill_billNumber_sourceWebsite_unique", + "nullsNotDistinct": false, + "columns": ["bill_number", "source_website"] + } + }, + "policies": {}, + "checkConstraints": { + "bill_description_max_100_chars": { + "name": "bill_description_max_100_chars", + "value": "\"bill\".\"description\" is null or char_length(\"bill\".\"description\") <= 100" + } + }, + "isRLSEnabled": false + }, + "public.blocked_content": { + "name": "blocked_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocked_content_user_id_idx": { + "name": "blocked_content_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blocked_content_userId_name_type_unique": { + "name": "blocked_content_userId_name_type_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "name", "type"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.candidate": { + "name": "candidate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "contest_id": { + "name": "contest_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "party": { + "name": "party", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "candidate_url": { + "name": "candidate_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "incumbent": { + "name": "incumbent", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "biography": { + "name": "biography", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "candidate_contest_id_idx": { + "name": "candidate_contest_id_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.civic_api_cache": { + "name": "civic_api_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "address_hash": { + "name": "address_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "response_data": { + "name": "response_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "civic_cache_expires_idx": { + "name": "civic_cache_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "civic_api_cache_addressHash_endpoint_params_unique": { + "name": "civic_api_cache_addressHash_endpoint_params_unique", + "nullsNotDistinct": false, + "columns": ["address_hash", "endpoint", "params"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_brief": { + "name": "content_brief", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "content_type": { + "name": "content_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "brief": { + "name": "brief", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "content_brief_content_id_idx": { + "name": "content_brief_content_id_idx", + "columns": [ + { + "expression": "content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "content_brief_contentType_contentId_unique": { + "name": "content_brief_contentType_contentId_unique", + "nullsNotDistinct": false, + "columns": ["content_type", "content_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_lens": { + "name": "content_lens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "content_type": { + "name": "content_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "lens_data": { + "name": "lens_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "content_lens_content_id_idx": { + "name": "content_lens_content_id_idx", + "columns": [ + { + "expression": "content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "content_lens_contentType_contentId_unique": { + "name": "content_lens_contentType_contentId_unique", + "nullsNotDistinct": false, + "columns": ["content_type", "content_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contest": { + "name": "contest", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "election_id": { + "name": "election_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "office": { + "name": "office", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district_name": { + "name": "district_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district_scope": { + "name": "district_scope", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "number_elected": { + "name": "number_elected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "referendum_title": { + "name": "referendum_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referendum_subtitle": { + "name": "referendum_subtitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referendum_text": { + "name": "referendum_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referendum_pro_statement": { + "name": "referendum_pro_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referendum_con_statement": { + "name": "referendum_con_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referendum_url": { + "name": "referendum_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_is_ai_generated": { + "name": "summary_is_ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "fiscal_impact": { + "name": "fiscal_impact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "citations": { + "name": "citations", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contest_election_id_idx": { + "name": "contest_election_id_idx", + "columns": [ + { + "expression": "election_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.court_case": { + "name": "court_case", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_number": { + "name": "case_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "court": { + "name": "court", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "filed_date": { + "name": "filed_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "full_text": { + "name": "full_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated_article": { + "name": "ai_generated_article", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "versions": { + "name": "versions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "(\n setweight(to_tsvector('english', coalesce(case_number, '')), 'A') ||\n setweight(to_tsvector('english', coalesce(title, '')), 'A') ||\n setweight(to_tsvector('english', coalesce(description, '')), 'B') ||\n setweight(to_tsvector('english', coalesce(full_text, '')), 'C')\n )", + "type": "stored" + } + } + }, + "indexes": { + "court_case_search_vector_idx": { + "name": "court_case_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "court_case_number_trgm_idx": { + "name": "court_case_number_trgm_idx", + "columns": [ + { + "expression": "case_number", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "court_case_caseNumber_unique": { + "name": "court_case_caseNumber_unique", + "nullsNotDistinct": false, + "columns": ["case_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.election": { + "name": "election", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "external_id": { + "name": "external_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "election_type": { + "name": "election_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "ocd_division_id": { + "name": "ocd_division_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "deadlines": { + "name": "deadlines", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "election_externalId_source_unique": { + "name": "election_externalId_source_unique", + "nullsNotDistinct": false, + "columns": ["external_id", "source"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.government_content": { + "name": "government_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "published_date": { + "name": "published_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "full_text": { + "name": "full_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated_article": { + "name": "ai_generated_article", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'whitehouse.gov'" + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "versions": { + "name": "versions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "(\n setweight(to_tsvector('english', coalesce(title, '')), 'A') ||\n setweight(to_tsvector('english', coalesce(description, '')), 'B') ||\n setweight(to_tsvector('english', coalesce(full_text, '')), 'C')\n )", + "type": "stored" + } + } + }, + "indexes": { + "government_content_search_vector_idx": { + "name": "government_content_search_vector_idx", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "government_content_url_unique": { + "name": "government_content_url_unique", + "nullsNotDistinct": false, + "columns": ["url"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legistar_agenda_item": { + "name": "legistar_agenda_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "jurisdiction": { + "name": "jurisdiction", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "event_item_id": { + "name": "event_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "agenda_sequence": { + "name": "agenda_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agenda_number": { + "name": "agenda_number", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_name": { + "name": "action_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "passed_flag_name": { + "name": "passed_flag_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "tally": { + "name": "tally", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "mover_name": { + "name": "mover_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "seconder_name": { + "name": "seconder_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "matter_id": { + "name": "matter_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "matter_file": { + "name": "matter_file", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "matter_name": { + "name": "matter_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "matter_type": { + "name": "matter_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "matter_status": { + "name": "matter_status", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "consent": { + "name": "consent", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "agenda_note": { + "name": "agenda_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "minutes_note": { + "name": "minutes_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_modified_utc": { + "name": "last_modified_utc", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "legistar_agenda_item_event_idx": { + "name": "legistar_agenda_item_event_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "legistar_agenda_item_jurisdiction_eventItemId_unique": { + "name": "legistar_agenda_item_jurisdiction_eventItemId_unique", + "nullsNotDistinct": false, + "columns": ["jurisdiction", "event_item_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legistar_body": { + "name": "legistar_body", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "jurisdiction": { + "name": "jurisdiction", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "body_id": { + "name": "body_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body_guid": { + "name": "body_guid", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type_name": { + "name": "type_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "active_flag": { + "name": "active_flag", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "number_of_members": { + "name": "number_of_members", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_name": { + "name": "contact_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "contact_email": { + "name": "contact_email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "contact_phone": { + "name": "contact_phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "legistar_body_jurisdiction_bodyId_unique": { + "name": "legistar_body_jurisdiction_bodyId_unique", + "nullsNotDistinct": false, + "columns": ["jurisdiction", "body_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legistar_matter": { + "name": "legistar_matter", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "jurisdiction": { + "name": "jurisdiction", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "matter_id": { + "name": "matter_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "matter_guid": { + "name": "matter_guid", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "matter_file": { + "name": "matter_file", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type_name": { + "name": "type_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "status_name": { + "name": "status_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "body_name": { + "name": "body_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "body_id": { + "name": "body_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "intro_date": { + "name": "intro_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "agenda_date": { + "name": "agenda_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "passed_date": { + "name": "passed_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enactment_date": { + "name": "enactment_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enactment_number": { + "name": "enactment_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "requester": { + "name": "requester", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_modified_utc": { + "name": "last_modified_utc", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "legistar_matter_file_idx": { + "name": "legistar_matter_file_idx", + "columns": [ + { + "expression": "matter_file", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "legistar_matter_jurisdiction_matterId_unique": { + "name": "legistar_matter_jurisdiction_matterId_unique", + "nullsNotDistinct": false, + "columns": ["jurisdiction", "matter_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legistar_meeting": { + "name": "legistar_meeting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "jurisdiction": { + "name": "jurisdiction", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_guid": { + "name": "event_guid", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "body_id": { + "name": "body_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "body_name": { + "name": "body_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "time": { + "name": "time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agenda_file": { + "name": "agenda_file", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "minutes_file": { + "name": "minutes_file", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "video_path": { + "name": "video_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agenda_status_name": { + "name": "agenda_status_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "minutes_status_name": { + "name": "minutes_status_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_site_url": { + "name": "in_site_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_modified_utc": { + "name": "last_modified_utc", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "legistar_meeting_date_idx": { + "name": "legistar_meeting_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "legistar_meeting_jurisdiction_eventId_unique": { + "name": "legistar_meeting_jurisdiction_eventId_unique", + "nullsNotDistinct": false, + "columns": ["jurisdiction", "event_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.legistar_vote": { + "name": "legistar_vote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "jurisdiction": { + "name": "jurisdiction", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "vote_id": { + "name": "vote_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_item_id": { + "name": "event_item_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "person_id": { + "name": "person_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "person_name": { + "name": "person_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "value_name": { + "name": "value_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_modified_utc": { + "name": "last_modified_utc", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "legistar_vote_event_item_idx": { + "name": "legistar_vote_event_item_idx", + "columns": [ + { + "expression": "event_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "legistar_vote_person_idx": { + "name": "legistar_vote_person_idx", + "columns": [ + { + "expression": "person_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "legistar_vote_jurisdiction_voteId_unique": { + "name": "legistar_vote_jurisdiction_voteId_unique", + "nullsNotDistinct": false, + "columns": ["jurisdiction", "vote_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.polling_location": { + "name": "polling_location", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "election_id": { + "name": "election_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line1": { + "name": "address_line1", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address_line2": { + "name": "address_line2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "zip": { + "name": "zip", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "hours": { + "name": "hours", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "location_type": { + "name": "location_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "voter_services": { + "name": "voter_services", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "start_date": { + "name": "start_date", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "polling_location_election_id_idx": { + "name": "polling_location_election_id_idx", + "columns": [ + { + "expression": "election_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_description": { + "name": "role_description", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'seed'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "role_description_role_level_unique": { + "name": "role_description_role_level_unique", + "nullsNotDistinct": false, + "columns": ["role", "level"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_article": { + "name": "saved_article", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "saved_article_user_id_idx": { + "name": "saved_article_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "saved_article_userId_contentId_unique": { + "name": "saved_article_userId_contentId_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "content_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preference": { + "name": "user_preference", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topics": { + "name": "topics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "content_types": { + "name": "content_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_preference_userId_unique": { + "name": "user_preference_userId_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "personalize": { + "name": "personalize", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "analytics": { + "name": "analytics", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "crash": { + "name": "crash", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "offline": { + "name": "offline", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_settings_userId_unique": { + "name": "user_settings_userId_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.video": { + "name": "video", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "content_type": { + "name": "content_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_data": { + "name": "image_data", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "image_mime_type": { + "name": "image_mime_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "image_width": { + "name": "image_width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_height": { + "name": "image_height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "engagement_metrics": { + "name": "engagement_metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"likes\":0,\"comments\":0,\"shares\":0}'::jsonb" + }, + "source_content_hash": { + "name": "source_content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "video_content_id_idx": { + "name": "video_content_id_idx", + "columns": [ + { + "expression": "content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "video_created_at_idx": { + "name": "video_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "video_contentType_contentId_unique": { + "name": "video_contentType_contentId_unique", + "nullsNotDistinct": false, + "columns": ["content_type", "content_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 28a60ce4..476b7e56 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1784908202601, "tag": "0002_dashing_odin", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1785104176186, + "tag": "0003_lumpy_nehzno", + "breakpoints": true } ] } diff --git a/packages/db/package.json b/packages/db/package.json index bf92733e..56b1baba 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "@acme/env": "workspace:*", + "@acme/validators": "workspace:*", "@vercel/postgres": "^0.10.0", "drizzle-orm": "^0.45.2", "drizzle-zod": "^0.8.3", diff --git a/packages/db/seed.ts b/packages/db/seed.ts index fe0fec78..b400f405 100644 --- a/packages/db/seed.ts +++ b/packages/db/seed.ts @@ -1,8 +1,18 @@ import { createHash } from "node:crypto"; +import { and, eq, inArray, sql } from "drizzle-orm"; + +import type { BillBriefRecord, CourtCaseBriefRecord } from "@acme/validators"; import { clampBillDescription } from "./src/bill-description"; import { db } from "./src/client"; -import { Bill, CourtCase, GovernmentContent, Video } from "./src/schema"; +import { + Bill, + ContentBrief, + ContentLens, + CourtCase, + GovernmentContent, + Video, +} from "./src/schema"; function hash(content: string) { return createHash("sha256").update(content).digest("hex"); @@ -86,7 +96,7 @@ Supporters argue the bill is long overdue, pointing to the American Society of C summary: "Establishes federal data privacy standards requiring companies to obtain consent before collecting personal data.", fullText: - "Be it enacted by the Senate and House of Representatives of the United States of America in Congress assembled, SECTION 1. SHORT TITLE. This Act may be cited as the 'Digital Privacy Protection Act'. SECTION 2. PURPOSE. The purpose of this Act is to establish comprehensive federal data privacy protections...", + "Be it enacted by the Senate and House of Representatives of the United States of America in Congress assembled, SECTION 1. SHORT TITLE. This Act may be cited as the 'Digital Privacy Protection Act'. SECTION 2. PURPOSE. The purpose of this Act is to establish comprehensive federal data privacy protections. SECTION 3. INDIVIDUAL DATA RIGHTS. A covered entity shall provide an individual with the right to access and delete personal data collected about the individual.", aiGeneratedArticle: `# What This Means For You Companies would need your permission before collecting or selling your personal data, and you'd have the right to see and delete what they've gathered. @@ -410,6 +420,524 @@ Free speech advocates are split: some see algorithmic curation as protected expr versions: [], })); +const courtCaseBriefs: Omit< + CourtCaseBriefRecord, + "generatedAt" | "modelVersion" +>[] = [ + { + kind: "court_case", + presentation: "court_case", + version: 1, + verifiedQuotes: 0, + badge: "ARGUED", + hook: "The Supreme Court is deciding when police must get a warrant before reading a person's past phone locations. The ruling could **expand digital privacy protections** or preserve a narrower rule tied to **longer location histories**.", + facts: [ + { label: "Court", value: "U.S. Supreme Court" }, + { label: "Status", value: "Argued" }, + ], + terms: [ + { + term: "Cell-site location data", + plain: + "Records created when a phone connects to towers, which can **reveal where someone traveled**.", + }, + { + term: "Warrant", + plain: + "A judge's permission for a search after police show **a specific reason to believe evidence will be found**.", + }, + ], + sections: [ + { + title: "The question before the court", + items: [ + { + text: "The justices must decide whether the Fourth Amendment requires **a warrant for historical phone-location records** beyond the situation covered by an earlier case.", + }, + ], + }, + { + title: "What earlier law says", + items: [ + { + text: "In Carpenter v. United States, the Court required a warrant for seven days of location history. This case asks **how far that protection extends**.", + }, + ], + }, + { + title: "What each outcome would change", + items: [ + { + text: "A broader ruling could make police obtain warrants for more requests. A narrower ruling could leave **some shorter or differently collected records available under a lower standard**.", + }, + ], + }, + ], + unknowns: [ + "Because the case is pending, **the final boundary for warrantless access is not yet known**.", + ], + }, + { + kind: "court_case", + presentation: "court_case", + version: 1, + verifiedQuotes: 0, + badge: "CERT GRANTED", + hook: "The Supreme Court will decide whether the Education Department had enough authority from Congress to broaden its discrimination rules. The decision could **keep the federal rules in place** or **limit what agencies may decide without a new law**.", + facts: [ + { label: "Court", value: "U.S. Supreme Court" }, + { label: "Status", value: "Review granted" }, + ], + terms: [ + { + term: "Certiorari", + plain: + "The Supreme Court's decision to **hear and review a lower-court case**.", + }, + { + term: "Agency authority", + plain: + "The power Congress gives a federal department to **write and enforce detailed rules**.", + }, + ], + sections: [ + { + title: "The question before the court", + items: [ + { + text: "The case asks whether the Education Department **went beyond the authority Congress gave it** when it expanded the meaning of sex-based discrimination.", + }, + ], + }, + { + title: "How the case got here", + items: [ + { + text: "Lower courts disagreed about the new rules, so schools face **different legal instructions in different parts of the country**.", + }, + ], + }, + { + title: "Who the decision reaches", + items: [ + { + text: "The ruling could change the complaint and compliance rules followed by **public schools, colleges, students, and federal officials**.", + }, + ], + }, + ], + unknowns: [ + "The record does not establish **which parts of the rule, if any, the Court might preserve**.", + ], + }, + { + kind: "court_case", + presentation: "court_case", + version: 1, + verifiedQuotes: 0, + badge: "BRIEFING", + hook: "The Supreme Court is considering whether California can require large social platforms to explain how they recommend content. The ruling could **allow more public disclosure** or treat those choices as **editorial speech protected from the mandate**.", + facts: [ + { label: "Court", value: "U.S. Supreme Court" }, + { label: "Status", value: "Briefing" }, + ], + terms: [ + { + term: "Algorithmic transparency", + plain: + "Rules requiring a platform to **explain the main factors its software uses to rank content**.", + }, + { + term: "Editorial judgment", + plain: + "A publisher's protected choice about **what information to present and how to arrange it**.", + }, + ], + sections: [ + { + title: "The question before the court", + items: [ + { + text: "The justices must decide whether California's disclosure rule regulates business practices or **forces platforms to explain protected editorial choices**.", + }, + ], + }, + { + title: "The competing legal ideas", + items: [ + { + text: "TechCorp compares its recommendations to a newspaper choosing stories. California says the law only requires **factual information about how an existing system works**.", + }, + ], + }, + { + title: "What each outcome would change", + items: [ + { + text: "A ruling for California could support similar disclosure laws. A ruling for TechCorp could make it **harder for states to demand explanations of recommendation systems**.", + }, + ], + }, + ], + unknowns: [ + "The pending case does not settle **how much technical detail a valid disclosure rule could require**.", + ], + }, +]; + +/** + * Structured briefs for the seeded bills, in the same shape the scraper writes. + * Seeded so local contributors can see the brief UI without an LLM key — and so + * the quote-verification contract is visible by example: every `quote.text` + * below appears verbatim in the matching bill's `fullText`, which is exactly + * what `verifyBriefQuotes` enforces in the pipeline. + * + * Indexed to match `bills` above. + */ +const billBriefs: (Omit< + BillBriefRecord, + "generatedAt" | "modelVersion" +> | null)[] = [ + { + version: 7, + legalStatus: "proposed", + verifiedQuotes: 1, + hook: "If this bill becomes law, states would get **a longer window to plan road and bridge repairs**, while cities could apply for **new public-transit money**. The $200 billion is a maximum, not guaranteed money; Congress would still decide how much can actually be spent each year.", + facts: [ + { + label: "Spending limit", + value: "$200B", + note: "Maximum over 10 years; Congress must approve spending each year.", + }, + { label: "Chamber status", value: "Passed House", note: "Senate next." }, + { + label: "How money is shared", + value: "State shares + city grants", + note: "States get set shares; cities apply for separate transit money.", + }, + ], + changes: [ + { + kind: "funds", + title: "Ten years of road and bridge money for states", + before: + "Congress usually approves federal road and transit programs for **only a few years at a time**. That makes long projects harder for states to plan.", + after: + "The bill would promise road and bridge money for **ten years**. Each state's share would depend on its **population, road conditions, and public-transit use**.", + visual: "infrastructure-repair", + quote: { + text: "Congress finds that the nation's infrastructure is in critical need of repair and modernization", + locator: "Sec. 2", + }, + }, + { + kind: "creates", + title: "New money for rail and faster bus service", + before: + "Cities now apply for federal transit money through **several programs that also fund other transportation projects**.", + after: + "The bill would create a **separate pool of money for light rail and faster bus service**. It would also support rural internet projects.", + visual: "public-transit", + }, + ], + affected: [ + { + group: "State transportation departments", + takeaway: + "States would get a **longer planning window** for multi-year projects.", + effect: + "State agencies could plan projects farther ahead because federal road and bridge support would be set for **ten years**. Federal officials would have fewer chances to approve projects one by one.", + direction: "gains", + }, + { + group: "Transit riders in mid-size cities", + takeaway: + "Whether riders benefit would depend on **which cities receive grants**.", + effect: + "New money could pay for transit expansions, but **the final rules would decide which cities can apply and receive it**.", + direction: "unclear", + }, + ], + unknowns: [ + "The bill does not say **how much each factor would count** when dividing road and bridge money among states.", + "Congress would still need to **approve the actual spending each year**, so the full $200 billion is not guaranteed.", + "The bill itself **does not support the job estimates** cited by its sponsors.", + ], + terms: [ + { + term: "Authorization", + plain: + "Congress sets a maximum amount a program may spend. This **does not provide the money by itself**; Congress must approve the actual spending later.", + }, + ], + whyNotBefore: { + summary: + "Congress already funds highways and transit in multi-year laws. The recurring disputes are **how long to promise money, which programs receive it, and how to pay for it**.", + points: [ + { + text: "Recent transportation laws have generally lasted **about five years, not ten**. Several earlier laws expired before Congress agreed on replacements, so lawmakers temporarily extended the old programs while negotiations continued.", + citations: [ + { + title: + "Surface Transportation Reauthorization: Federal Highway Programs", + publisher: "Congressional Research Service", + url: "https://www.congress.gov/crs-product/R48845", + }, + ], + }, + { + text: "A longer promise also raises the question of how to pay for it. **Federal fuel-tax revenue has not kept pace with planned spending**, and Congress has repeatedly transferred other federal money into the Highway Trust Fund.", + citations: [ + { + title: + "Funding and Financing Highways and Public Transportation Under the Infrastructure Investment and Jobs Act", + publisher: "Congressional Research Service", + url: "https://www.congress.gov/crs-product/R47573", + }, + ], + }, + ], + }, + deepDive: { + title: "Why the $200 billion is not guaranteed", + dek: "The bill could set a ten-year plan **without putting the full amount in agencies' bank accounts**.", + body: "## The short answer\n\nThe bill would let Congress spend as much as **$200 billion over ten years** on the programs it creates. That number is a limit, not a deposit. Federal agencies could not start spending the entire amount simply because this bill passed.\n\nCongress would still make a separate spending decision—usually each year—to determine how much money agencies can actually use. It could approve the full amount, a smaller amount, or no money for a particular year.\n\n## Why write a large number into the bill?\n\nA ten-year limit tells states and federal agencies how large Congress expects the program could become. That can help them prepare project lists, hire staff, and plan repairs that take several years.\n\nBut planning certainty is not the same as cash. A future Congress could face different priorities, a recession, an emergency, or a dispute over the federal budget. Any of those could lead lawmakers to approve less money than the bill allows.\n\n## What should readers watch next?\n\nIf this bill moves forward, the next important documents would be the yearly spending bills. Those would show whether Congress is turning the headline promise into money that states and cities can actually use.\n\nThe useful question is not only, **“Did Congress pass the infrastructure bill?”** It is also, **“How much did Congress approve for these programs this year?”**", + }, + reading: [ + { + title: "Overview of the Authorization-Appropriations Process", + publisher: "Congressional Research Service", + url: "https://www.congress.gov/crs-product/RS20371", + whyRead: + "A short, nonpartisan explanation of why **creating a program and paying for it are separate votes**.", + }, + { + title: "Authorizations and the Appropriations Process", + publisher: "Congressional Research Service", + url: "https://www.congress.gov/crs-product/R46497", + whyRead: + "A fuller guide to how Congress **sets spending limits and later approves usable money**.", + }, + ], + }, + { + version: 7, + legalStatus: "proposed", + verifiedQuotes: 2, + hook: "If passed, the bill would require companies to **get permission before collecting or selling personal data**. People across the country could also **review and delete information held about them**, although the text does not settle whether **stronger state privacy laws** would remain in place.", + facts: [{ label: "Chamber status", value: "In Committee" }], + changes: [ + { + kind: "requires", + title: "Companies would need permission before collecting data", + before: + "Different federal rules cover health, financial, and children's data. **Most other personal data has no nationwide permission rule**.", + after: + "Companies would have to **ask before collecting or selling most personal information**.", + visual: "data-privacy", + quote: { + text: "The purpose of this Act is to establish comprehensive federal data privacy protections", + locator: "Sec. 2", + }, + }, + { + kind: "creates", + title: "People could see and delete data held about them", + before: + "A person's ability to see or delete company-held data **depends on the company and their state**.", + after: + "People across the country would gain the **right to review and delete personal data** held about them.", + visual: "data-control", + quote: { + text: "A covered entity shall provide an individual with the right to access and delete personal data collected about the individual", + locator: "Sec. 3", + }, + }, + ], + affected: [ + { + group: "People whose data is collected online", + takeaway: + "People would gain federal rights to **review and delete their data**.", + effect: + "They could **see and delete personal information** that companies hold, and companies would have to ask before collecting it.", + direction: "gains", + }, + { + group: "Companies that buy and sell consumer data", + takeaway: + "Data brokers would have to ask permission and **honor access or deletion requests**.", + effect: + "They would have to **ask permission, explain what they collect, and delete data when required**. Companies built around selling data would face the biggest changes.", + direction: "loses", + }, + { + group: "States with their own privacy laws", + takeaway: + "State protections could **remain or be replaced** by the federal standard.", + effect: + "Residents' protection would depend on whether the federal rules **add to or replace stronger state laws**.", + direction: "unclear", + }, + ], + unknowns: [ + "The excerpt does not say whether **federal rules would replace stronger state privacy laws**.", + "The excerpt does not explain **who would enforce the rules**: a government agency, individuals filing lawsuits, or both.", + ], + terms: [ + { + term: "Preemption", + plain: + "When a federal law **overrides and replaces state laws** on the same subject rather than adding to them.", + }, + ], + whyNotBefore: { + summary: + "Congress has considered nationwide privacy rules for years, but earlier proposals **did not resolve the role of stronger state laws or private lawsuits**.", + points: [ + { + text: "Federal privacy law has mostly covered particular industries and types of data, while states built broader consumer rules. Past proposals **disagreed over whether stronger state laws would remain in force**.", + citations: [ + { + title: "Preemption and Privacy Law", + publisher: "Congressional Research Service", + url: "https://www.congress.gov/crs-product/R48667", + }, + ], + }, + { + text: "Earlier comprehensive bills also **differed over who could enforce them**. Some would have allowed individuals to sue in certain circumstances, while others relied more heavily on government enforcement.", + citations: [ + { + title: "Overview of the American Data Privacy and Protection Act", + publisher: "Congressional Research Service", + url: "https://www.congress.gov/crs-product/LSB10776", + }, + ], + }, + ], + }, + reading: [], + }, +]; + +/** + * The brief explains mechanics; the dual lens preserves the disagreement. + * Keep both in local fixtures so a brief can never make the product's + * signature compare-the-cases layer appear to have vanished. + */ +const billLenses = [ + { + framing: "proponent_opponent" as const, + left: { + stance: "Plan farther ahead", + points: [ + { + text: "Promising money for ten years could help states plan repairs that take several years to finish.", + example: { + fact: "The Interstate Highway System used a multi-year federal funding commitment to support construction across many states.", + relevance: + "That long commitment gave states a stable federal partner for projects that took years to plan and build.", + }, + sourceIds: [1, 2], + }, + { + text: "A separate pool of federal money could help more cities expand rail and faster bus service.", + example: { + fact: "Federal Capital Investment Grants already support light rail and bus rapid transit projects in cities across the country.", + relevance: + "Those projects show that a separate transit grant program can turn federal money into specific local rail and bus expansions.", + }, + sourceIds: [1, 3], + }, + ], + }, + right: { + stance: "Keep spending review frequent", + points: [ + { + text: "The $200 billion is only a limit. Congress would still decide how much money to approve each year.", + example: { + fact: "The current transit grant program separates $3 billion allowed each year from $1.6 billion provided up front.", + relevance: + "The difference shows why a spending limit is not the same as guaranteed money: Congress still has to approve much of it later.", + }, + sourceIds: [1, 3], + }, + { + text: "A ten-year plan gives Congress fewer automatic chances to reconsider how the money is divided.", + example: { + fact: "The 2021 infrastructure law listed transit grant funding for five years, from 2022 through 2026.", + relevance: + "That shorter schedule returned the program to Congress sooner than this proposal's ten-year schedule would.", + }, + sourceIds: [1, 3], + }, + ], + }, + sources: [ + { + id: 1, + title: "Infrastructure Modernization Act of 2025 — official text", + url: bills[0]!.url, + }, + { + id: 2, + title: "Interstate System funding history — FHWA", + url: "https://www.fhwa.dot.gov/highwayhistory/data/page01.cfm", + }, + { + id: 3, + title: "Capital Investment Grants program — FTA", + url: "https://www.transit.dot.gov/funding/grants/fact-sheet-capital-investment-grants-program", + }, + ], + }, + { + framing: "proponent_opponent" as const, + left: { + stance: "Create one national privacy floor", + points: [ + { + text: "A national rule would give people in every state the same basic rights to view and delete their data.", + example: { + fact: "California residents can already ask businesses to show or delete personal information collected about them.", + relevance: + "California demonstrates how these rights work in practice; this bill would extend similar access and deletion rights nationwide.", + }, + sourceIds: [1, 2], + }, + ], + }, + right: { + stance: "Preserve stronger state rules", + points: [ + { + text: "The excerpt does not guarantee that stronger state privacy rights would stay in place.", + example: { + fact: "California lets residents limit businesses' use of precise location and genetic data.", + relevance: + "The proposal excerpt creates nationwide access and deletion rights but does not expressly preserve California's additional limit-use right. That specific gap is why opponents worry state protections could be reduced.", + }, + sourceIds: [1, 2], + }, + ], + }, + sources: [ + { + id: 1, + title: "Digital Privacy Protection Act — official text", + url: bills[1]!.url, + }, + { + id: 2, + title: "California Consumer Privacy Act — consumer rights", + url: "https://oag.ca.gov/privacy/ccpa", + }, + ], + }, +]; + async function seed() { assertSeedTargetIsLocal(); @@ -427,6 +955,108 @@ async function seed() { }); console.log(` ${insertedBills.length} bills inserted`); + // Resolve every fixture after the insert instead of relying on RETURNING. + // RETURNING only includes newly inserted rows, which meant re-running the + // seed against an existing local database never added or refreshed briefs. + const seededBills = await db + .select({ + id: Bill.id, + billNumber: Bill.billNumber, + contentHash: Bill.contentHash, + }) + .from(Bill) + .where( + and( + eq(Bill.sourceWebsite, "congress.gov"), + inArray( + Bill.billNumber, + bills.map((bill) => bill.billNumber), + ), + ), + ); + + console.log("Inserting bill briefs..."); + const briefRecords = seededBills.flatMap((bill) => { + const fixtureIndex = bills.findIndex( + (fixture) => fixture.billNumber === bill.billNumber, + ); + const brief = billBriefs[fixtureIndex]; + return brief + ? [ + { + contentType: "bill" as const, + contentId: bill.id, + contentHash: bill.contentHash, + brief: { + ...brief, + generatedAt: now.toISOString(), + modelVersion: "seed", + }, + modelVersion: "seed", + }, + ] + : []; + }); + if (briefRecords.length === 0) { + console.log(" 0 briefs inserted (no new bills to link)"); + } else { + const insertedBriefs = await db + .insert(ContentBrief) + .values(briefRecords) + .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(` ${insertedBriefs.length} briefs inserted or refreshed`); + } + + console.log("Inserting bill dual lenses..."); + const lensRecords = seededBills.flatMap((bill) => { + const fixtureIndex = bills.findIndex( + (fixture) => fixture.billNumber === bill.billNumber, + ); + const lensData = billLenses[fixtureIndex]; + return lensData + ? [ + { + contentType: "bill" as const, + contentId: bill.id, + contentHash: bill.contentHash, + lensData: { + ...lensData, + generatedAt: now.toISOString(), + modelVersion: "seed", + }, + modelVersion: "seed", + }, + ] + : []; + }); + if (lensRecords.length === 0) { + console.log(" 0 dual lenses inserted (no seeded bills to link)"); + } else { + const insertedLenses = await db + .insert(ContentLens) + .values(lensRecords) + .onConflictDoUpdate({ + target: [ContentLens.contentType, ContentLens.contentId], + set: { + contentHash: sql`excluded.content_hash`, + lensData: sql`excluded.lens_data`, + modelVersion: sql`excluded.model_version`, + updatedAt: now, + }, + }) + .returning({ id: ContentLens.id }); + console.log(` ${insertedLenses.length} dual lenses inserted or refreshed`); + } + console.log("Inserting government content..."); const insertedGov = await db .insert(GovernmentContent) @@ -451,6 +1081,59 @@ async function seed() { }); console.log(` ${insertedCases.length} court cases inserted`); + const seededCases = await db + .select({ + id: CourtCase.id, + caseNumber: CourtCase.caseNumber, + contentHash: CourtCase.contentHash, + }) + .from(CourtCase) + .where( + inArray( + CourtCase.caseNumber, + courtCases.map((courtCase) => courtCase.caseNumber), + ), + ); + + console.log("Inserting court case briefs..."); + const courtBriefRecords = seededCases.flatMap((courtCase) => { + const fixtureIndex = courtCases.findIndex( + (fixture) => fixture.caseNumber === courtCase.caseNumber, + ); + const brief = courtCaseBriefs[fixtureIndex]; + return brief + ? [ + { + contentType: "court_case" as const, + contentId: courtCase.id, + contentHash: courtCase.contentHash, + brief: { + ...brief, + generatedAt: now.toISOString(), + modelVersion: "seed", + }, + modelVersion: "seed", + }, + ] + : []; + }); + const insertedCourtBriefs = await db + .insert(ContentBrief) + .values(courtBriefRecords) + .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( + ` ${insertedCourtBriefs.length} court briefs inserted or refreshed`, + ); + console.log("Inserting videos (feed items)..."); const videoRecords = [ ...insertedBills.map((b, i) => ({ diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 1c5d5de8..80429825 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -4,6 +4,8 @@ import { check, customType, index, pgTable, unique } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod/v4"; +import type { StoredContentBriefRecord } from "@acme/validators"; + // Custom bytea type for binary data storage const bytea = customType<{ data: Buffer; notNull: false; default: false }>({ dataType() { @@ -688,11 +690,19 @@ export const ContentLens = pgTable( framing?: "proponent_opponent" | "left_right"; left: { stance: string; - points: { text: string; sourceIds: number[] }[]; + points: { + text: string; + example?: string | { fact: string; relevance: string }; + sourceIds: number[]; + }[]; }; right: { stance: string; - points: { text: string; sourceIds: number[] }[]; + points: { + text: string; + example?: string | { fact: string; relevance: string }; + sourceIds: number[]; + }[]; }; sources: { id: number; title: string; url: string }[]; generatedAt: string; @@ -711,4 +721,31 @@ export const ContentLens = pgTable( }), ); +/** + * Structured article briefs — one row per content item, cached the same way as + * ContentLens (regenerated when `contentHash` moves). Kept out of the content + * tables so a brief can be regenerated, versioned, or dropped without touching + * scraped source rows, and so the three content types can adopt it one at a + * time. Only bills are generated today. + */ +export const ContentBrief = pgTable( + "content_brief", + (t) => ({ + id: t.uuid().notNull().primaryKey().defaultRandom(), + contentType: t.varchar({ length: 20 }).notNull(), // "bill" | "government_content" | "court_case" + contentId: t.uuid().notNull(), + contentHash: t.varchar({ length: 64 }).notNull(), + brief: t.jsonb().$type().notNull(), + modelVersion: t.varchar({ length: 50 }).notNull(), + createdAt: t.timestamp().defaultNow().notNull(), + updatedAt: t + .timestamp({ mode: "date", withTimezone: true }) + .$onUpdateFn(() => sql`now()`), + }), + (table) => ({ + uniqueContentBrief: unique().on(table.contentType, table.contentId), + contentIdIndex: index("content_brief_content_id_idx").on(table.contentId), + }), +); + export * from "./auth-schema"; diff --git a/packages/validators/src/bill-brief.ts b/packages/validators/src/bill-brief.ts new file mode 100644 index 00000000..62f35bd5 --- /dev/null +++ b/packages/validators/src/bill-brief.ts @@ -0,0 +1,496 @@ +/** + * Bill Brief — the structured replacement for the markdown "wall of text" + * article. + * + * The old pipeline emitted one long markdown blob with four `##` sections. It + * read like a policy memo: correct, sourced, and almost nobody finished it. + * A brief keeps every editorial guarantee (nonpartisan framing, no invented + * facts, provenance back to the original text) but stores the analysis as + * *typed pieces* instead of prose, so the client can render each piece as the + * UI element it actually is — stat tiles, before/after rows, affected-group + * cards, a collapsed glossary — and the reader can stop at any depth. + * + * Editorial rules encoded in the types rather than trusted to a prompt: + * + * - `kind` on a change is a *mechanical* verb (creates/repeals/funds/…), never + * an evaluative one. A brief cannot say a change is good or bad; it can only + * say what the change does. + * - `before`/`after` forces the model to state current law separately from the + * proposed change, which is where accessible summaries usually blur. + * - `quote` carries verbatim source text plus a locator, so every claim can be + * traced into the bill. Quotes are verified against the source before + * storage — see `verifyBriefQuotes` in the scraper. + * - `unknowns` is a required escape valve: the model is expected to name what + * the text does not settle instead of filling the gap. + * - `direction` on an affected group tops out at "mixed"/"unclear", so the + * model can decline to score a group rather than manufacturing symmetry. + * + * Argument-level "both sides" framing deliberately lives elsewhere, in the + * existing cited dual-lens (`ContentLens`). A brief describes the mechanism; + * the lens carries the debate. + */ +import { z } from "zod"; + +/** Bump when the shape or generation contract requires cached rows to refresh. */ +export const BILL_BRIEF_VERSION = 7; + +/** + * What a provision mechanically does. Deliberately descriptive: a reader can + * disagree with a "restricts" and still agree it is a restriction. Adding an + * evaluative member here (e.g. "improves") would break the guarantee. + */ +export const BriefChangeKindSchema = z.enum([ + "creates", + "repeals", + "expands", + "restricts", + "requires", + "waives", + "funds", + "transfers", +]); +export type BriefChangeKind = z.infer; + +/** Human label for a change kind, for badges. */ +export const CHANGE_KIND_LABEL: Record = { + creates: "Creates", + repeals: "Repeals", + expands: "Expands", + restricts: "Restricts", + requires: "Requires", + waives: "Waives", + funds: "Funds", + transfers: "Transfers", +}; + +/** + * A verbatim excerpt from the source document. `text` must appear in the + * source — the generator drops quotes that fail verification rather than + * shipping a plausible-looking paraphrase in quotation marks. + */ +export const BriefQuoteSchema = z.object({ + text: z + .string() + .trim() + .min(20) + .max(400) + .describe( + "A verbatim, unedited span copied from the source text. Never paraphrase, reorder, or fix grammar inside a quote.", + ), + locator: z + .string() + .trim() + .max(120) + .optional() + .describe( + 'Where the quote appears, as written in the document — e.g. "Sec. 4(b)(2)" or "Title II". Omit if the source has no usable label.', + ), +}); +export type BriefQuote = z.infer; + +/** + * One tile in the scannable header row: a number, date, or scope the reader + * would want before deciding whether to keep reading. + */ +export const BriefFactSchema = z.object({ + label: z + .string() + .trim() + .min(2) + .max(28) + .describe('What the figure is — e.g. "Authorized funding", "Deadline".'), + value: z + .string() + .trim() + .min(1) + .max(28) + .describe( + 'The figure itself, formatted for a tile — e.g. "$1.2B", "Jan 1, 2027", "38 states".', + ), + note: z + .string() + .trim() + .max(90) + .optional() + .describe("One short clause of context. Omit rather than padding."), + quote: BriefQuoteSchema.optional().describe( + "The source span the figure was read from.", + ), +}); +export type BriefFact = z.infer; + +/** + * Curated editorial artwork bundled by the client. The generator may select + * one only when the mechanism clearly matches; omitting a visual is always + * preferable to decorative or misleading imagery. + */ +export const BriefVisualSchema = z.enum([ + "infrastructure-repair", + "public-transit", + "data-privacy", + "data-control", +]); +export type BriefVisual = z.infer; + +/** A single concrete policy change, stated as current law → proposed law. */ +export const BriefChangeSchema = z.object({ + kind: BriefChangeKindSchema.describe( + "The mechanical action. Pick the verb the text supports, not the one the sponsor prefers.", + ), + title: z + .string() + .trim() + .min(8) + .max(70) + .describe( + "Everyday-language name for this change. Describe what a person would recognize, not the legislative mechanism; for example, 'Ten years of road money for states', not 'Formula funding authorization'.", + ), + before: z + .string() + .trim() + .min(10) + .max(240) + .describe( + "What happens today, using words a general reader already knows. Translate legislative and agency terminology rather than shortening it. If the source does not establish current law, say so plainly. At most two short **bold** spans may mark the phrases a scanner should retain.", + ), + after: z + .string() + .trim() + .min(10) + .max(240) + .describe( + "What would happen under this measure, in concrete everyday language. Explain who acts, what they do, and what changes; avoid unexplained terms such as authorization, appropriation, discretionary grant, allocation formula, and funding horizon. Preserve legal status. At most two short **bold** spans may mark the phrases a scanner should retain.", + ), + visual: BriefVisualSchema.optional().describe( + "Optional curated editorial visual. Use infrastructure-repair for physical road or bridge work, public-transit for rail or bus expansion, data-privacy for company collection or use of personal data, data-control for a person's right to access or delete personal data, and otherwise omit.", + ), + quote: BriefQuoteSchema.optional().describe( + "The exact provision this change is drawn from. Include it whenever the source contains a direct supporting span; evaluate every change rather than citing only the first card.", + ), +}); +export type BriefChange = z.infer; + +/** + * Who is on the receiving end. `direction` describes the flow of money, power, + * access, or obligation — not whether that flow is desirable. + */ +export const BriefAffectedSchema = z.object({ + group: z + .string() + .trim() + .min(3) + .max(52) + .describe( + 'A specific group — "Medicare Part D enrollees", not "the American people".', + ), + takeaway: z + .string() + .trim() + .min(24) + .max(140) + .describe( + "A complete, standalone sentence summarizing the concrete effect for this group. It must name the subject and action, make sense without surrounding text, and never be a noun phrase or dangling clause. Mark one short, concrete phrase with **double asterisks**.", + ), + effect: z + .string() + .trim() + .min(12) + .max(220) + .describe( + "Context explaining what concretely changes for this group, in one or two coherent sentences. One short **bold** span may mark the concrete consequence a scanner should retain, but the UI does not use that span as a headline.", + ), + direction: z + .enum(["gains", "loses", "mixed", "unclear"]) + .describe( + "Whether this group gains or loses money, access, discretion, protection, or obligations. Use 'mixed' when both happen and 'unclear' when the text does not settle it — never guess to balance the list.", + ), +}); +export type BriefAffected = z.infer; + +/** A term the reader would otherwise have to look up. */ +export const BriefTermSchema = z.object({ + term: z.string().trim().min(2).max(60), + plain: z + .string() + .trim() + .min(15) + .max(220) + .describe( + "A one-sentence definition in everyday words. Mark the practical meaning the reader should retain with one short **bold** span.", + ), +}); +export type BriefTerm = z.infer; + +/** A real article the research loop found and opened before recommending it. */ +export const BriefReadingSchema = z.object({ + title: z.string().trim().min(8).max(140), + publisher: z.string().trim().min(2).max(70), + url: z.url(), + whyRead: z + .string() + .trim() + .min(20) + .max(180) + .describe( + "One plain-language sentence explaining what this article helps the reader understand. Mark its specific added value with one short **bold** span.", + ), +}); +export type BriefReading = z.infer; + +/** One opened research source attached directly to a historical-context claim. */ +export const BriefContextCitationSchema = z.object({ + title: z.string().trim().min(8).max(140), + publisher: z.string().trim().min(2).max(70), + url: z.url(), +}); +export type BriefContextCitation = z.infer; + +/** A plain-language claim about why this policy has not already happened. */ +export const BriefContextPointSchema = z.object({ + text: z + .string() + .trim() + .min(40) + .max(420) + .describe( + "A coherent, neutral explanation of one documented barrier, tradeoff, or earlier attempt. Do not speculate about motives. Mark one or two short, factual phrases with **double asterisks**.", + ), + citations: z + .array(BriefContextCitationSchema) + .min(1) + .max(3) + .describe( + "Opened research pages that directly support this point. Copy each verified URL exactly.", + ), +}); +export type BriefContextPoint = z.infer; + +/** Optional, cited historical context shown as an expandable detail. */ +export const BriefContextSchema = z.object({ + summary: z + .string() + .trim() + .min(40) + .max(220) + .describe( + "A one- or two-sentence preview of the main reason this proposal was not already adopted. Mark one short statement of the central barrier with **double asterisks**.", + ), + points: z + .array(BriefContextPointSchema) + .min(1) + .max(4) + .describe( + "Documented earlier attempts, disagreements, constraints, or changed circumstances. The section is omitted unless at least two opened sources support it.", + ), +}); +export type BriefContext = z.infer; + +/** Billion's optional long-form explainer for readers who choose more depth. */ +export const BriefDeepDiveSchema = z.object({ + title: z.string().trim().min(8).max(90), + dek: z + .string() + .trim() + .min(30) + .max(220) + .describe( + "A plain-language preview of what the reader will learn. Mark its central question or insight with one short **bold** span.", + ), + body: z + .string() + .trim() + .min(350) + .max(5000) + .describe( + "A readable markdown article with short paragraphs, useful subheads, selective bolding, and bullets only where they clarify a list. It may focus on one important question rather than repeat the whole bill brief.", + ), +}); +export type BriefDeepDive = z.infer; + +/** + * The model-authored portion of a brief. Everything derivable without an LLM + * (legal status, timestamps, model version) is added by the pipeline and lives + * on `BillBriefRecordSchema` instead, so the model is never asked for a fact we + * already know. + */ +export const BillBriefSchema = z.object({ + hook: z + .string() + .trim() + .min(60) + .max(420) + .describe( + "A coherent 2–3 sentence 'What this means for you' paragraph. Explain the bill's most consequential concrete changes and the most important limitation or uncertainty in plain language. It must stand alone, not read like a list of facts, and preserve proposed-versus-enacted status. Mark two or three short, concrete phrases with **double asterisks** so scanners can retain the key changes; never bold a whole sentence.", + ), + facts: z + .array(BriefFactSchema) + .max(4) + .describe( + "Up to four scannable figures. Include only what the source states; an empty list is better than an invented number.", + ), + changes: z + .array(BriefChangeSchema) + .min(1) + .max(5) + .describe( + "The most consequential provisions, most significant first. Include changes that remove reviews, oversight, reporting, or eligibility — do not fold them into a positive-sounding summary.", + ), + affected: z + .array(BriefAffectedSchema) + .min(1) + .max(4) + .describe("Specific groups on the receiving end of those changes."), + unknowns: z + .array( + z + .string() + .trim() + .min(15) + .max(220) + .describe( + "One open question, stated plainly. Mark the unresolved decision or consequence with one short **bold** span.", + ), + ) + .min(1) + .max(3) + .describe( + "What the text does not settle: unfunded pieces, undefined terms, delegated decisions, effects the source does not establish.", + ), + terms: z + .array(BriefTermSchema) + .max(5) + .describe("Jargon a general reader would stumble on."), + whyNotBefore: BriefContextSchema.optional().describe( + "Optional cited historical context answering why this policy was not already implemented. Use only the supplied opened research sources; omit it when the research does not establish a clear answer.", + ), + deepDive: BriefDeepDiveSchema.optional().describe( + "One optional Billion explainer for a reader who wants more depth. Focus on the most important unresolved concept or consequence instead of repeating the entire brief.", + ), + reading: z + .array(BriefReadingSchema) + .max(4) + .describe( + "Optional outside articles discovered and opened by the research loop. Recommend only sources from the supplied verified reading list and copy each URL exactly.", + ), +}); +export type BillBrief = z.infer; + +/** + * Legal status is derived from the scraped bill status, never asked of the + * model — it decides whether the UI says a measure "would" or "does" change + * things, and getting it from a string match is both cheaper and correct. + */ +export const BriefLegalStatusSchema = z.enum(["proposed", "enacted"]); +export type BriefLegalStatus = z.infer; + +const BriefRecordMetadataSchema = { + legalStatus: BriefLegalStatusSchema, + /** Count of quotes that matched the source text verbatim, after verification. */ + verifiedQuotes: z.number().int().min(0), + generatedAt: z.string(), + modelVersion: z.string(), +}; + +/** A stored brief: model output plus pipeline-owned provenance. */ +export const BillBriefRecordSchema = BillBriefSchema.extend({ + version: z.literal(BILL_BRIEF_VERSION), + ...BriefRecordMetadataSchema, +}); +export type BillBriefRecord = z.infer; + +/** The rich brief shape before emphasis became a brief-wide contract. */ +const BillBriefV6RecordSchema = BillBriefSchema.extend({ + version: z.literal(6), + ...BriefRecordMetadataSchema, +}); + +/** The immediately preceding rich-brief shape, before cited history was added. */ +const BillBriefV5RecordSchema = BillBriefSchema.extend({ + version: z.literal(5), + ...BriefRecordMetadataSchema, +}); + +/** + * The first shipped brief shape. It had optional long-form `sections`, no + * affected-group takeaway, and no researched reading/history layer. + */ +const BillBriefV1RecordSchema = z.object({ + version: z.literal(1), + hook: z.string().trim().min(24).max(420), + facts: z.array(BriefFactSchema).max(4), + changes: z.array(BriefChangeSchema).min(1).max(5), + affected: z + .array( + z.object({ + group: z.string().trim().min(3).max(52), + effect: z.string().trim().min(12).max(220), + direction: z.enum(["gains", "loses", "mixed", "unclear"]), + }), + ) + .min(1) + .max(4), + unknowns: z.array(z.string().trim().min(15).max(220)).min(1).max(3), + terms: z.array(BriefTermSchema).max(5), + sections: z + .array( + z.object({ + heading: z.string().trim().min(3).max(60), + body: z.string().trim().min(120).max(2200), + }), + ) + .max(3), + ...BriefRecordMetadataSchema, +}); + +function conciseTakeaway(effect: string): string { + const firstSentence = effect.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim(); + const candidate = firstSentence || effect; + if (candidate.length <= 140) return candidate; + return `${candidate.slice(0, 136).replace(/\s+\S*$/, "")}…`; +} + +/** + * Parse any brief shape the app has shipped and return the current client + * shape. Normalizing at the API boundary keeps old cached rows renderable while + * the scraper independently decides whether they should be regenerated. + */ +export function parseBillBriefRecord(value: unknown): BillBriefRecord | null { + const current = BillBriefRecordSchema.safeParse(value); + if (current.success) return current.data; + + const v6 = BillBriefV6RecordSchema.safeParse(value); + if (v6.success) { + return { ...v6.data, version: BILL_BRIEF_VERSION }; + } + + const v5 = BillBriefV5RecordSchema.safeParse(value); + if (v5.success) { + return { ...v5.data, version: BILL_BRIEF_VERSION }; + } + + const v1 = BillBriefV1RecordSchema.safeParse(value); + if (!v1.success) return null; + const { sections: _legacySections, ...legacy } = v1.data; + return { + ...legacy, + version: BILL_BRIEF_VERSION, + affected: legacy.affected.map((item) => ({ + ...item, + takeaway: conciseTakeaway(item.effect), + })), + reading: [], + }; +} + +/** + * Whether a stored brief is renderable by the current client, including a + * shape that can be normalized from an older shipped schema. + */ +export function isUsableBillBrief(value: unknown): boolean { + return parseBillBriefRecord(value) !== null; +} + +/** Whether the scraper can reuse a cached row without regenerating it. */ +export function isCurrentBillBrief(value: unknown): boolean { + return BillBriefRecordSchema.safeParse(value).success; +} diff --git a/packages/validators/src/content-brief.ts b/packages/validators/src/content-brief.ts new file mode 100644 index 00000000..805f62b3 --- /dev/null +++ b/packages/validators/src/content-brief.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; + +import { + BillBriefRecordSchema, + BriefFactSchema, + BriefQuoteSchema, + BriefTermSchema, + parseBillBriefRecord, +} from "./bill-brief"; + +export const COURT_CASE_BRIEF_VERSION = 1; + +export const NarrativeBriefItemSchema = z.object({ + text: z + .string() + .trim() + .min(20) + .max(360) + .describe( + "A complete plain-language sentence or short paragraph. Mark one or two concrete phrases with **double asterisks**.", + ), + quote: BriefQuoteSchema.optional().describe( + "An exact supporting passage from the official source.", + ), +}); +export type NarrativeBriefItem = z.infer; + +export const NarrativeBriefSectionSchema = z.object({ + title: z.string().trim().min(4).max(64), + items: z.array(NarrativeBriefItemSchema).min(1).max(4), +}); +export type NarrativeBriefSection = z.infer; + +export const CourtCaseBriefSchema = z.object({ + badge: z.string().trim().min(3).max(24), + hook: z + .string() + .trim() + .min(60) + .max(420) + .describe( + "A coherent 2–3 sentence explanation of the case's practical importance. Preserve pending-versus-decided status and bold two or three short key phrases.", + ), + facts: z.array(BriefFactSchema).max(4), + sections: z.array(NarrativeBriefSectionSchema).min(2).max(5), + terms: z.array(BriefTermSchema).max(5), + unknowns: z + .array( + z + .string() + .trim() + .min(20) + .max(240) + .describe( + "A complete sentence explaining something the record or pending case does not settle. Bold one short key phrase.", + ), + ) + .max(3), +}); +export type CourtCaseBrief = z.infer; + +export const CourtCaseBriefRecordSchema = CourtCaseBriefSchema.extend({ + kind: z.literal("court_case"), + presentation: z.literal("court_case"), + version: z.literal(COURT_CASE_BRIEF_VERSION), + verifiedQuotes: z.number().int().min(0), + generatedAt: z.string(), + modelVersion: z.string(), +}); +export type CourtCaseBriefRecord = z.infer; + +export const StoredContentBriefRecordSchema = z.union([ + BillBriefRecordSchema, + CourtCaseBriefRecordSchema, +]); +export type StoredContentBriefRecord = z.infer< + typeof StoredContentBriefRecordSchema +>; + +export type ContentBriefRecord = + | ({ kind: "bill" } & z.infer) + | CourtCaseBriefRecord; + +export function parseContentBriefRecord( + value: unknown, +): ContentBriefRecord | null { + const bill = parseBillBriefRecord(value); + if (bill) return { kind: "bill", ...bill }; + + const courtCase = CourtCaseBriefRecordSchema.safeParse(value); + return courtCase.success ? courtCase.data : null; +} + +export function isCurrentCourtCaseBrief(value: unknown): boolean { + return CourtCaseBriefRecordSchema.safeParse(value).success; +} diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 22156a70..f47d828e 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -1,5 +1,8 @@ import { z } from "zod/v4"; +export * from "./bill-brief"; +export * from "./content-brief"; + export const unused = z.string().describe( `This lib is currently not used as we use drizzle-zod for simple schemas But as your application grows and you need other validators to share diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 743bff4b..66bcf446 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,7 +174,7 @@ importers: version: 11.16.0(@tanstack/react-query@5.95.2(react@19.2.3))(@trpc/client@11.16.0(@trpc/server@11.16.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.16.0(typescript@6.0.3))(react@19.2.3)(typescript@6.0.3) better-auth: specifier: 'catalog:' - version: 1.6.13(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@vercel/postgres@0.10.0(utf-8-validate@6.0.4))(better-sqlite3@12.8.0)(gel@2.0.0)(kysely@0.28.17)(mysql2@3.11.3)(pg@8.20.0)(postgres@3.4.4)(prisma@5.22.0))(mysql2@3.11.3)(next@16.2.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.20.0)(prisma@5.22.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.6.13(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@vercel/postgres@0.10.0(utf-8-validate@6.0.4))(better-sqlite3@12.8.0)(gel@2.0.0)(kysely@0.28.17)(mysql2@3.11.3)(pg@8.20.0)(postgres@3.4.4)(prisma@5.22.0))(mysql2@3.11.3)(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.20.0)(prisma@5.22.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) expo: specifier: ~56.0.17 version: 56.0.17(@babel/core@7.29.0)(@expo/dom-webview@56.0.6)(@expo/metro-runtime@56.0.18)(bufferutil@4.1.0)(expo-router@56.2.16)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0)(bufferutil@4.1.0)(utf-8-validate@6.0.4))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0)(bufferutil@4.1.0)(utf-8-validate@6.0.4))(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.4))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0)(bufferutil@4.1.0)(utf-8-validate@6.0.4))(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.4))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.4) @@ -367,7 +367,7 @@ importers: version: 11.16.0(@tanstack/react-query@5.95.2(react@19.2.3))(@trpc/client@11.16.0(@trpc/server@11.16.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.16.0(typescript@6.0.3))(react@19.2.3)(typescript@6.0.3) better-auth: specifier: 'catalog:' - version: 1.6.13(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@vercel/postgres@0.10.0(utf-8-validate@6.0.4))(better-sqlite3@12.8.0)(gel@2.0.0)(kysely@0.28.17)(mysql2@3.11.3)(pg@8.20.0)(postgres@3.4.4)(prisma@5.22.0))(mysql2@3.11.3)(next@16.2.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.20.0)(prisma@5.22.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.6.13(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@vercel/postgres@0.10.0(utf-8-validate@6.0.4))(better-sqlite3@12.8.0)(gel@2.0.0)(kysely@0.28.17)(mysql2@3.11.3)(pg@8.20.0)(postgres@3.4.4)(prisma@5.22.0))(mysql2@3.11.3)(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.20.0)(prisma@5.22.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) motion: specifier: ^12.23.25 version: 12.40.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -447,6 +447,9 @@ importers: '@acme/env': specifier: workspace:* version: link:../../packages/env + '@acme/validators': + specifier: workspace:* + version: link:../../packages/validators '@ai-sdk/anthropic': specifier: ^3.0.92 version: 3.0.96(zod@4.3.6) @@ -639,6 +642,9 @@ importers: '@acme/env': specifier: workspace:* version: link:../env + '@acme/validators': + specifier: workspace:* + version: link:../validators '@vercel/postgres': specifier: ^0.10.0 version: 0.10.0(utf-8-validate@6.0.4) @@ -13150,40 +13156,6 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-auth@1.6.13(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@vercel/postgres@0.10.0(utf-8-validate@6.0.4))(better-sqlite3@12.8.0)(gel@2.0.0)(kysely@0.28.17)(mysql2@3.11.3)(pg@8.20.0)(postgres@3.4.4)(prisma@5.22.0))(mysql2@3.11.3)(next@16.2.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.20.0)(prisma@5.22.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - '@better-auth/core': 1.6.13(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0) - '@better-auth/drizzle-adapter': 1.6.13(@better-auth/core@1.6.13(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@vercel/postgres@0.10.0(utf-8-validate@6.0.4))(better-sqlite3@12.8.0)(gel@2.0.0)(kysely@0.28.17)(mysql2@3.11.3)(pg@8.20.0)(postgres@3.4.4)(prisma@5.22.0)) - '@better-auth/kysely-adapter': 1.6.13(@better-auth/core@1.6.13(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.1)(kysely@0.28.17) - '@better-auth/memory-adapter': 1.6.13(@better-auth/core@1.6.13(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.1) - '@better-auth/mongo-adapter': 1.6.13(@better-auth/core@1.6.13(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.1) - '@better-auth/prisma-adapter': 1.6.13(@better-auth/core@1.6.13(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.1)(@prisma/client@5.22.0(prisma@5.22.0))(prisma@5.22.0) - '@better-auth/telemetry': 1.6.13(@better-auth/core@1.6.13(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21) - '@better-auth/utils': 0.4.1 - '@better-fetch/fetch': 1.1.21 - '@noble/ciphers': 2.1.1 - '@noble/hashes': 2.0.1 - better-call: 1.3.5(zod@4.3.6) - defu: 6.1.7 - jose: 6.2.2 - kysely: 0.28.17 - nanostores: 1.2.0 - zod: 4.3.6 - optionalDependencies: - '@prisma/client': 5.22.0(prisma@5.22.0) - better-sqlite3: 12.8.0 - drizzle-kit: 0.31.10 - drizzle-orm: 0.45.2(@neondatabase/serverless@0.10.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@vercel/postgres@0.10.0(utf-8-validate@6.0.4))(better-sqlite3@12.8.0)(gel@2.0.0)(kysely@0.28.17)(mysql2@3.11.3)(pg@8.20.0)(postgres@3.4.4)(prisma@5.22.0) - mysql2: 3.11.3 - next: 16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - pg: 8.20.0 - prisma: 5.22.0 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - transitivePeerDependencies: - - '@cloudflare/workers-types' - - '@opentelemetry/api' - better-auth@1.6.13(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@neondatabase/serverless@0.10.0)(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(@vercel/postgres@0.10.0(utf-8-validate@6.0.4))(better-sqlite3@12.8.0)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.11.3)(pg@8.20.0)(postgres@3.4.4)(prisma@5.22.0))(mysql2@3.11.3)(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.20.0)(prisma@5.22.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@better-auth/core': 1.6.13(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.2.0)