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:
@@ -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());
|
||||
|
||||
|
||||
@@ -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)}"`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user