Files
ERP/bot/app/keyboards.py
T
whqreza 1c853e50a9 feat(matching): Jev entity matching phases 2-5 (per-field fan-out, variants, candidate ambiguity buttons)
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.
2026-10-12 21:24:03 +03:30

94 lines
3.6 KiB
Python

"""Inline keyboard builders.
Bale keeps Telegram's 64-byte ``callback_data`` cap, so every callback is a
short op-code plus the 8-char draft prefix; the full state lives in bot_drafts
(or the in-memory pending-flow map) and is looked up on tap.
"""
def _kb(rows: list[list[dict]]) -> dict:
return {"inline_keyboard": rows}
def _btn(text: str, data: str) -> dict:
return {"text": text, "callback_data": data}
# callback scheme (≤64 bytes):
# q:ok:{d8} / q:no:{d8} / q:ok2:{d8} quote commit / cancel / commit-resolved-only
# s:ok:{d8} / s:no:{d8} supplier
# m:ok:{d8} / m:no:{d8} material
# pk:{d8}:{field}:{idx} pick candidate for a draft field
# qc:{idx} / ac:{op} / ar:{op} pending-flow buttons (accept/arrive)
def draft_actions(prefix: str, kind: str, has_partial_option: bool = False) -> list[list[dict]]:
op = {"quote": "q", "supplier": "s", "material": "m", "project": "p"}.get(kind, "q")
rows = [[_btn("✅ ثبت نهایی", f"{op}:ok:{prefix}"), _btn("❌ انصراف", f"{op}:no:{prefix}")]]
if has_partial_option:
rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")])
return rows
def pick_buttons(prefix: str, field: str, names: list[str]) -> list[list[dict]]:
return [[_btn(name[:60], f"pk:{prefix}:{field}:{i}")] for i, name in enumerate(names)]
def quote_review_keyboard(
prefix: str,
pick_rows: list[list[dict]],
has_partial_option: bool = False,
wizard_url: str | None = None,
) -> dict:
rows = list(pick_rows)
if wizard_url:
rows.append([{"text": "🖥 بازبینی و تکمیل در ویزارد", "url": wizard_url}])
rows += draft_actions(prefix, "quote", has_partial_option=has_partial_option)
return _kb(rows)
def simple_review_keyboard(prefix: str, kind: str, pick_rows: list[list[dict]]) -> dict:
rows = list(pick_rows)
rows += draft_actions(prefix, kind)
return _kb(rows)
def quote_pick_keyboard(pairs: list[tuple[str, str]]) -> dict:
"""Pick among candidate quotes: pairs of (label, quote_id)."""
return _kb([[_btn(label[:60], f"qc:{i}")] for i, (label, _) in enumerate(pairs)])
def accept_keyboard(termsheet_labels: list[str]) -> dict:
rows = [[_btn(f"شرایط: {label[:40]}", f"ac:ts:{i}")] for i, label in enumerate(termsheet_labels)]
rows.append([_btn("✅ تأیید پذیرش", "ac:ok"), _btn("❌ انصراف", "ac:no")])
return _kb(rows)
def arrive_keyboard() -> dict:
return _kb([
[_btn("📦 تحویل کامل همه اقلام", "ar:full")],
[_btn("✅ ثبت مقادیر اعلام‌شده", "ar:ok"), _btn("❌ انصراف", "ar:no")],
])
def cancel_keyboard() -> dict:
return _kb([[_btn("❌ انصراف", "ac:no")]])
def ambiguity_keyboard(prefix: str, amb_id: str) -> dict:
"""Inline buttons for ambiguity sub-message (≤64 bytes callback_data)."""
return _kb([
[_btn("✅ تأیید و اعمال در پیش‌نویس", f"amb:ok:{prefix}:{amb_id}")],
[_btn("❌ تایید نمی‌شود", f"amb:no:{prefix}:{amb_id}")],
])
def ambiguity_candidates_keyboard(prefix: str, amb_id: str, candidates: list[dict]) -> dict:
"""Phase 4: one button per Jev candidate (+ 'new') for an entity-match ambiguity."""
rows = [
[_btn(f"✅ {str(c.get('name') or '')[:40]}", f"amb:pick:{prefix}:{amb_id}:{i}")]
for i, c in enumerate(candidates[:5])
]
rows.append([_btn("🆕 هیچ‌کدام — ایجاد مورد جدید", f"amb:new:{prefix}:{amb_id}")])
return _kb(rows)