Files
Button/demo-server/app/main.py
T

796 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ButtonTask demo server — voice classify + press log + cancel."""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
import re
import shutil
import subprocess
import tempfile
import uuid
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Deque, Dict, List, Optional
import httpx
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic_settings import BaseSettings, SettingsConfigDict
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("demo-server")
UPLOAD_DIR = Path(os.environ.get("UPLOAD_DIR", "/tmp/buttontask-uploads"))
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
DATA_DIR = Path(os.environ.get("DATA_DIR", "/tmp/buttontask-data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
CATEGORIES_FILE = DATA_DIR / "categories.json"
DEFAULT_CATEGORIES = [
"Клининг",
"АХО",
"Охрана",
"Канцелярия",
"Техническая служба",
]
SILENCE_MARKERS = (
"",
"(тишина)",
"тишина",
"...",
".",
"-",
"silence",
"[silence]",
"(silence)",
)
def _load_categories() -> List[str]:
if CATEGORIES_FILE.is_file():
try:
data = json.loads(CATEGORIES_FILE.read_text(encoding="utf-8"))
cats = [str(c).strip() for c in (data.get("categories") or []) if str(c).strip()]
if cats:
return cats
except (OSError, json.JSONDecodeError, TypeError):
pass
return list(DEFAULT_CATEGORIES)
def _save_categories(cats: List[str]) -> List[str]:
cleaned = []
seen = set()
for c in cats:
name = str(c).strip()
if not name:
continue
key = name.lower()
if key in seen:
continue
seen.add(key)
cleaned.append(name)
if not cleaned:
cleaned = list(DEFAULT_CATEGORIES)
CATEGORIES_FILE.write_text(
json.dumps({"categories": cleaned}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return cleaned
def _classify_system_prompt(cats: List[str]) -> str:
listed = ", ".join(cats)
return f"""Ты классификатор заявок с голосовой кнопки в здании.
По тексту распознанной речи выбери ОДНУ категорию строго из списка:
{listed}
Учти, что текст приходит из STT и может содержать опечатки/созвучия
(например «скрипки»/«скрытки» вместо «скрепки», «клиненг» вместо «клининг»).
Восстанавливай смысл по контексту офисной заявки.
Если речь пустая, неразборчивая, это шум/тишина, или ни одна категория не подходит —
ответь ровно: UNMATCHED
Примеры:
- "пролили кофе", "уберите мусор" → Клининг (если есть в списке)
- "закончились скрепки", "принесите скрепки", "кончилась бумага" → Канцелярия / АХО (если есть)
- "подозрительный человек" → Охрана (если есть)
Ответь ТОЛЬКО названием категории из списка или UNMATCHED, без пояснений."""
# Whisper often mishears short office words on noisy mics.
_TRANSCRIPT_FIXES = (
(re.compile(r"\bскрипки\b", re.I), "скрепки"),
(re.compile(r"\bскрытки\b", re.I), "скрепки"),
(re.compile(r"\bскритки\b", re.I), "скрепки"),
(re.compile(r"\bскрепке\b", re.I), "скрепки"),
(re.compile(r"\bклиненг\b", re.I), "клининг"),
(re.compile(r"\bклинингг\b", re.I), "клининг"),
)
def _fix_transcript(text: str) -> str:
out = (text or "").strip()
for rx, repl in _TRANSCRIPT_FIXES:
out = rx.sub(repl, out)
return out.strip()
# Bias Whisper toward office Russian vocabulary (reduces «скрипки» etc.).
STT_PROMPT = (
"Голосовая заявка с панели в здании на русском. "
"Возможные темы: клининг, уборка, скрепки, бумага, канцелярия, АХО, охрана, "
"техническая служба, принесите, закончились, пролили, уберите."
)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False, extra="ignore")
api_key: str = "97098109-3188-496b-b075-0e8d83c2bef5"
openrouter_api_key: str = ""
openrouter_base_url: str = "https://openrouter.ai/api/v1"
# gpt-4o-mini-transcribe usually beats vanilla Whisper on noisy short RU clips
stt_model: str = "openai/gpt-4o-mini-transcribe"
classify_model: str = "openai/gpt-4o-mini"
socks5_proxy: str = ""
mock_ai: bool = False
host: str = "0.0.0.0"
port: int = 5000
# Profile denoise via post-roll silence tail. Off by default — raw audio STT better here.
denoise: bool = False
denoise_nf: float = -18.0
denoise_prop: float = 0.75
settings = Settings()
# In-memory event log and active voice calls
_events: Deque[Dict[str, Any]] = deque(maxlen=200)
_active: Dict[str, Dict[str, Any]] = {} # request_id -> call
_lock = asyncio.Lock()
_categories: List[str] = _load_categories()
app = FastAPI(title="ButtonTask Demo Server", version="1.0.0")
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
static_dir = Path(__file__).resolve().parent.parent / "static"
if static_dir.is_dir():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
app.mount("/media", StaticFiles(directory=str(UPLOAD_DIR)), name="media")
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _audio_url(filename: str) -> str:
return f"/media/{filename}"
def _push_event(kind: str, **payload: Any) -> Dict[str, Any]:
ev = {"id": str(uuid.uuid4()), "ts": _now_iso(), "kind": kind, **payload}
_events.appendleft(ev)
log.info("event %s %s", kind, {k: v for k, v in payload.items() if k not in ("headers",)})
return ev
def _is_silence(text: str) -> bool:
t = (text or "").strip().lower()
if t in SILENCE_MARKERS:
return True
# very short / non-letter noise
letters = re.sub(r"[^\wа-яё]", "", t, flags=re.I)
return len(letters) < 2
def _normalize_category(raw: str, cats: List[str]) -> Optional[str]:
cleaned = (raw or "").strip().strip('"').strip("'")
if not cleaned or cleaned.upper() == "UNMATCHED":
return None
for cat in cats:
if cat.lower() == cleaned.lower():
return cat
for cat in cats:
if cat.lower() in cleaned.lower() or cleaned.lower() in cat.lower():
return cat
return None
def _mock_transcript(path: Path) -> str:
name = path.name.lower()
if "silent" in name or "тишин" in name:
return ""
if "coffee" in name or "кофе" in name:
return "пролили кофе"
if "paper" in name or "бумаг" in name:
return "кончилась бумага"
return "пролили кофе в коридоре"
def _mock_classify(text: str, cats: List[str]) -> Optional[str]:
if _is_silence(text):
return None
low = (text or "").lower()
rules = [
(("кофе", "мусор", "гряз", "убор", "пролил"), "Клининг"),
(("бумаг", "ручк", "картридж", "канцеляр"), "Канцелярия"),
(("охран", "дверь", "подозрит"), "Охрана"),
(("розетк", "кран", "сломан", "не работает"), "Техническая служба"),
(("ахо", "хозяйств"), "АХО"),
]
for words, cat in rules:
if any(w in low for w in words) and cat in cats:
return cat
# first category containing substring match from text tokens — else unmatched
return None
def _extract_api_key(request: Request, header_key: Optional[str] = None) -> Optional[str]:
"""Prefer X-API-Key header; fall back to Authorization / query (debug)."""
if header_key:
return header_key.strip() or None
# Case-insensitive header lookup (proxies / Qt variants)
for name, val in request.headers.items():
if name.lower() == "x-api-key" and val:
return val.strip()
if name.lower() == "authorization" and val.lower().startswith("bearer "):
return val[7:].strip()
q = request.query_params.get("api_key")
return q.strip() if q else None
def _check_api_key(x_api_key: Optional[str]) -> None:
expected = (settings.api_key or "").strip()
got = (x_api_key or "").strip()
if not got or got != expected:
log.warning(
"API auth failed: header_present=%s got_len=%s expected_len=%s got_prefix=%r",
bool(got),
len(got),
len(expected),
(got[:12] + "") if got else "",
)
raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key")
def _httpx_client() -> httpx.AsyncClient:
kwargs: Dict[str, Any] = {"timeout": 120.0}
proxy = (settings.socks5_proxy or "").strip()
if proxy:
kwargs["proxy"] = proxy
return httpx.AsyncClient(**kwargs)
def _write_wav_mono_s16(path: Path, audio: Any, rate: int) -> None:
import wave
import numpy as np
pcm = np.clip(audio, -32768, 32767).astype(np.int16)
with wave.open(str(path), "wb") as out:
out.setnchannels(1)
out.setsampwidth(2)
out.setframerate(rate)
out.writeframes(pcm.tobytes())
def _ffmpeg_to_mono16k(src: Path, dst: Path) -> bool:
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
return False
# Light band-limit only — no blind denoise/gain (that hurt clarity).
af = "highpass=f=80,lowpass=f=7500"
try:
subprocess.run(
[
ffmpeg, "-y", "-i", str(src),
"-af", af,
"-ac", "1", "-ar", "16000", "-sample_fmt", "s16",
str(dst),
],
check=True,
capture_output=True,
timeout=90,
)
return dst.is_file() and dst.stat().st_size > 44
except Exception as exc:
log.warning("ffmpeg convert failed: %s", exc)
return False
def _preprocess_audio(src: Path, noise_tail_ms: int = 0) -> Path:
"""Convert to 16 kHz mono. Optional: trim/use silence tail for profile denoise."""
mid = src.with_name(f"{src.stem}_ff.wav")
dst = src.with_name(f"{src.stem}_clean.wav")
if not _ffmpeg_to_mono16k(src, mid):
if src.suffix.lower() == ".wav":
mid = src
else:
return src
tail_ms = max(0, int(noise_tail_ms or 0))
# Default path: convert only (best STT on this hardware). Optionally trim tail.
if not settings.denoise:
try:
import wave
import numpy as np
if tail_ms > 0:
with wave.open(str(mid), "rb") as w:
if w.getsampwidth() == 2:
rate = w.getframerate()
nch = w.getnchannels()
frames = w.readframes(w.getnframes())
audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32)
if nch > 1:
audio = audio.reshape(-1, nch).mean(axis=1)
cut = int(rate * (tail_ms / 1000.0))
if cut > 0 and audio.size > cut + int(rate * 0.2):
_write_wav_mono_s16(dst, audio[:-cut], rate)
if mid != src:
try:
mid.unlink(missing_ok=True)
except OSError:
pass
log.info("audio preprocess: convert+trim tail=%sms → %s", tail_ms, dst.name)
return dst
except Exception as exc:
log.warning("tail trim failed: %s", exc)
if mid != dst:
try:
if mid != src:
mid.replace(dst)
else:
import shutil as _sh
_sh.copy2(mid, dst)
except OSError:
return mid
log.info("audio preprocess: convert only → %s", dst.name)
return dst
# denoise=true: subtract noise profile from post-roll tail, then trim
try:
import numpy as np
import noisereduce as nr
import wave
with wave.open(str(mid), "rb") as w:
if w.getsampwidth() != 2:
mid.replace(dst)
return dst
rate = w.getframerate()
nch = w.getnchannels()
frames = w.readframes(w.getnframes())
audio = np.frombuffer(frames, dtype=np.int16).astype(np.float32)
if nch > 1:
audio = audio.reshape(-1, nch).mean(axis=1)
tail_samples = int(rate * (tail_ms / 1000.0))
min_speech = int(rate * 0.2)
if tail_samples < int(rate * 0.15) or audio.size < (tail_samples + min_speech):
log.info("audio preprocess: no usable tail, convert only")
mid.replace(dst)
return dst
speech = audio[:-tail_samples]
noise = audio[-tail_samples:]
prop = max(0.0, min(0.95, float(settings.denoise_prop)))
reduced = nr.reduce_noise(
y=speech,
sr=rate,
y_noise=noise,
stationary=True,
prop_decrease=prop,
)
peak = float(np.max(np.abs(reduced))) if reduced.size else 0.0
if peak > 200:
gain = min(6.0, (0.7 * 32767.0) / peak)
reduced = reduced * gain
_write_wav_mono_s16(dst, reduced, rate)
try:
if mid != src:
mid.unlink(missing_ok=True)
except OSError:
pass
log.info(
"audio preprocess: profile denoise ok → %s (tail=%sms prop=%s)",
dst.name,
tail_ms,
prop,
)
return dst if dst.is_file() else src
except Exception as exc:
log.warning("profile denoise failed: %s", exc)
try:
if mid.exists():
mid.replace(dst)
return dst
except OSError:
pass
return mid if mid.exists() else src
def _to_wav(src: Path) -> Path:
"""Ensure mono 16-bit PCM WAV (already cleaned if *_clean.wav)."""
if src.name.endswith("_clean.wav"):
return src
return _preprocess_audio(src, noise_tail_ms=0)
async def _stt_openrouter(audio_path: Path) -> str:
if settings.mock_ai or not settings.openrouter_api_key:
return _fix_transcript(_mock_transcript(audio_path))
wav = await asyncio.to_thread(_to_wav, audio_path)
url = f"{settings.openrouter_base_url.rstrip('/')}/audio/transcriptions"
headers = {
"Authorization": f"Bearer {settings.openrouter_api_key}",
"HTTP-Referer": "https://buttontask.local",
"X-Title": "ButtonTask Demo",
}
async with _httpx_client() as client:
with wav.open("rb") as f:
files = {"file": (wav.name, f, "audio/wav")}
data = {
"model": settings.stt_model,
"language": "ru",
"prompt": STT_PROMPT,
"temperature": "0",
}
resp = await client.post(url, headers=headers, files=files, data=data)
if resp.status_code >= 400:
# Fallback: chat completions with base64 audio (some OpenRouter models)
text = await _stt_via_chat(wav)
if text:
return _fix_transcript(text)
raise HTTPException(status_code=502, detail=f"STT failed: {resp.status_code} {resp.text[:300]}")
payload = resp.json()
return _fix_transcript((payload.get("text") or "").strip())
async def _stt_via_chat(audio_path: Path) -> str:
"""Fallback STT through chat completions with input_audio."""
raw = audio_path.read_bytes()
b64 = base64.b64encode(raw).decode("ascii")
fmt = "wav" if audio_path.suffix.lower() == ".wav" else audio_path.suffix.lstrip(".") or "wav"
url = f"{settings.openrouter_base_url.rstrip('/')}/chat/completions"
headers = {
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://buttontask.local",
"X-Title": "ButtonTask Demo",
}
body = {
"model": settings.stt_model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "Transcribe this audio to Russian text. Reply with only the transcript."},
{"type": "input_audio", "input_audio": {"data": b64, "format": fmt}},
],
}
],
}
try:
async with _httpx_client() as client:
resp = await client.post(url, headers=headers, json=body)
if resp.status_code >= 400:
log.warning("chat STT failed: %s %s", resp.status_code, resp.text[:200])
return ""
data = resp.json()
return (data["choices"][0]["message"]["content"] or "").strip()
except Exception as exc:
log.warning("chat STT exception: %s", exc)
return ""
async def _classify_openrouter(text: str) -> Optional[str]:
cats = list(_categories)
if settings.mock_ai or not settings.openrouter_api_key:
return _mock_classify(text, cats)
url = f"{settings.openrouter_base_url.rstrip('/')}/chat/completions"
headers = {
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://buttontask.local",
"X-Title": "ButtonTask Demo",
}
body = {
"model": settings.classify_model,
"temperature": 0,
"messages": [
{"role": "system", "content": _classify_system_prompt(cats)},
{"role": "user", "content": text or "(тишина)"},
],
}
async with _httpx_client() as client:
resp = await client.post(url, headers=headers, json=body)
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"Classify failed: {resp.status_code} {resp.text[:300]}")
data = resp.json()
raw = (data["choices"][0]["message"]["content"] or "").strip()
return _normalize_category(raw, cats)
# ── Routes ──────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"events": list(_events)[:50],
"active": list(_active.values()),
"categories": list(_categories),
"mock_ai": settings.mock_ai or not settings.openrouter_api_key,
},
)
@app.get("/api/events")
async def api_events(limit: int = 50):
return {
"events": list(_events)[: max(1, min(limit, 200))],
"active": list(_active.values()),
"categories": list(_categories),
}
@app.get("/api/categories")
async def get_categories():
return {"categories": list(_categories)}
@app.put("/api/categories")
async def put_categories(request: Request):
data = await request.json()
cats = data.get("categories")
if not isinstance(cats, list):
raise HTTPException(status_code=400, detail="categories must be an array of strings")
global _categories
_categories = _save_categories(cats)
_push_event("categories_updated", categories=list(_categories))
return {"ok": True, "categories": list(_categories)}
@app.post("/api/v1/classify-audio")
async def classify_audio(
request: Request,
audio: UploadFile = File(...),
source_id: str = Form(...),
noise_tail_ms: int = Form(0),
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
):
_check_api_key(_extract_api_key(request, x_api_key))
if not audio.filename and not audio.content_type:
raise HTTPException(status_code=400, detail="No audio file provided")
request_id = str(uuid.uuid4())
suffix = Path(audio.filename or "audio.wav").suffix or ".wav"
if suffix.lower() not in (".wav", ".webm", ".ogg", ".mp3", ".m4a", ".flac", ".pcm", ".raw"):
suffix = ".wav"
audio_name = f"{request_id}{suffix}"
dest = UPLOAD_DIR / audio_name
content = await audio.read()
if not content:
raise HTTPException(status_code=400, detail="Empty audio file")
dest.write_bytes(content)
clean = await asyncio.to_thread(_preprocess_audio, dest, int(noise_tail_ms or 0))
audio_url = _audio_url(clean.name)
try:
transcript = await _stt_openrouter(clean)
if _is_silence(transcript):
category = None
reason = "silence"
else:
category = await _classify_openrouter(transcript)
reason = "ok" if category else "unmatched"
except HTTPException:
_push_event(
"voice_error",
source_id=source_id,
request_id=request_id,
error="STT/classify failed",
ip=request.client.host if request.client else "",
audio_url=audio_url,
)
raise
except Exception as exc:
log.exception("classify failed")
_push_event(
"voice_error",
source_id=source_id,
request_id=request_id,
error=str(exc),
ip=request.client.host if request.client else "",
audio_url=audio_url,
)
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(exc)},
)
ts = _now_iso()
ip = request.client.host if request.client else ""
# Warning: silence / no matching category — button shows warning and does not latch
if category is None:
message = "Тишина" if reason == "silence" else "Не распознано"
_push_event(
"voice_warning",
source_id=source_id,
request_id=request_id,
category=message,
request_text=transcript or "",
reason=reason,
ip=ip,
audio_url=audio_url,
)
return {
"status": "warning",
"request_id": request_id,
"category": message,
"message": message,
"timestamp": ts,
"source_id": source_id,
"request_text": transcript or "",
"needs_cancel": False,
"reason": reason,
"audio_url": audio_url,
}
call = {
"request_id": request_id,
"source_id": source_id,
"category": category,
"request_text": transcript,
"timestamp": ts,
"status": "active",
"ip": ip,
"audio_url": audio_url,
}
async with _lock:
_active[request_id] = call
_push_event(
"voice",
source_id=source_id,
request_id=request_id,
category=category,
request_text=transcript,
ip=ip,
audio_url=audio_url,
)
return {
"status": "success",
"request_id": request_id,
"category": category,
"timestamp": ts,
"source_id": source_id,
"request_text": transcript,
"needs_cancel": True,
"audio_url": audio_url,
}
@app.post("/api/v1/cancel")
async def cancel_call(
request: Request,
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
):
_check_api_key(_extract_api_key(request, x_api_key))
body: Dict[str, Any] = {}
ctype = (request.headers.get("content-type") or "").lower()
if "application/json" in ctype:
body = await request.json()
elif "application/x-www-form-urlencoded" in ctype or "multipart/form-data" in ctype:
form = await request.form()
body = {k: form.get(k) for k in form.keys()}
else:
raw = (await request.body()).decode("utf-8", errors="replace").strip()
if raw:
try:
body = json.loads(raw)
except json.JSONDecodeError:
body = {}
request_id = (body.get("request_id") or "").strip()
source_id = (body.get("source_id") or "").strip()
cancelled = None
async with _lock:
if request_id and request_id in _active:
cancelled = _active.pop(request_id)
elif source_id:
for rid, call in list(_active.items()):
if call.get("source_id") == source_id:
cancelled = _active.pop(rid)
break
if not cancelled:
_push_event("cancel_miss", request_id=request_id, source_id=source_id)
return {"status": "ok", "cancelled": False, "message": "no active call"}
cancelled["status"] = "cancelled"
_push_event(
"cancel",
request_id=cancelled.get("request_id"),
source_id=cancelled.get("source_id"),
category=cancelled.get("category"),
)
return {"status": "ok", "cancelled": True, "request_id": cancelled.get("request_id")}
def _log_press(request: Request, path: str, method: str, qs: str, body: str = "") -> Dict[str, Any]:
return _push_event(
"press",
path=path,
method=method,
query=qs,
body=body[:500] if body else "",
ip=request.client.host if request.client else "",
)
@app.api_route("/api/click/btn", methods=["GET", "POST"])
async def click_btn(request: Request):
qs = str(request.url.query)
body = ""
if request.method == "POST":
body = (await request.body()).decode("utf-8", errors="replace")
bid = request.query_params.get("bid", "")
action = "reset" if bid.endswith("*00") else ("trigger" if bid.endswith("*01") else "unknown")
_log_press(request, "/api/click/btn", request.method, qs, body)
_push_event("latch", bid=bid, action=action, ip=request.client.host if request.client else "")
return PlainTextResponse("OK")
@app.get("/ok")
async def ok_endpoint(request: Request):
_log_press(request, "/ok", "GET", "")
return PlainTextResponse("OK\n")
@app.get("/fail")
async def fail_endpoint(request: Request):
_log_press(request, "/fail", "GET", "")
return PlainTextResponse("FAIL\n", status_code=500)
@app.get("/cleaning")
async def cleaning(request: Request, result: str = "ok"):
_log_press(request, "/cleaning", "GET", f"result={result}")
return {"success": result, "service": "cleaning", "message": f"cleaning:{result}"}
@app.get("/healthz")
async def healthz():
return {"ok": True, "mock_ai": settings.mock_ai or not settings.openrouter_api_key}
@app.get("/echo")
async def echo(request: Request):
_log_press(request, "/echo", "GET", str(request.url.query))
return {"query": dict(request.query_params), "headers": dict(request.headers)}