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 时才算一句结束;
|
||||||
逗号前内容不足 50 个单词时不在此处断句,会继续与后续内容合并。Whisper 在没有
|
逗号前内容不足 50 个单词时不在此处断句,会继续与后续内容合并。Whisper 在没有
|
||||||
标点处断开的 segment 会自动合并成同一句。管理后台编辑句子文本时,换行并保存会把
|
标点处断开的 segment 会自动合并成同一句。管理后台编辑句子文本时,换行并保存会把
|
||||||
该句拆成多句;拆分点优先使用对该句音频再次转写得到的词级时间戳,转写不可用时按
|
该句拆成多句;也支持将相邻两句合并成一句;拆分点优先使用对该句音频再次转写得到的词级时间戳,转写不可用时按
|
||||||
文本长度估算。管理后台的结束时间旁支持微调按钮;调整后下一句的起始时间联动更新,
|
文本长度估算。管理后台的结束时间旁支持微调按钮;调整后下一句的起始时间联动更新,
|
||||||
并保证相邻句子不重叠。若转写服务支持词级时间戳
|
并保证相邻句子不重叠。若转写服务支持词级时间戳
|
||||||
(`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用
|
(`timestamp_granularities[]=word`,Whisper/Speaches 支持),句子的结束时间用
|
||||||
|
|||||||
@@ -565,6 +565,27 @@ def create_app(
|
|||||||
raise HTTPException(status_code=404, detail="Sentence was not found.")
|
raise HTTPException(status_code=404, detail="Sentence was not found.")
|
||||||
return result
|
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(
|
@application.put(
|
||||||
"/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}/boundary",
|
"/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}/boundary",
|
||||||
response_model=List[SentenceBoundary],
|
response_model=List[SentenceBoundary],
|
||||||
|
|||||||
@@ -548,6 +548,115 @@ class VideoRepository:
|
|||||||
for row in result_rows
|
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(
|
def adjust_sentence_boundary(
|
||||||
self,
|
self,
|
||||||
video_hash: str,
|
video_hash: str,
|
||||||
@@ -1019,3 +1128,40 @@ class VideoRepository:
|
|||||||
(normalize_video_hash(video_hash),),
|
(normalize_video_hash(video_hash),),
|
||||||
)
|
)
|
||||||
return video
|
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>
|
</div>
|
||||||
</dialog>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ async function openSentences(video) {
|
|||||||
elements.sentenceMeta.textContent = `${sentences.length} 句 / ${detail.video.video_hash}`;
|
elements.sentenceMeta.textContent = `${sentences.length} 句 / ${detail.video.video_hash}`;
|
||||||
elements.sentenceRows.replaceChildren();
|
elements.sentenceRows.replaceChildren();
|
||||||
for (const sentence of sentences) {
|
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();
|
if (!elements.dialog.open) elements.dialog.showModal();
|
||||||
} catch (error) {
|
} 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 row = document.createElement("tr");
|
||||||
const textArea = document.createElement("textarea");
|
const textArea = document.createElement("textarea");
|
||||||
textArea.value = sentence.text || "";
|
textArea.value = sentence.text || "";
|
||||||
@@ -307,6 +307,26 @@ function sentenceRow(video, sentence) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
saveContainer.append(save);
|
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 timeContainer = document.createElement("td");
|
||||||
const startLabel = document.createElement("span");
|
const startLabel = document.createElement("span");
|
||||||
startLabel.textContent = formatDuration(sentence.start_ms);
|
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]
|
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):
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user