← back to Ventura Corridor

src/jobs/summarize_news.ts

148 lines

/**
 * News-item summarizer.
 *
 * Pulls news_items WHERE summary IS NULL, prompts qwen3:14b on Mac1, writes
 * back a 2–3 sentence editorial summary. The magazine surface uses these
 * summaries — fast, scannable, "what changed at this business" feed.
 *
 * Run:
 *   npm run news:summarize                  # default 100 items
 *   tsx src/jobs/summarize_news.ts -- --limit=20
 */
import 'dotenv/config';
import { pool, query } from '../../db/pool.ts';

const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434'; // old 100.94.103.98 default = dead mac2-era tailnet IP
const MODEL  = process.env.OLLAMA_MODEL || 'qwen3:14b';
const TIMEOUT_MS = parseInt(process.env.OLLAMA_TIMEOUT_MS || '90000', 10);

const argv = process.argv.slice(2).filter(a => a !== '--');
const limit = parseInt(argv.find(a => a.startsWith('--limit='))?.slice(8) || '100', 10);

interface Row {
  id: number; business_id: number; title: string; body: string;
  business_name: string; business_category: string | null;
}

function buildPrompt(r: Row): string {
  const ctx = `${r.business_name}${r.business_category ? ` (${r.business_category})` : ''}`;
  return `You are an editorial summarizer for a Ventura Boulevard small-business magazine. Summarize the following news/blog post from ${ctx} in EXACTLY 2–3 plain-English sentences (max 60 words). Lead with the concrete update — what changed, what's new, what was announced. No marketing fluff, no generic hype. If the post is purely promotional/empty, write one sentence that names the topic neutrally.

TITLE: ${r.title}

BODY: ${r.body.slice(0, 3500)}

Summary:`;
}

function sleep(ms: number) { return new Promise<void>(r => setTimeout(r, ms)); }

async function callOllama(prompt: string, attempt = 0): Promise<string> {
  const res = await fetch(`${OLLAMA}/api/chat`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: MODEL, stream: false,
      messages: [
        { role: 'system', content: 'You are an editorial summarizer. Output only the requested 2–3 sentence summary, no preamble, no thinking tags, no <think> blocks.' },
        { role: 'user', content: prompt }
      ],
      options: { temperature: 0.3, num_ctx: 6144, num_predict: 200 }
    }),
    signal: AbortSignal.timeout(TIMEOUT_MS)
  });
  // Mac1 magazine-gen runs the same Ollama hot — 503 "server busy" is common
  // and can persist for hours on a 2000-feature run. Backoff up to 6 times:
  // 60s, 120s, 240s, 480s, 960s, 1920s. Total ~62min wait before giving up.
  // The summarize job is normally invoked at 5:30am via launchd long after
  // generate_features completes, so this almost always succeeds first try.
  if (res.status === 503 || res.status === 429) {
    if (attempt >= 6) throw new Error(`ollama ${res.status}: backoff exhausted`);
    const delay = 60000 * Math.pow(2, attempt);
    await sleep(delay);
    return callOllama(prompt, attempt + 1);
  }
  if (!res.ok) throw new Error(`ollama ${res.status}: ${(await res.text()).slice(0, 120)}`);
  const j: any = await res.json();
  let text = j?.message?.content?.trim() || '';
  // Strip <think>...</think> blocks qwen3 sometimes emits.
  text = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
  return text;
}

// Pre-flight: probe Ollama with a tiny call. If it returns 503 "server busy"
// immediately (no backoff), Mac1 is locked by another job (typically the
// 2000-feature generate_features run). Exit 0 cleanly so the launchd job
// reschedules instead of burning the patient backoff budget on every item.
async function preflight(): Promise<{ ok: boolean; reason?: string }> {
  try {
    const res = await fetch(`${OLLAMA}/api/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        model: MODEL, stream: false,
        messages: [{ role: 'user', content: 'reply with OK' }],
        options: { num_predict: 4, num_ctx: 256 }
      }),
      signal: AbortSignal.timeout(60000) // 15s died on qwen3:14b cold-load while another model held the GPU
    });
    if (res.status === 503 || res.status === 429) {
      const txt = (await res.text()).slice(0, 120);
      return { ok: false, reason: `mac1_busy_${res.status}: ${txt}` };
    }
    if (!res.ok) return { ok: false, reason: `mac1_${res.status}` };
    return { ok: true };
  } catch (e: any) {
    return { ok: false, reason: `mac1_unreachable: ${e?.message || e}` };
  }
}

async function main() {
  const pf = await preflight();
  if (!pf.ok) {
    console.log(`[summarize_news] DEFERRED — ${pf.reason}`);
    console.log(`[summarize_news] Will retry on the next scheduled launchd tick (5:30am daily) or when Steve runs manually.`);
    await pool.end();
    process.exit(0);
  }

  const r = await query<Row>(`
    SELECT n.id, n.business_id, n.title, n.body,
           b.name AS business_name, b.category AS business_category
      FROM news_items n
      JOIN businesses b ON b.id = n.business_id
     WHERE n.summary IS NULL
       AND n.body IS NOT NULL
       AND length(n.body) > 200
     ORDER BY n.fetched_at DESC
     LIMIT $1
  `, [limit]);

  console.log(`[summarize_news] ${r.rows.length} item(s) to summarize via ${MODEL} on ${OLLAMA}`);
  let ok = 0, fail = 0;
  for (let i = 0; i < r.rows.length; i++) {
    const row = r.rows[i];
    const t0 = Date.now();
    try {
      const prompt = buildPrompt(row);
      const summary = await callOllama(prompt);
      if (!summary || summary.length < 30) throw new Error('empty_or_short');
      await query(`
        UPDATE news_items
           SET summary = $1, summary_model = $2, summarized_at = now()
         WHERE id = $3
      `, [summary, MODEL, row.id]);
      ok++;
      const ms = Date.now() - t0;
      console.log(`  [${String(i + 1).padStart(3)}/${r.rows.length}] ✓ ${row.business_name.padEnd(28)} (${ms}ms) — ${summary.slice(0, 80)}…`);
    } catch (e: any) {
      fail++;
      console.warn(`  [${String(i + 1).padStart(3)}/${r.rows.length}] ✗ ${row.business_name} — ${e?.message || e}`);
    }
  }
  console.log(`[summarize_news] done · ok=${ok} fail=${fail}`);
  await pool.end();
}

main().catch(e => { console.error('FATAL', e); pool.end(); process.exit(1); });