fixed a bug:translate maybe timeout

This commit is contained in:
2026-10-03 19:49:21 +08:00
parent 6bd64ee4a9
commit 9daea7b807
10 changed files with 554 additions and 99 deletions

View File

@@ -375,8 +375,12 @@ class MainActivity : Activity() {
screenState.chineseTranslationError = "" screenState.chineseTranslationError = ""
applyChineseSubtitle(controller.currentSentence()) applyChineseSubtitle(controller.currentSentence())
translationRequest?.cancel() translationRequest?.cancel()
val expectedIndexes = sentences.mapNotNull { sentence ->
sentence.index.takeIf { !sentence.text.isNullOrBlank() }
}
translationRequest = translationApi.translate( translationRequest = translationApi.translate(
item.id, item.id,
expectedIndexes,
object : SubtitleTranslationCallback { object : SubtitleTranslationCallback {
override fun onSuccess(result: SubtitleTranslationResult) { override fun onSuccess(result: SubtitleTranslationResult) {
if (!screenState.isCurrentTranslationRequest(generation, item.id)) return if (!screenState.isCurrentTranslationRequest(generation, item.id)) return

View File

@@ -41,11 +41,12 @@ internal class SubtitleTranslationApi(
fun translate( fun translate(
videoHash: String, videoHash: String,
expectedIndexes: Collection<Int> = emptyList(),
callback: SubtitleTranslationCallback, callback: SubtitleTranslationCallback,
): CancellableTranslationRequest { ): CancellableTranslationRequest {
val future = executor.submit { val future = executor.submit {
try { try {
val result = requestBlocking(videoHash) val result = loadBlocking(videoHash, expectedIndexes)
if (!Thread.currentThread().isInterrupted) { if (!Thread.currentThread().isInterrupted) {
mainHandler.post { callback.onSuccess(result) } mainHandler.post { callback.onSuccess(result) }
} }
@@ -62,13 +63,31 @@ internal class SubtitleTranslationApi(
executor.shutdownNow() executor.shutdownNow()
} }
private fun requestBlocking(videoHash: String): SubtitleTranslationResult { private fun loadBlocking(
videoHash: String,
expectedIndexes: Collection<Int>,
): SubtitleTranslationResult {
val stored = runCatching {
requestBlocking(videoHash, method = "GET", readTimeoutMs = 15_000)
}.getOrNull()
val expected = expectedIndexes.toSet()
if (stored != null && (expected.isEmpty() || expected.all { stored.translations.containsKey(it) })) {
return stored
}
return requestBlocking(videoHash, method = "POST", readTimeoutMs = 180_000)
}
private fun requestBlocking(
videoHash: String,
method: String,
readTimeoutMs: Int,
): SubtitleTranslationResult {
val endpoint = baseUrl.trimEnd('/') + "/api/v1/videos/$videoHash/subtitle-translation" val endpoint = baseUrl.trimEnd('/') + "/api/v1/videos/$videoHash/subtitle-translation"
val connection = URL(endpoint).openConnection() as HttpURLConnection val connection = URL(endpoint).openConnection() as HttpURLConnection
try { try {
connection.requestMethod = "POST" connection.requestMethod = method
connection.connectTimeout = 15_000 connection.connectTimeout = 15_000
connection.readTimeout = 120_000 connection.readTimeout = readTimeoutMs
connection.doOutput = false connection.doOutput = false
connection.useCaches = false connection.useCaches = false
connection.setRequestProperty("Accept", "application/json") connection.setRequestProperty("Accept", "application/json")

View File

@@ -15,7 +15,7 @@ enum AppConfig {
static let connectTimeout: TimeInterval = 15 static let connectTimeout: TimeInterval = 15
static let readTimeout: TimeInterval = 30 static let readTimeout: TimeInterval = 30
static let assessmentTimeout: TimeInterval = 180 static let assessmentTimeout: TimeInterval = 180
static let translationTimeout: TimeInterval = 120 static let translationTimeout: TimeInterval = 180
static let sha256Pattern = #"^[0-9a-fA-F]{64}$"# static let sha256Pattern = #"^[0-9a-fA-F]{64}$"#

View File

@@ -100,7 +100,29 @@ extension SentenceBoundaryAPI {
.explanation .explanation
} }
static func fetchStoredSubtitles(videoHash: String) async throws -> [Int: String] {
try await requestSubtitles(videoHash: videoHash, method: "GET", timeout: AppConfig.readTimeout)
}
static func translateSubtitles(videoHash: String) async throws -> [Int: String] { static func translateSubtitles(videoHash: String) async throws -> [Int: String] {
try await requestSubtitles(videoHash: videoHash, method: "POST", timeout: AppConfig.translationTimeout)
}
static func loadSubtitles(videoHash: String, expectedIndexes: [Int]) async throws -> [Int: String] {
if let stored = try? await fetchStoredSubtitles(videoHash: videoHash) {
let expected = Set(expectedIndexes)
if expected.isEmpty || expected.isSubset(of: Set(stored.keys)) {
return stored
}
}
return try await translateSubtitles(videoHash: videoHash)
}
private static func requestSubtitles(
videoHash: String,
method: String,
timeout: TimeInterval
) async throws -> [Int: String] {
let hash = videoHash.lowercased() let hash = videoHash.lowercased()
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else { guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
throw SentenceBoundaryAPIError.invalidHash throw SentenceBoundaryAPIError.invalidHash
@@ -112,8 +134,8 @@ extension SentenceBoundaryAPI {
let result = try await HTTPClient.execute( let result = try await HTTPClient.execute(
baseURL: AppConfig.serverBaseURL, baseURL: AppConfig.serverBaseURL,
path: "api/v1/videos/\(hash)/subtitle-translation", path: "api/v1/videos/\(hash)/subtitle-translation",
method: "POST", method: method,
timeout: AppConfig.translationTimeout, timeout: timeout,
headers: headers headers: headers
) )
guard (200...299).contains(result.statusCode) else { guard (200...299).contains(result.statusCode) else {

View File

@@ -649,7 +649,14 @@ struct RootView: View {
let mediaId = item.id let mediaId = item.id
chineseTranslationTask = Task { chineseTranslationTask = Task {
do { do {
let map = try await SentenceBoundaryAPI.translateSubtitles(videoHash: mediaId) let expectedIndexes = item.sentences.compactMap { sentence -> Int? in
let text = (sentence.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : sentence.index
}
let map = try await SentenceBoundaryAPI.loadSubtitles(
videoHash: mediaId,
expectedIndexes: expectedIndexes
)
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
guard controller.currentItem?.id == mediaId else { return } guard controller.currentItem?.id == mediaId else { return }

View File

@@ -100,10 +100,13 @@ 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 context. `AI_API_URL` and `AI_API_KEY` remain on the server and are never sent
to the mobile clients. to the mobile clients.
`POST /api/v1/videos/{sha256}/subtitle-translation` translates the full English Chinese captions are stored on the course in the server database and shared by
transcript in one AI request, then stores one Chinese caption per sentence. every user. `GET /api/v1/videos/{sha256}/subtitle-translation` returns the saved
Later requests reuse the cached Chinese captions until the English sentences captions without calling the AI. `POST` generates any missing captions in small
change. windows with neighboring context, runs several windows in parallel, retries and
splits a window if the model response is invalid, and writes each finished
window immediately so later viewers can reuse it. English sentence edits clear
the saved Chinese captions for that course.
## Assessment ## Assessment

View File

@@ -1,9 +1,11 @@
"""AI-backed sentence explanations and subtitle translation.""" """AI-backed sentence explanations and subtitle translation."""
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass from dataclasses import dataclass
import json import json
import logging import logging
import re import re
import threading
from urllib.parse import urlsplit from urllib.parse import urlsplit
import requests import requests
@@ -12,8 +14,14 @@ import requests
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE) _JSON_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE)
_NUMBERED_LINE_RE = re.compile(r"^\s*(\d+)[\.、::]\s*(.+?)\s*$") _NUMBERED_LINE_RE = re.compile(r"^\s*[\[(]?(\d+)[\])]?[\.、::\)]\s*(.+?)\s*$")
_TRANSLATE_BATCH_CHARS = 12_000 _TRANSLATE_BATCH_SENTENCES = 12
_TRANSLATE_BATCH_CHARS = 2_200
_TRANSLATE_CONTEXT_BEFORE = 8
_TRANSLATE_CONTEXT_AFTER = 4
_TRANSLATE_WORKERS = 4
_TRANSLATE_TIMEOUT_SECONDS = 45.0
_TRANSLATE_SPLIT_THRESHOLD = 4
class AITutorError(RuntimeError): class AITutorError(RuntimeError):
@@ -73,68 +81,210 @@ class AITutor:
sentences: list[tuple[int, str]], sentences: list[tuple[int, str]],
*, *,
course_title: str = "", course_title: str = "",
existing: dict[int, str] | None = None,
persist=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.")
if not sentences: if not sentences:
raise AITutorError("No sentences were provided for translation.") raise AITutorError("No sentences were provided for translation.")
translations: dict[int, str] = {} ordered = [(index, text.strip()) for index, text in sentences if text.strip()]
numbered = [(index, text.strip()) for index, text in sentences if text.strip()] if not ordered:
if not numbered:
raise AITutorError("No sentence text was provided for translation.") raise AITutorError("No sentence text was provided for translation.")
for batch, context_rows in _translation_batches(numbered): translations = {
translations.update( index: text.strip()
self._translate_batch( for index, text in (existing or {}).items()
if isinstance(text, str) and text.strip()
}
pending = [(index, text) for index, text in ordered if index not in translations]
if not pending:
return translations
persist_lock = threading.Lock()
def save_partial(partial: dict[int, str]) -> None:
if not partial:
return
translations.update(partial)
if persist is None:
return
with persist_lock:
persist(partial)
batches = _translation_batches(pending, ordered)
workers = min(_TRANSLATE_WORKERS, len(batches)) or 1
failures: list[tuple[list[tuple[int, str]], Exception]] = []
def run_batch(batch: list[tuple[int, str]]) -> dict[int, str]:
return self._translate_batch_resilient(
batch,
all_sentences=ordered,
existing=translations,
course_title=course_title,
)
if workers == 1:
for batch in batches:
try:
save_partial(run_batch(batch))
except Exception as exc: # noqa: BLE001 - collect and retry below
failures.append((batch, exc))
else:
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(run_batch, batch): batch for batch in batches}
for future in as_completed(futures):
batch = futures[future]
try:
save_partial(future.result())
except Exception as exc: # noqa: BLE001
failures.append((batch, exc))
for batch, error in failures:
logger.warning(
"Translation batch failed, retrying with smaller windows: size=%s error=%s",
len(batch),
error,
)
save_partial(
self._translate_batch_resilient(
batch, batch,
context_rows=context_rows, all_sentences=ordered,
existing=translations,
course_title=course_title, course_title=course_title,
total_count=len(numbered), force_split=len(batch) > 1,
) )
) )
missing = [index for index, _ in numbered 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.")
return translations return translations
def _translate_batch_resilient(
self,
batch: list[tuple[int, str]],
*,
all_sentences: list[tuple[int, str]],
existing: dict[int, str],
course_title: str,
force_split: bool = False,
) -> dict[int, str]:
if not batch:
return {}
if force_split and len(batch) > 1:
return self._translate_split_batch(
batch,
all_sentences=all_sentences,
existing=existing,
course_title=course_title,
)
last_error: Exception | None = None
attempts = 2 if len(batch) <= _TRANSLATE_SPLIT_THRESHOLD else 1
for _ in range(attempts):
try:
return self._translate_batch(
batch,
all_sentences=all_sentences,
existing=existing,
course_title=course_title,
)
except AITutorError as exc:
last_error = exc
logger.warning(
"Translation batch attempt failed: size=%s indexes=%s error=%s",
len(batch),
[index for index, _ in batch],
exc,
)
if len(batch) > 1:
return self._translate_split_batch(
batch,
all_sentences=all_sentences,
existing=existing,
course_title=course_title,
)
if last_error is not None:
raise last_error
raise AITutorError("AI translation did not cover every sentence.")
def _translate_split_batch(
self,
batch: list[tuple[int, str]],
*,
all_sentences: list[tuple[int, str]],
existing: dict[int, str],
course_title: str,
) -> dict[int, str]:
mid = max(1, len(batch) // 2)
merged: dict[int, str] = {}
for part in (batch[:mid], batch[mid:]):
merged.update(
self._translate_batch_resilient(
part,
all_sentences=all_sentences,
existing=existing,
course_title=course_title,
)
)
return merged
def _translate_batch( def _translate_batch(
self, self,
batch: list[tuple[int, str]], batch: list[tuple[int, str]],
*, *,
context_rows: list[tuple[int, str]], all_sentences: list[tuple[int, str]],
existing: dict[int, str],
course_title: str, course_title: str,
total_count: int,
) -> dict[int, str]: ) -> dict[int, str]:
context = course_title.strip() or "口语训练课程" context = course_title.strip() or "口语训练课程"
display_offset = {index: position for position, (index, _) in enumerate(batch, start=1)} existing = dict(existing)
before, after = _neighbor_context(batch, all_sentences)
expected_indexes = [index for index, _ in batch]
system_prompt = ( system_prompt = (
"你是专业的影视字幕翻译。请结合全文语境,把指定的外语句子翻译成自然、口语化的简体中文。" "你是专业的影视字幕翻译。请结合前后文语境,把指定编号的外语句子翻译成自然、口语化的简体中文。"
"必须一句对一句,不能合并、拆分、跳过或改写编号。" "必须一句对一句,不能合并、拆分、跳过或改写编号。"
"只输出 JSON 数组,数组长度必须等于待翻译句子数,第 i 项对应第 i 句中文。" "只输出 JSON 对象,键是句子编号,值是对应中文,不要输出解释或 markdown。"
"不要输出解释、markdown 或编号。"
) )
lines = [f"{display_offset[index]}. {text}" for index, text in batch]
user_parts = [ user_parts = [
f"课程:{context}", f"课程:{context}",
f"全片共 {total_count} 句,本次需要翻译 {len(batch)} 句。", f"本次需要翻译 {len(batch)} 句,编号必须与输入完全一致。",
] ]
if context_rows: glossary = [
(index, source, existing[index])
for index, source in before
if existing.get(index, "").strip()
]
if glossary:
user_parts.append("已有译文(供用词一致,不要重复输出):")
user_parts.extend(
f"[{index}] {source} => {zh}" for index, source, zh in glossary[-6:]
)
elif before:
user_parts.append("前文(只供理解语境,不要翻译):") user_parts.append("前文(只供理解语境,不要翻译):")
user_parts.extend(f"{index + 1}. {text}" for index, text in context_rows) user_parts.extend(f"[{index}] {text}" for index, text in before)
if after:
user_parts.append("后文(只供理解语境,不要翻译):")
user_parts.extend(f"[{index}] {text}" for index, text in after)
user_parts.append("待翻译句子:") user_parts.append("待翻译句子:")
user_parts.extend(lines) user_parts.extend(f"[{index}] {text}" for index, text in batch)
user_parts.append(
"输出示例:{" + ",".join(f'"{index}":"中文"' for index in expected_indexes[:2]) + "}"
)
content = self._complete( content = self._complete(
system_prompt, system_prompt,
"\n".join(user_parts), "\n".join(user_parts),
temperature=0.2, temperature=0.2,
timeout_seconds=max(self.timeout_seconds, 90.0), timeout_seconds=max(self.timeout_seconds, _TRANSLATE_TIMEOUT_SECONDS),
) )
zh_rows = _parse_translation_payload(content, len(batch)) parsed = _parse_translation_payload(content, expected_indexes)
return { missing = [index for index in expected_indexes if not parsed.get(index, "").strip()]
index: zh_rows[offset - 1] if missing:
for index, offset in display_offset.items() raise AITutorError("AI translation returned an unexpected response.")
} return {index: parsed[index] for index in expected_indexes}
def _complete( def _complete(
self, self,
@@ -201,67 +351,164 @@ class AITutor:
def _translation_batches( def _translation_batches(
sentences: list[tuple[int, str]], pending: list[tuple[int, str]],
) -> list[tuple[list[tuple[int, str]], list[tuple[int, str]]]]: all_sentences: list[tuple[int, str]],
batches: list[list[tuple[int, str]]] = [] ) -> list[list[tuple[int, str]]]:
position = {index: offset for offset, (index, _) in enumerate(all_sentences)}
ordered_pending = sorted(pending, key=lambda item: position[item[0]])
runs: list[list[tuple[int, str]]] = []
current: list[tuple[int, str]] = [] current: list[tuple[int, str]] = []
current_chars = 0 last_pos: int | None = None
for item in sentences: for item in ordered_pending:
extra = len(item[1]) + 8 pos = position[item[0]]
if current and current_chars + extra > _TRANSLATE_BATCH_CHARS: if current and last_pos is not None and pos != last_pos + 1:
batches.append(current) runs.append(current)
current = [] current = []
current_chars = 0
current.append(item) current.append(item)
current_chars += extra last_pos = pos
if current: if current:
batches.append(current) runs.append(current)
result: list[tuple[list[tuple[int, str]], list[tuple[int, str]]]] = []
consumed = 0 batches: list[list[tuple[int, str]]] = []
for batch in batches: for run in runs:
context_rows = sentences[max(0, consumed - 8):consumed] batch: list[tuple[int, str]] = []
result.append((batch, context_rows)) batch_chars = 0
consumed += len(batch) for item in run:
return result extra = len(item[1]) + 8
too_many = len(batch) >= _TRANSLATE_BATCH_SENTENCES
too_long = batch and batch_chars + extra > _TRANSLATE_BATCH_CHARS
if too_many or too_long:
batches.append(batch)
batch = []
batch_chars = 0
batch.append(item)
batch_chars += extra
if batch:
batches.append(batch)
return batches
def _parse_translation_payload(content: str, expected_count: int) -> list[str]: def _neighbor_context(
batch: list[tuple[int, str]],
all_sentences: list[tuple[int, str]],
) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
position = {index: offset for offset, (index, _) in enumerate(all_sentences)}
first = position[batch[0][0]]
last = position[batch[-1][0]]
before = all_sentences[max(0, first - _TRANSLATE_CONTEXT_BEFORE):first]
after = all_sentences[last + 1:last + 1 + _TRANSLATE_CONTEXT_AFTER]
return before, after
def _parse_translation_payload(content: str, expected_indexes: list[int] | int) -> dict[int, str]:
if isinstance(expected_indexes, int):
expected = list(range(expected_indexes))
else:
expected = list(expected_indexes)
text = _JSON_FENCE_RE.sub("", content.strip()).strip() text = _JSON_FENCE_RE.sub("", content.strip()).strip()
try: for candidate in (text, *_json_snippets(text)):
data = json.loads(text) try:
parsed = _translations_from_json(data, expected_count) parsed = _translations_from_json(json.loads(candidate), expected)
if parsed is not None: except (TypeError, ValueError):
parsed = None
if parsed:
return parsed return parsed
except (TypeError, ValueError):
pass
numbered: dict[int, str] = {} numbered: dict[int, str] = {}
for line in text.splitlines(): for line in text.splitlines():
match = _NUMBERED_LINE_RE.match(line) match = _NUMBERED_LINE_RE.match(line)
if match is None: if match is None:
continue continue
numbered[int(match.group(1))] = match.group(2).strip() numbered[int(match.group(1))] = match.group(2).strip()
if len(numbered) == expected_count and all( by_original = {
index in numbered and numbered[index] for index in range(1, expected_count + 1) index: numbered[index]
): for index in expected
return [numbered[index] for index in range(1, expected_count + 1)] if numbered.get(index, "").strip()
}
if len(by_original) == len(expected):
return by_original
by_order = {
expected[offset]: numbered[offset + 1]
for offset in range(len(expected))
if numbered.get(offset + 1, "").strip()
}
if len(by_order) == len(expected):
return by_order
raise AITutorError("AI translation returned an unexpected response.") raise AITutorError("AI translation returned an unexpected response.")
def _translations_from_json(data: object, expected_count: int) -> list[str] | None: def _json_snippets(text: str) -> list[str]:
snippets: list[str] = []
for opener, closer in (("{", "}"), ("[", "]")):
start = text.find(opener)
while start != -1:
depth = 0
in_string = False
escape = False
for offset, char in enumerate(text[start:], start=start):
if in_string:
if escape:
escape = False
elif char == "\\":
escape = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char == opener:
depth += 1
elif char == closer:
depth -= 1
if depth == 0:
snippets.append(text[start:offset + 1])
break
start = text.find(opener, start + 1)
return snippets
def _translations_from_json(data: object, expected: list[int]) -> dict[int, str] | None:
expected_set = set(expected)
if isinstance(data, dict): if isinstance(data, dict):
nested = None
for key in ("translations", "sentences", "zh", "items"): for key in ("translations", "sentences", "zh", "items"):
if key in data: if key in data:
data = data[key] nested = data[key]
break break
if isinstance(data, list) and len(data) == expected_count: if nested is not None:
if all(isinstance(item, str) and item.strip() for item in data): parsed = _translations_from_json(nested, expected)
return [item.strip() for item in data] if parsed:
if all(isinstance(item, dict) for item in data): return parsed
values: list[str] = [] values: dict[int, str] = {}
for item in data: for raw_key, raw_value in data.items():
zh = item.get("zh_text") or item.get("zh") or item.get("text") try:
if not isinstance(zh, str) or not zh.strip(): index = int(raw_key)
return None except (TypeError, ValueError):
values.append(zh.strip()) continue
if index not in expected_set or not isinstance(raw_value, str) or not raw_value.strip():
continue
values[index] = raw_value.strip()
if len(values) == len(expected):
return values return values
return None
if isinstance(data, list) and len(data) == len(expected):
if all(isinstance(item, str) and item.strip() for item in data):
return {
index: item.strip()
for index, item in zip(expected, data)
}
if all(isinstance(item, dict) for item in data):
values = {}
for item in data:
raw_index = item.get("index")
zh = item.get("zh_text") or item.get("zh") or item.get("text")
try:
index = int(raw_index)
except (TypeError, ValueError):
continue
if index not in expected_set or not isinstance(zh, str) or not zh.strip():
continue
values[index] = zh.strip()
if len(values) == len(expected):
return values
return None return None

View File

@@ -335,6 +335,32 @@ def create_app(
raise HTTPException(status_code=503, detail=str(exc)) from exc raise HTTPException(status_code=503, detail=str(exc)) from exc
return SentenceExplanationResponse(explanation=explanation) return SentenceExplanationResponse(explanation=explanation)
def stored_subtitle_translations(
document: SentenceBoundaryDocument,
) -> SubtitleTranslationResponse:
return SubtitleTranslationResponse(
video_hash=document.video_hash,
translations=[
SubtitleTranslationItem(index=sentence.index, zh_text=sentence.zh_text)
for sentence in document.sentences
if sentence.zh_text and sentence.zh_text.strip()
],
)
@application.get(
"/api/v1/videos/{video_hash}/subtitle-translation",
response_model=SubtitleTranslationResponse,
)
def get_subtitle_translation(
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.")
return stored_subtitle_translations(document)
@application.post( @application.post(
"/api/v1/videos/{video_hash}/subtitle-translation", "/api/v1/videos/{video_hash}/subtitle-translation",
response_model=SubtitleTranslationResponse, response_model=SubtitleTranslationResponse,
@@ -354,26 +380,28 @@ def create_app(
] ]
if not payload: if not payload:
raise HTTPException(status_code=422, detail="No sentence text is available to translate.") raise HTTPException(status_code=422, detail="No sentence text is available to translate.")
cached = [ cached = stored_subtitle_translations(document).translations
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( complete = len(cached) == len(payload) and all(
(sentence.zh_text or "").strip() (sentence.zh_text or "").strip()
for sentence in document.sentences for sentence in document.sentences
if (sentence.text or "").strip() if (sentence.text or "").strip()
) )
if complete: if complete:
return SubtitleTranslationResponse( return stored_subtitle_translations(document)
video_hash=document.video_hash,
translations=cached,
)
video = video_repository.get_video(video_hash) video = video_repository.get_video(video_hash)
existing = {
item.index: item.zh_text
for item in cached
}
try: try:
translated = application.state.ai_tutor.translate_sentences( translated = application.state.ai_tutor.translate_sentences(
payload, payload,
course_title=(video["title"] if video is not None else "") or "", course_title=(video["title"] if video is not None else "") or "",
existing=existing,
persist=lambda part: video_repository.save_sentence_translations(
video_hash,
part,
),
) )
except AITutorError as exc: except AITutorError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc raise HTTPException(status_code=503, detail=str(exc)) from exc

View File

@@ -1,6 +1,10 @@
import pytest import pytest
from sentence_api.ai_tutor import AITutor, AITutorError import json
import re
import threading
from sentence_api.ai_tutor import AITutor, AITutorError, _parse_translation_payload
class FakeResponse: class FakeResponse:
@@ -73,15 +77,13 @@ def test_ai_tutor_rejects_unexpected_response(monkeypatch):
tutor.explain("Hello.") tutor.explain("Hello.")
from sentence_api.ai_tutor import _parse_translation_payload
def test_ai_tutor_translates_full_document(monkeypatch): def test_ai_tutor_translates_full_document(monkeypatch):
requests_payloads = [] requests_payloads = []
def fake_post(endpoint, headers, json, timeout): def fake_post(endpoint, headers, json, timeout):
requests_payloads.append((endpoint, headers, json, timeout)) requests_payloads.append((endpoint, headers, json, timeout))
return FakeResponse(body={"choices": [{"message": {"content": '["你好。", "见到你很高兴。"]'}}]}) return FakeResponse(body={"choices": [{"message": {"content": '{"0":"你好。","1":"见到你很高兴。"}'}}]})
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post) monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1.5) tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1.5)
@@ -94,11 +96,87 @@ def test_ai_tutor_translates_full_document(monkeypatch):
assert translated == {0: "你好。", 1: "见到你很高兴。"} assert translated == {0: "你好。", 1: "见到你很高兴。"}
_endpoint, _headers, payload, timeout = requests_payloads[0] _endpoint, _headers, payload, timeout = requests_payloads[0]
user_prompt = payload["messages"][1]["content"] user_prompt = payload["messages"][1]["content"]
assert "Hello." in user_prompt assert "[0] Hello." in user_prompt
assert "Nice to meet you." in user_prompt assert "[1] Nice to meet you." in user_prompt
assert timeout == 90.0 assert timeout == 45.0
def _translation_response_from_prompt(payload):
user_prompt = payload["messages"][1]["content"]
section = user_prompt.split("待翻译句子:", 1)[1]
indexes = [int(match) for match in re.findall(r"^\[(\d+)\]", section, re.M)]
body = {str(index): f"中文{index}" for index in indexes}
return FakeResponse(
body={"choices": [{"message": {"content": json.dumps(body, ensure_ascii=False)}}]}
)
def test_ai_tutor_translates_long_input_in_batches(monkeypatch):
requests_payloads = []
lock = threading.Lock()
def fake_post(endpoint, headers, json, timeout):
with lock:
requests_payloads.append(json)
return _translation_response_from_prompt(json)
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1)
sentences = [(index, f"This is sentence {index} with enough text.") for index in range(30)]
translated = tutor.translate_sentences(sentences, course_title="Long talk")
assert translated == {index: f"中文{index}" for index in range(30)}
assert len(requests_payloads) >= 3
first_prompt = requests_payloads[0]["messages"][1]["content"]
assert "前文" in first_prompt or "后文" in first_prompt or "待翻译句子:" in first_prompt
def test_ai_tutor_skips_cached_sentences_and_persists_windows(monkeypatch):
persisted = []
def fake_post(endpoint, headers, json, timeout):
return _translation_response_from_prompt(json)
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, "Hello."), (1, "Thanks."), (2, "Bye.")],
existing={0: "你好。"},
persist=persisted.append,
course_title="Greetings",
)
assert translated[0] == "你好。"
assert translated[1] == "中文1"
assert translated[2] == "中文2"
assert persisted
assert 0 not in persisted[0]
def test_ai_tutor_splits_invalid_windows_and_retries(monkeypatch):
calls = {"count": 0}
def fake_post(endpoint, headers, json, timeout):
calls["count"] += 1
if calls["count"] == 1:
return FakeResponse(body={"choices": [{"message": {"content": "not-json"}}]})
return _translation_response_from_prompt(json)
monkeypatch.setattr("sentence_api.ai_tutor.requests.post", fake_post)
tutor = AITutor("https://ai.test/v1", "secret", "test-model", 1)
sentences = [(index, f"This is sentence {index} with enough text.") for index in range(12)]
translated = tutor.translate_sentences(sentences)
assert translated == {index: f"中文{index}" for index in range(12)}
assert calls["count"] >= 3
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) == ["一", "二"] assert _parse_translation_payload('```json\n["一", "二"]\n```', 2) == {0: "一", 1: "二"}
assert _parse_translation_payload("1. 一\n2. 二", 2) == ["一", "二"] assert _parse_translation_payload("1. 一\n2. 二", 2) == {0: "一", 1: "二"}
assert _parse_translation_payload('prefix {"12":"甲","13":"乙"} suffix', [12, 13]) == {
12: "甲",
13: "乙",
}

View File

@@ -268,6 +268,53 @@ def test_subtitle_translation_uses_full_document_and_is_cached(tmp_path, monkeyp
assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")] assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")]
def test_subtitle_translation_is_shared_with_other_users(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)
first = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert first.status_code == 200, first.text
stored = client.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert stored.status_code == 200
assert stored.json() == first.json()
other = TestClient(client.app)
signup = other.post(
"/api/v1/auth/register",
json={"username": "viewer2", "password": "password-123"},
)
assert signup.status_code == 201, signup.text
other.headers.update({"Authorization": f"Bearer {signup.json()['token']}"})
shared = other.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert shared.status_code == 200
assert shared.json()["translations"] == [
{"index": 0, "zh_text": "中文0"},
{"index": 1, "zh_text": "中文1"},
]
boundaries = other.get(f"/api/v1/videos/{VIDEO_HASH}/sentence-boundaries")
assert boundaries.status_code == 200
assert [item["zh_text"] for item in boundaries.json()["sentences"]] == ["中文0", "中文1"]
reused = other.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert reused.status_code == 200
assert reused.json() == first.json()
assert len(requested) == 1
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")