""" 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}"