Fix AI test endpoint min token requirement

OpenAI requires maxOutputTokens >= 16; bump from 10 to 20.

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-03-11 11:46:25 -05:00
parent 2db14b7336
commit 77b9fbea4c
7 changed files with 293 additions and 5 deletions

View file

@ -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() });

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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<CardData | null>(null);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [reprocessing, setReprocessing] = React.useState(false);
const [edits, setEdits] = React.useState<Record<string, string>>({});
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 (
<div className="space-y-6">
@ -215,6 +253,20 @@ export default function CardDetailPage() {
<Header title={String(card.name || "Unnamed Card")} icon={ScanLine}>
<div className="flex flex-wrap gap-2">
{ocrStatus === "error" && (
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleReprocess} disabled={reprocessing}>
{reprocessing ? (
<><Loader2 className="mr-1 size-4 animate-spin" /> Reprocessing...</>
) : (
<><RefreshCw className="mr-1 size-4" /> Reprocess</>
)}
</Button>
)}
{ocrStatus === "processing" && (
<Badge variant="secondary" className="bg-primary/10 text-primary gap-1.5 py-1.5 px-3">
<Loader2 className="size-3.5 animate-spin" /> Processing...
</Badge>
)}
{reviewStatus !== "reviewed" && (
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleMarkReviewed}>
<Check className="mr-1 size-4" /> Mark Reviewed
@ -233,6 +285,16 @@ export default function CardDetailPage() {
</div>
</Header>
{ocrStatus === "error" && card.ocrError && (
<div className="flex items-start gap-3 rounded-xl border border-red-300 bg-red-500/10 p-4 dark:border-red-800">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-red-600 dark:text-red-400" />
<div>
<p className="text-sm font-medium text-red-700 dark:text-red-300">OCR Processing Error</p>
<p className="mt-0.5 text-sm text-red-600 dark:text-red-400">{card.ocrError}</p>
</div>
</div>
)}
<div className="grid gap-4 sm:gap-6 grid-cols-1 xl:grid-cols-2">
<Card variant="glass">
<CardHeader>

View file

@ -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<ProcessingJob[]>([]);
const [dragOver, setDragOver] = React.useState(false);
const [retryingJobId, setRetryingJobId] = React.useState<string | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(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 (
<div className="space-y-6">
<Header
@ -291,10 +310,27 @@ export default function UploadPage() {
)}
</div>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
<div className="flex shrink-0 items-center gap-2">
{job.status === "error" && (
<Button
variant="outline"
size="sm"
className="h-7 rounded-lg px-2.5 text-xs"
onClick={() => handleRetryJob(job.id)}
disabled={retryingJobId === job.id}
>
{retryingJobId === job.id ? (
<><Loader2 className="mr-1 size-3 animate-spin" /> Retrying...</>
) : (
<><RefreshCw className="mr-1 size-3" /> Retry</>
)}
</Button>
)}
<span className="text-xs text-muted-foreground">
{new Date(job.createdAt).toLocaleTimeString()}
</span>
</div>
</div>
))}
</div>
)}

View file

@ -39,6 +39,14 @@ export async function getPresignedUrl(key: string, expiresIn = 3600): Promise<st
);
}
export async function getBuffer(key: string): Promise<Buffer> {
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<void> {
await s3.send(
new DeleteObjectCommand({ Bucket: BUCKET, Key: key })

View file

@ -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<void> {
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<string, unknown> = {};
let surveyData: Record<string, unknown> = {};
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<void> {
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<void>[] = [];
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);