- AGENTS.md: Sofiia Chief AI Architect role definition - SOFIIA_IN_OPENCODE.md, SOFIIA_NODA2_SETUP.md: NODA2 setup documentation - agromatrix_stepan_noda1_APPLY.md, agromatrix_stepan_noda1_prod.patch: AgroMatrix production patch - docker-compose.memory-node2.yml: memory service for NODA2 - docker-compose.node2-sofiia-supervisor.yml: sofiia supervisor for NODA2 - gateway-bot/gateway_boot.py, monitor_prompt.txt, vision_guard.py: gateway extras - models/Modelfile.qwen3.5-35b-a3b: Qwen model definition for NODA3 - opencode.json: OpenCode providers and agents config - scripts/init-sofiia-memory.py, scripts/node2/*, start-memory-node2.sh: NODA2 init scripts - setup_sofiia_node2.sh: NODA2 full setup script Made-with: Cursor
335 lines
13 KiB
Python
335 lines
13 KiB
Python
"""
|
||
vision_guard.py — v4.0.1 Vision Consistency Guard.
|
||
|
||
Зберігає lock на останній висновок vision для конкретного chat+photo:
|
||
- vision_last_photo_key → file_unique_id (або file_id як fallback)
|
||
- vision_last_label → витягнутий label (культура/діагноз)
|
||
- vision_last_confidence → "high" | "low" | "unknown"
|
||
- vision_user_label → підтвердження від юзера ("це соняшник")
|
||
|
||
Правила:
|
||
1. Те саме фото (file_unique_id fallback file_id) → НЕ переоцінюємо
|
||
без явного запиту "переоцінити/перевір ще раз".
|
||
reeval_request → clear_lock → повний реаналіз.
|
||
2. Низький confidence → додаємо уточнення (якщо LLM сам не поставив '?').
|
||
3. User override ("це соняшник") → whitelist + заборона негації.
|
||
Записуємо як user_label; LLM не сперечається.
|
||
|
||
Без залежностей від crewai, memory-service, httpx.
|
||
Тільки in-memory TTL dict (per-process).
|
||
|
||
Telemetry-теги (logger.info у caller):
|
||
vision_lock_set, vision_skip_reanalysis, vision_user_override_set,
|
||
vision_low_conf_clarifier_added, vision_reeval_forced
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
import time
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── In-memory store: key = "{agent_id}:{chat_id}" ───────────────────────────
|
||
_VISION_LOCK: dict[str, dict] = {}
|
||
VISION_LOCK_TTL = 1800.0 # 30 хвилин
|
||
|
||
|
||
def _cleanup() -> None:
|
||
now = time.time()
|
||
expired = [k for k, v in _VISION_LOCK.items()
|
||
if now - float(v.get("ts", 0)) > VISION_LOCK_TTL]
|
||
for k in expired:
|
||
del _VISION_LOCK[k]
|
||
|
||
|
||
# ── Photo key: file_unique_id має пріоритет над file_id ──────────────────────
|
||
def _photo_key(file_id: str, file_unique_id: str | None = None) -> str:
|
||
"""
|
||
Telegram надсилає одне фото у кількох розмірах з різними file_id,
|
||
але спільним file_unique_id. Lock прив'язуємо до file_unique_id якщо є.
|
||
"""
|
||
return (file_unique_id or "").strip() or file_id
|
||
|
||
|
||
# ── Regex для витягу культури/діагнозу з vision відповіді ─────────────────────
|
||
_LABEL_CROP_RE = re.compile(
|
||
r"\b(кукурудза|пшениця|соняшник|ріпак|соя|ячмінь|горох|буряк|картопля|льон"
|
||
r"|бур[''ʼ]ян|ґрунт|ґрунту|шкідник"
|
||
r"|corn|wheat|sunflower|rapeseed|soybean|barley|weed|soil|pest)\b",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
_LABEL_DIAG_RE = re.compile(
|
||
r"\b(хлороз|некроз|іржа|фузаріоз|борошниста\s+роса|септоріоз|попелиц[яі]|тля"
|
||
r"|дефіцит\s+\w+|нестача\s+\w+|шкідник|хвороба|гниль)\b",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
# Низька впевненість — коли LLM сам сумнівається
|
||
_LOW_CONFIDENCE_RE = re.compile(
|
||
r"\b(можливо|схоже|не впевнений|важко сказати|без точної|не можу визначити"
|
||
r"|потребує|потрібно перевірити|ймовірно|не однозначно|декілька варіантів)\b",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
|
||
# ── User override whitelist ───────────────────────────────────────────────────
|
||
# Дозволені лейбли (ті ж культури + агрономічні поняття).
|
||
_OVERRIDE_WHITELIST = {
|
||
"кукурудза", "пшениця", "соняшник", "ріпак", "соя", "ячмінь",
|
||
"горох", "буряк", "картопля", "льон", "бур'ян", "бурʼян", "ґрунт",
|
||
"шкідник", "хлороз", "некроз", "іржа", "фузаріоз",
|
||
"corn", "wheat", "sunflower", "rapeseed", "soybean", "barley",
|
||
"weed", "soil", "pest",
|
||
}
|
||
|
||
# Заборона: "не X", "то не X" → не записуємо
|
||
_NEGATION_PREFIX_RE = re.compile(
|
||
r"^(?:це|то|ось|тут)?\s*не\s+\S",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
|
||
# Позитивне підтвердження: "це X" / "то X" / "ось X" / "тепер це X" / просто "X"
|
||
_USER_OVERRIDE_RE = re.compile(
|
||
r"^(?:тепер\s+)?(?:це|ось|то|тут|так[,\s]|маю\s+на\s+увазі)?\s*"
|
||
r"(кукурудза|пшениця|соняшник|ріпак|соя|ячмінь|горох|буряк|картопля|льон"
|
||
r"|бур[''ʼ]ян|ґрунт|шкідник"
|
||
r"|хлороз|некроз|іржа|фузаріоз|борошниста\s+роса|попелиця|тля|дефіцит\s+\w+"
|
||
r"|corn|wheat|sunflower|rapeseed|soybean|weed|soil|pest)[\s!.]*$",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
|
||
# Явний запит переоцінки
|
||
_REEVAL_RE = re.compile(
|
||
r"переоцін|перевір\s+(?:ще\s+раз|знову)|переглянь|інша\s+думка|не\s+те|"
|
||
r"помилив(?:ся)?|re[-\s]?eval",
|
||
re.IGNORECASE | re.UNICODE,
|
||
)
|
||
|
||
|
||
# ── Public API ────────────────────────────────────────────────────────────────
|
||
|
||
def extract_label_from_response(answer_text: str) -> tuple[str, str]:
|
||
"""
|
||
Витягує (label, confidence) з vision відповіді.
|
||
|
||
label: перша знайдена культура або діагноз, нижній регістр.
|
||
confidence: "high" | "low" | "unknown"
|
||
|
||
Fail-safe: будь-яка помилка → ("", "unknown").
|
||
"""
|
||
try:
|
||
t = answer_text.strip()
|
||
label = ""
|
||
crop_m = _LABEL_CROP_RE.search(t)
|
||
if crop_m:
|
||
label = crop_m.group(0).lower()
|
||
elif (diag_m := _LABEL_DIAG_RE.search(t)):
|
||
label = diag_m.group(0).lower()
|
||
|
||
confidence = "low" if _LOW_CONFIDENCE_RE.search(t) else ("high" if label else "unknown")
|
||
return label, confidence
|
||
except Exception:
|
||
return "", "unknown"
|
||
|
||
|
||
def get_vision_lock(agent_id: str, chat_id: str) -> dict:
|
||
"""
|
||
Повертає поточний vision lock для (agent_id, chat_id).
|
||
{} якщо нема або протухло.
|
||
"""
|
||
try:
|
||
_cleanup()
|
||
key = f"{agent_id}:{chat_id}"
|
||
rec = _VISION_LOCK.get(key) or {}
|
||
if not rec:
|
||
return {}
|
||
age = time.time() - float(rec.get("ts", 0))
|
||
return rec if age <= VISION_LOCK_TTL else {}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def set_vision_lock(
|
||
agent_id: str,
|
||
chat_id: str,
|
||
file_id: str,
|
||
label: str,
|
||
confidence: str,
|
||
file_unique_id: str | None = None,
|
||
) -> None:
|
||
"""
|
||
Зберігає vision lock після обробки фото.
|
||
photo_key = file_unique_id якщо є, інакше file_id.
|
||
Fail-safe: не кидає назовні.
|
||
"""
|
||
try:
|
||
_cleanup()
|
||
key = f"{agent_id}:{chat_id}"
|
||
existing = _VISION_LOCK.get(key) or {}
|
||
pk = _photo_key(file_id, file_unique_id)
|
||
_VISION_LOCK[key] = {
|
||
"photo_key": pk,
|
||
"file_id": file_id,
|
||
"label": label,
|
||
"confidence": confidence,
|
||
"user_label": existing.get("user_label", ""), # зберігаємо user override
|
||
"ts": time.time(),
|
||
}
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def clear_vision_lock(agent_id: str, chat_id: str) -> None:
|
||
"""
|
||
Скидає vision lock (для reeval_request).
|
||
Fail-safe.
|
||
"""
|
||
try:
|
||
key = f"{agent_id}:{chat_id}"
|
||
_VISION_LOCK.pop(key, None)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def set_user_label(agent_id: str, chat_id: str, user_label: str) -> None:
|
||
"""
|
||
Зберігає user override label (юзер явно підтвердив що це).
|
||
Fail-safe.
|
||
"""
|
||
try:
|
||
_cleanup()
|
||
key = f"{agent_id}:{chat_id}"
|
||
rec = _VISION_LOCK.get(key) or {}
|
||
rec["user_label"] = user_label.strip().lower()
|
||
rec["ts"] = time.time()
|
||
_VISION_LOCK[key] = rec
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def detect_user_override(text: str) -> str:
|
||
"""
|
||
Перевіряє чи текст є user override ("це соняшник" тощо).
|
||
|
||
Правила (B):
|
||
- Заборонено: "не X", "то не X" → повертаємо ""
|
||
- Дозволено: тільки whitelist лейблів
|
||
- Коротке підтвердження: "це X" / "то X" / просто "X" (<=4 слова)
|
||
|
||
Повертає normalized label або "" якщо не override.
|
||
"""
|
||
try:
|
||
stripped = text.strip()
|
||
# Відхиляємо негацію ("це не соняшник")
|
||
if _NEGATION_PREFIX_RE.search(stripped):
|
||
return ""
|
||
m = _USER_OVERRIDE_RE.match(stripped)
|
||
if not m:
|
||
return ""
|
||
label = m.group(1).strip().lower()
|
||
# Нормалізуємо аpostrophes для whitelist check
|
||
label_norm = label.replace("ʼ", "'").replace("\u2019", "'")
|
||
# Перевіряємо whitelist (часткове співпадіння для "дефіцит X")
|
||
in_whitelist = any(
|
||
label_norm == w or label_norm.startswith(w)
|
||
for w in _OVERRIDE_WHITELIST
|
||
)
|
||
return label if in_whitelist else ""
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def is_reeval_request(text: str) -> bool:
|
||
"""
|
||
Чи явний запит переоцінки ("переоцінити", "перевір ще раз" тощо).
|
||
"""
|
||
try:
|
||
return bool(_REEVAL_RE.search(text.strip()))
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def should_skip_reanalysis(
|
||
agent_id: str,
|
||
chat_id: str,
|
||
file_id: str,
|
||
user_text: str,
|
||
file_unique_id: str | None = None,
|
||
) -> bool:
|
||
"""
|
||
Rule 1: Те саме фото + без запиту переоцінки → True (skip).
|
||
Rule C: reeval_request → clear_lock → False (реаналіз).
|
||
|
||
photo_key = file_unique_id якщо є, інакше file_id.
|
||
"""
|
||
try:
|
||
if is_reeval_request(user_text):
|
||
# C: очищуємо lock — наступний аналіз буде свіжим
|
||
clear_vision_lock(agent_id, chat_id)
|
||
logger.info(
|
||
"vision_reeval_forced agent=%s chat_id=%s file_id=%s",
|
||
agent_id, chat_id, file_id,
|
||
)
|
||
return False
|
||
lock = get_vision_lock(agent_id, chat_id)
|
||
if not lock:
|
||
return False
|
||
pk = _photo_key(file_id, file_unique_id)
|
||
return lock.get("photo_key") == pk
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def build_low_confidence_clarifier(answer_text: str) -> tuple[str, bool]:
|
||
"""
|
||
Rule 2: Якщо confidence низький — додати уточнення.
|
||
Повертає (modified_text, was_added).
|
||
|
||
Fail-safe: повертає (original, False) при будь-якій помилці.
|
||
"""
|
||
try:
|
||
_, conf = extract_label_from_response(answer_text)
|
||
if conf != "low":
|
||
return answer_text, False
|
||
|
||
# Якщо LLM вже дав уточнення — не дублюємо
|
||
if "?" in answer_text[-120:]:
|
||
return answer_text, False
|
||
|
||
result = (
|
||
answer_text.rstrip()
|
||
+ "\n\nЩоб визначити точніше: можеш надіслати фото листя ближче "
|
||
"або уточнити — це нові ознаки чи давні?"
|
||
)
|
||
return result, True
|
||
except Exception:
|
||
return answer_text, False
|
||
|
||
|
||
def build_locked_reply(lock: dict, user_text: str) -> str:
|
||
"""
|
||
Повертає коротку відповідь якщо фото вже аналізувалось (same photo_key, no reeval).
|
||
|
||
lock — словник з get_vision_lock().
|
||
"""
|
||
try:
|
||
user_lbl = lock.get("user_label") or ""
|
||
label = user_lbl or lock.get("label") or ""
|
||
conf = lock.get("confidence", "unknown")
|
||
|
||
if not label:
|
||
return "Це фото вже аналізував — повтори питання конкретніше або надішли нове."
|
||
|
||
label_str = label.capitalize()
|
||
if conf == "high" or user_lbl:
|
||
src = "ти підтвердив" if user_lbl else "визначено"
|
||
return (
|
||
f"Це фото вже аналізував: {label_str} ({src}). "
|
||
"Що саме перевірити ще раз?"
|
||
)
|
||
return (
|
||
f"Для цього фото раніше визначив: схоже на {label_str} (невисока впевненість). "
|
||
"Надішли нове фото або уточни — переоцінити?"
|
||
)
|
||
except Exception:
|
||
return "Це фото вже аналізував. Що саме хочеш перевірити?"
|