← back to Small Business Builder

src/lib/ollama-draft.js

184 lines

// Ollama-backed AI draft for shop interviews.
//
// Calls local qwen3:14b on Mac Studio 1 (per Steve's memory:
// "Default Ollama target = Mac Studio 1 — bulk LLM workloads point at
//  http://192.168.1.133:11434, not localhost. MS2 froze 2026-05-02 with
//  v3+v5+codex on the same GPU.")
//
// Returns {qa: {<question_id>: <draft_answer>, ...}} so the owner can
// open the interview form and edit before saving.

import { QUESTIONS, localizeQuestion } from './interview-questions.js';

// Primary Ollama target = MS1 (per Steve's memory). Fallback to localhost
// (Mac2) if MS1 is unreachable OR has its known wedge condition where /api/ps
// shows a model loaded but /api/generate hangs indefinitely. Override either
// via env var.
const OLLAMA_URLS = [
  process.env.OLLAMA_URL || 'http://192.168.1.133:11434',
  process.env.OLLAMA_FALLBACK_URL || 'http://127.0.0.1:11434',
];
// Default to gemma3:12b — typically faster than qwen3:14b on Apple Silicon
// AND avoids the "thinking-tokens eat the budget" issue qwen3 has under
// /api/generate. Confirmed loaded on MS1; Mac2 needs `ollama pull gemma3:12b`
// once (run by bulk-draft warmup or manually). Override via env.
const OLLAMA_MODEL = process.env.OLLAMA_DRAFT_MODEL || 'gemma3:12b';

// Trim a JSON-shaped response from a chatty LLM. qwen3 sometimes wraps the
// JSON in ```json fences or prepends a sentence; we only want the first {…}.
function extractJson(text) {
  if (!text) return null;
  const fenced = text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
  if (fenced) {
    try { return JSON.parse(fenced[1]); } catch {}
  }
  const m = text.match(/\{[\s\S]*\}/);
  if (m) {
    try { return JSON.parse(m[0]); } catch {}
  }
  return null;
}

function buildPrompt(business) {
  const b = business;
  // Build the question list with localized phrasing + max-char hints.
  const questionLines = QUESTIONS.map(q => {
    const lq = localizeQuestion(q, b);
    return `  - id: ${q.id}  (≤${q.max_chars} chars · section: ${q.section})\n    Q: ${lq.q}\n    Example tone: ${lq.placeholder}`;
  }).join('\n');

  // Pull every scrap of context we have on the shop.
  const contextBits = [
    `Name: ${b.name || '(unknown)'}`,
    b.neighborhood && `Neighborhood: ${b.neighborhood}`,
    b.city && `City: ${b.city}`,
    b.address && `Address: ${b.address}`,
    b.category && `Category: ${b.category}`,
    b.years_in_business && `Years in business: ${b.years_in_business}`,
    b.owner_name && `Owner: ${b.owner_name}`,
    b.website && `Website: ${b.website}`,
    b.about_text && `About text on file: ${b.about_text}`,
    b.source_data_json?.vibe && `Editor's vibe note: ${b.source_data_json.vibe}`,
  ].filter(Boolean).join('\n');

  return `/no_think
You are drafting a magazine-style interview profile for ${b.name}, a ${b.category || 'salon'} in ${b.neighborhood || 'Los Angeles'}.

CONTEXT WE KNOW:
${contextBits}

YOUR TASK: Draft answers for each question below. The answers will be EDITED by the actual shop owner before publication, so this is a starter draft, not a final voice. Aim for:
- specific, opinionated, plain-English (no marketing copy, no "we strive to")
- when you don't know a real fact (e.g., a real restaurant name nearby), invent a plausible-sounding LA neighborhood name they MIGHT recommend, with a clear "[VERIFY]" tag the owner will replace
- 1-3 sentences per answer, tight
- match the example-tone shown for each question

QUESTIONS:
${questionLines}

OUTPUT FORMAT: Return ONLY valid JSON, no commentary. Shape:
{
  "qa": {
    "origin_story": "We took over this space in...",
    "who_walks_in": "...",
    ...one key per question id above
  }
}

If you genuinely cannot draft a question (e.g., asks about something you have zero signal on), set its value to "" — the owner will fill it.
Output the JSON now.`;
}

// Probe an Ollama host to confirm /api/generate (not just /api/ps) actually
// works. The MS1 wedge fails this — model shows in /api/ps but generate hangs.
// Long timeout so a cold-load (qwen3:14b ~ 14GB on Apple Silicon ≈ 30-60s
// first-touch) counts as a successful warmup instead of failing fast.
async function probeOllama(url, perHostTimeoutMs = 75000) {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), perHostTimeoutMs);
  try {
    const res = await fetch(`${url}/api/generate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      signal: ctrl.signal,
      body: JSON.stringify({
        model: OLLAMA_MODEL, prompt: 'ok', stream: false,
        options: { num_predict: 2 }, keep_alive: '20m',
      }),
    });
    return res.ok;
  } catch { return false; }
  finally { clearTimeout(t); }
}

async function pickOllamaUrl() {
  for (const url of OLLAMA_URLS) {
    if (await probeOllama(url)) return url;
  }
  return null;
}

// Cheap helper: probe N hosts in parallel and return the responsive ones.
// Used by bulk-draft scripts that want to fan out across alive hosts.
export async function aliveHosts() {
  const probes = await Promise.all(
    OLLAMA_URLS.map(async (u) => ({ url: u, ok: await probeOllama(u) }))
  );
  return probes.filter(p => p.ok).map(p => p.url);
}

export const KNOWN_HOSTS = OLLAMA_URLS.slice();

export async function draftInterview(business, { timeoutMs = 240_000, host } = {}) {
  // If a specific host was pinned (e.g., bulk worker pinned to MS1), skip
  // discovery and trust the caller. Otherwise auto-pick the first responsive.
  const url = host || await pickOllamaUrl();
  if (!url) {
    throw new Error(
      'No Ollama host responsive. Tried: ' + OLLAMA_URLS.join(', ') +
      '. (MS1 wedge? `ssh user@192.168.1.133 pkill ollama && ollama serve &`)'
    );
  }
  const prompt = buildPrompt(business);
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
  try {
    const res = await fetch(`${url}/api/generate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      signal: ctrl.signal,
      body: JSON.stringify({
        model: OLLAMA_MODEL,
        prompt,
        // Don't enforce format:json — qwen3's <think> tokens get truncated and
        // produce {}. We extract JSON from free-text response below.
        stream: false,
        keep_alive: '30m',
        options: { temperature: 0.7, num_predict: 4000 },
      }),
    });
    if (!res.ok) throw new Error(`Ollama HTTP ${res.status}: ${await res.text().catch(() => '')}`);
    const j = await res.json();
    // Strip <think>...</think> blocks before extracting JSON.
    const stripped = String(j.response || '').replace(/<think>[\s\S]*?<\/think>/g, '').trim();
    const parsed = extractJson(stripped);
    if (!parsed || !parsed.qa) {
      throw new Error('Ollama returned no parseable JSON. raw: ' + String(j.response).slice(0, 400));
    }
    const known = new Set(QUESTIONS.map(q => q.id));
    const qa = {};
    for (const [k, v] of Object.entries(parsed.qa)) {
      if (!known.has(k)) continue;
      const s = String(v || '').trim();
      if (s) qa[k] = s.slice(0, 4000);
    }
    return {
      qa, model: OLLAMA_MODEL, host: url,
      eval_count: j.eval_count,
      total_duration_ms: Math.round((j.total_duration || 0) / 1e6),
    };
  } finally {
    clearTimeout(timer);
  }
}