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:
@@ -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=
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)])
|
||||||
|
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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 را از جدول بالا کپی و اینجا بچسبانید"
|
||||||
|
|||||||
Reference in New Issue
Block a user