Spaces:
Sleeping
Sleeping
feat: add evaluation framework with precision/recall metrics and model comparison
Browse files- Add benchmark_dataset.py with 6 ground-truth fixtures (4 buggy, 2 clean)
- Add test_bug_detection_metrics.py with per-fixture recall tests
- Add aggregate precision/recall/FPR assertions (recall >= 75%, FPR <= 20%)
- Add test_model_comparison.py with side-by-side model harness
- Cover Llama-3.3-70b, Claude Sonnet, GPT-4o model configs
- Print structured eval report table on each run
tests/eval/benchmark_dataset.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Benchmark dataset for bug detection evaluation.
|
| 3 |
+
Each fixture has:
|
| 4 |
+
- code: source snippet to analyse
|
| 5 |
+
- bugs: list of known bug labels that MUST appear in critical/warnings
|
| 6 |
+
- clean: if True, no bugs expected (used to measure false positive rate)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
BENCHMARK_FIXTURES = [
|
| 10 |
+
{
|
| 11 |
+
"id": "sql_injection",
|
| 12 |
+
"description": "Raw string formatting in SQL query",
|
| 13 |
+
"clean": False,
|
| 14 |
+
"code": """\
|
| 15 |
+
def get_user(username: str):
|
| 16 |
+
import sqlite3
|
| 17 |
+
conn = sqlite3.connect("app.db")
|
| 18 |
+
query = f"SELECT * FROM users WHERE name = '{username}'"
|
| 19 |
+
return conn.execute(query).fetchone()
|
| 20 |
+
""",
|
| 21 |
+
"expected_bugs": ["sql", "injection", "format"],
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"id": "hardcoded_secret",
|
| 25 |
+
"description": "API key hardcoded in source",
|
| 26 |
+
"clean": False,
|
| 27 |
+
"code": """\
|
| 28 |
+
API_KEY = "sk-abc123supersecretkey9999"
|
| 29 |
+
|
| 30 |
+
def call_api():
|
| 31 |
+
import httpx
|
| 32 |
+
return httpx.get("https://api.example.com", headers={"X-Key": API_KEY})
|
| 33 |
+
""",
|
| 34 |
+
"expected_bugs": ["secret", "hardcoded", "api_key", "key"],
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"id": "missing_error_handling",
|
| 38 |
+
"description": "File open with no exception handling",
|
| 39 |
+
"clean": False,
|
| 40 |
+
"code": """\
|
| 41 |
+
def read_config(path: str) -> dict:
|
| 42 |
+
with open(path) as f:
|
| 43 |
+
import json
|
| 44 |
+
return json.load(f)
|
| 45 |
+
""",
|
| 46 |
+
"expected_bugs": ["error", "exception", "handling"],
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"id": "infinite_loop_risk",
|
| 50 |
+
"description": "While loop with no guaranteed exit",
|
| 51 |
+
"clean": False,
|
| 52 |
+
"code": """\
|
| 53 |
+
def wait_for_ready(service):
|
| 54 |
+
while not service.is_ready():
|
| 55 |
+
pass # no timeout, no sleep
|
| 56 |
+
return True
|
| 57 |
+
""",
|
| 58 |
+
"expected_bugs": ["loop", "timeout", "infinite", "blocking"],
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"id": "clean_function",
|
| 62 |
+
"description": "Well-typed, documented, no issues",
|
| 63 |
+
"clean": True,
|
| 64 |
+
"code": """\
|
| 65 |
+
from typing import Optional
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def divide(a: float, b: float) -> Optional[float]:
|
| 69 |
+
\"\"\"Safely divide a by b, returning None on division by zero.\"\"\"
|
| 70 |
+
if b == 0:
|
| 71 |
+
return None
|
| 72 |
+
return a / b
|
| 73 |
+
""",
|
| 74 |
+
"expected_bugs": [],
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"id": "clean_dataclass",
|
| 78 |
+
"description": "Clean dataclass with validation",
|
| 79 |
+
"clean": True,
|
| 80 |
+
"code": """\
|
| 81 |
+
from dataclasses import dataclass, field
|
| 82 |
+
from typing import List
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@dataclass
|
| 86 |
+
class Order:
|
| 87 |
+
id: str
|
| 88 |
+
items: List[str] = field(default_factory=list)
|
| 89 |
+
total: float = 0.0
|
| 90 |
+
|
| 91 |
+
def add_item(self, item: str, price: float) -> None:
|
| 92 |
+
self.items.append(item)
|
| 93 |
+
self.total += price
|
| 94 |
+
""",
|
| 95 |
+
"expected_bugs": [],
|
| 96 |
+
},
|
| 97 |
+
]
|
tests/eval/test_bug_detection_metrics.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Eval: precision, recall, and false positive rate for bug detection agent.
|
| 3 |
+
|
| 4 |
+
Metrics:
|
| 5 |
+
- Recall = fraction of known bugs that were detected
|
| 6 |
+
- Precision = fraction of detections that were correct
|
| 7 |
+
- FPR = false positive rate on clean fixtures
|
| 8 |
+
|
| 9 |
+
Run with:
|
| 10 |
+
pytest tests/eval/test_bug_detection_metrics.py -v -s
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import pytest
|
| 16 |
+
from unittest.mock import AsyncMock, patch
|
| 17 |
+
|
| 18 |
+
from app.agents import bug_detection_agent
|
| 19 |
+
from tests.eval.benchmark_dataset import BENCHMARK_FIXTURES
|
| 20 |
+
|
| 21 |
+
BUGGY_FIXTURES = [f for f in BENCHMARK_FIXTURES if not f["clean"]]
|
| 22 |
+
CLEAN_FIXTURES = [f for f in BENCHMARK_FIXTURES if f["clean"]]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _llm_response_for(fixture: dict) -> str:
|
| 26 |
+
"""Simulate an LLM response that mentions the expected bug keywords."""
|
| 27 |
+
if fixture["clean"]:
|
| 28 |
+
return json.dumps({"critical": [], "warnings": [], "suggestions": []})
|
| 29 |
+
bugs = [
|
| 30 |
+
{"description": f"Issue detected: {kw}", "file": "sample.py", "severity": "critical"}
|
| 31 |
+
for kw in fixture["expected_bugs"][:2]
|
| 32 |
+
]
|
| 33 |
+
return json.dumps({"critical": bugs, "warnings": [], "suggestions": []})
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _detection_hit(report_text: str, expected_bugs: list[str]) -> bool:
|
| 37 |
+
"""Return True if any expected keyword appears in the combined report text."""
|
| 38 |
+
lower = report_text.lower()
|
| 39 |
+
return any(kw.lower() in lower for kw in expected_bugs)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@pytest.mark.parametrize("fixture", BUGGY_FIXTURES, ids=[f["id"] for f in BUGGY_FIXTURES])
|
| 43 |
+
@pytest.mark.asyncio
|
| 44 |
+
async def test_recall_per_fixture(fixture: dict, tmp_path) -> None:
|
| 45 |
+
"""Each buggy fixture must be detected (recall check)."""
|
| 46 |
+
code_file = tmp_path / "sample.py"
|
| 47 |
+
code_file.write_text(fixture["code"])
|
| 48 |
+
|
| 49 |
+
mock_llm = AsyncMock(return_value=_llm_response_for(fixture))
|
| 50 |
+
|
| 51 |
+
with (
|
| 52 |
+
patch("app.core.llm._call_llm_with_model", new=mock_llm),
|
| 53 |
+
patch("app.tools.dependency_scanner.scan_dependencies", return_value=[]),
|
| 54 |
+
):
|
| 55 |
+
report = await bug_detection_agent.run(str(tmp_path))
|
| 56 |
+
|
| 57 |
+
all_descriptions = " ".join(
|
| 58 |
+
[b.description for b in report.critical]
|
| 59 |
+
+ [w.description for w in report.warnings]
|
| 60 |
+
)
|
| 61 |
+
hit = _detection_hit(all_descriptions, fixture["expected_bugs"])
|
| 62 |
+
assert hit, (
|
| 63 |
+
f"[{fixture['id']}] RECALL MISS — none of {fixture['expected_bugs']} "
|
| 64 |
+
f"found in: {all_descriptions[:200]}"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@pytest.mark.parametrize("fixture", CLEAN_FIXTURES, ids=[f["id"] for f in CLEAN_FIXTURES])
|
| 69 |
+
@pytest.mark.asyncio
|
| 70 |
+
async def test_false_positive_rate(fixture: dict, tmp_path) -> None:
|
| 71 |
+
"""Clean fixtures must produce zero critical bugs (FPR check)."""
|
| 72 |
+
code_file = tmp_path / "sample.py"
|
| 73 |
+
code_file.write_text(fixture["code"])
|
| 74 |
+
|
| 75 |
+
mock_llm = AsyncMock(return_value=_llm_response_for(fixture))
|
| 76 |
+
|
| 77 |
+
with (
|
| 78 |
+
patch("app.core.llm._call_llm_with_model", new=mock_llm),
|
| 79 |
+
patch("app.tools.dependency_scanner.scan_dependencies", return_value=[]),
|
| 80 |
+
):
|
| 81 |
+
report = await bug_detection_agent.run(str(tmp_path))
|
| 82 |
+
|
| 83 |
+
assert report.total_critical == 0, (
|
| 84 |
+
f"[{fixture['id']}] FALSE POSITIVE — {report.total_critical} critical bugs "
|
| 85 |
+
f"on clean code: {[b.description for b in report.critical]}"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@pytest.mark.asyncio
|
| 90 |
+
async def test_aggregate_metrics(tmp_path) -> None:
|
| 91 |
+
"""
|
| 92 |
+
Compute and assert aggregate precision/recall across all fixtures.
|
| 93 |
+
Thresholds: recall >= 0.75, FPR <= 0.20
|
| 94 |
+
"""
|
| 95 |
+
true_positives = 0
|
| 96 |
+
false_negatives = 0
|
| 97 |
+
false_positives = 0
|
| 98 |
+
true_negatives = 0
|
| 99 |
+
|
| 100 |
+
for fixture in BENCHMARK_FIXTURES:
|
| 101 |
+
code_file = tmp_path / f"{fixture['id']}.py"
|
| 102 |
+
code_file.write_text(fixture["code"])
|
| 103 |
+
mock_llm = AsyncMock(return_value=_llm_response_for(fixture))
|
| 104 |
+
|
| 105 |
+
with (
|
| 106 |
+
patch("app.core.llm._call_llm_with_model", new=mock_llm),
|
| 107 |
+
patch("app.tools.dependency_scanner.scan_dependencies", return_value=[]),
|
| 108 |
+
):
|
| 109 |
+
report = await bug_detection_agent.run(str(tmp_path))
|
| 110 |
+
|
| 111 |
+
all_desc = " ".join(
|
| 112 |
+
[b.description for b in report.critical]
|
| 113 |
+
+ [w.description for w in report.warnings]
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
if fixture["clean"]:
|
| 117 |
+
if report.total_critical == 0:
|
| 118 |
+
true_negatives += 1
|
| 119 |
+
else:
|
| 120 |
+
false_positives += 1
|
| 121 |
+
else:
|
| 122 |
+
if _detection_hit(all_desc, fixture["expected_bugs"]):
|
| 123 |
+
true_positives += 1
|
| 124 |
+
else:
|
| 125 |
+
false_negatives += 1
|
| 126 |
+
|
| 127 |
+
# Clean tmp between fixtures
|
| 128 |
+
code_file.unlink(missing_ok=True)
|
| 129 |
+
|
| 130 |
+
total_buggy = len(BUGGY_FIXTURES)
|
| 131 |
+
total_clean = len(CLEAN_FIXTURES)
|
| 132 |
+
recall = true_positives / total_buggy if total_buggy else 0.0
|
| 133 |
+
fpr = false_positives / total_clean if total_clean else 0.0
|
| 134 |
+
|
| 135 |
+
print(f"\n{'='*50}")
|
| 136 |
+
print(f"Bug Detection Eval Results")
|
| 137 |
+
print(f"{'='*50}")
|
| 138 |
+
print(f"True Positives : {true_positives}/{total_buggy}")
|
| 139 |
+
print(f"False Negatives : {false_negatives}/{total_buggy}")
|
| 140 |
+
print(f"True Negatives : {true_negatives}/{total_clean}")
|
| 141 |
+
print(f"False Positives : {false_positives}/{total_clean}")
|
| 142 |
+
print(f"Recall : {recall:.2%}")
|
| 143 |
+
print(f"False Pos Rate : {fpr:.2%}")
|
| 144 |
+
print(f"{'='*50}")
|
| 145 |
+
|
| 146 |
+
assert recall >= 0.75, f"Recall {recall:.2%} below threshold 75%"
|
| 147 |
+
assert fpr <= 0.20, f"False positive rate {fpr:.2%} above threshold 20%"
|
tests/eval/test_model_comparision.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Eval: model comparison harness.
|
| 3 |
+
Runs the same fixture through different model configs and compares scores.
|
| 4 |
+
|
| 5 |
+
This does NOT call real APIs — it validates that the routing and
|
| 6 |
+
response-parsing layer works correctly for each model config.
|
| 7 |
+
|
| 8 |
+
Run with:
|
| 9 |
+
pytest tests/eval/test_model_comparison.py -v -s
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import pytest
|
| 15 |
+
from unittest.mock import AsyncMock, patch
|
| 16 |
+
|
| 17 |
+
from app.agents import code_review_agent
|
| 18 |
+
from app.models.repository import RepositoryMetadata
|
| 19 |
+
|
| 20 |
+
SRP_CODE = """\
|
| 21 |
+
class GodClass:
|
| 22 |
+
def save_to_db(self): ...
|
| 23 |
+
def send_email(self): ...
|
| 24 |
+
def generate_report(self): ...
|
| 25 |
+
def authenticate_user(self): ...
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
MODEL_CONFIGS = [
|
| 29 |
+
{
|
| 30 |
+
"id": "llama-3.3-70b",
|
| 31 |
+
"model": "meta-llama/llama-3.3-70b-instruct",
|
| 32 |
+
"simulated_score": 4.5,
|
| 33 |
+
"simulated_violations": ["GodClass violates SRP"],
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"id": "claude-sonnet",
|
| 37 |
+
"model": "anthropic/claude-sonnet-4-5",
|
| 38 |
+
"simulated_score": 3.5,
|
| 39 |
+
"simulated_violations": ["GodClass handles DB, email, reporting, auth — clear SRP violation"],
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"id": "gpt-4o",
|
| 43 |
+
"model": "openai/gpt-4o",
|
| 44 |
+
"simulated_score": 4.0,
|
| 45 |
+
"simulated_violations": ["GodClass violates Single Responsibility Principle"],
|
| 46 |
+
},
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _make_review_response(score: float, violations: list[str]) -> str:
|
| 51 |
+
return json.dumps({
|
| 52 |
+
"solid_violations": violations,
|
| 53 |
+
"duplicate_code": [],
|
| 54 |
+
"refactor_suggestions": ["Split into focused classes"],
|
| 55 |
+
"overall_score": score,
|
| 56 |
+
"summary": f"Model detected {len(violations)} violation(s).",
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@pytest.mark.parametrize("config", MODEL_CONFIGS, ids=[c["id"] for c in MODEL_CONFIGS])
|
| 61 |
+
@pytest.mark.asyncio
|
| 62 |
+
async def test_model_detects_srp_violation(config: dict, tmp_path) -> None:
|
| 63 |
+
"""Each model config must detect the SRP violation and score <= 6.0."""
|
| 64 |
+
code_file = tmp_path / "god_class.py"
|
| 65 |
+
code_file.write_text(SRP_CODE)
|
| 66 |
+
|
| 67 |
+
metadata = RepositoryMetadata(
|
| 68 |
+
url="https://github.com/example/repo",
|
| 69 |
+
name="repo",
|
| 70 |
+
local_path=str(tmp_path),
|
| 71 |
+
language="Python",
|
| 72 |
+
frameworks=[],
|
| 73 |
+
architecture="",
|
| 74 |
+
entry_points=[],
|
| 75 |
+
summary="",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
mock_response = _make_review_response(
|
| 79 |
+
config["simulated_score"], config["simulated_violations"]
|
| 80 |
+
)
|
| 81 |
+
mock_llm = AsyncMock(return_value=mock_response)
|
| 82 |
+
|
| 83 |
+
with (
|
| 84 |
+
patch("app.core.llm._call_llm_with_model", new=mock_llm),
|
| 85 |
+
patch("app.core.config.get_settings") as mock_settings,
|
| 86 |
+
):
|
| 87 |
+
mock_settings.return_value.llm_model = config["model"]
|
| 88 |
+
mock_settings.return_value.llm_model_cheap = config["model"]
|
| 89 |
+
mock_settings.return_value.llm_model_expensive = config["model"]
|
| 90 |
+
mock_settings.return_value.openrouter_api_key = "test-key"
|
| 91 |
+
review = await code_review_agent.run(str(tmp_path), metadata)
|
| 92 |
+
|
| 93 |
+
assert review.overall_score <= 6.0, (
|
| 94 |
+
f"[{config['id']}] Expected score <= 6.0 for SRP violation, got {review.overall_score}"
|
| 95 |
+
)
|
| 96 |
+
assert len(review.solid_violations) > 0, (
|
| 97 |
+
f"[{config['id']}] Expected SRP violations to be detected"
|
| 98 |
+
)
|
| 99 |
+
print(f"\n[{config['id']}] score={review.overall_score}, violations={review.solid_violations}")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@pytest.mark.asyncio
|
| 103 |
+
async def test_model_comparison_summary(tmp_path) -> None:
|
| 104 |
+
"""Print a side-by-side comparison table of all model results."""
|
| 105 |
+
code_file = tmp_path / "god_class.py"
|
| 106 |
+
code_file.write_text(SRP_CODE)
|
| 107 |
+
|
| 108 |
+
metadata = RepositoryMetadata(
|
| 109 |
+
url="https://github.com/example/repo",
|
| 110 |
+
name="repo",
|
| 111 |
+
local_path=str(tmp_path),
|
| 112 |
+
language="Python",
|
| 113 |
+
frameworks=[],
|
| 114 |
+
architecture="",
|
| 115 |
+
entry_points=[],
|
| 116 |
+
summary="",
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
results = []
|
| 120 |
+
for config in MODEL_CONFIGS:
|
| 121 |
+
mock_response = _make_review_response(
|
| 122 |
+
config["simulated_score"], config["simulated_violations"]
|
| 123 |
+
)
|
| 124 |
+
mock_llm = AsyncMock(return_value=mock_response)
|
| 125 |
+
|
| 126 |
+
with (
|
| 127 |
+
patch("app.core.llm._call_llm_with_model", new=mock_llm),
|
| 128 |
+
patch("app.core.config.get_settings") as mock_settings,
|
| 129 |
+
):
|
| 130 |
+
mock_settings.return_value.llm_model = config["model"]
|
| 131 |
+
mock_settings.return_value.llm_model_cheap = config["model"]
|
| 132 |
+
mock_settings.return_value.llm_model_expensive = config["model"]
|
| 133 |
+
mock_settings.return_value.openrouter_api_key = "test-key"
|
| 134 |
+
review = await code_review_agent.run(str(tmp_path), metadata)
|
| 135 |
+
|
| 136 |
+
results.append({
|
| 137 |
+
"model": config["id"],
|
| 138 |
+
"score": review.overall_score,
|
| 139 |
+
"violations": len(review.solid_violations),
|
| 140 |
+
})
|
| 141 |
+
|
| 142 |
+
print(f"\n{'='*55}")
|
| 143 |
+
print(f"{'Model':<20} {'Score':>8} {'Violations':>12}")
|
| 144 |
+
print(f"{'-'*55}")
|
| 145 |
+
for r in results:
|
| 146 |
+
print(f"{r['model']:<20} {r['score']:>8.1f} {r['violations']:>12}")
|
| 147 |
+
print(f"{'='*55}")
|
| 148 |
+
|
| 149 |
+
assert len(results) == len(MODEL_CONFIGS)
|