This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maple-brief-aggregator/src/lib/llm/synthesize.ts
T

94 lines
3.1 KiB
TypeScript

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<string[]>(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 };
}