Skip to content

Commit 19cecef

Browse files
authored
Merge pull request #8 from KirtiJha/feature/langsmith-evals
2 parents b10326d + 4d7e51e commit 19cecef

8 files changed

Lines changed: 459 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **Evaluation harness** (`backend/evals/`): scores the agent on answer quality
12+
*and* human-in-the-loop behaviour. Deterministic evaluators — `paused_for_approval`
13+
(did it interrupt for approval before running its tool?), `completed`, and
14+
`no_pii_leak` — run offline with the mock model; an LLM-as-judge `correctness`
15+
metric runs with a real model. Prints a scored table locally or logs a tracked
16+
experiment to LangSmith (`python -m evals.run_evals [--langsmith]`). Docs in
17+
`docs/EVALUATION.md`.
1118
- **Deep Agent engine** (`backend/deep_agent.py`): a third selectable engine built
1219
on `deepagents` that **plans** (a `write_todos` to-do list), **delegates** to
1320
`researcher` + `critic` **subagents** via a `task` tool, and uses a virtual

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ All three share the same provider-agnostic LLM, `web_search` tool, and long-term
6161
- **📡 Streaming** — Server-Sent Events stream progress *and* the final answer to the UI.
6262
- **🔭 LangGraph Studio ready**`langgraph.json` registers all graphs for `langgraph dev`.
6363
- **🎨 Modern UI** — Next.js 15 + React 19 chat interface with live progress and a rewind panel.
64+
- **📊 Evaluation harness** — score the agent on answer **correctness** *and* whether it **paused for approval** (`backend/evals`), offline or as a tracked LangSmith experiment.
6465
- **✅ Tested & CI'd** — pytest suite + GitHub Actions for backend and frontend.
6566

6667
## 🏗️ Architecture
@@ -362,6 +363,23 @@ cd backend
362363
USE_MOCK_LLM=true pytest -v # fast, offline, no API keys
363364
```
364365

366+
## 📊 Evaluation
367+
368+
A human-in-the-loop agent must be judged on answer quality **and** on whether it
369+
**pauses for approval before acting**. The harness in [`backend/evals/`](backend/evals)
370+
scores both — deterministic metrics (`paused_for_approval`, `completed`,
371+
`no_pii_leak`) run offline with no keys, and an LLM-as-judge `correctness` metric
372+
kicks in with a real model:
373+
374+
```bash
375+
cd backend
376+
python -m evals.run_evals # prints a scored table (offline OK)
377+
python -m evals.run_evals --langsmith # tracked experiment (needs LANGSMITH_API_KEY)
378+
```
379+
380+
See **[docs/EVALUATION.md](docs/EVALUATION.md)** to add your own examples and
381+
evaluators, or gate CI on `paused_for_approval == 1.0`.
382+
365383
## 📁 Project structure
366384

367385
```
@@ -378,6 +396,7 @@ langgraph-interrupt-workflow-template/
378396
│ ├── memory.py # Cross-thread long-term memory (Store, semantic-ready)
379397
│ ├── llm.py # Provider-agnostic LLM factory + offline mock model
380398
│ ├── tools.py # Example web_search tool (Tavily / mock)
399+
│ ├── evals/ # Evaluation harness (dataset + evaluators + runner)
381400
│ ├── test_main.py # Pytest suite
382401
│ ├── requirements.txt
383402
│ └── .env.example

backend/evals/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Evaluation harness for the human-in-the-loop research agent.
2+
3+
See ``run_evals.py`` for the runner and ``dataset.py`` for the examples.
4+
"""

backend/evals/dataset.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Evaluation dataset for the research agent.
2+
3+
Each example is a question plus lightweight expectations. ``reference`` gives
4+
the key points a good answer should touch (used by the optional LLM-judge);
5+
``expects_tool`` is the tool the agent should pause on for approval.
6+
7+
Keep this small and representative — it is meant as a starting point you extend
8+
with the questions that matter for *your* use case.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from typing import TypedDict
14+
15+
16+
class EvalExample(TypedDict):
17+
id: str
18+
question: str
19+
reference: str
20+
expects_tool: str
21+
22+
23+
DATASET: list[EvalExample] = [
24+
{
25+
"id": "solid-state-batteries",
26+
"question": "What are the main advantages of solid-state batteries over lithium-ion?",
27+
"reference": (
28+
"Higher energy density, improved safety (non-flammable solid "
29+
"electrolyte), longer cycle life, and faster charging."
30+
),
31+
"expects_tool": "web_search",
32+
},
33+
{
34+
"id": "tcp-vs-udp",
35+
"question": "What is the difference between TCP and UDP?",
36+
"reference": (
37+
"TCP is connection-oriented, reliable, ordered, with flow/congestion "
38+
"control; UDP is connectionless, faster, best-effort, no guaranteed "
39+
"delivery — used for streaming/gaming/DNS."
40+
),
41+
"expects_tool": "web_search",
42+
},
43+
{
44+
"id": "photosynthesis",
45+
"question": "How does photosynthesis convert sunlight into chemical energy?",
46+
"reference": (
47+
"Chlorophyll absorbs light; light-dependent reactions produce ATP and "
48+
"NADPH and split water (releasing O2); the Calvin cycle fixes CO2 into "
49+
"glucose."
50+
),
51+
"expects_tool": "web_search",
52+
},
53+
{
54+
"id": "intermittent-fasting",
55+
"question": "What are the health benefits and risks of intermittent fasting?",
56+
"reference": (
57+
"Potential benefits: weight loss, insulin sensitivity, cellular "
58+
"autophagy. Risks: hunger, low energy, disordered eating, not suitable "
59+
"for some (pregnancy, diabetes) — evidence is mixed."
60+
),
61+
"expects_tool": "web_search",
62+
},
63+
{
64+
"id": "2008-crisis",
65+
"question": "What were the main causes of the 2008 financial crisis?",
66+
"reference": (
67+
"Subprime mortgage lending, mortgage-backed securities and CDOs, "
68+
"excessive leverage, ratings-agency failures, and a housing-price "
69+
"collapse triggering systemic contagion."
70+
),
71+
"expects_tool": "web_search",
72+
},
73+
]

backend/evals/run_evals.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
"""Evaluation harness for the human-in-the-loop research agent.
2+
3+
What makes an agent *good* here isn't only answer quality — it's also that it
4+
**pauses for human approval before acting**. This harness scores both:
5+
6+
Deterministic evaluators (run offline, no API keys):
7+
- ``paused_for_approval`` — did the agent interrupt for approval before running
8+
its tool? (the behaviour this whole template is about)
9+
- ``completed`` — did it produce a non-empty final answer without erroring?
10+
- ``no_pii_leak`` — is the final answer free of raw PII? (pairs with the
11+
guardrail middleware)
12+
13+
Model-graded evaluator (needs a real model):
14+
- ``correctness`` — an LLM-as-judge score (0–1) of the answer against a
15+
reference. Skipped automatically on the offline mock model.
16+
17+
Run it two ways:
18+
19+
# Local — prints a scored table. Works offline with the mock model.
20+
python -m evals.run_evals
21+
22+
# Upload to LangSmith (needs LANGSMITH_API_KEY) for tracked experiments:
23+
python -m evals.run_evals --langsmith
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import argparse
29+
import logging
30+
import os
31+
from typing import Any, Callable, Optional
32+
33+
from langchain_core.messages import HumanMessage, SystemMessage
34+
from langgraph.checkpoint.memory import MemorySaver
35+
from langgraph.types import Command
36+
37+
from agent import build_agent
38+
from guardrails import _redact
39+
from llm import get_llm, using_mock_llm
40+
41+
from .dataset import DATASET, EvalExample
42+
43+
logger = logging.getLogger(__name__)
44+
45+
46+
# --- Running the agent on one example ---------------------------------------
47+
def run_agent(question: str, build: Callable = build_agent) -> dict:
48+
"""Run the agent to completion, auto-approving tool calls.
49+
50+
Returns a result dict describing what happened (used by the evaluators).
51+
"""
52+
agent = build(checkpointer=MemorySaver())
53+
config = {"configurable": {"thread_id": f"eval-{abs(hash(question)) % 10_000}"}}
54+
55+
result: dict[str, Any] = {"paused": False, "tool": None, "answer": "", "error": None}
56+
try:
57+
state = agent.invoke({"messages": [{"role": "user", "content": question}]}, config)
58+
guard = 0
59+
while isinstance(state, dict) and "__interrupt__" in state and guard < 6:
60+
value = state["__interrupt__"][0].value
61+
requests = value.get("action_requests", []) if isinstance(value, dict) else []
62+
if requests:
63+
result["paused"] = True
64+
result["tool"] = requests[0].get("name")
65+
decisions = [{"type": "approve"}] * (len(requests) or 1)
66+
state = agent.invoke(Command(resume={"decisions": decisions}), config)
67+
guard += 1
68+
messages = state.get("messages", []) if isinstance(state, dict) else []
69+
result["answer"] = (getattr(messages[-1], "content", "") if messages else "") or ""
70+
except Exception as exc: # pragma: no cover - surfaced as a failed example
71+
logger.warning("Agent run failed for %r: %s", question[:60], exc)
72+
result["error"] = str(exc)
73+
return result
74+
75+
76+
# --- Deterministic evaluators (offline-safe) --------------------------------
77+
def paused_for_approval(example: EvalExample, result: dict) -> float:
78+
"""1.0 if the agent paused for approval on the expected tool."""
79+
return 1.0 if result.get("paused") and result.get("tool") == example["expects_tool"] else 0.0
80+
81+
82+
def completed(example: EvalExample, result: dict) -> float:
83+
"""1.0 if a non-empty answer was produced without an error."""
84+
return 1.0 if result.get("answer") and not result.get("error") else 0.0
85+
86+
87+
def no_pii_leak(example: EvalExample, result: dict) -> float:
88+
"""1.0 if the final answer contains no recognisable raw PII."""
89+
_, count = _redact(result.get("answer", ""))
90+
return 1.0 if count == 0 else 0.0
91+
92+
93+
DETERMINISTIC_EVALUATORS: dict[str, Callable[[EvalExample, dict], float]] = {
94+
"paused_for_approval": paused_for_approval,
95+
"completed": completed,
96+
"no_pii_leak": no_pii_leak,
97+
}
98+
99+
100+
# --- Model-graded evaluator (needs a real model) ----------------------------
101+
def correctness(example: EvalExample, result: dict) -> Optional[float]:
102+
"""LLM-as-judge correctness (0–1) vs the reference. ``None`` on the mock model."""
103+
if using_mock_llm() or not result.get("answer"):
104+
return None
105+
judge = get_llm(temperature=0)
106+
prompt = (
107+
"You are grading a research assistant's answer. Compare the ANSWER to the "
108+
"REFERENCE key points for the QUESTION. Reply with a single number from 0 "
109+
"to 1 (1 = fully correct and complete, 0 = wrong/empty). Reply with only "
110+
"the number.\n\n"
111+
f"QUESTION: {example['question']}\n"
112+
f"REFERENCE: {example['reference']}\n"
113+
f"ANSWER: {result['answer']}"
114+
)
115+
try:
116+
raw = judge.invoke([SystemMessage(content="You are a strict grader."),
117+
HumanMessage(content=prompt)]).content
118+
return max(0.0, min(1.0, float(str(raw).strip().split()[0])))
119+
except Exception as exc: # pragma: no cover - judge/parse failure
120+
logger.warning("Judge failed: %s", exc)
121+
return None
122+
123+
124+
# --- Local runner (no LangSmith account needed) -----------------------------
125+
def evaluate_local(
126+
examples: list[EvalExample] | None = None,
127+
limit: int | None = None,
128+
build: Callable = build_agent,
129+
with_judge: bool = True,
130+
) -> dict:
131+
"""Run every example through the agent + evaluators; return results + summary."""
132+
examples = (examples or DATASET)[: limit or None]
133+
results = []
134+
for ex in examples:
135+
run = run_agent(ex["question"], build=build)
136+
scores = {name: fn(ex, run) for name, fn in DETERMINISTIC_EVALUATORS.items()}
137+
if with_judge:
138+
c = correctness(ex, run)
139+
if c is not None:
140+
scores["correctness"] = c
141+
results.append({"id": ex["id"], "scores": scores, "answer": run.get("answer", "")})
142+
143+
# Aggregate means per metric.
144+
summary: dict[str, float] = {}
145+
for metric in {k for r in results for k in r["scores"]}:
146+
vals = [r["scores"][metric] for r in results if metric in r["scores"]]
147+
summary[metric] = round(sum(vals) / len(vals), 3) if vals else 0.0
148+
return {"results": results, "summary": summary, "n": len(results)}
149+
150+
151+
def _print_report(report: dict) -> None:
152+
print("\n=== Eval results ===")
153+
for r in report["results"]:
154+
line = " ".join(f"{k}={v:.2f}" for k, v in r["scores"].items())
155+
print(f" {r['id']:<24} {line}")
156+
print("\n=== Summary (mean) ===")
157+
for metric, val in sorted(report["summary"].items()):
158+
print(f" {metric:<22} {val:.3f}")
159+
print(f"\n examples: {report['n']}")
160+
161+
162+
# --- Optional: upload + run as a LangSmith experiment -----------------------
163+
def evaluate_langsmith(dataset_name: str = "hitl-research-agent") -> Any:
164+
"""Create/refresh a LangSmith dataset and run a tracked experiment.
165+
166+
Requires ``LANGSMITH_API_KEY``. Uses LangSmith's ``evaluate`` so results are
167+
logged as an experiment you can compare over time.
168+
"""
169+
if not os.getenv("LANGSMITH_API_KEY"):
170+
raise RuntimeError("Set LANGSMITH_API_KEY to run the LangSmith experiment.")
171+
172+
from langsmith import Client, evaluate
173+
174+
client = Client()
175+
if not client.has_dataset(dataset_name=dataset_name):
176+
ds = client.create_dataset(dataset_name=dataset_name)
177+
client.create_examples(
178+
inputs=[{"question": e["question"]} for e in DATASET],
179+
outputs=[{"reference": e["reference"], "expects_tool": e["expects_tool"]} for e in DATASET],
180+
dataset_id=ds.id,
181+
)
182+
183+
def target(inputs: dict) -> dict:
184+
run = run_agent(inputs["question"])
185+
return {"answer": run["answer"], "paused": run["paused"], "tool": run["tool"]}
186+
187+
def _wrap(name: str, fn):
188+
def evaluator(run, example) -> dict:
189+
ex: EvalExample = { # reconstruct the fields the core evaluators need
190+
"id": name,
191+
"question": example.inputs.get("question", ""),
192+
"reference": (example.outputs or {}).get("reference", ""),
193+
"expects_tool": (example.outputs or {}).get("expects_tool", "web_search"),
194+
}
195+
score = fn(ex, run.outputs or {})
196+
return {"key": name, "score": score}
197+
return evaluator
198+
199+
evaluators = [_wrap(name, fn) for name, fn in DETERMINISTIC_EVALUATORS.items()]
200+
evaluators.append(_wrap("correctness", correctness))
201+
return evaluate(target, data=dataset_name, evaluators=evaluators, client=client)
202+
203+
204+
def main() -> None:
205+
parser = argparse.ArgumentParser(description="Run the research-agent evals.")
206+
parser.add_argument("--langsmith", action="store_true", help="upload + run on LangSmith")
207+
parser.add_argument("--limit", type=int, default=None, help="only run the first N examples")
208+
args = parser.parse_args()
209+
210+
logging.basicConfig(level=logging.INFO)
211+
if args.langsmith:
212+
print("Running LangSmith experiment…")
213+
result = evaluate_langsmith()
214+
print(result)
215+
else:
216+
if using_mock_llm():
217+
print("(mock model — the LLM-judge 'correctness' metric is skipped)")
218+
_print_report(evaluate_local(limit=args.limit))
219+
220+
221+
if __name__ == "__main__":
222+
main()

backend/requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,7 @@ langchain-openai>=1.2,<2
2828
# --- Optional tools ---
2929
# langchain-tavily>=0.2 # live web search (set TAVILY_API_KEY)
3030
# langchain-mcp-adapters>=0.1 # load tools from MCP servers (set MCP_SERVERS)
31+
32+
# --- Evaluation (backend/evals) ---
33+
# Pulled in transitively by langchain; pinned here for the eval harness.
34+
langsmith>=0.9,<1

0 commit comments

Comments
 (0)