From c1d5a11bfbfbce4cccfb09a02f39de6ebea00294 Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Sat, 17 Oct 2026 08:20:02 +0000 Subject: [PATCH] =?UTF-8?q?=D1=8F=D0=B4=D1=80=D0=BE=20=D0=9A=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8:=20=D0=BE=D0=B1=D1=83=D1=87=D0=B0=D0=B5=D0=BC=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=BA=20=D0=B0=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D0=B0=D1=86=D0=B8=D0=B8=20(=D1=86=D0=B8?= =?UTF-8?q?=D0=BA=D0=BB=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20systemone-bench),?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BD=D0=B5=D0=B2=D0=BE=D0=B9=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cli.py | 17 + harness/addressing.py | 35 ++- harness/kodi_core.py | 665 ++++++++++++++++++++++++++++++++++++++++ harness/scheduler.py | 16 + harness/tg_inbox.py | 14 +- requirements.txt | 2 +- tests/test_kodi_core.py | 215 +++++++++++++ 7 files changed, 959 insertions(+), 5 deletions(-) create mode 100644 harness/kodi_core.py create mode 100644 tests/test_kodi_core.py diff --git a/cli.py b/cli.py index 63c39a8..4012a96 100644 --- a/cli.py +++ b/cli.py @@ -202,6 +202,9 @@ def main(): p_cmp2.add_argument("--verify-archives", action="store_true", help="проверить все архивы: sha256 против StoreCompacted + подпись каждой строки") + p_core = sub.add_parser("core", help="ядро Коди: обучаемые ученики решений (status|train|backfill|rollback)") + p_core.add_argument("action", choices=["status", "train", "backfill", "rollback", "feedback"]) + p_dec = sub.add_parser("decide", help="decision-модель nimble (Ollama /v1/systemone): статус, вопрос, статистика") p_dec.add_argument("-q", "--question", help="вопрос/инструкция (без -o — noul: P(да))") p_dec.add_argument("-o", "--option", action="append", default=[], @@ -652,6 +655,20 @@ def main(): print(f"OK {r['compaction']}: в архив {r['rows']}, удалено {r['removed']}, " f"мост {r['bridge_count']}; архив {r['archive_path']}") + elif args.cmd == "core": + from harness import kodi_core + es.init_db() + if args.action == "status": + print(json.dumps(kodi_core.status(), ensure_ascii=False, indent=1)) + elif args.action == "train": + print(json.dumps(kodi_core.train(), ensure_ascii=False, default=str)) + elif args.action == "backfill": + print(f"меток учителя: {kodi_core.backfill()}") + elif args.action == "feedback": + print(f"обратная связь: {kodi_core.harvest_feedback()}") + else: + print(json.dumps(kodi_core.rollback(), ensure_ascii=False)) + elif args.cmd == "decide": from harness import decision if args.stats: diff --git a/harness/addressing.py b/harness/addressing.py index d840e2a..cb0f07c 100644 --- a/harness/addressing.py +++ b/harness/addressing.py @@ -126,6 +126,14 @@ def model_verdict(m: dict, inbox: dict): 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 {} @@ -148,9 +156,30 @@ def triage(model: dict, log=None) -> int: ok, rule = rule_verdict(m, bot_id) p = None if ok is None: - p = model_verdict(m, inbox) - ok, rule = ((p >= _min_p(), "decision") if p is not None - else (False, "no_decision")) + # ядро Коди (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}) diff --git a/harness/kodi_core.py b/harness/kodi_core.py new file mode 100644 index 0000000..2b8daf7 --- /dev/null +++ b/harness/kodi_core.py @@ -0,0 +1,665 @@ +"""Simbiot Harness — ядро Коди: обучаемые ученики решений (как ядро systemone-bench). + +Цикл бенча (engine42/router42): учитель → дистилляция в маленькую сеть + +kNN-память → живой роутер по новизне → дообучение на реальных исходах. +Здесь тот же цикл на решениях Коди. Первая голова — `addressing`: +отвечать ли на сообщение в группе (harness/addressing.py). + +Опыт (события aggregate='core', подписаны как всё в сторе): + CoreLabel — метка сообщения: source=teacher_backfill (учитель + разметил историю), source=human (кнопка Alex в личке); + CoreFeedback — неявная обратная связь чата: бот ответил, а следом + «не тебе / продолжает / молчи» → 0; бот промолчал, а + автор тут же дёрнул бота («@juno42bot», «Коди?») → 1; + CorePrediction — теневой прогноз ученика на живом сообщении; + CoreModelPromoted — смена чемпиона (метрики, sha256 файла модели). +Живые вердикты учителя (MessageTriaged/MessageIgnored rule=decision, p) +тоже метки — мягкие, вес 1. Обратная связь — вес 3, человек — вес 5. + +Признаки: эмбеддинг сообщения + эмбеддинг контекста (3 реплики до него, +ответы бота — только на адресованное) через локальный nomic-embed-text +(~70 мс, бесплатно; кеш на диске) + 8 флагов. Ученик — логистическая +регрессия (numpy, L2): на сотнях примеров она устойчивее MLP; формат +модели допускает MLP позже. Новизна — 1 − средняя косинусная близость +к 5 ближайшим из обучающей памяти. + +Роутер (shadow по умолчанию — SIMBIOT_CORE_ADDRESSING=shadow|on|off): + знакомо (новизна < NOV_FAMILIAR) и уверенно (|p−0.5| ≥ CONF) → ученик; + незнакомо (новизна ≥ NOV_NOVEL) и учитель сомневается → вопрос Alex + кнопками в личку (≤ SIMBIOT_CORE_ASK_PER_DAY в сутки, 5); + иначе → учитель. В shadow решает учитель, ученик только пишет прогноз. + +Тренировка — дневной тик (и cli.py core train): временной холдаут +(последние 20%), претендент заменяет чемпиона, только если лучше по +взвешенному log-loss на том же холдауте; файл модели в /core/. +""" +import hashlib +import json +import os +import re +import sqlite3 +import struct +import threading +import time +import urllib.request +from datetime import datetime, timedelta, timezone + +from . import es +from . import paths as _paths + +try: + import numpy as np +except ImportError: # ядро выключится само: featurize/train упадут, триаж — учитель + np = None + +HEAD = "addressing" +EMB_MODEL = "nomic-embed-text" +EMB_DIM = 768 +FLAG_NAMES = ("prev_is_bot", "same_author_as_last_addressed", "question", + "second_person", "names_other_human", "length", "link", "media") +# пороги новизны калибруются при обучении (перцентили новизны холдаута +# против обучающей памяти: у nomic близость реплик одного чата высокая, +# новизна живёт в 0.03–0.09 — фиксированные пороги бессмысленны); это — дефолт +NOV_FAMILIAR = 0.35 +NOV_NOVEL = 0.55 +# уверенность: |p−0.5| ≥ 0.4 — репетиция на истории правления (5 фолдов): +# покрытие 25%, совпадение с учителем 95%; 0.35 → 47% / 93%; 0.3 → 60% / 90% +CONF = 0.4 +MIN_TRAIN = 40 +WEIGHTS = {"teacher": 1.0, "teacher_backfill": 1.0, "feedback": 3.0, "human": 5.0} + +NEG_RE = re.compile(r"не\s+(?:тебе|к\s+тебе|тебя\s+спрашива)|молчи|не\s+встревай|" + r"заткн|продолжает|опять\s+(?:встрял|отвечает|лезет)|не\s+лезь", + re.IGNORECASE) +SECOND_RE = re.compile(r"\b(?:ты|тебе|тебя|твой|твоя|твоё|можешь|сделай|покажи|скинь|" + r"пришли|напиши|ответь|глянь|посмотри|расскажи|подскажи|найди|" + r"создай|собери|проверь)\b", re.IGNORECASE) +LINK_RE = re.compile(r"https?://") + +_LOCK = threading.Lock() +_STATE = {"model": None, "loaded_sha": None} + + +# ────────────────────────── конфиг/пути ────────────────────────── + +def mode() -> str: + from . import decision + v = decision._cfg("SIMBIOT_CORE_ADDRESSING", "shadow").lower() + return v if v in ("on", "shadow", "off") else "shadow" + + +def core_dir() -> str: + d = (os.environ.get("SIMBIOT_CORE_DIR", "").strip() + or os.path.join(_paths.home(), "core")) + os.makedirs(os.path.join(d, HEAD), mode=0o700, exist_ok=True) + return d + + +def _ollama_base() -> str: + from . import decision + return decision._cfg("SIMBIOT_EMBED_URL", "http://127.0.0.1:11434") + + +# ────────────────────────── эмбеддинги (кеш) ────────────────────────── + +def _cache(): + db = sqlite3.connect(os.path.join(core_dir(), "emb.sqlite"), timeout=30) + db.execute("CREATE TABLE IF NOT EXISTS emb (k TEXT PRIMARY KEY, v BLOB)") + return db + + +def embed(texts: list) -> list: + """Эмбеддинги (нормированные списки float) с дисковым кешем; батч к Ollama.""" + import numpy as np + keys = [hashlib.sha1(f"{EMB_MODEL}|{t}".encode()).hexdigest() for t in texts] + out = [None] * len(texts) + db = _cache() + try: + for i, k in enumerate(keys): + row = db.execute("SELECT v FROM emb WHERE k=?", (k,)).fetchone() + if row: + out[i] = np.frombuffer(row[0], dtype=np.float32) + miss = [i for i, v in enumerate(out) if v is None] + for s in range(0, len(miss), 32): + chunk = miss[s:s + 32] + body = json.dumps({"model": EMB_MODEL, "keep_alive": "2h", + "input": [f"classification: {texts[i][:2000]}" for i in chunk]}) + req = urllib.request.Request(_ollama_base() + "/api/embed", data=body.encode(), + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=60) as r: + vecs = json.loads(r.read().decode())["embeddings"] + for i, v in zip(chunk, vecs): + a = np.asarray(v, dtype=np.float32) + a /= (np.linalg.norm(a) or 1.0) + out[i] = a + db.execute("INSERT OR REPLACE INTO emb VALUES (?, ?)", (keys[i], a.tobytes())) + db.commit() + finally: + db.close() + return out + + +# ────────────────────────── опыт из стора ────────────────────────── + +def _q(sql, args=()): + db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30) + try: + return db.execute(sql, args).fetchall() + finally: + db.close() + + +def _once(agg_id: str, etype: str, data: dict) -> bool: + """Событие ядра ровно один раз на aggregate_id (метки/фидбек не дублируются).""" + if _q("SELECT 1 FROM events WHERE aggregate='core' AND aggregate_id=? LIMIT 1", (agg_id,)): + return False + es.append_event("core", agg_id, etype, data) + return True + + +def group_timeline(since: str = "") -> dict: + """{chat_id: [msg, ...]} по времени: группы + вердикты + ответы бота + метки ядра.""" + from .addressing import is_group + msgs, by_id = {}, {} + for aid, et, ts, d in _q( + "SELECT aggregate_id, event_type, timestamp, data FROM events WHERE " + "event_type IN ('MessageReceived','MessageTriaged','MessageIgnored','ReplySent'," + "'CoreLabel','CoreFeedback') AND timestamp > ? ORDER BY id", (since,)): + d = json.loads(d) + if et == "MessageReceived": + if not is_group(d): + continue + m = {**d, "id": aid, "ts": ts} + by_id[aid] = m + msgs.setdefault(d.get("chat_id"), []).append(m) + continue + if et in ("CoreLabel", "CoreFeedback"): + m = by_id.get(d.get("msg")) + if m is not None and d.get("head", HEAD) == HEAD: + m.setdefault("labels", []).append( + {"source": d.get("source") or "feedback", "y": float(d["label"]), + "ts": ts}) + continue + m = by_id.get(aid) + if m is None: + continue + if et == "MessageTriaged": + m.update(addressed=True, rule=d.get("rule"), teacher_p=d.get("p")) + elif et == "MessageIgnored" and d.get("reason") == "not_addressed": + m.update(addressed=False, rule=d.get("rule"), teacher_p=d.get("p")) + elif et == "ReplySent": + t = d.get("text") or "" + if not t.startswith("(сводный"): + m["reply_text"] = t + m.setdefault("reply_ts", ts) + return msgs + + +def _was_addressed(m: dict) -> bool: + if m.get("addressed") is not None: + return bool(m["addressed"]) + lab = [l for l in m.get("labels", []) if l["source"] == "teacher_backfill"] + return bool(lab and lab[-1]["y"] >= 0.5) + + +# ────────────────────────── признаки ────────────────────────── + +def _ctx_lines(prior: list) -> list: + out = [] + for x in prior[-3:]: + out.append(f"{x.get('first_name') or '?'}: " + f"{(x.get('text') or '[' + str(x.get('kind')) + ']')[:300]}") + if _was_addressed(x) and x.get("reply_text"): + out.append(f"Cody: {x['reply_text'][:200]}") + return out + + +def flags(m: dict, prior: list) -> list: + text = m.get("text") or "" + last = prior[-1] if prior else None + addressed_prior = [x for x in prior if _was_addressed(x)] + others = {x.get("first_name") for x in prior if x.get("first_name")} - {m.get("first_name")} + names_other = any(re.search(rf"(? 2) + return [ + 1.0 if (last and _was_addressed(last) and last.get("reply_text")) else 0.0, + 1.0 if (addressed_prior and addressed_prior[-1].get("first_name") + == m.get("first_name")) else 0.0, + 1.0 if "?" in text else 0.0, + 1.0 if SECOND_RE.search(text) else 0.0, + 1.0 if names_other else 0.0, + min(len(text) / 300.0, 1.0), + 1.0 if LINK_RE.search(text) else 0.0, + 0.0 if (m.get("kind") or "text") == "text" else 1.0, + ] + + +def featurize(items: list): + """items: [(msg, prior_msgs)] → (X, E_msg) numpy; эмбеддинги одним батчем.""" + import numpy as np + msg_t = [f"{m.get('first_name') or '?'}: " + f"{(m.get('text') or '[' + str(m.get('kind')) + ']')[:1500]}" for m, _ in items] + ctx_t = ["\n".join(_ctx_lines(p)) or "(начало разговора)" for _, p in items] + # эмбеддинг контекста в признаки не идёт: на истории правления он добавлял + # 768 шумных измерений (5 фолдов: 0.822 с ним, 0.845 без); полезное из + # контекста — во флагах (prev_is_bot, same_author…) + del ctx_t + vecs = embed(msg_t) + n = len(items) + em = np.stack(vecs) if n else np.zeros((0, EMB_DIM), np.float32) + fl = np.asarray([flags(m, p) for m, p in items], dtype=np.float32).reshape(n, len(FLAG_NAMES)) + return np.hstack([em, fl]), em + + +# ────────────────────────── датасет ────────────────────────── + +def target(m: dict): + """(y, вес, источник) — сильнейший источник метки; None — метки нет.""" + labs = m.get("labels", []) + for src in ("human", "feedback"): + got = [l for l in labs if l["source"] == src] + if got: + return got[-1]["y"], WEIGHTS[src], src + if m.get("rule") == "decision" and m.get("teacher_p") is not None: + return float(m["teacher_p"]), WEIGHTS["teacher"], "teacher" + got = [l for l in labs if l["source"] == "teacher_backfill"] + if got: + return got[-1]["y"], WEIGHTS["teacher_backfill"], "teacher_backfill" + return None + + +def dataset(): + """Серая зона (правила её не решают) с меткой → [(msg, prior, y, w, src)] по времени.""" + from .addressing import rule_verdict + rows = [] + for chat, msgs in group_timeline().items(): + for i, m in enumerate(msgs): + if m.get("rule") not in (None, "decision", "no_decision", "core"): + continue # правило решило — ученику не нужно + if m.get("rule") is None and rule_verdict(m)[0] is not None: + continue + t = target(m) + if t is None: + continue + rows.append((m, msgs[max(0, i - 8):i], *t)) + rows.sort(key=lambda r: r[0]["ts"]) + return rows + + +# ────────────────────────── модель ────────────────────────── + +EMB_SCALE = 4.0 # нормированные эмбеддинги: геометрию не трогаем, только масштаб +NF = len(FLAG_NAMES) + + +def _prep(model, X): + """Эмбеддинги — как есть (×EMB_SCALE), стандартизуются только флаги. + Стандартизация каждого из 1536 измерений на сотнях примеров раздувала + шумовые измерения до масштаба полезных — регрессия учила шум.""" + Z = X.astype(np.float64, copy=True) + Z[:, :-NF] *= EMB_SCALE + Z[:, -NF:] = (Z[:, -NF:] - model["mu"]) / model["sd"] + return Z + + +def train_logreg(X, y, w, l2=0.05, epochs=800, lr=0.2): + """Логистическая регрессия: взвешенный log-loss + L2, градиентный спуск (Adam).""" + fl = X[:, -NF:].astype(np.float64) + model = {"mu": fl.mean(0), "sd": fl.std(0) + 1e-6} + Z = _prep(model, X) + wt = np.zeros(Z.shape[1]); b = 0.0 + sw = w / w.sum() + m1 = np.zeros_like(wt); v1 = np.zeros_like(wt); mb = vb = 0.0 + for t in range(1, epochs + 1): + p = 1 / (1 + np.exp(-(Z @ wt + b))) + g = p - y + gw = Z.T @ (sw * g) + l2 * wt + gb = float((sw * g).sum()) + m1 = .9 * m1 + .1 * gw; v1 = .999 * v1 + .001 * gw * gw + mb = .9 * mb + .1 * gb; vb = .999 * vb + .001 * gb * gb + wt -= lr * (m1 / (1 - .9 ** t)) / (np.sqrt(v1 / (1 - .999 ** t)) + 1e-8) + b -= lr * (mb / (1 - .9 ** t)) / (np.sqrt(vb / (1 - .999 ** t)) + 1e-8) + model.update(w=wt, b=b) + return model + + +def predict(model, X): + Z = _prep(model, X) + return 1 / (1 + np.exp(-(Z @ model["w"] + model["b"]))) + + +def novelty(model, em): + """1 − средняя косинусная близость к 5 ближайшим из обучающей памяти.""" + import numpy as np + mem = model.get("mem") + if mem is None or not len(mem): + return np.ones(len(em)) + sims = em @ mem.T + k = min(5, mem.shape[0]) + top = -np.sort(-sims, axis=1)[:, :k] + return 1 - top.mean(1) + + +def metrics(model, X, y, w): + import numpy as np + p = np.clip(predict(model, X), 1e-6, 1 - 1e-6) + ll = float(-(w * (y * np.log(p) + (1 - y) * np.log(1 - p))).sum() / w.sum()) + acc = float((w * ((p >= .5) == (y >= .5))).sum() / w.sum()) + return {"logloss": round(ll, 4), "acc": round(acc, 4), "n": int(len(y))} + + +def _model_path(name): + return os.path.join(core_dir(), HEAD, name) + + +def _champion_meta(): + try: + with open(_model_path("champion.json")) as f: + return json.load(f) + except (OSError, ValueError): + return {} + + +def load_champion(): + """Чемпион из файла (кеш по sha256) или None.""" + import numpy as np + meta = _champion_meta() + if not meta.get("file"): + return None + if _STATE["loaded_sha"] == meta.get("sha256") and _STATE["model"] is not None: + return _STATE["model"] + path = _model_path(meta["file"]) + with open(path, "rb") as f: + blob = f.read() + if hashlib.sha256(blob).hexdigest() != meta.get("sha256"): + return None # файл подменён — ученика нет, решает учитель + z = np.load(path) + model = {k: z[k] for k in ("w", "mu", "sd", "mem")} + model["b"] = float(z["b"]) + model["nov_q"] = z["nov_q"] if "nov_q" in z else np.asarray([NOV_FAMILIAR, NOV_NOVEL]) + _STATE.update(model=model, loaded_sha=meta["sha256"]) + return model + + +def _save(model, metrics_, n_total): + import io + import numpy as np + name = f"model-{datetime.now(timezone.utc).strftime('%y%m%d%H%M%S')}.npz" + buf = io.BytesIO() + np.savez(buf, w=model["w"], b=np.float64(model["b"]), mu=model["mu"], + sd=model["sd"], mem=model["mem"], + nov_q=np.asarray(model.get("nov_q", [NOV_FAMILIAR, NOV_NOVEL]))) + blob = buf.getvalue() + path = _model_path(name) + with open(path, "wb") as f: + f.write(blob) + os.chmod(path, 0o600) + sha = hashlib.sha256(blob).hexdigest() + prev = _champion_meta() + meta = {"file": name, "sha256": sha, "metrics": metrics_, "n": n_total, + "kind": "logreg", "features": {"emb": EMB_MODEL, "flags": FLAG_NAMES}, + "ts": datetime.now(timezone.utc).isoformat(), + "history": ([{k: prev[k] for k in ("file", "sha256", "metrics", "ts") if k in prev}] + + prev.get("history", []))[:10] if prev.get("file") else []} + tmp = _model_path(".champion.tmp") + with open(tmp, "w") as f: + json.dump(meta, f, ensure_ascii=False, indent=1) + os.replace(tmp, _model_path("champion.json")) + return meta + + +def train(log=print) -> dict: + """Претендент против чемпиона на временном холдауте; лучше — промоут.""" + import numpy as np + rows = dataset() + if len(rows) < MIN_TRAIN: + log(f"[core] {HEAD}: примеров {len(rows)} < {MIN_TRAIN} — обучение отложено") + return {"promoted": False, "n": len(rows), "reason": "few_samples"} + X, em = featurize([(r[0], r[1]) for r in rows]) + y = np.asarray([r[2] for r in rows], np.float64) + w = np.asarray([r[3] for r in rows], np.float64) + cut = max(int(len(rows) * 0.8), len(rows) - max(10, len(rows) // 5)) + ch = train_logreg(X[:cut], y[:cut], w[:cut]) + ch["mem"] = em[:cut] + m_ch = metrics(ch, X[cut:], y[cut:], w[cut:]) + nov_h = novelty(ch, em[cut:]) + nov_q = np.asarray([np.quantile(nov_h, .75), np.quantile(nov_h, .97)]) + p_h = predict(ch, X[cut:]) + sure = np.abs(p_h - .5) >= CONF + m_ch["confident_share"] = round(float(sure.mean()), 3) + m_ch["confident_acc"] = (round(float(((p_h[sure] >= .5) == (y[cut:][sure] >= .5)).mean()), 3) + if sure.any() else None) + m_ch["nov_q"] = [round(float(v), 4) for v in nov_q] + champ = load_champion() + m_old = None + if champ is not None: + try: + m_old = metrics(champ, X[cut:], y[cut:], w[cut:]) + except Exception: + m_old = None # другой формат признаков — сравнивать нельзя, меняем + better = m_old is None or m_ch["logloss"] < m_old["logloss"] - 1e-4 + srcs = {} + for r in rows: + srcs[r[4]] = srcs.get(r[4], 0) + 1 + rep = {"head": HEAD, "n": len(rows), "holdout": m_ch, "champion_holdout": m_old, + "sources": srcs, "promoted": bool(better)} + if better: + full = train_logreg(X, y, w) + full["mem"] = em + full["nov_q"] = nov_q + meta = _save(full, m_ch, len(rows)) + _STATE.update(model=None, loaded_sha=None) + es.append_event("core", f"promote:{HEAD}:{meta['file']}", "CoreModelPromoted", { + "head": HEAD, "file": meta["file"], "sha256": meta["sha256"], + "holdout": m_ch, "champion_holdout": m_old, "n": len(rows), "sources": srcs}) + log(f"[core] {HEAD}: n={len(rows)} претендент {m_ch} чемпион {m_old} → " + f"{'ПРОМОУТ' if better else 'чемпион остаётся'}") + return rep + + +def rollback() -> dict: + meta = _champion_meta() + hist = meta.get("history") or [] + if not hist: + return {"ok": False, "reason": "истории нет"} + prev = hist[0] + new = {**prev, "history": hist[1:], "ts": datetime.now(timezone.utc).isoformat(), + "rolled_back_from": meta.get("file")} + tmp = _model_path(".champion.tmp") + with open(tmp, "w") as f: + json.dump(new, f, ensure_ascii=False, indent=1) + os.replace(tmp, _model_path("champion.json")) + _STATE.update(model=None, loaded_sha=None) + es.append_event("core", f"rollback:{HEAD}:{int(time.time())}", "CoreModelPromoted", { + "head": HEAD, "file": prev.get("file"), "sha256": prev.get("sha256"), + "rollback_from": meta.get("file")}) + return {"ok": True, "file": prev.get("file")} + + +# ────────────────────────── живой контур ────────────────────────── + +def route(p_student, nov, p_teacher, nov_q=None): + fam, novel = (float(nov_q[0]), float(nov_q[1])) if nov_q is not None else (NOV_FAMILIAR, NOV_NOVEL) + if p_student is not None and nov < fam and abs(p_student - 0.5) >= CONF: + return "student" + if nov >= novel and (p_teacher is None or 0.3 < p_teacher < 0.7): + return "human" + return "teacher" + + +def shadow(m: dict, prior: list, teacher_p, final: bool): + """Прогноз ученика на живом сообщении серой зоны → CorePrediction (+ вопрос Alex).""" + if mode() == "off": + return None + try: + model = load_champion() + X, em = featurize([(m, prior)]) + p = float(predict(model, X)[0]) if model is not None else None + nov = float(novelty(model, em)[0]) if model is not None else 1.0 + except Exception as e: + return {"error": str(e)[:200]} + r = route(p, nov, teacher_p, model.get("nov_q") if model is not None else None) + mid = f"m-{m.get('update_id')}" + rec = {"head": HEAD, "msg": mid, "p_student": None if p is None else round(p, 4), + "novelty": round(nov, 4), "route": r, "teacher_p": teacher_p, + "final": bool(final), "mode": mode(), + "agree": None if p is None or teacher_p is None else (p >= .5) == (teacher_p >= .5)} + _once(f"pred:{HEAD}:{mid}", "CorePrediction", rec) + if r == "human": + try: + ask_human(m, prior) + except Exception: + pass # вопрос — бонус, не путь решения + return rec + + +def ask_human(m: dict, prior: list) -> bool: + """Кнопки Alex в личку: «боту / не боту» — самая ценная метка (≤ N в сутки).""" + limit = int(os.environ.get("SIMBIOT_CORE_ASK_PER_DAY", "5")) + day = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() + asked = _q("SELECT COUNT(*) FROM events WHERE event_type='CoreQuestionAsked' " + "AND timestamp > ?", (day,))[0][0] + if asked >= limit: + return False + mid = f"m-{m.get('update_id')}" + if _q("SELECT 1 FROM events WHERE aggregate='core' AND aggregate_id=?", (f"ask:{mid}",)): + return False + from . import tg_inbox + chat = tg_inbox._default_chat_id() + if chat is None: + return False + ctx = "\n".join(_ctx_lines(prior)) or "(начало разговора)" + text = (f"🧠 Учусь понимать, когда ко мне обращаются в «{m.get('chat_title') or 'группе'}».\n\n" + f"Контекст:\n{ctx[-600:]}\n\n" + f"Сообщение — {m.get('first_name')}: «{(m.get('text') or '')[:400]}»\n\n" + "Это было мне?") + kb = {"inline_keyboard": [[ + {"text": "Да, Коди", "callback_data": f"core:{HEAD}:{mid}:1"}, + {"text": "Нет, не мне", "callback_data": f"core:{HEAD}:{mid}:0"}]]} + tg_inbox._load_env() + r = tg_inbox._api("sendMessage", {"chat_id": chat, "text": text, + "reply_markup": json.dumps(kb)}, timeout=15) + if (r or {}).get("ok"): + es.append_event("core", f"ask:{mid}", "CoreQuestionAsked", {"head": HEAD, "msg": mid}) + return True + return False + + +def on_callback(cq: dict, allowed: set) -> bool: + """Ответ Alex на кнопку → CoreLabel source=human. Возвращает True, если наш callback.""" + data = cq.get("data") or "" + if not data.startswith("core:"): + return False + from . import tg_inbox + _, head, mid, label = data.split(":", 3) + user = (cq.get("from") or {}).get("id") + ok = not allowed or str(user) in allowed + if ok: + _once(f"label:{head}:{mid}:human", "CoreLabel", + {"head": head, "msg": mid, "label": int(label), "source": "human", "by": user}) + try: + tg_inbox._api("answerCallbackQuery", { + "callback_query_id": cq.get("id"), + "text": ("Запомнил 👍" if ok else "Нет прав")}, timeout=10) + msg = cq.get("message") or {} + if ok and msg.get("message_id"): + tg_inbox._api("editMessageReplyMarkup", { + "chat_id": (msg.get("chat") or {}).get("id"), + "message_id": msg["message_id"], + "reply_markup": json.dumps({"inline_keyboard": [[{ + "text": "✓ " + ("мне" if label == "1" else "не мне"), + "callback_data": "core:noop"}]]})}, timeout=10) + except Exception: + pass + return True + + +def harvest_feedback(log=None) -> int: + """Неявная обратная связь чата за последние 2 часа → CoreFeedback (один раз).""" + since = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat() + n = 0 + for chat, msgs in group_timeline(since).items(): + for i, m in enumerate(msgs): + if m.get("rule") not in ("decision", "no_decision", "core"): + continue + t0 = datetime.fromisoformat(m.get("reply_ts") or m["ts"]) + nxt = [x for x in msgs[i + 1:] + if datetime.fromisoformat(x["ts"]) - t0 <= timedelta(minutes=5)] + if m.get("addressed") and m.get("reply_text"): + neg = next((x for x in nxt if NEG_RE.search(x.get("text") or "")), None) + if neg and _once(f"fb:{HEAD}:{m['id']}", "CoreFeedback", { + "head": HEAD, "msg": m["id"], "label": 0, + "reason": "neg: " + (neg.get("text") or "")[:120]}): + n += 1 + elif m.get("addressed") is False: + poke = next((x for x in nxt if x.get("first_name") == m.get("first_name") + and x.get("rule") in ("mention", "name", "reply_to_bot") + and _is_poke(x)), None) + if poke and _once(f"fb:{HEAD}:{m['id']}", "CoreFeedback", { + "head": HEAD, "msg": m["id"], "label": 1, + "reason": "poke: " + (poke.get("text") or "")[:120]}): + n += 1 + if n and log: + log(f"[core] обратная связь: {n}") + return n + + +def _is_poke(x: dict) -> bool: + """«Дёрнул бота» сразу после — короткое «@juno42bot» / «Коди?» без новой темы.""" + t = re.sub(r"@\w+|коди|cody", "", x.get("text") or "", flags=re.IGNORECASE) + return len(re.sub(r"[\W_]+", "", t)) <= 12 + + +# ────────────────────────── бэкфилл учителем ────────────────────────── + +def backfill(log=print, limit: int = 2000) -> int: + """Учитель размечает историю серой зоны (как живой: ответы бота в истории — + только на адресованное по его же меткам) → CoreLabel source=teacher_backfill.""" + from .addressing import model_verdict, rule_verdict + n = 0 + for chat, msgs in group_timeline().items(): + inbox = {} + for m in msgs: + inbox[m["id"]] = m + if m.get("rule") is not None or m.get("labels"): + continue # живой вердикт или метка уже есть + if rule_verdict(m)[0] is not None: + continue + p = model_verdict(m, {k: {**v, "addressed": _was_addressed(v)} + for k, v in inbox.items() if k != m["id"]}) + if p is None: + continue + if _once(f"label:{HEAD}:{m['id']}:teacher", "CoreLabel", { + "head": HEAD, "msg": m["id"], "label": round(p, 4), + "source": "teacher_backfill"}): + m.setdefault("labels", []).append( + {"source": "teacher_backfill", "y": p, "ts": m["ts"]}) + n += 1 + if n >= limit: + return n + log(f"[core] бэкфилл учителем: {n} меток") + return n + + +def status() -> dict: + meta = _champion_meta() + day = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat() + preds = [json.loads(d) for (d,) in _q( + "SELECT data FROM events WHERE event_type='CorePrediction' AND timestamp > ?", (day,))] + agree = [p["agree"] for p in preds if p.get("agree") is not None] + srcs = {} + for (d,) in _q("SELECT data FROM events WHERE event_type IN ('CoreLabel','CoreFeedback')"): + s = json.loads(d).get("source") or "feedback" + srcs[s] = srcs.get(s, 0) + 1 + return { + "head": HEAD, "mode": mode(), "champion": meta.get("file"), + "champion_metrics": meta.get("metrics"), "champion_n": meta.get("n"), + "champion_ts": meta.get("ts"), "labels": srcs, + "shadow_7d": {"predictions": len(preds), + "agree_with_teacher": (round(sum(agree) / len(agree), 3) if agree else None), + "would_be_local": (round(sum(p["route"] == "student" for p in preds) + / len(preds), 3) if preds else None), + "would_ask_human": sum(p["route"] == "human" for p in preds)}, + "questions_7d": _q("SELECT COUNT(*) FROM events WHERE event_type='CoreQuestionAsked' " + "AND timestamp > ?", (day,))[0][0], + } diff --git a/harness/scheduler.py b/harness/scheduler.py index 8061a31..aca0a1c 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -28,6 +28,8 @@ POLL = 1.0 # уровни, включённые в v0.1 (порядок для лога) ACTIVE = ["sec", "min", "hour", "day"] +# ядро Коди: когда последний раз собирали обратную связь чата +_CORE_FB = {"ts": 0.0} # состояние исполнителей уровня: level → {"busy": bool, "task": id|None} _runners = {} # когда уровень последний раз слал TG-уведомление о занятости (анти-спам) @@ -907,6 +909,12 @@ def process_level(level_cfg, model: dict, seq: int = None): _repo_sync_check(level_cfg) except Exception: pass # репо-синк — best effort, не валить дневной тик + # ядро Коди: ночная тренировка учеников (чемпион/претендент) + try: + from . import kodi_core + kodi_core.train(log=_log) + except Exception as e: + _log(f"core: ночная тренировка не удалась ({e})") try: from . import aum as _aum snap = _aum.measure() @@ -929,6 +937,14 @@ def process_level(level_cfg, model: dict, seq: int = None): model = es.run_projections() except Exception as e: _log(f"addressing: триаж не удался ({e}) — групповые ждут следующего такта") + # ядро Коди: неявная обратная связь чата → метки (не чаще раза в 5 мин) + if level_cfg.name == "min" and time.time() - _CORE_FB["ts"] >= 300: + _CORE_FB["ts"] = time.time() + try: + from . import kodi_core + kodi_core.harvest_feedback(log=_log) + except Exception as e: + _log(f"core: сбор обратной связи не удался ({e})") try: load = os.getloadavg()[0] except Exception: diff --git a/harness/tg_inbox.py b/harness/tg_inbox.py index c0c8018..66cacbd 100644 --- a/harness/tg_inbox.py +++ b/harness/tg_inbox.py @@ -657,7 +657,11 @@ def poll_loop(): print("[tg] инбокс-поллер запущен", flush=True) while True: try: - updates = _api("getUpdates", {"offset": offset, "timeout": 30, "limit": 16}) + updates = _api("getUpdates", { + "offset": offset, "timeout": 30, "limit": 16, + # явно: Telegram помнит прошлый allowed_updates; callback_query — + # кнопки ядра Коди («это было мне?») + "allowed_updates": json.dumps(["message", "edited_message", "callback_query"])}) # getUpdates ответил — канал жив: штамп для дашборда # (t-261004103718-d8112b). getUpdates сам НЕ пробуем в дашборде: # конфликт long polling с этим процессом (сдвиг offset). @@ -667,6 +671,14 @@ def poll_loop(): updates=len((updates or {}).get("result") or [])) for u in updates.get("result", []): offset = max(offset, u["update_id"] + 1) + if u.get("callback_query"): + # кнопки ядра Коди («это было мне?») → метка человека + try: + from . import kodi_core + kodi_core.on_callback(u["callback_query"], ALLOWED) + except Exception as e: + print(f"[tg] callback не обработан: {e}", flush=True) + continue msg = u.get("message") or u.get("edited_message") if not msg: continue diff --git a/requirements.txt b/requirements.txt index 372c1d2..9c5aa5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,4 +20,4 @@ uvicorn==0.54.0 sse-starlette==3.5.0 python-multipart==0.0.32 py-spy==0.4.2 -watchdog==6.0.0 \ No newline at end of file +watchdog==6.0.0numpy>=2.0 # ядро Коди (harness/kodi_core.py): обучение учеников diff --git a/tests/test_kodi_core.py b/tests/test_kodi_core.py new file mode 100644 index 0000000..56908fe --- /dev/null +++ b/tests/test_kodi_core.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +"""Ядро Коди (harness/kodi_core.py), голова addressing — hermetic. + +Запуск: ./venv/bin/python tests/test_kodi_core.py + +Эмбеддинги подменены детерминированными (тема «боту» vs «людям» + шум), +Ollama/Telegram не трогаются. Проверяет: датасет (серая зона, приоритет +меток human > feedback > teacher), обучение и промоут чемпиона с +подписанным событием, отказ от худшего претендента, подмена файла модели +→ ученика нет, откат; теневой прогноз пишется один раз; роутер; +вопрос Alex с лимитом в сутки и кнопка → метка человека (чужой — нет); +неявная обратная связь (встревание → 0, «дёрнул бота» → 1); триаж: в +shadow решает учитель, в on уверенный знакомый ученик решает сам. +""" +import hashlib +import json +import os +import sys +import tempfile + +sys.path.insert(0, "/root/simbiot-harness") +_tmp = tempfile.mkdtemp(prefix="simbiot_core_") +os.environ["SIMBIOT_DB"] = os.path.join(_tmp, "harness.db") +os.environ["SIMBIOT_CORE_DIR"] = os.path.join(_tmp, "core") +for _v in ("SIMBIOT_CORE_ADDRESSING", "SIMBIOT_CORE_ASK_PER_DAY", "SIMBIOT_DECISION"): + os.environ.pop(_v, None) + +import numpy as np + +from harness import addressing, decision, es, kodi_core, tg_inbox + +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) + + +A = np.zeros(768, np.float32); A[0] = 1 +B = np.zeros(768, np.float32); B[1] = 1 + + +def fake_embed(texts): + out = [] + for t in texts: + h = int(hashlib.sha1(t.encode()).hexdigest(), 16) + rng = np.random.default_rng(h % (2 ** 32)) + base = A if "#bot" in t else B + v = base + 0.02 * rng.standard_normal(768).astype(np.float32) + out.append(v / np.linalg.norm(v)) + return out + + +kodi_core.embed = fake_embed +API = [] +tg_inbox._api = lambda method, params, timeout=35: API.append((method, dict(params))) or {"ok": True, "result": True} +tg_inbox._load_env = lambda: None +tg_inbox._default_chat_id = lambda: 7569223131 +tg_inbox.mark_seen = lambda *a, **k: True +G = -1004402779881 +UID = [1000] + + +def recv(text, who="Vlad", ts=None): + UID[0] += 1 + mid = f"m-{UID[0]}" + es.append_event("inbox", mid, "MessageReceived", { + "update_id": UID[0], "chat_id": G, "chat_type": "supergroup", "chat_title": "Правление", + "first_name": who, "text": text, "kind": "text", "message_id": UID[0]}) + return mid + + +# ── 1. опыт: 60 размеченных учителем сообщений серой зоны + правило + метки ── +hm = recv("#bot спорное, учитель ошибся") +es.append_event("core", f"label:addressing:{hm}:teacher", "CoreLabel", + {"head": "addressing", "msg": hm, "label": 0.2, "source": "teacher_backfill"}) +es.append_event("core", f"label:addressing:{hm}:human", "CoreLabel", + {"head": "addressing", "msg": hm, "label": 1, "source": "human"}) +for i in range(60): + bot = i % 2 == 0 + mid = recv(f"{'#bot сделай отчёт' if bot else 'обсуждаем между собой'} {i}") + es.append_event("core", f"label:addressing:{mid}:teacher", "CoreLabel", { + "head": "addressing", "msg": mid, "label": 0.9 if bot else 0.1, "source": "teacher_backfill"}) +rule_mid = recv("Коди, привет") # правило — не в датасете +es.append_event("inbox", rule_mid, "MessageTriaged", {"addressed": True, "rule": "name"}) +rows = kodi_core.dataset() +ids = {r[0]["id"] for r in rows} +check("датасет: серая зона с метками (61)", len(rows) == 61, len(rows)) +check("датасет: решённое правилом не входит", rule_mid not in ids) +hrow = next(r for r in rows if r[0]["id"] == hm) +check("метка человека сильнее учителя (y=1, вес 5)", hrow[2] == 1 and hrow[3] == 5.0 and hrow[4] == "human") + +# ── 2. обучение и промоут ── +rep = kodi_core.train(log=lambda *_: None) +check("первое обучение → промоут", rep["promoted"] and rep["holdout"]["acc"] >= 0.9, rep) +st = kodi_core.status() +check("чемпион записан", st["champion"] and st["champion_n"] == 61, st) +prom = [json.loads(e["data"]) for e in es.get_events(0, 10**6) if e["event_type"] == "CoreModelPromoted"] +check("CoreModelPromoted с sha256", prom and len(prom[-1]["sha256"]) == 64) +model = kodi_core.load_champion() +_ctx = [{"first_name": "Vlad", "text": "обсуждаем между собой 1"}, + {"first_name": "Vlad", "text": "#bot сделай отчёт 2"}] +X, em = kodi_core.featurize([({"first_name": "Vlad", "text": "#bot глянь отчёт"}, _ctx), + ({"first_name": "Vlad", "text": "обсуждаем между собой"}, _ctx)]) +p = kodi_core.predict(model, X) +check("ученик различает «боту» и «людям»", p[0] > 0.7 and p[1] < 0.4, p) +nov = kodi_core.novelty(model, em) +check("знакомое — ниже порога «незнакомо» модели", nov.max() < model["nov_q"][1], (nov, model["nov_q"])) +check("пороги новизны откалиброваны и упорядочены", 0 <= model["nov_q"][0] <= model["nov_q"][1] < 1, model["nov_q"]) +rep2 = kodi_core.train(log=lambda *_: None) +check("тот же опыт — чемпион остаётся (не лучше)", rep2["promoted"] is False, rep2) + +# подмена файла модели +meta = kodi_core._champion_meta() +path = kodi_core._model_path(meta["file"]) +blob = open(path, "rb").read() +open(path, "wb").write(blob[:-1] + bytes([blob[-1] ^ 1])) +kodi_core._STATE.update(model=None, loaded_sha=None) +check("файл модели подменён → ученика нет", kodi_core.load_champion() is None) +open(path, "wb").write(blob) +kodi_core._STATE.update(model=None, loaded_sha=None) + +# ── 3. роутер ── +check("роутер: знакомо+уверенно → ученик", kodi_core.route(0.95, 0.1, 0.9) == "student") +check("роутер: незнакомо+учитель сомневается → человек", kodi_core.route(0.6, 0.8, 0.5) == "human") +check("роутер: иначе → учитель", kodi_core.route(0.55, 0.1, 0.9) == "teacher") + +# ── 4. триаж в shadow: решает учитель, ученик пишет прогноз ── +CALLS = [] +decision.choose = lambda purpose, opts, state, instr: CALLS.append(1) or { + "choice": "group", "applied": True, "confidence": .5, "probabilities": {"bot": 0.2, "group": 0.8}} +mid = recv("#bot сделай отчёт за неделю") +addressing.triage(es.run_projections()) +ib = es.run_projections()["inbox"][mid] +pred = [json.loads(e["data"]) for e in es.get_events(0, 10**6) if e["event_type"] == "CorePrediction"] +check("shadow: решение учителя (не боту, p=0.2)", ib.get("ignored") and CALLS, + (ib.get("ignored"), ib.get("ignore_reason"), ib.get("addressed"), CALLS)) +check("shadow: прогноз ученика записан, расходится с учителем", + pred and pred[-1]["msg"] == mid and pred[-1]["p_student"] > .7 and pred[-1]["agree"] is False, pred[-1:]) +kodi_core.shadow({"update_id": int(mid[2:]), "first_name": "Vlad", "text": "x"}, [], 0.2, True) +check("прогноз на сообщение пишется один раз", + sum(1 for e in es.get_events(0, 10**6) if e["event_type"] == "CorePrediction" + and json.loads(e["data"])["msg"] == mid) == 1) + +# ── 5. on: уверенный знакомый ученик решает сам, учитель не зовётся ── +os.environ["SIMBIOT_CORE_ADDRESSING"] = "on" +CALLS.clear() +_route = kodi_core.route # пороги роутера проверены в §3; здесь — связка с триажем +kodi_core.route = lambda p, nov, t, q=None: "student" if p is not None and abs(p - .5) >= .3 else "teacher" +mid2 = recv("#bot собери сводку") +addressing.triage(es.run_projections()) +ib2 = es.run_projections()["inbox"][mid2] +check("on: ученик решил (rule=core), учитель не вызван", + ib2.get("addressed") is True and ib2["triage"]["rule"] == "core" and not CALLS, (ib2.get("triage"), CALLS)) +os.environ.pop("SIMBIOT_CORE_ADDRESSING") +kodi_core.route = _route + +# ── 6. вопрос Alex и кнопки ── +os.environ["SIMBIOT_CORE_ASK_PER_DAY"] = "2" +for t in ("странное 1", "странное 2", "странное 3"): + mm = {"update_id": int(recv(t)[2:]), "first_name": "Vlad", "text": t, "chat_title": "Правление"} + kodi_core.ask_human(mm, []) +asked = [p for m_, p in API if m_ == "sendMessage"] +check("вопрос Alex: лимит в сутки соблюдён (2)", len(asked) == 2, len(asked)) +kb = json.loads(asked[0]["reply_markup"])["inline_keyboard"][0] +check("вопрос: кнопки «Да, Коди / Нет»", kb[0]["callback_data"].endswith(":1") and kb[1]["callback_data"].endswith(":0")) +target = kb[1]["callback_data"].split(":")[2] +check("кнопка от чужого — метки нет", + kodi_core.on_callback({"id": "q1", "data": kb[1]["callback_data"], "from": {"id": 666}}, + {"7569223131"}) and not [e for e in es.get_events(0, 10**6) + if e["event_type"] == "CoreLabel" and + json.loads(e["data"]).get("source") == "human" + and json.loads(e["data"])["msg"] == target]) +kodi_core.on_callback({"id": "q2", "data": kb[1]["callback_data"], "from": {"id": 7569223131}, + "message": {"message_id": 5, "chat": {"id": 7569223131}}}, {"7569223131"}) +hl = [json.loads(e["data"]) for e in es.get_events(0, 10**6) if e["event_type"] == "CoreLabel" + and json.loads(e["data"]).get("source") == "human" and json.loads(e["data"])["msg"] == target] +check("кнопка Alex → метка человека 0", hl and hl[-1]["label"] == 0, hl) +check("кнопки убраны после ответа", any(m_ == "editMessageReplyMarkup" for m_, _ in API)) +check("чужие callback не наши", kodi_core.on_callback({"data": "other:x"}, set()) is False) + +# ── 7. неявная обратная связь ── +fp = recv("ну что, как дела у всех") +es.append_event("inbox", fp, "MessageTriaged", {"addressed": True, "rule": "decision", "p": 0.6}) +es.append_event("inbox", fp, "ReplySent", {"text": "Всё отлично 🙂"}) +recv("Коди, не тебе", who="Alex") +fn = recv("подготовь сводку по встрече", who="Alex") +es.append_event("inbox", fn, "MessageIgnored", {"reason": "not_addressed", "rule": "decision", "p": 0.3}) +poke = recv("@juno42bot", who="Alex") +es.append_event("inbox", poke, "MessageTriaged", {"addressed": True, "rule": "mention"}) +n = kodi_core.harvest_feedback() +fb = {json.loads(e["data"])["msg"]: json.loads(e["data"]) for e in es.get_events(0, 10**6) + if e["event_type"] == "CoreFeedback"} +check("встревание + «не тебе» → метка 0", fb.get(fp, {}).get("label") == 0, fb) +check("промолчал + «@juno42bot» тем же автором → метка 1", fb.get(fn, {}).get("label") == 1, fb) +check("обратная связь пишется один раз", kodi_core.harvest_feedback() == 0) + +# ── 8. откат ── +for i in range(30): # новый опыт → новый чемпион, чтобы было куда откатываться + mid = recv(f"{'#bot' if i % 2 else ''} новое {i}") + es.append_event("core", f"label:addressing:{mid}:human", "CoreLabel", + {"head": "addressing", "msg": mid, "label": 1 if i % 2 else 0, "source": "human"}) +first = kodi_core._champion_meta()["file"] +r3 = kodi_core.train(log=lambda *_: None) +if r3["promoted"]: + rb = kodi_core.rollback() + check("откат к предыдущему чемпиону", rb["ok"] and kodi_core._champion_meta()["file"] == first, rb) +else: + check("откат: история пуста — честный отказ", kodi_core.rollback()["ok"] is False) + +print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)") +sys.exit(1 if fails else 0)