адресация в группах: Коди больше не встревает в чужие разговоры

Кейс «Правления»: бот отвечал на каждое сообщение группы — «Доброе
утро!», реплики Alex для Vlad, ответы Vlad на вопросы Alex: фильтра не
было, любое сообщение разрешённого человека будило минутный LLM.

harness/addressing.py — триаж ДО гейта, без LLM:
- личка/портал/агенты — как раньше;
- группа, детерминированно: @juno42bot, reply на бота, имя (Коди/Cody,
  SIMBIOT_BOT_NAMES) → боту; reply на человека → не боту;
- серая зона — decision-модель, choice «кому адресовано»: бот / человек /
  группа; P(бот) ≥ 0.5 (SIMBIOT_ADDRESSING_MIN_P). В истории — только
  ответы бота на адресованное: «встревания» сбивали модель. Замер на
  истории правления: адресованное 0.93, остальные 14 ≤ 0.22 (noul с
  полной историей давал 4 ошибки). Модель недоступна → молчим.
Вердикт: MessageTriaged{addressed} (+ 👀 только тогда) или
MessageIgnored{not_addressed}. Поллер пишет reply_to_user_id/is_bot и
mentions_bot; 👀 в группах ставит триаж, а не приём.
Тест: test_addressing 25 PASS; полный набор — без регрессий.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simbiot Harness 2026-10-17 07:04:56 +00:00
parent 2b266caab5
commit 9d13e59742
5 changed files with 366 additions and 4 deletions

153
harness/addressing.py Normal file
View file

@ -0,0 +1,153 @@
"""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 _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 (m.get("chat_type") in GROUP_TYPES 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 m.get("chat_type") not in GROUP_TYPES:
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

View file

@ -691,12 +691,20 @@ def _apply_event(model: dict, ev: dict):
"engine": data.get("engine"), "replied": False, "ignored": False, "engine": data.get("engine"), "replied": False, "ignored": False,
"ts": ev["timestamp"], "ts": ev["timestamp"],
} }
elif etype == "MessageTriaged":
# адресация в группе (harness/addressing.py): addressed=True — будить LLM
m = model.setdefault("inbox", {}).get(aid)
if m:
m["addressed"] = bool(data.get("addressed"))
m["triage"] = {"rule": data.get("rule"), "p": data.get("p")}
elif etype == "MessageIgnored": elif etype == "MessageIgnored":
# канал портала (t-261001123121): доставка реплая помечена ReplySent'ом # канал портала (t-261001123121): доставка реплая помечена ReplySent'ом
# (portal ReplySent — признак push'а) — отдельный маркер не нужен # (portal ReplySent — признак push'а) — отдельный маркер не нужен
m = model.setdefault("inbox", {}).get(aid) m = model.setdefault("inbox", {}).get(aid)
if m: if m:
m["ignored"] = True m["ignored"] = True
if data.get("reason"):
m["ignore_reason"] = data.get("reason")
else: else:
model["inbox"][aid] = {"ignored": True, "reason": data.get("reason"), "ts": ev["timestamp"]} model["inbox"][aid] = {"ignored": True, "reason": data.get("reason"), "ts": ev["timestamp"]}
elif etype == "CycleError": elif etype == "CycleError":

View file

@ -252,6 +252,10 @@ def _is_pending(m: dict) -> bool:
from . import tg_commands from . import tg_commands
if m.get("replied") or m.get("ignored") or tg_commands.parse(m.get("text") or ""): if m.get("replied") or m.get("ignored") or tg_commands.parse(m.get("text") or ""):
return False return False
# группа: только адресованное боту (harness/addressing.py, аудит 2026-10-17);
# без вердикта триажа — ещё не входящее для LLM
if m.get("chat_type") in ("group", "supergroup") and not m.get("addressed"):
return False
if m.get("text"): if m.get("text"):
return True return True
if m.get("kind") in ("voice", "audio"): if m.get("kind") in ("voice", "audio"):
@ -917,6 +921,13 @@ def process_level(level_cfg, model: dict, seq: int = None):
# только если есть событие ≥ порога уровня; тихий такт отрабатывает # только если есть событие ≥ порога уровня; тихий такт отрабатывает
# детерминированной механикой (как sec) — без расхода кредитов роутера. # детерминированной механикой (как sec) — без расхода кредитов роутера.
if level_cfg.uses_llm: if level_cfg.uses_llm:
# адресация в группах — ДО гейта: чужой разговор не будит LLM
try:
from . import addressing
if addressing.triage(model, log=_log):
model = es.run_projections()
except Exception as e:
_log(f"addressing: триаж не удался ({e}) — групповые ждут следующего такта")
try: try:
load = os.getloadavg()[0] load = os.getloadavg()[0]
except Exception: except Exception:

View file

@ -258,6 +258,36 @@ def _resolve_reply_target(chat_id, reply_to_message_id: int):
return None return None
_BOT = {}
def bot_identity() -> dict:
"""getMe один раз на процесс: {"id", "username"} (пусто, если TG недоступен)."""
if not _BOT:
try:
_load_env()
r = (_api("getMe", {}, timeout=15) or {}).get("result") or {}
_BOT.update(id=r.get("id"), username=(r.get("username") or "").lower())
except Exception:
return {}
return _BOT
def _mentions_bot(msg: dict) -> bool:
"""@username бота в тексте/подписи или text_mention на бота."""
me = bot_identity()
text = msg.get("text") or msg.get("caption") or ""
for ent in (msg.get("entities") or []) + (msg.get("caption_entities") or []):
if ent.get("type") == "mention" and me.get("username"):
frag = text[ent.get("offset", 0): ent.get("offset", 0) + ent.get("length", 0)]
if frag.lstrip("@").lower() == me["username"]:
return True
if ent.get("type") == "text_mention" and me.get("id") \
and (ent.get("user") or {}).get("id") == me["id"]:
return True
return bool(me.get("username")) and ("@" + me["username"]) in text.lower()
def receive_message(update: dict): def receive_message(update: dict):
"""MessageReceived в стор. Идемпотентно по update_id.""" """MessageReceived в стор. Идемпотентно по update_id."""
msg = update.get("message") or update.get("edited_message") or {} msg = update.get("message") or update.get("edited_message") or {}
@ -358,6 +388,15 @@ def receive_message(update: dict):
"date": data.get("date"), "date": data.get("date"),
} }
data["received_at"] = data.get("date") data["received_at"] = data.get("date")
# адресация в группах (аудит 2026-10-17, «Коди встревает в разговор»):
# кому reply (боту или человеку) и упомянут ли бот — сырьё для
# harness/addressing.py, решающего, будить ли LLM вообще
rfrom = (msg.get("reply_to_message") or {}).get("from") or {}
if rfrom:
data["reply_to_user_id"] = rfrom.get("id")
data["reply_to_is_bot"] = bool(rfrom.get("is_bot"))
data["reply_to_first_name"] = rfrom.get("first_name")
data["mentions_bot"] = _mentions_bot(msg)
es.append_event("inbox", f"m-{update['update_id']}", "MessageReceived", data) es.append_event("inbox", f"m-{update['update_id']}", "MessageReceived", data)
return f"m-{update['update_id']}" return f"m-{update['update_id']}"
@ -629,10 +668,13 @@ def poll_loop():
# Реакция 👀 — Alex сразу ВИДИТ, что сообщение принято и взято в # Реакция 👀 — Alex сразу ВИДИТ, что сообщение принято и взято в
# работу, без «тишины на весь тик» (e-261002202857). # работу, без «тишины на весь тик» (e-261002202857).
# best effort: недоступность TG не мешает приёму/обработке. # best effort: недоступность TG не мешает приёму/обработке.
try: # В группах 👀 ставит триаж адресации (harness/addressing.py) —
mark_seen(mid) # и только на адресованное боту: не метим чужие разговоры.
except Exception as e: if (msg.get("chat") or {}).get("type") not in ("group", "supergroup"):
print(f"[tg] {mid} реакция 👀 не удалась: {e}", flush=True) try:
mark_seen(mid)
except Exception as e:
print(f"[tg] {mid} реакция 👀 не удалась: {e}", flush=True)
# голосовые/аудио/видео-заметки: расшифровка сразу при приёме, # голосовые/аудио/видео-заметки: расшифровка сразу при приёме,
# чтобы минутный уровень уже видел текст (не качает файлы сам — # чтобы минутный уровень уже видел текст (не качает файлы сам —
# таймаут тика мал). Сбой транскрипции не валит поллер. # таймаут тика мал). Сбой транскрипции не валит поллер.

148
tests/test_addressing.py Normal file
View file

@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
"""Адресация в группах (harness/addressing.py, аудит 2026-10-17) — hermetic.
Запуск: ./venv/bin/python tests/test_addressing.py
Проверяет: правила (личка, @бот, reply боту, имя «Коди», reply человеку);
серая зона — через decision-модель (порог), модель недоступна → молчать;
триаж пишет MessageTriaged/MessageIgnored(not_addressed), неадресованное не
попадает в _pending_inbox и не даёт сигнала гейту; личка не трогается;
история для модели — без ответов бота на неадресованное; повторный триаж
не дублирует; receive_message ловит @упоминание и reply на бота.
"""
import os
import sys
import tempfile
sys.path.insert(0, "/root/simbiot-harness")
os.environ["SIMBIOT_DB"] = os.path.join(tempfile.mkdtemp(prefix="simbiot_addr_"), "harness.db")
for _v in ("SIMBIOT_BOT_NAMES", "SIMBIOT_ADDRESSING_MIN_P", "SIMBIOT_DECISION"):
os.environ.pop(_v, None)
from harness import addressing, decision, es, scheduler, tg_inbox
from harness.levels import LEVELS
es.init_db()
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "")
if not cond:
fails.append(name)
BOT_ID = 7245454391
tg_inbox._BOT.update(id=BOT_ID, username="juno42bot")
tg_inbox.mark_seen = lambda mid, **k: SEEN.append(mid) or True
SEEN = []
G = -1004402779881
def g(text="", **kw):
return {"chat_type": "supergroup", "chat_id": G, "text": text, **kw}
rv = lambda m: addressing.rule_verdict(m, BOT_ID)
check("личка → всегда", rv({"chat_type": "private", "text": "привет"}) == (True, "private"))
check("@juno42bot → да", rv(g("глянь", mentions_bot=True))[0] is True)
check("reply на бота → да", rv(g("а это?", reply_to_user_id=BOT_ID, reply_to_is_bot=True))[0] is True)
check("имя «Коди» → да", rv(g("Коди, сделай отчёт"))[1] == "name")
check("«cody» латиницей → да", rv(g("cody pls"))[0] is True)
check("слово-подстрока не ловится («кодировка»)", rv(g("кодировка сломалась"))[0] is None)
check("reply человеку → нет", rv(g("согласен", reply_to_user_id=156156021))
== (False, "reply_to_human"))
check("просто реплика → серая зона", rv(g("Доброе утро!")) == (None, None))
# ── триаж в сторе ──
CALLS = []
def fake_choose(purpose, options, state, instructions):
CALLS.append(state)
if FAKE["down"]:
return None
p = FAKE["p"]
return {"choice": "bot" if p >= .5 else "group", "applied": True, "confidence": 0.5,
"probabilities": {"bot": p, "group": 1 - p}}
FAKE = {"p": 0.9, "down": False}
decision.choose = fake_choose
def recv(uid, text, chat_type="supergroup", **kw):
es.append_event("inbox", f"m-{uid}", "MessageReceived", {
"update_id": uid, "chat_id": G if chat_type != "private" else 7569223131,
"chat_type": chat_type, "first_name": kw.pop("who", "Vlad"), "text": text,
"kind": "text", **kw})
recv(1, "Доброе утро!") # серая зона, p низкий
recv(2, "Коди, что по задачам?", who="Alex") # имя
recv(3, "ты я вижу хочешь подключить", reply_to_user_id=156156021, who="Alex")
recv(4, "привет из лички", chat_type="private", who="Alex")
FAKE["p"] = 0.1
n = addressing.triage(es.run_projections())
model = es.run_projections()
ib = model["inbox"]
check("триаж: 3 групповых вердикта, личка не трогается", n == 3 and "addressed" not in ib["m-4"], n)
check("«Доброе утро» → проигнорировано (модель)", ib["m-1"].get("ignored")
and ib["m-1"].get("ignore_reason") == "not_addressed")
check("«Коди, …» → адресовано без модели", ib["m-2"].get("addressed") is True
and ib["m-2"]["triage"]["rule"] == "name")
check("reply человеку → проигнорировано без модели", ib["m-3"].get("ignored"))
check("модель спрошена только по серой зоне", len(CALLS) == 1, len(CALLS))
check("👀 только на адресованное", SEEN == ["m-2"], SEEN)
pend = {m["update_id"] for m in scheduler._pending_inbox(model)}
check("в инбокс LLM: адресованное + личка", pend == {2, 4}, pend)
check("повторный триаж — без вердиктов", addressing.triage(model) == 0)
# порог и недоступная модель
recv(5, "посмотри отчёт пожалуйста")
FAKE["p"] = 0.8
addressing.triage(es.run_projections())
check("серая зона p≥0.5 → адресовано", es.run_projections()["inbox"]["m-5"].get("addressed") is True)
recv(6, "ну что там")
FAKE["down"] = True
addressing.triage(es.run_projections())
m6 = es.run_projections()["inbox"]["m-6"]
check("модель недоступна → молчим", m6.get("ignored") and m6.get("ignore_reason") == "not_addressed")
FAKE["down"] = False
# гейт: только неадресованное в группе → тишина
only = {"inbox": {"m-1": {**ib["m-1"]}}, "tasks": {}}
check("гейт: неадресованное не даёт сигнала",
not any(s[1] == "unreplied_inbox" for s in scheduler.collect_signals(LEVELS["min"], only)))
untriaged = {"inbox": {"m-9": {"update_id": 9, "chat_type": "supergroup", "text": "эй"}}, "tasks": {}}
check("без вердикта групповое не будит LLM", not scheduler._pending_inbox(untriaged))
# история для модели: ответ бота на НЕадресованное не попадает
es.append_event("inbox", "m-1", "ReplySent", {"text": "Доброе утро 🙂 (встрял)"})
es.append_event("inbox", "m-2", "ReplySent", {"text": "По задачам: 3 в работе"})
recv(7, "а что с дедлайном")
CALLS.clear()
addressing.triage(es.run_projections())
st = CALLS[-1] if CALLS else ""
check("история: ответ на адресованное есть", "По задачам: 3 в работе" in st, st[:300])
check("история: «встревание» вырезано", "встрял" not in st, st[:300])
# приём: упоминание и reply на бота
tg_inbox.save_document = lambda msg: None
mid = tg_inbox.receive_message({"update_id": 100, "message": {
"message_id": 1, "date": 1, "chat": {"id": G, "type": "supergroup", "title": "Правление"},
"from": {"id": 1, "first_name": "Vlad"}, "text": "@juno42bot глянь",
"entities": [{"type": "mention", "offset": 0, "length": 10}]}})
mid2 = tg_inbox.receive_message({"update_id": 101, "message": {
"message_id": 2, "date": 1, "chat": {"id": G, "type": "supergroup", "title": "Правление"},
"from": {"id": 1, "first_name": "Vlad"}, "text": "а так?",
"reply_to_message": {"message_id": 1, "text": "ответ бота",
"from": {"id": BOT_ID, "is_bot": True, "first_name": "Cody"}}}})
ib = es.run_projections()["inbox"]
check("приём: @упоминание бота", ib[mid].get("mentions_bot") is True)
check("приём: reply на бота", ib[mid2].get("reply_to_is_bot") is True
and ib[mid2].get("reply_to_user_id") == BOT_ID)
check("приём: обычный текст без упоминания", tg_inbox._mentions_bot({"text": "привет"}) is False)
print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)")
sys.exit(1 if fails else 0)