- 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
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.
Downloads require an Authorization header, which <iframe>/<img> cannot
send. The previous blob: workaround broke under download managers (IDM
hooks blob URLs, fails to resolve them, and leaves a 0-byte file) and in
Chrome's mobile emulation, which has no inline PDF plugin.
Backend:
- preview_token.py: HMAC-signed token bound to one (quote, kind, file)
with a 5-minute TTL, keyed off an existing secret
- GET /quotes/{id}/preview-url/{kind}/{attachment} mints the URL
- the two file GET routes accept a Bearer session OR the signed ?t= token
(they leave the global auth dep, so the other six file routes now carry
it explicitly — nothing is open by omission)
Frontend:
- api.files.previewUrl() replaces the blob helper
- shared FilePreviewModal + useFilePreview; FileList and the dashboard
quote card both use it
- PDFs render with the browser's native viewer on desktop and pdf.js on
canvas where no inline viewer exists (mobile); pdf.js is fed the bytes
in memory so no PDF URL is exposed for a download manager to intercept
- removed the per-attachment download buttons on the dashboard card and
the modal header
Ops:
- nginx: explicit MIME types (.mjs as text/javascript) — a module worker
served as octet-stream is refused and crashed the page
- frontend service bypasses the nginx entrypoint, whose apk manifest call
hangs when the package mirror is unreachable (502s)
- slowapi limiter on POST /auth/login (10/min per IP, Persian error msg);
edge-level coarse limits stay in Traefik
- mem_limit on every main-compose service (~4.4GB worst case, protects the
8GB shared host during rebuilds)
- monitoring images pinned to verified tags (prom v3.4.1, grafana 11.6.3,
loki/promtail 3.5.0, node-exporter v1.9.1, postgres-exporter v0.17.1,
cadvisor v0.49.1); exporter image name fixed to postgres-exporter
- db-backup sidecar: pg_dump -Fc of procurement+casdoor every
BACKUP_INTERVAL_SECONDS into BACKUP_DIR, retention via BACKUP_KEEP_DAYS
- audit_log table (migration.sql + runtime ensure_audit_table) with
model + transactional helper (utils/audit.record) — wired into user
create/role-change and inventory transaction creation
- get_actor: quote_events.actor now stores the VERIFIED user's display
name instead of the raw bearer token
- remove the separate casdoor-postgres container; casdoor uses the main
postgres (db casdoor, created by sql/02-casdoor-db.sql on fresh volumes);
data migrated via pg_dump/pg_restore (backups kept outside the repo)
- Traefik v3.6 as the public edge (ports 80/443): docker provider pinned to
the dedicated traefik_network (fixed-name network), host rules for
app/api/auth/s3/storage (env-overridable, localhost defaults), frontend
nginx demoted to internal SPA server; docker-compose.prod.yml adds real
domains + Let's Encrypt + HTTP→HTTPS redirect (redirect middleware in
infra/traefik-dynamic.yml)
- v3.6 required (Traefik <=3.3 fails against Docker Engine 29.x); socket
mounted rw (Docker Desktop rejects :ro)
- start.ps1: app URL now http://localhost, casdoor + db notes
- seed_casdoor.py: JSON type=login + cookie session (the old form-encoded
login-normal API is gone), application with grantTypes incl. password,
passwordType=plain for the seeded users, idempotent re-runs; adds a
dedicated tester user
- docker-compose: Casdoor env for the api service (endpoint casdoor:8000
in-network, issuer http://localhost:8080 as advertised by discovery) and
the DEV_LOGIN_ENABLED build arg for the frontend