Initial FRIDAY Voice Console scaffold
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
"""FRIDAY Voice Console backend package."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.0"
|
||||
@@ -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
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .config import Settings
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorizationDecision:
|
||||
level: str
|
||||
read_only: bool
|
||||
requires_confirmation: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def classify_voice_authorization(
|
||||
speaker_id: str | None,
|
||||
confidence: float,
|
||||
settings: Settings | None = None,
|
||||
) -> AuthorizationDecision:
|
||||
"""Map speaker identity confidence to a safety tier.
|
||||
|
||||
Voice identity is advisory. Dangerous changes still require explicit approval
|
||||
elsewhere in the protocol.
|
||||
"""
|
||||
|
||||
settings = settings or Settings()
|
||||
if not speaker_id:
|
||||
return AuthorizationDecision(
|
||||
level="unknown",
|
||||
read_only=True,
|
||||
requires_confirmation=True,
|
||||
reason="No enrolled speaker matched this utterance.",
|
||||
)
|
||||
|
||||
if confidence >= settings.speaker_confirmed_threshold:
|
||||
return AuthorizationDecision(
|
||||
level="confirmed",
|
||||
read_only=False,
|
||||
requires_confirmation=False,
|
||||
reason=f"Speaker {speaker_id} matched above confirmed threshold.",
|
||||
)
|
||||
|
||||
if confidence >= settings.speaker_probable_threshold:
|
||||
return AuthorizationDecision(
|
||||
level="probable",
|
||||
read_only=False,
|
||||
requires_confirmation=True,
|
||||
reason=f"Speaker {speaker_id} matched above probable threshold only.",
|
||||
)
|
||||
|
||||
return AuthorizationDecision(
|
||||
level="unknown",
|
||||
read_only=True,
|
||||
requires_confirmation=True,
|
||||
reason=f"Speaker {speaker_id} confidence below probable threshold.",
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
DEFAULT_SPEACHES_URL = "http://192.168.86.150:8000"
|
||||
DEFAULT_HERMES_COMMAND = "hermes"
|
||||
DEFAULT_HERMES_SOURCE = "friday-voice"
|
||||
DEFAULT_CONFIRMED_THRESHOLD = 0.85
|
||||
DEFAULT_PROBABLE_THRESHOLD = 0.70
|
||||
DEFAULT_DATA_DIR = "/data"
|
||||
DEFAULT_WHISPER_MODEL = "deepdml/faster-whisper-large-v3-turbo-ct2"
|
||||
DEFAULT_TTS_MODEL = "speaches-ai/Kokoro-82M-v1.0-ONNX"
|
||||
DEFAULT_TTS_VOICE = "af_nova"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Settings:
|
||||
"""Runtime settings for the FRIDAY voice gateway.
|
||||
|
||||
Defaults match Jeremy's residence services from the wiki crawl.
|
||||
"""
|
||||
|
||||
speaches_url: str = DEFAULT_SPEACHES_URL
|
||||
hermes_command: str = DEFAULT_HERMES_COMMAND
|
||||
hermes_source: str = DEFAULT_HERMES_SOURCE
|
||||
speaker_confirmed_threshold: float = DEFAULT_CONFIRMED_THRESHOLD
|
||||
speaker_probable_threshold: float = DEFAULT_PROBABLE_THRESHOLD
|
||||
data_dir: str = DEFAULT_DATA_DIR
|
||||
whisper_model: str = DEFAULT_WHISPER_MODEL
|
||||
tts_model: str = DEFAULT_TTS_MODEL
|
||||
tts_voice: str = DEFAULT_TTS_VOICE
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, environ: dict[str, str] | None = None) -> "Settings":
|
||||
import os
|
||||
|
||||
env = os.environ if environ is None else environ
|
||||
return cls(
|
||||
speaches_url=env.get("SPEACHES_URL", DEFAULT_SPEACHES_URL),
|
||||
hermes_command=env.get("HERMES_COMMAND", DEFAULT_HERMES_COMMAND),
|
||||
hermes_source=env.get("HERMES_SOURCE", DEFAULT_HERMES_SOURCE),
|
||||
data_dir=env.get("DATA_DIR", DEFAULT_DATA_DIR),
|
||||
whisper_model=env.get("WHISPER_MODEL", DEFAULT_WHISPER_MODEL),
|
||||
tts_model=env.get("TTS_MODEL", DEFAULT_TTS_MODEL),
|
||||
tts_voice=env.get("TTS_VOICE", DEFAULT_TTS_VOICE),
|
||||
speaker_confirmed_threshold=float(
|
||||
env.get("SPEAKER_CONFIRMED_THRESHOLD", DEFAULT_CONFIRMED_THRESHOLD)
|
||||
),
|
||||
speaker_probable_threshold=float(
|
||||
env.get("SPEAKER_PROBABLE_THRESHOLD", DEFAULT_PROBABLE_THRESHOLD)
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from .config import Settings
|
||||
|
||||
|
||||
def build_cli_command(text: str, settings: Settings) -> list[str]:
|
||||
return [
|
||||
settings.hermes_command,
|
||||
"chat",
|
||||
"-q",
|
||||
text,
|
||||
"--source",
|
||||
settings.hermes_source,
|
||||
]
|
||||
|
||||
|
||||
def ask_hermes(text: str, settings: Settings) -> str:
|
||||
command = build_cli_command(text, settings)
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
def cosine_similarity(left: Iterable[float], right: Iterable[float]) -> float:
|
||||
a = [float(x) for x in left]
|
||||
b = [float(x) for x in right]
|
||||
if len(a) != len(b) or not a:
|
||||
return 0.0
|
||||
dot = sum(x * y for x, y in zip(a, b, strict=True))
|
||||
norm_a = math.sqrt(sum(x * x for x in a))
|
||||
norm_b = math.sqrt(sum(y * y for y in b))
|
||||
if norm_a == 0.0 or norm_b == 0.0:
|
||||
return 0.0
|
||||
return dot / (norm_a * norm_b)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SpeakerEmbedding:
|
||||
vector: list[float]
|
||||
source: str
|
||||
created_at: float = field(default_factory=time)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SpeakerProfile:
|
||||
speaker_id: str
|
||||
display_name: str
|
||||
embeddings: list[SpeakerEmbedding] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SpeakerMatch:
|
||||
speaker_id: str | None
|
||||
confidence: float
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class SpeakerStore:
|
||||
"""Small JSON-backed speaker profile store for the MVP.
|
||||
|
||||
This intentionally stores embeddings, not raw audio. Raw clip retention can be
|
||||
added later for unknown-speaker review.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self.profiles: dict[str, SpeakerProfile] = {}
|
||||
self._load()
|
||||
|
||||
def add_embedding(
|
||||
self,
|
||||
speaker_id: str,
|
||||
vector: Iterable[float],
|
||||
source: str,
|
||||
display_name: str | None = None,
|
||||
) -> None:
|
||||
profile = self.profiles.get(speaker_id)
|
||||
if profile is None:
|
||||
profile = SpeakerProfile(
|
||||
speaker_id=speaker_id,
|
||||
display_name=display_name or speaker_id,
|
||||
)
|
||||
self.profiles[speaker_id] = profile
|
||||
profile.embeddings.append(
|
||||
SpeakerEmbedding(vector=[float(x) for x in vector], source=source)
|
||||
)
|
||||
self.save()
|
||||
|
||||
def identify(self, vector: Iterable[float]) -> SpeakerMatch:
|
||||
probe = [float(x) for x in vector]
|
||||
best = SpeakerMatch(speaker_id=None, confidence=0.0)
|
||||
for profile in self.profiles.values():
|
||||
for embedding in profile.embeddings:
|
||||
score = cosine_similarity(probe, embedding.vector)
|
||||
if score > best.confidence:
|
||||
best = SpeakerMatch(
|
||||
speaker_id=profile.speaker_id,
|
||||
display_name=profile.display_name,
|
||||
confidence=score,
|
||||
)
|
||||
return best
|
||||
|
||||
def save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {speaker_id: asdict(profile) for speaker_id, profile in self.profiles.items()}
|
||||
self.path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.path.exists():
|
||||
return
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
for speaker_id, profile_data in raw.items():
|
||||
embeddings = [
|
||||
SpeakerEmbedding(**embedding)
|
||||
for embedding in profile_data.get("embeddings", [])
|
||||
]
|
||||
self.profiles[speaker_id] = SpeakerProfile(
|
||||
speaker_id=profile_data.get("speaker_id", speaker_id),
|
||||
display_name=profile_data.get("display_name", speaker_id),
|
||||
embeddings=embeddings,
|
||||
)
|
||||
Reference in New Issue
Block a user