eval-doctor 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eval_doctor-0.1.0/LICENSE +21 -0
- eval_doctor-0.1.0/PKG-INFO +131 -0
- eval_doctor-0.1.0/README.md +103 -0
- eval_doctor-0.1.0/eval_doctor/__init__.py +8 -0
- eval_doctor-0.1.0/eval_doctor/capability_extractor.py +120 -0
- eval_doctor-0.1.0/eval_doctor/cli.py +249 -0
- eval_doctor-0.1.0/eval_doctor/coverage_engine.py +202 -0
- eval_doctor-0.1.0/eval_doctor/interview.py +199 -0
- eval_doctor-0.1.0/eval_doctor/llm.py +343 -0
- eval_doctor-0.1.0/eval_doctor/report_generator.py +207 -0
- eval_doctor-0.1.0/eval_doctor/scanner.py +482 -0
- eval_doctor-0.1.0/eval_doctor/schemas.py +138 -0
- eval_doctor-0.1.0/eval_doctor/taxonomy.py +551 -0
- eval_doctor-0.1.0/eval_doctor.egg-info/PKG-INFO +131 -0
- eval_doctor-0.1.0/eval_doctor.egg-info/SOURCES.txt +26 -0
- eval_doctor-0.1.0/eval_doctor.egg-info/dependency_links.txt +1 -0
- eval_doctor-0.1.0/eval_doctor.egg-info/entry_points.txt +2 -0
- eval_doctor-0.1.0/eval_doctor.egg-info/requires.txt +17 -0
- eval_doctor-0.1.0/eval_doctor.egg-info/top_level.txt +1 -0
- eval_doctor-0.1.0/pyproject.toml +50 -0
- eval_doctor-0.1.0/setup.cfg +4 -0
- eval_doctor-0.1.0/tests/test_capability_extractor.py +78 -0
- eval_doctor-0.1.0/tests/test_cli.py +85 -0
- eval_doctor-0.1.0/tests/test_coverage_engine.py +248 -0
- eval_doctor-0.1.0/tests/test_interview.py +134 -0
- eval_doctor-0.1.0/tests/test_llm.py +314 -0
- eval_doctor-0.1.0/tests/test_packaging.py +23 -0
- eval_doctor-0.1.0/tests/test_scanner.py +266 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sneha Upadhyaula
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eval-doctor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Know what you are not testing before you ship - an eval coverage auditor for RAG and agent systems.
|
|
5
|
+
Author: Sneha Upadhyaula
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: evals,llm,rag,agents,testing,evaluation
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: typer>=0.9
|
|
16
|
+
Requires-Dist: pydantic>=2.0
|
|
17
|
+
Requires-Dist: rich>=13.0
|
|
18
|
+
Requires-Dist: pyyaml>=6.0
|
|
19
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
20
|
+
Provides-Extra: anthropic
|
|
21
|
+
Requires-Dist: anthropic>=0.40; extra == "anthropic"
|
|
22
|
+
Provides-Extra: openai
|
|
23
|
+
Requires-Dist: openai>=1.60; extra == "openai"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff>=0.15; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# Eval Doctor
|
|
30
|
+
|
|
31
|
+
> Know what you are not testing before you ship.
|
|
32
|
+
|
|
33
|
+
Eval Doctor is a CLI that helps AI builders find blind spots in their eval suite — a linter for AI evals.
|
|
34
|
+
|
|
35
|
+
Most AI projects start with architecture and implementation. Evals come later, once the system already "kind of works." But for RAG systems and agents, the hardest part is not writing test cases — it's knowing **what failure modes matter**. Eval Doctor scans your repo, detects your AI system's capabilities, maps them to expected evaluation dimensions, and tells you what your current eval suite is missing.
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
You are testing faithfulness and answer relevance.
|
|
39
|
+
|
|
40
|
+
You are not testing:
|
|
41
|
+
- empty retrieval
|
|
42
|
+
- stale documents
|
|
43
|
+
- tool timeout
|
|
44
|
+
- hallucinated tool arguments
|
|
45
|
+
- prompt injection
|
|
46
|
+
- ambiguous user requests
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install eval-doctor # static analysis, no API keys needed
|
|
53
|
+
pip install "eval-doctor[anthropic]" # + optional LLM-powered capability detection (Claude API)
|
|
54
|
+
pip install "eval-doctor[openai]" # + optional LLM-powered capability detection (OpenAI API)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Already have [Claude Code](https://claude.com/claude-code) installed? No extra or API key needed — `eval-doctor audit --llm` uses your Claude subscription through the `claude` CLI.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
cd your-ai-project
|
|
63
|
+
eval-doctor audit
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
You get a console report plus two files:
|
|
67
|
+
|
|
68
|
+
- `eval_doctor_report.md` — human-readable coverage report
|
|
69
|
+
- `eval_doctor_findings.json` — machine-readable findings for automation
|
|
70
|
+
|
|
71
|
+
Options:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
eval-doctor audit --repo path/to/repo # audit a different directory
|
|
75
|
+
eval-doctor audit --evals path/to/evals # eval files living outside the repo
|
|
76
|
+
eval-doctor audit --output-dir reports/ # where to write report files
|
|
77
|
+
eval-doctor audit --llm # force LLM enrichment (also enables the claude CLI path)
|
|
78
|
+
eval-doctor audit --no-llm # force pure static analysis
|
|
79
|
+
eval-doctor audit --force # overwrite existing report files
|
|
80
|
+
eval-doctor interview # short risk interview; audit picks up the answers
|
|
81
|
+
eval-doctor show-taxonomy # print the built-in taxonomy
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Risk interview
|
|
85
|
+
|
|
86
|
+
Severity defaults are generic; your risks are not. `eval-doctor interview` asks a few multiple-choice questions keyed to what the scanner actually found in your repo — retrieval, web grounding, tool calling, SQL, memory — plus where the system will run, and writes `eval_doctor_risk.json`. The next `eval-doctor audit` picks it up automatically (or point at one with `--risk path/to/eval_doctor_risk.json`) and elevates the severity of the dimensions you said would hurt most, re-ordering the report. Answers only ever raise severity — the interview never argues a risk away.
|
|
87
|
+
|
|
88
|
+
## How it works
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
Repo Scanner ──► Capability Extractor ──► Coverage Engine ──► Report
|
|
92
|
+
(deterministic) (static rules, (expected vs
|
|
93
|
+
optional LLM enrich) observed dimensions)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
1. **Scan** — a deterministic scanner walks your repo and detects signals: frameworks (LangChain, LlamaIndex, CrewAI, ...), vector DBs, embeddings, chunking, reranking, tool definitions, SQL, browser automation, memory, prompt templates, and existing eval/test files. Nothing leaves your machine.
|
|
97
|
+
2. **Extract** — signals are mapped to a capability profile (`rag`, `tool_calling_agent`, `sql_agent`, ...).
|
|
98
|
+
3. **Compare** — the profile selects applicable dimensions from a built-in taxonomy of 40 eval dimensions across RAG, tool calling, SQL agents, memory, and general AI risks. Your existing eval files are checked for evidence of each.
|
|
99
|
+
4. **Report** — every applicable dimension is rated `covered`, `weak`, or `missing`, with a severity and a concrete recommendation.
|
|
100
|
+
|
|
101
|
+
### Optional LLM enhancement
|
|
102
|
+
|
|
103
|
+
Static analysis sees imports and patterns, not intent. With an LLM available, Eval Doctor sends its collected evidence snippets (never your whole repo) to a model to correct and complete the capability profile — catching things like web-search-grounded generation that deserves faithfulness evals, hand-rolled RAG with no framework, and pruning false positives like an sqlite cache being flagged as a SQL agent. Two ways to enable it:
|
|
104
|
+
|
|
105
|
+
- **API key** — set `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` and install the matching extra. Used automatically when present.
|
|
106
|
+
- **Claude subscription** — have [Claude Code](https://claude.com/claude-code) installed and signed in, then pass `--llm`. Eval Doctor shells out to `claude` on your machine; no API key or extra needed. This path is opt-in only — an installed CLI is never used unless you ask.
|
|
107
|
+
|
|
108
|
+
Without either, everything still works; the report just notes it ran in static-only mode.
|
|
109
|
+
|
|
110
|
+
Additional providers are welcome contributions: implement the two-method `LLMProvider` protocol in [`eval_doctor/llm.py`](eval_doctor/llm.py) — the existing providers are small, self-contained classes.
|
|
111
|
+
|
|
112
|
+
## Example
|
|
113
|
+
|
|
114
|
+
See [`examples/sample_report.md`](examples/sample_report.md) for the report produced by auditing a small RAG + tool-calling fixture project.
|
|
115
|
+
|
|
116
|
+
## Roadmap
|
|
117
|
+
|
|
118
|
+
- **Candidate eval generation** — generate golden eval cases for your missing dimensions
|
|
119
|
+
- **Coverage diff** — `eval-doctor diff` to score an eval suite against recommended coverage
|
|
120
|
+
- **CI / PR mode** — comment on PRs that add AI capabilities without corresponding evals
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pip install -e ".[dev]"
|
|
126
|
+
pytest
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Eval Doctor
|
|
2
|
+
|
|
3
|
+
> Know what you are not testing before you ship.
|
|
4
|
+
|
|
5
|
+
Eval Doctor is a CLI that helps AI builders find blind spots in their eval suite — a linter for AI evals.
|
|
6
|
+
|
|
7
|
+
Most AI projects start with architecture and implementation. Evals come later, once the system already "kind of works." But for RAG systems and agents, the hardest part is not writing test cases — it's knowing **what failure modes matter**. Eval Doctor scans your repo, detects your AI system's capabilities, maps them to expected evaluation dimensions, and tells you what your current eval suite is missing.
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
You are testing faithfulness and answer relevance.
|
|
11
|
+
|
|
12
|
+
You are not testing:
|
|
13
|
+
- empty retrieval
|
|
14
|
+
- stale documents
|
|
15
|
+
- tool timeout
|
|
16
|
+
- hallucinated tool arguments
|
|
17
|
+
- prompt injection
|
|
18
|
+
- ambiguous user requests
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install eval-doctor # static analysis, no API keys needed
|
|
25
|
+
pip install "eval-doctor[anthropic]" # + optional LLM-powered capability detection (Claude API)
|
|
26
|
+
pip install "eval-doctor[openai]" # + optional LLM-powered capability detection (OpenAI API)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Already have [Claude Code](https://claude.com/claude-code) installed? No extra or API key needed — `eval-doctor audit --llm` uses your Claude subscription through the `claude` CLI.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
cd your-ai-project
|
|
35
|
+
eval-doctor audit
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You get a console report plus two files:
|
|
39
|
+
|
|
40
|
+
- `eval_doctor_report.md` — human-readable coverage report
|
|
41
|
+
- `eval_doctor_findings.json` — machine-readable findings for automation
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
eval-doctor audit --repo path/to/repo # audit a different directory
|
|
47
|
+
eval-doctor audit --evals path/to/evals # eval files living outside the repo
|
|
48
|
+
eval-doctor audit --output-dir reports/ # where to write report files
|
|
49
|
+
eval-doctor audit --llm # force LLM enrichment (also enables the claude CLI path)
|
|
50
|
+
eval-doctor audit --no-llm # force pure static analysis
|
|
51
|
+
eval-doctor audit --force # overwrite existing report files
|
|
52
|
+
eval-doctor interview # short risk interview; audit picks up the answers
|
|
53
|
+
eval-doctor show-taxonomy # print the built-in taxonomy
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Risk interview
|
|
57
|
+
|
|
58
|
+
Severity defaults are generic; your risks are not. `eval-doctor interview` asks a few multiple-choice questions keyed to what the scanner actually found in your repo — retrieval, web grounding, tool calling, SQL, memory — plus where the system will run, and writes `eval_doctor_risk.json`. The next `eval-doctor audit` picks it up automatically (or point at one with `--risk path/to/eval_doctor_risk.json`) and elevates the severity of the dimensions you said would hurt most, re-ordering the report. Answers only ever raise severity — the interview never argues a risk away.
|
|
59
|
+
|
|
60
|
+
## How it works
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
Repo Scanner ──► Capability Extractor ──► Coverage Engine ──► Report
|
|
64
|
+
(deterministic) (static rules, (expected vs
|
|
65
|
+
optional LLM enrich) observed dimensions)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
1. **Scan** — a deterministic scanner walks your repo and detects signals: frameworks (LangChain, LlamaIndex, CrewAI, ...), vector DBs, embeddings, chunking, reranking, tool definitions, SQL, browser automation, memory, prompt templates, and existing eval/test files. Nothing leaves your machine.
|
|
69
|
+
2. **Extract** — signals are mapped to a capability profile (`rag`, `tool_calling_agent`, `sql_agent`, ...).
|
|
70
|
+
3. **Compare** — the profile selects applicable dimensions from a built-in taxonomy of 40 eval dimensions across RAG, tool calling, SQL agents, memory, and general AI risks. Your existing eval files are checked for evidence of each.
|
|
71
|
+
4. **Report** — every applicable dimension is rated `covered`, `weak`, or `missing`, with a severity and a concrete recommendation.
|
|
72
|
+
|
|
73
|
+
### Optional LLM enhancement
|
|
74
|
+
|
|
75
|
+
Static analysis sees imports and patterns, not intent. With an LLM available, Eval Doctor sends its collected evidence snippets (never your whole repo) to a model to correct and complete the capability profile — catching things like web-search-grounded generation that deserves faithfulness evals, hand-rolled RAG with no framework, and pruning false positives like an sqlite cache being flagged as a SQL agent. Two ways to enable it:
|
|
76
|
+
|
|
77
|
+
- **API key** — set `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` and install the matching extra. Used automatically when present.
|
|
78
|
+
- **Claude subscription** — have [Claude Code](https://claude.com/claude-code) installed and signed in, then pass `--llm`. Eval Doctor shells out to `claude` on your machine; no API key or extra needed. This path is opt-in only — an installed CLI is never used unless you ask.
|
|
79
|
+
|
|
80
|
+
Without either, everything still works; the report just notes it ran in static-only mode.
|
|
81
|
+
|
|
82
|
+
Additional providers are welcome contributions: implement the two-method `LLMProvider` protocol in [`eval_doctor/llm.py`](eval_doctor/llm.py) — the existing providers are small, self-contained classes.
|
|
83
|
+
|
|
84
|
+
## Example
|
|
85
|
+
|
|
86
|
+
See [`examples/sample_report.md`](examples/sample_report.md) for the report produced by auditing a small RAG + tool-calling fixture project.
|
|
87
|
+
|
|
88
|
+
## Roadmap
|
|
89
|
+
|
|
90
|
+
- **Candidate eval generation** — generate golden eval cases for your missing dimensions
|
|
91
|
+
- **Coverage diff** — `eval-doctor diff` to score an eval suite against recommended coverage
|
|
92
|
+
- **CI / PR mode** — comment on PRs that add AI capabilities without corresponding evals
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install -e ".[dev]"
|
|
98
|
+
pytest
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Eval Doctor - an eval coverage auditor for RAG and agent systems."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("eval-doctor")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree without install
|
|
8
|
+
__version__ = "0.0.0.dev0"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Map scanner signals to a CapabilityProfile.
|
|
2
|
+
|
|
3
|
+
The static pass always runs and needs no API key. An optional LLM provider
|
|
4
|
+
(see llm.py) can enrich the static profile using the collected evidence.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .schemas import CapabilityProfile, Evidence, ScanResult
|
|
10
|
+
|
|
11
|
+
_RETRIEVAL_SIGNALS = {
|
|
12
|
+
"retrieval",
|
|
13
|
+
"embedding",
|
|
14
|
+
"chunking",
|
|
15
|
+
"query_rewriting",
|
|
16
|
+
"reranking",
|
|
17
|
+
}
|
|
18
|
+
_TOOL_SIGNALS = {"tool_calling", "http_tool"}
|
|
19
|
+
|
|
20
|
+
# Which scanner signals substantiate each capability tag (used to attach
|
|
21
|
+
# repo evidence to the taxonomy dimensions selected by that tag).
|
|
22
|
+
_TAG_SIGNALS: dict[str, set[str]] = {
|
|
23
|
+
"retrieval": _RETRIEVAL_SIGNALS,
|
|
24
|
+
"tool_calling": _TOOL_SIGNALS,
|
|
25
|
+
"sql": {"sql", "sql_query_text"},
|
|
26
|
+
"browser": {"browser"},
|
|
27
|
+
"web_grounding": {"web_search"},
|
|
28
|
+
"memory": {"memory"},
|
|
29
|
+
"embedding": {"embedding"},
|
|
30
|
+
"chunking": {"chunking"},
|
|
31
|
+
"query_rewriting": {"query_rewriting"},
|
|
32
|
+
"reranking": {"reranking"},
|
|
33
|
+
"prompting": {"prompt_template"},
|
|
34
|
+
}
|
|
35
|
+
_TAG_SIGNAL_PREFIXES: dict[str, tuple[str, ...]] = {"retrieval": ("vector_db:",)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _has_prefix(signals: list[str], prefix: str) -> bool:
|
|
39
|
+
return any(s.startswith(prefix) for s in signals)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_static(scan: ScanResult) -> CapabilityProfile:
|
|
43
|
+
"""Rule-based capability extraction from scanner signals."""
|
|
44
|
+
signals = scan.signals
|
|
45
|
+
profile = CapabilityProfile(source="static")
|
|
46
|
+
|
|
47
|
+
profile.retrieval = bool(_RETRIEVAL_SIGNALS & set(signals)) or _has_prefix(
|
|
48
|
+
signals, "vector_db:"
|
|
49
|
+
)
|
|
50
|
+
profile.browser = "browser" in signals
|
|
51
|
+
profile.memory = "memory" in signals
|
|
52
|
+
# Only real tool-wiring patterns (@tool, tools=[, input_schema, ...) make a
|
|
53
|
+
# tool_calling agent. http_tool alone is not enough: shikhu's requests.post
|
|
54
|
+
# calls its LLM provider (the model call itself, not a tool the model
|
|
55
|
+
# invokes), yet it profiled as a tool_calling_agent with 9 tool dims.
|
|
56
|
+
tool_calling = "tool_calling" in signals
|
|
57
|
+
# SQL counts as a capability only when it can be model-facing (wired as a
|
|
58
|
+
# tool). Standalone sqlite/SQL is app storage: shikhu's coverage.db got 8
|
|
59
|
+
# SQL-agent dims (4 critical) that hijacked the top recommendation.
|
|
60
|
+
profile.sql = "sql" in signals and tool_calling
|
|
61
|
+
|
|
62
|
+
if profile.retrieval:
|
|
63
|
+
profile.capabilities.append("retrieval")
|
|
64
|
+
profile.system_type.append("rag")
|
|
65
|
+
for cap in ("query_rewriting", "reranking", "chunking", "embedding"):
|
|
66
|
+
if cap in signals:
|
|
67
|
+
profile.capabilities.append(cap)
|
|
68
|
+
if tool_calling:
|
|
69
|
+
profile.capabilities.append("tool_calling")
|
|
70
|
+
profile.system_type.append("tool_calling_agent")
|
|
71
|
+
if profile.sql:
|
|
72
|
+
profile.capabilities.append("sql")
|
|
73
|
+
profile.tools.append("sql_tool")
|
|
74
|
+
if profile.browser:
|
|
75
|
+
profile.capabilities.append("browser")
|
|
76
|
+
profile.tools.append("browser_tool")
|
|
77
|
+
if "web_search" in signals:
|
|
78
|
+
profile.capabilities.append("web_grounding")
|
|
79
|
+
profile.tools.append("web_search_tool")
|
|
80
|
+
if tool_calling and "http_tool" in signals:
|
|
81
|
+
profile.tools.append("http_tool")
|
|
82
|
+
if profile.memory:
|
|
83
|
+
profile.capabilities.append("memory")
|
|
84
|
+
if "prompt_template" in signals:
|
|
85
|
+
profile.capabilities.append("prompting")
|
|
86
|
+
if not profile.system_type and (
|
|
87
|
+
_has_prefix(signals, "sdk:") or _has_prefix(signals, "framework:")
|
|
88
|
+
):
|
|
89
|
+
profile.system_type.append("llm_pipeline")
|
|
90
|
+
|
|
91
|
+
profile.eval_files_detected = list(scan.eval_files)
|
|
92
|
+
return profile
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def evidence_for_tags(evidence: list[Evidence], tags: list[str]) -> list[Evidence]:
|
|
96
|
+
"""Scanner evidence that substantiates any of the given capability tags."""
|
|
97
|
+
signals: set[str] = set()
|
|
98
|
+
prefixes: tuple[str, ...] = ()
|
|
99
|
+
for tag in tags:
|
|
100
|
+
signals |= _TAG_SIGNALS.get(tag, set())
|
|
101
|
+
prefixes += _TAG_SIGNAL_PREFIXES.get(tag, ())
|
|
102
|
+
return [
|
|
103
|
+
e
|
|
104
|
+
for e in evidence
|
|
105
|
+
if e.snippet and (e.signal in signals or e.signal.startswith(prefixes))
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def capability_tags(profile: CapabilityProfile) -> set[str]:
|
|
110
|
+
"""Tags used to select applicable taxonomy dimensions."""
|
|
111
|
+
tags = set(profile.capabilities)
|
|
112
|
+
if profile.retrieval:
|
|
113
|
+
tags.add("retrieval")
|
|
114
|
+
if profile.sql:
|
|
115
|
+
tags.add("sql")
|
|
116
|
+
if profile.browser:
|
|
117
|
+
tags.add("browser")
|
|
118
|
+
if profile.memory:
|
|
119
|
+
tags.add("memory")
|
|
120
|
+
return tags
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Eval Doctor CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .capability_extractor import capability_tags, evidence_for_tags, extract_static
|
|
13
|
+
from .coverage_engine import analyze_coverage, apply_coverage_judgments
|
|
14
|
+
from .interview import RISK_JSON, build_risk_profile, questions_for
|
|
15
|
+
from .llm import enrich_profile, get_default_provider
|
|
16
|
+
from .report_generator import print_console_report, write_reports
|
|
17
|
+
from .scanner import scan_repo
|
|
18
|
+
from .schemas import RiskProfile
|
|
19
|
+
from .taxonomy import TAXONOMY
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="eval-doctor",
|
|
23
|
+
help="Know what you are not testing before you ship - an eval coverage auditor for RAG and agent systems.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
)
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command()
|
|
30
|
+
def audit(
|
|
31
|
+
repo: Path = typer.Option(Path("."), "--repo", help="Path to the repo to audit."),
|
|
32
|
+
evals: Path | None = typer.Option(
|
|
33
|
+
None, "--evals", help="Extra path to eval files outside the repo."
|
|
34
|
+
),
|
|
35
|
+
output_dir: Path = typer.Option(
|
|
36
|
+
Path("."), "--output-dir", help="Where to write report files."
|
|
37
|
+
),
|
|
38
|
+
llm: bool | None = typer.Option(
|
|
39
|
+
None,
|
|
40
|
+
"--llm/--no-llm",
|
|
41
|
+
help="Force LLM enrichment on/off. Default: use it when an API key is available.",
|
|
42
|
+
),
|
|
43
|
+
risk: Path | None = typer.Option(
|
|
44
|
+
None,
|
|
45
|
+
"--risk",
|
|
46
|
+
help=f"Risk profile from `eval-doctor interview`. Default: {RISK_JSON} in --output-dir, if present.",
|
|
47
|
+
),
|
|
48
|
+
force: bool = typer.Option(
|
|
49
|
+
False, "--force", help="Overwrite existing report files."
|
|
50
|
+
),
|
|
51
|
+
):
|
|
52
|
+
"""Scan a repo and produce an eval coverage report."""
|
|
53
|
+
try:
|
|
54
|
+
scan = scan_repo(repo, evals_path=evals)
|
|
55
|
+
except FileNotFoundError as exc:
|
|
56
|
+
console.print(f"[red]error:[/red] {exc}")
|
|
57
|
+
raise typer.Exit(code=1) from None
|
|
58
|
+
|
|
59
|
+
risk_path = risk if risk is not None else output_dir / RISK_JSON
|
|
60
|
+
risk_profile = None
|
|
61
|
+
if risk_path.exists():
|
|
62
|
+
try:
|
|
63
|
+
risk_profile = RiskProfile.model_validate_json(
|
|
64
|
+
risk_path.read_text(encoding="utf-8")
|
|
65
|
+
)
|
|
66
|
+
except ValueError as exc:
|
|
67
|
+
console.print(f"[red]error:[/red] invalid risk profile {risk_path}: {exc}")
|
|
68
|
+
raise typer.Exit(code=1) from None
|
|
69
|
+
elif risk is not None:
|
|
70
|
+
console.print(f"[red]error:[/red] risk profile not found: {risk}")
|
|
71
|
+
raise typer.Exit(code=1)
|
|
72
|
+
|
|
73
|
+
profile = extract_static(scan)
|
|
74
|
+
|
|
75
|
+
provider = None
|
|
76
|
+
if llm is not False:
|
|
77
|
+
# The claude CLI (subscription, no keys) is opt-in via --llm only.
|
|
78
|
+
provider = get_default_provider(include_claude_cli=llm is True)
|
|
79
|
+
if llm is True and provider is None:
|
|
80
|
+
console.print(
|
|
81
|
+
"[red]error:[/red] --llm requested but no provider is available. "
|
|
82
|
+
"Set ANTHROPIC_API_KEY or OPENAI_API_KEY (and install "
|
|
83
|
+
"eval-doctor\\[anthropic] or eval-doctor\\[openai]), or install "
|
|
84
|
+
"Claude Code so the `claude` CLI can be used."
|
|
85
|
+
)
|
|
86
|
+
raise typer.Exit(code=1)
|
|
87
|
+
if provider is not None:
|
|
88
|
+
try:
|
|
89
|
+
with console.status(f"Enriching capability profile via {provider.name}..."):
|
|
90
|
+
profile = enrich_profile(provider, scan.evidence, profile)
|
|
91
|
+
except Exception as exc: # degrade, don't crash the audit
|
|
92
|
+
console.print(
|
|
93
|
+
f"[yellow]warning:[/yellow] LLM enrichment failed ({exc}); using static analysis only."
|
|
94
|
+
)
|
|
95
|
+
elif llm is None:
|
|
96
|
+
console.print(
|
|
97
|
+
"[dim]Static analysis only - set ANTHROPIC_API_KEY or OPENAI_API_KEY "
|
|
98
|
+
"(and pip install 'eval-doctor\\[anthropic]' or 'eval-doctor\\[openai]'), "
|
|
99
|
+
"or pass --llm to use an installed Claude Code (`claude` CLI), for "
|
|
100
|
+
"deeper capability detection and eval-quality judgment.[/dim]"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
report = analyze_coverage(scan, profile, risk=risk_profile)
|
|
104
|
+
|
|
105
|
+
if not report.findings and not profile.system_type:
|
|
106
|
+
console.print(
|
|
107
|
+
f"No AI system detected in {scan.repo_path} "
|
|
108
|
+
f"({scan.files_scanned} files scanned) - nothing to audit."
|
|
109
|
+
)
|
|
110
|
+
raise typer.Exit()
|
|
111
|
+
|
|
112
|
+
if provider is not None and scan.eval_files:
|
|
113
|
+
try:
|
|
114
|
+
with console.status(f"Judging eval coverage via {provider.name}..."):
|
|
115
|
+
judgments = provider.judge_coverage(
|
|
116
|
+
scan.eval_file_contents, report.findings
|
|
117
|
+
)
|
|
118
|
+
apply_coverage_judgments(report, judgments)
|
|
119
|
+
except Exception as exc: # degrade, don't crash the audit
|
|
120
|
+
console.print(
|
|
121
|
+
f"[yellow]warning:[/yellow] LLM coverage judgment failed ({exc}); "
|
|
122
|
+
"keeping keyword-based coverage."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
print_console_report(report, console)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
paths = write_reports(report, output_dir, force=force)
|
|
129
|
+
except FileExistsError as exc:
|
|
130
|
+
console.print(f"[red]error:[/red] {exc}")
|
|
131
|
+
raise typer.Exit(code=1) from None
|
|
132
|
+
console.print(f"Wrote {', '.join(str(p) for p in paths)}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command()
|
|
136
|
+
def interview(
|
|
137
|
+
repo: Path = typer.Option(
|
|
138
|
+
Path("."), "--repo", help="Path to the repo the interview is about."
|
|
139
|
+
),
|
|
140
|
+
output_dir: Path = typer.Option(
|
|
141
|
+
Path("."), "--output-dir", help=f"Where to write {RISK_JSON}."
|
|
142
|
+
),
|
|
143
|
+
force: bool = typer.Option(
|
|
144
|
+
False, "--force", help="Overwrite an existing risk profile."
|
|
145
|
+
),
|
|
146
|
+
):
|
|
147
|
+
"""Short risk interview based on what the scanner found; feeds `audit`."""
|
|
148
|
+
try:
|
|
149
|
+
scan = scan_repo(repo)
|
|
150
|
+
except FileNotFoundError as exc:
|
|
151
|
+
console.print(f"[red]error:[/red] {exc}")
|
|
152
|
+
raise typer.Exit(code=1) from None
|
|
153
|
+
|
|
154
|
+
profile = extract_static(scan)
|
|
155
|
+
tags = capability_tags(profile)
|
|
156
|
+
if not profile.system_type and not tags:
|
|
157
|
+
console.print(
|
|
158
|
+
f"No AI system detected in {scan.repo_path} "
|
|
159
|
+
f"({scan.files_scanned} files scanned) - nothing to interview."
|
|
160
|
+
)
|
|
161
|
+
raise typer.Exit()
|
|
162
|
+
|
|
163
|
+
risk_path = Path(output_dir) / RISK_JSON
|
|
164
|
+
if risk_path.exists() and not force:
|
|
165
|
+
console.print(
|
|
166
|
+
f"[red]error:[/red] Refusing to overwrite {risk_path} (use --force)."
|
|
167
|
+
)
|
|
168
|
+
raise typer.Exit(code=1)
|
|
169
|
+
|
|
170
|
+
questions = questions_for(tags)
|
|
171
|
+
console.print("\n[bold]Eval Doctor Risk Interview[/bold]")
|
|
172
|
+
console.print(f"[dim]{scan.repo_path}[/dim]")
|
|
173
|
+
console.print(
|
|
174
|
+
f"Detected system type: [bold]{' + '.join(profile.system_type)}[/bold]\n"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
answered = []
|
|
178
|
+
for number, question in enumerate(questions, 1):
|
|
179
|
+
anchors = evidence_for_tags(scan.evidence, question.applies_when)
|
|
180
|
+
location = f" [dim]({anchors[0].path})[/dim]" if anchors else ""
|
|
181
|
+
console.print(
|
|
182
|
+
f"[bold]\\[{number}/{len(questions)}][/bold] {question.context}{location}"
|
|
183
|
+
)
|
|
184
|
+
console.print(question.prompt)
|
|
185
|
+
for n, option in enumerate(question.options, 1):
|
|
186
|
+
console.print(f" {n}. {option.label}")
|
|
187
|
+
while True:
|
|
188
|
+
choice = typer.prompt("Answer", type=int)
|
|
189
|
+
if 1 <= choice <= len(question.options):
|
|
190
|
+
break
|
|
191
|
+
console.print(f"Enter a number between 1 and {len(question.options)}.")
|
|
192
|
+
answered.append((question, choice - 1))
|
|
193
|
+
console.print()
|
|
194
|
+
|
|
195
|
+
risk = build_risk_profile(scan.repo_path, answered)
|
|
196
|
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
197
|
+
risk_path.write_text(risk.model_dump_json(indent=2), encoding="utf-8")
|
|
198
|
+
|
|
199
|
+
names = {d.id: d.name for d in TAXONOMY}
|
|
200
|
+
if risk.elevated_dimensions:
|
|
201
|
+
elevated = ", ".join(names.get(d, d) for d in risk.elevated_dimensions)
|
|
202
|
+
console.print(f"Elevated severity for: [bold]{elevated}[/bold]")
|
|
203
|
+
else:
|
|
204
|
+
console.print("No dimensions elevated - default severities stand.")
|
|
205
|
+
console.print(
|
|
206
|
+
f"Wrote {risk_path}. Run [bold]eval-doctor audit --repo {repo}[/bold] "
|
|
207
|
+
"to see the re-prioritized report."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command("show-taxonomy")
|
|
212
|
+
def show_taxonomy():
|
|
213
|
+
"""Print the built-in eval coverage taxonomy."""
|
|
214
|
+
table = Table(
|
|
215
|
+
title=f"Eval Doctor taxonomy ({len(TAXONOMY)} dimensions)", show_lines=False
|
|
216
|
+
)
|
|
217
|
+
table.add_column("Category", style="bold")
|
|
218
|
+
table.add_column("Dimension")
|
|
219
|
+
table.add_column("Applies when")
|
|
220
|
+
table.add_column("Severity")
|
|
221
|
+
table.add_column("Description", max_width=60)
|
|
222
|
+
for dim in TAXONOMY:
|
|
223
|
+
table.add_row(
|
|
224
|
+
dim.category,
|
|
225
|
+
dim.name,
|
|
226
|
+
", ".join(dim.applies_when),
|
|
227
|
+
dim.severity_default,
|
|
228
|
+
dim.description,
|
|
229
|
+
)
|
|
230
|
+
console.print(table)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _print_version(value: bool):
|
|
234
|
+
if value:
|
|
235
|
+
console.print(f"eval-doctor {__version__}")
|
|
236
|
+
raise typer.Exit()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@app.callback()
|
|
240
|
+
def _main(
|
|
241
|
+
version: bool = typer.Option(
|
|
242
|
+
False,
|
|
243
|
+
"--version",
|
|
244
|
+
help="Show version and exit.",
|
|
245
|
+
callback=_print_version,
|
|
246
|
+
is_eager=True,
|
|
247
|
+
),
|
|
248
|
+
):
|
|
249
|
+
pass
|