hidevs-eval 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hidevs_eval-0.1.1.dist-info/METADATA +6 -0
- hidevs_eval-0.1.1.dist-info/RECORD +8 -0
- hidevs_eval-0.1.1.dist-info/WHEEL +5 -0
- hidevs_eval-0.1.1.dist-info/entry_points.txt +2 -0
- hidevs_eval-0.1.1.dist-info/top_level.txt +1 -0
- hidevs_quest/__init__.py +1 -0
- hidevs_quest/cli.py +332 -0
- hidevs_quest/runner.py +243 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
hidevs_quest/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
|
|
2
|
+
hidevs_quest/cli.py,sha256=Qdx9MWcf8A4qo6EGUAHX5cjcXOHUhvbJGZ985m11zt4,12112
|
|
3
|
+
hidevs_quest/runner.py,sha256=gtX1EMSa-ZwdRRVtF8OU3mSBja61HB7bbH_lL92kmrw,8452
|
|
4
|
+
hidevs_eval-0.1.1.dist-info/METADATA,sha256=hcrbQxr7qgK2K5srqheLzGLKx8ldqk7NmGGdjoWgm5w,193
|
|
5
|
+
hidevs_eval-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
hidevs_eval-0.1.1.dist-info/entry_points.txt,sha256=M-eo_dbdAwOxU2WBYFGalT0seo03e7oW7oLIFkFlYz4,54
|
|
7
|
+
hidevs_eval-0.1.1.dist-info/top_level.txt,sha256=jkb_W8hpSCXXCEbwvLOs3mjHry2QlDV6a78mA2VFalc,13
|
|
8
|
+
hidevs_eval-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hidevs_quest
|
hidevs_quest/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
hidevs_quest/cli.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""hidevs-eval login, then hidevs-eval eval. The server sends the cases and stores the score."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import urllib.error
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from hidevs_quest import __version__
|
|
14
|
+
from hidevs_quest.runner import collect_sources, get_json, post_json, preflight, run_cases
|
|
15
|
+
|
|
16
|
+
SESSION = Path.home() / ".hidevs_quest" / "session.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_session() -> dict:
|
|
20
|
+
if not SESSION.exists():
|
|
21
|
+
return {}
|
|
22
|
+
return json.loads(SESSION.read_text())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_session(session: dict) -> None:
|
|
26
|
+
SESSION.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
SESSION.write_text(json.dumps(session, indent=2))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def server_url(args: argparse.Namespace) -> str:
|
|
31
|
+
return (args.server or load_session().get("server") or "https://cli.server.hidevs.xyz").rstrip("/")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def api(url: str, payload: dict, token: str = "") -> dict:
|
|
35
|
+
try:
|
|
36
|
+
return post_json(url, payload, token)
|
|
37
|
+
except urllib.error.HTTPError as exc:
|
|
38
|
+
detail = exc.read().decode()
|
|
39
|
+
try:
|
|
40
|
+
detail = json.loads(detail).get("detail", detail)
|
|
41
|
+
except json.JSONDecodeError:
|
|
42
|
+
pass
|
|
43
|
+
raise SystemExit(f"{exc.code} {detail}") from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def version_key(value: str) -> tuple[int, ...]:
|
|
47
|
+
parts = []
|
|
48
|
+
for part in value.split("."):
|
|
49
|
+
digits = "".join(character for character in part if character.isdigit())
|
|
50
|
+
parts.append(int(digits or "0"))
|
|
51
|
+
return tuple(parts)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def client_notice(base: str) -> dict:
|
|
55
|
+
try:
|
|
56
|
+
settings = get_json(f"{base}/v1/settings")
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
raise SystemExit(f"could not reach {base}: {exc}") from exc
|
|
59
|
+
if version_key(__version__) < version_key(settings.get("min_version") or "0.0.0"):
|
|
60
|
+
extra = f" {settings['message']}" if settings.get("message") else ""
|
|
61
|
+
raise SystemExit(
|
|
62
|
+
f"this install is {__version__}. The server requires {settings['min_version']}.{extra}"
|
|
63
|
+
)
|
|
64
|
+
if version_key(settings.get("latest_version") or "0.0.0") > version_key(__version__):
|
|
65
|
+
print(f"a newer package is published: {settings['latest_version']}. You have {__version__}.")
|
|
66
|
+
if settings.get("message"):
|
|
67
|
+
print(settings["message"])
|
|
68
|
+
published = (settings.get("server_url") or "").rstrip("/")
|
|
69
|
+
if published and published != base:
|
|
70
|
+
print(f"the published server link is {published}")
|
|
71
|
+
print(f"switch with: hidevs-eval server set {published}")
|
|
72
|
+
return settings
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def login(args: argparse.Namespace) -> dict:
|
|
76
|
+
previous = load_session()
|
|
77
|
+
code = args.code or input("Platform code: ").strip()
|
|
78
|
+
if not code:
|
|
79
|
+
raise SystemExit("a platform code is required")
|
|
80
|
+
base = server_url(args)
|
|
81
|
+
client_notice(base)
|
|
82
|
+
body = api(f"{base}/v1/auth", {"code": code})
|
|
83
|
+
if previous.get("user_id") and previous.get("user_id") != body["user_id"]:
|
|
84
|
+
print(f"replaced login for {previous['user_id']}")
|
|
85
|
+
session = {
|
|
86
|
+
"server": base,
|
|
87
|
+
"token": body["token"],
|
|
88
|
+
"user_id": body["user_id"],
|
|
89
|
+
"quest_id": body.get("quest_id") or "",
|
|
90
|
+
"ps_id": body["ps_id"],
|
|
91
|
+
}
|
|
92
|
+
save_session(session)
|
|
93
|
+
print(f"validated for user {body['user_id']} quest {session['quest_id']} problem {body['ps_id']}")
|
|
94
|
+
return session
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def logout(_: argparse.Namespace) -> None:
|
|
98
|
+
if SESSION.exists():
|
|
99
|
+
SESSION.unlink()
|
|
100
|
+
print("logged out")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def whoami(args: argparse.Namespace) -> None:
|
|
104
|
+
session = load_session()
|
|
105
|
+
print(f"package {__version__}")
|
|
106
|
+
base = server_url(args)
|
|
107
|
+
print(f"server {base}")
|
|
108
|
+
if not session.get("token"):
|
|
109
|
+
print("not logged in")
|
|
110
|
+
return
|
|
111
|
+
print(f"user {session.get('user_id')} quest {session.get('quest_id')} problem {session.get('ps_id')}")
|
|
112
|
+
client_notice(base)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def show_server(args: argparse.Namespace) -> None:
|
|
116
|
+
print(server_url(args))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def set_server(args: argparse.Namespace) -> None:
|
|
120
|
+
url = args.url.rstrip("/")
|
|
121
|
+
session = load_session()
|
|
122
|
+
session["server"] = url
|
|
123
|
+
session.pop("token", None)
|
|
124
|
+
session.pop("user_id", None)
|
|
125
|
+
session.pop("quest_id", None)
|
|
126
|
+
session.pop("ps_id", None)
|
|
127
|
+
save_session(session)
|
|
128
|
+
print(f"server set to {url}")
|
|
129
|
+
print("log in again with the platform code")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
HELP = """
|
|
133
|
+
hidevs-eval login ask for the platform code and save it
|
|
134
|
+
hidevs-eval login --code CODE save a different code, replacing the previous login
|
|
135
|
+
hidevs-eval logout forget the saved login
|
|
136
|
+
hidevs-eval whoami show the package version, server, and user
|
|
137
|
+
hidevs-eval preflight import entry and tools from agent_config.yaml
|
|
138
|
+
hidevs-eval eval run level 1, level 2, and level 3
|
|
139
|
+
hidevs-eval eval --level 1 --config agent_config.yaml
|
|
140
|
+
hidevs-eval server show print the server this laptop uses
|
|
141
|
+
hidevs-eval server set URL point this laptop at a different server
|
|
142
|
+
hidevs-eval version print the installed package version
|
|
143
|
+
hidevs-eval help show this list
|
|
144
|
+
""".strip()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def show_help(_: argparse.Namespace) -> None:
|
|
148
|
+
print(HELP)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def show_version(_: argparse.Namespace) -> None:
|
|
152
|
+
print(__version__)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
ARENA_URL = "https://app.hidevs.xyz/ai-quest/challenges/ps-lyzr-01/arena"
|
|
156
|
+
LEVEL_HEADINGS = {
|
|
157
|
+
1: "LEVEL 1: DETERMINISTIC PROPERTY ASSERTIONS",
|
|
158
|
+
2: "LEVEL 2: INPUT AND TOOL QUALITY",
|
|
159
|
+
3: "LEVEL 3: LLM JUDGE",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def check_score(check: dict) -> float:
|
|
164
|
+
evidence = check.get("evidence")
|
|
165
|
+
raw = evidence.get("score") if isinstance(evidence, dict) else None
|
|
166
|
+
if isinstance(raw, (int, float)):
|
|
167
|
+
return max(0.0, min(1.0, float(raw)))
|
|
168
|
+
return 1.0 if check.get("status") == "PASS" else 0.0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def print_result(results: list[dict]) -> None:
|
|
172
|
+
line = "=" * 80
|
|
173
|
+
first = results[0] if results else {}
|
|
174
|
+
name = first.get("problem_name") or first.get("ps_id") or "Quest evaluation"
|
|
175
|
+
summary_text = first.get("problem_summary") or ""
|
|
176
|
+
print(line)
|
|
177
|
+
print("BENCHMARK: HiDevs AI Quest Evaluation")
|
|
178
|
+
print(f"PROBLEM STATEMENT: {name}")
|
|
179
|
+
if summary_text:
|
|
180
|
+
print(summary_text)
|
|
181
|
+
print(line)
|
|
182
|
+
rows = []
|
|
183
|
+
passed_all = 0
|
|
184
|
+
total_all = 0
|
|
185
|
+
for result in results:
|
|
186
|
+
print()
|
|
187
|
+
level = result.get("level")
|
|
188
|
+
print(f"[{LEVEL_HEADINGS.get(level, f'LEVEL {level}')}]")
|
|
189
|
+
scores = []
|
|
190
|
+
passed = 0
|
|
191
|
+
for check in result.get("checks") or []:
|
|
192
|
+
score = check_score(check)
|
|
193
|
+
scores.append(score)
|
|
194
|
+
status = check.get("status") or "FAIL"
|
|
195
|
+
if status == "PASS":
|
|
196
|
+
passed += 1
|
|
197
|
+
mark = "PASS" if status == "PASS" else status
|
|
198
|
+
evidence = check.get("evidence") if isinstance(check.get("evidence"), dict) else {}
|
|
199
|
+
reason = (check.get("reason") or "").split("\n")[0]
|
|
200
|
+
if level == 3:
|
|
201
|
+
title = f"{evidence.get('metric', '')} ({evidence.get('case', '')})".strip()
|
|
202
|
+
print(f" [{mark}] {check.get('id', ''):<24} | Score: {score:.2f} | {title}")
|
|
203
|
+
if reason:
|
|
204
|
+
print(f" * Reason: {reason}")
|
|
205
|
+
else:
|
|
206
|
+
print(f" [{mark}] {check.get('id', ''):<24} | Score: {score:.2f} | {reason}")
|
|
207
|
+
total = len(scores)
|
|
208
|
+
level_score = (sum(scores) / total * 100.0) if total else 0.0
|
|
209
|
+
rows.append((LEVEL_HEADINGS.get(level, f"Level {level}"), passed, total, level_score))
|
|
210
|
+
passed_all += passed
|
|
211
|
+
total_all += total
|
|
212
|
+
composite = (sum(row[3] for row in rows) / len(rows)) if rows else 0.0
|
|
213
|
+
print()
|
|
214
|
+
print(line)
|
|
215
|
+
print("FINAL BENCHMARK SCORECARD")
|
|
216
|
+
print(line)
|
|
217
|
+
for heading, passed, total, level_score in rows:
|
|
218
|
+
print(f"{heading:<42}: Passed {passed}/{total:<2} | Level Score: {level_score:.1f}%")
|
|
219
|
+
print("-" * 80)
|
|
220
|
+
print(f"TOTAL SYSTEM AGENT COMPOSITE SCORE : {composite:.1f}% ({passed_all}/{total_all} Passed)")
|
|
221
|
+
print(line)
|
|
222
|
+
print()
|
|
223
|
+
print(f"For the detailed report, open {ARENA_URL}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def run_preflight(config_path: Path) -> dict:
|
|
227
|
+
if not config_path.exists():
|
|
228
|
+
raise SystemExit(f"missing {config_path}")
|
|
229
|
+
config = yaml.safe_load(config_path.read_text()) or {}
|
|
230
|
+
root = config_path.parent
|
|
231
|
+
errors = preflight(config, root)
|
|
232
|
+
if errors:
|
|
233
|
+
print("preflight failed (not a scored check)")
|
|
234
|
+
for item in errors:
|
|
235
|
+
print(f" - {item}")
|
|
236
|
+
raise SystemExit(1)
|
|
237
|
+
entry = config.get("entry") or {}
|
|
238
|
+
if entry.get("type") == "http":
|
|
239
|
+
label = f"http {entry.get('app')}"
|
|
240
|
+
elif "class" in entry:
|
|
241
|
+
label = f"python class {entry.get('class')}.{entry.get('method')}"
|
|
242
|
+
else:
|
|
243
|
+
label = f"python callable {entry.get('callable')}"
|
|
244
|
+
print(f"preflight ok: {label}")
|
|
245
|
+
tools = config.get("tools") or []
|
|
246
|
+
if tools:
|
|
247
|
+
print(f"preflight ok: {len(tools)} tool function(s) importable")
|
|
248
|
+
return config
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def preflight_command(args: argparse.Namespace) -> None:
|
|
252
|
+
run_preflight(Path(args.config).resolve())
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def evaluate(args: argparse.Namespace) -> None:
|
|
256
|
+
session = load_session()
|
|
257
|
+
if not session.get("token") or server_url(args) != session.get("server"):
|
|
258
|
+
session = login(args)
|
|
259
|
+
else:
|
|
260
|
+
client_notice(session["server"])
|
|
261
|
+
config_path = Path(args.config).resolve()
|
|
262
|
+
config = run_preflight(config_path)
|
|
263
|
+
root = config_path.parent
|
|
264
|
+
levels = [args.level] if args.level else [1, 2, 3]
|
|
265
|
+
reports = []
|
|
266
|
+
sources = collect_sources(root)
|
|
267
|
+
for level in levels:
|
|
268
|
+
started = api(
|
|
269
|
+
f"{session['server']}/v1/levels/{level}/start",
|
|
270
|
+
{"config": config, "sources": sources},
|
|
271
|
+
session["token"],
|
|
272
|
+
)
|
|
273
|
+
if started.get("result"):
|
|
274
|
+
reports.append(started["result"])
|
|
275
|
+
continue
|
|
276
|
+
results = run_cases(config, root, started["cases"])
|
|
277
|
+
reports.append(api(
|
|
278
|
+
f"{session['server']}/v1/runs/{started['run_id']}",
|
|
279
|
+
{"results": results},
|
|
280
|
+
session["token"],
|
|
281
|
+
))
|
|
282
|
+
print_result(reports)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def main() -> None:
|
|
286
|
+
parser = argparse.ArgumentParser(prog="hidevs-eval", description=HELP, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
287
|
+
parser.add_argument("--server", default="", help="eval server for this command")
|
|
288
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
289
|
+
|
|
290
|
+
help_parser = sub.add_parser("help", help="list the commands")
|
|
291
|
+
help_parser.set_defaults(func=show_help)
|
|
292
|
+
|
|
293
|
+
version_parser = sub.add_parser("version", help="print the installed package version")
|
|
294
|
+
version_parser.set_defaults(func=show_version)
|
|
295
|
+
|
|
296
|
+
login_parser = sub.add_parser("login", help="save a platform code, replacing any previous login")
|
|
297
|
+
login_parser.add_argument("--code", default="")
|
|
298
|
+
login_parser.set_defaults(func=login)
|
|
299
|
+
|
|
300
|
+
logout_parser = sub.add_parser("logout", help="forget the saved login")
|
|
301
|
+
logout_parser.set_defaults(func=logout)
|
|
302
|
+
|
|
303
|
+
whoami_parser = sub.add_parser("whoami", help="show version, server, and user")
|
|
304
|
+
whoami_parser.set_defaults(func=whoami)
|
|
305
|
+
|
|
306
|
+
server_parser = sub.add_parser("server", help="show or change the server link")
|
|
307
|
+
server_sub = server_parser.add_subparsers(dest="server_command", required=True)
|
|
308
|
+
server_show = server_sub.add_parser("show", help="print the server this laptop uses")
|
|
309
|
+
server_show.set_defaults(func=show_server)
|
|
310
|
+
server_set = server_sub.add_parser("set", help="point this laptop at a different server")
|
|
311
|
+
server_set.add_argument("url")
|
|
312
|
+
server_set.set_defaults(func=set_server)
|
|
313
|
+
|
|
314
|
+
preflight_parser = sub.add_parser("preflight", help="import entry and tools before scoring")
|
|
315
|
+
preflight_parser.add_argument("--config", default="agent_config.yaml")
|
|
316
|
+
preflight_parser.set_defaults(func=preflight_command)
|
|
317
|
+
|
|
318
|
+
eval_parser = sub.add_parser("eval", help="pull cases, run the local agent, upload replies")
|
|
319
|
+
eval_parser.add_argument("--config", default="agent_config.yaml")
|
|
320
|
+
eval_parser.add_argument("--level", type=int, default=None)
|
|
321
|
+
eval_parser.add_argument("--code", default="")
|
|
322
|
+
eval_parser.set_defaults(func=evaluate)
|
|
323
|
+
|
|
324
|
+
args = parser.parse_args()
|
|
325
|
+
try:
|
|
326
|
+
args.func(args)
|
|
327
|
+
except KeyboardInterrupt:
|
|
328
|
+
sys.exit(130)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
if __name__ == "__main__":
|
|
332
|
+
main()
|
hidevs_quest/runner.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Call the local agent. The server never sees this process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import traceback
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SKIP_DIRS = {".venv", "venv", ".git", "__pycache__", "node_modules", ".hidevs"}
|
|
15
|
+
MAX_FILE_BYTES = 200_000
|
|
16
|
+
MAX_TOTAL_BYTES = 1_500_000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def as_dict(value: Any) -> dict:
|
|
20
|
+
if hasattr(value, "model_dump"):
|
|
21
|
+
return value.model_dump()
|
|
22
|
+
if isinstance(value, dict):
|
|
23
|
+
return value
|
|
24
|
+
return {"_raw": str(value)}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def collect_sources(root: Path) -> dict[str, str]:
|
|
28
|
+
sources: dict[str, str] = {}
|
|
29
|
+
total = 0
|
|
30
|
+
for path in root.rglob("*.py"):
|
|
31
|
+
if any(part in SKIP_DIRS for part in path.parts):
|
|
32
|
+
continue
|
|
33
|
+
if path.stat().st_size > MAX_FILE_BYTES:
|
|
34
|
+
continue
|
|
35
|
+
text = path.read_text(errors="ignore")
|
|
36
|
+
total += len(text)
|
|
37
|
+
if total > MAX_TOTAL_BYTES:
|
|
38
|
+
break
|
|
39
|
+
sources[path.relative_to(root).as_posix()] = text
|
|
40
|
+
return sources
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_kwargs(config: dict, case: dict) -> dict:
|
|
44
|
+
entry = config["entry"]
|
|
45
|
+
kwargs = dict(entry.get("constants") or {})
|
|
46
|
+
for source, target in (entry.get("arguments") or {}).items():
|
|
47
|
+
if source in case["input"]:
|
|
48
|
+
kwargs[target] = case["input"][source]
|
|
49
|
+
return kwargs
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_symbol(root: Path, spec: str):
|
|
53
|
+
if ":" not in spec:
|
|
54
|
+
raise ValueError(f"expected module:name, got {spec!r}")
|
|
55
|
+
module_name, attr = spec.split(":", 1)
|
|
56
|
+
if str(root) not in sys.path:
|
|
57
|
+
sys.path.insert(0, str(root))
|
|
58
|
+
module = importlib.import_module(module_name)
|
|
59
|
+
if not hasattr(module, attr):
|
|
60
|
+
raise AttributeError(f"{module_name} has no attribute {attr}")
|
|
61
|
+
return getattr(module, attr)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def preflight(config: dict, root: Path) -> list[str]:
|
|
65
|
+
"""Import the entry and tools from agent_config.yaml. Not a scored check."""
|
|
66
|
+
errors: list[str] = []
|
|
67
|
+
root = root.resolve()
|
|
68
|
+
entry = config.get("entry") or {}
|
|
69
|
+
try:
|
|
70
|
+
if entry.get("type") == "http":
|
|
71
|
+
app = entry.get("app") or ""
|
|
72
|
+
_load_symbol(root, app)
|
|
73
|
+
if not entry.get("method") or not entry.get("endpoint"):
|
|
74
|
+
errors.append("entry.method and entry.endpoint are required for http")
|
|
75
|
+
elif "class" in entry:
|
|
76
|
+
cls = _load_symbol(root, entry["class"])
|
|
77
|
+
method_name = entry.get("method") or ""
|
|
78
|
+
if not method_name:
|
|
79
|
+
errors.append("entry.method is required for a python class")
|
|
80
|
+
elif not callable(getattr(cls, method_name, None)):
|
|
81
|
+
errors.append(f"class {entry['class']} has no callable method {method_name}")
|
|
82
|
+
elif "callable" in entry:
|
|
83
|
+
fn = _load_symbol(root, entry["callable"])
|
|
84
|
+
if not callable(fn):
|
|
85
|
+
errors.append(f"entry.callable {entry['callable']} is not callable")
|
|
86
|
+
else:
|
|
87
|
+
errors.append("entry must declare http app, python class, or python callable")
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
errors.append(f"entry could not be imported: {exc}")
|
|
90
|
+
|
|
91
|
+
for tool in config.get("tools") or []:
|
|
92
|
+
spec = tool.get("function") or ""
|
|
93
|
+
capability = tool.get("capability") or spec
|
|
94
|
+
if not spec:
|
|
95
|
+
errors.append(f"tool {capability} is missing function")
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
fn = _load_symbol(root, spec)
|
|
99
|
+
if not callable(fn):
|
|
100
|
+
errors.append(f"tool {capability} function {spec} is not callable")
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
errors.append(f"tool {capability} could not be imported ({spec}): {exc}")
|
|
103
|
+
|
|
104
|
+
req = root / "requirements.txt"
|
|
105
|
+
if req.is_file() and errors:
|
|
106
|
+
errors.append(
|
|
107
|
+
f"install agent libraries in this same Python, then retry: "
|
|
108
|
+
f"pip install -r {req}"
|
|
109
|
+
)
|
|
110
|
+
elif errors:
|
|
111
|
+
errors.append(
|
|
112
|
+
"install agent libraries in the same Python that runs hidevs-eval, then retry"
|
|
113
|
+
)
|
|
114
|
+
return errors
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def invoke(config: dict, root: Path, case: dict) -> Any:
|
|
118
|
+
entry = config["entry"]
|
|
119
|
+
kwargs = build_kwargs(config, case)
|
|
120
|
+
if entry.get("type") == "http":
|
|
121
|
+
application = _load_symbol(root, entry["app"])
|
|
122
|
+
from fastapi.testclient import TestClient
|
|
123
|
+
|
|
124
|
+
client = TestClient(application)
|
|
125
|
+
response = client.request(entry["method"], entry["endpoint"], json=kwargs)
|
|
126
|
+
response.raise_for_status()
|
|
127
|
+
return response.json()
|
|
128
|
+
if "class" in entry:
|
|
129
|
+
cls = _load_symbol(root, entry["class"])
|
|
130
|
+
method = getattr(cls(), entry["method"])
|
|
131
|
+
return method(**kwargs)
|
|
132
|
+
fn = _load_symbol(root, entry["callable"])
|
|
133
|
+
return fn(**kwargs)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Scripted:
|
|
137
|
+
def __init__(self, original, mode: str):
|
|
138
|
+
self.original = original
|
|
139
|
+
self.mode = mode
|
|
140
|
+
self.calls = []
|
|
141
|
+
|
|
142
|
+
def __call__(self, *args, **kwargs):
|
|
143
|
+
self.calls.append(True)
|
|
144
|
+
if self.mode == "raise":
|
|
145
|
+
raise RuntimeError("injected tool failure")
|
|
146
|
+
if self.mode == "timeout":
|
|
147
|
+
raise TimeoutError("injected tool timeout")
|
|
148
|
+
return self.original(*args, **kwargs)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def modules_under(root: Path):
|
|
152
|
+
root = root.resolve()
|
|
153
|
+
for module in list(sys.modules.values()):
|
|
154
|
+
path = getattr(module, "__file__", None)
|
|
155
|
+
if not path:
|
|
156
|
+
continue
|
|
157
|
+
try:
|
|
158
|
+
if Path(path).resolve().is_relative_to(root):
|
|
159
|
+
yield module
|
|
160
|
+
except (OSError, ValueError):
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def patch_tools(root: Path, config: dict, mode: str):
|
|
165
|
+
wrappers = {}
|
|
166
|
+
restores = []
|
|
167
|
+
mode = mode if mode in {"raise", "timeout"} else "ok"
|
|
168
|
+
sys.path.insert(0, str(root))
|
|
169
|
+
for tool in config.get("tools") or []:
|
|
170
|
+
capability = tool.get("capability")
|
|
171
|
+
spec = tool.get("function") or ""
|
|
172
|
+
if not capability or ":" not in spec:
|
|
173
|
+
continue
|
|
174
|
+
module_name, fn_name = spec.split(":", 1)
|
|
175
|
+
module = __import__(module_name, fromlist=[fn_name])
|
|
176
|
+
original = getattr(module, fn_name)
|
|
177
|
+
wrapper = Scripted(original, mode)
|
|
178
|
+
wrappers[capability] = wrapper
|
|
179
|
+
setattr(module, fn_name, wrapper)
|
|
180
|
+
restores.append((module, fn_name, original))
|
|
181
|
+
for loaded in modules_under(root):
|
|
182
|
+
for attr, value in list(vars(loaded).items()):
|
|
183
|
+
if value is original:
|
|
184
|
+
setattr(loaded, attr, wrapper)
|
|
185
|
+
restores.append((loaded, attr, original))
|
|
186
|
+
if isinstance(value, dict):
|
|
187
|
+
for key, item in list(value.items()):
|
|
188
|
+
if item is original:
|
|
189
|
+
value[key] = wrapper
|
|
190
|
+
restores.append((value, key, original))
|
|
191
|
+
return wrappers, restores
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def restore(restores) -> None:
|
|
195
|
+
for owner, key, original in reversed(restores):
|
|
196
|
+
if isinstance(owner, dict):
|
|
197
|
+
owner[key] = original
|
|
198
|
+
else:
|
|
199
|
+
setattr(owner, key, original)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def run_cases(config: dict, root: Path, cases: list[dict]) -> list[dict]:
|
|
203
|
+
results = []
|
|
204
|
+
for case in cases:
|
|
205
|
+
mode = case.get("tool_mode") or "ok"
|
|
206
|
+
wrappers, restores = patch_tools(root, config, mode)
|
|
207
|
+
try:
|
|
208
|
+
try:
|
|
209
|
+
payload = as_dict(invoke(config, root, case))
|
|
210
|
+
error = ""
|
|
211
|
+
except Exception:
|
|
212
|
+
payload = {}
|
|
213
|
+
error = traceback.format_exc()
|
|
214
|
+
finally:
|
|
215
|
+
restore(restores)
|
|
216
|
+
counts = {name: len(wrapper.calls) for name, wrapper in wrappers.items()}
|
|
217
|
+
results.append({
|
|
218
|
+
"id": case["id"],
|
|
219
|
+
"payload": payload,
|
|
220
|
+
"error": error[-1500:] if error else "",
|
|
221
|
+
"counts": counts,
|
|
222
|
+
})
|
|
223
|
+
return results
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
CLIENT_HEADER = "X-Hidevs-Client"
|
|
227
|
+
CLIENT_VALUE = "hidevs-eval"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def get_json(url: str) -> dict:
|
|
231
|
+
request = urllib.request.Request(url, headers={CLIENT_HEADER: CLIENT_VALUE})
|
|
232
|
+
with urllib.request.urlopen(request) as response:
|
|
233
|
+
return json.loads(response.read().decode())
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def post_json(url: str, payload: dict, token: str = "") -> dict:
|
|
237
|
+
data = json.dumps(payload).encode()
|
|
238
|
+
headers = {"Content-Type": "application/json", CLIENT_HEADER: CLIENT_VALUE}
|
|
239
|
+
if token:
|
|
240
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
241
|
+
request = urllib.request.Request(url, data=data, headers=headers)
|
|
242
|
+
with urllib.request.urlopen(request) as response:
|
|
243
|
+
return json.loads(response.read().decode())
|