feat: legibility — conditions, hints, kinder errors (Workstream B)

GET /api/conditions surfaces what the backend already computes: moon
phase, veil thinness, geomagnetic state — junk lon degrades to moon-only
instead of a 422, missing NOAA data means fewer lines, never an error.
VeilConditions renders it in the séance side column, polling every 10 min.

ModeHint: one in-fiction line per mode after 15s of an unused sensor,
dismissed forever via localStorage. Error copy in evp/radio now
detect-and-redirects (mic denied -> 'the board needs no ear'; no WebUSB
-> try EVP) instead of dead-ending.

No geolocation prompt from the conditions strip — asking for location
from a passive readout would be hostile; ?lon= stays supported for
callers that have it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	frontend/src/pages/SeancePage.tsx
This commit is contained in:
Indiana
2026-07-28 19:19:44 +00:00
13 changed files with 579 additions and 4 deletions

View File

@@ -13,6 +13,7 @@ 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.seances import router as seances_router
@@ -96,6 +97,7 @@ async def lifespan(app: FastAPI):
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(seances_router)

View File

@@ -0,0 +1,46 @@
"""Veil conditions readout — the sky and the field, in one call.
Composes the two real-world signals the app already computes:
sky - app.celestial.veil_thinness(): moon phase/illumination and
(when a longitude is offered) proximity to true solar
midnight. Pure math, always present.
geomagnetic - app.geomagnetic.geomagnetic_cache.reading(): NOAA's
planetary Kp from cache. May be None on a cold cache or
when NOAA is unreachable — that is a valid answer, not an
error, and the frontend simply shows fewer lines.
`lon` is accepted as a raw string and parsed by hand so a malformed or
out-of-range value degrades to the moon-only reading instead of a 422 —
the conditions strip must never fail because a browser sent junk.
"""
import math
from fastapi import APIRouter
from app.celestial import veil_thinness
from app.geomagnetic import geomagnetic_cache
router = APIRouter(prefix="/api", tags=["conditions"])
def _parse_longitude(raw: str | None) -> float | None:
"""A usable longitude in [-180, 180], or None for anything else."""
if raw is None:
return None
try:
lon = float(raw)
except (TypeError, ValueError):
return None
if not math.isfinite(lon) or not -180.0 <= lon <= 180.0:
return None
return lon
@router.get("/conditions")
async def get_conditions(lon: str | None = None):
return {
"sky": veil_thinness(_parse_longitude(lon)),
"geomagnetic": await geomagnetic_cache.reading(),
}

View File

@@ -0,0 +1,73 @@
"""GET /api/conditions — sky always present, geomagnetic nullable, lon lenient.
The geomagnetic cache is stubbed in every test so nothing here ever
touches NOAA; a cold real cache would otherwise fire a live HTTP request.
"""
import pytest
import app.routes.conditions as conditions_module
class _StubCache:
def __init__(self, reading):
self._reading = reading
async def reading(self):
return self._reading
@pytest.fixture
def geo_null(monkeypatch):
monkeypatch.setattr(conditions_module, "geomagnetic_cache", _StubCache(None))
@pytest.fixture
def geo_storm(monkeypatch):
monkeypatch.setattr(
conditions_module,
"geomagnetic_cache",
_StubCache({"kp": 6.33, "label": "moderate geomagnetic storm",
"disturbance": 6.33 / 9.0, "storm": True}),
)
@pytest.mark.asyncio
async def test_sky_always_present_geomagnetic_null_tolerated(client, geo_null):
resp = await client.get("/api/conditions")
assert resp.status_code == 200
body = resp.json()
assert body["geomagnetic"] is None
sky = body["sky"]
assert sky["moon_name"]
assert 0.0 <= sky["moon_illumination"] <= 1.0
assert 0.0 <= sky["thinness"] <= 1.0
# No longitude offered — witching proximity is unknowable.
assert sky["witching_proximity"] is None
@pytest.mark.asyncio
async def test_valid_lon_yields_witching_proximity(client, geo_null):
resp = await client.get("/api/conditions", params={"lon": "-3.7"})
assert resp.status_code == 200
witching = resp.json()["sky"]["witching_proximity"]
assert witching is not None
assert 0.0 <= witching <= 1.0
@pytest.mark.asyncio
@pytest.mark.parametrize("bad", ["ghost", "", "nan", "inf", "999", "-181"])
async def test_invalid_lon_ignored_not_422(client, geo_null, bad):
resp = await client.get("/api/conditions", params={"lon": bad})
assert resp.status_code == 200
assert resp.json()["sky"]["witching_proximity"] is None
@pytest.mark.asyncio
async def test_geomagnetic_reading_passed_through(client, geo_storm):
resp = await client.get("/api/conditions")
assert resp.status_code == 200
geo = resp.json()["geomagnetic"]
assert geo["kp"] == pytest.approx(6.33)
assert geo["storm"] is True
assert geo["label"] == "moderate geomagnetic storm"