← 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;
  }
}