Files
friday-voice/friday_voice/app.py
T

227 lines
7.3 KiB
Python

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 "<h1>FRIDAY Voice Console</h1><p>Static UI missing.</p>"
@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