simbiot-harness/harness/addressing.py
Simbiot Harness 1745e6c1c8 адресация: бот давно молчит в чате — строже порог и подсказка модели («тебе» — другому человеку)
Кейсы «Правления» 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>
2026-10-17 18:48:06 +00:00

250 lines
13 KiB
Python
Raw Permalink 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)
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