feat(agent): integrate procurement AI agent with Bale bot, real-time draft sync, and git history bundle

This commit is contained in:
2026-09-21 20:23:13 +03:30
parent 4cfb9ec6f6
commit 9e19be8354
44 changed files with 4582 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
{
"intent": "register_quote",
"patch": {
"supplier_name": "شرکت فولاد مبارکه",
"project_name": "برج آسمان",
"project_names": ["برج آسمان"],
"is_official": true,
"notes": "تحویل پای کارگاه",
"expiry_date": null,
"items": [
{
"material_name": "میلگرد",
"brand_name": null,
"specs": ["A3", "سایز ۱۴"],
"quantity": 5000,
"unit_name": "کیلوگرم",
"unit_price": 28500,
"notes": ""
}
],
"payment_terms": [
{
"label": "شرایط پرداخت",
"milestones": [
{"due_date": null, "amount": 50000000},
{"due_date": null, "amount": null}
]
}
]
},
"confidence": 0.95
}
+71
View File
@@ -0,0 +1,71 @@
import json
import urllib.request
api_key = "sk-a671eab15be846d494bf0839070a3b32"
url = "https://api.deepseek.com/chat/completions"
prompt = """تو دستیار تدارکات یک ERP فارسی هستی. ورودی تو پیام صوتی/متنی کاربر است.
یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل:
{
"intent": "register_quote",
"patch": {
"supplier_name": "نام تأمین‌کننده",
"project_name": "نام پروژه",
"project_names": ["نام پروژه"],
"is_official": true,
"notes": "یادداشت‌ها",
"expiry_date": "YYYY-MM-DD",
"items": [
{
"material_name": "فقط نام کلی کالا",
"brand_name": "نام کارخانه یا برند",
"specs": ["مشخصه ۱", "مشخصه ۲"],
"quantity": 5000,
"unit_name": "کیلوگرم",
"unit_price": 28500,
"notes": ""
}
],
"payment_terms": [
{
"label": "شرایط پرداخت",
"milestones": [
{"due_date": "YYYY-MM-DD", "amount": 50000000}
]
}
]
},
"confidence": 0.95
}
قوانین:
- برای کالاها: material_name فقط نام کلی کالا باشد (مانند «میلگرد» یا «لوله»)؛ کارخانه یا برند در brand_name؛ و مشخصات فنی (مثل سایز یا گرید) در آرایه specs قرار گیرد.
- برای شرایط پرداخت: در صورت ذکر اقساط/موعد و مبالغ، در milestones: [{"due_date": "YYYY-MM-DD", "amount": number}] قرار بده.
"""
user_msg = "استعلام قیمت از شرکت فولاد مبارکه برای ۵ تن میلگرد A3 سایز ۱۴ به قیمت هر کیلو ۲۸۵۰۰ تومن، تحویل پای کارگاه برج آسمان، پرداخت در دو مرحله ۵۰ میلیون نقد و مابقی تا آخر ماه"
payload = {
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": prompt},
{"role": "user", "content": user_msg}
],
"response_format": {"type": "json_object"}
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}"
})
try:
resp = urllib.request.urlopen(req)
res_data = json.loads(resp.read().decode("utf-8"))
content = res_data["choices"][0]["message"]["content"]
with open("tests/deepseek_output.json", "w", encoding="utf-8") as out_f:
out_f.write(content)
print("DEEPSEEK SUCCESS! Saved to tests/deepseek_output.json")
print("KEYS IN EXTRACTED JSON:", list(json.loads(content).get("patch", {}).keys()))
except Exception as e:
print("DEEPSEEK ERROR:", e)
+123
View File
@@ -0,0 +1,123 @@
import json
import sys
import types
from pathlib import Path
# Stub external dependencies if not present in host python
for mod in ["sqlalchemy", "sqlalchemy.ext", "sqlalchemy.ext.asyncio", "sqlalchemy.orm", "asyncpg", "pydantic", "pydantic_settings", "httpx"]:
if mod not in sys.modules:
m = types.ModuleType(mod)
sys.modules[mod] = m
sys.modules["pydantic_settings"].BaseSettings = type("BaseSettings", (), {})
sys.modules["pydantic_settings"].SettingsConfigDict = lambda **k: None
sys.modules["pydantic"].BaseModel = type("BaseModel", (), {})
# Stub specific symbols needed by app.db and app.models
sys.modules["sqlalchemy"].select = lambda *a, **k: None
sys.modules["sqlalchemy"].update = lambda *a, **k: None
sys.modules["sqlalchemy"].func = types.SimpleNamespace(gen_random_uuid=lambda: None, now=lambda: None)
sys.modules["sqlalchemy"].BigInteger = type("BigInteger", (), {})
sys.modules["sqlalchemy"].DateTime = lambda *a, **k: None
sys.modules["sqlalchemy"].Index = lambda *a, **k: None
sys.modules["sqlalchemy"].Integer = type("Integer", (), {})
sys.modules["sqlalchemy"].String = lambda *a, **k: None
sys.modules["sqlalchemy"].Text = type("Text", (), {})
sys.modules["sqlalchemy.dialects.postgresql"] = types.ModuleType("sqlalchemy.dialects.postgresql")
sys.modules["sqlalchemy.dialects.postgresql"].JSONB = lambda *a, **k: None
sys.modules["sqlalchemy.dialects.postgresql"].UUID = lambda *a, **k: None
sys.modules["sqlalchemy.orm"].DeclarativeBase = type("DeclarativeBase", (), {})
sys.modules["sqlalchemy.orm"].Mapped = type("Mapped", (), {})
sys.modules["sqlalchemy.orm"].mapped_column = lambda *a, **k: None
sys.modules["sqlalchemy.ext.asyncio"].create_async_engine = lambda *a, **k: None
sys.modules["sqlalchemy.ext.asyncio"].async_sessionmaker = lambda *a, **k: None
# Add 'ai procurement' to sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ai procurement"))
from app.drafts import (
new_quote_payload,
merge_quote_patch,
build_quote_request,
compute_quote_missing,
)
def test_wizard_conversion():
mock_path = Path(__file__).resolve().parents[1] / "tests" / "wizard_mock_data.json"
with open(mock_path, encoding="utf-8") as f:
data = json.load(f)
# 1. Start with clean payload
payload = new_quote_payload()
# 2. Extract patch from wizard mock data (simulating NLU patch extraction)
s1 = data["step1_topic_and_supplier"]
s2 = data["step2_line_items"]
s3 = data["step3_termsheets"]
s4 = data["step4_projects"]
s5 = data["step5_review_and_metadata"]
patch = {
"supplier_name": s1["supplier_name"],
"is_official": s1["is_official"],
"project_names": s4["projects"],
"expiry_date": s5["expiry_date"],
"notes": s5["notes"],
"items": [
{
"material_name": it["material_name"],
"brand_name": it["brand"],
"specs": it["specs"],
"quantity": it["quantity"],
"unit_name": it["unit"],
"unit_price": it["unit_price"],
}
for it in s2
],
"payment_terms": [
{
"label": ts["label"],
"milestones": ts["milestones"],
}
for ts in s3
],
}
# 3. Merge patch into payload
changed = merge_quote_patch(payload, patch)
assert changed, "Patch should have changed payload"
# 4. Simulate resolution
payload["supplier_id"] = "11111111-1111-1111-1111-111111111111"
payload["project_ids"] = ["22222222-2222-2222-2222-222222222222"]
for i, it in enumerate(payload["items"], start=1):
it["material_id"] = f"33333333-3333-3333-3333-33333333333{i}"
it["unit_id"] = 3
# 5. Build QuoteCreate request
body, problems = build_quote_request(payload)
assert problems == [], f"Expected no problems, got {problems}"
assert body is not None, "Body should not be None"
assert body["supplier_id"] == "11111111-1111-1111-1111-111111111111"
assert body["is_official"] is True
assert len(body["line_items"]) == 2
assert body["line_items"][0]["temp_id"] == "t1"
assert "A3، سایز ۱۴" in body["line_items"][0]["notes"]
assert "فولاد مبارکه" in body["line_items"][0]["notes"]
assert len(body["termsheets"]) == 1
assert body["termsheets"][0]["temp_id"] == "p1"
assert len(body["termsheets"][0]["schedule_steps"]) == 3
assert body["termsheets"][0]["schedule_steps"][0]["step_no"] == 1
assert body["termsheets"][0]["schedule_steps"][0]["amount"] == 300000000.0
assert len(body["item_termsheets"]) == 2
assert body["item_termsheets"][0]["line_item_temp_id"] == "t1"
assert body["item_termsheets"][0]["termsheet_temp_id"] == "p1"
assert body["item_termsheets"][0]["unit_price"] == 48500.0
print("ALL WIZARD CONVERSION ASSERTIONS PASSED SUCCESSFULLY! [OK]")
if __name__ == "__main__":
test_wizard_conversion()
+86
View File
@@ -0,0 +1,86 @@
{
"step1_topic_and_supplier": {
"topic": "آهن‌آلات و مقاطع فولادی",
"supplier_name": "فولاد آذرخش",
"is_official": true,
"new_supplier_details": {
"location": "تهران - ونک",
"phone": "02188776655",
"contact_person": "حسینی"
}
},
"step2_line_items": [
{
"material_name": "میلگرد",
"brand": "فولاد مبارکه",
"specs": [
"A3",
"سایز ۱۴"
],
"quantity": 12500.0,
"unit": "کیلوگرم",
"unit_price": 48500.0,
"total_price": 606250000.0
},
{
"material_name": "میلگرد",
"brand": "فولاد مبارکه",
"specs": [
"A3",
"سایز ۱۶"
],
"quantity": 8000.0,
"unit": "کیلوگرم",
"unit_price": 47200.0,
"total_price": 377600000.0
}
],
"step3_termsheets": [
{
"label": "گزینه ۱",
"milestones": [
{
"due_date": "2026-09-22",
"amount": 300000000.0
},
{
"due_date": "2026-09-28",
"amount": 450000000.0
},
{
"due_date": "2026-10-15",
"amount": 233850000.0
}
],
"covered_items": [
"میلگرد - فولاد مبارکه - A3 - سایز ۱۴",
"میلگرد - فولاد مبارکه - A3 - سایز ۱۶"
]
}
],
"step4_projects": {
"projects": [
"برج آسمان"
],
"item_project_assignments": [
{
"item_name": "میلگرد - فولاد مبارکه - A3 - سایز ۱۴",
"assigned_projects": [
"برج آسمان"
]
},
{
"item_name": "میلگرد - فولاد مبارکه - A3 - سایز ۱۶",
"assigned_projects": [
"برج آسمان"
]
}
]
},
"step5_review_and_metadata": {
"quote_date": "2026-09-19T08:30:00Z",
"expiry_date": "2026-10-05",
"notes": "تحویل پای کارگاه برج آسمان. کرایه حمل با فروشنده است.",
"proforma_file": "پیش‌فاکتور_فولاد_آذرخش_۱۴۰۳.pdf"
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"step1_topic_and_supplier": {
"topic": null,
"supplier_name": null,
"is_official": null,
"new_supplier_details": {
"location": null,
"phone": null,
"contact_person": null
}
},
"step2_line_items": [
{
"material_name": null,
"brand": null,
"specs": [],
"quantity": null,
"unit": null,
"unit_price": null,
"total_price": null
}
],
"step3_termsheets": [
{
"label": null,
"milestones": [
{
"due_date": null,
"amount": null
}
],
"covered_items": []
}
],
"step4_projects": {
"projects": [],
"item_project_assignments": [
{
"item_name": null,
"assigned_projects": []
}
]
},
"step5_review_and_metadata": {
"quote_date": null,
"expiry_date": null,
"notes": null,
"proforma_file": null
}
}