Skip to content

Commit fe30960

Browse files
committed
translation_review: Add ODT and PDF export; fast default
Default to a single translate API call per unit; add --with-review for the two-step review flow. Support --generate pdf,odt, CLI api-key/base-url overrides, and run options in summary.txt. Integrate ODT export via translateodt translation_overrides.
1 parent 737a65b commit fe30960

12 files changed

Lines changed: 1274 additions & 134 deletions

config.example.ini

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ deeplapikey = MySecretDeepLAPIKey
8989
[translationreview]
9090
# Which mediawiki environment do we read from? 4training, test or local (see family.py)
9191
site = 4training
92-
# OpenAI API configuration (read-only tool, no wiki writes)
93-
openai_api_key = sk-...
92+
# OpenAI-compatible API configuration (read-only tool, no wiki writes)
93+
api_key = sk-...
9494
model = gpt-4o
9595
# Optional: custom API base URL (leave unset for OpenAI default)
9696
# base_url = https://api.openai.com/v1
@@ -107,5 +107,5 @@ source_site = test
107107
destination_site = local
108108
# The username for the destination site
109109
destination_username = CorrectBot
110-
# Example call for transfer:
111-
# python3 transfer.py "A_Daily_Prayer" "fr"
110+
# Example call for transfer:
111+
# python3 transfer.py "A_Daily_Prayer" "fr"

pywikitools/test/test_translationreview.py

Lines changed: 184 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
)
1313
from pywikitools.translationreview.openai_client import TIMEOUT_SENTINEL
1414
from pywikitools.translationreview.prompts import (
15-
build_best_translation_prompt,
1615
build_review_prompt,
16+
build_translate_prompt,
1717
language_name,
1818
)
1919

@@ -25,7 +25,7 @@ def make_config(temp_dir: str) -> ConfigParser:
2525
"Paths": {"temp": temp_dir},
2626
"translationreview": {
2727
"site": "4training",
28-
"openai_api_key": "test-key",
28+
"api_key": "test-key",
2929
"model": "gpt-4o",
3030
"reference_language": "de",
3131
"request_delay": "0",
@@ -74,8 +74,8 @@ def test_review_prompt_contains_all_texts(self):
7474
self.assertIn("teyze", prompt)
7575
self.assertIn("do NOT provide a final improved translation", prompt)
7676

77-
def test_best_translation_prompt_includes_review(self):
78-
prompt = build_best_translation_prompt(
77+
def test_translate_after_review_prompt_includes_review_and_guidelines(self):
78+
prompt = build_translate_prompt(
7979
language_code="tr",
8080
english="Pray for others",
8181
reference="Betet für andere",
@@ -84,6 +84,19 @@ def test_best_translation_prompt_includes_review(self):
8484
)
8585
self.assertIn("ONLY the best improved translation", prompt)
8686
self.assertIn("The translation is good but could be more natural.", prompt)
87+
self.assertIn("teyze", prompt)
88+
89+
def test_translate_prompt(self):
90+
prompt = build_translate_prompt(
91+
language_code="tr",
92+
english="Pray for others",
93+
reference="Betet für andere",
94+
target="Başkaları için dua et",
95+
)
96+
self.assertIn("ONLY the best improved translation", prompt)
97+
self.assertIn("Pray for others", prompt)
98+
self.assertIn("teyze", prompt)
99+
self.assertNotIn("do NOT provide a final improved translation", prompt)
87100

88101
def test_default_guidelines_for_unknown_language(self):
89102
prompt = build_review_prompt(
@@ -140,6 +153,56 @@ def test_model_cli_override(
140153
mock_openai_cls.assert_called_once()
141154
self.assertEqual(mock_openai_cls.call_args.kwargs["model"], "gpt-4.1")
142155

156+
@patch("pywikitools.translationreview.bot.OpenAIClient")
157+
@patch("pywikitools.translationreview.bot.ForTrainingLib")
158+
@patch("pywikitools.translationreview.bot.Family")
159+
def test_api_key_cli_override(
160+
self, mock_family, mock_fortraininglib_cls, mock_openai_cls
161+
):
162+
TranslationReviewBot(self.config, api_key="cli-key")
163+
mock_openai_cls.assert_called_once()
164+
self.assertEqual(mock_openai_cls.call_args.kwargs["api_key"], "cli-key")
165+
166+
@patch("pywikitools.translationreview.bot.OpenAIClient")
167+
@patch("pywikitools.translationreview.bot.ForTrainingLib")
168+
@patch("pywikitools.translationreview.bot.Family")
169+
def test_base_url_cli_override(
170+
self, mock_family, mock_fortraininglib_cls, mock_openai_cls
171+
):
172+
TranslationReviewBot(
173+
self.config,
174+
base_url="https://openrouter.ai/api/v1",
175+
)
176+
mock_openai_cls.assert_called_once()
177+
self.assertEqual(
178+
mock_openai_cls.call_args.kwargs["base_url"],
179+
"https://openrouter.ai/api/v1",
180+
)
181+
182+
@patch("pywikitools.translationreview.bot.OpenAIClient")
183+
@patch("pywikitools.translationreview.bot.ForTrainingLib")
184+
@patch("pywikitools.translationreview.bot.Family")
185+
def test_missing_api_key_raises(
186+
self, mock_family, mock_fortraininglib_cls, mock_openai_cls
187+
):
188+
config = make_config(self.temp_dir)
189+
config.remove_option("translationreview", "api_key")
190+
with self.assertRaisesRegex(RuntimeError, "Missing API key"):
191+
TranslationReviewBot(config)
192+
mock_openai_cls.assert_not_called()
193+
194+
@patch("pywikitools.translationreview.bot.OpenAIClient")
195+
@patch("pywikitools.translationreview.bot.ForTrainingLib")
196+
@patch("pywikitools.translationreview.bot.Family")
197+
def test_api_key_cli_works_without_config_key(
198+
self, mock_family, mock_fortraininglib_cls, mock_openai_cls
199+
):
200+
config = make_config(self.temp_dir)
201+
config.remove_option("translationreview", "api_key")
202+
TranslationReviewBot(config, api_key="cli-only-key")
203+
mock_openai_cls.assert_called_once()
204+
self.assertEqual(mock_openai_cls.call_args.kwargs["api_key"], "cli-only-key")
205+
143206
@patch("pywikitools.translationreview.bot.OpenAIClient")
144207
@patch("pywikitools.translationreview.bot.ForTrainingLib")
145208
@patch("pywikitools.translationreview.bot.Family")
@@ -164,13 +227,13 @@ def test_default_output_dir_includes_model_and_date(
164227
"translation_review/Forgiving_Step_by_Step_tr_openai-gpt-4o_",
165228
out_dir,
166229
)
167-
date_part = out_dir.rstrip("/").rsplit("_", 1)[-1]
168-
self.assertRegex(date_part, r"^\d{4}-\d{2}-\d{2}$")
230+
date_part = out_dir.rstrip("/").split("openai-gpt-4o_")[-1]
231+
self.assertRegex(date_part, r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$")
169232

170233
@patch("pywikitools.translationreview.bot.OpenAIClient")
171234
@patch("pywikitools.translationreview.bot.ForTrainingLib")
172235
@patch("pywikitools.translationreview.bot.Family")
173-
def test_run_processes_units_with_two_api_calls(
236+
def test_run_default_one_api_call_per_unit(
174237
self, mock_family, mock_fortraininglib_cls, mock_openai_cls
175238
):
176239
target_units = [
@@ -194,36 +257,134 @@ def test_run_processes_units_with_two_api_calls(
194257
mock_client = MagicMock()
195258
mock_openai_cls.return_value = mock_client
196259
mock_client.complete.side_effect = [
197-
"Review for unit 1",
198260
"Improved translation 1",
199-
"Review for unit 2",
200261
"Improved translation 2",
201262
]
202263

203264
bot = TranslationReviewBot(self.config)
204-
summary = bot.run("Test", "tr", output_dir=self.temp_dir)
265+
summary = bot.run(
266+
"Test",
267+
"tr",
268+
output_dir=self.temp_dir,
269+
run_options={"loglevel": "warning", "with_review": "no"},
270+
)
205271

206272
self.assertEqual(summary.processed, 2)
207273
self.assertEqual(summary.skipped, 0)
208274
self.assertEqual(summary.failed, 0)
209-
self.assertEqual(mock_client.complete.call_count, 4)
275+
self.assertFalse(summary.with_review)
276+
self.assertEqual(mock_client.complete.call_count, 2)
210277

211278
review_path = os.path.join(self.temp_dir, "review_report.md")
212279
best_path = os.path.join(self.temp_dir, "best_translations.wiki")
213280
summary_path = os.path.join(self.temp_dir, "summary.txt")
214281

215-
with open(review_path, encoding="utf-8") as handle:
216-
review_content = handle.read()
282+
self.assertFalse(os.path.isfile(review_path))
217283
with open(best_path, encoding="utf-8") as handle:
218284
best_content = handle.read()
219285
with open(summary_path, encoding="utf-8") as handle:
220286
summary_content = handle.read()
221287

222-
self.assertIn("## Prayer/1", review_content)
223-
self.assertIn("Review for unit 1", review_content)
224288
self.assertIn("=== Prayer/1 ===", best_content)
225289
self.assertIn("Improved translation 1", best_content)
226290
self.assertIn("Processed: 2", summary_content)
291+
self.assertIn("with_review: no", summary_content)
292+
self.assertIn("loglevel: warning", summary_content)
293+
self.assertIn("model: gpt-4o", summary_content)
294+
295+
@patch("pywikitools.translationreview.bot.OpenAIClient")
296+
@patch("pywikitools.translationreview.bot.ForTrainingLib")
297+
@patch("pywikitools.translationreview.bot.Family")
298+
def test_run_with_review_two_api_calls_per_unit(
299+
self, mock_family, mock_fortraininglib_cls, mock_openai_cls
300+
):
301+
target_units = [
302+
TranslationUnit(
303+
"Prayer/1", "tr", "Pray for others", "Başkaları için dua et"
304+
),
305+
TranslationUnit("Prayer/2", "tr", "Trust God", "Tanrı'ya güven"),
306+
]
307+
reference_units = [
308+
TranslationUnit("Prayer/1", "de", "Pray for others", "Betet für andere"),
309+
TranslationUnit("Prayer/2", "de", "Trust God", "Vertraut Gott"),
310+
]
311+
312+
mock_lib = MagicMock()
313+
mock_fortraininglib_cls.return_value = mock_lib
314+
mock_lib.get_translation_units.side_effect = [
315+
TranslatedPage("Test", "tr", target_units),
316+
TranslatedPage("Test", "de", reference_units),
317+
]
318+
319+
mock_client = MagicMock()
320+
mock_openai_cls.return_value = mock_client
321+
mock_client.complete.side_effect = [
322+
"Review for unit 1",
323+
"Improved translation 1",
324+
"Review for unit 2",
325+
"Improved translation 2",
326+
]
327+
328+
bot = TranslationReviewBot(self.config)
329+
summary = bot.run("Test", "tr", output_dir=self.temp_dir, with_review=True)
330+
331+
self.assertEqual(summary.processed, 2)
332+
self.assertTrue(summary.with_review)
333+
self.assertEqual(mock_client.complete.call_count, 4)
334+
335+
review_path = os.path.join(self.temp_dir, "review_report.md")
336+
with open(review_path, encoding="utf-8") as handle:
337+
review_content = handle.read()
338+
339+
self.assertIn("## Prayer/1", review_content)
340+
self.assertIn("Review for unit 1", review_content)
341+
342+
@patch("pywikitools.translationreview.odt_export.export_suggested_odt")
343+
@patch("pywikitools.translationreview.pdf_report.build_pdf_report")
344+
@patch("pywikitools.translationreview.bot.OpenAIClient")
345+
@patch("pywikitools.translationreview.bot.ForTrainingLib")
346+
@patch("pywikitools.translationreview.bot.Family")
347+
def test_run_generate_pdf_and_odt(
348+
self,
349+
mock_family,
350+
mock_fortraininglib_cls,
351+
mock_openai_cls,
352+
mock_build_pdf_report,
353+
mock_export_suggested_odt,
354+
):
355+
target_units = [
356+
TranslationUnit(
357+
"Prayer/1", "tr", "Pray for others", "Başkaları için dua et"
358+
),
359+
]
360+
reference_units = [
361+
TranslationUnit("Prayer/1", "de", "Pray for others", "Betet für andere"),
362+
]
363+
364+
mock_lib = MagicMock()
365+
mock_fortraininglib_cls.return_value = mock_lib
366+
mock_lib.get_translation_units.side_effect = [
367+
TranslatedPage("Test", "tr", target_units),
368+
TranslatedPage("Test", "de", reference_units),
369+
]
370+
371+
mock_client = MagicMock()
372+
mock_openai_cls.return_value = mock_client
373+
mock_client.complete.side_effect = [
374+
"Improved translation 1",
375+
]
376+
mock_export_suggested_odt.return_value = os.path.join(
377+
self.temp_dir, "gpt-4o_suggested.odt"
378+
)
379+
380+
bot = TranslationReviewBot(self.config, model="gpt-4o")
381+
bot.run("Test", "tr", output_dir=self.temp_dir, generate={"pdf", "odt"})
382+
383+
mock_build_pdf_report.assert_called_once()
384+
self.assertFalse(
385+
mock_build_pdf_report.call_args.kwargs.get("include_review", True)
386+
)
387+
mock_export_suggested_odt.assert_called_once()
227388

228389
@patch("pywikitools.translationreview.bot.OpenAIClient")
229390
@patch("pywikitools.translationreview.bot.ForTrainingLib")
@@ -258,7 +419,7 @@ def test_run_logs_analysis_and_best_translation(
258419
with self.assertLogs(
259420
"pywikitools.translationreview", level=logging.INFO
260421
) as log_context:
261-
bot.run("Test", "tr", output_dir=self.temp_dir)
422+
bot.run("Test", "tr", output_dir=self.temp_dir, with_review=True)
262423

263424
messages = [record.getMessage() for record in log_context.records]
264425
info_messages = [
@@ -305,7 +466,6 @@ def test_skips_units_without_reference(
305466
mock_client = MagicMock()
306467
mock_openai_cls.return_value = mock_client
307468
mock_client.complete.side_effect = [
308-
"Review for unit 1",
309469
"Improved translation 1",
310470
]
311471

@@ -315,7 +475,7 @@ def test_skips_units_without_reference(
315475
self.assertEqual(summary.processed, 1)
316476
self.assertEqual(summary.skipped, 1)
317477
self.assertIn("Prayer/2", summary.skipped_units)
318-
self.assertEqual(mock_client.complete.call_count, 2)
478+
self.assertEqual(mock_client.complete.call_count, 1)
319479

320480
@patch("pywikitools.translationreview.bot.OpenAIClient")
321481
@patch("pywikitools.translationreview.bot.ForTrainingLib")
@@ -343,13 +503,13 @@ def test_limit_and_unit_filter(
343503

344504
mock_client = MagicMock()
345505
mock_openai_cls.return_value = mock_client
346-
mock_client.complete.side_effect = ["Review", "Best"]
506+
mock_client.complete.side_effect = ["Best"]
347507

348508
bot = TranslationReviewBot(self.config)
349509
summary = bot.run("Test", "tr", unit_id="Prayer/2", output_dir=self.temp_dir)
350510

351511
self.assertEqual(summary.processed, 1)
352-
self.assertEqual(mock_client.complete.call_count, 2)
512+
self.assertEqual(mock_client.complete.call_count, 1)
353513

354514
with open(
355515
os.path.join(self.temp_dir, "best_translations.wiki"), encoding="utf-8"
@@ -392,16 +552,14 @@ def test_run_marks_timeout_as_failed_and_writes_reports(
392552
self.assertEqual(summary.failed_units, ["Prayer/1"])
393553
mock_client.complete.assert_called_once()
394554

395-
with open(
396-
os.path.join(self.temp_dir, "review_report.md"), encoding="utf-8"
397-
) as handle:
398-
review_content = handle.read()
555+
self.assertFalse(
556+
os.path.isfile(os.path.join(self.temp_dir, "review_report.md"))
557+
)
399558
with open(
400559
os.path.join(self.temp_dir, "best_translations.wiki"), encoding="utf-8"
401560
) as handle:
402561
best_content = handle.read()
403562

404-
self.assertIn("TIMEOUT", review_content)
405563
self.assertIn("=== Prayer/1 ===", best_content)
406564
self.assertIn("TIMEOUT", best_content)
407565

@@ -432,7 +590,7 @@ def test_run_marks_partial_timeout_as_failed(
432590
mock_client.complete.side_effect = ["Review for unit 1", TIMEOUT_SENTINEL]
433591

434592
bot = TranslationReviewBot(self.config)
435-
summary = bot.run("Test", "tr", output_dir=self.temp_dir)
593+
summary = bot.run("Test", "tr", output_dir=self.temp_dir, with_review=True)
436594

437595
self.assertEqual(summary.processed, 0)
438596
self.assertEqual(summary.failed, 1)

0 commit comments

Comments
 (0)