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
227 lines
9.0 KiB
Python
227 lines
9.0 KiB
Python
"""
|
||
Reflection Engine для Степана (Deep mode only).
|
||
|
||
reflect_on_response(user_input, final_response, user_profile, farm_profile)
|
||
→ dict з полями: new_facts, style_shift, confidence, clarifying_question
|
||
|
||
Правила:
|
||
- НЕ генерує нову відповідь, тільки аналізує
|
||
- НЕ запускається в Light mode
|
||
- НЕ запускається рекурсивно (_REFLECTING flag)
|
||
- При будь-якій помилці → повертає safe_fallback()
|
||
- confidence < 0.6 → викликаючий код може додати clarifying_question до відповіді
|
||
|
||
Anti-recursion:
|
||
Три рівні захисту:
|
||
1. Модульний boolean _REFLECTING (per-process, cleared у finally)
|
||
2. Caller у run.py передає depth="deep" — reflection ніколи не викличе handle_message
|
||
3. Reflection не імпортує run.py, не використовує Crew/Agent
|
||
|
||
Fail-safe: повертає safe_fallback() при будь-якому винятку.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ─── Anti-recursion guard ─────────────────────────────────────────────────────
|
||
|
||
_REFLECTING: bool = False
|
||
|
||
|
||
def _safe_fallback() -> dict[str, Any]:
|
||
return {
|
||
"new_facts": {},
|
||
"style_shift": None,
|
||
"confidence": 1.0,
|
||
"clarifying_question": None,
|
||
}
|
||
|
||
|
||
# ─── Fact extraction (rule-based) ────────────────────────────────────────────
|
||
|
||
_CROP_RE = re.compile(
|
||
r'\b(пшениця|кукурудза|соняшник|ріпак|соя|ячмінь|жито|гречка|овес|цукровий\s+буряк)\b',
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
_REGION_RE = re.compile(
|
||
r'\b(область|район|село|місто|регіон|зона)\s+([\w-]+)',
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
_ROLE_RE = re.compile(
|
||
r'\b(я\s+)?(агроном|власник|господар|оператор|механік|агрономка|директор)\b',
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
_NAME_RE = re.compile(
|
||
r'\b(мене\s+звуть|я\s+[-—]?\s*|мене\s+кличуть)\s+([А-ЯІЇЄA-Z][а-яіїєa-z]{2,})',
|
||
re.UNICODE,
|
||
)
|
||
|
||
_STYLE_SIGNAL: dict[str, list[str]] = {
|
||
"concise": ["коротко", "стисло", "без деталей"],
|
||
"checklist": ["списком", "маркерами", "пунктами"],
|
||
"analytical": ["аналіз", "причини", "наслідки"],
|
||
"detailed": ["детально", "докладно", "розгорнуто"],
|
||
}
|
||
|
||
_UNCERTAINTY_PHRASES = [
|
||
"не впевнений", "не зрозуміло", "не знаю", "можливо", "мабуть",
|
||
"не ясно", "незрозуміло", "не зрозумів", "не визначив", "відсутні дані",
|
||
"потрібно уточнити", "уточніть",
|
||
]
|
||
|
||
|
||
def _extract_new_facts(user_input: str, response: str, user_profile: dict | None, farm_profile: dict | None) -> dict:
|
||
facts: dict[str, Any] = {}
|
||
up = user_profile or {}
|
||
fp = farm_profile or {}
|
||
|
||
# Name
|
||
m = _NAME_RE.search(user_input)
|
||
if m and not up.get("name"):
|
||
facts["name"] = m.group(2)
|
||
|
||
# Role
|
||
m = _ROLE_RE.search(user_input)
|
||
if m and up.get("role") == "unknown":
|
||
role_map = {
|
||
"агроном": "agronomist", "агрономка": "agronomist",
|
||
"власник": "owner", "господар": "owner", "директор": "owner",
|
||
"оператор": "operator", "механік": "mechanic",
|
||
}
|
||
raw = m.group(2).lower()
|
||
for k, v in role_map.items():
|
||
if k in raw:
|
||
facts["role"] = v
|
||
break
|
||
|
||
# Crops (new ones not yet in farm profile)
|
||
existing_crops = set(fp.get("crops", []))
|
||
found_crops = {m.group(0).lower() for m in _CROP_RE.finditer(user_input)}
|
||
new_crops = found_crops - existing_crops
|
||
if new_crops:
|
||
facts["new_crops"] = list(new_crops)
|
||
|
||
# Style shift from user phrasing
|
||
tl = user_input.lower()
|
||
for style, signals in _STYLE_SIGNAL.items():
|
||
if any(s in tl for s in signals) and up.get("style") != style:
|
||
facts["style_shift"] = style
|
||
break
|
||
|
||
return facts
|
||
|
||
|
||
def _compute_confidence(user_input: str, response: str) -> float:
|
||
"""
|
||
Оцінити впевненість відповіді (0..1).
|
||
Низька впевненість якщо відповідь містить ознаки невизначеності.
|
||
"""
|
||
resp_lower = response.lower()
|
||
uncertainty_count = sum(1 for ph in _UNCERTAINTY_PHRASES if ph in resp_lower)
|
||
if uncertainty_count >= 3:
|
||
return 0.4
|
||
if uncertainty_count >= 1:
|
||
return 0.55
|
||
# Response too short for the complexity of the question
|
||
if len(response) < 80 and len(user_input) > 150:
|
||
return 0.5
|
||
return 0.85
|
||
|
||
|
||
def _build_clarifying_question(user_input: str, response: str, facts: dict) -> str | None:
|
||
"""
|
||
Сформувати одне уточнювальне питання якщо потрібно.
|
||
Повертає None якщо питання не потрібне.
|
||
"""
|
||
if facts.get("new_crops"):
|
||
crops_str = ", ".join(facts["new_crops"])
|
||
return f"Уточніть: ці культури ({crops_str}) відносяться до поточного сезону?"
|
||
resp_lower = response.lower()
|
||
if "потрібно уточнити" in resp_lower or "уточніть" in resp_lower:
|
||
# Response itself already asks; no need to double
|
||
return None
|
||
if "не зрозуміло" in resp_lower or "не визначив" in resp_lower:
|
||
return "Чи можете уточнити — що саме вас цікавить найбільше?"
|
||
return None
|
||
|
||
|
||
# ─── Public API ───────────────────────────────────────────────────────────────
|
||
|
||
def reflect_on_response(
|
||
user_input: str,
|
||
final_response: str,
|
||
user_profile: dict | None,
|
||
farm_profile: dict | None,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
Аналізує відповідь після Deep mode.
|
||
|
||
Повертає:
|
||
{
|
||
"new_facts": dict — нові факти для запису в профіль
|
||
"style_shift": str | None — новий стиль якщо виявлено
|
||
"confidence": float 0..1 — впевненість відповіді
|
||
"clarifying_question": str | None — питання для користувача якщо confidence < 0.6
|
||
}
|
||
|
||
НЕ запускається рекурсивно.
|
||
Fail-safe: будь-який виняток → _safe_fallback().
|
||
"""
|
||
global _REFLECTING
|
||
|
||
if _REFLECTING:
|
||
from crews.agromatrix_crew.telemetry import tlog as _tlog
|
||
_tlog(logger, "reflection_skip", reason="recursion_guard")
|
||
logger.warning("reflection_engine: recursion guard active — skipping")
|
||
return _safe_fallback()
|
||
|
||
_REFLECTING = True
|
||
try:
|
||
if not user_input or not final_response:
|
||
return _safe_fallback()
|
||
|
||
facts = _extract_new_facts(user_input, final_response, user_profile, farm_profile)
|
||
confidence = _compute_confidence(user_input, final_response)
|
||
|
||
style_shift = facts.pop("style_shift", None)
|
||
clarifying_question: str | None = None
|
||
|
||
from crews.agromatrix_crew.telemetry import tlog as _tlog
|
||
if confidence < 0.6:
|
||
clarifying_question = _build_clarifying_question(user_input, final_response, facts)
|
||
_tlog(logger, "reflection_done", confidence=round(confidence, 2),
|
||
clarifying=bool(clarifying_question), new_facts=list(facts.keys()))
|
||
logger.info(
|
||
"reflection_engine: low confidence=%.2f clarifying=%s",
|
||
confidence,
|
||
bool(clarifying_question),
|
||
)
|
||
else:
|
||
_tlog(logger, "reflection_done", confidence=round(confidence, 2),
|
||
clarifying=False, new_facts=list(facts.keys()))
|
||
logger.debug("reflection_engine: confidence=%.2f no clarification needed", confidence)
|
||
|
||
if facts:
|
||
logger.info("reflection_engine: new_facts=%s", list(facts.keys()))
|
||
|
||
return {
|
||
"new_facts": facts,
|
||
"style_shift": style_shift,
|
||
"confidence": confidence,
|
||
"clarifying_question": clarifying_question,
|
||
}
|
||
|
||
except Exception as exc:
|
||
from crews.agromatrix_crew.telemetry import tlog as _tlog
|
||
_tlog(logger, "reflection_skip", reason="error", error=str(exc))
|
||
logger.warning("reflection_engine: error (fallback): %s", exc)
|
||
return _safe_fallback()
|
||
|
||
finally:
|
||
_REFLECTING = False
|