From a87228cdc9e6479f4edfdabeb14c757f33b7a164 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Thu, 29 Oct 2026 11:32:33 +0800 Subject: [PATCH] fixed bugs --- sentence_api/DEPLOYMENT.md | 6 +- sentence_api/main.py | 53 ++++++++++++- sentence_api/models.py | 2 + sentence_api/repository.py | 13 +--- sentence_api/static/admin.html | 2 +- sentence_api/static/admin.js | 132 +++++++++++++++++++++++--------- sentence_api/tests/test_api.py | 135 ++++++++++++++++++--------------- 7 files changed, 229 insertions(+), 114 deletions(-) diff --git a/sentence_api/DEPLOYMENT.md b/sentence_api/DEPLOYMENT.md index 83e22ba..75960a6 100644 --- a/sentence_api/DEPLOYMENT.md +++ b/sentence_api/DEPLOYMENT.md @@ -158,9 +158,9 @@ curl -X POST http://127.0.0.1:8001/v1/audio/transcriptions \ `sentence_api` 不以 Whisper 返回的 segment 直接作为句子:文本会跨 segment 累积, 只有碰到句号(`.`)、问号(`?`)或逗号前的单词数达到 50 时才算一句结束; 逗号前内容不足 50 个单词时不在此处断句,会继续与后续内容合并。Whisper 在没有 -标点处断开的 segment 会自动合并成同一句。管理后台会先暂存换行拆分、相邻句合并和 -结束时间微调;关闭句子对话框时才一次性提交,后台只启动一轮拆分时间戳/翻译处理。 -拆分点优先使用对原句音频再次转写得到的词级时间戳,转写不可用时按 +标点处断开的 segment 会自动合并成同一句。管理后台的文本保存、换行拆分占位、相邻句 +合并和结束时间微调会立即入库,但不会触发翻译或转写;关闭句子对话框时才统一执行 +一轮拆分时间戳处理和翻译。拆分点优先使用对原句音频再次转写得到的词级时间戳,转写不可用时按 文本长度估算。管理后台的结束时间旁支持微调按钮;调整后下一句的起始时间联动更新, 并保证相邻句子不重叠。若转写服务支持词级时间戳 (`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用 diff --git a/sentence_api/main.py b/sentence_api/main.py index 8d8a69c..05721e0 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -812,7 +812,10 @@ def create_app( raise HTTPException(status_code=404, detail="Course is not available.") from None except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc - application.state.subtitle_translations.refresh(video_hash) + if payload.process_timings: + application.state.subtitle_translations.refresh(video_hash) + else: + application.state.subtitle_translations.invalidate(video_hash) return result @application.put( @@ -833,7 +836,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) + application.state.subtitle_translations.invalidate(video_hash) return sentence @application.put( @@ -891,7 +894,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) + application.state.subtitle_translations.invalidate(video_hash) return sentence @application.put( @@ -1261,6 +1264,7 @@ def _replace_admin_sentences( end_ms=item.end_ms, text=item.text.strip(), language=item.language, + zh_text=item.zh_text, reference_speech_duration_ms=item.reference_speech_duration_ms, ) ) @@ -1279,7 +1283,32 @@ def _replace_admin_sentences( group = payload.sentences[position:group_end] source = old_sentences.get(source_key) if source is None: - raise ValueError("The original sentence for a queued split no longer exists.") + if payload.process_timings: + # A split may already have been persisted with provisional + # boundaries. Reconstruct its audio range from the saved rows so + # timing refinement can be retried after a dialog is reopened. + missing = [ + item + for item in group + if (item.start_ms, item.end_ms) not in old_sentences + ] + if missing: + raise ValueError("The original sentence for a queued split no longer exists.") + source = SentenceBoundary( + index=0, + start_ms=source_key[0], + end_ms=source_key[1], + text="\n".join(item.text.strip() for item in group), + language=next( + (item.language for item in group if item.language is not None), + None, + ), + reference_speech_duration_ms=sum( + item.reference_speech_duration_ms for item in group + ), + ) + else: + source = None if len(group) == 1: if "\n" in group[0].text or "\r" in group[0].text: raise ValueError("Unsplit batch sentences cannot contain line breaks.") @@ -1290,9 +1319,24 @@ def _replace_admin_sentences( end_ms=group[0].end_ms, text=group[0].text.strip(), language=group[0].language, + zh_text=group[0].zh_text, reference_speech_duration_ms=group[0].reference_speech_duration_ms, ) ) + elif not payload.process_timings: + for item in group: + rows.append( + SentenceBoundary( + index=index, + start_ms=item.start_ms, + end_ms=item.end_ms, + text=item.text.strip(), + language=item.language, + zh_text=item.zh_text, + reference_speech_duration_ms=item.reference_speech_duration_ms, + ) + ) + index += 1 else: split_text = "\n".join(item.text.strip() for item in group if item.text.strip()) replacements = split_sentence_text( @@ -1319,6 +1363,7 @@ def _replace_admin_sentences( if item.language is not None else source.language ), + zh_text=None, reference_speech_duration_ms=replacement.reference_speech_duration_ms, ) ) diff --git a/sentence_api/models.py b/sentence_api/models.py index 7d9e43f..bfc0b6d 100644 --- a/sentence_api/models.py +++ b/sentence_api/models.py @@ -100,6 +100,7 @@ class AdminSentenceBatchItem(BaseModel): end_ms: int = Field(gt=0) text: str = Field(min_length=1, max_length=4000) language: Optional[str] = Field(default=None, max_length=32) + zh_text: Optional[str] = Field(default=None, max_length=4000) reference_speech_duration_ms: int = Field(gt=0) source_start_ms: Optional[int] = Field(default=None, ge=0) source_end_ms: Optional[int] = Field(default=None, gt=0) @@ -119,6 +120,7 @@ class AdminSentenceBatchUpdate(BaseModel): model_config = ConfigDict(extra="forbid") sentences: List[AdminSentenceBatchItem] = Field(min_length=1, max_length=10000) + process_timings: bool = Field(default=True) class CourseTitleUpdate(BaseModel): diff --git a/sentence_api/repository.py b/sentence_api/repository.py index 09cc017..149831f 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -602,17 +602,14 @@ class VideoRepository: with self._connect() as connection: cursor = connection.execute( """ - UPDATE sentences SET text = ?, language = COALESCE(?, language) + UPDATE sentences SET text = ?, language = COALESCE(?, language), + zh_text = NULL WHERE video_hash = ? AND sentence_index = ? """, (text.strip(), language, normalized_hash, sentence_index), ) if cursor.rowcount == 0: return None - connection.execute( - "UPDATE sentences SET zh_text = NULL WHERE video_hash = ?", - (normalized_hash,), - ) connection.execute( "UPDATE videos SET updated_at = ? WHERE video_hash = ?", (utc_now(), normalized_hash), @@ -817,7 +814,7 @@ class VideoRepository: """ UPDATE sentences SET end_ms = ?, text = ?, language = ?, - reference_speech_duration_ms = ? + reference_speech_duration_ms = ?, zh_text = NULL WHERE video_hash = ? AND sentence_index = ? """, ( @@ -858,10 +855,6 @@ class VideoRepository: (old_index - 1, normalized_hash, old_index), ) - connection.execute( - "UPDATE sentences SET zh_text = NULL WHERE video_hash = ?", - (normalized_hash,), - ) connection.execute( "UPDATE videos SET updated_at = ? WHERE video_hash = ?", (utc_now(), normalized_hash), diff --git a/sentence_api/static/admin.html b/sentence_api/static/admin.html index d878057..fc28a24 100644 --- a/sentence_api/static/admin.html +++ b/sentence_api/static/admin.html @@ -139,6 +139,6 @@ - + diff --git a/sentence_api/static/admin.js b/sentence_api/static/admin.js index 73711d9..1cfb442 100644 --- a/sentence_api/static/admin.js +++ b/sentence_api/static/admin.js @@ -11,6 +11,7 @@ const state = { sentenceBaseMeta: "", sentencePending: false, sentenceSaving: false, + sentenceNeedsProcessing: false, }; const elements = { @@ -335,6 +336,7 @@ async function openSentences(video) { })); state.sentencePending = false; state.sentenceSaving = false; + state.sentenceNeedsProcessing = false; const durationMs = detail.video.duration_ms || 0; const sentences = state.sentenceDraft; const gaps = collectSentenceGaps(sentences, durationMs); @@ -357,6 +359,7 @@ function renderSentenceDraft(video) { elements.sentenceMeta.textContent = [ state.sentenceBaseMeta, state.sentencePending ? "有暂存修改" : "", + state.sentenceNeedsProcessing ? "已入库待处理" : "", state.sentenceSaving ? "正在处理" : "", ].filter(Boolean).join(" / "); elements.repairGaps.disabled = gaps.length === 0 || state.sentencePending; @@ -379,12 +382,19 @@ function renderSentenceDraft(video) { async function handleSentenceDialogClose() { if (!state.sentenceDraft || state.sentenceSaving) return; - if (!state.sentencePending) { + if (!state.sentencePending && !state.sentenceNeedsProcessing) { resetSentenceDraft(); return; } - const saved = await flushSentenceDraft(); - if (!saved) { + if (state.sentencePending) { + const persisted = await saveSentenceDraft({ processTimings: false }); + if (!persisted) { + if (state.sentenceVideo && !elements.dialog.open) elements.dialog.showModal(); + return; + } + } + const processed = await saveSentenceDraft({ processTimings: true }); + if (!processed) { if (state.sentenceVideo && !elements.dialog.open) elements.dialog.showModal(); return; } @@ -392,15 +402,23 @@ async function handleSentenceDialogClose() { loadVideos(); } -async function flushSentenceDraft() { +async function saveSentenceDraft({ processTimings = false } = {}) { const video = state.sentenceVideo; - if (!video || !state.sentencePending) return true; + if (!video) return false; + if (!processTimings && !state.sentencePending) return true; + if (processTimings && !state.sentencePending && !state.sentenceNeedsProcessing) return true; materializeSentenceDraftSplits(video); state.sentenceSaving = true; renderSentenceDraft(video); try { const body = { - sentences: state.sentenceDraft.map(({ index, zh_text, ...sentence }) => sentence), + process_timings: processTimings, + sentences: state.sentenceDraft.map(({ + index, + originStartMs, + originEndMs, + ...sentence + }) => sentence), }; const saved = await api( `/api/v1/admin/videos/${video.video_hash}/sentences/batch`, @@ -410,11 +428,26 @@ async function flushSentenceDraft() { body: JSON.stringify(body), }, ); - state.sentenceDraft = saved.map((sentence) => ({ ...sentence })); + const previousByRange = new Map( + state.sentenceDraft.map((sentence) => [`${sentence.start_ms}:${sentence.end_ms}`, sentence]), + ); + state.sentenceDraft = saved.map((sentence) => { + const previous = previousByRange.get(`${sentence.start_ms}:${sentence.end_ms}`); + return { + ...sentence, + originStartMs: previous?.originStartMs ?? sentence.start_ms, + originEndMs: previous?.originEndMs ?? sentence.end_ms, + sourceStartMs: previous?.sourceStartMs, + sourceEndMs: previous?.sourceEndMs, + }; + }); state.sentencePending = false; + state.sentenceNeedsProcessing = !processTimings; return true; } catch (error) { - elements.serviceState.textContent = `句子批量处理失败: ${error.message}`; + elements.serviceState.textContent = processTimings + ? `句子批量处理失败: ${error.message}` + : `句子入库失败: ${error.message}`; return false; } finally { state.sentenceSaving = false; @@ -434,6 +467,7 @@ function resetSentenceDraft() { state.sentenceDraft = null; state.sentencePending = false; state.sentenceSaving = false; + state.sentenceNeedsProcessing = false; state.sentenceVideo = null; state.sentenceBaseMeta = ""; } @@ -592,39 +626,64 @@ function sentenceRow(video, sentence, totalCount) { const languageContainer = document.createElement("td"); languageContainer.append(language); const saveContainer = document.createElement("td"); - const save = button("保存", () => { - if (/\r?\n/.test(sentence.text || "")) { - splitDraftSentence(video, sentence, sentence.text, language.value || null); - return; + const save = button("保存", async () => { + save.disabled = true; + save.textContent = "保存中"; + try { + if (/\r?\n/.test(sentence.text || "")) { + splitDraftSentence(video, sentence, sentence.text, language.value || null); + } else { + sentence.text = textArea.value; + sentence.language = language.value || null; + sentence.zh_text = null; + markSentenceDraftChanged(video); + } + await saveSentenceDraft({ processTimings: false }); + } finally { + save.disabled = false; + save.textContent = "保存"; } - markSentenceDraftChanged(video); }); saveContainer.append(save); if (sentence.index < totalCount - 1) { - const merge = button("合并下句", () => { - const preview = `${(textArea.value || sentence.text || "").trim()} + 下一句`; + const merge = button("合并下句", async () => { + const preview = `${(sentence.text || "").trim()} + 下一句`; const confirmed = window.confirm(`确定将第 ${sentence.index + 1} 句与下一句合并成一句吗?\n${preview}`); if (!confirmed) return; - const next = state.sentenceDraft[sentence.index + 1]; - const mergedLanguage = sentence.language || next.language; - const merged = { - index: sentence.index, - start_ms: sentence.start_ms, - end_ms: next.end_ms, - text: joinDraftSentenceText(sentence.text, next.text, mergedLanguage), - language: mergedLanguage, - zh_text: null, - reference_speech_duration_ms: Math.max( - 1, - (sentence.reference_speech_duration_ms || 1) + - (next.reference_speech_duration_ms || 1), - ), - sourceStartMs: undefined, - sourceEndMs: undefined, - }; - state.sentenceDraft.splice(sentence.index, 2, merged); - renumberSentenceDraft(); - markSentenceDraftChanged(video); + const sourceStart = sentence.start_ms; + const sourceEnd = sentence.end_ms; + merge.disabled = true; + merge.textContent = "入库中"; + try { + if (state.sentencePending) { + const persisted = await saveSentenceDraft({ processTimings: false }); + if (!persisted) return; + } + const targetIndex = state.sentenceDraft.findIndex( + (item) => item.start_ms === sourceStart && item.end_ms === sourceEnd, + ); + if (targetIndex < 0) throw new Error("找不到要合并的句子"); + const merged = await api( + `/api/v1/admin/videos/${video.video_hash}/sentences/${targetIndex}/merge`, + { method: "PUT" }, + ); + state.sentenceDraft.splice(targetIndex, 2, { + ...merged, + originStartMs: merged.start_ms, + originEndMs: merged.end_ms, + sourceStartMs: undefined, + sourceEndMs: undefined, + }); + renumberSentenceDraft(); + state.sentencePending = false; + state.sentenceNeedsProcessing = true; + } catch (error) { + elements.serviceState.textContent = `句子合并失败: ${error.message}`; + } finally { + merge.disabled = false; + merge.textContent = "合并下句"; + renderSentenceDraft(video); + } }); saveContainer.append(merge); } @@ -651,7 +710,7 @@ function boundaryControl(video, sentence) { const status = document.createElement("span"); status.className = "boundary-status"; - function adjust(deltaMs) { + async function adjust(deltaMs) { const next = state.sentenceDraft[sentence.index + 1]; if (!next) return; const nextBoundary = sentence.end_ms + deltaMs; @@ -669,6 +728,7 @@ function boundaryControl(video, sentence) { next.sourceStartMs = undefined; next.sourceEndMs = undefined; markSentenceDraftChanged(video); + await saveSentenceDraft({ processTimings: false }); } const minus = button("−0.1", (event) => adjust(event.shiftKey ? -10 : -100)); diff --git a/sentence_api/tests/test_api.py b/sentence_api/tests/test_api.py index 1e9fbf9..13d414a 100644 --- a/sentence_api/tests/test_api.py +++ b/sentence_api/tests/test_api.py @@ -824,7 +824,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) + assert [item.zh_text for item in document.sentences] == ["之前。", None, "下一句。"] last_response = client.put( f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/2/merge", @@ -936,7 +936,73 @@ def test_admin_batch_applies_merge_and_split_once(tmp_path, monkeypatch): assert all(not (item.zh_text or "").strip() for item in document.sentences) -def test_admin_sentence_merge_restarts_subtitle_translation(tmp_path, monkeypatch): +def test_admin_batch_can_persist_provisional_split_without_processing(tmp_path): + settings = replace(Settings.from_env(), data_dir=tmp_path / "data") + settings.ensure_directories() + repository = VideoRepository(settings.database_path) + repository.upsert_upload( + video_hash=VIDEO_HASH, + title="Provisional split lesson", + filename="lesson.mp4", + stored_filename=f"{VIDEO_HASH}.mp4", + content_type="video/mp4", + size_bytes=5, + language="en", + ) + repository.save_processing_result( + SentenceBoundaryDocument( + video_hash=VIDEO_HASH, + duration_ms=12000, + algorithm_version="test", + sentences=[ + SentenceBoundary(index=0, start_ms=0, end_ms=5000, text="First part second part", language="en", reference_speech_duration_ms=4600), + SentenceBoundary(index=1, start_ms=5000, end_ms=12000, text="Unchanged.", language="en", reference_speech_duration_ms=6400), + ], + ), + None, + ) + repository.save_sentence_translations(VIDEO_HASH, {1: "未变化。"}) + calls = [] + + class MustNotTranscribe: + available = True + + def transcribe(self, audio_path, language=None): + calls.append(audio_path) + raise AssertionError("provisional save must not transcribe") + + client = TestClient( + create_app( + BoundaryStore(tmp_path / "boundaries.json"), + settings=settings, + repository=repository, + transcriber=MustNotTranscribe(), + ) + ) + response = client.put( + f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/batch", + json={ + "process_timings": False, + "sentences": [ + {"start_ms": 0, "end_ms": 2500, "text": "First part", "language": "en", "zh_text": None, "reference_speech_duration_ms": 2300, "source_start_ms": 0, "source_end_ms": 5000}, + {"start_ms": 2500, "end_ms": 5000, "text": "second part", "language": "en", "zh_text": None, "reference_speech_duration_ms": 2300, "source_start_ms": 0, "source_end_ms": 5000}, + {"start_ms": 5000, "end_ms": 12000, "text": "Unchanged.", "language": "en", "zh_text": "未变化。", "reference_speech_duration_ms": 6400}, + ], + }, + ) + + assert response.status_code == 200, response.text + payload = response.json() + assert [item["text"] for item in payload] == ["First part", "second part", "Unchanged."] + assert [item["start_ms"] for item in payload] == [0, 2500, 5000] + assert [item["zh_text"] for item in payload] == [None, None, "未变化。"] + assert calls == [] + document = repository.get_document(VIDEO_HASH) + assert [item.text for item in document.sentences] == ["First part", "second part", "Unchanged."] + assert [item.zh_text for item in document.sentences] == [None, None, "未变化。"] + + +def test_admin_sentence_merge_does_not_start_subtitle_translation(tmp_path, monkeypatch): client = make_client(tmp_path) _ready_video(client.app.state.video_repository) requested = [] @@ -966,66 +1032,15 @@ def test_admin_sentence_merge_restarts_subtitle_translation(tmp_path, monkeypatc 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."}, - ] + snapshot = client.get(f"/api/v1/videos/{VIDEO_HASH}/subtitle-translation") + assert snapshot.status_code == 200 + assert snapshot.json()["status"] == "idle" + assert snapshot.json()["complete"] is False + assert snapshot.json()["translations"] == [] + assert len(requested) == 1 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.")] + assert [sentence.zh_text for sentence in document.sentences] == [None] def test_admin_boundary_adjustment_updates_linked_next_start(tmp_path):