billingsmoore Claude Sonnet 5 commited on
Commit
36fc86c
Β·
1 Parent(s): 9e83c68

Switch translation backend from Gemini to OpenRouter, add test suite

Browse files

Replaces the Gemini API integration with OpenRouter: the model dropdown is
now live-fetched from OpenRouter's catalog with a small curated set of
recommended models pinned to the top (also used as OpenRouter's server-side
model fallback chain). Adds a pytest suite covering chunking, file I/O,
prompt storage, the OpenRouter backend, the backend dispatcher, the local CPU
backend, and the Gradio handlers/app wiring, plus sample Tibetan input files
(txt/docx/pdf) used as test fixtures. Also adds a pre-push git hook that runs
the suite and blocks the push on failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

.githooks/pre-push ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Runs the test suite before allowing a push. Install this hook with:
3
+ # git config core.hooksPath .githooks
4
+ # (or copy it into .git/hooks/pre-push directly).
5
+ set -euo pipefail
6
+
7
+ repo_root="$(git rev-parse --show-toplevel)"
8
+ cd "$repo_root"
9
+
10
+ if [ -x ".venv/bin/pytest" ]; then
11
+ PYTEST=".venv/bin/pytest"
12
+ elif command -v pytest >/dev/null 2>&1; then
13
+ PYTEST="pytest"
14
+ else
15
+ echo "pre-push: pytest not found (expected .venv/bin/pytest or pytest on PATH)." >&2
16
+ echo "Install dev deps first: pip install -r requirements-dev.txt" >&2
17
+ exit 1
18
+ fi
19
+
20
+ echo "pre-push: running test suite..."
21
+ if ! "$PYTEST" -q; then
22
+ echo "pre-push: tests failed, aborting push." >&2
23
+ exit 1
24
+ fi
CLAUDE.md CHANGED
@@ -32,17 +32,22 @@ engine/
32
  chunking.py Plain-text -> segment list (paragraph, then shad/whitespace splitting)
33
  formats.py Read .txt/.docx/.pdf; write .txt/.docx/.json
34
  prompt.py Read/write/reset translation_prompt.txt (local file only)
35
- translate.py Backend dispatcher (Gemini vs local CPU model) + batching/progress
36
- gemini_backend.py Gemini API calls, retries, fallback model chain, batch prompt building
37
  local_backend.py CPU fallback: AutoModelForSeq2SeqLM "billingsmoore/mlotsawa-ground-base"
38
  ```
39
 
40
  ## Translation backend selection
41
 
42
  `engine/translate.py::_resolve_key()` decides per-call:
43
- - A Gemini API key was typed into the UI, **or** `GEMINI_API_KEY` is set (env var / `.env`) β†’ use Gemini,
44
- with the prompt in `translation_prompt.txt` (editable in Settings) and the model picked in the dropdown
45
- (`FALLBACK_CHAIN` in `engine/gemini_backend.py`; falls through the chain on failure).
 
 
 
 
 
46
  - No key anywhere β†’ use the local CPU model `billingsmoore/mlotsawa-ground-base` via
47
  `AutoModelForSeq2SeqLM.generate()` directly (see `engine/local_backend.py`). This is a plain finetuned
48
  T5 seq2seq model β€” it does **not** read `translation_prompt.txt` at all. First call downloads the
@@ -69,8 +74,32 @@ timestamp concept anywhere in this app β€” segments are just `{"source": str, "t
69
  pip install -r requirements.txt
70
  python app.py
71
  ```
72
- Opens on `http://localhost:7860`. Put `GEMINI_API_KEY=...` in a `.env` file to default to Gemini
73
- without typing a key into the UI each time.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  ## Conventions to keep
76
 
 
32
  chunking.py Plain-text -> segment list (paragraph, then shad/whitespace splitting)
33
  formats.py Read .txt/.docx/.pdf; write .txt/.docx/.json
34
  prompt.py Read/write/reset translation_prompt.txt (local file only)
35
+ translate.py Backend dispatcher (OpenRouter vs local CPU model) + batching/progress
36
+ openrouter_backend.py OpenRouter API calls, retries, server-side model fallback, batch prompt building
37
  local_backend.py CPU fallback: AutoModelForSeq2SeqLM "billingsmoore/mlotsawa-ground-base"
38
  ```
39
 
40
  ## Translation backend selection
41
 
42
  `engine/translate.py::_resolve_key()` decides per-call:
43
+ - An OpenRouter API key was typed into the UI, **or** `OPENROUTER_API_KEY` is set (env var / `.env`)
44
+ β†’ use OpenRouter, with the prompt in `translation_prompt.txt` (editable in Settings) and the model
45
+ picked in the dropdown. The dropdown is populated by `engine/openrouter_backend.py::list_model_choices()`,
46
+ which live-fetches OpenRouter's `/models` catalog and puts `CURATED_MODELS` (a small hand-picked,
47
+ known-good-for-translation set) at the top, followed by the rest of the text-output catalog
48
+ alphabetically β€” falls back to just `CURATED_MODELS` if the fetch fails (offline, OpenRouter down).
49
+ `CURATED_MODELS` also doubles as the `models` fallback array OpenRouter is given per request, so it
50
+ retries server-side against another good model if the chosen one errors out.
51
  - No key anywhere β†’ use the local CPU model `billingsmoore/mlotsawa-ground-base` via
52
  `AutoModelForSeq2SeqLM.generate()` directly (see `engine/local_backend.py`). This is a plain finetuned
53
  T5 seq2seq model β€” it does **not** read `translation_prompt.txt` at all. First call downloads the
 
74
  pip install -r requirements.txt
75
  python app.py
76
  ```
77
+ Opens on `http://localhost:7860`. Put `OPENROUTER_API_KEY=...` in a `.env` file to default to
78
+ OpenRouter without typing a key into the UI each time.
79
+
80
+ ## Testing
81
+
82
+ ```bash
83
+ pip install -r requirements-dev.txt
84
+ pytest
85
+ ```
86
+
87
+ `tests/` covers chunking, file I/O (txt/docx/pdf load+export, JSON resume),
88
+ prompt storage, the OpenRouter backend (model curation/fetch-fallback, request
89
+ retry/fallback logic, batch prompt building/parsing β€” all with `requests`
90
+ mocked, no real network calls or API key needed), the backend dispatcher, the
91
+ local CPU backend (with `torch`/`transformers` stubbed via `sys.modules` so
92
+ the suite doesn't require installing those heavy deps), the Gradio event
93
+ handlers, and a smoke test that the Blocks graph in `app.py` builds without
94
+ error. `samples/sample_tibetan.{txt,docx,pdf}` are real Tibetan-script fixture
95
+ files (a well-known refuge/bodhicitta verse, the four immeasurables, and a
96
+ dedication verse) used by the format-loading tests and useful for manually
97
+ exercising the upload flow.
98
+
99
+ A `pre-push` git hook (`.githooks/pre-push`) runs the full suite and blocks
100
+ the push if anything fails. It's already installed into `.git/hooks/pre-push`
101
+ in this working copy; on a fresh clone, install it with either
102
+ `git config core.hooksPath .githooks` or `cp .githooks/pre-push .git/hooks/`.
103
 
104
  ## Conventions to keep
105
 
README.md CHANGED
@@ -21,9 +21,11 @@ projects β€” just upload, translate, edit, download.
21
 
22
  ## Translation backend
23
 
24
- - If you provide a **Gemini API key** (in the UI, or via a `GEMINI_API_KEY`
25
- env var / `.env` file), translation uses Gemini with the editable prompt
26
- in the Settings panel (`translation_prompt.txt`).
 
 
27
  - Otherwise it falls back to a **local CPU model**,
28
  [billingsmoore/mlotsawa-ground-base](https://huggingface.co/billingsmoore/mlotsawa-ground-base),
29
  loaded via `transformers`. The prompt box has no effect on this backend β€”
@@ -37,8 +39,8 @@ pip install -r requirements.txt
37
  python app.py
38
  ```
39
 
40
- Optionally set `GEMINI_API_KEY` in a `.env` file to default to Gemini without
41
- typing a key into the UI each time.
42
 
43
  ## Downloads
44
 
 
21
 
22
  ## Translation backend
23
 
24
+ - If you provide an **OpenRouter API key** (in the UI, or via an
25
+ `OPENROUTER_API_KEY` env var / `.env` file), translation uses whichever
26
+ OpenRouter model you pick from the Settings dropdown (live-fetched from
27
+ OpenRouter's catalog, with a few recommended models pinned to the top),
28
+ with the editable prompt in the Settings panel (`translation_prompt.txt`).
29
  - Otherwise it falls back to a **local CPU model**,
30
  [billingsmoore/mlotsawa-ground-base](https://huggingface.co/billingsmoore/mlotsawa-ground-base),
31
  loaded via `transformers`. The prompt box has no effect on this backend β€”
 
39
  python app.py
40
  ```
41
 
42
+ Optionally set `OPENROUTER_API_KEY` in a `.env` file to default to OpenRouter
43
+ without typing a key into the UI each time.
44
 
45
  ## Downloads
46
 
app.py CHANGED
@@ -4,7 +4,7 @@ from dotenv import load_dotenv
4
  load_dotenv()
5
 
6
  import gradio as gr
7
- from engine import FALLBACK_CHAIN
8
 
9
  from styles import CSS
10
  from handlers import (
@@ -51,20 +51,23 @@ def build_app() -> gr.Blocks:
51
 
52
  with gr.Accordion("Settings", open=False):
53
  with gr.Row():
54
- gemini_api_key = gr.Textbox(
55
- label="Gemini API Key (optional)",
56
- placeholder="AIza… β€” leave blank to use the local CPU model instead",
57
  type="password",
58
  scale=2,
59
  )
60
- gemini_model = gr.Dropdown(
61
- choices=FALLBACK_CHAIN, value=FALLBACK_CHAIN[0],
62
- label="Gemini Model (only used if a key is given)", scale=1,
 
 
 
63
  )
64
 
65
  gr.Markdown(
66
  "### Translation Prompt\n"
67
- "Only used when translating with Gemini β€” the local CPU fallback "
68
  "(billingsmoore/mlotsawa-ground-base) is a plain translation model and ignores this. "
69
  "Saved directly to `translation_prompt.txt` in this app's folder."
70
  )
@@ -127,7 +130,7 @@ def build_app() -> gr.Blocks:
127
 
128
  translate_event = translate_all_btn.click(
129
  _translate_all,
130
- inputs=[app_state, gemini_api_key, gemini_model, *slot_sources, *slot_targets],
131
  outputs=translate_all_outputs,
132
  )
133
  cancel_btn.click(fn=None, cancels=[translate_event])
@@ -155,7 +158,7 @@ def build_app() -> gr.Blocks:
155
  outputs=[slot_targets[i]],
156
  ).then(
157
  fn=lambda state, source, key, model, i=i: _translate_one(state, i, source, key, model),
158
- inputs=[app_state, slot_sources[i], gemini_api_key, gemini_model],
159
  outputs=[app_state, slot_targets[i]],
160
  )
161
 
 
4
  load_dotenv()
5
 
6
  import gradio as gr
7
+ from engine import DEFAULT_MODEL, list_model_choices
8
 
9
  from styles import CSS
10
  from handlers import (
 
51
 
52
  with gr.Accordion("Settings", open=False):
53
  with gr.Row():
54
+ openrouter_api_key = gr.Textbox(
55
+ label="OpenRouter API Key (optional)",
56
+ placeholder="sk-or-… β€” leave blank to use the local CPU model instead",
57
  type="password",
58
  scale=2,
59
  )
60
+ openrouter_model = gr.Dropdown(
61
+ choices=list_model_choices(), value=DEFAULT_MODEL,
62
+ label="OpenRouter Model (only used if a key is given)",
63
+ info="A few recommended models are pinned at the top; the rest of "
64
+ "OpenRouter's catalog is below β€” type to filter.",
65
+ scale=1,
66
  )
67
 
68
  gr.Markdown(
69
  "### Translation Prompt\n"
70
+ "Only used when translating with OpenRouter β€” the local CPU fallback "
71
  "(billingsmoore/mlotsawa-ground-base) is a plain translation model and ignores this. "
72
  "Saved directly to `translation_prompt.txt` in this app's folder."
73
  )
 
130
 
131
  translate_event = translate_all_btn.click(
132
  _translate_all,
133
+ inputs=[app_state, openrouter_api_key, openrouter_model, *slot_sources, *slot_targets],
134
  outputs=translate_all_outputs,
135
  )
136
  cancel_btn.click(fn=None, cancels=[translate_event])
 
158
  outputs=[slot_targets[i]],
159
  ).then(
160
  fn=lambda state, source, key, model, i=i: _translate_one(state, i, source, key, model),
161
+ inputs=[app_state, slot_sources[i], openrouter_api_key, openrouter_model],
162
  outputs=[app_state, slot_targets[i]],
163
  )
164
 
engine/__init__.py CHANGED
@@ -13,8 +13,10 @@ from .formats import (
13
  )
14
  from .prompt import read_prompt, save_prompt, reset_prompt, PROMPT_PATH
15
  from .translate import (
16
- FALLBACK_CHAIN,
 
 
17
  translate_one,
18
  translate_segments,
19
- using_gemini,
20
  )
 
13
  )
14
  from .prompt import read_prompt, save_prompt, reset_prompt, PROMPT_PATH
15
  from .translate import (
16
+ CURATED_MODELS,
17
+ DEFAULT_MODEL,
18
+ list_model_choices,
19
  translate_one,
20
  translate_segments,
21
+ using_openrouter,
22
  )
engine/local_backend.py CHANGED
@@ -1,5 +1,5 @@
1
  """CPU translation backend using billingsmoore/mlotsawa-ground-base
2
- (a Tibetan->English seq2seq T5 model). Used whenever no Gemini API key is
3
  available. The model is loaded once, lazily, on first use.
4
 
5
  Generation uses the model's own task_specific_params["translation_bo_to_en"]
 
1
  """CPU translation backend using billingsmoore/mlotsawa-ground-base
2
+ (a Tibetan->English seq2seq T5 model). Used whenever no OpenRouter API key is
3
  available. The model is loaded once, lazily, on first use.
4
 
5
  Generation uses the model's own task_specific_params["translation_bo_to_en"]
engine/{gemini_backend.py β†’ openrouter_backend.py} RENAMED
@@ -1,56 +1,104 @@
1
- """Gemini translation backend. Used only when a Gemini API key is available
2
- (user-supplied or GEMINI_API_KEY env var) β€” otherwise engine.translate falls
3
- back to the local CPU model."""
4
 
5
  import re
6
  import time
7
- from concurrent.futures import ThreadPoolExecutor, TimeoutError as _FuturesTimeout
8
 
9
- FALLBACK_CHAIN = [
10
- "gemini-2.5-flash",
11
- "gemini-2.5-pro",
12
- "gemini-3-flash-preview",
13
- "gemini-3.1-pro-preview",
 
 
 
 
 
 
 
 
 
 
 
 
14
  ]
15
 
16
- _GEMINI_TIMEOUT = 120
 
 
 
17
  _BACKOFF_BASE = 2
18
  _BATCH_SIZE = 25
19
 
 
 
20
 
21
  def _is_retryable(exc) -> bool:
22
  msg = str(exc).lower()
23
- return any(x in msg for x in ("429", "rate limit", "quota", "503", "500", "unavailable"))
24
 
25
 
26
- def _call_gemini(client, model: str, prompt: str):
27
- from google.genai import types
28
- config = types.GenerateContentConfig(
29
- thinking_config=types.ThinkingConfig(thinking_budget=0)
30
- )
31
- queue = [model] + [m for m in FALLBACK_CHAIN if m != model]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  last_exc = None
33
- for candidate in queue:
34
- if candidate != model:
35
- print(f"[WARN] Gemini model '{model}' failed. Trying '{candidate}'.")
36
- for attempt in range(4):
37
- try:
38
- with ThreadPoolExecutor(max_workers=1) as executor:
39
- future = executor.submit(
40
- client.models.generate_content,
41
- model=candidate, contents=prompt, config=config,
42
- )
43
- return future.result(timeout=_GEMINI_TIMEOUT)
44
- except _FuturesTimeout:
45
- last_exc = TimeoutError(f"Gemini '{candidate}' timed out after {_GEMINI_TIMEOUT}s")
46
- print(f"[WARN] {last_exc} (attempt {attempt + 1}/4)")
47
- except Exception as e:
48
- last_exc = e
49
- if not _is_retryable(e):
50
- break
51
- print(f"[WARN] Gemini '{candidate}' attempt {attempt + 1}/4 failed: {e}")
52
- if attempt < 3:
53
- time.sleep(_BACKOFF_BASE ** attempt)
54
  raise last_exc
55
 
56
 
@@ -84,15 +132,12 @@ def _parse_batch_response(response_text: str, expected: int) -> list[str] | None
84
 
85
 
86
  def translate_one(text: str, api_key: str, model: str, template: str) -> str:
87
- from google import genai
88
- client = genai.Client(api_key=api_key)
89
  prompt = (
90
  template.rstrip()
91
  + f"\n\nTranslate the following into English, following all instructions "
92
  f"above. Output ONLY the translation, no other text.\n\n{text}"
93
  )
94
- response = _call_gemini(client, model, prompt)
95
- return response.text.strip()
96
 
97
 
98
  def translate_batch(
@@ -100,12 +145,10 @@ def translate_batch(
100
  ) -> list[str] | None:
101
  if not texts:
102
  return []
103
- from google import genai
104
- client = genai.Client(api_key=api_key)
105
  prompt = _build_batch_prompt(template, texts, preceding=preceding)
106
  try:
107
- response = _call_gemini(client, model, prompt)
108
- return _parse_batch_response(response.text.strip(), len(texts))
109
  except Exception as e:
110
- print(f"[WARN] Gemini batch translation failed: {e}")
111
  return None
 
1
+ """OpenRouter translation backend. Used only when an OpenRouter API key is
2
+ available (user-supplied or OPENROUTER_API_KEY env var) β€” otherwise
3
+ engine.translate falls back to the local CPU model."""
4
 
5
  import re
6
  import time
 
7
 
8
+ import requests
9
+
10
+ BASE_URL = "https://openrouter.ai/api/v1"
11
+
12
+ # Small hand-picked set of models known to work well for this kind of
13
+ # instruction-following translation task. These are pinned to the top of the
14
+ # UI dropdown (ahead of the full live-fetched catalog) and also used as
15
+ # OpenRouter's server-side model fallback chain for a given request.
16
+ CURATED_MODELS = [
17
+ "anthropic/claude-sonnet-4.5",
18
+ "openai/gpt-4o",
19
+ "openai/gpt-4o-mini",
20
+ "google/gemini-2.5-flash",
21
+ "google/gemini-2.5-pro",
22
+ "deepseek/deepseek-chat",
23
+ "qwen/qwen-2.5-72b-instruct",
24
+ "meta-llama/llama-3.3-70b-instruct",
25
  ]
26
 
27
+ DEFAULT_MODEL = CURATED_MODELS[0]
28
+
29
+ _TIMEOUT = 120
30
+ _MODELS_TIMEOUT = 10
31
  _BACKOFF_BASE = 2
32
  _BATCH_SIZE = 25
33
 
34
+ _RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
35
+
36
 
37
  def _is_retryable(exc) -> bool:
38
  msg = str(exc).lower()
39
+ return any(x in msg for x in ("429", "rate limit", "timed out", "timeout", "connection"))
40
 
41
 
42
+ def fetch_available_models(timeout: int = _MODELS_TIMEOUT) -> list[dict]:
43
+ """Raw model list from OpenRouter's public /models endpoint (no key required)."""
44
+ resp = requests.get(f"{BASE_URL}/models", timeout=timeout)
45
+ resp.raise_for_status()
46
+ return resp.json().get("data", [])
47
+
48
+
49
+ def _is_text_model(model: dict) -> bool:
50
+ arch = model.get("architecture") or {}
51
+ modality = arch.get("modality") or ""
52
+ output_modalities = arch.get("output_modalities") or []
53
+ return modality.endswith("->text") or "text" in output_modalities
54
+
55
+
56
+ def list_model_choices(timeout: int = _MODELS_TIMEOUT) -> list[str]:
57
+ """Curated models first (in preferred order), then the rest of OpenRouter's
58
+ text-output catalog alphabetically. Falls back to just the curated list if
59
+ the live fetch fails (offline, OpenRouter down, etc.)."""
60
+ try:
61
+ models = fetch_available_models(timeout=timeout)
62
+ except Exception as e:
63
+ print(f"[WARN] Could not fetch OpenRouter model list, using curated defaults only: {e}")
64
+ return list(CURATED_MODELS)
65
+
66
+ ids = {m["id"] for m in models if _is_text_model(m)}
67
+ curated_present = [m for m in CURATED_MODELS if m in ids]
68
+ rest = sorted(ids.difference(curated_present))
69
+ return curated_present + rest
70
+
71
+
72
+ def _call_openrouter(api_key: str, model: str, prompt: str) -> str:
73
+ headers = {
74
+ "Authorization": f"Bearer {api_key}",
75
+ "Content-Type": "application/json",
76
+ }
77
+ # OpenRouter tries "models" in order server-side if the primary errors out,
78
+ # so we don't need to re-implement model-swapping client-side.
79
+ fallback_models = [model] + [m for m in CURATED_MODELS if m != model]
80
+ body = {
81
+ "models": fallback_models,
82
+ "messages": [{"role": "user", "content": prompt}],
83
+ }
84
+
85
  last_exc = None
86
+ for attempt in range(4):
87
+ try:
88
+ resp = requests.post(f"{BASE_URL}/chat/completions", headers=headers, json=body, timeout=_TIMEOUT)
89
+ except requests.RequestException as e:
90
+ last_exc = e
91
+ print(f"[WARN] OpenRouter request attempt {attempt + 1}/4 failed: {e}")
92
+ else:
93
+ if resp.status_code == 200:
94
+ data = resp.json()
95
+ return data["choices"][0]["message"]["content"]
96
+ last_exc = RuntimeError(f"OpenRouter HTTP {resp.status_code}: {resp.text[:300]}")
97
+ if resp.status_code not in _RETRYABLE_STATUS:
98
+ raise last_exc
99
+ print(f"[WARN] OpenRouter attempt {attempt + 1}/4 failed: {last_exc}")
100
+ if attempt < 3:
101
+ time.sleep(_BACKOFF_BASE ** attempt)
 
 
 
 
 
102
  raise last_exc
103
 
104
 
 
132
 
133
 
134
  def translate_one(text: str, api_key: str, model: str, template: str) -> str:
 
 
135
  prompt = (
136
  template.rstrip()
137
  + f"\n\nTranslate the following into English, following all instructions "
138
  f"above. Output ONLY the translation, no other text.\n\n{text}"
139
  )
140
+ return _call_openrouter(api_key, model, prompt).strip()
 
141
 
142
 
143
  def translate_batch(
 
145
  ) -> list[str] | None:
146
  if not texts:
147
  return []
 
 
148
  prompt = _build_batch_prompt(template, texts, preceding=preceding)
149
  try:
150
+ content = _call_openrouter(api_key, model, prompt)
151
+ return _parse_batch_response(content.strip(), len(texts))
152
  except Exception as e:
153
+ print(f"[WARN] OpenRouter batch translation failed: {e}")
154
  return None
engine/translate.py CHANGED
@@ -1,29 +1,31 @@
1
- """Backend dispatcher: Gemini if an API key is available (user-supplied or
2
- GEMINI_API_KEY env var), otherwise the local CPU model. Callers don't need
3
  to know which backend actually ran."""
4
 
5
  import os
6
 
7
- from . import gemini_backend
8
  from . import local_backend
 
9
  from .prompt import read_prompt
10
 
11
- FALLBACK_CHAIN = gemini_backend.FALLBACK_CHAIN
12
- _BATCH_SIZE = gemini_backend._BATCH_SIZE
 
 
13
 
14
 
15
  def _resolve_key(api_key: str | None) -> str:
16
- return (api_key or "").strip() or os.environ.get("GEMINI_API_KEY", "")
17
 
18
 
19
- def using_gemini(api_key: str | None) -> bool:
20
  return bool(_resolve_key(api_key))
21
 
22
 
23
  def translate_one(text: str, api_key: str | None, model: str) -> str:
24
  key = _resolve_key(api_key)
25
  if key:
26
- return gemini_backend.translate_one(text, key, model, read_prompt())
27
  return local_backend.translate_batch([text])[0]
28
 
29
 
@@ -42,7 +44,7 @@ def translate_segments(
42
  if not seg.get("target", "").strip() and seg.get("source", "").strip()
43
  ]
44
  total = len(translatable)
45
- print(f"[INFO] Translating {total} segment(s) via {'Gemini' if key else 'local CPU model'}.")
46
 
47
  recent: list[str] = []
48
  for batch_start in range(0, total, _BATCH_SIZE):
@@ -57,14 +59,14 @@ def translate_segments(
57
 
58
  if key:
59
  preceding = recent[-3:] if recent else None
60
- translations = gemini_backend.translate_batch(texts, key, model, template, preceding=preceding)
61
  if translations is None:
62
  translations = []
63
  for idx, text in zip(indices, texts):
64
  if stop is not None and stop.is_set():
65
  break
66
  try:
67
- t = gemini_backend.translate_one(text, key, model, template)
68
  except Exception as e:
69
  print(f"[WARN] Segment {idx + 1} failed: {e}")
70
  errors.append(f"Segment {idx + 1}: {e}")
 
1
+ """Backend dispatcher: OpenRouter if an API key is available (user-supplied or
2
+ OPENROUTER_API_KEY env var), otherwise the local CPU model. Callers don't need
3
  to know which backend actually ran."""
4
 
5
  import os
6
 
 
7
  from . import local_backend
8
+ from . import openrouter_backend
9
  from .prompt import read_prompt
10
 
11
+ CURATED_MODELS = openrouter_backend.CURATED_MODELS
12
+ DEFAULT_MODEL = openrouter_backend.DEFAULT_MODEL
13
+ list_model_choices = openrouter_backend.list_model_choices
14
+ _BATCH_SIZE = openrouter_backend._BATCH_SIZE
15
 
16
 
17
  def _resolve_key(api_key: str | None) -> str:
18
+ return (api_key or "").strip() or os.environ.get("OPENROUTER_API_KEY", "")
19
 
20
 
21
+ def using_openrouter(api_key: str | None) -> bool:
22
  return bool(_resolve_key(api_key))
23
 
24
 
25
  def translate_one(text: str, api_key: str | None, model: str) -> str:
26
  key = _resolve_key(api_key)
27
  if key:
28
+ return openrouter_backend.translate_one(text, key, model, read_prompt())
29
  return local_backend.translate_batch([text])[0]
30
 
31
 
 
44
  if not seg.get("target", "").strip() and seg.get("source", "").strip()
45
  ]
46
  total = len(translatable)
47
+ print(f"[INFO] Translating {total} segment(s) via {'OpenRouter' if key else 'local CPU model'}.")
48
 
49
  recent: list[str] = []
50
  for batch_start in range(0, total, _BATCH_SIZE):
 
59
 
60
  if key:
61
  preceding = recent[-3:] if recent else None
62
+ translations = openrouter_backend.translate_batch(texts, key, model, template, preceding=preceding)
63
  if translations is None:
64
  translations = []
65
  for idx, text in zip(indices, texts):
66
  if stop is not None and stop.is_set():
67
  break
68
  try:
69
+ t = openrouter_backend.translate_one(text, key, model, template)
70
  except Exception as e:
71
  print(f"[WARN] Segment {idx + 1} failed: {e}")
72
  errors.append(f"Segment {idx + 1}: {e}")
handlers.py CHANGED
@@ -10,7 +10,6 @@ from pathlib import Path
10
  import gradio as gr
11
 
12
  from engine import (
13
- FALLBACK_CHAIN,
14
  export_to_docx,
15
  export_to_json,
16
  export_to_txt,
@@ -21,7 +20,7 @@ from engine import (
21
  save_prompt,
22
  translate_one as _engine_translate_one,
23
  translate_segments,
24
- using_gemini,
25
  )
26
 
27
  MAX_SLOTS = 25
@@ -249,7 +248,7 @@ def _translate_all(state, api_key, model, *slot_values):
249
  targets = list(slot_values[MAX_SLOTS:])
250
  state = _save_page_edits(state, sources, targets)
251
 
252
- backend = "Gemini" if using_gemini(api_key) else "local CPU model (mlotsawa-ground-base)"
253
 
254
  stop = threading.Event()
255
  result = [None, None]
 
10
  import gradio as gr
11
 
12
  from engine import (
 
13
  export_to_docx,
14
  export_to_json,
15
  export_to_txt,
 
20
  save_prompt,
21
  translate_one as _engine_translate_one,
22
  translate_segments,
23
+ using_openrouter,
24
  )
25
 
26
  MAX_SLOTS = 25
 
248
  targets = list(slot_values[MAX_SLOTS:])
249
  state = _save_page_edits(state, sources, targets)
250
 
251
+ backend = "OpenRouter" if using_openrouter(api_key) else "local CPU model (mlotsawa-ground-base)"
252
 
253
  stop = threading.Event()
254
  result = [None, None]
requirements-dev.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ -r requirements.txt
2
+ pytest
requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
  gradio>=6.9.0
2
  spaces
3
- google-genai
4
  python-dotenv
5
  transformers
6
  torch
 
1
  gradio>=6.9.0
2
  spaces
3
+ requests
4
  python-dotenv
5
  transformers
6
  torch
samples/sample_tibetan.docx ADDED
Binary file (37.1 kB). View file
 
samples/sample_tibetan.pdf ADDED
Binary file (8.41 kB). View file
 
samples/sample_tibetan.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ΰ½¦ΰ½„ΰ½¦ΰΌ‹ΰ½’ΰΎ’ΰΎ±ΰ½¦ΰΌ‹ΰ½†ΰ½Όΰ½¦ΰΌ‹ΰ½‘ΰ½„ΰΌ‹ΰ½šΰ½Όΰ½‚ΰ½¦ΰΌ‹ΰ½€ΰΎ±ΰ½²ΰΌ‹ΰ½˜ΰ½†ΰ½Όΰ½‚ΰΌ‹ΰ½’ΰΎ£ΰ½˜ΰ½¦ΰΌ‹ΰ½£ΰΌ །
2
+ ΰ½–ΰΎ±ΰ½„ΰΌ‹ΰ½†ΰ½΄ΰ½–ΰΌ‹ΰ½–ΰ½’ΰΌ‹ΰ½‘ΰ½΄ΰΌ‹ΰ½–ΰ½‘ΰ½‚ΰΌ‹ΰ½“ΰ½²ΰΌ‹ΰ½¦ΰΎΰΎ±ΰ½–ΰ½¦ΰΌ‹ΰ½¦ΰ½΄ΰΌ‹ΰ½˜ΰ½†ΰ½²ΰΌ །
3
+ ΰ½–ΰ½‘ΰ½‚ΰΌ‹ΰ½‚ΰ½²ΰ½¦ΰΌ‹ΰ½¦ΰΎ¦ΰΎ±ΰ½²ΰ½“ΰΌ‹ΰ½¦ΰ½Όΰ½‚ΰ½¦ΰΌ‹ΰ½–ΰ½‚ΰΎ±ΰ½²ΰ½¦ΰΌ‹ΰ½”ΰ½ ΰ½²ΰΌ‹ΰ½–ΰ½¦ΰ½Όΰ½‘ΰΌ‹ΰ½“ΰ½˜ΰ½¦ΰΌ‹ΰ½€ΰ½²ΰ½¦ΰΌ །
4
+ འགྲོ་ལ་ཕན་ཕྱིདྷ་སངས་དྷྒྱས་འགྲུབ་པདྷ་ཀོག། །
5
+
6
+ ΰ½¦ΰ½Ίΰ½˜ΰ½¦ΰΌ‹ΰ½…ΰ½“ΰΌ‹ΰ½ΰ½˜ΰ½¦ΰΌ‹ΰ½…ΰ½‘ΰΌ‹ΰ½–ΰ½‘ΰ½ΊΰΌ‹ΰ½–ΰΌ‹ΰ½‘ΰ½„ΰΌ‹ΰ½–ΰ½‘ΰ½ΊΰΌ‹ΰ½–ΰ½ ΰ½²ΰΌ‹ΰ½’ΰΎ’ΰΎ±ΰ½΄ΰΌ‹ΰ½‘ΰ½„ΰΌ‹ΰ½£ΰΎ‘ΰ½“ΰΌ‹ΰ½”ΰ½’ΰΌ‹ΰ½‚ΰΎ±ΰ½΄ΰ½’ΰΌ‹ΰ½…ΰ½²ΰ½‚ΰΌ ΰ½¦ΰ½Ίΰ½˜ΰ½¦ΰΌ‹ΰ½…ΰ½“ΰΌ‹ΰ½ΰ½˜ΰ½¦ΰΌ‹ΰ½…ΰ½‘ΰΌ‹ΰ½¦ΰΎ‘ΰ½΄ΰ½‚ΰΌ‹ΰ½–ΰ½¦ΰΎ”ΰ½£ΰΌ‹ΰ½‘ΰ½„ΰΌ‹ΰ½¦ΰΎ‘ΰ½΄ΰ½‚ΰΌ‹ΰ½–ΰ½¦ΰΎ”ΰ½£ΰΌ‹ΰ½‚ΰΎ±ΰ½²ΰΌ‹ΰ½’ΰΎ’ΰΎ±ΰ½΄ΰΌ‹ΰ½‘ΰ½„ΰΌ‹ΰ½–ΰΎ²ΰ½£ΰΌ‹ΰ½–ΰ½’ΰΌ‹ΰ½‚ΰΎ±ΰ½΄ΰ½’ΰΌ‹ΰ½…ΰ½²ΰ½‚ΰΌ ΰ½¦ΰ½Ίΰ½˜ΰ½¦ΰΌ‹ΰ½…ΰ½“ΰΌ‹ΰ½ΰ½˜ΰ½¦ΰΌ‹ΰ½…ΰ½‘ΰΌ‹ΰ½¦ΰΎ‘ΰ½΄ΰ½‚ΰΌ‹ΰ½–ΰ½¦ΰΎ”ΰ½£ΰΌ‹ΰ½˜ΰ½Ίΰ½‘ΰΌ‹ΰ½”ΰ½ ΰ½²ΰΌ‹ΰ½–ΰ½‘ΰ½ΊΰΌ‹ΰ½–ΰΌ‹ΰ½‘ΰ½„ΰΌ‹ΰ½˜ΰ½²ΰΌ‹ΰ½ ΰ½–ΰΎ²ΰ½£ΰΌ‹ΰ½–ΰ½’ΰΌ‹ΰ½‚ΰΎ±ΰ½΄ΰ½’ΰΌ‹ΰ½…ΰ½²ΰ½‚ΰΌ ΰ½¦ΰ½Ίΰ½˜ΰ½¦ΰΌ‹ΰ½…ΰ½“ΰΌ‹ΰ½ΰ½˜ΰ½¦ΰΌ‹ΰ½…ΰ½‘ΰΌ‹ΰ½‰ΰ½ΊΰΌ‹ΰ½’ΰ½²ΰ½„ΰΌ‹ΰ½†ΰ½‚ΰ½¦ΰΌ‹ΰ½¦ΰΎ‘ΰ½„ΰΌ‹ΰ½‚ΰ½‰ΰ½²ΰ½¦ΰΌ‹ΰ½‘ΰ½„ΰΌ‹ΰ½–ΰΎ²ΰ½£ΰΌ‹ΰ½–ΰ½ ΰ½²ΰΌ‹ΰ½–ΰ½ΰ½„ΰΌ‹ΰ½¦ΰΎ™ΰ½Όΰ½˜ΰ½¦ΰΌ‹ΰ½šΰ½‘ΰΌ‹ΰ½˜ΰ½Ίΰ½‘ΰΌ‹ΰ½”ΰΌ‹ΰ½£ΰΌ‹ΰ½‚ΰ½“ΰ½¦ΰΌ‹ΰ½”ΰ½’ΰΌ‹ΰ½‚ΰΎ±ΰ½΄ΰ½’ΰΌ‹ΰ½…ΰ½²ΰ½‚ΰΌ
7
+
8
+ ΰ½–ΰ½¦ΰ½Όΰ½‘ΰΌ‹ΰ½“ΰ½˜ΰ½¦ΰΌ‹ΰ½ ΰ½‘ΰ½²ΰΌ‹ΰ½‘ΰ½²ΰ½¦ΰΌ‹ΰ½ΰ½˜ΰ½¦ΰΌ‹ΰ½…ΰ½‘ΰΌ‹ΰ½‚ΰ½Ÿΰ½²ΰ½‚ΰ½¦ΰΌ‹ΰ½”ΰΌ‹ΰ½‰ΰ½²ΰ½‘ΰΌ །
9
+ ΰ½ΰ½Όΰ½–ΰΌ‹ΰ½“ΰ½¦ΰΌ‹ΰ½‰ΰ½Ίΰ½¦ΰΌ‹ΰ½”ΰ½ ΰ½²ΰΌ‹ΰ½‘ΰ½‚ΰΎ²ΰΌ‹ΰ½’ΰΎ£ΰ½˜ΰ½¦ΰΌ‹ΰ½•ΰ½˜ΰΌ‹ΰ½–ΰΎ±ΰ½¦ΰΌ‹ΰ½ΰ½ΊΰΌ །
10
+ སྐྱེ་དྷྒ་ན་འཆིའི་དྷྦ་ཀློང་འཁྲུགས་པ་དི། །
11
+ ΰ½¦ΰΎ²ΰ½²ΰ½‘ΰΌ‹ΰ½”ΰ½ ΰ½²ΰΌ‹ΰ½˜ΰ½šΰ½ΌΰΌ‹ΰ½£ΰ½¦ΰΌ‹ΰ½ ΰ½‚ΰΎ²ΰ½ΌΰΌ‹ΰ½–ΰΌ‹ΰ½¦ΰΎ’ΰΎ²ΰ½Όΰ½£ΰΌ‹ΰ½–ΰ½’ΰΌ‹ΰ½€ΰ½Όΰ½‚ΰΌ །
tests/conftest.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ ROOT = Path(__file__).parent.parent
5
+ if str(ROOT) not in sys.path:
6
+ sys.path.insert(0, str(ROOT))
tests/test_app.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke test: the Gradio Blocks graph wires up without error. This is what
2
+ would have caught a stale gr.Dropdown choices/value reference or a mismatched
3
+ inputs/outputs list length after the Gemini -> OpenRouter rename."""
4
+
5
+ from unittest.mock import patch
6
+
7
+ from app import build_app
8
+
9
+
10
+ def test_build_app_does_not_raise():
11
+ with patch("app.list_model_choices", return_value=["model/a", "model/b"]), \
12
+ patch("app.DEFAULT_MODEL", "model/a"):
13
+ demo = build_app()
14
+ assert demo is not None
15
+
16
+
17
+ def test_build_app_falls_back_gracefully_if_model_fetch_fails():
18
+ with patch("app.list_model_choices", return_value=["model/a"]), \
19
+ patch("app.DEFAULT_MODEL", "model/a"):
20
+ demo = build_app()
21
+ assert demo is not None
tests/test_chunking.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from engine.chunking import split_into_segments
2
+
3
+
4
+ def test_blank_line_separates_paragraphs():
5
+ text = "First paragraph.\n\nSecond paragraph."
6
+ assert split_into_segments(text, max_chars=400) == ["First paragraph.", "Second paragraph."]
7
+
8
+
9
+ def test_multiple_blank_lines_still_one_break():
10
+ text = "First.\n\n\n\nSecond."
11
+ assert split_into_segments(text, max_chars=400) == ["First.", "Second."]
12
+
13
+
14
+ def test_internal_whitespace_is_collapsed():
15
+ text = "word1 word2\nword3"
16
+ assert split_into_segments(text, max_chars=400) == ["word1 word2 word3"]
17
+
18
+
19
+ def test_empty_and_whitespace_only_input():
20
+ assert split_into_segments("", max_chars=400) == []
21
+ assert split_into_segments(" \n\n \n", max_chars=400) == []
22
+
23
+
24
+ def test_short_paragraph_stays_one_segment():
25
+ text = "ΰ½¦ΰ½„ΰ½¦ΰΌ‹ΰ½’ΰΎ’ΰΎ±ΰ½¦ΰΌ‹ΰ½†ΰ½Όΰ½¦ΰΌ‹ΰ½‘ΰ½„ΰΌ‹ΰ½šΰ½Όΰ½‚ΰ½¦ΰΌ‹ΰ½€ΰΎ±ΰ½²ΰΌ‹ΰ½˜ΰ½†ΰ½Όΰ½‚ΰΌ‹ΰ½’ΰΎ£ΰ½˜ΰ½¦ΰΌ‹ΰ½£ΰΌ །"
26
+ assert split_into_segments(text, max_chars=400) == [text]
27
+
28
+
29
+ def test_long_paragraph_splits_on_shad_marks():
30
+ sentence = "བདེ་བ་དང་ལྑན་པདྷ་གྱུདྷ་ཅིག" * 1 # one shad-terminated "sentence" below
31
+ long_text = "།".join(["ΰ½€" * 30 for _ in range(20)]) + "།"
32
+ segments = split_into_segments(long_text, max_chars=50)
33
+ assert len(segments) > 1
34
+ assert all(len(s) <= 60 for s in segments) # allows small overhead from joining
35
+ assert "".join(segments).replace(" ", "") == long_text.replace(" ", "")
36
+
37
+
38
+ def test_shad_split_regex_keeps_shad_with_preceding_text():
39
+ text = "ΰ½€ΰ½€ΰ½€ΰΌΰ½ΰ½ΰ½ΰΌŽ"
40
+ from engine.chunking import _SHAD_SPLIT_RE
41
+ pieces = [p for p in _SHAD_SPLIT_RE.split(text) if p]
42
+ assert pieces == ["ཀཀཀ།", "ཁཁཁ༎"]
43
+
44
+
45
+ def test_single_word_longer_than_max_chars_falls_back_to_word_chunking():
46
+ text = " ".join(["word"] * 100) # no shad marks at all, one huge "sentence"
47
+ segments = split_into_segments(text, max_chars=30)
48
+ assert len(segments) > 1
49
+ for seg in segments:
50
+ assert len(seg) <= 30 or " " not in seg # a single overlong word is allowed through
51
+
52
+
53
+ def test_max_chars_boundary_exact_length_not_split():
54
+ text = "a" * 400
55
+ assert split_into_segments(text, max_chars=400) == [text]
56
+
57
+
58
+ def test_max_chars_boundary_one_over_length_is_split():
59
+ text = ("a" * 200) + "། " + ("b" * 200)
60
+ segments = split_into_segments(text, max_chars=400)
61
+ assert len(segments) >= 1 # 401 chars total but splits cleanly on the shad mark
tests/test_formats.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from engine.formats import (
7
+ export_to_docx,
8
+ export_to_json,
9
+ export_to_txt,
10
+ load_source_file,
11
+ parse_json,
12
+ )
13
+
14
+ TIBETAN = "ΰ½¦ΰ½„ΰ½¦ΰΌ‹ΰ½’ΰΎ’ΰΎ±ΰ½¦ΰΌ‹ΰ½†ΰ½Όΰ½¦ΰΌ‹ΰ½‘ΰ½„ΰΌ‹ΰ½šΰ½Όΰ½‚ΰ½¦ΰΌ‹ΰ½€ΰΎ±ΰ½²ΰΌ‹ΰ½˜ΰ½†ΰ½Όΰ½‚ΰΌ‹ΰ½’ΰΎ£ΰ½˜ΰ½¦ΰΌ‹ΰ½£ΰΌ"
15
+
16
+
17
+ # ── load_source_file ──────────────────────────────────────────────────────
18
+
19
+ def test_load_txt(tmp_path):
20
+ path = tmp_path / "in.txt"
21
+ path.write_text(f"{TIBETAN}\n\nSecond paragraph.", encoding="utf-8")
22
+ segments = load_source_file(str(path))
23
+ assert segments == [
24
+ {"source": TIBETAN, "target": ""},
25
+ {"source": "Second paragraph.", "target": ""},
26
+ ]
27
+
28
+
29
+ def test_load_docx(tmp_path):
30
+ docx = pytest.importorskip("docx")
31
+ path = tmp_path / "in.docx"
32
+ doc = docx.Document()
33
+ doc.add_paragraph(TIBETAN)
34
+ doc.add_paragraph("Second paragraph.")
35
+ doc.add_paragraph("") # blank paragraphs should be dropped, not turned into segments
36
+ doc.save(str(path))
37
+
38
+ segments = load_source_file(str(path))
39
+ assert segments == [
40
+ {"source": TIBETAN, "target": ""},
41
+ {"source": "Second paragraph.", "target": ""},
42
+ ]
43
+
44
+
45
+ def test_load_pdf(tmp_path):
46
+ pytest.importorskip("pypdf")
47
+ reportlab_canvas = pytest.importorskip("reportlab.pdfgen.canvas")
48
+ from reportlab.lib.pagesizes import letter
49
+
50
+ path = tmp_path / "in.pdf"
51
+ c = reportlab_canvas.Canvas(str(path), pagesize=letter)
52
+ c.drawString(72, 700, "Hello world.")
53
+ c.save()
54
+
55
+ segments = load_source_file(str(path))
56
+ assert len(segments) == 1
57
+ assert "Hello world." in segments[0]["source"]
58
+ assert segments[0]["target"] == ""
59
+
60
+
61
+ def test_load_unsupported_extension_raises(tmp_path):
62
+ path = tmp_path / "in.rtf"
63
+ path.write_text("whatever", encoding="utf-8")
64
+ with pytest.raises(ValueError, match="Unsupported file type"):
65
+ load_source_file(str(path))
66
+
67
+
68
+ def test_load_empty_file_raises(tmp_path):
69
+ path = tmp_path / "empty.txt"
70
+ path.write_text(" \n\n ", encoding="utf-8")
71
+ with pytest.raises(ValueError, match="No text could be extracted"):
72
+ load_source_file(str(path))
73
+
74
+
75
+ def test_load_respects_max_chars(tmp_path):
76
+ path = tmp_path / "in.txt"
77
+ long_text = ("a" * 50 + "ΰ₯€ ") * 10
78
+ path.write_text(long_text, encoding="utf-8")
79
+ segments = load_source_file(str(path), max_chars=100)
80
+ assert len(segments) > 1
81
+ assert all(len(s["source"]) <= 110 for s in segments)
82
+
83
+
84
+ # ── the committed sample files (samples/sample_tibetan.*) ─────────────────
85
+
86
+ SAMPLES_DIR = Path(__file__).parent.parent / "samples"
87
+
88
+
89
+ @pytest.mark.parametrize("ext", ["txt", "docx", "pdf"])
90
+ def test_sample_files_load_successfully(ext):
91
+ path = SAMPLES_DIR / f"sample_tibetan.{ext}"
92
+ segments = load_source_file(str(path))
93
+ assert len(segments) > 0
94
+ assert all(seg["target"] == "" for seg in segments)
95
+ combined = "".join(seg["source"] for seg in segments)
96
+ assert "སངས་དྷྒྱས" in combined # "Buddha" appears in the refuge verse
97
+
98
+
99
+ # ── parse_json ─────────────────────────────────────────────────────────────
100
+
101
+ def test_parse_json_valid_list():
102
+ content = json.dumps([{"source": "src1", "target": "tgt1"}, {"source": "src2"}])
103
+ segments = parse_json(content)
104
+ assert segments == [
105
+ {"source": "src1", "target": "tgt1"},
106
+ {"source": "src2", "target": ""},
107
+ ]
108
+
109
+
110
+ def test_parse_json_accepts_bytes():
111
+ content = json.dumps([{"source": "src1", "target": "tgt1"}]).encode("utf-8")
112
+ segments = parse_json(content)
113
+ assert segments == [{"source": "src1", "target": "tgt1"}]
114
+
115
+
116
+ def test_parse_json_rejects_non_list():
117
+ with pytest.raises(ValueError, match="non-empty list"):
118
+ parse_json(json.dumps({"source": "a"}))
119
+
120
+
121
+ def test_parse_json_rejects_empty_list():
122
+ with pytest.raises(ValueError, match="non-empty list"):
123
+ parse_json(json.dumps([]))
124
+
125
+
126
+ def test_parse_json_rejects_missing_source():
127
+ with pytest.raises(ValueError, match="Item 0 is missing 'source'"):
128
+ parse_json(json.dumps([{"target": "only a target"}]))
129
+
130
+
131
+ def test_parse_json_rejects_non_dict_item():
132
+ with pytest.raises(ValueError, match="Item 1 is missing 'source'"):
133
+ parse_json(json.dumps([{"source": "ok"}, "not a dict"]))
134
+
135
+
136
+ # ── export_to_txt / export_to_docx / export_to_json ───────────────────────
137
+
138
+ def test_export_to_txt_joins_nonempty_targets():
139
+ segments = [{"source": "s1", "target": "t1"}, {"source": "s2", "target": ""}, {"source": "s3", "target": "t3"}]
140
+ assert export_to_txt(segments, "target") == "t1\n\nt3\n"
141
+
142
+
143
+ def test_export_to_txt_all_empty_returns_empty_string():
144
+ segments = [{"source": "s1", "target": ""}, {"source": "s2", "target": " "}]
145
+ assert export_to_txt(segments, "target") == ""
146
+
147
+
148
+ def test_export_to_txt_source_field():
149
+ segments = [{"source": "s1", "target": "t1"}, {"source": "s2", "target": "t2"}]
150
+ assert export_to_txt(segments, "source") == "s1\n\ns2\n"
151
+
152
+
153
+ def test_export_to_docx_roundtrips_through_load(tmp_path):
154
+ docx = pytest.importorskip("docx")
155
+ segments = [{"source": "s1", "target": "First translation."}, {"source": "s2", "target": "Second translation."}]
156
+ data = export_to_docx(segments, "target")
157
+ assert isinstance(data, bytes) and len(data) > 0
158
+
159
+ out_path = tmp_path / "out.docx"
160
+ out_path.write_bytes(data)
161
+ doc = docx.Document(str(out_path))
162
+ texts = [p.text for p in doc.paragraphs if p.text.strip()]
163
+ assert texts == ["First translation.", "Second translation."]
164
+
165
+
166
+ def test_export_to_docx_skips_empty_targets():
167
+ segments = [{"source": "s1", "target": ""}, {"source": "s2", "target": "kept"}]
168
+ data = export_to_docx(segments, "target")
169
+ docx = pytest.importorskip("docx")
170
+ import io
171
+ doc = docx.Document(io.BytesIO(data))
172
+ texts = [p.text for p in doc.paragraphs if p.text.strip()]
173
+ assert texts == ["kept"]
174
+
175
+
176
+ def test_export_to_json_roundtrip():
177
+ segments = [{"source": "s1", "target": "t1"}, {"source": "s2", "target": ""}]
178
+ text = export_to_json(segments)
179
+ assert json.loads(text) == segments
180
+
181
+
182
+ def test_export_to_json_preserves_unicode_without_escaping():
183
+ segments = [{"source": TIBETAN, "target": ""}]
184
+ text = export_to_json(segments)
185
+ assert TIBETAN in text # ensure_ascii=False
tests/test_handlers.py ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import time
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+
8
+ import handlers as h
9
+
10
+ SAMPLES_DIR = Path(__file__).parent.parent / "samples"
11
+
12
+
13
+ def _segments(*sources):
14
+ return [{"source": s, "target": ""} for s in sources]
15
+
16
+
17
+ # ── state / pagination ──────────────────────────────────────────────────────
18
+
19
+ def test_make_state_defaults():
20
+ state = h._make_state()
21
+ assert state == {"page": 0, "segments": [], "has_translation": False, "label": "output"}
22
+
23
+
24
+ def test_save_page_edits_writes_current_page_slots():
25
+ state = h._make_state()
26
+ state["segments"] = _segments("a", "b", "c")
27
+ state["page"] = 0
28
+ sources = ["A", "B", "C"] + [""] * (h.MAX_SLOTS - 3)
29
+ targets = ["ta", "tb", "tc"] + [""] * (h.MAX_SLOTS - 3)
30
+ state = h._save_page_edits(state, sources, targets)
31
+ assert state["segments"] == [
32
+ {"source": "A", "target": "ta"},
33
+ {"source": "B", "target": "tb"},
34
+ {"source": "C", "target": "tc"},
35
+ ]
36
+
37
+
38
+ def test_save_page_edits_respects_page_offset():
39
+ state = h._make_state()
40
+ state["segments"] = _segments(*[f"s{i}" for i in range(h.MAX_SLOTS + 2)])
41
+ state["page"] = 1
42
+ sources = ["edited0", "edited1"] + [""] * (h.MAX_SLOTS - 2)
43
+ targets = ["t0", "t1"] + [""] * (h.MAX_SLOTS - 2)
44
+ state = h._save_page_edits(state, sources, targets)
45
+ assert state["segments"][h.MAX_SLOTS]["source"] == "edited0"
46
+ assert state["segments"][h.MAX_SLOTS + 1]["source"] == "edited1"
47
+ assert state["segments"][0]["source"] == "s0" # page 0 untouched
48
+
49
+
50
+ def test_load_page_empty_state_hides_everything():
51
+ state = h._make_state()
52
+ result = h._load_page(state)
53
+ _, nav, prev, next_, *rest = result
54
+ assert nav["value"] == ""
55
+ assert prev["interactive"] is False
56
+ assert next_["interactive"] is False
57
+ groups = rest[:h.MAX_SLOTS]
58
+ assert all(g["visible"] is False for g in groups)
59
+
60
+
61
+ def test_load_page_shows_correct_slot_count_and_nav_text():
62
+ state = h._make_state()
63
+ state["segments"] = _segments("a", "b", "c")
64
+ result = h._load_page(state)
65
+ _, nav, prev, next_, *rest = result
66
+ assert "All 3 segments" in nav["value"]
67
+ assert prev["interactive"] is False
68
+ assert next_["interactive"] is False
69
+ groups = rest[:h.MAX_SLOTS]
70
+ sources = rest[h.MAX_SLOTS:2 * h.MAX_SLOTS]
71
+ assert [g["visible"] for g in groups[:3]] == [True, True, True]
72
+ assert all(g["visible"] is False for g in groups[3:])
73
+ assert [s["value"] for s in sources[:3]] == ["a", "b", "c"]
74
+
75
+
76
+ def test_load_page_pagination_across_multiple_pages():
77
+ total = h.MAX_SLOTS + 5
78
+ state = h._make_state()
79
+ state["segments"] = _segments(*[f"s{i}" for i in range(total)])
80
+ state["page"] = 0
81
+
82
+ result = h._load_page(state)
83
+ _, nav, prev, next_, *_rest = result
84
+ assert "Page 1 of 2" in nav["value"]
85
+ assert prev["interactive"] is False
86
+ assert next_["interactive"] is True
87
+
88
+ state["page"] = 1
89
+ result = h._load_page(state)
90
+ new_state, nav, prev, next_, *rest = result
91
+ assert "Page 2 of 2" in nav["value"]
92
+ assert prev["interactive"] is True
93
+ assert next_["interactive"] is False
94
+ groups = rest[:h.MAX_SLOTS]
95
+ assert [g["visible"] for g in groups[:5]] == [True] * 5
96
+ assert all(g["visible"] is False for g in groups[5:])
97
+
98
+
99
+ def test_load_page_clamps_page_when_segments_shrank():
100
+ state = h._make_state()
101
+ state["segments"] = _segments("a")
102
+ state["page"] = 5 # stale page index from before segments were replaced
103
+ new_state, *_ = h._load_page(state)
104
+ assert new_state["page"] == 0
105
+
106
+
107
+ def test_handle_prev_and_next_navigate_and_save(monkeypatch):
108
+ total = h.MAX_SLOTS + 3
109
+ state = h._make_state()
110
+ state["segments"] = _segments(*[f"s{i}" for i in range(total)])
111
+ state["page"] = 0
112
+ slot_values = [""] * h.MAX_SLOTS + [""] * h.MAX_SLOTS
113
+
114
+ result = h._handle_next(state, *slot_values)
115
+ new_state = result[0]
116
+ assert new_state["page"] == 1
117
+
118
+ result = h._handle_prev(new_state, *slot_values)
119
+ new_state = result[0]
120
+ assert new_state["page"] == 0
121
+
122
+
123
+ def test_handle_next_does_not_advance_past_last_page():
124
+ state = h._make_state()
125
+ state["segments"] = _segments("a", "b")
126
+ state["page"] = 0
127
+ slot_values = [""] * h.MAX_SLOTS * 2
128
+ result = h._handle_next(state, *slot_values)
129
+ assert result[0]["page"] == 0
130
+
131
+
132
+ def test_handle_prev_does_not_go_below_zero():
133
+ state = h._make_state()
134
+ state["segments"] = _segments("a", "b")
135
+ state["page"] = 0
136
+ slot_values = [""] * h.MAX_SLOTS * 2
137
+ result = h._handle_prev(state, *slot_values)
138
+ assert result[0]["page"] == 0
139
+
140
+
141
+ # ── file loading ─────────────────────────────────────────────────────────
142
+
143
+ def test_load_source_none_hides_editor():
144
+ state = h._make_state()
145
+ result = h._load_source(None, state)
146
+ assert result[-1]["visible"] is False
147
+
148
+
149
+ def test_load_source_txt_populates_segments():
150
+ state = h._make_state()
151
+ path = str(SAMPLES_DIR / "sample_tibetan.txt")
152
+ result = h._load_source(path, state)
153
+ new_state = result[0]
154
+ assert len(new_state["segments"]) > 0
155
+ assert new_state["label"] == "sample_tibetan"
156
+ assert new_state["has_translation"] is False
157
+ assert result[-1]["visible"] is True
158
+
159
+
160
+ def test_load_source_bad_file_hides_editor_and_does_not_raise(tmp_path):
161
+ bad_path = tmp_path / "bad.rtf"
162
+ bad_path.write_text("nope", encoding="utf-8")
163
+ state = h._make_state()
164
+ result = h._load_source(str(bad_path), state)
165
+ assert result[-1]["visible"] is False
166
+ assert state["segments"] == []
167
+
168
+
169
+ def test_load_resume_json_merges_targets_by_position():
170
+ state = h._make_state()
171
+ state["segments"] = _segments("a", "b", "c")
172
+ resume_content = json.dumps([
173
+ {"source": "a", "target": "A"},
174
+ {"source": "b", "target": ""},
175
+ {"source": "c", "target": "C"},
176
+ ])
177
+ import tempfile
178
+ with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f:
179
+ f.write(resume_content)
180
+ path = f.name
181
+
182
+ h._load_resume_json(path, state)
183
+ assert [s["target"] for s in state["segments"]] == ["A", "", "C"]
184
+ assert state["has_translation"] is True
185
+
186
+
187
+ def test_load_resume_json_no_file_or_no_segments_is_noop():
188
+ state = h._make_state()
189
+ result = h._load_resume_json(None, state)
190
+ assert result[0] == state
191
+
192
+ state2 = h._make_state()
193
+ state2["segments"] = []
194
+ result = h._load_resume_json("irrelevant.json", state2)
195
+ assert result[0]["segments"] == []
196
+
197
+
198
+ # ── downloads ────────────────────────────────────────────────────────────
199
+
200
+ def test_make_downloads_writes_expected_files(tmp_path, monkeypatch):
201
+ monkeypatch.setattr(h.tempfile, "gettempdir", lambda: str(tmp_path))
202
+ segments = [{"source": "src1", "target": "tgt1"}]
203
+ paths = h._make_downloads(segments, "mylabel")
204
+ assert paths["txt"] and Path(paths["txt"]).read_text(encoding="utf-8") == "tgt1\n"
205
+ assert paths["docx"] and Path(paths["docx"]).exists()
206
+ assert json.loads(Path(paths["json"]).read_text(encoding="utf-8")) == segments
207
+ assert paths["source_txt"] and Path(paths["source_txt"]).name == "mylabel_source.txt"
208
+
209
+
210
+ def test_make_downloads_omits_empty_content():
211
+ segments = [{"source": "", "target": ""}]
212
+ paths = h._make_downloads(segments, "mylabel")
213
+ assert paths["txt"] is None
214
+ assert paths["source_txt"] is None
215
+
216
+
217
+ def test_handle_download_click_lists_available_formats(tmp_path, monkeypatch):
218
+ monkeypatch.setattr(h.tempfile, "gettempdir", lambda: str(tmp_path))
219
+ state = h._make_state()
220
+ state["segments"] = [{"source": "src", "target": "tgt"}]
221
+ # _handle_download_click saves current textbox contents before exporting, so the
222
+ # slot values passed in must reflect the segment we just seeded above.
223
+ sources = ["src"] + [""] * (h.MAX_SLOTS - 1)
224
+ targets = ["tgt"] + [""] * (h.MAX_SLOTS - 1)
225
+ slot_values = sources + targets
226
+ new_state, checklist, get_files_btn, download_zip = h._handle_download_click(state, *slot_values)
227
+ assert "Translation (TXT)" in checklist["choices"]
228
+ assert "Translation (DOCX)" in checklist["choices"]
229
+ assert "JSON (for resume)" in checklist["choices"]
230
+ assert "Source text (TXT)" in checklist["choices"]
231
+ assert get_files_btn["visible"] is True
232
+ assert download_zip["visible"] is False
233
+
234
+
235
+ def test_handle_get_files_builds_zip_with_selected_formats(tmp_path, monkeypatch):
236
+ monkeypatch.setattr(h.tempfile, "gettempdir", lambda: str(tmp_path))
237
+ state = h._make_state()
238
+ state["segments"] = [{"source": "src", "target": "tgt"}]
239
+ state["label"] = "lbl"
240
+
241
+ zip_update, checklist_update, btn_update = h._handle_get_files(state, ["Translation (TXT)"])
242
+ assert zip_update["visible"] is True
243
+ zip_path = Path(zip_update["value"])
244
+ assert zip_path.exists()
245
+
246
+ import zipfile
247
+ with zipfile.ZipFile(zip_path) as zf:
248
+ assert zf.namelist() == ["lbl.txt"]
249
+
250
+
251
+ def test_handle_get_files_no_selection_hides_download():
252
+ state = h._make_state()
253
+ state["segments"] = [{"source": "src", "target": "tgt"}]
254
+ zip_update, checklist_update, btn_update = h._handle_get_files(state, [])
255
+ assert zip_update["visible"] is False
256
+
257
+
258
+ def test_handle_save_persists_edits_and_shows_status():
259
+ state = h._make_state()
260
+ state["segments"] = _segments("a")
261
+ slot_values = ["edited"] + [""] * (h.MAX_SLOTS - 1) + ["translated"] + [""] * (h.MAX_SLOTS - 1)
262
+ new_state, status = h._handle_save(state, *slot_values)
263
+ assert new_state["segments"][0] == {"source": "edited", "target": "translated"}
264
+ assert status["value"] == "Saved."
265
+
266
+
267
+ # ── translation handlers ───────────────────���──────────────────────────────
268
+
269
+ def test_translate_one_updates_state_and_returns_text():
270
+ state = h._make_state()
271
+ state["segments"] = _segments("source text")
272
+ state["page"] = 0
273
+ with patch.object(h, "_engine_translate_one", return_value="translated!") as mock_translate:
274
+ new_state, update = h._translate_one(state, 0, "source text", "api-key", "model-x")
275
+ assert update["value"] == "translated!"
276
+ assert new_state["segments"][0]["target"] == "translated!"
277
+ mock_translate.assert_called_once_with("source text", "api-key", "model-x")
278
+
279
+
280
+ def test_translate_one_records_error_in_target():
281
+ state = h._make_state()
282
+ state["segments"] = _segments("source text")
283
+ with patch.object(h, "_engine_translate_one", side_effect=RuntimeError("network down")):
284
+ new_state, update = h._translate_one(state, 0, "source text", "api-key", "model-x")
285
+ assert "Translation error" in update["value"]
286
+ assert "network down" in new_state["segments"][0]["target"]
287
+
288
+
289
+ def test_translate_one_out_of_range_slot_does_not_crash():
290
+ state = h._make_state()
291
+ state["segments"] = _segments("only one")
292
+ state["page"] = 0
293
+ with patch.object(h, "_engine_translate_one", return_value="x"):
294
+ new_state, update = h._translate_one(state, 5, "whatever", None, "model")
295
+ assert update["value"] == "x"
296
+ assert new_state["segments"] == _segments("only one") # untouched, index out of range
297
+
298
+
299
+ def test_translate_all_reports_completion_status():
300
+ state = h._make_state()
301
+ state["segments"] = _segments("a", "b")
302
+ # _translate_all saves current textbox contents before translating, so the slot
303
+ # values passed in must reflect the segments we just seeded above.
304
+ sources = ["a", "b"] + [""] * (h.MAX_SLOTS - 2)
305
+ targets = [""] * h.MAX_SLOTS
306
+ slot_values = sources + targets
307
+
308
+ def fake_translate_segments(segments, api_key, model, progress_callback=None, stop=None):
309
+ for seg in segments:
310
+ seg["target"] = seg["source"].upper()
311
+ return segments, []
312
+
313
+ with patch.object(h, "translate_segments", side_effect=fake_translate_segments), \
314
+ patch.object(h, "using_openrouter", return_value=True):
315
+ results = list(h._translate_all(state, "api-key", "model-x", *slot_values))
316
+
317
+ final = results[-1]
318
+ status = final[-1]
319
+ assert "Translated 2 segment(s) via OpenRouter." in status["value"]
320
+ final_state = final[0]
321
+ assert [s["target"] for s in final_state["segments"]] == ["A", "B"]
322
+
323
+
324
+ def test_translate_all_reports_errors_in_status():
325
+ state = h._make_state()
326
+ state["segments"] = _segments("a", "b")
327
+ slot_values = [""] * h.MAX_SLOTS * 2
328
+
329
+ def fake_translate_segments(segments, api_key, model, progress_callback=None, stop=None):
330
+ return segments, ["Segment 1: boom"]
331
+
332
+ with patch.object(h, "translate_segments", side_effect=fake_translate_segments), \
333
+ patch.object(h, "using_openrouter", return_value=False):
334
+ results = list(h._translate_all(state, None, "model-x", *slot_values))
335
+
336
+ status = results[-1][-1]
337
+ assert "local CPU model" in status["value"]
338
+ assert "1 error(s)" in status["value"]
339
+
340
+
341
+ def test_translate_all_cancel_sets_stop_event():
342
+ state = h._make_state()
343
+ state["segments"] = _segments(*[f"s{i}" for i in range(3)])
344
+ slot_values = [""] * h.MAX_SLOTS * 2
345
+
346
+ stop_seen = {}
347
+
348
+ def fake_translate_segments(segments, api_key, model, progress_callback=None, stop=None):
349
+ stop_seen["stop"] = stop
350
+ time.sleep(0.05)
351
+ return segments, []
352
+
353
+ with patch.object(h, "translate_segments", side_effect=fake_translate_segments):
354
+ gen = h._translate_all(state, "key", "model", *slot_values)
355
+ next(gen) # drive it far enough to start the background thread
356
+ for _ in gen:
357
+ pass
358
+
359
+ assert stop_seen["stop"] is not None
360
+ assert not stop_seen["stop"].is_set() # ran to completion, was never told to stop
361
+
362
+
363
+ # ── prompt management ──────────────────────────────────────────────────────
364
+
365
+ def test_read_save_reset_prompt_roundtrip(monkeypatch, tmp_path):
366
+ import engine.prompt as prompt_module
367
+ prompt_path = tmp_path / "translation_prompt.txt"
368
+ prompt_path.write_text("default", encoding="utf-8")
369
+ monkeypatch.setattr(prompt_module, "PROMPT_PATH", prompt_path)
370
+ monkeypatch.setattr(prompt_module, "_DEFAULT_PROMPT", "default")
371
+
372
+ assert h._read_prompt() == "default"
373
+
374
+ status = h._save_prompt("edited")
375
+ assert status["value"] == "Prompt saved."
376
+ assert h._read_prompt() == "edited"
377
+
378
+ box_update, status_update = h._reset_prompt()
379
+ assert box_update["value"] == "default"
380
+ assert status_update["value"] == "Prompt reset to default."
tests/test_local_backend.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for engine/local_backend.py, stubbing out torch/transformers so this
2
+ suite doesn't require installing the heavy real dependencies (torch is a
3
+ multi-GB download and isn't needed to verify this module's own glue logic:
4
+ prompt prefixing, device selection, and decode handling)."""
5
+
6
+ import sys
7
+ import types
8
+ from unittest.mock import MagicMock
9
+
10
+ import pytest
11
+
12
+
13
+ @pytest.fixture
14
+ def stubbed_torch_and_transformers(monkeypatch):
15
+ fake_torch = types.ModuleType("torch")
16
+ fake_torch.cuda = types.SimpleNamespace(is_available=lambda: False)
17
+
18
+ class _NoGrad:
19
+ def __enter__(self):
20
+ return None
21
+
22
+ def __exit__(self, *a):
23
+ return False
24
+
25
+ fake_torch.no_grad = _NoGrad
26
+ monkeypatch.setitem(sys.modules, "torch", fake_torch)
27
+
28
+ fake_transformers = types.ModuleType("transformers")
29
+ fake_transformers.AutoModelForSeq2SeqLM = MagicMock()
30
+ fake_transformers.AutoTokenizer = MagicMock()
31
+ monkeypatch.setitem(sys.modules, "transformers", fake_transformers)
32
+
33
+ return fake_torch, fake_transformers
34
+
35
+
36
+ @pytest.fixture(autouse=True)
37
+ def reset_model_cache(monkeypatch):
38
+ import engine.local_backend as lb
39
+ monkeypatch.setattr(lb, "_model", None)
40
+ monkeypatch.setattr(lb, "_tokenizer", None)
41
+ yield
42
+
43
+
44
+ def _make_fake_model_and_tokenizer(decoded_outputs):
45
+ tokenizer = MagicMock()
46
+ encoded = MagicMock()
47
+ encoded.to.return_value = encoded
48
+ tokenizer.return_value = encoded
49
+ tokenizer.decode.side_effect = decoded_outputs
50
+
51
+ model = MagicMock()
52
+ model.to.return_value = model
53
+ model.generate.return_value = list(range(len(decoded_outputs))) # dummy token id "rows"
54
+
55
+ return model, tokenizer
56
+
57
+
58
+ def test_translate_batch_empty_list_short_circuits(stubbed_torch_and_transformers):
59
+ import engine.local_backend as lb
60
+ assert lb.translate_batch([]) == []
61
+
62
+
63
+ def test_translate_batch_prefixes_and_decodes(stubbed_torch_and_transformers, monkeypatch):
64
+ import engine.local_backend as lb
65
+
66
+ model, tokenizer = _make_fake_model_and_tokenizer(["translation one", "translation two"])
67
+ monkeypatch.setattr(lb, "_load", lambda: (model, tokenizer))
68
+
69
+ result = lb.translate_batch(["first source", "second source"])
70
+
71
+ assert result == ["translation one", "translation two"]
72
+ called_texts = tokenizer.call_args[0][0]
73
+ assert called_texts == [
74
+ "translate Tibetan to English: first source",
75
+ "translate Tibetan to English: second source",
76
+ ]
77
+
78
+
79
+ def test_translate_batch_uses_generation_settings_from_docstring(stubbed_torch_and_transformers, monkeypatch):
80
+ import engine.local_backend as lb
81
+
82
+ model, tokenizer = _make_fake_model_and_tokenizer(["out"])
83
+ monkeypatch.setattr(lb, "_load", lambda: (model, tokenizer))
84
+
85
+ lb.translate_batch(["text"])
86
+
87
+ _, kwargs = model.generate.call_args
88
+ assert kwargs["max_length"] == 300
89
+ assert kwargs["num_beams"] == 4
90
+ assert kwargs["early_stopping"] is True
91
+
92
+
93
+ def test_translate_batch_uses_cpu_when_no_cuda(stubbed_torch_and_transformers, monkeypatch):
94
+ import engine.local_backend as lb
95
+
96
+ model, tokenizer = _make_fake_model_and_tokenizer(["out"])
97
+ monkeypatch.setattr(lb, "_load", lambda: (model, tokenizer))
98
+
99
+ lb.translate_batch(["text"])
100
+
101
+ model.to.assert_called_with("cpu")
102
+
103
+
104
+ def test_translate_batch_uses_cuda_when_available(stubbed_torch_and_transformers, monkeypatch):
105
+ import engine.local_backend as lb
106
+
107
+ stubbed_torch_and_transformers[0].cuda.is_available = lambda: True
108
+ model, tokenizer = _make_fake_model_and_tokenizer(["out"])
109
+ monkeypatch.setattr(lb, "_load", lambda: (model, tokenizer))
110
+
111
+ lb.translate_batch(["text"])
112
+
113
+ model.to.assert_called_with("cuda")
114
+
115
+
116
+ def test_load_caches_model_and_tokenizer_across_calls(stubbed_torch_and_transformers):
117
+ import engine.local_backend as lb
118
+
119
+ fake_model = MagicMock()
120
+ fake_tokenizer = MagicMock()
121
+
122
+ with pytest.MonkeyPatch.context() as mp:
123
+ mp.setattr(
124
+ sys.modules["transformers"], "AutoModelForSeq2SeqLM",
125
+ MagicMock(from_pretrained=MagicMock(return_value=fake_model)),
126
+ )
127
+ mp.setattr(
128
+ sys.modules["transformers"], "AutoTokenizer",
129
+ MagicMock(from_pretrained=MagicMock(return_value=fake_tokenizer)),
130
+ )
131
+ model1, tok1 = lb._load()
132
+ model2, tok2 = lb._load()
133
+
134
+ assert model1 is model2
135
+ assert tok1 is tok2
136
+ fake_model.eval.assert_called_once()
tests/test_openrouter_backend.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from unittest.mock import MagicMock, patch
2
+
3
+ import pytest
4
+ import requests
5
+
6
+ from engine import openrouter_backend as ob
7
+
8
+
9
+ # ── prompt building / response parsing (pure functions) ────────────────────
10
+
11
+ def test_build_batch_prompt_numbers_lines_in_order():
12
+ prompt = ob._build_batch_prompt("TEMPLATE", ["first", "second", "third"])
13
+ assert "1. first" in prompt
14
+ assert "2. second" in prompt
15
+ assert "3. third" in prompt
16
+ assert prompt.startswith("TEMPLATE")
17
+
18
+
19
+ def test_build_batch_prompt_includes_preceding_context_when_given():
20
+ prompt = ob._build_batch_prompt("TEMPLATE", ["text"], preceding=["prev1", "prev2"])
21
+ assert "prev1" in prompt
22
+ assert "prev2" in prompt
23
+ assert "translated immediately before" in prompt
24
+
25
+
26
+ def test_build_batch_prompt_omits_context_block_when_no_preceding():
27
+ prompt = ob._build_batch_prompt("TEMPLATE", ["text"], preceding=None)
28
+ assert "translated immediately before" not in prompt
29
+
30
+
31
+ def test_parse_batch_response_happy_path():
32
+ response = "1. hello\n2. world\n3. foo bar"
33
+ assert ob._parse_batch_response(response, 3) == ["hello", "world", "foo bar"]
34
+
35
+
36
+ def test_parse_batch_response_wrong_count_returns_none():
37
+ response = "1. hello\n2. world"
38
+ assert ob._parse_batch_response(response, 3) is None
39
+
40
+
41
+ def test_parse_batch_response_ignores_non_numbered_lines():
42
+ response = "Sure, here you go:\n1. hello\n2. world\nThanks!"
43
+ assert ob._parse_batch_response(response, 2) == ["hello", "world"]
44
+
45
+
46
+ def test_parse_batch_response_empty_input():
47
+ assert ob._parse_batch_response("", 0) == []
48
+ assert ob._parse_batch_response("", 1) is None
49
+
50
+
51
+ # ── model listing / curation ────────────────────────────────────────────────
52
+
53
+ def _model(id_, modality="text->text", output_modalities=None):
54
+ return {
55
+ "id": id_,
56
+ "architecture": {
57
+ "modality": modality,
58
+ "output_modalities": output_modalities if output_modalities is not None else ["text"],
59
+ },
60
+ }
61
+
62
+
63
+ def test_is_text_model_accepts_text_to_text():
64
+ assert ob._is_text_model(_model("a/a", modality="text->text"))
65
+
66
+
67
+ def test_is_text_model_accepts_multimodal_input_text_output():
68
+ assert ob._is_text_model(_model("a/a", modality="text+image->text"))
69
+
70
+
71
+ def test_is_text_model_rejects_image_output():
72
+ m = _model("a/a", modality="text->image", output_modalities=["image"])
73
+ assert not ob._is_text_model(m)
74
+
75
+
76
+ def test_list_model_choices_puts_curated_first_then_rest_alphabetically():
77
+ fetched = [
78
+ _model("zzz/last"),
79
+ _model(ob.CURATED_MODELS[1]),
80
+ _model("aaa/first"),
81
+ _model(ob.CURATED_MODELS[0]),
82
+ ]
83
+ with patch.object(ob, "fetch_available_models", return_value=fetched):
84
+ choices = ob.list_model_choices()
85
+ assert choices == [ob.CURATED_MODELS[0], ob.CURATED_MODELS[1], "aaa/first", "zzz/last"]
86
+
87
+
88
+ def test_list_model_choices_drops_curated_models_not_in_catalog():
89
+ fetched = [_model("aaa/first")]
90
+ with patch.object(ob, "fetch_available_models", return_value=fetched):
91
+ choices = ob.list_model_choices()
92
+ assert choices == ["aaa/first"]
93
+ for curated in ob.CURATED_MODELS:
94
+ assert curated not in choices
95
+
96
+
97
+ def test_list_model_choices_filters_out_non_text_models():
98
+ fetched = [
99
+ _model(ob.CURATED_MODELS[0]),
100
+ _model("image/only", modality="text->image", output_modalities=["image"]),
101
+ ]
102
+ with patch.object(ob, "fetch_available_models", return_value=fetched):
103
+ choices = ob.list_model_choices()
104
+ assert "image/only" not in choices
105
+
106
+
107
+ def test_list_model_choices_falls_back_to_curated_on_fetch_failure():
108
+ with patch.object(ob, "fetch_available_models", side_effect=requests.RequestException("network down")):
109
+ choices = ob.list_model_choices()
110
+ assert choices == list(ob.CURATED_MODELS)
111
+
112
+
113
+ def test_fetch_available_models_hits_public_models_endpoint():
114
+ fake_resp = MagicMock()
115
+ fake_resp.json.return_value = {"data": [{"id": "a/a"}]}
116
+ with patch.object(ob.requests, "get", return_value=fake_resp) as mock_get:
117
+ result = ob.fetch_available_models(timeout=5)
118
+ mock_get.assert_called_once_with(f"{ob.BASE_URL}/models", timeout=5)
119
+ fake_resp.raise_for_status.assert_called_once()
120
+ assert result == [{"id": "a/a"}]
121
+
122
+
123
+ # ── _call_openrouter (network layer) ────────────────────────────────────────
124
+
125
+ def _http_response(status_code, json_body=None, text=""):
126
+ resp = MagicMock()
127
+ resp.status_code = status_code
128
+ resp.text = text
129
+ if json_body is not None:
130
+ resp.json.return_value = json_body
131
+ return resp
132
+
133
+
134
+ def test_call_openrouter_success_returns_message_content():
135
+ ok = _http_response(200, {"choices": [{"message": {"content": "translated text"}}]})
136
+ with patch.object(ob.requests, "post", return_value=ok) as mock_post:
137
+ result = ob._call_openrouter("key123", "some/model", "prompt text")
138
+ assert result == "translated text"
139
+
140
+ _, kwargs = mock_post.call_args
141
+ assert kwargs["headers"]["Authorization"] == "Bearer key123"
142
+ assert kwargs["json"]["models"][0] == "some/model"
143
+ assert kwargs["json"]["messages"] == [{"role": "user", "content": "prompt text"}]
144
+
145
+
146
+ def test_call_openrouter_includes_curated_models_as_serverside_fallback():
147
+ ok = _http_response(200, {"choices": [{"message": {"content": "x"}}]})
148
+ with patch.object(ob.requests, "post", return_value=ok) as mock_post:
149
+ ob._call_openrouter("key", ob.CURATED_MODELS[2], "prompt")
150
+ sent_models = mock_post.call_args.kwargs["json"]["models"]
151
+ assert sent_models[0] == ob.CURATED_MODELS[2]
152
+ assert set(sent_models) == set(ob.CURATED_MODELS)
153
+ assert len(sent_models) == len(ob.CURATED_MODELS) # no duplicate of the primary model
154
+
155
+
156
+ def test_call_openrouter_retries_on_retryable_status_then_succeeds():
157
+ bad = _http_response(429, text="rate limited")
158
+ good = _http_response(200, {"choices": [{"message": {"content": "ok"}}]})
159
+ with patch.object(ob.requests, "post", side_effect=[bad, good]), patch.object(ob.time, "sleep"):
160
+ result = ob._call_openrouter("key", "model", "prompt")
161
+ assert result == "ok"
162
+
163
+
164
+ def test_call_openrouter_raises_immediately_on_non_retryable_status():
165
+ unauthorized = _http_response(401, text="invalid api key")
166
+ with patch.object(ob.requests, "post", return_value=unauthorized) as mock_post:
167
+ with pytest.raises(RuntimeError, match="401"):
168
+ ob._call_openrouter("bad-key", "model", "prompt")
169
+ assert mock_post.call_count == 1
170
+
171
+
172
+ def test_call_openrouter_exhausts_retries_and_raises_last_error():
173
+ bad = _http_response(503, text="unavailable")
174
+ with patch.object(ob.requests, "post", return_value=bad), patch.object(ob.time, "sleep"):
175
+ with pytest.raises(RuntimeError, match="503"):
176
+ ob._call_openrouter("key", "model", "prompt")
177
+
178
+
179
+ def test_call_openrouter_retries_on_network_exception():
180
+ good = _http_response(200, {"choices": [{"message": {"content": "recovered"}}]})
181
+ with patch.object(
182
+ ob.requests, "post", side_effect=[requests.ConnectionError("boom"), good]
183
+ ), patch.object(ob.time, "sleep"):
184
+ result = ob._call_openrouter("key", "model", "prompt")
185
+ assert result == "recovered"
186
+
187
+
188
+ # ── translate_one / translate_batch ─────────────────────────────────────────
189
+
190
+ def test_translate_one_strips_whitespace_from_response():
191
+ with patch.object(ob, "_call_openrouter", return_value=" translated \n"):
192
+ result = ob.translate_one("source text", "key", "model", "template")
193
+ assert result == "translated"
194
+
195
+
196
+ def test_translate_batch_empty_texts_short_circuits_without_network():
197
+ with patch.object(ob, "_call_openrouter") as mock_call:
198
+ result = ob.translate_batch([], "key", "model", "template")
199
+ assert result == []
200
+ mock_call.assert_not_called()
201
+
202
+
203
+ def test_translate_batch_happy_path():
204
+ with patch.object(ob, "_call_openrouter", return_value="1. one\n2. two"):
205
+ result = ob.translate_batch(["a", "b"], "key", "model", "template")
206
+ assert result == ["one", "two"]
207
+
208
+
209
+ def test_translate_batch_returns_none_on_parse_mismatch():
210
+ with patch.object(ob, "_call_openrouter", return_value="1. only one line"):
211
+ result = ob.translate_batch(["a", "b"], "key", "model", "template")
212
+ assert result is None
213
+
214
+
215
+ def test_translate_batch_returns_none_on_network_error():
216
+ with patch.object(ob, "_call_openrouter", side_effect=RuntimeError("boom")):
217
+ result = ob.translate_batch(["a", "b"], "key", "model", "template")
218
+ assert result is None
tests/test_prompt.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib
2
+
3
+ import engine.prompt as prompt_module
4
+
5
+
6
+ def _reload_with_prompt_path(monkeypatch, tmp_path, initial_text):
7
+ prompt_path = tmp_path / "translation_prompt.txt"
8
+ prompt_path.write_text(initial_text, encoding="utf-8")
9
+ monkeypatch.setattr(prompt_module, "PROMPT_PATH", prompt_path)
10
+ monkeypatch.setattr(prompt_module, "_DEFAULT_PROMPT", initial_text)
11
+ return prompt_path
12
+
13
+
14
+ def test_read_prompt_returns_file_contents(monkeypatch, tmp_path):
15
+ _reload_with_prompt_path(monkeypatch, tmp_path, "default prompt text")
16
+ assert prompt_module.read_prompt() == "default prompt text"
17
+
18
+
19
+ def test_save_prompt_persists_to_file(monkeypatch, tmp_path):
20
+ path = _reload_with_prompt_path(monkeypatch, tmp_path, "default prompt text")
21
+ prompt_module.save_prompt("edited prompt text")
22
+ assert path.read_text(encoding="utf-8") == "edited prompt text"
23
+ assert prompt_module.read_prompt() == "edited prompt text"
24
+
25
+
26
+ def test_reset_prompt_restores_default_and_writes_file(monkeypatch, tmp_path):
27
+ path = _reload_with_prompt_path(monkeypatch, tmp_path, "default prompt text")
28
+ prompt_module.save_prompt("edited prompt text")
29
+ result = prompt_module.reset_prompt()
30
+ assert result == "default prompt text"
31
+ assert path.read_text(encoding="utf-8") == "default prompt text"
32
+
33
+
34
+ def test_read_prompt_falls_back_to_default_when_file_missing(monkeypatch, tmp_path):
35
+ path = _reload_with_prompt_path(monkeypatch, tmp_path, "default prompt text")
36
+ path.unlink()
37
+ assert prompt_module.read_prompt() == "default prompt text"
38
+
39
+
40
+ def test_default_prompt_file_exists_and_is_nonempty():
41
+ # sanity check on the real shipped translation_prompt.txt, independent of monkeypatching above
42
+ importlib.reload(prompt_module)
43
+ assert prompt_module.PROMPT_PATH.exists()
44
+ assert len(prompt_module.read_prompt().strip()) > 0
tests/test_translate.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+
6
+ from engine import translate as tr
7
+
8
+
9
+ # ── _resolve_key / using_openrouter ─────────────────────────────────────────
10
+
11
+ def test_resolve_key_prefers_explicit_argument(monkeypatch):
12
+ monkeypatch.setenv("OPENROUTER_API_KEY", "env-key")
13
+ assert tr._resolve_key("explicit-key") == "explicit-key"
14
+
15
+
16
+ def test_resolve_key_falls_back_to_env_var(monkeypatch):
17
+ monkeypatch.setenv("OPENROUTER_API_KEY", "env-key")
18
+ assert tr._resolve_key(None) == "env-key"
19
+ assert tr._resolve_key("") == "env-key"
20
+ assert tr._resolve_key(" ") == "env-key"
21
+
22
+
23
+ def test_resolve_key_empty_when_neither_set(monkeypatch):
24
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
25
+ assert tr._resolve_key(None) == ""
26
+
27
+
28
+ def test_resolve_key_strips_whitespace(monkeypatch):
29
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
30
+ assert tr._resolve_key(" key-with-spaces ") == "key-with-spaces"
31
+
32
+
33
+ def test_using_openrouter_true_and_false(monkeypatch):
34
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
35
+ assert tr.using_openrouter("some-key") is True
36
+ assert tr.using_openrouter(None) is False
37
+ assert tr.using_openrouter("") is False
38
+
39
+
40
+ # ── translate_one dispatch ───────────────────────────────────────────────────
41
+
42
+ def test_translate_one_uses_openrouter_when_key_present(monkeypatch):
43
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
44
+ with patch("engine.translate.openrouter_backend.translate_one", return_value="translated") as mock_or, \
45
+ patch("engine.translate.local_backend.translate_batch") as mock_local:
46
+ result = tr.translate_one("source", "api-key", "model-x")
47
+ assert result == "translated"
48
+ mock_or.assert_called_once()
49
+ mock_local.assert_not_called()
50
+
51
+
52
+ def test_translate_one_uses_local_backend_when_no_key(monkeypatch):
53
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
54
+ with patch("engine.translate.openrouter_backend.translate_one") as mock_or, \
55
+ patch("engine.translate.local_backend.translate_batch", return_value=["local translation"]) as mock_local:
56
+ result = tr.translate_one("source", None, "model-x")
57
+ assert result == "local translation"
58
+ mock_or.assert_not_called()
59
+ mock_local.assert_called_once_with(["source"])
60
+
61
+
62
+ # ── translate_segments ───────────────────────────────────────────────────────
63
+
64
+ def _segments(*sources):
65
+ return [{"source": s, "target": ""} for s in sources]
66
+
67
+
68
+ def test_translate_segments_skips_already_translated(monkeypatch):
69
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
70
+ segments = [{"source": "a", "target": ""}, {"source": "b", "target": "already done"}]
71
+ with patch("engine.translate.local_backend.translate_batch", return_value=["A"]) as mock_local:
72
+ result, errors = tr.translate_segments(segments, None, "model")
73
+ assert result[0]["target"] == "A"
74
+ assert result[1]["target"] == "already done"
75
+ assert errors == []
76
+ mock_local.assert_called_once_with(["a"])
77
+
78
+
79
+ def test_translate_segments_skips_empty_source():
80
+ segments = [{"source": "", "target": ""}, {"source": " ", "target": ""}]
81
+ with patch("engine.translate.local_backend.translate_batch") as mock_local:
82
+ result, errors = tr.translate_segments(segments, None, "model")
83
+ mock_local.assert_not_called()
84
+ assert errors == []
85
+
86
+
87
+ def test_translate_segments_local_backend_path(monkeypatch):
88
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
89
+ segments = _segments("a", "b")
90
+ with patch("engine.translate.local_backend.translate_batch", return_value=["A", "B"]):
91
+ result, errors = tr.translate_segments(segments, None, "model")
92
+ assert [s["target"] for s in result] == ["A", "B"]
93
+ assert errors == []
94
+
95
+
96
+ def test_translate_segments_local_backend_error_recorded(monkeypatch):
97
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
98
+ segments = _segments("a", "b")
99
+ with patch("engine.translate.local_backend.translate_batch", side_effect=RuntimeError("model crashed")):
100
+ result, errors = tr.translate_segments(segments, None, "model")
101
+ assert len(errors) == 2
102
+ assert "model crashed" in errors[0]
103
+ assert all(s["target"] == "" for s in result)
104
+
105
+
106
+ def test_translate_segments_openrouter_batch_success():
107
+ segments = _segments("a", "b")
108
+ with patch("engine.translate.openrouter_backend.translate_batch", return_value=["A", "B"]) as mock_batch:
109
+ result, errors = tr.translate_segments(segments, "key", "model")
110
+ assert [s["target"] for s in result] == ["A", "B"]
111
+ assert errors == []
112
+ mock_batch.assert_called_once()
113
+
114
+
115
+ def test_translate_segments_openrouter_batch_falls_back_to_per_segment():
116
+ segments = _segments("a", "b")
117
+ with patch("engine.translate.openrouter_backend.translate_batch", return_value=None), \
118
+ patch("engine.translate.openrouter_backend.translate_one", side_effect=["A", "B"]) as mock_one:
119
+ result, errors = tr.translate_segments(segments, "key", "model")
120
+ assert [s["target"] for s in result] == ["A", "B"]
121
+ assert errors == []
122
+ assert mock_one.call_count == 2
123
+
124
+
125
+ def test_translate_segments_per_segment_fallback_records_errors_and_continues():
126
+ segments = _segments("a", "b")
127
+ with patch("engine.translate.openrouter_backend.translate_batch", return_value=None), \
128
+ patch("engine.translate.openrouter_backend.translate_one", side_effect=[RuntimeError("bad"), "B"]):
129
+ result, errors = tr.translate_segments(segments, "key", "model")
130
+ assert result[0]["target"] == "" # failed segment left untouched
131
+ assert result[1]["target"] == "B"
132
+ assert len(errors) == 1
133
+ assert "Segment 1" in errors[0]
134
+
135
+
136
+ def test_translate_segments_respects_stop_event_between_batches():
137
+ stop = threading.Event()
138
+ segments = _segments(*[f"seg{i}" for i in range(60)]) # 60 > _BATCH_SIZE(25), needs 3 batches
139
+
140
+ call_count = {"n": 0}
141
+
142
+ def fake_batch(texts, *a, **kw):
143
+ call_count["n"] += 1
144
+ if call_count["n"] == 1:
145
+ stop.set()
146
+ return [t.upper() for t in texts]
147
+
148
+ with patch("engine.translate.openrouter_backend.translate_batch", side_effect=fake_batch):
149
+ result, errors = tr.translate_segments(segments, "key", "model", stop=stop)
150
+
151
+ assert call_count["n"] == 1 # stopped before the second batch
152
+ assert result[0]["target"] == "SEG0"
153
+ assert result[59]["target"] == ""
154
+
155
+
156
+ def test_translate_segments_progress_callback_invoked_per_batch():
157
+ segments = _segments(*[f"seg{i}" for i in range(30)])
158
+ progress_calls = []
159
+
160
+ with patch("engine.translate.openrouter_backend.translate_batch", side_effect=lambda texts, *a, **kw: texts):
161
+ tr.translate_segments(
162
+ segments, "key", "model",
163
+ progress_callback=lambda i, total: progress_calls.append((i, total)),
164
+ )
165
+
166
+ assert progress_calls == [(0, 30), (25, 30)]
167
+
168
+
169
+ def test_translate_segments_passes_recent_translations_as_preceding_context():
170
+ segments = _segments(*[f"seg{i}" for i in range(30)])
171
+ seen_preceding = []
172
+
173
+ def fake_batch(texts, api_key, model, template, preceding=None):
174
+ seen_preceding.append(preceding)
175
+ return texts
176
+
177
+ with patch("engine.translate.openrouter_backend.translate_batch", side_effect=fake_batch):
178
+ tr.translate_segments(segments, "key", "model")
179
+
180
+ assert seen_preceding[0] is None # nothing translated yet for the first batch
181
+ assert seen_preceding[1] == ["seg22", "seg23", "seg24"] # last 3 of the first batch