simbiot-harness/harness/addressing.py
Simbiot Harness 3ff504143a адресация: группы без chat_type по chat_id<0; облако decision — таймаут 30 с, кулдаун 60 с
- старые записи инбокса без chat_type (до t-261013113629) считались
  личкой и обходили фильтр адресации — группа теперь и по chat_id < 0;
- утренний холодный старт clef:27b в облаке (> 15 с) взводил
  предохранитель на 5 минут → ответы от CPU-nimble по 10 с: облако
  ждём до 30 с, его кулдаун 60 с (локальной — по-прежнему 5 минут).
Реплей реальной истории «Правления» (62 сообщения, бот отвечает только
на адресованное, облако ~0.66 с): приветствия и переписка людей между
собой отфильтрованы; разговор Alex с ботом без упоминания распознан
по контексту; спорные — 2–3 в каждую сторону, «Коди»/@/reply — всегда.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:13:35 +00:00

162 lines
8 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 — адресация в группах: будить ли 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():
names = os.environ.get("SIMBIOT_BOT_NAMES", "коди,cody,juno,джуно")
alts = "|".join(re.escape(n.strip()) for n in names.split(",") if n.strip())
return re.compile(rf"(?<![\w@])({alts})(?!\w)", 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 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) is an AI assistant bot that must speak ONLY when addressed.\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 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:
p = model_verdict(m, inbox)
ok, rule = ((p >= _min_p(), "decision") if p is not None
else (False, "no_decision"))
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