From 4fd0fd36a4160ee376207448b2425c02e6f66cb5 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Sat, 3 Oct 2026 21:12:12 +0800 Subject: [PATCH] regenerate something when the sentences changed --- .../sample/CourseCatalogController.kt | 7 ++ .../oraltrainer/sample/MainActivity.kt | 13 ++- .../sample/SentenceExplanationOverlay.kt | 10 +- .../sample/SentenceExplanationStore.kt | 23 ++++ .../Services/SentenceExplanationStore.swift | 20 ++++ ios/OralTrainer/Views/CatalogView.swift | 16 +++ .../Views/FullscreenPlayerView.swift | 3 +- ios/OralTrainer/Views/PlayerSectionView.swift | 5 +- sentence_api/main.py | 3 + sentence_api/tests/test_api.py | 103 ++++++++++++++++++ sentence_api/translation_jobs.py | 55 ++++++++-- 11 files changed, 245 insertions(+), 13 deletions(-) diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt index 6f7343f..f83f6a1 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/CourseCatalogController.kt @@ -2,6 +2,7 @@ package cn.learningpad.oraltrainer.sample import cn.learningpad.oraltrainer.sdk.OralTrainerController import cn.learningpad.oraltrainer.sdk.OralTrainerSdk +import cn.learningpad.oraltrainer.sdk.SentenceBoundary import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary @@ -26,6 +27,7 @@ internal class CourseCatalogController( fun onSentencePlaceholder(meta: String, text: String) fun refreshPlayerUi() fun openTrainingModule() + fun syncSentenceExplanations(videoHash: String, sentences: List) } fun loadCatalog() { @@ -101,6 +103,7 @@ internal class CourseCatalogController( override fun onSuccess(result: SentenceBoundaryApiResult) { if (!screenState.isCurrentCatalogRequest(generation)) return screenState.courseBoundaries[result.videoHash] = result + host.syncSentenceExplanations(result.videoHash, result.sentences) updateCatalogVideo(video.videoHash, result) if (screenState.activeItemId == result.videoHash) { controller.updateSentences(result.videoHash, result.sentences) @@ -174,6 +177,9 @@ internal class CourseCatalogController( host.onCatalogChanged() val cachedBoundary = screenState.courseBoundaries[video.videoHash] + if (cachedBoundary != null) { + host.syncSentenceExplanations(cachedBoundary.videoHash, cachedBoundary.sentences) + } controller.loadItem(video.toTrainingMediaItem(cachedBoundary?.sentences.orEmpty())) screenState.currentSentenceCount = cachedBoundary?.sentences?.size ?: video.sentenceCount if (cachedBoundary == null) { @@ -198,6 +204,7 @@ internal class CourseCatalogController( return } screenState.courseBoundaries[result.videoHash] = result + host.syncSentenceExplanations(result.videoHash, result.sentences) updateCatalogVideo(video.videoHash, result) screenState.currentSentenceCount = result.sentences.size controller.updateSentences(video.videoHash, result.sentences) diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt index f36181d..1a64fdd 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/MainActivity.kt @@ -480,10 +480,12 @@ class MainActivity : Activity() { val sentence = controller.currentSentence() ?: return if (sentence.text.isNullOrBlank()) return controller.pause() + val item = controller.currentTrainingItem() explanationOverlay.show( sentence, - controller.currentTrainingItem()?.title.orEmpty(), - controller.currentTrainingItem()?.id.orEmpty(), + item?.title.orEmpty(), + item?.id.orEmpty(), + item?.sentences.orEmpty(), ) } @@ -561,6 +563,13 @@ class MainActivity : Activity() { screenFactory.renderCatalog() refreshCurrentUi() } + + override fun syncSentenceExplanations( + videoHash: String, + sentences: List, + ) { + SentenceExplanationStore(this@MainActivity).sync(videoHash, sentences) + } } private fun testHost() = object : TestSessionController.Host { diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt index c718614..894c955 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationOverlay.kt @@ -23,7 +23,15 @@ internal class SentenceExplanationOverlay( private var currentRequest: CancellableExplanationRequest? = null private var requestGeneration = 0 - fun show(sentence: SentenceBoundary, courseTitle: String, mediaId: String) { + fun show( + sentence: SentenceBoundary, + courseTitle: String, + mediaId: String, + sentences: List = emptyList(), + ) { + if (sentences.isNotEmpty()) { + store.sync(mediaId, sentences) + } close() val generation = ++requestGeneration val root = FrameLayout(activity).apply { diff --git a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationStore.kt b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationStore.kt index d6adfc6..4ff0973 100644 --- a/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationStore.kt +++ b/android/sample-app/src/main/java/cn/learningpad/oraltrainer/sample/SentenceExplanationStore.kt @@ -1,6 +1,7 @@ package cn.learningpad.oraltrainer.sample import android.content.Context +import cn.learningpad.oraltrainer.sdk.SentenceBoundary import java.io.File import java.util.Locale @@ -23,12 +24,34 @@ internal class SentenceExplanationStore(context: Context) { return file.isFile && file.length() > 0L } + fun sync(mediaId: String, sentences: List) { + if (sentences.isEmpty()) return + val directoryName = sanitize(mediaId) ?: return + val dir = File(rootDir, directoryName) + val revision = revisionFor(sentences) + val marker = File(dir, REVISION_FILE) + val previous = marker.takeIf { it.isFile }?.readText()?.trim().orEmpty() + if (previous == revision) return + if (dir.exists()) { + dir.deleteRecursively() + } + dir.mkdirs() + marker.writeText(revision) + } + private fun fileFor(mediaId: String, sentenceIndex: Int): File? { val directoryName = sanitize(mediaId) ?: return null return File(File(rootDir, directoryName), "$sentenceIndex.txt") } companion object { + private const val REVISION_FILE = "revision.txt" + + fun revisionFor(sentences: List): String = + sentences.joinToString("\n") { sentence -> + "${sentence.index}\t${sentence.text.orEmpty().trim()}" + } + fun sanitize(mediaId: String): String? { val filtered = mediaId.trim().lowercase(Locale.US) .replace(Regex("[^a-z0-9._-]"), "_") diff --git a/ios/OralTrainer/Services/SentenceExplanationStore.swift b/ios/OralTrainer/Services/SentenceExplanationStore.swift index 30e4285..c18bdc6 100644 --- a/ios/OralTrainer/Services/SentenceExplanationStore.swift +++ b/ios/OralTrainer/Services/SentenceExplanationStore.swift @@ -33,11 +33,31 @@ final class SentenceExplanationStore { } } + func sync(mediaId: String, sentences: [SentenceBoundary]) { + guard !sentences.isEmpty else { return } + let directory = root.appendingPathComponent(Self.sanitize(mediaId), isDirectory: true) + let revision = Self.revision(for: sentences) + let marker = directory.appendingPathComponent("revision.txt") + let previous = (try? String(contentsOf: marker, encoding: .utf8))? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard previous != revision else { return } + try? FileManager.default.removeItem(at: directory) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try? revision.write(to: marker, atomically: true, encoding: .utf8) + } + private func fileURL(mediaId: String, sentenceIndex: Int) -> URL { let directory = root.appendingPathComponent(Self.sanitize(mediaId), isDirectory: true) return directory.appendingPathComponent("\(sentenceIndex).txt") } + static func revision(for sentences: [SentenceBoundary]) -> String { + sentences.map { sentence in + let text = (sentence.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return "\(sentence.index)\t\(text)" + }.joined(separator: "\n") + } + static func sanitize(_ mediaId: String) -> String { let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) let filtered = mediaId.lowercased().unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } diff --git a/ios/OralTrainer/Views/CatalogView.swift b/ios/OralTrainer/Views/CatalogView.swift index 0d413b6..a15ade7 100644 --- a/ios/OralTrainer/Views/CatalogView.swift +++ b/ios/OralTrainer/Views/CatalogView.swift @@ -45,6 +45,10 @@ final class CatalogViewModel: ObservableObject { do { let document = try await SentenceBoundaryAPI.fetch(videoHash: video.videoHash) boundaryDocuments[document.videoHash] = document + SentenceExplanationStore.shared.sync( + mediaId: document.videoHash, + sentences: document.sentences + ) updatedVideos[index] = video.copy( durationMs: document.durationMs, sentenceCount: document.sentences.count @@ -75,6 +79,10 @@ final class CatalogViewModel: ObservableObject { return } if let document = boundaryDocuments[video.videoHash] { + SentenceExplanationStore.shared.sync( + mediaId: document.videoHash, + sentences: document.sentences + ) let item = TrainingMediaItem( id: video.videoHash, title: video.title, @@ -98,6 +106,10 @@ final class CatalogViewModel: ObservableObject { return } boundaryDocuments[document.videoHash] = document + SentenceExplanationStore.shared.sync( + mediaId: document.videoHash, + sentences: document.sentences + ) controller.updateBoundaries(document.sentences) controller.boundaryState = .loaded(count: document.sentences.count) controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句") @@ -142,6 +154,10 @@ final class CatalogViewModel: ObservableObject { guard activeVideoHash == hash else { return } + SentenceExplanationStore.shared.sync( + mediaId: document.videoHash, + sentences: document.sentences + ) controller.updateBoundaries(document.sentences) controller.boundaryState = .loaded(count: document.sentences.count) controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句") diff --git a/ios/OralTrainer/Views/FullscreenPlayerView.swift b/ios/OralTrainer/Views/FullscreenPlayerView.swift index a2ccbd7..04fa72b 100644 --- a/ios/OralTrainer/Views/FullscreenPlayerView.swift +++ b/ios/OralTrainer/Views/FullscreenPlayerView.swift @@ -29,7 +29,8 @@ struct FullscreenPlayerView: View { SentenceExplanationSheet( mediaId: controller.currentItem?.id ?? "", courseTitle: controller.currentItem?.title ?? "口语训练课程", - sentence: sentence + sentence: sentence, + sentences: controller.currentItem?.sentences ?? [] ) } } diff --git a/ios/OralTrainer/Views/PlayerSectionView.swift b/ios/OralTrainer/Views/PlayerSectionView.swift index a7ec0ce..d760eb2 100644 --- a/ios/OralTrainer/Views/PlayerSectionView.swift +++ b/ios/OralTrainer/Views/PlayerSectionView.swift @@ -27,7 +27,8 @@ struct PlayerSectionView: View { SentenceExplanationSheet( mediaId: controller.currentItem?.id ?? "", courseTitle: controller.currentItem?.title ?? "口语训练课程", - sentence: sentence + sentence: sentence, + sentences: controller.currentItem?.sentences ?? [] ) } } @@ -273,6 +274,7 @@ struct SentenceExplanationSheet: View { let mediaId: String let courseTitle: String let sentence: SentenceBoundary + var sentences: [SentenceBoundary] = [] @Environment(\.dismiss) private var dismiss @State private var explanation = "" @@ -327,6 +329,7 @@ struct SentenceExplanationSheet: View { } } .task { + SentenceExplanationStore.shared.sync(mediaId: mediaId, sentences: sentences) if let cached = SentenceExplanationStore.shared.load( mediaId: mediaId, sentenceIndex: sentence.index diff --git a/sentence_api/main.py b/sentence_api/main.py index cbf4789..bbc9194 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -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( diff --git a/sentence_api/tests/test_api.py b/sentence_api/tests/test_api.py index 22f129a..634245d 100644 --- a/sentence_api/tests/test_api.py +++ b/sentence_api/tests/test_api.py @@ -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() diff --git a/sentence_api/translation_jobs.py b/sentence_api/translation_jobs.py index c31e638..39e0bbc 100644 --- a/sentence_api/translation_jobs.py +++ b/sentence_api/translation_jobs.py @@ -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)