add chinese subtitles

This commit is contained in:
2026-10-03 12:14:17 +08:00
parent 138d446d73
commit 6bd64ee4a9
22 changed files with 922 additions and 77 deletions

View File

@@ -1,7 +1,9 @@
"""AI-backed sentence explanations for the mobile clients."""
"""AI-backed sentence explanations and subtitle translation."""
from dataclasses import dataclass
import json
import logging
import re
from urllib.parse import urlsplit
import requests
@@ -9,6 +11,10 @@ import requests
logger = logging.getLogger(__name__)
_JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)
_NUMBERED_LINE_RE = re.compile(r"^\s*(\d+)[\.、::]\s*(.+?)\s*$")
_TRANSLATE_BATCH_CHARS = 12_000
class AITutorError(RuntimeError):
"""Raised when the configured AI service cannot answer a request."""
@@ -60,9 +66,88 @@ class AITutor:
user_prompt = (
f"课程:{context}\n位置:{position}\n{language_note}句子:{sentence_text}"
)
return self._complete(system_prompt, user_prompt, temperature=0.3)
def translate_sentences(
self,
sentences: list[tuple[int, str]],
*,
course_title: str = "",
) -> dict[int, str]:
if not self.available:
raise AITutorError("AI translation is not configured.")
if not sentences:
raise AITutorError("No sentences were provided for translation.")
translations: dict[int, str] = {}
numbered = [(index, text.strip()) for index, text in sentences if text.strip()]
if not numbered:
raise AITutorError("No sentence text was provided for translation.")
for batch, context_rows in _translation_batches(numbered):
translations.update(
self._translate_batch(
batch,
context_rows=context_rows,
course_title=course_title,
total_count=len(numbered),
)
)
missing = [index for index, _ in numbered if not translations.get(index, "").strip()]
if missing:
raise AITutorError("AI translation did not cover every sentence.")
return translations
def _translate_batch(
self,
batch: list[tuple[int, str]],
*,
context_rows: list[tuple[int, str]],
course_title: str,
total_count: int,
) -> dict[int, str]:
context = course_title.strip() or "口语训练课程"
display_offset = {index: position for position, (index, _) in enumerate(batch, start=1)}
system_prompt = (
"你是专业的影视字幕翻译。请结合全文语境,把指定的外语句子翻译成自然、口语化的简体中文。"
"必须一句对一句,不能合并、拆分、跳过或改写编号。"
"只输出 JSON 数组,数组长度必须等于待翻译句子数,第 i 项对应第 i 句中文。"
"不要输出解释、markdown 或编号。"
)
lines = [f"{display_offset[index]}. {text}" for index, text in batch]
user_parts = [
f"课程:{context}",
f"全片共 {total_count} 句,本次需要翻译 {len(batch)} 句。",
]
if context_rows:
user_parts.append("前文(只供理解语境,不要翻译):")
user_parts.extend(f"{index + 1}. {text}" for index, text in context_rows)
user_parts.append("待翻译句子:")
user_parts.extend(lines)
content = self._complete(
system_prompt,
"\n".join(user_parts),
temperature=0.2,
timeout_seconds=max(self.timeout_seconds, 90.0),
)
zh_rows = _parse_translation_payload(content, len(batch))
return {
index: zh_rows[offset - 1]
for index, offset in display_offset.items()
}
def _complete(
self,
system_prompt: str,
user_prompt: str,
*,
temperature: float,
timeout_seconds: float | None = None,
) -> str:
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
timeout = self.timeout_seconds if timeout_seconds is None else timeout_seconds
try:
response = requests.post(
self.request_url,
@@ -73,14 +158,14 @@ class AITutor:
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.3,
"temperature": temperature,
"stream": False,
},
timeout=self.timeout_seconds,
timeout=timeout,
)
except requests.RequestException as exc:
logger.exception(
"AI explanation request failed: endpoint=%s model=%s",
"AI request failed: endpoint=%s model=%s",
self.request_url,
self.model,
)
@@ -88,7 +173,7 @@ class AITutor:
if response.status_code < 200 or response.status_code >= 300:
logger.warning(
"AI explanation returned HTTP %s: endpoint=%s model=%s body=%s",
"AI service returned HTTP %s: endpoint=%s model=%s body=%s",
response.status_code,
self.request_url,
self.model,
@@ -97,19 +182,86 @@ class AITutor:
raise AITutorError(f"AI service returned HTTP {response.status_code}.")
try:
body = response.json()
explanation = body["choices"][0]["message"]["content"]
content = body["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError, ValueError) as exc:
logger.warning(
"AI explanation returned an unexpected body: endpoint=%s body=%s",
"AI service returned an unexpected body: endpoint=%s body=%s",
self.request_url,
response.text[:1000],
)
raise AITutorError("AI service returned an unexpected response.") from exc
if not isinstance(explanation, str) or not explanation.strip():
if not isinstance(content, str) or not content.strip():
logger.warning(
"AI explanation returned empty content: endpoint=%s body=%s",
"AI service returned empty content: endpoint=%s body=%s",
self.request_url,
response.text[:1000],
)
raise AITutorError("AI service returned an empty explanation.")
return explanation.strip()
return content.strip()
def _translation_batches(
sentences: list[tuple[int, str]],
) -> list[tuple[list[tuple[int, str]], list[tuple[int, str]]]]:
batches: list[list[tuple[int, str]]] = []
current: list[tuple[int, str]] = []
current_chars = 0
for item in sentences:
extra = len(item[1]) + 8
if current and current_chars + extra > _TRANSLATE_BATCH_CHARS:
batches.append(current)
current = []
current_chars = 0
current.append(item)
current_chars += extra
if current:
batches.append(current)
result: list[tuple[list[tuple[int, str]], list[tuple[int, str]]]] = []
consumed = 0
for batch in batches:
context_rows = sentences[max(0, consumed - 8):consumed]
result.append((batch, context_rows))
consumed += len(batch)
return result
def _parse_translation_payload(content: str, expected_count: int) -> list[str]:
text = _JSON_FENCE_RE.sub("", content.strip()).strip()
try:
data = json.loads(text)
parsed = _translations_from_json(data, expected_count)
if parsed is not None:
return parsed
except (TypeError, ValueError):
pass
numbered: dict[int, str] = {}
for line in text.splitlines():
match = _NUMBERED_LINE_RE.match(line)
if match is None:
continue
numbered[int(match.group(1))] = match.group(2).strip()
if len(numbered) == expected_count and all(
index in numbered and numbered[index] for index in range(1, expected_count + 1)
):
return [numbered[index] for index in range(1, expected_count + 1)]
raise AITutorError("AI translation returned an unexpected response.")
def _translations_from_json(data: object, expected_count: int) -> list[str] | None:
if isinstance(data, dict):
for key in ("translations", "sentences", "zh", "items"):
if key in data:
data = data[key]
break
if isinstance(data, list) and len(data) == expected_count:
if all(isinstance(item, str) and item.strip() for item in data):
return [item.strip() for item in data]
if all(isinstance(item, dict) for item in data):
values: list[str] = []
for item in data:
zh = item.get("zh_text") or item.get("zh") or item.get("text")
if not isinstance(zh, str) or not zh.strip():
return None
values.append(zh.strip())
return values
return None