227 lines
7.3 KiB
Python
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
|