Archived
MapleBrief: production Canadian news aggregator
Next.js 14 + TypeScript + Tailwind + Prisma/SQLite. - LLM synthesis abstraction (OpenAI/Anthropic/Ollama) + admin settings UI - RSS ingestion pipeline (parser + cheerio content) + node-cron worker - AdSense AdUnit placements (header/in-feed/in-article/sidebar) - Sources analysis attribution, nofollow links, canonical/OG, sitemap/robots - Admin auth (ADMIN_API_KEY, timingSafeEqual, fail-closed 401) - Multi-cell lady-ga-ga marker, sitemap, robots, legal pages - Comprehensive README (setup, LLM config, AdSense, migrations, deploy)
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
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;
|
||||
for (const a of pending) {
|
||||
try {
|
||||
await synthesizeArticle(a.id);
|
||||
ok += 1;
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.error(`[synth] failed article ${a.slug}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
return { total: pending.length, ok, failed };
|
||||
}
|
||||
Reference in New Issue
Block a user