add a retranslate chinese function
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"(?<![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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -96,6 +96,26 @@
|
||||
</section>
|
||||
</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">
|
||||
<div class="dialog-heading">
|
||||
<div>
|
||||
@@ -114,6 +134,6 @@
|
||||
</div>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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: "二"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user