Spaces:
Sleeping
Sleeping
| """ | |
| builder.py | |
| ββββββββββ | |
| Assembles the LangGraph pipeline: nodes, conditional edges, and the checkpointer. | |
| Flow (matches the original main.py control flow exactly): | |
| START | |
| β input_guardrail ββblocked?βββΊ END | |
| ββelseββββββΊ load_context β classify | |
| classify ββ(conversational|intrinsic)βββΊ agent | |
| ββ(else)βββββββββββββββββββββββΊ retrieve β agent | |
| agent β output_guardrail ββblocked?βββΊ END | |
| ββelseββββββΊ persist β END | |
| Memory: a MongoDB checkpointer keyed by thread_id (= user_id) persists the | |
| clean conversation turns. Falls back to an in-memory saver if Mongo is | |
| unavailable, so the graph always compiles (e.g. in tests / local dev). | |
| """ | |
| from __future__ import annotations | |
| import functools | |
| import inspect | |
| import time | |
| from langgraph.checkpoint.memory import MemorySaver | |
| from langgraph.graph import END, START, StateGraph | |
| from app.config import settings | |
| from app.graph.nodes.agent import agent | |
| from app.graph.nodes.classify import classify | |
| from app.graph.nodes.context import load_context | |
| from app.graph.nodes.guardrails import input_guardrail, output_guardrail | |
| from app.graph.nodes.persist import persist | |
| from app.graph.nodes.retrieve import retrieve | |
| from app.graph.state import GraphState | |
| # ββ per-node timing wrapper ββ | |
| def _timed(name: str, fn): | |
| """Wrap a node so each execution logs start + duration. Handles both sync and | |
| async nodes. Lets you see which node dominates a request's latency.""" | |
| if inspect.iscoroutinefunction(fn): | |
| async def awrapper(*args, **kwargs): | |
| print(f"[timing] βΆ {name} start", flush=True) | |
| t0 = time.perf_counter() | |
| try: | |
| return await fn(*args, **kwargs) | |
| finally: | |
| print(f"[timing] β {name} done in {time.perf_counter() - t0:.2f}s", flush=True) | |
| return awrapper | |
| def wrapper(*args, **kwargs): | |
| print(f"[timing] βΆ {name} start", flush=True) | |
| t0 = time.perf_counter() | |
| try: | |
| return fn(*args, **kwargs) | |
| finally: | |
| print(f"[timing] β {name} done in {time.perf_counter() - t0:.2f}s", flush=True) | |
| return wrapper | |
| # Categories that skip RAG (original condition: conversational OR intrinsic). | |
| _SKIP_RAG = ("conversational", "intrinsic") | |
| # ββ join barrier ββ | |
| def gate(state: GraphState) -> dict: | |
| """No-op join node: runs only after input_guardrail + load_context + classify | |
| have all completed (the three parallel pre-steps). Routing happens in | |
| `route_after_gate`, which reads the merged state from all three.""" | |
| return {} | |
| # ββ routing functions ββ | |
| def route_after_gate(state: GraphState) -> str: | |
| """Decide what happens after the parallel pre-steps join. | |
| β’ If the input guardrail blocked, stop now β input_guardrail has already set | |
| the canned refusal response/flags in state. | |
| β’ Otherwise route by classification: skip RAG for conversational/intrinsic, | |
| else go through retrieve first (matches the original control flow).""" | |
| if state.get("blocked"): | |
| return "blocked" | |
| classification = state.get("classification", "") | |
| if any(tag in classification for tag in _SKIP_RAG): | |
| return "agent" | |
| return "retrieve" | |
| def route_after_output_guardrail(state: GraphState) -> str: | |
| return "blocked" if state.get("blocked") else "continue" | |
| def get_checkpointer(): | |
| """MongoDB checkpointer when configured, else an in-memory fallback.""" | |
| if settings.CONNECTION_STRING and settings.DB_NAME: | |
| try: | |
| from langgraph.checkpoint.mongodb import MongoDBSaver | |
| from app.services.mongo import get_client | |
| client = get_client() | |
| if client is not None: | |
| return MongoDBSaver( | |
| client, | |
| db_name=settings.DB_NAME, | |
| checkpoint_collection_name=settings.CHECKPOINT_COLLECTION, | |
| ) | |
| except Exception as e: # noqa: BLE001 | |
| print(f"[checkpoint] MongoDB checkpointer unavailable, using in-memory: {e}") | |
| return MemorySaver() | |
| def build_graph(checkpointer=None): | |
| builder = StateGraph(GraphState) | |
| builder.add_node("input_guardrail", _timed("input_guardrail", input_guardrail)) | |
| builder.add_node("load_context", _timed("load_context", load_context)) | |
| builder.add_node("classify", _timed("classify", classify)) | |
| builder.add_node("gate", _timed("gate", gate)) | |
| builder.add_node("retrieve", _timed("retrieve", retrieve)) | |
| builder.add_node("agent", _timed("agent", agent)) | |
| # builder.add_node("output_guardrail", _timed("output_guardrail", output_guardrail)) # disabled for now (see below) | |
| builder.add_node("persist", _timed("persist", persist)) | |
| # Fan out: the input guardrail, context load, and classifier are mutually | |
| # independent (guardrail+classify need only `message`, context needs only | |
| # `user_id`), so run them concurrently instead of sequentially. | |
| builder.add_edge(START, "input_guardrail") | |
| builder.add_edge(START, "load_context") | |
| builder.add_edge(START, "classify") | |
| # Join: `gate` runs only after all three parallel steps complete. | |
| builder.add_edge("input_guardrail", "gate") | |
| builder.add_edge("load_context", "gate") | |
| builder.add_edge("classify", "gate") | |
| # After the join: blocked β END (canned refusal already set by the guardrail), | |
| # else route by classification. | |
| builder.add_conditional_edges( | |
| "gate", | |
| route_after_gate, | |
| {"blocked": END, "retrieve": "retrieve", "agent": "agent"}, | |
| ) | |
| builder.add_edge("retrieve", "agent") | |
| # ββ OUTPUT GUARDRAIL: disabled for now βββββββββββββββββββββββββββββββββββ | |
| # We currently rely on the input guardrail and stream the agent's reply | |
| # directly (the streaming endpoint can run a "stream-then-guard with retract" | |
| # check instead β see app/main.py). To put the guardrail back IN the graph, | |
| # re-add its node above, restore the two edges below, and delete the direct | |
| # `agent β persist` edge: | |
| # | |
| # builder.add_edge("agent", "output_guardrail") | |
| # builder.add_conditional_edges( | |
| # "output_guardrail", | |
| # route_after_output_guardrail, | |
| # {"blocked": END, "continue": "persist"}, | |
| # ) | |
| builder.add_edge("agent", "persist") | |
| builder.add_edge("persist", END) | |
| if checkpointer is None: | |
| checkpointer = get_checkpointer() | |
| return builder.compile(checkpointer=checkpointer) | |
| # Process-wide compiled graph (built once, reused per request). | |
| _GRAPH = None | |
| def get_graph(): | |
| global _GRAPH | |
| if _GRAPH is None: | |
| _GRAPH = build_graph() | |
| return _GRAPH | |