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
+ TranslationCacheChat
@@ -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.
+
+
+
+ );
+}
diff --git a/desktop/src/components/translation/TokenEstimate.tsx b/desktop/src/components/translation/TokenEstimate.tsx
index 6a7cc2e..2c961c3 100644
--- a/desktop/src/components/translation/TokenEstimate.tsx
+++ b/desktop/src/components/translation/TokenEstimate.tsx
@@ -5,6 +5,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { useConfig } from "@/hooks/useConfig";
import { useTranslationEstimate } from "@/hooks/useTranslationEstimate";
import { parsePageRanges } from "@/lib/page-range";
import { useAppStore } from "@/lib/store";
@@ -44,11 +45,13 @@ export function TokenEstimate({ filePath, targetLang }: TokenEstimateProps) {
return () => window.clearTimeout(timer);
}, [filePath, text]);
+ const { data: config } = useConfig();
const parsed = parsePageRanges(pages, pageCount);
const estimate = useTranslationEstimate({
filePath,
ranges: parsed.ok ? parsed.ranges : null,
targetLang,
+ instructions: config?.translation.instructions ?? null,
enabled: parsed.ok,
});
// `enabled: false` does not hide what is already cached, and an unreadable
diff --git a/desktop/src/hooks/useTranslationEstimate.ts b/desktop/src/hooks/useTranslationEstimate.ts
index 5f3861a..caabecd 100644
--- a/desktop/src/hooks/useTranslationEstimate.ts
+++ b/desktop/src/hooks/useTranslationEstimate.ts
@@ -12,6 +12,10 @@ interface EstimateInput {
/** The Pages box, parsed; `null` for every page. */
ranges: PageRange[] | null;
targetLang: string | null;
+ /** The saved translation instructions. Not sent — the sidecar reads them
+ * from config — but each paragraph's prompt carries them, so a new set is a
+ * new question; without it the count went stale for the session. */
+ instructions: string | null;
/** Only while an LLM will run: Argos costs nothing to call. */
enabled: boolean;
}
@@ -21,18 +25,19 @@ const FILE_PATH = 2;
/**
* Roughly how many tokens translating these pages would take (#33). The file,
- * pages and language are the whole question, so an answer is kept for the
- * session; while a new one loads, the previous one for *this document* stays
- * on screen.
+ * pages, language and instructions are the whole question, so an answer is
+ * kept for the session; while a new one loads, the previous one for *this
+ * document* stays on screen.
*/
export function useTranslationEstimate({
filePath,
ranges,
targetLang,
+ instructions,
enabled,
}: EstimateInput) {
return useQuery({
- queryKey: ["translate", "estimate", filePath, ranges, targetLang],
+ queryKey: ["translate", "estimate", filePath, ranges, targetLang, instructions],
queryFn: () => {
const body: EstimateRequest = {
file_path: filePath as string,
diff --git a/desktop/src/lib/api-types.d.ts b/desktop/src/lib/api-types.d.ts
index 0eb6a04..50eca59 100644
--- a/desktop/src/lib/api-types.d.ts
+++ b/desktop/src/lib/api-types.d.ts
@@ -945,6 +945,8 @@ export interface components {
default_target_lang?: components["schemas"]["LanguageCode"] | null;
/** Max Parallel Chunks */
max_parallel_chunks?: number | null;
+ /** Translation Instructions */
+ translation_instructions?: string | null;
translation_model?: components["schemas"]["ModelRef"] | null;
/** Whole Document Tokens */
whole_document_tokens?: number | null;
@@ -1668,6 +1670,11 @@ export interface components {
* @default vi
*/
default_target_lang: components["schemas"]["LanguageCode"];
+ /**
+ * Instructions
+ * @description Instructions for the model that translates
+ */
+ instructions: string | null;
/**
* Min Text Length
* @description Minimum text length to translate
diff --git a/desktop/src/lib/openapi.json b/desktop/src/lib/openapi.json
index d720855..acc1891 100644
--- a/desktop/src/lib/openapi.json
+++ b/desktop/src/lib/openapi.json
@@ -762,6 +762,18 @@
],
"title": "Max Parallel Chunks"
},
+ "translation_instructions": {
+ "anyOf": [
+ {
+ "maxLength": 2000,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Translation Instructions"
+ },
"translation_model": {
"anyOf": [
{
@@ -2368,6 +2380,19 @@
"default": "vi",
"description": "Default target language (Vietnamese priority)"
},
+ "instructions": {
+ "anyOf": [
+ {
+ "maxLength": 2000,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Instructions for the model that translates",
+ "title": "Instructions"
+ },
"min_text_length": {
"default": 5,
"description": "Minimum text length to translate",
@@ -2404,7 +2429,8 @@
"cache_translated_pdfs",
"pdf_cache_max_size_mb",
"preserve_formatting",
- "min_text_length"
+ "min_text_length",
+ "instructions"
],
"title": "TranslationConfig",
"type": "object"
diff --git a/desktop/src/lib/store.ts b/desktop/src/lib/store.ts
index c0f5b4c..bd9d469 100644
--- a/desktop/src/lib/store.ts
+++ b/desktop/src/lib/store.ts
@@ -36,6 +36,12 @@ interface AppState {
pageRangeText: string;
setPageRangeText: (text: string) => void;
+ /** Settings → Translation's instructions box while it differs from what is
+ * saved; `null` when untouched. Here rather than in the tab because the
+ * sheet unmounts a tab it leaves, and an unsaved draft went with it. */
+ instructionsDraft: string | null;
+ setInstructionsDraft: (draft: string | null) => void;
+
/** Dimensions of the original PDF's first page (CSS points, scale=1). Used
* to render a blank placeholder in the translated panel before a
* translation exists. */
@@ -161,6 +167,9 @@ export const useAppStore = create((set) => ({
pageRangeText: "",
setPageRangeText: (pageRangeText) => set({ pageRangeText }),
+ instructionsDraft: null,
+ setInstructionsDraft: (instructionsDraft) => set({ instructionsDraft }),
+
originalFirstPageSize: null,
setOriginalFirstPageSize: (size) => set({ originalFirstPageSize: size }),
diff --git a/desktop/src/lib/translation-instructions.test.ts b/desktop/src/lib/translation-instructions.test.ts
new file mode 100644
index 0000000..efca3eb
--- /dev/null
+++ b/desktop/src/lib/translation-instructions.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ MAX_INSTRUCTIONS_CHARS,
+ instructionsApply,
+ instructionsDraft,
+} from "./translation-instructions";
+
+describe("instructionsDraft", () => {
+ it("is unchanged when the draft matches what is saved", () => {
+ const draft = instructionsDraft("Keep index in English", "Keep index in English");
+ expect(draft.dirty).toBe(false);
+ });
+
+ it("sends the draft trimmed, as the sidecar would store it", () => {
+ const draft = instructionsDraft(" Keep index in English \n", null);
+ expect(draft.value).toBe("Keep index in English");
+ expect(draft.dirty).toBe(true);
+ });
+
+ // The sidecar trims too, so only surrounding whitespace never makes a
+ // difference worth a Save.
+ it("ignores whitespace around a saved value", () => {
+ expect(instructionsDraft("Formal tone ", "Formal tone").dirty).toBe(false);
+ });
+
+ it("sends null for an empty or blank draft, which clears the saved one", () => {
+ expect(instructionsDraft(" ", "Formal tone")).toMatchObject({ value: null, dirty: true });
+ expect(instructionsDraft("", null)).toMatchObject({ value: null, dirty: false });
+ });
+
+ it("treats a config without the field as nothing saved", () => {
+ expect(instructionsDraft("", undefined).dirty).toBe(false);
+ });
+
+ it("is too long one character past the limit, counted after trimming", () => {
+ const atLimit = "a".repeat(MAX_INSTRUCTIONS_CHARS);
+ expect(instructionsDraft(` ${atLimit} `, null).tooLong).toBe(false);
+ expect(instructionsDraft(`${atLimit}a`, null).tooLong).toBe(true);
+ });
+
+ it("matches the sidecar's limit", () => {
+ expect(MAX_INSTRUCTIONS_CHARS).toBe(2000);
+ });
+});
+
+describe("instructionsApply", () => {
+ const llm = { id: "gemini", is_llm: true, requires_key: true, has_key: true };
+ const argos = { id: "argos", is_llm: false, requires_key: false, has_key: false };
+ const config = (provider: string, instructions: string | null) => ({
+ translation: { model: { provider, model: "m" }, instructions },
+ });
+
+ it("applies when instructions are saved and an LLM translates", () => {
+ expect(instructionsApply(config("gemini", "Keep index"), [llm, argos])).toBe(true);
+ });
+
+ it("does not apply with nothing saved", () => {
+ expect(instructionsApply(config("gemini", null), [llm, argos])).toBe(false);
+ });
+
+ it("does not apply when Argos translates", () => {
+ expect(instructionsApply(config("argos", "Keep index"), [llm, argos])).toBe(false);
+ });
+
+ // The sidecar runs Argos in place of an LLM with no key, so the toolbar
+ // must not claim instructions that will never be sent.
+ it("does not apply to an LLM with no key, which runs as Argos", () => {
+ const keyless = { ...llm, has_key: false };
+ expect(instructionsApply(config("gemini", "Keep index"), [keyless, argos])).toBe(false);
+ });
+});
diff --git a/desktop/src/lib/translation-instructions.ts b/desktop/src/lib/translation-instructions.ts
new file mode 100644
index 0000000..b73a924
--- /dev/null
+++ b/desktop/src/lib/translation-instructions.ts
@@ -0,0 +1,58 @@
+/**
+ * Settings → Translation's instructions box, and whether they will reach the
+ * model. Pure so the suite's `node` environment can test it.
+ */
+
+import type { ConfigResponse } from "@/hooks/useConfig";
+import type { ProviderInfo } from "@/hooks/useProviders";
+import { effectiveService } from "@/lib/translate-request";
+
+/** `MAX_TRANSLATION_INSTRUCTIONS_CHARS` in `config/models.py`: the instruction
+ * rides on every paragraph's prompt, so its length is paid once per paragraph. */
+export const MAX_INSTRUCTIONS_CHARS = 2000;
+
+export interface InstructionsDraft {
+ /** What `PUT /config` is sent: trimmed, `null` to clear. */
+ value: string | null;
+ dirty: boolean;
+ tooLong: boolean;
+}
+
+/** The sidecar trims and stores a blank instruction as none, so the draft is
+ * compared in that form — whitespace alone is never an unsaved change. */
+export function instructionsDraft(
+ draft: string,
+ saved: string | null | undefined,
+): InstructionsDraft {
+ const trimmed = draft.trim();
+ const value = trimmed === "" ? null : trimmed;
+ return {
+ value,
+ dirty: value !== (saved ?? null),
+ tooLong: trimmed.length > MAX_INSTRUCTIONS_CHARS,
+ };
+}
+
+type ApplyConfig = {
+ translation: Pick;
+};
+type ApplyProvider = Pick;
+
+/** Whether the service that will really run prompts a model. An LLM with no
+ * key runs as Argos, which takes no instructions. */
+export function translatesWithLlm(
+ config: { translation: Pick },
+ providers: readonly ApplyProvider[],
+): boolean {
+ const running = effectiveService(config, providers);
+ return providers.find((p) => p.id === running)?.is_llm ?? false;
+}
+
+/** Whether a translation started now would send the instructions. Mirrors
+ * `capabilities.translation_instructions`. */
+export function instructionsApply(
+ config: ApplyConfig,
+ providers: readonly ApplyProvider[],
+): boolean {
+ return !!config.translation.instructions && translatesWithLlm(config, providers);
+}
diff --git a/docs/architecture/translators.md b/docs/architecture/translators.md
index 9821a1e..85df1a1 100644
--- a/docs/architecture/translators.md
+++ b/docs/architecture/translators.md
@@ -55,6 +55,35 @@ notice; and a disabled Radix `SelectItem` sets `pointer-events: none`, so the
Broadening Argos beyond en→vi means shipping more language packs, not editing
its spec's `supported_pairs` alone.
+## Translation instructions
+
+Settings → Translation saves free text, `translation.instructions` (at most
+2,000 characters, trimmed, blank stored as `None`), telling the model how to
+translate: register, terms to keep in English, how to render names.
+
+- **They go in our prompt, not BabelDOC's.** `TranslationConfig.custom_system_prompt`
+ looks like the hook, but BabelDOC reads it only on the `do_llm_translate`
+ path, which none of our translators has (see the plug-in interface below),
+ so it would be inert. `BaseTranslator._user_instructions_block` appends them
+ to `_translation_instructions`, after every fixed rule, inside a
+ `` fence, and says the fixed rules win where they
+ conflict: the target language, the translation alone, the placeholders. With
+ none saved the prompt is byte-for-byte what it was before.
+- **Both caches key on them.** `translation_cache._make_cache_key` and
+ `pdf_cache._make_cache_key` append `|instructions=` only when there are
+ some, the way `pages=` already works, so no existing key moved and no
+ migration was needed. Without this a changed instruction was answered from
+ the old one's translations.
+- **Translation only, LLM only.** `capabilities.translation_instructions(settings,
+ service)` returns them for an `is_llm` provider and `None` for Argos; the
+ processor asks it once, of the *effective* service, and gives the answer to
+ the translator and to both ends of the PDF cache. Chat, verify and pre-warm
+ build translators without them.
+- **They are paid per paragraph.** `usage_estimate.prompt_tokens(target,
+ instructions)` adds them and their wrapper to every paragraph's prompt, so
+ `/translate/estimate` counts them; `test_usage_estimate.py` holds the
+ estimate to the real prompt with a 300-character instruction.
+
## Translator failures are counted, not swallowed
`BaseTranslator._handle_translation_error` is the funnel every backend's
diff --git a/src/desktop_pdf_translator/api/routes/config.py b/src/desktop_pdf_translator/api/routes/config.py
index c34d5a4..1839fa7 100644
--- a/src/desktop_pdf_translator/api/routes/config.py
+++ b/src/desktop_pdf_translator/api/routes/config.py
@@ -72,6 +72,8 @@ async def update_config(payload: ConfigUpdateRequest) -> ConfigResponse:
current["translation"]["default_source_lang"] = payload.default_source_lang.value
if payload.default_target_lang is not None:
current["translation"]["default_target_lang"] = payload.default_target_lang.value
+ if "translation_instructions" in payload.model_fields_set:
+ current["translation"]["instructions"] = payload.translation_instructions
if payload.chat_enabled is not None:
current["rag"]["chat_enabled"] = payload.chat_enabled
if payload.whole_document_tokens is not None:
diff --git a/src/desktop_pdf_translator/api/routes/translation.py b/src/desktop_pdf_translator/api/routes/translation.py
index e06dc51..e078335 100644
--- a/src/desktop_pdf_translator/api/routes/translation.py
+++ b/src/desktop_pdf_translator/api/routes/translation.py
@@ -24,6 +24,7 @@
from ...translators.capabilities import (
resolve_effective_service,
resolve_languages,
+ translation_instructions,
unsupported_reason,
)
from ...translators.usage_estimate import estimate_tokens
@@ -237,6 +238,11 @@ def _measure(payload: EstimateRequest, settings) -> TranslationEstimate:
cjk_chars=stats.cjk_chars,
other_chars=stats.other_chars,
target_lang=target_lang.value,
+ # The configured model's, not the effective one's: the toolbar shows
+ # an estimate only for an LLM that can run.
+ instructions=translation_instructions(
+ settings, settings.translation.model.provider
+ ),
)
return TranslationEstimate(
page_count=info.page_count,
diff --git a/src/desktop_pdf_translator/api/schemas.py b/src/desktop_pdf_translator/api/schemas.py
index cc05cde..2a59848 100644
--- a/src/desktop_pdf_translator/api/schemas.py
+++ b/src/desktop_pdf_translator/api/schemas.py
@@ -15,7 +15,11 @@
RAGSettings,
TranslationSettings,
)
-from ..config.models import WholeDocumentTokens, normalize_base_url
+from ..config.models import (
+ TranslationInstructions,
+ WholeDocumentTokens,
+ normalize_base_url,
+)
# ---------------------------------------------------------------------------
@@ -81,6 +85,8 @@ class ConfigUpdateRequest(BaseModel):
answer_model: Optional[ModelRef] = None
default_source_lang: Optional[LanguageCode] = None
default_target_lang: Optional[LanguageCode] = None
+ # Left out: unchanged. `null` or blank: cleared.
+ translation_instructions: TranslationInstructions = None
chat_enabled: Optional[bool] = None
whole_document_tokens: Optional[WholeDocumentTokens] = None
# Performance / cache toggles
diff --git a/src/desktop_pdf_translator/config/models.py b/src/desktop_pdf_translator/config/models.py
index ec04a12..825af7f 100644
--- a/src/desktop_pdf_translator/config/models.py
+++ b/src/desktop_pdf_translator/config/models.py
@@ -12,6 +12,7 @@
ConfigDict,
Field,
AfterValidator,
+ BeforeValidator,
ValidationInfo,
field_validator,
model_validator,
@@ -226,6 +227,29 @@ def _providers_default() -> Dict[str, "ProviderSettings"]:
WholeDocumentTokens = Annotated[int, Field(ge=0, le=WHOLE_DOCUMENT_MAX_TOKENS)]
+# The instructions ride in every paragraph's prompt, so their length is paid
+# once per paragraph: 2,000 characters is ~500 tokens on a ~190-token prompt,
+# room for a page of style and terminology rules without tripling the bill.
+MAX_TRANSLATION_INSTRUCTIONS_CHARS = 2000
+
+
+def _blank_is_unset(value):
+ # One spelling of "none", so the cache keys built from it can't tell a
+ # cleared box from a never-used one. Trimmed before the length check, so
+ # `PUT /config`, `config.toml` and the box all measure the same text.
+ if isinstance(value, str):
+ return value.strip() or None
+ return value
+
+
+# The bound sits on the `str`: on the `Optional`, validating an assignment of
+# `None` tried to measure it and failed the whole `update_settings`.
+TranslationInstructions = Annotated[
+ Optional[Annotated[str, Field(max_length=MAX_TRANSLATION_INSTRUCTIONS_CHARS)]],
+ BeforeValidator(_blank_is_unset),
+]
+
+
class TranslationSettings(BaseModel):
"""Translation-specific settings."""
@@ -259,6 +283,13 @@ class TranslationSettings(BaseModel):
)
preserve_formatting: bool = Field(True, description="Preserve PDF formatting")
min_text_length: int = Field(5, ge=0, description="Minimum text length to translate")
+ # How the user wants the document translated, added to the end of every
+ # LLM translator's prompt (`BaseTranslator._translation_instructions`).
+ # Argos takes none (`capabilities.translation_instructions`). Over the
+ # limit in a hand-edited file, the load path drops this field alone.
+ instructions: TranslationInstructions = Field(
+ None, description="Instructions for the model that translates"
+ )
class GUISettings(BaseModel):
"""GUI-specific settings.
diff --git a/src/desktop_pdf_translator/processors/pdf_cache.py b/src/desktop_pdf_translator/processors/pdf_cache.py
index 9dd7ca7..e9d8cc7 100644
--- a/src/desktop_pdf_translator/processors/pdf_cache.py
+++ b/src/desktop_pdf_translator/processors/pdf_cache.py
@@ -6,7 +6,8 @@
Cache key = sha256(file_hash | lang_in | lang_out | service | model | pipeline_version)
where file_hash is sha256 of the input PDF bytes. A translation of some of the
-document's pages adds `| pages=`. A hit lets `process_pdf`
+document's pages adds `| pages=`, and translation instructions
+add `| instructions=`. A hit lets `process_pdf`
skip the entire BabelDOC pipeline (layout / typeset / render / save) by
copying the cached PDF into the live output directory and emitting synthetic
SSE events.
@@ -121,6 +122,7 @@ def _make_cache_key(
model: Optional[str],
pipeline_version: str,
pages: Optional[str] = None,
+ instructions: Optional[str] = None,
) -> str:
# Source language IS part of the key. It used to be excluded, on the
# premise that output bytes are independent of what the user picked as
@@ -142,6 +144,11 @@ def _make_cache_key(
# partial translation can never be served as the whole document's.
if pages is not None:
payload += f"|pages={pages}"
+ # The user's translation instructions, the same way: only when there are
+ # some, so no existing key moves, and a changed instruction is a miss
+ # rather than the old one's PDF.
+ if instructions:
+ payload += f"|instructions={instructions}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@@ -330,6 +337,7 @@ def lookup(
model: Optional[str],
file_hash: Optional[str] = None,
pages: Optional[str] = None,
+ instructions: Optional[str] = None,
) -> Optional[CacheHit]:
"""Return a CacheHit or None. Bumps hit_count + last_used on hit.
@@ -359,7 +367,7 @@ def lookup(
for selection in candidates:
key = _make_cache_key(
file_hash, source_lang, target_lang, service, model,
- PIPELINE_VERSION, selection,
+ PIPELINE_VERSION, selection, instructions,
)
hit = self._hit(conn, key, selection)
if hit is not None:
@@ -430,6 +438,7 @@ def store(
model: Optional[str],
file_hash: Optional[str] = None,
pages: Optional[str] = None,
+ instructions: Optional[str] = None,
) -> Optional[Path]:
"""Copy `translated_path` into the cache; insert/replace the row.
Returns the cached file path on success, None on failure (non-fatal).
@@ -454,7 +463,7 @@ def store(
key = _make_cache_key(
file_hash, source_lang, target_lang, service, model, PIPELINE_VERSION,
- pages,
+ pages, instructions,
)
dest = self.files_dir / f"{key}.pdf"
try:
diff --git a/src/desktop_pdf_translator/processors/processor.py b/src/desktop_pdf_translator/processors/processor.py
index 03beba0..70408de 100644
--- a/src/desktop_pdf_translator/processors/processor.py
+++ b/src/desktop_pdf_translator/processors/processor.py
@@ -23,7 +23,11 @@
from ..translators.factory import TranslatorFactory
from ..translators.argos_translator import ArgosTranslator
from ..translators.base import describe_fatal_error
-from ..translators.capabilities import resolve_effective_service, resolve_languages
+from ..translators.capabilities import (
+ resolve_effective_service,
+ resolve_languages,
+ translation_instructions,
+)
from ..utils.memory import release_freed_memory
from .events import (
ProcessingEvent,
@@ -280,6 +284,11 @@ async def process_pdf(
requested_service = translation_service
translation_service = self._resolve_effective_service(translation_service)
fell_back = translation_service != requested_service
+ # Asked once, of the service that will really run, and handed to
+ # the translator and both ends of the PDF cache alike: a cache
+ # keyed on other instructions than the prompt used would serve the
+ # wrong translation.
+ instructions = translation_instructions(self.settings, translation_service)
# Log the actual languages being used
logger.info(f"Using languages - Source: {source_lang}, Target: {target_lang}")
@@ -408,6 +417,7 @@ async def process_pdf(
model_id,
input_file_hash,
pages=selection_key,
+ instructions=instructions,
)
if hit is not None:
# Use the highest-int name in the rolling-version family
@@ -522,6 +532,7 @@ async def process_pdf(
on_paragraph_translated=self._handle_paragraph,
on_translation_failed=self._handle_translation_failure,
cancel_event=self._cancel_event,
+ instructions=instructions,
)
yield ProgressEvent(
@@ -598,6 +609,7 @@ async def process_pdf(
self._resolve_model_id(translation_service),
input_file_hash,
selection_key,
+ instructions,
)
)
_pending_cache_writes.add(store_task)
diff --git a/src/desktop_pdf_translator/translators/anthropic_translator.py b/src/desktop_pdf_translator/translators/anthropic_translator.py
index d7bcc29..35343c7 100644
--- a/src/desktop_pdf_translator/translators/anthropic_translator.py
+++ b/src/desktop_pdf_translator/translators/anthropic_translator.py
@@ -103,7 +103,10 @@ def translate(self, text: str, **kwargs) -> str:
if not processed_text.strip():
return text
- cached = _llm_cache_get(processed_text, self.lang_in, self.lang_out, "anthropic", self.model)
+ cached = _llm_cache_get(
+ processed_text, self.lang_in, self.lang_out, "anthropic", self.model,
+ instructions=self.instructions,
+ )
if cached is not None:
self._fire_paragraph_callback(processed_text, cached)
return cached
@@ -144,7 +147,10 @@ def translate(self, text: str, **kwargs) -> str:
)
result = self._postprocess_text(translated_text)
- _llm_cache_set(processed_text, result, self.lang_in, self.lang_out, "anthropic", self.model)
+ _llm_cache_set(
+ processed_text, result, self.lang_in, self.lang_out, "anthropic", self.model,
+ instructions=self.instructions,
+ )
self._fire_paragraph_callback(processed_text, result)
return result
diff --git a/src/desktop_pdf_translator/translators/base.py b/src/desktop_pdf_translator/translators/base.py
index cc20e21..381fed9 100644
--- a/src/desktop_pdf_translator/translators/base.py
+++ b/src/desktop_pdf_translator/translators/base.py
@@ -295,6 +295,11 @@ def __init__(self, lang_in: str, lang_out: str, **kwargs):
max_qps: Optional[float]
Overrides the shared rate limiter's default for this
backend's service. None keeps the built-in default.
+ instructions: Optional[str]
+ The user's translation instructions, added after the
+ fixed rules of every paragraph's prompt and part of its
+ paragraph-cache key. Given only for translation, never
+ for chat (`capabilities.translation_instructions`).
provider_id: Optional[str]
The registry provider this instance serves, which names
its rate limiter. One class serves several providers —
@@ -328,6 +333,7 @@ def __init__(self, lang_in: str, lang_out: str, **kwargs):
"cancel_event", None
)
self.max_qps: Optional[float] = kwargs.pop("max_qps", None)
+ self.instructions: Optional[str] = kwargs.pop("instructions", None)
provider_id: Optional[str] = kwargs.pop("provider_id", None)
if provider_id is not None and self._SERVICE_NAME is not None:
self._SERVICE_NAME = provider_id
@@ -590,6 +596,22 @@ def _translation_instructions(self) -> str:
f"- The text may contain placeholders: {formula} stands for a formula, "
f"and {styled} marks styled text. Copy every placeholder through "
f"unchanged, keeping the words a {styled} pair wraps inside that pair."
+ ) + self._user_instructions_block()
+
+ def _user_instructions_block(self) -> str:
+ """The user's instructions, after every fixed rule and fenced, so the
+ prompt can say which wins: a user asking for notes, or another
+ language, would otherwise print them into the PDF. Empty without any,
+ leaving the prompt exactly as it was before instructions existed."""
+ if not self.instructions:
+ return ""
+ target_lang = LANGUAGE_DISPLAY_NAMES.get(self.lang_out, self.lang_out)
+ return (
+ "\n\nThe user gave these instructions for translating this document. "
+ "Follow them, except where they conflict with the rules above: the "
+ f"translation is always in {target_lang}, the reply is the translation "
+ "alone, and every placeholder is copied through.\n"
+ f"\n{self.instructions}\n"
)
def get_formular_placeholder(self, placeholder_id: int) -> tuple[str, str]:
diff --git a/src/desktop_pdf_translator/translators/capabilities.py b/src/desktop_pdf_translator/translators/capabilities.py
index 6b1a0fb..29070d1 100644
--- a/src/desktop_pdf_translator/translators/capabilities.py
+++ b/src/desktop_pdf_translator/translators/capabilities.py
@@ -24,7 +24,7 @@
from typing import Dict, FrozenSet, List, Optional, Set, Tuple, Union
from ..config import LanguageCode, TranslationService
-from ..providers.registry import PROVIDERS
+from ..providers.registry import PROVIDERS, provider
logger = logging.getLogger(__name__)
@@ -176,3 +176,19 @@ def resolve_effective_service(
return requested
logger.info("No API key for %s — falling back to Argos for this run", requested)
return TranslationService.ARGOS
+
+
+def translation_instructions(
+ settings, service: TranslationService
+) -> Optional[str]:
+ """The user's translation instructions, for the service that will run.
+
+ Pass the *effective* service (`resolve_effective_service`): an LLM with no
+ key runs as Argos. Argos has no prompt to carry them, and handing them on
+ anyway would key its caches on text that changes nothing. The processor
+ asks once and gives the answer to the translator and to both ends of the
+ PDF cache, so the three can never disagree.
+ """
+ if not provider(TranslationService(service).value).is_llm:
+ return None
+ return settings.translation.instructions
diff --git a/src/desktop_pdf_translator/translators/gemini_translator.py b/src/desktop_pdf_translator/translators/gemini_translator.py
index f53e24d..3dae316 100644
--- a/src/desktop_pdf_translator/translators/gemini_translator.py
+++ b/src/desktop_pdf_translator/translators/gemini_translator.py
@@ -88,7 +88,10 @@ def translate(self, text: str, **kwargs) -> str:
if not processed_text.strip():
return text
- cached = _llm_cache_get(processed_text, self.lang_in, self.lang_out, "gemini", self.model_name)
+ cached = _llm_cache_get(
+ processed_text, self.lang_in, self.lang_out, "gemini", self.model_name,
+ instructions=self.instructions,
+ )
if cached is not None:
self._fire_paragraph_callback(processed_text, cached)
return cached
@@ -126,7 +129,10 @@ def translate(self, text: str, **kwargs) -> str:
)
result = self._postprocess_text(translated_text)
- _llm_cache_set(processed_text, result, self.lang_in, self.lang_out, "gemini", self.model_name)
+ _llm_cache_set(
+ processed_text, result, self.lang_in, self.lang_out, "gemini", self.model_name,
+ instructions=self.instructions,
+ )
self._fire_paragraph_callback(processed_text, result)
return result
diff --git a/src/desktop_pdf_translator/translators/openai_translator.py b/src/desktop_pdf_translator/translators/openai_translator.py
index f7f7674..af9a090 100644
--- a/src/desktop_pdf_translator/translators/openai_translator.py
+++ b/src/desktop_pdf_translator/translators/openai_translator.py
@@ -76,7 +76,8 @@ def translate(self, text: str, **kwargs) -> str:
return text
cached = _llm_cache_get(
- processed_text, self.lang_in, self.lang_out, self._SERVICE_NAME, self.model
+ processed_text, self.lang_in, self.lang_out, self._SERVICE_NAME, self.model,
+ instructions=self.instructions,
)
if cached is not None:
self._fire_paragraph_callback(processed_text, cached)
@@ -116,7 +117,8 @@ def translate(self, text: str, **kwargs) -> str:
# paragraph is — the same name behind two cards would serve each
# other's text.
_llm_cache_set(
- processed_text, result, self.lang_in, self.lang_out, self._SERVICE_NAME, self.model
+ processed_text, result, self.lang_in, self.lang_out, self._SERVICE_NAME, self.model,
+ instructions=self.instructions,
)
self._fire_paragraph_callback(processed_text, result)
return result
diff --git a/src/desktop_pdf_translator/translators/translation_cache.py b/src/desktop_pdf_translator/translators/translation_cache.py
index 6dd2982..5e96f7a 100644
--- a/src/desktop_pdf_translator/translators/translation_cache.py
+++ b/src/desktop_pdf_translator/translators/translation_cache.py
@@ -39,8 +39,14 @@ def _make_cache_key(
lang_out: str,
service: str,
model: Optional[str],
+ instructions: Optional[str] = None,
) -> str:
payload = f"{lang_in}|{lang_out}|{service}|{model or ''}|{source_text}"
+ # Appended only when there are some, so every entry stored before they
+ # existed still matches a run without any. Without them in the key, a
+ # changed instruction would be answered from the old one's translations.
+ if instructions:
+ payload += f"|instructions={instructions}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@@ -152,9 +158,10 @@ def get(
lang_out: str,
service: str,
model: Optional[str] = None,
+ instructions: Optional[str] = None,
) -> Optional[str]:
"""Return cached translation or None. Records hit/miss stats."""
- key = _make_cache_key(source_text, lang_in, lang_out, service, model)
+ key = _make_cache_key(source_text, lang_in, lang_out, service, model, instructions)
try:
conn = self._conn()
row = conn.execute(
@@ -196,11 +203,12 @@ def set(
lang_out: str,
service: str,
model: Optional[str] = None,
+ instructions: Optional[str] = None,
) -> bool:
"""Insert or replace a cache entry. Returns True on success."""
if not translated_text:
return False
- key = _make_cache_key(source_text, lang_in, lang_out, service, model)
+ key = _make_cache_key(source_text, lang_in, lang_out, service, model, instructions)
now = now_ms()
expires_at = now + self.ttl_days * 86_400_000
try:
@@ -363,6 +371,7 @@ def llm_cache_get(
lang_out: str,
service: str,
model: Optional[str],
+ instructions: Optional[str] = None,
) -> Optional[str]:
"""Cache lookup wrapper for LLM translators. Silently bypasses when caching
is disabled in settings, so translator code stays clean."""
@@ -375,6 +384,7 @@ def llm_cache_get(
lang_out=lang_out,
service=service,
model=model,
+ instructions=instructions,
)
except Exception as e:
logger.warning("llm_cache_get failed: %s", e)
@@ -388,6 +398,7 @@ def llm_cache_set(
lang_out: str,
service: str,
model: Optional[str],
+ instructions: Optional[str] = None,
) -> None:
if not _cache_enabled():
return
@@ -399,6 +410,7 @@ def llm_cache_set(
lang_out=lang_out,
service=service,
model=model,
+ instructions=instructions,
)
except Exception as e:
logger.warning("llm_cache_set failed: %s", e)
diff --git a/src/desktop_pdf_translator/translators/usage_estimate.py b/src/desktop_pdf_translator/translators/usage_estimate.py
index 09d14f1..046dd12 100644
--- a/src/desktop_pdf_translator/translators/usage_estimate.py
+++ b/src/desktop_pdf_translator/translators/usage_estimate.py
@@ -45,8 +45,19 @@ def text_tokens(cjk_chars: int, other_chars: int) -> int:
return cjk_chars + math.ceil(other_chars / CHARS_PER_TOKEN)
-def prompt_tokens(target_lang: str) -> int:
- return PROMPT_TOKENS_VIETNAMESE if target_lang == "vi" else PROMPT_TOKENS_OTHER
+# The lead-in and fence `BaseTranslator._user_instructions_block` puts around
+# the user's instructions, at CHARS_PER_TOKEN: 300 characters as measured for
+# Vietnamese, the target it names in full.
+INSTRUCTIONS_WRAPPER_TOKENS = 75
+
+
+def prompt_tokens(target_lang: str, instructions: str | None = None) -> int:
+ fixed = PROMPT_TOKENS_VIETNAMESE if target_lang == "vi" else PROMPT_TOKENS_OTHER
+ if not instructions:
+ return fixed
+ return fixed + INSTRUCTIONS_WRAPPER_TOKENS + text_tokens(
+ count_cjk(instructions), len(instructions) - count_cjk(instructions)
+ )
@dataclass(frozen=True)
@@ -56,11 +67,17 @@ class TokenEstimate:
def estimate_tokens(
- *, paragraphs: int, cjk_chars: int, other_chars: int, target_lang: str
+ *,
+ paragraphs: int,
+ cjk_chars: int,
+ other_chars: int,
+ target_lang: str,
+ instructions: str | None = None,
) -> TokenEstimate:
- """Tokens sent (text plus one prompt per paragraph) and received."""
+ """Tokens sent (text plus one prompt per paragraph, the user's
+ instructions included) and received."""
text = text_tokens(cjk_chars, other_chars)
return TokenEstimate(
- input_tokens=text + paragraphs * prompt_tokens(target_lang),
+ input_tokens=text + paragraphs * prompt_tokens(target_lang, instructions),
output_tokens=math.ceil(text * OUTPUT_RATIO),
)
diff --git a/tests/test_config_api.py b/tests/test_config_api.py
index 357a015..56a6618 100644
--- a/tests/test_config_api.py
+++ b/tests/test_config_api.py
@@ -241,6 +241,76 @@ def test_a_zero_whole_document_budget_means_always_retrieve(client: TestClient):
assert put(client, {"whole_document_tokens": 0}).json()["rag"]["whole_document_tokens"] == 0
+# ---------------------------------------------------------------------------
+# translation instructions
+# ---------------------------------------------------------------------------
+
+
+def test_translation_instructions_are_unset_until_saved(client: TestClient):
+ assert client.get("/config", headers=AUTH).json()["translation"]["instructions"] is None
+
+
+def test_saved_translation_instructions_are_trimmed_returned_and_persisted(
+ client: TestClient, manager: ConfigManager
+):
+ response = put(client, {"translation_instructions": " Keep index in English\n"})
+
+ assert response.status_code == 200
+ assert response.json()["translation"]["instructions"] == "Keep index in English"
+ assert (
+ client.get("/config", headers=AUTH).json()["translation"]["instructions"]
+ == "Keep index in English"
+ )
+ reloaded = ConfigManager(config_dir=manager.config_dir).load_settings()
+ assert reloaded.translation.instructions == "Keep index in English"
+
+
+def test_a_save_that_leaves_translation_instructions_out_keeps_them(client: TestClient):
+ assert put(client, {"translation_instructions": "Keep index in English"}).status_code == 200
+
+ response = put(client, {"chat_enabled": False})
+
+ assert response.json()["translation"]["instructions"] == "Keep index in English"
+
+
+@pytest.mark.parametrize("cleared", [None, "", " "])
+def test_null_or_blank_translation_instructions_clear_them(client: TestClient, cleared):
+ """`null` has to mean "clear" even though a left-out field is also None
+ inside the model; the two are told apart by what the body carried."""
+ assert put(client, {"translation_instructions": "Keep index in English"}).status_code == 200
+
+ response = put(client, {"translation_instructions": cleared})
+
+ assert response.status_code == 200
+ assert response.json()["translation"]["instructions"] is None
+ assert client.get("/config", headers=AUTH).json()["translation"]["instructions"] is None
+
+
+def test_translation_instructions_may_be_exactly_2000_characters(client: TestClient):
+ response = put(client, {"translation_instructions": "z" * 2000})
+
+ assert response.status_code == 200
+ assert response.json()["translation"]["instructions"] == "z" * 2000
+
+
+def test_translation_instructions_are_measured_after_trimming(client: TestClient):
+ """The box counts the trimmed text and `config.toml` stores it, so a
+ trailing newline must not turn a full-length instruction into a 422."""
+ response = put(client, {"translation_instructions": "z" * 2000 + "\n"})
+
+ assert response.status_code == 200
+ assert response.json()["translation"]["instructions"] == "z" * 2000
+
+
+def test_translation_instructions_over_2000_characters_are_refused(
+ client: TestClient, manager: ConfigManager
+):
+ assert put(client, {"translation_instructions": "kept"}).status_code == 200
+
+ assert put(client, {"translation_instructions": "z" * 2001}).status_code == 422
+ assert manager.settings.translation.instructions == "kept"
+
+
# ---------------------------------------------------------------------------
# no translation limits
# ---------------------------------------------------------------------------
diff --git a/tests/test_config_manager_load.py b/tests/test_config_manager_load.py
index beb096b..c67639d 100644
--- a/tests/test_config_manager_load.py
+++ b/tests/test_config_manager_load.py
@@ -350,6 +350,57 @@ def test_an_unknown_enum_value_drops_only_that_field(manager: ConfigManager):
assert settings.translation.model.provider == TranslationService.GEMINI
+def test_translation_instructions_over_the_limit_drop_only_that_field(
+ manager: ConfigManager,
+):
+ """A hand-edited file can carry any length; one over the cap used to be
+ the kind of failure that discards every setting beside it."""
+ from desktop_pdf_translator.config.models import MAX_TRANSLATION_INSTRUCTIONS_CHARS
+
+ write_config(
+ manager,
+ {
+ "translation": {
+ "default_target_lang": "ja",
+ "instructions": "x" * (MAX_TRANSLATION_INSTRUCTIONS_CHARS + 1),
+ }
+ },
+ )
+ settings = manager.load_settings()
+
+ assert settings.translation.instructions is None
+ assert settings.translation.default_target_lang == LanguageCode.JAPANESE
+
+
+@pytest.mark.parametrize(
+ "saved, expected",
+ [
+ (" Keep code terms in English \n", "Keep code terms in English"),
+ (" \n\t ", None),
+ ("", None),
+ ],
+)
+def test_translation_instructions_are_trimmed_and_blank_means_unset(
+ manager: ConfigManager, saved: str, expected
+):
+ write_config(manager, {"translation": {"instructions": saved}})
+
+ assert manager.load_settings().translation.instructions == expected
+
+
+def test_translation_instructions_default_to_unset_and_allow_exactly_the_limit(
+ manager: ConfigManager,
+):
+ from desktop_pdf_translator.config.models import MAX_TRANSLATION_INSTRUCTIONS_CHARS
+
+ assert MAX_TRANSLATION_INSTRUCTIONS_CHARS == 2000
+ assert manager.load_settings().translation.instructions is None
+
+ at_limit = "y" * 2000
+ write_config(manager, {"translation": {"instructions": at_limit}})
+ assert manager.load_settings().translation.instructions == at_limit
+
+
def test_several_bad_fields_across_sections_are_all_dropped(manager: ConfigManager):
write_config(
manager,
diff --git a/tests/test_pdf_cache_store.py b/tests/test_pdf_cache_store.py
index a43c164..b922c81 100644
--- a/tests/test_pdf_cache_store.py
+++ b/tests/test_pdf_cache_store.py
@@ -166,6 +166,27 @@ def test_a_page_selection_separates_the_key():
assert _make_cache_key(*args, "1-20") != _make_cache_key(*args, "1-21")
+def test_instructions_separate_the_key():
+ """Instructions change what the model writes, so the same document under
+ other instructions is not the same translation."""
+ args = ("abc", "en", "vi", "openai", "gpt-4o", PIPELINE_VERSION)
+ plain = _make_cache_key(*args)
+ keep_code = _make_cache_key(*args, instructions="Keep code in English")
+ other = _make_cache_key(*args, instructions="Use formal tone")
+ assert len({plain, keep_code, other}) == 3
+ assert _make_cache_key(*args, "1-20", instructions="Use formal tone") != _make_cache_key(
+ *args, "1-20", instructions="Keep code in English"
+ )
+
+
+def test_no_instructions_keeps_every_existing_key():
+ """The entries already on disk were stored without the field; they have to
+ keep matching for anyone who never writes instructions."""
+ args = ("abc", "en", "vi", "openai", "gpt-4o", PIPELINE_VERSION)
+ assert _make_cache_key(*args, instructions=None) == _make_cache_key(*args)
+ assert _make_cache_key(*args, "1-20", instructions=None) == _make_cache_key(*args, "1-20")
+
+
# ---------------------------------------------------------------------------
# lookup / store
# ---------------------------------------------------------------------------
@@ -360,6 +381,54 @@ def test_a_precomputed_hash_is_honoured(
assert look(cache, source_pdf, file_hash=digest) is not None
+def test_a_translation_is_a_miss_under_other_instructions(
+ cache: PDFTranslationCache, source_pdf: Path, translated_pdf: Path
+):
+ put(cache, source_pdf, translated_pdf, instructions="Keep code in English")
+ assert look(cache, source_pdf, instructions="Use formal tone") is None
+ assert look(cache, source_pdf) is None
+
+
+def test_a_translation_is_a_hit_under_the_same_instructions(
+ cache: PDFTranslationCache, source_pdf: Path, translated_pdf: Path
+):
+ put(cache, source_pdf, translated_pdf, instructions="Keep code in English")
+
+ hit = look(cache, source_pdf, instructions="Keep code in English")
+ assert hit is not None
+ assert hit.cached_path.read_bytes() == translated_pdf.read_bytes()
+
+
+def test_a_translation_stored_without_instructions_is_found_with_none(
+ cache: PDFTranslationCache, source_pdf: Path, translated_pdf: Path
+):
+ put(cache, source_pdf, translated_pdf)
+
+ assert look(cache, source_pdf, instructions=None) is not None
+ assert look(cache, source_pdf, instructions="Keep code in English") is None
+
+
+def test_instructions_also_separate_page_selections(
+ cache: PDFTranslationCache, source_pdf: Path, translated_pdf: Path
+):
+ put(cache, source_pdf, translated_pdf, pages="1-20", instructions="Keep code in English")
+
+ assert look(cache, source_pdf, pages="1-20", instructions="Keep code in English") is not None
+ assert look(cache, source_pdf, pages="1-20", instructions="Use formal tone") is None
+ assert look(cache, source_pdf, pages="1-20") is None
+
+
+def test_a_whole_document_under_other_instructions_does_not_answer_a_selection(
+ cache: PDFTranslationCache, source_pdf: Path, translated_pdf: Path
+):
+ """A selection tries the whole document's entry first. That entry was
+ written under other instructions, so it must not be served."""
+ put(cache, source_pdf, translated_pdf, instructions="Keep code in English")
+
+ assert look(cache, source_pdf, pages="1-20", instructions="Use formal tone") is None
+ assert look(cache, source_pdf, pages="1-20", instructions="Keep code in English") is not None
+
+
def test_a_partial_translation_is_never_served_as_the_whole_document(
cache: PDFTranslationCache, source_pdf: Path, translated_pdf: Path
):
diff --git a/tests/test_translate_estimate_api.py b/tests/test_translate_estimate_api.py
index 5c51b2e..498af6b 100644
--- a/tests/test_translate_estimate_api.py
+++ b/tests/test_translate_estimate_api.py
@@ -17,9 +17,12 @@
from desktop_pdf_translator.api import auth
from desktop_pdf_translator.api.routes import translation as translation_routes
-from desktop_pdf_translator.config import AppSettings, LanguageCode, TranslationService
+from desktop_pdf_translator.config import AppSettings, LanguageCode, ModelRef
from desktop_pdf_translator.processors.pdf_pages import PdfInfo
-from desktop_pdf_translator.translators.usage_estimate import estimate_tokens
+from desktop_pdf_translator.translators.usage_estimate import (
+ CHARS_PER_TOKEN,
+ estimate_tokens,
+)
TOKEN = "test-token-for-translate-estimate"
LINE = "A sentence that a translator would be sent on its own."
@@ -28,7 +31,10 @@
class _FakeTranslationSettings:
default_source_lang = LanguageCode.ENGLISH
default_target_lang = LanguageCode.VIETNAMESE
- preferred_service = TranslationService.OPENAI
+ # What real settings carry since #88; the route asks the model's provider
+ # whether the user's instructions apply.
+ model = ModelRef(provider="openai", model="gpt-4.1")
+ instructions = None
min_text_length = 5
@@ -103,6 +109,45 @@ def test_the_target_language_changes_the_prompt(client, book, settings):
assert japanese["output_tokens"] == vietnamese["output_tokens"]
+def _llm_settings(instructions):
+ from desktop_pdf_translator.config import ModelRef
+
+ settings = AppSettings()
+ settings.translate_with(ModelRef(provider="openai", model="gpt-4.1"))
+ settings.translation.instructions = instructions
+ return settings
+
+
+def test_instructions_add_to_the_input_estimate_of_an_llm_run(client, book, monkeypatch):
+ """Each paragraph is sent with them, so the figure the toolbar shows before
+ a run would understate the bill by exactly that much."""
+ instructions = "Keep programming terms such as index in English. " * 4
+ monkeypatch.setattr(translation_routes, "get_settings", lambda: _llm_settings(None))
+ without = _estimate(client, book).json()
+ monkeypatch.setattr(
+ translation_routes, "get_settings", lambda: _llm_settings(instructions)
+ )
+ with_instructions = _estimate(client, book).json()
+
+ assert with_instructions["input_tokens"] - without["input_tokens"] >= 6 * (
+ len(instructions) // CHARS_PER_TOKEN
+ )
+ assert with_instructions["output_tokens"] == without["output_tokens"]
+
+
+def test_instructions_do_not_change_the_estimate_of_an_argos_run(client, book, monkeypatch):
+ """Argos has no prompt; the toolbar doesn't show an estimate for it, but
+ the number must not move if it is asked for."""
+ plain = AppSettings()
+ monkeypatch.setattr(translation_routes, "get_settings", lambda: plain)
+ without = _estimate(client, book).json()
+ with_text = AppSettings()
+ with_text.translation.instructions = "Keep index in English"
+ monkeypatch.setattr(translation_routes, "get_settings", lambda: with_text)
+
+ assert _estimate(client, book).json() == without
+
+
def test_a_large_file_is_estimated_like_any_other(client, book, monkeypatch):
"""There is no size limit to hide behind: the estimate reads the pages."""
monkeypatch.setattr(translation_routes, "get_settings", lambda: AppSettings())
diff --git a/tests/test_translate_language_contract.py b/tests/test_translate_language_contract.py
index fe56734..c56b54f 100644
--- a/tests/test_translate_language_contract.py
+++ b/tests/test_translate_language_contract.py
@@ -197,3 +197,46 @@ def test_cache_key_separates_target_languages():
def test_cache_key_is_stable_for_identical_inputs():
assert _key("en", "vi") == _key("en", "vi")
+
+
+# ---------------------------------------------------------------------------
+# translation instructions
+# ---------------------------------------------------------------------------
+
+
+def _settings_with_instructions(text):
+ from types import SimpleNamespace
+
+ return SimpleNamespace(translation=SimpleNamespace(instructions=text))
+
+
+def _services(*, llm: bool):
+ from desktop_pdf_translator.providers.registry import PROVIDERS
+
+ return [TranslationService(spec.id) for spec in PROVIDERS if spec.is_llm == llm]
+
+
+@pytest.mark.parametrize("service", _services(llm=True))
+def test_every_llm_translates_with_the_saved_instructions(service):
+ from desktop_pdf_translator.translators.capabilities import translation_instructions
+
+ settings = _settings_with_instructions("Keep index in English")
+ assert translation_instructions(settings, service) == "Keep index in English"
+
+
+@pytest.mark.parametrize("service", _services(llm=False))
+def test_a_translator_that_is_no_llm_never_gets_instructions(service):
+ """Argos has no prompt to put them in; passing them on would also key its
+ caches on text that changes nothing."""
+ from desktop_pdf_translator.translators.capabilities import translation_instructions
+
+ settings = _settings_with_instructions("Keep index in English")
+ assert translation_instructions(settings, service) is None
+ assert TranslationService.ARGOS in _services(llm=False)
+
+
+@pytest.mark.parametrize("service", list(TranslationService))
+def test_unset_instructions_are_none_for_every_service(service):
+ from desktop_pdf_translator.translators.capabilities import translation_instructions
+
+ assert translation_instructions(_settings_with_instructions(None), service) is None
diff --git a/tests/test_translation_cache_store.py b/tests/test_translation_cache_store.py
index 64aff38..f751cb7 100644
--- a/tests/test_translation_cache_store.py
+++ b/tests/test_translation_cache_store.py
@@ -89,6 +89,27 @@ def test_a_missing_model_is_not_the_same_as_an_empty_one():
)
+def test_instructions_separate_the_key():
+ """A paragraph translated under one set of instructions must not be served
+ to a run with another: the cache would hand back text that ignores them."""
+ plain = _make_cache_key("Hello", "en", "vi", "openai", "gpt-4o")
+ keep_code = _make_cache_key(
+ "Hello", "en", "vi", "openai", "gpt-4o", instructions="Keep code in English"
+ )
+ other = _make_cache_key(
+ "Hello", "en", "vi", "openai", "gpt-4o", instructions="Use formal tone"
+ )
+ assert len({plain, keep_code, other}) == 3
+
+
+def test_no_instructions_keeps_the_key_every_existing_entry_was_stored_under():
+ """Adding the field must not orphan the whole cache of anyone who never
+ writes instructions."""
+ assert _make_cache_key(
+ "Hello", "en", "vi", "openai", "gpt-4o", instructions=None
+ ) == _make_cache_key("Hello", "en", "vi", "openai", "gpt-4o")
+
+
# ---------------------------------------------------------------------------
# get / set
# ---------------------------------------------------------------------------
@@ -110,6 +131,27 @@ def test_a_hit_needs_every_field_to_match(cache: TranslationCache):
assert fetch(cache, "Hello", lang_out="ja") is None
+def test_a_paragraph_is_a_miss_under_other_instructions(cache: TranslationCache):
+ store(cache, "Hello", "Xin chào", instructions="Keep code in English")
+ assert fetch(cache, "Hello", instructions="Use formal tone") is None
+ assert fetch(cache, "Hello") is None
+
+
+def test_a_paragraph_is_a_hit_under_the_same_instructions(cache: TranslationCache):
+ store(cache, "Hello", "Xin chào", instructions="Keep code in English")
+ assert fetch(cache, "Hello", instructions="Keep code in English") == "Xin chào"
+
+
+def test_an_entry_stored_without_instructions_is_found_with_none(
+ cache: TranslationCache,
+):
+ """The call shape from before instructions existed, which is every entry
+ already on a user's disk."""
+ store(cache, "Hello", "Xin chào")
+ assert fetch(cache, "Hello", instructions=None) == "Xin chào"
+ assert fetch(cache, "Hello", instructions="Keep code in English") is None
+
+
def test_restoring_the_same_paragraph_overwrites_rather_than_duplicates(
cache: TranslationCache,
):
@@ -296,6 +338,23 @@ def test_the_wrappers_round_trip_through_the_cache(wired: TranslationCache):
assert llm_cache_get("Hello", "en", "vi", "openai", "gpt-4o") == "Xin chào"
+def test_the_wrappers_key_on_instructions(wired: TranslationCache):
+ llm_cache_set(
+ "Hello", "Xin chào", "en", "vi", "openai", "gpt-4o", instructions="Keep code in English"
+ )
+ assert (
+ llm_cache_get(
+ "Hello", "en", "vi", "openai", "gpt-4o", instructions="Keep code in English"
+ )
+ == "Xin chào"
+ )
+ assert (
+ llm_cache_get("Hello", "en", "vi", "openai", "gpt-4o", instructions="Formal")
+ is None
+ )
+ assert llm_cache_get("Hello", "en", "vi", "openai", "gpt-4o") is None
+
+
def test_a_disabled_cache_never_writes(
wired: TranslationCache, monkeypatch: pytest.MonkeyPatch
):
@@ -430,3 +489,168 @@ def test_an_upgraded_cache_opens_again_as_it_is(tmp_path: Path):
assert again._conn().execute("PRAGMA user_version").fetchone()[0] == 1
assert fetch(again, "Hello") == "Xin chào"
assert again.stats()["entries"] == 1
+
+
+# ---------------------------------------------------------------------------
+# a translator keys its paragraphs on its instructions
+# ---------------------------------------------------------------------------
+
+
+class _CountingCompletions:
+ def __init__(self) -> None:
+ self.calls = 0
+
+ def create(self, **kwargs):
+ from types import SimpleNamespace
+
+ self.calls += 1
+ return SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ message=SimpleNamespace(content="Xin chào"), finish_reason="stop"
+ )
+ ]
+ )
+
+
+def _openai_with(instructions, completions: _CountingCompletions):
+ from types import SimpleNamespace
+
+ from desktop_pdf_translator.translators.openai_translator import OpenAITranslator
+
+ translator = OpenAITranslator(
+ lang_in="en",
+ lang_out="vi",
+ api_key="sk-test",
+ model="gpt-test",
+ instructions=instructions,
+ )
+ translator.client = SimpleNamespace(chat=SimpleNamespace(completions=completions))
+ return translator
+
+
+def test_a_paragraph_cached_under_some_instructions_is_not_served_under_others(
+ wired: TranslationCache, monkeypatch: pytest.MonkeyPatch
+):
+ """The cache would otherwise hand back text written without the new
+ instructions, and the setting would appear to do nothing until the cache
+ expired."""
+ from desktop_pdf_translator.translators import rate_limiter
+
+ monkeypatch.setattr(rate_limiter, "_LIMITERS", {})
+ completions = _CountingCompletions()
+
+ assert _openai_with("A", completions).translate("Hello") == "Xin chào"
+ assert completions.calls == 1
+
+ _openai_with("A", completions).translate("Hello")
+ assert completions.calls == 1 # same instructions: served from the cache
+
+ _openai_with("B", completions).translate("Hello")
+ assert completions.calls == 2 # other instructions: translated again
+
+
+@pytest.mark.parametrize(
+ "module, name, extra",
+ [
+ ("openai_translator", "OpenAITranslator", {"model": "gpt-test"}),
+ ("anthropic_translator", "AnthropicTranslator", {}),
+ ("gemini_translator", "GeminiTranslator", {}),
+ ],
+)
+def test_every_llm_translator_looks_its_paragraphs_up_under_its_instructions(
+ module, name, extra, monkeypatch: pytest.MonkeyPatch
+):
+ import importlib
+
+ translators = importlib.import_module(f"desktop_pdf_translator.translators.{module}")
+ lookups: list = []
+
+ def recording_get(*args, **kwargs):
+ lookups.append(kwargs)
+ return "Xin chào"
+
+ monkeypatch.setattr(translators, "_llm_cache_get", recording_get)
+ translator = getattr(translators, name)(
+ lang_in="en", lang_out="vi", api_key="test-key", instructions="Keep code in English", **extra
+ )
+
+ assert translator.translate("Hello") == "Xin chào"
+ assert lookups and lookups[0].get("instructions") == "Keep code in English"
+
+
+def test_no_instructions_hash_the_payload_every_existing_entry_was_keyed_on():
+ """Pinned to the format, not to the function: entries already on disk
+ were keyed this way, and a key that moved would orphan all of them."""
+ import hashlib
+
+ expected = hashlib.sha256(b"en|vi|openai|gpt-4o|Hello").hexdigest()
+ assert _make_cache_key("Hello", "en", "vi", "openai", "gpt-4o") == expected
+ assert _make_cache_key("Hello", "en", "vi", "openai", "gpt-4o", None) == expected
+
+
+def _complete_client(module: str):
+ """A client answering every paragraph in full, per SDK."""
+ from types import SimpleNamespace
+
+ if module == "openai_translator":
+ response = SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ message=SimpleNamespace(content="Xin chào"), finish_reason="stop"
+ )
+ ]
+ )
+ return SimpleNamespace(
+ chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kw: response))
+ )
+ if module == "anthropic_translator":
+ response = SimpleNamespace(
+ content=[SimpleNamespace(type="text", text="Xin chào")], stop_reason="end_turn"
+ )
+ return SimpleNamespace(messages=SimpleNamespace(create=lambda **kw: response))
+ response = SimpleNamespace(
+ text="Xin chào",
+ candidates=[
+ SimpleNamespace(content=object(), finish_reason=SimpleNamespace(name="STOP"))
+ ],
+ )
+ return SimpleNamespace(models=SimpleNamespace(generate_content=lambda **kw: response))
+
+
+@pytest.mark.parametrize(
+ "module, name, extra",
+ [
+ ("openai_translator", "OpenAITranslator", {"model": "gpt-test"}),
+ ("anthropic_translator", "AnthropicTranslator", {}),
+ ("gemini_translator", "GeminiTranslator", {}),
+ ],
+)
+def test_every_llm_translator_stores_its_paragraphs_under_its_instructions(
+ module, name, extra, monkeypatch: pytest.MonkeyPatch
+):
+ """The store side: written under the plain key, an instructed translation
+ would be served to a later run that has no instructions."""
+ import importlib
+ import inspect
+
+ from desktop_pdf_translator.translators import rate_limiter
+
+ translators = importlib.import_module(f"desktop_pdf_translator.translators.{module}")
+ stored: list = []
+
+ def recording_set(*args, **kwargs):
+ # Positional or keyword, the call must carry them.
+ bound = inspect.signature(llm_cache_set).bind(*args, **kwargs)
+ stored.append(bound.arguments.get("instructions"))
+
+ monkeypatch.setattr(translators, "_llm_cache_get", lambda *a, **k: None)
+ monkeypatch.setattr(translators, "_llm_cache_set", recording_set)
+ monkeypatch.setattr(rate_limiter, "_LIMITERS", {})
+ translator = getattr(translators, name)(
+ lang_in="en", lang_out="vi", api_key="test-key", instructions="Keep code in English", **extra
+ )
+ translator.client = _complete_client(module)
+
+ assert translator.translate("Hello") == "Xin chào"
+ assert stored == ["Keep code in English"]
diff --git a/tests/test_translation_prompts.py b/tests/test_translation_prompts.py
index 02fc564..700be00 100644
--- a/tests/test_translation_prompts.py
+++ b/tests/test_translation_prompts.py
@@ -18,8 +18,8 @@
from desktop_pdf_translator.translators.openai_translator import OpenAITranslator
-def _prompt_text(cls, lang_out: str) -> str:
- translator = cls(lang_in="en", lang_out=lang_out, api_key="test-key")
+def _prompt_text(cls, lang_out: str, **kwargs) -> str:
+ translator = cls(lang_in="en", lang_out=lang_out, api_key="test-key", **kwargs)
prompt = translator._create_translation_prompt("Hello")
if isinstance(prompt, str):
return prompt
@@ -71,3 +71,91 @@ def test_rich_text_placeholders_follow_babeldocs_protocol(side: str, index: int)
pattern = make(r"\d+")[1]
assert re.fullmatch(pattern, make(7)[0], re.I)
assert re.search(pattern, "Bảng b/c và sub/dir", re.I) is None
+
+
+# ---------------------------------------------------------------------------
+# the user's instructions
+# ---------------------------------------------------------------------------
+
+INSTRUCTIONS = "Keep programming terms such as index in English."
+ALL_TRANSLATORS = [OpenAITranslator, AnthropicTranslator, GeminiTranslator]
+
+
+@pytest.mark.parametrize("cls", ALL_TRANSLATORS)
+def test_a_translator_exposes_the_instructions_it_was_given(cls):
+ translator = cls(lang_in="en", lang_out="vi", api_key="test-key", instructions=INSTRUCTIONS)
+ assert translator.instructions == INSTRUCTIONS
+
+
+@pytest.mark.parametrize("cls", ALL_TRANSLATORS)
+def test_a_translator_has_no_instructions_unless_given_some(cls):
+ assert cls(lang_in="en", lang_out="vi", api_key="test-key").instructions is None
+
+
+@pytest.mark.parametrize("cls", ALL_TRANSLATORS)
+@pytest.mark.parametrize("lang_out", ["vi", "ja"])
+def test_the_instructions_reach_the_prompt_inside_their_own_block(cls, lang_out: str):
+ """The fence is what lets the fixed rules say they win: text between the
+ tags is the user's, whatever it claims."""
+ prompt = _prompt_text(cls, lang_out, instructions=INSTRUCTIONS)
+
+ assert "" in prompt and "" in prompt
+ block = prompt[prompt.index("") : prompt.index("")]
+ assert INSTRUCTIONS in block
+
+
+@pytest.mark.parametrize("cls", ALL_TRANSLATORS)
+def test_the_instructions_come_after_the_fixed_rules(cls):
+ """Last, so nothing the user wrote can sit between the model and the
+ placeholder rule it must obey over them."""
+ translator = cls(lang_in="en", lang_out="vi", api_key="test-key", instructions=INSTRUCTIONS)
+ formula, _pattern = translator.get_formular_placeholder(1)
+
+ prompt = _prompt_text(cls, "vi", instructions=INSTRUCTIONS)
+
+ assert "" in prompt
+ assert prompt.rindex(formula) < prompt.index("") < prompt.index(INSTRUCTIONS)
+
+
+@pytest.mark.parametrize("cls", ALL_TRANSLATORS)
+def test_no_instructions_leave_the_prompt_exactly_as_it_was(cls):
+ assert _prompt_text(cls, "vi", instructions=None) == _prompt_text(cls, "vi")
+ assert "user-instructions" not in _prompt_text(cls, "vi")
+
+
+# sha256 of `_translation_instructions()` as it was before instructions
+# existed, taken from that commit's code rather than from this one: "no
+# instructions" must cost nothing, including a changed prompt for everyone.
+PROMPT_BEFORE_INSTRUCTIONS = {
+ "vi": "50cccc2a1b48dbde377068914165a7e8a11fe25f25339e7d7dba6eba925b62b2",
+ "ja": "7f3a641dea34ad0c4e19968d72d76ed7dfa3332b7a6aaca70c0739902b09efa4",
+}
+
+
+@pytest.mark.parametrize("cls", ALL_TRANSLATORS)
+@pytest.mark.parametrize("lang_out", ["vi", "ja"])
+def test_no_instructions_send_the_prompt_sent_before_they_existed(cls, lang_out: str):
+ import hashlib
+
+ prompt = cls(lang_in="en", lang_out=lang_out, api_key="test-key")._translation_instructions()
+
+ assert hashlib.sha256(prompt.encode()).hexdigest() == PROMPT_BEFORE_INSTRUCTIONS[lang_out]
+
+
+@pytest.mark.parametrize("cls", ALL_TRANSLATORS)
+@pytest.mark.parametrize("lang_out, language", [("vi", "Vietnamese"), ("ja", "Japanese")])
+def test_the_instructions_are_told_which_rules_they_cannot_override(
+ cls, lang_out: str, language: str
+):
+ """A user asking for notes, or for another language, would otherwise have
+ them printed into the PDF; the lead-in before the fence says what wins."""
+ translator = cls(lang_in="en", lang_out=lang_out, api_key="test-key", instructions=INSTRUCTIONS)
+ formula, _pattern = translator.get_formular_placeholder(1)
+
+ prompt = _prompt_text(cls, lang_out, instructions=INSTRUCTIONS)
+ lead_in = prompt[prompt.rindex(formula) : prompt.index("")]
+ after_rules = lead_in.split("\n\n", 1)[1]
+
+ assert language in after_rules
+ assert "translation alone" in after_rules
+ assert "placeholder" in after_rules
diff --git a/tests/test_translator_factory.py b/tests/test_translator_factory.py
index dba6fba..5f735a5 100644
--- a/tests/test_translator_factory.py
+++ b/tests/test_translator_factory.py
@@ -230,3 +230,20 @@ def test_two_translators_for_the_same_provider_do_share_the_cache(
assert first_result == "Bản dịch OpenAI (first)"
assert second_result == "Bản dịch OpenAI (first)"
assert len(second.client.completions.calls) == 0
+
+
+def test_a_translator_built_by_the_factory_carries_no_instructions(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ """Chat, key checks and pre-warm all build through the factory; only the
+ processor adds the user's translation instructions, so they can never
+ reach a chat answer's prompt."""
+ settings = _settings(openai={"api_key": "sk-test"})
+ settings.translation.instructions = "Keep index in English"
+ monkeypatch.setattr(factory_module, "get_settings", lambda: settings)
+
+ translator = TranslatorFactory.create_translator(
+ service="openai", lang_in="en", lang_out="vi"
+ )
+
+ assert translator.instructions is None
diff --git a/tests/test_usage_estimate.py b/tests/test_usage_estimate.py
index d5ec298..56d2324 100644
--- a/tests/test_usage_estimate.py
+++ b/tests/test_usage_estimate.py
@@ -47,6 +47,32 @@ def test_each_paragraph_carries_a_prompt():
assert ten.output_tokens == one.output_tokens
+def test_instructions_make_every_paragraphs_prompt_longer():
+ """They ride in each paragraph's system prompt, so a long instruction
+ times a long book is a real part of the bill."""
+ instructions = "Keep programming terms such as index in English. " * 6
+ plain = prompt_tokens("vi")
+
+ assert prompt_tokens("vi", instructions) >= plain + len(instructions) // CHARS_PER_TOKEN
+ assert prompt_tokens("vi", None) == plain
+
+ ten = estimate_tokens(
+ paragraphs=10, cjk_chars=0, other_chars=400, target_lang="vi", instructions=instructions
+ )
+ ten_plain = estimate_tokens(paragraphs=10, cjk_chars=0, other_chars=400, target_lang="vi")
+ assert ten.input_tokens - ten_plain.input_tokens >= 10 * (
+ len(instructions) // CHARS_PER_TOKEN
+ )
+ assert ten.output_tokens == ten_plain.output_tokens
+
+
+def test_instructions_cost_nothing_when_there_is_nothing_to_translate():
+ estimate = estimate_tokens(
+ paragraphs=0, cjk_chars=0, other_chars=0, target_lang="vi", instructions="x" * 400
+ )
+ assert (estimate.input_tokens, estimate.output_tokens) == (0, 0)
+
+
def test_a_translation_comes_back_longer_than_its_source():
estimate = estimate_tokens(paragraphs=1, cjk_chars=0, other_chars=4000, target_lang="vi")
assert estimate.output_tokens > text_tokens(0, 4000)
@@ -57,9 +83,10 @@ def test_nothing_to_translate_costs_nothing():
assert (estimate.input_tokens, estimate.output_tokens) == (0, 0)
-def _prompt_chars(translator_cls, target_lang: str) -> int:
+def _prompt_chars(translator_cls, target_lang: str, instructions=None) -> int:
translator = translator_cls.__new__(translator_cls)
translator.lang_in, translator.lang_out = "en", target_lang
+ translator.instructions = instructions
prompt = translator._create_translation_prompt("")
if isinstance(prompt, list): # OpenAI: chat messages
return sum(len(message["content"]) for message in prompt)
@@ -82,3 +109,17 @@ def test_the_prompt_constants_match_the_prompts(module, name, target_lang):
)
measured = _prompt_chars(cls, target_lang) / usage_estimate.CHARS_PER_TOKEN
assert prompt_tokens(target_lang) == pytest.approx(measured, rel=0.1)
+
+ # Instructions are the one part of the prompt the user writes, so the same
+ # hold is put on them: a 300-character one is ~75 tokens on a ~190-token
+ # prompt, well outside the tolerance if the estimate ignored it.
+ instructions = (
+ "Keep programming terms such as index, cache and pointer in English. "
+ "Translate formally and address the reader as 'ban'. Leave product names "
+ "alone. Prefer short sentences. Do not add notes of your own. "
+ ) * 2
+ instructions = instructions[:300]
+ assert len(instructions) == 300
+ measured = _prompt_chars(cls, target_lang, instructions) / usage_estimate.CHARS_PER_TOKEN
+ assert prompt_tokens(target_lang, instructions) == pytest.approx(measured, rel=0.1)
+