""" tests/test_stepan_doc_ux_v37.py v3.7 Doc Focus UX Polish: - build_mode_clarifier per-domain (URL, vision, fact, neutral) - _DOC_AWARENESS_RE pattern matching - Context bleed guard uses clarifier (not static phrase) - state-aware doc ack ("Працюємо зі звітом." / "По звіту дивлюсь.") - Vision intro polish (_VISION_INTRO_RE) - "no inertia" — general mode should not produce doc phrases """ import re import pytest from crews.agromatrix_crew.doc_focus import ( build_mode_clarifier, _VISION_INTRO_RE, _DOC_AWARENESS_RE, ) # ─── 1. build_mode_clarifier (v3.7 контекстне уточнення) ───────────────────── class TestBuildModeClarifierV37: """build_mode_clarifier виробляє 1 питання без "!" і без "будь ласка".""" def test_url_question(self): c = build_mode_clarifier("https://bayer.com/catalogue") assert "посилання" in c.lower() assert c.endswith("?") def test_vision_question(self): c = build_mode_clarifier("на листі жовті плями") assert "фото" in c.lower() or "рослин" in c.lower() assert c.endswith("?") def test_fact_question(self): c = build_mode_clarifier("що з витратами на добрива?") assert "цифри" in c.lower() or "звіт" in c.lower() assert c.endswith("?") def test_neutral_question(self): c = build_mode_clarifier("що там?") # Neutral fallback assert "звіт" in c.lower() or "інше" in c.lower() assert c.endswith("?") def test_no_bud_laska(self): for text in ["https://x.com", "листя плями", "витрати?", "привіт"]: c = build_mode_clarifier(text) assert "будь ласка" not in c.lower() assert "!" not in c def test_exactly_one_question_mark(self): for text in ["https://x.com", "листя плями", "витрати?", "привіт"]: c = build_mode_clarifier(text) assert c.count("?") == 1, f"Expected 1 '?', got '{c}'" def test_max_length(self): for text in ["https://x.com", "листя плями", "витрати?", "привіт"]: c = build_mode_clarifier(text) assert len(c) <= 60, f"Clarifier too long: '{c}'" # ─── 2. _DOC_AWARENESS_RE — заборонені UX-фрази ───────────────────────────── class TestDocAwarenessRegex: """v3.7: Регекс для виявлення "Так, пам'ятаю" / "Не бачу його" тощо.""" BANNED_PHRASES = [ "Так, пам'ятаю документ.", "Так, пам\u2019ятаю.", # curly apostrophe "Не бачу його перед собою.", "Не бачу його сьогодні.", "Мені доступний документ.", "Мені не доступний документ.", ] ALLOWED_PHRASES = [ "Працюємо зі звітом.", "По звіту дивлюсь.", "Йдеться про звіт чи про інше?", ] def test_banned_matched(self): for phrase in self.BANNED_PHRASES: assert _DOC_AWARENESS_RE.search(phrase), f"Should match banned: '{phrase}'" def test_allowed_not_matched(self): for phrase in self.ALLOWED_PHRASES: assert not _DOC_AWARENESS_RE.search(phrase), f"Should NOT match allowed: '{phrase}'" # ─── 3. _VISION_INTRO_RE — "На фото видно" ─────────────────────────────────── class TestVisionIntroRegex: def test_matches_vision_intro(self): assert _VISION_INTRO_RE.search("На фото видно жовті плями.") def test_no_match_other(self): assert not _VISION_INTRO_RE.search("Схоже на хлороз листя.") assert not _VISION_INTRO_RE.search("Ймовірно брак заліза.") # ─── 4. Context bleed guard — використовує clarifier (не статичну фразу) ───── def _apply_bleed_guard_v37(response: str, text: str) -> tuple[str, bool]: """ Симуляція v3.6/v3.7 bleed guard логіки (context_mode=general): замінює doc-фрази на build_mode_clarifier(text). """ _BLEED_RE = re.compile( r"у\s+(?:цьому|наданому|даному)\s+документі" r"|в\s+(?:цьому|наданому|даному)\s+документі" r"|у\s+(?:цьому\s+)?звіті|в\s+(?:цьому\s+)?звіті", re.IGNORECASE | re.UNICODE, ) if _BLEED_RE.search(response): return build_mode_clarifier(text), True return response, False class TestBleedGuardUseClarifier: def test_doc_phrase_replaced_by_clarifier(self): resp = "У цьому документі вказано прибуток 1М грн." out, replaced = _apply_bleed_guard_v37(resp, "що там?") assert replaced assert "У цьому документі" not in out assert "?" in out def test_doc_phrase_url_context_clarifier(self): resp = "У звіті є дані." out, replaced = _apply_bleed_guard_v37(resp, "https://example.com") assert replaced assert "посилання" in out.lower() def test_doc_phrase_vision_context_clarifier(self): resp = "В цьому документі щось." out, replaced = _apply_bleed_guard_v37(resp, "на листі плями") assert replaced assert "фото" in out.lower() or "рослин" in out.lower() def test_clean_response_not_touched(self): resp = "Добрива коштують 2000 грн/га." out, replaced = _apply_bleed_guard_v37(resp, "скільки добрив?") assert not replaced assert out == resp def test_case_insensitive(self): resp = "У ЦЬОМУ ДОКУМЕНТІ нічого немає." out, replaced = _apply_bleed_guard_v37(resp, "що там") assert replaced # ─── 5. State-aware doc ack симуляція ───────────────────────────────────────── def _simulate_doc_ack( styled_response: str, context_mode: str, has_explicit_doc_token: bool, doc_just_activated: bool, ) -> str: """Симуляція v3.7 state-aware doc ack prefix.""" if context_mode == "doc" and doc_just_activated: ack = "По звіту дивлюсь." if has_explicit_doc_token else "Працюємо зі звітом." if not styled_response.startswith(ack): return f"{ack}\n{styled_response}" return styled_response class TestStateAwareDocAck: def test_explicit_token_ack(self): out = _simulate_doc_ack( "Прибуток склав 500 тис грн.", "doc", True, True ) assert out.startswith("По звіту дивлюсь.") def test_general_doc_ack(self): out = _simulate_doc_ack( "Прибуток склав 500 тис грн.", "doc", False, True ) assert out.startswith("Працюємо зі звітом.") def test_no_ack_if_focus_was_already_active(self): # doc_just_activated=False — фокус вже був активний out = _simulate_doc_ack( "Прибуток склав 500 тис грн.", "doc", True, False ) assert not out.startswith("По звіту дивлюсь.") assert out == "Прибуток склав 500 тис грн." def test_no_ack_in_general_mode(self): out = _simulate_doc_ack( "Гаразд, зрозуміло.", "general", True, True ) assert out == "Гаразд, зрозуміло." def test_no_duplicate_ack(self): ack = "Працюємо зі звітом." out = _simulate_doc_ack(ack + "\nДані доступні.", "doc", False, True) assert out.count(ack) == 1 # ─── 6. UX awareness phrase suppression симуляція ──────────────────────────── def _apply_awareness_guard(response: str, text: str) -> tuple[str, bool]: """Симуляція v3.7 awareness phrase guard.""" if _DOC_AWARENESS_RE.search(response): replaced = re.sub( _DOC_AWARENESS_RE, lambda m: build_mode_clarifier(text), response, count=1, ) return replaced, True return response, False class TestAwarenessGuard: def test_pamyatayu_replaced(self): resp = "Так, пам'ятаю документ який ви надіслали." out, replaced = _apply_awareness_guard(resp, "що там?") assert replaced assert "пам" not in out.lower() or "пам'ятаю" not in out.lower() def test_ne_bachu_replaced(self): resp = "Не бачу його перед собою, надішліть ще раз." out, replaced = _apply_awareness_guard(resp, "листя плями") assert replaced assert "фото" in out.lower() or "рослин" in out.lower() def test_clean_response_unchanged(self): resp = "Прибуток у першому кварталі склав 1.2М грн." out, replaced = _apply_awareness_guard(resp, "прибуток?") assert not replaced assert out == resp def test_only_first_occurrence_replaced(self): """count=1 — замінюємо тільки перше входження.""" resp = "Так, пам'ятаю. Не бачу його." out, replaced = _apply_awareness_guard(resp, "що там?") assert replaced # Тільки перше замінено — друге може лишитись (count=1) assert out.count("?") >= 1 # ─── 7. Vision intro polish ─────────────────────────────────────────────────── class TestVisionIntroPhrases: GOOD_STARTS = [ "Схоже на хлороз листя.", "Ймовірно дефіцит мікроелементів.", "Виглядає як рання стадія хвороби.", ] BAD_STARTS = [ "На фото видно жовтіння листя.", ] def test_good_vision_intros_not_blocked(self): for phrase in self.GOOD_STARTS: assert not _VISION_INTRO_RE.search(phrase), f"False positive: '{phrase}'" def test_bad_vision_intro_blocked(self): for phrase in self.BAD_STARTS: assert _VISION_INTRO_RE.search(phrase), f"Not blocked: '{phrase}'"