fix(tts): strip nested markup from extracted expressive tag values - #6668
fix(tts): strip nested markup from extracted expressive tag values#6668priyam-garg wants to merge 4 commits into
Conversation
`extract_and_strip` recorded a wrapping tag's value as its raw inner
content, so nesting leaked the inner tags' delimiters into the value:
<excited><loud>no way</loud></excited>
-> [("excited", "<loud>no way</loud>"), ("loud", "no way")]
`ExpressiveTag.value` is documented as "the tag's inner text" and is
surfaced to the frontend via ATTRIBUTE_TRANSCRIPTION_EXPRESSION, so the
markup reached consumers even though the transcript itself was clean.
Clean the inner text before recording it. The raw inner content is still
returned, so the fixed-point loop's next pass records the nested tags on
their own -- the tag list is unchanged, only the values are now text.
test_nested_emotion_prosody_strips_cleanly already promised "no leaked
inner markup" but only asserted on the transcript; it now checks the tags
too, and fails without this change.
Summary
>>> extract_and_strip("<excited><loud>no way</loud></excited>", xml_tags=["excited", "loud"])
('no way', [('excited', '<loud>no way</loud>'), ('loud', 'no way')])
# ^^^^^^^^^^^^^^^^^^^^ markup, not text
Nesting isn't exotic — combining an xAI emotion with a prosody wrapper produces exactly this: raw = '<excited><loud><higher-pitch>no way</higher-pitch></loud></excited> <sound value="laugh"/> okay'
clean, tags = split_all_markup(raw)
clean # 'no way okay' <- correct
tags # {'type': 'excited', 'value': '<loud><higher-pitch>no way</higher-pitch></loud>'}
# {'type': 'sound', 'value': 'laugh'}
# {'type': 'loud', 'value': '<higher-pitch>no way</higher-pitch>'}FixClean the inner text before recording it, in inner_text = extract_and_strip(inner, xml_tags=xml_tags)[0].strip() if inner else ""The raw Non-nested shapes are unaffected:
Tests
…but it discarded the tag list and asserted only on the transcript, so the leak sat in the blind spot. It now asserts on the tags too. Plus two focused cases in
All three fail on 537 passing across the markup, transcript, expressive-toggle, TTS-fallback and agent-session suites. |
Cleaning a wrapping tag's value by re-running extract_and_strip on its
inner content made the cost exponential in nesting depth, since every
level re-scanned everything below it from inside the fixed-point loop:
depth 8: 4.39 ms
depth 11: 15.98 ms
depth 14: 167.27 ms
A value only needs its delimiters deleted, not the full restructuring
pass, so one regex sub over the inner content is enough and needs no
fixed point. Depth 14 is now 0.35 ms and depth 20 is 0.56 ms.
The strip and delimiter patterns are also compiled once per tag set
rather than on every call.
A wrapping tag whose inner content has no markup characters needs no scan at all, and that is the common shape on the transcript hot path. Short- circuit to inner.strip() there, keeping the delimiter sub for the nested case: ~0.45us -> ~0.16us per wrapping tag.
|
Summary of where this landed after review, since the fix changed shape along the way. The bug (9d315cb). >>> extract_and_strip("<excited><loud>no way</loud></excited>", xml_tags=["excited", "loud"])
('no way', [('excited', '<loud>no way</loud>'), ('loud', 'no way')])The complexity fix (5a30ce1). My first attempt cleaned the value by recursing into The micro-optimization (7374da1). Copilot's point that plain inner text shouldn't pay for a scan still held on top of that — it's the common shape per streamed chunk. Short-circuited to Net effect on behaviour: the tag list is unchanged — same tags, same order, same count. Only the values became text. Every non-nested shape produces exactly what it did before. Verification. The three nested-value tests fail against Happy to squash these into one commit if you'd prefer the history flat. |
The assert guarded an implementation that never existed. The fixed-point loop only re-scans the shrinking remainder, so the pre-PR code handles the 20-deep input in ~0.2ms, not minutes -- the bound gave CI a timing check to flake on while asserting nothing about the fix. What actually regresses without the fix is the recorded value: an outer tag kept the whole nested chain verbatim. Keep that assertion, rename the test to what it checks, and correct the comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
extract_and_striprecorded a wrapping tag's value as its raw inner content, so nesting leaked the inner tags' delimiters into the value:ExpressiveTag.valueis documented as "the tag's inner text" and is surfaced to the frontend via ATTRIBUTE_TRANSCRIPTION_EXPRESSION, so the markup reached consumers even though the transcript itself was clean.Clean the inner text before recording it. The raw inner content is still returned, so the fixed-point loop's next pass records the nested tags on their own -- the tag list is unchanged, only the values are now text.
test_nested_emotion_prosody_strips_cleanly already promised "no leaked inner markup" but only asserted on the transcript; it now checks the tags too, and fails without this change.