- старые записи инбокса без 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>
162 lines
8 KiB
Python
162 lines
8 KiB
Python
"""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
|