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:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -69,10 +69,16 @@ export const updateSettings = (patch: Partial<AdminSettings>, fetchFn?: typeof f
|
||||
request<AdminSettings>('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
|
||||
|
||||
// Categories
|
||||
export const createCategory = (name: string, isPrivate = false, isSpillover = false, fetchFn?: typeof fetch) =>
|
||||
export const createCategory = (
|
||||
name: string,
|
||||
isPrivate = false,
|
||||
isSpillover = false,
|
||||
disableAi = false,
|
||||
fetchFn?: typeof fetch
|
||||
) =>
|
||||
request<CategoryPriority>(
|
||||
'/api/admin/categories',
|
||||
{ method: 'POST', body: JSON.stringify({ name, isPrivate, isSpillover }) },
|
||||
{ method: 'POST', body: JSON.stringify({ name, isPrivate, isSpillover, disableAi }) },
|
||||
fetchFn
|
||||
);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface CategoryPriority {
|
||||
isDefault: boolean;
|
||||
isPrivate: boolean;
|
||||
isSpillover: boolean;
|
||||
disableAi: boolean;
|
||||
}
|
||||
|
||||
export interface WeatherHourEntry {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
let newCategoryName = $state('');
|
||||
let newCategoryPrivate = $state(false);
|
||||
let newCategorySpillover = $state(false);
|
||||
let newCategoryDisableAi = $state(false);
|
||||
let addingCategory = $state(false);
|
||||
|
||||
// Advisory only — the nav starts getting too wide / wrapping past ~10 tabs, so this
|
||||
@@ -48,11 +49,12 @@
|
||||
if (!name) return;
|
||||
addingCategory = true;
|
||||
try {
|
||||
const created = await createCategory(name, newCategoryPrivate, newCategorySpillover);
|
||||
const created = await createCategory(name, newCategoryPrivate, newCategorySpillover, newCategoryDisableAi);
|
||||
local.categoryPriority = [...local.categoryPriority, created];
|
||||
newCategoryName = '';
|
||||
newCategoryPrivate = false;
|
||||
newCategorySpillover = false;
|
||||
newCategoryDisableAi = false;
|
||||
} finally {
|
||||
addingCategory = false;
|
||||
}
|
||||
@@ -68,6 +70,11 @@
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function toggleDisableAi(id: string) {
|
||||
local.categoryPriority = local.categoryPriority.map((c) => (c.id === id ? { ...c, disableAi: !c.disableAi } : c));
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
async function removeCategory(id: string, isDefault: boolean, name: string) {
|
||||
if (isDefault) {
|
||||
// Sensible-default categories can still be removed — e.g. a fresh install's
|
||||
@@ -98,7 +105,9 @@
|
||||
private category (and everything in it) is hidden from the public site until a visitor
|
||||
logs in with the lock icon in the masthead. A "More" category is collapsed into a single
|
||||
"More »" nav tab instead of getting its own, and shows up on that overflow page with its
|
||||
latest few articles.
|
||||
latest few articles. "No AI" skips clustering and synthesis for that category — each item
|
||||
publishes on its own, using its own source's text, instead of being merged/rewritten by the
|
||||
model.
|
||||
</p>
|
||||
{#if primaryCategoryCount > 10}
|
||||
<p class="hint warn">
|
||||
@@ -120,6 +129,10 @@
|
||||
<input type="checkbox" checked={cat.isSpillover} onchange={() => toggleSpillover(cat.id)} />
|
||||
More
|
||||
</label>
|
||||
<label class="private-toggle">
|
||||
<input type="checkbox" checked={cat.disableAi} onchange={() => toggleDisableAi(cat.id)} />
|
||||
No AI
|
||||
</label>
|
||||
{/if}
|
||||
<button class="icon-btn" onclick={() => move(i, -1)} disabled={i === 0} aria-label="Move up">▲</button>
|
||||
<button
|
||||
@@ -151,6 +164,10 @@
|
||||
<input type="checkbox" bind:checked={newCategorySpillover} />
|
||||
More
|
||||
</label>
|
||||
<label class="private-toggle">
|
||||
<input type="checkbox" bind:checked={newCategoryDisableAi} />
|
||||
No AI
|
||||
</label>
|
||||
<button onclick={addCategory} disabled={addingCategory || !newCategoryName.trim()}>
|
||||
{addingCategory ? 'Adding…' : '+ Add'}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user