Make synthesis context/response length admin-configurable; give recaps their own writing style and let them run full-length

Adds a "Context window" panel to the Models tab: num_ctx and
num_predict sliders, bounded by the selected synthesis model's own
detected max context (via Ollama's /api/show, GET
/api/admin/model-context) when available, falling back to a generous
default otherwise. These now drive every synthesis/recap/tag-extraction
call instead of the old fixed 8192/700 constants — too low a
num_predict is exactly why a long recap or merge sometimes cut off
mid-sentence.

Recaps also get their own per-tracked-item writing style, independent
of the global Merge-tab style: a "More" collapsible section (minimized
by default) next to each tracked item's recap cadence, with the same
preset + free-text pattern as the Merge tab. The recap system prompt no
longer caps output at "3-5 short paragraphs" — it now asks for a full,
comprehensive article sized to the material, which only works well
together with a properly-sized num_predict.
This commit is contained in:
Claude
2026-07-28 13:34:54 +00:00
parent 7518e6f81e
commit a312e0a8d3
14 changed files with 327 additions and 42 deletions
+14
View File
@@ -208,6 +208,20 @@ export async function registerAdminRoutes(app: FastifyInstance) {
return { connected, host: settings.aiServiceHost, port: settings.aiServicePort, ramGB: null, gpu: null }; return { connected, host: settings.aiServiceHost, port: settings.aiServicePort, ramGB: null, gpu: null };
}); });
// Detects the selected synthesis model's own max context length (when Ollama exposes
// it) so the Models tab's num_ctx/num_predict sliders can be bounded by what the
// model actually supports, instead of an arbitrary fixed cap. contextLength is null
// when undetectable (older Ollama version, unusual model format, unreachable) — the
// frontend falls back to a generous default range in that case rather than blocking.
app.get('/api/admin/model-context', async (req, reply) => {
const { model } = req.query as { model?: string };
if (!model) return reply.code(400).send({ error: 'model query param required' });
const settings = settingsDb.getSettings();
const provider = new OllamaProvider(settings.aiServiceHost, settings.aiServicePort);
const contextLength = await provider.getModelContextLength(model);
return { contextLength };
});
// --- Telegram account (Connections tab — see telegram/client.ts and credentials.ts. // --- Telegram account (Connections tab — see telegram/client.ts and credentials.ts.
// API ID/hash and the resulting login session are stored encrypted at rest; none of // API ID/hash and the resulting login session are stored encrypted at rest; none of
// these routes ever echo them back to the client.) --- // these routes ever echo them back to the client.) ---
+28
View File
@@ -135,4 +135,32 @@ export class OllamaProvider implements InferenceProvider {
return false; return false;
} }
} }
/**
* Ollama's /api/show returns a model_info object whose keys are prefixed by the
* model's own architecture name (e.g. "qwen2.context_length", "llama.context_length")
* rather than one fixed field — there's no single stable key across model families.
* Scanning for whichever key ends in ".context_length" avoids hardcoding a list of
* known architectures that will inevitably miss a future/uncommon one. Returns null
* (rather than throwing) on any failure — the admin-facing slider falls back to a
* generous default cap when this can't be determined, rather than blocking the whole
* Models tab on one unreliable, best-effort lookup.
*/
async getModelContextLength(model: string): Promise<number | null> {
try {
const res = await fetch(`${this.base()}/api/show`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, name: model }),
signal: AbortSignal.timeout(5000)
});
if (!res.ok) return null;
const data = (await res.json()) as { model_info?: Record<string, unknown> };
const entry = Object.entries(data.model_info ?? {}).find(([key]) => key.endsWith('.context_length'));
const value = entry?.[1];
return typeof value === 'number' && value > 0 ? value : null;
} catch {
return null;
}
}
} }
+2
View File
@@ -6,4 +6,6 @@ export interface InferenceProvider {
embed(text: string, opts?: { model?: string }): Promise<number[]>; embed(text: string, opts?: { model?: string }): Promise<number[]>;
listModels(): Promise<string[]>; listModels(): Promise<string[]>;
isReachable(): Promise<boolean>; isReachable(): Promise<boolean>;
/** The model's own reported max context length (training/architecture limit), or null if the server doesn't expose it — used to bound the admin-facing num_ctx slider (Models tab) so it can't be set past what the model actually supports. */
getModelContextLength(model: string): Promise<number | null>;
} }
+2 -2
View File
@@ -327,7 +327,7 @@ export async function publishDirect(
let tagIds: string[] = []; let tagIds: string[] = [];
if (opts.provider) { if (opts.provider) {
try { try {
const tagLabels = await extractTags(opts.provider, settings.selectedModels.synthesis, item); const tagLabels = await extractTags(opts.provider, settings.selectedModels.synthesis, item, settings);
tagIds = await resolveTagIds(opts.provider, tagLabels, settings, 'synthesis'); tagIds = await resolveTagIds(opts.provider, tagLabels, settings, 'synthesis');
} catch (err) { } catch (err) {
logger.error('synthesis', `Tag extraction failed for "${item.title}": ${(err as Error).message}`); logger.error('synthesis', `Tag extraction failed for "${item.title}": ${(err as Error).message}`);
@@ -475,7 +475,7 @@ export async function publishEventRecap(
event: TrackedEvent, event: TrackedEvent,
constituents: MergedArticle[] constituents: MergedArticle[]
): Promise<MergedArticle> { ): Promise<MergedArticle> {
const { title, body, tagLabels } = await synthesizeRecap(provider, settings.selectedModels.synthesis, event.name, constituents, settings); const { title, body, tagLabels } = await synthesizeRecap(provider, settings.selectedModels.synthesis, event, constituents, settings);
const tagIds = await resolveTagIds(provider, tagLabels, settings, 'events'); const tagIds = await resolveTagIds(provider, tagLabels, settings, 'events');
const category = [...new Set(constituents.flatMap((a) => a.category))]; const category = [...new Set(constituents.flatMap((a) => a.category))];
+51 -30
View File
@@ -1,6 +1,5 @@
import type { InferenceProvider } from '../inference/provider.js'; import type { InferenceProvider } from '../inference/provider.js';
import type { ContentItem, GlobalSettings, MergedArticle } from '../storage/db/types.js'; import type { ContentItem, GlobalSettings, MergedArticle, TrackedEvent } from '../storage/db/types.js';
import { DEFAULT_NUM_CTX, DEFAULT_NUM_PREDICT } from '../inference/ollama-provider.js';
import { logger } from '../storage/db/logs.js'; import { logger } from '../storage/db/logs.js';
const TITLE_DELIMITER = '---TITLE---'; const TITLE_DELIMITER = '---TITLE---';
@@ -17,17 +16,21 @@ const TAG_DELIMITER_RE = /-{2,}\s*TAGS\s*-{2,}/i;
// Ollama truncates prompts that don't fit its context window by keeping a small prefix // Ollama truncates prompts that don't fit its context window by keeping a small prefix
// and dropping everything else in the middle — silently, with no error, and with no // and dropping everything else in the middle — silently, with no error, and with no
// regard for which sources end up cut (see ollama-provider.ts for the incident that // regard for which sources end up cut (see ollama-provider.ts for the incident that
// prompted this). Rather than relying on that, prompts here are sized to fit // prompted this). Rather than relying on that, prompts here are sized to fit the
// DEFAULT_NUM_CTX up front: each source/article gets an equal character budget, cut only // admin-configured num_ctx/num_predict (Models tab) up front: each source/article gets
// when the whole prompt would otherwise overflow, so every source stays at least // an equal character budget, cut only when the whole prompt would otherwise overflow, so
// partially represented (and attributable) instead of some being dropped outright. // every source stays at least partially represented (and attributable) instead of some
// ~4 chars/token is a rough heuristic (no tokenizer available here) — good enough for a // being dropped outright. ~4 chars/token is a rough heuristic (no tokenizer available
// safety margin, not meant to be exact. // here) — good enough for a safety margin, not meant to be exact.
const CHARS_PER_TOKEN = 4; const CHARS_PER_TOKEN = 4;
const RESERVED_OVERHEAD_TOKENS = 300; // system prompt + per-entry headers/formatting const RESERVED_OVERHEAD_TOKENS = 300; // system prompt + per-entry headers/formatting
const MAX_INPUT_CHARS = (DEFAULT_NUM_CTX - DEFAULT_NUM_PREDICT - RESERVED_OVERHEAD_TOKENS) * CHARS_PER_TOKEN;
const MIN_ENTRY_CHARS = 300; // floor so a huge cluster/recap doesn't shrink every entry to nothing const MIN_ENTRY_CHARS = 300; // floor so a huge cluster/recap doesn't shrink every entry to nothing
/** Character budget for prompt *input* — leaves numPredict's worth of the context window free for the model's own response, per the admin's configured num_ctx/num_predict (GlobalSettings.synthesisNumCtx/synthesisNumPredict). */
function maxInputChars(numCtx: number, numPredict: number): number {
return Math.max(0, (numCtx - numPredict - RESERVED_OVERHEAD_TOKENS) * CHARS_PER_TOKEN);
}
function capEntryText(text: string, budgetChars: number): string { function capEntryText(text: string, budgetChars: number): string {
return text.length > budgetChars ? text.slice(0, budgetChars) + '…' : text; return text.length > budgetChars ? text.slice(0, budgetChars) + '…' : text;
} }
@@ -56,14 +59,15 @@ function parseTagLabels(raw: string): string[] {
export async function extractTags( export async function extractTags(
provider: InferenceProvider, provider: InferenceProvider,
model: string, model: string,
item: Pick<ContentItem, 'title' | 'summary' | 'body'> item: Pick<ContentItem, 'title' | 'summary' | 'body'>,
settings: GlobalSettings
): Promise<string[]> { ): Promise<string[]> {
const summary = capEntryText(item.body || item.summary, MAX_INPUT_CHARS); const summary = capEntryText(item.body || item.summary, maxInputChars(settings.synthesisNumCtx, TAG_EXTRACTION_NUM_PREDICT));
const prompt = `Title: ${item.title}\nSummary: ${summary}`; const prompt = `Title: ${item.title}\nSummary: ${summary}`;
const raw = await provider.generate(prompt, { const raw = await provider.generate(prompt, {
model, model,
system: TAG_EXTRACTION_SYSTEM_PROMPT, system: TAG_EXTRACTION_SYSTEM_PROMPT,
numCtx: DEFAULT_NUM_CTX, numCtx: settings.synthesisNumCtx,
numPredict: TAG_EXTRACTION_NUM_PREDICT, numPredict: TAG_EXTRACTION_NUM_PREDICT,
label: `Extracting tags: "${item.title.slice(0, 60)}"` label: `Extracting tags: "${item.title.slice(0, 60)}"`
}); });
@@ -73,11 +77,11 @@ export async function extractTags(
const RECAP_SYSTEM_PROMPT_BASE = `You are a neutral news synthesis assistant. Given a chronological list of articles already published about an ongoing tracked event, write your response in exactly three parts, in this order: const RECAP_SYSTEM_PROMPT_BASE = `You are a neutral news synthesis assistant. Given a chronological list of articles already published about an ongoing tracked event, write your response in exactly three parts, in this order:
1. A short, specific headline for this recap (a single line, ideally under 12 words, no surrounding quotation marks, no trailing period). 1. A short, specific headline for this recap (a single line, ideally under 12 words, no surrounding quotation marks, no trailing period).
2. On a new line, write exactly "${TITLE_DELIMITER}", then the recap article: 2. On a new line, write exactly "${TITLE_DELIMITER}", then the recap:
- Summarizes what has happened across the period covered, in chronological order - Write a full, comprehensive news article covering the period — not a short summary or a bare list of bullet points. Use as many paragraphs and as much length as the material actually warrants; do not artificially cut it short.
- Highlights the most significant developments rather than restating every article - Organize it in chronological order, but group and connect related developments into a coherent narrative rather than restating each source article one at a time
- Give real weight and detail to the most significant developments; minor ones can be covered more briefly, but nothing significant should be dropped for the sake of brevity
- Stays neutral and factual, without editorializing - Stays neutral and factual, without editorializing
- Is 3-5 short paragraphs
3. On a new line after the recap, write exactly "${TAG_DELIMITER}" followed by 2-4 short comma-separated topic/entity tags (e.g. proper nouns, named events) that this recap is about. If nothing salient qualifies, leave the tag line empty.`; 3. On a new line after the recap, write exactly "${TAG_DELIMITER}" followed by 2-4 short comma-separated topic/entity tags (e.g. proper nouns, named events) that this recap is about. If nothing salient qualifies, leave the tag line empty.`;
const SYSTEM_PROMPT_BASE = `You are a neutral news synthesis assistant. Given summaries from multiple news sources describing the same event, write your response in exactly three parts, in this order: const SYSTEM_PROMPT_BASE = `You are a neutral news synthesis assistant. Given summaries from multiple news sources describing the same event, write your response in exactly three parts, in this order:
@@ -99,7 +103,7 @@ const STYLE_PRESETS: Record<GlobalSettings['synthesisStylePreset'], string> = {
formal: 'Write in a formal, measured register — precise language, no contractions, no colloquialisms.' formal: 'Write in a formal, measured register — precise language, no contractions, no colloquialisms.'
}; };
/** Admin-configurable tone: a preset plus optional free-text instructions, both from GlobalSettings — the only two knobs that affect HOW the model writes, as opposed to WHAT gets clustered/published. Appended to the base prompt, never replacing its structural rules (attribution, paragraph count, tag format). */ /** Admin-configurable tone: a preset plus optional free-text instructions, both from GlobalSettings — the only two knobs that affect HOW the model writes, as opposed to WHAT gets clustered/published. Appended to the base prompt, never replacing its structural rules (attribution, paragraph count, tag format). Applies only to regular same-story merges — recaps have their own independent style knob, see recapStyleAddendum below. */
function styleAddendum(settings: GlobalSettings): string { function styleAddendum(settings: GlobalSettings): string {
const preset = STYLE_PRESETS[settings.synthesisStylePreset] ?? ''; const preset = STYLE_PRESETS[settings.synthesisStylePreset] ?? '';
const custom = settings.synthesisCustomInstructions.trim(); const custom = settings.synthesisCustomInstructions.trim();
@@ -108,6 +112,21 @@ function styleAddendum(settings: GlobalSettings): string {
return `\n\nAdditional style instructions from the site admin (follow these without breaking the rules above):\n${lines.join('\n')}`; return `\n\nAdditional style instructions from the site admin (follow these without breaking the rules above):\n${lines.join('\n')}`;
} }
/**
* Per-tracked-item recap style — deliberately independent of the global synthesisStylePreset
* above (set in the Merge tab), since a recap's tone/scope is a very different kind of
* knob: it's set once per tracked item (the "More" section on its own edit panel, next to
* its recap cadence), not globally for every merge on the site. Reuses the same preset
* strings for consistency, but reads from the event's own fields instead of GlobalSettings.
*/
function recapStyleAddendum(event: TrackedEvent): string {
const preset = STYLE_PRESETS[event.recapStylePreset] ?? '';
const custom = event.recapCustomInstructions.trim();
const lines = [preset, custom].filter(Boolean);
if (lines.length === 0) return '';
return `\n\nAdditional style instructions from the site admin for this recap (follow these without breaking the rules above):\n${lines.join('\n')}`;
}
export interface SynthesisResult { export interface SynthesisResult {
title: string; title: string;
body: string; body: string;
@@ -120,8 +139,8 @@ function fallbackTitle(body: string): string {
return firstLine.length > 100 ? firstLine.slice(0, 97) + '…' : firstLine; return firstLine.length > 100 ? firstLine.slice(0, 97) + '…' : firstLine;
} }
function buildPrompt(items: ContentItem[], sourceNames: Map<string, string>): string { function buildPrompt(items: ContentItem[], sourceNames: Map<string, string>, numCtx: number, numPredict: number): string {
const budgetPerItem = Math.max(MIN_ENTRY_CHARS, Math.floor(MAX_INPUT_CHARS / items.length)); const budgetPerItem = Math.max(MIN_ENTRY_CHARS, Math.floor(maxInputChars(numCtx, numPredict) / items.length));
let truncated = 0; let truncated = 0;
const entries = items.map((item, i) => { const entries = items.map((item, i) => {
// Same fallback publishDirect uses (publish.ts) — body is the full article text // Same fallback publishDirect uses (publish.ts) — body is the full article text
@@ -187,15 +206,16 @@ export async function synthesizeArticle(
sourceNames: Map<string, string>, sourceNames: Map<string, string>,
settings: GlobalSettings settings: GlobalSettings
): Promise<SynthesisResult> { ): Promise<SynthesisResult> {
const prompt = buildPrompt(items, sourceNames); const { synthesisNumCtx: numCtx, synthesisNumPredict: numPredict } = settings;
const prompt = buildPrompt(items, sourceNames, numCtx, numPredict);
const system = SYSTEM_PROMPT_BASE + styleAddendum(settings); const system = SYSTEM_PROMPT_BASE + styleAddendum(settings);
const label = `Merging ${items.length} source${items.length === 1 ? '' : 's'}: "${items[0]?.title.slice(0, 60) ?? ''}"`; 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 }); const raw = await provider.generate(prompt, { model, system, numCtx, numPredict, label });
return assertNonEmpty(parseResult(raw), `"${items[0]?.title.slice(0, 60) ?? ''}"`); return assertNonEmpty(parseResult(raw), `"${items[0]?.title.slice(0, 60) ?? ''}"`);
} }
function buildRecapPrompt(eventName: string, articles: MergedArticle[]): string { function buildRecapPrompt(eventName: string, articles: MergedArticle[], numCtx: number, numPredict: number): string {
const budgetPerArticle = Math.max(MIN_ENTRY_CHARS, Math.floor(MAX_INPUT_CHARS / articles.length)); const budgetPerArticle = Math.max(MIN_ENTRY_CHARS, Math.floor(maxInputChars(numCtx, numPredict) / articles.length));
let truncated = 0; let truncated = 0;
const entries = articles.map((article, i) => { const entries = articles.map((article, i) => {
const body = capEntryText(article.body, budgetPerArticle); const body = capEntryText(article.body, budgetPerArticle);
@@ -219,17 +239,18 @@ function buildRecapPrompt(eventName: string, articles: MergedArticle[]): string
export async function synthesizeRecap( export async function synthesizeRecap(
provider: InferenceProvider, provider: InferenceProvider,
model: string, model: string,
eventName: string, event: TrackedEvent,
articles: MergedArticle[], articles: MergedArticle[],
settings: GlobalSettings settings: GlobalSettings
): Promise<SynthesisResult> { ): Promise<SynthesisResult> {
const prompt = buildRecapPrompt(eventName, articles); const { synthesisNumCtx: numCtx, synthesisNumPredict: numPredict } = settings;
const prompt = buildRecapPrompt(event.name, articles, numCtx, numPredict);
const raw = await provider.generate(prompt, { const raw = await provider.generate(prompt, {
model, model,
system: RECAP_SYSTEM_PROMPT_BASE + styleAddendum(settings), system: RECAP_SYSTEM_PROMPT_BASE + recapStyleAddendum(event),
numCtx: DEFAULT_NUM_CTX, numCtx,
numPredict: DEFAULT_NUM_PREDICT, numPredict,
label: `Recapping event: "${eventName.slice(0, 60)}"` label: `Recapping event: "${event.name.slice(0, 60)}"`
}); });
return assertNonEmpty(parseResult(raw), `event recap "${eventName.slice(0, 60)}"`); return assertNonEmpty(parseResult(raw), `event recap "${event.name.slice(0, 60)}"`);
} }
+9 -3
View File
@@ -14,6 +14,8 @@ function rowToEvent(row: any): TrackedEvent {
isSpillover: !!row.is_spillover, isSpillover: !!row.is_spillover,
retentionOverrideDays: row.retention_override_days, retentionOverrideDays: row.retention_override_days,
lastRecapAt: row.last_recap_at, lastRecapAt: row.last_recap_at,
recapStylePreset: row.recap_style_preset,
recapCustomInstructions: row.recap_custom_instructions,
createdAt: row.created_at createdAt: row.created_at
}; };
} }
@@ -52,8 +54,8 @@ export function createEvent(input: Partial<TrackedEvent>): TrackedEvent {
const id = `evt-${randomUUID()}`; const id = `evt-${randomUUID()}`;
const now = new Date().toISOString(); const now = new Date().toISOString();
db.prepare( db.prepare(
`INSERT INTO tracked_events (id, name, description, source_ids, keywords, recap_interval_hours, active, is_spillover, retention_override_days, last_recap_at, created_at) `INSERT INTO tracked_events (id, name, description, source_ids, keywords, recap_interval_hours, active, is_spillover, retention_override_days, last_recap_at, recap_style_preset, recap_custom_instructions, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)`
).run( ).run(
id, id,
input.name ?? 'Untitled event', input.name ?? 'Untitled event',
@@ -64,6 +66,8 @@ export function createEvent(input: Partial<TrackedEvent>): TrackedEvent {
input.active === false ? 0 : 1, input.active === false ? 0 : 1,
input.isSpillover ? 1 : 0, input.isSpillover ? 1 : 0,
input.retentionOverrideDays ?? null, input.retentionOverrideDays ?? null,
input.recapStylePreset ?? 'default',
input.recapCustomInstructions ?? '',
now now
); );
return getEvent(id)!; return getEvent(id)!;
@@ -74,7 +78,7 @@ export function updateEvent(id: string, patch: Partial<TrackedEvent>): TrackedEv
if (!existing) return null; if (!existing) return null;
const merged = { ...existing, ...patch }; const merged = { ...existing, ...patch };
db.prepare( db.prepare(
`UPDATE tracked_events SET name=?, description=?, source_ids=?, keywords=?, recap_interval_hours=?, active=?, is_spillover=?, retention_override_days=?, last_recap_at=? WHERE id=?` `UPDATE tracked_events SET name=?, description=?, source_ids=?, keywords=?, recap_interval_hours=?, active=?, is_spillover=?, retention_override_days=?, last_recap_at=?, recap_style_preset=?, recap_custom_instructions=? WHERE id=?`
).run( ).run(
merged.name, merged.name,
merged.description, merged.description,
@@ -85,6 +89,8 @@ export function updateEvent(id: string, patch: Partial<TrackedEvent>): TrackedEv
merged.isSpillover ? 1 : 0, merged.isSpillover ? 1 : 0,
merged.retentionOverrideDays, merged.retentionOverrideDays,
merged.lastRecapAt, merged.lastRecapAt,
merged.recapStylePreset,
merged.recapCustomInstructions,
id id
); );
return getEvent(id); return getEvent(id);
+16
View File
@@ -161,6 +161,8 @@ export function migrate() {
is_spillover INTEGER NOT NULL DEFAULT 0, is_spillover INTEGER NOT NULL DEFAULT 0,
retention_override_days INTEGER, retention_override_days INTEGER,
last_recap_at TEXT, last_recap_at TEXT,
recap_style_preset TEXT NOT NULL DEFAULT 'default', -- default | casual | formal — this item's own recap tone, independent of the global Merge-tab style (see pipeline/synthesis.ts)
recap_custom_instructions TEXT NOT NULL DEFAULT '', -- free-text addendum for this item's recap prompt specifically
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
@@ -214,6 +216,8 @@ export function migrate() {
telegram_media_mode TEXT NOT NULL DEFAULT 'self-host', -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL) telegram_media_mode TEXT NOT NULL DEFAULT 'self-host', -- self-host | proxy (no "direct" — Telegram has no public hotlinkable media URL)
synthesis_style_preset TEXT NOT NULL DEFAULT 'default', -- default | casual | formal — see pipeline/synthesis.ts's STYLE_PRESETS synthesis_style_preset TEXT NOT NULL DEFAULT 'default', -- default | casual | formal — see pipeline/synthesis.ts's STYLE_PRESETS
synthesis_custom_instructions TEXT NOT NULL DEFAULT '', -- free-text addendum appended to the synthesis system prompt, on top of the preset synthesis_custom_instructions TEXT NOT NULL DEFAULT '', -- free-text addendum appended to the synthesis system prompt, on top of the preset
synthesis_num_ctx INTEGER NOT NULL DEFAULT 8192, -- admin-tunable context window (Models tab) — see inference/ollama-provider.ts's DEFAULT_NUM_CTX
synthesis_num_predict INTEGER NOT NULL DEFAULT 700, -- admin-tunable max response length — too low silently truncates output mid-sentence
widget_weather_enabled INTEGER NOT NULL DEFAULT 1, widget_weather_enabled INTEGER NOT NULL DEFAULT 1,
widget_stocks_enabled INTEGER NOT NULL DEFAULT 1, widget_stocks_enabled INTEGER NOT NULL DEFAULT 1,
widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1, widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1,
@@ -343,6 +347,12 @@ export function migrate() {
if (!hasColumn('tracked_events', 'recap_interval_hours')) { if (!hasColumn('tracked_events', 'recap_interval_hours')) {
db.exec('ALTER TABLE tracked_events ADD COLUMN recap_interval_hours INTEGER'); db.exec('ALTER TABLE tracked_events ADD COLUMN recap_interval_hours INTEGER');
} }
if (!hasColumn('tracked_events', 'recap_style_preset')) {
db.exec("ALTER TABLE tracked_events ADD COLUMN recap_style_preset TEXT NOT NULL DEFAULT 'default'");
}
if (!hasColumn('tracked_events', 'recap_custom_instructions')) {
db.exec("ALTER TABLE tracked_events ADD COLUMN recap_custom_instructions TEXT NOT NULL DEFAULT ''");
}
if (!hasColumn('merged_articles', 'is_recap')) { if (!hasColumn('merged_articles', 'is_recap')) {
db.exec('ALTER TABLE merged_articles ADD COLUMN is_recap INTEGER NOT NULL DEFAULT 0'); db.exec('ALTER TABLE merged_articles ADD COLUMN is_recap INTEGER NOT NULL DEFAULT 0');
} }
@@ -386,6 +396,12 @@ export function migrate() {
if (!hasColumn('global_settings', 'synthesis_custom_instructions')) { if (!hasColumn('global_settings', 'synthesis_custom_instructions')) {
db.exec("ALTER TABLE global_settings ADD COLUMN synthesis_custom_instructions TEXT NOT NULL DEFAULT ''"); db.exec("ALTER TABLE global_settings ADD COLUMN synthesis_custom_instructions TEXT NOT NULL DEFAULT ''");
} }
if (!hasColumn('global_settings', 'synthesis_num_ctx')) {
db.exec('ALTER TABLE global_settings ADD COLUMN synthesis_num_ctx INTEGER NOT NULL DEFAULT 8192');
}
if (!hasColumn('global_settings', 'synthesis_num_predict')) {
db.exec('ALTER TABLE global_settings ADD COLUMN synthesis_num_predict INTEGER NOT NULL DEFAULT 700');
}
// Seed default categories if none exist yet. "News" sits right under "Top stories" — // Seed default categories if none exist yet. "News" sits right under "Top stories" —
// general news sources belong here, not on "Top stories" itself, which isn't a real // general news sources belong here, not on "Top stories" itself, which isn't a real
+5
View File
@@ -45,6 +45,8 @@ function rowToSettings(row: any): GlobalSettings {
telegramMediaMode: row.telegram_media_mode, telegramMediaMode: row.telegram_media_mode,
synthesisStylePreset: row.synthesis_style_preset, synthesisStylePreset: row.synthesis_style_preset,
synthesisCustomInstructions: row.synthesis_custom_instructions, synthesisCustomInstructions: row.synthesis_custom_instructions,
synthesisNumCtx: row.synthesis_num_ctx,
synthesisNumPredict: row.synthesis_num_predict,
...widgetsAndOrder(), ...widgetsAndOrder(),
retention: { retention: {
publishedArticleMaxAgeDays: row.published_article_max_age_days, publishedArticleMaxAgeDays: row.published_article_max_age_days,
@@ -93,6 +95,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, nitter_instance_url=$nitter_instance_url, nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, nitter_instance_url=$nitter_instance_url,
telegram_media_mode=$telegram_media_mode, telegram_media_mode=$telegram_media_mode,
synthesis_style_preset=$synthesis_style_preset, synthesis_custom_instructions=$synthesis_custom_instructions, synthesis_style_preset=$synthesis_style_preset, synthesis_custom_instructions=$synthesis_custom_instructions,
synthesis_num_ctx=$synthesis_num_ctx, synthesis_num_predict=$synthesis_num_predict,
published_article_max_age_days=$published_article_max_age_days, raw_item_max_age_days=$raw_item_max_age_days, published_article_max_age_days=$published_article_max_age_days, raw_item_max_age_days=$raw_item_max_age_days,
storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit
WHERE id = 1` WHERE id = 1`
@@ -112,6 +115,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
$telegram_media_mode: merged.telegramMediaMode, $telegram_media_mode: merged.telegramMediaMode,
$synthesis_style_preset: merged.synthesisStylePreset, $synthesis_style_preset: merged.synthesisStylePreset,
$synthesis_custom_instructions: merged.synthesisCustomInstructions, $synthesis_custom_instructions: merged.synthesisCustomInstructions,
$synthesis_num_ctx: merged.synthesisNumCtx,
$synthesis_num_predict: merged.synthesisNumPredict,
$published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays, $published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays,
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays, $raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0, $storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
+8
View File
@@ -185,6 +185,10 @@ export interface TrackedEvent {
isSpillover: boolean; isSpillover: boolean;
retentionOverrideDays: number | null; retentionOverrideDays: number | null;
lastRecapAt: string | null; lastRecapAt: string | null;
/** Tone preset for this item's own recap, independent of the global Merge-tab synthesis style — see pipeline/synthesis.ts's STYLE_PRESETS. 'default' adds nothing on top of the base recap prompt. */
recapStylePreset: 'default' | 'casual' | 'formal';
/** Free-text instructions appended to the recap system prompt for this item specifically — e.g. "focus on military developments", "write as a full narrative, not bullet points". Empty string means no addendum. */
recapCustomInstructions: string;
createdAt: string; createdAt: string;
} }
@@ -291,6 +295,10 @@ export interface GlobalSettings {
synthesisStylePreset: 'default' | 'casual' | 'formal'; synthesisStylePreset: 'default' | 'casual' | 'formal';
/** Free-text instructions appended to the synthesis system prompt alongside the style preset — e.g. "keep it under 3 sentences per paragraph". Empty string means no addendum. */ /** Free-text instructions appended to the synthesis system prompt alongside the style preset — e.g. "keep it under 3 sentences per paragraph". Empty string means no addendum. */
synthesisCustomInstructions: string; synthesisCustomInstructions: string;
/** Total context window (prompt + response) requested from Ollama for every synthesis/recap/tag-extraction call — see inference/ollama-provider.ts's DEFAULT_NUM_CTX for why this is ever explicit at all, and the Models tab for the admin-facing slider (bounded by the selected synthesis model's own reported max, when Ollama exposes it). */
synthesisNumCtx: number;
/** Max tokens the model is allowed to generate per synthesis/recap call — too low silently truncates the output mid-sentence rather than erroring (this is what a "cut off" recap/article means). Recaps in particular need real headroom: they're asked to summarize many source articles into several paragraphs, unlike a same-story merge. */
synthesisNumPredict: number;
/** Per-widget enable flags — see admin/settings' consolidated "Widgets" tab. Weather/Stocks/PoE2's backend pollers (scheduler.ts) are gated on these too, not just sidebar visibility; Bookmarks has no poller so its flag only affects the sidebar. */ /** Per-widget enable flags — see admin/settings' consolidated "Widgets" tab. Weather/Stocks/PoE2's backend pollers (scheduler.ts) are gated on these too, not just sidebar visibility; Bookmarks has no poller so its flag only affects the sidebar. */
widgets: { widgets: {
weather: boolean; weather: boolean;
+5 -1
View File
@@ -17,7 +17,8 @@ import type {
AdminWeatherSettings, AdminWeatherSettings,
InstalledWidget, InstalledWidget,
WidgetUploadManifest, WidgetUploadManifest,
PipelineStats PipelineStats,
ModelContextInfo
} from './adminTypes'; } from './adminTypes';
async function request<T>(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise<T> { async function request<T>(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise<T> {
@@ -142,6 +143,9 @@ export const getModels = (fetchFn?: typeof fetch) =>
export const getAiStatus = (fetchFn?: typeof fetch) => export const getAiStatus = (fetchFn?: typeof fetch) =>
request<AiStatus>('/api/admin/ai-status', {}, fetchFn); request<AiStatus>('/api/admin/ai-status', {}, fetchFn);
export const getModelContext = (model: string, fetchFn?: typeof fetch) =>
request<ModelContextInfo>(`/api/admin/model-context?model=${encodeURIComponent(model)}`, {}, fetchFn);
// Telegram account (Connections tab) — API ID/hash and the resulting login session are // Telegram account (Connections tab) — API ID/hash and the resulting login session are
// stored encrypted at rest server-side (see backend telegram/credentials.ts); none of // stored encrypted at rest server-side (see backend telegram/credentials.ts); none of
// these ever come back from the server, only status flags. // these ever come back from the server, only status flags.
+13
View File
@@ -157,6 +157,10 @@ export interface AdminSettings {
telegramMediaMode: 'self-host' | 'proxy'; telegramMediaMode: 'self-host' | 'proxy';
synthesisStylePreset: 'default' | 'casual' | 'formal'; synthesisStylePreset: 'default' | 'casual' | 'formal';
synthesisCustomInstructions: string; synthesisCustomInstructions: string;
/** Total context window (prompt + response) requested from Ollama for every synthesis/recap/tag-extraction call. */
synthesisNumCtx: number;
/** Max tokens the model may generate per call — too low silently truncates output mid-sentence. */
synthesisNumPredict: number;
widgets: AdminWidgetsEnabled; widgets: AdminWidgetsEnabled;
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[]; widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
retention: RetentionSettings; retention: RetentionSettings;
@@ -189,6 +193,10 @@ export interface AdminTrackedEvent {
active: boolean; active: boolean;
isSpillover: boolean; isSpillover: boolean;
retentionOverrideDays: number | null; retentionOverrideDays: number | null;
/** This item's own recap tone, independent of the global Merge-tab synthesis style. */
recapStylePreset: 'default' | 'casual' | 'formal';
/** Free-text instructions appended to this item's recap prompt specifically. */
recapCustomInstructions: string;
} }
export interface ModelCatalog { export interface ModelCatalog {
@@ -197,6 +205,11 @@ export interface ModelCatalog {
synthesis: string[]; synthesis: string[];
} }
/** Response from GET /api/admin/model-context — the selected model's own reported max context length, or null if Ollama doesn't expose it for this model/version. */
export interface ModelContextInfo {
contextLength: number | null;
}
export interface AiStatus { export interface AiStatus {
connected: boolean; connected: boolean;
host: string; host: string;
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { AdminTrackedEvent, AdminSource } from '$lib/adminTypes'; import type { AdminTrackedEvent, AdminSource } from '$lib/adminTypes';
import { addEvent, updateEvent, deleteEvent } from '$lib/adminApi'; import { addEvent, updateEvent, deleteEvent } from '$lib/adminApi';
import CollapsibleSection from './CollapsibleSection.svelte';
let { events: initial, sources }: { events: AdminTrackedEvent[]; sources: AdminSource[] } = $props(); let { events: initial, sources }: { events: AdminTrackedEvent[]; sources: AdminSource[] } = $props();
let events = $state([...initial]); let events = $state([...initial]);
@@ -18,7 +19,9 @@
keywordsText: '', keywordsText: '',
recapIntervalHours: null as AdminTrackedEvent['recapIntervalHours'], recapIntervalHours: null as AdminTrackedEvent['recapIntervalHours'],
isSpillover: false, isSpillover: false,
retentionOverrideDays: null as number | null retentionOverrideDays: null as number | null,
recapStylePreset: 'default' as AdminTrackedEvent['recapStylePreset'],
recapCustomInstructions: ''
}; };
} }
let editForm = $state(emptyEditForm()); let editForm = $state(emptyEditForm());
@@ -58,7 +61,9 @@
keywordsText: event.keywords.join(', '), keywordsText: event.keywords.join(', '),
recapIntervalHours: event.recapIntervalHours, recapIntervalHours: event.recapIntervalHours,
isSpillover: event.isSpillover, isSpillover: event.isSpillover,
retentionOverrideDays: event.retentionOverrideDays retentionOverrideDays: event.retentionOverrideDays,
recapStylePreset: event.recapStylePreset,
recapCustomInstructions: event.recapCustomInstructions
}; };
} }
@@ -86,7 +91,9 @@
keywords, keywords,
recapIntervalHours: editForm.recapIntervalHours, recapIntervalHours: editForm.recapIntervalHours,
isSpillover: editForm.isSpillover, isSpillover: editForm.isSpillover,
retentionOverrideDays: editForm.retentionOverrideDays retentionOverrideDays: editForm.retentionOverrideDays,
recapStylePreset: editForm.recapStylePreset,
recapCustomInstructions: editForm.recapCustomInstructions
}); });
events = events.map((e) => (e.id === editingId ? updated : e)); events = events.map((e) => (e.id === editingId ? updated : e));
editingId = null; editingId = null;
@@ -177,6 +184,30 @@
</p> </p>
</div> </div>
<div class="more-section">
<CollapsibleSection title="More">
<div class="field-label">Recap writing style</div>
<p class="hint">
Independent from the global "Writing style" in the Merge tab — that only applies to
regular AI-merged articles. This item's own recap uses only what's set here.
</p>
<select bind:value={editForm.recapStylePreset}>
<option value="default">Default (neutral, wire-service tone)</option>
<option value="casual">Casual</option>
<option value="formal">Formal</option>
</select>
<label class="field-label" for="recap-custom-instructions" style="margin-top: 10px;">
Additional instructions (optional)
</label>
<textarea
id="recap-custom-instructions"
rows="3"
placeholder={'e.g. "Focus on military developments", "Write as one continuous narrative, not a list"'}
bind:value={editForm.recapCustomInstructions}
></textarea>
</CollapsibleSection>
</div>
<label class="spillover-toggle edit-spillover"> <label class="spillover-toggle edit-spillover">
<input type="checkbox" bind:checked={editForm.isSpillover} /> <input type="checkbox" bind:checked={editForm.isSpillover} />
Show in "More »" instead of its own nav tab Show in "More »" instead of its own nav tab
@@ -339,6 +370,17 @@
.cadence-block .hint { .cadence-block .hint {
margin-top: 6px; margin-top: 6px;
} }
.more-section {
margin-top: 12px;
}
.more-section select,
.more-section textarea {
width: 100%;
}
.more-section textarea {
resize: vertical;
font: inherit;
}
.edit-spillover { .edit-spillover {
display: flex; display: flex;
margin-top: 12px; margin-top: 12px;
@@ -204,8 +204,9 @@
<div class="panel"> <div class="panel">
<span class="panel-title">Writing style</span> <span class="panel-title">Writing style</span>
<p class="hint"> <p class="hint">
Applies to AI-merged articles and event recaps only — a story with just one source Applies to AI-merged articles only — a story with just one source publishes with its
publishes with its original text untouched, no AI involved. original text untouched, no AI involved. Event recaps have their own independent writing
style, set per tracked item under Tracked items → Edit → More.
</p> </p>
<select bind:value={local.synthesisStylePreset} onchange={scheduleSave}> <select bind:value={local.synthesisStylePreset} onchange={scheduleSave}>
<option value="default">Default (neutral, wire-service tone)</option> <option value="default">Default (neutral, wire-service tone)</option>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { AdminSettings, ModelCatalog, AiStatus } from '$lib/adminTypes'; import type { AdminSettings, ModelCatalog, AiStatus } from '$lib/adminTypes';
import { updateSettings, getAiStatus } from '$lib/adminApi'; import { updateSettings, getAiStatus, getModelContext } from '$lib/adminApi';
import SaveStatus from './SaveStatus.svelte'; import SaveStatus from './SaveStatus.svelte';
let { settings, models, aiStatus: initialStatus }: { settings: AdminSettings; models: ModelCatalog; aiStatus: AiStatus } = let { settings, models, aiStatus: initialStatus }: { settings: AdminSettings; models: ModelCatalog; aiStatus: AiStatus } =
@@ -30,6 +30,63 @@
testing = false; testing = false;
} }
} }
// Context window / max response length — see backend/src/inference/ollama-provider.ts's
// DEFAULT_NUM_CTX/DEFAULT_NUM_PREDICT for why these are ever explicit at all: too low a
// num_predict silently truncates the model's output mid-sentence rather than erroring
// (this is what a "cut off" article/recap means), and num_ctx bounds how much source
// text can even be included in the prompt before it gets trimmed.
let numCtx = $state(settings.synthesisNumCtx);
let numPredict = $state(settings.synthesisNumPredict);
let contextStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
let contextSaveTimer: ReturnType<typeof setTimeout>;
// The selected synthesis model's own reported max context (via Ollama's /api/show) —
// null when undetectable (older Ollama, unusual model format, unreachable), in which
// case the slider falls back to a generous cap rather than blocking on it.
let detectedMax = $state<number | null>(null);
let detecting = $state(false);
const FALLBACK_MAX_CTX = 32768;
let maxCtx = $derived(detectedMax ?? FALLBACK_MAX_CTX);
// num_predict counts against the same context window as the prompt — capping it well
// under num_ctx leaves room for the prompt itself to actually fit.
let maxPredict = $derived(Math.max(100, numCtx - 512));
async function detectContext(model: string) {
if (!model) return;
detecting = true;
try {
const info = await getModelContext(model);
detectedMax = info.contextLength;
} catch {
detectedMax = null;
} finally {
detecting = false;
}
}
$effect(() => {
detectContext(selected.synthesis);
});
function scheduleContextSave() {
contextStatus = 'saving';
clearTimeout(contextSaveTimer);
contextSaveTimer = setTimeout(async () => {
try {
await updateSettings({ synthesisNumCtx: numCtx, synthesisNumPredict: numPredict });
contextStatus = 'saved';
setTimeout(() => (contextStatus = 'idle'), 1500);
} catch {
contextStatus = 'error';
}
}, 500);
}
function onNumCtxChange() {
if (numPredict > numCtx - 512) numPredict = Math.max(100, numCtx - 512);
scheduleContextSave();
}
</script> </script>
<div class="panel"> <div class="panel">
@@ -84,6 +141,57 @@
</select> </select>
</div> </div>
<div class="panel">
<div class="head">
<span class="panel-title">Context window</span>
<SaveStatus status={contextStatus} />
</div>
<p class="hint">
How much text the synthesis model can take in (context window) and how long its response
can be (max response length). Too low a response limit is why an article or event recap
sometimes cuts off mid-sentence instead of finishing.
{#if detecting}
Detecting {selected.synthesis}'s limit…
{:else if detectedMax}
Detected max for {selected.synthesis}: {detectedMax.toLocaleString()} tokens.
{:else}
Couldn't detect a limit for {selected.synthesis} — defaulting the slider's ceiling to
{FALLBACK_MAX_CTX.toLocaleString()}. Setting num_ctx above what the model actually
supports will make Ollama reject or silently degrade requests.
{/if}
</p>
<label class="field-label" for="num-ctx">
Context window (num_ctx) — {numCtx.toLocaleString()} tokens
</label>
<div class="slider-row">
<input
id="num-ctx"
type="range"
min="1024"
max={maxCtx}
step="512"
bind:value={numCtx}
oninput={onNumCtxChange}
/>
</div>
<label class="field-label" for="num-predict" style="margin-top: 12px;">
Max response length (num_predict) — {numPredict.toLocaleString()} tokens
</label>
<div class="slider-row">
<input
id="num-predict"
type="range"
min="100"
max={maxPredict}
step="50"
bind:value={numPredict}
oninput={scheduleContextSave}
/>
</div>
</div>
<style> <style>
.panel { .panel {
background: var(--surface-1); background: var(--surface-1);
@@ -121,6 +229,23 @@
.disconnected { .disconnected {
color: var(--text-danger); color: var(--text-danger);
} }
.field-label {
display: block;
font-size: 11px;
color: var(--text-muted);
margin-bottom: 6px;
}
.slider-row {
display: flex;
align-items: center;
gap: 12px;
}
.slider-row input[type='range'] {
flex: 1;
border: none;
padding: 0;
background: transparent;
}
select { select {
width: 100%; width: 100%;
} }