From d850d4828a952efc243a2b93de1b148eba41d596 Mon Sep 17 00:00:00 2001 From: simbiot-hour Date: Tue, 13 Oct 2026 13:15:44 +0000 Subject: [PATCH] =?UTF-8?q?t-261013131228:=20=D1=80=D0=B5=D0=B7=D0=BE?= =?UTF-8?q?=D0=BB=D0=B2=20=D1=86=D0=B5=D0=BB=D0=B8=20=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D1=8F=20=D0=B1=D0=B5=D0=B7=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D0=B0=20=E2=80=94=20reply=5Fto=5Fdesc=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=B8=D0=BD=D0=B1=D0=BE=D0=BA=D1=81=D0=B0=20(chat=5Fi?= =?UTF-8?q?d,=20reply=5Fto=5Fmessage=5Fid);=20build=5Fcontext=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=D1=80=D0=B6=D0=B8=D0=BC=D0=BE=D0=B5=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=C2=ABreply=20=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=C2=BB;=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=208=20PASS=20(=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82/=D1=82=D0=B5=D0=BA=D1=81=D1=82/=D0=B2=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B8=D0=BD=D0=B1=D0=BE=D0=BA=D1=81=D0=B0/=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- harness/scheduler.py | 56 +++++++++++++- harness/tg_inbox.py | 47 ++++++++++++ tests/test_reply_target_resolve.py | 116 +++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 tests/test_reply_target_resolve.py diff --git a/harness/scheduler.py b/harness/scheduler.py index 80e5bdf..c50442b 100644 --- a/harness/scheduler.py +++ b/harness/scheduler.py @@ -91,6 +91,39 @@ def _batch_cap(level_cfg): return MAX_TASK_BATCH.get(level_cfg.name, 3) +def _resolve_reply_target(model: dict, m: dict): + """t-261013131228: Alex реплаит на документ/фото — Telegram не отдаёт их текст + (reply_to_text пуст), а reply_to_message_id резолвится только по MessageReceived + в этом же чате. Ищем «голову» в инбоксе по (chat_id, message_id) — включая + kind=document/photo — и возвращаем её описание (caption/файл) или None.""" + rid = m.get("reply_to_message_id") + if rid is None or (m.get("reply_to_text") or "").strip(): + return None + for cand in (model.get("inbox") or {}).values(): + if cand.get("chat_id") == m.get("chat_id") and cand.get("message_id") == rid: + kind = cand.get("kind") or "text" + txt = (cand.get("text") or "").strip() + if kind in ("document", "photo", "audio", "video", "video_note", "voice"): + label = cand.get("file_name") or cand.get("file_path") or kind + return f"{kind} «{label}»" + (f": {txt[:300]}" if txt else " (без подписи)") + return txt[:300] if txt else None + return None + + +def _recent_docs(model: dict, chat_id, limit=3): + """Последние N документов/фото этого чата (caption) — контекст для реплаев + на сообщения без текста (t-261013131228): триаж видит, о каком документе речь.""" + docs = [m for m in (model.get("inbox") or {}).values() + if m.get("chat_id") == chat_id and m.get("kind") in ("document", "photo")] + docs.sort(key=lambda x: x.get("ts") or "") + out = [] + for d in docs[-limit:]: + label = d.get("file_name") or d.get("mime_type") or d.get("kind") + cap = (d.get("text") or "").strip()[:200] + out.append(f"«{label}»" + (f" — {cap}" if cap else " (без подписи)")) + return out + + def _est_minutes(t: dict) -> int: """Оценка длительности: из TaskSubmitted или дефолт по классу. @@ -336,13 +369,32 @@ def build_context(level_cfg, model: dict, batch=None): # t-261002085926: Alex ответил (reply) на конкретное # сообщение — покажи якорь, иначе триаж не понимает, # к какой реплике относится сообщение - + (f" [в ответ на «{m.get('reply_to_text','').strip()[:200]}»]" + # t-261013131228: цель реплая без текста (документ/ + # фото) резолвится из инбокса (reply_to_desc) — + # триаж видит, ЧТО за документ, и не угадывает + + (f" [в ответ на «{(m.get('reply_to_text') or m.get('reply_to_desc') or '').strip()[:200]}»]" if m.get("reply_to_message_id") is not None - and (m.get("reply_to_text") or "").strip() + and ((m.get("reply_to_text") or "").strip() + 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]) batch_ids = ",".join(ids) + # t-261013131228: если в батче есть reply на сообщение без текста — + # добавляем последние документы этого чата с caption, чтобы триаж + # видел, о каком документе речь, даже если «голова» старше капа инбокса + docs_ctx = {} + for m in inbox: + if m.get("reply_to_message_id") is not None and not ( + (m.get("reply_to_text") or "").strip() + or (m.get("reply_to_desc") or "").strip()): + docs = _recent_docs(model, m.get("chat_id")) + if docs: + docs_ctx[str(m.get("chat_id"))] = ( + "Последние документы этого чата (возможно, к ним относится reply): " + + "; ".join(docs)) + if docs_ctx: + parts.append("КОНТЕКСТ ДОКУМЕНТОВ ЧАТА:\n" + "\n".join(docs_ctx.values())) # t-260930064924: батч-триаж — ВСЕ неотвеченные обрабатываются ОДНИМ тиком. # Раньше мин-уровень отвечал по одному за тик (m-516173222..228 — 7 тиков # подряд на один разговор). Теперь в контексте лежит ГОТОВАЯ команда diff --git a/harness/tg_inbox.py b/harness/tg_inbox.py index 87df8b7..b2e8dcf 100644 --- a/harness/tg_inbox.py +++ b/harness/tg_inbox.py @@ -222,6 +222,42 @@ def save_document(msg: dict): return abs_path +def _describe_inbox_message(m: dict) -> str: + """Короткое описание входящего для якоря реплая (t-261013131228). + + Документ/фото без текста описываем тем, что есть: kind, file_name, + начало caption (text), путь к файлу — этого хватает триажу, чтобы + понять, НА ЧТО отвечает Alex, не открывая файл. + """ + kind = m.get("kind") or "text" + text = (m.get("text") or "").strip() + if text: + return f"{kind}: {text[:200]}" + fname = m.get("file_name") or "" + fpath = m.get("file_path") or "" + if fname or fpath: + return f"{kind} (файл): {fname or (fpath or '').split('/')[-1]}" + return f"[{kind} без текста]" + + +def _resolve_reply_target(chat_id, reply_to_message_id: int): + """Найти в инбоксе сообщение, на которое отвечает Alex (t-261013131228). + + Telegram кладёт в reply_to_message только текстовые реплики; цель без + текста (документ/фото/стикер) остаётся «чёрным ящиком». Ищем её в + инбоксе по (chat_id, message_id) и описываем. None — цели в инбоксе + нет (старее капа 100 или из чата, который бот не видел). + """ + try: + model = es.run_projections() + except Exception: + return None + for m in (model.get("inbox") or {}).values(): + if m.get("chat_id") == chat_id and m.get("message_id") == reply_to_message_id: + return _describe_inbox_message(m) + return None + + def receive_message(update: dict): """MessageReceived в стор. Идемпотентно по update_id.""" msg = update.get("message") or update.get("edited_message") or {} @@ -299,6 +335,17 @@ def receive_message(update: dict): "reply_to_text": ((msg.get("reply_to_message") or {}).get("text") or "")[:300], "message_thread_id": msg.get("message_thread_id"), } + # t-261013131228: Alex реплаит на сообщение без текста (документ/фото/ + # стикер) — Telegram не отдаёт его содержимое в reply_to_message, и + # триаж видит голый «[reply на сообщение без текста]» без контекста + # (кейс 13.10: реплай на концепт ИИШНИЦЫ → мин-уровень анонсировал + # не то событие). Резолвим цель по инбоксу того же чата: (chat_id, + # reply_to_message_id) → описание цели (текст/caption/файл). + rt = data.get("reply_to_message_id") + if rt is not None and not (data.get("reply_to_text") or "").strip(): + target = _resolve_reply_target(chat.get("id"), rt) + if target: + data["reply_to_desc"] = target es.append_event("inbox", f"m-{update['update_id']}", "MessageReceived", data) return f"m-{update['update_id']}" diff --git a/tests/test_reply_target_resolve.py b/tests/test_reply_target_resolve.py new file mode 100644 index 0000000..33f6e9f --- /dev/null +++ b/tests/test_reply_target_resolve.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""t-261013131228: резолв цели реплая без текста (документ/фото). + +Кейс 13.10: Alex в личке реплайнул на присланный ранее документ (концепт +панельной сессии для ИИШНИЦЫ) словами «вот этого мероприятия». Telegram не +отдаёт содержимое документа в reply_to_message → reply_to_text пустой → +мин-триаж видел «[reply на сообщение без текста]» без контекста и анонсировал +не то событие (перформанс 8 сентября вместо ИИШНИЦЫ 20.10). Фикс: +receive_message резолвит цель по инбоксу (chat_id, reply_to_message_id) +и пишет reply_to_desc; build_context показывает desc, когда текста нет. + +Запуск: ./venv/bin/python tests/test_reply_target_resolve.py +""" +import os +import sys + +_DST = "/tmp/simbiot-test-reply-target.db" +for p in (_DST, _DST + "-wal", _DST + "-shm"): + if os.path.exists(p): + os.unlink(p) +os.environ["SIMBIOT_DB"] = _DST +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from harness import es, tg_inbox + +fails = [] + + +def check(name, cond, extra=""): + print(("PASS" if cond else "FAIL") + " " + name + ("" if cond else " | " + extra)) + if not cond: + fails.append(name) + + +def _recv(update_id, message_id, chat_id, text=None, document=None, reply_to=None, + chat_type="private", date=1780000000): + msg = { + "message_id": message_id, "date": date, + "chat": {"id": chat_id, "type": chat_type}, + "from": {"id": 1, "first_name": "Alex"}, + } + if text is not None: + msg["text"] = text + if document is not None: + msg["document"] = document + if reply_to is not None: + msg["reply_to_message"] = reply_to + tg_inbox.receive_message({"update_id": update_id, "message": msg}) + + +DOC_CAPTION = "Концепт панельной сессии «Общее видение» для ИИШНИЦЫ (HTML): миссия 42 как рамка" + +es.init_db() + +# ── 1. Реплай на документ: цель резолвится из инбокса ────────────────────── +_recv(1001, 4060, 7569223131, text=DOC_CAPTION, + document={"file_id": "f1", "file_unique_id": "u1", + "file_name": "panel-concept.html", "mime_type": "text/html"}) +_recv(1002, 4076, 7569223131, text="вот этого мероприятия", + reply_to={"message_id": 4060}) # Telegram текст цели не отдал + +model = es.run_projections() +m = model["inbox"]["m-1002"] +check("реплай на документ: reply_to_message_id зафиксирован", + m["reply_to_message_id"] == 4060) +check("реплай на документ: reply_to_text пустой (Telegram не отдал)", + not (m.get("reply_to_text") or "").strip()) +desc = m.get("reply_to_desc") or "" +check("реплай на документ: reply_to_desc резолвнулся из инбокса", bool(desc), repr(desc)) +check("реплай на документ: desc несёт содержимое (caption документа)", + ("Концепт панельной сессии" in desc) or ("документ" in desc.lower()), repr(desc)) + +# ── 2. Реплай на текстовое: desc не нужен, текст из Telegram ─────────────── +_recv(2001, 5001, 7569223131, text="старое текстовое сообщение") +_recv(2002, 5002, 7569223131, text="принято", + reply_to={"message_id": 5001, "text": "старое текстовое сообщение"}) +model = es.run_projections() +m = model["inbox"]["m-2002"] +check("реплай на текст: reply_to_text из Telegram", + (m.get("reply_to_text") or "").strip() == "старое текстовое сообщение") +check("реплай на текст: reply_to_desc не пишется (не нужен)", + not (m.get("reply_to_desc") or "").strip()) + +# ── 3. Цель вне инбокса: не выдумываем desc ──────────────────────────────── +_recv(3001, 6002, 7569223131, text="вот этого мероприятия", + reply_to={"message_id": 999999}) +model = es.run_projections() +m = model["inbox"]["m-3001"] +check("цель вне инбокса: reply_to_desc не выдумывается", + not (m.get("reply_to_desc") or "").strip()) + +# ── 4. build_context: якорь реплая несёт контекст документа ──────────────── +_recv(4001, 7000, 7569223131, text=DOC_CAPTION, + document={"file_id": "f2", "file_unique_id": "u2", + "file_name": "panel-concept.html", "mime_type": "text/html"}) +_recv(4002, 7001, 7569223131, text="вот этого мероприятия", + reply_to={"message_id": 7000}) +# закрываем ранние входящие, чтобы в контекст попал только свежий батч +for mid in ("m-1001", "m-1002", "m-2001", "m-2002", "m-3001"): + mm = model["inbox"].get(mid) + if mm is not None: + es.append_event("inbox", mid, "MessageIgnored", {"reason": "test"}) + +from harness.scheduler import build_context +from harness.levels import LEVELS + +ctx = build_context(LEVELS["min"], es.run_projections(), batch=[]) +check("build_context: якорь реплая показывает контекст документа", + "в ответ на" in ctx and "[reply на сообщение без текста]" not in ctx + and "Концепт панельной сессии" in ctx) + +print() +if fails: + print("FAILS: " + ", ".join(fails)) + sys.exit(1) +print("ALL PASS")