t-261013131228: резолв цели реплая без текста — reply_to_desc из инбокса (chat_id, reply_to_message_id); build_context показывает содержимое документа вместо «reply на сообщение без текста»; тест 8 PASS (документ/текст/вне инбокса/контекст)
This commit is contained in:
parent
5b1d30a0ff
commit
d850d4828a
3 changed files with 217 additions and 2 deletions
|
|
@ -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 тиков
|
||||
# подряд на один разговор). Теперь в контексте лежит ГОТОВАЯ команда
|
||||
|
|
|
|||
|
|
@ -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']}"
|
||||
|
||||
|
|
|
|||
116
tests/test_reply_target_resolve.py
Normal file
116
tests/test_reply_target_resolve.py
Normal file
|
|
@ -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")
|
||||
Loading…
Reference in a new issue