"""Simbiot Harness — адресация в группах: будить ли LLM на сообщение (аудит 2026-10-17). Кейс: в «Правлении Коллективного Разума» люди переписываются между собой, а Коди (@juno42bot) отвечал на каждое сообщение — «Доброе утро!», реплики Alex для Vlad, ответы Vlad на вопросы Alex. Фильтра не было: любое сообщение разрешённого человека в группе поднимало минутный LLM-тик. Триаж — ДО гейта, без LLM. Личка/портал/агенты/TUI — всегда адресовано. В группе: 1) детерминированно ДА: @juno42bot, reply на сообщение бота, имя бота в тексте (Коди/Cody/… — SIMBIOT_BOT_NAMES); 2) детерминированно НЕТ: reply на сообщение другого человека; 3) серая зона — decision-модель (harness/decision.py): choice «кому адресовано последнее сообщение» — бот / конкретный человек / группа в целом; P(бот) ≥ SIMBIOT_ADDRESSING_MIN_P (0.5) → ДА. В историю идут реплики людей и ответы бота ТОЛЬКО на адресованное ему: ответы «встревания» сбивали модель (замер на истории правления: choice без них — адресованное 0.93, остальные 14 ≤ 0.22; noul с ними — 4 ошибки). Модель недоступна/выключена → НЕТ: в группе молчать безопаснее. Итог пишется событием: MessageTriaged{addressed: True} (+ 👀) или MessageIgnored{reason: not_addressed} — тогда ни один уровень его не видит. """ import os import re import threading from . import es GROUP_TYPES = ("group", "supergroup") _LOCK = threading.Lock() def is_group(m: dict) -> bool: """Группа: chat_type, а у старых записей без него — отрицательный chat_id TG.""" ct = m.get("chat_type") if ct: return ct in GROUP_TYPES cid = m.get("chat_id") return isinstance(cid, int) and cid < 0 def _names_re(): """Обращение по имени — только звательное, с пунктуацией: «Коди, …», «Коди!», «…, Коди», просто «Коди». Имя посреди фразы («Коди должен отвечать…», «он Коди») — разговор О боте, его решает модель (кейс 2026-10-17: правило «имя где угодно» отвечало на обсуждение бота).""" names = os.environ.get("SIMBIOT_BOT_NAMES", "коди,cody,juno,джуно") a = "|".join(re.escape(n.strip()) for n in names.split(",") if n.strip()) return re.compile( rf"^\s*(?:{a})\s*(?:[,!:?]|$)" # «Коди, …» / «Коди!» / «Коди» rf"|,\s*(?:{a})\s*[!?.)]*\s*$", # «…, Коди» re.IGNORECASE) def _min_p() -> float: try: return float(os.environ.get("SIMBIOT_ADDRESSING_MIN_P", "0.5")) except ValueError: return 0.5 def needs_triage(m: dict) -> bool: """Групповое входящее без вердикта (и не отвеченное/не проигнорированное).""" return (is_group(m) and "addressed" not in m and not m.get("replied") and not m.get("ignored")) def rule_verdict(m: dict, bot_id=None): """Детерминированная часть: (True|False, правило) или (None, None) — серая зона.""" if not is_group(m): return True, "private" if m.get("mentions_bot"): return True, "mention" if m.get("reply_to_is_bot") or (bot_id and m.get("reply_to_user_id") == bot_id): return True, "reply_to_bot" if _names_re().search(m.get("text") or ""): return True, "name" if m.get("reply_to_user_id") and not m.get("reply_to_is_bot"): return False, "reply_to_human" return None, None def _history(m: dict, inbox: dict, limit: int = 8) -> list: """Реплики того же чата до сообщения: люди + ответы бота на адресованное.""" same = sorted((x for x in inbox.values() if x.get("chat_id") == m.get("chat_id") and x.get("ts") and x.get("ts") < (m.get("ts") or "")), key=lambda x: x["ts"])[-limit:] out = [] for x in same: out.append(f"{x.get('first_name') or x.get('username') or '?'}: " f"{(x.get('text') or '[' + (x.get('kind') or '?') + ']')[:300]}") r = x.get("reply_text") or "" if x.get("addressed") and r and not r.startswith("(сводный"): out.append(f"Cody (bot): {r[:200]}") return out def model_verdict(m: dict, inbox: dict): """Серая зона: P(адресовано боту) от decision-модели или None.""" from . import decision hist = _history(m, inbox) who = m.get("first_name") or m.get("username") or "?" people = sorted({x.split(":", 1)[0] for x in hist if not x.startswith("Cody (bot)")} - {who}) crit = {"bot": "Cody (Коди), the AI assistant bot: the message speaks TO Cody — " "asks Cody to answer, explain or do something"} for nm in people[:6]: crit["p_" + nm] = f"{nm} (a human in the chat)" crit["group"] = ("the group in general / nobody specific (greetings, remarks, " "sharing links, thinking aloud)") rt = (f" [in reply to: {(m.get('reply_to_text') or m.get('reply_to_desc') or '')[:150]}]" if m.get("reply_to_message_id") else "") state = (f"Group chat '{m.get('chat_title') or ''}': humans talk to each other; Cody " "(@juno42bot, called «Коди» in Russian) is an AI assistant bot that must " "speak ONLY when addressed. Talking ABOUT Cody in the third person " "(«Коди должен…», «он Коди», «Коди стал…») is people discussing the bot " "with each other — NOT addressing Cody.\n" "Recent messages:\n" + "\n".join(hist) + f"\nLAST message — {who}{rt}: " f"{(m.get('text') or '[' + (m.get('kind') or '?') + ']')[:600]}") r = decision.choose("addressing", crit, state, "Who is the LAST message addressed to?") if not r: return None # в shadow решение пишется, но вердикт всё равно нужен — порог по P(бот) return float(r["probabilities"].get("bot", 0.0)) def _prior(m: dict, inbox: dict, limit: int = 8) -> list: """Реплики того же чата до сообщения (для признаков ядра).""" same = sorted((x for x in inbox.values() if x.get("chat_id") == m.get("chat_id") and x.get("ts") and x.get("ts") < (m.get("ts") or "")), key=lambda x: x["ts"]) return same[-limit:] def triage(model: dict, log=None) -> int: """Вердикт по всем групповым входящим без него. Возвращает число вердиктов.""" inbox = (model or {}).get("inbox") or {} todo = [m for m in inbox.values() if needs_triage(m)] if not todo: return 0 with _LOCK: # перечитать: параллельный уровень мог уже вынести вердикт inbox = (es.run_projections().get("inbox") or {}) todo = [m for m in inbox.values() if needs_triage(m)] bot_id = None try: from . import tg_inbox bot_id = tg_inbox.bot_identity().get("id") except Exception: pass n = 0 for m in sorted(todo, key=lambda x: x.get("ts") or ""): mid = f"m-{m.get('update_id')}" ok, rule = rule_verdict(m, bot_id) p = None if ok is None: # ядро Коди (harness/kodi_core.py): в on — уверенный ученик на # знакомой ситуации решает сам, учитель не зовётся; в shadow — # ученик только пишет прогноз рядом с решением учителя prior = _prior(m, inbox) core_rec = None try: from . import kodi_core if kodi_core.mode() == "on": core_rec = kodi_core.shadow(m, prior, None, final=False) except Exception: core_rec = None if core_rec and core_rec.get("route") == "student": p = core_rec["p_student"] ok, rule = p >= _min_p(), "core" else: p = model_verdict(m, inbox) ok, rule = ((p >= _min_p(), "decision") if p is not None else (False, "no_decision")) try: from . import kodi_core if kodi_core.mode() != "off": kodi_core.shadow(m, prior, p, final=True) except Exception: pass # ученик — бонус, решение уже есть if ok: es.append_event("inbox", mid, "MessageTriaged", {"addressed": True, "rule": rule, "p": p}) m["addressed"] = True # для истории следующих в этом же проходе try: from . import tg_inbox tg_inbox.mark_seen(mid) except Exception: pass else: es.append_event("inbox", mid, "MessageIgnored", {"reason": "not_addressed", "rule": rule, "p": p, "chat_id": m.get("chat_id"), "user_id": m.get("user_id")}) m["addressed"] = False if log: log(f"addressing: {mid} {'→ боту' if ok else '— не боту'} ({rule}" + (f", p={p:.2f}" if p is not None else "") + ")") n += 1 return n