Add per-category "No AI" toggle to skip clustering/synthesis

Category priority admin pane gains a "No AI" checkbox alongside
Private/More. When set, items whose source falls under that category
skip embedding, clustering, and LLM synthesis entirely — each
publishes on its own, verbatim from its source (title + body/summary),
the same direct-publish path YouTube/Nitter/Telegram items always use.

Backend: new categories.disable_ai column (default off, migrated in
for existing installs), threaded through categories.ts CRUD and the
POST /api/admin/categories + PATCH /api/admin/settings routes.
priorityQueue.ts's runSynthesisCycle now partitions items three ways
before clustering: source-type direct (youtube/nitter/telegram),
category-disabled direct (new), then whatever's left goes through the
normal embed/cluster/synthesize pipeline.

Tracked-event recaps are a separate, already-existing per-event
toggle (TrackedEvent.recapIntervalHours) since events aren't tied to
a single category — unaffected by this change.
This commit is contained in:
Claude
2026-07-27 04:10:01 +00:00
parent 53ebb68339
commit e063d90c97
8 changed files with 84 additions and 21 deletions
+7 -2
View File
@@ -69,9 +69,14 @@ export async function registerAdminRoutes(app: FastifyInstance) {
// --- Categories (add/remove — reordering/privacy is via PATCH /settings above) ---
app.post('/api/admin/categories', async (req, reply) => {
const { name, isPrivate, isSpillover } = req.body as { name?: string; isPrivate?: boolean; isSpillover?: boolean };
const { name, isPrivate, isSpillover, disableAi } = req.body as {
name?: string;
isPrivate?: boolean;
isSpillover?: boolean;
disableAi?: boolean;
};
if (!name || !name.trim()) return reply.code(400).send({ error: 'name required' });
const created = categoriesDb.createCategory(name.trim(), !!isPrivate, !!isSpillover);
const created = categoriesDb.createCategory(name.trim(), !!isPrivate, !!isSpillover, !!disableAi);
return reply.code(201).send(created);
});
+31 -6
View File
@@ -45,6 +45,16 @@ function primaryCategoryRank(item: ContentItem, rankByName: Map<string, number>,
return best;
}
/** True if any of the item's source's categories (same leading-segment match as primaryCategoryRank) has AI disabled. */
function inAiDisabledCategory(item: ContentItem, disabledNames: Set<string>, sourcesById: Map<string, Source>): boolean {
const source = sourcesById.get(item.sourceId);
for (const cat of source?.category ?? []) {
const leading = cat.split(':')[0].trim().toLowerCase();
if (disabledNames.has(leading)) return true;
}
return false;
}
/**
* Shared by both the passthrough (no-AI) and synthesis direct-publish paths — same
* publish-then-tag-then-log/error shape, differing only in how the success/failure
@@ -114,6 +124,8 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G
// direct-publish partition and each item's category/type lookups — avoids a
// separate sourcesDb.getSource() round-trip per item.
const sourcesById = new Map(sourcesDb.listSources().map((s) => [s.id, s]));
const categories = categoriesDb.listCategories();
const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank]));
// YouTube videos, Nitter tweets, and Telegram messages never get LLM-merged with
// anything else — each is always its own article, same shape whether the AI service
@@ -121,18 +133,31 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G
const directPublishSourceIds = new Set(
[...sourcesById.values()].filter((s) => s.type === 'youtube' || s.type === 'nitter' || s.type === 'telegram').map((s) => s.id)
);
const [directItems, mergeableItems] = partition(items, (item) => directPublishSourceIds.has(item.sourceId));
const [typeDirectItems, remaining] = partition(items, (item) => directPublishSourceIds.has(item.sourceId));
const publishedDirect = await publishItemsDirect(
directItems,
// A category with disableAi set (see the Category priority admin pane) opts its
// items out of clustering/synthesis entirely — each publishes on its own, using its
// own source's text, same as the source-type-driven direct items above.
const aiDisabledCategoryNames = new Set(categories.filter((c) => c.disableAi).map((c) => c.name.toLowerCase()));
const [categoryDirectItems, mergeableItems] = partition(remaining, (item) =>
inAiDisabledCategory(item, aiDisabledCategoryNames, sourcesById)
);
const publishedTypeDirect = await publishItemsDirect(
typeDirectItems,
settings,
activeEvents,
(item) => sourcesById.get(item.sourceId)?.type ?? 'unknown',
'Direct publish failed'
);
const categories = categoriesDb.listCategories();
const rankByName = new Map(categories.map((c) => [c.name.toLowerCase(), c.priorityRank]));
const publishedCategoryDirect = await publishItemsDirect(
categoryDirectItems,
settings,
activeEvents,
() => 'AI disabled for category',
'Direct publish failed'
);
const ranked = mergeableItems
.map((item) => ({ item, rank: primaryCategoryRank(item, rankByName, sourcesById) }))
@@ -184,5 +209,5 @@ export async function runSynthesisCycle(provider: InferenceProvider, settings: G
);
}
return published + publishedDirect;
return published + publishedTypeDirect + publishedCategoryDirect;
}
+11 -8
View File
@@ -9,7 +9,8 @@ function rowToCategory(row: any): Category {
priorityRank: row.priority_rank,
isDefault: !!row.is_default,
isPrivate: !!row.is_private,
isSpillover: !!row.is_spillover
isSpillover: !!row.is_spillover,
disableAi: !!row.disable_ai
};
}
@@ -24,18 +25,20 @@ export function listPrivateCategoryNames(): string[] {
return rows.map((r) => r.name);
}
export function setCategoryOrder(order: { id: string; priorityRank: number; isPrivate: boolean; isSpillover: boolean }[]) {
const stmt = db.prepare('UPDATE categories SET priority_rank = ?, is_private = ?, is_spillover = ? WHERE id = ?');
for (const c of order) stmt.run(c.priorityRank, c.isPrivate ? 1 : 0, c.isSpillover ? 1 : 0, c.id);
export function setCategoryOrder(
order: { id: string; priorityRank: number; isPrivate: boolean; isSpillover: boolean; disableAi: boolean }[]
) {
const stmt = db.prepare('UPDATE categories SET priority_rank = ?, is_private = ?, is_spillover = ?, disable_ai = ? WHERE id = ?');
for (const c of order) stmt.run(c.priorityRank, c.isPrivate ? 1 : 0, c.isSpillover ? 1 : 0, c.disableAi ? 1 : 0, c.id);
}
export function createCategory(name: string, isPrivate = false, isSpillover = false): Category {
export function createCategory(name: string, isPrivate = false, isSpillover = false, disableAi = false): Category {
const id = `cat-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')}-${randomUUID().slice(0, 6)}`;
const maxRank = db.prepare('SELECT COALESCE(MAX(priority_rank), 0) as m FROM categories').get() as { m: number };
db.prepare(
'INSERT INTO categories (id, name, priority_rank, is_default, is_private, is_spillover) VALUES (?, ?, ?, 0, ?, ?)'
).run(id, name, maxRank.m + 1, isPrivate ? 1 : 0, isSpillover ? 1 : 0);
return { id, name, priorityRank: maxRank.m + 1, isDefault: false, isPrivate, isSpillover };
'INSERT INTO categories (id, name, priority_rank, is_default, is_private, is_spillover, disable_ai) VALUES (?, ?, ?, 0, ?, ?, ?)'
).run(id, name, maxRank.m + 1, isPrivate ? 1 : 0, isSpillover ? 1 : 0, disableAi ? 1 : 0);
return { id, name, priorityRank: maxRank.m + 1, isDefault: false, isPrivate, isSpillover, disableAi };
}
export function deleteCategory(id: string) {
+5 -1
View File
@@ -179,7 +179,8 @@ export function migrate() {
priority_rank INTEGER NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
is_private INTEGER NOT NULL DEFAULT 0,
is_spillover INTEGER NOT NULL DEFAULT 0 -- collapsed into the nav's "More »" overflow page instead of its own tab
is_spillover INTEGER NOT NULL DEFAULT 0, -- collapsed into the nav's "More »" overflow page instead of its own tab
disable_ai INTEGER NOT NULL DEFAULT 0 -- skip clustering/synthesis for this category's items; publish each one directly
);
CREATE TABLE IF NOT EXISTS logs (
@@ -319,6 +320,9 @@ export function migrate() {
if (!hasColumn('categories', 'is_spillover')) {
db.exec('ALTER TABLE categories ADD COLUMN is_spillover INTEGER NOT NULL DEFAULT 0');
}
if (!hasColumn('categories', 'disable_ai')) {
db.exec('ALTER TABLE categories ADD COLUMN disable_ai INTEGER NOT NULL DEFAULT 0');
}
if (!hasColumn('content_items', 'telegram_message')) {
db.exec('ALTER TABLE content_items ADD COLUMN telegram_message TEXT');
}
+2
View File
@@ -206,6 +206,8 @@ export interface Category {
isPrivate: boolean;
/** Grouped into the nav's "More »" overflow page instead of getting its own top-level tab — see +layout.svelte and /more. */
isSpillover: boolean;
/** Skips clustering/AI synthesis for this category's items — each one publishes directly (own article, own source's text), same as YouTube/Nitter/Telegram items always do. See priorityQueue.ts's runSynthesisCycle. */
disableAi: boolean;
}
export interface StockTicker {