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
156 lines
5 KiB
Python
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),
|
|
)
|