Spaces:
Running on Zero
Running on Zero
Commit Β·
36fc86c
1
Parent(s): 9e83c68
Switch translation backend from Gemini to OpenRouter, add test suite
Browse filesReplaces 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 +24 -0
- CLAUDE.md +36 -7
- README.md +7 -5
- app.py +13 -10
- engine/__init__.py +4 -2
- engine/local_backend.py +1 -1
- engine/{gemini_backend.py β openrouter_backend.py} +90 -47
- engine/translate.py +13 -11
- handlers.py +2 -3
- requirements-dev.txt +2 -0
- requirements.txt +1 -1
- samples/sample_tibetan.docx +0 -0
- samples/sample_tibetan.pdf +0 -0
- samples/sample_tibetan.txt +11 -0
- tests/conftest.py +6 -0
- tests/test_app.py +21 -0
- tests/test_chunking.py +61 -0
- tests/test_formats.py +185 -0
- tests/test_handlers.py +380 -0
- tests/test_local_backend.py +136 -0
- tests/test_openrouter_backend.py +218 -0
- tests/test_prompt.py +44 -0
- tests/test_translate.py +181 -0
.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 (
|
| 36 |
-
|
| 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 |
-
-
|
| 44 |
-
with the prompt in `translation_prompt.txt` (editable in Settings) and the model
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 `
|
| 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
|
| 25 |
-
env var / `.env` file), translation uses
|
| 26 |
-
|
|
|
|
|
|
|
| 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 `
|
| 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
|
| 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 |
-
|
| 55 |
-
label="
|
| 56 |
-
placeholder="
|
| 57 |
type="password",
|
| 58 |
scale=2,
|
| 59 |
)
|
| 60 |
-
|
| 61 |
-
choices=
|
| 62 |
-
label="
|
|
|
|
|
|
|
|
|
|
| 63 |
)
|
| 64 |
|
| 65 |
gr.Markdown(
|
| 66 |
"### Translation Prompt\n"
|
| 67 |
-
"Only used when translating with
|
| 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,
|
| 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],
|
| 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 |
-
|
|
|
|
|
|
|
| 17 |
translate_one,
|
| 18 |
translate_segments,
|
| 19 |
-
|
| 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
|
| 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 |
-
"""
|
| 2 |
-
(user-supplied or
|
| 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 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
]
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 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", "
|
| 24 |
|
| 25 |
|
| 26 |
-
def
|
| 27 |
-
from
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
)
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
last_exc = None
|
| 33 |
-
for
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 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 |
-
|
| 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 |
-
|
| 108 |
-
return _parse_batch_response(
|
| 109 |
except Exception as e:
|
| 110 |
-
print(f"[WARN]
|
| 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:
|
| 2 |
-
|
| 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 |
-
|
| 12 |
-
|
|
|
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def _resolve_key(api_key: str | None) -> str:
|
| 16 |
-
return (api_key or "").strip() or os.environ.get("
|
| 17 |
|
| 18 |
|
| 19 |
-
def
|
| 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
|
| 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 {'
|
| 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 =
|
| 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 =
|
| 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 |
-
|
| 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 = "
|
| 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 |
-
|
| 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
|