diff --git a/sentence_api/README.md b/sentence_api/README.md index c4aa92b..e4f4a4a 100644 --- a/sentence_api/README.md +++ b/sentence_api/README.md @@ -62,6 +62,7 @@ POST /api/v1/admin/videos/{sha256}/process POST /api/v1/admin/videos/{sha256}/repair-gaps GET /api/v1/admin/videos/{sha256}/subtitle-translation POST /api/v1/admin/videos/{sha256}/subtitle-translation +POST /api/v1/admin/videos/{sha256}/subtitle-translation/repair 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 a2bc569..44125cf 100644 --- a/sentence_api/ai_tutor.py +++ b/sentence_api/ai_tutor.py @@ -14,6 +14,7 @@ import requests logger = logging.getLogger(__name__) _JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE) +_CJK_RE = re.compile("[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]") _NUMBERED_LINE_RE = re.compile(r"^\s*[\[(]?(\d+)[\])]?[\.、::\)]\s*(.+?)\s*$") _TRANSLATE_BATCH_SENTENCES = 12 _TRANSLATE_BATCH_CHARS = 2_200 @@ -95,10 +96,13 @@ class AITutor: raise AITutorError("No sentence text was provided for translation.") glossary = _normalize_translation_terms(terms) + source_by_index = {index: text for index, text in ordered} translations = { index: text.strip() for index, text in (existing or {}).items() - if isinstance(text, str) and text.strip() + if isinstance(text, str) + and text.strip() + and not translation_needs_repair(source_by_index.get(index, ""), text) } pending = [(index, text) for index, text in ordered if index not in translations] if not pending: @@ -304,10 +308,14 @@ class AITutor: timeout_seconds=max(self.timeout_seconds, _TRANSLATE_TIMEOUT_SECONDS), ) parsed = _parse_translation_payload(content, expected_indexes) - missing = [index for index in expected_indexes if not parsed.get(index, "").strip()] + source_by_index = {index: text for index, text in batch} + missing = [ + index + for index in expected_indexes + if translation_needs_repair(source_by_index.get(index, ""), parsed.get(index, "")) + ] if missing: raise AITutorError("AI translation returned an unexpected response.") - 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 @@ -377,6 +385,19 @@ class AITutor: return content.strip() +def translation_needs_repair(source: str | None, zh_text: str | None) -> bool: + """Return True when a sentence still needs a Chinese translation.""" + source_text = (source or "").strip() + if not source_text: + return False + zh = (zh_text or "").strip() + if not zh: + return True + if _CJK_RE.search(zh): + return False + return _CJK_RE.search(source_text) is None + + def _translation_batches( pending: list[tuple[int, str]], all_sentences: list[tuple[int, str]], diff --git a/sentence_api/main.py b/sentence_api/main.py index c5c3619..b6bec6f 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -753,6 +753,23 @@ def create_app( except AITutorError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc + @application.post( + "/api/v1/admin/videos/{video_hash}/subtitle-translation/repair", + response_model=AdminSubtitleTranslationResponse, + dependencies=[Depends(require_admin)], + ) + def repair_subtitle_translation( + video_hash: str = SHA256_PATH, + ) -> AdminSubtitleTranslationResponse: + try: + return application.state.subtitle_translations.repair_missing(video_hash) + 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 afba7ba..a1b7d1c 100644 --- a/sentence_api/models.py +++ b/sentence_api/models.py @@ -177,6 +177,8 @@ class SubtitleRetranslateRequest(BaseModel): class AdminSubtitleTranslationResponse(SubtitleTranslationResponse): terms: List[TranslationTerm] = Field(default_factory=list) + missing_count: int = Field(default=0, ge=0) + attempted_count: int = Field(default=0, ge=0) class SentenceBoundaryAdjust(BaseModel): diff --git a/sentence_api/repository.py b/sentence_api/repository.py index c79f999..09cc017 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -1011,11 +1011,28 @@ class VideoRepository: ) return cursor.rowcount > 0 - def clear_sentence_translations(self, video_hash: str) -> None: + def clear_sentence_translations( + self, + video_hash: str, + indexes: Optional[Sequence[int]] = None, + ) -> None: + normalized_hash = normalize_video_hash(video_hash) with self._connect() as connection: - connection.execute( - "UPDATE sentences SET zh_text = NULL WHERE video_hash = ?", - (normalize_video_hash(video_hash),), + if indexes is None: + connection.execute( + "UPDATE sentences SET zh_text = NULL WHERE video_hash = ?", + (normalized_hash,), + ) + return + unique_indexes = list(dict.fromkeys(indexes)) + if not unique_indexes: + return + connection.executemany( + """ + UPDATE sentences SET zh_text = NULL + WHERE video_hash = ? AND sentence_index = ? + """, + [(normalized_hash, index) for index in unique_indexes], ) def save_sentence_translations( diff --git a/sentence_api/static/admin.css b/sentence_api/static/admin.css index 532e8eb..c857673 100644 --- a/sentence_api/static/admin.css +++ b/sentence_api/static/admin.css @@ -105,6 +105,7 @@ dialog::backdrop { background: rgb(20 25 28 / 55%); } .dialog-heading span { font-size: 12px; color: #68747d; } .dialog-heading-actions { display: flex; align-items: center; gap: 8px; } .gap-row td { background: #fff8ee; color: #9a5b12; font-style: italic; } +.missing-zh-row .zh-text { color: #a45d08; } .sentence-scroll { max-height: calc(100vh - 120px); overflow: auto; } .sentence-scroll textarea { width: min(600px, 45vw); min-width: 260px; min-height: 58px; resize: vertical; padding: 7px; border: 1px solid #b8c0c7; border-radius: 3px; } .sentence-scroll select { min-width: 90px; } diff --git a/sentence_api/static/admin.html b/sentence_api/static/admin.html index c7d637e..20690e3 100644 --- a/sentence_api/static/admin.html +++ b/sentence_api/static/admin.html @@ -111,6 +111,7 @@