How ai-evals works
A deep-dive into the codebase — from a first ai-evals init all
the way to how every module connects.
Phase 1 is fully implemented.
Static analysis, scaffolding, init, analyze,
doctor, config, and all CLI surface are live and
tested. Phases 2–4 (judge gateway, bootstrap tracer, runner) are
designed and stubbed, waiting for implementation.
What is ai-evals?
ai-evals is a zero-config, repo-aware, model-agnostic AI evaluation CLI. You point it at any Python project that uses OpenAI, LangChain, ChromaDB, or similar frameworks, and it:
- Reads your code (AST, never executes it) to infer what AI tasks exist.
- Scaffolds an
eval/directory with a rubrics file, a golden-set stub, and a pytest entry point. - Captures real I/O traces while your tests run (Phase 3).
- Scores each trace through a configurable judge LLM and surfaces regressions (Phase 4).
The key insight: you do not write eval harnesses by hand. The tool reads your repo and generates them for you, then updates them as your code evolves.
Big picture — end-to-end flow
Your Python repo
│
▼ ast.parse every .py file
┌─────────────────────────────┐
│ inference/ │ ← Phase 1 ✅
│ ast_scan ▸ detectors │
│ signatures ▸ synthesize │
└────────────┬────────────────┘
│ list[DetectedTask] → RubricsConfig
▼
┌─────────────────────────────┐
│ scaffold/ │ ← Phase 1 ✅
│ rubrics_writer │ → eval/rubrics.yaml
│ golden_writer │ → eval/golden_set.json (stub)
│ tests_writer │ → eval/tests.py
└────────────┬────────────────┘
│
▼ user runs ai-evals bootstrap -- pytest
┌─────────────────────────────┐
│ bootstrap/ (Phase 3 🔜) │ → eval/golden_set.json (real captures)
│ wrappers ▸ tracer │
└────────────┬────────────────┘
│
▼ user runs ai-evals run
┌─────────────────────────────┐
│ judge/ (Phase 2 🔜) │ → LiteLLM → Ollama / OpenAI / …
│ runner/ (Phase 4 🔜) │ → metrics → .ai-evals/history.json
└────────────┬────────────────┘
│
▼
┌─────────────────────────────┐
│ insights/ (Phase 4 🔜) │ → ai-evals diff → regression report
└─────────────────────────────┘
4-phase roadmap
| Phase | Nickname | Status | Delivers |
|---|---|---|---|
| 1 | Static analysis & scaffolding | ✅ Done | AST scan, detectors, init, analyze, doctor, config |
| 2 | Model-agnostic judge gateway | 🔜 Next | LiteLLM facade, Instructor JSON parsing, dual-tier prompts, cache, judge command |
| 3 | Golden-set bootstrapper | ⏳ Future | Monkey-patch wrappers, OTEL ingest, bootstrap command, redaction |
| 4 | Runner + insights dashboard | ⏳ Future | Async eval engine, built-in metrics, run, diff, report, history |
Repository layout
├── __init__.py — version string
├── __main__.py — `python -m ai_eval` entry point
├── cli/ — Typer app + one module per command
│ ├── app.py — root Typer instance, GlobalOptions, CI detection
│ ├── init.py — `ai-evals init`
│ ├── analyze.py — `ai-evals analyze`
│ ├── doctor.py — `ai-evals doctor` (read-only)
│ ├── config_cmd.py — `ai-evals config`
│ ├── stubs.py — Phase 2-4 placeholder commands
│ ├── bootstrap.py run.py diff.py … — stubs for future phases
│ └── render/
│ ├── theme.py — icon/color palette
│ ├── tables.py — Rich human-format renderers
│ └── json_out.py — stable JSON output with schema_version
├── inference/ — repo awareness (Phase 1)
│ ├── signatures.py — shared AST predicates (attr_chain, import helpers, OpenAI partition keys)
│ ├── ast_scan.py — os.walk + prune + gitignore + per-file parse/detect
│ ├── synthesize.py — DetectedTask list → RubricsConfig
│ └── detectors/
│ ├── base.py — Detector ABC + DetectedTask dataclass
│ ├── openai_tools.py — chat.completions.create(tools=…)
│ ├── openai_chat.py — chat.completions.create (no tools)
│ ├── langchain.py — .invoke/.run + agent/retriever heuristics
│ └── chromadb.py — collection.query/get
├── scaffold/ — writes eval/* files (Phase 1)
│ ├── rubrics_writer.py — RubricsConfig → YAML
│ ├── golden_writer.py — safe write/merge of golden_set.json
│ ├── tests_writer.py — writes eval/tests.py from template
│ ├── gitignore_patch.py — appends .ai-evals/ to .gitignore
│ └── templates/tests_py.tmpl
├── config/
│ ├── defaults.py — built-in values (Ollama judge, 4 parallel, 0.02 tolerance…)
│ ├── schema.py — Pydantic models: RubricsConfig, TaskSpec, JudgeConfig…
│ └── loader.py — 5-layer deep-merge with per-key provenance
├── storage/
│ └── paths.py — ProjectPaths dataclass (all filesystem paths in one place)
└── telemetry/
├── logger.py — structured stderr logging (plain / JSON at -v)
└── progress.py — Rich spinner (silenced when piped)
Files ai-evals creates in your repo
After a successful ai-evals init your project gets two new
top-level directories:
├── eval/ ← checked into git
│ ├── rubrics.yaml — source of truth: tasks, thresholds, judges
│ ├── golden_set.json — captured I/O examples (filled by bootstrap)
│ └── tests.py — pytest entry point that calls ai-evals run
└── .ai-evals/ ← gitignored local cache
├── history.json — rolling list of past run summaries
├── runs/r_<id>/ — full record + traces per run
└── cache/judge/ — content-addressed judge response cache
eval/ is checked in; .ai-evals/ is local.
The tool appends .ai-evals/ to your .gitignore automatically.
golden_set.json contains your bootstrap captures — treat it like
a test fixture and commit it.
CLI commands
All commands follow the shape ai-evals <command> [flags] — max one level of nesting.
ai-evals init
Scan the repo, detect AI tasks, write eval/rubrics.yaml,
golden_set.json stub, and tests.py.
ai-evals analyze
Re-run detection and merge new findings into the existing
rubrics.yaml. Default is dry-run; use --write to apply.
ai-evals doctor
Read-only environment check: Python version, deps, provider creds, rubrics validity, state-dir writability. Exits 1 if any check fails.
ai-evals config
Print the merged config with per-key source provenance (cli → env → rubrics.yaml → user-global → builtin).
ai-evals bootstrap -- <cmd>
Monkey-patch frameworks, run your tests, capture live I/O into
golden_set.json. Requires explicit -- pytest etc.
ai-evals run
Execute the full eval suite against the golden set through the judge
gateway. Supports --fail-on-regression for CI gating.
ai-evals diff
Show why a run regressed by feeding paired traces through the regression judge for a "why it failed" narrative.
ai-evals judge
Sanity-check configured judge models: --list, --ping,
and --prompt for one-shot testing.
ai-evals report
Render a stored run as human / JSON / Markdown / HTML.
ai-evals history
List, show, prune, or export past runs from
.ai-evals/history.json.
ai-evals version
Print ai-evals X.Y.Z (python A.B) and exit.
Global flags
These flags apply to every command and are resolved
in cli/app.py → _root() before the subcommand runs. They are
stored on ctx.obj as a GlobalOptions dataclass.
| Flag | Short | Effect |
|---|---|---|
--cwd <dir> | -C | Run as if invoked from <dir> |
--config <file> | — | Override path to rubrics.yaml |
--format | — | auto (default) / human / json / tsv |
--no-color | — | Disable ANSI colors |
--quiet | -q | Suppress progress spinners |
--verbose | -v | JSON log lines to stderr (repeatable: -vv) |
--yes | -y | Assume yes on all confirmations |
--no-input | — | Fail instead of prompting (CI-safe) |
--version | — | Print version and exit |
CI auto-mode
When the environment variable CI=true is set, the root
callback in app.py:158 automatically applies:
--no-inputis forced on--no-coloris forced on--format autoresolves tojson
CI auto-mode is per the design decision in
.kilo/plans/ai-evals-cli-and-system-design.md §4 Q6. The
behavior is documented and user-overridable by explicitly passing e.g.
--format human.
Exit codes
| Code | Constant | Meaning |
|---|---|---|
0 | EXIT_OK | Success |
1 | EXIT_GENERAL | General / runtime error |
2 | EXIT_USAGE | Bad flags, --no-input hit a prompt, or command not yet implemented |
3 | EXIT_REGRESSION | Eval thresholds breached (run --fail-on-regression — Phase 4) |
Every error printed to stderr follows the pattern from plan §1.7:
error: <one-line summary>
what: <what went wrong>
why: <root cause if known>
fix: <suggested next step>
Configuration resolution order
Implemented in config/loader.py → load_resolved().
The five layers are deep-merged from lowest to highest priority.
Each key tracks its winning source for ai-evals config --print.
config/defaults.py
Ollama judge, 4 parallel workers, 0.02 tolerance, cache on.
~/.config/ai-evals/config.yaml
Optional. Set your personal default judge model here once.
eval/rubrics.yaml
The main editable config. Written by init, updated by analyze.
AI_EVAL_*
Allowlisted keys: AI_EVAL_JUDGE_DEFAULT, AI_EVAL_PARALLEL,
AI_EVAL_TOLERANCE, AI_EVAL_CACHE,
AI_EVAL_JUDGE_REGRESSION_CHECK.
--judge-default, --judge-regression, etc.
Always win over everything else.
Inference engine
The inference layer lives in ai_eval/inference/ and answers:
"What AI tasks does this repo contain, and what type are they?"
It does this with pure static analysis — no importing, no executing.
scan_repo — the main entry point
inference/ast_scan.py → scan_repo(root) is the top-level
function called by ai-evals init and ai-evals analyze.
Here is what it does in order:
Loads builtins (OpenAIToolsDetector, OpenAIChatDetector,
LangChainDetector, ChromaDBDetector) plus any
third-party detectors registered under the ai_eval.detectors
entry-point group. Deduplicates by class identity so a plugin cannot
re-register a builtin.
.gitignore set
Runs git check-ignore --stdin once to batch-check all
candidate .py files. Returns a frozenset of
repo-relative paths to skip. Falls back to the hard-coded
_DEFAULT_IGNORES set if git is unavailable.
os.walk + directory pruning
iter_python_files mutates dirnames
in-place before os.walk descends, so directories like
.venv, __pycache__, node_modules
are never enumerated — critical for performance on large repos.
ast.parse(source) produces a tree. Syntax errors silently
skip the file. Then collect_imports, iter_calls,
and find_callable_defs are computed once
and shared across all detectors — no redundant tree walks.
For each file, call detector.matches(tree, imports)
(cheap), then detector.extract(..., calls=calls, defs=defs).
Accumulate list[DetectedTask]. Return a
ScanResult.
Detectors
Every detector subclasses inference/detectors/base.py → Detector
and implements two methods:
class Detector(ABC):
framework: str # "openai" | "langchain" | "chromadb" | …
def matches(self, tree, imports) -> bool: ...
# Quick check: does this file import the relevant framework?
def extract(self, tree, imports, file_path, project_root,
*, calls, defs) -> list[DetectedTask]: ...
# Detailed extraction: which calls, which enclosing function?
Partition invariant — OpenAI chat vs tools
Two detectors target the OpenAI SDK. They must be mutually exclusive
— the same call cannot produce two tasks. The partition is enforced through
shared predicates in signatures.py:
signatures.py)OPENAI_TOOL_KWARGS = frozenset({"tools", "functions", "tool_choice"})
def is_openai_completions_create(call): ...
# True for .chat.completions.create(…) or .ChatCompletion.create(…)
def has_openai_tool_kwarg(call): ...
# True when call has tools=, functions=, or tool_choice= kwarg
OpenAIToolsDetector: is_create AND has_tool_kwarg → type="tool_calling"
OpenAIChatDetector: is_create AND NOT has_tool_kwarg → type="chat"
Built-in detector summary
| Detector | Matches when | Emits type | Key signal |
|---|---|---|---|
OpenAIToolsDetector |
openai imported |
tool_calling |
.chat.completions.create(tools=…) |
OpenAIChatDetector |
openai imported |
chat |
.chat.completions.create() without tools kwarg |
LangChainDetector |
langchain* imported |
chat / agent / rag |
.invoke(), .run() + agent/retriever import hints |
ChromaDBDetector |
chromadb imported |
rag |
collection.query() or .get() |
How attr_chain works
The key primitive used by all detectors is
signatures.py → attr_chain(node). It walks an
ast.Attribute chain upward and returns a list of names.
The tricky case is a chained receiver like
OpenAI().chat.completions.create(…) where the root is a
call expression, not a name:
attr_chain( OpenAI().chat.completions.create.func )
# → ['<call>', 'chat', 'completions', 'create']
chain[-3:] == ['chat', 'completions', 'create'] # → True ✓
The leading sentinel <call> means detectors only need to match
the tail of the chain, regardless of what the receiver expression is.
Synthesize — DetectedTask → RubricsConfig
inference/synthesize.py → build_rubrics(scan) converts the
raw list of detected tasks into a validated RubricsConfig Pydantic
model ready to be serialized to YAML.
It does three things:
-
Assigns default metrics per task type.
tool_callingtasks getargument_accuracy+hallucination_rate.ragtasks getcontext_precision+faithfulness. These thresholds are editable inrubrics.yamlafter init. -
Resolves name collisions.
If two files both have a
def run()that uses LLMs, synthesize gives the second one the namerun_2, thenrun_3, etc. -
Classifies the project type.
Counts task types and picks:
rag/tool_calling/agent/rag_and_tools/chat/custom. Written intorubrics.yamlas metadata.
Scaffold writers
The scaffold/ package contains the code that actually writes
files to disk. Three important invariants:
golden_set.json is protected data, not regenerable scaffold.
golden_writer.write_stub(rubrics, path) uses a four-outcome
protocol: wrote (new), refreshed (empty existing),
merged (captures exist → add new keys only, never delete),
overwrote (only when overwrite=True is passed
explicitly). init --force does not set
overwrite=True — only --reset-golden does.
analyze --write creates a .bak before replacing.
rubrics.yaml is written atomically (temp file +
os.replace). A rubrics.yaml.bak is created
first. The canonical header comment is always re-emitted. Writing is
skipped entirely when the merge produces no change, to avoid
touching mtime / git status.
rubrics_writer
Converts a RubricsConfig to YAML via
yaml.safe_dump and prepends a header comment. The YAML is
deterministic (no random ordering) because Pydantic serializes in field
declaration order.
tests_writer
Reads scaffold/templates/tests_py.tmpl from the installed
package using importlib.resources and writes it as
eval/tests.py. The template is a pytest file that calls
python -m ai_eval run --fail-on-regression as a single test.
gitignore_patch
Reads the existing .gitignore, checks line-by-line for
.ai-evals/, and appends the block only when absent. Never
touches any existing content.
Data contracts (on-disk JSON/YAML schemas)
Every file written by ai-evals embeds schema_version: 1.
Breaking changes bump the version and require a migration path in
config/loader.py.
rubrics.yaml — the editable source of truth
schema_version: 1
project_type: rag_and_tools # inferred, editable
judge:
default: ollama/qwen2.5-coder:7b
regression_check: openai/gpt-4o-mini
fallback: []
defaults:
parallel: 4
cache: true
tolerance: 0.02
tasks:
customer_support_agent:
file_path: src/agents/support.py
entry: customer_support_agent # enclosing function
type: tool_calling
inputs: [messages, tools]
outputs: [tool_calls, content]
metrics:
- name: argument_accuracy
threshold: 0.9
weight: 1.0
golden_set.json — captured I/O examples
{
"schema_version": 1,
"tasks": {
"customer_support_agent": [
{
"id": "gs_a3f8",
"captured_at": "2026-06-25T08:00:00Z",
"input": { "messages": [...] },
"trace": { "calls": [...] }
}
]
}
}
Pydantic schema hierarchy
| Model | File | Purpose |
|---|---|---|
RubricsConfig | config/schema.py | Top-level rubrics.yaml schema; validates on load |
JudgeConfig | config/schema.py | default, regression_check, fallback list |
TaskSpec | config/schema.py | One entry under tasks: |
MetricSpec | config/schema.py | name, threshold, weight, params per metric |
DefaultsBlock | config/schema.py | parallel, cache, tolerance |
ResolvedConfig | config/loader.py | Merged dict + per-key source provenance |
Telemetry — logging & progress
Two separate channels, as required by the design:
| Channel | Module | Content | Destination |
|---|---|---|---|
| Data | cli/render/ |
Rich tables / JSON / TSV output | stdout — pipe-safe |
| Status | telemetry/progress.py |
Spinners, progress counters, "wrote …" | stderr — silenced when not TTY |
| Logs (default) | telemetry/logger.py |
Human-readable status messages | stderr |
Logs (-v) |
telemetry/logger.py |
Single-line JSON: ts, level, event, task, model, duration_ms |
stderr |
The status() context manager from progress.py
shows a Rich spinner when stderr is a TTY and
enabled=True. It silently yields (no-op) when piped, in CI, or
when --quiet is set.
Security model (plan §2.12)
-
Write boundary. The tool never writes outside
<cwd>/eval/,<cwd>/.ai-evals/, or~/.config/ai-evals/unless--outis passed. -
Read boundary.
ast_scanrespects.gitignoreviagit check-ignore. Files the user has gitignored (e.g. secrets modules, generated artifacts) are never scanned and never appear inrubrics.yaml. -
doctoris read-only. It never creates directories or writes files. The writability check usesos.accesson the nearest existing ancestor. -
No outbound telemetry. No data is sent anywhere. The
--telemetry onhook is a design placeholder for anonymous version pings only, not implemented. -
Trace redaction (Phase 3).
The bootstrap layer (not yet implemented) will redact
env-var-looking strings (
OPENAI_API_KEY=…,Bearer …) from captured traces, and honor a per-taskredact:list inrubrics.yaml.
Test suite
49 tests in tests/, run with pytest -q.
| File | Tests | What it covers |
|---|---|---|
test_detectors.py |
~15 | Shared predicates, all 4 detectors, scan_repo, synthesize, name-collision, .venv exclusion, no-duplicate from entry-points |
test_cli_init.py |
~8 | init --dry-run JSON, full scaffold write, --force preserves captures, --reset-golden discards them, dry-run conflict display |
test_golden_writer.py |
~8 | All four write_stub outcomes, has_real_captures |
test_cli_misc.py |
~9 | version, doctor JSON, doctor non-mutating, doctor exit codes, config, stub exit 2, bootstrap missing cmd, CI auto-mode |
test_cli_help.py |
~12 | --help on root and every subcommand exits 0 and contains "Usage:" |
$ .venv/bin/python -m pytest -q
.................................................
49 passed in 0.38s
What comes next (Phase 2–4)
The CLI surface for all future commands is already live as stubs. Each stub parses its flags per the design spec and exits 2 with a clear "Phase X" message. When a phase is implemented, its module replaces the stub without changing the command name or any flag names.
| Module (future) | Phase | Key responsibility |
|---|---|---|
judge/gateway.py |
2 | LiteLLM facade — single seam between ai-evals and any LLM provider |
judge/prompts/ |
2 | CoT prompt (frontier models) and binary checklist (local models) |
judge/cache.py |
2 | Content-addressed SHA-256 cache under .ai-evals/cache/judge/ |
bootstrap/wrappers.py |
3 | Monkey-patches for openai, langchain, chromadb, pinecone |
bootstrap/tracer.py |
3 | sys.settrace / OTEL span ingest alternative |
runner/engine.py |
4 | Async task loop with asyncio + litellm.acompletion, semaphore = --parallel |
runner/metrics/ |
4 | argument_accuracy, hallucination_rate, context_precision, faithfulness, latency |
insights/diff.py |
4 | Δ computation vs baseline, regression threshold check |
Generated from source — ai-evals v0.1.0 · Phase 1 complete ·
.kilo/plans/ai-evals-cli-and-system-design.md