""" Session Context Layer — Humanized Stepan v3 / v3.1 / v3.2 / v3.5. In-memory, per-chat сесійний контекст з TTL 15 хвилин. Не персистується між рестартами контейнера (це очікувано — сесія коротка). Структура SessionContext: { "last_messages": list[str] (max 3, найновіші), "last_depth": "light" | "deep" | None, "last_agents": list[str] (max 5), "last_question": str | None, "pending_action": dict | None — Confirmation Gate (v3.1), "doc_facts": dict | None — Fact Lock Layer (v3.2): числові факти з документу (profit_uah, area_ha тощо), зберігаються між запитами щоб уникнути RAG-інконсистентності, "fact_claims": list[dict] — Self-Correction (v3.2): останні 3 твердження агента, напр. [{"key":"profit_present","value":False,"ts":1234}], "active_doc_id": str | None — Doc Anchor (v3.3): doc_id поточного активного документу; при зміні → скидаємо doc_facts і fact_claims, "doc_focus": bool — Doc Focus Gate (v3.5): True = документ "приклеєний" до діалогу (активний режим). False = документ є, але не нав'язуємо його контекст. "doc_focus_ts": float — timestamp активації doc_focus (time.time()), "updated_at": float (time.time()) } doc_focus TTL: DOC_FOCUS_TTL (600 с = 10 хв). Скидається автоматично при photo/URL/vision-інтенті або вручну через /doc off. Telemetry: AGX_STEPAN_METRIC session_loaded chat_id=h:... AGX_STEPAN_METRIC session_expired chat_id=h:... AGX_STEPAN_METRIC session_updated chat_id=h:... depth=... agents=... """ from __future__ import annotations import logging import time from copy import deepcopy from typing import Any from crews.agromatrix_crew.telemetry import tlog logger = logging.getLogger(__name__) # TTL 15 хвилин SESSION_TTL: float = 900.0 # Doc Focus Gate TTL: 10 хвилин після останньої активації DOC_FOCUS_TTL: float = 600.0 # v3.6: Cooldown після auto-clear — 2 хв блокування implicit doc re-activate DOC_FOCUS_COOLDOWN_S: float = 120.0 _STORE: dict[str, dict] = {} def _default_session() -> dict: return { "last_messages": [], "last_depth": None, "last_agents": [], "last_question": None, "pending_action": None, # v3.1: Confirmation Gate "doc_facts": None, # v3.2: Fact Lock Layer "fact_claims": [], # v3.2: Self-Correction Policy "active_doc_id": None, # v3.3: Doc Anchor Reset "doc_focus": False, # v3.5: Doc Focus Gate "doc_focus_ts": 0.0, # v3.5: timestamp активації doc_focus "doc_focus_cooldown_until": 0.0, # v3.6: epoch seconds, 0=inactive "last_photo_ts": 0.0, # v3.5 fix: timestamp останнього фото "updated_at": 0.0, } def is_doc_focus_cooldown_active(session: dict, now_ts: float | None = None) -> bool: """ Повертає True якщо cooldown активний (після auto-clear по web/vision домену). Поки cooldown — implicit doc re-activate заблокований. Fail-safe: будь-яка помилка → False. """ try: until = float(session.get("doc_focus_cooldown_until") or 0.0) now = now_ts if now_ts is not None else time.time() return until > now except Exception: return False def is_doc_focus_active(session: dict, now_ts: float | None = None) -> bool: """ Повертає True якщо doc_focus увімкнений і TTL ще не минув. Використовується в run.py для вирішення чи підмішувати doc_context в промпт. Fail-safe: будь-яка помилка → False. """ try: if not session.get("doc_focus"): return False ts = session.get("doc_focus_ts") or 0.0 now = now_ts if now_ts is not None else time.time() return (now - ts) <= DOC_FOCUS_TTL except Exception: return False def load_session(chat_id: str) -> dict: """ Завантажити SessionContext для chat_id. - Якщо нема → повернути default (порожній). - Якщо протух (now - updated_at > TTL) → очистити, повернути default. - Fail-safe: ніяких винятків назовні. """ try: if not chat_id: return _default_session() existing = _STORE.get(chat_id) if existing is None: tlog(logger, "session_loaded", chat_id=chat_id, status="new") return _default_session() age = time.time() - existing.get("updated_at", 0.0) if age > SESSION_TTL: _STORE.pop(chat_id, None) tlog(logger, "session_expired", chat_id=chat_id, age_s=round(age)) return _default_session() tlog(logger, "session_loaded", chat_id=chat_id, status="hit", last_depth=existing.get("last_depth")) return deepcopy(existing) except Exception as exc: logger.warning("load_session error (returning default): %s", exc) return _default_session() def update_session( chat_id: str, message: str, depth: str, agents: list[str] | None = None, last_question: str | None = None, pending_action: dict | None = None, # v3.1: Confirmation Gate doc_facts: dict | None = None, # v3.2: Fact Lock fact_claims: list | None = None, # v3.2: Self-Correction active_doc_id: str | None = None, # v3.3: Doc Anchor Reset doc_focus: bool | None = None, # v3.5: Doc Focus Gate doc_focus_ts: float | None = None, # v3.5: timestamp активації doc_focus_cooldown_until: float | None = None, # v3.6: cooldown epoch last_photo_ts: float | None = None, # v3.5 fix: timestamp фото ) -> None: """ Оновити SessionContext для chat_id. - last_messages: append + trim до 3 (зберігає найновіші). - last_agents: встановити нові; trim до 5. - updated_at: time.time() - Fail-safe: не кидає назовні. """ try: if not chat_id: return current = _STORE.get(chat_id) or _default_session() session = deepcopy(current) # last_messages: append + keep last 3 msgs: list[str] = session.get("last_messages") or [] if message: msgs.append(message[:500]) # guard against huge messages session["last_messages"] = msgs[-3:] # depth, agents, question, pending_action session["last_depth"] = depth new_agents = list(agents or [])[:5] session["last_agents"] = new_agents session["last_question"] = last_question # pending_action: зберігаємо якщо є; якщо None і питання немає — скидаємо if pending_action is not None: session["pending_action"] = pending_action elif not last_question: session["pending_action"] = None # v3.2: Fact Lock — merge якщо нові факти є if doc_facts is not None: session["doc_facts"] = doc_facts # v3.2: Self-Correction — append новий claim, тримати max 3 if fact_claims is not None: existing_claims: list = session.get("fact_claims") or [] existing_claims.extend(fact_claims) session["fact_claims"] = existing_claims[-3:] # v3.3: Doc Anchor — зберегти active_doc_id if active_doc_id is not None: session["active_doc_id"] = active_doc_id # v3.5: Doc Focus Gate if doc_focus is not None: session["doc_focus"] = doc_focus if doc_focus_ts is not None: session["doc_focus_ts"] = doc_focus_ts # v3.6: Cooldown if doc_focus_cooldown_until is not None: session["doc_focus_cooldown_until"] = doc_focus_cooldown_until # v3.5 fix: Photo timestamp if last_photo_ts is not None: session["last_photo_ts"] = last_photo_ts session["updated_at"] = time.time() _STORE[chat_id] = session tlog(logger, "session_updated", chat_id=chat_id, depth=depth, agents=new_agents) except Exception as exc: logger.warning("update_session error: %s", exc) def clear_session(chat_id: str) -> None: """Примусово очистити сесію (для тестів та ops-команд).""" _STORE.pop(chat_id, None)