Spaces:
Running
Running
Deploy: Stack A (NIM brain + Maverick judge + Sarvam voice). D-019.
Browse files- .pytest_cache/v/cache/nodeids +14 -0
- 40-data/llm_health.json +63 -63
- 40-data/profiles/asha.json +83 -0
- 40-data/profiles/ca28dcb8ed8b.json +81 -0
- backend/brain_tools.py +241 -1
- backend/session_state.py +11 -0
- backend/single_brain.py +103 -12
- backend/voice_format.py +44 -0
- frontend/src/app/page.tsx +9 -2
- tests/test_bug107_108_109_110_factfind.py +383 -0
- tests/test_bug69_71_conversation_logic.py +21 -2
- tests/test_recommendation_transparency.py +22 -3
.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-
|
| 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-
|
| 8 |
-
"last_failure_at": "2026-05-
|
| 9 |
"last_error": null,
|
| 10 |
"last_status_code": null,
|
| 11 |
-
"latency_ms":
|
| 12 |
"consecutive_failures": 0,
|
| 13 |
-
"tested_at": "2026-05-
|
| 14 |
"probe_history": [
|
| 15 |
{
|
| 16 |
"ok": true,
|
| 17 |
-
"latency_ms":
|
| 18 |
-
"ts": "2026-05-16T12:
|
| 19 |
"src": "probe"
|
| 20 |
},
|
| 21 |
{
|
| 22 |
-
"ok":
|
| 23 |
-
"latency_ms":
|
| 24 |
-
"ts": "2026-05-
|
| 25 |
"src": "probe"
|
| 26 |
},
|
| 27 |
{
|
| 28 |
-
"ok":
|
| 29 |
-
"latency_ms":
|
| 30 |
-
"ts": "2026-05-
|
| 31 |
"src": "probe"
|
| 32 |
},
|
| 33 |
{
|
| 34 |
"ok": false,
|
| 35 |
-
"latency_ms":
|
| 36 |
-
"ts": "2026-05-
|
| 37 |
"src": "probe"
|
| 38 |
},
|
| 39 |
{
|
| 40 |
"ok": true,
|
| 41 |
-
"latency_ms":
|
| 42 |
-
"ts": "2026-05-
|
| 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-
|
| 93 |
-
"last_failure_at": "2026-05-
|
| 94 |
"last_error": null,
|
| 95 |
"last_status_code": null,
|
| 96 |
-
"latency_ms":
|
| 97 |
"consecutive_failures": 0,
|
| 98 |
-
"tested_at": "2026-05-
|
| 99 |
"probe_history": [
|
| 100 |
{
|
| 101 |
"ok": true,
|
| 102 |
-
"latency_ms":
|
| 103 |
-
"ts": "2026-05-16T12:
|
| 104 |
"src": "probe"
|
| 105 |
},
|
| 106 |
{
|
| 107 |
-
"ok":
|
| 108 |
-
"latency_ms":
|
| 109 |
-
"ts": "2026-05-
|
| 110 |
"src": "probe"
|
| 111 |
},
|
| 112 |
{
|
| 113 |
-
"ok":
|
| 114 |
-
"latency_ms":
|
| 115 |
-
"ts": "2026-05-
|
| 116 |
"src": "probe"
|
| 117 |
},
|
| 118 |
{
|
| 119 |
-
"ok":
|
| 120 |
-
"latency_ms":
|
| 121 |
-
"ts": "2026-05-
|
| 122 |
"src": "probe"
|
| 123 |
},
|
| 124 |
{
|
| 125 |
"ok": true,
|
| 126 |
-
"latency_ms":
|
| 127 |
-
"ts": "2026-05-
|
| 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-
|
| 142 |
-
"last_failure_at": "2026-05-
|
| 143 |
"last_error": null,
|
| 144 |
"last_status_code": null,
|
| 145 |
-
"latency_ms":
|
| 146 |
"consecutive_failures": 0,
|
| 147 |
-
"tested_at": "2026-05-
|
| 148 |
"probe_history": [
|
| 149 |
{
|
| 150 |
"ok": true,
|
| 151 |
-
"latency_ms":
|
| 152 |
-
"ts": "2026-05-16T12:
|
| 153 |
"src": "probe"
|
| 154 |
},
|
| 155 |
{
|
| 156 |
"ok": true,
|
| 157 |
-
"latency_ms":
|
| 158 |
-
"ts": "2026-05-
|
| 159 |
"src": "probe"
|
| 160 |
},
|
| 161 |
{
|
| 162 |
-
"ok":
|
| 163 |
-
"latency_ms":
|
| 164 |
-
"ts": "2026-05-
|
| 165 |
"src": "probe"
|
| 166 |
},
|
| 167 |
{
|
| 168 |
"ok": true,
|
| 169 |
-
"latency_ms":
|
| 170 |
-
"ts": "2026-05-
|
| 171 |
"src": "probe"
|
| 172 |
},
|
| 173 |
{
|
| 174 |
"ok": true,
|
| 175 |
-
"latency_ms":
|
| 176 |
-
"ts": "2026-05-
|
| 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-
|
| 299 |
"last_failure_at": "2026-05-16T11:43:51Z",
|
| 300 |
"last_error": null,
|
| 301 |
"last_status_code": null,
|
| 302 |
-
"latency_ms":
|
| 303 |
"consecutive_failures": 0,
|
| 304 |
-
"tested_at": "2026-05-
|
| 305 |
"probe_history": [
|
| 306 |
{
|
| 307 |
"ok": true,
|
| 308 |
-
"latency_ms":
|
| 309 |
-
"ts": "2026-05-16T12:
|
| 310 |
"src": "probe"
|
| 311 |
},
|
| 312 |
{
|
| 313 |
"ok": true,
|
| 314 |
-
"latency_ms":
|
| 315 |
-
"ts": "2026-05-
|
| 316 |
"src": "probe"
|
| 317 |
},
|
| 318 |
{
|
| 319 |
"ok": true,
|
| 320 |
-
"latency_ms":
|
| 321 |
-
"ts": "2026-05-
|
| 322 |
"src": "probe"
|
| 323 |
},
|
| 324 |
{
|
| 325 |
"ok": true,
|
| 326 |
-
"latency_ms":
|
| 327 |
-
"ts": "2026-05-
|
| 328 |
"src": "probe"
|
| 329 |
},
|
| 330 |
{
|
| 331 |
"ok": true,
|
| 332 |
-
"latency_ms":
|
| 333 |
-
"ts": "2026-05-
|
| 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":
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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,
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 1979 |
-
#
|
| 1980 |
-
|
|
|
|
|
|
|
| 1981 |
|
| 1982 |
if len(citations) != len(recall_citations):
|
| 1983 |
_log.info(
|
| 1984 |
-
"single_brain citations: rec=%d recall=%d is_rec=%s
|
| 1985 |
-
"
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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.
|
| 1164 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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.
|
| 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 = [
|