From 9a214d6ac38a99ac5d04044c6797012c8b6a164e Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Wed, 30 Sep 2026 11:15:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(proactive):=20=D0=B8=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D0=B8=D0=B0=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D0=B5=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=BB=D0=B8=D0=BA=D0=B8=20=D1=85=D0=B0=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D0=B0=20=D0=B2=20=D1=87=D0=B0=D1=82=20=E2=80=94=20=D1=80?= =?UTF-8?q?=D0=B8=D1=82=D0=BC/=D0=B0=D0=BD=D1=82=D0=B8-=D1=81=D0=BF=D0=B0?= =?UTF-8?q?=D0=BC/=D0=B1=D0=B0=D0=BD=D0=BA=20=D1=82=D0=B5=D0=BC=20AUM=20(t?= =?UTF-8?q?-260930073419)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InitiativeSent (aggregate cycle, id proactive:) — идемпотентно 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) --- .gitignore | 1 + README.md | 31 ++++- cli.py | 14 ++ harness/es.py | 14 ++ harness/proactive.py | 281 ++++++++++++++++++++++++++++++++++++++++ harness/scheduler.py | 13 +- tests/test_proactive.py | 248 +++++++++++++++++++++++++++++++++++ 7 files changed, 599 insertions(+), 3 deletions(-) create mode 100644 harness/proactive.py create mode 100644 tests/test_proactive.py diff --git a/.gitignore b/.gitignore index a877b9a..6cd34b5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ __pycache__/ /tmp/ .env tg_inbox.db +simbiot.db diff --git a/README.md b/README.md index d4f3f82..26e9e6f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Strands Harness SDK как исполнительное ядро, модели `CycleTicked`, `TickAbsorbed`, `ReflexObserved`, `DigestWritten`, `TaskSubmitted`, `TaskClaimed`, `TaskCompleted`, `TaskFailed`, `TaskPromoted`, -`TaskAwaitDelivery`, `DeliverySent`, `EscalationRaised`, +`TaskAwaitDelivery`, `DeliverySent`, `InitiativeSent`, `EscalationRaised`, `EscalationAcknowledged`, `BudgetSpent`, `CycleError`, `HarnessInit`, `PolicySet` — append-only, проекции пересобираются replay'ем, стейт = производное от событий. @@ -98,6 +98,35 @@ journalctl -u simbiot-harness -f Гейт важности после сводного ответа больше не будит минуту на «хвосты». Раньше m-516173222..228 обрабатывались 7 тиками подряд — по одному за тик. +## Инициативные реплики в чат (t-260930073419) + +Харнес не только отвечает, но и сам пишет в чат: периодически — мысль/идею +по общему интересу (рост AUM кооператива) с вопросом-крючком. Механизм +(`harness/proactive.py`) без LLM: темы — детерминированный банк, ноль +кредитов роутера, предсказуемый тон. + +- Событие `InitiativeSent` (aggregate `cycle`, id `proactive:`) — + идемпотентно через 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) `/лестница` `/статус` `/задачи` `/помощь` — мгновенные ответы из read model: их закрывает **секундный рефлекс** (`harness/tg_commands.py` в `_reflex_tick`), diff --git a/cli.py b/cli.py index c62b6e0..f744b89 100644 --- a/cli.py +++ b/cli.py @@ -57,6 +57,10 @@ def main(): p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации") p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)") 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.add_argument("--register", action="store_true", help="(re)зарегистрировать setMyCommands (кириллица, фолбэк — транслит)") @@ -201,6 +205,16 @@ def main(): from harness import tg_inbox r = tg_inbox.notify_chat(args.text) 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": es.init_db() from harness import tg_commands diff --git a/harness/es.py b/harness/es.py index ade2718..a241ed9 100644 --- a/harness/es.py +++ b/harness/es.py @@ -311,6 +311,20 @@ def _apply_event(model: dict, ev: dict): data.get("level", "?"), {"busy": False, "current_task": None, "ticks": 0}) cs["errors"] = cs.get("errors", 0) + 1 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) не материализуем — они для дебага diff --git a/harness/proactive.py b/harness/proactive.py new file mode 100644 index 0000000..2a6eca1 --- /dev/null +++ b/harness/proactive.py @@ -0,0 +1,281 @@ +"""Simbiot Harness — инициативные реплики в чат (t-260930073419). + +Харнес не только отвечает, но и сам пишет Alex'у: периодически — +мысль/идею по общему интересу (рост AUM кооператива) с вопросом-крючком. +Alex просил «не только отвечать, но и думать в чат»; tg-notify вручную +не масштабируется — нужен ритм, встроенный в тики. + +Механика: +- события InitiativeSent (aggregate='cycle', id='proactive:') — + идемпотентно на уровне стора (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}" + ) \ No newline at end of file diff --git a/harness/scheduler.py b/harness/scheduler.py index 161e87b..d899e30 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -559,9 +559,9 @@ def deliver_pending(): def _delivery_and_orphans_loop(): - """Фоновый поток демона: доставка + сироты, не мешает тикам уровней.""" + """Фоновый поток демона: доставка + сироты + инициативы, не мешает тикам.""" import threading - _log("delivery-демон запущен (orphan-requeue + автодоставка)") + _log("delivery-демон запущен (orphan-requeue + автодоставка + инициативные реплики)") # первый проход сразу: чистим сирот рестарта, доим недоставленное while True: try: @@ -569,6 +569,15 @@ def _delivery_and_orphans_loop(): deliver_pending() except Exception: 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) diff --git a/tests/test_proactive.py b/tests/test_proactive.py new file mode 100644 index 0000000..bdc1ef2 --- /dev/null +++ b/tests/test_proactive.py @@ -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: все проверки прошли") \ No newline at end of file