diff --git a/.gitignore b/.gitignore index 5e64699..e7af246 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ store.db .simbiot-data/ harness/channel_*.json harness/channel_*.json.tmp + +# артефакты ядра/памяти, если SIMBIOT_HOME не задан (прод — в /var/lib/simbiot-harness) +/core/ +/memory/ diff --git a/cli.py b/cli.py index 2db66c8..e43a36d 100644 --- a/cli.py +++ b/cli.py @@ -202,12 +202,18 @@ def main(): p_cmp2.add_argument("--verify-archives", action="store_true", help="проверить все архивы: sha256 против StoreCompacted + подпись каждой строки") - p_tr = sub.add_parser("trust", help="круги доверия: list | set <субъект> <круг 0-7> (только человек, не из тика)") + p_tr = sub.add_parser("trust", help="круги доверия: list | set <субъект> <круг 1-4> (только человек, не из тика)") p_tr.add_argument("action", choices=["list", "set"]) p_tr.add_argument("subject", nargs="?", help="tg: | agent:<имя> | portal: | tui") p_tr.add_argument("circle", nargs="?", type=int) p_tr.add_argument("--note", default="") + p_mem = sub.add_parser("memory", help="память Коди: sync | stats | search \"запрос\" [--circle N]") + p_mem.add_argument("action", choices=["sync", "stats", "search"]) + p_mem.add_argument("query", nargs="?", default="") + p_mem.add_argument("--circle", type=int, default=1) + p_mem.add_argument("--all", action="store_true", help="sync: догнать стор целиком (пачками)") + p_core = sub.add_parser("core", help="ядро Коди: обучаемые ученики решений (status|train|backfill|rollback)") p_core.add_argument("action", choices=["status", "train", "backfill", "rollback", "feedback", "reflect", "report", "retire"]) @@ -669,7 +675,8 @@ def main(): if args.action == "list": for r in trust.snapshot(): print(f"{r['circle']} {r['name']:20} {r['subject']:24} {r.get('note') or ''} (by {r.get('by')})") - print("по умолчанию: владелец tg — 1, tui — 1, агенты — 4, портал — 3, прочие tg — 6, чужие — 7") + print("круги: 1 владелец · 2 правление · 3 свои (портал, MCP-агенты, партнёры) · 4 остальные; " + "по умолчанию: владелец tg/tui — 1, агенты/портал — 3, прочие — 4") else: if args.subject is None or args.circle is None: print("ERROR: cli.py trust set <субъект> <круг>") @@ -681,6 +688,18 @@ def main(): sys.exit(2) print(f"OK: {args.subject} → круг {args.circle} ({trust.NAMES[args.circle]})") + elif args.cmd == "memory": + from harness import memory + es.init_db() + if args.action == "sync": + total = memory.sync_all() if args.all else memory.sync() + print(f"записей добавлено: {total}; индекс: {memory.stats()}") + elif args.action == "stats": + print(json.dumps(memory.stats(), ensure_ascii=False)) + else: + for h in memory.search(args.query, args.circle, k=8): + print(f"{h['score']:.3f} [{h['kind']} {h['ts'][:10]} круг {h['circle']}] {h['text'][:160]!r}") + elif args.cmd == "core": from harness import kodi_core es.init_db() diff --git a/harness/_llm_worker.py b/harness/_llm_worker.py index 4b4734a..3e54113 100644 --- a/harness/_llm_worker.py +++ b/harness/_llm_worker.py @@ -452,8 +452,8 @@ def main(): level = payload.get("level") or "?" # круг доверия тика (harness/trust.py): в env — его наследуют shell и # cli.py (задачи штампуются кругом, trust set из тика запрещён) - circle = payload.get("circle") - circle = 1 if circle is None else int(circle) + from harness.trust import norm as _norm_circle + circle = _norm_circle(payload.get("circle")) os.environ["SIMBIOT_TICK_CIRCLE"] = str(circle) try: # RAG-инструмент тика (t-260929212316): у LLM появляется rag_lookup — diff --git a/harness/es.py b/harness/es.py index b951254..56aba98 100644 --- a/harness/es.py +++ b/harness/es.py @@ -585,6 +585,9 @@ def _apply_event(model: dict, ev: dict): "summary_ref": data.get("summary_ref"), "text": data.get("text", ""), "ts": ev["timestamp"], + # заглушки (gate:quiet / busy) — без LLM; build_context их фильтровал + # по stub, но поле не проецировалось — выжимки забивались «LLM не запускался» + "stub": bool(data.get("stub")), } # кап дайджестов: держим по 40 последних на уровень — # «наверх идут только дайджесты» не значит «все дайджесты навсегда» diff --git a/harness/kodi_core.py b/harness/kodi_core.py index 72d6041..5e51bb2 100644 --- a/harness/kodi_core.py +++ b/harness/kodi_core.py @@ -90,9 +90,14 @@ def mode(head: str = HEAD) -> str: 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 = (os.environ.get("SIMBIOT_CORE_DIR", "").strip() - or os.path.join(_paths.home(), "core")) + """Корень ядра — с созданием (только для записи: кеш эмбеддингов, модели).""" + d = _core_root() os.makedirs(d, mode=0o700, exist_ok=True) return d @@ -110,10 +115,15 @@ def _cache(): return db -def embed(texts: list) -> list: - """Эмбеддинги (нормированные списки float) с дисковым кешем; батч к Ollama.""" +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}".encode()).hexdigest() for t in texts] + 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: @@ -125,7 +135,7 @@ def embed(texts: list) -> list: 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]}) + "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: @@ -349,7 +359,11 @@ def metrics(model, X, y, w): return {"logloss": round(ll, 4), "acc": round(acc, 4), "n": int(len(y))} -def _model_path(name, head: str = HEAD): +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) @@ -357,7 +371,7 @@ def _model_path(name, head: str = HEAD): def _champion_meta(head: str = HEAD): try: - with open(_model_path("champion.json", head)) as f: + with open(_model_path("champion.json", head, create=False)) as f: return json.load(f) except (OSError, ValueError): return {} @@ -371,7 +385,7 @@ def load_champion(head: str = HEAD): 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) + 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"): @@ -425,6 +439,8 @@ def _heads(): "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")}, } @@ -593,9 +609,11 @@ def shadow(m: dict, prior: list, teacher_p, final: bool): 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 + 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) @@ -866,6 +884,63 @@ def task_shadow(task: dict, level: str): 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-путей: досье уходит задачей часовому diff --git a/harness/memory.py b/harness/memory.py new file mode 100644 index 0000000..b95006a --- /dev/null +++ b/harness/memory.py @@ -0,0 +1,355 @@ +"""Simbiot Harness — динамическая память Коди: глубина D0–D3 и подгрузка по смыслу. + +Третья ось куба 4×4×4 (круг доверия × тик × глубина). Раньше память тика — +12 последних реплик чата + свои сообщения за 48 ч + 1 запрос к базе знаний +кооператива: на «чем ты занимался последний месяц» Коди отвечал по базе +кооператива и наугад — поиска по СОБСТВЕННОЙ истории не было. + +Индекс (/memory/index.sqlite) — инкрементально из стора: + conv — сообщение + ответ Коди (ReplySent; батч — ответ головы); + out — собственные рассылки бота (OutboundMessage, не ответы); + task — задача и её итог (TaskCompleted); + digest — содержательные дайджесты час/день (заглушки — нет). +Каждая запись хранит КРУГ ПРИВАТНОСТИ: сказанное в чате видно всем его +участникам → круг записи = самый недоверенный участник чата (личка Alex — +1, «Правление» — 2); задача — её круг; дайджест — 1 (внутреннее). Поиск +отдаёт только записи с кругом ≥ круга тика: тик с правлением не поднимет +личку владельца. Эмбеддинги — nomic-embed-text (search_document/query). + +Глубина (choose_depth): D0 — только текущее (односложные «ок/спасибо»); +D1 — разговор (как было); D2 — + эпизоды по смыслу (8 записей); D3 — ++ архив (15 записей + последние дайджесты). Правила: уровень (час — D2, +день — D3), слова о прошлом («помнишь», «вчера», «за месяц», «как +договаривались»…), ответ на старое сообщение бота, задачи в батче; +потолок — круг (trust.max_depth). Решение пишется (DepthChosen) — сырьё +для головы ядра «depth». +""" +import json +import os +import re +import sqlite3 +import threading +from datetime import datetime, timedelta, timezone + +from . import es +from . import paths as _paths + +try: + import numpy as np +except ImportError: + np = None + +KINDS = ("conv", "out", "task", "digest") +BUDGET = {2: (8, 5000), 3: (15, 12000)} # глубина → (записей, символов) +SYNC_BATCH = 400 +MIN_SCORE = float(os.environ.get("SIMBIOT_MEMORY_MIN_SCORE", "0.45")) # ниже — шум +_LOCK = threading.Lock() +_LAST = {} # уровень → последнее решение о глубине (для DepthChosen) + +PAST_RE = re.compile( + r"помни|вспомн|вчера|позавчера|на прошлой|прошл(ый|ую|ой|ом|ые)\s+(раз|недел|месяц|встреч)|" + r"как\s+договаривал|мы\s+же|мы\s+обсуждал|ранее|раньше|в\s+прошлый|" + r"за\s+(последн\w+\s+)?(месяц|недел|дн)|что\s+(ты\s+)?(делал|сделал|было)|" + r"история|итог[иа]?\b|на\s+чём\s+останов|напомни", re.IGNORECASE) +ARCHIVE_RE = re.compile(r"за\s+(последн\w+\s+)?(месяц|недел|квартал|год)|" + r"чем\s+(ты\s+)?занимал|что\s+(ты\s+)?делал|отч[её]т|итоги|" + r"стратег|план\s+на", re.IGNORECASE) +TRIVIAL_RE = re.compile(r"^\W*(ок|окей|ok|ага|да|нет|спасибо|спс|понял|принято|супер|" + r"класс|норм|👍|🙏|❤️|🔥)\W*$", re.IGNORECASE) + + +# ────────────────────────── хранилище ────────────────────────── + +def enabled() -> bool: + """SIMBIOT_MEMORY=on (env > .env инстанса); дефолт off — тесты герметичны.""" + from . import decision + return decision._cfg("SIMBIOT_MEMORY", "off").lower() == "on" + + +def _dir() -> str: + d = (os.environ.get("SIMBIOT_MEMORY_DIR", "").strip() + or os.path.join(_paths.home(), "memory")) + os.makedirs(d, mode=0o700, exist_ok=True) + return d + + +def _db(): + db = sqlite3.connect(os.path.join(_dir(), "index.sqlite"), timeout=30) + db.execute("CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY, kind TEXT, ts TEXT, " + "chat_id TEXT, circle INTEGER, who TEXT, text TEXT, emb BLOB)") + db.execute("CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT)") + return db + + +def _store_ro(): + return sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30) + + +def _embed(texts, prefix): + from . import kodi_core + return kodi_core.embed(texts, prefix) + + +# ────────────────────────── круг приватности записи ────────────────────────── + +def chat_circle(chat_id, model: dict, senders: dict) -> int: + """Круг чата = самый недоверенный известный участник; личка — круг собеседника.""" + from . import trust + known = senders.get(str(chat_id)) or set() + if not known: + try: + cid = int(chat_id) + if cid > 0: + return trust.circle_of(f"tg:{cid}", model) + except (TypeError, ValueError): + pass + return trust.circle_of(trust.subject_of({"chat_id": chat_id}), model) + return max(trust.circle_of(s, model) for s in known) + + +# ────────────────────────── синхронизация ────────────────────────── + +def sync(limit: int = SYNC_BATCH, log=None) -> int: + """Новые события стора → записи индекса с эмбеддингами. Возвращает число записей.""" + return _sync_once(limit, log)[1] + + +def sync_all(log=None) -> int: + """Догнать стор целиком (бэкфилл): пачками, пока есть события.""" + total = 0 + while True: + nrows, nitems = _sync_once(SYNC_BATCH, log) + total += nitems + if nrows == 0: + return total + + +def _sync_once(limit: int, log=None) -> tuple: + """(событий обработано, записей добавлено).""" + from . import trust + with _LOCK: + db = _db() + try: + last = int((db.execute("SELECT v FROM meta WHERE k='last'").fetchone() or ["0"])[0]) + src = _store_ro() + try: + rows = src.execute( + "SELECT id, aggregate_id, event_type, timestamp, data FROM events WHERE id > ? " + "AND event_type IN ('MessageReceived','ReplySent','OutboundMessage'," + "'TaskSubmitted','TaskCompleted','DigestWritten') ORDER BY id LIMIT ?", + (last, limit)).fetchall() + if not rows: + return 0, 0 + model = es.run_projections() + senders = json.loads((db.execute("SELECT v FROM meta WHERE k='senders'") + .fetchone() or ["{}"])[0]) + senders = {k: set(v) for k, v in senders.items()} + pending = [] # (id, kind, ts, chat, circle, who, text) + for eid, aid, et, ts, d in rows: + d = json.loads(d) + if et == "MessageReceived": + if d.get("chat_id") is not None: + senders.setdefault(str(d["chat_id"]), set()).add(trust.subject_of(d)) + elif et == "ReplySent": + rec = _conv_item(src, db, aid, d, ts, model, senders, pending) + if rec: + pending.append(rec) + # исходящее с тем же текстом — это ответ, не рассылка + db.execute("DELETE FROM items WHERE kind='out' AND chat_id=? " + "AND substr(text,1,200)=?", + (str(rec[3]), (d.get("text") or "")[:200])) + pending[:] = [p for p in pending if not ( + p[1] == "out" and p[3] == rec[3] + and p[6][:200] == (d.get("text") or "")[:200])] + elif et == "OutboundMessage": + t = (d.get("text") or "").strip() + # ответ на входящее (ReplySent мог прийти раньше) — это conv, не рассылка + is_reply = any(p[1] == "conv" and p[3] == str(d.get("chat_id")) + and p[6].split("\nКоди: ", 1)[-1][:200] == t[:200] + for p in pending) or db.execute( + "SELECT 1 FROM items WHERE kind='conv' AND chat_id=? AND " + "instr(text, ?) > 0 LIMIT 1", (str(d.get("chat_id")), + "\nКоди: " + t[:200])).fetchone() + if t and not t.startswith("(сводный") and not is_reply: + pending.append((f"out:{aid}", "out", ts, str(d.get("chat_id")), + chat_circle(d.get("chat_id"), model, senders), + "Коди", t[:1500])) + elif et == "TaskCompleted": + t = (model.get("tasks") or {}).get(aid) or {} + ans = (d.get("answer") or d.get("result") or "") + if t.get("title") or ans: + pending.append((f"task:{aid}", "task", ts, "", trust.norm(t.get("circle")), + "задача", f"Задача: {t.get('title') or aid}\n" + f"Итог: {str(ans)[:1200]}")) + elif et == "DigestWritten": + txt = (d.get("text") or "").strip() + if (d.get("level") in ("hour", "day") and not d.get("stub") and txt + and not txt.startswith("[gate:quiet]") and "LLM не запускался" not in txt): + pending.append((f"digest:{d.get('level')}:{d.get('period')}", "digest", + ts, "", 1, f"дайджест {d.get('level')}", txt[:1500])) + finally: + src.close() + if pending: + vecs = _embed([p[6] for p in pending], "search_document") + db.executemany("INSERT OR REPLACE INTO items VALUES (?,?,?,?,?,?,?,?)", + [(*p, np.asarray(v, np.float32).tobytes()) + for p, v in zip(pending, vecs)]) + db.execute("INSERT OR REPLACE INTO meta VALUES ('last', ?)", (str(rows[-1][0]),)) + db.execute("INSERT OR REPLACE INTO meta VALUES ('senders', ?)", + (json.dumps({k: sorted(v) for k, v in senders.items()}),)) + db.commit() + if log and pending: + log(f"memory: +{len(pending)} записей") + return len(rows), len(pending) + finally: + db.close() + + +def _conv_item(src, db, aid, d, ts, model, senders, pending=()): + row = src.execute("SELECT data FROM events WHERE aggregate='inbox' AND aggregate_id=? " + "AND event_type='MessageReceived' LIMIT 1", (aid,)).fetchone() + if not row: + return None + m = json.loads(row[0]) + text = (m.get("text") or "").strip() + if not text: + return None + reply = d.get("text") or "" + if reply.startswith("(сводный"): + head = ((d.get("batch") or {}).get("head")) + hp = next((p[6] for p in pending if p[0] == f"conv:{head}"), None) # голова в этой пачке + hr = (hp,) if hp else db.execute("SELECT text FROM items WHERE id=?", (f"conv:{head}",)).fetchone() + reply = (hr[0].split("\nКоди: ", 1)[-1] if hr else "(общий ответ на несколько сообщений)") + who = m.get("first_name") or m.get("username") or str(m.get("user_id")) + return (f"conv:{aid}", "conv", ts, str(m.get("chat_id")), + chat_circle(m.get("chat_id"), model, senders), who, + f"{who}: {text[:700]}\nКоди: {reply[:700]}") + + +# ────────────────────────── поиск ────────────────────────── + +def search(query: str, circle: int, k: int = 8, kinds=KINDS, exclude_chat=None, + exclude_since: str = "") -> list: + """Записи по смыслу, видимые кругу тика (круг записи ≥ круга тика).""" + if np is None or not query.strip(): + return [] + db = _db() + try: + rows = db.execute( + f"SELECT id, kind, ts, chat_id, circle, who, text, emb FROM items WHERE circle >= ? " + f"AND kind IN ({','.join('?' * len(kinds))})", (int(circle), *kinds)).fetchall() + finally: + db.close() + if exclude_chat is not None and exclude_since: + # свежий разговор этого чата уже в контексте (D1) — не дублируем + rows = [r for r in rows if not (r[3] == str(exclude_chat) and r[2] >= exclude_since)] + if not rows: + return [] + q = np.asarray(_embed([query[:2000]], "search_query")[0], np.float32) + M = np.stack([np.frombuffer(r[7], np.float32) for r in rows]) + sims = M @ q + order = np.argsort(-sims)[:k] + return [{"id": rows[i][0], "kind": rows[i][1], "ts": rows[i][2], "circle": rows[i][4], + "text": rows[i][6], "score": round(float(sims[i]), 3)} for i in order] + + +def stats() -> dict: + db = _db() + try: + by = dict(db.execute("SELECT kind, COUNT(*) FROM items GROUP BY kind").fetchall()) + circ = dict(db.execute("SELECT circle, COUNT(*) FROM items GROUP BY circle").fetchall()) + last = (db.execute("SELECT v FROM meta WHERE k='last'").fetchone() or ["0"])[0] + finally: + db.close() + return {"items": by, "by_circle": circ, "last_event": int(last)} + + +# ────────────────────────── глубина ────────────────────────── + +def choose_depth(level: str, inbox: list, batch: list, circle: int) -> tuple: + """(глубина 0..3, причины). Правила + потолок круга; голова ядра — в тени.""" + from . import trust + reasons = [] + base = {"min": 1, "hour": 2, "day": 3}.get(level, 1) + d = base + reasons.append(f"уровень {level} → D{base}") + texts = [(m.get("text") or "") for m in inbox or []] + if inbox and not batch and all(TRIVIAL_RE.match(t.strip() or "x") for t in texts) \ + and not any(m.get("reply_to_full") for m in inbox): + d = 0 + reasons = ["односложные реплики → D0"] + joined = "\n".join(texts + [f"{t.get('title') or ''} {t.get('details') or ''}" for t in batch or []]) + if PAST_RE.search(joined): + d = max(d, 2) + reasons.append("отсылка к прошлому → D2") + if ARCHIVE_RE.search(joined): + d = max(d, 3) + reasons.append("обзор периода → D3") + old = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat() + for m in inbox or []: + if m.get("reply_to_full") and (m.get("reply_to_ts") or "") < old: + d = max(d, 2) + reasons.append("ответ на давнее сообщение бота → D2") + if batch: + d = max(d, 2) + reasons.append("задачи в батче → D2 (прошлые похожие)") + # голова ядра «depth» (harness/kodi_core.py): shadow — прогноз в причинах; + # on — уверенное «нужна глубина» поднимает D0/D1 до D2 + student = None + try: + from . import kodi_core + student = kodi_core.depth_predict({"query": joined[:1500], "level": level, + "circle": circle, "reasons": " ".join(reasons)}) + except Exception: + student = None + if student: + p, sure = student + reasons.append(f"ученик depth: p={p:.2f}{' уверен' if sure else ''}") + if sure and p >= .5 and d < 2 and kodi_core.mode(kodi_core.DEPTH_HEAD) == "on": + d = 2 + reasons.append("ученик → D2") + cap = trust.max_depth(circle) + if d > cap: + reasons.append(f"потолок круга {circle} → D{cap}") + d = cap + _LAST[level] = {"depth": d, "reasons": reasons, "circle": circle, "query": joined[:500]} + return d, reasons + + +def context_block(depth: int, level: str, inbox: list, batch: list, circle: int) -> str: + """Текст памяти для контекста тика на глубине ≥ 2 (пусто — для D0/D1).""" + if depth < 2 or not enabled(): + return "" + k, budget = BUDGET[min(depth, 3)] + query = "\n".join([(m.get("text") or "") + " " + (m.get("reply_to_full") or "")[:300] + for m in inbox or []] + + [f"{t.get('title') or ''}\n{(t.get('details') or '')[:400]}" for t in batch or []]) + if not query.strip(): + query = {"hour": "итоги последнего часа, задачи, решения", + "day": "итоги дня, решения, задачи, договорённости"}.get(level, "недавние дела") + chat = inbox[0].get("chat_id") if inbox else None + since = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat() + try: + hits = search(query, circle, k=k, exclude_chat=chat, exclude_since=since) + except Exception as e: + return f"ПАМЯТЬ: поиск недоступен ({str(e)[:80]}) — отвечай из контекста тика." + lines, used = [], 0 + for h in hits: + if h["score"] < MIN_SCORE: + continue + line = f"- [{h['kind']} {h['ts'][:10]}] {h['text'][:900]}" + if used + len(line) > budget: + break + lines.append(line) + used += len(line) + if _LAST.get(level): + _LAST[level]["hits"] = len(lines) + if not lines: + return "" + head = (f"ПАМЯТЬ — НАЙДЕНО ПО СМЫСЛУ (глубина D{depth}; записи, видимые кругу {circle}; " + "это прошлое — опирайся, но проверяй актуальность):") + return head + "\n" + "\n".join(lines) + + +def last_choice(level: str) -> dict: + return dict(_LAST.get(level) or {}) diff --git a/harness/scheduler.py b/harness/scheduler.py index 7358949..1980409 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -157,6 +157,13 @@ def _own_messages(chat_ids, replied_hist, hours: int = 48, cap: int = 6) -> list return list(reversed(out)) +def _is_stub_digest(d: dict) -> bool: + """Заглушка дайджеста (тихий/занятый такт без LLM) — не выжимка.""" + t = (d.get("text") or "").lstrip() + return bool(d.get("stub")) or t.startswith(("[gate:quiet]", "[absorbed", "[busy")) \ + or "LLM не запускался" in t + + def _est_minutes(t: dict) -> int: """Оценка длительности: из TaskSubmitted или дефолт по классу. @@ -216,7 +223,7 @@ def select_task_batch(level_cfg, model: dict, prioritize: bool = False): from . import trust as _trust ready = [t for t in (model.get("tasks") or {}).values() if t.get("status") == "ready" and t.get("size") in classes - and _trust.level_serves(level_cfg.name, int(t.get("circle") or 1))] + and _trust.level_serves(level_cfg.name, _trust.norm(t.get("circle")))] ready.sort(key=lambda t: t.get("created") or "") if prioritize and len(ready) > 1: from . import decision @@ -226,8 +233,8 @@ def select_task_batch(level_cfg, model: dict, prioritize: bool = False): if ready: # один круг на батч: задача правления рядом с задачей владельца # снизила бы права всего тика (taint) — круги не смешиваем - c0 = int(ready[0].get("circle") or 1) - ready = [t for t in ready if int(t.get("circle") or 1) == c0] + c0 = _trust.norm(ready[0].get("circle")) + ready = [t for t in ready if _trust.norm(t.get("circle")) == c0] cap = _batch_cap(level_cfg) budget_chars = int(level_cfg.ctx_tokens * CHARS_PER_TOKEN * TASK_BUDGET_SHARE) picked, used = [], 0 @@ -368,6 +375,21 @@ def build_context(level_cfg, model: dict, batch=None): """ parts = [level_cfg.mandate] + # динамическая память (harness/memory.py): глубина D0–D3 по входам тика, + # потолок — круг доверия; индекс догоняет стор перед поиском + depth, mem_circle = 1, 1 + try: + from . import memory as _memory, trust as _trust + _inb = _pending_inbox(model) + _bat = batch if batch is not None else [] + mem_circle = _trust.tick_circle(model, _inb, _bat) + depth, _why = _memory.choose_depth(level_cfg.name, _inb, _bat, mem_circle) + if depth >= 2 and _memory.enabled(): + _memory.sync() + except Exception as e: + _log(f"memory: глубина/синк не удались ({str(e)[:120]}) — D1") + depth = 1 + # идентичность/цели v0.1 — статично из policy (если есть); # north-star метрика AUM (t-260929161138) — тоже policy, инжект всем уровням policy = model.get("policy") @@ -383,7 +405,7 @@ def build_context(level_cfg, model: dict, batch=None): digests = [] for lvl in lower: for key, d in model["digests"].items(): - if d["level"] == lvl and not d.get("stub"): + if d["level"] == lvl and not _is_stub_digest(d): digests.append(d) digests.sort(key=lambda d: d["ts"], reverse=True) if digests: @@ -496,7 +518,9 @@ def build_context(level_cfg, model: dict, batch=None): if m.get("replied") and not m.get("ignored") and m.get("chat_id") in chat_ids and (m.get("text") or "").strip()] - own = _own_messages(chat_ids, replied_hist) + if depth == 0: + replied_hist = [] # D0: односложные реплики — память разговора не нужна + own = _own_messages(chat_ids, replied_hist) if depth >= 1 else [] if own: parts.append("ТВОИ НЕДАВНИЕ СООБЩЕНИЯ В ЭТОТ ЧАТ (сам отправил: итоги задач, " "уведомления, предложения — Alex может отвечать на них):\n" @@ -558,6 +582,16 @@ def build_context(level_cfg, model: dict, batch=None): lines = [f"- {t['id']}: {t['title'][:80]} [{t['size']}] {t['status']}" for t in tasks_board[:15]] parts.append("ДОСКА ЗАДАЧ:\n" + "\n".join(lines)) + if depth >= 2: + try: + from . import memory as _memory + block = _memory.context_block(depth, level_cfg.name, _pending_inbox(model), + batch or [], mem_circle) + if block: + parts.append(block) + except Exception as e: + _log(f"memory: блок памяти не собран ({str(e)[:120]})") + return "\n\n".join(parts) @@ -635,6 +669,39 @@ def _core_task_fit(batch: list) -> list: return keep +_UNDER_RE = __import__("re").compile( + r"не\s+помню|не\s+нашёл|не\s+нашел|нет\s+(данных|в\s+памяти|информации)|" + r"не\s+знаю,?\s+(что|о\s+чём)|не\s+вижу\s+(в\s+контексте|истори)", __import__("re").I) + + +def _depth_record(level_cfg, trace_id, text): + """DepthChosen: решение о глубине + признаки нехватки памяти по трассе + тика (поиск по базе, копание в своём сторе/дайджестах, «не помню») — + сырьё головы ядра «depth».""" + try: + from . import memory as _memory + ch = _memory.last_choice(level_cfg.name) + if not ch or not trace_id: + return + db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30) + try: + calls = [json.loads(d) for (d,) in db.execute( + "SELECT data FROM events WHERE event_type='ToolCalled' " + "AND json_extract(data,'$.trace_id')=?", (trace_id,))] + finally: + db.close() + rag = sum(1 for c in calls if c.get("tool") == "rag_lookup") + dig = sum(1 for c in calls if c.get("tool") == "shell" and __import__("re").search( + r"harness\.db|cli\.py (events|tg-list|escalations)|docs/digests|sqlite3", c.get("input") or "")) + under = bool(rag or dig or _UNDER_RE.search(text or "")) + es.append_event("trace", f"depth:{trace_id}", "DepthChosen", { + "level": level_cfg.name, "trace_id": trace_id, "depth": ch["depth"], + "reasons": ch.get("reasons"), "circle": ch.get("circle"), "hits": ch.get("hits"), + "query": ch.get("query"), "rag_calls": rag, "self_digging": dig, "under": under}) + except Exception: + pass # трасса — best effort + + def _trust_followup(trace_id, batch, circle): """Страж отказал в тике с задачами круга ≥ 2 → владельцу кнопки «разрешить полные права» по каждой такой задаче (раз на задачу).""" @@ -652,7 +719,7 @@ def _trust_followup(trace_id, batch, circle): why = json.loads(rows[0][0]).get("reason") or "права круга" from . import trust as _trust for t in batch or []: - if int(t.get("circle") or 1) >= 2: + if _trust.norm(t.get("circle")) >= 2: _trust.ask_owner_elevation(t, why) except Exception as e: _log(f"trust: кнопка владельцу не ушла ({e})") @@ -829,6 +896,7 @@ def run_level_tick(level_cfg, seq: int, model: dict): circle=circle, ) _trust_followup(trace_id, batch, circle) + _depth_record(level_cfg, trace_id, text) except Exception as e: # e-260930162600-cbb7e2/cbb7e2-семейство: TimeoutExpired от isolated_runner # пролетал МИМО блока text-is-None ниже — claimed-задачи висели в running diff --git a/harness/tg_inbox.py b/harness/tg_inbox.py index c092548..4302e17 100644 --- a/harness/tg_inbox.py +++ b/harness/tg_inbox.py @@ -99,31 +99,34 @@ def _strip_html(s: str) -> str: return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", s or "")).strip() -def resolve_bot_message(chat_id, message_id, prefix: str = "") -> str: +def resolve_bot_message(chat_id, message_id, prefix: str = "", with_ts: bool = False): """Полный текст НАШЕГО сообщения, на которое ответили: журнал исходящих по - (chat, message_id); для старых — по совпадению начала с доставками/ответами.""" + (chat, message_id); для старых — по совпадению начала с доставками/ответами. + with_ts=True → (текст, время отправки) — для глубины памяти (давнее → D2).""" import sqlite3 + found = ("", "") try: db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30) try: - row = db.execute("SELECT data FROM events WHERE aggregate='outbound' AND aggregate_id=? " - "LIMIT 1", (f"{chat_id}:{message_id}",)).fetchone() + row = db.execute("SELECT data, timestamp FROM events WHERE aggregate='outbound' " + "AND aggregate_id=? LIMIT 1", (f"{chat_id}:{message_id}",)).fetchone() if row: - return json.loads(row[0]).get("text") or "" - want = _strip_html(prefix)[:150] - if len(want) < 20: - return "" - for (d,) in db.execute( - "SELECT data FROM events WHERE event_type IN ('DeliverySent','ReplySent'," - "'FollowupSent','OutboundMessage') ORDER BY id DESC LIMIT 3000"): - t = json.loads(d).get("text") or "" - if _strip_html(t).startswith(want): - return t + found = (json.loads(row[0]).get("text") or "", row[1]) + else: + want = _strip_html(prefix)[:150] + if len(want) >= 20: + for d, ts in db.execute( + "SELECT data, timestamp FROM events WHERE event_type IN ('DeliverySent'," + "'ReplySent','FollowupSent','OutboundMessage') ORDER BY id DESC LIMIT 3000"): + t = json.loads(d).get("text") or "" + if _strip_html(t).startswith(want): + found = (t, ts) + break finally: db.close() except Exception: pass - return "" + return found if with_ts else found[0] def set_reaction(chat_id, message_id: int, emoji: str = "👀", is_big: bool = False): @@ -455,10 +458,11 @@ def receive_message(update: dict): data["reply_to_first_name"] = rfrom.get("first_name") data["mentions_bot"] = _mentions_bot(msg) if data.get("reply_to_is_bot") and data.get("reply_to_message_id"): - full = resolve_bot_message(chat.get("id"), data["reply_to_message_id"], - data.get("reply_to_text") or "") + full, rts = resolve_bot_message(chat.get("id"), data["reply_to_message_id"], + data.get("reply_to_text") or "", with_ts=True) if full: data["reply_to_full"] = full[:4000] + data["reply_to_ts"] = rts es.append_event("inbox", f"m-{update['update_id']}", "MessageReceived", data) return f"m-{update['update_id']}" diff --git a/harness/trust.py b/harness/trust.py index 854f4c3..f2cdf61 100644 --- a/harness/trust.py +++ b/harness/trust.py @@ -1,5 +1,10 @@ """Simbiot Harness — круги доверия (вторая ось к лестнице времени). +Куб 4×4×4 (Alex, 2026-10-17): 4 круга × 4 тика (sec/min/hour/day) × 4 +глубины памяти (harness/memory.py D0–D3) — одна таблица политик, не 64 +программы. Круги: 1 владелец (+система), 2 правление, 3 свои (члены, +MCP-агенты, партнёры), 4 остальные. + Время (sec…year) отвечает «как часто и как глубоко думать», доверие — «что можно с этим человеком». Оси независимы: 8 циклов × 8 кругов — не 64 программы, а один код + таблица политик POLICY (уровни, инструменты). @@ -30,9 +35,17 @@ from . import es OWNER_TG = os.environ.get("SIMBIOT_OWNER_TG", "7569223131") # Alex -NAMES = {0: "система", 1: "владелец", 2: "правление", 3: "члены кооператива", - 4: "агенты кооператива", 5: "партнёры и клиенты", 6: "знакомые без роли", - 7: "чужие"} +NAMES = {1: "владелец", 2: "правление", 3: "свои", 4: "остальные"} + + +def norm(c) -> int: + """Круг в 1..4: тик без внешних входов (система) — 1; до 4 кругов были + номера 0..7 — в сторе только 1/2, смысл которых не изменился.""" + try: + c = int(c) + except (TypeError, ValueError): + return 1 + return min(max(c, 1), 4) _REPLY = {"tg-reply", "decide"} _WORK = _REPLY | {"task", "task-await", "complete", "escalate", "block"} @@ -43,22 +56,20 @@ _MCP_BOARD = {"booking_slots", "booking_create", "booking_cancel", "list_calls", "contact_get", "contact_list", "feature_backlog", "feature_request"} _MCP_MEMBER = {"booking_slots", "booking_create", "projects_list", "whoami", "feature_request", "feature_backlog"} -_MCP_AGENT = {"projects_list", "list_agents", "whoami", "list_events", "feature_backlog"} -# уровни времени × круги: кого какой уровень обслуживает (задачи круга вне -# списка уровня он не берёт); инструменты — по кругу +# тики × круги: какие уровни обслуживают круг (задачи круга вне списка +# уровень не берёт); инструменты и максимальная глубина памяти — по кругу. +# «Свои» (3) — члены портала, MCP-агенты, партнёры: запись на звонки, +# проекты, фичи; казна/member_create/деньги — нет. POLICY = { - 0: {"levels": None, "shell": "full", "files": "full", "mcp": "*", "rag": True}, - 1: {"levels": None, "shell": "full", "files": "full", "mcp": "*", "rag": True}, + 1: {"levels": None, "shell": "full", "files": "full", "mcp": "*", "rag": True, + "max_depth": 3}, 2: {"levels": ("min", "hour", "day"), "shell": _BOARD, "files": "read_safe", - "mcp": _MCP_BOARD, "rag": True}, - 3: {"levels": ("min", "hour"), "shell": _WORK, "files": "none", "mcp": _MCP_MEMBER, "rag": True}, - 4: {"levels": ("min", "hour"), "shell": _WORK, "files": "none", "mcp": _MCP_AGENT, "rag": True}, - 5: {"levels": ("min",), "shell": _REPLY | {"escalate"}, "files": "none", - "mcp": {"booking_slots", "booking_create"}, "rag": False}, - 6: {"levels": ("min",), "shell": _REPLY | {"escalate"}, "files": "none", "mcp": set(), - "rag": False}, - 7: {"levels": (), "shell": set(), "files": "none", "mcp": set(), "rag": False}, + "mcp": _MCP_BOARD, "rag": True, "max_depth": 3}, + 3: {"levels": ("min", "hour"), "shell": _WORK, "files": "none", "mcp": _MCP_MEMBER, + "rag": True, "max_depth": 2}, + 4: {"levels": ("min",), "shell": _REPLY | {"escalate"}, "files": "none", "mcp": set(), + "rag": False, "max_depth": 1}, } REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -87,42 +98,43 @@ def default_circle(subject: str) -> int: return 1 # локальный терминал = доступ к серверу if subject == f"tg:{OWNER_TG}": return 1 - if subject.startswith("agent:"): - return 4 - if subject.startswith("portal:"): - return 3 - if subject.startswith("tg:"): - return 6 # прошёл ALLOWED, но роли нет - return 7 + if subject.startswith(("agent:", "portal:")): + return 3 # свои: MCP-агенты и члены портала + return 4 # прочие tg (прошли ALLOWED без роли) и чужие def circle_of(subject: str, model: dict = None) -> int: trust = ((model or {}).get("trust") or {}) if subject in trust: - return int(trust[subject]["circle"]) + return norm(trust[subject]["circle"]) return default_circle(subject) def tick_circle(model: dict, inbox: list, tasks: list) -> int: - """Самый недоверенный вход контекста тика; без внешних входов — 0.""" - c = 0 + """Самый недоверенный вход контекста тика; без внешних входов — 1 (система).""" + c = 1 for m in inbox or []: c = max(c, circle_of(subject_of(m), model)) for t in tasks or []: - c = max(c, int(t.get("circle", 1) if t.get("circle") is not None else 1)) + c = max(c, norm(t.get("circle"))) return c def level_serves(level: str, circle: int) -> bool: - lv = POLICY[circle]["levels"] + lv = POLICY[norm(circle)]["levels"] return lv is None or level in lv +def max_depth(circle: int) -> int: + """Потолок глубины памяти круга (D0–D3, harness/memory.py).""" + return POLICY[norm(circle)]["max_depth"] + + # ────────────────────────── изменение круга (только человек) ────────────────────────── def set_circle(subject: str, circle: int, by: str, note: str = "") -> None: if circle not in NAMES: - raise ValueError(f"круг {circle} вне 0..7") + raise ValueError(f"круг {circle} вне 1..4") if os.environ.get("SIMBIOT_TICK_CIRCLE"): raise PermissionError("круг доверия меняет только человек — не из LLM-тика") es.append_event("trust", subject, "TrustSet", @@ -152,7 +164,8 @@ def _strip_cd(cmd: str) -> str: def check_shell(cmd: str, circle: int): - """(ok, причина). Полный shell — круги 0/1; иначе одна команда cli.py из списка.""" + """(ok, причина). Полный shell — круг 1; иначе одна команда cli.py из списка.""" + circle = norm(circle) if re.search(r"cli\.py\s+trust\b", cmd): return False, "менять круги доверия из тика нельзя — только человек" pol = POLICY[circle]["shell"] @@ -178,13 +191,14 @@ def check_shell(cmd: str, circle: int): if sub == "tg-deliver-file": args = toks[toks.index(sub) + 1:] if sub in toks else [] path = next((a for a in args if not a.startswith("-")), "") - ok, why = check_path(path, "read", 2 if circle <= 2 else circle) + ok, why = check_path(path, "read", 2) if not ok: return False, "файл: " + why return True, "" def check_path(path: str, op: str, circle: int): + circle = norm(circle) pol = POLICY[circle]["files"] if pol == "full": return True, "" @@ -209,6 +223,7 @@ def check_path(path: str, op: str, circle: int): def check_tool(name: str, inp: dict, circle: int): """Решение стража по вызову инструмента LLM-тика: (ok, причина).""" inp = inp or {} + circle = norm(circle) if name == "shell": return check_shell(str(inp.get("command") or ""), circle) if name in ("read", "write", "edit"): @@ -230,6 +245,7 @@ def check_tool(name: str, inp: dict, circle: int): def mandate_line(circle: int) -> str: + circle = norm(circle) pol = POLICY[circle] if pol["shell"] == "full": return f"КРУГ ДОВЕРИЯ ТИКА: {circle} ({NAMES[circle]}) — полные права." @@ -253,7 +269,7 @@ def ask_owner_elevation(task: dict, reason: str) -> bool: chat = tg_inbox._default_chat_id() if chat is None: return False - c = int(task.get("circle") or 1) + c = norm(task.get("circle")) text = (f"🔐 Задача из круга «{NAMES.get(c, c)}» упёрлась в права:\n" f"«{(task.get('title') or '')[:200]}»\n\nЧто не дали: {reason[:300]}\n\n" "Разрешить выполнить с полными правами?") diff --git a/tests/test_kodi_core_tasks.py b/tests/test_kodi_core_tasks.py index 2e2e6a3..f5f5062 100644 --- a/tests/test_kodi_core_tasks.py +++ b/tests/test_kodi_core_tasks.py @@ -163,7 +163,7 @@ os.environ["SIMBIOT_CORE_DIR"] = os.path.join(_tmp, "core") kodi_core._forget("task_fit") st = kodi_core.status() -check("статус: обе головы", set(st) == {"addressing", "task_fit"} and st["task_fit"]["champion"], st.keys()) +check("статус: все головы", set(st) == {"addressing", "task_fit", "depth"} and st["task_fit"]["champion"], st.keys()) # ── rule-ветка (рефлексия t-261017093718): внешние системы → не влезет ── # чемпиона нет (пустой core-dir, как снятый) → решает правило; route=rule:ext diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..db05ae5 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +"""Динамическая память Коди (harness/memory.py) — hermetic. + +Запуск: ./venv/bin/python tests/test_memory.py + +Эмбеддинги — «мешок слов» (общие слова → близость), Ollama не трогается. +Проверяет: память выключена по умолчанию; синк: разговоры (батч — ответ +головы), свои рассылки (не ответы), итоги задач, дайджесты без заглушек; +круг приватности записи (личка Alex — 1, правление — 2, задача — её круг); +поиск не отдаёт тику правления записи владельца; свежий разговор чата не +дублируется; глубина: D1/D0/D2/D3 по правилам, потолок круга, давний +reply; контекст: блок памяти на D2+, D0 без памяти разговора; заглушки +дайджестов не в выжимках; DepthChosen с признаком нехватки памяти. +""" +import hashlib +import json +import os +import re +import sys +import tempfile +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, "/root/simbiot-harness") +_tmp = tempfile.mkdtemp(prefix="simbiot_memory_") +os.environ["SIMBIOT_DB"] = os.path.join(_tmp, "harness.db") +os.environ["SIMBIOT_MEMORY_DIR"] = os.path.join(_tmp, "memory") +os.environ["SIMBIOT_CORE_DIR"] = os.path.join(_tmp, "core") +for _v in ("SIMBIOT_MEMORY", "SIMBIOT_DECISION"): + os.environ.pop(_v, None) + +import numpy as np + +from harness import es, kodi_core, memory, scheduler, trust +from harness.levels import LEVELS + +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) + + +def bow(texts, prefix="classification"): + out = [] + for t in texts: + v = np.zeros(768, np.float32) + for w in re.findall(r"\w{4,}", t.lower()): + v[int(hashlib.md5(w.encode()).hexdigest(), 16) % 768] += 1 + v += 1e-3 + out.append(v / np.linalg.norm(v)) + return out + + +kodi_core.embed = bow +check("память выключена по умолчанию", memory.enabled() is False) +os.environ["SIMBIOT_MEMORY"] = "on" +trust.bootstrap() +A, V, G = 7569223131, 156156021, -1004402779881 +N = [0] + + +def msg(chat, uid, who, text, reply=None, batch=None): + N[0] += 1 + mid = f"m-{N[0]}" + es.append_event("inbox", mid, "MessageReceived", {"update_id": N[0], "chat_id": chat, + "user_id": uid, "first_name": who, "text": text, "chat_type": + "private" if chat > 0 else "supergroup"}) + if reply is not None: + d = {"to_chat": chat, "text": reply} + if batch: + d["batch"] = batch + es.append_event("inbox", mid, "ReplySent", d) + return mid + + +h = msg(A, A, "Alex", "секретный бюджет казны проекта альфа", "Бюджет альфа: 40 тысяч") +msg(G, V, "Vlad", "лендинг фильма мой срок дизайн обложки", "Сделаю лендинг фильма") +b1 = msg(A, A, "Alex", "проверь сервер миграцию", "Сервер мигрирован, всё ок", + batch={"head": None}) +b2 = msg(A, A, "Alex", "и бэкапы тоже", "(сводный ответ батча b-1 — см. " + b1 + ")", + batch={"head": b1}) +es.append_event("outbound", f"{A}:1", "OutboundMessage", {"chat_id": A, "message_id": 1, + "text": "🧠 Предложение: включить ученика адресации"}) +es.append_event("outbound", f"{A}:2", "OutboundMessage", {"chat_id": A, "message_id": 2, + "text": "Сервер мигрирован, всё ок"}) # это ответ — уже в conv +es.append_event("task", "t-1", "TaskSubmitted", {"id": "t-1", "title": "Обложка фильма мой срок", + "size": "M", "circle": 2}) +es.append_event("task", "t-1", "TaskCompleted", {"answer": "Обложка фильма готова: out/cover.png"}) +es.append_event("cycle", "digest:hour:1", "DigestWritten", {"level": "hour", "period": "p1", + "text": "Час: обсудили бюджет проекта альфа, сколько там денег и сроки"}) +es.append_event("cycle", "digest:hour:2", "DigestWritten", {"level": "hour", "period": "p2", + "text": "[gate:quiet] hour: LLM не запускался", "stub": True}) +n = memory.sync_all() +st = memory.stats() +check("синк: разговоры/рассылка/задача/дайджест", st["items"] == {"conv": 4, "out": 1, "task": 1, "digest": 1}, st) +db = memory._db() +items = {r[0]: r for r in db.execute("SELECT id, kind, circle, text FROM items")} +db.close() +check("ответ батча — текст головы", "Сервер мигрирован" in items[f"conv:{b2}"][3]) +check("рассылка-ответ не дублируется как out", not any( + r[1] == "out" and "мигрирован" in r[3] for r in items.values())) +check("личка Alex — круг 1", items[f"conv:{h}"][2] == 1) +check("группа с Vlad — круг 2", items["conv:m-2"][2] == 2) +check("задача правления — круг 2, дайджест — 1", + items["task:t-1"][2] == 2 and items["digest:hour:p1"][2] == 1) +check("заглушка дайджеста не в памяти", "digest:hour:p2" not in items) +check("повторный синк — без новых записей", memory.sync() == 0) + +# ── поиск и приватность ── +s2 = memory.search("бюджет альфа казна", circle=2, k=10) +check("тик правления не видит личку владельца", all(x["circle"] >= 2 for x in s2) + and not any("секретный" in x["text"] for x in s2), s2) +s1 = memory.search("бюджет альфа казна", circle=1, k=3) +check("владелец находит по смыслу", s1 and "альфа" in s1[0]["text"], s1) +since = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() +s3 = memory.search("бюджет альфа", circle=1, k=10, exclude_chat=A, exclude_since=since) +check("свежий разговор чата не дублируется", not any(x["kind"] == "conv" and "альфа" in x["text"] for x in s3)) + +# ── глубина ── +cd = lambda lvl, inbox, batch=(), c=1: memory.choose_depth(lvl, list(inbox), list(batch), c)[0] +m_ = lambda t, **k: {"text": t, "chat_id": A, **k} +check("мин по умолчанию — D1", cd("min", [m_("как дела с лендингом")]) == 1) +check("«ок/спасибо» — D0", cd("min", [m_("ок")]) == 0 and cd("min", [m_("Спасибо!")]) == 0) +check("«помнишь…» — D2", cd("min", [m_("помнишь, что мы решили по бюджету?")]) == 2) +check("«что ты делал за месяц» — D3", cd("min", [m_("расскажи что ты делал за последний месяц")]) == 3) +check("задачи в батче — D2", cd("min", [], [{"title": "x"}]) == 2) +check("час — D2, день — D3", cd("hour", []) == 2 and cd("day", []) == 3) +check("потолок круга 4 — D1", cd("min", [m_("что было за месяц")], c=4) == 1) +check("потолок круга 3 — D2", cd("day", [], c=3) == 2) +old = (datetime.now(timezone.utc) - timedelta(days=5)).isoformat() +check("reply на давнее сообщение бота — D2", cd("min", [m_("давай", reply_to_full="x", reply_to_ts=old)]) == 2) + +# ── контекст ── +q = msg(A, A, "Alex", "помнишь бюджет проекта альфа? сколько там") +ctx = scheduler.build_context(LEVELS["min"], es.run_projections(), batch=[]) +check("контекст D2: блок памяти", "ПАМЯТЬ — НАЙДЕНО ПО СМЫСЛУ (глубина D2" in ctx) +es.append_event("inbox", q, "ReplySent", {"to_chat": A, "text": "40 тысяч"}) +msg(G, V, "Vlad", "помнишь бюджет альфа?") +ctx2 = scheduler.build_context(LEVELS["min"], es.run_projections(), batch=[]) +check("тик правления: память без лички владельца", "ПАМЯТЬ" not in ctx2 or "секретный" not in ctx2) +es.append_event("inbox", f"m-{N[0]}", "ReplySent", {"to_chat": G, "text": "не скажу"}) +msg(A, A, "Alex", "ок") +ctx3 = scheduler.build_context(LEVELS["min"], es.run_projections(), batch=[]) +check("D0: без памяти разговора", "НЕДАВНИЙ РАЗГОВОР" not in ctx3 and "ПАМЯТЬ" not in ctx3) +ctxh = scheduler.build_context(LEVELS["hour"], es.run_projections(), batch=[]) +check("выжимки: заглушки дайджестов отсеяны", "LLM не запускался" not in ctxh) + +# ── DepthChosen ── +memory.choose_depth("min", [m_("помнишь?")], [], 1) +es.append_event("trace", "min-77", "ToolCalled", {"trace_id": "min-77", "tool": "rag_lookup", "input": "{}"}) +scheduler._depth_record(LEVELS["min"], "min-77", "ответ") +dc = [json.loads(e["data"]) for e in es.get_events(0, 10**6) if e["event_type"] == "DepthChosen"] +check("DepthChosen: глубина, причины, нехватка памяти", dc and dc[-1]["depth"] == 2 + and dc[-1]["under"] is True and dc[-1]["rag_calls"] == 1, dc[-1:]) + +# ── голова ядра depth: учится на решениях правил + поправках «не хватило» ── +for i in range(60): + past = i % 3 == 0 + under = i % 3 == 1 # «расскажи итоги проекта» без слов-триггеров, но агент полез искать + q = ("помнишь, что решили по проекту " if past else + "расскажи статус проекта подробно " if under else "привет как дела ") + str(i) + es.append_event("trace", f"depth:min-{i}", "DepthChosen", { + "level": "min", "trace_id": f"min-{i}", "depth": 2 if past else 1, "reasons": [], + "circle": 1, "query": q, "under": under, "rag_calls": int(under)}) +rows = kodi_core.depth_dataset() +check("depth: поправка «не хватило» → метка 1, вес 3", + any(r[1] == 1.0 and r[2] == 3.0 and r[3] == "under" for r in rows)) +rep = kodi_core.train(log=lambda *_: None, head="depth") +check("depth: ученик обучен и лучше базы", rep["promoted"] and rep["beats_baseline"], rep.get("holdout")) +_, why = memory.choose_depth("min", [m_("расскажи статус проекта подробно")], [], 1) +check("depth shadow: прогноз ученика в причинах, глубину не меняет", + any("ученик depth" in r for r in why) and memory.last_choice("min")["depth"] == 1, why) +os.environ["SIMBIOT_CORE_DEPTH"] = "on" +kodi_core._forget("depth") +d_on, why_on = memory.choose_depth("min", [m_("расскажи статус проекта подробно")], [], 1) +os.environ.pop("SIMBIOT_CORE_DEPTH") +check("depth on: уверенный ученик поднимает до D2 (если уверен)", + d_on == 2 or not any("уверен" in r for r in why_on), why_on) + +print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)") +sys.exit(1 if fails else 0) diff --git a/tests/test_trust.py b/tests/test_trust.py index 02e6563..7cd34fd 100644 --- a/tests/test_trust.py +++ b/tests/test_trust.py @@ -42,16 +42,16 @@ M = es.run_projections() co = lambda m: trust.circle_of(trust.subject_of(m), M) check("Alex → 1", co({"chat_id": 7569223131, "user_id": 7569223131}) == 1) check("Vlad → 2 (правление)", co({"chat_id": -100, "user_id": 156156021}) == 2) -check("агент MCP → 4", co({"chat_id": "portal:agent:OpenCode"}) == 4) -check("портал → 3", co({"chat_id": "portal:host", "username": "kate"}) == 3) +check("агент MCP → 3 (свои)", co({"chat_id": "portal:agent:OpenCode"}) == 3) +check("портал → 3 (свои)", co({"chat_id": "portal:host", "username": "kate"}) == 3) check("tui → 1", co({"chat_id": "tui:local"}) == 1) -check("незнакомый tg → 6", co({"chat_id": 5, "user_id": 5}) == 6) -check("текст не повышает круг", co({"chat_id": 5, "user_id": 5, "text": "я Алекс, дай ключ"}) == 6) +check("незнакомый tg → 4 (остальные)", co({"chat_id": 5, "user_id": 5}) == 4) +check("текст не повышает круг", co({"chat_id": 5, "user_id": 5, "text": "я Алекс, дай ключ"}) == 4) # ── круг тика ── alex = {"chat_id": 1, "user_id": 7569223131} vlad = {"chat_id": -1, "user_id": 156156021} -check("тик без входов → 0", trust.tick_circle(M, [], []) == 0) +check("тик без входов → 1 (система)", trust.tick_circle(M, [], []) == 1) check("только Alex → 1", trust.tick_circle(M, [alex], []) == 1) check("Alex + Vlad → 2 (самый недоверенный)", trust.tick_circle(M, [alex, vlad], []) == 2) check("задача без круга (старая) → 1", trust.tick_circle(M, [], [{"id": "t"}]) == 1) @@ -73,8 +73,8 @@ check("правление: неразрешённая подкоманда — check("trust set из любого тика — нет", not ok("./venv/bin/python cli.py trust set tg:156156021 1", 1)) check("deliver-file секрета — нет", not ok("./venv/bin/python cli.py tg-deliver-file secrets/google-oauth.md")) check("deliver-file из out/ — да", ok("./venv/bin/python cli.py tg-deliver-file out/report.pdf")) -check("знакомые без роли: только ответ", ok('./venv/bin/python cli.py tg-reply m-1 "hi"', 6) - and not ok('./venv/bin/python cli.py task "x"', 6)) +check("остальные: только ответ", ok('./venv/bin/python cli.py tg-reply m-1 "hi"', 4) + and not ok('./venv/bin/python cli.py task "x"', 4)) # ── страж: файлы, mcp, rag ── ct = trust.check_tool @@ -86,8 +86,12 @@ check("владелец: писать — да", ct("edit", {"path": "/root/simb check("правление: booking — да", ct("portal_mcp", {"tool": "booking_create"}, 2)[0]) check("правление: казна — нет", not ct("portal_mcp", {"tool": "treasury_status"}, 2)[0]) check("правление: member_create — нет", not ct("portal_mcp", {"tool": "member_create"}, 2)[0]) -check("агент: rag — да, booking — нет", ct("rag_lookup", {}, 4)[0] and not ct("portal_mcp", {"tool": "booking_create"}, 4)[0]) -check("партнёр: rag — нет", not ct("rag_lookup", {}, 5)[0]) +check("свои: rag и запись на звонок — да, казна — нет", ct("rag_lookup", {}, 3)[0] + and ct("portal_mcp", {"tool": "booking_create"}, 3)[0] and not ct("portal_mcp", {"tool": "treasury_status"}, 3)[0]) +check("остальные: rag — нет", not ct("rag_lookup", {}, 4)[0]) +check("старые номера кругов нормализуются (0→1, 7→4)", trust.norm(0) == 1 and trust.norm(7) == 4 and trust.norm(None) == 1) +check("потолок глубины памяти: 1/2 → D3, 3 → D2, 4 → D1", + [trust.max_depth(c) for c in (1, 2, 3, 4)] == [3, 3, 2, 1]) check("неизвестный инструмент у правления — нет", not ct("python_exec", {}, 2)[0]) check("мандат: строка круга", "правление" in trust.mandate_line(2) and "полные права" in trust.mandate_line(1)) @@ -109,10 +113,10 @@ check("задача вне тика — без круга (= 1)", T[tid_a].get(" hour = LEVELS["hour"] ids = scheduler.select_task_batch(hour, es.run_projections()) check("батч одного круга", len({int(es.run_projections()["tasks"][i].get("circle") or 1) for i in ids}) == 1, ids) -os.environ["SIMBIOT_TICK_CIRCLE"] = "5" -tid_p, _ = commands.submit_task("Партнёрская задача на час", "детали", 60) +os.environ["SIMBIOT_TICK_CIRCLE"] = "4" +tid_p, _ = commands.submit_task("Задача остальных на час", "детали", 60) os.environ.pop("SIMBIOT_TICK_CIRCLE") -check("час не берёт задачи партнёров (круг 5 — только min)", +check("час не берёт задачи круга «остальные» (только min)", tid_p not in scheduler.select_task_batch(hour, es.run_projections())) # ── одобрение владельцем ──