regenerate something when the sentences changed
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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._-]"), "_")
|
||||||
|
|||||||
@@ -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) : "_" }
|
||||||
|
|||||||
@@ -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) 句")
|
||||||
|
|||||||
@@ -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 ?? []
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user