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.
221 lines
11 KiB
Python
221 lines
11 KiB
Python
"""Seed DEMO observability traces so every dashboard panel has data (phase 5).
|
|
|
|
docker compose exec bot python tools/seed_observability_demo.py [--days 7]
|
|
|
|
- Writes traces with source='demo' (excluded from real-traffic aggregates in
|
|
monitor.py / bot-metrics / Grafana by default; visible via the ERP page's
|
|
«نمایش ترافیک آزمایشی» checkbox or Grafana queries without the filter).
|
|
- Covers every current pipeline stage (787824a): input.text/voice,
|
|
audio.enhance.result, stt.run/result, nlu.request/result, entities.extracted,
|
|
jev.request/result (+ one jev.failure), routing.decision (jev_scored),
|
|
matching.arbitration.result, matching.fallback.result, ambiguity.sent,
|
|
ambiguity.user_choice, user_choice.pick, draft.render, draft.commit.
|
|
- Idempotent per run-day: re-running the same day replaces that day's demo
|
|
traces (delete + re-insert of source='demo' rows only — real data untouched,
|
|
no-deletion policy applies to real traffic only).
|
|
"""
|
|
import argparse
|
|
import asyncio
|
|
import random
|
|
import sys
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.db import SessionLocal
|
|
|
|
rng = random.Random(42) # deterministic demo data
|
|
|
|
FIELDS = ["project", "supplier", "material", "brand", "spec", "unit"]
|
|
VARIANTS = {
|
|
"project": [["منشیانه"], ["دفتر"], ["باق اسپارت"], ["پردیس"]],
|
|
"supplier": [["مصطفوی", "مستفدی"], ["فولاد مبارکه"], ["راجین"]],
|
|
"material": [["گچ و خاک"], ["سیمان"], ["میلگرد"]],
|
|
"brand": [["ذوب آهن"], ["سیمان تهران"]],
|
|
"spec": [["سایز 14"], ["50 کیلویی"]],
|
|
"unit": [["کیسه"], ["کیلو"], ["تن"]],
|
|
}
|
|
NAMES = {
|
|
"project": ["منشانه", "دفتر", "باغ اسپارت", "پردیس کاشان"],
|
|
"supplier": ["بازرگانی مصطفوی", "نمایندگی فولاد مبارکه", "راجین"],
|
|
"material": ["گچ و خاک", "سیمان پرتلند", "میلگرد آجدار"],
|
|
"brand": ["ذوب آهن اصفهان", "سیمان تهران"],
|
|
"spec": ["سایز ۱۴", "۵۰ کیلویی"],
|
|
"unit": ["کیسه", "کیلوگرم", "تن"],
|
|
}
|
|
STAGES_F = ["سایز ۱۴", "۵۰ کیلویی"]
|
|
|
|
|
|
def _mentions(n: int) -> list[dict]:
|
|
out = []
|
|
for field in (FIELDS[:n]):
|
|
out.append({"field": field, "variants": rng.choice(VARIANTS[field])})
|
|
return out
|
|
|
|
|
|
async def one_trace(s, chat_id: int, t0: datetime, kind: str) -> None:
|
|
tid = uuid.uuid4()
|
|
n_ev = 0
|
|
|
|
async def ev(stage: str, *, minutes=0.0, ok=True, latency=None, model=None,
|
|
field=None, payload=None) -> None:
|
|
nonlocal n_ev
|
|
n_ev += 1
|
|
await s.execute(text("""
|
|
INSERT INTO bot_trace_events
|
|
(trace_id, stage, ok, latency_ms, model, field, payload, created_at)
|
|
VALUES (:t, :stage, :ok, :lat, :model, :field, CAST(:payload AS jsonb), :ts)
|
|
"""), {"t": tid, "stage": stage, "ok": ok, "lat": latency, "model": model,
|
|
"field": field, "payload": __import__("json").dumps(payload or {}, ensure_ascii=False),
|
|
"ts": t0 + timedelta(minutes=minutes)})
|
|
|
|
await s.execute(text("""
|
|
INSERT INTO bot_traces (id, chat_id, message_id, kind, source, status, created_at, updated_at)
|
|
VALUES (:id, :chat, :msg, :kind, 'demo', :status, :ts, :ts)
|
|
"""), {"id": tid, "chat": chat_id, "msg": rng.randrange(1000, 99999), "kind": kind,
|
|
"status": "committed", "ts": t0})
|
|
|
|
m = 0.0
|
|
# ---- input
|
|
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={
|
|
"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)
|
|
ok_enh = rng.random() < 0.85
|
|
await ev("audio.enhance.result", minutes=(m := m + 0.1), ok=ok_enh,
|
|
latency=rng.randrange(900, 2600), model="audio_enhancer",
|
|
payload={"size": 18000, "original_size": 21400} if ok_enh
|
|
else {"error": "HTTP 503", "fallback": True})
|
|
await ev("stt.run", minutes=(m := m + 0.1), payload={"providers": ["whisper", "vosk"]})
|
|
transcripts = []
|
|
for p in ("whisper", "vosk"):
|
|
ok = rng.random() < 0.92
|
|
t = "بیست و پنج کیسه گچ و خاک از مصطفوی برای منشیانه" if ok else None
|
|
await ev("stt.result", minutes=(m := m + 0.1), ok=ok, latency=rng.randrange(700, 2100),
|
|
model="whisper-1" if p == "whisper" else "vosk-fa",
|
|
payload={"provider": p, "text": t, "error": None if ok else "timeout"})
|
|
if ok:
|
|
transcripts.append({"name": p, "text": t})
|
|
payload_text = transcripts[0]["text"] if transcripts else ""
|
|
else:
|
|
await ev("input.text", minutes=m, payload={"text": "۲۵ کیسه گچ و خاک از مصطفوی برای منشیانه"})
|
|
payload_text = "۲۵ کیسه گچ و خاک از مصطفوی برای منشیانه"
|
|
|
|
# ---- NLU
|
|
await ev("nlu.request", minutes=(m := m + 0.05), payload={
|
|
"prompt_hash": "demo-hash", "prompt_version": "nlu-v2-entity-mentions"})
|
|
mentions = _mentions(rng.randint(3, 6))
|
|
await ev("nlu.result", minutes=(m := m + 0.9), latency=rng.randrange(1500, 3600),
|
|
model="deepseek-chat", payload={
|
|
"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})
|
|
|
|
# ---- Jev scoring + arbitration/fallback (two-stage, 787824a)
|
|
scored: list = []
|
|
statuses = {"exact": 0, "ambiguity": 0, "new": 0}
|
|
actions: dict = {}
|
|
act_names = ["endorsed_jev", "endorsed_jev", "endorsed_jev",
|
|
"demoted_to_ambiguity", "vetoed_to_new"]
|
|
for i, men in enumerate(mentions):
|
|
field = men["field"]
|
|
jev_failed = i == 0 and rng.random() < 0.25
|
|
if jev_failed:
|
|
await ev("jev.failure", minutes=(m := m + 0.1), field=field, ok=False,
|
|
payload={"error": "jev_unavailable_or_error", "n_mentions": 1})
|
|
# crisis fallback path
|
|
status = rng.choice(["exact", "ambiguity", "new"])
|
|
statuses[status] += 1
|
|
continue
|
|
await ev("jev.request", minutes=(m := m + 0.05), field=field,
|
|
payload={"n_mentions": 1, "n_candidates": rng.randint(6, 20)})
|
|
name = rng.choice(NAMES[field])
|
|
conf = rng.choice([0.55, 0.68, 0.75, 0.86, 0.9, 0.95, 0.99])
|
|
await ev("jev.result", minutes=(m := m + 0.9), field=field, model="jev-1.13.0",
|
|
latency=rng.randrange(600, 1300),
|
|
payload={"answers": {"mention_0": {"choice": name, "confidence": conf}},
|
|
"usage": {"input_tokens": rng.randint(400, 2600),
|
|
"output_tokens": rng.randint(60, 1300)}})
|
|
scored.append({"variants": men["variants"], "jev_choice": name, "confidence": conf})
|
|
if scored:
|
|
await ev("routing.decision", minutes=(m := m + 0.02), payload={
|
|
"source": "jev_scored", "mentions": scored})
|
|
for _ in scored:
|
|
action = rng.choice(act_names)
|
|
actions[action] = actions.get(action, 0) + 1
|
|
statuses["exact" if action == "endorsed_jev"
|
|
else "ambiguity" if action == "demoted_to_ambiguity" else "new"] += 1
|
|
await ev("matching.arbitration.result", minutes=(m := m + 1.1),
|
|
latency=rng.randrange(1200, 3200), model="deepseek-chat",
|
|
payload={"statuses": statuses, "actions": actions,
|
|
"n_items": len(scored), "n_resolved": len(scored),
|
|
"usage": {"input_tokens": rng.randint(800, 3000),
|
|
"output_tokens": rng.randint(100, 900)}})
|
|
else:
|
|
await ev("matching.fallback.result", minutes=(m := m + 1.2),
|
|
latency=rng.randrange(1400, 3400), model="deepseek-chat",
|
|
payload={"statuses": statuses, "n_mentions": len(mentions),
|
|
"n_resolved": max(len(mentions) - 1, 0),
|
|
"usage": {"input_tokens": rng.randint(900, 3200),
|
|
"output_tokens": rng.randint(120, 950)}})
|
|
|
|
# ---- ambiguity + user choice (the A/B proxy panels)
|
|
if statuses["ambiguity"] > 0:
|
|
await ev("ambiguity.sent", minutes=(m := m + 0.1), payload={
|
|
"amb_id": "em_demo", "kind": "entity_match",
|
|
"candidates": [{"id": "x1", "name": "منشانه", "prob": 0.62}]})
|
|
await ev("ambiguity.user_choice", minutes=(m := m + 2.5), payload={
|
|
"amb_id": "em_demo", "action": "pick", "arg": "0",
|
|
"candidates": [{"id": "x1", "name": "منشانه"}]})
|
|
await ev("user_choice.pick", minutes=(m := m + 0.05), payload={
|
|
"field": "project", "idx": 0, "choice": "منشانه"})
|
|
|
|
# ---- draft lifecycle
|
|
await ev("draft.render", minutes=(m := m + 0.2), payload={"fresh": True, "missing": []})
|
|
await ev("draft.commit", minutes=(m := m + 3.0), payload={
|
|
"quote_id": str(uuid.uuid4()), "items": len(mentions) - 3, "newly_created": [],
|
|
"only_resolved": False})
|
|
|
|
|
|
async def run(days: int) -> None:
|
|
async with SessionLocal() as s:
|
|
# idempotent per run-day: wipe only this script's demo rows for those days
|
|
await s.execute(text("""
|
|
DELETE FROM bot_traces
|
|
WHERE source = 'demo'
|
|
AND created_at >= now() - (:days * interval '1 day')
|
|
"""), {"days": days})
|
|
base = datetime.now(timezone.utc).replace(hour=10, minute=0, second=0, microsecond=0)
|
|
n = 0
|
|
for d in range(days):
|
|
for _ in range(rng.randint(2, 5)): # 2-5 traces/day
|
|
day0 = base - timedelta(days=d)
|
|
t0 = day0 + timedelta(minutes=rng.randrange(0, 600))
|
|
await one_trace(s, rng.choice([1461166742, 1839839922]), t0,
|
|
rng.choice(["voice", "text"]))
|
|
n += 1
|
|
await s.commit()
|
|
print(f"seeded {n} demo traces over {days} day(s) (source='demo')")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--days", type=int, default=7)
|
|
asyncio.run(run(ap.parse_args().days))
|