import { prisma } from '@/lib/db'; import { resolveLlm } from './provider'; import { safeParse } from './parse'; /** * Synthesize a single fetched article into an original brief. * * Keeps the original text (for provenance "editor's notes") and writes the * rewritten headline/body/takeaways/tags back to the row. Returns the * updated article, or null when the article is missing. * * Throws when synthesis truly fails so the caller can catch, mark status * 'failed', and log — the ingest loop must never crash on one bad article. */ export async function synthesizeArticle(id: string) { const { instance: provider, provider: settings } = await resolveLlm(); const article = await prisma.article.findUnique({ where: { id }, include: { feed: true }, }); if (!article) return null; // Compose the source text the model re-synthesizes. const sourceParts: string[] = []; if (article.title) sourceParts.push(`Headline: ${article.title}`); if (article.author) sourceParts.push(`By: ${article.author}`); if (article.originalText) sourceParts.push(article.originalText); sourceParts.push(`Original URL: ${article.sourceUrl}`); const sources = article.sources ? safeParse(article.sources).filter(Boolean) : [article.siteName].filter(Boolean); const result = await provider.synthesize({ sourceText: sourceParts.join('\n\n').slice(0, 14_000), sources, systemPrompt: settings.synthesisPrompt, }); return prisma.article.update({ where: { id }, data: { headline: result.headline, body: result.body, takeaways: JSON.stringify(result.takeaways), tags: JSON.stringify(result.tags), llmProvider: result.provider, synthesizedAt: new Date(), status: 'synthesized', }, }); } /** * Find up to `limit` fetched-but-not-yet-synthesized articles and synthesize * each. Idempotent: already-synthesized rows are skipped. */ export async function synthesizePending(limit: number = 24) { const pending = await prisma.article.findMany({ where: { status: 'fetched', body: null }, orderBy: { publishedAt: 'asc' }, take: limit, }); let ok = 0; let failed = 0; // Dead-letter: a row that is still `fetched` after 24h of continuous // failure will never succeed on its own (poison content, oversized // source, provider rejection). Mark it `failed` so it stops consuming // a slot in every cron pass and out of the backlog. Manual re-queue by // resetting status to `fetched` if the cause is later fixed. const deadAfter = new Date(Date.now() - 24 * 60 * 60 * 1000); for (const a of pending) { try { await synthesizeArticle(a.id); ok += 1; } catch (err) { failed += 1; const dead = a.createdAt < deadAfter; const why = (err as Error).message; if (dead) { await prisma.article.update({ where: { id: a.id }, data: { status: 'failed' }, }); console.error(`[synth] DEAD-LETTER ${a.slug} (failed >24h): ${why}`); } else { console.error(`[synth] failed article ${a.slug}: ${why}`); } } } return { total: pending.length, ok, failed }; }