simbiot-harness/tests/test_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

171 lines
9.7 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.

# -*- coding: utf-8 -*-
"""Адресация в группах (harness/addressing.py, аудит 2026-10-17) — hermetic.
Запуск: ./venv/bin/python tests/test_addressing.py
Проверяет: правила (личка, @бот, reply боту, имя «Коди», reply человеку);
серая зона — через decision-модель (порог), модель недоступна → молчать;
триаж пишет MessageTriaged/MessageIgnored(not_addressed), неадресованное не
попадает в _pending_inbox и не даёт сигнала гейту; личка не трогается;
история для модели — без ответов бота на неадресованное; повторный триаж
не дублирует; receive_message ловит @упоминание и reply на бота.
"""
import os
import sys
import tempfile
sys.path.insert(0, "/root/simbiot-harness")
os.environ["SIMBIOT_DB"] = os.path.join(tempfile.mkdtemp(prefix="simbiot_addr_"), "harness.db")
for _v in ("SIMBIOT_BOT_NAMES", "SIMBIOT_ADDRESSING_MIN_P", "SIMBIOT_DECISION"):
os.environ.pop(_v, None)
from harness import addressing, decision, es, scheduler, tg_inbox
from harness.levels import LEVELS
es.init_db()
fails = []
def check(name, cond, extra=""):
print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "")
if not cond:
fails.append(name)
BOT_ID = 7245454391
tg_inbox._BOT.update(id=BOT_ID, username="juno42bot")
tg_inbox.mark_seen = lambda mid, **k: SEEN.append(mid) or True
SEEN = []
G = -1004402779881
def g(text="", **kw):
return {"chat_type": "supergroup", "chat_id": G, "text": text, **kw}
rv = lambda m: addressing.rule_verdict(m, BOT_ID)
check("личка → всегда", rv({"chat_type": "private", "text": "привет"}) == (True, "private"))
check("@juno42bot → да", rv(g("глянь", mentions_bot=True))[0] is True)
check("reply на бота → да", rv(g("а это?", reply_to_user_id=BOT_ID, reply_to_is_bot=True))[0] is True)
check("имя «Коди» → да", rv(g("Коди, сделай отчёт"))[1] == "name")
check("«cody» латиницей → да", rv(g("cody, pls"))[0] is True)
check("слово-подстрока не ловится («кодировка»)", rv(g("кодировка сломалась"))[0] is None)
check("«сделай, Коди» → да", rv(g("сделай сводку, Коди"))[0] is True)
check("«Коди!» → да", rv(g("Коди!"))[0] is True)
check("разговор О боте → серая зона, не правило",
rv(g("Так теперь Коди должен отвечать когда его тегают")) == (None, None)
and rv(g("Ещё и забыл что он Коди)")) == (None, None)
and rv(g("Коди сейчас стал молчаливее")) == (None, None))
check("reply человеку → нет", rv(g("согласен", reply_to_user_id=156156021))
== (False, "reply_to_human"))
check("просто реплика → серая зона", rv(g("Доброе утро!")) == (None, None))
check("старая запись без chat_type, chat_id<0 → группа",
addressing.is_group({"chat_id": G}) and rv({"chat_id": G, "text": "эй"}) == (None, None))
check("без chat_type, chat_id>0 → личка", not addressing.is_group({"chat_id": 7569223131}))
check("портал-чат (строка) → не группа", not addressing.is_group({"chat_id": "portal:alex"}))
# ── триаж в сторе ──
CALLS = []
def fake_choose(purpose, options, state, instructions):
CALLS.append(state)
if FAKE["down"]:
return None
p = FAKE["p"]
return {"choice": "bot" if p >= .5 else "group", "applied": True, "confidence": 0.5,
"probabilities": {"bot": p, "group": 1 - p}}
FAKE = {"p": 0.9, "down": False}
decision.choose = fake_choose
def recv(uid, text, chat_type="supergroup", **kw):
es.append_event("inbox", f"m-{uid}", "MessageReceived", {
"update_id": uid, "chat_id": G if chat_type != "private" else 7569223131,
"chat_type": chat_type, "first_name": kw.pop("who", "Vlad"), "text": text,
"kind": "text", **kw})
recv(1, "Доброе утро!") # серая зона, p низкий
recv(2, "Коди, что по задачам?", who="Alex") # имя
recv(3, "ты я вижу хочешь подключить", reply_to_user_id=156156021, who="Alex")
recv(4, "привет из лички", chat_type="private", who="Alex")
FAKE["p"] = 0.1
n = addressing.triage(es.run_projections())
model = es.run_projections()
ib = model["inbox"]
check("триаж: 3 групповых вердикта, личка не трогается", n == 3 and "addressed" not in ib["m-4"], n)
check("«Доброе утро» → проигнорировано (модель)", ib["m-1"].get("ignored")
and ib["m-1"].get("ignore_reason") == "not_addressed")
check("«Коди, …» → адресовано без модели", ib["m-2"].get("addressed") is True
and ib["m-2"]["triage"]["rule"] == "name")
check("reply человеку → проигнорировано без модели", ib["m-3"].get("ignored"))
check("модель спрошена только по серой зоне", len(CALLS) == 1, len(CALLS))
check("👀 только на адресованное", SEEN == ["m-2"], SEEN)
pend = {m["update_id"] for m in scheduler._pending_inbox(model)}
check("в инбокс LLM: адресованное + личка", pend == {2, 4}, pend)
check("повторный триаж — без вердиктов", addressing.triage(model) == 0)
# порог и недоступная модель
recv(5, "посмотри отчёт пожалуйста")
FAKE["p"] = 0.8
addressing.triage(es.run_projections())
check("серая зона p≥0.5 → адресовано", es.run_projections()["inbox"]["m-5"].get("addressed") is True)
recv(6, "ну что там")
FAKE["down"] = True
addressing.triage(es.run_projections())
m6 = es.run_projections()["inbox"]["m-6"]
check("модель недоступна → молчим", m6.get("ignored") and m6.get("ignore_reason") == "not_addressed")
FAKE["down"] = False
# гейт: только неадресованное в группе → тишина
only = {"inbox": {"m-1": {**ib["m-1"]}}, "tasks": {}}
check("гейт: неадресованное не даёт сигнала",
not any(s[1] == "unreplied_inbox" for s in scheduler.collect_signals(LEVELS["min"], only)))
untriaged = {"inbox": {"m-9": {"update_id": 9, "chat_type": "supergroup", "text": "эй"}}, "tasks": {}}
check("без вердикта групповое не будит LLM", not scheduler._pending_inbox(untriaged))
# история для модели: ответ бота на НЕадресованное не попадает
es.append_event("inbox", "m-1", "ReplySent", {"text": "Доброе утро 🙂 (встрял)"})
es.append_event("inbox", "m-2", "ReplySent", {"text": "По задачам: 3 в работе"})
recv(7, "а что с дедлайном")
CALLS.clear()
addressing.triage(es.run_projections())
st = CALLS[-1] if CALLS else ""
check("история: ответ на адресованное есть", "По задачам: 3 в работе" in st, st[:300])
check("история: «встревание» вырезано", "встрял" not in st, st[:300])
# приём: упоминание и reply на бота
tg_inbox.save_document = lambda msg: None
mid = tg_inbox.receive_message({"update_id": 100, "message": {
"message_id": 1, "date": 1, "chat": {"id": G, "type": "supergroup", "title": "Правление"},
"from": {"id": 1, "first_name": "Vlad"}, "text": "@juno42bot глянь",
"entities": [{"type": "mention", "offset": 0, "length": 10}]}})
mid2 = tg_inbox.receive_message({"update_id": 101, "message": {
"message_id": 2, "date": 1, "chat": {"id": G, "type": "supergroup", "title": "Правление"},
"from": {"id": 1, "first_name": "Vlad"}, "text": "а так?",
"reply_to_message": {"message_id": 1, "text": "ответ бота",
"from": {"id": BOT_ID, "is_bot": True, "first_name": "Cody"}}}})
ib = es.run_projections()["inbox"]
check("приём: @упоминание бота", ib[mid].get("mentions_bot") is True)
check("приём: reply на бота", ib[mid2].get("reply_to_is_bot") is True
and ib[mid2].get("reply_to_user_id") == BOT_ID)
check("приём: обычный текст без упоминания", tg_inbox._mentions_bot({"text": "привет"}) is False)
# ── бот молчит давно → строже (кейс 17.10: «Ща обновлю тебе до премиум» Vlad'у) ──
from datetime import datetime, timedelta, timezone
os.environ.pop("SIMBIOT_ADDRESSING_MIN_P_IDLE", None)
check("порог: в разговоре с ботом (≤10 мин) — базовый 0.5", addressing._min_p_for(3) == 0.5)
check("порог: бот молчит давно — 0.6", addressing._min_p_for(120) == 0.6
and addressing._min_p_for(None) == 0.6)
t0 = datetime.now(timezone.utc)
es.append_event("outbound", f"{G}:900", "OutboundMessage", {"chat_id": G, "message_id": 900, "text": "ответ"})
mm = {"chat_id": G, "ts": (t0 + timedelta(minutes=30)).isoformat()}
idle = addressing.bot_idle_minutes(mm)
check("молчание бота считается по журналу исходящих", idle is not None and 29 <= idle <= 31, idle)
check("в другом чате бот не писал → None", addressing.bot_idle_minutes({"chat_id": -5, "ts": mm["ts"]}) is None)
print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)")
sys.exit(1 if fails else 0)