""" farm_state.py — v4 Farm State Layer. Сесійний оперативний контекст господарства. Ізольований від doc_mode, memory_manager, crewai. Публічні функції: detect_farm_state_updates(text) -> dict update_farm_state(session, updates, now_ts) -> None build_farm_state_prefix(session) -> str """ from __future__ import annotations import re import time # ── Культури ────────────────────────────────────────────────────────────────── _CROP_RE = re.compile( r"\b(кукурудз[аиіує]|кукурудзою|кукурудзі" r"|пшениц[яіює]|пшениця" r"|соняшник[аиуів]?|соняшник" r"|ріпак[аиуів]?|ріпак" r"|со[яіює]|соя" r"|ячмінь|ячмен[юі]" r"|горох[аиуів]?|горох" r"|буряк[аиуів]?|буряк" r"|картопл[яіі]|картопля" r"|льон[аиуів]?|льон)\b", re.IGNORECASE | re.UNICODE, ) # Нормалізація до канонічної форми _CROP_CANONICAL: dict[str, str] = { # кукурудза (всі відмінки) "кукурудза": "кукурудза", "кукурудзи": "кукурудза", "кукурудзі": "кукурудза", "кукурудзу": "кукурудза", "кукурудзою": "кукурудза", "кукурудзє": "кукурудза", # пшениця "пшениця": "пшениця", "пшениці": "пшениця", "пшеницею": "пшениця", "пшеницю": "пшениця", "пшеницю": "пшениця", # соняшник "соняшник": "соняшник", "соняшника": "соняшник", "соняшнику": "соняшник", "соняшників": "соняшник", # ріпак "ріпак": "ріпак", "ріпака": "ріпак", "ріпаку": "ріпак", "ріпаків": "ріпак", # соя "соя": "соя", "сої": "соя", "сою": "соя", "соєю": "соя", # ячмінь "ячмінь": "ячмінь", "ячменю": "ячмінь", "ячмені": "ячмінь", # горох "горох": "горох", "гороху": "горох", "гороха": "горох", "горохів": "горох", # буряк "буряк": "буряк", "буряка": "буряк", "буряку": "буряк", "буряків": "буряк", # картопля "картопля": "картопля", "картоплі": "картопля", # льон "льон": "льон", "льону": "льон", "льона": "льон", "льонів": "льон", } # ── Стадії росту ────────────────────────────────────────────────────────────── # Спочатку шукаємо числові коди (vN, rN, BBCH) — вони точніші. # Потім словесні фази. "стадія" — артикль, ігноруємо. _STAGE_NUMERIC_RE = re.compile( r"\b(v\d{1,2}|vt|r\d|bbch\s*\d+|\d+-\d+\s+листк[иів]?)\b", re.IGNORECASE | re.UNICODE, ) _STAGE_WORD_RE = re.compile( r"\b(сходи|кущення|викидання\s+волоті|цвітіння|наливання\s+зерна" r"|дозрівання|збирання|посів|кінець\s+вегетації)\b", re.IGNORECASE | re.UNICODE, ) # Єдиний RE для API-сумісності (використовуємо numeric першим) _STAGE_RE = _STAGE_NUMERIC_RE # backward compat alias # ── Проблеми / симптоми ─────────────────────────────────────────────────────── _ISSUE_RE = re.compile( r"\b(жовтизна|жовтіння|хлороз|некроз|плям[иа]|плями" r"|дефіцит\s+\w+|нестача\s+\w+" r"|шкідник[иів]?|хвороб[аи]|гриб[иок]|гниль" r"|бур['']?ян[иів]?|бур['']яни" r"|попелиц[яі]|тля|кліщ[іи]|трипс[иів]?" r"|фузаріоз|іржа|борошниста\s+роса|септоріоз)\b", re.IGNORECASE | re.UNICODE, ) # ── Ризики ──────────────────────────────────────────────────────────────────── _RISK_RE = re.compile( r"\b(посуха|посухи|засух[аи]" r"|заморозок|заморозки|приморозок" r"|спека|перегрів" r"|надлишок\s+вологи|затоплення|підтоплення" r"|град|вітер|буря" r"|брак\s+опадів|немає\s+дощу)\b", re.IGNORECASE | re.UNICODE, ) # Максимальний TTL farm_state в сесії (30 хв — синхронізовано з SESSION_TTL) FARM_STATE_TTL = 1800.0 def detect_farm_state_updates(text: str) -> dict: """ Rule-based витяг оновлень farm_state з тексту. Повертає тільки знайдені поля: current_crop: str growth_stage: str recent_issue: str risk_flags: list[str] Fail-safe: будь-яка помилка → {}. """ try: t = text.strip() updates: dict = {} crop_m = _CROP_RE.search(t) if crop_m: raw = crop_m.group(0).lower() updates["current_crop"] = _CROP_CANONICAL.get(raw, raw) # Числовий код (V6, R2, BBCH30) пріоритетніший за словесну фазу stage_m = _STAGE_NUMERIC_RE.search(t) or _STAGE_WORD_RE.search(t) if stage_m: updates["growth_stage"] = stage_m.group(0).strip().upper() issue_m = _ISSUE_RE.search(t) if issue_m: updates["recent_issue"] = issue_m.group(0).strip().lower() risk_matches = _RISK_RE.findall(t) if risk_matches: updates["risk_flags"] = [r.lower() for r in risk_matches] return updates except Exception: return {} def update_farm_state(session: dict, updates: dict, now_ts: float | None = None) -> None: """ Оновлює session["farm_state"] знайденими полями. Створює dict якщо відсутній. Встановлює last_update_ts. Fail-safe: не кидає назовні. """ try: if not updates: return now = now_ts if now_ts is not None else time.time() fs: dict = session.get("farm_state") or {} if "current_crop" in updates: fs["current_crop"] = updates["current_crop"] if "growth_stage" in updates: fs["growth_stage"] = updates["growth_stage"] if "recent_issue" in updates: fs["recent_issue"] = updates["recent_issue"] if "risk_flags" in updates: existing_risks: list = fs.get("risk_flags") or [] new_risks = updates["risk_flags"] # merge + dedup, max 5 merged = list(dict.fromkeys(existing_risks + new_risks))[:5] fs["risk_flags"] = merged fs["last_update_ts"] = now session["farm_state"] = fs except Exception: pass def build_farm_state_prefix(session: dict, now_ts: float | None = None) -> str: """ Повертає короткий структурований префікс якщо є farm_state. Максимум 5 рядків. Порожній рядок якщо нема current_crop або state протух. Fail-safe: будь-яка помилка → "". """ try: fs: dict = session.get("farm_state") or {} if not fs.get("current_crop"): return "" # TTL check last_ts = float(fs.get("last_update_ts") or 0.0) now = now_ts if now_ts is not None else time.time() if (now - last_ts) > FARM_STATE_TTL: return "" lines = ["[Контекст господарства]"] lines.append(f"Культура: {fs['current_crop']}") if fs.get("growth_stage"): lines.append(f"Стадія: {fs['growth_stage']}") if fs.get("recent_issue"): lines.append(f"Проблема: {fs['recent_issue']}") risks = fs.get("risk_flags") or [] if risks: lines.append(f"Ризики: {', '.join(risks[:3])}") return "\n".join(lines) except Exception: return ""