|
| 1 | +# LangGraph Integration |
| 2 | + |
| 3 | +groundlens provides a LangGraph-aware callback handler that automatically scores every LLM call in an agentic pipeline, builds a structured execution trace, and generates self-contained HTML triage reports. |
| 4 | + |
| 5 | +## Installation |
| 6 | + |
| 7 | +```bash |
| 8 | +pip install "groundlens[langgraph]" |
| 9 | +``` |
| 10 | + |
| 11 | +This installs `langgraph` and `langchain-core`. |
| 12 | + |
| 13 | +## GroundlensLangGraphCallback |
| 14 | + |
| 15 | +The callback intercepts every LLM call within a LangGraph execution, evaluates each response with groundlens, and accumulates the results into a structured `AgentTrace`. |
| 16 | + |
| 17 | +### Basic Usage |
| 18 | + |
| 19 | +```python |
| 20 | +from langgraph.graph import StateGraph |
| 21 | +from groundlens.integrations.langgraph import GroundlensLangGraphCallback |
| 22 | + |
| 23 | +gl = GroundlensLangGraphCallback() |
| 24 | + |
| 25 | +# Pass the callback in the LangGraph config |
| 26 | +result = app.invoke( |
| 27 | + {"question": "What is the capital of France?"}, |
| 28 | + config={"callbacks": [gl]}, |
| 29 | +) |
| 30 | + |
| 31 | +# Get the execution trace |
| 32 | +trace = gl.get_trace() |
| 33 | +print(trace.summary()) |
| 34 | +``` |
| 35 | + |
| 36 | +Output: |
| 37 | + |
| 38 | +``` |
| 39 | +Agent completed: 3 steps (523ms) |
| 40 | +✓ 2 trusted ⚠ 0 review ✗ 1 flagged |
| 41 | +Flagged: fact_check (DGI=0.180) |
| 42 | +``` |
| 43 | + |
| 44 | +### Configuration |
| 45 | + |
| 46 | +```python |
| 47 | +gl = GroundlensLangGraphCallback( |
| 48 | + groundlens_model="all-MiniLM-L6-v2", # Embedding model |
| 49 | + context_key="context", # Metadata key for explicit context |
| 50 | +) |
| 51 | +``` |
| 52 | + |
| 53 | +| Parameter | Default | Description | |
| 54 | +|---|---|---| |
| 55 | +| `groundlens_model` | `"all-MiniLM-L6-v2"` | Sentence-transformer model for scoring | |
| 56 | +| `context_key` | `"context"` | Metadata key for explicit context override | |
| 57 | + |
| 58 | +### Auto Context Detection |
| 59 | + |
| 60 | +The callback automatically detects grounding context: |
| 61 | + |
| 62 | +1. **Explicit context** — If metadata contains the `context_key`, that value is used. |
| 63 | +2. **Tool output** — If a tool call produced output before the LLM call, the tool output becomes the grounding context (SGI scoring). |
| 64 | +3. **No context** — When neither is available, DGI (ungrounded) scoring is applied. |
| 65 | + |
| 66 | +This means tool-augmented nodes automatically get SGI scoring, while reasoning nodes that generate without retrieval get DGI scoring — no configuration required. |
| 67 | + |
| 68 | +### Graph Node Tracking |
| 69 | + |
| 70 | +The callback tracks which LangGraph node produced each LLM call. Node names are resolved from: |
| 71 | + |
| 72 | +1. The `langgraph_node` key in chain metadata. |
| 73 | +2. The chain's serialized `name` field (excluding internal wrappers like `RunnableSequence`). |
| 74 | + |
| 75 | +This enables per-node triage in the execution trace. |
| 76 | + |
| 77 | +### Resetting State |
| 78 | + |
| 79 | +To reuse the same callback across multiple invocations, call `reset()` between runs: |
| 80 | + |
| 81 | +```python |
| 82 | +gl = GroundlensLangGraphCallback() |
| 83 | + |
| 84 | +result1 = app.invoke(input1, config={"callbacks": [gl]}) |
| 85 | +trace1 = gl.get_trace() |
| 86 | + |
| 87 | +gl.reset() |
| 88 | + |
| 89 | +result2 = app.invoke(input2, config={"callbacks": [gl]}) |
| 90 | +trace2 = gl.get_trace() |
| 91 | +``` |
| 92 | + |
| 93 | +## AgentTrace |
| 94 | + |
| 95 | +The `AgentTrace` object accumulates all evaluated steps and provides aggregate statistics, summaries, and export methods. |
| 96 | + |
| 97 | +### Properties |
| 98 | + |
| 99 | +| Property | Type | Description | |
| 100 | +|---|---|---| |
| 101 | +| `steps` | `list[AgentStep]` | Ordered list of evaluated steps | |
| 102 | +| `total_steps` | `int` | Total number of evaluated steps | |
| 103 | +| `flagged_steps` | `int` | Steps triaged as flagged | |
| 104 | +| `review_steps` | `int` | Steps triaged as needing review | |
| 105 | +| `trusted_steps` | `int` | Steps triaged as trusted | |
| 106 | +| `total_duration_ms` | `float` | Total LLM call time in milliseconds | |
| 107 | + |
| 108 | +### Triage Summary |
| 109 | + |
| 110 | +```python |
| 111 | +trace = gl.get_trace() |
| 112 | +print(trace.summary()) |
| 113 | +``` |
| 114 | + |
| 115 | +The summary shows step counts by triage category and lists any flagged or review steps with their node name and score. |
| 116 | + |
| 117 | +### Triage Loop |
| 118 | + |
| 119 | +Iterate over steps to build custom handling logic: |
| 120 | + |
| 121 | +```python |
| 122 | +trace = gl.get_trace() |
| 123 | + |
| 124 | +for step in trace.steps: |
| 125 | + if step.triage == "flagged": |
| 126 | + print(f"⚠ {step.node_name}: {step.score.explanation}") |
| 127 | + elif step.triage == "review": |
| 128 | + print(f"? {step.node_name}: needs human review") |
| 129 | + else: |
| 130 | + print(f"✓ {step.node_name}: trusted") |
| 131 | +``` |
| 132 | + |
| 133 | +### JSON Export |
| 134 | + |
| 135 | +```python |
| 136 | +# As a Python dict |
| 137 | +data = trace.to_dict() |
| 138 | + |
| 139 | +# As a JSON string |
| 140 | +json_str = trace.to_json(indent=2) |
| 141 | +``` |
| 142 | + |
| 143 | +### HTML Report |
| 144 | + |
| 145 | +Generate a self-contained HTML triage report: |
| 146 | + |
| 147 | +```python |
| 148 | +# Write to file |
| 149 | +trace.to_html("report.html") |
| 150 | + |
| 151 | +# Or get the HTML string |
| 152 | +html = trace.to_html() |
| 153 | +``` |
| 154 | + |
| 155 | +The HTML report includes a visual summary of all steps with color-coded triage status, scores, timing, and full input/output text. |
| 156 | + |
| 157 | +## AgentStep |
| 158 | + |
| 159 | +Each step in the trace is an `AgentStep` dataclass with the following fields: |
| 160 | + |
| 161 | +| Field | Type | Description | |
| 162 | +|---|---|---| |
| 163 | +| `node_name` | `str` | LangGraph node that produced this LLM call | |
| 164 | +| `step_index` | `int` | Execution order (0-based) | |
| 165 | +| `input_text` | `str` | Prompt sent to the LLM | |
| 166 | +| `output_text` | `str` | LLM response text | |
| 167 | +| `context` | `str \| None` | Grounding context (tool output), or `None` for DGI | |
| 168 | +| `score` | `GroundlensScore` | The groundlens evaluation result | |
| 169 | +| `triage` | `str` | `"trusted"`, `"review"`, or `"flagged"` | |
| 170 | +| `method` | `str` | `"sgi"` or `"dgi"` | |
| 171 | +| `duration_ms` | `float` | LLM call latency in milliseconds | |
| 172 | + |
| 173 | +## Triage Classification |
| 174 | + |
| 175 | +Each step is classified into one of three triage categories: |
| 176 | + |
| 177 | +| Category | Condition | Meaning | |
| 178 | +|---|---|---| |
| 179 | +| **trusted** | `normalized >= 0.6` and not flagged | Response is well-grounded | |
| 180 | +| **review** | `normalized < 0.6` and not flagged | Low confidence — human review recommended | |
| 181 | +| **flagged** | `score.flagged == True` | Likely hallucination detected | |
| 182 | + |
| 183 | +## Logging |
| 184 | + |
| 185 | +The callback uses Python's `logging` module under the `groundlens.integrations.langgraph.callback` logger: |
| 186 | + |
| 187 | +- **WARNING** — Flagged responses (likely hallucinations) |
| 188 | +- **INFO** — Passing responses |
| 189 | +- **DEBUG** — Lifecycle events (chain start/end, tool start/end, LLM start) |
| 190 | +- **ERROR** — Chain, tool, or LLM errors |
| 191 | + |
| 192 | +```python |
| 193 | +import logging |
| 194 | +logging.basicConfig(level=logging.INFO) |
| 195 | + |
| 196 | +gl = GroundlensLangGraphCallback() |
| 197 | +# Now groundlens callback events appear in logs |
| 198 | +``` |
| 199 | + |
| 200 | +## Complete Example |
| 201 | + |
| 202 | +```python |
| 203 | +from langgraph.graph import StateGraph, END |
| 204 | +from langchain_openai import ChatOpenAI |
| 205 | +from groundlens.integrations.langgraph import GroundlensLangGraphCallback |
| 206 | + |
| 207 | +# Build your LangGraph agent |
| 208 | +builder = StateGraph(dict) |
| 209 | + |
| 210 | +def research(state): |
| 211 | + llm = ChatOpenAI() |
| 212 | + result = llm.invoke(state["question"]) |
| 213 | + return {"research": result.content} |
| 214 | + |
| 215 | +def synthesize(state): |
| 216 | + llm = ChatOpenAI() |
| 217 | + result = llm.invoke(f"Summarize: {state['research']}") |
| 218 | + return {"answer": result.content} |
| 219 | + |
| 220 | +builder.add_node("research", research) |
| 221 | +builder.add_node("synthesize", synthesize) |
| 222 | +builder.set_entry_point("research") |
| 223 | +builder.add_edge("research", "synthesize") |
| 224 | +builder.add_edge("synthesize", END) |
| 225 | +app = builder.compile() |
| 226 | + |
| 227 | +# Run with groundlens monitoring |
| 228 | +gl = GroundlensLangGraphCallback() |
| 229 | +result = app.invoke( |
| 230 | + {"question": "What are the latest advances in quantum computing?"}, |
| 231 | + config={"callbacks": [gl]}, |
| 232 | +) |
| 233 | + |
| 234 | +# Inspect the trace |
| 235 | +trace = gl.get_trace() |
| 236 | +print(trace.summary()) |
| 237 | + |
| 238 | +# Export report |
| 239 | +trace.to_html("agent_report.html") |
| 240 | + |
| 241 | +# Programmatic triage |
| 242 | +for step in trace.steps: |
| 243 | + if step.triage == "flagged": |
| 244 | + print(f"Node '{step.node_name}' flagged: {step.score.explanation}") |
| 245 | +``` |
| 246 | + |
| 247 | +## Why This Matters for Agentic AI |
| 248 | + |
| 249 | +Multi-step agents make hallucination detection harder because errors compound across nodes. A hallucinated fact from an early reasoning step can propagate through tool calls and synthesis, producing a final answer that *looks* well-supported but rests on fabricated premises. |
| 250 | + |
| 251 | +groundlens addresses this by scoring each LLM call independently at the node level. The triage trace shows exactly *where* in the pipeline confidence dropped, enabling targeted human review instead of blanket distrust of the entire output. |
0 commit comments