Phase 2 — jev.py client: POST /v1/systemone, PINNED model jev-1.13.0 (drift warning when the response model differs), one retry on 429/5xx, fail-soft (None on any error), usage/latency returned for observability. Config adds TYPESAFE_API_KEY / JEV_* (thresholds 0.85/0.60, 15 questions per field); no daily cap by owner decision. bot/.env is now mounted read-only into the container so the key never enters compose/git.
Phase 3 — matching.py: برونگروهی (one request per field, parallel), درونگروهی (one choice question per mention sharing the same full candidate list + «_none»), درونواریانسی (all STT spellings in one question). Routing: conf>=exact → bind; floor..exact → ambiguity; else/_none → new; missing/failed field → difflib fallback (bug fixed during testing: a failed request used to record 'new' instead of falling back). Resolver gains full cached lists for suppliers/materials (paged) plus brands (API+DB) and specs (EAV values + draft specs). NLU prompt now emits entity_mentions (6 fields) with the voice-variant grouping rule; sanitize_mentions guards the shape. handle_message runs matching only for register_quote/update_draft/add_material behind JEV_ENABLED; jev_map threads into quote/material resolve_payload (Jev hit skips difflib; exact writes ids; ambiguity skips difflib). Spec effect per owner: spelling normalization only (normalize_brand_spec).
Phase 4 — entity-match ambiguity sub-messages: create_ambiguities() builds deduped entries {kind: entity_match, field, variants, candidates, details with Persian percentages}; new keyboards.ambiguity_candidates_keyboard renders one button per candidate + «هیچکدام — ایجاد جدید»; callbacks handle amb⛏️{prefix}:{amb_id}:{idx} (applies the candidate via matching.apply_choice then re-resolves/renders) and amb:new (rejected → auto-create path at commit). Every user choice lands in observability (ambiguity.user_choice with arg+candidates).
Phase 5 — voice variants: NLU groups different STT spellings of one entity into one variants[] (verified live: مصطفوی/مستفدی → one supplier mention); tools/test_matching.py live section (RUN_LIVE=1) exercises real DeepSeek + real Jev without touching Bale.
Tests: test_jev.py 5/5 (mock HTTP: no-key, happy path, 429-retry, 400, exception), test_matching.py 11/11 mock + 3/3 live (real NLU produced 7 mentions with correct fields; real Jev matched «50 کیلویی»→«۵۰ کیلویی» 0.98 and «کیسه» 1.0 and correctly returned new against the emptied catalog); observability regression 8/8.
261 lines
16 KiB
Python
261 lines
16 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 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
|