Initial FRIDAY Voice Console scaffold
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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
|
||||
Reference in New Issue
Block a user