From 17a5befd8640c2adb802a3ba8e1ef55fc50d1c3d Mon Sep 17 00:00:00 2001 From: Simbiot Harness Date: Sat, 17 Oct 2026 10:24:29 +0000 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B3=D0=BE=D0=B2=D0=BE=D1=80=D0=B0:=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=82=20=D0=BD=D0=B0=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=20=E2=80=94=20=D1=81=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BE=D0=BC?= =?UTF-8?q?,=20=D1=81=D0=B2=D0=BE=D0=B8=20=D1=80=D0=B0=D1=81=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B8=20=D0=B2=20=D0=BF=D0=B0=D0=BC=D1=8F=D1=82?= =?UTF-8?q?=D0=B8=20=D1=82=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кейс Alex 10:10: «Ок делай» реплаем на итог рефлексии. Telegram отдаёт в reply начало, харнес хранил 300 символов — тик видел «✅ Готово: Рефлексия…» без самого предложения; два мин-тика (10:11–10:17) искали по коду «что за переключатель» и упирались в бюджет, ответ — через 8 минут «посмотрю». - журнал исходящих: send_message пишет OutboundMessage (chat:message_id → полный текст до 4000) — любой ответ/доставка/уведомление; - receive_message: reply на бота → reply_to_full по message_id; для старых сообщений — по началу текста среди DeliverySent/ReplySent; - контекст тика: «↳ ЭТО ОТВЕТ НА ТВОЁ СООБЩЕНИЕ (полный текст)»; - память тика: «ТВОИ НЕДАВНИЕ СООБЩЕНИЯ» — собственные рассылки бота (итоги задач, уведомления, предложения) за 48 ч, без дублей с ответами; - kodi_core.status: прогноз rule-ветки без p_fit (коммит часа a163e88, проверен: precision 0.74 / recall 0.52 на 93 задачах) не валит статус. Тест: test_reply_memory 9 PASS; полный набор — без регрессий. Co-Authored-By: Claude Opus 5.5 --- harness/kodi_core.py | 8 ++- harness/scheduler.py | 44 +++++++++++++- harness/tg_inbox.py | 65 +++++++++++++++++++-- tests/test_reply_memory.py | 116 +++++++++++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 tests/test_reply_memory.py diff --git a/harness/kodi_core.py b/harness/kodi_core.py index f1928cb..72d6041 100644 --- a/harness/kodi_core.py +++ b/harness/kodi_core.py @@ -1027,8 +1027,14 @@ def status() -> dict: hist = task_history() tp = [p for p in preds if p.get("head") == TASK_HEAD] judged = [(p, task_label(hist[p["task"]])) for p in tp if p.get("task") in hist] + # прогноз «влезет» = p_fit ≥ 0.5; у rule-ветки без чемпиона p_fit=None — + # её прогноз: rule:ext → «не влезет», rule → «влезет» + def _says_fit(p): + if p.get("p_fit") is not None: + return p["p_fit"] >= .5 + return p.get("route") != "rule:ext" judged = [(p, y) for p, y in judged if y is not None] - out[TASK_HEAD]["outcome_acc_7d"] = (round(sum((p["p_fit"] >= .5) == (y >= .5) for p, y in judged) + out[TASK_HEAD]["outcome_acc_7d"] = (round(sum(_says_fit(p) == (y >= .5) for p, y in judged) / len(judged), 3) if judged else None) out[TASK_HEAD]["judged_7d"] = len(judged) return out diff --git a/harness/scheduler.py b/harness/scheduler.py index 82715bd..7358949 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -126,6 +126,37 @@ def _recent_docs(model: dict, chat_id, limit=3): return out +def _own_messages(chat_ids, replied_hist, hours: int = 48, cap: int = 6) -> list: + """Собственные исходящие бота (не ответы на входящие) за последние часы — + в память тика. Раньше память разговора = только входящие + наши ответы: + итог задачи/уведомление/предложение, на которое Alex потом отвечает, + из памяти выпадали.""" + if not chat_ids: + return [] + since = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat() + replies = {(m.get("reply_text") or "")[:200] for m in replied_hist} + out = [] + try: + db = sqlite3.connect(f"file:{es.DB_PATH}?mode=ro", uri=True, timeout=30) + try: + rows = db.execute( + "SELECT timestamp, data FROM events WHERE event_type='OutboundMessage' " + "AND timestamp > ? ORDER BY id DESC LIMIT 200", (since,)).fetchall() + finally: + db.close() + except Exception: + return [] + for ts, d in rows: + d = json.loads(d) + t = d.get("text") or "" + if d.get("chat_id") not in chat_ids or t[:200] in replies or t.startswith("(сводный"): + continue + out.append(f"- [{ts[5:16].replace('T', ' ')}] {t[:800]}") + if len(out) >= cap: + break + return list(reversed(out)) + + def _est_minutes(t: dict) -> int: """Оценка длительности: из TaskSubmitted или дефолт по классу. @@ -431,7 +462,13 @@ def build_context(level_cfg, model: dict, batch=None): or (m.get("reply_to_desc") or "").strip()) else (" [reply на сообщение без текста]" if m.get("reply_to_message_id") is not None else "")) - + ": " + m.get('text','')[:1200]) + + ": " + m.get('text','')[:1200] + # ответ на НАШЕ сообщение: полный его текст, а не 300 + # символов из Telegram (аудит 2026-10-17: «Ок делай» + # на итог рефлексии — тик не видел, что именно делать) + + (f"\n ↳ ЭТО ОТВЕТ НА ТВОЁ СООБЩЕНИЕ (полный текст):\n " + + m["reply_to_full"][:3000].replace("\n", "\n ") + if m.get("reply_to_full") else "")) batch_ids = ",".join(ids) chat_ids = {m.get("chat_id") for m in inbox if m.get("chat_id") is not None} # t-261013131228: если в батче есть reply на сообщение без текста — @@ -459,6 +496,11 @@ 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 own: + parts.append("ТВОИ НЕДАВНИЕ СООБЩЕНИЯ В ЭТОТ ЧАТ (сам отправил: итоги задач, " + "уведомления, предложения — Alex может отвечать на них):\n" + + "\n".join(own)) if replied_hist: replied_hist = sorted(replied_hist, key=lambda x: x.get("ts") or "")[-12:] hist_lines = [] diff --git a/harness/tg_inbox.py b/harness/tg_inbox.py index e001c23..c092548 100644 --- a/harness/tg_inbox.py +++ b/harness/tg_inbox.py @@ -8,6 +8,7 @@ getUpdates offset=last+1. Идемпотентность: update_id → aggregate_id. """ import json +import re import os import sys import time @@ -66,14 +67,63 @@ def send_message(chat_id, text: str, reply_to=None, timeout: int = 35): # сообщение удалили — ответ всё равно уходит (без цитаты), не теряется params["allow_sending_without_reply"] = "true" try: - return _api("sendMessage", params, timeout=timeout) + r = _api("sendMessage", params, timeout=timeout) except urllib.error.HTTPError as e: # 400 от кривого HTML (незакрытый тег, «a < b» в тексте) — шлём как plain text. # Иначе ответ/доставка молча теряются: исключение глотается на верхнем уровне. - if e.code == 400: - params.pop("parse_mode", None) - return _api("sendMessage", params, timeout=timeout) - raise + if e.code != 400: + raise + params.pop("parse_mode", None) + r = _api("sendMessage", params, timeout=timeout) + _record_outbound(chat_id, r, text, reply_to) + return r + + +def _record_outbound(chat_id, r, text: str, reply_to=None): + """Журнал исходящих (аудит 2026-10-17): message_id → полный текст. Alex + ответил «Ок делай» на итог рефлексии, а Telegram отдаёт в reply только + начало, харнес хранил 300 символов — тик не видел самого предложения. + Best effort: журнал не мешает отправке.""" + try: + res = (r or {}).get("result") if isinstance(r, dict) else None + if not (isinstance(res, dict) and res.get("message_id")): + return + es.append_event("outbound", f"{chat_id}:{res['message_id']}", "OutboundMessage", { + "chat_id": chat_id, "message_id": res["message_id"], "text": (text or "")[:4000], + "reply_to": reply_to}) + except Exception: + pass + + +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: + """Полный текст НАШЕГО сообщения, на которое ответили: журнал исходящих по + (chat, message_id); для старых — по совпадению начала с доставками/ответами.""" + import sqlite3 + 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() + 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 + finally: + db.close() + except Exception: + pass + return "" def set_reaction(chat_id, message_id: int, emoji: str = "👀", is_big: bool = False): @@ -404,6 +454,11 @@ def receive_message(update: dict): data["reply_to_is_bot"] = bool(rfrom.get("is_bot")) 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 "") + if full: + data["reply_to_full"] = full[:4000] es.append_event("inbox", f"m-{update['update_id']}", "MessageReceived", data) return f"m-{update['update_id']}" diff --git a/tests/test_reply_memory.py b/tests/test_reply_memory.py new file mode 100644 index 0000000..7107b9a --- /dev/null +++ b/tests/test_reply_memory.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""Память разговора: ответ на сообщение бота видит его полностью (аудит 2026-10-17). Hermetic. + +Запуск: ./venv/bin/python tests/test_reply_memory.py + +Кейс: Alex ответил «Ок делай» на итог рефлексии; Telegram отдал в reply +начало, харнес хранил 300 символов — тик не видел, что делать. Проверяет: +send_message пишет OutboundMessage (message_id → полный текст); ответ на +бота получает reply_to_full по message_id; для старых (до журнала) — по +началу текста среди DeliverySent; контекст тика показывает полный текст и +хвост за 300 символами; собственные рассылки бота — в памяти тика, ответы +на входящие не дублируются; статус ядра не падает на прогнозе правила +без p_fit. +""" +import os +import re +import sys +import tempfile + +sys.path.insert(0, "/root/simbiot-harness") +_tmp = tempfile.mkdtemp(prefix="simbiot_replymem_") +os.environ["SIMBIOT_DB"] = os.path.join(_tmp, "harness.db") +os.environ["SIMBIOT_CORE_DIR"] = os.path.join(_tmp, "core") + +from harness import es, scheduler, tg_inbox +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) + + +P = 7569223131 +NEXT = [4964] + + +def fake_api(method, params, timeout=35): + if method == "sendMessage": + NEXT[0] += 1 + return {"ok": True, "result": {"message_id": NEXT[0], "chat": {"id": params["chat_id"]}}} + if method == "getMe": + return {"ok": True, "result": {"id": 7245454391, "username": "juno42bot"}} + return {"ok": True, "result": True} + + +tg_inbox._api = fake_api +tg_inbox._load_env = lambda: None + +PROPOSAL = ("✅ Готово: «Рефлексия задач»\n\nПравило найдено. " + "Обоснование. " * 40 + + "\n\nПРЕДЛОЖЕНИЕ АЛЕКСУ: включить — в .env добавить SIMBIOT_CORE_TASK_FIT=on.") +assert len(PROPOSAL) > 600 +tg_inbox.send_message(P, PROPOSAL) +ob = [e for e in es.get_events(0, 10**6) if e["event_type"] == "OutboundMessage"] +check("исходящее записано с message_id", ob and '"message_id": 4965' in ob[-1]["data"]) + + +def recv(uid, text, reply_mid=None, reply_text=""): + msg = {"message_id": 5000 + uid, "date": 1, "chat": {"id": P, "type": "private"}, + "from": {"id": P, "first_name": "Alex"}, "text": text} + if reply_mid: + msg["reply_to_message"] = {"message_id": reply_mid, "text": reply_text, + "from": {"id": 7245454391, "is_bot": True, "first_name": "Cody"}} + return tg_inbox.receive_message({"update_id": uid, "message": msg}) + + +mid = recv(1, "Ок делай", 4965, PROPOSAL[:300]) +m = es.run_projections()["inbox"][mid] +check("reply на бота: полный текст по message_id", m.get("reply_to_full") == PROPOSAL) + +# старая доставка (до журнала): только DeliverySent +OLD = "✅ Готово: «Старая задача»\n\n" + "Подробности. " * 50 + "\nИТОГ: сделать X." +es.append_event("delivery", "t-old", "DeliverySent", {"task_id": "t-old", "text": OLD}) +mid2 = recv(2, "давай", 4000, OLD[:300]) +check("старое сообщение: полный текст по началу", es.run_projections()["inbox"][mid2].get("reply_to_full") == OLD) +mid3 = recv(3, "?", 3999, "коротко") +check("слишком короткое начало — не угадываем", not es.run_projections()["inbox"][mid3].get("reply_to_full")) + +# контекст тика +ctx = scheduler.build_context(LEVELS["min"], es.run_projections(), batch=[]) +check("контекст: блок «ответ на твоё сообщение»", "ЭТО ОТВЕТ НА ТВОЁ СООБЩЕНИЕ" in ctx) +check("контекст: хвост за 300 символами виден", "SIMBIOT_CORE_TASK_FIT=on" in ctx) + +# память: собственные рассылки, без дублей с ответами +tg_inbox.notify_chat = tg_inbox.notify_chat # реальная, через send_message +tg_inbox._default_chat_id = lambda: P +tg_inbox.send_message(P, "🧠 Ядро Коди — неделя: отчёт") +tg_inbox.send_reply(mid3, "Это ответ на вопрос") +ctx2 = scheduler.build_context(LEVELS["min"], es.run_projections(), batch=[]) +own = "" +if "ТВОИ НЕДАВНИЕ СООБЩЕНИЯ" in ctx2: # секция — до следующей известной секции + tail = ctx2.split("ТВОИ НЕДАВНИЕ СООБЩЕНИЯ", 1)[1] + ends = [tail.find(h) for h in ("НЕДАВНИЙ РАЗГОВОР", "ВХОДЯЩИЕ СООБЩЕНИЯ") if tail.find(h) >= 0] + own = tail[:min(ends)] if ends else tail +check("память: рассылка бота в контексте", "Ядро Коди — неделя" in own, ctx2[:600]) +check("память: ответ на входящее не дублируется", "Это ответ на вопрос" not in own) + +# статус ядра с прогнозом правила без p_fit +from harness import kodi_core +es.append_event("core", "pred:task_fit:t-x", "CorePrediction", + {"head": "task_fit", "task": "t-x", "p_fit": None, "route": "rule:ext", "act": False}) +es.append_event("task", "t-x", "TaskSubmitted", {"id": "t-x", "title": "портал", "size": "S"}) +es.append_event("task", "t-x", "TaskClaimed", {"level": "min"}) +es.append_event("task", "t-x", "TaskClaimed", {"level": "min"}) +try: + st = kodi_core.status()["task_fit"] + check("статус ядра: прогноз правила без p_fit не валит", st["judged_7d"] == 1 and st["outcome_acc_7d"] == 1.0, st) +except Exception as e: + check("статус ядра: прогноз правила без p_fit не валит", False, e) + +print(f"\n{'OK' if not fails else 'FAILED'}: {len(fails)} fail(s)") +sys.exit(1 if fails else 0)