ядро Коди: обучаемый ученик адресации (цикл как в systemone-bench), теневой режим
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>
This commit is contained in:
parent
72727c6339
commit
c1d5a11bfb
7 changed files with 959 additions and 5 deletions
17
cli.py
17
cli.py
|
|
@ -202,6 +202,9 @@ def main():
|
|||
p_cmp2.add_argument("--verify-archives", action="store_true",
|
||||
help="проверить все архивы: sha256 против StoreCompacted + подпись каждой строки")
|
||||
|
||||
p_core = sub.add_parser("core", help="ядро Коди: обучаемые ученики решений (status|train|backfill|rollback)")
|
||||
p_core.add_argument("action", choices=["status", "train", "backfill", "rollback", "feedback"])
|
||||
|
||||
p_dec = sub.add_parser("decide", help="decision-модель nimble (Ollama /v1/systemone): статус, вопрос, статистика")
|
||||
p_dec.add_argument("-q", "--question", help="вопрос/инструкция (без -o — noul: P(да))")
|
||||
p_dec.add_argument("-o", "--option", action="append", default=[],
|
||||
|
|
@ -652,6 +655,20 @@ def main():
|
|||
print(f"OK {r['compaction']}: в архив {r['rows']}, удалено {r['removed']}, "
|
||||
f"мост {r['bridge_count']}; архив {r['archive_path']}")
|
||||
|
||||
elif args.cmd == "core":
|
||||
from harness import kodi_core
|
||||
es.init_db()
|
||||
if args.action == "status":
|
||||
print(json.dumps(kodi_core.status(), ensure_ascii=False, indent=1))
|
||||
elif args.action == "train":
|
||||
print(json.dumps(kodi_core.train(), ensure_ascii=False, default=str))
|
||||
elif args.action == "backfill":
|
||||
print(f"меток учителя: {kodi_core.backfill()}")
|
||||
elif args.action == "feedback":
|
||||
print(f"обратная связь: {kodi_core.harvest_feedback()}")
|
||||
else:
|
||||
print(json.dumps(kodi_core.rollback(), ensure_ascii=False))
|
||||
|
||||
elif args.cmd == "decide":
|
||||
from harness import decision
|
||||
if args.stats:
|
||||
|
|
|
|||
|
|
@ -126,6 +126,14 @@ def model_verdict(m: dict, inbox: dict):
|
|||
return float(r["probabilities"].get("bot", 0.0))
|
||||
|
||||
|
||||
def _prior(m: dict, inbox: dict, limit: int = 8) -> list:
|
||||
"""Реплики того же чата до сообщения (для признаков ядра)."""
|
||||
same = sorted((x for x in inbox.values()
|
||||
if x.get("chat_id") == m.get("chat_id") and x.get("ts")
|
||||
and x.get("ts") < (m.get("ts") or "")), key=lambda x: x["ts"])
|
||||
return same[-limit:]
|
||||
|
||||
|
||||
def triage(model: dict, log=None) -> int:
|
||||
"""Вердикт по всем групповым входящим без него. Возвращает число вердиктов."""
|
||||
inbox = (model or {}).get("inbox") or {}
|
||||
|
|
@ -148,9 +156,30 @@ def triage(model: dict, log=None) -> int:
|
|||
ok, rule = rule_verdict(m, bot_id)
|
||||
p = None
|
||||
if ok is None:
|
||||
# ядро Коди (harness/kodi_core.py): в on — уверенный ученик на
|
||||
# знакомой ситуации решает сам, учитель не зовётся; в shadow —
|
||||
# ученик только пишет прогноз рядом с решением учителя
|
||||
prior = _prior(m, inbox)
|
||||
core_rec = None
|
||||
try:
|
||||
from . import kodi_core
|
||||
if kodi_core.mode() == "on":
|
||||
core_rec = kodi_core.shadow(m, prior, None, final=False)
|
||||
except Exception:
|
||||
core_rec = None
|
||||
if core_rec and core_rec.get("route") == "student":
|
||||
p = core_rec["p_student"]
|
||||
ok, rule = p >= _min_p(), "core"
|
||||
else:
|
||||
p = model_verdict(m, inbox)
|
||||
ok, rule = ((p >= _min_p(), "decision") if p is not None
|
||||
else (False, "no_decision"))
|
||||
try:
|
||||
from . import kodi_core
|
||||
if kodi_core.mode() != "off":
|
||||
kodi_core.shadow(m, prior, p, final=True)
|
||||
except Exception:
|
||||
pass # ученик — бонус, решение уже есть
|
||||
if ok:
|
||||
es.append_event("inbox", mid, "MessageTriaged",
|
||||
{"addressed": True, "rule": rule, "p": p})
|
||||
|
|
|
|||
665
harness/kodi_core.py
Normal file
665
harness/kodi_core.py
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
"""Simbiot Harness — ядро Коди: обучаемые ученики решений (как ядро systemone-bench).
|
||||
|
||||
Цикл бенча (engine42/router42): учитель → дистилляция в маленькую сеть +
|
||||
kNN-память → живой роутер по новизне → дообучение на реальных исходах.
|
||||
Здесь тот же цикл на решениях Коди. Первая голова — `addressing`:
|
||||
отвечать ли на сообщение в группе (harness/addressing.py).
|
||||
|
||||
Опыт (события aggregate='core', подписаны как всё в сторе):
|
||||
CoreLabel — метка сообщения: source=teacher_backfill (учитель
|
||||
разметил историю), source=human (кнопка Alex в личке);
|
||||
CoreFeedback — неявная обратная связь чата: бот ответил, а следом
|
||||
«не тебе / продолжает / молчи» → 0; бот промолчал, а
|
||||
автор тут же дёрнул бота («@juno42bot», «Коди?») → 1;
|
||||
CorePrediction — теневой прогноз ученика на живом сообщении;
|
||||
CoreModelPromoted — смена чемпиона (метрики, sha256 файла модели).
|
||||
Живые вердикты учителя (MessageTriaged/MessageIgnored rule=decision, p)
|
||||
тоже метки — мягкие, вес 1. Обратная связь — вес 3, человек — вес 5.
|
||||
|
||||
Признаки: эмбеддинг сообщения + эмбеддинг контекста (3 реплики до него,
|
||||
ответы бота — только на адресованное) через локальный nomic-embed-text
|
||||
(~70 мс, бесплатно; кеш на диске) + 8 флагов. Ученик — логистическая
|
||||
регрессия (numpy, L2): на сотнях примеров она устойчивее MLP; формат
|
||||
модели допускает MLP позже. Новизна — 1 − средняя косинусная близость
|
||||
к 5 ближайшим из обучающей памяти.
|
||||
|
||||
Роутер (shadow по умолчанию — SIMBIOT_CORE_ADDRESSING=shadow|on|off):
|
||||
знакомо (новизна < NOV_FAMILIAR) и уверенно (|p−0.5| ≥ CONF) → ученик;
|
||||
незнакомо (новизна ≥ NOV_NOVEL) и учитель сомневается → вопрос Alex
|
||||
кнопками в личку (≤ SIMBIOT_CORE_ASK_PER_DAY в сутки, 5);
|
||||
иначе → учитель. В shadow решает учитель, ученик только пишет прогноз.
|
||||
|
||||
Тренировка — дневной тик (и cli.py core train): временной холдаут
|
||||
(последние 20%), претендент заменяет чемпиона, только если лучше по
|
||||
взвешенному log-loss на том же холдауте; файл модели в <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],
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ POLL = 1.0
|
|||
# уровни, включённые в v0.1 (порядок для лога)
|
||||
ACTIVE = ["sec", "min", "hour", "day"]
|
||||
|
||||
# ядро Коди: когда последний раз собирали обратную связь чата
|
||||
_CORE_FB = {"ts": 0.0}
|
||||
# состояние исполнителей уровня: level → {"busy": bool, "task": id|None}
|
||||
_runners = {}
|
||||
# когда уровень последний раз слал TG-уведомление о занятости (анти-спам)
|
||||
|
|
@ -907,6 +909,12 @@ def process_level(level_cfg, model: dict, seq: int = None):
|
|||
_repo_sync_check(level_cfg)
|
||||
except Exception:
|
||||
pass # репо-синк — best effort, не валить дневной тик
|
||||
# ядро Коди: ночная тренировка учеников (чемпион/претендент)
|
||||
try:
|
||||
from . import kodi_core
|
||||
kodi_core.train(log=_log)
|
||||
except Exception as e:
|
||||
_log(f"core: ночная тренировка не удалась ({e})")
|
||||
try:
|
||||
from . import aum as _aum
|
||||
snap = _aum.measure()
|
||||
|
|
@ -929,6 +937,14 @@ def process_level(level_cfg, model: dict, seq: int = None):
|
|||
model = es.run_projections()
|
||||
except Exception as e:
|
||||
_log(f"addressing: триаж не удался ({e}) — групповые ждут следующего такта")
|
||||
# ядро Коди: неявная обратная связь чата → метки (не чаще раза в 5 мин)
|
||||
if level_cfg.name == "min" and time.time() - _CORE_FB["ts"] >= 300:
|
||||
_CORE_FB["ts"] = time.time()
|
||||
try:
|
||||
from . import kodi_core
|
||||
kodi_core.harvest_feedback(log=_log)
|
||||
except Exception as e:
|
||||
_log(f"core: сбор обратной связи не удался ({e})")
|
||||
try:
|
||||
load = os.getloadavg()[0]
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -657,7 +657,11 @@ def poll_loop():
|
|||
print("[tg] инбокс-поллер запущен", flush=True)
|
||||
while True:
|
||||
try:
|
||||
updates = _api("getUpdates", {"offset": offset, "timeout": 30, "limit": 16})
|
||||
updates = _api("getUpdates", {
|
||||
"offset": offset, "timeout": 30, "limit": 16,
|
||||
# явно: Telegram помнит прошлый allowed_updates; callback_query —
|
||||
# кнопки ядра Коди («это было мне?»)
|
||||
"allowed_updates": json.dumps(["message", "edited_message", "callback_query"])})
|
||||
# getUpdates ответил — канал жив: штамп для дашборда
|
||||
# (t-261004103718-d8112b). getUpdates сам НЕ пробуем в дашборде:
|
||||
# конфликт long polling с этим процессом (сдвиг offset).
|
||||
|
|
@ -667,6 +671,14 @@ def poll_loop():
|
|||
updates=len((updates or {}).get("result") or []))
|
||||
for u in updates.get("result", []):
|
||||
offset = max(offset, u["update_id"] + 1)
|
||||
if u.get("callback_query"):
|
||||
# кнопки ядра Коди («это было мне?») → метка человека
|
||||
try:
|
||||
from . import kodi_core
|
||||
kodi_core.on_callback(u["callback_query"], ALLOWED)
|
||||
except Exception as e:
|
||||
print(f"[tg] callback не обработан: {e}", flush=True)
|
||||
continue
|
||||
msg = u.get("message") or u.get("edited_message")
|
||||
if not msg:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -20,4 +20,4 @@ uvicorn==0.54.0
|
|||
sse-starlette==3.5.0
|
||||
python-multipart==0.0.32
|
||||
py-spy==0.4.2
|
||||
watchdog==6.0.0
|
||||
watchdog==6.0.0numpy>=2.0 # ядро Коди (harness/kodi_core.py): обучение учеников
|
||||
|
|
|
|||
215
tests/test_kodi_core.py
Normal file
215
tests/test_kodi_core.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Ядро Коди (harness/kodi_core.py), голова addressing — hermetic.
|
||||
|
||||
Запуск: ./venv/bin/python tests/test_kodi_core.py
|
||||
|
||||
Эмбеддинги подменены детерминированными (тема «боту» vs «людям» + шум),
|
||||
Ollama/Telegram не трогаются. Проверяет: датасет (серая зона, приоритет
|
||||
меток human > feedback > teacher), обучение и промоут чемпиона с
|
||||
подписанным событием, отказ от худшего претендента, подмена файла модели
|
||||
→ ученика нет, откат; теневой прогноз пишется один раз; роутер;
|
||||
вопрос Alex с лимитом в сутки и кнопка → метка человека (чужой — нет);
|
||||
неявная обратная связь (встревание → 0, «дёрнул бота» → 1); триаж: в
|
||||
shadow решает учитель, в on уверенный знакомый ученик решает сам.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, "/root/simbiot-harness")
|
||||
_tmp = tempfile.mkdtemp(prefix="simbiot_core_")
|
||||
os.environ["SIMBIOT_DB"] = os.path.join(_tmp, "harness.db")
|
||||
os.environ["SIMBIOT_CORE_DIR"] = os.path.join(_tmp, "core")
|
||||
for _v in ("SIMBIOT_CORE_ADDRESSING", "SIMBIOT_CORE_ASK_PER_DAY", "SIMBIOT_DECISION"):
|
||||
os.environ.pop(_v, None)
|
||||
|
||||
import numpy as np
|
||||
|
||||
from harness import addressing, decision, es, kodi_core, tg_inbox
|
||||
|
||||
es.init_db()
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
print(("PASS" if cond else "FAIL"), name, ("| " + str(extra)) if not cond else "")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
A = np.zeros(768, np.float32); A[0] = 1
|
||||
B = np.zeros(768, np.float32); B[1] = 1
|
||||
|
||||
|
||||
def fake_embed(texts):
|
||||
out = []
|
||||
for t in texts:
|
||||
h = int(hashlib.sha1(t.encode()).hexdigest(), 16)
|
||||
rng = np.random.default_rng(h % (2 ** 32))
|
||||
base = A if "#bot" in t else B
|
||||
v = base + 0.02 * rng.standard_normal(768).astype(np.float32)
|
||||
out.append(v / np.linalg.norm(v))
|
||||
return out
|
||||
|
||||
|
||||
kodi_core.embed = fake_embed
|
||||
API = []
|
||||
tg_inbox._api = lambda method, params, timeout=35: API.append((method, dict(params))) or {"ok": True, "result": True}
|
||||
tg_inbox._load_env = lambda: None
|
||||
tg_inbox._default_chat_id = lambda: 7569223131
|
||||
tg_inbox.mark_seen = lambda *a, **k: True
|
||||
G = -1004402779881
|
||||
UID = [1000]
|
||||
|
||||
|
||||
def recv(text, who="Vlad", ts=None):
|
||||
UID[0] += 1
|
||||
mid = f"m-{UID[0]}"
|
||||
es.append_event("inbox", mid, "MessageReceived", {
|
||||
"update_id": UID[0], "chat_id": G, "chat_type": "supergroup", "chat_title": "Правление",
|
||||
"first_name": who, "text": text, "kind": "text", "message_id": UID[0]})
|
||||
return mid
|
||||
|
||||
|
||||
# ── 1. опыт: 60 размеченных учителем сообщений серой зоны + правило + метки ──
|
||||
hm = recv("#bot спорное, учитель ошибся")
|
||||
es.append_event("core", f"label:addressing:{hm}:teacher", "CoreLabel",
|
||||
{"head": "addressing", "msg": hm, "label": 0.2, "source": "teacher_backfill"})
|
||||
es.append_event("core", f"label:addressing:{hm}:human", "CoreLabel",
|
||||
{"head": "addressing", "msg": hm, "label": 1, "source": "human"})
|
||||
for i in range(60):
|
||||
bot = i % 2 == 0
|
||||
mid = recv(f"{'#bot сделай отчёт' if bot else 'обсуждаем между собой'} {i}")
|
||||
es.append_event("core", f"label:addressing:{mid}:teacher", "CoreLabel", {
|
||||
"head": "addressing", "msg": mid, "label": 0.9 if bot else 0.1, "source": "teacher_backfill"})
|
||||
rule_mid = recv("Коди, привет") # правило — не в датасете
|
||||
es.append_event("inbox", rule_mid, "MessageTriaged", {"addressed": True, "rule": "name"})
|
||||
rows = kodi_core.dataset()
|
||||
ids = {r[0]["id"] for r in rows}
|
||||
check("датасет: серая зона с метками (61)", len(rows) == 61, len(rows))
|
||||
check("датасет: решённое правилом не входит", rule_mid not in ids)
|
||||
hrow = next(r for r in rows if r[0]["id"] == hm)
|
||||
check("метка человека сильнее учителя (y=1, вес 5)", hrow[2] == 1 and hrow[3] == 5.0 and hrow[4] == "human")
|
||||
|
||||
# ── 2. обучение и промоут ──
|
||||
rep = kodi_core.train(log=lambda *_: None)
|
||||
check("первое обучение → промоут", rep["promoted"] and rep["holdout"]["acc"] >= 0.9, rep)
|
||||
st = kodi_core.status()
|
||||
check("чемпион записан", st["champion"] and st["champion_n"] == 61, st)
|
||||
prom = [json.loads(e["data"]) for e in es.get_events(0, 10**6) if e["event_type"] == "CoreModelPromoted"]
|
||||
check("CoreModelPromoted с sha256", prom and len(prom[-1]["sha256"]) == 64)
|
||||
model = kodi_core.load_champion()
|
||||
_ctx = [{"first_name": "Vlad", "text": "обсуждаем между собой 1"},
|
||||
{"first_name": "Vlad", "text": "#bot сделай отчёт 2"}]
|
||||
X, em = kodi_core.featurize([({"first_name": "Vlad", "text": "#bot глянь отчёт"}, _ctx),
|
||||
({"first_name": "Vlad", "text": "обсуждаем между собой"}, _ctx)])
|
||||
p = kodi_core.predict(model, X)
|
||||
check("ученик различает «боту» и «людям»", p[0] > 0.7 and p[1] < 0.4, p)
|
||||
nov = kodi_core.novelty(model, em)
|
||||
check("знакомое — ниже порога «незнакомо» модели", nov.max() < model["nov_q"][1], (nov, model["nov_q"]))
|
||||
check("пороги новизны откалиброваны и упорядочены", 0 <= model["nov_q"][0] <= model["nov_q"][1] < 1, model["nov_q"])
|
||||
rep2 = kodi_core.train(log=lambda *_: None)
|
||||
check("тот же опыт — чемпион остаётся (не лучше)", rep2["promoted"] is False, rep2)
|
||||
|
||||
# подмена файла модели
|
||||
meta = kodi_core._champion_meta()
|
||||
path = kodi_core._model_path(meta["file"])
|
||||
blob = open(path, "rb").read()
|
||||
open(path, "wb").write(blob[:-1] + bytes([blob[-1] ^ 1]))
|
||||
kodi_core._STATE.update(model=None, loaded_sha=None)
|
||||
check("файл модели подменён → ученика нет", kodi_core.load_champion() is None)
|
||||
open(path, "wb").write(blob)
|
||||
kodi_core._STATE.update(model=None, loaded_sha=None)
|
||||
|
||||
# ── 3. роутер ──
|
||||
check("роутер: знакомо+уверенно → ученик", kodi_core.route(0.95, 0.1, 0.9) == "student")
|
||||
check("роутер: незнакомо+учитель сомневается → человек", kodi_core.route(0.6, 0.8, 0.5) == "human")
|
||||
check("роутер: иначе → учитель", kodi_core.route(0.55, 0.1, 0.9) == "teacher")
|
||||
|
||||
# ── 4. триаж в shadow: решает учитель, ученик пишет прогноз ──
|
||||
CALLS = []
|
||||
decision.choose = lambda purpose, opts, state, instr: CALLS.append(1) or {
|
||||
"choice": "group", "applied": True, "confidence": .5, "probabilities": {"bot": 0.2, "group": 0.8}}
|
||||
mid = recv("#bot сделай отчёт за неделю")
|
||||
addressing.triage(es.run_projections())
|
||||
ib = es.run_projections()["inbox"][mid]
|
||||
pred = [json.loads(e["data"]) for e in es.get_events(0, 10**6) if e["event_type"] == "CorePrediction"]
|
||||
check("shadow: решение учителя (не боту, p=0.2)", ib.get("ignored") and CALLS,
|
||||
(ib.get("ignored"), ib.get("ignore_reason"), ib.get("addressed"), CALLS))
|
||||
check("shadow: прогноз ученика записан, расходится с учителем",
|
||||
pred and pred[-1]["msg"] == mid and pred[-1]["p_student"] > .7 and pred[-1]["agree"] is False, pred[-1:])
|
||||
kodi_core.shadow({"update_id": int(mid[2:]), "first_name": "Vlad", "text": "x"}, [], 0.2, True)
|
||||
check("прогноз на сообщение пишется один раз",
|
||||
sum(1 for e in es.get_events(0, 10**6) if e["event_type"] == "CorePrediction"
|
||||
and json.loads(e["data"])["msg"] == mid) == 1)
|
||||
|
||||
# ── 5. on: уверенный знакомый ученик решает сам, учитель не зовётся ──
|
||||
os.environ["SIMBIOT_CORE_ADDRESSING"] = "on"
|
||||
CALLS.clear()
|
||||
_route = kodi_core.route # пороги роутера проверены в §3; здесь — связка с триажем
|
||||
kodi_core.route = lambda p, nov, t, q=None: "student" if p is not None and abs(p - .5) >= .3 else "teacher"
|
||||
mid2 = recv("#bot собери сводку")
|
||||
addressing.triage(es.run_projections())
|
||||
ib2 = es.run_projections()["inbox"][mid2]
|
||||
check("on: ученик решил (rule=core), учитель не вызван",
|
||||
ib2.get("addressed") is True and ib2["triage"]["rule"] == "core" and not CALLS, (ib2.get("triage"), CALLS))
|
||||
os.environ.pop("SIMBIOT_CORE_ADDRESSING")
|
||||
kodi_core.route = _route
|
||||
|
||||
# ── 6. вопрос Alex и кнопки ──
|
||||
os.environ["SIMBIOT_CORE_ASK_PER_DAY"] = "2"
|
||||
for t in ("странное 1", "странное 2", "странное 3"):
|
||||
mm = {"update_id": int(recv(t)[2:]), "first_name": "Vlad", "text": t, "chat_title": "Правление"}
|
||||
kodi_core.ask_human(mm, [])
|
||||
asked = [p for m_, p in API if m_ == "sendMessage"]
|
||||
check("вопрос Alex: лимит в сутки соблюдён (2)", len(asked) == 2, len(asked))
|
||||
kb = json.loads(asked[0]["reply_markup"])["inline_keyboard"][0]
|
||||
check("вопрос: кнопки «Да, Коди / Нет»", kb[0]["callback_data"].endswith(":1") and kb[1]["callback_data"].endswith(":0"))
|
||||
target = kb[1]["callback_data"].split(":")[2]
|
||||
check("кнопка от чужого — метки нет",
|
||||
kodi_core.on_callback({"id": "q1", "data": kb[1]["callback_data"], "from": {"id": 666}},
|
||||
{"7569223131"}) and not [e for e in es.get_events(0, 10**6)
|
||||
if e["event_type"] == "CoreLabel" and
|
||||
json.loads(e["data"]).get("source") == "human"
|
||||
and json.loads(e["data"])["msg"] == target])
|
||||
kodi_core.on_callback({"id": "q2", "data": kb[1]["callback_data"], "from": {"id": 7569223131},
|
||||
"message": {"message_id": 5, "chat": {"id": 7569223131}}}, {"7569223131"})
|
||||
hl = [json.loads(e["data"]) for e in es.get_events(0, 10**6) if e["event_type"] == "CoreLabel"
|
||||
and json.loads(e["data"]).get("source") == "human" and json.loads(e["data"])["msg"] == target]
|
||||
check("кнопка Alex → метка человека 0", hl and hl[-1]["label"] == 0, hl)
|
||||
check("кнопки убраны после ответа", any(m_ == "editMessageReplyMarkup" for m_, _ in API))
|
||||
check("чужие callback не наши", kodi_core.on_callback({"data": "other:x"}, set()) is False)
|
||||
|
||||
# ── 7. неявная обратная связь ──
|
||||
fp = recv("ну что, как дела у всех")
|
||||
es.append_event("inbox", fp, "MessageTriaged", {"addressed": True, "rule": "decision", "p": 0.6})
|
||||
es.append_event("inbox", fp, "ReplySent", {"text": "Всё отлично 🙂"})
|
||||
recv("Коди, не тебе", who="Alex")
|
||||
fn = recv("подготовь сводку по встрече", who="Alex")
|
||||
es.append_event("inbox", fn, "MessageIgnored", {"reason": "not_addressed", "rule": "decision", "p": 0.3})
|
||||
poke = recv("@juno42bot", who="Alex")
|
||||
es.append_event("inbox", poke, "MessageTriaged", {"addressed": True, "rule": "mention"})
|
||||
n = kodi_core.harvest_feedback()
|
||||
fb = {json.loads(e["data"])["msg"]: json.loads(e["data"]) for e in es.get_events(0, 10**6)
|
||||
if e["event_type"] == "CoreFeedback"}
|
||||
check("встревание + «не тебе» → метка 0", fb.get(fp, {}).get("label") == 0, fb)
|
||||
check("промолчал + «@juno42bot» тем же автором → метка 1", fb.get(fn, {}).get("label") == 1, fb)
|
||||
check("обратная связь пишется один раз", kodi_core.harvest_feedback() == 0)
|
||||
|
||||
# ── 8. откат ──
|
||||
for i in range(30): # новый опыт → новый чемпион, чтобы было куда откатываться
|
||||
mid = recv(f"{'#bot' if i % 2 else ''} новое {i}")
|
||||
es.append_event("core", f"label:addressing:{mid}:human", "CoreLabel",
|
||||
{"head": "addressing", "msg": mid, "label": 1 if i % 2 else 0, "source": "human"})
|
||||
first = kodi_core._champion_meta()["file"]
|
||||
r3 = kodi_core.train(log=lambda *_: None)
|
||||
if r3["promoted"]:
|
||||
rb = kodi_core.rollback()
|
||||
check("откат к предыдущему чемпиону", rb["ok"] and kodi_core._champion_meta()["file"] == first, rb)
|
||||
else:
|
||||
check("откат: история пуста — честный отказ", kodi_core.rollback()["ok"] is False)
|
||||
|
||||
print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)")
|
||||
sys.exit(1 if fails else 0)
|
||||
Loading…
Reference in a new issue