Кейсы «Правления» 17.10: «Ща обновлю тебе до премиум» (Alex — Vlad'у, 18:40, P=0.68) и «Так проверь пока опен роутер» (Alex — Vlad'у, 14:56, P=0.62) — бот молчал 2 и 6 часов, но «тебе/проверь» перевесило, и Коди встрял («О, спасибо! 🙏…», «Проверил OpenRouter ✅…»). - bot_idle_minutes: молчание бота в чате по журналу исходящих/ответам; - бот писал ≤ 10 мин назад — порог 0.5 и прежний промпт (подсказка «люди ещё говорят с Коди» завышала P — пробовал, откатил); - бот молчит дольше — порог 0.6 (SIMBIOT_ADDRESSING_MIN_P_IDLE) и в контексте модели: «Коди не участвует в текущем разговоре, «ты/тебе» без имени — скорее другому человеку». Реплей 111 спорных решений правления за 17.10: оба встревания ушли (P 0.68→0.18, 0.62→0.31), обращения к молчавшему боту сохранились («Можешь зайти на клоуд…» 0.77, «Чекни опен роутер генерацию…» 0.80). Тест: test_addressing 35 PASS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
250 lines
13 KiB
Python
250 lines
13 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():
|
||
"""Обращение по имени — только звательное, с пунктуацией: «Коди, …»,
|
||
«Коди!», «…, Коди», просто «Коди». Имя посреди фразы («Коди должен
|
||
отвечать…», «он Коди») — разговор О боте, его решает модель (кейс
|
||
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)
|
||
|
||
|
||
ACTIVE_MIN = 10 # бот писал в чат не позже N минут назад — с ним идёт разговор
|
||
|
||
|
||
def bot_idle_minutes(m: dict):
|
||
"""Сколько минут бот молчал в этом чате до сообщения (None — не писал вовсе).
|
||
|
||
Кейс 2026-10-17 18:40: «Ща обновлю тебе до премиум» (Alex — Vlad'у) →
|
||
учитель дал P(бот)=0.68 при пороге 0.5, хотя бот молчал в группе 2 часа:
|
||
«тебе» без разговора с ботом — почти всегда другому человеку."""
|
||
import sqlite3
|
||
from datetime import datetime
|
||
chat, ts = m.get("chat_id"), m.get("ts") or ""
|
||
try:
|
||
db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30)
|
||
try:
|
||
row = db.execute(
|
||
"SELECT MAX(timestamp) FROM events WHERE timestamp < ? AND ("
|
||
"(event_type='OutboundMessage' AND json_extract(data,'$.chat_id')=?) OR "
|
||
"(event_type IN ('ReplySent','FollowupSent') AND json_extract(data,'$.to_chat')=?))",
|
||
(ts or "9999", chat, chat)).fetchone()
|
||
finally:
|
||
db.close()
|
||
if not row or not row[0]:
|
||
return None
|
||
last = datetime.fromisoformat(row[0])
|
||
now = datetime.fromisoformat(ts) if ts else datetime.now(last.tzinfo)
|
||
return max(0.0, (now - last).total_seconds() / 60)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _min_p_for(idle) -> float:
|
||
"""Порог P(бот): в разговоре с ботом — базовый, бот давно молчит — строже."""
|
||
if idle is not None and idle <= ACTIVE_MIN:
|
||
return _min_p()
|
||
try:
|
||
return float(os.environ.get("SIMBIOT_ADDRESSING_MIN_P_IDLE", "0.6"))
|
||
except ValueError:
|
||
return 0.6
|
||
|
||
|
||
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 "")
|
||
idle = bot_idle_minutes(m)
|
||
# подсказка — только когда бот давно молчит: в активном диалоге «люди, возможно,
|
||
# говорят с Коди» завышало P (реплей 17.10: вставленный текст от Claude → «боту»)
|
||
spoke = ("" if idle is not None and idle <= ACTIVE_MIN else
|
||
("Cody has not written in this chat yet; " if idle is None else
|
||
f"Cody last wrote in this chat {int(idle)} minutes ago; ")
|
||
+ "Cody is NOT part of the current conversation, so «ты/тебе» without "
|
||
"Cody's name most likely means another human.")
|
||
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. " + spoke + "\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_for(bot_idle_minutes(m)), "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
|