"""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 hashlib import json import logging import re import httpx from app import observability as obs 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" MENTION_FIELDS = {"project", "supplier", "material", "brand", "spec", "unit"} def sanitize_mentions(raw) -> list[dict]: """Keep only valid {field, variants} mention objects from the LLM output.""" out: list[dict] = [] if not isinstance(raw, list): return out for m in raw: if not isinstance(m, dict): continue field = str(m.get("field") or "").strip() variants = [str(v).strip() for v in (m.get("variants") or []) if str(v or "").strip()] if field in MENTION_FIELDS and variants: out.append({"field": field, "variants": variants[:6]}) return out 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 ساختمانی و صنعتی در ایران هستی. پیام‌های صوتی کاربران به زبان فارسی، با گویش و لهجه اصفهانی و با اصطلاحات محاوره‌ای کارگاهی و بازار بیان شده‌اند که نسخه‌های متنی متعددی توسط موتورهای تبدیل گفتار به متن (STT مانند Whisper و Vosk) از آن‌ها تولید شده است. وظیفه تو تحلیل پیام، حل خطاهای شنیداری ناشی از لهجه و گویش، تولید متن اجماعی پالایش‌شده و استخراج دقیق داده‌های تدارکاتی است. یک JSON و فقط JSON برگردان (بدون هرگونه متن، مارک‌داون یا توضیح اضافه) با این ساختار دقیق: { "consensus_text": "بیانیه رسمی، پالایش‌شده، فشرده و بدون تکیه‌کلام از تقاضای تدارکات کاربر", "intent": یکی از {intents}, "patch": {شکل partial هر فعالیتی که کاربر داده}, "entity_mentions": [{"field": "project|supplier|material|brand|spec|unit", "variants": ["همه‌ی نگارش‌ها"]}], "confidence": 0.0..1.0 } قواعد حیاتی: ۱. قواعد آوایی و دستوری لهجه اصفهانی در رمزگشایی خطاهای STT: - شناسه مفعولی پیوسته: پسوند «ـشا» یا «ش ها» در پایان کلمات (مانند پولشا، بارشا) به معنای «آن را / آن‌ها را» است. - شناسه دوم شخص جمع (ت به د): پسوندهای «ـِدون»، «برادون»، «برهدون» به معنای «برایتان / به شما» هستند. - تخمین زمانی و مقداری: کلمه «اینا» پس از اعداد یا زمان (مثلاً «۱۰ روز اینا»، «۴۰ تا اینا») قید تخمین و به معنای «حدوداً» است و نباید با کلمات نامربوط (مانند روزنامه) اشتباه گرفته شود. - حذف حشوهای مکالمه: اصطلاحات پرکننده («عرض به خدمتتون»، «پامون درمیاد»، «دونه‌ای اینا»، «شبت بخیر»، «همین») باید کاملاً از consensus_text پالایش شوند. - حل تعارض نام‌ها در مدل‌های STT: در صورت تفاوت املایی نام تأمین‌کننده یا کالا بین چند مدل، اولویت با نگارشی است که با واژگان معتبر فارسی، نام‌های خانوادگی استاندارد و اقلام صنعتی/ساختمانی تطابق دارد. ۲. ساخت فیلد consensus_text: - متن consensus_text نباید انشا یا نقل‌قول داستانی پر از تعارفات و حواشی باشد. - کلیه احوالپرسی‌ها، تعارفات و تکیه‌کلام‌ها را کاملاً حذف کن. - این متن باید یک «بیانیه رسمی، تمیز و ساختاریافته تدارکاتی» باشد که شامل: نوع اقدام (استعلام/خرید)، اقلام، مقادیر، مشخصات، قیمت‌ها، نام تأمین‌کننده، نام پروژه‌ها، مهلت اعتبار و خلاصه نحوه پرداخت باشد. مثال سبک بیانیه رسمی: «استعلام خرید ۱۲۰ شاخه لوله پلیکا ۶ اینچ (فی ۴۵۰,۰۰۰ تومان) از بازرگانی البرز برای پروژه پردیس. مهلت اعتبار پیش‌فاکتور: ۵ روز. شرایط پرداخت: نقد با ۵٪ تخفیف.» ۳. تفکیک وظایف محاسباتی با defaults.py: - تو فقط ساختار مفهومی را در patch استخراج می‌کنی؛ محاسبات عددی اقساط و تاریخ‌های تقویم شمسی بر عهده توابع سیستم است. - برای اقساط: تعداد اقساط را در installments_count استخراج کن (مثلاً ۴). - برای پرداخت نقدی: درصد تخفیف را در discount_percent استخراج کن (مثلاً ۵). - برای مهلت اعتبار: تعداد روز را در expiry_days بگذار (مثلاً ۵). ۴. قوانین استخراج پروژه‌ها و اقلام: - پروژه‌ها: اگر کاربر چند پروژه را ذکر کرد (مانند «برای پردیس و کارگاه مرکزی»)، تمام نام‌ها را عیناً در آرایه project_names قرار بده: ["پردیس", "کارگاه مرکزی"]. پروژه‌ها را با برند اشتباه نگیر. - اقلام: material_name فقط نام کلی کالا (مانند «لوله پلیکا» یا «میلگرد»)؛ مشخصات فنی (مانند «۶ اینچ» یا «آجدار») در آرایه specs؛ تعداد در quantity؛ واحد در unit_name؛ و قیمت واحد در unit_price (عددی خالص، مثلاً ۴۵۰ هزار = 450000). ۵. قوانین عمومی قصدها (Intents): - اگر پیام کاربر اطلاعات جدیدی به پیش‌نویسِ باز اضافه می‌کند یا اصلاح می‌کند → 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 مقادیر. ۶. استخراج موجودیت‌های قابل تطبیق (entity_mentions): - فیلدهای مجاز: "project" | "supplier" | "material" | "brand" | "spec" | "unit" - هر نام کالا/تأمین‌کننده/پروژه/برند/واحد و هر مشخصه که در پیام آمده، یک عضو جداگانه است. - اگر در نسخه‌های مختلف STT همان نام با املاهای متفاوت پیاده شده (مثل «مصطفوی»، «مستفدی»، «مسطفی»)، همه را در variants همان یک عضو بگذار — نه چند عضو جداگانه. - برای پیام متنی معمولی، variants یک عضو تک‌عنصر است. - مشخصه (spec) و برند (brand) جدا از متریال و جدا از هم استخراج شوند. - وظیفه‌ی تطبیق با موجودیت‌های سیستم بر عهده‌ی تو نیست؛ هیچ نامی را به نزدیک‌ترین نام موجود «حدس» نزن. شکل 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, trace_id: str | None = None) -> dict: """Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises.""" fallback = {"consensus_text": "", "intent": "unknown", "patch": {}, "entity_mentions": [], "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 system_prompt = _build_system_prompt(draft_summary) prompt_hash = hashlib.sha256( f"{settings.OBS_PROMPT_VERSION}|{system_prompt}".encode() ).hexdigest()[:16] await obs.event(trace_id, "nlu.request", model=settings.LLM_MODEL, payload={ "is_multi": isinstance(text, list), "content": user_content[:2000], "prompt_hash": prompt_hash, "prompt_version": settings.OBS_PROMPT_VERSION, }) 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": system_prompt}, {"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]) await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL, payload={"error": f"HTTP {resp.status_code}", "body": 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]) await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL, payload={"error": "unparseable", "raw": content[:500]}) return fallback parsed.setdefault("patch", {}) parsed["entity_mentions"] = sanitize_mentions(parsed.get("entity_mentions")) if not parsed.get("consensus_text"): parsed["consensus_text"] = first_text await obs.event(trace_id, "nlu.result", ok=True, model=settings.LLM_MODEL, payload={ "intent": parsed.get("intent"), "confidence": parsed.get("confidence"), "patch": parsed.get("patch"), "consensus_text": parsed.get("consensus_text"), "entity_mentions": parsed.get("entity_mentions"), }) return parsed except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError): logger.exception("NLU failure") await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL, payload={"error": "exception"}) return fallback