linnet/app/api/sessions.py
pyr0ball b92285b4b3 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
2026-06-24 15:25:46 -07:00

96 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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; 18 = 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 08
)
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,
)