Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 31 additions & 17 deletions src/typeagent/transcripts/transcript_ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ def extract_speaker_from_text(text: str) -> tuple[str | None, str]:
return None, text


# Opening voice tag, optionally with classes: <v Speaker> or <v.loud Speaker>.
# 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"<v(?:\.[^\s>.]+)*\s+([^\s>][^>]*)>", re.IGNORECASE)
# Any other cue markup: </v>, <i>, <b>, <c.class>, <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.
Expand All @@ -79,35 +91,37 @@ def parse_voice_tags(raw_text: str) -> list[tuple[str | None, str]]:
WebVTT voice tags can be in the format:
- <v Speaker>Text</v>
- <v Speaker>Text (no closing tag)
- <v.class Speaker>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
<i>, <b>, <c.class> 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 <v> 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 <v Speaker>Text</v> or <v Speaker>Text
# Captures speaker name and the text that follows until the next tag or end
voice_pattern = r"<v\s+([^>]+)>([^<]*(?:</v>)?)"

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 </v> tag if present
text = re.sub(r"</v>\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(
Expand Down
74 changes: 74 additions & 0 deletions tests/test_transcripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<v Alice>I <i>really</i> mean it</v>",
[("Alice", "I really mean it")],
id="inline_tag_inside_voice",
),
pytest.param(
"<v Ann>one</v><v Bob>two <c.code>three</c></v>",
[("Ann", "one"), ("Bob", "two three")],
id="class_span_in_second_voice",
),
pytest.param(
"I <i>really</i> mean it",
[(None, "I really mean it")],
id="markup_without_voice",
),
pytest.param(
"<v.loud Alice>Hi <b>there</b></v>",
[("Alice", "Hi there")],
id="voice_with_class",
),
pytest.param(
"Intro <v Bob>hello</v>",
[(None, "Intro"), ("Bob", "hello")],
id="text_before_voice",
),
pytest.param(
"<v Bob>Say <00:00:01.500>hi<00:00:02.000> now",
[("Bob", "Say hi now")],
id="timestamp_tags_no_closing_v",
),
pytest.param(
"<v Alice>Hello</v>\n<v Bob>Hi there</v>",
[("Alice", "Hello"), ("Bob", "Hi there")],
id="plain_voices",
),
pytest.param(
"<v Bob>a < b <i>c</i></v>",
[("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 = "<v." + "!." * 100
assert parse_voice_tags(raw_text) == [(None, raw_text)]


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"
"<v Alice>I <i>really</i> mean it</v>\n\n"
"00:00:04.000 --> 00:00:05.000\n"
"<v Bob>Use the <c.code>make</c> 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 = [
Expand Down
Loading