Files
PocketVeto/frontend/app/settings/page.tsx
Jack Levy 7e5c5b473e feat: API optimizations — quota batching, ETags, caching, async sponsor (v0.9.7)
Nine efficiency improvements across the data pipeline:

1. NewsAPI OR batching (news_service.py + news_fetcher.py)
   - Combine up to 4 bills per NewsAPI call using OR query syntax
   - NEWSAPI_BATCH_SIZE=4 means ~4× effective daily quota (100→400 bill-fetches)
   - fetch_news_for_bill_batch task; fetch_news_for_active_bills queues batches

2. Google News RSS cache (news_service.py)
   - 2-hour Redis cache shared between news_fetcher and trend_scorer
   - Eliminates duplicate RSS hits when both workers run against same bill
   - clear_gnews_cache() admin helper + admin endpoint

3. pytrends keyword batching (trends_service.py + trend_scorer.py)
   - Compare up to 5 bills per pytrends call instead of 1
   - get_trends_scores_batch() returns scores in original order
   - Reduces pytrends calls by ~5× and associated rate-limit risk

4. GovInfo ETags (govinfo_api.py + document_fetcher.py)
   - If-None-Match conditional GET; DocumentUnchangedError on HTTP 304
   - ETags stored in Redis (30-day TTL) keyed by MD5(url)
   - document_fetcher catches DocumentUnchangedError → {"status": "unchanged"}

5. Anthropic prompt caching (llm_service.py)
   - cache_control: {type: ephemeral} on system messages in AnthropicProvider
   - Caches the ~700-token system prompt server-side; ~50% cost reduction on
     repeated calls within the 5-minute cache window

6. Async sponsor fetch (congress_poller.py)
   - New fetch_sponsor_for_bill Celery task replaces blocking get_bill_detail()
     inline in poll loop
   - Bills saved immediately with sponsor_id=None; sponsor linked async
   - Removes 0.25s sleep per new bill from poll hot path

7. Skip doc fetch for procedural actions (congress_poller.py)
   - _DOC_PRODUCING_CATEGORIES = {vote, committee_report, presidential, ...}
   - fetch_bill_documents only enqueued when action is likely to produce
     new GovInfo text (saves ~60–70% of unnecessary document fetch attempts)

8. Adaptive poll frequency (congress_poller.py)
   - _is_congress_off_hours(): weekends + before 9AM / after 9PM EST
   - Skips poll if off-hours AND last poll < 1 hour ago
   - Prevents wasteful polling when Congress is not in session

9. Admin panel additions (admin.py + settings/page.tsx + api.ts)
   - GET /api/admin/newsapi-quota → remaining calls today
   - POST /api/admin/clear-gnews-cache → flush RSS cache
   - Settings page shows NewsAPI quota remaining (amber if < 10)
   - "Clear Google News Cache" button in Manual Controls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 16:50:51 -04:00

927 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Settings,
Cpu,
RefreshCw,
CheckCircle,
XCircle,
Play,
Users,
Trash2,
ShieldCheck,
ShieldOff,
BarChart3,
Bell,
Shield,
Zap,
ChevronDown,
ChevronRight,
Wrench,
} from "lucide-react";
import Link from "next/link";
import { settingsAPI, adminAPI, notificationsAPI, type AdminUser, type LLMModel, type ApiHealthResult } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
const LLM_PROVIDERS = [
{
value: "openai",
label: "OpenAI",
hint: "Requires OPENAI_API_KEY in .env",
rateNote: "Free: 3 RPM · Paid tier 1: 500 RPM",
modelNote: "Recommended: gpt-4o-mini — excellent JSON quality at ~10× lower cost than gpt-4o",
},
{
value: "anthropic",
label: "Anthropic (Claude)",
hint: "Requires ANTHROPIC_API_KEY in .env",
rateNote: "Tier 1: 50 RPM · Tier 2: 1,000 RPM",
modelNote: "Recommended: claude-sonnet-4-6 — matches Opus quality at ~5× lower cost",
},
{
value: "gemini",
label: "Google Gemini",
hint: "Requires GEMINI_API_KEY in .env",
rateNote: "Free: 15 RPM · Paid: 2,000 RPM",
modelNote: "Recommended: gemini-2.0-flash — best value, generous free tier",
},
{
value: "ollama",
label: "Ollama (Local)",
hint: "Requires Ollama running on host",
rateNote: "No API rate limits",
modelNote: "Recommended: llama3.1 or mistral for reliable structured JSON output",
},
];
export default function SettingsPage() {
const qc = useQueryClient();
const currentUser = useAuthStore((s) => s.user);
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get(),
});
const { data: stats } = useQuery({
queryKey: ["admin-stats"],
queryFn: () => adminAPI.getStats(),
enabled: !!currentUser?.is_admin,
refetchInterval: 30_000,
});
const [healthTesting, setHealthTesting] = useState(false);
const [healthData, setHealthData] = useState<Record<string, ApiHealthResult> | null>(null);
const testApiHealth = async () => {
setHealthTesting(true);
try {
const result = await adminAPI.getApiHealth();
setHealthData(result as unknown as Record<string, ApiHealthResult>);
} finally {
setHealthTesting(false);
}
};
const { data: users, isLoading: usersLoading } = useQuery({
queryKey: ["admin-users"],
queryFn: () => adminAPI.listUsers(),
enabled: !!currentUser?.is_admin,
});
const updateSetting = useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) => settingsAPI.update(key, value),
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings"] }),
});
const deleteUser = useMutation({
mutationFn: (id: number) => adminAPI.deleteUser(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
});
const toggleAdmin = useMutation({
mutationFn: (id: number) => adminAPI.toggleAdmin(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
});
// Live model list from provider API
const { data: modelsData, isFetching: modelsFetching, refetch: refetchModels } = useQuery({
queryKey: ["llm-models", settings?.llm_provider],
queryFn: () => settingsAPI.listModels(settings!.llm_provider),
enabled: !!currentUser?.is_admin && !!settings?.llm_provider,
staleTime: 5 * 60 * 1000,
retry: false,
});
const liveModels: LLMModel[] = modelsData?.models ?? [];
const modelsError: string | undefined = modelsData?.error;
// Model picker state
const [showCustomModel, setShowCustomModel] = useState(false);
const [customModel, setCustomModel] = useState("");
useEffect(() => {
if (!settings || modelsFetching) return;
const inList = liveModels.some((m) => m.id === settings.llm_model);
if (!inList && settings.llm_model) {
setShowCustomModel(true);
setCustomModel(settings.llm_model);
} else {
setShowCustomModel(false);
setCustomModel("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings?.llm_provider, settings?.llm_model, modelsFetching]);
const [testResult, setTestResult] = useState<{
status: string;
detail?: string;
reply?: string;
provider?: string;
model?: string;
} | null>(null);
const [testing, setTesting] = useState(false);
const [modeTestResults, setModeTestResults] = useState<Record<string, { status: string; detail: string }>>({});
const [modeTestRunning, setModeTestRunning] = useState<Record<string, boolean>>({});
const runModeTest = async (key: string, mode: string, event_type: string) => {
setModeTestRunning((p) => ({ ...p, [key]: true }));
try {
const result = await notificationsAPI.testFollowMode(mode, event_type);
setModeTestResults((p) => ({ ...p, [key]: result }));
} catch (e: unknown) {
setModeTestResults((p) => ({
...p,
[key]: { status: "error", detail: e instanceof Error ? e.message : String(e) },
}));
} finally {
setModeTestRunning((p) => ({ ...p, [key]: false }));
}
};
const [taskIds, setTaskIds] = useState<Record<string, string>>({});
const [taskStatuses, setTaskStatuses] = useState<Record<string, "running" | "done" | "error">>({});
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [showMaintenance, setShowMaintenance] = useState(false);
const { data: newsApiQuota, refetch: refetchQuota } = useQuery({
queryKey: ["newsapi-quota"],
queryFn: () => adminAPI.getNewsApiQuota(),
enabled: !!currentUser?.is_admin && !!settings?.newsapi_enabled,
staleTime: 60_000,
});
const [clearingCache, setClearingCache] = useState(false);
const [cacheClearResult, setCacheClearResult] = useState<string | null>(null);
const clearGnewsCache = async () => {
setClearingCache(true);
setCacheClearResult(null);
try {
const result = await adminAPI.clearGnewsCache();
setCacheClearResult(`Cleared ${result.cleared} cached entries`);
} catch (e: unknown) {
setCacheClearResult(e instanceof Error ? e.message : "Failed");
} finally {
setClearingCache(false);
}
};
const testLLM = async () => {
setTesting(true);
setTestResult(null);
try {
const result = await settingsAPI.testLLM();
setTestResult(result);
} catch (e: unknown) {
setTestResult({ status: "error", detail: e instanceof Error ? e.message : String(e) });
} finally {
setTesting(false);
}
};
const pollTaskStatus = async (name: string, taskId: string) => {
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
try {
const data = await adminAPI.getTaskStatus(taskId);
if (["SUCCESS", "FAILURE", "REVOKED"].includes(data.status)) {
setTaskStatuses((prev) => ({ ...prev, [name]: data.status === "SUCCESS" ? "done" : "error" }));
qc.invalidateQueries({ queryKey: ["admin-stats"] });
return;
}
} catch { /* ignore polling errors */ }
}
setTaskStatuses((prev) => ({ ...prev, [name]: "error" }));
};
const trigger = async (name: string, fn: () => Promise<{ task_id: string }>) => {
const result = await fn();
setTaskIds((prev) => ({ ...prev, [name]: result.task_id }));
setTaskStatuses((prev) => ({ ...prev, [name]: "running" }));
pollTaskStatus(name, result.task_id);
};
if (settingsLoading) return <div className="text-center py-20 text-muted-foreground">Loading...</div>;
if (!currentUser?.is_admin) {
return <div className="text-center py-20 text-muted-foreground">Admin access required.</div>;
}
const pct = stats && stats.total_bills > 0
? Math.round((stats.briefs_generated / stats.total_bills) * 100)
: 0;
return (
<div className="space-y-8 max-w-2xl">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Settings className="w-5 h-5" /> Admin
</h1>
<p className="text-muted-foreground text-sm mt-1">Manage users, LLM provider, and system settings</p>
</div>
{/* Notifications link */}
<Link
href="/notifications"
className="flex items-center justify-between bg-card border border-border rounded-lg p-4 hover:bg-accent transition-colors group"
>
<div className="flex items-center gap-3">
<Bell className="w-4 h-4 text-muted-foreground group-hover:text-foreground" />
<div>
<div className="text-sm font-medium">Notification Settings</div>
<div className="text-xs text-muted-foreground">Configure ntfy push alerts and RSS feed per user</div>
</div>
</div>
<span className="text-xs text-muted-foreground group-hover:text-foreground"></span>
</Link>
{/* Follow Mode Notification Testing */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4" /> Follow Mode Notifications
</h2>
<p className="text-xs text-muted-foreground mt-1">
Requires at least one bill followed and ntfy configured. Tests use your first followed bill.
</p>
</div>
<div className="divide-y divide-border">
{([
{
key: "veto-suppress",
mode: "pocket_veto",
event_type: "new_document",
icon: Shield,
label: "Pocket Veto — suppress brief",
description: "Sends a new_document event. Dispatcher should silently drop it — no ntfy notification.",
expectColor: "text-amber-600 dark:text-amber-400",
},
{
key: "veto-deliver",
mode: "pocket_veto",
event_type: "bill_updated",
icon: Shield,
label: "Pocket Veto — deliver milestone",
description: "Sends a bill_updated (milestone) event. Dispatcher should allow it and send ntfy.",
expectColor: "text-amber-600 dark:text-amber-400",
},
{
key: "boost-deliver",
mode: "pocket_boost",
event_type: "bill_updated",
icon: Zap,
label: "Pocket Boost — deliver with actions",
description: "Sends a bill_updated event. ntfy notification should include 'View Bill' and 'Find Your Rep' action buttons.",
expectColor: "text-green-600 dark:text-green-400",
},
] as Array<{
key: string;
mode: string;
event_type: string;
icon: React.ElementType;
label: string;
description: string;
expectColor: string;
}>).map(({ key, mode, event_type, icon: Icon, label, description }) => {
const result = modeTestResults[key];
const running = modeTestRunning[key];
return (
<div key={key} className="flex items-start gap-3 py-3.5">
<Icon className="w-4 h-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0 space-y-1">
<div className="text-sm font-medium">{label}</div>
<p className="text-xs text-muted-foreground">{description}</p>
{result && (
<div className="flex items-start gap-1.5 text-xs mt-1">
{result.status === "ok"
? <CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0 mt-px" />
: <XCircle className="w-3.5 h-3.5 text-red-500 shrink-0 mt-px" />}
<span className={result.status === "ok" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
{result.detail}
</span>
</div>
)}
</div>
<button
onClick={() => runModeTest(key, mode, event_type)}
disabled={running}
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs bg-muted hover:bg-accent rounded-md transition-colors font-medium disabled:opacity-50"
>
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : "Run"}
</button>
</div>
);
})}
</div>
</section>
{/* Analysis Status */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<BarChart3 className="w-4 h-4" /> Bill Pipeline
<span className="text-xs text-muted-foreground font-normal ml-auto">refreshes every 30s</span>
</h2>
{stats ? (
<>
{/* Progress bar */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{stats.briefs_generated.toLocaleString()} analyzed ({stats.full_briefs} full · {stats.amendment_briefs} amendments)</span>
<span>{pct}% of {stats.total_bills.toLocaleString()} bills</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
{/* Pipeline breakdown table */}
<div className="divide-y divide-border text-sm">
{[
{ label: "Total bills tracked", value: stats.total_bills, color: "text-foreground", icon: "📋" },
{ label: "Text published on Congress.gov", value: stats.docs_fetched, color: "text-blue-600 dark:text-blue-400", icon: "📄" },
{ label: "No text published yet", value: stats.no_text_bills, color: "text-muted-foreground", icon: "⏳", note: "Normal — bill text appears after committee markup" },
{ label: "AI briefs generated", value: stats.briefs_generated, color: "text-green-600 dark:text-green-400", icon: "✅" },
{ label: "Pending LLM analysis", value: stats.pending_llm, color: stats.pending_llm > 0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", icon: "🔄", action: stats.pending_llm > 0 ? "Resume Analysis" : undefined },
{ label: "Briefs missing citations", value: stats.uncited_briefs, color: stats.uncited_briefs > 0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", icon: "⚠️", action: stats.uncited_briefs > 0 ? "Backfill Citations" : undefined },
{ label: "Briefs with unlabeled points", value: stats.unlabeled_briefs, color: stats.unlabeled_briefs > 0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground", icon: "🏷️", action: stats.unlabeled_briefs > 0 ? "Backfill Labels" : undefined },
].map(({ label, value, color, icon, note, action }) => (
<div key={label} className="flex items-center justify-between py-2.5 gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="text-base leading-none shrink-0">{icon}</span>
<div>
<span className="text-sm">{label}</span>
{note && <p className="text-xs text-muted-foreground mt-0.5">{note}</p>}
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className={`font-semibold tabular-nums ${color}`}>{value.toLocaleString()}</span>
{action && (
<span className="text-xs text-muted-foreground"> run {action}</span>
)}
</div>
</div>
))}
</div>
</>
) : (
<p className="text-sm text-muted-foreground">Loading stats...</p>
)}
</section>
{/* User Management */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<Users className="w-4 h-4" /> Users
</h2>
{usersLoading ? (
<p className="text-sm text-muted-foreground">Loading users...</p>
) : (
<div className="divide-y divide-border">
{(users ?? []).map((u: AdminUser) => (
<div key={u.id} className="flex items-center justify-between py-3 gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{u.email}</span>
{u.is_admin && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded font-medium">
admin
</span>
)}
{u.id === currentUser.id && (
<span className="text-xs text-muted-foreground">(you)</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{u.follow_count} follow{u.follow_count !== 1 ? "s" : ""} ·{" "}
joined {new Date(u.created_at).toLocaleDateString()}
</div>
</div>
{u.id !== currentUser.id && (
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => toggleAdmin.mutate(u.id)}
disabled={toggleAdmin.isPending}
title={u.is_admin ? "Remove admin" : "Make admin"}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
{u.is_admin ? <ShieldOff className="w-4 h-4" /> : <ShieldCheck className="w-4 h-4" />}
</button>
{confirmDelete === u.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => { deleteUser.mutate(u.id); setConfirmDelete(null); }}
className="text-xs px-2 py-1 bg-destructive text-destructive-foreground rounded hover:bg-destructive/90"
>
Confirm
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs px-2 py-1 bg-muted rounded hover:bg-accent"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(u.id)}
title="Delete user"
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-accent transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
))}
</div>
)}
</section>
{/* LLM Provider */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<Cpu className="w-4 h-4" /> LLM Provider
</h2>
<div className="space-y-2">
{LLM_PROVIDERS.map(({ value, label, hint, rateNote, modelNote }) => {
const hasKey = settings?.api_keys_configured?.[value] ?? true;
return (
<label key={value} className={`flex items-start gap-3 ${hasKey ? "cursor-pointer" : "cursor-not-allowed opacity-60"}`}>
<input
type="radio"
name="provider"
value={value}
checked={settings?.llm_provider === value}
disabled={!hasKey}
onChange={() => {
updateSetting.mutate({ key: "llm_provider", value });
setShowCustomModel(false);
setCustomModel("");
}}
className="mt-0.5"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{label}</span>
{hasKey ? (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 font-medium">
{value === "ollama" ? "local" : "key set"}
</span>
) : (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground font-medium">
no key
</span>
)}
</div>
<div className="text-xs text-muted-foreground">{hint}</div>
<div className="text-xs text-muted-foreground mt-0.5">{rateNote} · {modelNote}</div>
</div>
</label>
);
})}
</div>
{/* Model picker — live from provider API */}
<div className="space-y-2 pt-3 border-t border-border">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Model</label>
{modelsFetching && <span className="text-xs text-muted-foreground">Loading models</span>}
{modelsError && !modelsFetching && (
<span className="text-xs text-amber-600 dark:text-amber-400">{modelsError}</span>
)}
{!modelsFetching && liveModels.length > 0 && (
<button onClick={() => refetchModels()} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
Refresh
</button>
)}
</div>
{liveModels.length > 0 ? (
<select
value={showCustomModel ? "__custom__" : (settings?.llm_model ?? "")}
onChange={(e) => {
if (e.target.value === "__custom__") {
setShowCustomModel(true);
setCustomModel(settings?.llm_model ?? "");
} else {
setShowCustomModel(false);
setCustomModel("");
updateSetting.mutate({ key: "llm_model", value: e.target.value });
}
}}
className="w-full px-3 py-1.5 text-sm bg-background border border-border rounded-md"
>
{liveModels.map((m) => (
<option key={m.id} value={m.id}>{m.name !== m.id ? `${m.name} (${m.id})` : m.id}</option>
))}
<option value="__custom__">Custom model name</option>
</select>
) : (
!modelsFetching && (
<p className="text-xs text-muted-foreground">
{modelsError ? "Could not fetch models — enter a model name manually below." : "No models found."}
</p>
)
)}
{(showCustomModel || (liveModels.length === 0 && !modelsFetching)) && (
<div className="flex gap-2">
<input
type="text"
placeholder="e.g. gpt-4o or gemini-2.0-flash"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
className="flex-1 px-3 py-1.5 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button
onClick={() => {
if (customModel.trim()) updateSetting.mutate({ key: "llm_model", value: customModel.trim() });
}}
disabled={!customModel.trim() || updateSetting.isPending}
className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
Save
</button>
</div>
)}
<p className="text-xs text-muted-foreground">
Active: <strong>{settings?.llm_provider}</strong> / <strong>{settings?.llm_model}</strong>
</p>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-border">
<button
onClick={testLLM}
disabled={testing}
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Play className="w-3.5 h-3.5" />
{testing ? "Testing..." : "Test Connection"}
</button>
{testResult && (
<div className="flex items-center gap-2 text-sm">
{testResult.status === "ok" ? (
<>
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-green-600 dark:text-green-400">
{testResult.model} {testResult.reply}
</span>
</>
) : (
<>
<XCircle className="w-4 h-4 text-red-500" />
<span className="text-red-600 dark:text-red-400">{testResult.detail}</span>
</>
)}
</div>
)}
</div>
</section>
{/* Data Sources */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold flex items-center gap-2">
<RefreshCw className="w-4 h-4" /> Data Sources
</h2>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<div>
<div className="font-medium">Congress.gov Poll Interval</div>
<div className="text-xs text-muted-foreground">How often to check for new bills</div>
</div>
<select
value={settings?.congress_poll_interval_minutes}
onChange={(e) => updateSetting.mutate({ key: "congress_poll_interval_minutes", value: e.target.value })}
className="px-3 py-1.5 text-sm bg-background border border-border rounded-md"
>
<option value="15">Every 15 min</option>
<option value="30">Every 30 min</option>
<option value="60">Every hour</option>
<option value="360">Every 6 hours</option>
</select>
</div>
<div className="flex items-center justify-between py-2 border-t border-border">
<div>
<div className="font-medium">NewsAPI.org</div>
<div className="text-xs text-muted-foreground">100 requests/day free tier</div>
</div>
<div className="flex items-center gap-3">
{newsApiQuota && (
<span className={`text-xs ${newsApiQuota.remaining < 10 ? "text-amber-500" : "text-muted-foreground"}`}>
{newsApiQuota.remaining}/{newsApiQuota.limit} remaining today
</span>
)}
<span className={`text-xs font-medium ${settings?.newsapi_enabled ? "text-green-500" : "text-muted-foreground"}`}>
{settings?.newsapi_enabled ? "Configured" : "Not configured"}
</span>
</div>
</div>
<div className="flex items-center justify-between py-2 border-t border-border">
<div>
<div className="font-medium">Google Trends</div>
<div className="text-xs text-muted-foreground">Zeitgeist scoring via pytrends</div>
</div>
<span className={`text-xs font-medium ${settings?.pytrends_enabled ? "text-green-500" : "text-muted-foreground"}`}>
{settings?.pytrends_enabled ? "Enabled" : "Disabled"}
</span>
</div>
</div>
</section>
{/* API Health */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold">External API Health</h2>
<button
onClick={testApiHealth}
disabled={healthTesting}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-muted hover:bg-accent rounded-md transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${healthTesting ? "animate-spin" : ""}`} />
{healthTesting ? "Testing…" : "Run Tests"}
</button>
</div>
{healthData ? (
<div className="divide-y divide-border">
{[
{ key: "congress_gov", label: "Congress.gov API" },
{ key: "govinfo", label: "GovInfo API" },
{ key: "newsapi", label: "NewsAPI.org" },
{ key: "google_news", label: "Google News RSS" },
{ key: "rep_lookup", label: "Rep Lookup (Nominatim + TIGERweb)" },
].map(({ key, label }) => {
const r = healthData[key];
if (!r) return null;
return (
<div key={key} className="flex items-start justify-between py-3 gap-4">
<div>
<div className="text-sm font-medium">{label}</div>
<div className={`text-xs mt-0.5 ${
r.status === "ok" ? "text-green-600 dark:text-green-400"
: r.status === "skipped" ? "text-muted-foreground"
: "text-red-600 dark:text-red-400"
}`}>
{r.detail}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{r.latency_ms !== undefined && (
<span className="text-xs text-muted-foreground">{r.latency_ms}ms</span>
)}
{r.status === "ok" && <CheckCircle className="w-4 h-4 text-green-500" />}
{r.status === "error" && <XCircle className="w-4 h-4 text-red-500" />}
{r.status === "skipped" && <span className="text-xs text-muted-foreground"></span>}
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">
Click Run Tests to check connectivity to each external data source.
</p>
)}
</section>
{/* Manual Controls */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<h2 className="font-semibold">Manual Controls</h2>
{(() => {
type ControlItem = {
key: string;
name: string;
description: string;
fn: () => Promise<{ task_id: string }>;
status: "ok" | "needed" | "on-demand";
count?: number;
countLabel?: string;
};
const renderRow = ({ key, name, description, fn, status, count, countLabel }: ControlItem) => (
<div key={key} className="flex items-start gap-3 py-3.5">
<div className={`w-2.5 h-2.5 rounded-full mt-1 shrink-0 ${
status === "ok" ? "bg-green-500"
: status === "needed" ? "bg-red-500"
: "bg-border"
}`} />
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{name}</span>
{taskStatuses[key] === "running" ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<RefreshCw className="w-3 h-3 animate-spin" />
running
{taskIds[key] && (
<code className="font-mono opacity-60">{taskIds[key].slice(0, 8)}</code>
)}
</span>
) : taskStatuses[key] === "done" ? (
<span className="text-xs text-green-600 dark:text-green-400"> Complete</span>
) : taskStatuses[key] === "error" ? (
<span className="text-xs text-red-600 dark:text-red-400"> Failed</span>
) : status === "ok" ? (
<span className="text-xs text-green-600 dark:text-green-400"> Up to date</span>
) : status === "needed" && count !== undefined && count > 0 ? (
<span className="text-xs text-red-600 dark:text-red-400">
{count.toLocaleString()} {countLabel}
</span>
) : null}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
</div>
<button
onClick={() => trigger(key, fn)}
disabled={taskStatuses[key] === "running"}
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs bg-muted hover:bg-accent rounded-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{taskStatuses[key] === "running" ? <RefreshCw className="w-3 h-3 animate-spin" /> : "Run"}
</button>
</div>
);
// Clear RSS cache — inline action (returns count, not task_id)
const ClearCacheRow = (
<div className="flex items-start gap-3 py-3.5">
<div className="w-2.5 h-2.5 rounded-full mt-1 shrink-0 bg-border" />
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">Clear Google News Cache</span>
{cacheClearResult && (
<span className="text-xs text-green-600 dark:text-green-400"> {cacheClearResult}</span>
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
Flush the 2-hour Google News RSS cache so fresh articles are fetched on the next trend scoring or news run.
</p>
</div>
<button
onClick={clearGnewsCache}
disabled={clearingCache}
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs bg-muted hover:bg-accent rounded-md transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{clearingCache ? <RefreshCw className="w-3 h-3 animate-spin" /> : "Run"}
</button>
</div>
);
const recurring: ControlItem[] = [
{
key: "poll",
name: "Trigger Poll",
description: "Check Congress.gov for newly introduced or updated bills. Runs automatically on a schedule — use this to force an immediate sync.",
fn: adminAPI.triggerPoll,
status: "on-demand",
},
{
key: "members",
name: "Sync Members",
description: "Refresh all member profiles from Congress.gov including biography, current term, leadership roles, and contact information.",
fn: adminAPI.triggerMemberSync,
status: "on-demand",
},
{
key: "trends",
name: "Calculate Trends",
description: "Score bill and member newsworthiness by counting recent news headlines and Google search interest. Updates the trend charts.",
fn: adminAPI.triggerTrendScores,
status: "on-demand",
},
{
key: "actions",
name: "Fetch Bill Actions",
description: "Download the full legislative history (votes, referrals, amendments) for recently active bills and populate the timeline view.",
fn: adminAPI.triggerFetchActions,
status: "on-demand",
},
{
key: "resume",
name: "Resume Analysis",
description: "Restart AI brief generation for bills where processing stalled or failed (e.g. after an LLM quota outage). Also re-queues document fetching for bills that have no text yet.",
fn: adminAPI.resumeAnalysis,
status: stats ? (stats.pending_llm > 0 ? "needed" : "on-demand") : "on-demand",
count: stats?.pending_llm,
countLabel: "bills pending analysis",
},
{
key: "weekly-digest",
name: "Send Weekly Digest",
description: "Immediately dispatch the weekly bill activity summary to all users who have ntfy or RSS enabled and at least one bill followed. Runs automatically every Monday at 8:30 AM UTC.",
fn: adminAPI.triggerWeeklyDigest,
status: "on-demand",
},
];
const maintenance: ControlItem[] = [
{
key: "backfill-actions",
name: "Backfill All Action Histories",
description: "One-time catch-up: fetch action histories for all bills that were imported before this feature existed.",
fn: adminAPI.backfillAllActions,
status: stats ? (stats.bills_missing_actions > 0 ? "needed" : "ok") : "on-demand",
count: stats?.bills_missing_actions,
countLabel: "bills missing action history",
},
{
key: "sponsors",
name: "Backfill Sponsors",
description: "Link bill sponsors that weren't captured during the initial import. Safe to re-run — skips bills that already have a sponsor.",
fn: adminAPI.backfillSponsors,
status: stats ? (stats.bills_missing_sponsor > 0 ? "needed" : "ok") : "on-demand",
count: stats?.bills_missing_sponsor,
countLabel: "bills missing sponsor",
},
{
key: "metadata",
name: "Backfill Dates & Links",
description: "Fill in missing introduced dates, chamber assignments, and congress.gov links by re-fetching bill detail from Congress.gov.",
fn: adminAPI.backfillMetadata,
status: stats ? (stats.bills_missing_metadata > 0 ? "needed" : "ok") : "on-demand",
count: stats?.bills_missing_metadata,
countLabel: "bills missing metadata",
},
{
key: "citations",
name: "Backfill Citations",
description: "Regenerate AI briefs created before inline source citations were added. Deletes the old brief and re-runs LLM analysis using already-stored bill text.",
fn: adminAPI.backfillCitations,
status: stats ? (stats.uncited_briefs > 0 ? "needed" : "ok") : "on-demand",
count: stats?.uncited_briefs,
countLabel: "briefs need regeneration",
},
{
key: "labels",
name: "Backfill Fact/Inference Labels",
description: "Classify existing cited brief points as fact or inference. One compact LLM call per brief — no re-generation of summaries or citations.",
fn: adminAPI.backfillLabels,
status: stats ? (stats.unlabeled_briefs > 0 ? "needed" : "ok") : "on-demand",
count: stats?.unlabeled_briefs,
countLabel: "briefs with unlabeled points",
},
];
const maintenanceNeeded = maintenance.some((m) => m.status === "needed");
return (
<>
<div className="divide-y divide-border">
{recurring.map(renderRow)}
{ClearCacheRow}
</div>
{/* Maintenance subsection */}
<div className="border border-border rounded-md overflow-hidden">
<button
onClick={() => setShowMaintenance((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium bg-muted/50 hover:bg-muted transition-colors"
>
<span className="flex items-center gap-2">
<Wrench className="w-3.5 h-3.5 text-muted-foreground" />
Maintenance
{maintenanceNeeded && (
<span className="text-xs font-normal text-red-600 dark:text-red-400"> action needed</span>
)}
</span>
{showMaintenance
? <ChevronDown className="w-4 h-4 text-muted-foreground" />
: <ChevronRight className="w-4 h-4 text-muted-foreground" />}
</button>
{showMaintenance && (
<div className="divide-y divide-border px-4">
{maintenance.map(renderRow)}
</div>
)}
</div>
</>
);
})()}
</section>
</div>
);
}