From 23be5086c56e11f053a80b878a5a161165d5fda2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 19:14:50 +0000 Subject: [PATCH] Fix blank-article publishing bug + add per-article reissue tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quantized model can occasionally return just the delimiter scaffold ("---TITLE---" / "---TAGS---") with no real headline or article text in between — parseResult treated that as a structurally valid response and published a blank article with empty title/body but real sources and a hero image attached. synthesizeArticle/synthesizeRecap now throw on an empty parsed body instead, so the existing catch-and-retry logic in runSynthesisCycle leaves the cluster unclustered for the next tick rather than ever inserting one of these. Also adds POST /api/admin/articles/:id/reissue to fix articles already published this way: the existing per-source reissue tool explicitly refuses to touch a multi-source article, which this failure mode always produces (an empty synthesis only happens on an actual multi-item merge — a single-item cluster publishes verbatim with no AI call at all), so there was no way to recover one without this. --- backend/src/api/admin.ts | 13 ++++++++++++- backend/src/pipeline/synthesis.ts | 21 +++++++++++++++++++-- backend/src/storage/contentCascade.ts | 23 +++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) 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();