- 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)
248 lines
No EOL
12 KiB
Python
248 lines
No EOL
12 KiB
Python
# -*- 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: все проверки прошли") |