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 items = cluster.items;
|
||||||
|
|
||||||
const sourceNames = new Map(items.map((item) => [item.sourceId, sources.getSource(item.sourceId)?.name ?? 'Unknown source']));
|
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 = [];
|
const resolvedTags = [];
|
||||||
for (const label of tagLabels) {
|
for (const label of tagLabels) {
|
||||||
@@ -461,7 +461,7 @@ export async function publishEventRecap(
|
|||||||
event: TrackedEvent,
|
event: TrackedEvent,
|
||||||
constituents: MergedArticle[]
|
constituents: MergedArticle[]
|
||||||
): Promise<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 = [];
|
const resolvedTags = [];
|
||||||
for (const label of tagLabels) {
|
for (const label of tagLabels) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { InferenceProvider } from '../inference/provider.js';
|
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 { DEFAULT_NUM_CTX, DEFAULT_NUM_PREDICT } from '../inference/ollama-provider.js';
|
||||||
import { logger } from '../storage/db/logs.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;
|
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
|
- Summarizes what has happened across the period covered, in chronological order
|
||||||
- Highlights the most significant developments rather than restating every article
|
- Highlights the most significant developments rather than restating every article
|
||||||
- Stays neutral and factual, without editorializing
|
- 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.`;
|
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.
|
- 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
|
- Does not copy phrasing verbatim from any source
|
||||||
- Stays neutral and factual, without editorializing
|
- 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.`;
|
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 {
|
export interface SynthesisResult {
|
||||||
body: string;
|
body: string;
|
||||||
tagLabels: string[];
|
tagLabels: string[];
|
||||||
@@ -83,10 +101,12 @@ export async function synthesizeArticle(
|
|||||||
provider: InferenceProvider,
|
provider: InferenceProvider,
|
||||||
model: string,
|
model: string,
|
||||||
items: ContentItem[],
|
items: ContentItem[],
|
||||||
sourceNames: Map<string, string>
|
sourceNames: Map<string, string>,
|
||||||
|
settings: GlobalSettings
|
||||||
): Promise<SynthesisResult> {
|
): Promise<SynthesisResult> {
|
||||||
const prompt = buildPrompt(items, sourceNames);
|
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);
|
return parseResult(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +136,13 @@ export async function synthesizeRecap(
|
|||||||
provider: InferenceProvider,
|
provider: InferenceProvider,
|
||||||
model: string,
|
model: string,
|
||||||
eventName: string,
|
eventName: string,
|
||||||
articles: MergedArticle[]
|
articles: MergedArticle[],
|
||||||
|
settings: GlobalSettings
|
||||||
): Promise<SynthesisResult> {
|
): Promise<SynthesisResult> {
|
||||||
const prompt = buildRecapPrompt(eventName, articles);
|
const prompt = buildRecapPrompt(eventName, articles);
|
||||||
const raw = await provider.generate(prompt, {
|
const raw = await provider.generate(prompt, {
|
||||||
model,
|
model,
|
||||||
system: RECAP_SYSTEM_PROMPT,
|
system: RECAP_SYSTEM_PROMPT_BASE + styleAddendum(settings),
|
||||||
numCtx: DEFAULT_NUM_CTX,
|
numCtx: DEFAULT_NUM_CTX,
|
||||||
numPredict: DEFAULT_NUM_PREDICT
|
numPredict: DEFAULT_NUM_PREDICT
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ export function migrate() {
|
|||||||
fxtwitter_base_url TEXT NOT NULL DEFAULT 'https://api.fxtwitter.com',
|
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)
|
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)
|
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_weather_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
widget_stocks_enabled INTEGER NOT NULL DEFAULT 1,
|
widget_stocks_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
widget_bookmarks_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());
|
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" —
|
// 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
|
// 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,
|
fxtwitterBaseUrl: row.fxtwitter_base_url,
|
||||||
nitterInstanceUrl: row.nitter_instance_url,
|
nitterInstanceUrl: row.nitter_instance_url,
|
||||||
telegramMediaMode: row.telegram_media_mode,
|
telegramMediaMode: row.telegram_media_mode,
|
||||||
|
synthesisStylePreset: row.synthesis_style_preset,
|
||||||
|
synthesisCustomInstructions: row.synthesis_custom_instructions,
|
||||||
widgets: {
|
widgets: {
|
||||||
weather: !!row.widget_weather_enabled,
|
weather: !!row.widget_weather_enabled,
|
||||||
stocks: !!row.widget_stocks_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,
|
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,
|
nitter_media_mode=$nitter_media_mode, fxtwitter_base_url=$fxtwitter_base_url, nitter_instance_url=$nitter_instance_url,
|
||||||
telegram_media_mode=$telegram_media_mode,
|
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_weather_enabled=$widget_weather_enabled, widget_stocks_enabled=$widget_stocks_enabled,
|
||||||
widget_bookmarks_enabled=$widget_bookmarks_enabled, widget_poe2_enabled=$widget_poe2_enabled,
|
widget_bookmarks_enabled=$widget_bookmarks_enabled, widget_poe2_enabled=$widget_poe2_enabled,
|
||||||
widget_order=$widget_order,
|
widget_order=$widget_order,
|
||||||
@@ -105,6 +108,8 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
|
|||||||
$fxtwitter_base_url: merged.fxtwitterBaseUrl,
|
$fxtwitter_base_url: merged.fxtwitterBaseUrl,
|
||||||
$nitter_instance_url: merged.nitterInstanceUrl,
|
$nitter_instance_url: merged.nitterInstanceUrl,
|
||||||
$telegram_media_mode: merged.telegramMediaMode,
|
$telegram_media_mode: merged.telegramMediaMode,
|
||||||
|
$synthesis_style_preset: merged.synthesisStylePreset,
|
||||||
|
$synthesis_custom_instructions: merged.synthesisCustomInstructions,
|
||||||
$widget_weather_enabled: merged.widgets.weather ? 1 : 0,
|
$widget_weather_enabled: merged.widgets.weather ? 1 : 0,
|
||||||
$widget_stocks_enabled: merged.widgets.stocks ? 1 : 0,
|
$widget_stocks_enabled: merged.widgets.stocks ? 1 : 0,
|
||||||
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
|
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
|
||||||
|
|||||||
@@ -285,6 +285,10 @@ export interface GlobalSettings {
|
|||||||
nitterInstanceUrl: string;
|
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. */
|
/** 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';
|
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. */
|
/** 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: {
|
widgets: {
|
||||||
weather: boolean;
|
weather: boolean;
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ export interface AdminSettings {
|
|||||||
fxtwitterBaseUrl: string;
|
fxtwitterBaseUrl: string;
|
||||||
nitterInstanceUrl: string;
|
nitterInstanceUrl: string;
|
||||||
telegramMediaMode: 'self-host' | 'proxy';
|
telegramMediaMode: 'self-host' | 'proxy';
|
||||||
|
synthesisStylePreset: 'default' | 'casual' | 'formal';
|
||||||
|
synthesisCustomInstructions: string;
|
||||||
widgets: AdminWidgetsEnabled;
|
widgets: AdminWidgetsEnabled;
|
||||||
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
|
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
|
||||||
retention: RetentionSettings;
|
retention: RetentionSettings;
|
||||||
|
|||||||
@@ -34,7 +34,9 @@
|
|||||||
followUpMinNewSources: local.followUpMinNewSources,
|
followUpMinNewSources: local.followUpMinNewSources,
|
||||||
tagDedupThreshold: local.tagDedupThreshold,
|
tagDedupThreshold: local.tagDedupThreshold,
|
||||||
tagExpiryDays: local.tagExpiryDays,
|
tagExpiryDays: local.tagExpiryDays,
|
||||||
categoryPriority: local.categoryPriority
|
categoryPriority: local.categoryPriority,
|
||||||
|
synthesisStylePreset: local.synthesisStylePreset,
|
||||||
|
synthesisCustomInstructions: local.synthesisCustomInstructions
|
||||||
});
|
});
|
||||||
status = 'saved';
|
status = 'saved';
|
||||||
setTimeout(() => (status = 'idle'), 1500);
|
setTimeout(() => (status = 'idle'), 1500);
|
||||||
@@ -199,6 +201,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="panel">
|
||||||
<span class="panel-title">Hold before publish</span>
|
<span class="panel-title">Hold before publish</span>
|
||||||
<p class="hint">Wait window to gather more sources before finalizing a story.</p>
|
<p class="hint">Wait window to gather more sources before finalizing a story.</p>
|
||||||
@@ -328,6 +353,12 @@
|
|||||||
select {
|
select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 6px;
|
||||||
|
font: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
.priority-list {
|
.priority-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user