diff --git a/src/typeagent/transcripts/transcript_ingest.py b/src/typeagent/transcripts/transcript_ingest.py index 35b43d14..e4c174d6 100644 --- a/src/typeagent/transcripts/transcript_ingest.py +++ b/src/typeagent/transcripts/transcript_ingest.py @@ -70,6 +70,18 @@ def extract_speaker_from_text(text: str) -> tuple[str | None, str]: return None, text +# Opening voice tag, optionally with classes: or . +# Class names exclude "." and the speaker starts with a non-space, so each +# input splits only one way (no exponential backtracking on a failed match). +_VOICE_TAG_PATTERN = re.compile(r".]+)*\s+([^\s>][^>]*)>", re.IGNORECASE) +# Any other cue markup: , , , , <00:00:01.500>, ... +_CUE_TAG_PATTERN = re.compile(r"<[^<>]*>") + + +def _strip_cue_tags(text: str) -> str: + return _CUE_TAG_PATTERN.sub("", text).strip() + + def parse_voice_tags(raw_text: str) -> list[tuple[str | None, str]]: """ Parse WebVTT voice tags from raw caption text. @@ -79,35 +91,37 @@ def parse_voice_tags(raw_text: str) -> list[tuple[str | None, str]]: WebVTT voice tags can be in the format: - Text - Text (no closing tag) + - Text Multiple voice tags can exist in a single caption, and this function extracts all of them. + Each segment runs until the next voice tag; other cue markup inside it (such as + , , or timestamp tags) is removed but its text is kept. Text + before the first voice tag becomes a segment with no speaker. Args: raw_text: Raw caption text that may contain tags Returns: - List of (speaker, text) tuples. If no voice tags found, returns [(None, raw_text)] + List of (speaker, text) tuples. If no voice tags found, returns + [(None, text)] with the cue markup removed. """ - # Pattern to match Text or Text - # Captures speaker name and the text that follows until the next tag or end - voice_pattern = r"]+)>([^<]*(?:)?)" - - matches = list(re.finditer(voice_pattern, raw_text, re.IGNORECASE)) + matches = list(_VOICE_TAG_PATTERN.finditer(raw_text)) if not matches: - # No voice tags found, return the text as-is with no speaker - return [(None, raw_text.strip())] - - results = [] - for match in matches: - speaker = match.group(1).strip() - text = match.group(2).strip() - # Remove closing tag if present - text = re.sub(r"\s*$", "", text, flags=re.IGNORECASE).strip() + # No voice tags found, return the text with no speaker + return [(None, _strip_cue_tags(raw_text))] + + results: list[tuple[str | None, str]] = [] + leading_text = _strip_cue_tags(raw_text[: matches[0].start()]) + if leading_text: + results.append((None, leading_text)) + for i, match in enumerate(matches): + segment_end = matches[i + 1].start() if i + 1 < len(matches) else None + text = _strip_cue_tags(raw_text[match.end() : segment_end]) if text: # Only add non-empty text - results.append((speaker, text)) + results.append((match.group(1).strip(), text)) - return results if results else [(None, raw_text.strip())] + return results if results else [(None, _strip_cue_tags(raw_text))] async def ingest_vtt_transcript( diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index 36ba1685..544d7cb9 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -60,6 +60,80 @@ def test_extract_speaker_from_text(): ), f"Text mismatch for '{input_text}': got {text}, expected {expected_text}" +@pytest.mark.parametrize( + ("raw_text", "expected"), + [ + pytest.param( + "I really mean it", + [("Alice", "I really mean it")], + id="inline_tag_inside_voice", + ), + pytest.param( + "onetwo three", + [("Ann", "one"), ("Bob", "two three")], + id="class_span_in_second_voice", + ), + pytest.param( + "I really mean it", + [(None, "I really mean it")], + id="markup_without_voice", + ), + pytest.param( + "Hi there", + [("Alice", "Hi there")], + id="voice_with_class", + ), + pytest.param( + "Intro hello", + [(None, "Intro"), ("Bob", "hello")], + id="text_before_voice", + ), + pytest.param( + "Say <00:00:01.500>hi<00:00:02.000> now", + [("Bob", "Say hi now")], + id="timestamp_tags_no_closing_v", + ), + pytest.param( + "Hello\nHi there", + [("Alice", "Hello"), ("Bob", "Hi there")], + id="plain_voices", + ), + pytest.param( + "a < b c", + [("Bob", "a < b c")], + id="stray_less_than_kept", + ), + ], +) +def test_parse_voice_tags_keeps_text_and_strips_markup( + raw_text: str, expected: list[tuple[str | None, str]] +) -> None: + assert parse_voice_tags(raw_text) == expected + + +def test_parse_voice_tags_unclosed_class_list_is_not_exponential() -> None: + # Backtracks exponentially with an ambiguous class pattern (CodeQL alert). + raw_text = " None: + vtt_path = os.path.join(str(tmp_path), "inline.vtt") + with open(vtt_path, "w") as f: + f.write( + "WEBVTT\n\n" + "00:00:01.000 --> 00:00:03.000\n" + "I really mean it\n\n" + "00:00:04.000 --> 00:00:05.000\n" + "Use the make command\n" + ) + segments = [parse_voice_tags(c.raw_text) for c in webvtt.read(vtt_path)] + assert segments == [ + [("Alice", "I really mean it")], + [("Bob", "Use the make command")], + ] + + def test_webvtt_timestamp_conversion(): """Test conversion of WebVTT timestamps to seconds.""" test_cases = [