Fix AI test endpoint min token requirement
OpenAI requires maxOutputTokens >= 16; bump from 10 to 20. Made-with: Cursor
This commit is contained in:
parent
2db14b7336
commit
77b9fbea4c
7 changed files with 293 additions and 5 deletions
|
|
@ -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() });
|
||||
|
|
|
|||
36
src/app/api/cards/[id]/reprocess/route.ts
Normal file
36
src/app/api/cards/[id]/reprocess/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
src/app/api/jobs/[id]/reprocess/route.ts
Normal file
36
src/app/api/jobs/[id]/reprocess/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
112
src/lib/ocr.ts
112
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<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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue