Spaces:
Sleeping
feat(voice+session+llm): KI-195 + KI-196 + KI-197 + KI-199 bundle
Browse filesTHE "ONE LAST REBUILD" β adaptive voice calibration + clean session
lifecycle + 3 latency cheap wins + Gemini context caching.
KI-195 β Adaptive TTS volume calibration vs user speech level.
Per user idea: bot TTS should ALWAYS be quieter than user speech at the
mic so echo loop is mathematically impossible and barge-in always wins.
Implementation:
- userSpeechRmsRef: rolling EMA of peak mic RMS while user speaks
(recording active, not TTS); rAF loop tracks samples > 0.02 floor.
- calibrateBotVolume: every 300ms during TTS, if bot_rms_at_mic >
userSpeechRms Γ 0.35, multiply el.volume by 0.8 (floor 0.15).
- calibratedVolumes Map persists final levels across turns.
KI-196 β Clean session/profile lifecycle (ADR-041).
CRITICAL UX fix: silent KI-040 name-based profile recall confused users
into thinking the bot was "remembering" them from a prior session.
Implementation:
- clear_session() in session_state.py (in-memory evict only; on-disk
profile JSON untouched).
- POST /api/session/clear in main.py β returns new session_id;
frontend rotates session_id + wipes chat + completion chip + chat-
history localStorage.
- Orchestrator confirmation gate: when sales_brain captures a `name`
that matches an existing profile-store entry, stage the recall in
session.pending_profile_recall instead of silently merging. Brain
emits a deterministic "Welcome back β I have your earlier profile
(... summary ...). Continue from there or start fresh?" reply.
User affirm ("yes"/"continue"/Hinglish "haan") β merge; negate
("no"/"start fresh"/"nahi") β discard. Conservative trigger: name
just captured AND β€1 non-name slot is in profile.asked.
- "25% DONE" badge no longer counts builder-modal pre-selected
defaults β only fields the user has actually answered.
- ADR-041 documents the full spec table + rationale.
KI-197 β Three cheap latency wins.
1. _MAX_TOKENS 700 β 500 in sales_brain.py β saves ~200-400ms per
turn on Gemini Flash Lite. 500 still covers all observed 1-3
sentence sales_brain replies + JSON wrapper.
2. chat_history[-10:] β [-6:] in both messages[] and gemini_messages[]
β ~40% prompt-size reduction, saves ~100-200ms.
3. profile_rag.upsert_profile_chunk β fire-and-forget via
asyncio.create_task. The chunk doesn't need to be persisted before
the response returns to the user. Saves ~200-500ms.
Net: ~500-1000ms per turn (15-25%) shaved with zero behavior change.
KI-199 β Gemini cachedContents wired to sales_brain + TieredBrainLLM.
The sales_brain system prompt is ~6.6KB of fixed slot schema + tone
rules + few-shot examples. Without caching, Gemini reads it on every
turn (paid in input tokens AND wall-clock). With cachedContents:
- Module-level _CACHE_REGISTRY keyed by (model, sha256(system_text)),
guarded by threading.Lock. Holds {name, expires_at}.
- GoogleGeminiLLM.create_cache(): POSTs to /v1beta/cachedContents,
TTL 300s. Returns None on missing key / too-small payload / 4xx
(fail-safe β caller proceeds uncached).
- chat() accepts cached_content_name; when present sends
cachedContent: <name> + omits inline systemInstruction. On
cache-related 4xx, invalidates registry + retries with inline
systemInstruction.
- sales_brain split into _CACHED_PREAMBLE (6.6KB invariant) +
_dynamic_profile_block (per-turn KNOWN + REQUIRED REMAINING +
KI-196 welcome-back directive). Cache holds the preamble; dynamic
block sent inline per call.
- TieredBrainLLM forwards cached_content_name to Gemini tier only;
NIM and OR silently ignore (don't speak Gemini's cache protocol).
- Latency win: ~20-40% reduction on cache-hit turns (saves the
prompt-processing time on the server).
VERIFICATION:
py_compile all modified .py files β clean
npx tsc --noEmit (frontend) β clean
All imports resolve.
Live latency expected: from ~3-5s β ~2-3s per turn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 70-docs/60-decisions/ADR-041-session-profile-lifecycle.md +79 -0
- CLAUDE.md +7 -0
- README.md +1 -0
- backend/main.py +59 -6
- backend/orchestrator.py +157 -24
- backend/providers/google_gemini_llm.py +174 -2
- backend/providers/tiered_brain_llm.py +8 -0
- backend/sales_brain.py +165 -19
- backend/session_state.py +34 -0
- frontend/src/app/page.tsx +28 -28
- frontend/src/lib/api.ts +21 -0
- frontend/src/lib/useStreamingVoice.ts +144 -1
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR-041 β Session & profile lifecycle (KI-196)
|
| 2 |
+
|
| 3 |
+
**Status:** Accepted β 2026-05-15
|
| 4 |
+
**Owner:** Rohit Saraf
|
| 5 |
+
**Related KIs:** KI-020 (legacy clear-chat), KI-040 (silent name-based recall), KI-062 (persona_id keying), KI-077 (named profile recovery), KI-118 (in-memory-only sessions + cross-session name match), KI-167 (LLM-driven sales brain)
|
| 6 |
+
**Related ADRs:** [ADR-022](ADR-022-conversational-profile-updates.md), [ADR-039](ADR-039-llm-driven-sales-brain.md)
|
| 7 |
+
|
| 8 |
+
## Context
|
| 9 |
+
|
| 10 |
+
The bot grew three identity layers organically:
|
| 11 |
+
|
| 12 |
+
1. **`session_id`** β UUID issued per browser session; in-memory only since KI-118 (`backend/session_state.py::_sessions`).
|
| 13 |
+
2. **`persona_id`** β 12-char SHA1 of name + identity fields; keys the durable JSON at `40-data/profiles/<persona_id>.json` (KI-062).
|
| 14 |
+
3. **`name_slug`** β canonical lowercase first-name; the Chroma profile RAG chunk key and the cross-session re-entry key (KI-040 / KI-118).
|
| 15 |
+
|
| 16 |
+
Three problems compounded:
|
| 17 |
+
|
| 18 |
+
- **"Clear chat" was visual-only.** The legacy `POST /api/session/reset` had two modes (`drop_profile=false` / `true`) and the button only ever called the soft mode. The visible chat cleared but the server kept the in-memory session, so the bot acted as if mid-conversation on the user's next message.
|
| 19 |
+
- **Page reloads silently resumed.** `sessionStorage` survives refresh β correct β but the user got zero feedback that they were "still in" a prior session.
|
| 20 |
+
- **Profile recall was silent and aggressive.** KI-118's `rehydrate_by_name` auto-merged any on-disk profile whose name matched the captured name. A user named "Rohit" who happened to share a slug with a prior visitor would silently inherit that stranger's age / dependents / city. Even for the legitimate "same user, new device" case the silent merge confused users into thinking the bot was "remembering them" with no opt-in.
|
| 21 |
+
- **Profile completeness badge counted defaults.** The profile-builder modal pre-filled `dependents="self"` (UX nicety); the completeness scorer treated any non-empty slot as "captured", so a brand-new visitor saw "25% DONE" before answering anything.
|
| 22 |
+
|
| 23 |
+
## Decision
|
| 24 |
+
|
| 25 |
+
Implement a clean, explicit lifecycle with these semantics:
|
| 26 |
+
|
| 27 |
+
| Action | `session_id` | Profile JSON on disk | In-session profile state | Chat history |
|
| 28 |
+
|---|---|---|---|---|
|
| 29 |
+
| Open new tab / browser | NEW UUID | UNCHANGED (persists) | EMPTY | EMPTY |
|
| 30 |
+
| Refresh in same tab | SAME (from `sessionStorage`) | UNCHANGED | RESTORED from `_sessions` if still in memory | RESTORED if still in `localStorage` |
|
| 31 |
+
| Click "Clear chat" | NEW UUID (rotated server-side) | UNCHANGED | EMPTY | EMPTY |
|
| 32 |
+
| User says a name during fact-find that MATCHES an on-disk profile | SAME | UNCHANGED | One-turn pause β bot ASKS before recall | UNCHANGED |
|
| 33 |
+
| User explicitly opts into "welcome back" recall | SAME | LOADED into in-session state | UNCHANGED | UNCHANGED |
|
| 34 |
+
|
| 35 |
+
### Key change β confirmation-gated profile recall
|
| 36 |
+
|
| 37 |
+
When a fresh session captures a `name` that matches an existing on-disk profile, the orchestrator NO LONGER auto-merges. Instead:
|
| 38 |
+
|
| 39 |
+
1. The matched profile snapshot + the captured turn data are staged into `session.pending_profile_recall` (a new `Optional[Dict[str, Any]]` field on `SessionState`).
|
| 40 |
+
2. The orchestrator overrides `reply_text` for that turn with a deterministic, recognisable welcome-back ask:
|
| 41 |
+
*"Welcome back, Rohit β I have a profile under your name from before: age 29, metro, first buy. Continue from there or start fresh?"*
|
| 42 |
+
3. On the NEXT user turn, the confirmation gate at the top of `handle_turn` inspects `session.pending_profile_recall` and the user's reply:
|
| 43 |
+
- Affirm phrases (`yes`, `continue`, `use that`, `from there`, `haan`, β¦) β run `rehydrate_by_name` β merge stored fields.
|
| 44 |
+
- Negate phrases (`no`, `start fresh`, `new`, `nahi`, β¦) β drop the pending entry; treat as a new user.
|
| 45 |
+
- Anything else β leave the pending entry staged; the sales_brain's system prompt carries a high-priority directive to ask again.
|
| 46 |
+
|
| 47 |
+
### Backend surface
|
| 48 |
+
|
| 49 |
+
- `backend/session_state.py::clear_session(session_id)` β explicit symbol for the new endpoint. Evicts the in-memory entry; never touches `40-data/profiles/`.
|
| 50 |
+
- `backend/main.py::POST /api/session/clear` β `{session_id} β {cleared: bool, new_session_id: str}`. Always returns a new UUID so the caller has a guaranteed-fresh id.
|
| 51 |
+
- `backend/sales_brain.py::_build_system_prompt(profile, pending_profile_recall=β¦)` β when `pending_profile_recall` is supplied, the system prompt prepends a "WELCOME-BACK GATE" directive instructing the brain to ask, NOT capture, NOT proceed to the next fact-find slot.
|
| 52 |
+
- `backend/main.py::profile_completeness_view` + `profile_update` β `c = _completeness(...)` now masks fields NOT in `Profile.asked` to `None`. Default `dependents="self"` in the form no longer registers as "done"; only fields the user explicitly answered count.
|
| 53 |
+
|
| 54 |
+
### Frontend surface
|
| 55 |
+
|
| 56 |
+
- `frontend/src/lib/api.ts::postSessionClear` β typed wrapper for the new endpoint.
|
| 57 |
+
- `frontend/src/app/page.tsx::handleClearChat` β always rotates the session_id via `postSessionClear`, adopts the returned `new_session_id`, persists it to `sessionStorage`, and wipes messages + `profileCompleteness` + chat-history `localStorage`.
|
| 58 |
+
- `sessionStorage` (not `localStorage`) for the session_id is deliberate: matches the spec β new tab = new UUID, same-tab refresh = same UUID.
|
| 59 |
+
|
| 60 |
+
## Consequences
|
| 61 |
+
|
| 62 |
+
**Positive.**
|
| 63 |
+
- One-line semantic for every action; no more "did clear-chat work?" ambiguity.
|
| 64 |
+
- Recall is opt-in. Privacy posture matches what users assume from a chat UI.
|
| 65 |
+
- The 25% phantom-progress badge on first visit is gone β the % matches the fields the user actually answered.
|
| 66 |
+
- No data loss path: on-disk profile JSONs are never deleted as a side effect of any frontend action.
|
| 67 |
+
|
| 68 |
+
**Trade-offs.**
|
| 69 |
+
- One extra turn when a returning user gives their name (the welcome-back ask). Worth it β the prior silent merge was a privacy footgun.
|
| 70 |
+
- The `Profile.asked` list is now load-bearing for the completeness scorer. It was already maintained by `record_answer` + the sales_brain orchestrator path; the `POST /api/profile` endpoint now also appends to it on every accepted field.
|
| 71 |
+
|
| 72 |
+
**Carried forward.**
|
| 73 |
+
- KI-040 + KI-077 + KI-118 cross-session name-based recovery is preserved β just gated.
|
| 74 |
+
- Voice / RAG / recommendation paths are untouched; the gate sits in the fact-find branch only.
|
| 75 |
+
- The legacy `POST /api/session/reset` is left in place for backwards compatibility (any external smoke test or admin tool that hits it still works). New frontend code MUST use `/api/session/clear`.
|
| 76 |
+
|
| 77 |
+
## Rollback
|
| 78 |
+
|
| 79 |
+
Revert the three backend hunks (orchestrator gate + sales_brain prompt hook + main.py endpoint) and the page.tsx + api.ts hunks. The `Profile.asked`-gated completeness scorer can be reverted independently if the change creates friction for the profile-builder UX.
|
|
@@ -68,6 +68,13 @@ Every LLM role is a `NimChainLLM` candidate pool, NOT a hardcoded single model.
|
|
| 68 |
- **Natural-conversation escape (KI-045):** intent_change phrases / off-topic questions still exit fact-find by routing through `should_route_to_fact_find` upstream of the brain.
|
| 69 |
- **Indic queries** route through Sarvam-M for translation on input + output; the sales brain runs in English on the translated text.
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
## Refusal precision (KI-046)
|
| 72 |
|
| 73 |
- Persona prompt now explicitly instructs the bot to refuse on **fanciful / out-of-scope scenarios** (space tourism, diamond-tipped surgery, fictional procedures) with a specific refusal sentence.
|
|
|
|
| 68 |
- **Natural-conversation escape (KI-045):** intent_change phrases / off-topic questions still exit fact-find by routing through `should_route_to_fact_find` upstream of the brain.
|
| 69 |
- **Indic queries** route through Sarvam-M for translation on input + output; the sales brain runs in English on the translated text.
|
| 70 |
|
| 71 |
+
## Session & profile lifecycle (ADR-041) β KI-196
|
| 72 |
+
|
| 73 |
+
- **Three identity layers stay distinct.** `session_id` (UUID, in-memory only via `backend/session_state.py`) β `persona_id` (12-char SHA1, keys `40-data/profiles/<persona_id>.json`) β `name_slug` (canonical first-name, Chroma chunk key). New tab = new `session_id`; same-tab refresh = same `session_id` (`sessionStorage`); on-disk profile JSONs are durable user data and are NEVER deleted by frontend actions.
|
| 74 |
+
- **`POST /api/session/clear`** is the canonical "Clear chat" endpoint. Body `{session_id}`; reply `{cleared, new_session_id}`. Wipes the in-memory entry via `clear_session()` and always mints a fresh UUID. The legacy `POST /api/session/reset` (KI-020) is left in place for backwards compatibility but the frontend Clear-chat button calls `/api/session/clear`.
|
| 75 |
+
- **Confirmation-gated profile recall.** When a fresh session captures a `name` that matches an on-disk profile, the orchestrator stages the match into `session.pending_profile_recall` (NEW field on `SessionState`) and overrides the brain's `reply_text` for that turn with a deterministic welcome-back ask ("Welcome back, Rohit β I have a profile under your name from before: age 29, metro, first buy. Continue from there or start fresh?"). The user's NEXT message hits a gate at the top of `handle_turn`: affirm β `rehydrate_by_name`; negate β drop; ambiguous β leave staged so the brain re-asks via the WELCOME-BACK GATE block in `_build_system_prompt`. The legacy KI-118 silent auto-merge is gone.
|
| 76 |
+
- **Profile completeness gates on `Profile.asked`.** Default `dependents="self"` pre-fill in the builder form no longer registers as "done"; `profile_completeness_view` + `POST /api/profile` mask any field not in the `asked` list before scoring. The "Your profile X% done" badge now starts at 0% for a brand-new session and only ticks up on explicit captures.
|
| 77 |
+
|
| 78 |
## Refusal precision (KI-046)
|
| 79 |
|
| 80 |
- Persona prompt now explicitly instructs the bot to refuse on **fanciful / out-of-scope scenarios** (space tourism, diamond-tipped surgery, fictional procedures) with a specific refusal sentence.
|
|
@@ -69,6 +69,7 @@ Thirty-plus knowledge increments landed today. The corpus was rebuilt, the marke
|
|
| 69 |
- **KI-175** β NIM chain reorder: Mistral Large 3 675B promoted to primary, nemotron-49b demoted to last resort across all three chains. The old "nemotron primary" ordering predated the 675B model's availability.
|
| 70 |
- **KI-176** β OpenRouter re-added to the chain pool with `models: [...]` server-side fallback within the OR free pool. KI-178 live-audit then confirmed which OR free-tier models actually support `response_format` (nemotron-3-super-120b, qwen3-next-80b, gemma-4 β not Llama 3.3 70B or Hermes 3 405B).
|
| 71 |
- **KI-179** β Google AI Studio added as Tier 0 primary on Brain Fast + Brain Main ([ADR-040](70-docs/60-decisions/ADR-040-google-gemini-primary.md)). `backend/providers/google_gemini_llm.py` wrapper matches the `LLMProvider` interface; chains now span {Google β NIM β OpenRouter}. ADR-038 (NIM-only lock) is superseded β the lock was relaxed once KI-167 retired the `<FF>` trailer convention that originally motivated it. Brain Fast primary is `gemini-2.0-flash`; Brain Main primary is `gemini-2.5-flash`; Judge stays NIM Mistral Large 3 675B (different family from Gemini, preserving the brain β judge non-circular grading invariant). Google's free tier: 1500 req/day, 15 req/min, native JSON via `response_mime_type=application/json`.
|
|
|
|
| 72 |
|
| 73 |
Per-insurer card counts (166 total across 19 real insurers): HDFC ERGO 15 Β· National Insurance 14 Β· Niva Bupa 14 Β· Bajaj Allianz 13 Β· ICICI Lombard 13 Β· Star Health 11 Β· Care Health 10 Β· New India Assurance 9 Β· Tata AIG 9 Β· Acko 7 Β· Aditya Birla 7 Β· Royal Sundaram 7 Β· Cholamandalam MS 6 Β· Go Digit 6 Β· IFFCO Tokio 6 Β· ManipalCigna 6 Β· SBI General 6 Β· IndusInd General 3 Β· Oriental Insurance 3 Β· Reliance General 1.
|
| 74 |
|
|
|
|
| 69 |
- **KI-175** β NIM chain reorder: Mistral Large 3 675B promoted to primary, nemotron-49b demoted to last resort across all three chains. The old "nemotron primary" ordering predated the 675B model's availability.
|
| 70 |
- **KI-176** β OpenRouter re-added to the chain pool with `models: [...]` server-side fallback within the OR free pool. KI-178 live-audit then confirmed which OR free-tier models actually support `response_format` (nemotron-3-super-120b, qwen3-next-80b, gemma-4 β not Llama 3.3 70B or Hermes 3 405B).
|
| 71 |
- **KI-179** β Google AI Studio added as Tier 0 primary on Brain Fast + Brain Main ([ADR-040](70-docs/60-decisions/ADR-040-google-gemini-primary.md)). `backend/providers/google_gemini_llm.py` wrapper matches the `LLMProvider` interface; chains now span {Google β NIM β OpenRouter}. ADR-038 (NIM-only lock) is superseded β the lock was relaxed once KI-167 retired the `<FF>` trailer convention that originally motivated it. Brain Fast primary is `gemini-2.0-flash`; Brain Main primary is `gemini-2.5-flash`; Judge stays NIM Mistral Large 3 675B (different family from Gemini, preserving the brain β judge non-circular grading invariant). Google's free tier: 1500 req/day, 15 req/min, native JSON via `response_mime_type=application/json`.
|
| 72 |
+
- **KI-196** β Session & profile lifecycle ([ADR-041](70-docs/60-decisions/ADR-041-session-profile-lifecycle.md)). Three identity layers (`session_id` / `persona_id` / `name_slug`) are now spec'd end-to-end. New `POST /api/session/clear` endpoint rotates the session_id and wipes in-memory state without touching the on-disk profile JSON; the frontend "Clear chat" button calls this instead of the legacy `/api/session/reset`. The silent KI-118 name-based profile auto-merge is replaced with a **confirmation-gated welcome-back ask** β when a user provides a name that matches an on-disk profile, the bot stages `session.pending_profile_recall` and asks the user whether to continue from the prior captures or start fresh. Profile completeness now gates on `Profile.asked`, so the builder modal's default `dependents="self"` pre-fill no longer registers as "25% DONE" on a brand-new session.
|
| 73 |
|
| 74 |
Per-insurer card counts (166 total across 19 real insurers): HDFC ERGO 15 Β· National Insurance 14 Β· Niva Bupa 14 Β· Bajaj Allianz 13 Β· ICICI Lombard 13 Β· Star Health 11 Β· Care Health 10 Β· New India Assurance 9 Β· Tata AIG 9 Β· Acko 7 Β· Aditya Birla 7 Β· Royal Sundaram 7 Β· Cholamandalam MS 6 Β· Go Digit 6 Β· IFFCO Tokio 6 Β· ManipalCigna 6 Β· SBI General 6 Β· IndusInd General 3 Β· Oriental Insurance 3 Β· Reliance General 1.
|
| 75 |
|
|
@@ -1026,6 +1026,38 @@ class SessionResetResponse(BaseModel):
|
|
| 1026 |
cleared_state: bool
|
| 1027 |
|
| 1028 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1029 |
@app.post("/api/session/reset", response_model=SessionResetResponse)
|
| 1030 |
async def session_reset(req: SessionResetRequest):
|
| 1031 |
"""KI-020 β User-facing chat clear / fresh-start toggle.
|
|
@@ -1080,6 +1112,12 @@ async def profile_update(req: ProfileUpdateRequest):
|
|
| 1080 |
# KI-095 β never clobber a filled field with empty input from the client
|
| 1081 |
continue
|
| 1082 |
setattr(sess.profile, field_name, v)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1083 |
|
| 1084 |
# KI-077 β if name is set, also persist to the named-profile store so a
|
| 1085 |
# returning visitor's profile is recoverable across sessions.
|
|
@@ -1099,9 +1137,14 @@ async def profile_update(req: ProfileUpdateRequest):
|
|
| 1099 |
"parents_age_max": p.parents_age_max, "parents_has_ped": p.parents_has_ped,
|
| 1100 |
"health_conditions": p.health_conditions, "budget_band": p.budget_band,
|
| 1101 |
}
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1105 |
|
| 1106 |
# Ingest the profile into the RAG store so the brain sees user context
|
| 1107 |
# at retrieval time alongside policy + regulatory chunks. Fire-and-forget
|
|
@@ -1155,9 +1198,19 @@ async def profile_completeness_view(session_id: Optional[str] = None):
|
|
| 1155 |
"parents_age_max": p.parents_age_max, "parents_has_ped": p.parents_has_ped,
|
| 1156 |
"health_conditions": p.health_conditions, "budget_band": p.budget_band,
|
| 1157 |
}
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1161 |
hint = None
|
| 1162 |
try:
|
| 1163 |
# KI-167 WS3 (2026-05-15) β next_question now returns the field name
|
|
|
|
| 1026 |
cleared_state: bool
|
| 1027 |
|
| 1028 |
|
| 1029 |
+
class SessionClearRequest(BaseModel):
|
| 1030 |
+
session_id: str
|
| 1031 |
+
|
| 1032 |
+
|
| 1033 |
+
class SessionClearResponse(BaseModel):
|
| 1034 |
+
cleared: bool
|
| 1035 |
+
new_session_id: str
|
| 1036 |
+
|
| 1037 |
+
|
| 1038 |
+
@app.post("/api/session/clear", response_model=SessionClearResponse)
|
| 1039 |
+
async def session_clear(req: SessionClearRequest):
|
| 1040 |
+
"""KI-196 (ADR-041) β Clean Clear-chat semantic. Wipes the in-memory
|
| 1041 |
+
session state for the supplied session_id and ALWAYS returns a freshly
|
| 1042 |
+
minted UUID the frontend must adopt as its new session_id going forward.
|
| 1043 |
+
|
| 1044 |
+
The on-disk profile JSON under `40-data/profiles/` is intentionally NOT
|
| 1045 |
+
touched β it remains durable user data keyed by persona_id / name slug.
|
| 1046 |
+
The next time the user volunteers their name in conversation, the
|
| 1047 |
+
confirmation-gated recall flow (see orchestrator's `pending_profile_recall`)
|
| 1048 |
+
will ask before merging the prior captures.
|
| 1049 |
+
|
| 1050 |
+
Body : {session_id: str}
|
| 1051 |
+
Reply: {cleared: bool, new_session_id: str}
|
| 1052 |
+
"""
|
| 1053 |
+
from backend.session_state import clear_session
|
| 1054 |
+
cleared = clear_session(req.session_id) if req.session_id else False
|
| 1055 |
+
return SessionClearResponse(
|
| 1056 |
+
cleared=cleared,
|
| 1057 |
+
new_session_id=uuid.uuid4().hex[:12],
|
| 1058 |
+
)
|
| 1059 |
+
|
| 1060 |
+
|
| 1061 |
@app.post("/api/session/reset", response_model=SessionResetResponse)
|
| 1062 |
async def session_reset(req: SessionResetRequest):
|
| 1063 |
"""KI-020 β User-facing chat clear / fresh-start toggle.
|
|
|
|
| 1112 |
# KI-095 β never clobber a filled field with empty input from the client
|
| 1113 |
continue
|
| 1114 |
setattr(sess.profile, field_name, v)
|
| 1115 |
+
# KI-196 (ADR-041) β mark the slot as explicitly answered so the
|
| 1116 |
+
# completeness scorer recognises it. Without this, builder-form
|
| 1117 |
+
# captures land on the profile but the badge still reads 0% because
|
| 1118 |
+
# profile_completeness_view now gates on `Profile.asked`.
|
| 1119 |
+
if field_name not in sess.profile.asked:
|
| 1120 |
+
sess.profile.asked.append(field_name)
|
| 1121 |
|
| 1122 |
# KI-077 β if name is set, also persist to the named-profile store so a
|
| 1123 |
# returning visitor's profile is recoverable across sessions.
|
|
|
|
| 1137 |
"parents_age_max": p.parents_age_max, "parents_has_ped": p.parents_has_ped,
|
| 1138 |
"health_conditions": p.health_conditions, "budget_band": p.budget_band,
|
| 1139 |
}
|
| 1140 |
+
# KI-196 (ADR-041) β same answered-only gate as profile_completeness_view.
|
| 1141 |
+
answered = set(getattr(p, "asked", []) or [])
|
| 1142 |
+
completeness_input = {
|
| 1143 |
+
k: (v if k in answered else None) for k, v in profile_dict.items()
|
| 1144 |
+
}
|
| 1145 |
+
c = _completeness(completeness_input)
|
| 1146 |
+
collected = [k for k, v in profile_dict.items() if k in answered and v not in (None, "", [], False)]
|
| 1147 |
+
missing = [k for k, v in profile_dict.items() if k not in answered or v in (None, "", [])]
|
| 1148 |
|
| 1149 |
# Ingest the profile into the RAG store so the brain sees user context
|
| 1150 |
# at retrieval time alongside policy + regulatory chunks. Fire-and-forget
|
|
|
|
| 1198 |
"parents_age_max": p.parents_age_max, "parents_has_ped": p.parents_has_ped,
|
| 1199 |
"health_conditions": p.health_conditions, "budget_band": p.budget_band,
|
| 1200 |
}
|
| 1201 |
+
# KI-196 (ADR-041) β Profile completeness must reflect fields the user
|
| 1202 |
+
# EXPLICITLY answered, not defaults that were never touched. Default
|
| 1203 |
+
# `dependents="self"` pre-populated in the builder UI used to count as
|
| 1204 |
+
# "done" and produced the misleading "25% DONE" badge on a zero-input
|
| 1205 |
+
# session. Gate every field on Profile.asked containing the field name
|
| 1206 |
+
# before exposing it to the completeness scorer.
|
| 1207 |
+
answered = set(getattr(p, "asked", []) or [])
|
| 1208 |
+
completeness_input = {
|
| 1209 |
+
k: (v if k in answered else None) for k, v in profile_dict.items()
|
| 1210 |
+
}
|
| 1211 |
+
c = _completeness(completeness_input)
|
| 1212 |
+
collected = [k for k, v in profile_dict.items() if k in answered and v not in (None, "", [], False)]
|
| 1213 |
+
missing = [k for k, v in profile_dict.items() if k not in answered or v in (None, "", [])]
|
| 1214 |
hint = None
|
| 1215 |
try:
|
| 1216 |
# KI-167 WS3 (2026-05-15) β next_question now returns the field name
|
|
@@ -575,6 +575,57 @@ async def handle_turn(
|
|
| 575 |
query=user_text,
|
| 576 |
)
|
| 577 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
if treat_as_fact_find:
|
| 579 |
# KI-167 (2026-05-15) β WS2: replaces drive_fact_find with
|
| 580 |
# drive_sales_brain. The new brain owns conversation flow end-to-end
|
|
@@ -591,6 +642,7 @@ async def handle_turn(
|
|
| 591 |
profile=session.profile,
|
| 592 |
chat_history=chat_history[-10:],
|
| 593 |
session_id=session_id,
|
|
|
|
| 594 |
),
|
| 595 |
timeout=45.0, # KI-170 β bumped from 25s; qwen3-next-80b + JSON mode regularly lands 15-25s
|
| 596 |
)
|
|
@@ -642,40 +694,121 @@ async def handle_turn(
|
|
| 642 |
p = session.profile
|
| 643 |
slug = _normalise_name(p.name or "")
|
| 644 |
if slug:
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
except Exception as e:
|
| 659 |
logging.warning(
|
| 660 |
-
"sales_brain profile-chunk upsert failed (session=%s): %s: %s",
|
| 661 |
session_id, type(e).__name__, str(e)[:200],
|
| 662 |
)
|
| 663 |
|
| 664 |
# KI-040 / KI-062 β named-profile persistence preserved. If the brain
|
| 665 |
# captured (or already has) a name on the profile, write the merged
|
| 666 |
# profile to disk so the next visit can welcome the user back.
|
| 667 |
-
#
|
| 668 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
if session.profile.name:
|
| 670 |
try:
|
| 671 |
-
from backend.profile_store import save_profile
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
save_profile(session.profile.name, session.profile, session_id=session_id)
|
| 680 |
except Exception:
|
| 681 |
pass
|
|
|
|
| 575 |
query=user_text,
|
| 576 |
)
|
| 577 |
|
| 578 |
+
# KI-196 (ADR-041) β confirmation-gated profile recall. If the prior
|
| 579 |
+
# turn staged a pending recall (matched name β on-disk profile), the
|
| 580 |
+
# bot's last reply already asked the user whether to continue or start
|
| 581 |
+
# fresh. THIS turn's user_text decides:
|
| 582 |
+
# - Affirm β merge stored profile (via rehydrate_by_name) and continue.
|
| 583 |
+
# - Negate β drop pending_profile_recall, treat as new user.
|
| 584 |
+
# - Other β leave pending_profile_recall in place; the brain will
|
| 585 |
+
# re-ask one more time. (One re-ask cap is enforced by the
|
| 586 |
+
# brain's prompt β see sales_brain._build_system_prompt.)
|
| 587 |
+
if session.pending_profile_recall is not None:
|
| 588 |
+
_utxt = (user_text or "").strip().lower()
|
| 589 |
+
_AFFIRM = (
|
| 590 |
+
"yes", "yeah", "yep", "yup", "yes please", "continue", "continue from there",
|
| 591 |
+
"use that", "load it", "load that", "welcome back", "from there",
|
| 592 |
+
"carry on", "ok", "okay", "sure", "go ahead", "proceed", "haan", "ha",
|
| 593 |
+
"use my profile", "use the profile", "resume",
|
| 594 |
+
)
|
| 595 |
+
_NEGATE = (
|
| 596 |
+
"no", "nope", "nah", "start fresh", "fresh", "new", "new profile",
|
| 597 |
+
"start over", "from scratch", "different", "not me", "that's not me",
|
| 598 |
+
"thats not me", "discard", "begin again", "nahi", "nai",
|
| 599 |
+
)
|
| 600 |
+
_is_affirm = (
|
| 601 |
+
_utxt in _AFFIRM
|
| 602 |
+
or any(_utxt.startswith(a + " ") or _utxt.endswith(" " + a) for a in _AFFIRM)
|
| 603 |
+
or " continue " in f" {_utxt} "
|
| 604 |
+
or "from there" in _utxt
|
| 605 |
+
or "use that profile" in _utxt
|
| 606 |
+
)
|
| 607 |
+
_is_negate = (
|
| 608 |
+
_utxt in _NEGATE
|
| 609 |
+
or any(_utxt.startswith(n + " ") or _utxt.endswith(" " + n) for n in _NEGATE)
|
| 610 |
+
or "start fresh" in _utxt
|
| 611 |
+
or "start over" in _utxt
|
| 612 |
+
or "from scratch" in _utxt
|
| 613 |
+
)
|
| 614 |
+
if _is_affirm and not _is_negate:
|
| 615 |
+
try:
|
| 616 |
+
from backend.session_state import rehydrate_by_name as _rehydrate
|
| 617 |
+
_rehydrate(session, session.pending_profile_recall.get("name") or session.profile.name or "")
|
| 618 |
+
except Exception as _e:
|
| 619 |
+
logging.warning(
|
| 620 |
+
"pending_profile_recall merge failed (session=%s): %s",
|
| 621 |
+
session_id, _e,
|
| 622 |
+
)
|
| 623 |
+
session.pending_profile_recall = None
|
| 624 |
+
elif _is_negate and not _is_affirm:
|
| 625 |
+
session.pending_profile_recall = None
|
| 626 |
+
# If neither was detected, leave it staged β sales_brain will re-ask
|
| 627 |
+
# via its system-prompt hook (see _build_system_prompt).
|
| 628 |
+
|
| 629 |
if treat_as_fact_find:
|
| 630 |
# KI-167 (2026-05-15) β WS2: replaces drive_fact_find with
|
| 631 |
# drive_sales_brain. The new brain owns conversation flow end-to-end
|
|
|
|
| 642 |
profile=session.profile,
|
| 643 |
chat_history=chat_history[-10:],
|
| 644 |
session_id=session_id,
|
| 645 |
+
pending_profile_recall=session.pending_profile_recall,
|
| 646 |
),
|
| 647 |
timeout=45.0, # KI-170 β bumped from 25s; qwen3-next-80b + JSON mode regularly lands 15-25s
|
| 648 |
)
|
|
|
|
| 694 |
p = session.profile
|
| 695 |
slug = _normalise_name(p.name or "")
|
| 696 |
if slug:
|
| 697 |
+
# KI-197 (2026-05-15) β fire-and-forget. The profile-chunk
|
| 698 |
+
# upsert involves embedding generation + Chroma write
|
| 699 |
+
# (~200-500ms). Awaiting it serially adds that to the
|
| 700 |
+
# user-perceived turn latency for ZERO reason β the next
|
| 701 |
+
# turn's retrieval doesn't depend on this chunk being
|
| 702 |
+
# persisted yet. Schedule on the event loop, log errors
|
| 703 |
+
# via the task's add_done_callback if you ever need to
|
| 704 |
+
# debug β but don't block the response.
|
| 705 |
+
async def _bg_upsert(slug_=slug, p_=p, sid_=session_id):
|
| 706 |
+
try:
|
| 707 |
+
await upsert_profile_chunk(slug_, {
|
| 708 |
+
"age": p_.age,
|
| 709 |
+
"dependents": p_.dependents,
|
| 710 |
+
"income_band": p_.income_band,
|
| 711 |
+
"existing_cover_inr": p_.existing_cover_inr,
|
| 712 |
+
"primary_goal": p_.primary_goal,
|
| 713 |
+
"location_tier": p_.location_tier,
|
| 714 |
+
"parents_to_insure": p_.parents_to_insure,
|
| 715 |
+
"parents_age_max": p_.parents_age_max,
|
| 716 |
+
"parents_has_ped": p_.parents_has_ped,
|
| 717 |
+
"budget_band": p_.budget_band,
|
| 718 |
+
"health_conditions": p_.health_conditions,
|
| 719 |
+
})
|
| 720 |
+
except Exception as e:
|
| 721 |
+
logging.warning(
|
| 722 |
+
"sales_brain profile-chunk upsert failed bg (session=%s): %s: %s",
|
| 723 |
+
sid_, type(e).__name__, str(e)[:200],
|
| 724 |
+
)
|
| 725 |
+
asyncio.create_task(_bg_upsert())
|
| 726 |
except Exception as e:
|
| 727 |
logging.warning(
|
| 728 |
+
"sales_brain profile-chunk upsert scheduling failed (session=%s): %s: %s",
|
| 729 |
session_id, type(e).__name__, str(e)[:200],
|
| 730 |
)
|
| 731 |
|
| 732 |
# KI-040 / KI-062 β named-profile persistence preserved. If the brain
|
| 733 |
# captured (or already has) a name on the profile, write the merged
|
| 734 |
# profile to disk so the next visit can welcome the user back.
|
| 735 |
+
#
|
| 736 |
+
# KI-196 (ADR-041) β confirmation-gated recall. The silent auto-merge
|
| 737 |
+
# from KI-118 confused users into thinking the bot "remembered" them
|
| 738 |
+
# from a prior session. Replaced with a stage-then-ask flow:
|
| 739 |
+
# 1. Name newly captured this turn AND a stored profile exists
|
| 740 |
+
# under that name AND there's not already a pending recall AND
|
| 741 |
+
# the current session has NOT already accumulated a meaningful
|
| 742 |
+
# profile (>1 explicitly-answered slot β they're genuinely a
|
| 743 |
+
# fresh session, not a returning user mid-correction).
|
| 744 |
+
# 2. Stage the stored snapshot to `session.pending_profile_recall`.
|
| 745 |
+
# 3. The user's NEXT message hits the confirmation gate at the top
|
| 746 |
+
# of handle_turn (BEFORE drive_sales_brain) and either merges
|
| 747 |
+
# or discards based on affirm/negate intent.
|
| 748 |
+
# The disk save still runs every turn so partial captures aren't lost.
|
| 749 |
if session.profile.name:
|
| 750 |
try:
|
| 751 |
+
from backend.profile_store import save_profile, load_profile
|
| 752 |
+
if (
|
| 753 |
+
"name" in fact_find_profile_updates
|
| 754 |
+
and session.pending_profile_recall is None
|
| 755 |
+
):
|
| 756 |
+
# Count explicitly-answered slots OTHER than name on the
|
| 757 |
+
# live session. If >1, treat as continuation, not a fresh
|
| 758 |
+
# returning visit, and skip the gate (their current
|
| 759 |
+
# captures win β same intent as the old merge semantics).
|
| 760 |
+
answered_non_name = [
|
| 761 |
+
s for s in (session.profile.asked or [])
|
| 762 |
+
if s != "name"
|
| 763 |
+
]
|
| 764 |
+
stored = load_profile(session.profile.name)
|
| 765 |
+
if stored is not None and len(answered_non_name) <= 1:
|
| 766 |
+
summary = {
|
| 767 |
+
"age": stored.age,
|
| 768 |
+
"dependents": stored.dependents,
|
| 769 |
+
"location_tier": stored.location_tier,
|
| 770 |
+
"income_band": stored.income_band,
|
| 771 |
+
"primary_goal": stored.primary_goal,
|
| 772 |
+
"health_conditions": stored.health_conditions,
|
| 773 |
+
"budget_band": stored.budget_band,
|
| 774 |
+
"existing_cover_inr": stored.existing_cover_inr,
|
| 775 |
+
}
|
| 776 |
+
# Drop None / empty values so the brain's recall
|
| 777 |
+
# prompt summary is tight.
|
| 778 |
+
summary = {
|
| 779 |
+
k: v for k, v in summary.items()
|
| 780 |
+
if v not in (None, "", [])
|
| 781 |
+
}
|
| 782 |
+
session.pending_profile_recall = {
|
| 783 |
+
"name": session.profile.name,
|
| 784 |
+
"summary": summary,
|
| 785 |
+
"captured_this_turn": dict(fact_find_profile_updates),
|
| 786 |
+
"staged_at": time.time(),
|
| 787 |
+
}
|
| 788 |
+
# Override the brain's reply for THIS turn with a
|
| 789 |
+
# deterministic welcome-back ask. The brain didn't
|
| 790 |
+
# know about the stored profile when it composed its
|
| 791 |
+
# reply (the load_profile lookup happens in this
|
| 792 |
+
# post-processing block), so its prose would have
|
| 793 |
+
# been the next-slot fact-find question β not what
|
| 794 |
+
# the user should see right now. Subsequent turns
|
| 795 |
+
# WILL go through the brain with the recall directive
|
| 796 |
+
# in the system prompt (see _build_system_prompt).
|
| 797 |
+
_bits = []
|
| 798 |
+
if summary.get("age") is not None:
|
| 799 |
+
_bits.append(f"age {summary['age']}")
|
| 800 |
+
if summary.get("location_tier"):
|
| 801 |
+
_bits.append(str(summary["location_tier"]))
|
| 802 |
+
if summary.get("dependents"):
|
| 803 |
+
_bits.append(str(summary["dependents"]))
|
| 804 |
+
if summary.get("primary_goal"):
|
| 805 |
+
_bits.append(str(summary["primary_goal"]).replace("_", " "))
|
| 806 |
+
_summary_phrase = ", ".join(_bits) if _bits else "your earlier captures"
|
| 807 |
+
sb_result.reply_text = (
|
| 808 |
+
f"Welcome back, {session.profile.name} β I have a profile under your name "
|
| 809 |
+
f"from before: {_summary_phrase}. Continue from there or start fresh?"
|
| 810 |
+
)
|
| 811 |
+
sb_result.brain_used = "sales_brain::welcome_back_ask"
|
| 812 |
save_profile(session.profile.name, session.profile, session_id=session_id)
|
| 813 |
except Exception:
|
| 814 |
pass
|
|
@@ -43,7 +43,11 @@ path (Tier 0, via TieredBrainLLM wrapper).
|
|
| 43 |
from __future__ import annotations
|
| 44 |
|
| 45 |
import asyncio
|
|
|
|
|
|
|
| 46 |
import os
|
|
|
|
|
|
|
| 47 |
from typing import Optional
|
| 48 |
|
| 49 |
import httpx
|
|
@@ -52,8 +56,47 @@ from backend.providers.base import ChatMessage, LLMProvider, LLMResult
|
|
| 52 |
|
| 53 |
|
| 54 |
GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"
|
|
|
|
| 55 |
DEFAULT_MODEL = "gemini-2.5-flash-lite" # KI-183 β gemini-2.0-flash retired for new accounts
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
def _to_gemini_contents(messages: list[ChatMessage]) -> tuple[Optional[str], list[dict]]:
|
| 59 |
"""Split an OpenAI-style message list into (systemInstruction, contents).
|
|
@@ -115,12 +158,102 @@ class GoogleGeminiLLM(LLMProvider):
|
|
| 115 |
self.timeout = timeout
|
| 116 |
self.name = f"gemini::{model}"
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
async def chat(
|
| 119 |
self,
|
| 120 |
messages: list[ChatMessage],
|
| 121 |
temperature: float = 0.6,
|
| 122 |
max_tokens: int = 700,
|
| 123 |
response_format: Optional[dict] = None,
|
|
|
|
| 124 |
**kwargs, # absorb OR-specific kwargs like `models=[...]` β ignored here
|
| 125 |
) -> LLMResult:
|
| 126 |
if not self.api_key:
|
|
@@ -144,7 +277,14 @@ class GoogleGeminiLLM(LLMProvider):
|
|
| 144 |
"contents": contents,
|
| 145 |
"generationConfig": generation_config,
|
| 146 |
}
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
body["systemInstruction"] = {"parts": [{"text": system_instruction}]}
|
| 149 |
|
| 150 |
# API key goes in the URL query param β the Google AI Studio default.
|
|
@@ -165,6 +305,33 @@ class GoogleGeminiLLM(LLMProvider):
|
|
| 165 |
|
| 166 |
async with httpx.AsyncClient(timeout=client_timeout) as client:
|
| 167 |
resp = await client.post(url, headers=headers, json=body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
if resp.status_code >= 400:
|
| 169 |
# Surface the Google error body so the caller's log makes the
|
| 170 |
# root cause visible (typical failure: 429 quota exceeded or
|
|
@@ -230,4 +397,9 @@ def get_gemini_llm(
|
|
| 230 |
return GoogleGeminiLLM(model=model, timeout=timeout)
|
| 231 |
|
| 232 |
|
| 233 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
from __future__ import annotations
|
| 44 |
|
| 45 |
import asyncio
|
| 46 |
+
import hashlib
|
| 47 |
+
import logging
|
| 48 |
import os
|
| 49 |
+
import threading
|
| 50 |
+
import time
|
| 51 |
from typing import Optional
|
| 52 |
|
| 53 |
import httpx
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"
|
| 59 |
+
GEMINI_CACHE_URL = "https://generativelanguage.googleapis.com/v1beta/cachedContents"
|
| 60 |
DEFAULT_MODEL = "gemini-2.5-flash-lite" # KI-183 β gemini-2.0-flash retired for new accounts
|
| 61 |
|
| 62 |
+
# ----------------------------------------------------------------------------
|
| 63 |
+
# KI-199 β module-level cachedContents registry.
|
| 64 |
+
#
|
| 65 |
+
# Keyed by `(model, sha256(system_text))` so the same base preamble shared
|
| 66 |
+
# across sales_brain calls deduplicates onto one cache. Each value is a small
|
| 67 |
+
# dict with the cache `name` (the server-side resource id used in subsequent
|
| 68 |
+
# generateContent bodies as `cachedContent`) and the local-clock `expires_at`
|
| 69 |
+
# wall-time so we can self-evict before issuing a guaranteed-miss request.
|
| 70 |
+
#
|
| 71 |
+
# A threading.Lock guards entries against the rare interleave where two
|
| 72 |
+
# coroutines on different event loops race to create the same cache. In
|
| 73 |
+
# practice asyncio gives us implicit single-task ordering on one loop, but
|
| 74 |
+
# this is cheap insurance and keeps the contract honest if the module is ever
|
| 75 |
+
# pulled into a thread pool.
|
| 76 |
+
# ----------------------------------------------------------------------------
|
| 77 |
+
_CACHE_REGISTRY: dict[tuple[str, str], dict] = {}
|
| 78 |
+
_CACHE_REGISTRY_LOCK = threading.Lock()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _cache_key(model: str, system_text: str) -> tuple[str, str]:
|
| 82 |
+
"""Build the registry key for a (model, system_text) pair.
|
| 83 |
+
|
| 84 |
+
Hashing the system text rather than storing the raw string keeps the
|
| 85 |
+
registry footprint tiny even when the preamble is multi-KB.
|
| 86 |
+
"""
|
| 87 |
+
return (model, hashlib.sha256(system_text.encode("utf-8")).hexdigest())
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def invalidate_cache(model: str, system_text: str) -> None:
|
| 91 |
+
"""Drop a cache registry entry β called by upstream after a 4xx response
|
| 92 |
+
that names a stale `cachedContent`. The server-side cache may still be
|
| 93 |
+
alive (it will lapse on TTL), but our reference is gone so the next
|
| 94 |
+
chat() call provisions a fresh one.
|
| 95 |
+
"""
|
| 96 |
+
key = _cache_key(model, system_text)
|
| 97 |
+
with _CACHE_REGISTRY_LOCK:
|
| 98 |
+
_CACHE_REGISTRY.pop(key, None)
|
| 99 |
+
|
| 100 |
|
| 101 |
def _to_gemini_contents(messages: list[ChatMessage]) -> tuple[Optional[str], list[dict]]:
|
| 102 |
"""Split an OpenAI-style message list into (systemInstruction, contents).
|
|
|
|
| 158 |
self.timeout = timeout
|
| 159 |
self.name = f"gemini::{model}"
|
| 160 |
|
| 161 |
+
async def create_cache(
|
| 162 |
+
self,
|
| 163 |
+
system_text: str,
|
| 164 |
+
ttl_seconds: int = 300,
|
| 165 |
+
) -> Optional[str]:
|
| 166 |
+
"""Create (or reuse) a Gemini `cachedContents` resource for `system_text`.
|
| 167 |
+
|
| 168 |
+
Returns the cache resource name (e.g. `"cachedContents/<UUID>"`)
|
| 169 |
+
that downstream `chat()` calls should pass as `cached_content_name`.
|
| 170 |
+
Returns None on ANY failure (missing key, too-small payload, 4xx, network
|
| 171 |
+
error) β the caller is expected to proceed without caching when this is
|
| 172 |
+
the case.
|
| 173 |
+
|
| 174 |
+
Re-uses an existing live cache from the module registry when the
|
| 175 |
+
(model, system_text) pair matches AND the local `expires_at` is still
|
| 176 |
+
in the future. Cache misses + creation failures are silent (logged at
|
| 177 |
+
INFO) so a caching outage never breaks the main path β KI-199 brief
|
| 178 |
+
requires fail-safe behaviour.
|
| 179 |
+
"""
|
| 180 |
+
if not self.api_key or not system_text:
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
key = _cache_key(self.model, system_text)
|
| 184 |
+
now = time.time()
|
| 185 |
+
with _CACHE_REGISTRY_LOCK:
|
| 186 |
+
entry = _CACHE_REGISTRY.get(key)
|
| 187 |
+
# Refresh ~10s before expiry so an in-flight request never lands
|
| 188 |
+
# on a server-side cache that just rolled past its TTL.
|
| 189 |
+
if entry and entry.get("expires_at", 0) > now + 10:
|
| 190 |
+
return entry.get("name")
|
| 191 |
+
|
| 192 |
+
# `model` must be the fully-qualified Gemini path "models/<id>".
|
| 193 |
+
body: dict = {
|
| 194 |
+
"model": f"models/{self.model}",
|
| 195 |
+
"systemInstruction": {"parts": [{"text": system_text}]},
|
| 196 |
+
"ttl": f"{int(ttl_seconds)}s",
|
| 197 |
+
}
|
| 198 |
+
url = f"{GEMINI_CACHE_URL}?key={self.api_key}"
|
| 199 |
+
headers = {"Content-Type": "application/json"}
|
| 200 |
+
client_timeout = httpx.Timeout(
|
| 201 |
+
connect=2.0, read=self.timeout, write=2.0, pool=2.0
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
async with httpx.AsyncClient(timeout=client_timeout) as client:
|
| 206 |
+
resp = await client.post(url, headers=headers, json=body)
|
| 207 |
+
if resp.status_code >= 400:
|
| 208 |
+
# Most common 4xx is "the request must contain at least N
|
| 209 |
+
# tokens of cached content" β below the Gemini minimum the
|
| 210 |
+
# cache simply isn't allowed. Log + return None so the caller
|
| 211 |
+
# falls through to the uncached path.
|
| 212 |
+
detail = ""
|
| 213 |
+
try:
|
| 214 |
+
detail = resp.text[:300]
|
| 215 |
+
except Exception:
|
| 216 |
+
pass
|
| 217 |
+
logging.info(
|
| 218 |
+
"gemini.create_cache %s (model=%s): %s",
|
| 219 |
+
resp.status_code, self.model, detail,
|
| 220 |
+
)
|
| 221 |
+
return None
|
| 222 |
+
payload = resp.json()
|
| 223 |
+
except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
|
| 224 |
+
raise
|
| 225 |
+
except Exception as e: # noqa: BLE001 β fail-safe: any error = no cache
|
| 226 |
+
logging.info(
|
| 227 |
+
"gemini.create_cache raised %s (model=%s): %s",
|
| 228 |
+
type(e).__name__, self.model, str(e)[:200],
|
| 229 |
+
)
|
| 230 |
+
return None
|
| 231 |
+
|
| 232 |
+
cache_name = payload.get("name") or ""
|
| 233 |
+
if not cache_name:
|
| 234 |
+
return None
|
| 235 |
+
|
| 236 |
+
with _CACHE_REGISTRY_LOCK:
|
| 237 |
+
_CACHE_REGISTRY[key] = {
|
| 238 |
+
"name": cache_name,
|
| 239 |
+
# Store local expiry; the registered TTL is server-side
|
| 240 |
+
# truth, but we shadow it locally so we self-evict before
|
| 241 |
+
# the inevitable 4xx on an expired reference.
|
| 242 |
+
"expires_at": time.time() + ttl_seconds,
|
| 243 |
+
}
|
| 244 |
+
logging.info(
|
| 245 |
+
"gemini.create_cache OK (model=%s, name=%s, ttl=%ss)",
|
| 246 |
+
self.model, cache_name, ttl_seconds,
|
| 247 |
+
)
|
| 248 |
+
return cache_name
|
| 249 |
+
|
| 250 |
async def chat(
|
| 251 |
self,
|
| 252 |
messages: list[ChatMessage],
|
| 253 |
temperature: float = 0.6,
|
| 254 |
max_tokens: int = 700,
|
| 255 |
response_format: Optional[dict] = None,
|
| 256 |
+
cached_content_name: Optional[str] = None,
|
| 257 |
**kwargs, # absorb OR-specific kwargs like `models=[...]` β ignored here
|
| 258 |
) -> LLMResult:
|
| 259 |
if not self.api_key:
|
|
|
|
| 277 |
"contents": contents,
|
| 278 |
"generationConfig": generation_config,
|
| 279 |
}
|
| 280 |
+
# When a cachedContents resource is in play, the entire systemInstruction
|
| 281 |
+
# already lives inside it server-side; including it again in the request
|
| 282 |
+
# body causes a 400 INVALID_ARGUMENT ("cached prompt and inline prompt
|
| 283 |
+
# mutually exclusive"). Only attach systemInstruction on the uncached
|
| 284 |
+
# path.
|
| 285 |
+
if cached_content_name:
|
| 286 |
+
body["cachedContent"] = cached_content_name
|
| 287 |
+
elif system_instruction:
|
| 288 |
body["systemInstruction"] = {"parts": [{"text": system_instruction}]}
|
| 289 |
|
| 290 |
# API key goes in the URL query param β the Google AI Studio default.
|
|
|
|
| 305 |
|
| 306 |
async with httpx.AsyncClient(timeout=client_timeout) as client:
|
| 307 |
resp = await client.post(url, headers=headers, json=body)
|
| 308 |
+
# KI-199 β graceful fallback when a cache reference is stale.
|
| 309 |
+
# Symptoms: 400/404 with body mentioning "cachedContent" /
|
| 310 |
+
# "cache" / "not found". Strip the reference, re-add the inline
|
| 311 |
+
# systemInstruction, drop the registry entry, retry once.
|
| 312 |
+
if (
|
| 313 |
+
cached_content_name
|
| 314 |
+
and resp.status_code in (400, 403, 404)
|
| 315 |
+
and any(
|
| 316 |
+
tok in (resp.text or "").lower()
|
| 317 |
+
for tok in ("cache", "cachedcontent")
|
| 318 |
+
)
|
| 319 |
+
):
|
| 320 |
+
logging.info(
|
| 321 |
+
"gemini.chat cache miss/invalid (%s) β retrying uncached (model=%s)",
|
| 322 |
+
resp.status_code, self.model,
|
| 323 |
+
)
|
| 324 |
+
# Best-effort invalidate by direct name match in the registry.
|
| 325 |
+
with _CACHE_REGISTRY_LOCK:
|
| 326 |
+
for k, v in list(_CACHE_REGISTRY.items()):
|
| 327 |
+
if v.get("name") == cached_content_name:
|
| 328 |
+
_CACHE_REGISTRY.pop(k, None)
|
| 329 |
+
body.pop("cachedContent", None)
|
| 330 |
+
if system_instruction:
|
| 331 |
+
body["systemInstruction"] = {
|
| 332 |
+
"parts": [{"text": system_instruction}]
|
| 333 |
+
}
|
| 334 |
+
resp = await client.post(url, headers=headers, json=body)
|
| 335 |
if resp.status_code >= 400:
|
| 336 |
# Surface the Google error body so the caller's log makes the
|
| 337 |
# root cause visible (typical failure: 429 quota exceeded or
|
|
|
|
| 397 |
return GoogleGeminiLLM(model=model, timeout=timeout)
|
| 398 |
|
| 399 |
|
| 400 |
+
__all__ = [
|
| 401 |
+
"GoogleGeminiLLM",
|
| 402 |
+
"get_gemini_llm",
|
| 403 |
+
"DEFAULT_MODEL",
|
| 404 |
+
"invalidate_cache",
|
| 405 |
+
]
|
|
@@ -90,8 +90,15 @@ class TieredBrainLLM(LLMProvider):
|
|
| 90 |
temperature: float = 0.2,
|
| 91 |
max_tokens: int = 1024,
|
| 92 |
response_format: Optional[dict] = None,
|
|
|
|
| 93 |
**kwargs, # absorb provider-specific kwargs (e.g. OR's `models=[...]`)
|
| 94 |
) -> LLMResult:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
gemini_exc: Optional[BaseException] = None
|
| 96 |
nim_exc: Optional[BaseException] = None
|
| 97 |
or_exc: Optional[BaseException] = None
|
|
@@ -105,6 +112,7 @@ class TieredBrainLLM(LLMProvider):
|
|
| 105 |
temperature=temperature,
|
| 106 |
max_tokens=max_tokens,
|
| 107 |
response_format=response_format,
|
|
|
|
| 108 |
),
|
| 109 |
timeout=self.per_tier_timeout,
|
| 110 |
)
|
|
|
|
| 90 |
temperature: float = 0.2,
|
| 91 |
max_tokens: int = 1024,
|
| 92 |
response_format: Optional[dict] = None,
|
| 93 |
+
cached_content_name: Optional[str] = None,
|
| 94 |
**kwargs, # absorb provider-specific kwargs (e.g. OR's `models=[...]`)
|
| 95 |
) -> LLMResult:
|
| 96 |
+
"""KI-199 β `cached_content_name`, when supplied by the caller, is
|
| 97 |
+
forwarded to the Gemini tier ONLY. NIM and OpenRouter don't speak
|
| 98 |
+
Gemini's cachedContents protocol so we silently drop the reference on
|
| 99 |
+
those tiers β they receive the full messages list as before, which
|
| 100 |
+
keeps the wire shape correct on fall-through.
|
| 101 |
+
"""
|
| 102 |
gemini_exc: Optional[BaseException] = None
|
| 103 |
nim_exc: Optional[BaseException] = None
|
| 104 |
or_exc: Optional[BaseException] = None
|
|
|
|
| 112 |
temperature=temperature,
|
| 113 |
max_tokens=max_tokens,
|
| 114 |
response_format=response_format,
|
| 115 |
+
cached_content_name=cached_content_name,
|
| 116 |
),
|
| 117 |
timeout=self.per_tier_timeout,
|
| 118 |
)
|
|
@@ -56,7 +56,10 @@ from typing import Any, Optional
|
|
| 56 |
|
| 57 |
from backend.needs_finder import Profile
|
| 58 |
from backend.providers.base import ChatMessage
|
| 59 |
-
from backend.providers.google_gemini_llm import
|
|
|
|
|
|
|
|
|
|
| 60 |
from backend.providers.nvidia_nim_llm import get_fast_brain_llm
|
| 61 |
from backend.providers.openrouter_llm import get_openrouter_llm
|
| 62 |
from backend.sales_brain_normalizer import normalize_captures
|
|
@@ -99,7 +102,7 @@ class SalesBrainResult:
|
|
| 99 |
# + leaves room for one chain fallback. The fast-brain chain already has its
|
| 100 |
# own per-link + total-chain budget; this wait_for is a belt-and-braces stop.
|
| 101 |
_TIMEOUT_S: float = 45.0 # KI-170 β qwen3-next-80b + JSON mode regularly lands 15-25s; 25s breached periodically
|
| 102 |
-
_MAX_TOKENS: int =
|
| 103 |
_TEMPERATURE: float = 0.6 # mirrors fact_find_brain β conversational warmth
|
| 104 |
|
| 105 |
# KI-169 β strip <think>...</think> blocks the LLM may emit inside the JSON
|
|
@@ -291,8 +294,29 @@ def _format_slot_list(slots: list[str]) -> str:
|
|
| 291 |
return "\n".join(f" - {f}: {_SLOT_DESCRIPTIONS.get(f, '')}" for f in slots)
|
| 292 |
|
| 293 |
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
known = _profile_known(profile)
|
| 297 |
required_remaining = _required_remaining(known)
|
| 298 |
nice_to_have_remaining = _nice_to_have_remaining(known, profile)
|
|
@@ -301,11 +325,25 @@ def _build_system_prompt(profile: Profile) -> str:
|
|
| 301 |
json.dumps(known, ensure_ascii=False) if known else "{}"
|
| 302 |
)
|
| 303 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
return (
|
| 305 |
-
|
| 306 |
-
+ "\n\n--- SLOT SCHEMA (the fields you may capture) ---\n"
|
| 307 |
-
+ _format_slot_list(list(_SLOT_DESCRIPTIONS.keys()))
|
| 308 |
-
+ f"\n\nKNOWN: {known_block}"
|
| 309 |
+ "\n(These fields are ALREADY captured. Do NOT re-ask for them. Use the user's name when known.)"
|
| 310 |
+ "\n\nREQUIRED SLOTS STILL MISSING (you need these before setting ready_for_recommendations=true):\n"
|
| 311 |
+ _format_slot_list(required_remaining)
|
|
@@ -317,6 +355,28 @@ def _build_system_prompt(profile: Profile) -> str:
|
|
| 317 |
if not required_remaining else
|
| 318 |
"\n\nKeep gathering naturally β you don't yet have the minimum required set."
|
| 319 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
)
|
| 321 |
|
| 322 |
|
|
@@ -371,6 +431,7 @@ async def drive_sales_brain(
|
|
| 371 |
profile: Profile,
|
| 372 |
chat_history: list[dict],
|
| 373 |
session_id: Optional[str] = None,
|
|
|
|
| 374 |
) -> SalesBrainResult:
|
| 375 |
"""Single LLM call per turn β replaces the scripted slot-walker.
|
| 376 |
|
|
@@ -378,22 +439,53 @@ async def drive_sales_brain(
|
|
| 378 |
`brain_used` is `sales_brain::error:<reason>` and `reply_text` is
|
| 379 |
empty. Caller (orchestrator) is responsible for any fallback behavior
|
| 380 |
β NO scripted reply lives here.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
"""
|
| 382 |
t0 = time.time()
|
| 383 |
|
| 384 |
-
#
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
|
| 387 |
# Build messages: system + last ~10 chat history turns + user_text
|
| 388 |
messages: list[ChatMessage] = [ChatMessage(role="system", content=system_prompt)]
|
| 389 |
if chat_history:
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
| 391 |
role = turn.get("role")
|
| 392 |
content = turn.get("content")
|
| 393 |
if role in ("user", "assistant") and content:
|
| 394 |
messages.append(ChatMessage(role=role, content=str(content)))
|
| 395 |
messages.append(ChatMessage(role="user", content=user_text or ""))
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
# KI-179 (2026-05-15) β 3-tier LLM stack with Google Gemini 2.0 Flash
|
| 398 |
# PRIMARY (Tier 0), NIM fast-brain chain Tier 1, OpenRouter free-tier
|
| 399 |
# pool Tier 2. Google AI Studio's free tier is 1500 req/day with native
|
|
@@ -426,13 +518,41 @@ async def drive_sales_brain(
|
|
| 426 |
)
|
| 427 |
|
| 428 |
if gemini_llm is not None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
try:
|
| 430 |
result = await asyncio.wait_for(
|
| 431 |
gemini_llm.chat(
|
| 432 |
-
messages=
|
| 433 |
temperature=_TEMPERATURE,
|
| 434 |
max_tokens=_MAX_TOKENS,
|
| 435 |
response_format={"type": "json_object"},
|
|
|
|
| 436 |
),
|
| 437 |
timeout=_TIMEOUT_S,
|
| 438 |
)
|
|
@@ -446,6 +566,19 @@ async def drive_sales_brain(
|
|
| 446 |
)
|
| 447 |
except Exception as e: # noqa: BLE001
|
| 448 |
gemini_exc = e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
logging.info(
|
| 450 |
"sales_brain: Gemini raised %s, falling back to NIM (session=%s): %s",
|
| 451 |
type(e).__name__, session_id, str(e)[:200],
|
|
@@ -591,7 +724,17 @@ async def drive_sales_brain(
|
|
| 591 |
"sales_brain empty reply after think-strip β retrying once (session=%s, model=%s)",
|
| 592 |
session_id, served_model,
|
| 593 |
)
|
| 594 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
ChatMessage(
|
| 596 |
role="system",
|
| 597 |
content=(
|
|
@@ -602,14 +745,17 @@ async def drive_sales_brain(
|
|
| 602 |
),
|
| 603 |
),
|
| 604 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
try:
|
| 606 |
retry_result = await asyncio.wait_for(
|
| 607 |
-
llm.chat(
|
| 608 |
-
messages=retry_messages,
|
| 609 |
-
temperature=_TEMPERATURE,
|
| 610 |
-
max_tokens=_MAX_TOKENS,
|
| 611 |
-
response_format={"type": "json_object"},
|
| 612 |
-
),
|
| 613 |
timeout=_TIMEOUT_S,
|
| 614 |
)
|
| 615 |
retry_parsed = _parse_brain_json((retry_result.text or "").strip())
|
|
|
|
| 56 |
|
| 57 |
from backend.needs_finder import Profile
|
| 58 |
from backend.providers.base import ChatMessage
|
| 59 |
+
from backend.providers.google_gemini_llm import (
|
| 60 |
+
get_gemini_llm,
|
| 61 |
+
invalidate_cache as _gemini_invalidate_cache,
|
| 62 |
+
)
|
| 63 |
from backend.providers.nvidia_nim_llm import get_fast_brain_llm
|
| 64 |
from backend.providers.openrouter_llm import get_openrouter_llm
|
| 65 |
from backend.sales_brain_normalizer import normalize_captures
|
|
|
|
| 102 |
# + leaves room for one chain fallback. The fast-brain chain already has its
|
| 103 |
# own per-link + total-chain budget; this wait_for is a belt-and-braces stop.
|
| 104 |
_TIMEOUT_S: float = 45.0 # KI-170 β qwen3-next-80b + JSON mode regularly lands 15-25s; 25s breached periodically
|
| 105 |
+
_MAX_TOKENS: int = 500 # KI-197 β was 700; 500 still covers 1-3 sentence sales_brain replies + JSON wrapper. Saves ~200-400ms per turn.
|
| 106 |
_TEMPERATURE: float = 0.6 # mirrors fact_find_brain β conversational warmth
|
| 107 |
|
| 108 |
# KI-169 β strip <think>...</think> blocks the LLM may emit inside the JSON
|
|
|
|
| 294 |
return "\n".join(f" - {f}: {_SLOT_DESCRIPTIONS.get(f, '')}" for f in slots)
|
| 295 |
|
| 296 |
|
| 297 |
+
# KI-199 β Cached preamble = the strictly-invariant chunk that's identical on
|
| 298 |
+
# every turn for every session: the base tone/output rules + the SLOT SCHEMA
|
| 299 |
+
# section. This is what Gemini cachedContents wraps. Everything else (KNOWN,
|
| 300 |
+
# REQUIRED REMAINING, NICE-TO-HAVE, recall) varies per turn / per session and
|
| 301 |
+
# is sent inline as a system message in `contents[]`.
|
| 302 |
+
_CACHED_PREAMBLE: str = (
|
| 303 |
+
_BASE_SYSTEM_PROMPT
|
| 304 |
+
+ "\n\n--- SLOT SCHEMA (the fields you may capture) ---\n"
|
| 305 |
+
+ _format_slot_list(list(_SLOT_DESCRIPTIONS.keys()))
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def _dynamic_profile_block(
|
| 310 |
+
profile: Profile,
|
| 311 |
+
pending_profile_recall: Optional[dict] = None,
|
| 312 |
+
) -> str:
|
| 313 |
+
"""Build the per-turn dynamic system message: KNOWN, REQUIRED, NICE-TO-HAVE,
|
| 314 |
+
and the optional KI-196 welcome-back recall directive.
|
| 315 |
+
|
| 316 |
+
Kept disjoint from _CACHED_PREAMBLE β concatenating the two reproduces the
|
| 317 |
+
exact prompt the previous monolithic `_build_system_prompt` emitted, so
|
| 318 |
+
cached + uncached paths are byte-identical when assembled.
|
| 319 |
+
"""
|
| 320 |
known = _profile_known(profile)
|
| 321 |
required_remaining = _required_remaining(known)
|
| 322 |
nice_to_have_remaining = _nice_to_have_remaining(known, profile)
|
|
|
|
| 325 |
json.dumps(known, ensure_ascii=False) if known else "{}"
|
| 326 |
)
|
| 327 |
|
| 328 |
+
recall_block = ""
|
| 329 |
+
if pending_profile_recall:
|
| 330 |
+
recall_summary = pending_profile_recall.get("summary") or {}
|
| 331 |
+
recall_name = pending_profile_recall.get("name") or profile.name or "there"
|
| 332 |
+
recall_block = (
|
| 333 |
+
"\n\n--- WELCOME-BACK GATE (HIGHEST PRIORITY THIS TURN) ---\n"
|
| 334 |
+
f"The user just provided the name '{recall_name}' and a stored profile exists under that name with these prior captures:\n"
|
| 335 |
+
f" {json.dumps(recall_summary, ensure_ascii=False)}\n"
|
| 336 |
+
"Your ONLY job this turn is to ask warmly whether to continue from that stored profile OR start fresh.\n"
|
| 337 |
+
"Mention 2-3 of the most-distinctive prior captures in your reply so the user can recognise their own profile (e.g. age + city + dependents).\n"
|
| 338 |
+
"Do NOT capture any of those stored fields yourself β emit `\"captures\": {}` and `\"ready_for_recommendations\": false`.\n"
|
| 339 |
+
"Do NOT volunteer the next fact-find question on this turn. Wait for the user's yes / no.\n"
|
| 340 |
+
"Example reply: 'Welcome back, "
|
| 341 |
+
f"{recall_name} β I have a profile under your name from before: "
|
| 342 |
+
"age 29, in metro, looking for first policy. Continue from there or start fresh?'\n"
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
return (
|
| 346 |
+
f"KNOWN: {known_block}"
|
|
|
|
|
|
|
|
|
|
| 347 |
+ "\n(These fields are ALREADY captured. Do NOT re-ask for them. Use the user's name when known.)"
|
| 348 |
+ "\n\nREQUIRED SLOTS STILL MISSING (you need these before setting ready_for_recommendations=true):\n"
|
| 349 |
+ _format_slot_list(required_remaining)
|
|
|
|
| 355 |
if not required_remaining else
|
| 356 |
"\n\nKeep gathering naturally β you don't yet have the minimum required set."
|
| 357 |
)
|
| 358 |
+
+ recall_block
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def _build_system_prompt(
|
| 363 |
+
profile: Profile,
|
| 364 |
+
pending_profile_recall: Optional[dict] = None,
|
| 365 |
+
) -> str:
|
| 366 |
+
"""Backwards-compatible monolithic prompt builder.
|
| 367 |
+
|
| 368 |
+
Retained so any non-Gemini tier (NIM, OR) that doesn't understand
|
| 369 |
+
cachedContents still receives the FULL prompt as a single system
|
| 370 |
+
message β preserving the pre-KI-199 wire shape for those tiers. By
|
| 371 |
+
construction this is byte-identical to (_CACHED_PREAMBLE +
|
| 372 |
+
"\\n\\n" + _dynamic_profile_block(...)).
|
| 373 |
+
|
| 374 |
+
KI-196 (ADR-041) β when `pending_profile_recall` is supplied, the brain
|
| 375 |
+
receives an extra directive to ASK the user whether to load the prior
|
| 376 |
+
profile.
|
| 377 |
+
"""
|
| 378 |
+
return _CACHED_PREAMBLE + "\n\n" + _dynamic_profile_block(
|
| 379 |
+
profile, pending_profile_recall=pending_profile_recall
|
| 380 |
)
|
| 381 |
|
| 382 |
|
|
|
|
| 431 |
profile: Profile,
|
| 432 |
chat_history: list[dict],
|
| 433 |
session_id: Optional[str] = None,
|
| 434 |
+
pending_profile_recall: Optional[dict] = None,
|
| 435 |
) -> SalesBrainResult:
|
| 436 |
"""Single LLM call per turn β replaces the scripted slot-walker.
|
| 437 |
|
|
|
|
| 439 |
`brain_used` is `sales_brain::error:<reason>` and `reply_text` is
|
| 440 |
empty. Caller (orchestrator) is responsible for any fallback behavior
|
| 441 |
β NO scripted reply lives here.
|
| 442 |
+
|
| 443 |
+
KI-196 (ADR-041) β `pending_profile_recall` is the staged welcome-back
|
| 444 |
+
snapshot (see `backend.session_state.SessionState.pending_profile_recall`).
|
| 445 |
+
When non-None, the system prompt adds a high-priority directive to ask
|
| 446 |
+
the user whether to load the stored profile or start fresh, and forbids
|
| 447 |
+
the brain from capturing any of the stored fields itself.
|
| 448 |
"""
|
| 449 |
t0 = time.time()
|
| 450 |
|
| 451 |
+
# KI-199 β split the system prompt into the fixed-preamble (cacheable on
|
| 452 |
+
# Gemini) and the per-turn dynamic block. The Gemini tier sends the
|
| 453 |
+
# dynamic block as a system message inline + references the cache; the
|
| 454 |
+
# NIM and OR tiers receive the full monolithic prompt assembled below
|
| 455 |
+
# since they don't speak cachedContents.
|
| 456 |
+
dynamic_block = _dynamic_profile_block(
|
| 457 |
+
profile, pending_profile_recall=pending_profile_recall
|
| 458 |
+
)
|
| 459 |
+
system_prompt = _CACHED_PREAMBLE + "\n\n" + dynamic_block
|
| 460 |
|
| 461 |
# Build messages: system + last ~10 chat history turns + user_text
|
| 462 |
messages: list[ChatMessage] = [ChatMessage(role="system", content=system_prompt)]
|
| 463 |
if chat_history:
|
| 464 |
+
# KI-197 β was [-10:]; trimmed to last 6 turns to reduce prompt size
|
| 465 |
+
# by ~40% per call. 6 is still enough context for conversational
|
| 466 |
+
# follow-ups; older turns rarely influence the next slot to ask.
|
| 467 |
+
for turn in chat_history[-6:]:
|
| 468 |
role = turn.get("role")
|
| 469 |
content = turn.get("content")
|
| 470 |
if role in ("user", "assistant") and content:
|
| 471 |
messages.append(ChatMessage(role=role, content=str(content)))
|
| 472 |
messages.append(ChatMessage(role="user", content=user_text or ""))
|
| 473 |
|
| 474 |
+
# Gemini-specific message shape (KI-199): keep ONLY the dynamic block
|
| 475 |
+
# inline as a system message; the fixed preamble lives in cachedContents.
|
| 476 |
+
# When the cache isn't available we fall through and reuse the full
|
| 477 |
+
# `messages` list below.
|
| 478 |
+
gemini_messages: list[ChatMessage] = [
|
| 479 |
+
ChatMessage(role="system", content=dynamic_block)
|
| 480 |
+
]
|
| 481 |
+
if chat_history:
|
| 482 |
+
for turn in chat_history[-6:]: # KI-197 β match the messages[] trim
|
| 483 |
+
role = turn.get("role")
|
| 484 |
+
content = turn.get("content")
|
| 485 |
+
if role in ("user", "assistant") and content:
|
| 486 |
+
gemini_messages.append(ChatMessage(role=role, content=str(content)))
|
| 487 |
+
gemini_messages.append(ChatMessage(role="user", content=user_text or ""))
|
| 488 |
+
|
| 489 |
# KI-179 (2026-05-15) β 3-tier LLM stack with Google Gemini 2.0 Flash
|
| 490 |
# PRIMARY (Tier 0), NIM fast-brain chain Tier 1, OpenRouter free-tier
|
| 491 |
# pool Tier 2. Google AI Studio's free tier is 1500 req/day with native
|
|
|
|
| 518 |
)
|
| 519 |
|
| 520 |
if gemini_llm is not None:
|
| 521 |
+
# KI-199 β lazily provision a cachedContents resource for the fixed
|
| 522 |
+
# preamble. The cache is shared across sessions (same key for everyone
|
| 523 |
+
# who hits sales_brain Gemini tier with this preamble + model). On
|
| 524 |
+
# ANY provisioning failure (cache too small, network blip, 4xx, etc.)
|
| 525 |
+
# `create_cache` returns None and we proceed uncached. The chat() path
|
| 526 |
+
# also self-heals on a stale-cache 4xx by retrying without the
|
| 527 |
+
# reference, so a cache-server outage can never break the main path.
|
| 528 |
+
cache_name: Optional[str] = None
|
| 529 |
+
try:
|
| 530 |
+
cache_name = await gemini_llm.create_cache(
|
| 531 |
+
_CACHED_PREAMBLE, ttl_seconds=300
|
| 532 |
+
)
|
| 533 |
+
except Exception as e: # noqa: BLE001 β fail-safe: never blocking
|
| 534 |
+
logging.info(
|
| 535 |
+
"sales_brain: gemini.create_cache raised %s β proceeding uncached (session=%s): %s",
|
| 536 |
+
type(e).__name__, session_id, str(e)[:200],
|
| 537 |
+
)
|
| 538 |
+
cache_name = None
|
| 539 |
+
|
| 540 |
+
# Use cache-aware message shape only when the cache is in play;
|
| 541 |
+
# otherwise fall back to the full monolithic prompt so the LLM never
|
| 542 |
+
# sees a partial preamble.
|
| 543 |
+
if cache_name:
|
| 544 |
+
chat_messages = gemini_messages
|
| 545 |
+
else:
|
| 546 |
+
chat_messages = messages
|
| 547 |
+
|
| 548 |
try:
|
| 549 |
result = await asyncio.wait_for(
|
| 550 |
gemini_llm.chat(
|
| 551 |
+
messages=chat_messages,
|
| 552 |
temperature=_TEMPERATURE,
|
| 553 |
max_tokens=_MAX_TOKENS,
|
| 554 |
response_format={"type": "json_object"},
|
| 555 |
+
cached_content_name=cache_name,
|
| 556 |
),
|
| 557 |
timeout=_TIMEOUT_S,
|
| 558 |
)
|
|
|
|
| 566 |
)
|
| 567 |
except Exception as e: # noqa: BLE001
|
| 568 |
gemini_exc = e
|
| 569 |
+
# On any non-timeout error from the Gemini call, drop our cache
|
| 570 |
+
# ref proactively so the next turn re-provisions cleanly instead
|
| 571 |
+
# of repeatedly slamming a stale handle. (The provider itself
|
| 572 |
+
# already invalidates on a 4xx whose body names the cache, but
|
| 573 |
+
# 5xx / network errors land here and we want to be conservative.)
|
| 574 |
+
if cache_name:
|
| 575 |
+
try:
|
| 576 |
+
_gemini_invalidate_cache(
|
| 577 |
+
getattr(gemini_llm, "model", "gemini-2.5-flash-lite"),
|
| 578 |
+
_CACHED_PREAMBLE,
|
| 579 |
+
)
|
| 580 |
+
except Exception: # noqa: BLE001
|
| 581 |
+
pass
|
| 582 |
logging.info(
|
| 583 |
"sales_brain: Gemini raised %s, falling back to NIM (session=%s): %s",
|
| 584 |
type(e).__name__, session_id, str(e)[:200],
|
|
|
|
| 724 |
"sales_brain empty reply after think-strip β retrying once (session=%s, model=%s)",
|
| 725 |
session_id, served_model,
|
| 726 |
)
|
| 727 |
+
# KI-199 β when the original call used Gemini with a cachedContents
|
| 728 |
+
# ref, the retry must keep the dynamic-only message list AND keep
|
| 729 |
+
# passing the cache (the preamble lives server-side; sending it again
|
| 730 |
+
# inline would be redundant and could trip the cached-vs-inline
|
| 731 |
+
# mutex). For NIM/OR we keep the full monolithic prompt.
|
| 732 |
+
retry_base = (
|
| 733 |
+
gemini_messages
|
| 734 |
+
if served_tier == "gemini" and "cache_name" in locals() and cache_name
|
| 735 |
+
else messages
|
| 736 |
+
)
|
| 737 |
+
retry_messages = list(retry_base) + [
|
| 738 |
ChatMessage(
|
| 739 |
role="system",
|
| 740 |
content=(
|
|
|
|
| 745 |
),
|
| 746 |
),
|
| 747 |
]
|
| 748 |
+
retry_kwargs: dict = {
|
| 749 |
+
"messages": retry_messages,
|
| 750 |
+
"temperature": _TEMPERATURE,
|
| 751 |
+
"max_tokens": _MAX_TOKENS,
|
| 752 |
+
"response_format": {"type": "json_object"},
|
| 753 |
+
}
|
| 754 |
+
if served_tier == "gemini" and "cache_name" in locals() and cache_name:
|
| 755 |
+
retry_kwargs["cached_content_name"] = cache_name
|
| 756 |
try:
|
| 757 |
retry_result = await asyncio.wait_for(
|
| 758 |
+
llm.chat(**retry_kwargs),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
timeout=_TIMEOUT_S,
|
| 760 |
)
|
| 761 |
retry_parsed = _parse_brain_json((retry_result.text or "").strip())
|
|
@@ -35,6 +35,8 @@ from dataclasses import dataclass, field
|
|
| 35 |
from threading import Lock
|
| 36 |
from typing import Optional
|
| 37 |
|
|
|
|
|
|
|
| 38 |
from backend.needs_finder import Profile, record_answer
|
| 39 |
|
| 40 |
_log = logging.getLogger(__name__)
|
|
@@ -47,6 +49,18 @@ class SessionState:
|
|
| 47 |
awaiting_question_id: Optional[str] = None # if set, next user message answers this
|
| 48 |
free_form_session: bool = False # user explicitly opted out of fact-find
|
| 49 |
last_touched: float = field(default_factory=time.time)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
def _flush(self) -> None:
|
| 52 |
"""No-op since KI-118 (2026-05-15). Disk persistence was removed; the
|
|
@@ -161,6 +175,26 @@ def reset_session(session_id: str) -> bool:
|
|
| 161 |
return False
|
| 162 |
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
def purge_old_files() -> int:
|
| 165 |
"""KI-118 (2026-05-15) β no-op. Disk persistence was removed; there are
|
| 166 |
no files to purge. Kept as a stub so any existing scheduled-task caller
|
|
|
|
| 35 |
from threading import Lock
|
| 36 |
from typing import Optional
|
| 37 |
|
| 38 |
+
from typing import Any, Dict
|
| 39 |
+
|
| 40 |
from backend.needs_finder import Profile, record_answer
|
| 41 |
|
| 42 |
_log = logging.getLogger(__name__)
|
|
|
|
| 49 |
awaiting_question_id: Optional[str] = None # if set, next user message answers this
|
| 50 |
free_form_session: bool = False # user explicitly opted out of fact-find
|
| 51 |
last_touched: float = field(default_factory=time.time)
|
| 52 |
+
# KI-196 (ADR-041) β confirmation-gated profile recall. When a fresh
|
| 53 |
+
# session captures a name that matches an on-disk profile, the recall
|
| 54 |
+
# is staged here (NOT auto-merged) and surfaced to the sales_brain as a
|
| 55 |
+
# one-shot "welcome back" prompt. Affirm β merge stored fields into
|
| 56 |
+
# `profile`. Negate β discard. Shape:
|
| 57 |
+
# {
|
| 58 |
+
# "name": "Rohit Sarma",
|
| 59 |
+
# "summary": {age, dependents, location_tier, primary_goal, ...},
|
| 60 |
+
# "captured_this_turn": {<field>: <value>, ...}, # don't re-extract
|
| 61 |
+
# "staged_at": <epoch-seconds>,
|
| 62 |
+
# }
|
| 63 |
+
pending_profile_recall: Optional[Dict[str, Any]] = None
|
| 64 |
|
| 65 |
def _flush(self) -> None:
|
| 66 |
"""No-op since KI-118 (2026-05-15). Disk persistence was removed; the
|
|
|
|
| 175 |
return False
|
| 176 |
|
| 177 |
|
| 178 |
+
def clear_session(session_id: str) -> bool:
|
| 179 |
+
"""KI-196 (ADR-041) β Wipe in-memory state for one session_id WITHOUT
|
| 180 |
+
touching any on-disk profile JSON under `40-data/profiles/`.
|
| 181 |
+
|
| 182 |
+
Semantically identical to `reset_session` today (both just evict the
|
| 183 |
+
in-memory entry; the disk profile has always been independent and lives
|
| 184 |
+
by persona_id / name slug, not session_id). Kept as a distinct symbol so
|
| 185 |
+
the call-site intent at `POST /api/session/clear` is self-documenting and
|
| 186 |
+
so future divergence (e.g. partial-state wipes) doesn't require touching
|
| 187 |
+
the legacy KI-020 caller.
|
| 188 |
+
|
| 189 |
+
Returns True iff a live in-memory session was evicted.
|
| 190 |
+
"""
|
| 191 |
+
with _lock:
|
| 192 |
+
if session_id in _sessions:
|
| 193 |
+
del _sessions[session_id]
|
| 194 |
+
return True
|
| 195 |
+
return False
|
| 196 |
+
|
| 197 |
+
|
| 198 |
def purge_old_files() -> int:
|
| 199 |
"""KI-118 (2026-05-15) β no-op. Disk persistence was removed; there are
|
| 200 |
no files to purge. Kept as a stub so any existing scheduled-task caller
|
|
@@ -21,7 +21,7 @@ import {
|
|
| 21 |
postChat,
|
| 22 |
postPremiumEstimate,
|
| 23 |
postProfileUpdate,
|
| 24 |
-
|
| 25 |
postTranscribe,
|
| 26 |
PremiumEstimateResponse,
|
| 27 |
ProfileCompletenessResponse,
|
|
@@ -287,42 +287,42 @@ export default function Page() {
|
|
| 287 |
].includes(s);
|
| 288 |
}
|
| 289 |
|
| 290 |
-
// KI-
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
// Always wipe the visible chat + local storage.
|
| 293 |
setMessages([]);
|
| 294 |
setInput("");
|
| 295 |
// KI-073 (2026-05-15) β clear the profile-completeness chip immediately
|
| 296 |
// so the header doesn't show stale "55% DONE" for a brand-new visitor
|
| 297 |
-
// while the new session_id fetch is in flight.
|
| 298 |
-
// will repopulate this from the fresh backend session as soon as the new
|
| 299 |
-
// id lands.
|
| 300 |
setProfileCompleteness(null);
|
| 301 |
if (typeof window !== "undefined") {
|
| 302 |
localStorage.removeItem("insurance_chat_messages");
|
| 303 |
}
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
// Even if backend failed, drop client-side session so next message starts fresh
|
| 322 |
-
setSessionId(undefined);
|
| 323 |
-
if (typeof window !== "undefined") {
|
| 324 |
-
sessionStorage.removeItem("insurance_session_id");
|
| 325 |
-
}
|
| 326 |
}
|
| 327 |
}
|
| 328 |
}
|
|
|
|
| 21 |
postChat,
|
| 22 |
postPremiumEstimate,
|
| 23 |
postProfileUpdate,
|
| 24 |
+
postSessionClear,
|
| 25 |
postTranscribe,
|
| 26 |
PremiumEstimateResponse,
|
| 27 |
ProfileCompletenessResponse,
|
|
|
|
| 287 |
].includes(s);
|
| 288 |
}
|
| 289 |
|
| 290 |
+
// KI-196 (ADR-041) β Clean Clear-chat semantic.
|
| 291 |
+
// 1. POST /api/session/clear with the current session_id.
|
| 292 |
+
// 2. Adopt the returned new_session_id going forward (sessionStorage).
|
| 293 |
+
// 3. Wipe message array, profile chip, and chat-history localStorage.
|
| 294 |
+
// The legacy `dropProfile` parameter is retained for backwards compatibility
|
| 295 |
+
// with any other callsite β both true and false now route through the new
|
| 296 |
+
// endpoint since the server-side semantic is identical (in-memory wipe +
|
| 297 |
+
// fresh UUID; on-disk profile JSON untouched).
|
| 298 |
+
async function handleClearChat(_dropProfile: boolean = false) {
|
| 299 |
// Always wipe the visible chat + local storage.
|
| 300 |
setMessages([]);
|
| 301 |
setInput("");
|
| 302 |
// KI-073 (2026-05-15) β clear the profile-completeness chip immediately
|
| 303 |
// so the header doesn't show stale "55% DONE" for a brand-new visitor
|
| 304 |
+
// while the new session_id fetch is in flight.
|
|
|
|
|
|
|
| 305 |
setProfileCompleteness(null);
|
| 306 |
if (typeof window !== "undefined") {
|
| 307 |
localStorage.removeItem("insurance_chat_messages");
|
| 308 |
}
|
| 309 |
+
// Ask the backend to rotate the session and wipe in-memory state. We
|
| 310 |
+
// always do this, even without a sessionId, so the user gets a guaranteed
|
| 311 |
+
// fresh UUID for their next turn.
|
| 312 |
+
try {
|
| 313 |
+
const res = await postSessionClear({ session_id: sessionId ?? "" });
|
| 314 |
+
setSessionId(res.new_session_id);
|
| 315 |
+
if (typeof window !== "undefined") {
|
| 316 |
+
sessionStorage.setItem("insurance_session_id", res.new_session_id);
|
| 317 |
+
}
|
| 318 |
+
} catch (e) {
|
| 319 |
+
console.warn("session clear failed", e);
|
| 320 |
+
// Even if backend failed, drop client-side session so the next message
|
| 321 |
+
// starts a fresh server-side session (handle_turn mints one when none
|
| 322 |
+
// is supplied).
|
| 323 |
+
setSessionId(undefined);
|
| 324 |
+
if (typeof window !== "undefined") {
|
| 325 |
+
sessionStorage.removeItem("insurance_session_id");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
}
|
| 327 |
}
|
| 328 |
}
|
|
@@ -431,6 +431,27 @@ export async function postSessionReset(
|
|
| 431 |
}
|
| 432 |
|
| 433 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
export async function uploadPolicy(file: File): Promise<UploadResponse> {
|
| 435 |
const fd = new FormData();
|
| 436 |
fd.append("file", file);
|
|
|
|
| 431 |
}
|
| 432 |
|
| 433 |
|
| 434 |
+
// KI-196 (ADR-041) β Clean Clear-chat semantic. Wipes in-memory session
|
| 435 |
+
// state for the supplied session_id and ALWAYS returns a fresh UUID the
|
| 436 |
+
// caller must adopt going forward. The on-disk profile JSON is preserved.
|
| 437 |
+
export interface SessionClearResponse {
|
| 438 |
+
cleared: boolean;
|
| 439 |
+
new_session_id: string;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
export async function postSessionClear(
|
| 443 |
+
args: { session_id: string }
|
| 444 |
+
): Promise<SessionClearResponse> {
|
| 445 |
+
const resp = await fetch(`${BACKEND_URL}/api/session/clear`, {
|
| 446 |
+
method: "POST",
|
| 447 |
+
headers: { "Content-Type": "application/json" },
|
| 448 |
+
body: JSON.stringify({ session_id: args.session_id }),
|
| 449 |
+
});
|
| 450 |
+
if (!resp.ok) throw new Error(`session clear failed: ${resp.status}`);
|
| 451 |
+
return resp.json();
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
|
| 455 |
export async function uploadPolicy(file: File): Promise<UploadResponse> {
|
| 456 |
const fd = new FormData();
|
| 457 |
fd.append("file", file);
|
|
@@ -62,6 +62,21 @@ const BARGE_IN_BASE_THRESHOLD = 0.005;
|
|
| 62 |
// making barge-in trivial. 0.6 is loud enough to hear clearly on
|
| 63 |
// headphones and laptop speakers without overpowering user speech.
|
| 64 |
const VOICE_MODE_TTS_VOLUME = 0.6;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
// Minimal types for the Web Speech API since lib.dom.d.ts ships them under
|
| 67 |
// `webkitSpeechRecognition` only and the standard `SpeechRecognition` symbol
|
|
@@ -623,6 +638,108 @@ export function useStreamingVoice(
|
|
| 623 |
}>();
|
| 624 |
// Track which <audio> elements we've dimmed so we can restore on cleanup.
|
| 625 |
const duckedAudios = new Set<HTMLAudioElement>();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
|
| 627 |
const stopBargeInLoop = () => {
|
| 628 |
if (rafId !== null) {
|
|
@@ -831,6 +948,10 @@ export function useStreamingVoice(
|
|
| 831 |
if (rec) {
|
| 832 |
try { rec.abort(); } catch { /* ignore */ }
|
| 833 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 834 |
// KI-191 β re-duck every playing audio in case React or the audio
|
| 835 |
// element default reset volume after watchAudio set it.
|
| 836 |
ttsAudioElementsRef.current.forEach((el) => {
|
|
@@ -838,6 +959,9 @@ export function useStreamingVoice(
|
|
| 838 |
try { el.volume = VOICE_MODE_TTS_VOLUME; } catch { /* ignore */ }
|
| 839 |
}
|
| 840 |
});
|
|
|
|
|
|
|
|
|
|
| 841 |
// KI-192 (2026-05-15) β MediaRecorder might be torn down between
|
| 842 |
// user utterances (KI-168 teardownAudio). Without an active
|
| 843 |
// recorder, startBargeInLoop bails on the recorderActiveRef check
|
|
@@ -859,6 +983,10 @@ export function useStreamingVoice(
|
|
| 859 |
// Trigger immediately too so the user doesn't wait ~4s.
|
| 860 |
console.debug("[useStreamingVoice] KI-188 TTS ended β resuming recognition");
|
| 861 |
stopBargeInLoop();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
if (wantRunningRef.current && !isTextRequestPendingRef.current) {
|
| 863 |
safeStart();
|
| 864 |
}
|
|
@@ -870,8 +998,12 @@ export function useStreamingVoice(
|
|
| 870 |
ttsAudioElementsRef.current.add(el);
|
| 871 |
// KI-191 β duck bot TTS to 60% while voice mode is on, so AEC residual
|
| 872 |
// is even quieter and barge-in is trivial.
|
|
|
|
|
|
|
|
|
|
| 873 |
try {
|
| 874 |
-
|
|
|
|
| 875 |
duckedAudios.add(el);
|
| 876 |
} catch { /* readonly volume on some platforms β ignore */ }
|
| 877 |
// KI-190 β attach bot-level analyser for adaptive threshold.
|
|
@@ -916,7 +1048,18 @@ export function useStreamingVoice(
|
|
| 916 |
});
|
| 917 |
observer.observe(document.body, { childList: true, subtree: true });
|
| 918 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 919 |
return () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 920 |
observer.disconnect();
|
| 921 |
// KI-191 β restore bot TTS volume to default before unmount so a
|
| 922 |
// subsequent voice-OFF session doesn't end up with silent audio.
|
|
|
|
| 62 |
// making barge-in trivial. 0.6 is loud enough to hear clearly on
|
| 63 |
// headphones and laptop speakers without overpowering user speech.
|
| 64 |
const VOICE_MODE_TTS_VOLUME = 0.6;
|
| 65 |
+
// KI-195 (2026-05-15) β adaptive TTS volume calibration relative to user's
|
| 66 |
+
// own measured speech level. Architecture: while user speaks (recorder
|
| 67 |
+
// active, NOT TTS) we sample mic RMS and track a rolling peak in
|
| 68 |
+
// userSpeechRmsRef. While TTS plays, every 300ms we sample bot_rms_at_mic
|
| 69 |
+
// via the KI-190 botAnalysers and reduce el.volume by 20% if bot_rms is
|
| 70 |
+
// closer to user_rms than the target ratio. Floor at 0.15 so the bot
|
| 71 |
+
// stays audible. This makes "bot bleed < user speech" a mathematical
|
| 72 |
+
// guarantee after one calibration turn β barge-in always works, echo
|
| 73 |
+
// never crosses the recognition threshold.
|
| 74 |
+
const USER_SPEECH_RMS_INITIAL = 0.05; // typical quiet speech, used until calibrated
|
| 75 |
+
const USER_SPEECH_DETECTION_THRESHOLD = 0.02; // mic RMS above this counts as "user speaking"
|
| 76 |
+
const VOLUME_CALIB_TARGET_RATIO = 0.35; // bot_rms_at_mic should be β€ user_rms Γ this
|
| 77 |
+
const VOLUME_CALIB_TICK_MS = 300; // calibration sample period during TTS
|
| 78 |
+
const VOLUME_CALIB_DUCK_FACTOR = 0.8; // multiply el.volume by this per tick if too loud
|
| 79 |
+
const VOLUME_CALIB_FLOOR = 0.15; // never drop bot below this β must stay audible
|
| 80 |
|
| 81 |
// Minimal types for the Web Speech API since lib.dom.d.ts ships them under
|
| 82 |
// `webkitSpeechRecognition` only and the standard `SpeechRecognition` symbol
|
|
|
|
| 638 |
}>();
|
| 639 |
// Track which <audio> elements we've dimmed so we can restore on cleanup.
|
| 640 |
const duckedAudios = new Set<HTMLAudioElement>();
|
| 641 |
+
// KI-195 β user-speech RMS tracker + per-element calibrated volume.
|
| 642 |
+
// userSpeechRms is the rolling peak of mic RMS observed while the user
|
| 643 |
+
// is actively speaking (recorder active, not TTS). It seeds the bot
|
| 644 |
+
// volume target. Calibrated volumes per element survive across turns
|
| 645 |
+
// so we don't have to re-learn after every reply.
|
| 646 |
+
let userSpeechRms = USER_SPEECH_RMS_INITIAL;
|
| 647 |
+
const calibratedVolumes = new Map<HTMLAudioElement, number>();
|
| 648 |
+
let userRmsRafId: number | null = null;
|
| 649 |
+
let volumeCalibIntervalId: ReturnType<typeof setInterval> | null = null;
|
| 650 |
+
|
| 651 |
+
const sampleUserRms = (): number => {
|
| 652 |
+
if (!analyser || !rmsBuf) return 0;
|
| 653 |
+
try {
|
| 654 |
+
analyser.getFloatTimeDomainData(rmsBuf);
|
| 655 |
+
} catch { return 0; }
|
| 656 |
+
let sumSq = 0;
|
| 657 |
+
for (let i = 0; i < rmsBuf.length; i++) {
|
| 658 |
+
const v = rmsBuf[i];
|
| 659 |
+
sumSq += v * v;
|
| 660 |
+
}
|
| 661 |
+
return Math.sqrt(sumSq / rmsBuf.length);
|
| 662 |
+
};
|
| 663 |
+
|
| 664 |
+
const userRmsTick = () => {
|
| 665 |
+
// Only learn while user is potentially speaking β recorder active,
|
| 666 |
+
// no TTS, voice mode on.
|
| 667 |
+
if (
|
| 668 |
+
!wantRunningRef.current
|
| 669 |
+
|| isTtsPlayingRef.current
|
| 670 |
+
|| !recorderActiveRef.current
|
| 671 |
+
) {
|
| 672 |
+
userRmsRafId = null;
|
| 673 |
+
return;
|
| 674 |
+
}
|
| 675 |
+
if (!analyser || !rmsBuf) {
|
| 676 |
+
userRmsRafId = null;
|
| 677 |
+
return;
|
| 678 |
+
}
|
| 679 |
+
const rms = sampleUserRms();
|
| 680 |
+
// Only count as "user speaking" when above detection threshold.
|
| 681 |
+
// Then update userSpeechRms via slow EMA on peak so a single shout
|
| 682 |
+
// doesn't permanently raise the baseline.
|
| 683 |
+
if (rms > USER_SPEECH_DETECTION_THRESHOLD) {
|
| 684 |
+
userSpeechRms = Math.max(userSpeechRms * 0.95, rms);
|
| 685 |
+
}
|
| 686 |
+
userRmsRafId = requestAnimationFrame(userRmsTick);
|
| 687 |
+
};
|
| 688 |
+
|
| 689 |
+
const startUserRmsLoop = () => {
|
| 690 |
+
if (userRmsRafId !== null) return;
|
| 691 |
+
// Reuse the VAD analyser. startBargeInLoop sets it up; if it doesn't
|
| 692 |
+
// exist yet, the loop will exit on first tick (analyser null) and
|
| 693 |
+
// restart on the next state transition.
|
| 694 |
+
userRmsRafId = requestAnimationFrame(userRmsTick);
|
| 695 |
+
};
|
| 696 |
+
|
| 697 |
+
const stopUserRmsLoop = () => {
|
| 698 |
+
if (userRmsRafId !== null) {
|
| 699 |
+
cancelAnimationFrame(userRmsRafId);
|
| 700 |
+
userRmsRafId = null;
|
| 701 |
+
}
|
| 702 |
+
};
|
| 703 |
+
|
| 704 |
+
// KI-195 β volume calibration tick. Runs during TTS. Samples bot RMS
|
| 705 |
+
// at the mic via botAnalysers. If bot is louder than target relative
|
| 706 |
+
// to userSpeechRms, duck el.volume by 20% per tick down to the floor.
|
| 707 |
+
const calibrateBotVolume = () => {
|
| 708 |
+
if (!isTtsPlayingRef.current) {
|
| 709 |
+
if (volumeCalibIntervalId !== null) {
|
| 710 |
+
clearInterval(volumeCalibIntervalId);
|
| 711 |
+
volumeCalibIntervalId = null;
|
| 712 |
+
}
|
| 713 |
+
return;
|
| 714 |
+
}
|
| 715 |
+
const target = userSpeechRms * VOLUME_CALIB_TARGET_RATIO;
|
| 716 |
+
const botRms = computeBotRms();
|
| 717 |
+
if (botRms > target) {
|
| 718 |
+
ttsAudioElementsRef.current.forEach((el) => {
|
| 719 |
+
if (el.paused || el.ended) return;
|
| 720 |
+
const cur = el.volume;
|
| 721 |
+
const next = Math.max(VOLUME_CALIB_FLOOR, cur * VOLUME_CALIB_DUCK_FACTOR);
|
| 722 |
+
if (next < cur - 0.001) {
|
| 723 |
+
try {
|
| 724 |
+
el.volume = next;
|
| 725 |
+
calibratedVolumes.set(el, next);
|
| 726 |
+
} catch { /* ignore */ }
|
| 727 |
+
}
|
| 728 |
+
});
|
| 729 |
+
}
|
| 730 |
+
};
|
| 731 |
+
|
| 732 |
+
const startVolumeCalibration = () => {
|
| 733 |
+
if (volumeCalibIntervalId !== null) return;
|
| 734 |
+
volumeCalibIntervalId = setInterval(calibrateBotVolume, VOLUME_CALIB_TICK_MS);
|
| 735 |
+
};
|
| 736 |
+
|
| 737 |
+
const stopVolumeCalibration = () => {
|
| 738 |
+
if (volumeCalibIntervalId !== null) {
|
| 739 |
+
clearInterval(volumeCalibIntervalId);
|
| 740 |
+
volumeCalibIntervalId = null;
|
| 741 |
+
}
|
| 742 |
+
};
|
| 743 |
|
| 744 |
const stopBargeInLoop = () => {
|
| 745 |
if (rafId !== null) {
|
|
|
|
| 948 |
if (rec) {
|
| 949 |
try { rec.abort(); } catch { /* ignore */ }
|
| 950 |
}
|
| 951 |
+
// KI-195 β user cannot be speaking during TTS playback; stop the
|
| 952 |
+
// RMS-learning loop until TTS ends so we don't capture bot audio
|
| 953 |
+
// bleed-through as "user speech level".
|
| 954 |
+
stopUserRmsLoop();
|
| 955 |
// KI-191 β re-duck every playing audio in case React or the audio
|
| 956 |
// element default reset volume after watchAudio set it.
|
| 957 |
ttsAudioElementsRef.current.forEach((el) => {
|
|
|
|
| 959 |
try { el.volume = VOICE_MODE_TTS_VOLUME; } catch { /* ignore */ }
|
| 960 |
}
|
| 961 |
});
|
| 962 |
+
// KI-195 β once the volume floor is set, begin adaptive calibration
|
| 963 |
+
// so the bot's volume tracks the learned user speech level.
|
| 964 |
+
startVolumeCalibration();
|
| 965 |
// KI-192 (2026-05-15) β MediaRecorder might be torn down between
|
| 966 |
// user utterances (KI-168 teardownAudio). Without an active
|
| 967 |
// recorder, startBargeInLoop bails on the recorderActiveRef check
|
|
|
|
| 983 |
// Trigger immediately too so the user doesn't wait ~4s.
|
| 984 |
console.debug("[useStreamingVoice] KI-188 TTS ended β resuming recognition");
|
| 985 |
stopBargeInLoop();
|
| 986 |
+
// KI-195 β freeze the per-element calibrated volume and resume
|
| 987 |
+
// learning the user's speech RMS for the next turn.
|
| 988 |
+
stopVolumeCalibration();
|
| 989 |
+
startUserRmsLoop();
|
| 990 |
if (wantRunningRef.current && !isTextRequestPendingRef.current) {
|
| 991 |
safeStart();
|
| 992 |
}
|
|
|
|
| 998 |
ttsAudioElementsRef.current.add(el);
|
| 999 |
// KI-191 β duck bot TTS to 60% while voice mode is on, so AEC residual
|
| 1000 |
// is even quieter and barge-in is trivial.
|
| 1001 |
+
// KI-195 β if we already calibrated a volume for this exact element on
|
| 1002 |
+
// a previous turn (rare β elements are usually recreated), reuse it so
|
| 1003 |
+
// we don't reset the adaptive level on every play() event.
|
| 1004 |
try {
|
| 1005 |
+
const prior = calibratedVolumes.get(el);
|
| 1006 |
+
el.volume = prior !== undefined ? prior : VOICE_MODE_TTS_VOLUME;
|
| 1007 |
duckedAudios.add(el);
|
| 1008 |
} catch { /* readonly volume on some platforms β ignore */ }
|
| 1009 |
// KI-190 β attach bot-level analyser for adaptive threshold.
|
|
|
|
| 1048 |
});
|
| 1049 |
observer.observe(document.body, { childList: true, subtree: true });
|
| 1050 |
|
| 1051 |
+
// KI-195 β kick off the user-RMS learning loop on mount so by the time
|
| 1052 |
+
// the first TTS plays we already have a baseline. The loop self-exits
|
| 1053 |
+
// when conditions aren't met (no analyser / no stream / in TTS), so
|
| 1054 |
+
// firing it unconditionally here is safe.
|
| 1055 |
+
startUserRmsLoop();
|
| 1056 |
+
|
| 1057 |
return () => {
|
| 1058 |
+
// KI-195 β tear down adaptive volume calibration before clearing
|
| 1059 |
+
// ducked-audio state so the calibration tick can't race a clear().
|
| 1060 |
+
stopUserRmsLoop();
|
| 1061 |
+
stopVolumeCalibration();
|
| 1062 |
+
calibratedVolumes.clear();
|
| 1063 |
observer.disconnect();
|
| 1064 |
// KI-191 β restore bot TTS volume to default before unmount so a
|
| 1065 |
// subsequent voice-OFF session doesn't end up with silent audio.
|