diff --git a/src/app/api/ai-test/route.ts b/src/app/api/ai-test/route.ts index 570c677..f4c7f35 100644 --- a/src/app/api/ai-test/route.ts +++ b/src/app/api/ai-test/route.ts @@ -26,7 +26,7 @@ export async function POST(request: NextRequest) { const { text } = await generateText({ model, prompt: "Reply with exactly: OK", - maxOutputTokens: 10, + maxOutputTokens: 20, }); return NextResponse.json({ ok: true, response: text.trim() }); diff --git a/src/app/api/cards/[id]/reprocess/route.ts b/src/app/api/cards/[id]/reprocess/route.ts new file mode 100644 index 0000000..87fc2c8 --- /dev/null +++ b/src/app/api/cards/[id]/reprocess/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { reprocessCard } from "@/lib/ocr"; + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const card = await prisma.responseCard.findUnique({ where: { id } }); + + if (!card) { + return NextResponse.json({ error: "Card not found" }, { status: 404 }); + } + + if (card.ocrStatus !== "error") { + return NextResponse.json( + { error: `Cannot reprocess card with status "${card.ocrStatus}"` }, + { status: 400 } + ); + } + + reprocessCard(id).catch((err) => { + console.error(`[reprocess] Card ${id} reprocessing failed:`, err); + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[cards/[id]/reprocess POST]", error); + return NextResponse.json( + { error: "Failed to start reprocessing" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/jobs/[id]/reprocess/route.ts b/src/app/api/jobs/[id]/reprocess/route.ts new file mode 100644 index 0000000..d57ba60 --- /dev/null +++ b/src/app/api/jobs/[id]/reprocess/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { reprocessJob } from "@/lib/ocr"; + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const job = await prisma.processingJob.findUnique({ where: { id } }); + + if (!job) { + return NextResponse.json({ error: "Job not found" }, { status: 404 }); + } + + if (job.status !== "error") { + return NextResponse.json( + { error: `Cannot reprocess job with status "${job.status}"` }, + { status: 400 } + ); + } + + reprocessJob(id).catch((err) => { + console.error(`[reprocess] Job ${id} reprocessing failed:`, err); + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[jobs/[id]/reprocess POST]", error); + return NextResponse.json( + { error: "Failed to start reprocessing" }, + { status: 500 } + ); + } +} diff --git a/src/app/cards/[id]/page.tsx b/src/app/cards/[id]/page.tsx index 9872a42..59e184c 100644 --- a/src/app/cards/[id]/page.tsx +++ b/src/app/cards/[id]/page.tsx @@ -16,6 +16,9 @@ import { User, ClipboardList, Code, + RefreshCw, + Loader2, + AlertCircle, } from "lucide-react"; import { Header } from "@/components/layout/header"; @@ -81,6 +84,7 @@ export default function CardDetailPage() { const [card, setCard] = React.useState(null); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); + const [reprocessing, setReprocessing] = React.useState(false); const [edits, setEdits] = React.useState>({}); const [showRawOcr, setShowRawOcr] = React.useState(false); @@ -103,6 +107,24 @@ export default function CardDetailPage() { fetchCard(); }, [fetchCard]); + React.useEffect(() => { + if (card?.ocrStatus !== "processing") return; + const interval = setInterval(async () => { + try { + const res = await fetch(`/api/cards/${id}`); + if (!res.ok) return; + const data = await res.json(); + setCard(data); + if (data.ocrStatus !== "processing") { + setReprocessing(false); + if (data.ocrStatus === "complete") toast.success("Reprocessing complete"); + if (data.ocrStatus === "error") toast.error("Reprocessing failed"); + } + } catch { /* ignore polling errors */ } + }, 3000); + return () => clearInterval(interval); + }, [card?.ocrStatus, id]); + const getValue = (field: keyof CardData): string => { if (field in edits) return edits[field]; const val = card?.[field]; @@ -149,6 +171,22 @@ export default function CardDetailPage() { fetchCard(); }; + const handleReprocess = async () => { + setReprocessing(true); + try { + const res = await fetch(`/api/cards/${id}/reprocess`, { method: "POST" }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to start reprocessing"); + } + toast.success("Reprocessing started"); + await fetchCard(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to start reprocessing"); + setReprocessing(false); + } + }; + if (loading) { return (
@@ -215,6 +253,20 @@ export default function CardDetailPage() {
+ {ocrStatus === "error" && ( + + )} + {ocrStatus === "processing" && ( + + Processing... + + )} {reviewStatus !== "reviewed" && (
+ {ocrStatus === "error" && card.ocrError && ( +
+ +
+

OCR Processing Error

+

{card.ocrError}

+
+
+ )} +
diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index ebc0d71..0418f31 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -11,6 +11,7 @@ import { CheckCircle, AlertCircle, FolderOpen, + RefreshCw, } from "lucide-react"; import { Header } from "@/components/layout/header"; @@ -34,6 +35,7 @@ export default function UploadPage() { const [uploading, setUploading] = React.useState(false); const [jobs, setJobs] = React.useState([]); const [dragOver, setDragOver] = React.useState(false); + const [retryingJobId, setRetryingJobId] = React.useState(null); const fileInputRef = React.useRef(null); const fetchJobs = React.useCallback(async () => { @@ -118,6 +120,23 @@ export default function UploadPage() { } }; + const handleRetryJob = async (jobId: string) => { + setRetryingJobId(jobId); + try { + const res = await fetch(`/api/jobs/${jobId}/reprocess`, { method: "POST" }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to retry job"); + } + toast.success("Job reprocessing started"); + fetchJobs(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to retry job"); + } finally { + setRetryingJobId(null); + } + }; + return (
- - {new Date(job.createdAt).toLocaleTimeString()} - +
+ {job.status === "error" && ( + + )} + + {new Date(job.createdAt).toLocaleTimeString()} + +
))}
diff --git a/src/lib/minio.ts b/src/lib/minio.ts index 92f906f..ca1eec8 100644 --- a/src/lib/minio.ts +++ b/src/lib/minio.ts @@ -39,6 +39,14 @@ export async function getPresignedUrl(key: string, expiresIn = 3600): Promise { + const res = await s3.send( + new GetObjectCommand({ Bucket: BUCKET, Key: key }) + ); + const stream = res.Body as ReadableStream; + return Buffer.from(await new Response(stream).arrayBuffer()); +} + export async function deleteObject(key: string): Promise { await s3.send( new DeleteObjectCommand({ Bucket: BUCKET, Key: key }) diff --git a/src/lib/ocr.ts b/src/lib/ocr.ts index 0d783ac..3fc9532 100644 --- a/src/lib/ocr.ts +++ b/src/lib/ocr.ts @@ -1,5 +1,5 @@ import { prisma } from "./db"; -import { uploadBuffer } from "./minio"; +import { uploadBuffer, getBuffer, deleteObject } from "./minio"; import { ocrImage } from "./ai-ocr"; import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf"; @@ -162,6 +162,116 @@ export async function processFile( return cardIds; } +export async function reprocessCard(cardId: string): Promise { + const card = await prisma.responseCard.findUnique({ where: { id: cardId } }); + if (!card) throw new Error("Card not found"); + if (!card.backImagePath && !card.frontImagePath) { + throw new Error("No stored images available for reprocessing"); + } + + await prisma.responseCard.update({ + where: { id: cardId }, + data: { ocrStatus: "processing", ocrError: null }, + }); + + try { + let responseData: Record = {}; + let surveyData: Record = {}; + let totalConfidence = 0; + let confidenceCount = 0; + + if (card.backImagePath) { + const imgBuffer = await getBuffer(card.backImagePath); + const base64 = await imageToBase64(imgBuffer); + const result = await ocrImage(base64, "response"); + responseData = result.data; + totalConfidence += result.confidence; + confidenceCount++; + } + + if (card.frontImagePath) { + const imgBuffer = await getBuffer(card.frontImagePath); + const base64 = await imageToBase64(imgBuffer); + const result = await ocrImage(base64, "survey"); + surveyData = result.data; + totalConfidence += result.confidence; + confidenceCount++; + } + + const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0; + + await prisma.responseCard.update({ + where: { id: cardId }, + data: { + name: asString(responseData.name), + gender: asString(responseData.gender), + dateOfBirth: asString(responseData.dateOfBirth), + maritalStatus: asString(responseData.maritalStatus), + maritalStatusOther: asString(responseData.maritalStatusOther), + visitType: asString(responseData.visitType), + cellPhone: asString(responseData.cellPhone), + homePhone: asString(responseData.homePhone), + email: asString(responseData.email), + address: asString(responseData.address), + aptNumber: asString(responseData.aptNumber), + city: asString(responseData.city), + state: asString(responseData.state), + zip: asString(responseData.zip), + prayerRequests: asString(responseData.prayerRequests), + prayerForTeam: asBool(responseData.prayerForTeam), + prayerConfidential: asBool(responseData.prayerConfidential), + messageTopics: surveyData.messageTopics ?? [], + messageTopicsOther: asString(surveyData.messageTopicsOther), + nextStep: surveyData.nextStep ?? [], + attendanceDuration: asString(surveyData.attendanceDuration), + campusPreference: surveyData.campusPreference ?? [], + campusPreferenceOther: asString(surveyData.campusPreferenceOther), + howHeard: surveyData.howHeard ?? [], + howHeardOther: asString(surveyData.howHeardOther), + serviceAttended: asString(surveyData.serviceAttended), + ocrStatus: "complete", + ocrConfidence: Math.round(avgConfidence), + ocrError: null, + rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })), + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown OCR error"; + await prisma.responseCard.update({ + where: { id: cardId }, + data: { ocrStatus: "error", ocrError: message }, + }); + } +} + +export async function reprocessJob(jobId: string): Promise { + const job = await prisma.processingJob.findUnique({ where: { id: jobId } }); + if (!job) throw new Error("Job not found"); + + const oldCardIds = (job.cardIds as string[] | null) ?? []; + for (const cardId of oldCardIds) { + const card = await prisma.responseCard.findUnique({ where: { id: cardId } }); + if (card) { + const deletes: Promise[] = []; + if (card.frontImagePath) deletes.push(deleteObject(card.frontImagePath)); + if (card.backImagePath) deletes.push(deleteObject(card.backImagePath)); + await Promise.allSettled(deletes); + await prisma.responseCard.delete({ where: { id: cardId } }); + } + } + + const sourceKey = `sources/${jobId}/${job.fileName}`; + const fileBuffer = await getBuffer(sourceKey); + const isPdf = job.fileName.toLowerCase().endsWith(".pdf"); + + await prisma.processingJob.update({ + where: { id: jobId }, + data: { status: "queued", processed: 0, error: null, cardIds: null }, + }); + + await processFile(jobId, job.fileName, fileBuffer, isPdf); +} + function asString(v: unknown): string | null { if (v === null || v === undefined) return null; return String(v);