- фото/документ/видео без подписи выпадали из триажа (_pending_inbox и build_context пускали только текст/голос): висели до 23 ч, все 4 неотвеченных за неделю — такие. Один фильтр _is_pending, медиа с тегом и путём файла; окно свежести SIMBIOT_MEDIA_PENDING_H=6 — старые не будят LLM задним числом; стикер без текста — не повод. - MinNudge: мин-тик через 3 с тишины после MessageReceived/Transcribed, не ждём границы минуты (было: медиана 36 с до старта LLM, p90 93 с при 24 с самой модели). Слот следующего такта, граница его пропускает; SIMBIOT_MIN_NUDGE=0 — выключить. - сек-уровень: CycleTicked+ReflexObserved каждую секунду = 1.35 млн подписанных событий/нед (98% стора 1.37 ГБ). Теперь heartbeat раз в 60 с + такты с событием (алерт, команды, каналы); «reflex ok» не спамит журнал. - 6 тестов писали в боевой стор под SIMBIOT_HOME тика (TTFTWatchdogRetry с бюджетом 0.5 с в проде — это они): изоляция SIMBIOT_DB. Тест: tests/test_tick_latency_noise.py 20 PASS; полный набор — без регрессий. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
6.5 KiB
Python
124 lines
6.5 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Аудит 2026-10-16: медиа без подписи в триаже, тик по входящему, тишина сек-уровня.
|
||
|
||
Запуск: ./venv/bin/python tests/test_tick_latency_noise.py (hermetic, свой стор)
|
||
|
||
Проверяет: фото/документ/видео без подписи — ожидают ответа (свежие ≤ 6 ч),
|
||
старые — нет; стикер без текста — нет; команда — нет; контекст тика
|
||
показывает тег медиа с путём файла; MinNudge срабатывает после debounce на
|
||
MessageReceived/MessageTranscribed, игнорирует прочие события, выключается
|
||
env; сек-уровень пишет CycleTicked/ReflexObserved только heartbeat'ом и в
|
||
тактах с событием (команды/алерт), а тихие такты — без записи в стор.
|
||
"""
|
||
import json
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
sys.path.insert(0, "/root/simbiot-harness")
|
||
os.environ["SIMBIOT_DB"] = os.path.join(tempfile.mkdtemp(prefix="simbiot_latnoise_"), "harness.db")
|
||
for _v in ("SIMBIOT_MIN_NUDGE", "SIMBIOT_MIN_NUDGE_DEBOUNCE", "SIMBIOT_MEDIA_PENDING_H",
|
||
"SIMBIOT_SEC_HEARTBEAT", "SIMBIOT_DECISION"):
|
||
os.environ.pop(_v, None)
|
||
|
||
from harness import es, scheduler
|
||
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)
|
||
|
||
|
||
now = datetime.now(timezone.utc)
|
||
|
||
|
||
def msg(uid, kind="text", text="", age_h=0.0, **kw):
|
||
return {"update_id": uid, "kind": kind, "text": text, "replied": False, "ignored": False,
|
||
"ts": (now - timedelta(hours=age_h)).isoformat(), "chat_id": 1,
|
||
"first_name": "Alex", **kw}
|
||
|
||
|
||
# ── 1. медиа без подписи в триаже ──
|
||
inbox = {f"m-{m['update_id']}": m for m in [
|
||
msg(1, "photo", age_h=0.1, file_path="inbox/1.jpg"),
|
||
msg(2, "document", age_h=1, file_name="dogovor.pdf", file_path="inbox/dogovor.pdf"),
|
||
msg(3, "video", age_h=0.2),
|
||
msg(4, "photo", age_h=30), # старое, выпало до фикса
|
||
msg(5, "sticker", age_h=0.1),
|
||
msg(6, "text", "/статус", age_h=0.1),
|
||
msg(7, "text", "привет", age_h=50), # текст — как раньше, без окна свежести
|
||
msg(8, "voice", age_h=40), # голосовое — как раньше
|
||
msg(9, "photo", age_h=0.1, replied=True),
|
||
]}
|
||
ids = {m["update_id"] for m in scheduler._pending_inbox({"inbox": inbox})}
|
||
check("фото/документ/видео без подписи — ждут ответа", {1, 2, 3} <= ids, ids)
|
||
check("старое медиа (>6ч) — не будит LLM", 4 not in ids, ids)
|
||
check("стикер без текста — не ждёт", 5 not in ids, ids)
|
||
check("команда — не для LLM", 6 not in ids, ids)
|
||
check("текст и голосовое — как раньше", {7, 8} <= ids, ids)
|
||
check("отвеченное — нет", 9 not in ids, ids)
|
||
os.environ["SIMBIOT_MEDIA_PENDING_H"] = "48"
|
||
check("окно свежести настраивается env", 4 in {m["update_id"] for m in scheduler._pending_inbox({"inbox": inbox})})
|
||
os.environ.pop("SIMBIOT_MEDIA_PENDING_H")
|
||
sig = scheduler.collect_signals(LEVELS["min"], {"inbox": {"m-1": inbox["m-1"]}, "tasks": {}})
|
||
check("фото без подписи → critical-сигнал мин-уровня", any(s[1] == "unreplied_inbox" for s in sig), sig)
|
||
ctx = scheduler.build_context(LEVELS["min"], {"inbox": {"m-2": inbox["m-2"]}, "tasks": {},
|
||
"escalations": {}, "policy": {}}, batch=[])
|
||
check("контекст: тег документа с путём и «без подписи»",
|
||
"[document, файл: inbox/dogovor.pdf, без подписи]" in ctx, ctx[:400])
|
||
|
||
# ── 2. MinNudge ──
|
||
os.environ["SIMBIOT_MIN_NUDGE_DEBOUNCE"] = "0.3"
|
||
n = scheduler.MinNudge()
|
||
n.poll() # первый проход — только запоминает хвост
|
||
check("nudge: на старте не срабатывает", not n.due())
|
||
es.append_event("cycle", "noise:1", "DigestWritten", {"level": "min"})
|
||
n.poll()
|
||
check("nudge: прочие события не будят", n.pending_since is None)
|
||
es.append_event("tg", "m-777", "MessageReceived", {"update_id": 777, "text": "эй"})
|
||
n.poll()
|
||
check("nudge: входящее замечено, но debounce", n.pending_since is not None and not n.due())
|
||
time.sleep(0.35)
|
||
check("nudge: после debounce — пора", n.due())
|
||
n.fired()
|
||
check("nudge: fired сбрасывает", not n.due())
|
||
es.append_event("tg", "m-777", "MessageTranscribed", {"text": "расшифровка"})
|
||
n.poll(); time.sleep(0.35)
|
||
check("nudge: расшифровка голосового тоже будит", n.due())
|
||
os.environ["SIMBIOT_MIN_NUDGE"] = "0"
|
||
n2 = scheduler.MinNudge(); n2.poll()
|
||
es.append_event("tg", "m-778", "MessageReceived", {"update_id": 778, "text": "эй"})
|
||
n2.poll(); time.sleep(0.35)
|
||
check("nudge: SIMBIOT_MIN_NUDGE=0 выключает", not n2.due())
|
||
os.environ.pop("SIMBIOT_MIN_NUDGE")
|
||
|
||
# ── 3. сек-уровень: heartbeat + такты с событием ──
|
||
def count(et):
|
||
return sum(1 for e in es.get_events(0, 100000) if e["event_type"] == et)
|
||
|
||
sec = LEVELS["sec"]
|
||
scheduler._SEC_HB.update(last=None, seq=None)
|
||
base_t, base_r = count("CycleTicked"), count("ReflexObserved")
|
||
start = 1_000_000
|
||
for i in range(130):
|
||
scheduler.process_level(sec, {"inbox": {}, "tasks": {}, "policy": {"cpu_alert_threshold": 1e9}},
|
||
seq=start + i)
|
||
dt, dr = count("CycleTicked") - base_t, count("ReflexObserved") - base_r
|
||
check("сек: 130 тихих тактов → 3 heartbeat CycleTicked (0, 60, 120)", dt == 3, dt)
|
||
check("сек: ReflexObserved только heartbeat", dr == 3, dr)
|
||
scheduler.process_level(sec, {"inbox": {}, "tasks": {}, "policy": {"cpu_alert_threshold": 0}},
|
||
seq=start + 131)
|
||
check("сек: алерт нагрузки пишется вне heartbeat", count("ReflexObserved") - base_r == 4)
|
||
last = [json.loads(e["data"]) for e in es.get_events(0, 100000)
|
||
if e["event_type"] == "ReflexObserved"][-1]
|
||
check("сек: в записи алерта есть alert", "alert" in last, last)
|
||
|
||
print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)")
|
||
sys.exit(1 if fails else 0)
|