Files
ERP/bot/app/api/client.py
T
whqreza 1c853e50a9 feat(matching): Jev entity matching phases 2-5 (per-field fan-out, variants, candidate ambiguity buttons)
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.
2026-10-12 21:24:03 +03:30

157 lines
7.4 KiB
Python

"""Async client for the procurement API (/api/v1).
Attribution: every call carries ``X-Actor-Id`` (the linked ERP user id) so
``quote_events.actor`` records the human who asked, not the bot. A service
bearer token is sent when configured (procurement endpoints are currently
open — the slot is for the day auth lands).
"""
import logging
from typing import Any
import httpx
logger = logging.getLogger("bot.api")
class ApiError(Exception):
def __init__(self, status: int, code: str, message: str, field: str | None = None):
super().__init__(message)
self.status = status
self.code = code
self.message = message
self.field = field
class ErpClient:
def __init__(self, base_url: str, timeout: float = 30.0, service_token: str = ""):
self._base = base_url.rstrip("/")
self._timeout = timeout
self._service_token = service_token
def _actor_headers(self, actor_id: str | None, actor_name: str | None) -> dict:
headers: dict[str, str] = {}
if self._service_token:
headers["Authorization"] = f"Bearer {self._service_token}"
if actor_id:
headers["X-Actor-Id"] = actor_id
return headers
async def _request(self, method: str, path: str, *, actor_id: str | None = None,
json: Any = None, params: dict | None = None) -> Any:
url = f"{self._base}{path}"
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
resp = await client.request(
method, url, json=json, params=params, headers=self._actor_headers(actor_id, None)
)
except httpx.HTTPError as exc:
raise ApiError(0, "NETWORK", f"ERP API unreachable: {exc}") from exc
if resp.status_code in (200, 201):
return resp.json()
try:
err = resp.json().get("error") or {}
except ValueError:
err = {}
raise ApiError(
resp.status_code,
str(err.get("code") or "INTERNAL_ERROR"),
str(err.get("message") or f"HTTP {resp.status_code}"),
err.get("field"),
)
# ------------------------------------------------------------------ auth
async def login(self, username: str, password: str) -> dict:
body = await self._request("POST", "/auth/login", json={"username": username, "password": password})
return body.get("data") or {}
# ---------------------------------------------------------------- lookup
async def list_suppliers(self, q: str | None = None, page_size: int = 20, page: int = 1) -> list[dict]:
params: dict = {"page_size": page_size, "page": page}
if q:
params["q"] = q
return (await self._request("GET", "/suppliers", params=params)).get("data") or []
async def create_supplier(self, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("POST", "/suppliers", json=payload, actor_id=actor_id)).get("data") or {}
async def list_materials(self, q: str | None = None, page_size: int = 20, page: int = 1) -> list[dict]:
params: dict = {"page_size": page_size, "page": page}
if q:
params["q"] = q
return (await self._request("GET", "/materials", params=params)).get("data") or []
async def create_material(self, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("POST", "/materials", json=payload, actor_id=actor_id)).get("data") or []
async def list_units(self, q: str | None = None, page_size: int = 100) -> list[dict]:
params: dict = {"page_size": page_size}
if q:
params["q"] = q
return (await self._request("GET", "/units", params=params)).get("data") or []
async def list_projects(self, page_size: int = 100) -> list[dict]:
return (await self._request("GET", "/projects", params={"page_size": page_size})).get("data") or []
async def create_project(self, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("POST", "/projects", json=payload, actor_id=actor_id)).get("data") or {}
async def list_categories(self, page_size: int = 100) -> list[dict]:
return (await self._request("GET", "/material-categories", params={"page_size": page_size})).get("data") or []
async def list_brands(self, page: int = 1, page_size: int = 100) -> list[dict]:
return (await self._request("GET", "/brands", params={"page": page, "page_size": page_size})).get("data") or []
# ---------------------------------------------------------------- quotes
async def list_quotes(self, *, status: str | None = None, q: str | None = None,
expand: str | None = None, sort: str | None = None,
page: int = 1, page_size: int = 10) -> tuple[list[dict], dict]:
params: dict = {"page": page, "page_size": page_size}
if status:
params["status"] = status
if q:
params["q"] = q
if expand:
params["expand"] = expand
if sort:
params["sort"] = sort
body = await self._request("GET", "/quotes", params=params)
return body.get("data") or [], body.get("meta") or {}
async def get_quote(self, quote_id: str, expand: str | None = None, actor_id: str | None = None) -> dict:
params = {"expand": expand} if expand else None
return (await self._request("GET", f"/quotes/{quote_id}", params=params, actor_id=actor_id)).get("data") or {}
async def create_quote(self, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("POST", "/quotes", json=payload, actor_id=actor_id)).get("data") or {}
async def accept_quote(self, quote_id: str, payload: dict, actor_id: str | None = None) -> dict:
path = f"/quotes/{quote_id}/accept"
return (await self._request("PATCH", path, json=payload, actor_id=actor_id)).get("data") or {}
async def arrive_quote(self, quote_id: str, actor_id: str | None = None) -> dict:
return (await self._request("PATCH", f"/quotes/{quote_id}/arrive", actor_id=actor_id)).get("data") or {}
async def arrive_items(self, quote_id: str, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("PATCH", f"/quotes/{quote_id}/arrive-items", json=payload, actor_id=actor_id)).get("data") or {}
async def reject_quote(self, quote_id: str, reason: str | None = None, actor_id: str | None = None) -> dict:
return (await self._request(
"PATCH", f"/quotes/{quote_id}/reject", json={"reason": reason} if reason else None, actor_id=actor_id
)).get("data") or {}
async def upload_quote_file(self, quote_id: str, kind: str, file_name: str, content: bytes,
mime_type: str = "application/pdf", actor_id: str | None = None) -> dict:
url = f"{self._base}/quotes/{quote_id}/{kind}"
files = {"file": (file_name, content, mime_type)}
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
resp = await client.post(url, files=files, headers=self._actor_headers(actor_id, None))
if resp.status_code in (200, 201):
return resp.json()
except Exception as e:
logger.warning("Failed to upload quote attachment to ERP: %s", e)
return {}