rohitsar567 commited on
Commit
a62dd7c
Β·
verified Β·
1 Parent(s): e37857a

Deploy: Stack A (NIM brain + Maverick judge + Sarvam voice). D-019.

Browse files
.pytest_cache/v/cache/nodeids CHANGED
@@ -1,4 +1,18 @@
1
  [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  "tests/test_bug69_71_conversation_logic.py::TestBug69EndToEnd::test_fallback_path_when_name_only_missing_is_not_a_no_match",
3
  "tests/test_bug69_71_conversation_logic.py::TestBug69EndToEnd::test_name_requested_early_and_missing_name_not_a_no_match",
4
  "tests/test_bug69_71_conversation_logic.py::TestBug69NameFirstDeterministic::test_canonical_slot_order_puts_name_first",
 
1
  [
2
+ "tests/test_bug107_108_109_110_factfind.py::TestBug107NoCitationsOnFactFind::test_recommendation_turn_with_complete_profile_still_cites",
3
+ "tests/test_bug107_108_109_110_factfind.py::TestBug107NoCitationsOnFactFind::test_speculative_retrieve_then_clarifying_question_has_no_cards",
4
+ "tests/test_bug107_108_109_110_factfind.py::TestBug108PartialAnswerReask::test_explicit_skip_bypasses_the_reask_gate",
5
+ "tests/test_bug107_108_109_110_factfind.py::TestBug108PartialAnswerReask::test_explicit_skip_resolves_the_whole_bundle",
6
+ "tests/test_bug107_108_109_110_factfind.py::TestBug108PartialAnswerReask::test_partial_answer_triggers_one_shot_reask_then_proceeds",
7
+ "tests/test_bug107_108_109_110_factfind.py::TestBug108PartialAnswerReask::test_skip_phrase_detector",
8
+ "tests/test_bug107_108_109_110_factfind.py::TestBug108PartialAnswerReask::test_unresolved_bundle_includes_family_medical_history",
9
+ "tests/test_bug107_108_109_110_factfind.py::TestBug109BudgetBandPersistence::test_canonical_band_passes_through",
10
+ "tests/test_bug107_108_109_110_factfind.py::TestBug109BudgetBandPersistence::test_numeric_string_maps_to_band",
11
+ "tests/test_bug107_108_109_110_factfind.py::TestBug109BudgetBandPersistence::test_numeric_value_maps_to_band",
12
+ "tests/test_bug107_108_109_110_factfind.py::TestBug109BudgetBandPersistence::test_save_profile_field_persists_band_not_raw",
13
+ "tests/test_bug107_108_109_110_factfind.py::TestBug109BudgetBandPersistence::test_unparseable_returns_none_not_raw",
14
+ "tests/test_bug107_108_109_110_factfind.py::TestBug110FamilyHistoryInPlan::test_family_history_is_in_the_bundle_questions",
15
+ "tests/test_bug107_108_109_110_factfind.py::TestBug110FamilyHistoryInPlan::test_system_prompt_asks_family_medical_history",
16
  "tests/test_bug69_71_conversation_logic.py::TestBug69EndToEnd::test_fallback_path_when_name_only_missing_is_not_a_no_match",
17
  "tests/test_bug69_71_conversation_logic.py::TestBug69EndToEnd::test_name_requested_early_and_missing_name_not_a_no_match",
18
  "tests/test_bug69_71_conversation_logic.py::TestBug69NameFirstDeterministic::test_canonical_slot_order_puts_name_first",
40-data/llm_health.json CHANGED
@@ -1,45 +1,45 @@
1
  {
2
- "updated_at": "2026-05-16T12:56:33Z",
3
  "models": {
4
  "qwen/qwen3-next-80b-a3b-instruct": {
5
  "model": "qwen/qwen3-next-80b-a3b-instruct",
6
  "status": "healthy",
7
- "last_success_at": "2026-05-16T12:56:33Z",
8
- "last_failure_at": "2026-05-16T12:51:28Z",
9
  "last_error": null,
10
  "last_status_code": null,
11
- "latency_ms": 640,
12
  "consecutive_failures": 0,
13
- "tested_at": "2026-05-16T12:56:33Z",
14
  "probe_history": [
15
  {
16
  "ok": true,
17
- "latency_ms": 660,
18
- "ts": "2026-05-16T12:35:52Z",
19
  "src": "probe"
20
  },
21
  {
22
- "ok": true,
23
- "latency_ms": 1745,
24
- "ts": "2026-05-16T12:40:55Z",
25
  "src": "probe"
26
  },
27
  {
28
- "ok": true,
29
- "latency_ms": 4868,
30
- "ts": "2026-05-16T12:46:10Z",
31
  "src": "probe"
32
  },
33
  {
34
  "ok": false,
35
- "latency_ms": 8075,
36
- "ts": "2026-05-16T12:51:28Z",
37
  "src": "probe"
38
  },
39
  {
40
  "ok": true,
41
- "latency_ms": 640,
42
- "ts": "2026-05-16T12:56:33Z",
43
  "src": "probe"
44
  }
45
  ],
@@ -89,42 +89,42 @@
89
  "mistralai/mistral-large-3-675b-instruct-2512": {
90
  "model": "mistralai/mistral-large-3-675b-instruct-2512",
91
  "status": "healthy",
92
- "last_success_at": "2026-05-16T12:56:33Z",
93
- "last_failure_at": "2026-05-16T12:51:28Z",
94
  "last_error": null,
95
  "last_status_code": null,
96
- "latency_ms": 717,
97
  "consecutive_failures": 0,
98
- "tested_at": "2026-05-16T12:56:33Z",
99
  "probe_history": [
100
  {
101
  "ok": true,
102
- "latency_ms": 676,
103
- "ts": "2026-05-16T12:35:52Z",
104
  "src": "probe"
105
  },
106
  {
107
- "ok": true,
108
- "latency_ms": 739,
109
- "ts": "2026-05-16T12:40:55Z",
110
  "src": "probe"
111
  },
112
  {
113
- "ok": false,
114
- "latency_ms": 8003,
115
- "ts": "2026-05-16T12:46:10Z",
116
  "src": "probe"
117
  },
118
  {
119
- "ok": false,
120
- "latency_ms": 8065,
121
- "ts": "2026-05-16T12:51:28Z",
122
  "src": "probe"
123
  },
124
  {
125
  "ok": true,
126
- "latency_ms": 717,
127
- "ts": "2026-05-16T12:56:33Z",
128
  "src": "probe"
129
  }
130
  ],
@@ -138,42 +138,42 @@
138
  "nvidia/llama-3.3-nemotron-super-49b-v1.5": {
139
  "model": "nvidia/llama-3.3-nemotron-super-49b-v1.5",
140
  "status": "healthy",
141
- "last_success_at": "2026-05-16T12:56:33Z",
142
- "last_failure_at": "2026-05-16T12:25:35Z",
143
  "last_error": null,
144
  "last_status_code": null,
145
- "latency_ms": 412,
146
  "consecutive_failures": 0,
147
- "tested_at": "2026-05-16T12:56:33Z",
148
  "probe_history": [
149
  {
150
  "ok": true,
151
- "latency_ms": 359,
152
- "ts": "2026-05-16T12:35:52Z",
153
  "src": "probe"
154
  },
155
  {
156
  "ok": true,
157
- "latency_ms": 380,
158
- "ts": "2026-05-16T12:40:55Z",
159
  "src": "probe"
160
  },
161
  {
162
- "ok": true,
163
- "latency_ms": 354,
164
- "ts": "2026-05-16T12:46:10Z",
165
  "src": "probe"
166
  },
167
  {
168
  "ok": true,
169
- "latency_ms": 306,
170
- "ts": "2026-05-16T12:51:28Z",
171
  "src": "probe"
172
  },
173
  {
174
  "ok": true,
175
- "latency_ms": 412,
176
- "ts": "2026-05-16T12:56:33Z",
177
  "src": "probe"
178
  }
179
  ],
@@ -295,42 +295,42 @@
295
  "meta/llama-4-maverick-17b-128e-instruct": {
296
  "model": "meta/llama-4-maverick-17b-128e-instruct",
297
  "status": "healthy",
298
- "last_success_at": "2026-05-16T12:56:33Z",
299
  "last_failure_at": "2026-05-16T11:43:51Z",
300
  "last_error": null,
301
  "last_status_code": null,
302
- "latency_ms": 2559,
303
  "consecutive_failures": 0,
304
- "tested_at": "2026-05-16T12:56:33Z",
305
  "probe_history": [
306
  {
307
  "ok": true,
308
- "latency_ms": 212,
309
- "ts": "2026-05-16T12:35:52Z",
310
  "src": "probe"
311
  },
312
  {
313
  "ok": true,
314
- "latency_ms": 228,
315
- "ts": "2026-05-16T12:40:55Z",
316
  "src": "probe"
317
  },
318
  {
319
  "ok": true,
320
- "latency_ms": 1797,
321
- "ts": "2026-05-16T12:46:10Z",
322
  "src": "probe"
323
  },
324
  {
325
  "ok": true,
326
- "latency_ms": 1637,
327
- "ts": "2026-05-16T12:51:28Z",
328
  "src": "probe"
329
  },
330
  {
331
  "ok": true,
332
- "latency_ms": 2559,
333
- "ts": "2026-05-16T12:56:33Z",
334
  "src": "probe"
335
  }
336
  ],
@@ -356,7 +356,7 @@
356
  "credits_remaining": 32.802310819,
357
  "credits_unit": "usd_balance",
358
  "credits_reset_at": null,
359
- "credits_observed_at": 85053.044581083,
360
  "credits_low_water": 0.05
361
  },
362
  "groq:llama-3.3-70b-versatile": {
 
1
  {
2
+ "updated_at": "2026-05-16T13:17:20Z",
3
  "models": {
4
  "qwen/qwen3-next-80b-a3b-instruct": {
5
  "model": "qwen/qwen3-next-80b-a3b-instruct",
6
  "status": "healthy",
7
+ "last_success_at": "2026-05-16T13:17:20Z",
8
+ "last_failure_at": "2026-05-16T13:12:18Z",
9
  "last_error": null,
10
  "last_status_code": null,
11
+ "latency_ms": 467,
12
  "consecutive_failures": 0,
13
+ "tested_at": "2026-05-16T13:17:20Z",
14
  "probe_history": [
15
  {
16
  "ok": true,
17
+ "latency_ms": 640,
18
+ "ts": "2026-05-16T12:56:33Z",
19
  "src": "probe"
20
  },
21
  {
22
+ "ok": false,
23
+ "latency_ms": 8061,
24
+ "ts": "2026-05-16T13:01:51Z",
25
  "src": "probe"
26
  },
27
  {
28
+ "ok": false,
29
+ "latency_ms": 8070,
30
+ "ts": "2026-05-16T13:07:09Z",
31
  "src": "probe"
32
  },
33
  {
34
  "ok": false,
35
+ "latency_ms": 8065,
36
+ "ts": "2026-05-16T13:12:18Z",
37
  "src": "probe"
38
  },
39
  {
40
  "ok": true,
41
+ "latency_ms": 467,
42
+ "ts": "2026-05-16T13:17:20Z",
43
  "src": "probe"
44
  }
45
  ],
 
89
  "mistralai/mistral-large-3-675b-instruct-2512": {
90
  "model": "mistralai/mistral-large-3-675b-instruct-2512",
91
  "status": "healthy",
92
+ "last_success_at": "2026-05-16T13:17:20Z",
93
+ "last_failure_at": "2026-05-16T13:01:51Z",
94
  "last_error": null,
95
  "last_status_code": null,
96
+ "latency_ms": 379,
97
  "consecutive_failures": 0,
98
+ "tested_at": "2026-05-16T13:17:20Z",
99
  "probe_history": [
100
  {
101
  "ok": true,
102
+ "latency_ms": 717,
103
+ "ts": "2026-05-16T12:56:33Z",
104
  "src": "probe"
105
  },
106
  {
107
+ "ok": false,
108
+ "latency_ms": 8061,
109
+ "ts": "2026-05-16T13:01:51Z",
110
  "src": "probe"
111
  },
112
  {
113
+ "ok": true,
114
+ "latency_ms": 897,
115
+ "ts": "2026-05-16T13:07:09Z",
116
  "src": "probe"
117
  },
118
  {
119
+ "ok": true,
120
+ "latency_ms": 638,
121
+ "ts": "2026-05-16T13:12:18Z",
122
  "src": "probe"
123
  },
124
  {
125
  "ok": true,
126
+ "latency_ms": 379,
127
+ "ts": "2026-05-16T13:17:20Z",
128
  "src": "probe"
129
  }
130
  ],
 
138
  "nvidia/llama-3.3-nemotron-super-49b-v1.5": {
139
  "model": "nvidia/llama-3.3-nemotron-super-49b-v1.5",
140
  "status": "healthy",
141
+ "last_success_at": "2026-05-16T13:17:20Z",
142
+ "last_failure_at": "2026-05-16T13:07:09Z",
143
  "last_error": null,
144
  "last_status_code": null,
145
+ "latency_ms": 307,
146
  "consecutive_failures": 0,
147
+ "tested_at": "2026-05-16T13:17:20Z",
148
  "probe_history": [
149
  {
150
  "ok": true,
151
+ "latency_ms": 412,
152
+ "ts": "2026-05-16T12:56:33Z",
153
  "src": "probe"
154
  },
155
  {
156
  "ok": true,
157
+ "latency_ms": 314,
158
+ "ts": "2026-05-16T13:01:51Z",
159
  "src": "probe"
160
  },
161
  {
162
+ "ok": false,
163
+ "latency_ms": 8002,
164
+ "ts": "2026-05-16T13:07:09Z",
165
  "src": "probe"
166
  },
167
  {
168
  "ok": true,
169
+ "latency_ms": 374,
170
+ "ts": "2026-05-16T13:12:18Z",
171
  "src": "probe"
172
  },
173
  {
174
  "ok": true,
175
+ "latency_ms": 307,
176
+ "ts": "2026-05-16T13:17:20Z",
177
  "src": "probe"
178
  }
179
  ],
 
295
  "meta/llama-4-maverick-17b-128e-instruct": {
296
  "model": "meta/llama-4-maverick-17b-128e-instruct",
297
  "status": "healthy",
298
+ "last_success_at": "2026-05-16T13:17:20Z",
299
  "last_failure_at": "2026-05-16T11:43:51Z",
300
  "last_error": null,
301
  "last_status_code": null,
302
+ "latency_ms": 714,
303
  "consecutive_failures": 0,
304
+ "tested_at": "2026-05-16T13:17:20Z",
305
  "probe_history": [
306
  {
307
  "ok": true,
308
+ "latency_ms": 2559,
309
+ "ts": "2026-05-16T12:56:33Z",
310
  "src": "probe"
311
  },
312
  {
313
  "ok": true,
314
+ "latency_ms": 1191,
315
+ "ts": "2026-05-16T13:01:51Z",
316
  "src": "probe"
317
  },
318
  {
319
  "ok": true,
320
+ "latency_ms": 572,
321
+ "ts": "2026-05-16T13:07:09Z",
322
  "src": "probe"
323
  },
324
  {
325
  "ok": true,
326
+ "latency_ms": 239,
327
+ "ts": "2026-05-16T13:12:18Z",
328
  "src": "probe"
329
  },
330
  {
331
  "ok": true,
332
+ "latency_ms": 714,
333
+ "ts": "2026-05-16T13:17:20Z",
334
  "src": "probe"
335
  }
336
  ],
 
356
  "credits_remaining": 32.802310819,
357
  "credits_unit": "usd_balance",
358
  "credits_reset_at": null,
359
+ "credits_observed_at": 86302.751813875,
360
  "credits_low_water": 0.05
361
  },
362
  "groq:llama-3.3-70b-versatile": {
40-data/profiles/asha.json ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name_display": "Asha",
3
+ "name_slug": "asha",
4
+ "persona_id": "ca28dcb8ed8b",
5
+ "profile": {
6
+ "name": "Asha",
7
+ "age": 35,
8
+ "dependents": "self+spouse",
9
+ "income_band": "10L-25L",
10
+ "existing_cover_inr": null,
11
+ "primary_goal": "first_buy",
12
+ "location_tier": "metro",
13
+ "parents_to_insure": null,
14
+ "parents_age_max": null,
15
+ "parents_has_ped": null,
16
+ "budget_band": null,
17
+ "desired_sum_insured_inr": null,
18
+ "health_conditions": [
19
+ "none"
20
+ ],
21
+ "copay_pct": 0,
22
+ "family_medical_history": [],
23
+ "smoker": null,
24
+ "asked": [
25
+ "copay_pct"
26
+ ],
27
+ "free_form_session": false,
28
+ "shown_policies": [
29
+ {
30
+ "policy_slug": "royal-sundaram__multiplier",
31
+ "insurer": "royal-sundaram",
32
+ "event_at": "2026-05-16T13:20:31Z",
33
+ "session_id": "t_f4a3f58c",
34
+ "reason": "shown_in_recommendation",
35
+ "turn_idx": 1
36
+ },
37
+ {
38
+ "policy_slug": "niva-bupa__reassure-3",
39
+ "insurer": "niva-bupa",
40
+ "event_at": "2026-05-16T13:20:31Z",
41
+ "session_id": "t_f4a3f58c",
42
+ "reason": "shown_in_recommendation",
43
+ "turn_idx": 2
44
+ },
45
+ {
46
+ "policy_slug": "care__supreme",
47
+ "insurer": "care",
48
+ "event_at": "2026-05-16T13:20:31Z",
49
+ "session_id": "t_f4a3f58c",
50
+ "reason": "shown_in_recommendation",
51
+ "turn_idx": 2
52
+ }
53
+ ],
54
+ "selected_policies": [],
55
+ "rejected_policies": []
56
+ },
57
+ "first_seen": "2026-05-16T13:12:45Z",
58
+ "last_seen": "2026-05-16T13:20:31Z",
59
+ "sessions": [
60
+ "t_b09c3434",
61
+ "t_2e2054e1",
62
+ "t_bc4f7b3c",
63
+ "t_65645f0d",
64
+ "t_f0c70441",
65
+ "t_f53db2f1",
66
+ "t_b2bc5ac3",
67
+ "t_21220cc0",
68
+ "t_5adb43c6",
69
+ "t_759a9562",
70
+ "t_01ae863a",
71
+ "t_4537aa0b",
72
+ "t_e3dd60f1",
73
+ "t_ea1bc2b9",
74
+ "t_fd68124b",
75
+ "t_aaa5d82e",
76
+ "t_f16a65ad",
77
+ "t_3ba18b4a",
78
+ "t_9d8b0680",
79
+ "t_f4a3f58c"
80
+ ],
81
+ "recall_pointer": true,
82
+ "points_to_persona_id": "ca28dcb8ed8b"
83
+ }
40-data/profiles/ca28dcb8ed8b.json ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name_display": "Asha",
3
+ "name_slug": "asha",
4
+ "persona_id": "ca28dcb8ed8b",
5
+ "profile": {
6
+ "name": "Asha",
7
+ "age": 35,
8
+ "dependents": "self+spouse",
9
+ "income_band": "10L-25L",
10
+ "existing_cover_inr": null,
11
+ "primary_goal": "first_buy",
12
+ "location_tier": "metro",
13
+ "parents_to_insure": null,
14
+ "parents_age_max": null,
15
+ "parents_has_ped": null,
16
+ "budget_band": null,
17
+ "desired_sum_insured_inr": null,
18
+ "health_conditions": [
19
+ "none"
20
+ ],
21
+ "copay_pct": 0,
22
+ "family_medical_history": [],
23
+ "smoker": null,
24
+ "asked": [
25
+ "copay_pct"
26
+ ],
27
+ "free_form_session": false,
28
+ "shown_policies": [
29
+ {
30
+ "policy_slug": "royal-sundaram__multiplier",
31
+ "insurer": "royal-sundaram",
32
+ "event_at": "2026-05-16T13:20:31Z",
33
+ "session_id": "t_f4a3f58c",
34
+ "reason": "shown_in_recommendation",
35
+ "turn_idx": 1
36
+ },
37
+ {
38
+ "policy_slug": "niva-bupa__reassure-3",
39
+ "insurer": "niva-bupa",
40
+ "event_at": "2026-05-16T13:20:31Z",
41
+ "session_id": "t_f4a3f58c",
42
+ "reason": "shown_in_recommendation",
43
+ "turn_idx": 2
44
+ },
45
+ {
46
+ "policy_slug": "care__supreme",
47
+ "insurer": "care",
48
+ "event_at": "2026-05-16T13:20:31Z",
49
+ "session_id": "t_f4a3f58c",
50
+ "reason": "shown_in_recommendation",
51
+ "turn_idx": 2
52
+ }
53
+ ],
54
+ "selected_policies": [],
55
+ "rejected_policies": []
56
+ },
57
+ "first_seen": "2026-05-16T13:12:45Z",
58
+ "last_seen": "2026-05-16T13:20:31Z",
59
+ "sessions": [
60
+ "t_b09c3434",
61
+ "t_2e2054e1",
62
+ "t_bc4f7b3c",
63
+ "t_65645f0d",
64
+ "t_f0c70441",
65
+ "t_f53db2f1",
66
+ "t_b2bc5ac3",
67
+ "t_21220cc0",
68
+ "t_5adb43c6",
69
+ "t_759a9562",
70
+ "t_01ae863a",
71
+ "t_4537aa0b",
72
+ "t_e3dd60f1",
73
+ "t_ea1bc2b9",
74
+ "t_fd68124b",
75
+ "t_aaa5d82e",
76
+ "t_f16a65ad",
77
+ "t_3ba18b4a",
78
+ "t_9d8b0680",
79
+ "t_f4a3f58c"
80
+ ]
81
+ }
backend/brain_tools.py CHANGED
@@ -290,6 +290,100 @@ _REQUIRED_FOR_READY = (
290
  )
291
 
292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  # ───────────────────────────────────────────────────────────────────────────
294
  # SLOT_UNION (B6, 2026-05-15)
295
  # ───────────────────────────────────────────────────────────────────────────
@@ -419,6 +513,8 @@ def save_profile_field(session, field: str, value: Any) -> dict:
419
  normalized = _coerce_family_medical_history(value)
420
  elif fld == "smoker":
421
  normalized = _coerce_smoker(value)
 
 
422
  elif fld == "name":
423
  normalized = (str(value).strip() if value is not None else None) or None
424
  elif fld == "gender":
@@ -434,7 +530,8 @@ def save_profile_field(session, field: str, value: Any) -> dict:
434
  "profile_complete": _profile_complete(profile),
435
  }
436
  else:
437
- # location_tier / income_band / primary_goal / budget_band β€” pass through
 
438
  normalized = (str(value).strip() if value is not None else None) or None
439
  except (TypeError, ValueError) as e:
440
  return {
@@ -564,6 +661,63 @@ async def retrieve_policies(
564
  ),
565
  }
566
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
567
  try:
568
  from rag.retrieve import retrieve as _retrieve
569
 
@@ -924,6 +1078,92 @@ def _coerce_existing_cover(value: Any) -> Optional[int]:
924
  return None
925
 
926
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
927
  def _coerce_bool(value: Any) -> Optional[bool]:
928
  """Tri-state bool coercion for parents_to_insure / parents_has_ped.
929
 
 
290
  )
291
 
292
 
293
+ # ───────────────────────────────────────────────────────────────────────────
294
+ # Bug #108 + #110 (2026-05-16) β€” POST-RECAP pricing & family-history bundle.
295
+ #
296
+ # Bug #108: the RULE 2.5 bundle (SI / budget / co-pay / family history /
297
+ # smoker) is asked in ONE prompt. When the user answers SOME but
298
+ # not all, the LLM treated the bundle as fully satisfied (it's
299
+ # SOFT capture) and recommended with the unanswered slot blank β€”
300
+ # e.g. asked SI/budget/co-pay/smoker, user gave SI/budget/co-pay,
301
+ # smoker never re-asked, bot recommended.
302
+ # Bug #110: family_medical_history (item 5 of the bundle) was the slot most
303
+ # often silently dropped β€” the fact-find effectively NEVER asked it.
304
+ #
305
+ # FIX: a deterministic ONE-SHOT re-ask gate. After the hard 7-slot gate
306
+ # passes, on a recommendation retrieve_policies call, if any UNRESOLVED
307
+ # bundle slot remains we return a directive `pricing_inputs_incomplete`
308
+ # response (re-ask exactly the unresolved ones, verbatim) β€” but ONLY once
309
+ # per session, and ONLY when the user has not explicitly skipped. After the
310
+ # single re-ask we proceed (SOFT-capture semantics preserved: the user can
311
+ # still skip; we never hard-loop).
312
+ #
313
+ # A bundle slot is RESOLVED when it is either captured on the profile OR the
314
+ # user explicitly declined/skipped it (session.pricing_bundle_skipped β€” set
315
+ # by single_brain when the user says "just show me options" / "skip" / "you
316
+ # decide"). parents_age_max is conditional: only part of the bundle when the
317
+ # user is covering parents and it isn't captured yet.
318
+ _PRICING_BUNDLE_CORE: tuple[str, ...] = (
319
+ "desired_sum_insured_inr",
320
+ "budget_band",
321
+ "existing_cover_inr",
322
+ "copay_pct",
323
+ "family_medical_history", # Bug #110 β€” must be asked, same skip handling
324
+ "smoker",
325
+ )
326
+
327
+ # Exact verbatim re-ask phrasing per bundle slot (mirrors RULE 2.5 wording so
328
+ # the re-ask reads identically to the first ask). family_medical_history is
329
+ # first so a forgotten Bug #110 slot leads the re-ask.
330
+ _PRICING_BUNDLE_QUESTIONS: dict[str, str] = {
331
+ "family_medical_history": (
332
+ "Any major conditions running in your blood family "
333
+ "(parents/siblings) β€” cancer / diabetes / heart disease / "
334
+ "hypertension? (say 'none' if not)"
335
+ ),
336
+ "desired_sum_insured_inr": (
337
+ "How much sum insured would you like? (e.g., β‚Ή5L / β‚Ή10L / "
338
+ "β‚Ή25L / β‚Ή1Cr)"
339
+ ),
340
+ "budget_band": (
341
+ "What's your annual premium budget? (e.g., β‚Ή10-15K/year, or "
342
+ "β‚Ή50K+ for premium covers)"
343
+ ),
344
+ "existing_cover_inr": (
345
+ "Any existing health cover from work or otherwise? "
346
+ "(e.g., '5L through employer', or 'no')"
347
+ ),
348
+ "copay_pct": (
349
+ "Are you OK with a co-pay β€” sharing 10-30% of every claim β€” to "
350
+ "lower the premium? Or do you want zero co-pay?"
351
+ ),
352
+ "smoker": "Do you smoke or use tobacco products? (yes / no)",
353
+ "parents_age_max": (
354
+ "Roughly what's the age of the eldest parent you'd cover?"
355
+ ),
356
+ }
357
+
358
+
359
+ def _profile_has_parents(profile) -> bool:
360
+ """True when the captured dependents indicate parents are covered (so
361
+ parents_age_max becomes part of the post-recap bundle)."""
362
+ dep = getattr(profile, "dependents", None)
363
+ if isinstance(dep, str) and "parent" in dep.lower():
364
+ return True
365
+ return bool(getattr(profile, "parents_to_insure", None))
366
+
367
+
368
+ def _unresolved_pricing_bundle(profile, session) -> list[str]:
369
+ """Return the bundle slots that are NEITHER captured on the profile NOR
370
+ explicitly skipped by the user this session. Empty list β‡’ nothing left
371
+ to re-ask (recommend may proceed)."""
372
+ if session is not None and bool(
373
+ getattr(session, "pricing_bundle_skipped", False)
374
+ ):
375
+ # User explicitly said "just show me options / you decide / skip".
376
+ return []
377
+ bundle = list(_PRICING_BUNDLE_CORE)
378
+ if _profile_has_parents(profile):
379
+ bundle.append("parents_age_max")
380
+ unresolved: list[str] = []
381
+ for slot in bundle:
382
+ if getattr(profile, slot, None) in (None, "", []):
383
+ unresolved.append(slot)
384
+ return unresolved
385
+
386
+
387
  # ───────────────────────────────────────────────────────────────────────────
388
  # SLOT_UNION (B6, 2026-05-15)
389
  # ───────────────────────────────────────────────────────────────────────────
 
513
  normalized = _coerce_family_medical_history(value)
514
  elif fld == "smoker":
515
  normalized = _coerce_smoker(value)
516
+ elif fld == "budget_band":
517
+ normalized = _coerce_budget_band(value)
518
  elif fld == "name":
519
  normalized = (str(value).strip() if value is not None else None) or None
520
  elif fld == "gender":
 
530
  "profile_complete": _profile_complete(profile),
531
  }
532
  else:
533
+ # location_tier / income_band / primary_goal β€” pass through
534
+ # (budget_band is normalised above via _coerce_budget_band)
535
  normalized = (str(value).strip() if value is not None else None) or None
536
  except (TypeError, ValueError) as e:
537
  return {
 
661
  ),
662
  }
663
 
664
+ # Bug #108 + #110 (2026-05-16) β€” POST-RECAP pricing & family-history
665
+ # bundle re-ask gate. The hard 7-slot gate above is satisfied; before we
666
+ # return chunks for a RECOMMENDATION, ensure every bundle item (SI /
667
+ # budget / existing cover / co-pay / FAMILY MEDICAL HISTORY / smoker /
668
+ # parents-age-if-applicable) has been RESOLVED β€” captured OR explicitly
669
+ # skipped. If the user answered some but not all, re-ask ONLY the
670
+ # unresolved ones β€” but exactly ONCE per session (one-shot guard) so we
671
+ # never hard-loop and SOFT-capture semantics survive (the user can still
672
+ # skip on the re-ask). Follow-ups (policy_filter_ids) bypass this β€” it's
673
+ # purely the first recommendation path.
674
+ if (
675
+ profile is not None
676
+ and not policy_filter_ids
677
+ and (intent or "").lower() == "recommendation"
678
+ and session is not None
679
+ and not bool(getattr(session, "pricing_bundle_reasked", False))
680
+ ):
681
+ unresolved = _unresolved_pricing_bundle(profile, session)
682
+ if unresolved:
683
+ # One-shot: mark so the NEXT recommendation retrieve proceeds
684
+ # even if the user skips on the re-ask (SOFT capture, not a hard
685
+ # gate β€” Bug #108 fix must re-ask, not loop forever).
686
+ try:
687
+ session.pricing_bundle_reasked = True
688
+ except Exception: # noqa: BLE001 β€” bookkeeping must not break
689
+ pass
690
+ _qs = [
691
+ _PRICING_BUNDLE_QUESTIONS.get(
692
+ s, f"Could you share your {s.replace('_', ' ')}?"
693
+ )
694
+ for s in unresolved
695
+ ]
696
+ _numbered = "\n".join(
697
+ f"{i}. {q}" for i, q in enumerate(_qs, start=1)
698
+ )
699
+ return {
700
+ "chunks": [],
701
+ "count": 0,
702
+ "error": "pricing_inputs_incomplete",
703
+ "missing_slots": unresolved,
704
+ "action_required": "ask_user_for",
705
+ "field": unresolved[0],
706
+ "exact_question": (
707
+ "Before I pull your recommendations, just a couple more "
708
+ "(you can skip any):\n" + _numbered
709
+ ),
710
+ "instruction": (
711
+ "The user answered some but not all of the pricing / "
712
+ "family-history questions. Do NOT call retrieve_policies "
713
+ "again this turn and do NOT recommend yet. Emit a TEXT "
714
+ "reply that asks ONLY the still-missing items in "
715
+ "`exact_question` verbatim. If the user then provides "
716
+ "them, save each via save_profile_field; if they skip, "
717
+ "proceed to retrieve_policies on the next turn."
718
+ ),
719
+ }
720
+
721
  try:
722
  from rag.retrieve import retrieve as _retrieve
723
 
 
1078
  return None
1079
 
1080
 
1081
+ # Canonical budget bands β€” the EXACT string contract the frontend
1082
+ # ProfileBuilderPanel round-trips via budgetBandToInr / budgetInrToBand
1083
+ # (frontend/src/app/page.tsx) and that needs_finder._parse_budget_band
1084
+ # emits. Keeping this set inline makes the contract greppable from the
1085
+ # capture surface.
1086
+ _CANONICAL_BUDGET_BANDS: frozenset[str] = frozenset(
1087
+ {"under_15k", "15k_30k", "30k_60k", "60k+"}
1088
+ )
1089
+
1090
+
1091
+ def _coerce_budget_band(value: Any) -> Optional[str]:
1092
+ """Normalise a budget capture to the documented `budget_band` contract.
1093
+
1094
+ Bug #109 (2026-05-16). The user states a premium budget in chat
1095
+ ("max β‚Ή15,000/yr", "around 30k", "15000"). Gemini calls
1096
+ save_profile_field(field="budget_band", value=...) but often passes the
1097
+ NUMERIC the user said, not a canonical band. The old code let
1098
+ budget_band fall through the generic string pass-through, so
1099
+ profile.budget_band was stored as the raw "15000" β€” which the frontend
1100
+ ProfileBuilderPanel's budgetBandToInr() switch can't map, so the panel
1101
+ never pre-filled the budget slider even though the bot's summary showed
1102
+ it.
1103
+
1104
+ Mapping (matches frontend budgetInrToBand + needs_finder bands):
1105
+ β€’ already a canonical band ("15k_30k") β†’ passed through unchanged
1106
+ β€’ free-text / numeric ("max β‚Ή15,000/yr", "30k", 22000, "10-15K")
1107
+ β†’ delegated to needs_finder._parse_budget_band β†’ canonical band
1108
+ β€’ unrecognised β†’ None (KI-091 null-overwrite guard then refuses to
1109
+ clobber a previously-captured band)
1110
+ """
1111
+ if value is None:
1112
+ return None
1113
+ # Numeric β†’ bucket directly (β‚Ή amount per year).
1114
+ if isinstance(value, bool):
1115
+ return None
1116
+ if isinstance(value, (int, float)):
1117
+ v = int(value)
1118
+ if v < 15_000:
1119
+ return "under_15k"
1120
+ if v < 30_000:
1121
+ return "15k_30k"
1122
+ if v < 60_000:
1123
+ return "30k_60k"
1124
+ return "60k+"
1125
+ s = str(value).strip()
1126
+ if not s:
1127
+ return None
1128
+ # Already canonical β€” accept verbatim (case/space tolerant).
1129
+ norm = s.lower().replace(" ", "")
1130
+ if norm in _CANONICAL_BUDGET_BANDS:
1131
+ return norm
1132
+ # Strip ANNUAL-budget qualifiers FIRST. needs_finder._parse_inr_amount
1133
+ # treats a bare "yr" / "year" as an AGE context and refuses to read any
1134
+ # number as currency (the KI-161 age guard) β€” so the canonical live
1135
+ # phrasing "max β‚Ή15,000/yr" / "β‚Ή15000 per year" parsed to None and the
1136
+ # band was never captured (Bug #109). These suffixes are unambiguous
1137
+ # PER-ANNUM budget markers here (the field is explicitly the premium
1138
+ # budget), not an age, so we drop them before delegating.
1139
+ import re as _re
1140
+
1141
+ cleaned = _re.sub(
1142
+ r"\b(?:per\s*(?:year|annum)|p\.?\s*a\.?|/\s*(?:yr|year|annum)|"
1143
+ r"a\s*year|annually|yearly|/\s*yr)\b",
1144
+ " ",
1145
+ s,
1146
+ flags=_re.IGNORECASE,
1147
+ )
1148
+ # Free-text / amount β†’ canonical band via the shared parser. This
1149
+ # handles "max β‚Ή15,000", "30k", "around 25000", "15-30k", "1 lakh".
1150
+ try:
1151
+ from backend.needs_finder import _parse_budget_band
1152
+
1153
+ band = _parse_budget_band(cleaned)
1154
+ if band in _CANONICAL_BUDGET_BANDS:
1155
+ return band
1156
+ # Last-resort: if the qualifier strip left only the amount, try the
1157
+ # raw string too (covers phrasings the regex didn't anticipate).
1158
+ if cleaned != s:
1159
+ band = _parse_budget_band(s)
1160
+ if band in _CANONICAL_BUDGET_BANDS:
1161
+ return band
1162
+ except Exception: # noqa: BLE001 β€” parser optional; fall through to None
1163
+ pass
1164
+ return None
1165
+
1166
+
1167
  def _coerce_bool(value: Any) -> Optional[bool]:
1168
  """Tri-state bool coercion for parents_to_insure / parents_has_ped.
1169
 
backend/session_state.py CHANGED
@@ -92,6 +92,17 @@ class SessionState:
92
  # turn. Independent of `pending_profile_recall` (which tracks an
93
  # in-flight confirm), so a declined recall is not retried either.
94
  _recall_sniff_done: bool = False
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  def _flush(self) -> None:
97
  """No-op since KI-118 (2026-05-15). Disk persistence was removed; the
 
92
  # turn. Independent of `pending_profile_recall` (which tracks an
93
  # in-flight confirm), so a declined recall is not retried either.
94
  _recall_sniff_done: bool = False
95
+ # Bug #108 + #110 (2026-05-16) β€” POST-RECAP pricing & family-history
96
+ # bundle re-ask gate (brain_tools.retrieve_policies).
97
+ # pricing_bundle_reasked β€” one-shot guard. Set True the first time the
98
+ # gate re-asks an unresolved bundle slot so the NEXT recommendation
99
+ # retrieve proceeds even if the user skips (SOFT capture, no hard
100
+ # loop).
101
+ # pricing_bundle_skipped β€” set True by single_brain when the user
102
+ # explicitly declines the pricing inputs ("just show me options",
103
+ # "you decide", "skip"). Bypasses the re-ask entirely.
104
+ pricing_bundle_reasked: bool = False
105
+ pricing_bundle_skipped: bool = False
106
 
107
  def _flush(self) -> None:
108
  """No-op since KI-118 (2026-05-15). Disk persistence was removed; the
backend/single_brain.py CHANGED
@@ -181,7 +181,7 @@ NEVER ask the user for a fact you can already extract from their last message. C
181
  ═══════════════════════════════════
182
  RULE 2 β€” retrieve_policies query MUST be profile-aware
183
  ═══════════════════════════════════
184
- Only call retrieve_policies AFTER all 7 required slots are saved AND the user has confirmed your recap.
185
 
186
  Build the query string from the profile snapshot. The query MUST be profile+pricing aware β€” include both recommendation and pricing slots so retrieval scores reflect what the user actually needs.
187
 
@@ -235,7 +235,16 @@ When the user answers, call save_profile_field once per provided value:
235
 
236
  Gender hint: if the user mentions gender, keep it for conversational context only β€” Profile has no `gender` slot. Do NOT call save_profile_field(field="gender", ...) β€” it returns `field_not_on_profile_dataclass` and wastes a tool-call iteration.
237
 
238
- Then call retrieve_policies and INCLUDE the new inputs in the query (e.g., "...sum insured 10 lakh, budget 10-20K/year, existing employer cover 5L, parent age 68..."). If the user skips ("just show me options", "you decide"), proceed with retrieve_policies using profile defaults β€” DO NOT block. SOFT capture, not a hard gate.
 
 
 
 
 
 
 
 
 
239
 
240
  ═══════════════════════════════════
241
  RULE 2.6 β€” ONLY RECOMMEND PLANS THAT ARE GENUINELY STRONG FOR THIS USER
@@ -439,7 +448,8 @@ TOOL_SCHEMAS: list[dict] = [
439
  "per field every time the user reveals something new (name, age, "
440
  "dependents, location_tier, income_band, primary_goal, "
441
  "health_conditions, existing_cover_inr, budget_band, "
442
- "desired_sum_insured_inr, gender)."
 
443
  ),
444
  "parameters": {
445
  "type": "OBJECT",
@@ -450,7 +460,9 @@ TOOL_SCHEMAS: list[dict] = [
450
  "Field name. One of: name, age, dependents, "
451
  "location_tier, income_band, primary_goal, "
452
  "health_conditions, existing_cover_inr, budget_band, "
453
- "desired_sum_insured_inr, gender."
 
 
454
  ),
455
  },
456
  "value": {
@@ -751,6 +763,34 @@ _FALLBACK_REQUIRED_SLOTS = (
751
  )
752
 
753
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
754
  def _synthesise_fallback(profile) -> str:
755
  """KI-Z6-NONE (2026-05-15): replace the legacy 'I lost my train of
756
  thought' reply with a useful next-question synthesised from the
@@ -1773,6 +1813,18 @@ async def handle_turn(
1773
  session.profile, is_returning_user=is_returning_user,
1774
  )
1775
 
 
 
 
 
 
 
 
 
 
 
 
 
1776
  # The running `contents` list β€” we append model turns + function
1777
  # responses to it across loop iterations so Gemini sees the entire
1778
  # tool-call thread when emitting its final text.
@@ -1969,21 +2021,57 @@ async def handle_turn(
1969
  retrieved_chunks_all=retrieved_chunks_all,
1970
  marked_policy_ids=last_marked_policy_ids,
1971
  )
1972
- if is_recommendation:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1973
  # Recommendation turn β€” cards mirror the prose 1:1 (same count,
1974
  # same order). An empty rec set here is CORRECT (do not fall back
1975
  # to the recall dump and resurrect un-named policies).
1976
  citations = rec_citations
1977
  else:
1978
- # Pure QA / chit-chat (no shortlist named) β€” keep legacy recall
1979
- # chips so a factual answer still surfaces its supporting source.
1980
- citations = recall_citations
 
 
1981
 
1982
  if len(citations) != len(recall_citations):
1983
  _log.info(
1984
- "single_brain citations: rec=%d recall=%d is_rec=%s marked=%d "
1985
- "(KI-278 prose-aligned)",
 
1986
  len(citations), len(recall_citations), is_recommendation,
 
1987
  len(last_marked_policy_ids),
1988
  )
1989
 
@@ -1997,7 +2085,10 @@ async def handle_turn(
1997
  # derived from real state (prior snapshot + this turn's
1998
  # save_profile_field calls) β€” nothing is invented. Only on
1999
  # recommendation turns; the citation/gate behaviour is unchanged.
2000
- if is_recommendation:
 
 
 
2001
  _change_note = _recommendation_change_note(
2002
  prev_snapshot=prev_rec_snapshot,
2003
  current_citations=citations,
@@ -2019,7 +2110,7 @@ async def handle_turn(
2019
  # blind the next constraint-driven swap. brain_tools.mark_recommendation
2020
  # already wrote last_recommendation_ids; this name-bearing snapshot is
2021
  # written here (single_brain owns it; brain_tools is out of scope).
2022
- if is_recommendation and citations:
2023
  try:
2024
  session.last_recommendation_snapshot = {
2025
  (c.get("policy_id") or "").strip(): c.get("policy_name", "")
 
181
  ═══════════════════════════════════
182
  RULE 2 β€” retrieve_policies query MUST be profile-aware
183
  ═══════════════════════════════════
184
+ Only call retrieve_policies AFTER all 7 required slots are saved AND the user has confirmed your recap AND the RULE 2.5 pricing/family-history bundle has been either answered or explicitly skipped (a PARTIAL answer is not a skip β€” re-ask the missing items first; see RULE 2.5).
185
 
186
  Build the query string from the profile snapshot. The query MUST be profile+pricing aware β€” include both recommendation and pricing slots so retrieval scores reflect what the user actually needs.
187
 
 
235
 
236
  Gender hint: if the user mentions gender, keep it for conversational context only β€” Profile has no `gender` slot. Do NOT call save_profile_field(field="gender", ...) β€” it returns `field_not_on_profile_dataclass` and wastes a tool-call iteration.
237
 
238
+ PARTIAL ANSWER β†’ RE-ASK THE REST (Bug #108 β€” DO NOT SKIP THIS):
239
+ If the user answers SOME of the bundle but not ALL (e.g. you asked sum
240
+ insured / budget / co-pay / family history / smoking and they gave SI +
241
+ budget + co-pay only), you MUST re-ask ONLY the still-unanswered items in
242
+ ONE short follow-up before recommending β€” do NOT silently proceed to
243
+ retrieve_policies with the unanswered slot blank. The single most-dropped
244
+ item is FAMILY MEDICAL HISTORY (Bug #110): always confirm it is answered or
245
+ skipped. Re-ask at most ONCE; if the user then skips, proceed.
246
+
247
+ Then call retrieve_policies and INCLUDE the new inputs in the query (e.g., "...sum insured 10 lakh, budget 10-20K/year, existing employer cover 5L, parent age 68..."). If the user EXPLICITLY skips ("just show me options", "you decide", "skip the rest"), proceed with retrieve_policies using profile defaults β€” DO NOT block, DO NOT re-ask again. SOFT capture, not a hard gate β€” but a PARTIAL answer is NOT a skip: re-ask the missing items once (see above).
248
 
249
  ═══════════════════════════════════
250
  RULE 2.6 β€” ONLY RECOMMEND PLANS THAT ARE GENUINELY STRONG FOR THIS USER
 
448
  "per field every time the user reveals something new (name, age, "
449
  "dependents, location_tier, income_band, primary_goal, "
450
  "health_conditions, existing_cover_inr, budget_band, "
451
+ "desired_sum_insured_inr, copay_pct, family_medical_history, "
452
+ "smoker, parents_age_max, gender)."
453
  ),
454
  "parameters": {
455
  "type": "OBJECT",
 
460
  "Field name. One of: name, age, dependents, "
461
  "location_tier, income_band, primary_goal, "
462
  "health_conditions, existing_cover_inr, budget_band, "
463
+ "desired_sum_insured_inr, copay_pct, "
464
+ "family_medical_history, smoker, parents_age_max, "
465
+ "gender."
466
  ),
467
  },
468
  "value": {
 
763
  )
764
 
765
 
766
+ # Bug #108 + #110 (2026-05-16) β€” explicit-skip detector for the post-recap
767
+ # pricing & family-history bundle. When the user clearly declines the
768
+ # pricing inputs, single_brain stamps session.pricing_bundle_skipped so
769
+ # brain_tools.retrieve_policies' one-shot re-ask gate is BYPASSED (the user
770
+ # asked us not to keep asking β€” SOFT capture means "skip" is honoured).
771
+ # Phrase-level only (substring on a lowercased message) so it stays cheap +
772
+ # deterministic; a partial answer ("10 lakh cover, skip the rest") still
773
+ # counts as skip-the-rest, which is the desired behaviour.
774
+ _PRICING_SKIP_PHRASES: tuple[str, ...] = (
775
+ "just show me", "just show options", "just recommend", "just give me",
776
+ "you decide", "you choose", "your call", "whatever you think",
777
+ "skip", "skip the rest", "skip those", "skip that", "no preference",
778
+ "don't have a preference", "dont have a preference", "doesn't matter",
779
+ "doesnt matter", "not sure", "no idea", "show me options",
780
+ "show me the options", "show options", "proceed", "go ahead",
781
+ "let's see options", "lets see options", "recommend now",
782
+ )
783
+
784
+
785
+ def _user_skipped_pricing_inputs(user_text: str) -> bool:
786
+ """True when the user's message explicitly declines the pricing /
787
+ family-history bundle (so the deterministic re-ask gate is bypassed)."""
788
+ t = (user_text or "").strip().lower()
789
+ if not t:
790
+ return False
791
+ return any(p in t for p in _PRICING_SKIP_PHRASES)
792
+
793
+
794
  def _synthesise_fallback(profile) -> str:
795
  """KI-Z6-NONE (2026-05-15): replace the legacy 'I lost my train of
796
  thought' reply with a useful next-question synthesised from the
 
1813
  session.profile, is_returning_user=is_returning_user,
1814
  )
1815
 
1816
+ # Bug #108 + #110 β€” if the user explicitly declines the pricing /
1817
+ # family-history bundle on THIS turn, stamp the session so
1818
+ # brain_tools.retrieve_policies' one-shot re-ask gate is bypassed (a
1819
+ # skip is honoured under SOFT-capture semantics; we never nag). Sticky
1820
+ # for the rest of the session β€” once the user says "just show me
1821
+ # options" we don't re-gate the bundle on later recommendation turns.
1822
+ try:
1823
+ if _user_skipped_pricing_inputs(user_text):
1824
+ session.pricing_bundle_skipped = True
1825
+ except Exception: # noqa: BLE001 β€” never break a turn for this
1826
+ pass
1827
+
1828
  # The running `contents` list β€” we append model turns + function
1829
  # responses to it across loop iterations so Gemini sees the entire
1830
  # tool-call thread when emitting its final text.
 
2021
  retrieved_chunks_all=retrieved_chunks_all,
2022
  marked_policy_ids=last_marked_policy_ids,
2023
  )
2024
+ # Bug #107 (2026-05-16) β€” FACT-FIND / CLARIFYING TURNS CARRY NO
2025
+ # CITATIONS.
2026
+ #
2027
+ # ROOT CAUSE: on a non-recommendation turn `citations` fell back to
2028
+ # `recall_citations` (the raw retrieve_policies recall dump). When the
2029
+ # LLM speculatively called retrieve_policies and THEN asked the user a
2030
+ # clarifying / fact-find question (no policy named in prose, no
2031
+ # mark_recommendation), that dump still flowed to the frontend, which
2032
+ # rendered a full ranked "CITED POLICIES" list directly under a reply
2033
+ # that says "Before I recommend… I need more info" β€” the cards
2034
+ # contradict the message.
2035
+ #
2036
+ # "FACT-FIND vs RECOMMENDATION turn" detection (deterministic, no LLM):
2037
+ # β€’ RECOMMENDATION turn ⇔ `is_recommendation` is True
2038
+ # (_build_recommendation_citations saw an explicit
2039
+ # mark_recommendation OR a retrieved policy NAMED in the reply
2040
+ # prose) AND the required-slot profile gate is satisfied
2041
+ # (brain_tools._profile_complete β€” the SAME gate
2042
+ # retrieve_policies and main._compute_profile_complete use).
2043
+ # β€’ Anything else (profile gate NOT satisfied, or no policy
2044
+ # recommended) is a FACT-FIND / clarifying / chit-chat turn.
2045
+ #
2046
+ # Only a RECOMMENDATION turn attaches policy citations. A fact-find or
2047
+ # clarifying turn returns an EMPTY list so the UI renders nothing under
2048
+ # the question. This also covers the spec's "no recommendation made /
2049
+ # profile gate not satisfied" wording exactly.
2050
+ try:
2051
+ _profile_gate_ok = brain_tools._profile_complete(session.profile)
2052
+ except Exception: # noqa: BLE001 β€” gate read must never break a turn
2053
+ _profile_gate_ok = False
2054
+ _is_recommendation_turn = bool(is_recommendation) and _profile_gate_ok
2055
+
2056
+ if _is_recommendation_turn:
2057
  # Recommendation turn β€” cards mirror the prose 1:1 (same count,
2058
  # same order). An empty rec set here is CORRECT (do not fall back
2059
  # to the recall dump and resurrect un-named policies).
2060
  citations = rec_citations
2061
  else:
2062
+ # FACT-FIND / clarifying / chit-chat turn β€” NO policy citations.
2063
+ # Even if retrieve_policies ran speculatively this turn, the user
2064
+ # is being asked for more info, not given a recommendation; the
2065
+ # ranked-card UI must not contradict the question (Bug #107).
2066
+ citations = []
2067
 
2068
  if len(citations) != len(recall_citations):
2069
  _log.info(
2070
+ "single_brain citations: rec=%d recall=%d is_rec=%s "
2071
+ "profile_gate_ok=%s rec_turn=%s marked=%d "
2072
+ "(KI-278 prose-aligned; Bug #107 fact-find gate)",
2073
  len(citations), len(recall_citations), is_recommendation,
2074
+ _profile_gate_ok, _is_recommendation_turn,
2075
  len(last_marked_policy_ids),
2076
  )
2077
 
 
2085
  # derived from real state (prior snapshot + this turn's
2086
  # save_profile_field calls) β€” nothing is invented. Only on
2087
  # recommendation turns; the citation/gate behaviour is unchanged.
2088
+ # Bug #107 β€” use the GATED recommendation-turn signal (is_recommendation
2089
+ # AND profile gate satisfied) so a fact-find turn that speculatively
2090
+ # retrieved never emits a drop note or overwrites the rec snapshot.
2091
+ if _is_recommendation_turn:
2092
  _change_note = _recommendation_change_note(
2093
  prev_snapshot=prev_rec_snapshot,
2094
  current_citations=citations,
 
2110
  # blind the next constraint-driven swap. brain_tools.mark_recommendation
2111
  # already wrote last_recommendation_ids; this name-bearing snapshot is
2112
  # written here (single_brain owns it; brain_tools is out of scope).
2113
+ if _is_recommendation_turn and citations:
2114
  try:
2115
  session.last_recommendation_snapshot = {
2116
  (c.get("policy_id") or "").strip(): c.get("policy_name", "")
backend/voice_format.py CHANGED
@@ -317,6 +317,46 @@ def _expand_acronyms(text: str, language: str = "en") -> str:
317
  return text
318
 
319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  def _compress_whitespace(text: str) -> str:
321
  text = re.sub(r"\n{2,}", ". ", text) # paragraph break β†’ sentence break
322
  text = re.sub(r"\n", " ", text)
@@ -537,6 +577,10 @@ def tts_preprocess(text: str, language: str = "en", max_words: int = 60) -> str:
537
  # acronym handling so β‚Ή5L becomes "5 lakhs" instead of getting caught
538
  # by the bare-L acronym path.
539
  cleaned = _normalize_money(cleaned)
 
 
 
 
540
  cleaned = _expand_acronyms(cleaned, language=language)
541
  cleaned = _compress_whitespace(cleaned)
542
  cleaned = _truncate_for_voice(cleaned, max_words=max_words)
 
317
  return text
318
 
319
 
320
+ _ONES = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
321
+ "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
322
+ "sixteen", "seventeen", "eighteen", "nineteen"]
323
+ _TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
324
+ "eighty", "ninety"]
325
+
326
+
327
+ def _int_to_words(n: int) -> str:
328
+ """Deterministic 0–99,999 β†’ spoken English (no external dep). Covers ages,
329
+ counts, years. Money/lakh/crore is already handled by _normalize_money."""
330
+ if n < 20:
331
+ return _ONES[n]
332
+ if n < 100:
333
+ return _TENS[n // 10] + ("-" + _ONES[n % 10] if n % 10 else "")
334
+ if n < 1000:
335
+ return _ONES[n // 100] + " hundred" + (" " + _int_to_words(n % 100) if n % 100 else "")
336
+ if n < 100000:
337
+ return _int_to_words(n // 1000) + " thousand" + (" " + _int_to_words(n % 1000) if n % 1000 else "")
338
+ return str(n)
339
+
340
+
341
+ def _normalize_numbers(text: str) -> str:
342
+ """#106 β€” Sarvam TTS spells bare numerals digit-by-digit ("29" β†’ "two
343
+ nine") and chokes on en/em dashes ("29–year-old"). Run AFTER
344
+ _normalize_money (so lakh/crore is already words): neutralise –/β€” to a
345
+ space, then expand any remaining standalone integer ≀ 99,999 to words so
346
+ the spoken payload says "twenty-nine". Display text is untouched."""
347
+ text = re.sub(r"\s*[–—]\s*", " ", text)
348
+ def _sub(m: "re.Match[str]") -> str:
349
+ try:
350
+ return _int_to_words(int(m.group(0)))
351
+ except (ValueError, TypeError):
352
+ return m.group(0)
353
+ # standalone integers only β€” not glued to letters/decimals/version-ids
354
+ text = re.sub(r"(?<![\w.])\d{1,5}(?![\w.])", _sub, text)
355
+ # spoken "percent" so "ten%" isn't read as "ten" (symbol dropped)
356
+ text = re.sub(r"\s*%", " percent", text)
357
+ return text
358
+
359
+
360
  def _compress_whitespace(text: str) -> str:
361
  text = re.sub(r"\n{2,}", ". ", text) # paragraph break β†’ sentence break
362
  text = re.sub(r"\n", " ", text)
 
577
  # acronym handling so β‚Ή5L becomes "5 lakhs" instead of getting caught
578
  # by the bare-L acronym path.
579
  cleaned = _normalize_money(cleaned)
580
+ # #106 β€” AFTER money (lakh/crore already words): expand remaining bare
581
+ # integers ("29" β†’ "twenty-nine") + neutralise en/em dashes so Sarvam
582
+ # stops spelling numbers digit-by-digit ("two nine year old").
583
+ cleaned = _normalize_numbers(cleaned)
584
  cleaned = _expand_acronyms(cleaned, language=language)
585
  cleaned = _compress_whitespace(cleaned)
586
  cleaned = _truncate_for_voice(cleaned, max_words=max_words)
frontend/src/app/page.tsx CHANGED
@@ -1160,10 +1160,17 @@ export default function Page() {
1160
  }
1161
  } else if (tr.text && tr.text.trim()) {
1162
  // KI-213 β€” replace the interim SR transcript with Sarvam's
1163
- // authoritative version, then submit. send() clears the input
1164
- // itself so the brief flash here is intentional UX feedback.
 
 
 
 
 
 
1165
  // V4 FIX 4 β€” transcript-sourced.
1166
  setInputFromTranscript(tr.text);
 
1167
  // send() flips voicePhase to "thinking" itself; no need to set here
1168
  await send(tr.text);
1169
  } else if (srFallback) {
 
1160
  }
1161
  } else if (tr.text && tr.text.trim()) {
1162
  // KI-213 β€” replace the interim SR transcript with Sarvam's
1163
+ // authoritative version, then submit.
1164
+ // #105 β€” the live composer shows the Web-Speech INTERIM (instant
1165
+ // but approximate / shorter); the SENT message is this Sarvam
1166
+ // authoritative text (fuller, punctuated). Before, setInput then
1167
+ // immediate send() cleared it in the same tick, so the user only
1168
+ // ever saw the shorter interim and read it as "truncated". Show
1169
+ // the full authoritative transcript for a perceptible beat so
1170
+ // what they see == what is sent, THEN submit.
1171
  // V4 FIX 4 β€” transcript-sourced.
1172
  setInputFromTranscript(tr.text);
1173
+ await new Promise((r) => setTimeout(r, 380));
1174
  // send() flips voicePhase to "thinking" itself; no need to set here
1175
  await send(tr.text);
1176
  } else if (srFallback) {
tests/test_bug107_108_109_110_factfind.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bug #107 / #108 / #109 / #110 (2026-05-16) β€” fact-find / profile
2
+ regressions found live by the user.
3
+
4
+ #107 β€” On a FACT-FIND / clarifying turn (the assistant is asking for more
5
+ profile info, NOT recommending), the response still carried policy
6
+ citations so the UI rendered a ranked "CITED POLICIES" list directly
7
+ under "Before I recommend… I need more info". A fact-find /
8
+ clarifying turn (profile gate NOT satisfied, or no recommendation
9
+ made) must carry ZERO citations.
10
+
11
+ #108 β€” When the user answers SOME but not ALL of the post-recap pricing /
12
+ family-history bundle (SI / budget / co-pay / family history /
13
+ smoker), the bot recommended with the unanswered slot blank. The
14
+ gate must re-ask the still-missing slot(s) ONCE before recommending;
15
+ only proceed once they are filled OR the user explicitly skipped.
16
+
17
+ #109 β€” User states a premium budget in chat ("max β‚Ή15,000/yr"); it must be
18
+ persisted to the documented `budget_band` contract field as a
19
+ canonical band so the profile panel can pre-fill it (not stored as
20
+ the raw "15000" string the panel's band→inr switch can't map).
21
+
22
+ #110 β€” The fact-find never asked family_medical_history. It must be part
23
+ of the post-recap bundle with the same optional/skip handling, and
24
+ the deterministic gate must re-ask it when unresolved.
25
+
26
+ Run:
27
+ cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
28
+ PYTHONPATH=$PWD .venv/bin/python -m pytest -q \\
29
+ tests/test_bug107_108_109_110_factfind.py
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import os
36
+ import sys
37
+ import unittest
38
+ import uuid
39
+ from pathlib import Path
40
+ from unittest import mock
41
+
42
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
43
+ if str(_REPO_ROOT) not in sys.path:
44
+ sys.path.insert(0, str(_REPO_ROOT))
45
+
46
+ from backend import brain_tools, single_brain # noqa: E402
47
+ from backend.brain_tools import ( # noqa: E402
48
+ _coerce_budget_band,
49
+ _unresolved_pricing_bundle,
50
+ save_profile_field,
51
+ )
52
+ from backend.needs_finder import Profile # noqa: E402
53
+ from backend.single_brain import ( # noqa: E402
54
+ SYSTEM_PROMPT,
55
+ _user_skipped_pricing_inputs,
56
+ )
57
+
58
+
59
+ def _run(coro):
60
+ return asyncio.new_event_loop().run_until_complete(coro)
61
+
62
+
63
+ def _fc_part(name, args):
64
+ return {"functionCall": {"name": name, "args": args}}
65
+
66
+
67
+ def _text_payload(text):
68
+ return {"candidates": [{"content": {"parts": [{"text": text}]}}]}
69
+
70
+
71
+ def _tool_payload(parts):
72
+ return {"candidates": [{"content": {"parts": parts}}]}
73
+
74
+
75
+ def _enriched(pid, name, slug, score, cid, *, grade="B", overall=80):
76
+ return {
77
+ "chunk_id": cid, "policy_id": pid, "policy_name": name,
78
+ "insurer_slug": slug, "doc_type": "policy",
79
+ "source_url": f"https://example.com/{pid}.pdf", "score": score,
80
+ "_grade": grade, "_overall_score": overall,
81
+ }
82
+
83
+
84
+ class _Harness(unittest.TestCase):
85
+ """Drives single_brain.handle_turn for real with Gemini + the vector
86
+ store stubbed. The Bug #107 citation gate, the Bug #108/#110 bundle
87
+ re-ask gate, and the Bug #109 budget coercer all run for real."""
88
+
89
+ def setUp(self):
90
+ self._env = mock.patch.dict(os.environ,
91
+ {"GOOGLE_API_KEY": "test-key"})
92
+ self._env.start()
93
+ self._gemini_script: list = []
94
+ self._retrieve_chunks: list = []
95
+
96
+ async def _fake_gemini(*_a, **_k):
97
+ if not self._gemini_script:
98
+ return _text_payload("(no more scripted turns)")
99
+ return self._gemini_script.pop(0)
100
+
101
+ # The vector store is the only thing we hard-stub; the REAL
102
+ # brain_tools.retrieve_policies gate (7-slot profile gate + the Bug
103
+ # #108/#110 post-recap bundle re-ask gate) must run. We wrap the
104
+ # real function so the gate executes for real, but the network /
105
+ # Chroma seam (rag.retrieve) is replaced by canned chunks.
106
+ _real_retrieve_policies = brain_tools.retrieve_policies
107
+
108
+ async def _fake_rag_retrieve(*_a, **_k):
109
+ class _C:
110
+ pass
111
+ out = []
112
+ for c in self._retrieve_chunks:
113
+ o = _C()
114
+ for k, v in c.items():
115
+ setattr(o, k, v)
116
+ o.text = c.get("chunk_text", "")
117
+ out.append(o)
118
+ return out
119
+
120
+ async def _fake_retrieve_policies(*a, **k):
121
+ sess = k.get("session")
122
+ with mock.patch("rag.retrieve.retrieve", _fake_rag_retrieve), \
123
+ mock.patch(
124
+ "backend.retrieval_filters.filter_pipeline",
125
+ lambda raw, **_kw: (raw, None)), \
126
+ mock.patch.object(brain_tools, "_load_policy_facts",
127
+ lambda *_x, **_y: {}), \
128
+ mock.patch.object(brain_tools, "_scorecard_signal",
129
+ lambda *_x, **_y: {}):
130
+ res = await _real_retrieve_policies(*a, **k)
131
+ # Mirror the real session bookkeeping the citation builder needs.
132
+ if sess is not None and res.get("chunks"):
133
+ sess.last_retrieved_chunks = list(res["chunks"])
134
+ sess.slug_to_insurer = {
135
+ c["policy_id"]: c.get("insurer_slug", "")
136
+ for c in res["chunks"]
137
+ }
138
+ return res
139
+
140
+ self._gp = mock.patch.object(single_brain, "_gemini_call",
141
+ _fake_gemini)
142
+ self._rp = mock.patch.object(brain_tools, "retrieve_policies",
143
+ _fake_retrieve_policies)
144
+ self._gp.start()
145
+ self._rp.start()
146
+
147
+ def tearDown(self):
148
+ self._rp.stop()
149
+ self._gp.stop()
150
+ self._env.stop()
151
+
152
+ def _fresh_session(self):
153
+ from backend.session_state import SessionState
154
+ return SessionState(session_id=f"t_{uuid.uuid4().hex[:8]}")
155
+
156
+ def _required_filled_session(self):
157
+ """7 required slots filled; pricing bundle NOT yet resolved."""
158
+ sess = self._fresh_session()
159
+ sess.profile.name = "Asha"
160
+ sess.profile.age = 35
161
+ sess.profile.dependents = "self+spouse"
162
+ sess.profile.location_tier = "metro"
163
+ sess.profile.income_band = "10L-25L"
164
+ sess.profile.primary_goal = "first_buy"
165
+ sess.profile.health_conditions = ["none"]
166
+ return sess
167
+
168
+
169
+ # ════════════════════════════════════════════════════════════════════════════
170
+ # BUG #107 β€” fact-find / clarifying turns carry NO citations
171
+ # ════════════════════════════════════════════════════════════════════════════
172
+ class TestBug107NoCitationsOnFactFind(_Harness):
173
+ def test_speculative_retrieve_then_clarifying_question_has_no_cards(self):
174
+ # Profile is INCOMPLETE (name only). The LLM speculatively retrieves
175
+ # then β€” correctly β€” asks a clarifying question. The recall dump must
176
+ # NOT flow to the UI as a ranked CITED POLICIES list.
177
+ sess = self._fresh_session()
178
+ sess.profile.name = "Rohit"
179
+ self._retrieve_chunks = [
180
+ _enriched("hdfc-ergo__optima-secure", "my:Optima Secure",
181
+ "hdfc-ergo", 0.71, "c1"),
182
+ _enriched("niva-bupa__reassure-3", "ReAssure 3.0",
183
+ "niva-bupa", 0.66, "c2"),
184
+ ]
185
+ self._gemini_script = [
186
+ _tool_payload([_fc_part("retrieve_policies",
187
+ {"query": "family floater metro"})]),
188
+ _text_payload(
189
+ "Before I recommend, I need a bit more β€” how old are you, "
190
+ "and who should the cover include?"),
191
+ ]
192
+ r = _run(single_brain.handle_turn(sess, "I need health insurance"))
193
+ self.assertEqual(
194
+ r.citations, [],
195
+ "Bug #107: a clarifying / fact-find turn (profile gate not "
196
+ "satisfied) must carry ZERO policy citations")
197
+
198
+ def test_recommendation_turn_with_complete_profile_still_cites(self):
199
+ # Control: a genuine recommendation turn (profile complete, bundle
200
+ # skipped) still attaches the cited cards β€” the gate must not break
201
+ # the happy path.
202
+ sess = self._required_filled_session()
203
+ sess.pricing_bundle_skipped = True
204
+ self._retrieve_chunks = [
205
+ _enriched("hdfc-ergo__optima-secure", "my:Optima Secure",
206
+ "hdfc-ergo", 0.71, "c1", grade="A", overall=85),
207
+ ]
208
+ self._gemini_script = [
209
+ _tool_payload([_fc_part("retrieve_policies",
210
+ {"query": "metro family 10L"})]),
211
+ _tool_payload([_fc_part("mark_recommendation", {
212
+ "policy_ids": ["hdfc-ergo__optima-secure"]})]),
213
+ _text_payload("I recommend my:Optima Secure (HDFC ERGO)."),
214
+ ]
215
+ r = _run(single_brain.handle_turn(sess, "show me options"))
216
+ ids = [c["policy_id"] for c in r.citations]
217
+ self.assertEqual(ids, ["hdfc-ergo__optima-secure"],
218
+ "recommendation turn must still attach cited cards")
219
+
220
+
221
+ # ════════════════════════════════════════════════════════════════════════════
222
+ # BUG #108 + #110 β€” partial pricing/family-history answer β†’ re-ask gate
223
+ # ════════════════════════════════════════════════════════════════════════════
224
+ class TestBug108PartialAnswerReask(_Harness):
225
+ def test_unresolved_bundle_includes_family_medical_history(self):
226
+ # Bug #110 β€” family_medical_history is part of the bundle the gate
227
+ # tracks; with nothing pricing-related captured it's unresolved.
228
+ p = Profile()
229
+ sess = self._fresh_session()
230
+ unresolved = _unresolved_pricing_bundle(p, sess)
231
+ self.assertIn("family_medical_history", unresolved)
232
+ self.assertIn("smoker", unresolved)
233
+
234
+ def test_explicit_skip_resolves_the_whole_bundle(self):
235
+ p = Profile()
236
+ sess = self._fresh_session()
237
+ sess.pricing_bundle_skipped = True
238
+ self.assertEqual(_unresolved_pricing_bundle(p, sess), [])
239
+
240
+ def test_partial_answer_triggers_one_shot_reask_then_proceeds(self):
241
+ # User answered SI + budget + co-pay but NOT smoker / family
242
+ # history. The gate must re-ask (not recommend); the C/F bundle
243
+ # slots come back in the directive.
244
+ sess = self._required_filled_session()
245
+ sess.profile.desired_sum_insured_inr = 1_000_000
246
+ sess.profile.budget_band = "15k_30k"
247
+ sess.profile.copay_pct = 0
248
+ # smoker + family_medical_history still missing.
249
+ self._retrieve_chunks = [
250
+ _enriched("hdfc-ergo__optima-secure", "my:Optima Secure",
251
+ "hdfc-ergo", 0.71, "c1"),
252
+ ]
253
+ # The LLM tries to retrieve β†’ the gate intercepts β†’ it then asks the
254
+ # user the re-ask question (no recommendation this turn).
255
+ self._gemini_script = [
256
+ _tool_payload([_fc_part("retrieve_policies",
257
+ {"query": "metro family 10L"})]),
258
+ _text_payload(
259
+ "Just two more: any major conditions in your blood family, "
260
+ "and do you smoke?"),
261
+ ]
262
+ r1 = _run(single_brain.handle_turn(sess, "10 lakh, 20k budget, "
263
+ "zero co-pay"))
264
+ # Bug #108: no recommendation cards on the re-ask turn.
265
+ self.assertEqual(r1.citations, [])
266
+ # One-shot guard now set.
267
+ self.assertTrue(sess.pricing_bundle_reasked)
268
+
269
+ # Turn 2 β€” user answers the missing slots, then the recommend
270
+ # proceeds (gate no longer blocks; one-shot already consumed).
271
+ self._gemini_script = [
272
+ _tool_payload([
273
+ _fc_part("save_profile_field",
274
+ {"field": "family_medical_history",
275
+ "value": "none"}),
276
+ _fc_part("save_profile_field",
277
+ {"field": "smoker", "value": "no"}),
278
+ ]),
279
+ _tool_payload([_fc_part("retrieve_policies",
280
+ {"query": "metro family 10L no PED"})]),
281
+ _tool_payload([_fc_part("mark_recommendation", {
282
+ "policy_ids": ["hdfc-ergo__optima-secure"]})]),
283
+ _text_payload("I recommend my:Optima Secure (HDFC ERGO)."),
284
+ ]
285
+ r2 = _run(single_brain.handle_turn(
286
+ sess, "no family history, non-smoker"))
287
+ self.assertEqual(sess.profile.smoker, False)
288
+ self.assertEqual(sess.profile.family_medical_history, [])
289
+ self.assertEqual(
290
+ [c["policy_id"] for c in r2.citations],
291
+ ["hdfc-ergo__optima-secure"],
292
+ "after the missing bundle slots are filled the recommendation "
293
+ "proceeds and cites the policy")
294
+
295
+ def test_explicit_skip_bypasses_the_reask_gate(self):
296
+ sess = self._required_filled_session()
297
+ self._retrieve_chunks = [
298
+ _enriched("hdfc-ergo__optima-secure", "my:Optima Secure",
299
+ "hdfc-ergo", 0.71, "c1", grade="A", overall=85),
300
+ ]
301
+ self._gemini_script = [
302
+ _tool_payload([_fc_part("retrieve_policies",
303
+ {"query": "metro family"})]),
304
+ _tool_payload([_fc_part("mark_recommendation", {
305
+ "policy_ids": ["hdfc-ergo__optima-secure"]})]),
306
+ _text_payload("I recommend my:Optima Secure."),
307
+ ]
308
+ # User explicitly skips the pricing inputs β†’ gate is bypassed,
309
+ # recommendation proceeds on the SAME turn.
310
+ r = _run(single_brain.handle_turn(
311
+ sess, "just show me options, you decide"))
312
+ self.assertTrue(sess.pricing_bundle_skipped)
313
+ self.assertEqual([c["policy_id"] for c in r.citations],
314
+ ["hdfc-ergo__optima-secure"])
315
+
316
+ def test_skip_phrase_detector(self):
317
+ self.assertTrue(_user_skipped_pricing_inputs("just show me options"))
318
+ self.assertTrue(_user_skipped_pricing_inputs("you decide"))
319
+ self.assertTrue(_user_skipped_pricing_inputs("skip the rest"))
320
+ self.assertFalse(_user_skipped_pricing_inputs("10 lakh cover"))
321
+ self.assertFalse(_user_skipped_pricing_inputs(""))
322
+
323
+
324
+ # ════════════════════════════════════════════════════════════════════════════
325
+ # BUG #110 οΏ½οΏ½οΏ½ family_medical_history is in the fact-find plan
326
+ # ════════════════════════════════════════════════════════════════════════════
327
+ class TestBug110FamilyHistoryInPlan(unittest.TestCase):
328
+ def test_system_prompt_asks_family_medical_history(self):
329
+ self.assertIn("Family medical history", SYSTEM_PROMPT)
330
+ self.assertIn("family_medical_history", SYSTEM_PROMPT)
331
+ # The partial-answer re-ask rule names the most-dropped slot.
332
+ self.assertIn("FAMILY MEDICAL HISTORY", SYSTEM_PROMPT)
333
+
334
+ def test_family_history_is_in_the_bundle_questions(self):
335
+ self.assertIn("family_medical_history",
336
+ brain_tools._PRICING_BUNDLE_CORE)
337
+ self.assertIn("family_medical_history",
338
+ brain_tools._PRICING_BUNDLE_QUESTIONS)
339
+
340
+
341
+ # ════════════════════════════════════════════════════════════════════════════
342
+ # BUG #109 β€” budget persists to budget_band as a canonical band
343
+ # ════════════════════════════════════════════════════════════════════════════
344
+ class TestBug109BudgetBandPersistence(unittest.TestCase):
345
+ def test_numeric_string_maps_to_band(self):
346
+ # The exact live phrasing.
347
+ self.assertEqual(_coerce_budget_band("max β‚Ή15,000/yr"), "15k_30k")
348
+ self.assertEqual(_coerce_budget_band("15000"), "15k_30k")
349
+ self.assertEqual(_coerce_budget_band("around 30k"), "30k_60k")
350
+ self.assertEqual(_coerce_budget_band("12000"), "under_15k")
351
+ self.assertEqual(_coerce_budget_band("1 lakh"), "60k+")
352
+
353
+ def test_numeric_value_maps_to_band(self):
354
+ self.assertEqual(_coerce_budget_band(15000), "15k_30k")
355
+ self.assertEqual(_coerce_budget_band(12000), "under_15k")
356
+ self.assertEqual(_coerce_budget_band(45000), "30k_60k")
357
+ self.assertEqual(_coerce_budget_band(80000), "60k+")
358
+
359
+ def test_canonical_band_passes_through(self):
360
+ for band in ("under_15k", "15k_30k", "30k_60k", "60k+"):
361
+ self.assertEqual(_coerce_budget_band(band), band)
362
+ # case / space tolerant
363
+ self.assertEqual(_coerce_budget_band("15K_30K"), "15k_30k")
364
+
365
+ def test_unparseable_returns_none_not_raw(self):
366
+ self.assertIsNone(_coerce_budget_band("dunno"))
367
+ self.assertIsNone(_coerce_budget_band(""))
368
+
369
+ def test_save_profile_field_persists_band_not_raw(self):
370
+ class _S:
371
+ profile = Profile()
372
+ s = _S()
373
+ out = save_profile_field(s, field="budget_band",
374
+ value="max β‚Ή15,000/yr")
375
+ self.assertTrue(out["saved"])
376
+ self.assertEqual(out["value"], "15k_30k")
377
+ self.assertEqual(s.profile.budget_band, "15k_30k",
378
+ "Bug #109: must be the canonical band the profile "
379
+ "panel can map, not the raw '15000' string")
380
+
381
+
382
+ if __name__ == "__main__":
383
+ unittest.main(verbosity=2)
tests/test_bug69_71_conversation_logic.py CHANGED
@@ -187,6 +187,25 @@ class _HandleTurnHarness(unittest.TestCase):
187
  from backend.session_state import SessionState
188
  return SessionState(session_id=f"t_{uuid.uuid4().hex[:8]}")
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
  # ════════════════════════════════════════════════════════════════════════════
192
  # BUG #69 β€” end-to-end: all details EXCEPT name early; name turn not a no-match
@@ -382,7 +401,7 @@ class TestBug71EndToEnd(_HandleTurnHarness):
382
  # Retrieval surfaces a strong B/75 and a weak C/64 for this user's
383
  # own profile. Even if the LLM names both, the cited recommendation
384
  # set must exclude the C/64.
385
- sess = self._fresh_session()
386
  self._retrieve_chunks = [
387
  _enriched("hdfc-ergo__optima-secure", "my:Optima Secure",
388
  "hdfc-ergo", 0.71, "c1", grade="B", overall=75),
@@ -411,7 +430,7 @@ class TestBug71EndToEnd(_HandleTurnHarness):
411
  "present fewer (the one strong plan), not padded")
412
 
413
  def test_handle_turn_only_weak_matches_no_padded_recommendation(self):
414
- sess = self._fresh_session()
415
  self._retrieve_chunks = [
416
  _enriched("a__x", "Plan Xray", "a", 0.7, "w1", grade="C",
417
  overall=64),
 
187
  from backend.session_state import SessionState
188
  return SessionState(session_id=f"t_{uuid.uuid4().hex[:8]}")
189
 
190
+ def _ready_session(self):
191
+ """A session whose 7 required slots are filled AND the post-recap
192
+ pricing bundle is marked skipped β€” the realistic precondition for a
193
+ RECOMMENDATION turn. Bug #107 only attaches citations once the
194
+ profile gate (brain_tools._profile_complete) is satisfied, and Bug
195
+ #108's one-shot bundle re-ask gate is bypassed when the user skipped
196
+ the pricing inputs. End-to-end recommendation tests must reflect
197
+ that real flow rather than recommend off an empty profile."""
198
+ sess = self._fresh_session()
199
+ sess.profile.name = "Asha"
200
+ sess.profile.age = 35
201
+ sess.profile.dependents = "self+spouse"
202
+ sess.profile.location_tier = "metro"
203
+ sess.profile.income_band = "10L-25L"
204
+ sess.profile.primary_goal = "first_buy"
205
+ sess.profile.health_conditions = ["none"]
206
+ sess.pricing_bundle_skipped = True
207
+ return sess
208
+
209
 
210
  # ════════════════════════════════════════════════════════════════════════════
211
  # BUG #69 β€” end-to-end: all details EXCEPT name early; name turn not a no-match
 
401
  # Retrieval surfaces a strong B/75 and a weak C/64 for this user's
402
  # own profile. Even if the LLM names both, the cited recommendation
403
  # set must exclude the C/64.
404
+ sess = self._ready_session()
405
  self._retrieve_chunks = [
406
  _enriched("hdfc-ergo__optima-secure", "my:Optima Secure",
407
  "hdfc-ergo", 0.71, "c1", grade="B", overall=75),
 
430
  "present fewer (the one strong plan), not padded")
431
 
432
  def test_handle_turn_only_weak_matches_no_padded_recommendation(self):
433
+ sess = self._ready_session()
434
  self._retrieve_chunks = [
435
  _enriched("a__x", "Plan Xray", "a", 0.7, "w1", grade="C",
436
  overall=64),
tests/test_recommendation_transparency.py CHANGED
@@ -333,10 +333,29 @@ class _HandleTurnHarness(unittest.TestCase):
333
  from backend.session_state import SessionState
334
  return SessionState(session_id=f"t_{uuid.uuid4().hex[:8]}")
335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
 
337
  class TestHandleTurnTransparency(_HandleTurnHarness):
338
  def test_silent_swap_becomes_explicit_on_new_constraint(self):
339
- sess = self._fresh_session()
340
 
341
  # ---- Turn 1: recommend Multiplier + ReAssure (no constraint yet).
342
  self._retrieve_chunks = [_MULTIPLIER, _REASSURE]
@@ -394,7 +413,7 @@ class TestHandleTurnTransparency(_HandleTurnHarness):
394
  self.assertIn("niva-bupa__reassure-3", cited2)
395
 
396
  def test_no_spurious_note_when_set_unchanged(self):
397
- sess = self._fresh_session()
398
 
399
  self._retrieve_chunks = [_REASSURE, _CARE]
400
  self._gemini_script = [
@@ -422,7 +441,7 @@ class TestHandleTurnTransparency(_HandleTurnHarness):
422
  self.assertEqual(r2.reply_text, "Both still fit with zero co-pay.")
423
 
424
  def test_no_note_on_pure_qa_turn_after_recommendation(self):
425
- sess = self._fresh_session()
426
 
427
  self._retrieve_chunks = [_MULTIPLIER, _REASSURE]
428
  self._gemini_script = [
 
333
  from backend.session_state import SessionState
334
  return SessionState(session_id=f"t_{uuid.uuid4().hex[:8]}")
335
 
336
+ def _ready_session(self):
337
+ """Session with the 7 required slots filled + the post-recap pricing
338
+ bundle marked skipped β€” the realistic precondition for a
339
+ RECOMMENDATION turn. Bug #107 only attaches policy citations once
340
+ brain_tools._profile_complete is satisfied, and Bug #108's one-shot
341
+ bundle re-ask gate is bypassed when the user skipped the pricing
342
+ inputs; recommendation-transparency assertions (snapshot persists,
343
+ drop note fires) must reflect that real flow."""
344
+ sess = self._fresh_session()
345
+ sess.profile.name = "Asha"
346
+ sess.profile.age = 35
347
+ sess.profile.dependents = "self+spouse"
348
+ sess.profile.location_tier = "metro"
349
+ sess.profile.income_band = "10L-25L"
350
+ sess.profile.primary_goal = "first_buy"
351
+ sess.profile.health_conditions = ["none"]
352
+ sess.pricing_bundle_skipped = True
353
+ return sess
354
+
355
 
356
  class TestHandleTurnTransparency(_HandleTurnHarness):
357
  def test_silent_swap_becomes_explicit_on_new_constraint(self):
358
+ sess = self._ready_session()
359
 
360
  # ---- Turn 1: recommend Multiplier + ReAssure (no constraint yet).
361
  self._retrieve_chunks = [_MULTIPLIER, _REASSURE]
 
413
  self.assertIn("niva-bupa__reassure-3", cited2)
414
 
415
  def test_no_spurious_note_when_set_unchanged(self):
416
+ sess = self._ready_session()
417
 
418
  self._retrieve_chunks = [_REASSURE, _CARE]
419
  self._gemini_script = [
 
441
  self.assertEqual(r2.reply_text, "Both still fit with zero co-pay.")
442
 
443
  def test_no_note_on_pure_qa_turn_after_recommendation(self):
444
+ sess = self._ready_session()
445
 
446
  self._retrieve_chunks = [_MULTIPLIER, _REASSURE]
447
  self._gemini_script = [