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:
parent
06be288657
commit
9a214d6ac3
7 changed files with 599 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ __pycache__/
|
||||||
/tmp/
|
/tmp/
|
||||||
.env
|
.env
|
||||||
tg_inbox.db
|
tg_inbox.db
|
||||||
|
simbiot.db
|
||||||
|
|
|
||||||
31
README.md
31
README.md
|
|
@ -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
14
cli.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
281
harness/proactive.py
Normal 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}"
|
||||||
|
)
|
||||||
|
|
@ -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
248
tests/test_proactive.py
Normal 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: все проверки прошли")
|
||||||
Loading…
Reference in a new issue