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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user