feat(platform): add new services, tools, tests and crews modules
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
This commit is contained in:
287
tests/test_stepan_v42_vision_bridge.py
Normal file
287
tests/test_stepan_v42_vision_bridge.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
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"] == ""
|
||||
Reference in New Issue
Block a user