""" Acceptance tests для Humanized Stepan v2.5 Тести без crewai — перевіряємо: 1. 5-крокова сесія: привіт → план → а на післязавтра? → ок → дякую Очікування: 1=light, 2=deep, 3=light, 4=light, 5=light Жодного зайвого crew launch між кроками 1,3,4,5 2. Greeting рівні за interaction_count (neutral/soft/contextual) 3. RNG стабільний в межах дня, різний між днями 4. Weather mini-knowledge (дощ + FarmProfile phase) 5. Jaccard guard для summary 6. tone_constraints у default профілі """ 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.depth_classifier import classify_depth from crews.agromatrix_crew.light_reply import ( build_light_reply, classify_light_event, _seeded_rng, _weather_reply, ) from crews.agromatrix_crew.memory_manager import ( _default_user_profile, build_interaction_summary, _jaccard_similarity, _summary_changed_enough, _should_update_summary, push_recent_topic, migrate_profile_topics, summarize_topic_label, ) # ─── 5-крокова сесія ───────────────────────────────────────────────────────── def test_session_5_steps_depth(): """ Сесія з 5 повідомлень: 1. "привіт" → light 2. "сплануй день по полю 5" → deep (action verb) 3. "а на післязавтра?" → light (≤6 слів + last_topic) 4. "ок" → light 5. "дякую" → light """ # Крок 1: без last_topic assert classify_depth("привіт", last_topic=None) == "light", "step 1 should be light" # Крок 2: action verb → deep assert classify_depth("сплануй день по полю 5", last_topic=None) == "deep", "step 2 should be deep" # Крок 3: followup з last_topic (після step 2 last_topic = plan_day) assert classify_depth("а на післязавтра?", last_topic="plan_day") == "light", "step 3 should be light" # Крок 4 assert classify_depth("ок", last_topic="plan_day") == "light", "step 4 should be light" # Крок 5 assert classify_depth("дякую", last_topic="plan_day") == "light", "step 5 should be light" def test_session_no_crew_for_light_steps(): """Steps 1,3,4,5 не мають запускати crew — перевіряємо через build_light_reply.""" profile = {"user_id": "session_user", "name": None, "last_topic": None, "interaction_count": 0} # step 1 — greeting без теми → build_light_reply returns str (no LLM) r1 = build_light_reply("привіт", profile) assert r1 is not None, "step 1: greeting should return no-LLM reply" # step 3 — short followup profile_after_plan = dict(profile, last_topic="plan_day", interaction_count=1) r3 = build_light_reply("а на післязавтра?", profile_after_plan) assert r3 is not None, "step 3: short followup should return no-LLM reply" # step 4 — ack r4 = build_light_reply("ок", profile_after_plan) assert r4 is not None, "step 4: ack should return no-LLM reply" # step 5 — thanks r5 = build_light_reply("дякую", profile_after_plan) assert r5 is not None, "step 5: thanks should return no-LLM reply" # ─── Greeting рівні за interaction_count ───────────────────────────────────── def test_greeting_neutral_new_user(): """interaction_count=0 → neutral bank (коротка, без питання).""" profile = {"user_id": "u_new", "name": None, "last_topic": None, "interaction_count": 0} reply = build_light_reply("привіт", profile) assert reply is not None # Neutral phrases: "На звʼязку", "Слухаю", "Привіт", "Так?" # Should be short and not contain a question from the contextual bank assert "планування" not in reply.lower() and "датчики" not in reply.lower(), \ f"New user should not get contextual greeting: {reply!r}" def test_greeting_soft_mid_user(): """interaction_count=5 → soft bank.""" profile = {"user_id": "u_mid", "name": None, "last_topic": None, "interaction_count": 5} reply = build_light_reply("привіт", profile) assert reply is not None # Soft phrases contain open question but NOT the contextual binary choice assert reply is not None def test_greeting_contextual_experienced_user(): """interaction_count=10 → contextual bank.""" profile = {"user_id": "u_exp", "name": None, "last_topic": None, "interaction_count": 10} reply = build_light_reply("привіт", profile) assert reply is not None # Contextual phrases: "По плануванню чи по датчиках?" etc. contextual_words = ["плануванн", "датчик", "операції", "аналітик", "план чи факт", "польові"] assert any(w in reply.lower() for w in contextual_words), \ f"Experienced user should get contextual greeting: {reply!r}" def test_greeting_with_name_neutral(): """Ім'я у нейтральному greeting.""" profile = {"user_id": "u_named", "name": "Іван", "last_topic": None, "interaction_count": 1} reply = build_light_reply("привіт", profile) assert reply is not None assert "Іван" in reply # ─── RNG за днем ───────────────────────────────────────────────────────────── def test_rng_stable_same_day(): """Один user + один день → однаковий вибір.""" rng1 = _seeded_rng("user_a", day="2026-02-24") rng2 = _seeded_rng("user_a", day="2026-02-24") assert rng1.randint(0, 1000) == rng2.randint(0, 1000) def test_rng_different_days(): """Один user, різні дні → різний seed (з ймовірністю > 99.99%).""" rng1 = _seeded_rng("user_a", day="2026-02-24") rng2 = _seeded_rng("user_a", day="2026-02-25") # Not guaranteed different, but extremely likely; check at least they compile v1 = [rng1.randint(0, 1000) for _ in range(3)] v2 = [rng2.randint(0, 1000) for _ in range(3)] # Just assert both are valid lists assert len(v1) == 3 and len(v2) == 3 def test_rng_different_users_same_day(): """Різні users, один день → незалежні RNG.""" rng1 = _seeded_rng("user_aaa", day="2026-02-24") rng2 = _seeded_rng("user_zzz", day="2026-02-24") v1 = rng1.randint(0, 10000) v2 = rng2.randint(0, 10000) # Values can coincide by chance but seed must differ s1 = int(__import__('hashlib').sha256(b"user_aaa:2026-02-24").hexdigest(), 16) % (2**32) s2 = int(__import__('hashlib').sha256(b"user_zzz:2026-02-24").hexdigest(), 16) % (2**32) assert s1 != s2 # ─── Weather mini-knowledge ─────────────────────────────────────────────────── def test_weather_rain_growing_phase(): fp = {"season_state": "growing"} reply = _weather_reply("а якщо дощ?", fp) assert reply is not None assert "обробк" in reply.lower() or "перенос" in reply.lower() def test_weather_frost_sowing(): fp = {"season_state": "sowing"} reply = _weather_reply("буде мороз", fp) assert reply is not None assert "сів" in reply.lower() or "мороз" in reply.lower() or "призупиняємо" in reply.lower() def test_weather_wind_no_phase(): reply = _weather_reply("сильний вітер", None) assert reply is not None assert "обприск" in reply.lower() or "вітер" in reply.lower() def test_no_weather_trigger(): reply = _weather_reply("а на завтра?", None) assert reply is None, f"No weather trigger, should be None: {reply!r}" def test_weather_reply_in_followup_overrides_topic(): """Weather followup береться з weather_reply, не з _SHORT_FOLLOWUP_WITH_TOPIC.""" profile = {"user_id": "u_w", "name": None, "last_topic": "plan_day", "interaction_count": 5, "preferences": {}} farm = {"season_state": "growing"} reply = build_light_reply("а якщо дощ?", profile, farm_profile=farm) assert reply is not None # Should be weather answer, not a topic-followup template assert "по план на день" not in reply.lower() # ─── Jaccard guard ──────────────────────────────────────────────────────────── def test_jaccard_similar_no_update(): """Якщо нова summary дуже схожа — не оновлювати.""" old = "Іван — агроном. Надає перевагу стислим відповідям. Взаємодій: 10." new = "Іван — агроном. Надає перевагу стислим відповідям. Взаємодій: 20." # High similarity (only interaction_count changed) → should NOT update assert not _summary_changed_enough(old, new) def test_jaccard_different_update(): """Якщо summary суттєво змінилась — оновлювати.""" old = "Оператор. Спілкується в розмовному стилі. Взаємодій: 10." new = "Іван — агроном. Надає перевагу стислим відповідям. Частіше питає про план/факт. Взаємодій: 20." assert _summary_changed_enough(old, new) def test_jaccard_none_old_always_update(): """Якщо попереднього summary немає — завжди зберігати.""" assert _summary_changed_enough(None, "Будь-яке резюме.") def test_jaccard_similarity_values(): """Перевірка значень similarity.""" assert _jaccard_similarity("привіт степан", "привіт степан") == 1.0 assert _jaccard_similarity("", "щось") == 0.0 sim = _jaccard_similarity("a b c d", "a b e f") assert 0.0 < sim < 1.0 # ─── tone_constraints у default профілі ───────────────────────────────────── def test_default_profile_has_tone_constraints(): p = _default_user_profile("u1") assert "tone_constraints" in p["preferences"] tc = p["preferences"]["tone_constraints"] assert "no_emojis" in tc assert "no_exclamations" in tc assert tc["no_emojis"] is False assert tc["no_exclamations"] is False def test_default_profile_version_4(): """Profile version updated to 4 після додавання recent_topics.""" p = _default_user_profile("u2") assert p.get("_version") == 4 # ─── v2.7: recent_topics + topic_label acceptance ──────────────────────────── def test_v27_session_topic_label_recorded(): """ Крок 1: план на завтра по полю 12 (deep) → push_recent_topic записує label. Крок 2: а на післязавтра? → light (follow-up), topic НЕ добавляється. """ profile = _default_user_profile("u_v27_session") # Крок 1: deep запит msg1 = "сплануй завтра по полю 12" assert classify_depth(msg1, last_topic=None) == "deep" label1 = summarize_topic_label(msg1) push_recent_topic(profile, "plan_day", label1) assert len(profile["recent_topics"]) == 1 assert profile["last_topic"] == "plan_day" assert profile["last_topic_label"] == label1 assert "завтра" in label1.lower() or "план" in label1.lower() or "12" in label1 # Крок 2: light follow-up → НЕ змінює recent_topics (симуляція depth=light) msg2 = "а на післязавтра?" assert classify_depth(msg2, last_topic="plan_day") == "light" topics_before = len(profile["recent_topics"]) # Light path: НЕ викликає push_recent_topic (depth="light" у update_profile_if_needed) assert len(profile["recent_topics"]) == topics_before # no change def test_v27_session_thanks_short(): """Крок 3: дякую → коротко.""" profile = _default_user_profile("u_v27_thanks") reply = build_light_reply("дякую", profile) assert reply is not None assert len(reply) <= 40 def test_v27_session_greeting_with_label(): """Крок 4: привіт після збереженої теми → відповідь містить label.""" profile = _default_user_profile("u_v27_greet") push_recent_topic(profile, "plan_day", "план на завтра поле 12") profile["interaction_count"] = 3 reply = build_light_reply("привіт", profile) assert reply is not None # Відповідь має містити label або частину теми assert "план" in reply.lower() or "поле" in reply.lower() or "завтра" in reply.lower() def test_v27_session_horizon_after_6_more_deep(): """Крок 5: ще 6 deep запитів → horizon == 5.""" profile = _default_user_profile("u_v27_horizon") topics_data = [ ("plan_day", "план на завтра"), ("plan_vs_fact", "план vs факт поле 3"), ("iot_sensors", "датчики вологості поле 7"), ("sustainability","звіт по стійкості"), ("operations", "операції по збиранню"), ("plan_week", "тижневий план посів"), ("iot_sensors", "датчики температура поле 2"), ] for intent, label in topics_data: push_recent_topic(profile, intent, label) assert len(profile["recent_topics"]) == 5 labels = [t["label"] for t in profile["recent_topics"]] # Перші 2 мали бути витиснені assert "план на завтра" not in labels assert "план vs факт поле 3" not in labels # Останні 5 мають бути присутні assert "датчики вологості поле 7" in labels assert "тижневий план посів" in labels assert "датчики температура поле 2" in labels def test_v27_migration_old_profile(): """Backward-compat: профіль v3 (без recent_topics) після міграції.""" old_v3 = { "_version": 3, "user_id": "u_v3", "last_topic": "plan_day", "interaction_count": 15, "preferences": {"tone_constraints": {"no_emojis": False, "no_exclamations": False}}, } changed = migrate_profile_topics(old_v3) assert changed is True assert old_v3["recent_topics"][0]["intent"] == "plan_day" assert old_v3["last_topic_label"] is not None def test_v27_no_recent_topics_duplication(): """push_recent_topic не дублює якщо intent+label ті самі.""" profile = _default_user_profile("u_dedup") push_recent_topic(profile, "plan_day", "план на завтра") push_recent_topic(profile, "plan_day", "план на завтра") # same → dedup assert len(profile["recent_topics"]) == 1 def test_v27_summarize_topic_label_removes_action(): assert "зроби" not in summarize_topic_label("зроби план на завтра").lower() assert "перевір" not in summarize_topic_label("перевір датчики вологості поле 5").lower() def test_v27_last_topic_aliases_sync(): """push_recent_topic тримає last_topic і last_topic_label в синхроні.""" profile = _default_user_profile("u_alias") push_recent_topic(profile, "iot", "датчики поле 3") assert profile["last_topic"] == "iot" assert profile["last_topic_label"] == "датчики поле 3" push_recent_topic(profile, "plan_day", "план на тиждень") assert profile["last_topic"] == "plan_day" assert profile["last_topic_label"] == "план на тиждень"