Archived
94 lines
3.1 KiB
TypeScript
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 };
|
|
}
|