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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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: "二"}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user