Files
qtalker---/backend/app/main.py
Indiana eaa28b20e8 feat: legibility wave — veil conditions, first-run hints, kinder errors
Workstream B of the usability wave (#2 hints, #3 conditions, #4 errors):

- GET /api/conditions (new backend/app/routes/conditions.py): composes
  celestial veil_thinness with the cached NOAA Kp reading; lenient lon
  parsing (junk degrades to moon-only, never 422); geomagnetic may be
  null on a cold cache. Route tests stub the cache — no live NOAA calls.
- VeilConditions strip in the séance side column: moon glyph + phase,
  % lit, veil-thinness phrase, Kp line only when data exists. Polls
  every 10 min; renders nothing while loading; no error state.
- ModeHint: per-mode in-fiction one-liner after ~15s idle, suppressed
  once the mode's sensor runs this session, dismissal persisted in
  localStorage (qm_hint_<mode>). One mount line per panel.
- Error copy upgraded to detect-and-redirect: mic denied points at site
  settings and the ouija board/wire; WebUSB-unsupported suggests EVP.
- i18n en/es parity for every new string; coverage-check template
  domains extended for the new template-key call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:31:10 +00:00

145 lines
5.5 KiB
Python

import asyncio
import contextlib
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
import app.models # noqa: F401 — registers models on Base.metadata before create_all
from app.config import settings
from app.db import Base, async_session_maker, engine
from app.routes.auth import router as auth_router
from app.routes.codex import router as codex_router
from app.routes.conditions import router as conditions_router
from app.routes.device import router as device_router
from app.routes.inventory import router as inventory_router
from app.routes.shop import router as shop_router
from app.session_cleanup import delete_expired_sessions
from app.ws import AUDIO_DIR
from app.ws import router as ws_router
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
SESSION_CLEANUP_INTERVAL_SECONDS = 30 * 60
async def _session_cleanup_loop() -> None:
"""Periodically sweeps expired auth_sessions rows so the table doesn't
grow forever — get_current_user already rejects expired sessions on
read, this just deletes the rows themselves."""
try:
while True:
await asyncio.sleep(SESSION_CLEANUP_INTERVAL_SECONDS)
try:
async with async_session_maker() as db:
await delete_expired_sessions(db)
except Exception:
# A transient DB hiccup shouldn't kill the sweep loop —
# just try again next interval.
pass
except asyncio.CancelledError:
pass
@asynccontextmanager
async def lifespan(app: FastAPI):
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# No Alembic in this repo — `create_all` never alters existing
# tables, so columns added to live models need a manual, idempotent
# migration here. Safe to run on every startup.
await conn.execute(text(
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}'::jsonb"
))
# Workstream C (character-depth-ghost-log spec) — missing from C's
# own commit, added by the integrator after Workstream B's report
# flagged that User.essence had a live model column and application
# code (auth/me, inventory purchases, summon trickle) but no
# migration, which would have broken on the real production DB.
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS essence INTEGER NOT NULL DEFAULT 0"
))
# Workstream B (character-depth-ghost-log spec).
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS favor DOUBLE PRECISION NOT NULL DEFAULT 0.0"
))
await conn.execute(text(
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
))
# Defense-in-depth: purchase_unlock() already enforces one row per
# (user, unlock_key) via a row-locked check-then-insert, so this
# constraint should never actually find a conflict on a live DB.
# `ADD CONSTRAINT` has no IF NOT EXISTS form, so the guard is a
# catalog check instead — safe to run on every startup.
await conn.execute(text(
"DO $$ BEGIN "
"IF NOT EXISTS ("
" SELECT 1 FROM pg_constraint WHERE conname = 'uq_unlocks_user_key'"
") THEN "
" ALTER TABLE unlocks ADD CONSTRAINT uq_unlocks_user_key UNIQUE (user_id, unlock_key); "
"END IF; "
"END $$;"
))
cleanup_task = asyncio.create_task(_session_cleanup_loop())
try:
yield
finally:
cleanup_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await cleanup_task
app = FastAPI(title="Quantumancy", lifespan=lifespan)
app.include_router(auth_router)
app.include_router(codex_router)
app.include_router(conditions_router)
app.include_router(device_router)
app.include_router(inventory_router)
app.include_router(shop_router)
app.include_router(ws_router)
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
app.mount(
"/assets",
StaticFiles(directory=FRONTEND_DIST / "assets", check_dir=False),
name="frontend-assets",
)
app.mount(
"/audio",
StaticFiles(directory=AUDIO_DIR, check_dir=False),
name="spirit-audio",
)
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
index_file = FRONTEND_DIST / "index.html"
if not index_file.exists():
raise HTTPException(
status_code=503,
detail="Frontend not built. Run `npm run build` in frontend/ and restart.",
)
# Vite emits root-level static files (favicon.ico, favicon.svg,
# apple-touch-icon.png, og-image.png, …) straight into dist/ rather than
# dist/assets/ — the only mounted static dir. Without this, requests for
# them fall through to the SPA fallback below and get index.html back
# instead of the actual file (browsers silently ignore it; social-media
# link-preview crawlers fetching og:image get an HTML page).
if full_path:
dist_root = FRONTEND_DIST.resolve()
candidate = (dist_root / full_path).resolve()
if candidate.is_file() and dist_root in candidate.parents:
return FileResponse(candidate)
return FileResponse(index_file)