205 lines
13 KiB
Python
205 lines
13 KiB
Python
"""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
|