simbiot-harness/tests/test_proactive.py
Simbiot Harness 9a214d6ac3 feat(proactive): инициативные реплики харнеса в чат — ритм/анти-спам/банк тем AUM (t-260930073419)
- InitiativeSent (aggregate cycle, id proactive:<slot>) — идемпотентно UNIQUE-стора
- es-проекция: последний слот + история sent (анти-спам стейт)
- harness/proactive.py: окно 5-21 UTC (история чата), интервал 8ч, тишина 2ч
  после сообщения Alex, не при неотвеченных входящих/critical-эскалациях
- банк из 10 тем без LLM (ноль кредитов), round-robin по использованным
- хук в delivery-демоне (15с, best-effort); гейт важности не затронут
- CLI: proactive / --plan / --force; env-оверрайды интервала/тишины/слота
- tests/test_proactive.py: 34 проверки (окно/анти-спам/round-robin/идемпотентность/CLI)
2026-09-30 11:15:49 +00:00

248 lines
No EOL
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.

# -*- 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: все проверки прошли")