Phase 0 — Jev validated with the real key (5 Persian scenarios, api.typesafe.ai): stale project name «منشیانه»->«منشانه» conf 0.73 (ambiguity band), STT spelling variants «مصطفوی/مستفدی/مسطفی»->«بازرگانی مصطفوی» conf 0.93, exact «دفتر» 0.88, proper _none rejection for «کاغذ A4» 0.92, and two mentions in ONE request (0.77/0.64). Exact model version to pin: jev-1.13.0. Latency 0.7-1.1s per field request; ~450-650 in / 64-133 out tokens. Phase 1 — Observability (DB-only, zero extra LLM tokens, nothing is ever deleted): - new tables bot_traces + bot_trace_events (idempotent DDL in db_init.py; ORM in models.py) - new app/observability.py: _jsonable (UUID/datetime/Decimal/set/enum/dataclass-safe), trace_start, event, log_resolution, link_draft, latest_trace_for_draft, set_trace_status, span() context manager, save_voice (Path.mkdir parents+exist_ok, day folders) - capture points wired: input.text; input.voice (+voice persisted under OBS_VOICE_DIR); stt.run + per-provider stt.result (success AND failure, latency, model); nlu.request + nlu.result with prompt_hash/prompt_version; entities.extracted; routing.decision (difflib baseline for the phase-6 A/B); ambiguity.sent; ambiguity.user_choice (buttons + free-text reply path); user_choice.pick; draft.render; draft.edit (wizard-side edits detected by the sync loop); draft.commit (quote/supplier/material/project + trace status/quote id); draft.cancel - compose: persistent ./bot/data:/srv/bot/data volume; bot/.gitignore: data/ (runtime data never committed, never deleted) - tests: bot/tools/test_observability.py -> 8/8 pass (jsonable cases, trace lifecycle, JSONB round-trip, draft linking, status update, save_voice day-folder mkdir, span task). Verified live: voice file lands on the host volume and is gitignored; rows + files persist across bot restart.
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
- Send a message or voice note. Voice → ffmpeg → STT; text/STT text → LLM extracts a typed intent + entities (JSON mode, Persian digits normalized).
- Names are fuzzy-matched against the ERP. Several candidates → inline pick buttons; no match → listed as missing.
- The bot replies with a review card: everything captured + a ✅/⚠️/❌ list of what is still empty.
- Keep chatting to refine («قیمت میلگرد ۴۷ شد») — the open draft updates and re-renders. Drafts survive bot restarts (postgres-backed, TTL-swept).
- 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.payloadholds a talking-friendly JSON that is converted into the exactPOST /quotesrequest 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_stepsback toPATCH /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