diff --git a/ai procurement/.env.example b/ai procurement/.env.example new file mode 100644 index 0000000..e040620 --- /dev/null +++ b/ai procurement/.env.example @@ -0,0 +1,28 @@ +# Bale Bot API — token from Bale's BotFather (required) +PROCUREMENT_BOT_BOT_TOKEN= +PROCUREMENT_BOT_BALE_API_BASE=https://tapi.bale.ai + +# ERP procurement API (in Docker: http://api:8000/api/v1) +PROCUREMENT_BOT_API_BASE_URL=http://localhost:8000/api/v1 +PROCUREMENT_BOT_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/procurement + +# LLM provider — any OpenAI-compatible endpoint (JSON-mode chat completions) +PROCUREMENT_BOT_AI_BASE_URL=https://api.deepseek.com +PROCUREMENT_BOT_AI_API_KEY= +PROCUREMENT_BOT_LLM_MODEL=deepseek-flash + +# STT provider — must expose POST /audio/transcriptions. +# Falls back to AI_* when unset. DeepSeek has no audio API, so voice needs +# its own whisper-compatible provider (OpenAI, GLM, self-hosted faster-whisper…). +PROCUREMENT_BOT_STT_BASE_URL= +PROCUREMENT_BOT_STT_API_KEY= +PROCUREMENT_BOT_STT_MODEL=whisper-1 +PROCUREMENT_BOT_STT_LANGUAGE=fa + +# Bale chat ids allowed without Casdoor linking (comma separated) +PROCUREMENT_BOT_ADMIN_CHAT_IDS= +# Optional bearer for the day the ERP starts enforcing auth on procurement routes +PROCUREMENT_BOT_SERVICE_TOKEN= +# Drafts older than this are swept into `expired` +PROCUREMENT_BOT_DRAFT_TTL_HOURS=24 +PROCUREMENT_BOT_LOG_LEVEL=INFO diff --git a/ai procurement/.gitignore b/ai procurement/.gitignore new file mode 100644 index 0000000..bef9ac6 --- /dev/null +++ b/ai procurement/.gitignore @@ -0,0 +1,14 @@ +# ---- Env / secrets ---- +.env +*.local + +# ---- Python ---- +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# ---- OS junk ---- +.DS_Store +Thumbs.db diff --git a/ai procurement/Dockerfile b/ai procurement/Dockerfile new file mode 100644 index 0000000..ba8bf03 --- /dev/null +++ b/ai procurement/Dockerfile @@ -0,0 +1,23 @@ +# Bale procurement bot — a long-polling worker against tapi.bale.ai. +# ffmpeg converts Bale voice notes (OGG/Opus) before they are sent to STT. +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /srv/bot + +# Dependencies first (layer caching). PIP_INDEX_URL overridable like the api image. +ARG PIP_INDEX_URL=https://pypi.org/simple +COPY requirements.txt . +RUN pip install --no-cache-dir -i ${PIP_INDEX_URL} -r requirements.txt + +# Application +COPY app ./app +COPY tools ./tools + +CMD ["python", "-m", "app.main"] diff --git a/ai procurement/README.md b/ai procurement/README.md new file mode 100644 index 0000000..50350d0 --- /dev/null +++ b/ai procurement/README.md @@ -0,0 +1,120 @@ +# procurement_ai_agent — Bale chatbot for the procurement ERP + +A **Bale messenger bot** that turns Persian **chat messages and voice notes** +into procurement actions in the +[procurement ERP](https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP) — +draft-first: it captures what it understood, tells you **what it did** and +**what is still empty**, and **commits only after you approve** with a ✅ button. + +``` +you (Bale, text/voice) ┌─────────────────────────────────┐ + │ getUpdates (long poll) │ procurement_ai_agent │ + ▼ │ ffmpeg → STT (Persian) │ +┌──────────────────┐ POST {base}/bot │ LLM → intent + entities │ +│ tapi.bale.ai │◄───────────────────────►│ fuzzy resolve → draft payload │ +└──────────────────┘ sendMessage / buttons │ review + «چه چیزهایی خالی مونده»│ + └───────────────┬─────────────────┘ + │ HTTP + X-Actor-Id + ▼ + ┌─────────────────────────────────┐ + │ ERP api (/api/v1) │ + │ quotes · suppliers · materials│ + └─────────────────────────────────┘ +``` + +## What it can do + +| Activity | Example (voice or text) | Committed via | +|---|---|---| +| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` | +| Add a supplier | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` | +| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` | +| Status queries | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes?…` | +| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` | +| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` | + +### The draft → approve loop + +1. Send a message or voice note. Voice → ffmpeg → STT; text/STT text → LLM + extracts a typed **intent + entities** (JSON mode, Persian digits + normalized). +2. Names are fuzzy-matched against the ERP. Several candidates → inline + **pick buttons**; no match → listed as missing. +3. The bot replies with a **review card**: everything captured + a + ✅/⚠️/❌ list of what is still empty. +4. Keep chatting to refine («قیمت میلگرد ۴۷ شد») — the open draft updates and + re-renders. Drafts survive bot restarts (postgres-backed, TTL-swept). +5. **Nothing is written to the ERP until you tap ✅.** After commit you get the + created quote id and the fields left empty. + +### Team access & attribution + +Each member links their Bale chat once: `/start` → Casdoor username → password +(validated via the ERP's real `POST /auth/login`, used once, never stored). +Every mutating call carries `X-Actor-Id: ` so `quote_events.actor` +records the **human** who asked. + +Commands: `/start` · `/help` · `/drafts` · `/cancel` · `/whoami` · `/unlink` + +## Quick start (Docker) + +```bash +cp .env.example .env # fill PROCUREMENT_BOT_BOT_TOKEN + AI_API_KEY +docker build -t procurement-ai-agent . +docker run -d --name procurement-bot --env-file .env \ + --add-host host.docker.internal:host-gateway \ + procurement-ai-agent +docker logs -f procurement-bot +``` + +Or as part of the ERP stack — see [`integration/`](integration/README.md) +(compose service + the one tiny ERP-side patch). + +Full setup, env-var reference, flows and troubleshooting: +**[docs/SETUP.md](docs/SETUP.md)**. + +## Architecture + +``` +app/ +├── main.py ensure bot tables → sweeper task → getUpdates long-poll loop +├── config.py pydantic-settings, PROCUREMENT_BOT_ prefix +├── bale/client.py Telegram-compatible Bale API (poll, send, edit, buttons, files) +├── api/client.py ERP /api/v1 client (X-Actor-Id attribution, error envelope) +├── stt.py ffmpeg (OGG/Opus → MP3) → OpenAI-compatible /audio/transcriptions +├── nlu.py Persian intent+entity extraction (JSON-mode chat completions) +├── resolve.py fuzzy name→id matching (difflib over ilike search), TTL caches +├── drafts.py draft store · QuoteCreate payload builder · missing-field logic +├── keyboards.py inline keyboards (callback_data ≤ 64 B op-codes) +├── texts.py Persian message templates +├── runtime.py context, link store, in-memory pending flows +├── models.py bot_links / bot_drafts / bot_messages / bot_meta (own schema) +└── handlers/ dispatch · commands · callbacks · links · + quote · supplier · material · queries · decisions +``` + +Design notes: + +- **Telegram-compatible Bale API** (`tapi.bale.ai`): getUpdates long polling — + no public port, no webhook, resumable offset persisted across restarts. +- **Draft-first**: `bot_drafts.payload` holds a talking-friendly JSON that is + converted into the exact `POST /quotes` request body (temp_id + cross-references) only on approval; the API deliberately tolerates + incomplete quotes, so partial commits are explicit, never accidental. +- **Accept flow echoes the termsheet's existing `schedule_steps`** back to + `PATCH /quotes/{id}/accept` (the API replaces steps with what it receives). +- **Persian-aware matching**: Persian/Arabic digit normalization, ZWNJ folding, + Arabic ya/ka folding, difflib ranking on top of the API's ilike search. +- **Voice notes** arrive as OGG/Opus; ffmpeg converts to mono 16 kHz MP3 before + STT. The LLM provider and the STT provider are configured independently + (e.g. DeepSeek for NLU + any whisper-compatible endpoint for voice). + +## Testing + +An offline end-to-end harness stubs Bale/ERP/AI and drives the real handler +pipeline with fake updates — no token, DB or API key needed: + +```bash +pip install -r requirements.txt +python tools/smoke_test.py # → ALL PASSED: 48 passed +``` diff --git a/ai procurement/app/__init__.py b/ai procurement/app/__init__.py new file mode 100644 index 0000000..d325c30 --- /dev/null +++ b/ai procurement/app/__init__.py @@ -0,0 +1,2 @@ +"""Bale procurement chatbot — drafts ERP actions from chat text/voice, +reports what it captured + what is missing, and commits only on approval.""" diff --git a/ai procurement/app/api/__init__.py b/ai procurement/app/api/__init__.py new file mode 100644 index 0000000..0e904a5 --- /dev/null +++ b/ai procurement/app/api/__init__.py @@ -0,0 +1 @@ +"""ERP API client — the only way the bot touches procurement data.""" diff --git a/ai procurement/app/api/client.py b/ai procurement/app/api/client.py new file mode 100644 index 0000000..dca7026 --- /dev/null +++ b/ai procurement/app/api/client.py @@ -0,0 +1,137 @@ +"""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) -> list[dict]: + params: dict = {"page_size": page_size} + 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) -> list[dict]: + params: dict = {"page_size": page_size} + 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 list_categories(self, page_size: int = 100) -> list[dict]: + return (await self._request("GET", "/material-categories", params={"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 {} diff --git a/ai procurement/app/bale/__init__.py b/ai procurement/app/bale/__init__.py new file mode 100644 index 0000000..ffdd87e --- /dev/null +++ b/ai procurement/app/bale/__init__.py @@ -0,0 +1 @@ +"""Bale Bot API client (Telegram-compatible surface at tapi.bale.ai).""" diff --git a/ai procurement/app/bale/client.py b/ai procurement/app/bale/client.py new file mode 100644 index 0000000..51cba83 --- /dev/null +++ b/ai procurement/app/bale/client.py @@ -0,0 +1,175 @@ +"""Thin async client for the Bale Bot API. + +Bale's bot surface is a Telegram Bot API clone: ``POST {base}/bot{token}/{method}`` +and file downloads at ``{base}/file/bot{token}/{file_path}``. Long polling via +``getUpdates`` avoids any webhook/TLS setup on the server. + +HTML parse_mode is requested first; if Bale rejects it for a given payload we +transparently retry as plain text so messages never get lost. +""" +import asyncio +import logging + +import httpx + +logger = logging.getLogger("bot.bale") + +REPLY_MARKUP = "reply_markup" + + +class BaleError(Exception): + def __init__(self, description: str, error_code: int | None = None): + super().__init__(description) + self.description = description + self.error_code = error_code + + +class BaleClient: + def __init__(self, base_url: str, token: str, timeout: float = 60.0): + self._base = base_url.rstrip("/") + self._token = token + self._timeout = timeout + + # ------------------------------------------------------------------ core + + def _method_url(self, method: str) -> str: + return f"{self._base}/bot{self._token}/{method}" + + def _file_url(self, file_path: str) -> str: + return f"{self._base}/file/bot{self._token}/{file_path.lstrip('/')}" + + async def _call(self, method: str, payload: dict | None = None, timeout: float | None = None) -> dict: + async with httpx.AsyncClient(timeout=timeout or self._timeout) as client: + try: + resp = await client.post(self._method_url(method), json=payload or {}) + except httpx.HTTPError as exc: + raise BaleError(f"network error calling {method}: {exc}") from exc + try: + body = resp.json() + except ValueError as exc: + raise BaleError(f"non-JSON response for {method} (HTTP {resp.status_code})") from exc + if not body.get("ok"): + raise BaleError(str(body.get("description") or "unknown bale error"), resp.status_code) + return body.get("result") + + # ------------------------------------------------------------- receiving + + async def get_updates(self, offset: int, timeout_seconds: int) -> list[dict]: + """Long poll. ``timeout_seconds=0`` returns immediately (drain mode).""" + result = await self._call( + "getUpdates", + {"offset": offset, "timeout": timeout_seconds, "allowed_updates": ["message", "callback_query"]}, + timeout=timeout_seconds + 15, + ) + return result or [] + + # -------------------------------------------------------------- sending + + async def send_message(self, chat_id: int, text: str, reply_markup: dict | None = None, + reply_to: int | None = None) -> dict: + payload: dict = {"chat_id": chat_id, "text": text[:4000], "parse_mode": "HTML"} + if reply_markup: + payload[REPLY_MARKUP] = reply_markup + if reply_to: + payload["reply_to_message_id"] = reply_to + try: + return await self._call("sendMessage", payload) + except BaleError: + # parse_mode / markup rejection fallback: retry plain, keep the keyboard. + payload.pop("parse_mode", None) + return await self._call("sendMessage", payload) + + async def edit_message_text(self, chat_id: int, message_id: int, text: str, + reply_markup: dict | None = None) -> dict: + payload: dict = {"chat_id": chat_id, "message_id": message_id, "text": text[:4000], "parse_mode": "HTML"} + if reply_markup: + payload[REPLY_MARKUP] = reply_markup + try: + return await self._call("editMessageText", payload) + except BaleError: + payload.pop("parse_mode", None) + return await self._call("editMessageText", payload) + + async def answer_callback_query(self, callback_query_id: str, text: str | None = None) -> None: + payload: dict = {"callback_query_id": callback_query_id} + if text: + payload["text"] = text[:200] + try: + await self._call("answerCallbackQuery", payload) + except BaleError as exc: + logger.warning("answerCallbackQuery failed: %s", exc) + + async def send_chat_action(self, chat_id: int, action: str = "typing") -> None: + try: + await self._call("sendChatAction", {"chat_id": chat_id, "action": action}) + except BaleError as exc: + logger.debug("sendChatAction failed: %s", exc) + + # ---------------------------------------------------------------- files + + async def get_file(self, file_id: str) -> dict: + return await self._call("getFile", {"file_id": file_id}) + + async def download_file(self, file_id: str) -> bytes: + """Resolve a file_id to bytes (getFile → file_path → download URL).""" + info = await self.get_file(file_id) + file_path = info.get("file_path") + if not file_path: + raise BaleError("getFile returned no file_path") + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.get(self._file_url(file_path)) + resp.raise_for_status() + return resp.content + + +async def poll_loop(client: BaleClient, on_update, offset_key: str = "bale_offset") -> None: + """Long-poll supervisor: resumable offset, one update at a time, never dies. + + ``on_update`` must be an async callable receiving the raw update dict. + """ + from app.db import SessionLocal + from app.models import BotMeta + + async def load_offset() -> int: + async with SessionLocal() as session: + row = (await session.execute( + __import__("sqlalchemy").select(BotMeta.value).where(BotMeta.key == offset_key) + )).scalar() + return int(row) if row else 0 + + async def save_offset(value: int) -> None: + from sqlalchemy.dialects.postgresql import insert as pg_insert + + async with SessionLocal() as session: + stmt = pg_insert(BotMeta).values(key=offset_key, value=str(value)) + stmt = stmt.on_conflict_do_update(index_elements=[BotMeta.key], set_={"value": str(value)}) + await session.execute(stmt) + await session.commit() + + offset = await load_offset() + if offset == 0: + # Skip any backlog accumulated while the bot was down: peek once + # without waiting and jump straight to the newest update. + backlog = await client.get_updates(0, 0) + if backlog: + offset = max(u["update_id"] for u in backlog) + 1 + + logger.info("polling started (offset=%s)", offset) + while True: + try: + updates = await client.get_updates(offset, 25) + for update in updates: + offset = max(offset, update["update_id"] + 1) + await save_offset(offset) + try: + await on_update(update) + except Exception: + logger.exception("handler crashed on update %s", update.get("update_id")) + except asyncio.CancelledError: + raise + except BaleError as exc: + logger.warning("poll error: %s", exc) + await asyncio.sleep(3) + except Exception: + logger.exception("unexpected poll error") + await asyncio.sleep(3) diff --git a/ai procurement/app/config.py b/ai procurement/app/config.py new file mode 100644 index 0000000..86f655f --- /dev/null +++ b/ai procurement/app/config.py @@ -0,0 +1,77 @@ +"""Bot configuration. + +All settings can be overridden with environment variables prefixed with +``PROCUREMENT_BOT_`` (e.g. ``PROCUREMENT_BOT_BOT_TOKEN=...``) or a local +``.env`` file. Docker/compose passes them in directly (same convention as +the api service's ``PROCUREMENT_*`` prefix). +""" +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="PROCUREMENT_BOT_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # Bale Bot API — Telegram-compatible clone hosted at tapi.bale.ai. + BALE_API_BASE: str = "https://tapi.bale.ai" + BOT_TOKEN: str = "" # from Bale's BotFather + POLL_TIMEOUT_SECONDS: int = 25 # getUpdates long-poll window + POLL_ERROR_SLEEP_SECONDS: float = 3.0 + + # ERP API (in-network service URL inside Docker). + API_BASE_URL: str = "http://localhost:8000/api/v1" + API_TIMEOUT_SECONDS: float = 30.0 + # Optional bearer for the day procurement endpoints start enforcing auth. + SERVICE_TOKEN: str = "" + + # Frontend URL (for opening Wizard from Bale bot) + FRONTEND_URL: str = "http://localhost:8081" + + # OpenAI-compatible AI provider (LLM), e.g. OpenAI / GLM / DeepSeek / OpenRouter. + AI_BASE_URL: str = "" # e.g. https://api.openai.com/v1 + AI_API_KEY: str = "" + LLM_MODEL: str = "gpt-4o-mini" + LLM_TIMEOUT_SECONDS: float = 60.0 + + # STT provider — any OpenAI-compatible /audio/transcriptions endpoint. + # Falls back to the LLM provider when unset. Note: some providers + # (e.g. DeepSeek) have no audio API — voice stays disabled gracefully. + STT_BASE_URL: str = "" + STT_API_KEY: str = "" + STT_MODEL: str = "whisper-1" + STT_LANGUAGE: str = "fa" + + @property + def stt_base_url(self) -> str: + return self.STT_BASE_URL or self.AI_BASE_URL + + @property + def stt_api_key(self) -> str: + return self.STT_API_KEY or self.AI_API_KEY + + # PostgreSQL — same instance/database as the api service. + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/procurement" + + # Chats that may use the bot without Casdoor linking (comma separated). + ADMIN_CHAT_IDS: str = "" + + # Drafts older than this are marked expired by the sweeper loop. + DRAFT_TTL_HOURS: int = 24 + + LOG_LEVEL: str = "INFO" + + @property + def admin_chat_ids(self) -> set[int]: + out: set[int] = set() + for part in self.ADMIN_CHAT_IDS.replace(";", ",").split(","): + part = part.strip() + if part.isdigit(): + out.add(int(part)) + return out + + +settings = Settings() diff --git a/ai procurement/app/db.py b/ai procurement/app/db.py new file mode 100644 index 0000000..dfc23ae --- /dev/null +++ b/ai procurement/app/db.py @@ -0,0 +1,7 @@ +"""Async engine/session for the bot's own tables (same postgres as the api).""" +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.config import settings + +engine = create_async_engine(settings.DATABASE_URL, pool_pre_ping=True) +SessionLocal = async_sessionmaker(engine, expire_on_commit=False) diff --git a/ai procurement/app/db_init.py b/ai procurement/app/db_init.py new file mode 100644 index 0000000..82f87fa --- /dev/null +++ b/ai procurement/app/db_init.py @@ -0,0 +1,77 @@ +"""Idempotent DDL for the bot's own tables (mirrors the api's init_db.py +convention — the ORM never creates tables; this script does, IF NOT EXISTS). +""" +import logging + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Base + +logger = logging.getLogger("bot.db_init") + +DDL = [ + """ + CREATE TABLE IF NOT EXISTS bot_links ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chat_id BIGINT UNIQUE NOT NULL, + user_id UUID, + casdoor_username TEXT, + display_name TEXT, + role VARCHAR(32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """, + "CREATE INDEX IF NOT EXISTS ix_bot_links_chat_id ON bot_links (chat_id)", + """ + CREATE TABLE IF NOT EXISTS bot_drafts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chat_id BIGINT NOT NULL, + kind VARCHAR(16) NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + missing JSONB, + status VARCHAR(16) NOT NULL DEFAULT 'active', + source VARCHAR(16), + review_message_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """, + "CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_id ON bot_drafts (chat_id)", + "CREATE INDEX IF NOT EXISTS ix_bot_drafts_chat_status ON bot_drafts (chat_id, status)", + "ALTER TABLE bot_drafts ADD COLUMN IF NOT EXISTS last_bale_update_at TIMESTAMPTZ", + """ + CREATE TABLE IF NOT EXISTS bot_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + chat_id BIGINT NOT NULL, + direction VARCHAR(4) NOT NULL, + kind VARCHAR(16) NOT NULL DEFAULT 'text', + body TEXT, + payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """, + "CREATE INDEX IF NOT EXISTS ix_bot_messages_chat_id ON bot_messages (chat_id)", + """ + CREATE TABLE IF NOT EXISTS bot_meta ( + key VARCHAR(64) PRIMARY KEY, + value TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """, +] + + +async def ensure_bot_tables(session: AsyncSession) -> None: + for stmt in DDL: + await session.execute(text(stmt)) + await session.commit() + logger.info("bot tables ready") + + +async def ensure_schema() -> None: + from app.db import SessionLocal + + async with SessionLocal() as session: + await ensure_bot_tables(session) diff --git a/ai procurement/app/drafts.py b/ai procurement/app/drafts.py new file mode 100644 index 0000000..e36feed --- /dev/null +++ b/ai procurement/app/drafts.py @@ -0,0 +1,443 @@ +"""Draft store + payload/missing-field logic. + +A draft's ``payload`` is a talking-friendly JSON the bot keeps refining. When +the user approves, ``build_quote_payload`` converts it into the exact +``POST /quotes`` request body (temp_ids cross-referencing line items, +termsheets and prices). + +Resolution state lives inside the payload: + payload["supplier_id"], payload["project_id"], + item["material_id"], item["unit_id"], item["unit_explicit"], + payload["options"][field] = candidate list shown as pick-buttons. +""" +import difflib +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import cast, func, select, String, update + +from app.db import SessionLocal +from app.models import BotDraft +from app.nlu import normalize_text + +KIND_QUOTE = "quote" +KIND_SUPPLIER = "supplier" +KIND_MATERIAL = "material" + + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + +async def create(chat_id: int, kind: str, payload: dict, source: str | None = None) -> BotDraft: + async with SessionLocal() as session: + # one active draft per (chat, kind) + await session.execute( + update(BotDraft) + .where(BotDraft.chat_id == chat_id, BotDraft.kind == kind, BotDraft.status == "active") + .values(status="superseded") + ) + draft = BotDraft(chat_id=chat_id, kind=kind, payload=payload, source=source) + session.add(draft) + await session.commit() + await session.refresh(draft) + return draft + + +async def get_active(chat_id: int, kind: str | None = None) -> BotDraft | None: + async with SessionLocal() as session: + stmt = select(BotDraft).where(BotDraft.chat_id == chat_id, BotDraft.status == "active") + if kind: + stmt = stmt.where(BotDraft.kind == kind) + stmt = stmt.order_by(BotDraft.updated_at.desc()).limit(1) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def get_by_prefix(draft_prefix: str, chat_id: int | None = None) -> BotDraft | None: + """Resolve the short id used in callback_data (first 8 hex of the UUID).""" + async with SessionLocal() as session: + stmt = select(BotDraft).where(cast(BotDraft.id, String).like(f"{draft_prefix}%")) + if chat_id is not None: + stmt = stmt.where(BotDraft.chat_id == chat_id) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def save_payload(draft: BotDraft, missing: dict | None = None, + review_message_id: int | None = None) -> None: + async with SessionLocal() as session: + await session.execute( + update(BotDraft) + .where(BotDraft.id == draft.id) + .values(payload=draft.payload, missing=missing, review_message_id=review_message_id or draft.review_message_id) + ) + await session.commit() + + +async def set_status(draft_id: str, status: str) -> None: + async with SessionLocal() as session: + await session.execute(update(BotDraft).where(BotDraft.id == draft_id).values(status=status)) + await session.commit() + + +async def set_bale_updated_at(draft_id) -> None: + async with SessionLocal() as session: + await session.execute( + update(BotDraft) + .where(BotDraft.id == draft_id) + .values(last_bale_update_at=func.now()) + ) + await session.commit() + + +async def get_pending_bale_updates() -> list[BotDraft]: + async with SessionLocal() as session: + stmt = ( + select(BotDraft) + .where( + BotDraft.review_message_id.is_not(None), + (BotDraft.last_bale_update_at.is_(None)) | (BotDraft.updated_at > BotDraft.last_bale_update_at), + ) + .order_by(BotDraft.updated_at.asc()) + .limit(10) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def expire_stale(ttl_hours: int) -> int: + cutoff = datetime.now(timezone.utc) - timedelta(hours=ttl_hours) + async with SessionLocal() as session: + result = await session.execute( + update(BotDraft) + .where(BotDraft.status == "active", BotDraft.updated_at < cutoff) + .values(status="expired") + ) + await session.commit() + return result.rowcount or 0 + + +# --------------------------------------------------------------------------- +# Quote payload +# --------------------------------------------------------------------------- + +def new_quote_payload() -> dict: + return { + "supplier_name": None, "supplier_id": None, + "project_name": None, "project_id": None, "project_names": [], "project_ids": [], + "is_official": True, "notes": None, "expiry_date": None, + "items": [], "termsheets": [], + "options": {}, "proforma_file": None, + } + + +def new_item(material_name: str | None = None) -> dict: + return { + "material_name": material_name, "material_id": None, + "quantity": None, "unit_name": None, "unit_id": None, "unit_explicit": False, + "unit_price": None, "brand_name": None, "specs": [], "notes": None, + } + + +def _similarity(a: str | None, b: str | None) -> float: + na, nb = normalize_text(a or ""), normalize_text(b or "") + if not na or not nb: + return 0.0 + ratio = difflib.SequenceMatcher(None, na, nb).ratio() + if na in nb or nb in na: + ratio += 0.3 + return ratio + + +def merge_quote_patch(payload: dict, patch: dict) -> bool: + """Merge an NLU patch into the payload. Returns True if anything changed.""" + changed = False + + def set_field(key: str, value) -> None: + nonlocal changed + if value is None: + return + if payload.get(key) != value: + payload[key] = value + changed = True + + if patch.get("supplier_name"): + payload["supplier_name"] = str(patch["supplier_name"]).strip() + payload["supplier_id"] = None # needs re-resolution + changed = True + if patch.get("project_names"): + payload["project_names"] = [str(p).strip() for p in patch["project_names"] if p] + if payload["project_names"] and not payload.get("project_name"): + payload["project_name"] = payload["project_names"][0] + payload["project_id"] = None + changed = True + elif patch.get("project_name"): + payload["project_name"] = str(patch["project_name"]).strip() + payload["project_id"] = None + if not payload.get("project_names"): + payload["project_names"] = [payload["project_name"]] + changed = True + set_field("is_official", patch.get("is_official")) + if patch.get("notes"): + payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip() + changed = True + if patch.get("expiry_date"): + set_field("expiry_date", patch["expiry_date"]) + + for raw in patch.get("items") or []: + name = (raw.get("material_name") or "").strip() or None + target = None + if name: + best, best_ratio = None, 0.0 + for item in payload["items"]: + # If both items have distinct specs, they are different line items + if raw.get("specs") and item.get("specs") and set(raw["specs"]) != set(item["specs"]): + continue + # If both items have distinct brands, they are different line items + if raw.get("brand_name") and item.get("brand_name") and normalize_text(raw["brand_name"]) != normalize_text(item["brand_name"]): + continue + ratio = _similarity(name, item.get("material_name")) + if ratio > best_ratio: + best, best_ratio = item, ratio + if best is not None and best_ratio >= 0.55: + target = best + if target is None: + target = new_item(name) + payload["items"].append(target) + changed = True + for key in ("quantity", "unit_price"): + if raw.get(key) is not None: + try: + value = float(raw[key]) + except (TypeError, ValueError): + continue + if target.get(key) != value: + target[key] = value + changed = True + if raw.get("specs"): + target["specs"] = [str(s).strip() for s in raw["specs"] if s] + spec_str = "، ".join(target["specs"]) + if spec_str and not target.get("notes"): + target["notes"] = spec_str + changed = True + for key in ("unit_name", "brand_name", "notes"): + if raw.get(key): + val = str(raw[key]).strip() + if target.get(key) != val: + target[key] = val + changed = True + + for raw in patch.get("payment_terms") or []: + label = (raw.get("label") or "شرایط پرداخت").strip() + milestones = [] + for m in raw.get("milestones") or []: + if m.get("due_date") or m.get("amount"): + milestones.append({ + "due_date": m.get("due_date"), + "amount": float(m.get("amount") or 0), + "note": m.get("note"), + }) + entry = { + "label": label, + "discount_type": raw.get("discount_type") or "none", + "discount_value": float(raw.get("discount_value") or 0), + "milestones": milestones, + "description": (raw.get("description") or "").strip() or None, + } + if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]): + payload["termsheets"].append(entry) + changed = True + + return changed + + +def compute_quote_missing(payload: dict) -> list[dict]: + """What is still empty — the user's explicit ask («یادآوری چیزهای خالی»).""" + out: list[dict] = [] + + def add(key: str, label: str, ok: bool, blocking: bool) -> None: + out.append({"key": key, "label": label, "ok": ok, "blocking": blocking}) + + sup_ok = bool(payload.get("supplier_id")) + add("supplier", "تأمین‌کننده", sup_ok, True) + add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False) + add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False) + + items = payload.get("items") or [] + add("items", "قلم (کالا/مقدار)", bool(items), True) + for i, item in enumerate(items, start=1): + name = item.get("material_name") or f"قلم {i}" + if not item.get("material_id"): + add(f"item:{i}:material", f"شناسه کالا برای «{name}»", False, True) + if not item.get("quantity"): + add(f"item:{i}:qty", f"مقدار «{name}»", False, True) + if not (item.get("unit_id") or item.get("unit_name")): + add(f"item:{i}:unit", f"واحد «{name}»", False, True) + if not item.get("unit_price"): + add(f"item:{i}:price", f"قیمت واحد «{name}»", False, False) + if items and not payload.get("termsheets"): + add("terms", "شرایط پرداخت", False, False) + if not payload.get("notes"): + add("notes", "یادداشت", False, False) + return out + + +def quote_ready(payload: dict) -> tuple[bool, list[str]]: + """Blocking check before commit; returns (ok, blocking_labels).""" + missing = compute_quote_missing(payload) + return all(not (m["blocking"] and not m["ok"]) for m in missing), [ + m["label"] for m in missing if m["blocking"] and not m["ok"] + ] + + +def build_quote_request(payload: dict, only_resolved_items: bool = False) -> tuple[dict | None, list[str]]: + """Convert the payload into a QuoteCreate body. Returns (body, problems).""" + problems: list[str] = [] + if not payload.get("supplier_id"): + return None, ["تأمین‌کننده مشخص نیست"] + + items = payload.get("items") or [] + line_items: list[dict] = [] + for i, item in enumerate(items, start=1): + if not item.get("material_id"): + if only_resolved_items: + continue + problems.append(f"کالای قلم {i} («{item.get('material_name') or '?'}») شناسایی نشد") + continue + if not item.get("quantity"): + problems.append(f"مقدار قلم {i} («{item.get('material_name') or '?'}») مشخص نیست") + continue + unit_id = item.get("unit_id") + if not unit_id: + problems.append(f"واحد قلم {i} («{item.get('material_name') or '?'}») مشخص نیست") + continue + notes_parts = [] + if item.get("brand_name"): + notes_parts.append(f"برند: {item['brand_name']}") + if item.get("specs"): + notes_parts.append("، ".join(item["specs"])) + if item.get("notes"): + if item["notes"] not in notes_parts: + notes_parts.append(item["notes"]) + notes_str = " | ".join(notes_parts) if notes_parts else None + + line_items.append({ + "temp_id": f"t{i}", + "material_id": item["material_id"], + "unit_id": int(unit_id), + "quantity": item["quantity"], + "notes": notes_str, + }) + + termsheets: list[dict] = [] + item_termsheets: list[dict] = [] + priced = [it for it in items if it.get("unit_price") and it.get("material_id")] + if priced: + raw_sheets = payload.get("termsheets") or [] + if not raw_sheets: + raw_sheets = [{"label": "پیشنهاد قیمت"}] + for j, ts in enumerate(raw_sheets, start=1): + steps = [] + for s_idx, m in enumerate(ts.get("milestones") or ts.get("schedule_steps") or [], start=1): + steps.append({ + "step_no": s_idx, + "due_date": m.get("due_date"), + "amount": float(m.get("amount") or 0), + "note": m.get("note"), + }) + termsheets.append({ + "temp_id": f"p{j}", + "label": ts.get("label") or f"شرایط {j}", + "discount_type": ts.get("discount_type") or "none", + "discount_value": float(ts.get("discount_value") or 0), + "schedule_steps": steps, + }) + first = termsheets[0]["temp_id"] + for i, item in enumerate(items, start=1): + if item.get("unit_price") and item.get("material_id") and item.get("quantity") and item.get("unit_id"): + item_termsheets.append({ + "line_item_temp_id": f"t{i}", + "termsheet_temp_id": first, + "unit_price": item["unit_price"], + }) + + body: dict = { + "supplier_id": payload["supplier_id"], + "is_official": bool(payload.get("is_official", True)), + "line_items": line_items, + "termsheets": termsheets, + "item_termsheets": item_termsheets, + } + if payload.get("project_ids"): + body["project_ids"] = payload["project_ids"] + elif payload.get("project_id"): + body["project_ids"] = [payload["project_id"]] + if payload.get("expiry_date"): + body["expiry_date"] = payload["expiry_date"] + if payload.get("notes"): + body["notes"] = payload["notes"] + if not line_items and problems: + return None, problems + return body, problems + + +# --------------------------------------------------------------------------- +# Supplier / material payloads +# --------------------------------------------------------------------------- + +def new_supplier_payload() -> dict: + return {"name": None, "category_id": None, "category_name": None, + "phone": None, "location": None, "contact_person": None, "options": {}} + + +def merge_supplier_patch(payload: dict, patch: dict) -> bool: + src = patch.get("supplier") or patch + changed = False + for key in ("name", "phone", "location", "contact_person"): + if src.get(key) and payload.get(key) != str(src[key]).strip(): + payload[key] = str(src[key]).strip() + changed = True + if src.get("category_name"): + payload["category_name"] = str(src["category_name"]).strip() + payload["category_id"] = None + changed = True + return changed + + +def supplier_missing(payload: dict) -> list[dict]: + return [ + {"key": "name", "label": "نام تأمین‌کننده", "ok": bool(payload.get("name")), "blocking": True}, + {"key": "category", "label": "دسته‌بندی", "ok": bool(payload.get("category_id")), "blocking": True}, + {"key": "phone", "label": "تلفن", "ok": bool(payload.get("phone")), "blocking": False}, + {"key": "location", "label": "موقعیت", "ok": bool(payload.get("location")), "blocking": False}, + ] + + +def new_material_payload() -> dict: + return {"name": None, "canonical_unit_id": None, "unit_name": None, "topic": None, "options": {}} + + +def merge_material_patch(payload: dict, patch: dict) -> bool: + src = patch.get("material") or patch + changed = False + if src.get("name") and payload.get("name") != str(src["name"]).strip(): + payload["name"] = str(src["name"]).strip() + changed = True + if src.get("unit_name"): + payload["unit_name"] = str(src["unit_name"]).strip() + payload["canonical_unit_id"] = None + changed = True + if src.get("topic") and payload.get("topic") != str(src["topic"]).strip(): + payload["topic"] = str(src["topic"]).strip() + changed = True + return changed + + +def material_missing(payload: dict) -> list[dict]: + return [ + {"key": "name", "label": "نام کالا", "ok": bool(payload.get("name")), "blocking": True}, + {"key": "unit", "label": "واحد اصلی", "ok": bool(payload.get("canonical_unit_id")), "blocking": True}, + {"key": "topic", "label": "تاپیک", "ok": bool(payload.get("topic")), "blocking": False}, + ] + + +def short_id() -> str: + return uuid.uuid4().hex[:8] diff --git a/ai procurement/app/handlers/__init__.py b/ai procurement/app/handlers/__init__.py new file mode 100644 index 0000000..1ae7526 --- /dev/null +++ b/ai procurement/app/handlers/__init__.py @@ -0,0 +1,192 @@ +"""Update dispatch — the heart of the bot. + +Pipeline per update: + callback_query → callbacks router (link-gated) + message text starting "/" → command router + voice/audio → download → STT → treated as text (source=voice) + text → pending-flow interception (linking / accept-arrive confirmations) + → auth gate (link or admin) + → NLU → intent router +""" +import logging + +from app import drafts as draft_store +from app.config import settings +from app.nlu import understand +from app.runtime import BotContext, LinkedUser +from app.stt import transcribe +from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY + +from . import commands, callbacks, quote, supplier, material, queries, decisions + +logger = logging.getLogger("bot.handlers") + + +async def handle_update(ctx: BotContext, update: dict) -> None: + if update.get("callback_query"): + await callbacks.handle_callback(ctx, update["callback_query"]) + return + message = update.get("message") or update.get("edited_message") + if not message: + return + chat_id = message["chat"]["id"] + try: + await handle_message(ctx, chat_id, message) + except Exception: + logger.exception("message handling failed for chat %s", chat_id) + await ctx.send(chat_id, "خطای غیرمنتظره رخ داد؛ دوباره تلاش کن.") + + +async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None: + text = (message.get("text") or "").strip() + source = "text" + + if text.startswith("/"): + await commands.handle_command(ctx, chat_id, text) + return + + if not text and (message.get("voice") or message.get("audio") or message.get("document")): + source = "voice" + await ctx.bale.send_chat_action(chat_id, "typing") + file_id = (message.get("voice") or message.get("audio") or message.get("document"))["file_id"] + try: + audio = await ctx.bale.download_file(file_id) + text = await transcribe(settings, audio, + mime_type=(message.get("voice") or {}).get("mime_type"), + filename=(message.get("voice") or {}).get("file_name")) + except Exception as exc: + detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200] + await ctx.send(chat_id, STT_FAILED.format(detail=esc(detail))) + return + if not text: + await ctx.send(chat_id, VOICE_EMPTY) + return + await ctx._log(chat_id, "in", "voice", text) + + # Pending multi-step interactions take the message first. + flow = ctx.pending.get(chat_id) + if flow: + handled = await _route_to_flow(ctx, chat_id, flow, text) + if handled: + return + ctx.pending.pop(chat_id) # stale/irrelevant flow → fall through + + user = await _gate(ctx, chat_id, message) + if user is None: + return # gate either started linking or is mid-linking + + draft = await draft_store.get_active(chat_id) + draft_summary = _draft_summary(draft) + result = await understand(settings, text, draft_summary) + intent, patch = result.get("intent"), result.get("patch") or {} + logger.info("chat %s intent=%s conf=%s", chat_id, intent, result.get("confidence")) + + if intent in ("register_quote", "update_draft"): + await quote.handle_entities(ctx, chat_id, user, patch, source) + elif intent == "commit_draft": + active = await draft_store.get_active(chat_id) + if active is None: + await ctx.send(chat_id, NOTHING_TO_COMMIT) + elif active.kind == draft_store.KIND_QUOTE: + await quote.commit(ctx, chat_id, user, active) + elif active.kind == draft_store.KIND_SUPPLIER: + await supplier.commit(ctx, chat_id, user, active) + elif active.kind == draft_store.KIND_MATERIAL: + await material.commit(ctx, chat_id, user, active) + elif intent == "discard_draft": + active = await draft_store.get_active(chat_id) + if active: + await draft_store.set_status(active.id, "cancelled") + ctx.pending.pop(chat_id) + await ctx.send(chat_id, CANCELLED) + elif intent == "show_draft": + active = await draft_store.get_active(chat_id) + if active is None: + await ctx.send(chat_id, NOTHING_TO_COMMIT) + elif active.kind == draft_store.KIND_QUOTE: + await quote.render(ctx, chat_id, active, fresh=False) + elif active.kind == draft_store.KIND_SUPPLIER: + await supplier.render(ctx, chat_id, active) + elif active.kind == draft_store.KIND_MATERIAL: + await material.render(ctx, chat_id, active) + elif intent == "add_supplier": + await supplier.handle_entities(ctx, chat_id, user, patch, source) + elif intent == "add_material": + await material.handle_entities(ctx, chat_id, user, patch, source) + elif intent == "status_query": + await queries.handle_query(ctx, chat_id, user, patch) + elif intent == "accept_quote": + await decisions.start_accept(ctx, chat_id, user, patch) + elif intent == "arrive_items": + await decisions.start_arrive(ctx, chat_id, user, patch) + else: + await ctx.send(chat_id, UNKNOWN_HINT) + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +async def _gate(ctx: BotContext, chat_id: int, message: dict) -> LinkedUser | None: + """Link or admin gate. Returns None when the message was consumed by linking.""" + from . import links + + link = await ctx.links.get(chat_id) + if link and link.user_id: + return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False) + if chat_id in ctx.admin_chat_ids: + return LinkedUser(chat_id, None, None, None, is_admin=True) + + flow = ctx.pending.get(chat_id) + if flow and flow.kind == "link_password": + await links.finish_link(ctx, chat_id, flow, text=None, password=message.get("text") or "") + return None + if flow and flow.kind == "link_username": + await links.finish_username_step(ctx, chat_id, flow, message.get("text") or "") + return None + await ctx.send(chat_id, LINK_START) + ctx.pending.set(chat_id, "link_username", {}) + return None + + +async def _route_to_flow(ctx: BotContext, chat_id: int, flow, text: str) -> bool: + from . import links, decisions + + if flow.kind == "link_username": + await links.finish_username_step(ctx, chat_id, flow, text) + return True + if flow.kind == "link_password": + await links.finish_link(ctx, chat_id, flow, text=text, password=text) + return True + if flow.kind == "quote_pick": + await decisions.handle_quote_pick_text(ctx, chat_id, flow, text) + return True + if flow.kind in ("accept", "arrive_qty", "arrive_confirm"): + return await decisions.handle_flow_text(ctx, chat_id, flow, text) + return False + + +def _draft_summary(draft) -> str | None: + if draft is None: + return None + import json + + payload = draft.payload or {} + if draft.kind == draft_store.KIND_QUOTE: + items = ", ".join( + f"{it.get('material_name') or '?'}" + + (f" {it.get('quantity')}" if it.get("quantity") else "") + + (f" {it.get('unit_name')}" if it.get("unit_name") else "") + for it in payload.get("items") or [] + ) + summary = ( + f"kind=quote; supplier={payload.get('supplier_name') or '?'}; project={payload.get('project_name') or '?'}; " + f"items=[{items}]; terms={[t.get('label') for t in payload.get('termsheets') or []]}" + ) + elif draft.kind == draft_store.KIND_SUPPLIER: + summary = f"kind=add_supplier; name={payload.get('name') or '?'}; category={payload.get('category_name') or '?'}" + elif draft.kind == draft_store.KIND_MATERIAL: + summary = f"kind=add_material; name={payload.get('name') or '?'}; unit={payload.get('unit_name') or '?'}" + else: + summary = json.dumps(payload, ensure_ascii=False)[:500] + return summary diff --git a/ai procurement/app/handlers/callbacks.py b/ai procurement/app/handlers/callbacks.py new file mode 100644 index 0000000..a1620d0 --- /dev/null +++ b/ai procurement/app/handlers/callbacks.py @@ -0,0 +1,107 @@ +"""Inline-button routing (callback_query). + +Data grammar (≤64 bytes): op:{arg}:{d8} / pk:{d8}:{field}:{idx} / qc:{idx} + / ac:{op}[:{i}] / ar:{op} — full state lives in bot_drafts / pending flows. +""" +import logging + +from app import drafts as draft_store +from app.runtime import LinkedUser +from app.texts import CANCELLED, UNLINKED, esc + +from . import quote, supplier, material, decisions + +logger = logging.getLogger("bot.callbacks") + + +async def handle_callback(ctx, cq: dict) -> None: + chat_id = ((cq.get("message") or {}).get("chat") or {}).get("id") + data = cq.get("data") or "" + if chat_id is None: + return + try: + await _dispatch(ctx, chat_id, data, cq) + except Exception: + logger.exception("callback failed: %s", data) + await ctx.send(chat_id, "خطا در پردازش دکمه؛ دوباره تلاش کن.") + finally: + await ctx.bale.answer_callback_query(cq.get("id"), None) + + +async def _gate(ctx, chat_id: int) -> LinkedUser | None: + link = await ctx.links.get(chat_id) + if link and link.user_id: + return LinkedUser(chat_id, link.user_id, link.display_name, link.role, is_admin=False) + if chat_id in ctx.admin_chat_ids: + return LinkedUser(chat_id, None, None, None, is_admin=True) + await ctx.send(chat_id, UNLINKED) + return None + + +async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None: + user = await _gate(ctx, chat_id) + if user is None: + return + parts = data.split(":") + + # ---- pending-flow buttons (accept / arrive / quote pick) --------------- + if parts[0] == "qc" and len(parts) == 2: + flow = ctx.pending.get(chat_id) + if flow and flow.kind == "quote_pick": + await decisions.apply_quote_pick(ctx, chat_id, user, flow, int(parts[1])) + return + if parts[0] == "ac": + await decisions.handle_accept_callback(ctx, chat_id, user, parts) + return + if parts[0] == "ar": + await decisions.handle_arrive_callback(ctx, chat_id, user, parts) + return + + # ---- pick a candidate for a draft field ------------------------------- + if parts[0] == "pk" and len(parts) == 4: + draft = await draft_store.get_by_prefix(parts[1], chat_id) + if draft is None: + await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.") + return + await _apply_pick(ctx, chat_id, user, draft, parts[2], int(parts[3])) + return + + # ---- draft commit / cancel -------------------------------------------- + op, arg, prefix = parts[0], parts[1], parts[2] if len(parts) > 2 else "" + if op in ("q", "s", "m") and arg in ("ok", "ok2", "no"): + kind = {"q": draft_store.KIND_QUOTE, "s": draft_store.KIND_SUPPLIER, "m": draft_store.KIND_MATERIAL}[op] + draft = await draft_store.get_by_prefix(prefix, chat_id) + if draft is None or draft.kind != kind: + await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.") + return + if arg == "no": + await draft_store.set_status(draft.id, "cancelled") + ctx.pending.pop(chat_id, None) + if draft.review_message_id: + try: + await ctx.bale.edit_message_text(chat_id, draft.review_message_id, "❌ این پیش‌نویس لغو شد.", reply_markup=None) + await draft_store.set_bale_updated_at(draft.id) + except Exception: + pass + await ctx.send(chat_id, CANCELLED) + return + if kind == draft_store.KIND_QUOTE: + await quote.commit(ctx, chat_id, user, draft, only_resolved=(arg == "ok2")) + elif kind == draft_store.KIND_SUPPLIER: + await supplier.commit(ctx, chat_id, user, draft) + else: + await material.commit(ctx, chat_id, user, draft) + + +async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, idx: int) -> None: + options = (draft.payload.get("options") or {}).get(field) or [] + if idx < 0 or idx >= len(options): + await ctx.send(chat_id, "گزینه نامعتبر است.") + return + choice = options[idx] + if draft.kind == draft_store.KIND_QUOTE: + await quote.apply_pick(ctx, chat_id, user, draft, field, choice) + elif draft.kind == draft_store.KIND_SUPPLIER: + await supplier.apply_pick(ctx, chat_id, user, draft, field, choice) + elif draft.kind == draft_store.KIND_MATERIAL: + await material.apply_pick(ctx, chat_id, user, draft, field, choice) diff --git a/ai procurement/app/handlers/commands.py b/ai procurement/app/handlers/commands.py new file mode 100644 index 0000000..47d3912 --- /dev/null +++ b/ai procurement/app/handlers/commands.py @@ -0,0 +1,82 @@ +"""/commands — /start /help /drafts /cancel /whoami /unlink.""" +import logging + +from app import drafts as draft_store +from app.runtime import ROLE_FA +from app.texts import CANCELLED, DRAFTS_EMPTY, DRAFTS_LIST_HEADER, HELP, UNLINKED, WELCOME, esc + +logger = logging.getLogger("bot.commands") + +KIND_FA = {"quote": "استعلام", "supplier": "تأمین‌کننده", "material": "کالا"} +STATUS_FA = {"active": "باز", "committed": "ثبت‌شده", "cancelled": "لغوشده", "expired": "منقضی"} + + +async def handle_command(ctx, chat_id: int, text: str) -> None: + command = text.split()[0].split("@")[0].lower() + if command == "/start": + await _start(ctx, chat_id) + elif command in ("/help", "/راهنما"): + await ctx.send(chat_id, HELP) + elif command == "/drafts": + await _drafts(ctx, chat_id) + elif command in ("/cancel", "/لغو"): + await _cancel(ctx, chat_id) + elif command == "/whoami": + await _whoami(ctx, chat_id) + elif command == "/unlink": + await ctx.links.delete(chat_id) + await ctx.send(chat_id, "اتصال قطع شد. برای اتصال دوباره /start بزن.") + else: + await ctx.send(chat_id, HELP) + + +async def _start(ctx, chat_id: int) -> None: + await ctx.send(chat_id, WELCOME) + link = await ctx.links.get(chat_id) + if not link or not link.user_id: + if chat_id not in ctx.admin_chat_ids: + await ctx.send(chat_id, "برای شروع، حساب خود را متصل کن — نام کاربری Casdoor را بفرست:") + ctx.pending.set(chat_id, "link_username", {}) + + +async def _drafts(ctx, chat_id: int) -> None: + from sqlalchemy import select + + from app.db import SessionLocal + from app.models import BotDraft + + async with SessionLocal() as session: + rows = (await session.execute( + select(BotDraft) + .where(BotDraft.chat_id == chat_id, BotDraft.status == "active") + .order_by(BotDraft.updated_at.desc()) + )).scalars().all() + if not rows: + await ctx.send(chat_id, DRAFTS_EMPTY) + return + lines = [DRAFTS_LIST_HEADER] + for d in rows: + p = d.payload or {} + title = p.get("supplier_name") or p.get("name") or "بدون عنوان" + lines.append(f"• {KIND_FA.get(d.kind, d.kind)}: {esc(title)}") + await ctx.send(chat_id, "\n".join(lines)) + + +async def _cancel(ctx, chat_id: int) -> None: + ctx.pending.pop(chat_id) + active = await draft_store.get_active(chat_id) + if active: + await draft_store.set_status(active.id, "cancelled") + await ctx.send(chat_id, CANCELLED) + + +async def _whoami(ctx, chat_id: int) -> None: + link = await ctx.links.get(chat_id) + if chat_id in ctx.admin_chat_ids: + await ctx.send(chat_id, "حالت ادمین (بدون اتصال Casdoor).") + return + if not link or not link.user_id: + await ctx.send(chat_id, UNLINKED) + return + role = ROLE_FA.get(link.role or "", link.role or "بدون نقش") + await ctx.send(chat_id, f"اتصال: {esc(link.display_name)} — {role}") diff --git a/ai procurement/app/handlers/decisions.py b/ai procurement/app/handlers/decisions.py new file mode 100644 index 0000000..d8c1d6d --- /dev/null +++ b/ai procurement/app/handlers/decisions.py @@ -0,0 +1,389 @@ +"""Accept / arrive flows — always with an explicit confirmation step.""" +import logging +import re + +from app.api.client import ApiError +from app.keyboards import accept_keyboard, arrive_keyboard, quote_pick_keyboard +from app.nlu import normalize_digits +from app.texts import ( + ACCEPT_DONE_TS, ACCEPT_REPORT, ARRIVE_REPORT, CANCELLED, ERR_GENERIC, + QUOTE_NOT_FOUND, STATUS_FA, esc, +) + +logger = logging.getLogger("bot.decisions") + +EXPAND = "supplier,line_items,termsheets" +CONFIRM_WORDS = {"تایید", "تأیید", "ثبت", "ثبت کن", "اوکی", "اوکیه", "بله", "yes", "ok"} +CANCEL_WORDS = {"لغو", "بی خیال", "بیخیال", "انصراف", "نه", "cancel"} + + +def _norm(text: str) -> str: + return (text or "").strip().lower().replace("\u200c", " ") + + +# --------------------------------------------------------------------------- +# shared: pick a quote by supplier text +# --------------------------------------------------------------------------- + +async def start_accept(ctx, chat_id, user, patch: dict) -> None: + await _start_for_status(ctx, chat_id, user, patch, mode="accept", status="offered") + + +async def start_arrive(ctx, chat_id, user, patch: dict) -> None: + await _start_for_status(ctx, chat_id, user, patch, mode="arrive", status="accepted") + + +async def _start_for_status(ctx, chat_id, user, patch: dict, mode: str, status: str) -> None: + target = (patch.get("target") or {}) + q = target.get("supplier_name") or target.get("project_name") or target.get("quote_id") + try: + quotes, _ = await ctx.api.list_quotes(status=status, q=q, expand=EXPAND, page_size=10) + except ApiError as exc: + await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message))) + return + if not quotes: + await ctx.send(chat_id, QUOTE_NOT_FOUND.format(q=esc(q or "?"))) + return + if len(quotes) == 1: + if mode == "accept": + await begin_accept(ctx, chat_id, user, quotes[0]) + else: + await begin_arrive(ctx, chat_id, user, quotes[0]) + return + pairs = [(_quote_label(qq), str(qq["id"])) for qq in quotes] + flow = ctx.pending.set(chat_id, "quote_pick", {"mode": mode, "ids": [pid for _, pid in pairs]}) + flow.data["user_id"] = user.actor_id + message = await ctx.send(chat_id, "چند مورد پیدا شد؛ کدام را می‌خواهی؟", reply_markup=quote_pick_keyboard(pairs)) + flow.data["message_id"] = (message or {}).get("message_id") + + +def _quote_label(quote: dict) -> str: + sup = (quote.get("supplier") or {}).get("name") or "—" + return f"#{str(quote.get('id'))[:8]} {sup}" + + +async def apply_quote_pick(ctx, chat_id, user, flow, idx: int) -> None: + ids = flow.data.get("ids") or [] + ctx.pending.pop(chat_id) + if idx < 0 or idx >= len(ids): + await ctx.send(chat_id, "گزینه نامعتبر است.") + return + try: + quote = await ctx.api.get_quote(ids[idx], expand=EXPAND, actor_id=user.actor_id) + except ApiError as exc: + await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message))) + return + if flow.data.get("mode") == "arrive": + await begin_arrive(ctx, chat_id, user, quote) + else: + await begin_accept(ctx, chat_id, user, quote) + + +async def handle_quote_pick_text(ctx, chat_id, flow, text: str) -> None: + if _norm(text) in CANCEL_WORDS: + ctx.pending.pop(chat_id) + await ctx.send(chat_id, CANCELLED) + return + numbers = re.findall(r"\d+", normalize_digits(text)) + if numbers: + await apply_quote_pick(ctx, chat_id, _flow_user(flow), flow, int(numbers[0]) - 1) + else: + await ctx.send(chat_id, "شماره مورد را بفرست یا از دکمه‌ها استفاده کن.") + + +def _flow_user(flow): + from app.runtime import LinkedUser + return LinkedUser(chat_id=None, user_id=flow.data.get("user_id"), display_name=None, + role=None, is_admin=False) + + +# --------------------------------------------------------------------------- +# accept +# --------------------------------------------------------------------------- + +def _accept_summary(quote: dict, chosen: int) -> str: + lines = [ + f"✔️ پذیرش استعلام #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}", + "", + "اقلام:", + ] + for li in quote.get("line_items") or []: + mat = (li.get("material") or {}).get("name") or li.get("material_id") or "?" + unit = ((li.get("unit") or {}).get("name_fa")) or "" + price = li.get("unit_price") + parts = [f"• {esc(mat)} — مقدار {esc(li.get('quantity'))} {unit}"] + if price: + parts.append(f"قیمت {esc(price)}") + lines.append(" — ".join(parts)) + lines.append("") + lines.append("شرایط پرداخت:") + for i, ts in enumerate(quote.get("termsheets") or []): + mark = "👉" if i == chosen else " " + discount = f" (تخفیف {esc(ts.get('discount_value'))})" if ts.get("discount_value") else "" + lines.append(f"{mark} [{i + 1}] {esc(ts.get('label'))}{discount} — {len(ts.get('schedule_steps') or [])} مرحله") + lines.append("") + lines.append("شرایط را انتخاب کن و پذیرش را تأیید کن.") + return "\n".join(lines) + + +async def begin_accept(ctx, chat_id, user, quote: dict) -> None: + termsheets = quote.get("termsheets") or [] + if not termsheets: + await ctx.send(chat_id, "این استعلام شرایط پرداخت ندارد؛ اول در برنامه ثبت کن.") + return + if not quote.get("line_items"): + await ctx.send(chat_id, "این استعلام قلم ندارد.") + return + flow = ctx.pending.set(chat_id, "accept", { + "quote_id": str(quote["id"]), + "quote_snapshot": { + "id": quote.get("id"), + "supplier": quote.get("supplier"), + "line_items": quote.get("line_items") or [], + }, + "termsheets": termsheets, + "chosen": 0, + "user_id": user.actor_id, + }) + text = _accept_summary(quote, 0) + message = await ctx.send(chat_id, text, reply_markup=accept_keyboard([ts.get("label") or "?" for ts in termsheets])) + flow.data["message_id"] = (message or {}).get("message_id") + + +async def handle_accept_callback(ctx, chat_id, user, parts) -> None: + flow = ctx.pending.get(chat_id) + if not flow or flow.kind != "accept": + await ctx.send(chat_id, "این عملیات منقضی شده؛ دوباره درخواست بده.") + return + op = parts[1] if len(parts) > 1 else "" + if op == "no": + ctx.pending.pop(chat_id) + await ctx.send(chat_id, CANCELLED) + return + if op == "ts" and len(parts) == 3: + idx = int(parts[2]) + if 0 <= idx < len(flow.data["termsheets"]): + flow.data["chosen"] = idx + await ctx.edit(chat_id, flow.data.get("message_id"), + _accept_summary(flow.data["quote_snapshot"], idx), + reply_markup=accept_keyboard([ts.get("label") or "?" for ts in flow.data["termsheets"]])) + return + if op == "ok": + await _commit_accept(ctx, chat_id, user, flow) + + +async def _commit_accept(ctx, chat_id, user, flow) -> None: + data = flow.data + quote_id = data["quote_id"] + termsheets = data["termsheets"] + ts_index = data.get("chosen", 0) + if not 0 <= ts_index < len(termsheets): + await ctx.send(chat_id, "شرایط پرداخت انتخاب نشده است.") + return + ts = termsheets[ts_index] + steps = ts.get("schedule_steps") or [] + if not steps: + await ctx.send(chat_id, "ترم‌شیت انتخابی مرحله پرداخت ندارد؛ در برنامه تکمیلش کن.") + return + + try: + quote = await ctx.api.get_quote(quote_id, expand=EXPAND, actor_id=user.actor_id) + except ApiError as exc: + await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message))) + return + + decisions_payload = [] + problems = [] + for li in quote.get("line_items") or []: + decision = li.get("decision") or {} + if decision.get("status") == "rejected": + continue + price = li.get("unit_price") + if not price: + problems.append(f"قیمت قلم «{(li.get('material') or {}).get('name') or '?'}» ثبت نشده") + continue + li_projects = li.get("projects") or [] + project_id = li_projects[0]["id"] if len(li_projects) == 1 else quote.get("project_id") + decisions_payload.append({ + "line_item_id": li["id"], + "status": "chosen", + "accepted_termsheet_index": 0, + "adjusted_unit_price": price, + "project_id": project_id, + }) + if problems: + await ctx.send(chat_id, "پذیرش ممکن نیست:\n" + "\n".join(f"❌ {esc(p)}" for p in problems)) + return + if not decisions_payload: + await ctx.send(chat_id, "قلم قابل پذیرشی نیست (همه رد شده‌اند؟).") + return + + payload = { + "accepted_termsheets": [{ + "id": ts["id"], + "label": ts.get("label"), + "discount_type": ts.get("discount_type") or "none", + "discount_value": ts.get("discount_value") or 0, + "schedule_steps": steps, + }], + "item_decisions": decisions_payload, + } + try: + result = await ctx.api.accept_quote(quote_id, payload, actor_id=user.actor_id) + except ApiError as exc: + await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message))) + return + ctx.pending.pop(chat_id) + chosen_label = ts.get("label") or "" + await ctx.send(chat_id, ACCEPT_REPORT.format( + id=esc(str(result.get("id"))[:8]), + status=STATUS_FA.get(result.get("status"), result.get("status")), + count=len(decisions_payload), + ) + "\n" + ACCEPT_DONE_TS.format(label=esc(chosen_label))) + + +async def handle_flow_text(ctx, chat_id, flow, text: str) -> bool: + if flow.kind == "accept": + norm = _norm(text) + if norm in CANCEL_WORDS: + ctx.pending.pop(chat_id) + await ctx.send(chat_id, CANCELLED) + return True + if any(word in norm for word in ("تایید", "تأیید", "ثبت", "اوکی", "ok", "yes")): + await _commit_accept(ctx, chat_id, _flow_user(flow), flow) + return True + await ctx.send(chat_id, "برای پذیرش از دکمه‌ها استفاده کن (یا /cancel).") + return True + if flow.kind == "arrive_qty": + await _handle_arrive_quantities(ctx, chat_id, flow, text) + return True + if flow.kind == "arrive_confirm": + norm = _norm(text) + if any(word in norm for word in ("تایید", "تأیید", "ثبت", "اوکی", "ok", "yes")): + await _commit_arrive(ctx, chat_id, _flow_user(flow), flow) + return True + if norm in CANCEL_WORDS: + ctx.pending.pop(chat_id) + await ctx.send(chat_id, CANCELLED) + return True + await ctx.send(chat_id, "با دکمه‌ها تأیید یا لغو کن (یا /cancel).") + return True + return False + + +# --------------------------------------------------------------------------- +# arrive (partial deliveries) +# --------------------------------------------------------------------------- + +def _arrive_summary(items: list[dict], quantities: dict[str, float] | None = None) -> str: + lines = ["🚚 اعلام رسیدن اقلام", ""] + for li in items: + mat = (li.get("material") or {}).get("name") or "?" + ordered = li.get("quantity") or 0 + received = ((li.get("receipt") or {}).get("received_qty")) or 0 + unit = ((li.get("unit") or {}).get("name_fa")) or "" + got = "" + if quantities and li["id"] in quantities: + got = f" → الان: {esc(quantities[li['id']])} {unit}" + lines.append(f"• {esc(mat)} — سفارش {esc(ordered)} {unit} — تاکنون {esc(received)}{got}") + return "\n".join(lines) + + +async def begin_arrive(ctx, chat_id, user, quote: dict) -> None: + items = [li for li in quote.get("line_items") or [] if (li.get("decision") or {}).get("status") == "chosen"] + if not items: + await ctx.send(chat_id, "قلم پذیرفته‌شده‌ای برای تحویل نیست.") + return + flow = ctx.pending.set(chat_id, "arrive_qty", { + "quote_id": str(quote["id"]), + "items": [{"id": li["id"], "name": (li.get("material") or {}).get("name") or "?", + "ordered": li.get("quantity"), "unit": ((li.get("unit") or {}).get("name_fa")) or "", + "received": ((li.get("receipt") or {}).get("received_qty")) or 0} for li in items], + "user_id": user.actor_id, + }) + text = ( + f"استعلام #{esc(str(quote.get('id'))[:8])} — {esc((quote.get('supplier') or {}).get('name') or '—')}\n" + + _arrive_summary(quote.get("line_items") or []) + + "\n\nمقادیر رسیده را به ترتیب اقلام بفرست (مثلاً: «500 و 200»)، یا «تحویل کامل همه» را بزن." + ) + message = await ctx.send(chat_id, text, reply_markup=arrive_keyboard()) + flow.data["message_id"] = (message or {}).get("message_id") + + +async def _handle_arrive_quantities(ctx, chat_id, flow, text: str) -> None: + numbers = [float(n) for n in re.findall(r"\d+(?:\.\d+)?", normalize_digits(text))] + items = flow.data["items"] + if not numbers: + await ctx.send(chat_id, "مقادیر را عددی بفرست، مثل «500 و 200»، یا از دکمه‌ها استفاده کن.") + return + if len(numbers) == 1 and len(items) > 1: + await ctx.send(chat_id, f"{len(items)} قلم است؛ برای هر قلم یک عدد بفرست (به ترتیب).") + return + if len(numbers) != len(items): + await ctx.send(chat_id, "تعداد اعداد با تعداد اقلام نمی‌خواند؛ دوباره بفرست.") + return + quantities = {item["id"]: qty for item, qty in zip(items, numbers)} + flow.kind = "arrive_confirm" + flow.data["quantities"] = quantities + await ctx.edit(chat_id, flow.data.get("message_id"), + _arrive_summary(_items_with_details(flow.data["items"]), quantities) + + "\n\nتأیید می‌کنی؟", + reply_markup=arrive_keyboard()) + + +def _items_with_details(items: list[dict]) -> list[dict]: + return [{ + "id": it["id"], + "material": {"name": it["name"]}, + "quantity": it["ordered"], + "unit": {"name_fa": it["unit"]}, + "receipt": {"received_qty": it["received"]}, + } for it in items] + + +async def handle_arrive_callback(ctx, chat_id, user, parts) -> None: + flow = ctx.pending.get(chat_id) + if not flow or not flow.kind.startswith("arrive"): + await ctx.send(chat_id, "این عملیات منقضی شده؛ دوباره درخواست بده.") + return + op = parts[1] if len(parts) > 1 else "" + if op == "no": + ctx.pending.pop(chat_id) + await ctx.send(chat_id, CANCELLED) + return + if op == "full": + quantities = {item["id"]: max(float(item["ordered"] or 0) - float(item["received"] or 0), 0) for item in flow.data["items"]} + if any(v <= 0 for v in quantities.values()): + await ctx.send(chat_id, "برای بعضی اقلام چیزی برای تحویل نمانده؛ مقادیر را دستی بفرست.") + return + flow.kind = "arrive_confirm" + flow.data["quantities"] = quantities + await ctx.edit(chat_id, flow.data.get("message_id"), + _arrive_summary(_items_with_details(flow.data["items"]), quantities) + + "\n\nتأیید می‌کنی؟", + reply_markup=arrive_keyboard()) + return + if op == "ok": + if flow.kind != "arrive_confirm" or not flow.data.get("quantities"): + await ctx.send(chat_id, "اول مقادیر رسیده را بفرست یا «تحویل کامل همه» را بزن.") + return + await _commit_arrive(ctx, chat_id, user, flow) + + +async def _commit_arrive(ctx, chat_id, user, flow) -> None: + quantities: dict = flow.data["quantities"] + payload = {"items": [{"line_item_id": li_id, "received_qty": qty} for li_id, qty in quantities.items()]} + try: + result = await ctx.api.arrive_items(flow.data["quote_id"], payload, actor_id=user.actor_id) + except ApiError as exc: + await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message))) + return + ctx.pending.pop(chat_id) + lines = [] + for item in result.get("items") or []: + lines.append(f"• قلم {esc(str(item.get('line_item_id'))[:8])} — {esc(item.get('received_qty'))} ثبت شد") + await ctx.send(chat_id, ARRIVE_REPORT.format( + id=esc(str(result.get("id"))[:8]), + status=STATUS_FA.get(result.get("status"), result.get("status")), + lines="\n".join(lines) or "—", + )) diff --git a/ai procurement/app/handlers/links.py b/ai procurement/app/handlers/links.py new file mode 100644 index 0000000..603bb93 --- /dev/null +++ b/ai procurement/app/handlers/links.py @@ -0,0 +1,46 @@ +"""Casdoor linking: chat ↔ ERP user (validated through the real login endpoint).""" +import logging + +from app.api.client import ApiError +from app.runtime import ROLE_FA +from app.texts import LINK_ASK_PASSWORD, LINK_FAIL, LINK_OK, esc + +logger = logging.getLogger("bot.links") + + +async def finish_username_step(ctx, chat_id: int, flow, text: str) -> None: + username = (text or "").strip() + if not username or username.startswith("/"): + ctx.pending.pop(chat_id) + return + flow.kind = "link_password" + flow.data["username"] = username + await ctx.send(chat_id, LINK_ASK_PASSWORD) + + +async def finish_link(ctx, chat_id: int, flow, text: str | None, password: str) -> None: + username = flow.data.get("username") or "" + ctx.pending.pop(chat_id) + if not password or password.startswith("/"): + return + try: + data = await ctx.api.login(username, password) + except ApiError as exc: + await ctx.send(chat_id, LINK_FAIL.format(detail=esc(exc.message))) + return + except Exception as exc: # network etc. + logger.exception("login failed") + await ctx.send(chat_id, LINK_FAIL.format(detail=esc(str(exc)[:200]))) + return + + user = data.get("user") or {} + display = " ".join(x for x in [user.get("first_name"), user.get("last_name")] if x) or username + await ctx.links.upsert( + chat_id=chat_id, + user_id=user.get("id"), + username=username, + display_name=display, + role=user.get("role"), + ) + role_fa = ROLE_FA.get(user.get("role") or "", user.get("role") or "بدون نقش") + await ctx.send(chat_id, LINK_OK.format(name=esc(display), role=role_fa)) diff --git a/ai procurement/app/handlers/material.py b/ai procurement/app/handlers/material.py new file mode 100644 index 0000000..15adb20 --- /dev/null +++ b/ai procurement/app/handlers/material.py @@ -0,0 +1,98 @@ +"""Add-material flow: name + canonical unit (required).""" +import logging + +from app import drafts as draft_store +from app.api.client import ApiError +from app.drafts import ( + KIND_MATERIAL, merge_material_patch, material_missing, new_material_payload, +) +from app.keyboards import pick_buttons, simple_review_keyboard +from app.resolve import safe_resolve +from app.texts import ERR_GENERIC, esc, material_committed_report, material_review_text + +logger = logging.getLogger("bot.material") + + +async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None: + draft = await draft_store.get_active(chat_id, KIND_MATERIAL) + if draft is not None: + payload = draft.payload + merge_material_patch(payload, patch) + else: + payload = new_material_payload() + merge_material_patch(payload, patch) + draft = await draft_store.create(chat_id, KIND_MATERIAL, payload, source=source) + await resolve_payload(ctx, payload) + await draft_store.save_payload(draft, missing={"fields": material_missing(payload)}) + await render(ctx, chat_id, draft, fresh=draft.review_message_id is None) + + +async def resolve_payload(ctx, payload: dict) -> None: + options = payload.setdefault("options", {}) + if not payload.get("canonical_unit_id") and payload.get("unit_name"): + res = await safe_resolve(ctx.resolver, "unit", payload["unit_name"]) + if res.resolved: + payload["canonical_unit_id"] = res.candidates[0].id + payload["unit_name"] = res.candidates[0].name + options.pop("unit", None) + elif res.candidates: + options["unit"] = [{"id": c.id, "name": c.name} for c in res.candidates] + else: + options.pop("unit", None) + + +async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None: + if field == "unit": + draft.payload["canonical_unit_id"] = choice["id"] + draft.payload["unit_name"] = choice["name"] + draft.payload.setdefault("options", {}).pop("unit", None) + await draft_store.save_payload(draft, missing={"fields": material_missing(draft.payload)}) + await render(ctx, chat_id, draft, fresh=False) + + +def _keyboard(payload: dict, draft_id: str) -> dict: + rows: list[list[dict]] = [] + options = (payload.get("options") or {}).get("unit") or [] + if options: + rows += pick_buttons(draft_id, "unit", [o["name"] for o in options[:6]]) + return simple_review_keyboard(draft_id, KIND_MATERIAL, rows) + + +async def render(ctx, chat_id, draft, fresh: bool = True) -> None: + payload = draft.payload + missing = material_missing(payload) + text = material_review_text(payload, missing) + keyboard = _keyboard(payload, draft.id) + if fresh: + message = await ctx.send(chat_id, text, reply_markup=keyboard) + draft.review_message_id = (message or {}).get("message_id") + await draft_store.save_payload(draft, missing={"fields": missing}, + review_message_id=draft.review_message_id) + else: + await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard) + await draft_store.save_payload(draft, missing={"fields": missing}) + + +async def commit(ctx, chat_id, user, draft) -> None: + payload = draft.payload + await resolve_payload(ctx, payload) + missing = {m["key"]: m["ok"] for m in material_missing(payload)} + problems = [label for key, label, ok in [ + ("name", "نام کالا", missing.get("name")), + ("unit", "واحد اصلی", missing.get("unit")), + ] if not ok] + if problems: + await ctx.send(chat_id, "ثبت ممکن نیست؛ اول این‌ها را مشخص کن:\n" + + "\n".join(f"❌ {esc(p)}" for p in problems)) + return + body = {"name": payload["name"], "canonical_unit_id": int(payload["canonical_unit_id"])} + if payload.get("topic"): + body["topic"] = payload["topic"] + try: + created = await ctx.api.create_material(body, actor_id=user.actor_id) + except ApiError as exc: + await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message))) + return + await draft_store.set_status(draft.id, "committed") + ctx.resolver.invalidate("materials") + await ctx.send(chat_id, material_committed_report(created)) diff --git a/ai procurement/app/handlers/queries.py b/ai procurement/app/handlers/queries.py new file mode 100644 index 0000000..f5cf113 --- /dev/null +++ b/ai procurement/app/handlers/queries.py @@ -0,0 +1,44 @@ +"""Status queries: «استعلام‌های در انتظار؟»، «کدام‌ها منقضی می‌شوند؟» …""" +import logging +from datetime import date, timedelta + +from app.api.client import ApiError +from app.texts import ERR_GENERIC, status_report, esc + +logger = logging.getLogger("bot.queries") + +EXPAND = "supplier,line_items" + + +async def handle_query(ctx, chat_id, user, patch: dict) -> None: + query = patch.get("query") or {} + kind = query.get("kind") or "pending" + supplier_name = query.get("supplier_name") + q = supplier_name or query.get("project_name") + + try: + if kind == "expiring": + quotes, _ = await ctx.api.list_quotes(status="offered", expand=EXPAND, + q=q, sort="-quote_date", page_size=100) + deadline = date.today() + timedelta(days=query.get("days") or 7) + soon = [] + for quote in quotes: + expiry = (quote.get("expiry_date") or "")[:10] + if expiry and expiry <= deadline.isoformat(): + soon.append(quote) + soon.sort(key=lambda x: x.get("expiry_date") or "9999") + await ctx.send(chat_id, status_report("expiring", soon[:10])) + return + + if kind in ("accepted", "arrived", "pending", "offered"): + status = "offered" if kind == "pending" else kind + quotes, _ = await ctx.api.list_quotes(status=status, expand=EXPAND, q=q, + sort="-quote_date", page_size=10) + await ctx.send(chat_id, status_report("pending" if kind == "offered" else kind, quotes)) + return + + # recent / anything else + quotes, meta = await ctx.api.list_quotes(expand=EXPAND, q=q, sort="-quote_date", page_size=5) + await ctx.send(chat_id, status_report("recent", quotes)) + except ApiError as exc: + await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message))) diff --git a/ai procurement/app/handlers/quote.py b/ai procurement/app/handlers/quote.py new file mode 100644 index 0000000..c82feb6 --- /dev/null +++ b/ai procurement/app/handlers/quote.py @@ -0,0 +1,280 @@ +"""Register-quote flow: entities → draft → resolve → review (+missing) → commit.""" +import logging + +from app import drafts as draft_store +from app.api.client import ApiError +from app.drafts import ( + KIND_QUOTE, build_quote_request, compute_quote_missing, merge_quote_patch, + new_quote_payload, quote_ready, +) +from app.keyboards import quote_review_keyboard +from app.resolve import safe_resolve +from app.texts import ERR_GENERIC, quote_committed_report, quote_review_text, esc + +logger = logging.getLogger("bot.quote") + +FIELD_FA = {"sup": "تأمین‌کننده", "prj": "پروژه"} + + +async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None: + draft = await draft_store.get_active(chat_id, KIND_QUOTE) + if draft is not None: + payload = draft.payload + merge_quote_patch(payload, patch) + else: + payload = new_quote_payload() + merge_quote_patch(payload, patch) + draft = await draft_store.create(chat_id, KIND_QUOTE, payload, source=source) + await resolve_payload(ctx, payload) + await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)}) + await render(ctx, chat_id, draft, fresh=draft.review_message_id is None) + + +# --------------------------------------------------------------------------- +# resolution +# --------------------------------------------------------------------------- + +async def resolve_payload(ctx, payload: dict) -> None: + options = payload.setdefault("options", {}) + + if not payload.get("supplier_id") and payload.get("supplier_name"): + res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"]) + if res.resolved: + payload["supplier_id"] = res.candidates[0].id + payload["supplier_name"] = res.candidates[0].name + options.pop("sup", None) + elif res.candidates: + options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates] + else: + options.pop("sup", None) + + if not payload.get("project_id") and payload.get("project_name"): + res = await safe_resolve(ctx.resolver, "project", payload["project_name"]) + if res.resolved: + payload["project_id"] = res.candidates[0].id + payload["project_name"] = res.candidates[0].name + if payload["project_id"] not in payload.get("project_ids", []): + payload.setdefault("project_ids", []).append(payload["project_id"]) + options.pop("prj", None) + elif res.candidates: + options["prj"] = [{"id": c.id, "name": c.name} for c in res.candidates] + else: + options.pop("prj", None) + + for p_name in payload.get("project_names") or []: + res = await safe_resolve(ctx.resolver, "project", p_name) + if res.resolved: + p_id = res.candidates[0].id + if p_id not in payload.get("project_ids", []): + payload.setdefault("project_ids", []).append(p_id) + if not payload.get("project_id"): + payload["project_id"] = p_id + + for i, item in enumerate(payload.get("items") or [], start=1): + if not item.get("material_id") and item.get("material_name"): + res = await safe_resolve(ctx.resolver, "material", item["material_name"]) + if res.resolved: + _set_material(item, res.candidates[0].id, res.candidates[0].name, res.candidates[0].extra) + options.pop(f"mat{i}", None) + elif res.candidates: + options[f"mat{i}"] = [{"id": c.id, "name": c.name} for c in res.candidates] + else: + options.pop(f"mat{i}", None) + if not item.get("unit_id"): + await _resolve_unit(ctx, item) + + +def _set_material(item: dict, material_id: str, name: str, extra: dict | None = None) -> None: + item["material_id"] = material_id + item["material_row"] = {"id": material_id, "canonical_unit_id": (extra or {}).get("canonical_unit_id")} + + +async def _resolve_unit(ctx, item: dict) -> None: + if item.get("unit_id"): + return + if item.get("unit_name"): + res = await safe_resolve(ctx.resolver, "unit", item["unit_name"]) + if res.resolved: + item["unit_id"] = res.candidates[0].id + item["unit_explicit"] = True + item["unit_name"] = res.candidates[0].name + return + canonical = ((item.get("material_row") or {}).get("canonical_unit_id")) + if canonical: + item["unit_id"] = str(canonical) + item["unit_explicit"] = False + + +async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None: + payload = draft.payload + options = payload.setdefault("options", {}) + options.pop(field, None) + + if field == "sup": + payload["supplier_id"] = choice["id"] + payload["supplier_name"] = choice["name"] + elif field == "prj": + payload["project_id"] = choice["id"] + payload["project_name"] = choice["name"] + elif field.startswith("mat"): + index = int(field[3:]) - 1 + items = payload.get("items") or [] + if 0 <= index < len(items): + items[index]["material_name"] = choice["name"] + _set_material(items[index], choice["id"], choice["name"]) + await _resolve_unit(ctx, items[index]) + elif field.startswith("unit"): + index = int(field[4:]) - 1 + items = payload.get("items") or [] + if 0 <= index < len(items): + items[index]["unit_id"] = choice["id"] + items[index]["unit_explicit"] = True + items[index]["unit_name"] = choice["name"] + + await resolve_payload(ctx, payload) + await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)}) + await render(ctx, chat_id, draft, fresh=False) + + +# --------------------------------------------------------------------------- +# rendering +# --------------------------------------------------------------------------- + +def _pick_rows(payload: dict) -> list[list[str]]: + """Field labels shown as pick-button rows (via keyboards.pick_buttons).""" + rows: list[list[str]] = [] + options = payload.get("options") or {} + if options.get("sup"): + rows.append(["sup"] + [o["name"] for o in options["sup"]]) + if options.get("prj"): + rows.append(["prj"] + [o["name"] for o in options["prj"]]) + for i, item in enumerate(payload.get("items") or [], start=1): + if options.get(f"mat{i}"): + rows.append([f"mat{i}"] + [o["name"] for o in options[f"mat{i}"]]) + if options.get(f"unit{i}"): + rows.append([f"unit{i}"] + [o["name"] for o in options[f"unit{i}"]]) + return rows + + +def _keyboard(payload: dict, draft_id: str, wizard_url: str | None = None) -> dict: + from app.keyboards import pick_buttons + + prefix = str(draft_id)[:8] + pick_rows: list[list[dict]] = [] + for row in _pick_rows(payload): + field, names = row[0], row[1:] + pick_rows += pick_buttons(prefix, field, names[:6]) + ready_items = sum( + 1 for it in payload.get("items") or [] + if it.get("material_id") and it.get("quantity") and it.get("unit_id") + ) + has_partial = 0 < ready_items < len(payload.get("items") or []) + return quote_review_keyboard(prefix, pick_rows, has_partial_option=has_partial, wizard_url=wizard_url) + + +async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = None) -> None: + from app.config import settings + + payload = draft.payload + missing = compute_quote_missing(payload) + wizard_url = f"{settings.FRONTEND_URL}/wizard?draft={draft.id}" + text = quote_review_text(payload, missing, status_note=note) + text += f"\n\n🖥 مشاهده و تکمیل در ویزارد:\n{wizard_url}" + keyboard = _keyboard(payload, str(draft.id), wizard_url=wizard_url) + if fresh: + message = await ctx.send(chat_id, text, reply_markup=keyboard) + draft.review_message_id = (message or {}).get("message_id") + await draft_store.save_payload(draft, missing={"fields": missing}, + review_message_id=draft.review_message_id) + await draft_store.set_bale_updated_at(draft.id) + else: + await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard) + await draft_store.save_payload(draft, missing={"fields": missing}) + await draft_store.set_bale_updated_at(draft.id) + + +# --------------------------------------------------------------------------- +# commit +# --------------------------------------------------------------------------- + +async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None: + payload = draft.payload + await resolve_payload(ctx, payload) + + # 1. Sequential pre-creation for new supplier if user wants to commit + if not payload.get("supplier_id") and payload.get("supplier_name"): + cat_id = payload.get("supplier_category_id") + if not cat_id: + cats = await ctx.resolver.categories() + if cats: + cat_id = cats[0].id + if cat_id: + try: + sup_body = { + "name": payload["supplier_name"], + "category_id": cat_id, + } + if payload.get("supplier_phone"): + sup_body["phone"] = payload["supplier_phone"] + created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id) + payload["supplier_id"] = created_sup["id"] + ctx.resolver.invalidate("suppliers") + except Exception as e: + logger.warning("Could not auto-create supplier: %s", e) + + # 2. Sequential pre-creation for new materials + for i, item in enumerate(payload.get("items") or [], start=1): + if not item.get("material_id") and item.get("material_name") and item.get("unit_id"): + try: + created_mat = await ctx.api.create_material({ + "name": item["material_name"], + "canonical_unit_id": int(item["unit_id"]), + }, actor_id=user.actor_id) + _set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"]) + ctx.resolver.invalidate("materials") + except Exception as e: + logger.warning("Could not auto-create material: %s", e) + + ready, blockers = quote_ready(payload) + body, problems = build_quote_request(payload, only_resolved_items=only_resolved) + if body is None: + lines = ["ثبت ممکن نیست؛ این‌ها را اول مشخص کن:"] + [f"❌ {esc(p)}" for p in problems] + await ctx.send(chat_id, "\n".join(lines)) + return + if not ready and not only_resolved: + lines = ["برای ثبت کامل، اول این‌ها را مشخص کن:"] + [f"❌ {esc(b)}" for b in blockers] + lines += ["", "یا دکمه «ثبت فقط اقلام مشخص» را بزن."] + await ctx.send(chat_id, "\n".join(lines)) + return + + try: + created = await ctx.api.create_quote(body, actor_id=user.actor_id) + except ApiError as exc: + await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message))) + return + except Exception: + logger.exception("create_quote failed") + await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای شبکه")) + return + + await draft_store.set_status(draft.id, "committed") + ctx.pending.pop(chat_id, None) + ctx.resolver.invalidate() + + if draft.review_message_id: + try: + quote_id = (created or {}).get("id") or "" + msg = f"✅ این پیشنهاد در سیستم با موفقیت ثبت نهایی شد.\nشناسه پیشنهاد: {quote_id}" + await ctx.edit(chat_id, draft.review_message_id, msg, reply_markup=None) + await draft_store.set_bale_updated_at(draft.id) + except Exception: + pass + + remaining = [ + m["label"] for m in compute_quote_missing(payload) + if not m["blocking"] and not m["ok"] + ] + dropped = len(payload.get("items") or []) - len(body.get("line_items") or []) + if dropped > 0: + remaining.append(f"{dropped} قلم ناشناس حذف شد") + await ctx.send(chat_id, quote_committed_report(created, payload, remaining)) diff --git a/ai procurement/app/handlers/supplier.py b/ai procurement/app/handlers/supplier.py new file mode 100644 index 0000000..0c1fb1a --- /dev/null +++ b/ai procurement/app/handlers/supplier.py @@ -0,0 +1,103 @@ +"""Add-supplier flow: name + one of the 8 fixed Persian categories (required).""" +import logging + +from app import drafts as draft_store +from app.api.client import ApiError +from app.drafts import ( + KIND_SUPPLIER, merge_supplier_patch, new_supplier_payload, supplier_missing, +) +from app.keyboards import pick_buttons, simple_review_keyboard +from app.resolve import safe_resolve +from app.texts import ERR_GENERIC, esc, supplier_committed_report, supplier_review_text + +logger = logging.getLogger("bot.supplier") + + +async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None: + draft = await draft_store.get_active(chat_id, KIND_SUPPLIER) + if draft is not None: + payload = draft.payload + merge_supplier_patch(payload, patch) + else: + payload = new_supplier_payload() + merge_supplier_patch(payload, patch) + draft = await draft_store.create(chat_id, KIND_SUPPLIER, payload, source=source) + await resolve_payload(ctx, payload) + await draft_store.save_payload(draft, missing={"fields": supplier_missing(payload)}) + await render(ctx, chat_id, draft, fresh=draft.review_message_id is None) + + +async def resolve_payload(ctx, payload: dict) -> None: + options = payload.setdefault("options", {}) + if not payload.get("category_id") and payload.get("category_name"): + res = await safe_resolve(ctx.resolver, "category", payload["category_name"]) + if res.resolved: + payload["category_id"] = res.candidates[0].id + payload["category_name"] = res.candidates[0].name + options.pop("cat", None) + elif res.candidates: + options["cat"] = [{"id": c.id, "name": c.name} for c in res.candidates] + else: + options.pop("cat", None) + + +async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None: + if field == "cat": + draft.payload["category_id"] = choice["id"] + draft.payload["category_name"] = choice["name"] + draft.payload.setdefault("options", {}).pop("cat", None) + await draft_store.save_payload(draft, missing={"fields": supplier_missing(draft.payload)}) + await render(ctx, chat_id, draft, fresh=False) + + +def _keyboard(payload: dict, draft_id: str) -> dict: + rows: list[list[dict]] = [] + options = (payload.get("options") or {}).get("cat") or [] + if options: + rows += pick_buttons(draft_id, "cat", [o["name"] for o in options[:6]]) + return simple_review_keyboard(draft_id, KIND_SUPPLIER, rows) + + +async def render(ctx, chat_id, draft, fresh: bool = True) -> None: + payload = draft.payload + missing = supplier_missing(payload) + text = supplier_review_text(payload, missing) + keyboard = _keyboard(payload, draft.id) + if fresh: + message = await ctx.send(chat_id, text, reply_markup=keyboard) + draft.review_message_id = (message or {}).get("message_id") + await draft_store.save_payload(draft, missing={"fields": missing}, + review_message_id=draft.review_message_id) + else: + await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard) + await draft_store.save_payload(draft, missing={"fields": missing}) + + +async def commit(ctx, chat_id, user, draft) -> None: + payload = draft.payload + await resolve_payload(ctx, payload) + missing = {m["key"]: m["ok"] for m in supplier_missing(payload)} + problems = [label for key, label, ok in [ + ("name", "نام تأمین‌کننده", missing.get("name")), + ("category", "دسته‌بندی", missing.get("category")), + ] if not ok] + if problems: + await ctx.send(chat_id, "ثبت ممکن نیست؛ اول این‌ها را مشخص کن:\n" + + "\n".join(f"❌ {esc(p)}" for p in problems)) + return + body = { + "name": payload["name"], + "category_id": payload["category_id"], + "phone": payload.get("phone"), + "location": payload.get("location"), + "contact_person": payload.get("contact_person"), + } + body = {k: v for k, v in body.items() if v} + try: + created = await ctx.api.create_supplier(body, actor_id=user.actor_id) + except ApiError as exc: + await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message))) + return + await draft_store.set_status(draft.id, "committed") + ctx.resolver.invalidate("suppliers") + await ctx.send(chat_id, supplier_committed_report(created)) diff --git a/ai procurement/app/keyboards.py b/ai procurement/app/keyboards.py new file mode 100644 index 0000000..eaad568 --- /dev/null +++ b/ai procurement/app/keyboards.py @@ -0,0 +1,74 @@ +"""Inline keyboard builders. + +Bale keeps Telegram's 64-byte ``callback_data`` cap, so every callback is a +short op-code plus the 8-char draft prefix; the full state lives in bot_drafts +(or the in-memory pending-flow map) and is looked up on tap. +""" + + +def _kb(rows: list[list[dict]]) -> dict: + return {"inline_keyboard": rows} + + +def _btn(text: str, data: str) -> dict: + return {"text": text, "callback_data": data} + + +# callback scheme (≤64 bytes): +# q:ok:{d8} / q:no:{d8} / q:ok2:{d8} quote commit / cancel / commit-resolved-only +# s:ok:{d8} / s:no:{d8} supplier +# m:ok:{d8} / m:no:{d8} material +# pk:{d8}:{field}:{idx} pick candidate for a draft field +# qc:{idx} / ac:{op} / ar:{op} pending-flow buttons (accept/arrive) + +def draft_actions(prefix: str, kind: str, has_partial_option: bool = False) -> list[list[dict]]: + op = {"quote": "q", "supplier": "s", "material": "m"}.get(kind, "q") + rows = [[_btn("✅ ثبت نهایی", f"{op}:ok:{prefix}"), _btn("❌ انصراف", f"{op}:no:{prefix}")]] + if has_partial_option: + rows.insert(0, [_btn("✅ ثبت فقط اقلام مشخص", f"{op}:ok2:{prefix}")]) + return rows + + +def pick_buttons(prefix: str, field: str, names: list[str]) -> list[list[dict]]: + return [[_btn(name[:60], f"pk:{prefix}:{field}:{i}")] for i, name in enumerate(names)] + + +def quote_review_keyboard( + prefix: str, + pick_rows: list[list[dict]], + has_partial_option: bool = False, + wizard_url: str | None = None, +) -> dict: + rows = list(pick_rows) + if wizard_url: + rows.append([{"text": "🖥 بازبینی و تکمیل در ویزارد", "url": wizard_url}]) + rows += draft_actions(prefix, "quote", has_partial_option=has_partial_option) + return _kb(rows) + + +def simple_review_keyboard(prefix: str, kind: str, pick_rows: list[list[dict]]) -> dict: + rows = list(pick_rows) + rows += draft_actions(prefix, kind) + return _kb(rows) + + +def quote_pick_keyboard(pairs: list[tuple[str, str]]) -> dict: + """Pick among candidate quotes: pairs of (label, quote_id).""" + return _kb([[_btn(label[:60], f"qc:{i}")] for i, (label, _) in enumerate(pairs)]) + + +def accept_keyboard(termsheet_labels: list[str]) -> dict: + rows = [[_btn(f"شرایط: {label[:40]}", f"ac:ts:{i}")] for i, label in enumerate(termsheet_labels)] + rows.append([_btn("✅ تأیید پذیرش", "ac:ok"), _btn("❌ انصراف", "ac:no")]) + return _kb(rows) + + +def arrive_keyboard() -> dict: + return _kb([ + [_btn("📦 تحویل کامل همه اقلام", "ar:full")], + [_btn("✅ ثبت مقادیر اعلام‌شده", "ar:ok"), _btn("❌ انصراف", "ar:no")], + ]) + + +def cancel_keyboard() -> dict: + return _kb([[_btn("❌ انصراف", "ac:no")]]) diff --git a/ai procurement/app/main.py b/ai procurement/app/main.py new file mode 100644 index 0000000..0be6d75 --- /dev/null +++ b/ai procurement/app/main.py @@ -0,0 +1,60 @@ +"""Bot entrypoint: ensure tables → sweeper task → Bale long-poll loop. + +Mirrors the api's pattern of a plain asyncio background loop (no Celery/Redis). +""" +import asyncio +import logging + +from app.bale.client import BaleClient, poll_loop +from app.config import settings +from app.db_init import ensure_schema +from app.drafts import expire_stale +from app.handlers import handle_update +from app.runtime import BotContext +from app.api.client import ErpClient +from app.sync import sync_drafts_loop + +logging.basicConfig( + level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s %(levelname)s %(name)s — %(message)s", +) +logger = logging.getLogger("bot.main") + + +async def sweeper_loop() -> None: + """Expire stale drafts + sweep dead pending flows every 10 minutes.""" + while True: + try: + expired = await expire_stale(settings.DRAFT_TTL_HOURS) + if expired: + logger.info("expired %s stale drafts", expired) + except Exception: + logger.exception("sweeper failed") + await asyncio.sleep(600) + + +async def main() -> None: + if not settings.BOT_TOKEN: + raise SystemExit("PROCUREMENT_BOT_BOT_TOKEN is not set — get one from Bale's BotFather") + logger.info("starting bale procurement bot (api=%s, ai=%s)", + settings.API_BASE_URL, "configured" if settings.AI_BASE_URL else "NOT configured") + await ensure_schema() + + bale = BaleClient(settings.BALE_API_BASE, settings.BOT_TOKEN) + api = ErpClient(settings.API_BASE_URL, settings.API_TIMEOUT_SECONDS, settings.SERVICE_TOKEN) + ctx = BotContext(bale, api) + + sweeper = asyncio.create_task(sweeper_loop()) + sync_task = asyncio.create_task(sync_drafts_loop(ctx)) + try: + await poll_loop(bale, lambda update: handle_update(ctx, update)) + finally: + sweeper.cancel() + sync_task.cancel() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + pass diff --git a/ai procurement/app/models.py b/ai procurement/app/models.py new file mode 100644 index 0000000..ef55df9 --- /dev/null +++ b/ai procurement/app/models.py @@ -0,0 +1,80 @@ +"""Bot-owned tables (bot_* prefix, idempotent DDL in db_init.py). + +These are separate from the ERP schema on purpose: the bot is an autonomous +container and owns its own state (links, drafts, message audit log). +""" +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Index, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +def _uuid_pk() -> Mapped[str]: + return mapped_column(UUID(as_uuid=False), primary_key=True, server_default=func.gen_random_uuid()) + + +def _timestamptz(**kwargs) -> Mapped[datetime]: + return mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, **kwargs) + + +class BotLink(Base): + """Bale chat ↔ ERP user mapping (established via Casdoor login).""" + + __tablename__ = "bot_links" + + id: Mapped[str] = _uuid_pk() + chat_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False, index=True) + user_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False)) # users.id + casdoor_username: Mapped[str | None] = mapped_column(Text) + display_name: Mapped[str | None] = mapped_column(Text) + role: Mapped[str | None] = mapped_column(String(32)) + created_at: Mapped[datetime] = _timestamptz() + updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now()) + + +class BotDraft(Base): + """A prepared-but-not-committed ERP action (quote / supplier / material).""" + + __tablename__ = "bot_drafts" + __table_args__ = (Index("ix_bot_drafts_chat_status", "chat_id", "status"),) + + id: Mapped[str] = _uuid_pk() + chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + kind: Mapped[str] = mapped_column(String(16), nullable=False) # quote|supplier|material + payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + missing: Mapped[dict | None] = mapped_column(JSONB) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") # active|committed|cancelled|expired|failed + source: Mapped[str | None] = mapped_column(String(16)) # text|voice + review_message_id: Mapped[int | None] = mapped_column(BigInteger) + last_bale_update_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = _timestamptz() + updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now()) + + +class BotMessage(Base): + """Audit log of every inbound/outbound message (who asked for what).""" + + __tablename__ = "bot_messages" + + id: Mapped[str] = _uuid_pk() + chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + direction: Mapped[str] = mapped_column(String(4), nullable=False) # in|out + kind: Mapped[str] = mapped_column(String(16), nullable=False, default="text") # text|voice|callback|error + body: Mapped[str | None] = mapped_column(Text) + payload: Mapped[dict | None] = mapped_column(JSONB) + created_at: Mapped[datetime] = _timestamptz() + + +class BotMeta(Base): + """Tiny KV store (e.g. last processed Bale update_id across restarts).""" + + __tablename__ = "bot_meta" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[str | None] = mapped_column(Text) + updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now()) diff --git a/ai procurement/app/nlu.py b/ai procurement/app/nlu.py new file mode 100644 index 0000000..44b7f9d --- /dev/null +++ b/ai procurement/app/nlu.py @@ -0,0 +1,149 @@ +"""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 برگردان (بدون توضیح اضافه) با این شکل: + +{ + "intent": یکی از {intents}, + "patch": {شکل partial هر فعالیتی که کاربر داده}, + "confidence": 0.0..1.0 +} + +قوانین: +- اگر پیام کاربر اطلاعات جدیدی به پیش‌نویسِ باز اضافه می‌کند یا اصلاح می‌کند → 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, draft_summary: str | None = None) -> dict: + """Return {"intent": ..., "patch": {...}, "confidence": ...}; never raises.""" + fallback = {"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 + 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": normalize_digits(text)}, + ], + "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", {}) + return parsed + except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError): + logger.exception("NLU failure") + return fallback diff --git a/ai procurement/app/resolve.py b/ai procurement/app/resolve.py new file mode 100644 index 0000000..fb0af5f --- /dev/null +++ b/ai procurement/app/resolve.py @@ -0,0 +1,151 @@ +"""Name → ERP id resolution with fuzzy matching (Persian-aware) + tiny caches. + +The ERP search endpoints are ilike-based; we fetch candidates with `q=` and +rank them with difflib so "میلگرد A3 14" still matches "میلگرد A3 سایز ۱۴". +""" +import difflib +import time +from dataclasses import dataclass, field + +from app.api.client import ApiError, ErpClient +from app.nlu import normalize_text + + +@dataclass +class Candidate: + id: str + name: str + extra: dict = field(default_factory=dict) + + +@dataclass +class Resolution: + status: str # "one" | "many" | "none" + candidates: list[Candidate] + + @property + def resolved(self) -> bool: + return self.status == "one" + + +def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]: + q = normalize_text(query) + if not q: + return items[:limit] + scored = [] + for item in items: + name = normalize_text(item.name) + ratio = difflib.SequenceMatcher(None, q, name).ratio() + if q == name: + ratio = 1.0 + elif q in name: + ratio += 0.35 + scored.append((ratio, item)) + scored.sort(key=lambda pair: (-pair[0], pair[1].name)) + return [item for ratio, item in scored[:limit] if ratio >= 0.40] + + +class Resolver: + """Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand).""" + + def __init__(self, api: ErpClient, ttl_seconds: float = 1800): + self._api = api + self._ttl = ttl_seconds + self._cache: dict[str, tuple[float, list[Candidate]]] = {} + + async def _cached(self, key: str, fetch) -> list[Candidate]: + entry = self._cache.get(key) + now = time.monotonic() + if entry and now - entry[0] < self._ttl: + return entry[1] + items = await fetch() + self._cache[key] = (now, items) + return items + + def invalidate(self, key: str | None = None) -> None: + if key: + self._cache.pop(key, None) + else: + self._cache.clear() + + # ---------------------------------------------------------------- sources + + async def units(self) -> list[Candidate]: + async def fetch(): + rows = await self._api.list_units() + return [Candidate(id=str(u["id"]), name=u.get("name_fa") or u.get("code") or str(u["id"]), + extra={"code": u.get("code")}) for u in rows] + return await self._cached("units", fetch) + + async def categories(self) -> list[Candidate]: + async def fetch(): + rows = await self._api.list_categories() + return [Candidate(id=str(c["id"]), name=c.get("name") or c.get("slug") or str(c["id"])) for c in rows] + return await self._cached("categories", fetch) + + async def projects(self) -> list[Candidate]: + async def fetch(): + rows = await self._api.list_projects() + return [Candidate(id=str(p["id"]), name=p.get("name") or str(p["id"])) for p in rows] + return await self._cached("projects", fetch) + + async def suppliers(self, q: str | None = None) -> list[Candidate]: + rows = await self._api.list_suppliers(q=q, page_size=20) + items = [Candidate(id=str(s["id"]), name=s.get("name") or str(s["id"])) for s in rows] + return _rank(q, items) if q else items + + async def materials(self, q: str | None = None) -> list[Candidate]: + rows = await self._api.list_materials(q=q, page_size=20) + extra = {str(m["id"]): m for m in rows} + items = [Candidate(id=str(m["id"]), name=m.get("name") or str(m["id"]), extra=extra.get(str(m["id"]), {})) + for m in rows] + return _rank(q, items) if q else items + + # ------------------------------------------------------------ resolutions + + async def resolve(self, kind: str, name: str | None) -> Resolution: + """Resolve by name; exact hit → "one", several close → "many", none → "none".""" + if not name or not name.strip(): + return Resolution("none", []) + if kind == "supplier": + candidates = await self.suppliers(name) + elif kind == "material": + candidates = await self.materials(name) + elif kind == "project": + candidates = _rank(name, await self.projects()) + elif kind == "unit": + candidates = _rank(name, await self.units()) + elif kind == "category": + candidates = _rank(name, await self.categories()) + else: + raise ValueError(f"unknown kind: {kind}") + if not candidates: + return Resolution("none", []) + norm_name = normalize_text(name) + exact = [c for c in candidates if normalize_text(c.name) == norm_name] + if len(exact) == 1: + return Resolution("one", exact) + top = candidates[0] + top_norm = normalize_text(top.name) + ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio() + if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95): + return Resolution("one", [top]) + return Resolution("many", candidates) + + async def unit_for_material(self, unit_name: str | None, material: dict | None) -> tuple[str | None, bool]: + """Return (unit_id, explicitly_resolved). Falls back to the material's + canonical unit when the spoken unit isn't found.""" + if unit_name: + res = await self.resolve("unit", unit_name) + if res.resolved: + return res.candidates[0].id, True + if material and material.get("canonical_unit_id"): + return str(material["canonical_unit_id"]), False + return None, False + + +async def safe_resolve(resolver: Resolver, kind: str, name: str | None) -> Resolution: + try: + return await resolver.resolve(kind, name) + except ApiError: + return Resolution("none", []) diff --git a/ai procurement/app/runtime.py b/ai procurement/app/runtime.py new file mode 100644 index 0000000..bf646e1 --- /dev/null +++ b/ai procurement/app/runtime.py @@ -0,0 +1,159 @@ +"""Shared runtime: BotContext wiring + per-chat pending interaction flows. + +Pending flows (Casdoor linking, quote picking, accept/arrive confirmations) +are short-lived and single-instance, so they live in memory; anything that +must survive restarts (drafts, links) lives in postgres. +""" +import logging +import time +from dataclasses import dataclass, field + +from sqlalchemy import select, update + +from app.api.client import ErpClient +from app.bale.client import BaleClient +from app.config import settings +from app.db import SessionLocal +from app.models import BotLink, BotMessage +from app.resolve import Resolver + +logger = logging.getLogger("bot.runtime") + + +# --------------------------------------------------------------------------- +# Links (postgres) +# --------------------------------------------------------------------------- + +class LinkStore: + async def get(self, chat_id: int) -> BotLink | None: + async with SessionLocal() as session: + return (await session.execute( + select(BotLink).where(BotLink.chat_id == chat_id) + )).scalar_one_or_none() + + async def upsert(self, chat_id: int, user_id: str | None, username: str | None, + display_name: str | None, role: str | None) -> BotLink: + async with SessionLocal() as session: + link = (await session.execute(select(BotLink).where(BotLink.chat_id == chat_id))).scalar_one_or_none() + if link is None: + link = BotLink(chat_id=chat_id) + session.add(link) + link.user_id = user_id + link.casdoor_username = username + link.display_name = display_name + link.role = role + await session.commit() + await session.refresh(link) + return link + + async def delete(self, chat_id: int) -> bool: + async with SessionLocal() as session: + result = await session.execute( + update(BotLink).where(BotLink.chat_id == chat_id).values(user_id=None, role=None, + casdoor_username=None) + ) + await session.commit() + return (result.rowcount or 0) > 0 + + +ROLE_FA = {"pm": "مدیر پروژه", "procurement_manager": "مدیر تدارکات", "admin": "ادمین"} + + +# --------------------------------------------------------------------------- +# Pending flows (memory) — one per chat +# --------------------------------------------------------------------------- + +@dataclass +class PendingFlow: + kind: str # link_username | link_password | quote_pick | accept | arrive_qty | arrive_confirm + data: dict = field(default_factory=dict) + created: float = field(default_factory=time.monotonic) + + @property + def age_seconds(self) -> float: + return time.monotonic() - self.created + + +class PendingFlows: + TTL_SECONDS = 1800 + + def __init__(self): + self._flows: dict[int, PendingFlow] = {} + + def set(self, chat_id: int, kind: str, data: dict | None = None) -> PendingFlow: + flow = PendingFlow(kind=kind, data=data or {}) + self._flows[chat_id] = flow + return flow + + def get(self, chat_id: int) -> PendingFlow | None: + flow = self._flows.get(chat_id) + if flow and flow.age_seconds > self.TTL_SECONDS: + self._flows.pop(chat_id, None) + return None + return flow + + def pop(self, chat_id: int, default=None): + return self._flows.pop(chat_id, default) + + def sweep(self) -> None: + for chat_id, flow in list(self._flows.items()): + if flow.age_seconds > self.TTL_SECONDS: + self._flows.pop(chat_id, None) + + +# --------------------------------------------------------------------------- +# Context +# --------------------------------------------------------------------------- + +class BotContext: + def __init__(self, bale: BaleClient, api: ErpClient): + self.bale = bale + self.api = api + self.resolver = Resolver(api) + self.links = LinkStore() + self.pending = PendingFlows() + + @property + def admin_chat_ids(self) -> set[int]: + return settings.admin_chat_ids + + async def send(self, chat_id: int, text: str, reply_markup: dict | None = None) -> dict: + """Send + audit-log every outbound message.""" + try: + message = await self.bale.send_message(chat_id, text, reply_markup=reply_markup) + except Exception: + logger.exception("failed to send to chat %s", chat_id) + message = {} + await self._log(chat_id, "out", "text", text) + return message + + async def edit(self, chat_id: int, message_id: int | None, text: str, + reply_markup: dict | None = None) -> None: + if not message_id: + await self.send(chat_id, text, reply_markup=reply_markup) + return + try: + await self.bale.edit_message_text(chat_id, message_id, text, reply_markup=reply_markup) + except Exception: + await self.send(chat_id, text, reply_markup=reply_markup) + + async def _log(self, chat_id: int, direction: str, kind: str, body: str | None = None) -> None: + try: + async with SessionLocal() as session: + session.add(BotMessage(chat_id=chat_id, direction=direction, kind=kind, body=body)) + await session.commit() + except Exception: + logger.exception("failed to log bot message") + + +@dataclass +class LinkedUser: + chat_id: int + user_id: str | None + display_name: str | None + role: str | None + is_admin: bool + + @property + def actor_id(self) -> str | None: + return self.user_id diff --git a/ai procurement/app/stt.py b/ai procurement/app/stt.py new file mode 100644 index 0000000..83d9ff8 --- /dev/null +++ b/ai procurement/app/stt.py @@ -0,0 +1,62 @@ +"""Speech-to-text via an OpenAI-compatible /audio/transcriptions endpoint. + +Bale voice notes arrive as OGG/Opus which not every provider accepts, so the +audio is first converted to mono 16 kHz MP3 with ffmpeg (installed in the bot +image). Any OpenAI-compatible provider works (OpenAI, GLM, OpenRouter proxies, +self-hosted whisper servers exposing the same contract). +""" +import asyncio +import logging +import subprocess +import tempfile +from pathlib import Path + +import httpx + +from app.config import Settings + +logger = logging.getLogger("bot.stt") + +_CONVERT_TIMEOUT = 60 +_STT_TIMEOUT = 120 + + +def _to_mp3(raw: bytes, suffix: str) -> bytes: + """Convert arbitrary audio bytes to mono 16 kHz MP3 using ffmpeg.""" + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / f"in{suffix or '.ogg'}" + dst = Path(tmp) / "out.mp3" + src.write_bytes(raw) + proc = subprocess.run( + ["ffmpeg", "-y", "-i", str(src), "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", str(dst)], + capture_output=True, + timeout=_CONVERT_TIMEOUT, + ) + if proc.returncode != 0 or not dst.exists(): + raise RuntimeError(f"ffmpeg failed: {proc.stderr[-400:]!r}") + return dst.read_bytes() + + +async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None, + filename: str | None = None) -> str: + """Return the Persian transcript for a voice note (empty string on silence).""" + if not settings.stt_base_url or not settings.stt_api_key: + raise RuntimeError("STT provider is not configured (PROCUREMENT_BOT_STT_BASE_URL / _AI_BASE_URL)") + + suffix = Path(filename or "").suffix + if not suffix and mime_type: + suffix = {"audio/ogg": ".ogg", "audio/opus": ".ogg", "audio/mpeg": ".mp3", + "audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg") + converted = await asyncio.to_thread(_to_mp3, audio, suffix) + + url = f"{settings.stt_base_url.rstrip('/')}/audio/transcriptions" + async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client: + resp = await client.post( + url, + headers={"Authorization": f"Bearer {settings.stt_api_key}"}, + files={"file": ("voice.mp3", converted, "audio/mpeg")}, + data={"model": settings.STT_MODEL, "language": settings.STT_LANGUAGE}, + ) + if resp.status_code != 200: + raise RuntimeError(f"STT provider returned HTTP {resp.status_code}: {resp.text[:300]}") + return str((resp.json() or {}).get("text") or "").strip() diff --git a/ai procurement/app/sync.py b/ai procurement/app/sync.py new file mode 100644 index 0000000..996d0ba --- /dev/null +++ b/ai procurement/app/sync.py @@ -0,0 +1,59 @@ +"""Background sync loop: watches bot_drafts for updates from Web Wizard or external API. + +If a draft was updated in the Wizard: + - If status == 'active': re-renders review text & keyboard in Bale so chat reflects edits. + - If status == 'committed': edits Bale message to show it was committed and removes buttons. + - If status == 'cancelled': edits Bale message to show it was cancelled and removes buttons. +""" +import asyncio +import logging +from app import drafts as draft_store +from app.handlers import quote + +logger = logging.getLogger("bot.sync") + + +async def sync_drafts_loop(ctx) -> None: + while True: + try: + pending = await draft_store.get_pending_bale_updates() + for draft in pending: + try: + if not draft.review_message_id: + await draft_store.set_bale_updated_at(draft.id) + continue + + if draft.status == "committed": + payload = draft.payload or {} + quote_id = payload.get("committed_quote_id") or "" + msg = "✅ این پیشنهاد با موفقیت در سیستم ثبت نهایی شد." + if quote_id: + msg += f"\nشناسه پیشنهاد: {quote_id}" + await ctx.bale.edit_message_text(draft.chat_id, draft.review_message_id, msg, reply_markup=None) + await draft_store.set_bale_updated_at(draft.id) + logger.info("Synced committed draft %s to Bale message %s", draft.id, draft.review_message_id) + + elif draft.status == "cancelled": + msg = "❌ این پیش‌نویس لغو شد." + await ctx.bale.edit_message_text(draft.chat_id, draft.review_message_id, msg, reply_markup=None) + await draft_store.set_bale_updated_at(draft.id) + logger.info("Synced cancelled draft %s to Bale message %s", draft.id, draft.review_message_id) + + elif draft.status == "active" and draft.kind == draft_store.KIND_QUOTE: + await quote.render(ctx, draft.chat_id, draft, fresh=False) + await draft_store.set_bale_updated_at(draft.id) + logger.info("Synced active draft %s edits to Bale message %s", draft.id, draft.review_message_id) + + else: + await draft_store.set_bale_updated_at(draft.id) + + except Exception as exc: + logger.warning("Failed to sync draft %s to Bale: %s", draft.id, exc) + await draft_store.set_bale_updated_at(draft.id) + + except asyncio.CancelledError: + break + except Exception: + logger.exception("Error in sync_drafts_loop") + + await asyncio.sleep(1.5) diff --git a/ai procurement/app/texts.py b/ai procurement/app/texts.py new file mode 100644 index 0000000..bd62cd8 --- /dev/null +++ b/ai procurement/app/texts.py @@ -0,0 +1,254 @@ +"""Persian message templates (the ERP's user-facing layer is Persian).""" +import html +from datetime import date + +STATUS_FA = { + "offered": "🔵 در انتظار بررسی", + "accepted": "🟢 پذیرفته‌شده", + "arrived": "✅ رسیده", + "rejected": "🔴 ردشده", + "expired": "⚪️ منقضی", +} + +MISSING_MARKS = {True: "✅", False: "❌"} +WARNING_MARK = "⚠️" + + +def esc(value) -> str: + return html.escape(str(value if value is not None else "—")) + + +def fa_date(value: str | None) -> str: + if not value: + return "—" + return str(value)[:10] + + +WELCOME = ( + "سلام! 👋 من دستیار تدارکات هستم.\n\n" + "می‌توانی با پیام متنی یا صوتی فعالیتی تعریف کنی — مثلاً:\n" + "«سازان نو برای پروژه برج آسمان، ۵۰۰ کیلو میلگرد A3، هر کیلو ۴۵ تومن، پرداخت ۳ فاز»\n\n" + "من هر چه را فهمیدم به‌صورت پیش‌نویس آماده می‌کنم، چیزی که فهمیدم و چیزهای خالی را نشان " + "می‌دهم و فقط با تأیید تو در سیستم ثبت می‌کنم.\n\n" + "دستورها:\n" + "/drafts — پیش‌نویس‌های باز\n" + "/cancel — لغو پیش‌نویس/عملیات جاری\n" + "/whoami — اتصال من\n" + "/unlink — قطع اتصال\n" + "/help — راهنما" +) + +HELP = ( + "چه کارهایی می‌توانم بکنم؟\n\n" + "🧾 ثبت استعلام — تأمین‌کننده، پروژه، اقلام (کالا/مقدار/واحد)، قیمت و شرایط پرداخت را از گفتگو درمی‌آورم.\n" + "🏢 افزودن تأمین‌کننده — «تأمین‌کننده جدید فلانی، دسته لوله و اتصالات، تلفن ...»\n" + "📦 افزودن کالا — «کالای جدید میلگرد A3 با واحد کیلوگرم»\n" + "📊 پرس‌وجو — «استعلام‌های در انتظار؟»، «کدام‌ها به‌زودی منقضی می‌شوند؟»\n" + "✔️ پذیرش استعلام — «استعلام سازان نو را قبول کن»\n" + "🚚 اعلام رسیدن — «اقلام سازان نو رسید، ۵۰۰ کیلو»\n\n" + "هر ثبت فقط با دکمه ✅ انجام می‌شود. با /cancel هر چیز نیمه‌کاره را لغو کن." +) + +LINK_START = ( + "برای اتصال حساب Bale به حساب ERP، نام کاربری Casdoor خود را بفرست " + "(یا اگر رمز جدا داری همان نام کاربری سامانه). برای لغو: /cancel" +) +LINK_ASK_PASSWORD = "ممنون. حالا رمز عبور را بفرست (فقط برای یک بار ورود؛ ذخیره نمی‌شود). /cancel برای لغو" +LINK_OK = "✅ اتصال انجام شد: {name} ({role})\nحالا می‌توانی فعالیت‌ها را بفرستی. /help را ببین." +LINK_FAIL = "ورود ناموفق بود: {detail}\nدوباره /start بزن یا نام کاربری را بفرست." +ADMIN_NO_LINK = "حالت ادمین (بدون اتصال) فعال است؛ ثبت‌ها بدون انتساب کاربر انجام می‌شود." + +DRAFTS_LIST_HEADER = "📋 پیش‌نویس‌های باز:" +DRAFTS_EMPTY = "پیش‌نویس بازی نداری. یک فعالیت را با متن یا وویس بفرست." +NOTHING_TO_COMMIT = "پیش‌نویس بازی برای ثبت نیست؛ اول یک فعالیت بفرست." +CANCELLED = "لغو شد. ✔️" +UNKNOWN_HINT = ( + "متوجه منظورت نشد 🤔\n" + "می‌توانی مثلاً بگویی: «ثبت استعلام از سازان نو برای ۲۰۰ عدد شیر فلکه» یا /help را ببینی." +) +UNLINKED = "ابتدا حساب خود را متصل کن: /start" +AI_NOT_CONFIGURED = ( + "پردازش هوشمند تنظیم نشده است (متغیرهای PROCUREMENT_BOT_AI_*). " + "با مدیر سیستم تماس بگیر." +) +STT_FAILED = "نتوانستم پیام صوتی را تبدیل کنم: {detail}" +VOICE_EMPTY = "از پیام صوتی متنی شنیده نشد؛ دوباره بگو یا با متن بفرست." +ERR_GENERIC = "خطا: {detail}" +PICK_QUOTE = "چند استعلام پیدا شد؛ کدام را می‌خواهی؟" +QUOTE_NOT_FOUND = "استعلامی برای «{q}» پیدا نکردم." +ACCEPT_REPORT = ( + "✅ پذیرش انجام شد\n" + "استعلام #{id}\n" + "وضعیت: {status}\n" + "اقلام تصمیم‌گیری‌شده: {count}" +) +ARRIVE_REPORT = ( + "🚚 ثبت رسید انجام شد\n" + "استعلام #{id} — وضعیت: {status}\n{lines}" +) +ACCEPT_DONE_TS = "شرایط انتخاب‌شده: {label}" + + +def missing_block(missing: list[dict]) -> str: + """✅/❌ list of what's captured vs. still empty (⚠️ = nice-to-have).""" + if not missing: + return "همه فیلدها کامل است ✅" + lines = [] + for m in missing: + mark = MISSING_MARKS[m["ok"]] if m["blocking"] else (MISSING_MARKS.get(m["ok"], WARNING_MARK)) + if not m["blocking"] and not m["ok"]: + mark = WARNING_MARK + lines.append(f"{mark} {m['label']}") + return "\n".join(lines) + + +def quote_review_text(payload: dict, missing: list[dict], status_note: str | None = None) -> str: + lines = ["🧾 پیش‌نویس استعلام:"] + if status_note: + lines.append(status_note) + sup = payload.get("supplier_name") or "—" + if payload.get("supplier_id"): + sup_label = f"{esc(sup)} ✅" + elif payload.get("supplier_name"): + sup_label = f"{esc(sup)} ✨ (تأمین‌کننده جدید)" + else: + sup_label = "—" + lines.append(f"تأمین‌کننده: {sup_label}") + proj_names = payload.get("project_names") or ([payload["project_name"]] if payload.get("project_name") else []) + if proj_names: + lines.append(f"پروژه(ها): {esc('، '.join(proj_names))}") + lines.append(f"نوع: {'رسمی' if payload.get('is_official', True) else 'برآوردی'}") + if payload.get("expiry_date"): + lines.append(f"اعتبار تا: {fa_date(payload['expiry_date'])}") + + items = payload.get("items") or [] + if items: + lines.append("\n📦 اقلام:") + for i, it in enumerate(items, start=1): + name = it.get('material_name') or '?' + if it.get('brand_name'): + name += f" ({it['brand_name']})" + parts = [f"{i}. {esc(name)}"] + if it.get('specs'): + parts.append(esc("، ".join(it['specs']))) + if it.get("quantity"): + parts.append(f"مقدار {esc(it['quantity'])}") + unit = it.get("unit_name") or ("(واحد کالا)" if it.get("unit_id") else None) + if unit: + parts.append(str(unit)) + if it.get("unit_price"): + parts.append(f"قیمت {esc(it['unit_price'])}") + if not it.get("material_id"): + parts.append("✨ کالای جدید") + lines.append(" — ".join(str(p) for p in parts)) + else: + lines.append("\nاقلام: هنوز چیزی ثبت نشده ❌") + + sheets = payload.get("termsheets") or [] + if sheets: + ts_parts = [] + for t in sheets: + lbl = esc(t.get('label')) + if t.get("milestones"): + steps_txt = " + ".join(f"{fa_date(m.get('due_date'))}: {esc(m.get('amount'))}" for m in t["milestones"] if m.get("amount")) + if steps_txt: + lbl += f" [{steps_txt}]" + elif t.get("discount_value"): + lbl += f" (تخفیف {esc(t.get('discount_value'))})" + ts_parts.append(lbl) + lines.append("\n💳 شرایط پرداخت: " + "؛ ".join(ts_parts)) + if payload.get("notes"): + lines.append(f"یادداشت: {esc(payload['notes'])}") + + lines.append("\n📋 وضعیت فیلدها:") + lines.append(missing_block(missing)) + lines.append("\nبا پیام بعدی می‌توانی چیزهای خالی را تکمیل کنی، یا با دکمه‌ها ثبت/لغو کنی.") + return "\n".join(lines) + + +def supplier_review_text(payload: dict, missing: list[dict]) -> str: + lines = ["🏢 پیش‌نویس تأمین‌کننده جدید", f"نام: {esc(payload.get('name'))}"] + lines.append(f"دسته: {esc(payload.get('category_name'))}") + if payload.get("phone"): + lines.append(f"تلفن: {esc(payload['phone'])}") + if payload.get("location"): + lines.append(f"موقعیت: {esc(payload['location'])}") + if payload.get("contact_person"): + lines.append(f"رابط: {esc(payload['contact_person'])}") + lines.append("\nوضعیت فیلدها:") + lines.append(missing_block(missing)) + return "\n".join(lines) + + +def material_review_text(payload: dict, missing: list[dict]) -> str: + lines = ["📦 پیش‌نویس کالای جدید", f"نام: {esc(payload.get('name'))}"] + lines.append(f"واحد اصلی: {esc(payload.get('unit_name'))}") + if payload.get("topic"): + lines.append(f"تاپیک: {esc(payload['topic'])}") + lines.append("\nوضعیت فیلدها:") + lines.append(missing_block(missing)) + return "\n".join(lines) + + +def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str: + lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"] + sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name") + if sup: + lines.append(f"تأمین‌کننده: {esc(sup)}") + items = created.get("line_items") or [] + if items: + lines.append(f"اقلام: {len(items)}") + sheets = created.get("termsheets") or [] + if sheets: + lines.append(f"شرایط پرداخت: {len(sheets)}") + if payload.get("project_id"): + lines.append("پروژه: پیوست شد") + if remaining: + lines.append("\nخالی مانده‌ها (بعداً می‌توانی در برنامه تکمیل کنی):") + lines += [f"⚠️ {esc(r)}" for r in remaining] + lines.append("\nمرحله بعد: وقتی تصمیم گرفتی، بگو «استعلام را قبول کن» تا پذیرش را با هم انجام دهیم.") + return "\n".join(lines) + + +def supplier_committed_report(created: dict) -> str: + return ( + f"✅ تأمین‌کننده ثبت شد — {esc(created.get('name'))}\n" + f"دسته: {esc((created.get('category') or {}).get('name'))}" + ) + + +def material_committed_report(created: dict) -> str: + return f"✅ کالا ثبت شد — {esc(created.get('name'))}" + + +def quote_line(quote: dict) -> str: + sup = (quote.get("supplier") or {}).get("name") or quote.get("supplier_id") or "—" + items = quote.get("line_items") or [] + item_names = [] + for it in items: + mat = (it.get("material") or {}).get("name") + if mat: + qty = it.get("quantity") + item_names.append(f"{mat} ({qty})" if qty else mat) + tail = f" — {'، '.join(item_names[:3])}" + ("…" if len(item_names) > 3 else "") if item_names else "" + return ( + f"#{str(quote.get('id'))[:8]} — {esc(sup)} — {STATUS_FA.get(quote.get('status'), quote.get('status'))} — " + f"{fa_date(quote.get('quote_date'))}{tail}" + ) + + +def status_report(kind: str, quotes: list[dict], extra_note: str | None = None) -> str: + titles = { + "pending": "🔵 استعلام‌های در انتظار بررسی", + "accepted": "🟢 پذیرفته‌شده (در انتظار تحویل)", + "arrived": "✅ رسیده", + "expiring": "⏳ به‌زودی منقضی می‌شوند (۷ روز آینده)", + "recent": "🕘 آخرین استعلام‌ها", + } + if not quotes: + return f"{titles.get(kind, 'نتیجه‌ای')}\nموردی نیست." + lines = [titles.get(kind, "استعلام‌ها")] + if extra_note: + lines.append(extra_note) + lines += [f"• {quote_line(q)}" for q in quotes[:10]] + return "\n".join(lines) diff --git a/ai procurement/docs/SETUP.md b/ai procurement/docs/SETUP.md new file mode 100644 index 0000000..7446345 --- /dev/null +++ b/ai procurement/docs/SETUP.md @@ -0,0 +1,172 @@ +# Setup & Operations Guide + +A Bale messenger bot that turns Persian **chat messages and voice notes** into +procurement actions in the ERP. It works **draft-first**: it captures what it +understood, tells you exactly **what it did** and **what is still empty**, and +**commits only after you approve** with a ✅ button. + +Bale's Bot API is a Telegram-compatible clone hosted at `tapi.bale.ai`, so the +bot uses the familiar `getUpdates` long-polling — **no public port, no webhook, +no TLS setup**. + +--- + +## What it can do (v1) + +| Activity | Example (voice or text) | Committed via | +|---|---|---| +| Register a supplier quote | «سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴، هر کیلو ۴۵ تومن، پرداخت ۳ فاز» | `POST /quotes` (nested create) | +| Add a supplier | «تأمین‌کننده جدید پروفیل بر، دسته لوله و اتصالات، تلفن ۰۹۱۲…» | `POST /suppliers` | +| Add a material | «کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم» | `POST /materials` | +| Status queries | «استعلام‌های در انتظار؟» / «کدام‌ها به‌زودی منقضی می‌شوند؟» | `GET /quotes?…` | +| Accept a quote | «استعلام سازان نو را قبول کن» | `PATCH /quotes/{id}/accept` | +| Announce a delivery | «اقلام سازان نو رسید، ۵۰۰ و ۲۰۰» | `PATCH /quotes/{id}/arrive-items` | + +### The draft → approve loop (the core idea) + +1. You send a message (or voice note). The bot converts voice to text + (STT) and extracts an **intent + entities** (LLM). +2. Names are fuzzy-matched against the ERP (supplier / material / unit / + project). Multiple candidates → inline **pick buttons**; no match → it is + listed as missing. +3. The bot replies with a **review card**: everything it captured, plus a + ✅/⚠️/❌ **list of what is still empty** («یادآوری چیزهای خالی»). +4. You keep chatting to refine («قیمت میلگرد ۴۷ شد», «پروژه برج آسمان») — the + open draft is updated and re-rendered. +5. **Nothing is written to the ERP until you tap ✅ ثبت نهایی.** Partial + commits are possible («ثبت فقط اقلام مشخص») because the API deliberately + tolerates incomplete quotes. After commit you get the created quote id and + the list of fields that were left empty. + +### Team access & attribution + +- Every user links their Bale chat to their ERP account once: `/start` → + Casdoor **username** → **password** (validated through the real + `POST /auth/login`; the password is used once and never stored). +- Mutating API calls carry `X-Actor-Id: ` so `quote_events.actor` + records the **human** who asked, not the bot. (The bot's container talks to + the api in-network: `http://api:8000/api/v1`.) +- `BALE_ADMIN_CHAT_IDS` may list chat ids that bypass linking. + +### Commands + +`/start` · `/help` · `/drafts` (open drafts) · `/cancel` (abort draft/flow) · +`/whoami` · `/unlink` + +--- + +## Setup + +1. **Create the bot** in Bale's BotFather and copy the token. +2. **Configure an AI provider** — any OpenAI-compatible endpoint works for + both STT (`POST {base}/audio/transcriptions`, Persian) and the LLM + (`POST {base}/chat/completions`, JSON mode). Examples: OpenAI `whisper-1` + + `gpt-4o-mini`, or any proxy/provider you already use. +3. **Compose** (dev): + + ```powershell + # env passed at compose level (or a .env next to docker-compose.yml) + $env:BALE_BOT_TOKEN = "" + $env:AI_BASE_URL = "https://api.openai.com/v1" + $env:AI_API_KEY = "" + + docker compose up -d --build bot + docker compose logs -f bot + ``` + + For Coolify (`docker-compose.coolify.yml`) set the same variables as + service-level env vars; the bot container needs no domain/labels. + +### Environment variables (prefix `PROCUREMENT_BOT_`) + +| Variable | Default | Purpose | +|---|---|---| +| `BOT_TOKEN` | — (required) | token from Bale's BotFather | +| `BALE_API_BASE` | `https://tapi.bale.ai` | Bot API base (overridable for tests) | +| `API_BASE_URL` | `http://localhost:8000/api/v1` | ERP api (in Docker: `http://api:8000/api/v1`) | +| `DATABASE_URL` | same postgres as the api | bot's own tables (`bot_*`) | +| `AI_BASE_URL` / `AI_API_KEY` | — | OpenAI-compatible STT+LLM provider | +| `STT_MODEL` / `LLM_MODEL` | `whisper-1` / `gpt-4o-mini` | model names at the provider | +| `ADMIN_CHAT_IDS` | — | comma-separated Bale chat ids allowed without linking | +| `SERVICE_TOKEN` | — | optional bearer for the day procurement endpoints enforce auth | +| `DRAFT_TTL_HOURS` | `24` | drafts older than this are marked expired | + +In compose these are fed from `BALE_BOT_TOKEN`, `BALE_API_BASE`, `AI_BASE_URL`, +`AI_API_KEY`, `AI_STT_MODEL`, `AI_LLM_MODEL`, `BALE_ADMIN_CHAT_IDS`, +`BALE_SERVICE_TOKEN`. + +--- + +## How it's built + +``` +bot/ +├── Dockerfile python:3.13-slim + ffmpeg (voice conversion) +├── app/ +│ ├── main.py ensure tables → sweeper task → poll loop +│ ├── config.py pydantic-settings, PROCUREMENT_BOT_ prefix +│ ├── bale/client.py getUpdates/sendMessage/edit/callbacks/getFile+download +│ ├── api/client.py ERP /api/v1 client (X-Actor-Id attribution) +│ ├── stt.py ffmpeg (OGG/Opus → MP3) → /audio/transcriptions +│ ├── nlu.py Persian intent+entity extraction (JSON mode) +│ ├── resolve.py fuzzy name→id matching (difflib + ilike search) +│ ├── drafts.py draft store + QuoteCreate payload builder + missing-field logic +│ ├── keyboards.py inline keyboards (callback_data ≤ 64 B op-codes) +│ ├── texts.py Persian message templates +│ ├── runtime.py context, link store, in-memory pending flows +│ ├── models.py / db_init.py bot_links / bot_drafts / bot_messages / bot_meta (idempotent DDL) +│ └── handlers/ dispatch, commands, callbacks, links, +│ quote, supplier, material, queries, decisions +└── tools/smoke_test.py offline end-to-end harness (fake Bale/ERP/AI, in-memory store) +``` + +Design notes: + +- **Draft state lives in postgres** (`bot_drafts.payload` JSONB in the shape of + the eventual API request), so drafts survive bot restarts; short-lived + interactions (linking, quote picking, accept/arrive confirmations) are + in-memory with a 30-minute TTL. +- **Callback grammar** fits Bale's 64-byte `callback_data`: + `q:ok:{draft8}` (commit), `pk:{draft8}:{field}:{idx}` (pick candidate), + `ac:ts:{i}` (choose payment terms), `ar:full` / `ar:ok` (delivery)… +- **Persian handling**: Persian/Arabic digits are normalized before parsing; + fuzzy matching folds ZWNJ and Arabic ya/ka; all user-facing text is Persian. +- **Voice notes** arrive as OGG/Opus; ffmpeg converts to mono 16 kHz MP3 + before hitting the STT endpoint (accepted by essentially all providers). +- The **accept flow echoes the termsheet's existing `schedule_steps`** back to + `PATCH /quotes/{id}/accept` (the API replaces steps with what you send, so + the bot must send the current ones). + +### Attribution tweak in the api (backward compatible) + +`backend/app/utils/security.py::get_actor` now prefers an `X-Actor-Id` header +over the raw bearer token when attributing `quote_events.actor`. Requests +without that header behave exactly as before. + +--- + +## Testing + +Offline harness (no Bale token, no DB, no AI key — stubs everything and drives +the real handler pipeline with fake updates): + +```powershell +docker compose run --rm bot python tools/smoke_test.py +# or, with the requirements installed locally: +cd bot && python tools/smoke_test.py +``` + +Covers: linking, voice→draft, ambiguous-material pick buttons, unit fallback, +draft refinement, commit payload shape (temp_id cross-references), supplier / +material commits, status digest, accept payload (steps echoed, per-item price), +arrival quantities («۵۰۰ و ۲۰۰»), cancel and unknown-intent handling. + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| Bot silent | wrong `BALE_BOT_TOKEN`; check `docker compose logs bot` | +| «پردازش هوشمند تنظیم نشده است» | `AI_BASE_URL` / `AI_API_KEY` missing | +| Voice → «نتوانستم پیام صوتی را تبدیل کنم» | provider rejected the model name (`STT_MODEL`) or key | +| «ERP API unreachable» | api container not up / wrong `API_BASE_URL` | +| Draft disappears | TTL expiry (`DRAFT_TTL_HOURS`) or someone pressed ❌ / `/cancel` | diff --git a/ai procurement/git_history_backup.zip b/ai procurement/git_history_backup.zip new file mode 100644 index 0000000..9e1fc5f Binary files /dev/null and b/ai procurement/git_history_backup.zip differ diff --git a/ai procurement/integration/README.md b/ai procurement/integration/README.md new file mode 100644 index 0000000..cbe69b0 --- /dev/null +++ b/ai procurement/integration/README.md @@ -0,0 +1,44 @@ +# ERP integration pack + +The agent is a **standalone container** — it talks to the ERP exclusively over +HTTP and keeps its own state in its own `bot_*` tables. Integrating it into the +[ERP stack](https://git.bennu-api-bahrestan.ir/ali_mohaghegh/ERP) needs exactly +two things: + +## 1. Compose service + +Add [`erp-compose-service.yml`](erp-compose-service.yml) to the ERP's +`docker-compose.yml` (and the Coolify variant). The bot long-polls Bale, so it +needs **no ports, no webhook, no TLS** — it joins the compose `default` network +and reaches `http://api:8000/api/v1` in-network. + +Credentials/env are passed at compose level from a `.env` next to the compose +file: `BALE_BOT_TOKEN`, `AI_BASE_URL`, `AI_API_KEY`, `AI_LLM_MODEL` +(see [`../.env.example`](../.env.example) for the full list). + +> If your stack is started with multiple `-f` files (e.g. a prod_v2 checkout + +> port-remap override), keep using the same chain — the `bot` service merges in +> like any other service and the override's host-port remaps don't affect it. + +## 2. Per-user attribution in the API + +Apply [`x-actor-id.patch`](x-actor-id.patch) — a ~6-line, fully +backward-compatible change to `backend/app/utils/security.py`: + +- before: `get_actor` returns the raw `Authorization: Bearer …` string and + `quote_events.actor` stores the bot's token; +- after: an `X-Actor-Id: ` header (sent by the bot for every mutating + call, carrying the **linked ERP user's id**) takes precedence, so the audit + trail records the human who asked, not the bot. + +Requests without the header behave exactly as before. + +## What the agent does NOT touch + +- No ERP code is imported by the bot (HTTP-only, own postgres tables + `bot_links` / `bot_drafts` / `bot_messages` / `bot_meta`, created idempotently + at startup with `CREATE TABLE IF NOT EXISTS`). +- No Alembic migration needed — the bot owns its schema. +- The ERP's data model stays untouched; committed drafts flow through the + ordinary public endpoints (`POST /quotes`, `PATCH /quotes/{id}/accept`, + `PATCH /quotes/{id}/arrive-items`, `POST /suppliers`, `POST /materials`). diff --git a/ai procurement/integration/erp-compose-service.yml b/ai procurement/integration/erp-compose-service.yml new file mode 100644 index 0000000..02cd9eb --- /dev/null +++ b/ai procurement/integration/erp-compose-service.yml @@ -0,0 +1,39 @@ +# ========================================================================== +# Add this service to the ERP stack's docker-compose.yml (and mirror it in +# docker-compose.coolify.yml with `container_name: procurement-bot`). +# +# It expects THIS repository checked out (or vendored) next to the ERP repo +# as `./bot` — either `git clone … bot` inside the ERP checkout, or change +# `context:` to an absolute/relative path to this repo. +# +# In-container URLs are unaffected by host port remaps (postgres/api are +# reached over the compose network, not host ports). +# ========================================================================== +services: + bot: + build: + context: ./bot # ← path to this repository + dockerfile: Dockerfile + container_name: procurement-bot + + environment: + PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-} + PROCUREMENT_BOT_BALE_API_BASE: ${BALE_API_BASE:-https://tapi.bale.ai} + PROCUREMENT_BOT_API_BASE_URL: http://api:8000/api/v1 + PROCUREMENT_BOT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement + PROCUREMENT_BOT_AI_BASE_URL: ${AI_BASE_URL:-} + PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-} + PROCUREMENT_BOT_STT_MODEL: ${AI_STT_MODEL:-whisper-1} + PROCUREMENT_BOT_STT_BASE_URL: ${AI_STT_BASE_URL:-} + PROCUREMENT_BOT_STT_API_KEY: ${AI_STT_API_KEY:-} + PROCUREMENT_BOT_LLM_MODEL: ${AI_LLM_MODEL:-gpt-4o-mini} + PROCUREMENT_BOT_ADMIN_CHAT_IDS: ${BALE_ADMIN_CHAT_IDS:-} + PROCUREMENT_BOT_SERVICE_TOKEN: ${BALE_SERVICE_TOKEN:-} + + depends_on: + postgres: + condition: service_healthy + api: + condition: service_started + + restart: unless-stopped diff --git a/ai procurement/integration/x-actor-id.patch b/ai procurement/integration/x-actor-id.patch new file mode 100644 index 0000000..354b918 --- /dev/null +++ b/ai procurement/integration/x-actor-id.patch @@ -0,0 +1,18 @@ +diff --git a/backend/app/utils/security.py b/backend/app/utils/security.py +index a78c1cb..75e7349 100644 +--- a/backend/app/utils/security.py ++++ b/backend/app/utils/security.py +@@ -7,7 +7,13 @@ def get_actor(request: Request) -> str | None: + + This is the reserved placeholder from the contract — no authentication + or authorization is enforced yet. ++ ++ Services acting on behalf of a user (the Bale bot) pass ``X-Actor-Id`` ++ (users.id); it wins over the raw token so the audit trail shows the human. + """ ++ actor_id = request.headers.get("X-Actor-Id") ++ if actor_id: ++ return actor_id.strip() + auth = request.headers.get("Authorization") + if auth and auth.lower().startswith("bearer "): + return auth[7:].strip() or None diff --git a/ai procurement/procurement_ai_agent.bundle b/ai procurement/procurement_ai_agent.bundle new file mode 100644 index 0000000..362190d Binary files /dev/null and b/ai procurement/procurement_ai_agent.bundle differ diff --git a/ai procurement/requirements.txt b/ai procurement/requirements.txt new file mode 100644 index 0000000..30aba64 --- /dev/null +++ b/ai procurement/requirements.txt @@ -0,0 +1,5 @@ +httpx>=0.27.0 +sqlalchemy[asyncio]>=2.0.30 +asyncpg>=0.29.0 +pydantic>=2.7.0 +pydantic-settings>=2.2.0 diff --git a/ai procurement/tools/smoke_test.py b/ai procurement/tools/smoke_test.py new file mode 100644 index 0000000..941c5e6 --- /dev/null +++ b/ai procurement/tools/smoke_test.py @@ -0,0 +1,448 @@ +"""Offline smoke test for the Bale bot — no Bale network, no postgres, no AI keys. + +It stubs the Bale client, the ERP API client, STT and the NLU module, replaces +the draft store with an in-memory fake, then drives the real handler pipeline +end-to-end through fake updates (text, voice, inline-button callbacks). + +Run inside the bot container (or anywhere the requirements are installed): + + python tools/smoke_test.py + +Exit code 0 = all checks passed. +""" +import asyncio +import os +import sys +import types +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +# asyncpg is only needed when a real connection opens; stub it so app.db imports. +try: + import asyncpg # noqa: F401 +except ImportError: + sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg")) + +CHAT = 100200300 + +_passed = _failed = 0 + + +def check(name: str, ok: bool, detail: str = "") -> None: + global _passed, _failed + ok = bool(ok) + mark = "PASS" if ok else "FAIL" + print(f"[{mark}] {name}" + (f" — {detail}" if detail and not ok else "")) + _passed += ok + _failed += (not ok) + + +# --------------------------------------------------------------------------- +# fakes +# --------------------------------------------------------------------------- + +class FakeBale: + def __init__(self): + self.sent: list[dict] = [] + self.edited: list[dict] = [] + self.answered: list[str] = [] + self._mid = 0 + + async def send_message(self, chat_id, text, reply_markup=None, reply_to=None): + self._mid += 1 + self.sent.append({"chat_id": chat_id, "text": text, "reply_markup": reply_markup}) + return {"message_id": self._mid, "chat": {"id": chat_id}} + + async def edit_message_text(self, chat_id, message_id, text, reply_markup=None): + self.edited.append({"chat_id": chat_id, "message_id": message_id, "text": text, + "reply_markup": reply_markup}) + return {"message_id": message_id} + + async def answer_callback_query(self, callback_query_id, text=None): + self.answered.append(callback_query_id) + + async def send_chat_action(self, chat_id, action="typing"): + pass + + async def download_file(self, file_id): + return b"fake-ogg-bytes" + + def last_text(self) -> str: + return self.sent[-1]["text"] if self.sent else "" + + def last_markup(self) -> dict | None: + return self.sent[-1]["reply_markup"] if self.sent else None + + +class FakeApi: + """Canned ERP responses + captured mutation payloads.""" + + def __init__(self): + self.created_quotes: list[dict] = [] + self.created_suppliers: list[dict] = [] + self.created_materials: list[dict] = [] + self.accept_payloads: list[dict] = [] + self.arrive_payloads: list[dict] = [] + self.login_calls: list[dict] = [] + + async def login(self, username, password): + self.login_calls.append({"username": username, "password": password}) + if username == "mohaghegh" and password == "secret": + return {"access_token": "jwt", "user": {"id": "user-1", "first_name": "علی", + "last_name": "محقق", "role": "procurement_manager"}} + raise RuntimeError("invalid credentials") + + async def list_suppliers(self, q=None, page_size=20): + return [{"id": "sup-1", "name": "سازان نو", "category_id": "cat-2"}] + + async def create_supplier(self, payload, actor_id=None): + self.created_suppliers.append(payload) + return {"id": "sup-new", "name": payload["name"], + "category": {"id": payload["category_id"], "name": "لوله و اتصالات"}} + + async def list_materials(self, q=None, page_size=20): + return [ + {"id": "mat-1", "name": "میلگرد A3 سایز ۱۴", "canonical_unit_id": 3}, + {"id": "mat-2", "name": "میلگرد A3 سایز ۱۶", "canonical_unit_id": 3}, + {"id": "mat-3", "name": "شیر فلکه چهارطرفه", "canonical_unit_id": 2}, + ] + + async def create_material(self, payload, actor_id=None): + self.created_materials.append(payload) + return {"id": "mat-new", "name": payload["name"]} + + async def list_units(self, q=None, page_size=100): + return [{"id": 2, "code": "adad", "name_fa": "عدد"}, + {"id": 3, "code": "kg", "name_fa": "کیلوگرم"}] + + async def list_projects(self, page_size=100): + return [{"id": "prj-1", "name": "برج آسمان"}] + + async def list_categories(self, page_size=100): + return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"}, + {"id": "cat-2", "name": "لوله و اتصالات"}] + + def _expanded(self, quote_id, status): + """Realistic GET /quotes?expand=line_items,termsheets,supplier shape.""" + return { + "id": quote_id, "supplier": {"name": "سازان نو"}, "project_id": None, "status": status, + "quote_date": "2026-09-10T00:00:00Z", "expiry_date": "2026-09-20", + "line_items": [ + {"id": "li-1", "material": {"name": "میلگرد A3 سایز ۱۴"}, "unit": {"name_fa": "کیلوگرم"}, + "quantity": 500, "unit_price": 45000, "projects": [], + "decision": {"status": "chosen"} if status == "accepted" else None, "receipt": None}, + {"id": "li-2", "material": {"name": "شیر فلکه چهارطرفه"}, "unit": {"name_fa": "عدد"}, + "quantity": 200, "unit_price": 90000, "projects": [], + "decision": {"status": "chosen"} if status == "accepted" else None, "receipt": None}, + ], + "termsheets": [{"id": "ts-1", "label": "۳ فاز", "discount_type": "none", "discount_value": 0, + "schedule_steps": [{"step_no": 1, "due_date": "2026-10-01", "amount": 1000}]}], + } + + async def list_quotes(self, *, status=None, q=None, expand=None, sort=None, page=1, page_size=10): + if status == "offered": + return [self._expanded("q-aaaa1111", "offered")], {"total": 1} + if status == "accepted": + return [self._expanded("q-bbbb2222", "accepted")], {"total": 1} + return [], {"total": 0} + + async def get_quote(self, quote_id, expand=None, actor_id=None): + return self._expanded(quote_id, "accepted" if quote_id.startswith("q-bbbb") else "offered") + + async def create_quote(self, payload, actor_id=None): + self.created_quotes.append(payload) + return {"id": "q-new", "supplier": {"name": "سازان نو"}, + "line_items": payload.get("line_items") or [], + "termsheets": payload.get("termsheets") or []} + + async def accept_quote(self, quote_id, payload, actor_id=None): + self.accept_payloads.append({"quote_id": quote_id, "payload": payload}) + return {"id": quote_id, "status": "accepted", "line_items": [{"id": "li-1", "decision": {}}]} + + async def arrive_items(self, quote_id, payload, actor_id=None): + self.arrive_payloads.append({"quote_id": quote_id, "payload": payload}) + return {"id": quote_id, "status": "arrived", + "items": [{"line_item_id": it["line_item_id"], "received_qty": float(it["received_qty"])} + for it in payload["items"]]} + + +# --------------------------------------------------------------------------- +# in-memory draft store + link store +# --------------------------------------------------------------------------- + +MEM: dict = {"drafts": {}, "links": {}} + + +def _install_fake_stores() -> None: + import uuid as _uuid + + import app.drafts as drafts + import app.runtime as runtime + + async def create(chat_id, kind, payload, source=None): + draft_id = _uuid.uuid4() + draft = types.SimpleNamespace(id=str(draft_id), chat_id=chat_id, kind=kind, payload=payload, + missing=None, status="active", source=source, + review_message_id=None) + MEM["drafts"][str(draft_id)] = draft + return draft + + async def get_active(chat_id, kind=None): + found = [d for d in MEM["drafts"].values() + if d.chat_id == chat_id and d.status == "active" and (kind is None or d.kind == kind)] + return max(found, key=lambda d: d.id) if found else None + + async def get_by_prefix(prefix, chat_id=None): + for draft in MEM["drafts"].values(): + if draft.id.startswith(prefix) and (chat_id is None or draft.chat_id == chat_id): + return draft + return None + + async def save_payload(draft, missing=None, review_message_id=None): + draft.missing = missing + if review_message_id: + draft.review_message_id = review_message_id + + async def set_status(draft_id, status): + if draft_id in MEM["drafts"]: + MEM["drafts"][draft_id].status = status + + async def expire_stale(ttl_hours): + return 0 + + drafts.create, drafts.get_active, drafts.get_by_prefix = create, get_active, get_by_prefix + drafts.save_payload, drafts.set_status, drafts.expire_stale = save_payload, set_status, expire_stale + + async def link_get(self, chat_id): + return MEM["links"].get(chat_id) + + async def link_upsert(self, chat_id, user_id, username, display_name, role): + MEM["links"][chat_id] = types.SimpleNamespace(chat_id=chat_id, user_id=user_id, + casdoor_username=username, + display_name=display_name, role=role) + return MEM["links"][chat_id] + + async def link_delete(self, chat_id): + return MEM["links"].pop(chat_id, None) is not None + + runtime.LinkStore.get, runtime.LinkStore.upsert, runtime.LinkStore.delete = link_get, link_upsert, link_delete + + +async def fake_log(self, chat_id, direction, kind, body=None): + pass + + +NLU_QUEUE: list[dict] = [] + + +async def fake_understand(settings, text, draft_summary=None): + return NLU_QUEUE.pop(0) if NLU_QUEUE else {"intent": "unknown", "patch": {}, "confidence": 0} + + +async def fake_transcribe(settings, audio, mime_type=None, filename=None): + return ("سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن " + "پرداخت سه فاز") + + +# --------------------------------------------------------------------------- +# driver +# --------------------------------------------------------------------------- + +def build_ctx(): + import app.handlers as H + from app.resolve import Resolver + from app.runtime import BotContext + + bale, api = FakeBale(), FakeApi() + ctx = BotContext(bale, api) + ctx.bale = bale + ctx.api = api + ctx.resolver = Resolver(api) + ctx._log = types.MethodType(fake_log, ctx) + H.understand = fake_understand + H.transcribe = fake_transcribe + return ctx, bale, api + + +def text_update(update_id, text): + return {"update_id": update_id, "message": {"chat": {"id": CHAT}, "text": text, "from": {"id": CHAT}}} + + +def voice_update(update_id): + return {"update_id": update_id, + "message": {"chat": {"id": CHAT}, "voice": {"file_id": "v1", "mime_type": "audio/ogg", "duration": 6}}} + + +def callback_update(update_id, data): + return {"update_id": update_id, "callback_query": {"id": f"cb{update_id}", "data": data, + "message": {"chat": {"id": CHAT}}}} + + +async def main() -> int: + _install_fake_stores() + from app.drafts import build_quote_request, compute_quote_missing, merge_quote_patch, new_quote_payload + import app.handlers as H + from app.nlu import _extract_json, normalize_digits + + # ---- pure logic ------------------------------------------------------- + check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو") + check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1}) + + payload = new_quote_payload() + merge_quote_patch(payload, { + "supplier_name": "سازان نو", + "items": [{"material_name": "میلگرد A3", "quantity": 500, "unit_price": 45000}], + "payment_terms": [{"label": "۳ فاز"}], + }) + merge_quote_patch(payload, {"items": [{"material_name": "میلگرد A3", "quantity": 600}]}) + check("merge: no dup item", len(payload["items"]) == 1) + check("merge: qty updated", payload["items"][0]["quantity"] == 600) + check("merge: terms added", payload["termsheets"][0]["label"] == "۳ فاز") + missing = compute_quote_missing(payload) + check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing)) + body, problems = build_quote_request(payload) + check("build: blocked without supplier", body is None) + + # ---- pipeline: linking gate ------------------------------------------- + ctx, bale, api = build_ctx() + await H.handle_update(ctx, text_update(1, "سلام")) + check("gate: unlinked asked username", "نام کاربری" in bale.last_text()) + + await H.handle_update(ctx, text_update(2, "mohaghegh")) + await H.handle_update(ctx, text_update(3, "secret")) + check("link: login called", api.login_calls and api.login_calls[0]["username"] == "mohaghegh") + check("link: linked greeting", "اتصال انجام شد" in bale.last_text()) + + # ---- voice → draft quote ---------------------------------------------- + NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": { + "supplier_name": "سازان نو", "project_name": "برج آسمان", + "items": [{"material_name": "میلگرد", "quantity": 500, "unit_name": "کیلوگرم", + "unit_price": 45000}], + "payment_terms": [{"label": "۳ فاز", "description": "سه قسط"}], + }}) + await H.handle_update(ctx, voice_update(4)) + draft = await _active("quote") + check("voice: draft created", draft is not None) + check("voice: supplier resolved", draft.payload.get("supplier_id") == "sup-1") + check("voice: project resolved", draft.payload.get("project_id") == "prj-1") + item = (draft.payload.get("items") or [{}])[0] + check("voice: material ambiguous → pick buttons", "mat1" in (draft.payload.get("options") or {})) + check("voice: review shows missing list", "وضعیت فیلدها" in bale.last_text()) + check("voice: review mentions unit", "کیلوگرم" in bale.last_text()) + + # pick the material via inline button + markup = bale.last_markup() or {} + pick_data = _first_callback(markup, "pk:") + check("voice: pick callback present", pick_data is not None and ":mat1:0" in pick_data) + await H.handle_update(ctx, callback_update(5, pick_data)) + item = (draft.payload.get("items") or [{}])[0] + check("pick: material resolved", item.get("material_id") == "mat-1") + check("pick: unit resolved from spoken name", str(item.get("unit_id")) == "3" and item.get("unit_explicit") is True) + + # refine price via follow-up text + NLU_QUEUE.append({"intent": "update_draft", "confidence": 0.8, "patch": { + "items": [{"material_name": "میلگرد", "unit_price": 47000}]}}) + await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد")) + draft = await _active("quote") + check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000) + + # commit via ✅ + await H.handle_update(ctx, callback_update(7, f"q:ok:{draft.id[:8]}")) + check("commit: quote posted", len(api.created_quotes) == 1) + body = api.created_quotes[0] + check("commit: supplier id", body.get("supplier_id") == "sup-1") + check("commit: line item temp/refs", body["line_items"][0]["temp_id"] == "t1" + and body["item_termsheets"][0]["line_item_temp_id"] == "t1") + check("commit: price linked", body["item_termsheets"][0]["unit_price"] == 47000) + check("commit: termsheet carried", body["termsheets"][0]["label"] == "۳ فاز") + check("commit: report sent", "ثبت شد" in bale.last_text()) + check("commit: draft committed", (await _active("quote")) is None) + + # ---- add supplier ------------------------------------------------------ + NLU_QUEUE.append({"intent": "add_supplier", "confidence": 0.9, "patch": { + "supplier": {"name": "پروفیل بر", "category_name": "لوله و اتصالات", "phone": "0912"}}}) + await H.handle_update(ctx, text_update(8, "تأمین‌کننده جدید پروفیل بر دسته لوله و اتصالات تلفن 0912")) + draft = await _active("supplier") + check("supplier: draft created", draft is not None) + check("supplier: category resolved", draft.payload.get("category_id") == "cat-2") + await H.handle_update(ctx, callback_update(9, f"s:ok:{draft.id[:8]}")) + check("supplier: posted", api.created_suppliers and api.created_suppliers[0]["name"] == "پروفیل بر") + check("supplier: report", "تأمین‌کننده ثبت شد" in bale.last_text()) + + # ---- status query ------------------------------------------------------ + NLU_QUEUE.append({"intent": "status_query", "confidence": 0.9, + "patch": {"query": {"kind": "pending"}}}) + await H.handle_update(ctx, text_update(10, "استعلام‌های در انتظار چیه؟")) + check("query: pending digest", "در انتظار بررسی" in bale.last_text() and "#q-aaaa11" in bale.last_text()) + + # ---- accept flow ------------------------------------------------------- + NLU_QUEUE.append({"intent": "accept_quote", "confidence": 0.9, + "patch": {"target": {"supplier_name": "سازان نو"}}}) + await H.handle_update(ctx, text_update(11, "استعلام سازان نو را قبول کن")) + check("accept: summary shown", "پذیرش استعلام" in bale.last_text()) + flow = ctx.pending.get(CHAT) + check("accept: pending flow", flow is not None and flow.kind == "accept") + await H.handle_update(ctx, callback_update(12, "ac:ok")) + check("accept: payload posted", len(api.accept_payloads) == 1) + payload = api.accept_payloads[0]["payload"] + check("accept: termsheet echoed with steps", + payload["accepted_termsheets"][0]["id"] == "ts-1" and payload["accepted_termsheets"][0]["schedule_steps"]) + check("accept: decision price", payload["item_decisions"][0]["adjusted_unit_price"] == 45000) + check("accept: decision index", payload["item_decisions"][0]["accepted_termsheet_index"] == 0) + check("accept: report", "پذیرش انجام شد" in bale.last_text()) + + # ---- arrive flow ------------------------------------------------------- + NLU_QUEUE.append({"intent": "arrive_items", "confidence": 0.9, + "patch": {"target": {"supplier_name": "سازان نو"}}}) + await H.handle_update(ctx, text_update(13, "اقلام سازان نو رسید")) + check("arrive: items shown", "اعلام رسیدن" in bale.last_text()) + await H.handle_update(ctx, text_update(14, "۵۰۰ و ۲۰۰")) + flow = ctx.pending.get(CHAT) + check("arrive: quantities parsed", flow is not None and flow.kind == "arrive_confirm" + and flow.data["quantities"] == {"li-1": 500.0, "li-2": 200.0}) + await H.handle_update(ctx, callback_update(15, "ar:ok")) + check("arrive: payload posted", len(api.arrive_payloads) == 1 + and api.arrive_payloads[0]["payload"]["items"][0]["received_qty"] == 500) + check("arrive: report", "ثبت رسید انجام شد" in bale.last_text()) + + # ---- add material ------------------------------------------------------ + NLU_QUEUE.append({"intent": "add_material", "confidence": 0.9, + "patch": {"material": {"name": "میلگرد A3 سایز ۱۸", "unit_name": "کیلوگرم"}}}) + await H.handle_update(ctx, text_update(16, "کالای جدید میلگرد A3 سایز ۱۸ با واحد کیلوگرم")) + draft = await _active("material") + check("material: draft created", draft is not None) + check("material: unit resolved", str(draft.payload.get("canonical_unit_id")) == "3") + await H.handle_update(ctx, callback_update(17, f"m:ok:{draft.id[:8]}")) + check("material: posted", api.created_materials + and api.created_materials[0]["canonical_unit_id"] == 3) + + # ---- cancel / unknown -------------------------------------------------- + NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {"supplier_name": "سازان نو"}}) + await H.handle_update(ctx, text_update(18, "استعلام از سازان نو")) + await H.handle_update(ctx, text_update(19, "/cancel")) + check("cancel: draft dropped", (await _active("quote")) is None and "لغو شد" in bale.last_text()) + + NLU_QUEUE.append({"intent": "unknown", "confidence": 0.0, "patch": {}}) + await H.handle_update(ctx, text_update(20, "اسپاگتی بپز")) + check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text()) + + print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed") + return 1 if _failed else 0 + + +async def _active(kind): + from app import drafts as draft_store + return await draft_store.get_active(CHAT, kind) + + +def _first_callback(markup: dict | None, prefix: str) -> str | None: + for row in (markup or {}).get("inline_keyboard") or []: + for btn in row: + if btn.get("callback_data", "").startswith(prefix): + return btn["callback_data"] + return None + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/tests/deepseek_output.json b/tests/deepseek_output.json new file mode 100644 index 0000000..c7bddf8 --- /dev/null +++ b/tests/deepseek_output.json @@ -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 +} \ No newline at end of file diff --git a/tests/test_live_deepseek.py b/tests/test_live_deepseek.py new file mode 100644 index 0000000..2fe7ce1 --- /dev/null +++ b/tests/test_live_deepseek.py @@ -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) diff --git a/tests/test_wizard_conversion.py b/tests/test_wizard_conversion.py new file mode 100644 index 0000000..a451956 --- /dev/null +++ b/tests/test_wizard_conversion.py @@ -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() diff --git a/tests/wizard_mock_data.json b/tests/wizard_mock_data.json new file mode 100644 index 0000000..7cf0690 --- /dev/null +++ b/tests/wizard_mock_data.json @@ -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" + } +} diff --git a/tests/wizard_template.json b/tests/wizard_template.json new file mode 100644 index 0000000..2523fda --- /dev/null +++ b/tests/wizard_template.json @@ -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 + } +}