Files
ERP/bot/app/nlu.py
T

205 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 ساختمانی و صنعتی در ایران هستی.
پیام‌های صوتی کاربران به زبان فارسی، با گویش و لهجه اصفهانی و با اصطلاحات محاوره‌ای کارگاهی و بازار بیان شده‌اند که نسخه‌های متنی متعددی توسط موتورهای تبدیل گفتار به متن (STT مانند Whisper و Vosk) از آن‌ها تولید شده است.
وظیفه تو تحلیل پیام، حل خطاهای شنیداری ناشی از لهجه و گویش، تولید متن اجماعی پالایش‌شده و استخراج دقیق داده‌های تدارکاتی است.
یک JSON و فقط JSON برگردان (بدون هرگونه متن، مارک‌داون یا توضیح اضافه) با این ساختار دقیق:
{
"consensus_text": "بیانیه رسمی، پالایش‌شده، فشرده و بدون تکیه‌کلام از تقاضای تدارکات کاربر",
"intent": یکی از {intents},
"patch": {شکل partial هر فعالیتی که کاربر داده},
"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 مقادیر.
شکل 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