Куб Alex: круг доверия × тик (sec/min/hour/day) × глубина памяти — одна таблица политик, не 64 программы. - круги 4: 1 владелец (+система: тик без внешних входов), 2 правление, 3 свои (портал, MCP-агенты, партнёры), 4 остальные; trust.norm для старых номеров (в сторе только 1/2 — смысл тот же); потолок памяти круга; - harness/memory.py: индекс собственной истории (<home>/memory) — разговоры (сообщение + ответ; батч — ответ головы), свои рассылки (не ответы), итоги задач, дайджесты без заглушек; nomic search_document/query; КРУГ ПРИВАТНОСТИ записи = самый недоверенный участник чата (личка Alex — 1, «Правление» — 2, задача — её круг, дайджест — 1); поиск отдаёт только записи круга ≥ круга тика — тик с правлением не поднимет личку владельца; SIMBIOT_MEMORY=on|off (дефолт off — тесты герметичны); - глубина: D0 односложные / D1 разговор / D2 + эпизоды (8) / D3 + архив (15): уровень (час D2, день D3), «помнишь/вчера/как договаривались» → D2, «за месяц/что делал/итоги» → D3, задачи → D2, reply на давнее (>48 ч) сообщение бота → D2; потолок круга (свои — D2, остальные — D1); - DepthChosen по трассе (rag/копание в своём сторе/«не помню» = нехватка) → голова ядра depth (shadow; on — уверенный ученик поднимает до D2); - выжимки нижних уровней: проекция не хранила stub — заглушки «LLM не запускался» забивали контекст; теперь отсеиваются; - ядро: чтение чемпиона больше не создаёт каталоги, без чемпиона — без эмбеддингов (тесты создавали core/ в репо и ходили в Ollama). Тесты: test_memory 30, test_trust 55 PASS; полный набор — без регрессий. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1115 lines
57 KiB
Python
1115 lines
57 KiB
Python
"""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_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"(?<!\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, 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
|