rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
7d021cf
·
1 Parent(s): 4c6215a

fix(voice): KI-066 — TTS normalizes ₹/L/Cr shorthand to spoken language

Browse files

User 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>

Files changed (1) hide show
  1. backend/voice_format.py +46 -0
backend/voice_format.py CHANGED
@@ -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)