- бэкфилл индекса шёл 13 мин при 4 с CPU: _sync_once звал run_projections() (пишет в стор) и ждал блокировку живой лестницы — теперь только чтение; - круги людей — из событий TrustSet, задачи — из своих событий: отставшая проекция дала бы дефолтный круг 4 — ошибка в сторону утечки; - пороги релевантности по прод-индексу (nomic): релевантное 0.78–0.88, посторонний запрос ≤ 0.75 → MIN_SCORE 0.76 + окно 0.07 от лучшего; «рецепт борща» → пусто, «миграция Gennady» → нужная задача; - прод: индекс 1650 разговоров (личка 1 / правление 2 / агенты 3), 270 задач, 91 дайджест; поиск круга 2 не отдаёт записей круга 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
12 KiB
Python
187 lines
12 KiB
Python
# -*- 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)
|
||
# «мешок слов» даёт оценки ниже nomic — пороги под него (калибровка nomic — в коде)
|
||
os.environ["SIMBIOT_MEMORY_MIN_SCORE"] = "0.2"
|
||
os.environ["SIMBIOT_MEMORY_REL_WINDOW"] = "1.0"
|
||
|
||
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)
|