hidevs-eval 0.1.2__tar.gz → 0.1.3__tar.gz
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.2 → hidevs_eval-0.1.3}/PKG-INFO +1 -1
- {hidevs_eval-0.1.2 → hidevs_eval-0.1.3}/pyproject.toml +1 -1
- {hidevs_eval-0.1.2 → hidevs_eval-0.1.3}/src/hidevs_eval.egg-info/PKG-INFO +1 -1
- {hidevs_eval-0.1.2 → hidevs_eval-0.1.3}/src/hidevs_eval.egg-info/SOURCES.txt +1 -0
- hidevs_eval-0.1.3/src/hidevs_quest/__init__.py +1 -0
- {hidevs_eval-0.1.2 → hidevs_eval-0.1.3}/src/hidevs_quest/graphify.py +2 -0
- hidevs_eval-0.1.3/src/hidevs_quest/llm_probe.py +436 -0
- {hidevs_eval-0.1.2 → hidevs_eval-0.1.3}/src/hidevs_quest/runner.py +87 -44
- hidevs_eval-0.1.2/src/hidevs_quest/__init__.py +0 -1
- {hidevs_eval-0.1.2 → hidevs_eval-0.1.3}/README.md +0 -0
- {hidevs_eval-0.1.2 → hidevs_eval-0.1.3}/setup.cfg +0 -0
- {hidevs_eval-0.1.2 → hidevs_eval-0.1.3}/src/hidevs_eval.egg-info/dependency_links.txt +0 -0
- {hidevs_eval-0.1.2 → hidevs_eval-0.1.3}/src/hidevs_eval.egg-info/entry_points.txt +0 -0
- {hidevs_eval-0.1.2 → hidevs_eval-0.1.3}/src/hidevs_eval.egg-info/requires.txt +0 -0
- {hidevs_eval-0.1.2 → hidevs_eval-0.1.3}/src/hidevs_eval.egg-info/top_level.txt +0 -0
- {hidevs_eval-0.1.2 → hidevs_eval-0.1.3}/src/hidevs_quest/cli.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Prove a declared model call actually hit a model.
|
|
2
|
+
|
|
3
|
+
A config entry such as ``meetagent.provider.chat_completion`` is wrapped at eval
|
|
4
|
+
time. The wrapper counts only when, inside that call, an HTTP request looks like
|
|
5
|
+
a chat/completions call and the value returned to the agent was taken from the
|
|
6
|
+
response. A function that returns a fixed string, ignores the response, or echoes
|
|
7
|
+
the prompt is not recorded.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import hashlib
|
|
14
|
+
import importlib
|
|
15
|
+
import inspect
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
SECRET_TEXT = re.compile(
|
|
25
|
+
r"mongodb(?:\+srv)?://\S+|AIza[0-9A-Za-z_\-]{20,}|sk-[0-9A-Za-z]{16,}|\b(?:\d[ -]?){13,16}\b",
|
|
26
|
+
re.IGNORECASE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_LLM_HINTS = (
|
|
30
|
+
"chat/completions",
|
|
31
|
+
"/completions",
|
|
32
|
+
"generatecontent",
|
|
33
|
+
"/api/chat",
|
|
34
|
+
"/api/generate",
|
|
35
|
+
"/v1/messages",
|
|
36
|
+
"/v1/responses",
|
|
37
|
+
"generativelanguage",
|
|
38
|
+
)
|
|
39
|
+
_BODY_KEYS = {"messages", "contents", "prompt", "input"}
|
|
40
|
+
_HOOKS: list[tuple[Any, str, Any]] = []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def redact_text(value: str) -> str:
|
|
44
|
+
return SECRET_TEXT.sub("[redacted]", value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def clip_value(value: Any, depth: int = 0) -> Any:
|
|
48
|
+
if isinstance(value, str):
|
|
49
|
+
return redact_text(value)[:800]
|
|
50
|
+
if isinstance(value, (int, float, bool)) or value is None:
|
|
51
|
+
return value
|
|
52
|
+
if depth >= 4:
|
|
53
|
+
return redact_text(repr(value))[:200]
|
|
54
|
+
if isinstance(value, dict):
|
|
55
|
+
return {str(k)[:80]: clip_value(v, depth + 1) for k, v in list(value.items())[:12]}
|
|
56
|
+
if isinstance(value, (list, tuple)):
|
|
57
|
+
return [clip_value(v, depth + 1) for v in list(value)[:8]]
|
|
58
|
+
return redact_text(repr(value))[:200]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_llm_callable(root: Path, call: str):
|
|
62
|
+
"""Return (module, attr, fn) for ``pkg.mod.fn`` or ``pkg.mod:fn``. Otherwise None."""
|
|
63
|
+
call = (call or "").strip()
|
|
64
|
+
if not call or call in {"invoke", "llm.invoke"} or call.endswith(".invoke"):
|
|
65
|
+
# Instance methods such as llm.invoke are observed through the SDK tracer.
|
|
66
|
+
if ":" not in call:
|
|
67
|
+
return None
|
|
68
|
+
root_s = str(root)
|
|
69
|
+
if root_s not in sys.path:
|
|
70
|
+
sys.path.insert(0, root_s)
|
|
71
|
+
if ":" in call:
|
|
72
|
+
module_name, attr = call.split(":", 1)
|
|
73
|
+
if not module_name or "." in attr or not attr.isidentifier():
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
module = importlib.import_module(module_name)
|
|
77
|
+
except Exception:
|
|
78
|
+
return None
|
|
79
|
+
fn = getattr(module, attr, None)
|
|
80
|
+
if callable(fn):
|
|
81
|
+
return module, attr, fn
|
|
82
|
+
return None
|
|
83
|
+
parts = call.split(".")
|
|
84
|
+
if len(parts) < 2:
|
|
85
|
+
return None
|
|
86
|
+
attr = parts[-1]
|
|
87
|
+
module_name = ".".join(parts[:-1])
|
|
88
|
+
if not attr.isidentifier() or not module_name:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
module = importlib.import_module(module_name)
|
|
92
|
+
except Exception:
|
|
93
|
+
return None
|
|
94
|
+
fn = getattr(module, attr, None)
|
|
95
|
+
if callable(fn):
|
|
96
|
+
return module, attr, fn
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _fingerprint(text: str) -> str:
|
|
101
|
+
normalized = " ".join((text or "").split())[:2000]
|
|
102
|
+
return hashlib.sha256(normalized.encode("utf-8", errors="ignore")).hexdigest()[:16]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _result_text(result: Any) -> str:
|
|
106
|
+
if isinstance(result, str):
|
|
107
|
+
return result.strip()
|
|
108
|
+
if isinstance(result, dict):
|
|
109
|
+
for key in ("content", "text", "output", "answer"):
|
|
110
|
+
value = result.get(key)
|
|
111
|
+
if isinstance(value, str) and value.strip():
|
|
112
|
+
return value.strip()
|
|
113
|
+
content = getattr(result, "content", None)
|
|
114
|
+
if isinstance(content, str):
|
|
115
|
+
return content.strip()
|
|
116
|
+
return ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _return_constants(source: str) -> set[str]:
|
|
120
|
+
try:
|
|
121
|
+
tree = ast.parse(source)
|
|
122
|
+
except SyntaxError:
|
|
123
|
+
return set()
|
|
124
|
+
found: set[str] = set()
|
|
125
|
+
for node in ast.walk(tree):
|
|
126
|
+
if isinstance(node, ast.Return) and isinstance(node.value, ast.Constant):
|
|
127
|
+
if isinstance(node.value.value, str) and node.value.value.strip():
|
|
128
|
+
found.add(node.value.value.strip())
|
|
129
|
+
return found
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _request_bytes(body: Any) -> bytes:
|
|
133
|
+
if body is None:
|
|
134
|
+
return b""
|
|
135
|
+
if isinstance(body, bytes):
|
|
136
|
+
return body
|
|
137
|
+
if isinstance(body, str):
|
|
138
|
+
return body.encode("utf-8", errors="ignore")
|
|
139
|
+
if isinstance(body, dict):
|
|
140
|
+
return json.dumps(body).encode("utf-8")
|
|
141
|
+
return str(body).encode("utf-8", errors="ignore")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _loads(body: bytes | str | None) -> Any:
|
|
145
|
+
if body is None:
|
|
146
|
+
return None
|
|
147
|
+
if isinstance(body, dict):
|
|
148
|
+
return body
|
|
149
|
+
try:
|
|
150
|
+
text = body.decode("utf-8", errors="ignore") if isinstance(body, bytes) else str(body)
|
|
151
|
+
return json.loads(text)
|
|
152
|
+
except (json.JSONDecodeError, UnicodeError, TypeError):
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _url_of(url: Any) -> str:
|
|
157
|
+
full = getattr(url, "full_url", None)
|
|
158
|
+
return str(full or url or "")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def classify_exchange(url: str, request_body: bytes) -> dict[str, Any] | None:
|
|
162
|
+
"""Return a small description when the request looks like a model call."""
|
|
163
|
+
lowered = (url or "").lower().split("?", 1)[0]
|
|
164
|
+
data = _loads(request_body)
|
|
165
|
+
shaped = any(hint in lowered for hint in _LLM_HINTS)
|
|
166
|
+
if isinstance(data, dict) and _BODY_KEYS.intersection(data):
|
|
167
|
+
shaped = True
|
|
168
|
+
if not shaped:
|
|
169
|
+
return None
|
|
170
|
+
kind = "chat_completions"
|
|
171
|
+
if "generatecontent" in lowered or "generativelanguage" in lowered:
|
|
172
|
+
kind = "generate_content"
|
|
173
|
+
elif "/v1/messages" in lowered:
|
|
174
|
+
kind = "messages"
|
|
175
|
+
elif "/api/chat" in lowered:
|
|
176
|
+
kind = "api_chat"
|
|
177
|
+
elif "/api/generate" in lowered:
|
|
178
|
+
kind = "api_generate"
|
|
179
|
+
elif "/v1/responses" in lowered:
|
|
180
|
+
kind = "responses"
|
|
181
|
+
model = ""
|
|
182
|
+
user_bits: list[str] = []
|
|
183
|
+
if isinstance(data, dict):
|
|
184
|
+
if isinstance(data.get("model"), str):
|
|
185
|
+
model = data["model"].strip()
|
|
186
|
+
messages = data.get("messages")
|
|
187
|
+
if isinstance(messages, list):
|
|
188
|
+
for item in messages:
|
|
189
|
+
if isinstance(item, dict) and item.get("role") != "system":
|
|
190
|
+
content = item.get("content")
|
|
191
|
+
if isinstance(content, str):
|
|
192
|
+
user_bits.append(content)
|
|
193
|
+
for key in ("prompt", "input"):
|
|
194
|
+
if isinstance(data.get(key), str):
|
|
195
|
+
user_bits.append(data[key])
|
|
196
|
+
contents = data.get("contents")
|
|
197
|
+
if isinstance(contents, list):
|
|
198
|
+
for item in contents:
|
|
199
|
+
parts = item.get("parts") if isinstance(item, dict) else None
|
|
200
|
+
if isinstance(parts, list):
|
|
201
|
+
for part in parts:
|
|
202
|
+
if isinstance(part, dict) and isinstance(part.get("text"), str):
|
|
203
|
+
user_bits.append(part["text"])
|
|
204
|
+
host = ""
|
|
205
|
+
try:
|
|
206
|
+
host = urllib.parse.urlparse(url).hostname or ""
|
|
207
|
+
except Exception:
|
|
208
|
+
host = ""
|
|
209
|
+
return {
|
|
210
|
+
"url_kind": kind,
|
|
211
|
+
"url_host": host.lower(),
|
|
212
|
+
"model": model,
|
|
213
|
+
"user_text": "\n".join(user_bits)[:4000],
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _response_texts(body: bytes) -> list[str]:
|
|
218
|
+
texts: list[str] = []
|
|
219
|
+
raw = body.decode("utf-8", errors="ignore") if isinstance(body, bytes) else str(body or "")
|
|
220
|
+
if raw.strip():
|
|
221
|
+
texts.append(raw)
|
|
222
|
+
data = _loads(body)
|
|
223
|
+
if isinstance(data, dict):
|
|
224
|
+
try:
|
|
225
|
+
content = data["choices"][0]["message"]["content"]
|
|
226
|
+
if isinstance(content, str):
|
|
227
|
+
texts.append(content)
|
|
228
|
+
except (KeyError, IndexError, TypeError):
|
|
229
|
+
pass
|
|
230
|
+
try:
|
|
231
|
+
content = data["candidates"][0]["content"]["parts"][0]["text"]
|
|
232
|
+
if isinstance(content, str):
|
|
233
|
+
texts.append(content)
|
|
234
|
+
except (KeyError, IndexError, TypeError):
|
|
235
|
+
pass
|
|
236
|
+
response = data.get("response")
|
|
237
|
+
if isinstance(response, str):
|
|
238
|
+
texts.append(response)
|
|
239
|
+
message = data.get("message")
|
|
240
|
+
if isinstance(message, dict) and isinstance(message.get("content"), str):
|
|
241
|
+
texts.append(message["content"])
|
|
242
|
+
return texts
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _used_response(result: str, response_body: bytes) -> bool:
|
|
246
|
+
normalized = " ".join(result.split())
|
|
247
|
+
if len(normalized) < 8:
|
|
248
|
+
return False
|
|
249
|
+
for text in _response_texts(response_body):
|
|
250
|
+
other = " ".join(text.split())
|
|
251
|
+
if not other:
|
|
252
|
+
continue
|
|
253
|
+
if normalized == other or normalized in other or (len(other) >= 8 and other in normalized):
|
|
254
|
+
return True
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _is_echo(result: str, user_text: str) -> bool:
|
|
259
|
+
normalized = " ".join(result.split())
|
|
260
|
+
user = " ".join((user_text or "").split())
|
|
261
|
+
if not normalized or not user:
|
|
262
|
+
return False
|
|
263
|
+
return normalized == user or (len(normalized) > 40 and normalized in user)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _active_span(tracer: Any) -> dict | None:
|
|
267
|
+
spans = getattr(tracer, "_llm_spans", None) if tracer is not None else None
|
|
268
|
+
if not spans:
|
|
269
|
+
return None
|
|
270
|
+
return spans[-1]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def observe_http(url: str, request_body: Any, status: int, response_body: bytes) -> None:
|
|
274
|
+
from hidevs_quest.graphify import ExecutionTracer
|
|
275
|
+
|
|
276
|
+
tracer = ExecutionTracer._active
|
|
277
|
+
span = _active_span(tracer)
|
|
278
|
+
if span is None:
|
|
279
|
+
return
|
|
280
|
+
info = classify_exchange(url, _request_bytes(request_body))
|
|
281
|
+
if info is None:
|
|
282
|
+
return
|
|
283
|
+
if not isinstance(response_body, (bytes, bytearray)):
|
|
284
|
+
response_body = _request_bytes(response_body)
|
|
285
|
+
# Keep only what the proof needs. Do not store the prompt or the completion.
|
|
286
|
+
span["http"].append({
|
|
287
|
+
**info,
|
|
288
|
+
"status": int(status or 0),
|
|
289
|
+
"response_body": bytes(response_body)[:200_000],
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _prove(span: dict, result: Any, probe: "LlmCallProbe") -> dict | None:
|
|
294
|
+
text = _result_text(result)
|
|
295
|
+
if not text or text in probe.constants:
|
|
296
|
+
return None
|
|
297
|
+
for event in span.get("http") or []:
|
|
298
|
+
if not (200 <= int(event.get("status") or 0) < 300):
|
|
299
|
+
continue
|
|
300
|
+
if _is_echo(text, event.get("user_text") or ""):
|
|
301
|
+
continue
|
|
302
|
+
if not _used_response(text, event.get("response_body") or b""):
|
|
303
|
+
continue
|
|
304
|
+
return {
|
|
305
|
+
"method": probe.name,
|
|
306
|
+
"file": probe.filename,
|
|
307
|
+
"model": event.get("model") or None,
|
|
308
|
+
"via": "http",
|
|
309
|
+
"proven": True,
|
|
310
|
+
"declared": True,
|
|
311
|
+
"url_kind": event.get("url_kind") or "",
|
|
312
|
+
"url_host": event.get("url_host") or "",
|
|
313
|
+
"input_fingerprint": _fingerprint(event.get("user_text") or ""),
|
|
314
|
+
"output_fingerprint": _fingerprint(text),
|
|
315
|
+
}
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class LlmCallProbe:
|
|
320
|
+
def __init__(self, original, declared_model: str = ""):
|
|
321
|
+
self.original = original
|
|
322
|
+
self.name = getattr(original, "__name__", "call")
|
|
323
|
+
self.filename = str(getattr(original, "__code__", None) and original.__code__.co_filename or "")
|
|
324
|
+
self.declared_model = declared_model
|
|
325
|
+
try:
|
|
326
|
+
self.constants = _return_constants(inspect.getsource(original))
|
|
327
|
+
except (OSError, TypeError):
|
|
328
|
+
self.constants = set()
|
|
329
|
+
|
|
330
|
+
def __call__(self, *args, **kwargs):
|
|
331
|
+
from hidevs_quest.graphify import ExecutionTracer
|
|
332
|
+
|
|
333
|
+
tracer = ExecutionTracer._active
|
|
334
|
+
span = {"http": []}
|
|
335
|
+
if tracer is not None:
|
|
336
|
+
tracer._llm_spans = getattr(tracer, "_llm_spans", [])
|
|
337
|
+
tracer._llm_spans.append(span)
|
|
338
|
+
try:
|
|
339
|
+
result = self.original(*args, **kwargs)
|
|
340
|
+
except BaseException:
|
|
341
|
+
if tracer is not None and tracer._llm_spans:
|
|
342
|
+
tracer._llm_spans.pop()
|
|
343
|
+
raise
|
|
344
|
+
if tracer is not None and tracer._llm_spans:
|
|
345
|
+
tracer._llm_spans.pop()
|
|
346
|
+
record = _prove(span, result, self)
|
|
347
|
+
if record is not None:
|
|
348
|
+
tracer.llm_calls.append(record)
|
|
349
|
+
tracer.nodes.add(f"llm:{self.name}")
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _wrap_read(response, url: str, request_body: bytes):
|
|
354
|
+
original_read = response.read
|
|
355
|
+
|
|
356
|
+
def read(*args, **kwargs):
|
|
357
|
+
blob = original_read(*args, **kwargs)
|
|
358
|
+
try:
|
|
359
|
+
status = int(getattr(response, "status", None) or getattr(response, "code", None) or 200)
|
|
360
|
+
except (TypeError, ValueError):
|
|
361
|
+
status = 200
|
|
362
|
+
payload = blob if isinstance(blob, (bytes, bytearray)) else _request_bytes(blob)
|
|
363
|
+
observe_http(url, request_body, status, payload)
|
|
364
|
+
return blob
|
|
365
|
+
|
|
366
|
+
response.read = read
|
|
367
|
+
return response
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _wrapped_urlopen(original):
|
|
371
|
+
def urlopen(url, data=None, *args, **kwargs):
|
|
372
|
+
request_url = _url_of(url)
|
|
373
|
+
request_body = _request_bytes(getattr(url, "data", None) if data is None else data)
|
|
374
|
+
response = original(url, data, *args, **kwargs)
|
|
375
|
+
return _wrap_read(response, request_url, request_body)
|
|
376
|
+
|
|
377
|
+
return urlopen
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def install_http_hooks() -> None:
|
|
381
|
+
"""Patch HTTP clients so a declared model call can be proved. Idempotent."""
|
|
382
|
+
if _HOOKS:
|
|
383
|
+
return
|
|
384
|
+
original = urllib.request.urlopen
|
|
385
|
+
urllib.request.urlopen = _wrapped_urlopen(original)
|
|
386
|
+
_HOOKS.append((urllib.request, "urlopen", original))
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
import httpx
|
|
390
|
+
except Exception:
|
|
391
|
+
httpx = None
|
|
392
|
+
if httpx is not None and hasattr(httpx.Client, "send"):
|
|
393
|
+
original_send = httpx.Client.send
|
|
394
|
+
|
|
395
|
+
def send(self, request, *args, **kwargs):
|
|
396
|
+
response = original_send(self, request, *args, **kwargs)
|
|
397
|
+
try:
|
|
398
|
+
observe_http(
|
|
399
|
+
str(request.url),
|
|
400
|
+
getattr(request, "content", b"") or b"",
|
|
401
|
+
int(getattr(response, "status_code", 0) or 0),
|
|
402
|
+
response.content or b"",
|
|
403
|
+
)
|
|
404
|
+
except Exception:
|
|
405
|
+
pass
|
|
406
|
+
return response
|
|
407
|
+
|
|
408
|
+
httpx.Client.send = send
|
|
409
|
+
_HOOKS.append((httpx.Client, "send", original_send))
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
import requests
|
|
413
|
+
except Exception:
|
|
414
|
+
requests = None
|
|
415
|
+
if requests is not None:
|
|
416
|
+
original_request = requests.sessions.Session.request
|
|
417
|
+
|
|
418
|
+
def request(self, method, url, *args, **kwargs):
|
|
419
|
+
response = original_request(self, method, url, *args, **kwargs)
|
|
420
|
+
body = kwargs.get("data")
|
|
421
|
+
if body is None and kwargs.get("json") is not None:
|
|
422
|
+
body = kwargs.get("json")
|
|
423
|
+
try:
|
|
424
|
+
observe_http(str(response.url or url), body, int(response.status_code or 0), response.content or b"")
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
return response
|
|
428
|
+
|
|
429
|
+
requests.sessions.Session.request = request
|
|
430
|
+
_HOOKS.append((requests.sessions.Session, "request", original_request))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def remove_http_hooks() -> None:
|
|
434
|
+
while _HOOKS:
|
|
435
|
+
owner, name, original = _HOOKS.pop()
|
|
436
|
+
setattr(owner, name, original)
|
|
@@ -139,6 +139,7 @@ class Scripted:
|
|
|
139
139
|
self.mode = mode
|
|
140
140
|
self.calls = []
|
|
141
141
|
self.arg_records = []
|
|
142
|
+
self.results = []
|
|
142
143
|
self.node_id = node_id
|
|
143
144
|
|
|
144
145
|
def _note(self) -> None:
|
|
@@ -165,7 +166,11 @@ class Scripted:
|
|
|
165
166
|
raise RuntimeError("injected tool failure")
|
|
166
167
|
if self.mode == "timeout":
|
|
167
168
|
raise TimeoutError("injected tool timeout")
|
|
168
|
-
|
|
169
|
+
result = self.original(*args, **kwargs)
|
|
170
|
+
from hidevs_quest.llm_probe import clip_value
|
|
171
|
+
|
|
172
|
+
self.results.append(clip_value(result))
|
|
173
|
+
return result
|
|
169
174
|
|
|
170
175
|
|
|
171
176
|
def modules_under(root: Path):
|
|
@@ -230,6 +235,33 @@ def restore(restores) -> None:
|
|
|
230
235
|
setattr(owner, key, original)
|
|
231
236
|
|
|
232
237
|
|
|
238
|
+
def patch_llm(root: Path, config: dict):
|
|
239
|
+
"""Wrap requirements.llm.call when it is a real module function.
|
|
240
|
+
|
|
241
|
+
Instance calls such as llm.invoke stay on the SDK tracer. The wrapper
|
|
242
|
+
records a call only after llm_probe proves a model response was used.
|
|
243
|
+
"""
|
|
244
|
+
from hidevs_quest.llm_probe import LlmCallProbe, resolve_llm_callable
|
|
245
|
+
|
|
246
|
+
claim = ((config.get("requirements") or {}).get("llm")) or {}
|
|
247
|
+
found = resolve_llm_callable(root, str(claim.get("call") or ""))
|
|
248
|
+
if not found:
|
|
249
|
+
return []
|
|
250
|
+
module, name, original = found
|
|
251
|
+
if isinstance(original, LlmCallProbe):
|
|
252
|
+
original = original.original
|
|
253
|
+
probe = LlmCallProbe(original, declared_model=str(claim.get("model") or ""))
|
|
254
|
+
restores = [(module, name, original)]
|
|
255
|
+
setattr(module, name, probe)
|
|
256
|
+
for loaded in modules_under(root):
|
|
257
|
+
for attr, value in list(vars(loaded).items()):
|
|
258
|
+
current = value.original if isinstance(value, LlmCallProbe) else value
|
|
259
|
+
if current is original:
|
|
260
|
+
setattr(loaded, attr, probe)
|
|
261
|
+
restores.append((loaded, attr, original))
|
|
262
|
+
return restores
|
|
263
|
+
|
|
264
|
+
|
|
233
265
|
from hidevs_quest.graphify import ExecutionTracer, build_ast_graph, summarize_tool_args
|
|
234
266
|
|
|
235
267
|
|
|
@@ -255,50 +287,61 @@ def run_cases(config: dict, root: Path, cases: list[dict]) -> tuple[list[dict],
|
|
|
255
287
|
ast_graph = build_ast_graph(sources, method, config.get("tools"))
|
|
256
288
|
(artifacts_dir / "ast_graph.json").write_text(json.dumps(ast_graph, indent=2))
|
|
257
289
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
290
|
+
from hidevs_quest.llm_probe import install_http_hooks, remove_http_hooks
|
|
291
|
+
|
|
292
|
+
install_http_hooks()
|
|
293
|
+
try:
|
|
294
|
+
for case in cases:
|
|
295
|
+
mode = case.get("tool_mode") or "ok"
|
|
296
|
+
wrappers, restores = patch_tools(root, config, mode)
|
|
297
|
+
restores.extend(patch_llm(root, config))
|
|
298
|
+
runtime_graph = {}
|
|
263
299
|
try:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
"
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
try:
|
|
301
|
+
with ExecutionTracer(root) as tracer:
|
|
302
|
+
payload = as_dict(invoke(config, root, case))
|
|
303
|
+
runtime_graph = tracer.get_graph()
|
|
304
|
+
error = ""
|
|
305
|
+
except Exception:
|
|
306
|
+
payload = {}
|
|
307
|
+
error = traceback.format_exc()
|
|
308
|
+
finally:
|
|
309
|
+
restore(restores)
|
|
310
|
+
|
|
311
|
+
tool_arg_calls = {}
|
|
312
|
+
for name, wrapper in wrappers.items():
|
|
313
|
+
tool_arg_calls[name] = list(wrapper.arg_records)
|
|
314
|
+
tool_calls_by_cap.setdefault(name, []).extend(
|
|
315
|
+
{**rec, "case_id": case["id"]} for rec in wrapper.arg_records
|
|
316
|
+
)
|
|
317
|
+
runtime_graph["tool_arg_calls"] = tool_arg_calls
|
|
318
|
+
|
|
319
|
+
counts = {name: len(wrapper.calls) for name, wrapper in wrappers.items()}
|
|
320
|
+
runtime_graph = dict(runtime_graph or {})
|
|
321
|
+
runtime_graph["counts"] = counts
|
|
322
|
+
runtime_graph["tool_results"] = {
|
|
323
|
+
name: list(wrapper.results[:3])
|
|
324
|
+
for name, wrapper in wrappers.items()
|
|
325
|
+
if wrapper.results
|
|
326
|
+
}
|
|
327
|
+
all_runtime_graphs[case["id"]] = runtime_graph
|
|
328
|
+
results.append({
|
|
329
|
+
"id": case["id"],
|
|
330
|
+
"payload": payload,
|
|
331
|
+
"error": error[-1500:] if error else "",
|
|
332
|
+
"counts": counts,
|
|
333
|
+
"runtime_graph": runtime_graph,
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
tool_arg_summary = summarize_tool_args(tool_calls_by_cap)
|
|
337
|
+
all_llm_calls = []
|
|
338
|
+
for graph in all_runtime_graphs.values():
|
|
339
|
+
graph["tool_arg_summary"] = tool_arg_summary
|
|
340
|
+
all_llm_calls.extend(graph.get("llm_calls") or [])
|
|
341
|
+
for row in results:
|
|
342
|
+
row["runtime_graph"]["tool_arg_summary"] = tool_arg_summary
|
|
343
|
+
finally:
|
|
344
|
+
remove_http_hooks()
|
|
302
345
|
|
|
303
346
|
(artifacts_dir / "execution_trace_graph.json").write_text(json.dumps(all_runtime_graphs, indent=2))
|
|
304
347
|
(artifacts_dir / "tool_arg_summary.json").write_text(json.dumps(tool_arg_summary, indent=2))
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|