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>
201 lines
10 KiB
Python
201 lines
10 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)
|
||
|
||
|
||
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
|