Spaces:
Sleeping
fix(ux+voice): KI-252 — align profile bar with Path B 7-slot ready gate + RULE 4 implicit confirmation + ZCR scaling
Browse filesRoot-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 +6 -0
- backend/main.py +1 -0
- backend/scorecard.py +19 -10
- backend/single_brain.py +20 -2
- frontend/src/lib/voice_resilience.ts +17 -10
|
@@ -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 |
|
|
@@ -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,
|
|
@@ -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 |
-
#
|
| 608 |
-
# + conditions + location are deep-dives that further refine.
|
| 609 |
weights = {
|
| 610 |
"age": 0.20,
|
| 611 |
-
"dependents": 0.
|
| 612 |
-
"
|
| 613 |
-
"
|
| 614 |
-
"
|
| 615 |
-
"
|
| 616 |
-
"
|
| 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():
|
|
@@ -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 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
@@ -53,8 +53,11 @@ export interface RetryOptions {
|
|
| 53 |
|
| 54 |
/**
|
| 55 |
* Wraps an async transcribe call with up to `maxAttempts` retries on
|
| 56 |
-
* network errors.
|
| 57 |
-
*
|
|
|
|
|
|
|
|
|
|
| 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
|
| 121 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 134 |
-
//
|
| 135 |
-
//
|
| 136 |
-
|
| 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)),
|