feat(bot): centralize defaults, refine ambiguity lifecycle, improve entity resolution and auto-create new projects
This commit is contained in:
+83
-12
@@ -59,6 +59,9 @@ class FakeBale:
|
||||
"reply_markup": reply_markup})
|
||||
return {"message_id": message_id}
|
||||
|
||||
async def delete_message(self, chat_id, message_id):
|
||||
return True
|
||||
|
||||
async def answer_callback_query(self, callback_query_id, text=None):
|
||||
self.answered.append(callback_query_id)
|
||||
|
||||
@@ -82,6 +85,7 @@ class FakeApi:
|
||||
self.created_quotes: list[dict] = []
|
||||
self.created_suppliers: list[dict] = []
|
||||
self.created_materials: list[dict] = []
|
||||
self.created_projects: list[dict] = []
|
||||
self.accept_payloads: list[dict] = []
|
||||
self.arrive_payloads: list[dict] = []
|
||||
self.login_calls: list[dict] = []
|
||||
@@ -119,6 +123,10 @@ class FakeApi:
|
||||
async def list_projects(self, page_size=100):
|
||||
return [{"id": "prj-1", "name": "برج آسمان"}]
|
||||
|
||||
async def create_project(self, payload, actor_id=None):
|
||||
self.created_projects.append(payload)
|
||||
return {"id": f"prj-{len(self.created_projects)}", "name": payload["name"]}
|
||||
|
||||
async def list_categories(self, page_size=100):
|
||||
return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"},
|
||||
{"id": "cat-2", "name": "لوله و اتصالات"}]
|
||||
@@ -180,10 +188,11 @@ def _install_fake_stores() -> None:
|
||||
import app.drafts as drafts
|
||||
import app.runtime as runtime
|
||||
|
||||
async def create(chat_id, kind, payload, source=None):
|
||||
async def create(chat_id, kind, payload, source=None, source_message_id=None):
|
||||
draft_id = _uuid.uuid4()
|
||||
draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload,
|
||||
missing=None, status="active", source=source,
|
||||
source_message_id=source_message_id,
|
||||
review_message_id=None)
|
||||
MEM["drafts"][str(draft_id)] = draft
|
||||
return draft
|
||||
@@ -193,16 +202,33 @@ def _install_fake_stores() -> None:
|
||||
if d.chat_id == chat_id and d.status == "active" and (kind is None or d.kind == kind)]
|
||||
return max(found, key=lambda d: d.id) if found else None
|
||||
|
||||
async def get_by_message_id(chat_id, message_id):
|
||||
for draft in MEM["drafts"].values():
|
||||
if draft.chat_id == chat_id and draft.status == "active":
|
||||
if draft.review_message_id == message_id or getattr(draft, "source_message_id", None) == message_id:
|
||||
return draft
|
||||
return None
|
||||
|
||||
async def get_by_ambiguity_message_id(chat_id, message_id):
|
||||
for draft in MEM["drafts"].values():
|
||||
if draft.chat_id == chat_id and draft.status == "active":
|
||||
for amb in (draft.payload or {}).get("ambiguities") or []:
|
||||
if amb.get("message_id") == message_id:
|
||||
return draft, amb
|
||||
return None, None
|
||||
|
||||
async def get_by_prefix(prefix, chat_id=None):
|
||||
for draft in MEM["drafts"].values():
|
||||
if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id):
|
||||
return draft
|
||||
return None
|
||||
|
||||
async def save_payload(draft, missing=None, review_message_id=None):
|
||||
async def save_payload(draft, missing=None, review_message_id=None, source_message_id=None, mark_bale_synced=False):
|
||||
draft.missing = missing
|
||||
if review_message_id:
|
||||
draft.review_message_id = review_message_id
|
||||
if source_message_id:
|
||||
draft.source_message_id = source_message_id
|
||||
|
||||
async def set_status(draft_id, status):
|
||||
if draft_id in MEM["drafts"]:
|
||||
@@ -212,6 +238,7 @@ def _install_fake_stores() -> None:
|
||||
return 0
|
||||
|
||||
drafts.create, drafts.get_active, drafts.get_by_prefix = create, get_active, get_by_prefix
|
||||
drafts.get_by_message_id, drafts.get_by_ambiguity_message_id = get_by_message_id, get_by_ambiguity_message_id
|
||||
drafts.save_payload, drafts.set_status, drafts.expire_stale = save_payload, set_status, expire_stale
|
||||
|
||||
async def link_get(self, chat_id):
|
||||
@@ -245,6 +272,10 @@ async def fake_transcribe(settings, audio, mime_type=None, filename=None):
|
||||
"پرداخت سه فاز")
|
||||
|
||||
|
||||
async def fake_transcribe_multi(settings, audio, mime_type=None, filename=None):
|
||||
return [{"name": "fake", "text": "سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن پرداخت سه فاز"}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# driver
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -262,11 +293,15 @@ def build_ctx():
|
||||
ctx._log = types.MethodType(fake_log, ctx)
|
||||
H.understand = fake_understand
|
||||
H.transcribe = fake_transcribe
|
||||
H.transcribe_multi = fake_transcribe_multi
|
||||
return ctx, bale, api
|
||||
|
||||
|
||||
def text_update(update_id, text):
|
||||
return {"update_id": update_id, "message": {"chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}}
|
||||
def text_update(update_id, text, reply_to=None):
|
||||
msg = {"message_id": update_id, "chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}
|
||||
if reply_to:
|
||||
msg["reply_to_message"] = {"message_id": reply_to}
|
||||
return {"update_id": update_id, "message": msg}
|
||||
|
||||
|
||||
def voice_update(update_id):
|
||||
@@ -291,16 +326,18 @@ async def main() -> int:
|
||||
|
||||
payload = new_quote_payload()
|
||||
merge_quote_patch(payload, {
|
||||
"supplier_name": "سازان نو",
|
||||
"items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}],
|
||||
"payment_terms": [{"label": "۳ فاز"}],
|
||||
})
|
||||
merge_quote_patch(payload, {"items": [{"material_name": "میلگرد A3", "quantity": 600}]})
|
||||
missing = compute_quote_missing(payload)
|
||||
check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing))
|
||||
merge_quote_patch(payload, {
|
||||
"supplier_name": "سازان نو",
|
||||
"items": [{"material_name": "میلگرد A3", "quantity": 600}],
|
||||
})
|
||||
check("merge: no dup item", len(payload["items"]) == 1)
|
||||
check("merge: qty updated", payload["items"][0]["quantity"] == 600)
|
||||
check("merge: terms added", payload["termsheets"][0]["label"] == "۳ فاز")
|
||||
missing = compute_quote_missing(payload)
|
||||
check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing))
|
||||
body, problems = build_quote_request(payload)
|
||||
check("build: blocked without supplier", body is None)
|
||||
|
||||
@@ -343,7 +380,7 @@ async def main() -> int:
|
||||
# refine price via follow-up text
|
||||
NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": {
|
||||
"items": [{"material_name": "میلگرد", "unit_price": 47000}]}})
|
||||
await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد"))
|
||||
await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد", reply_to=draft.review_message_id))
|
||||
draft = await _active("quote")
|
||||
check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000)
|
||||
|
||||
@@ -417,14 +454,48 @@ async def main() -> int:
|
||||
check("material: posted", api.created_materials
|
||||
and api.created_materials[0]["canonical_unit_id"] == 3)
|
||||
|
||||
# ---- new projects in quote + auto-creation on commit --------------------
|
||||
NLU_QUEUE.append({
|
||||
"intent": "register_quote",
|
||||
"confidence": 0.95,
|
||||
"patch": {
|
||||
"supplier_name": "سازان نو",
|
||||
"project_names": ["منشانه", "دفتر"],
|
||||
"items": [
|
||||
{"material_name": "میلگرد A3 سایز ۱۴", "quantity": 100, "unit_price": 50000, "project_name": "منشانه"},
|
||||
{"material_name": "شیر فلکه چهارطرفه", "quantity": 20, "unit_price": 100000, "project_name": "دفتر"},
|
||||
],
|
||||
},
|
||||
})
|
||||
await H.handle_update(ctx, text_update(18, "استعلام برای منشانه و دفتر"))
|
||||
q_draft = await _active("quote")
|
||||
check("multi-project: draft created", q_draft is not None)
|
||||
check("multi-project: no bogus match to برج آسمان",
|
||||
"برج آسمان" not in bale.last_text() and "prj" not in (q_draft.payload.get("options") or {}))
|
||||
check("multi-project: new project badge shown", "✨ (پروژه جدید)" in bale.last_text())
|
||||
await H.handle_update(ctx, callback_update(19, f"q:ok:{q_draft.id[:8]}"))
|
||||
created_prj_names = [p["name"] for p in api.created_projects]
|
||||
check("multi-project: auto-created in ERP", "منشانه" in created_prj_names and "دفتر" in created_prj_names)
|
||||
check("multi-project: commit report mentions projects", "پروژه(های) جدید ثبت شد" in bale.last_text())
|
||||
|
||||
# ---- add project standalone --------------------------------------------
|
||||
NLU_QUEUE.append({"intent": "add_project", "confidence": 0.9,
|
||||
"patch": {"project": {"name": "پروژه امید", "description": "پروژه فاز سوم"}}})
|
||||
await H.handle_update(ctx, text_update(20, "پروژه جدید امید"))
|
||||
p_draft = await _active("project")
|
||||
check("project: draft created", p_draft is not None)
|
||||
await H.handle_update(ctx, callback_update(21, f"p:ok:{p_draft.id[:8]}"))
|
||||
check("project: posted to ERP", any(p["name"] == "پروژه امید" for p in api.created_projects))
|
||||
check("project: report sent", "پروژه ثبت شد" in bale.last_text())
|
||||
|
||||
# ---- cancel / unknown --------------------------------------------------
|
||||
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}})
|
||||
await H.handle_update(ctx, text_update(18, "استعلام از سازان نو"))
|
||||
await H.handle_update(ctx, text_update(19, "/cancel"))
|
||||
await H.handle_update(ctx, text_update(22, "استعلام از سازان نو"))
|
||||
await H.handle_update(ctx, text_update(23, "/cancel"))
|
||||
check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text())
|
||||
|
||||
NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}})
|
||||
await H.handle_update(ctx, text_update(20, "اسپاگتی بپز"))
|
||||
await H.handle_update(ctx, text_update(24, "اسپاگتی بپز"))
|
||||
check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text())
|
||||
|
||||
print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed")
|
||||
|
||||
Reference in New Issue
Block a user