Spaces:
Sleeping
Sleeping
| """Per-session state for multi-turn fact-find continuity (in-memory only). | |
| The orchestrator was originally stateless β each user turn re-classified | |
| intent from scratch. That broke fact-find: after the bot asked "what's | |
| your age?", the user's "39 years old" wasn't matched by intent_classifier | |
| and got routed to RAG retrieval (which then refused). This module fixes that. | |
| Persistence model (ADR-043, 2026-05-27 β REMOVAL of cross-session recall): | |
| - In-memory dict ONLY. No disk persistence anywhere. | |
| - Sessions are evicted from memory after `_TTL_SECONDS = 60 * 60` idle. | |
| - There is no cross-session memory. Closing the tab (or letting the | |
| session go idle for an hour) discards the profile permanently. | |
| - The previous cross-session recall design (ADR-041 + ADR-042 with | |
| name-slug pointers under `40-data/profiles/` plus a confirmation | |
| gate, redacted prompts, match-before-merge guards, two-fact gate | |
| and same-turn extractors) was removed. The complexity tax was high | |
| relative to the use case (insurance is a rare-purchase, return | |
| sessions are uncommon), the privacy surface β name-only key with | |
| slug-pointer collisions across distinct users β required four | |
| sequential hardening passes to keep contained, and the recall path | |
| became a recurring bug source. Minimum-data-retention now matches | |
| the simpler "stateless advisor" mental model. | |
| Public API: | |
| get_session(session_id) -> SessionState | |
| SessionState.profile, .awaiting (question id pending answer) | |
| SessionState.set_awaiting(qid) | |
| SessionState.record_user_answer(raw_answer) β also clears awaiting | |
| SessionState.update_profile_field(name, value) | |
| reset_session(session_id) / clear_session(session_id) β evict in-memory | |
| set_free_form(session_id, free_form) β bypass fact-find for this session | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import time | |
| from dataclasses import dataclass, field | |
| from threading import Lock | |
| from typing import Any, Dict, Optional | |
| from backend.needs_finder import Profile, record_answer | |
| _log = logging.getLogger(__name__) | |
| class SessionState: | |
| session_id: str | |
| profile: Profile = field(default_factory=Profile) | |
| awaiting_question_id: Optional[str] = None # if set, next user message answers this | |
| free_form_session: bool = False # user explicitly opted out of fact-find | |
| last_touched: float = field(default_factory=time.time) | |
| # KI-224 β most-recent recommendation policy_ids the brain cited on the | |
| # last user-visible recommendation/comparison turn. Populated by | |
| # single_brain after a clean closer reply. Lets the NEXT turn route | |
| # follow-ups like "tell me more about #2" without re-retrieving from | |
| # scratch. Empty list = no active shortlist on this session. | |
| last_recommendation_ids: list = field(default_factory=list) | |
| # X7 (admin Recommendation History β conversation_turn column). | |
| # Monotonically incremented at the START of every single_brain.handle_turn | |
| # call so the policy-event writer can stamp `turn_idx` on each event dict. | |
| turn_idx: int = 0 | |
| # Set True after the first successful single_brain turn; a later | |
| # SingleBrainError on the same session then emits a graceful retry | |
| # prompt instead of switching handlers, so the session stays on | |
| # single_brain (see ADR-042 retry policy in single_brain._gemini_call). | |
| single_brain_sticky: bool = False | |
| # Post-recap pricing & family-history bundle re-ask gate | |
| # (brain_tools.retrieve_policies): | |
| # pricing_bundle_reasked β one-shot guard; set True the first time | |
| # the gate re-asks an unresolved bundle slot so the next | |
| # recommendation retrieve proceeds even if the user skips. | |
| # pricing_bundle_skipped β set True by single_brain when the user | |
| # explicitly declines the pricing inputs; bypasses the re-ask. | |
| pricing_bundle_reasked: bool = False | |
| pricing_bundle_skipped: bool = False | |
| def _flush(self) -> None: | |
| """No-op. Session state lives only in the in-memory dict; the | |
| method is kept so callers' write paths don't have to change. | |
| """ | |
| return None | |
| def set_awaiting(self, question_id: Optional[str]) -> None: | |
| self.awaiting_question_id = question_id | |
| self.last_touched = time.time() | |
| def record_user_answer(self, raw_answer: str) -> Optional[str]: | |
| """If we're awaiting an answer, parse + store it. Returns the answered question_id.""" | |
| if not self.awaiting_question_id: | |
| return None | |
| qid = self.awaiting_question_id | |
| record_answer(self.profile, qid, raw_answer) | |
| self.awaiting_question_id = None | |
| self.last_touched = time.time() | |
| return qid | |
| def update_profile_field(self, name: str, value) -> None: | |
| """Set a Profile attribute. Used by /api/profile.""" | |
| if hasattr(self.profile, name): | |
| setattr(self.profile, name, value) | |
| self.last_touched = time.time() | |
| _sessions: dict[str, SessionState] = {} | |
| _lock = Lock() | |
| _TTL_SECONDS = 60 * 60 # 1h idle β evict from in-memory cache | |
| def get_session(session_id: str) -> SessionState: | |
| """Return the live in-memory SessionState for `session_id`, creating | |
| a fresh blank one on miss. Idle sessions older than _TTL_SECONDS are | |
| evicted lazily on every call. Disk is never consulted β see ADR-043. | |
| """ | |
| with _lock: | |
| now = time.time() | |
| # Evict idle entries from the hot cache. | |
| to_kill = [k for k, v in _sessions.items() if now - v.last_touched > _TTL_SECONDS] | |
| for k in to_kill: | |
| del _sessions[k] | |
| if session_id in _sessions: | |
| return _sessions[session_id] | |
| _sessions[session_id] = SessionState(session_id=session_id) | |
| return _sessions[session_id] | |
| def set_free_form(session_id: str, free_form: bool = True) -> None: | |
| s = get_session(session_id) | |
| s.free_form_session = free_form | |
| s.awaiting_question_id = None | |
| s.last_touched = time.time() | |
| def reset_session(session_id: str) -> bool: | |
| """Delete a session β evict from in-memory cache. | |
| Returns True if anything was actually deleted. | |
| KI-020 (2026-05-14) β backs the user-facing "Clear chat / start fresh" | |
| toggle. KI-118 (2026-05-15) removed disk persistence; in-memory | |
| eviction is the only side effect. | |
| """ | |
| with _lock: | |
| if session_id in _sessions: | |
| del _sessions[session_id] | |
| return True | |
| return False | |
| def clear_session(session_id: str) -> bool: | |
| """Wipe in-memory state for one session_id. | |
| Semantically identical to `reset_session` (both just evict the | |
| in-memory entry). Kept as a distinct symbol so the call-site intent | |
| at `POST /api/session/clear` is self-documenting and so future | |
| divergence (e.g. partial-state wipes) doesn't require touching the | |
| legacy KI-020 caller. | |
| """ | |
| with _lock: | |
| if session_id in _sessions: | |
| del _sessions[session_id] | |
| return True | |
| return False | |
| def purge_old_files() -> int: | |
| """No-op. Disk persistence was removed in KI-118 (2026-05-15) and | |
| cross-session profile recall was removed in ADR-043 (2026-05-27). | |
| Kept as a stub so any existing scheduled-task caller (cron / startup | |
| hook) doesn't crash on attribute miss. | |
| """ | |
| return 0 | |