feat: tier-aware sessions, session pinning, translation, audio settings, voice status
Backend: - tiers.py: real license validation via Heimdall (self-hosted key or cloud user_id), 30-min cache, BYOK exception for translation - session_store.py: create_session() accepts tier/user_id/window_ms/transcribe_lang/ num_speakers; tier-aware _should_reap() (Free: reap on subscriber leave, Paid: activity TTL); auto-snapshot paid sessions with history on reap; voice-ready probe on session start - services/translator.py: DeepL-backed label translation, CF-managed and BYOK key paths, per-session label cache - models/session.py, scene_event.py, service_event.py: new event types and session fields - api/snapshots.py: GET /sessions/history + POST /session/resume (Paid tier only) - api/dev.py: dev-only /dev/voice/stop|start|restart proxy to cf-orch agent - migrations/002_session_snapshots.sql: session_snapshots table Frontend: - ComposeBar.vue: pre-session settings panel (window, language, speakers, Elcor toggle) with slide transition; passes options to startSession() - stores/settings.ts: persistent user preferences (localStorage), survives page reload - stores/session.ts: voiceReady/voiceError/voiceWarning states; SceneEvent/AccentEvent/ ServiceEvent types; TTL timers for environ/queue badges; startSession() accepts opts - composables/useToneStream.ts: handlers for scene-event, accent-event, service-event SSE - NowPanel.vue: diarization speaker identity badge, acoustic scene badge, privacy risk indicator - HistoryStrip.vue: per-speaker colour chips in history strip - components/DevPanel.vue: dev-only cf-voice stop/restart controls + thread view toggle - App.vue: DevPanel in footer (DEV only), threadView flag switches v0.1.x/v0.2.x preview Tests: - tests/test_pinning.py: 20 tests covering tier-aware reaping, snapshot creation, and translator behaviour - tests/test_tiers.py: 17 tests covering key validation, cloud resolution, caching, require_paid() gate, BYOK exception - All 72 tests passing
This commit is contained in:
parent
6e7cf17b90
commit
b92285b4b3
29 changed files with 2372 additions and 136 deletions
16
.env.example
16
.env.example
|
|
@ -1,6 +1,20 @@
|
|||
LINNET_LICENSE_KEY=
|
||||
CF_VOICE_MOCK=1
|
||||
CF_VOICE_MOCK=0
|
||||
CF_VOICE_WHISPER_MODEL=small
|
||||
CF_VOICE_DIARIZE=0
|
||||
HF_TOKEN=
|
||||
LINNET_PORT=8522
|
||||
LINNET_FRONTEND_PORT=8521
|
||||
|
||||
# cf-orch coordinator URL — enables real cf-voice sidecar allocation per session.
|
||||
# Leave empty to fall back to in-process mock (also set CF_VOICE_MOCK=1 for mock audio).
|
||||
CF_ORCH_URL=
|
||||
|
||||
# cf-orch agent URL — local service lifecycle (stop/start).
|
||||
# Coordinator (:7700) = read/scheduling; Agent (:7701) = write operations.
|
||||
CF_ORCH_AGENT_URL=
|
||||
|
||||
# Vite dev server base paths — set when serving under a sub-path (e.g. /linnet/ via Caddy).
|
||||
# Leave empty for root-path local dev (http://localhost:8521).
|
||||
VITE_BASE_URL=
|
||||
VITE_API_BASE=
|
||||
|
|
|
|||
169
.nfs0000000000bc6a8c00021df0
Executable file
169
.nfs0000000000bc6a8c00021df0
Executable file
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env bash
|
||||
# manage.sh — Linnet dev/demo/cloud management script
|
||||
set -euo pipefail
|
||||
|
||||
CMD=${1:-help}
|
||||
PROFILE=${2:-dev} # dev | demo | cloud
|
||||
API_PORT=${LINNET_PORT:-8522}
|
||||
FE_PORT=${LINNET_FRONTEND_PORT:-8521}
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[linnet]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[linnet]${NC} $*"; }
|
||||
error() { echo -e "${RED}[linnet]${NC} $*" >&2; exit 1; }
|
||||
|
||||
_check_env() {
|
||||
if [[ ! -f .env ]]; then
|
||||
warn "No .env found — copying .env.example"
|
||||
cp .env.example .env
|
||||
fi
|
||||
}
|
||||
|
||||
_compose_cmd() {
|
||||
# Returns the correct compose file flags for a given profile
|
||||
case "$1" in
|
||||
demo) echo "-f compose.demo.yml -p linnet-demo" ;;
|
||||
cloud) echo "-f compose.cloud.yml -p linnet-cloud" ;;
|
||||
test) echo "-f compose.test.yml" ;;
|
||||
dev) echo "-f compose.yml" ;;
|
||||
*) error "Unknown profile: $1. Use dev|demo|cloud|test." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Commands ──────────────────────────────────────────────────────────────────
|
||||
case "$CMD" in
|
||||
|
||||
start)
|
||||
_check_env
|
||||
if [[ "$PROFILE" == "dev" ]]; then
|
||||
# Load .env into the current shell so uvicorn and Vite inherit all settings
|
||||
# (CF_VOICE_MOCK, CF_ORCH_URL, VITE_BASE_URL, VITE_API_BASE, etc.)
|
||||
set -a; source .env; set +a
|
||||
info "Starting Linnet API on :${API_PORT}…"
|
||||
conda run -n cf uvicorn app.main:app \
|
||||
--host 0.0.0.0 --port "$API_PORT" --reload &
|
||||
info "Starting Linnet frontend on :${FE_PORT}…"
|
||||
cd frontend && npm install --silent && npm run dev &
|
||||
info "API: http://localhost:${API_PORT}"
|
||||
info "UI: http://localhost:${FE_PORT}"
|
||||
wait
|
||||
else
|
||||
FLAGS=$(_compose_cmd "$PROFILE")
|
||||
info "Starting Linnet ($PROFILE profile)…"
|
||||
# shellcheck disable=SC2086
|
||||
docker compose $FLAGS up -d --build
|
||||
case "$PROFILE" in
|
||||
demo) info "Frontend: http://localhost:8524" ;;
|
||||
cloud) info "Frontend: http://localhost:8527 → menagerie.circuitforge.tech/linnet" ;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
|
||||
stop)
|
||||
if [[ "$PROFILE" == "dev" ]]; then
|
||||
info "Stopping Linnet dev processes…"
|
||||
pkill -f "uvicorn app.main:app" 2>/dev/null || true
|
||||
pkill -f "vite" 2>/dev/null || true
|
||||
else
|
||||
FLAGS=$(_compose_cmd "$PROFILE")
|
||||
# shellcheck disable=SC2086
|
||||
docker compose $FLAGS down
|
||||
fi
|
||||
info "Stopped ($PROFILE)."
|
||||
;;
|
||||
|
||||
restart)
|
||||
"$0" stop "$PROFILE"
|
||||
"$0" start "$PROFILE"
|
||||
;;
|
||||
|
||||
status)
|
||||
case "$PROFILE" in
|
||||
dev)
|
||||
echo -n "API: "
|
||||
curl -sf "http://localhost:${API_PORT}/health" 2>/dev/null \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'], '|', d.get('mode','dev'))" \
|
||||
|| echo "not running"
|
||||
echo -n "Frontend: "
|
||||
curl -sf "http://localhost:${FE_PORT}" -o /dev/null && echo "running" || echo "not running"
|
||||
;;
|
||||
demo)
|
||||
echo -n "Demo API: "; curl -sf "http://localhost:8523/health" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'])" || echo "not running"
|
||||
echo -n "Demo Web: "; curl -sf "http://localhost:8524" -o /dev/null && echo "running" || echo "not running"
|
||||
;;
|
||||
cloud)
|
||||
echo -n "Cloud API: "; curl -sf "http://localhost:8522/health" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'])" || echo "not running"
|
||||
echo -n "Cloud Web: "; curl -sf "http://localhost:8527" -o /dev/null && echo "running" || echo "not running"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
|
||||
test)
|
||||
_check_env
|
||||
if [[ "$PROFILE" == "dev" ]]; then
|
||||
info "Running test suite (local)…"
|
||||
CF_VOICE_MOCK=1 conda run -n cf python -m pytest tests/ -v "${@:3}"
|
||||
else
|
||||
info "Running test suite (Docker)…"
|
||||
docker compose -f compose.test.yml run --rm linnet-test "${@:3}"
|
||||
fi
|
||||
;;
|
||||
|
||||
logs)
|
||||
if [[ "$PROFILE" == "dev" ]]; then
|
||||
warn "Dev mode: check terminal output."
|
||||
else
|
||||
FLAGS=$(_compose_cmd "$PROFILE")
|
||||
SERVICE=${3:-}
|
||||
# shellcheck disable=SC2086
|
||||
docker compose $FLAGS logs -f $SERVICE
|
||||
fi
|
||||
;;
|
||||
|
||||
build)
|
||||
info "Building Docker images ($PROFILE)…"
|
||||
FLAGS=$(_compose_cmd "$PROFILE")
|
||||
# shellcheck disable=SC2086
|
||||
docker compose $FLAGS build
|
||||
info "Build complete."
|
||||
;;
|
||||
|
||||
open)
|
||||
case "$PROFILE" in
|
||||
demo) URL="http://localhost:8524" ;;
|
||||
cloud) URL="http://localhost:8527" ;;
|
||||
*) URL="http://localhost:${FE_PORT}" ;;
|
||||
esac
|
||||
xdg-open "$URL" 2>/dev/null || open "$URL" 2>/dev/null || info "Open $URL in your browser"
|
||||
;;
|
||||
|
||||
help|*)
|
||||
echo ""
|
||||
echo " Usage: $0 <command> [profile]"
|
||||
echo ""
|
||||
echo " Commands:"
|
||||
echo -e " ${GREEN}start${NC} [profile] Start API + frontend"
|
||||
echo -e " ${GREEN}stop${NC} [profile] Stop services"
|
||||
echo -e " ${GREEN}restart${NC} [profile] Stop then start"
|
||||
echo -e " ${GREEN}status${NC} [profile] Check running services"
|
||||
echo -e " ${GREEN}test${NC} [profile] Run pytest suite"
|
||||
echo -e " ${GREEN}logs${NC} [profile] Tail logs (Docker profiles only)"
|
||||
echo -e " ${GREEN}build${NC} [profile] Build Docker images"
|
||||
echo -e " ${GREEN}open${NC} [profile] Open UI in browser"
|
||||
echo ""
|
||||
echo " Profiles:"
|
||||
echo " dev (default) Local uvicorn + Vite dev server, mock mode"
|
||||
echo " demo Docker: DEMO_MODE=true, port 8524"
|
||||
echo " cloud Docker: CLOUD_MODE=true, port 8527 → menagerie.circuitforge.tech/linnet"
|
||||
echo " test Docker: pytest suite, hermetic"
|
||||
echo ""
|
||||
echo " Examples:"
|
||||
echo " $0 start # dev mode"
|
||||
echo " $0 start demo # demo stack"
|
||||
echo " $0 test # local pytest"
|
||||
echo " $0 test docker # pytest in Docker"
|
||||
echo " $0 logs demo # demo logs"
|
||||
echo ""
|
||||
;;
|
||||
esac
|
||||
|
|
@ -148,3 +148,7 @@ Linnet is developed and maintained on Forgejo at [git.opensourcesolarpunk.com/Ci
|
|||
---
|
||||
|
||||
*Linnet is a product of [CircuitForge LLC](https://circuitforge.tech) — privacy-first, safety-first, accessible AI tools for the tasks the system made hard on purpose.*
|
||||
|
||||
---
|
||||
|
||||
Humans own design, architecture, code review, testing, and verification. LLMs are part of our development workflow. [Our positions on LLM use →](https://circuitforge.tech/positions)
|
||||
|
|
|
|||
79
app/api/dev.py
Normal file
79
app/api/dev.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# app/api/dev.py — dev-only endpoints for local service management
|
||||
#
|
||||
# Proxies stop/start calls to the cf-orch agent so the frontend dev panel
|
||||
# doesn't need to know the agent URL directly.
|
||||
# Not registered in cloud or demo mode.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/dev", tags=["dev"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _agent_url() -> str:
|
||||
url = settings.cf_orch_agent_url
|
||||
if not url:
|
||||
raise HTTPException(503, detail="CF_ORCH_AGENT_URL not configured")
|
||||
return url.rstrip("/")
|
||||
|
||||
|
||||
@router.post("/voice/stop")
|
||||
async def voice_stop() -> dict:
|
||||
"""Stop the cf-voice sidecar via the cf-orch agent."""
|
||||
agent = _agent_url()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
resp = await client.post(f"{agent}/services/cf-voice/stop", json={})
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(502, detail=f"Agent unreachable: {exc}") from exc
|
||||
if resp.status_code >= 400:
|
||||
raise HTTPException(resp.status_code, detail=resp.text)
|
||||
logger.info("dev: cf-voice stop → %s", resp.status_code)
|
||||
return {"ok": True, "action": "stop"}
|
||||
|
||||
|
||||
@router.post("/voice/start")
|
||||
async def voice_start() -> dict:
|
||||
"""Start the cf-voice sidecar via the cf-orch agent."""
|
||||
agent = _agent_url()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
resp = await client.post(f"{agent}/services/cf-voice/start", json={})
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(502, detail=f"Agent unreachable: {exc}") from exc
|
||||
if resp.status_code >= 400:
|
||||
raise HTTPException(resp.status_code, detail=resp.text)
|
||||
logger.info("dev: cf-voice start → %s", resp.status_code)
|
||||
return {"ok": True, "action": "start"}
|
||||
|
||||
|
||||
@router.post("/voice/restart")
|
||||
async def voice_restart() -> dict:
|
||||
"""Stop then start cf-voice via the cf-orch agent."""
|
||||
import asyncio
|
||||
|
||||
agent = _agent_url()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
await client.post(f"{agent}/services/cf-voice/stop", json={})
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(502, detail=f"Agent unreachable on stop: {exc}") from exc
|
||||
|
||||
# Give the process time to fully exit before start fires
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
resp = await client.post(f"{agent}/services/cf-voice/start", json={})
|
||||
except httpx.RequestError as exc:
|
||||
raise HTTPException(502, detail=f"Agent unreachable on start: {exc}") from exc
|
||||
if resp.status_code >= 400:
|
||||
raise HTTPException(resp.status_code, detail=resp.text)
|
||||
logger.info("dev: cf-voice restart complete")
|
||||
return {"ok": True, "action": "restart"}
|
||||
|
|
@ -3,11 +3,15 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.models.service_event import ServiceEvent
|
||||
from app.models.tone_event import ToneEvent
|
||||
from app.services import session_store
|
||||
from app.services.translator import Translator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/session", tags=["events"])
|
||||
|
|
@ -22,6 +26,10 @@ async def stream_events(session_id: str, request: Request) -> StreamingResponse:
|
|||
const es = new EventSource(`/session/${sessionId}/stream`)
|
||||
es.addEventListener('tone-event', e => { ... })
|
||||
|
||||
If the session has a target_lang set, ToneEvent labels are translated via
|
||||
DeepL before emission. Labels are cached per-session — each unique label
|
||||
is translated once regardless of how often it appears.
|
||||
|
||||
The stream runs until the client disconnects or the session ends.
|
||||
"""
|
||||
session = session_store.get_session(session_id)
|
||||
|
|
@ -29,16 +37,32 @@ async def stream_events(session_id: str, request: Request) -> StreamingResponse:
|
|||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
|
||||
queue = session.subscribe()
|
||||
translator = Translator.for_session(session)
|
||||
|
||||
async def generator():
|
||||
try:
|
||||
# Heartbeat comment every 15s to keep connection alive through proxies
|
||||
yield ": heartbeat\n\n"
|
||||
|
||||
# If the voice probe already completed before this subscriber connected,
|
||||
# emit the result immediately rather than waiting for a queue event
|
||||
# that was already broadcast to an empty subscriber list.
|
||||
if session.voice_ready:
|
||||
yield ServiceEvent(session_id=session_id, status="ready").to_sse()
|
||||
elif session.voice_error:
|
||||
yield ServiceEvent(
|
||||
session_id=session_id, status="error", detail=session.voice_error,
|
||||
).to_sse()
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=15.0)
|
||||
# Translate tone label if a translator is configured for this session
|
||||
if translator is not None and isinstance(event, ToneEvent):
|
||||
translated = translator.translate(event.label)
|
||||
if translated != event.label:
|
||||
event = replace(event, label=translated)
|
||||
yield event.to_sse()
|
||||
except asyncio.TimeoutError:
|
||||
yield ": heartbeat\n\n"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
# app/api/sessions.py — session lifecycle endpoints
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import tiers
|
||||
from app.config import settings
|
||||
from app.services import session_store
|
||||
|
||||
router = APIRouter(prefix="/session", tags=["sessions"])
|
||||
|
|
@ -11,22 +13,61 @@ router = APIRouter(prefix="/session", tags=["sessions"])
|
|||
|
||||
class StartRequest(BaseModel):
|
||||
elcor: bool = False # enable Elcor subtext format (easter egg)
|
||||
# Self-hosted installs may pass their license key per-request instead of via env var.
|
||||
# Cloud mode ignores this field — tier is resolved from the authenticated user_id.
|
||||
license_key: str | None = None
|
||||
# Translation: BCP-47 target language tag (e.g. "es", "fr", "de").
|
||||
# Empty string or omitted = no translation.
|
||||
target_lang: str = ""
|
||||
# BYOK DeepL API key — user's own DeepL Free key.
|
||||
# Bypasses the Paid tier gate for translation only (see tiers.py BYOK_UNLOCKABLE).
|
||||
byok_deepl_key: str = ""
|
||||
# Audio classification tuning.
|
||||
# window_ms: PCM accumulation window before each /classify call (multiple of 100).
|
||||
# Longer = more Whisper context = better accuracy, higher latency. Default 1000ms.
|
||||
# transcribe_lang: BCP-47 hint for Whisper ("en", "es", …). Empty = auto-detect.
|
||||
# num_speakers: hint for pyannote diarization. 0 = auto-detect; 1–8 = fixed count.
|
||||
# Fixed count (e.g. 2) improves boundary accuracy for known speaker setups.
|
||||
window_ms: int = 1000
|
||||
transcribe_lang: str = ""
|
||||
num_speakers: int = 0
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
session_id: str
|
||||
state: str
|
||||
elcor: bool
|
||||
tier: str # "free" | "paid" | "premium"
|
||||
|
||||
|
||||
@router.post("/start", response_model=SessionResponse)
|
||||
async def start_session(req: StartRequest = StartRequest()) -> SessionResponse:
|
||||
async def start_session(
|
||||
request: Request,
|
||||
req: StartRequest = StartRequest(),
|
||||
) -> SessionResponse:
|
||||
"""Start a new annotation session and begin streaming VoiceFrames."""
|
||||
session = await session_store.create_session(elcor=req.elcor)
|
||||
# In cloud mode, user_id was set by CloudAuthMiddleware.
|
||||
# In self-hosted mode, fall back to the per-request key or env var.
|
||||
user_id: str | None = getattr(request.state, "cf_user", None) if settings.cloud_mode else None
|
||||
license_key: str | None = None if settings.cloud_mode else req.license_key
|
||||
|
||||
tier = tiers.get_tier(license_key=license_key, user_id=user_id)
|
||||
|
||||
session = await session_store.create_session(
|
||||
elcor=req.elcor,
|
||||
tier=tier,
|
||||
user_id=user_id or "",
|
||||
target_lang=req.target_lang,
|
||||
byok_deepl_key=req.byok_deepl_key,
|
||||
window_ms=max(100, (req.window_ms // 100) * 100), # round down to nearest 100ms
|
||||
transcribe_lang=req.transcribe_lang,
|
||||
num_speakers=max(0, min(8, req.num_speakers)), # clamp 0–8
|
||||
)
|
||||
return SessionResponse(
|
||||
session_id=session.session_id,
|
||||
state=session.state,
|
||||
elcor=session.elcor,
|
||||
tier=tier,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -40,12 +81,16 @@ async def end_session(session_id: str) -> dict:
|
|||
|
||||
|
||||
@router.get("/{session_id}")
|
||||
def get_session(session_id: str) -> SessionResponse:
|
||||
def get_session(request: Request, session_id: str) -> SessionResponse:
|
||||
session = session_store.get_session(session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||||
user_id: str | None = getattr(request.state, "cf_user", None) if settings.cloud_mode else None
|
||||
license_key: str | None = None if settings.cloud_mode else request.headers.get("X-CF-License-Key")
|
||||
tier = tiers.get_tier(license_key=license_key, user_id=user_id)
|
||||
return SessionResponse(
|
||||
session_id=session.session_id,
|
||||
state=session.state,
|
||||
elcor=session.elcor,
|
||||
tier=tier,
|
||||
)
|
||||
|
|
|
|||
156
app/api/snapshots.py
Normal file
156
app/api/snapshots.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# app/api/snapshots.py — session snapshot list and resume (Paid tier)
|
||||
#
|
||||
# Snapshots are saved automatically when a Paid session ends with history.
|
||||
# Users can list their past sessions and resume them (imports history into a new session).
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import tiers
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.services import session_store
|
||||
|
||||
router = APIRouter(tags=["snapshots"])
|
||||
|
||||
|
||||
def _resolve_user_id(request: Request) -> str:
|
||||
"""Return the cloud user_id from request state, or empty string for self-hosted."""
|
||||
return getattr(request.state, "cf_user", "") if settings.cloud_mode else ""
|
||||
|
||||
|
||||
# ── List snapshots ────────────────────────────────────────────────────────────
|
||||
|
||||
class SnapshotSummary(BaseModel):
|
||||
id: str
|
||||
session_id: str
|
||||
created_at: float
|
||||
ended_at: float
|
||||
elcor: bool
|
||||
event_count: int
|
||||
|
||||
|
||||
@router.get("/sessions/history", response_model=list[SnapshotSummary])
|
||||
def list_snapshots(
|
||||
request: Request,
|
||||
limit: int = 20,
|
||||
conn=Depends(get_db),
|
||||
) -> list[SnapshotSummary]:
|
||||
"""
|
||||
Return the user's last N saved sessions (Paid tier only).
|
||||
|
||||
Self-hosted: scoped to the instance (user_id = '').
|
||||
Cloud: scoped to the authenticated user.
|
||||
"""
|
||||
user_id = _resolve_user_id(request)
|
||||
license_key = None if settings.cloud_mode else request.headers.get("X-CF-License-Key")
|
||||
tiers.require_paid(license_key=license_key, user_id=user_id or None)
|
||||
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, session_id, created_at, ended_at, elcor, event_count
|
||||
FROM session_snapshots
|
||||
WHERE user_id = ?
|
||||
ORDER BY ended_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(user_id, min(limit, 100)),
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
SnapshotSummary(
|
||||
id=row[0],
|
||||
session_id=row[1],
|
||||
created_at=row[2],
|
||||
ended_at=row[3],
|
||||
elcor=bool(row[4]),
|
||||
event_count=row[5],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
# ── Resume a snapshot ─────────────────────────────────────────────────────────
|
||||
|
||||
class ResumeRequest(BaseModel):
|
||||
snapshot_id: str
|
||||
elcor: bool | None = None # override Elcor mode; None = use snapshot's setting
|
||||
target_lang: str = ""
|
||||
byok_deepl_key: str = ""
|
||||
license_key: str | None = None # self-hosted key
|
||||
|
||||
|
||||
class ResumeResponse(BaseModel):
|
||||
session_id: str
|
||||
state: str
|
||||
elcor: bool
|
||||
tier: str
|
||||
resumed_from: str # snapshot_id
|
||||
event_count: int # number of historical events injected
|
||||
|
||||
|
||||
@router.post("/session/resume", response_model=ResumeResponse)
|
||||
async def resume_session(
|
||||
request: Request,
|
||||
req: ResumeRequest,
|
||||
conn=Depends(get_db),
|
||||
) -> ResumeResponse:
|
||||
"""
|
||||
Resume a previous session by restoring its annotation history into a new session.
|
||||
|
||||
The new session starts fresh (new session_id, new classifier) but is pre-populated
|
||||
with the historical ToneEvents from the snapshot so the frontend can display them.
|
||||
|
||||
Paid tier only. The snapshot must belong to the authenticated user.
|
||||
"""
|
||||
user_id = _resolve_user_id(request)
|
||||
license_key = None if settings.cloud_mode else req.license_key
|
||||
tiers.require_paid(license_key=license_key, user_id=user_id or None)
|
||||
|
||||
tier = tiers.get_tier(license_key=license_key, user_id=user_id or None)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT session_id, elcor, events_json FROM session_snapshots WHERE id = ? AND user_id = ?",
|
||||
(req.snapshot_id, user_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Snapshot not found")
|
||||
|
||||
orig_session_id, orig_elcor, events_json = row
|
||||
use_elcor = req.elcor if req.elcor is not None else bool(orig_elcor)
|
||||
|
||||
session = await session_store.create_session(
|
||||
elcor=use_elcor,
|
||||
tier=tier,
|
||||
user_id=user_id,
|
||||
target_lang=req.target_lang,
|
||||
byok_deepl_key=req.byok_deepl_key,
|
||||
)
|
||||
|
||||
# Inject historical events so the frontend can render the prior session
|
||||
from app.models.tone_event import ToneEvent
|
||||
events_data = json.loads(events_json)
|
||||
for ev in events_data:
|
||||
tone = ToneEvent(
|
||||
session_id=session.session_id,
|
||||
label=ev["label"],
|
||||
confidence=ev["confidence"],
|
||||
speaker_id=ev.get("speaker_id", "speaker_a"),
|
||||
shift_magnitude=ev.get("shift_magnitude", 0.0),
|
||||
timestamp=ev["timestamp"],
|
||||
subtext=ev.get("subtext", ""),
|
||||
affect=ev.get("affect", ""),
|
||||
)
|
||||
session.history.append(tone)
|
||||
|
||||
return ResumeResponse(
|
||||
session_id=session.session_id,
|
||||
state=session.state,
|
||||
elcor=session.elcor,
|
||||
tier=tier,
|
||||
resumed_from=req.snapshot_id,
|
||||
event_count=len(events_data),
|
||||
)
|
||||
|
|
@ -15,17 +15,27 @@ class Settings:
|
|||
# DEMO: auto-kill sessions after this many seconds of inactivity
|
||||
demo_session_ttl_s: int = int(os.getenv("DEMO_SESSION_TTL_S", "300")) # 5 min
|
||||
|
||||
# All modes: kill a session this many seconds after its last SSE subscriber
|
||||
# disconnects. Covers mobile tab timeout / screen lock / crash.
|
||||
# Set generously enough to survive a brief screen-off without losing the session,
|
||||
# but short enough that zombie sessions don't accumulate.
|
||||
# Free tier: kill session this many seconds after the last SSE subscriber leaves.
|
||||
# Covers mobile tab timeout / screen lock / crash.
|
||||
session_idle_ttl_s: int = int(os.getenv("SESSION_IDLE_TTL_S", "90"))
|
||||
# Paid tier: hard idle timeout — kill session this many seconds after the last
|
||||
# annotation event, regardless of subscriber state. 30 minutes default.
|
||||
session_paid_ttl_s: int = int(os.getenv("SESSION_PAID_TTL_S", "1800"))
|
||||
|
||||
# DeepL API key for CF-managed cloud translation (Paid tier).
|
||||
# BYOK users supply their own key per-session in StartRequest.byok_deepl_key.
|
||||
deepl_api_key: str = os.getenv("DEEPL_API_KEY", "")
|
||||
|
||||
# CLOUD: where Caddy injects the cf_session user token
|
||||
cloud_session_header: str = os.getenv("CLOUD_SESSION_HEADER", "X-CF-Session")
|
||||
cloud_data_root: str = os.getenv("CLOUD_DATA_ROOT", "/devl/linnet-cloud-data")
|
||||
|
||||
heimdall_url: str = os.getenv("HEIMDALL_URL", "https://license.circuitforge.tech")
|
||||
# Admin token for cloud-mode user tier resolution (/admin/cloud/resolve).
|
||||
# Not required for self-hosted installs; required when CLOUD_MODE=true.
|
||||
heimdall_admin_token: str = os.getenv("HEIMDALL_ADMIN_TOKEN", "")
|
||||
# Self-hosted license key. Cloud mode resolves tier via user_id instead.
|
||||
linnet_license_key: str = os.getenv("LINNET_LICENSE_KEY", "")
|
||||
|
||||
linnet_port: int = int(os.getenv("LINNET_PORT", "8522"))
|
||||
linnet_frontend_port: int = int(os.getenv("LINNET_FRONTEND_PORT", "8521"))
|
||||
|
|
@ -35,6 +45,10 @@ class Settings:
|
|||
# When set, each session allocates a cf-voice sidecar via the coordinator REST API.
|
||||
cf_orch_url: str = os.getenv("CF_ORCH_URL", "")
|
||||
|
||||
# cf-orch agent URL — local service lifecycle (start/stop). Separate from the
|
||||
# coordinator: coordinator is read/scheduling (:7700), agent is write (:7701).
|
||||
cf_orch_agent_url: str = os.getenv("CF_ORCH_AGENT_URL", "")
|
||||
|
||||
# Static cf-voice sidecar URL — legacy override (bypasses cf-orch, points directly
|
||||
# at a running cf-voice process). Takes precedence over cf-orch allocation when set.
|
||||
cf_voice_url: str = os.getenv("CF_VOICE_URL", "")
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from contextlib import asynccontextmanager
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api import audio, corrections, events, export, history, samples, sessions
|
||||
from app.api import audio, corrections, dev, events, export, history, samples, sessions, snapshots
|
||||
from app.config import settings
|
||||
from app.services import session_store
|
||||
|
||||
|
|
@ -68,11 +68,14 @@ app.add_middleware(
|
|||
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||
app.include_router(sessions.router)
|
||||
app.include_router(events.router)
|
||||
if not settings.demo_mode and not settings.cloud_mode:
|
||||
app.include_router(dev.router)
|
||||
app.include_router(history.router)
|
||||
app.include_router(audio.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(samples.router)
|
||||
app.include_router(corrections.router, prefix="/corrections", tags=["corrections"])
|
||||
app.include_router(snapshots.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
|
|
|||
21
app/migrations/002_session_snapshots.sql
Normal file
21
app/migrations/002_session_snapshots.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- Migration 002: Session snapshots for Paid tier session pinning.
|
||||
-- When a Paid session ends, its annotation history is saved here so the user
|
||||
-- can resume it in a later session. Snapshots contain annotation metadata only —
|
||||
-- no audio, no raw transcription text unless the user has opted in to STT retention.
|
||||
--
|
||||
-- Privacy: user_id scopes snapshots to the authenticated user. For self-hosted
|
||||
-- installs with no cloud auth, user_id is an empty string (instance-shared).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_snapshots (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
user_id TEXT NOT NULL DEFAULT '', -- cf_user from cloud mode; '' for self-hosted
|
||||
session_id TEXT NOT NULL, -- original session_id (short 8-char)
|
||||
created_at REAL NOT NULL, -- Unix epoch (time.time())
|
||||
ended_at REAL NOT NULL, -- Unix epoch
|
||||
elcor INTEGER NOT NULL DEFAULT 0, -- 1 = Elcor subtext mode was active
|
||||
event_count INTEGER NOT NULL DEFAULT 0,
|
||||
events_json TEXT NOT NULL DEFAULT '[]' -- JSON array of ToneEvent dicts
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_user_time
|
||||
ON session_snapshots (user_id, ended_at DESC);
|
||||
58
app/models/scene_event.py
Normal file
58
app/models/scene_event.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# app/models/scene_event.py — acoustic scene and accent SSE event models
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SceneEvent:
|
||||
"""
|
||||
Acoustic scene classification from cf-voice.
|
||||
|
||||
Broad scene label (e.g. "indoor_quiet", "outdoor_urban") derived from the
|
||||
AST model. Primary input to the privacy risk indicator in the frontend.
|
||||
Broadcasts via SSE as `event: scene-event`.
|
||||
Not stored in session history.
|
||||
"""
|
||||
session_id: str
|
||||
label: str # SCENE_LABELS: indoor_quiet, outdoor_urban, etc.
|
||||
confidence: float
|
||||
timestamp: float
|
||||
privacy_risk: str = "low" # "low" | "moderate" | "high"
|
||||
|
||||
def to_sse(self) -> str:
|
||||
payload = {
|
||||
"session_id": self.session_id,
|
||||
"label": self.label,
|
||||
"confidence": self.confidence,
|
||||
"timestamp": self.timestamp,
|
||||
"privacy_risk": self.privacy_risk,
|
||||
}
|
||||
return f"event: scene-event\ndata: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccentEvent:
|
||||
"""
|
||||
Regional accent / language identification from cf-voice.
|
||||
|
||||
Gated by CF_VOICE_ACCENT=1 — only emitted when that flag is set.
|
||||
Broadcasts via SSE as `event: accent-event`.
|
||||
Not stored in session history or corrections DB.
|
||||
"""
|
||||
session_id: str
|
||||
region: str # ACCENT_LABELS: en_gb, en_us, fr, etc.
|
||||
language: str # raw language tag from the model
|
||||
confidence: float
|
||||
timestamp: float
|
||||
|
||||
def to_sse(self) -> str:
|
||||
payload = {
|
||||
"session_id": self.session_id,
|
||||
"region": self.region,
|
||||
"language": self.language,
|
||||
"confidence": self.confidence,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
return f"event: accent-event\ndata: {json.dumps(payload)}\n\n"
|
||||
30
app/models/service_event.py
Normal file
30
app/models/service_event.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# app/models/service_event.py — system/service lifecycle events for the SSE stream
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceEvent:
|
||||
"""
|
||||
Lifecycle signal broadcast over SSE to frontend subscribers.
|
||||
|
||||
Currently used to signal when the cf-voice backend is ready to accept audio.
|
||||
The frontend holds the mic button in a disabled "loading" state until it
|
||||
receives status="ready".
|
||||
|
||||
SSE event name: service-event
|
||||
"""
|
||||
session_id: str
|
||||
status: str # "ready" | "loading" | "error"
|
||||
detail: str = "" # human-readable message shown in the UI
|
||||
|
||||
def to_sse(self) -> str:
|
||||
payload = {
|
||||
"event_type": "service",
|
||||
"session_id": self.session_id,
|
||||
"status": self.status,
|
||||
"detail": self.detail,
|
||||
}
|
||||
return f"event: service-event\ndata: {json.dumps(payload)}\n\n"
|
||||
|
|
@ -32,19 +32,51 @@ class Session:
|
|||
created_at: float = field(default_factory=time.monotonic)
|
||||
elcor: bool = False
|
||||
|
||||
# Tier and user identity — set at creation from the license/auth resolution.
|
||||
# Used by the reaper for tier-aware TTL and by snapshot logic for user scoping.
|
||||
tier: str = "free"
|
||||
user_id: str = "" # cf_user in cloud mode; empty string for self-hosted
|
||||
|
||||
# cf-voice sidecar — populated by session_store._allocate_voice() when cf-orch
|
||||
# is configured. Empty string means in-process fallback is active.
|
||||
cf_voice_url: str = ""
|
||||
cf_voice_allocation_id: str = ""
|
||||
|
||||
# History: last 50 events retained for GET /session/{id}/history
|
||||
# Translation settings — set at session start from StartRequest.
|
||||
# target_lang: BCP-47 tag (e.g. "es", "fr", "de") or empty string = no translation.
|
||||
# byok_deepl_key: user-supplied DeepL Free API key; bypasses Paid gate for translation.
|
||||
target_lang: str = ""
|
||||
byok_deepl_key: str = ""
|
||||
|
||||
# Audio classification settings — set at session start, cannot be changed mid-session.
|
||||
# window_ms: how many ms of PCM to accumulate before calling cf-voice /classify.
|
||||
# AudioWorklet sends 100ms chunks; must be a multiple of 100. Default 1000ms.
|
||||
# transcribe_lang: BCP-47 language hint for Whisper (e.g. "en", "es").
|
||||
# Empty = Whisper auto-detects (slower, less accurate on short clips).
|
||||
# num_speakers: hint for pyannote diarization. 0 = auto-detect; 1–8 = fixed count
|
||||
# passed as min_speakers=max_speakers. Auto is slower; fixed count improves accuracy.
|
||||
window_ms: int = 1000
|
||||
transcribe_lang: str = ""
|
||||
num_speakers: int = 0
|
||||
|
||||
# History: last 200 events retained for GET /session/{id}/history and snapshots.
|
||||
history: list[ToneEvent] = field(default_factory=list)
|
||||
_subscribers: list[asyncio.Queue] = field(default_factory=list, repr=False)
|
||||
|
||||
# Voice readiness: set by _probe_voice_ready() once cf-voice health check passes.
|
||||
# Used to immediately send a service-event to late SSE subscribers who connect
|
||||
# after the probe has already completed (avoids the broadcast-before-subscribe race).
|
||||
voice_ready: bool = False
|
||||
voice_error: str = "" # non-empty when probe timed out
|
||||
|
||||
# Activity tracking: updated on every broadcast() so the paid-tier reaper
|
||||
# can measure 30-min hard idle (activity-based, not subscriber-based).
|
||||
last_activity_at: float = field(default_factory=time.monotonic, repr=False)
|
||||
|
||||
# Idle tracking: monotonic timestamp of when the last subscriber left.
|
||||
# None means at least one subscriber is currently active, or the session
|
||||
# has never had one yet (just started). The reaper uses this to detect
|
||||
# abandoned sessions after a mobile tab timeout.
|
||||
# has never had one yet (just started). The free-tier reaper uses this to
|
||||
# detect abandoned sessions after a mobile tab timeout.
|
||||
last_subscriber_left_at: float | None = field(default=None, repr=False)
|
||||
|
||||
def subscriber_count(self) -> int:
|
||||
|
|
@ -69,8 +101,10 @@ class Session:
|
|||
|
||||
def broadcast(self, event: SessionEvent) -> None:
|
||||
"""Fan out any SessionEvent (tone, speaker, etc.) to all SSE subscribers."""
|
||||
self.last_activity_at = time.monotonic()
|
||||
|
||||
if isinstance(event, ToneEvent):
|
||||
if len(self.history) >= 50:
|
||||
if len(self.history) >= 200:
|
||||
self.history.pop(0)
|
||||
self.history.append(event)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import logging
|
|||
import time
|
||||
|
||||
from app.config import settings
|
||||
from app.models.service_event import ServiceEvent
|
||||
from app.models.session import Session
|
||||
from app.models.tone_event import ToneEvent
|
||||
from app.services.annotator import annotate
|
||||
|
|
@ -26,13 +27,31 @@ _CHUNKS_PER_WINDOW = _CLASSIFY_WINDOW_MS // _CHUNK_MS # 10 chunks
|
|||
_audio_buffers: dict[str, list[bytes]] = {}
|
||||
|
||||
|
||||
async def create_session(elcor: bool = False) -> Session:
|
||||
async def create_session(
|
||||
elcor: bool = False,
|
||||
tier: str = "free",
|
||||
user_id: str = "",
|
||||
target_lang: str = "",
|
||||
byok_deepl_key: str = "",
|
||||
window_ms: int = 1000,
|
||||
transcribe_lang: str = "",
|
||||
num_speakers: int = 0,
|
||||
) -> Session:
|
||||
"""Create a new session and start its ContextClassifier background task.
|
||||
|
||||
If CF_ORCH_URL is configured, requests a managed cf-voice instance before
|
||||
starting the classifier. Falls back to in-process mock if allocation fails.
|
||||
"""
|
||||
session = Session(elcor=elcor)
|
||||
session = Session(
|
||||
elcor=elcor,
|
||||
tier=tier,
|
||||
user_id=user_id,
|
||||
target_lang=target_lang,
|
||||
byok_deepl_key=byok_deepl_key,
|
||||
window_ms=window_ms,
|
||||
transcribe_lang=transcribe_lang,
|
||||
num_speakers=num_speakers,
|
||||
)
|
||||
_sessions[session.session_id] = session
|
||||
await _allocate_voice(session)
|
||||
task = asyncio.create_task(
|
||||
|
|
@ -40,6 +59,10 @@ async def create_session(elcor: bool = False) -> Session:
|
|||
name=f"classifier-{session.session_id}",
|
||||
)
|
||||
_tasks[session.session_id] = task
|
||||
asyncio.create_task(
|
||||
_probe_voice_ready(session),
|
||||
name=f"voice-ready-{session.session_id}",
|
||||
)
|
||||
session.state = "running"
|
||||
logger.info(
|
||||
"Session %s started (voice=%s)",
|
||||
|
|
@ -59,7 +82,11 @@ def active_session_count() -> int:
|
|||
|
||||
|
||||
async def end_session(session_id: str) -> bool:
|
||||
"""Stop and remove a session. Returns True if it existed."""
|
||||
"""Stop and remove a session. Returns True if it existed.
|
||||
|
||||
For Paid tier sessions with annotation history, saves a snapshot to SQLite
|
||||
so the user can resume the session later.
|
||||
"""
|
||||
session = _sessions.pop(session_id, None)
|
||||
if session is None:
|
||||
return False
|
||||
|
|
@ -69,10 +96,194 @@ async def end_session(session_id: str) -> bool:
|
|||
task.cancel()
|
||||
_audio_buffers.pop(session_id, None)
|
||||
await _release_voice(session)
|
||||
|
||||
if session.tier in ("paid", "premium") and session.history:
|
||||
_save_snapshot(session)
|
||||
|
||||
logger.info("Session %s ended", session_id)
|
||||
return True
|
||||
|
||||
|
||||
def _save_snapshot(session: Session) -> None:
|
||||
"""Persist a session's annotation history to the snapshots table."""
|
||||
import json
|
||||
import time as _time
|
||||
import uuid
|
||||
|
||||
try:
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
snapshot_id = str(uuid.uuid4())
|
||||
events_json = json.dumps([
|
||||
{
|
||||
"label": e.label,
|
||||
"confidence": e.confidence,
|
||||
"speaker_id": e.speaker_id,
|
||||
"shift_magnitude": e.shift_magnitude,
|
||||
"timestamp": e.timestamp,
|
||||
"subtext": e.subtext,
|
||||
"affect": e.affect,
|
||||
}
|
||||
for e in session.history
|
||||
])
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO session_snapshots
|
||||
(id, user_id, session_id, created_at, ended_at, elcor, event_count, events_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
snapshot_id,
|
||||
session.user_id,
|
||||
session.session_id,
|
||||
session.created_at,
|
||||
_time.time(),
|
||||
1 if session.elcor else 0,
|
||||
len(session.history),
|
||||
events_json,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(
|
||||
"Snapshot %s saved for session %s (%d events)",
|
||||
snapshot_id, session.session_id, len(session.history),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save snapshot for session %s: %s", session.session_id, exc)
|
||||
|
||||
|
||||
async def _probe_voice_ready(session: Session) -> None:
|
||||
"""Poll the cf-voice health endpoint and broadcast a service-event when ready.
|
||||
|
||||
Retries every second for up to 30s. On success, broadcasts status="ready"
|
||||
so the frontend can unlock the mic button. On timeout, broadcasts
|
||||
status="error" so the UI can surface a warning.
|
||||
|
||||
No-op for in-process mode (no cf_voice_url) — broadcasts ready immediately.
|
||||
"""
|
||||
def _mark_ready() -> None:
|
||||
session.voice_ready = True
|
||||
session.broadcast(ServiceEvent(session_id=session.session_id, status="ready"))
|
||||
|
||||
def _mark_error(detail: str) -> None:
|
||||
session.voice_error = detail
|
||||
session.broadcast(ServiceEvent(
|
||||
session_id=session.session_id, status="error", detail=detail,
|
||||
))
|
||||
|
||||
if not session.cf_voice_url:
|
||||
# In-process mock or no managed voice: ready immediately
|
||||
_mark_ready()
|
||||
return
|
||||
|
||||
asyncio.create_task(
|
||||
_poll_model_status(session),
|
||||
name=f"model-status-{session.session_id}",
|
||||
)
|
||||
|
||||
import httpx
|
||||
|
||||
health_url = session.cf_voice_url.rstrip("/") + "/health"
|
||||
deadline = time.monotonic() + 30.0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
resp = await client.get(health_url)
|
||||
if resp.status_code == 200:
|
||||
_mark_ready()
|
||||
logger.debug("cf-voice ready for session %s", session.session_id)
|
||||
# Surface any soft warnings (e.g. diarize configured but HF_TOKEN missing)
|
||||
try:
|
||||
data = resp.json()
|
||||
for msg in data.get("warnings", []):
|
||||
session.broadcast(ServiceEvent(
|
||||
session_id=session.session_id,
|
||||
status="warning",
|
||||
detail=msg,
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
_mark_error("Voice service did not become ready in time. Try reloading.")
|
||||
logger.warning("cf-voice readiness probe timed out for session %s", session.session_id)
|
||||
|
||||
|
||||
_MODEL_LABELS = {
|
||||
"stt": "speech-to-text (Whisper)",
|
||||
"diarizer": "speaker diarization (pyannote)",
|
||||
"dimensional": "emotion dimensions (audeering)",
|
||||
"prosody": "prosody (openSMILE)",
|
||||
}
|
||||
|
||||
|
||||
async def _poll_model_status(session: Session) -> None:
|
||||
"""Poll cf-voice /health for per-model download progress and broadcast
|
||||
loading/ready events to the frontend. Stops when all models are stable."""
|
||||
if not session.cf_voice_url:
|
||||
return
|
||||
|
||||
import httpx
|
||||
|
||||
health_url = session.cf_voice_url.rstrip("/") + "/health"
|
||||
prev: dict[str, str] = {}
|
||||
|
||||
# Poll for up to 10 minutes (large model downloads can take a while)
|
||||
deadline = time.monotonic() + 600.0
|
||||
|
||||
while time.monotonic() < deadline and session.state == "running":
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
resp = await client.get(health_url)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
models: dict[str, str] = data.get("models", {})
|
||||
|
||||
for key, status in models.items():
|
||||
if models.get(key) == prev.get(key):
|
||||
continue
|
||||
label = _MODEL_LABELS.get(key, key)
|
||||
if status == "loading":
|
||||
session.broadcast(ServiceEvent(
|
||||
session_id=session.session_id,
|
||||
status="loading",
|
||||
detail=f"Downloading {label}…",
|
||||
))
|
||||
elif status == "ready" and prev.get(key) == "loading":
|
||||
session.broadcast(ServiceEvent(
|
||||
session_id=session.session_id,
|
||||
status="loading",
|
||||
detail=f"{label.capitalize()} ready.",
|
||||
))
|
||||
elif status == "error":
|
||||
session.broadcast(ServiceEvent(
|
||||
session_id=session.session_id,
|
||||
status="warning",
|
||||
detail=f"Failed to load {label}.",
|
||||
))
|
||||
|
||||
prev = dict(models)
|
||||
|
||||
# Done when nothing is still loading
|
||||
if models and all(s != "loading" for s in models.values()):
|
||||
# Clear any loading detail
|
||||
session.broadcast(ServiceEvent(
|
||||
session_id=session.session_id,
|
||||
status="loading",
|
||||
detail="",
|
||||
))
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
|
||||
async def _allocate_voice(session: Session) -> None:
|
||||
"""Request a managed cf-voice instance from cf-orch. No-op if CF_ORCH_URL is unset."""
|
||||
# Static override takes precedence — skip cf-orch allocation
|
||||
|
|
@ -207,7 +418,8 @@ async def forward_audio_chunk(
|
|||
buf = _audio_buffers.setdefault(session.session_id, [])
|
||||
buf.append(raw)
|
||||
|
||||
if len(buf) < _CHUNKS_PER_WINDOW:
|
||||
chunks_needed = max(1, session.window_ms // _CHUNK_MS)
|
||||
if len(buf) < chunks_needed:
|
||||
return # not enough audio yet — wait for more chunks
|
||||
|
||||
# Flush: concatenate window, reset buffer
|
||||
|
|
@ -221,6 +433,8 @@ async def forward_audio_chunk(
|
|||
"timestamp": timestamp,
|
||||
"elcor": session.elcor,
|
||||
"session_id": session.session_id,
|
||||
"language": session.transcribe_lang or None, # None = Whisper auto-detect
|
||||
"num_speakers": session.num_speakers or None, # None = pyannote auto-detect
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
|
|
@ -233,6 +447,7 @@ async def forward_audio_chunk(
|
|||
|
||||
from app.models.queue_event import QueueEvent
|
||||
from app.models.transcript_event import TranscriptEvent
|
||||
from app.models.scene_event import SceneEvent, AccentEvent
|
||||
|
||||
for ev in data.get("events", []):
|
||||
etype = ev.get("event_type")
|
||||
|
|
@ -277,63 +492,89 @@ async def forward_audio_chunk(
|
|||
)
|
||||
session.broadcast(queue_ev)
|
||||
|
||||
elif etype == "scene":
|
||||
scene_ev = SceneEvent(
|
||||
session_id=session.session_id,
|
||||
label=ev["label"],
|
||||
confidence=ev.get("confidence", 1.0),
|
||||
timestamp=ev["timestamp"],
|
||||
privacy_risk=ev.get("privacy_risk", "low"),
|
||||
)
|
||||
session.broadcast(scene_ev)
|
||||
|
||||
elif etype == "accent":
|
||||
accent_ev = AccentEvent(
|
||||
session_id=session.session_id,
|
||||
region=ev["label"],
|
||||
language=ev.get("language", ""),
|
||||
confidence=ev.get("confidence", 1.0),
|
||||
timestamp=ev["timestamp"],
|
||||
)
|
||||
session.broadcast(accent_ev)
|
||||
|
||||
|
||||
# ── Idle session reaper ───────────────────────────────────────────────────────
|
||||
|
||||
def _should_reap(session: Session, now: float) -> bool:
|
||||
"""
|
||||
Return True if a session should be reaped.
|
||||
|
||||
Free tier: reap when last subscriber left > SESSION_IDLE_TTL_S ago.
|
||||
Paid/premium: reap when last activity (broadcast) > SESSION_PAID_TTL_S ago,
|
||||
regardless of subscriber state. This lets paid sessions survive reconnects
|
||||
but enforces a hard 30-min activity timeout.
|
||||
"""
|
||||
if session.state != "running":
|
||||
return False
|
||||
|
||||
if session.tier in ("paid", "premium"):
|
||||
return (now - session.last_activity_at) > settings.session_paid_ttl_s
|
||||
|
||||
# Free tier: only reap when there are no subscribers and the idle clock is running
|
||||
return (
|
||||
session.subscriber_count() == 0
|
||||
and session.last_subscriber_left_at is not None
|
||||
and (now - session.last_subscriber_left_at) > settings.session_idle_ttl_s
|
||||
)
|
||||
|
||||
|
||||
async def _reaper_loop() -> None:
|
||||
"""
|
||||
Periodically kill sessions with no active SSE subscribers.
|
||||
Periodically kill sessions that have exceeded their tier TTL.
|
||||
|
||||
A session becomes eligible for reaping when:
|
||||
- Its last SSE subscriber disconnected more than SESSION_IDLE_TTL_S seconds ago
|
||||
- It has not been explicitly ended (state != "stopped")
|
||||
Free tier (90s): reaps when last SSE subscriber left > 90s ago.
|
||||
Covers mobile tab timeout / screen lock / crash.
|
||||
|
||||
This covers the common mobile pattern: screen locks → browser suspends tab →
|
||||
EventSource closes → SSE subscriber count drops to zero. If the user doesn't
|
||||
return within the TTL window, the session is cleaned up automatically.
|
||||
Paid tier (30 min): reaps when last annotation event > 30 min ago,
|
||||
regardless of subscriber state. Allows paid users to reconnect within
|
||||
the window without losing their session.
|
||||
|
||||
The reaper runs every REAP_INTERVAL_S seconds (half the TTL, so the worst-case
|
||||
overshoot is TTL + REAP_INTERVAL_S).
|
||||
The reaper runs every REAP_INTERVAL_S seconds (half the free TTL, capped
|
||||
at 60s, so worst-case overshoot is small).
|
||||
"""
|
||||
ttl = settings.session_idle_ttl_s
|
||||
interval = max(15, ttl // 2)
|
||||
logger.info("Session reaper started (TTL=%ds, check every %ds)", ttl, interval)
|
||||
interval = max(15, min(60, ttl // 2))
|
||||
logger.info("Session reaper started (free TTL=%ds, paid TTL=%ds, check every %ds)",
|
||||
ttl, settings.session_paid_ttl_s, interval)
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
now = time.monotonic()
|
||||
to_reap = [
|
||||
sid
|
||||
for sid, session in list(_sessions.items())
|
||||
if (
|
||||
session.state == "running"
|
||||
and session.subscriber_count() == 0
|
||||
and session.last_subscriber_left_at is not None
|
||||
and (now - session.last_subscriber_left_at) > ttl
|
||||
)
|
||||
]
|
||||
to_reap = [sid for sid, s in list(_sessions.items()) if _should_reap(s, now)]
|
||||
for sid in to_reap:
|
||||
logger.info(
|
||||
"Reaping idle session %s (no subscribers for >%ds)", sid, ttl
|
||||
)
|
||||
session = _sessions.get(sid)
|
||||
tier = session.tier if session else "free"
|
||||
logger.info("Reaping idle %s session %s", tier, sid)
|
||||
await end_session(sid)
|
||||
|
||||
|
||||
async def _reaper_loop_once() -> None:
|
||||
"""Single reaper pass — used by tests to avoid sleeping."""
|
||||
ttl = settings.session_idle_ttl_s
|
||||
now = time.monotonic()
|
||||
to_reap = [
|
||||
sid
|
||||
for sid, session in list(_sessions.items())
|
||||
if (
|
||||
session.state == "running"
|
||||
and session.subscriber_count() == 0
|
||||
and session.last_subscriber_left_at is not None
|
||||
and (now - session.last_subscriber_left_at) > ttl
|
||||
)
|
||||
]
|
||||
to_reap = [sid for sid, s in list(_sessions.items()) if _should_reap(s, now)]
|
||||
for sid in to_reap:
|
||||
logger.info("Reaping idle session %s (no subscribers for >%ds)", sid, ttl)
|
||||
session = _sessions.get(sid)
|
||||
tier = session.tier if session else "free"
|
||||
logger.info("Reaping idle %s session %s", tier, sid)
|
||||
await end_session(sid)
|
||||
|
||||
|
||||
|
|
|
|||
113
app/services/translator.py
Normal file
113
app/services/translator.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# app/services/translator.py — DeepL-backed tone label translation
|
||||
#
|
||||
# Translates short tone labels (e.g. "Neutral", "Stressed", "Apologetic")
|
||||
# into the user's target language via the DeepL API.
|
||||
#
|
||||
# Two API key paths:
|
||||
# CF-managed (Paid tier): DEEPL_API_KEY env var (DeepL Pro endpoint)
|
||||
# BYOK (any tier): per-session byok_deepl_key from StartRequest (DeepL Free endpoint)
|
||||
#
|
||||
# Per-session label cache: each unique label is translated once per session.
|
||||
# Cache is discarded when the session ends — it lives on the Translator instance,
|
||||
# which is created per-session and GC'd with the session.
|
||||
#
|
||||
# Rate limiting: translation runs only on ToneEvent emit, not on raw audio frames.
|
||||
# The SSE generator calls translate() once per event; the cache makes repeat labels free.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEEPL_FREE_URL = "https://api-free.deepl.com/v2/translate"
|
||||
_DEEPL_PRO_URL = "https://api.deepl.com/v2/translate"
|
||||
|
||||
|
||||
class Translator:
|
||||
"""
|
||||
Per-session DeepL translator with label cache.
|
||||
|
||||
Usage:
|
||||
translator = Translator.for_session(session)
|
||||
translated = translator.translate("Stressed") # "Stressé" for target_lang="fr"
|
||||
translated = translator.translate("Stressed") # cache hit — no API call
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target_lang: str,
|
||||
api_key: str,
|
||||
pro: bool = True,
|
||||
) -> None:
|
||||
self._target_lang = target_lang.upper() # DeepL uses uppercase codes
|
||||
self._api_key = api_key
|
||||
self._url = _DEEPL_PRO_URL if pro else _DEEPL_FREE_URL
|
||||
self._cache: dict[str, str] = {}
|
||||
|
||||
@classmethod
|
||||
def for_session(cls, session) -> "Translator | None":
|
||||
"""
|
||||
Create a Translator for a session, or return None if translation is disabled.
|
||||
|
||||
Returns None when:
|
||||
- session.target_lang is empty
|
||||
- No API key is available (neither BYOK nor CF-managed)
|
||||
"""
|
||||
target_lang = session.target_lang
|
||||
if not target_lang:
|
||||
return None
|
||||
|
||||
byok = session.byok_deepl_key.strip()
|
||||
cf_key = settings.deepl_api_key.strip()
|
||||
|
||||
if byok:
|
||||
return cls(target_lang=target_lang, api_key=byok, pro=False)
|
||||
if cf_key:
|
||||
return cls(target_lang=target_lang, api_key=cf_key, pro=True)
|
||||
|
||||
logger.warning(
|
||||
"[translator] target_lang=%s set but no DeepL key available — skipping translation",
|
||||
target_lang,
|
||||
)
|
||||
return None
|
||||
|
||||
def translate(self, label: str) -> str:
|
||||
"""
|
||||
Translate a tone label. Returns the original label on any error.
|
||||
|
||||
Results are cached per-session so the same label is only translated once.
|
||||
"""
|
||||
if not label:
|
||||
return label
|
||||
|
||||
if label in self._cache:
|
||||
return self._cache[label]
|
||||
|
||||
translated = self._call_deepl(label)
|
||||
self._cache[label] = translated
|
||||
return translated
|
||||
|
||||
def _call_deepl(self, text: str) -> str:
|
||||
try:
|
||||
resp = requests.post(
|
||||
self._url,
|
||||
headers={"Authorization": f"DeepL-Auth-Key {self._api_key}"},
|
||||
json={
|
||||
"text": [text],
|
||||
"target_lang": self._target_lang,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.warning("[translator] DeepL returned %s for label %r", resp.status_code, text)
|
||||
return text
|
||||
data = resp.json()
|
||||
return data["translations"][0]["text"]
|
||||
except Exception as exc:
|
||||
logger.warning("[translator] DeepL call failed for label %r: %s", text, exc)
|
||||
return text
|
||||
156
app/tiers.py
156
app/tiers.py
|
|
@ -1,31 +1,159 @@
|
|||
# app/tiers.py — tier gate checks
|
||||
#
|
||||
# Free tier: local inference only, no license key required.
|
||||
# Paid tier: cloud STT/TTS fallback, session pinning (v1.0).
|
||||
# Two validation paths:
|
||||
# Self-hosted: LINNET_LICENSE_KEY env var (or key passed per-request)
|
||||
# → cf-core validate_license() → Heimdall /v1/licenses/verify
|
||||
# Cloud mode: user_id from X-CF-Session header
|
||||
# → Heimdall /admin/cloud/resolve (admin token required)
|
||||
#
|
||||
# Both paths cache results in-process for 30 minutes so a brief Heimdall
|
||||
# outage does not interrupt active sessions.
|
||||
#
|
||||
# BYOK: user-supplied DeepL key bypasses the Paid gate for translation only.
|
||||
# Pass byok_deepl=True to require_paid() to enable this exception.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
|
||||
BYOK_UNLOCKABLE = ["cloud_stt", "cloud_tts", "session_pinning"]
|
||||
import requests
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PRODUCT = "LNNT"
|
||||
_CACHE_TTL = 1800 # 30 minutes — matches Heimdall offline grace window
|
||||
|
||||
# Cache: cache_key (str) -> (tier: str, expires_at: float)
|
||||
_cache: dict[str, tuple[str, float]] = {}
|
||||
|
||||
|
||||
def is_paid(license_key: str | None = None) -> bool:
|
||||
"""Return True if the request has a valid Paid+ license key."""
|
||||
key = license_key or os.environ.get("LINNET_LICENSE_KEY", "")
|
||||
# Paid keys start with CFG-LNNT- (format: CFG-LNNT-XXXX-XXXX-XXXX)
|
||||
return bool(key) and key.upper().startswith("CFG-LNNT-")
|
||||
# ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def _cached(cache_key: str) -> str | None:
|
||||
entry = _cache.get(cache_key)
|
||||
if entry and time.monotonic() < entry[1]:
|
||||
return entry[0]
|
||||
return None
|
||||
|
||||
|
||||
def require_free() -> None:
|
||||
"""No-op. All users get Free tier features."""
|
||||
def _store(cache_key: str, tier: str) -> str:
|
||||
_cache[cache_key] = (tier, time.monotonic() + _CACHE_TTL)
|
||||
return tier
|
||||
|
||||
|
||||
def require_paid(license_key: str | None = None) -> None:
|
||||
"""Raise if caller doesn't have a Paid license."""
|
||||
if not is_paid(license_key):
|
||||
def _resolve_by_key(license_key: str) -> str:
|
||||
"""Hit Heimdall /v1/licenses/verify for a raw key. Returns tier string."""
|
||||
cache_key = f"key:{license_key}"
|
||||
cached = _cached(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{settings.heimdall_url}/v1/licenses/verify",
|
||||
json={"key": license_key, "min_tier": "free"},
|
||||
timeout=5,
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.warning("[tiers] Heimdall /verify returned %s", resp.status_code)
|
||||
return _store(cache_key, "free")
|
||||
data = resp.json()
|
||||
if not data.get("valid", False):
|
||||
return _store(cache_key, "free")
|
||||
tier = data.get("tier", "free") or "free"
|
||||
return _store(cache_key, tier)
|
||||
except Exception as exc:
|
||||
logger.warning("[tiers] Heimdall key validation failed: %s", exc)
|
||||
# Do NOT cache on network failure — allow retry after next request
|
||||
return "free"
|
||||
|
||||
|
||||
def _resolve_by_user(user_id: str) -> str:
|
||||
"""Hit Heimdall /admin/cloud/resolve for a cloud user. Returns tier string."""
|
||||
if not settings.heimdall_admin_token:
|
||||
logger.warning("[tiers] HEIMDALL_ADMIN_TOKEN not set — defaulting to free")
|
||||
return "free"
|
||||
|
||||
cache_key = f"user:{user_id}"
|
||||
cached = _cached(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{settings.heimdall_url}/admin/cloud/resolve",
|
||||
json={"user_id": user_id, "product": _PRODUCT},
|
||||
headers={"Authorization": f"Bearer {settings.heimdall_admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.warning("[tiers] Heimdall /cloud/resolve returned %s", resp.status_code)
|
||||
return _store(cache_key, "free")
|
||||
data = resp.json()
|
||||
tier = data.get("tier", "free") or "free"
|
||||
return _store(cache_key, tier)
|
||||
except Exception as exc:
|
||||
logger.warning("[tiers] Heimdall cloud resolve failed: %s", exc)
|
||||
return "free"
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_tier(
|
||||
license_key: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Return the active tier for this request context.
|
||||
|
||||
Precedence:
|
||||
1. user_id (cloud mode — set by CloudAuthMiddleware on request.state.cf_user)
|
||||
2. license_key argument (self-hosted, passed per-request)
|
||||
3. LINNET_LICENSE_KEY env var (self-hosted, instance-wide)
|
||||
4. "free" (no key configured)
|
||||
"""
|
||||
if user_id:
|
||||
return _resolve_by_user(user_id)
|
||||
|
||||
key = license_key or settings.linnet_license_key
|
||||
if not key:
|
||||
return "free"
|
||||
|
||||
return _resolve_by_key(key)
|
||||
|
||||
|
||||
def is_paid(
|
||||
license_key: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Return True if the resolved tier is paid or premium."""
|
||||
return get_tier(license_key=license_key, user_id=user_id) in ("paid", "premium")
|
||||
|
||||
|
||||
def require_paid(
|
||||
license_key: str | None = None,
|
||||
user_id: str | None = None,
|
||||
byok_deepl: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Raise HTTP 402 if caller doesn't have a Paid+ license.
|
||||
|
||||
byok_deepl=True skips the check — the caller has provided their own
|
||||
DeepL API key and bypasses the Paid gate for translation only.
|
||||
"""
|
||||
if byok_deepl:
|
||||
return
|
||||
if not is_paid(license_key=license_key, user_id=user_id):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail="This feature requires a Linnet Paid license. "
|
||||
"Get one at circuitforge.tech.",
|
||||
)
|
||||
|
||||
|
||||
# ── Backwards-compat shim (old call sites that pass key as positional) ────────
|
||||
|
||||
BYOK_UNLOCKABLE = ["cloud_stt", "cloud_tts", "session_pinning"]
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@
|
|||
#
|
||||
# Requires in .env:
|
||||
# CLOUD_MODE=true
|
||||
# LINNET_LICENSE_KEY=CFG-LNNT-... (or per-request key via API)
|
||||
# HEIMDALL_URL=https://license.circuitforge.tech
|
||||
# HEIMDALL_ADMIN_TOKEN=... (admin token for /admin/cloud/resolve — resolves tier from user_id)
|
||||
# DIRECTUS_JWT_SECRET=... (Caddy injects X-CF-Session from cf_session cookie)
|
||||
# HF_TOKEN=... (needed for real cf-voice inference; omit for mock)
|
||||
# CF_VOICE_MOCK=0 (set to 1 during staged rollout)
|
||||
#
|
||||
# Note: LINNET_LICENSE_KEY is for self-hosted installs only.
|
||||
# Cloud mode resolves tier via HEIMDALL_ADMIN_TOKEN + /admin/cloud/resolve.
|
||||
|
||||
services:
|
||||
linnet-api:
|
||||
|
|
|
|||
|
|
@ -7,18 +7,30 @@
|
|||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<section class="now-section">
|
||||
<p class="section-label">Now</p>
|
||||
<NowPanel :event="store.latest" />
|
||||
</section>
|
||||
<!-- v0.1.x: flat single-stream view (default, internal test fixture) -->
|
||||
<template v-if="!settings.threadView">
|
||||
<section class="now-section">
|
||||
<p class="section-label">Now</p>
|
||||
<NowPanel :event="store.latest" />
|
||||
</section>
|
||||
|
||||
<section class="history-section">
|
||||
<p class="section-label">Recent</p>
|
||||
<HistoryStrip />
|
||||
</section>
|
||||
<section class="history-section">
|
||||
<p class="section-label">Recent</p>
|
||||
<HistoryStrip />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- v0.2.x: per-speaker thread view (dev preview — toggle in DevPanel) -->
|
||||
<template v-else>
|
||||
<section class="thread-section">
|
||||
<p class="section-label">Conversation threads <span class="thread-preview-badge">v0.2 preview</span></p>
|
||||
<div class="thread-placeholder">ThreadView not yet built — coming in v0.2.x</div>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<DevPanel v-if="isDev" />
|
||||
<ComposeBar />
|
||||
</footer>
|
||||
</div>
|
||||
|
|
@ -26,11 +38,15 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useSessionStore } from "./stores/session";
|
||||
import { useSettingsStore } from "./stores/settings";
|
||||
import NowPanel from "./components/NowPanel.vue";
|
||||
import HistoryStrip from "./components/HistoryStrip.vue";
|
||||
import ComposeBar from "./components/ComposeBar.vue";
|
||||
import DevPanel from "./components/DevPanel.vue";
|
||||
|
||||
const store = useSessionStore();
|
||||
const settings = useSettingsStore();
|
||||
const isDev = import.meta.env.DEV;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
@ -108,6 +124,26 @@ body {
|
|||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.thread-preview-badge {
|
||||
font-size: 0.6rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--color-accent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.05rem 0.3rem;
|
||||
margin-left: 0.4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.thread-placeholder {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
|
|
|
|||
|
|
@ -1,36 +1,93 @@
|
|||
<!-- ComposeBar.vue — session start/stop controls
|
||||
Navigation v0.1.x stub: start/end session only.
|
||||
v0.2.x: mic capture, audio routing controls.
|
||||
-->
|
||||
<!-- ComposeBar.vue — session start/stop controls with audio settings -->
|
||||
<template>
|
||||
<div class="compose-bar">
|
||||
<button
|
||||
v-if="store.state === 'idle' || store.state === 'stopped'"
|
||||
class="btn-start"
|
||||
@click="handleStart"
|
||||
:disabled="starting"
|
||||
>
|
||||
{{ starting ? "Starting…" : "Start session" }}
|
||||
</button>
|
||||
|
||||
<!-- ── Pre-session: settings + start ───────────────────────────── -->
|
||||
<template v-if="store.state === 'idle' || store.state === 'stopped'">
|
||||
|
||||
<transition name="settings-slide">
|
||||
<div v-if="showSettings" class="settings-panel">
|
||||
<label class="settings-item">
|
||||
<span class="settings-label">Window</span>
|
||||
<select v-model.number="settings.windowMs" class="settings-select">
|
||||
<option :value="500">500 ms</option>
|
||||
<option :value="1000">1 s (default)</option>
|
||||
<option :value="2000">2 s</option>
|
||||
<option :value="3000">3 s</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="settings-item">
|
||||
<span class="settings-label">Language</span>
|
||||
<select v-model="settings.transcribeLang" class="settings-select">
|
||||
<option value="">Auto-detect</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Spanish</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
<option value="pt">Portuguese</option>
|
||||
<option value="it">Italian</option>
|
||||
<option value="ja">Japanese</option>
|
||||
<option value="ko">Korean</option>
|
||||
<option value="zh">Chinese</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="settings-item">
|
||||
<span class="settings-label">Speakers</span>
|
||||
<select v-model.number="settings.numSpeakers" class="settings-select">
|
||||
<option :value="0">Auto-detect</option>
|
||||
<option :value="1">1 speaker</option>
|
||||
<option :value="2">2 speakers</option>
|
||||
<option :value="3">3 speakers</option>
|
||||
<option :value="4">4 speakers</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="settings-item settings-checkbox">
|
||||
<input type="checkbox" v-model="settings.elcor" />
|
||||
<span class="settings-label">Elcor prefix</span>
|
||||
</label>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="compose-row">
|
||||
<button
|
||||
class="btn-icon"
|
||||
:class="{ active: showSettings }"
|
||||
@click="showSettings = !showSettings"
|
||||
:title="showSettings ? 'Hide settings' : 'Audio settings'"
|
||||
aria-label="Toggle audio settings"
|
||||
>⚙</button>
|
||||
<button class="btn-start" @click="handleStart" :disabled="starting">
|
||||
{{ starting ? "Starting…" : "Start session" }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Active session ───────────────────────────────────────────── -->
|
||||
<template v-else>
|
||||
<div class="session-info">
|
||||
<span class="session-dot" :class="{ active: store.state === 'running' }" />
|
||||
<span class="session-id">{{ store.sessionId?.slice(0, 8) }}…</span>
|
||||
</div>
|
||||
|
||||
<label class="elcor-toggle" title="Tone prefix mode">
|
||||
<input type="checkbox" v-model="elcorLocal" disabled />
|
||||
Prefix
|
||||
</label>
|
||||
<!-- Active settings badges -->
|
||||
<span class="session-badge">{{ settings.windowMs }}ms</span>
|
||||
<span v-if="settings.transcribeLang" class="session-badge">{{ settings.transcribeLang }}</span>
|
||||
<span v-if="settings.numSpeakers > 0" class="session-badge">{{ settings.numSpeakers }}spk</span>
|
||||
<span v-if="settings.elcor" class="session-badge">elcor</span>
|
||||
|
||||
<button
|
||||
class="btn-mic"
|
||||
:class="{ active: capturing }"
|
||||
:class="{ active: capturing, loading: !store.voiceReady && !store.voiceError }"
|
||||
:disabled="!store.voiceReady && !store.voiceError"
|
||||
@click="handleMicToggle"
|
||||
:aria-label="capturing ? 'Stop microphone' : 'Start microphone'"
|
||||
:aria-label="capturing ? 'Stop microphone' : store.voiceReady ? 'Start microphone' : 'Voice service loading'"
|
||||
:title="store.voiceError ?? undefined"
|
||||
>
|
||||
{{ capturing ? "Mic on" : "Mic off" }}
|
||||
<span v-if="!store.voiceReady && !store.voiceError" class="mic-spinner" aria-hidden="true" />
|
||||
{{ capturing ? "Mic on" : store.voiceReady ? "Mic off" : store.voiceError ? "Voice error" : "Loading…" }}
|
||||
</button>
|
||||
|
||||
<button class="btn-stop" @click="handleStop">End session</button>
|
||||
|
|
@ -39,30 +96,43 @@
|
|||
<p v-if="expired" class="compose-notice">
|
||||
Session timed out after inactivity. Start a new one to continue.
|
||||
</p>
|
||||
<p v-else-if="store.voiceError" class="compose-error">{{ store.voiceError }}</p>
|
||||
<p v-else-if="error" class="compose-error">{{ error }}</p>
|
||||
<p v-if="store.voiceWarning" class="compose-warning">{{ store.voiceWarning }}</p>
|
||||
<p v-if="store.voiceLoadingDetail" class="compose-loading">
|
||||
<span class="compose-loading-spinner" aria-hidden="true" />{{ store.voiceLoadingDetail }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useSessionStore } from "../stores/session";
|
||||
import { useSettingsStore } from "../stores/settings";
|
||||
import { useToneStream } from "../composables/useToneStream";
|
||||
import { useAudioCapture } from "../composables/useAudioCapture";
|
||||
|
||||
const store = useSessionStore();
|
||||
const settings = useSettingsStore();
|
||||
const { connect, disconnect, expired } = useToneStream();
|
||||
const { start: startMic, stop: stopMic, capturing } = useAudioCapture();
|
||||
|
||||
const starting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const elcorLocal = ref(false);
|
||||
const showSettings = ref(false);
|
||||
|
||||
async function handleStart() {
|
||||
starting.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await store.startSession(elcorLocal.value);
|
||||
await store.startSession({
|
||||
elcor: settings.elcor,
|
||||
windowMs: settings.windowMs,
|
||||
transcribeLang: settings.transcribeLang,
|
||||
numSpeakers: settings.numSpeakers,
|
||||
});
|
||||
connect(store.sessionId!);
|
||||
showSettings.value = false; // collapse settings once running
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : "Failed to start session";
|
||||
} finally {
|
||||
|
|
@ -97,14 +167,113 @@ export default { name: "ComposeBar" };
|
|||
<style scoped>
|
||||
.compose-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface, #1a1d27);
|
||||
border-top: 1px solid var(--color-border, #2a2d3a);
|
||||
}
|
||||
|
||||
/* ── Settings panel ──────────────────────────────────────────────── */
|
||||
.settings-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.25rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #2a2d3a);
|
||||
background: color-mix(in srgb, var(--color-surface, #1a1d27) 80%, black);
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-checkbox {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-muted, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.settings-select {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text, #e2e8f0);
|
||||
background: var(--color-bg, #0f1117);
|
||||
border: 1px solid var(--color-border, #2a2d3a);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
cursor: pointer;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.settings-select:focus {
|
||||
outline: 2px solid var(--color-accent, #7c6af7);
|
||||
}
|
||||
|
||||
/* Slide transition */
|
||||
.settings-slide-enter-active,
|
||||
.settings-slide-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.settings-slide-enter-from,
|
||||
.settings-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* ── Bottom compose row ──────────────────────────────────────────── */
|
||||
.compose-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Active-session row reuses compose-bar padding directly */
|
||||
.compose-bar > .session-info,
|
||||
.compose-bar > .btn-mic,
|
||||
.compose-bar > .btn-stop,
|
||||
.compose-bar > .session-badge {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Inline flex for the running-session controls */
|
||||
.compose-bar > template,
|
||||
.compose-bar {
|
||||
/* children from v-else template land here directly */
|
||||
}
|
||||
|
||||
/* Running-session controls sit in a flex row via compose-bar */
|
||||
:deep(.compose-bar) { } /* noop, just for clarity */
|
||||
|
||||
.compose-bar > *:not(.settings-panel):not(.compose-row):not(p) {
|
||||
/* session-running items: give them the compose padding */
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
/* Hack: running-session template children need horizontal layout */
|
||||
.session-info ~ .session-badge,
|
||||
.session-info ~ .btn-mic,
|
||||
.session-info ~ .btn-stop {
|
||||
/* siblings of session-info are in the same flex flow */
|
||||
}
|
||||
|
||||
/* Override: when session is running the compose-bar itself is the flex row */
|
||||
.compose-bar:has(.session-info) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────── */
|
||||
.btn-start,
|
||||
.btn-stop {
|
||||
padding: 0.5rem 1.25rem;
|
||||
|
|
@ -115,10 +284,29 @@ export default { name: "ComposeBar" };
|
|||
border: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn-start { background: #7c6af7; color: #fff; }
|
||||
.btn-start { background: var(--color-accent, #7c6af7); color: #fff; }
|
||||
.btn-stop { background: #374151; color: #e2e8f0; }
|
||||
.btn-start:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--color-border, #2a2d3a);
|
||||
background: transparent;
|
||||
color: var(--color-muted, #6b7280);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon:hover { color: var(--color-text, #e2e8f0); border-color: var(--color-muted, #6b7280); }
|
||||
.btn-icon.active { color: var(--color-accent, #7c6af7); border-color: var(--color-accent, #7c6af7); background: color-mix(in srgb, var(--color-accent, #7c6af7) 12%, transparent); }
|
||||
|
||||
/* ── Session info + badges ───────────────────────────────────────── */
|
||||
.session-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -139,16 +327,17 @@ export default { name: "ComposeBar" };
|
|||
box-shadow: 0 0 6px #34d39966;
|
||||
}
|
||||
|
||||
.elcor-toggle {
|
||||
font-size: 0.75rem;
|
||||
.session-badge {
|
||||
font-size: 0.68rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--color-muted, #6b7280);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
background: color-mix(in srgb, var(--color-border, #2a2d3a) 60%, transparent);
|
||||
border: 1px solid var(--color-border, #2a2d3a);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
}
|
||||
|
||||
/* ── Mic button ──────────────────────────────────────────────────── */
|
||||
.btn-mic {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
|
|
@ -165,12 +354,34 @@ export default { name: "ComposeBar" };
|
|||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
.btn-mic.loading {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.btn-mic:disabled { cursor: not-allowed; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.mic-spinner {
|
||||
display: inline-block;
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border: 2px solid var(--color-border, #2a2d3a);
|
||||
border-top-color: var(--color-muted, #6b7280);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Notices ─────────────────────────────────────────────────────── */
|
||||
.compose-error {
|
||||
width: 100%;
|
||||
font-size: 0.75rem;
|
||||
color: #ef4444;
|
||||
margin: 0;
|
||||
padding: 0 1.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.compose-notice {
|
||||
|
|
@ -178,5 +389,41 @@ export default { name: "ComposeBar" };
|
|||
font-size: 0.75rem;
|
||||
color: var(--color-muted, #6b7280);
|
||||
margin: 0;
|
||||
padding: 0 1.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.compose-warning {
|
||||
width: 100%;
|
||||
font-size: 0.72rem;
|
||||
color: #f59e0b;
|
||||
margin: 0;
|
||||
padding: 0.4rem 1.5rem 0.5rem;
|
||||
border-top: 1px solid color-mix(in srgb, #f59e0b 20%, transparent);
|
||||
background: color-mix(in srgb, #f59e0b 5%, transparent);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.compose-loading {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--color-muted, #6b7280);
|
||||
margin: 0;
|
||||
padding: 0.3rem 1.5rem 0.4rem;
|
||||
border-top: 1px solid var(--color-border, #2a2d3a);
|
||||
}
|
||||
|
||||
.compose-loading-spinner {
|
||||
display: inline-block;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border: 1.5px solid var(--color-border, #2a2d3a);
|
||||
border-top-color: var(--color-muted, #6b7280);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
137
frontend/src/components/DevPanel.vue
Normal file
137
frontend/src/components/DevPanel.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<!-- DevPanel.vue — dev-only cf-voice lifecycle controls and UI feature flags -->
|
||||
<!-- Only rendered when import.meta.env.DEV is true (Vite dev server) -->
|
||||
<template>
|
||||
<div class="dev-panel">
|
||||
<span class="dev-label">dev</span>
|
||||
<span class="dev-section">cf-voice</span>
|
||||
|
||||
<button class="dev-btn" :disabled="busy" @click="stop">Stop</button>
|
||||
<button class="dev-btn" :disabled="busy" @click="restart">Restart</button>
|
||||
|
||||
<span v-if="status" class="dev-status" :class="statusClass">{{ status }}</span>
|
||||
|
||||
<span class="dev-divider" aria-hidden="true">|</span>
|
||||
<span class="dev-section">ui</span>
|
||||
<button
|
||||
class="dev-btn dev-btn--toggle"
|
||||
:class="{ 'dev-btn--active': settings.threadView }"
|
||||
@click="settings.threadView = !settings.threadView"
|
||||
:title="settings.threadView ? 'Switch to v0.1.x (NowPanel)' : 'Switch to v0.2.x (ThreadView preview)'"
|
||||
>
|
||||
{{ settings.threadView ? 'v0.2 thread' : 'v0.1 now' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useSettingsStore } from "../stores/settings";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const apiBase = import.meta.env.VITE_API_BASE ?? "";
|
||||
const busy = ref(false);
|
||||
const status = ref<string | null>(null);
|
||||
const isError = ref(false);
|
||||
|
||||
const statusClass = computed(() => isError.value ? "dev-status--err" : "dev-status--ok");
|
||||
|
||||
async function call(action: "stop" | "restart") {
|
||||
busy.value = true;
|
||||
status.value = action === "restart" ? "restarting…" : "stopping…";
|
||||
isError.value = false;
|
||||
try {
|
||||
const resp = await fetch(`${apiBase}/dev/voice/${action}`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||
throw new Error(body.detail ?? resp.statusText);
|
||||
}
|
||||
status.value = action === "restart" ? "restarted" : "stopped";
|
||||
} catch (e: unknown) {
|
||||
isError.value = true;
|
||||
status.value = e instanceof Error ? e.message : "failed";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
// Clear status after 4 s
|
||||
setTimeout(() => { status.value = null; }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() { call("stop"); }
|
||||
function restart() { call("restart"); }
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: "DevPanel" };
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 1rem;
|
||||
background: color-mix(in srgb, #f59e0b 8%, var(--color-surface, #1a1d27));
|
||||
border-top: 1px solid color-mix(in srgb, #f59e0b 25%, transparent);
|
||||
font-size: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dev-label {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #f59e0b;
|
||||
background: color-mix(in srgb, #f59e0b 15%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
|
||||
border-radius: 0.2rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
}
|
||||
|
||||
.dev-section {
|
||||
color: var(--color-muted, #6b7280);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.dev-btn {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 0.3rem;
|
||||
border: 1px solid var(--color-border, #2a2d3a);
|
||||
background: transparent;
|
||||
color: var(--color-text, #e2e8f0);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.dev-btn:hover:not(:disabled) {
|
||||
border-color: #f59e0b;
|
||||
background: color-mix(in srgb, #f59e0b 10%, transparent);
|
||||
}
|
||||
.dev-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dev-status {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.dev-status--ok { color: #34d399; }
|
||||
.dev-status--err { color: #ef4444; }
|
||||
|
||||
.dev-divider {
|
||||
color: var(--color-border, #2a2d3a);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dev-btn--toggle {
|
||||
border-color: color-mix(in srgb, #7c6af7 40%, transparent);
|
||||
color: var(--color-muted, #6b7280);
|
||||
}
|
||||
.dev-btn--toggle.dev-btn--active {
|
||||
border-color: #7c6af7;
|
||||
color: #7c6af7;
|
||||
background: color-mix(in srgb, #7c6af7 12%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,8 +5,9 @@
|
|||
:key="i"
|
||||
class="history-item"
|
||||
:data-affect="evt.affect"
|
||||
:title="`${evt.label} (${(evt.confidence * 100).toFixed(0)}%)`"
|
||||
:title="`${speakerShort(evt.speaker_id)} · ${evt.label} (${(evt.confidence * 100).toFixed(0)}%)`"
|
||||
>
|
||||
<span v-if="speakerShort(evt.speaker_id)" class="history-spk" :data-spk="speakerSlot(evt.speaker_id)">{{ speakerShort(evt.speaker_id) }}</span>
|
||||
<span class="history-label">{{ evt.label }}</span>
|
||||
<span class="history-conf">{{ (evt.confidence * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
|
|
@ -25,6 +26,22 @@ const DISPLAY_COUNT = 8;
|
|||
const store = useSessionStore();
|
||||
// Most recent N events, newest last (scroll right)
|
||||
const recent = computed(() => store.events.slice(-DISPLAY_COUNT));
|
||||
|
||||
// Returns single-letter label for a speaker_id, or empty for silence/unknown
|
||||
function speakerShort(id: string | undefined): string {
|
||||
if (!id || id === "speaker_a") return "";
|
||||
if (id === "Multiple") return "M";
|
||||
const match = id.match(/Speaker\s+([A-Z]+)/i);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
// CSS slot key for per-speaker colour
|
||||
function speakerSlot(id: string | undefined): string {
|
||||
if (!id || id === "speaker_a") return "";
|
||||
if (id === "Multiple") return "multi";
|
||||
const match = id.match(/Speaker\s+([A-Z]+)/i);
|
||||
return match ? match[1].toLowerCase() : "a";
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
@ -64,6 +81,21 @@ export default { name: "HistoryStrip" };
|
|||
.history-item[data-affect="dismissive"] { border-color: #a78bfa66; }
|
||||
.history-item[data-affect="urgent"] { border-color: #fbbf2466; }
|
||||
|
||||
.history-spk {
|
||||
font-size: 0.58rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-weight: 700;
|
||||
padding: 0.05em 0.35em;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
align-self: center;
|
||||
}
|
||||
.history-spk[data-spk="a"] { background: #1e3050; color: #60a5fa; border-color: #60a5fa44; }
|
||||
.history-spk[data-spk="b"] { background: #2a1e3a; color: #c084fc; border-color: #c084fc44; }
|
||||
.history-spk[data-spk="c"] { background: #1e3a2f; color: #34d399; border-color: #34d39944; }
|
||||
.history-spk[data-spk="d"] { background: #2a2a1e; color: #fbbf24; border-color: #fbbf2444; }
|
||||
.history-spk[data-spk="multi"] { background: #2a1e1e; color: #f87171; border-color: #f8717144; }
|
||||
|
||||
.history-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text, #e2e8f0);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
<template>
|
||||
<div class="now-panel" :data-affect="affect">
|
||||
<!-- Row 1: speaker type + queue state badges -->
|
||||
<!-- Row 1: diarization identity + speaker type + queue state badges -->
|
||||
<div class="now-badges">
|
||||
<div v-if="diarSpeaker" class="now-diar-id" :data-diar="diarSpeakerSlot">
|
||||
{{ diarSpeaker }}
|
||||
</div>
|
||||
<div v-if="speakerLabel" class="now-speaker" :data-speaker="speakerKind">
|
||||
{{ speakerLabel }}
|
||||
</div>
|
||||
|
|
@ -11,6 +14,17 @@
|
|||
<div v-if="environLabel" class="now-environ-badge" :data-environ="environKind">
|
||||
{{ environLabel }}
|
||||
</div>
|
||||
<div v-if="sceneLabel" class="now-scene-badge" :data-scene="sceneKind">
|
||||
{{ sceneLabel }}
|
||||
</div>
|
||||
<div
|
||||
v-if="privacyVisible"
|
||||
class="now-privacy-badge"
|
||||
:data-privacy="privacyRisk"
|
||||
:title="privacyTitle"
|
||||
>
|
||||
{{ privacyBadgeText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live transcript strip -->
|
||||
|
|
@ -70,7 +84,22 @@ const annotationOutput = computed(() => {
|
|||
return parts.join(" ");
|
||||
});
|
||||
|
||||
// Speaker badge — hidden when no_speaker or no session
|
||||
// Diarization identity badge — shows "Speaker A", "Speaker B", "Multiple" from pyannote
|
||||
// Hidden when speaker_id is "speaker_a" (the silence/unknown default) or absent
|
||||
const diarSpeaker = computed(() => {
|
||||
const id = props.event?.speaker_id;
|
||||
if (!id || id === "speaker_a") return null;
|
||||
return id; // "Speaker A", "Speaker B", "Multiple"
|
||||
});
|
||||
// Slot letter for CSS colour mapping: "a" for Speaker A, "b" for Speaker B, etc.
|
||||
const diarSpeakerSlot = computed(() => {
|
||||
const id = props.event?.speaker_id ?? "";
|
||||
if (id === "Multiple") return "multi";
|
||||
const match = id.match(/Speaker\s+([A-Z]+)/i);
|
||||
return match ? match[1].toLowerCase() : "a";
|
||||
});
|
||||
|
||||
// Speaker type badge — hidden when no_speaker or no session
|
||||
const SPEAKER_LABELS: Record<string, string> = {
|
||||
human_single: "Human",
|
||||
human_multi: "Group",
|
||||
|
|
@ -117,6 +146,36 @@ const environLabel = computed(() => {
|
|||
});
|
||||
const environKind = computed(() => store.currentEnviron?.label ?? "");
|
||||
|
||||
// Acoustic scene badge
|
||||
const SCENE_LABELS: Record<string, string> = {
|
||||
indoor_quiet: "Quiet Indoor",
|
||||
indoor_crowd: "Crowded Indoor",
|
||||
outdoor_urban: "Urban",
|
||||
outdoor_nature: "Nature",
|
||||
vehicle: "Vehicle",
|
||||
public_transit: "Transit",
|
||||
};
|
||||
const sceneLabel = computed(() => {
|
||||
const raw = store.currentScene?.label;
|
||||
if (!raw) return null;
|
||||
return SCENE_LABELS[raw] ?? raw;
|
||||
});
|
||||
const sceneKind = computed(() => store.currentScene?.label ?? "");
|
||||
|
||||
// Privacy risk indicator — calm, informational only. No alarm states.
|
||||
const privacyRisk = computed(() => store.currentScene?.privacy_risk ?? "low");
|
||||
const privacyVisible = computed(() => privacyRisk.value !== "low");
|
||||
const PRIVACY_BADGE_TEXT: Record<string, string> = {
|
||||
moderate: "Private",
|
||||
high: "Private",
|
||||
};
|
||||
const PRIVACY_TITLES: Record<string, string> = {
|
||||
moderate: "This environment may be identifiable — local processing preferred.",
|
||||
high: "Private environment detected — audio is being processed locally only.",
|
||||
};
|
||||
const privacyBadgeText = computed(() => PRIVACY_BADGE_TEXT[privacyRisk.value] ?? "");
|
||||
const privacyTitle = computed(() => PRIVACY_TITLES[privacyRisk.value] ?? "");
|
||||
|
||||
// Live transcript strip
|
||||
const transcriptText = computed(() => store.currentTranscript?.text ?? null);
|
||||
</script>
|
||||
|
|
@ -156,10 +215,12 @@ export default { name: "NowPanel" };
|
|||
min-height: 1.4rem;
|
||||
}
|
||||
|
||||
/* Shared pill base for speaker / queue / environ badges */
|
||||
/* Shared pill base for speaker / queue / environ / scene / privacy badges */
|
||||
.now-speaker,
|
||||
.now-queue-badge,
|
||||
.now-environ-badge {
|
||||
.now-environ-badge,
|
||||
.now-scene-badge,
|
||||
.now-privacy-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
|
|
@ -173,6 +234,24 @@ export default { name: "NowPanel" };
|
|||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
/* Diarization identity badge — per-speaker colours */
|
||||
.now-diar-id {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.15em 0.6em;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
.now-diar-id[data-diar="a"] { background: #1e3050; color: #60a5fa; border-color: #60a5fa44; }
|
||||
.now-diar-id[data-diar="b"] { background: #2a1e3a; color: #c084fc; border-color: #c084fc44; }
|
||||
.now-diar-id[data-diar="c"] { background: #1e3a2f; color: #34d399; border-color: #34d39944; }
|
||||
.now-diar-id[data-diar="d"] { background: #2a2a1e; color: #fbbf24; border-color: #fbbf2444; }
|
||||
.now-diar-id[data-diar="multi"] { background: #2a1e1e; color: #f87171; border-color: #f8717144; }
|
||||
|
||||
/* Speaker type colours */
|
||||
.now-speaker[data-speaker="human_single"] { background: #1e3a2f; color: #34d399; border-color: #34d39933; }
|
||||
.now-speaker[data-speaker="human_multi"] { background: #1e3a2f; color: #34d399; border-color: #34d39933; }
|
||||
|
|
@ -186,11 +265,37 @@ export default { name: "NowPanel" };
|
|||
.now-queue-badge[data-queue="dtmf_tone"] { background: #1e2a3a; color: #60a5fa; border-color: #60a5fa33; }
|
||||
.now-queue-badge[data-queue="dead_air"] { background: #1e1e1e; color: #4b5563; border-color: #4b556333; }
|
||||
|
||||
/* Environment colours */
|
||||
/* Environment colours — telephony */
|
||||
.now-environ-badge[data-environ="call_center"] { background: #1e2a3a; color: #60a5fa; border-color: #60a5fa22; }
|
||||
.now-environ-badge[data-environ="music"] { background: #2a1e3a; color: #a78bfa; border-color: #a78bfa22; }
|
||||
.now-environ-badge[data-environ="background_shift"] { background: #2a2a1e; color: #fbbf24; border-color: #fbbf2422; }
|
||||
.now-environ-badge[data-environ="noise_floor_change"] { background: #2a1e1e; color: #f87171; border-color: #f8717122; }
|
||||
/* Environment colours — nature */
|
||||
.now-environ-badge[data-environ="birdsong"] { background: #1e2e1e; color: #86efac; border-color: #86efac22; }
|
||||
.now-environ-badge[data-environ="wind"] { background: #1e2a2e; color: #7dd3fc; border-color: #7dd3fc22; }
|
||||
.now-environ-badge[data-environ="rain"] { background: #1e222e; color: #93c5fd; border-color: #93c5fd22; }
|
||||
.now-environ-badge[data-environ="water"] { background: #1e2a2e; color: #67e8f9; border-color: #67e8f922; }
|
||||
/* Environment colours — urban */
|
||||
.now-environ-badge[data-environ="traffic"] { background: #2a201e; color: #fdba74; border-color: #fdba7422; }
|
||||
.now-environ-badge[data-environ="crowd_chatter"]{ background: #2a1e28; color: #d8b4fe; border-color: #d8b4fe22; }
|
||||
.now-environ-badge[data-environ="construction"] { background: #2a201e; color: #fb923c; border-color: #fb923c22; }
|
||||
/* Environment colours — indoor */
|
||||
.now-environ-badge[data-environ="hvac"] { background: #1e2226; color: #94a3b8; border-color: #94a3b822; }
|
||||
.now-environ-badge[data-environ="keyboard_typing"]{ background: #1e2226; color: #94a3b8; border-color: #94a3b822; }
|
||||
.now-environ-badge[data-environ="restaurant"] { background: #2a1e22; color: #fca5a5; border-color: #fca5a522; }
|
||||
|
||||
/* Acoustic scene badges — informational, cool tones */
|
||||
.now-scene-badge[data-scene="indoor_quiet"] { background: #1e2226; color: #94a3b8; border-color: #94a3b822; }
|
||||
.now-scene-badge[data-scene="indoor_crowd"] { background: #2a1e28; color: #c084fc; border-color: #c084fc22; }
|
||||
.now-scene-badge[data-scene="outdoor_urban"] { background: #2a201e; color: #fdba74; border-color: #fdba7422; }
|
||||
.now-scene-badge[data-scene="outdoor_nature"] { background: #1e2e1e; color: #86efac; border-color: #86efac22; }
|
||||
.now-scene-badge[data-scene="vehicle"] { background: #1e2226; color: #7dd3fc; border-color: #7dd3fc22; }
|
||||
.now-scene-badge[data-scene="public_transit"] { background: #1e2226; color: #7dd3fc; border-color: #7dd3fc22; }
|
||||
|
||||
/* Privacy risk indicator — calm, never alarming. Teal for both levels. */
|
||||
.now-privacy-badge { cursor: default; }
|
||||
.now-privacy-badge[data-privacy="moderate"]{ background: #1e2a2e; color: #67e8f9; border-color: #67e8f933; }
|
||||
.now-privacy-badge[data-privacy="high"] { background: #1e2a2e; color: #22d3ee; border-color: #22d3ee44; }
|
||||
|
||||
/* Live transcript strip */
|
||||
.now-transcript {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@
|
|||
import { ref, onUnmounted } from "vue";
|
||||
import {
|
||||
useSessionStore,
|
||||
type ServiceEvent,
|
||||
type SpeakerEvent,
|
||||
type TranscriptEvent,
|
||||
type QueueEvent,
|
||||
type SceneEvent,
|
||||
type AccentEvent,
|
||||
} from "../stores/session";
|
||||
import { useWakeLock } from "./useWakeLock";
|
||||
|
||||
|
|
@ -45,6 +48,15 @@ export function useToneStream() {
|
|||
source.addEventListener("environ-event", (e: MessageEvent) => {
|
||||
try { store.updateQueue(JSON.parse(e.data) as QueueEvent); } catch { /* */ }
|
||||
});
|
||||
source.addEventListener("scene-event", (e: MessageEvent) => {
|
||||
try { store.updateScene(JSON.parse(e.data) as SceneEvent); } catch { /* */ }
|
||||
});
|
||||
source.addEventListener("accent-event", (e: MessageEvent) => {
|
||||
try { store.updateAccent(JSON.parse(e.data) as AccentEvent); } catch { /* */ }
|
||||
});
|
||||
source.addEventListener("service-event", (e: MessageEvent) => {
|
||||
try { store.updateService(JSON.parse(e.data) as ServiceEvent); } catch { /* */ }
|
||||
});
|
||||
|
||||
source.onopen = () => { connected.value = true; };
|
||||
source.onerror = () => { connected.value = false; };
|
||||
|
|
|
|||
|
|
@ -13,6 +13,22 @@ export interface ToneEvent {
|
|||
affect: string;
|
||||
shift_direction: string;
|
||||
prosody_flags: string[];
|
||||
// Dimensional emotion (audeering model — Navigation v0.2.x)
|
||||
valence: number | null;
|
||||
arousal: number | null;
|
||||
dominance: number | null;
|
||||
// Prosodic signals (openSMILE — Navigation v0.2.x)
|
||||
sarcasm_risk: number | null;
|
||||
flat_f0_score: number | null;
|
||||
// Trajectory signals — null until baseline established (min 3 frames per speaker)
|
||||
arousal_delta: number | null;
|
||||
valence_delta: number | null;
|
||||
trend: string | null;
|
||||
// Coherence signals (SER vs VAD cross-comparison)
|
||||
coherence_score: number | null;
|
||||
suppression_flag: boolean | null;
|
||||
reframe_type: string | null;
|
||||
affect_divergence: number | null;
|
||||
}
|
||||
|
||||
export interface SpeakerEvent {
|
||||
|
|
@ -39,6 +55,31 @@ export interface QueueEvent {
|
|||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ServiceEvent {
|
||||
event_type: "service";
|
||||
session_id: string;
|
||||
status: "ready" | "loading" | "error";
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface SceneEvent {
|
||||
event_type: "scene";
|
||||
session_id: string;
|
||||
label: string;
|
||||
confidence: number;
|
||||
timestamp: number;
|
||||
privacy_risk: "low" | "moderate" | "high";
|
||||
}
|
||||
|
||||
export interface AccentEvent {
|
||||
event_type: "accent";
|
||||
session_id: string;
|
||||
region: string;
|
||||
language: string;
|
||||
confidence: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export const useSessionStore = defineStore("session", () => {
|
||||
const sessionId = ref<string | null>(null);
|
||||
const elcor = ref(false);
|
||||
|
|
@ -49,14 +90,35 @@ export const useSessionStore = defineStore("session", () => {
|
|||
const currentTranscript = ref<TranscriptEvent | null>(null);
|
||||
const currentQueue = ref<QueueEvent | null>(null);
|
||||
const currentEnviron = ref<QueueEvent | null>(null);
|
||||
const currentScene = ref<SceneEvent | null>(null);
|
||||
const currentAccent = ref<AccentEvent | null>(null);
|
||||
|
||||
// voiceReady: false until the backend probes cf-voice and emits service-event status="ready".
|
||||
// voiceError: non-null when the probe times out — surfaced as a warning in the UI.
|
||||
// voiceWarning: non-null for soft/non-fatal warnings (e.g. diarizer misconfigured).
|
||||
// voiceLoadingDetail: non-null while models are downloading (cleared when all stable).
|
||||
const voiceReady = ref(false);
|
||||
const voiceError = ref<string | null>(null);
|
||||
const voiceWarning = ref<string | null>(null);
|
||||
const voiceLoadingDetail = ref<string | null>(null);
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE ?? "";
|
||||
|
||||
async function startSession(withElcor = false) {
|
||||
async function startSession(opts: {
|
||||
elcor?: boolean;
|
||||
windowMs?: number;
|
||||
transcribeLang?: string;
|
||||
numSpeakers?: number;
|
||||
} = {}) {
|
||||
const resp = await fetch(`${apiBase}/session/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ elcor: withElcor }),
|
||||
body: JSON.stringify({
|
||||
elcor: opts.elcor ?? false,
|
||||
window_ms: opts.windowMs ?? 1000,
|
||||
transcribe_lang: opts.transcribeLang ?? "",
|
||||
num_speakers: opts.numSpeakers ?? 0,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Failed to start session: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
|
|
@ -64,6 +126,8 @@ export const useSessionStore = defineStore("session", () => {
|
|||
elcor.value = data.elcor;
|
||||
state.value = "running";
|
||||
events.value = [];
|
||||
voiceReady.value = false;
|
||||
voiceError.value = null;
|
||||
}
|
||||
|
||||
async function endSession() {
|
||||
|
|
@ -86,11 +150,45 @@ export const useSessionStore = defineStore("session", () => {
|
|||
currentTranscript.value = evt;
|
||||
}
|
||||
|
||||
function updateScene(evt: SceneEvent) {
|
||||
currentScene.value = evt;
|
||||
}
|
||||
|
||||
function updateAccent(evt: AccentEvent) {
|
||||
currentAccent.value = evt;
|
||||
}
|
||||
|
||||
// TTL timers — clear environ/queue badges when the signal drops out.
|
||||
// AST classifier emits nothing when confidence falls below threshold, so without
|
||||
// a TTL the badge stays stuck indefinitely (e.g. music badge after music stops).
|
||||
let _environTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let _queueTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const ENVIRON_TTL_MS = 5000;
|
||||
const QUEUE_TTL_MS = 4000;
|
||||
|
||||
function updateQueue(evt: QueueEvent) {
|
||||
if (evt.event_type === "queue") {
|
||||
currentQueue.value = evt;
|
||||
if (_queueTimer) clearTimeout(_queueTimer);
|
||||
_queueTimer = setTimeout(() => { currentQueue.value = null; }, QUEUE_TTL_MS);
|
||||
} else {
|
||||
currentEnviron.value = evt;
|
||||
if (_environTimer) clearTimeout(_environTimer);
|
||||
_environTimer = setTimeout(() => { currentEnviron.value = null; }, ENVIRON_TTL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function updateService(evt: ServiceEvent) {
|
||||
if (evt.status === "ready") {
|
||||
voiceReady.value = true;
|
||||
voiceError.value = null;
|
||||
} else if (evt.status === "error") {
|
||||
voiceError.value = evt.detail || "Voice service failed to start.";
|
||||
} else if (evt.status === "warning") {
|
||||
voiceWarning.value = evt.detail || null;
|
||||
} else if (evt.status === "loading") {
|
||||
// Empty detail = all models done loading; clear the indicator
|
||||
voiceLoadingDetail.value = evt.detail || null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,13 +201,22 @@ export const useSessionStore = defineStore("session", () => {
|
|||
currentTranscript.value = null;
|
||||
currentQueue.value = null;
|
||||
currentEnviron.value = null;
|
||||
currentScene.value = null;
|
||||
currentAccent.value = null;
|
||||
voiceReady.value = false;
|
||||
voiceError.value = null;
|
||||
voiceWarning.value = null;
|
||||
voiceLoadingDetail.value = null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId, elcor, state, events, latest,
|
||||
currentSpeaker, currentTranscript, currentQueue, currentEnviron,
|
||||
currentScene, currentAccent,
|
||||
voiceReady, voiceError, voiceWarning, voiceLoadingDetail,
|
||||
startSession, endSession, pushEvent,
|
||||
updateSpeaker, updateTranscript, updateQueue,
|
||||
updateSpeaker, updateTranscript, updateQueue, updateService,
|
||||
updateScene, updateAccent,
|
||||
reset,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
72
frontend/src/stores/settings.ts
Normal file
72
frontend/src/stores/settings.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// stores/settings.ts — persistent user preferences, survives page reload via localStorage.
|
||||
//
|
||||
// All settings here apply to the NEXT session start. Mid-session changes take
|
||||
// effect on the following session (the ComposeBar shows current-session values
|
||||
// as read-only badges while a session is running).
|
||||
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const STORAGE_KEY = "linnet:settings:v1";
|
||||
|
||||
interface PersistedSettings {
|
||||
windowMs: number;
|
||||
transcribeLang: string;
|
||||
elcor: boolean;
|
||||
numSpeakers: number; // 0 = auto-detect
|
||||
threadView: boolean; // dev-only: preview v0.2.x thread UI (toggled from DevPanel)
|
||||
}
|
||||
|
||||
const DEFAULTS: PersistedSettings = {
|
||||
windowMs: 1000,
|
||||
transcribeLang: "",
|
||||
elcor: false,
|
||||
numSpeakers: 0,
|
||||
threadView: false,
|
||||
};
|
||||
|
||||
function loadFromStorage(): PersistedSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
// Merge with defaults so new fields added after initial save get their defaults
|
||||
return { ...DEFAULTS, ...(JSON.parse(raw) as Partial<PersistedSettings>) };
|
||||
}
|
||||
} catch {
|
||||
// corrupt or missing — fall through to defaults
|
||||
}
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore("settings", () => {
|
||||
const saved = loadFromStorage();
|
||||
|
||||
const windowMs = ref<number>(saved.windowMs);
|
||||
const transcribeLang = ref<string>(saved.transcribeLang);
|
||||
const elcor = ref<boolean>(saved.elcor);
|
||||
// 0 = auto; 1–8 = fixed count passed to pyannote as min_speakers=max_speakers
|
||||
const numSpeakers = ref<number>(saved.numSpeakers);
|
||||
// Dev-only: show v0.2.x thread UI instead of v0.1.x NowPanel/HistoryStrip.
|
||||
// Toggled from DevPanel; not exposed in user-facing settings.
|
||||
const threadView = ref<boolean>(saved.threadView);
|
||||
|
||||
// Persist to localStorage whenever any setting changes
|
||||
watch(
|
||||
[windowMs, transcribeLang, elcor, numSpeakers, threadView],
|
||||
() => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
windowMs: windowMs.value,
|
||||
transcribeLang: transcribeLang.value,
|
||||
elcor: elcor.value,
|
||||
numSpeakers: numSpeakers.value,
|
||||
threadView: threadView.value,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ deep: false },
|
||||
);
|
||||
|
||||
return { windowMs, transcribeLang, elcor, numSpeakers, threadView };
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ export default defineConfig({
|
|||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 8521,
|
||||
allowedHosts: ["menagerie.circuitforge.tech"],
|
||||
proxy: {
|
||||
"/session": {
|
||||
target: "http://localhost:8522",
|
||||
|
|
@ -22,6 +23,10 @@ export default defineConfig({
|
|||
target: "http://localhost:8522",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/dev": {
|
||||
target: "http://localhost:8522",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
15
manage.sh
15
manage.sh
|
|
@ -2,6 +2,12 @@
|
|||
# manage.sh — Linnet dev/demo/cloud management script
|
||||
set -euo pipefail
|
||||
|
||||
# Source .env early so LINNET_PORT, LINNET_FRONTEND_PORT, VITE_*, and all
|
||||
# other vars are available before any assignments below read them.
|
||||
if [[ -f .env ]]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
CMD=${1:-help}
|
||||
PROFILE=${2:-dev} # dev | demo | cloud
|
||||
API_PORT=${LINNET_PORT:-8522}
|
||||
|
|
@ -36,9 +42,14 @@ case "$CMD" in
|
|||
|
||||
start)
|
||||
_check_env
|
||||
# Re-source after _check_env in case .env was just created from .env.example
|
||||
set -a; source .env; set +a
|
||||
# Recompute ports in case .env was just created with different values
|
||||
API_PORT=${LINNET_PORT:-8522}
|
||||
FE_PORT=${LINNET_FRONTEND_PORT:-8521}
|
||||
if [[ "$PROFILE" == "dev" ]]; then
|
||||
info "Starting Linnet API on :${API_PORT} (mock mode, dev)…"
|
||||
CF_VOICE_MOCK=1 conda run -n cf uvicorn app.main:app \
|
||||
info "Starting Linnet API on :${API_PORT}…"
|
||||
conda run -n cf uvicorn app.main:app \
|
||||
--host 0.0.0.0 --port "$API_PORT" --reload &
|
||||
info "Starting Linnet frontend on :${FE_PORT}…"
|
||||
cd frontend && npm install --silent && npm run dev &
|
||||
|
|
|
|||
214
tests/test_pinning.py
Normal file
214
tests/test_pinning.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
# tests/test_pinning.py — session pinning (Paid tier TTL) and translator
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.session import Session
|
||||
from app.services import session_store
|
||||
from app.services.translator import Translator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_sessions():
|
||||
session_store._sessions.clear()
|
||||
session_store._tasks.clear()
|
||||
session_store._audio_buffers.clear()
|
||||
yield
|
||||
session_store._sessions.clear()
|
||||
session_store._tasks.clear()
|
||||
session_store._audio_buffers.clear()
|
||||
|
||||
|
||||
# ── Tier-aware reaper (_should_reap) ─────────────────────────────────────────
|
||||
|
||||
def test_free_session_reaped_after_subscriber_leaves(monkeypatch):
|
||||
monkeypatch.setattr("app.services.session_store.settings.session_idle_ttl_s", 0)
|
||||
s = Session(tier="free")
|
||||
s.state = "running"
|
||||
q = s.subscribe()
|
||||
s.unsubscribe(q) # triggers last_subscriber_left_at
|
||||
now = time.monotonic() + 1
|
||||
assert session_store._should_reap(s, now) is True
|
||||
|
||||
|
||||
def test_free_session_spared_while_subscriber_active(monkeypatch):
|
||||
monkeypatch.setattr("app.services.session_store.settings.session_idle_ttl_s", 0)
|
||||
s = Session(tier="free")
|
||||
s.state = "running"
|
||||
s.subscribe()
|
||||
now = time.monotonic() + 9999
|
||||
assert session_store._should_reap(s, now) is False
|
||||
|
||||
|
||||
def test_paid_session_reaped_on_activity_timeout(monkeypatch):
|
||||
monkeypatch.setattr("app.services.session_store.settings.session_paid_ttl_s", 0)
|
||||
s = Session(tier="paid")
|
||||
s.state = "running"
|
||||
now = time.monotonic() + 1
|
||||
assert session_store._should_reap(s, now) is True
|
||||
|
||||
|
||||
def test_paid_session_spared_within_activity_window(monkeypatch):
|
||||
monkeypatch.setattr("app.services.session_store.settings.session_paid_ttl_s", 9999)
|
||||
s = Session(tier="paid")
|
||||
s.state = "running"
|
||||
now = time.monotonic() + 1
|
||||
assert session_store._should_reap(s, now) is False
|
||||
|
||||
|
||||
def test_paid_session_not_reaped_despite_no_subscribers(monkeypatch):
|
||||
"""Paid tier ignores subscriber state — activity window is what matters."""
|
||||
monkeypatch.setattr("app.services.session_store.settings.session_paid_ttl_s", 9999)
|
||||
s = Session(tier="paid")
|
||||
s.state = "running"
|
||||
q = s.subscribe()
|
||||
s.unsubscribe(q) # no subscribers now
|
||||
now = time.monotonic() + 200 # past free-tier TTL but within paid TTL
|
||||
assert session_store._should_reap(s, now) is False
|
||||
|
||||
|
||||
def test_stopped_session_never_reaped():
|
||||
s = Session(tier="free")
|
||||
s.state = "stopped"
|
||||
assert session_store._should_reap(s, time.monotonic() + 9999) is False
|
||||
|
||||
|
||||
def test_broadcast_updates_last_activity_at():
|
||||
from app.models.tone_event import ToneEvent
|
||||
s = Session(tier="paid")
|
||||
before = s.last_activity_at
|
||||
time.sleep(0.01)
|
||||
tone = ToneEvent(
|
||||
session_id=s.session_id, label="Neutral", confidence=0.9,
|
||||
speaker_id="speaker_a", shift_magnitude=0.0, timestamp=0.0,
|
||||
subtext="", affect="neutral",
|
||||
)
|
||||
s.broadcast(tone)
|
||||
assert s.last_activity_at > before
|
||||
|
||||
|
||||
# ── Snapshot save on end ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_saved_for_paid_session_with_history():
|
||||
from app.models.tone_event import ToneEvent
|
||||
session = await session_store.create_session(tier="paid", user_id="u1")
|
||||
tone = ToneEvent(
|
||||
session_id=session.session_id, label="Stressed", confidence=0.85,
|
||||
speaker_id="Speaker A", shift_magnitude=0.1, timestamp=1.0,
|
||||
subtext="", affect="stressed",
|
||||
)
|
||||
session.history.append(tone)
|
||||
|
||||
with patch("app.services.session_store._save_snapshot") as mock_save:
|
||||
await session_store.end_session(session.session_id)
|
||||
mock_save.assert_called_once_with(session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_not_saved_for_free_session():
|
||||
session = await session_store.create_session(tier="free", user_id="u1")
|
||||
from app.models.tone_event import ToneEvent
|
||||
tone = ToneEvent(
|
||||
session_id=session.session_id, label="Neutral", confidence=0.9,
|
||||
speaker_id="speaker_a", shift_magnitude=0.0, timestamp=0.0,
|
||||
subtext="", affect="neutral",
|
||||
)
|
||||
session.history.append(tone)
|
||||
|
||||
with patch("app.services.session_store._save_snapshot") as mock_save:
|
||||
await session_store.end_session(session.session_id)
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_not_saved_for_empty_paid_session():
|
||||
session = await session_store.create_session(tier="paid", user_id="u1")
|
||||
# No events in history
|
||||
with patch("app.services.session_store._save_snapshot") as mock_save:
|
||||
await session_store.end_session(session.session_id)
|
||||
mock_save.assert_not_called()
|
||||
|
||||
|
||||
# ── Translator ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_session(target_lang="", byok="", tier="free"):
|
||||
s = Session(tier=tier, target_lang=target_lang, byok_deepl_key=byok)
|
||||
return s
|
||||
|
||||
|
||||
def _mock_deepl_resp(translated: str):
|
||||
resp = MagicMock()
|
||||
resp.ok = True
|
||||
resp.json.return_value = {"translations": [{"text": translated}]}
|
||||
return resp
|
||||
|
||||
|
||||
def test_translator_none_when_no_target_lang():
|
||||
s = _make_session(target_lang="")
|
||||
assert Translator.for_session(s) is None
|
||||
|
||||
|
||||
def test_translator_none_when_no_key(monkeypatch):
|
||||
monkeypatch.setattr("app.services.translator.settings.deepl_api_key", "")
|
||||
s = _make_session(target_lang="fr")
|
||||
assert Translator.for_session(s) is None
|
||||
|
||||
|
||||
def test_translator_uses_byok_key(monkeypatch):
|
||||
monkeypatch.setattr("app.services.translator.settings.deepl_api_key", "")
|
||||
s = _make_session(target_lang="fr", byok="my-deepl-key")
|
||||
t = Translator.for_session(s)
|
||||
assert t is not None
|
||||
assert t._url.endswith("api-free.deepl.com/v2/translate")
|
||||
|
||||
|
||||
def test_translator_uses_cf_key(monkeypatch):
|
||||
monkeypatch.setattr("app.services.translator.settings.deepl_api_key", "cf-pro-key")
|
||||
s = _make_session(target_lang="es")
|
||||
t = Translator.for_session(s)
|
||||
assert t is not None
|
||||
assert t._url.endswith("api.deepl.com/v2/translate")
|
||||
|
||||
|
||||
def test_translator_byok_takes_precedence_over_cf_key(monkeypatch):
|
||||
monkeypatch.setattr("app.services.translator.settings.deepl_api_key", "cf-pro-key")
|
||||
s = _make_session(target_lang="de", byok="user-free-key")
|
||||
t = Translator.for_session(s)
|
||||
assert t._url.endswith("api-free.deepl.com/v2/translate")
|
||||
assert t._api_key == "user-free-key"
|
||||
|
||||
|
||||
def test_translate_calls_deepl():
|
||||
t = Translator(target_lang="fr", api_key="k", pro=False)
|
||||
with patch("app.services.translator.requests.post", return_value=_mock_deepl_resp("Stressé")):
|
||||
result = t.translate("Stressed")
|
||||
assert result == "Stressé"
|
||||
|
||||
|
||||
def test_translate_caches_result():
|
||||
t = Translator(target_lang="fr", api_key="k", pro=False)
|
||||
with patch("app.services.translator.requests.post", return_value=_mock_deepl_resp("Stressé")) as mock_post:
|
||||
t.translate("Stressed")
|
||||
t.translate("Stressed")
|
||||
assert mock_post.call_count == 1
|
||||
|
||||
|
||||
def test_translate_returns_original_on_error():
|
||||
t = Translator(target_lang="fr", api_key="k", pro=False)
|
||||
with patch("app.services.translator.requests.post", side_effect=Exception("timeout")):
|
||||
result = t.translate("Stressed")
|
||||
assert result == "Stressed"
|
||||
|
||||
|
||||
def test_translate_target_lang_uppercased():
|
||||
t = Translator(target_lang="fr", api_key="k", pro=False)
|
||||
assert t._target_lang == "FR"
|
||||
|
||||
|
||||
def test_translate_empty_label_returns_empty():
|
||||
t = Translator(target_lang="fr", api_key="k", pro=False)
|
||||
assert t.translate("") == ""
|
||||
|
|
@ -1,34 +1,156 @@
|
|||
# tests/test_tiers.py
|
||||
# tests/test_tiers.py — tier gate and Heimdall validation logic
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.tiers import is_paid, require_paid
|
||||
from app import tiers
|
||||
|
||||
|
||||
def test_valid_linnet_key():
|
||||
assert is_paid("CFG-LNNT-AAAA-BBBB-CCCC") is True
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_tier_cache():
|
||||
"""Wipe the in-process tier cache between tests."""
|
||||
tiers._cache.clear()
|
||||
yield
|
||||
tiers._cache.clear()
|
||||
|
||||
|
||||
def test_invalid_prefix():
|
||||
assert is_paid("CFG-PRNG-AAAA-BBBB-CCCC") is False
|
||||
# ── Key-based resolution ──────────────────────────────────────────────────────
|
||||
|
||||
def _mock_verify(tier: str, valid: bool = True):
|
||||
"""Return a mock requests.Response for /v1/licenses/verify."""
|
||||
resp = MagicMock()
|
||||
resp.ok = valid
|
||||
resp.json.return_value = {"valid": valid, "tier": tier, "user_id": "u123"}
|
||||
return resp
|
||||
|
||||
|
||||
def test_empty_key():
|
||||
assert is_paid("") is False
|
||||
def test_get_tier_free_when_no_key(monkeypatch):
|
||||
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
||||
assert tiers.get_tier() == "free"
|
||||
|
||||
|
||||
def test_none_key():
|
||||
assert is_paid(None) is False # type: ignore[arg-type]
|
||||
def test_get_tier_paid_via_key(monkeypatch):
|
||||
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
||||
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")) as mock_post:
|
||||
result = tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
||||
assert result == "paid"
|
||||
mock_post.assert_called_once()
|
||||
assert "/v1/licenses/verify" in mock_post.call_args[0][0]
|
||||
|
||||
|
||||
def test_require_paid_raises_402():
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
require_paid(license_key="bad-key")
|
||||
def test_get_tier_free_on_invalid_key(monkeypatch):
|
||||
with patch("app.tiers.requests.post", return_value=_mock_verify("free", valid=False)):
|
||||
result = tiers.get_tier(license_key="CFG-LNNT-DEAD-BEEF-0000")
|
||||
assert result == "free"
|
||||
|
||||
|
||||
def test_get_tier_free_on_heimdall_error(monkeypatch):
|
||||
with patch("app.tiers.requests.post", side_effect=Exception("timeout")):
|
||||
result = tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
||||
assert result == "free"
|
||||
|
||||
|
||||
def test_get_tier_uses_env_key(monkeypatch):
|
||||
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "CFG-LNNT-ENV0-ENV0-ENV0")
|
||||
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")) as mock_post:
|
||||
result = tiers.get_tier()
|
||||
assert result == "paid"
|
||||
assert mock_post.call_args[1]["json"]["key"] == "CFG-LNNT-ENV0-ENV0-ENV0"
|
||||
|
||||
|
||||
def test_key_result_is_cached(monkeypatch):
|
||||
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
||||
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")) as mock_post:
|
||||
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
||||
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
||||
# Second call must hit cache, not Heimdall
|
||||
assert mock_post.call_count == 1
|
||||
|
||||
|
||||
def test_network_error_does_not_cache(monkeypatch):
|
||||
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
||||
with patch("app.tiers.requests.post", side_effect=Exception("timeout")) as mock_post:
|
||||
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
||||
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
||||
# Network errors are NOT cached — both calls hit Heimdall
|
||||
assert mock_post.call_count == 2
|
||||
|
||||
|
||||
# ── Cloud user resolution ─────────────────────────────────────────────────────
|
||||
|
||||
def _mock_cloud_resolve(tier: str):
|
||||
resp = MagicMock()
|
||||
resp.ok = True
|
||||
resp.json.return_value = {"tier": tier, "user_id": "u456"}
|
||||
return resp
|
||||
|
||||
|
||||
def test_get_tier_cloud_paid(monkeypatch):
|
||||
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "tok-admin")
|
||||
with patch("app.tiers.requests.post", return_value=_mock_cloud_resolve("paid")) as mock_post:
|
||||
result = tiers.get_tier(user_id="user-uuid-123")
|
||||
assert result == "paid"
|
||||
assert "/admin/cloud/resolve" in mock_post.call_args[0][0]
|
||||
|
||||
|
||||
def test_get_tier_cloud_free_when_no_admin_token(monkeypatch):
|
||||
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "")
|
||||
result = tiers.get_tier(user_id="user-uuid-123")
|
||||
assert result == "free"
|
||||
|
||||
|
||||
def test_user_id_takes_precedence_over_key(monkeypatch):
|
||||
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "tok-admin")
|
||||
with patch("app.tiers.requests.post", return_value=_mock_cloud_resolve("premium")) as mock_post:
|
||||
result = tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC", user_id="u999")
|
||||
assert result == "premium"
|
||||
assert "/admin/cloud/resolve" in mock_post.call_args[0][0]
|
||||
|
||||
|
||||
def test_cloud_result_is_cached(monkeypatch):
|
||||
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "tok-admin")
|
||||
with patch("app.tiers.requests.post", return_value=_mock_cloud_resolve("paid")) as mock_post:
|
||||
tiers.get_tier(user_id="u-cached")
|
||||
tiers.get_tier(user_id="u-cached")
|
||||
assert mock_post.call_count == 1
|
||||
|
||||
|
||||
# ── is_paid() and require_paid() ─────────────────────────────────────────────
|
||||
|
||||
def test_is_paid_true_for_paid_tier(monkeypatch):
|
||||
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
||||
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")):
|
||||
assert tiers.is_paid(license_key="CFG-LNNT-AAAA-BBBB-CCCC") is True
|
||||
|
||||
|
||||
def test_is_paid_true_for_premium_tier(monkeypatch):
|
||||
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
||||
with patch("app.tiers.requests.post", return_value=_mock_verify("premium")):
|
||||
assert tiers.is_paid(license_key="CFG-LNNT-PREM-PREM-PREM") is True
|
||||
|
||||
|
||||
def test_is_paid_false_for_free(monkeypatch):
|
||||
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
|
||||
assert tiers.is_paid() is False
|
||||
|
||||
|
||||
def test_require_paid_raises_402_for_free():
|
||||
with patch("app.tiers.get_tier", return_value="free"):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
tiers.require_paid()
|
||||
assert exc_info.value.status_code == 402
|
||||
|
||||
|
||||
def test_require_paid_passes():
|
||||
# Should not raise
|
||||
require_paid(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
|
||||
def test_require_paid_passes_for_paid():
|
||||
with patch("app.tiers.get_tier", return_value="paid"):
|
||||
tiers.require_paid() # must not raise
|
||||
|
||||
|
||||
def test_require_paid_byok_skips_check():
|
||||
# byok_deepl=True bypasses the tier gate regardless of tier
|
||||
with patch("app.tiers.get_tier", return_value="free") as mock_get:
|
||||
tiers.require_paid(byok_deepl=True) # must not raise
|
||||
mock_get.assert_not_called()
|
||||
|
|
|
|||
Loading…
Reference in a new issue