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