адресация: группы без chat_type по chat_id<0; облако decision — таймаут 30 с, кулдаун 60 с

- старые записи инбокса без chat_type (до t-261013113629) считались
  личкой и обходили фильтр адресации — группа теперь и по chat_id < 0;
- утренний холодный старт clef:27b в облаке (> 15 с) взводил
  предохранитель на 5 минут → ответы от CPU-nimble по 10 с: облако
  ждём до 30 с, его кулдаун 60 с (локальной — по-прежнему 5 минут).
Реплей реальной истории «Правления» (62 сообщения, бот отвечает только
на адресованное, облако ~0.66 с): приветствия и переписка людей между
собой отфильтрованы; разговор Alex с ботом без упоминания распознан
по контексту; спорные — 2–3 в каждую сторону, «Коди»/@/reply — всегда.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simbiot Harness 2026-10-17 07:13:35 +00:00
parent 9d13e59742
commit 3ff504143a
5 changed files with 24 additions and 7 deletions

View file

@ -356,7 +356,7 @@ nimble — арбитр там, где механика слепа; недост
off — тесты герметичны), точечно `SIMBIOT_DECISION_<TASKS|TOPIC|GATE>`, off — тесты герметичны), точечно `SIMBIOT_DECISION_<TASKS|TOPIC|GATE>`,
`SIMBIOT_DECISION_API_KEY` (есть → основной = облако clef:27b, нет → `SIMBIOT_DECISION_API_KEY` (есть → основной = облако clef:27b, нет →
локальная nimble без запасного), `SIMBIOT_DECISION_URL`, локальная nimble без запасного), `SIMBIOT_DECISION_URL`,
`SIMBIOT_DECISION_MODEL`, `SIMBIOT_DECISION_TIMEOUT` (облако 15 с / `SIMBIOT_DECISION_MODEL`, `SIMBIOT_DECISION_TIMEOUT` (облако 30 с, предохранитель 60 с /
локально 90 с), `SIMBIOT_DECISION_FALLBACK=on|off`, локально 90 с), `SIMBIOT_DECISION_FALLBACK=on|off`,
`SIMBIOT_DECISION_FALLBACK_URL|MODEL|TIMEOUT` (локальная nimble, 90 с), `SIMBIOT_DECISION_GATE_MIN_P` (0.35), `SIMBIOT_DECISION_FALLBACK_URL|MODEL|TIMEOUT` (локальная nimble, 90 с), `SIMBIOT_DECISION_GATE_MIN_P` (0.35),
`SIMBIOT_DECISION_KEEP_ALIVE` (2h). Глобальный shadow — потолок для всех. `SIMBIOT_DECISION_KEEP_ALIVE` (2h). Глобальный shadow — потолок для всех.

View file

@ -31,6 +31,15 @@ GROUP_TYPES = ("group", "supergroup")
_LOCK = threading.Lock() _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(): def _names_re():
names = os.environ.get("SIMBIOT_BOT_NAMES", "коди,cody,juno,джуно") names = os.environ.get("SIMBIOT_BOT_NAMES", "коди,cody,juno,джуно")
alts = "|".join(re.escape(n.strip()) for n in names.split(",") if n.strip()) alts = "|".join(re.escape(n.strip()) for n in names.split(",") if n.strip())
@ -46,13 +55,13 @@ def _min_p() -> float:
def needs_triage(m: dict) -> bool: def needs_triage(m: dict) -> bool:
"""Групповое входящее без вердикта (и не отвеченное/не проигнорированное).""" """Групповое входящее без вердикта (и не отвеченное/не проигнорированное)."""
return (m.get("chat_type") in GROUP_TYPES and "addressed" not in m return (is_group(m) and "addressed" not in m
and not m.get("replied") and not m.get("ignored")) and not m.get("replied") and not m.get("ignored"))
def rule_verdict(m: dict, bot_id=None): def rule_verdict(m: dict, bot_id=None):
"""Детерминированная часть: (True|False, правило) или (None, None) — серая зона.""" """Детерминированная часть: (True|False, правило) или (None, None) — серая зона."""
if m.get("chat_type") not in GROUP_TYPES: if not is_group(m):
return True, "private" return True, "private"
if m.get("mentions_bot"): if m.get("mentions_bot"):
return True, "mention" return True, "mention"

View file

@ -50,6 +50,9 @@ MAX_OPTIONS = 9 # шортлист stage2 строго ≤ 9 (бенч:
CACHE_TTL = 3600.0 # ответы детерминированы — час держим CACHE_TTL = 3600.0 # ответы детерминированы — час держим
CACHE_MAX = 256 CACHE_MAX = 256
BREAKER_COOLDOWN = 300.0 # после сбоя молчим 5 минут, не тормозим тики таймаутами BREAKER_COOLDOWN = 300.0 # после сбоя молчим 5 минут, не тормозим тики таймаутами
# облако: холодный старт clef после простоя бывает > 15 с (утро 2026-10-17:
# один таймаут → 5 минут ответов от CPU-nimble по 10 с) — короткий кулдаун
CLOUD_BREAKER_COOLDOWN = 60.0
_LOCK = threading.Lock() _LOCK = threading.Lock()
_CACHE = {} # sha256(вопросы+состояние) → (ts, answers, model) _CACHE = {} # sha256(вопросы+состояние) → (ts, answers, model)
@ -97,7 +100,7 @@ def backends() -> list:
primary = {"name": "primary", "key": key, primary = {"name": "primary", "key": key,
"url": _cfg("SIMBIOT_DECISION_URL", CLOUD_URL), "url": _cfg("SIMBIOT_DECISION_URL", CLOUD_URL),
"model": _cfg("SIMBIOT_DECISION_MODEL", "clef:27b"), "model": _cfg("SIMBIOT_DECISION_MODEL", "clef:27b"),
"timeout": _float("SIMBIOT_DECISION_TIMEOUT", 15.0)} "timeout": _float("SIMBIOT_DECISION_TIMEOUT", 30.0)}
else: else:
primary = {"name": "primary", "key": "", primary = {"name": "primary", "key": "",
"url": _cfg("SIMBIOT_DECISION_URL", LOCAL_URL), "url": _cfg("SIMBIOT_DECISION_URL", LOCAL_URL),
@ -192,8 +195,8 @@ def ask(questions: dict, state: str):
except (urllib.error.URLError, OSError, ValueError, TimeoutError) as e: except (urllib.error.URLError, OSError, ValueError, TimeoutError) as e:
err = f"{e.__class__.__name__}: {str(e)[:160]}" err = f"{e.__class__.__name__}: {str(e)[:160]}"
with _LOCK: with _LOCK:
_BREAKERS[be["name"]] = {"until": time.time() + BREAKER_COOLDOWN, cool = CLOUD_BREAKER_COOLDOWN if be["key"] else BREAKER_COOLDOWN
"error": err} _BREAKERS[be["name"]] = {"until": time.time() + cool, "error": err}
errors.append(f"{be['name']} {be['model']}: {err} " errors.append(f"{be['name']} {be['model']}: {err} "
f"({int((time.time() - t1) * 1000)} мс)") f"({int((time.time() - t1) * 1000)} мс)")
continue continue

View file

@ -254,7 +254,8 @@ def _is_pending(m: dict) -> bool:
return False return False
# группа: только адресованное боту (harness/addressing.py, аудит 2026-10-17); # группа: только адресованное боту (harness/addressing.py, аудит 2026-10-17);
# без вердикта триажа — ещё не входящее для LLM # без вердикта триажа — ещё не входящее для LLM
if m.get("chat_type") in ("group", "supergroup") and not m.get("addressed"): from .addressing import is_group
if is_group(m) and not m.get("addressed"):
return False return False
if m.get("text"): if m.get("text"):
return True return True

View file

@ -53,6 +53,10 @@ check("слово-подстрока не ловится («кодировка»
check("reply человеку → нет", rv(g("согласен", reply_to_user_id=156156021)) check("reply человеку → нет", rv(g("согласен", reply_to_user_id=156156021))
== (False, "reply_to_human")) == (False, "reply_to_human"))
check("просто реплика → серая зона", rv(g("Доброе утро!")) == (None, None)) 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 = [] CALLS = []