← back to Small Business Builder

scripts/bulk-draft-interviews.js

101 lines

#!/usr/bin/env node
// Bulk AI-draft every business that doesn't yet have an interview.
//
// Discovers alive Ollama hosts (MS1 + Mac2) at startup, then runs ONE worker
// per alive host — each worker pulls from a shared queue and pins its draft
// requests to its assigned host. If MS1 is wedged we get serial Mac2-only
// throughput; if both alive we get ~2× throughput automatically.
//
//   node scripts/bulk-draft-interviews.js             # all unfilled, all alive hosts
//   LIMIT=10 node scripts/bulk-draft-interviews.js    # cap to first 10
//   DRY=1 node scripts/bulk-draft-interviews.js       # plan only, no LLM/DB
//   HOSTS=http://127.0.0.1:11434 node ...             # force a specific host

import 'dotenv/config';
import { many, query } from '../src/lib/db.js';
import { draftInterview, aliveHosts, KNOWN_HOSTS } from '../src/lib/ollama-draft.js';

const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : null;
const DRY   = process.env.DRY === '1';
const FORCED_HOSTS = process.env.HOSTS ? process.env.HOSTS.split(',').map(s => s.trim()) : null;

function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
function log(msg) { console.log(`[${ts()}] ${msg}`); }

async function main() {
  const todo = await many(`
    SELECT b.id, b.slug, b.name, b.neighborhood, b.category, b.address,
           b.years_in_business, b.owner_name, b.website, b.about_text, b.source_data_json
      FROM businesses b
      LEFT JOIN LATERAL (
        SELECT 1 FROM business_interviews i
         WHERE i.business_id = b.id
           AND jsonb_typeof(i.qa_json) = 'object' AND i.qa_json <> '{}'::jsonb
         LIMIT 1
      ) hasInt ON true
     WHERE hasInt IS NULL
     ORDER BY b.ranking_score DESC NULLS LAST, b.id
     ${LIMIT ? `LIMIT ${LIMIT}` : ''}
  `);
  log(`${todo.length} businesses need a draft${DRY ? ' (DRY RUN)' : ''}`);
  if (DRY) {
    todo.slice(0, 20).forEach(b => log(`  • ${b.slug} (${b.neighborhood || '—'} · ${b.category || '—'})`));
    return;
  }

  // Discover alive hosts. If user forced a list, trust them.
  let hosts = FORCED_HOSTS || await aliveHosts();
  if (!hosts.length) {
    log(`✗ No Ollama host responsive. Tried: ${KNOWN_HOSTS.join(', ')}`);
    log(`  MS1 fix: ssh stevestudio@192.168.1.133 'pkill ollama && nohup ollama serve >/tmp/ollama.log 2>&1 &'`);
    process.exit(1);
  }
  log(`alive hosts (${hosts.length}): ${hosts.join(', ')}`);

  // Shared queue + counters. Workers race for items via shift().
  const queue = todo.slice();
  let ok = 0, fail = 0, fields = 0, msTotal = 0;
  const startAt = Date.now();
  const total = queue.length;

  async function worker(host) {
    while (queue.length) {
      const b = queue.shift();
      if (!b) break;
      const idx = total - queue.length;
      const head = `[${idx}/${total} · ${host.replace(/.*\/\//, '')}] ${b.slug}`;
      try {
        const t0 = Date.now();
        const result = await draftInterview(b, { host });
        const ms = Date.now() - t0;
        const fc = Object.keys(result.qa).length;
        if (!fc) throw new Error('LLM returned 0 fields');
        await query(
          `INSERT INTO business_interviews (business_id, qa_json, recorded_by) VALUES ($1, $2, $3)`,
          [b.id, JSON.stringify(result.qa), `AI Draft (${result.model})`]
        );
        ok += 1; fields += fc; msTotal += ms;
        log(`${head} ✓ ${fc} fields, ${ms}ms`);
      } catch (e) {
        fail += 1;
        const msg = String(e.message || e).slice(0, 200);
        log(`${head} ✗ ${msg}`);
        // If the host wedges mid-run (request times out / aborts), this worker
        // exits — the other worker keeps draining the queue.
        if (msg.includes('aborted') || msg.includes('No Ollama host')) {
          log(`  [${host}] worker exiting — host wedged`);
          return;
        }
      }
    }
  }

  await Promise.all(hosts.map(worker));

  const totalMs = Date.now() - startAt;
  log(`DONE · ok=${ok} fail=${fail} fields=${fields} avg=${ok ? Math.round(msTotal / ok) : 0}ms wall=${Math.round(totalMs / 1000)}s`);
  process.exit(fail > ok ? 1 : 0);
}

main().catch(e => { console.error(e); process.exit(2); });