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
430 lines
18 KiB
Python
430 lines
18 KiB
Python
"""
|
||
Tests for Doc Focus Gate (v3.5):
|
||
- PROMPT A: doc_focus fields in session, is_doc_focus_active, TTL
|
||
- PROMPT B: _is_doc_question, context_mode arbitration
|
||
- PROMPT C: auto-clear on vision/URL domain
|
||
- PROMPT D: /doc on|off|status operator commands
|
||
- PROMPT 1: domain override — explicit doc-token beats vision
|
||
- PROMPT 2: TTL auto-expire (simulated)
|
||
- PROMPT 3: context bleed guard
|
||
"""
|
||
import sys
|
||
import os
|
||
import time
|
||
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "gateway-bot"))
|
||
|
||
|
||
# ── PROMPT A: session_context ────────────────────────────────────────────────
|
||
|
||
def test_default_session_has_doc_focus():
|
||
from crews.agromatrix_crew.session_context import _default_session
|
||
s = _default_session()
|
||
assert "doc_focus" in s
|
||
assert s["doc_focus"] is False
|
||
assert "doc_focus_ts" in s
|
||
assert s["doc_focus_ts"] == 0.0
|
||
|
||
|
||
def test_is_doc_focus_active_false_by_default():
|
||
from crews.agromatrix_crew.session_context import _default_session, is_doc_focus_active
|
||
s = _default_session()
|
||
assert is_doc_focus_active(s) is False
|
||
|
||
|
||
def test_is_doc_focus_active_true_when_set():
|
||
from crews.agromatrix_crew.session_context import is_doc_focus_active
|
||
now = time.time()
|
||
s = {"doc_focus": True, "doc_focus_ts": now}
|
||
assert is_doc_focus_active(s, now) is True
|
||
|
||
|
||
def test_is_doc_focus_active_expired():
|
||
from crews.agromatrix_crew.session_context import is_doc_focus_active, DOC_FOCUS_TTL
|
||
old_ts = time.time() - DOC_FOCUS_TTL - 1 # протух
|
||
s = {"doc_focus": True, "doc_focus_ts": old_ts}
|
||
assert is_doc_focus_active(s) is False
|
||
|
||
|
||
def test_is_doc_focus_active_just_within_ttl():
|
||
from crews.agromatrix_crew.session_context import is_doc_focus_active, DOC_FOCUS_TTL
|
||
now = time.time()
|
||
recent_ts = now - DOC_FOCUS_TTL + 5 # 5 секунд до завершення
|
||
s = {"doc_focus": True, "doc_focus_ts": recent_ts}
|
||
assert is_doc_focus_active(s, now) is True
|
||
|
||
|
||
def test_update_session_doc_focus():
|
||
from crews.agromatrix_crew.session_context import update_session, load_session, clear_session
|
||
chat_id = "test_doc_focus_chat"
|
||
clear_session(chat_id)
|
||
now = time.time()
|
||
update_session(chat_id, "test", depth="deep", doc_focus=True, doc_focus_ts=now)
|
||
s = load_session(chat_id)
|
||
assert s["doc_focus"] is True
|
||
assert abs(s["doc_focus_ts"] - now) < 1.0
|
||
clear_session(chat_id)
|
||
|
||
|
||
def test_update_session_doc_focus_off():
|
||
from crews.agromatrix_crew.session_context import update_session, load_session, clear_session
|
||
chat_id = "test_doc_focus_off_chat"
|
||
clear_session(chat_id)
|
||
now = time.time()
|
||
# Спочатку вмикаємо
|
||
update_session(chat_id, "test", depth="deep", doc_focus=True, doc_focus_ts=now)
|
||
# Потім вимикаємо
|
||
update_session(chat_id, "test2", depth="deep", doc_focus=False, doc_focus_ts=0.0)
|
||
s = load_session(chat_id)
|
||
assert s["doc_focus"] is False
|
||
clear_session(chat_id)
|
||
|
||
|
||
def test_is_doc_focus_active_fail_safe():
|
||
from crews.agromatrix_crew.session_context import is_doc_focus_active
|
||
# Broken session
|
||
assert is_doc_focus_active(None) is False
|
||
assert is_doc_focus_active({}) is False
|
||
assert is_doc_focus_active({"doc_focus": True}) is False # немає ts → вважається протухлим
|
||
|
||
|
||
# ── PROMPT B: _is_doc_question ───────────────────────────────────────────────
|
||
|
||
def test_is_doc_question_explicit():
|
||
from crews.agromatrix_crew.doc_focus import _is_doc_question
|
||
assert _is_doc_question("що у звіті?") is True
|
||
assert _is_doc_question("прибуток у документі") is True
|
||
assert _is_doc_question("відкрий звіт") is True
|
||
assert _is_doc_question("в цьому документі є дані?") is True
|
||
|
||
|
||
def test_is_doc_question_financial_with_doc_anchor():
|
||
from crews.agromatrix_crew.doc_focus import _is_doc_question
|
||
assert _is_doc_question("прибуток зі звіту") is True
|
||
assert _is_doc_question("витрати у файлі xlsx") is True
|
||
assert _is_doc_question("скільки грн/га в документі") is True
|
||
|
||
|
||
def test_is_doc_question_url_returns_false():
|
||
from crews.agromatrix_crew.doc_focus import _is_doc_question
|
||
assert _is_doc_question("https://www.cropscience.bayer.ua/Products") is False
|
||
assert _is_doc_question("вивчи каталог https://bayer.com") is False
|
||
|
||
|
||
def test_is_doc_question_vision_returns_false():
|
||
from crews.agromatrix_crew.doc_focus import _is_doc_question
|
||
assert _is_doc_question("що з листям?") is False
|
||
assert _is_doc_question("плями на рослині — що це?") is False
|
||
assert _is_doc_question("шкідник на фото") is False
|
||
|
||
|
||
def test_is_doc_question_web_intent_returns_false():
|
||
from crews.agromatrix_crew.doc_focus import _is_doc_question
|
||
assert _is_doc_question("вивчи каталог засобів захисту") is False
|
||
assert _is_doc_question("переглянь сторінку сайту") is False
|
||
|
||
|
||
def test_is_doc_question_general_false():
|
||
from crews.agromatrix_crew.doc_focus import _is_doc_question
|
||
assert _is_doc_question("привіт") is False
|
||
assert _is_doc_question("що ти вмієш?") is False
|
||
assert _is_doc_question("план на тиждень") is False
|
||
|
||
|
||
# ── PROMPT B: _detect_domain ─────────────────────────────────────────────────
|
||
|
||
def test_detect_domain_vision():
|
||
from crews.agromatrix_crew.doc_focus import _detect_domain
|
||
assert _detect_domain("що з листям?") == "vision"
|
||
assert _detect_domain("плями на рослині") == "vision"
|
||
assert _detect_domain("хвороба кукурудзи") == "vision"
|
||
|
||
|
||
def test_detect_domain_web():
|
||
from crews.agromatrix_crew.doc_focus import _detect_domain
|
||
assert _detect_domain("https://bayer.com") == "web"
|
||
assert _detect_domain("вивчи каталог сайту") == "web"
|
||
|
||
|
||
def test_detect_domain_doc():
|
||
from crews.agromatrix_crew.doc_focus import _detect_domain
|
||
assert _detect_domain("що у звіті?") == "doc"
|
||
assert _detect_domain("прибуток у документі") == "doc"
|
||
|
||
|
||
def test_detect_domain_general():
|
||
from crews.agromatrix_crew.doc_focus import _detect_domain
|
||
assert _detect_domain("привіт") == "general"
|
||
assert _detect_domain("як справи?") == "general"
|
||
|
||
|
||
# ── PROMPT C: auto-clear logic ────────────────────────────────────────────────
|
||
|
||
def test_doc_focus_clear_on_vision():
|
||
"""Симулюємо логіку auto-clear: domain=vision → doc_focus=False."""
|
||
from crews.agromatrix_crew.doc_focus import _detect_domain
|
||
from crews.agromatrix_crew.session_context import is_doc_focus_active
|
||
|
||
now = time.time()
|
||
session = {"doc_focus": True, "doc_focus_ts": now}
|
||
text = "що з листям на фото?"
|
||
domain = _detect_domain(text)
|
||
|
||
assert domain == "vision"
|
||
# Логіка з run.py:
|
||
if domain in ("vision", "web"):
|
||
session["doc_focus"] = False
|
||
session["doc_focus_ts"] = 0.0
|
||
|
||
assert is_doc_focus_active(session, now) is False
|
||
|
||
|
||
def test_doc_focus_clear_on_url():
|
||
"""URL message → doc_focus скидається."""
|
||
from crews.agromatrix_crew.doc_focus import _detect_domain
|
||
from crews.agromatrix_crew.session_context import is_doc_focus_active
|
||
|
||
now = time.time()
|
||
session = {"doc_focus": True, "doc_focus_ts": now}
|
||
text = "ще будь ласка вивчи каталог засобів захисту рослин фірми BAYER https://www.cropscience.bayer.ua"
|
||
domain = _detect_domain(text)
|
||
|
||
assert domain == "web"
|
||
if domain in ("vision", "web"):
|
||
session["doc_focus"] = False
|
||
session["doc_focus_ts"] = 0.0
|
||
|
||
assert is_doc_focus_active(session, now) is False
|
||
|
||
|
||
def test_doc_focus_preserved_on_doc_question():
|
||
"""Doc питання → doc_focus не скидається."""
|
||
from crews.agromatrix_crew.doc_focus import _detect_domain, _is_doc_question
|
||
from crews.agromatrix_crew.session_context import is_doc_focus_active
|
||
|
||
now = time.time()
|
||
session = {"doc_focus": True, "doc_focus_ts": now}
|
||
text = "який прибуток у звіті?"
|
||
|
||
domain = _detect_domain(text)
|
||
assert domain == "doc"
|
||
# Vision/web auto-clear не спрацьовує
|
||
assert domain not in ("vision", "web")
|
||
# doc_focus залишається
|
||
assert is_doc_focus_active(session, now) is True
|
||
|
||
|
||
# ── PROMPT D: /doc commands ───────────────────────────────────────────────────
|
||
|
||
def test_doc_command_status_no_session():
|
||
from crews.agromatrix_crew.session_context import clear_session
|
||
from crews.agromatrix_crew.doc_focus import handle_doc_focus
|
||
clear_session("doc_test_chat_99")
|
||
result = handle_doc_focus("status", chat_id="doc_test_chat_99")
|
||
assert result is not None
|
||
msg = result.get("message", "") or str(result)
|
||
assert "doc_focus=off" in msg or "off" in msg
|
||
|
||
|
||
def test_doc_command_on():
|
||
from crews.agromatrix_crew.session_context import clear_session, load_session
|
||
from crews.agromatrix_crew.doc_focus import handle_doc_focus
|
||
chat_id = "doc_on_test_chat"
|
||
clear_session(chat_id)
|
||
result = handle_doc_focus("on", chat_id=chat_id)
|
||
assert result is not None
|
||
msg = result.get("message", "") or str(result)
|
||
assert "on" in msg
|
||
# Сесія має відображати зміну
|
||
from crews.agromatrix_crew.session_context import _STORE
|
||
s = _STORE.get(chat_id) or {}
|
||
assert s.get("doc_focus") is True
|
||
|
||
|
||
def test_doc_command_off():
|
||
from crews.agromatrix_crew.session_context import _STORE, clear_session
|
||
from crews.agromatrix_crew.doc_focus import handle_doc_focus
|
||
chat_id = "doc_off_test_chat"
|
||
_STORE[chat_id] = {"doc_focus": True, "doc_focus_ts": time.time(), "updated_at": time.time()}
|
||
result = handle_doc_focus("off", chat_id=chat_id)
|
||
assert result is not None
|
||
msg = result.get("message", "") or str(result)
|
||
assert "off" in msg
|
||
s = _STORE.get(chat_id) or {}
|
||
assert s.get("doc_focus") is False
|
||
clear_session(chat_id)
|
||
|
||
|
||
def test_doc_command_no_chat_id():
|
||
from crews.agromatrix_crew.doc_focus import handle_doc_focus
|
||
result = handle_doc_focus("on", chat_id=None)
|
||
msg = result.get("message", "") or str(result)
|
||
assert "chat_id" in msg.lower() or "required" in msg.lower()
|
||
|
||
|
||
def test_doc_command_status_shows_ttl():
|
||
from crews.agromatrix_crew.session_context import _STORE, clear_session
|
||
from crews.agromatrix_crew.doc_focus import handle_doc_focus
|
||
chat_id = "doc_status_ttl_test"
|
||
now = time.time()
|
||
_STORE[chat_id] = {"doc_focus": True, "doc_focus_ts": now, "active_doc_id": "abc123",
|
||
"doc_facts": {"profit_uah": 5000000}, "updated_at": now}
|
||
result = handle_doc_focus("status", chat_id=chat_id)
|
||
msg = result.get("message", "") or str(result)
|
||
assert "on" in msg
|
||
assert "ttl_left" in msg
|
||
assert "abc123" in msg
|
||
assert "profit_uah" in msg
|
||
clear_session(chat_id)
|
||
|
||
|
||
# ── PROMPT 1: domain override ────────────────────────────────────────────────
|
||
|
||
def test_detect_domain_explicit_doc_beats_vision():
|
||
"""Explicit doc-токен ('по звіту', 'у файлі') перемагає vision навіть якщо є рослини/листя."""
|
||
from crews.agromatrix_crew.doc_focus import _detect_domain
|
||
# Скрін таблиці + питання по звіту
|
||
assert _detect_domain("по звіту: що з листям?") == "doc"
|
||
assert _detect_domain("у файлі є дані по хворобі рослин?") == "doc"
|
||
assert _detect_domain("в документі — плями чи хвороба?") == "doc"
|
||
|
||
|
||
def test_detect_domain_empty_text_is_vision():
|
||
"""Порожній текст (caption відсутній) → vision (фото без caption)."""
|
||
from crews.agromatrix_crew.doc_focus import _detect_domain
|
||
assert _detect_domain("") == "vision"
|
||
assert _detect_domain(" ") == "vision"
|
||
|
||
|
||
def test_detect_domain_url_beats_explicit_doc():
|
||
"""URL завжди виграє навіть якщо є explicit doc-токен."""
|
||
from crews.agromatrix_crew.doc_focus import _detect_domain
|
||
assert _detect_domain("по звіту https://bayer.com/products") == "web"
|
||
|
||
|
||
def test_is_doc_question_explicit_doc_token_beats_vision():
|
||
"""_is_doc_question повертає True якщо explicit doc-токен, навіть якщо є vision-слова."""
|
||
from crews.agromatrix_crew.doc_focus import _is_doc_question
|
||
assert _is_doc_question("по звіту є дані про шкідника?") is True
|
||
assert _is_doc_question("у файлі листя згадується?") is True
|
||
assert _is_doc_question("в документі хвороба кукурудзи?") is True
|
||
|
||
|
||
def test_is_doc_question_vision_only_without_doc_token():
|
||
"""Vision без explicit doc-токена → False."""
|
||
from crews.agromatrix_crew.doc_focus import _is_doc_question
|
||
assert _is_doc_question("що з листям на фото?") is False
|
||
assert _is_doc_question("є хвороба чи шкідник?") is False
|
||
|
||
|
||
# ── PROMPT 2: TTL auto-expire ────────────────────────────────────────────────
|
||
|
||
def test_ttl_auto_expire_logic():
|
||
"""Симулюємо: doc_focus=True але TTL протух → expire."""
|
||
from crews.agromatrix_crew.session_context import is_doc_focus_active, DOC_FOCUS_TTL
|
||
|
||
now = time.time()
|
||
old_ts = now - DOC_FOCUS_TTL - 60 # на хвилину старіше TTL
|
||
|
||
session = {
|
||
"doc_focus": True,
|
||
"doc_focus_ts": old_ts,
|
||
"active_doc_id": "some_doc",
|
||
}
|
||
|
||
# is_doc_focus_active має повернути False
|
||
assert is_doc_focus_active(session, now) is False
|
||
|
||
# Логіка expire з run.py:
|
||
expired_age = round(now - (session.get("doc_focus_ts") or 0.0))
|
||
if session.get("doc_focus") and not is_doc_focus_active(session, now):
|
||
session["doc_focus"] = False
|
||
session["doc_focus_ts"] = 0.0
|
||
|
||
assert session["doc_focus"] is False
|
||
assert session["doc_focus_ts"] == 0.0
|
||
assert expired_age > DOC_FOCUS_TTL
|
||
|
||
|
||
def test_ttl_not_expired_within_window():
|
||
"""doc_focus не скидається якщо TTL ще не минув."""
|
||
from crews.agromatrix_crew.session_context import is_doc_focus_active, DOC_FOCUS_TTL
|
||
|
||
now = time.time()
|
||
recent_ts = now - (DOC_FOCUS_TTL / 2) # половина TTL
|
||
|
||
session = {"doc_focus": True, "doc_focus_ts": recent_ts}
|
||
assert is_doc_focus_active(session, now) is True
|
||
|
||
# Expire logic не спрацьовує
|
||
if session.get("doc_focus") and not is_doc_focus_active(session, now):
|
||
session["doc_focus"] = False
|
||
assert session["doc_focus"] is True
|
||
|
||
|
||
# ── PROMPT 3: context bleed guard ────────────────────────────────────────────
|
||
|
||
def _apply_bleed_guard(styled_response: str, context_mode: str) -> str:
|
||
"""Копія логіки context bleed guard з run.py для тестів."""
|
||
import re as _re
|
||
if context_mode == "general":
|
||
_BLEED_RE = _re.compile(
|
||
r"у\s+(?:цьому|наданому|даному)\s+документі"
|
||
r"|в\s+(?:цьому|наданому|даному)\s+документі"
|
||
r"|у\s+(?:цьому\s+)?звіті|в\s+(?:цьому\s+)?звіті",
|
||
_re.IGNORECASE | _re.UNICODE,
|
||
)
|
||
if _BLEED_RE.search(styled_response):
|
||
return "Щоб відповісти точно, уточни: це питання про звіт чи про інше?"
|
||
return styled_response
|
||
|
||
|
||
def test_bleed_guard_replaces_doc_phrase_in_general_mode():
|
||
"""В general-mode фраза 'у цьому документі' заміняється на нейтральне питання."""
|
||
response = "У цьому документі немає інформації про каталог BAYER."
|
||
result = _apply_bleed_guard(response, "general")
|
||
assert "цьому документі" not in result
|
||
assert "уточни" in result
|
||
|
||
|
||
def test_bleed_guard_replaces_zvit_phrase():
|
||
"""В general-mode фраза 'у звіті' також блокується."""
|
||
response = "У звіті немає даних про засоби захисту."
|
||
result = _apply_bleed_guard(response, "general")
|
||
assert "уточни" in result
|
||
|
||
|
||
def test_bleed_guard_no_replacement_in_doc_mode():
|
||
"""В doc-mode doc-фрази дозволені."""
|
||
response = "У цьому документі прибуток: 5 972 016 грн."
|
||
result = _apply_bleed_guard(response, "doc")
|
||
assert result == response
|
||
|
||
|
||
def test_bleed_guard_no_replacement_without_doc_phrase():
|
||
"""Відповідь без doc-фраз не змінюється в general-mode."""
|
||
response = "Це рослина кукурудзи, виглядає здоровою."
|
||
result = _apply_bleed_guard(response, "general")
|
||
assert result == response
|
||
|
||
|
||
def test_bleed_guard_case_insensitive():
|
||
"""Блокування не залежить від регістру."""
|
||
response = "В Цьому Документі відсутня ця інформація."
|
||
result = _apply_bleed_guard(response, "general")
|
||
assert "уточни" in result
|
||
|
||
|
||
def test_bleed_guard_variant_phrases():
|
||
"""Різні варіанти фрази блокуються."""
|
||
phrases = [
|
||
"у наданому документі немає",
|
||
"В даному документі відсутні",
|
||
"у цьому звіті є лише",
|
||
"в цьому звіті міститься",
|
||
]
|
||
for p in phrases:
|
||
result = _apply_bleed_guard(p, "general")
|
||
assert "уточни" in result, f"Not blocked: {p!r}"
|