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.
157 lines
5.7 KiB
Python
157 lines
5.7 KiB
Python
"""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
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import threading
|
|
from uuid import UUID, uuid4
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger("bot.storage")
|
|
|
|
|
|
def sanitize_filename(filename: str) -> str:
|
|
"""Sanitize filename preserving Persian/Arabic/English letters, numbers, and extensions."""
|
|
raw = (filename or "proforma.pdf").strip()
|
|
name = Path(raw).name
|
|
# Remove control characters and path traversal
|
|
clean = re.sub(r'[\/\\:\*\?"<>\|\r\n\t]', "_", name)
|
|
clean = clean.strip(". ")
|
|
return clean or f"proforma_{uuid4().hex[:8]}.pdf"
|
|
|
|
|
|
async def save_proforma_file(
|
|
draft_id: str | UUID,
|
|
file_name: str,
|
|
content: bytes,
|
|
mime_type: str | None = None,
|
|
storage_dir: str = "data/attachments/proformas",
|
|
) -> dict:
|
|
"""Save proforma bytes permanently to disk.
|
|
|
|
Returns dict metadata for persisting in draft payload.
|
|
"""
|
|
clean_name = sanitize_filename(file_name)
|
|
target_dir = Path(storage_dir) / str(draft_id)
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
unique_prefix = uuid4().hex[:8]
|
|
saved_filename = f"{unique_prefix}_{clean_name}"
|
|
target_path = target_dir / saved_filename
|
|
|
|
# Write bytes to disk
|
|
target_path.write_bytes(content)
|
|
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 {
|
|
"file_name": clean_name,
|
|
"saved_filename": saved_filename,
|
|
"saved_path": str(target_path.resolve()),
|
|
"relative_path": str(target_path.as_posix()),
|
|
"media_ref": rel,
|
|
"size_bytes": len(content),
|
|
"mime_type": mime_type or "application/octet-stream",
|
|
"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
|