simbiot-harness/harness/proactive.py
Simbiot Harness 88bb2c0a95 decision-модель в тиках: Ollama Cloud clef:27b → локальная nimble (systemone-bench)
harness/decision.py — клиент /v1/systemone по схеме router42 бенча:
механика — быстрый путь, decision-модель — арбитр, недоступна → фолбэк.
- порядок батча задач: из 9 старейших первой — самая ценная для AUM (on);
- тема инициативной реплики: выбор вместо round-robin (on);
- серая зона гейта (только warn/info): P(нужен LLM) < 0.35 → тихий такт (shadow);
- cli.py decide: статус бэкендов, вопрос 2–9 вариантов / P(да), --stats.
Бэкенды: облако clef:27b (~0.7 с, SIMBIOT_DECISION_API_KEY) → nimble на
CPU (10–30 с); предохранитель на бэкенд, кеш детерминированных ответов,
DecisionMade (backend/model/ms/applied) — policy trace для GA-сшивки.
Дефолт SIMBIOT_DECISION=off — старые тесты герметичны.
Тест: tests/test_decision.py 34 PASS (фейковый эндпоинт); полный набор — без регрессий.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:21:29 +00:00

290 lines
No EOL
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 по банку).
При включённой decision-модели (SIMBIOT_DECISION) — выбор nimble из
шортлиста ≤ 9 наименее использованных с учётом доски и AUM-политики;
модель недоступна — 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
from . import decision
pick = decision.pick_topic(TOPICS, counts, model)
if pick:
return next(t for t in TOPICS if t["id"] == pick)
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}"
)