diff --git a/backend/src/pipeline/publish.ts b/backend/src/pipeline/publish.ts index 27ac515..b5348ef 100644 --- a/backend/src/pipeline/publish.ts +++ b/backend/src/pipeline/publish.ts @@ -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 { - 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) { diff --git a/backend/src/pipeline/synthesis.ts b/backend/src/pipeline/synthesis.ts index 21bacbf..a3be66c 100644 --- a/backend/src/pipeline/synthesis.ts +++ b/backend/src/pipeline/synthesis.ts @@ -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 = { + 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 + sourceNames: Map, + settings: GlobalSettings ): Promise { 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 { 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 }); diff --git a/backend/src/storage/db/index.ts b/backend/src/storage/db/index.ts index 0058dab..b4541c0 100644 --- a/backend/src/storage/db/index.ts +++ b/backend/src/storage/db/index.ts @@ -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 diff --git a/backend/src/storage/db/settings.ts b/backend/src/storage/db/settings.ts index 604422e..6b49290 100644 --- a/backend/src/storage/db/settings.ts +++ b/backend/src/storage/db/settings.ts @@ -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 { 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 { $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, diff --git a/backend/src/storage/db/types.ts b/backend/src/storage/db/types.ts index 66c1af9..3b7f69c 100644 --- a/backend/src/storage/db/types.ts +++ b/backend/src/storage/db/types.ts @@ -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; diff --git a/frontend/src/lib/adminTypes.ts b/frontend/src/lib/adminTypes.ts index 891a856..20b00ee 100644 --- a/frontend/src/lib/adminTypes.ts +++ b/frontend/src/lib/adminTypes.ts @@ -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; diff --git a/frontend/src/lib/components/admin/MergeTab.svelte b/frontend/src/lib/components/admin/MergeTab.svelte index 7439a51..137e32f 100644 --- a/frontend/src/lib/components/admin/MergeTab.svelte +++ b/frontend/src/lib/components/admin/MergeTab.svelte @@ -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 @@ +
+ Writing style +

+ Applies to AI-merged articles and event recaps only — a story with just one source + publishes with its original text untouched, no AI involved. +

+ + + +
+
Hold before publish

Wait window to gather more sources before finalizing a story.

@@ -328,6 +353,12 @@ select { width: 100%; } + textarea { + width: 100%; + margin-top: 6px; + font: inherit; + resize: vertical; + } .priority-list { display: flex; flex-direction: column;