New router intelligence modules (26 files): alert_ingest/store, audit_store, architecture_pressure, backlog_generator/store, cost_analyzer, data_governance, dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment, platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files), signature_state_store, sofiia_auto_router, tool_governance New services: - sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static - memory-service: integration_endpoints, integrations, voice_endpoints, static UI - aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents) - sofiia-supervisor: new supervisor service - aistalk-bridge-lite: Telegram bridge lite - calendar-service: CalDAV calendar service with reminders - mlx-stt-service / mlx-tts-service: Apple Silicon speech services - binance-bot-monitor: market monitor service - node-worker: STT/TTS memory providers New tools (9): agent_email, browser_tool, contract_tool, observability_tool, oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus, farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine, session_context, style_adapter, telemetry) Tests: 85+ test files for all new modules Made-with: Cursor
232 lines
9.0 KiB
Python
232 lines
9.0 KiB
Python
"""
|
||
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)
|