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:
Indiana
2026-07-28 17:20:51 +00:00
12 changed files with 656 additions and 3 deletions

View File

@@ -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)

View 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
]
}