From d2261629f9fc369540a23fc15a6b8e23d0389c95 Mon Sep 17 00:00:00 2001 From: Kevin Turcios Date: Sun, 18 Oct 2026 06:31:07 -0500 Subject: [PATCH 1/3] fix: keep WebVTT cue text around inline markup parse_voice_tags captured each voice segment only up to the first "<", so inline cue markup truncated the text: "I really mean it" was ingested as "I". Cues without a voice tag kept their markup verbatim, voice tags with classes ("") were not recognized, and text before the first voice tag was dropped. Split segments at voice tags and strip the remaining cue markup while keeping its text. Co-authored-by: wtfyd-agent[bot] <328989958+wtfyd-agent[bot]@users.noreply.github.com> --- .../transcripts/transcript_ingest.py | 46 +++++++++----- tests/test_transcripts.py | 63 +++++++++++++++++++ 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/typeagent/transcripts/transcript_ingest.py b/src/typeagent/transcripts/transcript_ingest.py index 35b43d14..c3e6c2d8 100644 --- a/src/typeagent/transcripts/transcript_ingest.py +++ b/src/typeagent/transcripts/transcript_ingest.py @@ -70,6 +70,16 @@ def extract_speaker_from_text(text: str) -> tuple[str | None, str]: return None, text +# Opening voice tag, optionally with classes: or +_VOICE_TAG_PATTERN = re.compile(r"]+)*\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 +89,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..ad88b6b2 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -60,6 +60,69 @@ 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", + ), + ], +) +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_on_webvtt_raw_text(tmp_path: os.PathLike[str]) -> 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 = [ From 80854c0c2d229ee8cbe98b407fbbfc0962f37eb5 Mon Sep 17 00:00:00 2001 From: Kevin Turcios Date: Sun, 18 Oct 2026 07:16:58 -0500 Subject: [PATCH 2/3] fix: avoid exponential backtracking in the voice-tag regex The class part of the voice-tag pattern also matched ".", so an unclosed " --- src/typeagent/transcripts/transcript_ingest.py | 6 ++++-- tests/test_transcripts.py | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/typeagent/transcripts/transcript_ingest.py b/src/typeagent/transcripts/transcript_ingest.py index c3e6c2d8..b4beb401 100644 --- a/src/typeagent/transcripts/transcript_ingest.py +++ b/src/typeagent/transcripts/transcript_ingest.py @@ -70,8 +70,10 @@ def extract_speaker_from_text(text: str) -> tuple[str | None, str]: return None, text -# Opening voice tag, optionally with classes: or -_VOICE_TAG_PATTERN = re.compile(r"]+)*\s+([^>]+)>", re.IGNORECASE) +# 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"<[^>]*>") diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index ad88b6b2..baeccccc 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -106,6 +106,12 @@ def test_parse_voice_tags_keeps_text_and_strips_markup( 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: From 7885b381bc7f0bc7f8c45ad82652eb27f8fe7d03 Mon Sep 17 00:00:00 2001 From: Kevin Turcios Date: Sun, 18 Oct 2026 16:51:39 -0500 Subject: [PATCH 3/3] fix: strip cue tags in linear time without eating stray '<' --- src/typeagent/transcripts/transcript_ingest.py | 2 +- tests/test_transcripts.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/typeagent/transcripts/transcript_ingest.py b/src/typeagent/transcripts/transcript_ingest.py index b4beb401..e4c174d6 100644 --- a/src/typeagent/transcripts/transcript_ingest.py +++ b/src/typeagent/transcripts/transcript_ingest.py @@ -75,7 +75,7 @@ def extract_speaker_from_text(text: str) -> tuple[str | None, str]: # 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"<[^>]*>") +_CUE_TAG_PATTERN = re.compile(r"<[^<>]*>") def _strip_cue_tags(text: str) -> str: diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index baeccccc..544d7cb9 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -98,6 +98,11 @@ def test_extract_speaker_from_text(): [("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(