""" 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