From e01c12c2fe3f2064913a4a2d7c360f7907b77eb0 Mon Sep 17 00:00:00 2001 From: nammovuivui Date: Mon, 26 Oct 2026 16:40:29 +0700 Subject: [PATCH] feat(translate): translation instructions the model follows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Translation saves free text telling the model how to translate. It is appended to every LLM translator's prompt after the fixed rules, in a fence, with the fixed rules winning on conflict; with none saved the prompt is byte-identical to before. BabelDOC's custom_system_prompt was not used: it is read only on the do_llm_translate path, which none of our translators implements. - Both caches key on the instructions, appended only when set, so no existing key moved. - Only an LLM that will really run gets them (capabilities.translation_instructions, asked of the effective service); chat, verify and pre-warm never do. - /translate/estimate counts them per paragraph, and the toolbar's estimate refetches when they change. - The toolbar shows "Instructions on" when they will be sent. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 9 +- desktop/src/components/layout/ContextBar.tsx | 28 ++- .../src/components/settings/SettingsSheet.tsx | 10 +- .../components/settings/TranslationTab.tsx | 116 +++++++++ .../components/translation/TokenEstimate.tsx | 3 + desktop/src/hooks/useTranslationEstimate.ts | 13 +- desktop/src/lib/api-types.d.ts | 7 + desktop/src/lib/openapi.json | 28 ++- desktop/src/lib/store.ts | 9 + .../src/lib/translation-instructions.test.ts | 72 ++++++ desktop/src/lib/translation-instructions.ts | 58 +++++ docs/architecture/translators.md | 29 +++ .../api/routes/config.py | 2 + .../api/routes/translation.py | 6 + src/desktop_pdf_translator/api/schemas.py | 8 +- src/desktop_pdf_translator/config/models.py | 31 +++ .../processors/pdf_cache.py | 15 +- .../processors/processor.py | 14 +- .../translators/anthropic_translator.py | 10 +- .../translators/base.py | 22 ++ .../translators/capabilities.py | 18 +- .../translators/gemini_translator.py | 10 +- .../translators/openai_translator.py | 6 +- .../translators/translation_cache.py | 16 +- .../translators/usage_estimate.py | 27 ++- tests/test_config_api.py | 70 ++++++ tests/test_config_manager_load.py | 51 ++++ tests/test_pdf_cache_store.py | 69 ++++++ tests/test_translate_estimate_api.py | 51 +++- tests/test_translate_language_contract.py | 43 ++++ tests/test_translation_cache_store.py | 224 ++++++++++++++++++ tests/test_translation_prompts.py | 92 ++++++- tests/test_translator_factory.py | 17 ++ tests/test_usage_estimate.py | 43 +++- 34 files changed, 1193 insertions(+), 34 deletions(-) create mode 100644 desktop/src/components/settings/TranslationTab.tsx create mode 100644 desktop/src/lib/translation-instructions.test.ts create mode 100644 desktop/src/lib/translation-instructions.ts diff --git a/CLAUDE.md b/CLAUDE.md index f49c0f2..7e52f0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,8 @@ Non-obvious bits of the contract: per-job temp dir. - Keys, endpoints and a provider's models change only through `/providers`; `PUT /config` takes the models chosen (`translation_model`, `answer_model`) - and refuses the per-service blocks and `preferred_service` retired in #88. + and `translation_instructions` (omitted is unchanged, `null` or blank + clears), and refuses the per-service blocks and `preferred_service` retired in #88. A provider id is a plain string on the wire, checked against the registry. ## Invariants @@ -251,6 +252,12 @@ rule has, unless noted, a test. They are the things most easily undone by `providers.md`'s "Adding a provider" and fix the design, not the checklist, if it needs more. `default_base_url` is *sent* when no endpoint is saved (`registry.endpoint_for`), which is what lets OpenRouter reuse OpenAI's SDK. +- **Translation instructions go in our prompt, after the fixed rules** — + BabelDOC's `custom_system_prompt` is inert without `do_llm_translate`. They + are part of both cache keys (appended only when set), reach LLM translation + only (`capabilities.translation_instructions`, asked of the effective + service), and never chat. (The processor's wiring — one value to the + translator and both ends of the PDF cache — is checked by hand.) - Translation has **no page or file-size limit** (`max_pages` / `max_file_size_mb` are gone; a `config.toml` that still holds them is ignored and loses them on the next save). The Pages box only chooses *which* pages; the output is always diff --git a/desktop/src/components/layout/ContextBar.tsx b/desktop/src/components/layout/ContextBar.tsx index d892628..db19ad3 100644 --- a/desktop/src/components/layout/ContextBar.tsx +++ b/desktop/src/components/layout/ContextBar.tsx @@ -1,4 +1,13 @@ -import { ArrowRight, FilePlus, Loader2, Lock, MessageSquare, RefreshCw, Sparkles } from "lucide-react"; +import { + ArrowRight, + FilePlus, + Loader2, + Lock, + MessageSquare, + NotebookPen, + RefreshCw, + Sparkles, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -38,6 +47,7 @@ import { isSourceSupported, } from "@/lib/translate-request"; import { useAppStore } from "@/lib/store"; +import { instructionsApply } from "@/lib/translation-instructions"; import { cn } from "@/lib/utils"; /** Warm the backend the user just selected. The value is passed in rather than @@ -291,6 +301,22 @@ export function ContextBar({ /> )} + {/* Instructions live in Settings, out of sight of the button they change + the output of, so the toolbar says when they will be sent. */} + {config && providers && instructionsApply(config, providers) && ( + + + + + Instructions on + + + + {config.translation.instructions} + + + )} + {/* What an LLM run would cost, roughly. Argos costs nothing to call. */} {originalPath && running && running !== OFFLINE_ENGINE && ( diff --git a/desktop/src/components/settings/SettingsSheet.tsx b/desktop/src/components/settings/SettingsSheet.tsx index 4138406..282b567 100644 --- a/desktop/src/components/settings/SettingsSheet.tsx +++ b/desktop/src/components/settings/SettingsSheet.tsx @@ -4,6 +4,7 @@ import { Database, FileText, Loader2, RefreshCw, Sparkles, Trash2 } from "lucide import { ChatIndexTab } from "@/components/settings/ChatIndexTab"; import { ModelsTab } from "@/components/settings/ModelsTab"; +import { TranslationTab } from "@/components/settings/TranslationTab"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -45,7 +46,7 @@ import { useConfig, useUpdateConfig } from "@/hooks/useConfig"; import type { OpenedFrom } from "@/lib/provider-draft"; import { cn } from "@/lib/utils"; -type TabValue = "models" | "cache" | "chat"; +type TabValue = "models" | "translation" | "cache" | "chat"; interface SettingsSheetProps { open: boolean; @@ -84,8 +85,9 @@ export function SettingsSheet({
setTab(v as TabValue)}> - + Models + Translation Cache Chat @@ -94,6 +96,10 @@ export function SettingsSheet({ + + + +
diff --git a/desktop/src/components/settings/TranslationTab.tsx b/desktop/src/components/settings/TranslationTab.tsx new file mode 100644 index 0000000..48e7b5a --- /dev/null +++ b/desktop/src/components/settings/TranslationTab.tsx @@ -0,0 +1,116 @@ +import { Info, Languages } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { useConfig, useUpdateConfig } from "@/hooks/useConfig"; +import { useProviders } from "@/hooks/useProviders"; +import { useAppStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { + MAX_INSTRUCTIONS_CHARS, + instructionsDraft, + translatesWithLlm, +} from "@/lib/translation-instructions"; + +/** + * Settings → Translation: instructions added to every paragraph's prompt. + * Saved with a button rather than as the user types — each `PUT /config` + * rewrites `config.toml`, and a half-typed instruction is not one to run with. + */ +export function TranslationTab() { + const { data: config } = useConfig(); + const { data: providers } = useProviders(); + const update = useUpdateConfig(); + + // `null` until edited, so the box shows whatever is saved — including a + // value saved elsewhere while the sheet was closed — without an effect to + // copy it in. + const edited = useAppStore((s) => s.instructionsDraft); + const setEdited = useAppStore((s) => s.setInstructionsDraft); + const saved = config?.translation.instructions ?? null; + const text = edited ?? saved ?? ""; + const draft = instructionsDraft(text, saved); + const llm = config && providers ? translatesWithLlm(config, providers) : true; + + const save = (value: string | null) => + update.mutate( + { translation_instructions: value }, + { + onSuccess: () => { + // Safe to drop: the box is locked while a save is in flight, so + // nothing was typed after what was sent. + setEdited(null); + toast.success(value ? "Instructions saved" : "Instructions cleared"); + }, + onError: (e) => + toast.error("Could not save the instructions", { + description: (e as Error).message, + }), + }, + ); + + return ( +
+
+
+ + +
+

+ Tell the model how to translate: tone, terms to keep, how to render + names. Sent with every paragraph, so a long instruction costs tokens + on each one. Applies to translation only, not to chat. +

+