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

84 lines
2.8 KiB
Python

# app/main.py — Linnet FastAPI application factory
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import audio, corrections, dev, events, export, history, samples, sessions, snapshots
from app.config import settings
from app.services import session_store
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s%(message)s",
)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: begin idle session reaper
session_store.start_reaper()
yield
# Shutdown: cancel the reaper cleanly
await session_store.stop_reaper()
app = FastAPI(
title="Linnet",
description="Real-time tone annotation — tonal subtext labels for ND/autistic users",
version="0.1.0",
lifespan=lifespan,
)
# ── Mode middleware (applied before CORS so headers are always present) ──────
if settings.demo_mode:
from app.middleware.demo import DemoModeMiddleware
app.add_middleware(DemoModeMiddleware)
logging.getLogger(__name__).info("DEMO_MODE active")
if settings.cloud_mode:
from app.middleware.cloud import CloudAuthMiddleware
app.add_middleware(CloudAuthMiddleware)
logging.getLogger(__name__).info("CLOUD_MODE active")
# ── CORS ─────────────────────────────────────────────────────────────────────
_frontend_port = str(settings.linnet_frontend_port)
_origins = [
f"http://localhost:{_frontend_port}",
f"http://127.0.0.1:{_frontend_port}",
]
if settings.cloud_mode:
_origins += [
"https://menagerie.circuitforge.tech",
"https://circuitforge.tech",
]
app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(sessions.router)
app.include_router(events.router)
if not settings.demo_mode and not settings.cloud_mode:
app.include_router(dev.router)
app.include_router(history.router)
app.include_router(audio.router)
app.include_router(export.router)
app.include_router(samples.router)
app.include_router(corrections.router, prefix="/corrections", tags=["corrections"])
app.include_router(snapshots.router)
@app.get("/health")
def health() -> dict:
mode = "demo" if settings.demo_mode else ("cloud" if settings.cloud_mode else "dev")
return {"status": "ok", "service": "linnet", "mode": mode}