repaire sentences
This commit is contained in:
@@ -59,6 +59,7 @@ POST /api/v1/videos/{sha256}/sentences/{index}/assessments
|
||||
POST /api/v1/admin/videos
|
||||
PUT /api/v1/admin/videos/raw
|
||||
POST /api/v1/admin/videos/{sha256}/process
|
||||
POST /api/v1/admin/videos/{sha256}/repair-gaps
|
||||
GET /api/v1/admin/videos/{sha256}/subtitle-translation
|
||||
POST /api/v1/admin/videos/{sha256}/subtitle-translation
|
||||
PUT /api/v1/admin/videos/{sha256}/sentences/{index}
|
||||
|
||||
@@ -43,6 +43,8 @@ from .models import (
|
||||
CourseSummary,
|
||||
CourseTitleUpdate,
|
||||
SentenceBoundaryAdjust,
|
||||
SentenceGapRepairRequest,
|
||||
SentenceGapRepairResponse,
|
||||
SentenceBoundary,
|
||||
SentenceBoundaryDocument,
|
||||
SentenceExplanationRequest,
|
||||
@@ -688,6 +690,34 @@ def create_app(
|
||||
background_tasks.add_task(_process_safely, processor, video_hash)
|
||||
return {"video_hash": video_hash.lower(), "status": "processing_queued"}
|
||||
|
||||
@application.post(
|
||||
"/api/v1/admin/videos/{video_hash}/repair-gaps",
|
||||
response_model=SentenceGapRepairResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def repair_sentence_gaps(
|
||||
payload: Optional[SentenceGapRepairRequest] = None,
|
||||
video_hash: str = SHA256_PATH,
|
||||
) -> SentenceGapRepairResponse:
|
||||
request = payload or SentenceGapRepairRequest()
|
||||
try:
|
||||
result = await run_in_threadpool(
|
||||
processor.repair_gaps,
|
||||
video_hash,
|
||||
request.min_gap_ms,
|
||||
)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Video was not found.") from None
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
if result["inserted_count"]:
|
||||
application.state.subtitle_translations.start_missing(video_hash)
|
||||
return SentenceGapRepairResponse(video_hash=video_hash.lower(), **result)
|
||||
|
||||
@application.get(
|
||||
"/api/v1/admin/videos/{video_hash}/subtitle-translation",
|
||||
response_model=AdminSubtitleTranslationResponse,
|
||||
|
||||
@@ -185,6 +185,23 @@ class SentenceBoundaryAdjust(BaseModel):
|
||||
delta_ms: int = Field(ge=-10_000, le=10_000)
|
||||
|
||||
|
||||
class SentenceGapRepairRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
min_gap_ms: int = Field(default=8000, ge=2000, le=180_000)
|
||||
|
||||
|
||||
class SentenceGapRepairResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
video_hash: str
|
||||
min_gap_ms: int = Field(ge=1)
|
||||
gap_count: int = Field(ge=0)
|
||||
transcribed_gaps: int = Field(ge=0)
|
||||
inserted_count: int = Field(ge=0)
|
||||
sentence_count: int = Field(ge=0)
|
||||
|
||||
|
||||
class TextSubstitution(BaseModel):
|
||||
expected: str
|
||||
actual: str
|
||||
|
||||
@@ -4,7 +4,7 @@ import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from .audio_metrics import (
|
||||
AudioAnalysisError,
|
||||
@@ -29,6 +29,10 @@ from .transcription import (
|
||||
logger = logging.getLogger(__name__)
|
||||
MOSS_ALGORITHM_VERSION = "moss-punctuation-v4"
|
||||
SILENCE_ALGORITHM_VERSION = "silence-rms-v1"
|
||||
GAP_REPAIR_MIN_MS = 8_000
|
||||
GAP_REPAIR_INSET_MS = 150
|
||||
GAP_REPAIR_MIN_SPEECH_MS = 400
|
||||
GAP_REPAIR_MIN_SENTENCE_MS = 100
|
||||
_CUES_ID = b"\x1e\x53\xb6\x80"
|
||||
_CLUSTER_ID = b"\x1f\x43\xb6\x75"
|
||||
_HEADER_SCAN_SIZE = 64 * 1024
|
||||
@@ -173,6 +177,106 @@ class VideoProcessor:
|
||||
)
|
||||
self.repository.mark_oss_uploaded(document.video_hash, remote_url)
|
||||
|
||||
def repair_gaps(
|
||||
self,
|
||||
video_hash: str,
|
||||
min_gap_ms: int = GAP_REPAIR_MIN_MS,
|
||||
) -> Dict[str, int]:
|
||||
"""Re-transcribe long unsentenced gaps without replacing existing cues."""
|
||||
if min_gap_ms < 1:
|
||||
raise ValueError("min_gap_ms must be positive")
|
||||
if not self.transcriber.available:
|
||||
raise RuntimeError("MOSS transcription is not configured on this server.")
|
||||
video = self.repository.get_video(video_hash)
|
||||
if video is None:
|
||||
raise KeyError(video_hash)
|
||||
document = self.repository.get_document(video_hash)
|
||||
if document is None or not document.sentences:
|
||||
raise ValueError("Sentence boundaries are not available for this video.")
|
||||
media_path = self.settings.videos_dir / video["stored_filename"]
|
||||
if not media_path.is_file():
|
||||
raise FileNotFoundError(f"Stored video is missing: {media_path.name}")
|
||||
|
||||
gaps = find_sentence_gaps(document, min_gap_ms)
|
||||
inserted: List[SentenceBoundary] = []
|
||||
transcribed_gaps = 0
|
||||
if gaps:
|
||||
language = video.get("language")
|
||||
with tempfile.TemporaryDirectory(prefix="gap-repair-") as temp_dir:
|
||||
for gap_index, (raw_start, raw_end) in enumerate(gaps):
|
||||
clipped = _inset_gap(raw_start, raw_end, document.duration_ms)
|
||||
if clipped is None:
|
||||
continue
|
||||
start_ms, end_ms = clipped
|
||||
chunk_path = Path(temp_dir) / f"gap-{gap_index}.wav"
|
||||
try:
|
||||
extract_audio_segment(
|
||||
media_path,
|
||||
chunk_path,
|
||||
start_ms=start_ms,
|
||||
duration_ms=end_ms - start_ms,
|
||||
)
|
||||
samples, sample_rate = decode_audio_mono(chunk_path)
|
||||
metrics = analyze_samples(samples, sample_rate)
|
||||
except (AudioAnalysisError, RuntimeError) as exc:
|
||||
logger.info(
|
||||
"Skipping sentence gap %s-%sms: %s",
|
||||
start_ms,
|
||||
end_ms,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
if metrics.speech_duration_ms < GAP_REPAIR_MIN_SPEECH_MS:
|
||||
continue
|
||||
try:
|
||||
transcript = self.transcriber.transcribe(chunk_path, language)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Gap transcription failed for %s %s-%sms: %s",
|
||||
video_hash,
|
||||
start_ms,
|
||||
end_ms,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
gap_document = document_from_transcript(
|
||||
video_hash=video_hash,
|
||||
duration_ms=end_ms - start_ms,
|
||||
transcript=transcript,
|
||||
language=language,
|
||||
audio_path=chunk_path,
|
||||
end_padding_ms=self.settings.moss_end_padding_ms,
|
||||
)
|
||||
shifted = _shift_gap_sentences(
|
||||
gap_document.sentences,
|
||||
offset_ms=start_ms,
|
||||
limit_end_ms=end_ms,
|
||||
)
|
||||
if not shifted:
|
||||
continue
|
||||
transcribed_gaps += 1
|
||||
inserted.extend(shifted)
|
||||
|
||||
if not inserted:
|
||||
return {
|
||||
"min_gap_ms": min_gap_ms,
|
||||
"gap_count": len(gaps),
|
||||
"transcribed_gaps": transcribed_gaps,
|
||||
"inserted_count": 0,
|
||||
"sentence_count": len(document.sentences),
|
||||
}
|
||||
|
||||
merged = _merge_sentence_lists(document.sentences, inserted)
|
||||
updated = document.model_copy(update={"sentences": merged})
|
||||
saved = self.repository.replace_sentences(updated)
|
||||
return {
|
||||
"min_gap_ms": min_gap_ms,
|
||||
"gap_count": len(gaps),
|
||||
"transcribed_gaps": transcribed_gaps,
|
||||
"inserted_count": len(saved.sentences) - len(document.sentences),
|
||||
"sentence_count": len(saved.sentences),
|
||||
}
|
||||
|
||||
|
||||
def extract_audio(media_path: Path, output_path: Path) -> None:
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
@@ -241,6 +345,82 @@ def extract_audio_segment(
|
||||
raise RuntimeError(f"Could not extract sentence audio: {message[-2000:]}")
|
||||
|
||||
|
||||
def find_sentence_gaps(
|
||||
document: SentenceBoundaryDocument,
|
||||
min_gap_ms: int = GAP_REPAIR_MIN_MS,
|
||||
) -> List[Tuple[int, int]]:
|
||||
"""Return [start_ms, end_ms) ranges that are missing sentence coverage."""
|
||||
if min_gap_ms < 1:
|
||||
raise ValueError("min_gap_ms must be positive")
|
||||
gaps: List[Tuple[int, int]] = []
|
||||
cursor = 0
|
||||
for sentence in document.sentences:
|
||||
if sentence.start_ms - cursor >= min_gap_ms:
|
||||
gaps.append((cursor, sentence.start_ms))
|
||||
cursor = max(cursor, sentence.end_ms)
|
||||
if document.duration_ms - cursor >= min_gap_ms:
|
||||
gaps.append((cursor, document.duration_ms))
|
||||
return gaps
|
||||
|
||||
|
||||
def _inset_gap(
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
duration_ms: int,
|
||||
inset_ms: int = GAP_REPAIR_INSET_MS,
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
start = start_ms + (inset_ms if start_ms > 0 else 0)
|
||||
end = end_ms - (inset_ms if end_ms < duration_ms else 0)
|
||||
if end - start < GAP_REPAIR_MIN_SPEECH_MS:
|
||||
return None
|
||||
return start, end
|
||||
|
||||
|
||||
def _shift_gap_sentences(
|
||||
sentences: Sequence[SentenceBoundary],
|
||||
*,
|
||||
offset_ms: int,
|
||||
limit_end_ms: int,
|
||||
) -> List[SentenceBoundary]:
|
||||
shifted: List[SentenceBoundary] = []
|
||||
previous_end = offset_ms
|
||||
for sentence in sentences:
|
||||
start_ms = max(previous_end, sentence.start_ms + offset_ms)
|
||||
end_ms = min(limit_end_ms, sentence.end_ms + offset_ms)
|
||||
text = (sentence.text or "").strip()
|
||||
if not text or end_ms - start_ms < GAP_REPAIR_MIN_SENTENCE_MS:
|
||||
continue
|
||||
shifted.append(
|
||||
sentence.model_copy(
|
||||
update={
|
||||
"index": len(shifted),
|
||||
"start_ms": start_ms,
|
||||
"end_ms": end_ms,
|
||||
"text": text,
|
||||
"zh_text": None,
|
||||
}
|
||||
)
|
||||
)
|
||||
previous_end = end_ms
|
||||
return shifted
|
||||
|
||||
|
||||
def _merge_sentence_lists(
|
||||
existing: Sequence[SentenceBoundary],
|
||||
inserted: Sequence[SentenceBoundary],
|
||||
) -> List[SentenceBoundary]:
|
||||
combined = [*existing, *inserted]
|
||||
combined.sort(key=lambda sentence: (sentence.start_ms, sentence.end_ms))
|
||||
merged: List[SentenceBoundary] = []
|
||||
previous_end = 0
|
||||
for sentence in combined:
|
||||
if sentence.start_ms < previous_end:
|
||||
continue
|
||||
merged.append(sentence.model_copy(update={"index": len(merged)}))
|
||||
previous_end = sentence.end_ms
|
||||
return merged
|
||||
|
||||
|
||||
def split_sentence_text(
|
||||
*,
|
||||
transcriber: Transcriber,
|
||||
|
||||
@@ -544,6 +544,53 @@ class VideoRepository:
|
||||
sentences=[_sentence_from_row(row) for row in rows],
|
||||
)
|
||||
|
||||
def replace_sentences(
|
||||
self,
|
||||
document: SentenceBoundaryDocument,
|
||||
) -> SentenceBoundaryDocument:
|
||||
"""Replace stored sentences while preserving video status and translations."""
|
||||
normalized_hash = normalize_video_hash(document.video_hash)
|
||||
with self._connect() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT video_hash FROM videos WHERE video_hash = ?",
|
||||
(normalized_hash,),
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
raise KeyError(normalized_hash)
|
||||
connection.execute(
|
||||
"DELETE FROM sentences WHERE video_hash = ?",
|
||||
(normalized_hash,),
|
||||
)
|
||||
connection.executemany(
|
||||
"""
|
||||
INSERT INTO sentences (
|
||||
video_hash, sentence_index, start_ms, end_ms, text,
|
||||
language, zh_text, reference_speech_duration_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
normalized_hash,
|
||||
sentence.index,
|
||||
sentence.start_ms,
|
||||
sentence.end_ms,
|
||||
sentence.text,
|
||||
sentence.language,
|
||||
sentence.zh_text,
|
||||
sentence.reference_speech_duration_ms,
|
||||
)
|
||||
for sentence in document.sentences
|
||||
],
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE videos SET updated_at = ? WHERE video_hash = ?",
|
||||
(utc_now(), normalized_hash),
|
||||
)
|
||||
saved = self.get_document(normalized_hash)
|
||||
if saved is None:
|
||||
raise KeyError(normalized_hash)
|
||||
return saved
|
||||
|
||||
def update_sentence_text(
|
||||
self,
|
||||
video_hash: str,
|
||||
|
||||
@@ -81,7 +81,7 @@ table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e3e7ea; vertical-align: middle; }
|
||||
th { position: sticky; top: 0; z-index: 1; color: #44515b; background: #edf1f3; font-weight: 600; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
.actions-column { min-width: 280px; }
|
||||
.actions-column { min-width: 340px; }
|
||||
.actions { display: flex; gap: 6px; white-space: nowrap; }
|
||||
.actions button, .actions a { min-height: 30px; padding: 5px 9px; font-size: 12px; }
|
||||
.actions a { display: inline-flex; align-items: center; border: 1px solid #aab4bc; border-radius: 4px; color: #26323c; text-decoration: none; }
|
||||
@@ -103,6 +103,8 @@ dialog {
|
||||
dialog::backdrop { background: rgb(20 25 28 / 55%); }
|
||||
.dialog-heading { padding: 16px 18px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #dce1e5; }
|
||||
.dialog-heading span { font-size: 12px; color: #68747d; }
|
||||
.dialog-heading-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.gap-row td { background: #fff8ee; color: #9a5b12; font-style: italic; }
|
||||
.sentence-scroll { max-height: calc(100vh - 120px); overflow: auto; }
|
||||
.sentence-scroll textarea { width: min(600px, 45vw); min-width: 260px; min-height: 58px; resize: vertical; padding: 7px; border: 1px solid #b8c0c7; border-radius: 3px; }
|
||||
.sentence-scroll select { min-width: 90px; }
|
||||
|
||||
@@ -122,7 +122,10 @@
|
||||
<h2 id="sentence-title">句子文本</h2>
|
||||
<span id="sentence-meta"></span>
|
||||
</div>
|
||||
<button id="close-dialog" class="icon-button" type="button" aria-label="关闭">X</button>
|
||||
<div class="dialog-heading-actions">
|
||||
<button id="repair-gaps" type="button">修复空白</button>
|
||||
<button id="close-dialog" class="icon-button" type="button" aria-label="关闭">X</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sentence-scroll">
|
||||
<table>
|
||||
@@ -134,6 +137,6 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/admin.js?v=admin-retranslate-v1" defer></script>
|
||||
<script src="/static/admin.js?v=admin-repair-gaps-v1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
const GAP_REPAIR_MIN_MS = 8000;
|
||||
|
||||
const state = {
|
||||
adminKey: localStorage.getItem("oralTrainerAdminKey") || "",
|
||||
videos: [],
|
||||
users: [],
|
||||
retranslateVideo: null,
|
||||
sentenceVideo: null,
|
||||
};
|
||||
|
||||
const elements = {
|
||||
@@ -23,6 +26,7 @@ const elements = {
|
||||
sentenceMeta: document.querySelector("#sentence-meta"),
|
||||
sentenceRows: document.querySelector("#sentence-rows"),
|
||||
closeDialog: document.querySelector("#close-dialog"),
|
||||
repairGaps: document.querySelector("#repair-gaps"),
|
||||
retranslateDialog: document.querySelector("#retranslate-dialog"),
|
||||
retranslateTitle: document.querySelector("#retranslate-title"),
|
||||
retranslateMeta: document.querySelector("#retranslate-meta"),
|
||||
@@ -45,6 +49,9 @@ elements.refresh.addEventListener("click", () => {
|
||||
loadUsers();
|
||||
});
|
||||
elements.closeDialog.addEventListener("click", () => elements.dialog.close());
|
||||
elements.repairGaps.addEventListener("click", () => {
|
||||
if (state.sentenceVideo) repairGaps(state.sentenceVideo, { reloadSentences: true });
|
||||
});
|
||||
elements.closeRetranslateDialog.addEventListener("click", () => elements.retranslateDialog.close());
|
||||
elements.retranslateForm.addEventListener("submit", submitRetranslate);
|
||||
elements.uploadForm.addEventListener("submit", uploadVideo);
|
||||
@@ -192,6 +199,7 @@ function actionCell(video) {
|
||||
actions.append(play);
|
||||
actions.append(button("改标题", () => renameVideo(video)));
|
||||
actions.append(button("句子", () => openSentences(video)));
|
||||
actions.append(button("修复空白", () => repairGaps(video)));
|
||||
actions.append(button("重新翻译", () => openRetranslate(video)));
|
||||
actions.append(button("重新处理", () => reprocess(video.video_hash)));
|
||||
const remove = button("删除", () => deleteVideo(video));
|
||||
@@ -282,19 +290,61 @@ function readXhrError(request) {
|
||||
async function openSentences(video) {
|
||||
try {
|
||||
const detail = await api(`/api/v1/videos/${video.video_hash}`);
|
||||
state.sentenceVideo = video;
|
||||
elements.sentenceTitle.textContent = detail.video.title;
|
||||
const sentences = detail.boundaries?.sentences || [];
|
||||
elements.sentenceMeta.textContent = `${sentences.length} 句 / ${detail.video.video_hash}`;
|
||||
const durationMs = detail.video.duration_ms || 0;
|
||||
const gaps = collectSentenceGaps(sentences, durationMs);
|
||||
const gapLabel = gaps.length ? ` / ${gaps.length} 段空白待修复` : "";
|
||||
elements.sentenceMeta.textContent = `${sentences.length} 句${gapLabel} / ${detail.video.video_hash}`;
|
||||
elements.repairGaps.disabled = gaps.length === 0;
|
||||
elements.sentenceRows.replaceChildren();
|
||||
let gapIndex = 0;
|
||||
for (const sentence of sentences) {
|
||||
if (gapIndex < gaps.length && gaps[gapIndex].end_ms === sentence.start_ms) {
|
||||
elements.sentenceRows.append(gapRow(gaps[gapIndex]));
|
||||
gapIndex += 1;
|
||||
}
|
||||
elements.sentenceRows.append(sentenceRow(video, sentence, sentences.length));
|
||||
}
|
||||
if (gapIndex < gaps.length) {
|
||||
elements.sentenceRows.append(gapRow(gaps[gapIndex]));
|
||||
}
|
||||
if (!elements.dialog.open) elements.dialog.showModal();
|
||||
} catch (error) {
|
||||
elements.serviceState.textContent = `句子加载失败: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function collectSentenceGaps(sentences, durationMs, minGapMs = GAP_REPAIR_MIN_MS) {
|
||||
const gaps = [];
|
||||
let cursor = 0;
|
||||
for (const sentence of sentences) {
|
||||
if (sentence.start_ms - cursor >= minGapMs) {
|
||||
gaps.push({ start_ms: cursor, end_ms: sentence.start_ms });
|
||||
}
|
||||
cursor = Math.max(cursor, sentence.end_ms);
|
||||
}
|
||||
const endMs = durationMs || cursor;
|
||||
if (endMs - cursor >= minGapMs) {
|
||||
gaps.push({ start_ms: cursor, end_ms: endMs });
|
||||
}
|
||||
return gaps;
|
||||
}
|
||||
|
||||
function gapRow(gap) {
|
||||
const row = document.createElement("tr");
|
||||
row.className = "gap-row";
|
||||
row.append(
|
||||
textCell(""),
|
||||
textCell(`${formatDuration(gap.start_ms)} — ${formatDuration(gap.end_ms)}`),
|
||||
textCell(`未识别空白 ${formatDuration(gap.end_ms - gap.start_ms)}`),
|
||||
textCell(""),
|
||||
textCell(""),
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
function sentenceRow(video, sentence, totalCount) {
|
||||
const row = document.createElement("tr");
|
||||
const textArea = document.createElement("textarea");
|
||||
@@ -499,6 +549,36 @@ async function reprocess(videoHash) {
|
||||
}
|
||||
}
|
||||
|
||||
async function repairGaps(video, options = {}) {
|
||||
const confirmed = window.confirm(
|
||||
`将对“${video.title}”中间隔超过 8 秒的空白重新识别。现有句子会保留,可能需要几分钟。继续?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
const button = elements.repairGaps;
|
||||
if (button) button.disabled = true;
|
||||
elements.serviceState.textContent = "正在重新识别空白段…";
|
||||
try {
|
||||
const payload = await api(`/api/v1/admin/videos/${video.video_hash}/repair-gaps`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ min_gap_ms: GAP_REPAIR_MIN_MS }),
|
||||
});
|
||||
if (payload.inserted_count) {
|
||||
elements.serviceState.textContent = `空白修复完成:补了 ${payload.inserted_count} 句(${payload.transcribed_gaps}/${payload.gap_count} 段)`;
|
||||
} else if (payload.gap_count) {
|
||||
elements.serviceState.textContent = `发现 ${payload.gap_count} 段空白,但没有识别到新句子`;
|
||||
} else {
|
||||
elements.serviceState.textContent = "没有发现需要修复的空白段";
|
||||
}
|
||||
await loadVideos();
|
||||
if (options.reloadSentences) await openSentences(video);
|
||||
} catch (error) {
|
||||
elements.serviceState.textContent = `空白修复失败: ${error.message}`;
|
||||
} finally {
|
||||
if (button && !options.reloadSentences) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVideo(video) {
|
||||
if (!window.confirm(`删除“${video.title}”?`)) return;
|
||||
try {
|
||||
|
||||
@@ -1029,3 +1029,139 @@ def test_admin_can_update_course_title(tmp_path):
|
||||
json={"title": "Missing course"},
|
||||
)
|
||||
assert missing_response.status_code == 404
|
||||
|
||||
|
||||
|
||||
def test_admin_can_repair_sentence_gaps(tmp_path, monkeypatch):
|
||||
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="Repair gaps lesson",
|
||||
filename="lesson.mp4",
|
||||
stored_filename=f"{VIDEO_HASH}.mp4",
|
||||
content_type="video/mp4",
|
||||
size_bytes=5,
|
||||
language="en",
|
||||
)
|
||||
(settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"fake-video")
|
||||
repository.save_processing_result(
|
||||
SentenceBoundaryDocument(
|
||||
video_hash=VIDEO_HASH,
|
||||
duration_ms=18000,
|
||||
algorithm_version="test",
|
||||
sentences=[
|
||||
SentenceBoundary(
|
||||
index=0,
|
||||
start_ms=0,
|
||||
end_ms=2000,
|
||||
text="Hello.",
|
||||
language="en",
|
||||
reference_speech_duration_ms=1800,
|
||||
),
|
||||
SentenceBoundary(
|
||||
index=1,
|
||||
start_ms=15000,
|
||||
end_ms=18000,
|
||||
text="Goodbye.",
|
||||
language="en",
|
||||
reference_speech_duration_ms=2500,
|
||||
),
|
||||
],
|
||||
),
|
||||
"Hello. Goodbye.",
|
||||
)
|
||||
repository.save_sentence_translations(VIDEO_HASH, {0: "你好。", 1: "再见。"})
|
||||
|
||||
from sentence_api.transcription import Transcript, TranscriptionSegment, _wav_bytes
|
||||
import numpy as np
|
||||
|
||||
class GapTranscriber:
|
||||
available = True
|
||||
|
||||
def transcribe(self, audio_path, language=None):
|
||||
return Transcript(
|
||||
text="The missing sentence.",
|
||||
segments=[
|
||||
TranscriptionSegment(
|
||||
start_seconds=1.0,
|
||||
end_seconds=5.0,
|
||||
text="The missing sentence.",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def fake_extract(media_path, output_path, *, start_ms, duration_ms):
|
||||
sample_rate = 16000
|
||||
t = np.arange(int(max(1.0, duration_ms / 1000) * sample_rate)) / sample_rate
|
||||
samples = (0.25 * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
|
||||
output_path.write_bytes(_wav_bytes(samples, sample_rate))
|
||||
|
||||
monkeypatch.setattr("sentence_api.processing.extract_audio_segment", fake_extract)
|
||||
client = TestClient(
|
||||
create_app(
|
||||
BoundaryStore(tmp_path / "boundaries.json"),
|
||||
settings=settings,
|
||||
repository=repository,
|
||||
transcriber=GapTranscriber(),
|
||||
)
|
||||
)
|
||||
|
||||
response = client.post(f"/api/v1/admin/videos/{VIDEO_HASH}/repair-gaps")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["gap_count"] == 1
|
||||
assert payload["inserted_count"] == 1
|
||||
assert payload["sentence_count"] == 3
|
||||
document = repository.get_document(VIDEO_HASH)
|
||||
assert [item.text for item in document.sentences] == [
|
||||
"Hello.",
|
||||
"The missing sentence.",
|
||||
"Goodbye.",
|
||||
]
|
||||
assert [item.zh_text for item in document.sentences] == ["你好。", None, "再见。"]
|
||||
|
||||
|
||||
def test_admin_gap_repair_requires_transcriber(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="Repair gaps lesson",
|
||||
filename="lesson.mp4",
|
||||
stored_filename=f"{VIDEO_HASH}.mp4",
|
||||
content_type="video/mp4",
|
||||
size_bytes=5,
|
||||
language="en",
|
||||
)
|
||||
(settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"fake-video")
|
||||
repository.save_processing_result(
|
||||
SentenceBoundaryDocument(
|
||||
video_hash=VIDEO_HASH,
|
||||
duration_ms=18000,
|
||||
algorithm_version="test",
|
||||
sentences=[
|
||||
SentenceBoundary(index=0, start_ms=0, end_ms=2000, text="Hello."),
|
||||
SentenceBoundary(index=1, start_ms=15000, end_ms=18000, text="Goodbye."),
|
||||
],
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
class UnavailableTranscriber:
|
||||
available = False
|
||||
|
||||
client = TestClient(
|
||||
create_app(
|
||||
BoundaryStore(tmp_path / "boundaries.json"),
|
||||
settings=settings,
|
||||
repository=repository,
|
||||
transcriber=UnavailableTranscriber(),
|
||||
)
|
||||
)
|
||||
|
||||
response = client.post(f"/api/v1/admin/videos/{VIDEO_HASH}/repair-gaps")
|
||||
assert response.status_code == 503
|
||||
|
||||
@@ -4,6 +4,7 @@ from sentence_api.processing import (
|
||||
MOSS_ALGORITHM_VERSION,
|
||||
VideoProcessor,
|
||||
document_from_transcript,
|
||||
find_sentence_gaps,
|
||||
split_sentence_text,
|
||||
)
|
||||
from sentence_api.models import SentenceBoundary, SentenceBoundaryDocument
|
||||
@@ -381,3 +382,178 @@ def test_mark_processing_clears_previous_sentence_data(tmp_path):
|
||||
assert document.sentences == []
|
||||
assert repository.get_moss_segments(VIDEO_HASH) == []
|
||||
|
||||
|
||||
|
||||
|
||||
def test_find_sentence_gaps_detects_leading_middle_and_trailing():
|
||||
document = SentenceBoundaryDocument(
|
||||
video_hash=VIDEO_HASH,
|
||||
duration_ms=60000,
|
||||
algorithm_version="test",
|
||||
sentences=[
|
||||
SentenceBoundary(index=0, start_ms=10000, end_ms=12000, text="A"),
|
||||
SentenceBoundary(index=1, start_ms=25000, end_ms=27000, text="B"),
|
||||
],
|
||||
)
|
||||
|
||||
assert find_sentence_gaps(document, min_gap_ms=8000) == [
|
||||
(0, 10000),
|
||||
(12000, 25000),
|
||||
(27000, 60000),
|
||||
]
|
||||
|
||||
|
||||
def test_find_sentence_gaps_ignores_short_pauses():
|
||||
document = SentenceBoundaryDocument(
|
||||
video_hash=VIDEO_HASH,
|
||||
duration_ms=9000,
|
||||
algorithm_version="test",
|
||||
sentences=[
|
||||
SentenceBoundary(index=0, start_ms=0, end_ms=4000, text="A"),
|
||||
SentenceBoundary(index=1, start_ms=4500, end_ms=9000, text="B"),
|
||||
],
|
||||
)
|
||||
|
||||
assert find_sentence_gaps(document) == []
|
||||
|
||||
|
||||
def test_repair_gaps_inserts_transcribed_sentences_and_keeps_existing(tmp_path, monkeypatch):
|
||||
from dataclasses import replace
|
||||
|
||||
from sentence_api.config import Settings
|
||||
|
||||
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="Gap repair lesson",
|
||||
filename="lesson.mp4",
|
||||
stored_filename=f"{VIDEO_HASH}.mp4",
|
||||
content_type="video/mp4",
|
||||
size_bytes=5,
|
||||
language="en",
|
||||
)
|
||||
(settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"fake-video")
|
||||
repository.save_processing_result(
|
||||
SentenceBoundaryDocument(
|
||||
video_hash=VIDEO_HASH,
|
||||
duration_ms=18000,
|
||||
algorithm_version="test",
|
||||
sentences=[
|
||||
SentenceBoundary(
|
||||
index=0,
|
||||
start_ms=0,
|
||||
end_ms=2000,
|
||||
text="Hello.",
|
||||
language="en",
|
||||
reference_speech_duration_ms=1800,
|
||||
),
|
||||
SentenceBoundary(
|
||||
index=1,
|
||||
start_ms=15000,
|
||||
end_ms=18000,
|
||||
text="Goodbye.",
|
||||
language="en",
|
||||
reference_speech_duration_ms=2500,
|
||||
),
|
||||
],
|
||||
),
|
||||
"Hello. Goodbye.",
|
||||
)
|
||||
repository.save_sentence_translations(VIDEO_HASH, {0: "你好。", 1: "再见。"})
|
||||
|
||||
class GapTranscriber:
|
||||
available = True
|
||||
calls = 0
|
||||
|
||||
def transcribe(self, audio_path, language=None):
|
||||
self.calls += 1
|
||||
return Transcript(
|
||||
text="The missing sentence.",
|
||||
segments=[
|
||||
TranscriptionSegment(
|
||||
start_seconds=1.0,
|
||||
end_seconds=5.0,
|
||||
text="The missing sentence.",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def fake_extract(media_path, output_path, *, start_ms, duration_ms):
|
||||
output_path.write_bytes(_wav_bytes(_speech(max(1.0, duration_ms / 1000)), SAMPLE_RATE))
|
||||
|
||||
monkeypatch.setattr("sentence_api.processing.extract_audio_segment", fake_extract)
|
||||
transcriber = GapTranscriber()
|
||||
result = VideoProcessor(settings, repository, transcriber).repair_gaps(VIDEO_HASH)
|
||||
|
||||
assert result["gap_count"] == 1
|
||||
assert result["transcribed_gaps"] == 1
|
||||
assert result["inserted_count"] == 1
|
||||
assert transcriber.calls == 1
|
||||
document = repository.get_document(VIDEO_HASH)
|
||||
assert [sentence.text for sentence in document.sentences] == [
|
||||
"Hello.",
|
||||
"The missing sentence.",
|
||||
"Goodbye.",
|
||||
]
|
||||
assert document.sentences[0].zh_text == "你好。"
|
||||
assert document.sentences[1].zh_text is None
|
||||
assert document.sentences[2].zh_text == "再见。"
|
||||
assert document.sentences[0].end_ms <= document.sentences[1].start_ms
|
||||
assert document.sentences[1].end_ms <= document.sentences[2].start_ms
|
||||
assert document.sentences[1].start_ms >= 2000
|
||||
assert document.sentences[1].end_ms <= 15000
|
||||
|
||||
|
||||
def test_repair_gaps_skips_silent_gaps(tmp_path, monkeypatch):
|
||||
from dataclasses import replace
|
||||
|
||||
from sentence_api.config import Settings
|
||||
|
||||
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="Silent gap lesson",
|
||||
filename="lesson.mp4",
|
||||
stored_filename=f"{VIDEO_HASH}.mp4",
|
||||
content_type="video/mp4",
|
||||
size_bytes=5,
|
||||
language="en",
|
||||
)
|
||||
(settings.videos_dir / f"{VIDEO_HASH}.mp4").write_bytes(b"fake-video")
|
||||
repository.save_processing_result(
|
||||
SentenceBoundaryDocument(
|
||||
video_hash=VIDEO_HASH,
|
||||
duration_ms=18000,
|
||||
algorithm_version="test",
|
||||
sentences=[
|
||||
SentenceBoundary(index=0, start_ms=0, end_ms=2000, text="Hello."),
|
||||
SentenceBoundary(index=1, start_ms=15000, end_ms=18000, text="Goodbye."),
|
||||
],
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
class TrackingTranscriber:
|
||||
available = True
|
||||
calls = 0
|
||||
|
||||
def transcribe(self, audio_path, language=None):
|
||||
self.calls += 1
|
||||
return Transcript(text="hallucinated", segments=[])
|
||||
|
||||
def fake_extract(media_path, output_path, *, start_ms, duration_ms):
|
||||
output_path.write_bytes(_wav_bytes(_silence(max(1.0, duration_ms / 1000)), SAMPLE_RATE))
|
||||
|
||||
monkeypatch.setattr("sentence_api.processing.extract_audio_segment", fake_extract)
|
||||
transcriber = TrackingTranscriber()
|
||||
result = VideoProcessor(settings, repository, transcriber).repair_gaps(VIDEO_HASH)
|
||||
|
||||
assert result["gap_count"] == 1
|
||||
assert result["inserted_count"] == 0
|
||||
assert transcriber.calls == 0
|
||||
document = repository.get_document(VIDEO_HASH)
|
||||
assert [sentence.text for sentence in document.sentences] == ["Hello.", "Goodbye."]
|
||||
|
||||
@@ -116,6 +116,23 @@ class SubtitleTranslationService:
|
||||
except (KeyError, ValueError, AITutorError):
|
||||
logger.exception("Could not restart subtitle translation: video=%s", video_hash)
|
||||
|
||||
def start_missing(self, video_hash: str) -> None:
|
||||
"""Translate sentences that still lack Chinese text, keeping existing ones."""
|
||||
if not getattr(self.ai_tutor, "available", False):
|
||||
return
|
||||
document = self.repository.get_document(video_hash)
|
||||
if document is None:
|
||||
return
|
||||
if self.snapshot(document).complete:
|
||||
return
|
||||
try:
|
||||
self._start_if_needed(video_hash)
|
||||
except (KeyError, ValueError, AITutorError):
|
||||
logger.exception(
|
||||
"Could not start subtitle translation after gap repair: video=%s",
|
||||
video_hash,
|
||||
)
|
||||
|
||||
def admin_snapshot(self, video_hash: str) -> AdminSubtitleTranslationResponse | None:
|
||||
current = self.current(video_hash)
|
||||
if current is None:
|
||||
|
||||
Reference in New Issue
Block a user