"""Read-only views of the context an adventure would send, or did send. The dry run assembles a prompt without calling the model. The per-action endpoint returns the prompt a turn was actually generated from. Neither writes anything. """ from fastapi import Depends, HTTPException from sqlalchemy.orm import Session from ... import derived, memorybank, models, summaries from ... import contextwindow from ...context import ContextOverflow, build_context from ...database import get_db from ...knowledge import retrieval as knowledge_retrieval from ..settings import get_settings from .deps import CurrentUser, current_adventure, router @router.get("/{adventure_id}/context") async def dry_run_context( db: Session = Depends(get_db), user: models.User = CurrentUser, adventure: models.Adventure = Depends(current_adventure), ): """Returns what the app would send to the AI if the player continued now.""" settings = get_settings(db, user) memories = await memorybank.retrieve_memories(adventure, settings, update_stats=False) # M7: retrieved here too, and by the same call the turn makes. A dry run # that skipped the library would show a prompt the next turn will not send, # which is the one thing this panel must never do. knowledge = await knowledge_retrieval.retrieve(adventure, settings) # M11: and by the same probe the turn makes, for the same reason — a panel # that showed a 16,384-token budget while the next turn will be capped to # 4,096 would be showing a prompt that is not the one about to be sent. window = await contextwindow.probe(settings.endpoint_url, settings.model, declared=settings.context_window_override) try: _, _, report = build_context( adventure, settings, memories, knowledge=knowledge, window=window ) except ContextOverflow as exc: # M6: a dry run of a prompt that cannot be built is still an answer, and # a more useful one than a 500. The reader opened this panel to find out # what would be sent; "nothing, because the protected context does not # fit, and here is by how much" is exactly that. raise HTTPException(422, str(exc)) from exc return report @router.get("/{adventure_id}/derived") def derived_status( db: Session = Depends(get_db), adventure: models.Adventure = Depends(current_adventure), ): """M6: whether background memory, summary and embedding work is healthy. The surface that makes a dead memory bank findable. M2 shipped with the whole bank failing inside a fire-and-forget task and nothing anywhere said so — not the UI, not a log a player would read, not a failing test (`BUILD-MILESTONES.md`, note from M2). This endpoint is where that now shows. """ # Resolved once, not once per row: which summary the current head is # entitled to. Asking inside the comprehension would be one query per # summary, which is the shape M5 spent a finding removing. eligible = summaries.current(db, adventure) eligible_id = eligible.id if eligible is not None else None status = derived.report(db, adventure.id) return { "status": status, "failing": [row["kind"] for row in status if row["status"] == "failed"], "summaries": [ { "id": row.id, "branch_id": row.branch_id, "depth": row.depth, "trigger": row.trigger, "model": row.model_name, "eligible": row.id == eligible_id, "created_at": row.created_at.isoformat() if row.created_at else None, "preview": row.text[:200], } for row in summaries.all_for(db, adventure) ], } @router.get("/{adventure_id}/actions/{action_id}/context") def action_context( adventure_id: int, action_id: int, db: Session = Depends(get_db), adventure: models.Adventure = Depends(current_adventure), ): action = db.get(models.Action, action_id) if action is None or action.adventure_id != adventure_id: raise HTTPException(404, "Action not found") if action.context_snapshot is None: raise HTTPException(404, "No context snapshot for this action") return action.context_snapshot