Phase 2 — jev.py client: POST /v1/systemone, PINNED model jev-1.13.0 (drift warning when the response model differs), one retry on 429/5xx, fail-soft (None on any error), usage/latency returned for observability. Config adds TYPESAFE_API_KEY / JEV_* (thresholds 0.85/0.60, 15 questions per field); no daily cap by owner decision. bot/.env is now mounted read-only into the container so the key never enters compose/git.
Phase 3 — matching.py: برونگروهی (one request per field, parallel), درونگروهی (one choice question per mention sharing the same full candidate list + «_none»), درونواریانسی (all STT spellings in one question). Routing: conf>=exact → bind; floor..exact → ambiguity; else/_none → new; missing/failed field → difflib fallback (bug fixed during testing: a failed request used to record 'new' instead of falling back). Resolver gains full cached lists for suppliers/materials (paged) plus brands (API+DB) and specs (EAV values + draft specs). NLU prompt now emits entity_mentions (6 fields) with the voice-variant grouping rule; sanitize_mentions guards the shape. handle_message runs matching only for register_quote/update_draft/add_material behind JEV_ENABLED; jev_map threads into quote/material resolve_payload (Jev hit skips difflib; exact writes ids; ambiguity skips difflib). Spec effect per owner: spelling normalization only (normalize_brand_spec).
Phase 4 — entity-match ambiguity sub-messages: create_ambiguities() builds deduped entries {kind: entity_match, field, variants, candidates, details with Persian percentages}; new keyboards.ambiguity_candidates_keyboard renders one button per candidate + «هیچکدام — ایجاد جدید»; callbacks handle amb⛏️{prefix}:{amb_id}:{idx} (applies the candidate via matching.apply_choice then re-resolves/renders) and amb:new (rejected → auto-create path at commit). Every user choice lands in observability (ambiguity.user_choice with arg+candidates).
Phase 5 — voice variants: NLU groups different STT spellings of one entity into one variants[] (verified live: مصطفوی/مستفدی → one supplier mention); tools/test_matching.py live section (RUN_LIVE=1) exercises real DeepSeek + real Jev without touching Bale.
Tests: test_jev.py 5/5 (mock HTTP: no-key, happy path, 429-retry, 400, exception), test_matching.py 11/11 mock + 3/3 live (real NLU produced 7 mentions with correct fields; real Jev matched «50 کیلویی»→«۵۰ کیلویی» 0.98 and «کیسه» 1.0 and correctly returned new against the emptied catalog); observability regression 8/8.
206 lines
8.8 KiB
Python
206 lines
8.8 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"),
|
|
"arg": parts[4] if len(parts) > 4 else None,
|
|
"candidates": target_amb.get("candidates"),
|
|
})
|
|
|
|
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
|
|
elif action == "pick" and len(parts) >= 5:
|
|
# Phase 4: user picked one of the Jev candidates.
|
|
try:
|
|
idx = int(parts[4])
|
|
except ValueError:
|
|
return
|
|
cands = target_amb.get("candidates") or []
|
|
if not (0 <= idx < len(cands)):
|
|
return
|
|
from app import matching
|
|
matching.apply_choice(payload, target_amb.get("field"), cands[idx],
|
|
target_amb.get("variants") or [])
|
|
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 == "new":
|
|
# «هیچکدام» — keep the raw name; the auto-create path handles it at commit.
|
|
target_amb["status"] = "rejected"
|
|
await draft_store.save_payload(draft, mark_bale_synced=True)
|
|
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)
|