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
288 lines
13 KiB
Python
288 lines
13 KiB
Python
"""
|
||
tests/test_stepan_v42_vision_bridge.py
|
||
|
||
Unit tests for v4.2 Vision → Agronomy Bridge.
|
||
|
||
Тестуємо ізольовано: без crewai, без httpx, без memory-service.
|
||
Перевіряємо логіку безпосередньо з vision_guard + session_context.
|
||
"""
|
||
from __future__ import annotations
|
||
import time
|
||
import sys
|
||
import os
|
||
import pytest
|
||
|
||
# ── Налаштовуємо шляхи для локального запуску ────────────────────────────────
|
||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
_GATEWAY = os.path.join(_ROOT, "gateway-bot")
|
||
_CREWS = os.path.join(_ROOT, "crews")
|
||
if _GATEWAY not in sys.path:
|
||
sys.path.insert(0, _GATEWAY)
|
||
if _CREWS not in sys.path:
|
||
sys.path.insert(0, _CREWS)
|
||
|
||
import vision_guard as vg
|
||
|
||
|
||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def clear_vg_lock():
|
||
vg._VISION_LOCK.clear()
|
||
yield
|
||
vg._VISION_LOCK.clear()
|
||
|
||
|
||
def _make_session() -> dict:
|
||
"""Мінімальна сесія для тестів (без load_session)."""
|
||
return {
|
||
"doc_focus": False,
|
||
"doc_focus_ts": 0.0,
|
||
"doc_focus_cooldown_until": 0.0,
|
||
"active_doc_id": None,
|
||
"doc_facts": {},
|
||
"fact_claims": [],
|
||
"last_photo_ts": 0.0,
|
||
"farm_state": {},
|
||
"vision_last_label": "",
|
||
"updated_at": time.time(),
|
||
}
|
||
|
||
|
||
# ── Helper: симулює логіку v4.2 з run.py ─────────────────────────────────────
|
||
|
||
def _run_vision_bridge(
|
||
session: dict,
|
||
agent_id: str,
|
||
chat_id: str,
|
||
text: str = "",
|
||
) -> dict:
|
||
"""
|
||
Відтворює блок v4.2 з run.py (без crewai/LLM).
|
||
Повертає оновлену session.
|
||
"""
|
||
# Читаємо vision lock (аналог _vb_get_vision_lock)
|
||
try:
|
||
lock = vg.get_vision_lock(agent_id, chat_id)
|
||
if lock:
|
||
label = (lock.get("user_label") or lock.get("label") or "").strip()
|
||
if label:
|
||
session["vision_last_label"] = label
|
||
except Exception:
|
||
pass
|
||
|
||
# User text override
|
||
if text:
|
||
try:
|
||
text_label = vg.detect_user_override(text)
|
||
if text_label:
|
||
session["vision_last_label"] = text_label
|
||
except Exception:
|
||
pass
|
||
|
||
return session
|
||
|
||
|
||
def _should_inject_prefix(session: dict, context_mode: str, domain: str) -> bool:
|
||
"""Відтворює умову для vision bridge prefix injection."""
|
||
if context_mode == "doc":
|
||
return False
|
||
if domain == "web":
|
||
return False
|
||
label = (session.get("vision_last_label") or "").strip()
|
||
farm_crop = str((session.get("farm_state") or {}).get("current_crop", "")).strip()
|
||
return bool(label and label != farm_crop)
|
||
|
||
|
||
# ─── Тест 1: vision label з'являється у session після lock ───────────────────
|
||
|
||
class TestVisionLabelInSession:
|
||
def test_label_loaded_from_lock(self):
|
||
"""Якщо vision lock є — vision_last_label заповнюється."""
|
||
vg.set_vision_lock("agromatrix", "chat1", "fid", "соняшник", "high",
|
||
file_unique_id="uid1")
|
||
session = _make_session()
|
||
session = _run_vision_bridge(session, "agromatrix", "chat1")
|
||
assert session["vision_last_label"] == "соняшник"
|
||
|
||
def test_user_label_preferred_over_model_label(self):
|
||
"""user_label має пріоритет над model label."""
|
||
vg.set_vision_lock("agromatrix", "chat1", "fid", "пшениця", "high",
|
||
file_unique_id="uid1")
|
||
vg.set_user_label("agromatrix", "chat1", "Соняшник")
|
||
session = _make_session()
|
||
session = _run_vision_bridge(session, "agromatrix", "chat1")
|
||
assert session["vision_last_label"] == "соняшник"
|
||
|
||
def test_no_label_if_no_lock(self):
|
||
"""Без lock — vision_last_label порожній."""
|
||
session = _make_session()
|
||
session = _run_vision_bridge(session, "agromatrix", "chat_new")
|
||
assert session["vision_last_label"] == ""
|
||
|
||
def test_expired_lock_no_label(self):
|
||
"""Протухлий lock — не вставляємо label."""
|
||
vg.set_vision_lock("agromatrix", "chatX", "fid", "кукурудза", "high")
|
||
vg._VISION_LOCK["agromatrix:chatX"]["ts"] = time.time() - vg.VISION_LOCK_TTL - 1
|
||
session = _make_session()
|
||
session = _run_vision_bridge(session, "agromatrix", "chatX")
|
||
assert session["vision_last_label"] == ""
|
||
|
||
|
||
# ─── Тест 2: prefix injection тільки не в doc/web режимі ─────────────────────
|
||
|
||
class TestPrefixInjection:
|
||
def test_injected_in_general_mode(self):
|
||
vg.set_vision_lock("agromatrix", "chat1", "fid", "соняшник", "high")
|
||
session = _make_session()
|
||
session = _run_vision_bridge(session, "agromatrix", "chat1")
|
||
assert _should_inject_prefix(session, "general", "general") is True
|
||
|
||
def test_not_injected_in_doc_mode(self):
|
||
"""Rule: doc mode → NЕ вставляємо vision prefix."""
|
||
vg.set_vision_lock("agromatrix", "chat1", "fid", "кукурудза", "high")
|
||
session = _make_session()
|
||
session = _run_vision_bridge(session, "agromatrix", "chat1")
|
||
assert _should_inject_prefix(session, "doc", "general") is False
|
||
|
||
def test_not_injected_in_web_mode(self):
|
||
"""Rule: web mode → НЕ вставляємо vision prefix."""
|
||
vg.set_vision_lock("agromatrix", "chat1", "fid", "пшениця", "high")
|
||
session = _make_session()
|
||
session = _run_vision_bridge(session, "agromatrix", "chat1")
|
||
assert _should_inject_prefix(session, "general", "web") is False
|
||
|
||
def test_not_injected_if_empty_label(self):
|
||
"""Без label — не вставляємо."""
|
||
session = _make_session()
|
||
assert _should_inject_prefix(session, "general", "general") is False
|
||
|
||
def test_not_injected_if_same_as_farm_crop(self):
|
||
"""Якщо farm_state.current_crop === vision_last_label — не дублюємо."""
|
||
vg.set_vision_lock("agromatrix", "chat1", "fid", "кукурудза", "high")
|
||
session = _make_session()
|
||
session = _run_vision_bridge(session, "agromatrix", "chat1")
|
||
session["farm_state"] = {"current_crop": "кукурудза"}
|
||
assert _should_inject_prefix(session, "general", "general") is False
|
||
|
||
|
||
# ─── Тест 3: user text override ──────────────────────────────────────────────
|
||
|
||
class TestTextOverride:
|
||
def test_text_override_changes_label(self):
|
||
"""Юзер явно пише "це соняшник" → перезаписує label."""
|
||
vg.set_vision_lock("agromatrix", "chat1", "fid", "пшениця", "high")
|
||
session = _make_session()
|
||
session["vision_last_label"] = "пшениця"
|
||
# Симулюємо нову відправку з override text
|
||
session = _run_vision_bridge(session, "agromatrix", "chat1", text="це соняшник")
|
||
assert session["vision_last_label"] == "соняшник"
|
||
|
||
def test_negation_does_not_override(self):
|
||
"""Негація "це не соняшник" → не перезаписує."""
|
||
vg.set_vision_lock("agromatrix", "chat1", "fid", "пшениця", "high")
|
||
session = _make_session()
|
||
session["vision_last_label"] = "пшениця"
|
||
session = _run_vision_bridge(session, "agromatrix", "chat1", text="це не соняшник")
|
||
assert session["vision_last_label"] == "пшениця"
|
||
|
||
def test_irrelevant_text_no_change(self):
|
||
"""Звичайний текст → label не змінюється."""
|
||
vg.set_vision_lock("agromatrix", "chat1", "fid", "ріпак", "high")
|
||
session = _make_session()
|
||
session["vision_last_label"] = "ріпак"
|
||
session = _run_vision_bridge(session, "agromatrix", "chat1",
|
||
text="чи варто внести азот зараз?")
|
||
assert session["vision_last_label"] == "ріпак"
|
||
|
||
def test_text_override_sets_label_without_lock(self):
|
||
"""User пише "соняшник" навіть без попереднього lock."""
|
||
session = _make_session()
|
||
session = _run_vision_bridge(session, "agromatrix", "chat_no_lock",
|
||
text="це кукурудза")
|
||
assert session["vision_last_label"] == "кукурудза"
|
||
|
||
|
||
# ─── Тест 4: TTL — label зникає після 30 хвилин ──────────────────────────────
|
||
|
||
class TestTTL:
|
||
def test_label_absent_after_ttl(self):
|
||
"""Після TTL lock→ порожній → label не завантажується."""
|
||
vg.set_vision_lock("agromatrix", "chatTTL", "fid", "ячмінь", "high")
|
||
# Симулюємо протухання
|
||
vg._VISION_LOCK["agromatrix:chatTTL"]["ts"] = time.time() - vg.VISION_LOCK_TTL - 1
|
||
session = _make_session()
|
||
session = _run_vision_bridge(session, "agromatrix", "chatTTL")
|
||
assert session["vision_last_label"] == ""
|
||
|
||
def test_label_present_within_ttl(self):
|
||
"""Якщо lock свіжий — label є."""
|
||
vg.set_vision_lock("agromatrix", "chatTTL2", "fid", "горох", "high")
|
||
session = _make_session()
|
||
session = _run_vision_bridge(session, "agromatrix", "chatTTL2")
|
||
assert session["vision_last_label"] == "горох"
|
||
|
||
|
||
# ─── Тест 5: ізоляція між чатами ─────────────────────────────────────────────
|
||
|
||
class TestChatIsolation:
|
||
def test_different_chats_isolated(self):
|
||
"""Два різні чати — окремі label."""
|
||
vg.set_vision_lock("agromatrix", "chatA", "f1", "соняшник", "high")
|
||
vg.set_vision_lock("agromatrix", "chatB", "f2", "пшениця", "high")
|
||
|
||
sA = _make_session()
|
||
sA = _run_vision_bridge(sA, "agromatrix", "chatA")
|
||
sB = _make_session()
|
||
sB = _run_vision_bridge(sB, "agromatrix", "chatB")
|
||
|
||
assert sA["vision_last_label"] == "соняшник"
|
||
assert sB["vision_last_label"] == "пшениця"
|
||
|
||
def test_forward_different_chat_no_bleed(self):
|
||
"""Forward фото в інший чат — чужий lock не потрапляє."""
|
||
vg.set_vision_lock("agromatrix", "chatSource", "f1", "ріпак", "high",
|
||
file_unique_id="uid_shared")
|
||
# В іншому чаті немає lock навіть з тим самим photo
|
||
sB = _make_session()
|
||
sB = _run_vision_bridge(sB, "agromatrix", "chatDest")
|
||
assert sB["vision_last_label"] == ""
|
||
|
||
|
||
# ─── Тест 6: Smoke cycle ─────────────────────────────────────────────────────
|
||
|
||
class TestSmokeCycle:
|
||
def test_full_vision_agronomy_flow(self):
|
||
"""
|
||
1. Фото → lock_set (соняшник)
|
||
2. Степан отримує label у session
|
||
3. Питання без фото → prefix інжектується
|
||
4. Юзер каже "тепер це кукурудза" → label змінюється
|
||
5. TTL минає → label пропадає
|
||
"""
|
||
# 1. Vision response saved
|
||
vg.set_vision_lock("agromatrix", "chat_cycle", "fid",
|
||
"соняшник", "high", file_unique_id="uid_cycle")
|
||
|
||
# 2. run.py bridge
|
||
session = _make_session()
|
||
session = _run_vision_bridge(session, "agromatrix", "chat_cycle")
|
||
assert session["vision_last_label"] == "соняшник"
|
||
|
||
# 3. Inject в general mode
|
||
assert _should_inject_prefix(session, "general", "general") is True
|
||
assert _should_inject_prefix(session, "doc", "general") is False
|
||
|
||
# 4. Text override
|
||
session = _run_vision_bridge(session, "agromatrix", "chat_cycle",
|
||
text="тепер це кукурудза")
|
||
assert session["vision_last_label"] == "кукурудза"
|
||
|
||
# 5. TTL
|
||
vg._VISION_LOCK["agromatrix:chat_cycle"]["ts"] = (
|
||
time.time() - vg.VISION_LOCK_TTL - 1
|
||
)
|
||
session2 = _make_session()
|
||
session2 = _run_vision_bridge(session2, "agromatrix", "chat_cycle")
|
||
assert session2["vision_last_label"] == ""
|