Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getBillByIdFromDB } from "@/server/get-bill-by-id-from-db";
import { requireAuthenticatedUser } from "@/lib/auth-guards";
import { BASE_PATH } from "@/utils/basePath";
import { Button } from "@/components/ui/button";
import { ReprocessButton } from "@/components/ReprocessButton/reprocess-button.component";

interface Params {
params: Promise<{ id: string }>;
Expand All @@ -25,6 +26,9 @@ export default async function EditBillPage({ params }: Params) {
return (
<div className="mx-auto max-w-[900px] px-6 py-8">
<h1 className="text-xl font-semibold mb-6">Edit Bill</h1>
<div className="mb-6 border rounded p-4">
<ReprocessButton billId={id} />
</div>
<form
className="space-y-6"
action={`${BASE_PATH}/api/${id}`}
Expand Down
101 changes: 101 additions & 0 deletions src/app/api/[id]/reprocess/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { connectToDatabase } from "@/lib/mongoose";
import { Bill, type BillDocument } from "@/models/Bill";
import { User } from "@/models/User";
import { authOptions } from "@/lib/auth";
import {
fetchBillMarkdown,
getBillFromCivicsProjectApi,
summarizeBillText,
} from "@/services/billApi";

/**
* Admin-only endpoint to re-run the AI analysis for a bill.
*
* Pulls the latest bill text (preferring the freshest source from the Civics
* Project API, falling back to the source already stored on the bill), feeds it
* through `summarizeBillText`, and overwrites the AI-generated fields in the DB.
*/
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

await connectToDatabase();

// Verify user is on the allowlist; do not create
const dbUser = await User.findOne({
emailLower: session.user.email.toLowerCase(),
});
if (!dbUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { id } = await params;

const existing = (await Bill.findOne({ billId: id })
.lean()
.exec()) as BillDocument | null;
if (!existing) {
return NextResponse.json({ error: "Bill not found" }, { status: 404 });
}

// Prefer the freshest source from the Civics Project API; fall back to the
// source stored on the existing bill.
let source: string | undefined = existing.source;
try {
const apiBill = await getBillFromCivicsProjectApi(id);
const apiSource =
apiBill?.source ||
(apiBill?.billTexts?.[0] as { url?: string } | undefined)?.url;
if (apiSource) {
source = apiSource;
}
} catch (error) {
console.error(`Reprocess ${id}: failed to fetch latest source`, error);
}

if (!source) {
return NextResponse.json(
{ error: "No bill text source available to reprocess" },
{ status: 422 },
);
}

const markdown = await fetchBillMarkdown(source);
if (!markdown) {
return NextResponse.json(
{ error: "Failed to fetch bill text from source" },
{ status: 502 },
);
}

const analysis = await summarizeBillText(markdown);

await Bill.updateOne(
{ billId: id },
{
$set: {
summary: analysis.summary,
short_title: analysis.short_title ?? existing.short_title,
tenet_evaluations: analysis.tenet_evaluations,
final_judgment: analysis.final_judgment,
rationale: analysis.rationale,
needs_more_info: analysis.needs_more_info,
missing_details: analysis.missing_details,
steel_man: analysis.steel_man,
question_period_questions: analysis.question_period_questions ?? [],
source,
lastUpdatedOn: new Date(),
},
},
{ upsert: false },
);

return NextResponse.json({ ok: true });
}
62 changes: 62 additions & 0 deletions src/components/ReprocessButton/reprocess-button.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { BASE_PATH } from "@/utils/basePath";

interface ReprocessButtonProps {
billId: string;
}

export const ReprocessButton = ({ billId }: ReprocessButtonProps) => {
const router = useRouter();
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleReprocess = async () => {
const confirmed = window.confirm(
"Re-run the AI analysis for this bill? This will overwrite the summary, judgment, rationale, steel man, tenet evaluations and Question Period questions.",
);
if (!confirmed) return;

setIsPending(true);
setError(null);
try {
const res = await fetch(`${BASE_PATH}/api/${billId}/reprocess`, {
method: "POST",
});
if (!res.ok) {
const body = (await res.json().catch(() => null)) as {
error?: string;
} | null;
throw new Error(body?.error || `Request failed (${res.status})`);
}
// Reload so the form re-renders with the freshly generated analysis.
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setIsPending(false);
}
};

return (
<div className="space-y-2">
<div className="flex items-center gap-3">
<Button
type="button"
variant="secondary"
onClick={handleReprocess}
disabled={isPending}
>
{isPending ? "Reprocessing…" : "Re-run AI Summary"}
</Button>
<p className="text-sm text-muted-foreground">
Regenerates all AI fields from the latest bill text.
</p>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
);
};
Loading