Spaces:
Sleeping
fix(voice): KI-066 — TTS normalizes ₹/L/Cr shorthand to spoken language
Browse filesUser report (with screenshot of fact-find Q): TTS is reading "₹5L, ₹5-10L,
₹10-25L, or ₹25L+" as "two five L plus" — letter-by-letter — instead of
spoken-language "5 lakhs, 5 to 10 lakhs, 10 to 25 lakhs, or 25 lakhs or
more". The robotic delivery breaks the voice UX of the entire fact-find
flow.
Root cause: backend/voice_format.py was already stripping markdown and
expanding a few BFSI acronyms (PED, OPD, NCB...) but had no handling for
the rupee/lakh/crore shorthand that's everywhere in this domain.
Fix: new `_normalize_money(text)` step in tts_preprocess. Order-sensitive
regex chain — ranges first ("5-10L" → "5 to 10 lakhs"), then plus-suffix
("25L+" → "25 lakhs or more"), then bare units, then "Rs."/"₹" prefix
replacement.
Patterns now spoken correctly (verified 6/6 inline cases):
₹5L → "5 lakhs"
₹5-10L → "5 to 10 lakhs"
₹25L+ → "25 lakhs or more"
₹1Cr / ₹2Cr+ → "1 crores" / "2 crores or more"
Rs. 50,000 → "rupees 50,000"
₹15,000 → "rupees 15,000"
Money normalization runs AFTER markdown strip and BEFORE acronym expansion
so the bare "L" doesn't get caught by a future acronym rule.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/voice_format.py +46 -0
|
@@ -62,6 +62,48 @@ ACRONYMS = {
|
|
| 62 |
r"\bpp\.(\d+)": r"page \1",
|
| 63 |
}
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
# Strip section labels that LLMs love but ruin voice flow.
|
| 66 |
# Require the trailing colon so we only catch actual labels, not normal prose
|
| 67 |
# that happens to start with "Coverage applies..." etc.
|
|
@@ -124,6 +166,10 @@ def tts_preprocess(text: str, language: str = "en", max_words: int = 60) -> str:
|
|
| 124 |
if not text:
|
| 125 |
return ""
|
| 126 |
cleaned = _strip_markdown(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
cleaned = _expand_acronyms(cleaned, language=language)
|
| 128 |
cleaned = _compress_whitespace(cleaned)
|
| 129 |
cleaned = _truncate_for_voice(cleaned, max_words=max_words)
|
|
|
|
| 62 |
r"\bpp\.(\d+)": r"page \1",
|
| 63 |
}
|
| 64 |
|
| 65 |
+
# KI-066 (2026-05-15) — money / range shorthand that Sarvam Bulbul reads
|
| 66 |
+
# letter-by-letter. The user said "₹25L+" was being spoken as "two five L
|
| 67 |
+
# plus". Order in `_normalize_money` matters: handle RANGES first, then
|
| 68 |
+
# PLUS-SUFFIXES, then bare unit-suffixes, then standalone "+".
|
| 69 |
+
_MONEY_RANGE_L = re.compile(
|
| 70 |
+
r"₹?\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*L\b",
|
| 71 |
+
re.IGNORECASE,
|
| 72 |
+
)
|
| 73 |
+
_MONEY_RANGE_CR = re.compile(
|
| 74 |
+
r"₹?\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*Cr\b",
|
| 75 |
+
re.IGNORECASE,
|
| 76 |
+
)
|
| 77 |
+
_MONEY_PLUS_L = re.compile(r"₹?\s*(\d+(?:\.\d+)?)\s*L\s*\+", re.IGNORECASE)
|
| 78 |
+
_MONEY_PLUS_CR = re.compile(r"₹?\s*(\d+(?:\.\d+)?)\s*Cr\s*\+", re.IGNORECASE)
|
| 79 |
+
_MONEY_L = re.compile(r"₹?\s*(\d+(?:\.\d+)?)\s*L\b", re.IGNORECASE)
|
| 80 |
+
_MONEY_CR = re.compile(r"₹?\s*(\d+(?:\.\d+)?)\s*Cr\b", re.IGNORECASE)
|
| 81 |
+
_MONEY_RS_PREFIX = re.compile(r"\bRs\.?\s*", re.IGNORECASE)
|
| 82 |
+
_MONEY_RUPEE_SYMBOL = re.compile(r"₹\s*(\d)")
|
| 83 |
+
# Bare year ranges like "29-32" or "24/7" — leave alone; TTS handles dashes.
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _normalize_money(text: str) -> str:
|
| 87 |
+
"""Turn currency / range shorthand into spoken-language equivalents.
|
| 88 |
+
|
| 89 |
+
Examples:
|
| 90 |
+
"₹5L" → "5 lakhs"
|
| 91 |
+
"₹25L+" → "25 lakhs or more"
|
| 92 |
+
"₹5-10L" → "5 to 10 lakhs"
|
| 93 |
+
"₹2Cr" → "2 crores"
|
| 94 |
+
"Rs. 5000" → "rupees 5000"
|
| 95 |
+
"""
|
| 96 |
+
text = _MONEY_RANGE_L.sub(lambda m: f"{m.group(1)} to {m.group(2)} lakhs", text)
|
| 97 |
+
text = _MONEY_RANGE_CR.sub(lambda m: f"{m.group(1)} to {m.group(2)} crores", text)
|
| 98 |
+
text = _MONEY_PLUS_L.sub(lambda m: f"{m.group(1)} lakhs or more", text)
|
| 99 |
+
text = _MONEY_PLUS_CR.sub(lambda m: f"{m.group(1)} crores or more", text)
|
| 100 |
+
text = _MONEY_L.sub(lambda m: f"{m.group(1)} lakhs", text)
|
| 101 |
+
text = _MONEY_CR.sub(lambda m: f"{m.group(1)} crores", text)
|
| 102 |
+
text = _MONEY_RS_PREFIX.sub("rupees ", text)
|
| 103 |
+
text = _MONEY_RUPEE_SYMBOL.sub(r"rupees \1", text)
|
| 104 |
+
return text
|
| 105 |
+
|
| 106 |
+
|
| 107 |
# Strip section labels that LLMs love but ruin voice flow.
|
| 108 |
# Require the trailing colon so we only catch actual labels, not normal prose
|
| 109 |
# that happens to start with "Coverage applies..." etc.
|
|
|
|
| 166 |
if not text:
|
| 167 |
return ""
|
| 168 |
cleaned = _strip_markdown(text)
|
| 169 |
+
# KI-066 (2026-05-15) — currency/range shorthand expansion before
|
| 170 |
+
# acronym handling so ₹5L becomes "5 lakhs" instead of getting caught
|
| 171 |
+
# by the bare-L acronym path.
|
| 172 |
+
cleaned = _normalize_money(cleaned)
|
| 173 |
cleaned = _expand_acronyms(cleaned, language=language)
|
| 174 |
cleaned = _compress_whitespace(cleaned)
|
| 175 |
cleaned = _truncate_for_voice(cleaned, max_words=max_words)
|