diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index 77521e0..30f3034 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -6,7 +6,7 @@ import * as categoriesDb from '../storage/db/categories.js'; import * as stocksDb from '../storage/db/stocks.js'; import * as bookmarksDb from '../storage/db/bookmarks.js'; import * as poe2WatchlistDb from '../storage/db/poe2Watchlist.js'; -import { clearSourceContent, reissueSourceContent, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js'; +import { clearSourceContent, reissueSourceContent, reissueArticle, clearAllArticles, clearAllMedia } from '../storage/contentCascade.js'; import { totalStorageBytes } from '../storage/media/index.js'; import { OllamaProvider } from '../inference/ollama-provider.js'; import { pollSourceNow } from '../ingestion/poller.js'; @@ -151,6 +151,17 @@ export async function registerAdminRoutes(app: FastifyInstance) { return reissueSourceContent(id); }); + // Fixes one specific bad article (e.g. a degenerate/empty AI synthesis — see + // synthesis.ts's assertNonEmpty) by deleting it and requeuing every item it merged, + // regardless of how many different sources contributed — reissueSourceContent above + // deliberately won't touch a multi-source article at all. + app.post('/api/admin/articles/:id/reissue', async (req, reply) => { + const { id } = req.params as { id: string }; + const result = reissueArticle(id); + if (!result) return reply.code(404).send({ error: 'not found' }); + return result; + }); + // --- Tracked events --- app.get('/api/admin/events', async () => eventsDb.listEvents()); diff --git a/backend/src/pipeline/synthesis.ts b/backend/src/pipeline/synthesis.ts index dfdd5be..d9bcf83 100644 --- a/backend/src/pipeline/synthesis.ts +++ b/backend/src/pipeline/synthesis.ts @@ -163,6 +163,23 @@ function parseResult(raw: string): SynthesisResult { return { title, body, tagLabels }; } +/** + * A quantized/small model occasionally reproduces just the requested delimiter + * scaffold ("---TITLE---\n\n---TAGS---") with no real headline or article text in + * between — a structurally "valid" response by parseResult's own logic (delimiters + * found, nothing crashed) but empty in substance. Left unchecked this published a + * blank article (empty title/body, still with real sources/hero image attached) once + * in production. Treating an empty body as a hard failure lets the caller's existing + * catch-and-retry logic (see priorityQueue.ts's runSynthesisCycle) leave the cluster + * unclustered for the next cycle instead of ever inserting one of these. + */ +function assertNonEmpty(result: SynthesisResult, context: string): SynthesisResult { + if (!result.body.trim()) { + throw new Error(`Model returned an empty article body for ${context}`); + } + return result; +} + export async function synthesizeArticle( provider: InferenceProvider, model: string, @@ -174,7 +191,7 @@ export async function synthesizeArticle( const system = SYSTEM_PROMPT_BASE + styleAddendum(settings); const label = `Merging ${items.length} source${items.length === 1 ? '' : 's'}: "${items[0]?.title.slice(0, 60) ?? ''}"`; const raw = await provider.generate(prompt, { model, system, numCtx: DEFAULT_NUM_CTX, numPredict: DEFAULT_NUM_PREDICT, label }); - return parseResult(raw); + return assertNonEmpty(parseResult(raw), `"${items[0]?.title.slice(0, 60) ?? ''}"`); } function buildRecapPrompt(eventName: string, articles: MergedArticle[]): string { @@ -214,5 +231,5 @@ export async function synthesizeRecap( numPredict: DEFAULT_NUM_PREDICT, label: `Recapping event: "${eventName.slice(0, 60)}"` }); - return parseResult(raw); + return assertNonEmpty(parseResult(raw), `event recap "${eventName.slice(0, 60)}"`); } diff --git a/backend/src/storage/contentCascade.ts b/backend/src/storage/contentCascade.ts index 3757245..15ef491 100644 --- a/backend/src/storage/contentCascade.ts +++ b/backend/src/storage/contentCascade.ts @@ -86,6 +86,29 @@ export function reissueSourceContent(sourceId: string): ReissueResult { return { articlesDeleted, itemsRequeued: requeueIds.size }; } +/** + * Deletes one specific article (and its media) and requeues every content item that + * contributed to it — unlike reissueSourceContent, this works regardless of how many + * different sources the article merged together, since it's scoped to the article + * itself rather than "everything from source X". Exists for exactly the failure mode + * synthesis.ts's assertNonEmpty guards against going forward: a bad synthesis call + * that already made it into a published (garbage) article before that guard existed, + * where the source-scoped reissue tools can't help because the article spans sources. + * Returns null if the article doesn't exist. + */ +export function reissueArticle(articleId: string): ReissueResult | null { + const article = articlesDb.getArticle(articleId); + if (!article) return null; + + const itemIds = article.sources.map((s) => s.itemId); + deleteMediaByArticleId(article.id); + articlesDb.deleteArticle(article.id); + contentItemsDb.resetClusterForItems(itemIds); + + logger.info('admin', `Reissuing article ${articleId}: deleted, ${itemIds.length} item(s) requeued`); + return { articlesDeleted: 1, itemsRequeued: itemIds.length }; +} + /** Wipes every published article and its media, keeping raw ingested items intact so they can be re-synthesized fresh. */ export function clearAllArticles(): number { const articles = articlesDb.allArticlesNewestFirst();