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)
This commit is contained in:
Simbiot Harness 2026-09-30 11:15:46 +00:00
parent 06be288657
commit 9a214d6ac3
7 changed files with 599 additions and 3 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@ __pycache__/
/tmp/ /tmp/
.env .env
tg_inbox.db tg_inbox.db
simbiot.db

View file

@ -20,7 +20,7 @@ Strands Harness SDK как исполнительное ядро, модели
`CycleTicked`, `TickAbsorbed`, `ReflexObserved`, `DigestWritten`, `CycleTicked`, `TickAbsorbed`, `ReflexObserved`, `DigestWritten`,
`TaskSubmitted`, `TaskClaimed`, `TaskCompleted`, `TaskFailed`, `TaskPromoted`, `TaskSubmitted`, `TaskClaimed`, `TaskCompleted`, `TaskFailed`, `TaskPromoted`,
`TaskAwaitDelivery`, `DeliverySent`, `EscalationRaised`, `TaskAwaitDelivery`, `DeliverySent`, `InitiativeSent`, `EscalationRaised`,
`EscalationAcknowledged`, `BudgetSpent`, `CycleError`, `HarnessInit`, `EscalationAcknowledged`, `BudgetSpent`, `CycleError`, `HarnessInit`,
`PolicySet` — append-only, проекции пересобираются replay'ем, стейт = `PolicySet` — append-only, проекции пересобираются replay'ем, стейт =
производное от событий. производное от событий.
@ -98,6 +98,35 @@ journalctl -u simbiot-harness -f
Гейт важности после сводного ответа больше не будит минуту на «хвосты». Гейт важности после сводного ответа больше не будит минуту на «хвосты».
Раньше m-516173222..228 обрабатывались 7 тиками подряд — по одному за тик. Раньше m-516173222..228 обрабатывались 7 тиками подряд — по одному за тик.
## Инициативные реплики в чат (t-260930073419)
Харнес не только отвечает, но и сам пишет в чат: периодически — мысль/идею
по общему интересу (рост AUM кооператива) с вопросом-крючком. Механизм
(`harness/proactive.py`) без LLM: темы — детерминированный банк, ноль
кредитов роутера, предсказуемый тон.
- Событие `InitiativeSent` (aggregate `cycle`, id `proactive:<slot>`) —
идемпотентно через UNIQUE стора; проекция хранит последний слот +
историю (анти-спам стейт).
- Ритм: слот = 8-часовые коряги в окне активности 5–21 UTC (по истории
чата: пики 5–8 и 15–21, ночь — молчим); не чаще раза в 8ч; тишина 2ч
после последнего сообщения Alex (не перебиваем разговор); не пишем при
неотвеченных входящих и открытых critical-эскалациях.
- Темы round-robin: наименее использованная из банка (10 идей про AUM —
формула капитала, места 42 Labs, расходы роутера, воронка портала,
проекты, тарифы, отчёт о пользе); ответ Alex подхватывает минутный
уровень как обычное входящее.
- Запускается из delivery-демона (раз в 15с, best-effort), гейт важности
не затрагивается: LLM-тики остаются тихими.
- env-оверрайды: `SIMBIOT_PROACTIVE_INTERVAL_H`, `SIMBIOT_PROACTIVE_QUIET_H`,
`SIMBIOT_PROACTIVE_SLOT_H`.
```bash
./venv/bin/python cli.py proactive # отправить, если ритм позволяет (SKIP + план, если нет)
./venv/bin/python cli.py proactive --plan # состояние ритма без отправки
./venv/bin/python cli.py proactive --force # сейчас, игнорируя окно/тишину (демо)
```
## Без-LLM команды бота (t-260929210755) ## Без-LLM команды бота (t-260929210755)
`/лестница` `/статус` `/задачи` `/помощь` — мгновенные ответы из read model: `/лестница` `/статус` `/задачи` `/помощь` — мгновенные ответы из read model:
их закрывает **секундный рефлекс** (`harness/tg_commands.py` в `_reflex_tick`), их закрывает **секундный рефлекс** (`harness/tg_commands.py` в `_reflex_tick`),

14
cli.py
View file

@ -57,6 +57,10 @@ def main():
p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации") p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации")
p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)") p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)")
p_not.add_argument("text") p_not.add_argument("text")
p_pro = sub.add_parser("proactive", help="инициативная реплика: ритм/тема/отправка (t-260930073419)")
p_pro.add_argument("--force", action="store_true",
help="отправить сейчас, игнорируя окно/интервал/тишину")
p_pro.add_argument("--plan", action="store_true", help="только состояние ритма, без отправки")
p_tgc = sub.add_parser("tg-commands", help="команды бота: setMyCommands + список") p_tgc = sub.add_parser("tg-commands", help="команды бота: setMyCommands + список")
p_tgc.add_argument("--register", action="store_true", p_tgc.add_argument("--register", action="store_true",
help="(re)зарегистрировать setMyCommands (кириллица, фолбэк — транслит)") help="(re)зарегистрировать setMyCommands (кириллица, фолбэк — транслит)")
@ -201,6 +205,16 @@ def main():
from harness import tg_inbox from harness import tg_inbox
r = tg_inbox.notify_chat(args.text) r = tg_inbox.notify_chat(args.text)
print("OK: уведомление отправлено" if r else "SKIP: некому отправить (чат не известен)") print("OK: уведомление отправлено" if r else "SKIP: некому отправить (чат не известен)")
elif args.cmd == "proactive":
es.init_db()
from harness import proactive
if args.plan:
print(proactive.plan_state())
return
slot, why = proactive.speak(force=args.force)
print(f"OK: {why}" if slot else f"SKIP: {why}")
if not slot and not args.force:
print("\n" + proactive.plan_state())
elif args.cmd == "tg-commands": elif args.cmd == "tg-commands":
es.init_db() es.init_db()
from harness import tg_commands from harness import tg_commands

View file

@ -311,6 +311,20 @@ def _apply_event(model: dict, ev: dict):
data.get("level", "?"), {"busy": False, "current_task": None, "ticks": 0}) data.get("level", "?"), {"busy": False, "current_task": None, "ticks": 0})
cs["errors"] = cs.get("errors", 0) + 1 cs["errors"] = cs.get("errors", 0) + 1
cs["last_error"] = (data.get("error") or "")[:300] cs["last_error"] = (data.get("error") or "")[:300]
elif etype == "InitiativeSent":
# инициативные реплики (t-260930073419): харнес сам пишет в чат с идеями
# по кооперативу/AUM. Проекция — только последний слот (для анти-спама:
# не чаще раза в MIN_INTERVAL_H и только в окне активности).
snt = model.setdefault("initiative", {})
snt["last_slot"] = data.get("slot")
snt["last_ts"] = ev["timestamp"]
snt.setdefault("sent", []).append({
"slot": data.get("slot"), "topic": data.get("topic"),
"text": (data.get("text") or "")[:600], "ts": ev["timestamp"],
"tg_ok": bool(data.get("tg_ok")),
})
if len(snt["sent"]) > 50: # кап истории реплик
snt["sent"] = snt["sent"][-50:]
# trace-события (InvocationStarted/ToolCalled/ToolResult) не материализуем — они для дебага # trace-события (InvocationStarted/ToolCalled/ToolResult) не материализуем — они для дебага

281
harness/proactive.py Normal file
View file

@ -0,0 +1,281 @@
"""Simbiot Harness — инициативные реплики в чат (t-260930073419).
Харнес не только отвечает, но и сам пишет Alex'у: периодически —
мысль/идею по общему интересу (рост AUM кооператива) с вопросом-крючком.
Alex просил «не только отвечать, но и думать в чат»; tg-notify вручную
не масштабируется — нужен ритм, встроенный в тики.
Механика:
- события InitiativeSent (aggregate='cycle', id='proactive:<slot>') —
идемпотентно на уровне стора (UNIQUE cycle), проекция хранит последний
слот + историю (анти-спам стейт);
- слот = 8-часовые коряги в окне активности (по умолчанию 5–21 UTC —
реальная статистика чата: пики 5–8 и 15–21, ночь — молчим);
- анти-спам: не чаще раз в MIN_INTERVAL_H; тишина QUIET_AFTER_MSG_H
после последнего сообщения Alex (не перебивать разговор); не пишем
при неотвеченных входящих и открытых critical-эскалациях;
- темы — детерминированный банк (без LLM): ноль кредитов, предсказуемый
тон, round-robin по использованным; новый текст — это новая задача,
не тайный самозапуск модели.
Гейт важности НЕ затрагивается: реплики идут из без-LLМ демона
(delivery-loop), LLM-тики как были тихими, так и остаются.
Ручные рычаги (CLI):
- cli.py proactive → отправить, если ритм позволяет (сухой прогон: план)
- cli.py proactive --force → сейчас, игнорируя окно/тишину (демо/«скажи что-нибудь»)
- cli.py proactive --plan → состояние ритма без отправки
"""
import os
import secrets
from datetime import datetime, timezone
from . import es
# ────────────────────────── ритм (env-оверрайд) ──────────────────────────
def _env_int(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
# максимальная частота инициатив: одна реплика не чаще чем раз в N часов
MIN_INTERVAL_H = _env_int("SIMBIOT_PROACTIVE_INTERVAL_H", 8)
# не писать, если Alex писал в последние N часов (не перебивать разговор)
QUIET_AFTER_MSG_H = _env_int("SIMBIOT_PROACTIVE_QUIET_H", 2)
# окно активности (UTC, час, [lo, hi)): по истории чата Alex активен
# утром 5–8 и вечером 15–21; ночь 21–05 — тишина
WINDOW_HOURS = (5, 21)
SLOT_HOURS = _env_int("SIMBIOT_PROACTIVE_SLOT_H", 8) # размер слота идемпотентности
# ────────────────────────── банк тем (без LLM) ──────────────────────────
# Идеи по росту AUM кооператива: паи + 50% отчислений проектов − расходы.
# Каждая — короткая мысль + вопрос-крючок: ответ Alex подхватит мин-уровень
# как обычное входящее. Round-robin: pick_topic берёт наименее использованную.
TOPICS = [
{
"id": "aum-formula",
"text": ("Мысль про AUM: капитал кооператива — это паи + поток отчислений "
"от проектов (50% у каждого в реестре) минус расходы на "
"инфраструктуру и роутер. Если зафиксируем формулу и «число на "
"сегодня», я смогу сверять каждое своё решение с вопросом "
"«растит ли это AUM». Зафиксируем формулу и стартовое число?"),
},
{
"id": "labs-seats",
"text": ("В когорте 42 Labs по последним данным 8 мест, 4 заняты, 4 в "
"продажу по $999/мес. Заполненная когорта — это +$4к/мес к "
"доходу кооператива, самый прямой рычаг AUM из тех, что у нас "
"под рукой. Помочь собрать воронку постов/писем под эти места?"),
},
{
"id": "router-costs",
"text": ("Расходы на 9router — прямой минус к AUM. На доске висит "
"недельный дайджест расходов (t-260929144516): закроем его — "
"и я начну регулярно присылать срез «burn vs польза», чтобы "
"каждый доллар роутера был осознанным. Взять в работу?"),
},
{
"id": "automate-coop",
"text": ("Идея: перенести в меня рутину кооператива — напоминания о "
"встречах, дайджесты решений, отчёты по проектам. Часы "
"участников — тоже капитал: каждый автоматизированный час — "
"вклад в AUM. С какого процесса начать?"),
},
{
"id": "portal-funnel",
"text": ("Портал cil.by — вход в кооператив: визит → анкета (3 мин) → "
"созвон (25 мин) → место в когорте. Идея: замерить конверсию "
"по шагам и подсветить узкое место — тогда рост входящего "
"потока станет управляемым. Собрать данные по воронке?"),
},
{
"id": "cyberbank",
"text": ("Кибербанк в реестре с 50% отчислений в кооператив. Какая у "
"него сейчас модель монетизации и что мешает первым деньгам? "
"Если обсудим — пойму, где я могу ускорить продукт."),
},
{
"id": "reelsup",
"text": ("Рилсап (ReelsUp, Vlad) — проект кооператива. Могу взять на "
"себя аналитику: ритм публикаций, хештеги, что залетает. "
"Растёт проект — растут 50% в копилку, растёт AUM. Обсудить "
"с Vlad, что ему полезнее всего автоматизировать?"),
},
{
"id": "pricing-tier",
"text": ("Модель 42 Labs — подписка $999/мес + процент с выручки, "
"пересматриваемый. А если добавить нижний уровень входа "
"(разовый спринт дешевле) — расширится воронка, а полный "
"пакет останется якорем. Продумать второй тариф?"),
},
{
"id": "infra-costs",
"text": ("Инфраструктура: git.cil.by, порталы, домены, роутер. Идея: "
"собрать реестр регулярных платежей и раз в месяц показывать, "
"что можно сжать или убрать. Мелкие утечки — это тоже AUM. "
"Завести такой реестр?"),
},
{
"id": "self-metric",
"text": ("Про себя: хочу мерить свой вклад в AUM — сколько задач "
"закрыл, сколько часов сэкономил, что автоматизировал. "
"Предлагаю раз в неделю присылать «отчёт о пользе». Начать "
"с закрытия L-задач на доске — например, дайджеста расходов?"),
},
]
# ────────────────────────── планирование ──────────────────────────
def slot_of(now: "datetime | None" = None):
"""Текущий слот (окно активности) или None — сейчас писать нельзя.
Слот = период SLOT_HOURS внутри окна WINDOW_HOURS: за окном (ночь) —
None; идемпотентность InitiativeSent завязана на его имя.
"""
n = now or datetime.now(timezone.utc)
lo, hi = WINDOW_HOURS
if not (lo <= n.hour < hi):
return None
if hi - lo < SLOT_HOURS: # окно уже слота — не бывает с дефолтами, но пусть
slot = 0
else:
slot = (n.hour - lo) // SLOT_HOURS
return f"{n.strftime('%y%m%d')}-{slot}"
def _ts(s: str):
try:
return datetime.fromisoformat(s)
except (TypeError, ValueError):
return None
def should_speak(model: dict, now: "datetime | None" = None):
"""Решение анти-спама: (слот, причина) — слот None значит «молчим».
Чистая функция от read model — тестируется hermetic без TG/LLM.
"""
n = now or datetime.now(timezone.utc)
slot = slot_of(n)
if slot is None:
return None, "вне окна активности (UTC-часы вне %d–%d)" % WINDOW_HOURS
init = model.get("initiative") or {}
if init.get("last_slot") == slot:
return None, f"слот {slot} уже отработан"
# не чаще раз в MIN_INTERVAL_H (пересечение слотов/рестарты не удваивают)
last = _ts(init.get("last_ts") or "")
if last and (n - last).total_seconds() < MIN_INTERVAL_H * 3600:
return None, f"последняя реплика была {(n - last).total_seconds() / 3600:.1f}ч назад (< {MIN_INTERVAL_H}ч)"
# не перебивать разговор: Alex писал недавно — подождём его ответа
inbox = [m for m in (model.get("inbox") or {}).values()
if not m.get("ignored") and m.get("ts")]
if inbox:
last_in = max(m["ts"] for m in inbox)
dt = _ts(last_in)
if dt and (n - dt).total_seconds() < QUIET_AFTER_MSG_H * 3600:
return None, "Alex писал недавно — не перебиваем"
# незакрытые входящие: сначала триаж, потом инициативы
from . import tg_commands
unreplied = [m for m in inbox if not m.get("replied")
and not tg_commands.parse(m.get("text") or "")]
if unreplied:
return None, f"неотвеченных входящих: {len(unreplied)}"
# critical-эскалация — не время для идей: сначала разбор
crit = [e for e in (model.get("escalations") or {}).values()
if isinstance(e, dict) and e.get("status") == "open"
and e.get("severity") == "critical" and not e.get("stub")]
if crit:
return None, "открытые critical-эскалации"
return slot, "можно писать"
def pick_topic(model: dict):
"""Следующая тема: наименее использованная (round-robin по банку)."""
init = model.get("initiative") or {}
counts = {}
for s in (init.get("sent") or []):
counts[s.get("topic")] = counts.get(s.get("topic"), 0) + 1
return min(TOPICS, key=lambda t: counts.get(t["id"], 0))
def build_message(topic: dict, model: dict) -> str:
"""Текст реплики: мысль + вопрос, плюс живая строка о состоянии работ."""
parts = ["💭 " + topic["text"]]
tasks = model.get("tasks") or {}
active = sum(1 for t in tasks.values() if t.get("status") in ("ready", "running"))
if active:
parts.append(f"\n(У меня сейчас {active} задач(и) в работе — детали: /задачи)")
parts.append("\n— инициативный режим: не чаще раза в %dч, в окне %d–%d UTC; "
"надоест — скажи, отключу." % (MIN_INTERVAL_H, *WINDOW_HOURS))
return "\n".join(parts)
# ────────────────────────── отправка ──────────────────────────
def speak(model: "dict | None" = None, now: "datetime | None" = None,
force: bool = False):
"""Отправить инициативную реплику, если ритм позволяет.
Возвращает (slot | None, reason). force=True — сейчас, минуя окно и
интервал (демо/ручной запрос «скажи что-нибудь»); слот force-* свой
на каждую отправку — идемпотентность стора не блокирует.
"""
model = model or es.run_projections()
n = now or datetime.now(timezone.utc)
if force:
slot = f"force-{n.strftime('%y%m%d%H%M%S')}-{secrets.token_hex(2)}"
why = "принудительная отправка"
else:
slot, why = should_speak(model, n)
if slot is None:
return None, why
from . import tg_inbox
topic = pick_topic(model)
text = build_message(topic, model)
try:
r = tg_inbox.notify_chat(text)
except Exception as e:
r = None
why += f" (ошибка TG: {e})"
if r is None:
# чат не известен / TG лежит: события нет, попробуем в следующем проходе
return None, why + " → чат недоступен, отложено"
# InitiativeSent идемпотентен по слоту (UNIQUE cycle) — дубль не пройдёт
es.append_event("cycle", f"proactive:{slot}", "InitiativeSent", {
"slot": slot, "topic": topic["id"], "text": text[:600],
"tg_ok": bool((r or {}).get("ok")),
})
return slot, f"реплика «{topic['id']}» отправлена"
def plan_state() -> str:
"""Человекочитаемое состояние ритма (для CLI/плана, без отправки)."""
model = es.run_projections()
n = datetime.now(timezone.utc)
slot, why = should_speak(model, n)
init = model.get("initiative") or {}
last_slot = init.get("last_slot") or "—"
last_ts = (init.get("last_ts") or "—")[:16]
used = len({s.get("topic") for s in (init.get("sent") or [])})
return (
f"время (UTC): {n.strftime('%Y-%m-%d %H:%M')}\n"
f"окно активности: {WINDOW_HOURS[0]}–{WINDOW_HOURS[1]} UTC, "
f"интервал: {MIN_INTERVAL_H}ч, тишина после сообщения: {QUIET_AFTER_MSG_H}ч\n"
f"последняя реплика: слот {last_slot}, {last_ts}\n"
f"тем в банке: {len(TOPICS)}, использовано: {used}\n"
f"сейчас: {'ПИСАТЬ МОЖНО' if slot else 'молчим — ' + why}"
)

View file

@ -559,9 +559,9 @@ def deliver_pending():
def _delivery_and_orphans_loop(): def _delivery_and_orphans_loop():
"""Фоновый поток демона: доставка + сироты, не мешает тикам уровней.""" """Фоновый поток демона: доставка + сироты + инициативы, не мешает тикам."""
import threading import threading
_log("delivery-демон запущен (orphan-requeue + автодоставка)") _log("delivery-демон запущен (orphan-requeue + автодоставка + инициативные реплики)")
# первый проход сразу: чистим сирот рестарта, доим недоставленное # первый проход сразу: чистим сирот рестарта, доим недоставленное
while True: while True:
try: try:
@ -569,6 +569,15 @@ def _delivery_and_orphans_loop():
deliver_pending() deliver_pending()
except Exception: except Exception:
pass # демон не должен умирать: следующий проход попробует снова pass # демон не должен умирать: следующий проход попробует снова
# инициативные реплики (t-260930073419): без LLM, ритм внутри себя;
# ошибки глотаем — реплика подождёт следующего прохода, демон жив
try:
from . import proactive
slot, why = proactive.speak()
if slot:
_log(f"proactive: инициативная реплика слота {slot} отправлена")
except Exception:
pass
time.sleep(DELIVERY_POLL) time.sleep(DELIVERY_POLL)

248
tests/test_proactive.py Normal file
View file

@ -0,0 +1,248 @@
# -*- 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: все проверки прошли")