Phase 0 — Jev validated with the real key (5 Persian scenarios, api.typesafe.ai): stale project name «منشیانه»->«منشانه» conf 0.73 (ambiguity band), STT spelling variants «مصطفوی/مستفدی/مسطفی»->«بازرگانی مصطفوی» conf 0.93, exact «دفتر» 0.88, proper _none rejection for «کاغذ A4» 0.92, and two mentions in ONE request (0.77/0.64). Exact model version to pin: jev-1.13.0. Latency 0.7-1.1s per field request; ~450-650 in / 64-133 out tokens. Phase 1 — Observability (DB-only, zero extra LLM tokens, nothing is ever deleted): - new tables bot_traces + bot_trace_events (idempotent DDL in db_init.py; ORM in models.py) - new app/observability.py: _jsonable (UUID/datetime/Decimal/set/enum/dataclass-safe), trace_start, event, log_resolution, link_draft, latest_trace_for_draft, set_trace_status, span() context manager, save_voice (Path.mkdir parents+exist_ok, day folders) - capture points wired: input.text; input.voice (+voice persisted under OBS_VOICE_DIR); stt.run + per-provider stt.result (success AND failure, latency, model); nlu.request + nlu.result with prompt_hash/prompt_version; entities.extracted; routing.decision (difflib baseline for the phase-6 A/B); ambiguity.sent; ambiguity.user_choice (buttons + free-text reply path); user_choice.pick; draft.render; draft.edit (wizard-side edits detected by the sync loop); draft.commit (quote/supplier/material/project + trace status/quote id); draft.cancel - compose: persistent ./bot/data:/srv/bot/data volume; bot/.gitignore: data/ (runtime data never committed, never deleted) - tests: bot/tools/test_observability.py -> 8/8 pass (jsonable cases, trace lifecycle, JSONB round-trip, draft linking, status update, save_voice day-folder mkdir, span task). Verified live: voice file lands on the host volume and is gitignored; rows + files persist across bot restart.
172 lines
7.2 KiB
Python
172 lines
7.2 KiB
Python
"""Inline-button routing (callback_query).
|
|
|
|
Data grammar (≤64 bytes): op:{arg}:{d8} / pk:{d8}:{field}:{idx} / qc:{idx}
|
|
/ ac:{op}[:{i}] / ar:{op} — full state lives in bot_drafts / pending flows.
|
|
"""
|
|
import logging
|
|
|
|
from app import drafts as draft_store
|
|
from app import observability as obs
|
|
from app.runtime import LinkedUser
|
|
from app.texts import CANCELLED, UNLINKED, esc
|
|
|
|
from . import quote, supplier, material, project, decisions
|
|
|
|
logger = logging.getLogger("bot.callbacks")
|
|
|
|
|
|
async def handle_callback(ctx, cq: dict) -> None:
|
|
chat_id = ((cq.get("message") or {}).get("chat") or {}).get("id")
|
|
data = cq.get("data") or ""
|
|
if chat_id is None:
|
|
return
|
|
try:
|
|
await _dispatch(ctx, chat_id, data, cq)
|
|
except Exception:
|
|
logger.exception("callback failed: %s", data)
|
|
await ctx.send(chat_id, "خطا در پردازش دکمه؛ دوباره تلاش کن.")
|
|
finally:
|
|
await ctx.bale.answer_callback_query(cq.get("id"), None)
|
|
|
|
|
|
async def _gate(ctx, chat_id: int) -> LinkedUser | None:
|
|
from app.defaults import AUTHORIZED_USERS
|
|
|
|
if chat_id in AUTHORIZED_USERS:
|
|
u = AUTHORIZED_USERS[chat_id]
|
|
return LinkedUser(
|
|
chat_id=chat_id,
|
|
user_id=u.get("user_id"),
|
|
display_name=u.get("display_name"),
|
|
role=u.get("role", "procurement_manager"),
|
|
is_admin=u.get("is_admin", True),
|
|
)
|
|
await ctx.send(chat_id, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.")
|
|
return None
|
|
|
|
|
|
async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
|
user = await _gate(ctx, chat_id)
|
|
if user is None:
|
|
return
|
|
parts = data.split(":")
|
|
|
|
# ---- pending-flow buttons (accept / arrive / quote pick) ---------------
|
|
if parts[0] == "qc" and len(parts) == 2:
|
|
flow = ctx.pending.get(chat_id)
|
|
if flow and flow.kind == "quote_pick":
|
|
await decisions.apply_quote_pick(ctx, chat_id, user, flow, int(parts[1]))
|
|
return
|
|
if parts[0] == "ac":
|
|
await decisions.handle_accept_callback(ctx, chat_id, user, parts)
|
|
return
|
|
if parts[0] == "ar":
|
|
await decisions.handle_arrive_callback(ctx, chat_id, user, parts)
|
|
return
|
|
|
|
# ---- ambiguity sub-messages (Requirement 2.a) -------------------------
|
|
if parts[0] == "amb" and len(parts) >= 4:
|
|
action, prefix, amb_id = parts[1], parts[2], parts[3]
|
|
draft = await draft_store.get_by_prefix(prefix, chat_id)
|
|
if draft is None:
|
|
await ctx.send(chat_id, "این پیشنویس دیگر معتبر نیست.")
|
|
return
|
|
|
|
payload = draft.payload or {}
|
|
ambiguities = payload.get("ambiguities") or []
|
|
target_amb = next((a for a in ambiguities if a.get("id") == amb_id), None)
|
|
if not target_amb:
|
|
return
|
|
|
|
tid = await obs.latest_trace_for_draft(draft.id)
|
|
await obs.event(tid, "ambiguity.user_choice", payload={
|
|
"amb_id": amb_id, "action": action, "kind": target_amb.get("kind"),
|
|
"title": target_amb.get("title"),
|
|
})
|
|
|
|
if action == "ok":
|
|
proposed = target_amb.get("proposed_patch") or {}
|
|
draft_store.merge_quote_patch(payload, proposed)
|
|
target_amb["status"] = "applied"
|
|
await quote.resolve_payload(ctx, payload)
|
|
await draft_store.save_payload(draft, mark_bale_synced=True)
|
|
# In-place edit main review card
|
|
await quote.render(ctx, chat_id, draft, fresh=False)
|
|
# Delete ONLY this ambiguity message
|
|
if target_amb.get("message_id"):
|
|
try:
|
|
await ctx.delete(chat_id, target_amb["message_id"])
|
|
except Exception:
|
|
pass
|
|
elif action == "no":
|
|
target_amb["status"] = "rejected"
|
|
await draft_store.save_payload(draft, mark_bale_synced=True)
|
|
# Delete ONLY this ambiguity message
|
|
if target_amb.get("message_id"):
|
|
try:
|
|
await ctx.delete(chat_id, target_amb["message_id"])
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
# ---- pick a candidate for a draft field -------------------------------
|
|
if parts[0] == "pk" and len(parts) == 4:
|
|
draft = await draft_store.get_by_prefix(parts[1], chat_id)
|
|
if draft is None:
|
|
await ctx.send(chat_id, "این پیشنویس دیگر معتبر نیست.")
|
|
return
|
|
await _apply_pick(ctx, chat_id, user, draft, parts[2], int(parts[3]))
|
|
return
|
|
|
|
# ---- draft commit / cancel --------------------------------------------
|
|
op, arg, prefix = parts[0], parts[1], parts[2] if len(parts) > 2 else ""
|
|
if op in ("q", "s", "m", "p") and arg in ("ok", "ok2", "no"):
|
|
kind = {
|
|
"q": draft_store.KIND_QUOTE,
|
|
"s": draft_store.KIND_SUPPLIER,
|
|
"m": draft_store.KIND_MATERIAL,
|
|
"p": draft_store.KIND_PROJECT,
|
|
}[op]
|
|
draft = await draft_store.get_by_prefix(prefix, chat_id)
|
|
if draft is None or draft.kind != kind:
|
|
await ctx.send(chat_id, "این پیشنویس دیگر معتبر نیست.")
|
|
return
|
|
if arg == "no":
|
|
await draft_store.set_status(draft.id, "cancelled")
|
|
tid = await obs.latest_trace_for_draft(draft.id)
|
|
await obs.event(tid, "draft.cancel", payload={"draft_id": str(draft.id), "kind": draft.kind})
|
|
await obs.set_trace_status(tid, "cancelled")
|
|
ctx.pending.pop(chat_id, None)
|
|
if draft.review_message_id:
|
|
try:
|
|
await ctx.bale.edit_message_text(chat_id, draft.review_message_id, "❌ این پیشنویس لغو شد.", reply_markup=None)
|
|
await draft_store.set_bale_updated_at(draft.id)
|
|
except Exception:
|
|
pass
|
|
await ctx.send(chat_id, CANCELLED)
|
|
return
|
|
if kind == draft_store.KIND_QUOTE:
|
|
await quote.commit(ctx, chat_id, user, draft, only_resolved=(arg == "ok2"))
|
|
elif kind == draft_store.KIND_SUPPLIER:
|
|
await supplier.commit(ctx, chat_id, user, draft)
|
|
elif kind == draft_store.KIND_MATERIAL:
|
|
await material.commit(ctx, chat_id, user, draft)
|
|
elif kind == draft_store.KIND_PROJECT:
|
|
await project.commit(ctx, chat_id, user, draft)
|
|
|
|
|
|
async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, idx: int) -> None:
|
|
options = (draft.payload.get("options") or {}).get(field) or []
|
|
if idx < 0 or idx >= len(options):
|
|
await ctx.send(chat_id, "گزینه نامعتبر است.")
|
|
return
|
|
choice = options[idx]
|
|
tid = await obs.latest_trace_for_draft(draft.id)
|
|
await obs.event(tid, "user_choice.pick", payload={"field": field, "idx": idx, "choice": choice})
|
|
if draft.kind == draft_store.KIND_QUOTE:
|
|
await quote.apply_pick(ctx, chat_id, user, draft, field, choice, trace_id=tid)
|
|
elif draft.kind == draft_store.KIND_SUPPLIER:
|
|
await supplier.apply_pick(ctx, chat_id, user, draft, field, choice)
|
|
elif draft.kind == draft_store.KIND_MATERIAL:
|
|
await material.apply_pick(ctx, chat_id, user, draft, field, choice)
|