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.
123 lines
5.8 KiB
Python
123 lines
5.8 KiB
Python
"""Add-material flow: name + canonical unit (required)."""
|
|
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.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,
|
|
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
|
|
merge_material_patch(payload, patch)
|
|
else:
|
|
payload = new_material_payload()
|
|
merge_material_patch(payload, patch)
|
|
draft = await draft_store.create(chat_id, KIND_MATERIAL, payload, source=source)
|
|
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, trace_id=trace_id)
|
|
|
|
|
|
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"):
|
|
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]]
|
|
else:
|
|
res = await safe_resolve(ctx.resolver, "unit", payload["unit_name"])
|
|
await obs.log_resolution(trace_id, "unit", payload["unit_name"], res.status,
|
|
[{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]])
|
|
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]
|
|
else:
|
|
options.pop("unit", None)
|
|
|
|
|
|
async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> None:
|
|
if field == "unit":
|
|
draft.payload["canonical_unit_id"] = choice["id"]
|
|
draft.payload["unit_name"] = choice["name"]
|
|
draft.payload.setdefault("options", {}).pop("unit", None)
|
|
await draft_store.save_payload(draft, missing={"fields": material_missing(draft.payload)})
|
|
await render(ctx, chat_id, draft, fresh=False)
|
|
|
|
|
|
def _keyboard(payload: dict, draft_id: str) -> dict:
|
|
rows: list[list[dict]] = []
|
|
options = (payload.get("options") or {}).get("unit") or []
|
|
if options:
|
|
rows += pick_buttons(draft_id, "unit", [o["name"] for o in options[:6]])
|
|
return simple_review_keyboard(draft_id, KIND_MATERIAL, rows)
|
|
|
|
|
|
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)
|
|
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)
|
|
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")),
|
|
("unit", "واحد اصلی", missing.get("unit")),
|
|
] if not ok]
|
|
if problems:
|
|
await ctx.send(chat_id, "ثبت ممکن نیست؛ اول اینها را مشخص کن:\n" +
|
|
"\n".join(f"❌ {esc(p)}" for p in problems))
|
|
return
|
|
body = {"name": payload["name"], "canonical_unit_id": int(payload["canonical_unit_id"])}
|
|
if payload.get("topic"):
|
|
body["topic"] = payload["topic"]
|
|
try:
|
|
created = await ctx.api.create_material(body, actor_id=user.actor_id)
|
|
except ApiError as exc:
|
|
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
|
return
|
|
await draft_store.set_status(draft.id, "committed")
|
|
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))
|