Files
ERP/backend/app/routers/bot_metrics.py
T
whqreza cb621ca5d0 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.
2026-10-25 22:02:40 +03:30

632 lines
28 KiB
Python

"""Bot observability metrics (read-only) for the ERP admin dashboard page.
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,
demo and test traffic (source != 'bale' / chat_id = 0) is excluded from the
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.
"""
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.ext.asyncio import AsyncSession
from app.auth import get_optional_user, require_role
from app.config import settings
from app.db.engine import get_db
from app.utils.errors import NotFoundError, UnauthorizedError, ValidationError
router = APIRouter(prefix="/bot-metrics", tags=["bot-metrics"])
# real-traffic predicate shared by every aggregate (same rule as the bot's
# tools/monitor.py so the numbers match the weekly digest)
REAL = ("e.trace_id IN (SELECT id FROM bot_traces"
" WHERE coalesce(source,'bale') NOT IN ('calibration','demo')"
" AND coalesce(chat_id,0) <> 0)")
async def _rows(db: AsyncSession, sql: str, params: dict | None = None) -> list[dict]:
res = await db.execute(text(sql), params or {})
return [dict(r._mapping) for r in res.all()]
@router.get("/summary")
async def summary(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
_ctx=Depends(require_role("admin")),
):
"""Headline KPIs + daily volumes for the dashboard header cards."""
p = {"days": days}
kpis = (await _rows(db, f"""
SELECT
(SELECT count(*) FROM bot_traces
WHERE coalesce(source,'bale') NOT IN ('calibration','demo')
AND created_at >= now() - (:days * interval '1 day')) AS traces,
(SELECT count(*) FROM bot_trace_events e WHERE {REAL}
AND e.created_at >= now() - (:days * interval '1 day')) AS events,
(SELECT count(*) FROM bot_trace_events e WHERE {REAL}
AND (e.stage = 'jev.failure' OR (e.stage = 'jev.result' AND NOT e.ok)
OR e.stage LIKE 'matching.%.failure')
AND e.created_at >= now() - (:days * interval '1 day')) AS errors,
(SELECT coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY e.latency_ms)), 0)
FROM bot_trace_events e WHERE {REAL} AND e.stage = 'jev.result'
AND e.latency_ms IS NOT NULL
AND e.created_at >= now() - (:days * interval '1 day')) AS jev_p95_ms,
(SELECT count(*) FROM bot_trace_events e WHERE {REAL} AND e.stage = 'draft.commit'
AND e.created_at >= now() - (:days * interval '1 day')) AS commits,
(SELECT coalesce(sum((e.payload->'usage'->>'input_tokens')::bigint), 0)
FROM bot_trace_events e WHERE {REAL}
AND e.stage IN ('jev.result','matching.arbitration.result','matching.fallback.result')
AND e.payload->'usage' IS NOT NULL
AND e.created_at >= now() - (:days * interval '1 day')) AS tokens_in,
(SELECT coalesce(sum((e.payload->'usage'->>'output_tokens')::bigint), 0)
FROM bot_trace_events e WHERE {REAL}
AND e.stage IN ('jev.result','matching.arbitration.result','matching.fallback.result')
AND e.payload->'usage' IS NOT NULL
AND e.created_at >= now() - (:days * interval '1 day')) AS tokens_out
""", p))[0]
daily = await _rows(db, f"""
SELECT to_char(date_trunc('day', e.created_at), 'YYYY-MM-DD') AS day,
count(*) AS events,
count(DISTINCT e.trace_id) AS traces
FROM bot_trace_events e WHERE {REAL}
AND e.created_at >= now() - (:days * interval '1 day')
GROUP BY 1 ORDER BY 1
""", p)
return {"data": {"kpis": kpis, "daily": daily, "days": days}}
@router.get("/routing")
async def routing(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
_ctx=Depends(require_role("admin")),
):
"""Final mention statuses (arbiter + crisis fallback aggregates, plus the
legacy per-mention rows) and the arbiter's actions on Jev's suggestions."""
p = {"days": days}
statuses = await _rows(db, f"""
SELECT metric, sum(n)::int AS n FROM (
SELECT unnest(ARRAY['exact','ambiguity','new']) AS metric, 0 AS n
UNION ALL
SELECT 'exact', sum(coalesce((e.payload->'statuses'->>'exact')::int,0))
FROM bot_trace_events e WHERE {REAL}
AND e.stage IN ('matching.arbitration.result','matching.fallback.result')
AND e.created_at >= now() - (:days * interval '1 day')
UNION ALL
SELECT 'ambiguity', sum(coalesce((e.payload->'statuses'->>'ambiguity')::int,0))
FROM bot_trace_events e WHERE {REAL}
AND e.stage IN ('matching.arbitration.result','matching.fallback.result')
AND e.created_at >= now() - (:days * interval '1 day')
UNION ALL
SELECT 'new', sum(coalesce((e.payload->'statuses'->>'new')::int,0))
FROM bot_trace_events e WHERE {REAL}
AND e.stage IN ('matching.arbitration.result','matching.fallback.result')
AND e.created_at >= now() - (:days * interval '1 day')
UNION ALL
SELECT m->>'status', count(*) FROM bot_trace_events e
JOIN bot_traces t ON t.id = e.trace_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 coalesce(t.chat_id,0) <> 0
AND e.created_at >= now() - (:days * interval '1 day')
GROUP BY 1
) x GROUP BY metric
""", p)
actions = await _rows(db, f"""
SELECT
coalesce(sum((e.payload->'actions'->>'endorsed_jev')::int),0)::int AS endorsed_jev,
coalesce(sum((e.payload->'actions'->>'vetoed_to_new')::int),0)::int AS vetoed_to_new,
coalesce(sum((e.payload->'actions'->>'demoted_to_ambiguity')::int),0)::int AS demoted_to_ambiguity,
coalesce(sum((e.payload->'actions'->>'other')::int),0)::int AS other,
count(*)::int AS invocations
FROM bot_trace_events e WHERE {REAL} AND e.stage='matching.arbitration.result'
AND e.created_at >= now() - (:days * interval '1 day')
""", p)
stages = await _rows(db, f"""
SELECT e.stage, count(*)::int AS n
FROM bot_trace_events e WHERE {REAL}
AND e.stage IN ('jev.request','jev.result','jev.failure',
'matching.arbitration.result','matching.fallback.result')
AND e.created_at >= now() - (:days * interval '1 day')
GROUP BY 1 ORDER BY n DESC
""", p)
return {"data": {"statuses": statuses, "actions": actions[0] if actions else {}, "stages": stages}}
@router.get("/quality")
async def quality(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
_ctx=Depends(require_role("admin")),
):
"""User choices as the real A/B proxy: what the user picked when asked."""
p = {"days": days}
amb = await _rows(db, f"""
SELECT coalesce(e.payload->>'action','?') AS action,
coalesce(e.payload->>'arg','-') AS arg,
count(*)::int AS n
FROM bot_trace_events e WHERE {REAL} AND e.stage='ambiguity.user_choice'
AND e.created_at >= now() - (:days * interval '1 day')
GROUP BY 1,2 ORDER BY n DESC
""", p)
picks = await _rows(db, f"""
SELECT CASE WHEN (e.payload->>'idx')::int = 0 THEN 'top' ELSE 'other' END AS pick,
count(*)::int AS n
FROM bot_trace_events e WHERE {REAL} AND e.stage='user_choice.pick'
AND e.created_at >= now() - (:days * interval '1 day')
GROUP BY 1
""", p)
drafts = await _rows(db, f"""
SELECT e.stage, count(*)::int AS n
FROM bot_trace_events e WHERE {REAL}
AND e.stage IN ('draft.render','draft.commit','draft.cancel','ambiguity.sent')
AND e.created_at >= now() - (:days * interval '1 day')
GROUP BY 1 ORDER BY n DESC
""", p)
return {"data": {"ambiguities": amb, "picks": picks, "drafts": drafts}}
@router.get("/jev")
async def jev_health(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
_ctx=Depends(require_role("admin")),
):
"""Jev health: models seen (drift vs the pinned version), tokens, latency."""
p = {"days": days}
models = await _rows(db, f"""
SELECT coalesce(e.model,'?') AS model, count(*)::int AS n,
max(e.created_at)::text AS last_seen
FROM bot_trace_events e WHERE {REAL} AND e.stage='jev.result'
AND e.created_at >= now() - (:days * interval '1 day')
GROUP BY 1 ORDER BY n DESC
""", p)
latency = await _rows(db, f"""
SELECT e.stage,
coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY e.latency_ms)),0)::int AS p50,
coalesce(round(percentile_cont(0.95) WITHIN GROUP (ORDER BY e.latency_ms)),0)::int AS p95,
count(*)::int AS n
FROM bot_trace_events e WHERE {REAL}
AND e.stage IN ('jev.result','nlu.result','matching.arbitration.result','matching.fallback.result')
AND e.latency_ms IS NOT NULL
AND e.created_at >= now() - (:days * interval '1 day')
GROUP BY 1 ORDER BY n DESC
""", p)
tokens_daily = await _rows(db, f"""
SELECT to_char(date_trunc('day', e.created_at), 'YYYY-MM-DD') AS day,
coalesce(sum((e.payload->'usage'->>'input_tokens')::bigint),0)::bigint AS tokens_in,
coalesce(sum((e.payload->'usage'->>'output_tokens')::bigint),0)::bigint AS tokens_out
FROM bot_trace_events e WHERE {REAL}
AND e.stage IN ('jev.result','matching.arbitration.result','matching.fallback.result')
AND e.payload->'usage' IS NOT NULL
AND e.created_at >= now() - (:days * interval '1 day')
GROUP BY 1 ORDER BY 1
""", p)
return {"data": {"models": models, "latency": latency, "tokens_daily": tokens_daily}}
@router.get("/stt")
async def stt_health(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
_ctx=Depends(require_role("admin")),
):
"""STT providers and the audio-enhancement layer (ok-rate + p50 latency)."""
p = {"days": days}
providers = await _rows(db, f"""
SELECT coalesce(e.payload->>'provider','?') AS provider,
count(*) FILTER (WHERE e.ok)::int AS ok,
count(*)::int AS total,
coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY e.latency_ms)),0)::int AS p50_ms
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 ok DESC
""", p)
enhance = await _rows(db, f"""
SELECT count(*) FILTER (WHERE e.ok)::int AS ok,
count(*)::int AS total,
coalesce(round(percentile_cont(0.5) WITHIN GROUP (ORDER BY e.latency_ms)),0)::int AS p50_ms
FROM bot_trace_events e WHERE {REAL} AND e.stage='audio.enhance.result'
AND e.created_at >= now() - (:days * interval '1 day')
""", p)
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")
async def traces(
limit: int = Query(50, ge=1, le=200),
status: str | None = None,
include_synthetic: bool = False,
db: AsyncSession = Depends(get_db),
_ctx=Depends(require_role("admin")),
):
"""Latest message records («پرونده‌ی پیام») with a text excerpt + media flag."""
sql = """
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,
t.kind, coalesce(t.status,'open') AS status,
(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
WHERE (:include_synthetic
OR (coalesce(t.source,'bale') NOT IN ('calibration','demo') AND coalesce(t.chat_id,0) <> 0))
"""
params: dict = {"include_synthetic": include_synthetic}
if status:
sql += " AND t.status = :status"
params["status"] = status
sql += " ORDER BY t.created_at DESC LIMIT :limit"
params["limit"] = limit
return {"data": await _rows(db, sql, params)}
@router.get("/traces/{trace_id}")
async def trace_detail(
trace_id: str,
db: AsyncSession = Depends(get_db),
_ctx=Depends(require_role("admin")),
):
"""Full event list of one trace (the drawer/drill-down view)."""
events = await _rows(db, """
SELECT e.created_at::text AS created_at, e.stage, e.ok,
e.latency_ms, e.model, e.field, e.payload
FROM bot_trace_events e
WHERE e.trace_id::text = :tid
ORDER BY e.created_at ASC, e.id ASC
LIMIT 500
""", {"tid": trace_id})
head = await _rows(db, """
SELECT id::text AS id, created_at::text AS created_at,
coalesce(source,'bale') AS source, chat_id::text AS chat_id,
kind, coalesce(status,'open') AS status,
draft_id::text AS draft_id, committed_quote_id::text AS committed_quote_id
FROM bot_traces WHERE id::text = :tid
""", {"tid": trace_id})
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"})