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