Files
ERP/bot
whqreza 1c853e50a9 feat(matching): Jev entity matching phases 2-5 (per-field fan-out, variants, candidate ambiguity buttons)
Phase 2 — jev.py client: POST /v1/systemone, PINNED model jev-1.13.0 (drift warning when the response model differs), one retry on 429/5xx, fail-soft (None on any error), usage/latency returned for observability. Config adds TYPESAFE_API_KEY / JEV_* (thresholds 0.85/0.60, 15 questions per field); no daily cap by owner decision. bot/.env is now mounted read-only into the container so the key never enters compose/git.

Phase 3 — matching.py: برون‌گروهی (one request per field, parallel), درون‌گروهی (one choice question per mention sharing the same full candidate list + «_none»), درون‌واریانسی (all STT spellings in one question). Routing: conf>=exact → bind; floor..exact → ambiguity; else/_none → new; missing/failed field → difflib fallback (bug fixed during testing: a failed request used to record 'new' instead of falling back). Resolver gains full cached lists for suppliers/materials (paged) plus brands (API+DB) and specs (EAV values + draft specs). NLU prompt now emits entity_mentions (6 fields) with the voice-variant grouping rule; sanitize_mentions guards the shape. handle_message runs matching only for register_quote/update_draft/add_material behind JEV_ENABLED; jev_map threads into quote/material resolve_payload (Jev hit skips difflib; exact writes ids; ambiguity skips difflib). Spec effect per owner: spelling normalization only (normalize_brand_spec).

Phase 4 — entity-match ambiguity sub-messages: create_ambiguities() builds deduped entries {kind: entity_match, field, variants, candidates, details with Persian percentages}; new keyboards.ambiguity_candidates_keyboard renders one button per candidate + «هیچ‌کدام — ایجاد جدید»; callbacks handle amb⛏️{prefix}:{amb_id}:{idx} (applies the candidate via matching.apply_choice then re-resolves/renders) and amb:new (rejected → auto-create path at commit). Every user choice lands in observability (ambiguity.user_choice with arg+candidates).

Phase 5 — voice variants: NLU groups different STT spellings of one entity into one variants[] (verified live: مصطفوی/مستفدی → one supplier mention); tools/test_matching.py live section (RUN_LIVE=1) exercises real DeepSeek + real Jev without touching Bale.

Tests: test_jev.py 5/5 (mock HTTP: no-key, happy path, 429-retry, 400, exception), test_matching.py 11/11 mock + 3/3 live (real NLU produced 7 mentions with correct fields; real Jev matched «50 کیلویی»→«۵۰ کیلویی» 0.98 and «کیسه» 1.0 and correctly returned new against the emptied catalog); observability regression 8/8.
2026-10-12 21:24:03 +03:30
..

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 — 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<T>    │  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: <users.id> so quote_events.actor records the human who asked.

Commands: /start · /help · /drafts · /cancel · /whoami · /unlink

Quick start (Docker)

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/ (compose service + the one tiny ERP-side patch).

Full setup, env-var reference, flows and troubleshooting: 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:

pip install -r requirements.txt
python tools/smoke_test.py     # → ALL PASSED: 48 passed