Files
microdao-daarion/tests/test_stepan_invariants.py
Apple 129e4ea1fc feat(platform): add new services, tools, tests and crews modules
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
2026-03-03 07:14:14 -08:00

296 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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