regenerate something when the sentences changed

This commit is contained in:
2026-10-03 21:12:12 +08:00
parent d5609c5c42
commit 4fd0fd36a4
11 changed files with 245 additions and 13 deletions

View File

@@ -700,6 +700,7 @@ def create_app(
)
if sentence is None:
raise HTTPException(status_code=404, detail="Sentence was not found.")
application.state.subtitle_translations.refresh(video_hash)
return sentence
@application.put(
@@ -735,6 +736,7 @@ def create_app(
raise HTTPException(status_code=400, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=404, detail="Sentence was not found.")
application.state.subtitle_translations.refresh(video_hash)
return result
@@ -756,6 +758,7 @@ def create_app(
raise HTTPException(status_code=400, detail=str(exc)) from exc
if sentence is None:
raise HTTPException(status_code=404, detail="Sentence was not found.")
application.state.subtitle_translations.refresh(video_hash)
return sentence
@application.put(

View File

@@ -555,6 +555,10 @@ def test_admin_can_split_sentence_and_reindexes_following_sentences(tmp_path):
),
None,
)
repository.save_sentence_translations(
VIDEO_HASH,
{0: "之前。", 1: "第一段第二段", 2: "下一句。"},
)
class UnavailableTranscriber:
available = False
@@ -586,6 +590,7 @@ def test_admin_can_split_sentence_and_reindexes_following_sentences(tmp_path):
"Next sentence.",
]
assert [item.index for item in document.sentences] == [0, 1, 2, 3]
assert all(not (item.zh_text or "").strip() for item in document.sentences)
def test_admin_can_merge_adjacent_sentences_and_reindexes_following_sentences(tmp_path):
@@ -643,6 +648,10 @@ def test_admin_can_merge_adjacent_sentences_and_reindexes_following_sentences(tm
),
None,
)
repository.save_sentence_translations(
VIDEO_HASH,
{0: "之前。", 1: "第一段", 2: "第二段", 3: "下一句。"},
)
class UnavailableTranscriber:
available = False
@@ -676,6 +685,7 @@ def test_admin_can_merge_adjacent_sentences_and_reindexes_following_sentences(tm
assert [item.index for item in document.sentences] == [0, 1, 2]
assert [item.start_ms for item in document.sentences] == [0, 2000, 9000]
assert [item.end_ms for item in document.sentences] == [2000, 9000, 12000]
assert all(not (item.zh_text or "").strip() for item in document.sentences)
last_response = client.put(
f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/2/merge",
@@ -684,6 +694,99 @@ def test_admin_can_merge_adjacent_sentences_and_reindexes_following_sentences(tm
assert last_response.json()["detail"] == "No next sentence to merge with."
def test_admin_sentence_merge_restarts_subtitle_translation(tmp_path, monkeypatch):
client = make_client(tmp_path)
_ready_video(client.app.state.video_repository)
requested = []
def translate_sentences(sentences, **kwargs):
requested.append([(index, text) for index, text in sentences])
return {index: f"中文:{text}" for index, text in sentences}
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
first = client.post(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation")
if not first.json().get("complete"):
first = wait_for_subtitle_translation(client, VIDEO_HASH)
assert first.json()["translations"] == [
{"index": 0, "zh_text": "中文:Hello."},
{"index": 1, "zh_text": "中文:Nice to meet you."},
]
merge = client.put(f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/0/merge")
assert merge.status_code == 200, merge.text
assert merge.json()["text"] == "Hello. Nice to meet you."
finished = wait_for_subtitle_translation(client, VIDEO_HASH)
assert finished.json()["complete"] is True
assert finished.json()["translations"] == [
{"index": 0, "zh_text": "中文:Hello. 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."]
assert [sentence.zh_text for sentence in document.sentences] == ["中文:Hello. Nice to meet you."]
assert requested[-1] == [(0, "Hello. Nice to meet you.")]
def test_admin_sentence_merge_drops_stale_in_flight_translation(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):
call_index = len(requested)
requested.append([(index, text) for index, text in sentences])
persist = kwargs.get("persist")
if call_index == 0:
if persist is not None:
persist({0: "旧0", 1: "旧1"})
started.set()
assert release.wait(timeout=5)
if persist is not None:
persist({0: "晚0", 1: "晚1"})
return {0: "晚0", 1: "晚1"}
return {index: f"新:{text}" for index, text in sentences}
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
merge = client.put(f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/0/merge")
assert merge.status_code == 200, merge.text
release.set()
finished = wait_for_subtitle_translation(client, VIDEO_HASH)
assert finished.json()["complete"] is True
assert finished.json()["translations"] == [
{"index": 0, "zh_text": "新:Hello. Nice to meet you."},
]
document = client.app.state.video_repository.get_document(VIDEO_HASH)
assert [sentence.zh_text for sentence in document.sentences] == [
"新:Hello. Nice to meet you."
]
assert requested[0] == [(0, "Hello."), (1, "Nice to meet you.")]
assert requested[-1] == [(0, "Hello. Nice to meet you.")]
def test_admin_boundary_adjustment_updates_linked_next_start(tmp_path):
settings = replace(Settings.from_env(), data_dir=tmp_path / "data")
settings.ensure_directories()

View File

@@ -20,6 +20,7 @@ class _Job:
done: threading.Event
lock: threading.Lock
running: bool = True
cancelled: bool = False
error: str | None = None
thread: threading.Thread | None = None
@@ -89,6 +90,26 @@ class SubtitleTranslationService:
return None
return self.snapshot(document)
def invalidate(self, video_hash: str) -> None:
"""Stop an in-flight job so stale translations cannot be written back."""
with self._lock:
job = self._jobs.pop(video_hash, None)
if job is None:
return
with job.lock:
job.cancelled = True
def refresh(self, video_hash: str) -> None:
"""Drop saved Chinese subtitles and translate the current sentences again."""
self.invalidate(video_hash)
self.repository.clear_sentence_translations(video_hash)
if not getattr(self.ai_tutor, "available", False):
return
try:
self._start_if_needed(video_hash)
except (KeyError, ValueError, AITutorError):
logger.exception("Could not restart subtitle translation: video=%s", video_hash)
def ensure(self, video_hash: str) -> SubtitleTranslationResponse:
if not getattr(self.ai_tutor, "available", False):
raise AITutorError("AI translation is not configured.")
@@ -119,8 +140,15 @@ class SubtitleTranslationService:
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()
still_running = (
not existing.cancelled
and (
existing.running
or (
existing.thread is not None
and existing.thread.is_alive()
)
)
)
if still_running:
return existing
@@ -140,8 +168,17 @@ class SubtitleTranslationService:
thread.start()
return job
def _persist(self, job: _Job, translations: dict[int, str]) -> None:
with job.lock:
if job.cancelled:
return
self.repository.save_sentence_translations(job.video_hash, translations)
def _run_job(self, job: _Job) -> None:
try:
with job.lock:
if job.cancelled:
return
document = self.repository.get_document(job.video_hash)
if document is None:
raise AITutorError("Course is not available.")
@@ -162,16 +199,18 @@ class SubtitleTranslationService:
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,
),
persist=lambda part: self._persist(job, part),
)
if translated:
self.repository.save_sentence_translations(job.video_hash, translated)
self._persist(job, translated)
with job.lock:
job.error = None
if not job.cancelled:
job.error = None
except Exception as exc: # noqa: BLE001 - surface job failure to clients
with job.lock:
cancelled = job.cancelled
if cancelled:
return
logger.exception("Subtitle translation job failed: video=%s", job.video_hash)
with job.lock:
job.error = str(exc)