from __future__ import annotations import hashlib import json import urllib.error import urllib.request from pathlib import Path from typing import Any from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from .authorization import classify_voice_authorization from .config import Settings from .hermes_client import ask_hermes from .speaker_store import SpeakerStore settings = Settings.from_env() data_dir = Path(settings.data_dir) speaker_store = SpeakerStore(data_dir / "speaker_profiles.json") app = FastAPI(title="FRIDAY Voice Console", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) static_dir = Path(__file__).resolve().parent.parent / "web" if static_dir.exists(): app.mount("/static", StaticFiles(directory=static_dir), name="static") class AskRequest(BaseModel): text: str = Field(min_length=1) speaker_id: str | None = None confidence: float = 0.0 class AskResponse(BaseModel): reply: str authorization_level: str read_only: bool requires_confirmation: bool reason: str class IdentifyRequest(BaseModel): embedding: list[float] class EnrollRequest(BaseModel): speaker_id: str display_name: str | None = None source: str = "manual" embedding: list[float] @app.get("/", response_class=HTMLResponse) def index() -> str: index_path = static_dir / "index.html" if index_path.exists(): return index_path.read_text(encoding="utf-8") return "

FRIDAY Voice Console

Static UI missing.

" @app.get("/health") def health() -> dict[str, Any]: return { "status": "ok", "speaches_url": settings.speaches_url, "hermes_source": settings.hermes_source, "speaker_profiles": len(speaker_store.profiles), } @app.post("/api/ask", response_model=AskResponse) def ask(request: AskRequest) -> AskResponse: decision = classify_voice_authorization( request.speaker_id, request.confidence, settings, ) prompt = _build_prompt(request.text, decision.level, decision.read_only) try: reply = ask_hermes(prompt, settings) except Exception as exc: # pragma: no cover - integration boundary raise HTTPException(status_code=502, detail=f"Hermes call failed: {exc}") from exc return AskResponse( reply=reply, authorization_level=decision.level, read_only=decision.read_only, requires_confirmation=decision.requires_confirmation, reason=decision.reason, ) @app.post("/api/enroll") def enroll(request: EnrollRequest) -> dict[str, Any]: speaker_store.add_embedding( request.speaker_id, request.embedding, request.source, display_name=request.display_name, ) return {"ok": True, "speaker_id": request.speaker_id} @app.post("/api/identify") def identify(request: IdentifyRequest) -> dict[str, Any]: match = speaker_store.identify(request.embedding) decision = classify_voice_authorization(match.speaker_id, match.confidence, settings) return { "speaker_id": match.speaker_id, "display_name": match.display_name, "confidence": match.confidence, "authorization_level": decision.level, "read_only": decision.read_only, "requires_confirmation": decision.requires_confirmation, } @app.post("/api/transcribe") async def transcribe(request: Request) -> dict[str, Any]: payload = await request.body() if not payload: raise HTTPException(status_code=400, detail="empty audio upload") result = _post_multipart( f"{settings.speaches_url}/v1/audio/transcriptions", files={ "file": ("speech.webm", payload, request.headers.get("content-type", "audio/webm")), }, data={"model": settings.whisper_model}, ) return {"text": result.get("text", ""), "provider_response": result} @app.post("/api/speak") async def speak(request: Request) -> FileResponse: body = await request.json() text = str(body.get("text", "")) if not text.strip(): raise HTTPException(status_code=400, detail="empty text") payload = { "model": settings.tts_model, "input": text, "voice": settings.tts_voice, "response_format": "mp3", } audio = _post_json_bytes(f"{settings.speaches_url}/v1/audio/speech", payload) data_dir.mkdir(parents=True, exist_ok=True) digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] path = data_dir / f"tts-{digest}.mp3" path.write_bytes(audio) return FileResponse(path, media_type="audio/mpeg", filename=path.name) def _build_prompt(text: str, voice_level: str, read_only: bool) -> str: if read_only: safety = "Voice authorization is read-only; do not perform environment writes or reveal secrets." else: safety = "Voice authorization matched the boss; still require explicit confirmation for destructive or credential actions." return "\n".join( [ "[FRIDAY Voice Console]", "Voice tier: " + voice_level, "Safety: " + safety, "", "User said: " + text, ] ) def _post_json_bytes(url: str, payload: dict[str, Any]) -> bytes: request = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=120) as response: return response.read() except urllib.error.HTTPError as exc: # pragma: no cover - integration boundary detail = exc.read().decode("utf-8", "replace") raise HTTPException(status_code=502, detail=detail) from exc def _post_multipart( url: str, files: dict[str, tuple[str, bytes, str]], data: dict[str, str], ) -> dict[str, Any]: boundary = "----friday-voice-boundary" body = bytearray() for name, value in data.items(): body.extend(f"--{boundary}\r\n".encode()) body.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()) body.extend(str(value).encode()) body.extend(b"\r\n") for name, (filename, payload, content_type) in files.items(): body.extend(f"--{boundary}\r\n".encode()) body.extend( f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode() ) body.extend(f"Content-Type: {content_type}\r\n\r\n".encode()) body.extend(payload) body.extend(b"\r\n") body.extend(f"--{boundary}--\r\n".encode()) request = urllib.request.Request( url, data=bytes(body), headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=120) as response: return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: # pragma: no cover - integration boundary detail = exc.read().decode("utf-8", "replace") raise HTTPException(status_code=502, detail=detail) from exc