feat(monitoring): multi-model dashboard v2.1 — tabs, per-model records, voice playback + MinIO dual-write

Problem: the dashboard was Jev-centric; NLU/arbiter/fallback DeepSeek, STT
providers and the audio-enhancer were barely visible, and stored texts/voices
had no real access path.

Bot (additive; reply path untouched):
- nlu.result now records latency_ms + the DeepSeek usage tokens
- matching.arbitration/fallback.result fill the model + latency_ms columns
  (payload unchanged; SQL coalesces column/payload so old rows still count)
- storage.py: MinIO dual-write layer — every voice (raw + denoised) and user
  attachment is mirrored fire-and-forget into bucket bot-media; the blocking
  minio SDK runs via asyncio.to_thread (senior-engineer review) so the event
  loop is never stalled; empty MINIO_* env = layer off; bucket auto-created
- requirements: minio>=7.2

Backend (admin-gated, read-only):
- GET /bot-metrics/models — one record per model (jev, deepseek-nlu,
  deepseek-arbiter, deepseek-fallback, audio-enhancer + dynamic per-provider
  STT) with calls/ok-rate/p50/p95/tokens/last-seen + version-drift vs the pin
- GET /bot-metrics/models/{id}/daily — per-model daily calls/fails/tokens/p50
- GET /bot-metrics/stages — every stage tagged with its Persian pipeline
  section (صوت/درک پیام/تطبیق/ابهام و انتخاب/پیش‌نویس)
- /traces now carries input_kind + a 120-char text excerpt (consensus →
  input.text → first STT transcript)
- media serving: POST /media-url mints a short-lived HMAC URL (audio/a tags
  cannot send Authorization); GET /media streams from MinIO bot-media with
  the read-only ./bot/data mount as fallback for pre-MinIO files; hard ref
  validation (voices|attachments only, no .., extension whitelist)
- router exempted from the global Bearer dependency (files.py pattern) —
  every endpoint keeps its own require_role('admin')

Frontend /bot-monitor (RTL, recharts):
- four tabs: نمای کلی / مدل‌ها (records table + per-model expandable daily
  charts: calls/fails, tokens, p50) / بخش‌ها (sections grouped) / پیام‌ها
- message drawer rebuilt: inline <audio controls> for raw + denoised voice,
  download links, every STT transcript + consensus side-by-side, user
  attachments, then the event timeline
- terminology: «ترس» → «پرونده‌ی پیام» with tooltips + a one-line legend

Grafana: bot-observability gains a «کارکرد مدل‌ها» table (stage→model
mapping for legacy rows); the explorer gains a message text column.

Seed: real (tiny) ogg files written + mirrored to MinIO, NLU usage tokens,
column-level model/latency on matching events.

Verified locally: /models returns all 7 records incl. per-STT and NLU tokens;
media 200 via signed URL (MinIO) and 401 without; ../ and .py rejected (400);
disk fallback serves a legacy file; pm-role 403 on every new endpoint;
bucket auto-created with 14 mirrored objects; tsc + vite build clean;
Grafana re-provisions 24 panels.
This commit is contained in:
2026-10-25 22:02:40 +03:30
parent 0adadd2cf8
commit cb621ca5d0
18 changed files with 2028 additions and 543 deletions
+3
View File
@@ -64,6 +64,9 @@ GRAFANA_ROOT_URL=http://localhost:3001/
# (empty = role skipped; datasource then can't connect). # (empty = role skipped; datasource then can't connect).
PROCUREMENT_BOT_OBS_READER_PASSWORD= PROCUREMENT_BOT_OBS_READER_PASSWORD=
OBS_READER_USER=obs_reader OBS_READER_USER=obs_reader
# Bot media second copy in MinIO (bucket is auto-created; empty endpoint = off)
BOT_MINIO_ENDPOINT=minio:9000
BOT_MINIO_BUCKET=bot-media
# OpenPanel (product analytics) — client id/secret from the OpenPanel project # OpenPanel (product analytics) — client id/secret from the OpenPanel project
OPENPANEL_API_URL=http://openpanel-api:3333 OPENPANEL_API_URL=http://openpanel-api:3333
OPENPANEL_CLIENT_ID= OPENPANEL_CLIENT_ID=
+3
View File
@@ -39,6 +39,9 @@ class Settings(BaseSettings):
MINIO_SECRET_KEY: str = "minioadmin" MINIO_SECRET_KEY: str = "minioadmin"
MINIO_BUCKET: str = "procurement-files" MINIO_BUCKET: str = "procurement-files"
MINIO_SECURE: bool = False MINIO_SECURE: bool = False
# Second bucket the BOT dual-writes its media into (voices/attachments);
# the admin bot-media endpoint streams from here first, disk as fallback.
BOT_MEDIA_BUCKET: str = "bot-media"
# Security token for standalone wizard review URL (must match bot) # Security token for standalone wizard review URL (must match bot)
DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7" DRAFT_TOKEN_SECRET: str = "procurement-draft-secret-change-in-prod-v7"
+6
View File
@@ -236,6 +236,10 @@ from app.routers.auth import router as _auth_router # noqa: E402
# routes accept a short-lived signed ?t= token as well as a Bearer session. # routes accept a short-lived signed ?t= token as well as a Bearer session.
from app.routers.files import router as _files_router # noqa: E402 from app.routers.files import router as _files_router # noqa: E402
from app.routers.drafts import router as _drafts_router # noqa: E402 from app.routers.drafts import router as _drafts_router # noqa: E402
# bot-metrics: every endpoint carries its own require_role("admin"); the media
# GET additionally accepts the short-lived signed ?t= token (same reason as
# files) so <audio>/<a> tags can load it without an Authorization header.
from app.routers.bot_metrics import router as _bot_metrics_router # noqa: E402
for router in ROUTERS: for router in ROUTERS:
if router is _auth_router: if router is _auth_router:
@@ -244,6 +248,8 @@ for router in ROUTERS:
app.include_router(router, prefix=API_V1_PREFIX) app.include_router(router, prefix=API_V1_PREFIX)
elif router is _drafts_router: elif router is _drafts_router:
app.include_router(router, prefix=API_V1_PREFIX) app.include_router(router, prefix=API_V1_PREFIX)
elif router is _bot_metrics_router:
app.include_router(router, prefix=API_V1_PREFIX)
else: else:
app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)]) app.include_router(router, prefix=API_V1_PREFIX, dependencies=[Depends(get_current_user)])
+353 -4
View File
@@ -4,13 +4,28 @@ Reads the bot's own tables (bot_traces / bot_trace_events — owned and written
by the Bale bot container) with plain SELECTs only. Admin-gated. Calibration, by the Bale bot container) with plain SELECTs only. Admin-gated. Calibration,
demo and test traffic (source != 'bale' / chat_id = 0) is excluded from the demo and test traffic (source != 'bale' / chat_id = 0) is excluded from the
aggregates; the traces endpoint can show everything with ?include_synthetic=1. aggregates; the traces endpoint can show everything with ?include_synthetic=1.
v2.1: per-MODEL and per-SECTION records (/models, /stages), message text
excerpts on /traces, and media serving (voice/attachment playback+download)
streamed from the bot's MinIO bucket with an on-disk fallback, via a
short-lived signed URL — <audio>/<a> tags cannot send Authorization headers.
""" """
from fastapi import APIRouter, Depends, Query import hashlib
import hmac
import os
import re
import time
from pathlib import Path
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import FileResponse
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import get_optional_user, require_role
from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.utils.errors import NotFoundError, UnauthorizedError, ValidationError
router = APIRouter(prefix="/bot-metrics", tags=["bot-metrics"]) router = APIRouter(prefix="/bot-metrics", tags=["bot-metrics"])
@@ -230,6 +245,191 @@ async def stt_health(
return {"data": {"providers": providers, "enhance": enhance[0] if enhance else None}} return {"data": {"providers": providers, "enhance": enhance[0] if enhance else None}}
# ---------------------------------------------------------------------------
# per-MODEL records (v2.1) — one row per AI model / engine in the pipeline
# ---------------------------------------------------------------------------
# result stage(s) per model; latency lives in the latency_ms column with the
# payload copy as fallback for pre-v2.1 rows; tokens in payload->'usage'.
MODEL_DEFS: list[dict] = [
{"id": "jev", "name": "Jev (تطبیق موجودیت)", "kind": "matching",
"stages": ["jev.request", "jev.result", "jev.failure"], "result_stage": "jev.result",
"model_stages": ["jev.result"], "pinned": "jev-1.13.0"}, # the bot's pinned version (calibrated)
{"id": "deepseek-nlu", "name": "DeepSeek — درک پیام (NLU)", "kind": "llm",
"stages": ["nlu.request", "nlu.result"], "result_stage": "nlu.result",
"model_stages": ["nlu.result"], "pinned": None},
{"id": "deepseek-arbiter", "name": "DeepSeek — داور تطبیق", "kind": "llm",
"stages": ["matching.arbitration.result", "matching.arbitration.failure"], "result_stage": "matching.arbitration.result",
"model_stages": [], "pinned": None},
{"id": "deepseek-fallback", "name": "DeepSeek — فالبک بحرانی", "kind": "llm",
"stages": ["matching.fallback.result", "matching.fallback.failure"], "result_stage": "matching.fallback.result",
"model_stages": [], "pinned": None},
{"id": "audio-enhancer", "name": "پیش‌پردازش صدا (Denoise)", "kind": "audio",
"stages": ["audio.enhance.result"], "result_stage": "audio.enhance.result",
"model_stages": ["audio.enhance.result"], "pinned": None},
]
_STAGE_SECTION = {
"audio.enhance.result": ("صوت", "پیش‌پردازش و نویززدایی"),
"stt.run": ("صوت", "اجرای STT"),
"stt.result": ("صوت", "تبدیل گفتار به متن"),
"nlu.request": ("درک پیام", "درخواست NLU"),
"nlu.result": ("درک پیام", "پاسخ NLU"),
"entities.extracted": ("درک پیام", "موجودیت‌های استخراج‌شده"),
"jev.request": ("تطبیق", "درخواست Jev"),
"jev.result": ("تطبیق", "امتیازدهی Jev"),
"jev.failure": ("تطبیق", "خطای Jev"),
"routing.decision": ("تطبیق", "تصمیم مسیریابی"),
"matching.arbitration.result": ("تطبیق", "داوری DeepSeek"),
"matching.arbitration.failure": ("تطبیق", "خطای داوری"),
"matching.fallback.result": ("تطبیق", "فالبک بحرانی"),
"matching.fallback.failure": ("تطبیق", "خطای فالبک"),
"ambiguity.sent": ("ابهام و انتخاب", "ارسال پرسش ابهام"),
"ambiguity.user_choice": ("ابهام و انتخاب", "انتخاب کاربر"),
"user_choice.pick": ("ابهام و انتخاب", "دکمه‌ی گزینه"),
"draft.render": ("پیش‌نویس", "نمایش پیش‌نویس"),
"draft.edit": ("پیش‌نویس", "ویرایش پیش‌نویس"),
"draft.commit": ("پیش‌نویس", "ثبت نهایی"),
"draft.cancel": ("پیش‌نویس", "لغو"),
}
_LAT = "coalesce(e.latency_ms, nullif(e.payload->>'latency_ms','')::int)"
_TOK = "(e.payload->'usage'->>'input_tokens')::bigint"
_TOK_OUT = "(e.payload->'usage'->>'output_tokens')::bigint"
async def _model_rows(db: AsyncSession, days: int, stage: str) -> list[dict]:
return await _rows(db, f"""
SELECT count(*)::int AS calls,
count(*) FILTER (WHERE e.ok)::int AS ok,
coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p50,
coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p95,
coalesce(sum({_TOK}),0)::bigint AS tokens_in,
coalesce(sum({_TOK_OUT}),0)::bigint AS tokens_out,
max(e.created_at)::text AS last_seen
FROM bot_trace_events e WHERE {REAL} AND e.stage = :stage
AND e.created_at >= now() - (:days * interval '1 day')
""", {"stage": stage, "days": days})
async def _models_seen(db: AsyncSession, days: int, stages: list[str]) -> list[dict]:
return await _rows(db, f"""
SELECT DISTINCT ON (coalesce(e.model,'?')) coalesce(e.model,'?') AS model,
count(*) OVER (PARTITION BY coalesce(e.model,'?'))::int AS n
FROM bot_trace_events e
WHERE {REAL} AND e.stage = ANY(:stages)
AND e.created_at >= now() - (:days * interval '1 day')
""", {"stages": stages, "days": days})
@router.get("/models")
async def models(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
_ctx=Depends(require_role("admin")),
):
"""One record per model in the pipeline (incl. dynamic STT providers)."""
out: list[dict] = []
for d in MODEL_DEFS:
agg = (await _model_rows(db, days, d["result_stage"]))[0]
versions: list[str] = []
if d["model_stages"]:
versions = [r["model"] for r in await _models_seen(db, days, d["model_stages"])
if r["model"] != "?"]
out.append({
"id": d["id"], "name": d["name"], "kind": d["kind"],
"stages": d["stages"], **agg,
"versions": versions,
"drift": bool(d["pinned"] and any(v != d["pinned"] for v in versions)),
"pinned": d["pinned"],
})
# dynamic STT models — one record per provider/model id in stt.result
stt_rows = await _rows(db, f"""
SELECT coalesce(e.model, e.payload->>'provider', 'stt') AS model,
count(*)::int AS calls,
count(*) FILTER (WHERE e.ok)::int AS ok,
coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p50,
coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p95,
0::bigint AS tokens_in, 0::bigint AS tokens_out,
max(e.created_at)::text AS last_seen
FROM bot_trace_events e
WHERE {REAL} AND e.stage = 'stt.result'
AND e.created_at >= now() - (:days * interval '1 day')
GROUP BY 1 ORDER BY calls DESC
""", {"days": days})
for r in stt_rows:
out.append({"id": f"stt:{r['model']}",
"name": f"STT — {r['model']}", "kind": "stt",
"stages": ["stt.result"], **r,
"versions": [], "drift": False, "pinned": None})
return {"data": out, "days": days}
def _model_def(model_id: str) -> dict | None:
for d in MODEL_DEFS:
if d["id"] == model_id:
return d
return None
@router.get("/models/{model_id}/daily")
async def model_daily(
model_id: str,
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
_ctx=Depends(require_role("admin")),
):
"""Daily calls / failures / tokens / latency for one model."""
if model_id.startswith("stt:"):
stage, model_val = "stt.result", model_id[4:]
extra = " AND coalesce(e.model, e.payload->>'provider') = :mv"
params: dict = {"mv": model_val}
else:
d = next((x for x in MODEL_DEFS if x["id"] == model_id), None)
if d is None:
raise NotFoundError("مدل یافت نشد")
stage, model_val, extra = d["result_stage"], None, ""
params = {}
rows = await _rows(db, f"""
SELECT to_char(date_trunc('day', e.created_at), 'YYYY-MM-DD') AS day,
count(*)::int AS calls,
count(*) FILTER (WHERE NOT e.ok)::int AS fails,
coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p50,
coalesce(sum({_TOK}),0)::bigint AS tokens_in,
coalesce(sum({_TOK_OUT}),0)::bigint AS tokens_out
FROM bot_trace_events e
WHERE {REAL} AND e.stage = :stage{extra}
AND e.created_at >= now() - (:days * interval '1 day')
GROUP BY 1 ORDER BY 1
""", {"stage": stage, "days": days, **params})
return {"data": rows, "model": model_id, "days": days}
@router.get("/stages")
async def stages(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
_ctx=Depends(require_role("admin")),
):
"""One record per pipeline stage, tagged with its Persian section."""
rows = await _rows(db, f"""
SELECT e.stage,
count(*)::int AS n,
count(*) FILTER (WHERE NOT e.ok)::int AS fails,
coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p50,
coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY {_LAT})),0)::int AS p95,
coalesce(sum({_TOK}),0)::bigint AS tokens_in,
coalesce(sum({_TOK_OUT}),0)::bigint AS tokens_out
FROM bot_trace_events e
WHERE {REAL} AND e.created_at >= now() - (:days * interval '1 day')
GROUP BY 1 ORDER BY n DESC
""", {"days": days})
for r in rows:
sec = _STAGE_SECTION.get(r["stage"])
r["section"] = sec[0] if sec else "سایر"
r["stage_fa"] = sec[1] if sec else r["stage"]
return {"data": rows, "days": days}
@router.get("/traces") @router.get("/traces")
async def traces( async def traces(
limit: int = Query(50, ge=1, le=200), limit: int = Query(50, ge=1, le=200),
@@ -238,12 +438,25 @@ async def traces(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_ctx=Depends(require_role("admin")), _ctx=Depends(require_role("admin")),
): ):
"""Latest traces for the explorer table (oldest events come from /traces/{id}).""" """Latest message records («پرونده‌ی پیام») with a text excerpt + media flag."""
sql = """ sql = """
SELECT t.id::text AS id, t.created_at::text AS created_at, SELECT t.id::text AS id, t.created_at::text AS created_at,
coalesce(t.source,'bale') AS source, t.chat_id::text AS chat_id, coalesce(t.source,'bale') AS source, t.chat_id::text AS chat_id,
t.kind, coalesce(t.status,'open') AS status, t.kind, coalesce(t.status,'open') AS status,
(SELECT count(*) FROM bot_trace_events e WHERE e.trace_id=t.id)::int AS events (SELECT count(*) FROM bot_trace_events e WHERE e.trace_id=t.id)::int AS events,
CASE WHEN EXISTS (SELECT 1 FROM bot_trace_events e
WHERE e.trace_id=t.id AND e.stage='input.voice')
THEN 'voice' ELSE 'text' END AS input_kind,
left(coalesce((SELECT e.payload->>'consensus_text' FROM bot_trace_events e
WHERE e.trace_id=t.id AND e.stage='nlu.result'
ORDER BY e.created_at LIMIT 1),
(SELECT e.payload->>'text' FROM bot_trace_events e
WHERE e.trace_id=t.id AND e.stage='input.text'
ORDER BY e.created_at LIMIT 1),
(SELECT e.payload->>'text' FROM bot_trace_events e
WHERE e.trace_id=t.id AND e.stage='stt.result' AND e.ok
ORDER BY e.created_at LIMIT 1),
''), 120) AS text_excerpt
FROM bot_traces t FROM bot_traces t
WHERE (:include_synthetic WHERE (:include_synthetic
OR (coalesce(t.source,'bale') NOT IN ('calibration','demo') AND coalesce(t.chat_id,0) <> 0)) OR (coalesce(t.source,'bale') NOT IN ('calibration','demo') AND coalesce(t.chat_id,0) <> 0))
@@ -280,3 +493,139 @@ async def trace_detail(
FROM bot_traces WHERE id::text = :tid FROM bot_traces WHERE id::text = :tid
""", {"tid": trace_id}) """, {"tid": trace_id})
return {"data": {"trace": head[0] if head else None, "events": events}} return {"data": {"trace": head[0] if head else None, "events": events}}
# ---------------------------------------------------------------------------
# media serving (v2.1) — inline playback + download of voices / user files
# ---------------------------------------------------------------------------
# <audio controls> and <a download> cannot attach an Authorization header, so
# the admin page first mints a short-lived signed URL (same idea as the quote
# preview tokens); the GET below accepts that token OR an admin session and
# streams the file from the bot's MinIO bucket, falling back to the read-only
# ./bot/data mount for pre-MinIO files.
_MEDIA_TTL_SECONDS = 600
_MEDIA_RE = re.compile(r"^(voices|attachments)/[A-Za-z0-9_\-./]+$")
_MEDIA_EXT = {".ogg", ".wav", ".m4a", ".mp3", ".pdf", ".png", ".jpg", ".jpeg", ".webp"}
_MEDIA_MIME = {
".ogg": "audio/ogg", ".wav": "audio/wav", ".m4a": "audio/mp4",
".mp3": "audio/mpeg", ".pdf": "application/pdf", ".png": "image/png",
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
}
def _check_ref(ref: str) -> str:
"""Validate a media ref (path under ./bot/data, doubles as the MinIO key)."""
ref = (ref or "").strip()
if not _MEDIA_RE.match(ref) or ".." in ref or ref.endswith("."):
raise ValidationError("مسیر فایل نامعتبر است")
if os.path.splitext(ref)[1].lower() not in _MEDIA_EXT:
raise ValidationError("نوع فایل پشتیبانی نمی‌شود")
return ref
def _media_secret() -> bytes:
base = (settings.CASDOOR_CLIENT_SECRET or settings.MINIO_SECRET_KEY
or "procurement-preview-fallback")
return base.encode("utf-8")
def _media_sign(ref: str, expiry: int) -> str:
return hmac.new(_media_secret(), f"bot-media:{ref}:{expiry}".encode(),
hashlib.sha256).hexdigest()
def _media_token(ref: str, ttl: int = _MEDIA_TTL_SECONDS) -> str:
expiry = int(time.time()) + ttl
return f"{expiry}.{_media_sign(ref, expiry)}"
def _verify_media_token(token: str, ref: str) -> bool:
if not token or "." not in token:
return False
expiry_raw, sig = token.split(".", 1)
try:
expiry = int(expiry_raw)
except ValueError:
return False
if expiry < int(time.time()):
return False
return hmac.compare_digest(_media_sign(ref, expiry), sig)
from pydantic import BaseModel # noqa: E402 (kept next to its only use)
class MediaUrlRequest(BaseModel):
ref: str
@router.post("/media-url")
async def media_url(
body: MediaUrlRequest,
_ctx=Depends(require_role("admin")),
):
"""Mint a short-lived signed URL for inline playback / download."""
ref = _check_ref(body.ref)
from urllib.parse import quote
return {"data": {
"url": f"/api/v1/bot-metrics/media?ref={quote(ref)}&t={_media_token(ref)}",
"expires_in": _MEDIA_TTL_SECONDS,
}}
@router.get("/media")
async def media(
request: Request,
ref: str,
t: str | None = None,
db: AsyncSession = Depends(get_db),
):
"""Stream one bot media file (admin session OR signed ?t= token).
Prefers the MinIO copy (bucket bot-media, key = ref); falls back to the
read-only disk mount for files saved before MinIO mirroring existed.
"""
ref = _check_ref(ref)
allowed = False
if request.headers.get("authorization"):
user = await get_optional_user(request, db)
if user is not None and user.role == "admin":
allowed = True
if not allowed and t and _verify_media_token(t, ref):
allowed = True
if not allowed:
raise UnauthorizedError("دسترسی به این فایل مجاز نیست")
name = os.path.basename(ref)
mime = _MEDIA_MIME.get(os.path.splitext(ref)[1].lower(), "application/octet-stream")
# 1) MinIO copy (the bot dual-writes everything here)
try:
import asyncio
from app.services.storage import get_client as _minio_client
def _fetch() -> bytes:
resp = _minio_client().get_object(settings.BOT_MEDIA_BUCKET, ref)
try:
return resp.read()
finally:
resp.close()
resp.release_conn()
content = await asyncio.to_thread(_fetch)
return Response(content, media_type=mime, headers={
"Content-Disposition": f'inline; filename="{name}"',
"Cache-Control": "private, max-age=300",
})
except Exception:
pass # not in MinIO (old file) or MinIO down → disk fallback
# 2) disk fallback (read-only mount of ./bot/data)
path = (Path("/srv/bot/data") / ref).resolve()
data_root = Path("/srv/bot/data").resolve()
if data_root not in path.parents or not path.is_file():
raise NotFoundError("فایل یافت نشد")
return FileResponse(path, media_type=mime, filename=name,
headers={"Cache-Control": "private, max-age=300"})
+8
View File
@@ -152,6 +152,14 @@ class Settings(BaseSettings):
# postgres user instead). Rotating = change env + restart the bot. # postgres user instead). Rotating = change env + restart the bot.
OBS_READER_PASSWORD: str = "" OBS_READER_PASSWORD: str = ""
# Second copy of every bot media file in the ERP's existing MinIO
# (dual-write with the on-disk archive; empty endpoint = mirroring off).
MINIO_ENDPOINT: str = "" # e.g. "minio:9000" inside the compose network
MINIO_ACCESS_KEY: str = ""
MINIO_SECRET_KEY: str = ""
MINIO_BUCKET: str = "bot-media"
MINIO_SECURE: bool = False
# Jev (TypeSafe) entity matching — phases 2-5. Model version is PINNED # Jev (TypeSafe) entity matching — phases 2-5. Model version is PINNED
# (phase 0 resolved jev-1.13.0) so upstream updates cannot silently change # (phase 0 resolved jev-1.13.0) so upstream updates cannot silently change
# decisions; every response's actual model is recorded for drift detection. # decisions; every response's actual model is recorded for drift detection.
+6 -3
View File
@@ -333,8 +333,10 @@ async def match_with_deepseek(
for v in variants: for v in variants:
results_map[normalize_text(v)] = entry results_map[normalize_text(v)] = entry
# Observability: matching.fallback.result # Observability: matching.fallback.result (model/latency also as columns so
await obs.event(trace_id, "matching.fallback.result", ok=True, payload={ # per-model dashboards aggregate like jev.result's do; payload unchanged)
await obs.event(trace_id, "matching.fallback.result", ok=True,
model=settings.LLM_MODEL, latency_ms=latency_ms, payload={
"latency_ms": latency_ms, "latency_ms": latency_ms,
"usage": usage, "usage": usage,
"statuses": statuses_count, "statuses": statuses_count,
@@ -540,7 +542,8 @@ async def adjudicate_with_deepseek(
results_map[normalize_text(v)] = entry results_map[normalize_text(v)] = entry
# Observability: matching.arbitration.result # Observability: matching.arbitration.result
await obs.event(trace_id, "matching.arbitration.result", ok=True, payload={ await obs.event(trace_id, "matching.arbitration.result", ok=True,
model=settings.LLM_MODEL, latency_ms=latency_ms, payload={
"latency_ms": latency_ms, "latency_ms": latency_ms,
"usage": usage, "usage": usage,
"actions": actions_count, "actions": actions_count,
+16 -8
View File
@@ -8,6 +8,7 @@ import hashlib
import json import json
import logging import logging
import re import re
import time
import httpx import httpx
@@ -220,6 +221,7 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
}) })
try: try:
t0 = time.perf_counter()
async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client: async with httpx.AsyncClient(timeout=settings.LLM_TIMEOUT_SECONDS) as client:
resp = await client.post( resp = await client.post(
f"{settings.AI_BASE_URL.rstrip('/')}/chat/completions", f"{settings.AI_BASE_URL.rstrip('/')}/chat/completions",
@@ -234,29 +236,35 @@ async def understand(settings: Settings, text: str | list[dict], draft_summary:
"response_format": {"type": "json_object"}, "response_format": {"type": "json_object"},
}, },
) )
latency_ms = int((time.perf_counter() - t0) * 1000)
if resp.status_code != 200: if resp.status_code != 200:
logger.error("LLM HTTP %s: %s", resp.status_code, resp.text[:300]) logger.error("LLM HTTP %s: %s", resp.status_code, resp.text[:300])
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL, await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
latency_ms=latency_ms,
payload={"error": f"HTTP {resp.status_code}", "body": resp.text[:300]}) payload={"error": f"HTTP {resp.status_code}", "body": resp.text[:300]})
raise NLUFailureError(f"HTTP {resp.status_code}") raise NLUFailureError(f"HTTP {resp.status_code}")
content = ((resp.json() or {}).get("choices") or [{}])[0].get("message", {}).get("content", "") resp_json = resp.json() or {}
content = ((resp_json.get("choices") or [{}])[0].get("message", {}) or {}).get("content", "")
parsed = _extract_json(content) parsed = _extract_json(content)
if not parsed or parsed.get("intent") not in INTENTS: if not parsed or parsed.get("intent") not in INTENTS:
logger.warning("unparseable LLM output: %r", content[:200]) logger.warning("unparseable LLM output: %r", content[:200])
await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL, await obs.event(trace_id, "nlu.result", ok=False, model=settings.LLM_MODEL,
latency_ms=latency_ms,
payload={"error": "unparseable", "raw": content[:500]}) payload={"error": "unparseable", "raw": content[:500]})
raise NLUFailureError("unparseable LLM output") raise NLUFailureError("unparseable LLM output")
parsed.setdefault("patch", {}) parsed.setdefault("patch", {})
parsed["entity_mentions"] = sanitize_mentions(parsed.get("entity_mentions")) parsed["entity_mentions"] = sanitize_mentions(parsed.get("entity_mentions"))
if not parsed.get("consensus_text"): if not parsed.get("consensus_text"):
parsed["consensus_text"] = first_text parsed["consensus_text"] = first_text
await obs.event(trace_id, "nlu.result", ok=True, model=settings.LLM_MODEL, payload={ await obs.event(trace_id, "nlu.result", ok=True, model=settings.LLM_MODEL,
"intent": parsed.get("intent"), latency_ms=latency_ms, payload={
"confidence": parsed.get("confidence"), "intent": parsed.get("intent"),
"patch": parsed.get("patch"), "confidence": parsed.get("confidence"),
"consensus_text": parsed.get("consensus_text"), "patch": parsed.get("patch"),
"entity_mentions": parsed.get("entity_mentions"), "consensus_text": parsed.get("consensus_text"),
}) "entity_mentions": parsed.get("entity_mentions"),
"usage": resp_json.get("usage"),
})
return parsed return parsed
except Exception as exc: except Exception as exc:
if isinstance(exc, NLUFailureError): if isinstance(exc, NLUFailureError):
+21
View File
@@ -200,6 +200,7 @@ def save_voice(trace_id: str | None, message_id: int, content: bytes,
base.mkdir(parents=True, exist_ok=True) # الزام سخت‌سازی: مسیر همیشه ساخته می‌شود base.mkdir(parents=True, exist_ok=True) # الزام سخت‌سازی: مسیر همیشه ساخته می‌شود
path = base / f"{trace_id}_{message_id}{ext}" path = base / f"{trace_id}_{message_id}{ext}"
path.write_bytes(content) path.write_bytes(content)
_mirror_bg(path, "audio/ogg")
return str(path) return str(path)
except Exception: except Exception:
logger.warning("obs: save_voice failed for trace %s", trace_id, exc_info=True) logger.warning("obs: save_voice failed for trace %s", trace_id, exc_info=True)
@@ -217,7 +218,27 @@ def save_enhanced_voice(trace_id: str | None, message_id: int, content: bytes,
ext = f".{ext}" ext = f".{ext}"
path = base / f"{trace_id}_{message_id}_enhanced{ext or '.ogg'}" path = base / f"{trace_id}_{message_id}_enhanced{ext or '.ogg'}"
path.write_bytes(content) path.write_bytes(content)
_mirror_bg(path, "audio/ogg")
return str(path) return str(path)
except Exception: except Exception:
logger.warning("obs: save_enhanced_voice failed for trace %s", trace_id, exc_info=True) logger.warning("obs: save_enhanced_voice failed for trace %s", trace_id, exc_info=True)
return None return None
async def _mirror(path: Path, content_type: str) -> None:
"""Second copy in MinIO (fire-and-forget; see app/storage.py)."""
try:
from app import storage
ref = storage.media_ref(path)
if ref:
await storage.mirror_to_minio(ref, path, content_type)
except Exception:
pass
def _mirror_bg(path: Path, content_type: str) -> None:
"""Sync-context wrapper around the MinIO mirror (never raises)."""
try:
asyncio.get_running_loop().create_task(_mirror(path, content_type))
except RuntimeError:
pass
+104 -1
View File
@@ -1,11 +1,24 @@
"""Persistent storage for draft attachments (proforma files).""" """Persistent storage for draft attachments (proforma files) + MinIO mirroring.
Dual-write policy (senior-engineer review 2026-10-25): every file the bot
persists (voices, enhanced voices, proforma/user attachments) stays on disk
under ./bot/data (primary copy, "archive — never delete") and is ALSO
uploaded fire-and-forget to the ERP's existing MinIO under bucket `bot-media`
with the SAME key as its path relative to the data dir, e.g.
`voices/20261025/<trace>_<msg>.ogg` — the dashboard's media endpoint derives
the object key from that relative path, so nothing extra is stored in payloads.
"""
from datetime import datetime, timezone from datetime import datetime, timezone
import asyncio
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import re import re
import threading
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from app.config import settings
logger = logging.getLogger("bot.storage") logger = logging.getLogger("bot.storage")
@@ -42,12 +55,102 @@ async def save_proforma_file(
target_path.write_bytes(content) target_path.write_bytes(content)
logger.info("Persisted proforma (%d bytes) to %s", len(content), target_path) logger.info("Persisted proforma (%d bytes) to %s", len(content), target_path)
# Second copy in MinIO (never blocks/fails the bot; disabled when unset)
rel = media_ref(target_path)
if rel:
await mirror_to_minio(rel, target_path,
content_type=mime_type or "application/octet-stream")
return { return {
"file_name": clean_name, "file_name": clean_name,
"saved_filename": saved_filename, "saved_filename": saved_filename,
"saved_path": str(target_path.resolve()), "saved_path": str(target_path.resolve()),
"relative_path": str(target_path.as_posix()), "relative_path": str(target_path.as_posix()),
"media_ref": rel,
"size_bytes": len(content), "size_bytes": len(content),
"mime_type": mime_type or "application/octet-stream", "mime_type": mime_type or "application/octet-stream",
"saved_at": datetime.now(timezone.utc).isoformat(), "saved_at": datetime.now(timezone.utc).isoformat(),
} }
# ---------------------------------------------------------------------------
# MinIO mirror — second copy of every bot media file
# ---------------------------------------------------------------------------
# The official `minio` Python client is synchronous/blocking; every SDK call
# therefore runs in a worker thread (asyncio.to_thread) and the whole upload
# is fire-and-forget so a MinIO outage can never stall the reply path.
DATA_DIR = Path("data") # ./bot/data inside the container (compose volume)
_client = None
_client_lock = threading.Lock()
_bg: set = set()
def media_ref(path: str | Path) -> str | None:
"""Relative ref of a file under ./bot/data ('voices/.../x.ogg'), else None.
This ref doubles as the MinIO object key and as the dashboard's media
identifier — files outside the data dir are simply not mirrored/servable.
"""
try:
rel = Path(path).resolve().relative_to((DATA_DIR).resolve()).as_posix()
return rel if rel and ".." not in rel else None
except ValueError:
return None
def _get_client():
"""Lazily build the blocking MinIO client (None when unconfigured)."""
global _client
if not (settings.MINIO_ENDPOINT and settings.MINIO_ACCESS_KEY and settings.MINIO_SECRET_KEY):
return None
if _client is not None:
return _client
with _client_lock:
if _client is not None:
return _client
try:
from minio import Minio # imported only when configured
_client = Minio(settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_SECURE)
except Exception:
logger.exception("storage: minio client init failed — mirroring disabled")
_client = None
return _client
def _upload_sync(key: str, path: Path, content_type: str) -> None:
client = _get_client()
if client is None:
return
if not client.bucket_exists(settings.MINIO_BUCKET):
client.make_bucket(settings.MINIO_BUCKET)
logger.info("storage: created bucket %s", settings.MINIO_BUCKET)
client.fput_object(settings.MINIO_BUCKET, key, str(path), content_type=content_type)
async def mirror_to_minio(key: str, path: str | Path,
content_type: str = "audio/ogg") -> None:
"""Fire-and-forget upload of an already-saved disk file (never raises)."""
try:
p = Path(path)
if not p.is_file():
logger.warning("storage: mirror skipped — missing file %s", p)
return
async def _run() -> None:
try:
await asyncio.to_thread(_upload_sync, key, p, content_type)
logger.info("storage: mirrored %s -> %s/%s", p.name, settings.MINIO_BUCKET, key)
except Exception:
logger.warning("storage: minio mirror failed for %s", p.name, exc_info=True)
task = asyncio.get_running_loop().create_task(_run())
_bg.add(task)
task.add_done_callback(_bg.discard)
except RuntimeError:
pass # no running loop (sync unit tests) — skip silently
+11 -4
View File
@@ -59,13 +59,20 @@
4. راستی‌آزمایی پایش: `docker compose exec bot python tools/monitor.py --days 1` 4. راستی‌آزمایی پایش: `docker compose exec bot python tools/monitor.py --days 1`
→ قیف باید پر شود و رخدادهای همان پیام‌ها در `bot_trace_events` باشد. → قیف باید پر شود و رخدادهای همان پیام‌ها در `bot_trace_events` باشد.
5. چک سهمیه: `usage` در `jev.result` (توکن ورودی/خروجی) + latency p95. 5. چک سهمیه: `usage` در `jev.result` (توکن ورودی/خروجی) + latency p95.
6. **داشبوردها (۲۰۲۶-۱۰-۲۴):** `PROCUREMENT_BOT_OBS_READER_PASSWORD` را در `.env` ست کنید و 6. **داشبوردها (به‌روز ۲۰۲۶-۱۰-۲۵، v2.1):** `PROCUREMENT_BOT_OBS_READER_PASSWORD` را در `.env` ست کنید و
یک بار بات را restart کنید تا نقش فقط‌خواندنی `obs_reader` ساخته شود؛ سپس گرفانا را یک بار بات را restart کنید تا نقش فقط‌خواندنی `obs_reader` ساخته شود؛ سپس گرفانا را
(ری)استارت کنید — دیتاسورس `BotPostgres` و دو داشبورد «بات — Observability خط لوله» و (ری)استارت کنید — دیتاسورس `BotPostgres` و دو داشبورد «بات — Observability خط لوله» و
«بات — اکسپلورر ترس‌ها» خودکار provision می‌شوند (جزئیات در `monitoring/README.md`). «بات — اکسپلورر پیام‌ها» خودکار provision می‌شوند (جزئیات در `monitoring/README.md`).
صفحه‌ی فارسی داخل ERP هم روی مسیر `/bot-monitor` (فقط ادمین) است. صفحه‌ی فارسی داخل ERP روی `/bot-monitor` (فقط ادمین) چهار تب دارد: نمای کلی،
**مدل‌ها** (رکورد و روند زمانی هر مدل: Jev، DeepSeek-NLU، داور، فالبک، هر STT، بهبود صدا)،
**بخش‌ها** (مراحل به تفکیک بخش خط‌لوله) و **پیام‌ها** (پرونده‌ی هر پیام با پخش/دانلود ویس و
ترنسکریپت همه‌ی موتورها کنار هم).
**رسانه (v2.1):** فایل‌های بات دو-نویسی می‌شوند — دیسک (آرشیو) + باکت MinIO `bot-media`
(env: `BOT_MINIO_ENDPOINT/BOT_MINIO_BUCKET`؛ خالی = خاموش). سرو پخش/دانلود از MinIO با
fallback به دیسک از `/api/v1/bot-metrics/media` با لینک امضاشده‌ی کوتاه‌مدت انجام می‌شود؛
سرویس api ولوم فقط‌خواندنی `./bot/data` دارد (ولیم جدید = `up -d --build api` برای recreate).
برای دیدن همه‌ی پنل‌ها روی نصب تازه: `docker compose exec bot python tools/seed_observability_demo.py --days 7` برای دیدن همه‌ی پنل‌ها روی نصب تازه: `docker compose exec bot python tools/seed_observability_demo.py --days 7`
(داده‌ها با `source='demo'` از آمار ترافیک واقعی جدا می‌مانند). (داده‌ها با `source='demo'` از آمار ترافیک واقعی جدا می‌مانند و در MinIO هم آینه می‌شوند).
## د) Rollback (بدون دیپلوی — فقط env + restart) ## د) Rollback (بدون دیپلوی — فقط env + restart)
+1
View File
@@ -3,3 +3,4 @@ sqlalchemy[asyncio]>=2.0.30
asyncpg>=0.29.0 asyncpg>=0.29.0
pydantic>=2.7.0 pydantic>=2.7.0
pydantic-settings>=2.2.0 pydantic-settings>=2.2.0
minio>=7.2.0
+15 -2
View File
@@ -82,8 +82,19 @@ async def one_trace(s, chat_id: int, t0: datetime, kind: str) -> None:
m = 0.0 m = 0.0
# ---- input # ---- input
if kind == "voice": if kind == "voice":
# a real (tiny, silent) ogg so the media player/download path has bytes
day = t0.strftime("%Y%m%d")
vdir = Path("data/voices") / day
vdir.mkdir(parents=True, exist_ok=True)
vpath = vdir / f"{tid}_{rng.randrange(1000, 99999)}.ogg"
vpath.write_bytes(bytes.fromhex("4f6767530002000000000000000000000001") + b"\x00" * 3000)
await ev("input.voice", minutes=m, payload={ await ev("input.voice", minutes=m, payload={
"path": f"data/voices/demo/{tid}.ogg", "size": 21400, "mime": "audio/ogg"}) "path": vpath.as_posix(), "size": vpath.stat().st_size, "mime": "audio/ogg"})
try: # second copy in MinIO exactly like the live bot does
from app import storage
await storage.mirror_to_minio(storage.media_ref(vpath) or "", vpath, "audio/ogg")
except Exception:
pass
# audio enhancement (new in 787824a) # audio enhancement (new in 787824a)
ok_enh = rng.random() < 0.85 ok_enh = rng.random() < 0.85
await ev("audio.enhance.result", minutes=(m := m + 0.1), ok=ok_enh, await ev("audio.enhance.result", minutes=(m := m + 0.1), ok=ok_enh,
@@ -111,7 +122,9 @@ async def one_trace(s, chat_id: int, t0: datetime, kind: str) -> None:
mentions = _mentions(rng.randint(3, 6)) mentions = _mentions(rng.randint(3, 6))
await ev("nlu.result", minutes=(m := m + 0.9), latency=rng.randrange(1500, 3600), await ev("nlu.result", minutes=(m := m + 0.9), latency=rng.randrange(1500, 3600),
model="deepseek-chat", payload={ model="deepseek-chat", payload={
"intent": "register_quote", "consensus_text": payload_text}) "intent": "register_quote", "consensus_text": payload_text,
"usage": {"input_tokens": rng.randint(600, 2200),
"output_tokens": rng.randint(150, 900)}})
await ev("entities.extracted", minutes=(m := m + 0.02), payload={"entity_mentions": mentions}) await ev("entities.extracted", minutes=(m := m + 0.02), payload={"entity_mentions": mentions})
# ---- Jev scoring + arbitration/fallback (two-stage, 787824a) # ---- Jev scoring + arbitration/fallback (two-stage, 787824a)
+12
View File
@@ -214,12 +214,18 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: procurement-api container_name: procurement-api
volumes:
# Bot media archive (voices/attachments) — READ-ONLY fallback for the
# admin media endpoint when an object is not (yet) in MinIO.
- ./bot/data:/srv/bot/data:ro
environment: environment:
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
PROCUREMENT_MINIO_ENDPOINT: minio:9000 PROCUREMENT_MINIO_ENDPOINT: minio:9000
PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
PROCUREMENT_MINIO_BUCKET: procurement-files PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_BOT_MEDIA_BUCKET: ${BOT_MINIO_BUCKET:-bot-media}
PROCUREMENT_MINIO_SECURE: "false" PROCUREMENT_MINIO_SECURE: "false"
# "dev" enables the local defaults; "prod" turns on the fail-fast # "dev" enables the local defaults; "prod" turns on the fail-fast
# startup checks (no wildcard CORS, no default credentials). # startup checks (no wildcard CORS, no default credentials).
@@ -337,6 +343,12 @@ services:
PROCUREMENT_BOT_ADMIN_CHAT_IDS: ${BALE_ADMIN_CHAT_IDS:-1461166742,1839839922} PROCUREMENT_BOT_ADMIN_CHAT_IDS: ${BALE_ADMIN_CHAT_IDS:-1461166742,1839839922}
PROCUREMENT_BOT_SERVICE_TOKEN: ${BALE_SERVICE_TOKEN:-} PROCUREMENT_BOT_SERVICE_TOKEN: ${BALE_SERVICE_TOKEN:-}
PROCUREMENT_BOT_FRONTEND_URL: ${APP_HOST:-http://localhost} PROCUREMENT_BOT_FRONTEND_URL: ${APP_HOST:-http://localhost}
# Second copy of bot media in the ERP MinIO (empty user/key = off)
PROCUREMENT_BOT_MINIO_ENDPOINT: ${BOT_MINIO_ENDPOINT:-minio:9000}
PROCUREMENT_BOT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
PROCUREMENT_BOT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
PROCUREMENT_BOT_MINIO_BUCKET: ${BOT_MINIO_BUCKET:-bot-media}
PROCUREMENT_BOT_MINIO_SECURE: "false"
depends_on: depends_on:
postgres: postgres:
+47
View File
@@ -1654,6 +1654,43 @@ export interface BotTraceRow {
kind: string; kind: string;
status: string; status: string;
events: number; events: number;
input_kind: "voice" | "text";
text_excerpt: string | null;
}
export interface BotModelRecord {
id: string;
name: string;
kind: string;
stages: string[];
calls: number;
ok: number;
p50: number;
p95: number;
tokens_in: number;
tokens_out: number;
last_seen: string | null;
versions: string[];
drift: boolean;
pinned: string | null;
}
export interface BotModelDaily {
day: string;
calls: number;
fails: number;
p50: number;
tokens_in: number;
tokens_out: number;
}
export interface BotStageRecord {
stage: string;
stage_fa: string;
section: string;
n: number;
fails: number;
p50: number;
p95: number;
tokens_in: number;
tokens_out: number;
} }
export interface BotTraceDetail { export interface BotTraceDetail {
trace: { trace: {
@@ -1894,5 +1931,15 @@ export const apiV26 = {
}, },
traceDetail: (traceId: string) => traceDetail: (traceId: string) =>
getData<BotTraceDetail>(`/bot-metrics/traces/${encodeURIComponent(traceId)}`), getData<BotTraceDetail>(`/bot-metrics/traces/${encodeURIComponent(traceId)}`),
models: (days: number) => getData<BotModelRecord[]>(`/bot-metrics/models?days=${days}`),
modelDaily: (modelId: string, days: number) =>
getData<BotModelDaily[]>(
`/bot-metrics/models/${encodeURIComponent(modelId)}/daily?days=${days}`,
),
stages: (days: number) => getData<BotStageRecord[]>(`/bot-metrics/stages?days=${days}`),
// Short-lived signed URL so <audio>/<a> tags (no Authorization header)
// can load the file — minted per file right before rendering.
mediaUrl: (ref: string) =>
postData<{ url: string; expires_in: number }>("/bot-metrics/media-url", { ref }),
}, },
}; };
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -120,7 +120,15 @@ tools/seed_observability_demo.py --days 7` (marked `source='demo'`).
The same data is also available inside the ERP itself (admin-only, Persian The same data is also available inside the ERP itself (admin-only, Persian
RTL): **/bot-monitor** — served by `GET /api/v1/bot-metrics/*` RTL): **/bot-monitor** — served by `GET /api/v1/bot-metrics/*`
(`backend/app/routers/bot_metrics.py`). (`backend/app/routers/bot_metrics.py`). The page is tabbed: نمای کلی
(overview) / **مدل‌ها** (a record per model — Jev, DeepSeek-NLU, DeepSeek
arbiter, DeepSeek crisis fallback, every STT provider, audio-enhancer — with
per-model daily charts) / **بخش‌ها** (pipeline stages grouped by section) /
**پیام‌ها** (message records with inline voice playback, download and every
STT transcript side-by-side; media is streamed from the bot's MinIO bucket
`bot-media` with the on-disk archive as fallback, via short-lived signed
URLs). Grafana's bot-observability dashboard carries a matching
«کارکرد مدل‌ها» table.
## Alerting ## Alerting
@@ -1,144 +1,662 @@
{ {
"annotations": { "list": [] }, "annotations": {
"list": []
},
"editable": true, "editable": true,
"graphTooltip": 1, "graphTooltip": 1,
"refresh": "5m", "refresh": "5m",
"schemaVersion": 41, "schemaVersion": 41,
"tags": ["erp", "bot"], "tags": [
"time": { "from": "now-7d", "to": "now" }, "erp",
"bot"
],
"time": {
"from": "now-7d",
"to": "now"
},
"timezone": "browser", "timezone": "browser",
"title": "بات — Observability خط لوله", "title": "بات — Observability خط لوله",
"uid": "bot-observability", "uid": "bot-observability",
"version": 1, "version": 1,
"panels": [ "panels": [
{ "type": "row", "title": "نمای کلی", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 100 },
{ {
"type": "stat", "title": "روندها (ترافیک واقعی)", "id": 1, "type": "row",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "نمای کلی",
"gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 }, "gridPos": {
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT count(*) AS value FROM bot_traces WHERE coalesce(source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(created_at)" }], "h": 1,
"fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "steps": [ { "color": "green", "value": null } ] } }, "overrides": [] } "w": 24,
"x": 0,
"y": 0
},
"id": 100
}, },
{ {
"type": "stat", "title": "رخدادها", "id": 2, "type": "stat",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "روندها (ترافیک واقعی)",
"gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 }, "id": 1,
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT count(*) AS value FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at)" }] "datasource": {
"type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 0,
"y": 1
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT count(*) AS value FROM bot_traces WHERE coalesce(source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(created_at)"
}
],
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
}
}, },
{ {
"type": "stat", "title": "خطای Jev / فالبک", "id": 3, "type": "stat",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "رخدادها",
"gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 }, "id": 2,
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT count(*) AS value FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE (e.stage='jev.failure' OR (e.stage='jev.result' AND NOT e.ok) OR e.stage LIKE 'matching.%.failure') AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at)" }], "datasource": {
"fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 1 } ] } }, "overrides": [] } "type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 4,
"y": 1
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT count(*) AS value FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at)"
}
]
}, },
{ {
"type": "stat", "title": "p95 تأخیر Jev (ms)", "id": 4, "type": "stat",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "خطای Jev / فالبک",
"gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 }, "id": 3,
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY e.latency_ms)),0) AS value FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='jev.result' AND e.latency_ms IS NOT NULL AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at)" }], "datasource": {
"fieldConfig": { "defaults": { "unit": "ms" }, "overrides": [] } "type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 8,
"y": 1
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT count(*) AS value FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE (e.stage='jev.failure' OR (e.stage='jev.result' AND NOT e.ok) OR e.stage LIKE 'matching.%.failure') AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at)"
}
],
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
}
}, },
{ {
"type": "stat", "title": "ثبت نهایی پیش‌نویس", "id": 5, "type": "stat",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "p95 تأخیر Jev (ms)",
"gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 }, "id": 4,
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT count(*) AS value FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='draft.commit' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at)" }] "datasource": {
"type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 12,
"y": 1
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY e.latency_ms)),0) AS value FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='jev.result' AND e.latency_ms IS NOT NULL AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at)"
}
],
"fieldConfig": {
"defaults": {
"unit": "ms"
},
"overrides": []
}
}, },
{ {
"type": "stat", "title": "رخداد این ماه (گارد آرشیو ۱۰۰k)", "id": 6, "type": "stat",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "ثبت نهایی پیش‌نویس",
"gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 }, "id": 5,
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT count(*) AS value FROM bot_trace_events WHERE created_at >= date_trunc('month', now())" }], "datasource": {
"fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 50000 }, { "color": "red", "value": 100000 } ] } }, "overrides": [] } "type": "postgres",
}, "uid": "bot-postgres"
},
{ "type": "row", "title": "حجم و تأخیر", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 101 }, "gridPos": {
{ "h": 4,
"type": "timeseries", "title": "رخداد در روز (ترافیک واقعی)", "id": 10, "w": 4,
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "x": 16,
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 }, "y": 1
"targets": [{ "refId": "A", "format": "time_series", "rawSql": "SELECT $__timeGroupAlias(e.created_at,'1d'), count(*) AS value FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1 ORDER BY 1" }], },
"fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 60, "lineWidth": 1 } }, "overrides": [] } "targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT count(*) AS value FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='draft.commit' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at)"
}
]
}, },
{ {
"type": "timeseries", "title": "تأخیر p50/p95 به تفکیک مرحله", "id": 11, "type": "stat",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "رخداد این ماه (گارد آرشیو ۱۰۰k)",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 }, "id": 6,
"targets": [{ "refId": "A", "format": "time_series", "rawSql": "SELECT $__timeGroupAlias(e.created_at,'1h'), round(percentile_cont(0.5) WITHIN GROUP (ORDER BY e.latency_ms)) AS p50, round(percentile_cont(0.95) WITHIN GROUP (ORDER BY e.latency_ms)) AS p95, e.stage AS metric FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('jev.result','nlu.result','stt.result','audio.enhance.result','matching.arbitration.result') AND e.latency_ms IS NOT NULL AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1, e.stage ORDER BY 1" }], "datasource": {
"fieldConfig": { "defaults": { "unit": "ms" }, "overrides": [] } "type": "postgres",
}, "uid": "bot-postgres"
},
{ "type": "row", "title": "تطبیق موجودیت (Jev + داور DeepSeek)", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, "id": 102 }, "gridPos": {
{ "h": 4,
"type": "piechart", "title": "وضعیت نهایی mentionها (exact/ambiguity/new)", "id": 20, "w": 4,
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "x": 20,
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 15 }, "y": 1
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT 'exact' AS metric, sum(coalesce((e.payload->'statuses'->>'exact')::int,0)) AS value FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('matching.arbitration.result','matching.fallback.result') AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) UNION ALL SELECT 'ambiguity', sum(coalesce((e.payload->'statuses'->>'ambiguity')::int,0)) FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('matching.arbitration.result','matching.fallback.result') AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) UNION ALL SELECT 'new', sum(coalesce((e.payload->'statuses'->>'new')::int,0)) FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('matching.arbitration.result','matching.fallback.result') AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) UNION ALL SELECT m->>'status', count(*) FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id, jsonb_array_elements(e.payload->'mentions') m WHERE e.stage='routing.decision' AND e.payload->>'source'='jev' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1" }], },
"fieldConfig": { "defaults": {}, "overrides": [] }, "targets": [
"options": { "pieType": "donut", "legend": { "displayMode": "table", "placement": "right" } } {
"refId": "A",
"format": "table",
"rawSql": "SELECT count(*) AS value FROM bot_trace_events WHERE created_at >= date_trunc('month', now())"
}
],
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 50000
},
{
"color": "red",
"value": 100000
}
]
}
},
"overrides": []
}
}, },
{ {
"type": "table", "title": "اقدامات داور DeepSeek روی پیشنهاد Jev", "id": 21, "type": "row",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "حجم و تأخیر",
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 15 }, "gridPos": {
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT coalesce(e.payload->'actions'->>'endorsed_jev','0')::int AS \"تأیید Jev\", coalesce(e.payload->'actions'->>'vetoed_to_new','0')::int AS \"وتو → جدید\", coalesce(e.payload->'actions'->>'demoted_to_ambiguity','0')::int AS \"تنزل → ابهام\", coalesce(e.payload->'actions'->>'other','0')::int AS \"سایر\", count(*) OVER () AS \"فراخوانی‌ها\" FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='matching.arbitration.result' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at)" }], "h": 1,
"fieldConfig": { "defaults": {}, "overrides": [] } "w": 24,
"x": 0,
"y": 5
},
"id": 101
}, },
{ {
"type": "timeseries", "title": "توکن مصرفی در زمان (ورودی/خروجی)", "id": 22, "type": "timeseries",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "رخداد در روز (ترافیک واقعی)",
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 15 }, "id": 10,
"targets": [{ "refId": "A", "format": "time_series", "rawSql": "SELECT $__timeGroupAlias(e.created_at,'1d'), sum(coalesce((e.payload->'usage'->>'input_tokens')::bigint,0)) AS input, sum(coalesce((e.payload->'usage'->>'output_tokens')::bigint,0)) AS output FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('jev.result','matching.arbitration.result','matching.fallback.result') AND e.payload->'usage' IS NOT NULL AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1 ORDER BY 1" }], "datasource": {
"fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 50 } }, "overrides": [] } "type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 6
},
"targets": [
{
"refId": "A",
"format": "time_series",
"rawSql": "SELECT $__timeGroupAlias(e.created_at,'1d'), count(*) AS value FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1 ORDER BY 1"
}
],
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 60,
"lineWidth": 1
}
},
"overrides": []
}
}, },
{ {
"type": "table", "title": "نسخه مدل Jev دیده‌شده (پین: jev-1.13.0)", "id": 23, "type": "timeseries",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "تأخیر p50/p95 به تفکیک مرحله",
"gridPos": { "h": 6, "w": 8, "x": 0, "y": 23 }, "id": 11,
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT coalesce(e.model,'?') AS \"مدل\", count(*) AS \"تعداد\", max(e.created_at)::text AS \"آخرین بار\" FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='jev.result' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1 ORDER BY 2 DESC" }] "datasource": {
"type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 6
},
"targets": [
{
"refId": "A",
"format": "time_series",
"rawSql": "SELECT $__timeGroupAlias(e.created_at,'1h'), round(percentile_cont(0.5) WITHIN GROUP (ORDER BY e.latency_ms)) AS p50, round(percentile_cont(0.95) WITHIN GROUP (ORDER BY e.latency_ms)) AS p95, e.stage AS metric FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('jev.result','nlu.result','stt.result','audio.enhance.result','matching.arbitration.result') AND e.latency_ms IS NOT NULL AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1, e.stage ORDER BY 1"
}
],
"fieldConfig": {
"defaults": {
"unit": "ms"
},
"overrides": []
}
}, },
{ {
"type": "timeseries", "title": "فراخوانی مراحل تطبیق (Jev/داور/فالبک)", "id": 24, "type": "row",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "تطبیق موجودیت (Jev + داور DeepSeek)",
"gridPos": { "h": 6, "w": 8, "x": 8, "y": 23 }, "gridPos": {
"targets": [{ "refId": "A", "format": "time_series", "rawSql": "SELECT $__timeGroupAlias(e.created_at,'1d'), count(*) AS value, e.stage AS metric FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('jev.request','matching.arbitration.result','matching.fallback.result') AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1, e.stage ORDER BY 1" }], "h": 1,
"fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 40, "stacking": { "mode": "normal" } } }, "overrides": [] } "w": 24,
"x": 0,
"y": 14
},
"id": 102
}, },
{ {
"type": "stat", "title": "حجم Observability (گارد آرشیو ۵GB)", "id": 25, "type": "piechart",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "وضعیت نهایی mentionها (exact/ambiguity/new)",
"gridPos": { "h": 6, "w": 8, "x": 16, "y": 23 }, "id": 20,
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT (pg_total_relation_size('bot_trace_events') + pg_total_relation_size('bot_traces')) AS value" }], "datasource": {
"fieldConfig": { "defaults": { "unit": "bytes", "color": { "mode": "thresholds" }, "thresholds": { "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 3000000000 }, { "color": "red", "value": 5000000000 } ] } }, "overrides": [] } "type": "postgres",
}, "uid": "bot-postgres"
},
{ "type": "row", "title": "کیفیت و ورودی", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 29 }, "id": 103 }, "gridPos": {
{ "h": 8,
"type": "table", "title": "انتخاب کاربر (A/B واقعی)", "id": 30, "w": 8,
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "x": 0,
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 30 }, "y": 15
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT payload->>'action' AS \"اقدام\", coalesce(payload->>'arg','-') AS \"آرگومان\", count(*) AS \"تعداد\" FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='ambiguity.user_choice' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1,2 UNION ALL SELECT CASE WHEN (payload->>'idx')::int = 0 THEN 'انتخاب: گزینه اول (پیشنهاد مدل)' ELSE 'انتخاب: گزینه غیراول' END, '-', count(*) FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='user_choice.pick' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1" }] },
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT 'exact' AS metric, sum(coalesce((e.payload->'statuses'->>'exact')::int,0)) AS value FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('matching.arbitration.result','matching.fallback.result') AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) UNION ALL SELECT 'ambiguity', sum(coalesce((e.payload->'statuses'->>'ambiguity')::int,0)) FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('matching.arbitration.result','matching.fallback.result') AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) UNION ALL SELECT 'new', sum(coalesce((e.payload->'statuses'->>'new')::int,0)) FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('matching.arbitration.result','matching.fallback.result') AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) UNION ALL SELECT m->>'status', count(*) FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id, jsonb_array_elements(e.payload->'mentions') m WHERE e.stage='routing.decision' AND e.payload->>'source'='jev' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1"
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"pieType": "donut",
"legend": {
"displayMode": "table",
"placement": "right"
}
}
}, },
{ {
"type": "table", "title": "سلامت STT به تفکیک پرووایدر", "id": 31, "type": "table",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "اقدامات داور DeepSeek روی پیشنهاد Jev",
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 30 }, "id": 21,
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT coalesce(e.payload->>'provider','?') AS \"پرووایدر\", count(*) FILTER (WHERE e.ok) AS \"موفق\", count(*) AS \"کل\", round(100.0*count(*) FILTER (WHERE e.ok)/greatest(count(*),1),1)::text || '%' AS \"نرخ موفقیت\", coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY e.latency_ms))::text,'—') AS \"p50 (ms)\" FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='stt.result' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1 ORDER BY 2 DESC" }] "datasource": {
"type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 15
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT coalesce(e.payload->'actions'->>'endorsed_jev','0')::int AS \"تأیید Jev\", coalesce(e.payload->'actions'->>'vetoed_to_new','0')::int AS \"وتو → جدید\", coalesce(e.payload->'actions'->>'demoted_to_ambiguity','0')::int AS \"تنزل → ابهام\", coalesce(e.payload->'actions'->>'other','0')::int AS \"سایر\", count(*) OVER () AS \"فراخوانی‌ها\" FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='matching.arbitration.result' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at)"
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
}
}, },
{ {
"type": "timeseries", "title": "پیش‌پردازش صدا (audio.enhance)", "id": 32, "type": "timeseries",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "توکن مصرفی در زمان (ورودی/خروجی)",
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 30 }, "id": 22,
"targets": [{ "refId": "A", "format": "time_series", "rawSql": "SELECT $__timeGroupAlias(e.created_at,'1d'), count(*) FILTER (WHERE e.ok) AS value, 'موفق' AS metric FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='audio.enhance.result' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1 UNION ALL SELECT $__timeGroupAlias(e.created_at,'1d'), count(*) FILTER (WHERE NOT e.ok), 'ناموفق (بازگشت به صدای خام)' FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='audio.enhance.result' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1" }], "datasource": {
"fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 50, "stacking": { "mode": "normal" } } }, "overrides": [] } "type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 15
},
"targets": [
{
"refId": "A",
"format": "time_series",
"rawSql": "SELECT $__timeGroupAlias(e.created_at,'1d'), sum(coalesce((e.payload->'usage'->>'input_tokens')::bigint,0)) AS input, sum(coalesce((e.payload->'usage'->>'output_tokens')::bigint,0)) AS output FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('jev.result','matching.arbitration.result','matching.fallback.result') AND e.payload->'usage' IS NOT NULL AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1 ORDER BY 1"
}
],
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 50
}
},
"overrides": []
}
}, },
{ {
"type": "table", "title": "چرخه‌ی پیش‌نویس", "id": 33, "type": "table",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "نسخه مدل Jev دیده‌شده (پین: jev-1.13.0)",
"gridPos": { "h": 5, "w": 24, "x": 0, "y": 38 }, "id": 23,
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT e.stage AS \"مرحله\", count(*) AS \"تعداد\" FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('draft.render','draft.commit','draft.cancel','ambiguity.sent') AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1 ORDER BY 2 DESC" }] "datasource": {
"type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 6,
"w": 8,
"x": 0,
"y": 23
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT coalesce(e.model,'?') AS \"مدل\", count(*) AS \"تعداد\", max(e.created_at)::text AS \"آخرین بار\" FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='jev.result' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1 ORDER BY 2 DESC"
}
]
},
{
"type": "timeseries",
"title": "فراخوانی مراحل تطبیق (Jev/داور/فالبک)",
"id": 24,
"datasource": {
"type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 6,
"w": 8,
"x": 8,
"y": 23
},
"targets": [
{
"refId": "A",
"format": "time_series",
"rawSql": "SELECT $__timeGroupAlias(e.created_at,'1d'), count(*) AS value, e.stage AS metric FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('jev.request','matching.arbitration.result','matching.fallback.result') AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1, e.stage ORDER BY 1"
}
],
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 40,
"stacking": {
"mode": "normal"
}
}
},
"overrides": []
}
},
{
"type": "stat",
"title": "حجم Observability (گارد آرشیو ۵GB)",
"id": 25,
"datasource": {
"type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 6,
"w": 8,
"x": 16,
"y": 23
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT (pg_total_relation_size('bot_trace_events') + pg_total_relation_size('bot_traces')) AS value"
}
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"color": {
"mode": "thresholds"
},
"thresholds": {
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 3000000000
},
{
"color": "red",
"value": 5000000000
}
]
}
},
"overrides": []
}
},
{
"type": "row",
"title": "کیفیت و ورودی",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 29
},
"id": 103
},
{
"type": "table",
"title": "انتخاب کاربر (A/B واقعی)",
"id": 30,
"datasource": {
"type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 30
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT payload->>'action' AS \"اقدام\", coalesce(payload->>'arg','-') AS \"آرگومان\", count(*) AS \"تعداد\" FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='ambiguity.user_choice' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1,2 UNION ALL SELECT CASE WHEN (payload->>'idx')::int = 0 THEN 'انتخاب: گزینه اول (پیشنهاد مدل)' ELSE 'انتخاب: گزینه غیراول' END, '-', count(*) FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='user_choice.pick' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1"
}
]
},
{
"type": "table",
"title": "سلامت STT به تفکیک پرووایدر",
"id": 31,
"datasource": {
"type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 30
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT coalesce(e.payload->>'provider','?') AS \"پرووایدر\", count(*) FILTER (WHERE e.ok) AS \"موفق\", count(*) AS \"کل\", round(100.0*count(*) FILTER (WHERE e.ok)/greatest(count(*),1),1)::text || '%' AS \"نرخ موفقیت\", coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY e.latency_ms))::text,'—') AS \"p50 (ms)\" FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='stt.result' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1 ORDER BY 2 DESC"
}
]
},
{
"type": "timeseries",
"title": "پیش‌پردازش صدا (audio.enhance)",
"id": 32,
"datasource": {
"type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 30
},
"targets": [
{
"refId": "A",
"format": "time_series",
"rawSql": "SELECT $__timeGroupAlias(e.created_at,'1d'), count(*) FILTER (WHERE e.ok) AS value, 'موفق' AS metric FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='audio.enhance.result' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1 UNION ALL SELECT $__timeGroupAlias(e.created_at,'1d'), count(*) FILTER (WHERE NOT e.ok), 'ناموفق (بازگشت به صدای خام)' FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage='audio.enhance.result' AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1"
}
],
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 50,
"stacking": {
"mode": "normal"
}
}
},
"overrides": []
}
},
{
"type": "table",
"title": "چرخه‌ی پیش‌نویس",
"id": 33,
"datasource": {
"type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 5,
"w": 24,
"x": 0,
"y": 38
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT e.stage AS \"مرحله\", count(*) AS \"تعداد\" FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE e.stage IN ('draft.render','draft.commit','draft.cancel','ambiguity.sent') AND coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) GROUP BY 1 ORDER BY 2 DESC"
}
]
},
{
"type": "row",
"title": "کارکرد مدل‌ها",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 45
},
"id": 110
},
{
"type": "table",
"title": "رکورد عملکرد مدل‌ها (فراخوانی/موفق/p95/توکن)",
"id": 111,
"datasource": {
"type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 9,
"w": 24,
"x": 0,
"y": 46
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT 'Jev (تطبیق)' AS metric, count(*)::int AS value0, count(*) FILTER (WHERE e.ok)::int AS ok, coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY coalesce(e.latency_ms, nullif(e.payload->>'latency_ms','')::int))),0)::int AS p95, coalesce(sum((e.payload->'usage'->>'input_tokens')::bigint),0)::bigint AS tin, coalesce(sum((e.payload->'usage'->>'output_tokens')::bigint),0)::bigint AS tout FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) AND e.stage = 'jev.result' UNION ALL SELECT 'داور DeepSeek' AS metric, count(*)::int AS value0, count(*) FILTER (WHERE e.ok)::int AS ok, coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY coalesce(e.latency_ms, nullif(e.payload->>'latency_ms','')::int))),0)::int AS p95, coalesce(sum((e.payload->'usage'->>'input_tokens')::bigint),0)::bigint AS tin, coalesce(sum((e.payload->'usage'->>'output_tokens')::bigint),0)::bigint AS tout FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) AND e.stage = 'matching.arbitration.result' UNION ALL SELECT 'فالبک DeepSeek' AS metric, count(*)::int AS value0, count(*) FILTER (WHERE e.ok)::int AS ok, coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY coalesce(e.latency_ms, nullif(e.payload->>'latency_ms','')::int))),0)::int AS p95, coalesce(sum((e.payload->'usage'->>'input_tokens')::bigint),0)::bigint AS tin, coalesce(sum((e.payload->'usage'->>'output_tokens')::bigint),0)::bigint AS tout FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) AND e.stage = 'matching.fallback.result' UNION ALL SELECT 'NLU (DeepSeek)' AS metric, count(*)::int AS value0, count(*) FILTER (WHERE e.ok)::int AS ok, coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY coalesce(e.latency_ms, nullif(e.payload->>'latency_ms','')::int))),0)::int AS p95, coalesce(sum((e.payload->'usage'->>'input_tokens')::bigint),0)::bigint AS tin, coalesce(sum((e.payload->'usage'->>'output_tokens')::bigint),0)::bigint AS tout FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) AND e.stage = 'nlu.result' UNION ALL SELECT 'پیش‌پردازش صدا' AS metric, count(*)::int AS value0, count(*) FILTER (WHERE e.ok)::int AS ok, coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY coalesce(e.latency_ms, nullif(e.payload->>'latency_ms','')::int))),0)::int AS p95, coalesce(sum((e.payload->'usage'->>'input_tokens')::bigint),0)::bigint AS tin, coalesce(sum((e.payload->'usage'->>'output_tokens')::bigint),0)::bigint AS tout FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) AND e.stage = 'audio.enhance.result' UNION ALL SELECT coalesce(e.model, e.payload->>'provider','stt') || ' (STT)' AS metric, count(*)::int, count(*) FILTER (WHERE e.ok)::int, coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY coalesce(e.latency_ms, nullif(e.payload->>'latency_ms','')::int))),0)::int, 0::bigint, 0::bigint FROM bot_trace_events e JOIN bot_traces t ON e.trace_id=t.id WHERE coalesce(t.source,'bale') NOT IN ('calibration','demo') AND $__timeFilter(e.created_at) AND e.stage='stt.result' GROUP BY 1 ORDER BY 2 DESC"
}
],
"fieldConfig": {
"defaults": {
"custom": {
"align": "auto"
}
},
"overrides": []
}
} }
], ],
"templating": { "list": [] } "templating": {
"list": []
}
} }
@@ -1,29 +1,86 @@
{ {
"annotations": { "list": [] }, "annotations": {
"list": []
},
"editable": true, "editable": true,
"graphTooltip": 1, "graphTooltip": 1,
"refresh": "30s", "refresh": "30s",
"schemaVersion": 41, "schemaVersion": 41,
"tags": ["erp", "bot"], "tags": [
"time": { "from": "now-24h", "to": "now" }, "erp",
"bot"
],
"time": {
"from": "now-24h",
"to": "now"
},
"timezone": "browser", "timezone": "browser",
"title": "بات — اکسپلورر ترس‌ها", "title": "بات — اکسپلورر ترس‌ها",
"uid": "bot-trace-explorer", "uid": "bot-trace-explorer",
"version": 1, "version": 1,
"panels": [ "panels": [
{ {
"type": "table", "title": "آخرین ترس‌ها", "id": 1, "type": "table",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "آخرین ترس‌ها",
"gridPos": { "h": 12, "w": 24, "x": 0, "y": 0 }, "id": 1,
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT t.id::text AS \"trace_id\", t.created_at AS \"زمان\", coalesce(t.source,'bale') AS \"منبع\", t.chat_id AS \"چت\", t.kind AS \"نوع\", coalesce(t.status,'—') AS \"وضعیت\", (SELECT count(*) FROM bot_trace_events e WHERE e.trace_id=t.id) AS \"رخدادها\" FROM bot_traces t WHERE ($trace_id = '' OR t.id::text = $trace_id) AND $__timeFilter(t.created_at) ORDER BY t.created_at DESC LIMIT 100" }], "datasource": {
"fieldConfig": { "defaults": { "custom": { "align": "auto" } }, "overrides": [] } "type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 12,
"w": 24,
"x": 0,
"y": 0
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT t.id::text AS \"trace_id\", t.created_at AS \"زمان\", coalesce(t.source,'bale') AS \"منبع\", t.chat_id AS \"چت\", t.kind AS \"نوع\", coalesce(t.status,'—') AS \"وضعیت\", (SELECT count(*) FROM bot_trace_events e WHERE e.trace_id=t.id) AS \"رخدادها\", , left(coalesce((SELECT e2.payload->>'consensus_text' FROM bot_trace_events e2 WHERE e2.trace_id=t.id AND e2.stage='nlu.result' LIMIT 1), (SELECT e2.payload->>'text' FROM bot_trace_events e2 WHERE e2.trace_id=t.id AND e2.stage='input.text' LIMIT 1), ''), 90) AS \"متن/گزیده\"\n FROM bot_traces t WHERE ($trace_id = '' OR t.id::text = $trace_id) AND $__timeFilter(t.created_at) ORDER BY t.created_at DESC LIMIT 100"
}
],
"fieldConfig": {
"defaults": {
"custom": {
"align": "auto"
}
},
"overrides": []
}
}, },
{ {
"type": "table", "title": "رویدادهای ترس انتخاب‌شده (به ترتیب زمان)", "id": 2, "type": "table",
"datasource": { "type": "postgres", "uid": "bot-postgres" }, "title": "رویدادهای ترس انتخاب‌شده (به ترتیب زمان)",
"gridPos": { "h": 14, "w": 24, "x": 0, "y": 12 }, "id": 2,
"targets": [{ "refId": "A", "format": "table", "rawSql": "SELECT e.created_at AS \"زمان\", e.stage AS \"مرحله\", e.ok AS \"ok\", coalesce(e.latency_ms::text,'—') AS \"تأخیر (ms)\", coalesce(e.model,'—') AS \"مدل\", coalesce(e.field,'—') AS \"فیلد\", left(e.payload::text, 700) AS \"payload\" FROM bot_trace_events e WHERE e.trace_id::text = $trace_id ORDER BY e.created_at ASC LIMIT 500" }], "datasource": {
"fieldConfig": { "defaults": { "custom": { "align": "auto", "cellOptions": { "type": "text" } } }, "overrides": [] } "type": "postgres",
"uid": "bot-postgres"
},
"gridPos": {
"h": 14,
"w": 24,
"x": 0,
"y": 12
},
"targets": [
{
"refId": "A",
"format": "table",
"rawSql": "SELECT e.created_at AS \"زمان\", e.stage AS \"مرحله\", e.ok AS \"ok\", coalesce(e.latency_ms::text,'—') AS \"تأخیر (ms)\", coalesce(e.model,'—') AS \"مدل\", coalesce(e.field,'—') AS \"فیلد\", left(e.payload::text, 700) AS \"payload\" FROM bot_trace_events e WHERE e.trace_id::text = $trace_id ORDER BY e.created_at ASC LIMIT 500"
}
],
"fieldConfig": {
"defaults": {
"custom": {
"align": "auto",
"cellOptions": {
"type": "text"
}
}
},
"overrides": []
}
} }
], ],
"templating": { "templating": {
@@ -33,7 +90,11 @@
"label": "شناسه ترس", "label": "شناسه ترس",
"type": "textbox", "type": "textbox",
"hide": 0, "hide": 0,
"current": { "selected": false, "text": "", "value": "" }, "current": {
"selected": false,
"text": "",
"value": ""
},
"options": [], "options": [],
"query": "", "query": "",
"description": "trace_id را از جدول بالا کپی و اینجا بچسبانید" "description": "trace_id را از جدول بالا کپی و اینجا بچسبانید"