"""Natural-language understanding: Persian free text → typed intent + entities. One chat/completions call per message with JSON-mode output. The prompt embeds the current draft summary so the model can decide between "start a new activity" and "refine the open draft" (update_draft with a partial patch). """ import json import logging import re import httpx from app.config import Settings logger = logging.getLogger("bot.nlu") PERSIAN_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789") def normalize_digits(text: str) -> str: return (text or "").translate(PERSIAN_DIGITS) ZWNJ = "\u200c" def normalize_text(text: str) -> str: """Normalize Persian text for fuzzy matching (ZWNJ, arabic ya/ka, spaces).""" t = normalize_digits(text or "") t = t.replace(ZWNJ, " ").replace("ي", "ی").replace("ك", "ک") t = re.sub(r"\s+", " ", t).strip().lower() return t INTENTS = [ "register_quote", # ثبت استعلام/پیشنهاد جدید از یک تأمین‌کننده "update_draft", # تکمیل/اصلاح پیش‌نویس باز (patch روی آن) "commit_draft", # «ثبت کن» / «تأیید» — commit the open draft "discard_draft", # «بی‌خیال» / «لغو» "show_draft", # «وضعیت پیش‌نویس؟» "add_supplier", "add_material", "status_query", # پرس‌وجو: استعلام‌های در انتظار / به‌زودی منقضی / ... "accept_quote", # پذیرش یک استعلام "arrive_items", # اعلام رسیدن اقلام "help", "unknown", ] SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی. ورودی تو پیام صوتی/متنی کاربر است. یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل: { "intent": یکی از {intents}, "patch": {شکل partial هر فعالیتی که کاربر داده}, "confidence": 0.0..1.0 } قوانین: - اگر پیام کاربر اطلاعات جدیدی به پیش‌نویسِ باز اضافه می‌کند یا اصلاح می‌کند → intent="update_draft" و patch شامل فقط فیلدهای جدید/تغییریافته. - اگر عبارت تأیید نهایی است (مثل «ثبت کن»، «همینه، ثبتش کن») → intent="commit_draft". - اگر لغو است («بی‌خیال»، «لغو») → intent="discard_draft". - اگر سؤال درباره وضعیت است → intent="status_query". - اگر درباره پذیرش استعلام است → intent="accept_quote" و در patch.target نام تأمین‌کننده/استعلام. - اگر درباره اعلام رسیدن اقلام است → intent="arrive_items" و در patch.target نام تأمین‌کننده/استعلام و در patch.arrivals مقادیر. - برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد» یا «لوله»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز یا گرید) در آرایه specs قرار گیرد. - برای شرایط پرداخت: در صورت ذکر اقساط/موعد و مبالغ، در milestones: [{"due_date": "YYYY-MM-DD", "amount": number}] قرار بده. شکل patch برای intent="register_quote"/"update_draft": {"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_date": "YYYY-MM-DD", "items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "notes": str}], "payment_terms": [{"label": str, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number}], "discount_type": "none|percent|fixed", "discount_value": number}]} شکل patch برای intent="add_supplier": {"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}} شکل patch برای intent="add_material": {"material": {"name": str, "unit_name": str, "topic": str}} شکل patch برای intent="status_query": {"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}} شکل patch برای intent="accept_quote"/"arrive_items": {"target": {"supplier_name": str, "project_name": str, "quote_id": str}, "arrivals": [{"material_name": str, "quantity": number, "quality_note": str}]} {draft_block} """ def _build_system_prompt(draft_summary: str | None) -> str: intents = ", ".join(INTENTS) prompt = SYSTEM_PROMPT.replace("{intents}", intents).replace("{draft_block}", "") if draft_summary: prompt += ( "\n\nپیش‌نویسِ باز فعلی کاربر (اگر مرتبط است به آن تکیه کن و فیلدهای تکراری را بازنویسی نکن):\n" + draft_summary ) return prompt def _extract_json(raw: str) -> dict | None: raw = (raw or "").strip() if raw.startswith("```"): raw = re.sub(r"^```[a-zA-Z]*\n?", "", raw).rstrip("`").strip() start = raw.find("{") end = raw.rfind("}") if start == -1 or end <= start: return None try: return json.loads(raw[start : end + 1]) except json.JSONDecodeError: return None async def understand(settings: Settings, text: str, draft_summary: str | None = None) -> dict: """Return {"intent": ..., "patch": {...}, "confidence": ...}; never raises.""" fallback = {"intent": "unknown", "patch": {}, "confidence": 0.0} if not settings.AI_BASE_URL or not settings.AI_API_KEY: logger.warning("AI provider not configured; cannot understand message") return fallback try: async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client: resp = await client.post( f"{settings.AI_BASE_URL.rstrip('/')}/chat/completions", headers={"Authorization": f"Bearer {settings.AI_API_KEY}"}, json={ "model": settings.LLM_MODEL, "messages": [ {"role": "system", "content": _build_system_prompt(draft_summary)}, {"role": "user", "content": normalize_digits(text)}, ], "temperature": 0.1, "response_format": {"type": "json_object"}, }, ) if resp.status_code != 200: logger.error("LLM HTTP %s: %s", resp.status_code, resp.text[:300]) return fallback content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "") parsed = _extract_json(content) if not parsed or parsed.get("intent") not in INTENTS: logger.warning("unparseable LLM output: %r", content[:200]) return fallback parsed.setdefault("patch", {}) return parsed except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError): logger.exception("NLU failure") return fallback