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.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.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.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.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 -{item.title} { - e.currentTarget.src = "/images/default-government.jpg"; - }} -/> -``` - -## Performance Tips - -1. **Lazy loading**: Use native lazy loading for images - ```tsx - {title} - ``` - -2. **Responsive images**: Add appropriate sizing classes - ```tsx - {title} - ``` - -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", }; }