feat: PWA manifest + the Ghost Log (Workstream C)
manifest.webmanifest referencing the icon assets that actually exist (sizes verified with file, not asserted), standalone display, no service worker — offline séance is meaningless and SW cache bugs are not worth it. GET /api/seances/recent: last 12 sessions in exactly three queries (sessions+entity join, one GROUP BY for counts, one window-function query for up to 3 echoes per session). Echoes filter on utterance payload kinds because DB event kinds carry no greeting/manifest — the agent verified where _speak actually writes rather than trusting the spec's phrasing. /log page: entity glyphs, relative in-fiction timestamps, counts, echo lines in transcript style, gated like the séance, 390px-safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from app.routes.auth import router as auth_router
|
||||
from app.routes.codex import router as codex_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
|
||||
from app.routes.shop import router as shop_router
|
||||
from app.session_cleanup import delete_expired_sessions
|
||||
from app.ws import AUDIO_DIR
|
||||
@@ -97,6 +98,7 @@ app.include_router(auth_router)
|
||||
app.include_router(codex_router)
|
||||
app.include_router(device_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(seances_router)
|
||||
app.include_router(shop_router)
|
||||
app.include_router(ws_router)
|
||||
|
||||
|
||||
109
backend/app/routes/seances.py
Normal file
109
backend/app/routes/seances.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Ghost Log recap — a seeker's own recent séances, with who answered and
|
||||
what was said. Workstream C of docs/superpowers/specs/
|
||||
2026-07-28-usability-wave-design.md (#7)."""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.models.contact_session import ContactSession
|
||||
from app.models.entity import Entity
|
||||
from app.models.event import Event
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["seances"])
|
||||
|
||||
RECENT_LIMIT = 12
|
||||
ECHOES_PER_SESSION = 3
|
||||
|
||||
# Spirit speech lands in the DB as kind="utterance" rows whose payload
|
||||
# carries the flavor (see app.ws._speak) — these are the ones worth
|
||||
# echoing back in a recap.
|
||||
ECHO_PAYLOAD_KINDS = ("greeting", "reply", "manifest")
|
||||
|
||||
|
||||
@router.get("/seances/recent")
|
||||
async def recent_seances(
|
||||
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
# One query: the user's last sessions with their entity (may be null).
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ContactSession, Entity)
|
||||
.outerjoin(Entity, ContactSession.entity_id == Entity.id)
|
||||
.where(ContactSession.user_id == user.id)
|
||||
.order_by(desc(ContactSession.started_at))
|
||||
.limit(RECENT_LIMIT)
|
||||
)
|
||||
).all()
|
||||
|
||||
session_ids = [session.id for session, _ in rows]
|
||||
|
||||
# One GROUP BY across the fetched sessions for event counts by kind.
|
||||
counts: dict = defaultdict(dict)
|
||||
if session_ids:
|
||||
for session_id, kind, count in await db.execute(
|
||||
select(Event.session_id, Event.kind, func.count(Event.id))
|
||||
.where(Event.session_id.in_(session_ids))
|
||||
.group_by(Event.session_id, Event.kind)
|
||||
):
|
||||
counts[session_id][kind] = count
|
||||
|
||||
# One bounded query for echoes: top-N spirit utterances per session,
|
||||
# newest first, via a window function — never one query per session.
|
||||
echoes: dict = defaultdict(list)
|
||||
if session_ids:
|
||||
ranked = (
|
||||
select(
|
||||
Event.session_id,
|
||||
Event.text,
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=Event.session_id,
|
||||
order_by=desc(Event.created_at),
|
||||
)
|
||||
.label("rn"),
|
||||
)
|
||||
.where(
|
||||
Event.session_id.in_(session_ids),
|
||||
Event.kind == "utterance",
|
||||
Event.text.is_not(None),
|
||||
Event.payload["kind"].astext.in_(ECHO_PAYLOAD_KINDS),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
for session_id, text, _rn in await db.execute(
|
||||
select(ranked.c.session_id, ranked.c.text, ranked.c.rn).where(
|
||||
ranked.c.rn <= ECHOES_PER_SESSION
|
||||
)
|
||||
):
|
||||
echoes[session_id].append(text)
|
||||
|
||||
return {
|
||||
"seances": [
|
||||
{
|
||||
"id": str(session.id),
|
||||
"started_at": session.started_at.isoformat(),
|
||||
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
|
||||
"mode": session.mode,
|
||||
"entity": (
|
||||
{
|
||||
"id": str(entity.id),
|
||||
"name": entity.name,
|
||||
"epithet": entity.epithet,
|
||||
"rarity": entity.rarity_tier,
|
||||
"visual": entity.visual_profile,
|
||||
}
|
||||
if entity is not None
|
||||
else None
|
||||
),
|
||||
"counts": counts.get(session.id, {}),
|
||||
"echoes": echoes.get(session.id, []),
|
||||
}
|
||||
for session, entity in rows
|
||||
]
|
||||
}
|
||||
151
backend/tests/test_seances.py
Normal file
151
backend/tests/test_seances.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Tests for GET /api/seances/recent — the Ghost Log recap
|
||||
(usability wave, Workstream C, #7)."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.contact_session import ContactSession
|
||||
from app.models.entity import Entity
|
||||
from app.models.event import Event
|
||||
|
||||
|
||||
async def _register(client, username):
|
||||
resp = await client.post(
|
||||
"/auth/register", json={"username": username, "password": "spookyspooky"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
async def _login(client, username):
|
||||
resp = await client.post(
|
||||
"/auth/login", json={"username": username, "password": "spookyspooky"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def _entity(name, signature):
|
||||
return Entity(
|
||||
name=name,
|
||||
epithet="the Test Wraith",
|
||||
rarity_tier="rare",
|
||||
signature=signature,
|
||||
visual_profile={"hue": 200, "form": "wisp"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_rejected(client):
|
||||
resp = await client.get("/api/seances/recent")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_own_sessions_newest_first_with_counts_and_echoes(client, db_session):
|
||||
my_id = await _register(client, "seeker1")
|
||||
other_id = await _register(client, "seeker2")
|
||||
await _login(client, "seeker1")
|
||||
|
||||
entity = _entity("Testnamed", "sig-aaaa")
|
||||
db_session.add(entity)
|
||||
await db_session.flush()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
older = ContactSession(
|
||||
user_id=my_id, entity_id=entity.id, mode="evp", started_at=now - timedelta(hours=2)
|
||||
)
|
||||
newer = ContactSession(
|
||||
user_id=my_id, entity_id=entity.id, mode="wire", started_at=now - timedelta(hours=1)
|
||||
)
|
||||
theirs = ContactSession(user_id=other_id, mode="ouija", started_at=now)
|
||||
db_session.add_all([older, newer, theirs])
|
||||
await db_session.flush()
|
||||
|
||||
# Events on the newer session: 2 questions, 3 anomalies, 4 spirit
|
||||
# utterances (only the newest 3 should come back as echoes).
|
||||
events = [
|
||||
Event(session_id=newer.id, kind="question", text="who's there?"),
|
||||
Event(session_id=newer.id, kind="question", text="still there?"),
|
||||
Event(session_id=newer.id, kind="anomaly", payload={"score": 1}),
|
||||
Event(session_id=newer.id, kind="anomaly", payload={"score": 2}),
|
||||
Event(session_id=newer.id, kind="anomaly", payload={"score": 3}),
|
||||
]
|
||||
for i in range(4):
|
||||
events.append(
|
||||
Event(
|
||||
session_id=newer.id,
|
||||
kind="utterance",
|
||||
text=f"echo {i}",
|
||||
payload={"kind": "reply"},
|
||||
created_at=now - timedelta(minutes=30 - i),
|
||||
)
|
||||
)
|
||||
# An utterance on the OTHER user's session must never leak.
|
||||
events.append(
|
||||
Event(
|
||||
session_id=theirs.id,
|
||||
kind="utterance",
|
||||
text="not yours",
|
||||
payload={"kind": "greeting"},
|
||||
)
|
||||
)
|
||||
db_session.add_all(events)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/seances/recent")
|
||||
assert resp.status_code == 200
|
||||
seances = resp.json()["seances"]
|
||||
|
||||
# Only my sessions, newest first.
|
||||
assert [s["id"] for s in seances] == [str(newer.id), str(older.id)]
|
||||
|
||||
first = seances[0]
|
||||
assert first["mode"] == "wire"
|
||||
assert first["counts"] == {"question": 2, "anomaly": 3, "utterance": 4}
|
||||
assert first["echoes"] == ["echo 3", "echo 2", "echo 1"]
|
||||
assert first["entity"] == {
|
||||
"id": str(entity.id),
|
||||
"name": "Testnamed",
|
||||
"epithet": "the Test Wraith",
|
||||
"rarity": "rare",
|
||||
"visual": {"hue": 200, "form": "wisp"},
|
||||
}
|
||||
|
||||
# No cross-user text anywhere in the response.
|
||||
assert "not yours" not in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_without_entity_does_not_crash(client, db_session):
|
||||
my_id = await _register(client, "seeker3")
|
||||
await _login(client, "seeker3")
|
||||
|
||||
session = ContactSession(user_id=my_id, mode="ouija")
|
||||
db_session.add(session)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/seances/recent")
|
||||
assert resp.status_code == 200
|
||||
seances = resp.json()["seances"]
|
||||
assert len(seances) == 1
|
||||
assert seances[0]["entity"] is None
|
||||
assert seances[0]["counts"] == {}
|
||||
assert seances[0]["echoes"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_limited_to_twelve(client, db_session):
|
||||
my_id = await _register(client, "seeker4")
|
||||
await _login(client, "seeker4")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
db_session.add_all(
|
||||
ContactSession(user_id=my_id, mode="wire", started_at=now - timedelta(minutes=i))
|
||||
for i in range(15)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/seances/recent")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["seances"]) == 12
|
||||
Reference in New Issue
Block a user