# -*- coding: utf-8 -*- """t-260930073419: инициативные реплики харнеса в чат (hermetic, без LLM/TG). Запуск: ./venv/bin/python tests/test_proactive.py Харнес сам пишет в чат с идеями по AUM: не чаще раза в интервал, только в окне активности, не перебивая разговор Alex (тишина после его сообщения), не при неотвеченных входящих и critical-эскалациях. Проверяет: слоты и окно, анти-спам (интервал/тишина/входящие/эскалации), round-robin тем, идемпотентность InitiativeSent по слоту (UNIQUE cycle), CLI-команду proactive (plan/force), проекцию es (последний слот+история). """ import io import json import os import sys import contextlib # ── изоляция: своя БД в /tmp, TG не трогаем (мокаем notify_chat) ── _DST = "/tmp/simbiot-test-proactive.db" for p in (_DST, _DST + "-wal", _DST + "-shm"): if os.path.exists(p): os.unlink(p) os.environ["SIMBIOT_DB"] = _DST os.environ.pop("SIMBIOT_PROACTIVE_INTERVAL_H", None) os.environ.pop("SIMBIOT_PROACTIVE_QUIET_H", None) sys.path.insert(0, "/root/simbiot-harness") from datetime import datetime, timezone, timedelta from harness import es, tg_inbox from harness import proactive from harness.scheduler import build_context, collect_signals, LEVELS fails = [] def check(name, cond, extra=""): print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "") if not cond: fails.append(name) def _recv(update_id, text, ts=None): """MessageReceived в Telegram-формате (ts — ISO строки события в сторе).""" tg_inbox.receive_message({"update_id": update_id, "message": { "message_id": update_id % 100000, "date": 1780000000, "chat": {"id": 42}, "from": {"id": 1, "username": "alex", "first_name": "Alex"}, "text": text}}) # мок отправки: без реального TG, фиксируем вызовы _sent = [] def _fake_notify(text): _sent.append(text) return {"ok": True} tg_inbox.notify_chat = _fake_notify es.init_db() def _model(inbox=None, escs=None, init=None, tasks=None): return {"inbox": inbox or {}, "escalations": escs or {}, "initiative": init or {}, "tasks": tasks or {}, "policy": {}} U = lambda h, m=0: datetime(2026, 9, 30, h, m, tzinfo=timezone.utc) # ── 1. Слоты и окно активности ── check("окно: 10:00 UTC — слот есть", proactive.slot_of(U(10)) is not None, str(proactive.slot_of(U(10)))) check("окно: 23:00 UTC — ночь, слота нет", proactive.slot_of(U(23)) is None) check("окно: 03:00 UTC — ночь, слота нет", proactive.slot_of(U(3)) is None) check("окно: граница 5:00 — включена", proactive.slot_of(U(5)) is not None) check("окно: граница 21:00 — исключена", proactive.slot_of(U(21)) is None) check("слот: 6:00 и 7:00 — один слот (8ч)", proactive.slot_of(U(6)) == proactive.slot_of(U(7))) check("слот: 6:00 и 14:00 — разные слоты", proactive.slot_of(U(6)) != proactive.slot_of(U(14))) # ── 2. Анти-спам: should_speak (чистая функция от модели) ── # ночь → молчим slot, why = proactive.should_speak(_model(), U(23)) check("ночь → не пишем", slot is None and "окна" in why, why) # окно, пустой чат → можно slot, why = proactive.should_speak(_model(), U(10)) check("пустая модель, окно → можно", slot is not None, why) # слот уже отработан → молчим init = {"last_slot": proactive.slot_of(U(10)), "last_ts": U(10).isoformat()} slot, why = proactive.should_speak(_model(init=init), U(11)) check("тот же слот → не пишем", slot is None and "отработан" in why, why) # интервал: реплика была меньше INTERVAL часов назад (другой слот) → молчим init = {"last_slot": "x", "last_ts": U(9, 30).isoformat()} slot, why = proactive.should_speak(_model(init=init), U(10)) check("интервал < 8ч → не пишем", slot is None and "реплика была" in why, why) # интервал прошёл (реплика > 8ч назад, свежий слот) → можно init = {"last_slot": "x", "last_ts": (U(15) - timedelta(hours=9)).isoformat()} slot, why = proactive.should_speak(_model(init=init), U(15)) check("интервал прошёл → можно", slot is not None, why) # Alex писал недавно → не перебиваем inbox = {"m-1": {"text": "привет", "replied": True, "ignored": False, "ts": U(9, 30).isoformat()}} slot, why = proactive.should_speak(_model(inbox=inbox), U(10)) check("Alex писал 0.5ч назад → не пишем", slot is None and "не перебиваем" in why, why) # Alex писал давно (> QUIET_H) → можно inbox = {"m-1": {"text": "привет", "replied": True, "ignored": False, "ts": U(6).isoformat()}} slot, why = proactive.should_speak(_model(inbox=inbox), U(10)) check("Alex писал 4ч назад → можно", slot is not None, why) # неотвеченные входящие → сначала триаж inbox = {"m-2": {"text": "вопрос без ответа", "replied": False, "ignored": False, "ts": U(6).isoformat()}} slot, why = proactive.should_speak(_model(inbox=inbox), U(10)) check("неотвеченные входящие → не пишем", slot is None and "неотвеченных" in why, why) # команды не считаются «неотвеченными» (их закрывает сек-рефлекс) inbox = {"m-3": {"text": "/лестница", "replied": False, "ignored": False, "ts": U(6).isoformat()}} slot, why = proactive.should_speak(_model(inbox=inbox), U(10)) check("неотвеченная команда бота → можно (её закроет рефлекс)", slot is not None, why) # critical-эскалация → не время для идей escs = {"e-1": {"id": "e-1", "status": "open", "severity": "critical"}} slot, why = proactive.should_speak(_model(escs=escs), U(10)) check("critical-эскалация → не пишем", slot is None and "critical" in why, why) # warn-эскалация не блокирует escs = {"e-2": {"id": "e-2", "status": "open", "severity": "warn"}} slot, why = proactive.should_speak(_model(escs=escs), U(10)) check("warn-эскалация → можно", slot is not None, why) # ── 3. Темы: round-robin по использованию ── m = _model() first = proactive.pick_topic(m) check("пустая история → первая тема банка", first["id"] == proactive.TOPICS[0]["id"]) m2 = _model(init={"sent": [{"topic": first["id"]}]}) second = proactive.pick_topic(m2) check("одна тема использована → берём другую", second["id"] != first["id"], f"{first['id']} vs {second['id']}") msg = proactive.build_message(first, _model(tasks={ "t-1": {"status": "running"}, "t-2": {"status": "ready"}})) check("build_message: текст темы + счётчик задач + подпись про режим", "💭" in msg and "/задачи" in msg and "инициативный режим" in msg) # ── 4. speak() через стор: событие + проекция + идемпотентность слота ── # Решение о ритме — синтетическая модель (детерминизм, не зависит от часа # запуска теста), а вот запись события и проекция — настоящий стор. _recv(100, "привет, я Alex") # чат известен es.append_event("inbox", "m-100", "ReplySent", {"text": "ок", "tg_ok": True}) es.rebuild_projections() model_now = es.run_projections() check("фикстура: m-100 в инбоксе и отвечен", (model_now["inbox"].get("m-100") or {}).get("replied") is True, str((model_now["inbox"].get("m-100") or {}).get("replied"))) # синтетическая модель: разговор давно утих (ts U(10) — 4ч до U(14)), # входящие отвечены, инициатив ещё не было synth = _model(inbox={"m-100": { "text": "привет, я Alex", "replied": True, "ignored": False, "ts": U(10).isoformat()}}) _before = len(_sent) slot, why = proactive.speak(model=synth, now=U(14)) check("speak: реплика отправлена (окно, ритм ок)", slot is not None and len(_sent) == _before + 1, f"{slot} {why}") model = es.run_projections() init_proj = model.get("initiative") or {} check("проекция: last_slot записан", init_proj.get("last_slot") == slot, str(init_proj.get("last_slot"))) check("проекция: история sent содержит тему", (init_proj.get("sent") or [{}])[0].get("topic") == first["id"], str((init_proj.get("sent") or [{}])[:1])) # тот же слот (14:00 и 15:00 → slot 1) — реальная модель стора блокирует slot2, why2 = proactive.speak(now=U(15)) check("speak: тот же слот → не пишем", slot2 is None and "отработан" in why2, why2) # идемпотентность InitiativeSent по слоту: дубль события не проходит ev1 = es.append_event("cycle", f"proactive:{slot}", "InitiativeSent", {"slot": slot, "topic": "dup"}) check("InitiativeSent дубль → None (UNIQUE cycle)", ev1 is None, str(ev1)) # интервал: реальная модель, сейчас ~момент первой реплики (last_ts=сейчас) # → блок по интервалу (ночью вместо него блок по окну — любой блок валиден) slot4, why4 = proactive.speak(now=datetime.now(timezone.utc)) check("speak: повтор сразу после отправки → не пишем (интервал/окно)", slot4 is None and "можно писать" not in (why4 or ""), why4) # ── 5. CLI: plan/force/skip ── import importlib import cli def _run_cli(argv): old_argv, sys.argv = sys.argv, ["cli.py"] + argv buf = io.StringIO() try: with contextlib.redirect_stdout(buf): cli.main() except SystemExit as e: pass finally: sys.argv = old_argv return buf.getvalue() out = _run_cli(["proactive", "--plan"]) check("cli proactive --plan: состояние без отправки", "окно активности" in out and "ПИСАТЬ МОЖНО" in out or "молчим" in out, out[:200]) # интервал уже не прошёл (реплика в 10:00, сейчас реальное время) → SKIP с планом out = _run_cli(["proactive"]) check("cli proactive: ритм не даёт → SKIP + план", out.startswith("SKIP:") and "последняя реплика" in out, out[:200]) # force: шлёт всегда, слот force-* уникален на отправку _before = len(_sent) out = _run_cli(["proactive", "--force"]) check("cli proactive --force: отправка несмотря на ритм", out.startswith("OK:") and len(_sent) == _before + 1, out[:200]) # force снова — тоже ок (уникальный force-слот на каждую отправку) out = _run_cli(["proactive", "--force"]) check("cli proactive --force: повторно ок (новый force-слот)", out.startswith("OK:"), out[:120]) # ── 6. Гейт важности НЕ будится инициативами: сигналы не меняются ── model = es.run_projections() sigs = collect_signals(LEVELS["min"], model) check("гейт min: инициативы не будят LLM", all("initiative" not in (s[1] or "") for s in sigs), str(sigs)) sigs_h = collect_signals(LEVELS["hour"], model) check("гейт hour: инициативы не будят LLM", all("initiative" not in (s[1] or "") for s in sigs_h), str(sigs_h)) print() if fails: print(f"FAIL: {len(fails)} из проверок не прошло:", ", ".join(fails)) sys.exit(1) print("OK: все проверки прошли")