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
266 lines
11 KiB
Python
266 lines
11 KiB
Python
"""
|
||
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}'"
|