Phase 0 — Jev validated with the real key (5 Persian scenarios, api.typesafe.ai): stale project name «منشیانه»->«منشانه» conf 0.73 (ambiguity band), STT spelling variants «مصطفوی/مستفدی/مسطفی»->«بازرگانی مصطفوی» conf 0.93, exact «دفتر» 0.88, proper _none rejection for «کاغذ A4» 0.92, and two mentions in ONE request (0.77/0.64). Exact model version to pin: jev-1.13.0. Latency 0.7-1.1s per field request; ~450-650 in / 64-133 out tokens. Phase 1 — Observability (DB-only, zero extra LLM tokens, nothing is ever deleted): - new tables bot_traces + bot_trace_events (idempotent DDL in db_init.py; ORM in models.py) - new app/observability.py: _jsonable (UUID/datetime/Decimal/set/enum/dataclass-safe), trace_start, event, log_resolution, link_draft, latest_trace_for_draft, set_trace_status, span() context manager, save_voice (Path.mkdir parents+exist_ok, day folders) - capture points wired: input.text; input.voice (+voice persisted under OBS_VOICE_DIR); stt.run + per-provider stt.result (success AND failure, latency, model); nlu.request + nlu.result with prompt_hash/prompt_version; entities.extracted; routing.decision (difflib baseline for the phase-6 A/B); ambiguity.sent; ambiguity.user_choice (buttons + free-text reply path); user_choice.pick; draft.render; draft.edit (wizard-side edits detected by the sync loop); draft.commit (quote/supplier/material/project + trace status/quote id); draft.cancel - compose: persistent ./bot/data:/srv/bot/data volume; bot/.gitignore: data/ (runtime data never committed, never deleted) - tests: bot/tools/test_observability.py -> 8/8 pass (jsonable cases, trace lifecycle, JSONB round-trip, draft linking, status update, save_voice day-folder mkdir, span task). Verified live: voice file lands on the host volume and is gitignored; rows + files persist across bot restart.
125 lines
5.4 KiB
Python
125 lines
5.4 KiB
Python
"""Speech-to-text via an OpenAI-compatible /audio/transcriptions endpoint.
|
|
|
|
Bale voice notes arrive as OGG/Opus which not every provider accepts, so the
|
|
audio is first converted to mono 16 kHz MP3 with ffmpeg (installed in the bot
|
|
image). Any OpenAI-compatible provider works (OpenAI, GLM, OpenRouter proxies,
|
|
self-hosted whisper servers exposing the same contract).
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
|
|
from app import observability as obs
|
|
from app.config import Settings
|
|
|
|
logger = logging.getLogger("bot.stt")
|
|
|
|
_CONVERT_TIMEOUT = 60
|
|
_STT_TIMEOUT = 120
|
|
|
|
|
|
def _to_mp3(raw: bytes, suffix: str) -> bytes:
|
|
"""Convert arbitrary audio bytes to mono 16 kHz MP3 using ffmpeg."""
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
src = Path(tmp) / f"in{suffix or '.ogg'}"
|
|
dst = Path(tmp) / "out.mp3"
|
|
src.write_bytes(raw)
|
|
proc = subprocess.run(
|
|
["ffmpeg", "-y", "-i", str(src), "-vn", "-ac", "1", "-ar", "16000", "-b:a", "64k", str(dst)],
|
|
capture_output=True,
|
|
timeout=_CONVERT_TIMEOUT,
|
|
)
|
|
if proc.returncode != 0 or not dst.exists():
|
|
raise RuntimeError(f"ffmpeg failed: {proc.stderr[-400:]!r}")
|
|
return dst.read_bytes()
|
|
|
|
|
|
async def _call_single_stt(client: httpx.AsyncClient, provider: dict, converted_mp3: bytes) -> dict:
|
|
"""Call one provider; always returns {"name","ok","text"/"error","model","latency_ms"}."""
|
|
name = provider.get("name") or "stt"
|
|
base_url = (provider.get("base_url") or "").rstrip("/")
|
|
api_key = provider.get("api_key") or ""
|
|
model = provider.get("model") or "whisper-1"
|
|
language = provider.get("language") or "fa"
|
|
timeout = float(provider.get("timeout") or _STT_TIMEOUT)
|
|
|
|
url = f"{base_url}/audio/transcriptions"
|
|
t0 = time.perf_counter()
|
|
try:
|
|
resp = await client.post(
|
|
url,
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
files={"file": ("voice.mp3", converted_mp3, "audio/mpeg")},
|
|
data={"model": model, "language": language},
|
|
timeout=timeout,
|
|
)
|
|
ms = int((time.perf_counter() - t0) * 1000)
|
|
if resp.status_code == 200:
|
|
txt = str((resp.json() or {}).get("text") or "").strip()
|
|
if txt:
|
|
logger.info("STT provider %s succeeded: %r", name, txt[:60])
|
|
return {"name": name, "ok": True, "text": txt, "model": model, "latency_ms": ms}
|
|
return {"name": name, "ok": False, "error": "empty transcript (HTTP 200)",
|
|
"model": model, "latency_ms": ms}
|
|
logger.warning("STT provider %s returned HTTP %s: %s", name, resp.status_code, resp.text[:200])
|
|
return {"name": name, "ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}",
|
|
"model": model, "latency_ms": ms}
|
|
except Exception as exc:
|
|
logger.warning("STT provider %s failed: %s", name, exc)
|
|
return {"name": name, "ok": False, "error": str(exc)[:300],
|
|
"model": model, "latency_ms": int((time.perf_counter() - t0) * 1000)}
|
|
|
|
|
|
async def transcribe_multi(settings: Settings, audio: bytes, mime_type: str | None = None,
|
|
filename: str | None = None, trace_id: str | None = None) -> list[dict]:
|
|
"""Transcribe audio with all configured STT providers in parallel.
|
|
|
|
Returns a list of candidate dicts: [{"name": str, "text": str, "model": str}, ...]
|
|
Empty list if all failed or silence. Every provider outcome (success AND
|
|
failure) is recorded in Observability when trace_id is given.
|
|
"""
|
|
providers = settings.stt_providers
|
|
if not providers:
|
|
raise RuntimeError("No STT providers are configured (PROCUREMENT_BOT_STT_*)")
|
|
|
|
suffix = Path(filename or "").suffix
|
|
if not suffix and mime_type:
|
|
suffix = {"audio/ogg": ".ogg", "audio/opus": ".ogg", "audio/mpeg": ".mp3",
|
|
"audio/mp4": ".m4a", "audio/wav": ".wav"}.get(mime_type.split(";")[0].strip(), ".ogg")
|
|
converted = await asyncio.to_thread(_to_mp3, audio, suffix)
|
|
|
|
await obs.event(trace_id, "stt.run", payload={
|
|
"providers": [p.get("name") or "stt" for p in providers],
|
|
"audio_bytes": len(converted),
|
|
})
|
|
|
|
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
|
|
tasks = [_call_single_stt(client, p, converted) for p in providers]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
valid_transcripts: list[dict] = []
|
|
for r in results:
|
|
if not isinstance(r, dict):
|
|
continue
|
|
await obs.event(trace_id, "stt.result", ok=bool(r.get("ok")),
|
|
latency_ms=r.get("latency_ms"), model=r.get("model"),
|
|
payload={"provider": r.get("name"), "text": r.get("text"),
|
|
"error": r.get("error")})
|
|
if r.get("ok") and r.get("text"):
|
|
valid_transcripts.append({"name": r["name"], "text": r["text"], "model": r.get("model")})
|
|
|
|
return valid_transcripts
|
|
|
|
|
|
async def transcribe(settings: Settings, audio: bytes, mime_type: str | None = None,
|
|
filename: str | None = None, trace_id: str | None = None) -> str:
|
|
"""Return the Persian transcript for a voice note (empty string on silence)."""
|
|
transcripts = await transcribe_multi(settings, audio, mime_type=mime_type,
|
|
filename=filename, trace_id=trace_id)
|
|
return transcripts[0]["text"] if transcripts else ""
|