Skip to content

Commit cf6f6c9

Browse files
author
Sathwik Arroju
committed
Phase 14: LLM agent eval harness
- analyst/evals: EvalCase + scorer + runner (4-dimension grading) - 55-case corpus covering all 10 agent tools (5+ per tool) - python -m analyst.evals CLI with --backend, --tag, --baseline, --min-pass-rate - pages/evals.py: live Streamlit eval viewer with per-tool breakdown - Eval-driven heuristic fixes: 78.2% -> 100% pass rate - 23 new pytest tests, baseline pinned, CI runs evals on every PR
1 parent eb574e4 commit cf6f6c9

17 files changed

Lines changed: 3243 additions & 112 deletions

File tree

.github/workflows/tests.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,25 @@ jobs:
4343
SMTP_HOST: ""
4444
GEMINI_API_KEY: ""
4545
run: pytest -v
46+
47+
# Eval harness — runs the agent against the 55-case corpus on the
48+
# heuristic backend and fails if pass rate drops > 2pp below the
49+
# pinned baseline. Only run on one Python version since it's slow-ish.
50+
- name: Run agent evals
51+
if: matrix.python-version == '3.11'
52+
env:
53+
GEMINI_API_KEY: ""
54+
run: |
55+
python -m analyst.evals \
56+
--backend heuristic \
57+
--baseline tests/fixtures/eval_baseline.json \
58+
--min-pass-rate 0.95 \
59+
--json eval_report.json
60+
61+
- name: Upload eval report
62+
if: matrix.python-version == '3.11' && always()
63+
uses: actions/upload-artifact@v4
64+
with:
65+
name: eval-report
66+
path: eval_report.json
67+
if-no-files-found: ignore

agent/ui.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Shared Streamlit chrome — sidebar nav, login state, and the
2+
"sign up to save your work" banner shown on anonymous pages.
3+
4+
Every page calls `apply_chrome(page_name)` once at the top so the look + feel
5+
stays consistent without repeating boilerplate.
6+
"""
7+
from __future__ import annotations
8+
9+
import streamlit as st
10+
11+
12+
# Pages that work without authentication. Listed here so the sidebar can mark
13+
# them as "✨ public" and the nav can hide auth-only pages from anonymous users.
14+
PUBLIC_PAGES = {"app.py", "pages/demo.py", "pages/analyst_workbench.py",
15+
"pages/login.py", "pages/evals.py"}
16+
17+
18+
def is_authed() -> bool:
19+
return bool(st.session_state.get("username"))
20+
21+
22+
def current_username() -> str | None:
23+
return st.session_state.get("username")
24+
25+
26+
def _sidebar_account_block() -> None:
27+
"""Sidebar account widget: shows logout if authed, login CTA if not."""
28+
with st.sidebar:
29+
st.markdown("### Account")
30+
if is_authed():
31+
st.success(f"👤 {current_username()}")
32+
if st.button("🚪 Log out", use_container_width=True, key="ui_logout"):
33+
st.session_state.clear()
34+
st.switch_page("app.py")
35+
else:
36+
st.caption("You're browsing anonymously.")
37+
if st.button("🔐 Log in / Sign up", use_container_width=True,
38+
key="ui_login_cta"):
39+
st.switch_page("pages/login.py")
40+
41+
42+
def _sidebar_nav_block() -> None:
43+
"""Sidebar nav links — explicit page links so users don't need to know
44+
that public pages are accessible while anonymous."""
45+
with st.sidebar:
46+
st.markdown("### Pages")
47+
st.page_link("app.py", label="🏠 Home", icon=None)
48+
st.page_link("pages/demo.py", label="🎬 Demo (no signup)")
49+
st.page_link("pages/analyst_workbench.py", label="🧪 Analyst Workbench")
50+
st.page_link("pages/evals.py", label="🧪 Agent Evals")
51+
if is_authed():
52+
st.page_link("pages/dashboard.py", label="📊 Ops Dashboard")
53+
st.page_link("pages/run_agent.py", label="🤖 Run Ops Agent")
54+
55+
56+
def _anon_save_banner() -> None:
57+
"""Shown at the top of pages that work anonymously — invites the user
58+
to sign up so their work persists across sessions."""
59+
if is_authed():
60+
return
61+
st.info(
62+
"👋 You're browsing anonymously — "
63+
"[**sign up**](#) to save connections, recommendations, and bandit feedback. "
64+
"Use the sidebar to log in.",
65+
icon="ℹ️",
66+
)
67+
68+
69+
def apply_chrome(page_path: str, *, show_anon_banner: bool = True) -> None:
70+
"""Idempotent chrome injector. Call once at the top of every page.
71+
72+
page_path: this page's path relative to repo root (e.g. "pages/demo.py").
73+
Used to decide whether to show the anonymous banner.
74+
show_anon_banner: explicit override — set False on the landing page itself
75+
so we don't double up on the CTA.
76+
"""
77+
_sidebar_account_block()
78+
_sidebar_nav_block()
79+
if show_anon_banner and page_path in PUBLIC_PAGES and not is_authed():
80+
_anon_save_banner()
81+
82+
83+
def require_auth(*, redirect_to: str = "pages/login.py") -> str:
84+
"""Block until the user is authenticated. Returns the username on success;
85+
redirects to the login page otherwise."""
86+
if not is_authed():
87+
st.warning("You need to log in to view this page.")
88+
if st.button("Go to login", type="primary"):
89+
st.switch_page(redirect_to)
90+
st.stop()
91+
return current_username() # type: ignore[return-value]
92+
93+
94+
__all__ = [
95+
"apply_chrome", "require_auth", "is_authed", "current_username",
96+
"PUBLIC_PAGES",
97+
]

analyst/agent.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -242,19 +242,39 @@ def to_dict(self) -> dict:
242242

243243
# Each pattern → (tool_name, args). Order matters: more-specific first.
244244
_HEURISTIC_PATTERNS: List[tuple[str, str, Dict[str, Any]]] = [
245-
(r"\b(churn|leaving|inactive|retention risk)\b", "churn_risk", {}),
246-
(r"\b(cohort|retention|monthly retention)\b", "cohort_retention", {}),
247-
(r"\b(elasticity|price sensitiv|how price-sensitive)\b", "price_elasticity", {}),
248-
(r"\b(co.?purchas\w*|basket\w*|frequently bought|bundle\w*|cross.?sell\w*)\b", "co_purchases", {}),
249-
(r"\b(quadrant|star product|cash cow|dog product|bcg)\b", "product_quadrants", {}),
250-
(r"\b(segment|rfm|champion|loyal|at-?risk)\b", "segment_customers", {}),
251-
(r"\btop\s+(\d+)?\s*customers?\b", "top_customers", {}),
252-
(r"\b(best|top)\s+(\d+)?\s*(products?|sku)\b", "top_products", {}),
245+
# Retention — match churn/churning/churned via \w*
246+
(r"\b(churn\w*|leaving|inactive|retention risk)\b", "churn_risk", {}),
247+
(r"\b(cohort\w*|monthly retention|retention curve|retention decay|retention by signup)\b",
248+
"cohort_retention", {}),
249+
# Pricing — also catch "price-sensitive", "pricing power", "demand drop when ... price"
250+
(r"\b(elasticity|elastic|price[\s-]?sensitiv\w*|pricing power|how price[\s-]?sensitive|"
251+
r"demand\s+drops?\s+when\s+(i\s+)?(raise|increase)\s+(the\s+)?prices?)\b",
252+
"price_elasticity", {}),
253+
# Basket
254+
(r"\b(co.?purchas\w*|basket\w*|frequently bought|bundle\w*|cross.?sell\w*)\b",
255+
"co_purchases", {}),
256+
# BCG quadrants — accept plurals + "question mark(s)"
257+
(r"\b(quadrants?|stars?|cash\s+cows?|dog\s+products?|bcg|question\s+marks?)\b",
258+
"product_quadrants", {}),
259+
# RFM/segments — \w* lets "segment", "segments", "segmentation" through
260+
(r"\b(segment\w*|rfm|champion\w*|loyal(ty)?|at-?risk)\b", "segment_customers", {}),
261+
# Top customers — also "biggest spenders" / "highest paying customers" / "best customer"
262+
(r"\b(?:top|best|biggest|highest)\s+(\d+)?\s*"
263+
r"(?:customer\w*|spender\w*|paying\s+customer\w*)\b",
264+
"top_customers", {}),
265+
# Top products — also "best-selling", "Which N products bring in the most..."
266+
(r"\b(?:best.?selling|top|best|biggest)\s+(\d+)?\s*(?:products?|skus?)\b",
267+
"top_products", {}),
268+
(r"\bwhich\s+(\d+)\s+(?:products?|skus?)\b", "top_products", {}),
269+
# Revenue cadence — daily/monthly/weekly fall through to weekly default
253270
(r"\b(daily|by day)\b.*revenue|revenue.*\b(daily|by day)\b", "revenue_by_period", {"freq": "D"}),
254-
(r"\b(monthly|by month)\b.*revenue|revenue.*\b(monthly|by month)\b", "revenue_by_period", {"freq": "M"}),
271+
(r"\b(monthly|by month)\b.*\b(revenue|sales)\b|\b(revenue|sales)\b.*\b(monthly|by month)\b",
272+
"revenue_by_period", {"freq": "M"}),
255273
(r"\b(weekly|by week|trend|over time)\b", "revenue_by_period", {"freq": "W"}),
256274
(r"\b(revenue|sales)\b", "revenue_by_period", {"freq": "W"}),
257-
(r"\b(schema|columns|what.* in (the|my) (data|dataset))\b", "describe_columns", {}),
275+
# Schema
276+
(r"\b(schema|columns?|what.* in (the|my) (data|dataset)|describe.*columns?)\b",
277+
"describe_columns", {}),
258278
]
259279

260280

analyst/evals/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Eval harness for the LLM agent — runs a corpus of questions through
2+
`analyst.agent.ask`, scores each against an expected plan, and aggregates
3+
pass/fail metrics so we can spot regressions.
4+
5+
Public surface:
6+
7+
from analyst.evals import (
8+
EvalCase, CaseResult, EvalReport,
9+
run_eval, run_all, load_cases,
10+
)
11+
12+
Run from the CLI with:
13+
14+
python -m analyst.evals --backend heuristic
15+
"""
16+
from __future__ import annotations
17+
18+
from analyst.evals.scorer import (
19+
EvalCase, CaseResult, EvalReport, score_case,
20+
)
21+
from analyst.evals.runner import run_eval, run_all
22+
from analyst.evals.cases import load_cases, ALL_CASES
23+
24+
__all__ = [
25+
"EvalCase", "CaseResult", "EvalReport", "score_case",
26+
"run_eval", "run_all", "load_cases", "ALL_CASES",
27+
]

analyst/evals/__main__.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""CLI entry point — runs the eval corpus and exits with a non-zero status if
2+
the pass rate falls below `--min-pass-rate` (or below the baseline).
3+
4+
Examples:
5+
6+
python -m analyst.evals
7+
python -m analyst.evals --backend llm
8+
python -m analyst.evals --tag retention --tag products
9+
python -m analyst.evals --json eval_results.json
10+
python -m analyst.evals --min-pass-rate 0.95
11+
python -m analyst.evals --baseline tests/fixtures/eval_baseline.json
12+
"""
13+
from __future__ import annotations
14+
15+
import argparse
16+
import json
17+
import sys
18+
from pathlib import Path
19+
from typing import List
20+
21+
import pandas as pd
22+
23+
from analyst.evals import ALL_CASES, EvalReport, load_cases, run_all
24+
25+
26+
REPO_ROOT = Path(__file__).resolve().parents[2]
27+
DEFAULT_DATA = REPO_ROOT / "analyst" / "sample_data" / "retail_orders.csv"
28+
29+
30+
def _build_parser() -> argparse.ArgumentParser:
31+
p = argparse.ArgumentParser(
32+
prog="python -m analyst.evals",
33+
description="Run the LLM agent eval harness against a sample dataset.",
34+
)
35+
p.add_argument(
36+
"--data", type=Path, default=DEFAULT_DATA,
37+
help=f"CSV/Parquet to run evals against (default: {DEFAULT_DATA.name}).",
38+
)
39+
p.add_argument(
40+
"--backend", choices=["heuristic", "llm", "auto"], default="heuristic",
41+
help="Agent planning backend. CI should use 'heuristic' for determinism.",
42+
)
43+
p.add_argument(
44+
"--tag", action="append", default=None,
45+
help="Filter by tag (repeatable). OR semantics across tags.",
46+
)
47+
p.add_argument(
48+
"--id", action="append", default=None,
49+
help="Filter by case id (repeatable).",
50+
)
51+
p.add_argument(
52+
"--json", type=Path, default=None,
53+
help="Write the full report to this file as JSON.",
54+
)
55+
p.add_argument(
56+
"--min-pass-rate", type=float, default=0.0,
57+
help="Exit non-zero if pass rate falls below this threshold.",
58+
)
59+
p.add_argument(
60+
"--baseline", type=Path, default=None,
61+
help=("Compare to a previous run's JSON. Exit non-zero if pass_rate "
62+
"drops by more than 0.02."),
63+
)
64+
p.add_argument(
65+
"--quiet", action="store_true", help="Suppress per-case logs.",
66+
)
67+
return p
68+
69+
70+
def _print_report(report: EvalReport, *, quiet: bool = False) -> None:
71+
print(f"\n=== eval report (backend={report.backend}) ===")
72+
print(f"pass_rate : {report.pass_rate * 100:.1f}% "
73+
f"({report.n_passed}/{report.n_cases})")
74+
print(f"mean_overall : {report.mean_overall:.3f}")
75+
print(f" tool_match : {report.mean_tool_match:.3f}")
76+
print(f" args_match : {report.mean_args_match:.3f}")
77+
print(f" no_forbidden: {report.mean_no_forbidden:.3f}")
78+
print(f" success_match:{report.mean_success_match:.3f}")
79+
80+
if report.per_tool:
81+
print("\nper-tool:")
82+
for tool, stats in sorted(report.per_tool.items(),
83+
key=lambda kv: kv[1]["pass_rate"]):
84+
print(f" {tool:<22s} {stats['pass_rate']*100:5.1f}% "
85+
f"(n={int(stats['n'])}, mean={stats['mean_overall']:.3f})")
86+
87+
if not quiet:
88+
failures = [c for c in report.cases if not c.passed]
89+
if failures:
90+
print(f"\nfailures ({len(failures)}):")
91+
for r in failures:
92+
print(f" ✗ {r.case_id} {r.question[:70]}")
93+
for reason in r.failed_reasons:
94+
print(f" • {reason}")
95+
96+
97+
def _maybe_compare_baseline(report: EvalReport, baseline_path: Path) -> int:
98+
if not baseline_path.exists():
99+
print(f"\n(no baseline at {baseline_path} — skipping comparison)")
100+
return 0
101+
baseline = json.loads(baseline_path.read_text())
102+
base_pass = float(baseline.get("pass_rate", 0.0))
103+
delta = report.pass_rate - base_pass
104+
print(f"\nbaseline pass_rate : {base_pass*100:.1f}%")
105+
print(f"current pass_rate : {report.pass_rate*100:.1f}%")
106+
print(f"delta : {delta*100:+.1f}pp")
107+
if delta < -0.02: # >2 percentage point regression
108+
print("✗ REGRESSION — pass rate dropped by more than 2pp")
109+
return 2
110+
return 0
111+
112+
113+
def main(argv: list[str] | None = None) -> int:
114+
args = _build_parser().parse_args(argv)
115+
116+
if not args.data.exists():
117+
print(f"error: data file not found: {args.data}", file=sys.stderr)
118+
return 1
119+
120+
df = pd.read_csv(args.data) if args.data.suffix == ".csv" else pd.read_parquet(args.data)
121+
print(f"loaded {len(df):,} rows from {args.data.name}")
122+
123+
cases = load_cases(tags=args.tag, ids=args.id)
124+
if not cases:
125+
print("error: no cases matched the filters", file=sys.stderr)
126+
return 1
127+
print(f"running {len(cases)} cases on backend={args.backend}")
128+
129+
report = run_all(cases, df, backend=args.backend)
130+
_print_report(report, quiet=args.quiet)
131+
132+
if args.json:
133+
args.json.parent.mkdir(parents=True, exist_ok=True)
134+
args.json.write_text(json.dumps(report.to_dict(), default=str, indent=2))
135+
print(f"\nwrote {args.json}")
136+
137+
exit_code = 0
138+
if args.baseline:
139+
exit_code = max(exit_code, _maybe_compare_baseline(report, args.baseline))
140+
141+
if report.pass_rate < args.min_pass_rate:
142+
print(f"\n✗ pass rate {report.pass_rate*100:.1f}% < min "
143+
f"{args.min_pass_rate*100:.1f}%")
144+
exit_code = max(exit_code, 1)
145+
elif args.min_pass_rate > 0:
146+
print(f"\n✓ pass rate {report.pass_rate*100:.1f}% ≥ min "
147+
f"{args.min_pass_rate*100:.1f}%")
148+
149+
return exit_code
150+
151+
152+
if __name__ == "__main__":
153+
raise SystemExit(main())

0 commit comments

Comments
 (0)