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/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/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 590a3a2..dea5a00 100644 --- a/apps/expo/src/app/article-detail.tsx +++ b/apps/expo/src/app/article-detail.tsx @@ -13,7 +13,9 @@ 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"; import { badges, @@ -57,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 { @@ -68,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 ( @@ -170,6 +186,15 @@ export default function ArticleDetailScreen() { {content.description} + {/* Article Depth Control */} + + {selectedTab === "article" - ? content.articleContent + ? (articleAtDepth?.content || content.articleContent) : content.originalContent} + + {/* Show citations only for AI-generated articles */} + {selectedTab === "article" && content.isAIGenerated && content.citations && ( + + )} + + {/* Add bottom padding */} + {/* Floating action icons on right side */} @@ -258,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/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/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 59f148b..b3adf40 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.89", "@trpc/server": "catalog:", + "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 bcbb84e..858be39 100644 --- a/packages/api/src/router/content.ts +++ b/packages/api/src/router/content.ts @@ -5,12 +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 { + DEPTH_DESCRIPTIONS, + 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") { @@ -237,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, @@ -245,7 +250,9 @@ 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", }; } @@ -257,7 +264,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, @@ -265,6 +272,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", }; @@ -277,7 +285,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, @@ -285,6 +293,8 @@ 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", }; @@ -292,4 +302,85 @@ 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: + typeof g.generatedAt === "string" + ? g.generatedAt + : "new Date(g.generatedAt).toISOString()", + })), + }; + }), } 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..ea6f57d --- /dev/null +++ b/packages/api/src/utils/article-depth.ts @@ -0,0 +1,334 @@ +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]; +} + +/** + * 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 + */ +export async function generateArticleAtDepth( + title: string, + fullText: string, + type: string, + url: string, + depth: ArticleDepth, +): Promise<{ article: string; citations: { number: number; text: string; url: string; title?: string }[] }> { + const { wordCount, instructions } = getDepthPrompt(depth); + + const result = await generateText({ + 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 + +**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:`, + }); + + const fullArticle = result.text.trim(); + const citations = extractCitations(fullArticle); + const article = removeSourcesSection(fullArticle); + + return { article, citations }; +} + +/** + * 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 + // 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 }; + } + + // 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 { article: newArticle, citations } = await generateArticleAtDepth( + content.title, + content.fullText, + contentType === "bill" || contentType === "case" + ? contentType + : "government content", + content.url || "", + depth, + ); + console.log(generations, newArticle); + + // Cache the generated article + const updatedGenerations = [ + ...generations, + { + depth, + content: newArticle, + generatedAt: " new Date().toISOString()", + }, + ]; + + // Update both article generations and citations + const updateData: any = { + articleGenerations: updatedGenerations, + citations: citations, + }; + + await db + .update(table) + .set(updateData) + .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/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/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/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 e852c81..4b11823 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1768848737753, "tag": "0000_abnormal_the_spike", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "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/src/schema.ts b/packages/db/src/schema.ts index 299af10..8a660eb 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() @@ -70,7 +74,15 @@ 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) + 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() @@ -111,7 +123,15 @@ 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) + 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() 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/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 1fb79a1..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) @@ -370,8 +373,6 @@ importers: specifier: ~5.9.3 version: 5.9.3 - apps/scraper-python: {} - packages/api: dependencies: '@acme/auth': @@ -383,9 +384,15 @@ importers: '@acme/validators': specifier: workspace:* version: link:../validators + '@ai-sdk/openai': + 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: ^6.0.41 + version: 6.0.41(zod@4.1.12) superjson: specifier: 2.2.2 version: 2.2.2 @@ -3454,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'} @@ -11190,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 @@ -12456,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': {} @@ -13602,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 @@ -14908,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: @@ -14972,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): @@ -15117,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: {}