From 9e19be83540b8475351f40b4c9a5ffd5acfb4723 Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Mon, 21 Sep 2026 20:23:13 +0330 Subject: [PATCH] feat(agent): integrate procurement AI agent with Bale bot, real-time draft sync, and git history bundle --- ai procurement/.env.example | 28 ++ ai procurement/.gitignore | 14 + ai procurement/Dockerfile | 23 + ai procurement/README.md | 120 +++++ ai procurement/app/__init__.py | 2 + ai procurement/app/api/__init__.py | 1 + ai procurement/app/api/client.py | 137 ++++++ ai procurement/app/bale/__init__.py | 1 + ai procurement/app/bale/client.py | 175 +++++++ ai procurement/app/config.py | 77 +++ ai procurement/app/db.py | 7 + ai procurement/app/db_init.py | 77 +++ ai procurement/app/drafts.py | 443 +++++++++++++++++ ai procurement/app/handlers/__init__.py | 192 ++++++++ ai procurement/app/handlers/callbacks.py | 107 +++++ ai procurement/app/handlers/commands.py | 82 ++++ ai procurement/app/handlers/decisions.py | 389 +++++++++++++++ ai procurement/app/handlers/links.py | 46 ++ ai procurement/app/handlers/material.py | 98 ++++ ai procurement/app/handlers/queries.py | 44 ++ ai procurement/app/handlers/quote.py | 280 +++++++++++ ai procurement/app/handlers/supplier.py | 103 ++++ ai procurement/app/keyboards.py | 74 +++ ai procurement/app/main.py | 60 +++ ai procurement/app/models.py | 80 ++++ ai procurement/app/nlu.py | 149 ++++++ ai procurement/app/resolve.py | 151 ++++++ ai procurement/app/runtime.py | 159 +++++++ ai procurement/app/stt.py | 62 +++ ai procurement/app/sync.py | 59 +++ ai procurement/app/texts.py | 254 ++++++++++ ai procurement/docs/SETUP.md | 172 +++++++ ai procurement/git_history_backup.zip | Bin 0 -> 125146 bytes ai procurement/integration/README.md | 44 ++ .../integration/erp-compose-service.yml | 39 ++ ai procurement/integration/x-actor-id.patch | 18 + ai procurement/procurement_ai_agent.bundle | Bin 0 -> 73247 bytes ai procurement/requirements.txt | 5 + ai procurement/tools/smoke_test.py | 448 ++++++++++++++++++ tests/deepseek_output.json | 32 ++ tests/test_live_deepseek.py | 71 +++ tests/test_wizard_conversion.py | 123 +++++ tests/wizard_mock_data.json | 86 ++++ tests/wizard_template.json | 50 ++ 44 files changed, 4582 insertions(+) create mode 100644 ai procurement/.env.example create mode 100644 ai procurement/.gitignore create mode 100644 ai procurement/Dockerfile create mode 100644 ai procurement/README.md create mode 100644 ai procurement/app/__init__.py create mode 100644 ai procurement/app/api/__init__.py create mode 100644 ai procurement/app/api/client.py create mode 100644 ai procurement/app/bale/__init__.py create mode 100644 ai procurement/app/bale/client.py create mode 100644 ai procurement/app/config.py create mode 100644 ai procurement/app/db.py create mode 100644 ai procurement/app/db_init.py create mode 100644 ai procurement/app/drafts.py create mode 100644 ai procurement/app/handlers/__init__.py create mode 100644 ai procurement/app/handlers/callbacks.py create mode 100644 ai procurement/app/handlers/commands.py create mode 100644 ai procurement/app/handlers/decisions.py create mode 100644 ai procurement/app/handlers/links.py create mode 100644 ai procurement/app/handlers/material.py create mode 100644 ai procurement/app/handlers/queries.py create mode 100644 ai procurement/app/handlers/quote.py create mode 100644 ai procurement/app/handlers/supplier.py create mode 100644 ai procurement/app/keyboards.py create mode 100644 ai procurement/app/main.py create mode 100644 ai procurement/app/models.py create mode 100644 ai procurement/app/nlu.py create mode 100644 ai procurement/app/resolve.py create mode 100644 ai procurement/app/runtime.py create mode 100644 ai procurement/app/stt.py create mode 100644 ai procurement/app/sync.py create mode 100644 ai procurement/app/texts.py create mode 100644 ai procurement/docs/SETUP.md create mode 100644 ai procurement/git_history_backup.zip create mode 100644 ai procurement/integration/README.md create mode 100644 ai procurement/integration/erp-compose-service.yml create mode 100644 ai procurement/integration/x-actor-id.patch create mode 100644 ai procurement/procurement_ai_agent.bundle create mode 100644 ai procurement/requirements.txt create mode 100644 ai procurement/tools/smoke_test.py create mode 100644 tests/deepseek_output.json create mode 100644 tests/test_live_deepseek.py create mode 100644 tests/test_wizard_conversion.py create mode 100644 tests/wizard_mock_data.json create mode 100644 tests/wizard_template.json 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 0000000000000000000000000000000000000000..9e1fc5f6e758dcd53349847585e1dccec97255de GIT binary patch literal 125146 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 tfz;QVhzoYWc>8Wh$gbWT_+o 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­VAueg`W}8w^g;5DXMdC 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..362190d65d90e53dbb3f86ef4d59975591a506a6 GIT binary patch literal 73247 zcmbrFQ>-X3*QU2^+qQK!&bDpawr$(CZQHhO+nV$J^Jg*_Gsz@#(aW|gP2cr&y={pJ zT$l(<&7BAgoNbJ(jG@>JSeO{u4LOWh7}$;3*x5N*SWTE%8B7@2S=bFsObj?on2nj3 zjhG1>j7=Qr&5ZSp9O^21wXut>(_-)T3x^<#*qEo3SJ%<{Ora57Y3Ag~N?$C8MOnQz`k^DpObvpKetu zmfmlQVHA0ksOWwy?Si-@aYTD>$3M3W+siAbvQ2;AbT1%MAR=-uIiW8kJuwMdX`nqK z3{%MmHKp*X4C`^@3vhSQ3?Wg~Eh||`+7_D~rR!3)OY>T#b3vkkH>~$V$VqJs{-i`R?kTrt%A4#3D)A1sX)zt2e5Eh1K3)#2xTfQ>K}BO7VT+>S&o!NO`1YeXg^md7jX+U{W8%Zgvk&T)$_!@T>fFOOH#^ryM@1)?_+Jnt&oZ^sy}R7MocJuZ zU`FB|y&U+{8U=NKKT_9jBD?zzwiuYm-bpM?Qc>e(Zv5RHY8h`3WCKR!Pid;`uj4>v zX*`%Vcw=#r#efjH6|K$+T+Yu=k?n%I!V+BLO>MK7J2-(Dqon0H_eUvwZ)Eym+lw7- zxbXoWJotnxTzDR&z(Z|Ogd3(J_Hzq2((wzAtsD-C71D=g>ncd#dN`1&h(fzlwD3Fw z31XV>WN;&O{?b|TpF&l@J|qwyW%n;w?^kik#G^dW6{Sg2(F7{HmB_-ka&Wk#<>nC! zDRU*prq=r1D?p@cQ8ZC`v%P88m3~il^l`i`KyDgXhyG}RpJ6(%VkfZK zqp8CIrWQSoDq6voiut0|j&l0`-A)Rq2^})~&0_u-@)SckuabK~#Bvdgl`N(`phRgu z5rSqS2@S8ES;fHkI*9(O?Xw>J^L?s84AwF8Zs;h)WuLv%?F%wR&}n5Bj*)UYDOtbx zgVX4GD-JdzE;@IBp@v~#Mh_zZp7CZn$2JQHNny2BFM2O)FH{tX#?-n3c+imL1?+vd zy_RA1ABj!I5VN9euc~h0ZWD=|~}^31dB= zUyF)UtIzEH+nq_jzqSk?v{olE(Zl;RC5xkKK)o+LH_uD?!cY= z>_UeAxNOyxR<3>=WQu(5M5nTue6+sqlB_OCISAr~A97AYk2oE~jD=O|*9~@dUq=QF(BBQcKA5_{)ExJ_+g2BlI{=-u`uL%$kV3??>V#TC%6*q%RdmGHMuxR{P`xp zF@v`{zQ0VTlhEtX008TKYQ2*oIE3OI+NTEu#yfzU{7Zd z{XIu9tHFCM%?|KG`lW(nV}AfrX#I+^vUr5XBA@O{b$MRZKNo3<9&hQnxT_=QHRU=Mmx z*TP!je9L}{NY*k6oNBap8{IN;YnsO(TCA_9892x6BO87wJz%sUnQ?jUcFeYBIryzl z@AzQ+;+l83&DSrpj{CenOUD%4Y6-Qix}-b79Qg81vh>eJ>nwodg8=`f0A=Y|Y+;1% zPt_!ce#t9b-yzFd;IKG^lSoU#$-~}<}#l`Q7;nNz=h~HzvF(g&f?{#C@wg)5DmG z!3{2tr!zcnxizim(8GScr~3*yGQKIRdqmjM#>0C%7VMpZ%)ooMxqb_MfxTbXibf;U zj>)Z@&p=M&maT?IDhy2pt*dyp9tA?pL@HK7$W^74r2Iwos&tFlnqH-JO4X!Fu>RVf zH-8KQX|+j%z7nfyAXKfQx2tp>XXTtlNFYq*7p~yE+gG2Dqaz-CLm;@g;|djO-ZWP( z?+v>|jpHW1stnF_CU(+Z^&&^)zX?o!rBs`96XgYRaCH#9Ed8!wyz-pDzh^3Z)UO4Y;z!v{7EK+_w!=v z_;PIT*W;<{`10(of_|6iozD^=inr3($DD1lvz9t~kZa0n2W#Zmxt=aS?lM|CCayG< zgAj=4_YTi$mxDnG)Q^YusTf@XD!@$|H1qquV>Mq zhLRewM$A{@dnVnv&gbn#<*i|R{rG5VH7n-f{<#6-uIBhB5$9T7`5GjlIqy3i4N@_* z;(~+(xn(Yo7uszDCh+h&Rw$f}E;x`3JAVnC98nSFx@udql|xTU1w+!(0v|R`GIoTM zs=IaJXmPou^4+wAyRS`lN%ce_ys&1FR`u?MvJye*R!tw$`_MYU#)n?<0UE#j1dkkL zAz=6*GoD>oPo)-n14uHdsdUQYS4WGu_PO_iwPy2daf+v$!4#UWOgm$q^{KZq4DSP06WtBu!D^=Sjtixy!i7afD`4smiX zc}#M$j*pX7%B)>6>l89@IHz$x?T{XJtOt&nyMVk5=ELZ?%bUBCYTkGb2T+h1bf0M) z2GwCX`GCYfk)7}_;pAy3^qK;lla%`}#_!_D(Kd_JX7y zOvUV_X-Y;F_n2Uc*TnmyP(S8E4(r9I^ToXk+Ezy~pH1sh!r`aclak!AYi20Ro@hz6 z%egi6gO^s|ja=Hz(H{^zolXhRQS|29(BMqAjiqng`1=EIdpex{95~gxxqa;2H(yMos{slf%JqH)2q1zuP zOulyvE7{Hp2=?J84+FMQ@Fo`Sqx(mXJC2yL;feqo)qnu(oxh%s1`Jj{wA}gWY~J%p z#k_gUV|_>GH(Q)C>E@`#E7K!%P=4hS-t*JRmVWXDtBqC};_Fw36$&k%GMgg67FV7t z4t~WxGvedRi$A7&xzrC;WI?nU_N!(PyF`Mf2{mD)c_Bf&@ZM>H#YIBq4nLH$F`r20 zJS|&wAg(s;(R=_B!uxmp9viP+D?4p89xhB2|kGPfz8B6mL7>@QK zrN-CW+1~N}qOv!KUY90!@jJ@nbCn)21fjYdg)q0_H@5Ehb~4wNF12@lIx@5JGBvw1 z1vnQ}At%bYcHP?XtIz<6R!loA%v@_)5MKECq4V%bHla%2~+s%Y$Lqqq=3mXZGQT_D1JE0wWC|KS~P#eJ|VuA!QO(&?xiY!52W>YJ<%*@#3ulSbTknyemOuOb0X~P^k_7%NiWM%5_1E6+P<^Wz|gM^a$-~ z4z4HNPnH5T$nGGWQe+rXa54{h(3*-iX|40psd<(A$?+GWkkKFifw7LfB|j;BOY>T` zSu~P!@^Z2n>&|%C2vf{)jcfNO#H=vpG+9&@3e3o)?FpU!3#TqkkjjM4%?TBxpa@2O zGw`7OQ3j#MYM=T80rlF#2_PcQXTvZG2|NZe3rK=-S2g{y)@D!Uis&2C*lJ(6kh4da zY+IE(m*1v>$|)5X*IM0HcT2;a5x%y@9pYT`|5RX`HrVV4-n+FJXX7G9;+EPp>(cX0 zfJo>rR(&^(38e{T)xKV`z;Nca69Cf z^a1j>IR=GhSmNyFCMT;ETsv->h@Bao%^O3j?*<~I_1HVbr!?m@3P;qj8DGY{c#D7)rUrNfblO@iP>2S12o zZZH&n5quR)KtowR4mhK{7Z4sZK6SR>fa7`CI-lPlPvH}RKZxHAgjBDzeng?CAT?I^ z{KsBK6qlN6a%3D&d`6&CPyv*8)amI8DISYEG*Ny-reJ$y0vg>=W17Mb+jXaYrDMZk zKKXCPXlSYFhv6vyPnL^b3Bm@*akHdFXb+X*w8^eOTPvv|s0rBb*h2T0gsZH0Wla< zITR$e3t{|_k_T-2g`$PY-MwpKFa+3GN|<7fAgg~^iOnCxYJYy}m_-sVTJO&BdfWrT z(X;|T`!_^AGq&;3vd7EC!P3Yj>LM7pStEhjK6II~Rg!A#&~!%YMNCeoy-Bq6PhS9&nLt0kKi5DPlLCiOS$#KOH%YUHyV9gK4iur3oiUdO%iE~+P9wNe1RB5JC7?ICWoRX3plPE5xG@1mInoc zL#JEX6scTyA=7MZB=mr9#Ac{dWSAn`wiVs}!kx>o`36=$*qyYV$F9YQkUtz}+l%zA zt>Z%h`l6;+iGsFff3CW&PyMRVHGBc&8N?F0Q3?}m=h=tgFuZIv6$vB*(%d@4D~~@r zsOm!#{h`)9VRTj7PH(UKUh3%YdhkP33k@-1{eHMY#R)meGy56axSR+PD8`|{WgC0} znXM!!(S7QhE}D~ zf-Ads-P!C)$levW@z_Fp`ebq$NJqBPLSxL@RH0zkQ@JVVJRqQV%MZTlrO&QBndYxdA}26G%}fJ5iti|8Ibyb zSX|p#_lQtxW&y^aiU=3~D!CM?Ykv zmfV0`w4jWdDNRuxS)C6bm>O6hq`3`NM;dSv9}XLTO_&U}X9*#;$RiSIj3F8*+JV(y zK$t30s$DagQ((TL9cICLj1NT#zT^uS16JaecYC32UFgF|5I*M){^IPfWCDH(uta0kdM+tX+9KC<%#BxHS|~RNu%E~`QKUW4M2^M` zq~f8(*Qse>o(MmJyc1@|*dlN|8EG9@c4xhmpoHocCOtIu+LwmGCZAPKH>Lf{l;my9 z+t+PYUN28Fr`awU<0R`e%9AVXwNs}n+US+4FB~*kxajNGjy6rgHQ0v}m~a#>fw_6Q zgKxQP>uDyqdw=y;^Xs9HJ8$#VY+H=&dv&b$lyy?vM>?~`!+&-))dkaRESj7?0dmI{ z==9i6H}De6Y5TF0@N@S@cYVQ(VJF8L%oB4w@hO3RUh!6^XPtKt^#X#r)x};xGcbkV z%USO?$XD@_&gl<+P!1{F=m9 zN2%r=4aBvuk?n4Xl(Sc#K1yX}Z$X{Gb{HMv*g%#<)Dc5f_4h*8JauS zk)I19v<+R=^3qyM>t8UQs$pJ$X6+$BVvIaePKhKCu<*I0#JRjMrPh2sZ7hjg<=0-T z(wOU}9&wZ=lZ1&KxfIHJ(Dh|}d3pjfv3CNSvMO0#$ z1_SxUS2#l)K9tc)9eT8`#p6h5lb?Hh)2gBcW%u<8;Gx}S>lCH@Ukk#Kpm$l~k^Xr% zebVkHkivy-027)oWQ5pr$oIn;L~h~RmtGAwk8}1fo<{p;A_#`nyW$ARSRMTZc{rMg z)MHVAqA>w$42ihx!CrJtg;LHBDmSHHnC-KiW8CY|tMAW^Mf=l-o$$Wg0+ZY&*hF3> z;5PX{BQH2Plt_a$sHplr=c9m$^b*k- zu}KFac9iW`)vfJ43u=oOF{y37O@j(_=FgYEE87$CT5Cj2uY3*$a(qWk>aj2D4yO?C{ESBQ{jz zn$m>H^RFmn)&ygBy3^w}6=TX-Qv*8D;Io4f_m0la5?w6Qp`(B1UbL?!yFg~X({!y9 z7xii4;{8=u(c!!i6II>D8OHl>HkAcheW~^J@H}WU-B9!CU)s++K_lvRS&>b4sCjoX zebo0Ek!=QXQ{vw+;rfleL9Tm8O>D8zI53o>5N!V6uV;495C9^kMq<^{z@;Ik}IMY)k{AT#*-jl;N@%Q}9@TaCG7h>3oL z!<7xZYyM<()-szc`F&s#dRU8V!J_I%+l?AREux)4wdeEK)6h>zi{TlL_%UPJ0g5x6Wm3;BMgo{R2;%zVUDRH9aJbQ|Wyq_n|fC}DZSZ|eseM#y4=SHf`hXmB8G zJ9xA(LhN*vYd0>^u2&;6SB6oBD@%_Jnv0eX9$duP{~dGng(q#EPm z7{F7Ll)G?d0Xu=4eEx``@N!WpwmIEf?5Z^_^MaPO!dK0Quh`dII|5Z_iNE6GE5V6+ zcL;CgHXfHq|X$bTcB7=j;`E4Rdl+ zQwp4MTzJ0#*#L^<_PEAnY0y5h42wwE9ERWFiHWm22{qcT(QF$9acF8(+Yl}e+#HbI zPu-hwHOZWP{gELn62ziFO}={bD)cEi3x-t><9h6)&ZCyOg~@?BzDnf)5kd-FmJ}l^ zj(FpXRO}^YKKHeqY3&yfXzh;{;3!yCPL04r`vqd7hRVuN_$ST8ew#x!gpga0i1y|4 zjR+dUM5`8x3lb4ME2PePbTfIBkuWUrAj$+nO7foPfBgF-`IDq!=zmj;&X2;u{QEmy zyB9`)zDSv(ig}Fg&X3MHQ^GvuaIepWye{k$uYK_OC80&S7Cge?=G7ILt5JB+O*83R zu9-xITJnuqlB$YtviQX`yQj^TX$5R2M^rZxHX|>| z1+a2|K8{!(E&2P!i0_ zNiQF$hj=$O`TP5Q!iCp=$8Pf-093dF+`(UhSE-BI%!odK9#`L3d^UBLXv?Z~ z%g@O?dTIZPxGME>`{CT?e)Hz>5f~*~=@>*Cw%Tci!`EPRh`&XO(wI0#hl|th#oP^g z6*!Xd4Lvw;pU(qm;foVbZOy|Ah}8u0HyACx;hZeO76Lv&D{B~xTD|ne?K?X|6%$L1 zuhxc-j~)+Qc5{2$qVo_#j&!##vn@47X|j5+qyuPYLw~5pnGq0 zk&W(hDKS#iYiE0kJ+iIh(M=DsQ#NupU!a(%C1AQvCXL>eFUOt$#3d(95Zm;3bA0eD zX3Qq+#dHtb+_0gZy7-)Sm=y-nNE2(~4Lt?LaFjlvLZ+OU`N_WyM3H|V;?>@*NG=V* zHHB#=X@}t>DB*XUm-27a69l*u66UC=m?frBwV}mp)tFq(>o)r2)(%M!$ ztN8=!Jw|^;*}rEj%q$EH%#8ALlGD_SlGS2Uv}5ANZhLVT z2uwj0lCw19QnQov62@z8_VDQ zV%8sDu%N!TGaOvW?vq-VZoFj`1gfIwx~knAtDnYJ@t=7|1pT z03cA|pr9}=FyenwCvkXNuRU*yZ%FcQ(XJOc2^uKh$A@7)@o=GPdfk2$LsBZf{~5eE zyL2J4{$i#l$wejTq$x%xr;J~_sy@r+3ZEtF$e%R3T-36xWoMGX9fcIlbu5h(tn93{ zMQXK01ws9K&rGE}iUp>8oAt{pQ)^z&Fw43l2*{ETkB$+TD!aYvE*^mqW-h1SPrt?! z`PtMuDS!a$=;7OKQf^t!|GcA(zX;x^)FKmt;@AYzisb?gO5ia0j2M{6<+3vdv4^N8 ztLxgKC$#wUwr8Qol3bPobgbS0wcMU2Gn~wa#BF+@1HmkNGvs$qZ`e^`w(zu^_ z)=ooGF;E*&w^TI@JT}vVdUY_}1(kE@ULrrqp7Rb;!l8q%-a zq>okYQrvhc61%BttU2mKe zhZKlwI)ivY8o;#O`7*tUTJBjeJbD^s=f|N+94n0wg>RFssH_2e;}Ibq^$_`El_^ts z3e)Do4~(ZfB1y*} zeAjxjwKdwx)^ZxW#N5t|NV;BeVm8jKK&!cCmST-jx0BXr+Bm3VVlZS&l;`akOb^pG zi9dwIS0zxIz|j``$&xX--&=@27`NM(3(mU%rPd3UiWkH@E_4n_;#rdIbl+eQJ}Jo4 zGk~t2I3gJCOja6#a8^u0*O3L=@%fI`D^oN1qrB#mmIe87SR_q&Mw?QkG)J;E_q1j^ zF71dY=qjX}I2KDEXxwHuGn3hf6=k^FN!Cc-C_ncBJr16d+yUX1#TqpYn{y085Es z3jNoKjK_RXh$)b>>*ntece31Nvr5LCyW7G++34IDC9y$&gb3LV7mT|(i z%_afL=&gH1p;N_%xnIGK@`Nz*AN8i?7;aPTCTUccu)qj^z;P4l&y z^gh6u(O*}N!16hytk&oLNtFzUSXi1%4hh6K(inwLID(+dMNL7 zxu~pun^ms$zt+?89~dEZqLmhqBO;i4M7lI?H*ot%RPBvA0$Y=CAT|QIY#MH=DN$3A zUuatBuPa)*_4lZ-8qGmP>z-=X6RYkiN9yjXG?Pvpl#A*)MTv}FM1F>Cx~7ZIUO8yy zOqj?DRm=!<3nC&MZPj57zFs~*uXU=#O<)^<#Ul&5Y|jlXv_R`2d>deVyFkx>_C)vF zRZvGc+CnhiBn56B4x}4s{J?#WAQA}Dg}9=Kq^siwWZZ*X^MylcEah(zuSo-!9h`fo z5}pRXJKUhr5IvevQHho=_JyD>mGd;_#YAi(h8<(ac}37`I9+jFhCf2Um$_)iX>_go zCh5UhS=j5|Xo3F7+fRHW?^54R#yeGV%YoAO@J-%@75qXkRd(9TRB8wrPsd4ffWwrx z^+&B-_<@%u$E{lf;fo4-iq`50ZTgI?Rt@{Ien|NMh5@f|w=@E~Wp%4t+itF){>#WOX0GT8F!PL_mMsOOQ(;sA` zvD{dmmRwc63iJ&&@_oc#(_M4CAtmLKj%3@$8iEwp&VGH&I-MJ(m|V0=n#`A)%8;`x zh=u0ra!8a3Q&ZqTD9OaSDCSJ}ugWPYex%M>Ef5*Z!I7voUP$HK_bT*X9Eig_4lhAX zwIj3!N~NNOY5u zi@>NSiSN&Zg~ZI9fDxP9|D(Btil&WmgF z8>6ymU4iPXQk(P8BdE0#6qbCv%+|evs?md&SC?zU$E@gkGNoRLEj$^+E%K&{87zh) zKYN3XV%!)h#1`y;kMr}h@k>T%>wRLt*x_U@MX}4W6SqR6xtkPjpA}KYa&l5g<$-Nd z*jw0G<4#FIrQE4@mcEs(le@*W#<`Y14xXsFXxWx#BW)|y zO5VA6>6xqA_!Ir(<@vbl%c9$xtqxx~eY$atDs*`2L(O7-lN-}hIM&+p){_6xGrxzO zF}w``0rr-UXaV>G&4yRYXLlkW^y4wa9Q49V#xv2N1_=FT0%10h9QOPwXC1qyj&{*) zIC^$$P#2VzEPvsi3b|a!mV~)ztkFRcf_Y*!EiIKZiUH~D<**QgPRuBkoHw~+y!&d( zhv1;|l6;a+Y?5z3Kj8Yhlev|-m9Zu4X|w3$=@Cfwo3^!IQ+(b_?pnhBvMa*AX?nYO zM4#xnNbC8uR~QHn&s+5E3hz>~dRKjprh9dJutnFLLy$57@fX|`?naj2>f>nU>#6@c zFxJcgTdoe2!LEX=v+gi(+?uORbdi0nq2jS--cB1KVS3Sq30?fR-P{ADRsF-YQkCrawdC@%M*VLu)WI>ZtW0i1o#pJ zvz7*)RX&d#iv#vpQM>UVsJrBnNX{wvLm?uJs@RNp>6)Z#eVR)=vrvsX%Pec=JH4Kl zsUC3}yp50MMDV{oqc-%$Y_1*v=-zOL%`@D04y84Qzm5(lL?|@eW_&WA61q?5@s3$X zabFEX2GL&_%fJIMZ5$bl7|fXtrI=Q~kT3zet9W`lykbJkLGaWa?#28WEzuH+-VspV z6QVApZ$+iK$U~;49nU#}V~L=_<7htwNH^A40U%~>2-?c*`%ug3iv42XhN$-(S>gxKKJ2m`l z9Z)jFd?s(OZ9waD{p&Zip;Cg|QL=zm6R8?Lp(HOUF-0>BAZ;W5wgaUah;3PGEW$$| zPNFtM!cIS?fGlt&4bnB`1FWz@E4+I}4}Bm{2w#A#Y{$_0CklvCe&`h$=j7d74N94H zr=P^DZvNF4Daf)4&g5Hyz?`V*`^(4_NFeh`bxu>4HFlw%7X%H`F5{@VCW!_SLj1Rg zIz0`Vz4h;U`eKu?M~3yhG%C*`VYb5PhzaZfQekF5taF!ENZqVQSP_hfVEi2#yO{ix z7*4=2GiLu1sxQ9?SO4D;b$fG5VRZJhe7TWfSdj31DjSh4nadc0E{+4AhKDj2D&hDe z=T1rkK1?@#5$SX*7UO=DHxn7t7{c;9_v5dJap=?38Me5vmwU1zQ^?fsKRMDf%i;I(g7WQ(rI^LuxJUQw z(ay5M0yVD6)+}hM!<>7wN;gmM5|^_}IcL;`4y<($S@Ijw2fEpJnx(fNXoba8f_F7h zkRLys^69r#P@1K=$U2Zc3Y*Z(9T6aeR#7*{w$m-YR8CAs{Zb&yF_88XR;fg{hH8i8#o;QTZNjnc{;fEJ=; zFpS-BILGAmRxMOeac-`PysS-|9zK)O&o&Z(w+8G7UU>J=mMWSQ6`SRU1K578Hc$J$ zXlO}Bperwvuqxq-)kx)31uzBFO6M9+e}~G=aga9<`ZBC*gldCU>`=5o%qbls3LbOQ znXB#~VT`d;<>`8WT{wIj3sjY$+DUu%;j7$nS8l_fEDM+mZmw0i<^VX~M|O`3aW4Py zs4-E|%YbyHZV?T(Gg=(SyQhI+)|b+^V!q8qVvp-R(*BMn3Qak z_fTFgq2mh?etN_kPT1O|cPfCbDG}zJjsf4W8>3@%^z?{@-8m*Qa5!`Y3+krro#b9nl zzYdX%fi$3#AZa|Tm=aOuSnycO2;z%Fybu5gj2L2kH6F> z2VcaJXY@cMFd2Ssr!G>cRztXyenU{HWnKqE=Dq!(LXB&1LchnN4rl{NvD+T#E{-6X49~DMJba zRKa6PJ_fIda(5^@|KXVG<|e25 z0zg>@Gnu;?^_IGTR zx|I&y#p&N`1?Pv(RLe%*lTl1z;JNVq(|;e+()9k)r7{3(QjZbpo%qpp0wZWd-a(mh`okSecVNQodKX_vHm^Q*SCpe}R7_%<*Pf;$HQPDaRGr(i0wWR;cObUygotmTxk6ooMGis~yNUOb`y=_!YrAf1GlhT;} z7^vhx1}2abqZu<=8>j9TmUYS;<|Hqj{o{ZeJN#Px0TeGSF(q!?#(J)N8Tm)`TM3O1 zc_K57r1|fkx@iOhQ3oF#4HK@hkQUqgU-I!&GCHm&8gEToX_ejBWA?lVg@nIQH~0-6 zr40MR1d~G|yA`FHy|$~KB%rDQE>vR5i?JV|(4E+CH9V-aUpg+X1;>XW?+nVq;pU{n z1j4b8yV`9AkL+h!W7e_(;94%sFk#B3{3FEAYU6V^Z9J? zs8e`z2nbk1zXhizf{+waFC*jEiP>+w30+iaU ze%)!8&sO=@2ey$1V|?CKQ@=KVDheo?IrvIxP>U|uS66dAxoysV-7K`9bw4*?uOUp- z)XsOSJg4Iig}zZri!nseQU+9+P7 zr6@MuWRXe=nCLN!j7;cWI|hV!KtqNKa_eE~af>~SIr1eW6sQRZyV=jJ7Q>CN;(uMT zb@80|=|!wLZLtC9X?`(VAl`T4 zBrt%j4}o*rzabSB6dYio7{4HXjLdECS>VoC8j|sWyER3m;QA>00Qtk!|3Ub`d5E%t7+P9vZ45_))KU#m)l)6 zc{0ZV>gZPuN3e_qSoui;tMq%w<6rrJ$e&kQe7sZ?m_%MGhcRuATiDv%my|D+Fw(MBIBto$;Vr zCdJVN;uB&dpPih%CW;rdakyvhY~{&KPOAOHmza6D`%8#}8kh%>z}wi|NYt3YC^(RY z3e^UX29RkLEvPlnsFH}nOU8tOAOa^j8iU!iV--LGnZMHH_tTOBfl&X%pM;5@fiL2x z+$G$3&7k>_3F{-HPcH^A&qeBy2G{_0uXF8z(tLc*_C}$l`*R_jhKrA5#VgRti##X+ z;bI*6RYTFCOuQNNKL|6Rdj&6b_^~+%CVJt%nM)Q&$nxiz2KJl!tlRM~|5-#gh=zq2 zl}7*(7by}-43G}qBg*oTFw1X`9Rd8e42~$HG^SKBg5r(u0OG<*l%o)JumIi?qjRKS zX2QiJ(7$Y^ERxdL-~Kc1GW{sur$-l}DWGb|u+;A|0!p(0!Ge|WDVrjma+jfm->Z0b z4SG|}8-pX<7%ac2u!{0sZ1EoyZ<<8rS`kzEN>;+|MfmRi|I`L<}7WbQBU42yw|aM>TdH zx;fS7O?-o7eEw2OD!~pzVrSl@=Y#sx^oLEiWDK2nhpY%-1(>)2VfKN3O8xthOKTqE z+XUHm9^Wu5&5D}gv+jJ)5b%9v(U4bx(w=LZPFxa#`2*5TU9J?X z8p>RaJCt+oe{3u&-5HtzbUN4D-qFF}EE^aUkM}o-KSJdg(E&SGw${hf_nkQeEA%<0 zkiCuxJ&8opA(Oww>hA)Y=opHH8GE53@)4DWl?D2w{M!Y}Q4g0lX7h8W9E&6iwSKfw zs27z}xVhBwGw+a+CTt17fCv*8oDZqGIBX5Cdh|Ok5|>B|U~?e00UwC{3I>mrb-(Rw zZAt2BqU-(B!^1ae%uAKS@g`lC;Wbm!xy81Nu+pTozio^NTWPN#xhS7Wn-a}vDW6GJUe@>5FbC6%XD+2>I|`mJw!K-?g4wWCpzUMeH@)$^WxF;a<# z^W0_TW|`($lgy-&1WLKUaXJSO+uLZv+J2Zg>fnp+-{7*A{LUT)$P{`rnsrkPIGnlf zizS9q;VBmHRaW0>3rNJM%Ha^8RU2yw*Xy}fB$KxpD>zr}h5?<`FP7$umCsbMKN@)(x?B*8c-L>I;;^M;%)W3*P zM!(prDt<9`)v5Q_TANKD<2BpE`dXR;=5OIP-tHf{&z1a-q`a(Jea^SQ(o|eIAYTNJ zT>%>dqYzwcCm;p}{m;@LmFm}nH$^MXFn*?M!BeJw*@IpCgK(U5I!mpaIej;$ zV)%_vZNsk-33iIU1x*86)!C6A+a_Hl6GVlL8?L}kVk4ZYX@Y~j$GL`O?2y-K<3lsn z-*-d7=`rm)=PpxK_0uWV8LK{%dP5ta(Sb5ryjj*kS193va0<+My*HtqGliREQg!)f zl9lgv!(ZDDvJ^blhjk;^y?=;CW!_rI@aAD=J~ajuk$i8;v8=nEH|Z>O-OB9#m?h@f z)_JSM_=XrFia#Y==0?T>71h3LK2USqsi~2V)SZSa^FQYs=1vmePM1~`PR{eLRWtSE0$bX9R*R ze?JstI-6e-mi~y$jV|z|(t8DapVAZUwgs87DOTWU*G|x zgyQZVP!YOWU2ol@Y;~cjn0(9o@uCJ$V!^Lz|8G?NJ4z|r{ln4!#;Z99@o8~sdWorJ z>6uwtv459(Koyk#5G5%kqbx=vPa#e-wj>E;u_QUQ^xwF0wn3lj8UKf?Vf9U$%`wcL zQ$-jI8i4|+YU$2yxqP5?f?=1-tCDcVJO&sKp%@Yol+gXS6wuYyPk_%b?*yM;95)2> z!A?jy?X(5E6F#qB_p8sJVK!(%M!IoO(xfD>ceT?!ia0!=2}6ktO`eq*VcJ zU1lF*<^naGb^fXrg_{|=89oNmixQ)TISDHH?J%f|Z@c@KtmG~QqMgbA0c=2%zf|j1 ziO-EtwICTQRsV%$YXeFpK8d7OEHhwnw#jZpfzwCQ0nEI*;)Mb=;F%w>zpjn|nUy&Q z+O@a^hLY`tQhXy6D+FXzL)JwS=GienRGvMx!FKl&;8!kb$RP~vis`HzQb<65@mjj_q^IQrSExOl5?fBj&G_; zR>7OwM3i+Vi*VsFd{VJyahw%d9mjzZ`Fe&Wl~kGDv|y^PW=zo0I01+YmcgI?!+wH8 zfE_(7A^NDMgnVJt66}!*yItTC_1=I^@7Hi!#~Hi29|Of+YbSqD9df+5sdB4}c0Bhz z+Y8y$3$})pYjF7UJev3QArWFCfOn2g1jm7_fq&+o=oD+(*s7nW?+h<`Yrc7U!!!2c zi$5%pmGC<(GIHQ^b( zq6Oo3fu*%{21fcASUjXOk@S4jeUL$h3kD-qLJ)r7NJZvUR3e20hGx&S=4&`(pOh?? zE>nT~-F-M9D|EUxcj|GGq6Qb4bqB=DHZE5qKerI3SiKNg$V^6 z_ygPcU9aHJ25;_sqm93?YR#B$rNYvQe0T@a@*q-&2=-k1wzm6tO{`Zu*~RE4s-u5( zKje0j5gc9i2lnlE-OiECt(Neb%d4+b9Dr1jP8ge(W2=v~cCzJloTbj(=x1ym?Kc#Y z$#j%J`q82f*K(?D7^m4`%3i{1Rx03czmeS_8yO9c%f+AO^Z6APyEYH6eCquC`+ist zv=~TinO^oAsVkkMx)WncP-&8i?_N82N&1dlEg#N})(b{1?vu6Nw$5icjS^>@l7>{D z^*i7*X7sV`F7;k(>&gq;(&~P8#&$xKx|`mK<4W=>ji%H^WkMg+!+vH=`$c-4_$snP z-GWI*pLEy`?U4sR=AS($eb~c)x2N`8V9}HC|Lnt0cRYgL8t8%q{>{$!RovteDNbAk z1KL4U@i03(cNr{dKQSYK^OFgi)0anWeoB+9f4Vj>q2@N7IJ8TU(Tr2PgikzdvN1|V z_t%#DKXqMtg1si2EHisX2VNB@9+R|@H_)F0jBikCiNeSjy2j{O;5ySxhOK#J@W=34 zA4@RwC%}IK0>tn&sK3%QA=PwH8kSfxU+#Y&HmO)On^T?-f%4@uo*u2#RL@2)`r}vD z8}o)a>%jm6nZMzvM$FOxJoelD$gu8W=TX+HBig1xNBCYSncoP;-DXV5W*ZBv1nj@R z{MrqX`j=zIKE6L6_Jmonmcx#)g~KxFEZfms>T{;EZdZGAyqy5LN%qm^-$7@w6)6t0 zDY8!*AisS%e{fcu8VgAYW5;$5Z+VBO$rba4mOjRG6r0H-m`uQWEBe(MEIiNa>`vW$ zoJ*u~D`4;P1=B)dn`JjR#ONH_z^3UZwk`NcblYL!1(7!wNH>{6A<^ydp09weYuPD1 z8zyw8$aSARvsZ0tCRAix_4>LM27|`FQJCm(qP-J^(`&NrETkKqiXR+tikrv@r=NOK zQEAHs4P2xW=t9W8;8cva; zM+DzR^sRIFV4OkH_J}sMyzJQmts}8n-uareuY=CAIk^d%_6}nE*uF&W z_Zhs@0A{pmbJ4($^#<71?OC#UhubsuVV9Nq?w;BR6jGGnq}ew)rwjSRmJ%sNBK<$& z0OTq6FIXwW4w0XT9w9OnUA2(}6u_!;Jf1FU; zJCON|%?D+{o*DW<3&x(`2F;~!PMevJuAv$@o;w-R{BJO{O$3!9^u_ky9Z1R*0TCFY z%WqWZyJTu4F%Vr;&0~#`Rp2q*Tt0pI-O%h>Vv_w6=jEAa#&#U%S{g{D$k}qMh+$Iroz9SR z(9{?vmKstbU_n9ds8TG;sDM2)E1+V>loT5(4D5(vWWlM1LeZRkp;@WZd3?N|MWcsV za2HJDfmA?%%am|5SaC}=`ydKQ72d!(2hh2AqGq8QI2N}T1PK+X%+Slg*_yn2b*l2kC^B%^Qbt3h)`3Cdl6f9+i_Y?U0&%Yq1oGumFbZs$^Kga?|xfB#uQX3=!F<=mIe}OxF!u-vwkVlA4DiBL<0s;V!#{MAGk>$mG7Y5X@%LESZNvaNk zWe@S_?Or*}wr7boAzfljRY*}`DNi`|E>ToLj_qX0pOFI+T&7z^%ig6TD=P+?8{!u}vCYwH9HX}ogR%`BS6 z!F2pz*o=5gHSXd8}*!wX3C$TnlLhn*{Qs#sZj-PG2v3q?5BupsFgj4auJakS?5D;(}y zfiupBsWcSBmpDLcxjtH{26zps2P?9pAS9F_G=pI$E%}jC2aOY1+v6bvoD?a|x11l2 z#ERn=@NYyf!)ql^;oYy8E~x6hY8Ara^!8zR+YXnU9EtQE^Fd{EDF0ASaYUiVH$O4G2T6*1N`l6r$vXN!^8FmMKx*8^-r7!jRGOpi0MwgbaOPHzZCSrEL9 z(5#-74bEX=}q)iN|6kEJxMY3gk}1q+|>Mr zhJSBsjwtk-WweX0uGdKIFfHs@W=C2)MZ#CK;Mh{G^P_VC&V}Roy5P*X_Op z{P8gZR-AaT56deV(-~W$g|qF)P<@hRc*q&o>U!Qeb5R3tJk2Gkz007pe*mPGGYhvA zc${sHv2Ft)5JY>vVnyS#bRu7H#YIUfSGH55HQGC)083b`*nb~l;wmXeGxKJ&*0bD_ z14^q;^duzWIl;mO>Y3ogbtAxE2`Zif2@HafAhXo9G>LMLVfI_2wN^{v2=W;)xHH9@ zp=&kxGvFC~%v>tmrTra>_(5d^R|qTFZuKjwvoL4lI4ypC^o45mX%(e<5T^g?!y{&1 z=HvW4A71VIemAz~9BeLRb2F!7xzJV}`j;I$42|~p;Lw>P&^7zkv4Xt62W%{f(Z_vK76cv=8G9{+@XX-BGCq-M*78A znF5tamTRB_)g|~ZAe=|w-TKRD>5MJgwk>ATk{VL6-7>zltc?^IsDDzDCU=+}ps{Ow ztB1C+c{e8Nz!N4~Kezn5M5RoQ4H(JuaV99Hs&%>|3N5QV(xm;YJ$t-G9aQf|w&7(= z*Kt|8?O0Yx*)#al0`rtfm%rBw&y75Y+^fJlt*m4)vx zhTpx{F!f%zt000c@>mj1MHUwYqa)|?7?#`-9h=xI|D_*BunPS@R$g^hxC(;KuTUuC z=8Rr6QB(*SmsU^Gu$>$GTJ*F`GH zM*sJ1eAvHx#_5soAD%jATyk+ndT*$yvyQKFCu_M>`w!5zbWoSBWUFfFn@=!Q!?}y6yHoS)F^O%=S zCyv-|5P6G6jXi&NI)&?bu$qN~15=`Oye28eHBSSYzD?{+^ZpOHQzkK~=18lipU+AD zi?^avX*n2r8eI!|7<|f3?#(0h_();3j1HyMG5xsDQihuJx^$0+BTG*Wk-ctp_3lVo z48QXK>T>;MSynZDf#L>`t3qN`b-e(LR6T_!Co|cBC(j`i948e}xrZp7X|rUMd&%UO z8QBx2p@A@i9+N`jsBHGA^mpn!PQ4P5WlT4T*3*5=_(oLYyj^fi*Xg00=?`;PBp{XSh2}!LwkqT?i^xcWkU=h{kH!SXpj5*e40-wu%w(tmeoYfpnk0VF* z?q5-Kgr-~ZIvYVGWN=U*L0ZTc*>}`C++82OH;LGXGRFKSZNMM=qvWF2p_ zBA&mrN~xphm7ymgDD6^Th5XUA+pnbLfoZOKos zzw3qG?`(>iPBn_8 z$nVm!u1H#wG_MN=n4ZQ--Me+`-FL3vATO~g6^t$EoeiyOz&qcPu%@f^G@Vx@DT}JQ z0wmCq<_Sh2r7p?A|J;=wOOyTl$L-wnDli!tn&k!)VvucAog*v0=Q)wXJRyO+~jH9Y@nwA zH%V(gUBNBZNMRLB#!O%nG9n4ABb{@Kfg9DbILS#|k#(9JUzu&{8paQLK@jxP)w(Ea zGEWzaES<^M%_g1q7G<#_2&7I|z$-COy!Q$Ioztw2`QwL0PJ4V#btj9nWJy&68VmuWj8hb;vGGX^yIz18&{LXhU=6ApKU{x8Nl|2V4o;S^W;9BjdmH5NF%1{NnaPCnZwHYjdZ{)L+~g3z=rtdp2~CxI z*3zuvyDB{5!W=3I^@#}HxEZ%)*|*kUd`fyZr6PlfLW)u#YjpW6e2c}kWC%Nw+aIwVQ|X3zD|>kkEO)uw!B||J zF2U)8B;n@(k5SIpJGVn!I%4)Wq8jGj^#&^oUnZvt&JVOJqz z4+ijKN#x`k_)42KuHp2treQ7=uNvE6kXB%->X2EArk&AT_HYpA@`(|c9G`zA+6DOl zI0&7!bUNOc`CWU0aKcx8;uIDfg%TcO#zBY!@PmlM6ggyaX-UxnWHhYnY`TPau3}b~ z2_3$<`~YIHSb+3o-o-Rc=39eJoxpgViiHf$NI{IALR}#K+hnLP&w7J3<1Hf?GlcyE zvFh$9o3qd#VqXyQ3L)}^4$9&rDK_Blz{p>H4r$%bG0T*J{IgE)P?>VriB`>o8xHjU zT&iNdXNm#3M3KiU8Vq@KK&;Be&7Yl4C_j!(`&#Vk}9Uq_H}VM5L=ZE*@DZ8IrSePSFDE1JgyC*+9>GnzINyR7DzG zQd?Tn#c&G%;IiKP#Hs={mUm|p=XF|dH8)r}$QFbw8QBf0WHcl{iL=Ut%KBNYOV62= zaXxpIVoejpTuUkzucj54O|W=CXBe9->XVuI%2>4kH5I(d<37n}z(>1Q6UMk7gCOd= z9W&9vW<#)!5qi!>v!~h6-RD;aSGiPT5I>qyUh5iM$68^Y(6{0`0ZSEfVmUq0fQ=3{ zmKbxIoFRw5_#7LU80Z&Afa^P^2C#O)pcO5_RmzPDC_|^>!W#JtW74cBGL>q*g|kVV zlegcz$w%s1sDLvd?=h?u+d@cmoNwzTRy!dR$#2uS%C*EhFoS7Dk2T6@ja3oDW4aX) zDKorIsKz8$XFG|=FF$(_64{1h ze09XI#TSg`@x(BIz-COWU;-a!d-+H>Dz*3E)lz~w2Af{{R0E?7OZ+p6 z2PpV=D%}`&SJJu34=w=7`9X84oozsgAZr{v{)vR}Ibc z6XMZNMAR1%kOLLRnZ;GcC}Z-g7)ZVHN)wgZF*w-VF`~m8aHvTtoW})6e)B4}${=22 zNhaiT?Xezvgdf~_5Srk|3w6lew*qZPjD}9JxTm$=%$8AZ{k-R~)`$nyOu;ZhYKoJd zCUjks@Wysc-zduhLc3q!RQ7yX1yek~C~8yWv(fFro5JYluE(tSq^wMa41Fp)^ufb> zVj3I-T-i|u_DnHh8IfT3fCPgNiZl-$_>n6}HA}`t278*0(xqDaO9`hj^bobvd7+)m zsm!fg0h{!JoJ91a^s3>X4PbtiI8Y%DQHKsE4O@5xHhjwR|E9Cf;u*~>Z_QeSm)?E3 z`(pR-$%EbB$^KvP_;~kl_vhWCpt)Kwr(kfFs4AgK?XcmWIuXBtEkqee8ry14nPt+3Fjl(_+hcZ}3FCbv$X zXx2bvb%0kvl47;q)UpmD4&m%dh(7kfw}~o&yuXZ@hlemO%LrYq>n-{BN53KRD%nv* zWVwsP)iDXb`Xg-Uhp?A_xBqPS#ghl*>-*5~m;I->(+TYFhx<>SJoxIL`V6|*)I$R` z-{Gn_f|o0FS%k4wJQv49R<3~%C@YdsC4RjBWPgu@U;hDBW%ux<{k{Fi%r|@y zNebBD$Yc~t!TKr-qxohIz9s${X$80g*DjbXDo0YS17bH3^T!Xar|CJRm%RF7j`1=Vf zUlp#TS+%t3H|FalU}S*1X-F8=<0CyEEBKKgiMMRyfo~LD>?&{z+_uUFpDZgYOyzPD zUvtxVh_Qe~4qHW$2u!7**8mU+wNTs#mqU4_U)Lm*De))rf%pLnrl~=VfuEa*TGs}A zKPa)s>q`P)u0!$WX1B(50}fF&xZvb%0L2X>Z5461j!FF15;06K8pI+d+=XLE?f3}? z%90ywCh2AfChGDYgQ^@02Wy_ zjl7&b9i})PDk5lZ1IN5wupyK`m|kG`)eHsNoPn%t8E;+sGJaafnXzR?>f!cPzh()?zKg&zZbP{=gHWOLi!s8w{ik1?%YsvDnA0NfwF$jxFI;JH zBX`DDr}tV5>tK__jg*#p*+qbxH$ITf6EDRrmm@e}>k5QPvDM*)+lab2M17Fu-UTt! zIW0Qzi(8Qa`g1wiZRX#0GWk4yjVUykyreE`$|Y6L>lUxtH5Xju4eT453D9@D0CAbu zKUJeS)(+p+OY{M1243b9hZPbAWD{|MzfLGCv)#N%dv0BCG*)n_8;(_(FqLWC)RVuRPJ9dfD8d%UCMfB4CcfTOEo@>oQPQpe2qcMlPK*T4xDA?BU z;V}1t=IXuuXZw$L|A5HyQ3onDw+q|ExH+$4smoe32u;%j=J~I*+oS7ZohFA^CWD%9 zW|`Ev$pMzh+{VV8FpqAUwk2@(s_%YfW23k2?+c8JaYo`nqkAn}Msue@aRqsZSuu&@ z!u9Y>c9=}#ZJk^bLrBN0OItjpSI^Vp$ujM;GI8R-%fKUxlJNC6JQeRWZmwJpMa!9O zn>bk5zn*{y?A%PfhH*DI)Gka`}J@l1mCgs;GzJ#!QzvSSPl-3%ehk`r@sk`j}% zi$UrZCh;;F<{S7-54d4+MU;&@zOH!&{-tjzuLM2(Od@qZE-tueyNO;7Hw zv=V_TOG!=6EY8f&1FO4n@Y{jDgIJkhz0>H?$^(%n zk4|M4=QrUr{}R{*Rg;@ol3J9Rm;+KKx^@%$!+*JXc14r6?bXnFDDq~G1XNjJDNt1^ z*s!nX1uZ@tKDE#|yYKJT>*;!0Yfm%6RppnYg4CSQf1Q2df_r^q3hQOQ0O6iYmi3#V zYKlt>3UV@2i$KbFf)C^{!tr z;G!^v%F3cZFACJc^?F+)j_f9Ki^4F7F*%YZp-6@^j4ikUdT5e8E##{cX?lj-A-qc zr*p=$jM)!S&L>)-Q1lcio#vv1>Jwj62dsofMXKt15*O}ckwhW~T_;iXIWH0|=fZ*f zBGtQTf_mz)0}w_;#|ro?!IjnyROdWVVJ^>V%-}`k0CsmqyL$%*dtU&m{n4Y~>3HYK zxW`6^<6rHMei@7pgWbc&kN4pS3>bX5vrnMMqer8|-;M%+fiJ%~+~3^`_8uRNe`ht* zcj&E;s9xo3J@%qVwdgTbWLYdl-eX`ekxL%qCwLG56NykNv|^`&N(bK@I_>wTLLbnX z@1e)ffItj7^SYf*hpR;r!tDt&$AJj|Khzh4hT?2sKtzSQ%eH^UMrlHE!kC=cuSm)F znt|hW2hL>}Kj%_cl37jycYs74p_4Y6)6(-;E_9J6^y(sR*x5n9>fKPCEL5O zAzNWG*6a)SrIgNK$Ecog8AFDcfUt<2{YZHwoo-wC`l7EOQTQbv-tyb!KbGHoe8-mm z`uJ}7-PP->H_P|S@8Rh!TYk6vmM#Cb{09F086K{_TK)xI=+pbFKf>b&_V2%5z3H!a zrkdnUZ?e<}m^w;TtaUC*Z$su=7-L5~G}iUJF2rvxGv7#RvLjqDFdb)MeCtz6R_9XB zd`QZxj%kCh^h@*JvKwMr@x{6$5yzP5n>#(;69>dH56NpPbL&M0PUBS~Ou&1LbKe`oC!R%WV`C*gh-E5#!#3-#3I>S6w|KgZ zo7N}H#VTd{u2A$0?q1{4rk^=fiU5=%h8xAdeATsy#ld>r+aX8x6F$EUZjuyXL4PIg zc3!}W31>}3MnXddK&QBR3jz9pUAV5z@wH6REfplq2;=-G5ea4PVOU33ztmU@SA~TJZ2`cEw_;W#> zq|3?BO)2Op$@JKU4T+%b%*f7-ybQu3SS6^*JW=V~0rgaYP6LE%WgYMW4OVL!jg()DtLuWr^ zLF2%uHv^dq1*R{NSIuTE*O-ddKw-tDwz$X41TwV9OthPCkVYD(1nKeu;jrajCX>|n zY<0JYhwy~)JFM6HUP-l%m%=;}Nl3}X*}}H%5cj^9i>UMCl@`91h2s)u zAN(#trt921+8=!p{QCHCytj^&QI(N#t%eNdJCb{Pd~|fMzxQMvMK>wjJJH8GqS?y^!)$Z`*VSrv*i;8C)6=WRkPuj9D0sV5d_!J{uJ(iBe}Nqc@zbXS+AXEODXOt z)gm#+>I$(^hI=Ar!paWts}+P+7mgMIZ6>rq8ro*rn$;#yu0+h-7Z~6>lb}smAhnpQ zn@JGx%_NjqnLSq`vc5CI;rcVSeSbfIZ6@LjnwfjXTiXGY?cm);|7t_WeqT#qp64CZ z7m?H{_SqBa98IQU=QEKs+VOKvEfREP&_Xjqdbk_QL2E%uBS;L?iN#^59E?S0(+=# z#RMv@`F}}joEO4&i+31iNEi4}=1~vj2r{nJd74KnL1a`rk%?5Z0ADtaIYYys!!+Ey z6KYG27jMHPT7zcrYngOV0ZxQ#x&1FV+mr{YHM6-}w}Gl`&}&Q3!_`8kO{o!}E5qe= zky@EyfSUn|)w48>2b&_Pv6Qaq{u8Nu-CmBN3tvO~K77*Xs`SY_vO%RQ$Yl8Zn&MKH zNCz7rmH$->H20aJi1VH;bT9Ox_GbS;RGw`rB}4u~0SxrzCe=H-)2@fwwhE z0Z3tH{!M#?D%y=ZK2xb((Pn%>e_^xDUEjS|1Ghtd>sFUK16;m=*80yf6^m9DII>J!03M*HKY#BE7K@T_T?FIr&kc&)?ThS$}BXDzSJv2VpIdRz;n z=ACUG>z#5%8w0u@74uB_7EzB0X!CLra1}~vyQGuBPu=3$W(JF*^B*>SM;yMK0(hLo zSI=u3M-;y6uXx>3(1N^8+)#?B0wHn1#C6j&J_)gmcSrJ|)y`^WWm~w092zGDAA2jk z72M{~kknAxL;sFda`Ru(H#7TND_aee?m?QJnSI}z_rCYdjAdEdVZcJVu|JDr9)jSr zn1v%~dn5?Pw&9~Jf_m_0`RDNLd58_d*Xj~ z3h*&NG}i6z$&k^&$A9J@n~|w!URwAKK7gr)Vt8{aP{;D7|wzKWK3cT0Xw7; zSP1((c6~vHvBb#{4SnV8FyKehX<3%lVv~rA7y>>T;Yh1B6np|CiXa_Gza`SNTp4pg z^I49Vg)y7ZZ1xTd$JF1GR1{NUdW;6r3HR^q-rw5U*?Q1{z1^Md-G{10rI!Lb*}UnD z$Fu`-7DWN0q5~6Lm0%?3fKNRp84qQvrM#vBdYSvqh{ij*uWjMNoh&EPvRkbdk@L{Q z&xde8LO-BxHr)2&;|_S!F8IugZP>UAyF8@b7U1I@kl1BDg0$P-R3Wsj36+wJsAU5e zaB^y!UNy7U#e76oR(!>U9%Kg8tC@-^P~&Wv12I`bSC63aOoaM17W1N?91#|S>oXaV z*gHs9VXSmCqFR)}r^gOso$5J0- z-U@itAvH{D!p`!iry{X*!Rw(8z2{M3Lw9plu8Wwxb;^;Bg{HJmZS zmWEI+xymK0Fk56V*G~u4Iheb}^qPXGICiw{j!Kbnw|{$J+f6~OFO+yzy?hJgRDex5 z>pEP;H$hqpQLKTe^Y%W76>?y(fPjZKnXk6nAu2gBJ0$4syUvCB)3|_W+{BGL#i9&A zRsy9BSy9B@pSzKuLw1~Js#Y5*rm?bAAlJ+K#k=#x87lg_^W^gCDYh3cu=zV``MdMC zTI`=Ce`k<%R^gj|29aNE5C5W+Z)XdVVB~>jF z&t%)ILi=UNg~z2DR9VL(9#!|->eMVwj-ZE(9=Yi$vvP{ap2|V6IdJ-*M12xbqEUVgxrrCqrrKO+O;pj_sbfUvIzi(h#!XPkgG1`~+8F^=&6iY-2G==} z6?Bsy+12SrSIS_raX-lJUTc`D9QRfzdwSqg-*rnz(L6GG15J;Dc_0`% zwx&9IPR;j1{|6TU%g?#P0eGC1R?TkPMi9R9DHeMo0hQ`mNQ}C+f*4Vpz;;j+X+W&W zm9-7UWw@)#f*`;xYS{-L`vmR5j*HYt>b7v7gp6-~lFsh(N0j8B`eJHl=G*!CW@a(Q zO`rGsBnkw&Irs%Ng(QhY%^nS7)C>~Rl>#+=68SVl&0&v|K10nx6td{8i!ruXKjvT% z^1VG4?O9eg;eABn80CT-f(Y~F3CR;$YPrcElB`b)?X4G`w)bSUl1sV|*12?Tb#txV z>9p4yXnkw-$ zhzj4bl)p4Vj|<1$Jt{l;%*MN1x3UA)KD3f2QZ@RrYgw znjPW>!eS7|A)|?wo~OTMhu0_B^yc8?^ZRU?P1B3)NU8OSqzNOTQv3WKi~#^otnHoE zombn%HN=as6u7q?yW=SqO1b2Q) z|GqwfYb}e2QRJh5c2N%k4yl)iZu@e-f&3nk9t&ECMY4fZHCjl>#6e%IpiLgpmWALi zHv!j`hASWs(so{d`6mCwUt#?#fXqitBXGJA}$As#k(>W>TwlgQJ@HD8Q54QBvY zglw!it72`YA0e$r5al7H)|rO{ELTt!pusa#S%RwFGW=ci%bY*VF;f}snyJg8Q=ZYe z4^O*fx^U4YbN(O*)4&T5Dm6+V+<=BuQo9CoR|-`>oTq;&IwK(0Kj{@xKuQBa3qaN(=V}P)2@jJ91{PAI2Q&Z&P}YbDIQXtj(zEPG_<-rw!cNT{G=M)UU{3`ckv;{Yk0*0=S<)`c3Zv(~co&k;@6rCK82kd*=u4|oQL7=?EyUYY z2*#rh_eogS>2n}*xj(+kz zEltK-ySS~MLwDGt2`zIo0LgYo72Y5YV8H|)kOge}Oc~%jtmrB9!)Z3fC7^cF1%jy9 zy7=bcS2WJIyao!~w=9Ba&3|XoLSvOn?T0hTi*Rdmeivc)d(I+TU*pVm!jeJ1tP^H? zE#`53PCin^`WYC8^k( zt$^6@t*C*bMV^ut2HJ;pY$RJd?!^tz2Y&@+zxfB6Pw2VKaE27M()yCPs2?J7=5XfB zxnE{uS=P~B&mU&u9pNQO@UbrjVSF(a&p!Q3c;Q8FI~5m$?1Jzj;a?htK`+QeFOCL5 zvgu_(9EmjZhfT|}mV(V;oMa-5&z}#X^XfJ6mzD-eyeYilu;~qhW-ko?QqFfp~Ipk6NuM{1=g2BgE_6W{Fz=YuTO zK$(9jD>k&31pGUC?AYpuXWZkbPOiFVPB+y1+Ukd=*3Q7|+S>cSSS?NddNp^3)mk#Hq6<0pj$Tqi4>Zc268_2tVyLmYk&} zq?(_Iwjy8iyq|qQW45&(XU)Ff3sN9=Y60--uRc6_@;Crn>1YT%@smavM7|qj{$|?9 z{A4q|;QLu>Egd^`^2GaVKXZS1YVG)G_+ZD%@8-KtZ#})0?^z9jZ+|h~?&Wv#yZT8y z`EqidUUw%qpM3fBmiaunL2q{^SNUn%Pw^+-u*OSF4P)2I)%;(RoBYAkTO$7&w%nau zRRg8>-O2U*Z~1-ry^QaZ>%4x?i+X+ti!Uwp{ef_inKtsR1tOR@;_V~&+aii5el|)X zLDM2mgk?ERm<)!t(*)@TqB_mQABJAfx2_5y&0t-Ib!H#y$dbT z4|*9<@njtNOh)gb2iorP3WGSor)-qYUZo*6cSxov}lJTL8o345P%w zjl4~tLCxu55`X0PvKg~%Ap<-lA(3n|Hnb7Wr48Z2hD=sCDVgdpNHdqGZAmWv0h8O_ zYM?{#qJEpr5fbCP@1|g0+lN+oAO8}$7G>mxm*SA#T5p&7QQt0zYnagH0F8EL_x;QZ z!Zz4T84{sOvZas3f+wL=ed(yR-mC5AZz_c?d*CSC)bV7D!myS%VVpSV$kM zMP!1>pr67^XBhNpc^8BV9$^c@t1DFl_h|OL%`2t)ZzD=$P^hf~q^wzUa!CwLN?&Jo5-iA}4 zE7^88y<}rFY}W)MHev5fAurKW_}dCywgRlV28^RzUx_pLY?#Qx2ZFu8HEF(VK?*y= z!`fn45cFB5107gz?hx0DMV5p9vg34GKk1Ie4lFpfN>&qMDWHTVL9GNL1sWpgUupqo ztA-sU0MIUo#3-Uz5QZHVGE=&S@ldhTQ5p$ri^F0Czre>MZPHO##hGsmtS5W<0|;** z><}Ot!20|Vgh_Yu`y$`Xzsc`5ty;~Qwy9J06wbo5K~y?`S$E2YD)DWXg!u=zrWJmW z(mHI`*q>IhSj-H`bCpclK}=%VWnTb7hQ1+vE=E?BuUYuYaibx9W?<={;W0y5jpV)p zxF0WyJfW7-(P(qsC;OZD%|Q_Ly)d*B>sfuoUVd98L z(9S5c<%;Mk#M|P~Y-ZIcQT`D0W;ef&N~BCHSx`tAF2e)pk-(TAb?Sl0_aM`_Hn{<& zb#j9T0RRAZ^F3Gr(~zb%e-)G&nuu1zP-3$qVa$NgPT55u~oy-wdMm2@lBkDv}J=l$V`F5V>XkosKra z!)==m2jwUR#%nuXcpuD1DUK+JGq9fh@w5N@^LOI#`jN@M^S=W3@8=ICtMf;YA%dwr z{LcCj@#YTNrCEN*8B=Y7U`znz-IhzIRX~578EZ1pWi!J+w`5qObG2%yp;WcL0teMV9+u3jG8aV}?^kokcR=F}a^? zV1eel+rR&fMIi}#OI8=Mp~3>0E5O01^kOhv82VVk28H-~avA7xnW+JEA~Gv5_hT;{ z`ABSs7MJr@c^=T>oveBy4y+wSb1XVLfp|+C8h6XPQGkh??5K66>R~XYfYROtzdr)E zi*>+sSy5sPq5_2dimF2kv&P^HI}5A@0WE+%cgPEf$xUGz4+1J`ScRi7EWLFul43$z zl_Q45qKw)Cgyuf*Y+tD`Gg3Xfh!5SxFtlPJ#SGcSSd|o$vKB1TO|F7p#i$iRR}N&s zl^TV;8F8$cJ5O7==SCYXEB+K%&e(o#{#ewOcC_s-${Q_FxDA=7HmXo*%vU$}f>%NpEc`7B4DD@zm zUiW5XrR+t@N?s$3=e008roIp;;6#{jKS?HDg?^rQ^ zh>y>9c zdN+`B0UR??gW(?;1vSX#+mxK}N!~T8Sg_2BINX-WKQ3WNk)cVZo36;3)FzZOSf!@A zhDY=JfnkrTnXw^>vl0XPS5-U%i;A7m(5w%dz0H-mi} z4wSW(i|q@3RMt*J*sK+C*<88;m@uh{)??alPam0KQWq51za$};cv3bB+bH#(M6URN zNQ-i`o>D@aDFFqDUXb2$1p&Yu^g)20%$Pw;>Il6|0(T8x3 zGU{NwbSSuA$TA(P+{LN@n8m9Q>dlB)4qkdw^6=W1-dM&}-`5>_+hOdXTuUAelXyLZ z5JHa}I7d~h8%d!WQc0z0Ue%lwA8G~3GGO9k_sQ>9`Ww9%(!yxuA1nuz(pQ%=$}`ub zjWWP|d~c`7N>wU7yCm@5^7!$$DkxSuW?)i#hA?ntw=FeJqJ~O(BypzB9N8q}+s;at zV^rmgjX7>4u}?O5sh2WvoDva5oJx3G2m*(OVZggrBV6Wcs|YPC^Qv9y7G{P`OgRK) z(EdlG6hRFpx0tIHNaI{BgI-2ZT-aPC0Sj0qBT)oo5Wav;}D)sieHH|KsOo^wkCpXv_QU#Ud6YcCS+0NU} zER(3Mg^?yr^NzZ0b!iZYXPtC#1knIH!$2ks(z@1lRtWw6PSwPh{ zV1=sGRlHh@otPm?#+fWq2Yt7uHO)wwG)?=N#7{?IHsxq!@KyCSYPHwS%i!a_aXp&> z?lv01gBeS5(l86V(#0u-pApfwRkfEXs&j8H_iAp*D+tByDS|XVQXZ)x>PC%~NQl~K zbEo(%p&V%(;HP{MOdWvEPpF#&LIT(1pVYBfuo<-;$6?Wo(%EF~6#{GtVG^%fR%IUw zvol**a~VHR>S~fYEmz`9Vt7nI#*4P?i*Z8Dd_oKjtQxQvy_RO&LyI@mtuv>R&@GUN z)VICnA7pj}e20U0QEqK4)Y=j@b;91^8*ViwUE?q<5_}hOE8CvV#8mM^f^dmG@T)hO zv=APAUwIRBVUhgdq@b=7{k&4}TItq)BDp&)e!5srLdO>~4=RT8ow}K?qM6ATk*UT5 z;X7E;5Z;pj%lEIO!^y&|N->lyVSt=w9jyF!u@9h>hnoQ%y5$Gb;$>a&YpO|r(BsdH zHmLR8?ax0K)MM0@{QLPg$O$TztIi`g9>J$pQ#!_~V97z-i)I%Q^bK!g8p(_5-^q~5 zV0_O9()3F`M;aVf+l~s9Y>`u0=Y{%~=VScV7y$j#4B>)dpi-&G{w;n54wjHGz69s_~3L&cli1PLH8 zMMG!21K5ra-pQJC-=?;cMS_Jg3Uan!1pzr=&#D+WHRS!rtW^ zNGC&BAXg3;sQT69CRUA#B$W4l z#T=njR&U(d@3qb~;n#U@_T9T~$$%P%W&W4U-HiKrbq22@9>AqX`5wL-5|C3_9YmnQ zeZ~BP+^egxjj|*uk(T91)taNzQP*((X$IDg{$#AGv{H4y&}JL(yRT-S-g@$-Jab9r zE2>df{HnwKjkbaHV%>~K)R@>qJd4LW96sx*LNCFEm+pmMmkkZGQ(GKk2GT}A(-u33 zD|?HU04vJ3kzlMrZy>Fo1VcVhLkX5M)*8oY+EkWSRY|=ksic0b)N@{}<5{(pY#d$u zt3sE{RH;3!I*u81J9XA7fvAg}(Z@oYgg{_@CPK1)u$DbMWwpxvOpW z6EqbMW+o7LiyiGb$wG}|-a|nsIp(HSSF_P!>pc5h04-vp%jbUan<=l#R^z|twAW0- z`Jv`aAunB0DHSr`m}WuAzG&hZTv%A@_zLE{Sikhg5C@6a_m#2*il1}u;>`J|Y{shv zvT`_V3Ne8j?MX9Fo@!AuDJxzI{;SeSnwKGwVIgQq+vy&lZO3B3A5PuH;P5zlU&>441`BIaZw+WNmHuo%-oil7D}{SzN74L$FT5Td zMWRu=TB0lds8Ti+ou&T*lzUKryJ&cvrBuOg6EP6I?^le>Wmj5E#W7NlB1okwQB$=C z4pp@>yWYffW3Oz7qyjD+Dn5XZ;KqfINv?beWA8dnOKFkV%i1%~o;Po1ObB_+Or?~D zM#yU+=L0Ag%)+nRZ!kVTgO%YL(hsZ=6|=koyU=PiU*MmE=B$B6&4mOmt5ykVGfD_a z#IjY|Iv4upNm6UI1lG2cwStz7;L<{m8MR`pwbBuzSJH|l4}kO8bX**dqTlk3HNiVQ zIh_=vi%D@dy0~~dJAadRrsL7cv|r99IdD@ZiKBzb3;{td*`v^kb3PB?*Da6=K82gV>r0>%$rh8{*}E*5N@JUDSV2 z4?cPAatH*U&~(q-RWyz4vrgyaNu_%2LpXOVtpBe4fwWfNM7tw`q@q%;>HUaNInS~O8={Xo_CKLy2n+BG zB)3TK8n`X#{%kiul!pT;>4v|?n7ks{fAPf%JI>kV;N{WL)dQ6GRY!5}7P~9-$Il*7 z3DY+0#x*$*`W_#pbORU;HrQ)$>G0jjT4-a7u2FJ`_J-}vSFSTQ+2JhSn*#r4&{d0G zo5r#15*~)c_YQZ9bHr!@b-U|#QHJ|YiEY*8<(UopP4sHj@Pk|3Hr(3@=`2O<8nb^; zJ@EdCZeR}8IIwTOZ-4B*?rvdsyZf^J+1LBgTF)k-(w?@fb59*5_ndoPSnl7c=Nfar zmjZa4tyoWQ<3tp{=TnU4WNW<^Cxk2oX$1wXSglqCP9cgW_M{!+c+AYCZB#08pe=`f z1H_5LE{ntp0pG=8Z~IAj^UofqPP+@_kk~Wty?K9r@6QBd{HQ1%%5`0rj0&_U`DunK zvZCk#;uS@Ffz&ZYAD{(0qXl|Kg=C~cIZ^b83q~c11pQ{s1TEqeV?1K3nhS+WessjD zqtR#~_zID_MsA}JWT7M?(mu_k;)1qRDXCeSgDREs43BDdAOsg3jT7PaqY;A7XOEwL zJp261XV0EIJ~&L!ii#sT^JdLzqVnSeRrEAm+mxIX32IkXOiIu>8O7aMOS+kJB8v97 zn&r#ce619(B=Ev&NoRtdFp%wuJ9a@OFHfjvmSmxc&00sK=vimZ!QtWT(}Sl6hmSu> z5S4kLU(Q!6rW7qegr*I)oB~?XXrz4)u^js#q(@XeF>4W?bCr579FImLA~#iz3c5hY zq$`^c3I{qlX`whs*@U z1Pb&Zr-Z36-ddv>$rU@HQF}r!gs~C87ET6}sNyORtv@b;aDX97-Fv$~1cImyyLq+8 z&{7VH9l}c#zq|aqeUS?a*|f(G3F>fa+GRry6{j*0tT}INn`Op zKF142%R=JpR&IFn;)}ilRRQ4Y1I7tmfOf}Y8z6_$6_t{J=UAcRYH-s7;$8Mw?Cs!; z*NQHVyIq{1^NSW%8cm)Uz!+gy(KetJjuE87o^4$laonK=lx?FL zrnlDGr#~c4D*(5qOm>7$JFqb01kYY{rv><&^i-aL5}G9k%~=5(a`N6ZWyL+5_TWKY zz18D(eoSj#8-IW$Hg=wXmQL?ZP&OpNoNrE@oMX^ELwTwZgN-)&O+Q?q1s5nstlE(| z4Gs+R_D&r`d_m1EH#1$x2Wy-MuFe5d+%yGey3!3`4@@?cXgoGS##uk*)4k!cLMPe- z(|dW^KzS~^FwD0f{=>oh|H?y8jdv{)pAd+r4wVj4+f1Wg<~tT(HlZ=W(CgOgf}hF^ zl}xIM`O-U61GMQ*p@@3f?R^v@fSh)d&w0DzxFOVzFi3wm5j`09#)8PJXwx2CJ>foJ z2Gm@G+9yNy{?ui@plU6uJu^ihbf-aR6?O~gJ+98sobwXg@P%0KHtoB*stYsh?QIN( zPR}7BQ81o#4bpL14sRxK-HN=^9cn~dzO5BOCpI0WEyTL^N^nBgRQ1u|DylPoMZ|Jl zkNq(_J$j%^0bKkEAwD_^+{yUYx#X5El3qc(<8w>TeO-2F$m#q<3#OtzN^XmIO9%dE zS(lEe$6K zOOSa@5MOFBlh$#OEe}vIwpY#b?ImhnHa~AKU*Bx6-h6lS_b=NkSbw#>#;x}(1no2u zCxM_~(7?^B=8xv(_8Md~FSLZ$H@gyAaxZ1-$0NRM%bquA2tWNi!_BYFi{>TTUT!b7 z+GucgGtJgN}fN4vsd7blOqr=7g{BV`I4 z6~#KpCzf7`O&4Z+Hz|6B)m+iddVBYYw5$Yy3S8(yq;oX&Z?62D<+Sc@^ZE|}Sh^oj z!F@Od?N>xa1yzKVV;y)9k-91s4~Ki8BvJiD)Y>&_JA1a5Ygfy|@jYeL2`SC=8DVd; z#Nl*vTk*Zu_Q*LE^!*3iuG`k?GKxfaocphIT~S*}Ny#xKMK?FGB(*3rF-IXSC%@cE zAulmERY6-JIWaFkFEbe^T$-0zGSR8W&DSx+HOSM^M@JzywJ1F`9;Pq8Ah9Gl11JjP z=VlfcXXd5rDCDJOtxmT z0n?RiDPa0OTLG93WS2$NS;DRZ(Yb=%j~i;4Uw&TdE%w#1^D|qB4<)>6egM`I_;;{<02VylP!4&E&4p%g_FZ~YXBz4ep$W@fXy>8e{gm(9MvzxR8;NhqaH6jK99_68t0 z$QC*MQXZ9G%g^P{>TP*c9h~ovt9Rwm*_f2a^jA5?jNhNoH;X0J zL3LQYDZgVgh!Ox3aL1#b(Hi&>0}zK#;IT6m&s7GB%g)ke-Im%*^ zK!@~LmZT8rQO1zqMoPnl1Dfs_(oDgMb2){`pFynZ z6qBRG$U@hLcP8MFCKwi6Xe2YYoiJA*eG*|W_JVOmkRh|$luH#^w`-Rb(!A3F0SU!@ z4GW|-v}^DiB`V*bg=->gBWtu%*xgfa ztF^Yey^xM;nN&au>&H9cbi9De9C7rSa=pdoHFjK*7MF#vI9i~&b$4MDrmVk{FfweB z;lGgH#zUJmW@3=K%k;`L?yWnMm2bM5fxTuW0cs#JXF-)nuuEASL!peMC=-G|w~zI; z!N*#27pSl}^b4R4Xvn=TKp>T1%vYDa{kf=lqu?tDf0^d5w1(-p^BhcxnR4Jy24AwF zYiNn}d;JC~bfkJ%+0>W@)b#a1vHvpo7s{3paB~LjlCxW^^ulZ~)WVv6k_eO9)LL46 z?jsdUE@6UG@-)Ox6wVU|eUK92df4a>-(-5qvV{hCoYfl3jvL4E&aY^U5r6~DGEx!* z0bx2YtW_XbkdcjiqKLt8*xYStI5V0VE_bt8AU3R(4tvTW2#^Fh1c4q#vXQ00hX6kK zD~8fdUyx5oRlla^MRM0GBjI6}?5?h=u2)x8w`Ey7beE+WO|~A)ql}Vi5H0$Ih8fEk zP08Dn-;qh;O*8&2p=lKCQ~79tM6C7-hl#PSk=AW44_nQBuH26mq`XA@KS5=I!h5 zFF*a{&bC9wa~4cozA-nU59Tz@9Aq(`!+;o;+&g4OlU+3!dzruIaC9ME=)uDv@+J;Z z_`J!X)3FO~?$g6@zJ(`cUfv`^A3A-^HFqmbHU z>jVC!&vhaUazMO=$1>A;a7(V|XKbH#HHE?;Y`&UJB?6X`Fv<+P_>o!$>>Q{kf9v_0 znRK;X@SlPP4bn!OHEZhH=fu2v|2|k{*0^_vXlz2h1MyE_6_}*)Y2LV|G0~P$0lo53c`(RajLXh3bGeRF_#DIM3|WZpn>GO zK-6!-!0B_U!y^lL2e;^E;bkR{t$L4>=pOa67PYI{ zi)vl2H?4D2MWwM)T99U{5j8X)>vRpZQgG_8!>EWcJ1TG1)aDGMHewTjpooaiUga+Aq!7DyGF|Nktwo$ z#ExsejGwjsY+xH|Oqbx-f_T-pH|D)&4D*mRS*TF5Qm~6qBK^UYn9pH;R9B3u7z`8R zWrIU-d-5xny?~VE1c;@fs;JnG^bg~A6I2o{IGB{O0S&?ifkq+o;iuYmpL97p=su8@ z37OQv>~aOwBA3RT5G1}*+6m3(N!S8<(bFCmYws&CZFoz^!jV1H|QaT?Pz=d~` zIc)Nz81*7gsE+tq+}6JTlAH#~6BlF7iy5gfWP z^Y1s-+p3(x@=UskzZWsAx*Ke2RZV=J8l)CBkbE^!<%47}Afok9Eu!n$zhHcb4mer? zt+lV<#egi=ON4nxqmHU_a;~OZOFLJR?eTLoU9fDA8P8PE|Ts=ixB(ch-qs)&$0*-H9$zZ`^+P{-Q44p z#C&S=-aDd0$bOX)W`v`bz|D^Nw(1F$vIHQcN)^@OxI%`JvN@h*Mwn%m z`yeB`5j#fTR_vl&U4xwiN$i(b)tF4`gtuCuQwOgpJnQ2(9N1%i1ezG4o*V;dRSlWmspU9Gm#N=cN$PlZ^2M z7ml zS!BA}I{he|vR!>ON3#r$31yZm(qVbj;kmo=2;{)U-T;1T9Vs%s8#|xezO#M%y8G$Q zt>YgGu%B~cgCjwcO^!vB#|u7KUAy!vE|2sLCq%Vup1LTB0sgTI1Xqy$-SBSspTGS6 zZ)Eu}f0aL3KEfM5vV2VP=dYjf%fBy3ewM$0p*N@bSNW6tMSj-58y*$=kW4Av17o!q z;uPKtyRci8Ep1)s(zqn!w>`Ql#X>a2(5G!c+;}Dw>z|pI+@Hq-#X{uFd6HL8J zK2|5aq2Rx>&@R5k%g-Oh-TS+{5ES@V74T6A!q!a{74byEym<}3Njbm)-y_$0mJgWVzPkw-nHmZH~N9%o6WsvFr z$_-Fuw&l8@(M6P=0ArhZ=%&GTu@&!9A>vrry-<@2IqcL57|u{gByFY~AQb4Z+*5AksvS)M?K{4#%@|B>WRfqB&+{8a6!VYh3xIab>o z<2IF&M-}QmpXXmA7oP(=z?3HY?Z2hapXOgJPkPoR z2vF2bW(<;^*A{oU{dL!$N79?rLs#xh2YypdUgp?%7uo|+&M@z#na6?wN*GY|WTI|2 zTSL{04c!aJrD~>YnOh=Lph4?J(RnzuKuKQ7n!{vit_D{?+mXeJVc_HF*9~L>RDiux#`)@IP<@7=5nXQ>=J%Dr9@syBi`F!0{66 z`V!JUIa2%{m-#t&9S9q!l!5j43>PU+R~ssRVlPeAkumdFC>k-tqIod1cxzquLwS<3 zL7PTe07gi_z#|mE01_29VKllg1B>`ONuoIB@nB8+jBIfT3=Lu$Lyp_fV&f(#kbXd} znpgC4r>X}$wT>j*|PDVSn~jm`(MzJ zkRZIU0eGB^R?TkPMi9R9DF*DJBqG^HU>E@l_s~OuUWy)i69jriuB?TYTzYrqpSl8? zAhugTw*oz-$e}+@T(qu>1VJx-4l=&^3GxV?U6K;@6FEF+N6VRSW@o;crR%z1q@1M` zhV-N-h>W2(Vmzi&Z8*+ngqs&MQ}8wAbYGI>V+A|g?cRx|u(uZx&U+*phB47(Zx14p zwLl4=hg6H0ctTn=mbMd`2y~axG-m03yU)cDBqZ~Jq_OeMg&5KpMj40{G|4s` z*L5AoiD@6gVb7D|NCoIb>5l!0S){vNAKIUS5d#PCkyMXl3a2i!=?d#~xd9maagEp0 zsi*0&4ls7v0+Sdkz7NjmB8FL7;Lar!x6NXpKbj>|MFW~p@a}&2b!Qt)A(S7W0>D8a zhQVq4XbhVur(!0H;luKeeGWzflNyO6VH#gYB#kIHg57CLssulzaa*MP!~&}Zu$lrd zKWda^oKb0wl7>;T#wfuGOGdag%1V>qw9hDyab@xGDx-{HCNS`r#fBh#Bm=29WG%~l zM3wy}5EMmbC0R;MuqJVOBdtZMz#~yaGrdWqME3b*b*+bxM4F+BSF*J#^pK@-B@T!R zGa@w}DioQ~4UoBl!!CSIxT2N{i*ze#Au?1tb!~;P{O*B^q^Q$sHk8?@Q)V_2cf7Gr zwP*adjDcVB3pt5UosK1>d-v=N@;~wy`4trB#YKL3JGq@8RNpnpu$3xLGj{zJR5m|P zAipfm^Vj(eu8(KxrkGRpZr3_w{Z;37>KvPRHdv~t)ZSJLR)qDqc#^*{4xbcH@%Idh zv;0;5SN^&g=EJ6Azu8H~LDhqX9AQ<*`65|vRB}oZTDLuJbk|g+a#1DI71?ldR&zX_ffCZ zI%40Tk8mWp#fER;OMy4Qu3cKr;;1UAWPEwk0Q|b>4XpWMlg#v$*O`tu_uccKegiD_ zW&Th8;&xJ;-%gUsI$H&+Pic8EzMQiPt>vD z8tyL!hWF!FX2Oz0r2CCIYhe+?LV@Um$4QU!R^^Yy{)V|)7XqvL#;Q&m#}4OapVUyH zKi@6ZmudF$WaU_%oeC#mq6U=e(AqC`^isB~X!5s=b;owz;Dw>p zLWEXlKDb5ie%D!QfX1l1%6~C~H@}ABzx;0$Zt_=#;^<8b*G^uel8VKlw^;V_vYI7l z^+$)T$+TIk_})GL@plv8pQin=72m!zLl}kP0+)-%V7Af7zw0vtcq!os(&|AKuHq7??W?3b{y;I)8StJAwy z*EN3WHcOI6X^b`&+_M1Q%3mzB+&N(s=n6QrHJ1vwy{JNJIP&7l$>_5xgF8^AVJT{1 zF2C(WDeWvv2qB4VT5VkK)h}y(I!Sd(lFH}??`pFnGRlftq7aBip5!v@q~K{Xf6wSq zXjREmZ)}Bgh-k<2^2nr;16mgqW{YRh@GC3BD`y9z*H(HXhgXj_&s>!#Y_RIq?A(^z zU74A)a@l^cJ~hlb~vorU)!hMel~%SRgW@-4k-$e$fT+? zCH-ADb|Q|Xz5j?SUJKD;t%R4{p0p_nh>9( zw~0ViN{?NZEw&hd?ZA?2+a=ZDkzdGILyU(j-etkjVUOmHMY*EiS z_4h^%eOb+i3M!1i5yd4;&Rm7lq-z0G3VBeo-MaF`5!K&jeEA!0eGCvR@-jlHV}RHR}9n_DNr1= zUF<`E6lm%eNV}J!>+MTX5VSb92~(uPi!Td5_Y3-k`z1T1~V$H#5Xo78w!|Sv}K|ng-8hG$Vo7x1|tBm$sYP9rS z0HJaqy^Am4eDs_vsb{VPVfon8vqtCmgu(|QJt#9FL|K;g5&}UxysQx}CA@!sH+Zzo*m`3&z)Hnp$~oS0M<`zNra?g&rzfk73bpR_dSvDt zM4S9zPjTG?`Zcspb^^!1LXd(rv%G-IA3BeJIdA`NEiL+B^?rmr0?oNgTW@~hiu{(u z4Ow%$Vl!Ob3~eBI4(%K*qbNKme-o?HpP*)z8fogZJ1>e2j@SiiXx;b^`NhR-!R^HD zV%l`-zkqETlt;r4Mq@%!9EYOKBBv!Cl!_#86Di>#_O_75zaom|I|U>@-BLTDnsN^x z|M@quI#84Zh^PR0OlG8{o6;1`C5NB56UNv`NU4i{WMp#1Seh}-Yh(;=Ad91xDBT%I zalP9YGTHVl8AK3l$4Y7+t@cZ^%j2NseZa0uhxyA$?Y3^od(4XMV}Lchq2fLca)cNK zz&FW<>r2tXZ41HT$C&k5p5tJ(c!YF~;w$zUTdh&&ZE43j|Ngt8tFT34mSEgpKcZvo z$1Zofsx{*59Y(j-JG9&*-yjOx0sDT8T1?W4;&K%Hr6$3yOkGP)K@%ByAsvaKykRE)A5TvkHXF*Ck)AB(d1 zTC6@ryUZ~Y5d9f8{k{UOqgEX7SN)>=A8 zQ1;wC$=2kx-Pcz6bFERu$fJ9V9+{xUl?gt*Jlv1+#nb?IGZKt3ol6Uvw^NGHZT)&D)2SI2#I_`C zXHopc@i~@yO~v?PP1u>1$lUCJ!!#vIjd(R%qqWASa3dpD&pfk^3ClT3@H}?z5zF%f zRt=?I!Eb+(x){3B=h&b0(_GORWGbQrF(}=Jx(SnlB*PkVXJZKK-DzHV-mTO1@vMft zd~xK?*`_|{Dm?dU@WrF~3B2ro*t5R_c$}?S-E-8|6@S-XaeF(NXvG+}3>02M#lC*%6V3yCA%<#ZpA+c}#B{}Eb ztFQGsz)bf6q@#1s_xYWBuPLRACXYkYopMcYJvP@Fi9MTz5jQ>C@z$GU30@r2Bb|^j z!r2v0UT^)K@Ls@d;&_~SoE#(YufrV{8Jc$5W5n7pdBgI%fy=n#dl9ir*Chc9NtZm-rD;taiAuQyCb=%C2G!7Ii4nx#qhh~S9DDHO6uty@F z;745Vf&!1&%yK|hoAFJ?JVL>TkQ0-5Y*RBW?p$_wj}q{U(2#W;uoKt&0q_dRl<09_ zo1C?zB4MLR0+8aEV7|$%jhdlRN;Rh&_#pdY#wgvz_QnnY1!ij=fM?C>wsUx z309*rK4o{XN3o{hd69i;zG|C8QMzf8YM|C|0Vy_4Qe z@6kG;)5lMI@bh2%@>jq9P4R>}G@EsZ=Z9U>b*@4@oOOpsI%dC#>S|=_cldu(Bk&6u zRFFr+XX1y1(wZS`aUqs^)m)VQ*`F3a0ki4(_{8+IMGt91IY;S&;4fy3d1(s89njo~ zuU_quE+z`%M|9LwCuV529gFnL#wD{(qQJCRR5LJ*D3OCH6FpU~VEAC6*HK zGb7RTuNcqKZiV(2q`kUV5)v{airacf&qv3okWnYRW`KWApw|r7--K+}%3J@>7z0o!m|KNOC86lCNO$daIGX80{vH)2(zH zKJ1g^n`9pd`^ff?jCNn$Bf$J+dTX?sZoU2j-hp*1*-y8nrgBFJJwnqSu;GfE4G{AL zH6`B?sR?9&gdKQ%M8F=@qKnoc$q>mTxt(rF4=F%I*WqKrUw*9|K~r9SPZ~%86Gyvf zHxOQb@ed+bjy13pYCDl-hIU!15PEmC3$`WqLCT%brO$=!4tU3&I1=ih2p2A~;7>1#2u`Cww zQpG5}55SFK@>tbEMXg#GhR$WyayaWoMG4%=mqsY$ocup8y28_W$dzhm!`=WlxO zx~6H%=ayII7FtVZPA@F2AQ(`Z?4>*C9_Ysup__Z~3JL-d}>SLAvHF-J?l zK^@-r(;akm56c2{e2YS!!q@BRc1@H#!Av2`V<_R9O-<`V1gIRAv?=^RE3q~Dk{S&# zNL2I*aP;NMeDr{qL-hIpD;MifIFW8wJi$;w+e%4d#@NiW7>JuTrVZn`F|7?W4f3;t zo}htEZ3?VF?}}(uWAjY3_JIh0h8$=*6x`ufNdJ~Kd=U_WFri=tKnInOGGtV~;>JS8 zW2TBbm=uK}kI_m9jmlAa9Z?AVmffLjY(VKe7l1JoeqD!bZ5xHV;L=^;=N>7bg-DRZ z7gZNThnfM57{{b!7g1qbf+nolRoDw%BpGTIUn($HvOGDojk0SV0>iJ9{XTXu!SI(~HYP%dH{A$v_y#s522I!xT_wsCn@lW)a%+-oK(suw%kskBQtgNh zpD&ON_84jvEAx)D2TMc@V~JBR*+iRR;Q&(IOt+zQl7D8MeP1lwR;>sY2@%5lHf-8# zVFj=w4&DM)_md~0{lPB8&a5NAfABkP? zeymZ2ucp?g+0HSeNn!Y zT?w49H_=TE1c-tc!Kl<+zPu=aoFwvBGUD^t#Is&6k5(NgyEEp3pah{K#!t>yvURYA z1_frX3-N;;f0!BOnr-Iha*7nvcgq}mylsPFcI(2 zBevPYk^n>AA|B;Y)Tp9A8Z9ATSp5zb2yrdPR;3x(GM#Ob!=b%`!{)!m4YL9UTSsIKbyS#39Q#|?^&i@xLO z>U1g3Wa{>1hE#Uw#hEtWu)=!vSUT}mOhygr=<9Sy)j&-huQGkdW{95C#Bt%gci#Jv zEhg@U^U_g%gugu2f2qs21Vk_FdMlKGYjy{ zv4v}E&GR0un0&12WB#pjoR|J)nPgXZhz*VxNqT5*7B?QYV{w&1b<1LILROF|yJJz8 z23dgNiy^MOBD}cL>4ggyXsrmeT%X@R{24y4np0P2r#@Tz*?I5xB3X6hf^S&$(Bz69$`6Ag@oLo!nL3V^BkONxB(-Fnj1Iah?@G!wqCR4f4A(d9 z2&1qnX@$E zHmysbuq3lMWtSu$5(@{yL6#l>9g;a~%+9wynq8i2ojr33b2a{Lc4@x#>D;*@kn+mX z*fc{Acyu~Dj|F}i6v@T%sxuCm{UKh_BB_XRb{VWnrIeV=x5K3%b0uzO#kHz2i-eB> zvpueJr_219&*UbbIK3H@+h8V!Vx~`LW4_^s&ed%Ccj!mV3?YX73DFEJrSoc@cALA_xBZH1u7utCE9=s{M+r!#B^U z+5|5S%Z?+wOe;&CcvpLru(cyR1fyV2g@5^w?Qr}EJ}e4I9&2?_tK1`Gn@J3L%Wvb( zU{XfLNJWh!RD)PUZJrRG$WLa-iRtNTL8{(j2wggboRL4StSpiD0FvIrd$@5Sih|)- zZGJF4y;d8m@58NyKNVS=ExHgOW>F2>zdxwq-yn2qZTPU2kEo!o_6KXV)#)|)DeK{S ziCg8W82|VS%rIg&C8|SDF;~KaFrnB%pL~*{70%TJi{lZsb(t8r9F$^Dh%nnOBN#^c z$0mp(R+iGg+C1^r5mN?1JHgB_V;Ag_P%C0Ej%AX&JezchT$oKUm+BLzRh@jwdg^Hf zN=`i14neP!39zBl#Zza&SjWYpJ{nSg04pA9Bf9Sbc$~FZ&2rl|5WedvFw7z3L}u(J z>7+`PNhXutn#rV@UNjm8CLs$o{{(<>71y`6r=0o{J!aDOVVwHbC+T7V5+Efxjx*6g zqJYJ>yWcJrSO!7xj24Xi_xm5@>5J!tXGAbr<&EN1Nlv(0l1208&6*TcrAuBOk?=Va zlGAdJo>0Lea*HTlu1QMMC6n=J^m54%Nir(ZC1GV&SG-h`fHdJbKW2M}RI=;=IbmeM z02S7j8PBL<(rA3On!X}1HWIWvCThtFGoJCqBIk!hRwVfF+r`@tzyAB~{Qdbm^7sxp zxEm0uc%BX>ra6@o z09)C1XiOHI<=Mzc(z=dm&EquZtW>S=SnpsA|Fda5U* zZCYM*!8dB8qN?5;C0{Bfxl#kmwW%R}B+uG;&P1V#THf zV;Cs7nh;1uVC8F6HjpuTS1{d=hJlX^?F*PxWVK-uFAl!;F%e zaxo#{p8lQfL$L7SU~arOOD)ecq)>C|p=%orbQ=Nfs;`(Y!jstdJyZO0MK>7-UD4;7s0z?EQ(Qwva(K9_H);kxgC0t`_n(lpQ8G%_Y(4 z!XffdKr;p#C5vKRYPzgX5)Rl&f>}R?O28+sI*f@@c_LX_l^LY*BCn`U*n|7~`;iL) z?uZ0d!W4WD#sLAwJ)FTj)@oBBX1HfmQ|D}ky2is@F`on0>6tN~%5|9u4Ax ztw|vxcrbThdN*n&PbgQ$15cG_<_t{KbcCSMR+?yQA#lOwidU1W$#KZz)3;!d{O!u@5r>a<*usk+ZpJ!Hz4F~roLL`-YXPFlL7 z%NC~9uCla~t;^_e=&R{UALvIck#Mz{ezhNM6y8mCWS-7Kt|6JqW?MeFF<6?iQAJQmw{bTX!l;WrBQXHUA;1u4>9`Sc08@1Fi?9tUXRlMe%s)pNkw1o>n^RgL=WyLcJ`FH4QIC!+hgf zcQIw!KeoYW-A*it!S-yybKI_+ZCRyHci>sFVfvWZnW@ppyF1baYl9Su_JvCKYUR_9 zTIk3(Ua1-4Tl9{64Q*d=+4r3(tKFN{4VWFp?tj8_53Ic90(hLYSWl1CHWa_-Q&>Tj z5;2=@Z`D+TDBI<3g;_ z&c6Y6S#p?(?dRv8-~X2|3{RI-vYg0OrFqGwrYB@o!xrYqXk90?=1Ep? zR_TWHSk^~o!V{g*g7v6w!Bn5j$aUe5)Ee%g{Ca-YterBYK=>yjqlI5DMb3)d-M}_Z zKwcAk`5EZxf_YL9nkY~cmiC?<${;}LF-c~P7?TClCuS}R&xB5#>4k9+1lP#H)jhxs z;exgst;Sb;Kwva&PC3;=vS~ozp=wiQBxiF1H;q8g^9e!sNqIg+*NMr&jYgK_Ik_V$ zx6yB2bG_`SB#L1L6li8VH9nH*m8?ia*OcpqBwkm6(QE^_C`$$H5oYlex# zNoKPKa@^q%$B6Qd+v(+sTSz{AtY&pqnS> zsxIgz9gq_#iq1@1&R-&6j1geIdjx!8dF~To`$#^wr*hRZj~*s>OyqdaMs;SjZc)=b zk8p21xMAWH16HGv2`sNBjj#*WXx$2n^#g{p#S``{@_n97QGr|QdDtSVS6;qMZ+swPe>?4kJ znWt|6hJPpQ$RXr~dv{Nc)7w9S44;K{8BR%f{%ZU7 z!|Uxka{ll3-T5Ey|2qFYoRF}VB3nsTLKaL*T0w@A9qR3y?c43&+jsB(f!{Z{gXSf# zkOn5+p1=C=`uw->#aBp0z52*jD>3=+<(~v(h&7hc=T&E@o4bOv=hQk+A#ift+#t&RcXO2J&Q-}Z=dVwj6B5*qj z>Q3iSMuH$_xM=MnlMKusAc3Ul{tZ|&Kz&jPEh?V540IFp50caydn~v%SU{>6fXS8d zZ{yL1vv*QwfnAQ^&4GyJjq zX842N`0PbIhW>#=gK%-8FIF?RyTnIIq85fhS>#~0j>R%E8ss}joQ4!T27F0c`iEFz z_P4av4b*M!wWu5J6Eci)?rym7@%tj5OWfD-`UE0x8f(UCZ{I!H2}yPvjdVDsL1h)u zu;-eUDvIN-NSr(`B*xDW<<*B**R+0}0^c{q({aC^5Nyb%tsV?I+?r2)>(=*tqz8k> zzwQET!&0H<-D%feVIS4BRuK^OJkqqYK(gydq8>rh!FN1fMf<}iX2;ZNuN6Td7Gb=4 zIAXE3$4t6A`xTdh(?^4u5wZXi^n~7a}<~2K3kA9Z2yKxO-6Xfdld84H#T-sRM#g*lSu-{Vp_JLw)>?6gK z{J9?Jl^M+C@7U9(F^e&6=3FxKq0c#GtJpXb`KH}(@7L5&Y8P&z@M$-({u5Nc`E6mI z?cOYKSJfc!8UDn>M)?yB%jNwSL(!QxSM#}RP@H4jX8EsBe_O_FU6_fHuO~KE@A)cX z7W*GY%p_N-3DHy2-|mh7SZu?At;oM)GhVGU9}oTq0vN}n=mLxac$~YVb6sacB`?2@ zLP}~{Vrfo^tzUj#s^;Vgyjtu~kxuF4goPAbZZ{j!Wb{TCKFq z1t{%=1#XO73E&onx3}lRva+zA%Y07TAXdU`pfq|Vr@{cwb5T0m0;|EzMdX8lv`|Q6 z9pqY}y%SS#8kSmVxVVD%U%%nu=?AQ(n?q47OEH63D(m=SftFH{Czxm#$t9nOB!#}( zoT#ixVy(@)g*aHW;P(EZ<;_RBa=49lA5z#>WW}Or&$Xp|up(RHrWFP=#D;v7+DbL^ zu`OQ;${m}QyCjVmW0*^+jl-^NR4#S>y3n&3o~mDMHNj}Pu(n=tVp$e)A)>UY04wFXXH?eHPAPAR9##5)<#$vD2Aw_kJyA*tFC-$C4<@ zZibJ44MyW^eD^RIzKrp}RVT`x$Dda&zIUQ1nu-E4r?X}GBQ|{9g~H$06*A~n=o61%S2+M5EWtg-s;r6E8pKR@RV9gjywqJCZ%IR)uOMT(umw? zvxyS|qFa_BE`6-YF?H%)jOz)UL&2olhHOC_I7_h|XFJxMm?9L9PcVc=iuJP3UemRa zPGr6SYDCg((ooUEVZ5oSRNQRS=aq%ZSPNKtgozVMA`DLEd<0AYv{`Xa< z)A8?=bq%&@^OKrK3 zE@pqQk+d9`H4n&4rMne!4kg`0&G%Eb-+KWV*J(cJHUf{w6dQ%BfL@fLK)!iH%b zH+halBKUh-*00=L8~K}4YS`9-T?=jybK6sx-Lc)VBLWe*p(mb>nWs(|>!H)fb5Fkb zm~zL_#S(>Ufj*kJRQtp5=inZXFSwdj_)p^N{(b-P_N9M2*nJg7gfR66ljp9oS*?oS zQNaF>cf~RBg18}6evQXZu>IUmd3p-lK#iunVx`#~QLL$RU@3F~rFlmINmu4!#&f zk5rmzMx$1`dv$xSsX3ZeirMejBY01kmHwc%!=VKjofJTc+~?+!uX zp~50XrD=E~a0!M&zy~nH)mYk~gms_i2wFli<%Z0%91~)SsW3X~08iw6E)4M?{Km)u z%u#DTK`;{ZR4zd{&KqmsR2+qKd15QFFh&;L@VrPc&;KrbYuydc7z@O_l*$0NjS;f_ z%;jVvib>EkftMxt|GrNI-Hc*wq{59=G^H@PEvGmCZXpc@fki_FqZT%rp!uTt7c%O8 zWMi{I5CjbiNHJ@4;#Cn_xrB*19l+r|cqj|BO$As^1!ew7E&R8Aj9P6!>{C*IDI&$@ zOD@b!+LlG^=i@O|G4|362Rjf}lul)&25-L`q%(5+hzttp14m9~v2Rx_(rzF7yvr^gvmY zn|H8H2)7aA)`ATz6zQ`MYOZZl*_)qVKy`k-u3tj^=k>aJUB9efRp-?uRKHf|P`{|p z>L2PKY32f|%j$Qiu4sH!UDV|F8`)fd^SEALuc3NfT}J&esaP$){>rrEJ{&>Do)nf5 z@wuvB?GYTp(-!Pme@LeS5`SbnAK4|pQIFlMya4RxL_i09GCQ!84rm7`^Hn_OYPKjx zb|v-8=C1YfNmhDr=tfv*Z)zuUEcV}*RS@3_f(*VZSB?QF;zKzqN~zxS{kY+M>G8J#1R;YZQOgWxaK3YFoohz59^ z&01S;6IUL8&!;#$eXwU6%vOC_SuLwHm^K2OY+{O5sbvlJ7^e8f%!C$sEfqvAS|U}u z%Su%*V#Tf+8Ym_tw565$z^53;Z+wC6N7(=6oHJ*};{dBZELFiXbI$+z`(Ms~# z*AG0WBie2dI2TC!%MCxEidH)u@<=gv8Ge^Iu}LDx#s%93xwQh7c9$~ z@!GwvABsz1yEW7FyKT_`8ll&Ag?xh7Gczldxyref+NnA5w&>3Y_+xdOP1koDR#8~L z+`0WkM8jxLL_4F6XgB&H+QEx^(H{J_Ki;-xd5=@CyS-3tx^q{IevTfEx1+t$^=Nmr z`TUdZ=pY)7HuZYn_pb8t=U=W1CT1@1-4ETmCV2Y*mkg6M+>d*nl=J!LyZ`y&Q!!c} zZH#V2-;rE{nVGW-D=Q1-cWY*H;7?(S#{5i;IJZ0vydk;G^nh{7Yhjo0F>3n!u{uo4)&b^O8!0pNHF%WlHM zUEI10mwV&w(RFe74ebK0ki>lw^j1-`eRdy2*@+$_PXRvPVo2`5769JfXzTE^(I($} zAfmgV={f-z0{jBn?{P$+DfZB?XsrXdgPOq4eGnN1Mz9NM zVKfNb+XkAj;xQ-+cQ%uBL0oKh!k)y(7F+Q&kh7v{A;|8 zbV+%c129Gj0wTH(%XX8(nYdsap?)Y!?6~6%-)V*cJ2tI0{*;p6L#xKS>Q1Nbws_ag z=tmSbz4>_b6byfppt~!g2MFBpc1+@Mg*MWVZP{82T68`<`2?>?443X3= zgsPSznMq_Klt$GF<&X@NWce3lNw%dB*oq#EwoLsKZa0D?U6-Rf&wc{kNW7u3Rwo?N zh#;uyq(@5wtKu^ zwFDap9vZ_L^lANMj!!0M%*!f!nF((QBCP{js2xG0K9B`W+b0?pc&$oDGRI@a6^K6E zPY~sJmnw6s zA{C%9?*xr**C(?@4^eIztM>rZd*~!p0`gRqqn$%S65tXPK|GT^IDrbR4${h#tWX?& zJleDiMiC~Zi^@`{H>o|ha=NxOx3Y46arq=RSzs}QB0#Tz%27-a4x}tk5iZdltCG?o zAG?p4j=}O=Irapl9?(4cLPU?Eht!E~B2Y|sg2c*|DakDscab|mLPYGB7#!G-7E#`Q z$7#C*5;NcTyDfKMf6&niO*Tnp(D=*ei24pXF zq}nv(BrWo=eTY6atY&MKRRZ}!Mq!+z^d`z^4D?u@=|KrS`pMhE)ga|GOPW0Byx$^1q z;`wq7Ise(mmlL|-lgKAr`p&Y{D;orB9I1j|_JN7w4)M~t4#9W@$)Nn&FwYeWZ zrpyi+4l%18U|duA$7cB1Pi*dtr~z+b4mwvx+_C`>UVOlb)2f**QETYXxl|Qp$Up=v z0Q@W9vGS>fch4;oEoCRw9X*CJ32Ac+@QL=K78=Zk1wtAPbNPXw z5I$QfS8DG7E8zHB$W)fD6eQb_R5v^FT{<%Cla=uyX}nUYP^yql-%yT`oAfzN9m}NI zb}<^UdJ67L<4j`ByuY|GUy5Ue95zWuAN;@w8+^2vx+7lAWW=ao*O{S{z;0=Ix%O_U zTv}e3C#DafJKr&-T3VPtUHjv?#R?v9t-}qrdlFkDB{`hMP;HWOI79a#S!ffM-mzwg zB6T{jzQE6%xCxeR|bGW&1bX$EcP%lV(y%uFD+GS%cZ47v-D$iNwimq>JlHQ zlB&Gc_ZovZ>b9cqqNf<~U=jdoxE2hIopH~Eh{hz1ogt${CQ|ed?5%g#I^h6;Gq=3F z@P27RT58l(EDJqx{P|bgv_y29;-gDWm4j^) z+YncS0<8C1E#bC%;Wd%x5fMn&@#^leuzT#1bJgYPn=!CatS3e`*jdpL9s`X|Y&(3` zSl3k&yHO@awgFbFRl00;ebE+PXOeo`@h`)=#OQ0ay=rwYTh%Jc1i#59u*<6QJSv}9 zUAX9mdAVa&m~%1P)@+n=31UvCq3+`5SSUjG9Oiq@wN}?@6a~6`R%F0nRxki- zAb4W>2C_~L;j|n0Y@SiB6PPbrRRsl;OSay|3RX5)>j5jN0(r%PTfLU&`Za7gEn5H# z;?W;)$zJR=EIVcjw`*ipAP%<1(I(uo2Qk>()>Kl=_oay4gHG@TM|WHC3*2myv2rPh zOrebVL~U1?>G|CcN$wzRO?=|kLw?BC%&*E7tBI#FcBbxz%@j$iRa?74vz_AarWMa; z@)-R#NRI~0!Pzdy@Q#Vtw$dj}1fUl^g{?VE$Y_&XTGX5u)Vj^4SNEJ&ZdTwFT*{lH z;zg4h^hi$eNb5?o_G>h!Hoc#A3LM|5caYO#tk7z@t<0J;Nad=e6mtyS_Jo7zCOc+T zM-ttfA_F7_n&HGnuV}?d3$vy-3j{E8x}CMQ>!WBykn(^ZzWs)sOj~Gt5X%eMl5N=F zgBi&wIg%{*hg@O@iO8!j_>dCi(j-|;`gM#OI5m3Vs=2&6kg=iu^guYuJP>8xpukAM zrC`G9H2y))tp{1GiJCGRQnCPZGNqyH<89zzdy0YeULNB~#y@-0#iieCWH8N1! zB?QdfYaMWqwz!3tM!L&ed1I)?Cv4j?C8G_P@)+Wj#M4^fnit58Z%}aAbQ2Jxo)5+U zWsIOks@eqc_6#G)`l?uI*d+#m!n1z8>lLbn&x!jBQTmUvy65`tAFz&3Mftht=~Afd~A zFP}JV?7s27758`Hs>zreIF^T(TPb;!eUvzpk}BOf@vQds(m^$uai|NB$~U}#&IUDl zrV_>> za710Bmr6sTen4jy)JhjL+HIDyjEZ#2IP)Yr{$f^9&pf->84EGw67O5#>Zu&@rw1`d zyc&#larmvz`}7_qK98%>xSrHe7#}wV+c;i%nf_dNk+0UGPjA8beNsjj{(`;}C$-kB zz~+gQ70z%Cr!ru0z?zw(&Bp(DBVCMTYk0b$uQyS#x z@3z}s7`hE`Eq())ulp`ITYEL-WZ!K&UMF@lICq+tn>5EWUezD4 zFV*)3$vGI>NwSvbvj(}KY8?9$3-tM$`=I{7mW4~V#YdgibQZn1!B#cr{`&T+)sAkNmR!N`Xbe2 z0QkN{LrgV>)zsz&fut(5{B&s;7vMZsd3vXk@d4IQexWDd2yRZ6ciW7x7{{+Oka?;} ze)m-`>SHge1?}3(!O% zF)NSXwr!3QZ|%`q$m|u{lB4rO=BI!8_^_c0+o-|6rD+@8ja)@)oJ7s z$w5SQIdG5R1+KeJfMEJ)8^G6!a?S^;eocOmP4r&&qiiTB zlN5I$m2Y9quoEO-@ZB~oq6x25OI{ADJxq(TQ@HTda@gXc!ALytE&CUUC}r5s;b;qFjuk0^d)%p$b$G^oNSHuqyl)H zGc+(TGci#Jb`1#)(92C>C~-3h*B7y3{i$NbbDQP0MNs031puvi3kS8L1bCc%T5oR~ z$C-b>pJGrKIg%#1aqR{Lo$G;?lvu58xssAz?~Qi2I~2EE?re5;B{NY0$B5+=5OBaD zFAj$mJ@gKB8{E}PZy#XNc<>sY`d#+6U$UI8X>);eR5YB zA=60g={Qs()rB&RhKSQbds(i-GFKy&7D9&hq&NjZCDu0Yw#9lO_H{lqpcmz`U$pvh zZi+^uBjQ4YGQ*)DzE4NRg;%B#jxV&1nxariV#HXNLfr%9WDF8+pNQfJ3=m7zJ0naH zCy7v_tQa>MOdmImWiK5KF_nrcc{kRwiDNcX)Q1r^8&}rNANJ0N# z#0@B>dv~2^OR=pIwVTUP3luYGv$Z%8rZAfH;E{q$ z11U)Hq4 zdf4>vynSm+7&3CB0ghX(mgil%B!21i6T*Oz7PY-Ab?Otlg8N47}H@ zEp-k(-z(}^Sz%R?D0{IK2i^gQ&i)6HO|=^vQlJ#3%(5iL<3}ZY0pGs*YVv&Yc=BxW zczP(Nhtto+ufMe9pyN3|Z_wYj#NcBv0}lQN%as~w_w?c7 z+0`-b?5#o;rGc&S5?;)Tc}y}+{zm6dA3`}W3^M{+f-H;0Z?Wm=*oqPM6a&gbA=Z!<~4rBJ631psMR3(aug2_b4*+uL=9SiHyqz?1@jOF(_X`pW`8 z<09rvP`h>OcD?QWs4ecu5#Rb=GB5q8T4jPSI?Zs*zXXX(lD@6t`1_aOZyoDr{C5Q}3^pQdQ)BpI-|NHh| z{nOtcfdU;UbYEPUpG57|SMZI;@Qyzs0-nO{UmsGWR)O?a3htAmSKmRe*Q`y4N@aqQ zazSjsRJrPt20&{10I~cKsCNbcfREpZ$&1PHtM8g{)i)tA&f&a6`3l15Or_4{zLWD&WIKM1@sq?!-K6AX(Ek)Z#0Npiim`QDHaea(ioIuR6N!8WD;Au7K2=uy8{*nl9Cl% zCXi(@@Dsfor@?hzt_ZMO7<{W46X6&sg$SG+SQNo$TZmia4RQ>!Lnx`3Lo`9i;GbJ4 zNOay>k2>OA;?cC@=%)eIO988R2ei_{v?Xl|z)r4!agnW{8jV3Yk}1`01C4kdQXHn* zS_-k;7Bv^usNsNb1&{}v6*3{MYR{Stl^R^2m8ycuQ|NF|6j{gj@xJqux8Hu79{jy4 zLAwr7f*ajiYdhW5+v|6BR&I8;ch*<80)WieTA%F`SH|y+Nl9!)Mj5s}6<5%b@w}O_ zUVQ_@4OEi3M-DftP3FO8>UIgp7peqHvWHsGuKE2$@6$RcLJ=q{9i5VJaTv^tLhc1P zGc+12?8%j?xQG|?a`!!ng`CY9>TJy03O!UQV>`aqX+Pct@=+@2t`kV_A@AhmUjPr8 zj;Y~DyX%WpGSDK^$QrH>=nBpIOBc-m9%S0 zY(65ckZ?Fjo%xvT;Jc`HAO37>!6 zAH2)P`soj5Te|BzH@aJEJ0EP`!YkTBZf>lfx5?3D+y-%Y`k9XqvO7Ot`;~AXy*pH6 zP^BG~p&8?7GFy>owR&ZoL}b(p_5eSOX!WaAD1|A#2WeNdPU8L;c04C&XctJ}piYer zTipQ5#GXX40LMax;KUC6*hB5ovN|N?VTZY1`S=0^q)H)3Q_|`PZ~R2|RAO9&uBCf% zu2XJg=SS2sO0xHWhnpKKA8fAOUc0l+IuE$9`p|7eHMgqz5|s*`?v^>rz1%_tGi#x? zZ39y$ixe_p6KU#v>cQ6bB3Y2)~G}xsLXk$^Ta~cyHa4luK>+Cz@9kmbIWH4 zTbO1=j(lWB5)=~+%%;DiN%;rpSp#&evemvHJ>40r-R_&r~*p_A^nIrR}ImpO;sa07|XCV&`@RQpBtg%N2XWwqf>?AgPU{ zd;X6~4P{&x4phgPeRKj`(wHH?pve*UgG)q1&e+RjvvDL*nTPO2ZYK^X!J7W$S* z63X}?x}~%@Lw7#U@~Qj0ZCg)YMKO{xT)QX3q4BZnJ*e_>gb+`(jF!j~a$G>2VaLIW zuG~2)Qxq3zc4n)M_uuz7veIhz_T8&>ez>q0S)!dL<%P7XIjZfKFLSp% zu6k)u61914vP2ZHsFOXlM3|du1|BeoK7{*Rf{0oa_xosd;DB)w50NSX2jO6uvW@6v zj%~BYu%p%VUWtxuKoBnVQ)|`z(o3jZ`vl;?6 z(O8GEaRE{BRiJ1?@Q+Z&>`;lZs9#O)nQ}MKDs+p+Qv;c&)C<@85)MnL&_|H_-StJC zvk~fy%igLo@osAGA3D@DHyW$XZOKt-D#w=(Rb@fNcraIa+j(-pUBQj&n!*fZrof+T z47v3zkx;38qUsES)OnWg3YGC)C}SJxpmm&coKen9go z{?*PG-OSHIbw0AUHFZj!OsGn8jsfNaq_+wNVJ=G`hLTN?dblt1aa)yz8O#_;L`Q@FTaU1D~jio=7*Qxg6 z=}W6GLmsbD0~XJ&!>p!1Jahl$?1u8S${*LOj4~UmqJJEZaN~vHKa9go5_nT@x#QpcK_N>RxR4 zw7}3nwoKHHDO6?x2*S?8laV(~!E4n_jb^1XR5Z~90qdANq=ZZ&HibUYvt2kVk^DM|x2f=}ar&RZt@EGd?3x(*mV)J<3h6BnOI>LLnO<;ET9&q&r|J7ZmG|GK| zDaxK%98!~mN93NytEPHY+5rA!ijQE+G5FdEuc5KU*_p+;Rvw8H&3-;&yV~OSBGmUT z35~SD^zHH71_muy{Stv^NZWhlEYfmOXZmPV66H(0t% zs#Bh)5h2^XOo#CJwB>GUDj;oJKg)GVjRYXt`rCon8jrF99om7og~}2BX-DV_;|tFY z6buUYlqMYexu!rk*U?=^C##_A;G+2t>}xO5>^%fu(>|NL5YtD~&nGYV0{56MMGq(6 z(p84Q!wY-Y%GY`J!t71Wzbi`l42#oL!{f9=x*_H}NxGH=<-gO#3*Fbf#QP_7*+N%> zlP8lu(@mti6m&PDv$j50bRDHEhRyqb6d3cSv`!O43P~#`bu5IJWs2}alMl5sO#-!r zp%ne=fDF<&rJkF|fRe3oP}~9OfJ}4)i`;KJZyKGQZ{L{>qeqJVbpmjYhmCY6rcqzHJNGc+(TGci#Ja&>g^b=AvFVL1J4HdkfH%~#fMm))Ca^J&kMGySs+ zA&OFq3Urh6a|`l|Q+10|i^?*SQ}rrya~PP;&f8;lWZw*hlT)^)7Nq9-9yig1YN^mo zOfJbU(#=fKD@ZI!&R{V0p4c@@B>DxLw?S)I_TI?enEIIjyoxlexPt+BoOM;(j@vdA zeAibTAP?(xEjh_UP&h>Z@76_(IN8Oni>3$yla`Jp)|9A_lpVKd(U0gi^uzik9a2_y z5wtIYM3Lt*XJ+_>7I(L#8qcMbUYUkkneUTiT`|d$8&ArSr198!*%VTnhAB6Vm#Seq z9@9U6{zb}@mwNBW8}c|8BJ$^4cTOExpU528Ca>4)TS5rhGq(J`B!$TwfH#tpFfw6fVpsqC``U*JM4^SX%wIdd#;VqYImZ8v`RMqGNUjGfI`5& zOk1P1qbsR-Le2n@q;F$9ZVa`?dN(1w9eGQd%gvj`&*!hz>h0$3&G(CyNJfqsv&yn*$dh5pb*mXO`fBBpcE-l+yH0D;$^r5u zc}2?|olIwYiUkqi#dHP~T2pKe&k6YDd((OP2I0bJ zQp>e$qa3U%_;M1pK#&Qz9ZGPr1_~9l6~`5pf*i!aK&si6$fO!X)M^ijHjo$g*)LlL z9t~M89972oG!#Iw07r#sEdw@qjc0(6-R? z2k|+DXpBu&k+st26>$A}=`yw;>5mtwhF44?zNcghG>_62k^N9LL>@YBN)9X#;Wsx0 zSk_LF9&~b?cUJjR)}92EnbMXqvA~a#{U+uV=*xHZ%xf!;AVcRXW7V(G(q?qUV5CJw ze*;Uj2E2h&LL@$Q#`6ZmY`n|P(Hx%K2Lt*S!PijKOXQY2&vu5hB;Kd<*zdBK(RTqf zm#N~Fpo(RIRNx=Hb9)-1M4BOuJ%Kwu+8NU4QtJtx)}96qjK_d$51s{o3vrQO72`2X zMD9^lYdH^M%XT1+_tPDwqn86PGP!Xnn$DcYlq>G#m zOpRyy2DV(u0}u9y^Jn0DviwxZes_OI6b6;OyIBLNF0aCr-kI1yZVC?KQFY)Sx_X4j zC|n7tY4m9logdowWduH2f0$0V3j!QY(6s+5EN1ZVS+jE};qii(aDyEF71BWf$m~%g zxJSW^qKvz?m$A6MTg=xBTFRxUQ1(ZiM+erT{|(M&{NMn^c(tN`I64cmTafUrwEL9M)TghnVnrV;q$9K zjV83y6l}%~7}QIiAXrH@LO8qyV_9Vc=SmuEt>!r#Gm*laYb`Z!3tSM@ zi?Bq>oKIIp_ye~Yj1ws=;{tUo*c<~;S|$sPa}?H_NIQPpYBY(bYXKh>Ce}c7)g6IG z;^S886(ku_juhm=!ZD>@qDZC2^eqT{w7c3Jh`5-6am#n)@ci=+An8C2;~8mAaw(9! zf1!BIh2bey_#zXfIRQ$OWK7JE=!z03Q!sY9m;g30GGAEqHRjCnC4yp9Y9WN0>Qros z5?)n4+zopMm_}F#Hl0$XRMKPxnKTxZ)Ru5_rVJcY;dZ9c_)KvJAR>*7v{4I|MC8vlny?&OjMs6MsF>y;0EZ+X;et=6r>Ab=FVAYA zop#jTLd)^^p1)&eB@#6wqS6_Zi!YVu`$Ivltn2XG22xtJ zh*1>9{Xr)zb8Kzzfks2oRdRL+`$FQL>tND-ox7e|Sg+UL)4H|2nX=xw^yXaD?{}x) zy@@Zc+ELu?jJCtG@2=Mz4!?FU!xuBV?901wQ657&M=7_QLdr`& z&XDCcE6<=C#Y55bUC?x8tuqp}GXuKIax}&>Rw_j={{b113r4drc$|e&!EV$r5WVLs zo^nc*Cfik)27*8>H!d820}`sbjy>5}q;{~KhD99sh<+ho!Z;}fAwjJi?3sD*&Af@y zU5Bzf=m4>4h5STDZNxZkrl1GE@gygw!?7BVX#by2v{Cd0qJAp3vaPE63Dsx2XV{*e zp6_-^S(c9}Bt=m?s&a9GvOcL%)F<0B@JmsL1BGiLgLRps&lCC3;O%c!B(b`Y4j5oA&Il^6)E<^D(E zJ@)$#X}%(K_W)|vNIZ_h`1EUwH;&diUsvo+E)&@(pR z0t*zRgVkCB)fQByB<7W5CW921Bk^^MQ%g!R^U{k!5^iFQJKcll@4x*z9g~H#M4_C9rEqvJN^0Iph!^ zz~&%Z@n$@-y!Pgh-$80_{w1lZ_tCFEI3&jsu!OB)zmIxV_3G=@tEOdHza5QyFW@Yh zg;UN_o}_FP#w?w1_G`!IY!IgG=Rf=#3qtW6@boZ@r%h~#!XzEXJfVl{H`$aQC9PWR zX3COuK1ebkL*L^;+N6dXpMJuwe{xf|-M({&Ie|-kAK&_n%|dtX^CpXV75FJ{K#;-0|52K>Ivqkr#2Kv&I8=FAeuN ztr&;%@r0(cdE`1kJ*%hu0bq4M^o9V_3vkljV4kL7z=n?R51ipNX#ka<&Jl&gb2+Vs zd>V#9>cIGz)q#ahaCA7~F_*~Pn|m>z0m%t-pb@5~0FJwjZmq`P6CI@!SR)NXKe+p1ZXRr6+17^lohj)I{Vs>d)<-;yK! zH9d-ev7G-|cC1P;-dc?k;x{|JPJyWC}mxI>QE7~oaFhp7i7hY=5wzbBbtKxYy% zCyH9G!U;qwjgPv-B$Gyf&)x#byXSP-2k*2yHU418qm zku&$xx+S4lO~H@WM>OURe9!ujp7%ezeh1jzX}9;=?JMnet!4|pL!&)%fE)M?nri@B z>`XmyX1t3`YqD_K1?dR=CUZH6hfmEg!Lk}`?;qI5VZa5RV?P`KXC&~Zf=zwl6lNfl zdN^%R%gl+f&HCi}?b{afMmU7~31`*^*KgjkgiOU=kk&_5?-)lf`s^6-T3GCD2Cbym z#{#JZfe_zG9RrYO4H4OZ@D$x|A3&dIk@Q1LT}%OnfduTmz7Ie{4NYD%)Y{MoV%P@& z8f}!=0^C5`_5v@p?RvufQA4zo7{MdRPmp$*?YDzGS|&F zOiX)k5z=x?OcA4yw^%4nJEj1B1*7_W#YH(3~N<8j6fG#*i{c{Pbx>n68HPr*~r7(MvP~b+3 z<*L4>HY-(Nf~w=Mwc-8seXP4jGSe4tyYhBM=))8U&aw@BSL*o|Q4bsWB%H~T0bHG` z70UM7ZwM@NxV6*S4sXe#vwi<~BO`Khayv*($v=c+a5|1pkP>Hb#crGI{#}xP98t`- zL}1OUhxrT}zW=}i2PVJ++i$Tc16uDq0W!b-pn-a?uHt;{lZREGNvf^ISf}-9UIGrxG9+>d9vDH> z8K_^d_TdF3tj)byQ#iIsp4NG7B_an&-FRo(csF$(^gYwcb(V+SVDfhxuLQRvUg^<$ zo=%KC1(IuM6?QX{V66-{J;Q8A24q?p$yU4&5&gm z(#92JNJXYfskDmOJbM4eZ{wE#@}86dpWo@70_{unEQT7GO;13U@RxNPL|=?}zk*`f z4bMVGF@Go#v z+vTrL2IX*R0$ewb`Ovnt^r|=@e|3{@MX>fET>1U_q;HT_DP{%6GOsa?!;UGJ>{*OYjl>KUKrhf?HN z*z=1@*h#o$spR~AAcFtH?%04n?Vkv@-!1r`uD0i=R^6+$qDc}0q@`2?m<76Od*NzH1`PnOcU-#^*1YP+J>!`jjzDuiD-C)G}J$-7Ak<(r! z>TDKj*)xHe_;d;hPebI&LqvD!f;p zW$Llrgv;ts@@cE1KHY7f>(PT=F=u%J(_QsITySo}jeD&2$e(Agcoe6OPV&sbZmGYi&|}7Bz6z8Bnxf&=n%H2=<85RP6Q_@{ATz#65azIVV)gT zC+4H%n&W}CMa3NcrG%>V^yRs{9k_!J5*%?y@lk%Yk0iaE-o6f!pcdd$B~WA`k0-;| z&(HdmxuF!tu-$FG+froB>u7`@Lmp)j^Q4Oz=@-p|_3RzS6`2rZQv;R};ji?}rw?=z zO+@5P;u>pWMf>1#8N2xdis92!7sG;%0T-fa#`lslHt8aJCI8ck`Nv2lC9T}kR};BZj^jhSE=d5LFd!*U1EKg8D5Z*wv)YvJf9kkaoSlNY?6UJB4IRnE2YgqZeA3N z=)rsr;5PddDXw)PHqHkF)ooZN$xfQCuL+OBoik)<(xt~oj+?FsN=!H_oq@jo)=HN& z51);bkT$w|$SXirf} z(Kbv36n&Xu#!0u#?Miwymcek(FeC>TiRlKsy83W-kZ|b%L-!i@J9>L78%3ft#8W}e z!Ty*eS!S46=%MTVP3b)+-R~X-s=5)mPW-9sUO>M-`$1PXNS*CmcG%$l^)n_1V`~PK z7qWP_uoj;en%7O}JuPeWE39ihovn%+af)u(zXI`b%WE2!>Ce20TlJ&A>9@sv?K>qf z&BX6XjW8T$%)~EcG>+xi1T&i2!rb3ooCzf<@79Th67Pj;YV&1yOP zq2WWU-U6ws+1ogo9bFbjth^(B1lO2dr2~&+TCu1@%yBFY$X1eFAZ&ZnQ@mtJz>n^A z0hV`rc0tua)G+h|-&iAbH2s+^Wn$&}nh}8+Asv7`@N`@;sByU%Bx+Lwzb<6 zC#8IRE8sp=*|jXUhxijN?kv^?s*N?}SYgXB=c3^4Kt!W|B{hOi*ceby@0fq;is3|j z<0UeQt{caATYtj3D;UY2D*?Zja&23kSGRTMK4gg97WfvJv95Sjs1w5ZrJ)aGz9$(P zdROyi$)zGrZiVxPyh2V{ozg`(Gc1{y6#+_?`xXOk4@{W8E#`yYfI5TeXif2p%U=@Y z?-ASbCsNdZ+D|^Kv=OFEC%)J+O#f3>{qE#2LD$~>s3D4F!RYz#lIr|J5iy@qJX5pU z%sHA2&Z0#cwe{gX3uK9srm=I$IR(QVuQ`0PF~x9yBMALi+8UF3TlQ3sZyN+|lQi%; zDOR4W&nG0^xmvuph*qF408ksae^MCrSdpfEe$5kx^@oT#GBU;E*37(Y2Pp+;Xx}{$ z42l0?Pqen!Cx*8V%N5xoK0CCNP4`sr6OXdca2-9Z=Pp3>xZ=i#r-{?vbm3j@+Ggv= zkj5X>h127IGg?0hKgs<@t*HKLh4W})$dm}FrIaooWHU%9YEKbc2>N8lTO`25hSlDWT29A=%|SMpOy zunK%k;Yd`GMmS16m``GK8hbd?Pmk-ed~Uk2c)oSMDebE2@l#0tR)i&R0xCeU2Mm+r4VWXhrSi zmr4N^u#DMTf`mdxp@Y_6nRg;q{fYn;I6wjIt;YVDHx>YJ;2W)^rphNxSs&Q4;taEk z`Bs^tw)0lCD;Q9H(Oz3)aiIG{XKHwcPEYknv=`TU9Mg3QZs6 zx%-l!(>)84i5S(vNMqw?o0UF)RRXt<0PR)iVpST>D@8mEj&-BkM1d%D$05!In~GSP zX%~{1zun0r!uSaWzR zVr-KPj+_+2I+)~hR{Lp&kq6GP-P#6ru6ZsnMr;CK6eorhdnZktsvxTxtpN<(Cey`>VNROHg zpc+=5N9SSEXA8V^l@*qbH1MDgeMnSp`+QZCOp#z!+RuK) z{Ifw=-oj97HH>GO7|2+nYpaB8FN_sc)8Ql{S>i2}j@K1&@S2O)tO1mN>)9TtwU=Gp z3pNO&_Buna=dYPP?_@j=47FG`1}dPFTrzL9ng8nKO?bTxB|Fo5z`hE&1aczlIT93*RLa}H;I=RKZY&WI7Jz8uI1 z@19sk-S|XeC=cP$W>^ePV6W=OJ0Yj5e!wh{VDyD=O)Vp0q8wL5hDWzKEL<^hel)p! zs3p9*l9IZ2a(D)bpA}HhF;$vc+e=Tpq^g}(SOi0fM^ftTrUYcJZbAMAM zL*H?-ktZYfIKP-eV#i1)hCP@3bz@{b$fSi%$^~;8#cd-&Ykvd%+tJaj<3j$BlKs|) zw&n4?FXU=`lQ{nU;w27~>`m7L4s*sqe9Na_BZRw{L7(~J^p8Jcn*7q+8Ztf1WN+%j z?(VfrHI@0LL9JLyu?(Az88kB^aF)J3X^+jBc)v(L$)iUqd06qw?wYg_q=aHzaK03$b8~d1 z%EUg;v8B7Rs=#t5sxw8P+{_r&vp==Ds+CM*utQ22f3eu=*ij&Zs|t`kO7Cn@ z@G6mqby9Ff-G=u$+icr5xnl7}cB7=y{q?WHnHG6N`>UQdyl_iSt&&u4? zu`y&JIjE;mnDnu4(AZU%9L2VzY%hJ^RCENu)Jje`v*67=m#Vk3+3-6j)SgSS-BQZ* zreI`b2t0`%Xu)HG7W0K@3*n$OJg9g{j&C4&aXD}~`(rf-p1*p07rLb_c?M&R?S%Pu zst4$KfO@QS=<~;VLBFLig>u70AE{b##G&t9Cxv(11Z)qgcS@Wsc;(nH!oTd4Y#f|P zDmJ&3=-PoB&kj==jRSlq`NrRrnt*Y^2AP<8Ra^l)VY&*(un`S%JPTP8i|hV*(POX} zGNguy6+KK*tl_r^7^4=>O=+Y-qW3lBwHSUl+fq?TUnYjmouzjc&EY($pOQ>6ON~EF|x%$PG1}1t}#%`biyDei6hzLmoZylxN$3F1+Ibi%v_R;?AF> zYJYB5h9Ds*{Zx71^=?}aV%{2hK;BDV8~H7iKzaAqbZ>r5CjfivWQ-@{7To?$!mhi| zvTU*rS{d2UfQ@Z1+vsIr>Ugm0LZaJH%vq=}qQ73ut~+R3&6wfhO&#?>>hY=kcXXg> z#kp&#(}Nl5tff`$%X^vp+k*0CZPmADN^$F|UCe+0BDNFoF8)aF`B%B!zUn|Up+~zB zhJJV2?o88$21Jaf)ItaEuMlH+2! zP$PPdVPTT~(ji8%%174?19bIhrNE_l42)P~^oIiJ0PBxKpdnDwSJI1}Ito4h&c`gC zcg|M#m0f^#K8oa~RH#!5(LaBa*OdQoSp%>6daRe#-*{>>+6J!>t_-YV4^6U*h6{cm+Q znL4X|%w=ec;V#jHQYjY~lY&!nqR_kwkJsluDM`x1y%6xy*jD|VQB?$|nktB+`Tfv- zPR|F5aBdQskcwQ_d${>YGN~DGzKp@3q2TpuT?yZ+--OF?f2O3yH}mafDBf#>)C@yC zT)j)9^Itg|1dE_N-n&KjTjfn0C1mC=#^WCER~}!a~yTx~m<Q zgW%(8%_|L;E?XaY>>g(LS&$rM5>{R^dm=~o)zg>7X)Tur1Tjr)G;q`xk(|RL9B5_p z>~~)DI|IYU6f!uM&E&OF6+Y%d;pOaVJY(Y=>S$$xwSOhlC1kgZ3^xW<-!5#}kBFi5 z$d{E0*DwoFR1W``n!l>Q8N`barHXM0=WAl;H9~!DUE*>I58dPJ z44wM3JZ61GRYx>t%6sMxmVhVgm=|%)j>|$~;F@t*j4A-oeLz(Cbe!nluAKk>w%4-P z$gWRoQ|?B7fZjWjON7bu)0Gp=PZJ0)G>Qzp)~_2yo9ECfEy9R^fY@%0K$*a3BY=Bx0unZ|z99naQ>m8Kv9hIlh!~jE&i=ahN zk`yUTQ>s?WE(SMlrXg(+8*f@Jn;d7d_D|9S74&ik1*_3oF-$%57wLd3Q~O7V5RgrO zj&xxg7(ONXl=5$P25%!V2(nqzL4+FChV#MD^c*yRPANLh31)cM`zPVNRQKL@k>Wd> zu=Ya;-*=ei*xE*BaJbjeraiGM?dX%?~iD0}uwBp?QxuS7q3$XKBRUecjY9g&7j`1nJ45^A-2`3Yq)SYXSu!VXW+D_$vK-Nk5*W&}cpXLI{$-L$oAQ-k^nsd6jbi z9lH(=y>5GK|4UuHs&>}{Vj<5z=14V~;m}D6%T&uR=o{!emo%W|AH>B`8|2U9bPdlU z%zj%yyiz1(P)Mi4`QVkl!M3i~sj=&TnhB?{87cFv7T?;O5K5gL78RM>gB~#MYFEm7 z1b@9(xd88Ve&nBF2Ctr>y(Bl$a?<-|WNh7w>M{&xefw;3PZs|UP`-u$;Stzd2!{9Xqe7|%;9i>{4TCg1M(L&gKCx%IVjHr~aL zGq3+TBMEfl8c+@}!rBAlc`@JefT6gql1jWE2U&W=S=2*lWzaBLQBgPBpt>`R!_ipZTf=NCCC zwL;lT#gSozG!*jIbumeEHU^C0h#&HD*>2cef+&DsLr$Ut@~~uef2fnnVdCm{3-7w* z!uid9+6V~Wr){k?;yJ>4)w&8T9<5n+UY?(o4HMdR*~{#VG7{QQ`?}gJ{-o`Ub|dq^ za~co2ATG4;Jx|&n(7Qg;j>~CcysL=9yUOX!l$-XxWqlvbs5>UK%E?TQ{Vd2MM~sVT zDxozg$Ryw$@<&NFk8IrxwzK{BqI(+-jtPVi%a)O_(-&(o?UcnHb}_&6sxPu4vOd^i zjmyq$wRZ#CG$U@;%g4F^&qj@eloqiC%bavg4pst(O`DJaEV87x*lO5|*lJ>ST(I6nVuw%Hvj;0 z1KPM9<Cxf!3-n!2LJ#z>|oGFk>7B%k}2>zESpQ94gRMYLW^$XHgm3lL(7{R9; zc;7DNk%ABC|BgrPnkfIzFXU6v`omz|LS_yV z5CcvhJhne#>}TP=!Tby`pox3#nuPHWL@@h*A?1)7p+BX6U6hs5S2y4Z@cJA zJX$G;w&D}6V|$@ba5kZTM$%D>OR)}2()fpRp6(HG{OTn4X*BkAdM*{{;%m4rY literal 0 HcmV?d00001 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 + } +}