← back to Govarbitrage
src/lib/ai.ts
123 lines
// Local-first AI layer. Product identification runs on Ollama (this machine,
// $0/query). Anthropic is an opt-in fallback only, enabled by setting
// AI_PROVIDER=anthropic and ANTHROPIC_API_KEY. If no model is reachable, callers
// fall back to the deterministic heuristic in engines/demand.ts.
const OLLAMA_BASE = process.env.OLLAMA_BASE_URL || "http://localhost:11434";
const TEXT_MODEL = process.env.OLLAMA_TEXT_MODEL || "qwen3:14b";
const VISION_MODEL = process.env.OLLAMA_VISION_MODEL || "qwen2.5vl:7b";
export interface AiIdentification {
manufacturer?: string;
model?: string;
category?: string;
estimatedNewRetail?: number;
demandScore?: number; // 0..100
confidence?: number; // 0..1
reasoning?: string;
}
const SYSTEM = `You are a resale-arbitrage product analyst. Given a government-surplus
auction listing, identify the product and estimate its market. Respond ONLY with
compact JSON matching:
{"manufacturer":string,"model":string,"category":string,"estimatedNewRetail":number,"demandScore":number,"confidence":number,"reasoning":string}
demandScore is 0-100 (secondary-market liquidity). confidence is 0-1. Use USD.`;
async function ollamaJson(prompt: string, model = TEXT_MODEL, images?: string[]): Promise<AiIdentification | null> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 45_000);
const res = await fetch(`${OLLAMA_BASE}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
model,
prompt: `${SYSTEM}\n\nLISTING:\n${prompt}`,
stream: false,
format: "json",
options: { temperature: 0.1 },
...(images && images.length ? { images } : {}),
}),
});
clearTimeout(timeout);
if (!res.ok) return null;
const data = (await res.json()) as { response?: string };
if (!data.response) return null;
return JSON.parse(data.response) as AiIdentification;
} catch {
return null;
}
}
/** Identify a product from listing text (and optionally base64 images). */
export async function identifyProduct(args: {
title: string;
description?: string | null;
category?: string | null;
imagesBase64?: string[];
}): Promise<{ result: AiIdentification | null; model: string }> {
const provider = process.env.AI_PROVIDER || "ollama";
const text = [
`Title: ${args.title}`,
args.category ? `Category: ${args.category}` : "",
args.description ? `Description: ${args.description}` : "",
]
.filter(Boolean)
.join("\n");
// Vision path when images are supplied and a vision model is configured.
if (args.imagesBase64 && args.imagesBase64.length) {
const v = await ollamaJson(text, VISION_MODEL, args.imagesBase64);
if (v) return { result: v, model: `ollama:${VISION_MODEL}` };
}
const t = await ollamaJson(text, TEXT_MODEL);
if (t) return { result: t, model: `ollama:${TEXT_MODEL}` };
// Opt-in cloud fallback.
if (provider === "anthropic" && process.env.ANTHROPIC_API_KEY) {
const a = await anthropicJson(text);
if (a) return { result: a, model: "anthropic:claude" };
}
return { result: null, model: "heuristic" };
}
async function anthropicJson(text: string): Promise<AiIdentification | null> {
try {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.ANTHROPIC_API_KEY as string,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-haiku-4-5-20251001",
max_tokens: 512,
system: SYSTEM,
messages: [{ role: "user", content: `LISTING:\n${text}` }],
}),
});
if (!res.ok) return null;
const data = (await res.json()) as { content?: { text?: string }[] };
const raw = data.content?.[0]?.text;
if (!raw) return null;
const match = raw.match(/\{[\s\S]*\}/);
return match ? (JSON.parse(match[0]) as AiIdentification) : null;
} catch {
return null;
}
}
/** Is a local model reachable? Used by health checks + the worker banner. */
export async function ollamaReachable(): Promise<boolean> {
try {
const res = await fetch(`${OLLAMA_BASE}/api/tags`, { signal: AbortSignal.timeout(3000) });
return res.ok;
} catch {
return false;
}
}