feat(bot): centralize defaults, refine ambiguity lifecycle, improve entity resolution and auto-create new projects
This commit is contained in:
@@ -18,7 +18,7 @@ from app.storage import save_proforma_file
|
||||
from app.stt import transcribe, transcribe_multi
|
||||
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc
|
||||
|
||||
from . import commands, callbacks, quote, supplier, material, queries, decisions
|
||||
from . import commands, callbacks, quote, supplier, material, project, queries, decisions
|
||||
|
||||
logger = logging.getLogger("bot.handlers")
|
||||
|
||||
@@ -98,6 +98,12 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
# BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft
|
||||
# =========================================================================
|
||||
if reply_to_msg_id is not None:
|
||||
# Check if replying to an ambiguity message (Requirement 2.b)
|
||||
amb_draft, target_amb = await draft_store.get_by_ambiguity_message_id(chat_id, reply_to_msg_id)
|
||||
if amb_draft is not None and target_amb is not None:
|
||||
await _handle_ambiguity_user_reply(ctx, chat_id, user, amb_draft, target_amb, message, message_id)
|
||||
return
|
||||
|
||||
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
|
||||
if target_draft is None:
|
||||
await ctx.send(
|
||||
@@ -221,6 +227,8 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
await supplier.commit(ctx, chat_id, user, active)
|
||||
elif active.kind == draft_store.KIND_MATERIAL:
|
||||
await material.commit(ctx, chat_id, user, active)
|
||||
elif active.kind == draft_store.KIND_PROJECT:
|
||||
await project.commit(ctx, chat_id, user, active)
|
||||
elif intent == "discard_draft":
|
||||
active = target_draft or await draft_store.get_active(chat_id)
|
||||
if active:
|
||||
@@ -242,10 +250,14 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
await supplier.render(ctx, chat_id, active)
|
||||
elif active.kind == draft_store.KIND_MATERIAL:
|
||||
await material.render(ctx, chat_id, active)
|
||||
elif active.kind == draft_store.KIND_PROJECT:
|
||||
await project.render(ctx, chat_id, active)
|
||||
elif intent == "add_supplier":
|
||||
await supplier.handle_entities(ctx, chat_id, user, patch, source)
|
||||
elif intent == "add_material":
|
||||
await material.handle_entities(ctx, chat_id, user, patch, source)
|
||||
elif intent == "add_project":
|
||||
await project.handle_entities(ctx, chat_id, user, patch, source)
|
||||
elif intent == "status_query":
|
||||
await queries.handle_query(ctx, chat_id, user, patch)
|
||||
elif intent == "accept_quote":
|
||||
@@ -323,3 +335,63 @@ def _draft_summary(draft) -> str | None:
|
||||
else:
|
||||
summary = json.dumps(payload, ensure_ascii=False)[:500]
|
||||
return summary
|
||||
|
||||
|
||||
async def _handle_ambiguity_user_reply(
|
||||
ctx: BotContext, chat_id: int, user: LinkedUser,
|
||||
draft, amb: dict, message: dict, incoming_msg_id: int | None
|
||||
) -> None:
|
||||
"""Handle text/audio correction sent directly as a reply to an ambiguity sub-message (Requirement 2.b)."""
|
||||
text = (message.get("text") or message.get("caption") or "").strip()
|
||||
if not text and (message.get("voice") or message.get("audio")):
|
||||
try:
|
||||
audio_media = message.get("voice") or message.get("audio")
|
||||
audio_bytes = await ctx.bale.download_file(audio_media["file_id"])
|
||||
text = await transcribe(settings, audio_bytes)
|
||||
except Exception:
|
||||
text = ""
|
||||
|
||||
if text:
|
||||
draft_summary = _draft_summary(draft)
|
||||
result = await understand(settings, text, draft_summary)
|
||||
patch = result.get("patch") or {}
|
||||
|
||||
# Apply user correction specifically to the target field
|
||||
if amb.get("kind") == "payment_schedule":
|
||||
if patch.get("payment_terms"):
|
||||
draft_store.merge_quote_patch(draft.payload, patch)
|
||||
else:
|
||||
draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {})
|
||||
if not draft.payload.get("notes"):
|
||||
draft.payload["notes"] = f"نحوه پرداخت: {text}"
|
||||
else:
|
||||
draft.payload["notes"] += f"\nنحوه پرداخت: {text}"
|
||||
elif amb.get("kind") == "project_assignment":
|
||||
if patch.get("items") or patch.get("project_name") or patch.get("project_names"):
|
||||
draft_store.merge_quote_patch(draft.payload, patch)
|
||||
else:
|
||||
draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {})
|
||||
else:
|
||||
draft_store.merge_quote_patch(draft.payload, patch)
|
||||
|
||||
amb["status"] = "applied"
|
||||
await quote.resolve_payload(ctx, draft.payload)
|
||||
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||
|
||||
# In-place edit main review card (Requirement 2.b)
|
||||
await quote.render(ctx, chat_id, draft, fresh=False)
|
||||
|
||||
# Delete the ambiguity message (Requirement 2.b)
|
||||
if amb.get("message_id"):
|
||||
try:
|
||||
await ctx.delete(chat_id, amb["message_id"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Delete user's message to keep chat clean (Requirement 2.b)
|
||||
if incoming_msg_id:
|
||||
try:
|
||||
await ctx.delete(chat_id, incoming_msg_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from app import drafts as draft_store
|
||||
from app.runtime import LinkedUser
|
||||
from app.texts import CANCELLED, UNLINKED, esc
|
||||
|
||||
from . import quote, supplier, material, decisions
|
||||
from . import quote, supplier, material, project, decisions
|
||||
|
||||
logger = logging.getLogger("bot.callbacks")
|
||||
|
||||
@@ -57,6 +57,45 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
||||
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
|
||||
|
||||
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)
|
||||
@@ -68,8 +107,13 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
||||
|
||||
# ---- draft commit / cancel --------------------------------------------
|
||||
op, arg, prefix = parts[0], parts[1], parts[2] if len(parts) > 2 else ""
|
||||
if op in ("q", "s", "m") and arg in ("ok", "ok2", "no"):
|
||||
kind = {"q": draft_store.KIND_QUOTE, "s": draft_store.KIND_SUPPLIER, "m": draft_store.KIND_MATERIAL}[op]
|
||||
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, "این پیشنویس دیگر معتبر نیست.")
|
||||
@@ -89,8 +133,10 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
|
||||
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)
|
||||
else:
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Add-project flow: name + optional description."""
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app.api.client import ApiError
|
||||
from app.drafts import (
|
||||
KIND_PROJECT, merge_project_patch, new_project_payload, project_missing,
|
||||
)
|
||||
from app.keyboards import simple_review_keyboard
|
||||
from app.texts import ERR_GENERIC, esc, project_committed_report, project_review_text
|
||||
|
||||
logger = logging.getLogger("bot.project")
|
||||
|
||||
|
||||
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
|
||||
draft = await draft_store.get_active(chat_id, KIND_PROJECT)
|
||||
if draft is not None:
|
||||
payload = draft.payload
|
||||
merge_project_patch(payload, patch)
|
||||
else:
|
||||
payload = new_project_payload()
|
||||
merge_project_patch(payload, patch)
|
||||
draft = await draft_store.create(chat_id, KIND_PROJECT, payload, source=source)
|
||||
await draft_store.save_payload(draft, missing={"fields": project_missing(payload)})
|
||||
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
|
||||
|
||||
|
||||
def _keyboard(payload: dict, draft_id: str) -> dict:
|
||||
return simple_review_keyboard(draft_id, KIND_PROJECT, [])
|
||||
|
||||
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||
payload = draft.payload
|
||||
missing = project_missing(payload)
|
||||
text = project_review_text(payload, missing)
|
||||
keyboard = _keyboard(payload, draft.id)
|
||||
if fresh:
|
||||
message = await ctx.send(chat_id, text, reply_markup=keyboard)
|
||||
draft.review_message_id = (message or {}).get("message_id")
|
||||
await draft_store.save_payload(draft, missing={"fields": missing},
|
||||
review_message_id=draft.review_message_id)
|
||||
else:
|
||||
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
||||
await draft_store.save_payload(draft, missing={"fields": missing})
|
||||
|
||||
|
||||
async def commit(ctx, chat_id, user, draft) -> None:
|
||||
payload = draft.payload
|
||||
missing = {m["key"]: m["ok"] for m in project_missing(payload)}
|
||||
if not missing.get("name"):
|
||||
await ctx.send(chat_id, "ثبت ممکن نیست؛ نام پروژه مشخص نشده است.")
|
||||
return
|
||||
body = {"name": payload["name"]}
|
||||
if payload.get("description"):
|
||||
body["description"] = payload["description"]
|
||||
try:
|
||||
created = await ctx.api.create_project(body, actor_id=user.actor_id)
|
||||
except ApiError as exc:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("create_project failed")
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای سرور"))
|
||||
return
|
||||
await draft_store.set_status(draft.id, "committed")
|
||||
ctx.resolver.invalidate("projects")
|
||||
await ctx.send(chat_id, project_committed_report(created))
|
||||
+111
-4
@@ -26,6 +26,17 @@ async def handle_entities(
|
||||
draft = target_draft
|
||||
prev_review_msg_id = draft.review_message_id
|
||||
draft.review_message_id = None
|
||||
|
||||
# Clean up old ambiguity sub-messages (Requirement 2.c)
|
||||
old_ambs = (draft.payload or {}).get("ambiguities") or []
|
||||
for a in old_ambs:
|
||||
if a.get("message_id"):
|
||||
try:
|
||||
await ctx.delete(chat_id, a["message_id"])
|
||||
except Exception:
|
||||
pass
|
||||
draft.payload["ambiguities"] = []
|
||||
|
||||
payload = draft.payload
|
||||
merge_quote_patch(payload, patch)
|
||||
draft.source_message_id = incoming_msg_id
|
||||
@@ -70,11 +81,16 @@ async def resolve_payload(ctx, payload: dict) -> None:
|
||||
else:
|
||||
options.pop("sup", None)
|
||||
|
||||
resolved_projects = payload.setdefault("resolved_projects", {})
|
||||
|
||||
if not payload.get("project_id") and payload.get("project_name"):
|
||||
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
|
||||
if res.resolved:
|
||||
payload["project_id"] = res.candidates[0].id
|
||||
payload["project_name"] = res.candidates[0].name
|
||||
c = res.candidates[0]
|
||||
payload["project_id"] = c.id
|
||||
payload["project_name"] = c.name
|
||||
resolved_projects[c.name] = c.id
|
||||
resolved_projects[payload["project_name"]] = c.id
|
||||
if payload["project_id"] not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(payload["project_id"])
|
||||
options.pop("prj", None)
|
||||
@@ -86,7 +102,10 @@ async def resolve_payload(ctx, payload: dict) -> None:
|
||||
for p_name in payload.get("project_names") or []:
|
||||
res = await safe_resolve(ctx.resolver, "project", p_name)
|
||||
if res.resolved:
|
||||
p_id = res.candidates[0].id
|
||||
c = res.candidates[0]
|
||||
p_id = c.id
|
||||
resolved_projects[c.name] = p_id
|
||||
resolved_projects[p_name] = p_id
|
||||
if p_id not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(p_id)
|
||||
if not payload.get("project_id"):
|
||||
@@ -231,6 +250,9 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
|
||||
source_message_id=reply_to or draft.source_message_id,
|
||||
mark_bale_synced=True,
|
||||
)
|
||||
|
||||
# Send pending ambiguity sub-messages as replies to the main review card
|
||||
await send_pending_ambiguities(ctx, chat_id, draft)
|
||||
else:
|
||||
if not draft.review_message_id:
|
||||
return
|
||||
@@ -242,6 +264,36 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
|
||||
await draft_store.set_bale_updated_at(draft.id)
|
||||
|
||||
|
||||
async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
|
||||
from app.keyboards import ambiguity_keyboard
|
||||
from app.texts import ambiguity_message_text
|
||||
|
||||
if not draft.review_message_id:
|
||||
return
|
||||
|
||||
prefix = str(draft.id)[:8]
|
||||
payload = draft.payload or {}
|
||||
ambiguities = payload.get("ambiguities") or []
|
||||
updated = False
|
||||
|
||||
for amb in ambiguities:
|
||||
if amb.get("status") == "pending" and not amb.get("message_id"):
|
||||
msg_text = ambiguity_message_text(amb.get("title", ""), amb.get("details", ""))
|
||||
kb = ambiguity_keyboard(prefix, amb.get("id", ""))
|
||||
sent = await ctx.send(
|
||||
chat_id,
|
||||
msg_text,
|
||||
reply_markup=kb,
|
||||
reply_to=draft.review_message_id,
|
||||
)
|
||||
if sent and sent.get("message_id"):
|
||||
amb["message_id"] = sent["message_id"]
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
await draft_store.save_payload(draft, mark_bale_synced=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# commit
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -250,6 +302,8 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
||||
payload = draft.payload
|
||||
await resolve_payload(ctx, payload)
|
||||
|
||||
newly_created: dict = {}
|
||||
|
||||
# 1. Sequential pre-creation for new supplier if user wants to commit
|
||||
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
||||
cat_id = payload.get("supplier_category_id")
|
||||
@@ -267,11 +321,13 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
||||
sup_body["phone"] = payload["supplier_phone"]
|
||||
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
|
||||
payload["supplier_id"] = created_sup["id"]
|
||||
newly_created["supplier"] = payload["supplier_name"]
|
||||
ctx.resolver.invalidate("suppliers")
|
||||
except Exception as e:
|
||||
logger.warning("Could not auto-create supplier: %s", e)
|
||||
|
||||
# 2. Sequential pre-creation for new materials
|
||||
created_mats = []
|
||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
||||
if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
|
||||
try:
|
||||
@@ -280,9 +336,60 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
||||
"canonical_unit_id": int(item["unit_id"]),
|
||||
}, actor_id=user.actor_id)
|
||||
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
|
||||
created_mats.append(item["material_name"])
|
||||
ctx.resolver.invalidate("materials")
|
||||
except Exception as e:
|
||||
logger.warning("Could not auto-create material: %s", e)
|
||||
if created_mats:
|
||||
newly_created["materials"] = created_mats
|
||||
|
||||
# 3. Sequential pre-creation for new projects
|
||||
created_prjs = []
|
||||
all_p_names = []
|
||||
if payload.get("project_names"):
|
||||
for p in payload["project_names"]:
|
||||
p_str = str(p).strip()
|
||||
if p_str and p_str not in all_p_names:
|
||||
all_p_names.append(p_str)
|
||||
elif payload.get("project_name"):
|
||||
p_str = str(payload["project_name"]).strip()
|
||||
if p_str and p_str not in all_p_names:
|
||||
all_p_names.append(p_str)
|
||||
|
||||
for it in payload.get("items") or []:
|
||||
it_p = it.get("project_name")
|
||||
if it_p:
|
||||
it_p_str = str(it_p).strip()
|
||||
if it_p_str and it_p_str not in all_p_names:
|
||||
all_p_names.append(it_p_str)
|
||||
|
||||
resolved_map = payload.setdefault("resolved_projects", {})
|
||||
for p_name in all_p_names:
|
||||
p_id = resolved_map.get(p_name)
|
||||
if not p_id:
|
||||
res = await safe_resolve(ctx.resolver, "project", p_name)
|
||||
if res.resolved:
|
||||
p_id = res.candidates[0].id
|
||||
resolved_map[p_name] = p_id
|
||||
else:
|
||||
try:
|
||||
created_prj = await ctx.api.create_project({"name": p_name}, actor_id=user.actor_id)
|
||||
p_id = created_prj.get("id")
|
||||
if p_id:
|
||||
resolved_map[p_name] = p_id
|
||||
created_prjs.append(p_name)
|
||||
ctx.resolver.invalidate("projects")
|
||||
except Exception as e:
|
||||
logger.warning("Could not auto-create project %s: %s", p_name, e)
|
||||
|
||||
if p_id:
|
||||
if p_id not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(p_id)
|
||||
if not payload.get("project_id"):
|
||||
payload["project_id"] = p_id
|
||||
|
||||
if created_prjs:
|
||||
newly_created["projects"] = created_prjs
|
||||
|
||||
ready, blockers = quote_ready(payload)
|
||||
body, problems = build_quote_request(payload, only_resolved_items=only_resolved)
|
||||
@@ -346,4 +453,4 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
||||
dropped = len(payload.get("items") or []) - len(body.get("line_items") or [])
|
||||
if dropped > 0:
|
||||
remaining.append(f"{dropped} قلم ناشناس حذف شد")
|
||||
await ctx.send(chat_id, quote_committed_report(created, payload, remaining))
|
||||
await ctx.send(chat_id, quote_committed_report(created, payload, remaining, newly_created=newly_created))
|
||||
|
||||
Reference in New Issue
Block a user