rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
e610ee9
·
1 Parent(s): 23a6d29

feat(profile + voice): KI-059 / KI-060 / KI-061 / KI-062 — opening-turn name capture, looser VAD silence, personalized welcome-back, unique persona ID

Browse files

Four user-testing fixes landed in one commit.

KI-059 — Catch name disclosed in opening message
═══════════════════════════════════════════════════════════════
User typed "Hi this is Rohit" as the very first message; bot still asked
"what should I call you?". Now an opening turn that contains a self-
introduction routes straight into the name-slot handler.

- New `_contains_self_introduction(text)` helper. Strips leading
greetings ("hi", "hello", "hey", "namaste", "yo") then matches
"I'm X" / "I am X" / "this is X" / "my name is X" / "name is X" /
"call me X" / "name's X".
- Tested 10/10 (positive + negative cases pass).
- When detected on turn 0, orchestrator synthesises
`session.awaiting_question_id = "name"` so the existing name-slot
pipeline (extract, save, welcome-back lookup) runs.
- Name-handler prefix loop widened: now strips leading
"hi[,]? " / "hello[,]? " / "hey[,]? " / etc. BEFORE the
"this is " / "i'm " / etc. loop, so "Hi, this is Rohit" → "Rohit".

KI-060 — Live VAD silence threshold loosened
═══════════════════════════════════════════════════════════════
User report: "small background noise is making the live stream auto submit.
Even if I pause for a second before I can continue speaking."

Root cause: KI-057 made the noise gate work correctly, which exposed an
underlying problem — `silenceEndFrames: 40` (~640 ms of silence) was too
aggressive for natural mid-sentence pauses. Bumped to 90 (~1.5 s). Single
config change in useLiveConversation.ts DEFAULTS.

KI-061 — Personalized welcome-back greeting
═══════════════════════════════════════════════════════════════
Previously: "Welcome back, Rohit! I've loaded your profile from last time."
Now also lists what's on file and what's missing, so the user feels
remembered AND any gap can be filled before recommending.

- `_format_known_profile_summary(profile)` returns a comma-joined
rundown ("age 34, covering you+spouse, income 10-25L, looking
for first health policy").
- `_format_missing_slots(profile)` returns human-readable labels
for high-value gaps (age, dependents, income, primary_goal,
parents' health, budget). Minor slots (location, existing cover,
health conditions) deliberately excluded to keep the greeting
focused.
- Greeting composes both: "Welcome back, X! Here's what I have on
file: ... We never got around to ... — happy to fill that in or
jump straight to a recommendation."

KI-062 — Full name + unique persona ID
═══════════════════════════════════════════════════════════════
User: "it should get my full name, Rohit Sar, for example, so that it's
easier to identify. Unique user / persona ID should also be created. That
is a combination of not just the name, which can be similar, but all the
other data points that we also collect."

- Name regex widened from 1-2 words to 1-4 words. "Rohit Sar" and
"Anjali Devi Kumar" now capture fully.
- New `compute_persona_id(profile)` in profile_store. Returns a
12-char sha1 over normalised name + age + dependents + income_band
+ location_tier + parents_age_max. Two users named "Rohit" with
different identity fields resolve to distinct IDs (verified: ids
d4ea9d29... vs c49f6838... in roundtrip test).
- `save_profile` now keys files by persona_id when there's enough
signal; falls back to name-slug for first-visit (only name known).
- `load_profile(name, persona_id=...)` does a 3-tier lookup: direct
persona-id hit → legacy name-slug → directory scan for any
persona-id-keyed file whose stored display name matches.
- Legacy name-slug file cleanup on save: when an identity is finally
resolved, the older same-name file's session history is folded
forward into the new persona-id file, then the legacy file is
unlinked. No orphan files; session lineage preserved.

Verification
═══════════════════════════════════════════════════════════════
- py_compile both backend files: OK
- 10/10 inline cases on _contains_self_introduction
- persona_id distinct for two different-identity Rohits + stable on replay
- save_profile + load_profile by persona_id roundtrip
- npx tsc --noEmit (frontend): 0 errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

backend/orchestrator.py CHANGED
@@ -235,6 +235,93 @@ def _family_aware_opener(user_text: str, fallback: str) -> Optional[str]:
235
  return pool[idx]
236
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  def _pick_opener(
239
  user_text: str,
240
  session_id: Optional[str],
@@ -500,6 +587,21 @@ async def handle_turn(
500
  # of this branch to emit a "Welcome back" message instead of the
501
  # next slot's question.
502
  returning_visitor_greeting: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
503
  if session.awaiting_question_id:
504
  from backend.fact_find_normalizer import is_valid_answer, normalize_answer
505
  qid = session.awaiting_question_id
@@ -507,6 +609,15 @@ async def handle_turn(
507
  if qid == "name":
508
  from backend.profile_store import is_valid_name, load_profile, save_profile
509
  raw_name = user_text.strip().strip(".,!?")
 
 
 
 
 
 
 
 
 
510
  # Tolerate "I'm Rohit" / "My name is Rohit" / "call me Rohit"
511
  for prefix in ("i'm ", "i am ", "my name is ", "name is ", "call me ", "this is "):
512
  if raw_name.lower().startswith(prefix):
@@ -550,11 +661,36 @@ async def handle_turn(
550
  session.profile.asked.append(slot_id)
551
  session.free_form_session = True
552
  session._flush()
553
- returning_visitor_greeting = (
554
- f"Welcome back, {raw_name}! I've loaded your profile from last time. "
555
- "Want me to suggest some policies that fit your situation, or do "
556
- "you have a specific question in mind?"
557
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  else:
559
  # New visitor — record an initial profile so subsequent
560
  # turns persist; orchestrator will continue to next slot.
 
235
  return pool[idx]
236
 
237
 
238
+ _SELF_INTRO_RE = re.compile(
239
+ # KI-062 (2026-05-15) — widened from 1-2 words to 1-4 words so full
240
+ # names like "Rohit Sar" or "Anjali Devi Kumar" get captured.
241
+ r"\b(?:i'?m|i\s+am|this\s+is|my\s+name\s+is|name\s+is|call\s+me|name'?s)\s+"
242
+ r"([a-zA-Z][a-zA-Z'\-]{1,30}"
243
+ r"(?:\s+[a-zA-Z][a-zA-Z'\-]{1,30}){0,3})\b",
244
+ re.IGNORECASE,
245
+ )
246
+
247
+ # KI-059 (2026-05-15) — leading greeting tokens to ignore so "Hi this is X"
248
+ # and "Hello, my name is X" reach the introduction phrase.
249
+ _GREETING_LEAD = re.compile(
250
+ r"^\s*(?:hi|hello|hey|namaste|yo|hola)[,!.\s]+",
251
+ re.IGNORECASE,
252
+ )
253
+
254
+
255
+ def _contains_self_introduction(text: str) -> bool:
256
+ """True if the message volunteers a name via "I'm X" / "this is X" /
257
+ "my name is X" / "call me X" — with or without a greeting prefix.
258
+
259
+ Used by KI-059 to detect when a user has supplied their name in the
260
+ very first turn so we don't ask "what should I call you?" right after
261
+ they told us.
262
+ """
263
+ if not text:
264
+ return False
265
+ stripped = _GREETING_LEAD.sub("", text.strip())
266
+ return bool(_SELF_INTRO_RE.search(stripped))
267
+
268
+
269
+ # KI-061 (2026-05-15) — human-readable summaries for the welcome-back
270
+ # greeting. Each tuple is (field name on Profile, label, formatter).
271
+ _KNOWN_FIELD_FORMATTERS: tuple[tuple[str, str, "callable"], ...] = (
272
+ ("age", "age", lambda v: f"{v}"),
273
+ ("dependents", "covering", lambda v: str(v).replace("self+", "you+").replace("+", " + ")),
274
+ ("income_band", "income band", lambda v: str(v)),
275
+ ("existing_cover_inr", "existing cover", lambda v: f"₹{v:,}" if isinstance(v, int) else str(v)),
276
+ ("primary_goal", "looking for", lambda v: str(v).replace("_", " ")),
277
+ ("location_tier", "city tier", lambda v: str(v)),
278
+ ("parents_age_max", "parents' age", lambda v: f"oldest {v}"),
279
+ ("health_conditions", "health conditions", lambda v: ", ".join(v) if isinstance(v, list) and v else None),
280
+ ("budget_band", "budget", lambda v: str(v)),
281
+ )
282
+
283
+
284
+ def _format_known_profile_summary(profile) -> str:
285
+ """Return a comma-separated rundown of what we already know, e.g.
286
+ 'age 34, covering you + spouse, income ₹10-25L, looking for first
287
+ health policy'. Returns '' if nothing meaningful is stored."""
288
+ parts: list[str] = []
289
+ for field_name, label, fmt in _KNOWN_FIELD_FORMATTERS:
290
+ val = getattr(profile, field_name, None)
291
+ if val in (None, "", []):
292
+ continue
293
+ try:
294
+ rendered = fmt(val)
295
+ except Exception:
296
+ rendered = str(val)
297
+ if rendered:
298
+ parts.append(f"{label} {rendered}")
299
+ return ", ".join(parts)
300
+
301
+
302
+ _HELPFUL_GAP_LABELS = {
303
+ "age": "your age",
304
+ "dependents": "who you're covering",
305
+ "income_band": "your income band",
306
+ "primary_goal": "what you're shopping for",
307
+ "parents_age_max": "your parents' health context",
308
+ "budget_band": "your budget",
309
+ }
310
+
311
+
312
+ def _format_missing_slots(profile) -> list[str]:
313
+ """Return human-readable labels for the high-value slots we never
314
+ captured. Limited to the ones that genuinely shape a recommendation
315
+ — minor slots (location, existing_cover, health_conditions) are
316
+ skipped to keep the welcome-back focused."""
317
+ missing = []
318
+ for field_name, label in _HELPFUL_GAP_LABELS.items():
319
+ val = getattr(profile, field_name, None)
320
+ if val in (None, "", []):
321
+ missing.append(label)
322
+ return missing
323
+
324
+
325
  def _pick_opener(
326
  user_text: str,
327
  session_id: Optional[str],
 
587
  # of this branch to emit a "Welcome back" message instead of the
588
  # next slot's question.
589
  returning_visitor_greeting: Optional[str] = None
590
+
591
+ # KI-059 (2026-05-15) — opening-turn name capture. If the user
592
+ # volunteered a name in their FIRST message ("Hi this is Rohit",
593
+ # "I'm Anjali", "My name is Ravi") before we asked, route it into
594
+ # the name-slot handler below so the existing extract + save +
595
+ # welcome-back logic fires — instead of asking "what should I
596
+ # call you?" right after they told us. Pre-condition: not yet
597
+ # awaiting any slot, no name on the profile, and not in
598
+ # free-form session.
599
+ if (not session.awaiting_question_id
600
+ and not session.profile.name
601
+ and not session.free_form_session
602
+ and _contains_self_introduction(user_text)):
603
+ session.set_awaiting("name")
604
+
605
  if session.awaiting_question_id:
606
  from backend.fact_find_normalizer import is_valid_answer, normalize_answer
607
  qid = session.awaiting_question_id
 
609
  if qid == "name":
610
  from backend.profile_store import is_valid_name, load_profile, save_profile
611
  raw_name = user_text.strip().strip(".,!?")
612
+ # KI-059 — strip a leading greeting + comma so "Hi, this is
613
+ # Rohit" / "Hello I'm Anjali" reduces to the introduction
614
+ # phrase the next loop expects.
615
+ for greet in ("hi there ", "hello there ", "hey there ",
616
+ "hi, ", "hello, ", "hey, ",
617
+ "hi ", "hello ", "hey ", "namaste ", "yo "):
618
+ if raw_name.lower().startswith(greet):
619
+ raw_name = raw_name[len(greet):].strip()
620
+ break
621
  # Tolerate "I'm Rohit" / "My name is Rohit" / "call me Rohit"
622
  for prefix in ("i'm ", "i am ", "my name is ", "name is ", "call me ", "this is "):
623
  if raw_name.lower().startswith(prefix):
 
661
  session.profile.asked.append(slot_id)
662
  session.free_form_session = True
663
  session._flush()
664
+ # KI-061 (2026-05-15) — personalized welcome-back:
665
+ # summarize what's on file, call out helpful gaps,
666
+ # offer next step. So the returning visitor sees
667
+ # the bot remembers them precisely, and any missing
668
+ # info can be filled before recommending.
669
+ known_summary = _format_known_profile_summary(stored)
670
+ missing_labels = _format_missing_slots(stored)
671
+ parts = [f"Welcome back, {raw_name}!"]
672
+ if known_summary:
673
+ parts.append(
674
+ f"Here's what I have on file from your last visit: {known_summary}."
675
+ )
676
+ if missing_labels:
677
+ if len(missing_labels) == 1:
678
+ gap_phrase = missing_labels[0]
679
+ elif len(missing_labels) == 2:
680
+ gap_phrase = " and ".join(missing_labels)
681
+ else:
682
+ gap_phrase = ", ".join(missing_labels[:-1]) + f", and {missing_labels[-1]}"
683
+ parts.append(
684
+ f"We never got around to {gap_phrase} — happy to fill that in "
685
+ "so I can grade policies more precisely, or you can jump straight "
686
+ "to a recommendation or any specific question."
687
+ )
688
+ else:
689
+ parts.append(
690
+ "Looks like we have everything we need — want me to suggest some "
691
+ "policies that fit, or do you have a specific question in mind?"
692
+ )
693
+ returning_visitor_greeting = " ".join(parts)
694
  else:
695
  # New visitor — record an initial profile so subsequent
696
  # turns persist; orchestrator will continue to next slot.
backend/profile_store.py CHANGED
@@ -25,6 +25,7 @@ display name is preserved inside the JSON.
25
 
26
  from __future__ import annotations
27
 
 
28
  import json
29
  import logging
30
  import re
@@ -47,7 +48,38 @@ def _normalise_name(name: str) -> str:
47
  return cleaned[:60] # cap filename length
48
 
49
 
50
- def _path_for(name: str) -> Optional[Path]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  slug = _normalise_name(name)
52
  if not slug:
53
  return None
@@ -66,19 +98,13 @@ def is_valid_name(text: str) -> bool:
66
  return alpha / max(1, len(s)) >= 0.5
67
 
68
 
69
- def load_profile(name: str) -> Optional[Profile]:
70
- """Return the stored Profile for `name`, or None if no record exists.
71
-
72
- Future-proofs against Profile schema drift — any persisted fields that no
73
- longer exist on the Profile dataclass are silently dropped.
74
- """
75
- p = _path_for(name)
76
- if not p or not p.exists():
77
- return None
78
  try:
79
  raw = json.loads(p.read_text())
80
  except Exception as e:
81
- logging.warning("profile_store load failed name=%s: %s", name, e)
82
  return None
83
  prof_dict = raw.get("profile") or {}
84
  valid_fields = set(Profile.__dataclass_fields__.keys())
@@ -86,13 +112,52 @@ def load_profile(name: str) -> Optional[Profile]:
86
  try:
87
  return Profile(**prof_dict)
88
  except Exception as e:
89
- logging.warning("profile_store reconstruct failed name=%s: %s", name, e)
90
  return None
91
 
92
 
93
- def save_profile(name: str, profile: Profile, *, session_id: Optional[str] = None) -> bool:
94
- """Persist `profile` keyed by `name`. Returns True on success."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  p = _path_for(name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  if not p:
97
  return False
98
  try:
@@ -103,6 +168,22 @@ def save_profile(name: str, profile: Profile, *, session_id: Optional[str] = Non
103
  existing = json.loads(p.read_text())
104
  except Exception:
105
  existing = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
107
  sessions = list(existing.get("sessions") or [])
108
  if session_id and session_id not in sessions:
@@ -111,6 +192,7 @@ def save_profile(name: str, profile: Profile, *, session_id: Optional[str] = Non
111
  payload = {
112
  "name_display": (profile.name or name).strip(),
113
  "name_slug": _normalise_name(name),
 
114
  "profile": asdict(profile),
115
  "first_seen": existing.get("first_seen") or now_iso,
116
  "last_seen": now_iso,
 
25
 
26
  from __future__ import annotations
27
 
28
+ import hashlib
29
  import json
30
  import logging
31
  import re
 
48
  return cleaned[:60] # cap filename length
49
 
50
 
51
+ # KI-062 (2026-05-15) — identity-defining fields used to disambiguate two
52
+ # users with the same display name. Order matters for hash stability.
53
+ _PERSONA_ID_FIELDS: tuple[str, ...] = (
54
+ "age", "dependents", "income_band", "location_tier", "parents_age_max",
55
+ )
56
+
57
+
58
+ def compute_persona_id(profile: Profile) -> str:
59
+ """Return a 12-char hash blending the user's normalised name with their
60
+ identity-defining profile fields. Two users named 'Rohit' but with
61
+ different age/dependents/location resolve to different persona IDs.
62
+
63
+ Returns '' if there's not enough signal (no name AND no identity
64
+ fields). Caller falls back to name-only slug in that case.
65
+
66
+ KI-062 (2026-05-15).
67
+ """
68
+ parts = [_normalise_name(profile.name or "")]
69
+ for f in _PERSONA_ID_FIELDS:
70
+ v = getattr(profile, f, None)
71
+ parts.append("" if v in (None, "", []) else str(v).strip().lower())
72
+ if not any(parts):
73
+ return ""
74
+ blob = "|".join(parts).encode("utf-8")
75
+ return hashlib.sha1(blob).hexdigest()[:12]
76
+
77
+
78
+ def _path_for(name: str, *, persona_id: Optional[str] = None) -> Optional[Path]:
79
+ """Resolve the JSON file path. Prefers persona_id (KI-062) when given,
80
+ falling back to the name slug for legacy lookups."""
81
+ if persona_id:
82
+ return _PROFILES_DIR / f"{persona_id}.json"
83
  slug = _normalise_name(name)
84
  if not slug:
85
  return None
 
98
  return alpha / max(1, len(s)) >= 0.5
99
 
100
 
101
+ def _load_from_path(p: Path) -> Optional[Profile]:
102
+ """Read a profile file path Profile. Drops persisted fields that no
103
+ longer exist on the Profile dataclass (schema-drift safety)."""
 
 
 
 
 
 
104
  try:
105
  raw = json.loads(p.read_text())
106
  except Exception as e:
107
+ logging.warning("profile_store load failed path=%s: %s", p, e)
108
  return None
109
  prof_dict = raw.get("profile") or {}
110
  valid_fields = set(Profile.__dataclass_fields__.keys())
 
112
  try:
113
  return Profile(**prof_dict)
114
  except Exception as e:
115
+ logging.warning("profile_store reconstruct failed path=%s: %s", p, e)
116
  return None
117
 
118
 
119
+ def load_profile(name: str, *, persona_id: Optional[str] = None) -> Optional[Profile]:
120
+ """Return the stored Profile for `name` (and optional `persona_id`).
121
+
122
+ KI-062 (2026-05-15) lookup order:
123
+ 1. If `persona_id` given, try that file first.
124
+ 2. Try the name-slug file (legacy + first-visit path before
125
+ identity fields are known).
126
+ 3. If both miss but the name slug is set, scan the directory for
127
+ any persona-id-keyed file whose stored name matches — handles
128
+ the case where the user introduced themselves by name but no
129
+ persona ID is known yet client-side.
130
+ """
131
+ # 1. Direct persona-id hit
132
+ if persona_id:
133
+ p = _path_for(name, persona_id=persona_id)
134
+ if p and p.exists():
135
+ return _load_from_path(p)
136
+ # 2. Legacy / first-visit name-slug file
137
  p = _path_for(name)
138
+ if p and p.exists():
139
+ return _load_from_path(p)
140
+ # 3. Scan for any persona-id file whose stored display-name matches
141
+ slug = _normalise_name(name)
142
+ if slug and _PROFILES_DIR.exists():
143
+ for cand in _PROFILES_DIR.glob("*.json"):
144
+ try:
145
+ raw = json.loads(cand.read_text())
146
+ if _normalise_name(raw.get("name_display") or "") == slug:
147
+ return _load_from_path(cand)
148
+ except Exception:
149
+ continue
150
+ return None
151
+
152
+
153
+ def save_profile(name: str, profile: Profile, *, session_id: Optional[str] = None) -> bool:
154
+ """Persist `profile`. KI-062 (2026-05-15): files are keyed by
155
+ `compute_persona_id(profile)` when there's enough signal so two users
156
+ named 'Rohit' with different age/location don't overwrite each other.
157
+ Falls back to the name slug when persona_id can't be derived.
158
+ """
159
+ persona_id = compute_persona_id(profile)
160
+ p = _path_for(name, persona_id=persona_id) if persona_id else _path_for(name)
161
  if not p:
162
  return False
163
  try:
 
168
  existing = json.loads(p.read_text())
169
  except Exception:
170
  existing = {}
171
+ # KI-062 — also clean up any older same-name file that was saved
172
+ # before we had enough identity signal to disambiguate. We move
173
+ # its session history into the new file rather than orphaning.
174
+ if persona_id:
175
+ legacy = _path_for(name)
176
+ if legacy and legacy.exists() and legacy.resolve() != p.resolve():
177
+ try:
178
+ leg_raw = json.loads(legacy.read_text())
179
+ legacy_sessions = list(leg_raw.get("sessions") or [])
180
+ existing.setdefault("sessions", [])
181
+ for s in legacy_sessions:
182
+ if s not in existing["sessions"]:
183
+ existing["sessions"].append(s)
184
+ legacy.unlink()
185
+ except Exception:
186
+ pass
187
  now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
188
  sessions = list(existing.get("sessions") or [])
189
  if session_id and session_id not in sessions:
 
192
  payload = {
193
  "name_display": (profile.name or name).strip(),
194
  "name_slug": _normalise_name(name),
195
+ "persona_id": persona_id, # KI-062
196
  "profile": asdict(profile),
197
  "first_seen": existing.get("first_seen") or now_iso,
198
  "last_seen": now_iso,
frontend/src/lib/useLiveConversation.ts CHANGED
@@ -5,6 +5,8 @@
5
  *
6
  * KI-044 (2026-05-14) — PCM pre-roll via AudioWorklet.
7
  * KI-057 (2026-05-15) — Noise-robust VAD + flush-on-stop.
 
 
8
  *
9
  * Why KI-057 was needed
10
  * --------------------------------------------------------------------
@@ -93,7 +95,13 @@ const DEFAULTS = {
93
  // don't open a segment. KI-044's preroll buffer still captures the
94
  // first phoneme since we look back 300 ms.
95
  speechStartFrames: 3,
96
- silenceEndFrames: 40, // ~640 ms of silence to declare utterance end
 
 
 
 
 
 
97
  minUtteranceMs: 400,
98
  // KI-044 — How much pre-trigger PCM we keep in the rolling buffer.
99
  // 300 ms is generous; covers the ~80 ms VAD latency + ~100 ms of
 
5
  *
6
  * KI-044 (2026-05-14) — PCM pre-roll via AudioWorklet.
7
  * KI-057 (2026-05-15) — Noise-robust VAD + flush-on-stop.
8
+ * KI-060 (2026-05-15) — Silence-end window lengthened (40 → 90 frames,
9
+ * ~640 ms → ~1.5 s) so natural mid-sentence pauses don't auto-submit.
10
  *
11
  * Why KI-057 was needed
12
  * --------------------------------------------------------------------
 
95
  // don't open a segment. KI-044's preroll buffer still captures the
96
  // first phoneme since we look back 300 ms.
97
  speechStartFrames: 3,
98
+ // KI-060 (2026-05-15) — bumped 40 90 (~1.5 s of silence) so a
99
+ // natural mid-sentence pause doesn't auto-close the segment.
100
+ // After KI-057 made noise correctly NOT keep the segment alive,
101
+ // the underlying silence-end timer was exposed as too aggressive —
102
+ // users reported that pausing for ~1 s between phrases caused the
103
+ // bot to submit prematurely.
104
+ silenceEndFrames: 90,
105
  minUtteranceMs: 400,
106
  // KI-044 — How much pre-trigger PCM we keep in the rolling buffer.
107
  // 300 ms is generous; covers the ~80 ms VAD latency + ~100 ms of