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:
2026-08-15 16:07:07 -04:00
commit 1010f27f44
76 changed files with 7177 additions and 0 deletions
+77
View File
@@ -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 };
}