continue fixing

This commit is contained in:
2026-10-03 20:52:56 +08:00
parent beb29429d2
commit d5609c5c42
10 changed files with 491 additions and 104 deletions

View File

@@ -102,11 +102,12 @@ to the mobile clients.
Chinese captions are stored on the course in the server database and shared by
every user. `GET /api/v1/videos/{sha256}/subtitle-translation` returns the saved
captions without calling the AI. `POST` generates any missing captions in small
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.
captions without calling the AI, including `complete` and `status` so clients can
poll long videos. `POST` starts missing-caption generation in the background and
returns quickly; it translates in small 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

View File

@@ -29,6 +29,7 @@ from fastapi.staticfiles import StaticFiles
from starlette.concurrency import run_in_threadpool
from starlette.requests import ClientDisconnect
from .ai_tutor import AITutor, AITutorError
from .translation_jobs import SubtitleTranslationService
from .assessment import AssessmentService
from .auth import AuthenticatedUser
@@ -45,7 +46,6 @@ from .models import (
SentenceBoundaryDocument,
SentenceExplanationRequest,
SentenceExplanationResponse,
SubtitleTranslationItem,
SubtitleTranslationResponse,
PasswordResetRequest,
SentenceTextUpdate,
@@ -129,6 +129,10 @@ def create_app(
application.state.oss_uploader = oss
application.state.video_processor = processor
application.state.assessment_service = assessment_service
application.state.subtitle_translations = SubtitleTranslationService(
video_repository,
lambda: application.state.ai_tutor,
)
static_dir = Path(__file__).resolve().parent / "static"
if static_dir.is_dir():
@@ -338,14 +342,7 @@ def create_app(
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()
],
)
return application.state.subtitle_translations.snapshot(document)
@application.get(
"/api/v1/videos/{video_hash}/subtitle-translation",
@@ -356,10 +353,10 @@ def create_app(
user: AuthenticatedUser = Depends(current_user),
) -> SubtitleTranslationResponse:
del user
document = video_repository.get_document(video_hash)
if document is None:
snapshot = application.state.subtitle_translations.current(video_hash)
if snapshot is None:
raise HTTPException(status_code=404, detail="Course is not available.")
return stored_subtitle_translations(document)
return snapshot
@application.post(
"/api/v1/videos/{video_hash}/subtitle-translation",
@@ -370,52 +367,14 @@ def create_app(
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 = stored_subtitle_translations(document).translations
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 stored_subtitle_translations(document)
video = video_repository.get_video(video_hash)
existing = {
item.index: item.zh_text
for item in cached
}
try:
translated = application.state.ai_tutor.translate_sentences(
payload,
course_title=(video["title"] if video is not None else "") or "",
existing=existing,
persist=lambda part: video_repository.save_sentence_translations(
video_hash,
part,
),
)
return application.state.subtitle_translations.ensure(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
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",

View File

@@ -139,6 +139,9 @@ class SubtitleTranslationResponse(BaseModel):
video_hash: str
translations: List[SubtitleTranslationItem]
complete: bool = True
status: str = "ready"
error: Optional[str] = None
class SentenceBoundaryAdjust(BaseModel):

View File

@@ -1,4 +1,6 @@
import json
import threading
import time
from dataclasses import replace
from fastapi.testclient import TestClient
@@ -14,6 +16,18 @@ from sentence_api.store import BoundaryStore
VIDEO_HASH = "a" * 64
def wait_for_subtitle_translation(client, video_hash, timeout=5.0):
deadline = time.time() + timeout
last = None
while time.time() < deadline:
last = client.get(f"/api/v1/videos/{video_hash}/subtitle-translation")
if last.status_code == 200 and last.json().get("complete"):
return last
time.sleep(0.05)
raise AssertionError(f"subtitle translation did not complete: {getattr(last, 'text', last)}")
def make_client(tmp_path, admin_api_key=""):
index_path = tmp_path / "boundaries.json"
@@ -172,7 +186,7 @@ def test_sentence_explanation_uses_server_side_ai_client(tmp_path, monkeypatch):
return "这句话表示打招呼。"
class StubAITutor:
pass
available = True
stub = StubAITutor()
stub.explain = explain
@@ -244,7 +258,7 @@ def test_subtitle_translation_uses_full_document_and_is_cached(tmp_path, monkeyp
return {index: f"中文{index}" for index, _text in sentences}
class StubAITutor:
pass
available = True
stub = StubAITutor()
stub.translate_sentences = translate_sentences
@@ -252,8 +266,12 @@ def test_subtitle_translation_uses_full_document_and_is_cached(tmp_path, monkeyp
response = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert response.status_code == 200, response.text
if not response.json().get("complete"):
response = wait_for_subtitle_translation(client, VIDEO_HASH)
payload = response.json()
assert payload["video_hash"] == VIDEO_HASH
assert payload["complete"] is True
assert payload["status"] == "ready"
assert payload["translations"] == [
{"index": 0, "zh_text": "中文0"},
{"index": 1, "zh_text": "中文1"},
@@ -263,7 +281,8 @@ def test_subtitle_translation_uses_full_document_and_is_cached(tmp_path, monkeyp
second = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert second.status_code == 200
assert second.json() == payload
assert second.json()["translations"] == payload["translations"]
assert second.json()["complete"] is True
assert len(requested) == 1
assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")]
@@ -278,7 +297,7 @@ def test_subtitle_translation_is_shared_with_other_users(tmp_path, monkeypatch):
return {index: f"中文{index}" for index, _text in sentences}
class StubAITutor:
pass
available = True
stub = StubAITutor()
stub.translate_sentences = translate_sentences
@@ -286,10 +305,13 @@ def test_subtitle_translation_is_shared_with_other_users(tmp_path, monkeypatch):
first = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert first.status_code == 200, first.text
if not first.json().get("complete"):
first = wait_for_subtitle_translation(client, VIDEO_HASH)
stored = client.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert stored.status_code == 200
assert stored.json() == first.json()
assert stored.json()["translations"] == first.json()["translations"]
assert stored.json()["complete"] is True
other = TestClient(client.app)
signup = other.post(
@@ -311,7 +333,61 @@ def test_subtitle_translation_is_shared_with_other_users(tmp_path, monkeypatch):
reused = other.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert reused.status_code == 200
assert reused.json() == first.json()
assert reused.json()["translations"] == first.json()["translations"]
assert reused.json()["complete"] is True
assert len(requested) == 1
def test_subtitle_translation_does_not_block_http_on_long_jobs(tmp_path, monkeypatch):
client = make_client(tmp_path)
_ready_video(client.app.state.video_repository)
started = threading.Event()
release = threading.Event()
requested = []
def translate_sentences(sentences, **kwargs):
requested.append(sentences)
persist = kwargs.get("persist")
if persist is not None:
persist({0: "中文0"})
started.set()
assert release.wait(timeout=5)
if persist is not None:
persist({1: "中文1"})
return {0: "中文0", 1: "中文1"}
class StubAITutor:
available = True
stub = StubAITutor()
stub.translate_sentences = translate_sentences
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
client.app.state.subtitle_translations.ai_tutor = stub
client.app.state.subtitle_translations.post_wait_seconds = 0.2
response = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert started.wait(timeout=2)
assert response.status_code == 200, response.text
payload = response.json()
assert payload["complete"] is False
assert payload["status"] == "running"
if {"index": 0, "zh_text": "中文0"} not in payload["translations"]:
payload = client.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation").json()
assert {"index": 0, "zh_text": "中文0"} in payload["translations"]
stored = client.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
assert stored.status_code == 200
assert stored.json()["status"] == "running"
assert stored.json()["complete"] is False
release.set()
finished = wait_for_subtitle_translation(client, VIDEO_HASH)
assert finished.json()["complete"] is True
assert finished.json()["translations"] == [
{"index": 0, "zh_text": "中文0"},
{"index": 1, "zh_text": "中文1"},
]
assert len(requested) == 1

View File

@@ -0,0 +1,181 @@
"""Background subtitle translation so long videos do not block HTTP."""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass
from .ai_tutor import AITutor, AITutorError
from .models import SentenceBoundaryDocument, SubtitleTranslationItem, SubtitleTranslationResponse
from .repository import VideoRepository
logger = logging.getLogger(__name__)
@dataclass
class _Job:
video_hash: str
done: threading.Event
lock: threading.Lock
running: bool = True
error: str | None = None
thread: threading.Thread | None = None
class SubtitleTranslationService:
def __init__(
self,
repository: VideoRepository,
ai_tutor: AITutor,
*,
post_wait_seconds: float = 8.0,
) -> None:
self.repository = repository
self._ai_tutor = ai_tutor
self.post_wait_seconds = post_wait_seconds
self._lock = threading.Lock()
self._jobs: dict[str, _Job] = {}
@property
def ai_tutor(self) -> AITutor:
value = self._ai_tutor
return value() if callable(value) else value
@ai_tutor.setter
def ai_tutor(self, value) -> None:
self._ai_tutor = value
def snapshot(self, document: SentenceBoundaryDocument) -> SubtitleTranslationResponse:
translations = [
SubtitleTranslationItem(index=sentence.index, zh_text=sentence.zh_text)
for sentence in document.sentences
if sentence.zh_text and sentence.zh_text.strip()
]
needed = [
sentence
for sentence in document.sentences
if (sentence.text or "").strip()
]
complete = bool(needed) and all((sentence.zh_text or "").strip() for sentence in needed)
job = self._jobs.get(document.video_hash)
running = False
error: str | None = None
if job is not None:
with job.lock:
running = job.running
error = job.error
if complete:
status = "ready"
error = None
elif running:
status = "running"
elif error:
status = "error"
else:
status = "idle"
return SubtitleTranslationResponse(
video_hash=document.video_hash,
translations=translations,
complete=complete,
status=status,
error=error,
)
def current(self, video_hash: str) -> SubtitleTranslationResponse | None:
document = self.repository.get_document(video_hash)
if document is None:
return None
return self.snapshot(document)
def ensure(self, video_hash: str) -> SubtitleTranslationResponse:
if not getattr(self.ai_tutor, "available", False):
raise AITutorError("AI translation is not configured.")
document = self.repository.get_document(video_hash)
if document is None:
raise KeyError(video_hash)
current = self.snapshot(document)
if current.complete:
return current
payload = [
(sentence.index, sentence.text or "")
for sentence in document.sentences
if (sentence.text or "").strip()
]
if not payload:
raise ValueError("No sentence text is available to translate.")
job = self._start_if_needed(video_hash)
wait_seconds = max(0.0, float(self.post_wait_seconds))
if wait_seconds > 0:
job.done.wait(timeout=wait_seconds)
latest = self.current(video_hash)
if latest is None:
raise KeyError(video_hash)
return latest
def _start_if_needed(self, video_hash: str) -> _Job:
with self._lock:
existing = self._jobs.get(video_hash)
if existing is not None:
with existing.lock:
still_running = existing.running or (
existing.thread is not None and existing.thread.is_alive()
)
if still_running:
return existing
job = _Job(
video_hash=video_hash,
done=threading.Event(),
lock=threading.Lock(),
)
thread = threading.Thread(
target=self._run_job,
args=(job,),
name=f"subtitle-translate-{video_hash[:8]}",
daemon=True,
)
job.thread = thread
self._jobs[video_hash] = job
thread.start()
return job
def _run_job(self, job: _Job) -> None:
try:
document = self.repository.get_document(job.video_hash)
if document is None:
raise AITutorError("Course is not available.")
payload = [
(sentence.index, sentence.text or "")
for sentence in document.sentences
if (sentence.text or "").strip()
]
if not payload:
return
existing = {
sentence.index: sentence.zh_text
for sentence in document.sentences
if sentence.zh_text and sentence.zh_text.strip()
}
video = self.repository.get_video(job.video_hash)
translated = self.ai_tutor.translate_sentences(
payload,
course_title=(video["title"] if video is not None else "") or "",
existing=existing,
persist=lambda part: self.repository.save_sentence_translations(
job.video_hash,
part,
),
)
if translated:
self.repository.save_sentence_translations(job.video_hash, translated)
with job.lock:
job.error = None
except Exception as exc: # noqa: BLE001 - surface job failure to clients
logger.exception("Subtitle translation job failed: video=%s", job.video_hash)
with job.lock:
job.error = str(exc)
finally:
with job.lock:
job.running = False
job.done.set()