simbiot-harness/harness/addressing.py
Simbiot Harness c1d5a11bfb ядро Коди: обучаемый ученик адресации (цикл как в systemone-bench), теневой режим
harness/kodi_core.py — цикл engine42/router42 бенча на решениях Коди,
первая голова addressing (отвечать ли в группе):
- опыт — подписанные события core: CoreLabel (учитель по истории;
  человек — кнопки «Это было мне?» в личку, ≤5/сут), CoreFeedback
  (неявно из чата: ответил, а следом «не тебе/продолжает» → 0; промолчал,
  а автор тут же «@juno42bot»/«Коди?» → 1), живые вердикты учителя;
  веса: учитель 1, обратная связь 3, человек 5;
- признаки: nomic-embed-text сообщения (локально, кеш) + 8 флагов;
  контекстный эмбеддинг убран — шумел (5 фолдов 0.822 → 0.845 без него);
- ученик: логрегрессия (numpy, Adam, L2) — эмбеддинги без поразмерной
  стандартизации (раздувала шум на сотнях примеров); kNN-память новизны,
  пороги новизны калибруются на холдауте (у nomic она 0.03–0.09);
- роутер: знакомо и |p−0.5| ≥ 0.4 → ученик; незнакомо и учитель
  сомневается → вопрос Alex; иначе учитель. SIMBIOT_CORE_ADDRESSING=
  shadow (дефолт: решает учитель, ученик пишет CorePrediction) | on | off;
- тренировка в дневной тик: временной холдаут, претендент против
  чемпиона по log-loss, CoreModelPromoted с sha256; подмена файла →
  ученика нет; cli.py core status|train|backfill|feedback|rollback.
Репетиция на истории правления (259 примеров): холдаут — совпадение с
учителем 73%, уверенная доля 4% при 100% совпадения; 5 фолдов при
CONF 0.4 — покрытие 25%, совпадение 95%. До включения нужны метки.
Поллер: callback_query (allowed_updates явно). numpy — в requirements.
Тест: test_kodi_core 28 PASS; полный набор — без регрессий.

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

201 lines
10 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 _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(), "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