Fix blank-article publishing bug + add per-article reissue tool

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.
This commit is contained in:
Claude
2026-07-27 19:14:50 +00:00
parent ee3123aa94
commit 23be5086c5
3 changed files with 54 additions and 3 deletions
+12 -1
View File
@@ -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());
+19 -2
View File
@@ -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)}"`);
}
+23
View File
@@ -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();