add merging sentences action

This commit is contained in:
2026-09-04 09:09:34 +08:00
parent a38679d4a0
commit ff1341e191
6 changed files with 287 additions and 4 deletions

View File

@@ -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 支持),句子的结束时间用

View File

@@ -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],

View File

@@ -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
)

View File

@@ -114,6 +114,6 @@
</div>
</dialog>
<script src="/static/admin.js?v=admin-title-autofill-v2" defer></script>
<script src="/static/admin.js?v=admin-merge-sentence-v1" defer></script>
</body>
</html>

View File

@@ -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);

View File

@@ -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()