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>
tinalenguyen
left a comment
There was a problem hiding this comment.
hi, thanks for the PR! this makes sense to me, though have you seen nested tags from the LLM? the prompts advise against doing so, so i'd be surprised if this is the case. this would be a good safeguard though
|
Good question — I hadn't observed it in live traffic either, and I don't have telemetry to say how often it happens, so "safeguard" is fair. But it's not purely hypothetical, for three reasons: The nesting case is already in the repo, from #6116. test_nested_emotion_prosody_strips_cleanly arrived with expressive mode itself, and its comment says "combining emotion + prosody means nesting; the transcript must come out clean (no leaked inner markup) — this is what the fixed-point strip guarantees." The fixed-point loop exists to handle nesting. That test asserted the promise on clean but never on tags, so the values kept the raw delimiters. This PR just closes that half — the test now checks both and fails without the change. "Never nest" is narrower than it reads. _provider_format.py:375 says "Never nest one prosody marker inside another," and :452 scopes the same rule to emphasis. Neither forbids an emotion wrapper around a prosody marker — which is exactly …. For xAI it's structural: emotion and prosody are both wrapping tags in the native dialect, so combining them can only be expressed by nesting. The strip set already assumes the LLM ignores instructions. test_emotion_tags_stripped_though_unprompted keeps _XAI_EMOTIONS in _XAI_TAGS with the note "emotion tags are no longer instructed, but stay in _XAI_TAGS so a stray one is stripped from the transcript rather than leaking to the user." Same reasoning applies here — prompt guidance reduces the rate, it doesn't make it zero. Impact when it does happen is user-visible rather than cosmetic: the value goes to the frontend via ATTRIBUTE_TRANSCRIPTION_EXPRESSION (lk.expression), so a consumer reading it got no way instead of no way. The cost is one cached regex and a .sub() that only runs when the inner text actually contains < or >. |
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.