simbiot-harness/harness/kodi_core.py
simbiot-harness a163e88d7b task_fit: rule-ветка «внешние системы → не влезет в мин-тик» (рефлексия t-261017093718)
93 размеченных S-задачи: ext>=1 precision 0.74 / recall 0.52; ML-ученик
logloss 0.849 хуже базы 0.707 — чемпион не промоутится. Без чемпиона /
вне зоны уверенности ученика решает детерминированное правило: портал/
сервер/деплой/UI/git в тексте S-задачи → S→M до мин-клейма (route=rule:ext,
act только в SIMBIOT_CORE_TASK_FIT=on; дефолт shadow — прогноз пишется,
батч не трогается). Уверенный ученик (student) приоритетнее правила.
2026-10-17 10:06:40 +00:00

1034 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 на том же холдауте; файл модели в <home>/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_dir() -> str:
d = (os.environ.get("SIMBIOT_CORE_DIR", "").strip()
or os.path.join(_paths.home(), "core"))
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) -> 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"(?<!\w){re.escape(n)}(?!\w)", text, re.IGNORECASE)
for n in others if n and len(n) > 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):
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)) 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)
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")},
}
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()
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
# ────────────────────────── голова 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
# ────────────────────────── рефлексия и отчёт ──────────────────────────
# Как 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]
judged = [(p, y) for p, y in judged if y is not None]
out[TASK_HEAD]["outcome_acc_7d"] = (round(sum((p["p_fit"] >= .5) == (y >= .5) for p, y in judged)
/ len(judged), 3) if judged else None)
out[TASK_HEAD]["judged_7d"] = len(judged)
return out