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