4 Commits
Author SHA1 Message Date
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
27 changed files with 2863 additions and 112 deletions
+3
View File
@@ -2,6 +2,9 @@
.env
*.local
# ---- Runtime data (voices etc.) — never commit, never delete ----
data/
# ---- Python ----
__pycache__/
*.pyc
+7 -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 []
@@ -100,6 +100,9 @@ class ErpClient:
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,
+19
View File
@@ -133,6 +133,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)",
]
+57 -12
View File
@@ -11,6 +11,7 @@ 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.runtime import BotContext, LinkedUser
@@ -48,6 +49,16 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
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
@@ -79,11 +90,20 @@ 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,
)
except Exception as exc:
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
@@ -201,9 +221,23 @@ 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)
result = await understand(settings, input_payload, draft_summary, trace_id=trace_id)
intent, patch = result.get("intent"), result.get("patch") or {}
consensus_text = result.get("consensus_text") or text
mentions = result.get("entity_mentions") or []
await obs.event(trace_id, "entities.extracted",
payload={"patch": patch, "intent": intent, "entity_mentions": mentions})
# Phase 3: Jev per-field matching — only when it can be applied (flag-gated;
# a failure leaves jev_map empty and the difflib path runs unchanged).
jev_map: dict = {}
if settings.JEV_ENABLED and 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)
except Exception:
logger.exception("jev matching failed")
jev_map = {}
if source == "voice":
await ctx._log(chat_id, "in", "voice", consensus_text)
@@ -216,23 +250,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)
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)
@@ -253,11 +292,11 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
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)
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":
@@ -375,18 +414,24 @@ async def _handle_ambiguity_user_reply(
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"])
text = await transcribe(settings, audio_bytes)
obs.save_voice(trace_id, incoming_msg_id or 0, audio_bytes)
text = await transcribe(settings, audio_bytes, trace_id=trace_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)
result = await understand(settings, text, draft_summary, trace_id=trace_id)
patch = result.get("patch") or {}
# Apply user correction specifically to the target field
@@ -408,11 +453,11 @@ async def _handle_ambiguity_user_reply(
draft_store.merge_quote_patch(draft.payload, patch)
amb["status"] = "applied"
await quote.resolve_payload(ctx, draft.payload)
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)
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"):
+47 -1
View File
@@ -6,6 +6,7 @@ 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
@@ -77,6 +78,14 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> 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)
@@ -100,6 +109,38 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
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 -------------------------------
@@ -126,6 +167,9 @@ async def _dispatch(ctx, chat_id: int, data: str, cq: dict) -> None:
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:
@@ -151,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:
+40 -16
View File
@@ -2,6 +2,8 @@
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,
@@ -13,7 +15,9 @@ from app.texts import ERR_GENERIC, esc, material_committed_report, material_revi
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,37 @@ 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]]
else:
options.pop("unit", None)
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:
@@ -58,7 +76,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 +89,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 +115,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))
+14 -5
View File
@@ -2,6 +2,7 @@
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,
@@ -12,7 +13,8 @@ from app.texts import ERR_GENERIC, esc, project_committed_report, project_review
logger = logging.getLogger("bot.project")
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_PROJECT)
if draft is not None:
payload = draft.payload
@@ -21,15 +23,16 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -
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)
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) -> None:
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)
@@ -42,10 +45,13 @@ 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
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, "ثبت ممکن نیست؛ نام پروژه مشخص نشده است.")
@@ -63,5 +69,8 @@ async def commit(ctx, chat_id, user, draft) -> None:
await ctx.send(chat_id, ERR_GENERIC.format(detail="خطای سرور"))
return
await draft_store.set_status(draft.id, "committed")
ctx.resolver.invalidate("projects")
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))
+123 -51
View File
@@ -2,6 +2,8 @@
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,
@@ -20,6 +22,8 @@ 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:
@@ -47,7 +51,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:
@@ -55,52 +63,80 @@ 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
# ---------------------------------------------------------------------------
async def resolve_payload(ctx, payload: dict) -> None:
async def _log_res(trace_id, field: str, mention, res) -> None:
"""Observability: record one difflib resolution attempt (baseline for phase 6)."""
await obs.log_resolution(trace_id, field, mention, res.status,
[{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]])
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)
else:
options.pop("sup", None)
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
await _log_res(trace_id, "supplier", payload["supplier_name"], res)
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:
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 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"]
options.pop("prj", None)
else:
options.pop("prj", None)
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
await _log_res(trace_id, "project", payload["project_name"], res)
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 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]
else:
options.pop("prj", None)
for p_name in payload.get("project_names") or []:
hit = matching.lookup(jev_map, p_name)
if hit is not None:
if hit["status"] == "exact":
matching.apply_exact(payload, hit)
continue
res = await safe_resolve(ctx.resolver, "project", p_name)
await _log_res(trace_id, "project", p_name, res)
if res.resolved:
c = res.candidates[0]
p_id = c.id
@@ -113,16 +149,23 @@ async def resolve_payload(ctx, payload: dict) -> None:
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)
else:
options.pop(f"mat{i}", None)
res = await safe_resolve(ctx.resolver, "material", item["material_name"])
await _log_res(trace_id, "material", item["material_name"], res)
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]
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:
@@ -130,24 +173,36 @@ 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
item["unit_explicit"] = True
item["unit_name"] = res.candidates[0].name
return
hit = matching.lookup(jev_map, item["unit_name"])
if hit is not None:
if hit["status"] == "exact":
item["unit_id"] = hit["id"]
item["unit_explicit"] = True
item["unit_name"] = hit["name"]
return
else:
res = await safe_resolve(ctx.resolver, "unit", item["unit_name"])
await _log_res(trace_id, "unit", item["unit_name"], res)
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)
@@ -163,7 +218,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 []
@@ -172,9 +227,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)
# ---------------------------------------------------------------------------
@@ -214,7 +269,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
@@ -263,6 +318,9 @@ 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
@@ -275,6 +333,7 @@ async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
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"):
@@ -289,6 +348,10 @@ async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
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)
@@ -298,9 +361,11 @@ async def send_pending_ambiguities(ctx, chat_id: int, draft) -> None:
# 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)
newly_created: dict = {}
@@ -418,6 +483,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"):
+19 -8
View File
@@ -2,6 +2,7 @@
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,
@@ -13,7 +14,8 @@ from app.texts import ERR_GENERIC, esc, supplier_committed_report, supplier_revi
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,15 +24,18 @@ 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"])
await obs.log_resolution(trace_id, "category", payload["category_name"], res.status,
[{"id": c.id, "name": c.name} for c in (res.candidates or [])[:4]])
if res.resolved:
payload["category_id"] = res.candidates[0].id
payload["category_name"] = res.candidates[0].name
@@ -58,7 +63,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 +76,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 +107,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
+10
View File
@@ -81,3 +81,13 @@ def ambiguity_keyboard(prefix: str, amb_id: str) -> dict:
[_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)
+333
View File
@@ -0,0 +1,333 @@
"""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» را انتخاب کن."
)
# ---------------------------------------------------------------------------
# the per-field fan-out
# ---------------------------------------------------------------------------
async def resolve_mentions(ctx, patch: dict, mentions: list[dict], trace_id) -> dict:
"""Run Jev per field (parallel); 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
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)
return
if not cands:
return
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:
# Field-wide failure → leave no results so callers fall back to difflib.
return
answers = resp.get("answers") or {}
decisions = []
for i, m in enumerate(capped):
ans = answers.get(f"mention_{i}") or {}
if not ans:
# No answer for this mention → difflib fallback for it.
continue
choice = ans.get("choice")
conf = float(ans.get("confidence") or 0.0)
probs = ans.get("probabilities") or {}
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 probs.items() if n != "_none" and n in by_name),
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 = {**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 = {**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:
# 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"],
"choice": res.get("name"), "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", "mentions": decisions})
await asyncio.gather(*(one_field(f, ms) for f, ms in by_field.items()))
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()
+59 -3
View File
@@ -4,12 +4,14 @@ 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")
@@ -23,6 +25,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)."""
@@ -58,6 +77,7 @@ SYSTEM_PROMPT = """تو دستیار هوشمند تدارکات یک سیستم
"consensus_text": "بیانیه رسمی، پالایش‌شده، فشرده و بدون تکیه‌کلام از تقاضای تدارکات کاربر",
"intent": یکی از {intents},
"patch": {شکل partial هر فعالیتی که کاربر داده},
"entity_mentions": [{"field": "project|supplier|material|brand|spec|unit", "variants": ["همه‌ی نگارش‌ها"]}],
"confidence": 0.0..1.0
}
@@ -93,6 +113,15 @@ SYSTEM_PROMPT = """تو دستیار هوشمند تدارکات یک سیستم
- اگر درباره پذیرش استعلام است → intent="accept_quote" و در patch.target نام تأمین‌کننده/استعلام.
- اگر درباره اعلام رسیدن اقلام است → intent="arrive_items" و در patch.target نام تأمین‌کننده/استعلام و در patch.arrivals مقادیر.
۶. استخراج موجودیت‌های قابل تطبیق (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_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}],
@@ -143,9 +172,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}
fallback = {"consensus_text": "", "intent": "unknown", "patch": {},
"entity_mentions": [], "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
@@ -172,6 +203,17 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
fallback["consensus_text"] = first_text
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(
@@ -180,7 +222,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,
@@ -189,16 +231,30 @@ 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])
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
payload={"error": f"HTTP {resp.status_code}", "body": resp.text[:300]})
return fallback
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])
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
payload={"error": "unparseable", "raw": content[:500]})
return fallback
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):
logger.exception("NLU failure")
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
payload={"error": "exception"})
return fallback
+206
View File
@@ -0,0 +1,206 @@
"""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
+105
View File
@@ -153,6 +153,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)."""
@@ -236,6 +273,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:
+33 -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,24 +58,30 @@ 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) -> 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:
@@ -83,20 +93,32 @@ async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | No
"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) -> 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)
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)
+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())
+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())
+202
View File
@@ -0,0 +1,202 @@
"""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}},
})
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, "منشیانه")
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 -> 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({})
matching.jev_ask.fail = True
res_fail = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
check("jev failure -> empty results (difflib fallback path)", res_fail == {})
# 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}