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
96 lines
3.8 KiB
Python
96 lines
3.8 KiB
Python
# app/api/sessions.py — session lifecycle endpoints
|
||
from __future__ import annotations
|
||
|
||
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"])
|
||
|
||
|
||
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(
|
||
request: Request,
|
||
req: StartRequest = StartRequest(),
|
||
) -> SessionResponse:
|
||
"""Start a new annotation session and begin streaming VoiceFrames."""
|
||
# 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,
|
||
)
|
||
|
||
|
||
@router.delete("/{session_id}/end")
|
||
async def end_session(session_id: str) -> dict:
|
||
"""Stop a session and release its classifier."""
|
||
removed = await session_store.end_session(session_id)
|
||
if not removed:
|
||
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
|
||
return {"session_id": session_id, "state": "stopped"}
|
||
|
||
|
||
@router.get("/{session_id}")
|
||
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,
|
||
)
|