"""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}" )