diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml new file mode 100644 index 0000000..81d28b5 --- /dev/null +++ b/.github/workflows/eval.yml @@ -0,0 +1,41 @@ +name: Eval + +on: + workflow_dispatch: + inputs: + case: + description: "Run a specific case (leave empty for all)" + required: false + default: "" + +permissions: + contents: read + +jobs: + eval: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + - run: uv python install 3.12 + - run: uv sync --extra dev + + - name: Run evals + env: + MODEL_API_BASE: ${{ secrets.MODEL_API_BASE }} + MODEL_API_KEY: ${{ secrets.MODEL_API_KEY }} + MODEL_NAME: ${{ secrets.MODEL_NAME }} + run: | + args="--verbose" + if [ -n "${{ inputs.case }}" ]; then + args="$args --case ${{ inputs.case }}" + fi + uv run python evals/run.py $args + + - name: Upload results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: eval-results + path: evals/results/ + if-no-files-found: ignore diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 0000000..27e62c5 --- /dev/null +++ b/evals/README.md @@ -0,0 +1,62 @@ +# Evaluation Harness + +Fixture-based evaluation for code-to-docs prompt quality. Tests run against +a real LLM endpoint and check assertions rather than comparing golden text. + +## Running + +```bash +# Set endpoint credentials +export MODEL_API_BASE="https://your-endpoint/v1" +export MODEL_API_KEY="your-key" +export MODEL_NAME="your-model" + +# Run all cases +uv run python evals/run.py + +# Run a single case +uv run python evals/run.py --case issue-52-deletion --verbose +``` + +## Adding a Case + +Create a directory under `evals/fixtures/` with: + +``` +evals/fixtures/my-case/ + diff.patch # The code diff to analyze + before/ # Doc files in their original state + docs/guide.md + docs/api.md + expectations.yaml # Assertions to check + instructions.txt # (optional) User instructions +``` + +### expectations.yaml format + +```yaml +# Files that should be updated (not return NO_UPDATE_NEEDED) +selected: + - docs/guide.md + +# Files that should NOT be updated +not_selected: + - docs/unrelated.md + +# Content checks on the generated output +content_checks: + docs/guide.md: + contains: + - "new-flag" + not_contains: + - "deleted heading" + heading_present: + - "## Configuration" + +# If true, all files should return NO_UPDATE_NEEDED +expect_no_update: false +``` + +Assertions are preferred over golden files because model output varies +across runs and model versions. Check for structural properties (headings +present, keywords included, sections preserved) rather than exact text. diff --git a/evals/fixtures/cli-reference-update/before/docs/cli.md b/evals/fixtures/cli-reference-update/before/docs/cli.md new file mode 100644 index 0000000..cde85f1 --- /dev/null +++ b/evals/fixtures/cli-reference-update/before/docs/cli.md @@ -0,0 +1,24 @@ +# CLI Reference + +## Usage + +``` +mytool [options] +``` + +## Options + +| Flag | Description | +|------|-------------| +| `--verbose`, `-v` | Enable verbose output | +| `--help` | Show help message | + +## Examples + +```bash +# Basic usage +mytool data.csv + +# Verbose mode +mytool -v data.csv +``` diff --git a/evals/fixtures/cli-reference-update/diff.patch b/evals/fixtures/cli-reference-update/diff.patch new file mode 100644 index 0000000..e9eac2f --- /dev/null +++ b/evals/fixtures/cli-reference-update/diff.patch @@ -0,0 +1,16 @@ +diff --git a/src/cli.py b/src/cli.py +index 1112222..3334444 100644 +--- a/src/cli.py ++++ b/src/cli.py +@@ -35,6 +35,12 @@ def build_parser(): + "--verbose", "-v", + action="store_true", + help="Enable verbose output", + ) ++ parser.add_argument( ++ "--output-format", ++ choices=["json", "text", "csv"], ++ default="text", ++ help="Output format for results (default: text)", ++ ) + return parser diff --git a/evals/fixtures/cli-reference-update/expectations.yaml b/evals/fixtures/cli-reference-update/expectations.yaml new file mode 100644 index 0000000..12bdd8e --- /dev/null +++ b/evals/fixtures/cli-reference-update/expectations.yaml @@ -0,0 +1,12 @@ +selected: + - docs/cli.md + +content_checks: + docs/cli.md: + contains: + - "--output-format" + - "json" + - "csv" + heading_present: + - "# CLI Reference" + - "## Options" diff --git a/evals/fixtures/issue-52-deletion/before/docs/auth-guide.md b/evals/fixtures/issue-52-deletion/before/docs/auth-guide.md new file mode 100644 index 0000000..a4ef2e8 --- /dev/null +++ b/evals/fixtures/issue-52-deletion/before/docs/auth-guide.md @@ -0,0 +1,42 @@ +# Authentication Guide + +## Overview + +This guide covers the authentication system used by the application. + +## Token Validation + +Use `AuthManager.validate_token()` to check whether a JWT token is valid: + +```python +auth = AuthManager() +if auth.validate_token(token): + print("Token is valid") +``` + +## Rate Limiting + +The auth system enforces rate limits on token validation requests. +By default, each client is limited to 100 validations per minute. + +To configure: + +```python +auth = AuthManager(rate_limit=200) +``` + +## Error Handling + +When validation fails, the system logs the failure reason. Common causes: + +- Expired token +- Invalid signature +- Malformed payload + +## Troubleshooting + +If you encounter persistent validation failures, check: + +1. Clock synchronization between services +2. Key rotation schedule +3. Token issuer configuration diff --git a/evals/fixtures/issue-52-deletion/diff.patch b/evals/fixtures/issue-52-deletion/diff.patch new file mode 100644 index 0000000..9ec4dc5 --- /dev/null +++ b/evals/fixtures/issue-52-deletion/diff.patch @@ -0,0 +1,19 @@ +diff --git a/src/auth.py b/src/auth.py +index aaa1111..bbb2222 100644 +--- a/src/auth.py ++++ b/src/auth.py +@@ -45,6 +45,15 @@ class AuthManager: + def validate_token(self, token): + """Validate a JWT token.""" + return self._decode(token) is not None ++ ++ def refresh_token(self, token): ++ """Refresh an expired token. ++ ++ Returns a new token with an extended expiry, or None if the ++ original token is invalid. ++ """ ++ payload = self._decode(token, allow_expired=True) ++ if payload: ++ return self._encode(payload) ++ return None diff --git a/evals/fixtures/issue-52-deletion/expectations.yaml b/evals/fixtures/issue-52-deletion/expectations.yaml new file mode 100644 index 0000000..d0ca8af --- /dev/null +++ b/evals/fixtures/issue-52-deletion/expectations.yaml @@ -0,0 +1,12 @@ +selected: + - docs/auth-guide.md + +content_checks: + docs/auth-guide.md: + contains: + - "refresh_token" + # These sections must survive; the model should not delete them + heading_present: + - "## Rate Limiting" + - "## Error Handling" + - "## Troubleshooting" diff --git a/evals/fixtures/no-update-needed/before/docs/api-reference.md b/evals/fixtures/no-update-needed/before/docs/api-reference.md new file mode 100644 index 0000000..fa7947a --- /dev/null +++ b/evals/fixtures/no-update-needed/before/docs/api-reference.md @@ -0,0 +1,23 @@ +# API Reference + +## Authentication + +### POST /auth/login + +Authenticates a user and returns a JWT token. + +**Request body:** +```json +{"username": "admin", "password": "secret"} +``` + +**Response:** +```json +{"token": "eyJ..."} +``` + +## Users + +### GET /users + +Returns a list of all users. diff --git a/evals/fixtures/no-update-needed/diff.patch b/evals/fixtures/no-update-needed/diff.patch new file mode 100644 index 0000000..a39bf95 --- /dev/null +++ b/evals/fixtures/no-update-needed/diff.patch @@ -0,0 +1,14 @@ +diff --git a/src/internal/cache.py b/src/internal/cache.py +index eee5555..fff6666 100644 +--- a/src/internal/cache.py ++++ b/src/internal/cache.py +@@ -22,7 +22,7 @@ class LRUCache: + def get(self, key): + if key in self._store: + self._hits += 1 +- return self._store[key] ++ value = self._store.pop(key) ++ self._store[key] = value # move to end ++ return value + self._misses += 1 + return None diff --git a/evals/fixtures/no-update-needed/expectations.yaml b/evals/fixtures/no-update-needed/expectations.yaml new file mode 100644 index 0000000..db2da3a --- /dev/null +++ b/evals/fixtures/no-update-needed/expectations.yaml @@ -0,0 +1,3 @@ +# The diff changes an internal cache implementation detail. +# The API reference doc has nothing to do with it. +expect_no_update: true diff --git a/evals/fixtures/short-doc-legitimate-edit/before/docs/config.md b/evals/fixtures/short-doc-legitimate-edit/before/docs/config.md new file mode 100644 index 0000000..42ff437 --- /dev/null +++ b/evals/fixtures/short-doc-legitimate-edit/before/docs/config.md @@ -0,0 +1,9 @@ +# Configuration + +## Default Settings + +| Key | Default | Description | +|-----|---------|-------------| +| `timeout` | `30` | Request timeout in seconds | +| `retries` | `3` | Number of retry attempts | +| `log_level` | `INFO` | Logging verbosity | diff --git a/evals/fixtures/short-doc-legitimate-edit/diff.patch b/evals/fixtures/short-doc-legitimate-edit/diff.patch new file mode 100644 index 0000000..60ea721 --- /dev/null +++ b/evals/fixtures/short-doc-legitimate-edit/diff.patch @@ -0,0 +1,10 @@ +diff --git a/src/config.py b/src/config.py +index ccc3333..ddd4444 100644 +--- a/src/config.py ++++ b/src/config.py +@@ -10,6 +10,7 @@ DEFAULTS = { + "timeout": 30, + "retries": 3, + "log_level": "INFO", ++ "max_connections": 50, + } diff --git a/evals/fixtures/short-doc-legitimate-edit/expectations.yaml b/evals/fixtures/short-doc-legitimate-edit/expectations.yaml new file mode 100644 index 0000000..7d8636b --- /dev/null +++ b/evals/fixtures/short-doc-legitimate-edit/expectations.yaml @@ -0,0 +1,9 @@ +selected: + - docs/config.md + +content_checks: + docs/config.md: + contains: + - "max_connections" + heading_present: + - "# Configuration" diff --git a/evals/run.py b/evals/run.py new file mode 100644 index 0000000..741f94b --- /dev/null +++ b/evals/run.py @@ -0,0 +1,187 @@ +"""Fixture-based evaluation harness for code-to-docs prompt quality. + +Runs test cases against a real LLM endpoint and checks assertions +rather than comparing golden text. Requires MODEL_API_BASE, MODEL_API_KEY, +and MODEL_NAME environment variables. + +Usage: + uv run python evals/run.py [--case CASE_NAME] [--verbose] +""" + +import argparse +import os +import sys +import time +from pathlib import Path + +import yaml + +# Add src/ to path so we can import the action's modules +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + + +def discover_cases(fixtures_dir, filter_name=None): + """Find all fixture cases in the fixtures directory.""" + cases = [] + for case_dir in sorted(Path(fixtures_dir).iterdir()): + if not case_dir.is_dir(): + continue + if filter_name and case_dir.name != filter_name: + continue + expectations_file = case_dir / "expectations.yaml" + diff_file = case_dir / "diff.patch" + if not expectations_file.exists() or not diff_file.exists(): + print(f" Skipping {case_dir.name}: missing expectations.yaml or diff.patch") + continue + cases.append(case_dir) + return cases + + +def load_case(case_dir): + """Load a single test case from a fixture directory.""" + diff = (case_dir / "diff.patch").read_text(encoding="utf-8") + expectations = yaml.safe_load((case_dir / "expectations.yaml").read_text(encoding="utf-8")) + + instructions_file = case_dir / "instructions.txt" + instructions = "" + if instructions_file.exists(): + instructions = instructions_file.read_text(encoding="utf-8").strip() + + before_dir = case_dir / "before" + doc_files = {} + if before_dir.exists(): + for doc in before_dir.rglob("*"): + if doc.is_file() and doc.suffix in (".md", ".rst", ".adoc"): + rel = str(doc.relative_to(before_dir)) + doc_files[rel] = doc.read_text(encoding="utf-8") + + return { + "name": case_dir.name, + "diff": diff, + "instructions": instructions, + "doc_files": doc_files, + "expectations": expectations, + } + + +def run_case(case, verbose=False): + """Run a single test case and return (passed, failures, token_count).""" + from generation import ask_ai_for_updated_content + + expectations = case["expectations"] + failures = [] + total_tokens = 0 + + selected = expectations.get("selected", []) + not_selected = expectations.get("not_selected", []) + content_checks = expectations.get("content_checks", {}) + expect_no_update = expectations.get("expect_no_update", False) + + results = {} + for file_path, content in case["doc_files"].items(): + if verbose: + print(f" Generating update for {file_path}...") + start = time.time() + updated = ask_ai_for_updated_content( + case["diff"], + file_path, + content, + user_instructions=case["instructions"], + skip_verification=True, + ) + elapsed = time.time() - start + if verbose: + print(f" {file_path}: {elapsed:.1f}s") + + is_no_update = updated.strip() == "NO_UPDATE_NEEDED" + results[file_path] = {"updated": updated, "no_update": is_no_update} + + for f in selected: + if f in results and results[f]["no_update"]: + failures.append(f"Expected {f} to be updated, got NO_UPDATE_NEEDED") + + for f in not_selected: + if f in results and not results[f]["no_update"]: + failures.append(f"Expected {f} to return NO_UPDATE_NEEDED, but it was updated") + + if expect_no_update: + for f, r in results.items(): + if not r["no_update"]: + failures.append(f"Expected NO_UPDATE_NEEDED for {f}, but it was updated") + + for file_path, checks in content_checks.items(): + if file_path not in results or results[file_path]["no_update"]: + failures.append(f"Cannot check content of {file_path}: not updated") + continue + content = results[file_path]["updated"] + + for phrase in checks.get("contains", []): + if phrase not in content: + failures.append(f"{file_path}: expected to contain '{phrase}'") + + for phrase in checks.get("not_contains", []): + if phrase in content: + failures.append(f"{file_path}: should not contain '{phrase}'") + + for heading in checks.get("heading_present", []): + if heading not in content: + failures.append(f"{file_path}: expected heading '{heading}' to be present") + + return len(failures) == 0, failures, total_tokens + + +def main(): + parser = argparse.ArgumentParser(description="Run code-to-docs eval fixtures") + parser.add_argument("--case", help="Run only this case") + parser.add_argument("--verbose", "-v", action="store_true") + args = parser.parse_args() + + for var in ("MODEL_API_BASE", "MODEL_NAME"): + if not os.environ.get(var): + print(f"Error: {var} environment variable is required") + sys.exit(1) + + fixtures_dir = Path(__file__).parent / "fixtures" + cases = discover_cases(fixtures_dir, args.case) + + if not cases: + print("No fixture cases found.") + sys.exit(1) + + print(f"Found {len(cases)} case(s)\n") + + results_table = [] + for case_dir in cases: + case = load_case(case_dir) + print(f" Running: {case['name']}...") + try: + passed, failures, tokens = run_case(case, verbose=args.verbose) + status = "PASS" if passed else "FAIL" + results_table.append((case["name"], status, failures)) + if not passed and args.verbose: + for f in failures: + print(f" FAIL: {f}") + except Exception as e: + results_table.append((case["name"], "ERROR", [str(e)])) + if args.verbose: + print(f" ERROR: {e}") + + print("\n" + "=" * 60) + print(f"{'Case':<35} {'Result':<10}") + print("-" * 60) + for name, status, failures in results_table: + print(f"{name:<35} {status:<10}") + if status == "FAIL": + for f in failures: + print(f" - {f}") + print("=" * 60) + + passed = sum(1 for _, s, _ in results_table if s == "PASS") + total = len(results_table) + print(f"\n{passed}/{total} passed") + + sys.exit(0 if passed == total else 1) + + +if __name__ == "__main__": + main()