"""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", "add_project", "status_query", # پرس‌وجو: استعلام‌های در انتظار / به‌زودی منقضی / ... "accept_quote", # پذیرش یک استعلام "arrive_items", # اعلام رسیدن اقلام "help", "unknown", ] SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی. ورودی تو پیام صوتی/متنی کاربر است. یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل: { "consensus_text": "متن تجمیع‌شده و اصلاح‌شده کاربر (فارسی روان و بدون غلط شنیداری)", "intent": یکی از {intents}, "patch": {شکل partial هر فعالیتی که کاربر داده}, "confidence": 0.0..1.0 } قوانین: - اگر ورودی شامل چند نسخه تبدیل گفتار به متن (STT) از یک پیام صوتی کاربر است: ۱. این نسخه‌ها از چند مدل مختلف برای افزایش دقت به دست آمده‌اند. آن‌ها را با هم مقایسه و مقابله کن. ۲. خطاهای شنیداری هر نسخه را بر مبنای منطق تدارکات (نام کالاها، برندها، مشخصات فنی و واحدهای اندازه‌گیری) تصحیح و حل تعارض کن. ۳. متن نهایی اصلاح و تجمیع‌شده را در فیلد consensus_text قرار بده. ۴. استخراج قصد (intent) و موجودیت‌ها (patch) را بر اساس همین متن دقیق انجام بده. - اگر ورودی یک متن عادی است، همان متن را در consensus_text قرار بده. - اگر پیام کاربر اطلاعات جدیدی به پیش‌نویسِ باز اضافه می‌کند یا اصلاح می‌کند → 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 مقادیر. - برای پروژه‌ها: * اگر کاربر یک یا چند پروژه را ذکر کرد (مانند «برای منشانه و دفتر» یا «پروژه آسمان و دفتر مرکزی»)، همه نام‌های پروژه‌ها را دقیقاً و بدون جا انداختن یا اشتباه گرفتن با برند در آرایه project_names قرار بده: ["منشانه", "دفتر"]. * این پروژه‌ها ممکن است پروژه‌های جدید باشند؛ هرگز آن‌ها را با نام‌های دیگر جایگزین نکن یا نادیده نگیر. * نام پروژه‌ها را هرگز به عنوان برند کالا در نظر نگیر. * اگر هر کالا به پروژه خاصی تعلق داشت در item.project_name بنویس، وگرنه آن را خالی بگذار تا سیستم تفکیک کند. - برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد»، «سیمان» یا «گچ و خاک»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز، وزن بسته مانند ۵۰ کیلویی یا گرید) در آرایه specs قرار گیرد. - برای مهلت اعتبار یا پیش‌فاکتور: اگر کاربر گفت مثلاً «پیش‌فاکتور تا ۲۰ روز دیگر آماده/معتبر است»، تعداد روز را در expiry_days: 20 بگذار. - برای شرایط پرداخت: ساختار مفهومی را در payment_terms استخراج کن و نیازی به محاسبه تقویمی تاریخ‌ها نداری: * label: متن عنوان شرایط (مثلاً «سه قسط در طول زمان» یا «پرداخت یکباره امروز»). * type: یکی از "installments" (اقساطی)، "cash_today" (نقدی امروز)، "deferred_check" (چک)، "other". * installments_count: تعداد اقساط اگر ذکر شد (مثلاً ۳). * is_today: آیا تسویه یکباره امروز یا قسط اول امروز است (true/false). * discount_percent: درصد تخفیف نقدی اگر ذکر شد (مثلاً ۱۰). شکل patch برای intent="register_quote"/"update_draft": {"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_days": int, "expiry_date": "YYYY-MM-DD", "items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "project_name": str, "notes": str}], "payment_terms": [{"label": str, "type": "installments|cash_today|deferred_check|other", "installments_count": int, "is_today": bool, "discount_percent": number, "discount_type": "none|percent|fixed", "discount_value": number, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number, "note": str}], "notes": str}]} شکل 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="add_project": {"project": {"name": str, "description": 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 | list[dict], draft_summary: str | None = None) -> dict: """Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises.""" fallback = {"consensus_text": "", "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 if isinstance(text, list): first_text = text[0].get("text") if text else "" lines = ["پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیاده‌سازی شده است:"] for idx, item in enumerate(text, start=1): name = item.get("name") or f"مدل {idx}" t = normalize_digits(item.get("text") or "") lines.append(f"- نسخه {idx} ({name}): «{t}»") lines.append("\nبا تلفیق این نسخه‌ها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز.") user_content = "\n".join(lines) elif isinstance(text, str) and ("مدل 1" in text or "مدل ۱" in text) and ("مدل 2" in text or "مدل ۲" in text): user_content = ( "پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیاده‌سازی شده است:\n" + normalize_digits(text) + "\n\nبا تلفیق این نسخه‌ها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز." ) first_text = text else: user_content = normalize_digits(str(text)) first_text = str(text) fallback["consensus_text"] = first_text 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": user_content}, ], "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", {}) if not parsed.get("consensus_text"): parsed["consensus_text"] = first_text return parsed except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError): logger.exception("NLU failure") return fallback