add merging sentences action
This commit is contained in:
@@ -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 支持),句子的结束时间用
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user