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 12f8e2b525
commit c6626500fd
14 changed files with 327 additions and 42 deletions
+14
View File
@@ -204,6 +204,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.) ---
+28
View File
@@ -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<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[]>;
listModels(): Promise<string[]>;
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[] = [];
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<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 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 { 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<ContentItem, 'title' | 'summary' | 'body'>
item: Pick<ContentItem, 'title' | 'summary' | 'body'>,
settings: GlobalSettings
): 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 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<GlobalSettings['synthesisStylePreset'], string> = {
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, string>): string {
const budgetPerItem = Math.max(MIN_ENTRY_CHARS, Math.floor(MAX_INPUT_CHARS / items.length));
function buildPrompt(items: ContentItem[], sourceNames: Map<string, string>, 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<string, string>,
settings: GlobalSettings
): 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 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<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, {
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)}"`);
}
+9 -3
View File
@@ -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>): 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>): 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<TrackedEvent>): 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<TrackedEvent>): TrackedEv
merged.isSpillover ? 1 : 0,
merged.retentionOverrideDays,
merged.lastRecapAt,
merged.recapStylePreset,
merged.recapCustomInstructions,
id
);
return getEvent(id);
+16
View File
@@ -135,6 +135,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
);
@@ -188,6 +190,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,
@@ -345,6 +349,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');
}
@@ -402,6 +412,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
+5
View File
@@ -18,6 +18,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,
widgets: {
weather: !!row.widget_weather_enabled,
stocks: !!row.widget_stocks_enabled,
@@ -83,6 +85,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,
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,
widget_weather_enabled=$widget_weather_enabled, widget_stocks_enabled=$widget_stocks_enabled,
widget_bookmarks_enabled=$widget_bookmarks_enabled, widget_poe2_enabled=$widget_poe2_enabled,
widget_order=$widget_order,
@@ -110,6 +113,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): 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,
$widget_weather_enabled: merged.widgets.weather ? 1 : 0,
$widget_stocks_enabled: merged.widgets.stocks ? 1 : 0,
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
+8
View File
@@ -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;
}
@@ -289,6 +293,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;
+5 -1
View File
@@ -14,7 +14,8 @@ import type {
AdminBookmark,
Poe2BrowseEntry,
AdminPoe2Entry,
PipelineStats
PipelineStats,
ModelContextInfo
} from './adminTypes';
async function request<T>(path: string, options: RequestInit = {}, fetchFn: typeof fetch = fetch): Promise<T> {
@@ -139,6 +140,9 @@ export const getModels = (fetchFn?: typeof fetch) =>
export const getAiStatus = (fetchFn?: typeof fetch) =>
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
// stored encrypted at rest server-side (see backend telegram/credentials.ts); none of
// these ever come back from the server, only status flags.
+13
View File
@@ -142,6 +142,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;
@@ -176,6 +180,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 {
@@ -184,6 +192,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;
@@ -1,6 +1,7 @@
<script lang="ts">
import type { AdminTrackedEvent, AdminSource } from '$lib/adminTypes';
import { addEvent, updateEvent, deleteEvent } from '$lib/adminApi';
import CollapsibleSection from './CollapsibleSection.svelte';
let { events: initial, sources }: { events: AdminTrackedEvent[]; sources: AdminSource[] } = $props();
let events = $state([...initial]);
@@ -18,7 +19,9 @@
keywordsText: '',
recapIntervalHours: null as AdminTrackedEvent['recapIntervalHours'],
isSpillover: false,
retentionOverrideDays: null as number | null
retentionOverrideDays: null as number | null,
recapStylePreset: 'default' as AdminTrackedEvent['recapStylePreset'],
recapCustomInstructions: ''
};
}
let editForm = $state(emptyEditForm());
@@ -58,7 +61,9 @@
keywordsText: event.keywords.join(', '),
recapIntervalHours: event.recapIntervalHours,
isSpillover: event.isSpillover,
retentionOverrideDays: event.retentionOverrideDays
retentionOverrideDays: event.retentionOverrideDays,
recapStylePreset: event.recapStylePreset,
recapCustomInstructions: event.recapCustomInstructions
};
}
@@ -86,7 +91,9 @@
keywords,
recapIntervalHours: editForm.recapIntervalHours,
isSpillover: editForm.isSpillover,
retentionOverrideDays: editForm.retentionOverrideDays
retentionOverrideDays: editForm.retentionOverrideDays,
recapStylePreset: editForm.recapStylePreset,
recapCustomInstructions: editForm.recapCustomInstructions
});
events = events.map((e) => (e.id === editingId ? updated : e));
editingId = null;
@@ -177,6 +184,30 @@
</p>
</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">
<input type="checkbox" bind:checked={editForm.isSpillover} />
Show in "More »" instead of its own nav tab
@@ -339,6 +370,17 @@
.cadence-block .hint {
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 {
display: flex;
margin-top: 12px;
@@ -204,8 +204,9 @@
<div class="panel">
<span class="panel-title">Writing style</span>
<p class="hint">
Applies to AI-merged articles and event recaps only — a story with just one source
publishes with its original text untouched, no AI involved.
Applies to AI-merged articles only — a story with just one source publishes with its
original text untouched, no AI involved. Event recaps have their own independent writing
style, set per tracked item under Tracked items → Edit → More.
</p>
<select bind:value={local.synthesisStylePreset} onchange={scheduleSave}>
<option value="default">Default (neutral, wire-service tone)</option>
@@ -1,6 +1,6 @@
<script lang="ts">
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';
let { settings, models, aiStatus: initialStatus }: { settings: AdminSettings; models: ModelCatalog; aiStatus: AiStatus } =
@@ -30,6 +30,63 @@
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>
<div class="panel">
@@ -84,6 +141,57 @@
</select>
</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>
.panel {
background: var(--surface-1);
@@ -121,6 +229,23 @@
.disconnected {
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 {
width: 100%;
}