The bot service in docker-compose.yml builds from ./bot, but the agent code lived in 'ai procurement/' and ./bot was only a Windows junction on reza.g's machine (and was gitignored) — exactly as the team reported: 'bot/ was never committed — prod-v7's bot is un-buildable from the repo.' - junction removed; 'ai procurement/' renamed to 'bot/' via git mv (all 41 files keep their history as pure renames) - 'bot/' dropped from .gitignore (voice/ stays ignored — runtime voice notes; bot/.env stays ignored by bot/.gitignore) - compose needs no change: context: ./bot and ./bot/app now resolve to committed files - bot/INTEGRATION-REPORT.md added: full story (problem, root cause, git state, security note on the committed BALE_BOT_TOKEN / AI_API_KEY compose defaults, and usage instructions for the team) Verified: 41 renames staged with zero secret/cache files; voice/ and bot/.env still ignored.
175 lines
9.0 KiB
Python
175 lines
9.0 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",
|
|
"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 مقادیر.
|
|
- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد» یا «لوله»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز یا گرید) در آرایه specs قرار گیرد.
|
|
- برای شرایط پرداخت: در صورت ذکر اقساط/موعد و مبالغ، در milestones: [{"due_date": "YYYY-MM-DD", "amount": number}] قرار بده.
|
|
|
|
شکل patch برای intent="register_quote"/"update_draft":
|
|
{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_date": "YYYY-MM-DD",
|
|
"items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "notes": str}],
|
|
"payment_terms": [{"label": str, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number}], "discount_type": "none|percent|fixed", "discount_value": number}]}
|
|
|
|
شکل 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="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)
|
|
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
|