Add admin-configurable writing style for AI synthesis

The synthesis system prompts were previously the only "instructions"
the AI ever got, hardcoded and invisible from the admin panel — no
way to control tone, and no way to know what was actually being sent
without reading the source.

Adds a "Writing style" panel to the Merge tab: a preset dropdown
(Default/Casual/Formal) plus a free-text field for arbitrary
additional instructions (e.g. "keep paragraphs under 3 sentences").
Both are appended as an addendum to the existing base system prompts
in synthesis.ts — the structural rules (attribution, paragraph count,
tag format) are never overridden, only style on top of them. Applies
to AI-merged articles and event recaps; single-source items still
publish verbatim with no AI involved either way.

Backend: new global_settings.synthesis_style_preset (default) and
.synthesis_custom_instructions ('') columns, migrated in for existing
installs, threaded through settings.ts and into synthesizeArticle/
synthesizeRecap's system prompt construction.

Verified: settings round-trip through GET/PATCH /api/admin/settings
with correct defaults; a captured prompt confirms 'default' with no
custom text produces the exact original prompt unchanged, while
'casual' + custom text appends both correctly; migration against an
old-schema global_settings table adds both columns with correct
defaults.
This commit is contained in:
Claude
2026-07-27 13:55:26 +00:00
parent dae6a51db0
commit 1176bb4425
7 changed files with 81 additions and 10 deletions
+2 -2
View File
@@ -362,7 +362,7 @@ export async function publishCluster(
const items = cluster.items;
const sourceNames = new Map(items.map((item) => [item.sourceId, sources.getSource(item.sourceId)?.name ?? 'Unknown source']));
const { body, tagLabels } = await synthesizeArticle(provider, settings.selectedModels.synthesis, items, sourceNames);
const { body, tagLabels } = await synthesizeArticle(provider, settings.selectedModels.synthesis, items, sourceNames, settings);
const resolvedTags = [];
for (const label of tagLabels) {
@@ -461,7 +461,7 @@ export async function publishEventRecap(
event: TrackedEvent,
constituents: MergedArticle[]
): Promise<MergedArticle> {
const { body, tagLabels } = await synthesizeRecap(provider, settings.selectedModels.synthesis, event.name, constituents);
const { body, tagLabels } = await synthesizeRecap(provider, settings.selectedModels.synthesis, event.name, constituents, settings);
const resolvedTags = [];
for (const label of tagLabels) {
+28 -7
View File
@@ -1,5 +1,5 @@
import type { InferenceProvider } from '../inference/provider.js';
import type { ContentItem, MergedArticle } from '../storage/db/types.js';
import type { ContentItem, GlobalSettings, MergedArticle } from '../storage/db/types.js';
import { DEFAULT_NUM_CTX, DEFAULT_NUM_PREDICT } from '../inference/ollama-provider.js';
import { logger } from '../storage/db/logs.js';
@@ -23,7 +23,7 @@ function capEntryText(text: string, budgetChars: number): string {
return text.length > budgetChars ? text.slice(0, budgetChars) + '…' : text;
}
const RECAP_SYSTEM_PROMPT = `You are a neutral news synthesis assistant. Given a chronological list of articles already published about an ongoing tracked event, write a single recap article that:
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 a single recap article that:
- Summarizes what has happened across the period covered, in chronological order
- Highlights the most significant developments rather than restating every article
- Stays neutral and factual, without editorializing
@@ -31,7 +31,7 @@ const RECAP_SYSTEM_PROMPT = `You are a neutral news synthesis assistant. Given a
After the recap, on a new line, 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 = `You are a neutral news synthesis assistant. Given summaries from multiple news sources describing the same event, write a single original article that:
const SYSTEM_PROMPT_BASE = `You are a neutral news synthesis assistant. Given summaries from multiple news sources describing the same event, write a single original article that:
- Attributes specific claims to the outlet that reported them, using each source's exact name as given below (e.g. if a source is labeled "Source 1 (Reuters)", write "Reuters reported..."). Never invent, guess, or substitute an outlet name that isn't one of the source names actually given below.
- Does not copy phrasing verbatim from any source
- Stays neutral and factual, without editorializing
@@ -39,6 +39,24 @@ const SYSTEM_PROMPT = `You are a neutral news synthesis assistant. Given summari
After the article, on a new line, write exactly "${TAG_DELIMITER}" followed by 2-4 short comma-separated topic/entity tags (e.g. proper nouns, named events) that this article is about. If nothing salient qualifies, leave the tag line empty.`;
// Admin-selectable presets (Merge tab, "Writing style") — appended to whichever base
// prompt applies. 'default' adds nothing: the base prompts above already describe the
// original neutral wire-service tone this pipeline shipped with.
const STYLE_PRESETS: Record<GlobalSettings['synthesisStylePreset'], string> = {
default: '',
casual: 'Write in a casual, conversational tone, like a knowledgeable friend catching you up on what happened — contractions and plain language are fine. Still stay factual and keep outlet attribution accurate.',
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). */
function styleAddendum(settings: GlobalSettings): string {
const preset = STYLE_PRESETS[settings.synthesisStylePreset] ?? '';
const custom = settings.synthesisCustomInstructions.trim();
const lines = [preset, custom].filter(Boolean);
if (lines.length === 0) return '';
return `\n\nAdditional style instructions from the site admin (follow these without breaking the rules above):\n${lines.join('\n')}`;
}
export interface SynthesisResult {
body: string;
tagLabels: string[];
@@ -83,10 +101,12 @@ export async function synthesizeArticle(
provider: InferenceProvider,
model: string,
items: ContentItem[],
sourceNames: Map<string, string>
sourceNames: Map<string, string>,
settings: GlobalSettings
): Promise<SynthesisResult> {
const prompt = buildPrompt(items, sourceNames);
const raw = await provider.generate(prompt, { model, system: SYSTEM_PROMPT, numCtx: DEFAULT_NUM_CTX, numPredict: DEFAULT_NUM_PREDICT });
const system = SYSTEM_PROMPT_BASE + styleAddendum(settings);
const raw = await provider.generate(prompt, { model, system, numCtx: DEFAULT_NUM_CTX, numPredict: DEFAULT_NUM_PREDICT });
return parseResult(raw);
}
@@ -116,12 +136,13 @@ export async function synthesizeRecap(
provider: InferenceProvider,
model: string,
eventName: string,
articles: MergedArticle[]
articles: MergedArticle[],
settings: GlobalSettings
): Promise<SynthesisResult> {
const prompt = buildRecapPrompt(eventName, articles);
const raw = await provider.generate(prompt, {
model,
system: RECAP_SYSTEM_PROMPT,
system: RECAP_SYSTEM_PROMPT_BASE + styleAddendum(settings),
numCtx: DEFAULT_NUM_CTX,
numPredict: DEFAULT_NUM_PREDICT
});
+8
View File
@@ -186,6 +186,8 @@ export function migrate() {
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com',
nitter_instance_url TEXT NOT NULL DEFAULT 'https://nitter.net', -- admin's preferred instance, prefills new Nitter sources (Connections tab)
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
widget_weather_enabled INTEGER NOT NULL DEFAULT 1,
widget_stocks_enabled INTEGER NOT NULL DEFAULT 1,
widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1,
@@ -394,6 +396,12 @@ export function migrate() {
stmt.run(`stk-${symbol.replace(/[^a-z0-9]+/gi, '-')}`, label, symbol, i + 1, new Date().toISOString());
});
}
if (!hasColumn('global_settings', 'synthesis_style_preset')) {
db.exec("ALTER TABLE global_settings ADD COLUMN synthesis_style_preset TEXT NOT NULL DEFAULT 'default'");
}
if (!hasColumn('global_settings', 'synthesis_custom_instructions')) {
db.exec("ALTER TABLE global_settings ADD COLUMN synthesis_custom_instructions TEXT NOT NULL DEFAULT ''");
}
// 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
@@ -16,6 +16,8 @@ function rowToSettings(row: any): GlobalSettings {
fxtwitterBaseUrl: row.fxtwitter_base_url,
nitterInstanceUrl: row.nitter_instance_url,
telegramMediaMode: row.telegram_media_mode,
synthesisStylePreset: row.synthesis_style_preset,
synthesisCustomInstructions: row.synthesis_custom_instructions,
widgets: {
weather: !!row.widget_weather_enabled,
stocks: !!row.widget_stocks_enabled,
@@ -80,6 +82,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
ai_service_host=$ai_service_host, ai_service_port=$ai_service_port, selected_models=$selected_models,
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,
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,
@@ -105,6 +108,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
$fxtwitter_base_url: merged.fxtwitterBaseUrl,
$nitter_instance_url: merged.nitterInstanceUrl,
$telegram_media_mode: merged.telegramMediaMode,
$synthesis_style_preset: merged.synthesisStylePreset,
$synthesis_custom_instructions: merged.synthesisCustomInstructions,
$widget_weather_enabled: merged.widgets.weather ? 1 : 0,
$widget_stocks_enabled: merged.widgets.stocks ? 1 : 0,
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
+4
View File
@@ -285,6 +285,10 @@ export interface GlobalSettings {
nitterInstanceUrl: string;
/** How Telegram message media (attached photos/videos, channel avatars) is served — see pipeline/publish.ts's resolveTelegramMedia. No "direct" option: Telegram has no public hotlinkable media URL, bytes only come from the authenticated MTProto session. */
telegramMediaMode: 'self-host' | 'proxy';
/** Tone preset applied to every AI-synthesized article/recap (see pipeline/synthesis.ts's STYLE_PRESETS) — 'default' is the original neutral wire-service tone with no addendum. Never applies to single-source items, which always publish verbatim without going through the AI at all. */
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;
/** 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;
+2
View File
@@ -140,6 +140,8 @@ export interface AdminSettings {
fxtwitterBaseUrl: string;
nitterInstanceUrl: string;
telegramMediaMode: 'self-host' | 'proxy';
synthesisStylePreset: 'default' | 'casual' | 'formal';
synthesisCustomInstructions: string;
widgets: AdminWidgetsEnabled;
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
retention: RetentionSettings;
@@ -34,7 +34,9 @@
followUpMinNewSources: local.followUpMinNewSources,
tagDedupThreshold: local.tagDedupThreshold,
tagExpiryDays: local.tagExpiryDays,
categoryPriority: local.categoryPriority
categoryPriority: local.categoryPriority,
synthesisStylePreset: local.synthesisStylePreset,
synthesisCustomInstructions: local.synthesisCustomInstructions
});
status = 'saved';
setTimeout(() => (status = 'idle'), 1500);
@@ -199,6 +201,29 @@
</div>
</div>
<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.
</p>
<select bind:value={local.synthesisStylePreset} onchange={scheduleSave}>
<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="custom-instructions" style="margin-top: 10px;">
Additional instructions (optional)
</label>
<textarea
id="custom-instructions"
rows="3"
placeholder={'e.g. "Keep paragraphs under 3 sentences", "Never use the word notably"'}
bind:value={local.synthesisCustomInstructions}
oninput={scheduleSave}
></textarea>
</div>
<div class="panel">
<span class="panel-title">Hold before publish</span>
<p class="hint">Wait window to gather more sources before finalizing a story.</p>
@@ -328,6 +353,12 @@
select {
width: 100%;
}
textarea {
width: 100%;
margin-top: 6px;
font: inherit;
resize: vertical;
}
.priority-list {
display: flex;
flex-direction: column;