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
296 lines
12 KiB
Python
296 lines
12 KiB
Python
"""
|
||
Invariant tests для Humanized Stepan v2.7 — захист від "повзучої ботячості".
|
||
|
||
Ці тести фіксують верхні межі та заборонені патерни.
|
||
При будь-якій зміні rule-банків / логіки — інваріанти мають залишатись зеленими.
|
||
|
||
Інваріанти:
|
||
1. Greeting (light) ≤ 80 символів
|
||
2. Thanks/Ack ≤ 40 символів
|
||
3. Light відповіді не містять заборонених фраз
|
||
4. Light не містить технічних слів з інфраструктури
|
||
5. Weather + ZZR → завжди містить "за етикеткою" або "за регламентом"
|
||
6. recent_topics horizon ≤ 5 після 7 deep взаємодій
|
||
7. Міграція: last_topic без recent_topics → recent_topics з 1 елементом
|
||
"""
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
root = Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(root))
|
||
sys.path.insert(0, str(root / 'packages' / 'agromatrix-tools'))
|
||
|
||
from crews.agromatrix_crew.light_reply import (
|
||
build_light_reply,
|
||
classify_light_event,
|
||
_weather_reply,
|
||
_GREETING_NEUTRAL,
|
||
_GREETING_SOFT,
|
||
_GREETING_CONTEXTUAL,
|
||
_GREETING_WITH_TOPIC,
|
||
_THANKS,
|
||
_ACK,
|
||
)
|
||
from crews.agromatrix_crew.memory_manager import (
|
||
_default_user_profile,
|
||
push_recent_topic,
|
||
migrate_profile_topics,
|
||
summarize_topic_label,
|
||
)
|
||
from crews.agromatrix_crew.depth_classifier import classify_depth
|
||
|
||
|
||
# ─── Forbidden phrases & technical words ────────────────────────────────────
|
||
|
||
_FORBIDDEN_PHRASES = [
|
||
"чим можу допомогти",
|
||
"чим допомогти",
|
||
"оберіть",
|
||
"я як агент",
|
||
"я бот",
|
||
"я є бот",
|
||
"я є штучний",
|
||
"я є ai",
|
||
]
|
||
|
||
_TECHNICAL_WORDS = [
|
||
"container", "uvicorn", "trace_id", "STEPAN_IMPORTS_OK",
|
||
"env var", "docker", "crewai", "agromatrix_tools",
|
||
]
|
||
|
||
|
||
def _check_no_forbidden(text: str) -> None:
|
||
tl = text.lower()
|
||
for phrase in _FORBIDDEN_PHRASES:
|
||
assert phrase not in tl, f"Forbidden phrase {phrase!r} found in: {text!r}"
|
||
|
||
|
||
def _check_no_technical(text: str) -> None:
|
||
tl = text.lower()
|
||
for word in _TECHNICAL_WORDS:
|
||
assert word not in tl, f"Technical word {word!r} found in: {text!r}"
|
||
|
||
|
||
# ─── Invariant 1: Greeting ≤ 80 chars ────────────────────────────────────────
|
||
|
||
def test_inv1_all_greeting_no_topic_le_80():
|
||
"""Всі greeting фрази без теми ≤ 80 символів (з підстановкою пустого name)."""
|
||
for bank in [_GREETING_NEUTRAL, _GREETING_SOFT, _GREETING_CONTEXTUAL]:
|
||
for template in bank:
|
||
rendered = template.format(name="")
|
||
assert len(rendered) <= 80, \
|
||
f"Greeting template too long ({len(rendered)} > 80): {template!r}"
|
||
|
||
|
||
def test_inv1_greeting_with_topic_le_80():
|
||
"""Greeting з темою ≤ 80 символів для типових тем."""
|
||
for template in _GREETING_WITH_TOPIC:
|
||
rendered = template.format(name="", topic="план на день", topic_cap="План на день", text_frag="")
|
||
assert len(rendered) <= 80, \
|
||
f"Topic greeting too long ({len(rendered)} > 80): {template!r}"
|
||
|
||
|
||
def test_inv1_build_greeting_le_80():
|
||
"""build_light_reply на привітання повертає ≤ 80 символів."""
|
||
for count in [0, 5, 10]:
|
||
profile = {"user_id": f"u_count_{count}", "name": None, "last_topic": None,
|
||
"interaction_count": count, "recent_topics": [], "last_topic_label": None}
|
||
reply = build_light_reply("привіт", profile)
|
||
if reply:
|
||
assert len(reply) <= 80, f"Greeting too long (count={count}): {reply!r}"
|
||
|
||
|
||
# ─── Invariant 2: Thanks/Ack ≤ 40 chars ──────────────────────────────────────
|
||
|
||
def test_inv2_all_thanks_le_40():
|
||
for phrase in _THANKS:
|
||
assert len(phrase) <= 40, f"Thanks phrase too long: {phrase!r}"
|
||
|
||
|
||
def test_inv2_all_ack_le_40():
|
||
for phrase in _ACK:
|
||
assert len(phrase) <= 40, f"Ack phrase too long: {phrase!r}"
|
||
|
||
|
||
def test_inv2_build_thanks_le_40():
|
||
profile = {"user_id": "u1", "name": None, "last_topic": None, "interaction_count": 0}
|
||
for text in ["дякую", "спасибі", "велике дякую"]:
|
||
reply = build_light_reply(text, profile)
|
||
if reply:
|
||
assert len(reply) <= 40, f"Thanks too long: {reply!r}"
|
||
|
||
|
||
def test_inv2_build_ack_le_40():
|
||
profile = {"user_id": "u2", "name": None, "last_topic": None, "interaction_count": 0}
|
||
for text in ["ок", "зрозумів", "добре", "чудово"]:
|
||
reply = build_light_reply(text, profile)
|
||
if reply:
|
||
assert len(reply) <= 40, f"Ack too long: {reply!r}"
|
||
|
||
|
||
# ─── Invariant 3: No forbidden phrases ───────────────────────────────────────
|
||
|
||
def test_inv3_greeting_no_forbidden():
|
||
for bank in [_GREETING_NEUTRAL, _GREETING_SOFT, _GREETING_CONTEXTUAL, _GREETING_WITH_TOPIC]:
|
||
for template in bank:
|
||
rendered = template.format(name="", topic="план", topic_cap="План", text_frag="")
|
||
_check_no_forbidden(rendered)
|
||
|
||
|
||
def test_inv3_thanks_no_forbidden():
|
||
for phrase in _THANKS:
|
||
_check_no_forbidden(phrase)
|
||
|
||
|
||
def test_inv3_ack_no_forbidden():
|
||
for phrase in _ACK:
|
||
_check_no_forbidden(phrase)
|
||
|
||
|
||
def test_inv3_build_replies_no_forbidden():
|
||
profile = {"user_id": "u3", "name": None, "last_topic": "plan_day",
|
||
"interaction_count": 5, "recent_topics": [], "last_topic_label": "план на завтра"}
|
||
for text in ["привіт", "дякую", "ок", "зрозумів", "а на завтра?"]:
|
||
reply = build_light_reply(text, profile)
|
||
if reply:
|
||
_check_no_forbidden(reply)
|
||
|
||
|
||
# ─── Invariant 4: No technical words ─────────────────────────────────────────
|
||
|
||
def test_inv4_all_banks_no_technical():
|
||
all_phrases = (
|
||
_GREETING_NEUTRAL + _GREETING_SOFT + _GREETING_CONTEXTUAL
|
||
+ _GREETING_WITH_TOPIC + _THANKS + _ACK
|
||
)
|
||
for phrase in all_phrases:
|
||
_check_no_technical(phrase)
|
||
|
||
|
||
# ─── Invariant 5: Weather + ZZR → disclaimer ──────────────────────────────────
|
||
|
||
def test_inv5_weather_zzr_has_disclaimer():
|
||
"""Якщо текст містить ZZR + погодний тригер — відповідь містить застереження."""
|
||
zzr_texts = [
|
||
"обприскування гербіцидом якщо дощ",
|
||
"обробка фунгіцидом при дощі",
|
||
"ЗЗР і сильний вітер",
|
||
"застосування пестициду — є мороз",
|
||
]
|
||
for text in zzr_texts:
|
||
reply = _weather_reply(text, None)
|
||
if reply: # may be None if no matching rule — that's ok, but if not None → must have disclaimer
|
||
assert "за етикеткою" in reply or "за регламентом" in reply, \
|
||
f"ZZR weather reply missing disclaimer: {reply!r} for text: {text!r}"
|
||
|
||
|
||
def test_inv5_weather_no_zzr_no_disclaimer():
|
||
"""Звичайний погодний запит без ZZR — без застереження."""
|
||
reply = _weather_reply("а якщо дощ?", {"season_state": "growing"})
|
||
assert reply is not None
|
||
assert "за етикеткою" not in reply
|
||
assert "за регламентом" not in reply
|
||
|
||
|
||
def test_inv5_zzr_rain_disclaimer_present():
|
||
"""Конкретний кейс: 'обприскування якщо дощ' → disclaimer."""
|
||
reply = _weather_reply("обприскування якщо дощ", {"season_state": "growing"})
|
||
assert reply is not None
|
||
assert "за етикеткою" in reply or "за регламентом" in reply
|
||
|
||
|
||
# ─── Invariant 6: recent_topics horizon ≤ 5 ─────────────────────────────────
|
||
|
||
def test_inv6_horizon_after_7_deep():
|
||
"""Після 7 push_recent_topic → len(recent_topics) == 5."""
|
||
profile = _default_user_profile("u_horizon")
|
||
for i in range(7):
|
||
push_recent_topic(profile, f"intent_{i}", f"Тема {i}")
|
||
assert len(profile["recent_topics"]) == 5, \
|
||
f"Expected 5, got {len(profile['recent_topics'])}"
|
||
|
||
|
||
def test_inv6_horizon_order_preserves_latest():
|
||
"""recent_topics зберігає 5 найновіших, не перших."""
|
||
profile = _default_user_profile("u_order")
|
||
for i in range(7):
|
||
push_recent_topic(profile, f"intent_{i}", f"Тема {i}")
|
||
labels = [t["label"] for t in profile["recent_topics"]]
|
||
assert "Тема 2" in labels # third oldest of 7 (7-5=2)
|
||
assert "Тема 6" in labels # latest
|
||
assert "Тема 0" not in labels # first was dropped
|
||
assert "Тема 1" not in labels # second was dropped
|
||
|
||
|
||
# ─── Invariant 7: migration ───────────────────────────────────────────────────
|
||
|
||
def test_inv7_migration_adds_recent_topics():
|
||
"""Профіль з last_topic але без recent_topics → міграція додає recent_topics."""
|
||
old_profile = {
|
||
"user_id": "u_old",
|
||
"last_topic": "plan_day",
|
||
"interaction_count": 5,
|
||
}
|
||
changed = migrate_profile_topics(old_profile)
|
||
assert changed is True
|
||
assert "recent_topics" in old_profile
|
||
assert len(old_profile["recent_topics"]) == 1
|
||
assert old_profile["recent_topics"][0]["intent"] == "plan_day"
|
||
|
||
|
||
def test_inv7_migration_adds_last_topic_label():
|
||
"""Профіль без last_topic_label → міграція додає поле."""
|
||
profile = {
|
||
"user_id": "u_nolabel",
|
||
"last_topic": "plan_vs_fact",
|
||
"recent_topics": [{"label": "план/факт", "intent": "plan_vs_fact", "ts": "2026-01-01"}],
|
||
}
|
||
changed = migrate_profile_topics(profile)
|
||
assert "last_topic_label" in profile
|
||
assert profile["last_topic_label"] == "план/факт"
|
||
|
||
|
||
def test_inv7_migration_idempotent():
|
||
"""Повторна міграція не змінює вже мігрований профіль."""
|
||
profile = _default_user_profile("u_idem")
|
||
push_recent_topic(profile, "plan_day", "план на день")
|
||
changed_first = migrate_profile_topics(profile)
|
||
# Already has everything → no change
|
||
changed_second = migrate_profile_topics(profile)
|
||
assert changed_second is False
|
||
|
||
|
||
def test_inv7_migration_tone_constraints():
|
||
"""Профіль без tone_constraints → міграція додає."""
|
||
profile = {
|
||
"user_id": "u_notc",
|
||
"preferences": {"units": "ha"},
|
||
}
|
||
changed = migrate_profile_topics(profile)
|
||
assert "tone_constraints" in profile["preferences"]
|
||
|
||
|
||
# ─── summarize_topic_label ────────────────────────────────────────────────────
|
||
|
||
def test_label_removes_action_verb():
|
||
label = summarize_topic_label("зроби план на завтра по полю 12")
|
||
assert "зроби" not in label.lower()
|
||
assert "план" in label.lower() or "завтра" in label.lower()
|
||
|
||
|
||
def test_label_max_8_words():
|
||
text = "зроби детальний план на завтра по полям один два три чотири пять"
|
||
label = summarize_topic_label(text)
|
||
assert len(label.split()) <= 9 # ≤8 words + minor slack for strip
|
||
|
||
|
||
def test_label_preserves_field_number():
|
||
label = summarize_topic_label("перевір вологість на полі 7")
|
||
# "полі" is a stop-word but "7" or "поле/поля" may be kept
|
||
assert label # just assert non-empty
|
||
|
||
|
||
def test_label_nonempty_for_short_text():
|
||
label = summarize_topic_label("план")
|
||
assert len(label) > 0
|