10 Commits
Author SHA1 Message Date
whqreza 787824a19e feat(matching): implement two-stage matching with DeepSeek arbiter, fail-stop guard, and zero-probability filter 2026-10-16 03:13:21 +03:30
whqreza 9e848f535f feat(bot): add DeepSeek crisis fallback engine for Jev failure and remove difflib heuristics
- Add app/fallback_matcher.py for intelligent crisis entity matching with token and latency observability
- Route Jev failure/timeouts in app/matching.py to DeepSeek fallback engine
- Handle MatchingFailureError defensively in handlers/__init__.py with honest error message
- Enforce strict 'new' entity status in handlers/quote.py and implement defensive get_or_create on commit
- Clean up app/resolve.py to eliminate difflib and character ratio heuristics
- Pass all smoke tests (59/59), fallback matching tests (15/15), and matching unit tests (13/13)
2026-10-16 02:05:01 +03:30
whqreza 76b297f823 feat(audio): add pluggable audio preprocessing and denoising layer before STT
- Add audio_enhancer module with 60s timeout, zero-transcoding default, and fail-soft fallback
- Persist enhanced audio in daily folders with _enhanced suffix and emit audio.enhance.result event
- Connect audio enhancer to transcribe_multi and transcribe in stt.py
- Forward message_id and trace_id from handlers
- Ensure consensus_text fallback persistence and remove scenario leakage in nlu.py
- Add test_enhancer.py unit suite and update smoke_test.py
2026-10-15 20:40:45 +03:30
whqreza b44816aa98 feat(bot): phase-7 monitoring digest + deploy checklist
- tools/monitor.py: weekly SQL digest over bot_traces/bot_trace_events —
  pipeline funnel, Jev health (failures/latency/tokens/model drift vs pinned
  jev-1.13.0), user-choice quality proxy (Jev↔user A/B), difflib fallback
  volume, STT provider health, and the plan-§4.5 archive guard (>5GB or
  >100k events/month -> «archive, do not delete» session); real-traffic only
  by default (calibration/test traces excluded, --include-calibration shows
  all); writes markdown to data/reports/; --check exits 1 on warnings
- docs/DEPLOY-AND-MONITORING.md: deploy checklist — server env vars (TypeSafe
  key via Coolify env or bot/.env), recreate-not-restart for new mounts,
  single-Bale-poller rule, smoke tests per plan §7.12, one-env rollback
  (JEV_ENABLED/OBS_ENABLED=false); bot server deployment deliberately stays
  an owner decision (no service added to the Coolify compose)
2026-10-12 21:50:40 +03:30
whqreza a61da4a378 feat(bot): phase-6 calibration — mock seed, A/B harness, low-trust fallback
- tools/seed_mock.py: idempotent additive mock data (uuid5 ids) covering all
  tables (units, brands, specs, suppliers, materials + EAV/brand/unit links,
  projects, warehouses, inventory, quotes with termsheets/line items/decisions,
  receipts) so phases 6-7 can be exercised end-to-end
- tools/calibrate_matching.py: A/B difflib vs Jev on 35 scenarios (31 positive
  + 4 negative); collects RAW picks with widened bands, classifies against the
  configured thresholds, sweeps EXACT 0.55-0.95 (negatives count as binds),
  recommends FLOOR strictly above the highest wrong pick with margin for
  ±0.05 run-to-run confidence jitter; markdown report + real traces
- matching.py: below-FLOOR picks no longer suppress difflib — they log a
  routing.decision event (source=jev-lowtrust) and leave no result so the
  existing difflib path keeps its chance (0.54 «پردیس» was lost to «new»)
- config.py: JEV_CONF_FLOOR 0.60 -> 0.65 (removes the جعبه→بسته @0.61 wrong
  candidate offer; correct sub-floor picks باق اسپارت/کیلو are difflib-covered)
- test_matching.py: +2 low-trust cases (13/13 mock, 16/16 live)
- docker-compose.yml: mount bot/tools into the bot container
2026-10-12 21:42:32 +03:30
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
whqreza d58b619107 feat(observability): phase-1 silent pipeline recorder + phase-0 Jev validation
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.
2026-10-12 21:04:50 +03:30
whqreza 693e7511c6 feat(nlu): clean up entity leakage and add generic Isfahan acoustic rules 2026-10-11 10:12:40 +03:30
whqreza 19c3055008 feat(bot): add strict whitelist auth bypass, multi-STT consensus pipeline, and applicant logging 2026-10-08 23:24:17 +03:30
whqreza 77c0cacbb6 feat(bot): centralize defaults, refine ambiguity lifecycle, improve entity resolution and auto-create new projects 2026-10-08 22:33:15 +03:30
46 changed files with 8606 additions and 261 deletions
+1
View File
@@ -31,6 +31,7 @@ scratch/
v1.2 - Copy/
inv dash/
voice/
data/
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
docker-compose.override.yml
@@ -0,0 +1,388 @@
# پلی‌بوک اجرایی: افزودن موتور STT بله به خط لوله چندمدله‌ی بات تدارکات
> **نسخه:** ۱.۰
> **مخاطب:** ایجنت هوش مصنوعی مجری + تیم فنی + مدیر محصول
> **ماهیت سند:** سند تصمیم‌محور — هر تست به یک «دروازه‌ی تصمیم (Gate)» می‌رسد و هر دروازه، جدول «وضعیت مشاهده‌شده ← اقدام» دارد. ایجنت باید فقط بر اساس وضعیت‌های واقعیِ مشاهده‌شده در همان لحظه، شاخه‌ی درست را انتخاب کند.
> **هدف نهایی:** افزودن ترنسکریپشن موتور بله (STT سمت سرور بله) به‌عنوان یکی از کاندیداهای خط لوله‌ی Multi-Provider STT فعلیِ بات، بدون هیچ تغییر ساختاری در بات.
---
## ۰. قواعد الزام‌آور اجرا (برای ایجنت — قبل از هر اقدام بخوانید)
1. **مبنای گیت فقط `production` است.** سایر برنچ‌ها (از جمله `prod-v7` که کد بات روی آن است) متعلق به جریان کاری خودشان‌اند و **نباید** توسط ایجنت دست‌کاری، مرج یا پوش شوند. تنها مهندس ارشد شرکت مرج می‌کند. **هیچ پوشی بدون تأیید صریح مالک انجام نشود.**
2. **درخواست‌های شبکه به بله باید کم‌حجم و انسان‌نما باشند.** هشدار جدی: سازندگان کتابخانه‌های مهندسی‌معکوس بله تأکید کرده‌اند که «استفاده‌ی زیاد از POST gRPCِ غیر-احراز (پیام/مدیا/حضور) می‌تواند فلگ، rate-limit یا بن موقت ایجاد کند». پس: ترجیح کانال WebSocket، ترتیبی نه موازی، تأخیر انسانی، سقف روزانه.
3. **حساب‌ها:** فقط شماره‌ی اختصاصی شرکت (Bale ئرنر) — هرگز شماره‌ی شخصی. حساب فقط برای این کار باشد.
4. **حریم خصوصی:** صدای کاربران تدارکات روی سرورهای بله پردازش می‌شود (همین حالا هم ویس از بله می‌آید)؛ اما اگر ایجنت صدا را مجدداً به یک چت آپلود می‌کند (مسیر R2/R3)، باید بلافاصله پس از ترنسکرایب پیام را حذف کند و آدرسی از صدا نگه ندارد.
5. **هیچ سکرتی (توکن بات، توکن کاربر، session_file، کلید LLM) نباید در گیت کامیت شود.** سکرت‌های موجود در `docker-compose.yml` (توکن بات و کلید DeepSeek به‌عنوان پیش‌فرض) باید در فرصت مناسب rotate و به `${VAR:?…}` تبدیل شوند.
6. **معیار موفقیت، سرعت نیست — دقت است.** بدون عبور از دروازه‌ی دقت (G5) هیچ ساخت/استقراری مجاز نیست.
---
## ۱. واقعیت‌های تأییدشده (پایه‌ی همه‌ی تصمیم‌ها)
### ۱.۱ — سیستم فعلی: بات و خط لوله‌ی STT
| مؤلفه | مسیر | توضیح |
|---|---|---|
| خط لوله STT | `bot/app/stt.py` | `transcribe_multi()` صدای MP3 (خروجی ffmpeg: mono 16kHz) را **به موازات** به همه‌ی پرووایدرها می‌فرستد؛ هر پرووایدر یک `POST {base_url}/audio/transcriptions` (multipart، فیلد `file`، به‌همراه `model` و `language=fa`) است و خروجی `{"text": "..."}` انتظار دارد. **fail-soft:** خطای هر پرووایدر فقط آن را حذف می‌کند (`return None`)؛ بقیه ادامه می‌دهند. |
| پیکربندی پرووایدرها | `bot/app/config.py` | `STT_PROVIDER_1..3_NAME/BASE_URL/API_KEY/MODEL/LANGUAGE` + `STT_PROVIDERS_JSON` + fallback قدیمی `STT_*`. نکته: یک پرووایدر فقط وقتی فعال می‌شود که **هم `BASE_URL` و هم `API_KEY`** پر باشند. |
| مصرف خروجی‌ها | `bot/app/nlu.py` | همه‌ی ترنسکریپت‌های کاندید به DeepSeek می‌روند و خروجی نهایی `consensus_text` + `intent` + `patch` (اقلام استعلام) است. **یعنی افزودن بله = یک کاندید بیشتر در ورودی consensus.** |
| مسیر ویس در بات | `bot/app/handlers/__init__.py` | ویس → دانلود (`getFile`) → `transcribe_multi` → متن به‌عنوان ورودی NLU. |
**نتیجه‌ی معماری (کلیدی):** افزودن هر مسیر STT = ساخت **یک سرویس adapter با قرارداد OpenAI-compatible** + پر کردن `STT_PROVIDER_3_*`. **صفر تغییر در کد بات.** (این نقطه‌ی اتصال رسمی و پایدار است.)
### ۱.۲ — شواهد تجربی Bot API (تست‌شده — تکرارپذیر)
روی توکن بات فعلی (`procurement_assistant_bot`)، متدهای مشکوک STT تست شدند:
```
POST https://tapi.bale.ai/bot<TOKEN>/transcribeVoice → {"ok":false,"error_code":404,"description":"Bad Request: handler not found"}
POST .../getTranscription → (همان 404)
POST .../transcribe → (همان 404)
POST .../speechToText → (همان 404)
POST .../voiceToText → (همان 404)
POST .../getMe (کنترل) → {"ok":true,"result":{"username":"procurement_assistant_bot",...}}
```
مستندات رسمی (`docs.bale.ai`) هم هیچ متد یا فیلد ترنسکریپشن برای آبجکت Voice/Audio ندارند.
**نتیجه: Bot API بله قطعاً STT ندارد. مسیر «فیلد مخفی» فقط با تست T1 (بخش ۳) قابل رد شدن نهایی است.**
### ۱.۳ — یافته‌های گزارش‌های ایجنت شرکت (خلاصه‌ی راستی‌آزمایی‌شده)
| یافته | وضعیت راستی‌آزمایی |
|---|---|
| کلاینت وب بله روی **gRPC-Web + Protocol Buffers** (`@bufbuild/protobuf`) است | **منطقی و هم‌راستا:** کتابخانه‌های مستقل مهندسی‌معکوس بله هم همین را تأیید می‌کنند؛ باید در T2 مجدداً مشاهده شود. |
| گیت‌وی‌ها: `https://maviz-ws.bale.ai` و `wss://maviz-ws.bale.ai/ws/?uid={user_id}` (+ بتا/نکست) | در وب ایندکس نشده (طبیعی) — **باید در T2 با چشم مشاهده شود.** |
| ماژول‌های `SpeechTranscript` و `AIChat` در باندل | سرنخ معتبر — **باید در T2/T3 تأیید و دیکود شود.** |
| «روش دوم غیرعملی است چون باید کل .protoها بازسازی شوند» | **نادرست/بیش‌برآورد:** با دیکود جنریک Protobuf و استخراج توصیف‌گر از خود باندل (T3) یا با استفاده از aiobale-py (T4)، این کار در حد «افزودن یک متد» کوچک می‌شود. |
| ماتریس تأخیر («روش دوم زیر ۱ ثانیه») | **گمراه‌کننده:** گلوگاه، استنتاج ASR روی سرور بله است؛ عدد واقعی فقط از T2/T5 می‌آید. |
### ۱.۴ — اکوسیستم مهندسی معکوس موجود (مهم — دارایی راهبردی)
- **`aiobale-py`** (v0.3.9، فعال): فریم‌ورک async برای بله روی **WebSocket + Protobuf**، الهام‌گرفته از aiogram؛ ~۸۰ متد RPC (پیام، فایل، گروه، مخاطبین…)، ورود تلفنی با OTP، `session_file`، توکن JWT، rate-limiter داخلی، docs و مخزن عمومی:
- PyPI: `pip install aiobale-py` (ایمپورت: `aiobale`)
- GitHub: `github.com/aminmadaniofficial/aiobale` — مستندات: `aminmadaniofficial.github.io/aiobale/`
- **متد ترنسکرایب ندارد** → باید همان RPC کشف‌شده در T2 به آن افزوده شود (زیرساخت لاگین/WS/کدک آماده است).
- نسخه‌ی اولیه‌ی `Enalite/aiobale` (GitHub الان 404؛ نام PyPI توسط یک پکیج خالی اشغال شده) → **فقط به فورک فعال `aiobale-py` اتکا کنید.**
- پروژه‌ی جانبی مرتبط: `bale-mcp-server` (MCP برای بله با aiobale-py) — برای الگوگیری نحوه‌ی استفاده.
- سرویس ثالث STT: [majidapi.ir](https://majidapi.ir) (`/ai/stt?url=...&token=...`) — مسیر جایگزین R7 (بخش ۵).
### ۱.۵ — وضعیت برنچ‌ها (قواعد همکاری)
| برنچ | وضعیت | مجاز برای ایجنت؟ |
|---|---|---|
| `production` (`253c922`) | مبنای رسمی کار | ✅ فقط خواندن/مبنا |
| `prod-v7` (کد بات/ایجنت، `638076d`) | کار مالک این سند؛ منتظر تست نهایی و مرج توسط مهندس ارشد | ⛔ دست‌کاری/پوش ممنوع |
| `prod-v8/9/10`، `pm`، `pm_2` و بقیه | برنچ‌های سایر اعضا؛ در حال تست | ⛔ هیچ دخالتی |
---
## ۲. نقشه‌ی وضعیت‌ها (State Machine) — نمای کلی مسیر
```
S0 (شروع)
│
├─T1─> G1 ──(فیلد ترنسکریپشن پیدا شد)───────────────► R1: پایان! (بدون کرالر) ✅
│ └─(پیدا نشد)──► T2
│
├─T2─> G2 ──(RPC ساده/دیکودپذیر)──► T3+T4 ─> G3 ──(موفق)──► T5+ G4 ─► T7 ─> G5
│ ├─(فقط روی WS)──────────► همان T3+T4 (aiobale مسیر WS است) ▲
│ ├─(رمزنگاری/امضای خاص)──► R4 (Playwright) یا R3 ─► T5/T7
│ └─(نیاز به اثر انگشت مرورگر)─► R4 ─► T5/T7
│
├─(T5/T7)─> G5 ──(بهبود دقت کافی)──► G6 (ToS) ──(سبز)──► BUILD (R2/R3/R4) ─► G7 (سواک) ─► استقرار کامل
│ └─(بهبود ناکافی)──► STOP: همان پرووایدرهای فعلی ⛔
│ └─(قرمز)──► STOP یا فقط R4 در حد محدود ⛔
```
---
## ۳. فاز A — تست‌های کشف (T1…T9)
> برای هر تست: هدف، پیش‌نیاز، مراحل، خروجی، معیار قبولی، و حالت‌های ممکن. ایجنت بعد از هر تست **حتماً** به جدول دروازه‌ی مربوطه (بخش ۴) مراجعه کند.
### T1 — تست payload خام ویس (۳۰ دقیقه)
**هدف:** رد یا تأیید نهایی وجود فیلد ترنسکریپشن مخفی در پیام‌های Bot API.
**پیش‌نیاز:** یک ربات تستی **مستقل** (از BotFather بله بسازید؛ از ربات فعلی استفاده نکنید چون در حال long-poll است و مصرف‌کننده‌ی دوم تعارض ایجاد می‌کند). یک حساب بله برای فرستادن ویس.
**مراحل:**
1. اسکریپت مینیمال پایتون: `getUpdates` بدون offset + چاپ JSON کامل هر آپدیت در فایل.
2. یک ویس فارسی واقعی (۵–۱۰ ثانیه) به ربات تستی بفرستید.
3. JSON کامل ویس را ذخیره کنید؛ کلیدهای آبجکت `voice`/`audio`/`message` را لیست کنید.
4. `getFile` را هم صدا بزنید و فیلدهای پاسخ را ضبط کنید.
**معیار قبولی/حالت‌ها:**
- **حالت A:** هر فیلدی مثل `transcription`/`transcript`/`speech`/`text` روی ویس وجود دارد → **پایان پروژه‌ی کرالر (R1)**؛ فقط به بات وصل کنید.
- **حالت B:** خبری نیست (قوی‌ترین احتمال طبق مستندات) → برو به T2.
### T2 — کپچر ترافیک کلاینت وب (نیم‌روز) — قاطع‌ترین تست
**هدف:** کشف «دقیق» مکانیزم ترنسکرایب: نام RPC، ترابری (unary/WS)، قالب فریم، هدر احراز، و **زمان واقعی ASR**.
**پیش‌نیاز:** حساب سوخته‌ی اختصاصی (سیم‌کارت شرکت)، مرورگر Chromium، ابزار دیکود Protobuf: `protoscope` یا `blackboxprotobuf` (نصب با pip/go)، و اختیاری mitmproxy.
**مراحل:**
1. ورود به `web.bale.ai`؛ DevTools → Network با گزینه‌ی Preserve log؛ فیلتر `WS` و `Fetch/XHR` را فعال نگه دارید.
2. یک ویس در یک چت باز کنید و دکمه‌ی «تبدیل به متن / متن پیاده» را بزنید.
3. **همه‌ی درخواست‌های همان لحظه** را ضبط کنید:
- اگر `POST` روی هاستی مثل `maviz-ws.bale.ai` با `content-type: application/grpc-web+proto` (یا `grpc-web-text`) ظاهر شد → همین RPC است.
- اگر قاب باینری روی WebSocket `wss://…/ws/?uid=…` رفت → RPC روی WS است.
4. برای هر نمونه ذخیره کنید: `req.bin`، `resp.bin`، هدرهای مهم (نام هدر توکن: `authorization`/`x-auth`/…)، URL کامل، و **زمان پاسخ از تب Timing** (همین = تأخیر واقعی ASR بله).
5. ۵ تا ۱۰ ویس متنوع (کوتاه/بلند/با لهجه) تکرار کنید تا پایداری اسکیما معلوم شود؛ در `samples/` ذخیره و در `findings.md` خلاصه کنید.
6. دیکود آفلاین: به یاد داشته باشید پیام‌های gRPC-Web یک **پیشوند ۵ بایتی** دارند (۱ بایت فلگ + ۴ بایت طول) و سپس Protobuf می‌آید → ابتدا پیشوند را بردارید، سپس:
`protoc --decode_raw < resp_payload.bin` یا `protoscope resp_payload.bin`
7. اگر آرایه‌ی WS دیده شد: فریم‌ها را از DevTools کپی و با protoscope دیکود کنید؛ به دنبال پیام معادل `SpeechTranscript` بگردید.
**خروجی:** `capture/findings.md` شامل: نام دقیق RPC، ترابری، framing، هدر احراز، تأخیر واقعی، و نقشه‌ی مقدماتی فیلدها (کدام فیلد = message_id؟ file_id؟ متن؟).
### T3 — استخراج اسکیما (۱–۲ روز)
**هدف:** تبدیل کپچرها به یک اسکیمای قابل استفاده (و ترجیحاً `.proto`).
**روش‌ها (به ترتیب):**
1. **از خود باندل:** باندل وب بله (`/static/js/index.*.js` و در صورت وجود `.js.map`) را از قبل دارید؛ پروژه‌های ساخته‌شده با `@bufbuild/protobuf` معمولاً **`FileDescriptorProto` باینری را داخل JS جاسازی می‌کنند**. روش عملی: جست‌وجوی بایت‌های توصیف‌گر، استخراج و سپس:
`protoc --decode=google.protobuf.FileDescriptorProto google/protobuf/descriptor.proto < desc.bin`
(یا دیکود همان بایت‌ها با پایتون + `grpcio-tools`). اگر sourcemap موجود بود، مستقیم‌تر است.
2. **از نمونه‌ها:** با چند نمونه‌ی req/resp دیکودشده با `protoc --decode_raw`، همبستگی فیلدها را استخراج کنید (فیلد شماره‌ی متن جواب، message_id درخواست و…).
3. **مسیر موازی (اپ اندروید):** APK رسمی بله (از سایت رسمی) + `jadx`؛ جست‌وجوی `"SpeechTranscript"`؛ در اندروید معمولاً شماره‌ی فیلدها واضح‌ترند.
**معیار قبولی:** توان ساخت درخواست معتبر و خواندن پاسخ — حتی به‌صورت «فیلد ۱ = message_id، فیلد ۲ = متن». داشتن `.proto` کامل لازم نیست.
### T4 — اسپایک aiobale-py (۱ روز)
**هدف:** اثبات امکان اجرای ترنسکرایب با یک اکانت کاربری، بدون مرورگر.
**پیش‌نیاز:** venv ایزوله (**نه** محیط بات)، حساب سوخته، اسکیمای T3.
**مراحل:**
1. `pip install aiobale-py` → ورود: `Client(session_file="bale.session", phone_number="…")` و عبور از OTP (دروازه‌ی انسانی).
2. کاوش لایه‌ی کدک: در `site-packages/aiobale/…` ببینید متدها چطور تعریف می‌شوند (احتمالاً مپینگ متد→پیام Protobuf). **یک متد سفارشی برای RPC ترنسکریپت کشف‌شده اضافه کنید.**
3. **کشف مکانیزم دقیق:** آیا RPC ترنسکرایب `message_id` می‌خواهد (یعنی صدا باید از قبل «پیامی در بله» باشد) یا `file_id`/بایت خام قبول می‌کند؟ هر سه فرض را تست کنید:
- (الف) آپلود ویس به یک **چت اسکرچ** (چت با خود یا گروه خصوصی اختصاصی) → گرفتن `message_id` → ترنسکرایب → **حذف پیام**.
- (ب) `upload_file` (در aiobale موجود است) → ترنسکرایب با فایل.
- (ج) اگر RPC ورودی بایت می‌گیرد → ساده‌ترین (بدون پیام واسط).
4. تست صحت: متن برگشتی را با متنی که وب بله برای **همان ویس** نشان می‌دهد مقایسه کنید (باید یکسان/نزدیک باشد).
5. تست پایداری نشست: ری‌استارت پروسه و استفاده‌ی مجدد از session file؛ ثبت زمان انقضای توکن.
**حالت‌ها:**
- **موفق کامل** → مسیر R2 برای ساخت adapter انتخاب می‌شود.
- **نیمه‌موفق** (login/WS می‌شود ولی RPC دیکود نمی‌شود) → T3 را عمیق‌تر + کپچر بیشتر؛ در غیر این صورت R3/R4.
- **ناموفق** (چالش احراز، بن، نبود قابلیت افزودن متد) → R3 و R4.
### T-extra — تست «AI Chat به‌عنوان اوراکل» (نیم‌روز، موازی با T2/T4)
**هدف:** اگر چت هوش مصنوعی بله ویس را با متن پیاده‌شده پاسخ می‌دهد، **حتی نیازی به RPC جدید نیست**: با متدهای پیام‌رسانیِ *موجودِ* aiobale، ویس را به چت AI بفرستید و متن را از پاسخ استخراج کنید.
**مراحل:** با اکانت سوخته در وب بله، یک ویس به چت AI بفرستید؛ دقیقاً ببینید آیا متن پیاده‌شده در پاسخ می‌آید و با چه قالبی. سپس همان را با aiobale بازسازی کنید.
**معیار:** اگر متن پیاده با قالب قابل‌اعتماد (ثابت) برگردد → مسیر R3 فعال می‌شود (ارزان‌تر از R2).
### T5 — اندازه‌گیری تأخیر/پایداری واقعی (۱–۲ روز)
**هدف:** جایگزینی اعداد ساختگی گزارش با اعداد واقعی.
**مراحل:** ۵۰–۱۰۰ ویس واقعی از مسیر انتخابی (ترتیبی، با فاصله‌ی انسانی ۱–۳ ثانیه)؛ ثبت p50/p95، نرخ خطا، انواع خطا، و هر نشانه‌ی rate-limit.
**معیار پذیرش پیشنهادی:** نرخ موفقیت ≥ ۹۵٪ و p95 ≤ ۸ ثانیه (برای UX بات). در غیر این صورت G4 قرمز می‌شود.
### T7 — بنچمارک دقت (۱ روز) — **دروازه‌ی کسب‌وکاری، قبل از هر ساخت**
**هدف:** آیا افزودن بله واقعاً خروجی نهایی را بهتر می‌کند؟
**مراحل:**
1. ۲۰–۳۰ ویس **واقعی تدارکاتی** جمع کنید (با اطلاع فرستندگان؛ صداها را بلندمدت نگه ندارید).
2. برای هر ویس سه خروجی بگیرید: (الف) وضعیت فعلی (whisper/سایر + consensus فعلی)، (ب) فقط متن بله، (ج) consensus با اضافه‌شدن کاندید بله.
3. سه معیار بسنجید: WER متن، دقت استخراج موجودیت‌ها (نام کالا/برند/تعداد/واحد در JSON نهایی)، و تعداد «موارد سخت» که با بله درست شده‌اند.
**معیار پیشنهادی:** اگر کاهش خطای استخراج ≥ ~۱۵٪ نسبی **یا** حل ≥ ۲ مورد سختِ لهجه‌ای → پروژه توجیه دارد؛ در غیر این‌صورت **STOP** (همان خط لوله‌ی فعلی کافی است).
### T6 + T9 — سواک بن و ماندگاری نشست (۱–۲ هفته، پسیو)
استفاده‌ی کم‌حجم روزانه (مثلاً ≤ ۱۰۰ ترنسکرایب در روز) از IP شرکت؛ ثبت: تعداد ورود مجدد لازم، چالش‌های امنیتی، هشدارها، عمر واقعی توکن. خروجی: سقف عملیاتی + runbook ورود مجدد.
### T8 — بازبینی حقوقی/ToS (موازی — **دروازه‌ی انسانی**)
ایجنت فقط شواهد را جمع می‌کند؛ تصمیم Go/No-Go توسط مدیریت مستند می‌شود. معیار: سند تصمیم مکتوب.
---
## ۴. دروازه‌های تصمیم (جدول‌های وضعیت ← اقدام)
### G1 (بعد از T1)
| وضعیت مشاهده‌شده | اقدام |
|---|---|
| فیلد ترنسکریپشن روی ویس هست | **پایان:** R1 — مستقیم به بات وصل کن (بدون هیچ کرالری) |
| نیست | برو T2 |
### G2 (بعد از T2) — مهم‌ترین دروازه‌ی فنی
| وضعیت مشاهده‌شده | معنا | اقدام |
|---|---|---|
| RPC از نوع **unary POST gRPC-Web** با پروتوباف دیکودپذیر | بهترین حالت | برو T3+T4 → R2 |
| RPC **فقط روی WebSocket** | حالت مطلوب دوم (aiobale دقیقاً WS است) | برو T3+T4 → R2 (از کانال WS؛ **POST gRPC زیاد نزن**) |
| درخواست **رمزنگاری/امضای اختصاصی** دارد (فراتر از پروتوباف) | هزینه‌ی نگهداری بالا | R4 (Playwright) یا R3 به‌عنوان مسیر اصلی؛ R2 فقط اگر … شکست |
| ترنسکرایب نیازمند **اثر انگشت/رفتار مرورگر** است | R2 غیرممکن | R4 |
| ترنسکرایب **message_id روی پیامی موجود** می‌خواهد | نیازمند «آپلود به چت اسکرچ» | R2 با فلوی «آپلود→ترنسکرایب→حذف» (بخش ۵.۲) |
### G3 (بعد از T3/T4)
| وضعیت | اقدام |
|---|---|
| ساخت درخواست + خواندن پاسخ ممکن شد | برو T5 → T7 |
| فقط یکی از دو جهت ممکن است | کپچر بیشتری بگیر (T2)؛ اگر نشد → R3/R4 |
| شکست کامل | R3 (اگر T-extra موفق بود) وگرنه R4؛ اگر هر دو هم نشد → STOP |
### G4 (بعد از T5)
| وضعیت | اقدام |
|---|---|
| نرخ موفقیت ≥ ۹۵٪ و p95 ≤ ۸s | ادامه |
| rate-limit مشاهده شد | کاهش نرخ + backoff نمایی + سقف روزانه؛ مجدداً بسنج |
| بن/چالش امنیتی | توقف فوری؛ رفتن به R4 با اکانت جدید، یا STOP |
### G5 (بعد از T7) — دروازه‌ی کسب‌وکار
| وضعیت | اقدام |
|---|---|
| بهبود دقت مطابق آستانه | ادامه به G6 |
| بهبود ناکافی | **STOP** — همان پرووایدرها؛ یافته‌ها را مستند کن |
### G6 (ToS) / G7 (سواک)
| وضعیت | اقدام |
|---|---|
| ToS سبز + سواک سالم | استقرار کامل (بخش ۶) |
| ToS قرمز | STOP یا فقط R4 با حجم محدود (طبق تصمیم مدیریت) |
| سواک ناسالم | rollback به پرووایدرهای قبلی (حذف envها) و بازگشت به G4 |
---
## ۵. فاز B — پیاده‌سازی (فقط پس از عبور از دروازه‌ها)
### ۵.۱ — قرارداد مشترک adapter (برای همه‌ی مسیرها یکسان)
سرویس `bale-stt` (کانتینر داخلی، **بدون پورت عمومی**):
- `POST /v1/audio/transcriptions` — multipart، فیلد `file` (فایل MP3)، `model`، `language=fa` → پاسخ `{"text": "..."}` (دقیقاً قرارداد OpenAI که بات انتظار دارد).
- هدر `Authorization: Bearer <INTERNAL_KEY>` را بپذیرد (فقط کلید داخلی، نه توکن بله).
- `GET /healthz` برای سلامت.
- موتور داخلی قابل‌سوییچ با env: `BALE_STT_ENGINE=grpc|aichat|playwright` → همه‌ی مسیرها همزیستی دارند و تعویض موتور بدون تغییر بات.
### ۵.۲ — R2: مهندسی معکوس (مسیر پیشنهادی اصلی)
معماری: `bot → adapter → aiobale-py (اکانت سوخته) → WS/gRPC بله → متن`
نکات پیاده‌سازی:
- اکانت: `session_file` روی یک **داکر ولوم**؛ هرگز در گیت. `BALE_PHONE` و سکرت‌ها در env.
- اگر RPC به message_id نیاز دارد: فلوی «آپلود ویس به چت اسکرچ → ترنسکرایب → **حذف پیام**» — با `send_voice` و `delete_message` موجود در aiobale.
- ترتیبی اجرا کنید (قفل asyncio)، فاصله‌ی ۱–۳ ثانیه، سقف روزانه قابل‌تنظیم (`BALE_STT_DAILY_CAP`).
- مدیریت انقضای توکن: تشخیص خطای احراز → علامت‌گذاری «نیازمند OTP» + هشدار به ادمین (دروازه‌ی انسانی).
- fail-soft مضاعف: adapter در هر خطا `{"text": ""}` (یا 5xx کنترل‌شده) برگرداند تا بات فقط این کاندید را حذف کند.
### ۵.۳ — R3: اوراکل AI Chat
همان adapter، اما داخلش: ارسال ویس به چت AI (با aiobale) → استخراج متن از قالب پاسخ → بازگرداندن. سبک‌ترین پیاده‌سازی؛ وابسته به پایداری قالب پاسخ.
### ۵.۴ — R4: Playwright (فالبک)
کانتینر جدا با Chromium headless + پروفایل پایدار (`userDataDir` روی ولوم)، یک worker تک‌نمونه با صف؛ مراحل: ورود یک‌باره (OTP انسانی) → برای هر درخواست: آپلود ویس به یک چت اسکرچ → کلیک «تبدیل به متن» → انتظار روی سلکتور متن → استخراج → حذف پیام. RAM/زمان بیشتر؛ مقاوم‌تر به تغییر پروتکل.
### ۵.۵ — ادغام با بات (بدون تغییر کد)
در `docker-compose.yml` (نسخه‌ی تیم/سرور، **نه** برنچ دیگران) سرویس `bale-stt` اضافه و در سرویس `bot` این envها ست شود:
```
PROCUREMENT_BOT_STT_PROVIDER_3_NAME=bale
PROCUREMENT_BOT_STT_PROVIDER_3_BASE_URL=http://bale-stt:8080/v1
PROCUREMENT_BOT_STT_PROVIDER_3_API_KEY=<INTERNAL_KEY>
PROCUREMENT_BOT_STT_PROVIDER_3_MODEL=bale-stt
PROCUREMENT_BOT_STT_PROVIDER_3_LANGUAGE=fa
```
(یادآوری از کد: پرووایدر فقط با پر بودن همزمان BASE_URL و API_KEY فعال می‌شود.)
### ۵.۶ — سکرت‌ها و امنیت
| سکرت | محل | نکته |
|---|---|---|
| توکن بات بله | env سرور | **موجود در compose کامیت‌شده → rotate** |
| کلید DeepSeek | env سرور | **همین‌طور rotate** |
| `session_file` اکانت سوخته | داکر ولوم | هرگز گیت؛ مالک مشخص |
| `INTERNAL_KEY` adapter | env | فقط بین bot و adapter |
| `DRAFT_TOKEN_SECRET` بات | env | پیش‌فرض فعلی در کد ضعیف است → ست شود |
### ۵.۷ — مقیاس
فاز اول: تک‌نمونه، ترتیبی، سقف روزانه. توسعه فقط بعد از G7 و فقط با شواهد پایداری.
---
## ۶. پذیرش، استقرار، پایش، بازگشت
**تست‌های پذیرش (اجباری):**
1. `curl -F "file=@voice.mp3" -F model=bale-stt http://localhost:8080/v1/audio/transcriptions` → `{"text": "<متن فارسی درست>"}`.
2. بات: ارسال ویس واقعی → پاسخ استخراج‌شده‌ی صحیح.
3. **fail-soft:** توقف کانتینر `bale-stt` → بات همچنان ویس را (با پرووایدرهای دیگر) پردازش کند ✓.
4. حریم خصوصی: بررسی کنید هیچ فایل صوتی روی adapter باقی نمی‌ماند و پیام‌های اسکرچ حذف شده‌اند.
**استقرار فازبندی‌شده:** dev → سرور با حجم کم (۱ هفته) → کامل.
**پایش:** شمارش درخواست، نرخ موفقیت، هیستوگرام تأخیر، انواع خطا، وضعیت نشست. هشدار اگر نرخ خطا در ۳۰ دقیقه > ۲۰٪.
**بازگشت (Rollback):** حذف `STT_PROVIDER_3_*` از env بات + توقف کانتینر adapter — بات فوراً به وضعیت قبل برمی‌گردد (بدون deploy مجدد).
---
## ۷. رجیستر ریسک
| ریسک | احتمال | اثر | مقابله |
|---|---|---|---|
| بن/rate-limit اکانت | متوسط | توقف مسیر | حجم کم، WS-first، backoff، اکانت اختصاصی قابل‌تعویض، G4 |
| تغییر پروتکل بله | متوسط | قطع سرویس | pایش نسخه‌ی باندل (hash)، آلرت خطا، فالبک R4، سوییچ موتور با env |
| انقضای نشست/نیاز OTP | بالا (دوره‌ای) | توقف موقت | ولوم session + آلرت + runbook ورود (انسانی) |
| دقت ناکافی نسبت به هزینه | نامعلوم | هزینه‌ی بی‌فایده | **G5 قبل از ساخت** |
| ریسک حقوقی/ToS | نامعلوم | توقف اجباری | **T8/G6 — تصمیم مدیریت** |
| حریم خصوصی صدا | کم | اعتباری | حذف فوری پیام‌ها/فایل‌ها؛ عدم نگهداری |
| وابستگی به فرد نگهدارنده | متوسط | رکود | مستندسازی در همین پلی‌بوک + مالک رسمی |
---
## ۸. دروازه‌های انسانی (فقط انسان می‌تواند)
1. **OTP ورود اکانت** (بار اول و هر بار انقضا).
2. **تصمیم ToS/حقوقی (G6).**
3. **جمع‌آوری نمونه‌های واقعی ویس برای T7** (با رضایت).
4. **تأیید نهایی استقرار** و تأیید مرج برنچ توسط مهندس ارشد.
---
## ضمیمه A — دستورات probe (تکرارپذیر)
```bash
TOKEN="<BOT_TOKEN>"
for M in transcribeVoice getTranscription transcribe speechToText voiceToText; do
curl -s -X POST "https://tapi.bale.ai/bot$TOKEN/$M" -H "Content-Type: application/json" -d '{}'; echo
done
curl -s -X POST "https://tapi.bale.ai/bot$TOKEN/getMe" -H "Content-Type: application/json" -d '{}'
```
خروجی مورد انتظار: پنج 404 «handler not found» و یک getMe سالم. (همین تست را قبل از هر تصمیم مجدداً اجرا کنید — شاید بله متد اضافه کرده باشد.)
## ضمیمه B — اسکلت adapter (قابل استفاده برای هر سه موتور)
```python
# bale_stt/adapter.py — قرارداد OpenAI-compatible برای بات
import os
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
app = FastAPI()
INTERNAL_KEY = os.environ["BALE_STT_INTERNAL_KEY"]
ENGINE = os.environ.get("BALE_STT_ENGINE", "grpc") # grpc | aichat | playwright
@app.get("/healthz")
def healthz():
return {"status": "ok", "engine": ENGINE}
@app.post("/v1/audio/transcriptions")
async def transcribe(file: UploadFile = File(...), model: str = Form("bale-stt"),
language: str = Form("fa"), authorization: str = Header("")):
if authorization != f"Bearer {INTERNAL_KEY}":
raise HTTPException(status_code=401)
audio = await file.read()
if ENGINE == "grpc":
text = await grpc_transcribe(audio) # ← aiobale-py + RPC کشف‌شده
elif ENGINE == "aichat":
text = await aichat_transcribe(audio) # ← ارسال به چت AI و استخراج متن
else:
text = await playwright_transcribe(audio)
return {"text": text or ""}
# نکته: هرگز استثنا را بیرون نده؛ در خطا {"text": ""} برگردان تا بات fail-soft بماند.
```
## ضمیمه C — منابع
- aiobale-py: [PyPI](https://pypi.org/project/aiobale-py/) · [GitHub](https://github.com/aminmadaniofficial/aiobale) · [مستندات](https://aminmadaniofficial.github.io/aiobale/)
- مستندات رسمی بات بله: [docs.bale.ai](https://docs.bale.ai)
- سرویس ثالث STT: [majidapi.ir](https://majidapi.ir)
- گزارش‌های ایجنت شرکت: `bale_stt_integration_report.md` و `bale_stt_integration_report_2.md` (کنار همین فایل)
## ضمیمه D — چک‌لیست سریع ایجنت (TL;DR)
1. [ ] قواعد بخش ۰ را خواندم.
2. [ ] T1 (payload ویس) → G1.
3. [ ] T2 (کپچر) → G2 → مسیر (R2 / R3 / R4) انتخاب شد.
4. [ ] T3+T4 (اسکیما + اسپایک) → G3.
5. [ ] T-extra (اوراکل AI Chat) اگر مسیر R3 مدنظر است.
6. [ ] T5 (تأخیر واقعی) → G4.
7. [ ] T7 (دقت) → G5 — **بدون این، ساخت ممنوع.**
8. [ ] T8 (ToS — انسانی) → G6.
9. [ ] ساخت adapter (۵.۱) + ادغام env (۵.۵) + تست‌های پذیرش (بخش ۶).
10. [ ] سواک (T6/T9) → G7 → استقرار کامل. هر شکست ⇒ جدول همان دروازه ⇒ اقدام جایگزین یا STOP.
+256
View File
@@ -0,0 +1,256 @@
# گزارش جامع: اعتبارسنجی گزارش‌ها و راهکار کرال موتور STT بله
> **تاریخ:** ۱۵ مهر ۱۴۰۵
> **موضوع:** افزودن متن تولیدشده توسط قابلیت «تبدیل ویس به متن» پیام‌رسان بله به‌عنوان یک کاندید در خط‌لوله چندمدله STT بات تدارکات
> **ورودی‌ها:** سه سند ارائه‌شده (تحلیل جامع، پلی‌بوک اجرایی، تحلیل مقایسه‌ای) + راستی‌آزمایی مستقل وب
> **محدودیت:** `integration_report` شماره ۱ و ۲ در دسترس من نبود و فقط از روی نقل‌قول‌های همین اسناد قضاوت شده‌اند. دستورهای کشف (بخش ۴) در محیط من اجرا نشده‌اند، چون شبکه sandbox بسته بود.
---
## ۱. خلاصه مدیریتی
1. **Bot API رسمی بله STT ندارد.** همه اسناد در این مورد درست می‌گویند.
2. **تناقض اصلی** (مهندسی معکوس «غیرعملی است» در برابر «ساده است») هیچ‌کدام کاملاً درست نیست. مسیر شدنی است، ولی «افزودن یک متد» نیست و نیاز به یک فاز کشف دارد.
3. **دو فرض اثبات‌نشده** کل مسیر را تحت تأثیر قرار می‌دهد:
- دکمه «تبدیل به متن» روی **وب** بله وجود دارد (هر سه سند فرض کرده‌اند، هیچ منبعی تأیید نکرده).
- پکیج `aiobale-py` نسخه 0.3.9 فعال است (پیدا نشد؛ به بخش ۲ مراجعه کنید).
4. **کشف جدید:** پکیج `bale-sdk` با ۶۰۷ RPC تایپ‌شده و پروتوهای بازیابی‌شده. احتمال وجود RPC ترنسکریپشن در آن بالاست. ولی مجوز آن اختصاصی است.
5. **بزرگ‌ترین صرفه‌جویی:** آزمون دقت را **قبل از ساخت** و به‌صورت **دستی** روی ۳۰ ویس واقعی انجام دهید. اگر بله از خط‌لوله فعلی بهتر نبود، پروژه لازم نیست.
6. **ریسک حقوقی/ToS** (استفاده خودکار از حساب کاربری) تصمیم مدیریتی است و باید قبل از استقرار روشن شود.
---
## ۲. اعتبارسنجی گزارش‌ها
### ۲.۱ جدول ادعاها
| ادعا | نتیجه | توضیح |
|---|---|---|
| Bot API رسمی STT ندارد | ✅ تأیید | پروب‌ها همگی ۴۰۴ داده‌اند و تایپ‌های SDKها برای Voice فقط شناسه فایل و مدت‌زمان دارند. **تذکر:** ۴۰۴ برای نام‌های حدسی چیزی درباره فیلد مخفی در payload نمی‌گوید؛ تست T1 (ویس به یک بات تستی و خواندن JSON کامل) هنوز لازم است (۳۰ دقیقه). |
| بله قابلیت تبدیل ویس به متن دارد | ✅ با شرط | نسخه جدید بله این قابلیت را برای اندروید و iOS منتشر کرده. وجود دکمه در **وب** تأییدنشده است و ریسک اصلی مسیر Playwright همین است. |
| مهندسی معکوس «غیرعملی» است (گزارش ۲) | ⚠️ بدبینانه | کلاینت‌های متن‌باز مهندسی‌معکوس‌شده وجود دارند. اما استدلال «Protobuf خودتوصیف است» نادرست است: wire format بدون `.proto` فقط تا حدی (شماره فیلد و نوع خام، بدون نام و معنا) دیکود می‌شود. |
| مهندسی معکوس «در حد افزودن یک متد» است (پلی‌بوک) | ⚠️ خوش‌بینانه | کشف RPC، نگاشت فیلدها و پایداری نشست کار واقعی است. تخمین «نیم‌روز» برای T2 ریسکی است. |
| `aiobale-py` v0.3.9 فعال است و نام `aiobale` را پکیج خالی گرفته | ❌ تأییدنشده / احتمالاً غلط | `aiobale-py` را پیدا نکردم. `aiobale` یک کتابخانه واقعی است (MIT، مستندات `docs.aiobale.ir`، نسخه‌های 0.1.x). فورک‌هایی مثل Autobale می‌گویند نسخه اصلی نگهداری نمی‌شود و باگ دارد. قبل از وابستگی به هر نام، `pip index versions` را اجرا کنید. |
| `bale-sdk` (کشف جدید) | 🔎 مهم | از کلاینت وب بله مهندسی معکوس شده؛ ۶۰۷ RPC در ۵۲ سرویس، پروتوهای بازیابی‌شده، احراز با JWT یا OTP؛ نسخه 0.1.1 (ژوئن ۲۰۲۶). **مجوز اختصاصی و «همه حقوق محفوظ»**. |
| تأخیر «زیر ۱ ثانیه» | ❌ رد | همه موافقند. اعداد ۱–۴ ثانیه (R2) و ۴–۸ ثانیه (Playwright) هم **اندازه‌گیری نشده‌اند**؛ فقط T5 عدد واقعی می‌دهد. |
| AI Chat بله ویس را «پیاده» می‌کند (R3) | ⚠️ تأییدنشده و نامطلوب | حتی اگر درست باشد، خروجی «متن + تفسیر مدل» است و برای consensus مناسب نیست. با کنار گذاشتن موافقم. |
| RAM ‏۳۵۰–۵۰۰ مگابایت برای Playwright | ✅ معقول | برای بات تک‌کاربره قابل‌تحمل است. |
| Playwright در ۱–۲ روز | ⚠️ خوش‌بینانه | وابسته به وجود دکمه وب، چالش‌های ورود و پایداری سلکتورها. |
| ساختار Gate / State-machine پلی‌بوک | ✅ قوی | بهترین بخش اسناد. ایراد: دروازه دقت (T7) بعد از ساخت آمده، در حالی که دستی و قبل از ساخت شدنی است. |
| ریسک بن، ToS و حریم خصوصی | ✅ درست و حیاتی | فقط پلی‌بوک به آن پرداخته است. |
| ترجیح WebSocket به POST gRPC (پلی‌بوک) | ✅ منطقی | کلاینت رسمی بله POST gRPC را عمدتاً برای چند متد احراز هویت استفاده می‌کند؛ استفاده زیاد از آن غیرعادی به نظر می‌رسد. |
| «Playwright اول» (سند مقایسه‌ای) در برابر «R2 اول» (پلی‌بوک) | ⚠️ هر دو مشروط | بدون نتیجه کشف روز اول قابل تصمیم نیست. |
### ۲.۲ ایرادهای کد adapter در سند اول
- کلید پیش‌فرض ضعیف (`bale_secure_token`) و مقایسه کلید با `!=` به‌جای مقایسه ثابت‌زمان.
- بلعیدن همه استثناها بدون لاگ.
- بازگرداندن ۲۰۰ با متن خالی: ممکن است بات آن را یک کاندید معتبر (خالی) در consensus بشمارد. باید در `bot/app/stt.py` بررسی شود که پاسخ ۵xx و متن خالی چطور مدیریت می‌شوند. توصیه: در خطا ۵۰۳ برگردد.
- نبود قفل ترتیبی، سقف روزانه و تأخیر تصادفی در خود adapter.
### ۲.۳ نکات فنی اصلاح‌شده
- فریم gRPC-Web پیشوند ۵ بایتی دارد (۱ بایت فلگ + ۴ بایت طول). این برای **POST gRPC-Web** درست است؛ اینکه فریم‌های WebSocket بله هم همان قالب را دارند فرض اثبات‌نشده‌ای است و باید در T2 دیده شود.
- هاست‌ها و نام ماژول‌هایی مثل `maviz-ws.bale.ai` و `SpeechTranscript` از تحلیل باندل آمده‌اند و در وب ایندکس نشده‌اند. باید با چشم در کپچر تأیید شوند.
---
## ۳. راهکارها
| راهکار | توضیح | رم | خلوص متن | ریسک | حکم |
|---|---|---|---|---|---|
| **A. مسیر رسمی** | درخواست API یا همکاری از بله | — | — | — | هزینه صفر؛ قبل از هر کار غیررسمی یک ایمیل بزنید |
| **B. RPC مستقیم** | کلاینت کاربری روی WebSocket با `bale-sdk` یا `aiobale` + RPC ترنسکریپت | ≈ ۲۰ MB | خالص | متوسط (تغییر پروتکل، بن) | **بهترین نتیجه فنی**، مشروط به کشف RPC |
| **C. Playwright** | Chromium headless روی `web.bale.ai` با پروفایل پایدار | ۳۵۰–۵۰۰ MB | خالص | کم–متوسط | مقاوم، **فقط اگر دکمه وب موجود باشد** |
| **D. AI Chat** | ویس به چت هوش مصنوعی بله و استخراج از پاسخ | ≈ ۲۰ MB | ❌ متن + تفسیر | بالا | کنار گذاشته شود |
| **E. سرویس ثالث** | پروکسی‌های STT با ادعای بله | — | نامشخص | حریم خصوصی/پایداری | کنار گذاشته شود |
| **F. اندروید امولاتور** | اپ بله + uiautomator | زیاد | خالص | بالا (شکننده) | آخرین چاره اگر B و C ممکن نشد |
---
## ۴. مسیر دقیق اجرا
### گام ۰: آزمون دقت دستی (نیم‌روز، بدون کد)
۳۰ ویس واقعی تدارکاتی (با رضایت فرستندگان؛ صداها را بلندمدت نگه ندارید) را در اپ بله دستی به متن تبدیل کنید و سه حالت را مقایسه کنید:
1. خروجی فعلی بات (whisper/سایرین + consensus)
2. فقط متن بله
3. consensus با اضافه‌شدن بله
سه معیار: WER متن، دقت استخراج اقلام (نام کالا، برند، تعداد، واحد) و تعداد موارد سخت لهجه‌ای که با بله درست شده‌اند.
**معیار پیشنهادی:** کاهش نسبی خطای استخراج حدود ۱۵٪ یا بیشتر، یا حل حداقل ۲ مورد سخت. در غیر این صورت **STOP**.
### گام ۱: کشف سریع (حدود ۳۰ دقیقه)
```bash
python -m venv .v && . .v/bin/activate
pip index versions aiobale-py # وجود یا عدم وجود را بررسی کنید
pip download bale-sdk --no-deps --no-binary :all: && tar xzf bale_sdk-*.tar.gz
grep -rniE "transcri|speech|asr|stt" bale_sdk-*/proto bale_sdk-*/docs | head -50
```
همچنین در پایتون `from bale import ALL_RPCS` را با همان کلیدواژه‌ها فیلتر کنید.
همزمان **T1**: یک بات تستی مستقل بسازید، یک ویس بفرستید و JSON کامل آپدیت را بخوانید. اگر فیلد ترنسکریپشن وجود داشت، پروژه کرالر لازم نیست.
### گام ۲: وب و کپچر (نیم‌روز تا یک روز)
1. با **یک حساب اختصاصی شرکت** روی `web.bale.ai` وارد شوید و بررسی کنید دکمه «تبدیل به متن» روی ویس هست یا نه (روی ویس دریافتی و ارسالی).
2. DevTools تب Network را با Preserve log و فیلتر WS و Fetch/XHR ضبط کنید و هنگام کلیک روی دکمه ذخیره کنید:
- URL کامل و ترابری (unary یا WS)
- هدر احراز
- `req.bin` و `resp.bin`
- زمان پاسخ (همان تأخیر واقعی ASR)
3. ۵ تا ۱۰ ویس متنوع (کوتاه، بلند، لهجه‌دار) را تکرار کنید و در `findings.md` خلاصه کنید.
4. دیکود آفلاین: پیشوند ۵ بایتی را بردارید، سپس `protoc --decode_raw` یا `protoscope`.
### گام ۳: انتخاب مسیر
| وضعیت | مسیر |
|---|---|
| RPC در `bale-sdk` یا کپچر پیدا شد | **B** |
| RPC پیدا نشد ولی دکمه وب هست | **C** |
| هیچ‌کدام | **F** یا توقف |
| ترنسکرایب نیاز به امضا یا اثر انگشت مرورگر دارد | **C** |
**الگوی پیشنهادی برای B:** بات خودتان با `sendVoice(file_id)` ویس را برای حساب کرالر بفرستد (کرالر یک‌بار بات را `/start` کرده). این‌طور نیازی به دانلود و آپلود مجدد نیست و `message_id` بومی در اختیار کرالر است. پس از دریافت متن، پیام حذف شود. اگر RPC به `message_id` نیاز نداشت، مستقیم فایل یا بایت داده شود.
**مجوز:** `aiobale` مجوز MIT دارد ولی نگهداری نمی‌شود (فورک‌ها: Autobale و BaleClient). `bale-sdk` مجوز اختصاصی دارد؛ برای استفاده تجاری از نویسنده اجازه بگیرید.
### گام ۴: ساخت adapter سخت‌شده
قرارداد OpenAI-compatible (همان `POST /v1/audio/transcriptions` که بات انتظار دارد) و موتور قابل‌سوییچ با `BALE_STT_ENGINE=grpc|playwright`. پیشنهاد کد:
```python
import os, secrets, asyncio, logging
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
log = logging.getLogger("bale-stt")
app = FastAPI()
KEY = os.environ["BALE_STT_INTERNAL_KEY"] # بدون مقدار پیش‌فرض
DAILY_CAP = int(os.environ.get("BALE_STT_DAILY_CAP", "100"))
lock = asyncio.Lock() # اجرای ترتیبی
count_today = 0 # در عمل با ریست روزانه
@app.get("/healthz")
def healthz():
return {"status": "ok"}
@app.post("/v1/audio/transcriptions")
async def transcribe(file: UploadFile = File(...), model: str = Form("bale-stt"),
language: str = Form("fa"), authorization: str = Header("")):
if not secrets.compare_digest(authorization, f"Bearer {KEY}"):
raise HTTPException(401)
if count_today >= DAILY_CAP:
raise HTTPException(429)
async with lock:
try:
text = await run_engine(await file.read()) # + تأخیر تصادفی ۱ تا ۳ ثانیه
except Exception:
log.exception("bale stt failed")
raise HTTPException(503) # تا بات فقط همین کاندید را حذف کند
if not text:
raise HTTPException(503)
return {"text": text}
```
اتصال به بات (بدون تغییر کد):
```env
PROCUREMENT_BOT_STT_PROVIDER_3_NAME=bale
PROCUREMENT_BOT_STT_PROVIDER_3_BASE_URL=http://bale-stt:8080/v1
PROCUREMENT_BOT_STT_PROVIDER_3_API_KEY=<INTERNAL_KEY>
PROCUREMENT_BOT_STT_PROVIDER_3_MODEL=bale-stt
PROCUREMENT_BOT_STT_PROVIDER_3_LANGUAGE=fa
```
> پرووایدر فقط وقتی فعال می‌شود که هم `BASE_URL` و هم `API_KEY` پر باشند.
### گام ۵: عملیات و ایمنی
- فقط **شماره اختصاصی شرکت**؛ هرگز شماره شخصی.
- `session_file` یا پروفایل مرورگر روی ولوم داکر و هرگز در گیت.
- **OTP تنها گام انسانی** است. در ابطال نشست: هشدار به ادمین و هدایت ترافیک به سایر پرووایدرها.
- اجرای ترتیبی، فاصله تصادفی ۱ تا ۳ ثانیه، سقف روزانه، و ترجیح WebSocket به POST gRPC.
- حذف فوری پیام اسکرچ پس از استخراج و عدم نگهداری فایل صوتی روی دیسک.
- پایش: نرخ موفقیت، p50 و p95 تأخیر، انواع خطا، وضعیت نشست. هشدار اگر نرخ خطا در ۳۰ دقیقه بیش از ۲۰٪ شد.
- **Rollback:** خالی کردن `STT_PROVIDER_3_*` در env بات و توقف کانتینر. بات فوراً به وضعیت قبل برمی‌گردد.
- سکرت‌هایی که در `docker-compose.yml` کامیت شده‌اند (توکن بات، کلید DeepSeek) باید rotate شوند و `DRAFT_TOKEN_SECRET` پیش‌فرض ضعیف را عوض کنید.
- روی برنچ‌های دیگران (`prod-v7` و غیره) دخالت نکنید و بدون تأیید مالک چیزی پوش نشود.
### گام ۶: اندازه‌گیری و سواک
- **T5:** ۵۰ تا ۱۰۰ ویس ترتیبی با فاصله انسانی؛ پذیرش پیشنهادی: موفقیت ≥ ۹۵٪ و p95 ≤ ۸ ثانیه.
- **سواک یک‌هفته‌ای** با حجم کم (حدود ۱۰۰ ترنسکرایب در روز): ثبت تعداد ورود مجدد، چالش‌های امنیتی و عمر واقعی توکن.
---
## ۵. دروازه‌های تصمیم (خلاصه)
| دروازه | شرط عبور | در صورت شکست |
|---|---|---|
| G0 دقت دستی | بهبود ≈ ۱۵٪ یا ۲ مورد سخت | **STOP** |
| G1 (T1) | فیلد ترنسکریپشن نبود | اگر بود: پایان، مستقیم وصل شود |
| G2 کشف | RPC یا دکمه وب پیدا شد | F یا توقف |
| G3 پروتوتایپ | درخواست ساخته و پاسخ خوانده شد | سوییچ به C |
| G4 نرخ و پایداری | ≥ ۹۵٪ موفقیت، p95 ≤ ۸ ثانیه | کاهش نرخ و backoff؛ در صورت بن: توقف |
| G6 ToS | تصمیم مکتوب مدیریت | **STOP** یا حجم بسیار محدود |
| G7 سواک | نشست سالم یک هفته | rollback و بازگشت به G4 |
---
## ۶. زمان‌بندی واقع‌بینانه
| مرحله | زمان |
|---|---|
| گام ۰ و ۱ (دقت دستی + کشف سریع) | نیم‌روز |
| گام ۲ (وب و کپچر) | ۱ روز |
| ساخت B یا C | ۲ تا ۴ روز |
| T5 و سواک | ۱ هفته |
---
## ۷. رجیستر ریسک
| ریسک | احتمال | اثر | مقابله |
|---|---|---|---|
| بن یا rate-limit حساب | متوسط | توقف مسیر | حجم کم، WS-first، سقف روزانه، حساب اختصاصی قابل‌تعویض |
| تغییر پروتکل یا UI بله | متوسط | قطع سرویس | هشدار خطا، فالبک، سوییچ موتور با env، rollback با env |
| انقضای نشست و نیاز به OTP | بالا (دوره‌ای) | توقف موقت | ولوم نشست + هشدار + runbook ورود انسانی |
| دکمه وب وجود ندارد | نامعلوم | ناممکن‌شدن C | بررسی در گام ۲ قبل از ساخت |
| دقت ناکافی نسبت به هزینه | نامعلوم | هزینه بی‌فایده | G0 قبل از ساخت |
| ToS / مسائل حقوقی | نامعلوم | توقف اجباری | تصمیم مکتوب مدیریت (G6)؛ ایمیل به بله (A) |
| مجوز اختصاصی `bale-sdk` | بالا | ممنوعیت استفاده | اجازه از نویسنده یا استفاده از `aiobale` (MIT) |
| حریم خصوصی صدا | کم | اعتباری | حذف فوری پیام‌ها، عدم نگهداری فایل |
| وابستگی به یک فرد نگهدارنده | متوسط | رکود | مستندسازی و مالک رسمی |
---
## ۸. دروازه‌های انسانی
1. OTP ورود حساب (بار اول و هر انقضا).
2. تصمیم ToS / حقوقی.
3. جمع‌آوری نمونه ویس واقعی با رضایت.
4. تأیید نهایی استقرار و مرج توسط مهندس ارشد.
---
## ۹. توصیه نهایی
1. **همین هفته:** گام ۰ (دقت دستی) + ایمیل به بله + گام ۱ (کشف سریع و T1).
2. **اگر دقت توجیه داشت:** گام ۲ و انتخاب B یا C بر اساس شواهد.
3. **معماری:** adapter با قرارداد OpenAI و موتور قابل‌سوییچ؛ بات دست‌نخورده می‌ماند.
4. **تصمیم مدیریتی** درباره ToS قبل از استقرار.
5. اگر هر کدام از G0 یا G6 شکست خورد، **توقف** و ادامه با پرووایدرهای فعلی.
---
## منابع
- aiobale (PyPI): https://pypi.org/project/aiobale
- bale-sdk (PyPI): https://pypi.org/project/bale-sdk/
- Autobale (فورک aiobale): https://pypi.org/project/autobale/
- خبر انتشار نسخه بله با قابلیت تبدیل پیام صوتی به متن (خبرگزاری آنا): https://ana.ir/fa/news/994908
- مستندات رسمی بات بله: https://docs.bale.ai
@@ -0,0 +1,159 @@
# گزارش فنی و امکان‌سنجی: ادغام قابلیت صوت به متن (STT) بله در سامانه تدارکات هوشمند
> [!NOTE]
> **مخاطب گزارش:** تیم فنی، معماران سیستم و مدیران محصول
> **موضوع:** بررسی، مقایسه دو رویکرد فنی و نقشه راه پیاده‌سازی اتصال به موتور تبدیل ویس به متن پیام‌رسان «بله»
> **هدف نهایی:** افزودن بله به عنوان یکی از مدل‌های کاندید در خط لوله STT چندمدله (Multi-Provider STT) جهت افزایش دقت استخراج اقلام تدارکات
---
## ۱. خلاصه مدیریتی (Executive Summary)
سامانه هوشمند تدارکات در حال حاضر مجهز به موتور پردازش موازی صوت (**Multi-Provider STT**) است که قادر است فایل‌های صوتی دریافتی از کاربران را هم‌زمان به چندین ارائه‌دهنده (نظیر Whisper، Soniox یا سرویس‌های ابری) ارسال کرده و از طریق مدل تحلیلی DeepSeek یک خروجی یکپارچه و دقیق (**Consensus Text**) به همراه اقلام استعلام استخراج کند.
برای افزایش هرچه بیشتر دقت در زبان فارسی و لهجه‌های محلی، استفاده از **موتور صوت‌به‌متن بومی پیام‌رسان بله** به عنوان یکی از مدل‌های کاندید مطرح شده است. از آن‌جا که پلتفرم توسعه‌دهندگان بله (Bale Bot API) متد مستقیمی برای فراخوانی API ترنسکرایب ارائه نمی‌دهد، دو روش اجرایی اصلی برای دستیابی به این موتور وجود دارد:
1. **روش اول (اتوماسیون رابط کاربری وب / Headless Browser):** شبیه‌سازی رفتار کاربر در مرورگر و کلیک روی دکمه تبدیل وویس.
2. **روش دوم (مهندسی معکوس پروتکل کلاینت / Direct Client API):** شبیه‌سازی درخواست‌های شبکه (WebSocket/HTTP) کلاینت بله بدون نیاز به مرورگر.
---
## ۲. پس‌زمینه فنی زیرساخت بله (Technical Context)
| بخش | وضعیت دسترسی | توضیحات |
| :--- | :--- | :--- |
| **Bale Bot API (رسمی)** | فاقد متد STT | فقط امکان دانلود فایل صوتی خام (`voice` / `audio`) با `file_id` را فراهم می‌کند. |
| **کلاینت‌های کاربری بله (Web / Android)** | دارای موتور فعال STT | روی هر ویس یک دکمه تبدیل صوت به متن وجود دارد که با فشرده شدن آن، صوت روی سرورهای بله به متن تبدیل می‌شود. |
| **دستیار هوش مصنوعی بله** | پشتیبانی از ورودی صوتی | کاربر با ارسال ویس به چت هوش مصنوعی بله، متن پیاده‌شده به همراه پاسخ تحلیلی را دریافت می‌کند. |
**نتیجه:** برای بهره‌برداری از این قابلیت، بات تدارکات باید در لایه ارتباطی خود، یک نشست کاربری (User Session) در بله داشته باشد تا بتواند خدمات تبدیل صوت کلاینت را فراخوانی کند.
---
## ۳. روش اول: اتوماسیون رابط کاربری وب (Headless Browser Scraping)
در این رویکرد، یک مرورگر کنترل‌شده بدون واسط گرافیکی (مانند Chromium تحت فریم‌ورک **Playwright**) در یک کانتینر اختصاصی داکر مستقر شده و در نسخه وب بله (`web.bale.ai`) فعال نگه داشته می‌شود.
### معماری جریان داده (Architecture Flow)
```mermaid
sequenceDiagram
autonumber
participant Bot as Procurement Bot
participant Worker as Browser Worker (Playwright)
participant BaleWeb as web.bale.ai (Chromium)
participant BaleServer as سرورهای پردازش صوت بله
Bot->>Worker: ارسال فایل صوتی (voice.mp3)
Worker->>BaleWeb: بارگذاری فایل در چت کلاینت
BaleWeb->>BaleServer: ارسال صوت و درخواست تبدیل
BaleServer-->>BaleWeb: بازگرداندن متن پیاده‌شده به صفحه
Worker->>BaleWeb: استخراج متن از DOM (.transcription-text)
Worker-->>Bot: بازگرداندن متن خروجی به خط لوله STT
```
### مؤلفه‌های پیاده‌سازی:
1. **مدیریت نشست پایدار (Persistent Profile):**
- پوشه پروفایل مرورگر (`userDataDir`) بر روی یک داکر ولوم ماندگار (`/data/bale_browser_session`) قرار می‌گیرد.
- ورود اولیه فقط یک‌بار توسط تیم فنی با شماره تلفن اختصاصی و کد OTP پیامکی انجام می‌شود و کوکی‌ها و توکن‌های LocalStorage/IndexedDB برای همیشه حفظ می‌گردند.
2. **انتظار هوشمند بر اساس رخدادهای DOM:**
- استفاده از سلکتورهای مقاوم با `waitForSelector` یا `MutationObserver` روی باکس متن ترنسکرایب.
3. **مزایا و معایب:**
- 🟢 **تضمین تطابق با سرور:** چون مرورگر رسمی در حال اجرا است، هیچ‌گونه ریسک ناسازگاری پکت‌های شبکه وجود ندارد.
- 🟢 **پایداری در برابر تغییرات داخلی API:** حتی اگر بله فریم‌ورک پکت‌های وب‌سوکت خود را عوض کند، تا زمانی که دکمه در UI وجود دارد سیستم کار می‌کند.
- 🔴 **مصرف بالای منابع:** هر نمونه Chromium حدود ۳۰۰ تا ۵۰۰ مگابایت رم اشغال می‌کند.
- 🔴 **تاخیر زمانی (Latency):** فرآیند رندر صفحه، شبیه‌سازی آپلود و رویداد کلیک حدود ۳ تا ۶ ثانیه زمان می‌برد.
---
## ۴. روش دوم: مهندسی معکوس شبکه و پروتکل کلاینت (Direct Client API)
در این روش، بدون استفاده از مرورگر سنگین، کلاینت پایتونی سبک ما مستقیماً همان درخواست‌های سوکت یا HTTP کلاینت رسمی بله را بازسازی و فراخوانی می‌کند.
### معماری جریان داده (Architecture Flow)
```mermaid
sequenceDiagram
autonumber
participant Bot as Procurement Bot
participant Adapter as Bale Client Protocol Adapter (Python)
participant BaleCore as هسته سرور بله (WebSocket / RPC)
Bot->>Adapter: ارسال بایت‌های فایل صوتی
Adapter->>BaleCore: فراخوانی مستقیم متد Transcribe (از طریق سوکت یا HTTP)
BaleCore-->>Adapter: پاسخ مستقیم باینری/JSON متن
Adapter-->>Bot: متن نهایی در کمتر از ۱ ثانیه
```
### مؤلفه‌های پیاده‌سازی:
1. **آنالیز ترافیک شبکه (Traffic Inspection):**
- تحلیل تب Network مرورگر در `web.bale.ai`:
- استخراج مسیر اتصال (غالباً `wss://api.bale.ai/v1/users/websocket` یا اندپوینت‌های REST).
- استخراج هدرهای نشست از `localStorage` کلاینت وب (مانند `access_token`, `device_id`, `session_key`).
- کشف پکت یا درخواست RPC مسئول تبدیل صوت (شامل شناسه پیام `message_id` و شناسه فایل صوتی `file_id`).
2. **کلاینت ناهمگام پایتون (`httpx` / `websockets`):**
- اتصال دائمی و سبک به زیرساخت پیام‌رسانی بله.
- ارسال مستقیم درخواست بدون بارگذاری فایل‌های گرافیکی، جاوااسکریپت و CSS مرورگر.
3. **مزایا و معایب:**
- 🟢 **سرعت خیره‌کننده (Ultra-low Latency):** پاسخگویی معمولاً در **کمتر از ۱ ثانیه** انجام می‌شود.
- 🟢 **مصرف ناچیز منابع (Lightweight):** مصرف حافظه رم زیر ۱۰ مگابایت.
- 🟢 **ظرفیت پردازش همزمان بالا (High Concurrency):** امکان ارسال همزمان ده‌ها درخواست بدون افت پرفورمنس.
- 🔴 **حساسیت به پروتکل:** در صورت بازنویسی پروتکل یا فیلدهای باینری کلاینت بله، ممکن است نیاز به به‌روزرسانی پکت‌ها باشد.
---
## ۵. ماتریس مقایسه جامع فنی (Technical Decision Matrix)
| معیار ارزیابی | روش اول: اتوماسیون وب (Playwright) | روش دوم: مهندسی معکوس پروتکل کلاینت | اهمیت برای سیستم |
| :--- | :---: | :---: | :---: |
| **تاخیر پاسخ‌دهی (Latency)** | ۳ تا ۶ ثانیه | **زیر ۱ ثانیه** ⚡️ | بسیار بالا (تاثیر مستقیم روی UX وویس) |
| **مصرف حافظه RAM** | ۳۵۰ الی ۵۰۰ مگابایت | **کمتر از ۱۵ مگابایت** 🪶 | متوسط به بالا |
| **هزینه پردازشی CPU** | متوسط تا بالا (رندرینگ) | بسیار پایین (درخواست شبکه) | متوسط |
| **پیچیدگی پیاده‌سازی اولیه** | کم (طی چند ساعت با سلکتور) | متوسط (نیازمند اسنیف و دیکود پکت) | موقت |
| **پایداری نشست کاربری (Session)** | ذخیره در پوشه کاربری مرورگر | ذخیره توکن در متغیر محیطی / دیتابیس | بالا |
| **ریسک تغییرات توسط بله** | کم (مقاوم در برابر تغییر پروتکل) | نیازمند پایش در تغییر نسخه‌های ماژور | متوسط |
| **قابلیت مقیاس‌پذیری (Scalability)** | محدود (محدودیت منابع مرورگر) | نامحدود (ایسنک و چندنخی) | بالا |
---
## ۶. نحوه اتصال به پایپ‌لاین STT چندمدله فعلی
سامانه تدارکات در حال حاضر قابلیت دریافت چند کاندیدا را دارد. با اتصال بله، خروجی به شکل زیر در پردازش قرار می‌گیرد:
```mermaid
graph TD
Voice[وویس دریافتی کاربر از بله] --> FFMPEG[تبدیل یک‌باره به MP3]
FFMPEG --> S1[مدل ۱: Whisper-Large]
FFMPEG --> S2[مدل ۲: Soniox / OpenRouter]
FFMPEG --> S3[مدل ۳: موتور صوت هوش مصنوعی بله]
S1 --> Aggregator[تجمیع نتایج کاندیداها]
S2 --> Aggregator
S3 --> Aggregator
Aggregator --> DeepSeek[تحلیلگر هوشمند DeepSeek]
DeepSeek --> Final[متن اصلاح‌شده Consensus Text + استخراج اقلام استعلام JSON]
```
---
## ۷. نقشه راه پیشنهادی دو مرحله‌ای برای تیم توسعه (Recommended Roadmap)
> [!TIP]
> **استراتژی برد-برد (Fast Win + High Performance):**
> برای اینکه از سریع‌ترین حالت ممکن با کمترین ریسک بهره‌مند شویم، فرآیند اجرای فازبندی‌شده زیر پیشنهاد می‌شود:
### فاز ۱: ممیزی و اکتشاف ترافیک شبکه (کمتر از ۱ روز کاری)
1. باز کردن `web.bale.ai` با تب Network و کلیک روی دکمه تبدیل ویس.
2. تعیین ماهیت درخواست:
- اگر درخواست یک **HTTP POST ساده** یا یک فریم متنی وب‌سوکت با Authorization Header باشد:
👉 **روش دوم بلافاصله پیاده‌سازی می‌شود** و به بالاترین سرعت و بهره‌وری ممکن می‌رسیم.
- اگر پروتکل شامل امضاها و فریم‌های رمزگذاری‌شده پیچیده باشد:
👉 به سراغ اجرای فاز ۲ (روش اول) می‌رویم.
### فاز ۲: استقرار سرویس اتوماسیون (Fallback تضمین‌شده)
1. ساخت کانتینر داکر سبک `playwright-worker` در کنار کانتینر `procurement-bot`.
2. اتصال امن این کانتینر از طریق یک اندپوینت داخلی ساده:
`POST http://bale-stt-worker:8080/transcribe`
3. قرار گرفتن این سرویس به عنوان مدل سوم در متغیر محیطی:
`PROCUREMENT_BOT_STT_PROVIDER_3_BASE_URL=http://bale-stt-worker:8080/v1`
---
*تهیه شده جهت ارائه به تیم فنی و مدیران سیستم تدارکات هوشمند.*
@@ -0,0 +1,137 @@
# گزارش فنی و امکان‌سنجی: ادغام قابلیت صوت به متن (STT) بله در سامانه تدارکات هوشمند
> [!NOTE]
> **مخاطب گزارش:** تیم فنی، معماران سیستم و مدیران محصول
> **موضوع:** بررسی، مقایسه دو رویکرد فنی و نقشه راه پیاده‌سازی اتصال به موتور تبدیل ویس به متن پیام‌رسان «بله»
> **هدف نهایی:** افزودن بله به عنوان یکی از مدل‌های کاندید در خط لوله STT چندمدله (Multi-Provider STT) جهت افزایش دقت استخراج اقلام تدارکات
---
## ۱. خلاصه مدیریتی (Executive Summary)
سامانه هوشمند تدارکات در حال حاضر مجهز به موتور پردازش موازی صوت (**Multi-Provider STT**) است که قادر است فایل‌های صوتی دریافتی از کاربران را هم‌زمان به چندین ارائه‌دهنده (نظیر Whisper، Soniox یا سرویس‌های ابری) ارسال کرده و از طریق مدل تحلیلی DeepSeek یک خروجی یکپارچه و دقیق (**Consensus Text**) به همراه اقلام استعلام استخراج کند.
برای افزایش هرچه بیشتر دقت در زبان فارسی و لهجه‌های محلی، استفاده از **موتور صوت‌به‌متن بومی پیام‌رسان بله** به عنوان یکی از مدل‌های کاندید مطرح شده است. از آن‌جا که پلتفرم توسعه‌دهندگان بله (Bale Bot API) متد مستقیمی برای فراخوانی API ترنسکرایب ارائه نمی‌دهد، دو روش اجرایی اصلی برای دستیابی به این موتور وجود دارد:
1. **روش اول (اتوماسیون رابط کاربری وب / Headless Browser):** شبیه‌سازی رفتار کاربر در مرورگر و کلیک روی دکمه تبدیل وویس.
2. **روش دوم (مهندسی معکوس پروتکل کلاینت / Direct Client API):** شبیه‌سازی درخواست‌های شبکه (WebSocket/HTTP) کلاینت بله بدون نیاز به مرورگر.
---
## ۲. پس‌زمینه فنی زیرساخت بله (Technical Context)
| بخش | وضعیت دسترسی | توضیحات |
| :--- | :--- | :--- |
| **Bale Bot API (رسمی)** | فاقد متد STT | فقط امکان دانلود فایل صوتی خام (`voice` / `audio`) با `file_id` را فراهم می‌کند. |
| **کلاینت‌های کاربری بله (Web / Android)** | دارای موتور فعال STT | روی هر ویس یک دکمه تبدیل صوت به متن وجود دارد که با فشرده شدن آن، صوت روی سرورهای بله به متن تبدیل می‌شود. |
| **دستیار هوش مصنوعی بله** | پشتیبانی از ورودی صوتی | کاربر با ارسال ویس به چت هوش مصنوعی بله، متن پیاده‌شده به همراه پاسخ تحلیلی را دریافت می‌کند. |
**نتیجه:** برای بهره‌برداری از این قابلیت، بات تدارکات باید در لایه ارتباطی خود، یک نشست کاربری (User Session) در بله داشته باشد تا بتواند خدمات تبدیل صوت کلاینت را فراخوانی کند.
---
## ۳. روش اول: اتوماسیون رابط کاربری وب (Headless Browser Scraping)
در این رویکرد، یک مرورگر کنترل‌شده بدون واسط گرافیکی (مانند Chromium تحت فریم‌ورک **Playwright**) در یک کانتینر اختصاصی داکر مستقر شده و در نسخه وب بله (`web.bale.ai`) فعال نگه داشته می‌شود.
### معماری جریان داده (Architecture Flow)
```mermaid
sequenceDiagram
autonumber
participant Bot as Procurement Bot
participant Worker as Browser Worker (Playwright)
participant BaleWeb as web.bale.ai (Chromium)
participant BaleServer as سرورهای پردازش صوت بله
Bot->>Worker: ارسال فایل صوتی (voice.mp3)
Worker->>BaleWeb: بارگذاری فایل در چت کلاینت
BaleWeb->>BaleServer: ارسال صوت و درخواست تبدیل
BaleServer-->>BaleWeb: بازگرداندن متن پیاده‌شده به صفحه
Worker->>BaleWeb: استخراج متن از DOM (.transcription-text)
Worker-->>Bot: بازگرداندن متن خروجی به خط لوله STT
```
### مؤلفه‌های پیاده‌سازی:
1. **مدیریت نشست پایدار (Persistent Profile):**
- پوشه پروفایل مرورگر (`userDataDir`) بر روی یک داکر ولوم ماندگار (`/data/bale_browser_session`) قرار می‌گیرد.
- ورود اولیه فقط یک‌بار توسط تیم فنی با شماره تلفن اختصاصی و کد OTP پیامکی انجام می‌شود و کوکی‌ها و توکن‌های LocalStorage/IndexedDB برای همیشه حفظ می‌گردند.
2. **انتظار هوشمند بر اساس رخدادهای DOM:**
- استفاده از سلکتورهای مقاوم با `waitForSelector` یا `MutationObserver` روی باکس متن ترنسکرایب.
3. **مزایا و معایب:**
- 🟢 **تضمین تطابق با سرور:** چون مرورگر رسمی در حال اجرا است، هیچ‌گونه ریسک ناسازگاری پکت‌های شبکه وجود ندارد.
- 🟢 **پایداری در برابر تغییرات داخلی API:** حتی اگر بله فریم‌ورک پکت‌های وب‌سوکت خود را عوض کند، تا زمانی که دکمه در UI وجود دارد سیستم کار می‌کند.
- 🔴 **مصرف بالای منابع:** هر نمونه Chromium حدود ۳۰۰ تا ۵۰۰ مگابایت رم اشغال می‌کند.
- 🔴 **تاخیر زمانی (Latency):** فرآیند رندر صفحه، شبیه‌سازی آپلود و رویداد کلیک حدود ۳ تا ۶ ثانیه زمان می‌برد.
---
## ۴. روش دوم: مهندسی معکوس شبکه و پروتکل کلاینت (Direct Client Protocol / gRPC-Web)
در این روش، بدون استفاده از مرورگر سنگین، تلاش می‌شود تا کلاینت پایتونی مستقیماً همان درخواست‌های شبکه کلاینت رسمی بله را شبیه‌سازی و فراخوانی کند.
### یافته‌های واقعی حاصل از بازرسی و تحلیل سورس‌کد لایو وب بله (`web.bale.ai`):
تیم ما اسکریپت‌های باندل تولیدی بله (`/static/js/index.4aefbadad3.js` و بسته‌های وابسته) را مستقیماً تحلیل کرد که نتایج فنی مهم زیر حاصل شد:
1. **پروتکل زیرساختی:** بله از APIهای متنی ساده (مانند REST یا JSON) استفاده **نمی‌کند**؛ بلکه کل ارتباطات کلاینت وب بر پایه **gRPC-Web همراه با Protocol Buffers دودویی (`@bufbuild/protobuf`)** پیاده‌سازی شده است.
2. **آدرس‌های Gateway سرورهای بله:**
- سرور gRPC پروداکشن: `https://maviz-ws.bale.ai`
- اتصال وب‌سوکت پروداکشن: `wss://maviz-ws.bale.ai/ws/?uid={user_id}`
- محیط‌های آزمایشی: `beta-ws.bale.ai` و `next-ws.bale.ai`
3. **شناسه‌ها و فیچرهای کشف‌شده در کد:**
- شناسه ماژول پیاده‌سازی صوت: `u.SpeechTranscript = "SpeechTranscript"`
- ماژول چت هوش مصنوعی: `u.AIChat = "AIChat"` با روت صفحه `/${L.AI_CHAT}`
- دامنه‌های مربوطه: `domain_chat_ai: "app.all.domain_chat_ai"` و `chat_ai_ttl`
### ارزیابی فنی و چالش‌های روش دوم:
- **پیچیدگی بالای پیاده‌سازی:** به دلیل استفاده از پکت‌های فشرده دودویی Protobuf، برای فراخوانی مستقیم در پایتون باید فایل‌های تعاریف اینترفیس (`.proto`) بله به صورت کامل مهندسی معکوس و دیکود شوند و فریم‌های ۵ بایتی استاندارد gRPC-Web در کلاینت پایتون کدگذاری گردند.
- **ریسک شکنندگی در تغییر اسکیما:** با کوچک‌ترین تغییر در شماره فیلدهای Protobuf یا متدهای سرور بله در نسخه‌های جدید، کلاینت مهندسی معکوس‌شده از کار می‌افتد.
---
## ۵. ماتریس مقایسه جامع فنی (Technical Decision Matrix)
| معیار ارزیابی | روش اول: اتوماسیون وب (Playwright) | روش دوم: مهندسی معکوس پروتکل کلاینت | اهمیت برای سیستم |
| :--- | :---: | :---: | :---: |
| **تاخیر پاسخ‌دهی (Latency)** | ۳ تا ۶ ثانیه | **زیر ۱ ثانیه** ⚡️ | بسیار بالا (تاثیر مستقیم روی UX وویس) |
| **مصرف حافظه RAM** | ۳۵۰ الی ۵۰۰ مگابایت | **کمتر از ۱۵ مگابایت** 🪶 | متوسط به بالا |
| **هزینه پردازشی CPU** | متوسط تا بالا (رندرینگ) | بسیار پایین (درخواست شبکه) | متوسط |
| **پیچیدگی پیاده‌سازی اولیه** | کم (طی چند ساعت با سلکتور) | متوسط (نیازمند اسنیف و دیکود پکت) | موقت |
| **پایداری نشست کاربری (Session)** | ذخیره در پوشه کاربری مرورگر | ذخیره توکن در متغیر محیطی / دیتابیس | بالا |
| **ریسک تغییرات توسط بله** | کم (مقاوم در برابر تغییر پروتکل) | نیازمند پایش در تغییر نسخه‌های ماژور | متوسط |
| **قابلیت مقیاس‌پذیری (Scalability)** | محدود (محدودیت منابع مرورگر) | نامحدود (ایسنک و چندنخی) | بالا |
---
## ۶. نحوه اتصال به پایپ‌لاین STT چندمدله فعلی
سامانه تدارکات در حال حاضر قابلیت دریافت چند کاندیدا را دارد. با اتصال بله، خروجی به شکل زیر در پردازش قرار می‌گیرد:
```mermaid
graph TD
Voice[وویس دریافتی کاربر از بله] --> FFMPEG[تبدیل یک‌باره به MP3]
FFMPEG --> S1[مدل ۱: Whisper-Large]
FFMPEG --> S2[مدل ۲: Soniox / OpenRouter]
FFMPEG --> S3[مدل ۳: موتور صوت هوش مصنوعی بله]
S1 --> Aggregator[تجمیع نتایج کاندیداها]
S2 --> Aggregator
S3 --> Aggregator
Aggregator --> DeepSeek[تحلیلگر هوشمند DeepSeek]
DeepSeek --> Final[متن اصلاح‌شده Consensus Text + استخراج اقلام استعلام JSON]
```
---
## ۷. نقشه راه پیشنهادی دو مرحله‌ای برای تیم توسعه (Recommended Roadmap)
> [!TIP]
> **نتیجه تست و تحلیل تجربی ترافیک:**
> بر اساس آنالیز دقیق سورس‌کد لایو `web.bale.ai` که انجام شد، ماهیت درخواست‌های بله مشخص گردید: ارتباطات به صورت فریم‌های دودویی **Protobuf + gRPC-Web** است. بنابراین مهندسی معکوس پکت‌ها نیازمند استخراج و بازسازی مستمر فایل‌های `.proto` اختصاصی بله است که نگهداری آن را بسیار شکننده می‌کند.
>
> **نتیجه‌گیری و پیشنهاد نهایی:**
> مناسب‌ترین، مطمئن‌ترین و پایدارترین گزینه برای استقرار در محصول، **روش اول (اتوماسیون مرورگر با Playwright Worker)** است:
> ۱. مرورگر به صورت طبیعی از وب‌سوکت و پروتکل Protobuf بله پشتیبانی می‌کند و نیازی به رمزگشایی پکت‌ها نیست.
> ۲. با یک سشن پایدار در داکر، وویس در صفحه پیاده شده و متن خروجی به شکل مطمئن به خط لوله Multi-STT تحویل داده می‌شود.
> ۳. پیاده‌سازی آن در قالب یک کانتینر ایزوله (`bale-stt-worker`) با یک اندپوینت سبک `POST /transcribe` سریعاً انجام می‌پذیرد.
---
*تهیه شده جهت ارائه به تیم فنی و مدیران سیستم تدارکات هوشمند.*
@@ -0,0 +1,499 @@
# سند جامع، تحلیلی و عملیاتی کرالر تبدیل صوت‌به‌متن (STT) بله
> **نسخه:** ۳.۰ (جامع، تجمیعی و نهایی)
> **مخاطب اول:** مدیر پروژه (تصمیم‌گیری کلان، ماتریس‌های مقایسه‌ای، جریان‌های داده و فلوهای اجرایی)
> **مخاطب دوم:** ایجنت هوش مصنوعی مجری (شناخت پروژه، معماری فنی، ساختار پروتکل، کدهای مرجع و چک‌لیست راه‌اندازی)
> **وضعیت نیازمندی‌ها:** محدودیت حریم خصوصی موقتاً برداشته شده؛ صحت و کیفیت بالای موتور بله در استعلام‌های تدارکاتی توسط کاربر اثبات شده؛ وجود دکمه تبدیل متن در وب (`web.bale.ai`) تأیید شده؛ رویکرد Playwright به عنوان **فالبک آخر (Last Resort)** و رویکرد RPC به عنوان **اولویت اول** تعیین شده است؛ هیچ تخمین زمانی تقویمی وجود ندارد چون تمامی پیاده‌سازی‌ها توسط ایجنت انجام می‌شود.
---
# بخش اول: برای مدیر پروژه (دید کلان، مقایسه‌ها و فلوهای تصویری)
## ۱. صورت‌مسئله و دلایل توجیهی پروژه
در سامانه استعلام هوشمند تدارکات، کاربران پیام‌های صوتی (ویس) حاوی درخواست‌های پیچیده خرید (شامل نام کالا، برند، مشخصات فنی، مقادیر، واحدها و اصطلاحات بازار) ارسال می‌کنند.
بات تدارکات برای رونویسی این پیام‌ها از یک **خط‌لوله چندمدله (Multi-Provider STT Pipeline)** استفاده می‌کند؛ به این معنا که ویس همزمان به چند سرویس تبدیل صوت ارسال شده و متن‌های دریافت‌شده در اختیار یک مدل تحلیلی هوش مصنوعی (DeepSeek NLU) قرار می‌گیرد تا با ایجاد اجماع (Consensus)، خطاهای واژگانی برطرف و ساختار استاندارد استعلام استخراج گردد.
### چرا کرال کردن موتور بله ارزش بالایی دارد؟
1. **کیفیت اثبات‌شده در آزمون واقعی:** تست‌های تجربی روی ویس‌های واقعی استعلام تدارکات نشان داد که موتور تشخیص گفتار بله تسلط بسیار بالایی بر محاورات عامیانه فارسی، اصطلاحات بازاری و لهجه‌ها دارد.
2. **فقدان متد در رابط رسمی (Bot API):** در مستندات رسمی بله (`docs.bale.ai`)، شیء `Voice` ارسالی به ربات‌ها فاقد فیلد ترنسکریپت بوده و تنها مشخصاتی نظیر `file_id` را برمی‌گرداند؛ بنابراین دسترسی به این موتور صرفاً از طریق شبیه‌سازی نشست کاربری امکان‌پذیر است.
3. **تأیید وجود دکمه در وب بله:** بررسی محیط `web.bale.ai` اثبات کرد که قابلیت تبدیل ویس به متن در کلاینت تحت وب بله فعال است؛ این یافته امکان کپچر ترافیک و مهندسی معکوس و نیز استفاده از وب به عنوان پشتیبان را تضمین می‌کند.
4. **دستور مدیریت پروژه در چینش اولویت‌ها:**
* **اولویت اول:** ارتباط مستقیم در لایه شبکه و پروتکل کاربری (RPC / WebSocket) به دلیل مصرف ناچیز منابع (کمتر از ۲۰ مگابایت رم) و سرعت استخراج بالا.
* **فالبک آخر (Last Resort):** کرال کردن بصری صفحه چت با Playwright صرفاً در شرایطی که ارتباط شبکه‌ای به بن‌بست برسد.
---
## ۲. ماتریس مقایسه‌ای جامع ۸ راهکار موجود
برای ایجاد دید همه‌جانبه، تمامی راهکارهای مطرح در اکوسیستم فنی در جدول زیر کالبدشکافی شده‌اند (بدون ستون‌های زائد زمانی؛ با تمرکز بر رفتار سیستمی):
| # | راهکار فنی | نحوه عملکرد | مصرف حافظه RAM | خلوص متن خروجی | پایداری در برابر تغییرات | ریسک عملیاتی | اولویت در پروژه ما |
| :---: | :--- | :--- | :---: | :---: | :---: | :---: | :---: |
| **۱** | **کلاینت شبکه و RPC بله (Web RPC / WS)** | فراخوانی مستقیم متد کشف‌شده `GetTranscript` در کلاینت کاربری پایتون | کمتر از ۲۰ مگابایت | متن خالص گفتار | متوسط (وابسته به ثبات متد) | احتمال نیاز به تمدید نشست (OTP) | ⭐⭐⭐ **اولویت اول و اصلی** |
| **۲** | **اتوماسیون Playwright در `web.bale.ai`** | مرورگر بدون سربرگ (Headless)، کلیک روی دکمه و خواندن متن از DOM | ۳۵۰ تا ۵۰۰ مگابایت | متن خالص گفتار | بالا (مقاوم به پکت‌های شبکه) | مصرف رم و سربار رندر المان‌ها | ⚠️ **فالبک نهایی (Last Resort)** |
| **۳** | **وب‌سرویس‌های تجاری ایرانی (فارس‌آوا / آوانگار)** | اتصال رسمی به API شرکت‌های پردازش صوت بومی (عامراندیش، پارت‌آی و...) | ناچیز (سمت سرور ثالث) | متن خالص گفتار | بسیار بالا (SLA تجاری) | ایجاد هزینه اشتراک ریالی برای دقایق صوت | ⭐⭐ **پیشنهاد به عنوان کاندید موازی** |
| **۴** | **مدل متن‌باز خودمیزبان (Faster-Whisper)** | استقرار مدل `whisper-v3-turbo` با واژگان تدارکات روی سرور داخلی | ۱ تا ۳ گیگابایت | متن خالص گفتار | پایدار مطلق (بدون وابستگی) | نیاز به پردازنده گرافیکی (GPU) مناسب | ⭐⭐ **پرووایدر پشتیبان و مستقل** |
| **۵** | **وب‌سرویس‌های واسط غیررسمی (MajidAPI و...)** | ارسال لینک ویس به پراکسی‌های عمومی رایگان | ناچیز (سمت سرور ثالث) | وابسته به موتور واسط | بسیار پایین و ناپایدار | فقدان SLA، احتمال قطعی ناگهانی و نشت صوت | ❌ **رد شده (غیرقابل اتکا)** |
| **۶** | **چت هوش مصنوعی بله (اوراکل)** | ارسال ویس به ربات رسمی هوش مصنوعی درون بله | کمتر از ۲۰ مگابایت | **ناخالص** (متن + تحلیل LLM) | بسیار پایین | پاسخ تفسیری، تاخیر ۵ تا ۱۵ ثانیه‌ای | ❌ **رد شده (تخریب اجماع)** |
| **۷** | **شبیه‌ساز اندروید (Appium / UIAutomator)** | اجرای اپلیکیشن اندروید بله روی ماشین مجازی و اسکرپ متن | بیش از ۲ گیگابایت | متن خالص گفتار | شکننده | سربار بسیار سنگین CPU/RAM سرور | ❌ **رد شده** |
| **۸** | **سرویس‌های ابری خارجی (OpenAI / Chirp 2)** | ارسال فایل به API رسمی Whisper یا Google Chirp | ناچیز (سمت سرور ثالث) | متن خالص گفتار | بسیار بالا | وابستگی به ارز و ریسک تحریم/اختلال شبکه | ⭐ **پرووایدر کمکی در صورت دسترسی** |
---
## ۳. فلوهای جامع تصویری (Architecture & Sequence Flows)
### فلو ۱: جریان کلان پردازش ویس در سیستم تدارکات (End-to-End System Flow)
این دیاگرام نشان می‌دهد ویس ارسالی کاربر چگونه در خط‌لوله موازی پردازش شده و کاندید بله بدون ایجاد وقفه در سیستم ثبت استعلام می‌نشیند:
```mermaid
sequenceDiagram
autonumber
actor User as کاربر تدارکات
participant MainBot as بات اصلی تدارکات (Bale Bot API)
participant STTPipeline as موتور چندمدله (bot/app/stt.py)
participant BaleAdapter as میکروسرویس آداپتور بله (bale-stt:8080)
participant OtherProviders as سایر پرووایدرها (OpenAI / محلی)
participant ConsensusNLU as مدل استخراج هوشمند (DeepSeek NLU)
User->>MainBot: ارسال پیام صوتی استعلام خرید
MainBot->>STTPipeline: دریافت ویس و تبدیل فرمت به MP3 (16kHz Mono)
par استعلام موازی از پرووایدرهای فعال
STTPipeline->>BaleAdapter: POST /v1/audio/transcriptions (بایت‌های ویس)
STTPipeline->>OtherProviders: POST /v1/audio/transcriptions
end
critical پردازش در کانتینر ایزوله کرالر بله
BaleAdapter->>BaleAdapter: کنترل صف ترتیبی و اعمال تاخیر رفتاری (Jitter)
BaleAdapter->>BaleAdapter: ارسال صوت به اکانت کرالر و فراخوانی متد GetTranscript
BaleAdapter-->>BaleAdapter: دریافت متن پیاده‌شده و حذف پیام اسکرچ
end
BaleAdapter-->>STTPipeline: بازگشت متن کاندید بله {"text": "..."}
OtherProviders-->>STTPipeline: بازگشت متن سایر کاندیدها
STTPipeline->>ConsensusNLU: ارسال تمام کاندیدها به مدل اجماع‌گیر
ConsensusNLU-->>MainBot: استخراج اقلام کالا، برند، مشخصات و تعداد
MainBot-->>User: ارسال پیش‌فاکتور استعلام یا تاییدیه به کاربر
```
---
### فلو ۲: جریان رله صوتی در کرالر (Relay Bot Pattern Flow)
برای جلوگیری از فرآیند پرریسک و پیچیده «آپلود مستقیم فایل از اکانت شخصی»، از الگوی **بات رله** استفاده می‌شود:
```mermaid
sequenceDiagram
autonumber
participant Adapter as هسته آداپتور FastAPI
participant RelayBot as بات رله اختصاصی (Bale Bot API)
participant BaleServer as سرورهای مرکزی بله
participant CrawlerUser as اکانت کاربری کرالر (WebSocket Client)
Adapter->>Adapter: دریافت فایل صوتی از خط‌لوله بات
Adapter->>RelayBot: فراخوانی sendVoice(chat_id=کرالر, voice=audio, caption=nonce)
RelayBot->>BaleServer: ارسال ویس با مجوز رسمی بات
BaleServer-->>CrawlerUser: دریافت رویداد پیام صوتی جدید (دارای Media و MessageId و OutPeer)
CrawlerUser->>BaleServer: فراخوانی مستقیم RPC: GetTranscript(Media, OutPeer, MessageId)
BaleServer-->>CrawlerUser: بازگشت پاسخ (mustWait / downloadSource)
opt در صورت فعال بودن mustWait
CrawlerUser->>CrawlerUser: پولینگ سبک یا انتظار دریافت رویداد تکمیلی
end
CrawlerUser->>BaleServer: فراخوانی DeleteMessage (حذف پیام برای حفظ بهداشت اکانت)
CrawlerUser-->>Adapter: تحویل متن خالص استخراج‌شده
```
---
### فلو ۳: فلو تاب‌آوری و عدم توقف بات (Fail-Safe & Graceful Degradation)
این فلو تضمین می‌کند که حتی در بدترین سناریوها (تغییر پروتکل بله، بن شدن اکانت یا قطعی سرور بله)، سیستم اصلی تدارکات دچار حتی یک ثانیه قطعی نخواهد شد:
```mermaid
flowchart TD
Start["کاربر ویس می‌فرستد"] --> Multi["اجرای همزمان transcribe_multi() در stt.py"]
Multi --> CallBale["ارسال درخواست به آداپتور بله"]
Multi --> CallOthers["ارسال درخواست به سایر پرووایدرها"]
CallBale --> CheckBale{"آیا خطایی رخ داد؟ (بن/تغییر پروتکل/تایم‌اوت)"}
CheckBale -- "بله" --> Resp503["آداپتور کد HTTP 503 برمی‌گرداند"]
Resp503 --> LogWarn["تابع _call_single_stt هشدار ثبت کرده و None بازمی‌گرداند"]
LogWarn --> IgnoreBale["کاندید بله بدون اختلال از لیست اجماع حذف می‌شود"]
CheckBale -- "خیر" --> Resp200["آداپتور متن رونویسی‌شده را تحویل می‌دهد"]
Resp200 --> AddBale["متن بله به لیست کاندیدها اضافه می‌شود"]
CallOthers --> RespOthers["متن‌های سایر پرووایدرها دریافت می‌شوند"]
IgnoreBale --> NLU["اجماع NLU روی سایر متن‌های موجود انجام می‌شود"]
AddBale --> NLU
RespOthers --> NLU
NLU --> Done["استعلام با موفقیت ثبت و پاسخ به کاربر داده می‌شود"]
subgraph Rollback["عملیات قطع دائمی (در صورت لزوم)"]
Env["خالی کردن متغیرهای PROCUREMENT_BOT_STT_PROVIDER_3_*"]
ZeroDowntime["سیستم بدون ری‌استارت یا دیپلوی مجدد به کار عادی ادامه می‌دهد"]
end
```
---
### فلو ۴: سلسله‌مراتب گزینش موتور در آداپتور (Engine Fallback Hierarchy)
```mermaid
graph TD
StartEngine["درخواست STT وارد آداپتور می‌شود"] --> ModeCheck{"بررسی تنظیم متغیر BALE_STT_ENGINE"}
ModeCheck -- "rpc (پیش‌فرض)" --> TryRPC["اجرای موتور E1: متد باینری GetTranscript"]
TryRPC --> CheckRPC{"آیا رونویسی با موفقیت انجام شد؟"}
CheckRPC -- "بله" --> ReturnText["بازگرداندن متن (HTTP 200)"]
CheckRPC -- "شکست در پروتکل شبکه" --> CheckFallback{"آیا فالبک خودکار مجاز است؟"}
CheckFallback -- "بله" --> TryPW["سوئیچ به موتور E2: اتوماسیون Playwright (فالبک آخر)"]
TryPW --> CheckPW{"آیا متن از وب استخراج شد؟"}
CheckPW -- "بله" --> ReturnText
CheckPW -- "خیر" --> Fail503["بازگرداندن خطای HTTP 503 (Fail-Soft)"]
CheckFallback -- "خیر" --> Fail503
ModeCheck -- "playwright" --> TryPW
```
---
# بخش دوم: برای ایجنت هوش مصنوعی مجری (Technical Execution Specification)
## ۱. شناخت ساختار کدهای موجود در پروژه
ایجنت باید بداند بات تدارکات فعلی در فایل‌های زیر پیاده‌سازی شده و قراردادهای آن به چه صورت است:
### ۱.۱ فایل [bot/app/stt.py](file:///c:/Users/Reza/Desktop/v1.5/bot/app/stt.py)
* **قرارداد ارتباطی:** تابع `_call_single_stt` ریکوئست `POST` به اندپوینت `{base_url}/audio/transcriptions` با هدر `Authorization: Bearer {api_key}` ارسال می‌کند.
* **فایل ارسالی:** فرم چندبخشی (Multipart) شامل کلید `file` با باینری صوتی MP3 (16kHz Mono)، `model` و `language="fa"`.
* **مدیریت پاسخ:**
* در صورت دریافت کد `200` و وجود کلید `text` غیرخالی، مقدار به عنوان کاندید معتبر ثبت می‌شود.
* در صورت دریافت هر کد خطایی (مانند `503`) یا بروز استثنا، صرفاً لاگ هشدار ثبت شده و مقدار `None` بازمی‌گردد.
* **تابع `transcribe_multi`:** تمام تسک‌ها را با `asyncio.gather(..., return_exceptions=True)` اجرا می‌کند؛ یعنی شکست یک پرووایدر هرگز کل فرآیند را متوقف نمی‌کند.
### ۱.۲ فایل [bot/app/config.py](file:///c:/Users/Reza/Desktop/v1.5/bot/app/config.py)
* متغیرهای محیطی با پیشوند `PROCUREMENT_BOT_STT_PROVIDER_N_*` (که N اعداد ۱ تا ۳ است) خوانده می‌شوند.
* یک پرووایدر تنها زمانی فعال می‌شود که **هر دو فیلد `BASE_URL` و `API_KEY` مقداردهی شده باشند**. بنابراین با خالی کردن این دو فیلد، پرووایدر بله فوراً از مدار خارج می‌شود (Rollback آنی).
---
## ۲. مشخصات قطعی کشف‌شده از پروتکل بله
در بررسی و راستی‌آزمایی مخازن PyPI روی پکیج `bale-sdk` (نسخه 0.1.1)، متد و ساختار فیلدهای ترنسکریپشن دقیقاً استخراج شده است:
* **فضای نام و سرویس:** `bale.turing.v1.AI`
* **متد باینری:** `GetTranscript`
* **اسکیمای درخواست (`GetTranscriptRequest`):**
```protobuf
message GetTranscriptRequest {
Media voice = 1; // آبجکت فایل صدا (شناسه و خصوصیات باینری)
OutPeer outPeer = 2; // مقصد چت (پیر چت شخصی یا بات رله)
MessageId messageId = 3;// شناسه پیام ثبت‌شده در چت
}
```
* **اسکیمای پاسخ (`GetTranscriptResponse`):**
```protobuf
message GetTranscriptResponse {
bool mustWait = 1; // فلگ بولی نشان‌دهنده در صف بودن پردازش سرور
DownloadSource downloadSource = 2; // منبع یا لینک بافر متنی خروجی
}
```
---
## ۳. ارزیابی کتابخانه‌های پایتون و استراتژی Clean-Room
| نام پکیج | نسخه در PyPI | مجوز حقوقی | وضعیت استفاده فنی در پروژه ما |
| :--- | :---: | :---: | :--- |
| **`bale-sdk`** | 0.1.1 | اختصاصی (All Rights Reserved) | **استفاده به عنوان مرجع پروتکل؛** به دلیل مجوز اختصاصی، کدهای آن کپی مستقیم نمی‌شوند بلکه ساختار پیام‌ها بر اساس کپچر بازسازی می‌گردد. |
| **`autobale`** | 1.0.0 | **MIT** (متن‌باز) | **مناسب برای هسته ارتباطی؛** فورک فعال و بازنویسی‌شده از `aiobale` که بر بستر WebSocket و Dispatcher ناهمگام کار می‌کند. |
| **`aiobale-py`** | 0.3.9 | عمومی | دارای لایه وب‌سوکت و هندلرهای باینری مبتنی بر `blackboxprotobuf` (`bbpb`). |
| **`bbpb` / `blackboxprotobuf`** | آخرین نسخه | MIT | ابزار دیکود و انکود فریم‌های باینری پروتوباف بدون نیاز به کامپایل فایلهای `.proto`. |
---
## ۴. ورودی‌های انسانی مورد نیاز ایجنت (Agent Inputs)
ایجنت قبل از راه‌اندازی، این ۵ مورد را از انسان دریافت می‌کند:
1. `BALE_PHONE`: شماره سیم‌کارت شرکتی اختصاصی جهت ورود به اکانت کاربری.
2. `BALE_OTP_CODE`: کد پیامکی ۶ رقمی یک‌بارمصرف در زمان اجرای اسکریپت ورود اولیه.
3. `BALE_RELAY_BOT_TOKEN`: توکن بات رله اختصاصی که از BotFather بله دریافت شده است.
4. `BALE_STT_INTERNAL_KEY`: کلید امنیتی تصادفی برای هدر `Authorization: Bearer <KEY>`.
5. `CRAWLER_USER_ID`: شناسه عددی کاربری اکانت کرالر (جهت ارسال پیام توسط بات رله).
---
## ۵. کدهای مرجع میکروسرویس آداپتور (`bale-stt`)
### ۵.۱ ساختار پروژه ایجنت
```
bale-stt/
├── adapter/
│ ├── main.py # میکروسرویس FastAPI سازگار با قرارداد OpenAI
│ ├── counters.py # مدیریت سقف روزانه با دیتابیس پایدار SQLite
│ ├── config.py # تنظیمات محیطی بدون مقدار پیش‌فرض خطرناک
│ ├── engines/
│ │ ├── base.py # اینترفیس استاندارد موتورها
│ │ ├── rpc.py # موتور اولویت ۱: ارتباط RPC و الگوی بات رله
│ │ └── playwright.py # موتور فالبک آخر: اتوماسیون صفحه وب بله
│ ├── Dockerfile
│ └── requirements.txt
├── docker-compose.stt.yml
└── .env.example
```
### ۵.۲ مدیریت سقف روزانه (`adapter/counters.py`)
```python
import os
import sqlite3
from contextlib import closing
from datetime import date
DB_PATH = os.getenv("BALE_STT_DB", "/app/state/usage.db")
def _init_db() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
con = sqlite3.connect(DB_PATH, timeout=5)
con.execute("CREATE TABLE IF NOT EXISTS usage (day TEXT PRIMARY KEY, count INTEGER NOT NULL)")
return con
def get_today_count() -> int:
with closing(_init_db()) as con:
row = con.execute("SELECT count FROM usage WHERE day = ?", (date.today().isoformat(),)).fetchone()
return row[0] if row else 0
def increment_today_count() -> None:
with closing(_init_db()) as con, con:
con.execute(
"INSERT INTO usage(day, count) VALUES (?, 1) "
"ON CONFLICT(day) DO UPDATE SET count = count + 1",
(date.today().isoformat(),),
)
```
### ۵.۳ کد اصلی آداپتور (`adapter/main.py`)
```python
import os
import secrets
import asyncio
import logging
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
from counters import get_today_count, increment_today_count
from engines.base import get_engine
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("bale-stt-adapter")
app = FastAPI(title="Bale STT OpenAI Adapter")
INTERNAL_KEY = os.environ["BALE_STT_INTERNAL_KEY"]
DAILY_CAP = int(os.getenv("BALE_STT_DAILY_CAP", "200"))
TIMEOUT_SECONDS = float(os.getenv("BALE_STT_TIMEOUT", "25.0"))
# قفل همزمانی برای پردازش تک‌اکانت کاربری
account_lock = asyncio.Lock()
engine = get_engine()
@app.get("/healthz")
def health_check():
return {
"status": "ok",
"engine": engine.name,
"used_today": get_today_count(),
"cap": DAILY_CAP
}
@app.post("/v1/audio/transcriptions")
async def transcribe(
file: UploadFile = File(...),
model: str = Form("bale-stt"),
language: str = Form("fa"),
authorization: str = Header("")
):
# ۱. اعتبارسنجی امن کلید احراز هویت (مقاوم به Timing Attack)
expected_header = f"Bearer {INTERNAL_KEY}"
if not secrets.compare_digest(authorization.encode(), expected_header.encode()):
raise HTTPException(status_code=401, detail="Unauthorized")
# ۲. کنترل سقف مصرف مجاز روزانه
if get_today_count() >= DAILY_CAP:
log.warning("Daily STT capacity reached (%d)", DAILY_CAP)
raise HTTPException(status_code=429, detail="Daily quota exhausted")
audio_bytes = await file.read()
if not audio_bytes:
raise HTTPException(status_code=400, detail="Empty audio payload")
# ۳. ورود به صف ترتیبی با فاصله زمانی تصادفی انسانی
async with account_lock:
try:
# اعمال Jitter بین ۱ تا ۲.۵ ثانیه
await asyncio.sleep(1.0 + (secrets.randbelow(15) / 10.0))
# فراخوانی موتور فعال با سقف زمانی مجاز
text = await asyncio.wait_for(
engine.transcribe(audio_bytes, filename=file.filename or "voice.mp3"),
timeout=TIMEOUT_SECONDS
)
cleaned_text = (text or "").strip()
if not cleaned_text:
raise RuntimeError("Received empty transcript from Bale")
increment_today_count()
log.info("Transcription successful (length=%d)", len(cleaned_text))
return {"text": cleaned_text}
except Exception as exc:
log.exception("Transcription failed in engine %s: %s", engine.name, exc)
# پرتاب 503 حیاتی است: باعث می‌شود bot/app/stt.py کاندید را نادیده بگیرد
raise HTTPException(status_code=503, detail="Bale STT Service Temporarily Unavailable")
```
### ۵.۴ موتور RPC با الگوی بات رله (`adapter/engines/rpc.py`)
```python
import os
import uuid
import asyncio
import httpx
from engines.base import BaseEngine
class RpcRelayEngine(BaseEngine):
name = "rpc"
def __init__(self):
self.bot_token = os.environ["BALE_RELAY_BOT_TOKEN"]
self.crawler_user_id = int(os.environ["BALE_CRAWLER_USER_ID"])
self.api_url = f"https://tapi.bale.ai/bot{self.bot_token}"
async def transcribe(self, audio: bytes, filename: str) -> str:
nonce = str(uuid.uuid4())[:8]
# ۱. ارسال ویس با Bot API رسمی به چت اکانت کرالر
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{self.api_url}/sendVoice",
data={"chat_id": self.crawler_user_id, "caption": nonce},
files={"voice": ("voice.ogg", audio, "audio/ogg")}
)
if resp.status_code != 200:
raise RuntimeError(f"Relay bot sendVoice failed: {resp.text}")
sent_message_id = resp.json()["result"]["message_id"]
try:
# ۲. دریافت رویداد پیام در کلاینت کاربری و فراخوانی GetTranscript
from driver.bale_client import get_client
bale_client = await get_client()
transcript_text = await bale_client.call_get_transcript(
message_id=sent_message_id,
nonce=nonce,
timeout=18.0
)
return transcript_text
finally:
# ۳. پاکسازی بهداشتی: حذف پیام از چت
async with httpx.AsyncClient(timeout=5.0) as client:
await client.post(
f"{self.api_url}/deleteMessage",
json={"chat_id": self.crawler_user_id, "message_id": sent_message_id}
)
```
---
## ۶. تنظیمات داکر و یکپارچه‌سازی با بات اصلی
### ۶.۱ فایل `adapter/Dockerfile`
```dockerfile
FROM python:3.11-slim
WORKDIR /app
ENV TZ=Asia/Tehran PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
```
### ۶.۲ فایل `docker-compose.stt.yml`
```yaml
services:
bale-stt:
build:
context: ./adapter
dockerfile: Dockerfile
container_name: bale-stt-adapter
restart: unless-stopped
environment:
- TZ=Asia/Tehran
- BALE_STT_ENGINE=rpc
- BALE_STT_INTERNAL_KEY=${BALE_STT_INTERNAL_KEY}
- BALE_STT_DAILY_CAP=200
- BALE_RELAY_BOT_TOKEN=${BALE_RELAY_BOT_TOKEN}
- BALE_CRAWLER_USER_ID=${BALE_CRAWLER_USER_ID}
volumes:
- bale_session_data:/app/session
- bale_usage_data:/app/state
networks:
- bot_network
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz')"]
interval: 30s
timeout: 5s
retries: 3
volumes:
bale_session_data:
bale_usage_data:
networks:
bot_network:
external: true
```
### ۶.۳ تنظیمات متغیرهای محیطی در فایل `.env` بات اصلی
اتصال به بات اصلی **بدون دستکاری حتی ۱ خط از کدهای پایتون بات** انجام می‌پذیرد:
```env
PROCUREMENT_BOT_STT_PROVIDER_3_NAME=bale
PROCUREMENT_BOT_STT_PROVIDER_3_BASE_URL=http://bale-stt:8080/v1
PROCUREMENT_BOT_STT_PROVIDER_3_API_KEY=${BALE_STT_INTERNAL_KEY}
PROCUREMENT_BOT_STT_PROVIDER_3_MODEL=bale-stt
PROCUREMENT_BOT_STT_PROVIDER_3_LANGUAGE=fa
PROCUREMENT_BOT_STT_PROVIDER_3_TIMEOUT=30
```
---
## ۷. چک‌لیست گام‌به‌گام پیاده‌سازی خودکار توسط ایجنت
ایجنت مجری برای به سرانجام رساندن پروژه، این چک‌لیست عملیاتی را بدون معطلی و به ترتیب اجرا می‌کند:
* [ ] **گام ۱ (لاگین کاربری و ذخیره سشن):** اجرای اسکریپت CLI برای ثبت شماره `BALE_PHONE`، دریافت کد `BALE_OTP_CODE` از انسان، صدور توکن پایدار JWT و ذخیره در مسیر `/app/session/session.json`.
* [ ] **گام ۲ (تست اتصال بات رله):** تست ارسال یک صوت آزمایشی با `sendVoice` از بات رله به `BALE_CRAWLER_USER_ID` و اطمینان از دریافت وب‌سوکت در کلاینت کرالر.
* [ ] **گام ۳ (اعتبارسنجی فراخوانی `GetTranscript`):** پاس دادن `Media` و `MessageId` به متد باینری و دریافت رشته متن فارسی.
* [ ] **گام ۴ (بیلد کانتینر آداپتور FastAPI):** بیلد ایمیج داکر و اجرای محلی روی پورت 8080.
* [ ] **گام ۵ (تست تطابق با قرارداد OpenAI):** ارسال ریکوئست تستی با `curl` به `/v1/audio/transcriptions` و اعتبارسنجی پاسخ `{"text": "..."}`.
* [ ] **گام ۶ (تست سناریوی خطا):** قطع عمدی اتصال بله و حصول اطمینان از بازگشت کد `HTTP 503` (جهت تضمین عدم توقف بات اصلی).
* [ ] **گام ۷ (فعال‌سازی در بات تدارکات):** قرار دادن مقادیر در `.env` بات اصلی.
* [ ] **گام ۸ (تست سراسری E2E):** ارسال پیام صوتی استعلام قیمت در پیام‌رسان بله و مشاهده لاگ‌های `bot.stt` مبنی بر رونویسی موفق کاندید بله و اجماع در مدل DeepSeek NLU.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,432 @@
# گزارش جامع: ارزیابی پروژه‌های متن‌باز و سرویس‌های شخص ثالث، و ترکیب بهینه برای کرالر STT بله
> **تاریخ:** ۱۶ مهر ۱۴۰۵ (8 Oct 2026)
> **ورودی‌ها:** دو گزارش مدیر پروژه
> - **گزارش A:** «ارزیابی تحلیلی راهکارهای استخراج صوت‌به‌متن در بله: سرویس‌های شخص ثالث، کتابخانه‌ها، پروژه‌های متن‌باز و جایگزین‌ها»
> - **گزارش B:** `bale_stt_executive_roadmap_report3.md` (سند عملیاتی برای مدیر پروژه و ایجنت مجری)
>
> **دامنه (طبق درخواست):** حریم خصوصی و امنیت در این گزارش بررسی نشده و بعداً تصمیم‌گیری می‌شود. فقط فهرست کامل راهکارها، ارزیابی، الگوبرداری از متن‌باز و بهترین ترکیب ارائه شده است.
>
> **روش کار:** هر دو گزارش را خواندم و ادعاهای قابل راستی‌آزمایی را با منابع عمومی (PyPI، مستندات رسمی بله، arXiv، Hugging Face، GitHub) تطبیق دادم. هر ادعا یکی از سه وضعیت دارد:
> ✅ **تأیید شد** | 🟡 **تأییدنشده / فقط استنباط** | ❌ **با شواهد در تناقض است**
>
> **محدودیت مهم:** شبکه sandbox من بسته است؛ پس نتوانستم پکیج‌ها را نصب کنم یا پروتوها را بخوانم. هر جا بگویم «تأییدنشده»، یعنی در منابع عمومی قابل مشاهده برای من نبود، نه لزوماً اینکه غلط باشد.
---
## ۱. خلاصه اجرایی
### پاسخ کوتاه به چهار سؤال شما
| سؤال | پاسخ کوتاه |
|---|---|
| **نظر درباره گزارش A (متن‌باز و شخص ثالث)** | تحلیل خوبی است و دسته‌بندی ۸ راهکار درست است. ولی چند ادعای مهم آن تأییدنشده یا غلط است: نام و وضعیت پکیج `aiobale-py`، وجود متد `GetTranscript` (فقط ادعا شده، من نتوانستم ببینم)، و برتری «مدل متن‌باز» بر سرویس‌های تجاری. داده‌های مستقل خلاف این برتری را نشان می‌دهد (بخش ۶). |
| **آیا دیگران همین مشکل را داشته‌اند و چطور حل کرده‌اند؟** | **هیچ پروژه عمومی‌ای که دقیقاً خروجی STT خود بله را استخراج کند پیدا نکردم.** همان الگوهای نزدیک وجود دارد: کلاینت‌های کاربری مهندسی‌معکوس‌شده (`aiobale`، `bale-sdk`، فورک‌ها)، سلف‌بات‌ها و اسکرپرهای مبتنی بر Selenium/Playwright، و کسب‌وکارهایی که سراغ ASR مستقل رفته‌اند (بخش ۳). |
| **اگر الگوبرداری یا شخص ثالث بخواهیم، چه کنیم؟** | از متن‌باز، لایه‌های انتقال و نشست و ابزار دیکود پروتوباف را بردارید، نه خود «ترنسکرایب» را. از شخص ثالث، چند ASR را به‌عنوان کاندید موازی کنار بله بگذارید (بخش ۵ و ۶). |
| **بهترین ترکیب؟** | **«بله + یک ASR تجاری فارسی + یک مدل خودمیزبان با واژگان تخصصی + LLM اجماع‌گیر با کاتالوگ کالا»**، با کرالر بله به‌صورت adapter قابل‌سوییچ. جزئیات در بخش ۷. |
### سه یافته‌ای که تصمیم شما را تغییر می‌دهد
1. **«نیازی به آپلود ویس نیست.»** بات خودتان با `sendVoice(file_id)` می‌تواند همان ویس کاربر را برای حساب کرالر بفرستد. این‌طور فایل را کرالر دانلود و آپلود نمی‌کند. این طراحی از گزارش B ساده‌تر و کم‌ریسک‌تر است (بخش ۷.۲).
2. **داده بنچمارک مستقل نشان می‌دهد ASR تجاری فارسی حدود ۱۴ امتیاز WER از بهترین متن‌باز جلوتر است** (مقاله PSRB، بخش ۶.۲). این با ادعای گزارش A که «متن‌باز پایدارترین راهکار» است، همخوان نیست. البته روی داده عمومی بوده، نه واژگان تدارکات شما.
3. **کد مرجع گزارش B شبه‌کد است، نه کد اجراشدنی.** توابعی مثل `client.send_voice` و `client.raw_call` در مستندات عمومی `bale-sdk` وجود ندارند (بخش ۲.۲).
---
## ۲. ارزیابی دو گزارش مدیر پروژه
### ۲.۱ گزارش A: متن‌باز و شخص ثالث
**نقاط قوت**
- پوشش گسترده: ۸ راهکار در یک ماتریس مقایسه (تأخیر، رم، خلوص متن، پایداری، هزینه).
- تفکیک درست «چه چیزی را از بله بگیریم» و «چه چیزی را مستقل بسازیم».
- پیشنهاد معماری دومسیره (بله + مسیر مستقل) درست و عملی است.
- معرفی `bale-sdk` و توجه به مجوز اختصاصی آن (نکته‌ای که گزارش‌های قبلی کم‌رنگ داشتند).
- اشاره درست به اینکه Playwright روی وب بله الگوی جاافتاده‌ای است.
**نقاط ضعف**
- چند ادعای کلیدی بدون منبع است و در بررسی من تأیید نشد (جدول زیر).
- مقایسه دقت متن‌باز با تجاری فقط بر اساس WER خودگزارشی مدل‌ها روی Common Voice است. این عدد روی ویس‌های واقعی و محاوره‌ای معنی ندارد.
- ارائه‌دهندگان تجاری مهم فارسی (Avanegar/Partai، Aipa) و Google Chirp 2 اصلاً در گزارش نیستند.
- تحلیل خطای شخص ثالث ناقص است (مثلاً قیمت‌ها بدون تاریخ و منبع).
### جدول راستی‌آزمایی ادعاهای گزارش A
| ادعا | وضعیت | یافته من |
|---|---|---|
| Bot API رسمی STT ندارد | ✅ | مستندات رسمی `docs.bale.ai`: شیء `Voice` فقط `file_id` و `file_unique_id` دارد و هیچ متدی برای ترنسکریپت نیست. **اصلاح:** گزارش می‌گوید «حجم و مدت‌زمان» هم هست؛ در مستندات رسمی نیست (SDKهای شخص ثالث فیلد `duration` را اضافه کرده‌اند). |
| `bale-sdk` از `mtp1376`، نسخه 0.1.1، ۶۰۷ RPC در ۵۲ سرویس، پوشه `proto/` | ✅ | صفحه PyPI (27 Jun 2026) دقیقاً همین را می‌گوید. |
| `bale-sdk` مجوز اختصاصی «All Rights Reserved» دارد | ✅ | تأیید شد. خود README هم می‌گوید استفاده فقط با حساب‌ها و داده‌هایی که مجاز هستید و مطابق ToS بله باشد. |
| سرویس `bale.turing.v1.AI` و متد `GetTranscript` با فیلدهای ۱ تا ۳ | 🟡 | **نتوانستم از منبع عمومی تأیید کنم.** فایل‌های `docs/API_COVERAGE.md` و `docs/API_FA.md` در صفحه PyPI لینک شده‌اند ولی برای من باز نشد. این مهم‌ترین ادعای هر دو گزارش است و باید در ۳۰ دقیقه اول خودتان تأیید شود (بخش ۹). |
| `aiobale-py` توسط امین مدنی، نسخه 0.3.x، متکی بر `blackboxprotobuf` | 🟡 | `aiobale-py` را پیدا نکردم. آنچه پیدا کردم: `aiobale` (Enalite، MIT، آخرین نسخه 0.1.5 در Aug 2025). توضیح خودش می‌گوید «از صفر و بدون فایل proto ساخته شده». |
| `aiobale` اولیه رها شده | ✅ | آخرین انتشار Aug 2025؛ فورک‌ها (`autobale`، `BaleClient`) می‌گویند نگهداری نمی‌شود و باگ دارد. |
| `autobale` توسط علی عمادی، نسخه 1.0.0، MIT | ✅ | تأیید شد (مخزن `AliEmadii/autobale`). |
| `BaleClient` | — | در گزارش نیست، ولی وجود دارد: فورک دیگری از aiobale (`Foad-KhAb/baleclient`، Python 3.11+). |
| `bale-mcp-server` روی `aiobale-py` | 🟡/❌ | پروژه‌ای به نام `bale-mcp` پیدا کردم که **با توکن بات رسمی** و `tapi.bale.ai` کار می‌کند، نه کلاینت کاربری. شاید پروژه دیگری باشد. |
| `bale-chat-exporter` با نشست کلاینت | 🟡 | اسکرپر Playwright + BeautifulSoup برای Bale Web پیدا کردم (خروجی اکسل). هم‌پروژه‌بودن با آنچه گزارش می‌گوید تأییدنشده است، ولی **الگوی Playwright روی وب بله اثبات‌شده است.** |
| `Bale-Self-Bot` در GitLab چابکان | 🟡 | آن را پیدا نکردم. ولی `baleself` (PyPI، `zellias/bale-self-bot`) یک سلف‌بات مبتنی بر **Selenium** است و الگو را تأیید می‌کند. |
| MajidAPI یک پراکسی روی موتورهای عمومی است | 🟡 | endpoint را در منابع قابل جست‌وجو پیدا نکردم. «پراکسی روی موتور عمومی» استنباط گزارش است، نه چیزی که ثابت شده باشد. هرچه باشد، SLA ندارد. |
| فارس‌آوا (عامراندیش): ۱۰ هزار ساعت داده، نصب On-Premise، ساعتی ۴۵–۶۰ هزار تومان | 🟡 | قیمت و جزئیات را مستقل تأیید نکردم؛ قیمت‌ها زمان‌دارند. قبل از تصمیم، استعلام رسمی بگیرید. |
| آی‌او تایپ دقت «بالای ۹۸٪» | 🟡 | ادعای بازاریابی است. روی ویس تدارکات خودتان اندازه بگیرید. |
| `hezarai/whisper-small-fa` روی CPU زیر ۲ ثانیه | 🟡 | محتمل برای ویس کوتاه، ولی مدل small روی فارسی محاوره‌ای ضعیف است (بخش ۶). |
| `nezamisafa/whisper-v3-turbo-persian-v1.0` با WER ‏۲۹.۹٪ | 🟡 | این مدل را نتوانستم باز کنم. مدل مشابه `vhdm/whisper-v3-turbo-persian-v1.1` ‏WER ‏14.07٪ می‌دهد ولی روی **دادگان خودش**؛ اعداد WER بین دادگان‌های مختلف قابل مقایسه نیستند. |
| Faster-Whisper تا ۴ برابر سریع‌تر | ✅ (تا حد دانسته‌های من) | ادعای رسمی خود پروژه است. در مقاله PSRB هم Faster-Whisper هالوسینیشن کمتری از Whisper خام داشت. |
| «مدل‌های متن‌باز پایدارترین راهکار فنی‌اند» | ❌ | با داده PSRB سازگار نیست: ASR تجاری فارسی و Chirp 2 به‌طور محسوس دقیق‌ترند (بخش ۶.۲). متن‌باز «مستقل‌ترین» است، نه لزوماً «دقیق‌ترین». |
### ۲.۲ گزارش B: نقشه راه اجرایی
**نقاط قوت**
- دیاگرام‌های توالی و جریان تاب‌آوری خوب و قابل‌تحویل به مدیر پروژه‌اند.
- اصلاحات قبلی اعمال شده است: `compare_digest`، سقف روزانه، قفل ترتیبی، ۵۰۳ در خطا و متن خالی.
- ورودی‌های انسانی روشن‌اند و «تأیید دکمه روی وب» به‌عنوان ورودی گذاشته شده است.
- Rollback با خالی‌کردن env درست است.
**ایرادها و ریسک‌های اجرایی**
| مورد | توضیح |
|---|---|
| **کد مرجع شبه‌کد است** | `client.send_voice(...)`، `client.raw_call(service=..., method=...)` و `sent_msg.voice.to_media()` در README عمومی `bale-sdk` نیست. جدول متدهای سطح‌بالای آن ارسال رسانه ندارد. مدل دسترسی RPC خام آن این‌طور است: «namespace برابر snake_case بخش آخر نام سرویس». یعنی `bale.messaging.v2.Messaging` → `client.messaging`. پس فراخوانی احتمالی `bale.turing.v1.AI` باید چیزی شبیه `client.ai.GetTranscript(...)` باشد (**استنباط من**). |
| **آپلود ویس** | کارِ آپلود فایل و ساخت پیام صوتی (Media) را سند فرض گرفته ولی طراحی نکرده. این بخش از خود RPC ترنسکریپت سخت‌تر است. راه ساده‌تر در بخش ۷.۲ آمده. |
| **پاسخ `GetTranscript`** | طبق خود سند، پاسخ `bool mustWait` و یک `DownloadSource` دارد، نه رشته متن. یعنی احتمالاً متن باید از یک لینک/منبع دانلود خوانده شود. مثال کد این را مدیریت نمی‌کند. |
| **کلید پیش‌فرض** | `os.environ.get("BALE_STT_INTERNAL_KEY", "bale_internal_secret")` هنوز مقدار پیش‌فرض دارد؛ بدون env هم بالا می‌آید. با `os.environ[...]` خطا بدهد. |
| **شمارنده روزانه** | متغیر درون‌حافظه است: با ریست کانتینر صفر می‌شود و ریست روزانه هم ندارد (پس بعد از رسیدن به سقف تا ری‌استارت بعدی می‌ماند). |
| **`.env` نمونه** | `API_KEY=bale_internal_secret` یعنی همان مقدار پیش‌فرض ضعیف. |
| **مسیرهای محلی** | لینک‌ها به `file:///c:/Users/Reza/Desktop/...` اشاره دارند. در اشتراک سند باید پاک شوند. |
| **«بدون وقفه»** | ادعای «بدون حتی ۱ ثانیه قطعی» فقط وقتی درست است که `stt.py` در مواجهه با ۵۰۳ همان پرووایدر را بی‌خطا رد کند. این را باید در کد بات بررسی کرد، نه از روی سند. |
---
## ۳. آیا دیگران هم همین مشکل را داشته‌اند؟ و چطور حل کرده‌اند؟
### ۳.۱ نتیجه کلی
**پروژه عمومیِ مشخصی که خروجی STT خود بله را برای یک بات استخراج کند پیدا نکردم** (گزارش A هم همین را می‌گوید). آنچه وجود دارد الگوهای مجاور است:
| الگو | نمونه‌های تأییدشده | درسی که برای ما دارد |
|---|---|---|
| **A. بات رسمی + ASR مستقل** | بات‌های بله روی Webhook (مثلاً `hamedcode/bale-ai-bot` روی Cloudflare Workers)، بات‌های مشتری‌مداری با LLM | رایج‌ترین راه. بله فقط کانال پیام است؛ صوت دانلود و به موتور دیگر داده می‌شود. |
| **B. کلاینت کاربری مهندسی‌معکوس‌شده** | `aiobale` (MIT)، فورک‌های `autobale` و `BaleClient`، `bale-sdk` (اختصاصی، ۶۰۷ RPC) | نشان می‌دهد نگهداری نشست کاربری و تماس با RPCهای داخلی شدنی است، ولی پروژه‌ها تازه‌اند و بعضی رها می‌شوند. |
| **C. اتوماسیون مرورگر** | `baleself` (Selenium)، اسکرپر چت Bale Web با Playwright | روی وب بله جاافتاده است، ولی معمولاً برای کارهای ساده مثل پاسخ خودکار و بک‌آپ. |
| **D. سوءاستفاده خلاقانه از زیرساخت** | `BaleVPN` (تونل روی تماس صوتی بله) | نشان می‌دهد جامعه تا کجا از لایه‌های داخلی استفاده می‌کند؛ برای STT به کار ما نمی‌آید. |
| **E. ابزارهای مدیریتی روی Bot API** | `bale-mcp`، SDKهای PHP/Node/Python/Swift/C# | فقط Bot API؛ بدون هیچ راهی برای رسیدن به ترنسکریپت. |
### ۳.۲ برداشت از همین شواهد
1. **راه مشترک جامعه این است که STT را بیرون از بله انجام می‌دهند.** هیچ‌کس STT بله را «کرال» نکرده یا اگر کرده منتشر نشده است. پس در هر حال ریسک بالاتر و شواهد کمتری برای مسیر شماست.
2. **زیرساخت لازم برای کرالر وجود دارد** (انتقال WebSocket، OTP، نشست، ارسال و دانلود فایل)؛ پس این پروژه «ساخت از صفر» نیست.
3. **پایداری کتابخانه‌ها ضعیف است** (aiobale رها شد، `bale-sdk` نسخه 0.1.1 است). هر معماری باید فرض کند اجزای متن‌باز ممکن است از کار بیفتند.
4. **تنها چیزی که کل شرط‌بندی را توجیه می‌کند، دقت بله روی ویس‌های شماست.** بدون آزمون دستی (بخش ۸، گام ۰) نباید ساخت شروع شود.
---
## ۴. کاتالوگ کامل راهکارها
برای هر راهکار: چه کار می‌کند، مزیت، عیب، تلاش (تخمین من)، وضعیت تأیید.
### گروه ۱: استفاده از STT خود بله
| # | راهکار | توضیح | مزایا | معایب | تلاش | وضعیت |
|---|---|---|---|---|---|---|
| **S1** | **مسیر رسمی/تجاری با بله** | ایمیل یا تماس با تیم توسعه‌دهندگان/بیزینس بله برای API رسمی STT یا قرارداد | پایدار، بدون ریسک بن | احتمال موافقت کم؛ زمان‌بر | ۱ ایمیل | هیچ مستند عمومی‌ای برای وجود چنین API نیست |
| **S2** | **RPC مستقیم با `bale-sdk`** | استفاده از پروتوها و کلاینت آماده، فراخوانی `GetTranscript` | سریع‌ترین مسیر فنی؛ پروتو آماده | **مجوز اختصاصی**؛ نسخه 0.1.1؛ ادعای RPC تأییدنشده | ۱–۲ روز | 🟡 |
| **S3** | **RPC مستقیم با `aiobale` / `autobale` / `BaleClient` (MIT) + پروتوی خودساخته** | انتقال و نشست و آپلود از کتابخانه MIT؛ تعریف `GetTranscript` را از کپچر ترافیک خودتان در `web.bale.ai` بسازید | بدون مشکل مجوز؛ قابل‌نگهداری | کشف RPC و تعریف پروتو کار می‌برد؛ کتابخانه‌ها کم‌نگهداری‌اند | ۳–۵ روز | ✅ ابزارها؛ 🟡 RPC |
| **S4** | **Playwright روی `web.bale.ai`** | کرومیوم headless با پروفایل پایدار؛ ارسال ویس، کلیک دکمه، خواندن DOM | مقاوم در برابر تغییر پروتکل؛ الگوی اثبات‌شده | ۳۵۰–۵۰۰MB رم؛ شکننده در برابر تغییر UI؛ وابسته به وجود دکمه در وب | ۱–۲ روز | ✅ الگو؛ 🟡 دکمه وب |
| **S5** | **ترکیب «مرورگر برای ورود، پروتکل برای کار»** | Playwright فقط ورود/تمدید توکن (JWT) را انجام می‌دهد؛ کار اصلی را کلاینت WebSocket سبک می‌کند | از سبک‌بودن S3 و پایداری ورود S4 بهره می‌برد | پیچیدگی دو لایه | ۴–۶ روز | پیشنهاد من |
| **S6** | **شبیه‌ساز اندروید + uiautomator** | اپ بله در شبیه‌ساز، خواندن متن از رابط | مستقل از وب | رم >2GB؛ شکننده؛ سخت‌ترین نگهداری | ۵+ روز | آخرین چاره |
| **S7** | **چت هوش مصنوعی بله (Oracle)** | ارسال ویس به بات هوش مصنوعی بله و گرفتن پاسخ | بدون کشف RPC | خروجی «متن + تفسیر»؛ تأخیر ۵–۱۵ ثانیه؛ اینکه ویس را پیاده می‌کند هم تأییدنشده | ۱ روز | 🟡 |
### گروه ۲: سرویس‌های شخص ثالث
| # | راهکار | توضیح | مزایا | معایب | تلاش | وضعیت |
|---|---|---|---|---|---|---|
| **T1** | **پراکسی‌های غیررسمی (MajidAPI و مشابه)** | فراخوانی REST با لینک ویس | چند ساعت | بدون SLA؛ موتور نامشخص؛ ورودی URL (ویس باید لینک عمومی شود) | ساعتی | 🟡 |
| **T2** | **ASR تجاری فارسی** | **Avanegar/Partai** (API: `api.ivira.ai/partai/speechRecognition`)، **Aipa**، عامراندیش (فارس‌آوا)، IOType | دقت بالا روی فارسی؛ پشتیبانی؛ گاهی On-Premise | هزینه ریالی؛ قرارداد | ۱ روز (adapter) | ✅ Avanegar/Aipa در بنچمارک؛ 🟡 قیمت‌ها |
| **T3** | **ASR ابری بین‌المللی** | Google **Chirp 2**، Azure Speech، OpenAI Whisper API، Groq Whisper | Chirp 2 تقریباً هم‌سطح بهترین تجاری فارسی | هزینه ارزی؛ محدودیت دسترسی؛ بسته‌به شرایط شبکه | ۱ روز | ✅ Chirp/Azure در بنچمارک |
### گروه ۳: مدل‌های متن‌باز خودمیزبان
| # | راهکار | توضیح | مزایا | معایب | تلاش | وضعیت |
|---|---|---|---|---|---|---|
| **O1** | **Faster-Whisper large-v3 (+ `initial_prompt`/hotwords واژگان تدارکات)** | سرور OpenAI-compatible داخلی | مستقل؛ قابل‌شخصی‌سازی با واژگان؛ بهترین متن‌باز در بنچمارک | WER ≈ 34٪ روی فارسی واقعی؛ نیاز به GPU برای سرعت | ۱ روز | ✅ |
| **O2** | **Whisper large-v3-turbo فاین‌تیون فارسی** (مثل `vhdm/whisper-v3-turbo-persian-v1.1`) | مدل فاین‌تیون‌شده روی دادگان فارسی | سریع‌تر؛ احتمالاً دقیق‌تر روی گفتار تمیز | ارقام خودگزارشی و روی دادگان متفاوت؛ باید روی داده شما سنجید | ۱ روز | 🟡 |
| **O3** | **NeMo FastConformer فارسی (NVIDIA)** | `stt_fa_fastconformer_hybrid_large` | مدل مستقل از Whisper؛ دیکودر CTC | در بنچمارک WER ≈ 45٪ (ضعیف‌تر از Whisper) | ۲ روز | ✅ |
| **O4** | **Vosk فارسی (`vosk-model-fa-0.5`)** | Kaldi/DNN-HMM سبک، CPU | بسیار سبک؛ آفلاین | WER ≈ 45٪ | ساعتی | ✅ |
| **O5** | **SLPL wav2vec2 فارسی (دانشگاه شریف)** | XLS-R فاین‌تیون‌شده | مناسب برای تحقیق و ensemble | WER ≈ 47٪ | ۲ روز | ✅ |
| **O6** | **Seamless M4T v2** | مدل چندزبانه | — | احتمال بازنویسی/هالوسینیشن (Interpretation Error در مقاله PSRB) | ۲ روز | ✅ مشکل |
### گروه ۴: لایه‌های بالاتر (نه جایگزین، بلکه تقویت‌کننده)
| # | راهکار | توضیح |
|---|---|---|
| **L1** | **LLM اجماع‌گیر با کاتالوگ** | کاندیدها + فهرست کالا/برند/واحد دیتابیس تدارکات را به LLM بدهید تا خروجی ساختاریافته و تصحیح‌شده بسازد. (مقاله PSRB هم می‌گوید LLMها خطاهای جزئی ASR را خوب اصلاح می‌کنند.) |
| **L2** | **نرمال‌سازی فارسی** | یکسان‌سازی «ی/ک»، ارقام، نیم‌فاصله (ZWNJ) و اعداد حرفی. PSRB نشان می‌دهد مرزبندی کلمه و نیم‌فاصله WER را بالا می‌برد. |
| **L3** | **فاین‌تیون روی داده خودتان** | ویس‌های واقعی + متن نهایی تأییدشده توسط کاربر → LoRA روی Whisper. بلندمدت‌ترین سرمایه‌گذاری برای استقلال از بله. |
| **L4** | **مسیریابی بر اساس اعتماد** | اگر کاندیدها در اعداد/واحدها اختلاف دارند، از کاربر تأیید بگیرید (UX)، نه حدس بزنید. |
---
## ۵. اگر از پروژه‌های متن‌باز الگوبرداری کنیم: چه چیزی را می‌توان برداشت؟
| مؤلفه | منبع | مجوز | چه چیزی بردارید | ملاحظات |
|---|---|---|---|---|
| انتقال WebSocket، ورود با OTP، ذخیره نشست، Dispatcher | `aiobale` / `autobale` / `BaleClient` | MIT ✅ | همه چیز؛ حتی می‌توانید فورک کنید | `aiobale` رها شده؛ `autobale` فعال‌تر ادعا می‌شود. نسخه را قفل کنید |
| ارسال/دانلود فایل و رسانه | همان کتابخانه‌ها (گزارش A: حدود ۸۰ متد) | MIT | آپلود و ساخت پیام صوتی | اگر ایده بخش ۷.۲ را بپذیرید (بات `sendVoice` بفرستد) اصلاً لازم نیست |
| **تعریف RPC و پروتوها** | `bale-sdk` (`proto/`) | **اختصاصی ❌** | **فقط به‌عنوان مرجع و با اجازه نویسنده**؛ کپی مستقیم ممنوع | راه تمیز: از کپی خود کپچر `web.bale.ai` پروتو بسازید. ابزارها: `bbpb`/`blackboxprotobuf`، `protoscope`، `protoc --decode_raw` |
| دیکود بدون `.proto` | `blackboxprotobuf` (NCC Group؛ پکیج رسمی PyPI با نام `bbpb`) | MIT | تحلیل payloadهای ناشناخته | wire format خودتوصیف نیست؛ نام فیلد را باید خودتان حدس بزنید |
| الگوی DOM و صفحه‌خوانی بله | اسکرپر Playwright چت (GitHub) و `baleself` (Selenium) | بررسی مجوز هر مخزن | سلکتورها، انتظار برای بارگذاری، اسکرول | سلکتورها با هر آپدیت UI می‌شکنند |
| سرور OpenAI-compatible برای Whisper | Faster-Whisper، پروژه‌های سرور مبتنی بر آن | معمولاً MIT/Apache | سرور داخلی با همان `/v1/audio/transcriptions` | همان قرارداد adapter بات؛ تغییر کد لازم نیست |
| نرمال‌سازی و ارزیابی فارسی | مقاله و دادگان PSRB (`PartAI/PSRB` در Hugging Face)، `hazm` | بررسی | معیار SW-WER، فهرست خطاهای رایج فارسی | دادگان عمومی فقط زیرمجموعه است |
### قواعد پیشنهادی الگوبرداری
1. **تمیز‌کاری حقوقی:** منطق را از متن‌باز MIT بردارید و تعریف RPC را از کپچر خودتان تولید کنید (clean-room).
2. **لایه‌بندی:** adapter فقط قرارداد OpenAI را بشناسد؛ هیچ import از `bale*` بیرون از ماژول `driver/` نباشد.
3. **قفل نسخه و آینه:** نسخه دقیق کتابخانه‌ها را pin و کپی داخلی نگه دارید (رهاشدن مخزن، مثل `aiobale`، اتفاق افتاده است).
4. **تست قرارداد:** با یک «ویس طلایی» شبانه بررسی کنید پاسخ هنوز ساختار قبلی را دارد.
---
## ۶. اگر شخص ثالث بخواهیم: چه کار کنیم؟
### ۶.۱ ساده‌ترین کار: هر سرویس یک «پرووایدر» در `stt.py`
بات شما اکنون پرووایدرهای شماره‌دار با قرارداد OpenAI دارد. پس هر شخص ثالث را یا مستقیم (اگر OpenAI-compatible است) یا با یک adapter کوچک اضافه کنید. مزیت: چند ASR موازی، اجماع LLM، و حذف خودکار پرووایدر خراب (۵۰۳).
### ۶.۲ داده مستقل برای انتخاب: بنچمارک PSRB (مقاله arXiv:2505.21230)
مقاله «PSRB: A Comprehensive Benchmark for Evaluating Persian ASR Systems» (مرکز تحقیقات Part AI، May 2025) ده سیستم را روی گفتار واقعی (۵۸٪ خودانگیخته، ۷۱٪ غیررسمی، ۵۳٪ نویزی/تلفنی) سنجیده است.
| مدل | نوع | CER | WER | SW-WER |
|---|---|---|---|---|
| **Avanegar** | تجاری فارسی | **8.75** | **19.30** | **15.68** |
| **Google Chirp v2** | ابری چندزبانه | 9.05 | 19.92 | 15.99 |
| **Aipa** | تجاری فارسی | 10.43 | 24.64 | 19.83 |
| Faster-Whisper large-v3 | متن‌باز | 13.72 | 33.93 | 26.77 |
| Azure | ابری چندزبانه | 15.78 | 33.94 | 28.06 |
| Seamless M4T v2 | متن‌باز | 22.30 | 38.85 | 34.76 |
| Whisper large-v3 | متن‌باز | 18.92 | 41.49 | 36.97 |
| Vosk fa 0.5 | متن‌باز | 23.96 | 44.62 | 39.41 |
| NeMo FastConformer fa | متن‌باز | 19.42 | 44.85 | 37.58 |
| SLPL wav2vec2 | متن‌باز | 19.06 | 46.74 | 39.16 |
**نکات خواندن این جدول:**
- **ASR تجاری فارسی (و Chirp 2) تقریباً ۱۴ امتیاز WER از بهترین متن‌باز جلوتر است.** این یعنی ادعای «متن‌باز پایدارترین راهکار» در گزارش A دقیق نیست. متن‌باز مستقل‌تر است، نه دقیق‌تر.
- **سوگیری احتمالی:** نویسندگان از Part AI هستند و از آدرس API در پاورقی مقاله، Avanegar ظاهراً محصول همان شرکت است. پس عدد Avanegar را «بالاترین حد خوش‌بینانه» بدانید.
- **داده‌ها عمومی‌اند، نه واژگان تدارکات.** نام کالا، برند، واحد و اعداد در محاوره سخت‌ترین بخش‌اند و فقط آزمون روی ویس‌های خودتان تصمیم‌گیر است.
- مقاله می‌گوید خطا در **گفتار غیررسمی و لهجه‌های محلی** (مثلاً مشهدی، یزدی) بیشتر است و **Whisper/Seamless هالوسینیشن و بازنویسی** دارند. هر دو برای متن تدارکات خطرناک‌اند، چون عدد یا واحد اشتباه معنی سفارش را عوض می‌کند.
- ترکیب «مرتبه اول: دو سیستم متفاوت» معمولاً از «یک سیستم قوی» پایدارتر است. اجماع LLM هم برای همین است.
### ۶.۳ ارزیابی شخص ثالث: روش پیشنهادی (بدون وابستگی به ادعاهای فروشنده)
1. **مجموعه طلایی:** ۱۰۰ ویس واقعی (حداقل ۳۰) با رونوشت انسانی و جدول کالا/تعداد/واحد.
2. **اجرای همه کاندیدها** روی همان ویس‌ها: بله (دستی در گام ۰)، Avanegar، Aipa، Chirp 2، Faster-Whisper، مدل فاین‌تیون فارسی.
3. **سه معیار:** CER (برای فارسی مناسب‌تر از WER)، WER و مهم‌تر **F1 استخراج اقلام** (نام کالا، تعداد، واحد).
4. **انتخاب بر اساس «بهبود اجماع»،** نه دقت تکی: هر کاندید را به مجموعه فعلی اضافه کنید و ببینید خطای نهایی استخراج اقلام چقدر کم می‌شود.
5. **هزینه:** هزینه به ازای هر ویس ۱۰–۳۰ثانیه‌ای در ماه برای حجم واقعی شما.
### ۶.۴ چه سرویسی را کجا بگذاریم؟ (پیشنهاد مقدماتی)
| نقش | کاندید | چرا |
|---|---|---|
| پرووایدر ۱ (تجاری فارسی) | Avanegar یا Aipa | بهترین در بنچمارک فارسی و لهجه‌ها |
| پرووایدر ۲ (ابری) | Chirp 2 (اگر دسترسی ممکن است) | تقریباً هم‌سطح؛ مکمل متفاوت |
| پرووایدر ۳ (خودمیزبان) | Faster-Whisper large-v3 + واژگان تدارکات | مستقل؛ قابل بهبود با داده خودتان |
| پرووایدر ۴ (بله) | کرالر بله (S3/S4/S5) | تنها کاندیدی که تفاوت واقعی (آموزش‌دیده روی داده‌های متفاوت) دارد؛ ارزش آن را گام ۰ تعیین می‌کند |
| پشتیبان سبک | Vosk | فقط برای اینکه همیشه یک خروجی داشته باشیم |
---
## ۷. بهترین ترکیب برای کرالر شما
### ۷.۱ اصول طراحی
1. **کرالر بله «یک کاندید» است، نه ستون فقرات.** اگر خراب شد، سیستم باید بدون آن ادامه دهد.
2. **حداقل دو کاندید غیر بله همیشه فعال** باشند تا اجماع بدون بله هم معنی داشته باشد.
3. **تصمیم بر اساس داده خودتان**، نه ادعای مقاله و فروشنده.
4. **هر چیز بله پشت یک قرارداد ثابت (OpenAI-compatible)** باشد تا موتور داخلی عوض شود و بات دست نخورد.
5. **داده را جمع کنید** تا در بلندمدت از بله مستقل شوید (L3).
### ۷.۲ معماری پیشنهادی
```mermaid
flowchart TD
U[کاربر: ویس] --> BOT[بات تدارکات - Bot API رسمی]
BOT --> PRE[پیش‌پردازش: 16kHz mono، VAD، مدت]
PRE --> P1[پرووایدر 1: ASR تجاری فارسی]
PRE --> P2[پرووایدر 2: Chirp 2 / ابری]
PRE --> P3[پرووایدر 3: Faster-Whisper + واژگان]
PRE --> RELAY[رله ویس به حساب کرالر: sendVoice با file_id]
RELAY --> CR[کرالر بله: adapter]
CR -->|انتخاب موتور با env| E1[موتور RPC: GetTranscript]
CR -->|فالبک| E2[موتور Playwright]
CR --> DEL[حذف پیام رله]
P1 --> NORM[نرمال‌سازی فارسی: ZWNJ، ارقام، یی/کاف]
P2 --> NORM
P3 --> NORM
E1 --> NORM
E2 --> NORM
NORM --> LLM[LLM اجماع‌گیر + کاتالوگ کالا/برند/واحد]
LLM --> CONF{اختلاف در عدد/واحد؟}
CONF -- خیر --> OUT[ثبت استعلام]
CONF -- بله --> ASK[از کاربر تأیید بگیر]
ASK --> OUT
OUT --> DATA[(ذخیره ویس + متن تأییدشده برای فاین‌تیون)]
```
### ۷.۳ ایده کلیدی: «رله» به‌جای آپلود
گزارش B فرض می‌کند کرالر ویس را به **Saved Messages** خودش آپلود می‌کند. راه ساده‌تر و سالم‌تر:
1. حساب کرالر یک‌بار به بات شما `/start` می‌زند (گفتگوی خصوصی بات↔کرالر).
2. بات با Bot API رسمی `sendVoice(chat_id=<id حساب کرالر>, voice=<file_id همان ویس کاربر>)` می‌فرستد. طبق مستندات رسمی، ارسال با `file_id` بدون آپلود مجدد و بدون محدودیت است. (`file_id` مخصوص همان بات است؛ اینجا همان بات می‌فرستد، پس مشکلی نیست.)
3. حساب کرالر (کلاینت کاربری) آپدیت پیام جدید را می‌گیرد؛ **شیء Media ویس، `OutPeer` و `message_id` همه همان‌جاست.** دقیقاً سه ورودی `GetTranscript` (Media voice، OutPeer، MessageId).
4. کرالر `GetTranscript` را صدا می‌زند، متن را می‌گیرد.
5. پیام با `deleteMessage` رسمی بات (برای پیام خروجی در گفتگوی خصوصی مجاز است، تا ۴۸ ساعت) یا از سمت کرالر حذف می‌شود.
**مزایا:** بدون RPC آپلود؛ بدون ساخت `Media` از صفر؛ بدون الگوی شبه‌انسانی «آپلود فایل» روی حساب؛ ID پیام و peer واقعی‌اند.
**ریسک:** اینکه RPC روی ویس **دریافتی** هم کار کند (در اپ، دکمه روی ویس دریافتی هم هست؛ ولی تأییدش با آزمون واقعی لازم است).
### ۷.۴ لایه‌بندی موتور کرالر (به ترتیب اولویت)
| اولویت | موتور | شرط | یادداشت |
|---|---|---|---|
| ۱ | **E1: RPC مستقیم** (transport از `aiobale`/`autobale`، پروتوی خودساخته) | RPC تأیید شد | سبک (<۲۰MB)، متن خالص |
| ۲ | **E1+: ورود با مرورگر، کار با پروتکل (S5)** | ورود/تمدید JWT ناپایدار باشد | Playwright فقط برای توکن |
| ۳ | **E2: Playwright کامل** | RPC تأیید نشد ولی دکمه وب هست | رم ۳۵۰–۵۰۰MB |
| ۴ | شبیه‌ساز اندروید | هیچ‌کدام ممکن نشد | فقط در صورت ارزش بالای بله |
اگر RPC در باندل وب بله وجود دارد، **به احتمال زیاد دکمه هم در وب هست** (استنباط من: گزارش‌ها می‌گویند پروتوها از کلاینت وب بازیابی شده‌اند). ولی تا خودتان نبینید فرض نکنید.
### ۷.۵ تقویت اجماع (جایی که بیشترین دقت را می‌گیرید)
| اقدام | اثر مورد انتظار | هزینه |
|---|---|---|
| تزریق **کاتالوگ کالا/برند/واحد** به LLM اجماع‌گیر | بیشترین کاهش خطای استخراج اقلام | کم |
| `initial_prompt`/hotwords واژگان تدارکات در Faster-Whisper | بهبود نام برندها و اصطلاح‌ها | کم |
| نرمال‌سازی فارسی (ZWNJ، ارقام) پیش از اجماع | کاهش اختلاف‌های ساختگی بین کاندیدها | کم |
| قاعده «اختلاف عدد/واحد ⇒ تأیید از کاربر» | حذف سفارش اشتباه | متوسط (UX) |
| ثبت ویس + متن تأییدشده → LoRA روی Whisper | استقلال بلندمدت؛ بهبود روی لهجه و واژگان شما | بالا (ولی ارزشمندترین) |
| VAD پیش از ASR | کاهش هالوسینیشن Whisper روی سکوت/نویز (پیشنهاد مقاله PSRB) | کم |
### ۷.۶ سناریوهای تصمیم
| اگر… | آنگاه… |
|---|---|
| گام ۰: بله بهتر از مجموعه فعلی نیست | **کرالر را نسازید.** سرمایه را روی ASR تجاری + فاین‌تیون بگذارید. |
| بله کمی بهتر است (< ۱۰٪) | فقط مسیر سبک E1 را بسازید؛ Playwright و شبیه‌ساز را رها کنید. |
| بله به‌طور چشمگیر بهتر است (> ۱۵٪) | E1 + فالبک E2 + مسیر رسمی S1 را موازی دنبال کنید. |
| RPC پیدا نشد و دکمه وب نیست | S6 فقط اگر ارزش بله زیاد باشد؛ در غیر این صورت منصرف شوید. |
| بله خوب است ولی ناپایدار | از خروجی بله به‌عنوان **برچسب‌گذار** برای ساخت دادگان فاین‌تیون استفاده کنید (L3) تا وابستگی کم شود. |
---
## ۸. نقشه راه پیشنهادی
| روز | کار | خروجی | دروازه |
|---|---|---|---|
| ۰ | **آزمون دستی دقت:** ۳۰ ویس واقعی را در اپ بله به متن تبدیل کنید و با کاندیدهای فعلی مقایسه کنید | جدول CER/WER و F1 اقلام | G0: بهبود معنی‌دار یا توقف |
| ۰ | جست‌وجوی `GetTranscript` در `bale-sdk`؛ تأیید دکمه روی `web.bale.ai`؛ ایمیل به بله (S1) | یک صفحه یافته | G1: RPC یا دکمه وجود دارد |
| ۱ | کپچر ترافیک وب و ساخت پروتوی خودتان | `req.bin`/`resp.bin`، `findings.md` | G2 |
| ۲–۳ | پروتوتایپ E1 با رله بات→کرالر | یک ویس → یک متن | G3 |
| ۳–۴ | adapter سخت‌شده (۵۰۳، سقف روزانه پایدار، قفل، لاگ) | اتصال به `stt.py` | — |
| ۴–۵ | **موازی:** پرووایدرهای شخص ثالث (Avanegar/Aipa/Chirp، Faster-Whisper) | سنجش روی مجموعه طلایی | G4: بهبود اجماع |
| ۶–۱۰ | سواک ۵۰–۱۰۰ ویس + مانیتورینگ + مجموعه طلایی شبانه | گزارش پایداری | G5: استقرار |
| ماه ۲+ | جمع‌آوری داده و LoRA | مدل داخلی | کاهش وابستگی |
**KPIها:** F1 استخراج اقلام، CER، نرخ اختلاف عدد/واحد، نرخ موفقیت کرالر، p95 تأخیر، هزینه به ازای ویس.
---
## ۹. ضمیمه: دستورها و نمونه‌ها
### ۹.۱ کشف سریع (۳۰ دقیقه)
```bash
python -m venv .v && . .v/bin/activate
pip index versions aiobale-py # آیا واقعاً وجود دارد؟
pip index versions aiobale autobale baleclient bale-sdk
pip download bale-sdk --no-deps --no-binary :all:
tar xzf bale_sdk-*.tar.gz
grep -rniE "transcri|turing|speech|asr|stt" bale_sdk-*/proto bale_sdk-*/docs | head -50
```
در پایتون:
```python
from bale import ALL_RPCS
for r in ALL_RPCS:
s = str(r).lower()
if any(k in s for k in ("transcri", "turing", "speech")):
print(r)
```
### ۹.۲ ساخت پروتو از کپچر خودتان
1. `web.bale.ai` را با حساب اختصاصی باز کنید، DevTools → Network (فیلتر WS و Fetch).
2. روی ویس «تبدیل به متن» را بزنید؛ درخواست/پاسخ را ذخیره کنید.
3. پیشوند ۵ بایتی gRPC-Web را بردارید (برای POST gRPC-Web؛ در WebSocket بله قالب را باید ببینید).
4. `protoc --decode_raw < req.bin` یا `protoscope`.
### ۹.۳ اسکلت رله بات→کرالر (شبه‌کد؛ نام متدها را با کتابخانه واقعی تطبیق دهید)
```python
# سمت بات (Bot API رسمی)
async def relay_voice_to_crawler(voice_file_id: str, crawler_user_id: int) -> int:
msg = await bot_api("sendVoice", chat_id=crawler_user_id, voice=voice_file_id)
return msg["message_id"]
# سمت کرالر (کلاینت کاربری) - شبه‌کد
@client.on(NewMessage) # فقط از بات خودمان
async def on_voice(event):
media, peer, mid = event.media, event.peer, event.id
resp = await client.ai.GetTranscript(voice=media, outPeer=peer, messageId=mid) # نام‌ها = استنباط
while resp.mustWait:
await asyncio.sleep(1.5)
resp = await client.ai.GetTranscript(voice=media, outPeer=peer, messageId=mid)
text = await read_download_source(resp.downloadSource)
await client.delete_messages(peer, [mid])
return text
```
### ۹.۴ قرارداد adapter (بدون تغییر در بات)
- ورودی: `POST /v1/audio/transcriptions` (multipart: `file`, `model`, `language`).
- خروجی موفق: `{"text": "..."}`.
- هر خطا یا متن خالی: **HTTP 503** (نه ۲۰۰ با متن خالی).
- کلید داخلی: از env الزامی، بدون مقدار پیش‌فرض؛ مقایسه با `secrets.compare_digest`.
- سقف روزانه و صف ترتیبی؛ شمارنده را پایدار (Redis/فایل) و روزانه ریست‌شونده بگیرید.
---
## ۱۰. پرسش‌های باز (قبل از شروع ساخت پاسخ دهید)
1. `GetTranscript` واقعاً در `bale-sdk` وجود دارد؟ (۳۰ دقیقه، بخش ۹.۱)
2. دکمه «تبدیل به متن» روی `web.bale.ai` برای ویس **دریافتی** هست؟
3. خروجی پاسخ: متن مستقیم است یا `DownloadSource` که باید دانلود شود؟
4. `stt.py` با ۵۰۳ و متن خالی چه می‌کند؟
5. حجم واقعی ویس‌های شما در روز و طول متوسط آن‌ها چقدر است؟ (برای هزینه و سقف)
6. آیا می‌توانید ۳۰–۱۰۰ ویس واقعی با رونوشت انسانی تهیه کنید؟ (بدون آن هیچ مقایسه‌ای معتبر نیست.)
7. آیا دسترسی شبکه به Chirp 2 / ارائه‌دهندگان خارجی از سرورهای شما ممکن است؟
8. نگهداری: چه کسی مالک کرالر است اگر بله پروتکل را عوض کرد؟
---
## ۱۱. منابع
- مستندات رسمی بات بله: https://docs.bale.ai/
- `bale-sdk` (PyPI): https://pypi.org/project/bale-sdk/ و https://pypi.org/project/bale-sdk/0.1.1/
- `aiobale` (PyPI): https://pypi.org/project/aiobale ، مستندات: https://docs.aiobale.ir
- `autobale` (PyPI): https://pypi.org/project/autobale/
- `BaleClient` (PyPI): https://pypi.org/project/BaleClient/
- `baleself` (Selenium): https://pypi.org/project/baleself/
- `bale-mcp` (PyPI): https://pypi.org/project/bale-mcp/
- بات هوش مصنوعی بله روی Cloudflare Workers: https://github.com/hamedcode/bale-ai-bot
- BaleVPN: https://github.com/2077DevWave/BaleVPN
- Blackbox Protobuf (NCC Group): https://www.github.com/nccgroup/blackboxprotobuf
- PSRB (Persian ASR benchmark): https://arxiv.org/pdf/2505.21230
- مدل فارسی Whisper (نمونه): https://huggingface.co/vhdm/whisper-large-fa-v1
- خبر انتشار نسخه بله با قابلیت تبدیل ویس به متن (آنا): https://ana.ir/fa/news/994908
+3
View File
@@ -2,6 +2,9 @@
.env
*.local
# ---- Runtime data (voices etc.) — never commit, never delete ----
data/
# ---- Python ----
__pycache__/
*.pyc
+10 -4
View File
@@ -67,8 +67,8 @@ class ErpClient:
# ---------------------------------------------------------------- lookup
async def list_suppliers(self, q: str | None = None, page_size: int = 20) -> list[dict]:
params: dict = {"page_size": page_size}
async def list_suppliers(self, q: str | None = None, page_size: int = 20, page: int = 1) -> list[dict]:
params: dict = {"page_size": page_size, "page": page}
if q:
params["q"] = q
return (await self._request("GET", "/suppliers", params=params)).get("data") or []
@@ -76,8 +76,8 @@ class ErpClient:
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}
async def list_materials(self, q: str | None = None, page_size: int = 20, page: int = 1) -> list[dict]:
params: dict = {"page_size": page_size, "page": page}
if q:
params["q"] = q
return (await self._request("GET", "/materials", params=params)).get("data") or []
@@ -94,9 +94,15 @@ class ErpClient:
async def list_projects(self, page_size: int = 100) -> list[dict]:
return (await self._request("GET", "/projects", params={"page_size": page_size})).get("data") or []
async def create_project(self, payload: dict, actor_id: str | None = None) -> dict:
return (await self._request("POST", "/projects", json=payload, actor_id=actor_id)).get("data") or {}
async def list_categories(self, page_size: int = 100) -> list[dict]:
return (await self._request("GET", "/material-categories", params={"page_size": page_size})).get("data") or []
async def list_brands(self, page: int = 1, page_size: int = 100) -> list[dict]:
return (await self._request("GET", "/brands", params={"page": page, "page_size": page_size})).get("data") or []
# ---------------------------------------------------------------- quotes
async def list_quotes(self, *, status: str | None = None, q: str | None = None,
+197
View File
@@ -0,0 +1,197 @@
"""Audio preprocessing & denoising pipeline for procurement bot.
Workflow:
Raw Voice Bytes (Bale)
→ enhance_voice() [Optional Audio Enhancement / Denoising API]
- If disabled or missing URL → zero overhead pass-through
- Zero transcoding by default (raw bytes sent as-is)
- Transcoding to target format ONLY if explicitly configured
- Graceful fail-soft on timeout or error (transparent raw audio fallback)
- Enhanced bytes saved to data/voices/{YYYYMMDD}/{trace_id}_{message_id}_enhanced.{ext}
- Observability events recorded (audio.enhance.result with ok/size/latency_ms/path)
→ Multi-STT Providers
"""
import asyncio
import logging
import subprocess
import tempfile
import time
from pathlib import Path
import httpx
from app import observability as obs
from app.config import Settings
logger = logging.getLogger("bot.audio_enhancer")
_CONVERT_TIMEOUT = 60
def _transcode(raw: bytes, src_ext: str, target_fmt: str) -> bytes:
"""Convert audio to target format using ffmpeg only when explicitly requested."""
target_fmt = target_fmt.lstrip(".")
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / f"in{src_ext or '.ogg'}"
dst = Path(tmp) / f"out.{target_fmt}"
src.write_bytes(raw)
proc = subprocess.run(
["ffmpeg", "-y", "-i", str(src), "-vn", str(dst)],
capture_output=True,
timeout=_CONVERT_TIMEOUT,
)
if proc.returncode != 0 or not dst.exists():
raise RuntimeError(f"ffmpeg conversion to {target_fmt} failed: {proc.stderr[-300:]!r}")
return dst.read_bytes()
def _resolve_extension(filename: str | None, mime_type: str | None) -> str:
ext = Path(filename or "").suffix
if not ext and mime_type:
clean_mime = mime_type.split(";")[0].strip().lower()
ext = {
"audio/ogg": ".ogg",
"audio/opus": ".ogg",
"audio/mpeg": ".mp3",
"audio/mp4": ".m4a",
"audio/wav": ".wav",
"audio/x-wav": ".wav",
"audio/webm": ".webm",
"audio/flac": ".flac",
}.get(clean_mime, ".ogg")
return ext or ".ogg"
async def enhance_voice(
settings: Settings,
audio_bytes: bytes,
mime_type: str | None = None,
filename: str | None = None,
trace_id: str | None = None,
message_id: int | None = None,
) -> tuple[bytes, dict]:
"""Enhance and denoise voice audio before STT.
Returns:
(processed_audio_bytes, metadata)
Guarantees:
- Never raises an unhandled exception.
- On any failure or timeout, gracefully returns original audio_bytes (fallback).
- Enhanced bytes are saved to daily folder with _enhanced suffix when successful.
- Observability events are captured.
"""
if not settings.AUDIO_ENHANCE_ENABLED or not (settings.AUDIO_ENHANCE_BASE_URL or "").strip():
return audio_bytes, {"enhanced": False, "skipped": True}
base_url = settings.AUDIO_ENHANCE_BASE_URL.rstrip("/")
api_key = settings.AUDIO_ENHANCE_API_KEY or ""
model = settings.AUDIO_ENHANCE_MODEL or ""
timeout = float(settings.AUDIO_ENHANCE_TIMEOUT_SECONDS or 60.0)
target_format = (settings.AUDIO_ENHANCE_TARGET_FORMAT or "").strip().lower()
method = (settings.AUDIO_ENHANCE_METHOD or "auto").strip().lower()
orig_ext = _resolve_extension(filename, mime_type)
t0 = time.perf_counter()
# Condition 2: Zero transcoding unless explicitly requested by configuration
payload_bytes = audio_bytes
upload_ext = orig_ext
if target_format and not orig_ext.endswith(f".{target_format}"):
try:
payload_bytes = await asyncio.to_thread(_transcode, audio_bytes, orig_ext, target_format)
upload_ext = f".{target_format}"
except Exception as exc:
logger.warning("Target format conversion failed: %s; using raw bytes", exc)
payload_bytes = audio_bytes
upload_ext = orig_ext
headers: dict[str, str] = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try:
async with httpx.AsyncClient(timeout=timeout) as client:
upload_filename = f"voice{upload_ext}"
req_mime = mime_type or "audio/ogg"
if method == "raw_stream":
headers["Content-Type"] = req_mime
resp = await client.post(base_url, content=payload_bytes, headers=headers)
else:
# Default / multipart upload (compatible with standard voice enhancement APIs)
data = {"model": model} if model else {}
files = {"file": (upload_filename, payload_bytes, req_mime)}
resp = await client.post(base_url, files=files, data=data, headers=headers)
ms = int((time.perf_counter() - t0) * 1000)
if resp.status_code == 200 and len(resp.content) > 0:
enhanced_bytes = resp.content
# Determine response audio extension
resp_ct = (resp.headers.get("content-type") or "").split(";")[0].strip().lower()
out_ext = {
"audio/ogg": ".ogg",
"audio/opus": ".ogg",
"audio/mpeg": ".mp3",
"audio/mp4": ".m4a",
"audio/wav": ".wav",
"audio/x-wav": ".wav",
}.get(resp_ct, upload_ext)
saved_path = obs.save_enhanced_voice(
trace_id, message_id or 0, enhanced_bytes, ext=out_ext
)
await obs.event(
trace_id,
"audio.enhance.result",
ok=True,
latency_ms=ms,
model=model,
payload={
"path": saved_path,
"size": len(enhanced_bytes),
"original_size": len(audio_bytes),
"provider": model or "audio_enhancer",
},
)
logger.info(
"Audio enhancement succeeded in %d ms (size: %d -> %d bytes)",
ms,
len(audio_bytes),
len(enhanced_bytes),
)
return enhanced_bytes, {
"enhanced": True,
"path": saved_path,
"size": len(enhanced_bytes),
"latency_ms": ms,
}
# Non-200 or empty response -> graceful fallback
err_msg = f"HTTP {resp.status_code}: {resp.text[:200]}"
logger.warning("Audio enhancement failed: %s; falling back to raw audio", err_msg)
await obs.event(
trace_id,
"audio.enhance.result",
ok=False,
latency_ms=ms,
model=model,
payload={"error": err_msg, "fallback": True},
)
return audio_bytes, {"enhanced": False, "fallback": True, "error": err_msg}
except Exception as exc:
ms = int((time.perf_counter() - t0) * 1000)
err_msg = str(exc)[:300]
logger.warning("Audio enhancement exception: %s; falling back to raw audio", err_msg)
await obs.event(
trace_id,
"audio.enhance.result",
ok=False,
latency_ms=ms,
model=model,
payload={"error": err_msg, "fallback": True},
)
return audio_bytes, {"enhanced": False, "fallback": True, "error": err_msg}
+28
View File
@@ -73,6 +73,15 @@ class Settings(BaseSettings):
STT_MODEL: str = "whisper-1"
STT_LANGUAGE: str = "fa"
# Audio Enhancement / Preprocessing (denoising & speech quality enhancement)
AUDIO_ENHANCE_ENABLED: bool = False
AUDIO_ENHANCE_BASE_URL: str = ""
AUDIO_ENHANCE_API_KEY: str = ""
AUDIO_ENHANCE_MODEL: str = ""
AUDIO_ENHANCE_TIMEOUT_SECONDS: float = 60.0
AUDIO_ENHANCE_TARGET_FORMAT: str = "" # empty = pass raw bytes directly; e.g. "wav" / "mp3"
AUDIO_ENHANCE_METHOD: str = "auto" # "auto" | "raw_stream" | "multipart"
@property
def stt_base_url(self) -> str:
return self.STT_BASE_URL or self.AI_BASE_URL
@@ -133,6 +142,25 @@ class Settings(BaseSettings):
# Drafts older than this are marked expired by the sweeper loop.
DRAFT_TTL_HOURS: int = 24
# Observability (phase 1) — silent pipeline recorder (DB only; no external
# tools, no extra LLM tokens). Data is NEVER deleted.
OBS_ENABLED: bool = True
OBS_VOICE_DIR: str = "data/voices" # persistent volume (./bot/data) — keep forever
OBS_PROMPT_VERSION: str = "" # manual label for the NLU prompt in use
# Jev (TypeSafe) entity matching — phases 2-5. Model version is PINNED
# (phase 0 resolved jev-1.13.0) so upstream updates cannot silently change
# decisions; every response's actual model is recorded for drift detection.
TYPESAFE_API_KEY: str = ""
JEV_MODEL: str = "jev-1.13.0"
JEV_ENABLED: bool = True
JEV_TIMEOUT: float = 8.0
JEV_CONF_EXACT: float = 0.85
JEV_CONF_FLOOR: float = 0.65 # phase-6 calibration (2026-10-12): 0.60 let one
# wrong candidate (جعبه→بسته @0.61) through; the
# correct picks it drops are covered by difflib.
JEV_MAX_QUESTIONS_PER_FIELD: int = 15
LOG_LEVEL: str = "INFO"
@property
+34
View File
@@ -64,6 +64,40 @@ DDL = [
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
# ---- Observability (phase 1) — silent pipeline recorder, DB-only -------
"""
CREATE TABLE IF NOT EXISTS bot_traces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id BIGINT NOT NULL,
message_id BIGINT,
kind VARCHAR(8) NOT NULL,
source VARCHAR(16),
draft_id UUID,
draft_kind VARCHAR(16),
committed_quote_id UUID,
status VARCHAR(16) NOT NULL DEFAULT 'open',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"CREATE INDEX IF NOT EXISTS ix_bot_traces_chat ON bot_traces (chat_id, created_at)",
"CREATE INDEX IF NOT EXISTS ix_bot_traces_draft ON bot_traces (draft_id)",
"""
CREATE TABLE IF NOT EXISTS bot_trace_events (
id BIGSERIAL PRIMARY KEY,
trace_id UUID NOT NULL REFERENCES bot_traces(id) ON DELETE CASCADE,
stage VARCHAR(32) NOT NULL,
ok BOOLEAN NOT NULL DEFAULT TRUE,
latency_ms INTEGER,
model VARCHAR(64),
field VARCHAR(32),
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""",
"CREATE INDEX IF NOT EXISTS ix_bot_trace_events_trace ON bot_trace_events (trace_id, created_at)",
"CREATE INDEX IF NOT EXISTS ix_bot_trace_events_stage ON bot_trace_events (stage)",
"CREATE INDEX IF NOT EXISTS ix_bot_trace_events_payload ON bot_trace_events USING GIN (payload jsonb_path_ops)",
]
+273
View File
@@ -0,0 +1,273 @@
"""Centralized market conventions & domain defaults for procurement bot.
This module isolates all procurement business heuristics and defaults (equal
installments calculation, end-of-solar-month due dates, validity rules per
material, project assignment logic, official vs unofficial defaults).
drafts.py and nlu.py consume this module directly.
"""
from datetime import date, datetime, timedelta, timezone
# ---------------------------------------------------------------------------
# Jalali Calendar math (Pure Python, zero-dependency)
# ---------------------------------------------------------------------------
JALALI_MONTH_NAMES = [
"", "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند"
]
def g2j(gy: int, gm: int, gd: int) -> tuple[int, int, int]:
g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
gy2 = gy if gm > 2 else gy - 1
days = 355666 + (365 * gy) + ((gy2 + 3) // 4) - ((gy2 + 99) // 100) + ((gy2 + 399) // 400) + gd + g_d_m[gm - 1]
jy = -1595 + (33 * (days // 12053))
days %= 12053
jy += 4 * (days // 1461)
days %= 1461
if days > 365:
jy += (days - 1) // 365
days = (days - 1) % 365
if days < 186:
jm = 1 + (days // 31)
jd = 1 + (days % 31)
else:
jm = 7 + ((days - 186) // 30)
jd = 1 + ((days - 186) % 30)
return jy, jm, jd
def j2g(jy: int, jm: int, jd: int) -> tuple[int, int, int]:
jy2 = jy + 1595
days = -355668 + (365 * jy2) + ((jy2 // 33) * 8) + (((jy2 % 33) + 3) // 4) + jd
if jm < 7:
days += (jm - 1) * 31
else:
days += ((jm - 7) * 30) + 186
gy = 400 * (days // 146097)
days %= 146097
if days > 36524:
days -= 1
gy += 100 * (days // 36524)
days %= 36524
if days >= 365:
days += 1
gy += 4 * (days // 1461)
days %= 1461
if days > 365:
gy += (days - 1) // 365
days = (days - 1) % 365
gd = days + 1
sal_a = [0, 31, 29 if ((gy % 4 == 0 and gy % 100 != 0) or gy % 400 == 0) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
gm = 0
while gm < 13 and gd > sal_a[gm]:
gd -= sal_a[gm]
gm += 1
return gy, gm, gd
def is_jalali_leap(jy: int) -> bool:
return (((jy + 38) * 31) % 128) <= 31
def jalali_month_days(jy: int, jm: int) -> int:
if 1 <= jm <= 6:
return 31
elif 7 <= jm <= 11:
return 30
elif jm == 12:
return 30 if is_jalali_leap(jy) else 29
return 30
def today_jalali(ref_date: date | None = None) -> tuple[int, int, int]:
d = ref_date or date.today()
return g2j(d.year, d.month, d.day)
def jalali_to_iso(jy: int, jm: int, jd: int) -> str:
gy, gm, gd = j2g(jy, jm, jd)
return f"{gy:04d}-{gm:02d}-{gd:02d}"
def jalali_format(jy: int, jm: int, jd: int) -> str:
return f"{jy:04d}/{jm:02d}/{jd:02d}"
def jalali_month_name(jm: int) -> str:
return JALALI_MONTH_NAMES[jm] if 1 <= jm <= 12 else ""
# ---------------------------------------------------------------------------
# Market Conventions & Constants
# ---------------------------------------------------------------------------
DEFAULT_IS_OFFICIAL = True
# Standard validity days in volatile markets (materials)
DEFAULT_VALIDITY_DAYS_MAP: dict[str, int] = {
"آهن": 1,
"میلگرد": 1,
"تیرآهن": 1,
"ورق": 1,
"پروفیل": 2,
"سیمان": 3,
"گچ": 7,
"لوله": 7,
"اتصالات": 7,
"شیرآلات": 7,
"شن": 7,
"ماسه": 7,
}
FALLBACK_VALIDITY_DAYS: int = 7
def compute_validity_date(materials: list[str] | None = None, explicit_days: int | None = None,
ref_date: date | None = None) -> tuple[str, str]:
"""Compute (iso_date, jalali_date_str) based on material or explicit days."""
base = ref_date or date.today()
if explicit_days is not None and explicit_days > 0:
days = explicit_days
else:
days = FALLBACK_VALIDITY_DAYS
if materials:
for m in materials:
m_str = (m or "").lower()
for k, v in DEFAULT_VALIDITY_DAYS_MAP.items():
if k in m_str:
days = min(days, v)
target = base + timedelta(days=days)
jy, jm, jd = g2j(target.year, target.month, target.day)
return f"{target.year:04d}-{target.month:02d}-{target.day:02d}", jalali_format(jy, jm, jd)
# ---------------------------------------------------------------------------
# Payment Terms Generators (Heuristics)
# ---------------------------------------------------------------------------
def build_equal_installments(
total_amount: float,
count: int,
ref_date: date | None = None,
) -> list[dict]:
"""Build equal installments milestones.
- 1st milestone: today (or ref_date)
- 2nd..nth milestones: end of subsequent Jalali months
Amounts: total_amount / count, rounded to thousands, with remainder added to last milestone.
"""
if count < 1:
count = 1
base = ref_date or date.today()
curr_jy, curr_jm, curr_jd = g2j(base.year, base.month, base.day)
base_per_installment = int(total_amount // count) if total_amount > 0 else 0
remainder = int(total_amount - (base_per_installment * count)) if total_amount > 0 else 0
milestones: list[dict] = []
# Milestone 1: today
m1_iso = f"{base.year:04d}-{base.month:02d}-{base.day:02d}"
m1_jalali = f"{curr_jy}/{curr_jm:02d}/{curr_jd:02d}"
milestones.append({
"due_date": m1_iso,
"due_date_jalali": m1_jalali,
"amount": base_per_installment,
"note": f"قسط اول (امروز - {curr_jd} {jalali_month_name(curr_jm)})",
})
# Milestone 2..N: end of consecutive Jalali months
target_jy = curr_jy
target_jm = curr_jm
for i in range(2, count + 1):
target_jm += 1
if target_jm > 12:
target_jm = 1
target_jy += 1
end_day = jalali_month_days(target_jy, target_jm)
iso_str = jalali_to_iso(target_jy, target_jm, end_day)
jalali_str = f"{target_jy}/{target_jm:02d}/{end_day:02d}"
amt = base_per_installment + (remainder if i == count else 0)
m_name = jalali_month_name(target_jm)
milestones.append({
"due_date": iso_str,
"due_date_jalali": jalali_str,
"amount": amt,
"note": f"قسط {i} (پایان {m_name})",
})
return milestones
def build_cash_today_termsheet(
total_amount: float,
discount_percent: float = 0.0,
ref_date: date | None = None,
) -> dict:
"""Build cash today payment termsheet."""
base = ref_date or date.today()
curr_jy, curr_jm, curr_jd = g2j(base.year, base.month, base.day)
iso_date = f"{base.year:04d}-{base.month:02d}-{base.day:02d}"
jalali_str = f"{curr_jy}/{curr_jm:02d}/{curr_jd:02d}"
discount_val = 0.0
payable = total_amount
if discount_percent > 0 and total_amount > 0:
discount_val = round((total_amount * discount_percent) / 100.0)
payable = total_amount - discount_val
disc_label = f" (تخفیف {int(discount_percent)}٪)" if discount_percent > 0 else ""
return {
"label": f"پرداخت یکباره امروز{disc_label}",
"discount_type": "percent" if discount_percent > 0 else "none",
"discount_value": discount_percent if discount_percent > 0 else 0.0,
"milestones": [
{
"due_date": iso_date,
"due_date_jalali": jalali_str,
"amount": payable,
"note": "تسویه نقدی یکباره (امروز)",
}
],
"description": f"مبلغ فاکتور: {int(total_amount):,} | تخفیف: {int(discount_val):,} | خالص پرداختی: {int(payable):,}",
}
def propose_project_distribution(project_names: list[str], items: list[dict]) -> list[tuple[str, str]]:
"""Propose assignment of items to projects when multiple projects exist.
Returns list of (item_material_name, assigned_project_name).
"""
clean_projects = [p.strip() for p in project_names if p and p.strip()]
if not clean_projects or not items:
return []
proposals: list[tuple[str, str]] = []
p_len = len(clean_projects)
for idx, it in enumerate(items):
assigned_p = clean_projects[idx % p_len]
mat_name = it.get("material_name") or f"قلم {idx + 1}"
proposals.append((mat_name, assigned_p))
return proposals
# ---------------------------------------------------------------------------
# Authorized Whitelist Users (Static Role Configuration)
# ---------------------------------------------------------------------------
AUTHORIZED_USERS: dict[int, dict] = {
1461166742: {
"user_id": "03091956-99ad-4729-923a-a6aa1c5c13be",
"display_name": "مدیر تدارکات",
"role": "procurement_manager",
"is_admin": True,
},
1839839922: {
"user_id": "03091956-99ad-4729-923a-a6aa1c5c13be",
"display_name": "همکار / تست",
"role": "procurement_manager",
"is_admin": False,
},
}
+170 -24
View File
@@ -16,6 +16,7 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import cast, func, select, String, update
from app import defaults
from app.db import SessionLocal
from app.models import BotDraft
from app.nlu import normalize_text
@@ -23,6 +24,7 @@ from app.nlu import normalize_text
KIND_QUOTE = "quote"
KIND_SUPPLIER = "supplier"
KIND_MATERIAL = "material"
KIND_PROJECT = "project"
# ---------------------------------------------------------------------------
@@ -70,6 +72,22 @@ async def get_by_message_id(chat_id: int, message_id: int) -> BotDraft | None:
return (await session.execute(stmt)).scalar_one_or_none()
async def get_by_ambiguity_message_id(chat_id: int, message_id: int) -> tuple[BotDraft | None, dict | None]:
"""Find the active draft and specific ambiguity dict associated with message_id."""
async with SessionLocal() as session:
stmt = (
select(BotDraft)
.where(BotDraft.chat_id == chat_id, BotDraft.status == "active")
.order_by(BotDraft.updated_at.desc())
)
drafts = (await session.execute(stmt)).scalars().all()
for d in drafts:
for amb in (d.payload or {}).get("ambiguities") or []:
if amb.get("message_id") == message_id:
return d, amb
return None, 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:
@@ -154,9 +172,10 @@ 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,
"is_official": defaults.DEFAULT_IS_OFFICIAL, "notes": None, "expiry_date": None,
"items": [], "termsheets": [],
"options": {}, "proforma_file": None,
"ambiguities": [],
}
@@ -206,11 +225,15 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
if not payload.get("project_names"):
payload["project_names"] = [payload["project_name"]]
changed = True
set_field("is_official", patch.get("is_official"))
set_field("is_official", patch.get("is_official", defaults.DEFAULT_IS_OFFICIAL))
if patch.get("notes"):
payload["notes"] = f"{payload.get('notes') or ''}\n{patch['notes']}".strip()
changed = True
if patch.get("expiry_date"):
if patch.get("expiry_days"):
m_names = [it.get("material_name") for it in payload.get("items") or [] if it.get("material_name")]
iso_val, _ = defaults.compute_validity_date(materials=m_names, explicit_days=int(patch["expiry_days"]))
set_field("expiry_date", iso_val)
elif patch.get("expiry_date"):
set_field("expiry_date", patch["expiry_date"])
for raw in patch.get("items") or []:
@@ -219,10 +242,8 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
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"))
@@ -249,34 +270,130 @@ def merge_quote_patch(payload: dict, patch: dict) -> bool:
if spec_str and not target.get("notes"):
target["notes"] = spec_str
changed = True
for key in ("unit_name", "brand_name", "notes"):
for key in ("unit_name", "brand_name", "project_name", "notes"):
if raw.get(key):
val = str(raw[key]).strip()
if target.get(key) != val:
target[key] = val
changed = True
# Calculate total amount of items
total_amount = sum(
(float(it.get("quantity") or 0) * float(it.get("unit_price") or 0))
for it in payload.get("items") or []
)
for raw in patch.get("payment_terms") or []:
p_type = raw.get("type") 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)
# 1. Cash today with discount
if p_type == "cash_today" or (raw.get("is_today") and not raw.get("installments_count")):
disc_pct = float(raw.get("discount_percent") or raw.get("discount_value") or 0.0)
entry = defaults.build_cash_today_termsheet(total_amount, discount_percent=disc_pct)
if label and "یکباره" not in label and "نقد" not in label:
entry["label"] = label
existing = [t for t in payload["termsheets"] if "نقدی" in t.get("label", "") or "امروز" in t.get("label", "")]
if existing:
existing[0].update(entry)
else:
payload["termsheets"].append(entry)
changed = True
# 2. Equal installments -> Generate Ambiguity sub-message and record termsheet
elif p_type == "installments" or raw.get("installments_count"):
count = int(raw.get("installments_count") or 3)
milestones = defaults.build_equal_installments(total_amount, count=count)
lines = [f"• {m['note']}: {int(m['amount']):,} تومان" for m in milestones]
amb_id = f"terms_{count}_split"
if not any(normalize_text(t["label"]) == normalize_text(label) for t in payload["termsheets"]):
payload["termsheets"].append({
"label": label or f"{count} قسط در طول زمان",
"discount_type": "none",
"discount_value": 0,
"milestones": [],
"description": None,
})
ambs = payload.setdefault("ambiguities", [])
existing_amb = next((a for a in ambs if a.get("id") == amb_id), None)
proposed = {
"payment_terms": [{
"label": label or f"{count} قسط در طول زمان",
"milestones": milestones,
"discount_type": "none",
"discount_value": 0,
}]
}
if existing_amb:
existing_amb["details"] = "\n".join(lines)
existing_amb["proposed_patch"] = proposed
else:
ambs.append({
"id": amb_id,
"kind": "payment_schedule",
"title": f"زمان‌بندی {count} قسط پرداخت",
"details": "\n".join(lines),
"status": "pending",
"proposed_patch": proposed,
})
changed = True
# 3. Explicit milestones from user or general label
else:
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,
}
existing = [t for t in payload["termsheets"] if normalize_text(t.get("label", "")) == normalize_text(label)]
if existing:
if milestones:
existing[0]["milestones"] = milestones
else:
payload["termsheets"].append(entry)
changed = True
# 4. Multi-project ambiguity generation
proj_names = payload.get("project_names") or []
items = payload.get("items") or []
if len(proj_names) > 1 and len(items) > 1:
unassigned = any(not it.get("project_name") for it in items)
if unassigned:
proposals = defaults.propose_project_distribution(proj_names, items)
if proposals:
lines = [f"• {mat} 👈 پروژه {prj}" for mat, prj in proposals]
amb_id = "proj_split"
ambs = payload.setdefault("ambiguities", [])
existing_amb = next((a for a in ambs if a.get("id") == amb_id), None)
proposed = {
"items": [{"material_name": mat, "project_name": prj} for mat, prj in proposals]
}
if existing_amb:
existing_amb["details"] = "\n".join(lines)
existing_amb["proposed_patch"] = proposed
else:
ambs.append({
"id": amb_id,
"kind": "project_assignment",
"title": f"تخصیص اقلام به پروژه‌های {' و '.join(proj_names)}",
"details": "\n".join(lines),
"status": "pending",
"proposed_patch": proposed,
})
changed = True
return changed
@@ -292,7 +409,13 @@ def compute_quote_missing(payload: dict) -> list[dict]:
add("supplier", "تأمین‌کننده", sup_ok, True)
# In DB: project_id is nullable (optional)
add("project", "پروژه", bool(payload.get("project_id") or payload.get("project_ids")), False)
has_proj = bool(
payload.get("project_id")
or payload.get("project_ids")
or payload.get("project_name")
or payload.get("project_names")
)
add("project", "پروژه", has_proj, False)
# In DB: expiry_date is nullable (optional)
add("expiry", "تاریخ اعتبار", bool(payload.get("expiry_date")), False)
@@ -478,5 +601,28 @@ def material_missing(payload: dict) -> list[dict]:
]
def new_project_payload() -> dict:
return {"name": None, "description": None, "options": {}}
def merge_project_patch(payload: dict, patch: dict) -> bool:
src = patch.get("project") 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("description") and payload.get("description") != str(src["description"]).strip():
payload["description"] = str(src["description"]).strip()
changed = True
return changed
def project_missing(payload: dict) -> list[dict]:
return [
{"key": "name", "label": "نام پروژه", "ok": bool(payload.get("name")), "blocking": True},
{"key": "description", "label": "توضیحات پروژه", "ok": bool(payload.get("description")), "blocking": False},
]
def short_id() -> str:
return uuid.uuid4().hex[:8]
+552
View File
@@ -0,0 +1,552 @@
"""Dedicated DeepSeek fallback matching engine for crisis mode.
Invoked when Jev (TypeSafe) fails, times out, returns 5xx/429, or is unavailable.
Takes:
1. raw_input: Raw user text or list of STT transcriptions.
2. mentions: Entity mentions extracted by DeepSeek NLU.
3. candidates_by_field: Catalog candidate records fetched from the database.
4. patch_context: Current draft patch / context.
5. trace_id: Observability trace ID.
Outputs:
Standard resolution map compatible with jev_map:
{
"<normalized_variant>": {
"field": str,
"status": "exact" | "ambiguity" | "new",
"variants": list[str],
"id": str | None,
"name": str | None,
"extra": dict,
"candidates": list[dict],
"inferred_category_slug"?: str,
}
}
Defensive Guarantee:
If DeepSeek call fails (HTTP error, timeout, or unparseable JSON),
raises MatchingFailureError so the bot halts gracefully with:
"⚠️ سامانه در پردازش هوشمند موجودیت‌ها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید."
"""
import hashlib
import json
import logging
import re
import time
import httpx
from app import observability as obs
from app.config import settings
from app.nlu import normalize_text
logger = logging.getLogger("bot.fallback_matcher")
class MatchingFailureError(Exception):
"""Raised when matching cannot proceed due to LLM / network failure."""
pass
ARBITRATION_SYSTEM_PROMPT = """تو قاضی و داور نهایی هوشمند تطبیق موجودیت‌های تدارکاتی و ساختمانی در یک سیستم ERP هستی.
یک مدل جستجوی برداری اولیه (به نام Jev) برای هر منشن کاربر، کاتالوگ دیتابیس را بررسی کرده و گزینه‌ای را پیشنهاد داده است.
وظیفه خطیر تو:
تحلیل هم‌زمان ۱) پیام اولیه کاربر، ۲) کاندیداهای کاتالوگ دیتابیس، و ۳) پیشنهاد مدل Jev، و صدور «حکم قطعی نهایی».
یک شیء JSON و فقط JSON برگردان با ساختار زیر:
{
"decisions": [
{
"field": "supplier|material|project|unit|brand|spec",
"variant": "نگارش منشن",
"status": "exact|ambiguity|new",
"id": "شناسه رکورد کاتالوگ یا null",
"name": "نام رسمی رکورد کاتالوگ یا null",
"candidates": [
{"id": "شناسه", "name": "نام", "prob": 0.0..1.0}
],
"inferred_category_slug": "یکی از ۸ اسلاگ مجاز فقط برای تامین‌کننده new یا null",
"adjudication_action": "endorsed_jev|vetoed_to_new|demoted_to_ambiguity",
"reasoning": "توضیح کوتاه فنی دلیل قضاوت"
}
]
}
قوانین حیاتی داوری:
۱. عدم اعتماد کورکورانه به Jev:
- مدل Jev صرفاً یک مدل آماری/برداری است و تفاوت سایزها، رده‌ها، ابعاد فنی و استانداردهای صنعتی (مانند لوله ۶ اینچ در برابر لوله ۴ اینچ، میلگرد ۱۶ در برابر ۲۰، یا تفاوت دو برند متفاوت) را تشخیص نمی‌دهد.
- تو باید کلام کاربر را با پیشنهاد Jev مقایسه کنی.
۲. حالات صدور حکم:
الف) تأیید انتخاب Jev (status: "exact"):
اگر پیشنهاد Jev دقیقاً و با احتساب نام تجاری و مشخصات فنی با خواسته کاربر در پیام مطابقت دارد.
- فیلد id و name را با مقادیر دقیق رکورد کاتالوگ پر کن. candidates خالی باشد ([]).
ب) وتوی Jev و اعلام قلم جدید (status: "new"):
اگر کالا، تأمین‌کننده یا پروژه مورد نظر کاربر در کاتالوگ ارسالی وجود ندارد (حتی اگر Jev به اشتباه به دلیل شباهت حروفی گزینه‌ای را با اطمینان بالا پیشنهاد داده باشد).
- پیشنهاد Jev را بلافاصله باطل و وتو کن!
- وضعیت را "new" بگذار و id حتماً null باشد. هرگز قلم ناموجود را به زور به کاتالوگ نچسبان.
- برای تأمین‌کننده جدید (supplier)، مناسب‌ترین اسلاگ را از بین ۸ دسته مجاز در inferred_category_slug قرار بده.
ج) تنزل به ابهام (status: "ambiguity"):
اگر کاربر عبارت کلی گفته (مثلاً «میلگرد») و در کاتالوگ چند گزینه محتمل وجود دارد، یا بین انتخاب Jev و سایر کاندیداها تردید فنی وجود دارد.
- وضعیت را "ambiguity" بگذار و گزینه‌های منتخب را در candidates بگذار.
۳. تحلیل ورودی‌های صوتی (Voice Mode):
اگر ورودی صوتی است (input_mode: "voice")، آرایه کامل ترانویسی موتورهای STT (مانند Whisper، Vosk و غیره) به همراه consensus_text در اختیارت است.
شواهد صوتی، تلفظ‌ها، خطاهای شنیداری و لهجه‌های کارگاهی را ملاک قضاوت قرار بده.
۴. اسلاگ‌های مجاز دسته‌بندی برای تأمین‌کننده جدید:
* metals-steel-sections
* mechanical-installations-pipes
* electrical-equipment-supplies
* construction-materials
* construction-chemicals
* doors-windows-glass
* finishing-interior-decoration
* construction-tools-equipment
"""
FALLBACK_SYSTEM_PROMPT = """تو موتور تطبیق و پالایش هوشمند موجودیت‌های تدارکاتی یک سیستم ساختمانی و صنعتی ERP در ایران هستی.
سرویس جانبی تطبیق کاتالوگ در دسترس نیست و اکنون تو وظیفه داری با درک مهندسی، تجاری و معنایی خود، عبارات ذکرشده توسط کاربر را مستقیماً با رکوردهای کاتالوگ دیتابیس تطبیق دهی.
یک شیء JSON و فقط JSON برگردان (بدون هرگونه متن یا مارک‌داون اضافه) با ساختار زیر:
{
"results": [
{
"field": "supplier|material|project|unit|brand|spec",
"variants": ["تمام نگارش‌های این موجودیت"],
"status": "exact|ambiguity|new",
"id": "شناسه رکورد کاتالوگ منطبق یا null",
"name": "نام رسمی رکورد کاتالوگ منطبق یا null",
"candidates": [
{"id": "شناسه رکورد کاندیدا", "name": "نام رکورد کاندیدا", "prob": 0.0..1.0}
],
"inferred_category_slug": "فقط برای supplier جدید یکی از ۸ اسلاگ مجاز یا null"
}
]
}
قوانین حیاتی تطبیق:
۱. تطبیق قطعی (status: "exact"):
- اگر واریانت کاربر با یکی از رکوردهای کاتالوگ انطباق معنایی و تجاری روشن دارد (با احتساب خطاهای گفتاری STT، لهجه اصفهانی، املای ارقام فارسی/انگلیسی، پیشوند و پسوندهای کارگاهی مانند «آهن»، «بازرگانی»، «لوله»، «میلگرد»)، آن را انتخاب کن.
- فیلد id و name را با مقادیر دقیق موجود در کاتالوگ پر کن.
- فیلد candidates را خالی بگذار ([]).
۲. ابهام تجاری/ابعادی (status: "ambiguity"):
- اگر کاربر عنوان عامی را به کار برده که به چند رکورد مشخص در کاتالوگ قابل تفکیک است (مثلاً کاربر گفته «میلگرد» ولی در کاتالوگ انواع سایزهای ۱۴، ۱۶، ۱۸ داریم؛ یا کاربر نام خانوادگی تأمین‌کننده را گفته و چند تأمین‌کننده مشابه با این نام وجود دارند)، حتماً وضعیت را "ambiguity" بگذار.
- رکوردهای کاندیدای محتمل را در لیست candidates با نام، شناسه و تخمین احتمال (prob) قرار بده (حداکثر ۶ مورد).
۳. موجودیت جدید (status: "new"):
- قانون طلایی: اگر کالا، تأمین‌کننده یا پروژه در کاتالوگ ارسالی وجود ندارد، وضعیت را حتماً "new" بگذار!
- به هیچ عنوان و تحت هیچ شرایطی یک قلم جدید را به زور شباهت کلمات یا کاراکتر به یک قلم نامربوط در کاتالوگ نچسبان (مثلاً لوله ۶ اینچ را به لوله ۴ اینچ یا میلگرد ۲۰ را به میلگرد ۱۶ نچسبان).
- در حالت new، فیلد id باید null باشد.
۴. استنتاج خودکار دسته‌بندی برای تأمین‌کننده جدید (inferred_category_slug):
- اگر تأمین‌کننده‌ای وضعیتش new است، با توجه به اقلام استعلام‌شده در پیام کاربر، مناسب‌ترین اسلاگ دسته‌بندی را از بین این ۸ مورد انتخاب کن:
* metals-steel-sections (فلزات و مقاطع فولادی)
* mechanical-installations-pipes (تأسیسات مکانیکی و آبی)
* electrical-equipment-supplies (تجهیزات و لوازم برقی)
* construction-materials (مصالح ساختمانی)
* construction-chemicals (مواد شیمیایی ساختمانی)
* doors-windows-glass (درب، پنجره و شیشه)
* finishing-interior-decoration (نازک‌کاری و دکوراسیون داخلی)
* construction-tools-equipment (ابزار و تجهیزات کارگاهی)
"""
def _extract_json(raw: str) -> dict | None:
if not raw or not raw.strip():
return None
text = raw.strip()
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
return None
try:
return json.loads(match.group(0))
except Exception:
return None
async def match_with_deepseek(
raw_input: object,
mentions: list[dict],
candidates_by_field: dict[str, list],
patch_context: dict | None,
trace_id: str | None,
consensus_text: str | None = None,
) -> dict[str, dict]:
"""Execute DeepSeek crisis matching for unmapped mentions.
Returns a dict keyed by normalized variant, ready to merge into jev_map.
Raises MatchingFailureError if DeepSeek fails or returns invalid output.
"""
if not mentions:
return {}
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
logger.error("fallback_matcher: AI provider not configured (AI_BASE_URL/AI_API_KEY missing)")
raise MatchingFailureError("AI provider not configured")
# Serialize candidates to clean dicts for prompt efficiency
catalog_summary: dict[str, list[dict]] = {}
for fld, cands in (candidates_by_field or {}).items():
field_cands = []
for c in cands:
item = {"id": str(getattr(c, "id", "")), "name": str(getattr(c, "name", ""))}
extra = getattr(c, "extra", {}) or {}
for k in ("category_name", "location", "canonical_unit_id", "code"):
if extra.get(k):
item[k] = extra[k]
field_cands.append(item)
catalog_summary[fld] = field_cands[:80] # token-safe ceiling per field
if isinstance(raw_input, list):
reference_input = {
"input_mode": "voice",
"stt_multi_transcripts": raw_input,
"consensus_text": consensus_text or "",
}
else:
reference_input = {
"input_mode": "text",
"raw_text": str(raw_input or ""),
}
payload_data = {
"reference_user_input": reference_input,
"entity_mentions": mentions,
"catalog_candidates": catalog_summary,
"patch_context": patch_context or {},
}
user_content = json.dumps(payload_data, ensure_ascii=False)
t0 = time.perf_counter()
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": FALLBACK_SYSTEM_PROMPT},
{"role": "user", "content": user_content},
],
"temperature": 0.1,
"response_format": {"type": "json_object"},
},
)
latency_ms = int((time.perf_counter() - t0) * 1000)
if resp.status_code != 200:
logger.error("fallback_matcher: HTTP %s from DeepSeek: %s", resp.status_code, resp.text[:300])
await obs.event(trace_id, "matching.fallback.failure", ok=False, payload={
"status_code": resp.status_code,
"error": "http_error",
"body": resp.text[:300],
})
raise MatchingFailureError(f"DeepSeek returned HTTP {resp.status_code}")
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "")
usage = (resp.json() or {}).get("usage") or {}
parsed = _extract_json(content)
if not parsed or not isinstance(parsed.get("results"), list):
logger.error("fallback_matcher: unparseable JSON from DeepSeek: %r", content[:300])
await obs.event(trace_id, "matching.fallback.failure", ok=False, payload={
"error": "unparseable_json",
"raw": content[:500],
})
raise MatchingFailureError("DeepSeek returned unparseable JSON")
except httpx.HTTPError as exc:
latency_ms = int((time.perf_counter() - t0) * 1000)
logger.exception("fallback_matcher: HTTP network error calling DeepSeek")
await obs.event(trace_id, "matching.fallback.failure", ok=False, payload={
"error": "network_exception",
"details": str(exc),
"latency_ms": latency_ms,
})
raise MatchingFailureError("Network exception calling DeepSeek") from exc
except Exception as exc:
if isinstance(exc, MatchingFailureError):
raise
latency_ms = int((time.perf_counter() - t0) * 1000)
logger.exception("fallback_matcher: Unexpected error")
await obs.event(trace_id, "matching.fallback.failure", ok=False, payload={
"error": "unexpected_exception",
"details": str(exc),
"latency_ms": latency_ms,
})
raise MatchingFailureError("Unexpected error calling DeepSeek") from exc
results_map: dict[str, dict] = {}
statuses_count = {"exact": 0, "ambiguity": 0, "new": 0}
# Build candidate lookup dict for candidate extras
cand_by_field_id: dict[tuple[str, str], object] = {}
for fld, c_list in (candidates_by_field or {}).items():
for c in c_list:
cid = str(getattr(c, "id", ""))
cand_by_field_id[(fld, cid)] = c
for item in parsed.get("results", []):
if not isinstance(item, dict):
continue
field = str(item.get("field") or "").strip()
variants = [str(v).strip() for v in (item.get("variants") or []) if str(v or "").strip()]
status = str(item.get("status") or "").strip().lower()
if status not in ("exact", "ambiguity", "new"):
status = "new"
statuses_count[status] = statuses_count.get(status, 0) + 1
cand_id = str(item.get("id")) if item.get("id") else None
cand_name = str(item.get("name")) if item.get("name") else None
cand_obj = cand_by_field_id.get((field, cand_id or "")) if cand_id else None
extra = getattr(cand_obj, "extra", {}) or {}
# Format candidates for ambiguity
amb_candidates = []
for c in item.get("candidates") or []:
if isinstance(c, dict) and c.get("id") and c.get("name"):
amb_candidates.append({
"id": str(c["id"]),
"name": str(c["name"]),
"prob": float(c.get("prob") or 0.5),
})
entry = {
"field": field,
"status": status,
"variants": variants,
"id": cand_id,
"name": cand_name,
"extra": extra,
"candidates": amb_candidates,
"source": "deepseek-fallback",
}
if item.get("inferred_category_slug"):
entry["inferred_category_slug"] = str(item["inferred_category_slug"])
for v in variants:
results_map[normalize_text(v)] = entry
# Observability: matching.fallback.result
await obs.event(trace_id, "matching.fallback.result", ok=True, payload={
"latency_ms": latency_ms,
"usage": usage,
"statuses": statuses_count,
"n_mentions": len(mentions),
"n_resolved": len(results_map),
})
return results_map
async def adjudicate_with_deepseek(
raw_input: object,
consensus_text: str | None,
items: list[dict],
patch_context: dict | None,
trace_id: str | None,
) -> dict[str, dict]:
"""Execute DeepSeek final arbitration over Jev recommendations.
Takes Jev-assessed items and makes final binding decisions.
Raises MatchingFailureError if DeepSeek fails or returns unparseable output.
"""
if not items:
return {}
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
logger.error("fallback_matcher: AI provider not configured for arbitration")
raise MatchingFailureError("AI provider not configured")
if isinstance(raw_input, list):
reference_input = {
"input_mode": "voice",
"stt_multi_transcripts": raw_input,
"consensus_text": consensus_text or "",
}
else:
reference_input = {
"input_mode": "text",
"raw_text": str(raw_input or ""),
}
arbitration_items_payload = []
# Candidate lookup by (field, id) and (field, name) for metadata
cand_lookup: dict[tuple[str, str], object] = {}
for it in items:
fld = it["field"]
c_list = it.get("candidates") or []
serialized_cands = []
for c in c_list:
cid = str(getattr(c, "id", ""))
cname = str(getattr(c, "name", ""))
cand_lookup[(fld, cid)] = c
cand_lookup[(fld, normalize_text(cname))] = c
item_entry = {"id": cid, "name": cname}
extra = getattr(c, "extra", {}) or {}
for k in ("category_name", "location", "canonical_unit_id", "code"):
if extra.get(k):
item_entry[k] = extra[k]
serialized_cands.append(item_entry)
raw_probs = it.get("jev_probabilities") or {}
non_zero_probs = {k: v for k, v in raw_probs.items() if float(v or 0.0) > 0.0}
arbitration_items_payload.append({
"field": fld,
"variants": it.get("variants") or [],
"catalog_candidates": serialized_cands[:60],
"jev_assessment": {
"recommended_choice": it.get("jev_choice"),
"recommended_id": it.get("jev_choice_id"),
"confidence": it.get("jev_confidence"),
"probabilities": non_zero_probs,
"top_ranked": it.get("jev_ranked") or [],
},
})
payload_data = {
"reference_user_input": reference_input,
"items_to_adjudicate": arbitration_items_payload,
"patch_context": patch_context or {},
}
user_content = json.dumps(payload_data, ensure_ascii=False)
t0 = time.perf_counter()
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": ARBITRATION_SYSTEM_PROMPT},
{"role": "user", "content": user_content},
],
"temperature": 0.1,
"response_format": {"type": "json_object"},
},
)
latency_ms = int((time.perf_counter() - t0) * 1000)
if resp.status_code != 200:
logger.error("adjudicate_with_deepseek: HTTP %s from DeepSeek: %s", resp.status_code, resp.text[:300])
await obs.event(trace_id, "matching.arbitration.failure", ok=False, payload={
"status_code": resp.status_code,
"error": "http_error",
"body": resp.text[:300],
})
raise MatchingFailureError(f"DeepSeek returned HTTP {resp.status_code}")
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "")
usage = (resp.json() or {}).get("usage") or {}
parsed = _extract_json(content)
if not parsed or not isinstance(parsed.get("decisions"), list):
logger.error("adjudicate_with_deepseek: unparseable JSON from DeepSeek: %r", content[:300])
await obs.event(trace_id, "matching.arbitration.failure", ok=False, payload={
"error": "unparseable_json",
"raw": content[:500],
})
raise MatchingFailureError("DeepSeek returned unparseable arbitration JSON")
except httpx.HTTPError as exc:
latency_ms = int((time.perf_counter() - t0) * 1000)
logger.exception("adjudicate_with_deepseek: HTTP network error calling DeepSeek")
await obs.event(trace_id, "matching.arbitration.failure", ok=False, payload={
"error": "network_exception",
"details": str(exc),
"latency_ms": latency_ms,
})
raise MatchingFailureError("Network exception calling DeepSeek") from exc
except Exception as exc:
if isinstance(exc, MatchingFailureError):
raise
latency_ms = int((time.perf_counter() - t0) * 1000)
logger.exception("adjudicate_with_deepseek: Unexpected error")
await obs.event(trace_id, "matching.arbitration.failure", ok=False, payload={
"error": "unexpected_exception",
"details": str(exc),
"latency_ms": latency_ms,
})
raise MatchingFailureError("Unexpected error calling DeepSeek") from exc
results_map: dict[str, dict] = {}
actions_count = {"endorsed_jev": 0, "vetoed_to_new": 0, "demoted_to_ambiguity": 0, "other": 0}
statuses_count = {"exact": 0, "ambiguity": 0, "new": 0}
# Map decisions back to results_map
for dec in parsed.get("decisions", []):
if not isinstance(dec, dict):
continue
field = str(dec.get("field") or "").strip()
variant = str(dec.get("variant") or "").strip()
status = str(dec.get("status") or "").strip().lower()
if status not in ("exact", "ambiguity", "new"):
status = "new"
action = str(dec.get("adjudication_action") or "other")
actions_count[action] = actions_count.get(action, 0) + 1
statuses_count[status] = statuses_count.get(status, 0) + 1
cand_id = str(dec.get("id")) if dec.get("id") else None
cand_name = str(dec.get("name")) if dec.get("name") else None
cand_obj = None
if cand_id:
cand_obj = cand_lookup.get((field, cand_id))
if not cand_obj and cand_name:
cand_obj = cand_lookup.get((field, normalize_text(cand_name)))
extra = getattr(cand_obj, "extra", {}) or {}
amb_candidates = []
for c in dec.get("candidates") or []:
if isinstance(c, dict) and c.get("id") and c.get("name"):
amb_candidates.append({
"id": str(c["id"]),
"name": str(c["name"]),
"prob": float(c.get("prob") or 0.5),
})
# Find matching item from items for variants
matched_item = next(
(it for it in items if it["field"] == field and any(normalize_text(v) == normalize_text(variant) for v in it.get("variants", []))),
None
)
variants = matched_item.get("variants") if matched_item else [variant]
entry = {
"field": field,
"status": status,
"variants": variants,
"id": cand_id,
"name": cand_name,
"extra": extra,
"candidates": amb_candidates,
"source": "deepseek-arbiter",
"adjudication_action": action,
"reasoning": dec.get("reasoning"),
}
if dec.get("inferred_category_slug"):
entry["inferred_category_slug"] = str(dec["inferred_category_slug"])
for v in variants:
results_map[normalize_text(v)] = entry
# Observability: matching.arbitration.result
await obs.event(trace_id, "matching.arbitration.result", ok=True, payload={
"latency_ms": latency_ms,
"usage": usage,
"actions": actions_count,
"statuses": statuses_count,
"n_items": len(items),
"n_resolved": len(results_map),
})
return results_map
+208 -29
View File
@@ -11,14 +11,16 @@ Pipeline per update:
import logging
from app import drafts as draft_store
from app import observability as obs
from app.config import settings
from app.nlu import understand
from app.fallback_matcher import MatchingFailureError
from app.nlu import NLUFailureError, understand
from app.runtime import BotContext, LinkedUser
from app.storage import save_proforma_file
from app.stt import transcribe, transcribe_multi
from app.texts import CANCELLED, LINK_START, NOTHING_TO_COMMIT, STT_FAILED, UNKNOWN_HINT, VOICE_EMPTY, esc
from . import commands, callbacks, quote, supplier, material, queries, decisions
from . import commands, callbacks, quote, supplier, material, project, queries, decisions
logger = logging.getLogger("bot.handlers")
@@ -39,11 +41,25 @@ async def handle_update(ctx: BotContext, update: dict) -> None:
async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
user = await _gate(ctx, chat_id, message)
if user is None:
return
text = (message.get("text") or message.get("caption") or "").strip()
source = "text"
voice_transcripts: list[dict] | None = None
message_id = message.get("message_id")
# Observability (phase 1): open a trace for every incoming message.
_doc = message.get("document") or {}
_audio_like = bool(
message.get("voice") or message.get("audio")
or (_doc.get("mime_type") or "").lower().startswith("audio/")
)
trace_id = await obs.trace_start(chat_id, message_id, "voice" if _audio_like else "text")
if text:
await obs.event(trace_id, "input.text", payload={"text": text[:500]})
if text.startswith("/"):
await commands.handle_command(ctx, chat_id, text)
return
@@ -56,10 +72,6 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
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
reply_to = message.get("reply_to_message")
reply_to_msg_id = reply_to.get("message_id") if reply_to else None
@@ -79,11 +91,21 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
file_id = audio_media["file_id"]
try:
audio_bytes = await ctx.bale.download_file(file_id)
voice_path = obs.save_voice(trace_id, message_id or 0, audio_bytes)
await obs.event(trace_id, "input.voice", payload={
"path": voice_path,
"size": len(audio_bytes),
"mime": audio_media.get("mime_type"),
"file_id": file_id,
"duration": audio_media.get("duration"),
})
transcripts = await transcribe_multi(
settings,
audio_bytes,
mime_type=audio_media.get("mime_type"),
filename=audio_media.get("file_name"),
trace_id=trace_id,
message_id=message_id,
)
except Exception as exc:
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
@@ -98,6 +120,12 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
# BRANCH 1: EDIT MODE — Strictly requires reply_to_message_id on a draft
# =========================================================================
if reply_to_msg_id is not None:
# Check if replying to an ambiguity message (Requirement 2.b)
amb_draft, target_amb = await draft_store.get_by_ambiguity_message_id(chat_id, reply_to_msg_id)
if amb_draft is not None and target_amb is not None:
await _handle_ambiguity_user_reply(ctx, chat_id, user, amb_draft, target_amb, message, message_id)
return
target_draft = await draft_store.get_by_message_id(chat_id, reply_to_msg_id)
if target_draft is None:
await ctx.send(
@@ -195,9 +223,48 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
draft_summary = _draft_summary(target_draft) if target_draft else None
input_payload = voice_transcripts if voice_transcripts else text
result = await understand(settings, input_payload, draft_summary)
try:
result = await understand(settings, input_payload, draft_summary, trace_id=trace_id)
except (MatchingFailureError, NLUFailureError):
logger.error("NLU processing failed completely (Universal Fail-Stop Guard)")
await ctx.send(
chat_id,
"⚠️ سامانه در پردازش هوشمند موجودیت‌ها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید.",
reply_to=message_id,
)
return
intent, patch = result.get("intent"), result.get("patch") or {}
consensus_text = result.get("consensus_text") or text
consensus_text = result.get("consensus_text") or (text if isinstance(text, str) else "")
mentions = result.get("entity_mentions") or []
await obs.event(trace_id, "entities.extracted",
payload={"patch": patch, "intent": intent, "entity_mentions": mentions})
# Entity matching (Two-Stage: Jev scorer + DeepSeek final arbiter; DeepSeek crisis fallback if Jev down)
jev_map: dict = {}
if mentions and intent in ("register_quote", "update_draft", "add_material"):
try:
from app import matching
jev_map = await matching.resolve_mentions(
ctx, patch, mentions, trace_id=trace_id, raw_input=input_payload,
consensus_text=consensus_text,
)
except matching.MatchingFailureError:
logger.error("entity matching failed completely (Universal Fail-Stop Guard)")
await ctx.send(
chat_id,
"⚠️ سامانه در پردازش هوشمند موجودیت‌ها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید.",
reply_to=message_id,
)
return
except Exception:
logger.exception("unexpected matching failure")
await ctx.send(
chat_id,
"⚠️ سامانه در پردازش هوشمند موجودیت‌ها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید.",
reply_to=message_id,
)
return
if source == "voice":
await ctx._log(chat_id, "in", "voice", consensus_text)
@@ -210,21 +277,28 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
ctx, chat_id, user, patch, source,
incoming_msg_id=message_id,
target_draft=target_draft,
trace_id=trace_id,
jev_map=jev_map,
)
elif intent == "commit_draft":
active = target_draft or await draft_store.get_active(chat_id)
if active is None:
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
elif active.kind == draft_store.KIND_QUOTE:
await quote.commit(ctx, chat_id, user, active)
await quote.commit(ctx, chat_id, user, active, trace_id=trace_id)
elif active.kind == draft_store.KIND_SUPPLIER:
await supplier.commit(ctx, chat_id, user, active)
await supplier.commit(ctx, chat_id, user, active, trace_id=trace_id)
elif active.kind == draft_store.KIND_MATERIAL:
await material.commit(ctx, chat_id, user, active)
await material.commit(ctx, chat_id, user, active, trace_id=trace_id)
elif active.kind == draft_store.KIND_PROJECT:
await project.commit(ctx, chat_id, user, active, trace_id=trace_id)
elif intent == "discard_draft":
active = target_draft or await draft_store.get_active(chat_id)
if active:
await draft_store.set_status(active.id, "cancelled")
await obs.event(trace_id, "draft.cancel",
payload={"draft_id": str(active.id), "kind": active.kind})
await obs.set_trace_status(trace_id, "cancelled")
if active.review_message_id:
try:
await ctx.delete(chat_id, active.review_message_id)
@@ -242,10 +316,14 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
await supplier.render(ctx, chat_id, active)
elif active.kind == draft_store.KIND_MATERIAL:
await material.render(ctx, chat_id, active)
elif active.kind == draft_store.KIND_PROJECT:
await project.render(ctx, chat_id, active)
elif intent == "add_supplier":
await supplier.handle_entities(ctx, chat_id, user, patch, source)
await supplier.handle_entities(ctx, chat_id, user, patch, source, trace_id=trace_id)
elif intent == "add_material":
await material.handle_entities(ctx, chat_id, user, patch, source)
await material.handle_entities(ctx, chat_id, user, patch, source, trace_id=trace_id, jev_map=jev_map)
elif intent == "add_project":
await project.handle_entities(ctx, chat_id, user, patch, source, trace_id=trace_id)
elif intent == "status_query":
await queries.handle_query(ctx, chat_id, user, patch)
elif intent == "accept_quote":
@@ -260,25 +338,58 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
# helpers
# ---------------------------------------------------------------------------
from datetime import datetime, timezone
import json
from pathlib import Path
UNREGISTERED_FILE = Path(__file__).resolve().parent.parent / "unregistered_users.json"
def _record_unregistered_user(chat_id: int, message: dict) -> None:
from_user = message.get("from") or {}
record = {
"chat_id": chat_id,
"username": from_user.get("username"),
"first_name": from_user.get("first_name"),
"last_name": from_user.get("last_name"),
"timestamp": datetime.now(timezone.utc).isoformat(),
"text_preview": (message.get("text") or "")[:100],
}
records = []
try:
if UNREGISTERED_FILE.exists():
content = UNREGISTERED_FILE.read_text(encoding="utf-8").strip()
if content:
records = json.loads(content)
if not isinstance(records, list):
records = []
except Exception:
records = []
records.append(record)
try:
UNREGISTERED_FILE.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as e:
logger.warning("Could not write to unregistered_users.json: %s", e)
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
"""Whitelist gate (Casdoor bypassed). Unregistered users are blocked and logged."""
from app.defaults import AUTHORIZED_USERS
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)
# 1. Whitelist check (only registered whitelist IDs)
if chat_id in AUTHORIZED_USERS:
u = AUTHORIZED_USERS[chat_id]
return LinkedUser(
chat_id=chat_id,
user_id=u.get("user_id"),
display_name=u.get("display_name"),
role=u.get("role", "procurement_manager"),
is_admin=u.get("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", {})
# 2. Block unregistered user and record log
_record_unregistered_user(chat_id, message)
await ctx.send(chat_id, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.")
return None
@@ -323,3 +434,71 @@ def _draft_summary(draft) -> str | None:
else:
summary = json.dumps(payload, ensure_ascii=False)[:500]
return summary
async def _handle_ambiguity_user_reply(
ctx: BotContext, chat_id: int, user: LinkedUser,
draft, amb: dict, message: dict, incoming_msg_id: int | None
) -> None:
"""Handle text/audio correction sent directly as a reply to an ambiguity sub-message (Requirement 2.b)."""
trace_id = await obs.latest_trace_for_draft(draft.id)
text = (message.get("text") or message.get("caption") or "").strip()
if not text and (message.get("voice") or message.get("audio")):
try:
audio_media = message.get("voice") or message.get("audio")
audio_bytes = await ctx.bale.download_file(audio_media["file_id"])
obs.save_voice(trace_id, incoming_msg_id or 0, audio_bytes)
text = await transcribe(
settings, audio_bytes, trace_id=trace_id, message_id=incoming_msg_id
)
except Exception:
text = ""
if text:
await obs.event(trace_id, "ambiguity.user_choice", payload={
"amb_id": amb.get("id"), "action": "reply", "kind": amb.get("kind"),
"reply_text": text[:500],
})
draft_summary = _draft_summary(draft)
result = await understand(settings, text, draft_summary, trace_id=trace_id)
patch = result.get("patch") or {}
# Apply user correction specifically to the target field
if amb.get("kind") == "payment_schedule":
if patch.get("payment_terms"):
draft_store.merge_quote_patch(draft.payload, patch)
else:
draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {})
if not draft.payload.get("notes"):
draft.payload["notes"] = f"نحوه پرداخت: {text}"
else:
draft.payload["notes"] += f"\nنحوه پرداخت: {text}"
elif amb.get("kind") == "project_assignment":
if patch.get("items") or patch.get("project_name") or patch.get("project_names"):
draft_store.merge_quote_patch(draft.payload, patch)
else:
draft_store.merge_quote_patch(draft.payload, amb.get("proposed_patch") or {})
else:
draft_store.merge_quote_patch(draft.payload, patch)
amb["status"] = "applied"
await quote.resolve_payload(ctx, draft.payload, trace_id)
await draft_store.save_payload(draft, mark_bale_synced=True)
# In-place edit main review card (Requirement 2.b)
await quote.render(ctx, chat_id, draft, fresh=False, trace_id=trace_id)
# Delete the ambiguity message (Requirement 2.b)
if amb.get("message_id"):
try:
await ctx.delete(chat_id, amb["message_id"])
except Exception:
pass
# Delete user's message to keep chat clean (Requirement 2.b)
if incoming_msg_id:
try:
await ctx.delete(chat_id, incoming_msg_id)
except Exception:
pass
+109 -11
View File
@@ -6,10 +6,11 @@ Data grammar (≤64 bytes): op:{arg}:{d8} / pk:{d8}:{field}:{idx} / qc:{idx
import logging
from app import drafts as draft_store
from app import observability as obs
from app.runtime import LinkedUser
from app.texts import CANCELLED, UNLINKED, esc
from . import quote, supplier, material, decisions
from . import quote, supplier, material, project, decisions
logger = logging.getLogger("bot.callbacks")
@@ -29,12 +30,18 @@ async def handle_callback(ctx, cq: dict) -> 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)
from app.defaults import AUTHORIZED_USERS
if chat_id in AUTHORIZED_USERS:
u = AUTHORIZED_USERS[chat_id]
return LinkedUser(
chat_id=chat_id,
user_id=u.get("user_id"),
display_name=u.get("display_name"),
role=u.get("role", "procurement_manager"),
is_admin=u.get("is_admin", True),
)
await ctx.send(chat_id, "دسترسی شما هنوز تعریف نشده است. درخواست شما جهت بررسی ثبت شد.")
return None
@@ -57,6 +64,85 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
await decisions.handle_arrive_callback(ctx, chat_id, user, parts)
return
# ---- ambiguity sub-messages (Requirement 2.a) -------------------------
if parts[0] == "amb" and len(parts) >= 4:
action, prefix, amb_id = parts[1], parts[2], parts[3]
draft = await draft_store.get_by_prefix(prefix, chat_id)
if draft is None:
await ctx.send(chat_id, "این پیش‌نویس دیگر معتبر نیست.")
return
payload = draft.payload or {}
ambiguities = payload.get("ambiguities") or []
target_amb = next((a for a in ambiguities if a.get("id") == amb_id), None)
if not target_amb:
return
tid = await obs.latest_trace_for_draft(draft.id)
await obs.event(tid, "ambiguity.user_choice", payload={
"amb_id": amb_id, "action": action, "kind": target_amb.get("kind"),
"title": target_amb.get("title"),
"arg": parts[4] if len(parts) > 4 else None,
"candidates": target_amb.get("candidates"),
})
if action == "ok":
proposed = target_amb.get("proposed_patch") or {}
draft_store.merge_quote_patch(payload, proposed)
target_amb["status"] = "applied"
await quote.resolve_payload(ctx, payload)
await draft_store.save_payload(draft, mark_bale_synced=True)
# In-place edit main review card
await quote.render(ctx, chat_id, draft, fresh=False)
# Delete ONLY this ambiguity message
if target_amb.get("message_id"):
try:
await ctx.delete(chat_id, target_amb["message_id"])
except Exception:
pass
elif action == "no":
target_amb["status"] = "rejected"
await draft_store.save_payload(draft, mark_bale_synced=True)
# Delete ONLY this ambiguity message
if target_amb.get("message_id"):
try:
await ctx.delete(chat_id, target_amb["message_id"])
except Exception:
pass
elif action == "pick" and len(parts) >= 5:
# Phase 4: user picked one of the Jev candidates.
try:
idx = int(parts[4])
except ValueError:
return
cands = target_amb.get("candidates") or []
if not (0 <= idx < len(cands)):
return
from app import matching
matching.apply_choice(payload, target_amb.get("field"), cands[idx],
target_amb.get("variants") or [])
target_amb["status"] = "applied"
await quote.resolve_payload(ctx, payload)
await draft_store.save_payload(draft, mark_bale_synced=True)
# In-place edit main review card
await quote.render(ctx, chat_id, draft, fresh=False)
# Delete ONLY this ambiguity message
if target_amb.get("message_id"):
try:
await ctx.delete(chat_id, target_amb["message_id"])
except Exception:
pass
elif action == "new":
# «هیچ‌کدام» — keep the raw name; the auto-create path handles it at commit.
target_amb["status"] = "rejected"
await draft_store.save_payload(draft, mark_bale_synced=True)
if target_amb.get("message_id"):
try:
await ctx.delete(chat_id, target_amb["message_id"])
except Exception:
pass
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)
@@ -68,14 +154,22 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
# ---- 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]
if op in ("q", "s", "m", "p") and arg in ("ok", "ok2", "no"):
kind = {
"q": draft_store.KIND_QUOTE,
"s": draft_store.KIND_SUPPLIER,
"m": draft_store.KIND_MATERIAL,
"p": draft_store.KIND_PROJECT,
}[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")
tid = await obs.latest_trace_for_draft(draft.id)
await obs.event(tid, "draft.cancel", payload={"draft_id": str(draft.id), "kind": draft.kind})
await obs.set_trace_status(tid, "cancelled")
ctx.pending.pop(chat_id, None)
if draft.review_message_id:
try:
@@ -89,8 +183,10 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
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:
elif kind == draft_store.KIND_MATERIAL:
await material.commit(ctx, chat_id, user, draft)
elif kind == draft_store.KIND_PROJECT:
await project.commit(ctx, chat_id, user, draft)
async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, idx: int) -> None:
@@ -99,8 +195,10 @@ async def _apply_pick(ctx, chat_id: int, user: LinkedUser, draft, field: str, id
await ctx.send(chat_id, "گزینه نامعتبر است.")
return
choice = options[idx]
tid = await obs.latest_trace_for_draft(draft.id)
await obs.event(tid, "user_choice.pick", payload={"field": field, "idx": idx, "choice": choice})
if draft.kind == draft_store.KIND_QUOTE:
await quote.apply_pick(ctx, chat_id, user, draft, field, choice)
await quote.apply_pick(ctx, chat_id, user, draft, field, choice, trace_id=tid)
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:
+13 -3
View File
@@ -31,12 +31,15 @@ async def handle_command(ctx, chat_id: int, text: str) -> None:
async def _start(ctx, chat_id: int) -> None:
from app.defaults import AUTHORIZED_USERS
await ctx.send(chat_id, WELCOME)
if chat_id in AUTHORIZED_USERS or chat_id in ctx.admin_chat_ids:
return
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", {})
await ctx.send(chat_id, "برای شروع، حساب خود را متصل کن — نام کاربری Casdoor را بفرست:")
ctx.pending.set(chat_id, "link_username", {})
async def _drafts(ctx, chat_id: int) -> None:
@@ -71,6 +74,13 @@ async def _cancel(ctx, chat_id: int) -> None:
async def _whoami(ctx, chat_id: int) -> None:
from app.defaults import AUTHORIZED_USERS
if chat_id in AUTHORIZED_USERS:
u = AUTHORIZED_USERS[chat_id]
role = ROLE_FA.get(u.get("role") or "", u.get("role") or "مدیر تدارکات")
await ctx.send(chat_id, f"اتصال (لیست سفید): {esc(u.get('display_name'))} — {role}")
return
link = await ctx.links.get(chat_id)
if chat_id in ctx.admin_chat_ids:
await ctx.send(chat_id, "حالت ادمین (بدون اتصال Casdoor).")
+44 -17
View File
@@ -2,18 +2,22 @@
import logging
from app import drafts as draft_store
from app import matching
from app import observability as obs
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.nlu import normalize_text
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:
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
trace_id: str | None = None,
jev_map: dict | None = None) -> None:
draft = await draft_store.get_active(chat_id, KIND_MATERIAL)
if draft is not None:
payload = draft.payload
@@ -22,23 +26,40 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -
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 obs.link_draft(trace_id, draft.id, draft.kind)
await resolve_payload(ctx, payload, trace_id, jev_map)
await draft_store.save_payload(draft, missing={"fields": material_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None, trace_id=trace_id)
async def resolve_payload(ctx, payload: dict) -> None:
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
jev_map: dict | None = None) -> 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]
hit = matching.lookup(jev_map, payload["unit_name"])
if hit is not None:
if hit["status"] == "exact":
payload["canonical_unit_id"] = hit["id"]
payload["unit_name"] = hit["name"]
options.pop("unit", None)
elif hit["status"] == "ambiguity" and hit.get("candidates"):
options["unit"] = [{"id": c["id"], "name": c["name"]}
for c in hit["candidates"][:6]]
elif hit["status"] == "new":
options.pop("unit", None)
else:
options.pop("unit", None)
try:
units = await ctx.resolver.units()
except Exception:
units = []
norm_u = normalize_text(payload["unit_name"])
exact = [u for u in units if normalize_text(u.name) == norm_u or (u.extra or {}).get("code") == norm_u]
if len(exact) == 1:
payload["canonical_unit_id"] = exact[0].id
payload["unit_name"] = exact[0].name
options.pop("unit", None)
else:
options.pop("unit", None)
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
@@ -58,7 +79,7 @@ def _keyboard(payload: dict, draft_id: str) -> dict:
return simple_review_keyboard(draft_id, KIND_MATERIAL, rows)
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
async def render(ctx, chat_id, draft, fresh: bool = True, trace_id: str | None = None) -> None:
payload = draft.payload
missing = material_missing(payload)
text = material_review_text(payload, missing)
@@ -71,11 +92,14 @@ async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing})
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
async def commit(ctx, chat_id, user, draft) -> None:
async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
await resolve_payload(ctx, payload, trace_id)
missing = {m["key"]: m["ok"] for m in material_missing(payload)}
problems = [label for key, label, ok in [
("name", "نام کالا", missing.get("name")),
@@ -94,5 +118,8 @@ async def commit(ctx, chat_id, user, draft) -> None:
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 obs.event(trace_id, "draft.commit", payload={"kind": "material",
"material_id": (created or {}).get("id")})
await obs.set_trace_status(trace_id, "committed")
ctx.resolver.invalidate()
await ctx.send(chat_id, material_committed_report(created))
+76
View File
@@ -0,0 +1,76 @@
"""Add-project flow: name + optional description."""
import logging
from app import drafts as draft_store
from app import observability as obs
from app.api.client import ApiError
from app.drafts import (
KIND_PROJECT, merge_project_patch, new_project_payload, project_missing,
)
from app.keyboards import simple_review_keyboard
from app.texts import ERR_GENERIC, esc, project_committed_report, project_review_text
logger = logging.getLogger("bot.project")
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
trace_id: str | None = None) -> None:
draft = await draft_store.get_active(chat_id, KIND_PROJECT)
if draft is not None:
payload = draft.payload
merge_project_patch(payload, patch)
else:
payload = new_project_payload()
merge_project_patch(payload, patch)
draft = await draft_store.create(chat_id, KIND_PROJECT, payload, source=source)
await obs.link_draft(trace_id, draft.id, draft.kind)
await draft_store.save_payload(draft, missing={"fields": project_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None, trace_id=trace_id)
def _keyboard(payload: dict, draft_id: str) -> dict:
return simple_review_keyboard(draft_id, KIND_PROJECT, [])
async def render(ctx, chat_id, draft, fresh: bool = True, trace_id: str | None = None) -> None:
payload = draft.payload
missing = project_missing(payload)
text = project_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})
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None:
payload = draft.payload
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
missing = {m["key"]: m["ok"] for m in project_missing(payload)}
if not missing.get("name"):
await ctx.send(chat_id, "ثبت ممکن نیست؛ نام پروژه مشخص نشده است.")
return
body = {"name": payload["name"]}
if payload.get("description"):
body["description"] = payload["description"]
try:
created = await ctx.api.create_project(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_project failed")
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای سرور"))
return
await draft_store.set_status(draft.id, "committed")
await obs.event(trace_id, "draft.commit", payload={"kind": "project",
"project_id": (created or {}).get("id")})
await obs.set_trace_status(trace_id, "committed")
ctx.resolver.invalidate()
await ctx.send(chat_id, project_committed_report(created))
+344 -81
View File
@@ -2,12 +2,15 @@
import logging
from app import drafts as draft_store
from app import matching
from app import observability as obs
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.nlu import normalize_text
from app.resolve import safe_resolve
from app.texts import ERR_GENERIC, quote_committed_report, quote_review_text, esc
@@ -20,12 +23,25 @@ async def handle_entities(
ctx, chat_id, user, patch: dict, source: str | None,
incoming_msg_id: int | None = None,
target_draft = None,
trace_id: str | None = None,
jev_map: dict | None = None,
) -> None:
prev_review_msg_id = None
if target_draft is not None:
draft = target_draft
prev_review_msg_id = draft.review_message_id
draft.review_message_id = None
# Clean up old ambiguity sub-messages (Requirement 2.c)
old_ambs = (draft.payload or {}).get("ambiguities") or []
for a in old_ambs:
if a.get("message_id"):
try:
await ctx.delete(chat_id, a["message_id"])
except Exception:
pass
draft.payload["ambiguities"] = []
payload = draft.payload
merge_quote_patch(payload, patch)
draft.source_message_id = incoming_msg_id
@@ -36,7 +52,11 @@ async def handle_entities(
chat_id, KIND_QUOTE, payload, source=source, source_message_id=incoming_msg_id
)
await resolve_payload(ctx, payload)
await obs.link_draft(trace_id, draft.id, draft.kind)
await resolve_payload(ctx, payload, trace_id, jev_map)
if jev_map:
matching.normalize_brand_spec(payload, jev_map)
matching.create_ambiguities(payload, jev_map)
if prev_review_msg_id:
try:
@@ -44,66 +64,121 @@ async def handle_entities(
except Exception as e:
logger.warning("Could not delete previous review message %s: %s", prev_review_msg_id, e)
await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id)
await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id, trace_id=trace_id)
# ---------------------------------------------------------------------------
# resolution
# resolution (pure intelligence; zero local difflib heuristics)
# ---------------------------------------------------------------------------
async def resolve_payload(ctx, payload: dict) -> None:
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
jev_map: dict | None = None) -> 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:
c = res.candidates[0]
payload["supplier_id"] = c.id
payload["supplier_name"] = c.name
extra = c.extra or {}
for k in ("location", "phone", "contact_person", "category_name"):
if extra.get(k):
payload[f"supplier_{k}"] = extra[k]
options.pop("sup", None)
elif res.candidates:
options["sup"] = [{"id": c.id, "name": c.name} for c in res.candidates]
hit = matching.lookup(jev_map, payload["supplier_name"])
if hit is not None:
if hit["status"] == "exact":
matching.apply_exact(payload, hit)
options.pop("sup", None)
elif hit["status"] == "ambiguity" and hit.get("candidates"):
options["sup"] = [{"id": c["id"], "name": c["name"]} for c in hit["candidates"][:6]]
elif hit["status"] == "new":
options.pop("sup", None)
if hit.get("inferred_category_slug"):
payload["supplier_inferred_category_slug"] = hit["inferred_category_slug"]
else:
options.pop("sup", None)
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
if res.resolved:
c = res.candidates[0]
payload["supplier_id"] = c.id
payload["supplier_name"] = c.name
extra = c.extra or {}
for k in ("location", "phone", "contact_person", "category_name"):
if extra.get(k):
payload[f"supplier_{k}"] = extra[k]
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)
resolved_projects = payload.setdefault("resolved_projects", {})
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]
hit = matching.lookup(jev_map, payload["project_name"])
if hit is not None:
if hit["status"] == "exact":
matching.apply_exact(payload, hit)
payload["project_name"] = hit["name"]
resolved_projects[hit["name"]] = hit["id"]
resolved_projects[payload["project_name"]] = hit["id"]
if hit["id"] not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(hit["id"])
options.pop("prj", None)
elif hit["status"] == "ambiguity" and hit.get("candidates"):
options["prj"] = [{"id": c["id"], "name": c["name"]} for c in hit["candidates"][:6]]
elif hit["status"] == "new":
options.pop("prj", None)
else:
options.pop("prj", None)
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
if res.resolved:
c = res.candidates[0]
payload["project_id"] = c.id
payload["project_name"] = c.name
resolved_projects[c.name] = c.id
resolved_projects[payload["project_name"]] = c.id
if c.id not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(c.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)
hit = matching.lookup(jev_map, p_name)
if hit is not None and hit.get("status") == "exact":
matching.apply_exact(payload, hit)
resolved_projects[hit["name"]] = hit["id"]
resolved_projects[p_name] = hit["id"]
if hit["id"] not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(hit["id"])
if not payload.get("project_id"):
payload["project_id"] = p_id
payload["project_id"] = hit["id"]
elif hit is None:
res = await safe_resolve(ctx.resolver, "project", p_name)
if res.resolved:
resolved_projects[p_name] = res.candidates[0].id
resolved_projects[res.candidates[0].name] = res.candidates[0].id
if res.candidates[0].id not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(res.candidates[0].id)
if not payload.get("project_id"):
payload["project_id"] = res.candidates[0].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]
hit = matching.lookup(jev_map, item["material_name"])
if hit is not None:
if hit["status"] == "exact":
matching.apply_exact(payload, hit)
options.pop(f"mat{i}", None)
elif hit["status"] == "ambiguity" and hit.get("candidates"):
options[f"mat{i}"] = [{"id": c["id"], "name": c["name"]} for c in hit["candidates"][:6]]
elif hit["status"] == "new":
options.pop(f"mat{i}", None)
else:
options.pop(f"mat{i}", None)
res = await safe_resolve(ctx.resolver, "material", item["material_name"])
if res.resolved:
c = res.candidates[0]
_set_material(item, c.id, c.name, c.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)
await _resolve_unit(ctx, item, trace_id, jev_map)
def _set_material(item: dict, material_id: str, name: str, extra: dict | None = None) -> None:
@@ -111,24 +186,34 @@ def _set_material(item: dict, material_id: str, name: str, extra: dict | None =
item["material_row"] = {"id": material_id, "canonical_unit_id": (extra or {}).get("canonical_unit_id")}
async def _resolve_unit(ctx, item: dict) -> None:
async def _resolve_unit(ctx, item: dict, trace_id: str | None = None,
jev_map: dict | None = None) -> 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
hit = matching.lookup(jev_map, item["unit_name"])
if hit is not None and hit.get("status") == "exact":
item["unit_id"] = hit["id"]
item["unit_explicit"] = True
item["unit_name"] = res.candidates[0].name
item["unit_name"] = hit["name"]
return
if hit is None:
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:
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict,
trace_id: str | None = None) -> None:
payload = draft.payload
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
options = payload.setdefault("options", {})
options.pop(field, None)
@@ -144,7 +229,7 @@ async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> Non
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])
await _resolve_unit(ctx, items[index], trace_id)
elif field.startswith("unit"):
index = int(field[4:]) - 1
items = payload.get("items") or []
@@ -153,9 +238,9 @@ async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> Non
items[index]["unit_explicit"] = True
items[index]["unit_name"] = choice["name"]
await resolve_payload(ctx, payload)
await resolve_payload(ctx, payload, trace_id)
await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)})
await render(ctx, chat_id, draft, fresh=False)
await render(ctx, chat_id, draft, fresh=False, trace_id=trace_id)
# ---------------------------------------------------------------------------
@@ -195,7 +280,7 @@ def _keyboard(payload: dict, draft_id: str, wizard_url: str | None = None) -> di
async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = None,
reply_to: int | None = None) -> None:
reply_to: int | None = None, trace_id: str | None = None) -> None:
from app.config import settings
from app.security import create_draft_token
@@ -231,6 +316,9 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
source_message_id=reply_to or draft.source_message_id,
mark_bale_synced=True,
)
# Send pending ambiguity sub-messages as replies to the main review card
await send_pending_ambiguities(ctx, chat_id, draft)
else:
if not draft.review_message_id:
return
@@ -241,48 +329,216 @@ async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = Non
logger.warning("Could not edit review message %s for draft %s", draft.review_message_id, draft.id)
await draft_store.set_bale_updated_at(draft.id)
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
from app.keyboards import ambiguity_keyboard
from app.texts import ambiguity_message_text
if not draft.review_message_id:
return
prefix = str(draft.id)[:8]
payload = draft.payload or {}
ambiguities = payload.get("ambiguities") or []
updated = False
trace_id = await obs.latest_trace_for_draft(draft.id)
for amb in ambiguities:
if amb.get("status") == "pending" and not amb.get("message_id"):
msg_text = ambiguity_message_text(amb.get("title", ""), amb.get("details", ""))
kb = ambiguity_keyboard(prefix, amb.get("id", ""))
sent = await ctx.send(
chat_id,
msg_text,
reply_markup=kb,
reply_to=draft.review_message_id,
)
if sent and sent.get("message_id"):
amb["message_id"] = sent["message_id"]
updated = True
await obs.event(trace_id, "ambiguity.sent", payload={
"amb_id": amb.get("id"), "title": amb.get("title"),
"kind": amb.get("kind"), "message_id": sent["message_id"],
})
if updated:
await draft_store.save_payload(draft, mark_bale_synced=True)
# ---------------------------------------------------------------------------
# commit
# ---------------------------------------------------------------------------
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None:
async def commit(ctx, chat_id, user, draft, only_resolved: bool = False,
trace_id: str | None = None) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
await resolve_payload(ctx, payload, trace_id)
# 1. Sequential pre-creation for new supplier if user wants to commit
newly_created: dict = {}
# 1. Defensive pre-creation for new supplier (get_or_create)
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)
sup_name = payload["supplier_name"].strip()
# 1.a) Check if already in DB
try:
existing_sups = await ctx.api.list_suppliers(q=sup_name, page_size=10)
for s in existing_sups:
if normalize_text(s.get("name") or "") == normalize_text(sup_name):
payload["supplier_id"] = str(s["id"])
payload["supplier_name"] = s.get("name")
break
except Exception as e:
logger.warning("Could not check existing suppliers: %s", e)
# 2. Sequential pre-creation for new materials
# 1.b) If still not in DB, create it
if not payload.get("supplier_id"):
cat_id = payload.get("supplier_category_id")
if not cat_id and payload.get("supplier_inferred_category_slug"):
inferred_slug = payload["supplier_inferred_category_slug"]
try:
cats = await ctx.resolver.categories()
for c in cats:
if (c.extra or {}).get("slug") == inferred_slug or c.name == inferred_slug:
cat_id = c.id
break
except Exception:
pass
if not cat_id:
try:
cats = await ctx.resolver.categories()
if cats:
cat_id = cats[0].id
except Exception:
pass
if cat_id:
try:
sup_body = {
"name": sup_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"]
newly_created["supplier"] = sup_name
ctx.resolver.invalidate("suppliers")
except Exception as e:
logger.warning("Could not auto-create supplier: %s — trying fallback fetch", e)
try:
existing_sups = await ctx.api.list_suppliers(q=sup_name, page_size=10)
for s in existing_sups:
if normalize_text(s.get("name") or "") == normalize_text(sup_name):
payload["supplier_id"] = str(s["id"])
payload["supplier_name"] = s.get("name")
break
except Exception:
pass
# 2. Defensive pre-creation for new materials (get_or_create)
created_mats = []
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"):
mat_name = item["material_name"].strip()
# 2.a) Check if already in DB
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")
existing_mats = await ctx.api.list_materials(q=mat_name, page_size=10)
for m in existing_mats:
if normalize_text(m.get("name") or "") == normalize_text(mat_name):
_set_material(item, str(m["id"]), m.get("name") or mat_name,
{"canonical_unit_id": m.get("canonical_unit_id")})
break
except Exception as e:
logger.warning("Could not auto-create material: %s", e)
logger.warning("Could not check existing materials: %s", e)
# 2.b) If still not in DB, create it
if not item.get("material_id"):
try:
created_mat = await ctx.api.create_material({
"name": mat_name,
"canonical_unit_id": int(item["unit_id"]),
}, actor_id=user.actor_id)
_set_material(item, created_mat["id"], created_mat.get("name") or mat_name)
created_mats.append(mat_name)
ctx.resolver.invalidate("materials")
except Exception as e:
logger.warning("Could not auto-create material %s: %s — trying fallback fetch", mat_name, e)
try:
existing_mats = await ctx.api.list_materials(q=mat_name, page_size=10)
for m in existing_mats:
if normalize_text(m.get("name") or "") == normalize_text(mat_name):
_set_material(item, str(m["id"]), m.get("name") or mat_name,
{"canonical_unit_id": m.get("canonical_unit_id")})
break
except Exception:
pass
if created_mats:
newly_created["materials"] = created_mats
# 3. Defensive pre-creation for new projects (get_or_create)
created_prjs = []
all_p_names = []
if payload.get("project_names"):
for p in payload["project_names"]:
p_str = str(p).strip()
if p_str and p_str not in all_p_names:
all_p_names.append(p_str)
elif payload.get("project_name"):
p_str = str(payload["project_name"]).strip()
if p_str and p_str not in all_p_names:
all_p_names.append(p_str)
for it in payload.get("items") or []:
it_p = it.get("project_name")
if it_p:
it_p_str = str(it_p).strip()
if it_p_str and it_p_str not in all_p_names:
all_p_names.append(it_p_str)
resolved_map = payload.setdefault("resolved_projects", {})
all_known_prjs = None
for p_name in all_p_names:
p_id = resolved_map.get(p_name)
if not p_id:
# 3.a) Check if project already exists in catalog
if all_known_prjs is None:
try:
all_known_prjs = await ctx.resolver.projects()
except Exception:
all_known_prjs = []
norm_pn = normalize_text(p_name)
for prj in all_known_prjs:
if normalize_text(prj.name) == norm_pn:
p_id = prj.id
resolved_map[p_name] = p_id
resolved_map[prj.name] = p_id
break
# 3.b) If not in catalog, create it
if not p_id:
try:
created_prj = await ctx.api.create_project({"name": p_name}, actor_id=user.actor_id)
p_id = created_prj.get("id")
if p_id:
resolved_map[p_name] = p_id
created_prjs.append(p_name)
ctx.resolver.invalidate("projects")
all_known_prjs = None
except Exception as e:
logger.warning("Could not auto-create project %s: %s", p_name, e)
if p_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
if created_prjs:
newly_created["projects"] = created_prjs
ready, blockers = quote_ready(payload)
body, problems = build_quote_request(payload, only_resolved_items=only_resolved)
@@ -311,6 +567,13 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
ctx.resolver.invalidate()
quote_id = (created or {}).get("id") or ""
await obs.event(trace_id, "draft.commit", payload={
"quote_id": quote_id,
"items": len(body.get("line_items") or []),
"newly_created": newly_created,
"only_resolved": only_resolved,
})
await obs.set_trace_status(trace_id, "committed", quote_id)
# If a proforma file was attached, persist it to the created ERP quote
proforma_info = payload.get("proforma")
if quote_id and proforma_info and proforma_info.get("saved_path"):
@@ -346,4 +609,4 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
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))
await ctx.send(chat_id, quote_committed_report(created, payload, remaining, newly_created=newly_created))
+36 -16
View File
@@ -2,18 +2,20 @@
import logging
from app import drafts as draft_store
from app import observability as obs
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.nlu import normalize_text
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:
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
trace_id: str | None = None) -> None:
draft = await draft_store.get_active(chat_id, KIND_SUPPLIER)
if draft is not None:
payload = draft.payload
@@ -22,23 +24,35 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -
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 obs.link_draft(trace_id, draft.id, draft.kind)
await resolve_payload(ctx, payload, trace_id)
await draft_store.save_payload(draft, missing={"fields": supplier_missing(payload)})
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None, trace_id=trace_id)
async def resolve_payload(ctx, payload: dict) -> None:
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None) -> 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
norm_cat = normalize_text(payload["category_name"])
try:
cats = await ctx.resolver.categories()
except Exception:
cats = []
exact = [c for c in cats if normalize_text(c.name) == norm_cat or (c.extra or {}).get("slug") == norm_cat]
if len(exact) == 1:
payload["category_id"] = exact[0].id
payload["category_name"] = exact[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)
partial = [c for c in cats if norm_cat in normalize_text(c.name) and len(norm_cat) >= 3]
if len(partial) == 1:
payload["category_id"] = partial[0].id
payload["category_name"] = partial[0].name
options.pop("cat", None)
elif partial:
options["cat"] = [{"id": c.id, "name": c.name} for c in partial]
else:
options.pop("cat", None)
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
@@ -58,7 +72,7 @@ def _keyboard(payload: dict, draft_id: str) -> dict:
return simple_review_keyboard(draft_id, KIND_SUPPLIER, rows)
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
async def render(ctx, chat_id, draft, fresh: bool = True, trace_id: str | None = None) -> None:
payload = draft.payload
missing = supplier_missing(payload)
text = supplier_review_text(payload, missing)
@@ -71,11 +85,14 @@ async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
else:
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
await draft_store.save_payload(draft, missing={"fields": missing})
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
async def commit(ctx, chat_id, user, draft) -> None:
async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None:
payload = draft.payload
await resolve_payload(ctx, payload)
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
await resolve_payload(ctx, payload, trace_id)
missing = {m["key"]: m["ok"] for m in supplier_missing(payload)}
problems = [label for key, label, ok in [
("name", "نام تأمین‌کننده", missing.get("name")),
@@ -99,5 +116,8 @@ async def commit(ctx, chat_id, user, draft) -> None:
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 obs.event(trace_id, "draft.commit", payload={"kind": "supplier",
"supplier_id": (created or {}).get("id")})
await obs.set_trace_status(trace_id, "committed")
ctx.resolver.invalidate()
await ctx.send(chat_id, supplier_committed_report(created))
+67
View File
@@ -0,0 +1,67 @@
"""Jev (TypeSafe) client — structured `choice` matching over HTTP (phases 2-5).
Contract (docs.typesafe.ai): POST /v1/systemone with
{"state": ..., "model": <pinned>, "questions": {key: {type, instructions, criteria}}}
→ {"answers": {key: {choice, confidence, probabilities}}, "usage": {...}, "model": "..."}
Design rules (final plan v5):
- The model version is PINNED (JEV_MODEL=jev-1.13.0 from phase 0); a response
whose `model` differs logs a drift warning and is still returned (recorded).
- Fail-soft: any error/timeout → None. Callers fall back per field to the
existing difflib path; with JEV_ENABLED=false nothing here is called at all.
- One retry only for 429/5xx (after a short backoff), then give up.
"""
import asyncio
import logging
import time
import httpx
from app.config import settings
logger = logging.getLogger("bot.jev")
_URL = "https://api.typesafe.ai/v1/systemone"
async def ask(state: dict, questions: dict, *, timeout: float | None = None) -> dict | None:
"""One systemone request. Returns {"answers", "usage", "model", "latency_ms"} or None."""
if not settings.TYPESAFE_API_KEY:
logger.warning("jev: TYPESAFE_API_KEY is not configured")
return None
payload = {"state": state, "model": settings.JEV_MODEL, "questions": questions}
headers = {
"Authorization": f"Bearer {settings.TYPESAFE_API_KEY}",
"Content-Type": "application/json",
}
t0 = time.perf_counter()
for attempt in (1, 2):
try:
async with httpx.AsyncClient(timeout=timeout or settings.JEV_TIMEOUT) as client:
resp = await client.post(_URL, json=payload, headers=headers)
if resp.status_code == 200:
body = resp.json() or {}
actual = str(body.get("model") or "")
if actual and actual != settings.JEV_MODEL:
logger.warning("jev: model drift — got %s, pinned %s", actual, settings.JEV_MODEL)
return {
"answers": body.get("answers") or {},
"usage": body.get("usage") or {},
"model": actual or settings.JEV_MODEL,
"latency_ms": int((time.perf_counter() - t0) * 1000),
}
if resp.status_code in (429, 500, 502, 503, 504) and attempt == 1:
logger.warning("jev: HTTP %s — retrying once", resp.status_code)
await asyncio.sleep(1.0)
continue
logger.error("jev: HTTP %s: %s", resp.status_code, resp.text[:300])
return None
except Exception as exc:
if attempt == 1:
logger.warning("jev: request failed (%s) — retrying once", exc)
await asyncio.sleep(1.0)
continue
logger.error("jev: request failed: %s", exc)
return None
return None
+20 -1
View File
@@ -22,7 +22,7 @@ def _btn(text: str, data: str) -> dict:
# 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")
op = {"quote": "q", "supplier": "s", "material": "m", "project": "p"}.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}")])
@@ -72,3 +72,22 @@ def arrive_keyboard() -> dict:
def cancel_keyboard() -> dict:
return _kb([[_btn("❌ انصراف", "ac:no")]])
def ambiguity_keyboard(prefix: str, amb_id: str) -> dict:
"""Inline buttons for ambiguity sub-message (≤64 bytes callback_data)."""
return _kb([
[_btn("✅ تأیید و اعمال در پیش‌نویس", f"amb:ok:{prefix}:{amb_id}")],
[_btn("❌ تایید نمی‌شود", f"amb:no:{prefix}:{amb_id}")],
])
def ambiguity_candidates_keyboard(prefix: str, amb_id: str, candidates: list[dict]) -> dict:
"""Phase 4: one button per Jev candidate (+ 'new') for an entity-match ambiguity."""
rows = [
[_btn(f"✅ {str(c.get('name') or '')[:40]}", f"amb:pick:{prefix}:{amb_id}:{i}")]
for i, c in enumerate(candidates[:5])
]
rows.append([_btn("🆕 هیچ‌کدام — ایجاد مورد جدید", f"amb:new:{prefix}:{amb_id}")])
return _kb(rows)
+391
View File
@@ -0,0 +1,391 @@
"""Entity matching with Jev (TypeSafe) — phases 3-5 of the final plan.
Mechanics (final plan v5 §2):
- برون‌گروهی: ONE Jev request per FIELD that has mentions (requests run in
parallel; latency ≈ one round-trip regardless of field count).
- درون‌گروهی: inside a field request, ONE `choice` question per mention; every
question rides with the SAME full candidate list (+ a `_none` option).
- درون‌واریانسی: all STT spellings of one entity travel in ONE question, and
the instructions ask the model to treat them as one name.
Routing (thresholds from env; calibrated in phase 6):
conf >= JEV_CONF_EXACT → exact (bind id, no ambiguity sub-message)
JEV_CONF_FLOOR..JEV_CONF_EXACT → ambiguity (candidate buttons sub-message)
below floor / _none → new (existing auto-create path)
Fail-soft: a failed field simply yields no results for its mentions; callers
fall back to the difflib path (`safe_resolve`). With JEV_ENABLED=false nothing
in this module runs (zero regression).
"""
import asyncio
import hashlib
import json
import logging
from app import observability as obs
from app.config import settings
from app.jev import ask as jev_ask
from app.nlu import normalize_text
logger = logging.getLogger("bot.matching")
FIELD_ORDER = ["project", "supplier", "material", "brand", "spec", "unit"]
FIELD_FA = {
"project": "پروژه",
"supplier": "تأمین‌کننده",
"material": "متریال",
"brand": "برند",
"spec": "مشخصه",
"unit": "واحد",
}
# ---------------------------------------------------------------------------
# candidate lists (full lists — no prefilter, per owner decision)
# ---------------------------------------------------------------------------
async def _candidates(resolver, field: str) -> list:
if field == "project":
return await resolver.projects()
if field == "supplier":
return await resolver.all_suppliers()
if field == "material":
return await resolver.all_materials()
if field == "brand":
return await resolver.brands()
if field == "spec":
return await resolver.specs()
if field == "unit":
return await resolver.units()
return []
def _hint(field: str, cand) -> str:
"""Short, token-cheap description shown next to a candidate name."""
if field == "supplier":
extra = cand.extra or {}
bits = [b for b in (extra.get("category_name"), extra.get("location")) if b]
return "، ".join(str(b) for b in bits) if bits else "تأمین‌کننده"
return ""
def _instructions(field: str, variants: list[str]) -> str:
fa = FIELD_FA.get(field, field)
vlist = "»، «".join(v for v in variants if v)
if len(variants) > 1:
return (
f"کاربر در پیام (صوتی) خود این {fa} را ذکر کرده و مدل‌های مختلف STT آن را به شکل‌های "
f"«{vlist}» پیاده کرده‌اند؛ این‌ها چند نگارش از یک نام واحدند. با توجه به بافت جمله و "
f"تشابه آوایی میان این واریانت‌ها، مشخص کن به کدام گزینه از فهرست اشاره دارد. "
f"اگر هیچ‌کدام منطبق نیست یا تردید جدی داری «_none» را انتخاب کن."
)
return (
f"کاربر «{vlist}» را به‌عنوان {fa} ذکر کرده است. این عبارت به کدام گزینه از فهرست اشاره دارد؟ "
f"اگر هیچ‌کدام منطبق نیست یا تردید جدی داری «_none» را انتخاب کن."
)
# ---------------------------------------------------------------------------
from app.fallback_matcher import MatchingFailureError, match_with_deepseek, adjudicate_with_deepseek
# ---------------------------------------------------------------------------
# the per-field fan-out
# ---------------------------------------------------------------------------
async def resolve_mentions(
ctx,
patch: dict,
mentions: list[dict],
trace_id: str | None = None,
raw_input: object = None,
consensus_text: str | None = None,
) -> dict:
"""Run Two-Stage Matching: Jev scorer + DeepSeek final arbiter; DeepSeek crisis fallback if Jev down.
Return a map keyed by normalized variant.
Each value: {"field", "status": exact|ambiguity|new", "variants",
"id"?, "name"?, "extra"?, "confidence"?, "candidates": [...]}
"""
by_field: dict[str, list[dict]] = {}
for m in mentions or []:
field = str((m or {}).get("field") or "").strip()
variants = [str(v).strip() for v in ((m or {}).get("variants") or []) if str(v or "").strip()]
if field in FIELD_ORDER and variants:
by_field.setdefault(field, []).append({"field": field, "variants": variants[:6]})
results: dict[str, dict] = {}
if not by_field:
return results
unhandled_mentions: list[dict] = []
unhandled_fields: set[str] = set()
jev_arbitration_items: list[dict] = []
cands_by_field: dict[str, list] = {}
async def one_field(field: str, ms: list[dict]) -> None:
try:
cands = await _candidates(ctx.resolver, field)
except Exception:
logger.exception("matching: candidate fetch failed for %s", field)
unhandled_mentions.extend(ms)
unhandled_fields.add(field)
return
if not cands:
unhandled_mentions.extend(ms)
unhandled_fields.add(field)
return
cands_by_field[field] = cands
by_name: dict[str, object] = {}
criteria: dict[str, str] = {}
for c in cands:
if c.name in criteria:
continue
criteria[c.name] = _hint(field, c)
by_name[c.name] = c
criteria["_none"] = "هیچ‌کدام از موارد موجود نیست"
capped = ms[: settings.JEV_MAX_QUESTIONS_PER_FIELD]
questions = {
f"mention_{i}": {
"type": "choice",
"instructions": _instructions(field, m["variants"]),
"criteria": criteria,
}
for i, m in enumerate(capped)
}
state = {
"field": field,
"patch_context": json.dumps(patch, ensure_ascii=False, default=str)[:1500],
}
await obs.event(trace_id, "jev.request", field=field, payload={
"n_mentions": len(capped), "n_candidates": len(cands),
})
resp = await jev_ask(state, questions)
await obs.event(trace_id, "jev.result", field=field, ok=resp is not None,
model=(resp or {}).get("model"),
latency_ms=(resp or {}).get("latency_ms"),
payload={"answers": (resp or {}).get("answers"),
"usage": (resp or {}).get("usage")})
if resp is None:
# Observability: log jev.failure event
await obs.event(trace_id, "jev.failure", field=field, payload={
"error": "jev_unavailable_or_error",
"reason": "HTTP error or timeout or no response",
"model": settings.JEV_MODEL,
"n_mentions": len(capped),
})
unhandled_mentions.extend(capped)
unhandled_fields.add(field)
return
answers = resp.get("answers") or {}
decisions = []
for i, m in enumerate(capped):
ans = answers.get(f"mention_{i}") or {}
if not ans:
unhandled_mentions.append(m)
unhandled_fields.add(field)
continue
choice = ans.get("choice")
conf = float(ans.get("confidence") or 0.0)
probs = ans.get("probabilities") or {}
non_zero_probs = {k: v for k, v in probs.items() if float(v or 0.0) > 0.0}
cand = by_name.get(choice) if choice else None
ranked = sorted(
({"id": by_name[n].id, "name": n, "prob": float(p or 0.0)}
for n, p in non_zero_probs.items() if n != "_none" and n in by_name),
key=lambda x: -x["prob"],
)[:6]
# Collect for DeepSeek final arbitration (never write directly to results)
jev_arbitration_items.append({
"field": field,
"variants": m["variants"],
"candidates": cands,
"jev_choice": cand.name if cand else ("_none" if choice == "_none" else None),
"jev_choice_id": cand.id if cand else None,
"jev_confidence": conf,
"jev_probabilities": non_zero_probs,
"jev_ranked": ranked,
})
decisions.append({
"variants": m["variants"],
"jev_choice": cand.name if cand else choice,
"confidence": conf,
"probs_top": [{k: round(v, 3)} for k, v in
sorted(probs.items(), key=lambda kv: -kv[1])[:3]],
})
await obs.event(trace_id, "routing.decision", field=field,
payload={"source": "jev_scored", "mentions": decisions})
if settings.JEV_ENABLED:
await asyncio.gather(*(one_field(f, ms) for f, ms in by_field.items()))
else:
# Jev disabled -> all mentions route to DeepSeek fallback
for f, ms in by_field.items():
unhandled_mentions.extend(ms)
unhandled_fields.add(f)
# 1. Final Arbitration: Pass Jev recommendations + raw user input + DB catalog to DeepSeek
if jev_arbitration_items:
adjudicated_res = await adjudicate_with_deepseek(
raw_input=raw_input,
consensus_text=consensus_text,
items=jev_arbitration_items,
patch_context=patch,
trace_id=trace_id,
)
results.update(adjudicated_res)
# 2. Crisis Fallback: If any mentions were unhandled / failed in Jev, run DeepSeek Crisis Fallback
if unhandled_mentions:
for f in unhandled_fields:
if f not in cands_by_field:
try:
cands_by_field[f] = await _candidates(ctx.resolver, f)
except Exception:
cands_by_field[f] = []
fallback_res = await match_with_deepseek(
raw_input=raw_input,
mentions=unhandled_mentions,
candidates_by_field=cands_by_field,
patch_context=patch,
trace_id=trace_id,
consensus_text=consensus_text,
)
results.update(fallback_res)
return results
def lookup(jev_map: dict | None, name: str | None) -> dict | None:
"""Find the Jev result for a raw name (by normalized variant)."""
if not jev_map or not name:
return None
return jev_map.get(normalize_text(name))
# ---------------------------------------------------------------------------
# writing results into drafts
# ---------------------------------------------------------------------------
def apply_choice(payload: dict, field: str, cand: dict, variants: list[str]) -> None:
"""Bind one chosen candidate (exact match or a user pick) into the payload."""
name, cid = cand.get("name"), cand.get("id")
extra = cand.get("extra") or {}
variants_norm = {normalize_text(v) for v in (variants or []) if v}
if field == "supplier":
payload["supplier_id"] = cid
payload["supplier_name"] = name
for k in ("location", "phone", "contact_person", "category_name"):
if extra.get(k):
payload[f"supplier_{k}"] = extra[k]
elif field == "project":
resolved = payload.setdefault("resolved_projects", {})
for v in (variants or []):
resolved[v] = cid
resolved[name] = cid
if cid not in payload.get("project_ids", []):
payload.setdefault("project_ids", []).append(cid)
if not payload.get("project_id"):
payload["project_id"] = cid
elif field == "material":
for item in payload.get("items") or []:
if normalize_text(item.get("material_name") or "") in variants_norm:
item["material_name"] = name
item["material_id"] = cid
item["material_row"] = {"id": cid,
"canonical_unit_id": extra.get("canonical_unit_id")}
elif field == "unit":
for item in payload.get("items") or []:
if normalize_text(item.get("unit_name") or "") in variants_norm:
item["unit_id"] = cid
item["unit_explicit"] = True
item["unit_name"] = name
if not payload.get("items"):
payload["canonical_unit_id"] = cid
payload["unit_name"] = name
elif field == "brand":
for item in payload.get("items") or []:
if normalize_text(item.get("brand_name") or "") in variants_norm:
item["brand_name"] = name
elif field == "spec":
for item in payload.get("items") or []:
specs = item.get("specs") or []
for i, s in enumerate(specs):
if normalize_text(s) in variants_norm:
specs[i] = name
def apply_exact(payload: dict, res: dict) -> None:
"""Bind one exact Jev match into a draft payload."""
apply_choice(payload, res.get("field"), {"id": res.get("id"), "name": res.get("name"),
"extra": res.get("extra") or {}},
res.get("variants") or [])
def normalize_brand_spec(payload: dict, jev_map: dict | None) -> None:
"""Canonicalize brand/spec strings via exact matches (spec effect per owner:
spelling normalization only)."""
if not jev_map:
return
for item in payload.get("items") or []:
brand = item.get("brand_name")
if brand:
hit = lookup(jev_map, brand)
if hit and hit.get("status") == "exact":
item["brand_name"] = hit["name"]
specs = item.get("specs") or []
for i, s in enumerate(specs):
hit = lookup(jev_map, s)
if hit and hit.get("status") == "exact":
specs[i] = hit["name"]
# ---------------------------------------------------------------------------
# phase 4 — ambiguity sub-messages with candidate buttons
# ---------------------------------------------------------------------------
def _amb_id(field: str, variants: list[str]) -> str:
digest = hashlib.sha1("|".join(sorted(variants)).encode()).hexdigest()[:4]
return f"em_{field[:4]}_{digest}"
def _fa_num(n: int) -> str:
return str(n).translate(str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹"))
def create_ambiguities(payload: dict, jev_map: dict | None) -> None:
"""One `entity_match` ambiguity entry per ambiguous mention (deduped)."""
if not jev_map:
return
ambs = payload.setdefault("ambiguities", [])
seen = {a.get("id") for a in ambs}
unique: dict[int, dict] = {}
for res in jev_map.values():
unique[id(res)] = res # dedupe identical result objects
for res in unique.values():
if res.get("status") != "ambiguity" or not res.get("candidates"):
continue
amb_id = _amb_id(res["field"], res.get("variants") or [])
if amb_id in seen:
continue
cands = res["candidates"][:6]
lines = [f"• {c['name']} — {_fa_num(int(round(c['prob'] * 100)))}٪" for c in cands]
ambs.append({
"id": amb_id,
"kind": "entity_match",
"field": res["field"],
"variants": res.get("variants") or [],
"candidates": [{"id": c["id"], "name": c["name"]} for c in cands],
"title": f"تطبیق {FIELD_FA.get(res['field'], res['field'])}: «{' / '.join(res.get('variants') or [])}»",
"details": "\n".join(lines),
"status": "pending",
})
seen.add(amb_id)
+45 -1
View File
@@ -5,7 +5,7 @@ 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 import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -83,3 +83,47 @@ class BotMeta(Base):
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())
class BotTrace(Base):
"""Observability: one row per incoming interaction (message) — the chain root.
Events of the whole pipeline hang off it (bot_trace_events). Linked to the
draft it produced so later button clicks can be attributed to the same chain.
"""
__tablename__ = "bot_traces"
id: Mapped[str] = _uuid_pk()
chat_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
message_id: Mapped[int | None] = mapped_column(BigInteger)
kind: Mapped[str] = mapped_column(String(8), nullable=False) # text | voice
source: Mapped[str | None] = mapped_column(String(16)) # bale | wizard | api
draft_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False))
draft_kind: Mapped[str | None] = mapped_column(String(16))
committed_quote_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False))
status: Mapped[str] = mapped_column(String(16), nullable=False, default="open") # open|committed|cancelled
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = _timestamptz(onupdate=func.now())
class BotTraceEvent(Base):
"""Observability: append-only pipeline event log (payload schemas by `stage`).
Written ONLY through app.observability (fire-and-forget, _jsonable-safe).
Nothing is ever deleted from here by the bot.
"""
__tablename__ = "bot_trace_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
trace_id: Mapped[str] = mapped_column(
UUID(as_uuid=False), ForeignKey("bot_traces.id", ondelete="CASCADE"), nullable=False
)
stage: Mapped[str] = mapped_column(String(32), nullable=False)
ok: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
latency_ms: Mapped[int | None] = mapped_column(Integer)
model: Mapped[str | None] = mapped_column(String(64))
field: Mapped[str | None] = mapped_column(String(32))
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
created_at: Mapped[datetime] = _timestamptz()
+119 -26
View File
@@ -4,16 +4,23 @@ 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 hashlib
import json
import logging
import re
import httpx
from app import observability as obs
from app.config import Settings
logger = logging.getLogger("bot.nlu")
class NLUFailureError(Exception):
"""Raised when NLU understanding fails due to LLM / network failure."""
pass
PERSIAN_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
@@ -23,6 +30,23 @@ def normalize_digits(text: str) -> str:
ZWNJ = "\u200c"
MENTION_FIELDS = {"project", "supplier", "material", "brand", "spec", "unit"}
def sanitize_mentions(raw) -> list[dict]:
"""Keep only valid {field, variants} mention objects from the LLM output."""
out: list[dict] = []
if not isinstance(raw, list):
return out
for m in raw:
if not isinstance(m, dict):
continue
field = str(m.get("field") or "").strip()
variants = [str(v).strip() for v in (m.get("variants") or []) if str(v or "").strip()]
if field in MENTION_FIELDS and variants:
out.append({"field": field, "variants": variants[:6]})
return out
def normalize_text(text: str) -> str:
"""Normalize Persian text for fuzzy matching (ZWNJ, arabic ya/ka, spaces)."""
@@ -40,6 +64,7 @@ INTENTS = [
"show_draft", # «وضعیت پیش‌نویس؟»
"add_supplier",
"add_material",
"add_project",
"status_query", # پرس‌وجو: استعلام‌های در انتظار / به‌زودی منقضی / ...
"accept_quote", # پذیرش یک استعلام
"arrive_items", # اعلام رسیدن اقلام
@@ -47,36 +72,65 @@ INTENTS = [
"unknown",
]
SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی. ورودی تو پیام صوتی/متنی کاربر است.
یک JSON و فقط JSON برگردان (بدون توضیح اضافه) با این شکل:
SYSTEM_PROMPT = """تو دستیار هوشمند تدارکات یک سیستم ERP ساختمانی و صنعتی در ایران هستی.
پیام‌های صوتی کاربران به زبان فارسی، با گویش و لهجه اصفهانی و با اصطلاحات محاوره‌ای کارگاهی و بازار بیان شده‌اند که نسخه‌های متنی متعددی توسط موتورهای تبدیل گفتار به متن (STT مانند Whisper و Vosk) از آن‌ها تولید شده است.
وظیفه تو تحلیل پیام، حل خطاهای شنیداری ناشی از لهجه و گویش، تولید متن اجماعی پالایش‌شده و استخراج دقیق داده‌های تدارکاتی است.
یک JSON و فقط JSON برگردان (بدون هرگونه متن، مارک‌داون یا توضیح اضافه) با این ساختار دقیق:
{
"consensus_text": "متن تجمیع‌شده و اصلاح‌شده کاربر (فارسی روان و بدون غلط شنیداری)",
"consensus_text": "بیانیه رسمی، پالایش‌شده، فشرده و بدون تکیه‌کلام از تقاضای تدارکات کاربر",
"intent": یکی از {intents},
"patch": {شکل partial هر فعالیتی که کاربر داده},
"entity_mentions": [{"field": "project|supplier|material|brand|spec|unit", "variants": ["همه‌ی نگارش‌ها"]}],
"confidence": 0.0..1.0
}
قوانین:
- اگر ورودی شامل چند نسخه تبدیل گفتار به متن (STT) از یک پیام صوتی کاربر است:
۱. این نسخه‌ها از چند مدل مختلف برای افزایش دقت به دست آمده‌اند. آن‌ها را با هم مقایسه و مقابله کن.
۲. خطاهای شنیداری هر نسخه را بر مبنای منطق تدارکات (نام کالاها، برندها، مشخصات فنی و واحدهای اندازه‌گیری) تصحیح و حل تعارض کن.
۳. متن نهایی اصلاح و تجمیع‌شده را در فیلد consensus_text قرار بده.
۴. استخراج قصد (intent) و موجودیت‌ها (patch) را بر اساس همین متن دقیق انجام بده.
- اگر ورودی یک متن عادی است، همان متن را در consensus_text قرار بده.
قواعد حیاتی:
۱. قواعد آوایی و دستوری لهجه اصفهانی در رمزگشایی خطاهای STT:
- شناسه مفعولی پیوسته: پسوند «ـشا» یا «ش ها» در پایان کلمات (مانند پولشا، بارشا) به معنای «آن را / آن‌ها را» است.
- شناسه دوم شخص جمع (ت به د): پسوندهای «ـِدون»، «برادون»، «برهدون» به معنای «برایتان / به شما» هستند.
- تخمین زمانی و مقداری: کلمه «اینا» پس از اعداد یا زمان (مثلاً «۱۰ روز اینا»، «۴۰ تا اینا») قید تخمین و به معنای «حدوداً» است و نباید با کلمات نامربوط (مانند روزنامه) اشتباه گرفته شود.
- حذف حشوهای مکالمه: اصطلاحات پرکننده («عرض به خدمتتون»، «پامون درمیاد»، «دونه‌ای اینا»، «شبت بخیر»، «همین») باید کاملاً از consensus_text پالایش شوند.
- حل تعارض نام‌ها در مدل‌های STT: در صورت تفاوت املایی نام تأمین‌کننده یا کالا بین چند مدل، اولویت با نگارشی است که با واژگان معتبر فارسی، نام‌های خانوادگی استاندارد و اقلام صنعتی/ساختمانی تطابق دارد.
۲. ساخت فیلد consensus_text:
- متن consensus_text نباید انشا یا نقل‌قول داستانی پر از تعارفات و حواشی باشد.
- کلیه احوالپرسی‌ها، تعارفات و تکیه‌کلام‌ها را کاملاً حذف کن.
- این متن باید یک «بیانیه رسمی، تمیز و ساختاریافته تدارکاتی» باشد که شامل: نوع اقدام (استعلام/خرید)، اقلام، مقادیر، مشخصات، قیمت‌ها، نام تأمین‌کننده، نام پروژه‌ها، مهلت اعتبار و خلاصه نحوه پرداخت باشد.
مثال سبک بیانیه رسمی: «استعلام خرید ۱۲۰ شاخه لوله پلیکا ۶ اینچ (فی ۴۵۰,۰۰۰ تومان) از بازرگانی البرز برای پروژه پردیس. مهلت اعتبار پیش‌فاکتور: ۵ روز. شرایط پرداخت: نقد با ۵٪ تخفیف.»
۳. تفکیک وظایف محاسباتی با defaults.py:
- تو فقط ساختار مفهومی را در patch استخراج می‌کنی؛ محاسبات عددی اقساط و تاریخ‌های تقویم شمسی بر عهده توابع سیستم است.
- برای اقساط: تعداد اقساط را در installments_count استخراج کن (مثلاً ۴).
- برای پرداخت نقدی: درصد تخفیف را در discount_percent استخراج کن (مثلاً ۵).
- برای مهلت اعتبار: تعداد روز را در expiry_days بگذار (مثلاً ۵).
۴. قوانین استخراج پروژه‌ها و اقلام:
- پروژه‌ها: اگر کاربر چند پروژه را ذکر کرد (مانند «برای پردیس و کارگاه مرکزی»)، تمام نام‌ها را عیناً در آرایه project_names قرار بده: ["پردیس", "کارگاه مرکزی"]. پروژه‌ها را با برند اشتباه نگیر.
- اقلام: material_name فقط نام کلی کالا (مانند «لوله پلیکا» یا «میلگرد»)؛ مشخصات فنی (مانند «۶ اینچ» یا «آجدار») در آرایه specs؛ تعداد در quantity؛ واحد در unit_name؛ و قیمت واحد در unit_price (عددی خالص، مثلاً ۴۵۰ هزار = 450000).
۵. قوانین عمومی قصدها (Intents):
- اگر پیام کاربر اطلاعات جدیدی به پیش‌نویسِ باز اضافه می‌کند یا اصلاح می‌کند → 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}] قرار بده.
۶. استخراج موجودیت‌های قابل تطبیق (entity_mentions):
- فیلدهای مجاز: "project" | "supplier" | "material" | "brand" | "spec" | "unit"
- هر نام کالا/تأمین‌کننده/پروژه/برند/واحد و هر مشخصه که در پیام آمده، یک عضو جداگانه است.
- اگر در نسخه‌های مختلف STT همان نام با املاهای متفاوت پیاده شده (مثل «فولاد گستر»، «پولاد گستر»)،
همه را در variants همان یک عضو بگذار — نه چند عضو جداگانه.
- برای پیام متنی معمولی، variants یک عضو تک‌عنصر است.
- مشخصه (spec) و برند (brand) جدا از متریال و جدا از هم استخراج شوند.
- وظیفه‌ی تطبیق با موجودیت‌های سیستم بر عهده‌ی تو نیست؛ هیچ نامی را به نزدیک‌ترین نام موجود «حدس» نزن.
شکل 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}]}
{"supplier_name": str, "project_name": str, "project_names": [str], "is_official": bool, "notes": str, "expiry_days": int, "expiry_date": "YYYY-MM-DD",
"items": [{"material_name": str, "brand_name": str, "specs": [str], "quantity": number, "unit_name": str, "unit_price": number, "project_name": str, "notes": str}],
"payment_terms": [{"label": str, "type": "installments|cash_today|deferred_check|other", "installments_count": int, "is_today": bool, "discount_percent": number, "discount_type": "none|percent|fixed", "discount_value": number, "milestones": [{"due_date": "YYYY-MM-DD", "amount": number, "note": str}], "notes": str}]}
شکل patch برای intent="add_supplier":
{"supplier": {"name": str, "category_name": str, "phone": str, "location": str, "contact_person": str}}
@@ -84,6 +138,9 @@ SYSTEM_PROMPT = """تو دستیار تدارکات یک ERP فارسی هستی
شکل patch برای intent="add_material":
{"material": {"name": str, "unit_name": str, "topic": str}}
شکل patch برای intent="add_project":
{"project": {"name": str, "description": str}}
شکل patch برای intent="status_query":
{"query": {"kind": "pending|accepted|arrived|expiring|recent", "supplier_name": str, "project_name": str, "days": int}}
@@ -120,13 +177,11 @@ def _extract_json(raw: str) -> dict | None:
return None
async def understand(settings: Settings, text: str | list[dict], draft_summary: str | None = None) -> dict:
async def understand(settings: Settings, text: str | list[dict], draft_summary: str | None = None,
trace_id: str | None = None) -> dict:
"""Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises."""
fallback = {"consensus_text": "", "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
fallback = {"consensus_text": "", "intent": "unknown", "patch": {},
"entity_mentions": [], "confidence": 0.0}
if isinstance(text, list):
first_text = text[0].get("text") if text else ""
lines = ["پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیاده‌سازی شده است:"]
@@ -136,12 +191,34 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
lines.append(f"- نسخه {idx} ({name}): «{t}»")
lines.append("\nبا تلفیق این نسخه‌ها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز.")
user_content = "\n".join(lines)
elif isinstance(text, str) and ("مدل 1" in text or "مدل ۱" in text) and ("مدل 2" in text or "مدل ۲" in text):
user_content = (
"پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیاده‌سازی شده است:\n"
+ normalize_digits(text)
+ "\n\nبا تلفیق این نسخه‌ها و تصحیح خطاهای شنیداری، پیام را تحلیل کن و consensus_text را به همراه intent و patch بساز."
)
first_text = text
else:
user_content = normalize_digits(str(text))
first_text = str(text)
fallback["consensus_text"] = first_text
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
logger.warning("AI provider not configured; cannot understand message")
return fallback
system_prompt = _build_system_prompt(draft_summary)
prompt_hash = hashlib.sha256(
f"{settings.OBS_PROMPT_VERSION}|{system_prompt}".encode()
).hexdigest()[:16]
await obs.event(trace_id, "nlu.request", model=settings.LLM_MODEL, payload={
"is_multi": isinstance(text, list),
"content": user_content[:2000],
"prompt_hash": prompt_hash,
"prompt_version": settings.OBS_PROMPT_VERSION,
})
try:
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
resp = await client.post(
@@ -150,7 +227,7 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
json={
"model": settings.LLM_MODEL,
"messages": [
{"role": "system", "content": _build_system_prompt(draft_summary)},
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
"temperature": 0.1,
@@ -159,16 +236,32 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
)
if resp.status_code != 200:
logger.error("LLM HTTP %s: %s", resp.status_code, resp.text[:300])
return fallback
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
payload={"error": f"HTTP {resp.status_code}", "body": resp.text[:300]})
raise NLUFailureError(f"HTTP {resp.status_code}")
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
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
payload={"error": "unparseable", "raw": content[:500]})
raise NLUFailureError("unparseable LLM output")
parsed.setdefault("patch", {})
parsed["entity_mentions"] = sanitize_mentions(parsed.get("entity_mentions"))
if not parsed.get("consensus_text"):
parsed["consensus_text"] = first_text
await obs.event(trace_id, "nlu.result", ok=True, model=settings.LLM_MODEL, payload={
"intent": parsed.get("intent"),
"confidence": parsed.get("confidence"),
"patch": parsed.get("patch"),
"consensus_text": parsed.get("consensus_text"),
"entity_mentions": parsed.get("entity_mentions"),
})
return parsed
except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError):
except Exception as exc:
if isinstance(exc, NLUFailureError):
raise
logger.exception("NLU failure")
return fallback
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
payload={"error": "exception", "details": str(exc)})
raise NLUFailureError(f"NLU exception: {exc}") from exc
+223
View File
@@ -0,0 +1,223 @@
"""Silent observer — records the whole bot pipeline into Postgres (phase 1).
Design rules (final plan v5):
- DB is the single source of truth; no external tools, no dashboards here.
- ZERO extra LLM tokens: we only persist values that already exist in code.
- Never raises, never blocks the reply path (fire-and-forget like runtime._log).
- Never deletes anything (no retention/cleanup tasks exist by policy).
- `_jsonable` guarantees JSONB-safety for UUID/datetime/Decimal/... payloads.
Stages captured (see plan §4.3): input.text, input.voice, stt.run, stt.result,
nlu.request, nlu.result, entities.extracted, jev.request, jev.result,
routing.decision, ambiguity.sent, ambiguity.user_choice, user_choice.pick,
draft.render, draft.edit, draft.commit, draft.cancel.
"""
import asyncio
import logging
import uuid
from datetime import date, datetime, time
from decimal import Decimal
from enum import Enum
from pathlib import Path
from sqlalchemy import select, update
from app.config import settings
from app.db import SessionLocal
from app.models import BotTrace, BotTraceEvent
logger = logging.getLogger("bot.obs")
_bg_tasks: set = set()
def _fire(coro) -> None:
"""Schedule a coroutine without awaiting; keep a ref so it is not GC'd."""
try:
task = asyncio.get_running_loop().create_task(coro)
_bg_tasks.add(task)
task.add_done_callback(_bg_tasks.discard)
except RuntimeError:
# No running loop (should not happen inside the bot) — drop silently but log.
logger.warning("obs: no running loop; dropped %r", coro)
def _jsonable(obj):
"""Recursively make any Python object JSONB-safe (UUID/datetime/Decimal/…)."""
if obj is None or isinstance(obj, (bool, int, float, str)):
return obj
if isinstance(obj, (uuid.UUID, Path)):
return str(obj)
if isinstance(obj, (datetime, date, time)):
return obj.isoformat()
if isinstance(obj, Decimal):
return str(obj) # دقت حفظ می‌شود؛ در تحلیل با ::numeric قابل تبدیل است
if isinstance(obj, Enum):
return _jsonable(obj.value)
if isinstance(obj, dict):
return {str(k): _jsonable(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple, set, frozenset)):
return [_jsonable(v) for v in obj]
if hasattr(obj, "model_dump"): # pydantic v2
return _jsonable(obj.model_dump())
if hasattr(obj, "__dataclass_fields__"):
return _jsonable(vars(obj))
return str(obj) # تور نهایی — هرگز raise نمی‌کند
# ---------------------------------------------------------------------------
# traces
# ---------------------------------------------------------------------------
async def trace_start(chat_id: int, message_id: int | None, kind: str,
source: str = "bale") -> str | None:
"""Open a new trace; returns its id (str) or None on failure."""
try:
async with SessionLocal() as s:
t = BotTrace(chat_id=chat_id, message_id=message_id, kind=kind, source=source)
s.add(t)
await s.commit()
return str(t.id)
except Exception:
logger.exception("obs: trace_start failed")
return None
async def link_draft(trace_id: str | None, draft_id, draft_kind: str | None) -> None:
"""Attach a draft to its originating trace (so later button clicks can find it)."""
if not trace_id:
return
try:
async with SessionLocal() as s:
await s.execute(
update(BotTrace)
.where(BotTrace.id == trace_id)
.values(draft_id=str(draft_id), draft_kind=draft_kind)
)
await s.commit()
except Exception:
logger.exception("obs: link_draft failed")
async def latest_trace_for_draft(draft_id) -> str | None:
"""Newest trace that produced this draft — used by button/render flows."""
try:
async with SessionLocal() as s:
row = (await s.execute(
select(BotTrace.id)
.where(BotTrace.draft_id == str(draft_id))
.order_by(BotTrace.created_at.desc())
.limit(1)
)).scalar()
return str(row) if row else None
except Exception:
logger.exception("obs: latest_trace_for_draft failed")
return None
async def set_trace_status(trace_id: str | None, status: str,
committed_quote_id: str | None = None) -> None:
if not trace_id:
return
try:
async with SessionLocal() as s:
values = {"status": status}
if committed_quote_id:
values["committed_quote_id"] = str(committed_quote_id)
await s.execute(update(BotTrace).where(BotTrace.id == trace_id).values(**values))
await s.commit()
except Exception:
logger.exception("obs: set_trace_status failed")
# ---------------------------------------------------------------------------
# events
# ---------------------------------------------------------------------------
async def event(trace_id, stage: str, *, ok: bool = True, latency_ms: int | None = None,
model: str | None = None, field: str | None = None,
payload: dict | None = None) -> None:
"""Append one pipeline event. Never raises; failures are loud in the log."""
if not trace_id or not settings.OBS_ENABLED:
return
try:
async with SessionLocal() as s:
s.add(BotTraceEvent(
trace_id=str(trace_id), stage=stage, ok=ok, latency_ms=latency_ms,
model=model, field=field, payload=_jsonable(payload or {}),
))
await s.commit()
except Exception:
logger.exception("obs: event %s failed", stage) # خطای خاموش نمی‌ماند
async def log_resolution(trace_id, field: str, mention, status: str, candidates: list) -> None:
"""Record one matching attempt (difflib baseline / fallback paths)."""
await event(trace_id, "routing.decision", field=field, payload={
"source": "difflib",
"mention": mention,
"status": status,
"candidates": candidates[:4],
})
class span:
"""with span('stt.run', trace_id=t, provider='whisper'): ... → one DB event with latency/ok."""
def __init__(self, name: str, trace_id: str | None = None, **attrs):
self.name, self.trace_id, self.attrs = name, trace_id, attrs
self._t0 = None
def __enter__(self):
import time as _t
self._t0 = _t.perf_counter_ns()
return self
def __exit__(self, exc_type, exc, tb):
import time as _t
try:
_fire(event(
self.trace_id, self.name, ok=exc_type is None,
latency_ms=int((_t.perf_counter_ns() - self._t0) / 1e6),
model=self.attrs.get("model"), field=self.attrs.get("field"),
payload=self.attrs,
))
except Exception:
logger.exception("obs: span %s failed", self.name)
return False # استثنا هرگز بلعیده نمی‌شود
# ---------------------------------------------------------------------------
# voice persistence (never deleted)
# ---------------------------------------------------------------------------
def save_voice(trace_id: str | None, message_id: int, content: bytes,
ext: str = ".ogg") -> str | None:
"""Persist a voice note under OBS_VOICE_DIR; creates the day folder safely."""
try:
day = datetime.utcnow().strftime("%Y%m%d")
base = Path(settings.OBS_VOICE_DIR) / day
base.mkdir(parents=True, exist_ok=True) # الزام سخت‌سازی: مسیر همیشه ساخته می‌شود
path = base / f"{trace_id}_{message_id}{ext}"
path.write_bytes(content)
return str(path)
except Exception:
logger.warning("obs: save_voice failed for trace %s", trace_id, exc_info=True)
return None
def save_enhanced_voice(trace_id: str | None, message_id: int, content: bytes,
ext: str = ".ogg") -> str | None:
"""Persist an enhanced voice note under OBS_VOICE_DIR/{YYYYMMDD}/{trace_id}_{message_id}_enhanced{ext}."""
try:
day = datetime.utcnow().strftime("%Y%m%d")
base = Path(settings.OBS_VOICE_DIR) / day
base.mkdir(parents=True, exist_ok=True)
if ext and not ext.startswith("."):
ext = f".{ext}"
path = base / f"{trace_id}_{message_id}_enhanced{ext or '.ogg'}"
path.write_bytes(content)
return str(path)
except Exception:
logger.warning("obs: save_enhanced_voice failed for trace %s", trace_id, exc_info=True)
return None
+114 -11
View File
@@ -3,7 +3,6 @@
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
@@ -37,14 +36,18 @@ def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]
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))
elif q in name and len(q) >= 3:
ratio = 0.85 + 0.15 * (len(q) / len(name))
elif name in q and len(name) >= 3:
ratio = 0.85 + 0.15 * (len(name) / len(q))
else:
ratio = 0.0
if ratio >= 0.72:
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]
return [item for ratio, item in scored[:limit]]
async def _db_units() -> list[Candidate]:
@@ -149,6 +152,43 @@ async def _db_materials(q: str | None = None) -> list[Candidate]:
return []
async def _db_brands() -> list[Candidate]:
try:
async with SessionLocal() as s:
rows = (await s.execute(text("SELECT id, name FROM brands"))).mappings().all()
return [Candidate(id=str(b["id"]), name=b.get("name") or str(b["id"])) for b in rows]
except Exception:
return []
async def _db_specs() -> list[Candidate]:
"""Distinct spec values: EAV strings from the catalog + specs used in recent drafts."""
values: dict[str, None] = {}
try:
async with SessionLocal() as s:
rows = (await s.execute(text(
"SELECT DISTINCT value_string FROM material_attribute_values "
"WHERE value_string IS NOT NULL AND btrim(value_string) <> '' LIMIT 500"
))).scalars().all()
for v in rows:
values.setdefault(str(v), None)
except Exception:
pass
try:
async with SessionLocal() as s:
rows = (await s.execute(text(
"SELECT DISTINCT spec FROM bot_drafts d, "
"jsonb_array_elements(COALESCE(d.payload->'items', '[]'::jsonb)) it, "
"jsonb_array_elements_text(COALESCE(it->'specs', '[]'::jsonb)) spec "
"WHERE btrim(spec) <> '' LIMIT 300"
))).scalars().all()
for v in rows:
values.setdefault(str(v), None)
except Exception:
pass
return [Candidate(id=v, name=v) for v in values]
class Resolver:
"""Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand)."""
@@ -232,6 +272,74 @@ class Resolver:
items = await _db_materials(q=q)
return _rank(q, items) if q else items
# ------------------------------------------------- full lists (Jev, v5)
async def _fetch_all(self, fetch_page, max_pages: int = 5) -> list[dict]:
"""Page through a paginated ERP list endpoint (page_size=100)."""
out: list[dict] = []
for page in range(1, max_pages + 1):
rows = await fetch_page(page)
out += rows
if len(rows) < 100:
break
return out
async def all_suppliers(self) -> list[Candidate]:
async def fetch():
try:
rows = await self._fetch_all(
lambda page: self._api.list_suppliers(page=page, page_size=100)
)
return [
Candidate(
id=str(s["id"]),
name=s.get("name") or str(s["id"]),
extra={
"location": s.get("location"),
"phone": s.get("phone"),
"contact_person": s.get("contact_person"),
"category_name": (s.get("category") or {}).get("name")
if isinstance(s.get("category"), dict) else s.get("category_name"),
},
)
for s in rows
]
except Exception:
return await _db_suppliers()
return await self._cached("all_suppliers", fetch)
async def all_materials(self) -> list[Candidate]:
async def fetch():
try:
rows = await self._fetch_all(
lambda page: self._api.list_materials(page=page, page_size=100)
)
return [
Candidate(
id=str(m["id"]),
name=m.get("name") or str(m["id"]),
extra={"canonical_unit_id": m.get("canonical_unit_id")},
)
for m in rows
]
except Exception:
return await _db_materials()
return await self._cached("all_materials", fetch)
async def brands(self) -> list[Candidate]:
async def fetch():
try:
rows = await self._fetch_all(
lambda page: self._api.list_brands(page=page, page_size=100)
)
return [Candidate(id=str(b["id"]), name=b.get("name") or str(b["id"])) for b in rows]
except Exception:
return await _db_brands()
return await self._cached("brands", fetch)
async def specs(self) -> list[Candidate]:
return await self._cached("specs", _db_specs)
# ------------------------------------------------------------ resolutions
async def resolve(self, kind: str, name: str | None) -> Resolution:
@@ -256,11 +364,6 @@ class Resolver:
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]:
+43 -11
View File
@@ -9,10 +9,12 @@ import asyncio
import logging
import subprocess
import tempfile
import time
from pathlib import Path
import httpx
from app import observability as obs
from app.config import Settings
logger = logging.getLogger("bot.stt")
@@ -37,7 +39,8 @@ def _to_mp3(raw: bytes, suffix: str) -> bytes:
return dst.read_bytes()
async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict | None:
async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict:
"""Call one provider; always returns {"name","ok","text"/"error","model","latency_ms"}."""
name = provider.get("name") or "stt"
base_url = (provider.get("base_url") or "").rstrip("/")
api_key = provider.get("api_key") or ""
@@ -46,6 +49,7 @@ async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_
timeout = float(provider.get("timeout") or _STT_TIMEOUT)
url = f"{base_url}/audio/transcriptions"
t0 = time.perf_counter()
try:
resp = await client.post(
url,
@@ -54,49 +58,77 @@ async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_
data={"model": model, "language": language},
timeout=timeout,
)
ms = int((time.perf_counter() - t0) * 1000)
if resp.status_code == 200:
txt = str((resp.json() or {}).get("text") or "").strip()
if txt:
logger.info("STT provider %s succeeded: %r", name, txt[:60])
return {"name": name, "text": txt, "model": model}
else:
logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200])
return {"name": name, "ok": True, "text": txt, "model": model, "latency_ms": ms}
return {"name": name, "ok": False, "error": "empty transcript (HTTP 200)",
"model": model, "latency_ms": ms}
logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200])
return {"name": name, "ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}",
"model": model, "latency_ms": ms}
except Exception as exc:
logger.warning("STT provider %s failed: %s", name, exc)
return None
return {"name": name, "ok": False, "error": str(exc)[:300],
"model": model, "latency_ms": int((time.perf_counter() - t0) * 1000)}
async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None,
filename: str | None = None) -> list[dict]:
filename: str | None = None, trace_id: str | None = None,
message_id: int | None = None) -> list[dict]:
"""Transcribe audio with all configured STT providers in parallel.
Returns a list of candidate dicts: [{"name": str, "text": str, "model": str}, ...]
Empty list if all failed or silence.
Empty list if all failed or silence. Every provider outcome (success AND
failure) is recorded in Observability when trace_id is given.
"""
providers = settings.stt_providers
if not providers:
raise RuntimeError("No STT providers are configured (PROCUREMENT_BOT_STT_*)")
# Audio enhancement / denoising step (pluggable, fail-soft, zero-transcode by default)
from app.audio_enhancer import enhance_voice
audio, _ = await enhance_voice(
settings, audio, mime_type=mime_type, filename=filename,
trace_id=trace_id, message_id=message_id,
)
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)
await obs.event(trace_id, "stt.run", payload={
"providers": [p.get("name") or "stt" for p in providers],
"audio_bytes": len(converted),
})
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
tasks = [_call_single_stt(client, p, converted) for p in providers]
results = await asyncio.gather(*tasks, return_exceptions=True)
valid_transcripts: list[dict] = []
for r in results:
if isinstance(r, dict) and r.get("text"):
valid_transcripts.append(r)
if not isinstance(r, dict):
continue
await obs.event(trace_id, "stt.result", ok=bool(r.get("ok")),
latency_ms=r.get("latency_ms"), model=r.get("model"),
payload={"provider": r.get("name"), "text": r.get("text"),
"error": r.get("error")})
if r.get("ok") and r.get("text"):
valid_transcripts.append({"name": r["name"], "text": r["text"], "model": r.get("model")})
return valid_transcripts
async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None,
filename: str | None = None) -> str:
filename: str | None = None, trace_id: str | None = None,
message_id: int | None = None) -> str:
"""Return the Persian transcript for a voice note (empty string on silence)."""
transcripts = await transcribe_multi(settings, audio, mime_type=mime_type, filename=filename)
transcripts = await transcribe_multi(settings, audio, mime_type=mime_type,
filename=filename, trace_id=trace_id,
message_id=message_id)
return transcripts[0]["text"] if transcripts else ""
+14
View File
@@ -8,6 +8,7 @@ If a draft was updated in the Wizard:
import asyncio
import logging
from app import drafts as draft_store
from app import observability as obs
from app.handlers import quote
logger = logging.getLogger("bot.sync")
@@ -40,6 +41,19 @@ async def sync_drafts_loop(ctx) -> None:
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:
import hashlib
import json as _json
await obs.event(
await obs.latest_trace_for_draft(draft.id), "draft.edit",
payload={
"source": "wizard",
"kind": draft.kind,
"payload_hash": hashlib.sha1(
_json.dumps(draft.payload, ensure_ascii=False,
sort_keys=True, default=str).encode()
).hexdigest()[:16],
},
)
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)
+55 -3
View File
@@ -210,6 +210,20 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
if cat_name:
lines.append(f"\n🏷️ موضوع: {cat_name}")
# 🏗️ پروژه(ها)
p_names = payload.get("project_names") or ([payload["project_name"]] if payload.get("project_name") else [])
resolved_projects = payload.get("resolved_projects") or {}
if p_names:
if len(p_names) == 1:
p = p_names[0]
badge = " ✅" if (p in resolved_projects or payload.get("project_id")) else " ✨ (پروژه جدید)"
lines.append(f"\n🏗️ پروژه: {p}{badge}")
else:
lines.append(f"\n🏗️ پروژه‌ها ({fa_num(len(p_names))} مورد):")
for p in p_names:
badge = " ✅" if p in resolved_projects else " ✨ (پروژه جدید)"
lines.append(f"▫️ {p}{badge}")
# 📦 اقلام
items = payload.get("items") or []
total_amount = 0.0
@@ -232,7 +246,10 @@ def quote_review_text(payload: dict, missing: list[dict], status_note: str | Non
# Project
item_proj = it.get("project_name") or default_proj
if item_proj:
lines.append(f" 🏗️ {item_proj}")
p_badge = ""
if item_proj not in resolved_projects and not (payload.get("project_id") and len(p_names) <= 1):
p_badge = " ✨ (پروژه جدید)"
lines.append(f" 🏗️ {item_proj}{p_badge}")
else:
lines.append(" 🏗️ پروژه مشخص نشده")
@@ -368,7 +385,7 @@ def material_review_text(payload: dict, missing: list[dict]) -> str:
return "\n".join(lines)
def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -> str:
def quote_committed_report(created: dict, payload: dict, remaining: list[str], newly_created: dict | None = None) -> str:
lines = [f"✅ ثبت شد — استعلام #{esc(created.get('id'))}"]
sup = (created.get("supplier") or {}).get("name") or payload.get("supplier_name")
if sup:
@@ -379,8 +396,21 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -
sheets = created.get("termsheets") or []
if sheets:
lines.append(f"شرایط پرداخت: {len(sheets)}")
if payload.get("project_id"):
if payload.get("project_id") or payload.get("project_ids"):
lines.append("پروژه: پیوست شد")
if newly_created:
new_lines = []
if newly_created.get("supplier"):
new_lines.append(f"🏢 تأمین‌کننده جدید ثبت شد: {esc(newly_created['supplier'])}")
if newly_created.get("projects"):
new_lines.append(f"🏗️ پروژه(های) جدید ثبت شد: {esc('، '.join(newly_created['projects']))}")
if newly_created.get("materials"):
new_lines.append(f"📦 کالای جدید ثبت شد: {esc('، '.join(newly_created['materials']))}")
if new_lines:
lines.append("\n✨ موارد جدید ایجادشده:")
lines += new_lines
if remaining:
lines.append("\nخالی مانده‌ها (بعداً می‌توانی در برنامه تکمیل کنی):")
lines += [f"⚠️ {esc(r)}" for r in remaining]
@@ -388,6 +418,19 @@ def quote_committed_report(created: dict, payload: dict, remaining: list[str]) -
return "\n".join(lines)
def project_review_text(payload: dict, missing: list[dict]) -> str:
lines = ["🏗️ پیش‌نویس پروژه جدید", f"نام: {esc(payload.get('name'))}"]
if payload.get("description"):
lines.append(f"توضیحات: {esc(payload['description'])}")
lines.append("\nوضعیت فیلدها:")
lines.append(missing_block(missing))
return "\n".join(lines)
def project_committed_report(created: dict) -> str:
return f"✅ پروژه ثبت شد — {esc(created.get('name'))}"
def supplier_committed_report(created: dict) -> str:
return (
f"✅ تأمین‌کننده ثبت شد — {esc(created.get('name'))}\n"
@@ -430,3 +473,12 @@ def status_report(kind: str, quotes: list[dict], extra_note: str | None = None)
lines.append(extra_note)
lines += [f"• {quote_line(q)}" for q in quotes[:10]]
return "\n".join(lines)
def ambiguity_message_text(title: str, details: str) -> str:
return (
f"🤔 آیا نظر شما این بود؟\n"
f"موضوع: {title}\n\n"
f"{details}"
)
+1
View File
@@ -0,0 +1 @@
[]
+86
View File
@@ -0,0 +1,86 @@
# استقرار و پایش — فاز ۷ (Jev + Observability)
وضعیت به‌روزشده: 2026-10-12 · برنچ: `prod-v7` · **هیچ پوشی انجام نمی‌شود تا مالک دستور بدهد.**
## الف) وضعیت فعلی
- بات همین حالا **روی استک لوکال** اجرا می‌شود (long-polling بله؛ بدون پورت ورودی) و
`JEV_ENABLED` + `OBS_ENABLED` روی آن فعال است.
- استقرار بات روی سرور یک **تصمیم جدا** است (نه بخشی از مرج تیم). تا آن تصمیم، بات لوکال می‌ماند.
- ⚠️ **قانون یک‌پولره:** Bale فقط اجازه‌ی یک مصرف‌کننده‌ی `getUpdates` می‌دهد. اگر باتِ سرور و
باتِ لوکال هم‌زمان روشن باشند، پیام‌ها بین آن‌ها گم/تکراری می‌شوند. **قبل از روشن‌کردن بات سرور،
بات لوکال باید خاموش شود.**
## ب) پیش‌نیازهای سرور (وقتی تصمیم گرفته شد)
کلیدها و تنظیم‌های بات (در گیت نیستند — از طریق Coolify env یا فایل `bot/.env` روی سرور):
| متغیر | مقدار |
|---|---|
| `PROCUREMENT_BOT_BOT_TOKEN` | توکن بله (rotated — بخش امنیتی پایین را ببین) |
| `PROCUREMENT_BOT_AI_API_KEY` | کلید DeepSeek |
| `PROCUREMENT_BOT_TYPESAFE_API_KEY` | کلید Jev |
| `PROCUREMENT_BOT_API_BASE_URL` | `http://api:8000/api/v1` |
| `PROCUREMENT_BOT_DATABASE_URL` | `postgresql+asyncpg://postgres:***@postgres:5432/procurement` |
| `PROCUREMENT_BOT_JEV_CONF_EXACT` / `_FLOOR` | اختیاری — پیش‌فرض کالیبره: `0.85` / `0.65` |
| `PROCUREMENT_BOT_OBS_PROMPT_VERSION` | `nlu-v2-entity-mentions` (برای prompt_hash) |
ولوم‌ها: `bot/app` (کد)، `bot/tools` (ابزارها/pایش)، `bot/data` (**صدای خام — آرشیو، نه حذف**)،
`bot/.env:ro`.
نکته: **mount جدید فقط با recreate اعمال می‌شود، نه restart** → `docker compose up -d --force-recreate bot`.
بلوک آماده‌ی سرویس بات برای کامپوز دیپلوی Coolify (فقط «در صورت» تصمیم — عمداً به فایل کامپوز
اضافه نشده چون سرویس‌شدن خودکار = خطر دو‌پولری و تغییر رفتار سرور):
```yaml
bot:
image: v15-bot:latest
build: { context: ./bot, dockerfile: Dockerfile }
restart: unless-stopped
mem_limit: 512m
volumes:
- ./bot/data:/srv/bot/data
- ./bot/tools:/srv/bot/tools
environment:
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN}
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY}
PROCUREMENT_BOT_TYPESAFE_API_KEY: ${TYPESAFE_API_KEY}
PROCUREMENT_BOT_API_BASE_URL: http://api:8000/api/v1
PROCUREMENT_BOT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD}@postgres:5432/procurement
```
## ج) ترتیب استقرار (فقط وقتی مالک گفت «پوش کن»)
1. مهندس ارشد `prod-v7` را با بقیه مرج و CI/Coolify دیپلوی ERP را انجام می‌دهد.
2. تصمیم بات سرور (بالا) → در صورت تأیید: خاموش‌کردن بات لوکال → روشن‌کردن بات سرور.
3. **Smoke test (چک‌لیست پلن §۷ بند ۱۲):** ۱۰ پیام نمونه شامل «منشیانه/دفتر/مصطفوی» + یک ویس؛
بررسی: اتصال دقیق‌ها، پیام ابهام با دکمه‌های کاندیدا، ساخت جدید، پیش‌نویس، ثبت نهایی.
4. راستی‌آزمایی پایش: `docker compose exec bot python tools/monitor.py --days 1`
→ قیف باید پر شود و رخدادهای همان پیام‌ها در `bot_trace_events` باشد.
5. چک سهمیه: `usage` در `jev.result` (توکن ورودی/خروجی) + latency p95.
## د) Rollback (بدون دیپلوی — فقط env + restart)
| سوئیچ | اثر |
|---|---|
| `JEV_ENABLED=false` | بازگشت کامل به مسیر difflib قدیم |
| `OBS_ENABLED=false` | خاموش‌شدن ثبت (داده‌های قبلی دست‌نخورده می‌مانند) |
## ه) پایش هفتگی
```bash
docker compose exec bot python tools/monitor.py --days 7 # DIGEST
docker compose exec bot python tools/monitor.py --days 7 --check # exit 1 اگر هشدار
```
- خروجی: کنسول + فایل `bot/data/reports/monitoring_<ts>.md`
- آمار **فقط روی ترافیک واقعی** است (calibration/test حذف می‌شوند؛ `--include-calibration` برای دیدن همه).
- هشدارها (پلن §۴.۵): حجم Observability > ۵GB یا رخداد ماهانه > ۱۰۰هزار → **جلسه‌ی «آرشیو، نه حذف»**؛
انحراف نسخه‌ی مدل از پین `jev-1.13.0`؛ نسبت خطای Jev > ۲۰٪.
- پروکسی کیفیت: انتخاب کاربر در ابهام‌ها (گزینه اول مدل / گزینه دیگر / «هیچ‌کدام») — این عددِ A/B واقعی است.
## و) یادآوری امنیتی (از قبل علامت‌خورده — تصمیم مالک)
`docker-compose.yml` (نسخه لوکال) هنوز توکن بله و کلید DeepSeek را به‌صورت پیش‌فرض
(`:-...`) داخل خود دارد. قبل از هر پوش/جابه‌جایی سرور، چرخش کلیدها توصیه می‌شود.
+257
View File
@@ -0,0 +1,257 @@
"""Phase-6 calibration: A/B difflib vs Jev + confidence-threshold sweep.
docker compose exec bot python tools/calibrate_matching.py
- Runs a fixed scenario set (seeded catalog + realistic STT-ish variants and
negatives) through BOTH matchers:
* difflib baseline: resolve.safe_resolve (the pre-Jev path)
* Jev: matching.resolve_mentions (one request per field — that is how the
pipeline actually runs آن)
- Every run is recorded as real observability data (traces with
source="calibration") plus a markdown report under data/reports/.
- Prints a summary with recommended JEV_CONF_EXACT / JEV_CONF_FLOOR values.
Nothing is sent to Bale and no drafts are created.
"""
import asyncio
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app import matching, observability as obs
from app.api.client import ErpClient
from app.config import settings
from app.resolve import Resolver, safe_resolve
# (field, variants, truth) — truth=None means "must NOT match anything".
SCENARIOS = [
("project", ["دفتر"], "دفتر"),
("project", ["منشیانه"], "منشانه"),
("project", ["منشانه"], "منشانه"),
("project", ["باق اسپارت"], "باغ اسپارت"),
("project", ["پردیس"], "پردیس کاشان"),
("project", ["بابک"], "بابک"),
("project", ["آسمون"], "آسمان"),
("project", ["تست یک"], "تست 1"),
("project", ["انبار قدیم"], None),
("supplier", ["مصطفوی"], "بازرگانی مصطفوی"),
("supplier", ["مستفدی"], "بازرگانی مصطفوی"),
("supplier", ["مصطفوی", "مستفدی"], "بازرگانی مصطفوی"),
("supplier", ["فولاد مبارکه"], "نمایندگی فولاد مبارکه"),
("supplier", ["پلیکا البرز"], "پلیکا فروش البرز"),
("supplier", ["راجین"], "راجین"),
("supplier", ["آهن فروشی آزاد"], None),
("material", ["گچ و خاک"], "گچ و خاک"),
("material", ["سیمان"], "سیمان پرتلند"),
("material", ["میلگرد"], "میلگرد آجدار"),
("material", ["تیر آهن"], "تیرآهن"),
("material", ["لوله پلیکا"], "لوله پلیکا"),
("material", ["کاغذ A4"], None),
("unit", ["کیلو"], "کیلوگرم"),
("unit", ["تن"], "تن"),
("unit", ["کیسه"], "کیسه"),
("unit", ["شاخه"], "شاخه"),
("unit", ["جعبه"], None),
("brand", ["ذوب آهن"], "ذوب آهن اصفهان"),
("brand", ["مبارکه"], "فولاد مبارکه"),
("brand", ["سیمان تهران"], "سیمان تهران"),
("spec", ["سایز 14"], "سایز ۱۴"),
("spec", ["50 کیلویی"], "۵۰ کیلویی"),
("spec", ["6 اینچ"], "۶ اینچ"),
("spec", ["40×40"], "۴۰×۴۰"),
("spec", ["۲ میلی"], "۲ میل"),
]
SWEEP = [round(0.55 + 0.05 * i, 2) for i in range(9)] # 0.55 … 0.95
async def run() -> None:
api = ErpClient(settings.API_BASE_URL, settings.API_TIMEOUT_SECONDS, settings.SERVICE_TOKEN)
resolver = Resolver(api)
ctx = SimpleNamespace(resolver=resolver)
fields: dict[str, list[dict]] = {}
for field, variants, truth in SCENARIOS:
fields.setdefault(field, []).append({"field": field, "variants": variants,
"truth": truth, "difflib": None, "jev": None})
for field, scs in fields.items():
trace = await obs.trace_start(0, None, "text", source="calibration")
# --- difflib baseline -------------------------------------------------
for sc in scs:
res = await safe_resolve(resolver, field, sc["variants"][0])
cands = [{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]]
sc["difflib"] = {"status": res.status, "top": cands[0]["name"] if cands else None,
"cands": [c["name"] for c in cands]}
await obs.log_resolution(trace, field, sc["variants"][0], res.status, cands)
# --- Jev (one batch for the whole field) ------------------------------
# Collect RAW picks: temporarily widen the bands so every confidence is
# returned; classification below uses the CONFIGURED thresholds.
saved_exact, saved_floor = settings.JEV_CONF_EXACT, settings.JEV_CONF_FLOOR
settings.JEV_CONF_EXACT, settings.JEV_CONF_FLOOR = 1.01, 0.0
try:
jev = await matching.resolve_mentions(
ctx, {}, [{"field": s["field"], "variants": s["variants"]} for s in scs], trace)
finally:
settings.JEV_CONF_EXACT, settings.JEV_CONF_FLOOR = saved_exact, saved_floor
for sc in scs:
hit = matching.lookup(jev, sc["variants"][0])
raw_conf = None if hit is None else hit.get("confidence")
raw_choice = None if hit is None else hit.get("choice")
if raw_choice is None:
sc["jev"] = {"status": "new", "choice": None, "conf": raw_conf}
elif raw_conf >= saved_exact:
sc["jev"] = {"status": "exact", "choice": raw_choice, "conf": raw_conf}
elif raw_conf >= saved_floor:
sc["jev"] = {"status": "ambiguity", "choice": raw_choice, "conf": raw_conf}
else:
sc["jev"] = {"status": "lowtrust", "choice": raw_choice, "conf": raw_conf}
await obs.event(trace, "calibration.truth", payload={
"field": field,
"rows": [{"variants": s["variants"], "truth": s["truth"],
"difflib": s["difflib"], "jev": s["jev"]} for s in scs],
})
# ------------------------- metrics ---------------------------------------
pos = [s for s in sum(fields.values(), []) if s["truth"]]
neg = [s for s in sum(fields.values(), []) if not s["truth"]]
d_auto = d_amb = d_miss = 0
for s in pos:
d = s["difflib"]
if d["top"] == s["truth"]:
d_auto += 1
elif s["truth"] in d["cands"]:
d_amb += 1
else:
d_miss += 1
d_neg_ok = sum(1 for s in neg if s["difflib"]["status"] == "none")
j_exact_ok = sum(1 for s in pos if s["jev"]["status"] == "exact"
and s["jev"]["choice"] == s["truth"])
j_exact_bad = sum(1 for s in pos if s["jev"]["status"] == "exact"
and s["jev"]["choice"] != s["truth"])
j_amb = sum(1 for s in pos if s["jev"]["status"] == "ambiguity")
j_low = sum(1 for s in pos if s["jev"]["status"] == "lowtrust")
j_new = sum(1 for s in pos if s["jev"]["status"] == "new")
# negatives: rejected cleanly / asked the user (safe but noisy) / wrongly bound
j_neg_ok = sum(1 for s in neg if s["jev"]["status"] in ("new", "lowtrust"))
j_neg_ask = sum(1 for s in neg if s["jev"]["status"] == "ambiguity")
j_neg_bad = sum(1 for s in neg if s["jev"]["status"] == "exact")
# --------------------- threshold sweep (EXACT) ---------------------------
# Binds = positives above the threshold PLUS any pick on a negative scenario
# (a wrong pick above EXACT would auto-bind — it must drag precision down).
sweep_rows = []
for th in SWEEP:
pos_binds = [s for s in pos if s["jev"]["choice"] and (s["jev"]["conf"] or 0) >= th]
neg_binds = [s for s in neg if s["jev"]["choice"] and (s["jev"]["conf"] or 0) >= th]
ok = sum(1 for s in pos_binds if s["jev"]["choice"] == s["truth"])
binds = len(pos_binds) + len(neg_binds)
prec = (ok / binds) if binds else 0.0
cov = len(pos_binds) / len(pos)
sweep_rows.append({"th": th, "binds": binds, "correct": ok,
"precision": round(prec, 3), "coverage": round(cov, 3)})
# Conservative recommendation (risk-asymmetric: a wrong auto-bind costs more
# than an extra confirmation tap; the sample here is still small):
# - EXACT: keep the configured value while its precision is >= 0.95; raise it
# to the first threshold with precision >= 0.95 otherwise.
# - FLOOR: raise just above the highest confidence of a WRONG pick (a wrong
# pick below the floor never again becomes a candidate button).
binds_now = [r for r in sweep_rows if r["th"] == settings.JEV_CONF_EXACT]
prec_now = binds_now[0]["precision"] if binds_now else 0.0
rec_exact = settings.JEV_CONF_EXACT
if prec_now < 0.95:
for row in sweep_rows:
if row["precision"] >= 0.95 and row["coverage"] >= 0.30:
rec_exact = row["th"]
break
correct_confs = sorted(s["jev"]["conf"] for s in pos
if s["jev"]["choice"] == s["truth"] and s["jev"]["conf"])
# a wrong pick on a NEGATIVE scenario (truth=None) matters too: the floor
# must sit above it so it never becomes a candidate button offer.
wrong_confs = sorted(s["jev"]["conf"] for s in pos + neg
if s["jev"]["choice"] and s["jev"]["choice"] != s["truth"] and s["jev"]["conf"])
rec_floor = settings.JEV_CONF_FLOOR
if wrong_confs:
import math
# The same scenario's confidence jitters ±0.05 across identical runs
# (جعبه observed 0.61/0.60/0.58), so sit one step above the highest wrong
# pick + margin — routing uses `conf >= FLOOR`, so equality leaks through.
target = max(wrong_confs) + 0.05
rec_floor = min(rec_exact, round(math.floor(target * 20 + 0.5) / 20.0, 2))
if rec_floor <= max(wrong_confs):
rec_floor = min(rec_exact,
round((math.floor(max(wrong_confs) * 20) + 1) / 20.0, 2))
# how much coverage a more aggressive EXACT would buy (info only)
lower_info = next((r for r in reversed(sweep_rows)
if r["th"] < settings.JEV_CONF_EXACT and r["precision"] >= 0.98), None)
# ------------------------------ report -----------------------------------
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M")
out_dir = Path(settings.OBS_VOICE_DIR).parent / "reports"
out_dir.mkdir(parents=True, exist_ok=True)
report = out_dir / f"calibration_{ts}.md"
lines = [
"# گزارش کالیبراسیون تطبیق موجودیت‌ها (فاز ۶)",
f"\nتاریخ: {ts} UTC · مدل Jev پین‌شده: `{settings.JEV_MODEL}` · "
f"آستانه‌های فعلی: EXACT={settings.JEV_CONF_EXACT} · FLOOR={settings.JEV_CONF_FLOOR}",
f"\nسناریوها: {len(pos)} مثبت + {len(neg)} منفی (بدون تطبیق) = {len(pos) + len(neg)}",
"\n> روش: انتخاب‌های خام Jev با باند بازِ موقت جمع‌آوری می‌شوند و «وضعیت» هر "
"سناریو با آستانه‌های پیکربندی‌شده بالا محاسبه می‌شود. status `lowtrust` یعنی "
"انتخاب زیر FLOOR بود و در اجرای واقعی رد می‌شود → مسیر difflib ادامه پیدا می‌کند.",
"\n## خلاصه",
f"- **difflib (پایه):** اتصال درست {d_auto}/{len(pos)} · ابهام {d_amb} · از دست رفته {d_miss} "
f"· رد درست منفی‌ها {d_neg_ok}/{len(neg)}",
f"- **Jev:** exact-درست {j_exact_ok} · exact-غلط {j_exact_bad} · ابهام(سؤال از کاربر) {j_amb} · "
f"کم‌اعتماد→difflib {j_low} · new {j_new}",
f"- **منفی‌ها:** ردِ تمیز {j_neg_ok}/{len(neg)} · سؤال از کاربر {j_neg_ask} · پذیرش غلط {j_neg_bad}",
f"- **پیشنهاد آستانه:** `JEV_CONF_EXACT={rec_exact}` · `JEV_CONF_FLOOR={rec_floor}`"
+ (f" _(داده فعلی از کاهش EXACT تا {lower_info['th']} با دقت {lower_info['precision']} "
f"و پوشش {lower_info['coverage']} حمایت می‌کند — با حجم داده واقعی بازبینی شود)_"
if lower_info else ""),
"\n## جدول sweep آستانه EXACT",
"| آستانه | اتصال خودکار (مثبت+منفی) | درست | دقت | پوشش مثبت‌ها |",
"|---|---|---|---|---|",
]
for r in sweep_rows:
lines.append(f"| {r['th']} | {r['binds']} | {r['correct']} | {r['precision']} | {r['coverage']} |")
lines += [
"\n## توزیع confidence",
f"- انتخاب‌های درست: {correct_confs or '—'}",
f"- انتخاب‌های غلط: {wrong_confs or '—'}",
]
for field, scs in fields.items():
lines += [f"\n## فیلد {field}", "| variants | حقیقت | difflib | Jev (choice/conf) |",
"|---|---|---|---|"]
for s in scs:
j = s["jev"]
jtxt = "—" if not j else f"{j['status']}: {j['choice'] or '_none'} @ {j['conf']}"
if j and j["status"] == "lowtrust":
jtxt += " (→ مسیر difflib)"
lines.append(f"| {' / '.join(s['variants'])} | {s['truth'] or '—'} | "
f"{s['difflib']['status']}: {s['difflib']['top'] or '—'} | {jtxt} |")
report.write_text("\n".join(lines), encoding="utf-8")
print("\n".join(lines[:14]))
print(f"\nگزارش کامل: {report}")
print(f"\nپیشنهاد: JEV_CONF_EXACT={rec_exact} JEV_CONF_FLOOR={rec_floor}")
if __name__ == "__main__":
asyncio.run(run())
+292
View File
@@ -0,0 +1,292 @@
"""Phase-7 weekly monitoring digest (plan §7) — all from the observability tables.
docker compose exec bot python tools/monitor.py [--days 7] [--check] [--include-calibration]
- Prints a Persian digest and writes the same content as markdown to
`data/reports/monitoring_<YYYYMMDD_HHMM>.md`.
- No dashboard by design (owner decision): raw tables + SELECTs are the
source of truth; this script is the weekly read of them.
- Default window covers REAL traffic only: traces with source='calibration'
and the test traces (chat_id=0) are excluded from the stats (pass
`--include-calibration` to see everything; the volume section always shows
the full breakdown).
- `--check` exits 1 when a warning trips (space/volume growth per plan §4.5 —
>5GB or >100k events/month → the «archive, do not delete» session is due —
plus Jev failure ratio and model drift) so it can be wired to a scheduler.
- Nothing is ever deleted here (no-deletion policy).
"""
import argparse
import asyncio
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import text
from app.config import settings
from app.db import SessionLocal
GB = 1024 ** 3
TABLE_SIZE_WARN = 5 * GB # plan §4.5: >5GB of observability storage
MONTHLY_EVENTS_WARN = 100_000 # plan §4.5: >100k events / month
JEV_FAIL_RATIO_WARN = 0.20 # of jev.result events in the window (n>=5)
# window predicate reused everywhere: --days 0 → all time
WIN = "(:days = 0 or created_at >= now() - (:days * interval '1 day'))"
WIN_E = "(:days = 0 or e.created_at >= now() - (:days * interval '1 day'))"
# real-traffic filter: keep only traces of genuine chats (no calibration, no chat_id=0 tests)
REAL = ("trace_id in (select id from bot_traces where coalesce(source,'bale') <> 'calibration' "
"and coalesce(chat_id, 0) <> 0)")
REAL_E = ("e.trace_id in (select id from bot_traces where coalesce(source,'bale') <> 'calibration' "
"and coalesce(chat_id, 0) <> 0)")
def _n(x) -> str:
try:
return f"{int(x):,}"
except (TypeError, ValueError):
return "—"
def _human(nbytes: int) -> str:
if not nbytes:
return "0 B"
step = 1024.0
v = float(nbytes)
for unit in ("B", "KB", "MB", "GB", "TB"):
if v < step or unit == "TB":
return f"{v:,.1f} {unit}"
v /= step
return f"{v:,.1f} TB"
def _join(rows, fmt) -> str:
return " · ".join(fmt(r) for r in rows) or "—"
async def build(days: int, include_cal: bool = False) -> tuple[list[str], list[str]]:
"""Return (digest lines, warnings)."""
flt = "" if include_cal else f" and {REAL}"
flt_e = "" if include_cal else f" and {REAL_E}"
warnings: list[str] = []
L: list[str] = []
async with SessionLocal() as s:
async def rows(sql, **params):
return (await s.execute(text(sql), {"days": days, **params})).all()
async def one(sql, **params):
return (await s.execute(text(sql), {"days": days, **params})).first()
# ---------------------------------------------------------- volume (always all traces)
by_src = await rows("""
select coalesce(source,'?') src, count(*) n from bot_traces
where (:days = 0 or created_at >= now() - (:days * interval '1 day'))
group by 1 order by n desc
""")
per_day = await rows(f"""
select to_char(date_trunc('day', created_at), 'MM-DD') d, count(*) n
from bot_trace_events where {WIN}{flt}
group by 1 order by min(created_at)
""")
total_events = await one(f"select count(*) n from bot_trace_events where {WIN}{flt}")
L += [f"پنجره: {'از ابتدا' if days == 0 else f'{days} روز اخیر'}"
+ ("" if include_cal else " · فقط ترافیک واقعی (بدون calibration/test)"), ""]
L.append("- منابع روندها: " + _join(by_src, lambda r: f"{r.src}: {_n(r.n)}"))
if per_day:
L.append(f"- رخداد{'' if include_cal else ' واقعی'} در روز: "
+ _join(per_day, lambda r: f"{r.d}={_n(r.n)}")
+ f" (کل پنجره: {_n(total_events.n)})")
# ---------------------------------------------------------- stages / latency
stages = await rows(f"""
select stage, count(*) n,
count(*) filter (where not ok) fails,
round(percentile_cont(0.5) within group (order by latency_ms)) p50,
round(percentile_cont(0.95) within group (order by latency_ms)) p95
from bot_trace_events where {WIN}{flt}
group by 1 order by n desc
""")
L += ["", "### مرحله‌ها (stage · تعداد · خطا · p50/p95 ms)"]
if not stages:
L.append("- —")
for r in stages:
lat = f" · {r.p50}/{r.p95}" if r.p50 is not None else ""
bad = f" · ⚠️ {_n(r.fails)} خطا" if r.fails else ""
L.append(f"- `{r.stage}`: {_n(r.n)}{bad}{lat}")
fails = [(r.stage, r.fails) for r in stages if r.fails]
if fails:
warnings.append("خطا در مراحل: " + ", ".join(f"{a}×{b}" for a, b in fails))
# ---------------------------------------------------------- funnel
nlu = await one(f"""
select count(*) n, count(*) filter (where ok) ok from bot_trace_events
where stage='nlu.result' and {WIN}{flt}
""")
mentions = await one(f"""
select coalesce(sum(jsonb_array_length(payload->'entity_mentions')), 0) n
from bot_trace_events
where stage='entities.extracted' and jsonb_typeof(payload->'entity_mentions')='array'
and {WIN}{flt}
""")
jev = await one(f"""
select count(*) n, count(*) filter (where ok) ok, count(*) filter (where not ok) fail
from bot_trace_events where stage='jev.result' and {WIN}{flt}
""")
route_src = await rows(f"""
select coalesce(payload->>'source','?') src, count(*) n from bot_trace_events
where stage='routing.decision' and {WIN}{flt} group by 1 order by n desc
""")
jev_status = await rows(f"""
select coalesce(m->>'status','?') st, count(*) n
from bot_trace_events e, jsonb_array_elements(e.payload->'mentions') m
where e.stage='routing.decision' and e.payload->>'source'='jev' and {WIN_E}{flt_e}
group by 1 order by n desc
""")
bands = await rows(f"""
select case
when (m->>'confidence')::float >= :ex then 'exact'
when (m->>'confidence')::float >= :fl then 'ambiguity'
else 'low' end band, count(*) n
from bot_trace_events e, jsonb_array_elements(e.payload->'mentions') m
where e.stage='routing.decision' and e.payload->>'source'='jev' and {WIN_E}{flt_e}
group by 1 order by n desc
""", ex=settings.JEV_CONF_EXACT, fl=settings.JEV_CONF_FLOOR)
difflib = await rows(f"""
select coalesce(payload->>'status','?') st, count(*) n from bot_trace_events
where stage='routing.decision' and payload->>'source'='difflib' and {WIN}{flt}
group by 1 order by n desc
""")
commits = await one(f"""
select count(*) filter (where stage='draft.commit') committed,
count(*) filter (where stage='draft.cancel') cancelled,
count(*) filter (where stage='draft.render') rendered
from bot_trace_events where {WIN}{flt}
""")
L += ["", "### قیف پردازش"]
L.append(f"- NLU (nlu.result): {_n(nlu.n)} — mentionهای استخراج‌شده: {_n(mentions.n)}")
L.append(f"- Jev (jev.result): {_n(jev.n)} درخواست/فیلد — خطا: {_n(jev.fail)}")
L.append(f"- مسیریابی: " + _join(route_src, lambda r: f"{r.src}: {_n(r.n)}"))
L.append(f"- وضعیت mentionهای Jev: " + _join(jev_status, lambda r: f"{r.st}: {_n(r.n)}"))
L.append(f"- باندهای confidence (EXACT={settings.JEV_CONF_EXACT} / FLOOR={settings.JEV_CONF_FLOOR}): "
+ _join(bands, lambda r: f"{r.band}: {_n(r.n)}"))
L.append(f"- difflib (fallback): " + _join(difflib, lambda r: f"{r.st}: {_n(r.n)}"))
L.append(f"- پیش‌نویس: نمایش {_n(commits.rendered)} · ثبت نهایی {_n(commits.committed)} · لغو {_n(commits.cancelled)}")
if jev.n and jev.n >= 5 and jev.fail / jev.n > JEV_FAIL_RATIO_WARN:
warnings.append(f"نسبت خطای Jev بالا: {jev.fail}/{jev.n}")
# ---------------------------------------------------------- user choices (quality proxy)
user_amb = await rows(f"""
select coalesce(payload->>'action','?') act, coalesce(payload->>'arg','-') arg, count(*) n
from bot_trace_events where stage='ambiguity.user_choice' and {WIN}{flt}
group by 1,2 order by n desc
""")
picks = await rows(f"""
select case when (payload->>'idx')::int = 0 then 'top' else 'other' end k, count(*) n
from bot_trace_events where stage='user_choice.pick' and {WIN}{flt}
group by 1 order by n desc
""")
L += ["", "### انتخاب کاربر (پروکسی کیفیت — A/B واقعی Jev↔difflib↔کاربر)"]
L.append("- ابهام‌ها: " + (_join(user_amb, lambda r: f"{r.act}({r.arg}): {_n(r.n)}")
if user_amb else "— هنوز کاربری در ابهام انتخاب نکرده"))
top = sum(r.n for r in picks if r.k == "top")
other = sum(r.n for r in picks if r.k == "other")
if picks:
L.append(f"- دکمه‌های گزینه: گزینه اول (پیشنهاد مدل) {_n(top)} · گزینه دیگر {_n(other)}")
new_rej = sum(r.n for r in user_amb if r.act == "new")
if new_rej:
L.append(f"- «هیچ‌کدام/جدید» در ابهام: {_n(new_rej)} (پیشنهاد مدل رد شد)")
# ---------------------------------------------------------- jev health
tokens = await one(f"""
select coalesce(sum((payload->'usage'->>'input_tokens')::bigint),0) tin,
coalesce(sum((payload->'usage'->>'output_tokens')::bigint),0) tout
from bot_trace_events
where stage='jev.result' and payload->'usage' is not null and {WIN}{flt}
""")
models = await rows(f"""
select coalesce(model,'?') m, count(*) n from bot_trace_events
where stage='jev.result' and {WIN}{flt} group by 1 order by n desc
""")
L += ["", "### سلامت Jev"]
L.append(f"- توکن مصرفی (از usage): ورودی {_n(tokens.tin)} · خروجی {_n(tokens.tout)}")
L.append(f"- مدل‌ها: " + _join(models, lambda r: f"`{r.m}`×{_n(r.n)}"))
drift = [r.m for r in models if r.m not in ("?", settings.JEV_MODEL)]
if drift:
warnings.append(f"انحراف نسخه مدل Jev: {drift} ≠ پین {settings.JEV_MODEL}")
# ---------------------------------------------------------- stt health
stt = await rows(f"""
select coalesce(payload->>'provider','?') p, count(*) n, count(*) filter (where ok) ok,
round(percentile_cont(0.5) within group (order by latency_ms)) p50
from bot_trace_events where stage='stt.result' and {WIN}{flt} group by 1 order by n desc
""")
if stt:
L += ["", "### STT"]
for r in stt:
L.append(f"- {r.p}: {_n(r.ok)}/{_n(r.n)} موفق · p50 {r.p50 if r.p50 is not None else '—'} ms")
# ---------------------------------------------------------- space / archive guard (always all data)
sizes = await one("""
select pg_total_relation_size('bot_trace_events') ev_bytes,
pg_total_relation_size('bot_traces') tr_bytes,
pg_total_relation_size('bot_messages') msg_bytes
""")
month_events = await one("""
select count(*) n from bot_trace_events
where created_at >= date_trunc('month', now())
""")
voices_dir = Path(settings.OBS_VOICE_DIR)
v_files = v_bytes = 0
if voices_dir.exists():
for p in voices_dir.rglob("*"):
if p.is_file():
v_files += 1
v_bytes += p.stat().st_size
obs_bytes = (sizes.ev_bytes or 0) + (sizes.tr_bytes or 0) + v_bytes
L += ["", "### فضا و آرشیو (سیاست: آرشیو، نه حذف — پلن §4.5)"]
L.append(f"- bot_trace_events: {_human(sizes.ev_bytes or 0)} · bot_traces: {_human(sizes.tr_bytes or 0)} "
f"· bot_messages: {_human(sizes.msg_bytes or 0)} · صدای خام: {_human(v_bytes)} ({_n(v_files)} فایل)")
L.append(f"- رخدادهای این ماه تقویمی: {_n(month_events.n)}")
if obs_bytes > TABLE_SIZE_WARN:
warnings.append(f"حجم Observability از ۵GB گذشت: {_human(obs_bytes)} → جلسه آرشیو لازم است")
if month_events.n > MONTHLY_EVENTS_WARN:
warnings.append(f"رخداد این ماه از ۱۰۰هزار گذشت: {_n(month_events.n)} → جلسه آرشیو لازم است")
return L, warnings
async def main() -> None:
ap = argparse.ArgumentParser(description="Phase-7 monitoring digest")
ap.add_argument("--days", type=int, default=7, help="0 = از ابتدا (default: 7)")
ap.add_argument("--check", action="store_true", help="exit 1 when a warning trips")
ap.add_argument("--include-calibration", action="store_true",
help="شامل ترافیک calibration/test در آمار (پیش‌فرض: فقط ترافیک واقعی)")
args = ap.parse_args()
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M")
lines, warnings = await build(args.days, args.include_calibration)
header = [f"# پایش هفتگی بات — {ts} UTC", ""]
if warnings:
header += ["## ⚠️ هشدارها", *[f"- {w}" for w in warnings], ""]
out_dir = Path(settings.OBS_VOICE_DIR).parent / "reports"
out_dir.mkdir(parents=True, exist_ok=True)
report = out_dir / f"monitoring_{ts}.md"
report.write_text("\n".join(header + lines), encoding="utf-8")
print("\n".join(header + lines))
print(f"\nگزارش: {report}")
if args.check and warnings:
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
+533
View File
@@ -0,0 +1,533 @@
"""Mock-data seeder (ADDITIVE + idempotent) for JeV testing and phase-6 calibration.
docker compose exec bot python tools/seed_mock.py
Rules:
- Never deletes or modifies existing user data; existing rows (by name/key/id)
are skipped, so re-runs are safe and the team's own test data is untouched.
- Deterministic ids (uuid5) for every created entity → stable cross-references.
- Covers: units, brands, attribute definitions + material EAV values, materials,
brand/material links, material unit conversions, suppliers (+categories),
supplier↔material edges, projects, warehouses, inventory + ledger, and three
full quotes: offered / accepted multi-project / arrived (receipt + allocation
+ PURCHASE ledger row + decisions + events).
- All numeric parameters use Decimal (asyncpg is strict about numeric columns).
"""
import asyncio
import sys
import uuid
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import text
from app.db import SessionLocal
NS = uuid.UUID("6d3ed6a0-9e11-4b2a-8f7e-000000000000")
def uid(*parts: str) -> uuid.UUID:
return uuid.uuid5(NS, "|".join(parts))
def D(v) -> Decimal:
return Decimal(str(v))
STATS: dict[str, list[int]] = {}
def _bump(kind: str, created: int, skipped: int) -> None:
c, s = STATS.get(kind, [0, 0])
STATS[kind] = [c + created, s + skipped]
UNITS_EXTRA = [
("sh", "شاخه", "count"),
("m2", "متر مربع", "area"),
("pack", "بسته", "count"),
("roll", "رول", "count"),
("sheet", "ورق", "count"),
]
BRANDS = [
"فولاد مبارکه", "ذوب آهن اصفهان", "سیمان تهران", "سیمان سپاهان",
"پلیکا", "البرز", "سرامیک آسیا",
]
ATTR_DEFS = [
("spec_قطر", "قطر"),
("spec_سایز", "سایز"),
("spec_گرید", "گرید"),
("spec_ضخامت", "ضخامت"),
("spec_بسته‌بندی", "بسته‌بندی"),
]
# name, canonical unit, supplier, brand|None, specs[(key,value)], conversions[(unit, factor)]
MATERIALS = [
("میلگرد آجدار", "kg", "نمایندگی فولاد مبارکه", "فولاد مبارکه",
[("spec_سایز", "سایز ۱۴"), ("spec_گرید", "آجدار A3")], [("شاخه", 12), ("تن", 1000)]),
("تیرآهن", "kg", "نمایندگی فولاد مبارکه", "فولاد مبارکه",
[("spec_سایز", "سایز ۱۸")], [("شاخه", 226)]),
("ورق سیاه", "kg", "نمایندگی فولاد مبارکه", "فولاد مبارکه",
[("spec_ضخامت", "۲ میل")], [("ورق", "24.6")]),
("پروفیل قوطی", "kg", "نمایندگی فولاد مبارکه", "فولاد مبارکه",
[("spec_سایز", "۴۰×۴۰"), ("spec_ضخامت", "۲ میل")], []),
("سیمان پرتلند", "kg", "بازرگانی مصطفوی", "سیمان تهران",
[("spec_بسته‌بندی", "۵۰ کیلویی")], [("کیسه", 50), ("تن", 1000)]),
("گچ و خاک", "kg", "بازرگانی مصطفوی", None,
[("spec_بسته‌بندی", "۳۰ کیلویی")], [("کیسه", 30)]),
("گچ ساختمانی", "kg", "بازرگانی مصطفوی", None, [], [("کیسه", 30)]),
("شن و ماسه", "kg", "بازرگانی مصطفوی", None, [], []),
("لوله پلیکا", "m", "پلیکا فروش البرز", "پلیکا",
[("spec_قطر", "۶ اینچ")], [("شاخه", 6)]),
("لوله مانیسمان", "m", "تجهیزات آبی پارس", None,
[("spec_قطر", "۲ اینچ")], []),
("سه راهی پلیکا", "piece", "پلیکا فروش البرز", "پلیکا",
[("spec_قطر", "۶ اینچ")], []),
("شیر فلکه", "piece", "تجهیزات آبی پارس", None,
[("spec_سایز", "۲ اینچ")], []),
("کاشی سرامیک", "m2", "سرامیک آسیا", "سرامیک آسیا",
[("spec_سایز", "۶۰×۶۰")], []),
("رنگ روغنی", "kg", "شیمی صنعت سپاهان", None,
[("spec_بسته‌بندی", "گالن ۳.۶ کیلویی")], []),
("الکترود جوشکاری", "kg", "ابزار سازان اصفهان", None,
[("spec_سایز", "۳.۲")], []),
("نایلون شیرینک", "kg", "شیمی صنعت سپاهان", None, [], []),
]
SUPPLIERS = [
("بازرگانی مصطفوی", "construction-materials", "اصفهان، خیابان امام", "031-32223344", "حاج آقا مصطفوی"),
("نمایندگی فولاد مبارکه", "metals-steel-sections", "اصفهان، شهرک صنعتی", "031-33445566", "مهندس کریمی"),
("پلیکا فروش البرز", "mechanical-installations-pipes", "کرج، جاده مخصوص", "026-34567890", "آقای رضایی"),
("تجهیزات آبی پارس", "mechanical-installations-pipes", "تهران، بازار آهن", "021-55667788", "آقای شریفی"),
("سرامیک آسیا", "finishing-interior-decoration", "یزد، میبد", "035-32345678", "آقای دهقان"),
("ابزار سازان اصفهان", "construction-tools-equipment", "اصفهان، بازار", "031-32112233", "آقای نوری"),
("شیمی صنعت سپاهان", "construction-chemicals", "اصفهان، شهرک علمی", "031-33889900", "خانم احمدی"),
]
PROJECTS = [
("دفتر", "دفتر مرکزی شرکت"),
("منشانه", "پروژه منشانه"),
("باغ اسپارت", "پروژه باغ اسپارت"),
("پردیس کاشان", "پروژه پردیس کاشان"),
("بابک", "پروژه بابک"),
]
async def _name_id(s, table: str, name: str):
row = (await s.execute(
text(f"SELECT id FROM {table} WHERE name = :n LIMIT 1"), {"n": name}
)).scalar()
return str(row) if row else None
async def seed() -> None:
now = datetime.now(timezone.utc)
async with SessionLocal() as s:
# ---------------- units ----------------
created = skipped = 0
for code, name_fa, dim in UNITS_EXTRA:
r = await s.execute(text(
"INSERT INTO units (code, name_fa, dimension, si_factor, is_base) "
"VALUES (:c, :n, :d, 1, false) ON CONFLICT (code) DO NOTHING"
), {"c": code, "n": name_fa, "d": dim})
created += r.rowcount
skipped += 1 - r.rowcount
_bump("units", created, skipped)
_unit_rows = (await s.execute(text("SELECT id, code, name_fa FROM units"))).all()
unit_map = {r.code: r.id for r in _unit_rows}
unit_fa = {r.name_fa: r.id for r in _unit_rows}
def unit_of(ref) -> int:
return unit_map.get(ref) or unit_fa[ref]
# ---------------- brands ----------------
created = skipped = 0
brand_id: dict[str, str] = {}
for b in BRANDS:
existing = await _name_id(s, "brands", b)
if existing:
brand_id[b] = existing
skipped += 1
continue
bid = uid("brand", b)
await s.execute(text(
"INSERT INTO brands (id, name, country) VALUES (:id, :name, 'ایران') ON CONFLICT (id) DO NOTHING"
), {"id": str(bid), "name": b})
brand_id[b] = str(bid)
created += 1
_bump("brands", created, skipped)
# ---------------- attribute definitions ----------------
created = skipped = 0
attr_id: dict[str, str] = {}
for key, label in ATTR_DEFS:
row = (await s.execute(
text("SELECT id FROM attribute_definitions WHERE attribute_key=:k"), {"k": key}
)).scalar()
if row:
attr_id[key] = str(row)
skipped += 1
continue
aid = uid("attr", key)
await s.execute(text(
"INSERT INTO attribute_definitions (id, attribute_key, label, data_type, enum_values) "
"VALUES (:id, :k, :l, 'string', NULL) ON CONFLICT (id) DO NOTHING"
), {"id": str(aid), "k": key, "l": label})
attr_id[key] = str(aid)
created += 1
_bump("attribute_definitions", created, skipped)
# ---------------- suppliers ----------------
created = skipped = 0
cat_map = {r.slug: str(r.id) for r in
(await s.execute(text("SELECT id, slug FROM material_categories"))).all()}
supplier_id: dict[str, str] = {}
for name, slug, location, phone, contact in SUPPLIERS:
existing = await _name_id(s, "suppliers", name)
if existing:
supplier_id[name] = existing
skipped += 1
continue
sid = uid("supplier", name)
await s.execute(text(
"INSERT INTO suppliers (id, name, category_id, location, phone, contact_person) "
"VALUES (:id, :name, :cat, :loc, :phone, :contact) ON CONFLICT (id) DO NOTHING"
), {"id": str(sid), "name": name, "cat": cat_map.get(slug), "loc": location,
"phone": phone, "contact": contact})
supplier_id[name] = str(sid)
created += 1
_bump("suppliers", created, skipped)
# ---------------- materials (+ links, EAV, conversions) ----------------
created = skipped = 0
material_id: dict[str, str] = {}
brand_material_of: dict[str, str | None] = {}
brand_of: dict[str, str | None] = {}
for name, unit_code, sup_name, brand, specs, convs in MATERIALS:
mid = await _name_id(s, "materials", name)
if mid is None:
mid = str(uid("material", name))
await s.execute(text(
"INSERT INTO materials (id, name, canonical_unit_id, status) "
"VALUES (:id, :name, :unit, 'active') ON CONFLICT (id) DO NOTHING"
), {"id": mid, "name": name, "unit": unit_map[unit_code]})
created += 1
else:
skipped += 1
material_id[name] = mid
for key, value in specs:
await s.execute(text(
"INSERT INTO material_attribute_values "
"(id, material_id, attribute_definition_id, value_string) "
"VALUES (:id, :m, :a, :v) "
"ON CONFLICT (material_id, attribute_definition_id) DO NOTHING"
), {"id": str(uid("mav", name, key)), "m": mid, "a": attr_id[key], "v": value})
if brand:
bmid = str(uid("bm", brand, name))
await s.execute(text(
"INSERT INTO brand_materials (id, brand_id, material_id, is_active) "
"VALUES (:id, :b, :m, true) ON CONFLICT (brand_id, material_id) DO NOTHING"
), {"id": bmid, "b": brand_id[brand], "m": mid})
brand_material_of[name] = bmid
brand_of[name] = brand_id[brand]
else:
brand_material_of[name] = None
brand_of[name] = None
for conv_code, factor in convs:
exists = (await s.execute(text(
"SELECT 1 FROM material_units WHERE material_id=:m AND brand_material_id IS NULL "
"AND unit_id=:u LIMIT 1"
), {"m": mid, "u": unit_of(conv_code)})).scalar()
if exists:
continue
await s.execute(text(
"INSERT INTO material_units (id, material_id, brand_material_id, unit_id, "
"conversion_factor, is_default) VALUES (:id, :m, NULL, :u, :f, false)"
), {"id": str(uid("mu", name, conv_code)), "m": mid,
"u": unit_of(conv_code), "f": D(factor)})
await s.execute(text(
"INSERT INTO supplier_materials (id, supplier_id, material_id, is_active, "
"last_quoted_price, last_quoted_at) VALUES (:id, :sup, :m, true, :price, :at) "
"ON CONFLICT (supplier_id, material_id) DO NOTHING"
), {"id": str(uid("sm", sup_name, name)), "sup": supplier_id[sup_name],
"m": mid, "price": D("100000"), "at": now})
_bump("materials", created, skipped)
# ---------------- projects + warehouses ----------------
created = skipped = 0
project_id: dict[str, str] = {}
for name, desc in PROJECTS:
pid = await _name_id(s, "projects", name)
if pid is None:
pid = str(uid("project", name))
await s.execute(text(
"INSERT INTO projects (id, name, description) VALUES (:id, :name, :desc) "
"ON CONFLICT (id) DO NOTHING"
), {"id": pid, "name": name, "desc": desc})
created += 1
else:
skipped += 1
project_id[name] = pid
_bump("projects", created, skipped)
created = skipped = 0
warehouse_id: dict[tuple[str, str], str] = {}
for pname, pid in project_id.items():
for wname in ("انبار مرکزی", "انبار کارگاه"):
wid = str(uid("wh", pname, wname))
exists = (await s.execute(text(
"SELECT 1 FROM warehouses WHERE id=:id LIMIT 1"), {"id": wid})).scalar()
if exists:
skipped += 1
else:
await s.execute(text(
"INSERT INTO warehouses (id, project_id, name, description) "
"VALUES (:id, :p, :n, :d)"
), {"id": wid, "p": pid, "n": wname, "d": f"{wname} پروژه {pname}"})
created += 1
warehouse_id[(pname, wname)] = wid
_bump("warehouses", created, skipped)
# ---------------- inventory + ledger ----------------
inv_plan = [
("منشانه", "انبار مرکزی", "گچ و خاک", None, "200", "kg"),
("منشانه", "انبار کارگاه", "سیمان پرتلند", "سیمان تهران", "150", "kg"),
("بابک", "انبار مرکزی", "میلگرد آجدار", "فولاد مبارکه", "800", "kg"),
("پردیس کاشان", "انبار مرکزی", "لوله پلیکا", "پلیکا", "60", "m"),
]
created = skipped = 0
for pname, wname, mname, bname, qty, unit_code in inv_plan:
iid = str(uid("inv", pname, wname, mname))
exists = (await s.execute(text(
"SELECT 1 FROM inventory WHERE id=:id LIMIT 1"), {"id": iid})).scalar()
if exists:
skipped += 1
continue
await s.execute(text(
"INSERT INTO inventory (id, warehouse_id, material_id, brand_id, quantity, unit_id, "
"specs, specs_hash) VALUES (:id, :w, :m, :b, :q, :u, '{}'::jsonb, 'default')"
), {"id": iid, "w": warehouse_id[(pname, wname)], "m": material_id[mname],
"b": brand_id.get(bname) if bname else None, "q": D(qty), "u": unit_map[unit_code]})
await s.execute(text(
"INSERT INTO inventory_transactions (id, inventory_id, warehouse_id, transaction_type, "
"quantity, performed_by, transaction_date, note) "
"VALUES (:id, :inv, :w, 'PURCHASE', :q, 'کاربر آزمایشی', :at, 'موجودی اولیه (seed)')"
), {"id": str(uid("tx", iid, "purchase")), "inv": iid,
"w": warehouse_id[(pname, wname)], "q": D(qty), "at": now - timedelta(days=20)})
created += 1
_bump("inventory", created, skipped)
# ---------------- quotes ----------------
created = skipped = 0
admin_uid = (await s.execute(text(
"SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1"
))).scalar()
admin_uid = str(admin_uid) if admin_uid else None
async def insert_quote_block(qkey, supplier, projects, status, quote_date, expiry,
accepted, arrived, is_official, notes, items, termsheets,
decisions_map, events, receipts=None, legacy_project=None):
qid = str(uid("quote", qkey))
exists = (await s.execute(text("SELECT 1 FROM quotes WHERE id=:id"), {"id": qid})).scalar()
if exists:
return False
await s.execute(text(
"INSERT INTO quotes (id, supplier_id, project_id, quote_date, expiry_date, "
"accepted_date, arrived_date, status, is_official, notes) VALUES "
"(:id, :sup, :proj, :qd, :ed, :ad, :ar, CAST(:st AS quote_status), :off, :notes)"
), {"id": qid, "sup": supplier_id[supplier],
"proj": project_id.get(legacy_project) if legacy_project else None,
"qd": quote_date, "ed": expiry, "ad": accepted, "ar": arrived,
"st": status, "off": is_official, "notes": notes})
for p in projects:
await s.execute(text(
"INSERT INTO quote_projects (quote_id, project_id) VALUES (:q, :p) "
"ON CONFLICT DO NOTHING"), {"q": qid, "p": project_id[p]})
ts_ids: dict[str, str] = {}
for ts in termsheets:
tid = str(uid("ts", qkey, ts["label"]))
ts_ids[ts["label"]] = tid
await s.execute(text(
"INSERT INTO termsheets (id, quote_id, label, status, discount_type, discount_value) "
"VALUES (:id, :q, :l, CAST(:st AS termsheet_status), CAST(:dt AS discount_type), :dv)"
), {"id": tid, "q": qid, "l": ts["label"], "st": ts["status"],
"dt": ts.get("discount_type", "none"), "dv": D(ts.get("discount_value", 0))})
for i, (due, amt, note) in enumerate(ts["steps"], start=1):
await s.execute(text(
"INSERT INTO termsheet_schedule_steps (id, termsheet_id, step_no, due_date, "
"amount, note) VALUES (:id, :ts, :no, :due, :amt, :note)"
), {"id": str(uid("step", qkey, ts["label"], str(i))), "ts": tid, "no": i,
"due": due, "amt": D(amt), "note": note})
line_ids: dict[str, str] = {}
its_ids: dict[tuple[str, str], str] = {}
for it in items:
lid = str(uid("li", qkey, it["material"]))
line_ids[it["material"]] = lid
await s.execute(text(
"INSERT INTO line_items (id, quote_id, material_id, brand_material_id, unit_id, "
"quantity, notes) VALUES (:id, :q, :m, :bm, :u, :qty, NULL)"
), {"id": lid, "q": qid, "m": material_id[it["material"]],
"bm": brand_material_of.get(it["material"]), "u": unit_map[it["unit"]],
"qty": D(it["qty"])})
for p in it.get("projects", projects):
await s.execute(text(
"INSERT INTO line_item_projects (line_item_id, project_id) VALUES (:li, :p) "
"ON CONFLICT DO NOTHING"), {"li": lid, "p": project_id[p]})
if it.get("price") is not None:
tlabel = it.get("termsheet", termsheets[0]["label"])
iid = str(uid("it", qkey, it["material"], tlabel))
its_ids[(it["material"], tlabel)] = iid
await s.execute(text(
"INSERT INTO item_termsheets (id, line_item_id, termsheet_id, unit_price, "
"min_order_qty) VALUES (:id, :li, :ts, :price, NULL)"
), {"id": iid, "li": lid, "ts": ts_ids[tlabel], "price": D(it["price"])})
dec_ids: dict[str, str] = {}
for mname, dec in decisions_map.items():
did = str(uid("dec", qkey, mname))
chosen_its = (its_ids.get((mname, dec.get("termsheet")))
if dec.get("status") == "chosen" else None)
await s.execute(text(
"INSERT INTO item_decisions (id, line_item_id, chosen_item_termsheet_id, status, "
"project_id, adjusted_unit_price, decided_at) VALUES "
"(:id, :li, :its, CAST(:st AS item_decision_status), :proj, :price, :at)"
), {"id": did, "li": line_ids[mname], "its": chosen_its, "st": dec["status"],
"proj": project_id.get(dec.get("project")) if dec.get("project") else None,
"price": D(dec["price"]) if dec.get("price") is not None else None,
"at": accepted if dec.get("status") == "chosen" else None})
dec_ids[mname] = did
for ev in events:
at = {"offered": quote_date, "accepted": accepted, "arrived": arrived}.get(ev) or quote_date
await s.execute(text(
"INSERT INTO quote_events (id, quote_id, event_type, payload, occurred_at, actor) "
"VALUES (:id, :q, CAST(:ev AS quote_event_type), '{}'::jsonb, :at, 'کاربر آزمایشی (seed)')"
), {"id": str(uid("ev", qkey, ev)), "q": qid, "ev": ev, "at": at})
for rc in receipts or []:
rid = str(uid("rc", qkey, rc["material"]))
await s.execute(text(
"INSERT INTO item_receipts (id, item_decision_id, confirmed_by, received_at, "
"received_qty, on_time, quality_note) VALUES (:id, :d, :by, :at, :qty, true, NULL)"
), {"id": rid, "d": dec_ids[rc["material"]], "by": admin_uid,
"at": arrived, "qty": D(rc["qty"])})
for wname, alloc_qty in rc["allocations"]:
aid = str(uid("alloc", qkey, rc["material"], wname))
wid = warehouse_id[(rc["project"], wname)]
await s.execute(text(
"INSERT INTO item_receipt_allocations (id, receipt_id, warehouse_id, quantity) "
"VALUES (:id, :r, :w, :q)"
), {"id": aid, "r": rid, "w": wid, "q": D(alloc_qty)})
inv_row = (await s.execute(text(
"SELECT id FROM inventory WHERE warehouse_id=:w AND material_id=:m "
"AND COALESCE(brand_id, '00000000-0000-0000-0000-000000000000') = "
"COALESCE(CAST(:b AS uuid), '00000000-0000-0000-0000-000000000000') "
"AND specs_hash='default' LIMIT 1"
), {"w": wid, "m": material_id[rc["material"]],
"b": brand_of.get(rc["material"])})).scalar()
qty_canonical = D(alloc_qty) * D(rc.get("factor", 1))
if inv_row is None:
inv_id = str(uid("inv", rc["project"], wname, rc["material"]))
await s.execute(text(
"INSERT INTO inventory (id, warehouse_id, material_id, brand_id, quantity, "
"unit_id, specs, specs_hash) "
"VALUES (:id, :w, :m, :b, :q, :u, '{}'::jsonb, 'default')"
), {"id": inv_id, "w": wid, "m": material_id[rc["material"]],
"b": brand_of.get(rc["material"]), "q": qty_canonical,
"u": unit_map[rc.get("canonical", "kg")]})
else:
inv_id = str(inv_row)
await s.execute(text(
"UPDATE inventory SET quantity = quantity + :q WHERE id=:id"
), {"q": qty_canonical, "id": inv_id})
await s.execute(text(
"INSERT INTO inventory_transactions (id, inventory_id, warehouse_id, "
"receipt_allocation_id, transaction_type, quantity, performed_by, "
"transaction_date, note) VALUES (:id, :inv, :w, :alloc, 'PURCHASE', :q, "
"'کاربر آزمایشی', :at, :note)"
), {"id": str(uid("tx", qkey, rc["material"], wname)), "inv": inv_id,
"w": wid, "alloc": aid, "q": qty_canonical, "at": arrived,
"note": f"ورود از رسید خرید {rc['material']}"})
return True
d_today = date(2026, 10, 12)
ok1 = await insert_quote_block(
"q1", "پلیکا فروش البرز", ["پردیس کاشان"], "offered",
now - timedelta(days=2), d_today + timedelta(days=12), None, None, True,
"[mock] استعلام لوله و اتصالات پردیس",
items=[
{"material": "لوله پلیکا", "unit": "sh", "qty": "120", "price": "450000"},
{"material": "سه راهی پلیکا", "unit": "piece", "qty": "40", "price": "85000"},
],
termsheets=[{"label": "نقد امروز (۵٪ تخفیف)", "status": "offered",
"discount_type": "percent", "discount_value": "5",
"steps": [(d_today, "60000000", "تسویه نقدی یکباره")]}],
decisions_map={"لوله پلیکا": {"status": "pending"}, "سه راهی پلیکا": {"status": "pending"}},
events=["offered"],
legacy_project="پردیس کاشان",
)
ok2 = await insert_quote_block(
"q2", "بازرگانی مصطفوی", ["منشانه", "دفتر"], "accepted",
now - timedelta(days=9), d_today + timedelta(days=5),
now - timedelta(days=6), None, False,
"[mock] خرید گچ و سیمان پروژه‌های منشانه و دفتر",
items=[
{"material": "گچ و خاک", "unit": "bag", "qty": "100", "price": "85000",
"termsheet": "سه قسط ماهانه", "projects": ["منشانه"]},
{"material": "سیمان پرتلند", "unit": "bag", "qty": "50", "price": "185000",
"termsheet": "سه قسط ماهانه", "projects": ["دفتر"]},
],
termsheets=[{"label": "سه قسط ماهانه", "status": "accepted",
"steps": [(d_today, "5875000", "قسط اول"),
(d_today + timedelta(days=30), "5875000", "قسط دوم"),
(d_today + timedelta(days=60), "5875000", "قسط سوم")]}],
decisions_map={
"گچ و خاک": {"status": "chosen", "project": "منشانه",
"termsheet": "سه قسط ماهانه", "price": "85000"},
"سیمان پرتلند": {"status": "chosen", "project": "دفتر",
"termsheet": "سه قسط ماهانه", "price": "185000"},
},
events=["offered", "accepted"],
legacy_project="منشانه",
)
ok3 = await insert_quote_block(
"q3", "نمایندگی فولاد مبارکه", ["بابک"], "arrived",
now - timedelta(days=15), d_today - timedelta(days=1),
now - timedelta(days=12), now - timedelta(days=3), True,
"[mock] میلگرد پروژه بابک (رسید کامل)",
items=[{"material": "میلگرد آجدار", "unit": "ton", "qty": "2", "price": "42000000"}],
termsheets=[{"label": "نقد امروز", "status": "accepted",
"steps": [(d_today, "84000000", "تسویه")]}],
decisions_map={"میلگرد آجدار": {"status": "chosen", "project": "بابک",
"termsheet": "نقد امروز", "price": "42000000"}},
events=["offered", "accepted", "arrived"],
receipts=[{"material": "میلگرد آجدار", "qty": "2", "project": "بابک",
"allocations": [("انبار مرکزی", "2")], "factor": 1000,
"canonical": "kg"}],
legacy_project="بابک",
)
_bump("quotes", sum(1 for x in (ok1, ok2, ok3) if x),
sum(1 for x in (ok1, ok2, ok3) if not x))
await s.commit()
print("\n=== seed summary (created / skipped) ===")
for kind, (c, sk) in STATS.items():
print(f" {kind:22} created={c:3} skipped={sk:3}")
print("done. Nothing was deleted or modified.")
if __name__ == "__main__":
asyncio.run(seed())
+82
View File
@@ -0,0 +1,82 @@
import asyncio
import json
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.config import settings
from app.bale.client import BaleClient
from app.api.client import ErpClient
from app.runtime import BotContext, LinkedUser
from app.nlu import understand
from app.handlers import quote
from app.defaults import AUTHORIZED_USERS
from app import drafts as draft_store
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s — %(message)s")
logger = logging.getLogger("send_analysis")
CHAT_ID = 1461166742
TRANSCRIPTS = [
{
"name": "مدل 1 (Whisper)",
"text": "الان شبت بخیر می خواستیم که بیست و پنج تا کیسه عرض به خدمت شما که گچ و خاک از مصطفوی بخریم و کنارش هم یه پنجاه تا کیسه سیمان پنجاه کیلویی که اونا تقریبا در می آد پامون دونه ای بیست و پنج میلیون سیمانی ها پامون در می آد حدودا چهل میلیون اینا دونه ای بهمون گفته که می تونین سه قسط بهم بدید کلی پولش ها در طول زمان و یا بیاید یه بار پولش رو امروز بهم بدین یه تخفیف ده درصدی تقریبا براتون لحاظ می کنم عددش رو کم می کنم خریدمون هم عمدتا برای منشیانه و دفتر پیش فاکتورم گفته تا حدودا بیست روزی نامه براتون اوکیه همین",
},
{
"name": "مدل 2 (Vosk)",
"text": "سلام شبت بخیر. میخواستیم که 25 تا کیسه عرض به خدمت شما که گچ و خاک از مستفدی بخریم و کنارشم یه 50 تا کیسه سیمان 50 کیلویی که اونا تقریباً در میاد پامون دونهای 25 میلیون، سیمانیا پامون در میاد حدوداً 40 میلیون اینا دونهای. بهمون گفته که میتونین سه قسم بدین کلی پولشا در طول زمان و یا بیاین یه باره پولشو امروز بم بدین یه تخفیف 10 درصدی تقریباً بره دون، لحاظ میکنم عددشو کم میکنم. خریدم اونم اومدن برای منشیانه و دفتر. پیشفاکتورم گفته تا حدود یه 20 روز اینا برادون اوکیه. همین.",
},
]
async def run():
print(f"Connecting to Bale and ERP API for Chat ID: {CHAT_ID}...")
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)
user_info = AUTHORIZED_USERS.get(CHAT_ID, {})
user = LinkedUser(
chat_id=CHAT_ID,
user_id=user_info.get("user_id"),
display_name=user_info.get("display_name", "مدیر تدارکات"),
role=user_info.get("role", "procurement_manager"),
is_admin=user_info.get("is_admin", True),
)
# Cancel previous active draft to ensure fresh card
active = await draft_store.get_active(CHAT_ID)
if active:
await draft_store.set_status(active.id, "cancelled")
print("Sending transcripts to NLU (LLM) with upgraded Isfahan dialect prompt...")
result = await understand(settings, TRANSCRIPTS)
print("NLU Result:")
print(json.dumps(result, ensure_ascii=False, indent=2))
consensus_text = result.get("consensus_text") or ""
patch = result.get("patch") or {}
intro_msg = (
f"🤖 **تحلیل هوشمند فایل صوتی (تجمیع خروجی چند مدل با لحاظ لهجه اصفهانی):**\n\n"
f"📝 **متن پالایش‌شده و رسمی (Consensus):**\n«{consensus_text}»\n\n"
f"📋 پیش‌نویس استعلام خرید با مشخصات استخراج‌شده صادر گردید:"
)
await ctx.send(CHAT_ID, intro_msg)
# Create and render the new draft quote
await quote.handle_entities(
ctx,
CHAT_ID,
user,
patch,
source="voice",
)
print("Draft quote sent successfully to Bale chat!")
if __name__ == "__main__":
asyncio.run(run())
+141 -23
View File
@@ -24,7 +24,7 @@ try:
except ImportError:
sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg"))
CHAT = 100200300
CHAT = 1461166742
_passed = _failed = 0
@@ -59,6 +59,9 @@ class FakeBale:
"reply_markup": reply_markup})
return {"message_id": message_id}
async def delete_message(self, chat_id, message_id):
return True
async def answer_callback_query(self, callback_query_id, text=None):
self.answered.append(callback_query_id)
@@ -82,6 +85,7 @@ class FakeApi:
self.created_quotes: list[dict] = []
self.created_suppliers: list[dict] = []
self.created_materials: list[dict] = []
self.created_projects: list[dict] = []
self.accept_payloads: list[dict] = []
self.arrive_payloads: list[dict] = []
self.login_calls: list[dict] = []
@@ -119,6 +123,10 @@ class FakeApi:
async def list_projects(self, page_size=100):
return [{"id": "prj-1", "name": "برج آسمان"}]
async def create_project(self, payload, actor_id=None):
self.created_projects.append(payload)
return {"id": f"prj-{len(self.created_projects)}", "name": payload["name"]}
async def list_categories(self, page_size=100):
return [{"id": "cat-1", "name": "تأسیسات مکانیکی و آبی"},
{"id": "cat-2", "name": "لوله و اتصالات"}]
@@ -180,10 +188,11 @@ def _install_fake_stores() -> None:
import app.drafts as drafts
import app.runtime as runtime
async def create(chat_id, kind, payload, source=None):
async def create(chat_id, kind, payload, source=None, source_message_id=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,
source_message_id=source_message_id,
review_message_id=None)
MEM["drafts"][str(draft_id)] = draft
return draft
@@ -193,16 +202,33 @@ def _install_fake_stores() -> None:
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_message_id(chat_id, message_id):
for draft in MEM["drafts"].values():
if draft.chat_id == chat_id and draft.status == "active":
if draft.review_message_id == message_id or getattr(draft, "source_message_id", None) == message_id:
return draft
return None
async def get_by_ambiguity_message_id(chat_id, message_id):
for draft in MEM["drafts"].values():
if draft.chat_id == chat_id and draft.status == "active":
for amb in (draft.payload or {}).get("ambiguities") or []:
if amb.get("message_id") == message_id:
return draft, amb
return None, 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):
async def save_payload(draft, missing=None, review_message_id=None, source_message_id=None, mark_bale_synced=False):
draft.missing = missing
if review_message_id:
draft.review_message_id = review_message_id
if source_message_id:
draft.source_message_id = source_message_id
async def set_status(draft_id, status):
if draft_id in MEM["drafts"]:
@@ -212,6 +238,7 @@ def _install_fake_stores() -> None:
return 0
drafts.create, drafts.get_active, drafts.get_by_prefix = create, get_active, get_by_prefix
drafts.get_by_message_id, drafts.get_by_ambiguity_message_id = get_by_message_id, get_by_ambiguity_message_id
drafts.save_payload, drafts.set_status, drafts.expire_stale = save_payload, set_status, expire_stale
async def link_get(self, chat_id):
@@ -228,6 +255,17 @@ def _install_fake_stores() -> None:
runtime.LinkStore.get, runtime.LinkStore.upsert, runtime.LinkStore.delete = link_get, link_upsert, link_delete
import app.observability as obs
async def fake_obs(*args, **kwargs):
pass
async def fake_obs_trace(*args, **kwargs):
return None
obs.event = fake_obs
obs.trace_start = fake_obs
obs.link_draft = fake_obs
obs.set_trace_status = fake_obs
obs.latest_trace_for_draft = fake_obs_trace
async def fake_log(self, chat_id, direction, kind, body=None):
pass
@@ -236,15 +274,19 @@ async def fake_log(self, chat_id, direction, kind, body=None):
NLU_QUEUE: list[dict] = []
async def fake_understand(settings, text, draft_summary=None):
async def fake_understand(settings, text, draft_summary=None, *args, **kwargs):
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):
async def fake_transcribe(settings, audio, mime_type=None, filename=None, *args, **kwargs):
return ("سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن "
"پرداخت سه فاز")
async def fake_transcribe_multi(settings, audio, mime_type=None, filename=None, *args, **kwargs):
return [{"name": "fake", "text": "سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن پرداخت سه فاز"}]
# ---------------------------------------------------------------------------
# driver
# ---------------------------------------------------------------------------
@@ -262,11 +304,17 @@ def build_ctx():
ctx._log = types.MethodType(fake_log, ctx)
H.understand = fake_understand
H.transcribe = fake_transcribe
H.transcribe_multi = fake_transcribe_multi
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 text_update(update_id, text, reply_to=None, chat_id=None, from_user=None):
cid = chat_id or CHAT
fuser = from_user or {"id": cid, "first_name": "مدیر تدارکات", "username": "proc_manager"}
msg = {"message_id": update_id, "chat": {"id": cid}, "text": text, "from": fuser}
if reply_to:
msg["reply_to_message"] = {"message_id": reply_to}
return {"update_id": update_id, "message": msg}
def voice_update(update_id):
@@ -283,36 +331,72 @@ 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
from app.nlu import _extract_json, normalize_digits, understand
from app.config import settings
# ---- pure logic -------------------------------------------------------
check("digits: ۵۰۰→500", normalize_digits("۵۰۰ کیلو") == "500 کیلو")
check("json extract: fenced", (_extract_json('```json\n{"a": 1}\n```') or {}) == {"a": 1})
# ---- multi-STT understand pipeline support ---------------------------
multi_stt_samples = [
{"name": "whisper", "text": "25 کیسه گچ از مصطفوی"},
{"name": "vosk", "text": "بیست و پنج کیسه گچ از مصطفوی"},
]
stt_res = await understand(settings, multi_stt_samples)
check("multi-stt: pipeline accepted list input", isinstance(stt_res, dict) and "consensus_text" in stt_res)
check("multi-stt: consensus text generated", bool(stt_res.get("consensus_text")))
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}]})
missing = compute_quote_missing(payload)
check("missing: supplier empty", any(m["key"] == "supplier" and not m["ok"] for m in missing))
merge_quote_patch(payload, {
"supplier_name": "سازان نو",
"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 -------------------------------------------
# ---- pipeline: whitelist gate & unregistered blocking -----------------
ctx, bale, api = build_ctx()
await H.handle_update(ctx, text_update(1, "سلام"))
check("gate: unlinked asked username", "نام کاربری" in bale.last_text())
UNAUTH_CHAT = 999999999
from app.handlers import UNREGISTERED_FILE
if UNREGISTERED_FILE.exists():
try:
UNREGISTERED_FILE.unlink()
except Exception:
pass
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())
# 1. Unregistered user is blocked and recorded
await H.handle_update(
ctx,
text_update(
1,
"سلام من متقاضی جدید هستم",
chat_id=UNAUTH_CHAT,
from_user={"id": UNAUTH_CHAT, "first_name": "کاربر متفرقه", "username": "intruder"},
),
)
check("gate: unauthorized user blocked", "دسترسی شما هنوز تعریف نشده است" in bale.last_text())
import json
unreg_records = json.loads(UNREGISTERED_FILE.read_text(encoding="utf-8")) if UNREGISTERED_FILE.exists() else []
check(
"gate: unregistered logged to JSON file",
any(u.get("chat_id") == UNAUTH_CHAT and u.get("first_name") == "کاربر متفرقه" for u in unreg_records),
)
# 2. Whitelisted user (1461166742) bypasses Casdoor completely
await H.handle_update(ctx, text_update(2, "/whoami"))
check("gate: whitelisted user active without casdoor", "مدیر تدارکات" in bale.last_text())
check("gate: no casdoor login prompted", "نام کاربری Casdoor" not in bale.last_text())
# ---- voice → draft quote ----------------------------------------------
NLU_QUEUE.append({"intent": "register_quote", "confidence": 0.9, "patch": {
@@ -343,7 +427,7 @@ async def main() -> int:
# 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, "قیمت میلگرد ۴۷ شد"))
await H.handle_update(ctx, text_update(6, "قیمت میلگرد ۴۷ شد", reply_to=draft.review_message_id))
draft = await _active("quote")
check("refine: price updated", (draft.payload["items"][0]["unit_price"]) == 47000)
@@ -417,14 +501,48 @@ async def main() -> int:
check("material: posted", api.created_materials
and api.created_materials[0]["canonical_unit_id"] == 3)
# ---- new projects in quote + auto-creation on commit --------------------
NLU_QUEUE.append({
"intent": "register_quote",
"confidence": 0.95,
"patch": {
"supplier_name": "سازان نو",
"project_names": ["منشانه", "دفتر"],
"items": [
{"material_name": "میلگرد A3 سایز ۱۴", "quantity": 100, "unit_price": 50000, "project_name": "منشانه"},
{"material_name": "شیر فلکه چهارطرفه", "quantity": 20, "unit_price": 100000, "project_name": "دفتر"},
],
},
})
await H.handle_update(ctx, text_update(18, "استعلام برای منشانه و دفتر"))
q_draft = await _active("quote")
check("multi-project: draft created", q_draft is not None)
check("multi-project: no bogus match to برج آسمان",
"برج آسمان" not in bale.last_text() and "prj" not in (q_draft.payload.get("options") or {}))
check("multi-project: new project badge shown", "✨ (پروژه جدید)" in bale.last_text())
await H.handle_update(ctx, callback_update(19, f"q:ok:{q_draft.id[:8]}"))
created_prj_names = [p["name"] for p in api.created_projects]
check("multi-project: auto-created in ERP", "منشانه" in created_prj_names and "دفتر" in created_prj_names)
check("multi-project: commit report mentions projects", "پروژه(های) جدید ثبت شد" in bale.last_text())
# ---- add project standalone --------------------------------------------
NLU_QUEUE.append({"intent": "add_project", "confidence": 0.9,
"patch": {"project": {"name": "پروژه امید", "description": "پروژه فاز سوم"}}})
await H.handle_update(ctx, text_update(20, "پروژه جدید امید"))
p_draft = await _active("project")
check("project: draft created", p_draft is not None)
await H.handle_update(ctx, callback_update(21, f"p:ok:{p_draft.id[:8]}"))
check("project: posted to ERP", any(p["name"] == "پروژه امید" for p in api.created_projects))
check("project: report sent", "پروژه ثبت شد" in bale.last_text())
# ---- 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"))
await H.handle_update(ctx, text_update(22, "استعلام از سازان نو"))
await H.handle_update(ctx, text_update(23, "/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, "اسپاگتی بپز"))
await H.handle_update(ctx, text_update(24, "اسپاگتی بپز"))
check("unknown: hint sent", "متوجه منظورت نشد" in bale.last_text())
print(f"\n{['FAILED', 'ALL PASSED'][int(_failed == 0)]}: {_passed} passed, {_failed} failed")
+182
View File
@@ -0,0 +1,182 @@
"""Tests for audio preprocessing / enhancement module (audio_enhancer.py).
Validates:
1. Disabled enhancer -> zero overhead pass-through of raw bytes.
2. Successful enhancement -> receives enhanced audio, saves with _enhanced suffix,
emits audio.enhance.result with latency_ms/path/size.
3. Failure/timeout -> graceful fallback to raw audio bytes, ok=False event logged.
4. Zero transcoding by default (raw bytes sent without ffmpeg).
5. Timeout configuration adheres to 60.0s.
"""
import asyncio
import os
import sys
from pathlib import Path
from unittest.mock import AsyncMock, patch
import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.audio_enhancer import enhance_voice
from app.config import Settings
from app import observability as obs
_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)
async def test_disabled_enhancer():
cfg = Settings(AUDIO_ENHANCE_ENABLED=False, AUDIO_ENHANCE_BASE_URL="http://mock/enhance")
raw_audio = b"fake-raw-voice-bytes"
out_audio, meta = await enhance_voice(cfg, raw_audio, mime_type="audio/ogg")
check("disabled: raw bytes returned directly", out_audio == raw_audio)
check("disabled: metadata indicates skipped", meta.get("skipped") is True)
async def test_timeout_setting():
cfg = Settings(AUDIO_ENHANCE_TIMEOUT_SECONDS=60.0)
check("config: timeout defaults to 60.0s", cfg.AUDIO_ENHANCE_TIMEOUT_SECONDS == 60.0)
async def test_successful_enhancement():
cfg = Settings(
AUDIO_ENHANCE_ENABLED=True,
AUDIO_ENHANCE_BASE_URL="http://mock-enhancer.internal/v1/enhance",
AUDIO_ENHANCE_MODEL="voice-cleaner-v1",
AUDIO_ENHANCE_TIMEOUT_SECONDS=60.0,
)
raw_audio = b"noisy-voice-bytes"
enhanced_audio = b"crystal-clear-voice-bytes"
trace_id = "test-trace-enh-001"
message_id = 12345
events_captured = []
async def fake_event(t_id, stage, ok=True, latency_ms=None, model=None, payload=None, **kwargs):
events_captured.append({"stage": stage, "ok": ok, "latency_ms": latency_ms, "payload": payload})
# Mock httpx response
mock_resp = httpx.Response(
status_code=200,
content=enhanced_audio,
headers={"content-type": "audio/ogg"},
request=httpx.Request("POST", cfg.AUDIO_ENHANCE_BASE_URL),
)
with patch.object(obs, "event", side_effect=fake_event):
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = mock_resp
out_bytes, meta = await enhance_voice(
cfg,
raw_audio,
mime_type="audio/ogg",
filename="voice.ogg",
trace_id=trace_id,
message_id=message_id,
)
check("success: enhanced bytes returned", out_bytes == enhanced_audio)
check("success: meta indicates enhanced", meta.get("enhanced") is True)
check("success: enhanced path exists in meta", bool(meta.get("path")))
check("success: path has _enhanced suffix", "_enhanced" in (meta.get("path") or ""))
# Verify physical file existence
if meta.get("path"):
saved_file = Path(meta["path"])
check("success: enhanced file written to disk", saved_file.exists() and saved_file.read_bytes() == enhanced_audio)
# Verify observability event
enh_events = [e for e in events_captured if e["stage"] == "audio.enhance.result"]
check("obs: audio.enhance.result event emitted", len(enh_events) == 1)
if enh_events:
ev = enh_events[0]
check("obs: event ok=True", ev["ok"] is True)
check("obs: payload contains size & latency_ms", "size" in ev["payload"] and ev.get("latency_ms") is not None)
check("obs: payload recorded correct size", ev["payload"]["size"] == len(enhanced_audio))
async def test_graceful_fallback_on_error():
cfg = Settings(
AUDIO_ENHANCE_ENABLED=True,
AUDIO_ENHANCE_BASE_URL="http://mock-enhancer.internal/v1/enhance",
AUDIO_ENHANCE_TIMEOUT_SECONDS=60.0,
)
raw_audio = b"original-unprocessed-audio"
trace_id = "test-trace-enh-002"
message_id = 999
events_captured = []
async def fake_event(t_id, stage, ok=True, latency_ms=None, model=None, payload=None, **kwargs):
events_captured.append({"stage": stage, "ok": ok, "latency_ms": latency_ms, "payload": payload})
# Mock HTTP 500 error
mock_resp = httpx.Response(
status_code=500,
text="Internal Model Error",
request=httpx.Request("POST", cfg.AUDIO_ENHANCE_BASE_URL),
)
with patch.object(obs, "event", side_effect=fake_event):
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = mock_resp
out_bytes, meta = await enhance_voice(
cfg,
raw_audio,
mime_type="audio/ogg",
trace_id=trace_id,
message_id=message_id,
)
check("fallback: returns raw original bytes on 500", out_bytes == raw_audio)
check("fallback: meta indicates fallback", meta.get("fallback") is True)
enh_events = [e for e in events_captured if e["stage"] == "audio.enhance.result"]
check("obs: fallback event ok=False", len(enh_events) == 1 and enh_events[0]["ok"] is False)
if enh_events:
check("obs: error detail captured", "HTTP 500" in enh_events[0]["payload"].get("error", ""))
async def test_zero_transcoding_by_default():
cfg = Settings(
AUDIO_ENHANCE_ENABLED=True,
AUDIO_ENHANCE_BASE_URL="http://mock-enhancer.internal/v1/enhance",
AUDIO_ENHANCE_TARGET_FORMAT="", # empty -> zero transcoding
)
raw_audio = b"raw-bale-ogg-bytes"
captured_payload = None
async def fake_post(url, files=None, data=None, headers=None, **kwargs):
nonlocal captured_payload
captured_payload = files["file"][1]
return httpx.Response(status_code=200, content=b"clean", request=httpx.Request("POST", url))
with patch("httpx.AsyncClient.post", side_effect=fake_post):
await enhance_voice(cfg, raw_audio, mime_type="audio/ogg")
check("transcoding: raw bytes sent directly without modification", captured_payload == raw_audio)
async def main():
print("Running audio_enhancer unit tests...")
await test_disabled_enhancer()
await test_timeout_setting()
await test_successful_enhancement()
await test_graceful_fallback_on_error()
await test_zero_transcoding_by_default()
print(f"\nResult: {_passed} passed, {_failed} failed")
return 1 if _failed else 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))
+403
View File
@@ -0,0 +1,403 @@
"""Unit and integration tests for DeepSeek crisis fallback matching.
Covers:
1. Jev failure -> jev.failure observability event + DeepSeek fallback invocation.
2. DeepSeek fallback results mapping: exact, ambiguity, new (with inferred category).
3. Defensive failure guard: DeepSeek network/HTTP/JSON failure raises MatchingFailureError.
4. quote.py resolve_payload: respects new, binds exact, populates ambiguity, zero difflib.
5. quote.py commit: defensive get_or_create pattern prevents 409 duplicate errors.
"""
import asyncio
import json
import sys
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app import fallback_matcher, matching
from app.config import settings
from app.fallback_matcher import MatchingFailureError
from app.handlers import quote
from app.nlu import normalize_text
from app.resolve import Candidate
PASSED: list[str] = []
FAILED: list[str] = []
def check(name: str, ok: bool, extra: str = "") -> None:
(PASSED if ok else FAILED).append(name)
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}")
class FakeResolver:
async def projects(self):
return [Candidate(id="p1", name="پروژه پردیس"), Candidate(id="p2", name="برج نگین")]
async def all_suppliers(self):
return [Candidate(
id="s1",
name="فولاد خوزستان",
extra={"location": "اهواز", "category_name": "فلزات و مقاطع فولادی", "slug": "metals-steel-sections"}
)]
async def all_materials(self):
return [
Candidate(id="m1", name="میلگرد ۱۴ آجدار", extra={"canonical_unit_id": 1}),
Candidate(id="m2", name="لوله پی‌وی‌سی ۴ اینچ", extra={"canonical_unit_id": 3}),
]
async def categories(self):
return [
Candidate(id="cat1", name="فلزات و مقاطع فولادی", extra={"slug": "metals-steel-sections"}),
Candidate(id="cat2", name="تأسیسات مکانیکی و آبی", extra={"slug": "mechanical-installations-pipes"}),
]
async def units(self):
return [Candidate(id="u1", name="شاخه", extra={"code": "branch"}), Candidate(id="u2", name="کیلوگرم", extra={"code": "kg"})]
def invalidate(self, key=None):
pass
class FakeApi:
def __init__(self):
self.created_materials = []
self.created_suppliers = []
self.created_projects = []
async def list_materials(self, q=None, page_size=20, page=1):
if q and "میلگرد ۱۴" in q:
return [{"id": "m1", "name": "میلگرد ۱۴ آجدار", "canonical_unit_id": 1}]
return []
async def create_material(self, body, actor_id=None):
if body["name"] == "میلگرد ۱۴ آجدار":
raise Exception("Conflict 409")
item = {"id": f"m_new_{len(self.created_materials)+1}", **body}
self.created_materials.append(item)
return item
async def list_suppliers(self, q=None, page_size=20, page=1):
if q and "فولاد خوزستان" in q:
return [{"id": "s1", "name": "فولاد خوزستان"}]
return []
async def create_supplier(self, body, actor_id=None):
item = {"id": f"s_new_{len(self.created_suppliers)+1}", **body}
self.created_suppliers.append(item)
return item
async def list_projects(self, page_size=100):
return [{"id": "p1", "name": "پروژه پردیس"}]
async def create_project(self, body, actor_id=None):
item = {"id": f"p_new_{len(self.created_projects)+1}", **body}
self.created_projects.append(item)
return item
async def test_jev_failure_triggers_fallback() -> None:
"""1. When Jev returns None (HTTP 500/timeout), DeepSeek fallback is triggered."""
ctx = SimpleNamespace(resolver=FakeResolver())
events_logged = []
async def fake_event(trace_id, event_type, ok=True, **kwargs):
events_logged.append((event_type, kwargs))
orig_event = matching.obs.event
matching.obs.event = fake_event
saved_key = settings.TYPESAFE_API_KEY
settings.TYPESAFE_API_KEY = "test-typesafe-key"
orig_jev_ask = matching.jev_ask
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result=None)
# Mock DeepSeek fallback matcher
async def fake_fallback(*args, **kwargs):
return {
normalize_text("میلگرد ۱۴"): {
"field": "material",
"status": "exact",
"id": "m1",
"name": "میلگرد ۱۴ آجدار",
"variants": ["میلگرد ۱۴"],
"candidates": [],
},
normalize_text("لوله ۶ اینچ"): {
"field": "material",
"status": "new",
"id": None,
"name": None,
"variants": ["لوله ۶ اینچ"],
"candidates": [],
},
normalize_text("آهن"): {
"field": "material",
"status": "ambiguity",
"id": None,
"name": None,
"variants": ["آهن"],
"candidates": [{"id": "m1", "name": "میلگرد ۱۴ آجدار", "prob": 0.8}],
},
normalize_text("تأمین‌کننده البرز"): {
"field": "supplier",
"status": "new",
"id": None,
"name": None,
"variants": ["تأمین‌کننده البرز"],
"candidates": [],
"inferred_category_slug": "metals-steel-sections",
},
}
orig_deepseek = matching.match_with_deepseek
matching.match_with_deepseek = fake_fallback
try:
mentions = [
{"field": "material", "variants": ["میلگرد ۱۴"]},
{"field": "material", "variants": ["لوله ۶ اینچ"]},
{"field": "material", "variants": ["آهن"]},
{"field": "supplier", "variants": ["تأمین‌کننده البرز"]},
]
res = await matching.resolve_mentions(ctx, {"items": []}, mentions, trace_id="t1", raw_input="تست")
check("Jev failure recorded in observability", any(e[0] == "jev.failure" for e in events_logged))
check("Exact match resolved via fallback", res[normalize_text("میلگرد ۱۴")]["status"] == "exact")
check("New item strictly preserved as new (no difflib forced match)",
res[normalize_text("لوله ۶ اینچ")]["status"] == "new" and res[normalize_text("لوله ۶ اینچ")]["id"] is None)
check("Ambiguity item has candidates",
res[normalize_text("آهن")]["status"] == "ambiguity" and len(res[normalize_text("آهن")]["candidates"]) == 1)
check("New supplier has inferred_category_slug",
res[normalize_text("تأمین‌کننده البرز")]["inferred_category_slug"] == "metals-steel-sections")
finally:
settings.TYPESAFE_API_KEY = saved_key
matching.obs.event = orig_event
matching.jev_ask = orig_jev_ask
matching.match_with_deepseek = orig_deepseek
async def test_two_stage_adjudication_flow() -> None:
"""2. When Jev succeeds, outputs are NOT directly committed; they are sent to DeepSeek Arbiter."""
ctx = SimpleNamespace(resolver=FakeResolver())
saved_key = settings.TYPESAFE_API_KEY
settings.TYPESAFE_API_KEY = "test-typesafe-key"
orig_jev_ask = matching.jev_ask
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result={
"answers": {
"mention_0": {
"choice": "میلگرد ۱۴ آجدار",
"confidence": 0.95,
"probabilities": {"میلگرد ۱۴ آجدار": 0.95, "تسمه فلزی": 0.0, "_none": 0.05},
},
"mention_1": {
"choice": "لوله پی‌وی‌سی ۴ اینچ",
"confidence": 0.82,
"probabilities": {"لوله پی‌وی‌سی ۴ اینچ": 0.82, "لوله پلی‌اتیلن": 0.0, "_none": 0.18},
},
}
})
arbiter_called_with = []
async def fake_arbiter(*args, **kwargs):
arbiter_called_with.append((args, kwargs))
items = kwargs.get("items") or (args[2] if len(args) > 2 else [])
res = {}
for it in items:
v0 = it["variants"][0]
if v0 == "میلگرد ۱۴":
# Endorsed
res[normalize_text(v0)] = {
"field": it["field"], "status": "exact", "id": "m1", "name": "میلگرد ۱۴ آجدار",
"variants": it["variants"], "source": "deepseek-arbiter", "adjudication_action": "endorsed_jev",
}
elif v0 == "لوله ۶ اینچ":
# Vetoed Jev (Jev wrongly picked 4-inch pipe)
res[normalize_text(v0)] = {
"field": it["field"], "status": "new", "id": None, "name": None,
"variants": it["variants"], "source": "deepseek-arbiter", "adjudication_action": "vetoed_to_new",
}
return res
orig_arbiter = matching.adjudicate_with_deepseek
matching.adjudicate_with_deepseek = fake_arbiter
try:
mentions = [
{"field": "material", "variants": ["میلگرد ۱۴"]},
{"field": "material", "variants": ["لوله ۶ اینچ"]},
]
res = await matching.resolve_mentions(
ctx, {"items": []}, mentions, trace_id="t_arb",
raw_input="استعلام میلگرد ۱۴ و لوله ۶ اینچ",
)
check("Two-stage arbitration called when Jev succeeds", len(arbiter_called_with) == 1)
passed_items = arbiter_called_with[0][1].get("items") or arbiter_called_with[0][0][2]
m0_probs = passed_items[0]["jev_probabilities"]
check("Zero probability items filtered out", "تسمه فلزی" not in m0_probs)
check("Non-zero probability preserved exactly", m0_probs.get("میلگرد ۱۴ آجدار") == 0.95)
check("_none with non-zero probability preserved", m0_probs.get("_none") == 0.05)
check("Catalog candidates list complete and untouched", len(passed_items[0]["candidates"]) > 0)
check("Arbiter endorsed valid Jev choice as exact", res[normalize_text("میلگرد ۱۴")]["status"] == "exact")
check("Arbiter vetoed wrong Jev choice to new", res[normalize_text("لوله ۶ اینچ")]["status"] == "new" and res[normalize_text("لوله ۶ اینچ")]["id"] is None)
finally:
settings.TYPESAFE_API_KEY = saved_key
matching.jev_ask = orig_jev_ask
matching.adjudicate_with_deepseek = orig_arbiter
async def test_defensive_failure_guard() -> None:
"""3. Universal Fail-Stop Guard: If DeepSeek (fallback or arbiter or NLU) fails, MatchingFailureError is raised."""
ctx = SimpleNamespace(resolver=FakeResolver())
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result=None)
async def boom_fallback(*args, **kwargs):
raise MatchingFailureError("DeepSeek API 500 error")
orig_deepseek = matching.match_with_deepseek
matching.match_with_deepseek = boom_fallback
try:
mentions = [{"field": "material", "variants": ["سیمان"]}]
failed = False
try:
await matching.resolve_mentions(ctx, {"items": []}, mentions, trace_id="t2")
except MatchingFailureError:
failed = True
check("Crisis fallback failure raises MatchingFailureError (stops cleanly)", failed)
finally:
matching.match_with_deepseek = orig_deepseek
# Also test Arbiter failure raises MatchingFailureError
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result={"answers": {"mention_0": {"choice": "m1", "confidence": 0.9}}})
async def boom_arbiter(*args, **kwargs):
raise MatchingFailureError("Arbiter DeepSeek timeout")
orig_arbiter = matching.adjudicate_with_deepseek
matching.adjudicate_with_deepseek = boom_arbiter
try:
failed_arb = False
try:
await matching.resolve_mentions(ctx, {"items": []}, mentions, trace_id="t2_arb")
except MatchingFailureError:
failed_arb = True
check("Arbiter failure raises MatchingFailureError (stops cleanly)", failed_arb)
finally:
matching.adjudicate_with_deepseek = orig_arbiter
# Test NLUFailureError is subclass of Exception
from app.nlu import NLUFailureError
check("NLUFailureError is subclass of Exception", issubclass(NLUFailureError, Exception))
async def test_quote_resolve_payload_zero_difflib() -> None:
"""3. resolve_payload in quote.py respects Jev/fallback map and never runs difflib."""
ctx = SimpleNamespace(resolver=FakeResolver())
payload = {
"supplier_name": "تأمین‌کننده البرز",
"items": [
{"material_name": "لوله ۶ اینچ", "unit_name": "شاخه"},
{"material_name": "میلگرد ۱۴", "unit_name": "شاخه"},
],
}
jev_map = {
normalize_text("تأمین‌کننده البرز"): {
"field": "supplier", "status": "new", "variants": ["تأمین‌کننده البرز"],
"inferred_category_slug": "metals-steel-sections",
},
normalize_text("لوله ۶ اینچ"): {
"field": "material", "status": "new", "variants": ["لوله ۶ اینچ"],
},
normalize_text("میلگرد ۱۴"): {
"field": "material", "status": "exact", "id": "m1", "name": "میلگرد ۱۴ آجدار",
"variants": ["میلگرد ۱۴"],
},
normalize_text("شاخه"): {
"field": "unit", "status": "exact", "id": "u1", "name": "شاخه", "variants": ["شاخه"],
},
}
await quote.resolve_payload(ctx, payload, trace_id="t3", jev_map=jev_map)
check("New supplier kept id=None (no difflib forced match)", payload.get("supplier_id") is None)
check("New supplier inferred category saved", payload.get("supplier_inferred_category_slug") == "metals-steel-sections")
check("New material kept id=None (no difflib forced match to 4-inch pipe)", payload["items"][0].get("material_id") is None)
check("Exact material bound to catalog ID", payload["items"][1].get("material_id") == "m1")
check("Exact unit bound to catalog ID", payload["items"][0].get("unit_id") == "u1")
async def test_quote_commit_defensive_get_or_create() -> None:
"""4. commit in quote.py uses get_or_create to prevent 409 conflict errors."""
api = FakeApi()
resolver = FakeResolver()
ctx = SimpleNamespace(api=api, resolver=resolver, pending={},
send=lambda *a, **k: asyncio.sleep(0.0001))
user = SimpleNamespace(actor_id="usr_1")
# Mock obs calls so offline test doesn't require live DB connection
orig_latest = quote.obs.latest_trace_for_draft
orig_link = quote.obs.link_draft
orig_event = quote.obs.event
quote.obs.latest_trace_for_draft = lambda did: asyncio.sleep(0.0001, result="mock_trace")
quote.obs.link_draft = lambda *a, **k: asyncio.sleep(0.0001)
quote.obs.event = lambda *a, **k: asyncio.sleep(0.0001)
# Item with name "میلگرد ۱۴ آجدار" that already exists in DB
payload = {
"supplier_name": "فولاد خوزستان", # Already in DB
"supplier_inferred_category_slug": "metals-steel-sections",
"items": [
{"material_name": "میلگرد ۱۴ آجدار", "unit_id": 1, "quantity": 10, "unit_price": 25000},
{"material_name": "تیرآهن ۱۸", "unit_id": 1, "quantity": 5, "unit_price": 50000}, # Brand new
],
"project_name": "پروژه پردیس", # Already in DB
}
draft = SimpleNamespace(id="d1", payload=payload, review_message_id=None)
# Mock create_quote
api.create_quote = lambda body, actor_id: asyncio.sleep(0.001, result={"id": "q1"})
# Mock draft_store.set_status
quote.draft_store.set_status = lambda did, st: asyncio.sleep(0.001)
try:
await quote.commit(ctx, chat_id=123, user=user, draft=draft, only_resolved=False)
check("Existing supplier reused ID from DB (not recreated)", payload["supplier_id"] == "s1")
check("Existing material reused ID from DB on conflict (not crashing)", payload["items"][0]["material_id"] == "m1")
check("New material created cleanly", payload["items"][1]["material_id"] == "m_new_1")
check("Existing project reused ID from DB", payload["project_id"] == "p1")
finally:
quote.obs.latest_trace_for_draft = orig_latest
quote.obs.link_draft = orig_link
quote.obs.event = orig_event
async def main() -> None:
print("Running DeepSeek Crisis Fallback & Zero-Difflib Tests...\n")
orig_fb_event = fallback_matcher.obs.event
fallback_matcher.obs.event = lambda *a, **k: asyncio.sleep(0.0001)
try:
await test_jev_failure_triggers_fallback()
await test_two_stage_adjudication_flow()
await test_defensive_failure_guard()
await test_quote_resolve_payload_zero_difflib()
await test_quote_commit_defensive_get_or_create()
finally:
fallback_matcher.obs.event = orig_fb_event
total = len(PASSED) + len(FAILED)
print(f"\n{len(PASSED)}/{total} passed")
if FAILED:
print("FAILED:", FAILED)
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
+115
View File
@@ -0,0 +1,115 @@
"""Phase-2 tests for the Jev client — MOCKED HTTP (no network, no key needed).
docker compose exec bot python tools/test_jev.py
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app import jev
from app.config import settings
PASSED: list[str] = []
FAILED: list[str] = []
def check(name: str, ok: bool, extra: str = "") -> None:
(PASSED if ok else FAILED).append(name)
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}")
class FakeResp:
def __init__(self, status: int, body: dict):
self.status_code = status
self._body = body
self.text = str(body)
def json(self):
return self._body
class FakeClient:
"""Scripted httpx.AsyncClient stand-in: pops one (status, body) per POST."""
script: list = []
posts: int = 0
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url, json=None, headers=None):
FakeClient.posts += 1
status, body = FakeClient.script.pop(0)
return FakeResp(status, body)
Q = {"m0": {"type": "choice", "instructions": "i", "criteria": {"x": ""}}}
async def main() -> None:
orig_client = jev.httpx.AsyncClient
saved_key = settings.TYPESAFE_API_KEY
jev.httpx.AsyncClient = FakeClient
try:
# 1) no key -> None (no HTTP at all)
settings.TYPESAFE_API_KEY = ""
FakeClient.posts = 0
r = await jev.ask({"s": 1}, Q)
check("no api key -> None (no request)", r is None and FakeClient.posts == 0)
settings.TYPESAFE_API_KEY = saved_key or "test-key"
# 2) happy path: answers + model + usage + latency
FakeClient.script = [(200, {"model": "jev-1.13.0",
"answers": {"m0": {"choice": "x", "confidence": 0.9,
"probabilities": {"x": 0.9}}},
"usage": {"input_tokens": 10, "output_tokens": 2}})]
r = await jev.ask({"s": 1}, Q)
ok = bool(r) and r["answers"]["m0"]["choice"] == "x" and r["model"] == "jev-1.13.0" \
and r["usage"]["input_tokens"] == 10 and isinstance(r["latency_ms"], int)
check("happy path -> answers/model/usage/latency", ok)
# 3) 429 -> one retry, then success
FakeClient.script = [(429, {"error": "rate_limited"}),
(200, {"model": "jev-1.13.0", "answers": {}, "usage": {}})]
FakeClient.posts = 0
r = await jev.ask({"s": 1}, Q)
check("429 retried once then succeeds", r is not None and FakeClient.posts == 2)
# 4) hard 400 -> None, no retry
FakeClient.script = [(400, {"error": "bad_request"})]
FakeClient.posts = 0
r = await jev.ask({"s": 1}, Q)
check("400 -> None without retry", r is None and FakeClient.posts == 1)
# 5) exception -> retried once -> None
class BoomClient(FakeClient):
async def post(self, url, json=None, headers=None):
FakeClient.posts += 1
raise RuntimeError("net down")
jev.httpx.AsyncClient = BoomClient
FakeClient.posts = 0
r = await jev.ask({"s": 1}, Q)
check("exception retried once -> None", r is None and FakeClient.posts == 2)
finally:
jev.httpx.AsyncClient = orig_client
settings.TYPESAFE_API_KEY = saved_key
total = len(PASSED) + len(FAILED)
print(f"\n{len(PASSED)}/{total} passed")
if FAILED:
print("FAILED:", FAILED)
raise SystemExit(1)
if __name__ == "__main__":
asyncio.run(main())
+241
View File
@@ -0,0 +1,241 @@
"""Phase 3-5 tests for entity matching (mock by default; live with RUN_LIVE=1).
docker compose exec bot python tools/test_matching.py # mock only
docker compose exec -e RUN_LIVE=1 bot python tools/test_matching.py # + real NLU + Jev
The live section calls DeepSeek (NLU) and TypeSafe (Jev) with the REAL keys
from bot/.env — it never sends anything to Bale and never creates drafts.
"""
import asyncio
import os
import sys
from types import SimpleNamespace
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app import matching
from app.config import settings
from app.nlu import normalize_text
from app.resolve import Candidate
PASSED: list[str] = []
FAILED: list[str] = []
def check(name: str, ok: bool, extra: str = "") -> None:
(PASSED if ok else FAILED).append(name)
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}")
class FakeResolver:
async def projects(self):
return [Candidate(id="p1", name="دفتر"), Candidate(id="p2", name="منشانه")]
async def all_suppliers(self):
return [Candidate(id="s1", name="بازرگانی مصطفوی",
extra={"location": "اصفهان", "category_name": "مصالح"})]
async def all_materials(self):
return [Candidate(id="m1", name="گچ و خاک", extra={"canonical_unit_id": 2})]
async def brands(self):
return [Candidate(id="b1", name="سیمان تهران")]
async def specs(self):
return [Candidate(id="سایز ۱۴", name="سایز ۱۴")]
async def units(self):
return [Candidate(id="u1", name="کیسه"), Candidate(id="u2", name="عدد")]
def make_fake_ask(answers_by_mention: dict, model: str = "jev-1.13.0"):
async def fake_ask(state, questions):
if fake_ask.fail:
return None
return {"answers": answers_by_mention, "usage": {"input_tokens": 1, "output_tokens": 1},
"model": model, "latency_ms": 7}
fake_ask.fail = False
return fake_ask
async def mock_tests() -> None:
ctx = SimpleNamespace(resolver=FakeResolver())
orig_ask = matching.jev_ask
# 1) routing bands: exact / ambiguity / low-trust / new + variant grouping
matching.jev_ask = make_fake_ask({
"mention_0": {"choice": "منشانه", "confidence": 0.9,
"probabilities": {"منشانه": 0.95, "_none": 0.05}},
"mention_1": {"choice": "دفتر", "confidence": 0.7,
"probabilities": {"دفتر": 0.73, "منشانه": 0.15, "_none": 0.12}},
"mention_2": {"choice": "_none", "confidence": 0.9,
"probabilities": {"_none": 0.94}},
"mention_3": {"choice": "دفتر", "confidence": 0.5,
"probabilities": {"دفتر": 0.52, "_none": 0.48}},
})
orig_fallback = matching.match_with_deepseek
async def mock_fallback_below_floor(*args, **kwargs):
ms = kwargs.get("mentions") or (args[1] if len(args) > 1 else [])
return {normalize_text(v): {"status": "new", "field": m.get("field"), "variants": m.get("variants"), "id": None}
for m in ms for v in m.get("variants", [])}
matching.match_with_deepseek = mock_fallback_below_floor
orig_arbiter = matching.adjudicate_with_deepseek
async def mock_arbiter(*args, **kwargs):
items = kwargs.get("items") or (args[2] if len(args) > 2 else [])
res = {}
for it in items:
choice = it.get("jev_choice")
conf = it.get("jev_confidence", 0.0)
if choice == "منشانه" and conf >= 0.8:
entry = {"status": "exact", "id": "p2", "name": "منشانه", "field": it["field"], "variants": it["variants"]}
elif choice == "دفتر" and conf >= 0.6:
entry = {"status": "ambiguity", "id": "p1", "name": "دفتر", "field": it["field"], "variants": it["variants"],
"candidates": [{"id": "p1", "name": "دفتر", "prob": conf}]}
else:
entry = {"status": "new", "id": None, "name": None, "field": it["field"], "variants": it["variants"], "candidates": []}
for v in it["variants"]:
res[normalize_text(v)] = entry
return res
matching.adjudicate_with_deepseek = mock_arbiter
mentions = [
{"field": "project", "variants": ["منشیانه"]},
{"field": "project", "variants": ["دفتر", "دفر"]},
{"field": "project", "variants": ["چیز عجیب"]},
{"field": "project", "variants": ["دفر دو"]},
]
try:
res = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
finally:
matching.match_with_deepseek = orig_fallback
matching.adjudicate_with_deepseek = orig_arbiter
r0 = matching.lookup(res, "منشیانه")
r1 = matching.lookup(res, "دفر") # variant key resolves too
r2 = matching.lookup(res, "چیز عجیب")
check("exact band -> status exact + id", r0 and r0["status"] == "exact" and r0["id"] == "p2")
check("mid band -> ambiguity + candidates", r1 and r1["status"] == "ambiguity"
and r1["candidates"][0]["name"] == "دفتر")
check("variant of same mention shares one result", r1 is r0 if False else
(r1 and matching.lookup(res, "دفتر") is r1))
check("_none / low conf -> new", r2 and r2["status"] == "new")
check("below-floor pick -> routed to crisis fallback",
matching.lookup(res, "دفر دو") and matching.lookup(res, "دفر دو")["status"] == "new")
check("below-floor pick did not pollute other mentions", r1 and r1["status"] == "ambiguity")
# 2) Jev failure -> DeepSeek crisis fallback
matching.jev_ask = make_fake_ask({})
matching.jev_ask.fail = True
async def mock_deepseek(*args, **kwargs):
return {normalize_text("منشیانه"): {"status": "exact", "id": "p2", "name": "منشانه",
"field": "project", "variants": ["منشیانه"]}}
matching.match_with_deepseek = mock_deepseek
try:
res_fail = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
check("jev failure -> triggers crisis fallback",
normalize_text("منشیانه") in res_fail and res_fail[normalize_text("منشیانه")]["status"] == "exact")
finally:
matching.match_with_deepseek = orig_fallback
# 3) apply_exact writes supplier/project ids into a payload
payload = {"items": []}
matching.apply_exact(payload, {"field": "project", "id": "p2", "name": "منشانه",
"variants": ["منشیانه"], "scores": {}})
matching.apply_exact(payload, {"field": "supplier", "id": "s1", "name": "بازرگانی مصطفوی",
"extra": {"location": "اصفهان"}, "variants": ["مستفدی"]})
check("apply_exact -> project_id/resolved_projects/project_ids",
payload.get("project_id") == "p2"
and payload["resolved_projects"].get("منشیانه") == "p2")
check("apply_exact -> supplier_id/name/extra",
payload.get("supplier_id") == "s1" and payload.get("supplier_location") == "اصفهان")
# 4) material/unit/brand/spec application on items
payload2 = {"items": [{"material_name": "مصطفی", "unit_name": "کیسه",
"brand_name": "سیمان تهران", "specs": ["سایز 14"]}]}
matching.apply_choice(payload2, "material", {"id": "m1", "name": "گچ و خاک",
"extra": {"canonical_unit_id": 2}},
["مصطفی"])
matching.apply_choice(payload2, "unit", {"id": "u1", "name": "کیسه"}, ["کیسه"])
matching.normalize_brand_spec(payload2, {normalize_text("سایز 14"): {
"field": "spec", "status": "exact", "name": "سایز ۱۴", "id": "سایز ۱۴",
"variants": ["سایز 14"]}})
it = payload2["items"][0]
check("material/unit applied on matching items",
it["material_id"] == "m1" and it["unit_id"] == "u1" and it["unit_explicit"] is True)
check("spec normalization (spelling only)", it["specs"][0] == "سایز ۱۴")
# 5) create_ambiguities builds an entity_match entry with percentages
payload3 = {"items": [], "ambiguities": []}
jev_map = {normalize_text("منشیانه"): {
"field": "project", "status": "ambiguity", "variants": ["منشیانه"],
"candidates": [{"id": "p2", "name": "منشانه", "prob": 0.8},
{"id": "p1", "name": "دفتر", "prob": 0.02}]}}
matching.create_ambiguities(payload3, jev_map)
ambs = payload3["ambiguities"]
check("entity_match ambiguity created with candidates + details",
len(ambs) == 1 and ambs[0]["kind"] == "entity_match"
and len(ambs[0]["candidates"]) == 2 and "۸۰٪" in ambs[0]["details"])
matching.create_ambiguities(payload3, jev_map) # idempotent
check("create_ambiguities is deduped", len(payload3["ambiguities"]) == 1)
matching.jev_ask = orig_ask
# ---------------------------------------------------------------------------
# live section (real NLU + real Jev) — opt-in and Bale-free
# ---------------------------------------------------------------------------
HARNESS_TRANSCRIPT = (
"مدل 1 (Whisper): الان شبت بخیر می خواستیم که بیست و پنج تا کیسه گچ و خاک از مصطفوی بخریم "
"و کنارش هم یه پنجاه تا کیسه سیمان پنجاه کیلویی. خریدمون هم عمدتا برای منشیانه و دفتر. "
"پیش فاکتورم گفته تا حدودا بیست روزی اوکیه.\n"
"مدل 2 (Vosk): میخواستیم 25 تا کیسه گچ و خاک از مستفدی بخریم و کنارشم یه 50 تا کیسه سیمان "
"50 کیلویی، خریدمون هم برای منشیانه و دفتر."
)
async def live_tests() -> None:
from app.api.client import ErpClient
from app.resolve import Resolver
from app.nlu import understand
api = ErpClient(settings.API_BASE_URL, settings.API_TIMEOUT_SECONDS, settings.SERVICE_TOKEN)
ctx = SimpleNamespace(resolver=Resolver(api))
print("\n--- LIVE: NLU entity_mentions ---")
result = await understand(settings, HARNESS_TRANSCRIPT)
mentions = result.get("entity_mentions") or []
print("intent:", result.get("intent"), "| mentions:", mentions)
check("live NLU produced entity_mentions", len(mentions) >= 2, f"{len(mentions)} mentions")
sup = next((m for m in mentions if m["field"] == "supplier"), None)
check("voice spellings grouped into ONE supplier mention",
bool(sup) and len(sup["variants"]) >= 2, str(sup))
print("\n--- LIVE: Jev per-field matching (real data) ---")
jev_map = await matching.resolve_mentions(ctx, result.get("patch") or {}, mentions, None)
for key, r in sorted(jev_map.items()):
print(f" {key!r}: {r['status']} -> {r.get('name')!r} conf={r.get('confidence')}")
check("live matching returned results", len(jev_map) > 0, f"{len(jev_map)} variants")
async def main() -> None:
await mock_tests()
if os.environ.get("RUN_LIVE") == "1":
if not settings.TYPESAFE_API_KEY:
check("live section requires TYPESAFE_API_KEY", False, "bot/.env not mounted?")
else:
await live_tests()
else:
print("\n(live section skipped — run with RUN_LIVE=1 to hit real NLU + Jev)")
total = len(PASSED) + len(FAILED)
print(f"\n{len(PASSED)}/{total} passed")
if FAILED:
print("FAILED:", FAILED)
raise SystemExit(1)
if __name__ == "__main__":
asyncio.run(main())
+140
View File
@@ -0,0 +1,140 @@
"""Phase-1 tests for the Observability subsystem (run INSIDE the bot container):
docker compose exec bot python tools/test_observability.py
Covers: _jsonable safety (UUID/datetime/Decimal/set/enum/dataclass), trace
lifecycle, event round-trip through JSONB, draft linking, status updates,
save_voice (day-folder mkdir), and the span() context manager.
NOTE (policy): test rows/files are tagged `_test` and are NEVER deleted —
the observability subsystem has no retention/cleanup by design.
"""
import asyncio
import dataclasses
import sys
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import select
from app import observability as obs
from app.db import SessionLocal
from app.db_init import ensure_schema
from app.models import BotTrace, BotTraceEvent
PASSED: list[str] = []
FAILED: list[str] = []
def check(name: str, ok: bool, extra: str = "") -> None:
(PASSED if ok else FAILED).append(name)
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}")
class SampleColor(Enum):
RED = "red"
@dataclasses.dataclass
class SampleRec:
a: int
b: str
def test_jsonable() -> None:
payload = {
"uuid": uuid.uuid4(),
"dt": datetime(2026, 10, 12, 10, 30, tzinfo=timezone.utc),
"dec": Decimal("123.45"),
"set": {"x", "y"},
"enum": SampleColor.RED,
"dc": SampleRec(a=1, b="two"),
"nested": [{"inner": uuid.UUID(int=1)}],
"none": None,
}
out = obs._jsonable(payload)
ok = (
isinstance(out["uuid"], str)
and isinstance(out["dt"], str)
and isinstance(out["dec"], str)
and isinstance(out["set"], list)
and out["enum"] == "red"
and isinstance(out["dc"], dict)
)
check("_jsonable produces JSON-serializable output", ok,
f"dt={out['dt'][:19]}, dec={out['dec']!r}")
async def main() -> None:
await ensure_schema()
test_jsonable()
# 1) trace lifecycle
tid = await obs.trace_start(chat_id=0, message_id=999001, kind="voice")
check("trace_start returns an id", bool(tid), str(tid))
# 2) events with tricky payloads (must not raise; must be stored)
await obs.event(tid, "input.voice", payload={
"_test": True, "size": 1234,
"when": datetime.now(timezone.utc),
"amount": Decimal("9.9"),
})
await obs.event(tid, "routing.decision", field="project", payload={
"_test": True,
"candidates": [{"id": uuid.uuid4(), "name": "منشانه"}],
})
await obs.log_resolution(tid, "supplier", "مصطفوی", "many",
[{"id": "x", "name": "بازرگانی مصطفوی"}])
# 3) draft linking + latest lookup
draft_id = str(uuid.uuid4())
await obs.link_draft(tid, draft_id, "quote")
latest = await obs.latest_trace_for_draft(draft_id)
check("latest_trace_for_draft returns the linked trace", latest == tid, latest)
# 4) status update + JSONB round-trip
await obs.set_trace_status(tid, "committed", str(uuid.uuid4()))
async with SessionLocal() as s:
tr = (await s.execute(select(BotTrace).where(BotTrace.id == tid))).scalar_one()
check("trace status + quote id updated",
tr.status == "committed" and tr.committed_quote_id is not None)
rows = (await s.execute(
select(BotTraceEvent).where(BotTraceEvent.trace_id == tid)
.order_by(BotTraceEvent.id)
)).scalars().all()
check("events persisted", len(rows) >= 3, f"{len(rows)} rows")
proj = next((r for r in rows if r.stage == "routing.decision"), None)
check("JSONB payload round-trip keeps values as strings",
proj is not None and isinstance(proj.payload["candidates"][0]["id"], str))
# 5) save_voice (parents mkdir + day folder)
path = obs.save_voice(tid, 999001, b"OGGFAKE", ext=".ogg")
check("save_voice writes file under day folder",
bool(path) and Path(path).exists(), str(path))
# 6) span() context manager (fire-and-forget task)
with obs.span("span.test", trace_id=tid, note="unit"):
pass
await asyncio.sleep(0.4)
async with SessionLocal() as s:
n = (await s.execute(
select(BotTraceEvent).where(
BotTraceEvent.trace_id == tid, BotTraceEvent.stage == "span.test"
)
)).scalars().all()
check("span() recorded via background task", len(n) == 1, f"{len(n)} rows")
total = len(PASSED) + len(FAILED)
print(f"\n{len(PASSED)}/{total} passed")
if FAILED:
print("FAILED:", FAILED)
raise SystemExit(1)
if __name__ == "__main__":
asyncio.run(main())
+3
View File
@@ -322,6 +322,9 @@ services:
volumes:
- ./bot/app:/srv/bot/app
- ./bot/tools:/srv/bot/tools
- ./bot/data:/srv/bot/data
- ./bot/.env:/srv/bot/.env:ro
environment:
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}