"""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 FRESH_MIN = 10 # свежих (не виденных чемпионом) примеров для честного сравнения 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", "sha"} # ────────────────────────── конфиг/пути ────────────────────────── def mode(head: str = HEAD) -> str: """SIMBIOT_CORE_<ГОЛОВА>=shadow|on|off (дефолт shadow).""" from . import decision v = decision._cfg("SIMBIOT_CORE_" + head.upper(), "shadow").lower() return v if v in ("on", "shadow", "off") else "shadow" def _core_root() -> str: return (os.environ.get("SIMBIOT_CORE_DIR", "").strip() or os.path.join(_paths.home(), "core")) def core_dir() -> str: """Корень ядра — с созданием (только для записи: кеш эмбеддингов, модели).""" d = _core_root() os.makedirs(d, 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, prefix: str = "classification") -> list: """Эмбеддинги (нормированные списки float) с дисковым кешем; батч к Ollama. prefix — задача nomic: classification (ученики ядра) / search_document, search_query (память, harness/memory.py); ключ кеша включает префикс (classification — прежний ключ, кеш не теряется).""" import numpy as np keys = [hashlib.sha1((f"{EMB_MODEL}|{t}" if prefix == "classification" else f"{EMB_MODEL}|{prefix}|{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"{prefix}: {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 измерений на сотнях примеров раздувала шумовые измерения до масштаба полезных — регрессия учила шум.""" nf = int(model.get("nf", NF)) 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, nf=NF): """Логистическая регрессия: взвешенный log-loss + L2, градиентный спуск (Adam).""" fl = X[:, -nf:].astype(np.float64) model = {"mu": fl.mean(0), "sd": fl.std(0) + 1e-6, "nf": nf} 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 ближайшим из обучающей памяти.""" 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): 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, head: str = HEAD, create: bool = True): """Путь файла головы; create=False — для чтения: каталоги не плодим (чтение чемпиона из build_context создавало core/ в репо в тестах).""" if not create: return os.path.join(_core_root(), head, name) d = os.path.join(core_dir(), head) os.makedirs(d, mode=0o700, exist_ok=True) return os.path.join(d, name) def _champion_meta(head: str = HEAD): try: with open(_model_path("champion.json", head, create=False)) as f: return json.load(f) except (OSError, ValueError): return {} def load_champion(head: str = HEAD): """Чемпион головы из файла (кеш по sha256) или None.""" meta = _champion_meta(head) if not meta.get("file"): return None st = _STATE.setdefault(head, {}) if st.get("sha") == meta.get("sha256") and st.get("model") is not None: return st["model"] path = _model_path(meta["file"], head, create=False) 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["nf"] = int(z["nf"]) if "nf" in z else NF model["nov_q"] = z["nov_q"] if "nov_q" in z else np.asarray([NOV_FAMILIAR, NOV_NOVEL]) st.update(model=model, sha=meta["sha256"]) return model def _forget(head: str): _STATE.pop(head, None) def _save(model, metrics_, n_total, head: str = HEAD, flag_names=FLAG_NAMES, trained_upto=""): import io 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"], nf=np.int64(model.get("nf", NF)), nov_q=np.asarray(model.get("nov_q", [NOV_FAMILIAR, NOV_NOVEL]))) blob = buf.getvalue() path = _model_path(name, head) with open(path, "wb") as f: f.write(blob) os.chmod(path, 0o600) sha = hashlib.sha256(blob).hexdigest() prev = _champion_meta(head) meta = {"file": name, "sha256": sha, "metrics": metrics_, "n": n_total, "trained_upto": trained_upto, "kind": "logreg", "features": {"emb": EMB_MODEL, "flags": list(flag_names)}, "ts": datetime.now(timezone.utc).isoformat(), "history": ([{k: prev[k] for k in ("file", "sha256", "metrics", "ts", "trained_upto") if k in prev}] + prev.get("history", []))[:10] if prev.get("file") else []} tmp = _model_path(".champion.tmp", head) with open(tmp, "w") as f: json.dump(meta, f, ensure_ascii=False, indent=1) os.replace(tmp, _model_path("champion.json", head)) return meta def _heads(): """Реестр голов: dataset() → [(item, y, w, src)], featurize(items) → (X, em).""" return { HEAD: {"dataset": lambda: [((r[0], r[1]), r[2], r[3], r[4]) for r in dataset()], "featurize": featurize, "nf": NF, "flags": FLAG_NAMES, "ts": lambda it: it[0].get("ts")}, TASK_HEAD: {"dataset": task_dataset, "featurize": task_featurize, "nf": len(TASK_FLAGS), "flags": TASK_FLAGS, "ts": lambda t: t.get("ts")}, DEPTH_HEAD: {"dataset": depth_dataset, "featurize": depth_featurize, "nf": len(DEPTH_FLAGS), "flags": DEPTH_FLAGS, "ts": lambda it: it.get("ts")}, } def _logloss(p, y, w) -> float: p = np.clip(p, 1e-6, 1 - 1e-6) return float(-(w * (y * np.log(p) + (1 - y) * np.log(1 - p))).sum() / w.sum()) def train(log=print, head: str = HEAD) -> dict: """Чемпион/претендент без утечки. Чемпион обучен на всём, что видел до trained_upto, — сравнивать его на этих же данных нечестно (он видел ответы и выигрывал всегда: прод 2026-10-17, 0.745 против 0.81). Поэтому: - свежих (после trained_upto) ≥ FRESH_MIN → холдаут = свежие; честно сравниваются претендент (обучен на остальном), чемпион и база; - иначе холдаут = последние 20%; чемпиона не сравнить — он остаётся, претендент без чемпиона проходит только при победе над базой; - база — константа = доля класса в обучении; ученик, не бьющий базу, — шум с уверенным лицом: не промоутится, а чемпион на свежих хуже базы снимается (ученик молчит, решает учитель/правило). """ spec = _heads()[head] rows = spec["dataset"]() if len(rows) < MIN_TRAIN: log(f"[core] {head}: примеров {len(rows)} < {MIN_TRAIN} — обучение отложено") return {"head": head, "promoted": False, "n": len(rows), "reason": "few_samples"} X, em = spec["featurize"]([r[0] for r in rows]) y = np.asarray([r[1] for r in rows], np.float64) w = np.asarray([r[2] for r in rows], np.float64) ts = [spec["ts"](r[0]) or "" for r in rows] nf = spec["nf"] meta0 = _champion_meta(head) champ = load_champion(head) # чемпионы до появления trained_upto: всё до момента обучения он видел upto = meta0.get("trained_upto") or meta0.get("ts") or "" fresh = np.asarray([t > upto for t in ts]) if (champ is not None and upto) else None if fresh is not None and fresh.sum() >= FRESH_MIN: hold, train_m, fair = fresh, ~fresh, True else: cut = max(int(len(rows) * 0.8), len(rows) - max(10, len(rows) // 5)) hold = np.arange(len(rows)) >= cut train_m, fair = ~hold, False ch = train_logreg(X[train_m], y[train_m], w[train_m], nf=nf) ch["mem"] = em[train_m] yh, wh = y[hold], w[hold] m_ch = metrics(ch, X[hold], yh, wh) p_h = predict(ch, X[hold]) 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) == (yh[sure] >= .5)).mean()), 3) if sure.any() else None) m_ch["base_rate"] = round(float((yh >= .5).mean()), 3) pb = float(np.clip((w[train_m] * y[train_m]).sum() / w[train_m].sum(), 1e-3, 1 - 1e-3)) m_ch["baseline_logloss"] = round(_logloss(np.full(len(yh), pb), yh, wh), 4) nov_h = novelty(ch, em[hold]) nov_q = np.asarray([np.quantile(nov_h, .75), np.quantile(nov_h, .97)]) m_ch["nov_q"] = [round(float(v), 4) for v in nov_q] m_ch["fair_vs_champion"] = fair m_old = None if champ is not None and fair: try: m_old = metrics(champ, X[hold], yh, wh) except Exception: m_old = None # другой формат признаков — сравнить нельзя beats_base = m_ch["logloss"] < m_ch["baseline_logloss"] - 1e-4 if champ is None: better = beats_base elif fair: better = beats_base and (m_old is None or m_ch["logloss"] < m_old["logloss"] - 1e-4) else: better = False # чемпиона не сравнить честно — ждём свежих данных retired = False if (not better and fair and m_old is not None and m_old["logloss"] >= m_ch["baseline_logloss"] - 1e-4): retire(head, "на свежих данных не лучше базовой линии", m_old, m_ch["baseline_logloss"]) retired = True srcs = {} for r in rows: srcs[r[3]] = srcs.get(r[3], 0) + 1 rep = {"head": head, "n": len(rows), "holdout": m_ch, "champion_holdout": m_old, "sources": srcs, "promoted": bool(better), "beats_baseline": bool(beats_base), "retired": retired} if better: full = train_logreg(X, y, w, nf=nf) full["mem"] = em full["nov_q"] = nov_q meta = _save(full, m_ch, len(rows), head, spec["flags"], trained_upto=max(ts)) _forget(head) 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}) verdict = ("ПРОМОУТ" if better else "снят: на свежих хуже базы" if retired else "не лучше базовой линии — без промоута" if not beats_base else "чемпион остаётся (мало свежих для честного сравнения)" if not fair else "чемпион остаётся") log(f"[core] {head}: n={len(rows)} претендент {m_ch} чемпион {m_old} → {verdict}") return rep def retire(head: str, reason: str, holdout=None, baseline=None) -> dict: """Снять чемпиона: ученик молчит, решает учитель/правило. Откат — rollback.""" meta0 = _champion_meta(head) if not meta0.get("file"): return {"ok": False, "reason": "чемпиона нет"} tmp = _model_path(".champion.tmp", head) with open(tmp, "w") as f: json.dump({"file": None, "retired": meta0.get("file"), "reason": reason, "ts": datetime.now(timezone.utc).isoformat(), "history": ([{k: meta0[k] for k in ("file", "sha256", "metrics", "ts", "trained_upto") if k in meta0}] + meta0.get("history", []))[:10]}, f, ensure_ascii=False, indent=1) os.replace(tmp, _model_path("champion.json", head)) _forget(head) es.append_event("core", f"retire:{head}:{int(time.time())}", "CoreModelRetired", { "head": head, "file": meta0.get("file"), "reason": reason, "holdout": holdout, "baseline_logloss": baseline}) return {"ok": True, "retired": meta0.get("file")} def train_all(log=print) -> list: """Ночная тренировка всех голов (дневной тик).""" out = [] for head in _heads(): try: out.append(train(log=log, head=head)) except Exception as e: log(f"[core] {head}: тренировка упала ({e})") out.append({"head": head, "error": str(e)[:200]}) return out def rollback(head: str = HEAD) -> dict: meta = _champion_meta(head) 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", head) with open(tmp, "w") as f: json.dump(new, f, ensure_ascii=False, indent=1) os.replace(tmp, _model_path("champion.json", head)) _forget(head) 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() p, nov = None, 1.0 # без чемпиона эмбеддинги не нужны: всё «незнакомо» if model is not None: X, em = featurize([(m, prior)]) p = float(predict(model, X)[0]) nov = float(novelty(model, em)[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 # ────────────────────────── голова task_fit ────────────────────────── # Влезет ли S-задача в мин-тик с первой попытки (бюджет 30 шагов) или ей # нужен час. Метки — сами из истории задач: 1 — закрыта на min с одного # захвата; 0 — повторные захваты на min / закрыта другим уровнем / эскалация. # На истории: 98 S-задач, 45 влезли, 53 нет. В on — уверенное «не влезет» # сразу S→M (минуя пустые мин-попытки); правило эскалации — страховка. # Rule-ветка (рефлексия t-261017093718, 93 метки): ученик не уверен или # чемпиона нет → решает правило «внешние системы (портал/сервер/деплой/ # UI/git) в тексте → не влезет» (precision 0.74 / recall 0.52; ML-ученик # logloss 0.849 хуже базы 0.707 — не промоутится). Режим дефолт shadow: # правило пишется в CorePrediction (route=rule:ext), включается SIMBIOT_CORE_TASK_FIT=on. TASK_HEAD = "task_fit" TASK_FLAGS = ("details_len", "code_paths", "external_systems", "est_minutes", "await_delivery") _CODE_RE = re.compile(r"/root|/var/|/opt/|\.py\b|\.js\b|\.html\b|app\.py|static/", re.IGNORECASE) _EXT_RE = re.compile(r"портал|cil\.by|сервер|ssh|деплой|deploy|миграц|панел|panel|кабинет|" r"фронт|ui\b|дашборд|dashboard|репо|git", re.IGNORECASE) def task_history() -> dict: """{task_id: {title, details, size, est, await, claims:[level], done_level, ts}}.""" T = {} for aid, et, ts, d in _q("SELECT aggregate_id, event_type, timestamp, data FROM events " "WHERE aggregate='task' ORDER BY id"): d = json.loads(d) if et == "TaskSubmitted": T[aid] = {"id": aid, "title": d.get("title") or "", "details": d.get("details") or "", "size": d.get("size"), "est": d.get("est_minutes"), "await": False, "claims": [], "done_level": None, "ts": ts, "escalated": False} elif aid in T: t = T[aid] if et == "TaskAwaitDelivery": t["await"] = True elif et == "TaskClaimed": t["claims"].append(d.get("level")) elif et == "TaskCompleted": t["done_level"] = t["claims"][-1] if t["claims"] else None elif et == "TaskPromoted" and d.get("from") != d.get("to"): t["escalated"] = True return T def task_label(t: dict): """1/0 — исход S-задачи на мин-уровне; None — исход ещё не ясен.""" if t.get("size") != "S" or not t["claims"] or t["claims"][0] != "min": return None if t["done_level"] == "min" and t["claims"].count("min") == 1: return 1.0 if t["claims"].count("min") >= 2 or t["escalated"] or (t["done_level"] and t["done_level"] != "min"): return 0.0 return None def task_dataset(): rows = [] for t in sorted(task_history().values(), key=lambda t: t["ts"]): y = task_label(t) if y is not None: rows.append((t, y, 1.0, "outcome")) return rows def task_flags(t: dict) -> list: text = f"{t.get('title') or ''}\n{t.get('details') or ''}" est = t.get("est") return [min(len(t.get("details") or "") / 1000.0, 1.0), 1.0 if _CODE_RE.search(text) else 0.0, 1.0 if _EXT_RE.search(text) else 0.0, min(float(est) / 60.0, 1.0) if isinstance(est, (int, float)) and est > 0 else 0.0, 1.0 if t.get("await") else 0.0] def task_featurize(items: list): texts = [f"задача: {t.get('title') or ''} — {(t.get('details') or '')[:600]}" for t in items] vecs = embed(texts) n = len(items) em = np.stack(vecs) if n else np.zeros((0, EMB_DIM), np.float32) fl = np.asarray([task_flags(t) for t in items], dtype=np.float32).reshape(n, len(TASK_FLAGS)) return np.hstack([em, fl]), em def task_shadow(task: dict, level: str): """Прогноз «влезет в мин-тик» для S-задачи перед первым мин-захватом. Возвращает {"p_fit", "route", ...} или None. В on уверенное «не влезет» → вызывающий поднимает S→M до клейма. CorePrediction — один раз на задачу. Rule-ветка (рефлексия t-261017093718): без чемпиона/вне зоны уверенности ученика решает детерминированное правило «внешние системы → не влезет» (портал/сервер/деплой/UI/git в тексте задачи; история S-задач: precision 0.74, recall 0.52 — см. комментарий головы task_fit). """ m = mode(TASK_HEAD) if m == "off" or level != "min" or task.get("size") != "S": return None text = f"{task.get('title') or ''}\n{task.get('details') or ''}" try: model = load_champion(TASK_HEAD) p = nov = None if model is not None: t = {"title": task.get("title"), "details": task.get("details"), "est": task.get("est_minutes"), "await": task.get("await_delivery")} X, em = task_featurize([t]) p = float(predict(model, X)[0]) nov = float(novelty(model, em)[0]) except Exception as e: return {"error": str(e)[:200]} ext = len(_EXT_RE.findall(text)) if p is not None and nov is not None: fam = float(model["nov_q"][0]) if nov < fam and abs(p - .5) >= CONF: # ученик уверен — он и решает r, act = "student", bool(m == "on" and p < .5) elif ext: # серая зона + правило p, r, act = 0.2, "rule:ext", bool(m == "on") else: r, act = "rule", False else: # чемпиона нет — только правило r, act = ("rule:ext", bool(m == "on")) if ext else ("rule", False) rec = {"head": TASK_HEAD, "task": task.get("id"), "p_fit": round(p, 4) if p is not None else None, "novelty": round(nov, 4) if nov is not None else None, "route": r, "mode": m, "ext_hits": ext, "act": act} _once(f"pred:{TASK_HEAD}:{task.get('id')}", "CorePrediction", rec) return rec # ────────────────────────── голова depth ────────────────────────── # Нужна ли глубокая память (D2+) — harness/memory.py. Метки: решение правил # (учитель, вес 1) и поправка из трассы тика: на D0/D1 агент полез в базу # знаний / свои дайджесты / «не помню» → глубины не хватило (1, вес 3). # shadow: прогноз пишется в DepthChosen; on: уверенное «нужно» поднимает D1→D2. DEPTH_HEAD = "depth" DEPTH_FLAGS = ("level", "circle", "past_words", "archive_words", "has_reply_full", "has_tasks") _LEVEL_IDX = {"sec": 0.0, "min": 1 / 3, "hour": 2 / 3, "day": 1.0} def depth_dataset(): rows = [] for ts, d in _q("SELECT timestamp, data FROM events WHERE event_type='DepthChosen' ORDER BY id"): d = json.loads(d) if d.get("level") != "min": continue # час/день — D2/D3 по уровню, учиться нечему item = {"query": d.get("query") or "", "level": d.get("level"), "circle": d.get("circle") or 1, "ts": ts, "reasons": " ".join(d.get("reasons") or [])} if d.get("depth", 1) < 2 and d.get("under"): rows.append((item, 1.0, 3.0, "under")) else: rows.append((item, 1.0 if d.get("depth", 1) >= 2 else 0.0, 1.0, "rules")) return rows def depth_flags(it: dict) -> list: from . import memory q = it.get("query") or "" return [_LEVEL_IDX.get(it.get("level"), 1 / 3), (it.get("circle") or 1) / 4.0, 1.0 if memory.PAST_RE.search(q) else 0.0, 1.0 if memory.ARCHIVE_RE.search(q) else 0.0, 1.0 if "давнее" in (it.get("reasons") or "") else 0.0, 1.0 if "задачи" in (it.get("reasons") or "") else 0.0] def depth_featurize(items: list): vecs = embed([(it.get("query") or "(пусто)")[:1500] for it in items]) n = len(items) em = np.stack(vecs) if n else np.zeros((0, EMB_DIM), np.float32) fl = np.asarray([depth_flags(it) for it in items], dtype=np.float32).reshape(n, len(DEPTH_FLAGS)) return np.hstack([em, fl]), em def depth_predict(item: dict): """(p_нужна_глубина, уверен?) или None — ученика нет/выключен.""" if mode(DEPTH_HEAD) == "off": return None model = load_champion(DEPTH_HEAD) if model is None: return None X, em = depth_featurize([item]) p = float(predict(model, X)[0]) nov = float(novelty(model, em)[0]) return p, bool(nov < float(model["nov_q"][0]) and abs(p - .5) >= CONF) # ────────────────────────── рефлексия и отчёт ────────────────────────── # Как reflexion.py бенча: системная ошибка → не молча копить, а принести # человеку правило. Без новых LLM-путей: досье уходит задачей часовому # уровню (с автодоставкой итога в чат) — он разбирает случаи и предлагает # Alex правило/навык. Порог — SIMBIOT_CORE_REFLECT_MIN (3) случаев с # прошлой рефлексии этой темы. def _last(etype: str, key: str): """Время прошлой рефлексии темы; первой — окно 7 дней (не тащить всю историю, уже исправленную, в досье).""" row = _q("SELECT MAX(timestamp) FROM events WHERE event_type=? " "AND json_extract(data,'$.topic')=?", (etype, key)) return row[0][0] or (datetime.now(timezone.utc) - timedelta(days=7)).isoformat() def _reflect_cases_addressing(since: str) -> list: """Где учитель/правило ошиблись: метка человека или чата против вердикта.""" out = [] for chat, msgs in group_timeline(since).items(): for m in msgs: if m.get("addressed") is None: continue labs = [l for l in m.get("labels", []) if l["source"] in ("human", "feedback")] if labs and (labs[-1]["y"] >= .5) != bool(m["addressed"]): out.append({"msg": m["id"], "who": m.get("first_name"), "text": (m.get("text") or "")[:200], "rule": m.get("rule"), "verdict": "ответил" if m["addressed"] else "промолчал", "truth": "было боту" if labs[-1]["y"] >= .5 else "было не боту"}) return out def _reflect_cases_tasks(since: str) -> list: """S-задачи, поданные после прошлой рефлексии, которые не влезли в мин-тик.""" done = _reflected_tasks() return [{"task": t["id"], "title": t["title"][:120], "min_attempts": t["claims"].count("min")} for t in task_history().values() if t["ts"] > since and t["id"] not in done and (t["escalated"] or t["claims"].count("min") >= 2)] def _reflected_tasks() -> set: got = set() for (d,) in _q("SELECT data FROM events WHERE event_type='CoreReflection' " "AND json_extract(data,'$.topic')='task_fit'"): got.update(json.loads(d).get("cases") or []) return got def reflect(log=print) -> list: """Досье системных ошибок → задача часу с автодоставкой. Возвращает id задач.""" from . import commands limit = int(os.environ.get("SIMBIOT_CORE_REFLECT_MIN", "3")) made = [] since = _last("CoreReflection", HEAD) cases = _reflect_cases_addressing(since) if len(cases) >= limit: lines = [f"- {c['who']}: «{c['text']}» — я {c['verdict']} (правило {c['rule']}), " f"а {c['truth']}" for c in cases[:20]] tid, _ = commands.submit_task( "Рефлексия адресации: где я ошибся в группе — предложи правило", "Ядро Коди собрало случаи, где решение «отвечать/молчать» разошлось с " "твоей отметкой или реакцией чата:\n" + "\n".join(lines) + "\n\nРазбери, что их объединяет, и предложи Alex 1–3 конкретных правила " "(текстом в чат: формулировка + пример). Ничего в коде не меняй — правила " "внедряются после одобрения.", minutes=30, source="core:reflect") es.append_event("task", tid, "TaskAwaitDelivery", {"reply_to": None, "reason": "рефлексия ядра"}) es.append_event("core", f"reflect:{HEAD}:{tid}", "CoreReflection", {"topic": HEAD, "task": tid, "cases": [c["msg"] for c in cases]}) made.append(tid) tcases = _reflect_cases_tasks(_last("CoreReflection", TASK_HEAD)) if len(tcases) >= limit: lines = [f"- {c['title']} ({c['min_attempts']} попыток на мин-уровне)" for c in tcases[:20]] tid, _ = commands.submit_task( "Рефлексия задач: какие S-задачи не влезают в минутный тик — предложи правило", "Эти задачи были поданы как S, но не уложились в мин-тик (30 шагов) и " "ушли выше:\n" + "\n".join(lines) + "\n\nНайди общее (тип работы, нужный доступ, чужой код…) и предложи Alex " "правило постановки/оценки размера (текстом в чат). Код не меняй.", minutes=30, source="core:reflect") es.append_event("task", tid, "TaskAwaitDelivery", {"reply_to": None, "reason": "рефлексия ядра"}) es.append_event("core", f"reflect:{TASK_HEAD}:{tid}", "CoreReflection", {"topic": TASK_HEAD, "task": tid, "cases": [c["task"] for c in tcases]}) made.append(tid) if made: log(f"[core] рефлексия: задачи {made}") return made def weekly_report(force: bool = False) -> "str | None": """Недельный отчёт ядра Alex — детерминированный текст, без LLM. Раз в ISO-неделю (идемпотентно по ключу доставки), в понедельник.""" now = datetime.now(timezone.utc) if not force and now.weekday() != 0: return None st = status() a, t = st[HEAD], st[TASK_HEAD] week = now.strftime("%G-W%V") since = (now - timedelta(days=7)).isoformat() prom = [json.loads(d) for (d,) in _q("SELECT data FROM events WHERE event_type=" "'CoreModelPromoted' AND timestamp > ?", (since,))] refl = _q("SELECT COUNT(*) FROM events WHERE event_type='CoreReflection' AND timestamp > ?", (since,))[0][0] fb = _q("SELECT COUNT(*) FROM events WHERE event_type='CoreFeedback' AND timestamp > ?", (since,))[0][0] def pct(v): return "—" if v is None else f"{v * 100:.0f}%" am, tm = a.get("champion_metrics") or {}, t.get("champion_metrics") or {} text = "\n".join([ f"🧠 Ядро Коди — неделя {week}", "", f"Адресация в группах ({a['mode']}): ученик на {a.get('champion_n') or 0} примерах, " f"совпадение с учителем на свежих {pct(am.get('acc'))}; за неделю прогнозов " f"{a['predictions_7d']}, решил бы сам {pct(a.get('would_be_local'))}, " f"согласие с учителем {pct(a.get('agree_with_teacher_7d'))}.", f"Метки: от тебя {a['labels'].get('human', 0)}, от реакции чата за неделю {fb}; " f"вопросов тебе {a['questions_7d']}.", f"Задачи ({t['mode']}): влезет ли S в мин-тик — точность на свежих " f"{pct(tm.get('acc'))} при базовой {pct(tm.get('base_rate'))}; сверено с исходом " f"{t.get('judged_7d', 0)}, верно {pct(t.get('outcome_acc_7d'))}.", f"Смен чемпиона за неделю: {len(prom)}; рефлексий (правил на разбор): {refl}.", "", "Включить ученика вместо учителя: SIMBIOT_CORE_ADDRESSING=on / SIMBIOT_CORE_TASK_FIT=on.", ]) from . import commands, tg_inbox ok = commands.deliver_artifact(f"msg:core-report:{week}", kind="message", owner="core:weekly", caption=text, send=lambda: bool(tg_inbox.notify_chat(text))) return text if ok else None def status() -> dict: 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,))] out = {} for head in _heads(): meta = _champion_meta(head) hp = [p for p in preds if p.get("head", HEAD) == head] out[head] = {"mode": mode(head), "champion": meta.get("file"), "champion_metrics": meta.get("metrics"), "champion_n": meta.get("n"), "champion_ts": meta.get("ts"), "predictions_7d": len(hp), "would_be_local": (round(sum(p["route"] == "student" for p in hp) / len(hp), 3) if hp else None)} a = out[HEAD] ap = [p for p in preds if p.get("head", HEAD) == HEAD] agree = [p["agree"] for p in ap 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 a.update(labels=srcs, agree_with_teacher_7d=(round(sum(agree) / len(agree), 3) if agree else None), would_ask_human_7d=sum(p["route"] == "human" for p in ap), questions_7d=_q("SELECT COUNT(*) FROM events WHERE event_type='CoreQuestionAsked' " "AND timestamp > ?", (day,))[0][0]) # task_fit: сверка теневых прогнозов с фактическим исходом hist = task_history() tp = [p for p in preds if p.get("head") == TASK_HEAD] judged = [(p, task_label(hist[p["task"]])) for p in tp if p.get("task") in hist] # прогноз «влезет» = p_fit ≥ 0.5; у rule-ветки без чемпиона p_fit=None — # её прогноз: rule:ext → «не влезет», rule → «влезет» def _says_fit(p): if p.get("p_fit") is not None: return p["p_fit"] >= .5 return p.get("route") != "rule:ext" judged = [(p, y) for p, y in judged if y is not None] out[TASK_HEAD]["outcome_acc_7d"] = (round(sum(_says_fit(p) == (y >= .5) for p, y in judged) / len(judged), 3) if judged else None) out[TASK_HEAD]["judged_7d"] = len(judged) return out