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:
@@ -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.) ---
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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))];
|
||||
|
||||
@@ -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)}"`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user