Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
787824a19e | ||
|
|
9e848f535f | ||
|
|
76b297f823 | ||
|
|
b44816aa98 | ||
|
|
a61da4a378 | ||
|
|
1c853e50a9 | ||
|
|
d58b619107 |
@@ -31,6 +31,7 @@ scratch/
|
||||
v1.2 - Copy/
|
||||
inv dash/
|
||||
voice/
|
||||
data/
|
||||
|
||||
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
.env
|
||||
*.local
|
||||
|
||||
# ---- Runtime data (voices etc.) — never commit, never delete ----
|
||||
data/
|
||||
|
||||
# ---- Python ----
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Audio preprocessing & denoising pipeline for procurement bot.
|
||||
|
||||
Workflow:
|
||||
Raw Voice Bytes (Bale)
|
||||
→ enhance_voice() [Optional Audio Enhancement / Denoising API]
|
||||
- If disabled or missing URL → zero overhead pass-through
|
||||
- Zero transcoding by default (raw bytes sent as-is)
|
||||
- Transcoding to target format ONLY if explicitly configured
|
||||
- Graceful fail-soft on timeout or error (transparent raw audio fallback)
|
||||
- Enhanced bytes saved to data/voices/{YYYYMMDD}/{trace_id}_{message_id}_enhanced.{ext}
|
||||
- Observability events recorded (audio.enhance.result with ok/size/latency_ms/path)
|
||||
→ Multi-STT Providers
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app import observability as obs
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger("bot.audio_enhancer")
|
||||
|
||||
_CONVERT_TIMEOUT = 60
|
||||
|
||||
|
||||
def _transcode(raw: bytes, src_ext: str, target_fmt: str) -> bytes:
|
||||
"""Convert audio to target format using ffmpeg only when explicitly requested."""
|
||||
target_fmt = target_fmt.lstrip(".")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = Path(tmp) / f"in{src_ext or '.ogg'}"
|
||||
dst = Path(tmp) / f"out.{target_fmt}"
|
||||
src.write_bytes(raw)
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-y", "-i", str(src), "-vn", str(dst)],
|
||||
capture_output=True,
|
||||
timeout=_CONVERT_TIMEOUT,
|
||||
)
|
||||
if proc.returncode != 0 or not dst.exists():
|
||||
raise RuntimeError(f"ffmpeg conversion to {target_fmt} failed: {proc.stderr[-300:]!r}")
|
||||
return dst.read_bytes()
|
||||
|
||||
|
||||
def _resolve_extension(filename: str | None, mime_type: str | None) -> str:
|
||||
ext = Path(filename or "").suffix
|
||||
if not ext and mime_type:
|
||||
clean_mime = mime_type.split(";")[0].strip().lower()
|
||||
ext = {
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/opus": ".ogg",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/wav": ".wav",
|
||||
"audio/x-wav": ".wav",
|
||||
"audio/webm": ".webm",
|
||||
"audio/flac": ".flac",
|
||||
}.get(clean_mime, ".ogg")
|
||||
return ext or ".ogg"
|
||||
|
||||
|
||||
async def enhance_voice(
|
||||
settings: Settings,
|
||||
audio_bytes: bytes,
|
||||
mime_type: str | None = None,
|
||||
filename: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
message_id: int | None = None,
|
||||
) -> tuple[bytes, dict]:
|
||||
"""Enhance and denoise voice audio before STT.
|
||||
|
||||
Returns:
|
||||
(processed_audio_bytes, metadata)
|
||||
Guarantees:
|
||||
- Never raises an unhandled exception.
|
||||
- On any failure or timeout, gracefully returns original audio_bytes (fallback).
|
||||
- Enhanced bytes are saved to daily folder with _enhanced suffix when successful.
|
||||
- Observability events are captured.
|
||||
"""
|
||||
if not settings.AUDIO_ENHANCE_ENABLED or not (settings.AUDIO_ENHANCE_BASE_URL or "").strip():
|
||||
return audio_bytes, {"enhanced": False, "skipped": True}
|
||||
|
||||
base_url = settings.AUDIO_ENHANCE_BASE_URL.rstrip("/")
|
||||
api_key = settings.AUDIO_ENHANCE_API_KEY or ""
|
||||
model = settings.AUDIO_ENHANCE_MODEL or ""
|
||||
timeout = float(settings.AUDIO_ENHANCE_TIMEOUT_SECONDS or 60.0)
|
||||
target_format = (settings.AUDIO_ENHANCE_TARGET_FORMAT or "").strip().lower()
|
||||
method = (settings.AUDIO_ENHANCE_METHOD or "auto").strip().lower()
|
||||
|
||||
orig_ext = _resolve_extension(filename, mime_type)
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Condition 2: Zero transcoding unless explicitly requested by configuration
|
||||
payload_bytes = audio_bytes
|
||||
upload_ext = orig_ext
|
||||
if target_format and not orig_ext.endswith(f".{target_format}"):
|
||||
try:
|
||||
payload_bytes = await asyncio.to_thread(_transcode, audio_bytes, orig_ext, target_format)
|
||||
upload_ext = f".{target_format}"
|
||||
except Exception as exc:
|
||||
logger.warning("Target format conversion failed: %s; using raw bytes", exc)
|
||||
payload_bytes = audio_bytes
|
||||
upload_ext = orig_ext
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
upload_filename = f"voice{upload_ext}"
|
||||
req_mime = mime_type or "audio/ogg"
|
||||
|
||||
if method == "raw_stream":
|
||||
headers["Content-Type"] = req_mime
|
||||
resp = await client.post(base_url, content=payload_bytes, headers=headers)
|
||||
else:
|
||||
# Default / multipart upload (compatible with standard voice enhancement APIs)
|
||||
data = {"model": model} if model else {}
|
||||
files = {"file": (upload_filename, payload_bytes, req_mime)}
|
||||
resp = await client.post(base_url, files=files, data=data, headers=headers)
|
||||
|
||||
ms = int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
if resp.status_code == 200 and len(resp.content) > 0:
|
||||
enhanced_bytes = resp.content
|
||||
|
||||
# Determine response audio extension
|
||||
resp_ct = (resp.headers.get("content-type") or "").split(";")[0].strip().lower()
|
||||
out_ext = {
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/opus": ".ogg",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/wav": ".wav",
|
||||
"audio/x-wav": ".wav",
|
||||
}.get(resp_ct, upload_ext)
|
||||
|
||||
saved_path = obs.save_enhanced_voice(
|
||||
trace_id, message_id or 0, enhanced_bytes, ext=out_ext
|
||||
)
|
||||
|
||||
await obs.event(
|
||||
trace_id,
|
||||
"audio.enhance.result",
|
||||
ok=True,
|
||||
latency_ms=ms,
|
||||
model=model,
|
||||
payload={
|
||||
"path": saved_path,
|
||||
"size": len(enhanced_bytes),
|
||||
"original_size": len(audio_bytes),
|
||||
"provider": model or "audio_enhancer",
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"Audio enhancement succeeded in %d ms (size: %d -> %d bytes)",
|
||||
ms,
|
||||
len(audio_bytes),
|
||||
len(enhanced_bytes),
|
||||
)
|
||||
return enhanced_bytes, {
|
||||
"enhanced": True,
|
||||
"path": saved_path,
|
||||
"size": len(enhanced_bytes),
|
||||
"latency_ms": ms,
|
||||
}
|
||||
|
||||
# Non-200 or empty response -> graceful fallback
|
||||
err_msg = f"HTTP {resp.status_code}: {resp.text[:200]}"
|
||||
logger.warning("Audio enhancement failed: %s; falling back to raw audio", err_msg)
|
||||
await obs.event(
|
||||
trace_id,
|
||||
"audio.enhance.result",
|
||||
ok=False,
|
||||
latency_ms=ms,
|
||||
model=model,
|
||||
payload={"error": err_msg, "fallback": True},
|
||||
)
|
||||
return audio_bytes, {"enhanced": False, "fallback": True, "error": err_msg}
|
||||
|
||||
except Exception as exc:
|
||||
ms = int((time.perf_counter() - t0) * 1000)
|
||||
err_msg = str(exc)[:300]
|
||||
logger.warning("Audio enhancement exception: %s; falling back to raw audio", err_msg)
|
||||
await obs.event(
|
||||
trace_id,
|
||||
"audio.enhance.result",
|
||||
ok=False,
|
||||
latency_ms=ms,
|
||||
model=model,
|
||||
payload={"error": err_msg, "fallback": True},
|
||||
)
|
||||
return audio_bytes, {"enhanced": False, "fallback": True, "error": err_msg}
|
||||
@@ -73,6 +73,15 @@ class Settings(BaseSettings):
|
||||
STT_MODEL: str = "whisper-1"
|
||||
STT_LANGUAGE: str = "fa"
|
||||
|
||||
# Audio Enhancement / Preprocessing (denoising & speech quality enhancement)
|
||||
AUDIO_ENHANCE_ENABLED: bool = False
|
||||
AUDIO_ENHANCE_BASE_URL: str = ""
|
||||
AUDIO_ENHANCE_API_KEY: str = ""
|
||||
AUDIO_ENHANCE_MODEL: str = ""
|
||||
AUDIO_ENHANCE_TIMEOUT_SECONDS: float = 60.0
|
||||
AUDIO_ENHANCE_TARGET_FORMAT: str = "" # empty = pass raw bytes directly; e.g. "wav" / "mp3"
|
||||
AUDIO_ENHANCE_METHOD: str = "auto" # "auto" | "raw_stream" | "multipart"
|
||||
|
||||
@property
|
||||
def stt_base_url(self) -> str:
|
||||
return self.STT_BASE_URL or self.AI_BASE_URL
|
||||
@@ -133,6 +142,25 @@ class Settings(BaseSettings):
|
||||
# Drafts older than this are marked expired by the sweeper loop.
|
||||
DRAFT_TTL_HOURS: int = 24
|
||||
|
||||
# Observability (phase 1) — silent pipeline recorder (DB only; no external
|
||||
# tools, no extra LLM tokens). Data is NEVER deleted.
|
||||
OBS_ENABLED: bool = True
|
||||
OBS_VOICE_DIR: str = "data/voices" # persistent volume (./bot/data) — keep forever
|
||||
OBS_PROMPT_VERSION: str = "" # manual label for the NLU prompt in use
|
||||
|
||||
# Jev (TypeSafe) entity matching — phases 2-5. Model version is PINNED
|
||||
# (phase 0 resolved jev-1.13.0) so upstream updates cannot silently change
|
||||
# decisions; every response's actual model is recorded for drift detection.
|
||||
TYPESAFE_API_KEY: str = ""
|
||||
JEV_MODEL: str = "jev-1.13.0"
|
||||
JEV_ENABLED: bool = True
|
||||
JEV_TIMEOUT: float = 8.0
|
||||
JEV_CONF_EXACT: float = 0.85
|
||||
JEV_CONF_FLOOR: float = 0.65 # phase-6 calibration (2026-10-12): 0.60 let one
|
||||
# wrong candidate (جعبه→بسته @0.61) through; the
|
||||
# correct picks it drops are covered by difflib.
|
||||
JEV_MAX_QUESTIONS_PER_FIELD: int = 15
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
@property
|
||||
|
||||
@@ -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)",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
"""Dedicated DeepSeek fallback matching engine for crisis mode.
|
||||
|
||||
Invoked when Jev (TypeSafe) fails, times out, returns 5xx/429, or is unavailable.
|
||||
Takes:
|
||||
1. raw_input: Raw user text or list of STT transcriptions.
|
||||
2. mentions: Entity mentions extracted by DeepSeek NLU.
|
||||
3. candidates_by_field: Catalog candidate records fetched from the database.
|
||||
4. patch_context: Current draft patch / context.
|
||||
5. trace_id: Observability trace ID.
|
||||
|
||||
Outputs:
|
||||
Standard resolution map compatible with jev_map:
|
||||
{
|
||||
"<normalized_variant>": {
|
||||
"field": str,
|
||||
"status": "exact" | "ambiguity" | "new",
|
||||
"variants": list[str],
|
||||
"id": str | None,
|
||||
"name": str | None,
|
||||
"extra": dict,
|
||||
"candidates": list[dict],
|
||||
"inferred_category_slug"?: str,
|
||||
}
|
||||
}
|
||||
|
||||
Defensive Guarantee:
|
||||
If DeepSeek call fails (HTTP error, timeout, or unparseable JSON),
|
||||
raises MatchingFailureError so the bot halts gracefully with:
|
||||
"⚠️ سامانه در پردازش هوشمند موجودیتها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید."
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app import observability as obs
|
||||
from app.config import settings
|
||||
from app.nlu import normalize_text
|
||||
|
||||
logger = logging.getLogger("bot.fallback_matcher")
|
||||
|
||||
|
||||
class MatchingFailureError(Exception):
|
||||
"""Raised when matching cannot proceed due to LLM / network failure."""
|
||||
pass
|
||||
|
||||
|
||||
ARBITRATION_SYSTEM_PROMPT = """تو قاضی و داور نهایی هوشمند تطبیق موجودیتهای تدارکاتی و ساختمانی در یک سیستم ERP هستی.
|
||||
یک مدل جستجوی برداری اولیه (به نام Jev) برای هر منشن کاربر، کاتالوگ دیتابیس را بررسی کرده و گزینهای را پیشنهاد داده است.
|
||||
وظیفه خطیر تو:
|
||||
تحلیل همزمان ۱) پیام اولیه کاربر، ۲) کاندیداهای کاتالوگ دیتابیس، و ۳) پیشنهاد مدل Jev، و صدور «حکم قطعی نهایی».
|
||||
|
||||
یک شیء JSON و فقط JSON برگردان با ساختار زیر:
|
||||
{
|
||||
"decisions": [
|
||||
{
|
||||
"field": "supplier|material|project|unit|brand|spec",
|
||||
"variant": "نگارش منشن",
|
||||
"status": "exact|ambiguity|new",
|
||||
"id": "شناسه رکورد کاتالوگ یا null",
|
||||
"name": "نام رسمی رکورد کاتالوگ یا null",
|
||||
"candidates": [
|
||||
{"id": "شناسه", "name": "نام", "prob": 0.0..1.0}
|
||||
],
|
||||
"inferred_category_slug": "یکی از ۸ اسلاگ مجاز فقط برای تامینکننده new یا null",
|
||||
"adjudication_action": "endorsed_jev|vetoed_to_new|demoted_to_ambiguity",
|
||||
"reasoning": "توضیح کوتاه فنی دلیل قضاوت"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
قوانین حیاتی داوری:
|
||||
۱. عدم اعتماد کورکورانه به Jev:
|
||||
- مدل Jev صرفاً یک مدل آماری/برداری است و تفاوت سایزها، ردهها، ابعاد فنی و استانداردهای صنعتی (مانند لوله ۶ اینچ در برابر لوله ۴ اینچ، میلگرد ۱۶ در برابر ۲۰، یا تفاوت دو برند متفاوت) را تشخیص نمیدهد.
|
||||
- تو باید کلام کاربر را با پیشنهاد Jev مقایسه کنی.
|
||||
|
||||
۲. حالات صدور حکم:
|
||||
الف) تأیید انتخاب Jev (status: "exact"):
|
||||
اگر پیشنهاد Jev دقیقاً و با احتساب نام تجاری و مشخصات فنی با خواسته کاربر در پیام مطابقت دارد.
|
||||
- فیلد id و name را با مقادیر دقیق رکورد کاتالوگ پر کن. candidates خالی باشد ([]).
|
||||
|
||||
ب) وتوی Jev و اعلام قلم جدید (status: "new"):
|
||||
اگر کالا، تأمینکننده یا پروژه مورد نظر کاربر در کاتالوگ ارسالی وجود ندارد (حتی اگر Jev به اشتباه به دلیل شباهت حروفی گزینهای را با اطمینان بالا پیشنهاد داده باشد).
|
||||
- پیشنهاد Jev را بلافاصله باطل و وتو کن!
|
||||
- وضعیت را "new" بگذار و id حتماً null باشد. هرگز قلم ناموجود را به زور به کاتالوگ نچسبان.
|
||||
- برای تأمینکننده جدید (supplier)، مناسبترین اسلاگ را از بین ۸ دسته مجاز در inferred_category_slug قرار بده.
|
||||
|
||||
ج) تنزل به ابهام (status: "ambiguity"):
|
||||
اگر کاربر عبارت کلی گفته (مثلاً «میلگرد») و در کاتالوگ چند گزینه محتمل وجود دارد، یا بین انتخاب Jev و سایر کاندیداها تردید فنی وجود دارد.
|
||||
- وضعیت را "ambiguity" بگذار و گزینههای منتخب را در candidates بگذار.
|
||||
|
||||
۳. تحلیل ورودیهای صوتی (Voice Mode):
|
||||
اگر ورودی صوتی است (input_mode: "voice")، آرایه کامل ترانویسی موتورهای STT (مانند Whisper، Vosk و غیره) به همراه consensus_text در اختیارت است.
|
||||
شواهد صوتی، تلفظها، خطاهای شنیداری و لهجههای کارگاهی را ملاک قضاوت قرار بده.
|
||||
|
||||
۴. اسلاگهای مجاز دستهبندی برای تأمینکننده جدید:
|
||||
* metals-steel-sections
|
||||
* mechanical-installations-pipes
|
||||
* electrical-equipment-supplies
|
||||
* construction-materials
|
||||
* construction-chemicals
|
||||
* doors-windows-glass
|
||||
* finishing-interior-decoration
|
||||
* construction-tools-equipment
|
||||
"""
|
||||
|
||||
|
||||
FALLBACK_SYSTEM_PROMPT = """تو موتور تطبیق و پالایش هوشمند موجودیتهای تدارکاتی یک سیستم ساختمانی و صنعتی ERP در ایران هستی.
|
||||
سرویس جانبی تطبیق کاتالوگ در دسترس نیست و اکنون تو وظیفه داری با درک مهندسی، تجاری و معنایی خود، عبارات ذکرشده توسط کاربر را مستقیماً با رکوردهای کاتالوگ دیتابیس تطبیق دهی.
|
||||
|
||||
یک شیء JSON و فقط JSON برگردان (بدون هرگونه متن یا مارکداون اضافه) با ساختار زیر:
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"field": "supplier|material|project|unit|brand|spec",
|
||||
"variants": ["تمام نگارشهای این موجودیت"],
|
||||
"status": "exact|ambiguity|new",
|
||||
"id": "شناسه رکورد کاتالوگ منطبق یا null",
|
||||
"name": "نام رسمی رکورد کاتالوگ منطبق یا null",
|
||||
"candidates": [
|
||||
{"id": "شناسه رکورد کاندیدا", "name": "نام رکورد کاندیدا", "prob": 0.0..1.0}
|
||||
],
|
||||
"inferred_category_slug": "فقط برای supplier جدید یکی از ۸ اسلاگ مجاز یا null"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
قوانین حیاتی تطبیق:
|
||||
۱. تطبیق قطعی (status: "exact"):
|
||||
- اگر واریانت کاربر با یکی از رکوردهای کاتالوگ انطباق معنایی و تجاری روشن دارد (با احتساب خطاهای گفتاری STT، لهجه اصفهانی، املای ارقام فارسی/انگلیسی، پیشوند و پسوندهای کارگاهی مانند «آهن»، «بازرگانی»، «لوله»، «میلگرد»)، آن را انتخاب کن.
|
||||
- فیلد id و name را با مقادیر دقیق موجود در کاتالوگ پر کن.
|
||||
- فیلد candidates را خالی بگذار ([]).
|
||||
|
||||
۲. ابهام تجاری/ابعادی (status: "ambiguity"):
|
||||
- اگر کاربر عنوان عامی را به کار برده که به چند رکورد مشخص در کاتالوگ قابل تفکیک است (مثلاً کاربر گفته «میلگرد» ولی در کاتالوگ انواع سایزهای ۱۴، ۱۶، ۱۸ داریم؛ یا کاربر نام خانوادگی تأمینکننده را گفته و چند تأمینکننده مشابه با این نام وجود دارند)، حتماً وضعیت را "ambiguity" بگذار.
|
||||
- رکوردهای کاندیدای محتمل را در لیست candidates با نام، شناسه و تخمین احتمال (prob) قرار بده (حداکثر ۶ مورد).
|
||||
|
||||
۳. موجودیت جدید (status: "new"):
|
||||
- قانون طلایی: اگر کالا، تأمینکننده یا پروژه در کاتالوگ ارسالی وجود ندارد، وضعیت را حتماً "new" بگذار!
|
||||
- به هیچ عنوان و تحت هیچ شرایطی یک قلم جدید را به زور شباهت کلمات یا کاراکتر به یک قلم نامربوط در کاتالوگ نچسبان (مثلاً لوله ۶ اینچ را به لوله ۴ اینچ یا میلگرد ۲۰ را به میلگرد ۱۶ نچسبان).
|
||||
- در حالت new، فیلد id باید null باشد.
|
||||
|
||||
۴. استنتاج خودکار دستهبندی برای تأمینکننده جدید (inferred_category_slug):
|
||||
- اگر تأمینکنندهای وضعیتش new است، با توجه به اقلام استعلامشده در پیام کاربر، مناسبترین اسلاگ دستهبندی را از بین این ۸ مورد انتخاب کن:
|
||||
* metals-steel-sections (فلزات و مقاطع فولادی)
|
||||
* mechanical-installations-pipes (تأسیسات مکانیکی و آبی)
|
||||
* electrical-equipment-supplies (تجهیزات و لوازم برقی)
|
||||
* construction-materials (مصالح ساختمانی)
|
||||
* construction-chemicals (مواد شیمیایی ساختمانی)
|
||||
* doors-windows-glass (درب، پنجره و شیشه)
|
||||
* finishing-interior-decoration (نازککاری و دکوراسیون داخلی)
|
||||
* construction-tools-equipment (ابزار و تجهیزات کارگاهی)
|
||||
"""
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> dict | None:
|
||||
if not raw or not raw.strip():
|
||||
return None
|
||||
text = raw.strip()
|
||||
match = re.search(r"\{.*\}", text, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return json.loads(match.group(0))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def match_with_deepseek(
|
||||
raw_input: object,
|
||||
mentions: list[dict],
|
||||
candidates_by_field: dict[str, list],
|
||||
patch_context: dict | None,
|
||||
trace_id: str | None,
|
||||
consensus_text: str | None = None,
|
||||
) -> dict[str, dict]:
|
||||
"""Execute DeepSeek crisis matching for unmapped mentions.
|
||||
|
||||
Returns a dict keyed by normalized variant, ready to merge into jev_map.
|
||||
Raises MatchingFailureError if DeepSeek fails or returns invalid output.
|
||||
"""
|
||||
if not mentions:
|
||||
return {}
|
||||
|
||||
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
|
||||
logger.error("fallback_matcher: AI provider not configured (AI_BASE_URL/AI_API_KEY missing)")
|
||||
raise MatchingFailureError("AI provider not configured")
|
||||
|
||||
# Serialize candidates to clean dicts for prompt efficiency
|
||||
catalog_summary: dict[str, list[dict]] = {}
|
||||
for fld, cands in (candidates_by_field or {}).items():
|
||||
field_cands = []
|
||||
for c in cands:
|
||||
item = {"id": str(getattr(c, "id", "")), "name": str(getattr(c, "name", ""))}
|
||||
extra = getattr(c, "extra", {}) or {}
|
||||
for k in ("category_name", "location", "canonical_unit_id", "code"):
|
||||
if extra.get(k):
|
||||
item[k] = extra[k]
|
||||
field_cands.append(item)
|
||||
catalog_summary[fld] = field_cands[:80] # token-safe ceiling per field
|
||||
|
||||
if isinstance(raw_input, list):
|
||||
reference_input = {
|
||||
"input_mode": "voice",
|
||||
"stt_multi_transcripts": raw_input,
|
||||
"consensus_text": consensus_text or "",
|
||||
}
|
||||
else:
|
||||
reference_input = {
|
||||
"input_mode": "text",
|
||||
"raw_text": str(raw_input or ""),
|
||||
}
|
||||
|
||||
payload_data = {
|
||||
"reference_user_input": reference_input,
|
||||
"entity_mentions": mentions,
|
||||
"catalog_candidates": catalog_summary,
|
||||
"patch_context": patch_context or {},
|
||||
}
|
||||
user_content = json.dumps(payload_data, ensure_ascii=False)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.AI_BASE_URL.rstrip('/')}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {settings.AI_API_KEY}"},
|
||||
json={
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": FALLBACK_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"response_format": {"type": "json_object"},
|
||||
},
|
||||
)
|
||||
latency_ms = int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error("fallback_matcher: HTTP %s from DeepSeek: %s", resp.status_code, resp.text[:300])
|
||||
await obs.event(trace_id, "matching.fallback.failure", ok=False, payload={
|
||||
"status_code": resp.status_code,
|
||||
"error": "http_error",
|
||||
"body": resp.text[:300],
|
||||
})
|
||||
raise MatchingFailureError(f"DeepSeek returned HTTP {resp.status_code}")
|
||||
|
||||
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "")
|
||||
usage = (resp.json() or {}).get("usage") or {}
|
||||
parsed = _extract_json(content)
|
||||
if not parsed or not isinstance(parsed.get("results"), list):
|
||||
logger.error("fallback_matcher: unparseable JSON from DeepSeek: %r", content[:300])
|
||||
await obs.event(trace_id, "matching.fallback.failure", ok=False, payload={
|
||||
"error": "unparseable_json",
|
||||
"raw": content[:500],
|
||||
})
|
||||
raise MatchingFailureError("DeepSeek returned unparseable JSON")
|
||||
|
||||
except httpx.HTTPError as exc:
|
||||
latency_ms = int((time.perf_counter() - t0) * 1000)
|
||||
logger.exception("fallback_matcher: HTTP network error calling DeepSeek")
|
||||
await obs.event(trace_id, "matching.fallback.failure", ok=False, payload={
|
||||
"error": "network_exception",
|
||||
"details": str(exc),
|
||||
"latency_ms": latency_ms,
|
||||
})
|
||||
raise MatchingFailureError("Network exception calling DeepSeek") from exc
|
||||
except Exception as exc:
|
||||
if isinstance(exc, MatchingFailureError):
|
||||
raise
|
||||
latency_ms = int((time.perf_counter() - t0) * 1000)
|
||||
logger.exception("fallback_matcher: Unexpected error")
|
||||
await obs.event(trace_id, "matching.fallback.failure", ok=False, payload={
|
||||
"error": "unexpected_exception",
|
||||
"details": str(exc),
|
||||
"latency_ms": latency_ms,
|
||||
})
|
||||
raise MatchingFailureError("Unexpected error calling DeepSeek") from exc
|
||||
|
||||
results_map: dict[str, dict] = {}
|
||||
statuses_count = {"exact": 0, "ambiguity": 0, "new": 0}
|
||||
|
||||
# Build candidate lookup dict for candidate extras
|
||||
cand_by_field_id: dict[tuple[str, str], object] = {}
|
||||
for fld, c_list in (candidates_by_field or {}).items():
|
||||
for c in c_list:
|
||||
cid = str(getattr(c, "id", ""))
|
||||
cand_by_field_id[(fld, cid)] = c
|
||||
|
||||
for item in parsed.get("results", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
field = str(item.get("field") or "").strip()
|
||||
variants = [str(v).strip() for v in (item.get("variants") or []) if str(v or "").strip()]
|
||||
status = str(item.get("status") or "").strip().lower()
|
||||
if status not in ("exact", "ambiguity", "new"):
|
||||
status = "new"
|
||||
|
||||
statuses_count[status] = statuses_count.get(status, 0) + 1
|
||||
|
||||
cand_id = str(item.get("id")) if item.get("id") else None
|
||||
cand_name = str(item.get("name")) if item.get("name") else None
|
||||
cand_obj = cand_by_field_id.get((field, cand_id or "")) if cand_id else None
|
||||
extra = getattr(cand_obj, "extra", {}) or {}
|
||||
|
||||
# Format candidates for ambiguity
|
||||
amb_candidates = []
|
||||
for c in item.get("candidates") or []:
|
||||
if isinstance(c, dict) and c.get("id") and c.get("name"):
|
||||
amb_candidates.append({
|
||||
"id": str(c["id"]),
|
||||
"name": str(c["name"]),
|
||||
"prob": float(c.get("prob") or 0.5),
|
||||
})
|
||||
|
||||
entry = {
|
||||
"field": field,
|
||||
"status": status,
|
||||
"variants": variants,
|
||||
"id": cand_id,
|
||||
"name": cand_name,
|
||||
"extra": extra,
|
||||
"candidates": amb_candidates,
|
||||
"source": "deepseek-fallback",
|
||||
}
|
||||
if item.get("inferred_category_slug"):
|
||||
entry["inferred_category_slug"] = str(item["inferred_category_slug"])
|
||||
|
||||
for v in variants:
|
||||
results_map[normalize_text(v)] = entry
|
||||
|
||||
# Observability: matching.fallback.result
|
||||
await obs.event(trace_id, "matching.fallback.result", ok=True, payload={
|
||||
"latency_ms": latency_ms,
|
||||
"usage": usage,
|
||||
"statuses": statuses_count,
|
||||
"n_mentions": len(mentions),
|
||||
"n_resolved": len(results_map),
|
||||
})
|
||||
|
||||
return results_map
|
||||
|
||||
|
||||
async def adjudicate_with_deepseek(
|
||||
raw_input: object,
|
||||
consensus_text: str | None,
|
||||
items: list[dict],
|
||||
patch_context: dict | None,
|
||||
trace_id: str | None,
|
||||
) -> dict[str, dict]:
|
||||
"""Execute DeepSeek final arbitration over Jev recommendations.
|
||||
|
||||
Takes Jev-assessed items and makes final binding decisions.
|
||||
Raises MatchingFailureError if DeepSeek fails or returns unparseable output.
|
||||
"""
|
||||
if not items:
|
||||
return {}
|
||||
|
||||
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
|
||||
logger.error("fallback_matcher: AI provider not configured for arbitration")
|
||||
raise MatchingFailureError("AI provider not configured")
|
||||
|
||||
if isinstance(raw_input, list):
|
||||
reference_input = {
|
||||
"input_mode": "voice",
|
||||
"stt_multi_transcripts": raw_input,
|
||||
"consensus_text": consensus_text or "",
|
||||
}
|
||||
else:
|
||||
reference_input = {
|
||||
"input_mode": "text",
|
||||
"raw_text": str(raw_input or ""),
|
||||
}
|
||||
|
||||
arbitration_items_payload = []
|
||||
# Candidate lookup by (field, id) and (field, name) for metadata
|
||||
cand_lookup: dict[tuple[str, str], object] = {}
|
||||
|
||||
for it in items:
|
||||
fld = it["field"]
|
||||
c_list = it.get("candidates") or []
|
||||
serialized_cands = []
|
||||
for c in c_list:
|
||||
cid = str(getattr(c, "id", ""))
|
||||
cname = str(getattr(c, "name", ""))
|
||||
cand_lookup[(fld, cid)] = c
|
||||
cand_lookup[(fld, normalize_text(cname))] = c
|
||||
item_entry = {"id": cid, "name": cname}
|
||||
extra = getattr(c, "extra", {}) or {}
|
||||
for k in ("category_name", "location", "canonical_unit_id", "code"):
|
||||
if extra.get(k):
|
||||
item_entry[k] = extra[k]
|
||||
serialized_cands.append(item_entry)
|
||||
|
||||
raw_probs = it.get("jev_probabilities") or {}
|
||||
non_zero_probs = {k: v for k, v in raw_probs.items() if float(v or 0.0) > 0.0}
|
||||
|
||||
arbitration_items_payload.append({
|
||||
"field": fld,
|
||||
"variants": it.get("variants") or [],
|
||||
"catalog_candidates": serialized_cands[:60],
|
||||
"jev_assessment": {
|
||||
"recommended_choice": it.get("jev_choice"),
|
||||
"recommended_id": it.get("jev_choice_id"),
|
||||
"confidence": it.get("jev_confidence"),
|
||||
"probabilities": non_zero_probs,
|
||||
"top_ranked": it.get("jev_ranked") or [],
|
||||
},
|
||||
})
|
||||
|
||||
payload_data = {
|
||||
"reference_user_input": reference_input,
|
||||
"items_to_adjudicate": arbitration_items_payload,
|
||||
"patch_context": patch_context or {},
|
||||
}
|
||||
user_content = json.dumps(payload_data, ensure_ascii=False)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
|
||||
resp = await client.post(
|
||||
f"{settings.AI_BASE_URL.rstrip('/')}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {settings.AI_API_KEY}"},
|
||||
json={
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": ARBITRATION_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"response_format": {"type": "json_object"},
|
||||
},
|
||||
)
|
||||
latency_ms = int((time.perf_counter() - t0) * 1000)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.error("adjudicate_with_deepseek: HTTP %s from DeepSeek: %s", resp.status_code, resp.text[:300])
|
||||
await obs.event(trace_id, "matching.arbitration.failure", ok=False, payload={
|
||||
"status_code": resp.status_code,
|
||||
"error": "http_error",
|
||||
"body": resp.text[:300],
|
||||
})
|
||||
raise MatchingFailureError(f"DeepSeek returned HTTP {resp.status_code}")
|
||||
|
||||
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "")
|
||||
usage = (resp.json() or {}).get("usage") or {}
|
||||
parsed = _extract_json(content)
|
||||
if not parsed or not isinstance(parsed.get("decisions"), list):
|
||||
logger.error("adjudicate_with_deepseek: unparseable JSON from DeepSeek: %r", content[:300])
|
||||
await obs.event(trace_id, "matching.arbitration.failure", ok=False, payload={
|
||||
"error": "unparseable_json",
|
||||
"raw": content[:500],
|
||||
})
|
||||
raise MatchingFailureError("DeepSeek returned unparseable arbitration JSON")
|
||||
|
||||
except httpx.HTTPError as exc:
|
||||
latency_ms = int((time.perf_counter() - t0) * 1000)
|
||||
logger.exception("adjudicate_with_deepseek: HTTP network error calling DeepSeek")
|
||||
await obs.event(trace_id, "matching.arbitration.failure", ok=False, payload={
|
||||
"error": "network_exception",
|
||||
"details": str(exc),
|
||||
"latency_ms": latency_ms,
|
||||
})
|
||||
raise MatchingFailureError("Network exception calling DeepSeek") from exc
|
||||
except Exception as exc:
|
||||
if isinstance(exc, MatchingFailureError):
|
||||
raise
|
||||
latency_ms = int((time.perf_counter() - t0) * 1000)
|
||||
logger.exception("adjudicate_with_deepseek: Unexpected error")
|
||||
await obs.event(trace_id, "matching.arbitration.failure", ok=False, payload={
|
||||
"error": "unexpected_exception",
|
||||
"details": str(exc),
|
||||
"latency_ms": latency_ms,
|
||||
})
|
||||
raise MatchingFailureError("Unexpected error calling DeepSeek") from exc
|
||||
|
||||
results_map: dict[str, dict] = {}
|
||||
actions_count = {"endorsed_jev": 0, "vetoed_to_new": 0, "demoted_to_ambiguity": 0, "other": 0}
|
||||
statuses_count = {"exact": 0, "ambiguity": 0, "new": 0}
|
||||
|
||||
# Map decisions back to results_map
|
||||
for dec in parsed.get("decisions", []):
|
||||
if not isinstance(dec, dict):
|
||||
continue
|
||||
field = str(dec.get("field") or "").strip()
|
||||
variant = str(dec.get("variant") or "").strip()
|
||||
status = str(dec.get("status") or "").strip().lower()
|
||||
if status not in ("exact", "ambiguity", "new"):
|
||||
status = "new"
|
||||
|
||||
action = str(dec.get("adjudication_action") or "other")
|
||||
actions_count[action] = actions_count.get(action, 0) + 1
|
||||
statuses_count[status] = statuses_count.get(status, 0) + 1
|
||||
|
||||
cand_id = str(dec.get("id")) if dec.get("id") else None
|
||||
cand_name = str(dec.get("name")) if dec.get("name") else None
|
||||
cand_obj = None
|
||||
if cand_id:
|
||||
cand_obj = cand_lookup.get((field, cand_id))
|
||||
if not cand_obj and cand_name:
|
||||
cand_obj = cand_lookup.get((field, normalize_text(cand_name)))
|
||||
extra = getattr(cand_obj, "extra", {}) or {}
|
||||
|
||||
amb_candidates = []
|
||||
for c in dec.get("candidates") or []:
|
||||
if isinstance(c, dict) and c.get("id") and c.get("name"):
|
||||
amb_candidates.append({
|
||||
"id": str(c["id"]),
|
||||
"name": str(c["name"]),
|
||||
"prob": float(c.get("prob") or 0.5),
|
||||
})
|
||||
|
||||
# Find matching item from items for variants
|
||||
matched_item = next(
|
||||
(it for it in items if it["field"] == field and any(normalize_text(v) == normalize_text(variant) for v in it.get("variants", []))),
|
||||
None
|
||||
)
|
||||
variants = matched_item.get("variants") if matched_item else [variant]
|
||||
|
||||
entry = {
|
||||
"field": field,
|
||||
"status": status,
|
||||
"variants": variants,
|
||||
"id": cand_id,
|
||||
"name": cand_name,
|
||||
"extra": extra,
|
||||
"candidates": amb_candidates,
|
||||
"source": "deepseek-arbiter",
|
||||
"adjudication_action": action,
|
||||
"reasoning": dec.get("reasoning"),
|
||||
}
|
||||
if dec.get("inferred_category_slug"):
|
||||
entry["inferred_category_slug"] = str(dec["inferred_category_slug"])
|
||||
|
||||
for v in variants:
|
||||
results_map[normalize_text(v)] = entry
|
||||
|
||||
# Observability: matching.arbitration.result
|
||||
await obs.event(trace_id, "matching.arbitration.result", ok=True, payload={
|
||||
"latency_ms": latency_ms,
|
||||
"usage": usage,
|
||||
"actions": actions_count,
|
||||
"statuses": statuses_count,
|
||||
"n_items": len(items),
|
||||
"n_resolved": len(results_map),
|
||||
})
|
||||
|
||||
return results_map
|
||||
@@ -11,8 +11,10 @@ Pipeline per update:
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app import observability as obs
|
||||
from app.config import settings
|
||||
from app.nlu import understand
|
||||
from app.fallback_matcher import MatchingFailureError
|
||||
from app.nlu import NLUFailureError, understand
|
||||
from app.runtime import BotContext, LinkedUser
|
||||
from app.storage import save_proforma_file
|
||||
from app.stt import transcribe, transcribe_multi
|
||||
@@ -48,6 +50,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 +91,21 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
file_id = audio_media["file_id"]
|
||||
try:
|
||||
audio_bytes = await ctx.bale.download_file(file_id)
|
||||
voice_path = obs.save_voice(trace_id, message_id or 0, audio_bytes)
|
||||
await obs.event(trace_id, "input.voice", payload={
|
||||
"path": voice_path,
|
||||
"size": len(audio_bytes),
|
||||
"mime": audio_media.get("mime_type"),
|
||||
"file_id": file_id,
|
||||
"duration": audio_media.get("duration"),
|
||||
})
|
||||
transcripts = await transcribe_multi(
|
||||
settings,
|
||||
audio_bytes,
|
||||
mime_type=audio_media.get("mime_type"),
|
||||
filename=audio_media.get("file_name"),
|
||||
trace_id=trace_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
detail = "هوش مصنوعی تنظیم نشده" if "not configured" in str(exc) else str(exc)[:200]
|
||||
@@ -201,9 +223,48 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
|
||||
draft_summary = _draft_summary(target_draft) if target_draft else None
|
||||
input_payload = voice_transcripts if voice_transcripts else text
|
||||
result = await understand(settings, input_payload, draft_summary)
|
||||
try:
|
||||
result = await understand(settings, input_payload, draft_summary, trace_id=trace_id)
|
||||
except (MatchingFailureError, NLUFailureError):
|
||||
logger.error("NLU processing failed completely (Universal Fail-Stop Guard)")
|
||||
await ctx.send(
|
||||
chat_id,
|
||||
"⚠️ سامانه در پردازش هوشمند موجودیتها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید.",
|
||||
reply_to=message_id,
|
||||
)
|
||||
return
|
||||
|
||||
intent, patch = result.get("intent"), result.get("patch") or {}
|
||||
consensus_text = result.get("consensus_text") or text
|
||||
consensus_text = result.get("consensus_text") or (text if isinstance(text, str) else "")
|
||||
mentions = result.get("entity_mentions") or []
|
||||
await obs.event(trace_id, "entities.extracted",
|
||||
payload={"patch": patch, "intent": intent, "entity_mentions": mentions})
|
||||
|
||||
# Entity matching (Two-Stage: Jev scorer + DeepSeek final arbiter; DeepSeek crisis fallback if Jev down)
|
||||
jev_map: dict = {}
|
||||
if mentions and intent in ("register_quote", "update_draft", "add_material"):
|
||||
try:
|
||||
from app import matching
|
||||
jev_map = await matching.resolve_mentions(
|
||||
ctx, patch, mentions, trace_id=trace_id, raw_input=input_payload,
|
||||
consensus_text=consensus_text,
|
||||
)
|
||||
except matching.MatchingFailureError:
|
||||
logger.error("entity matching failed completely (Universal Fail-Stop Guard)")
|
||||
await ctx.send(
|
||||
chat_id,
|
||||
"⚠️ سامانه در پردازش هوشمند موجودیتها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید.",
|
||||
reply_to=message_id,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("unexpected matching failure")
|
||||
await ctx.send(
|
||||
chat_id,
|
||||
"⚠️ سامانه در پردازش هوشمند موجودیتها با اختلال مواجه شد. لطفاً دوباره پیام را ارسال کنید.",
|
||||
reply_to=message_id,
|
||||
)
|
||||
return
|
||||
|
||||
if source == "voice":
|
||||
await ctx._log(chat_id, "in", "voice", consensus_text)
|
||||
@@ -216,23 +277,28 @@ async def handle_message(ctx: BotContext, chat_id: int, message: dict) -> None:
|
||||
ctx, chat_id, user, patch, source,
|
||||
incoming_msg_id=message_id,
|
||||
target_draft=target_draft,
|
||||
trace_id=trace_id,
|
||||
jev_map=jev_map,
|
||||
)
|
||||
elif intent == "commit_draft":
|
||||
active = target_draft or await draft_store.get_active(chat_id)
|
||||
if active is None:
|
||||
await ctx.send(chat_id, NOTHING_TO_COMMIT, reply_to=message_id)
|
||||
elif active.kind == draft_store.KIND_QUOTE:
|
||||
await quote.commit(ctx, chat_id, user, active)
|
||||
await quote.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
||||
elif active.kind == draft_store.KIND_SUPPLIER:
|
||||
await supplier.commit(ctx, chat_id, user, active)
|
||||
await supplier.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
||||
elif active.kind == draft_store.KIND_MATERIAL:
|
||||
await material.commit(ctx, chat_id, user, active)
|
||||
await material.commit(ctx, chat_id, user, active, trace_id=trace_id)
|
||||
elif active.kind == draft_store.KIND_PROJECT:
|
||||
await project.commit(ctx, chat_id, user, active)
|
||||
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 +319,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 +441,26 @@ 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, message_id=incoming_msg_id
|
||||
)
|
||||
except Exception:
|
||||
text = ""
|
||||
|
||||
if text:
|
||||
await obs.event(trace_id, "ambiguity.user_choice", payload={
|
||||
"amb_id": amb.get("id"), "action": "reply", "kind": amb.get("kind"),
|
||||
"reply_text": text[:500],
|
||||
})
|
||||
draft_summary = _draft_summary(draft)
|
||||
result = await understand(settings, text, draft_summary)
|
||||
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 +482,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"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,18 +2,22 @@
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app import matching
|
||||
from app import observability as obs
|
||||
from app.api.client import ApiError
|
||||
from app.drafts import (
|
||||
KIND_MATERIAL, merge_material_patch, material_missing, new_material_payload,
|
||||
)
|
||||
from app.keyboards import pick_buttons, simple_review_keyboard
|
||||
from app.resolve import safe_resolve
|
||||
from app.nlu import normalize_text
|
||||
from app.texts import ERR_GENERIC, esc, material_committed_report, material_review_text
|
||||
|
||||
logger = logging.getLogger("bot.material")
|
||||
|
||||
|
||||
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
|
||||
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
|
||||
trace_id: str | None = None,
|
||||
jev_map: dict | None = None) -> None:
|
||||
draft = await draft_store.get_active(chat_id, KIND_MATERIAL)
|
||||
if draft is not None:
|
||||
payload = draft.payload
|
||||
@@ -22,21 +26,38 @@ 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
|
||||
hit = matching.lookup(jev_map, payload["unit_name"])
|
||||
if hit is not None:
|
||||
if hit["status"] == "exact":
|
||||
payload["canonical_unit_id"] = hit["id"]
|
||||
payload["unit_name"] = hit["name"]
|
||||
options.pop("unit", None)
|
||||
elif hit["status"] == "ambiguity" and hit.get("candidates"):
|
||||
options["unit"] = [{"id": c["id"], "name": c["name"]}
|
||||
for c in hit["candidates"][:6]]
|
||||
elif hit["status"] == "new":
|
||||
options.pop("unit", None)
|
||||
else:
|
||||
try:
|
||||
units = await ctx.resolver.units()
|
||||
except Exception:
|
||||
units = []
|
||||
norm_u = normalize_text(payload["unit_name"])
|
||||
exact = [u for u in units if normalize_text(u.name) == norm_u or (u.extra or {}).get("code") == norm_u]
|
||||
if len(exact) == 1:
|
||||
payload["canonical_unit_id"] = exact[0].id
|
||||
payload["unit_name"] = exact[0].name
|
||||
options.pop("unit", None)
|
||||
elif res.candidates:
|
||||
options["unit"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||
else:
|
||||
options.pop("unit", None)
|
||||
|
||||
@@ -58,7 +79,7 @@ def _keyboard(payload: dict, draft_id: str) -> dict:
|
||||
return simple_review_keyboard(draft_id, KIND_MATERIAL, rows)
|
||||
|
||||
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True, trace_id: str | None = None) -> None:
|
||||
payload = draft.payload
|
||||
missing = material_missing(payload)
|
||||
text = material_review_text(payload, missing)
|
||||
@@ -71,11 +92,14 @@ async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||
else:
|
||||
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
||||
await draft_store.save_payload(draft, missing={"fields": missing})
|
||||
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
|
||||
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
|
||||
|
||||
|
||||
async def commit(ctx, chat_id, user, draft) -> None:
|
||||
async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None:
|
||||
payload = draft.payload
|
||||
await resolve_payload(ctx, payload)
|
||||
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
|
||||
await resolve_payload(ctx, payload, trace_id)
|
||||
missing = {m["key"]: m["ok"] for m in material_missing(payload)}
|
||||
problems = [label for key, label, ok in [
|
||||
("name", "نام کالا", missing.get("name")),
|
||||
@@ -94,5 +118,8 @@ async def commit(ctx, chat_id, user, draft) -> None:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
await draft_store.set_status(draft.id, "committed")
|
||||
ctx.resolver.invalidate("materials")
|
||||
await obs.event(trace_id, "draft.commit", payload={"kind": "material",
|
||||
"material_id": (created or {}).get("id")})
|
||||
await obs.set_trace_status(trace_id, "committed")
|
||||
ctx.resolver.invalidate()
|
||||
await ctx.send(chat_id, material_committed_report(created))
|
||||
|
||||
@@ -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))
|
||||
|
||||
+193
-37
@@ -2,12 +2,15 @@
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app import matching
|
||||
from app import observability as obs
|
||||
from app.api.client import ApiError
|
||||
from app.drafts import (
|
||||
KIND_QUOTE, build_quote_request, compute_quote_missing, merge_quote_patch,
|
||||
new_quote_payload, quote_ready,
|
||||
)
|
||||
from app.keyboards import quote_review_keyboard
|
||||
from app.nlu import normalize_text
|
||||
from app.resolve import safe_resolve
|
||||
from app.texts import ERR_GENERIC, quote_committed_report, quote_review_text, esc
|
||||
|
||||
@@ -20,6 +23,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 +52,11 @@ async def handle_entities(
|
||||
chat_id, KIND_QUOTE, payload, source=source, source_message_id=incoming_msg_id
|
||||
)
|
||||
|
||||
await resolve_payload(ctx, payload)
|
||||
await obs.link_draft(trace_id, draft.id, draft.kind)
|
||||
await resolve_payload(ctx, payload, trace_id, jev_map)
|
||||
if jev_map:
|
||||
matching.normalize_brand_spec(payload, jev_map)
|
||||
matching.create_ambiguities(payload, jev_map)
|
||||
|
||||
if prev_review_msg_id:
|
||||
try:
|
||||
@@ -55,17 +64,30 @@ async def handle_entities(
|
||||
except Exception as e:
|
||||
logger.warning("Could not delete previous review message %s: %s", prev_review_msg_id, e)
|
||||
|
||||
await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id)
|
||||
await render(ctx, chat_id, draft, fresh=True, reply_to=incoming_msg_id, trace_id=trace_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolution
|
||||
# resolution (pure intelligence; zero local difflib heuristics)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def resolve_payload(ctx, payload: dict) -> None:
|
||||
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None,
|
||||
jev_map: dict | None = None) -> None:
|
||||
options = payload.setdefault("options", {})
|
||||
|
||||
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
||||
hit = matching.lookup(jev_map, payload["supplier_name"])
|
||||
if hit is not None:
|
||||
if hit["status"] == "exact":
|
||||
matching.apply_exact(payload, hit)
|
||||
options.pop("sup", None)
|
||||
elif hit["status"] == "ambiguity" and hit.get("candidates"):
|
||||
options["sup"] = [{"id": c["id"], "name": c["name"]} for c in hit["candidates"][:6]]
|
||||
elif hit["status"] == "new":
|
||||
options.pop("sup", None)
|
||||
if hit.get("inferred_category_slug"):
|
||||
payload["supplier_inferred_category_slug"] = hit["inferred_category_slug"]
|
||||
else:
|
||||
res = await safe_resolve(ctx.resolver, "supplier", payload["supplier_name"])
|
||||
if res.resolved:
|
||||
c = res.candidates[0]
|
||||
@@ -84,6 +106,21 @@ async def resolve_payload(ctx, payload: dict) -> None:
|
||||
resolved_projects = payload.setdefault("resolved_projects", {})
|
||||
|
||||
if not payload.get("project_id") and payload.get("project_name"):
|
||||
hit = matching.lookup(jev_map, payload["project_name"])
|
||||
if hit is not None:
|
||||
if hit["status"] == "exact":
|
||||
matching.apply_exact(payload, hit)
|
||||
payload["project_name"] = hit["name"]
|
||||
resolved_projects[hit["name"]] = hit["id"]
|
||||
resolved_projects[payload["project_name"]] = hit["id"]
|
||||
if hit["id"] not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(hit["id"])
|
||||
options.pop("prj", None)
|
||||
elif hit["status"] == "ambiguity" and hit.get("candidates"):
|
||||
options["prj"] = [{"id": c["id"], "name": c["name"]} for c in hit["candidates"][:6]]
|
||||
elif hit["status"] == "new":
|
||||
options.pop("prj", None)
|
||||
else:
|
||||
res = await safe_resolve(ctx.resolver, "project", payload["project_name"])
|
||||
if res.resolved:
|
||||
c = res.candidates[0]
|
||||
@@ -91,8 +128,8 @@ async def resolve_payload(ctx, payload: dict) -> None:
|
||||
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"])
|
||||
if c.id not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(c.id)
|
||||
options.pop("prj", None)
|
||||
elif res.candidates:
|
||||
options["prj"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||
@@ -100,29 +137,48 @@ async def resolve_payload(ctx, payload: dict) -> None:
|
||||
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 and hit.get("status") == "exact":
|
||||
matching.apply_exact(payload, hit)
|
||||
resolved_projects[hit["name"]] = hit["id"]
|
||||
resolved_projects[p_name] = hit["id"]
|
||||
if hit["id"] not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(hit["id"])
|
||||
if not payload.get("project_id"):
|
||||
payload["project_id"] = hit["id"]
|
||||
elif hit is None:
|
||||
res = await safe_resolve(ctx.resolver, "project", p_name)
|
||||
if res.resolved:
|
||||
c = res.candidates[0]
|
||||
p_id = c.id
|
||||
resolved_projects[c.name] = p_id
|
||||
resolved_projects[p_name] = p_id
|
||||
if p_id not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(p_id)
|
||||
resolved_projects[p_name] = res.candidates[0].id
|
||||
resolved_projects[res.candidates[0].name] = res.candidates[0].id
|
||||
if res.candidates[0].id not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(res.candidates[0].id)
|
||||
if not payload.get("project_id"):
|
||||
payload["project_id"] = p_id
|
||||
payload["project_id"] = res.candidates[0].id
|
||||
|
||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
||||
if not item.get("material_id") and item.get("material_name"):
|
||||
hit = matching.lookup(jev_map, item["material_name"])
|
||||
if hit is not None:
|
||||
if hit["status"] == "exact":
|
||||
matching.apply_exact(payload, hit)
|
||||
options.pop(f"mat{i}", None)
|
||||
elif hit["status"] == "ambiguity" and hit.get("candidates"):
|
||||
options[f"mat{i}"] = [{"id": c["id"], "name": c["name"]} for c in hit["candidates"][:6]]
|
||||
elif hit["status"] == "new":
|
||||
options.pop(f"mat{i}", None)
|
||||
else:
|
||||
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)
|
||||
c = res.candidates[0]
|
||||
_set_material(item, c.id, c.name, c.extra)
|
||||
options.pop(f"mat{i}", None)
|
||||
elif res.candidates:
|
||||
options[f"mat{i}"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||
else:
|
||||
options.pop(f"mat{i}", None)
|
||||
if not item.get("unit_id"):
|
||||
await _resolve_unit(ctx, item)
|
||||
await _resolve_unit(ctx, item, trace_id, jev_map)
|
||||
|
||||
|
||||
def _set_material(item: dict, material_id: str, name: str, extra: dict | None = None) -> None:
|
||||
@@ -130,10 +186,18 @@ 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"):
|
||||
hit = matching.lookup(jev_map, item["unit_name"])
|
||||
if hit is not None and hit.get("status") == "exact":
|
||||
item["unit_id"] = hit["id"]
|
||||
item["unit_explicit"] = True
|
||||
item["unit_name"] = hit["name"]
|
||||
return
|
||||
if hit is None:
|
||||
res = await safe_resolve(ctx.resolver, "unit", item["unit_name"])
|
||||
if res.resolved:
|
||||
item["unit_id"] = res.candidates[0].id
|
||||
@@ -146,8 +210,10 @@ async def _resolve_unit(ctx, item: dict) -> None:
|
||||
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 +229,7 @@ async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> Non
|
||||
if 0 <= index < len(items):
|
||||
items[index]["material_name"] = choice["name"]
|
||||
_set_material(items[index], choice["id"], choice["name"])
|
||||
await _resolve_unit(ctx, items[index])
|
||||
await _resolve_unit(ctx, items[index], trace_id)
|
||||
elif field.startswith("unit"):
|
||||
index = int(field[4:]) - 1
|
||||
items = payload.get("items") or []
|
||||
@@ -172,9 +238,9 @@ async def apply_pick(ctx, chat_id, user, draft, field: str, choice: dict) -> Non
|
||||
items[index]["unit_explicit"] = True
|
||||
items[index]["unit_name"] = choice["name"]
|
||||
|
||||
await resolve_payload(ctx, payload)
|
||||
await resolve_payload(ctx, payload, trace_id)
|
||||
await draft_store.save_payload(draft, missing={"fields": compute_quote_missing(payload)})
|
||||
await render(ctx, chat_id, draft, fresh=False)
|
||||
await render(ctx, chat_id, draft, fresh=False, trace_id=trace_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -214,7 +280,7 @@ def _keyboard(payload: dict, draft_id: str, wizard_url: str | None = None) -> di
|
||||
|
||||
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True, note: str | None = None,
|
||||
reply_to: int | None = None) -> None:
|
||||
reply_to: int | None = None, trace_id: str | None = None) -> None:
|
||||
from app.config import settings
|
||||
|
||||
from app.security import create_draft_token
|
||||
@@ -263,6 +329,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 +344,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 +359,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,52 +372,114 @@ 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 = {}
|
||||
|
||||
# 1. Sequential pre-creation for new supplier if user wants to commit
|
||||
# 1. Defensive pre-creation for new supplier (get_or_create)
|
||||
if not payload.get("supplier_id") and payload.get("supplier_name"):
|
||||
sup_name = payload["supplier_name"].strip()
|
||||
# 1.a) Check if already in DB
|
||||
try:
|
||||
existing_sups = await ctx.api.list_suppliers(q=sup_name, page_size=10)
|
||||
for s in existing_sups:
|
||||
if normalize_text(s.get("name") or "") == normalize_text(sup_name):
|
||||
payload["supplier_id"] = str(s["id"])
|
||||
payload["supplier_name"] = s.get("name")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("Could not check existing suppliers: %s", e)
|
||||
|
||||
# 1.b) If still not in DB, create it
|
||||
if not payload.get("supplier_id"):
|
||||
cat_id = payload.get("supplier_category_id")
|
||||
if not cat_id and payload.get("supplier_inferred_category_slug"):
|
||||
inferred_slug = payload["supplier_inferred_category_slug"]
|
||||
try:
|
||||
cats = await ctx.resolver.categories()
|
||||
for c in cats:
|
||||
if (c.extra or {}).get("slug") == inferred_slug or c.name == inferred_slug:
|
||||
cat_id = c.id
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not cat_id:
|
||||
try:
|
||||
cats = await ctx.resolver.categories()
|
||||
if cats:
|
||||
cat_id = cats[0].id
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if cat_id:
|
||||
try:
|
||||
sup_body = {
|
||||
"name": payload["supplier_name"],
|
||||
"name": sup_name,
|
||||
"category_id": cat_id,
|
||||
}
|
||||
if payload.get("supplier_phone"):
|
||||
sup_body["phone"] = payload["supplier_phone"]
|
||||
created_sup = await ctx.api.create_supplier(sup_body, actor_id=user.actor_id)
|
||||
payload["supplier_id"] = created_sup["id"]
|
||||
newly_created["supplier"] = payload["supplier_name"]
|
||||
newly_created["supplier"] = sup_name
|
||||
ctx.resolver.invalidate("suppliers")
|
||||
except Exception as e:
|
||||
logger.warning("Could not auto-create supplier: %s", e)
|
||||
logger.warning("Could not auto-create supplier: %s — trying fallback fetch", e)
|
||||
try:
|
||||
existing_sups = await ctx.api.list_suppliers(q=sup_name, page_size=10)
|
||||
for s in existing_sups:
|
||||
if normalize_text(s.get("name") or "") == normalize_text(sup_name):
|
||||
payload["supplier_id"] = str(s["id"])
|
||||
payload["supplier_name"] = s.get("name")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Sequential pre-creation for new materials
|
||||
# 2. Defensive pre-creation for new materials (get_or_create)
|
||||
created_mats = []
|
||||
for i, item in enumerate(payload.get("items") or [], start=1):
|
||||
if not item.get("material_id") and item.get("material_name") and item.get("unit_id"):
|
||||
mat_name = item["material_name"].strip()
|
||||
# 2.a) Check if already in DB
|
||||
try:
|
||||
existing_mats = await ctx.api.list_materials(q=mat_name, page_size=10)
|
||||
for m in existing_mats:
|
||||
if normalize_text(m.get("name") or "") == normalize_text(mat_name):
|
||||
_set_material(item, str(m["id"]), m.get("name") or mat_name,
|
||||
{"canonical_unit_id": m.get("canonical_unit_id")})
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("Could not check existing materials: %s", e)
|
||||
|
||||
# 2.b) If still not in DB, create it
|
||||
if not item.get("material_id"):
|
||||
try:
|
||||
created_mat = await ctx.api.create_material({
|
||||
"name": item["material_name"],
|
||||
"name": mat_name,
|
||||
"canonical_unit_id": int(item["unit_id"]),
|
||||
}, actor_id=user.actor_id)
|
||||
_set_material(item, created_mat["id"], created_mat.get("name") or item["material_name"])
|
||||
created_mats.append(item["material_name"])
|
||||
_set_material(item, created_mat["id"], created_mat.get("name") or mat_name)
|
||||
created_mats.append(mat_name)
|
||||
ctx.resolver.invalidate("materials")
|
||||
except Exception as e:
|
||||
logger.warning("Could not auto-create material: %s", e)
|
||||
logger.warning("Could not auto-create material %s: %s — trying fallback fetch", mat_name, e)
|
||||
try:
|
||||
existing_mats = await ctx.api.list_materials(q=mat_name, page_size=10)
|
||||
for m in existing_mats:
|
||||
if normalize_text(m.get("name") or "") == normalize_text(mat_name):
|
||||
_set_material(item, str(m["id"]), m.get("name") or mat_name,
|
||||
{"canonical_unit_id": m.get("canonical_unit_id")})
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if created_mats:
|
||||
newly_created["materials"] = created_mats
|
||||
|
||||
# 3. Sequential pre-creation for new projects
|
||||
# 3. Defensive pre-creation for new projects (get_or_create)
|
||||
created_prjs = []
|
||||
all_p_names = []
|
||||
if payload.get("project_names"):
|
||||
@@ -364,14 +500,26 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
||||
all_p_names.append(it_p_str)
|
||||
|
||||
resolved_map = payload.setdefault("resolved_projects", {})
|
||||
all_known_prjs = None
|
||||
for p_name in all_p_names:
|
||||
p_id = resolved_map.get(p_name)
|
||||
if not p_id:
|
||||
res = await safe_resolve(ctx.resolver, "project", p_name)
|
||||
if res.resolved:
|
||||
p_id = res.candidates[0].id
|
||||
# 3.a) Check if project already exists in catalog
|
||||
if all_known_prjs is None:
|
||||
try:
|
||||
all_known_prjs = await ctx.resolver.projects()
|
||||
except Exception:
|
||||
all_known_prjs = []
|
||||
norm_pn = normalize_text(p_name)
|
||||
for prj in all_known_prjs:
|
||||
if normalize_text(prj.name) == norm_pn:
|
||||
p_id = prj.id
|
||||
resolved_map[p_name] = p_id
|
||||
else:
|
||||
resolved_map[prj.name] = p_id
|
||||
break
|
||||
|
||||
# 3.b) If not in catalog, create it
|
||||
if not p_id:
|
||||
try:
|
||||
created_prj = await ctx.api.create_project({"name": p_name}, actor_id=user.actor_id)
|
||||
p_id = created_prj.get("id")
|
||||
@@ -379,6 +527,7 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
||||
resolved_map[p_name] = p_id
|
||||
created_prjs.append(p_name)
|
||||
ctx.resolver.invalidate("projects")
|
||||
all_known_prjs = None
|
||||
except Exception as e:
|
||||
logger.warning("Could not auto-create project %s: %s", p_name, e)
|
||||
|
||||
@@ -418,6 +567,13 @@ async def commit(ctx, chat_id, user, draft, only_resolved: bool = False) -> None
|
||||
ctx.resolver.invalidate()
|
||||
|
||||
quote_id = (created or {}).get("id") or ""
|
||||
await obs.event(trace_id, "draft.commit", payload={
|
||||
"quote_id": quote_id,
|
||||
"items": len(body.get("line_items") or []),
|
||||
"newly_created": newly_created,
|
||||
"only_resolved": only_resolved,
|
||||
})
|
||||
await obs.set_trace_status(trace_id, "committed", quote_id)
|
||||
# If a proforma file was attached, persist it to the created ERP quote
|
||||
proforma_info = payload.get("proforma")
|
||||
if quote_id and proforma_info and proforma_info.get("saved_path"):
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
import logging
|
||||
|
||||
from app import drafts as draft_store
|
||||
from app import observability as obs
|
||||
from app.api.client import ApiError
|
||||
from app.drafts import (
|
||||
KIND_SUPPLIER, merge_supplier_patch, new_supplier_payload, supplier_missing,
|
||||
)
|
||||
from app.keyboards import pick_buttons, simple_review_keyboard
|
||||
from app.resolve import safe_resolve
|
||||
from app.nlu import normalize_text
|
||||
from app.texts import ERR_GENERIC, esc, supplier_committed_report, supplier_review_text
|
||||
|
||||
logger = logging.getLogger("bot.supplier")
|
||||
|
||||
|
||||
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -> None:
|
||||
async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None,
|
||||
trace_id: str | None = None) -> None:
|
||||
draft = await draft_store.get_active(chat_id, KIND_SUPPLIER)
|
||||
if draft is not None:
|
||||
payload = draft.payload
|
||||
@@ -22,21 +24,33 @@ async def handle_entities(ctx, chat_id, user, patch: dict, source: str | None) -
|
||||
payload = new_supplier_payload()
|
||||
merge_supplier_patch(payload, patch)
|
||||
draft = await draft_store.create(chat_id, KIND_SUPPLIER, payload, source=source)
|
||||
await resolve_payload(ctx, payload)
|
||||
await obs.link_draft(trace_id, draft.id, draft.kind)
|
||||
await resolve_payload(ctx, payload, trace_id)
|
||||
await draft_store.save_payload(draft, missing={"fields": supplier_missing(payload)})
|
||||
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None)
|
||||
await render(ctx, chat_id, draft, fresh=draft.review_message_id is None, trace_id=trace_id)
|
||||
|
||||
|
||||
async def resolve_payload(ctx, payload: dict) -> None:
|
||||
async def resolve_payload(ctx, payload: dict, trace_id: str | None = None) -> None:
|
||||
options = payload.setdefault("options", {})
|
||||
if not payload.get("category_id") and payload.get("category_name"):
|
||||
res = await safe_resolve(ctx.resolver, "category", payload["category_name"])
|
||||
if res.resolved:
|
||||
payload["category_id"] = res.candidates[0].id
|
||||
payload["category_name"] = res.candidates[0].name
|
||||
norm_cat = normalize_text(payload["category_name"])
|
||||
try:
|
||||
cats = await ctx.resolver.categories()
|
||||
except Exception:
|
||||
cats = []
|
||||
exact = [c for c in cats if normalize_text(c.name) == norm_cat or (c.extra or {}).get("slug") == norm_cat]
|
||||
if len(exact) == 1:
|
||||
payload["category_id"] = exact[0].id
|
||||
payload["category_name"] = exact[0].name
|
||||
options.pop("cat", None)
|
||||
elif res.candidates:
|
||||
options["cat"] = [{"id": c.id, "name": c.name} for c in res.candidates]
|
||||
else:
|
||||
partial = [c for c in cats if norm_cat in normalize_text(c.name) and len(norm_cat) >= 3]
|
||||
if len(partial) == 1:
|
||||
payload["category_id"] = partial[0].id
|
||||
payload["category_name"] = partial[0].name
|
||||
options.pop("cat", None)
|
||||
elif partial:
|
||||
options["cat"] = [{"id": c.id, "name": c.name} for c in partial]
|
||||
else:
|
||||
options.pop("cat", None)
|
||||
|
||||
@@ -58,7 +72,7 @@ def _keyboard(payload: dict, draft_id: str) -> dict:
|
||||
return simple_review_keyboard(draft_id, KIND_SUPPLIER, rows)
|
||||
|
||||
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||
async def render(ctx, chat_id, draft, fresh: bool = True, trace_id: str | None = None) -> None:
|
||||
payload = draft.payload
|
||||
missing = supplier_missing(payload)
|
||||
text = supplier_review_text(payload, missing)
|
||||
@@ -71,11 +85,14 @@ async def render(ctx, chat_id, draft, fresh: bool = True) -> None:
|
||||
else:
|
||||
await ctx.edit(chat_id, draft.review_message_id, text, reply_markup=keyboard)
|
||||
await draft_store.save_payload(draft, missing={"fields": missing})
|
||||
await obs.event(trace_id or await obs.latest_trace_for_draft(draft.id), "draft.render",
|
||||
payload={"fresh": fresh, "missing": [m.get("key") for m in missing if not m.get("ok")]})
|
||||
|
||||
|
||||
async def commit(ctx, chat_id, user, draft) -> None:
|
||||
async def commit(ctx, chat_id, user, draft, trace_id: str | None = None) -> None:
|
||||
payload = draft.payload
|
||||
await resolve_payload(ctx, payload)
|
||||
trace_id = trace_id or await obs.latest_trace_for_draft(draft.id)
|
||||
await resolve_payload(ctx, payload, trace_id)
|
||||
missing = {m["key"]: m["ok"] for m in supplier_missing(payload)}
|
||||
problems = [label for key, label, ok in [
|
||||
("name", "نام تأمینکننده", missing.get("name")),
|
||||
@@ -99,5 +116,8 @@ async def commit(ctx, chat_id, user, draft) -> None:
|
||||
await ctx.send(chat_id, ERR_GENERIC.format(detail=esc(exc.message)))
|
||||
return
|
||||
await draft_store.set_status(draft.id, "committed")
|
||||
ctx.resolver.invalidate("suppliers")
|
||||
await obs.event(trace_id, "draft.commit", payload={"kind": "supplier",
|
||||
"supplier_id": (created or {}).get("id")})
|
||||
await obs.set_trace_status(trace_id, "committed")
|
||||
ctx.resolver.invalidate()
|
||||
await ctx.send(chat_id, supplier_committed_report(created))
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Entity matching with Jev (TypeSafe) — phases 3-5 of the final plan.
|
||||
|
||||
Mechanics (final plan v5 §2):
|
||||
- برونگروهی: ONE Jev request per FIELD that has mentions (requests run in
|
||||
parallel; latency ≈ one round-trip regardless of field count).
|
||||
- درونگروهی: inside a field request, ONE `choice` question per mention; every
|
||||
question rides with the SAME full candidate list (+ a `_none` option).
|
||||
- درونواریانسی: all STT spellings of one entity travel in ONE question, and
|
||||
the instructions ask the model to treat them as one name.
|
||||
|
||||
Routing (thresholds from env; calibrated in phase 6):
|
||||
conf >= JEV_CONF_EXACT → exact (bind id, no ambiguity sub-message)
|
||||
JEV_CONF_FLOOR..JEV_CONF_EXACT → ambiguity (candidate buttons sub-message)
|
||||
below floor / _none → new (existing auto-create path)
|
||||
|
||||
Fail-soft: a failed field simply yields no results for its mentions; callers
|
||||
fall back to the difflib path (`safe_resolve`). With JEV_ENABLED=false nothing
|
||||
in this module runs (zero regression).
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app import observability as obs
|
||||
from app.config import settings
|
||||
from app.jev import ask as jev_ask
|
||||
from app.nlu import normalize_text
|
||||
|
||||
logger = logging.getLogger("bot.matching")
|
||||
|
||||
FIELD_ORDER = ["project", "supplier", "material", "brand", "spec", "unit"]
|
||||
|
||||
FIELD_FA = {
|
||||
"project": "پروژه",
|
||||
"supplier": "تأمینکننده",
|
||||
"material": "متریال",
|
||||
"brand": "برند",
|
||||
"spec": "مشخصه",
|
||||
"unit": "واحد",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# candidate lists (full lists — no prefilter, per owner decision)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _candidates(resolver, field: str) -> list:
|
||||
if field == "project":
|
||||
return await resolver.projects()
|
||||
if field == "supplier":
|
||||
return await resolver.all_suppliers()
|
||||
if field == "material":
|
||||
return await resolver.all_materials()
|
||||
if field == "brand":
|
||||
return await resolver.brands()
|
||||
if field == "spec":
|
||||
return await resolver.specs()
|
||||
if field == "unit":
|
||||
return await resolver.units()
|
||||
return []
|
||||
|
||||
|
||||
def _hint(field: str, cand) -> str:
|
||||
"""Short, token-cheap description shown next to a candidate name."""
|
||||
if field == "supplier":
|
||||
extra = cand.extra or {}
|
||||
bits = [b for b in (extra.get("category_name"), extra.get("location")) if b]
|
||||
return "، ".join(str(b) for b in bits) if bits else "تأمینکننده"
|
||||
return ""
|
||||
|
||||
|
||||
def _instructions(field: str, variants: list[str]) -> str:
|
||||
fa = FIELD_FA.get(field, field)
|
||||
vlist = "»، «".join(v for v in variants if v)
|
||||
if len(variants) > 1:
|
||||
return (
|
||||
f"کاربر در پیام (صوتی) خود این {fa} را ذکر کرده و مدلهای مختلف STT آن را به شکلهای "
|
||||
f"«{vlist}» پیاده کردهاند؛ اینها چند نگارش از یک نام واحدند. با توجه به بافت جمله و "
|
||||
f"تشابه آوایی میان این واریانتها، مشخص کن به کدام گزینه از فهرست اشاره دارد. "
|
||||
f"اگر هیچکدام منطبق نیست یا تردید جدی داری «_none» را انتخاب کن."
|
||||
)
|
||||
return (
|
||||
f"کاربر «{vlist}» را بهعنوان {fa} ذکر کرده است. این عبارت به کدام گزینه از فهرست اشاره دارد؟ "
|
||||
f"اگر هیچکدام منطبق نیست یا تردید جدی داری «_none» را انتخاب کن."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
from app.fallback_matcher import MatchingFailureError, match_with_deepseek, adjudicate_with_deepseek
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the per-field fan-out
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def resolve_mentions(
|
||||
ctx,
|
||||
patch: dict,
|
||||
mentions: list[dict],
|
||||
trace_id: str | None = None,
|
||||
raw_input: object = None,
|
||||
consensus_text: str | None = None,
|
||||
) -> dict:
|
||||
"""Run Two-Stage Matching: Jev scorer + DeepSeek final arbiter; DeepSeek crisis fallback if Jev down.
|
||||
|
||||
Return a map keyed by normalized variant.
|
||||
Each value: {"field", "status": exact|ambiguity|new", "variants",
|
||||
"id"?, "name"?, "extra"?, "confidence"?, "candidates": [...]}
|
||||
"""
|
||||
by_field: dict[str, list[dict]] = {}
|
||||
for m in mentions or []:
|
||||
field = str((m or {}).get("field") or "").strip()
|
||||
variants = [str(v).strip() for v in ((m or {}).get("variants") or []) if str(v or "").strip()]
|
||||
if field in FIELD_ORDER and variants:
|
||||
by_field.setdefault(field, []).append({"field": field, "variants": variants[:6]})
|
||||
|
||||
results: dict[str, dict] = {}
|
||||
if not by_field:
|
||||
return results
|
||||
|
||||
unhandled_mentions: list[dict] = []
|
||||
unhandled_fields: set[str] = set()
|
||||
jev_arbitration_items: list[dict] = []
|
||||
cands_by_field: dict[str, list] = {}
|
||||
|
||||
async def one_field(field: str, ms: list[dict]) -> None:
|
||||
try:
|
||||
cands = await _candidates(ctx.resolver, field)
|
||||
except Exception:
|
||||
logger.exception("matching: candidate fetch failed for %s", field)
|
||||
unhandled_mentions.extend(ms)
|
||||
unhandled_fields.add(field)
|
||||
return
|
||||
if not cands:
|
||||
unhandled_mentions.extend(ms)
|
||||
unhandled_fields.add(field)
|
||||
return
|
||||
|
||||
cands_by_field[field] = cands
|
||||
|
||||
by_name: dict[str, object] = {}
|
||||
criteria: dict[str, str] = {}
|
||||
for c in cands:
|
||||
if c.name in criteria:
|
||||
continue
|
||||
criteria[c.name] = _hint(field, c)
|
||||
by_name[c.name] = c
|
||||
criteria["_none"] = "هیچکدام از موارد موجود نیست"
|
||||
|
||||
capped = ms[: settings.JEV_MAX_QUESTIONS_PER_FIELD]
|
||||
questions = {
|
||||
f"mention_{i}": {
|
||||
"type": "choice",
|
||||
"instructions": _instructions(field, m["variants"]),
|
||||
"criteria": criteria,
|
||||
}
|
||||
for i, m in enumerate(capped)
|
||||
}
|
||||
state = {
|
||||
"field": field,
|
||||
"patch_context": json.dumps(patch, ensure_ascii=False, default=str)[:1500],
|
||||
}
|
||||
await obs.event(trace_id, "jev.request", field=field, payload={
|
||||
"n_mentions": len(capped), "n_candidates": len(cands),
|
||||
})
|
||||
resp = await jev_ask(state, questions)
|
||||
await obs.event(trace_id, "jev.result", field=field, ok=resp is not None,
|
||||
model=(resp or {}).get("model"),
|
||||
latency_ms=(resp or {}).get("latency_ms"),
|
||||
payload={"answers": (resp or {}).get("answers"),
|
||||
"usage": (resp or {}).get("usage")})
|
||||
if resp is None:
|
||||
# Observability: log jev.failure event
|
||||
await obs.event(trace_id, "jev.failure", field=field, payload={
|
||||
"error": "jev_unavailable_or_error",
|
||||
"reason": "HTTP error or timeout or no response",
|
||||
"model": settings.JEV_MODEL,
|
||||
"n_mentions": len(capped),
|
||||
})
|
||||
unhandled_mentions.extend(capped)
|
||||
unhandled_fields.add(field)
|
||||
return
|
||||
answers = resp.get("answers") or {}
|
||||
|
||||
decisions = []
|
||||
for i, m in enumerate(capped):
|
||||
ans = answers.get(f"mention_{i}") or {}
|
||||
if not ans:
|
||||
unhandled_mentions.append(m)
|
||||
unhandled_fields.add(field)
|
||||
continue
|
||||
choice = ans.get("choice")
|
||||
conf = float(ans.get("confidence") or 0.0)
|
||||
probs = ans.get("probabilities") or {}
|
||||
non_zero_probs = {k: v for k, v in probs.items() if float(v or 0.0) > 0.0}
|
||||
cand = by_name.get(choice) if choice else None
|
||||
ranked = sorted(
|
||||
({"id": by_name[n].id, "name": n, "prob": float(p or 0.0)}
|
||||
for n, p in non_zero_probs.items() if n != "_none" and n in by_name),
|
||||
key=lambda x: -x["prob"],
|
||||
)[:6]
|
||||
|
||||
# Collect for DeepSeek final arbitration (never write directly to results)
|
||||
jev_arbitration_items.append({
|
||||
"field": field,
|
||||
"variants": m["variants"],
|
||||
"candidates": cands,
|
||||
"jev_choice": cand.name if cand else ("_none" if choice == "_none" else None),
|
||||
"jev_choice_id": cand.id if cand else None,
|
||||
"jev_confidence": conf,
|
||||
"jev_probabilities": non_zero_probs,
|
||||
"jev_ranked": ranked,
|
||||
})
|
||||
|
||||
decisions.append({
|
||||
"variants": m["variants"],
|
||||
"jev_choice": cand.name if cand else choice,
|
||||
"confidence": conf,
|
||||
"probs_top": [{k: round(v, 3)} for k, v in
|
||||
sorted(probs.items(), key=lambda kv: -kv[1])[:3]],
|
||||
})
|
||||
|
||||
await obs.event(trace_id, "routing.decision", field=field,
|
||||
payload={"source": "jev_scored", "mentions": decisions})
|
||||
|
||||
if settings.JEV_ENABLED:
|
||||
await asyncio.gather(*(one_field(f, ms) for f, ms in by_field.items()))
|
||||
else:
|
||||
# Jev disabled -> all mentions route to DeepSeek fallback
|
||||
for f, ms in by_field.items():
|
||||
unhandled_mentions.extend(ms)
|
||||
unhandled_fields.add(f)
|
||||
|
||||
# 1. Final Arbitration: Pass Jev recommendations + raw user input + DB catalog to DeepSeek
|
||||
if jev_arbitration_items:
|
||||
adjudicated_res = await adjudicate_with_deepseek(
|
||||
raw_input=raw_input,
|
||||
consensus_text=consensus_text,
|
||||
items=jev_arbitration_items,
|
||||
patch_context=patch,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
results.update(adjudicated_res)
|
||||
|
||||
# 2. Crisis Fallback: If any mentions were unhandled / failed in Jev, run DeepSeek Crisis Fallback
|
||||
if unhandled_mentions:
|
||||
for f in unhandled_fields:
|
||||
if f not in cands_by_field:
|
||||
try:
|
||||
cands_by_field[f] = await _candidates(ctx.resolver, f)
|
||||
except Exception:
|
||||
cands_by_field[f] = []
|
||||
fallback_res = await match_with_deepseek(
|
||||
raw_input=raw_input,
|
||||
mentions=unhandled_mentions,
|
||||
candidates_by_field=cands_by_field,
|
||||
patch_context=patch,
|
||||
trace_id=trace_id,
|
||||
consensus_text=consensus_text,
|
||||
)
|
||||
results.update(fallback_res)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def lookup(jev_map: dict | None, name: str | None) -> dict | None:
|
||||
"""Find the Jev result for a raw name (by normalized variant)."""
|
||||
if not jev_map or not name:
|
||||
return None
|
||||
return jev_map.get(normalize_text(name))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# writing results into drafts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply_choice(payload: dict, field: str, cand: dict, variants: list[str]) -> None:
|
||||
"""Bind one chosen candidate (exact match or a user pick) into the payload."""
|
||||
name, cid = cand.get("name"), cand.get("id")
|
||||
extra = cand.get("extra") or {}
|
||||
variants_norm = {normalize_text(v) for v in (variants or []) if v}
|
||||
|
||||
if field == "supplier":
|
||||
payload["supplier_id"] = cid
|
||||
payload["supplier_name"] = name
|
||||
for k in ("location", "phone", "contact_person", "category_name"):
|
||||
if extra.get(k):
|
||||
payload[f"supplier_{k}"] = extra[k]
|
||||
elif field == "project":
|
||||
resolved = payload.setdefault("resolved_projects", {})
|
||||
for v in (variants or []):
|
||||
resolved[v] = cid
|
||||
resolved[name] = cid
|
||||
if cid not in payload.get("project_ids", []):
|
||||
payload.setdefault("project_ids", []).append(cid)
|
||||
if not payload.get("project_id"):
|
||||
payload["project_id"] = cid
|
||||
elif field == "material":
|
||||
for item in payload.get("items") or []:
|
||||
if normalize_text(item.get("material_name") or "") in variants_norm:
|
||||
item["material_name"] = name
|
||||
item["material_id"] = cid
|
||||
item["material_row"] = {"id": cid,
|
||||
"canonical_unit_id": extra.get("canonical_unit_id")}
|
||||
elif field == "unit":
|
||||
for item in payload.get("items") or []:
|
||||
if normalize_text(item.get("unit_name") or "") in variants_norm:
|
||||
item["unit_id"] = cid
|
||||
item["unit_explicit"] = True
|
||||
item["unit_name"] = name
|
||||
if not payload.get("items"):
|
||||
payload["canonical_unit_id"] = cid
|
||||
payload["unit_name"] = name
|
||||
elif field == "brand":
|
||||
for item in payload.get("items") or []:
|
||||
if normalize_text(item.get("brand_name") or "") in variants_norm:
|
||||
item["brand_name"] = name
|
||||
elif field == "spec":
|
||||
for item in payload.get("items") or []:
|
||||
specs = item.get("specs") or []
|
||||
for i, s in enumerate(specs):
|
||||
if normalize_text(s) in variants_norm:
|
||||
specs[i] = name
|
||||
|
||||
|
||||
def apply_exact(payload: dict, res: dict) -> None:
|
||||
"""Bind one exact Jev match into a draft payload."""
|
||||
apply_choice(payload, res.get("field"), {"id": res.get("id"), "name": res.get("name"),
|
||||
"extra": res.get("extra") or {}},
|
||||
res.get("variants") or [])
|
||||
|
||||
|
||||
def normalize_brand_spec(payload: dict, jev_map: dict | None) -> None:
|
||||
"""Canonicalize brand/spec strings via exact matches (spec effect per owner:
|
||||
spelling normalization only)."""
|
||||
if not jev_map:
|
||||
return
|
||||
for item in payload.get("items") or []:
|
||||
brand = item.get("brand_name")
|
||||
if brand:
|
||||
hit = lookup(jev_map, brand)
|
||||
if hit and hit.get("status") == "exact":
|
||||
item["brand_name"] = hit["name"]
|
||||
specs = item.get("specs") or []
|
||||
for i, s in enumerate(specs):
|
||||
hit = lookup(jev_map, s)
|
||||
if hit and hit.get("status") == "exact":
|
||||
specs[i] = hit["name"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# phase 4 — ambiguity sub-messages with candidate buttons
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _amb_id(field: str, variants: list[str]) -> str:
|
||||
digest = hashlib.sha1("|".join(sorted(variants)).encode()).hexdigest()[:4]
|
||||
return f"em_{field[:4]}_{digest}"
|
||||
|
||||
|
||||
def _fa_num(n: int) -> str:
|
||||
return str(n).translate(str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹"))
|
||||
|
||||
|
||||
def create_ambiguities(payload: dict, jev_map: dict | None) -> None:
|
||||
"""One `entity_match` ambiguity entry per ambiguous mention (deduped)."""
|
||||
if not jev_map:
|
||||
return
|
||||
ambs = payload.setdefault("ambiguities", [])
|
||||
seen = {a.get("id") for a in ambs}
|
||||
unique: dict[int, dict] = {}
|
||||
for res in jev_map.values():
|
||||
unique[id(res)] = res # dedupe identical result objects
|
||||
for res in unique.values():
|
||||
if res.get("status") != "ambiguity" or not res.get("candidates"):
|
||||
continue
|
||||
amb_id = _amb_id(res["field"], res.get("variants") or [])
|
||||
if amb_id in seen:
|
||||
continue
|
||||
cands = res["candidates"][:6]
|
||||
lines = [f"• {c['name']} — {_fa_num(int(round(c['prob'] * 100)))}٪" for c in cands]
|
||||
ambs.append({
|
||||
"id": amb_id,
|
||||
"kind": "entity_match",
|
||||
"field": res["field"],
|
||||
"variants": res.get("variants") or [],
|
||||
"candidates": [{"id": c["id"], "name": c["name"]} for c in cands],
|
||||
"title": f"تطبیق {FIELD_FA.get(res['field'], res['field'])}: «{' / '.join(res.get('variants') or [])}»",
|
||||
"details": "\n".join(lines),
|
||||
"status": "pending",
|
||||
})
|
||||
seen.add(amb_id)
|
||||
+45
-1
@@ -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()
|
||||
|
||||
+74
-11
@@ -4,16 +4,23 @@ One chat/completions call per message with JSON-mode output. The prompt embeds
|
||||
the current draft summary so the model can decide between "start a new
|
||||
activity" and "refine the open draft" (update_draft with a partial patch).
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from app import observability as obs
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger("bot.nlu")
|
||||
|
||||
|
||||
class NLUFailureError(Exception):
|
||||
"""Raised when NLU understanding fails due to LLM / network failure."""
|
||||
pass
|
||||
|
||||
PERSIAN_DIGITS = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
|
||||
|
||||
|
||||
@@ -23,6 +30,23 @@ def normalize_digits(text: str) -> str:
|
||||
|
||||
ZWNJ = "\u200c"
|
||||
|
||||
MENTION_FIELDS = {"project", "supplier", "material", "brand", "spec", "unit"}
|
||||
|
||||
|
||||
def sanitize_mentions(raw) -> list[dict]:
|
||||
"""Keep only valid {field, variants} mention objects from the LLM output."""
|
||||
out: list[dict] = []
|
||||
if not isinstance(raw, list):
|
||||
return out
|
||||
for m in raw:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
field = str(m.get("field") or "").strip()
|
||||
variants = [str(v).strip() for v in (m.get("variants") or []) if str(v or "").strip()]
|
||||
if field in MENTION_FIELDS and variants:
|
||||
out.append({"field": field, "variants": variants[:6]})
|
||||
return out
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Normalize Persian text for fuzzy matching (ZWNJ, arabic ya/ka, spaces)."""
|
||||
@@ -58,6 +82,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 +118,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,13 +177,11 @@ def _extract_json(raw: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
async def understand(settings: Settings, text: str | list[dict], draft_summary: str | None = None) -> dict:
|
||||
async def understand(settings: Settings, text: str | list[dict], draft_summary: str | None = None,
|
||||
trace_id: str | None = None) -> dict:
|
||||
"""Return {"consensus_text": ..., "intent": ..., "patch": {...}, "confidence": ...}; never raises."""
|
||||
fallback = {"consensus_text": "", "intent": "unknown", "patch": {}, "confidence": 0.0}
|
||||
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
|
||||
logger.warning("AI provider not configured; cannot understand message")
|
||||
return fallback
|
||||
|
||||
fallback = {"consensus_text": "", "intent": "unknown", "patch": {},
|
||||
"entity_mentions": [], "confidence": 0.0}
|
||||
if isinstance(text, list):
|
||||
first_text = text[0].get("text") if text else ""
|
||||
lines = ["پیام صوتی کاربر توسط چند مدل تبدیل گفتار به متن (STT) به صورت زیر پیادهسازی شده است:"]
|
||||
@@ -172,6 +204,21 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
|
||||
|
||||
fallback["consensus_text"] = first_text
|
||||
|
||||
if not settings.AI_BASE_URL or not settings.AI_API_KEY:
|
||||
logger.warning("AI provider not configured; cannot understand message")
|
||||
return fallback
|
||||
|
||||
system_prompt = _build_system_prompt(draft_summary)
|
||||
prompt_hash = hashlib.sha256(
|
||||
f"{settings.OBS_PROMPT_VERSION}|{system_prompt}".encode()
|
||||
).hexdigest()[:16]
|
||||
await obs.event(trace_id, "nlu.request", model=settings.LLM_MODEL, payload={
|
||||
"is_multi": isinstance(text, list),
|
||||
"content": user_content[:2000],
|
||||
"prompt_hash": prompt_hash,
|
||||
"prompt_version": settings.OBS_PROMPT_VERSION,
|
||||
})
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
|
||||
resp = await client.post(
|
||||
@@ -180,7 +227,7 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
|
||||
json={
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": _build_system_prompt(draft_summary)},
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
@@ -189,16 +236,32 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.error("LLM HTTP %s: %s", resp.status_code, resp.text[:300])
|
||||
return fallback
|
||||
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
|
||||
payload={"error": f"HTTP {resp.status_code}", "body": resp.text[:300]})
|
||||
raise NLUFailureError(f"HTTP {resp.status_code}")
|
||||
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "")
|
||||
parsed = _extract_json(content)
|
||||
if not parsed or parsed.get("intent") not in INTENTS:
|
||||
logger.warning("unparseable LLM output: %r", content[:200])
|
||||
return fallback
|
||||
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
|
||||
payload={"error": "unparseable", "raw": content[:500]})
|
||||
raise NLUFailureError("unparseable LLM output")
|
||||
parsed.setdefault("patch", {})
|
||||
parsed["entity_mentions"] = sanitize_mentions(parsed.get("entity_mentions"))
|
||||
if not parsed.get("consensus_text"):
|
||||
parsed["consensus_text"] = first_text
|
||||
await obs.event(trace_id, "nlu.result", ok=True, model=settings.LLM_MODEL, payload={
|
||||
"intent": parsed.get("intent"),
|
||||
"confidence": parsed.get("confidence"),
|
||||
"patch": parsed.get("patch"),
|
||||
"consensus_text": parsed.get("consensus_text"),
|
||||
"entity_mentions": parsed.get("entity_mentions"),
|
||||
})
|
||||
return parsed
|
||||
except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError):
|
||||
except Exception as exc:
|
||||
if isinstance(exc, NLUFailureError):
|
||||
raise
|
||||
logger.exception("NLU failure")
|
||||
return fallback
|
||||
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
|
||||
payload={"error": "exception", "details": str(exc)})
|
||||
raise NLUFailureError(f"NLU exception: {exc}") from exc
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Silent observer — records the whole bot pipeline into Postgres (phase 1).
|
||||
|
||||
Design rules (final plan v5):
|
||||
- DB is the single source of truth; no external tools, no dashboards here.
|
||||
- ZERO extra LLM tokens: we only persist values that already exist in code.
|
||||
- Never raises, never blocks the reply path (fire-and-forget like runtime._log).
|
||||
- Never deletes anything (no retention/cleanup tasks exist by policy).
|
||||
- `_jsonable` guarantees JSONB-safety for UUID/datetime/Decimal/... payloads.
|
||||
|
||||
Stages captured (see plan §4.3): input.text, input.voice, stt.run, stt.result,
|
||||
nlu.request, nlu.result, entities.extracted, jev.request, jev.result,
|
||||
routing.decision, ambiguity.sent, ambiguity.user_choice, user_choice.pick,
|
||||
draft.render, draft.edit, draft.commit, draft.cancel.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import BotTrace, BotTraceEvent
|
||||
|
||||
logger = logging.getLogger("bot.obs")
|
||||
|
||||
_bg_tasks: set = set()
|
||||
|
||||
|
||||
def _fire(coro) -> None:
|
||||
"""Schedule a coroutine without awaiting; keep a ref so it is not GC'd."""
|
||||
try:
|
||||
task = asyncio.get_running_loop().create_task(coro)
|
||||
_bg_tasks.add(task)
|
||||
task.add_done_callback(_bg_tasks.discard)
|
||||
except RuntimeError:
|
||||
# No running loop (should not happen inside the bot) — drop silently but log.
|
||||
logger.warning("obs: no running loop; dropped %r", coro)
|
||||
|
||||
|
||||
def _jsonable(obj):
|
||||
"""Recursively make any Python object JSONB-safe (UUID/datetime/Decimal/…)."""
|
||||
if obj is None or isinstance(obj, (bool, int, float, str)):
|
||||
return obj
|
||||
if isinstance(obj, (uuid.UUID, Path)):
|
||||
return str(obj)
|
||||
if isinstance(obj, (datetime, date, time)):
|
||||
return obj.isoformat()
|
||||
if isinstance(obj, Decimal):
|
||||
return str(obj) # دقت حفظ میشود؛ در تحلیل با ::numeric قابل تبدیل است
|
||||
if isinstance(obj, Enum):
|
||||
return _jsonable(obj.value)
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): _jsonable(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple, set, frozenset)):
|
||||
return [_jsonable(v) for v in obj]
|
||||
if hasattr(obj, "model_dump"): # pydantic v2
|
||||
return _jsonable(obj.model_dump())
|
||||
if hasattr(obj, "__dataclass_fields__"):
|
||||
return _jsonable(vars(obj))
|
||||
return str(obj) # تور نهایی — هرگز raise نمیکند
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# traces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def trace_start(chat_id: int, message_id: int | None, kind: str,
|
||||
source: str = "bale") -> str | None:
|
||||
"""Open a new trace; returns its id (str) or None on failure."""
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
t = BotTrace(chat_id=chat_id, message_id=message_id, kind=kind, source=source)
|
||||
s.add(t)
|
||||
await s.commit()
|
||||
return str(t.id)
|
||||
except Exception:
|
||||
logger.exception("obs: trace_start failed")
|
||||
return None
|
||||
|
||||
|
||||
async def link_draft(trace_id: str | None, draft_id, draft_kind: str | None) -> None:
|
||||
"""Attach a draft to its originating trace (so later button clicks can find it)."""
|
||||
if not trace_id:
|
||||
return
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
await s.execute(
|
||||
update(BotTrace)
|
||||
.where(BotTrace.id == trace_id)
|
||||
.values(draft_id=str(draft_id), draft_kind=draft_kind)
|
||||
)
|
||||
await s.commit()
|
||||
except Exception:
|
||||
logger.exception("obs: link_draft failed")
|
||||
|
||||
|
||||
async def latest_trace_for_draft(draft_id) -> str | None:
|
||||
"""Newest trace that produced this draft — used by button/render flows."""
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
row = (await s.execute(
|
||||
select(BotTrace.id)
|
||||
.where(BotTrace.draft_id == str(draft_id))
|
||||
.order_by(BotTrace.created_at.desc())
|
||||
.limit(1)
|
||||
)).scalar()
|
||||
return str(row) if row else None
|
||||
except Exception:
|
||||
logger.exception("obs: latest_trace_for_draft failed")
|
||||
return None
|
||||
|
||||
|
||||
async def set_trace_status(trace_id: str | None, status: str,
|
||||
committed_quote_id: str | None = None) -> None:
|
||||
if not trace_id:
|
||||
return
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
values = {"status": status}
|
||||
if committed_quote_id:
|
||||
values["committed_quote_id"] = str(committed_quote_id)
|
||||
await s.execute(update(BotTrace).where(BotTrace.id == trace_id).values(**values))
|
||||
await s.commit()
|
||||
except Exception:
|
||||
logger.exception("obs: set_trace_status failed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def event(trace_id, stage: str, *, ok: bool = True, latency_ms: int | None = None,
|
||||
model: str | None = None, field: str | None = None,
|
||||
payload: dict | None = None) -> None:
|
||||
"""Append one pipeline event. Never raises; failures are loud in the log."""
|
||||
if not trace_id or not settings.OBS_ENABLED:
|
||||
return
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
s.add(BotTraceEvent(
|
||||
trace_id=str(trace_id), stage=stage, ok=ok, latency_ms=latency_ms,
|
||||
model=model, field=field, payload=_jsonable(payload or {}),
|
||||
))
|
||||
await s.commit()
|
||||
except Exception:
|
||||
logger.exception("obs: event %s failed", stage) # خطای خاموش نمیماند
|
||||
|
||||
|
||||
async def log_resolution(trace_id, field: str, mention, status: str, candidates: list) -> None:
|
||||
"""Record one matching attempt (difflib baseline / fallback paths)."""
|
||||
await event(trace_id, "routing.decision", field=field, payload={
|
||||
"source": "difflib",
|
||||
"mention": mention,
|
||||
"status": status,
|
||||
"candidates": candidates[:4],
|
||||
})
|
||||
|
||||
|
||||
class span:
|
||||
"""with span('stt.run', trace_id=t, provider='whisper'): ... → one DB event with latency/ok."""
|
||||
|
||||
def __init__(self, name: str, trace_id: str | None = None, **attrs):
|
||||
self.name, self.trace_id, self.attrs = name, trace_id, attrs
|
||||
self._t0 = None
|
||||
|
||||
def __enter__(self):
|
||||
import time as _t
|
||||
self._t0 = _t.perf_counter_ns()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
import time as _t
|
||||
try:
|
||||
_fire(event(
|
||||
self.trace_id, self.name, ok=exc_type is None,
|
||||
latency_ms=int((_t.perf_counter_ns() - self._t0) / 1e6),
|
||||
model=self.attrs.get("model"), field=self.attrs.get("field"),
|
||||
payload=self.attrs,
|
||||
))
|
||||
except Exception:
|
||||
logger.exception("obs: span %s failed", self.name)
|
||||
return False # استثنا هرگز بلعیده نمیشود
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# voice persistence (never deleted)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def save_voice(trace_id: str | None, message_id: int, content: bytes,
|
||||
ext: str = ".ogg") -> str | None:
|
||||
"""Persist a voice note under OBS_VOICE_DIR; creates the day folder safely."""
|
||||
try:
|
||||
day = datetime.utcnow().strftime("%Y%m%d")
|
||||
base = Path(settings.OBS_VOICE_DIR) / day
|
||||
base.mkdir(parents=True, exist_ok=True) # الزام سختسازی: مسیر همیشه ساخته میشود
|
||||
path = base / f"{trace_id}_{message_id}{ext}"
|
||||
path.write_bytes(content)
|
||||
return str(path)
|
||||
except Exception:
|
||||
logger.warning("obs: save_voice failed for trace %s", trace_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def save_enhanced_voice(trace_id: str | None, message_id: int, content: bytes,
|
||||
ext: str = ".ogg") -> str | None:
|
||||
"""Persist an enhanced voice note under OBS_VOICE_DIR/{YYYYMMDD}/{trace_id}_{message_id}_enhanced{ext}."""
|
||||
try:
|
||||
day = datetime.utcnow().strftime("%Y%m%d")
|
||||
base = Path(settings.OBS_VOICE_DIR) / day
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
if ext and not ext.startswith("."):
|
||||
ext = f".{ext}"
|
||||
path = base / f"{trace_id}_{message_id}_enhanced{ext or '.ogg'}"
|
||||
path.write_bytes(content)
|
||||
return str(path)
|
||||
except Exception:
|
||||
logger.warning("obs: save_enhanced_voice failed for trace %s", trace_id, exc_info=True)
|
||||
return None
|
||||
+106
-9
@@ -3,7 +3,6 @@
|
||||
The ERP search endpoints are ilike-based; we fetch candidates with `q=` and
|
||||
rank them with difflib so "میلگرد A3 14" still matches "میلگرد A3 سایز ۱۴".
|
||||
"""
|
||||
import difflib
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -44,7 +43,7 @@ def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]
|
||||
elif name in q and len(name) >= 3:
|
||||
ratio = 0.85 + 0.15 * (len(name) / len(q))
|
||||
else:
|
||||
ratio = difflib.SequenceMatcher(None, q, name).ratio()
|
||||
ratio = 0.0
|
||||
if ratio >= 0.72:
|
||||
scored.append((ratio, item))
|
||||
scored.sort(key=lambda pair: (-pair[0], pair[1].name))
|
||||
@@ -153,6 +152,43 @@ async def _db_materials(q: str | None = None) -> list[Candidate]:
|
||||
return []
|
||||
|
||||
|
||||
async def _db_brands() -> list[Candidate]:
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
rows = (await s.execute(text("SELECT id, name FROM brands"))).mappings().all()
|
||||
return [Candidate(id=str(b["id"]), name=b.get("name") or str(b["id"])) for b in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _db_specs() -> list[Candidate]:
|
||||
"""Distinct spec values: EAV strings from the catalog + specs used in recent drafts."""
|
||||
values: dict[str, None] = {}
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
rows = (await s.execute(text(
|
||||
"SELECT DISTINCT value_string FROM material_attribute_values "
|
||||
"WHERE value_string IS NOT NULL AND btrim(value_string) <> '' LIMIT 500"
|
||||
))).scalars().all()
|
||||
for v in rows:
|
||||
values.setdefault(str(v), None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
rows = (await s.execute(text(
|
||||
"SELECT DISTINCT spec FROM bot_drafts d, "
|
||||
"jsonb_array_elements(COALESCE(d.payload->'items', '[]'::jsonb)) it, "
|
||||
"jsonb_array_elements_text(COALESCE(it->'specs', '[]'::jsonb)) spec "
|
||||
"WHERE btrim(spec) <> '' LIMIT 300"
|
||||
))).scalars().all()
|
||||
for v in rows:
|
||||
values.setdefault(str(v), None)
|
||||
except Exception:
|
||||
pass
|
||||
return [Candidate(id=v, name=v) for v in values]
|
||||
|
||||
|
||||
class Resolver:
|
||||
"""Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand)."""
|
||||
|
||||
@@ -236,6 +272,74 @@ class Resolver:
|
||||
items = await _db_materials(q=q)
|
||||
return _rank(q, items) if q else items
|
||||
|
||||
# ------------------------------------------------- full lists (Jev, v5)
|
||||
|
||||
async def _fetch_all(self, fetch_page, max_pages: int = 5) -> list[dict]:
|
||||
"""Page through a paginated ERP list endpoint (page_size=100)."""
|
||||
out: list[dict] = []
|
||||
for page in range(1, max_pages + 1):
|
||||
rows = await fetch_page(page)
|
||||
out += rows
|
||||
if len(rows) < 100:
|
||||
break
|
||||
return out
|
||||
|
||||
async def all_suppliers(self) -> list[Candidate]:
|
||||
async def fetch():
|
||||
try:
|
||||
rows = await self._fetch_all(
|
||||
lambda page: self._api.list_suppliers(page=page, page_size=100)
|
||||
)
|
||||
return [
|
||||
Candidate(
|
||||
id=str(s["id"]),
|
||||
name=s.get("name") or str(s["id"]),
|
||||
extra={
|
||||
"location": s.get("location"),
|
||||
"phone": s.get("phone"),
|
||||
"contact_person": s.get("contact_person"),
|
||||
"category_name": (s.get("category") or {}).get("name")
|
||||
if isinstance(s.get("category"), dict) else s.get("category_name"),
|
||||
},
|
||||
)
|
||||
for s in rows
|
||||
]
|
||||
except Exception:
|
||||
return await _db_suppliers()
|
||||
return await self._cached("all_suppliers", fetch)
|
||||
|
||||
async def all_materials(self) -> list[Candidate]:
|
||||
async def fetch():
|
||||
try:
|
||||
rows = await self._fetch_all(
|
||||
lambda page: self._api.list_materials(page=page, page_size=100)
|
||||
)
|
||||
return [
|
||||
Candidate(
|
||||
id=str(m["id"]),
|
||||
name=m.get("name") or str(m["id"]),
|
||||
extra={"canonical_unit_id": m.get("canonical_unit_id")},
|
||||
)
|
||||
for m in rows
|
||||
]
|
||||
except Exception:
|
||||
return await _db_materials()
|
||||
return await self._cached("all_materials", fetch)
|
||||
|
||||
async def brands(self) -> list[Candidate]:
|
||||
async def fetch():
|
||||
try:
|
||||
rows = await self._fetch_all(
|
||||
lambda page: self._api.list_brands(page=page, page_size=100)
|
||||
)
|
||||
return [Candidate(id=str(b["id"]), name=b.get("name") or str(b["id"])) for b in rows]
|
||||
except Exception:
|
||||
return await _db_brands()
|
||||
return await self._cached("brands", fetch)
|
||||
|
||||
async def specs(self) -> list[Candidate]:
|
||||
return await self._cached("specs", _db_specs)
|
||||
|
||||
# ------------------------------------------------------------ resolutions
|
||||
|
||||
async def resolve(self, kind: str, name: str | None) -> Resolution:
|
||||
@@ -260,13 +364,6 @@ class Resolver:
|
||||
exact = [c for c in candidates if normalize_text(c.name) == norm_name]
|
||||
if len(exact) == 1:
|
||||
return Resolution("one", exact)
|
||||
top = candidates[0]
|
||||
top_norm = normalize_text(top.name)
|
||||
ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio()
|
||||
if (norm_name in top_norm or top_norm in norm_name) and len(norm_name) >= 3:
|
||||
ratio = max(ratio, 0.90)
|
||||
if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95):
|
||||
return Resolution("one", [top])
|
||||
return Resolution("many", candidates)
|
||||
|
||||
async def unit_for_material(self, unit_name: str | None, material: dict | None) -> tuple[str | None, bool]:
|
||||
|
||||
+42
-10
@@ -9,10 +9,12 @@ import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app import observability as obs
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger("bot.stt")
|
||||
@@ -37,7 +39,8 @@ def _to_mp3(raw: bytes, suffix: str) -> bytes:
|
||||
return dst.read_bytes()
|
||||
|
||||
|
||||
async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict | None:
|
||||
async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict:
|
||||
"""Call one provider; always returns {"name","ok","text"/"error","model","latency_ms"}."""
|
||||
name = provider.get("name") or "stt"
|
||||
base_url = (provider.get("base_url") or "").rstrip("/")
|
||||
api_key = provider.get("api_key") or ""
|
||||
@@ -46,6 +49,7 @@ async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_
|
||||
timeout = float(provider.get("timeout") or _STT_TIMEOUT)
|
||||
|
||||
url = f"{base_url}/audio/transcriptions"
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
resp = await client.post(
|
||||
url,
|
||||
@@ -54,49 +58,77 @@ async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_
|
||||
data={"model": model, "language": language},
|
||||
timeout=timeout,
|
||||
)
|
||||
ms = int((time.perf_counter() - t0) * 1000)
|
||||
if resp.status_code == 200:
|
||||
txt = str((resp.json() or {}).get("text") or "").strip()
|
||||
if txt:
|
||||
logger.info("STT provider %s succeeded: %r", name, txt[:60])
|
||||
return {"name": name, "text": txt, "model": model}
|
||||
else:
|
||||
return {"name": name, "ok": True, "text": txt, "model": model, "latency_ms": ms}
|
||||
return {"name": name, "ok": False, "error": "empty transcript (HTTP 200)",
|
||||
"model": model, "latency_ms": ms}
|
||||
logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200])
|
||||
return {"name": name, "ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}",
|
||||
"model": model, "latency_ms": ms}
|
||||
except Exception as exc:
|
||||
logger.warning("STT provider %s failed: %s", name, exc)
|
||||
return None
|
||||
return {"name": name, "ok": False, "error": str(exc)[:300],
|
||||
"model": model, "latency_ms": int((time.perf_counter() - t0) * 1000)}
|
||||
|
||||
|
||||
async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None,
|
||||
filename: str | None = None) -> list[dict]:
|
||||
filename: str | None = None, trace_id: str | None = None,
|
||||
message_id: int | None = None) -> list[dict]:
|
||||
"""Transcribe audio with all configured STT providers in parallel.
|
||||
|
||||
Returns a list of candidate dicts: [{"name": str, "text": str, "model": str}, ...]
|
||||
Empty list if all failed or silence.
|
||||
Empty list if all failed or silence. Every provider outcome (success AND
|
||||
failure) is recorded in Observability when trace_id is given.
|
||||
"""
|
||||
providers = settings.stt_providers
|
||||
if not providers:
|
||||
raise RuntimeError("No STT providers are configured (PROCUREMENT_BOT_STT_*)")
|
||||
|
||||
# Audio enhancement / denoising step (pluggable, fail-soft, zero-transcode by default)
|
||||
from app.audio_enhancer import enhance_voice
|
||||
audio, _ = await enhance_voice(
|
||||
settings, audio, mime_type=mime_type, filename=filename,
|
||||
trace_id=trace_id, message_id=message_id,
|
||||
)
|
||||
|
||||
suffix = Path(filename or "").suffix
|
||||
if not suffix and mime_type:
|
||||
suffix = {"audio/ogg": ".ogg", "audio/opus": ".ogg", "audio/mpeg": ".mp3",
|
||||
"audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg")
|
||||
converted = await asyncio.to_thread(_to_mp3, audio, suffix)
|
||||
|
||||
await obs.event(trace_id, "stt.run", payload={
|
||||
"providers": [p.get("name") or "stt" for p in providers],
|
||||
"audio_bytes": len(converted),
|
||||
})
|
||||
|
||||
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
|
||||
tasks = [_call_single_stt(client, p, converted) for p in providers]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
valid_transcripts: list[dict] = []
|
||||
for r in results:
|
||||
if isinstance(r, dict) and r.get("text"):
|
||||
valid_transcripts.append(r)
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
await obs.event(trace_id, "stt.result", ok=bool(r.get("ok")),
|
||||
latency_ms=r.get("latency_ms"), model=r.get("model"),
|
||||
payload={"provider": r.get("name"), "text": r.get("text"),
|
||||
"error": r.get("error")})
|
||||
if r.get("ok") and r.get("text"):
|
||||
valid_transcripts.append({"name": r["name"], "text": r["text"], "model": r.get("model")})
|
||||
|
||||
return valid_transcripts
|
||||
|
||||
|
||||
async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None,
|
||||
filename: str | None = None) -> str:
|
||||
filename: str | None = None, trace_id: str | None = None,
|
||||
message_id: int | None = None) -> str:
|
||||
"""Return the Persian transcript for a voice note (empty string on silence)."""
|
||||
transcripts = await transcribe_multi(settings, audio, mime_type=mime_type, filename=filename)
|
||||
transcripts = await transcribe_multi(settings, audio, mime_type=mime_type,
|
||||
filename=filename, trace_id=trace_id,
|
||||
message_id=message_id)
|
||||
return transcripts[0]["text"] if transcripts else ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 را بهصورت پیشفرض
|
||||
(`:-...`) داخل خود دارد. قبل از هر پوش/جابهجایی سرور، چرخش کلیدها توصیه میشود.
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
+14
-3
@@ -255,6 +255,17 @@ def _install_fake_stores() -> None:
|
||||
|
||||
runtime.LinkStore.get, runtime.LinkStore.upsert, runtime.LinkStore.delete = link_get, link_upsert, link_delete
|
||||
|
||||
import app.observability as obs
|
||||
async def fake_obs(*args, **kwargs):
|
||||
pass
|
||||
async def fake_obs_trace(*args, **kwargs):
|
||||
return None
|
||||
obs.event = fake_obs
|
||||
obs.trace_start = fake_obs
|
||||
obs.link_draft = fake_obs
|
||||
obs.set_trace_status = fake_obs
|
||||
obs.latest_trace_for_draft = fake_obs_trace
|
||||
|
||||
|
||||
async def fake_log(self, chat_id, direction, kind, body=None):
|
||||
pass
|
||||
@@ -263,16 +274,16 @@ async def fake_log(self, chat_id, direction, kind, body=None):
|
||||
NLU_QUEUE: list[dict] = []
|
||||
|
||||
|
||||
async def fake_understand(settings, text, draft_summary=None):
|
||||
async def fake_understand(settings, text, draft_summary=None, *args, **kwargs):
|
||||
return NLU_QUEUE.pop(0) if NLU_QUEUE else {"intent": "unknown", "patch": {}, "confidence": 0}
|
||||
|
||||
|
||||
async def fake_transcribe(settings, audio, mime_type=None, filename=None):
|
||||
async def fake_transcribe(settings, audio, mime_type=None, filename=None, *args, **kwargs):
|
||||
return ("سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن "
|
||||
"پرداخت سه فاز")
|
||||
|
||||
|
||||
async def fake_transcribe_multi(settings, audio, mime_type=None, filename=None):
|
||||
async def fake_transcribe_multi(settings, audio, mime_type=None, filename=None, *args, **kwargs):
|
||||
return [{"name": "fake", "text": "سازان نو برای پروژه برج آسمان ۵۰۰ کیلو میلگرد A3 سایز ۱۴ هر کیلو ۴۵ هزار تومن پرداخت سه فاز"}]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Tests for audio preprocessing / enhancement module (audio_enhancer.py).
|
||||
|
||||
Validates:
|
||||
1. Disabled enhancer -> zero overhead pass-through of raw bytes.
|
||||
2. Successful enhancement -> receives enhanced audio, saves with _enhanced suffix,
|
||||
emits audio.enhance.result with latency_ms/path/size.
|
||||
3. Failure/timeout -> graceful fallback to raw audio bytes, ok=False event logged.
|
||||
4. Zero transcoding by default (raw bytes sent without ffmpeg).
|
||||
5. Timeout configuration adheres to 60.0s.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.audio_enhancer import enhance_voice
|
||||
from app.config import Settings
|
||||
from app import observability as obs
|
||||
|
||||
_passed = _failed = 0
|
||||
|
||||
|
||||
def check(name: str, ok: bool, detail: str = "") -> None:
|
||||
global _passed, _failed
|
||||
ok = bool(ok)
|
||||
mark = "PASS" if ok else "FAIL"
|
||||
print(f"[{mark}] {name}" + (f" — {detail}" if detail and not ok else ""))
|
||||
_passed += ok
|
||||
_failed += (not ok)
|
||||
|
||||
|
||||
async def test_disabled_enhancer():
|
||||
cfg = Settings(AUDIO_ENHANCE_ENABLED=False, AUDIO_ENHANCE_BASE_URL="http://mock/enhance")
|
||||
raw_audio = b"fake-raw-voice-bytes"
|
||||
out_audio, meta = await enhance_voice(cfg, raw_audio, mime_type="audio/ogg")
|
||||
check("disabled: raw bytes returned directly", out_audio == raw_audio)
|
||||
check("disabled: metadata indicates skipped", meta.get("skipped") is True)
|
||||
|
||||
|
||||
async def test_timeout_setting():
|
||||
cfg = Settings(AUDIO_ENHANCE_TIMEOUT_SECONDS=60.0)
|
||||
check("config: timeout defaults to 60.0s", cfg.AUDIO_ENHANCE_TIMEOUT_SECONDS == 60.0)
|
||||
|
||||
|
||||
async def test_successful_enhancement():
|
||||
cfg = Settings(
|
||||
AUDIO_ENHANCE_ENABLED=True,
|
||||
AUDIO_ENHANCE_BASE_URL="http://mock-enhancer.internal/v1/enhance",
|
||||
AUDIO_ENHANCE_MODEL="voice-cleaner-v1",
|
||||
AUDIO_ENHANCE_TIMEOUT_SECONDS=60.0,
|
||||
)
|
||||
raw_audio = b"noisy-voice-bytes"
|
||||
enhanced_audio = b"crystal-clear-voice-bytes"
|
||||
trace_id = "test-trace-enh-001"
|
||||
message_id = 12345
|
||||
|
||||
events_captured = []
|
||||
|
||||
async def fake_event(t_id, stage, ok=True, latency_ms=None, model=None, payload=None, **kwargs):
|
||||
events_captured.append({"stage": stage, "ok": ok, "latency_ms": latency_ms, "payload": payload})
|
||||
|
||||
# Mock httpx response
|
||||
mock_resp = httpx.Response(
|
||||
status_code=200,
|
||||
content=enhanced_audio,
|
||||
headers={"content-type": "audio/ogg"},
|
||||
request=httpx.Request("POST", cfg.AUDIO_ENHANCE_BASE_URL),
|
||||
)
|
||||
|
||||
with patch.object(obs, "event", side_effect=fake_event):
|
||||
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = mock_resp
|
||||
out_bytes, meta = await enhance_voice(
|
||||
cfg,
|
||||
raw_audio,
|
||||
mime_type="audio/ogg",
|
||||
filename="voice.ogg",
|
||||
trace_id=trace_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
check("success: enhanced bytes returned", out_bytes == enhanced_audio)
|
||||
check("success: meta indicates enhanced", meta.get("enhanced") is True)
|
||||
check("success: enhanced path exists in meta", bool(meta.get("path")))
|
||||
check("success: path has _enhanced suffix", "_enhanced" in (meta.get("path") or ""))
|
||||
|
||||
# Verify physical file existence
|
||||
if meta.get("path"):
|
||||
saved_file = Path(meta["path"])
|
||||
check("success: enhanced file written to disk", saved_file.exists() and saved_file.read_bytes() == enhanced_audio)
|
||||
|
||||
# Verify observability event
|
||||
enh_events = [e for e in events_captured if e["stage"] == "audio.enhance.result"]
|
||||
check("obs: audio.enhance.result event emitted", len(enh_events) == 1)
|
||||
if enh_events:
|
||||
ev = enh_events[0]
|
||||
check("obs: event ok=True", ev["ok"] is True)
|
||||
check("obs: payload contains size & latency_ms", "size" in ev["payload"] and ev.get("latency_ms") is not None)
|
||||
check("obs: payload recorded correct size", ev["payload"]["size"] == len(enhanced_audio))
|
||||
|
||||
|
||||
async def test_graceful_fallback_on_error():
|
||||
cfg = Settings(
|
||||
AUDIO_ENHANCE_ENABLED=True,
|
||||
AUDIO_ENHANCE_BASE_URL="http://mock-enhancer.internal/v1/enhance",
|
||||
AUDIO_ENHANCE_TIMEOUT_SECONDS=60.0,
|
||||
)
|
||||
raw_audio = b"original-unprocessed-audio"
|
||||
trace_id = "test-trace-enh-002"
|
||||
message_id = 999
|
||||
|
||||
events_captured = []
|
||||
|
||||
async def fake_event(t_id, stage, ok=True, latency_ms=None, model=None, payload=None, **kwargs):
|
||||
events_captured.append({"stage": stage, "ok": ok, "latency_ms": latency_ms, "payload": payload})
|
||||
|
||||
# Mock HTTP 500 error
|
||||
mock_resp = httpx.Response(
|
||||
status_code=500,
|
||||
text="Internal Model Error",
|
||||
request=httpx.Request("POST", cfg.AUDIO_ENHANCE_BASE_URL),
|
||||
)
|
||||
|
||||
with patch.object(obs, "event", side_effect=fake_event):
|
||||
with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = mock_resp
|
||||
out_bytes, meta = await enhance_voice(
|
||||
cfg,
|
||||
raw_audio,
|
||||
mime_type="audio/ogg",
|
||||
trace_id=trace_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
check("fallback: returns raw original bytes on 500", out_bytes == raw_audio)
|
||||
check("fallback: meta indicates fallback", meta.get("fallback") is True)
|
||||
|
||||
enh_events = [e for e in events_captured if e["stage"] == "audio.enhance.result"]
|
||||
check("obs: fallback event ok=False", len(enh_events) == 1 and enh_events[0]["ok"] is False)
|
||||
if enh_events:
|
||||
check("obs: error detail captured", "HTTP 500" in enh_events[0]["payload"].get("error", ""))
|
||||
|
||||
|
||||
async def test_zero_transcoding_by_default():
|
||||
cfg = Settings(
|
||||
AUDIO_ENHANCE_ENABLED=True,
|
||||
AUDIO_ENHANCE_BASE_URL="http://mock-enhancer.internal/v1/enhance",
|
||||
AUDIO_ENHANCE_TARGET_FORMAT="", # empty -> zero transcoding
|
||||
)
|
||||
raw_audio = b"raw-bale-ogg-bytes"
|
||||
captured_payload = None
|
||||
|
||||
async def fake_post(url, files=None, data=None, headers=None, **kwargs):
|
||||
nonlocal captured_payload
|
||||
captured_payload = files["file"][1]
|
||||
return httpx.Response(status_code=200, content=b"clean", request=httpx.Request("POST", url))
|
||||
|
||||
with patch("httpx.AsyncClient.post", side_effect=fake_post):
|
||||
await enhance_voice(cfg, raw_audio, mime_type="audio/ogg")
|
||||
|
||||
check("transcoding: raw bytes sent directly without modification", captured_payload == raw_audio)
|
||||
|
||||
|
||||
async def main():
|
||||
print("Running audio_enhancer unit tests...")
|
||||
await test_disabled_enhancer()
|
||||
await test_timeout_setting()
|
||||
await test_successful_enhancement()
|
||||
await test_graceful_fallback_on_error()
|
||||
await test_zero_transcoding_by_default()
|
||||
|
||||
print(f"\nResult: {_passed} passed, {_failed} failed")
|
||||
return 1 if _failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Unit and integration tests for DeepSeek crisis fallback matching.
|
||||
|
||||
Covers:
|
||||
1. Jev failure -> jev.failure observability event + DeepSeek fallback invocation.
|
||||
2. DeepSeek fallback results mapping: exact, ambiguity, new (with inferred category).
|
||||
3. Defensive failure guard: DeepSeek network/HTTP/JSON failure raises MatchingFailureError.
|
||||
4. quote.py resolve_payload: respects new, binds exact, populates ambiguity, zero difflib.
|
||||
5. quote.py commit: defensive get_or_create pattern prevents 409 duplicate errors.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app import fallback_matcher, matching
|
||||
from app.config import settings
|
||||
from app.fallback_matcher import MatchingFailureError
|
||||
from app.handlers import quote
|
||||
from app.nlu import normalize_text
|
||||
from app.resolve import Candidate
|
||||
|
||||
PASSED: list[str] = []
|
||||
FAILED: list[str] = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, extra: str = "") -> None:
|
||||
(PASSED if ok else FAILED).append(name)
|
||||
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}")
|
||||
|
||||
|
||||
class FakeResolver:
|
||||
async def projects(self):
|
||||
return [Candidate(id="p1", name="پروژه پردیس"), Candidate(id="p2", name="برج نگین")]
|
||||
|
||||
async def all_suppliers(self):
|
||||
return [Candidate(
|
||||
id="s1",
|
||||
name="فولاد خوزستان",
|
||||
extra={"location": "اهواز", "category_name": "فلزات و مقاطع فولادی", "slug": "metals-steel-sections"}
|
||||
)]
|
||||
|
||||
async def all_materials(self):
|
||||
return [
|
||||
Candidate(id="m1", name="میلگرد ۱۴ آجدار", extra={"canonical_unit_id": 1}),
|
||||
Candidate(id="m2", name="لوله پیویسی ۴ اینچ", extra={"canonical_unit_id": 3}),
|
||||
]
|
||||
|
||||
async def categories(self):
|
||||
return [
|
||||
Candidate(id="cat1", name="فلزات و مقاطع فولادی", extra={"slug": "metals-steel-sections"}),
|
||||
Candidate(id="cat2", name="تأسیسات مکانیکی و آبی", extra={"slug": "mechanical-installations-pipes"}),
|
||||
]
|
||||
|
||||
async def units(self):
|
||||
return [Candidate(id="u1", name="شاخه", extra={"code": "branch"}), Candidate(id="u2", name="کیلوگرم", extra={"code": "kg"})]
|
||||
|
||||
def invalidate(self, key=None):
|
||||
pass
|
||||
|
||||
|
||||
class FakeApi:
|
||||
def __init__(self):
|
||||
self.created_materials = []
|
||||
self.created_suppliers = []
|
||||
self.created_projects = []
|
||||
|
||||
async def list_materials(self, q=None, page_size=20, page=1):
|
||||
if q and "میلگرد ۱۴" in q:
|
||||
return [{"id": "m1", "name": "میلگرد ۱۴ آجدار", "canonical_unit_id": 1}]
|
||||
return []
|
||||
|
||||
async def create_material(self, body, actor_id=None):
|
||||
if body["name"] == "میلگرد ۱۴ آجدار":
|
||||
raise Exception("Conflict 409")
|
||||
item = {"id": f"m_new_{len(self.created_materials)+1}", **body}
|
||||
self.created_materials.append(item)
|
||||
return item
|
||||
|
||||
async def list_suppliers(self, q=None, page_size=20, page=1):
|
||||
if q and "فولاد خوزستان" in q:
|
||||
return [{"id": "s1", "name": "فولاد خوزستان"}]
|
||||
return []
|
||||
|
||||
async def create_supplier(self, body, actor_id=None):
|
||||
item = {"id": f"s_new_{len(self.created_suppliers)+1}", **body}
|
||||
self.created_suppliers.append(item)
|
||||
return item
|
||||
|
||||
async def list_projects(self, page_size=100):
|
||||
return [{"id": "p1", "name": "پروژه پردیس"}]
|
||||
|
||||
async def create_project(self, body, actor_id=None):
|
||||
item = {"id": f"p_new_{len(self.created_projects)+1}", **body}
|
||||
self.created_projects.append(item)
|
||||
return item
|
||||
|
||||
|
||||
async def test_jev_failure_triggers_fallback() -> None:
|
||||
"""1. When Jev returns None (HTTP 500/timeout), DeepSeek fallback is triggered."""
|
||||
ctx = SimpleNamespace(resolver=FakeResolver())
|
||||
events_logged = []
|
||||
|
||||
async def fake_event(trace_id, event_type, ok=True, **kwargs):
|
||||
events_logged.append((event_type, kwargs))
|
||||
|
||||
orig_event = matching.obs.event
|
||||
matching.obs.event = fake_event
|
||||
|
||||
saved_key = settings.TYPESAFE_API_KEY
|
||||
settings.TYPESAFE_API_KEY = "test-typesafe-key"
|
||||
|
||||
orig_jev_ask = matching.jev_ask
|
||||
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result=None)
|
||||
|
||||
# Mock DeepSeek fallback matcher
|
||||
async def fake_fallback(*args, **kwargs):
|
||||
return {
|
||||
normalize_text("میلگرد ۱۴"): {
|
||||
"field": "material",
|
||||
"status": "exact",
|
||||
"id": "m1",
|
||||
"name": "میلگرد ۱۴ آجدار",
|
||||
"variants": ["میلگرد ۱۴"],
|
||||
"candidates": [],
|
||||
},
|
||||
normalize_text("لوله ۶ اینچ"): {
|
||||
"field": "material",
|
||||
"status": "new",
|
||||
"id": None,
|
||||
"name": None,
|
||||
"variants": ["لوله ۶ اینچ"],
|
||||
"candidates": [],
|
||||
},
|
||||
normalize_text("آهن"): {
|
||||
"field": "material",
|
||||
"status": "ambiguity",
|
||||
"id": None,
|
||||
"name": None,
|
||||
"variants": ["آهن"],
|
||||
"candidates": [{"id": "m1", "name": "میلگرد ۱۴ آجدار", "prob": 0.8}],
|
||||
},
|
||||
normalize_text("تأمینکننده البرز"): {
|
||||
"field": "supplier",
|
||||
"status": "new",
|
||||
"id": None,
|
||||
"name": None,
|
||||
"variants": ["تأمینکننده البرز"],
|
||||
"candidates": [],
|
||||
"inferred_category_slug": "metals-steel-sections",
|
||||
},
|
||||
}
|
||||
|
||||
orig_deepseek = matching.match_with_deepseek
|
||||
matching.match_with_deepseek = fake_fallback
|
||||
|
||||
try:
|
||||
mentions = [
|
||||
{"field": "material", "variants": ["میلگرد ۱۴"]},
|
||||
{"field": "material", "variants": ["لوله ۶ اینچ"]},
|
||||
{"field": "material", "variants": ["آهن"]},
|
||||
{"field": "supplier", "variants": ["تأمینکننده البرز"]},
|
||||
]
|
||||
res = await matching.resolve_mentions(ctx, {"items": []}, mentions, trace_id="t1", raw_input="تست")
|
||||
|
||||
check("Jev failure recorded in observability", any(e[0] == "jev.failure" for e in events_logged))
|
||||
check("Exact match resolved via fallback", res[normalize_text("میلگرد ۱۴")]["status"] == "exact")
|
||||
check("New item strictly preserved as new (no difflib forced match)",
|
||||
res[normalize_text("لوله ۶ اینچ")]["status"] == "new" and res[normalize_text("لوله ۶ اینچ")]["id"] is None)
|
||||
check("Ambiguity item has candidates",
|
||||
res[normalize_text("آهن")]["status"] == "ambiguity" and len(res[normalize_text("آهن")]["candidates"]) == 1)
|
||||
check("New supplier has inferred_category_slug",
|
||||
res[normalize_text("تأمینکننده البرز")]["inferred_category_slug"] == "metals-steel-sections")
|
||||
|
||||
finally:
|
||||
settings.TYPESAFE_API_KEY = saved_key
|
||||
matching.obs.event = orig_event
|
||||
matching.jev_ask = orig_jev_ask
|
||||
matching.match_with_deepseek = orig_deepseek
|
||||
|
||||
|
||||
async def test_two_stage_adjudication_flow() -> None:
|
||||
"""2. When Jev succeeds, outputs are NOT directly committed; they are sent to DeepSeek Arbiter."""
|
||||
ctx = SimpleNamespace(resolver=FakeResolver())
|
||||
saved_key = settings.TYPESAFE_API_KEY
|
||||
settings.TYPESAFE_API_KEY = "test-typesafe-key"
|
||||
|
||||
orig_jev_ask = matching.jev_ask
|
||||
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result={
|
||||
"answers": {
|
||||
"mention_0": {
|
||||
"choice": "میلگرد ۱۴ آجدار",
|
||||
"confidence": 0.95,
|
||||
"probabilities": {"میلگرد ۱۴ آجدار": 0.95, "تسمه فلزی": 0.0, "_none": 0.05},
|
||||
},
|
||||
"mention_1": {
|
||||
"choice": "لوله پیویسی ۴ اینچ",
|
||||
"confidence": 0.82,
|
||||
"probabilities": {"لوله پیویسی ۴ اینچ": 0.82, "لوله پلیاتیلن": 0.0, "_none": 0.18},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
arbiter_called_with = []
|
||||
|
||||
async def fake_arbiter(*args, **kwargs):
|
||||
arbiter_called_with.append((args, kwargs))
|
||||
items = kwargs.get("items") or (args[2] if len(args) > 2 else [])
|
||||
res = {}
|
||||
for it in items:
|
||||
v0 = it["variants"][0]
|
||||
if v0 == "میلگرد ۱۴":
|
||||
# Endorsed
|
||||
res[normalize_text(v0)] = {
|
||||
"field": it["field"], "status": "exact", "id": "m1", "name": "میلگرد ۱۴ آجدار",
|
||||
"variants": it["variants"], "source": "deepseek-arbiter", "adjudication_action": "endorsed_jev",
|
||||
}
|
||||
elif v0 == "لوله ۶ اینچ":
|
||||
# Vetoed Jev (Jev wrongly picked 4-inch pipe)
|
||||
res[normalize_text(v0)] = {
|
||||
"field": it["field"], "status": "new", "id": None, "name": None,
|
||||
"variants": it["variants"], "source": "deepseek-arbiter", "adjudication_action": "vetoed_to_new",
|
||||
}
|
||||
return res
|
||||
|
||||
orig_arbiter = matching.adjudicate_with_deepseek
|
||||
matching.adjudicate_with_deepseek = fake_arbiter
|
||||
|
||||
try:
|
||||
mentions = [
|
||||
{"field": "material", "variants": ["میلگرد ۱۴"]},
|
||||
{"field": "material", "variants": ["لوله ۶ اینچ"]},
|
||||
]
|
||||
res = await matching.resolve_mentions(
|
||||
ctx, {"items": []}, mentions, trace_id="t_arb",
|
||||
raw_input="استعلام میلگرد ۱۴ و لوله ۶ اینچ",
|
||||
)
|
||||
|
||||
check("Two-stage arbitration called when Jev succeeds", len(arbiter_called_with) == 1)
|
||||
passed_items = arbiter_called_with[0][1].get("items") or arbiter_called_with[0][0][2]
|
||||
m0_probs = passed_items[0]["jev_probabilities"]
|
||||
check("Zero probability items filtered out", "تسمه فلزی" not in m0_probs)
|
||||
check("Non-zero probability preserved exactly", m0_probs.get("میلگرد ۱۴ آجدار") == 0.95)
|
||||
check("_none with non-zero probability preserved", m0_probs.get("_none") == 0.05)
|
||||
check("Catalog candidates list complete and untouched", len(passed_items[0]["candidates"]) > 0)
|
||||
check("Arbiter endorsed valid Jev choice as exact", res[normalize_text("میلگرد ۱۴")]["status"] == "exact")
|
||||
check("Arbiter vetoed wrong Jev choice to new", res[normalize_text("لوله ۶ اینچ")]["status"] == "new" and res[normalize_text("لوله ۶ اینچ")]["id"] is None)
|
||||
finally:
|
||||
settings.TYPESAFE_API_KEY = saved_key
|
||||
matching.jev_ask = orig_jev_ask
|
||||
matching.adjudicate_with_deepseek = orig_arbiter
|
||||
|
||||
|
||||
async def test_defensive_failure_guard() -> None:
|
||||
"""3. Universal Fail-Stop Guard: If DeepSeek (fallback or arbiter or NLU) fails, MatchingFailureError is raised."""
|
||||
ctx = SimpleNamespace(resolver=FakeResolver())
|
||||
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result=None)
|
||||
|
||||
async def boom_fallback(*args, **kwargs):
|
||||
raise MatchingFailureError("DeepSeek API 500 error")
|
||||
|
||||
orig_deepseek = matching.match_with_deepseek
|
||||
matching.match_with_deepseek = boom_fallback
|
||||
|
||||
try:
|
||||
mentions = [{"field": "material", "variants": ["سیمان"]}]
|
||||
failed = False
|
||||
try:
|
||||
await matching.resolve_mentions(ctx, {"items": []}, mentions, trace_id="t2")
|
||||
except MatchingFailureError:
|
||||
failed = True
|
||||
check("Crisis fallback failure raises MatchingFailureError (stops cleanly)", failed)
|
||||
finally:
|
||||
matching.match_with_deepseek = orig_deepseek
|
||||
|
||||
# Also test Arbiter failure raises MatchingFailureError
|
||||
matching.jev_ask = lambda state, qs: asyncio.sleep(0.001, result={"answers": {"mention_0": {"choice": "m1", "confidence": 0.9}}})
|
||||
async def boom_arbiter(*args, **kwargs):
|
||||
raise MatchingFailureError("Arbiter DeepSeek timeout")
|
||||
|
||||
orig_arbiter = matching.adjudicate_with_deepseek
|
||||
matching.adjudicate_with_deepseek = boom_arbiter
|
||||
try:
|
||||
failed_arb = False
|
||||
try:
|
||||
await matching.resolve_mentions(ctx, {"items": []}, mentions, trace_id="t2_arb")
|
||||
except MatchingFailureError:
|
||||
failed_arb = True
|
||||
check("Arbiter failure raises MatchingFailureError (stops cleanly)", failed_arb)
|
||||
finally:
|
||||
matching.adjudicate_with_deepseek = orig_arbiter
|
||||
|
||||
# Test NLUFailureError is subclass of Exception
|
||||
from app.nlu import NLUFailureError
|
||||
check("NLUFailureError is subclass of Exception", issubclass(NLUFailureError, Exception))
|
||||
|
||||
|
||||
async def test_quote_resolve_payload_zero_difflib() -> None:
|
||||
"""3. resolve_payload in quote.py respects Jev/fallback map and never runs difflib."""
|
||||
ctx = SimpleNamespace(resolver=FakeResolver())
|
||||
payload = {
|
||||
"supplier_name": "تأمینکننده البرز",
|
||||
"items": [
|
||||
{"material_name": "لوله ۶ اینچ", "unit_name": "شاخه"},
|
||||
{"material_name": "میلگرد ۱۴", "unit_name": "شاخه"},
|
||||
],
|
||||
}
|
||||
jev_map = {
|
||||
normalize_text("تأمینکننده البرز"): {
|
||||
"field": "supplier", "status": "new", "variants": ["تأمینکننده البرز"],
|
||||
"inferred_category_slug": "metals-steel-sections",
|
||||
},
|
||||
normalize_text("لوله ۶ اینچ"): {
|
||||
"field": "material", "status": "new", "variants": ["لوله ۶ اینچ"],
|
||||
},
|
||||
normalize_text("میلگرد ۱۴"): {
|
||||
"field": "material", "status": "exact", "id": "m1", "name": "میلگرد ۱۴ آجدار",
|
||||
"variants": ["میلگرد ۱۴"],
|
||||
},
|
||||
normalize_text("شاخه"): {
|
||||
"field": "unit", "status": "exact", "id": "u1", "name": "شاخه", "variants": ["شاخه"],
|
||||
},
|
||||
}
|
||||
|
||||
await quote.resolve_payload(ctx, payload, trace_id="t3", jev_map=jev_map)
|
||||
|
||||
check("New supplier kept id=None (no difflib forced match)", payload.get("supplier_id") is None)
|
||||
check("New supplier inferred category saved", payload.get("supplier_inferred_category_slug") == "metals-steel-sections")
|
||||
check("New material kept id=None (no difflib forced match to 4-inch pipe)", payload["items"][0].get("material_id") is None)
|
||||
check("Exact material bound to catalog ID", payload["items"][1].get("material_id") == "m1")
|
||||
check("Exact unit bound to catalog ID", payload["items"][0].get("unit_id") == "u1")
|
||||
|
||||
|
||||
async def test_quote_commit_defensive_get_or_create() -> None:
|
||||
"""4. commit in quote.py uses get_or_create to prevent 409 conflict errors."""
|
||||
api = FakeApi()
|
||||
resolver = FakeResolver()
|
||||
ctx = SimpleNamespace(api=api, resolver=resolver, pending={},
|
||||
send=lambda *a, **k: asyncio.sleep(0.0001))
|
||||
user = SimpleNamespace(actor_id="usr_1")
|
||||
|
||||
# Mock obs calls so offline test doesn't require live DB connection
|
||||
orig_latest = quote.obs.latest_trace_for_draft
|
||||
orig_link = quote.obs.link_draft
|
||||
orig_event = quote.obs.event
|
||||
quote.obs.latest_trace_for_draft = lambda did: asyncio.sleep(0.0001, result="mock_trace")
|
||||
quote.obs.link_draft = lambda *a, **k: asyncio.sleep(0.0001)
|
||||
quote.obs.event = lambda *a, **k: asyncio.sleep(0.0001)
|
||||
|
||||
# Item with name "میلگرد ۱۴ آجدار" that already exists in DB
|
||||
payload = {
|
||||
"supplier_name": "فولاد خوزستان", # Already in DB
|
||||
"supplier_inferred_category_slug": "metals-steel-sections",
|
||||
"items": [
|
||||
{"material_name": "میلگرد ۱۴ آجدار", "unit_id": 1, "quantity": 10, "unit_price": 25000},
|
||||
{"material_name": "تیرآهن ۱۸", "unit_id": 1, "quantity": 5, "unit_price": 50000}, # Brand new
|
||||
],
|
||||
"project_name": "پروژه پردیس", # Already in DB
|
||||
}
|
||||
draft = SimpleNamespace(id="d1", payload=payload, review_message_id=None)
|
||||
|
||||
# Mock create_quote
|
||||
api.create_quote = lambda body, actor_id: asyncio.sleep(0.001, result={"id": "q1"})
|
||||
# Mock draft_store.set_status
|
||||
quote.draft_store.set_status = lambda did, st: asyncio.sleep(0.001)
|
||||
|
||||
try:
|
||||
await quote.commit(ctx, chat_id=123, user=user, draft=draft, only_resolved=False)
|
||||
|
||||
check("Existing supplier reused ID from DB (not recreated)", payload["supplier_id"] == "s1")
|
||||
check("Existing material reused ID from DB on conflict (not crashing)", payload["items"][0]["material_id"] == "m1")
|
||||
check("New material created cleanly", payload["items"][1]["material_id"] == "m_new_1")
|
||||
check("Existing project reused ID from DB", payload["project_id"] == "p1")
|
||||
finally:
|
||||
quote.obs.latest_trace_for_draft = orig_latest
|
||||
quote.obs.link_draft = orig_link
|
||||
quote.obs.event = orig_event
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
print("Running DeepSeek Crisis Fallback & Zero-Difflib Tests...\n")
|
||||
orig_fb_event = fallback_matcher.obs.event
|
||||
fallback_matcher.obs.event = lambda *a, **k: asyncio.sleep(0.0001)
|
||||
try:
|
||||
await test_jev_failure_triggers_fallback()
|
||||
await test_two_stage_adjudication_flow()
|
||||
await test_defensive_failure_guard()
|
||||
await test_quote_resolve_payload_zero_difflib()
|
||||
await test_quote_commit_defensive_get_or_create()
|
||||
finally:
|
||||
fallback_matcher.obs.event = orig_fb_event
|
||||
|
||||
total = len(PASSED) + len(FAILED)
|
||||
print(f"\n{len(PASSED)}/{total} passed")
|
||||
if FAILED:
|
||||
print("FAILED:", FAILED)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Phase 3-5 tests for entity matching (mock by default; live with RUN_LIVE=1).
|
||||
|
||||
docker compose exec bot python tools/test_matching.py # mock only
|
||||
docker compose exec -e RUN_LIVE=1 bot python tools/test_matching.py # + real NLU + Jev
|
||||
|
||||
The live section calls DeepSeek (NLU) and TypeSafe (Jev) with the REAL keys
|
||||
from bot/.env — it never sends anything to Bale and never creates drafts.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app import matching
|
||||
from app.config import settings
|
||||
from app.nlu import normalize_text
|
||||
from app.resolve import Candidate
|
||||
|
||||
PASSED: list[str] = []
|
||||
FAILED: list[str] = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, extra: str = "") -> None:
|
||||
(PASSED if ok else FAILED).append(name)
|
||||
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + str(extra) if extra else ''}")
|
||||
|
||||
|
||||
class FakeResolver:
|
||||
async def projects(self):
|
||||
return [Candidate(id="p1", name="دفتر"), Candidate(id="p2", name="منشانه")]
|
||||
|
||||
async def all_suppliers(self):
|
||||
return [Candidate(id="s1", name="بازرگانی مصطفوی",
|
||||
extra={"location": "اصفهان", "category_name": "مصالح"})]
|
||||
|
||||
async def all_materials(self):
|
||||
return [Candidate(id="m1", name="گچ و خاک", extra={"canonical_unit_id": 2})]
|
||||
|
||||
async def brands(self):
|
||||
return [Candidate(id="b1", name="سیمان تهران")]
|
||||
|
||||
async def specs(self):
|
||||
return [Candidate(id="سایز ۱۴", name="سایز ۱۴")]
|
||||
|
||||
async def units(self):
|
||||
return [Candidate(id="u1", name="کیسه"), Candidate(id="u2", name="عدد")]
|
||||
|
||||
|
||||
def make_fake_ask(answers_by_mention: dict, model: str = "jev-1.13.0"):
|
||||
async def fake_ask(state, questions):
|
||||
if fake_ask.fail:
|
||||
return None
|
||||
return {"answers": answers_by_mention, "usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
"model": model, "latency_ms": 7}
|
||||
fake_ask.fail = False
|
||||
return fake_ask
|
||||
|
||||
|
||||
async def mock_tests() -> None:
|
||||
ctx = SimpleNamespace(resolver=FakeResolver())
|
||||
orig_ask = matching.jev_ask
|
||||
|
||||
# 1) routing bands: exact / ambiguity / low-trust / new + variant grouping
|
||||
matching.jev_ask = make_fake_ask({
|
||||
"mention_0": {"choice": "منشانه", "confidence": 0.9,
|
||||
"probabilities": {"منشانه": 0.95, "_none": 0.05}},
|
||||
"mention_1": {"choice": "دفتر", "confidence": 0.7,
|
||||
"probabilities": {"دفتر": 0.73, "منشانه": 0.15, "_none": 0.12}},
|
||||
"mention_2": {"choice": "_none", "confidence": 0.9,
|
||||
"probabilities": {"_none": 0.94}},
|
||||
"mention_3": {"choice": "دفتر", "confidence": 0.5,
|
||||
"probabilities": {"دفتر": 0.52, "_none": 0.48}},
|
||||
})
|
||||
orig_fallback = matching.match_with_deepseek
|
||||
async def mock_fallback_below_floor(*args, **kwargs):
|
||||
ms = kwargs.get("mentions") or (args[1] if len(args) > 1 else [])
|
||||
return {normalize_text(v): {"status": "new", "field": m.get("field"), "variants": m.get("variants"), "id": None}
|
||||
for m in ms for v in m.get("variants", [])}
|
||||
matching.match_with_deepseek = mock_fallback_below_floor
|
||||
|
||||
orig_arbiter = matching.adjudicate_with_deepseek
|
||||
async def mock_arbiter(*args, **kwargs):
|
||||
items = kwargs.get("items") or (args[2] if len(args) > 2 else [])
|
||||
res = {}
|
||||
for it in items:
|
||||
choice = it.get("jev_choice")
|
||||
conf = it.get("jev_confidence", 0.0)
|
||||
if choice == "منشانه" and conf >= 0.8:
|
||||
entry = {"status": "exact", "id": "p2", "name": "منشانه", "field": it["field"], "variants": it["variants"]}
|
||||
elif choice == "دفتر" and conf >= 0.6:
|
||||
entry = {"status": "ambiguity", "id": "p1", "name": "دفتر", "field": it["field"], "variants": it["variants"],
|
||||
"candidates": [{"id": "p1", "name": "دفتر", "prob": conf}]}
|
||||
else:
|
||||
entry = {"status": "new", "id": None, "name": None, "field": it["field"], "variants": it["variants"], "candidates": []}
|
||||
for v in it["variants"]:
|
||||
res[normalize_text(v)] = entry
|
||||
return res
|
||||
matching.adjudicate_with_deepseek = mock_arbiter
|
||||
|
||||
mentions = [
|
||||
{"field": "project", "variants": ["منشیانه"]},
|
||||
{"field": "project", "variants": ["دفتر", "دفر"]},
|
||||
{"field": "project", "variants": ["چیز عجیب"]},
|
||||
{"field": "project", "variants": ["دفر دو"]},
|
||||
]
|
||||
try:
|
||||
res = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
|
||||
finally:
|
||||
matching.match_with_deepseek = orig_fallback
|
||||
matching.adjudicate_with_deepseek = orig_arbiter
|
||||
|
||||
r0 = matching.lookup(res, "منشیانه")
|
||||
r1 = matching.lookup(res, "دفر") # variant key resolves too
|
||||
r2 = matching.lookup(res, "چیز عجیب")
|
||||
check("exact band -> status exact + id", r0 and r0["status"] == "exact" and r0["id"] == "p2")
|
||||
check("mid band -> ambiguity + candidates", r1 and r1["status"] == "ambiguity"
|
||||
and r1["candidates"][0]["name"] == "دفتر")
|
||||
check("variant of same mention shares one result", r1 is r0 if False else
|
||||
(r1 and matching.lookup(res, "دفتر") is r1))
|
||||
check("_none / low conf -> new", r2 and r2["status"] == "new")
|
||||
check("below-floor pick -> routed to crisis fallback",
|
||||
matching.lookup(res, "دفر دو") and matching.lookup(res, "دفر دو")["status"] == "new")
|
||||
check("below-floor pick did not pollute other mentions", r1 and r1["status"] == "ambiguity")
|
||||
|
||||
# 2) Jev failure -> DeepSeek crisis fallback
|
||||
matching.jev_ask = make_fake_ask({})
|
||||
matching.jev_ask.fail = True
|
||||
async def mock_deepseek(*args, **kwargs):
|
||||
return {normalize_text("منشیانه"): {"status": "exact", "id": "p2", "name": "منشانه",
|
||||
"field": "project", "variants": ["منشیانه"]}}
|
||||
matching.match_with_deepseek = mock_deepseek
|
||||
try:
|
||||
res_fail = await matching.resolve_mentions(ctx, {"items": []}, mentions, None)
|
||||
check("jev failure -> triggers crisis fallback",
|
||||
normalize_text("منشیانه") in res_fail and res_fail[normalize_text("منشیانه")]["status"] == "exact")
|
||||
finally:
|
||||
matching.match_with_deepseek = orig_fallback
|
||||
|
||||
# 3) apply_exact writes supplier/project ids into a payload
|
||||
payload = {"items": []}
|
||||
matching.apply_exact(payload, {"field": "project", "id": "p2", "name": "منشانه",
|
||||
"variants": ["منشیانه"], "scores": {}})
|
||||
matching.apply_exact(payload, {"field": "supplier", "id": "s1", "name": "بازرگانی مصطفوی",
|
||||
"extra": {"location": "اصفهان"}, "variants": ["مستفدی"]})
|
||||
check("apply_exact -> project_id/resolved_projects/project_ids",
|
||||
payload.get("project_id") == "p2"
|
||||
and payload["resolved_projects"].get("منشیانه") == "p2")
|
||||
check("apply_exact -> supplier_id/name/extra",
|
||||
payload.get("supplier_id") == "s1" and payload.get("supplier_location") == "اصفهان")
|
||||
|
||||
# 4) material/unit/brand/spec application on items
|
||||
payload2 = {"items": [{"material_name": "مصطفی", "unit_name": "کیسه",
|
||||
"brand_name": "سیمان تهران", "specs": ["سایز 14"]}]}
|
||||
matching.apply_choice(payload2, "material", {"id": "m1", "name": "گچ و خاک",
|
||||
"extra": {"canonical_unit_id": 2}},
|
||||
["مصطفی"])
|
||||
matching.apply_choice(payload2, "unit", {"id": "u1", "name": "کیسه"}, ["کیسه"])
|
||||
matching.normalize_brand_spec(payload2, {normalize_text("سایز 14"): {
|
||||
"field": "spec", "status": "exact", "name": "سایز ۱۴", "id": "سایز ۱۴",
|
||||
"variants": ["سایز 14"]}})
|
||||
it = payload2["items"][0]
|
||||
check("material/unit applied on matching items",
|
||||
it["material_id"] == "m1" and it["unit_id"] == "u1" and it["unit_explicit"] is True)
|
||||
check("spec normalization (spelling only)", it["specs"][0] == "سایز ۱۴")
|
||||
|
||||
# 5) create_ambiguities builds an entity_match entry with percentages
|
||||
payload3 = {"items": [], "ambiguities": []}
|
||||
jev_map = {normalize_text("منشیانه"): {
|
||||
"field": "project", "status": "ambiguity", "variants": ["منشیانه"],
|
||||
"candidates": [{"id": "p2", "name": "منشانه", "prob": 0.8},
|
||||
{"id": "p1", "name": "دفتر", "prob": 0.02}]}}
|
||||
matching.create_ambiguities(payload3, jev_map)
|
||||
ambs = payload3["ambiguities"]
|
||||
check("entity_match ambiguity created with candidates + details",
|
||||
len(ambs) == 1 and ambs[0]["kind"] == "entity_match"
|
||||
and len(ambs[0]["candidates"]) == 2 and "۸۰٪" in ambs[0]["details"])
|
||||
matching.create_ambiguities(payload3, jev_map) # idempotent
|
||||
check("create_ambiguities is deduped", len(payload3["ambiguities"]) == 1)
|
||||
|
||||
matching.jev_ask = orig_ask
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# live section (real NLU + real Jev) — opt-in and Bale-free
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HARNESS_TRANSCRIPT = (
|
||||
"مدل 1 (Whisper): الان شبت بخیر می خواستیم که بیست و پنج تا کیسه گچ و خاک از مصطفوی بخریم "
|
||||
"و کنارش هم یه پنجاه تا کیسه سیمان پنجاه کیلویی. خریدمون هم عمدتا برای منشیانه و دفتر. "
|
||||
"پیش فاکتورم گفته تا حدودا بیست روزی اوکیه.\n"
|
||||
"مدل 2 (Vosk): میخواستیم 25 تا کیسه گچ و خاک از مستفدی بخریم و کنارشم یه 50 تا کیسه سیمان "
|
||||
"50 کیلویی، خریدمون هم برای منشیانه و دفتر."
|
||||
)
|
||||
|
||||
|
||||
async def live_tests() -> None:
|
||||
from app.api.client import ErpClient
|
||||
from app.resolve import Resolver
|
||||
from app.nlu import understand
|
||||
|
||||
api = ErpClient(settings.API_BASE_URL, settings.API_TIMEOUT_SECONDS, settings.SERVICE_TOKEN)
|
||||
ctx = SimpleNamespace(resolver=Resolver(api))
|
||||
|
||||
print("\n--- LIVE: NLU entity_mentions ---")
|
||||
result = await understand(settings, HARNESS_TRANSCRIPT)
|
||||
mentions = result.get("entity_mentions") or []
|
||||
print("intent:", result.get("intent"), "| mentions:", mentions)
|
||||
check("live NLU produced entity_mentions", len(mentions) >= 2, f"{len(mentions)} mentions")
|
||||
sup = next((m for m in mentions if m["field"] == "supplier"), None)
|
||||
check("voice spellings grouped into ONE supplier mention",
|
||||
bool(sup) and len(sup["variants"]) >= 2, str(sup))
|
||||
|
||||
print("\n--- LIVE: Jev per-field matching (real data) ---")
|
||||
jev_map = await matching.resolve_mentions(ctx, result.get("patch") or {}, mentions, None)
|
||||
for key, r in sorted(jev_map.items()):
|
||||
print(f" {key!r}: {r['status']} -> {r.get('name')!r} conf={r.get('confidence')}")
|
||||
check("live matching returned results", len(jev_map) > 0, f"{len(jev_map)} variants")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
await mock_tests()
|
||||
if os.environ.get("RUN_LIVE") == "1":
|
||||
if not settings.TYPESAFE_API_KEY:
|
||||
check("live section requires TYPESAFE_API_KEY", False, "bot/.env not mounted?")
|
||||
else:
|
||||
await live_tests()
|
||||
else:
|
||||
print("\n(live section skipped — run with RUN_LIVE=1 to hit real NLU + Jev)")
|
||||
|
||||
total = len(PASSED) + len(FAILED)
|
||||
print(f"\n{len(PASSED)}/{total} passed")
|
||||
if FAILED:
|
||||
print("FAILED:", FAILED)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -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())
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user