From ff1341e191dc4a180ff3d9a9e80b5516b83a3379 Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Fri, 4 Sep 2026 09:09:34 +0800 Subject: [PATCH] add merging sentences action --- sentence_api/DEPLOYMENT.md | 2 +- sentence_api/main.py | 21 +++++ sentence_api/repository.py | 146 +++++++++++++++++++++++++++++++++ sentence_api/static/admin.html | 2 +- sentence_api/static/admin.js | 24 +++++- sentence_api/tests/test_api.py | 96 ++++++++++++++++++++++ 6 files changed, 287 insertions(+), 4 deletions(-) diff --git a/sentence_api/DEPLOYMENT.md b/sentence_api/DEPLOYMENT.md index 25156b0..6474d0a 100644 --- a/sentence_api/DEPLOYMENT.md +++ b/sentence_api/DEPLOYMENT.md @@ -156,7 +156,7 @@ curl -X POST http://127.0.0.1:8001/v1/audio/transcriptions \ 只有碰到句号(`.`)、问号(`?`)或逗号前的单词数达到 50 时才算一句结束; 逗号前内容不足 50 个单词时不在此处断句,会继续与后续内容合并。Whisper 在没有 标点处断开的 segment 会自动合并成同一句。管理后台编辑句子文本时,换行并保存会把 -该句拆成多句;拆分点优先使用对该句音频再次转写得到的词级时间戳,转写不可用时按 +该句拆成多句;也支持将相邻两句合并成一句;拆分点优先使用对该句音频再次转写得到的词级时间戳,转写不可用时按 文本长度估算。管理后台的结束时间旁支持微调按钮;调整后下一句的起始时间联动更新, 并保证相邻句子不重叠。若转写服务支持词级时间戳 (`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用 diff --git a/sentence_api/main.py b/sentence_api/main.py index 73977c9..e760bd9 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -565,6 +565,27 @@ def create_app( raise HTTPException(status_code=404, detail="Sentence was not found.") return result + + @application.put( + "/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}/merge", + response_model=SentenceBoundary, + dependencies=[Depends(require_admin)], + ) + def merge_sentence( + video_hash: str = SHA256_PATH, + sentence_index: int = ApiPath(ge=0), + ) -> SentenceBoundary: + try: + sentence = video_repository.merge_with_next_sentence( + video_hash, + sentence_index, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if sentence is None: + raise HTTPException(status_code=404, detail="Sentence was not found.") + return sentence + @application.put( "/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}/boundary", response_model=List[SentenceBoundary], diff --git a/sentence_api/repository.py b/sentence_api/repository.py index 2d45759..3797f3c 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -548,6 +548,115 @@ class VideoRepository: for row in result_rows ] + + def merge_with_next_sentence( + self, + video_hash: str, + sentence_index: int, + ) -> Optional[SentenceBoundary]: + """Merge sentence_index with the immediately following sentence.""" + normalized_hash = normalize_video_hash(video_hash) + with self._connect() as connection: + current = connection.execute( + """ + SELECT * FROM sentences + WHERE video_hash = ? AND sentence_index = ? + """, + (normalized_hash, sentence_index), + ).fetchone() + if current is None: + return None + + nxt = connection.execute( + """ + SELECT * FROM sentences + WHERE video_hash = ? AND sentence_index = ? + """, + (normalized_hash, sentence_index + 1), + ).fetchone() + if nxt is None: + raise ValueError("No next sentence to merge with.") + + merged_text = _join_adjacent_sentence_text( + current["text"] or "", + nxt["text"] or "", + current["language"] or nxt["language"], + ) + merged_language = current["language"] or nxt["language"] + merged_speech_duration = max( + 1, + int(current["reference_speech_duration_ms"] or 0) + + int(nxt["reference_speech_duration_ms"] or 0), + ) + if nxt["end_ms"] <= current["start_ms"]: + raise ValueError("Merged sentence times must increase.") + + connection.execute( + """ + UPDATE sentences + SET end_ms = ?, text = ?, language = ?, + reference_speech_duration_ms = ? + WHERE video_hash = ? AND sentence_index = ? + """, + ( + nxt["end_ms"], + merged_text, + merged_language, + merged_speech_duration, + normalized_hash, + sentence_index, + ), + ) + connection.execute( + """ + DELETE FROM sentences + WHERE video_hash = ? AND sentence_index = ? + """, + (normalized_hash, sentence_index + 1), + ) + + # Compact following indexes upward so sentence_index stays contiguous. + following = [ + row["sentence_index"] + for row in connection.execute( + """ + SELECT sentence_index FROM sentences + WHERE video_hash = ? AND sentence_index > ? + ORDER BY sentence_index ASC + """, + (normalized_hash, sentence_index + 1), + ).fetchall() + ] + for old_index in following: + connection.execute( + """ + UPDATE sentences SET sentence_index = ? + WHERE video_hash = ? AND sentence_index = ? + """, + (old_index - 1, normalized_hash, old_index), + ) + + connection.execute( + "UPDATE videos SET updated_at = ? WHERE video_hash = ?", + (utc_now(), normalized_hash), + ) + row = connection.execute( + """ + SELECT * FROM sentences + WHERE video_hash = ? AND sentence_index = ? + """, + (normalized_hash, sentence_index), + ).fetchone() + + return SentenceBoundary( + index=row["sentence_index"], + start_ms=row["start_ms"], + end_ms=row["end_ms"], + text=row["text"], + language=row["language"], + reference_speech_duration_ms=row["reference_speech_duration_ms"], + ) + def adjust_sentence_boundary( self, video_hash: str, @@ -1019,3 +1128,40 @@ class VideoRepository: (normalize_video_hash(video_hash),), ) return video + + +def _join_adjacent_sentence_text( + left: str, + right: str, + language: Optional[str], +) -> str: + """Join two adjacent sentence texts with language-aware spacing.""" + left_text = left.strip() + right_text = right.strip() + if not left_text: + return right_text + if not right_text: + return left_text + + lang = (language or "").strip().lower() + if lang.startswith(("zh", "ja", "ko")): + return f"{left_text}{right_text}" + + left_end = left_text[-1] + right_start = right_text[0] + if _is_cjk_char(left_end) or _is_cjk_char(right_start): + return f"{left_text}{right_text}" + if left_end.isspace() or right_start in ",.;:!?)]}、,。!?;:”’》〉】": + return f"{left_text}{right_text}" + return f"{left_text} {right_text}" + + +def _is_cjk_char(char: str) -> bool: + code = ord(char) + return ( + 0x3400 <= code <= 0x4DBF + or 0x4E00 <= code <= 0x9FFF + or 0xF900 <= code <= 0xFAFF + or 0x20000 <= code <= 0x2CEAF + ) + diff --git a/sentence_api/static/admin.html b/sentence_api/static/admin.html index 7317d61..25cd807 100644 --- a/sentence_api/static/admin.html +++ b/sentence_api/static/admin.html @@ -114,6 +114,6 @@ - + diff --git a/sentence_api/static/admin.js b/sentence_api/static/admin.js index 33c5ddd..cc9676c 100644 --- a/sentence_api/static/admin.js +++ b/sentence_api/static/admin.js @@ -254,7 +254,7 @@ async function openSentences(video) { elements.sentenceMeta.textContent = `${sentences.length} 句 / ${detail.video.video_hash}`; elements.sentenceRows.replaceChildren(); for (const sentence of sentences) { - elements.sentenceRows.append(sentenceRow(video, sentence)); + elements.sentenceRows.append(sentenceRow(video, sentence, sentences.length)); } if (!elements.dialog.open) elements.dialog.showModal(); } catch (error) { @@ -262,7 +262,7 @@ async function openSentences(video) { } } -function sentenceRow(video, sentence) { +function sentenceRow(video, sentence, totalCount) { const row = document.createElement("tr"); const textArea = document.createElement("textarea"); textArea.value = sentence.text || ""; @@ -307,6 +307,26 @@ function sentenceRow(video, sentence) { } }); saveContainer.append(save); + if (sentence.index < totalCount - 1) { + const merge = button("合并下句", async () => { + const preview = `${(textArea.value || sentence.text || "").trim()} + 下一句`; + const confirmed = window.confirm(`确定将第 ${sentence.index + 1} 句与下一句合并成一句吗?\n${preview}`); + if (!confirmed) return; + merge.disabled = true; + try { + await api(`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/merge`, { + method: "PUT", + }); + merge.textContent = "已合并"; + await openSentences(video); + } catch (error) { + elements.serviceState.textContent = `句子合并失败: ${error.message}`; + merge.textContent = error.message; + window.setTimeout(() => { merge.disabled = false; merge.textContent = "合并下句"; }, 1600); + } + }); + saveContainer.append(merge); + } const timeContainer = document.createElement("td"); const startLabel = document.createElement("span"); startLabel.textContent = formatDuration(sentence.start_ms); diff --git a/sentence_api/tests/test_api.py b/sentence_api/tests/test_api.py index 066d62d..760da13 100644 --- a/sentence_api/tests/test_api.py +++ b/sentence_api/tests/test_api.py @@ -277,6 +277,102 @@ def test_admin_can_split_sentence_and_reindexes_following_sentences(tmp_path): assert [item.index for item in document.sentences] == [0, 1, 2, 3] +def test_admin_can_merge_adjacent_sentences_and_reindexes_following_sentences(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="Manual merge 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=2000, + text="Before.", + language="en", + reference_speech_duration_ms=1800, + ), + SentenceBoundary( + index=1, + start_ms=2000, + end_ms=5000, + text="First part", + language="en", + reference_speech_duration_ms=2800, + ), + SentenceBoundary( + index=2, + start_ms=5000, + end_ms=9000, + text="second part", + language="en", + reference_speech_duration_ms=3600, + ), + SentenceBoundary( + index=3, + start_ms=9000, + end_ms=12000, + text="Next sentence.", + language="en", + reference_speech_duration_ms=2800, + ), + ], + ), + None, + ) + + class UnavailableTranscriber: + available = False + + client = TestClient( + create_app( + BoundaryStore(tmp_path / "boundaries.json"), + settings=settings, + repository=repository, + transcriber=UnavailableTranscriber(), + ) + ) + response = client.put( + f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/1/merge", + ) + + assert response.status_code == 200 + merged = response.json() + assert merged["index"] == 1 + assert merged["text"] == "First part second part" + assert merged["start_ms"] == 2000 + assert merged["end_ms"] == 9000 + assert merged["reference_speech_duration_ms"] == 6400 + + document = repository.get_document(VIDEO_HASH) + assert [item.text for item in document.sentences] == [ + "Before.", + "First part second part", + "Next sentence.", + ] + 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] + + last_response = client.put( + f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/2/merge", + ) + assert last_response.status_code == 400 + assert last_response.json()["detail"] == "No next sentence to merge with." + + def test_admin_boundary_adjustment_updates_linked_next_start(tmp_path): settings = replace(Settings.from_env(), data_dir=tmp_path / "data") settings.ensure_directories()