- 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)
281 lines
No EOL
16 KiB
Python
281 lines
No EOL
16 KiB
Python
"""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}"
|
||
) |