From c96712dbe8ca8e787ec6f7c6632be2cfff3030da Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 15:37:56 +0530 Subject: [PATCH 01/15] docs: add README, CONTRIBUTING, and CHANGELOG --- CHANGELOG.md | 16 ++++ CONTRIBUTING.md | 29 ++++++ README.md | 243 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 README.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5290307 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project will be documented here. + +Format follows [Keep a Changelog](https://keepachangelog.com/). + +--- + +## [Unreleased] + +### Added +- Initial project scaffold and folder structure +- README with full architecture documentation +- Branch strategy and contributing guidelines + +--- \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3be53c9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing + +## Branch Strategy + +- `main` β€” stable, production-ready code only +- `dev` β€” active development + +All changes go through `dev` first, then merge to `main` via PR. + +## Commit Convention + +We follow [Conventional Commits](https://www.conventionalcommits.org/): + +| Prefix | Use for | +|--------|---------| +| `feat:` | New feature | +| `fix:` | Bug fix | +| `docs:` | Documentation only | +| `chore:` | Tooling, config, setup | +| `test:` | Adding or fixing tests | +| `refactor:` | Code restructure, no behavior change | + +## Examples + +``` +feat: add search_code tool with pattern matching +fix: handle empty repo path gracefully +docs: update README with optimizer results +``` \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b8000d --- /dev/null +++ b/README.md @@ -0,0 +1,243 @@ +# πŸ” Repo Explainer Agent + +> An agentic AI system that reads any codebase, maps its architecture, and answers questions about how it works β€” built on the Claude Agent SDK. + +--- + +## Overview + +It demonstrates three pillars of production-grade agentic AI engineering: + +| Pillar | What it does | +|--------|-------------| +| πŸ€– **Agent** | Multi-step reasoning agent using Claude Agent SDK β€” reads files, searches code, maps dependencies | +| πŸ“Š **Eval Harness** | Systematic test suite with 8 test cases and 4 weighted metrics to measure agent quality | +| ⚑ **Optimizer** | Automated prompt tuning loop that uses eval scores to iteratively improve the agent | + +--- + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ CLI Entry Point β”‚ +β”‚ scripts/run_agent.py β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Agent Core β”‚ +β”‚ agent/repo_explainer.py β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚System Promptβ”‚ β”‚ Agentic Loop β”‚ β”‚ +β”‚ β”‚agent/ │────▢│ send β†’ tool_use β†’ β”‚ β”‚ +β”‚ β”‚prompts.py β”‚ β”‚ execute β†’ send result β†’ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ repeat β†’ final answer β”‚ β”‚ +β”‚ └──────────────────────────-β”˜ β”‚ +└─────────────────────────────────────────────────────-β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Tools Layer β”‚ +β”‚ agent/tools.py β”‚ +β”‚ β”‚ +β”‚ read_file β”‚ list_directory β”‚ search_code β”‚ β”‚ +β”‚ get_file_tree β”‚ detect_language_and_framework β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Claude API β”‚ +β”‚ claude-haiku-4-5-20251001 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Eval + Optimizer β”‚ +β”‚ β”‚ +β”‚ evals/harness.py ──▢ evals/metrics.py β”‚ +β”‚ β”‚ β”‚ +β”‚ β–Ό β”‚ +β”‚ optimizer/optimizer.py β”‚ +β”‚ (prompt tuning loop β€” uses eval scores to improve) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## Quick Start + +### Prerequisites +- Python 3.10+ +- An Anthropic API key **or** Claude Code (Pro/Max β€” zero metered cost) + +### Installation + +```bash +# Clone the repo +git clone https://github.com/AxaySharma/repo-explainer.git +cd repo-explainer + +# Create virtual environment +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt + +# Set up environment +cp .env.example .env +# Edit .env and add your ANTHROPIC_API_KEY +``` + +### Running with Claude Code (recommended β€” no API cost) +```bash +# Claude Code routes through your Pro/Max subscription +# Just run via Claude Code and it handles auth automatically +claude "python scripts/run_agent.py --repo . --question 'How does this project work?'" +``` + +--- + +## Usage + +### 1. Run the Agent +```bash +python scripts/run_agent.py --repo /path/to/any/repo --question "How does authentication work?" +``` + +**Example output:** +``` +πŸ” Repo Explainer Agent +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +πŸ“ Repo: /path/to/repo +❓ Question: How does authentication work? + +πŸ€– Thinking... + +βœ… Answer: +The authentication system uses JWT tokens... + +πŸ“Š Stats: 4 iterations | Tools used: get_file_tree, read_file, search_code +``` + +### 2. Run Evals +```bash +python scripts/run_evals.py +``` + +### 3. Run Optimizer +```bash +python scripts/run_optimizer.py --iterations 5 +``` + +--- + +## The Agent + +The agent runs a proper **agentic loop** β€” not a single-shot API call: + +1. Receives repo path + question +2. Always starts with `get_file_tree` + `detect_language_and_framework` +3. Reads relevant files, searches for patterns +4. Synthesizes a grounded answer from actual code +5. Stops when it has enough context (max 10 iterations) + +**Tools available:** + +| Tool | Purpose | +|------|---------| +| `read_file` | Read any file with line numbers | +| `list_directory` | Browse directory contents | +| `search_code` | Grep patterns across the codebase | +| `get_file_tree` | Full ASCII repo structure | +| `detect_language_and_framework` | Identify stack from config files | + +--- + +## Eval Harness + +8 test cases against a sample FastAPI project in `evals/fixtures/sample_project/`. + +**Metrics (weighted):** + +| Metric | Weight | What it measures | +|--------|--------|-----------------| +| Topic Coverage | 40% | Are expected concepts present in the answer? | +| Hallucination Penalty | 30% | Does the answer contain wrong information? | +| Answer Length | 15% | Is the answer appropriately detailed? | +| Groundedness | 15% | Did the agent actually read the code? | + +A test case **passes** if overall score β‰₯ 0.6. + +--- + +## Optimizer + +The optimizer runs a **prompt tuning loop**: + +``` +baseline eval β†’ score failed cases β†’ ask Claude to improve prompt +β†’ re-eval β†’ keep if better β†’ repeat N times β†’ report best prompt +``` + +Uses `claude-haiku-4-5-20251001` for cost efficiency at every stage. + +--- + +## Results + +| Run | Pass Rate | Avg Score | Topic Coverage | Groundedness | +|-----|-----------|-----------|----------------|--------------| +| Baseline | β€” | β€” | β€” | β€” | +| After Optimization | β€” | β€” | β€” | β€” | +| Ξ” Improvement | β€” | β€” | β€” | β€” | + +> Results will be filled in after running the optimizer. See `optimizer/results/` for full JSON reports. + +--- + +## Design Decisions + +- **Claude Agent SDK over raw API** β€” proper agentic loop with tool use, not a single-shot call +- **Haiku for cost efficiency** β€” fast iteration on evals and optimizer without burning credits +- **Grounded answers only** β€” agent is instructed to never claim something it hasn't read in the code +- **Weighted metrics** β€” hallucination penalty weighted heavily (30%) because wrong answers are worse than incomplete ones + +--- + +## What I'd Do With More Time + +- Add semantic search over code (embeddings) for large repos +- Support remote GitHub URLs, not just local paths +- Add a web UI for interactive Q&A +- Expand eval set to cover more edge cases (monorepos, polyglot projects) + +--- + +## Project Structure + +``` +repo-explainer/ +β”œβ”€β”€ agent/ +β”‚ β”œβ”€β”€ repo_explainer.py # Core agent + agentic loop +β”‚ β”œβ”€β”€ tools.py # File system tools +β”‚ └── prompts.py # System prompt + templates +β”œβ”€β”€ evals/ +β”‚ β”œβ”€β”€ harness.py # Eval runner +β”‚ β”œβ”€β”€ metrics.py # Scoring functions +β”‚ β”œβ”€β”€ test_cases.py # 8 test cases +β”‚ └── fixtures/ +β”‚ └── sample_project/ # Fake FastAPI project for testing +β”œβ”€β”€ optimizer/ +β”‚ β”œβ”€β”€ optimizer.py # Prompt tuning loop +β”‚ └── results/ # Before/after JSON reports +β”œβ”€β”€ scripts/ +β”‚ β”œβ”€β”€ run_agent.py # CLI: run the agent +β”‚ β”œβ”€β”€ run_evals.py # CLI: run eval suite +β”‚ └── run_optimizer.py # CLI: run optimizer +β”œβ”€β”€ .env.example +β”œβ”€β”€ requirements.txt +└── README.md +``` + +--- + +*Built with ❀️ using Claude Agent SDK* \ No newline at end of file From 391b49f77166d2999c356c723385791a076cf236 Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 15:56:30 +0530 Subject: [PATCH 02/15] docs: reframe README as standalone open source tool, add GitHub URL support mention --- README.md | 71 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 7b8000d..96e3b5f 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ # πŸ” Repo Explainer Agent -> An agentic AI system that reads any codebase, maps its architecture, and answers questions about how it works β€” built on the Claude Agent SDK. +> Point it at any codebase β€” local or GitHub URL β€” and ask it anything. It maps the architecture, reads the code, and gives you grounded answers. --- ## Overview -It demonstrates three pillars of production-grade agentic AI engineering: +Repo Explainer is an agentic AI system built on the **Claude Agent SDK**. It uses multi-step reasoning and file-system tools to deeply understand any codebase and answer natural language questions about it. + +It is built around three pillars: | Pillar | What it does | |--------|-------------| -| πŸ€– **Agent** | Multi-step reasoning agent using Claude Agent SDK β€” reads files, searches code, maps dependencies | -| πŸ“Š **Eval Harness** | Systematic test suite with 8 test cases and 4 weighted metrics to measure agent quality | +| πŸ€– **Agent** | Multi-step reasoning agent β€” reads files, searches code, maps dependencies, synthesizes answers | +| πŸ“Š **Eval Harness** | Systematic test suite with 8 test cases and 4 weighted metrics to measure answer quality | | ⚑ **Optimizer** | Automated prompt tuning loop that uses eval scores to iteratively improve the agent | --- @@ -34,7 +36,7 @@ It demonstrates three pillars of production-grade agentic AI engineering: β”‚ β”‚prompts.py β”‚ β”‚ execute β†’ send result β†’ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ repeat β†’ final answer β”‚ β”‚ β”‚ └──────────────────────────-β”˜ β”‚ -└─────────────────────────────────────────────────────-β”˜ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Tools Layer β”‚ @@ -90,7 +92,7 @@ cp .env.example .env ### Running with Claude Code (recommended β€” no API cost) ```bash # Claude Code routes through your Pro/Max subscription -# Just run via Claude Code and it handles auth automatically +# Just run via Claude Code β€” it handles auth automatically claude "python scripts/run_agent.py --repo . --question 'How does this project work?'" ``` @@ -98,12 +100,17 @@ claude "python scripts/run_agent.py --repo . --question 'How does this project w ## Usage -### 1. Run the Agent +### Ask about a local repo ```bash python scripts/run_agent.py --repo /path/to/any/repo --question "How does authentication work?" ``` -**Example output:** +### Ask about a GitHub repo (no cloning needed) +```bash +python scripts/run_agent.py --repo https://github.com/any/public-repo --question "What is the overall architecture?" +``` + +### Example output ``` πŸ” Repo Explainer Agent ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -113,17 +120,19 @@ python scripts/run_agent.py --repo /path/to/any/repo --question "How does authen πŸ€– Thinking... βœ… Answer: -The authentication system uses JWT tokens... +The authentication system uses JWT tokens issued at /auth/login. +Tokens are validated in auth.py via the decode_token() function, +which is called by the require_auth decorator applied to protected routes. πŸ“Š Stats: 4 iterations | Tools used: get_file_tree, read_file, search_code ``` -### 2. Run Evals +### Run the Eval Suite ```bash python scripts/run_evals.py ``` -### 3. Run Optimizer +### Run the Optimizer ```bash python scripts/run_optimizer.py --iterations 5 ``` @@ -134,11 +143,11 @@ python scripts/run_optimizer.py --iterations 5 The agent runs a proper **agentic loop** β€” not a single-shot API call: -1. Receives repo path + question +1. Receives repo path or GitHub URL + question 2. Always starts with `get_file_tree` + `detect_language_and_framework` 3. Reads relevant files, searches for patterns -4. Synthesizes a grounded answer from actual code -5. Stops when it has enough context (max 10 iterations) +4. Synthesizes a grounded answer from actual code it has read +5. Stops when it has sufficient context (max 10 iterations) **Tools available:** @@ -154,16 +163,16 @@ The agent runs a proper **agentic loop** β€” not a single-shot API call: ## Eval Harness -8 test cases against a sample FastAPI project in `evals/fixtures/sample_project/`. +8 test cases run against a sample FastAPI project in `evals/fixtures/sample_project/`. **Metrics (weighted):** | Metric | Weight | What it measures | |--------|--------|-----------------| | Topic Coverage | 40% | Are expected concepts present in the answer? | -| Hallucination Penalty | 30% | Does the answer contain wrong information? | +| Hallucination Penalty | 30% | Does the answer contain fabricated information? | | Answer Length | 15% | Is the answer appropriately detailed? | -| Groundedness | 15% | Did the agent actually read the code? | +| Groundedness | 15% | Did the agent actually read the code to answer? | A test case **passes** if overall score β‰₯ 0.6. @@ -171,11 +180,11 @@ A test case **passes** if overall score β‰₯ 0.6. ## Optimizer -The optimizer runs a **prompt tuning loop**: +The optimizer runs an automated **prompt tuning loop**: ``` -baseline eval β†’ score failed cases β†’ ask Claude to improve prompt -β†’ re-eval β†’ keep if better β†’ repeat N times β†’ report best prompt +baseline eval β†’ identify failed cases β†’ ask Claude to improve system prompt +β†’ re-run evals β†’ keep if better β†’ revert if worse β†’ repeat N times ``` Uses `claude-haiku-4-5-20251001` for cost efficiency at every stage. @@ -190,25 +199,17 @@ Uses `claude-haiku-4-5-20251001` for cost efficiency at every stage. | After Optimization | β€” | β€” | β€” | β€” | | Ξ” Improvement | β€” | β€” | β€” | β€” | -> Results will be filled in after running the optimizer. See `optimizer/results/` for full JSON reports. +> Results populated after running the optimizer. See `optimizer/results/` for full JSON reports. --- ## Design Decisions -- **Claude Agent SDK over raw API** β€” proper agentic loop with tool use, not a single-shot call +- **Claude Agent SDK over raw API** β€” proper agentic loop with tool use, not a single-shot prompt - **Haiku for cost efficiency** β€” fast iteration on evals and optimizer without burning credits -- **Grounded answers only** β€” agent is instructed to never claim something it hasn't read in the code -- **Weighted metrics** β€” hallucination penalty weighted heavily (30%) because wrong answers are worse than incomplete ones - ---- - -## What I'd Do With More Time - -- Add semantic search over code (embeddings) for large repos -- Support remote GitHub URLs, not just local paths -- Add a web UI for interactive Q&A -- Expand eval set to cover more edge cases (monorepos, polyglot projects) +- **Grounded answers only** β€” agent is instructed never to claim something it has not read in the actual code +- **GitHub URL support** β€” clones to a temp directory transparently so remote repos work out of the box +- **Weighted metrics** β€” hallucination penalty weighted heavily (30%) because a wrong answer is worse than an incomplete one --- @@ -225,7 +226,7 @@ repo-explainer/ β”‚ β”œβ”€β”€ metrics.py # Scoring functions β”‚ β”œβ”€β”€ test_cases.py # 8 test cases β”‚ └── fixtures/ -β”‚ └── sample_project/ # Fake FastAPI project for testing +β”‚ └── sample_project/ # Sample FastAPI project for testing β”œβ”€β”€ optimizer/ β”‚ β”œβ”€β”€ optimizer.py # Prompt tuning loop β”‚ └── results/ # Before/after JSON reports @@ -240,4 +241,4 @@ repo-explainer/ --- -*Built with ❀️ using Claude Agent SDK* \ No newline at end of file +*Built with the Claude Agent SDK Β· MIT License* \ No newline at end of file From 4bc141d91330ee6e2fb1160cf8a378295a5caa91 Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 16:07:11 +0530 Subject: [PATCH 03/15] feat: add filesystem tools for agent (read, list, search, tree, detect) --- agent/tools.py | 386 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 agent/tools.py diff --git a/agent/tools.py b/agent/tools.py new file mode 100644 index 0000000..3ef4494 --- /dev/null +++ b/agent/tools.py @@ -0,0 +1,386 @@ +import os +import pathlib +import json +import re + +def _is_binary_file(path: pathlib.Path) -> bool: + """Helper to detect if a file is binary or unreadable.""" + try: + with open(path, 'rb') as f: + chunk = f.read(8192) + if b'\x00' in chunk: + return True + try: + chunk.decode('utf-8') + except UnicodeDecodeError as e: + # If decode failure is not close to the end, it is binary + if e.start < len(chunk) - 4: + return True + return False + except Exception: + return True + +def read_file(path: str) -> str: + """Read a file from disk and return its contents with line numbers.""" + try: + p = pathlib.Path(path) + if not p.exists() or not p.is_file(): + return f"ERROR: File not found: {path}" + + if _is_binary_file(p): + return f"ERROR: Cannot read binary file: {path}" + + with open(p, 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + + total_lines = len(lines) + show_lines = lines[:500] + + formatted_lines = [] + width = max(3, len(str(total_lines))) + for i, line in enumerate(show_lines, 1): + formatted_lines.append(f"{i:>{width}} | {line.rstrip()}") + + result = "\n".join(formatted_lines) + if total_lines > 500: + result += f"\n... [truncated: file has {total_lines} total lines. Use search_code to find specific sections]" + return result + except Exception: + return f"ERROR: Cannot read binary file: {path}" + +def list_directory(path: str) -> str: + """List all files and subdirectories at the given path up to 2 levels deep.""" + try: + root = pathlib.Path(path) + if not root.is_dir(): + return f"ERROR: Directory not found: {path}" + + ignored_names = {'__pycache__', 'node_modules', '.git', 'venv', '.venv', 'dist', 'build'} + + def traverse(current_dir: pathlib.Path, depth: int) -> list[str]: + if depth > 2: + return [] + + try: + entries = list(current_dir.iterdir()) + except Exception: + return [] + + filtered_entries = [] + for entry in entries: + if entry.name.startswith('.'): + continue + if entry.name in ignored_names: + continue + filtered_entries.append(entry) + + dirs = sorted([e for e in filtered_entries if e.is_dir()], key=lambda e: e.name.lower()) + files = sorted([e for e in filtered_entries if e.is_file()], key=lambda e: e.name.lower()) + + lines = [] + + for d in dirs: + rel_path = f"/{d.relative_to(root).as_posix()}" + lines.append(f"DIR {rel_path}") + if depth < 2: + lines.extend(traverse(d, depth + 1)) + + for f in files: + rel_path = f"/{f.relative_to(root).as_posix()}" + try: + size_bytes = f.stat().st_size + except Exception: + size_bytes = 0 + size_kb = size_bytes / 1024.0 + lines.append(f"FILE {size_kb:.1f} KB {rel_path}") + + return lines + + result_lines = traverse(root, 1) + return "\n".join(result_lines) + except Exception: + return f"ERROR: Directory not found: {path}" + +def search_code(repo_path: str, pattern: str) -> str: + """Search for a string pattern across all files in the repo recursively.""" + try: + root = pathlib.Path(repo_path) + if not root.is_dir(): + return f"ERROR: Repository path not found: {repo_path}" + + ignored_names = {'__pycache__', 'node_modules', '.git', 'venv', '.venv', 'dist', 'build'} + matches = [] + pattern_lower = pattern.lower() + + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in ignored_names] + + for filename in filenames: + file_path = pathlib.Path(dirpath) / filename + + try: + with open(file_path, 'rb') as f: + content_bytes = f.read() + content_bytes.decode('utf-8') + except UnicodeDecodeError: + continue + except Exception: + continue + + try: + with open(file_path, 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + except Exception: + continue + + rel_path = file_path.relative_to(root).as_posix() + for line_num, line in enumerate(lines, 1): + if pattern_lower in line.lower(): + matches.append(f"{rel_path}:{line_num}: {line.rstrip()}") + if len(matches) >= 50: + break + if len(matches) >= 50: + break + if len(matches) >= 50: + break + + if not matches: + return f"No matches found for pattern: '{pattern}'" + + result = "\n".join(matches[:50]) + if len(matches) >= 50: + result += "\n[Search truncated: 50 matches shown]" + return result + except Exception: + return f"ERROR: Repository path not found: {repo_path}" + +def get_file_tree(repo_path: str) -> str: + """Return a clean ASCII tree of the entire repo structure.""" + try: + root = pathlib.Path(repo_path).resolve() + if not root.is_dir(): + return f"ERROR: Repository path not found: {repo_path}" + + ignored_names = {'__pycache__', 'node_modules', '.git', 'venv', '.venv', 'dist', 'build'} + root_name = root.name + "/" + + def build_tree(current_dir: pathlib.Path, prefix: str = "") -> list[str]: + try: + entries = list(current_dir.iterdir()) + except Exception: + return [] + + filtered = [] + for entry in entries: + if entry.name.startswith('.'): + continue + if entry.name in ignored_names: + continue + filtered.append(entry) + + dirs = sorted([e for e in filtered if e.is_dir()], key=lambda e: e.name.lower()) + files = sorted([e for e in filtered if e.is_file()], key=lambda e: e.name.lower()) + + sorted_entries = dirs + files + entry_count = len(sorted_entries) + + lines = [] + for i, entry in enumerate(sorted_entries): + is_last = (i == entry_count - 1) + connector = "└── " if is_last else "β”œβ”€β”€ " + + name_suffix = "/" if entry.is_dir() else "" + lines.append(f"{prefix}{connector}{entry.name}{name_suffix}") + + if entry.is_dir(): + next_prefix = prefix + (" " if is_last else "β”‚ ") + lines.extend(build_tree(entry, next_prefix)) + + return lines + + tree_lines = [root_name] + build_tree(root) + return "\n".join(tree_lines) + except Exception: + return f"ERROR: Repository path not found: {repo_path}" + +def detect_language_and_framework(repo_path: str) -> str: + """Detect the primary programming language and framework of the repo.""" + try: + root = pathlib.Path(repo_path).resolve() + if not root.is_dir(): + return json.dumps({"error": "Repository path not found"}) + + try: + entries = list(root.iterdir()) + except Exception: + return json.dumps({"error": "Repository path not found"}) + + config_files_in_root = [] + for entry in entries: + if entry.is_file(): + config_files_in_root.append(entry.name) + + config_mappings = { + "package.json": "JavaScript/TypeScript", + "requirements.txt": "Python", + "Pipfile": "Python", + "pyproject.toml": "Python", + "go.mod": "Go", + "Cargo.toml": "Rust", + "pom.xml": "Java (Maven)", + "build.gradle": "Java/Kotlin (Gradle)", + "composer.json": "PHP" + } + + config_files_found = [] + languages_detected = [] + + for filename in config_files_in_root: + if filename in config_mappings: + config_files_found.append(filename) + elif filename.endswith('.csproj'): + config_files_found.append(filename) + + config_files_found.sort() + + for filename in config_files_found: + if filename in config_mappings: + lang = config_mappings[filename] + elif filename.endswith('.csproj'): + lang = "C#" + else: + continue + if lang not in languages_detected: + languages_detected.append(lang) + + primary_lang = languages_detected[0] if languages_detected else "Unknown" + framework = None + notes_list = [] + + if "requirements.txt" in config_files_found: + try: + req_path = root / "requirements.txt" + with open(req_path, 'r', encoding='utf-8', errors='replace') as f: + req_content = f.read().lower() + if "fastapi" in req_content: + framework = "FastAPI" + elif "django" in req_content: + framework = "Django" + elif "flask" in req_content: + framework = "Flask" + except Exception as e: + notes_list.append(f"Could not read requirements.txt: {str(e)}") + + if not framework and "package.json" in config_files_found: + try: + pkg_path = root / "package.json" + with open(pkg_path, 'r', encoding='utf-8', errors='replace') as f: + pkg_content = f.read().lower() + if "next" in pkg_content: + framework = "Next.js" + elif "react" in pkg_content: + framework = "React" + elif "express" in pkg_content: + framework = "Express" + elif "vue" in pkg_content: + framework = "Vue" + except Exception as e: + notes_list.append(f"Could not read package.json: {str(e)}") + + if primary_lang != "Unknown": + notes_str = f"Detected {primary_lang} as the primary language." + if len(languages_detected) > 1: + other_langs = [l for l in languages_detected if l != primary_lang] + notes_str += f" Also found config files for: {', '.join(other_langs)}." + else: + notes_str = "No config files or recognized languages detected." + + if notes_list: + notes_str += " " + " ".join(notes_list) + + result_dict = { + "language": primary_lang, + "framework": framework, + "config_files_found": config_files_found, + "notes": notes_str + } + return json.dumps(result_dict, indent=2) + except Exception: + return json.dumps({"error": "Repository path not found"}) + +TOOL_DEFINITIONS = [ + { + "name": "read_file", + "description": "Read a file from disk and return its contents with line numbers. Truncates files larger than 500 lines.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The absolute or relative path of the file to read." + } + }, + "required": ["path"] + } + }, + { + "name": "list_directory", + "description": "List all files and subdirectories at the given path up to 2 levels deep, showing types and file sizes.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The absolute or relative directory path to list." + } + }, + "required": ["path"] + } + }, + { + "name": "search_code", + "description": "Search for a string pattern across all files in the repository recursively. Matches case-insensitively, limits to 50 results.", + "input_schema": { + "type": "object", + "properties": { + "repo_path": { + "type": "string", + "description": "The path to the repository directory to search." + }, + "pattern": { + "type": "string", + "description": "The case-insensitive text pattern to search for." + } + }, + "required": ["repo_path", "pattern"] + } + }, + { + "name": "get_file_tree", + "description": "Return a clean ASCII tree representation of the repository directory structure.", + "input_schema": { + "type": "object", + "properties": { + "repo_path": { + "type": "string", + "description": "The path to the repository directory." + } + }, + "required": ["repo_path"] + } + }, + { + "name": "detect_language_and_framework", + "description": "Detect the primary programming language and framework of the repository based on root configuration files.", + "input_schema": { + "type": "object", + "properties": { + "repo_path": { + "type": "string", + "description": "The path to the repository directory." + } + }, + "required": ["repo_path"] + } + } +] From de069d67bda96a242290f735b8ddb7507320a798 Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 16:11:01 +0530 Subject: [PATCH 04/15] feat: add system prompt, optimizer meta-prompt, and prompt builder functions --- agent/prompts.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 agent/prompts.py diff --git a/agent/prompts.py b/agent/prompts.py new file mode 100644 index 0000000..d321c81 --- /dev/null +++ b/agent/prompts.py @@ -0,0 +1,85 @@ +SYSTEM_PROMPT = """You are Repo Explainer, an expert AI assistant that helps developers deeply understand codebases through systematic exploration and analysis. Your goal is to guide developers through the structure, dependencies, frameworks, logic flow, and specific files within the repository. + +### MANDATORY INITIAL STEPS +When a user asks a question about the repository, you MUST ALWAYS perform the following two actions in this exact order before attempting to formulate any answer or making any conclusions: +1. Call the `get_file_tree` tool to get the full hierarchical ASCII structure of the repository. This gives you the map of the codebase. +2. Call the `detect_language_and_framework` tool immediately afterward to identify the repository's core languages, configuration files, and frameworks. +Under no circumstances should you bypass these steps. Never attempt to answer a question or formulate hypotheses without executing these two tools first. Never answer based on assumptions, generic knowledge, or external conventions. You must only answer using facts from the files and code you have actually read during this conversation session. + +### EXPLORATION STRATEGY +Once the initial mandatory tools are executed, proceed with a systematic exploration of the repository: +1. Formulate a clear hypothesis about which files, directories, or modules are most relevant to the user's question. +2. Call `read_file` to read the contents of the most promising files line by line. +3. Call `search_code` to search the codebase recursively for specific patterns, keywords, function names, classes, decorators, imports, or variable names relevant to the question. +4. Call `list_directory` to inspect and explore any unfamiliar subdirectories or package modules to gain context on what files exist there. +5. If your initial hypothesis was wrong, formulate a new one, find the relevant files, and read them. Keep exploring and analyzing the codebase until you have collected sufficient concrete evidence to provide a fully grounded, complete, and robust answer. + +### ANSWER QUALITY RULES +1. Grounding: Every claim, explanation, or architectural description you write must be grounded in specific file paths and line numbers that you have read. For example, cite: "In auth.py line 42, the decode_token function...". +2. Formatting: Structure your answers using clear sections with markdown headers, lists, bold text, and code blocks. +3. Component Map: If you are explaining the architecture or how multiple modules interact, you must always produce an ASCII component map or dependency diagram showing how the different parts and layers connect. +4. Logic Explanation: When describing a specific function, method, or class, always display its signature (including arguments and return types) and explain its inner logic step by step. +5. Missing Information: If you search the codebase and cannot find the answer, or if the code does not implement what was asked, say exactly that. Never invent file names, function names, modules, or code behaviors that do not exist. +6. Citation: Explicitly cite your sources by referencing code lines and file names. + +### ANSWER FORMAT +Your response should be structured as follows: +- **Summary (TL;DR)**: A concise one-paragraph summary overview of the answer. +- **Detailed Explanation**: Multiple sections with clear markdown headers detailing the components, architectures, design patterns, and line-by-step logic. +- **Key Files**: A final section containing a list of the most relevant files you read, with a brief explanation of the role each file plays in relation to the question. +Use clean markdown blocks, code blocks with proper syntax highlighting, and bullet points. + +### BOUNDARIES +- Answer questions solely about the provided repository. +- If the user asks something completely unrelated to the repository's codebase (e.g. general trivia, unrelated code, personal questions), politely decline to answer and redirect them back to questions about the repository. +- Do not make up or guess file names, code snippets, library functions, or logic flows. If you have not read it in the code, do not claim it exists.""" + +OPTIMIZER_META_PROMPT = """You are an expert prompt engineer. Your task is to improve a system prompt for an agent called "Repo Explainer" that answers questions about codebases. + +Below is the current system prompt: +--- +{current_prompt} +--- + +The agent was run against a set of evaluation test cases and failed some of them. +Here is the average score across the evals: {overall_score} + +Here is a formatted list of the failed test cases: +{failed_cases} + +Your job is to: +1. Analyze why the current system prompt likely caused these failures (e.g., did the agent fail to produce a component map? Did it fail to cite line numbers? Did it guess instead of reading the files?). +2. Output an improved version of the system prompt that specifically addresses these weaknesses. +3. Keep everything that worked well in the original prompt. + +CRITICAL INSTRUCTIONS: +- You must return ONLY the raw text of the improved system prompt. +- Do NOT include any explanations, introduction, preamble, postscript, or surrounding comments. +- Do NOT wrap the output in markdown code blocks (such as ``` or ```markdown). +- Return only the system prompt text as the direct response.""" + +def build_user_prompt(repo_path: str, question: str) -> str: + """Build the user-facing message sent to the agent.""" + return f"""Repository Path: {repo_path} +Question: {question} + +Remember: +1. You must start your exploration by calling get_file_tree to understand the layout of the repository. +2. Next, call detect_language_and_framework to understand the tech stack. +3. Explore the repository and read the relevant files before answering. Do not answer until you have read the code that contains the logic.""" + +def format_failed_cases(failed_cases: list[dict]) -> str: + """Format a list of failed evaluation cases into a string for the optimizer prompt.""" + formatted_list = [] + for case in failed_cases: + case_id = case.get("id", "unknown") + question = case.get("question", "") + score = case.get("overall_score", 0.0) + missing_topics = ", ".join(case.get("missing_topics", [])) + + formatted_list.append( + f'Test: {case_id} (score: {score:.2f})\n' + f'Question: "{question}"\n' + f'Missing topics: {missing_topics}' + ) + return "\n\n".join(formatted_list) From 52732b3c884af515862c1cdeda69ae3f72d7d9d9 Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 16:25:25 +0530 Subject: [PATCH 05/15] feat: implement core agentic loop with tool use, GitHub URL support, and result dataclass --- agent/repo_explainer.py | 197 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 agent/repo_explainer.py diff --git a/agent/repo_explainer.py b/agent/repo_explainer.py new file mode 100644 index 0000000..f2a1a99 --- /dev/null +++ b/agent/repo_explainer.py @@ -0,0 +1,197 @@ +import os +import json +import tempfile +import shutil +import subprocess +import dataclasses +from typing import List, Dict, Tuple, Optional, Any +from dotenv import load_dotenv +import anthropic + +from agent.tools import ( + read_file, + list_directory, + search_code, + get_file_tree, + detect_language_and_framework, + TOOL_DEFINITIONS +) +from agent.prompts import SYSTEM_PROMPT, build_user_prompt + +@dataclasses.dataclass +class AgentResult: + """The result of the agent exploration loop.""" + answer: str + tools_used: List[str] + iterations: int + success: bool + error: Optional[str] = None + +def resolve_repo_path(repo_input: str) -> Tuple[str, bool]: + """Resolve local path or clone GitHub repository to a temp directory.""" + if repo_input.startswith("https://github.com") or repo_input.startswith("git@github.com"): + temp_dir = tempfile.mkdtemp() + try: + subprocess.run( + ["git", "clone", "--depth", "1", repo_input, temp_dir], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + return temp_dir, True + except subprocess.CalledProcessError as e: + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + raise ValueError(f"Failed to clone GitHub repository: {repo_input}. Error: {e}") + except Exception as e: + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + raise ValueError(f"An unexpected error occurred while cloning repository: {repo_input}. Error: {e}") + else: + resolved_path = os.path.abspath(repo_input) + if not os.path.exists(resolved_path): + raise ValueError(f"Local path does not exist: {repo_input}") + return resolved_path, False + +def execute_tool(tool_name: str, tool_input: Dict[str, Any], repo_path: str) -> str: + """Routes a tool call from Claude to the correct Python function with sandbox enforcement.""" + try: + if tool_name == "read_file": + path_val = tool_input.get("path", "") + if not path_val.startswith(repo_path): + # Sandbox enforcement: strip leading slashes and join to repo path + rel_path = path_val.lstrip('/') + path_val = os.path.join(repo_path, rel_path) + return read_file(path_val) + + elif tool_name == "list_directory": + path_val = tool_input.get("path", "") + if not path_val.startswith(repo_path): + rel_path = path_val.lstrip('/') + path_val = os.path.join(repo_path, rel_path) + return list_directory(path_val) + + elif tool_name == "search_code": + pattern = tool_input.get("pattern", "") + return search_code(repo_path, pattern) + + elif tool_name == "get_file_tree": + return get_file_tree(repo_path) + + elif tool_name == "detect_language_and_framework": + return detect_language_and_framework(repo_path) + + else: + return f"ERROR: Unknown tool: {tool_name}" + + except Exception as e: + return f"ERROR: {e}" + +def run_agent( + repo_input: str, + question: str, + max_iterations: int = 10, + system_prompt: Optional[str] = None +) -> AgentResult: + """Executes the main agentic loop to explore a codebase and answer a question.""" + load_dotenv() + repo_path: Optional[str] = None + should_cleanup: bool = False + tools_used: List[str] = [] + iterations: int = 0 + + try: + # Phase 1: Setup and path resolution + repo_path, should_cleanup = resolve_repo_path(repo_input) + client = anthropic.Anthropic() + + # Determine prompt and construct the first user message + sys_prompt = system_prompt if system_prompt is not None else SYSTEM_PROMPT + user_message = build_user_prompt(repo_path, question) + messages = [{"role": "user", "content": user_message}] + + # Phase 2: Agentic loop + while iterations < max_iterations: + iterations += 1 + + # Make API request to Claude + response = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=4096, + system=sys_prompt, + tools=TOOL_DEFINITIONS, + messages=messages + ) + + # Record Claude's response in history + messages.append({"role": "assistant", "content": response.content}) + + # Stop Condition: Claude finished turn and returned text answer + if response.stop_reason == "end_turn": + answer_text = "" + for block in response.content: + if getattr(block, 'type', None) == "text": + answer_text += block.text + return AgentResult( + answer=answer_text, + tools_used=tools_used, + iterations=iterations, + success=True, + error=None + ) + + # Tool use request: Execute requested tools and append outcomes + elif response.stop_reason == "tool_use": + tool_results = [] + for block in response.content: + if getattr(block, 'type', None) == "tool_use": + tool_name = block.name + tool_input = block.input + tool_use_id = block.id + + if tool_name not in tools_used: + tools_used.append(tool_name) + + result_str = execute_tool(tool_name, tool_input, repo_path) + + tool_results.append({ + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": result_str + }) + + messages.append({ + "role": "user", + "content": tool_results + }) + continue + + else: + # Handle unexpected stop reasons + break + + # If loop exited without returning an answer + return AgentResult( + answer="Max iterations reached without a final answer.", + tools_used=tools_used, + iterations=iterations, + success=False, + error="max_iterations_exceeded" + ) + + except Exception as e: + return AgentResult( + answer="", + tools_used=tools_used, + iterations=iterations, + success=False, + error=str(e) + ) + + finally: + # Guarantee cleanup of temporary directory in all scenarios + if should_cleanup and repo_path and os.path.exists(repo_path): + try: + shutil.rmtree(repo_path) + except Exception: + pass From 4a0354fe88f547d152b097a1767c1adf893db8ae Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 16:31:34 +0530 Subject: [PATCH 06/15] feat: add CLI entry point with rich output, spinner, and verbose mode --- scripts/run_agent.py | 78 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 scripts/run_agent.py diff --git a/scripts/run_agent.py b/scripts/run_agent.py new file mode 100644 index 0000000..de24d52 --- /dev/null +++ b/scripts/run_agent.py @@ -0,0 +1,78 @@ +import sys +import os + +# Insert parent directory into sys.path to enable imports of agent packages +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.markdown import Markdown + +from agent.repo_explainer import run_agent, AgentResult + +# Initialize rich Console +console = Console() + +def main( + repo: str = typer.Option(..., "--repo", help="Local path or GitHub URL"), + question: str = typer.Option(..., "--question", help="Question about the codebase"), + max_iterations: int = typer.Option(10, "--max-iterations", help="Max agent iterations"), + verbose: bool = typer.Option(False, "--verbose", help="Show tool call details"), +) -> None: + """Run the Repo Explainer Agent to explore a repository and answer a question.""" + try: + # STEP 1 β€” Header + header_text = f"[bold cyan]πŸ” Repo Explainer Agent[/bold cyan]\n\n[bold]Repo:[/bold] {repo}\n[bold]Question:[/bold] {question}" + console.print(Panel(header_text, border_style="cyan")) + + # STEP 2 β€” Thinking spinner wrapping core run_agent invocation + with console.status("[bold green]Exploring codebase..."): + result = run_agent( + repo_input=repo, + question=question, + max_iterations=max_iterations + ) + + # STEP 3 β€” Handle and display result + if result.success: + markdown_answer = Markdown(result.answer) + console.print(Panel(markdown_answer, title="[bold green]βœ… Answer[/bold green]", border_style="green")) + else: + console.print(Panel(f"[bold red]Error:[/bold red] {result.error}", title="[bold red]❌ Failure[/bold red]", border_style="red")) + + # STEP 4 β€” Stats table (always shown if result exists) + table = Table(title="πŸ“Š Run Statistics", show_header=True, header_style="bold magenta") + table.add_column("Metric", style="dim", width=20) + table.add_column("Value", width=20) + + table.add_row("Iterations", f"{result.iterations} / {max_iterations}") + table.add_row("Tools Used", str(len(result.tools_used))) + + success_val = "βœ… Yes" if result.success else "❌ No" + table.add_row("Success", success_val) + + console.print(table) + + # STEP 5 β€” Verbose tool details + if verbose and result.tools_used: + tools_list = "\n".join([f"β€’ {t}" for t in result.tools_used]) + console.print(Panel(tools_list, title="πŸ”§ Tools Called", border_style="yellow")) + + # Exit if run was not successful + if not result.success: + raise typer.Exit(code=1) + + except ValueError as e: + console.print(Panel(f"[bold red]ValueError:[/bold red] {e}", title="❌ Error", border_style="red")) + raise typer.Exit(code=1) + except typer.Exit: + # Propagate typer exits directly without wrapping + raise + except Exception as e: + console.print(Panel(f"[bold red]Unexpected error:[/bold red] {e}", title="❌ Error", border_style="red")) + raise typer.Exit(code=1) + +if __name__ == "__main__": + typer.run(main) From 196b5a0c4ffcbd4cf7f44af0390ee19e402aeaf8 Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 16:42:49 +0530 Subject: [PATCH 07/15] test: add sample FastAPI project fixture for eval harness --- evals/fixtures/sample_project/README.md | 41 +++++ evals/fixtures/sample_project/auth.py | 56 +++++++ evals/fixtures/sample_project/database.py | 142 ++++++++++++++++++ evals/fixtures/sample_project/main.py | 69 +++++++++ evals/fixtures/sample_project/models.py | 43 ++++++ .../fixtures/sample_project/requirements.txt | 5 + 6 files changed, 356 insertions(+) create mode 100644 evals/fixtures/sample_project/README.md create mode 100644 evals/fixtures/sample_project/auth.py create mode 100644 evals/fixtures/sample_project/database.py create mode 100644 evals/fixtures/sample_project/main.py create mode 100644 evals/fixtures/sample_project/models.py create mode 100644 evals/fixtures/sample_project/requirements.txt diff --git a/evals/fixtures/sample_project/README.md b/evals/fixtures/sample_project/README.md new file mode 100644 index 0000000..ceafe03 --- /dev/null +++ b/evals/fixtures/sample_project/README.md @@ -0,0 +1,41 @@ +# User Management API + +A simple REST API for managing users, built with FastAPI and SQLite. + +## Stack +- **Framework**: FastAPI +- **Database**: SQLite (via sqlite3) +- **Auth**: JWT (PyJWT) +- **Runtime**: Python 3.10+ + +## Running Locally + +```bash +pip install -r requirements.txt +uvicorn main:app --reload +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| SECRET_KEY | dev-secret-key-change-in-production | JWT signing secret | + +## API Endpoints + +| Method | Path | Auth Required | Description | +|--------|------|---------------|-------------| +| GET | /health | No | Health check | +| POST | /auth/login | No | Login, returns JWT | +| GET | /users | Yes | List all users | +| GET | /users/{id} | Yes | Get user by ID | +| POST | /users | Yes | Create new user | + +## Project Structure +```text +β”œβ”€β”€ main.py # FastAPI app and route definitions +β”œβ”€β”€ models.py # Pydantic data models +β”œβ”€β”€ database.py # SQLite database layer +β”œβ”€β”€ auth.py # JWT authentication helpers +└── requirements.txt +``` diff --git a/evals/fixtures/sample_project/auth.py b/evals/fixtures/sample_project/auth.py new file mode 100644 index 0000000..65ce51e --- /dev/null +++ b/evals/fixtures/sample_project/auth.py @@ -0,0 +1,56 @@ +"""JWT-based authentication helpers for the User Management API. + +Provides helpers to generate token payloads, verify signature expirations, +and validate Bearer header dependencies. +""" + +import os +import jwt +import datetime +from fastapi import Header, HTTPException +from models import TokenResponse + +SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production") +ALGORITHM = "HS256" +TOKEN_EXPIRY_HOURS = 24 + +def create_token(user_id: int) -> TokenResponse: + """Generate a JWT access token for a given user ID with expiry metadata.""" + now = datetime.datetime.utcnow() + payload = { + "sub": str(user_id), + "iat": now, + "exp": now + datetime.timedelta(hours=TOKEN_EXPIRY_HOURS) + } + token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + return TokenResponse( + access_token=token, + expires_in=TOKEN_EXPIRY_HOURS * 3600 + ) + +def decode_token(token: str) -> dict: + """Decode and verify JWT signature and claims, raising ValueError if invalid or expired.""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + raise ValueError("Token expired") + except jwt.DecodeError: + raise ValueError("Invalid token") + +def require_auth(authorization: str = Header(None)) -> dict: + """FastAPI dependency to enforce Bearer token authentication on incoming requests.""" + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException( + status_code=401, + detail="Missing or invalid authorization header" + ) + token = authorization.split(" ")[1] + try: + payload = decode_token(token) + return payload + except ValueError as e: + raise HTTPException( + status_code=401, + detail=str(e) + ) diff --git a/evals/fixtures/sample_project/database.py b/evals/fixtures/sample_project/database.py new file mode 100644 index 0000000..dfb110e --- /dev/null +++ b/evals/fixtures/sample_project/database.py @@ -0,0 +1,142 @@ +"""SQLite database layer for the User Management API. + +Provides Database class connecting to local SQLite database and helper +functions to perform CRUD actions on the users table. +""" + +import sqlite3 +import os +import datetime +from models import User, UserCreate + +DB_PATH = "users.db" + +class Database: + """Handles connection and CRUD operations on the SQLite database.""" + + def __init__(self) -> None: + """Initialize connection object and database file path.""" + self.conn: Optional[sqlite3.Connection] = None + self.db_path: str = DB_PATH + + def connect(self) -> None: + """Establish connection to SQLite and initialize database tables.""" + self.conn = sqlite3.connect(self.db_path) + self._create_tables() + + def disconnect(self) -> None: + """Close connection to SQLite if active.""" + if self.conn: + self.conn.close() + self.conn = None + + def _create_tables(self) -> None: + """Create users table if not exists.""" + if not self.conn: + raise RuntimeError("Database connection not established. Call connect() first.") + cursor = self.conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + created_at TEXT NOT NULL, + is_active INTEGER DEFAULT 1 + ) + """) + self.conn.commit() + + def get_all_users(self) -> list[User]: + """Retrieve all users from the database.""" + if not self.conn: + raise RuntimeError("Database connection not established.") + cursor = self.conn.cursor() + cursor.execute("SELECT id, name, email, created_at, is_active, password FROM users") + rows = cursor.fetchall() + + users_list = [] + for row in rows: + # Map SQLite columns to User Pydantic model + user = User( + id=row[0], + name=row[1], + email=row[2], + created_at=row[3], + is_active=bool(row[4]) + ) + # Attach password dynamically to keep model consistent + user.password = row[5] + users_list.append(user) + return users_list + + def get_user_by_id(self, user_id: int) -> Optional[User]: + """Fetch a single user from database by ID.""" + if not self.conn: + raise RuntimeError("Database connection not established.") + cursor = self.conn.cursor() + cursor.execute("SELECT id, name, email, created_at, is_active, password FROM users WHERE id = ?", (user_id,)) + row = cursor.fetchone() + if row: + user = User( + id=row[0], + name=row[1], + email=row[2], + created_at=row[3], + is_active=bool(row[4]) + ) + user.password = row[5] + return user + return None + + def get_user_by_email(self, email: str) -> Optional[User]: + """Fetch a single user from database by email.""" + if not self.conn: + raise RuntimeError("Database connection not established.") + cursor = self.conn.cursor() + cursor.execute("SELECT id, name, email, created_at, is_active, password FROM users WHERE email = ?", (email,)) + row = cursor.fetchone() + if row: + user = User( + id=row[0], + name=row[1], + email=row[2], + created_at=row[3], + is_active=bool(row[4]) + ) + user.password = row[5] + return user + return None + + def insert_user(self, user_data: UserCreate) -> User: + """Insert new user record into database and return corresponding User object.""" + if not self.conn: + raise RuntimeError("Database connection not established.") + created_at_str = datetime.datetime.utcnow().isoformat() + cursor = self.conn.cursor() + cursor.execute( + "INSERT INTO users (name, email, password, created_at, is_active) VALUES (?, ?, ?, ?, ?)", + (user_data.name, user_data.email, user_data.password, created_at_str, 1) + ) + self.conn.commit() + user_id = cursor.lastrowid + + user = User( + id=user_id, + name=user_data.name, + email=user_data.email, + created_at=created_at_str, + is_active=True + ) + user.password = user_data.password + return user + +# Module-level Database singleton +_db_instance: Optional[Database] = None + +def get_db() -> Database: + """Retrieve or initialize the global Database singleton instance.""" + global _db_instance + if _db_instance is None: + _db_instance = Database() + return _db_instance diff --git a/evals/fixtures/sample_project/main.py b/evals/fixtures/sample_project/main.py new file mode 100644 index 0000000..580ba26 --- /dev/null +++ b/evals/fixtures/sample_project/main.py @@ -0,0 +1,69 @@ +"""Main FastAPI application module for the User Management API. + +Defines all HTTP endpoints, request/response models, startup/shutdown database hooks, +and dependency-injected bearer validation requirements. +""" + +from fastapi import FastAPI, Depends, HTTPException +from database import get_db, Database +from auth import create_token, require_auth +from models import User, UserCreate, LoginRequest, TokenResponse, UserResponse + +# Instantiate FastAPI application +app = FastAPI(title="User Management API", version="1.0.0") + +@app.on_event("startup") +def startup_event() -> None: + """Establish connection with the database when the application starts.""" + db: Database = get_db() + db.connect() + +@app.on_event("shutdown") +def shutdown_event() -> None: + """Safely terminate database connections when the application stops.""" + db: Database = get_db() + db.disconnect() + +@app.get("/health") +def health_check() -> dict: + """Perform health checks to confirm container statuses and api run states.""" + return {"status": "ok", "version": "1.0.0"} + +@app.post("/auth/login", response_model=TokenResponse) +def login(body: LoginRequest) -> TokenResponse: + """Accept credential details, verify password match, and return active JWT bearer token.""" + db: Database = get_db() + user = db.get_user_by_email(body.email) + + # Simple password equality check as specified + if not user or getattr(user, "password", None) != body.password: + raise HTTPException(status_code=401, detail="Invalid email or password") + + return create_token(user.id) + +@app.get("/users", response_model=list[UserResponse]) +def get_users(token: dict = Depends(require_auth)) -> list[User]: + """Retrieve all user accounts from the database.""" + db: Database = get_db() + return db.get_all_users() + +@app.get("/users/{user_id}", response_model=UserResponse) +def get_user_by_id(user_id: int, token: dict = Depends(require_auth)) -> User: + """Retrieve details for a single user by ID.""" + db: Database = get_db() + user = db.get_user_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + +@app.post("/users", response_model=UserResponse) +def create_user(body: UserCreate, token: dict = Depends(require_auth)) -> User: + """Register a new user inside the database.""" + db: Database = get_db() + + # Check if email is already taken + existing_user = db.get_user_by_email(body.email) + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + + return db.insert_user(body) diff --git a/evals/fixtures/sample_project/models.py b/evals/fixtures/sample_project/models.py new file mode 100644 index 0000000..10d3414 --- /dev/null +++ b/evals/fixtures/sample_project/models.py @@ -0,0 +1,43 @@ +"""Pydantic models for the User Management API. + +This module defines models for users, user registration, authentication requests, +and token responses. Models use Pydantic v2 structures. +""" + +from pydantic import BaseModel, ConfigDict + +class User(BaseModel): + """Represents a user record in the application.""" + id: int + name: str + email: str + created_at: str # ISO format datetime string + is_active: bool = True + + # Allow arbitrary extra attributes (so password check can happen dynamically) + model_config = ConfigDict(extra="allow") + +class UserCreate(BaseModel): + """Input validation model for user registration.""" + name: str + email: str + password: str + +class LoginRequest(BaseModel): + """Input model for login requests.""" + email: str + password: str + +class TokenResponse(BaseModel): + """Response schema for successful authentication, returning JWT access token.""" + access_token: str + token_type: str = "bearer" + expires_in: int = 3600 + +class UserResponse(BaseModel): + """Response schema representing a user (excluding sensitive fields like password).""" + id: int + name: str + email: str + created_at: str + is_active: bool diff --git a/evals/fixtures/sample_project/requirements.txt b/evals/fixtures/sample_project/requirements.txt new file mode 100644 index 0000000..cb7bb84 --- /dev/null +++ b/evals/fixtures/sample_project/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.100.0 +uvicorn>=0.23.0 +pydantic>=2.0.0 +PyJWT>=2.8.0 +python-dotenv>=1.0.0 From e560d6461bf3517ca5c6f693126fced5d6727420 Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 16:46:57 +0530 Subject: [PATCH 08/15] feat: add eval harness with 8 test cases, 4 metrics, and rich reporting --- evals/harness.py | 272 ++++++++++++++++++++++++++++++++++++++++++++ evals/metrics.py | 87 ++++++++++++++ evals/test_cases.py | 78 +++++++++++++ 3 files changed, 437 insertions(+) create mode 100644 evals/harness.py create mode 100644 evals/metrics.py create mode 100644 evals/test_cases.py diff --git a/evals/harness.py b/evals/harness.py new file mode 100644 index 0000000..39694c0 --- /dev/null +++ b/evals/harness.py @@ -0,0 +1,272 @@ +import sys +import os + +# Insert project root into sys.path to enable imports of agent/eval modules +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import json +import datetime +import dataclasses +from typing import List, Dict, Tuple, Optional, Any +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from evals.test_cases import TestCase, TEST_CASES +from evals.metrics import ( + score_topic_coverage, + score_hallucination_penalty, + score_answer_length, + score_groundedness, + compute_overall_score, + get_missing_topics +) +from agent.repo_explainer import run_agent, AgentResult + +# Initialize rich Console +console = Console() + +@dataclasses.dataclass +class EvalResult: + """Represents the evaluation outcome of a single test case.""" + test_id: str + question: str + answer: str + scores: Dict[str, float] # Keys: topic, hallucination, length, groundedness, overall + overall_score: float + tools_used: List[str] + iterations: int + passed: bool + error: Optional[str] = None + +@dataclasses.dataclass +class EvalReport: + """Summary report across all test cases run in an evaluation session.""" + results: List[EvalResult] + pass_rate: float + average_score: float + metric_averages: Dict[str, float] + total_tests: int + passed_tests: int + timestamp: str # ISO format + +def run_single_eval( + test_case: TestCase, + repo_path: str, + system_prompt: Optional[str] = None +) -> EvalResult: + """Executes a single test case evaluation against the agent.""" + try: + result = run_agent( + repo_input=repo_path, + question=test_case.question, + max_iterations=10, + system_prompt=system_prompt + ) + + if not result.success: + zero_scores = { + "topic": 0.0, + "hallucination": 0.0, + "length": 0.0, + "groundedness": 0.0, + "overall": 0.0 + } + return EvalResult( + test_id=test_case.id, + question=test_case.question, + answer="", + scores=zero_scores, + overall_score=0.0, + tools_used=result.tools_used, + iterations=result.iterations, + passed=False, + error=result.error + ) + + # Calculate individual metric scores + t_score = score_topic_coverage(result.answer, test_case.expected_topics) + h_score = score_hallucination_penalty(result.answer, test_case.must_not_contain) + l_score = score_answer_length(result.answer, test_case.min_words, test_case.max_words) + g_score = score_groundedness(result.answer, result.tools_used) + o_score = compute_overall_score(t_score, h_score, l_score, g_score) + + scores_dict = { + "topic": t_score, + "hallucination": h_score, + "length": l_score, + "groundedness": g_score, + "overall": o_score + } + + return EvalResult( + test_id=test_case.id, + question=test_case.question, + answer=result.answer, + scores=scores_dict, + overall_score=o_score, + tools_used=result.tools_used, + iterations=result.iterations, + passed=(o_score >= 0.6), + error=None + ) + + except Exception as e: + zero_scores = { + "topic": 0.0, + "hallucination": 0.0, + "length": 0.0, + "groundedness": 0.0, + "overall": 0.0 + } + return EvalResult( + test_id=test_case.id, + question=test_case.question, + answer="", + scores=zero_scores, + overall_score=0.0, + tools_used=[], + iterations=0, + passed=False, + error=str(e) + ) + +def run_evals( + repo_path: str, + test_cases: Optional[List[TestCase]] = None, + system_prompt: Optional[str] = None, + save_results: bool = True +) -> EvalReport: + """Runs the full evaluation test suite and compiles the final report.""" + if test_cases is None: + test_cases = TEST_CASES + + # Print header panel + console.print(Panel( + f"[bold green]πŸ§ͺ Running Eval Harness[/bold green]\n\n[bold]{len(test_cases)}[/bold] test cases against [dim]{repo_path}[/dim]", + border_style="green" + )) + + results = [] + passed_count = 0 + + for case in test_cases: + console.print(f" Running: [cyan]{case.id}[/cyan]...", end="") + + # Call run_single_eval + res = run_single_eval(case, repo_path, system_prompt) + results.append(res) + + # Print inline result + if res.passed: + passed_count += 1 + console.print(f" [bold green]βœ… PASS[/bold green] (score: {res.overall_score:.4f})") + else: + console.print(f" [bold red]❌ FAIL[/bold red] (score: {res.overall_score:.4f})") + + # Compute aggregates + total_tests = len(test_cases) + pass_rate = passed_count / total_tests if total_tests > 0 else 0.0 + average_score = sum(r.overall_score for r in results) / total_tests if total_tests > 0 else 0.0 + + # Calculate metric averages + metric_averages = { + "topic": 0.0, + "hallucination": 0.0, + "length": 0.0, + "groundedness": 0.0 + } + if total_tests > 0: + metric_averages["topic"] = sum(r.scores["topic"] for r in results) / total_tests + metric_averages["hallucination"] = sum(r.scores["hallucination"] for r in results) / total_tests + metric_averages["length"] = sum(r.scores["length"] for r in results) / total_tests + metric_averages["groundedness"] = sum(r.scores["groundedness"] for r in results) / total_tests + + # Render detailed results table + table = Table(title="πŸ“Š Detailed Evaluation Results", show_header=True, header_style="bold magenta") + table.add_column("Test ID", style="cyan") + table.add_column("Score", justify="right") + table.add_column("Topic", justify="right") + table.add_column("Halluc", justify="right") + table.add_column("Length", justify="right") + table.add_column("Ground", justify="right") + table.add_column("Pass?", justify="center") + + for r in results: + row_style = "green" if r.passed else "red" + pass_str = "βœ…" if r.passed else "❌" + table.add_row( + r.test_id, + f"{r.overall_score:.4f}", + f"{r.scores['topic']:.4f}", + f"{r.scores['hallucination']:.4f}", + f"{r.scores['length']:.4f}", + f"{r.scores['groundedness']:.4f}", + pass_str, + style=row_style + ) + + # Add bottom summary row + table.add_section() + table.add_row( + "Averages / Pass Rate", + f"[bold]{average_score:.4f}[/bold]", + f"{metric_averages['topic']:.4f}", + f"{metric_averages['hallucination']:.4f}", + f"{metric_averages['length']:.4f}", + f"{metric_averages['groundedness']:.4f}", + f"[bold]{pass_rate * 100:.1f}%[/bold]" + ) + + console.print(table) + + # Compile report dataclass + iso_timestamp = datetime.datetime.utcnow().isoformat() + report = EvalReport( + results=results, + pass_rate=pass_rate, + average_score=average_score, + metric_averages=metric_averages, + total_tests=total_tests, + passed_tests=passed_count, + timestamp=iso_timestamp + ) + + # Save output report as JSON + if save_results: + os.makedirs("optimizer/results", exist_ok=True) + fn_timestamp = iso_timestamp.replace(":", "-") + report_path = f"optimizer/results/eval_{fn_timestamp}.json" + + # Serialize report object including child dataclasses + report_dict = dataclasses.asdict(report) + try: + with open(report_path, "w") as f: + json.dump(report_dict, f, indent=2) + console.print(f"πŸ’Ύ Results saved to [green]{report_path}[/green]") + except Exception as e: + console.print(f"[bold red]Failed to save results:[/bold red] {e}") + + return report + +def print_eval_summary(report: EvalReport) -> None: + """Print a clean summary panel of the evaluation report details.""" + best_test = None + worst_test = None + + if report.results: + sorted_res = sorted(report.results, key=lambda r: r.overall_score) + worst_test = sorted_res[0] + best_test = sorted_res[-1] + + best_str = f"{best_test.test_id} ({best_test.overall_score:.4f})" if best_test else "N/A" + worst_str = f"{worst_test.test_id} ({worst_test.overall_score:.4f})" if worst_test else "N/A" + + summary_text = ( + f"[bold]Total passed:[/bold] {report.passed_tests} / {report.total_tests}\n" + f"[bold]Average Score:[/bold] {report.average_score:.4f}\n" + f"[bold]Best test:[/bold] {best_str}\n" + f"[bold]Worst test:[/bold] {worst_str}" + ) + + console.print(Panel(summary_text, title="πŸ“Š Evaluation Summary", border_style="cyan")) diff --git a/evals/metrics.py b/evals/metrics.py new file mode 100644 index 0000000..36ed77f --- /dev/null +++ b/evals/metrics.py @@ -0,0 +1,87 @@ +"""Metrics module for scoring agent answers. + +Contains functions to measure topic coverage, evaluate hallucination penalties, +check answer length constraints, score groundedness, compute the overall weighted score, +and locate missing topics. +""" + +from typing import List + +def score_topic_coverage(answer: str, expected_topics: List[str]) -> float: + """Calculate the fraction of expected topics that appear in the answer (case-insensitive).""" + if not expected_topics: + return 1.0 + + answer_lower = answer.lower() + found_count = 0 + for topic in expected_topics: + if topic.lower() in answer_lower: + found_count += 1 + + return found_count / len(expected_topics) + +def score_hallucination_penalty(answer: str, must_not_contain: List[str]) -> float: + """Return 0.0 if any forbidden word appears in the answer (case-insensitive), otherwise 1.0.""" + if not must_not_contain: + return 1.0 + + answer_lower = answer.lower() + for forbidden in must_not_contain: + if forbidden.lower() in answer_lower: + return 0.0 + + return 1.0 + +def score_answer_length(answer: str, min_words: int = 50, max_words: int = 800) -> float: + """Evaluate if the answer's word count satisfies constraints, penalizing short/long responses.""" + words = answer.split() + word_count = len(words) + + if min_words <= word_count <= max_words: + return 1.0 + elif word_count < min_words: + if min_words == 0: + return 1.0 + return word_count / min_words + else: + if word_count == 0: + return 0.0 + return max_words / word_count + +def score_groundedness(answer: str, tools_used: List[str]) -> float: + """Score the groundedness of the response based on exploration tools executed by the agent.""" + if not tools_used: + return 0.0 + + # High-value reading/exploring tools + if "read_file" in tools_used or "search_code" in tools_used: + return 1.0 + + # Structural inspection tools only + if "get_file_tree" in tools_used or "list_directory" in tools_used: + return 0.7 + + return 0.5 + +def compute_overall_score( + topic: float, + hallucination: float, + length: float, + groundedness: float +) -> float: + """Compute the weighted average of the four metrics, rounded to 4 decimal places.""" + weighted_sum = (topic * 0.40) + (hallucination * 0.30) + (length * 0.15) + (groundedness * 0.15) + return round(weighted_sum, 4) + +def get_missing_topics(answer: str, expected_topics: List[str]) -> List[str]: + """Retrieve all expected topics that did not appear in the answer (case-insensitive).""" + if not expected_topics: + return [] + + answer_lower = answer.lower() + missing = [] + for topic in expected_topics: + if topic.lower() not in answer_lower: + missing.append(topic) + + return missing diff --git a/evals/test_cases.py b/evals/test_cases.py new file mode 100644 index 0000000..01e0f11 --- /dev/null +++ b/evals/test_cases.py @@ -0,0 +1,78 @@ +"""Test cases for evaluating the Repo Explainer Agent. + +Defines the TestCase dataclass and the 8 core evaluation scenarios covering different +aspects of the sample user management codebase. +""" + +import dataclasses +from typing import List + +@dataclasses.dataclass +class TestCase: + """Represents a single evaluation test case for the agent.""" + id: str + question: str + expected_topics: List[str] + must_not_contain: List[str] + description: str + min_words: int = 50 + max_words: int = 800 + +TEST_CASES: List[TestCase] = [ + TestCase( + id="architecture_overview", + question="What is the overall architecture of this project?", + expected_topics=["fastapi", "sqlite", "jwt", "auth", "database", "models", "routes", "main"], + must_not_contain=["django", "postgresql", "mongodb", "flask", "express", "spring"], + description="Agent should map out all components and how they connect" + ), + TestCase( + id="framework_detection", + question="What framework and language is this project built with?", + expected_topics=["fastapi", "python", "pydantic", "uvicorn"], + must_not_contain=["django", "flask", "javascript", "node", "ruby", "java", "go"], + description="Agent should correctly identify FastAPI and Python" + ), + TestCase( + id="auth_mechanism", + question="How does authentication work in this codebase?", + expected_topics=["jwt", "token", "bearer", "secret", "decode", "require_auth", "authorization", "header"], + must_not_contain=["oauth", "session", "cookie", "basic auth", "api key", "no authentication"], + description="Agent should explain JWT auth flow end to end" + ), + TestCase( + id="database_layer", + question="What database does this project use and how is it accessed?", + expected_topics=["sqlite", "database.py", "get_db", "connect", "sql", "singleton", "connection"], + must_not_contain=["postgresql", "mysql", "mongodb", "redis", "orm", "sqlalchemy", "no database"], + description="Agent should identify SQLite and explain the database.py layer" + ), + TestCase( + id="api_endpoints", + question="List all the API endpoints and what each one does.", + expected_topics=["health", "login", "users", "get", "post", "auth", "jwt", "create"], + must_not_contain=["delete", "put", "patch", "graphql", "websocket", "no endpoints"], + description="Agent should list all 5 endpoints with methods and purposes" + ), + TestCase( + id="data_models", + question="What are the main data models in this project?", + expected_topics=["user", "usercreate", "loginrequest", "tokenresponse", "pydantic", "email", "password"], + must_not_contain=["sqlalchemy", "django model", "no models", "no schema", "mongodb"], + description="Agent should describe all Pydantic models from models.py" + ), + TestCase( + id="running_locally", + question="How would I run this project locally?", + expected_topics=["uvicorn", "pip install", "requirements", "main:app", "python"], + must_not_contain=["docker", "kubernetes", "npm", "yarn", "cannot run", "no instructions"], + description="Agent should give accurate run instructions from README" + ), + TestCase( + id="adding_endpoint", + question="Which files would I need to modify to add a new API endpoint?", + expected_topics=["main.py", "models.py", "database.py", "route", "pydantic", "function"], + must_not_contain=["no files", "any file", "cannot determine", "not possible"], + description="Agent should identify main.py and potentially models.py/database.py" + ) +] From 9ae0dec9e002679ed2e467df6717dc498a48a244 Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 16:52:36 +0530 Subject: [PATCH 09/15] feat: add prompt optimizer with iterative eval loop and before/after reporting --- optimizer/optimizer.py | 294 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 optimizer/optimizer.py diff --git a/optimizer/optimizer.py b/optimizer/optimizer.py new file mode 100644 index 0000000..06667b4 --- /dev/null +++ b/optimizer/optimizer.py @@ -0,0 +1,294 @@ +import sys +import os + +# Insert project root into sys.path to enable imports of agent/eval modules +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import json +import datetime +import dataclasses +from typing import List, Dict, Tuple, Optional, Any +from dotenv import load_dotenv +import anthropic +from rich.console import Console +from rich.panel import Panel +from rich.progress import Progress +from rich.table import Table +from rich.text import Text + +from agent.prompts import SYSTEM_PROMPT, OPTIMIZER_META_PROMPT, format_failed_cases +from evals.harness import run_evals, EvalReport +from evals.metrics import get_missing_topics +from evals.test_cases import TEST_CASES, TestCase + +@dataclasses.dataclass +class IterationRecord: + """Records performance data and outputs from a single optimizer iteration.""" + iteration: int + prompt_used: str + score: float + pass_rate: float + improved: bool + failed_test_ids: List[str] + +@dataclasses.dataclass +class OptimizationReport: + """Summary report detailing the baseline, final results, and iteration logs.""" + baseline_score: float + final_score: float + improvement: float + baseline_pass_rate: float + final_pass_rate: float + best_prompt: str + iterations_run: int + iteration_history: List[IterationRecord] + timestamp: str + +def get_failed_cases(report: EvalReport, test_cases: List[TestCase]) -> List[Dict[str, Any]]: + """Identifies failed test cases from a report and extracts keywords missed by the agent.""" + failed_cases = [] + tc_map = {tc.id: tc for tc in test_cases} + + for result in report.results: + if not result.passed: + tc = tc_map.get(result.test_id) + if tc: + failed_cases.append({ + "id": result.test_id, + "question": result.question, + "overall_score": result.overall_score, + "missing_topics": get_missing_topics(result.answer, tc.expected_topics) + }) + return failed_cases + +def generate_improved_prompt( + current_prompt: str, + failed_cases: List[Dict[str, Any]], + overall_score: float +) -> str: + """Calls the Anthropic messages API directly to generate a system prompt variant based on test failures.""" + load_dotenv() + + meta_prompt = OPTIMIZER_META_PROMPT.format( + current_prompt=current_prompt, + failed_cases=format_failed_cases(failed_cases), + overall_score=round(overall_score, 3) + ) + + client = anthropic.Anthropic() + + response = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=2048, + messages=[{"role": "user", "content": meta_prompt}] + ) + + # Clean output and strip out explanations + new_prompt = response.content[0].text.strip() + if not new_prompt or len(new_prompt) < 100: + # Fallback to current prompt if model returns garbage or empty string + return current_prompt + + return new_prompt + +def save_optimization_report(report: OptimizationReport, repo_path: str) -> str: + """Save the final optimization metrics to a JSON report in the results directory.""" + os.makedirs("optimizer/results", exist_ok=True) + + fn_timestamp = report.timestamp.replace(":", "-") + file_path = f"optimizer/results/optimization_{fn_timestamp}.json" + + report_dict = dataclasses.asdict(report) + with open(file_path, "w") as f: + json.dump(report_dict, f, indent=2) + + return file_path + +def save_best_prompt(prompt: str) -> str: + """Writes the optimal prompt to prompts_optimized.py inside the agent module.""" + os.makedirs("agent", exist_ok=True) + file_path = "agent/prompts_optimized.py" + + timestamp = datetime.datetime.utcnow().isoformat() + content = f'''# Auto-generated by optimizer β€” do not edit manually +# Generated: {timestamp} + +OPTIMIZED_SYSTEM_PROMPT = """{prompt}""" +''' + + with open(file_path, "w") as f: + f.write(content) + + return file_path + +class PromptOptimizer: + """Drives the automated prompt optimization loop.""" + + def __init__(self, repo_path: str, n_iterations: int = 5) -> None: + """Initialize optimizer properties, baseline values, and file system targets.""" + self.repo_path: str = repo_path + self.n_iterations: int = n_iterations + self.console: Console = Console() + self.current_prompt: str = SYSTEM_PROMPT + self.best_prompt: str = SYSTEM_PROMPT + self.best_score: float = 0.0 + self.iteration_history: List[IterationRecord] = [] + + def run(self) -> OptimizationReport: + """Executes prompt adjustments and evaluations sequentially.""" + + # Display initialization header panel + self.console.print(Panel( + f"[bold cyan]⚑ Prompt Optimizer[/bold cyan]\n\nRunning [bold]{self.n_iterations}[/bold] optimization loop iterations against repository", + border_style="cyan" + )) + + # PHASE 1 β€” BASELINE + self.console.print("πŸ“Š Running baseline evaluation...") + baseline_report = run_evals( + repo_path=self.repo_path, + test_cases=TEST_CASES, + system_prompt=self.current_prompt, + save_results=True + ) + + baseline_score = baseline_report.average_score + baseline_pass_rate = baseline_report.pass_rate + + self.best_score = baseline_score + self.best_prompt = self.current_prompt + active_report = baseline_report + + self.console.print(f"Baseline Score: [bold magenta]{baseline_score:.3f}[/bold magenta] | Pass Rate: [bold green]{baseline_pass_rate:.0%}[/bold green]") + + # PHASE 2 β€” OPTIMIZATION LOOP + with Progress(console=self.console) as progress: + task = progress.add_task("[cyan]Running optimization cycles...", total=self.n_iterations) + + for i in range(1, self.n_iterations + 1): + self.console.print(f"\nπŸ”„ [bold]Iteration {i}/{self.n_iterations}[/bold]") + + # STEP A β€” Find failures + failed = get_failed_cases(active_report, TEST_CASES) + if not failed: + self.console.print("βœ… [green]All tests passing β€” no optimization needed[/green]") + progress.update(task, advance=self.n_iterations - i + 1) + break + + self.console.print(f" Failed tests: { [f['id'] for f in failed] }") + + try: + # STEP B β€” Generate improved prompt + self.console.print(" πŸ€– Asking Claude to improve the system prompt...") + new_prompt = generate_improved_prompt( + self.current_prompt, + failed, + active_report.average_score + ) + + # STEP C β€” Re-evaluate with new prompt + self.console.print(" πŸ“Š Re-running evals with improved prompt...") + new_report = run_evals( + repo_path=self.repo_path, + test_cases=TEST_CASES, + system_prompt=new_prompt, + save_results=False + ) + + new_score = new_report.average_score + new_pass_rate = new_report.pass_rate + improved = new_score > self.best_score + + # STEP D β€” Keep or revert + if improved: + self.console.print(f" βœ… [green]Improved! {self.best_score:.3f} β†’ {new_score:.3f} (+{new_score - self.best_score:.3f})[/green]") + self.current_prompt = new_prompt + self.best_prompt = new_prompt + self.best_score = new_score + active_report = new_report + else: + self.console.print(f" ↩️ No improvement ({new_score:.3f} ≀ {self.best_score:.3f}) β€” reverting") + + # STEP E β€” Record iteration + record = IterationRecord( + iteration=i, + prompt_used=new_prompt if improved else self.current_prompt, + score=new_score, + pass_rate=new_pass_rate, + improved=improved, + failed_test_ids=[f['id'] for f in failed] + ) + self.iteration_history.append(record) + + except Exception as e: + self.console.print(f" ❌ Error encountered in iteration {i}: {e}") + record = IterationRecord( + iteration=i, + prompt_used=self.current_prompt, + score=active_report.average_score, + pass_rate=active_report.pass_rate, + improved=False, + failed_test_ids=[f['id'] for f in failed] + ) + self.iteration_history.append(record) + + # STEP F β€” Print iteration summary table + table = Table(title=f"πŸ“ˆ Optimization Progress (Up to Iteration {i})", show_header=True, header_style="bold magenta") + table.add_column("Iteration", justify="center") + table.add_column("Score", justify="right") + table.add_column("Pass Rate", justify="right") + table.add_column("Improved?", justify="center") + + for r in self.iteration_history: + improved_str = "βœ… Yes" if r.improved else "❌ No" + table.add_row( + str(r.iteration), + f"{r.score:.3f}", + f"{r.pass_rate:.0%}", + improved_str + ) + self.console.print(table) + progress.update(task, advance=1) + + # PHASE 3 β€” FINAL EVAL WITH BEST PROMPT + self.console.print("\n🏁 Running final evaluation with best prompt...") + final_report = run_evals( + repo_path=self.repo_path, + test_cases=TEST_CASES, + system_prompt=self.best_prompt, + save_results=True + ) + final_score = final_report.average_score + final_pass_rate = final_report.pass_rate + + # PHASE 4 β€” SAVE AND REPORT + save_best_prompt(self.best_prompt) + + timestamp = datetime.datetime.utcnow().isoformat() + improvement = final_score - baseline_score + + report_obj = OptimizationReport( + baseline_score=baseline_score, + final_score=final_score, + improvement=improvement, + baseline_pass_rate=baseline_pass_rate, + final_pass_rate=final_pass_rate, + best_prompt=self.best_prompt, + iterations_run=len(self.iteration_history), + iteration_history=self.iteration_history, + timestamp=timestamp + ) + + report_file = save_optimization_report(report_obj, self.repo_path) + + self.console.print(Panel( + f"[bold green]πŸ† Optimization Complete[/bold green]\n\n" + f"[bold]Baseline Score:[/bold] {baseline_score:.3f}\n" + f"[bold]Final Score:[/bold] {final_score:.3f}\n" + f"[bold]Improvement:[/bold] +{improvement:.3f}\n\n" + f"Best prompt saved to [cyan]agent/prompts_optimized.py[/cyan]\n" + f"Full report saved to [cyan]{report_file}[/cyan]", + border_style="green" + )) + + return report_obj From 0ea4f5600124aa9dccc369789158762bd52a7640 Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 16:56:55 +0530 Subject: [PATCH 10/15] feat: add eval and optimizer CLI entry points with rich output --- scripts/run_evals.py | 80 ++++++++++++++++++++++++ scripts/run_optimizer.py | 130 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 scripts/run_evals.py create mode 100644 scripts/run_optimizer.py diff --git a/scripts/run_evals.py b/scripts/run_evals.py new file mode 100644 index 0000000..a9cfa59 --- /dev/null +++ b/scripts/run_evals.py @@ -0,0 +1,80 @@ +import sys +import os + +# Insert project root into sys.path to enable imports of agent/eval modules +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import typer +from rich.console import Console +from rich.panel import Panel + +# Initialize Console +console = Console() + +def main( + repo: str = typer.Option("evals/fixtures/sample_project", "--repo", help="Path to repo to evaluate against"), + save: bool = typer.Option(True, "--save/--no-save", help="Save results to optimizer/results/"), + prompt: str = typer.Option(None, "--prompt", help="Path to a .py file containing OPTIMIZED_SYSTEM_PROMPT"), +) -> None: + """Run the Repo Explainer Evaluation Harness suite.""" + # Move heavy imports inside main to adhere to fast CLI startup and network rules + import importlib.util + from evals.harness import run_evals, print_eval_summary + from evals.test_cases import TEST_CASES + from agent.prompts import SYSTEM_PROMPT + + try: + # STEP 1 β€” Header panel + header_text = f"[bold blue]πŸ§ͺ Repo Explainer β€” Eval Suite[/bold blue]\n\nRunning 8 test cases against: [dim]{repo}[/dim]" + console.print(Panel(header_text, border_style="blue")) + + # STEP 2 β€” Load prompt + system_prompt = SYSTEM_PROMPT + if prompt is not None: + prompt_path = os.path.abspath(prompt) + if not os.path.exists(prompt_path): + console.print(f"[bold yellow]⚠️ Warning:[/bold yellow] Prompt file not found at {prompt}. Falling back to default system prompt.") + else: + try: + spec = importlib.util.spec_from_file_location("prompts_optimized", prompt_path) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + if hasattr(module, "OPTIMIZED_SYSTEM_PROMPT"): + system_prompt = getattr(module, "OPTIMIZED_SYSTEM_PROMPT") + console.print(f"πŸ“ Using optimized prompt from [green]{prompt}[/green]") + else: + console.print(f"[bold yellow]⚠️ Warning:[/bold yellow] OPTIMIZED_SYSTEM_PROMPT not found in {prompt}. Falling back to default system prompt.") + else: + console.print(f"[bold yellow]⚠️ Warning:[/bold yellow] Could not load module spec for {prompt}. Falling back to default system prompt.") + except Exception as e: + console.print(f"[bold yellow]⚠️ Warning:[/bold yellow] Failed to dynamically load prompt module: {e}. Falling back to default system prompt.") + else: + console.print("πŸ“ Using default system prompt") + + # STEP 3 β€” Run evals + report = run_evals( + repo_path=repo, + test_cases=TEST_CASES, + system_prompt=system_prompt, + save_results=save + ) + + # STEP 4 β€” Print summary + print_eval_summary(report) + + # STEP 5 β€” Exit code + if report.pass_rate == 1.0: + sys.exit(0) + else: + raise typer.Exit(code=1) + + except typer.Exit: + # Propagate typer exits directly + raise + except Exception as e: + console.print(Panel(f"[bold red]Unexpected error:[/bold red] {e}", title="❌ Error", border_style="red")) + raise typer.Exit(code=1) + +if __name__ == "__main__": + typer.run(main) diff --git a/scripts/run_optimizer.py b/scripts/run_optimizer.py new file mode 100644 index 0000000..d2b9bae --- /dev/null +++ b/scripts/run_optimizer.py @@ -0,0 +1,130 @@ +import sys +import os + +# Insert project root into sys.path to enable imports of agent/eval modules +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +# Initialize Console +console = Console() + +def main( + repo: str = typer.Option("evals/fixtures/sample_project", "--repo", help="Path to repo to run evals against"), + iterations: int = typer.Option(5, "--iterations", help="Number of optimization iterations"), + dry_run: bool = typer.Option(False, "--dry-run/--no-dry-run", help="If set, only run baseline eval, skip optimization"), +) -> None: + """Run the Prompt Optimizer loop to improve the system prompt.""" + # Move heavy imports inside main to adhere to fast CLI startup and network rules + from evals.harness import run_evals, print_eval_summary + from evals.test_cases import TEST_CASES + from optimizer.optimizer import PromptOptimizer + + try: + # STEP 1 β€” Header panel + mode_str = "Dry Run (baseline only)" if dry_run else "Full Optimization" + header_text = ( + f"[bold yellow]⚑ Repo Explainer β€” Prompt Optimizer[/bold yellow]\n\n" + f"[bold]Repo:[/bold] [dim]{repo}[/dim]\n" + f"[bold]Iterations:[/bold] {iterations}\n" + f"[bold]Mode:[/bold] {mode_str}" + ) + console.print(Panel(header_text, border_style="yellow")) + + # STEP 2 β€” Dry run mode + if dry_run: + console.print("Running baseline eval only...") + report = run_evals( + repo_path=repo, + test_cases=TEST_CASES, + system_prompt=None, + save_results=True + ) + print_eval_summary(report) + console.print(Panel("Dry run complete. Run without --dry-run to optimize.", border_style="blue")) + return + + # STEP 3 β€” Confirmation prompt (only in full mode) + warning_text = ( + "[bold yellow]⚠️ This will make multiple API calls to Claude.[/bold yellow]\n\n" + "Estimated cost: very low (Haiku model used throughout)\n" + "Results will be saved to [cyan]optimizer/results/[/cyan]" + ) + console.print(Panel(warning_text, border_style="yellow")) + + confirmed = typer.confirm("Continue?") + if not confirmed: + console.print("Aborted.") + return + + # STEP 4 β€” Run optimizer + optimizer = PromptOptimizer(repo_path=repo, n_iterations=iterations) + report = optimizer.run() + + # STEP 5 β€” Print final comparison table + table = Table(title="πŸ“Š Optimization Results", show_header=True, header_style="bold magenta") + table.add_column("Metric", style="dim", width=20) + table.add_column("Baseline", justify="right", width=15) + table.add_column("Final", justify="right", width=15) + table.add_column("Change", justify="right", width=15) + + # Format Change values + delta_score = report.final_score - report.baseline_score + if delta_score > 0: + change_score_str = f"[green]+{delta_score:.3f}[/green]" + elif delta_score < 0: + change_score_str = f"[red]{delta_score:.3f}[/red]" + else: + change_score_str = "0.000" + + delta_pass = report.final_pass_rate - report.baseline_pass_rate + if delta_pass > 0: + change_pass_str = f"[green]+{delta_pass:.0%}[/green]" + elif delta_pass < 0: + change_pass_str = f"[red]{delta_pass:.0%}[/red]" + else: + change_pass_str = "0%" + + table.add_row( + "Average Score", + f"{report.baseline_score:.3f}", + f"{report.final_score:.3f}", + change_score_str + ) + table.add_row( + "Pass Rate", + f"{report.baseline_pass_rate:.0%}", + f"{report.final_pass_rate:.0%}", + change_pass_str + ) + table.add_row( + "Iterations Run", + "β€”", + "β€”", + str(report.iterations_run) + ) + + console.print(table) + + # STEP 6 β€” Next steps panel + next_steps_text = ( + "Best prompt saved to: [cyan]agent/prompts_optimized.py[/cyan]\n\n" + "To use it: [bold green]python scripts/run_agent.py --repo . --question 'How does this work?'[/bold green]\n" + " (the agent will automatically use the optimized prompt)" + ) + console.print(Panel(next_steps_text, title="πŸš€ Next Steps", border_style="green")) + + except KeyboardInterrupt: + console.print("\n[bold red]Interrupted by user.[/bold red]") + sys.exit(1) + except typer.Exit: + raise + except Exception as e: + console.print(Panel(f"[bold red]Unexpected error:[/bold red] {e}", title="❌ Error", border_style="red")) + raise typer.Exit(code=1) + +if __name__ == "__main__": + typer.run(main) From 997dafe41ad24b52071ed4e3b1ac44f4e8594b70 Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 17:00:06 +0530 Subject: [PATCH 11/15] ci: add GitHub Actions workflow and unit tests for metrics --- .github/workflows/ci.yml | 53 +++++++++++++ tests/__init__.py | 0 tests/test_metrics.py | 164 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/__init__.py create mode 100644 tests/test_metrics.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8687134 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + branches: [main] + +jobs: + lint-and-test: + name: Lint & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Check imports β€” agent + run: | + python -c "import agent.tools; print('βœ… agent.tools')" + python -c "import agent.prompts; print('βœ… agent.prompts')" + python -c "import agent.repo_explainer; print('βœ… agent.repo_explainer')" + + - name: Check imports β€” evals + run: | + python -c "from evals.test_cases import TEST_CASES; print(f'βœ… {len(TEST_CASES)} test cases')" + python -c "from evals.metrics import compute_overall_score; print('βœ… evals.metrics')" + python -c "from evals.harness import run_evals; print('βœ… evals.harness')" + + - name: Check imports β€” optimizer + run: | + python -c "from optimizer.optimizer import PromptOptimizer; print('βœ… optimizer')" + + - name: Check CLI scripts + run: | + python scripts/run_agent.py --help + python scripts/run_evals.py --help + python scripts/run_optimizer.py --help + + - name: Run unit tests + run: | + pytest tests/ -v --tb=short + continue-on-error: true \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..15d8f3d --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,164 @@ +""" +Unit tests for evals/metrics.py +These run in CI without needing an Anthropic API key. +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from evals.metrics import ( + score_topic_coverage, + score_hallucination_penalty, + score_answer_length, + score_groundedness, + compute_overall_score, + get_missing_topics, +) + + +# --- score_topic_coverage --- + +def test_topic_coverage_all_found(): + answer = "This project uses FastAPI and SQLite with JWT auth" + topics = ["fastapi", "sqlite", "jwt"] + assert score_topic_coverage(answer, topics) == 1.0 + + +def test_topic_coverage_none_found(): + answer = "This project uses Django and PostgreSQL" + topics = ["fastapi", "sqlite", "jwt"] + assert score_topic_coverage(answer, topics) == 0.0 + + +def test_topic_coverage_partial(): + answer = "This project uses FastAPI" + topics = ["fastapi", "sqlite", "jwt"] + score = score_topic_coverage(answer, topics) + assert round(score, 4) == round(1/3, 4) + + +def test_topic_coverage_empty_topics(): + assert score_topic_coverage("anything", []) == 1.0 + + +def test_topic_coverage_case_insensitive(): + answer = "Built with FASTAPI and SQLITE" + topics = ["fastapi", "sqlite"] + assert score_topic_coverage(answer, topics) == 1.0 + + +# --- score_hallucination_penalty --- + +def test_hallucination_penalty_clean(): + answer = "This uses FastAPI and SQLite" + must_not = ["django", "postgresql"] + assert score_hallucination_penalty(answer, must_not) == 1.0 + + +def test_hallucination_penalty_hit(): + answer = "This uses Django and PostgreSQL" + must_not = ["django", "postgresql"] + assert score_hallucination_penalty(answer, must_not) == 0.0 + + +def test_hallucination_penalty_empty(): + assert score_hallucination_penalty("anything", []) == 1.0 + + +def test_hallucination_penalty_case_insensitive(): + answer = "This uses DJANGO framework" + must_not = ["django"] + assert score_hallucination_penalty(answer, must_not) == 0.0 + + +# --- score_answer_length --- + +def test_length_in_range(): + answer = " ".join(["word"] * 100) + assert score_answer_length(answer, min_words=50, max_words=800) == 1.0 + + +def test_length_too_short(): + answer = " ".join(["word"] * 25) + score = score_answer_length(answer, min_words=50, max_words=800) + assert score == 25 / 50 + + +def test_length_too_long(): + answer = " ".join(["word"] * 1000) + score = score_answer_length(answer, min_words=50, max_words=800) + assert score == 800 / 1000 + + +def test_length_exactly_at_min(): + answer = " ".join(["word"] * 50) + assert score_answer_length(answer, min_words=50, max_words=800) == 1.0 + + +def test_length_exactly_at_max(): + answer = " ".join(["word"] * 800) + assert score_answer_length(answer, min_words=50, max_words=800) == 1.0 + + +# --- score_groundedness --- + +def test_groundedness_read_file(): + assert score_groundedness("answer", ["read_file", "get_file_tree"]) == 1.0 + + +def test_groundedness_search_code(): + assert score_groundedness("answer", ["search_code"]) == 1.0 + + +def test_groundedness_tree_only(): + assert score_groundedness("answer", ["get_file_tree"]) == 0.7 + + +def test_groundedness_empty_tools(): + assert score_groundedness("answer", []) == 0.0 + + +def test_groundedness_other_tools(): + assert score_groundedness("answer", ["list_directory"]) == 0.7 + + +# --- compute_overall_score --- + +def test_overall_score_perfect(): + score = compute_overall_score(1.0, 1.0, 1.0, 1.0) + assert score == 1.0 + + +def test_overall_score_zero(): + score = compute_overall_score(0.0, 0.0, 0.0, 0.0) + assert score == 0.0 + + +def test_overall_score_weights(): + # topic=1.0 (40%), hallucination=0.0 (30%), length=1.0 (15%), groundedness=1.0 (15%) + # Expected: 0.4 + 0.0 + 0.15 + 0.15 = 0.70 + score = compute_overall_score(1.0, 0.0, 1.0, 1.0) + assert score == round(0.70, 4) + + +# --- get_missing_topics --- + +def test_missing_topics_none_missing(): + answer = "fastapi sqlite jwt auth" + topics = ["fastapi", "sqlite", "jwt"] + assert get_missing_topics(answer, topics) == [] + + +def test_missing_topics_all_missing(): + answer = "django postgresql" + topics = ["fastapi", "sqlite", "jwt"] + assert set(get_missing_topics(answer, topics)) == {"fastapi", "sqlite", "jwt"} + + +def test_missing_topics_partial(): + answer = "fastapi is great" + topics = ["fastapi", "sqlite", "jwt"] + missing = get_missing_topics(answer, topics) + assert "fastapi" not in missing + assert "sqlite" in missing + assert "jwt" in missing \ No newline at end of file From b11370e2a997619907eddead5ebd87645f8d5066 Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 17:02:22 +0530 Subject: [PATCH 12/15] Add CI badge to README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 96e3b5f..bf90520 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # πŸ” Repo Explainer Agent +![CI](https://github.com/AxaySharma/repo-explainer/actions/workflows/ci.yml/badge.svg) + > Point it at any codebase β€” local or GitHub URL β€” and ask it anything. It maps the architecture, reads the code, and gives you grounded answers. --- @@ -241,4 +243,4 @@ repo-explainer/ --- -*Built with the Claude Agent SDK Β· MIT License* \ No newline at end of file +*Built with the Claude Agent SDK Β· MIT License* From c9a87f2f3703969f7d6390e686d380325e4d03ca Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 19:14:59 +0530 Subject: [PATCH 13/15] fix: update model to anthropic/claude-3.5-haiku for OpenRouter compatibility --- agent/repo_explainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/repo_explainer.py b/agent/repo_explainer.py index f2a1a99..148e647 100644 --- a/agent/repo_explainer.py +++ b/agent/repo_explainer.py @@ -116,7 +116,7 @@ def run_agent( # Make API request to Claude response = client.messages.create( - model="claude-haiku-4-5-20251001", + model="anthropic/claude-3.5-haiku", max_tokens=4096, system=sys_prompt, tools=TOOL_DEFINITIONS, From 73d8da605285f6d1e7699b72be4bad38b161fd6e Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 19:45:41 +0530 Subject: [PATCH 14/15] docs: update README with honest API setup options, real results, and full changelog --- CHANGELOG.md | 58 ++++++++++++++++++++++++++++--- README.md | 71 ++++++++++++++++++++++++++++++-------- agent/prompts_optimized.py | 38 ++++++++++++++++++++ 3 files changed, 148 insertions(+), 19 deletions(-) create mode 100644 agent/prompts_optimized.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5290307..e2fa02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,61 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). --- -## [Unreleased] +## [1.0.0] - 2026-06-13 ### Added -- Initial project scaffold and folder structure -- README with full architecture documentation -- Branch strategy and contributing guidelines + +#### Agent +- Core agentic loop in `agent/repo_explainer.py` using Anthropic Python SDK +- 5 filesystem tools: `read_file`, `list_directory`, `search_code`, `get_file_tree`, `detect_language_and_framework` +- Transparent GitHub URL support β€” clones to temp dir, cleans up automatically +- Path sandboxing to prevent traversal outside repo root +- `AgentResult` dataclass with answer, tools used, iterations, success, error + +#### Prompts +- Detailed system prompt (600+ words) with mandatory exploration steps +- Optimizer meta-prompt for automated prompt improvement +- `build_user_prompt()` and `format_failed_cases()` helpers + +#### Eval Harness +- 8 test cases covering architecture, framework, auth, database, endpoints, models, setup, and extensibility +- 4 weighted metrics: topic coverage (40%), hallucination penalty (30%), answer length (15%), groundedness (15%) +- Rich terminal output with color-coded pass/fail table +- JSON report auto-saved to `optimizer/results/` with timestamp + +#### Optimizer +- Automated prompt tuning loop with N configurable iterations +- Keeps improved prompts, reverts regressions +- Saves best prompt to `agent/prompts_optimized.py` +- Full optimization report with before/after comparison table + +#### CLI Scripts +- `scripts/run_agent.py` β€” ask any question about any repo with rich output and spinner +- `scripts/run_evals.py` β€” run full eval suite with optional custom prompt +- `scripts/run_optimizer.py` β€” run optimizer with dry-run mode and confirmation prompt + +#### Infrastructure +- GitHub Actions CI workflow β€” runs on every push to `dev` and PR to `main` +- 25 unit tests for metrics module (all passing in 0.02s) +- Branch strategy: `dev` for development, `main` for stable releases +- `.env.example` with documented configuration options +- `CONTRIBUTING.md` with commit conventions and branch strategy +- Compatible with Anthropic API direct, OpenRouter, or any Claude-compatible gateway + +### Results +- Baseline eval: **8/8 tests passing, average score 0.9917** +- Zero hallucinations across all baseline runs +- Perfect groundedness score (1.0000) β€” agent always reads code before answering +- Optimizer correctly identified baseline was already optimal and preserved the prompt + +--- + +## [Unreleased] + +### Potential Improvements +- Semantic search over code using embeddings for large repos +- Support for private GitHub repos via token auth +- Web UI for interactive Q&A sessions +- Expanded eval set for monorepos and polyglot projects --- \ No newline at end of file diff --git a/README.md b/README.md index bf90520..54036ca 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ It is built around three pillars: β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Claude API β”‚ -β”‚ claude-haiku-4-5-20251001 β”‚ +β”‚ (Anthropic direct or any compatible gateway) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” @@ -70,7 +70,7 @@ It is built around three pillars: ### Prerequisites - Python 3.10+ -- An Anthropic API key **or** Claude Code (Pro/Max β€” zero metered cost) +- An API key for Claude (see setup options below) ### Installation @@ -88,16 +88,53 @@ pip install -r requirements.txt # Set up environment cp .env.example .env -# Edit .env and add your ANTHROPIC_API_KEY +# Edit .env and fill in your credentials (see options below) ``` -### Running with Claude Code (recommended β€” no API cost) +--- + +## API Setup Options + +The agent uses the Anthropic Python SDK. You can point it at any compatible endpoint. + +### Option 1 β€” Anthropic API (Direct) + +Get a key from [console.anthropic.com](https://console.anthropic.com). + +```bash +# .env +ANTHROPIC_API_KEY=sk-ant-your-key-here +``` + +### Option 2 β€” OpenRouter (pay-per-use, many Claude models available) + +Get a key from [openrouter.ai](https://openrouter.ai). Costs fractions of a cent per run. + +```bash +# .env +ANTHROPIC_API_KEY=sk-or-your-key-here +ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1 +``` + +Then in `agent/repo_explainer.py` set: +```python +model = "anthropic/claude-3.5-haiku" +``` + +### Option 3 β€” Claude Code (Pro/Max subscribers) + +If you have a Claude Pro or Max subscription, you can run scripts as bash tasks from within an active Claude Code session. Claude Code injects its auth into subprocesses it spawns directly: + ```bash -# Claude Code routes through your Pro/Max subscription -# Just run via Claude Code β€” it handles auth automatically -claude "python scripts/run_agent.py --repo . --question 'How does this project work?'" +# Start Claude Code in your project directory +claude + +# Then ask Claude Code to run it as a task: +# "Please run: python scripts/run_agent.py --repo evals/fixtures/sample_project --question 'What is the architecture?'" ``` +> Note: This requires running the command from within the Claude Code session itself, not from a separate terminal window. + --- ## Usage @@ -189,7 +226,7 @@ baseline eval β†’ identify failed cases β†’ ask Claude to improve system prompt β†’ re-run evals β†’ keep if better β†’ revert if worse β†’ repeat N times ``` -Uses `claude-haiku-4-5-20251001` for cost efficiency at every stage. +Cost-efficient by design β€” uses the fastest available Claude model throughout. --- @@ -197,19 +234,21 @@ Uses `claude-haiku-4-5-20251001` for cost efficiency at every stage. | Run | Pass Rate | Avg Score | Topic Coverage | Groundedness | |-----|-----------|-----------|----------------|--------------| -| Baseline | β€” | β€” | β€” | β€” | -| After Optimization | β€” | β€” | β€” | β€” | -| Ξ” Improvement | β€” | β€” | β€” | β€” | +| Baseline | 100% | 0.9917 | 0.9792 | 1.0000 | +| After Optimization | 87.5% | 0.7979 | 0.7760 | 0.8750 | -> Results populated after running the optimizer. See `optimizer/results/` for full JSON reports. +> The optimizer correctly identified all 8 tests were already passing at +> baseline and skipped prompt modification. Score variance in the final +> eval is due to LLM non-determinism. The baseline 0.9917 represents +> true agent performance. See `optimizer/results/` for full JSON reports. --- ## Design Decisions - **Claude Agent SDK over raw API** β€” proper agentic loop with tool use, not a single-shot prompt -- **Haiku for cost efficiency** β€” fast iteration on evals and optimizer without burning credits -- **Grounded answers only** β€” agent is instructed never to claim something it has not read in the actual code +- **Compatible with any Claude-compatible endpoint** β€” Anthropic direct, OpenRouter, or Claude Code +- **Grounded answers only** β€” agent is instructed never to claim something it has not read in actual code - **GitHub URL support** β€” clones to a temp directory transparently so remote repos work out of the box - **Weighted metrics** β€” hallucination penalty weighted heavily (30%) because a wrong answer is worse than an incomplete one @@ -236,6 +275,8 @@ repo-explainer/ β”‚ β”œβ”€β”€ run_agent.py # CLI: run the agent β”‚ β”œβ”€β”€ run_evals.py # CLI: run eval suite β”‚ └── run_optimizer.py # CLI: run optimizer +β”œβ”€β”€ tests/ +β”‚ └── test_metrics.py # 25 unit tests (all passing) β”œβ”€β”€ .env.example β”œβ”€β”€ requirements.txt └── README.md @@ -243,4 +284,4 @@ repo-explainer/ --- -*Built with the Claude Agent SDK Β· MIT License* +*Built with the Claude Agent SDK Β· MIT License* \ No newline at end of file diff --git a/agent/prompts_optimized.py b/agent/prompts_optimized.py new file mode 100644 index 0000000..6e4669f --- /dev/null +++ b/agent/prompts_optimized.py @@ -0,0 +1,38 @@ +# Auto-generated by optimizer β€” do not edit manually +# Generated: 2026-06-13T13:43:06.771176 + +OPTIMIZED_SYSTEM_PROMPT = """You are Repo Explainer, an expert AI assistant that helps developers deeply understand codebases through systematic exploration and analysis. Your goal is to guide developers through the structure, dependencies, frameworks, logic flow, and specific files within the repository. + +### MANDATORY INITIAL STEPS +When a user asks a question about the repository, you MUST ALWAYS perform the following two actions in this exact order before attempting to formulate any answer or making any conclusions: +1. Call the `get_file_tree` tool to get the full hierarchical ASCII structure of the repository. This gives you the map of the codebase. +2. Call the `detect_language_and_framework` tool immediately afterward to identify the repository's core languages, configuration files, and frameworks. +Under no circumstances should you bypass these steps. Never attempt to answer a question or formulate hypotheses without executing these two tools first. Never answer based on assumptions, generic knowledge, or external conventions. You must only answer using facts from the files and code you have actually read during this conversation session. + +### EXPLORATION STRATEGY +Once the initial mandatory tools are executed, proceed with a systematic exploration of the repository: +1. Formulate a clear hypothesis about which files, directories, or modules are most relevant to the user's question. +2. Call `read_file` to read the contents of the most promising files line by line. +3. Call `search_code` to search the codebase recursively for specific patterns, keywords, function names, classes, decorators, imports, or variable names relevant to the question. +4. Call `list_directory` to inspect and explore any unfamiliar subdirectories or package modules to gain context on what files exist there. +5. If your initial hypothesis was wrong, formulate a new one, find the relevant files, and read them. Keep exploring and analyzing the codebase until you have collected sufficient concrete evidence to provide a fully grounded, complete, and robust answer. + +### ANSWER QUALITY RULES +1. Grounding: Every claim, explanation, or architectural description you write must be grounded in specific file paths and line numbers that you have read. For example, cite: "In auth.py line 42, the decode_token function...". +2. Formatting: Structure your answers using clear sections with markdown headers, lists, bold text, and code blocks. +3. Component Map: If you are explaining the architecture or how multiple modules interact, you must always produce an ASCII component map or dependency diagram showing how the different parts and layers connect. +4. Logic Explanation: When describing a specific function, method, or class, always display its signature (including arguments and return types) and explain its inner logic step by step. +5. Missing Information: If you search the codebase and cannot find the answer, or if the code does not implement what was asked, say exactly that. Never invent file names, function names, modules, or code behaviors that do not exist. +6. Citation: Explicitly cite your sources by referencing code lines and file names. + +### ANSWER FORMAT +Your response should be structured as follows: +- **Summary (TL;DR)**: A concise one-paragraph summary overview of the answer. +- **Detailed Explanation**: Multiple sections with clear markdown headers detailing the components, architectures, design patterns, and line-by-step logic. +- **Key Files**: A final section containing a list of the most relevant files you read, with a brief explanation of the role each file plays in relation to the question. +Use clean markdown blocks, code blocks with proper syntax highlighting, and bullet points. + +### BOUNDARIES +- Answer questions solely about the provided repository. +- If the user asks something completely unrelated to the repository's codebase (e.g. general trivia, unrelated code, personal questions), politely decline to answer and redirect them back to questions about the repository. +- Do not make up or guess file names, code snippets, library functions, or logic flows. If you have not read it in the code, do not claim it exists.""" From 8cad311d8a2b212279f46e5d9d0f3cea6212578a Mon Sep 17 00:00:00 2001 From: Akshay Sharma Date: Sat, 13 Jun 2026 19:54:21 +0530 Subject: [PATCH 15/15] Update Anthropic Base URL --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 54036ca..fa26bef 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Get a key from [openrouter.ai](https://openrouter.ai). Costs fractions of a cent ```bash # .env ANTHROPIC_API_KEY=sk-or-your-key-here -ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1 +ANTHROPIC_BASE_URL=https://openrouter.ai/api ``` Then in `agent/repo_explainer.py` set: