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
209 lines
8.7 KiB
Python
209 lines
8.7 KiB
Python
"""
|
||
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 ""
|