add a retranslate chinese function

This commit is contained in:
2026-10-06 14:47:22 +08:00
parent 4fd0fd36a4
commit b28f809c46
11 changed files with 448 additions and 9 deletions

View File

@@ -59,6 +59,8 @@ POST /api/v1/videos/{sha256}/sentences/{index}/assessments
POST /api/v1/admin/videos POST /api/v1/admin/videos
PUT /api/v1/admin/videos/raw PUT /api/v1/admin/videos/raw
POST /api/v1/admin/videos/{sha256}/process 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}
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/split PUT /api/v1/admin/videos/{sha256}/sentences/{index}/split
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary

View File

@@ -83,6 +83,7 @@ class AITutor:
course_title: str = "", course_title: str = "",
existing: dict[int, str] | None = None, existing: dict[int, str] | None = None,
persist=None, persist=None,
terms: list[tuple[str, str]] | None = None,
) -> dict[int, str]: ) -> dict[int, str]:
if not self.available: if not self.available:
raise AITutorError("AI translation is not configured.") raise AITutorError("AI translation is not configured.")
@@ -93,6 +94,7 @@ class AITutor:
if not ordered: if not ordered:
raise AITutorError("No sentence text was provided for translation.") raise AITutorError("No sentence text was provided for translation.")
glossary = _normalize_translation_terms(terms)
translations = { translations = {
index: text.strip() index: text.strip()
for index, text in (existing or {}).items() for index, text in (existing or {}).items()
@@ -123,6 +125,7 @@ class AITutor:
all_sentences=ordered, all_sentences=ordered,
existing=translations, existing=translations,
course_title=course_title, course_title=course_title,
terms=glossary,
) )
if workers == 1: if workers == 1:
@@ -153,6 +156,7 @@ class AITutor:
all_sentences=ordered, all_sentences=ordered,
existing=translations, existing=translations,
course_title=course_title, course_title=course_title,
terms=glossary,
force_split=len(batch) > 1, force_split=len(batch) > 1,
) )
) )
@@ -160,6 +164,12 @@ class AITutor:
missing = [index for index, _ in pending if not translations.get(index, "").strip()] missing = [index for index, _ in pending if not translations.get(index, "").strip()]
if missing: if missing:
raise AITutorError("AI translation did not cover every sentence.") 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 return translations
def _translate_batch_resilient( def _translate_batch_resilient(
@@ -169,6 +179,7 @@ class AITutor:
all_sentences: list[tuple[int, str]], all_sentences: list[tuple[int, str]],
existing: dict[int, str], existing: dict[int, str],
course_title: str, course_title: str,
terms: list[tuple[str, str]] | None = None,
force_split: bool = False, force_split: bool = False,
) -> dict[int, str]: ) -> dict[int, str]:
if not batch: if not batch:
@@ -179,6 +190,7 @@ class AITutor:
all_sentences=all_sentences, all_sentences=all_sentences,
existing=existing, existing=existing,
course_title=course_title, course_title=course_title,
terms=terms,
) )
last_error: Exception | None = None last_error: Exception | None = None
@@ -190,6 +202,7 @@ class AITutor:
all_sentences=all_sentences, all_sentences=all_sentences,
existing=existing, existing=existing,
course_title=course_title, course_title=course_title,
terms=terms,
) )
except AITutorError as exc: except AITutorError as exc:
last_error = exc last_error = exc
@@ -206,6 +219,7 @@ class AITutor:
all_sentences=all_sentences, all_sentences=all_sentences,
existing=existing, existing=existing,
course_title=course_title, course_title=course_title,
terms=terms,
) )
if last_error is not None: if last_error is not None:
raise last_error raise last_error
@@ -218,6 +232,7 @@ class AITutor:
all_sentences: list[tuple[int, str]], all_sentences: list[tuple[int, str]],
existing: dict[int, str], existing: dict[int, str],
course_title: str, course_title: str,
terms: list[tuple[str, str]] | None = None,
) -> dict[int, str]: ) -> dict[int, str]:
mid = max(1, len(batch) // 2) mid = max(1, len(batch) // 2)
merged: dict[int, str] = {} merged: dict[int, str] = {}
@@ -228,6 +243,7 @@ class AITutor:
all_sentences=all_sentences, all_sentences=all_sentences,
existing=existing, existing=existing,
course_title=course_title, course_title=course_title,
terms=terms,
) )
) )
return merged return merged
@@ -239,29 +255,36 @@ class AITutor:
all_sentences: list[tuple[int, str]], all_sentences: list[tuple[int, str]],
existing: dict[int, str], existing: dict[int, str],
course_title: str, course_title: str,
terms: list[tuple[str, str]] | None = None,
) -> dict[int, str]: ) -> dict[int, str]:
context = course_title.strip() or "口语训练课程" context = course_title.strip() or "口语训练课程"
existing = dict(existing) existing = dict(existing)
before, after = _neighbor_context(batch, all_sentences) before, after = _neighbor_context(batch, all_sentences)
expected_indexes = [index for index, _ in batch] expected_indexes = [index for index, _ in batch]
glossary = _normalize_translation_terms(terms)
system_prompt = ( system_prompt = (
"你是专业的影视字幕翻译。请结合前后文语境,把指定编号的外语句子翻译成自然、口语化的简体中文。" "你是专业的影视字幕翻译。请结合前后文语境,把指定编号的外语句子翻译成自然、口语化的简体中文。"
"必须一句对一句,不能合并、拆分、跳过或改写编号。" "必须一句对一句,不能合并、拆分、跳过或改写编号。"
"只输出 JSON 对象,键是句子编号,值是对应中文,不要输出解释或 markdown。" "只输出 JSON 对象,键是句子编号,值是对应中文,不要输出解释或 markdown。"
) )
if glossary:
system_prompt += "必须严格使用用户指定的专有名词译法,禁止自行音译成其他写法。"
user_parts = [ user_parts = [
f"课程:{context}", f"课程:{context}",
f"本次需要翻译 {len(batch)} 句,编号必须与输入完全一致。", 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]) (index, source, existing[index])
for index, source in before for index, source in before
if existing.get(index, "").strip() if existing.get(index, "").strip()
] ]
if glossary: if previous:
user_parts.append("已有译文(供用词一致,不要重复输出):") user_parts.append("已有译文(供用词一致,不要重复输出):")
user_parts.extend( 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: elif before:
user_parts.append("前文(只供理解语境,不要翻译):") user_parts.append("前文(只供理解语境,不要翻译):")
@@ -284,7 +307,11 @@ class AITutor:
missing = [index for index in expected_indexes if not parsed.get(index, "").strip()] missing = [index for index in expected_indexes if not parsed.get(index, "").strip()]
if missing: if missing:
raise AITutorError("AI translation returned an unexpected response.") 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( def _complete(
self, self,
@@ -512,3 +539,43 @@ def _translations_from_json(data: object, expected: list[int]) -> dict[int, str]
if len(values) == len(expected): if len(values) == len(expected):
return values return values
return None 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"(?<![A-Za-z0-9]){escaped}(?![A-Za-z0-9])", re.IGNORECASE)
return re.compile(escaped)
def _apply_translation_terms(
source_text: str,
zh_text: str,
terms: list[tuple[str, str]] | None,
) -> str:
del source_text
result = zh_text
for source, zh in _normalize_translation_terms(terms):
result = _term_pattern(source).sub(zh, result)
return result

View File

@@ -46,6 +46,8 @@ from .models import (
SentenceBoundaryDocument, SentenceBoundaryDocument,
SentenceExplanationRequest, SentenceExplanationRequest,
SentenceExplanationResponse, SentenceExplanationResponse,
AdminSubtitleTranslationResponse,
SubtitleRetranslateRequest,
SubtitleTranslationResponse, SubtitleTranslationResponse,
PasswordResetRequest, PasswordResetRequest,
SentenceTextUpdate, SentenceTextUpdate,
@@ -668,6 +670,41 @@ def create_app(
background_tasks.add_task(_process_safely, processor, video_hash) background_tasks.add_task(_process_safely, processor, video_hash)
return {"video_hash": video_hash.lower(), "status": "processing_queued"} 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( @application.put(
"/api/v1/admin/videos/{video_hash}/title", "/api/v1/admin/videos/{video_hash}/title",
response_model=VideoSummary, response_model=VideoSummary,

View File

@@ -144,6 +144,31 @@ class SubtitleTranslationResponse(BaseModel):
error: Optional[str] = None 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): class SentenceBoundaryAdjust(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

View File

@@ -2,7 +2,7 @@ import json
import sqlite3 import sqlite3
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Sequence, Tuple
import uuid import uuid
from .auth import hash_password, hash_token, new_token, verify_password from .auth import hash_password, hash_token, new_token, verify_password
@@ -202,6 +202,14 @@ class VideoRepository:
if "zh_text" not in sentence_columns: if "zh_text" not in sentence_columns:
connection.execute("ALTER TABLE sentences ADD COLUMN zh_text TEXT") 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( def upsert_upload(
self, self,
*, *,
@@ -833,6 +841,36 @@ class VideoRepository:
return [_sentence_from_row(row) for row in result_rows] 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: def clear_sentence_translations(self, video_hash: str) -> None:
with self._connect() as connection: with self._connect() as connection:
connection.execute( 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, str(speaker) if speaker is not None else None,
words_json, 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)

View File

@@ -119,3 +119,11 @@ dialog::backdrop { background: rgb(20 25 28 / 55%); }
#upload-form { grid-template-columns: 1fr; } #upload-form { grid-template-columns: 1fr; }
.actions-column { min-width: 250px; } .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; }

View File

@@ -96,6 +96,26 @@
</section> </section>
</main> </main>
<dialog id="retranslate-dialog">
<div class="dialog-heading">
<div>
<h2 id="retranslate-title">重新翻译中文字幕</h2>
<span id="retranslate-meta"></span>
</div>
<button id="close-retranslate-dialog" class="icon-button" type="button" aria-label="关闭">X</button>
</div>
<form id="retranslate-form" class="retranslate-form">
<p>会清空当前中文字幕并重新翻译。可指定专有名词译法,每行一条,格式为 <code>原文=中文</code>。例如 <code>Harimoto=哈瑞么托</code>,也可以把错误译名写在左边:<code>哈达马托=哈瑞么托</code>。</p>
<label>
<span>专有名词译法</span>
<textarea id="retranslate-terms" placeholder="Harimoto=哈瑞么托"></textarea>
</label>
<div class="retranslate-actions">
<button class="primary" type="submit">开始重新翻译</button>
</div>
</form>
</dialog>
<dialog id="sentence-dialog"> <dialog id="sentence-dialog">
<div class="dialog-heading"> <div class="dialog-heading">
<div> <div>
@@ -114,6 +134,6 @@
</div> </div>
</dialog> </dialog>
<script src="/static/admin.js?v=admin-course-title-v1" defer></script> <script src="/static/admin.js?v=admin-retranslate-v1" defer></script>
</body> </body>
</html> </html>

View File

@@ -2,6 +2,7 @@ const state = {
adminKey: localStorage.getItem("oralTrainerAdminKey") || "", adminKey: localStorage.getItem("oralTrainerAdminKey") || "",
videos: [], videos: [],
users: [], users: [],
retranslateVideo: null,
}; };
const elements = { const elements = {
@@ -22,6 +23,12 @@ const elements = {
sentenceMeta: document.querySelector("#sentence-meta"), sentenceMeta: document.querySelector("#sentence-meta"),
sentenceRows: document.querySelector("#sentence-rows"), sentenceRows: document.querySelector("#sentence-rows"),
closeDialog: document.querySelector("#close-dialog"), 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"), userRows: document.querySelector("#user-rows"),
userCount: document.querySelector("#user-count"), userCount: document.querySelector("#user-count"),
userEmptyState: document.querySelector("#user-empty-state"), userEmptyState: document.querySelector("#user-empty-state"),
@@ -38,6 +45,8 @@ elements.refresh.addEventListener("click", () => {
loadUsers(); loadUsers();
}); });
elements.closeDialog.addEventListener("click", () => elements.dialog.close()); 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.uploadForm.addEventListener("submit", uploadVideo);
elements.videoFile.addEventListener("change", () => { elements.videoFile.addEventListener("change", () => {
if (!elements.videoFile.files[0]) return; if (!elements.videoFile.files[0]) return;
@@ -183,6 +192,7 @@ function actionCell(video) {
actions.append(play); actions.append(play);
actions.append(button("改标题", () => renameVideo(video))); actions.append(button("改标题", () => renameVideo(video)));
actions.append(button("句子", () => openSentences(video))); actions.append(button("句子", () => openSentences(video)));
actions.append(button("重新翻译", () => openRetranslate(video)));
actions.append(button("重新处理", () => reprocess(video.video_hash))); actions.append(button("重新处理", () => reprocess(video.video_hash)));
const remove = button("删除", () => deleteVideo(video)); const remove = button("删除", () => deleteVideo(video));
remove.className = "danger"; remove.className = "danger";
@@ -298,7 +308,10 @@ function sentenceRow(video, sentence, totalCount) {
language.append(option); language.append(option);
} }
const textContainer = document.createElement("td"); 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"); const languageContainer = document.createElement("td");
languageContainer.append(language); languageContainer.append(language);
const saveContainer = document.createElement("td"); const saveContainer = document.createElement("td");
@@ -408,6 +421,75 @@ function boundaryControl(video, sentence) {
return container; 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) { async function reprocess(videoHash) {
try { try {
await api(`/api/v1/admin/videos/${videoHash}/process`, { method: "POST" }); await api(`/api/v1/admin/videos/${videoHash}/process`, { method: "POST" });

View File

@@ -4,7 +4,7 @@ import json
import re import re
import threading 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: class FakeResponse:
@@ -173,6 +173,38 @@ def test_ai_tutor_splits_invalid_windows_and_retries(monkeypatch):
assert calls["count"] >= 3 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(): 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('```json\n["一", "二"]\n```', 2) == {0: "一", 1: "二"}
assert _parse_translation_payload("1. 一\n2. 二", 2) == {0: "一", 1: "二"} assert _parse_translation_payload("1. 一\n2. 二", 2) == {0: "一", 1: "二"}

View File

@@ -391,6 +391,59 @@ def test_subtitle_translation_does_not_block_http_on_long_jobs(tmp_path, monkeyp
assert len(requested) == 1 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): def test_oss_video_url_is_used_for_catalog_and_content(tmp_path):
index_path = tmp_path / "boundaries.json" index_path = tmp_path / "boundaries.json"
index_path.write_text("{}", encoding="utf-8") index_path.write_text("{}", encoding="utf-8")

View File

@@ -7,7 +7,13 @@ import threading
from dataclasses import dataclass from dataclasses import dataclass
from .ai_tutor import AITutor, AITutorError from .ai_tutor import AITutor, AITutorError
from .models import SentenceBoundaryDocument, SubtitleTranslationItem, SubtitleTranslationResponse from .models import (
AdminSubtitleTranslationResponse,
SentenceBoundaryDocument,
SubtitleTranslationItem,
SubtitleTranslationResponse,
TranslationTerm,
)
from .repository import VideoRepository from .repository import VideoRepository
@@ -110,6 +116,34 @@ class SubtitleTranslationService:
except (KeyError, ValueError, AITutorError): except (KeyError, ValueError, AITutorError):
logger.exception("Could not restart subtitle translation: video=%s", video_hash) 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: def ensure(self, video_hash: str) -> SubtitleTranslationResponse:
if not getattr(self.ai_tutor, "available", False): if not getattr(self.ai_tutor, "available", False):
raise AITutorError("AI translation is not configured.") raise AITutorError("AI translation is not configured.")
@@ -200,6 +234,7 @@ class SubtitleTranslationService:
course_title=(video["title"] if video is not None else "") or "", course_title=(video["title"] if video is not None else "") or "",
existing=existing, existing=existing,
persist=lambda part: self._persist(job, part), persist=lambda part: self._persist(job, part),
terms=self.repository.get_translation_glossary(job.video_hash),
) )
if translated: if translated:
self._persist(job, translated) self._persist(job, translated)