From a61da4a378c60852f210db317f2606c42978a1d5 Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Mon, 12 Oct 2026 21:42:32 +0330 Subject: [PATCH] =?UTF-8?q?feat(bot):=20phase-6=20calibration=20=E2=80=94?= =?UTF-8?q?=20mock=20seed,=20A/B=20harness,=20low-trust=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bot/app/config.py | 4 +- bot/app/matching.py | 28 +- bot/tools/calibrate_matching.py | 257 +++++++++++++++ bot/tools/seed_mock.py | 533 ++++++++++++++++++++++++++++++++ bot/tools/test_matching.py | 8 +- docker-compose.yml | 1 + 6 files changed, 821 insertions(+), 10 deletions(-) create mode 100644 bot/tools/calibrate_matching.py create mode 100644 bot/tools/seed_mock.py diff --git a/bot/app/config.py b/bot/app/config.py index a7f5406..f652745 100644 --- a/bot/app/config.py +++ b/bot/app/config.py @@ -147,7 +147,9 @@ class Settings(BaseSettings): JEV_ENABLED: bool = True JEV_TIMEOUT: float = 8.0 JEV_CONF_EXACT: float = 0.85 - JEV_CONF_FLOOR: float = 0.60 + 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" diff --git a/bot/app/matching.py b/bot/app/matching.py index 35d72e2..5174393 100644 --- a/bot/app/matching.py +++ b/bot/app/matching.py @@ -168,18 +168,30 @@ async def resolve_mentions(ctx, patch: dict, mentions: list[dict], trace_id) -> key=lambda x: -x["prob"], )[:6] + base = {"field": field, "variants": m["variants"], "confidence": conf, + "choice": (cand.name if cand is not None else None), + "candidates": ranked} + if cand is not None and conf >= settings.JEV_CONF_EXACT: - res = {"field": field, "status": "exact", "variants": m["variants"], - "id": cand.id, "name": cand.name, "extra": cand.extra or {}, - "confidence": conf} + res = {**base, "status": "exact", "id": cand.id, "name": cand.name, + "extra": cand.extra or {}} elif cand is not None and conf >= settings.JEV_CONF_FLOOR: - res = {"field": field, "status": "ambiguity", "variants": m["variants"], - "id": cand.id, "name": cand.name, "extra": cand.extra or {}, - "confidence": conf, + res = {**base, "status": "ambiguity", "id": cand.id, "name": cand.name, + "extra": cand.extra or {}, "candidates": ranked or [{"id": cand.id, "name": cand.name, "prob": conf}]} + elif cand is None: + # Explicit «_none» (or an unusable choice): the model judged the whole + # list → treat as new (the auto-create path handles it). + res = {**base, "status": "new"} else: - res = {"field": field, "status": "new", "variants": m["variants"], - "confidence": conf, "candidates": ranked} + # Picked something but below the floor = low trust: leave NO result so + # the difflib fallback still gets its chance (suppressing it here would + # let a correct-but-under-confident pick create duplicates instead). + await obs.event(trace_id, "routing.decision", field=field, payload={ + "source": "jev-lowtrust", "variants": m["variants"], + "choice": cand.name, "confidence": conf, + }) + continue for v in m["variants"]: results[normalize_text(v)] = res decisions.append({"variants": m["variants"], "status": res["status"], diff --git a/bot/tools/calibrate_matching.py b/bot/tools/calibrate_matching.py new file mode 100644 index 0000000..c2aecd8 --- /dev/null +++ b/bot/tools/calibrate_matching.py @@ -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()) diff --git a/bot/tools/seed_mock.py b/bot/tools/seed_mock.py new file mode 100644 index 0000000..5284b14 --- /dev/null +++ b/bot/tools/seed_mock.py @@ -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()) diff --git a/bot/tools/test_matching.py b/bot/tools/test_matching.py index 13cc345..1cdf9f3 100644 --- a/bot/tools/test_matching.py +++ b/bot/tools/test_matching.py @@ -63,7 +63,7 @@ async def mock_tests() -> None: ctx = SimpleNamespace(resolver=FakeResolver()) orig_ask = matching.jev_ask - # 1) routing bands: exact / ambiguity / new + variant grouping + # 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}}, @@ -71,11 +71,14 @@ async def mock_tests() -> None: "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}}, }) mentions = [ {"field": "project", "variants": ["منشیانه"]}, {"field": "project", "variants": ["دفتر", "دفر"]}, {"field": "project", "variants": ["چیز عجیب"]}, + {"field": "project", "variants": ["دفر دو"]}, ] res = await matching.resolve_mentions(ctx, {"items": []}, mentions, None) r0 = matching.lookup(res, "منشیانه") @@ -87,6 +90,9 @@ async def mock_tests() -> None: 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 -> NO result (difflib fallback keeps its chance)", + matching.lookup(res, "دفر دو") is None) + check("below-floor pick did not pollute other mentions", r1 and r1["status"] == "ambiguity") # 2) Jev failure -> empty map (callers fall back to difflib) matching.jev_ask = make_fake_ask({}) diff --git a/docker-compose.yml b/docker-compose.yml index 9544234..dabd254 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -322,6 +322,7 @@ services: volumes: - ./bot/app:/srv/bot/app + - ./bot/tools:/srv/bot/tools - ./bot/data:/srv/bot/data - ./bot/.env:/srv/bot/.env:ro