Integrate Vercel AI SDK for multi-provider OCR

Replace the raw Ollama fetch-based OCR with the Vercel AI SDK,
adding support for OpenAI, Google Gemini, Anthropic, and Ollama
as selectable providers from the Settings page. Uses generateText
with Output.object() and Zod schemas for type-safe structured
data extraction.

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-03-11 09:24:15 -05:00
parent c879328775
commit 1ec50d9033
9 changed files with 527 additions and 34 deletions

View file

@ -1,7 +1,12 @@
# Database (shared PostgreSQL on CT 102) # Database (shared PostgreSQL on CT 102)
DATABASE_URL="postgresql://echos_ocr:YOUR_PASSWORD@192.168.68.102:5432/echos_ocr" DATABASE_URL="postgresql://echos_ocr:YOUR_PASSWORD@192.168.68.102:5432/echos_ocr"
# Ollama Vision LLM (CT 108) # AI Provider API Keys (set the ones you need)
OPENAI_API_KEY=""
GOOGLE_GENERATIVE_AI_API_KEY=""
ANTHROPIC_API_KEY=""
# Ollama (only needed if using Ollama as the AI provider)
OLLAMA_BASE_URL="http://192.168.68.108:11434" OLLAMA_BASE_URL="http://192.168.68.108:11434"
OLLAMA_MODEL="llava:7b" OLLAMA_MODEL="llava:7b"

157
package-lock.json generated
View file

@ -9,12 +9,17 @@
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.58",
"@ai-sdk/google": "^3.0.43",
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/openai-compatible": "^2.0.35",
"@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0",
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"ai": "^6.0.116",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -48,6 +53,116 @@
"typescript": "^5" "typescript": "^5"
} }
}, },
"node_modules/@ai-sdk/anthropic": {
"version": "3.0.58",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.58.tgz",
"integrity": "sha512-/53SACgmVukO4bkms4dpxpRlYhW8Ct6QZRe6sj1Pi5H00hYhxIrqfiLbZBGxkdRvjsBQeP/4TVGsXgH5rQeb8Q==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.19"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "3.0.66",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.66.tgz",
"integrity": "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.19",
"@vercel/oidc": "3.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/google": {
"version": "3.0.43",
"resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.43.tgz",
"integrity": "sha512-NGCgP5g8HBxrNdxvF8Dhww+UKfqAkZAmyYBvbu9YLoBkzAmGKDBGhVptN/oXPB5Vm0jggMdoLycZ8JReQM8Zqg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.19"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
"version": "3.0.41",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.41.tgz",
"integrity": "sha512-IZ42A+FO+vuEQCVNqlnAPYQnnUpUfdJIwn1BEDOBywiEHa23fw7PahxVtlX9zm3/zMvTW4JKPzWyvAgDu+SQ2A==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.19"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai-compatible": {
"version": "2.0.35",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-2.0.35.tgz",
"integrity": "sha512-g3wA57IAQFb+3j4YuFndgkUdXyRETZVvbfAWM+UX7bZSxA3xjes0v3XKgIdKdekPtDGsh4ZX2byHD0gJIMPfiA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.19"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "4.0.19",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.19.tgz",
"integrity": "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@ -2917,6 +3032,15 @@
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@prisma/adapter-pg": { "node_modules/@prisma/adapter-pg": {
"version": "7.4.2", "version": "7.4.2",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.2.tgz",
@ -5352,6 +5476,15 @@
"win32" "win32"
] ]
}, },
"node_modules/@vercel/oidc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
"integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@ -5397,6 +5530,24 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/ai": {
"version": "6.0.116",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.116.tgz",
"integrity": "sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "3.0.66",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.19",
"@opentelemetry/api": "1.9.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@ -9181,6 +9332,12 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",

View file

@ -13,12 +13,17 @@
"postinstall": "npx prisma generate" "postinstall": "npx prisma generate"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.58",
"@ai-sdk/google": "^3.0.43",
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/openai-compatible": "^2.0.35",
"@aws-sdk/client-s3": "^3.1005.0", "@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0", "@aws-sdk/s3-request-presigner": "^3.1005.0",
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"ai": "^6.0.116",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View file

@ -82,4 +82,7 @@ model AppSettings {
sourceRetentionDays Int @default(30) sourceRetentionDays Int @default(30)
imageRetentionDays Int @default(180) imageRetentionDays Int @default(180)
aiProvider String @default("ollama")
aiModel String @default("")
} }

View file

@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
import { google } from "@ai-sdk/google";
import { anthropic } from "@ai-sdk/anthropic";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModel } from "ai";
const DEFAULT_MODELS: Record<string, string> = {
openai: "gpt-4o-mini",
google: "gemini-2.5-flash",
anthropic: "claude-sonnet-4-20250514",
ollama: "llava:7b",
};
function getTestModel(
provider: string,
modelId: string,
ollamaUrl: string
): LanguageModel {
const resolvedModel = modelId || DEFAULT_MODELS[provider] || DEFAULT_MODELS.ollama;
switch (provider) {
case "openai":
return openai(resolvedModel);
case "google":
return google(resolvedModel);
case "anthropic":
return anthropic(resolvedModel);
case "ollama": {
const baseURL = (ollamaUrl || process.env.OLLAMA_BASE_URL || "http://192.168.68.108:11434") + "/v1";
const ollama = createOpenAICompatible({ name: "ollama", baseURL });
return ollama.chatModel(resolvedModel);
}
default:
throw new Error(`Unknown provider: ${provider}`);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const provider = body.provider || "ollama";
const modelId = body.model || "";
const ollamaUrl = body.ollamaUrl || "";
const model = getTestModel(provider, modelId, ollamaUrl);
const { text } = await generateText({
model,
prompt: "Reply with exactly: OK",
maxOutputTokens: 10,
});
return NextResponse.json({ ok: true, response: text.trim() });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error("[ai-test]", message);
return NextResponse.json({ error: message }, { status: 500 });
}
}

View file

@ -35,6 +35,8 @@ export async function PUT(request: NextRequest) {
if (body.watching != null) data.watching = Boolean(body.watching); if (body.watching != null) data.watching = Boolean(body.watching);
if (body.sourceRetentionDays != null) data.sourceRetentionDays = Math.max(1, parseInt(String(body.sourceRetentionDays)) || 30); if (body.sourceRetentionDays != null) data.sourceRetentionDays = Math.max(1, parseInt(String(body.sourceRetentionDays)) || 30);
if (body.imageRetentionDays != null) data.imageRetentionDays = Math.max(1, parseInt(String(body.imageRetentionDays)) || 180); if (body.imageRetentionDays != null) data.imageRetentionDays = Math.max(1, parseInt(String(body.imageRetentionDays)) || 180);
if (body.aiProvider != null) data.aiProvider = String(body.aiProvider);
if (body.aiModel != null) data.aiModel = String(body.aiModel);
const settings = await prisma.appSettings.upsert({ const settings = await prisma.appSettings.upsert({
where: { id: "singleton" }, where: { id: "singleton" },

View file

@ -2,7 +2,7 @@
import * as React from "react"; import * as React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2, Save, Wifi, WifiOff, Trash2 } from "lucide-react"; import { Loader2, Save, Wifi, WifiOff, Trash2, Brain } from "lucide-react";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -10,6 +10,20 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const AI_PROVIDERS = [
{ value: "openai", label: "OpenAI", defaultModel: "gpt-4o-mini", hint: "GPT-4o, GPT-4o-mini" },
{ value: "google", label: "Google Gemini", defaultModel: "gemini-2.5-flash", hint: "Gemini 2.5 Flash, Gemini 2.5 Pro" },
{ value: "anthropic", label: "Anthropic", defaultModel: "claude-sonnet-4-20250514", hint: "Claude Sonnet, Claude Haiku" },
{ value: "ollama", label: "Ollama (Local)", defaultModel: "llava:7b", hint: "llava:7b, moondream, llama3.2-vision" },
] as const;
type Settings = { type Settings = {
ollamaUrl: string; ollamaUrl: string;
@ -18,6 +32,8 @@ type Settings = {
watching: boolean; watching: boolean;
sourceRetentionDays: number; sourceRetentionDays: number;
imageRetentionDays: number; imageRetentionDays: number;
aiProvider: string;
aiModel: string;
}; };
export default function SettingsPage() { export default function SettingsPage() {
@ -28,10 +44,12 @@ export default function SettingsPage() {
watching: false, watching: false,
sourceRetentionDays: 30, sourceRetentionDays: 30,
imageRetentionDays: 180, imageRetentionDays: 180,
aiProvider: "ollama",
aiModel: "",
}); });
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 [ollamaStatus, setOllamaStatus] = React.useState<"unknown" | "connected" | "error">("unknown"); const [aiTestStatus, setAiTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle");
const [cleanupStatus, setCleanupStatus] = React.useState<{ sourcesEligible: number; imagesEligible: number } | null>(null); const [cleanupStatus, setCleanupStatus] = React.useState<{ sourcesEligible: number; imagesEligible: number } | null>(null);
const [cleaning, setCleaning] = React.useState(false); const [cleaning, setCleaning] = React.useState(false);
@ -74,26 +92,44 @@ export default function SettingsPage() {
} }
}; };
const testOllamaConnection = async () => { const testAiProvider = async () => {
setOllamaStatus("unknown"); setAiTestStatus("testing");
try { try {
const url = settings.ollamaUrl || "http://192.168.68.108:11434"; const res = await fetch("/api/ai-test", {
const res = await fetch(`${url}/api/tags`, { method: "POST",
signal: AbortSignal.timeout(5000), headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: settings.aiProvider,
model: settings.aiModel,
ollamaUrl: settings.ollamaUrl,
}),
signal: AbortSignal.timeout(15000),
}); });
if (res.ok) { if (res.ok) {
setOllamaStatus("connected"); setAiTestStatus("success");
toast.success("Connected to Ollama"); toast.success("AI provider connected successfully");
} else { } else {
setOllamaStatus("error"); const data = await res.json().catch(() => ({}));
toast.error("Ollama responded with an error"); setAiTestStatus("error");
toast.error(data.error || "AI provider test failed");
} }
} catch { } catch {
setOllamaStatus("error"); setAiTestStatus("error");
toast.error("Cannot reach Ollama. Check the URL and ensure it's running."); toast.error("Cannot reach AI provider. Check your configuration and API keys.");
} }
}; };
const handleProviderChange = (value: string | null) => {
if (!value) return;
const provider = AI_PROVIDERS.find((p) => p.value === value);
setSettings((s) => ({
...s,
aiProvider: value,
aiModel: provider?.defaultModel || "",
}));
setAiTestStatus("idle");
};
const toggleWatch = async () => { const toggleWatch = async () => {
try { try {
const action = settings.watching ? "stop" : "start"; const action = settings.watching ? "stop" : "start";
@ -149,31 +185,69 @@ export default function SettingsPage() {
</Header> </Header>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
{/* Ollama Configuration */} {/* AI Provider */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle className="text-base">Ollama Configuration</CardTitle> <CardTitle className="flex items-center gap-2 text-base">
<CardDescription>Connect to your Ollama instance for OCR processing</CardDescription> <Brain className="size-4" />
AI Provider
</CardTitle>
<CardDescription>Choose the AI model for OCR processing</CardDescription>
</div> </div>
<Badge <Badge
variant="secondary" variant="secondary"
className={ className={
ollamaStatus === "connected" aiTestStatus === "success"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300" ? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
: ollamaStatus === "error" : aiTestStatus === "error"
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300" ? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
: "" : ""
} }
> >
{ollamaStatus === "connected" && <Wifi className="mr-1 size-3" />} {aiTestStatus === "success" && <Wifi className="mr-1 size-3" />}
{ollamaStatus === "error" && <WifiOff className="mr-1 size-3" />} {aiTestStatus === "error" && <WifiOff className="mr-1 size-3" />}
{ollamaStatus === "connected" ? "Connected" : ollamaStatus === "error" ? "Error" : "Not tested"} {aiTestStatus === "testing" && <Loader2 className="mr-1 size-3 animate-spin" />}
{aiTestStatus === "success"
? "Connected"
: aiTestStatus === "error"
? "Error"
: aiTestStatus === "testing"
? "Testing..."
: "Not tested"}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Provider</Label>
<Select value={settings.aiProvider} onValueChange={handleProviderChange}>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{AI_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Model</Label>
<Input
value={settings.aiModel}
onChange={(e) => setSettings((s) => ({ ...s, aiModel: e.target.value }))}
placeholder={AI_PROVIDERS.find((p) => p.value === settings.aiProvider)?.defaultModel || ""}
/>
<p className="mt-1 text-xs text-muted-foreground">
{AI_PROVIDERS.find((p) => p.value === settings.aiProvider)?.hint || ""}
</p>
</div>
{settings.aiProvider === "ollama" && (
<div> <div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Ollama URL</Label> <Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Ollama URL</Label>
<Input <Input
@ -182,18 +256,29 @@ export default function SettingsPage() {
placeholder="http://192.168.68.108:11434" placeholder="http://192.168.68.108:11434"
/> />
</div> </div>
<div> )}
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">Model</Label>
<Input {settings.aiProvider !== "ollama" && (
value={settings.model} <div className="rounded-lg border bg-muted/50 p-3">
onChange={(e) => setSettings((s) => ({ ...s, model: e.target.value }))} <p className="text-xs text-muted-foreground">
placeholder="llava:7b" API key is read from the environment variable:{" "}
/> <code className="rounded bg-muted px-1 py-0.5">
<p className="mt-1 text-xs text-muted-foreground"> {settings.aiProvider === "openai"
Recommended: llava:7b, moondream, or llama3.2-vision ? "OPENAI_API_KEY"
: settings.aiProvider === "google"
? "GOOGLE_GENERATIVE_AI_API_KEY"
: "ANTHROPIC_API_KEY"}
</code>
</p> </p>
</div> </div>
<Button variant="outline" size="sm" onClick={testOllamaConnection}> )}
<Button variant="outline" size="sm" onClick={testAiProvider} disabled={aiTestStatus === "testing"}>
{aiTestStatus === "testing" ? (
<Loader2 className="mr-2 size-3 animate-spin" />
) : (
<Wifi className="mr-2 size-3" />
)}
Test Connection Test Connection
</Button> </Button>
</CardContent> </CardContent>

175
src/lib/ai-ocr.ts Normal file
View file

@ -0,0 +1,175 @@
import { generateText, Output } from "ai";
import { openai } from "@ai-sdk/openai";
import { google } from "@ai-sdk/google";
import { anthropic } from "@ai-sdk/anthropic";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { z } from "zod";
import { prisma } from "./db";
import type { LanguageModel } from "ai";
const DEFAULT_MODELS: Record<string, string> = {
openai: "gpt-4o-mini",
google: "gemini-2.5-flash",
anthropic: "claude-sonnet-4-20250514",
ollama: "llava:7b",
};
async function getSettings() {
let settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
if (!settings) {
settings = await prisma.appSettings.create({
data: { id: "singleton" },
});
}
return settings;
}
function getModelInstance(
provider: string,
modelId: string,
ollamaUrl: string
): LanguageModel {
const resolvedModel = modelId || DEFAULT_MODELS[provider] || DEFAULT_MODELS.ollama;
switch (provider) {
case "openai":
return openai(resolvedModel);
case "google":
return google(resolvedModel);
case "anthropic":
return anthropic(resolvedModel);
case "ollama": {
const baseURL = (ollamaUrl || process.env.OLLAMA_BASE_URL || "http://192.168.68.108:11434") + "/v1";
const ollama = createOpenAICompatible({
name: "ollama",
baseURL,
});
return ollama.chatModel(resolvedModel);
}
default:
throw new Error(`Unknown AI provider: ${provider}`);
}
}
const responseCardSchema = z.object({
name: z.string().nullable().describe("Full name as written on the card"),
gender: z.string().nullable().describe("Male or Female"),
dateOfBirth: z.string().nullable().describe("Date of birth as written"),
maritalStatus: z.string().nullable().describe("Married, Single, or Other"),
maritalStatusOther: z.string().nullable().describe("Value if Other is checked"),
visitType: z.string().nullable().describe("First/Second Time Guest or Update My Information"),
cellPhone: z.string().nullable().describe("Cell phone number"),
homePhone: z.string().nullable().describe("Home phone number"),
email: z.string().nullable().describe("Email address"),
address: z.string().nullable().describe("Street address"),
aptNumber: z.string().nullable().describe("Apartment number"),
city: z.string().nullable().describe("City"),
state: z.string().nullable().describe("State"),
zip: z.string().nullable().describe("ZIP code"),
prayerRequests: z.string().nullable().describe("Written prayer requests"),
prayerForTeam: z.boolean().describe("Whether prayer team checkbox is checked"),
prayerConfidential: z.boolean().describe("Whether confidential checkbox is checked"),
confidence: z.number().min(0).max(100).describe("Your confidence in the OCR accuracy, 0-100"),
});
const surveySchema = z.object({
messageTopics: z.array(z.string()).describe(
"Checked topics from: Stress, Marriage, Revival, Addiction, Parenting, Miracles, Forgiveness, Finances, My Identity, Conflict Resolution, The Holy Spirit, Understanding The Bible, Spiritual Warfare, Sharing My Faith, Anxiety, Heaven, Spiritual Gifts"
),
messageTopicsOther: z.string().nullable().describe("Value if Other is filled in"),
nextStep: z.array(z.string()).describe("Checked items from: Baptism, Next Steps"),
attendanceDuration: z.string().nullable().describe(
"Less than 6 months, 6 Months - 1 Year, 1-3 Years, 4-6 Years, or 7+ Years"
),
campusPreference: z.array(z.string()).describe(
"Checked locations from: Beulah, Pace/Milton, Gulf Breeze, Warrington"
),
campusPreferenceOther: z.string().nullable().describe("Value if Other is filled in"),
howHeard: z.array(z.string()).describe(
"Checked items from: This is my church home, Regular Attender, Drove by, Social Media, Google, Personal Invite"
),
howHeardOther: z.string().nullable().describe("Value if Other is filled in"),
serviceAttended: z.string().nullable().describe("Service letter: A, B, C, or D"),
confidence: z.number().min(0).max(100).describe("Your confidence in the OCR accuracy, 0-100"),
});
const RESPONSE_SYSTEM_PROMPT = `You are analyzing a scanned church response card. This is the PERSONAL INFORMATION side.
Extract ALL of the following fields from the image. For checkboxes, determine if they are checked or unchecked.
For handwritten text, read it as accurately as possible.
Be precise: return null for fields you cannot read. Set prayerForTeam and prayerConfidential to false if the checkboxes are not clearly marked.`;
const SURVEY_SYSTEM_PROMPT = `You are analyzing a scanned church Easter survey form. This is the SURVEY side.
Extract ALL of the following fields. For checkboxes, determine if they are checked (filled/marked) or unchecked (empty).
Return empty arrays for checkbox groups where nothing is checked.
Be precise: return null for fields you cannot read.`;
export interface OcrResult {
data: Record<string, unknown>;
confidence: number;
raw: string;
side: "response" | "survey";
}
async function extractStructured<T extends Record<string, unknown>>(
model: LanguageModel,
schema: z.ZodType<T>,
systemPrompt: string,
imageBase64: string
): Promise<T> {
const { output, text } = await generateText({
model,
output: Output.object({ schema }),
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content: [
{ type: "text", text: "Extract all data from this scanned card image." },
{ type: "image", image: Buffer.from(imageBase64, "base64") },
],
},
],
});
if (!output) {
throw new Error(`AI model did not return structured output. Raw text: ${(text || "").slice(0, 500)}`);
}
return output;
}
export async function ocrImage(
imageBase64: string,
side: "response" | "survey"
): Promise<OcrResult> {
const settings = await getSettings();
const provider = settings.aiProvider || "ollama";
const modelId = settings.aiModel || "";
const model = getModelInstance(provider, modelId, settings.ollamaUrl);
let output: Record<string, unknown>;
if (side === "response") {
output = await extractStructured(model, responseCardSchema, RESPONSE_SYSTEM_PROMPT, imageBase64);
} else {
output = await extractStructured(model, surveySchema, SURVEY_SYSTEM_PROMPT, imageBase64);
}
const confidence = typeof output.confidence === "number" ? output.confidence : 50;
const data = { ...output };
delete data.confidence;
return { data, confidence, raw: JSON.stringify(output), side };
}
export { DEFAULT_MODELS };

View file

@ -1,6 +1,6 @@
import { prisma } from "./db"; import { prisma } from "./db";
import { uploadBuffer } from "./minio"; import { uploadBuffer } from "./minio";
import { ocrImage } from "./ollama"; import { ocrImage } from "./ai-ocr";
import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf"; import { pdfToImages, imageToBase64, processUploadedImage } from "./pdf";
export async function processFile( export async function processFile(