Skip to content

Commit 8c1bc82

Browse files
Add GEVAL readme (#484)
добавил метрику deepeval, захардкодил дефолтные параметры запускается одной командой python -m tests.integration.run_benchmark каждый прогон создает файлик run_YYYYMMDD_HHMMSS.csv в папку benchmark_results. скрипт результат красивой табличкой выдает в консоль + сохраняет в логах всё. реализовал пока отдельной папкой, как удобнее сделать не знаю
1 parent c619526 commit 8c1bc82

1 file changed

Lines changed: 384 additions & 0 deletions

File tree

tests/integration/run_benchmark.py

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
import asyncio
2+
import json
3+
import logging
4+
import os
5+
import shutil
6+
import sys
7+
import time
8+
from concurrent.futures import ProcessPoolExecutor, as_completed
9+
from typing import Any, Optional, Type
10+
11+
import pandas as pd
12+
import requests
13+
from pandas import DataFrame
14+
from pydantic import BaseModel
15+
16+
from deepeval.metrics import GEval
17+
from deepeval.models.base_model import DeepEvalBaseLLM
18+
from deepeval.test_case import LLMTestCase, LLMTestCaseParams
19+
20+
from osa_tool.config.settings import ConfigManager
21+
from osa_tool.core.git.git_agent import GitHubAgent, GitLabAgent, GitverseAgent
22+
from osa_tool.core.git.metadata import RepositoryMetadata
23+
from osa_tool.operations.docs.readme_generation.readme_agent import ReadmeAgent
24+
from osa_tool.tools.repository_analysis.sourcerank import SourceRank
25+
from osa_tool.utils.arguments_parser import build_parser_from_yaml
26+
from osa_tool.utils.logger import logger
27+
from osa_tool.utils.utils import delete_repository, format_time, parse_git_url, rich_section
28+
29+
README_QUALITY_CRITERIA = """
30+
Determine whether the AI-generated Readme file (ACTUAL_OUTPUT)
31+
is better than the original one (EXPECTED_OUTPUT).
32+
ACTUAL_OUTPUT contains two fields: 'readme', which contains the generated README itself,
33+
and 'repo_structure' which is json with repository's structure.
34+
Generated README's content must be consistent with the provided repository structure.
35+
The ACTUAL_OUTPUT does not necessary have to be the same as EXPECTED_OUTPUT,
36+
Your goal is to determine which text is better, using the provided Evaluations steps.
37+
Readme structure does not matter much as long as it passes the evaluation steps.
38+
"""
39+
40+
README_QUALITY_STEPS = [
41+
"Step 1: Does the provided structure of the repository address README content?",
42+
"Step 2: Does the README provide a clear and accurate overview of the repository's purpose?",
43+
"Step 3: Are installation and setup instructions included and easy to follow?",
44+
"Step 4: Are usage examples provided and do they clearly demonstrate functionality?",
45+
"Step 5: Are dependencies or requirements listed appropriately?",
46+
"Step 6: Is the README easy to read, well-structured, and free of confusing language?",
47+
]
48+
49+
50+
def _strip_markdown_json_fence(text: str) -> str:
51+
cleaned = text.strip()
52+
if not cleaned.startswith("```"):
53+
return cleaned
54+
cleaned = cleaned.removeprefix("```").strip()
55+
if cleaned.lower().startswith("json"):
56+
cleaned = cleaned[4:].lstrip()
57+
if "```" in cleaned:
58+
cleaned = cleaned.split("```", 1)[0].strip()
59+
return cleaned
60+
61+
62+
class CustomLLM(DeepEvalBaseLLM):
63+
def __init__(
64+
self,
65+
api: str = "openrouter",
66+
model: str = "gpt-4.1",
67+
url: str = "https://openrouter.ai/api/v1",
68+
*,
69+
max_tokens: int = 1024,
70+
request_timeout: float = 180.0,
71+
use_json_object_mode: bool = True,
72+
):
73+
self.api = api
74+
self.model_name = model
75+
self.url = url.rstrip("/")
76+
self.max_tokens = max_tokens
77+
self.request_timeout = request_timeout
78+
self.use_json_object_mode = use_json_object_mode
79+
80+
def load_model(self):
81+
return self
82+
83+
def supports_json_mode(self) -> bool:
84+
return True
85+
86+
def get_model_name(self) -> str:
87+
return self.model_name
88+
89+
def _api_key(self) -> str:
90+
api = (self.api or "").lower().strip()
91+
url = (self.url or "").lower()
92+
openrouter_key = os.getenv("OPENROUTER_API_KEY", "")
93+
openai_key = os.getenv("OPENAI_API_KEY", "")
94+
service_key = os.getenv("LLM_SERVICE_KEY", "")
95+
if api == "openrouter" or "openrouter.ai" in url:
96+
return openrouter_key or openai_key or service_key
97+
if api == "openai":
98+
return openai_key or openrouter_key or service_key
99+
return openrouter_key or openai_key or service_key
100+
101+
def _headers(self) -> dict[str, str]:
102+
key = self._api_key()
103+
if not key:
104+
return {}
105+
headers = {
106+
"Authorization": f"Bearer {key}",
107+
"Content-Type": "application/json",
108+
}
109+
if "openrouter.ai" in self.url.lower():
110+
headers["HTTP-Referer"] = "https://github.com/aimclub/OSA"
111+
headers["X-Title"] = "OSA README benchmark"
112+
return headers
113+
114+
def _post_chat(self, messages: list[dict[str, str]], *, response_format: Optional[dict[str, str]] = None) -> str:
115+
headers = self._headers()
116+
if not headers:
117+
raise RuntimeError("Missing judge API key. Set OPENROUTER_API_KEY, OPENAI_API_KEY, or LLM_SERVICE_KEY.")
118+
payload: dict[str, Any] = {
119+
"model": self.model_name,
120+
"messages": messages,
121+
"max_tokens": self.max_tokens,
122+
"temperature": 0.0,
123+
}
124+
if response_format and self.use_json_object_mode:
125+
payload["response_format"] = response_format
126+
127+
response = requests.post(
128+
f"{self.url}/chat/completions", headers=headers, json=payload, timeout=self.request_timeout
129+
)
130+
if response.status_code == 200:
131+
return (response.json()["choices"][0]["message"]["content"] or "").strip()
132+
133+
if response_format and self.use_json_object_mode:
134+
payload.pop("response_format", None)
135+
response = requests.post(
136+
f"{self.url}/chat/completions", headers=headers, json=payload, timeout=self.request_timeout
137+
)
138+
if response.status_code == 200:
139+
return (response.json()["choices"][0]["message"]["content"] or "").strip()
140+
141+
raise RuntimeError(f"Judge LLM HTTP {response.status_code}: {response.text[:500]}")
142+
143+
def generate(self, prompt: str) -> str:
144+
return self._post_chat([{"role": "user", "content": prompt}])
145+
146+
async def a_generate(self, prompt: str, schema=None):
147+
return await asyncio.to_thread(self.generate, prompt)
148+
149+
150+
def generate_readme(config_manager: ConfigManager, metadata: RepositoryMetadata, args, safe_name: str) -> str:
151+
readmes_dir = os.path.join(os.path.dirname(args.table_path), "readmes")
152+
os.makedirs(readmes_dir, exist_ok=True)
153+
154+
readme_agent = ReadmeAgent(config_manager=config_manager, metadata=metadata)
155+
dest_path = os.path.join(readmes_dir, f"{safe_name}_README.md")
156+
readme_agent.file_to_save = dest_path
157+
158+
readme_agent.generate_readme()
159+
160+
src = os.path.join(readme_agent.repo_path, "README.md")
161+
if os.path.isfile(src):
162+
shutil.copy2(src, dest_path)
163+
else:
164+
logger.warning(f"README not found at clone path after generation: {src}")
165+
166+
return dest_path
167+
168+
169+
def get_repo_structure_json(repo_path: str) -> str:
170+
"""Gathers a simple repository structure for GEVAL."""
171+
tree = []
172+
for root, dirs, files in os.walk(repo_path):
173+
if ".git" in dirs:
174+
dirs.remove(".git")
175+
rel_path = os.path.relpath(root, repo_path)
176+
tree.append({"dir": rel_path if rel_path != "." else "/", "files": files})
177+
return json.dumps(tree, ensure_ascii=False)
178+
179+
180+
def process_repository(repo_url: str, args) -> dict:
181+
stage_start = time.time()
182+
183+
repos_dir = os.path.join(os.path.dirname(args.table_path), "repositories")
184+
logs_dir = os.path.join(os.path.dirname(args.table_path), "logs")
185+
os.makedirs(logs_dir, exist_ok=True)
186+
os.makedirs(repos_dir, exist_ok=True)
187+
188+
_, _, repo_name, _ = parse_git_url(repo_url)
189+
190+
url_parts = repo_url.rstrip("/").split("/")
191+
safe_name = f"{url_parts[-2]}_{url_parts[-1]}" if len(url_parts) >= 2 else repo_name
192+
193+
worker_dir = os.path.join(repos_dir, safe_name)
194+
os.makedirs(worker_dir, exist_ok=True)
195+
196+
original_cwd = os.getcwd()
197+
os.chdir(worker_dir)
198+
199+
log_file = os.path.join(logs_dir, f"{safe_name}.log")
200+
201+
logger.setLevel(logging.DEBUG)
202+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
203+
file_handler.setLevel(logging.DEBUG)
204+
file_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
205+
logger.addHandler(file_handler)
206+
207+
result = {"repository": repo_url, "name": repo_name, "status": "Failed", "geval_score": None}
208+
209+
try:
210+
args.repository = repo_url
211+
config_manager = ConfigManager(args)
212+
213+
if not hasattr(config_manager.config, "git"):
214+
config_manager.config.git = type("obj", (object,), {"repository": repo_url})
215+
else:
216+
config_manager.config.git.repository = repo_url
217+
218+
if "github.com" in repo_url:
219+
git_agent = GitHubAgent(repo_url)
220+
elif "gitlab" in repo_url:
221+
git_agent = GitLabAgent(repo_url)
222+
elif "gitverse.ru" in repo_url:
223+
git_agent = GitverseAgent(repo_url)
224+
else:
225+
logger.error(f"Unsupported GIT platform: {repo_url}")
226+
return result
227+
228+
git_agent.clone_repository()
229+
230+
actual_clone_path = os.path.join(worker_dir, repo_name)
231+
232+
expected_output = ""
233+
original_readme_path = os.path.join(actual_clone_path, "README.md")
234+
235+
if not os.path.exists(original_readme_path):
236+
original_readme_path = os.path.join(actual_clone_path, "readme.md")
237+
238+
if os.path.exists(original_readme_path):
239+
with open(original_readme_path, "r", encoding="utf-8", errors="replace") as f:
240+
expected_output = f.read()
241+
242+
repo_structure = get_repo_structure_json(actual_clone_path)
243+
244+
SourceRank(config_manager)
245+
dest_path = generate_readme(config_manager, git_agent.metadata, args, safe_name)
246+
247+
if os.path.exists(dest_path):
248+
result.update({"name": git_agent.metadata.name, "status": "Success"})
249+
logger.info(f"Successfully generated README in {format_time(time.time() - stage_start)}")
250+
251+
logger.info("Starting GEVAL assessment...")
252+
with open(dest_path, "r", encoding="utf-8", errors="replace") as f:
253+
generated_readme = f.read()
254+
255+
judge_model = CustomLLM(api=args.api, model=args.model, url=args.base_url)
256+
metric = GEval(
257+
name="Readme quality",
258+
criteria=README_QUALITY_CRITERIA,
259+
evaluation_steps=README_QUALITY_STEPS,
260+
evaluation_params=[LLMTestCaseParams.ACTUAL_OUTPUT, LLMTestCaseParams.EXPECTED_OUTPUT],
261+
model=judge_model,
262+
verbose_mode=False,
263+
async_mode=False,
264+
)
265+
266+
test_case = LLMTestCase(
267+
input="",
268+
actual_output=json.dumps(
269+
{"readme": generated_readme, "repo_structure": repo_structure}, ensure_ascii=False
270+
),
271+
expected_output=expected_output,
272+
)
273+
274+
try:
275+
metric.measure(test_case)
276+
result["geval_score"] = metric.score
277+
logger.info(f"GEVAL Score: {metric.score}")
278+
except Exception as e:
279+
logger.error(f"GEval metric measurement failed: {e}")
280+
281+
else:
282+
result.update({"name": git_agent.metadata.name, "status": "Failed"})
283+
logger.error(f"Failed to generate README for {git_agent.metadata.name}")
284+
285+
except Exception as e:
286+
logger.error(f"Error processing {repo_url}: {e}")
287+
288+
finally:
289+
file_handler.flush()
290+
file_handler.close()
291+
logger.removeHandler(file_handler)
292+
delete_repository(repo_url)
293+
os.chdir(original_cwd)
294+
shutil.rmtree(worker_dir, ignore_errors=True)
295+
296+
return result
297+
298+
299+
def load_table(table_path: str) -> DataFrame:
300+
if not os.path.isfile(table_path):
301+
test_repos = [
302+
"https://github.com/google/python-fire",
303+
"https://github.com/encode/httpx",
304+
"https://github.com/AntonOsika/gpt-engineer",
305+
"https://github.com/THUDM/ChatGLM-6B",
306+
]
307+
308+
rows = [{"repository": repo, "status": "Pending", "geval_score": None} for repo in test_repos]
309+
df = pd.DataFrame(rows)
310+
df.to_csv(table_path, index=False)
311+
logger.info(f"Created new benchmark run at {table_path} with {len(test_repos)} repos.")
312+
return df
313+
314+
df = pd.read_csv(table_path) if table_path.endswith(".csv") else pd.read_excel(table_path)
315+
316+
if "repository" not in df.columns:
317+
if "repo_url" in df.columns:
318+
df["repository"] = df["repo_url"]
319+
else:
320+
logger.error("Table must contain a 'repository' or 'repo_url' column.")
321+
sys.exit(1)
322+
323+
if "status" not in df.columns:
324+
df["status"] = "Pending"
325+
if "geval_score" not in df.columns:
326+
df["geval_score"] = None
327+
328+
return df
329+
330+
331+
def main():
332+
parser = build_parser_from_yaml(extra_sections=["settings", "arguments", "multi-run"])
333+
args, _ = parser.parse_known_args()
334+
335+
if getattr(args, "table_path", None) is None:
336+
results_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "benchmark_results"))
337+
os.makedirs(results_dir, exist_ok=True)
338+
timestamp = time.strftime("%Y%m%d_%H%M%S")
339+
args.table_path = os.path.join(results_dir, f"run_{timestamp}.csv")
340+
341+
if getattr(args, "api", None) is None:
342+
args.api = "openai"
343+
if getattr(args, "base_url", None) is None:
344+
args.base_url = "https://openrouter.ai/api/v1"
345+
if getattr(args, "model", None) is None:
346+
args.model = "openai/gpt-4.1"
347+
348+
args.table_path = os.path.abspath(args.table_path)
349+
350+
df = load_table(args.table_path)
351+
repos = df["repository"].dropna().tolist()
352+
353+
unprocessed = [r for r in repos if df.loc[df["repository"] == r, "status"].values[0] != "Success"]
354+
355+
if unprocessed:
356+
rich_section(f"Starting lightweight README Generation & GEVAL for {len(unprocessed)} repos")
357+
with ProcessPoolExecutor(max_workers=max(1, os.cpu_count() // 2)) as executor:
358+
futures = {executor.submit(process_repository, repo, args): repo for repo in unprocessed}
359+
for future in as_completed(futures):
360+
repo = futures[future]
361+
try:
362+
res = future.result()
363+
df.loc[df["repository"] == repo, "status"] = res["status"]
364+
df.loc[df["repository"] == repo, "geval_score"] = res.get("geval_score")
365+
366+
if args.table_path.endswith(".csv"):
367+
df.to_csv(args.table_path, index=False)
368+
else:
369+
df.to_excel(args.table_path, index=False)
370+
except Exception as e:
371+
logger.error(f"Failed to process {repo}{e}")
372+
373+
print("\n" + "=" * 90)
374+
print(" FINAL BENCHMARK RESULTS ".center(90, "="))
375+
print("=" * 90)
376+
print(df.to_string(index=False))
377+
print("=" * 90)
378+
print(f"All files (logs, readmes, table) saved to: {os.path.dirname(args.table_path)}\n")
379+
else:
380+
rich_section("All repositories processed successfully.")
381+
382+
383+
if __name__ == "__main__":
384+
main()

0 commit comments

Comments
 (0)