From a312e0a8d3a25b788896e382623c11023ae9ad7b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 13:34:54 +0000 Subject: [PATCH] Make synthesis context/response length admin-configurable; give recaps their own writing style and let them run full-length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/src/api/admin.ts | 14 ++ backend/src/inference/ollama-provider.ts | 28 ++++ backend/src/inference/provider.ts | 2 + backend/src/pipeline/publish.ts | 4 +- backend/src/pipeline/synthesis.ts | 81 ++++++----- backend/src/storage/db/events.ts | 12 +- backend/src/storage/db/index.ts | 16 +++ backend/src/storage/db/settings.ts | 5 + backend/src/storage/db/types.ts | 8 ++ frontend/src/lib/adminApi.ts | 6 +- frontend/src/lib/adminTypes.ts | 13 ++ .../src/lib/components/admin/EventsTab.svelte | 48 ++++++- .../src/lib/components/admin/MergeTab.svelte | 5 +- .../src/lib/components/admin/ModelsTab.svelte | 127 +++++++++++++++++- 14 files changed, 327 insertions(+), 42 deletions(-) diff --git a/backend/src/api/admin.ts b/backend/src/api/admin.ts index 0b66960..7b83f3c 100644 --- a/backend/src/api/admin.ts +++ b/backend/src/api/admin.ts @@ -208,6 +208,20 @@ export async function registerAdminRoutes(app: FastifyInstance) { 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. // API ID/hash and the resulting login session are stored encrypted at rest; none of // these routes ever echo them back to the client.) --- diff --git a/backend/src/inference/ollama-provider.ts b/backend/src/inference/ollama-provider.ts index df32f58..247f878 100644 --- a/backend/src/inference/ollama-provider.ts +++ b/backend/src/inference/ollama-provider.ts @@ -135,4 +135,32 @@ export class OllamaProvider implements InferenceProvider { 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 { + 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 }; + 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; + } + } } diff --git a/backend/src/inference/provider.ts b/backend/src/inference/provider.ts index a542722..9e08016 100644 --- a/backend/src/inference/provider.ts +++ b/backend/src/inference/provider.ts @@ -6,4 +6,6 @@ export interface InferenceProvider { embed(text: string, opts?: { model?: string }): Promise; listModels(): Promise; isReachable(): Promise; + /** 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; } diff --git a/backend/src/pipeline/publish.ts b/backend/src/pipeline/publish.ts index 9ff908e..3366230 100644 --- a/backend/src/pipeline/publish.ts +++ b/backend/src/pipeline/publish.ts @@ -327,7 +327,7 @@ export async function publishDirect( let tagIds: string[] = []; if (opts.provider) { 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'); } catch (err) { logger.error('synthesis', `Tag extraction failed for "${item.title}": ${(err as Error).message}`); @@ -475,7 +475,7 @@ export async function publishEventRecap( event: TrackedEvent, constituents: MergedArticle[] ): Promise { - 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 category = [...new Set(constituents.flatMap((a) => a.category))]; diff --git a/backend/src/pipeline/synthesis.ts b/backend/src/pipeline/synthesis.ts index d9bcf83..d83d880 100644 --- a/backend/src/pipeline/synthesis.ts +++ b/backend/src/pipeline/synthesis.ts @@ -1,6 +1,5 @@ import type { InferenceProvider } from '../inference/provider.js'; -import type { ContentItem, GlobalSettings, MergedArticle } from '../storage/db/types.js'; -import { DEFAULT_NUM_CTX, DEFAULT_NUM_PREDICT } from '../inference/ollama-provider.js'; +import type { ContentItem, GlobalSettings, MergedArticle, TrackedEvent } from '../storage/db/types.js'; import { logger } from '../storage/db/logs.js'; 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 // 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 -// prompted this). Rather than relying on that, prompts here are sized to fit -// DEFAULT_NUM_CTX up front: each source/article gets an equal character budget, cut only -// when the whole prompt would otherwise overflow, so every source stays at least -// partially represented (and attributable) instead of some being dropped outright. -// ~4 chars/token is a rough heuristic (no tokenizer available here) — good enough for a -// safety margin, not meant to be exact. +// prompted this). Rather than relying on that, prompts here are sized to fit the +// admin-configured num_ctx/num_predict (Models tab) up front: each source/article gets +// an equal character budget, cut only when the whole prompt would otherwise overflow, so +// every source stays at least partially represented (and attributable) instead of some +// being dropped outright. ~4 chars/token is a rough heuristic (no tokenizer available +// here) — good enough for a safety margin, not meant to be exact. const CHARS_PER_TOKEN = 4; 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 +/** 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 { return text.length > budgetChars ? text.slice(0, budgetChars) + '…' : text; } @@ -56,14 +59,15 @@ function parseTagLabels(raw: string): string[] { export async function extractTags( provider: InferenceProvider, model: string, - item: Pick + item: Pick, + settings: GlobalSettings ): Promise { - 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 raw = await provider.generate(prompt, { model, system: TAG_EXTRACTION_SYSTEM_PROMPT, - numCtx: DEFAULT_NUM_CTX, + numCtx: settings.synthesisNumCtx, numPredict: TAG_EXTRACTION_NUM_PREDICT, 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: 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: - - Summarizes what has happened across the period covered, in chronological order - - Highlights the most significant developments rather than restating every article +2. On a new line, write exactly "${TITLE_DELIMITER}", then the recap: + - 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. + - 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 - - 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.`; 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 = { 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 { const preset = STYLE_PRESETS[settings.synthesisStylePreset] ?? ''; 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')}`; } +/** + * 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 { title: string; body: string; @@ -120,8 +139,8 @@ function fallbackTitle(body: string): string { return firstLine.length > 100 ? firstLine.slice(0, 97) + '…' : firstLine; } -function buildPrompt(items: ContentItem[], sourceNames: Map): string { - const budgetPerItem = Math.max(MIN_ENTRY_CHARS, Math.floor(MAX_INPUT_CHARS / items.length)); +function buildPrompt(items: ContentItem[], sourceNames: Map, numCtx: number, numPredict: number): string { + const budgetPerItem = Math.max(MIN_ENTRY_CHARS, Math.floor(maxInputChars(numCtx, numPredict) / items.length)); let truncated = 0; const entries = items.map((item, i) => { // Same fallback publishDirect uses (publish.ts) — body is the full article text @@ -187,15 +206,16 @@ export async function synthesizeArticle( sourceNames: Map, settings: GlobalSettings ): Promise { - 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 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) ?? ''}"`); } -function buildRecapPrompt(eventName: string, articles: MergedArticle[]): string { - const budgetPerArticle = Math.max(MIN_ENTRY_CHARS, Math.floor(MAX_INPUT_CHARS / articles.length)); +function buildRecapPrompt(eventName: string, articles: MergedArticle[], numCtx: number, numPredict: number): string { + const budgetPerArticle = Math.max(MIN_ENTRY_CHARS, Math.floor(maxInputChars(numCtx, numPredict) / articles.length)); let truncated = 0; const entries = articles.map((article, i) => { const body = capEntryText(article.body, budgetPerArticle); @@ -219,17 +239,18 @@ function buildRecapPrompt(eventName: string, articles: MergedArticle[]): string export async function synthesizeRecap( provider: InferenceProvider, model: string, - eventName: string, + event: TrackedEvent, articles: MergedArticle[], settings: GlobalSettings ): Promise { - 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, { model, - system: RECAP_SYSTEM_PROMPT_BASE + styleAddendum(settings), - numCtx: DEFAULT_NUM_CTX, - numPredict: DEFAULT_NUM_PREDICT, - label: `Recapping event: "${eventName.slice(0, 60)}"` + system: RECAP_SYSTEM_PROMPT_BASE + recapStyleAddendum(event), + numCtx, + numPredict, + 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)}"`); } diff --git a/backend/src/storage/db/events.ts b/backend/src/storage/db/events.ts index e2b12ab..ba6b1e8 100644 --- a/backend/src/storage/db/events.ts +++ b/backend/src/storage/db/events.ts @@ -14,6 +14,8 @@ function rowToEvent(row: any): TrackedEvent { isSpillover: !!row.is_spillover, retentionOverrideDays: row.retention_override_days, lastRecapAt: row.last_recap_at, + recapStylePreset: row.recap_style_preset, + recapCustomInstructions: row.recap_custom_instructions, createdAt: row.created_at }; } @@ -52,8 +54,8 @@ export function createEvent(input: Partial): TrackedEvent { const id = `evt-${randomUUID()}`; const now = new Date().toISOString(); 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)` + `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, ?, ?, ?)` ).run( id, input.name ?? 'Untitled event', @@ -64,6 +66,8 @@ export function createEvent(input: Partial): TrackedEvent { input.active === false ? 0 : 1, input.isSpillover ? 1 : 0, input.retentionOverrideDays ?? null, + input.recapStylePreset ?? 'default', + input.recapCustomInstructions ?? '', now ); return getEvent(id)!; @@ -74,7 +78,7 @@ export function updateEvent(id: string, patch: Partial): TrackedEv if (!existing) return null; const merged = { ...existing, ...patch }; 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( merged.name, merged.description, @@ -85,6 +89,8 @@ export function updateEvent(id: string, patch: Partial): TrackedEv merged.isSpillover ? 1 : 0, merged.retentionOverrideDays, merged.lastRecapAt, + merged.recapStylePreset, + merged.recapCustomInstructions, id ); return getEvent(id); diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index 0af104f..0deb983 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -161,6 +161,8 @@ export function migrate() { is_spillover INTEGER NOT NULL DEFAULT 0, retention_override_days INTEGER, 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 ); @@ -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) 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_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_stocks_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')) { 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')) { 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')) { 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" — // general news sources belong here, not on "Top stories" itself, which isn't a real diff --git a/backend/src/storage/db/settings.ts b/backend/src/storage/db/settings.ts index 02a8035..68782e2 100644 --- a/backend/src/storage/db/settings.ts +++ b/backend/src/storage/db/settings.ts @@ -45,6 +45,8 @@ function rowToSettings(row: any): GlobalSettings { telegramMediaMode: row.telegram_media_mode, synthesisStylePreset: row.synthesis_style_preset, synthesisCustomInstructions: row.synthesis_custom_instructions, + synthesisNumCtx: row.synthesis_num_ctx, + synthesisNumPredict: row.synthesis_num_predict, ...widgetsAndOrder(), retention: { publishedArticleMaxAgeDays: row.published_article_max_age_days, @@ -93,6 +95,7 @@ export function updateSettings(patch: Partial): GlobalSettings { nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, nitter_instance_url=$nitter_instance_url, telegram_media_mode=$telegram_media_mode, 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, storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit WHERE id = 1` @@ -112,6 +115,8 @@ export function updateSettings(patch: Partial): GlobalSettings { $telegram_media_mode: merged.telegramMediaMode, $synthesis_style_preset: merged.synthesisStylePreset, $synthesis_custom_instructions: merged.synthesisCustomInstructions, + $synthesis_num_ctx: merged.synthesisNumCtx, + $synthesis_num_predict: merged.synthesisNumPredict, $published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays, $raw_item_max_age_days: merged.retention.rawItemMaxAgeDays, $storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0, diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index de5c96f..366de36 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -185,6 +185,10 @@ export interface TrackedEvent { isSpillover: boolean; retentionOverrideDays: number | 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; } @@ -291,6 +295,10 @@ export interface GlobalSettings { 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. */ 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. */ widgets: { weather: boolean; diff --git a/frontend/src/lib/adminApi.ts b/frontend/src/lib/adminApi.ts index a95bdb6..a77d98d 100644 --- a/frontend/src/lib/adminApi.ts +++ b/frontend/src/lib/adminApi.ts @@ -17,7 +17,8 @@ import type { AdminWeatherSettings, InstalledWidget, WidgetUploadManifest, - PipelineStats + PipelineStats, + ModelContextInfo } from './adminTypes'; async function request(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise { @@ -142,6 +143,9 @@ export const getModels = (fetchFn?: typeof fetch) => export const getAiStatus = (fetchFn?: typeof fetch) => request('/api/admin/ai-status', {}, fetchFn); +export const getModelContext = (model: string, fetchFn?: typeof fetch) => + request(`/api/admin/model-context?model=${encodeURIComponent(model)}`, {}, fetchFn); + // 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 // these ever come back from the server, only status flags. diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index b15e373..a94956b 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -157,6 +157,10 @@ export interface AdminSettings { telegramMediaMode: 'self-host' | 'proxy'; synthesisStylePreset: 'default' | 'casual' | 'formal'; 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; widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[]; retention: RetentionSettings; @@ -189,6 +193,10 @@ export interface AdminTrackedEvent { active: boolean; isSpillover: boolean; 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 { @@ -197,6 +205,11 @@ export interface ModelCatalog { 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 { connected: boolean; host: string; diff --git a/frontend/src/lib/components/admin/EventsTab.svelte b/frontend/src/lib/components/admin/EventsTab.svelte index fd9a0e3..44a6a6a 100644 --- a/frontend/src/lib/components/admin/EventsTab.svelte +++ b/frontend/src/lib/components/admin/EventsTab.svelte @@ -1,6 +1,7 @@
@@ -84,6 +141,57 @@
+
+
+ Context window + +
+

+ 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} +

+ + +
+ +
+ + +
+ +
+
+