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

@@ -2,6 +2,7 @@ package cn.learningpad.oraltrainer.sample
import cn.learningpad.oraltrainer.sdk.OralTrainerController import cn.learningpad.oraltrainer.sdk.OralTrainerController
import cn.learningpad.oraltrainer.sdk.OralTrainerSdk import cn.learningpad.oraltrainer.sdk.OralTrainerSdk
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiCallback
import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult import cn.learningpad.oraltrainer.sdk.SentenceBoundaryApiResult
import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary import cn.learningpad.oraltrainer.sdk.TrainingVideoSummary
@@ -26,6 +27,7 @@ internal class CourseCatalogController(
fun onSentencePlaceholder(meta: String, text: String) fun onSentencePlaceholder(meta: String, text: String)
fun refreshPlayerUi() fun refreshPlayerUi()
fun openTrainingModule() fun openTrainingModule()
fun syncSentenceExplanations(videoHash: String, sentences: List<SentenceBoundary>)
} }
fun loadCatalog() { fun loadCatalog() {
@@ -101,6 +103,7 @@ internal class CourseCatalogController(
override fun onSuccess(result: SentenceBoundaryApiResult) { override fun onSuccess(result: SentenceBoundaryApiResult) {
if (!screenState.isCurrentCatalogRequest(generation)) return if (!screenState.isCurrentCatalogRequest(generation)) return
screenState.courseBoundaries[result.videoHash] = result screenState.courseBoundaries[result.videoHash] = result
host.syncSentenceExplanations(result.videoHash, result.sentences)
updateCatalogVideo(video.videoHash, result) updateCatalogVideo(video.videoHash, result)
if (screenState.activeItemId == result.videoHash) { if (screenState.activeItemId == result.videoHash) {
controller.updateSentences(result.videoHash, result.sentences) controller.updateSentences(result.videoHash, result.sentences)
@@ -174,6 +177,9 @@ internal class CourseCatalogController(
host.onCatalogChanged() host.onCatalogChanged()
val cachedBoundary = screenState.courseBoundaries[video.videoHash] val cachedBoundary = screenState.courseBoundaries[video.videoHash]
if (cachedBoundary != null) {
host.syncSentenceExplanations(cachedBoundary.videoHash, cachedBoundary.sentences)
}
controller.loadItem(video.toTrainingMediaItem(cachedBoundary?.sentences.orEmpty())) controller.loadItem(video.toTrainingMediaItem(cachedBoundary?.sentences.orEmpty()))
screenState.currentSentenceCount = cachedBoundary?.sentences?.size ?: video.sentenceCount screenState.currentSentenceCount = cachedBoundary?.sentences?.size ?: video.sentenceCount
if (cachedBoundary == null) { if (cachedBoundary == null) {
@@ -198,6 +204,7 @@ internal class CourseCatalogController(
return return
} }
screenState.courseBoundaries[result.videoHash] = result screenState.courseBoundaries[result.videoHash] = result
host.syncSentenceExplanations(result.videoHash, result.sentences)
updateCatalogVideo(video.videoHash, result) updateCatalogVideo(video.videoHash, result)
screenState.currentSentenceCount = result.sentences.size screenState.currentSentenceCount = result.sentences.size
controller.updateSentences(video.videoHash, result.sentences) controller.updateSentences(video.videoHash, result.sentences)

View File

@@ -480,10 +480,12 @@ class MainActivity : Activity() {
val sentence = controller.currentSentence() ?: return val sentence = controller.currentSentence() ?: return
if (sentence.text.isNullOrBlank()) return if (sentence.text.isNullOrBlank()) return
controller.pause() controller.pause()
val item = controller.currentTrainingItem()
explanationOverlay.show( explanationOverlay.show(
sentence, sentence,
controller.currentTrainingItem()?.title.orEmpty(), item?.title.orEmpty(),
controller.currentTrainingItem()?.id.orEmpty(), item?.id.orEmpty(),
item?.sentences.orEmpty(),
) )
} }
@@ -561,6 +563,13 @@ class MainActivity : Activity() {
screenFactory.renderCatalog() screenFactory.renderCatalog()
refreshCurrentUi() refreshCurrentUi()
} }
override fun syncSentenceExplanations(
videoHash: String,
sentences: List<SentenceBoundary>,
) {
SentenceExplanationStore(this@MainActivity).sync(videoHash, sentences)
}
} }
private fun testHost() = object : TestSessionController.Host { private fun testHost() = object : TestSessionController.Host {

View File

@@ -23,7 +23,15 @@ internal class SentenceExplanationOverlay(
private var currentRequest: CancellableExplanationRequest? = null private var currentRequest: CancellableExplanationRequest? = null
private var requestGeneration = 0 private var requestGeneration = 0
fun show(sentence: SentenceBoundary, courseTitle: String, mediaId: String) { fun show(
sentence: SentenceBoundary,
courseTitle: String,
mediaId: String,
sentences: List<SentenceBoundary> = emptyList(),
) {
if (sentences.isNotEmpty()) {
store.sync(mediaId, sentences)
}
close() close()
val generation = ++requestGeneration val generation = ++requestGeneration
val root = FrameLayout(activity).apply { val root = FrameLayout(activity).apply {

View File

@@ -1,6 +1,7 @@
package cn.learningpad.oraltrainer.sample package cn.learningpad.oraltrainer.sample
import android.content.Context import android.content.Context
import cn.learningpad.oraltrainer.sdk.SentenceBoundary
import java.io.File import java.io.File
import java.util.Locale import java.util.Locale
@@ -23,12 +24,34 @@ internal class SentenceExplanationStore(context: Context) {
return file.isFile && file.length() > 0L return file.isFile && file.length() > 0L
} }
fun sync(mediaId: String, sentences: List<SentenceBoundary>) {
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? { private fun fileFor(mediaId: String, sentenceIndex: Int): File? {
val directoryName = sanitize(mediaId) ?: return null val directoryName = sanitize(mediaId) ?: return null
return File(File(rootDir, directoryName), "$sentenceIndex.txt") return File(File(rootDir, directoryName), "$sentenceIndex.txt")
} }
companion object { companion object {
private const val REVISION_FILE = "revision.txt"
fun revisionFor(sentences: List<SentenceBoundary>): String =
sentences.joinToString("\n") { sentence ->
"${sentence.index}\t${sentence.text.orEmpty().trim()}"
}
fun sanitize(mediaId: String): String? { fun sanitize(mediaId: String): String? {
val filtered = mediaId.trim().lowercase(Locale.US) val filtered = mediaId.trim().lowercase(Locale.US)
.replace(Regex("[^a-z0-9._-]"), "_") .replace(Regex("[^a-z0-9._-]"), "_")

View File

@@ -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 { private func fileURL(mediaId: String, sentenceIndex: Int) -> URL {
let directory = root.appendingPathComponent(Self.sanitize(mediaId), isDirectory: true) let directory = root.appendingPathComponent(Self.sanitize(mediaId), isDirectory: true)
return directory.appendingPathComponent("\(sentenceIndex).txt") 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 { static func sanitize(_ mediaId: String) -> String {
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
let filtered = mediaId.lowercased().unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } let filtered = mediaId.lowercased().unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }

View File

@@ -45,6 +45,10 @@ final class CatalogViewModel: ObservableObject {
do { do {
let document = try await SentenceBoundaryAPI.fetch(videoHash: video.videoHash) let document = try await SentenceBoundaryAPI.fetch(videoHash: video.videoHash)
boundaryDocuments[document.videoHash] = document boundaryDocuments[document.videoHash] = document
SentenceExplanationStore.shared.sync(
mediaId: document.videoHash,
sentences: document.sentences
)
updatedVideos[index] = video.copy( updatedVideos[index] = video.copy(
durationMs: document.durationMs, durationMs: document.durationMs,
sentenceCount: document.sentences.count sentenceCount: document.sentences.count
@@ -75,6 +79,10 @@ final class CatalogViewModel: ObservableObject {
return return
} }
if let document = boundaryDocuments[video.videoHash] { if let document = boundaryDocuments[video.videoHash] {
SentenceExplanationStore.shared.sync(
mediaId: document.videoHash,
sentences: document.sentences
)
let item = TrainingMediaItem( let item = TrainingMediaItem(
id: video.videoHash, id: video.videoHash,
title: video.title, title: video.title,
@@ -98,6 +106,10 @@ final class CatalogViewModel: ObservableObject {
return return
} }
boundaryDocuments[document.videoHash] = document boundaryDocuments[document.videoHash] = document
SentenceExplanationStore.shared.sync(
mediaId: document.videoHash,
sentences: document.sentences
)
controller.updateBoundaries(document.sentences) controller.updateBoundaries(document.sentences)
controller.boundaryState = .loaded(count: document.sentences.count) controller.boundaryState = .loaded(count: document.sentences.count)
controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句") controller.showTransientMessage("课程已就绪:\(document.sentences.count) 句")
@@ -142,6 +154,10 @@ final class CatalogViewModel: ObservableObject {
guard activeVideoHash == hash else { guard activeVideoHash == hash else {
return return
} }
SentenceExplanationStore.shared.sync(
mediaId: document.videoHash,
sentences: document.sentences
)
controller.updateBoundaries(document.sentences) controller.updateBoundaries(document.sentences)
controller.boundaryState = .loaded(count: document.sentences.count) controller.boundaryState = .loaded(count: document.sentences.count)
controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句") controller.showTransientMessage("句子边界已加载:\(document.sentences.count) 句")

View File

@@ -29,7 +29,8 @@ struct FullscreenPlayerView: View {
SentenceExplanationSheet( SentenceExplanationSheet(
mediaId: controller.currentItem?.id ?? "", mediaId: controller.currentItem?.id ?? "",
courseTitle: controller.currentItem?.title ?? "口语训练课程", courseTitle: controller.currentItem?.title ?? "口语训练课程",
sentence: sentence sentence: sentence,
sentences: controller.currentItem?.sentences ?? []
) )
} }
} }

View File

@@ -27,7 +27,8 @@ struct PlayerSectionView: View {
SentenceExplanationSheet( SentenceExplanationSheet(
mediaId: controller.currentItem?.id ?? "", mediaId: controller.currentItem?.id ?? "",
courseTitle: controller.currentItem?.title ?? "口语训练课程", courseTitle: controller.currentItem?.title ?? "口语训练课程",
sentence: sentence sentence: sentence,
sentences: controller.currentItem?.sentences ?? []
) )
} }
} }
@@ -273,6 +274,7 @@ struct SentenceExplanationSheet: View {
let mediaId: String let mediaId: String
let courseTitle: String let courseTitle: String
let sentence: SentenceBoundary let sentence: SentenceBoundary
var sentences: [SentenceBoundary] = []
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var explanation = "" @State private var explanation = ""
@@ -327,6 +329,7 @@ struct SentenceExplanationSheet: View {
} }
} }
.task { .task {
SentenceExplanationStore.shared.sync(mediaId: mediaId, sentences: sentences)
if let cached = SentenceExplanationStore.shared.load( if let cached = SentenceExplanationStore.shared.load(
mediaId: mediaId, mediaId: mediaId,
sentenceIndex: sentence.index sentenceIndex: sentence.index

View File

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

View File

@@ -555,6 +555,10 @@ def test_admin_can_split_sentence_and_reindexes_following_sentences(tmp_path):
), ),
None, None,
) )
repository.save_sentence_translations(
VIDEO_HASH,
{0: "之前。", 1: "第一段第二段", 2: "下一句。"},
)
class UnavailableTranscriber: class UnavailableTranscriber:
available = False available = False
@@ -586,6 +590,7 @@ def test_admin_can_split_sentence_and_reindexes_following_sentences(tmp_path):
"Next sentence.", "Next sentence.",
] ]
assert [item.index for item in document.sentences] == [0, 1, 2, 3] 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): 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, None,
) )
repository.save_sentence_translations(
VIDEO_HASH,
{0: "之前。", 1: "第一段", 2: "第二段", 3: "下一句。"},
)
class UnavailableTranscriber: class UnavailableTranscriber:
available = False 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.index for item in document.sentences] == [0, 1, 2]
assert [item.start_ms for item in document.sentences] == [0, 2000, 9000] 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 [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( last_response = client.put(
f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/2/merge", 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." 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): def test_admin_boundary_adjustment_updates_linked_next_start(tmp_path):
settings = replace(Settings.from_env(), data_dir=tmp_path / "data") settings = replace(Settings.from_env(), data_dir=tmp_path / "data")
settings.ensure_directories() settings.ensure_directories()

View File

@@ -20,6 +20,7 @@ class _Job:
done: threading.Event done: threading.Event
lock: threading.Lock lock: threading.Lock
running: bool = True running: bool = True
cancelled: bool = False
error: str | None = None error: str | None = None
thread: threading.Thread | None = None thread: threading.Thread | None = None
@@ -89,6 +90,26 @@ class SubtitleTranslationService:
return None return None
return self.snapshot(document) 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: def ensure(self, video_hash: str) -> SubtitleTranslationResponse:
if not getattr(self.ai_tutor, "available", False): if not getattr(self.ai_tutor, "available", False):
raise AITutorError("AI translation is not configured.") raise AITutorError("AI translation is not configured.")
@@ -119,8 +140,15 @@ class SubtitleTranslationService:
existing = self._jobs.get(video_hash) existing = self._jobs.get(video_hash)
if existing is not None: if existing is not None:
with existing.lock: with existing.lock:
still_running = existing.running or ( still_running = (
existing.thread is not None and existing.thread.is_alive() not existing.cancelled
and (
existing.running
or (
existing.thread is not None
and existing.thread.is_alive()
)
)
) )
if still_running: if still_running:
return existing return existing
@@ -140,8 +168,17 @@ class SubtitleTranslationService:
thread.start() thread.start()
return job 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: def _run_job(self, job: _Job) -> None:
try: try:
with job.lock:
if job.cancelled:
return
document = self.repository.get_document(job.video_hash) document = self.repository.get_document(job.video_hash)
if document is None: if document is None:
raise AITutorError("Course is not available.") raise AITutorError("Course is not available.")
@@ -162,16 +199,18 @@ class SubtitleTranslationService:
payload, payload,
course_title=(video["title"] if video is not None else "") or "", course_title=(video["title"] if video is not None else "") or "",
existing=existing, existing=existing,
persist=lambda part: self.repository.save_sentence_translations( persist=lambda part: self._persist(job, part),
job.video_hash,
part,
),
) )
if translated: if translated:
self.repository.save_sentence_translations(job.video_hash, translated) self._persist(job, translated)
with job.lock: with job.lock:
job.error = None if not job.cancelled:
job.error = None
except Exception as exc: # noqa: BLE001 - surface job failure to clients 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) logger.exception("Subtitle translation job failed: video=%s", job.video_hash)
with job.lock: with job.lock:
job.error = str(exc) job.error = str(exc)