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/
|
||||
.env
|
||||
tg_inbox.db
|
||||
simbiot.db
|
||||
|
|
|
|||
31
README.md
31
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:<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)
|
||||
`/лестница` `/статус` `/задачи` `/помощь` — мгновенные ответы из read model:
|
||||
их закрывает **секундный рефлекс** (`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_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
|
||||
|
|
|
|||
|
|
@ -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) не материализуем — они для дебага
|
||||
|
||||
|
||||
|
|
|
|||
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():
|
||||
"""Фоновый поток демона: доставка + сироты, не мешает тикам уровней."""
|
||||
"""Фоновый поток демона: доставка + сироты + инициативы, не мешает тикам."""
|
||||
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)
|
||||
|
||||
|
||||
|
|
|
|||
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