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