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({
|
const { text } = await generateText({
|
||||||
model,
|
model,
|
||||||
prompt: "Reply with exactly: OK",
|
prompt: "Reply with exactly: OK",
|
||||||
maxOutputTokens: 10,
|
maxOutputTokens: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, response: text.trim() });
|
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,
|
User,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Code,
|
Code,
|
||||||
|
RefreshCw,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
|
|
@ -81,6 +84,7 @@ export default function CardDetailPage() {
|
||||||
const [card, setCard] = React.useState<CardData | null>(null);
|
const [card, setCard] = React.useState<CardData | null>(null);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const [reprocessing, setReprocessing] = React.useState(false);
|
||||||
const [edits, setEdits] = React.useState<Record<string, string>>({});
|
const [edits, setEdits] = React.useState<Record<string, string>>({});
|
||||||
const [showRawOcr, setShowRawOcr] = React.useState(false);
|
const [showRawOcr, setShowRawOcr] = React.useState(false);
|
||||||
|
|
||||||
|
|
@ -103,6 +107,24 @@ export default function CardDetailPage() {
|
||||||
fetchCard();
|
fetchCard();
|
||||||
}, [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 => {
|
const getValue = (field: keyof CardData): string => {
|
||||||
if (field in edits) return edits[field];
|
if (field in edits) return edits[field];
|
||||||
const val = card?.[field];
|
const val = card?.[field];
|
||||||
|
|
@ -149,6 +171,22 @@ export default function CardDetailPage() {
|
||||||
fetchCard();
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -215,6 +253,20 @@ export default function CardDetailPage() {
|
||||||
|
|
||||||
<Header title={String(card.name || "Unnamed Card")} icon={ScanLine}>
|
<Header title={String(card.name || "Unnamed Card")} icon={ScanLine}>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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" && (
|
{reviewStatus !== "reviewed" && (
|
||||||
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleMarkReviewed}>
|
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleMarkReviewed}>
|
||||||
<Check className="mr-1 size-4" /> Mark Reviewed
|
<Check className="mr-1 size-4" /> Mark Reviewed
|
||||||
|
|
@ -233,6 +285,16 @@ export default function CardDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</Header>
|
</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">
|
<div className="grid gap-4 sm:gap-6 grid-cols-1 xl:grid-cols-2">
|
||||||
<Card variant="glass">
|
<Card variant="glass">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
|
|
@ -34,6 +35,7 @@ export default function UploadPage() {
|
||||||
const [uploading, setUploading] = React.useState(false);
|
const [uploading, setUploading] = React.useState(false);
|
||||||
const [jobs, setJobs] = React.useState<ProcessingJob[]>([]);
|
const [jobs, setJobs] = React.useState<ProcessingJob[]>([]);
|
||||||
const [dragOver, setDragOver] = React.useState(false);
|
const [dragOver, setDragOver] = React.useState(false);
|
||||||
|
const [retryingJobId, setRetryingJobId] = React.useState<string | null>(null);
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const fetchJobs = React.useCallback(async () => {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Header
|
<Header
|
||||||
|
|
@ -291,10 +310,27 @@ export default function UploadPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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()}
|
{new Date(job.createdAt).toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</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> {
|
export async function deleteObject(key: string): Promise<void> {
|
||||||
await s3.send(
|
await s3.send(
|
||||||
new DeleteObjectCommand({ Bucket: BUCKET, Key: key })
|
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 { prisma } from "./db";
|
||||||
import { uploadBuffer } from "./minio";
|
import { uploadBuffer, getBuffer, deleteObject } from "./minio";
|
||||||
import { ocrImage } from "./ai-ocr";
|
import { ocrImage } from "./ai-ocr";
|
||||||
import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf";
|
import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf";
|
||||||
|
|
||||||
|
|
@ -162,6 +162,116 @@ export async function processFile(
|
||||||
return cardIds;
|
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 {
|
function asString(v: unknown): string | null {
|
||||||
if (v === null || v === undefined) return null;
|
if (v === null || v === undefined) return null;
|
||||||
return String(v);
|
return String(v);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue