linnet/app/models/session.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

118 lines
4.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/models/session.py — Session dataclass
from __future__ import annotations
import asyncio
import time
import uuid
from dataclasses import dataclass, field
from typing import Literal, Protocol, runtime_checkable
from app.models.tone_event import ToneEvent
SessionState = Literal["starting", "running", "stopped"]
@runtime_checkable
class SessionEvent(Protocol):
"""Any event that can be broadcast over SSE from a session."""
def to_sse(self) -> str: ...
@dataclass
class Session:
"""
An active annotation session.
The session owns the subscriber queue fan-out: each SSE connection
subscribes by calling subscribe() and gets its own asyncio.Queue.
The session_store fans out ToneEvents to all queues via broadcast().
"""
session_id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
state: SessionState = "starting"
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 = ""
# 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; 18 = 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 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:
return len(self._subscribers)
def subscribe(self) -> asyncio.Queue:
"""Add an SSE subscriber. Returns its dedicated queue."""
q: asyncio.Queue = asyncio.Queue(maxsize=100)
self._subscribers.append(q)
# Reset idle clock — someone is watching again
self.last_subscriber_left_at = None
return q
def unsubscribe(self, q: asyncio.Queue) -> None:
try:
self._subscribers.remove(q)
except ValueError:
pass
# Start the idle clock when the last subscriber leaves
if not self._subscribers:
self.last_subscriber_left_at = time.monotonic()
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) >= 200:
self.history.pop(0)
self.history.append(event)
dead: list[asyncio.Queue] = []
for q in self._subscribers:
try:
q.put_nowait(event)
except asyncio.QueueFull:
dead.append(q)
for q in dead:
self.unsubscribe(q)