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:

  1. Reads your code (AST, never executes it) to infer what AI tasks exist.
  2. Scaffolds an eval/ directory with a rubrics file, a golden-set stub, and a pytest entry point.
  3. Captures real I/O traces while your tests run (Phase 3).
  4. 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

PhaseNicknameStatusDelivers
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

ai_eval/
├── __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:

your-repo/
├── 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.

Phase 1
ai-evals analyze

Re-run detection and merge new findings into the existing rubrics.yaml. Default is dry-run; use --write to apply.

Phase 1
ai-evals doctor

Read-only environment check: Python version, deps, provider creds, rubrics validity, state-dir writability. Exits 1 if any check fails.

Phase 1
ai-evals config

Print the merged config with per-key source provenance (cli → env → rubrics.yaml → user-global → builtin).

Phase 1
ai-evals bootstrap -- <cmd>

Monkey-patch frameworks, run your tests, capture live I/O into golden_set.json. Requires explicit -- pytest etc.

Phase 3 stub
ai-evals run

Execute the full eval suite against the golden set through the judge gateway. Supports --fail-on-regression for CI gating.

Phase 4 stub
ai-evals diff

Show why a run regressed by feeding paired traces through the regression judge for a "why it failed" narrative.

Phase 4 stub
ai-evals judge

Sanity-check configured judge models: --list, --ping, and --prompt for one-shot testing.

Phase 2 stub
ai-evals report

Render a stored run as human / JSON / Markdown / HTML.

Phase 4 stub
ai-evals history

List, show, prune, or export past runs from .ai-evals/history.json.

Phase 4 stub
ai-evals version

Print ai-evals X.Y.Z (python A.B) and exit.

Phase 1

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.

FlagShortEffect
--cwd <dir>-CRun as if invoked from <dir>
--config <file>Override path to rubrics.yaml
--formatauto (default) / human / json / tsv
--no-colorDisable ANSI colors
--quiet-qSuppress progress spinners
--verbose-vJSON log lines to stderr (repeatable: -vv)
--yes-yAssume yes on all confirmations
--no-inputFail instead of prompting (CI-safe)
--versionPrint version and exit

CI auto-mode

When the environment variable CI=true is set, the root callback in app.py:158 automatically applies:

ℹ️

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

CodeConstantMeaning
0EXIT_OKSuccess
1EXIT_GENERALGeneral / runtime error
2EXIT_USAGEBad flags, --no-input hit a prompt, or command not yet implemented
3EXIT_REGRESSIONEval 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.

5. Built-in defaults config/defaults.py

Ollama judge, 4 parallel workers, 0.02 tolerance, cache on.

4. User-global ~/.config/ai-evals/config.yaml

Optional. Set your personal default judge model here once.

3. Project eval/rubrics.yaml

The main editable config. Written by init, updated by analyze.

2. Env vars AI_EVAL_*

Allowlisted keys: AI_EVAL_JUDGE_DEFAULT, AI_EVAL_PARALLEL, AI_EVAL_TOLERANCE, AI_EVAL_CACHE, AI_EVAL_JUDGE_REGRESSION_CHECK.

1. CLI flags (highest priority)

--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:

1. Build detector list

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.

2. Load .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.

3. Walk with 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.

4. Parse each file once

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.

5. Run matching detectors

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:

OpenAI call partition (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_kwargtype="tool_calling"
OpenAIChatDetector:  is_create AND NOT has_tool_kwargtype="chat"

Built-in detector summary

DetectorMatches whenEmits typeKey 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:

  1. Assigns default metrics per task type. tool_calling tasks get argument_accuracy + hallucination_rate. rag tasks get context_precision + faithfulness. These thresholds are editable in rubrics.yaml after init.
  2. Resolves name collisions. If two files both have a def run() that uses LLMs, synthesize gives the second one the name run_2, then run_3, etc.
  3. Classifies the project type. Counts task types and picks: rag / tool_calling / agent / rag_and_tools / chat / custom. Written into rubrics.yaml as 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

ModelFilePurpose
RubricsConfigconfig/schema.pyTop-level rubrics.yaml schema; validates on load
JudgeConfigconfig/schema.pydefault, regression_check, fallback list
TaskSpecconfig/schema.pyOne entry under tasks:
MetricSpecconfig/schema.pyname, threshold, weight, params per metric
DefaultsBlockconfig/schema.pyparallel, cache, tolerance
ResolvedConfigconfig/loader.pyMerged dict + per-key source provenance

Telemetry — logging & progress

Two separate channels, as required by the design:

ChannelModuleContentDestination
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)

Test suite

49 tests in tests/, run with pytest -q.

FileTestsWhat 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)PhaseKey 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