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
This commit is contained in:
pyr0ball 2026-06-24 15:25:46 -07:00
parent 6e7cf17b90
commit b92285b4b3
29 changed files with 2372 additions and 136 deletions

View file

@ -1,6 +1,20 @@
LINNET_LICENSE_KEY=
CF_VOICE_MOCK=1
CF_VOICE_MOCK=0
CF_VOICE_WHISPER_MODEL=small
CF_VOICE_DIARIZE=0
HF_TOKEN=
LINNET_PORT=8522
LINNET_FRONTEND_PORT=8521
# cf-orch coordinator URL — enables real cf-voice sidecar allocation per session.
# Leave empty to fall back to in-process mock (also set CF_VOICE_MOCK=1 for mock audio).
CF_ORCH_URL=
# cf-orch agent URL — local service lifecycle (stop/start).
# Coordinator (:7700) = read/scheduling; Agent (:7701) = write operations.
CF_ORCH_AGENT_URL=
# Vite dev server base paths — set when serving under a sub-path (e.g. /linnet/ via Caddy).
# Leave empty for root-path local dev (http://localhost:8521).
VITE_BASE_URL=
VITE_API_BASE=

169
.nfs0000000000bc6a8c00021df0 Executable file
View file

@ -0,0 +1,169 @@
#!/usr/bin/env bash
# manage.sh — Linnet dev/demo/cloud management script
set -euo pipefail
CMD=${1:-help}
PROFILE=${2:-dev} # dev | demo | cloud
API_PORT=${LINNET_PORT:-8522}
FE_PORT=${LINNET_FRONTEND_PORT:-8521}
# ── Helpers ───────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
info() { echo -e "${GREEN}[linnet]${NC} $*"; }
warn() { echo -e "${YELLOW}[linnet]${NC} $*"; }
error() { echo -e "${RED}[linnet]${NC} $*" >&2; exit 1; }
_check_env() {
if [[ ! -f .env ]]; then
warn "No .env found — copying .env.example"
cp .env.example .env
fi
}
_compose_cmd() {
# Returns the correct compose file flags for a given profile
case "$1" in
demo) echo "-f compose.demo.yml -p linnet-demo" ;;
cloud) echo "-f compose.cloud.yml -p linnet-cloud" ;;
test) echo "-f compose.test.yml" ;;
dev) echo "-f compose.yml" ;;
*) error "Unknown profile: $1. Use dev|demo|cloud|test." ;;
esac
}
# ── Commands ──────────────────────────────────────────────────────────────────
case "$CMD" in
start)
_check_env
if [[ "$PROFILE" == "dev" ]]; then
# Load .env into the current shell so uvicorn and Vite inherit all settings
# (CF_VOICE_MOCK, CF_ORCH_URL, VITE_BASE_URL, VITE_API_BASE, etc.)
set -a; source .env; set +a
info "Starting Linnet API on :${API_PORT}…"
conda run -n cf uvicorn app.main:app \
--host 0.0.0.0 --port "$API_PORT" --reload &
info "Starting Linnet frontend on :${FE_PORT}…"
cd frontend && npm install --silent && npm run dev &
info "API: http://localhost:${API_PORT}"
info "UI: http://localhost:${FE_PORT}"
wait
else
FLAGS=$(_compose_cmd "$PROFILE")
info "Starting Linnet ($PROFILE profile)…"
# shellcheck disable=SC2086
docker compose $FLAGS up -d --build
case "$PROFILE" in
demo) info "Frontend: http://localhost:8524" ;;
cloud) info "Frontend: http://localhost:8527 → menagerie.circuitforge.tech/linnet" ;;
esac
fi
;;
stop)
if [[ "$PROFILE" == "dev" ]]; then
info "Stopping Linnet dev processes…"
pkill -f "uvicorn app.main:app" 2>/dev/null || true
pkill -f "vite" 2>/dev/null || true
else
FLAGS=$(_compose_cmd "$PROFILE")
# shellcheck disable=SC2086
docker compose $FLAGS down
fi
info "Stopped ($PROFILE)."
;;
restart)
"$0" stop "$PROFILE"
"$0" start "$PROFILE"
;;
status)
case "$PROFILE" in
dev)
echo -n "API: "
curl -sf "http://localhost:${API_PORT}/health" 2>/dev/null \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'], '|', d.get('mode','dev'))" \
|| echo "not running"
echo -n "Frontend: "
curl -sf "http://localhost:${FE_PORT}" -o /dev/null && echo "running" || echo "not running"
;;
demo)
echo -n "Demo API: "; curl -sf "http://localhost:8523/health" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'])" || echo "not running"
echo -n "Demo Web: "; curl -sf "http://localhost:8524" -o /dev/null && echo "running" || echo "not running"
;;
cloud)
echo -n "Cloud API: "; curl -sf "http://localhost:8522/health" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'])" || echo "not running"
echo -n "Cloud Web: "; curl -sf "http://localhost:8527" -o /dev/null && echo "running" || echo "not running"
;;
esac
;;
test)
_check_env
if [[ "$PROFILE" == "dev" ]]; then
info "Running test suite (local)…"
CF_VOICE_MOCK=1 conda run -n cf python -m pytest tests/ -v "${@:3}"
else
info "Running test suite (Docker)…"
docker compose -f compose.test.yml run --rm linnet-test "${@:3}"
fi
;;
logs)
if [[ "$PROFILE" == "dev" ]]; then
warn "Dev mode: check terminal output."
else
FLAGS=$(_compose_cmd "$PROFILE")
SERVICE=${3:-}
# shellcheck disable=SC2086
docker compose $FLAGS logs -f $SERVICE
fi
;;
build)
info "Building Docker images ($PROFILE)…"
FLAGS=$(_compose_cmd "$PROFILE")
# shellcheck disable=SC2086
docker compose $FLAGS build
info "Build complete."
;;
open)
case "$PROFILE" in
demo) URL="http://localhost:8524" ;;
cloud) URL="http://localhost:8527" ;;
*) URL="http://localhost:${FE_PORT}" ;;
esac
xdg-open "$URL" 2>/dev/null || open "$URL" 2>/dev/null || info "Open $URL in your browser"
;;
help|*)
echo ""
echo " Usage: $0 <command> [profile]"
echo ""
echo " Commands:"
echo -e " ${GREEN}start${NC} [profile] Start API + frontend"
echo -e " ${GREEN}stop${NC} [profile] Stop services"
echo -e " ${GREEN}restart${NC} [profile] Stop then start"
echo -e " ${GREEN}status${NC} [profile] Check running services"
echo -e " ${GREEN}test${NC} [profile] Run pytest suite"
echo -e " ${GREEN}logs${NC} [profile] Tail logs (Docker profiles only)"
echo -e " ${GREEN}build${NC} [profile] Build Docker images"
echo -e " ${GREEN}open${NC} [profile] Open UI in browser"
echo ""
echo " Profiles:"
echo " dev (default) Local uvicorn + Vite dev server, mock mode"
echo " demo Docker: DEMO_MODE=true, port 8524"
echo " cloud Docker: CLOUD_MODE=true, port 8527 → menagerie.circuitforge.tech/linnet"
echo " test Docker: pytest suite, hermetic"
echo ""
echo " Examples:"
echo " $0 start # dev mode"
echo " $0 start demo # demo stack"
echo " $0 test # local pytest"
echo " $0 test docker # pytest in Docker"
echo " $0 logs demo # demo logs"
echo ""
;;
esac

View file

@ -148,3 +148,7 @@ Linnet is developed and maintained on Forgejo at [git.opensourcesolarpunk.com/Ci
---
*Linnet is a product of [CircuitForge LLC](https://circuitforge.tech) — privacy-first, safety-first, accessible AI tools for the tasks the system made hard on purpose.*
---
Humans own design, architecture, code review, testing, and verification. LLMs are part of our development workflow. [Our positions on LLM use →](https://circuitforge.tech/positions)

79
app/api/dev.py Normal file
View file

@ -0,0 +1,79 @@
# app/api/dev.py — dev-only endpoints for local service management
#
# Proxies stop/start calls to the cf-orch agent so the frontend dev panel
# doesn't need to know the agent URL directly.
# Not registered in cloud or demo mode.
from __future__ import annotations
import logging
import httpx
from fastapi import APIRouter, HTTPException
from app.config import settings
router = APIRouter(prefix="/dev", tags=["dev"])
logger = logging.getLogger(__name__)
def _agent_url() -> str:
url = settings.cf_orch_agent_url
if not url:
raise HTTPException(503, detail="CF_ORCH_AGENT_URL not configured")
return url.rstrip("/")
@router.post("/voice/stop")
async def voice_stop() -> dict:
"""Stop the cf-voice sidecar via the cf-orch agent."""
agent = _agent_url()
async with httpx.AsyncClient(timeout=10) as client:
try:
resp = await client.post(f"{agent}/services/cf-voice/stop", json={})
except httpx.RequestError as exc:
raise HTTPException(502, detail=f"Agent unreachable: {exc}") from exc
if resp.status_code >= 400:
raise HTTPException(resp.status_code, detail=resp.text)
logger.info("dev: cf-voice stop → %s", resp.status_code)
return {"ok": True, "action": "stop"}
@router.post("/voice/start")
async def voice_start() -> dict:
"""Start the cf-voice sidecar via the cf-orch agent."""
agent = _agent_url()
async with httpx.AsyncClient(timeout=10) as client:
try:
resp = await client.post(f"{agent}/services/cf-voice/start", json={})
except httpx.RequestError as exc:
raise HTTPException(502, detail=f"Agent unreachable: {exc}") from exc
if resp.status_code >= 400:
raise HTTPException(resp.status_code, detail=resp.text)
logger.info("dev: cf-voice start → %s", resp.status_code)
return {"ok": True, "action": "start"}
@router.post("/voice/restart")
async def voice_restart() -> dict:
"""Stop then start cf-voice via the cf-orch agent."""
import asyncio
agent = _agent_url()
async with httpx.AsyncClient(timeout=10) as client:
try:
await client.post(f"{agent}/services/cf-voice/stop", json={})
except httpx.RequestError as exc:
raise HTTPException(502, detail=f"Agent unreachable on stop: {exc}") from exc
# Give the process time to fully exit before start fires
await asyncio.sleep(2.0)
async with httpx.AsyncClient(timeout=10) as client:
try:
resp = await client.post(f"{agent}/services/cf-voice/start", json={})
except httpx.RequestError as exc:
raise HTTPException(502, detail=f"Agent unreachable on start: {exc}") from exc
if resp.status_code >= 400:
raise HTTPException(resp.status_code, detail=resp.text)
logger.info("dev: cf-voice restart complete")
return {"ok": True, "action": "restart"}

View file

@ -3,11 +3,15 @@ from __future__ import annotations
import asyncio
import logging
from dataclasses import replace
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from app.models.service_event import ServiceEvent
from app.models.tone_event import ToneEvent
from app.services import session_store
from app.services.translator import Translator
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/session", tags=["events"])
@ -22,6 +26,10 @@ async def stream_events(session_id: str, request: Request) -> StreamingResponse:
const es = new EventSource(`/session/${sessionId}/stream`)
es.addEventListener('tone-event', e => { ... })
If the session has a target_lang set, ToneEvent labels are translated via
DeepL before emission. Labels are cached per-session each unique label
is translated once regardless of how often it appears.
The stream runs until the client disconnects or the session ends.
"""
session = session_store.get_session(session_id)
@ -29,16 +37,32 @@ async def stream_events(session_id: str, request: Request) -> StreamingResponse:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
queue = session.subscribe()
translator = Translator.for_session(session)
async def generator():
try:
# Heartbeat comment every 15s to keep connection alive through proxies
yield ": heartbeat\n\n"
# If the voice probe already completed before this subscriber connected,
# emit the result immediately rather than waiting for a queue event
# that was already broadcast to an empty subscriber list.
if session.voice_ready:
yield ServiceEvent(session_id=session_id, status="ready").to_sse()
elif session.voice_error:
yield ServiceEvent(
session_id=session_id, status="error", detail=session.voice_error,
).to_sse()
while True:
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(queue.get(), timeout=15.0)
# Translate tone label if a translator is configured for this session
if translator is not None and isinstance(event, ToneEvent):
translated = translator.translate(event.label)
if translated != event.label:
event = replace(event, label=translated)
yield event.to_sse()
except asyncio.TimeoutError:
yield ": heartbeat\n\n"

View file

@ -1,9 +1,11 @@
# app/api/sessions.py — session lifecycle endpoints
from __future__ import annotations
from fastapi import APIRouter, HTTPException
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"])
@ -11,22 +13,61 @@ 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(req: StartRequest = StartRequest()) -> SessionResponse:
async def start_session(
request: Request,
req: StartRequest = StartRequest(),
) -> SessionResponse:
"""Start a new annotation session and begin streaming VoiceFrames."""
session = await session_store.create_session(elcor=req.elcor)
# 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,
)
@ -40,12 +81,16 @@ async def end_session(session_id: str) -> dict:
@router.get("/{session_id}")
def get_session(session_id: str) -> SessionResponse:
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,
)

156
app/api/snapshots.py Normal file
View file

@ -0,0 +1,156 @@
# 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),
)

View file

@ -15,17 +15,27 @@ class Settings:
# DEMO: auto-kill sessions after this many seconds of inactivity
demo_session_ttl_s: int = int(os.getenv("DEMO_SESSION_TTL_S", "300")) # 5 min
# All modes: kill a session this many seconds after its last SSE subscriber
# disconnects. Covers mobile tab timeout / screen lock / crash.
# Set generously enough to survive a brief screen-off without losing the session,
# but short enough that zombie sessions don't accumulate.
# Free tier: kill session this many seconds after the last SSE subscriber leaves.
# Covers mobile tab timeout / screen lock / crash.
session_idle_ttl_s: int = int(os.getenv("SESSION_IDLE_TTL_S", "90"))
# Paid tier: hard idle timeout — kill session this many seconds after the last
# annotation event, regardless of subscriber state. 30 minutes default.
session_paid_ttl_s: int = int(os.getenv("SESSION_PAID_TTL_S", "1800"))
# DeepL API key for CF-managed cloud translation (Paid tier).
# BYOK users supply their own key per-session in StartRequest.byok_deepl_key.
deepl_api_key: str = os.getenv("DEEPL_API_KEY", "")
# CLOUD: where Caddy injects the cf_session user token
cloud_session_header: str = os.getenv("CLOUD_SESSION_HEADER", "X-CF-Session")
cloud_data_root: str = os.getenv("CLOUD_DATA_ROOT", "/devl/linnet-cloud-data")
heimdall_url: str = os.getenv("HEIMDALL_URL", "https://license.circuitforge.tech")
# Admin token for cloud-mode user tier resolution (/admin/cloud/resolve).
# Not required for self-hosted installs; required when CLOUD_MODE=true.
heimdall_admin_token: str = os.getenv("HEIMDALL_ADMIN_TOKEN", "")
# Self-hosted license key. Cloud mode resolves tier via user_id instead.
linnet_license_key: str = os.getenv("LINNET_LICENSE_KEY", "")
linnet_port: int = int(os.getenv("LINNET_PORT", "8522"))
linnet_frontend_port: int = int(os.getenv("LINNET_FRONTEND_PORT", "8521"))
@ -35,6 +45,10 @@ class Settings:
# When set, each session allocates a cf-voice sidecar via the coordinator REST API.
cf_orch_url: str = os.getenv("CF_ORCH_URL", "")
# cf-orch agent URL — local service lifecycle (start/stop). Separate from the
# coordinator: coordinator is read/scheduling (:7700), agent is write (:7701).
cf_orch_agent_url: str = os.getenv("CF_ORCH_AGENT_URL", "")
# Static cf-voice sidecar URL — legacy override (bypasses cf-orch, points directly
# at a running cf-voice process). Takes precedence over cf-orch allocation when set.
cf_voice_url: str = os.getenv("CF_VOICE_URL", "")

View file

@ -8,7 +8,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import audio, corrections, events, export, history, samples, sessions
from app.api import audio, corrections, dev, events, export, history, samples, sessions, snapshots
from app.config import settings
from app.services import session_store
@ -68,11 +68,14 @@ app.add_middleware(
# ── 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")

View file

@ -0,0 +1,21 @@
-- Migration 002: Session snapshots for Paid tier session pinning.
-- When a Paid session ends, its annotation history is saved here so the user
-- can resume it in a later session. Snapshots contain annotation metadata only —
-- no audio, no raw transcription text unless the user has opted in to STT retention.
--
-- Privacy: user_id scopes snapshots to the authenticated user. For self-hosted
-- installs with no cloud auth, user_id is an empty string (instance-shared).
CREATE TABLE IF NOT EXISTS session_snapshots (
id TEXT PRIMARY KEY, -- UUID
user_id TEXT NOT NULL DEFAULT '', -- cf_user from cloud mode; '' for self-hosted
session_id TEXT NOT NULL, -- original session_id (short 8-char)
created_at REAL NOT NULL, -- Unix epoch (time.time())
ended_at REAL NOT NULL, -- Unix epoch
elcor INTEGER NOT NULL DEFAULT 0, -- 1 = Elcor subtext mode was active
event_count INTEGER NOT NULL DEFAULT 0,
events_json TEXT NOT NULL DEFAULT '[]' -- JSON array of ToneEvent dicts
);
CREATE INDEX IF NOT EXISTS idx_snapshots_user_time
ON session_snapshots (user_id, ended_at DESC);

58
app/models/scene_event.py Normal file
View file

@ -0,0 +1,58 @@
# app/models/scene_event.py — acoustic scene and accent SSE event models
from __future__ import annotations
import json
from dataclasses import dataclass
@dataclass
class SceneEvent:
"""
Acoustic scene classification from cf-voice.
Broad scene label (e.g. "indoor_quiet", "outdoor_urban") derived from the
AST model. Primary input to the privacy risk indicator in the frontend.
Broadcasts via SSE as `event: scene-event`.
Not stored in session history.
"""
session_id: str
label: str # SCENE_LABELS: indoor_quiet, outdoor_urban, etc.
confidence: float
timestamp: float
privacy_risk: str = "low" # "low" | "moderate" | "high"
def to_sse(self) -> str:
payload = {
"session_id": self.session_id,
"label": self.label,
"confidence": self.confidence,
"timestamp": self.timestamp,
"privacy_risk": self.privacy_risk,
}
return f"event: scene-event\ndata: {json.dumps(payload)}\n\n"
@dataclass
class AccentEvent:
"""
Regional accent / language identification from cf-voice.
Gated by CF_VOICE_ACCENT=1 only emitted when that flag is set.
Broadcasts via SSE as `event: accent-event`.
Not stored in session history or corrections DB.
"""
session_id: str
region: str # ACCENT_LABELS: en_gb, en_us, fr, etc.
language: str # raw language tag from the model
confidence: float
timestamp: float
def to_sse(self) -> str:
payload = {
"session_id": self.session_id,
"region": self.region,
"language": self.language,
"confidence": self.confidence,
"timestamp": self.timestamp,
}
return f"event: accent-event\ndata: {json.dumps(payload)}\n\n"

View file

@ -0,0 +1,30 @@
# app/models/service_event.py — system/service lifecycle events for the SSE stream
from __future__ import annotations
import json
from dataclasses import dataclass
@dataclass
class ServiceEvent:
"""
Lifecycle signal broadcast over SSE to frontend subscribers.
Currently used to signal when the cf-voice backend is ready to accept audio.
The frontend holds the mic button in a disabled "loading" state until it
receives status="ready".
SSE event name: service-event
"""
session_id: str
status: str # "ready" | "loading" | "error"
detail: str = "" # human-readable message shown in the UI
def to_sse(self) -> str:
payload = {
"event_type": "service",
"session_id": self.session_id,
"status": self.status,
"detail": self.detail,
}
return f"event: service-event\ndata: {json.dumps(payload)}\n\n"

View file

@ -32,19 +32,51 @@ class Session:
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 = ""
# History: last 50 events retained for GET /session/{id}/history
# 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 reaper uses this to detect
# abandoned sessions after a mobile tab timeout.
# 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:
@ -69,8 +101,10 @@ class Session:
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) >= 50:
if len(self.history) >= 200:
self.history.pop(0)
self.history.append(event)

View file

@ -6,6 +6,7 @@ import logging
import time
from app.config import settings
from app.models.service_event import ServiceEvent
from app.models.session import Session
from app.models.tone_event import ToneEvent
from app.services.annotator import annotate
@ -26,13 +27,31 @@ _CHUNKS_PER_WINDOW = _CLASSIFY_WINDOW_MS // _CHUNK_MS # 10 chunks
_audio_buffers: dict[str, list[bytes]] = {}
async def create_session(elcor: bool = False) -> Session:
async def create_session(
elcor: bool = False,
tier: str = "free",
user_id: str = "",
target_lang: str = "",
byok_deepl_key: str = "",
window_ms: int = 1000,
transcribe_lang: str = "",
num_speakers: int = 0,
) -> Session:
"""Create a new session and start its ContextClassifier background task.
If CF_ORCH_URL is configured, requests a managed cf-voice instance before
starting the classifier. Falls back to in-process mock if allocation fails.
"""
session = Session(elcor=elcor)
session = Session(
elcor=elcor,
tier=tier,
user_id=user_id,
target_lang=target_lang,
byok_deepl_key=byok_deepl_key,
window_ms=window_ms,
transcribe_lang=transcribe_lang,
num_speakers=num_speakers,
)
_sessions[session.session_id] = session
await _allocate_voice(session)
task = asyncio.create_task(
@ -40,6 +59,10 @@ async def create_session(elcor: bool = False) -> Session:
name=f"classifier-{session.session_id}",
)
_tasks[session.session_id] = task
asyncio.create_task(
_probe_voice_ready(session),
name=f"voice-ready-{session.session_id}",
)
session.state = "running"
logger.info(
"Session %s started (voice=%s)",
@ -59,7 +82,11 @@ def active_session_count() -> int:
async def end_session(session_id: str) -> bool:
"""Stop and remove a session. Returns True if it existed."""
"""Stop and remove a session. Returns True if it existed.
For Paid tier sessions with annotation history, saves a snapshot to SQLite
so the user can resume the session later.
"""
session = _sessions.pop(session_id, None)
if session is None:
return False
@ -69,10 +96,194 @@ async def end_session(session_id: str) -> bool:
task.cancel()
_audio_buffers.pop(session_id, None)
await _release_voice(session)
if session.tier in ("paid", "premium") and session.history:
_save_snapshot(session)
logger.info("Session %s ended", session_id)
return True
def _save_snapshot(session: Session) -> None:
"""Persist a session's annotation history to the snapshots table."""
import json
import time as _time
import uuid
try:
from app.db import get_connection
conn = get_connection()
snapshot_id = str(uuid.uuid4())
events_json = json.dumps([
{
"label": e.label,
"confidence": e.confidence,
"speaker_id": e.speaker_id,
"shift_magnitude": e.shift_magnitude,
"timestamp": e.timestamp,
"subtext": e.subtext,
"affect": e.affect,
}
for e in session.history
])
conn.execute(
"""
INSERT INTO session_snapshots
(id, user_id, session_id, created_at, ended_at, elcor, event_count, events_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
snapshot_id,
session.user_id,
session.session_id,
session.created_at,
_time.time(),
1 if session.elcor else 0,
len(session.history),
events_json,
),
)
conn.commit()
conn.close()
logger.info(
"Snapshot %s saved for session %s (%d events)",
snapshot_id, session.session_id, len(session.history),
)
except Exception as exc:
logger.warning("Failed to save snapshot for session %s: %s", session.session_id, exc)
async def _probe_voice_ready(session: Session) -> None:
"""Poll the cf-voice health endpoint and broadcast a service-event when ready.
Retries every second for up to 30s. On success, broadcasts status="ready"
so the frontend can unlock the mic button. On timeout, broadcasts
status="error" so the UI can surface a warning.
No-op for in-process mode (no cf_voice_url) broadcasts ready immediately.
"""
def _mark_ready() -> None:
session.voice_ready = True
session.broadcast(ServiceEvent(session_id=session.session_id, status="ready"))
def _mark_error(detail: str) -> None:
session.voice_error = detail
session.broadcast(ServiceEvent(
session_id=session.session_id, status="error", detail=detail,
))
if not session.cf_voice_url:
# In-process mock or no managed voice: ready immediately
_mark_ready()
return
asyncio.create_task(
_poll_model_status(session),
name=f"model-status-{session.session_id}",
)
import httpx
health_url = session.cf_voice_url.rstrip("/") + "/health"
deadline = time.monotonic() + 30.0
while time.monotonic() < deadline:
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(health_url)
if resp.status_code == 200:
_mark_ready()
logger.debug("cf-voice ready for session %s", session.session_id)
# Surface any soft warnings (e.g. diarize configured but HF_TOKEN missing)
try:
data = resp.json()
for msg in data.get("warnings", []):
session.broadcast(ServiceEvent(
session_id=session.session_id,
status="warning",
detail=msg,
))
except Exception:
pass
return
except Exception:
pass
await asyncio.sleep(1.0)
_mark_error("Voice service did not become ready in time. Try reloading.")
logger.warning("cf-voice readiness probe timed out for session %s", session.session_id)
_MODEL_LABELS = {
"stt": "speech-to-text (Whisper)",
"diarizer": "speaker diarization (pyannote)",
"dimensional": "emotion dimensions (audeering)",
"prosody": "prosody (openSMILE)",
}
async def _poll_model_status(session: Session) -> None:
"""Poll cf-voice /health for per-model download progress and broadcast
loading/ready events to the frontend. Stops when all models are stable."""
if not session.cf_voice_url:
return
import httpx
health_url = session.cf_voice_url.rstrip("/") + "/health"
prev: dict[str, str] = {}
# Poll for up to 10 minutes (large model downloads can take a while)
deadline = time.monotonic() + 600.0
while time.monotonic() < deadline and session.state == "running":
try:
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(health_url)
if resp.status_code == 200:
data = resp.json()
models: dict[str, str] = data.get("models", {})
for key, status in models.items():
if models.get(key) == prev.get(key):
continue
label = _MODEL_LABELS.get(key, key)
if status == "loading":
session.broadcast(ServiceEvent(
session_id=session.session_id,
status="loading",
detail=f"Downloading {label}",
))
elif status == "ready" and prev.get(key) == "loading":
session.broadcast(ServiceEvent(
session_id=session.session_id,
status="loading",
detail=f"{label.capitalize()} ready.",
))
elif status == "error":
session.broadcast(ServiceEvent(
session_id=session.session_id,
status="warning",
detail=f"Failed to load {label}.",
))
prev = dict(models)
# Done when nothing is still loading
if models and all(s != "loading" for s in models.values()):
# Clear any loading detail
session.broadcast(ServiceEvent(
session_id=session.session_id,
status="loading",
detail="",
))
return
except Exception:
pass
await asyncio.sleep(2.0)
async def _allocate_voice(session: Session) -> None:
"""Request a managed cf-voice instance from cf-orch. No-op if CF_ORCH_URL is unset."""
# Static override takes precedence — skip cf-orch allocation
@ -207,7 +418,8 @@ async def forward_audio_chunk(
buf = _audio_buffers.setdefault(session.session_id, [])
buf.append(raw)
if len(buf) < _CHUNKS_PER_WINDOW:
chunks_needed = max(1, session.window_ms // _CHUNK_MS)
if len(buf) < chunks_needed:
return # not enough audio yet — wait for more chunks
# Flush: concatenate window, reset buffer
@ -221,6 +433,8 @@ async def forward_audio_chunk(
"timestamp": timestamp,
"elcor": session.elcor,
"session_id": session.session_id,
"language": session.transcribe_lang or None, # None = Whisper auto-detect
"num_speakers": session.num_speakers or None, # None = pyannote auto-detect
}
try:
async with httpx.AsyncClient(timeout=5.0) as client:
@ -233,6 +447,7 @@ async def forward_audio_chunk(
from app.models.queue_event import QueueEvent
from app.models.transcript_event import TranscriptEvent
from app.models.scene_event import SceneEvent, AccentEvent
for ev in data.get("events", []):
etype = ev.get("event_type")
@ -277,63 +492,89 @@ async def forward_audio_chunk(
)
session.broadcast(queue_ev)
elif etype == "scene":
scene_ev = SceneEvent(
session_id=session.session_id,
label=ev["label"],
confidence=ev.get("confidence", 1.0),
timestamp=ev["timestamp"],
privacy_risk=ev.get("privacy_risk", "low"),
)
session.broadcast(scene_ev)
elif etype == "accent":
accent_ev = AccentEvent(
session_id=session.session_id,
region=ev["label"],
language=ev.get("language", ""),
confidence=ev.get("confidence", 1.0),
timestamp=ev["timestamp"],
)
session.broadcast(accent_ev)
# ── Idle session reaper ───────────────────────────────────────────────────────
def _should_reap(session: Session, now: float) -> bool:
"""
Return True if a session should be reaped.
Free tier: reap when last subscriber left > SESSION_IDLE_TTL_S ago.
Paid/premium: reap when last activity (broadcast) > SESSION_PAID_TTL_S ago,
regardless of subscriber state. This lets paid sessions survive reconnects
but enforces a hard 30-min activity timeout.
"""
if session.state != "running":
return False
if session.tier in ("paid", "premium"):
return (now - session.last_activity_at) > settings.session_paid_ttl_s
# Free tier: only reap when there are no subscribers and the idle clock is running
return (
session.subscriber_count() == 0
and session.last_subscriber_left_at is not None
and (now - session.last_subscriber_left_at) > settings.session_idle_ttl_s
)
async def _reaper_loop() -> None:
"""
Periodically kill sessions with no active SSE subscribers.
Periodically kill sessions that have exceeded their tier TTL.
A session becomes eligible for reaping when:
- Its last SSE subscriber disconnected more than SESSION_IDLE_TTL_S seconds ago
- It has not been explicitly ended (state != "stopped")
Free tier (90s): reaps when last SSE subscriber left > 90s ago.
Covers mobile tab timeout / screen lock / crash.
This covers the common mobile pattern: screen locks browser suspends tab
EventSource closes SSE subscriber count drops to zero. If the user doesn't
return within the TTL window, the session is cleaned up automatically.
Paid tier (30 min): reaps when last annotation event > 30 min ago,
regardless of subscriber state. Allows paid users to reconnect within
the window without losing their session.
The reaper runs every REAP_INTERVAL_S seconds (half the TTL, so the worst-case
overshoot is TTL + REAP_INTERVAL_S).
The reaper runs every REAP_INTERVAL_S seconds (half the free TTL, capped
at 60s, so worst-case overshoot is small).
"""
ttl = settings.session_idle_ttl_s
interval = max(15, ttl // 2)
logger.info("Session reaper started (TTL=%ds, check every %ds)", ttl, interval)
interval = max(15, min(60, ttl // 2))
logger.info("Session reaper started (free TTL=%ds, paid TTL=%ds, check every %ds)",
ttl, settings.session_paid_ttl_s, interval)
while True:
await asyncio.sleep(interval)
now = time.monotonic()
to_reap = [
sid
for sid, session in list(_sessions.items())
if (
session.state == "running"
and session.subscriber_count() == 0
and session.last_subscriber_left_at is not None
and (now - session.last_subscriber_left_at) > ttl
)
]
to_reap = [sid for sid, s in list(_sessions.items()) if _should_reap(s, now)]
for sid in to_reap:
logger.info(
"Reaping idle session %s (no subscribers for >%ds)", sid, ttl
)
session = _sessions.get(sid)
tier = session.tier if session else "free"
logger.info("Reaping idle %s session %s", tier, sid)
await end_session(sid)
async def _reaper_loop_once() -> None:
"""Single reaper pass — used by tests to avoid sleeping."""
ttl = settings.session_idle_ttl_s
now = time.monotonic()
to_reap = [
sid
for sid, session in list(_sessions.items())
if (
session.state == "running"
and session.subscriber_count() == 0
and session.last_subscriber_left_at is not None
and (now - session.last_subscriber_left_at) > ttl
)
]
to_reap = [sid for sid, s in list(_sessions.items()) if _should_reap(s, now)]
for sid in to_reap:
logger.info("Reaping idle session %s (no subscribers for >%ds)", sid, ttl)
session = _sessions.get(sid)
tier = session.tier if session else "free"
logger.info("Reaping idle %s session %s", tier, sid)
await end_session(sid)

113
app/services/translator.py Normal file
View file

@ -0,0 +1,113 @@
# app/services/translator.py — DeepL-backed tone label translation
#
# Translates short tone labels (e.g. "Neutral", "Stressed", "Apologetic")
# into the user's target language via the DeepL API.
#
# Two API key paths:
# CF-managed (Paid tier): DEEPL_API_KEY env var (DeepL Pro endpoint)
# BYOK (any tier): per-session byok_deepl_key from StartRequest (DeepL Free endpoint)
#
# Per-session label cache: each unique label is translated once per session.
# Cache is discarded when the session ends — it lives on the Translator instance,
# which is created per-session and GC'd with the session.
#
# Rate limiting: translation runs only on ToneEvent emit, not on raw audio frames.
# The SSE generator calls translate() once per event; the cache makes repeat labels free.
from __future__ import annotations
import logging
import time
import requests
from app.config import settings
logger = logging.getLogger(__name__)
_DEEPL_FREE_URL = "https://api-free.deepl.com/v2/translate"
_DEEPL_PRO_URL = "https://api.deepl.com/v2/translate"
class Translator:
"""
Per-session DeepL translator with label cache.
Usage:
translator = Translator.for_session(session)
translated = translator.translate("Stressed") # "Stressé" for target_lang="fr"
translated = translator.translate("Stressed") # cache hit — no API call
"""
def __init__(
self,
target_lang: str,
api_key: str,
pro: bool = True,
) -> None:
self._target_lang = target_lang.upper() # DeepL uses uppercase codes
self._api_key = api_key
self._url = _DEEPL_PRO_URL if pro else _DEEPL_FREE_URL
self._cache: dict[str, str] = {}
@classmethod
def for_session(cls, session) -> "Translator | None":
"""
Create a Translator for a session, or return None if translation is disabled.
Returns None when:
- session.target_lang is empty
- No API key is available (neither BYOK nor CF-managed)
"""
target_lang = session.target_lang
if not target_lang:
return None
byok = session.byok_deepl_key.strip()
cf_key = settings.deepl_api_key.strip()
if byok:
return cls(target_lang=target_lang, api_key=byok, pro=False)
if cf_key:
return cls(target_lang=target_lang, api_key=cf_key, pro=True)
logger.warning(
"[translator] target_lang=%s set but no DeepL key available — skipping translation",
target_lang,
)
return None
def translate(self, label: str) -> str:
"""
Translate a tone label. Returns the original label on any error.
Results are cached per-session so the same label is only translated once.
"""
if not label:
return label
if label in self._cache:
return self._cache[label]
translated = self._call_deepl(label)
self._cache[label] = translated
return translated
def _call_deepl(self, text: str) -> str:
try:
resp = requests.post(
self._url,
headers={"Authorization": f"DeepL-Auth-Key {self._api_key}"},
json={
"text": [text],
"target_lang": self._target_lang,
},
timeout=5,
)
if not resp.ok:
logger.warning("[translator] DeepL returned %s for label %r", resp.status_code, text)
return text
data = resp.json()
return data["translations"][0]["text"]
except Exception as exc:
logger.warning("[translator] DeepL call failed for label %r: %s", text, exc)
return text

View file

@ -1,31 +1,159 @@
# app/tiers.py — tier gate checks
#
# Free tier: local inference only, no license key required.
# Paid tier: cloud STT/TTS fallback, session pinning (v1.0).
# Two validation paths:
# Self-hosted: LINNET_LICENSE_KEY env var (or key passed per-request)
# → cf-core validate_license() → Heimdall /v1/licenses/verify
# Cloud mode: user_id from X-CF-Session header
# → Heimdall /admin/cloud/resolve (admin token required)
#
# Both paths cache results in-process for 30 minutes so a brief Heimdall
# outage does not interrupt active sessions.
#
# BYOK: user-supplied DeepL key bypasses the Paid gate for translation only.
# Pass byok_deepl=True to require_paid() to enable this exception.
from __future__ import annotations
import os
import logging
import time
BYOK_UNLOCKABLE = ["cloud_stt", "cloud_tts", "session_pinning"]
import requests
from app.config import settings
logger = logging.getLogger(__name__)
_PRODUCT = "LNNT"
_CACHE_TTL = 1800 # 30 minutes — matches Heimdall offline grace window
# Cache: cache_key (str) -> (tier: str, expires_at: float)
_cache: dict[str, tuple[str, float]] = {}
def is_paid(license_key: str | None = None) -> bool:
"""Return True if the request has a valid Paid+ license key."""
key = license_key or os.environ.get("LINNET_LICENSE_KEY", "")
# Paid keys start with CFG-LNNT- (format: CFG-LNNT-XXXX-XXXX-XXXX)
return bool(key) and key.upper().startswith("CFG-LNNT-")
# ── Internal helpers ──────────────────────────────────────────────────────────
def _cached(cache_key: str) -> str | None:
entry = _cache.get(cache_key)
if entry and time.monotonic() < entry[1]:
return entry[0]
return None
def require_free() -> None:
"""No-op. All users get Free tier features."""
def _store(cache_key: str, tier: str) -> str:
_cache[cache_key] = (tier, time.monotonic() + _CACHE_TTL)
return tier
def require_paid(license_key: str | None = None) -> None:
"""Raise if caller doesn't have a Paid license."""
if not is_paid(license_key):
def _resolve_by_key(license_key: str) -> str:
"""Hit Heimdall /v1/licenses/verify for a raw key. Returns tier string."""
cache_key = f"key:{license_key}"
cached = _cached(cache_key)
if cached is not None:
return cached
try:
resp = requests.post(
f"{settings.heimdall_url}/v1/licenses/verify",
json={"key": license_key, "min_tier": "free"},
timeout=5,
)
if not resp.ok:
logger.warning("[tiers] Heimdall /verify returned %s", resp.status_code)
return _store(cache_key, "free")
data = resp.json()
if not data.get("valid", False):
return _store(cache_key, "free")
tier = data.get("tier", "free") or "free"
return _store(cache_key, tier)
except Exception as exc:
logger.warning("[tiers] Heimdall key validation failed: %s", exc)
# Do NOT cache on network failure — allow retry after next request
return "free"
def _resolve_by_user(user_id: str) -> str:
"""Hit Heimdall /admin/cloud/resolve for a cloud user. Returns tier string."""
if not settings.heimdall_admin_token:
logger.warning("[tiers] HEIMDALL_ADMIN_TOKEN not set — defaulting to free")
return "free"
cache_key = f"user:{user_id}"
cached = _cached(cache_key)
if cached is not None:
return cached
try:
resp = requests.post(
f"{settings.heimdall_url}/admin/cloud/resolve",
json={"user_id": user_id, "product": _PRODUCT},
headers={"Authorization": f"Bearer {settings.heimdall_admin_token}"},
timeout=5,
)
if not resp.ok:
logger.warning("[tiers] Heimdall /cloud/resolve returned %s", resp.status_code)
return _store(cache_key, "free")
data = resp.json()
tier = data.get("tier", "free") or "free"
return _store(cache_key, tier)
except Exception as exc:
logger.warning("[tiers] Heimdall cloud resolve failed: %s", exc)
return "free"
# ── Public API ────────────────────────────────────────────────────────────────
def get_tier(
license_key: str | None = None,
user_id: str | None = None,
) -> str:
"""
Return the active tier for this request context.
Precedence:
1. user_id (cloud mode set by CloudAuthMiddleware on request.state.cf_user)
2. license_key argument (self-hosted, passed per-request)
3. LINNET_LICENSE_KEY env var (self-hosted, instance-wide)
4. "free" (no key configured)
"""
if user_id:
return _resolve_by_user(user_id)
key = license_key or settings.linnet_license_key
if not key:
return "free"
return _resolve_by_key(key)
def is_paid(
license_key: str | None = None,
user_id: str | None = None,
) -> bool:
"""Return True if the resolved tier is paid or premium."""
return get_tier(license_key=license_key, user_id=user_id) in ("paid", "premium")
def require_paid(
license_key: str | None = None,
user_id: str | None = None,
byok_deepl: bool = False,
) -> None:
"""
Raise HTTP 402 if caller doesn't have a Paid+ license.
byok_deepl=True skips the check the caller has provided their own
DeepL API key and bypasses the Paid gate for translation only.
"""
if byok_deepl:
return
if not is_paid(license_key=license_key, user_id=user_id):
from fastapi import HTTPException
raise HTTPException(
status_code=402,
detail="This feature requires a Linnet Paid license. "
"Get one at circuitforge.tech.",
)
# ── Backwards-compat shim (old call sites that pass key as positional) ────────
BYOK_UNLOCKABLE = ["cloud_stt", "cloud_tts", "session_pinning"]

View file

@ -6,11 +6,14 @@
#
# Requires in .env:
# CLOUD_MODE=true
# LINNET_LICENSE_KEY=CFG-LNNT-... (or per-request key via API)
# HEIMDALL_URL=https://license.circuitforge.tech
# HEIMDALL_ADMIN_TOKEN=... (admin token for /admin/cloud/resolve — resolves tier from user_id)
# DIRECTUS_JWT_SECRET=... (Caddy injects X-CF-Session from cf_session cookie)
# HF_TOKEN=... (needed for real cf-voice inference; omit for mock)
# CF_VOICE_MOCK=0 (set to 1 during staged rollout)
#
# Note: LINNET_LICENSE_KEY is for self-hosted installs only.
# Cloud mode resolves tier via HEIMDALL_ADMIN_TOKEN + /admin/cloud/resolve.
services:
linnet-api:

View file

@ -7,18 +7,30 @@
</header>
<main class="app-main">
<section class="now-section">
<p class="section-label">Now</p>
<NowPanel :event="store.latest" />
</section>
<!-- v0.1.x: flat single-stream view (default, internal test fixture) -->
<template v-if="!settings.threadView">
<section class="now-section">
<p class="section-label">Now</p>
<NowPanel :event="store.latest" />
</section>
<section class="history-section">
<p class="section-label">Recent</p>
<HistoryStrip />
</section>
<section class="history-section">
<p class="section-label">Recent</p>
<HistoryStrip />
</section>
</template>
<!-- v0.2.x: per-speaker thread view (dev preview toggle in DevPanel) -->
<template v-else>
<section class="thread-section">
<p class="section-label">Conversation threads <span class="thread-preview-badge">v0.2 preview</span></p>
<div class="thread-placeholder">ThreadView not yet built coming in v0.2.x</div>
</section>
</template>
</main>
<footer class="app-footer">
<DevPanel v-if="isDev" />
<ComposeBar />
</footer>
</div>
@ -26,11 +38,15 @@
<script setup lang="ts">
import { useSessionStore } from "./stores/session";
import { useSettingsStore } from "./stores/settings";
import NowPanel from "./components/NowPanel.vue";
import HistoryStrip from "./components/HistoryStrip.vue";
import ComposeBar from "./components/ComposeBar.vue";
import DevPanel from "./components/DevPanel.vue";
const store = useSessionStore();
const settings = useSettingsStore();
const isDev = import.meta.env.DEV;
</script>
<style>
@ -108,6 +124,26 @@ body {
margin-bottom: 0.5rem;
}
.thread-preview-badge {
font-size: 0.6rem;
text-transform: none;
letter-spacing: 0;
color: var(--color-accent);
border: 1px solid color-mix(in srgb, var(--color-accent) 40%, transparent);
border-radius: 0.25rem;
padding: 0.05rem 0.3rem;
margin-left: 0.4rem;
vertical-align: middle;
}
.thread-placeholder {
color: var(--color-muted);
font-size: 0.85rem;
font-style: italic;
padding: 2rem 0;
text-align: center;
}
.app-footer {
position: sticky;
bottom: 0;

View file

@ -1,36 +1,93 @@
<!-- ComposeBar.vue session start/stop controls
Navigation v0.1.x stub: start/end session only.
v0.2.x: mic capture, audio routing controls.
-->
<!-- ComposeBar.vue session start/stop controls with audio settings -->
<template>
<div class="compose-bar">
<button
v-if="store.state === 'idle' || store.state === 'stopped'"
class="btn-start"
@click="handleStart"
:disabled="starting"
>
{{ starting ? "Starting…" : "Start session" }}
</button>
<!-- Pre-session: settings + start -->
<template v-if="store.state === 'idle' || store.state === 'stopped'">
<transition name="settings-slide">
<div v-if="showSettings" class="settings-panel">
<label class="settings-item">
<span class="settings-label">Window</span>
<select v-model.number="settings.windowMs" class="settings-select">
<option :value="500">500 ms</option>
<option :value="1000">1 s (default)</option>
<option :value="2000">2 s</option>
<option :value="3000">3 s</option>
</select>
</label>
<label class="settings-item">
<span class="settings-label">Language</span>
<select v-model="settings.transcribeLang" class="settings-select">
<option value="">Auto-detect</option>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
<option value="pt">Portuguese</option>
<option value="it">Italian</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
<option value="zh">Chinese</option>
</select>
</label>
<label class="settings-item">
<span class="settings-label">Speakers</span>
<select v-model.number="settings.numSpeakers" class="settings-select">
<option :value="0">Auto-detect</option>
<option :value="1">1 speaker</option>
<option :value="2">2 speakers</option>
<option :value="3">3 speakers</option>
<option :value="4">4 speakers</option>
</select>
</label>
<label class="settings-item settings-checkbox">
<input type="checkbox" v-model="settings.elcor" />
<span class="settings-label">Elcor prefix</span>
</label>
</div>
</transition>
<div class="compose-row">
<button
class="btn-icon"
:class="{ active: showSettings }"
@click="showSettings = !showSettings"
:title="showSettings ? 'Hide settings' : 'Audio settings'"
aria-label="Toggle audio settings"
></button>
<button class="btn-start" @click="handleStart" :disabled="starting">
{{ starting ? "Starting…" : "Start session" }}
</button>
</div>
</template>
<!-- Active session -->
<template v-else>
<div class="session-info">
<span class="session-dot" :class="{ active: store.state === 'running' }" />
<span class="session-id">{{ store.sessionId?.slice(0, 8) }}</span>
</div>
<label class="elcor-toggle" title="Tone prefix mode">
<input type="checkbox" v-model="elcorLocal" disabled />
Prefix
</label>
<!-- Active settings badges -->
<span class="session-badge">{{ settings.windowMs }}ms</span>
<span v-if="settings.transcribeLang" class="session-badge">{{ settings.transcribeLang }}</span>
<span v-if="settings.numSpeakers > 0" class="session-badge">{{ settings.numSpeakers }}spk</span>
<span v-if="settings.elcor" class="session-badge">elcor</span>
<button
class="btn-mic"
:class="{ active: capturing }"
:class="{ active: capturing, loading: !store.voiceReady && !store.voiceError }"
:disabled="!store.voiceReady && !store.voiceError"
@click="handleMicToggle"
:aria-label="capturing ? 'Stop microphone' : 'Start microphone'"
:aria-label="capturing ? 'Stop microphone' : store.voiceReady ? 'Start microphone' : 'Voice service loading'"
:title="store.voiceError ?? undefined"
>
{{ capturing ? "Mic on" : "Mic off" }}
<span v-if="!store.voiceReady && !store.voiceError" class="mic-spinner" aria-hidden="true" />
{{ capturing ? "Mic on" : store.voiceReady ? "Mic off" : store.voiceError ? "Voice error" : "Loading…" }}
</button>
<button class="btn-stop" @click="handleStop">End session</button>
@ -39,30 +96,43 @@
<p v-if="expired" class="compose-notice">
Session timed out after inactivity. Start a new one to continue.
</p>
<p v-else-if="store.voiceError" class="compose-error">{{ store.voiceError }}</p>
<p v-else-if="error" class="compose-error">{{ error }}</p>
<p v-if="store.voiceWarning" class="compose-warning">{{ store.voiceWarning }}</p>
<p v-if="store.voiceLoadingDetail" class="compose-loading">
<span class="compose-loading-spinner" aria-hidden="true" />{{ store.voiceLoadingDetail }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useSessionStore } from "../stores/session";
import { useSettingsStore } from "../stores/settings";
import { useToneStream } from "../composables/useToneStream";
import { useAudioCapture } from "../composables/useAudioCapture";
const store = useSessionStore();
const settings = useSettingsStore();
const { connect, disconnect, expired } = useToneStream();
const { start: startMic, stop: stopMic, capturing } = useAudioCapture();
const starting = ref(false);
const error = ref<string | null>(null);
const elcorLocal = ref(false);
const showSettings = ref(false);
async function handleStart() {
starting.value = true;
error.value = null;
try {
await store.startSession(elcorLocal.value);
await store.startSession({
elcor: settings.elcor,
windowMs: settings.windowMs,
transcribeLang: settings.transcribeLang,
numSpeakers: settings.numSpeakers,
});
connect(store.sessionId!);
showSettings.value = false; // collapse settings once running
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Failed to start session";
} finally {
@ -97,14 +167,113 @@ export default { name: "ComposeBar" };
<style scoped>
.compose-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
flex-direction: column;
background: var(--color-surface, #1a1d27);
border-top: 1px solid var(--color-border, #2a2d3a);
}
/* ── Settings panel ──────────────────────────────────────────────── */
.settings-panel {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--color-border, #2a2d3a);
background: color-mix(in srgb, var(--color-surface, #1a1d27) 80%, black);
}
.settings-item {
display: flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
}
.settings-checkbox {
user-select: none;
}
.settings-label {
font-size: 0.72rem;
color: var(--color-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.settings-select {
font-size: 0.8rem;
color: var(--color-text, #e2e8f0);
background: var(--color-bg, #0f1117);
border: 1px solid var(--color-border, #2a2d3a);
border-radius: 0.375rem;
padding: 0.2rem 0.4rem;
cursor: pointer;
outline-offset: 2px;
}
.settings-select:focus {
outline: 2px solid var(--color-accent, #7c6af7);
}
/* Slide transition */
.settings-slide-enter-active,
.settings-slide-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.settings-slide-enter-from,
.settings-slide-leave-to {
opacity: 0;
transform: translateY(-4px);
}
/* ── Bottom compose row ──────────────────────────────────────────── */
.compose-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1.5rem;
flex-wrap: wrap;
}
/* Active-session row reuses compose-bar padding directly */
.compose-bar > .session-info,
.compose-bar > .btn-mic,
.compose-bar > .btn-stop,
.compose-bar > .session-badge {
margin: 0;
}
/* Inline flex for the running-session controls */
.compose-bar > template,
.compose-bar {
/* children from v-else template land here directly */
}
/* Running-session controls sit in a flex row via compose-bar */
:deep(.compose-bar) { } /* noop, just for clarity */
.compose-bar > *:not(.settings-panel):not(.compose-row):not(p) {
/* session-running items: give them the compose padding */
margin-inline: 0;
}
/* Hack: running-session template children need horizontal layout */
.session-info ~ .session-badge,
.session-info ~ .btn-mic,
.session-info ~ .btn-stop {
/* siblings of session-info are in the same flex flow */
}
/* Override: when session is running the compose-bar itself is the flex row */
.compose-bar:has(.session-info) {
flex-direction: row;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1.5rem;
flex-wrap: wrap;
}
/* ── Buttons ─────────────────────────────────────────────────────── */
.btn-start,
.btn-stop {
padding: 0.5rem 1.25rem;
@ -115,10 +284,29 @@ export default { name: "ComposeBar" };
border: none;
transition: opacity 0.2s;
}
.btn-start { background: #7c6af7; color: #fff; }
.btn-start { background: var(--color-accent, #7c6af7); color: #fff; }
.btn-stop { background: #374151; color: #e2e8f0; }
.btn-start:disabled { opacity: 0.6; cursor: not-allowed; }
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
border: 1px solid var(--color-border, #2a2d3a);
background: transparent;
color: var(--color-muted, #6b7280);
font-size: 0.9rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
flex-shrink: 0;
}
.btn-icon:hover { color: var(--color-text, #e2e8f0); border-color: var(--color-muted, #6b7280); }
.btn-icon.active { color: var(--color-accent, #7c6af7); border-color: var(--color-accent, #7c6af7); background: color-mix(in srgb, var(--color-accent, #7c6af7) 12%, transparent); }
/* ── Session info + badges ───────────────────────────────────────── */
.session-info {
display: flex;
align-items: center;
@ -139,16 +327,17 @@ export default { name: "ComposeBar" };
box-shadow: 0 0 6px #34d39966;
}
.elcor-toggle {
font-size: 0.75rem;
.session-badge {
font-size: 0.68rem;
font-family: var(--font-mono, monospace);
color: var(--color-muted, #6b7280);
display: flex;
align-items: center;
gap: 0.3rem;
cursor: not-allowed;
opacity: 0.5;
background: color-mix(in srgb, var(--color-border, #2a2d3a) 60%, transparent);
border: 1px solid var(--color-border, #2a2d3a);
border-radius: 0.25rem;
padding: 0.1rem 0.35rem;
}
/* ── Mic button ──────────────────────────────────────────────────── */
.btn-mic {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
@ -165,12 +354,34 @@ export default { name: "ComposeBar" };
border-color: #dc2626;
color: #dc2626;
}
.btn-mic.loading {
opacity: 0.6;
cursor: not-allowed;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn-mic:disabled { cursor: not-allowed; }
@keyframes spin { to { transform: rotate(360deg); } }
.mic-spinner {
display: inline-block;
width: 0.7rem;
height: 0.7rem;
border: 2px solid var(--color-border, #2a2d3a);
border-top-color: var(--color-muted, #6b7280);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
/* ── Notices ─────────────────────────────────────────────────────── */
.compose-error {
width: 100%;
font-size: 0.75rem;
color: #ef4444;
margin: 0;
padding: 0 1.5rem 0.75rem;
}
.compose-notice {
@ -178,5 +389,41 @@ export default { name: "ComposeBar" };
font-size: 0.75rem;
color: var(--color-muted, #6b7280);
margin: 0;
padding: 0 1.5rem 0.75rem;
}
.compose-warning {
width: 100%;
font-size: 0.72rem;
color: #f59e0b;
margin: 0;
padding: 0.4rem 1.5rem 0.5rem;
border-top: 1px solid color-mix(in srgb, #f59e0b 20%, transparent);
background: color-mix(in srgb, #f59e0b 5%, transparent);
line-height: 1.5;
}
.compose-loading {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.7rem;
font-family: var(--font-mono, monospace);
color: var(--color-muted, #6b7280);
margin: 0;
padding: 0.3rem 1.5rem 0.4rem;
border-top: 1px solid var(--color-border, #2a2d3a);
}
.compose-loading-spinner {
display: inline-block;
width: 0.55rem;
height: 0.55rem;
border: 1.5px solid var(--color-border, #2a2d3a);
border-top-color: var(--color-muted, #6b7280);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,137 @@
<!-- DevPanel.vue dev-only cf-voice lifecycle controls and UI feature flags -->
<!-- Only rendered when import.meta.env.DEV is true (Vite dev server) -->
<template>
<div class="dev-panel">
<span class="dev-label">dev</span>
<span class="dev-section">cf-voice</span>
<button class="dev-btn" :disabled="busy" @click="stop">Stop</button>
<button class="dev-btn" :disabled="busy" @click="restart">Restart</button>
<span v-if="status" class="dev-status" :class="statusClass">{{ status }}</span>
<span class="dev-divider" aria-hidden="true">|</span>
<span class="dev-section">ui</span>
<button
class="dev-btn dev-btn--toggle"
:class="{ 'dev-btn--active': settings.threadView }"
@click="settings.threadView = !settings.threadView"
:title="settings.threadView ? 'Switch to v0.1.x (NowPanel)' : 'Switch to v0.2.x (ThreadView preview)'"
>
{{ settings.threadView ? 'v0.2 thread' : 'v0.1 now' }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useSettingsStore } from "../stores/settings";
const settings = useSettingsStore();
const apiBase = import.meta.env.VITE_API_BASE ?? "";
const busy = ref(false);
const status = ref<string | null>(null);
const isError = ref(false);
const statusClass = computed(() => isError.value ? "dev-status--err" : "dev-status--ok");
async function call(action: "stop" | "restart") {
busy.value = true;
status.value = action === "restart" ? "restarting…" : "stopping…";
isError.value = false;
try {
const resp = await fetch(`${apiBase}/dev/voice/${action}`, { method: "POST" });
if (!resp.ok) {
const body = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(body.detail ?? resp.statusText);
}
status.value = action === "restart" ? "restarted" : "stopped";
} catch (e: unknown) {
isError.value = true;
status.value = e instanceof Error ? e.message : "failed";
} finally {
busy.value = false;
// Clear status after 4 s
setTimeout(() => { status.value = null; }, 4000);
}
}
function stop() { call("stop"); }
function restart() { call("restart"); }
</script>
<script lang="ts">
export default { name: "DevPanel" };
</script>
<style scoped>
.dev-panel {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 1rem;
background: color-mix(in srgb, #f59e0b 8%, var(--color-surface, #1a1d27));
border-top: 1px solid color-mix(in srgb, #f59e0b 25%, transparent);
font-size: 0.7rem;
flex-wrap: wrap;
}
.dev-label {
font-family: var(--font-mono, monospace);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #f59e0b;
background: color-mix(in srgb, #f59e0b 15%, transparent);
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
border-radius: 0.2rem;
padding: 0.1rem 0.35rem;
}
.dev-section {
color: var(--color-muted, #6b7280);
font-family: var(--font-mono, monospace);
}
.dev-btn {
padding: 0.2rem 0.6rem;
border-radius: 0.3rem;
border: 1px solid var(--color-border, #2a2d3a);
background: transparent;
color: var(--color-text, #e2e8f0);
font-size: 0.7rem;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.dev-btn:hover:not(:disabled) {
border-color: #f59e0b;
background: color-mix(in srgb, #f59e0b 10%, transparent);
}
.dev-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dev-status {
font-family: var(--font-mono, monospace);
font-size: 0.68rem;
}
.dev-status--ok { color: #34d399; }
.dev-status--err { color: #ef4444; }
.dev-divider {
color: var(--color-border, #2a2d3a);
user-select: none;
}
.dev-btn--toggle {
border-color: color-mix(in srgb, #7c6af7 40%, transparent);
color: var(--color-muted, #6b7280);
}
.dev-btn--toggle.dev-btn--active {
border-color: #7c6af7;
color: #7c6af7;
background: color-mix(in srgb, #7c6af7 12%, transparent);
}
</style>

View file

@ -5,8 +5,9 @@
:key="i"
class="history-item"
:data-affect="evt.affect"
:title="`${evt.label} (${(evt.confidence * 100).toFixed(0)}%)`"
:title="`${speakerShort(evt.speaker_id)} · ${evt.label} (${(evt.confidence * 100).toFixed(0)}%)`"
>
<span v-if="speakerShort(evt.speaker_id)" class="history-spk" :data-spk="speakerSlot(evt.speaker_id)">{{ speakerShort(evt.speaker_id) }}</span>
<span class="history-label">{{ evt.label }}</span>
<span class="history-conf">{{ (evt.confidence * 100).toFixed(0) }}%</span>
</div>
@ -25,6 +26,22 @@ const DISPLAY_COUNT = 8;
const store = useSessionStore();
// Most recent N events, newest last (scroll right)
const recent = computed(() => store.events.slice(-DISPLAY_COUNT));
// Returns single-letter label for a speaker_id, or empty for silence/unknown
function speakerShort(id: string | undefined): string {
if (!id || id === "speaker_a") return "";
if (id === "Multiple") return "M";
const match = id.match(/Speaker\s+([A-Z]+)/i);
return match ? match[1] : "";
}
// CSS slot key for per-speaker colour
function speakerSlot(id: string | undefined): string {
if (!id || id === "speaker_a") return "";
if (id === "Multiple") return "multi";
const match = id.match(/Speaker\s+([A-Z]+)/i);
return match ? match[1].toLowerCase() : "a";
}
</script>
<script lang="ts">
@ -64,6 +81,21 @@ export default { name: "HistoryStrip" };
.history-item[data-affect="dismissive"] { border-color: #a78bfa66; }
.history-item[data-affect="urgent"] { border-color: #fbbf2466; }
.history-spk {
font-size: 0.58rem;
font-family: var(--font-mono, monospace);
font-weight: 700;
padding: 0.05em 0.35em;
border-radius: 999px;
border: 1px solid transparent;
align-self: center;
}
.history-spk[data-spk="a"] { background: #1e3050; color: #60a5fa; border-color: #60a5fa44; }
.history-spk[data-spk="b"] { background: #2a1e3a; color: #c084fc; border-color: #c084fc44; }
.history-spk[data-spk="c"] { background: #1e3a2f; color: #34d399; border-color: #34d39944; }
.history-spk[data-spk="d"] { background: #2a2a1e; color: #fbbf24; border-color: #fbbf2444; }
.history-spk[data-spk="multi"] { background: #2a1e1e; color: #f87171; border-color: #f8717144; }
.history-label {
font-size: 0.7rem;
color: var(--color-text, #e2e8f0);

View file

@ -1,7 +1,10 @@
<template>
<div class="now-panel" :data-affect="affect">
<!-- Row 1: speaker type + queue state badges -->
<!-- Row 1: diarization identity + speaker type + queue state badges -->
<div class="now-badges">
<div v-if="diarSpeaker" class="now-diar-id" :data-diar="diarSpeakerSlot">
{{ diarSpeaker }}
</div>
<div v-if="speakerLabel" class="now-speaker" :data-speaker="speakerKind">
{{ speakerLabel }}
</div>
@ -11,6 +14,17 @@
<div v-if="environLabel" class="now-environ-badge" :data-environ="environKind">
{{ environLabel }}
</div>
<div v-if="sceneLabel" class="now-scene-badge" :data-scene="sceneKind">
{{ sceneLabel }}
</div>
<div
v-if="privacyVisible"
class="now-privacy-badge"
:data-privacy="privacyRisk"
:title="privacyTitle"
>
{{ privacyBadgeText }}
</div>
</div>
<!-- Live transcript strip -->
@ -70,7 +84,22 @@ const annotationOutput = computed(() => {
return parts.join(" ");
});
// Speaker badge hidden when no_speaker or no session
// Diarization identity badge shows "Speaker A", "Speaker B", "Multiple" from pyannote
// Hidden when speaker_id is "speaker_a" (the silence/unknown default) or absent
const diarSpeaker = computed(() => {
const id = props.event?.speaker_id;
if (!id || id === "speaker_a") return null;
return id; // "Speaker A", "Speaker B", "Multiple"
});
// Slot letter for CSS colour mapping: "a" for Speaker A, "b" for Speaker B, etc.
const diarSpeakerSlot = computed(() => {
const id = props.event?.speaker_id ?? "";
if (id === "Multiple") return "multi";
const match = id.match(/Speaker\s+([A-Z]+)/i);
return match ? match[1].toLowerCase() : "a";
});
// Speaker type badge hidden when no_speaker or no session
const SPEAKER_LABELS: Record<string, string> = {
human_single: "Human",
human_multi: "Group",
@ -117,6 +146,36 @@ const environLabel = computed(() => {
});
const environKind = computed(() => store.currentEnviron?.label ?? "");
// Acoustic scene badge
const SCENE_LABELS: Record<string, string> = {
indoor_quiet: "Quiet Indoor",
indoor_crowd: "Crowded Indoor",
outdoor_urban: "Urban",
outdoor_nature: "Nature",
vehicle: "Vehicle",
public_transit: "Transit",
};
const sceneLabel = computed(() => {
const raw = store.currentScene?.label;
if (!raw) return null;
return SCENE_LABELS[raw] ?? raw;
});
const sceneKind = computed(() => store.currentScene?.label ?? "");
// Privacy risk indicator calm, informational only. No alarm states.
const privacyRisk = computed(() => store.currentScene?.privacy_risk ?? "low");
const privacyVisible = computed(() => privacyRisk.value !== "low");
const PRIVACY_BADGE_TEXT: Record<string, string> = {
moderate: "Private",
high: "Private",
};
const PRIVACY_TITLES: Record<string, string> = {
moderate: "This environment may be identifiable — local processing preferred.",
high: "Private environment detected — audio is being processed locally only.",
};
const privacyBadgeText = computed(() => PRIVACY_BADGE_TEXT[privacyRisk.value] ?? "");
const privacyTitle = computed(() => PRIVACY_TITLES[privacyRisk.value] ?? "");
// Live transcript strip
const transcriptText = computed(() => store.currentTranscript?.text ?? null);
</script>
@ -156,10 +215,12 @@ export default { name: "NowPanel" };
min-height: 1.4rem;
}
/* Shared pill base for speaker / queue / environ badges */
/* Shared pill base for speaker / queue / environ / scene / privacy badges */
.now-speaker,
.now-queue-badge,
.now-environ-badge {
.now-environ-badge,
.now-scene-badge,
.now-privacy-badge {
display: inline-block;
font-size: 0.65rem;
font-family: var(--font-mono, monospace);
@ -173,6 +234,24 @@ export default { name: "NowPanel" };
transition: background 0.3s, color 0.3s;
}
/* Diarization identity badge — per-speaker colours */
.now-diar-id {
display: inline-block;
font-size: 0.65rem;
font-family: var(--font-mono, monospace);
font-weight: 700;
letter-spacing: 0.06em;
padding: 0.15em 0.6em;
border-radius: 999px;
border: 1px solid transparent;
transition: background 0.3s, color 0.3s;
}
.now-diar-id[data-diar="a"] { background: #1e3050; color: #60a5fa; border-color: #60a5fa44; }
.now-diar-id[data-diar="b"] { background: #2a1e3a; color: #c084fc; border-color: #c084fc44; }
.now-diar-id[data-diar="c"] { background: #1e3a2f; color: #34d399; border-color: #34d39944; }
.now-diar-id[data-diar="d"] { background: #2a2a1e; color: #fbbf24; border-color: #fbbf2444; }
.now-diar-id[data-diar="multi"] { background: #2a1e1e; color: #f87171; border-color: #f8717144; }
/* Speaker type colours */
.now-speaker[data-speaker="human_single"] { background: #1e3a2f; color: #34d399; border-color: #34d39933; }
.now-speaker[data-speaker="human_multi"] { background: #1e3a2f; color: #34d399; border-color: #34d39933; }
@ -186,11 +265,37 @@ export default { name: "NowPanel" };
.now-queue-badge[data-queue="dtmf_tone"] { background: #1e2a3a; color: #60a5fa; border-color: #60a5fa33; }
.now-queue-badge[data-queue="dead_air"] { background: #1e1e1e; color: #4b5563; border-color: #4b556333; }
/* Environment colours */
/* Environment colours — telephony */
.now-environ-badge[data-environ="call_center"] { background: #1e2a3a; color: #60a5fa; border-color: #60a5fa22; }
.now-environ-badge[data-environ="music"] { background: #2a1e3a; color: #a78bfa; border-color: #a78bfa22; }
.now-environ-badge[data-environ="background_shift"] { background: #2a2a1e; color: #fbbf24; border-color: #fbbf2422; }
.now-environ-badge[data-environ="noise_floor_change"] { background: #2a1e1e; color: #f87171; border-color: #f8717122; }
/* Environment colours — nature */
.now-environ-badge[data-environ="birdsong"] { background: #1e2e1e; color: #86efac; border-color: #86efac22; }
.now-environ-badge[data-environ="wind"] { background: #1e2a2e; color: #7dd3fc; border-color: #7dd3fc22; }
.now-environ-badge[data-environ="rain"] { background: #1e222e; color: #93c5fd; border-color: #93c5fd22; }
.now-environ-badge[data-environ="water"] { background: #1e2a2e; color: #67e8f9; border-color: #67e8f922; }
/* Environment colours — urban */
.now-environ-badge[data-environ="traffic"] { background: #2a201e; color: #fdba74; border-color: #fdba7422; }
.now-environ-badge[data-environ="crowd_chatter"]{ background: #2a1e28; color: #d8b4fe; border-color: #d8b4fe22; }
.now-environ-badge[data-environ="construction"] { background: #2a201e; color: #fb923c; border-color: #fb923c22; }
/* Environment colours — indoor */
.now-environ-badge[data-environ="hvac"] { background: #1e2226; color: #94a3b8; border-color: #94a3b822; }
.now-environ-badge[data-environ="keyboard_typing"]{ background: #1e2226; color: #94a3b8; border-color: #94a3b822; }
.now-environ-badge[data-environ="restaurant"] { background: #2a1e22; color: #fca5a5; border-color: #fca5a522; }
/* Acoustic scene badges — informational, cool tones */
.now-scene-badge[data-scene="indoor_quiet"] { background: #1e2226; color: #94a3b8; border-color: #94a3b822; }
.now-scene-badge[data-scene="indoor_crowd"] { background: #2a1e28; color: #c084fc; border-color: #c084fc22; }
.now-scene-badge[data-scene="outdoor_urban"] { background: #2a201e; color: #fdba74; border-color: #fdba7422; }
.now-scene-badge[data-scene="outdoor_nature"] { background: #1e2e1e; color: #86efac; border-color: #86efac22; }
.now-scene-badge[data-scene="vehicle"] { background: #1e2226; color: #7dd3fc; border-color: #7dd3fc22; }
.now-scene-badge[data-scene="public_transit"] { background: #1e2226; color: #7dd3fc; border-color: #7dd3fc22; }
/* Privacy risk indicator — calm, never alarming. Teal for both levels. */
.now-privacy-badge { cursor: default; }
.now-privacy-badge[data-privacy="moderate"]{ background: #1e2a2e; color: #67e8f9; border-color: #67e8f933; }
.now-privacy-badge[data-privacy="high"] { background: #1e2a2e; color: #22d3ee; border-color: #22d3ee44; }
/* Live transcript strip */
.now-transcript {

View file

@ -11,9 +11,12 @@
import { ref, onUnmounted } from "vue";
import {
useSessionStore,
type ServiceEvent,
type SpeakerEvent,
type TranscriptEvent,
type QueueEvent,
type SceneEvent,
type AccentEvent,
} from "../stores/session";
import { useWakeLock } from "./useWakeLock";
@ -45,6 +48,15 @@ export function useToneStream() {
source.addEventListener("environ-event", (e: MessageEvent) => {
try { store.updateQueue(JSON.parse(e.data) as QueueEvent); } catch { /* */ }
});
source.addEventListener("scene-event", (e: MessageEvent) => {
try { store.updateScene(JSON.parse(e.data) as SceneEvent); } catch { /* */ }
});
source.addEventListener("accent-event", (e: MessageEvent) => {
try { store.updateAccent(JSON.parse(e.data) as AccentEvent); } catch { /* */ }
});
source.addEventListener("service-event", (e: MessageEvent) => {
try { store.updateService(JSON.parse(e.data) as ServiceEvent); } catch { /* */ }
});
source.onopen = () => { connected.value = true; };
source.onerror = () => { connected.value = false; };

View file

@ -13,6 +13,22 @@ export interface ToneEvent {
affect: string;
shift_direction: string;
prosody_flags: string[];
// Dimensional emotion (audeering model — Navigation v0.2.x)
valence: number | null;
arousal: number | null;
dominance: number | null;
// Prosodic signals (openSMILE — Navigation v0.2.x)
sarcasm_risk: number | null;
flat_f0_score: number | null;
// Trajectory signals — null until baseline established (min 3 frames per speaker)
arousal_delta: number | null;
valence_delta: number | null;
trend: string | null;
// Coherence signals (SER vs VAD cross-comparison)
coherence_score: number | null;
suppression_flag: boolean | null;
reframe_type: string | null;
affect_divergence: number | null;
}
export interface SpeakerEvent {
@ -39,6 +55,31 @@ export interface QueueEvent {
timestamp: number;
}
export interface ServiceEvent {
event_type: "service";
session_id: string;
status: "ready" | "loading" | "error";
detail: string;
}
export interface SceneEvent {
event_type: "scene";
session_id: string;
label: string;
confidence: number;
timestamp: number;
privacy_risk: "low" | "moderate" | "high";
}
export interface AccentEvent {
event_type: "accent";
session_id: string;
region: string;
language: string;
confidence: number;
timestamp: number;
}
export const useSessionStore = defineStore("session", () => {
const sessionId = ref<string | null>(null);
const elcor = ref(false);
@ -49,14 +90,35 @@ export const useSessionStore = defineStore("session", () => {
const currentTranscript = ref<TranscriptEvent | null>(null);
const currentQueue = ref<QueueEvent | null>(null);
const currentEnviron = ref<QueueEvent | null>(null);
const currentScene = ref<SceneEvent | null>(null);
const currentAccent = ref<AccentEvent | null>(null);
// voiceReady: false until the backend probes cf-voice and emits service-event status="ready".
// voiceError: non-null when the probe times out — surfaced as a warning in the UI.
// voiceWarning: non-null for soft/non-fatal warnings (e.g. diarizer misconfigured).
// voiceLoadingDetail: non-null while models are downloading (cleared when all stable).
const voiceReady = ref(false);
const voiceError = ref<string | null>(null);
const voiceWarning = ref<string | null>(null);
const voiceLoadingDetail = ref<string | null>(null);
const apiBase = import.meta.env.VITE_API_BASE ?? "";
async function startSession(withElcor = false) {
async function startSession(opts: {
elcor?: boolean;
windowMs?: number;
transcribeLang?: string;
numSpeakers?: number;
} = {}) {
const resp = await fetch(`${apiBase}/session/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ elcor: withElcor }),
body: JSON.stringify({
elcor: opts.elcor ?? false,
window_ms: opts.windowMs ?? 1000,
transcribe_lang: opts.transcribeLang ?? "",
num_speakers: opts.numSpeakers ?? 0,
}),
});
if (!resp.ok) throw new Error(`Failed to start session: ${resp.status}`);
const data = await resp.json();
@ -64,6 +126,8 @@ export const useSessionStore = defineStore("session", () => {
elcor.value = data.elcor;
state.value = "running";
events.value = [];
voiceReady.value = false;
voiceError.value = null;
}
async function endSession() {
@ -86,11 +150,45 @@ export const useSessionStore = defineStore("session", () => {
currentTranscript.value = evt;
}
function updateScene(evt: SceneEvent) {
currentScene.value = evt;
}
function updateAccent(evt: AccentEvent) {
currentAccent.value = evt;
}
// TTL timers — clear environ/queue badges when the signal drops out.
// AST classifier emits nothing when confidence falls below threshold, so without
// a TTL the badge stays stuck indefinitely (e.g. music badge after music stops).
let _environTimer: ReturnType<typeof setTimeout> | null = null;
let _queueTimer: ReturnType<typeof setTimeout> | null = null;
const ENVIRON_TTL_MS = 5000;
const QUEUE_TTL_MS = 4000;
function updateQueue(evt: QueueEvent) {
if (evt.event_type === "queue") {
currentQueue.value = evt;
if (_queueTimer) clearTimeout(_queueTimer);
_queueTimer = setTimeout(() => { currentQueue.value = null; }, QUEUE_TTL_MS);
} else {
currentEnviron.value = evt;
if (_environTimer) clearTimeout(_environTimer);
_environTimer = setTimeout(() => { currentEnviron.value = null; }, ENVIRON_TTL_MS);
}
}
function updateService(evt: ServiceEvent) {
if (evt.status === "ready") {
voiceReady.value = true;
voiceError.value = null;
} else if (evt.status === "error") {
voiceError.value = evt.detail || "Voice service failed to start.";
} else if (evt.status === "warning") {
voiceWarning.value = evt.detail || null;
} else if (evt.status === "loading") {
// Empty detail = all models done loading; clear the indicator
voiceLoadingDetail.value = evt.detail || null;
}
}
@ -103,13 +201,22 @@ export const useSessionStore = defineStore("session", () => {
currentTranscript.value = null;
currentQueue.value = null;
currentEnviron.value = null;
currentScene.value = null;
currentAccent.value = null;
voiceReady.value = false;
voiceError.value = null;
voiceWarning.value = null;
voiceLoadingDetail.value = null;
}
return {
sessionId, elcor, state, events, latest,
currentSpeaker, currentTranscript, currentQueue, currentEnviron,
currentScene, currentAccent,
voiceReady, voiceError, voiceWarning, voiceLoadingDetail,
startSession, endSession, pushEvent,
updateSpeaker, updateTranscript, updateQueue,
updateSpeaker, updateTranscript, updateQueue, updateService,
updateScene, updateAccent,
reset,
};
});

View file

@ -0,0 +1,72 @@
// stores/settings.ts — persistent user preferences, survives page reload via localStorage.
//
// All settings here apply to the NEXT session start. Mid-session changes take
// effect on the following session (the ComposeBar shows current-session values
// as read-only badges while a session is running).
import { defineStore } from "pinia";
import { ref, watch } from "vue";
const STORAGE_KEY = "linnet:settings:v1";
interface PersistedSettings {
windowMs: number;
transcribeLang: string;
elcor: boolean;
numSpeakers: number; // 0 = auto-detect
threadView: boolean; // dev-only: preview v0.2.x thread UI (toggled from DevPanel)
}
const DEFAULTS: PersistedSettings = {
windowMs: 1000,
transcribeLang: "",
elcor: false,
numSpeakers: 0,
threadView: false,
};
function loadFromStorage(): PersistedSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
// Merge with defaults so new fields added after initial save get their defaults
return { ...DEFAULTS, ...(JSON.parse(raw) as Partial<PersistedSettings>) };
}
} catch {
// corrupt or missing — fall through to defaults
}
return { ...DEFAULTS };
}
export const useSettingsStore = defineStore("settings", () => {
const saved = loadFromStorage();
const windowMs = ref<number>(saved.windowMs);
const transcribeLang = ref<string>(saved.transcribeLang);
const elcor = ref<boolean>(saved.elcor);
// 0 = auto; 18 = fixed count passed to pyannote as min_speakers=max_speakers
const numSpeakers = ref<number>(saved.numSpeakers);
// Dev-only: show v0.2.x thread UI instead of v0.1.x NowPanel/HistoryStrip.
// Toggled from DevPanel; not exposed in user-facing settings.
const threadView = ref<boolean>(saved.threadView);
// Persist to localStorage whenever any setting changes
watch(
[windowMs, transcribeLang, elcor, numSpeakers, threadView],
() => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
windowMs: windowMs.value,
transcribeLang: transcribeLang.value,
elcor: elcor.value,
numSpeakers: numSpeakers.value,
threadView: threadView.value,
}),
);
},
{ deep: false },
);
return { windowMs, transcribeLang, elcor, numSpeakers, threadView };
});

View file

@ -8,6 +8,7 @@ export default defineConfig({
server: {
host: "0.0.0.0",
port: 8521,
allowedHosts: ["menagerie.circuitforge.tech"],
proxy: {
"/session": {
target: "http://localhost:8522",
@ -22,6 +23,10 @@ export default defineConfig({
target: "http://localhost:8522",
changeOrigin: true,
},
"/dev": {
target: "http://localhost:8522",
changeOrigin: true,
},
},
},
});

View file

@ -2,6 +2,12 @@
# manage.sh — Linnet dev/demo/cloud management script
set -euo pipefail
# Source .env early so LINNET_PORT, LINNET_FRONTEND_PORT, VITE_*, and all
# other vars are available before any assignments below read them.
if [[ -f .env ]]; then
set -a; source .env; set +a
fi
CMD=${1:-help}
PROFILE=${2:-dev} # dev | demo | cloud
API_PORT=${LINNET_PORT:-8522}
@ -36,9 +42,14 @@ case "$CMD" in
start)
_check_env
# Re-source after _check_env in case .env was just created from .env.example
set -a; source .env; set +a
# Recompute ports in case .env was just created with different values
API_PORT=${LINNET_PORT:-8522}
FE_PORT=${LINNET_FRONTEND_PORT:-8521}
if [[ "$PROFILE" == "dev" ]]; then
info "Starting Linnet API on :${API_PORT} (mock mode, dev)…"
CF_VOICE_MOCK=1 conda run -n cf uvicorn app.main:app \
info "Starting Linnet API on :${API_PORT}"
conda run -n cf uvicorn app.main:app \
--host 0.0.0.0 --port "$API_PORT" --reload &
info "Starting Linnet frontend on :${FE_PORT}"
cd frontend && npm install --silent && npm run dev &

214
tests/test_pinning.py Normal file
View file

@ -0,0 +1,214 @@
# tests/test_pinning.py — session pinning (Paid tier TTL) and translator
from __future__ import annotations
import time
from unittest.mock import MagicMock, patch
import pytest
from app.models.session import Session
from app.services import session_store
from app.services.translator import Translator
@pytest.fixture(autouse=True)
def clean_sessions():
session_store._sessions.clear()
session_store._tasks.clear()
session_store._audio_buffers.clear()
yield
session_store._sessions.clear()
session_store._tasks.clear()
session_store._audio_buffers.clear()
# ── Tier-aware reaper (_should_reap) ─────────────────────────────────────────
def test_free_session_reaped_after_subscriber_leaves(monkeypatch):
monkeypatch.setattr("app.services.session_store.settings.session_idle_ttl_s", 0)
s = Session(tier="free")
s.state = "running"
q = s.subscribe()
s.unsubscribe(q) # triggers last_subscriber_left_at
now = time.monotonic() + 1
assert session_store._should_reap(s, now) is True
def test_free_session_spared_while_subscriber_active(monkeypatch):
monkeypatch.setattr("app.services.session_store.settings.session_idle_ttl_s", 0)
s = Session(tier="free")
s.state = "running"
s.subscribe()
now = time.monotonic() + 9999
assert session_store._should_reap(s, now) is False
def test_paid_session_reaped_on_activity_timeout(monkeypatch):
monkeypatch.setattr("app.services.session_store.settings.session_paid_ttl_s", 0)
s = Session(tier="paid")
s.state = "running"
now = time.monotonic() + 1
assert session_store._should_reap(s, now) is True
def test_paid_session_spared_within_activity_window(monkeypatch):
monkeypatch.setattr("app.services.session_store.settings.session_paid_ttl_s", 9999)
s = Session(tier="paid")
s.state = "running"
now = time.monotonic() + 1
assert session_store._should_reap(s, now) is False
def test_paid_session_not_reaped_despite_no_subscribers(monkeypatch):
"""Paid tier ignores subscriber state — activity window is what matters."""
monkeypatch.setattr("app.services.session_store.settings.session_paid_ttl_s", 9999)
s = Session(tier="paid")
s.state = "running"
q = s.subscribe()
s.unsubscribe(q) # no subscribers now
now = time.monotonic() + 200 # past free-tier TTL but within paid TTL
assert session_store._should_reap(s, now) is False
def test_stopped_session_never_reaped():
s = Session(tier="free")
s.state = "stopped"
assert session_store._should_reap(s, time.monotonic() + 9999) is False
def test_broadcast_updates_last_activity_at():
from app.models.tone_event import ToneEvent
s = Session(tier="paid")
before = s.last_activity_at
time.sleep(0.01)
tone = ToneEvent(
session_id=s.session_id, label="Neutral", confidence=0.9,
speaker_id="speaker_a", shift_magnitude=0.0, timestamp=0.0,
subtext="", affect="neutral",
)
s.broadcast(tone)
assert s.last_activity_at > before
# ── Snapshot save on end ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_snapshot_saved_for_paid_session_with_history():
from app.models.tone_event import ToneEvent
session = await session_store.create_session(tier="paid", user_id="u1")
tone = ToneEvent(
session_id=session.session_id, label="Stressed", confidence=0.85,
speaker_id="Speaker A", shift_magnitude=0.1, timestamp=1.0,
subtext="", affect="stressed",
)
session.history.append(tone)
with patch("app.services.session_store._save_snapshot") as mock_save:
await session_store.end_session(session.session_id)
mock_save.assert_called_once_with(session)
@pytest.mark.asyncio
async def test_snapshot_not_saved_for_free_session():
session = await session_store.create_session(tier="free", user_id="u1")
from app.models.tone_event import ToneEvent
tone = ToneEvent(
session_id=session.session_id, label="Neutral", confidence=0.9,
speaker_id="speaker_a", shift_magnitude=0.0, timestamp=0.0,
subtext="", affect="neutral",
)
session.history.append(tone)
with patch("app.services.session_store._save_snapshot") as mock_save:
await session_store.end_session(session.session_id)
mock_save.assert_not_called()
@pytest.mark.asyncio
async def test_snapshot_not_saved_for_empty_paid_session():
session = await session_store.create_session(tier="paid", user_id="u1")
# No events in history
with patch("app.services.session_store._save_snapshot") as mock_save:
await session_store.end_session(session.session_id)
mock_save.assert_not_called()
# ── Translator ────────────────────────────────────────────────────────────────
def _make_session(target_lang="", byok="", tier="free"):
s = Session(tier=tier, target_lang=target_lang, byok_deepl_key=byok)
return s
def _mock_deepl_resp(translated: str):
resp = MagicMock()
resp.ok = True
resp.json.return_value = {"translations": [{"text": translated}]}
return resp
def test_translator_none_when_no_target_lang():
s = _make_session(target_lang="")
assert Translator.for_session(s) is None
def test_translator_none_when_no_key(monkeypatch):
monkeypatch.setattr("app.services.translator.settings.deepl_api_key", "")
s = _make_session(target_lang="fr")
assert Translator.for_session(s) is None
def test_translator_uses_byok_key(monkeypatch):
monkeypatch.setattr("app.services.translator.settings.deepl_api_key", "")
s = _make_session(target_lang="fr", byok="my-deepl-key")
t = Translator.for_session(s)
assert t is not None
assert t._url.endswith("api-free.deepl.com/v2/translate")
def test_translator_uses_cf_key(monkeypatch):
monkeypatch.setattr("app.services.translator.settings.deepl_api_key", "cf-pro-key")
s = _make_session(target_lang="es")
t = Translator.for_session(s)
assert t is not None
assert t._url.endswith("api.deepl.com/v2/translate")
def test_translator_byok_takes_precedence_over_cf_key(monkeypatch):
monkeypatch.setattr("app.services.translator.settings.deepl_api_key", "cf-pro-key")
s = _make_session(target_lang="de", byok="user-free-key")
t = Translator.for_session(s)
assert t._url.endswith("api-free.deepl.com/v2/translate")
assert t._api_key == "user-free-key"
def test_translate_calls_deepl():
t = Translator(target_lang="fr", api_key="k", pro=False)
with patch("app.services.translator.requests.post", return_value=_mock_deepl_resp("Stressé")):
result = t.translate("Stressed")
assert result == "Stressé"
def test_translate_caches_result():
t = Translator(target_lang="fr", api_key="k", pro=False)
with patch("app.services.translator.requests.post", return_value=_mock_deepl_resp("Stressé")) as mock_post:
t.translate("Stressed")
t.translate("Stressed")
assert mock_post.call_count == 1
def test_translate_returns_original_on_error():
t = Translator(target_lang="fr", api_key="k", pro=False)
with patch("app.services.translator.requests.post", side_effect=Exception("timeout")):
result = t.translate("Stressed")
assert result == "Stressed"
def test_translate_target_lang_uppercased():
t = Translator(target_lang="fr", api_key="k", pro=False)
assert t._target_lang == "FR"
def test_translate_empty_label_returns_empty():
t = Translator(target_lang="fr", api_key="k", pro=False)
assert t.translate("") == ""

View file

@ -1,34 +1,156 @@
# tests/test_tiers.py
# tests/test_tiers.py — tier gate and Heimdall validation logic
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from fastapi import HTTPException
from app.tiers import is_paid, require_paid
from app import tiers
def test_valid_linnet_key():
assert is_paid("CFG-LNNT-AAAA-BBBB-CCCC") is True
@pytest.fixture(autouse=True)
def clear_tier_cache():
"""Wipe the in-process tier cache between tests."""
tiers._cache.clear()
yield
tiers._cache.clear()
def test_invalid_prefix():
assert is_paid("CFG-PRNG-AAAA-BBBB-CCCC") is False
# ── Key-based resolution ──────────────────────────────────────────────────────
def _mock_verify(tier: str, valid: bool = True):
"""Return a mock requests.Response for /v1/licenses/verify."""
resp = MagicMock()
resp.ok = valid
resp.json.return_value = {"valid": valid, "tier": tier, "user_id": "u123"}
return resp
def test_empty_key():
assert is_paid("") is False
def test_get_tier_free_when_no_key(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
assert tiers.get_tier() == "free"
def test_none_key():
assert is_paid(None) is False # type: ignore[arg-type]
def test_get_tier_paid_via_key(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")) as mock_post:
result = tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
assert result == "paid"
mock_post.assert_called_once()
assert "/v1/licenses/verify" in mock_post.call_args[0][0]
def test_require_paid_raises_402():
with pytest.raises(HTTPException) as exc_info:
require_paid(license_key="bad-key")
def test_get_tier_free_on_invalid_key(monkeypatch):
with patch("app.tiers.requests.post", return_value=_mock_verify("free", valid=False)):
result = tiers.get_tier(license_key="CFG-LNNT-DEAD-BEEF-0000")
assert result == "free"
def test_get_tier_free_on_heimdall_error(monkeypatch):
with patch("app.tiers.requests.post", side_effect=Exception("timeout")):
result = tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
assert result == "free"
def test_get_tier_uses_env_key(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "CFG-LNNT-ENV0-ENV0-ENV0")
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")) as mock_post:
result = tiers.get_tier()
assert result == "paid"
assert mock_post.call_args[1]["json"]["key"] == "CFG-LNNT-ENV0-ENV0-ENV0"
def test_key_result_is_cached(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")) as mock_post:
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
# Second call must hit cache, not Heimdall
assert mock_post.call_count == 1
def test_network_error_does_not_cache(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
with patch("app.tiers.requests.post", side_effect=Exception("timeout")) as mock_post:
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
# Network errors are NOT cached — both calls hit Heimdall
assert mock_post.call_count == 2
# ── Cloud user resolution ─────────────────────────────────────────────────────
def _mock_cloud_resolve(tier: str):
resp = MagicMock()
resp.ok = True
resp.json.return_value = {"tier": tier, "user_id": "u456"}
return resp
def test_get_tier_cloud_paid(monkeypatch):
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "tok-admin")
with patch("app.tiers.requests.post", return_value=_mock_cloud_resolve("paid")) as mock_post:
result = tiers.get_tier(user_id="user-uuid-123")
assert result == "paid"
assert "/admin/cloud/resolve" in mock_post.call_args[0][0]
def test_get_tier_cloud_free_when_no_admin_token(monkeypatch):
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "")
result = tiers.get_tier(user_id="user-uuid-123")
assert result == "free"
def test_user_id_takes_precedence_over_key(monkeypatch):
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "tok-admin")
with patch("app.tiers.requests.post", return_value=_mock_cloud_resolve("premium")) as mock_post:
result = tiers.get_tier(license_key="CFG-LNNT-AAAA-BBBB-CCCC", user_id="u999")
assert result == "premium"
assert "/admin/cloud/resolve" in mock_post.call_args[0][0]
def test_cloud_result_is_cached(monkeypatch):
monkeypatch.setattr("app.tiers.settings.heimdall_admin_token", "tok-admin")
with patch("app.tiers.requests.post", return_value=_mock_cloud_resolve("paid")) as mock_post:
tiers.get_tier(user_id="u-cached")
tiers.get_tier(user_id="u-cached")
assert mock_post.call_count == 1
# ── is_paid() and require_paid() ─────────────────────────────────────────────
def test_is_paid_true_for_paid_tier(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
with patch("app.tiers.requests.post", return_value=_mock_verify("paid")):
assert tiers.is_paid(license_key="CFG-LNNT-AAAA-BBBB-CCCC") is True
def test_is_paid_true_for_premium_tier(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
with patch("app.tiers.requests.post", return_value=_mock_verify("premium")):
assert tiers.is_paid(license_key="CFG-LNNT-PREM-PREM-PREM") is True
def test_is_paid_false_for_free(monkeypatch):
monkeypatch.setattr("app.tiers.settings.linnet_license_key", "")
assert tiers.is_paid() is False
def test_require_paid_raises_402_for_free():
with patch("app.tiers.get_tier", return_value="free"):
with pytest.raises(HTTPException) as exc_info:
tiers.require_paid()
assert exc_info.value.status_code == 402
def test_require_paid_passes():
# Should not raise
require_paid(license_key="CFG-LNNT-AAAA-BBBB-CCCC")
def test_require_paid_passes_for_paid():
with patch("app.tiers.get_tier", return_value="paid"):
tiers.require_paid() # must not raise
def test_require_paid_byok_skips_check():
# byok_deepl=True bypasses the tier gate regardless of tier
with patch("app.tiers.get_tier", return_value="free") as mock_get:
tiers.require_paid(byok_deepl=True) # must not raise
mock_get.assert_not_called()