From c837614ee387bff6479ee6f396ad296b9e8d029d Mon Sep 17 00:00:00 2001 From: Aditya A P Date: Sun, 21 Dec 2025 23:42:51 +0530 Subject: [PATCH 1/3] feat(prompts): add metadata preamble builder --- services/prompts/metadataPreamble.ts | 91 +++++++++++++++++++ .../services/prompts/metadataPreamble.test.ts | 68 ++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 services/prompts/metadataPreamble.ts create mode 100644 tests/services/prompts/metadataPreamble.test.ts diff --git a/services/prompts/metadataPreamble.ts b/services/prompts/metadataPreamble.ts new file mode 100644 index 0000000..71c4534 --- /dev/null +++ b/services/prompts/metadataPreamble.ts @@ -0,0 +1,91 @@ +import type { AppSettings } from '../../types'; + +export interface GlossaryEntry { + source: string; + target: string; + note?: string; +} + +export interface MetadataPreamble { + projectName?: string | null; + sourceLanguage?: string | null; + targetLanguage?: string | null; + styleNotes?: string | null; + footnotePolicy?: string | null; + chapterTitle?: string | null; + tags?: string[] | null; + glossary?: GlossaryEntry[] | null; +} + +const formatGlossary = (glossary: GlossaryEntry[]): string => { + if (!glossary.length) return '- Glossary: none'; + + const header = '| Source term | Translation | Notes |\n| --- | --- | --- |'; + const rows = glossary.map(entry => { + const source = entry.source || ''; + const target = entry.target || ''; + const note = entry.note || ''; + return `| ${source} | ${target} | ${note} |`; + }); + + return ['Glossary:', header, ...rows].join('\n'); +}; + +export const buildMetadataPreamble = (meta: MetadataPreamble): string => { + const { + projectName, + sourceLanguage, + targetLanguage, + styleNotes, + footnotePolicy, + chapterTitle, + tags, + glossary, + } = meta; + + const parts: string[] = []; + + parts.push('Session Context:'); + parts.push(`- Project: ${projectName || 'Unspecified'}`); + parts.push(`- Source → Target: ${sourceLanguage || 'Unknown'} → ${targetLanguage || 'Unknown'}`); + if (chapterTitle) { + parts.push(`- Chapter: ${chapterTitle}`); + } + if (tags && tags.length) { + parts.push(`- Tags: ${tags.join(', ')}`); + } + if (styleNotes) { + parts.push(`- Style cues: ${styleNotes}`); + } + if (footnotePolicy) { + parts.push(`- Footnote policy: ${footnotePolicy}`); + } + + if (glossary && glossary.length) { + parts.push(''); + parts.push(formatGlossary(glossary)); + } else { + parts.push(''); + parts.push('- Glossary: none'); + } + + return parts.join('\n'); +}; + +export const buildPreambleFromSettings = ( + settings: AppSettings, + overrides: Partial = {} +): string => { + const meta: MetadataPreamble = { + projectName: overrides.projectName ?? (settings as any)?.novelTitle ?? null, + sourceLanguage: overrides.sourceLanguage ?? (settings as any)?.sourceLanguage ?? null, + targetLanguage: overrides.targetLanguage ?? settings.targetLanguage ?? null, + styleNotes: overrides.styleNotes ?? null, + footnotePolicy: overrides.footnotePolicy ?? null, + chapterTitle: overrides.chapterTitle ?? null, + tags: overrides.tags ?? null, + glossary: overrides.glossary ?? null, + }; + + return buildMetadataPreamble(meta); +}; diff --git a/tests/services/prompts/metadataPreamble.test.ts b/tests/services/prompts/metadataPreamble.test.ts new file mode 100644 index 0000000..9718bc1 --- /dev/null +++ b/tests/services/prompts/metadataPreamble.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { buildMetadataPreamble, buildPreambleFromSettings } from '../../../services/prompts/metadataPreamble'; + +describe('metadataPreamble', () => { + it('builds a preamble with glossary table and context', () => { + const text = buildMetadataPreamble({ + projectName: 'Project X', + sourceLanguage: 'Korean', + targetLanguage: 'English', + chapterTitle: 'Ch. 1', + tags: ['fantasy', 'dark'], + glossary: [ + { source: '호감도', target: 'Affection', note: 'game stat' }, + { source: '악명', target: 'Notoriety' }, + ], + styleNotes: 'Preserve UI tone', + footnotePolicy: 'Cultural terms only', + }); + + expect(text).toContain('Project: Project X'); + expect(text).toContain('Korean → English'); + expect(text).toContain('Ch. 1'); + expect(text).toContain('fantasy, dark'); + expect(text).toContain('| Source term | Translation | Notes |'); + expect(text).toContain('| 호감도 | Affection | game stat |'); + expect(text).toContain('| 악명 | Notoriety | |'); + expect(text).toContain('Footnote policy: Cultural terms only'); + }); + + it('handles missing glossary with fallback', () => { + const text = buildMetadataPreamble({ + projectName: null, + sourceLanguage: null, + targetLanguage: null, + glossary: [], + }); + expect(text).toContain('Project: Unspecified'); + expect(text).toContain('Unknown → Unknown'); + expect(text).toContain('Glossary: none'); + }); + + it('builds from settings when provided', () => { + const text = buildPreambleFromSettings( + { + provider: 'Gemini', + model: 'gpt', + temperature: 0.7, + contextDepth: 2, + preloadCount: 0, + fontSize: 16, + fontStyle: 'serif', + lineHeight: 1.6, + systemPrompt: 'x', + imageModel: 'none', + showDiffHeatmap: false, + maxSessionSize: 10, + targetLanguage: 'English', + } as any, + { + sourceLanguage: 'Korean', + glossary: [{ source: '테스트', target: 'test' }], + } + ); + + expect(text).toContain('Korean → English'); + expect(text).toContain('| 테스트 | test |'); + }); +}); From 41463cf9e1d4764d0ff186a8d5eda30b35370b29 Mon Sep 17 00:00:00 2001 From: Aditya A P Date: Sun, 21 Dec 2025 23:42:56 +0530 Subject: [PATCH 2/3] feat(ai): inject metadata preamble --- services/ai/providers/gemini.ts | 4 +++- services/ai/providers/openai.ts | 4 +++- services/claudeService.ts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/services/ai/providers/gemini.ts b/services/ai/providers/gemini.ts index 0928383..7c82637 100644 --- a/services/ai/providers/gemini.ts +++ b/services/ai/providers/gemini.ts @@ -2,6 +2,7 @@ import { GoogleGenAI, GenerateContentResponse, Type } from '@google/genai'; import prompts from '@/config/prompts.json'; import appConfig from '@/config/app.json'; import { buildFanTranslationContext, formatHistory } from '@/services/prompts'; +import { buildPreambleFromSettings } from '@/services/prompts/metadataPreamble'; import { getEnvVar } from '@/services/env'; import type { AppSettings, HistoricalChapter, TranslationResult, UsageMetrics } from '@/types'; import { sanitizeHtml as sanitizeTranslationHTML } from '@/services/translate/HtmlSanitizer'; @@ -129,6 +130,7 @@ export const translateWithGemini = async ( (fanTranslation ? prompts.translateFanSuffix : '') + prompts.translateInstruction + prompts.translateTitleGuidance; + const preamble = buildPreambleFromSettings(settings); const fullPrompt = `${historyPrompt}\n\n${fanTranslationContext}\n\n-----\n\n${preface}\n\n${prompts.translateTitleLabel}\n${title}\n\n${prompts.translateContentLabel}\n${content}`; dlog('[Gemini Debug] Request summary:', { @@ -143,7 +145,7 @@ export const translateWithGemini = async ( const baseRequest = { model: settings.model, contents: [{ role: 'user', parts: [{ text: fullPrompt }] }], - systemInstruction: replacePlaceholders(settings.systemPrompt, settings), + systemInstruction: replacePlaceholders(`${settings.systemPrompt}\n\n${preamble}`, settings), generationConfig: { temperature: settings.temperature, responseMimeType: 'application/json', diff --git a/services/ai/providers/openai.ts b/services/ai/providers/openai.ts index 6ffbdf4..3a7c5df 100644 --- a/services/ai/providers/openai.ts +++ b/services/ai/providers/openai.ts @@ -7,6 +7,7 @@ import { getEnvVar } from '@/services/env'; import { rateLimitService } from '@/services/rateLimitService'; import { supportsStructuredOutputs, supportsParameters } from '@/services/capabilityService'; import { openrouterService } from '@/services/openrouterService'; +import { buildPreambleFromSettings } from '@/services/prompts/metadataPreamble'; import type { AppSettings, HistoricalChapter, TranslationResult, UsageMetrics } from '@/types'; import { getDefaultApiKey } from '@/services/defaultApiKeyService'; import { dlog, dlogFull, aiDebugEnabled } from '../debug'; @@ -199,7 +200,8 @@ export const translateWithOpenAI = async ( await rateLimitService.canMakeRequest(settings.model); const hasStructuredOutputs = await supportsStructuredOutputs(settings.provider, settings.model); - let systemPrompt = replacePlaceholders(settings.systemPrompt, settings); + const preamble = buildPreambleFromSettings(settings); + let systemPrompt = replacePlaceholders(`${settings.systemPrompt}\n\n${preamble}`, settings); const requestOptions: any = { model: settings.model }; const parameterSupport = await Promise.all([ diff --git a/services/claudeService.ts b/services/claudeService.ts index b83c934..812cc1d 100644 --- a/services/claudeService.ts +++ b/services/claudeService.ts @@ -4,6 +4,7 @@ import { AppSettings, HistoricalChapter, TranslationResult, UsageMetrics } from import prompts from '../config/prompts.json'; import { calculateCost } from './aiService'; import { buildFanTranslationContext, formatHistory } from './prompts'; +import { buildPreambleFromSettings } from './prompts/metadataPreamble'; import { getEnvVar } from './env'; // --- DEBUG UTILITIES --- @@ -51,7 +52,8 @@ export const translateWithClaude = async ( // Create comprehensive prompt with schema description const preface = prompts.translatePrefix + (effectiveFanTranslation ? prompts.translateFanSuffix : '') + prompts.translateInstruction + prompts.translateTitleGuidance; - const sys = (settings.systemPrompt || '') + const preamble = buildPreambleFromSettings(settings); + const sys = ((settings.systemPrompt || '') + '\n\n' + preamble) .replaceAll('{{targetLanguage}}', settings.targetLanguage || 'English') .replaceAll('{{targetLanguageVariant}}', settings.targetLanguage || 'English'); const fullPrompt = `${sys}\n\n${historyPrompt}\n\n${fanTranslationContext}\n\n-----\n\n${preface}\n\n${prompts.translateTitleLabel}\n${title}\n\n${prompts.translateContentLabel}\n${content} From 4a7bf003e95397dff883deda7b458697fc405716 Mon Sep 17 00:00:00 2001 From: Aditya A P Date: Sun, 21 Dec 2025 23:43:26 +0530 Subject: [PATCH 3/3] docs(worklog): note metadata preamble --- docs/WORKLOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index 570d001..b1ed5f7 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -1118,3 +1118,8 @@ Next: After running with reduced logs, gather traces for 'Chapter not found' and - Files: .gitignore; docs/WORKLOG.md - Why: Keep local Codex/Claude config and symlink artifacts out of `git status` and prevent accidental commits. - Details: Ignore `.claude/` and `CLAUDE.md`. + +2025-12-21 18:13 UTC - Prompts: metadata preamble plumbing +- Files: services/prompts/metadataPreamble.ts; tests/services/prompts/metadataPreamble.test.ts; services/ai/providers/{openai.ts,gemini.ts}; services/claudeService.ts; docs/WORKLOG.md +- Why: Centralize “session context” (project/languages/glossary) generation and inject it into provider prompts to reduce prompt drift across models. +- Tests: `npm test -- --run tests/services/prompts/metadataPreamble.test.ts tests/services/aiService.providers.test.ts`