pc translate sentences
This commit is contained in:
@@ -111,6 +111,12 @@ several windows in parallel, retries and splits a window if the model response i
|
||||
invalid, and writes each finished window immediately so later viewers can reuse
|
||||
it. English sentence edits clear the saved Chinese captions for that course.
|
||||
|
||||
Desktop playback of a local file can `POST` the same endpoint with the locally
|
||||
generated English cues (`duration_ms`, `algorithm_version`, `sentences`). If that
|
||||
SHA-256 is not already a course, the server stores the English subtitles and
|
||||
translates them; later `GET`/`POST` calls poll and reuse the saved Chinese
|
||||
captions. Official course sentences are never overwritten by a local upload.
|
||||
|
||||
## Assessment
|
||||
|
||||
```bash
|
||||
|
||||
@@ -24,6 +24,7 @@ from fastapi import (
|
||||
Request,
|
||||
UploadFile,
|
||||
)
|
||||
from pydantic import ValidationError
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
@@ -47,6 +48,7 @@ from .models import (
|
||||
SentenceExplanationRequest,
|
||||
SentenceExplanationResponse,
|
||||
AdminSubtitleTranslationResponse,
|
||||
LocalSubtitleTranslationRequest,
|
||||
SubtitleRetranslateRequest,
|
||||
SubtitleTranslationResponse,
|
||||
PasswordResetRequest,
|
||||
@@ -364,12 +366,28 @@ def create_app(
|
||||
"/api/v1/videos/{video_hash}/subtitle-translation",
|
||||
response_model=SubtitleTranslationResponse,
|
||||
)
|
||||
def translate_subtitles(
|
||||
async def translate_subtitles(
|
||||
request: Request,
|
||||
video_hash: str = SHA256_PATH,
|
||||
user: AuthenticatedUser = Depends(current_user),
|
||||
) -> SubtitleTranslationResponse:
|
||||
del user
|
||||
raw = await request.body()
|
||||
local_document = None
|
||||
if raw.strip():
|
||||
try:
|
||||
payload = LocalSubtitleTranslationRequest.model_validate_json(raw)
|
||||
local_document = SentenceBoundaryDocument(
|
||||
video_hash=video_hash,
|
||||
duration_ms=payload.duration_ms,
|
||||
algorithm_version=payload.algorithm_version,
|
||||
sentences=payload.sentences,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=exc.errors()) from exc
|
||||
try:
|
||||
if local_document is not None:
|
||||
return application.state.subtitle_translations.ensure_from_local(local_document)
|
||||
return application.state.subtitle_translations.ensure(video_hash)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Course is not available.") from None
|
||||
|
||||
@@ -52,7 +52,7 @@ class SentenceBoundaryDocument(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
VideoStatus = Literal["uploaded", "processing", "ready", "failed"]
|
||||
VideoStatus = Literal["uploaded", "processing", "ready", "failed", "local_subtitles"]
|
||||
|
||||
|
||||
class VideoSummary(BaseModel):
|
||||
@@ -144,6 +144,16 @@ class SubtitleTranslationResponse(BaseModel):
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class LocalSubtitleTranslationRequest(BaseModel):
|
||||
"""Upload locally generated English subtitles so the server can translate them."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
duration_ms: int = Field(gt=0)
|
||||
algorithm_version: str = Field(default="local-pc", min_length=1, max_length=64)
|
||||
sentences: List[SentenceBoundary] = Field(min_length=1)
|
||||
|
||||
|
||||
class TranslationTerm(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -355,6 +355,98 @@ class VideoRepository:
|
||||
],
|
||||
)
|
||||
|
||||
def import_local_subtitle_document(
|
||||
self,
|
||||
document: SentenceBoundaryDocument,
|
||||
) -> SentenceBoundaryDocument:
|
||||
"""Save locally generated English subtitles for translation.
|
||||
|
||||
Official course videos are left unchanged. A previous local-only
|
||||
import is replaced when the English cues changed.
|
||||
"""
|
||||
normalized_hash = normalize_video_hash(document.video_hash)
|
||||
document = document.model_copy(update={"video_hash": normalized_hash})
|
||||
existing_video = self.get_video(normalized_hash)
|
||||
existing_document = self.get_document(normalized_hash)
|
||||
if existing_video is not None and existing_video.get("status") != "local_subtitles":
|
||||
if existing_document is None:
|
||||
raise KeyError(normalized_hash)
|
||||
return existing_document
|
||||
if existing_document is not None and _english_fingerprint(existing_document) == _english_fingerprint(document):
|
||||
return existing_document
|
||||
|
||||
now = utc_now()
|
||||
language = next((sentence.language for sentence in document.sentences if sentence.language), None)
|
||||
with self._connect() as connection:
|
||||
if existing_video is None:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO videos (
|
||||
video_hash, title, filename, stored_filename, content_type,
|
||||
size_bytes, duration_ms, language, status, error_message,
|
||||
algorithm_version, transcription, remote_url, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'local_subtitles', NULL, ?, NULL, NULL, ?, ?)
|
||||
""",
|
||||
(
|
||||
normalized_hash,
|
||||
"Local subtitles",
|
||||
"local-subtitles.json",
|
||||
"",
|
||||
"application/json",
|
||||
0,
|
||||
document.duration_ms,
|
||||
language,
|
||||
document.algorithm_version,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE videos
|
||||
SET duration_ms = ?, language = COALESCE(?, language),
|
||||
algorithm_version = ?, status = 'local_subtitles',
|
||||
error_message = NULL, updated_at = ?
|
||||
WHERE video_hash = ?
|
||||
""",
|
||||
(
|
||||
document.duration_ms,
|
||||
language,
|
||||
document.algorithm_version,
|
||||
now,
|
||||
normalized_hash,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM sentences WHERE video_hash = ?",
|
||||
(normalized_hash,),
|
||||
)
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO sentences (
|
||||
video_hash, sentence_index, start_ms, end_ms, text,
|
||||
language, zh_text, reference_speech_duration_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
normalized_hash,
|
||||
sentence.index,
|
||||
sentence.start_ms,
|
||||
sentence.end_ms,
|
||||
sentence.text,
|
||||
sentence.language,
|
||||
sentence.reference_speech_duration_ms,
|
||||
)
|
||||
for sentence in document.sentences
|
||||
],
|
||||
)
|
||||
imported = self.get_document(normalized_hash)
|
||||
if imported is None:
|
||||
raise KeyError(normalized_hash)
|
||||
return imported
|
||||
|
||||
def mark_oss_uploaded(self, video_hash: str, remote_url: str) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
@@ -389,6 +481,7 @@ class VideoRepository:
|
||||
SELECT v.*, COUNT(s.sentence_index) AS sentence_count
|
||||
FROM videos v
|
||||
LEFT JOIN sentences s ON s.video_hash = v.video_hash
|
||||
WHERE v.status != 'local_subtitles'
|
||||
GROUP BY v.video_hash
|
||||
ORDER BY v.created_at DESC
|
||||
"""
|
||||
@@ -1316,6 +1409,13 @@ class VideoRepository:
|
||||
|
||||
|
||||
|
||||
def _english_fingerprint(document: SentenceBoundaryDocument) -> tuple:
|
||||
return tuple(
|
||||
(sentence.index, sentence.start_ms, sentence.end_ms, (sentence.text or "").strip())
|
||||
for sentence in document.sentences
|
||||
)
|
||||
|
||||
|
||||
def _sentence_from_row(row: sqlite3.Row) -> SentenceBoundary:
|
||||
keys = set(row.keys())
|
||||
zh_text = row["zh_text"] if "zh_text" in keys else None
|
||||
|
||||
@@ -248,6 +248,91 @@ def test_subtitle_translation_requires_login(tmp_path):
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
LOCAL_SUBTITLE_HASH = "b" * 64
|
||||
|
||||
|
||||
def _stub_translator(monkeypatch, client, requested=None):
|
||||
requested = requested if requested is not None else []
|
||||
|
||||
def translate_sentences(sentences, **kwargs):
|
||||
requested.append((sentences, kwargs))
|
||||
return {index: f"中文{index}" for index, _text in sentences}
|
||||
|
||||
class StubAITutor:
|
||||
available = True
|
||||
|
||||
stub = StubAITutor()
|
||||
stub.translate_sentences = translate_sentences
|
||||
monkeypatch.setattr(client.app.state, "ai_tutor", stub)
|
||||
return requested
|
||||
|
||||
|
||||
def test_local_english_subtitles_can_be_uploaded_for_translation(tmp_path, monkeypatch):
|
||||
client = make_client(tmp_path)
|
||||
requested = _stub_translator(monkeypatch, client)
|
||||
|
||||
missing = client.post(f"/api/v1/videos/{LOCAL_SUBTITLE_HASH}/subtitle-translation")
|
||||
assert missing.status_code == 404
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/videos/{LOCAL_SUBTITLE_HASH}/subtitle-translation",
|
||||
json={
|
||||
"duration_ms": 9000,
|
||||
"algorithm_version": "local-pc",
|
||||
"sentences": [
|
||||
{"index": 0, "start_ms": 0, "end_ms": 4000, "text": "Hello.", "language": "en"},
|
||||
{"index": 1, "start_ms": 4000, "end_ms": 9000, "text": "Nice to meet you.", "language": "en"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
if not response.json().get("complete"):
|
||||
response = wait_for_subtitle_translation(client, LOCAL_SUBTITLE_HASH)
|
||||
payload = response.json()
|
||||
assert payload["complete"] is True
|
||||
assert payload["translations"] == [
|
||||
{"index": 0, "zh_text": "中文0"},
|
||||
{"index": 1, "zh_text": "中文1"},
|
||||
]
|
||||
assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")]
|
||||
|
||||
stored = client.get(f"/api/v1/videos/{LOCAL_SUBTITLE_HASH}/subtitle-translation")
|
||||
assert stored.status_code == 200
|
||||
assert stored.json()["translations"] == payload["translations"]
|
||||
|
||||
courses = client.get("/api/v1/courses")
|
||||
assert courses.status_code == 200
|
||||
assert all(item["video_hash"] != LOCAL_SUBTITLE_HASH for item in courses.json())
|
||||
|
||||
videos = client.get("/api/v1/videos")
|
||||
assert videos.status_code == 200
|
||||
assert all(item["video_hash"] != LOCAL_SUBTITLE_HASH for item in videos.json()["videos"])
|
||||
|
||||
|
||||
def test_local_subtitle_upload_does_not_overwrite_course_sentences(tmp_path, monkeypatch):
|
||||
client = make_client(tmp_path)
|
||||
_ready_video(client.app.state.video_repository)
|
||||
requested = _stub_translator(monkeypatch, client)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation",
|
||||
json={
|
||||
"duration_ms": 9000,
|
||||
"algorithm_version": "local-pc",
|
||||
"sentences": [
|
||||
{"index": 0, "start_ms": 0, "end_ms": 4000, "text": "Changed.", "language": "en"},
|
||||
{"index": 1, "start_ms": 4000, "end_ms": 9000, "text": "Also changed.", "language": "en"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
if not response.json().get("complete"):
|
||||
response = wait_for_subtitle_translation(client, VIDEO_HASH)
|
||||
assert requested[0][0] == [(0, "Hello."), (1, "Nice to meet you.")]
|
||||
document = client.app.state.video_repository.get_document(VIDEO_HASH)
|
||||
assert [sentence.text for sentence in document.sentences] == ["Hello.", "Nice to meet you."]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -144,6 +144,11 @@ class SubtitleTranslationService:
|
||||
payload["terms"] = terms
|
||||
return AdminSubtitleTranslationResponse(**payload)
|
||||
|
||||
def ensure_from_local(self, document: SentenceBoundaryDocument) -> SubtitleTranslationResponse:
|
||||
"""Accept locally generated English subtitles, then translate like a course."""
|
||||
self.repository.import_local_subtitle_document(document)
|
||||
return self.ensure(document.video_hash)
|
||||
|
||||
def ensure(self, video_hash: str) -> SubtitleTranslationResponse:
|
||||
if not getattr(self.ai_tutor, "available", False):
|
||||
raise AITutorError("AI translation is not configured.")
|
||||
|
||||
Reference in New Issue
Block a user