память разговора: ответ на сообщение бота — с полным его текстом, свои рассылки в памяти тика

Кейс 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 <noreply@anthropic.com>
This commit is contained in:
Simbiot Harness 2026-10-17 10:24:29 +00:00
parent a163e88d7b
commit 17a5befd86
4 changed files with 226 additions and 7 deletions

View file

@ -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

View file

@ -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 = []

View file

@ -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']}"

116
tests/test_reply_memory.py Normal file
View file

@ -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)