""" 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