From 94c4711f991b95d0bc862e5262d2769a680bcad7 Mon Sep 17 00:00:00 2001
From: junj-st <68572255+junj-st@users.noreply.github.com>
Date: Mon, 19 Jan 2026 15:26:51 -0800
Subject: [PATCH 1/5] customizable depth generation
---
ARTICLE_DEPTH.md | 358 +++++++++
packages/api/package.json | 2 +
packages/api/src/router/content.ts | 82 ++
packages/api/src/utils/article-depth.ts | 256 ++++++
.../drizzle/0001_naive_lady_deathstrike.sql | 3 +
packages/db/drizzle/meta/0001_snapshot.json | 754 ++++++++++++++++++
packages/db/drizzle/meta/_journal.json | 7 +
packages/db/run-migration.ts | 35 +
packages/db/src/schema.ts | 18 +-
packages/ui/src/article-depth-control.tsx | 99 +++
packages/ui/src/index.ts | 2 +
packages/ui/src/slider.tsx | 57 ++
pnpm-lock.yaml | 10 +-
13 files changed, 1678 insertions(+), 5 deletions(-)
create mode 100644 ARTICLE_DEPTH.md
create mode 100644 packages/api/src/utils/article-depth.ts
create mode 100644 packages/db/drizzle/0001_naive_lady_deathstrike.sql
create mode 100644 packages/db/drizzle/meta/0001_snapshot.json
create mode 100644 packages/db/run-migration.ts
create mode 100644 packages/ui/src/article-depth-control.tsx
create mode 100644 packages/ui/src/slider.tsx
diff --git a/ARTICLE_DEPTH.md b/ARTICLE_DEPTH.md
new file mode 100644
index 0000000..5f04d77
--- /dev/null
+++ b/ARTICLE_DEPTH.md
@@ -0,0 +1,358 @@
+# Article Depth Feature Implementation Guide
+
+## Overview
+Users can now adjust the depth of AI-generated articles using a slider (1-5 levels). Articles are cached in the database to avoid regeneration.
+
+## Features
+
+### 5 Depth Levels
+1. **Brief (1)** - 100-200 words, key points only
+2. **Summary (2)** - 300-400 words, essential facts
+3. **Standard (3)** - 500-700 words, balanced coverage (default)
+4. **Detailed (4)** - 800-1000 words, comprehensive analysis
+5. **Expert (5)** - 1200+ words, in-depth with historical context
+
+### Caching System
+- Articles generated at each depth level are stored in `articleGenerations` JSONB field
+- Cached articles are retrieved instantly
+- Only generates new content if depth level hasn't been cached yet
+- Default article (`aiGeneratedArticle`) is used for depth 3
+
+## Database Schema
+
+### New Field: `articleGenerations`
+Added to `Bill`, `GovernmentContent`, and `CourtCase` tables:
+
+```typescript
+articleGenerations: {
+ depth: number; // 1-5
+ content: string; // Generated markdown article
+ generatedAt: string; // ISO timestamp
+}[]
+```
+
+### Migration
+Run the migration to add the new field:
+```bash
+cd packages/db
+npx drizzle-kit push
+```
+
+## API Endpoints
+
+### 1. Get Article at Specific Depth
+```typescript
+api.content.getArticleAtDepth.useQuery({
+ id: "content-id",
+ type: "general", // or "bill" | "case"
+ depth: 3, // 1-5
+});
+
+// Returns:
+{
+ content: "markdown article content...",
+ cached: true, // true if from cache, false if newly generated
+ depth: 3,
+ depthDescription: "Standard (500-700 words) - Balanced coverage with analysis"
+}
+```
+
+### 2. Get Available Depth Levels
+```typescript
+api.content.getDepthLevels.useQuery();
+
+// Returns:
+[
+ { depth: 1, description: "Brief (100-200 words) - Quick overview..." },
+ { depth: 2, description: "Summary (300-400 words) - Essential facts..." },
+ ...
+]
+```
+
+### 3. Check Cached Depths
+```typescript
+api.content.getCachedDepths.useQuery({
+ id: "content-id",
+ type: "general",
+});
+
+// Returns:
+{
+ cachedDepths: [
+ { depth: 3, generatedAt: "2026-01-19T12:00:00Z" },
+ { depth: 4, generatedAt: "2026-01-19T12:05:00Z" },
+ ]
+}
+```
+
+## UI Components
+
+### ArticleDepthControl Component
+Pre-built slider component with labels and status indicators:
+
+```tsx
+import { ArticleDepthControl } from "@acme/ui";
+import { useState } from "react";
+
+function ArticleView() {
+ const [depth, setDepth] = useState<1 | 2 | 3 | 4 | 5>(3);
+
+ const { data, isLoading } = api.content.getArticleAtDepth.useQuery({
+ id: articleId,
+ type: "general",
+ depth,
+ });
+
+ return (
+
+
+
+
+ {data?.content}
+
+
+ );
+}
+```
+
+### Slider Component
+Low-level slider component for custom implementations:
+
+```tsx
+import { Slider } from "@acme/ui";
+
+
+```
+
+## Complete Example: Article Detail Page
+
+```tsx
+"use client";
+
+import { useState } from "react";
+import { ArticleDepthControl } from "@acme/ui";
+import { api } from "~/trpc/react";
+import ReactMarkdown from "react-markdown";
+
+export default function ArticlePage({ params }: { params: { id: string } }) {
+ const [depth, setDepth] = useState<1 | 2 | 3 | 4 | 5>(3);
+
+ // Get article metadata
+ const { data: article } = api.content.getById.useQuery({
+ id: params.id,
+ });
+
+ // Get article content at selected depth
+ const { data: articleContent, isLoading } = api.content.getArticleAtDepth.useQuery({
+ id: params.id,
+ type: article?.type === "bill" ? "bill" :
+ article?.type === "case" ? "case" : "general",
+ depth,
+ });
+
+ // Check which depths are cached
+ const { data: cachedInfo } = api.content.getCachedDepths.useQuery({
+ id: params.id,
+ type: article?.type === "bill" ? "bill" :
+ article?.type === "case" ? "case" : "general",
+ });
+
+ if (!article) return Loading...
;
+
+ return (
+
+ {/* Header */}
+
+ {article.thumbnailUrl && (
+

+ )}
+
{article.title}
+
{article.description}
+
+
+ {/* Depth Control */}
+
+
+ {/* Cache Status */}
+ {cachedInfo && (
+
+ Cached depths: {cachedInfo.cachedDepths.map(d => d.depth).join(", ") || "None"}
+
+ )}
+
+ {/* Article Content */}
+
+ {isLoading ? (
+
+ ) : articleContent ? (
+ {articleContent.content}
+ ) : (
+ No content available
+ )}
+
+
+ {/* Original Content Toggle */}
+
+
+ View Original Text
+
+
+ {article.originalContent}
+
+
+
+ );
+}
+```
+
+## React Native / Expo Example
+
+```tsx
+import { useState } from "react";
+import { View, Text, ScrollView, ActivityIndicator } from "react-native";
+import { api } from "~/utils/api";
+import Slider from "@react-native-community/slider";
+
+export function ArticleScreen({ route }) {
+ const { id, type } = route.params;
+ const [depth, setDepth] = useState<1 | 2 | 3 | 4 | 5>(3);
+
+ const { data: article } = api.content.getById.useQuery({ id });
+ const { data: content, isLoading } = api.content.getArticleAtDepth.useQuery({
+ id,
+ type,
+ depth,
+ });
+
+ const depthLabels = ["Brief", "Summary", "Standard", "Detailed", "Expert"];
+
+ return (
+
+ {/* Header */}
+
+ {article?.title}
+
+
+ {/* Depth Control */}
+
+
+ Article Depth: {depthLabels[depth - 1]}
+
+
+ setDepth(Math.round(val) as any)}
+ style={{ marginTop: 10 }}
+ />
+
+
+ {depthLabels.map((label) => (
+
+ {label}
+
+ ))}
+
+
+ {content?.cached && (
+ ✓ Cached
+ )}
+
+
+ {/* Content */}
+
+ {isLoading ? (
+
+ ) : (
+ {content?.content}
+ )}
+
+
+ );
+}
+```
+
+## Utility Functions
+
+### Pre-generate All Depths (Batch Processing)
+Useful for pre-caching articles during scraping:
+
+```typescript
+import { preGenerateAllDepths } from "@acme/api/utils/article-depth";
+
+// In your scraper
+await preGenerateAllDepths(contentId, "general");
+// Generates and caches articles at all 5 depth levels
+```
+
+### Get Depth Descriptions
+```typescript
+import { DEPTH_DESCRIPTIONS } from "@acme/api/utils/article-depth";
+
+console.log(DEPTH_DESCRIPTIONS[3]);
+// "Standard (500-700 words) - Balanced coverage with analysis"
+```
+
+## Performance Considerations
+
+1. **First Load**: Depth 3 (Standard) uses the default `aiGeneratedArticle` - instant
+2. **New Depth**: Takes 3-10 seconds to generate with GPT-4o-mini
+3. **Cached Depth**: Instant retrieval from database
+4. **Storage**: Each article ~500-2000 chars, JSONB efficiently stores 5 versions
+
+## Cost Optimization
+
+- Default articles (depth 3) generated during scraping
+- Other depths generated on-demand only
+- Caching eliminates duplicate API calls
+- Consider pre-generating popular depths (1, 3, 5) during low-traffic periods
+
+## Troubleshooting
+
+### Article not generating
+- Check if `fullText` field exists in content
+- Verify OpenAI API key in environment
+- Check API rate limits
+
+### Slider not responding
+- Ensure `isGenerating` prop is passed to ArticleDepthControl
+- Check that `onValueChange` callback is properly connected
+
+### Cache not working
+- Verify database migration ran successfully
+- Check `articleGenerations` field exists in table
+- Ensure content ID and type are correct
+
+## Future Enhancements
+
+- [ ] Background job to pre-generate all depths for popular articles
+- [ ] Analytics to track which depths users prefer
+- [ ] A/B testing different depth descriptions
+- [ ] Voice narration at different speeds based on depth
+- [ ] Export to PDF with depth selection
diff --git a/packages/api/package.json b/packages/api/package.json
index 59f148b..0f07420 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -21,7 +21,9 @@
"@acme/auth": "workspace:*",
"@acme/db": "workspace:*",
"@acme/validators": "workspace:*",
+ "@ai-sdk/openai": "^2.0.57",
"@trpc/server": "catalog:",
+ "ai": "^5.0.82",
"superjson": "2.2.2",
"zod": "catalog:"
},
diff --git a/packages/api/src/router/content.ts b/packages/api/src/router/content.ts
index bcbb84e..6b6199c 100644
--- a/packages/api/src/router/content.ts
+++ b/packages/api/src/router/content.ts
@@ -6,6 +6,11 @@ import { db } from "@acme/db/client";
import { Bill, CourtCase, GovernmentContent } from "@acme/db/schema";
import { publicProcedure } from "../trpc";
+import {
+ getOrGenerateArticle,
+ DEPTH_DESCRIPTIONS,
+ type ArticleDepth,
+} from "../utils/article-depth";
// Helper function to get thumbnail URL for any content
export async function getThumbnailForContent(
@@ -292,4 +297,81 @@ export const contentRouter = {
throw new Error(`Content with id ${input.id} not found`);
}),
+
+ // Get article at specific depth level
+ getArticleAtDepth: publicProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ type: z.enum(["bill", "case", "general"]),
+ depth: z.union([
+ z.literal(1),
+ z.literal(2),
+ z.literal(3),
+ z.literal(4),
+ z.literal(5),
+ ]),
+ }),
+ )
+ .query(async ({ input }) => {
+ const result = await getOrGenerateArticle(
+ input.id,
+ input.type,
+ input.depth as ArticleDepth,
+ );
+ return {
+ content: result.content,
+ cached: result.cached,
+ depth: input.depth,
+ depthDescription: DEPTH_DESCRIPTIONS[input.depth as ArticleDepth],
+ };
+ }),
+
+ // Get available depth levels and their descriptions
+ getDepthLevels: publicProcedure.query(async () => {
+ return Object.entries(DEPTH_DESCRIPTIONS).map(([depth, description]) => ({
+ depth: Number(depth) as ArticleDepth,
+ description,
+ }));
+ }),
+
+ // Check which depth levels are cached for a content item
+ getCachedDepths: publicProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ type: z.enum(["bill", "case", "general"]),
+ }),
+ )
+ .query(async ({ input }) => {
+ const table =
+ input.type === "bill"
+ ? Bill
+ : input.type === "case"
+ ? CourtCase
+ : GovernmentContent;
+
+ const [content] = await db
+ .select({ articleGenerations: table.articleGenerations })
+ .from(table)
+ .where(eq(table.id, input.id))
+ .limit(1);
+
+ if (!content) {
+ return { cachedDepths: [] };
+ }
+
+ const generations = (content.articleGenerations as {
+ depth: number;
+ content: string;
+ generatedAt: string;
+ }[]) || [];
+
+ return {
+ cachedDepths: generations.map((g) => ({
+ depth: g.depth as ArticleDepth,
+ generatedAt: g.generatedAt,
+ })),
+ };
+ }),
} satisfies TRPCRouterRecord;
diff --git a/packages/api/src/utils/article-depth.ts b/packages/api/src/utils/article-depth.ts
new file mode 100644
index 0000000..2cb0d62
--- /dev/null
+++ b/packages/api/src/utils/article-depth.ts
@@ -0,0 +1,256 @@
+import { openai } from "@ai-sdk/openai";
+import { generateText } from "ai";
+import { eq } from "@acme/db";
+import { db } from "@acme/db/client";
+import { Bill, CourtCase, GovernmentContent } from "@acme/db/schema";
+
+export type ArticleDepth = 1 | 2 | 3 | 4 | 5;
+
+export const DEPTH_DESCRIPTIONS = {
+ 1: "Brief (100-200 words) - Quick overview with key points only",
+ 2: "Summary (300-400 words) - Essential facts and context",
+ 3: "Standard (500-700 words) - Balanced coverage with analysis",
+ 4: "Detailed (800-1000 words) - Comprehensive with examples",
+ 5: "Expert (1200+ words) - In-depth with historical context and implications",
+} as const;
+
+/**
+ * Get the word count range and prompt instructions for each depth level
+ */
+function getDepthPrompt(depth: ArticleDepth): {
+ wordCount: string;
+ instructions: string;
+} {
+ const prompts = {
+ 1: {
+ wordCount: "100-200 words",
+ instructions: `Write a VERY BRIEF article focused only on the most critical information.
+
+## Structure (Single Section):
+- Start with 2-3 sentences explaining what this is and why it matters
+- List 3-5 key bullet points
+- End with one sentence about immediate impact
+
+Keep it extremely concise. Skip historical context and detailed analysis.`,
+ },
+ 2: {
+ wordCount: "300-400 words",
+ instructions: `Write a SUMMARY-LENGTH article covering the essentials.
+
+## Structure (2 Sections):
+### What This Means For You (50-75 words)
+Direct impact in 2-3 sentences.
+
+### Overview (250-325 words)
+- What this is about
+- Key facts and figures
+- Who is affected
+- Immediate implications
+
+Skip detailed background. Focus on current facts and direct effects.`,
+ },
+ 3: {
+ wordCount: "500-700 words",
+ instructions: `Write a STANDARD-LENGTH balanced article with analysis.
+
+## Structure (4 Sections):
+### What This Means For You (75-100 words)
+Direct impact with examples.
+
+### Overview (200-250 words)
+What's happening, context, and key details.
+
+### Impact & Implications (150-200 words)
+Short and long-term effects, who's affected.
+
+### The Debate (150-200 words)
+Brief coverage of both sides of the political spectrum.`,
+ },
+ 4: {
+ wordCount: "800-1000 words",
+ instructions: `Write a DETAILED article with comprehensive coverage.
+
+## Structure (5 Sections):
+### What This Means For You (100-150 words)
+Direct impact with concrete examples and scenarios.
+
+### Overview (250-300 words)
+Full context, background, key players, and detailed explanation.
+
+### Impact & Implications (250-300 words)
+Comprehensive analysis of effects across different groups and timelines.
+
+### The Debate (200-250 words)
+Balanced coverage of multiple viewpoints with specific arguments from each side.
+
+### What's Next (100-150 words)
+Timeline, expected developments, and what to watch for.`,
+ },
+ 5: {
+ wordCount: "1200+ words",
+ instructions: `Write an EXPERT-LEVEL in-depth article with scholarly analysis.
+
+## Structure (6 Sections):
+### What This Means For You (150-200 words)
+Detailed impact analysis with multiple scenarios and examples.
+
+### Historical Context (200-300 words)
+Background, precedents, how we got here, related past events.
+
+### Overview (300-400 words)
+Comprehensive explanation with all relevant details, players, and mechanisms.
+
+### Impact & Implications (300-400 words)
+Deep analysis of cascading effects across society, economy, and policy.
+
+### The Debate (250-300 words)
+Nuanced exploration of perspectives across the political spectrum with specific quotes and arguments.
+
+### Expert Analysis & Future Outlook (200-300 words)
+What experts are saying, potential scenarios, long-term implications, and what to watch.
+
+Include specific data, quotes, and expert perspectives throughout.`,
+ },
+ };
+
+ return prompts[depth];
+}
+
+/**
+ * Generate an AI article at a specific depth level
+ */
+export async function generateArticleAtDepth(
+ title: string,
+ fullText: string,
+ type: string,
+ url: string,
+ depth: ArticleDepth,
+): Promise {
+ const { wordCount, instructions } = getDepthPrompt(depth);
+
+ const result = await generateText({
+ // @ts-ignore - AI SDK v5 type compatibility issue, works at runtime
+ model: openai("gpt-4o-mini"),
+ prompt: `You are an expert at making government and legal content accessible and engaging for everyday people. Transform the following ${type} into a well-structured, markdown-formatted article.
+
+**Target Length:** ${wordCount}
+
+${instructions}
+
+**Content to transform:**
+Title: ${title}
+Source: ${url}
+Full Text: ${fullText.substring(0, 5000)}
+
+**Formatting Guidelines:**
+- Use markdown headers (##) for each section
+- Use **bold** for emphasis on key terms
+- Use bullet points or numbered lists where appropriate
+- Include blockquotes (>) for any direct quotes
+- Keep paragraphs short (2-4 sentences) for readability
+- Use 8th-grade reading level language
+- Focus on facts and balance - remain objective
+
+Write the article now:`,
+ });
+
+ return result.text.trim();
+}
+
+/**
+ * Get cached article at specific depth or generate if not cached
+ */
+export async function getOrGenerateArticle(
+ contentId: string,
+ contentType: "bill" | "case" | "general",
+ depth: ArticleDepth,
+): Promise<{ content: string; cached: boolean }> {
+ // Select the appropriate table
+ const table =
+ contentType === "bill"
+ ? Bill
+ : contentType === "case"
+ ? CourtCase
+ : GovernmentContent;
+
+ // Fetch content from database
+ const [content] = await db
+ .select()
+ .from(table)
+ .where(eq(table.id, contentId))
+ .limit(1);
+
+ if (!content) {
+ throw new Error(`Content with id ${contentId} not found`);
+ }
+
+ // Check if article at this depth exists in cache
+ const generations = (content.articleGenerations as {
+ depth: number;
+ content: string;
+ generatedAt: string;
+ }[]) || [];
+
+ const cached = generations.find((gen) => gen.depth === depth);
+ if (cached) {
+ return { content: cached.content, cached: true };
+ }
+
+ // If depth is 3 and we have the default article, use it
+ if (depth === 3 && content.aiGeneratedArticle) {
+ return { content: content.aiGeneratedArticle, cached: true };
+ }
+
+ // Generate new article at requested depth
+ if (!content.fullText) {
+ throw new Error("Cannot generate article: fullText is missing");
+ }
+
+ const newArticle = await generateArticleAtDepth(
+ content.title,
+ content.fullText,
+ contentType === "bill"
+ ? "bill"
+ : contentType === "case"
+ ? "court case"
+ : (content as any).type || "government content",
+ (content as any).url || "",
+ depth,
+ );
+
+ // Cache the generated article
+ const updatedGenerations = [
+ ...generations,
+ {
+ depth,
+ content: newArticle,
+ generatedAt: new Date().toISOString(),
+ },
+ ];
+
+ await db
+ .update(table)
+ .set({ articleGenerations: updatedGenerations as any })
+ .where(eq(table.id, contentId));
+
+ return { content: newArticle, cached: false };
+}
+
+/**
+ * Pre-generate articles at all depth levels (useful for batch processing)
+ */
+export async function preGenerateAllDepths(
+ contentId: string,
+ contentType: "bill" | "case" | "general",
+): Promise {
+ const depths: ArticleDepth[] = [1, 2, 3, 4, 5];
+
+ for (const depth of depths) {
+ try {
+ await getOrGenerateArticle(contentId, contentType, depth);
+ console.log(`Generated article at depth ${depth} for ${contentId}`);
+ } catch (error) {
+ console.error(`Failed to generate depth ${depth}:`, error);
+ }
+ }
+}
diff --git a/packages/db/drizzle/0001_naive_lady_deathstrike.sql b/packages/db/drizzle/0001_naive_lady_deathstrike.sql
new file mode 100644
index 0000000..fd7dc32
--- /dev/null
+++ b/packages/db/drizzle/0001_naive_lady_deathstrike.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "bill" ADD COLUMN "article_generations" jsonb DEFAULT '[]'::jsonb;--> statement-breakpoint
+ALTER TABLE "court_case" ADD COLUMN "article_generations" jsonb DEFAULT '[]'::jsonb;--> statement-breakpoint
+ALTER TABLE "government_content" ADD COLUMN "article_generations" jsonb DEFAULT '[]'::jsonb;
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..cbb9625
--- /dev/null
+++ b/packages/db/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,754 @@
+{
+ "id": "3ea25df1-543f-4b69-a192-cd5d22e62075",
+ "prevId": "77934619-ba01-442e-99c2-93b806143575",
+ "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
+ },
+ "article_generations": {
+ "name": "article_generations",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "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_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
+ }
+ },
+ "indexes": {},
+ "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
+ },
+ "article_generations": {
+ "name": "article_generations",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "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
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "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
+ },
+ "article_generations": {
+ "name": "article_generations",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "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
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "government_content_url_unique": {
+ "name": "government_content_url_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "url"
+ ]
+ }
+ },
+ "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.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": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index e852c81..d99f6d9 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1768848737753,
"tag": "0000_abnormal_the_spike",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1768864087960,
+ "tag": "0001_naive_lady_deathstrike",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/run-migration.ts b/packages/db/run-migration.ts
new file mode 100644
index 0000000..3f7d81d
--- /dev/null
+++ b/packages/db/run-migration.ts
@@ -0,0 +1,35 @@
+import 'dotenv/config';
+import pg from 'pg';
+
+const client = new pg.Client({
+ connectionString: process.env.POSTGRES_URL,
+});
+
+async function runMigration() {
+ try {
+ await client.connect();
+ console.log('Connected to database');
+
+ // Add article_generations column to all three tables
+ const migrations = [
+ `ALTER TABLE bill ADD COLUMN IF NOT EXISTS article_generations jsonb DEFAULT '[]'::jsonb`,
+ `ALTER TABLE court_case ADD COLUMN IF NOT EXISTS article_generations jsonb DEFAULT '[]'::jsonb`,
+ `ALTER TABLE government_content ADD COLUMN IF NOT EXISTS article_generations jsonb DEFAULT '[]'::jsonb`,
+ ];
+
+ for (const migration of migrations) {
+ console.log(`Running: ${migration}`);
+ await client.query(migration);
+ console.log('✓ Success');
+ }
+
+ console.log('\n✅ All migrations completed successfully!');
+ } catch (error) {
+ console.error('❌ Migration failed:', error);
+ process.exit(1);
+ } finally {
+ await client.end();
+ }
+}
+
+runMigration();
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 775b7d7..f9d8b20 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -35,7 +35,11 @@ export const Bill = pgTable("bill", (t) => ({
chamber: t.varchar({ length: 50 }), // "House" or "Senate"
summary: t.text(),
fullText: t.text(),
- aiGeneratedArticle: t.text(), // AI-generated accessible article version
+ aiGeneratedArticle: t.text(), // AI-generated accessible article version (default depth: 3)
+ articleGenerations: t
+ .jsonb()
+ .$type<{ depth: number; content: string; generatedAt: string }[]>()
+ .default([]), // Cached articles at different depth levels (1-5)
thumbnailUrl: t.text(), // URL of the thumbnail image
images: t
.jsonb()
@@ -68,7 +72,11 @@ export const GovernmentContent = pgTable("government_content", (t) => ({
publishedDate: t.timestamp().notNull(),
description: t.text(),
fullText: t.text(),
- aiGeneratedArticle: t.text(), // AI-generated accessible article version
+ aiGeneratedArticle: t.text(), // AI-generated accessible article version (default depth: 3)
+ articleGenerations: t
+ .jsonb()
+ .$type<{ depth: number; content: string; generatedAt: string }[]>()
+ .default([]), // Cached articles at different depth levels (1-5)
thumbnailUrl: t.text(), // URL of the thumbnail image
images: t
.jsonb()
@@ -109,7 +117,11 @@ export const CourtCase = pgTable("court_case", (t) => ({
description: t.text(),
status: t.varchar({ length: 100 }), // e.g., "Pending", "Decided"
fullText: t.text(),
- aiGeneratedArticle: t.text(), // AI-generated accessible article version
+ aiGeneratedArticle: t.text(), // AI-generated accessible article version (default depth: 3)
+ articleGenerations: t
+ .jsonb()
+ .$type<{ depth: number; content: string; generatedAt: string }[]>()
+ .default([]), // Cached articles at different depth levels (1-5)
thumbnailUrl: t.text(), // URL of the thumbnail image
images: t
.jsonb()
diff --git a/packages/ui/src/article-depth-control.tsx b/packages/ui/src/article-depth-control.tsx
new file mode 100644
index 0000000..3dcc691
--- /dev/null
+++ b/packages/ui/src/article-depth-control.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import * as React from "react";
+import { Slider } from "./slider";
+import { cn } from "./index";
+
+export interface ArticleDepthControlProps {
+ value: 1 | 2 | 3 | 4 | 5;
+ onValueChange: (value: 1 | 2 | 3 | 4 | 5) => void;
+ isGenerating?: boolean;
+ isCached?: boolean;
+ className?: string;
+}
+
+const DEPTH_LABELS = {
+ 1: "Brief",
+ 2: "Summary",
+ 3: "Standard",
+ 4: "Detailed",
+ 5: "Expert",
+} as const;
+
+const DEPTH_DESCRIPTIONS = {
+ 1: "Quick overview (100-200 words)",
+ 2: "Essential facts (300-400 words)",
+ 3: "Balanced coverage (500-700 words)",
+ 4: "Comprehensive (800-1000 words)",
+ 5: "In-depth analysis (1200+ words)",
+} as const;
+
+export function ArticleDepthControl({
+ value,
+ onValueChange,
+ isGenerating = false,
+ isCached = false,
+ className,
+}: ArticleDepthControlProps) {
+ return (
+
+
+
+
+ Article Depth: {DEPTH_LABELS[value]}
+
+
+ {DEPTH_DESCRIPTIONS[value]}
+
+
+ {isGenerating && (
+
+
+ Generating...
+
+ )}
+ {isCached && !isGenerating && (
+
✓ Cached
+ )}
+
+
+
+
onValueChange(val as 1 | 2 | 3 | 4 | 5)}
+ disabled={isGenerating}
+ className="w-full"
+ />
+
+ Brief
+ Summary
+ Standard
+ Detailed
+ Expert
+
+
+
+ );
+}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index de41d1c..759b205 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -5,3 +5,5 @@ export const cn = (...inputs: Parameters) => twMerge(cx(inputs));
// Export theme tokens for React Native
export * from "./theme-tokens";
+export * from "./slider";
+export * from "./article-depth-control";
diff --git a/packages/ui/src/slider.tsx b/packages/ui/src/slider.tsx
new file mode 100644
index 0000000..4502fbb
--- /dev/null
+++ b/packages/ui/src/slider.tsx
@@ -0,0 +1,57 @@
+import * as React from "react";
+
+export interface SliderProps extends React.InputHTMLAttributes {
+ min?: number;
+ max?: number;
+ step?: number;
+ value?: number;
+ onValueChange?: (value: number) => void;
+}
+
+const Slider = React.forwardRef(
+ ({ className, min = 0, max = 100, step = 1, value, onValueChange, ...props }, ref) => {
+ const handleChange = (e: React.ChangeEvent) => {
+ const newValue = Number(e.target.value);
+ onValueChange?.(newValue);
+ };
+
+ return (
+
+
+
+ );
+ },
+);
+Slider.displayName = "Slider";
+
+export { Slider };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index feb3cab..5770cf6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -119,7 +119,7 @@ importers:
version: 11.6.0(@tanstack/react-query@5.90.2(react@19.1.1))(@trpc/client@11.6.0(@trpc/server@11.6.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.6.0(typescript@5.9.3))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.3)
better-auth:
specifier: 'catalog:'
- version: 1.4.0-beta.9(better-sqlite3@12.2.0)(next@15.5.4(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ version: 1.4.0-beta.9(better-sqlite3@12.2.0)(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
expo:
specifier: ~54.0.13
version: 54.0.13(@babel/core@7.28.4)(@expo/metro-runtime@6.1.1)(bufferutil@4.0.8)(expo-router@6.0.12)(graphql@15.8.0)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1))(react@19.1.1)
@@ -380,9 +380,15 @@ importers:
'@acme/validators':
specifier: workspace:*
version: link:../validators
+ '@ai-sdk/openai':
+ specifier: ^2.0.57
+ version: 2.0.57(zod@4.1.12)
'@trpc/server':
specifier: 'catalog:'
version: 11.6.0(typescript@5.9.3)
+ ai:
+ specifier: ^5.0.82
+ version: 5.0.82(zod@4.1.12)
superjson:
specifier: 2.2.2
version: 2.2.2
@@ -9406,7 +9412,7 @@ snapshots:
'@better-auth/expo@1.4.0-beta.9(better-auth@1.4.0-beta.9(better-sqlite3@12.2.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(expo-constants@18.0.9)(expo-crypto@15.0.7(expo@54.0.13))(expo-linking@8.0.8)(expo-secure-store@15.0.7(expo@54.0.13))(expo-web-browser@15.0.8(expo@54.0.13)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1)))':
dependencies:
'@better-fetch/fetch': 1.1.18
- better-auth: 1.4.0-beta.9(better-sqlite3@12.2.0)(next@15.5.4(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ better-auth: 1.4.0-beta.9(better-sqlite3@12.2.0)(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
expo-constants: 18.0.9(expo@54.0.13)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1))
expo-crypto: 15.0.7(expo@54.0.13)
expo-linking: 8.0.8(expo@54.0.13)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1))(react@19.1.1)
From ada72c194fcc8df82cee5739687a5d23abf0ef00 Mon Sep 17 00:00:00 2001
From: ThatXliner
Date: Mon, 19 Jan 2026 15:49:38 -0800
Subject: [PATCH 2/5] Clean up branch?
---
ARTICLE_DEPTH.md | 358 ---------
THUMBNAIL_USAGE.md | 196 -----
packages/db/drizzle/0002_loving_havok.sql | 2 +
packages/db/drizzle/meta/0002_snapshot.json | 771 ++++++++++++++++++++
packages/db/drizzle/meta/_journal.json | 7 +
packages/db/migrate-images.ts | 66 --
packages/db/migrations/add_image_fields.sql | 24 -
packages/db/run-migration.ts | 35 -
8 files changed, 780 insertions(+), 679 deletions(-)
delete mode 100644 ARTICLE_DEPTH.md
delete mode 100644 THUMBNAIL_USAGE.md
create mode 100644 packages/db/drizzle/0002_loving_havok.sql
create mode 100644 packages/db/drizzle/meta/0002_snapshot.json
delete mode 100644 packages/db/migrate-images.ts
delete mode 100644 packages/db/migrations/add_image_fields.sql
delete mode 100644 packages/db/run-migration.ts
diff --git a/ARTICLE_DEPTH.md b/ARTICLE_DEPTH.md
deleted file mode 100644
index 5f04d77..0000000
--- a/ARTICLE_DEPTH.md
+++ /dev/null
@@ -1,358 +0,0 @@
-# Article Depth Feature Implementation Guide
-
-## Overview
-Users can now adjust the depth of AI-generated articles using a slider (1-5 levels). Articles are cached in the database to avoid regeneration.
-
-## Features
-
-### 5 Depth Levels
-1. **Brief (1)** - 100-200 words, key points only
-2. **Summary (2)** - 300-400 words, essential facts
-3. **Standard (3)** - 500-700 words, balanced coverage (default)
-4. **Detailed (4)** - 800-1000 words, comprehensive analysis
-5. **Expert (5)** - 1200+ words, in-depth with historical context
-
-### Caching System
-- Articles generated at each depth level are stored in `articleGenerations` JSONB field
-- Cached articles are retrieved instantly
-- Only generates new content if depth level hasn't been cached yet
-- Default article (`aiGeneratedArticle`) is used for depth 3
-
-## Database Schema
-
-### New Field: `articleGenerations`
-Added to `Bill`, `GovernmentContent`, and `CourtCase` tables:
-
-```typescript
-articleGenerations: {
- depth: number; // 1-5
- content: string; // Generated markdown article
- generatedAt: string; // ISO timestamp
-}[]
-```
-
-### Migration
-Run the migration to add the new field:
-```bash
-cd packages/db
-npx drizzle-kit push
-```
-
-## API Endpoints
-
-### 1. Get Article at Specific Depth
-```typescript
-api.content.getArticleAtDepth.useQuery({
- id: "content-id",
- type: "general", // or "bill" | "case"
- depth: 3, // 1-5
-});
-
-// Returns:
-{
- content: "markdown article content...",
- cached: true, // true if from cache, false if newly generated
- depth: 3,
- depthDescription: "Standard (500-700 words) - Balanced coverage with analysis"
-}
-```
-
-### 2. Get Available Depth Levels
-```typescript
-api.content.getDepthLevels.useQuery();
-
-// Returns:
-[
- { depth: 1, description: "Brief (100-200 words) - Quick overview..." },
- { depth: 2, description: "Summary (300-400 words) - Essential facts..." },
- ...
-]
-```
-
-### 3. Check Cached Depths
-```typescript
-api.content.getCachedDepths.useQuery({
- id: "content-id",
- type: "general",
-});
-
-// Returns:
-{
- cachedDepths: [
- { depth: 3, generatedAt: "2026-01-19T12:00:00Z" },
- { depth: 4, generatedAt: "2026-01-19T12:05:00Z" },
- ]
-}
-```
-
-## UI Components
-
-### ArticleDepthControl Component
-Pre-built slider component with labels and status indicators:
-
-```tsx
-import { ArticleDepthControl } from "@acme/ui";
-import { useState } from "react";
-
-function ArticleView() {
- const [depth, setDepth] = useState<1 | 2 | 3 | 4 | 5>(3);
-
- const { data, isLoading } = api.content.getArticleAtDepth.useQuery({
- id: articleId,
- type: "general",
- depth,
- });
-
- return (
-
-
-
-
- {data?.content}
-
-
- );
-}
-```
-
-### Slider Component
-Low-level slider component for custom implementations:
-
-```tsx
-import { Slider } from "@acme/ui";
-
-
-```
-
-## Complete Example: Article Detail Page
-
-```tsx
-"use client";
-
-import { useState } from "react";
-import { ArticleDepthControl } from "@acme/ui";
-import { api } from "~/trpc/react";
-import ReactMarkdown from "react-markdown";
-
-export default function ArticlePage({ params }: { params: { id: string } }) {
- const [depth, setDepth] = useState<1 | 2 | 3 | 4 | 5>(3);
-
- // Get article metadata
- const { data: article } = api.content.getById.useQuery({
- id: params.id,
- });
-
- // Get article content at selected depth
- const { data: articleContent, isLoading } = api.content.getArticleAtDepth.useQuery({
- id: params.id,
- type: article?.type === "bill" ? "bill" :
- article?.type === "case" ? "case" : "general",
- depth,
- });
-
- // Check which depths are cached
- const { data: cachedInfo } = api.content.getCachedDepths.useQuery({
- id: params.id,
- type: article?.type === "bill" ? "bill" :
- article?.type === "case" ? "case" : "general",
- });
-
- if (!article) return Loading...
;
-
- return (
-
- {/* Header */}
-
- {article.thumbnailUrl && (
-

- )}
-
{article.title}
-
{article.description}
-
-
- {/* Depth Control */}
-
-
- {/* Cache Status */}
- {cachedInfo && (
-
- Cached depths: {cachedInfo.cachedDepths.map(d => d.depth).join(", ") || "None"}
-
- )}
-
- {/* Article Content */}
-
- {isLoading ? (
-
- ) : articleContent ? (
- {articleContent.content}
- ) : (
- No content available
- )}
-
-
- {/* Original Content Toggle */}
-
-
- View Original Text
-
-
- {article.originalContent}
-
-
-
- );
-}
-```
-
-## React Native / Expo Example
-
-```tsx
-import { useState } from "react";
-import { View, Text, ScrollView, ActivityIndicator } from "react-native";
-import { api } from "~/utils/api";
-import Slider from "@react-native-community/slider";
-
-export function ArticleScreen({ route }) {
- const { id, type } = route.params;
- const [depth, setDepth] = useState<1 | 2 | 3 | 4 | 5>(3);
-
- const { data: article } = api.content.getById.useQuery({ id });
- const { data: content, isLoading } = api.content.getArticleAtDepth.useQuery({
- id,
- type,
- depth,
- });
-
- const depthLabels = ["Brief", "Summary", "Standard", "Detailed", "Expert"];
-
- return (
-
- {/* Header */}
-
- {article?.title}
-
-
- {/* Depth Control */}
-
-
- Article Depth: {depthLabels[depth - 1]}
-
-
- setDepth(Math.round(val) as any)}
- style={{ marginTop: 10 }}
- />
-
-
- {depthLabels.map((label) => (
-
- {label}
-
- ))}
-
-
- {content?.cached && (
- ✓ Cached
- )}
-
-
- {/* Content */}
-
- {isLoading ? (
-
- ) : (
- {content?.content}
- )}
-
-
- );
-}
-```
-
-## Utility Functions
-
-### Pre-generate All Depths (Batch Processing)
-Useful for pre-caching articles during scraping:
-
-```typescript
-import { preGenerateAllDepths } from "@acme/api/utils/article-depth";
-
-// In your scraper
-await preGenerateAllDepths(contentId, "general");
-// Generates and caches articles at all 5 depth levels
-```
-
-### Get Depth Descriptions
-```typescript
-import { DEPTH_DESCRIPTIONS } from "@acme/api/utils/article-depth";
-
-console.log(DEPTH_DESCRIPTIONS[3]);
-// "Standard (500-700 words) - Balanced coverage with analysis"
-```
-
-## Performance Considerations
-
-1. **First Load**: Depth 3 (Standard) uses the default `aiGeneratedArticle` - instant
-2. **New Depth**: Takes 3-10 seconds to generate with GPT-4o-mini
-3. **Cached Depth**: Instant retrieval from database
-4. **Storage**: Each article ~500-2000 chars, JSONB efficiently stores 5 versions
-
-## Cost Optimization
-
-- Default articles (depth 3) generated during scraping
-- Other depths generated on-demand only
-- Caching eliminates duplicate API calls
-- Consider pre-generating popular depths (1, 3, 5) during low-traffic periods
-
-## Troubleshooting
-
-### Article not generating
-- Check if `fullText` field exists in content
-- Verify OpenAI API key in environment
-- Check API rate limits
-
-### Slider not responding
-- Ensure `isGenerating` prop is passed to ArticleDepthControl
-- Check that `onValueChange` callback is properly connected
-
-### Cache not working
-- Verify database migration ran successfully
-- Check `articleGenerations` field exists in table
-- Ensure content ID and type are correct
-
-## Future Enhancements
-
-- [ ] Background job to pre-generate all depths for popular articles
-- [ ] Analytics to track which depths users prefer
-- [ ] A/B testing different depth descriptions
-- [ ] Voice narration at different speeds based on depth
-- [ ] Export to PDF with depth selection
diff --git a/THUMBNAIL_USAGE.md b/THUMBNAIL_USAGE.md
deleted file mode 100644
index 28cc4fa..0000000
--- a/THUMBNAIL_USAGE.md
+++ /dev/null
@@ -1,196 +0,0 @@
-# Thumbnail Image Integration Guide
-
-## Overview
-The scraper now automatically generates thumbnail images for all government content (Bills, Government Content, and Court Cases) using Google Custom Search API with AI-generated keywords.
-
-## What Changed
-
-### Scraper (`apps/scraper/src/utils/db.ts`)
-- Only fetches **one thumbnail image** per article (not multiple images)
-- Uses `getThumbnailImage()` to get a single Google-hosted cached thumbnail
-- Saves thumbnail URL to `thumbnailUrl` field in database
-- ❌ No longer fetches `images[]` array
-
-### API (`packages/api/src/router/content.ts`)
-- All content endpoints now return `thumbnailUrl` in their responses
-- Added `getThumbnailForContent()` helper function for direct thumbnail access
-- Available through both tRPC API and direct function import
-
-## Using Thumbnails in Your App
-
-### 1. Using tRPC API (Recommended)
-
-#### Get all content with thumbnails:
-```typescript
-import { api } from "~/trpc/react";
-
-function FeedView() {
- const { data: content } = api.content.getAll.useQuery();
-
- return (
-
- {content?.map((item) => (
-
- {item.thumbnailUrl && (
-

- )}
-
{item.title}
-
{item.description}
-
- ))}
-
- );
-}
-```
-
-#### Get single content detail with thumbnail:
-```typescript
-function ArticleDetail({ id }: { id: string }) {
- const { data: article } = api.content.getById.useQuery({ id });
-
- return (
-
- {article?.thumbnailUrl && (
-
- )}
- {article.title}
- {article.description}
-
-
- );
-}
-```
-
-### 2. Using Direct Function Import
-
-```typescript
-import { getThumbnailForContent } from "@acme/api";
-
-// Get thumbnail for a specific content item
-const thumbnailUrl = await getThumbnailForContent(
- "content-id-here",
- "bill" // or "case" or "general"
-);
-
-if (thumbnailUrl) {
- console.log("Thumbnail URL:", thumbnailUrl);
-}
-```
-
-### 3. React Native / Expo Usage
-
-```tsx
-import { Image } from "react-native";
-import { api } from "~/utils/api";
-
-function ContentCard({ id }: { id: string }) {
- const { data } = api.content.getById.useQuery({ id });
-
- return (
-
- {data?.thumbnailUrl && (
-
- )}
- {data.title}
-
- );
-}
-```
-
-## Thumbnail URLs
-
-All thumbnails are Google-hosted cached images:
-- Format: `https://encrypted-tbn0.gstatic.com/images?q=tbn:...`
-- Size: Typically ~200x200px (Google's cached thumbnails)
-- Cross-origin enabled: Works in web and mobile apps
-- No hotlinking issues: Google serves these images
-
-## Database Schema
-
-### thumbnailUrl field (TEXT)
-All three content tables have this field:
-- `Bill.thumbnailUrl`
-- `GovernmentContent.thumbnailUrl`
-- `CourtCase.thumbnailUrl`
-
-Example:
-```sql
-SELECT id, title, thumbnail_url FROM government_content LIMIT 5;
-```
-
-## Re-scraping Content
-
-To regenerate thumbnails for existing content:
-```bash
-cd apps/scraper
-pnpm tsx src/main.ts whitehouse # Re-scrape White House content
-```
-
-The scraper will automatically:
-1. Generate AI keywords based on article content
-2. Search Google Images with those keywords
-3. Get the cached thumbnail URL
-4. Save to database
-
-## Fallback Behavior
-
-If thumbnail generation fails:
-- `thumbnailUrl` will be `null`
-- Your UI should handle missing images gracefully
-- Consider showing a default placeholder image
-
-```tsx
-
{
- e.currentTarget.src = "/images/default-government.jpg";
- }}
-/>
-```
-
-## Performance Tips
-
-1. **Lazy loading**: Use native lazy loading for images
- ```tsx
-
- ```
-
-2. **Responsive images**: Add appropriate sizing classes
- ```tsx
-
- ```
-
-3. **Caching**: Google's CDN handles caching automatically
-
-## Troubleshooting
-
-### Images not loading in browser
-- Google thumbnails may have referrer restrictions for direct browser access
-- Images should work fine when loaded through React/Expo Image components
-- If needed, you can implement an image proxy in Next.js API routes
-
-### Missing thumbnails
-- Check if article has `fullText` (required for AI keyword generation)
-- Verify Google API credentials in `.env`:
- ```
- GOOGLE_API_KEY=your_key
- GOOGLE_SEARCH_ENGINE_ID=your_cx
- ```
-- Check rate limits (100 free searches/day)
diff --git a/packages/db/drizzle/0002_loving_havok.sql b/packages/db/drizzle/0002_loving_havok.sql
new file mode 100644
index 0000000..c54a985
--- /dev/null
+++ b/packages/db/drizzle/0002_loving_havok.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "bill" ADD CONSTRAINT "bill_billNumber_sourceWebsite_unique" UNIQUE("bill_number","source_website");--> statement-breakpoint
+ALTER TABLE "court_case" ADD CONSTRAINT "court_case_caseNumber_unique" UNIQUE("case_number");
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/0002_snapshot.json b/packages/db/drizzle/meta/0002_snapshot.json
new file mode 100644
index 0000000..04312fe
--- /dev/null
+++ b/packages/db/drizzle/meta/0002_snapshot.json
@@ -0,0 +1,771 @@
+{
+ "id": "147d203e-1950-4eba-8ff2-e33e591ad0f4",
+ "prevId": "3ea25df1-543f-4b69-a192-cd5d22e62075",
+ "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
+ },
+ "article_generations": {
+ "name": "article_generations",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "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_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
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "bill_billNumber_sourceWebsite_unique": {
+ "name": "bill_billNumber_sourceWebsite_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "bill_number",
+ "source_website"
+ ]
+ }
+ },
+ "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
+ },
+ "article_generations": {
+ "name": "article_generations",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "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
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "court_case_caseNumber_unique": {
+ "name": "court_case_caseNumber_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "case_number"
+ ]
+ }
+ },
+ "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
+ },
+ "article_generations": {
+ "name": "article_generations",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "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
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "government_content_url_unique": {
+ "name": "government_content_url_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "url"
+ ]
+ }
+ },
+ "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.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": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index d99f6d9..4b11823 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1768864087960,
"tag": "0001_naive_lady_deathstrike",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "7",
+ "when": 1768866507380,
+ "tag": "0002_loving_havok",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/migrate-images.ts b/packages/db/migrate-images.ts
deleted file mode 100644
index d0a6bd0..0000000
--- a/packages/db/migrate-images.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { config } from 'dotenv';
-import { join, dirname } from 'path';
-import { fileURLToPath } from 'url';
-import pg from 'pg';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-
-// Load .env from root
-config({ path: join(__dirname, '../../.env') });
-
-const { Pool } = pg;
-
-const pool = new Pool({
- connectionString: process.env.POSTGRES_URL,
-});
-
-async function migrate() {
- console.log('🚀 Running image field migrations...\n');
-
- try {
- const client = await pool.connect();
-
- // Add columns to bill table
- console.log('📝 Adding image fields to bill table...');
- await client.query(`
- ALTER TABLE bill
- ADD COLUMN IF NOT EXISTS thumbnail_url TEXT,
- ADD COLUMN IF NOT EXISTS images JSONB DEFAULT '[]'::jsonb;
- `);
- console.log('✅ Bill table updated\n');
-
- // Add columns to government_content table
- console.log('📝 Adding image fields to government_content table...');
- await client.query(`
- ALTER TABLE government_content
- ADD COLUMN IF NOT EXISTS thumbnail_url TEXT,
- ADD COLUMN IF NOT EXISTS images JSONB DEFAULT '[]'::jsonb;
- `);
- console.log('✅ Government content table updated\n');
-
- // Add columns to court_case table
- console.log('📝 Adding image fields to court_case table...');
- await client.query(`
- ALTER TABLE court_case
- ADD COLUMN IF NOT EXISTS thumbnail_url TEXT,
- ADD COLUMN IF NOT EXISTS images JSONB DEFAULT '[]'::jsonb;
- `);
- console.log('✅ Court case table updated\n');
-
- client.release();
-
- console.log('🎉 All migrations completed successfully!');
- console.log('\nNew columns added:');
- console.log(' - thumbnail_url (TEXT) - stores the main image URL');
- console.log(' - images (JSONB) - stores array of image objects with metadata');
-
- } catch (error) {
- console.error('❌ Migration failed:', error);
- process.exit(1);
- } finally {
- await pool.end();
- }
-}
-
-migrate();
diff --git a/packages/db/migrations/add_image_fields.sql b/packages/db/migrations/add_image_fields.sql
deleted file mode 100644
index f9365a0..0000000
--- a/packages/db/migrations/add_image_fields.sql
+++ /dev/null
@@ -1,24 +0,0 @@
--- Add image fields to content tables
--- Migration: Add thumbnailUrl and images fields
-
--- Add to Bill table
-ALTER TABLE bill ADD COLUMN IF NOT EXISTS thumbnail_url TEXT;
-ALTER TABLE bill ADD COLUMN IF NOT EXISTS images JSONB DEFAULT '[]';
-
--- Add to GovernmentContent table
-ALTER TABLE government_content ADD COLUMN IF NOT EXISTS thumbnail_url TEXT;
-ALTER TABLE government_content ADD COLUMN IF NOT EXISTS images JSONB DEFAULT '[]';
-
--- Add to CourtCase table
-ALTER TABLE court_case ADD COLUMN IF NOT EXISTS thumbnail_url TEXT;
-ALTER TABLE court_case ADD COLUMN IF NOT EXISTS images JSONB DEFAULT '[]';
-
--- Add comments for documentation
-COMMENT ON COLUMN bill.thumbnail_url IS 'URL of the thumbnail image for the article';
-COMMENT ON COLUMN bill.images IS 'Array of relevant images for the article with metadata (url, alt, source, sourceUrl)';
-
-COMMENT ON COLUMN government_content.thumbnail_url IS 'URL of the thumbnail image for the article';
-COMMENT ON COLUMN government_content.images IS 'Array of relevant images for the article with metadata (url, alt, source, sourceUrl)';
-
-COMMENT ON COLUMN court_case.thumbnail_url IS 'URL of the thumbnail image for the article';
-COMMENT ON COLUMN court_case.images IS 'Array of relevant images for the article with metadata (url, alt, source, sourceUrl)';
diff --git a/packages/db/run-migration.ts b/packages/db/run-migration.ts
deleted file mode 100644
index 3f7d81d..0000000
--- a/packages/db/run-migration.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import 'dotenv/config';
-import pg from 'pg';
-
-const client = new pg.Client({
- connectionString: process.env.POSTGRES_URL,
-});
-
-async function runMigration() {
- try {
- await client.connect();
- console.log('Connected to database');
-
- // Add article_generations column to all three tables
- const migrations = [
- `ALTER TABLE bill ADD COLUMN IF NOT EXISTS article_generations jsonb DEFAULT '[]'::jsonb`,
- `ALTER TABLE court_case ADD COLUMN IF NOT EXISTS article_generations jsonb DEFAULT '[]'::jsonb`,
- `ALTER TABLE government_content ADD COLUMN IF NOT EXISTS article_generations jsonb DEFAULT '[]'::jsonb`,
- ];
-
- for (const migration of migrations) {
- console.log(`Running: ${migration}`);
- await client.query(migration);
- console.log('✓ Success');
- }
-
- console.log('\n✅ All migrations completed successfully!');
- } catch (error) {
- console.error('❌ Migration failed:', error);
- process.exit(1);
- } finally {
- await client.end();
- }
-}
-
-runMigration();
From 3afe07885d80202038c9e4c57e87c5c0ba27ec80 Mon Sep 17 00:00:00 2001
From: junj-st <68572255+junj-st@users.noreply.github.com>
Date: Mon, 19 Jan 2026 17:01:32 -0800
Subject: [PATCH 3/5] citation changes
---
apps/expo/src/app/article-detail.tsx | 11 +-
apps/expo/src/components/Citations.tsx | 220 ++++++++++++++++++++++++
packages/api/src/router/content.ts | 9 +-
packages/api/src/utils/article-depth.ts | 79 ++++++++-
packages/db/src/schema.ts | 8 +
5 files changed, 319 insertions(+), 8 deletions(-)
create mode 100644 apps/expo/src/components/Citations.tsx
diff --git a/apps/expo/src/app/article-detail.tsx b/apps/expo/src/app/article-detail.tsx
index 590a3a2..8200a35 100644
--- a/apps/expo/src/app/article-detail.tsx
+++ b/apps/expo/src/app/article-detail.tsx
@@ -14,6 +14,7 @@ import { useQuery } from "@tanstack/react-query";
import { Button } from "@acme/ui/button-native";
import { Text, View } from "~/components/Themed";
+import { Citations } from "~/components/Citations";
// import { WireframeWave } from "~/components/WireframeWave";
import {
badges,
@@ -177,7 +178,7 @@ export default function ArticleDetailScreen() {
backgroundColor: theme.card,
borderColor: colors.cyan[700],
marginTop: sp[5],
- marginBottom: sp[20],
+ marginBottom: sp[5],
},
]}
>
@@ -187,6 +188,14 @@ export default function ArticleDetailScreen() {
: content.originalContent}
+
+ {/* Show citations only for AI-generated articles */}
+ {selectedTab === "article" && content.isAIGenerated && content.citations && (
+
+ )}
+
+ {/* Add bottom padding */}
+
{/* Floating action icons on right side */}
diff --git a/apps/expo/src/components/Citations.tsx b/apps/expo/src/components/Citations.tsx
new file mode 100644
index 0000000..b066ba4
--- /dev/null
+++ b/apps/expo/src/components/Citations.tsx
@@ -0,0 +1,220 @@
+import { StyleSheet, TouchableOpacity, Linking } from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+
+import { Text, View } from "./Themed";
+import { colors, rd, sp, typography, useTheme } from "~/styles";
+
+interface Citation {
+ number: number;
+ text: string;
+ url: string;
+ title?: string;
+}
+
+interface CitationsProps {
+ citations: Citation[];
+}
+
+export function Citations({ citations }: CitationsProps) {
+ const { theme } = useTheme();
+
+ if (!citations || citations.length === 0) {
+ return null;
+ }
+
+ const handleCitationPress = (url: string) => {
+ Linking.openURL(url).catch((err) => {
+ console.error("Failed to open URL:", err);
+ });
+ };
+
+ return (
+
+
+
+
+ Sources
+
+
+
+
+ {citations.map((citation, index) => (
+ handleCitationPress(citation.url)}
+ activeOpacity={0.7}
+ >
+
+
+ {citation.number}
+
+
+
+
+
+ {citation.text}
+
+
+ {citation.url.replace(/^https?:\/\//i, "")}
+
+
+
+
+
+ ))}
+
+
+
+
+ Tap any source to verify information
+
+
+
+ );
+}
+
+const localStyles = StyleSheet.create({
+ container: {
+ borderRadius: rd["lg"],
+ borderWidth: 1,
+ marginTop: sp[5],
+ marginBottom: sp[5],
+ overflow: "hidden",
+ },
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: sp[4],
+ paddingVertical: sp[3],
+ },
+ icon: {
+ marginRight: sp[2],
+ },
+ headerText: {
+ fontSize: 13,
+ fontWeight: "600",
+ letterSpacing: 0.5,
+ textTransform: "uppercase",
+ },
+ citation: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: sp[4],
+ paddingVertical: sp[3],
+ gap: sp[3],
+ },
+ citationNumber: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ justifyContent: "center",
+ alignItems: "center",
+ flexShrink: 0,
+ },
+ citationNumberText: {
+ fontSize: 12,
+ fontWeight: "700",
+ },
+ citationContent: {
+ flex: 1,
+ gap: sp[1],
+ },
+ citationText: {
+ fontSize: 14,
+ fontWeight: "500",
+ lineHeight: 18,
+ },
+ citationUrl: {
+ fontSize: 12,
+ fontWeight: "400",
+ },
+ openIcon: {
+ flexShrink: 0,
+ },
+ footer: {
+ paddingHorizontal: sp[4],
+ paddingVertical: sp[2],
+ borderTopWidth: 1,
+ },
+ footerText: {
+ fontSize: 11,
+ fontWeight: "400",
+ textAlign: "center",
+ },
+});
diff --git a/packages/api/src/router/content.ts b/packages/api/src/router/content.ts
index 6b6199c..e1b6557 100644
--- a/packages/api/src/router/content.ts
+++ b/packages/api/src/router/content.ts
@@ -242,7 +242,7 @@ export const contentRouter = {
.where(eq(Bill.id, input.id))
.limit(1);
if (bill.length > 0) {
- const b = bill[0]!;
+ const b = bill[0]! as any;
return {
id: b.id,
title: b.title,
@@ -250,6 +250,7 @@ export const contentRouter = {
type: "bill" as const,
isAIGenerated: !!b.aiGeneratedArticle,
thumbnailUrl: b.thumbnailUrl || undefined,
+ citations: (b.citations as { number: number; text: string; url: string; title?: string }[]) || [],
articleContent: b.aiGeneratedArticle || b.fullText || "No content available",
originalContent: b.fullText || "Full text not available",
};
@@ -262,7 +263,7 @@ export const contentRouter = {
.where(eq(GovernmentContent.id, input.id))
.limit(1);
if (content.length > 0) {
- const c = content[0]!;
+ const c = content[0]! as any;
return {
id: c.id,
title: c.title,
@@ -270,6 +271,7 @@ export const contentRouter = {
type: "general" as const,
isAIGenerated: !!c.aiGeneratedArticle,
thumbnailUrl: c.thumbnailUrl || undefined,
+ citations: (c.citations as { number: number; text: string; url: string; title?: string }[]) || [],
articleContent: c.aiGeneratedArticle || c.fullText || "No content available",
originalContent: c.fullText || "Full text not available",
};
@@ -282,7 +284,7 @@ export const contentRouter = {
.where(eq(CourtCase.id, input.id))
.limit(1);
if (courtCase.length > 0) {
- const c = courtCase[0]!;
+ const c = courtCase[0]! as any;
return {
id: c.id,
title: c.title,
@@ -290,6 +292,7 @@ export const contentRouter = {
type: "case" as const,
isAIGenerated: !!c.aiGeneratedArticle,
thumbnailUrl: c.thumbnailUrl || undefined,
+ citations: (c.citations as { number: number; text: string; url: string; title?: string }[]) || [],
articleContent: c.aiGeneratedArticle || c.fullText || "No content available",
originalContent: c.fullText || "Full text not available",
};
diff --git a/packages/api/src/utils/article-depth.ts b/packages/api/src/utils/article-depth.ts
index 2cb0d62..f38754f 100644
--- a/packages/api/src/utils/article-depth.ts
+++ b/packages/api/src/utils/article-depth.ts
@@ -116,6 +116,49 @@ Include specific data, quotes, and expert perspectives throughout.`,
return prompts[depth];
}
+/**
+ * Extract citations from markdown article text
+ * Looks for a "## Sources" section and parses [1], [2], etc.
+ */
+function extractCitations(articleText: string): {
+ number: number;
+ text: string;
+ url: string;
+ title?: string;
+}[] {
+ const citations: { number: number; text: string; url: string; title?: string }[] = [];
+
+ // Find the Sources section
+ const sourcesMatch = articleText.match(/## Sources\n([\s\S]*?)($|##)/);
+ if (!sourcesMatch || !sourcesMatch[1]) return citations;
+
+ const sourcesSection = sourcesMatch[1];
+
+ // Match citation lines like: [1] Description - URL
+ const citationRegex = /\[(\d+)\]\s*([^-\n]+?)\s*-\s*(https?:\/\/[^\s\n]+)/g;
+ let match;
+
+ while ((match = citationRegex.exec(sourcesSection)) !== null) {
+ const [, number, text, url] = match;
+ if (number && text && url) {
+ citations.push({
+ number: parseInt(number),
+ text: text.trim(),
+ url: url.trim(),
+ });
+ }
+ }
+
+ return citations;
+}
+
+/**
+ * Remove the Sources section from article text (since we'll display citations separately)
+ */
+function removeSourcesSection(articleText: string): string {
+ return articleText.replace(/## Sources\n[\s\S]*?($|(?=## [^S]))/g, '').trim();
+}
+
/**
* Generate an AI article at a specific depth level
*/
@@ -125,7 +168,7 @@ export async function generateArticleAtDepth(
type: string,
url: string,
depth: ArticleDepth,
-): Promise {
+): Promise<{ article: string; citations: { number: number; text: string; url: string; title?: string }[] }> {
const { wordCount, instructions } = getDepthPrompt(depth);
const result = await generateText({
@@ -151,10 +194,32 @@ Full Text: ${fullText.substring(0, 5000)}
- Use 8th-grade reading level language
- Focus on facts and balance - remain objective
+**CRITICAL - Citations:**
+You MUST include inline citations throughout the article to verify claims and facts. Use this format:
+- Add [1], [2], [3] etc. after claims that need verification
+- At the end of the article, add a "## Sources" section with:
+ [1] Brief description of what this source verifies - URL
+ [2] Brief description - URL
+- For bills: cite congress.gov, govtrack.us, official bill pages
+- For court cases: cite official court websites, legal databases
+- For government content: cite whitehouse.gov, official agency sites
+- Always include the original source URL as the first citation
+
+Example:
+The bill proposes $50 billion in funding [1] and has bipartisan support [2].
+
+## Sources
+[1] Bill text and funding details - https://congress.gov/bill/...
+[2] Co-sponsor information - https://govtrack.us/...
+
Write the article now:`,
});
- return result.text.trim();
+ const fullArticle = result.text.trim();
+ const citations = extractCitations(fullArticle);
+ const article = removeSourcesSection(fullArticle);
+
+ return { article, citations };
}
/**
@@ -206,7 +271,7 @@ export async function getOrGenerateArticle(
throw new Error("Cannot generate article: fullText is missing");
}
- const newArticle = await generateArticleAtDepth(
+ const { article: newArticle, citations } = await generateArticleAtDepth(
content.title,
content.fullText,
contentType === "bill"
@@ -228,9 +293,15 @@ export async function getOrGenerateArticle(
},
];
+ // Update both article generations and citations
+ const updateData: any = {
+ articleGenerations: updatedGenerations,
+ citations: citations,
+ };
+
await db
.update(table)
- .set({ articleGenerations: updatedGenerations as any })
+ .set(updateData)
.where(eq(table.id, contentId));
return { content: newArticle, cached: false };
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index 2fc96f6..8a660eb 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -79,6 +79,10 @@ export const GovernmentContent = pgTable("government_content", (t) => ({
.jsonb()
.$type<{ depth: number; content: string; generatedAt: string }[]>()
.default([]), // Cached articles at different depth levels (1-5)
+ citations: t
+ .jsonb()
+ .$type<{ number: number; text: string; url: string; title?: string }[]>()
+ .default([]), // Citations used in AI-generated articles for fact verification
thumbnailUrl: t.text(), // URL of the thumbnail image
images: t
.jsonb()
@@ -124,6 +128,10 @@ export const CourtCase = pgTable("court_case", (t) => ({
.jsonb()
.$type<{ depth: number; content: string; generatedAt: string }[]>()
.default([]), // Cached articles at different depth levels (1-5)
+ citations: t
+ .jsonb()
+ .$type<{ number: number; text: string; url: string; title?: string }[]>()
+ .default([]), // Citations used in AI-generated articles for fact verification
thumbnailUrl: t.text(), // URL of the thumbnail image
images: t
.jsonb()
From a6e8d5fe57c06d72cd7cdb00698cb8233915cfff Mon Sep 17 00:00:00 2001
From: ThatXliner
Date: Mon, 19 Jan 2026 17:09:05 -0800
Subject: [PATCH 4/5] Add Article Depth control and slider support
Add ArticleDepthControl and Slider components to the Expo app and
export entries in packages/ui; add the native
@react-native-community/slider dependency. Wire depth state into the
ArticleDetail screen and fetch article variants at the selected depth,
showing loading/cached status.
Normalize article generation cache timestamps in packages/api to avoid
type inconsistencies and bump AI SDK versions. Also include minor
scraper formatting fixes and a README troubleshooting note.
---
README.md | 5 +
apps/expo/package.json | 1 +
apps/expo/src/app/article-detail.tsx | 29 +++-
.../src/components/ArticleDepthControl.tsx | 131 ++++++++++++++++++
apps/expo/src/components/Slider.tsx | 54 ++++++++
apps/scraper/src/scrapers/congress.ts | 91 +++++++-----
packages/api/package.json | 4 +-
packages/api/src/router/content.ts | 33 +++--
packages/api/src/utils/article-depth.ts | 37 +++--
packages/ui/package.json | 2 +
pnpm-lock.yaml | 30 ++--
11 files changed, 344 insertions(+), 73 deletions(-)
create mode 100644 apps/expo/src/components/ArticleDepthControl.tsx
create mode 100644 apps/expo/src/components/Slider.tsx
diff --git a/README.md b/README.md
index 22260a3..602136a 100644
--- a/README.md
+++ b/README.md
@@ -148,6 +148,11 @@ node-linker=hoisted
In this case, `pnpm clean && pnpm install && cd apps/expo && pnpm ios` before you go back to root and run `pnpm dev`. Maybe open the Xcode project and build from there?
+
+- "Unimplemented component"
+
+You don't have the native module properly installed. `cd apps/expo && pnpm expo install && pnpm run start:ios`
+
## FAQ & Deployment
Just see the original [here](https://github.com/t3-oss/create-t3-turbo#faq) (permalink [here](https://github.com/t3-oss/create-t3-turbo/tree/cf9aefdf46036df0b9a3bec4f08d0f4f2fe54e83?tab=readme-ov-file#faq)).
diff --git a/apps/expo/package.json b/apps/expo/package.json
index 8928505..3ae3178 100644
--- a/apps/expo/package.json
+++ b/apps/expo/package.json
@@ -18,6 +18,7 @@
"@better-auth/expo": "catalog:",
"@expo/vector-icons": "^15.0.3",
"@legendapp/list": "^2.0.11",
+ "@react-native-community/slider": "^5.0.1",
"@tanstack/react-query": "catalog:",
"@trpc/client": "catalog:",
"@trpc/server": "catalog:",
diff --git a/apps/expo/src/app/article-detail.tsx b/apps/expo/src/app/article-detail.tsx
index 8200a35..dea5a00 100644
--- a/apps/expo/src/app/article-detail.tsx
+++ b/apps/expo/src/app/article-detail.tsx
@@ -13,6 +13,7 @@ import { useQuery } from "@tanstack/react-query";
import { Button } from "@acme/ui/button-native";
+import { ArticleDepthControl } from "~/components/ArticleDepthControl";
import { Text, View } from "~/components/Themed";
import { Citations } from "~/components/Citations";
// import { WireframeWave } from "~/components/WireframeWave";
@@ -58,6 +59,7 @@ export default function ArticleDetailScreen() {
const [selectedTab, setSelectedTab] = useState<"article" | "original">(
"article",
);
+ const [depth, setDepth] = useState<1 | 2 | 3 | 4 | 5>(3);
// Fetch content from tRPC
const {
@@ -69,6 +71,19 @@ export default function ArticleDetailScreen() {
enabled: !!id,
});
+ // Fetch article at the selected depth
+ const {
+ data: articleAtDepth,
+ isLoading: isLoadingDepth,
+ } = useQuery({
+ ...trpc.content.getArticleAtDepth.queryOptions({
+ id,
+ type: content?.type === "bill" ? "bill" : content?.type === "case" ? "case" : "general",
+ depth,
+ }),
+ enabled: !!id && !!content,
+ });
+
// Handle loading state
if (isLoading) {
return (
@@ -171,6 +186,15 @@ export default function ArticleDetailScreen() {
{content.description}
+ {/* Article Depth Control */}
+
+
{selectedTab === "article"
- ? content.articleContent
+ ? (articleAtDepth?.content || content.articleContent)
: content.originalContent}
@@ -267,6 +291,9 @@ const localStyles = StyleSheet.create({
articleDescription: {
marginBottom: sp[4],
},
+ depthControl: {
+ marginBottom: sp[4],
+ },
floatingActions: {
position: "absolute",
top: "50%",
diff --git a/apps/expo/src/components/ArticleDepthControl.tsx b/apps/expo/src/components/ArticleDepthControl.tsx
new file mode 100644
index 0000000..4d80b9b
--- /dev/null
+++ b/apps/expo/src/components/ArticleDepthControl.tsx
@@ -0,0 +1,131 @@
+import * as React from "react";
+import { View, Text, StyleSheet, ActivityIndicator, useColorScheme } from "react-native";
+import { SliderComponent } from "./Slider";
+import { darkTheme, lightTheme, spacing, fontSize, fontWeight, radius } from "@acme/ui/theme-tokens";
+
+export interface ArticleDepthControlProps {
+ value: 1 | 2 | 3 | 4 | 5;
+ onValueChange: (value: 1 | 2 | 3 | 4 | 5) => void;
+ isGenerating?: boolean;
+ isCached?: boolean;
+ style?: any;
+}
+
+const DEPTH_LABELS = {
+ 1: "Brief",
+ 2: "Summary",
+ 3: "Standard",
+ 4: "Detailed",
+ 5: "Expert",
+} as const;
+
+const DEPTH_DESCRIPTIONS = {
+ 1: "Quick overview (100-200 words)",
+ 2: "Essential facts (300-400 words)",
+ 3: "Balanced coverage (500-700 words)",
+ 4: "Comprehensive (800-1000 words)",
+ 5: "In-depth analysis (1200+ words)",
+} as const;
+
+export function ArticleDepthControl({
+ value,
+ onValueChange,
+ isGenerating = false,
+ isCached = false,
+ style,
+}: ArticleDepthControlProps) {
+ const colorScheme = useColorScheme();
+ const theme = colorScheme === "dark" ? darkTheme : lightTheme;
+
+ return (
+
+
+
+
+ Article Depth: {DEPTH_LABELS[value]}
+
+
+ {DEPTH_DESCRIPTIONS[value]}
+
+
+ {isGenerating && (
+
+
+
+ Generating...
+
+
+ )}
+ {isCached && !isGenerating && (
+
+ ✓ Cached
+
+ )}
+
+
+
+ onValueChange(Math.round(val) as 1 | 2 | 3 | 4 | 5)}
+ disabled={isGenerating}
+ style={styles.slider}
+ />
+
+ Brief
+ Summary
+ Standard
+ Detailed
+ Expert
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ padding: spacing[4] * 16,
+ borderRadius: radius.lg * 16,
+ borderWidth: 1,
+ gap: spacing[4] * 16,
+ },
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ title: {
+ fontSize: fontSize.lg,
+ fontWeight: fontWeight.semibold,
+ },
+ description: {
+ fontSize: fontSize.sm,
+ marginTop: spacing[1] * 16,
+ },
+ statusContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing[2] * 16,
+ },
+ statusText: {
+ fontSize: fontSize.sm,
+ fontWeight: fontWeight.medium,
+ },
+ sliderContainer: {
+ gap: spacing[2] * 16,
+ },
+ slider: {
+ width: "100%",
+ },
+ labels: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ paddingHorizontal: spacing[1] * 16,
+ },
+ label: {
+ fontSize: fontSize.xs,
+ },
+});
diff --git a/apps/expo/src/components/Slider.tsx b/apps/expo/src/components/Slider.tsx
new file mode 100644
index 0000000..3892f27
--- /dev/null
+++ b/apps/expo/src/components/Slider.tsx
@@ -0,0 +1,54 @@
+import * as React from "react";
+import { View, StyleSheet, useColorScheme } from "react-native";
+import Slider from "@react-native-community/slider";
+import { darkTheme, lightTheme } from "@acme/ui/theme-tokens";
+
+export interface SliderProps {
+ min?: number;
+ max?: number;
+ step?: number;
+ value?: number;
+ onValueChange?: (value: number) => void;
+ disabled?: boolean;
+ style?: any;
+}
+
+export function SliderComponent({
+ min = 0,
+ max = 100,
+ step = 1,
+ value = 0,
+ onValueChange,
+ disabled = false,
+ style,
+}: SliderProps) {
+ const colorScheme = useColorScheme();
+ const theme = colorScheme === "dark" ? darkTheme : lightTheme;
+
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ width: "100%",
+ },
+ slider: {
+ width: "100%",
+ height: 40,
+ },
+});
diff --git a/apps/scraper/src/scrapers/congress.ts b/apps/scraper/src/scrapers/congress.ts
index ecf1907..c3a9ad6 100644
--- a/apps/scraper/src/scrapers/congress.ts
+++ b/apps/scraper/src/scrapers/congress.ts
@@ -1,9 +1,10 @@
-import { PlaywrightCrawler, Dataset } from 'crawlee';
-import { upsertBill } from '../utils/db/operations.js';
-import { resetMetrics, printMetricsSummary } from '../utils/db/metrics.js';
+import { Dataset, PlaywrightCrawler } from "crawlee";
+
+import { printMetricsSummary, resetMetrics } from "../utils/db/metrics.js";
+import { upsertBill } from "../utils/db/operations.js";
export async function scrapeCongress() {
- console.log('Starting Congress.gov scraper...');
+ console.log("Starting Congress.gov scraper...");
// Reset metrics for this scraper run
resetMetrics();
@@ -13,26 +14,28 @@ export async function scrapeCongress() {
log.info(`Scraping ${request.loadedUrl}`);
// Wait for the page to load
- await page.waitForLoadState('networkidle');
+ await page.waitForLoadState("networkidle");
// Handle the browse/listing page
- if (request.url.includes('/browse')) {
+ if (request.url.includes("/browse")) {
// Wait for the page content to load
await page.waitForTimeout(2000);
// Extract bill links
- const billLinks = await page.$$eval('a[href*="/bill/"]', (links) =>
- links
- .map((link) => (link as HTMLAnchorElement).href)
- .filter((href) => /\/bill\/\d+/.test(href))
- .slice(0, 20) // Limit to first 20 bills for testing
+ const billLinks = await page.$$eval(
+ 'a[href*="/bill/"]',
+ (links) =>
+ links
+ .map((link) => (link as HTMLAnchorElement).href)
+ .filter((href) => /\/bill\/\d+/.test(href))
+ .slice(0, 20), // Limit to first 20 bills for testing
);
log.info(`Found ${billLinks.length} bill links from Congress.gov`);
// Return bill links to be processed
await Dataset.pushData({
- type: 'congressBillLinks',
+ type: "congressBillLinks",
links: [...new Set(billLinks)], // Remove duplicates
});
}
@@ -44,30 +47,41 @@ export async function scrapeCongress() {
// Extract bill number
const billNumber = await page
- .locator('.bill-number, h1')
+ .locator(".bill-number, h1")
.first()
.textContent()
.then((text) => {
- const match = text?.match(/([HS]\.\s?(?:R\.|J\.\s?Res\.|Con\.\s?Res\.|Res\.)\s?\d+)/i);
- return match ? match[1]!.trim() : text?.trim().split(' ')[0] || '';
+ const match = text?.match(
+ /([HS]\.\s?(?:R\.|J\.\s?Res\.|Con\.\s?Res\.|Res\.)\s?\d+)/i,
+ );
+ return match
+ ? match[1]!.trim()
+ : text?.trim().split(" ")[0] || "";
});
// Extract title
const title = await page
- .locator('.bill-title, h1')
+ .locator(".bill-title, h1")
.first()
.textContent()
.then((text) => {
// Remove bill number from title if present
- return text?.replace(/[HS]\.\s?(?:R\.|J\.\s?Res\.|Con\.\s?Res\.|Res\.)\s?\d+/i, '').trim() || '';
+ return (
+ text
+ ?.replace(
+ /[HS]\.\s?(?:R\.|J\.\s?Res\.|Con\.\s?Res\.|Res\.)\s?\d+/i,
+ "",
+ )
+ .trim() || ""
+ );
});
// Extract sponsor
const sponsor = await page
- .locator('text=/Sponsor:/i')
- .locator('..')
+ .locator("text=/Sponsor:/i")
+ .locator("..")
.textContent()
- .then((text) => text?.replace(/Sponsor:/i, '').trim())
+ .then((text) => text?.replace(/Sponsor:/i, "").trim())
.catch(() => undefined);
// Extract status
@@ -76,25 +90,34 @@ export async function scrapeCongress() {
.first()
.textContent()
.then((text) => text?.trim())
- .catch(() => 'Unknown');
+ .catch(() => "Unknown");
// Extract introduced date
const introducedDateStr = await page
- .locator('text=/Introduced:/i')
- .locator('..')
+ .locator("text=/Introduced:/i")
+ .locator("..")
.textContent()
- .then((text) => text?.replace(/Introduced:/i, '').trim())
+ .then((text) => text?.replace(/Introduced:/i, "").trim())
.catch(() => undefined);
- const introducedDate = introducedDateStr ? new Date(introducedDateStr) : undefined;
+ const introducedDate = introducedDateStr
+ ? new Date(introducedDateStr)
+ : undefined;
// Extract congress number from URL or page
- const congressMatch = request.url.match(/\/(\d+)(?:th|st|nd|rd)?-congress/i) ||
- await page.textContent('body').then(text => text?.match(/(\d+)(?:th|st|nd|rd)\s+Congress/i));
- const congress = congressMatch ? parseInt(congressMatch[1]!) : undefined;
+ const congressMatch =
+ request.url.match(/\/(\d+)(?:th|st|nd|rd)?-congress/i) ||
+ (await page
+ .textContent("body")
+ .then((text) => text?.match(/(\d+)(?:th|st|nd|rd)\s+Congress/i)));
+ const congress = congressMatch
+ ? parseInt(congressMatch[1]!)
+ : undefined;
// Extract chamber from bill number
- const chamber = billNumber.toLowerCase().startsWith('h.') ? 'House' : 'Senate';
+ const chamber = billNumber.toLowerCase().startsWith("h.")
+ ? "House"
+ : "Senate";
// Extract summary
const summary = await page
@@ -124,7 +147,7 @@ export async function scrapeCongress() {
summary,
fullText,
url: request.url,
- sourceWebsite: 'congress.gov',
+ sourceWebsite: "congress.gov",
};
log.info(`Scraped bill from Congress.gov: ${billNumber} - ${title}`);
@@ -142,19 +165,21 @@ export async function scrapeCongress() {
});
// Start with the browse page
- await crawler.run(['https://www.congress.gov/browse']);
+ await crawler.run(["https://www.congress.gov/browse"]);
// Get the bill links from the dataset
const dataset = await Dataset.open();
const data = await dataset.getData();
- const billLinksData = data.items.find((item: any) => item.type === 'congressBillLinks');
+ const billLinksData = data.items.find(
+ (item: any) => item.type === "congressBillLinks",
+ );
if (billLinksData && Array.isArray(billLinksData.links)) {
// Now scrape each bill page
await crawler.run(billLinksData.links);
}
- console.log('Congress.gov scraper completed');
+ console.log("Congress.gov scraper completed");
// Print metrics summary
printMetricsSummary("Congress.gov");
diff --git a/packages/api/package.json b/packages/api/package.json
index 0f07420..b3adf40 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -21,9 +21,9 @@
"@acme/auth": "workspace:*",
"@acme/db": "workspace:*",
"@acme/validators": "workspace:*",
- "@ai-sdk/openai": "^2.0.57",
+ "@ai-sdk/openai": "^2.0.89",
"@trpc/server": "catalog:",
- "ai": "^5.0.82",
+ "ai": "^6.0.41",
"superjson": "2.2.2",
"zod": "catalog:"
},
diff --git a/packages/api/src/router/content.ts b/packages/api/src/router/content.ts
index e1b6557..35a12f3 100644
--- a/packages/api/src/router/content.ts
+++ b/packages/api/src/router/content.ts
@@ -5,17 +5,17 @@ import { desc, eq, sql } from "@acme/db";
import { db } from "@acme/db/client";
import { Bill, CourtCase, GovernmentContent } from "@acme/db/schema";
+import type { ArticleDepth } from "../utils/article-depth";
import { publicProcedure } from "../trpc";
import {
- getOrGenerateArticle,
DEPTH_DESCRIPTIONS,
- type ArticleDepth,
+ getOrGenerateArticle,
} from "../utils/article-depth";
// Helper function to get thumbnail URL for any content
export async function getThumbnailForContent(
- id: string,
- type: "bill" | "case" | "general"
+ id: string,
+ type: "bill" | "case" | "general",
): Promise {
try {
if (type === "bill") {
@@ -273,6 +273,8 @@ export const contentRouter = {
thumbnailUrl: c.thumbnailUrl || undefined,
citations: (c.citations as { number: number; text: string; url: string; title?: string }[]) || [],
articleContent: c.aiGeneratedArticle || c.fullText || "No content available",
+ articleContent:
+ c.aiGeneratedArticle || c.fullText || "No content available",
originalContent: c.fullText || "Full text not available",
};
}
@@ -292,8 +294,15 @@ export const contentRouter = {
type: "case" as const,
isAIGenerated: !!c.aiGeneratedArticle,
thumbnailUrl: c.thumbnailUrl || undefined,
+<<<<<<< Updated upstream
citations: (c.citations as { number: number; text: string; url: string; title?: string }[]) || [],
articleContent: c.aiGeneratedArticle || c.fullText || "No content available",
+||||||| Stash base
+ articleContent: c.aiGeneratedArticle || c.fullText || "No content available",
+=======
+ articleContent:
+ c.aiGeneratedArticle || c.fullText || "No content available",
+>>>>>>> Stashed changes
originalContent: c.fullText || "Full text not available",
};
}
@@ -364,16 +373,20 @@ export const contentRouter = {
return { cachedDepths: [] };
}
- const generations = (content.articleGenerations as {
- depth: number;
- content: string;
- generatedAt: string;
- }[]) || [];
+ const generations =
+ (content.articleGenerations as {
+ depth: number;
+ content: string;
+ generatedAt: string;
+ }[]) || [];
return {
cachedDepths: generations.map((g) => ({
depth: g.depth as ArticleDepth,
- generatedAt: g.generatedAt,
+ generatedAt:
+ typeof g.generatedAt === "string"
+ ? g.generatedAt
+ : "new Date(g.generatedAt).toISOString()",
})),
};
}),
diff --git a/packages/api/src/utils/article-depth.ts b/packages/api/src/utils/article-depth.ts
index f38754f..ea6f57d 100644
--- a/packages/api/src/utils/article-depth.ts
+++ b/packages/api/src/utils/article-depth.ts
@@ -1,5 +1,6 @@
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
+
import { eq } from "@acme/db";
import { db } from "@acme/db/client";
import { Bill, CourtCase, GovernmentContent } from "@acme/db/schema";
@@ -172,7 +173,6 @@ export async function generateArticleAtDepth(
const { wordCount, instructions } = getDepthPrompt(depth);
const result = await generateText({
- // @ts-ignore - AI SDK v5 type compatibility issue, works at runtime
model: openai("gpt-4o-mini"),
prompt: `You are an expert at making government and legal content accessible and engaging for everyday people. Transform the following ${type} into a well-structured, markdown-formatted article.
@@ -250,12 +250,20 @@ export async function getOrGenerateArticle(
}
// Check if article at this depth exists in cache
- const generations = (content.articleGenerations as {
- depth: number;
- content: string;
- generatedAt: string;
- }[]) || [];
-
+ // Normalize the generations data to ensure generatedAt is always a string
+ const generations = ((content.articleGenerations ?? []) as any[]).map(
+ (gen) => ({
+ depth: gen.depth,
+ content: gen.content,
+ generatedAt:
+ typeof gen.generatedAt === "string"
+ ? gen.generatedAt
+ : gen.generatedAt instanceof Date
+ ? gen.generatedAt.toISOString()
+ : String(gen.generatedAt),
+ }),
+ );
+
const cached = generations.find((gen) => gen.depth === depth);
if (cached) {
return { content: cached.content, cached: true };
@@ -274,14 +282,13 @@ export async function getOrGenerateArticle(
const { article: newArticle, citations } = await generateArticleAtDepth(
content.title,
content.fullText,
- contentType === "bill"
- ? "bill"
- : contentType === "case"
- ? "court case"
- : (content as any).type || "government content",
- (content as any).url || "",
+ contentType === "bill" || contentType === "case"
+ ? contentType
+ : "government content",
+ content.url || "",
depth,
);
+ console.log(generations, newArticle);
// Cache the generated article
const updatedGenerations = [
@@ -289,7 +296,7 @@ export async function getOrGenerateArticle(
{
depth,
content: newArticle,
- generatedAt: new Date().toISOString(),
+ generatedAt: " new Date().toISOString()",
},
];
@@ -315,7 +322,7 @@ export async function preGenerateAllDepths(
contentType: "bill" | "case" | "general",
): Promise {
const depths: ArticleDepth[] = [1, 2, 3, 4, 5];
-
+
for (const depth of depths) {
try {
await getOrGenerateArticle(contentId, contentType, depth);
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 6d316a5..ca35e07 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -12,6 +12,8 @@
"./input": "./src/input.tsx",
"./label": "./src/label.tsx",
"./separator": "./src/separator.tsx",
+ "./slider": "./src/slider.tsx",
+ "./article-depth-control": "./src/article-depth-control.tsx",
"./theme": "./src/theme.tsx",
"./theme-tokens": "./src/theme-tokens.ts",
"./toast": "./src/toast.tsx"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1da6070..1b2f0d8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -105,6 +105,9 @@ importers:
'@legendapp/list':
specifier: ^2.0.11
version: 2.0.11(react-native@0.81.4(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1))(react@19.1.1)
+ '@react-native-community/slider':
+ specifier: ^5.0.1
+ version: 5.0.1
'@tanstack/react-query':
specifier: 'catalog:'
version: 5.90.2(react@19.1.1)
@@ -119,7 +122,7 @@ importers:
version: 11.6.0(@tanstack/react-query@5.90.2(react@19.1.1))(@trpc/client@11.6.0(@trpc/server@11.6.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.6.0(typescript@5.9.3))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.3)
better-auth:
specifier: 'catalog:'
- version: 1.4.0-beta.9(better-sqlite3@12.2.0)(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ version: 1.4.0-beta.9(better-sqlite3@12.2.0)(next@15.5.4(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
expo:
specifier: ~54.0.13
version: 54.0.13(@babel/core@7.28.6)(@expo/metro-runtime@6.1.1)(bufferutil@4.0.8)(expo-router@6.0.12)(graphql@15.8.0)(react-native@0.81.4(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1))(react@19.1.1)
@@ -370,8 +373,6 @@ importers:
specifier: ~5.9.3
version: 5.9.3
- apps/scraper-python: {}
-
packages/api:
dependencies:
'@acme/auth':
@@ -384,14 +385,14 @@ importers:
specifier: workspace:*
version: link:../validators
'@ai-sdk/openai':
- specifier: ^2.0.57
- version: 2.0.57(zod@4.1.12)
+ specifier: ^2.0.89
+ version: 2.0.89(zod@4.1.12)
'@trpc/server':
specifier: 'catalog:'
version: 11.6.0(typescript@5.9.3)
ai:
- specifier: ^5.0.82
- version: 5.0.82(zod@4.1.12)
+ specifier: ^6.0.41
+ version: 6.0.41(zod@4.1.12)
superjson:
specifier: 2.2.2
version: 2.2.2
@@ -3460,6 +3461,9 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+ '@react-native-community/slider@5.0.1':
+ resolution: {integrity: sha512-K3JRWkIW4wQ79YJ6+BPZzp1SamoikxfPRw7Yw4B4PElEQmqZFrmH9M5LxvIo460/3QSrZF/wCgi3qizJt7g/iw==}
+
'@react-native/assets-registry@0.81.4':
resolution: {integrity: sha512-AMcDadefBIjD10BRqkWw+W/VdvXEomR6aEZ0fhQRAv7igrBzb4PTn4vHKYg+sUK0e3wa74kcMy2DLc/HtnGcMA==}
engines: {node: '>= 20.19.4'}
@@ -11196,7 +11200,7 @@ snapshots:
'@expo/json-file': 10.0.7
'@react-native/normalize-colors': 0.81.4
debug: 4.4.3
- expo: 54.0.13(@babel/core@7.28.6)(@expo/metro-runtime@6.1.1)(bufferutil@4.0.8)(expo-router@6.0.12)(graphql@15.8.0)(react-native@0.82.0(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1)(utf-8-validate@6.0.4))(react@19.1.1)(utf-8-validate@6.0.4)
+ expo: 54.0.13(@babel/core@7.28.6)(@expo/metro-runtime@6.1.1)(bufferutil@4.0.8)(expo-router@6.0.12)(graphql@15.8.0)(react-native@0.81.4(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1))(react@19.1.1)
resolve-from: 5.0.0
semver: 7.7.3
xml2js: 0.6.0
@@ -12462,6 +12466,8 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
+ '@react-native-community/slider@5.0.1': {}
+
'@react-native/assets-registry@0.81.4': {}
'@react-native/assets-registry@0.82.0': {}
@@ -13608,7 +13614,7 @@ snapshots:
resolve-from: 5.0.0
optionalDependencies:
'@babel/runtime': 7.28.4
- expo: 54.0.13(@babel/core@7.28.6)(@expo/metro-runtime@6.1.1)(bufferutil@4.0.8)(expo-router@6.0.12)(graphql@15.8.0)(react-native@0.82.0(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1)(utf-8-validate@6.0.4))(react@19.1.1)(utf-8-validate@6.0.4)
+ expo: 54.0.13(@babel/core@7.28.6)(@expo/metro-runtime@6.1.1)(bufferutil@4.0.8)(expo-router@6.0.12)(graphql@15.8.0)(react-native@0.81.4(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1))(react@19.1.1)
transitivePeerDependencies:
- '@babel/core'
- supports-color
@@ -14914,7 +14920,7 @@ snapshots:
expo-crypto@15.0.7(expo@54.0.13):
dependencies:
base64-js: 1.5.1
- expo: 54.0.13(@babel/core@7.28.6)(@expo/metro-runtime@6.1.1)(bufferutil@4.0.8)(expo-router@6.0.12)(graphql@15.8.0)(react-native@0.82.0(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1)(utf-8-validate@6.0.4))(react@19.1.1)(utf-8-validate@6.0.4)
+ expo: 54.0.13(@babel/core@7.28.6)(@expo/metro-runtime@6.1.1)(bufferutil@4.0.8)(expo-router@6.0.12)(graphql@15.8.0)(react-native@0.81.4(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1))(react@19.1.1)
expo-dev-client@6.0.15(expo@54.0.13):
dependencies:
@@ -14978,7 +14984,7 @@ snapshots:
expo-keep-awake@15.0.7(expo@54.0.13)(react@19.1.1):
dependencies:
- expo: 54.0.13(@babel/core@7.28.6)(@expo/metro-runtime@6.1.1)(bufferutil@4.0.8)(expo-router@6.0.12)(graphql@15.8.0)(react-native@0.82.0(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1)(utf-8-validate@6.0.4))(react@19.1.1)(utf-8-validate@6.0.4)
+ expo: 54.0.13(@babel/core@7.28.6)(@expo/metro-runtime@6.1.1)(bufferutil@4.0.8)(expo-router@6.0.12)(graphql@15.8.0)(react-native@0.81.4(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1))(react@19.1.1)
react: 19.1.1
expo-linear-gradient@15.0.8(expo@54.0.13)(react-native@0.81.4(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1))(react@19.1.1):
@@ -15123,7 +15129,7 @@ snapshots:
expo-secure-store@15.0.7(expo@54.0.13):
dependencies:
- expo: 54.0.13(@babel/core@7.28.6)(@expo/metro-runtime@6.1.1)(bufferutil@4.0.8)(expo-router@6.0.12)(graphql@15.8.0)(react-native@0.82.0(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1)(utf-8-validate@6.0.4))(react@19.1.1)(utf-8-validate@6.0.4)
+ expo: 54.0.13(@babel/core@7.28.6)(@expo/metro-runtime@6.1.1)(bufferutil@4.0.8)(expo-router@6.0.12)(graphql@15.8.0)(react-native@0.81.4(@babel/core@7.28.6)(@types/react@19.1.12)(bufferutil@4.0.8)(react@19.1.1))(react@19.1.1)
expo-server@1.0.1: {}
From a2d753601f74b40b0d0bcef4afe61fa29997cd98 Mon Sep 17 00:00:00 2001
From: ThatXliner
Date: Mon, 19 Jan 2026 17:11:57 -0800
Subject: [PATCH 5/5] missed some
---
packages/api/src/router/content.ts | 11 ++---------
1 file changed, 2 insertions(+), 9 deletions(-)
diff --git a/packages/api/src/router/content.ts b/packages/api/src/router/content.ts
index 35a12f3..858be39 100644
--- a/packages/api/src/router/content.ts
+++ b/packages/api/src/router/content.ts
@@ -252,6 +252,7 @@ export const contentRouter = {
thumbnailUrl: b.thumbnailUrl || undefined,
citations: (b.citations as { number: number; text: string; url: string; title?: string }[]) || [],
articleContent: b.aiGeneratedArticle || b.fullText || "No content available",
+
originalContent: b.fullText || "Full text not available",
};
}
@@ -273,8 +274,6 @@ export const contentRouter = {
thumbnailUrl: c.thumbnailUrl || undefined,
citations: (c.citations as { number: number; text: string; url: string; title?: string }[]) || [],
articleContent: c.aiGeneratedArticle || c.fullText || "No content available",
- articleContent:
- c.aiGeneratedArticle || c.fullText || "No content available",
originalContent: c.fullText || "Full text not available",
};
}
@@ -294,15 +293,9 @@ export const contentRouter = {
type: "case" as const,
isAIGenerated: !!c.aiGeneratedArticle,
thumbnailUrl: c.thumbnailUrl || undefined,
-<<<<<<< Updated upstream
+
citations: (c.citations as { number: number; text: string; url: string; title?: string }[]) || [],
articleContent: c.aiGeneratedArticle || c.fullText || "No content available",
-||||||| Stash base
- articleContent: c.aiGeneratedArticle || c.fullText || "No content available",
-=======
- articleContent:
- c.aiGeneratedArticle || c.fullText || "No content available",
->>>>>>> Stashed changes
originalContent: c.fullText || "Full text not available",
};
}