regenerate something when the sentences changed
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user