busy-spoon-35253
04/01/2026, 10:01 AMtall-airline-16756
04/01/2026, 10:01 AMtall-airline-16756
04/01/2026, 10:02 AMlk.transcript and how to get the spoken output to match. You can fix the transcript text and also make the spoken output match — here’s how to approach both, and a short example you can adapt.
What you can do right now
• Yes — modify what gets published to lk.transcription by post-processing the transcription stream. The recommended place to do this is the agent’s transcription_node hook, which can transform interim/final text before it’s forwarded to the frontend.
• For email-like spoken patterns, use logic like the prebuilt GetEmailTask or equivalent normalization rules to convert phrases like “at” → @ and “dot” → ..
• For numeric amounts, convert spoken numbers to numeric form (libraries or simple pattern replacements) and then format as currency (e.g., add $ and commas).
A minimal example (Python-style) to illustrate using transcription_node
1. Intercept transcription deltas.
2. Run simple replacements and a numeric parse step.
3. Yield the cleaned text back to the pipeline.
async def transcription_node(self, text: AsyncIterable[str], model_settings: ModelSettings) -> AsyncIterable[str]:
async for delta in text:
# quick email normalization
delta = delta.replace(" at ", "@").replace(" dot ", ".")
# quick currency fixes (example: naive replacement)
delta = delta.replace("nine hundred and fifty dollar", "$950")
# For more robust numeric handling, call a helper that parses words -> number
# e.g., delta = normalize_numbers_and_currency(delta)
yield delta
Notes and recommended improvements
• Simple string replacements are okay for predictable phrases, but for general robustness use:
◦ An email-normalization routine that handles variations (e.g., “underscore”, spelled letters).
◦ A numbers-to-numeric parser (there are libraries that convert English words to numbers) and then format as currency.
• The GetEmailTask already performs email-pattern normalization if you use it to collect an email in a form-like flow. You can reuse that logic or mirror it in transcription_node.
• Fixing only lk.transcription changes the transcript the user sees. It does not change the spoken audio that the agent plays back.
◦ To have the spoken response read aloud as “$950” rather than “nine hundred and fifty dollars,” you need to ensure the text given to your TTS or LLM is already normalized (either by having your LLM generate the formatted form or by running normalization before TTS).
◦ If your TTS supports text normalization options, enable those so the TTS pronounces the formatted output correctly.
Questions to help me give a precise solution
• Are you already using an agent transcription_node hook, or do you need a complete example integrated into your project structure?
• Is your TTS produced from an LLM output (so we could normalize at the LLM prompt), or are you directly sending transcript text into the TTS engine?
• Do you want a robust/general normalization (covers many number formats, spelled letters, punctuation) or a few specific fixed replacements?
If you’d like, I can:
• Provide a more complete transcription_node implementation that uses a numbers-to-int library plus regex-based email normalization.
• Show how to wire this into the LLM prompt or TTS input so spoken output matches the cleaned transcript.
Which of those would help you most?
Sources: Text and transcriptions | LiveKit Documentation | Pipeline nodes and hooks | LiveKit Documentation | GetEmailTask | LiveKit Documentation | Redacting PII from agent logs and transcripts | LiveKit | Speechify TTS plugin guide | LiveKit Documentation, with interactive elementsbusy-spoon-35253
04/01/2026, 10:14 AM"""Helpers for formatting LiveKit transcript text before client delivery."""
import re
import phonenumbers
PHONE_DIGIT_WORD_PATTERN = r"(?:zero|oh|one|two|three|four|five|six|seven|eight|nine)"
SPOKEN_EMAIL_DOMAIN_PATTERN = r"(?:gmail|yahoo|outlook|hotmail|icloud|me|live|msn|aol|protonmail|gmx)"
SPOKEN_PHONE_REGEX = re.compile(
rf"\b{PHONE_DIGIT_WORD_PATTERN}(?:[\s,-]+{PHONE_DIGIT_WORD_PATTERN}){{9,10}}\b",
re.IGNORECASE,
)
SPOKEN_EMAIL_REGEX = re.compile(
rf"\b(?P<local>[a-z0-9][a-z0-9._%+-]*)\s+at\s+(?P<domain>{SPOKEN_EMAIL_DOMAIN_PATTERN})\s+dot\s+com\b",
re.IGNORECASE,
)
SPOKEN_CURRENCY_REGEX = re.compile(
r"\b(?P<amount>\d{1,3}(?:,\d{3})*|\d+)\s+(?P<currency>dollars|dollar|usd|euros|euro|eur|rupees|rupee|inr)\b",
re.IGNORECASE,
)
NUMBER_WORD_PATTERN = (
r"(?:zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|"
r"fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|"
r"sixty|seventy|eighty|ninety|hundred|thousand|million|billion|lakh|crore|and)"
)
SPOKEN_NUMBER_CURRENCY_REGEX = re.compile(
rf"\b(?P<amount>{NUMBER_WORD_PATTERN}(?:[\s-]+{NUMBER_WORD_PATTERN})*)\s+"
r"(?P<currency>dollars|dollar|usd|euros|euro|eur|rupees|rupee|inr)\b",
re.IGNORECASE,
)
SPOKEN_DIGIT_MAP = {
"zero": "0",
"oh": "0",
"one": "1",
"two": "2",
"three": "3",
"four": "4",
"five": "5",
"six": "6",
"seven": "7",
"eight": "8",
"nine": "9",
}
SMALL_NUMBER_WORDS = {
"zero": 0,
"one": 1,
"two": 2,
"three": 3,
"four": 4,
"five": 5,
"six": 6,
"seven": 7,
"eight": 8,
"nine": 9,
"ten": 10,
"eleven": 11,
"twelve": 12,
"thirteen": 13,
"fourteen": 14,
"fifteen": 15,
"sixteen": 16,
"seventeen": 17,
"eighteen": 18,
"nineteen": 19,
"twenty": 20,
"thirty": 30,
"forty": 40,
"fifty": 50,
"sixty": 60,
"seventy": 70,
"eighty": 80,
"ninety": 90,
}
SCALE_WORDS = {
"hundred": 100,
"thousand": 1_000,
"million": 1_000_000,
"billion": 1_000_000_000,
"lakh": 100_000,
"crore": 10_000_000,
}
TRANSCRIPT_CURRENCY_MAP = {
"dollar": "$",
"dollars": "$",
"usd": "$",
"euro": "€",
"euros": "€",
"eur": "€",
"rupee": "₹",
"rupees": "₹",
"inr": "₹",
}
NON_EMAIL_LOCAL_PARTS = {"me", "you", "us", "him", "her", "them", "it"}
def format_transcript_text(text: str) -> str:
"""Normalize transcript display text before publishing it to the client."""
text = _format_spoken_emails(text)
text = _format_spoken_phone_numbers(text)
text = _format_spoken_currency_amounts(text)
return _format_numeric_currency_amounts(text)
def _format_spoken_emails(text: str) -> str:
def _replace(match: re.Match[str]) -> str:
local = match.group("local").lower()
if local in NON_EMAIL_LOCAL_PARTS:
return match.group(0)
domain = match.group("domain").lower()
return f"{local}@{domain}.com"
return SPOKEN_EMAIL_REGEX.sub(_replace, text)
def _format_spoken_phone_numbers(text: str) -> str:
def _replace(match: re.Match[str]) -> str:
words = re.findall(PHONE_DIGIT_WORD_PATTERN, match.group(0), re.IGNORECASE)
digits = "".join(SPOKEN_DIGIT_MAP[word.lower()] for word in words)
if len(digits) == 10:
parse_input = digits
parse_region = "US" # NANP default fits current US/Canada traffic.
elif len(digits) == 11 and digits.startswith("1"):
parse_input = f"+{digits}"
parse_region = None
else:
return match.group(0)
try:
parsed = phonenumbers.parse(parse_input, parse_region)
except phonenumbers.NumberParseException:
return match.group(0)
if not _is_supported_nanp_number(parsed):
return match.group(0)
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.NATIONAL)
return SPOKEN_PHONE_REGEX.sub(_replace, text)
def _is_supported_nanp_number(parsed: phonenumbers.PhoneNumber) -> bool:
"""Validate numbers against the current NANP footprint we support in transcripts."""
return phonenumbers.is_valid_number_for_region(parsed, "US") or phonenumbers.is_valid_number_for_region(
parsed, "CA"
)
def _format_spoken_currency_amounts(text: str) -> str:
def _replace(match: re.Match[str]) -> str:
amount = _parse_spoken_integer(match.group("amount"))
if amount is None:
return match.group(0)
currency_key = match.group("currency").lower()
prefix = TRANSCRIPT_CURRENCY_MAP.get(currency_key)
if prefix is None:
return match.group(0)
return f"{prefix}{amount:,}"
return SPOKEN_NUMBER_CURRENCY_REGEX.sub(_replace, text)
def _format_numeric_currency_amounts(text: str) -> str:
def _replace(match: re.Match[str]) -> str:
raw_amount = match.group("amount").replace(",", "")
try:
amount = int(raw_amount)
except ValueError:
return match.group(0)
currency_key = match.group("currency").lower()
prefix = TRANSCRIPT_CURRENCY_MAP.get(currency_key)
if prefix is None:
return match.group(0)
return f"{prefix}{amount:,}"
return SPOKEN_CURRENCY_REGEX.sub(_replace, text)
def _parse_spoken_integer(amount_text: str) -> int | None:
parts = amount_text.lower().replace("-", " ").split()
if not parts:
return None
total = 0
current = 0
for part in parts:
if part == "and":
continue
if part in SMALL_NUMBER_WORDS:
current += SMALL_NUMBER_WORDS[part]
continue
scale = SCALE_WORDS.get(part)
if scale is None:
return None
if scale == 100:
current = max(current, 1) * scale
continue
total += max(current, 1) * scale
current = 0
return total + currenttall-airline-16756
04/01/2026, 10:15 AMtranscription_node. Below I’ll confirm what it already covers, point out a few edge-cases and improvements that will make it more robust, and give a couple of integration notes so spoken audio, LLM output, and the transcript stay consistent.
What your helper already does well
• Covers common spoken-email patterns like “local at domain dot com” and converts to local@domain.com while protecting obvious non-email words via NON_EMAIL_LOCAL_PARTS.
• Handles spoken digit phone patterns (e.g., “one two three ...”) and uses phonenumbers to validate/format NANP numbers.
• Detects numeric currency (e.g., “950 dollars”) and formats with thousands separators and a currency symbol.
• Converts spoken number words (e.g., “nine hundred and fifty dollars”) to an integer with _parse_spoken_integer and maps currency words to symbols.
• Keeps transforms isolated and composable by splitting responsibilities into focused functions.
Potential issues and recommended improvements
• Spoken email coverage
◦ Current domain regex only matches a small list of common providers and forces .com. Real users may say other domains or explicit TLDs (e.g., “dot org”, “dot co dot uk”), or spell letters in the local part (“x y z” vs “xyz”). Consider:
▪︎ Expanding domain/TLD support (allow dot <tld> or capture dot <tld> groups).
▪︎ Handling spelled-out local parts: map spelled letters to characters (e.g., “x y z” or “ex why zed”).
▪︎ Allow optional subdomains and multi-part TLDs (e.g., .co.uk).
• Email false positives
◦ Your use of \b(?P<local>[a-z0-9][a-z0-9._%+-]*)\s+at\s+(?P<domain>...) is good, but be mindful of cases like “send that to me at work” — your NON_EMAIL_LOCAL_PARTS helps, but you may still get false matches for other words. Consider requiring a word count minimum for local part (e.g., at least 2+ characters) or validating the local part structure more strictly.
• Spoken currency numeric parser limits
◦ _parse_spoken_integer handles hundreds/thousands/millions and scales like lakh/crore. It returns None if it sees unknown words, which is safe but may miss phrases like “nine hundred fifty” (your code handles this) or decimals (“ninety point five”) — decimals and cents aren’t handled yet.
◦ Replace or augment with a tested words-to-number library if you expect a wide variety of phrasing (e.g., “one thousand and twenty-five”, “twelve hundred”, “point fifty”, “two hundred twenty-five thousand”).
• Ambiguities around “dollar”/“USD” vs other currencies
◦ You map currency words to symbols — that's fine. Consider allowing currency symbol inference for phrases like “pounds” or spelled ISO codes (GBP) if you expect them.
• Numeric currency regex limitation
◦ SPOKEN_CURRENCY_REGEX currently requires a numeric digits sequence (e.g., 950 dollars) but not something like $950 already present. That’s fine because _format_numeric_currency_amounts handles digits. However, ensure the order of transforms will not double-format: you run spoken number → numeric → numeric regex; the order you chose is okay (spoken first, then numeric).
• Phone handling
◦ Your PHONE_DIGIT_WORD_PATTERN covers spelled single digits and “oh” — good for many use-cases. If callers sometimes spell with letters or say area code separately, you may need to accept parentheses, pauses, or mixed numeric/digit-word forms.
• Performance & streaming considerations
◦ If you run this on interim transcriptions, regexes and the words-to-number parse might run many times. Keep the implementation efficient or only apply heavy parsing to final results (use lk.transcription_final flag when available).
• Internationalization
◦ If you have users outside US/CA you’ll need to extend phone region handling and currency words/scale words for other languages/variants.
Small bug / edge-case checks
• _parse_spoken_integer uses max(current, 1) for scales — that’s a common pattern and works for “hundred” and larger scales. But test cases like “one hundred and five” and “hundred five” should both produce 105; ensure your test vectors cover those.
• SPOKEN_PHONE_REGEX requires 9–10 repetitions of PHONE_DIGIT_WORD_PATTERN (so total 10–11 digits?) — double-check the count matches your expected digit lengths. It looks designed to match sequences of 10 or 11 spoken digits; confirm this aligns with your traffic.
• SPOKEN_EMAIL_REGEX lowercases the local part; some local parts are case-sensitive by spec (rare). Lowercasing is usually fine for display, but be aware if you hand emails back into systems that expect exact case.
Integration tips for spoken output parity
• Fixing lk.transcription only affects what users see. To make the agent speak “$950” instead of “nine hundred and fifty dollars”:
◦ Normalize the LLM output or the text you send to your TTS engine the same way you normalize transcripts (call your helper on the text going to TTS).
◦ Alternatively, adjust LLM prompts to prefer numeric/currency/symbol formats when generating responses.
◦ If your TTS has text-normalization options, enable them so it pronounces formatted text as expected (some TTS will read $950 as “nine hundred and fifty dollars”—test to confirm).
• Apply heavy normalization only on final transcript chunks to avoid confusing interim displays.
Suggested next steps / quick checklist
• Add tests for:
◦ “nine hundred and fifty dollars” → $950
◦ “nine hundred fifty dollars” → $950
◦ “xyz at gmail dot com” → xyz@gmail.com
◦ “x y z at gmail dot com” → xyz@gmail.com (if you want to support spelled letters)
◦ Phone examples: “one two three four five six seven eight nine zero” → formatted number
• Consider integrating a words-to-number library if you see many edge cases.
• Run normalization both on transcript output and on text sent to TTS/LLM so display and audio match.
If you’d like, I can:
• Provide an updated _format_spoken_emails implementation that supports multi-part TLDs and spelled local parts.
• Add decimal/cents handling to _parse_spoken_integer (and return floats where appropriate).
• Suggest a lightweight words-to-number library and show how to plug it in.
Which of those enhancements would you like me to draft next?
, with interactive elementsbusy-spoon-35253
04/01/2026, 10:19 AM"""Helpers for formatting LiveKit transcript text before client delivery."""
import re
import phonenumbers
PHONE_DIGIT_WORD_PATTERN = r"(?:zero|oh|one|two|three|four|five|six|seven|eight|nine)"
SPOKEN_EMAIL_DOMAIN_PATTERN = r"(?:gmail|yahoo|outlook|hotmail|icloud|me|live|msn|aol|protonmail|gmx)"
SPOKEN_EMAIL_TLD_PATTERN = r"(?:com|org|net)"
SPOKEN_EMAIL_MULTI_TLD_PATTERN = r"(?:co\s+dot\s+uk)"
SPOKEN_PHONE_REGEX = re.compile(
rf"\b{PHONE_DIGIT_WORD_PATTERN}(?:[\s,-]+{PHONE_DIGIT_WORD_PATTERN}){{9,10}}\b",
re.IGNORECASE,
)
SPOKEN_EMAIL_REGEX = re.compile(
rf"\b(?P<local>(?:[a-z0-9._%+-]+|(?:[a-z]\s+){{1,10}}[a-z]))\s+at\s+"
rf"(?P<domain>{SPOKEN_EMAIL_DOMAIN_PATTERN})\s+dot\s+"
rf"(?P<tld>{SPOKEN_EMAIL_TLD_PATTERN}|{SPOKEN_EMAIL_MULTI_TLD_PATTERN})\b",
re.IGNORECASE,
)
SPOKEN_CURRENCY_REGEX = re.compile(
r"\b(?P<amount>\d{1,3}(?:,\d{3})*(?:\.\d+)?|\d+(?:\.\d+)?)\s+"
r"(?P<currency>dollars|dollar|usd|euros|euro|eur|rupees|rupee|inr)\b",
re.IGNORECASE,
)
NUMBER_WORD_PATTERN = (
r"(?:zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|"
r"fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|"
r"sixty|seventy|eighty|ninety|hundred|thousand|million|billion|lakh|crore|and|point)"
)
SPOKEN_NUMBER_CURRENCY_REGEX = re.compile(
rf"\b(?P<amount>{NUMBER_WORD_PATTERN}(?:[\s-]+{NUMBER_WORD_PATTERN})*)\s+"
r"(?P<currency>dollars|dollar|usd|euros|euro|eur|rupees|rupee|inr)\b",
re.IGNORECASE,
)
SPOKEN_DIGIT_MAP = {
"zero": "0",
"oh": "0",
"one": "1",
"two": "2",
"three": "3",
"four": "4",
"five": "5",
"six": "6",
"seven": "7",
"eight": "8",
"nine": "9",
}
SMALL_NUMBER_WORDS = {
"zero": 0,
"one": 1,
"two": 2,
"three": 3,
"four": 4,
"five": 5,
"six": 6,
"seven": 7,
"eight": 8,
"nine": 9,
"ten": 10,
"eleven": 11,
"twelve": 12,
"thirteen": 13,
"fourteen": 14,
"fifteen": 15,
"sixteen": 16,
"seventeen": 17,
"eighteen": 18,
"nineteen": 19,
"twenty": 20,
"thirty": 30,
"forty": 40,
"fifty": 50,
"sixty": 60,
"seventy": 70,
"eighty": 80,
"ninety": 90,
}
DECIMAL_DIGIT_WORDS = {
"zero": "0",
"oh": "0",
"one": "1",
"two": "2",
"three": "3",
"four": "4",
"five": "5",
"six": "6",
"seven": "7",
"eight": "8",
"nine": "9",
}
SCALE_WORDS = {
"hundred": 100,
"thousand": 1_000,
"million": 1_000_000,
"billion": 1_000_000_000,
"lakh": 100_000,
"crore": 10_000_000,
}
TRANSCRIPT_CURRENCY_MAP = {
"dollar": "$",
"dollars": "$",
"usd": "$",
"euro": "€",
"euros": "€",
"eur": "€",
"rupee": "₹",
"rupees": "₹",
"inr": "₹",
}
NON_EMAIL_LOCAL_PARTS = {"me", "you", "us", "him", "her", "them", "it"}
def format_transcript_text(text: str) -> str:
"""Normalize transcript display text before publishing it to the client."""
text = _format_spoken_emails(text)
text = _format_spoken_phone_numbers(text)
text = _format_spoken_currency_amounts(text)
return _format_numeric_currency_amounts(text)
def _format_spoken_emails(text: str) -> str:
def _replace(match: re.Match[str]) -> str:
local = _normalize_spoken_email_local(match.group("local"))
if local in NON_EMAIL_LOCAL_PARTS:
return match.group(0)
domain = match.group("domain").lower()
tld = _normalize_spoken_email_tld(match.group("tld"))
return f"{local}@{domain}.{tld}"
return SPOKEN_EMAIL_REGEX.sub(_replace, text)
def _normalize_spoken_email_local(local: str) -> str:
compact_local = " ".join(local.lower().split())
if re.fullmatch(r"(?:[a-z]\s+){1,10}[a-z]", compact_local):
return compact_local.replace(" ", "")
return compact_local
def _normalize_spoken_email_tld(tld: str) -> str:
return tld.lower().replace(" dot ", ".").replace(" ", "")
def _format_spoken_phone_numbers(text: str) -> str:
def _replace(match: re.Match[str]) -> str:
words = re.findall(PHONE_DIGIT_WORD_PATTERN, match.group(0), re.IGNORECASE)
digits = "".join(SPOKEN_DIGIT_MAP[word.lower()] for word in words)
if len(digits) == 10:
parse_input = digits
parse_region = "US" # NANP default fits current US/Canada traffic.
elif len(digits) == 11 and digits.startswith("1"):
parse_input = f"+{digits}"
parse_region = None
else:
return match.group(0)
try:
parsed = phonenumbers.parse(parse_input, parse_region)
except phonenumbers.NumberParseException:
return match.group(0)
if not _is_supported_nanp_number(parsed):
return match.group(0)
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.NATIONAL)
return SPOKEN_PHONE_REGEX.sub(_replace, text)
def _is_supported_nanp_number(parsed: phonenumbers.PhoneNumber) -> bool:
"""Validate numbers against the current NANP footprint we support in transcripts."""
return phonenumbers.is_valid_number_for_region(parsed, "US") or phonenumbers.is_valid_number_for_region(
parsed, "CA"
)
def _format_spoken_currency_amounts(text: str) -> str:
def _replace(match: re.Match[str]) -> str:
amount = _parse_spoken_number(match.group("amount"))
if amount is None:
return match.group(0)
currency_key = match.group("currency").lower()
prefix = TRANSCRIPT_CURRENCY_MAP.get(currency_key)
if prefix is None:
return match.group(0)
return f"{prefix}{_format_currency_amount(amount)}"
return SPOKEN_NUMBER_CURRENCY_REGEX.sub(_replace, text)
def _format_numeric_currency_amounts(text: str) -> str:
def _replace(match: re.Match[str]) -> str:
raw_amount = match.group("amount").replace(",", "")
try:
amount = float(raw_amount)
except ValueError:
return match.group(0)
currency_key = match.group("currency").lower()
prefix = TRANSCRIPT_CURRENCY_MAP.get(currency_key)
if prefix is None:
return match.group(0)
return f"{prefix}{_format_currency_amount(amount)}"
return SPOKEN_CURRENCY_REGEX.sub(_replace, text)
def _parse_spoken_number(amount_text: str) -> float | None:
parts = amount_text.lower().replace("-", " ").split()
if not parts:
return None
if "point" not in parts:
integer_value = _parse_spoken_integer(parts)
return float(integer_value) if integer_value is not None else None
point_index = parts.index("point")
integer_value = _parse_spoken_integer(parts[:point_index])
decimal_value = _parse_spoken_decimal(parts[point_index + 1 :])
if integer_value is None or decimal_value is None:
return None
return float(f"{integer_value}.{decimal_value}")
def _parse_spoken_integer(parts: list[str]) -> int | None:
if not parts:
return None
total = 0
current = 0
for part in parts:
if part == "and":
continue
if part in SMALL_NUMBER_WORDS:
current += SMALL_NUMBER_WORDS[part]
continue
scale = SCALE_WORDS.get(part)
if scale is None:
return None
if scale == 100:
current = max(current, 1) * scale
continue
total += max(current, 1) * scale
current = 0
return total + current
def _parse_spoken_decimal(parts: list[str]) -> str | None:
if not parts:
return None
digits: list[str] = []
for part in parts:
digit = DECIMAL_DIGIT_WORDS.get(part)
if digit is None:
return None
digits.append(digit)
return "".join(digits)
def _format_currency_amount(amount: float) -> str:
if amount.is_integer():
return f"{int(amount):,}"
return f"{amount:,.2f}"tall-airline-16756
04/01/2026, 10:19 AMxyz.
◦ Normalizes and lowercases local/domain/tld consistently.
• Currency handling
◦ Parses both numeric currency (e.g., “950 dollars”, “950.50 dollars”) and spoken-word amounts with decimals (e.g., “nine hundred and fifty point five dollars”).
◦ Formats integers with commas and decimals to 2 places where appropriate.
• Phone handling
◦ Keeps the phonenumbers validation and formatting for US/CA NANP numbers.
◦ Supports spoken single-digit sequences mapped to digits (including “oh”).
• Structure and safety
◦ Functions are well separated and composable so you can apply heavy normalization only when desired (e.g., final transcripts).
◦ Gracefully returns original text when parsing fails (safe fallback).
Small issues and recommended refinements
• SPOKEN_PHONE_REGEX digit count
◦ Your regex uses {9,10} repetitions in addition to the initial pattern, which will match 10–11 spoken digits total. That’s OK for 10-digit NANP numbers and 11-digit with leading 1, but double-check that aligns with your upstream STT segmentation (pauses/commas might split sequences). Consider allowing optional separators between groups or lowering sensitivity when used on interim results.
• Email TLD normalization
◦ _normalize_spoken_email_tld currently does simple replacements; it will transform co dot uk → codotuk unless SPOKEN_EMAIL_MULTI_TLD_PATTERN is matched as you have. Your pattern uses co\s+dot\s+uk, and you replace spaces; that should work, but add a test for “co dot uk” vs “co dot uk dot” variants. Also consider allowing spoken TLDs other than com/org/net if you expect them.
• False positives for short locals and contextual phrases
◦ You check NON_EMAIL_LOCAL_PARTS which helps, but phrases like “email me at support” vs “email support@domain dot com” can still be tricky. Consider requiring local part length >= 2 (you already allow 1) or verifying you have at least a domain + tld before converting.
• Decimal/cents edge cases
◦ Your decimal parsing returns floats and _format_currency_amount formats to two decimals when needed — good. Be aware of spoken “cents” phrasing like “ninety five cents” or “dollars and fifty cents”; those are not covered yet. If you need cents-handling, detect “cents” and convert to .xx.
• Words-to-number edge cases
◦ _parse_spoken_integer returns None when it sees unexpected words; this is safe but may miss variants like “twelve hundred” (which some expect as 1200) or mixed numeric/spoken forms. If you need broad coverage, consider a tested library for words→numbers or add rules for common shorthand (“hundred”, “twelve hundred”).
• Phone parsing and mixed formats
◦ If callers sometimes say area code separately (“area code two one two”), or mix digits and numerals, your regex may miss or partially match. You can expand PHONE_DIGIT_WORD_PATTERN to accept spelled letters or accept digits as well.
Performance & streaming considerations
• Run heavy parsing (numbers→numeric, decimal parsing, email spelled-letter collapsing) only on final transcriptions when possible (lk.transcription_final) to avoid frequent reprocessing and flicker in interim UI updates.
• If you must run on interim results, consider a lightweight fallback that only does cheap regex replacements, and run full normalization on final.
Integration tips so spoken output matches the transcript
• Normalize the same way for:
◦ Text you publish to lk.transcription.
◦ Text you send to your TTS engine (so the spoken audio says $950 if that’s what you want).
◦ LLM prompts / outputs if the LLM generates or reformats values.
• Test TTS behavior: some TTS engines will read $950 as “nine hundred and fifty dollars” (good) or as “dollar nine fifty” (bad). If TTS pronounces the formatted string unexpectedly, either:
◦ Adjust text you send to TTS (e.g., send spelled form when you want a spelled pronunciation).
◦ Use TTS normalization configuration if available.
• Prefer applying the helper to LLM output before TTS so both display and audio match.
Suggested tests (quick test vectors)
• Emails:
◦ “xyz at gmail dot com” → xyz@gmail.com
◦ “x y z at gmail dot com” → xyz@gmail.com
◦ “firstname dot lastname at outlook dot com” → firstname.lastname@outlook.com
◦ “me at gmail dot com” → unchanged (protected by NON_EMAIL_LOCAL_PARTS)
• Currency:
◦ “nine hundred and fifty dollars” → $950
◦ “nine hundred fifty dollars” → $950
◦ “nine hundred and fifty point five dollars” → $950.50
◦ “950 dollars” → $950
◦ “950.5 dollars” → $950.50
◦ “ninety five cents” / “dollars and fifty cents” (test & extend if you need cents)
• Phone:
◦ “one two three four five six seven eight nine zero” → formatted NANP number
◦ “one two three four five six seven eight nine” → no change (invalid length)
If you want, I can:
• Add cents handling (detect “cents” and mix with dollars).
• Extend email TLD handling to generic spoken TLDs and multi-part TLD patterns more generally.
• Show a safe pattern to only run heavy normalization for final transcription chunks (example transcription_node changes).
• Provide unit-test snippets for the test vectors above.
Which of these would you like me to draft next (cents support, expanded TLDs, final-only normalization integration, or unit tests)?
, with interactive elementsbusy-spoon-35253
04/01/2026, 11:51 AMHIGH in `backend/app/agent/base_agent.py`: transcription_node() now buffers transcript output until more than 96 chars have accumulated, so short utterances emit nothing until the stream ends, and longer utterances always lag by the last 96 chars.
async def transcription_node(
self, text: AsyncIterable[str], model_settings: ModelSettings
) -> AsyncGenerator[str, None]:
"""Post-process transcript text before it is emitted to frontend transcript streams."""
tail = ""
async for chunk in text:
combined = f"{tail}{chunk}"
if len(combined) <= TRANSCRIPT_TAIL_CHARS:
tail = combined
continue
split_at = len(combined) - TRANSCRIPT_TAIL_CHARS
yield format_transcript_text(combined[:split_at])
tail = combined[split_at:]
if tail:
yield format_transcript_text(tail)
Why it matters: both transcript UIs render whatever the backend streams, so this changes behavior from progressive transcript updates to delayed delivery. The current tests only check "".join(result) after completion, so they would miss this regression.
2. MEDIUM in `backend/app/utils/transcript_formatting.py`: _parse_spoken_integer() adds adjacent small-number words instead of treating common ASR digit sequences as digits, so phrases like "one two five dollars" can become $8, and "one two five thousand dollars" can become $8,000.
def _parse_spoken_integer(parts: list[str]) -> int | None:
if not parts:
return None
total = 0
current = 0
for part in parts:
if part == "and":
continue
if part in SMALL_NUMBER_WORDS:
current += SMALL_NUMBER_WORDS[part]
continue
scale = SCALE_WORDS.get(part)
if scale is None:
return None
if scale == 100:
current = max(current, 1) * scale
continue
total += max(current, 1) * scale
current = 0
return total + current
Why it matters: this silently rewrites quoted budget amounts in the transcript to incorrect values. The added tests cover canonical number-word phrasing, but not the digit-by-digit phrasing ASR often produces.tall-airline-16756
04/01/2026, 11:51 AMtranscription_node that delays/blocks progressive transcript updates, and (2) the _parse_spoken_integer() behavior that sums adjacent small-number words (breaking digit-by-digit ASR like “one two five”).
1) Buffering in transcription_node (HIGH) What’s happening
• The node accumulates up to TRANSCRIPT_TAIL_CHARS (96) and only yields once combined > 96 chars, producing:
◦ Short utterances: nothing is emitted until the stream ends (no interim updates).
◦ Longer utterances: output always lags by the last 96 chars (you see transcripts delayed and not truly progressive).
Why it matters
• Frontend progressive transcript UIs render whatever the backend streams. This change moves the UX from real-time incremental updates to delayed/batched updates causing perceived lag and poor interactivity.
• Current tests that only assert final concatenation ("".join(result)) will miss this regression.
Suggested fixes
• Option A — Emit as-you-go and keep a short tail for reassembly:
◦ On each chunk, immediately yield the safe portion, and keep only the final N chars as tail for the next chunk. This preserves progressive updates while still protecting the last N chars from being split mid-token.
◦ Pseudocode:
i. tail = ""
ii. async for chunk in text:
◦ combined = tail + chunk
◦ if len(combined) <= T: tail = combined; continue
◦ split_at = len(combined) - T
◦ yield format_transcript_text(combined[:split_at]) # immediate
◦ tail = combined[split_at:]
i. if tail: yield format_transcript_text(tail) # final
◦ This is basically what you have but ensure you yield on every chunk when split_at > 0 (your code already does that — verify the condition ordering and that interim yields happen for every arriving chunk).
• Option B — Lower TRANSCRIPT_TAIL_CHARS (e.g., 8–16) if the safety window only needs to avoid tiny mid-token fragmentation. This reduces lag.
• Option C — Only buffer when chunk boundaries risk splitting tokens:
◦ If the incoming chunk ends with whitespace or punctuation, you can safely emit it without tailing.
◦ Inspect last char of chunk; if it’s a token boundary, yield immediately and set tail="".
• Option D — Emit interim normalized text but mark interim vs final on transcript stream (if protocol supports it), so frontend can render interim updates differently.
Testing changes
• Add unit/integration tests that assert intermediate yields, not just the final concatenation:
◦ Feed a stream of small chunks that individually produce less than TRANSCRIPT_TAIL_CHARS and verify that finalization yields the expected final chunk and that some interim yields happen when applicable.
◦ Simulate longer chunks and ensure the output stream yields progressively and that the tail size remains <= TRANSCRIPT_TAIL_CHARS at each step.
• Add an end-to-end test that inspects the timing/ordering of yielded chunks to catch regressions.
Immediate quick mitigation
• If you need immediate behavioral parity with previous progressive streaming, set TRANSCRIPT_TAIL_CHARS to 0 or a small value while you validate a robust approach above.
2) Spoken integer parsing bug (MEDIUM) What’s happening
• _parse_spoken_integer treats every SMALL_NUMBER_WORD as additive into current, so sequences like ["one", "two", "five"] become 1+2+5 = 8 rather than the intended digit sequence 125 (common ASR output).
• This causes mis-formatting for digit-by-digit ASR results: “one two five dollars” → $8 and “one two five thousand dollars” → $8,000.
Why it matters
• Digit-by-digit ASR output is common for account numbers, budgets read aloud, or people spelling numbers. Silent rewrites of amounts to incorrect values are high risk.
Concrete fixes
• Fast-path: detect pure digit-word sequences and treat them as digits (concatenate) before falling back to word-number parsing.
◦ Condition: If all tokens are in DECIMAL_DIGIT_WORDS / SPOKEN_DIGIT_MAP and there are >= N tokens (e.g., >=2), then join mapped digits and parse as integer.
◦ Example:
▪︎ parts = ["one","two","five"] → all in SMALL_NUMBER_WORDS and also in SPOKEN_DIGIT_MAP → fast-path → digits = "125" → return 125
• Mixed detection: If sequence is a mix (e.g., “one hundred twenty five”), keep existing scale logic. If sequence is digits then scales appear (e.g., “one two five thousand”), you must decide precedence:
◦ Heuristic: if the first N tokens are digit-words and a scale word appears at the end (e.g., "thousand"), interpret digits as a number then multiply by the scale: "one two five thousand" → 125 * 1000 = 125000. This matches many human expectations.
◦ Safer: require pure-digit sequence for digit-concat fast-path unless pattern is specifically digit-sequence + final scale. Only implement the latter if you have good test coverage.
• Preserve "and" semantics: keep ignoring "and" where appropriate.
Suggested implementation sketch (replace/add to _parse_spoken_integer)
1. Normalize parts (lower).
2. If all parts ∈ DECIMAL_DIGIT_WORDS or SPOKEN_DIGIT_MAP:
◦ digits = "".join(SPOKEN_DIGIT_MAP[p] for p in parts)
◦ return int(digits)
1. If pattern matches digit-words followed by a single scale word at the end:
◦ digits_part, scale_part = split
◦ digits = int("".join(...))
◦ scale = SCALE_WORDS[scale_part]; return digits * scale
1. Else fallback to existing scale-based parsing.
Edge cases to consider
• “one two five” vs “one hundred twenty five” — use the above fast-path to distinguish.
• “twelve hundred” — your parser currently treats “hundred” scale; consider adding a special-case mapping for common shorthands if needed.
• Decimals/cents — you already added “point” parsing; also consider “dollars and fifty cents” parsing if important.
Testing changes Create unit tests specifically covering digit-by-digit ASR and mixed forms:
• ["one", "two", "five"] → 125
• ["one","two","five","thousand"] → 125000 (if you implement scale-mix heuristic)
• ["one","hundred","and","five"] → 105
• ["nine","hundred","and","fifty"] → 950
• ["twelve","hundred"] → 1200 (if you choose to support)
• ["one","two","five","point","five"] → 125.5 (ensure decimal fast-path composes with digits)
Putting it together: rollout plan
1. Implement the digit-sequence fast-path in _parse_spoken_integer (and tests).
2. Add tests for streaming behavior of transcription_node (verify interim yields) and reduce TRANSCRIPT_TAIL_CHARS or alter emission logic accordingly.
3. Run end-to-end tests where ASR emits both digit-by-digit and canonical word numbers to ensure consistent formatting in lk.transcription.
4. Optionally apply normalization to LLM/TTS inputs as well so spoken output matches formatted transcripts.
If you want, I can:
• Draft a concrete patch for _parse_spoken_integer that implements the digit fast-path and optional digit+scale behavior with tests.
• Draft a suggested change to transcription_node (small diff) that ensures progressive yields and adds tests that assert intermediate outputs.
• Provide sample unit tests for the key cases listed above.
Which patch would you like me to draft first — the integer parser fast-path, or the transcription_node streaming fix?
, with interactive elementsbusy-spoon-35253
04/01/2026, 11:55 AMtall-airline-16756
04/01/2026, 11:55 AMtranscription_node emit progressive updates while still preserving a short tail to avoid splitting tokens mid-word. I’ll also include a unit test pattern that verifies intermediate yields (so this regression is caught going forward) and brief notes on choosing TRANSCRIPT_TAIL_CHARS.
Patch: make streaming progressive
• Goal: For every incoming chunk, immediately yield the portion of text that is safe to emit, and keep at most TRANSCRIPT_TAIL_CHARS characters buffered as the tail. This preserves progressive updates and avoids the previous behavior where short utterances produced no interim output and longer utterances always lagged by the full tail.
Example patch (replace the current transcription_node implementation):
async def transcription_node(
self, text: AsyncIterable[str], model_settings: ModelSettings
) -> AsyncGenerator[str, None]:
"""Post-process transcript text before it is emitted to frontend transcript streams.
Emit progressive updates immediately while preserving a short tail of the last
TRANSCRIPT_TAIL_CHARS characters to avoid splitting tokens mid-word.
"""
tail = ""
async for chunk in text:
if not chunk:
continue
combined = f"{tail}{chunk}"
# If combined length is less than or equal to tail size, keep buffering.
if len(combined) <= TRANSCRIPT_TAIL_CHARS:
tail = combined
continue
# We can safely emit everything up to the last TRANSCRIPT_TAIL_CHARS.
split_at = len(combined) - TRANSCRIPT_TAIL_CHARS
# Emit the safe portion immediately (progressive update).
safe_part = combined[:split_at]
yield format_transcript_text(safe_part)
# Keep only the tail for the next iteration.
tail = combined[split_at:]
# At end of stream, emit any remaining tail.
if tail:
yield format_transcript_text(tail)
Why this fixes the regression
• The earlier code could defer yielding for short inputs because it only yielded when combined > tail size but then continued; this version yields immediately whenever there is a safe portion to emit (split_at > 0) and only buffers the minimal tail.
• Short utterances (shorter than the tail) still won't emit until finalization, but typical behavior for very short phrases is unavoidable if you want to avoid splitting tokens. If you want those short utterances to appear earlier as interim lines, reduce TRANSCRIPT_TAIL_CHARS (see notes below).
Tests to catch regressions
• Add tests that assert intermediate yields instead of only checking final concatenation. Example unit-test patterns:
1) Short-chunk stream that collectively exceeds the tail
• Feed three small chunks where the combined length grows over TRANSCRIPT_TAIL_CHARS only after the 2nd or 3rd chunk.
• Assert that for the chunk causing split_at > 0, the generator yields the safe portion immediately, and finalization yields the remaining tail.
2) Many small chunks under tail length
• Feed a stream of many tiny chunks whose cumulative length never exceeds TRANSCRIPT_TAIL_CHARS until finalization.
• Assert that no yields occur until the final yield contains entire combined text.
3) Large chunk streaming
• Feed a single large chunk > TRANSCRIPT_TAIL_CHARS.
• Assert you get one immediate yield for safe_part and then a final yield with the tail.
Pseudo-test example (pytest-like):
import asyncio
async def collect(generator):
out = []
async for piece in generator:
out.append(piece)
return out
def test_progressive_yield_small_then_over_tail():
chunks = ["hello ", "this is ", "a test that will exceed the tail by being long enough"]
gen = agent.transcription_node(iter_async(chunks), model_settings)
result = asyncio.run(collect(gen))
# Ensure at least two yields: one progressive and one final
assert len(result) >= 2
# Ensure joins equal formatted concatenation
assert "".join(result) == format_transcript_text("".join(chunks))
def test_single_large_chunk_emits_progressively():
chunk = "x" * (TRANSCRIPT_TAIL_CHARS + 50)
gen = agent.transcription_node(iter_async([chunk]), model_settings)
result = asyncio.run(collect(gen))
assert len(result) == 2 # safe_part + final tail
assert "".join(result) == format_transcript_text(chunk)
(Implement iter_async helper to convert list to AsyncIterable for tests.)
Choosing TRANSCRIPT_TAIL_CHARS and alternatives
• Default tail size (96) may be too large for interactive transcripts. Recommended values:
◦ 0–8: minimal buffering, more risk of splitting tokens but best immediacy.
◦ 8–32: small safety margin to avoid obvious mid-word splits while keeping low lag.
◦ 32–96: safer for token integrity but introduces perceptible lag for longer utterances.
• If you want near-instant interim updates for very short utterances, set TRANSCRIPT_TAIL_CHARS to a small number (8–16), or implement token-boundary detection:
◦ Check if the chunk ends with whitespace or punctuation. If so, emit immediately without tailing.
◦ Only buffer when the chunk ends mid-token (no trailing whitespace/punctuation).
Streaming/performance notes
• Run expensive formatting (numbers parsing, email normalization) only on final/near-final chunks if latency is critical. Use quick safe formatting for interim yields, and run full normalization on the tail or final yields.
• If you can mark interim vs final in the transcript stream (e.g., with a flag), let the frontend render interim results with lower visual weight to reduce flicker.
If you’d like, I can:
• Draft a small diff against your repo format (with imports/constant location) and a concrete pytest file using an AsyncIterable helper.
• Implement token-boundary emit logic so you skip buffering when chunks already end on safe boundaries.
• Adjust integration with your format_transcript_text so interim yields use cheaper normalization while final yields run the full helper.
Which follow-up would you prefer — the concrete repo diff + pytest file, or adding token-boundary detection to avoid buffering when chunks already end on word boundaries?
, with interactive elementsbusy-spoon-35253
04/02/2026, 3:53 AMtall-airline-16756
04/02/2026, 3:53 AMtranscription_node helper plus tests and notes.
Summary of the two-part fix
• Part 1: Buffering + progressive yield — accumulate incoming chunks into a sliding buffer, run pattern matching on the buffer, but only emit the buffer up to a short safety margin (e.g., last 12 words). This preserves progressive UI updates while ensuring patterns spanning chunk boundaries are recognized.
• Part 2: Phone context guard — only convert digit sequences into phone numbers when preceded by a phone cue (and not preceded by known non-phone cues). This mirrors your email guard behavior and prevents accidental reformatting of order/account numbers.
Part 1 — Buffering with word-based safety margin
• Use words, not raw characters, for the safety tail (words are a better unit for ASR behavior).
• On each incoming chunk:
a. Append to buffer.
b. If buffer has more than safety_word_count words, split it into safe_part + tail_words, run formatting on safe_part, yield formatted safe_part, and keep tail_words as the buffer.
c. Otherwise keep buffering.
• On stream end: run formatting on remaining buffer and yield.
Example implementation sketch (sync-style for clarity; adapt to your async generator):
SAFETY_WORDS = 12 # tune between immediacy and matching accuracy
def _words(text: str) -> list[str]:
return text.split()
async def transcription_node(self, text: AsyncIterable[str], model_settings: ModelSettings):
tail_words: list[str] = []
async for chunk in text:
if not chunk:
continue
# Append chunk words to tail buffer
tail_words.extend(_words(chunk))
# If we have more than safety words, emit the safe portion
if len(tail_words) > SAFETY_WORDS:
safe_count = len(tail_words) - SAFETY_WORDS
safe_words = tail_words[:safe_count]
tail_words = tail_words[safe_count:]
safe_text = " ".join(safe_words)
yield format_transcript_text(safe_text)
# flush remaining buffer at end
if tail_words:
yield format_transcript_text(" ".join(tail_words))
Notes:
• Use a lightweight .split() for words; you can improve tokenization later (strip punctuation, preserve punctuation boundaries).
• Tune SAFETY_WORDS (8–16 is a good starting point for phone/email/currency patterns).
• For very short utterances you’ll still buffer until finalization if they’re shorter than SAFETY_WORDS; if you need more immediacy for short phrases, lower SAFETY_WORDS or emit chunk when the chunk ends with punctuation/whitespace token boundary.
Part 2 — Phone context guard
• Maintain two sets:
◦ PHONE_CUES: phrases that imply a phone number (e.g., "call me at", "phone number is", "reach me at", "my number is", "contact is").
◦ PHONE_NEGATIVE_CUES: phrases that should prevent phone formatting (e.g., "order number is", "account number is", "reference", "tracking number").
• When you find a candidate digit-word sequence (or concatenated digits) in the buffer, check a sliding window of preceding words (e.g., 6–8 words) for presence of a PHONE_CUE and absence of PHONE_NEGATIVE_CUES.
• Only format if cue present (or if you have other strong signals such as explicit tel: or “call” verbs).
Example phone-check helper:
PHONE_CUES = [
"phone number is", "my number is", "call me at", "reach me at", "contact me at", "you can call", "dial"
]
PHONE_NEGATIVE_CUES = ["order number", "account number", "reference number", "tracking number"]
def has_phone_context(buffer_text: str, match_start_index: int, window_words: int = 8) -> bool:
# buffer_text -> token list
words = buffer_text.lower().split()
start = max(0, match_start_index - window_words)
context = " ".join(words[start:match_start_index])
# Positive cues
for cue in PHONE_CUES:
if cue in context:
# ensure no negative cues nearby
if not any(neg in context for neg in PHONE_NEGATIVE_CUES):
return True
return False
Integration with formatting flow
• Run candidate detection on the buffer (after you’ve accumulated enough words).
• For email/currency, keep your existing logic (emails already check some context; currency often safe).
• For phone candidates:
◦ If pure-digit sequence detected, locate its start index in the buffer word list and call has_phone_context.
◦ Only replace when has_phone_context returns True.
Examples of common cases handled
• "my phone number is nine one seven five five five one two one two" → buffer sees full sequence, has phone cue → formats to (917) 555-1212.
• "my order number is eight zero zero five five five one two one two" → buffer sees digits but negative cue “order number” present → no formatting.
• "call me at nine one seven five five five one two one two" → phone cue present → formats.
• "nine one seven five five five one two one two" with prior silence/no cue → no formatting (safe).
Tests to add
• Streaming behavior tests:
◦ Feed the chunk stream you showed and assert you get a final formatted phone.
◦ Feed chunks where digits are split and ensure at least one progressive yield happened before final and final contains the formatted phone.
• Context guard tests:
◦ "call me at one two three four five six seven eight nine zero" -> formats
◦ "order number one two three four five six seven eight nine zero" -> does not format
◦ "my number is one two three four five six seven eight nine zero" -> formats
• False-positive prevention:
◦ "our invoice id is one two three four" -> no phone formatting
• Performance tests:
◦ Ensure large streams still produce frequent progressive yields and buffer length stays bounded.
Additional practical notes
• Keep heavy normalization (numbers->ints, decimal parsing) for final or near-final chunks. For progressive yields you can run cheaper, partial normalization (e.g., only punctuation cleanup) to keep UI responsive.
• Consider marking yielded updates as interim vs final (if your transcript stream supports that) so the frontend behaves correctly while you still buffer a small tail.
• Document chosen SAFETY_WORDS and PHONE_CUES so others can tune them for your domain.
If you’d like, I can:
• Draft a concrete async implementation diff that replaces your current char-based tail approach with the word-buffer approach above (with the phone context check and tests in pytest/asyncio).
• Provide a ready-to-run pytest file that simulates the chunk stream you shared and asserts progressive yields + final formatting.
• Suggest an optimal SAFETY_WORDS value based on a quick sample of your average utterance lengths.
Which follow-up do you want first — the concrete repo diff + pytest, or the SAFETY_WORDS tuning recommendation with a sample of expected chunk sizes?
, with interactive elementsbusy-spoon-35253
04/02/2026, 3:54 AMtall-airline-16756
04/02/2026, 3:55 AMlk.transcription_final / is_final).
• Prefer provider-side streaming STT (Deepgram, Speechmatics, etc.) or use StreamAdapter + VAD when your STT is non-streaming.
• Implement formatting and structured-data extraction in the agent’s transcription_node or on("user_input_transcribed") handler, but buffer at the word level (a short safety tail) so patterns spanning chunk boundaries are recognized.
• For structured fields (emails, phone numbers), prefer LiveKit’s prebuilt tasks (e.g., GetEmailTask) or guard formatting with contextual cues (like “call me at”, “my number is”) to avoid false positives.
• Run cheap, interim-friendly fixes on partial transcripts and run full normalization on final transcripts to balance latency and correctness.
Official examples & references (code/projects)
• Transcriber recipe — minimal STT agent, good starting point for streaming transcripts:
https://docs.livekit.io/recipes/transcriber/ https://docs.livekit.io/recipes/transcriber
• Pi Zero Transcriber — shows interim + final rendering and example STT usage:
https://docs.livekit.io/recipes/pi_zero_transcriber/ https://docs.livekit.io/recipes/pizerotranscriber
• TTS Translator example — shows handling user_input_transcribed and speaking final transcripts:
https://docs.livekit.io/recipes/tts_translator/ https://docs.livekit.io/recipes/tts_translator
• Repeater example — echoes final user transcriptions with `session.say`:
https://docs.livekit.io/recipes/repeater/ https://docs.livekit.io/recipes/repeater
• GetEmailTask (prebuilt task) — demonstrates robust spoken-email normalization:
https://docs.livekit.io/agents/prebuilt/tasks/get-email/ https://docs.livekit.io/agents/prebuilt/tasks/get-email/
• Text & transcriptions guide — overview of lk.transcription, interim vs final streams, and frontend hooks:
https://docs.livekit.io/agents/multimodality/text/ https://docs.livekit.io/agents/multimodality/text/
Practical code patterns (recommended) 1) Word-buffered transcription_node (concept)
• Buffer incoming chunks at the word level and keep a short safety tail (8–16 words).
• On each chunk: append words to buffer; if buffer > safety_words, emit the safe prefix formatted and keep tail words.
• On final is_final or stream end: format and emit remaining buffer.
2) Interim vs final handling
• If you can access lk.transcription_final (or is_final in events), use it:
◦ For interim messages: run lightweight cleanup (punctuation, quick replacements).
◦ For final messages: run full normalization (email parsing, numbers→numeric, phone formatting).
3) Use prebuilt tasks for structured data
• For emails use GetEmailTask — it’s designed to normalize spoken email patterns and spelled letters reliably.
• For phone numbers, either use model prompt/context to coax a single contiguous digit sequence, or apply a context-guarded formatter that only formats when preceding words indicate a phone number.
4) Token/word boundary optimization
• If a chunk ends with whitespace or punctuation, it likely ends on a token boundary — safe to emit with no tail.
• Only buffer when chunks appear to cut tokens mid-word.
Which example would be most helpful to you now?
• A ready-to-drop-in async transcription_node implementation that uses word buffering + phone-context guard + unit tests (pytest/asyncio). I can format this as a repo diff.
• A small sample project example showing GetEmailTask + transcription buffering integration.
• A recommended set of TRANSCRIPT_TAIL_WORDS and tests based on sample chunk sizes you provide.
Which one should I draft next?
Sources: Text and transcriptions | LiveKit Documentation | TTS Translator with Gladia STT | LiveKit Documentation | Pipeline nodes and hooks | LiveKit Documentation | GetEmailTask | LiveKit Documentation | Sequential Pipeline Architecture for Voice Agents | LiveKit, with interactive elementsbusy-spoon-35253
04/02/2026, 3:57 AMtall-airline-16756
04/02/2026, 3:57 AMtranscription_node you can drop into your agent. It handles streaming ASR, preserves progressive updates, buffers at the word level to catch patterns across chunk boundaries, uses the lk.transcription_final / is_final concept, and includes a phone-context guard so you don’t accidentally format order/account numbers.
Short summary of recommended behavior for real-time models
• Use interim transcripts for low-latency UI updates and final transcripts for heavy normalization.
• Buffer at the word level (not arbitrary character windows) and keep a short safety tail (8–16 words) to let patterns spanning chunks form.
• Emit the safe portion immediately each time the buffer grows beyond the tail, and keep only the tail for future chunks.
• Only apply aggressive formatting (phone reformatting, phone digit concatenation) when a phone cue appears in the nearby context.
• Run full formatting on final segments (when lk.transcription_final is true) and lightweight formatting on interim pieces to avoid flicker/latency.
Drop-in async transcription_node (word-buffer + phone context guard)
• This implementation assumes your streaming text input yields tuples (text_chunk: str, is_final: bool). If your pipeline uses a different shape, adapt the loop to read is_final (or check lk.transcription_final) when available.
# constants you can tune
SAFETY_WORDS = 12 # tail length (8-16 recommended)
PHONE_CONTEXT_WINDOW = 8 # words to look back for phone cues
PHONE_CUES = [
"phone number", "my number", "call me at", "reach me at", "contact me at", "you can call", "dial", "call"
]
PHONE_NEGATIVE_CUES = [
"order number", "account number", "reference number", "tracking number", "invoice number"
]
def _words(text: str) -> list[str]:
# simple split; optionally use a tokenizer to preserve punctuation
return [w for w in text.strip().split() if w]
def _context_has_phone_cue(buffer_words: list[str], match_start_index: int) -> bool:
start = max(0, match_start_index - PHONE_CONTEXT_WINDOW)
context = " ".join(buffer_words[start:match_start_index]).lower()
if any(neg in context for neg in PHONE_NEGATIVE_CUES):
return False
return any(cue in context for cue in PHONE_CUES)
async def transcription_node(self, text: AsyncIterable[tuple[str, bool]], model_settings) -> AsyncGenerator[str, None]:
"""
text yields (chunk_text, is_final) tuples from real-time STT.
- Buffers at word level with a small safety tail.
- Emits safe portions progressively.
- Applies heavy formatting on final chunks and guarded phone formatting.
"""
buffer_words: list[str] = []
async for chunk_text, is_final in text:
if not chunk_text:
if is_final:
# flush on explicit final even if empty
if buffer_words:
yield format_transcript_text(" ".join(buffer_words), final=True)
buffer_words = []
continue
incoming_words = _words(chunk_text)
buffer_words.extend(incoming_words)
# If we have more than SAFETY_WORDS, emit safe prefix
if len(buffer_words) > SAFETY_WORDS:
safe_count = len(buffer_words) - SAFETY_WORDS
safe_words = buffer_words[:safe_count]
buffer_words = buffer_words[safe_count:]
safe_text = " ".join(safe_words)
# Lightweight formatting for interim output
yield format_transcript_text(safe_text, final=False)
# If this chunk is marked final, flush buffer (apply full formatting)
if is_final:
if buffer_words:
final_text = " ".join(buffer_words)
yield format_transcript_text(final_text, final=True)
buffer_words = []
# End of stream: flush anything remaining
if buffer_words:
yield format_transcript_text(" ".join(buffer_words), final=True)
Notes about format_transcript_text integration
• Extend your existing format_transcript_text(text: str, final: bool) signature:
◦ If final is False (interim), run cheap operations only (punctuation clean-up, small replacements).
◦ If final is True, run full normalization: spoken-email parsing, numbers→numeric (with digit-sequence fast-path), phone formatting with context checks, decimals/cents.
• For phone formatting, when scanning buffer words for a digit sequence, call _context_has_phone_cue(buffer_words, match_start_index) before converting.
Why this works for real-time models
• Real-time STT emits partial tokens; buffering at the word level lets you see sequences that span multiple chunks.
• Progressive emission of safe parts keeps frontend latency low.
• Using is_final / lk.transcription_final ensures heavy processing only on stable text (reduces CPU and UI flicker).
• Phone cue guard prevents false positives on non-phone digit lists.
Testing suggestions (to prevent regressions)
• Simulate streamed chunks as you shared (tiny chunks for digit-by-digit speech) and assert the final output contains formatted phone/email/currency.
• Test that interim yields occur (i.e., generator yields before final flush) — don't only assert final concatenation.
• Add negative tests where digit lists are preceded by “order number” and ensure no phone formatting.
• Test digit-by-digit cases:
◦ "one two five" → if preceded by phone cue → 125 (or phone if 10 digits)
◦ "one two five thousand" → if preceded by phone cue and last token is 'thousand' handle per requirement.
Useful LiveKit docs & examples (real-time oriented)
• Transcriber recipe (streaming STT): https://docs.livekit.io/recipes/transcriber/
• Pi Zero transcriber (interim + final rendering): https://docs.livekit.io/recipes/pi_zero_transcriber/
• TTS Translator (shows user_input_transcribed handling): https://docs.livekit.io/recipes/tts_translator/
• Prebuilt email task (if you want a robust email collector): https://docs.livekit.io/agents/prebuilt/tasks/get-email/
Would you like any of these next steps?
• I can draft a concrete repo diff (async code) that replaces your char-based tail with this word-buffer approach and adds is_final handling.
• I can produce a pytest/asyncio test file that simulates your chunk streams (the example you posted) and asserts interim yields + final formatting.
• I can adapt format_transcript_text to a two-path (interim vs final) implementation including a phone-context-aware formatting helper and a digit fast-path for numbers.
Which one should I prepare first?
, with interactive elementsbusy-spoon-35253
04/02/2026, 4:02 AMtall-airline-16756
04/02/2026, 4:02 AMon("user_input_transcribed") handler or as part of a form-collection workflow). That keeps the normalization and validation logic centralized.
• What it returns: a validated email address you can trust for downstream use (display, storage, or sending messages).
Minimal usage example (Python-style)
from livekit.agents.beta.workflows import GetEmailTask
# inside an async agent handler or flow
email_result = await GetEmailTask(chat_ctx=self.chat_ctx)
if email_result and email_result.email_address:
normalized = email_result.email_address
# use normalized (e.g., save, display, or confirm with the user)
Integration tips
• Buffering: call the task after you’ve seen the full spoken email (use `is_final`/final transcript) or when your word-buffer indicates the user likely finished speaking.
• Confirmation: for high-value actions, echo the parsed email back to the user (e.g., “I heard xyz@gmail.com — is that correct?”) to avoid mis-entry.
• Fallbacks: if GetEmailTask fails or returns low confidence, present the raw transcript to the user and ask them to spell it or enter it manually.
• Interop with your formatter: keep your format_transcript_text for general display cleanup, but let GetEmailTask do the canonical parsing/validation work for email fields.
Docs & examples
• Prebuilt task docs: https://docs.livekit.io/agents/prebuilt/tasks/get-email/ https://docs.livekit.io/agents/prebuilt/tasks/get-email/
• If you want, I can:
◦ Show a concrete repo diff integrating GetEmailTask into your current flow (with checks for is_final and a confirmation prompt).
◦ Draft an example that falls back to your existing format_transcript_text behavior when GetEmailTask returns no result.
Which of those would help you next?
, with interactive elements