simbiot-harness/harness/addressing.py
Simbiot Harness 16d2b438bd адресация: имя бота — только звательное обращение; разговор О Коди — не повод отвечать
Кейс 2026-10-17 07:40: фильтр отсёк «Блять)»/«Продолжает)», но правило
«имя в тексте = обращение» ответило на «Так теперь Коди должен отвечать
когда его тегают…» и «Ещё и забыл что он Коди)» — люди обсуждали бота.
- детерминированно — только звательное: «Коди, …», «Коди!», «…, Коди»;
  имя посреди фразы — серая зона;
- промпт decision-модели: «Коди» = Cody, речь о нём в третьем лице —
  не обращение. Пробы: 8/8 (о боте ≤ 0.14, к боту ≥ 0.96); реплей
  истории правления — оба утренних ложных ответа ушли (0.40/0.13),
  разговор Alex с ботом без тега по-прежнему распознаётся;
- мандат мин-уровня: «Коди» в чате — это ты (бот писал о себе в
  третьем лице: «Коди … на вашей стороне»).
Тест: test_addressing 31 PASS; полный набор — без регрессий.

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

172 lines
8.9 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():
"""Обращение по имени — только звательное, с пунктуацией: «Коди, …»,
«Коди!», «…, Коди», просто «Коди». Имя посреди фразы («Коди должен
отвечать…», «он Коди») — разговор О боте, его решает модель (кейс
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 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