rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
bcf9753
·
1 Parent(s): 52e5881

fix(ux+voice): KI-252 — align profile bar with Path B 7-slot ready gate + RULE 4 implicit confirmation + ZCR scaling

Browse files

Root-cause of "70% DONE despite all data" + "Let me pause for a second":

1. Profile completeness bar mismatch (Q2 from user screenshot 59):
- scorecard.py:profile_completeness checked 9 weighted fields:
age 20% + dependents 15% + budget_band 15% + existing_cover_inr 10%
+ primary_goal 10% + location_tier 10% + health_conditions 10%
+ income_band 5% + parents_age_max 5% = 100%.
- Path B's _REQUIRED_FOR_READY = (name, age, dependents, location_tier,
income_band, primary_goal, health_conditions) — 7 slots.
- So a fully captured Path B profile (Priya) shows only 70%:
age (20) + dependents (15) + primary_goal (10) + location_tier (10)
+ health_conditions (10) + income_band (5) = 70%. The 30% gap is
budget_band/existing_cover_inr/parents_age_max that Path B doesn't
gather (intentionally — budget can be inferred from income).
- FIX: re-weighted scorecard.profile_completeness to the 7 Path B slots:
age 20% + dependents 17% + income_band 16% + primary_goal 15% +
location_tier 14% + health_conditions 13% + name 5% = 100%.
- main.py:profile_completeness_view now includes "name" in profile_dict
(was omitted; new key matches the scorecard).

2. RULE 4 wait-for-confirmation too rigid (Q1 from user screenshot 58):
- "Let me pause for a second — could you tell me a bit more..." was
Gemini's OWN polite-stalling output (not our fallback — grep'd code).
- Welcome-back recap asked "Has anything changed?". When user replied
with NEW slots ("18L income, first family policy, no medical issues")
instead of literal "yes", RULE 4's "WAIT for explicit confirmation"
made Gemini hesitate — it didn't treat data-provision as confirmation.
- FIX: added "IMPLICIT CONFIRMATION" subsection to RULE 4 telling Gemini
that providing new fields counts as both (a) confirming the recap +
(b) supplying data. Once 7 slots captured, immediately retrieve.

3. Carryover fixes from U2 + U3 agents:
- voice_resilience.ts scaleSpeechZcrBand formula was inverted —
at fixed fftSize=2048, lower sample rate = longer buffer = MORE ZC.
Confirmed via Kedem 1986 / WebRTC VAD literature. Inverted formula
to REFERENCE_SAMPLE_RATE / actualSampleRate. 16kHz now gets ~60..750
band (3× 48kHz's 20..250). Practical: barge-in works on older
Android / Bluetooth devices.
- voice_resilience.ts retryPostTranscribe docblock corrected from
"1s/2s/4s, max 7s" to accurate "1s+2s, ~3s pre-attempt waits".
- single_brain.py RULE 1 gained gender no-op note (don't waste
iterations calling save_profile_field for gender).
- admin.py: ADMIN_IP_ALLOWLIST env unset on HF Space; TODO comment
added in _check_admin (no logic change, password-gated remains).

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

backend/admin.py CHANGED
@@ -72,6 +72,12 @@ def _check_admin(request: Request, password: Optional[str]) -> None:
72
  function no longer inspects the client IP. Earlier dual-gate behavior
73
  (IP allowlist + 404-to-hide-existence) was removed in KI-097.
74
  """
 
 
 
 
 
 
75
  if not _password_ok(password):
76
  raise HTTPException(status_code=401, detail="Unauthorized")
77
 
 
72
  function no longer inspects the client IP. Earlier dual-gate behavior
73
  (IP allowlist + 404-to-hide-existence) was removed in KI-097.
74
  """
75
+ # TODO: enforce IP allowlist for hardening — verified 2026-05-15 that
76
+ # ADMIN_IP_ALLOWLIST is not configured on the HF Space (rohitsar567/
77
+ # InsuranceBot). Gate is password-only, which is acceptable for the
78
+ # current threat model per KI-097, but a future hardening pass should
79
+ # re-introduce an IP allowlist as a second factor (with a documented
80
+ # break-glass procedure so a network change doesn't lock ops out).
81
  if not _password_ok(password):
82
  raise HTTPException(status_code=401, detail="Unauthorized")
83
 
backend/main.py CHANGED
@@ -1429,6 +1429,7 @@ async def profile_completeness_view(session_id: Optional[str] = None):
1429
  sess = get_session(session_id)
1430
  p = sess.profile
1431
  profile_dict = {
 
1432
  "age": p.age, "dependents": p.dependents, "income_band": p.income_band,
1433
  "existing_cover_inr": p.existing_cover_inr, "primary_goal": p.primary_goal,
1434
  "location_tier": p.location_tier, "parents_to_insure": p.parents_to_insure,
 
1429
  sess = get_session(session_id)
1430
  p = sess.profile
1431
  profile_dict = {
1432
+ "name": p.name, # KI-252 — Path B 7-slot alignment with completeness bar
1433
  "age": p.age, "dependents": p.dependents, "income_band": p.income_band,
1434
  "existing_cover_inr": p.existing_cover_inr, "primary_goal": p.primary_goal,
1435
  "location_tier": p.location_tier, "parents_to_insure": p.parents_to_insure,
backend/scorecard.py CHANGED
@@ -597,6 +597,18 @@ def _profile_tuned_weights(profile: Optional[dict]) -> dict[str, float]:
597
  def profile_completeness(profile: Optional[dict]) -> float:
598
  """0.0–1.0 measure of how much we know about the buyer.
599
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  Used by the frontend to GATE the personalized scorecard view — until
601
  completeness >= 0.6, we show insurer-level metrics (CSR, complaints —
602
  universal) but suppress the per-user grade since it's meaningless without
@@ -604,18 +616,15 @@ def profile_completeness(profile: Optional[dict]) -> float:
604
  """
605
  if not profile:
606
  return 0.0
607
- # Weighted by signal importance: age + dependents + budget are core; goal
608
- # + conditions + location are deep-dives that further refine.
609
  weights = {
610
  "age": 0.20,
611
- "dependents": 0.15,
612
- "budget_band": 0.15,
613
- "existing_cover_inr": 0.10,
614
- "primary_goal": 0.10,
615
- "location_tier": 0.10,
616
- "health_conditions": 0.10,
617
- "income_band": 0.05,
618
- "parents_age_max": 0.05,
619
  }
620
  total = 0.0
621
  for field_name, weight in weights.items():
 
597
  def profile_completeness(profile: Optional[dict]) -> float:
598
  """0.0–1.0 measure of how much we know about the buyer.
599
 
600
+ KI-252 (2026-05-15): aligned with Path B's `_REQUIRED_FOR_READY` 7-slot
601
+ list (see brain_tools.py + single_brain.py). Previously this checked 9
602
+ weighted fields including budget_band / existing_cover_inr /
603
+ parents_age_max which Path B doesn't gather, causing the UI bar to read
604
+ 70% while Path B already considered the profile "ready to recommend".
605
+
606
+ The 7 slots: name, age, dependents, location_tier, income_band,
607
+ primary_goal, health_conditions. `name` is the identifier; the other 6
608
+ are decision-critical for retrieval. Existing-cover and budget-band are
609
+ captured opportunistically (Rajesh provided existing_cover_inr in turn 1)
610
+ but are NOT required to recommend.
611
+
612
  Used by the frontend to GATE the personalized scorecard view — until
613
  completeness >= 0.6, we show insurer-level metrics (CSR, complaints —
614
  universal) but suppress the per-user grade since it's meaningless without
 
616
  """
617
  if not profile:
618
  return 0.0
619
+ # Weights align with Path B _REQUIRED_FOR_READY. Sum = 1.0.
 
620
  weights = {
621
  "age": 0.20,
622
+ "dependents": 0.17,
623
+ "income_band": 0.16,
624
+ "primary_goal": 0.15,
625
+ "location_tier": 0.14,
626
+ "health_conditions": 0.13,
627
+ "name": 0.05,
 
 
628
  }
629
  total = 0.0
630
  for field_name, weight in weights.items():
backend/single_brain.py CHANGED
@@ -124,6 +124,11 @@ of these facts and call save_profile_field ONCE PER FACT:
124
  ← MANDATORY even though it's a negation. "none" tells the system the slot is captured.
125
  Without this call the profile stays incomplete forever and the bot loops asking for PED.
126
 
 
 
 
 
 
127
  Worked example A. User says: "Hi I'm Priya, 34, Bangalore, with husband and one kid"
128
  → You MUST call:
129
  save_profile_field(field="name", value="Priya")
@@ -175,8 +180,21 @@ reply MUST:
175
  dependents, primary_goal, health_conditions).
176
  3. Ask: "Has anything changed since last time, or should we go with this
177
  profile?"
178
- 4. WAIT for explicit user confirmation BEFORE calling retrieve_policies.
179
- Do NOT skip the confirmation step even if all 7 slots look complete.
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
  ═══════════════════════════════════════════════════════════
182
  RULE 5 — Comparison view ("compare #1 and #3")
 
124
  ← MANDATORY even though it's a negation. "none" tells the system the slot is captured.
125
  Without this call the profile stays incomplete forever and the bot loops asking for PED.
126
 
127
+ NOT-ON-PROFILE FIELDS (do NOT call save_profile_field for these):
128
+ • gender — the system does NOT track gender. save_profile_field will reject it
129
+ with field_not_on_profile_dataclass and waste a tool-call iteration. Just
130
+ remember it for conversational context and continue.
131
+
132
  Worked example A. User says: "Hi I'm Priya, 34, Bangalore, with husband and one kid"
133
  → You MUST call:
134
  save_profile_field(field="name", value="Priya")
 
180
  dependents, primary_goal, health_conditions).
181
  3. Ask: "Has anything changed since last time, or should we go with this
182
  profile?"
183
+
184
+ IMPLICIT CONFIRMATION (KI-252 DO NOT MISS THIS):
185
+ If the user's NEXT message provides ANY new profile fields (e.g. "Around
186
+ 18 lakh income, no medical issues, first family policy"), that counts as
187
+ BOTH (a) implicit confirmation of the recap AND (b) provision of the new
188
+ fields. Your flow on that turn:
189
+ i. Call save_profile_field once per new slot the user mentioned.
190
+ ii. IF all 7 required slots are now captured: IMMEDIATELY call
191
+ retrieve_policies and produce recommendations. DO NOT ask "are you
192
+ sure?" again — the user already confirmed by providing data.
193
+ iii. IF some slots are still missing: ask for the next missing slot
194
+ only, do NOT re-confirm what they just provided.
195
+
196
+ Explicit confirmation is only required when the user's reply is a literal
197
+ "yes/no/that's right" with no new data. Bypass the WAIT in any other case.
198
 
199
  ═══════════════════════════════════════════════════════════
200
  RULE 5 — Comparison view ("compare #1 and #3")
frontend/src/lib/voice_resilience.ts CHANGED
@@ -53,8 +53,11 @@ export interface RetryOptions {
53
 
54
  /**
55
  * Wraps an async transcribe call with up to `maxAttempts` retries on
56
- * network errors. Backs off 1s 2s → 4s. Aborts propagate immediately
57
- * (we don't retry a user-initiated abort).
 
 
 
58
  *
59
  * The caller passes a thunk that performs the actual POST. The thunk MUST
60
  * accept its own AbortSignal so each attempt can be individually timed
@@ -117,8 +120,14 @@ export async function retryPostTranscribe<T>(
117
  // ---------------------------------------------------------------------------
118
  // V1.3 — sample-rate-aware ZCR band.
119
  // The original VAD assumes fftSize=2048 @ 48 kHz, where speech ZCR sits in
120
- // ~20..250 zero crossings per buffer. At 16 kHz the same 2048-sample window
121
- // covers 3x as long in time ZCR counts scale by sampleRate/48000.
 
 
 
 
 
 
122
  //
123
  // We expose a helper so the hook can compute the band at AudioContext init.
124
  // ---------------------------------------------------------------------------
@@ -130,12 +139,10 @@ export function scaleSpeechZcrBand(actualSampleRate: number): { min: number; max
130
  if (!actualSampleRate || actualSampleRate <= 0) {
131
  return { min: REFERENCE_ZCR_MIN, max: REFERENCE_ZCR_MAX };
132
  }
133
- // ZCR scales linearly with the time-window length per buffer at fixed
134
- // fftSize, which scales inversely with sampleRate. So a SHORTER window
135
- // (higher rate) sees PROPORTIONALLY fewer crossings but the per-second
136
- // speech crossing rate is roughly constant. Net: the per-buffer count
137
- // scales linearly with sampleRate.
138
- const ratio = actualSampleRate / REFERENCE_SAMPLE_RATE;
139
  return {
140
  min: Math.max(1, Math.round(REFERENCE_ZCR_MIN * ratio)),
141
  max: Math.max(REFERENCE_ZCR_MIN + 1, Math.round(REFERENCE_ZCR_MAX * ratio)),
 
53
 
54
  /**
55
  * Wraps an async transcribe call with up to `maxAttempts` retries on
56
+ * network errors. With defaults (maxAttempts=3, baseDelayMs=1000) the
57
+ * loop performs 3 attempts separated by exponential pre-attempt delays
58
+ * of 1s then 2s — max ~3s of additional wait across the run. (Delays
59
+ * sit BETWEEN attempts; no delay follows the final attempt.) Aborts
60
+ * propagate immediately (we don't retry a user-initiated abort).
61
  *
62
  * The caller passes a thunk that performs the actual POST. The thunk MUST
63
  * accept its own AbortSignal so each attempt can be individually timed
 
120
  // ---------------------------------------------------------------------------
121
  // V1.3 — sample-rate-aware ZCR band.
122
  // The original VAD assumes fftSize=2048 @ 48 kHz, where speech ZCR sits in
123
+ // ~20..250 zero crossings per 2048-sample buffer. At a fixed fftSize the
124
+ // buffer's TIME duration = fftSize / sampleRate, so a 16 kHz buffer covers
125
+ // 128 ms (vs 48 kHz's 42.7 ms — 3× longer). Speech ZCR per SECOND is roughly
126
+ // constant for a given phoneme class (cf. Kedem 1986 "Spectral analysis and
127
+ // discrimination by zero-crossings"; Bachu et al. "Separation of Voiced and
128
+ // Unvoiced using Zero Crossing Rate"; WebRTC VAD per-rate feature tuning),
129
+ // so a longer-duration window observes MORE crossings, not fewer. Net: the
130
+ // per-buffer count scales INVERSELY with sampleRate (ratio = 48000 / actual).
131
  //
132
  // We expose a helper so the hook can compute the band at AudioContext init.
133
  // ---------------------------------------------------------------------------
 
139
  if (!actualSampleRate || actualSampleRate <= 0) {
140
  return { min: REFERENCE_ZCR_MIN, max: REFERENCE_ZCR_MAX };
141
  }
142
+ // Per-buffer ZCR = ZCR_per_second * (fftSize / sampleRate). With fftSize
143
+ // fixed, per-buffer count scales as 1/sampleRate. So at 16 kHz we expect
144
+ // ~3× the crossings seen at 48 kHz for the same speech signal.
145
+ const ratio = REFERENCE_SAMPLE_RATE / actualSampleRate;
 
 
146
  return {
147
  min: Math.max(1, Math.round(REFERENCE_ZCR_MIN * ratio)),
148
  max: Math.max(REFERENCE_ZCR_MIN + 1, Math.round(REFERENCE_ZCR_MAX * ratio)),