Source code for klea_utils.api.context
#!/usr/bin/env python3
"""
Session-context projection endpoint.
Returns the session context for a chat -- the app-defined projection of
*checkpointed graph state* via :meth:`BaseLangGraph.context_snapshot`
(ADR-0032) -- so frontends can restore the mode badge / selector on
hydration without waiting for the next streamed query.
Design: the mode lives in checkpointed graph state, which is the single
source of truth (ADR-0030 / ADR-0032). This endpoint reads it from the
checkpoint directly (``graph.checkpointer`` / ``graph.graph``); nothing
is duplicated into the sessions database. An unsent query is not a chat
yet, so a thread with no checkpoint simply reports ``null`` context --
the chat entity exists, only its projection is unset, so ``200`` with
``null`` is returned rather than a 404.
File: klea_utils/api/context.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Request
from langgraph.types import RunnableConfig
from pydantic import Field
from klea_utils.api.chat_core import _graph_and_store, thread_id_for
from klea_utils.graph.base import BaseLangGraph, _normalise_state_snapshot
logger = logging.getLogger(__name__)
[docs]
def create_context_router() -> APIRouter:
"""Create an APIRouter exposing the graph-level ``context`` projection.
``GET /chat/{user_id}/{chat_id}/context``
Return ``{"context": {...}}`` -- the checkpointed session context
projected by the app's ``context_snapshot`` hook -- or
``{"context": null}`` when the thread has no checkpoint yet (a
chat that never ran a query) or the graph uses no checkpointer.
"""
router = APIRouter(prefix="/chat", tags=["context"])
@router.get("/{user_id}/{chat_id}/context")
async def get_chat_context(
user_id: Annotated[str, Field(pattern=r"^[^:]*$")],
chat_id: Annotated[str, Field(pattern=r"^[^:]+$")],
request: Request,
):
# ``_graph_and_store`` also enforces service readiness (503).
graph: BaseLangGraph
graph, _ = _graph_and_store(request)
thread_id = thread_id_for(user_id, chat_id)
logger.debug("get_chat_context(%s, %s): thread=%s", user_id, chat_id, thread_id)
# No checkpointer (e.g. checkpoint="none") means there is no
# checkpointed context to project -- fall through to null.
if graph.checkpointer is None:
logger.debug(
"get_chat_context(%s, %s): graph has no checkpointer",
user_id,
chat_id,
)
return {"context": None}
config: RunnableConfig = {"configurable": {"thread_id": thread_id}}
# ``aget_tuple`` returns None for a thread that never ran. That is
# the "no context yet" case (an unsent query is not a chat yet), so
# we surface null instead of projecting the state-schema defaults.
checkpoint = await graph.checkpointer.aget_tuple(config)
if checkpoint is None:
logger.debug(
"get_chat_context(%s, %s): no checkpoint yet", user_id, chat_id
)
return {"context": None}
snapshot = await graph.graph.aget_state(config)
# ``_normalise_state_snapshot`` keeps this identical to the stream
# path (``run_graph_astream_events``), so the app hook always sees
# a plain dict whatever the state backend renders.
values = _normalise_state_snapshot(snapshot.values)
context = graph.context_snapshot(values)
logger.debug("get_chat_context(%s, %s): context=%s", user_id, chat_id, context)
return {"context": context}
return router