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
344 lines
13 KiB
Python
344 lines
13 KiB
Python
"""
|
||
tests/test_stepan_doc_mode_hardening_v36.py
|
||
|
||
v3.6 Doc Mode Hardening:
|
||
- Cooldown blocks implicit doc re-activate (Rule 1)
|
||
- Explicit doc token bypasses cooldown (Rule 3)
|
||
- Fact signal allows implicit doc (Rule 2)
|
||
- No fact signal + no explicit → clarifier
|
||
- Auto-clear sets cooldown (cooldown_set)
|
||
- build_mode_clarifier (URL, vision, fact, neutral)
|
||
- detect_context_signals correctness
|
||
- is_doc_focus_cooldown_active TTL
|
||
- /doc status shows cooldown_left
|
||
"""
|
||
import time
|
||
import pytest
|
||
|
||
|
||
# ─── helpers для ізольованого тестування без crewai ──────────────────────────
|
||
from crews.agromatrix_crew.doc_focus import (
|
||
_is_doc_question,
|
||
_detect_domain,
|
||
detect_context_signals,
|
||
build_mode_clarifier,
|
||
handle_doc_focus,
|
||
)
|
||
from crews.agromatrix_crew.session_context import (
|
||
_default_session,
|
||
is_doc_focus_cooldown_active,
|
||
is_doc_focus_active,
|
||
DOC_FOCUS_TTL,
|
||
DOC_FOCUS_COOLDOWN_S,
|
||
)
|
||
|
||
|
||
# ─── 1. detect_context_signals ────────────────────────────────────────────────
|
||
|
||
class TestDetectContextSignals:
|
||
def test_empty_text(self):
|
||
s = detect_context_signals("")
|
||
assert not any(s.values()), "Empty text should produce all-False signals"
|
||
|
||
def test_explicit_doc_token(self):
|
||
s = detect_context_signals("по звіту що таке прибуток?")
|
||
assert s["has_explicit_doc_token"]
|
||
assert s["has_doc_trigger"]
|
||
|
||
def test_url_detected(self):
|
||
s = detect_context_signals("дивись https://example.com")
|
||
assert s["has_url"]
|
||
assert not s["has_explicit_doc_token"]
|
||
|
||
def test_vision_trigger(self):
|
||
s = detect_context_signals("на листі плями чорні")
|
||
assert s["has_vision_trigger"]
|
||
assert not s["has_explicit_doc_token"]
|
||
|
||
def test_fact_signal_units(self):
|
||
s = detect_context_signals("скільки грн/га вийшло?")
|
||
assert s["has_fact_signal"]
|
||
|
||
def test_fact_signal_words(self):
|
||
s = detect_context_signals("що з витратами на добрива?")
|
||
assert s["has_fact_signal"]
|
||
|
||
def test_no_signals(self):
|
||
s = detect_context_signals("привіт, як справи?")
|
||
assert not s["has_explicit_doc_token"]
|
||
assert not s["has_url"]
|
||
assert not s["has_fact_signal"]
|
||
|
||
def test_fact_units_number(self):
|
||
s = detect_context_signals("4500 грн на гектар")
|
||
assert s["has_fact_signal"]
|
||
|
||
|
||
# ─── 2. build_mode_clarifier ──────────────────────────────────────────────────
|
||
|
||
class TestBuildModeClarifier:
|
||
def test_url_clarifier(self):
|
||
c = build_mode_clarifier("ось посилання https://site.com")
|
||
assert "посилання" in c.lower()
|
||
assert "?" in c
|
||
assert "!" not in c
|
||
|
||
def test_vision_clarifier(self):
|
||
c = build_mode_clarifier("на листі плями є")
|
||
assert "фото" in c.lower() or "рослин" in c.lower()
|
||
|
||
def test_fact_clarifier(self):
|
||
c = build_mode_clarifier("а що з витратами?")
|
||
assert "цифри" in c.lower() or "звіт" in c.lower()
|
||
|
||
def test_neutral_clarifier(self):
|
||
c = build_mode_clarifier("що там?")
|
||
assert "звіт" in c.lower() or "інше" in c.lower()
|
||
|
||
def test_no_exclamation(self):
|
||
for text in ["https://x.com", "листя плями", "витрати?", "привіт"]:
|
||
assert "!" not in build_mode_clarifier(text)
|
||
|
||
def test_exactly_one_question_mark(self):
|
||
for text in ["https://x.com", "листя плями", "витрати?", "привіт"]:
|
||
assert build_mode_clarifier(text).count("?") == 1
|
||
|
||
|
||
# ─── 3. is_doc_focus_cooldown_active ─────────────────────────────────────────
|
||
|
||
class TestCooldownHelper:
|
||
def test_inactive_by_default(self):
|
||
s = _default_session()
|
||
assert not is_doc_focus_cooldown_active(s)
|
||
|
||
def test_active_when_in_future(self):
|
||
now = time.time()
|
||
s = _default_session()
|
||
s["doc_focus_cooldown_until"] = now + 60
|
||
assert is_doc_focus_cooldown_active(s, now)
|
||
|
||
def test_expired(self):
|
||
now = time.time()
|
||
s = _default_session()
|
||
s["doc_focus_cooldown_until"] = now - 1
|
||
assert not is_doc_focus_cooldown_active(s, now)
|
||
|
||
def test_zero_is_inactive(self):
|
||
s = _default_session()
|
||
s["doc_focus_cooldown_until"] = 0.0
|
||
assert not is_doc_focus_cooldown_active(s)
|
||
|
||
def test_fail_safe_on_garbage(self):
|
||
s = _default_session()
|
||
s["doc_focus_cooldown_until"] = "broken"
|
||
# Should return False without exception
|
||
result = is_doc_focus_cooldown_active(s)
|
||
assert result is False
|
||
|
||
|
||
# ─── 4. DOC_FOCUS_COOLDOWN_S constant ────────────────────────────────────────
|
||
|
||
class TestCooldownConstants:
|
||
def test_cooldown_120s(self):
|
||
assert DOC_FOCUS_COOLDOWN_S == 120.0
|
||
|
||
def test_ttl_600s(self):
|
||
assert DOC_FOCUS_TTL == 600.0
|
||
|
||
|
||
# ─── 5. Gating симуляція (без LLM/HTTP) ──────────────────────────────────────
|
||
|
||
def _simulate_gating(text: str, session: dict, now: float) -> tuple[str, str | None]:
|
||
"""
|
||
Спрощена симуляція логіки context_mode з run.py.
|
||
Повертає (context_mode, denied_reason | None).
|
||
"""
|
||
signals = detect_context_signals(text)
|
||
domain = _detect_domain(text)
|
||
focus_active = is_doc_focus_active(session, now)
|
||
cooldown_active = is_doc_focus_cooldown_active(session, now)
|
||
|
||
if domain == "doc":
|
||
is_explicit = signals["has_explicit_doc_token"]
|
||
# Rule 1: cooldown blocks implicit
|
||
if cooldown_active and not is_explicit:
|
||
return "general", "cooldown"
|
||
# Rule 2: implicit → needs fact_signal
|
||
if not is_explicit:
|
||
if signals["has_fact_signal"]:
|
||
return "doc", None
|
||
return "general", "no_fact_signal"
|
||
# Rule 3: explicit always allowed
|
||
return "doc", None
|
||
|
||
return "general", None
|
||
|
||
|
||
class TestGatingRules:
|
||
"""Rule 1/2/3 з v3.6."""
|
||
|
||
def test_cooldown_blocks_implicit_doc(self):
|
||
now = time.time()
|
||
s = _default_session()
|
||
s["doc_focus"] = True
|
||
s["doc_focus_ts"] = now
|
||
s["doc_focus_cooldown_until"] = now + 120
|
||
# "документ що там" — загальний тригер (_DOC_QUESTION_RE),
|
||
# але НЕ explicit ("в документі" потрапляє в explicit regex).
|
||
# Тому використовуємо текст без конструкції "в/у документі":
|
||
mode, reason = _simulate_gating("документ що там", s, now)
|
||
assert mode == "general"
|
||
assert reason == "cooldown"
|
||
|
||
def test_explicit_bypasses_cooldown(self):
|
||
now = time.time()
|
||
s = _default_session()
|
||
s["doc_focus"] = True
|
||
s["doc_focus_ts"] = now
|
||
s["doc_focus_cooldown_until"] = now + 120
|
||
mode, reason = _simulate_gating("по звіту прибуток?", s, now)
|
||
assert mode == "doc"
|
||
assert reason is None
|
||
|
||
def test_no_explicit_no_fact_signal_denied(self):
|
||
now = time.time()
|
||
s = _default_session()
|
||
s["doc_facts"] = {}
|
||
mode, reason = _simulate_gating("а що там у документі?", s, now)
|
||
# domain=doc (загальний тригер), але signals.has_fact_signal=False → denied
|
||
# Може бути mode=doc якщо has_doc_trigger, перевіряємо логіку
|
||
signals = detect_context_signals("а що там у документі?")
|
||
if not signals["has_fact_signal"] and not signals["has_explicit_doc_token"]:
|
||
assert mode == "general"
|
||
assert reason == "no_fact_signal"
|
||
|
||
def test_fact_signal_allows_implicit_doc(self):
|
||
now = time.time()
|
||
s = _default_session()
|
||
s["doc_focus"] = True
|
||
s["doc_focus_ts"] = now
|
||
s["doc_focus_cooldown_until"] = 0.0 # cooldown inactive
|
||
mode, reason = _simulate_gating("скільки грн/га витрат?", s, now)
|
||
# has_fact_signal=True і domain=?
|
||
signals = detect_context_signals("скільки грн/га витрат?")
|
||
domain = _detect_domain("скільки грн/га витрат?")
|
||
if domain == "doc":
|
||
assert mode == "doc"
|
||
# Якщо domain != doc (загальний текст з units) — general, ok теж
|
||
assert reason is None or reason == "no_fact_signal"
|
||
|
||
def test_url_domain_goes_general(self):
|
||
now = time.time()
|
||
s = _default_session()
|
||
mode, reason = _simulate_gating("дивись https://example.com", s, now)
|
||
assert mode == "general"
|
||
|
||
def test_empty_text_general(self):
|
||
now = time.time()
|
||
s = _default_session()
|
||
mode, reason = _simulate_gating("", s, now)
|
||
assert mode == "general"
|
||
|
||
|
||
# ─── 6. Auto-clear sets cooldown симуляція ────────────────────────────────────
|
||
|
||
def _simulate_auto_clear(session: dict, domain: str, now: float):
|
||
"""Симуляція auto-clear логіки."""
|
||
focus_active = is_doc_focus_active(session, now)
|
||
if focus_active and domain in ("vision", "web"):
|
||
session["doc_focus"] = False
|
||
session["doc_focus_ts"] = 0.0
|
||
session["doc_focus_cooldown_until"] = now + DOC_FOCUS_COOLDOWN_S
|
||
return True # cleared
|
||
return False
|
||
|
||
|
||
class TestAutoClearCooldown:
|
||
def test_web_domain_sets_cooldown(self):
|
||
now = time.time()
|
||
s = _default_session()
|
||
s["doc_focus"] = True
|
||
s["doc_focus_ts"] = now
|
||
cleared = _simulate_auto_clear(s, "web", now)
|
||
assert cleared
|
||
assert not s["doc_focus"]
|
||
assert is_doc_focus_cooldown_active(s, now)
|
||
assert s["doc_focus_cooldown_until"] == pytest.approx(now + 120, abs=1)
|
||
|
||
def test_vision_domain_sets_cooldown(self):
|
||
now = time.time()
|
||
s = _default_session()
|
||
s["doc_focus"] = True
|
||
s["doc_focus_ts"] = now
|
||
_simulate_auto_clear(s, "vision", now)
|
||
assert is_doc_focus_cooldown_active(s, now)
|
||
|
||
def test_general_domain_no_clear(self):
|
||
now = time.time()
|
||
s = _default_session()
|
||
s["doc_focus"] = True
|
||
s["doc_focus_ts"] = now
|
||
cleared = _simulate_auto_clear(s, "general", now)
|
||
assert not cleared
|
||
assert s["doc_focus"]
|
||
|
||
def test_cooldown_expires_after_120s(self):
|
||
now = time.time()
|
||
s = _default_session()
|
||
s["doc_focus_cooldown_until"] = now - 1 # вже закінчився
|
||
assert not is_doc_focus_cooldown_active(s, now)
|
||
|
||
|
||
# ─── 7. /doc status shows cooldown ────────────────────────────────────────────
|
||
|
||
class TestDocFocusHandlerCooldown:
|
||
def test_status_no_cooldown(self, monkeypatch):
|
||
"""Status при відсутньому cooldown."""
|
||
from crews.agromatrix_crew.session_context import _STORE
|
||
chat_id = "test_v36_status_no_cooldown"
|
||
_STORE[chat_id] = _default_session()
|
||
result = handle_doc_focus("status", chat_id)
|
||
assert result["ok"]
|
||
assert "cooldown" not in result["message"]
|
||
|
||
def test_status_with_cooldown(self, monkeypatch):
|
||
"""Status показує cooldown_left якщо cooldown активний."""
|
||
from crews.agromatrix_crew.session_context import _STORE
|
||
now = time.time()
|
||
chat_id = "test_v36_status_with_cooldown"
|
||
s = _default_session()
|
||
s["doc_focus_cooldown_until"] = now + 60
|
||
s["updated_at"] = now # щоб сесія не вважалась протухлою
|
||
_STORE[chat_id] = s
|
||
result = handle_doc_focus("status", chat_id)
|
||
assert result["ok"]
|
||
assert "cooldown" in result["message"]
|
||
|
||
def test_doc_on_resets_cooldown(self):
|
||
"""'/doc on' скидає cooldown."""
|
||
from crews.agromatrix_crew.session_context import _STORE
|
||
now = time.time()
|
||
chat_id = "test_v36_on_resets_cooldown"
|
||
s = _default_session()
|
||
s["doc_focus_cooldown_until"] = now + 100
|
||
s["updated_at"] = now
|
||
_STORE[chat_id] = s
|
||
handle_doc_focus("on", chat_id)
|
||
updated = _STORE[chat_id]
|
||
assert updated["doc_focus"] is True
|
||
assert updated.get("doc_focus_cooldown_until", 0.0) == 0.0
|
||
|
||
|
||
# ─── 8. Default session has new field ─────────────────────────────────────────
|
||
|
||
class TestDefaultSession:
|
||
def test_has_cooldown_field(self):
|
||
s = _default_session()
|
||
assert "doc_focus_cooldown_until" in s
|
||
assert s["doc_focus_cooldown_until"] == 0.0
|
||
|
||
def test_has_doc_focus(self):
|
||
s = _default_session()
|
||
assert "doc_focus" in s
|
||
assert s["doc_focus"] is False
|