linnet/app/api/snapshots.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

156 lines
5 KiB
Python

# 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),
)