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