From b28f809c465e7fd10178e03c9b42c89853e90fac Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Tue, 6 Oct 2026 14:47:22 +0800 Subject: [PATCH] add a retranslate chinese function --- sentence_api/README.md | 2 + sentence_api/ai_tutor.py | 75 ++++++++++++++++++++++++-- sentence_api/main.py | 37 +++++++++++++ sentence_api/models.py | 25 +++++++++ sentence_api/repository.py | 80 ++++++++++++++++++++++++++- sentence_api/static/admin.css | 8 +++ sentence_api/static/admin.html | 22 +++++++- sentence_api/static/admin.js | 84 ++++++++++++++++++++++++++++- sentence_api/tests/test_ai_tutor.py | 34 +++++++++++- sentence_api/tests/test_api.py | 53 ++++++++++++++++++ sentence_api/translation_jobs.py | 37 ++++++++++++- 11 files changed, 448 insertions(+), 9 deletions(-) diff --git a/sentence_api/README.md b/sentence_api/README.md index f6629e0..0092d21 100644 --- a/sentence_api/README.md +++ b/sentence_api/README.md @@ -59,6 +59,8 @@ POST /api/v1/videos/{sha256}/sentences/{index}/assessments POST /api/v1/admin/videos PUT /api/v1/admin/videos/raw POST /api/v1/admin/videos/{sha256}/process +GET /api/v1/admin/videos/{sha256}/subtitle-translation +POST /api/v1/admin/videos/{sha256}/subtitle-translation PUT /api/v1/admin/videos/{sha256}/sentences/{index} PUT /api/v1/admin/videos/{sha256}/sentences/{index}/split PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary diff --git a/sentence_api/ai_tutor.py b/sentence_api/ai_tutor.py index b6c21d6..a2bc569 100644 --- a/sentence_api/ai_tutor.py +++ b/sentence_api/ai_tutor.py @@ -83,6 +83,7 @@ class AITutor: course_title: str = "", existing: dict[int, str] | None = None, persist=None, + terms: list[tuple[str, str]] | None = None, ) -> dict[int, str]: if not self.available: raise AITutorError("AI translation is not configured.") @@ -93,6 +94,7 @@ class AITutor: if not ordered: raise AITutorError("No sentence text was provided for translation.") + glossary = _normalize_translation_terms(terms) translations = { index: text.strip() for index, text in (existing or {}).items() @@ -123,6 +125,7 @@ class AITutor: all_sentences=ordered, existing=translations, course_title=course_title, + terms=glossary, ) if workers == 1: @@ -153,6 +156,7 @@ class AITutor: all_sentences=ordered, existing=translations, course_title=course_title, + terms=glossary, force_split=len(batch) > 1, ) ) @@ -160,6 +164,12 @@ class AITutor: missing = [index for index, _ in pending if not translations.get(index, "").strip()] if missing: raise AITutorError("AI translation did not cover every sentence.") + if glossary: + source_by_index = {index: text for index, text in ordered} + translations = { + index: _apply_translation_terms(source_by_index.get(index, ""), zh_text, glossary) + for index, zh_text in translations.items() + } return translations def _translate_batch_resilient( @@ -169,6 +179,7 @@ class AITutor: all_sentences: list[tuple[int, str]], existing: dict[int, str], course_title: str, + terms: list[tuple[str, str]] | None = None, force_split: bool = False, ) -> dict[int, str]: if not batch: @@ -179,6 +190,7 @@ class AITutor: all_sentences=all_sentences, existing=existing, course_title=course_title, + terms=terms, ) last_error: Exception | None = None @@ -190,6 +202,7 @@ class AITutor: all_sentences=all_sentences, existing=existing, course_title=course_title, + terms=terms, ) except AITutorError as exc: last_error = exc @@ -206,6 +219,7 @@ class AITutor: all_sentences=all_sentences, existing=existing, course_title=course_title, + terms=terms, ) if last_error is not None: raise last_error @@ -218,6 +232,7 @@ class AITutor: all_sentences: list[tuple[int, str]], existing: dict[int, str], course_title: str, + terms: list[tuple[str, str]] | None = None, ) -> dict[int, str]: mid = max(1, len(batch) // 2) merged: dict[int, str] = {} @@ -228,6 +243,7 @@ class AITutor: all_sentences=all_sentences, existing=existing, course_title=course_title, + terms=terms, ) ) return merged @@ -239,29 +255,36 @@ class AITutor: all_sentences: list[tuple[int, str]], existing: dict[int, str], course_title: str, + terms: list[tuple[str, str]] | None = None, ) -> dict[int, str]: context = course_title.strip() or "口语训练课程" existing = dict(existing) before, after = _neighbor_context(batch, all_sentences) expected_indexes = [index for index, _ in batch] + glossary = _normalize_translation_terms(terms) system_prompt = ( "你是专业的影视字幕翻译。请结合前后文语境,把指定编号的外语句子翻译成自然、口语化的简体中文。" "必须一句对一句,不能合并、拆分、跳过或改写编号。" "只输出 JSON 对象,键是句子编号,值是对应中文,不要输出解释或 markdown。" ) + if glossary: + system_prompt += "必须严格使用用户指定的专有名词译法,禁止自行音译成其他写法。" user_parts = [ f"课程:{context}", f"本次需要翻译 {len(batch)} 句,编号必须与输入完全一致。", ] - glossary = [ + if glossary: + user_parts.append("专有名词译法(必须严格遵守,原文出现时译文必须使用指定中文):") + user_parts.extend(f"{source} => {zh}" for source, zh in glossary) + previous = [ (index, source, existing[index]) for index, source in before if existing.get(index, "").strip() ] - if glossary: + if previous: user_parts.append("已有译文(供用词一致,不要重复输出):") user_parts.extend( - f"[{index}] {source} => {zh}" for index, source, zh in glossary[-6:] + f"[{index}] {source} => {zh}" for index, source, zh in previous[-6:] ) elif before: user_parts.append("前文(只供理解语境,不要翻译):") @@ -284,7 +307,11 @@ class AITutor: missing = [index for index in expected_indexes if not parsed.get(index, "").strip()] if missing: raise AITutorError("AI translation returned an unexpected response.") - return {index: parsed[index] for index in expected_indexes} + source_by_index = {index: text for index, text in batch} + return { + index: _apply_translation_terms(source_by_index.get(index, ""), parsed[index], glossary) + for index in expected_indexes + } def _complete( self, @@ -512,3 +539,43 @@ def _translations_from_json(data: object, expected: list[int]) -> dict[int, str] if len(values) == len(expected): return values return None + + +def _normalize_translation_terms(terms: list[tuple[str, str]] | None) -> list[tuple[str, str]]: + cleaned: list[tuple[str, str]] = [] + seen: dict[str, int] = {} + for item in terms or []: + if not isinstance(item, (tuple, list)) or len(item) < 2: + continue + source = str(item[0] or "").strip()[:120] + zh = str(item[1] or "").strip()[:120] + if not source or not zh: + continue + key = source.casefold() + if key in seen: + cleaned[seen[key]] = (source, zh) + continue + seen[key] = len(cleaned) + cleaned.append((source, zh)) + if len(cleaned) >= 50: + break + return cleaned + + +def _term_pattern(source: str) -> re.Pattern[str]: + escaped = re.escape(source) + if re.fullmatch(r"[A-Za-z][A-Za-z0-9'’.\- ]*", source): + return re.compile(rf"(? str: + del source_text + result = zh_text + for source, zh in _normalize_translation_terms(terms): + result = _term_pattern(source).sub(zh, result) + return result diff --git a/sentence_api/main.py b/sentence_api/main.py index bbc9194..48e8d8b 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -46,6 +46,8 @@ from .models import ( SentenceBoundaryDocument, SentenceExplanationRequest, SentenceExplanationResponse, + AdminSubtitleTranslationResponse, + SubtitleRetranslateRequest, SubtitleTranslationResponse, PasswordResetRequest, SentenceTextUpdate, @@ -668,6 +670,41 @@ def create_app( background_tasks.add_task(_process_safely, processor, video_hash) return {"video_hash": video_hash.lower(), "status": "processing_queued"} + @application.get( + "/api/v1/admin/videos/{video_hash}/subtitle-translation", + response_model=AdminSubtitleTranslationResponse, + dependencies=[Depends(require_admin)], + ) + def get_admin_subtitle_translation( + video_hash: str = SHA256_PATH, + ) -> AdminSubtitleTranslationResponse: + snapshot = application.state.subtitle_translations.admin_snapshot(video_hash) + if snapshot is None: + raise HTTPException(status_code=404, detail="Course is not available.") + return snapshot + + @application.post( + "/api/v1/admin/videos/{video_hash}/subtitle-translation", + response_model=AdminSubtitleTranslationResponse, + dependencies=[Depends(require_admin)], + ) + def retranslate_subtitles( + payload: Optional[SubtitleRetranslateRequest] = None, + video_hash: str = SHA256_PATH, + ) -> AdminSubtitleTranslationResponse: + request = payload or SubtitleRetranslateRequest() + try: + return application.state.subtitle_translations.retranslate( + video_hash, + terms=[(item.source, item.zh) for item in request.terms], + ) + except KeyError: + raise HTTPException(status_code=404, detail="Course is not available.") from None + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except AITutorError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + @application.put( "/api/v1/admin/videos/{video_hash}/title", response_model=VideoSummary, diff --git a/sentence_api/models.py b/sentence_api/models.py index 5893dfd..85f8796 100644 --- a/sentence_api/models.py +++ b/sentence_api/models.py @@ -144,6 +144,31 @@ class SubtitleTranslationResponse(BaseModel): error: Optional[str] = None +class TranslationTerm(BaseModel): + model_config = ConfigDict(extra="forbid") + + source: str = Field(min_length=1, max_length=120) + zh: str = Field(min_length=1, max_length=120) + + @field_validator("source", "zh") + @classmethod + def strip_term(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("translation term cannot be empty") + return cleaned + + +class SubtitleRetranslateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + terms: List[TranslationTerm] = Field(default_factory=list, max_length=50) + + +class AdminSubtitleTranslationResponse(SubtitleTranslationResponse): + terms: List[TranslationTerm] = Field(default_factory=list) + + class SentenceBoundaryAdjust(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/sentence_api/repository.py b/sentence_api/repository.py index 0a4eb71..9d81665 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -2,7 +2,7 @@ import json import sqlite3 from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Sequence, Tuple import uuid from .auth import hash_password, hash_token, new_token, verify_password @@ -202,6 +202,14 @@ class VideoRepository: if "zh_text" not in sentence_columns: connection.execute("ALTER TABLE sentences ADD COLUMN zh_text TEXT") + with self._connect() as connection: + video_columns = { + row["name"] + for row in connection.execute("PRAGMA table_info(videos)") + } + if "translation_glossary" not in video_columns: + connection.execute("ALTER TABLE videos ADD COLUMN translation_glossary TEXT") + def upsert_upload( self, *, @@ -833,6 +841,36 @@ class VideoRepository: return [_sentence_from_row(row) for row in result_rows] + def get_translation_glossary(self, video_hash: str) -> List[Tuple[str, str]]: + video = self.get_video(video_hash) + if video is None: + return [] + return _parse_translation_glossary(video.get("translation_glossary")) + + def save_translation_glossary( + self, + video_hash: str, + terms: Sequence[Any], + ) -> bool: + normalized_hash = normalize_video_hash(video_hash) + payload = json.dumps( + [ + {"source": source, "zh": zh} + for source, zh in _normalize_translation_glossary(terms) + ], + ensure_ascii=False, + ) + with self._connect() as connection: + cursor = connection.execute( + """ + UPDATE videos + SET translation_glossary = ?, updated_at = ? + WHERE video_hash = ? + """, + (payload, utc_now(), normalized_hash), + ) + return cursor.rowcount > 0 + def clear_sentence_translations(self, video_hash: str) -> None: with self._connect() as connection: connection.execute( @@ -1345,3 +1383,43 @@ def _moss_segment_row(video_hash: str, index: int, segment: Dict[str, Any]) -> t str(speaker) if speaker is not None else None, words_json, ) + + +def _normalize_translation_glossary(terms: Sequence[Any]) -> List[Tuple[str, str]]: + cleaned: List[Tuple[str, str]] = [] + seen: Dict[str, int] = {} + for item in terms or []: + source = "" + zh = "" + if isinstance(item, dict): + source = str(item.get("source") or "") + zh = str(item.get("zh") or "") + elif isinstance(item, (tuple, list)) and len(item) >= 2: + source = str(item[0] or "") + zh = str(item[1] or "") + source = source.strip()[:120] + zh = zh.strip()[:120] + if not source or not zh: + continue + key = source.casefold() + if key in seen: + cleaned[seen[key]] = (source, zh) + continue + seen[key] = len(cleaned) + cleaned.append((source, zh)) + if len(cleaned) >= 50: + break + return cleaned + + +def _parse_translation_glossary(raw: Any) -> List[Tuple[str, str]]: + if not raw: + return [] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return [] + if not isinstance(raw, list): + return [] + return _normalize_translation_glossary(raw) diff --git a/sentence_api/static/admin.css b/sentence_api/static/admin.css index a0cdb22..a998f18 100644 --- a/sentence_api/static/admin.css +++ b/sentence_api/static/admin.css @@ -119,3 +119,11 @@ dialog::backdrop { background: rgb(20 25 28 / 55%); } #upload-form { grid-template-columns: 1fr; } .actions-column { min-width: 250px; } } + +.zh-text { margin-top: 6px; color: #66727d; font-size: 12px; white-space: pre-wrap; line-height: 1.45; } +.retranslate-form { padding: 16px 18px 20px; display: grid; gap: 12px; } +.retranslate-form p { margin: 0; font-size: 13px; color: #4f5b64; line-height: 1.55; } +.retranslate-form label { display: grid; gap: 6px; font-size: 13px; color: #4f5b64; } +.retranslate-form textarea { width: 100%; min-height: 160px; resize: vertical; } +.retranslate-form code { font-size: 12px; } +.retranslate-actions { display: flex; justify-content: flex-end; } diff --git a/sentence_api/static/admin.html b/sentence_api/static/admin.html index 3a98581..8c0b388 100644 --- a/sentence_api/static/admin.html +++ b/sentence_api/static/admin.html @@ -96,6 +96,26 @@ + +
+
+

重新翻译中文字幕

+ +
+ +
+
+

会清空当前中文字幕并重新翻译。可指定专有名词译法,每行一条,格式为 原文=中文。例如 Harimoto=哈瑞么托,也可以把错误译名写在左边:哈达马托=哈瑞么托。

+ +
+ +
+
+
+
@@ -114,6 +134,6 @@
- + diff --git a/sentence_api/static/admin.js b/sentence_api/static/admin.js index f05e97a..e65c3a7 100644 --- a/sentence_api/static/admin.js +++ b/sentence_api/static/admin.js @@ -2,6 +2,7 @@ const state = { adminKey: localStorage.getItem("oralTrainerAdminKey") || "", videos: [], users: [], + retranslateVideo: null, }; const elements = { @@ -22,6 +23,12 @@ const elements = { sentenceMeta: document.querySelector("#sentence-meta"), sentenceRows: document.querySelector("#sentence-rows"), closeDialog: document.querySelector("#close-dialog"), + retranslateDialog: document.querySelector("#retranslate-dialog"), + retranslateTitle: document.querySelector("#retranslate-title"), + retranslateMeta: document.querySelector("#retranslate-meta"), + retranslateForm: document.querySelector("#retranslate-form"), + retranslateTerms: document.querySelector("#retranslate-terms"), + closeRetranslateDialog: document.querySelector("#close-retranslate-dialog"), userRows: document.querySelector("#user-rows"), userCount: document.querySelector("#user-count"), userEmptyState: document.querySelector("#user-empty-state"), @@ -38,6 +45,8 @@ elements.refresh.addEventListener("click", () => { loadUsers(); }); elements.closeDialog.addEventListener("click", () => elements.dialog.close()); +elements.closeRetranslateDialog.addEventListener("click", () => elements.retranslateDialog.close()); +elements.retranslateForm.addEventListener("submit", submitRetranslate); elements.uploadForm.addEventListener("submit", uploadVideo); elements.videoFile.addEventListener("change", () => { if (!elements.videoFile.files[0]) return; @@ -183,6 +192,7 @@ function actionCell(video) { actions.append(play); actions.append(button("改标题", () => renameVideo(video))); actions.append(button("句子", () => openSentences(video))); + actions.append(button("重新翻译", () => openRetranslate(video))); actions.append(button("重新处理", () => reprocess(video.video_hash))); const remove = button("删除", () => deleteVideo(video)); remove.className = "danger"; @@ -298,7 +308,10 @@ function sentenceRow(video, sentence, totalCount) { language.append(option); } const textContainer = document.createElement("td"); - textContainer.append(textArea); + const zhNote = document.createElement("div"); + zhNote.className = "zh-text"; + zhNote.textContent = sentence.zh_text || "暂无中文翻译"; + textContainer.append(textArea, zhNote); const languageContainer = document.createElement("td"); languageContainer.append(language); const saveContainer = document.createElement("td"); @@ -408,6 +421,75 @@ function boundaryControl(video, sentence) { return container; } +async function openRetranslate(video) { + state.retranslateVideo = video; + elements.retranslateTitle.textContent = `重新翻译:${video.title}`; + elements.retranslateMeta.textContent = video.video_hash; + elements.retranslateTerms.value = ""; + try { + const payload = await api(`/api/v1/admin/videos/${video.video_hash}/subtitle-translation`); + elements.retranslateTerms.value = formatTranslationTerms(payload.terms || []); + const status = payload.complete ? "中文字幕已就绪" : (payload.status === "running" ? "正在翻译" : "尚未翻译完成"); + elements.retranslateMeta.textContent = `${status} / ${video.video_hash}`; + } catch (error) { + elements.serviceState.textContent = `加载翻译信息失败: ${error.message}`; + } + if (!elements.retranslateDialog.open) elements.retranslateDialog.showModal(); +} + +function formatTranslationTerms(terms) { + return (terms || []).map((item) => `${item.source}=${item.zh}`).join("\n"); +} + +function parseTranslationTerms(text) { + const terms = []; + for (const rawLine of (text || "").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = line.match(/^(.+?)\s*(?:=|=>|:|:)\s*(.+)$/); + if (!match) { + throw new Error(`无法解析专有名词:${line}`); + } + const source = match[1].trim(); + const zh = match[2].trim(); + if (!source || !zh) { + throw new Error(`无法解析专有名词:${line}`); + } + terms.push({ source, zh }); + } + return terms; +} + +async function submitRetranslate(event) { + event.preventDefault(); + const video = state.retranslateVideo; + if (!video) return; + let terms; + try { + terms = parseTranslationTerms(elements.retranslateTerms.value); + } catch (error) { + elements.serviceState.textContent = error.message; + return; + } + const submit = elements.retranslateForm.querySelector("button[type='submit']"); + submit.disabled = true; + try { + const payload = await api(`/api/v1/admin/videos/${video.video_hash}/subtitle-translation`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ terms }), + }); + const status = payload.complete ? "中文字幕已重新翻译" : "已开始重新翻译"; + elements.serviceState.textContent = terms.length ? `${status},已应用 ${terms.length} 条专有名词` : status; + elements.retranslateDialog.close(); + await loadVideos(); + } catch (error) { + elements.serviceState.textContent = `重新翻译失败: ${error.message}`; + } finally { + submit.disabled = false; + } +} + async function reprocess(videoHash) { try { await api(`/api/v1/admin/videos/${videoHash}/process`, { method: "POST" }); diff --git a/sentence_api/tests/test_ai_tutor.py b/sentence_api/tests/test_ai_tutor.py index 5c5aa92..21fab38 100644 --- a/sentence_api/tests/test_ai_tutor.py +++ b/sentence_api/tests/test_ai_tutor.py @@ -4,7 +4,7 @@ import json import re import threading -from sentence_api.ai_tutor import AITutor, AITutorError, _parse_translation_payload +from sentence_api.ai_tutor import AITutor, AITutorError, _apply_translation_terms, _parse_translation_payload class FakeResponse: @@ -173,6 +173,38 @@ def test_ai_tutor_splits_invalid_windows_and_retries(monkeypatch): assert calls["count"] >= 3 +def test_ai_tutor_includes_name_terms_and_rewrites_output(monkeypatch): + requests_payloads = [] + + def fake_post(endpoint, headers, json, timeout): + requests_payloads.append(json) + return FakeResponse( + body={"choices": [{"message": {"content": '{"0":"哈达马托击败了 Harimoto。"}'}}]} + ) + + monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post) + tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1) + + translated = tutor.translate_sentences( + [(0, "Harimoto won.")], + course_title="Table tennis", + terms=[("Harimoto", "哈瑞么托"), ("哈达马托", "哈瑞么托")], + ) + + assert translated == {0: "哈瑞么托击败了 哈瑞么托。"} + user_prompt = requests_payloads[0]["messages"][1]["content"] + assert "Harimoto => 哈瑞么托" in user_prompt + assert "哈达马托 => 哈瑞么托" in user_prompt + + +def test_apply_translation_terms_replaces_english_and_wrong_chinese(): + assert _apply_translation_terms( + "Harimoto won.", + "哈达马托击败了 Harimoto。", + [("Harimoto", "哈瑞么托"), ("哈达马托", "哈瑞么托")], + ) == "哈瑞么托击败了 哈瑞么托。" + + def test_parse_translation_payload_accepts_fenced_json_and_numbered_lines(): assert _parse_translation_payload('```json\n["一", "二"]\n```', 2) == {0: "一", 1: "二"} assert _parse_translation_payload("1. 一\n2. 二", 2) == {0: "一", 1: "二"} diff --git a/sentence_api/tests/test_api.py b/sentence_api/tests/test_api.py index 634245d..571879b 100644 --- a/sentence_api/tests/test_api.py +++ b/sentence_api/tests/test_api.py @@ -391,6 +391,59 @@ def test_subtitle_translation_does_not_block_http_on_long_jobs(tmp_path, monkeyp assert len(requested) == 1 +def test_admin_can_retranslate_subtitles_with_name_terms(tmp_path, monkeypatch): + client = make_client(tmp_path) + _ready_video(client.app.state.video_repository) + requested = [] + + def translate_sentences(sentences, **kwargs): + requested.append((sentences, kwargs.get("terms"))) + terms = dict(kwargs.get("terms") or []) + return { + index: terms.get("Harimoto", "哈达马托") + f":{text}" + for index, text in sentences + } + + class StubAITutor: + available = True + + stub = StubAITutor() + stub.translate_sentences = translate_sentences + monkeypatch.setattr(client.app.state, "ai_tutor", stub) + client.app.state.subtitle_translations.ai_tutor = stub + client.app.state.subtitle_translations.post_wait_seconds = 0.2 + + first = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation") + if not first.json().get("complete"): + first = wait_for_subtitle_translation(client, VIDEO_HASH) + assert first.json()["translations"] == [ + {"index": 0, "zh_text": "哈达马托:Hello."}, + {"index": 1, "zh_text": "哈达马托:Nice to meet you."}, + ] + + response = client.post( + f"/api/v1/admin/videos/{VIDEO_HASH}/subtitle-translation", + json={"terms": [{"source": "Harimoto", "zh": "哈瑞么托"}]}, + ) + assert response.status_code == 200, response.text + if not response.json().get("complete"): + response = wait_for_subtitle_translation(client, VIDEO_HASH) + stored = client.get(f"/api/v1/admin/videos/{VIDEO_HASH}/subtitle-translation") + assert stored.status_code == 200 + payload = stored.json() + else: + payload = response.json() + assert payload["translations"] == [ + {"index": 0, "zh_text": "哈瑞么托:Hello."}, + {"index": 1, "zh_text": "哈瑞么托:Nice to meet you."}, + ] + assert payload["terms"] == [{"source": "Harimoto", "zh": "哈瑞么托"}] + assert requested[-1][1] == [("Harimoto", "哈瑞么托")] + assert client.app.state.video_repository.get_translation_glossary(VIDEO_HASH) == [ + ("Harimoto", "哈瑞么托") + ] + + def test_oss_video_url_is_used_for_catalog_and_content(tmp_path): index_path = tmp_path / "boundaries.json" index_path.write_text("{}", encoding="utf-8") diff --git a/sentence_api/translation_jobs.py b/sentence_api/translation_jobs.py index 39e0bbc..53d4caf 100644 --- a/sentence_api/translation_jobs.py +++ b/sentence_api/translation_jobs.py @@ -7,7 +7,13 @@ import threading from dataclasses import dataclass from .ai_tutor import AITutor, AITutorError -from .models import SentenceBoundaryDocument, SubtitleTranslationItem, SubtitleTranslationResponse +from .models import ( + AdminSubtitleTranslationResponse, + SentenceBoundaryDocument, + SubtitleTranslationItem, + SubtitleTranslationResponse, + TranslationTerm, +) from .repository import VideoRepository @@ -110,6 +116,34 @@ class SubtitleTranslationService: except (KeyError, ValueError, AITutorError): logger.exception("Could not restart subtitle translation: video=%s", video_hash) + def admin_snapshot(self, video_hash: str) -> AdminSubtitleTranslationResponse | None: + current = self.current(video_hash) + if current is None: + return None + return self._with_terms(current) + + def retranslate( + self, + video_hash: str, + terms: list[tuple[str, str]] | None = None, + ) -> AdminSubtitleTranslationResponse: + if self.repository.get_video(video_hash) is None: + raise KeyError(video_hash) + if terms is not None: + self.repository.save_translation_glossary(video_hash, terms) + self.invalidate(video_hash) + self.repository.clear_sentence_translations(video_hash) + return self._with_terms(self.ensure(video_hash)) + + def _with_terms(self, snapshot: SubtitleTranslationResponse) -> AdminSubtitleTranslationResponse: + terms = [ + TranslationTerm(source=source, zh=zh) + for source, zh in self.repository.get_translation_glossary(snapshot.video_hash) + ] + payload = snapshot.model_dump() + payload["terms"] = terms + return AdminSubtitleTranslationResponse(**payload) + def ensure(self, video_hash: str) -> SubtitleTranslationResponse: if not getattr(self.ai_tutor, "available", False): raise AITutorError("AI translation is not configured.") @@ -200,6 +234,7 @@ class SubtitleTranslationService: course_title=(video["title"] if video is not None else "") or "", existing=existing, persist=lambda part: self._persist(job, part), + terms=self.repository.get_translation_glossary(job.video_hash), ) if translated: self._persist(job, translated)