fixed a bug
This commit is contained in:
@@ -27,7 +27,7 @@ from .transcription import (
|
|||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
MOSS_ALGORITHM_VERSION = "moss-punctuation-v3"
|
MOSS_ALGORITHM_VERSION = "moss-punctuation-v4"
|
||||||
SILENCE_ALGORITHM_VERSION = "silence-rms-v1"
|
SILENCE_ALGORITHM_VERSION = "silence-rms-v1"
|
||||||
_CUES_ID = b"\x1e\x53\xb6\x80"
|
_CUES_ID = b"\x1e\x53\xb6\x80"
|
||||||
_CLUSTER_ID = b"\x1f\x43\xb6\x75"
|
_CLUSTER_ID = b"\x1f\x43\xb6\x75"
|
||||||
|
|||||||
@@ -244,7 +244,24 @@ class VideoRepository:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def mark_processing(self, video_hash: str) -> None:
|
def mark_processing(self, video_hash: str) -> None:
|
||||||
self._update_status(video_hash, "processing", None)
|
normalized_hash = normalize_video_hash(video_hash)
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM sentences WHERE video_hash = ?",
|
||||||
|
(normalized_hash,),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM moss_segments WHERE video_hash = ?",
|
||||||
|
(normalized_hash,),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE videos
|
||||||
|
SET status = ?, error_message = NULL, transcription = NULL, updated_at = ?
|
||||||
|
WHERE video_hash = ?
|
||||||
|
""",
|
||||||
|
("processing", utc_now(), normalized_hash),
|
||||||
|
)
|
||||||
|
|
||||||
def mark_failed(self, video_hash: str, message: str) -> None:
|
def mark_failed(self, video_hash: str, message: str) -> None:
|
||||||
self._update_status(video_hash, "failed", message[:4000])
|
self._update_status(video_hash, "failed", message[:4000])
|
||||||
|
|||||||
@@ -323,3 +323,61 @@ def test_reprocess_without_moss_clears_original_segments(tmp_path, monkeypatch):
|
|||||||
VideoProcessor(settings, repository, UnavailableTranscriber()).process(VIDEO_HASH)
|
VideoProcessor(settings, repository, UnavailableTranscriber()).process(VIDEO_HASH)
|
||||||
assert repository.get_moss_segments(VIDEO_HASH) == []
|
assert repository.get_moss_segments(VIDEO_HASH) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_processing_clears_previous_sentence_data(tmp_path):
|
||||||
|
repository = VideoRepository(tmp_path / "oral.sqlite3")
|
||||||
|
repository.upsert_upload(
|
||||||
|
video_hash=VIDEO_HASH,
|
||||||
|
title="MOSS 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=2400,
|
||||||
|
algorithm_version=MOSS_ALGORITHM_VERSION,
|
||||||
|
sentences=[
|
||||||
|
SentenceBoundary(
|
||||||
|
index=0,
|
||||||
|
start_ms=0,
|
||||||
|
end_ms=1200,
|
||||||
|
text="Old first.",
|
||||||
|
language="en",
|
||||||
|
reference_speech_duration_ms=1000,
|
||||||
|
),
|
||||||
|
SentenceBoundary(
|
||||||
|
index=1,
|
||||||
|
start_ms=1200,
|
||||||
|
end_ms=2400,
|
||||||
|
text="Old second.",
|
||||||
|
language="en",
|
||||||
|
reference_speech_duration_ms=1000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"Old first. Old second.",
|
||||||
|
moss_segments=[
|
||||||
|
{
|
||||||
|
"start_seconds": 0.0,
|
||||||
|
"end_seconds": 1.2,
|
||||||
|
"text": "Old first.",
|
||||||
|
"speaker": "S01",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.mark_processing(VIDEO_HASH)
|
||||||
|
|
||||||
|
video = repository.get_video(VIDEO_HASH)
|
||||||
|
document = repository.get_document(VIDEO_HASH)
|
||||||
|
assert video["status"] == "processing"
|
||||||
|
assert video["transcription"] is None
|
||||||
|
assert video["sentence_count"] == 0
|
||||||
|
assert document is not None
|
||||||
|
assert document.sentences == []
|
||||||
|
assert repository.get_moss_segments(VIDEO_HASH) == []
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,36 @@ def test_split_sentences_ignores_am_pm_abbreviations():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_sentences_ignores_clock_times_with_pm_period():
|
||||||
|
compact = TranscriptionSegment(
|
||||||
|
start_seconds=0.0,
|
||||||
|
end_seconds=6.0,
|
||||||
|
text="The briefing is at 3:00 PM. Eastern officials will attend later.",
|
||||||
|
)
|
||||||
|
assert [s.text for s in split_sentences_at_punctuation([compact])] == [
|
||||||
|
"The briefing is at 3:00 PM. Eastern officials will attend later."
|
||||||
|
]
|
||||||
|
|
||||||
|
spoken = TranscriptionSegment(
|
||||||
|
start_seconds=0.0,
|
||||||
|
end_seconds=6.0,
|
||||||
|
text="We start at 5 pm. Eastern time and then break.",
|
||||||
|
)
|
||||||
|
assert [s.text for s in split_sentences_at_punctuation([spoken])] == [
|
||||||
|
"We start at 5 pm. Eastern time and then break."
|
||||||
|
]
|
||||||
|
|
||||||
|
morning = TranscriptionSegment(
|
||||||
|
start_seconds=0.0,
|
||||||
|
end_seconds=6.0,
|
||||||
|
text="It begins at 9:30 AM. Please arrive early.",
|
||||||
|
)
|
||||||
|
assert [s.text for s in split_sentences_at_punctuation([morning])] == [
|
||||||
|
"It begins at 9:30 AM.",
|
||||||
|
"Please arrive early.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_split_sentences_merges_segment_ending_with_abbreviation():
|
def test_split_sentences_merges_segment_ending_with_abbreviation():
|
||||||
segments = [
|
segments = [
|
||||||
TranscriptionSegment(0.0, 5.0, "The meeting ends at 5 p.m."),
|
TranscriptionSegment(0.0, 5.0, "The meeting ends at 5 p.m."),
|
||||||
@@ -460,6 +490,77 @@ def test_split_sentences_ignores_common_abbreviations():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_sentences_merges_same_speaker_line_wraps():
|
||||||
|
segments = [
|
||||||
|
TranscriptionSegment(
|
||||||
|
11.18,
|
||||||
|
15.45,
|
||||||
|
"[S01]Let's begin with some scheduling announcements for the remainder of President Trump's very",
|
||||||
|
),
|
||||||
|
TranscriptionSegment(15.45, 16.29, "[S01]busy week."),
|
||||||
|
TranscriptionSegment(
|
||||||
|
16.29,
|
||||||
|
21.8,
|
||||||
|
"[S01]Today at 3:00 PM, President Trump will host a Black History Month reception in the East",
|
||||||
|
),
|
||||||
|
TranscriptionSegment(21.8, 23.29, "[S01]Room of the White House."),
|
||||||
|
]
|
||||||
|
sentences = split_sentences_at_punctuation(segments)
|
||||||
|
assert [s.text for s in sentences] == [
|
||||||
|
(
|
||||||
|
"Let's begin with some scheduling announcements for the remainder of "
|
||||||
|
"President Trump's very busy week."
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Today at 3:00 PM, President Trump will host a Black History Month "
|
||||||
|
"reception in the East Room of the White House."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
assert sentences[0].start_seconds == 11.18
|
||||||
|
assert sentences[0].end_seconds == 16.29
|
||||||
|
assert sentences[1].start_seconds == 16.29
|
||||||
|
assert sentences[1].end_seconds == 23.29
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_sentences_splits_when_speaker_changes():
|
||||||
|
segments = [
|
||||||
|
TranscriptionSegment(4.63, 5.93, "[S01]Hello, everybody."),
|
||||||
|
TranscriptionSegment(6.0, 6.53, "[S02]Hello."),
|
||||||
|
TranscriptionSegment(6.56, 7.83, "[S01]Good afternoon."),
|
||||||
|
]
|
||||||
|
sentences = split_sentences_at_punctuation(segments)
|
||||||
|
assert [s.text for s in sentences] == [
|
||||||
|
"Hello, everybody.",
|
||||||
|
"Hello.",
|
||||||
|
"Good afternoon.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_sentences_keeps_same_speaker_sentences_apart():
|
||||||
|
segments = [
|
||||||
|
TranscriptionSegment(6.56, 7.83, "[S01]Good afternoon."),
|
||||||
|
TranscriptionSegment(7.93, 9.86, "[S01]Thank you for your patience, as always."),
|
||||||
|
]
|
||||||
|
sentences = split_sentences_at_punctuation(segments)
|
||||||
|
assert [s.text for s in sentences] == [
|
||||||
|
"Good afternoon.",
|
||||||
|
"Thank you for your patience, as always.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_sentences_ignores_initials_and_version_numbers():
|
||||||
|
segment = TranscriptionSegment(
|
||||||
|
start_seconds=0.0,
|
||||||
|
end_seconds=8.0,
|
||||||
|
text="Donald J. Trump discussed version 1.9 today. Then the meeting ended.",
|
||||||
|
)
|
||||||
|
sentences = split_sentences_at_punctuation([segment])
|
||||||
|
assert [s.text for s in sentences] == [
|
||||||
|
"Donald J. Trump discussed version 1.9 today.",
|
||||||
|
"Then the meeting ended.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_post_audio_requests_word_timestamps_and_falls_back(monkeypatch):
|
def test_post_audio_requests_word_timestamps_and_falls_back(monkeypatch):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
|
|||||||
@@ -246,9 +246,18 @@ _PUNCTUATION_BOUNDARY = re.compile(r"[.?]+|,+")
|
|||||||
MIN_WORDS_FOR_COMMA_SPLIT = 50
|
MIN_WORDS_FOR_COMMA_SPLIT = 50
|
||||||
_ABBREVIATION_RE = re.compile(
|
_ABBREVIATION_RE = re.compile(
|
||||||
r"(?i)\b(?:a\.m\.|p\.m\.|mr\.|mrs\.|ms\.|dr\.|prof\.|st\.|vs\.|etc\.|"
|
r"(?i)\b(?:a\.m\.|p\.m\.|mr\.|mrs\.|ms\.|dr\.|prof\.|st\.|vs\.|etc\.|"
|
||||||
r"e\.g\.|i\.e\.|no\.|approx\.|fig\.|inc\.|ltd\.|jr\.|sr\.|u\.s\.|u\.k\.)"
|
r"e\.g\.|i\.e\.|no\.|approx\.|fig\.|inc\.|ltd\.|jr\.|sr\.|u\.s\.|u\.k\.|"
|
||||||
|
r"ph\.d\.|m\.d\.|b\.a\.|m\.a\.|d\.c\.|p\.s\.)"
|
||||||
r"(?![A-Za-z0-9])"
|
r"(?![A-Za-z0-9])"
|
||||||
)
|
)
|
||||||
|
_TIME_MERIDIEM_RE = re.compile(
|
||||||
|
r"(?i)\b(?:[01]?\d|2[0-3])(?::[0-5]\d)?\s*(?:a\.m\.|p\.m\.|am\.|pm\.)"
|
||||||
|
r"(?![A-Za-z0-9])"
|
||||||
|
)
|
||||||
|
_TIME_CONTINUATION_RE = re.compile(
|
||||||
|
r"(?i)^(eastern|western|central|pacific|mountain|time|"
|
||||||
|
r"et|est|edt|pt|pst|pdt|ct|cst|cdt|mt|mst|mdt|gmt|utc|local)\b"
|
||||||
|
)
|
||||||
_SPEAKER_TAG_RE = re.compile(r"\[(S\d+)\]")
|
_SPEAKER_TAG_RE = re.compile(r"\[(S\d+)\]")
|
||||||
|
|
||||||
|
|
||||||
@@ -319,8 +328,10 @@ def split_sentences_at_punctuation(
|
|||||||
are also eligible boundaries, but only when the text before the comma has
|
are also eligible boundaries, but only when the text before the comma has
|
||||||
enough words; otherwise the comma stays with the following text.
|
enough words; otherwise the comma stays with the following text.
|
||||||
|
|
||||||
Inline ``[S01]`` speaker tags are stripped and each speaker turn always
|
Inline ``[S01]`` speaker tags are stripped. A new sentence starts when the
|
||||||
starts a new sentence, even when no sentence-ending punctuation exists.
|
speaker actually changes; same-speaker line wraps stay merged until
|
||||||
|
sentence punctuation. Decimal points, initials, and common abbreviations
|
||||||
|
are not treated as sentence ends.
|
||||||
"""
|
"""
|
||||||
sentences: List[TranscriptionSegment] = []
|
sentences: List[TranscriptionSegment] = []
|
||||||
pieces: List[str] = []
|
pieces: List[str] = []
|
||||||
@@ -354,10 +365,14 @@ def split_sentences_at_punctuation(
|
|||||||
text = segment.text.strip()
|
text = segment.text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
# Each speaker-tagged sub-segment always starts a new sentence,
|
# MOSS often tags every phrase/line with [S01] even when the speaker
|
||||||
# regardless of whether the speaker actually changed — MOSS emits
|
# did not change. Only a real speaker change starts a new sentence.
|
||||||
# a tag per phrase/line even for the same speaker.
|
if (
|
||||||
if pieces and segment.speaker is not None:
|
pieces
|
||||||
|
and segment.speaker is not None
|
||||||
|
and current_speaker is not None
|
||||||
|
and segment.speaker != current_speaker
|
||||||
|
):
|
||||||
flush(segment.start_seconds)
|
flush(segment.start_seconds)
|
||||||
if not pieces:
|
if not pieces:
|
||||||
start_seconds = segment.start_seconds
|
start_seconds = segment.start_seconds
|
||||||
@@ -418,31 +433,70 @@ def _is_sentence_end_run(text: str, run_start: int, run_end: int) -> bool:
|
|||||||
punctuation = text[run_start:run_end]
|
punctuation = text[run_start:run_end]
|
||||||
if "?" in punctuation:
|
if "?" in punctuation:
|
||||||
return True
|
return True
|
||||||
if (
|
if _is_decimal_point(text, run_start, run_end):
|
||||||
run_start > 0
|
return False
|
||||||
and run_end < len(text)
|
|
||||||
and text[run_start - 1].isdigit()
|
|
||||||
and text[run_end].isdigit()
|
|
||||||
):
|
|
||||||
return False # decimal point, e.g. "0.4"
|
|
||||||
if len(punctuation) > 1:
|
if len(punctuation) > 1:
|
||||||
return True # ellipsis or "!?" etc.
|
return True # ellipsis or "!?" etc.
|
||||||
if _is_abbreviation_run(text, run_start, run_end):
|
if _is_abbreviation_run(text, run_start, run_end):
|
||||||
return False # e.g. "a.m.", "p.m.", "Mr."
|
return False # e.g. "a.m.", "p.m.", "Mr."
|
||||||
|
if _is_time_meridiem_run(text, run_start, run_end):
|
||||||
|
return False # e.g. "3:00 PM.", "5 pm."
|
||||||
|
if _is_single_letter_initial(text, run_start, run_end):
|
||||||
|
return False # e.g. "Donald J. Trump"
|
||||||
rest = text[run_end:].lstrip()
|
rest = text[run_end:].lstrip()
|
||||||
if rest and rest[0].islower():
|
if rest and rest[0].islower():
|
||||||
return False # period followed by a lowercase word is an abbreviation
|
return False # period followed by a lowercase word is an abbreviation
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_decimal_point(text: str, run_start: int, run_end: int) -> bool:
|
||||||
|
if run_end - run_start != 1 or text[run_start] != ".":
|
||||||
|
return False
|
||||||
|
if run_start == 0 or run_end >= len(text):
|
||||||
|
return False
|
||||||
|
return text[run_start - 1].isdigit() and text[run_end].isdigit()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_single_letter_initial(text: str, run_start: int, run_end: int) -> bool:
|
||||||
|
if run_end - run_start != 1 or text[run_start] != ".":
|
||||||
|
return False
|
||||||
|
if run_start == 0 or not text[run_start - 1].isalpha():
|
||||||
|
return False
|
||||||
|
return run_start == 1 or not text[run_start - 2].isalpha()
|
||||||
|
|
||||||
|
|
||||||
|
def _meridiem_continues(text: str, after: int) -> bool:
|
||||||
|
rest = text[after:].lstrip()
|
||||||
|
if not rest:
|
||||||
|
return True
|
||||||
|
if rest[0].islower():
|
||||||
|
return True
|
||||||
|
return _TIME_CONTINUATION_RE.match(rest) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_time_meridiem_run(text: str, run_start: int, run_end: int) -> bool:
|
||||||
|
for match in _TIME_MERIDIEM_RE.finditer(text):
|
||||||
|
if not (match.start() < run_end <= match.end()):
|
||||||
|
continue
|
||||||
|
if run_end < match.end():
|
||||||
|
return True
|
||||||
|
return _meridiem_continues(text, match.end())
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_abbreviation_run(text: str, run_start: int, run_end: int) -> bool:
|
def _is_abbreviation_run(text: str, run_start: int, run_end: int) -> bool:
|
||||||
for match in _ABBREVIATION_RE.finditer(text):
|
for match in _ABBREVIATION_RE.finditer(text):
|
||||||
if not (match.start() < run_end <= match.end()):
|
if not (match.start() < run_end <= match.end()):
|
||||||
continue
|
continue
|
||||||
if match.group(0).lower() == "no.":
|
token = match.group(0).lower()
|
||||||
|
if token == "no.":
|
||||||
rest = text[match.end():].lstrip()
|
rest = text[match.end():].lstrip()
|
||||||
if not rest or not rest[0].isdigit():
|
if not rest or not rest[0].isdigit():
|
||||||
return False # "No." as a reply ends the sentence
|
return False # "No." as a reply ends the sentence
|
||||||
|
if token in {"a.m.", "p.m."}:
|
||||||
|
if run_end < match.end():
|
||||||
|
return True
|
||||||
|
return _meridiem_continues(text, match.end())
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user