harness/kodi_core.py — цикл engine42/router42 бенча на решениях Коди, первая голова addressing (отвечать ли в группе): - опыт — подписанные события core: CoreLabel (учитель по истории; человек — кнопки «Это было мне?» в личку, ≤5/сут), CoreFeedback (неявно из чата: ответил, а следом «не тебе/продолжает» → 0; промолчал, а автор тут же «@juno42bot»/«Коди?» → 1), живые вердикты учителя; веса: учитель 1, обратная связь 3, человек 5; - признаки: nomic-embed-text сообщения (локально, кеш) + 8 флагов; контекстный эмбеддинг убран — шумел (5 фолдов 0.822 → 0.845 без него); - ученик: логрегрессия (numpy, Adam, L2) — эмбеддинги без поразмерной стандартизации (раздувала шум на сотнях примеров); kNN-память новизны, пороги новизны калибруются на холдауте (у nomic она 0.03–0.09); - роутер: знакомо и |p−0.5| ≥ 0.4 → ученик; незнакомо и учитель сомневается → вопрос Alex; иначе учитель. SIMBIOT_CORE_ADDRESSING= shadow (дефолт: решает учитель, ученик пишет CorePrediction) | on | off; - тренировка в дневной тик: временной холдаут, претендент против чемпиона по log-loss, CoreModelPromoted с sha256; подмена файла → ученика нет; cli.py core status|train|backfill|feedback|rollback. Репетиция на истории правления (259 примеров): холдаут — совпадение с учителем 73%, уверенная доля 4% при 100% совпадения; 5 фолдов при CONF 0.4 — покрытие 25%, совпадение 95%. До включения нужны метки. Поллер: callback_query (allowed_updates явно). numpy — в requirements. Тест: test_kodi_core 28 PASS; полный набор — без регрессий. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
665 lines
32 KiB
Python
665 lines
32 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
|
||
WEIGHTS = {"teacher": 1.0, "teacher_backfill": 1.0, "feedback": 3.0, "human": 5.0}
|
||
|
||
NEG_RE = re.compile(r"не\s+(?:тебе|к\s+тебе|тебя\s+спрашива)|молчи|не\s+встревай|"
|
||
r"заткн|продолжает|опять\s+(?:встрял|отвечает|лезет)|не\s+лезь",
|
||
re.IGNORECASE)
|
||
SECOND_RE = re.compile(r"\b(?:ты|тебе|тебя|твой|твоя|твоё|можешь|сделай|покажи|скинь|"
|
||
r"пришли|напиши|ответь|глянь|посмотри|расскажи|подскажи|найди|"
|
||
r"создай|собери|проверь)\b", re.IGNORECASE)
|
||
LINK_RE = re.compile(r"https?://")
|
||
|
||
_LOCK = threading.Lock()
|
||
_STATE = {"model": None, "loaded_sha": None}
|
||
|
||
|
||
# ────────────────────────── конфиг/пути ──────────────────────────
|
||
|
||
def mode() -> str:
|
||
from . import decision
|
||
v = decision._cfg("SIMBIOT_CORE_ADDRESSING", "shadow").lower()
|
||
return v if v in ("on", "shadow", "off") else "shadow"
|
||
|
||
|
||
def core_dir() -> str:
|
||
d = (os.environ.get("SIMBIOT_CORE_DIR", "").strip()
|
||
or os.path.join(_paths.home(), "core"))
|
||
os.makedirs(os.path.join(d, HEAD), mode=0o700, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def _ollama_base() -> str:
|
||
from . import decision
|
||
return decision._cfg("SIMBIOT_EMBED_URL", "http://127.0.0.1:11434")
|
||
|
||
|
||
# ────────────────────────── эмбеддинги (кеш) ──────────────────────────
|
||
|
||
def _cache():
|
||
db = sqlite3.connect(os.path.join(core_dir(), "emb.sqlite"), timeout=30)
|
||
db.execute("CREATE TABLE IF NOT EXISTS emb (k TEXT PRIMARY KEY, v BLOB)")
|
||
return db
|
||
|
||
|
||
def embed(texts: list) -> list:
|
||
"""Эмбеддинги (нормированные списки float) с дисковым кешем; батч к Ollama."""
|
||
import numpy as np
|
||
keys = [hashlib.sha1(f"{EMB_MODEL}|{t}".encode()).hexdigest() for t in texts]
|
||
out = [None] * len(texts)
|
||
db = _cache()
|
||
try:
|
||
for i, k in enumerate(keys):
|
||
row = db.execute("SELECT v FROM emb WHERE k=?", (k,)).fetchone()
|
||
if row:
|
||
out[i] = np.frombuffer(row[0], dtype=np.float32)
|
||
miss = [i for i, v in enumerate(out) if v is None]
|
||
for s in range(0, len(miss), 32):
|
||
chunk = miss[s:s + 32]
|
||
body = json.dumps({"model": EMB_MODEL, "keep_alive": "2h",
|
||
"input": [f"classification: {texts[i][:2000]}" for i in chunk]})
|
||
req = urllib.request.Request(_ollama_base() + "/api/embed", data=body.encode(),
|
||
headers={"Content-Type": "application/json"})
|
||
with urllib.request.urlopen(req, timeout=60) as r:
|
||
vecs = json.loads(r.read().decode())["embeddings"]
|
||
for i, v in zip(chunk, vecs):
|
||
a = np.asarray(v, dtype=np.float32)
|
||
a /= (np.linalg.norm(a) or 1.0)
|
||
out[i] = a
|
||
db.execute("INSERT OR REPLACE INTO emb VALUES (?, ?)", (keys[i], a.tobytes()))
|
||
db.commit()
|
||
finally:
|
||
db.close()
|
||
return out
|
||
|
||
|
||
# ────────────────────────── опыт из стора ──────────────────────────
|
||
|
||
def _q(sql, args=()):
|
||
db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30)
|
||
try:
|
||
return db.execute(sql, args).fetchall()
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def _once(agg_id: str, etype: str, data: dict) -> bool:
|
||
"""Событие ядра ровно один раз на aggregate_id (метки/фидбек не дублируются)."""
|
||
if _q("SELECT 1 FROM events WHERE aggregate='core' AND aggregate_id=? LIMIT 1", (agg_id,)):
|
||
return False
|
||
es.append_event("core", agg_id, etype, data)
|
||
return True
|
||
|
||
|
||
def group_timeline(since: str = "") -> dict:
|
||
"""{chat_id: [msg, ...]} по времени: группы + вердикты + ответы бота + метки ядра."""
|
||
from .addressing import is_group
|
||
msgs, by_id = {}, {}
|
||
for aid, et, ts, d in _q(
|
||
"SELECT aggregate_id, event_type, timestamp, data FROM events WHERE "
|
||
"event_type IN ('MessageReceived','MessageTriaged','MessageIgnored','ReplySent',"
|
||
"'CoreLabel','CoreFeedback') AND timestamp > ? ORDER BY id", (since,)):
|
||
d = json.loads(d)
|
||
if et == "MessageReceived":
|
||
if not is_group(d):
|
||
continue
|
||
m = {**d, "id": aid, "ts": ts}
|
||
by_id[aid] = m
|
||
msgs.setdefault(d.get("chat_id"), []).append(m)
|
||
continue
|
||
if et in ("CoreLabel", "CoreFeedback"):
|
||
m = by_id.get(d.get("msg"))
|
||
if m is not None and d.get("head", HEAD) == HEAD:
|
||
m.setdefault("labels", []).append(
|
||
{"source": d.get("source") or "feedback", "y": float(d["label"]),
|
||
"ts": ts})
|
||
continue
|
||
m = by_id.get(aid)
|
||
if m is None:
|
||
continue
|
||
if et == "MessageTriaged":
|
||
m.update(addressed=True, rule=d.get("rule"), teacher_p=d.get("p"))
|
||
elif et == "MessageIgnored" and d.get("reason") == "not_addressed":
|
||
m.update(addressed=False, rule=d.get("rule"), teacher_p=d.get("p"))
|
||
elif et == "ReplySent":
|
||
t = d.get("text") or ""
|
||
if not t.startswith("(сводный"):
|
||
m["reply_text"] = t
|
||
m.setdefault("reply_ts", ts)
|
||
return msgs
|
||
|
||
|
||
def _was_addressed(m: dict) -> bool:
|
||
if m.get("addressed") is not None:
|
||
return bool(m["addressed"])
|
||
lab = [l for l in m.get("labels", []) if l["source"] == "teacher_backfill"]
|
||
return bool(lab and lab[-1]["y"] >= 0.5)
|
||
|
||
|
||
# ────────────────────────── признаки ──────────────────────────
|
||
|
||
def _ctx_lines(prior: list) -> list:
|
||
out = []
|
||
for x in prior[-3:]:
|
||
out.append(f"{x.get('first_name') or '?'}: "
|
||
f"{(x.get('text') or '[' + str(x.get('kind')) + ']')[:300]}")
|
||
if _was_addressed(x) and x.get("reply_text"):
|
||
out.append(f"Cody: {x['reply_text'][:200]}")
|
||
return out
|
||
|
||
|
||
def flags(m: dict, prior: list) -> list:
|
||
text = m.get("text") or ""
|
||
last = prior[-1] if prior else None
|
||
addressed_prior = [x for x in prior if _was_addressed(x)]
|
||
others = {x.get("first_name") for x in prior if x.get("first_name")} - {m.get("first_name")}
|
||
names_other = any(re.search(rf"(?<!\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 измерений на сотнях примеров раздувала
|
||
шумовые измерения до масштаба полезных — регрессия учила шум."""
|
||
Z = X.astype(np.float64, copy=True)
|
||
Z[:, :-NF] *= EMB_SCALE
|
||
Z[:, -NF:] = (Z[:, -NF:] - model["mu"]) / model["sd"]
|
||
return Z
|
||
|
||
|
||
def train_logreg(X, y, w, l2=0.05, epochs=800, lr=0.2):
|
||
"""Логистическая регрессия: взвешенный log-loss + L2, градиентный спуск (Adam)."""
|
||
fl = X[:, -NF:].astype(np.float64)
|
||
model = {"mu": fl.mean(0), "sd": fl.std(0) + 1e-6}
|
||
Z = _prep(model, X)
|
||
wt = np.zeros(Z.shape[1]); b = 0.0
|
||
sw = w / w.sum()
|
||
m1 = np.zeros_like(wt); v1 = np.zeros_like(wt); mb = vb = 0.0
|
||
for t in range(1, epochs + 1):
|
||
p = 1 / (1 + np.exp(-(Z @ wt + b)))
|
||
g = p - y
|
||
gw = Z.T @ (sw * g) + l2 * wt
|
||
gb = float((sw * g).sum())
|
||
m1 = .9 * m1 + .1 * gw; v1 = .999 * v1 + .001 * gw * gw
|
||
mb = .9 * mb + .1 * gb; vb = .999 * vb + .001 * gb * gb
|
||
wt -= lr * (m1 / (1 - .9 ** t)) / (np.sqrt(v1 / (1 - .999 ** t)) + 1e-8)
|
||
b -= lr * (mb / (1 - .9 ** t)) / (np.sqrt(vb / (1 - .999 ** t)) + 1e-8)
|
||
model.update(w=wt, b=b)
|
||
return model
|
||
|
||
|
||
def predict(model, X):
|
||
Z = _prep(model, X)
|
||
return 1 / (1 + np.exp(-(Z @ model["w"] + model["b"])))
|
||
|
||
|
||
def novelty(model, em):
|
||
"""1 − средняя косинусная близость к 5 ближайшим из обучающей памяти."""
|
||
import numpy as np
|
||
mem = model.get("mem")
|
||
if mem is None or not len(mem):
|
||
return np.ones(len(em))
|
||
sims = em @ mem.T
|
||
k = min(5, mem.shape[0])
|
||
top = -np.sort(-sims, axis=1)[:, :k]
|
||
return 1 - top.mean(1)
|
||
|
||
|
||
def metrics(model, X, y, w):
|
||
import numpy as np
|
||
p = np.clip(predict(model, X), 1e-6, 1 - 1e-6)
|
||
ll = float(-(w * (y * np.log(p) + (1 - y) * np.log(1 - p))).sum() / w.sum())
|
||
acc = float((w * ((p >= .5) == (y >= .5))).sum() / w.sum())
|
||
return {"logloss": round(ll, 4), "acc": round(acc, 4), "n": int(len(y))}
|
||
|
||
|
||
def _model_path(name):
|
||
return os.path.join(core_dir(), HEAD, name)
|
||
|
||
|
||
def _champion_meta():
|
||
try:
|
||
with open(_model_path("champion.json")) as f:
|
||
return json.load(f)
|
||
except (OSError, ValueError):
|
||
return {}
|
||
|
||
|
||
def load_champion():
|
||
"""Чемпион из файла (кеш по sha256) или None."""
|
||
import numpy as np
|
||
meta = _champion_meta()
|
||
if not meta.get("file"):
|
||
return None
|
||
if _STATE["loaded_sha"] == meta.get("sha256") and _STATE["model"] is not None:
|
||
return _STATE["model"]
|
||
path = _model_path(meta["file"])
|
||
with open(path, "rb") as f:
|
||
blob = f.read()
|
||
if hashlib.sha256(blob).hexdigest() != meta.get("sha256"):
|
||
return None # файл подменён — ученика нет, решает учитель
|
||
z = np.load(path)
|
||
model = {k: z[k] for k in ("w", "mu", "sd", "mem")}
|
||
model["b"] = float(z["b"])
|
||
model["nov_q"] = z["nov_q"] if "nov_q" in z else np.asarray([NOV_FAMILIAR, NOV_NOVEL])
|
||
_STATE.update(model=model, loaded_sha=meta["sha256"])
|
||
return model
|
||
|
||
|
||
def _save(model, metrics_, n_total):
|
||
import io
|
||
import numpy as np
|
||
name = f"model-{datetime.now(timezone.utc).strftime('%y%m%d%H%M%S')}.npz"
|
||
buf = io.BytesIO()
|
||
np.savez(buf, w=model["w"], b=np.float64(model["b"]), mu=model["mu"],
|
||
sd=model["sd"], mem=model["mem"],
|
||
nov_q=np.asarray(model.get("nov_q", [NOV_FAMILIAR, NOV_NOVEL])))
|
||
blob = buf.getvalue()
|
||
path = _model_path(name)
|
||
with open(path, "wb") as f:
|
||
f.write(blob)
|
||
os.chmod(path, 0o600)
|
||
sha = hashlib.sha256(blob).hexdigest()
|
||
prev = _champion_meta()
|
||
meta = {"file": name, "sha256": sha, "metrics": metrics_, "n": n_total,
|
||
"kind": "logreg", "features": {"emb": EMB_MODEL, "flags": FLAG_NAMES},
|
||
"ts": datetime.now(timezone.utc).isoformat(),
|
||
"history": ([{k: prev[k] for k in ("file", "sha256", "metrics", "ts") if k in prev}]
|
||
+ prev.get("history", []))[:10] if prev.get("file") else []}
|
||
tmp = _model_path(".champion.tmp")
|
||
with open(tmp, "w") as f:
|
||
json.dump(meta, f, ensure_ascii=False, indent=1)
|
||
os.replace(tmp, _model_path("champion.json"))
|
||
return meta
|
||
|
||
|
||
def train(log=print) -> dict:
|
||
"""Претендент против чемпиона на временном холдауте; лучше — промоут."""
|
||
import numpy as np
|
||
rows = dataset()
|
||
if len(rows) < MIN_TRAIN:
|
||
log(f"[core] {HEAD}: примеров {len(rows)} < {MIN_TRAIN} — обучение отложено")
|
||
return {"promoted": False, "n": len(rows), "reason": "few_samples"}
|
||
X, em = featurize([(r[0], r[1]) for r in rows])
|
||
y = np.asarray([r[2] for r in rows], np.float64)
|
||
w = np.asarray([r[3] for r in rows], np.float64)
|
||
cut = max(int(len(rows) * 0.8), len(rows) - max(10, len(rows) // 5))
|
||
ch = train_logreg(X[:cut], y[:cut], w[:cut])
|
||
ch["mem"] = em[:cut]
|
||
m_ch = metrics(ch, X[cut:], y[cut:], w[cut:])
|
||
nov_h = novelty(ch, em[cut:])
|
||
nov_q = np.asarray([np.quantile(nov_h, .75), np.quantile(nov_h, .97)])
|
||
p_h = predict(ch, X[cut:])
|
||
sure = np.abs(p_h - .5) >= CONF
|
||
m_ch["confident_share"] = round(float(sure.mean()), 3)
|
||
m_ch["confident_acc"] = (round(float(((p_h[sure] >= .5) == (y[cut:][sure] >= .5)).mean()), 3)
|
||
if sure.any() else None)
|
||
m_ch["nov_q"] = [round(float(v), 4) for v in nov_q]
|
||
champ = load_champion()
|
||
m_old = None
|
||
if champ is not None:
|
||
try:
|
||
m_old = metrics(champ, X[cut:], y[cut:], w[cut:])
|
||
except Exception:
|
||
m_old = None # другой формат признаков — сравнивать нельзя, меняем
|
||
better = m_old is None or m_ch["logloss"] < m_old["logloss"] - 1e-4
|
||
srcs = {}
|
||
for r in rows:
|
||
srcs[r[4]] = srcs.get(r[4], 0) + 1
|
||
rep = {"head": HEAD, "n": len(rows), "holdout": m_ch, "champion_holdout": m_old,
|
||
"sources": srcs, "promoted": bool(better)}
|
||
if better:
|
||
full = train_logreg(X, y, w)
|
||
full["mem"] = em
|
||
full["nov_q"] = nov_q
|
||
meta = _save(full, m_ch, len(rows))
|
||
_STATE.update(model=None, loaded_sha=None)
|
||
es.append_event("core", f"promote:{HEAD}:{meta['file']}", "CoreModelPromoted", {
|
||
"head": HEAD, "file": meta["file"], "sha256": meta["sha256"],
|
||
"holdout": m_ch, "champion_holdout": m_old, "n": len(rows), "sources": srcs})
|
||
log(f"[core] {HEAD}: n={len(rows)} претендент {m_ch} чемпион {m_old} → "
|
||
f"{'ПРОМОУТ' if better else 'чемпион остаётся'}")
|
||
return rep
|
||
|
||
|
||
def rollback() -> dict:
|
||
meta = _champion_meta()
|
||
hist = meta.get("history") or []
|
||
if not hist:
|
||
return {"ok": False, "reason": "истории нет"}
|
||
prev = hist[0]
|
||
new = {**prev, "history": hist[1:], "ts": datetime.now(timezone.utc).isoformat(),
|
||
"rolled_back_from": meta.get("file")}
|
||
tmp = _model_path(".champion.tmp")
|
||
with open(tmp, "w") as f:
|
||
json.dump(new, f, ensure_ascii=False, indent=1)
|
||
os.replace(tmp, _model_path("champion.json"))
|
||
_STATE.update(model=None, loaded_sha=None)
|
||
es.append_event("core", f"rollback:{HEAD}:{int(time.time())}", "CoreModelPromoted", {
|
||
"head": HEAD, "file": prev.get("file"), "sha256": prev.get("sha256"),
|
||
"rollback_from": meta.get("file")})
|
||
return {"ok": True, "file": prev.get("file")}
|
||
|
||
|
||
# ────────────────────────── живой контур ──────────────────────────
|
||
|
||
def route(p_student, nov, p_teacher, nov_q=None):
|
||
fam, novel = (float(nov_q[0]), float(nov_q[1])) if nov_q is not None else (NOV_FAMILIAR, NOV_NOVEL)
|
||
if p_student is not None and nov < fam and abs(p_student - 0.5) >= CONF:
|
||
return "student"
|
||
if nov >= novel and (p_teacher is None or 0.3 < p_teacher < 0.7):
|
||
return "human"
|
||
return "teacher"
|
||
|
||
|
||
def shadow(m: dict, prior: list, teacher_p, final: bool):
|
||
"""Прогноз ученика на живом сообщении серой зоны → CorePrediction (+ вопрос Alex)."""
|
||
if mode() == "off":
|
||
return None
|
||
try:
|
||
model = load_champion()
|
||
X, em = featurize([(m, prior)])
|
||
p = float(predict(model, X)[0]) if model is not None else None
|
||
nov = float(novelty(model, em)[0]) if model is not None else 1.0
|
||
except Exception as e:
|
||
return {"error": str(e)[:200]}
|
||
r = route(p, nov, teacher_p, model.get("nov_q") if model is not None else None)
|
||
mid = f"m-{m.get('update_id')}"
|
||
rec = {"head": HEAD, "msg": mid, "p_student": None if p is None else round(p, 4),
|
||
"novelty": round(nov, 4), "route": r, "teacher_p": teacher_p,
|
||
"final": bool(final), "mode": mode(),
|
||
"agree": None if p is None or teacher_p is None else (p >= .5) == (teacher_p >= .5)}
|
||
_once(f"pred:{HEAD}:{mid}", "CorePrediction", rec)
|
||
if r == "human":
|
||
try:
|
||
ask_human(m, prior)
|
||
except Exception:
|
||
pass # вопрос — бонус, не путь решения
|
||
return rec
|
||
|
||
|
||
def ask_human(m: dict, prior: list) -> bool:
|
||
"""Кнопки Alex в личку: «боту / не боту» — самая ценная метка (≤ N в сутки)."""
|
||
limit = int(os.environ.get("SIMBIOT_CORE_ASK_PER_DAY", "5"))
|
||
day = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||
asked = _q("SELECT COUNT(*) FROM events WHERE event_type='CoreQuestionAsked' "
|
||
"AND timestamp > ?", (day,))[0][0]
|
||
if asked >= limit:
|
||
return False
|
||
mid = f"m-{m.get('update_id')}"
|
||
if _q("SELECT 1 FROM events WHERE aggregate='core' AND aggregate_id=?", (f"ask:{mid}",)):
|
||
return False
|
||
from . import tg_inbox
|
||
chat = tg_inbox._default_chat_id()
|
||
if chat is None:
|
||
return False
|
||
ctx = "\n".join(_ctx_lines(prior)) or "(начало разговора)"
|
||
text = (f"🧠 Учусь понимать, когда ко мне обращаются в «{m.get('chat_title') or 'группе'}».\n\n"
|
||
f"Контекст:\n{ctx[-600:]}\n\n"
|
||
f"Сообщение — {m.get('first_name')}: «{(m.get('text') or '')[:400]}»\n\n"
|
||
"Это было мне?")
|
||
kb = {"inline_keyboard": [[
|
||
{"text": "Да, Коди", "callback_data": f"core:{HEAD}:{mid}:1"},
|
||
{"text": "Нет, не мне", "callback_data": f"core:{HEAD}:{mid}:0"}]]}
|
||
tg_inbox._load_env()
|
||
r = tg_inbox._api("sendMessage", {"chat_id": chat, "text": text,
|
||
"reply_markup": json.dumps(kb)}, timeout=15)
|
||
if (r or {}).get("ok"):
|
||
es.append_event("core", f"ask:{mid}", "CoreQuestionAsked", {"head": HEAD, "msg": mid})
|
||
return True
|
||
return False
|
||
|
||
|
||
def on_callback(cq: dict, allowed: set) -> bool:
|
||
"""Ответ Alex на кнопку → CoreLabel source=human. Возвращает True, если наш callback."""
|
||
data = cq.get("data") or ""
|
||
if not data.startswith("core:"):
|
||
return False
|
||
from . import tg_inbox
|
||
_, head, mid, label = data.split(":", 3)
|
||
user = (cq.get("from") or {}).get("id")
|
||
ok = not allowed or str(user) in allowed
|
||
if ok:
|
||
_once(f"label:{head}:{mid}:human", "CoreLabel",
|
||
{"head": head, "msg": mid, "label": int(label), "source": "human", "by": user})
|
||
try:
|
||
tg_inbox._api("answerCallbackQuery", {
|
||
"callback_query_id": cq.get("id"),
|
||
"text": ("Запомнил 👍" if ok else "Нет прав")}, timeout=10)
|
||
msg = cq.get("message") or {}
|
||
if ok and msg.get("message_id"):
|
||
tg_inbox._api("editMessageReplyMarkup", {
|
||
"chat_id": (msg.get("chat") or {}).get("id"),
|
||
"message_id": msg["message_id"],
|
||
"reply_markup": json.dumps({"inline_keyboard": [[{
|
||
"text": "✓ " + ("мне" if label == "1" else "не мне"),
|
||
"callback_data": "core:noop"}]]})}, timeout=10)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
|
||
|
||
def harvest_feedback(log=None) -> int:
|
||
"""Неявная обратная связь чата за последние 2 часа → CoreFeedback (один раз)."""
|
||
since = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
|
||
n = 0
|
||
for chat, msgs in group_timeline(since).items():
|
||
for i, m in enumerate(msgs):
|
||
if m.get("rule") not in ("decision", "no_decision", "core"):
|
||
continue
|
||
t0 = datetime.fromisoformat(m.get("reply_ts") or m["ts"])
|
||
nxt = [x for x in msgs[i + 1:]
|
||
if datetime.fromisoformat(x["ts"]) - t0 <= timedelta(minutes=5)]
|
||
if m.get("addressed") and m.get("reply_text"):
|
||
neg = next((x for x in nxt if NEG_RE.search(x.get("text") or "")), None)
|
||
if neg and _once(f"fb:{HEAD}:{m['id']}", "CoreFeedback", {
|
||
"head": HEAD, "msg": m["id"], "label": 0,
|
||
"reason": "neg: " + (neg.get("text") or "")[:120]}):
|
||
n += 1
|
||
elif m.get("addressed") is False:
|
||
poke = next((x for x in nxt if x.get("first_name") == m.get("first_name")
|
||
and x.get("rule") in ("mention", "name", "reply_to_bot")
|
||
and _is_poke(x)), None)
|
||
if poke and _once(f"fb:{HEAD}:{m['id']}", "CoreFeedback", {
|
||
"head": HEAD, "msg": m["id"], "label": 1,
|
||
"reason": "poke: " + (poke.get("text") or "")[:120]}):
|
||
n += 1
|
||
if n and log:
|
||
log(f"[core] обратная связь: {n}")
|
||
return n
|
||
|
||
|
||
def _is_poke(x: dict) -> bool:
|
||
"""«Дёрнул бота» сразу после — короткое «@juno42bot» / «Коди?» без новой темы."""
|
||
t = re.sub(r"@\w+|коди|cody", "", x.get("text") or "", flags=re.IGNORECASE)
|
||
return len(re.sub(r"[\W_]+", "", t)) <= 12
|
||
|
||
|
||
# ────────────────────────── бэкфилл учителем ──────────────────────────
|
||
|
||
def backfill(log=print, limit: int = 2000) -> int:
|
||
"""Учитель размечает историю серой зоны (как живой: ответы бота в истории —
|
||
только на адресованное по его же меткам) → CoreLabel source=teacher_backfill."""
|
||
from .addressing import model_verdict, rule_verdict
|
||
n = 0
|
||
for chat, msgs in group_timeline().items():
|
||
inbox = {}
|
||
for m in msgs:
|
||
inbox[m["id"]] = m
|
||
if m.get("rule") is not None or m.get("labels"):
|
||
continue # живой вердикт или метка уже есть
|
||
if rule_verdict(m)[0] is not None:
|
||
continue
|
||
p = model_verdict(m, {k: {**v, "addressed": _was_addressed(v)}
|
||
for k, v in inbox.items() if k != m["id"]})
|
||
if p is None:
|
||
continue
|
||
if _once(f"label:{HEAD}:{m['id']}:teacher", "CoreLabel", {
|
||
"head": HEAD, "msg": m["id"], "label": round(p, 4),
|
||
"source": "teacher_backfill"}):
|
||
m.setdefault("labels", []).append(
|
||
{"source": "teacher_backfill", "y": p, "ts": m["ts"]})
|
||
n += 1
|
||
if n >= limit:
|
||
return n
|
||
log(f"[core] бэкфилл учителем: {n} меток")
|
||
return n
|
||
|
||
|
||
def status() -> dict:
|
||
meta = _champion_meta()
|
||
day = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
|
||
preds = [json.loads(d) for (d,) in _q(
|
||
"SELECT data FROM events WHERE event_type='CorePrediction' AND timestamp > ?", (day,))]
|
||
agree = [p["agree"] for p in preds if p.get("agree") is not None]
|
||
srcs = {}
|
||
for (d,) in _q("SELECT data FROM events WHERE event_type IN ('CoreLabel','CoreFeedback')"):
|
||
s = json.loads(d).get("source") or "feedback"
|
||
srcs[s] = srcs.get(s, 0) + 1
|
||
return {
|
||
"head": HEAD, "mode": mode(), "champion": meta.get("file"),
|
||
"champion_metrics": meta.get("metrics"), "champion_n": meta.get("n"),
|
||
"champion_ts": meta.get("ts"), "labels": srcs,
|
||
"shadow_7d": {"predictions": len(preds),
|
||
"agree_with_teacher": (round(sum(agree) / len(agree), 3) if agree else None),
|
||
"would_be_local": (round(sum(p["route"] == "student" for p in preds)
|
||
/ len(preds), 3) if preds else None),
|
||
"would_ask_human": sum(p["route"] == "human" for p in preds)},
|
||
"questions_7d": _q("SELECT COUNT(*) FROM events WHERE event_type='CoreQuestionAsked' "
|
||
"AND timestamp > ?", (day,))[0][0],
|
||
}
|