add chinese subtitles
This commit is contained in:
@@ -100,6 +100,11 @@ The endpoint requires a signed-in user and accepts only the sentence and course
|
||||
context. `AI_API_URL` and `AI_API_KEY` remain on the server and are never sent
|
||||
to the mobile clients.
|
||||
|
||||
`POST /api/v1/videos/{sha256}/subtitle-translation` translates the full English
|
||||
transcript in one AI request, then stores one Chinese caption per sentence.
|
||||
Later requests reuse the cached Chinese captions until the English sentences
|
||||
change.
|
||||
|
||||
## Assessment
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,6 +45,8 @@ from .models import (
|
||||
SentenceBoundaryDocument,
|
||||
SentenceExplanationRequest,
|
||||
SentenceExplanationResponse,
|
||||
SubtitleTranslationItem,
|
||||
SubtitleTranslationResponse,
|
||||
PasswordResetRequest,
|
||||
SentenceTextUpdate,
|
||||
UserDubShareSummary,
|
||||
@@ -333,6 +335,60 @@ def create_app(
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
return SentenceExplanationResponse(explanation=explanation)
|
||||
|
||||
@application.post(
|
||||
"/api/v1/videos/{video_hash}/subtitle-translation",
|
||||
response_model=SubtitleTranslationResponse,
|
||||
)
|
||||
def translate_subtitles(
|
||||
video_hash: str = SHA256_PATH,
|
||||
user: AuthenticatedUser = Depends(current_user),
|
||||
) -> SubtitleTranslationResponse:
|
||||
del user
|
||||
document = video_repository.get_document(video_hash)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=404, detail="Course is not available.")
|
||||
payload = [
|
||||
(sentence.index, sentence.text or "")
|
||||
for sentence in document.sentences
|
||||
if (sentence.text or "").strip()
|
||||
]
|
||||
if not payload:
|
||||
raise HTTPException(status_code=422, detail="No sentence text is available to translate.")
|
||||
cached = [
|
||||
SubtitleTranslationItem(index=sentence.index, zh_text=sentence.zh_text)
|
||||
for sentence in document.sentences
|
||||
if sentence.zh_text and sentence.zh_text.strip()
|
||||
]
|
||||
complete = len(cached) == len(payload) and all(
|
||||
(sentence.zh_text or "").strip()
|
||||
for sentence in document.sentences
|
||||
if (sentence.text or "").strip()
|
||||
)
|
||||
if complete:
|
||||
return SubtitleTranslationResponse(
|
||||
video_hash=document.video_hash,
|
||||
translations=cached,
|
||||
)
|
||||
video = video_repository.get_video(video_hash)
|
||||
try:
|
||||
translated = application.state.ai_tutor.translate_sentences(
|
||||
payload,
|
||||
course_title=(video["title"] if video is not None else "") or "",
|
||||
)
|
||||
except AITutorError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
saved = video_repository.save_sentence_translations(video_hash, translated)
|
||||
if saved is None:
|
||||
raise HTTPException(status_code=404, detail="Course is not available.")
|
||||
return SubtitleTranslationResponse(
|
||||
video_hash=saved.video_hash,
|
||||
translations=[
|
||||
SubtitleTranslationItem(index=sentence.index, zh_text=sentence.zh_text)
|
||||
for sentence in saved.sentences
|
||||
if sentence.zh_text and sentence.zh_text.strip()
|
||||
],
|
||||
)
|
||||
|
||||
@application.post(
|
||||
"/api/v1/audio/sentence-boundaries",
|
||||
response_model=SentenceBoundaryDocument,
|
||||
|
||||
@@ -12,6 +12,7 @@ class SentenceBoundary(BaseModel):
|
||||
end_ms: int = Field(gt=0)
|
||||
text: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
zh_text: Optional[str] = None
|
||||
reference_speech_duration_ms: Optional[int] = Field(default=None, gt=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -126,6 +127,20 @@ class SentenceExplanationResponse(BaseModel):
|
||||
explanation: str = Field(min_length=1, max_length=6000)
|
||||
|
||||
|
||||
class SubtitleTranslationItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
index: int = Field(ge=0)
|
||||
zh_text: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
class SubtitleTranslationResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
video_hash: str
|
||||
translations: List[SubtitleTranslationItem]
|
||||
|
||||
|
||||
class SentenceBoundaryAdjust(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ class VideoRepository:
|
||||
end_ms INTEGER NOT NULL,
|
||||
text TEXT,
|
||||
language TEXT,
|
||||
zh_text TEXT,
|
||||
reference_speech_duration_ms INTEGER,
|
||||
PRIMARY KEY (video_hash, sentence_index),
|
||||
FOREIGN KEY (video_hash) REFERENCES videos(video_hash) ON DELETE CASCADE
|
||||
@@ -193,6 +194,14 @@ class VideoRepository:
|
||||
if "remote_url" not in video_columns:
|
||||
connection.execute("ALTER TABLE videos ADD COLUMN remote_url TEXT")
|
||||
|
||||
with self._connect() as connection:
|
||||
sentence_columns = {
|
||||
row["name"]
|
||||
for row in connection.execute("PRAGMA table_info(sentences)")
|
||||
}
|
||||
if "zh_text" not in sentence_columns:
|
||||
connection.execute("ALTER TABLE sentences ADD COLUMN zh_text TEXT")
|
||||
|
||||
def upsert_upload(
|
||||
self,
|
||||
*,
|
||||
@@ -431,17 +440,7 @@ class VideoRepository:
|
||||
video_hash=video["video_hash"],
|
||||
duration_ms=video["duration_ms"],
|
||||
algorithm_version=video["algorithm_version"] or "unknown",
|
||||
sentences=[
|
||||
SentenceBoundary(
|
||||
index=row["sentence_index"],
|
||||
start_ms=row["start_ms"],
|
||||
end_ms=row["end_ms"],
|
||||
text=row["text"],
|
||||
language=row["language"],
|
||||
reference_speech_duration_ms=row["reference_speech_duration_ms"],
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
sentences=[_sentence_from_row(row) for row in rows],
|
||||
)
|
||||
|
||||
def update_sentence_text(
|
||||
@@ -462,6 +461,10 @@ class VideoRepository:
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
return None
|
||||
connection.execute(
|
||||
"UPDATE sentences SET zh_text = NULL WHERE video_hash = ?",
|
||||
(normalized_hash,),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
|
||||
(utc_now(), normalized_hash),
|
||||
@@ -470,14 +473,7 @@ class VideoRepository:
|
||||
"SELECT * FROM sentences WHERE video_hash = ? AND sentence_index = ?",
|
||||
(normalized_hash, sentence_index),
|
||||
).fetchone()
|
||||
return SentenceBoundary(
|
||||
index=row["sentence_index"],
|
||||
start_ms=row["start_ms"],
|
||||
end_ms=row["end_ms"],
|
||||
text=row["text"],
|
||||
language=row["language"],
|
||||
reference_speech_duration_ms=row["reference_speech_duration_ms"],
|
||||
)
|
||||
return _sentence_from_row(row)
|
||||
|
||||
def get_sentence(
|
||||
self,
|
||||
@@ -495,14 +491,7 @@ class VideoRepository:
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return SentenceBoundary(
|
||||
index=row["sentence_index"],
|
||||
start_ms=row["start_ms"],
|
||||
end_ms=row["end_ms"],
|
||||
text=row["text"],
|
||||
language=row["language"],
|
||||
reference_speech_duration_ms=row["reference_speech_duration_ms"],
|
||||
)
|
||||
return _sentence_from_row(row)
|
||||
|
||||
def replace_sentence(
|
||||
self,
|
||||
@@ -614,6 +603,10 @@ class VideoRepository:
|
||||
replacement.reference_speech_duration_ms,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE sentences SET zh_text = NULL WHERE video_hash = ?",
|
||||
(normalized_hash,),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
|
||||
(utc_now(), normalized_hash),
|
||||
@@ -627,17 +620,7 @@ class VideoRepository:
|
||||
(normalized_hash, sentence_index, sentence_index + len(rows)),
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
SentenceBoundary(
|
||||
index=row["sentence_index"],
|
||||
start_ms=row["start_ms"],
|
||||
end_ms=row["end_ms"],
|
||||
text=row["text"],
|
||||
language=row["language"],
|
||||
reference_speech_duration_ms=row["reference_speech_duration_ms"],
|
||||
)
|
||||
for row in result_rows
|
||||
]
|
||||
return [_sentence_from_row(row) for row in result_rows]
|
||||
|
||||
|
||||
def merge_with_next_sentence(
|
||||
@@ -727,6 +710,10 @@ class VideoRepository:
|
||||
(old_index - 1, normalized_hash, old_index),
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
"UPDATE sentences SET zh_text = NULL WHERE video_hash = ?",
|
||||
(normalized_hash,),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
|
||||
(utc_now(), normalized_hash),
|
||||
@@ -739,14 +726,7 @@ class VideoRepository:
|
||||
(normalized_hash, sentence_index),
|
||||
).fetchone()
|
||||
|
||||
return SentenceBoundary(
|
||||
index=row["sentence_index"],
|
||||
start_ms=row["start_ms"],
|
||||
end_ms=row["end_ms"],
|
||||
text=row["text"],
|
||||
language=row["language"],
|
||||
reference_speech_duration_ms=row["reference_speech_duration_ms"],
|
||||
)
|
||||
return _sentence_from_row(row)
|
||||
|
||||
def adjust_sentence_boundary(
|
||||
self,
|
||||
@@ -851,17 +831,36 @@ class VideoRepository:
|
||||
(normalized_hash, *updated_indexes),
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
SentenceBoundary(
|
||||
index=row["sentence_index"],
|
||||
start_ms=row["start_ms"],
|
||||
end_ms=row["end_ms"],
|
||||
text=row["text"],
|
||||
language=row["language"],
|
||||
reference_speech_duration_ms=row["reference_speech_duration_ms"],
|
||||
return [_sentence_from_row(row) for row in result_rows]
|
||||
|
||||
def clear_sentence_translations(self, video_hash: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE sentences SET zh_text = NULL WHERE video_hash = ?",
|
||||
(normalize_video_hash(video_hash),),
|
||||
)
|
||||
for row in result_rows
|
||||
]
|
||||
|
||||
def save_sentence_translations(
|
||||
self,
|
||||
video_hash: str,
|
||||
translations: Dict[int, str],
|
||||
) -> Optional[SentenceBoundaryDocument]:
|
||||
normalized_hash = normalize_video_hash(video_hash)
|
||||
with self._connect() as connection:
|
||||
for index, zh_text in translations.items():
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE sentences
|
||||
SET zh_text = ?
|
||||
WHERE video_hash = ? AND sentence_index = ?
|
||||
""",
|
||||
(zh_text.strip(), normalized_hash, index),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
|
||||
(utc_now(), normalized_hash),
|
||||
)
|
||||
return self.get_document(normalized_hash)
|
||||
|
||||
def create_dub_share(
|
||||
self,
|
||||
@@ -1278,6 +1277,21 @@ class VideoRepository:
|
||||
return video
|
||||
|
||||
|
||||
|
||||
def _sentence_from_row(row: sqlite3.Row) -> SentenceBoundary:
|
||||
keys = set(row.keys())
|
||||
zh_text = row["zh_text"] if "zh_text" in keys else None
|
||||
return SentenceBoundary(
|
||||
index=row["sentence_index"],
|
||||
start_ms=row["start_ms"],
|
||||
end_ms=row["end_ms"],
|
||||
text=row["text"],
|
||||
language=row["language"],
|
||||
zh_text=zh_text,
|
||||
reference_speech_duration_ms=row["reference_speech_duration_ms"],
|
||||
)
|
||||
|
||||
|
||||
def _join_adjacent_sentence_text(
|
||||
left: str,
|
||||
right: str,
|
||||
|
||||
@@ -71,3 +71,34 @@ def test_ai_tutor_rejects_unexpected_response(monkeypatch):
|
||||
|
||||
with pytest.raises(AITutorError):
|
||||
tutor.explain("Hello.")
|
||||
|
||||
|
||||
from sentence_api.ai_tutor import _parse_translation_payload
|
||||
|
||||
|
||||
def test_ai_tutor_translates_full_document(monkeypatch):
|
||||
requests_payloads = []
|
||||
|
||||
def fake_post(endpoint, headers, json, timeout):
|
||||
requests_payloads.append((endpoint, headers, json, timeout))
|
||||
return FakeResponse(body={"choices": [{"message": {"content": '["你好。", "见到你很高兴。"]'}}]})
|
||||
|
||||
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
|
||||
tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1.5)
|
||||
|
||||
translated = tutor.translate_sentences(
|
||||
[(0, "Hello."), (1, "Nice to meet you.")],
|
||||
course_title="Greetings",
|
||||
)
|
||||
|
||||
assert translated == {0: "你好。", 1: "见到你很高兴。"}
|
||||
_endpoint, _headers, payload, timeout = requests_payloads[0]
|
||||
user_prompt = payload["messages"][1]["content"]
|
||||
assert "Hello." in user_prompt
|
||||
assert "Nice to meet you." in user_prompt
|
||||
assert timeout == 90.0
|
||||
|
||||
|
||||
def test_parse_translation_payload_accepts_fenced_json_and_numbered_lines():
|
||||
assert _parse_translation_payload('```json\n["一", "二"]\n```', 2) == ["一", "二"]
|
||||
assert _parse_translation_payload("1. 一\n2. 二", 2) == ["一", "二"]
|
||||
|
||||
@@ -202,6 +202,72 @@ def test_sentence_explanation_uses_server_side_ai_client(tmp_path, monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
|
||||
def _ready_video(repository):
|
||||
repository.upsert_upload(
|
||||
video_hash=VIDEO_HASH,
|
||||
title="Greetings",
|
||||
filename="lesson.mp4",
|
||||
stored_filename=f"{VIDEO_HASH}.mp4",
|
||||
content_type="video/mp4",
|
||||
size_bytes=5,
|
||||
language="en",
|
||||
)
|
||||
repository.save_processing_result(
|
||||
SentenceBoundaryDocument(
|
||||
video_hash=VIDEO_HASH,
|
||||
duration_ms=9000,
|
||||
algorithm_version="test",
|
||||
sentences=[
|
||||
SentenceBoundary(index=0, start_ms=0, end_ms=4000, text="Hello.", language="en", reference_speech_duration_ms=3000),
|
||||
SentenceBoundary(index=1, start_ms=4000, end_ms=9000, text="Nice to meet you.", language="en", reference_speech_duration_ms=4000),
|
||||
],
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def test_subtitle_translation_requires_login(tmp_path):
|
||||
client = make_client(tmp_path)
|
||||
client.headers.clear()
|
||||
response = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_subtitle_translation_uses_full_document_and_is_cached(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))
|
||||
return {index: f"中文{index}" for index, _text in sentences}
|
||||
|
||||
class StubAITutor:
|
||||
pass
|
||||
|
||||
stub = StubAITutor()
|
||||
stub.translate_sentences = translate_sentences
|
||||
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
|
||||
|
||||
response = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["video_hash"] == VIDEO_HASH
|
||||
assert payload["translations"] == [
|
||||
{"index": 0, "zh_text": "中文0"},
|
||||
{"index": 1, "zh_text": "中文1"},
|
||||
]
|
||||
document = client.app.state.video_repository.get_document(VIDEO_HASH)
|
||||
assert [sentence.zh_text for sentence in document.sentences] == ["中文0", "中文1"]
|
||||
|
||||
second = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
|
||||
assert second.status_code == 200
|
||||
assert second.json() == payload
|
||||
assert len(requested) == 1
|
||||
assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")]
|
||||
|
||||
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user