python-yama 0.1.0__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.
- python_yama-0.1.0.dist-info/METADATA +98 -0
- python_yama-0.1.0.dist-info/RECORD +27 -0
- python_yama-0.1.0.dist-info/WHEEL +4 -0
- python_yama-0.1.0.dist-info/entry_points.txt +2 -0
- python_yama-0.1.0.dist-info/licenses/LICENSE +21 -0
- yama/__init__.py +31 -0
- yama/__main__.py +3 -0
- yama/assertions.py +577 -0
- yama/cli.py +318 -0
- yama/llm.py +365 -0
- yama/loaders/__init__.py +6 -0
- yama/loaders/case.py +378 -0
- yama/loaders/common.py +29 -0
- yama/loaders/tool_loader.py +173 -0
- yama/mocks.py +383 -0
- yama/models.py +159 -0
- yama/report.py +854 -0
- yama/runner.py +531 -0
- yama/tools/__init__.py +0 -0
- yama/tools/base.py +19 -0
- yama/tools/bash/__init__.py +0 -0
- yama/tools/bash/_cli_shim.py +71 -0
- yama/tools/bash/bash.py +285 -0
- yama/tools/bash/fs.py +530 -0
- yama/tools/skill/__init__.py +0 -0
- yama/tools/skill/skill.py +57 -0
- yama/workspace.py +115 -0
yama/assertions.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .llm import LLMClient
|
|
11
|
+
from .mocks import load_python_callable
|
|
12
|
+
from .models import StepResult, ToolCallRecord
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AssertionConfigurationError(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_MISSING = object()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _select_jsonpath(value: Any, path: str) -> Any:
|
|
23
|
+
if path == "$":
|
|
24
|
+
return value
|
|
25
|
+
if not path.startswith("$."):
|
|
26
|
+
raise AssertionConfigurationError(f"unsupported JSONPath: {path}")
|
|
27
|
+
current = value
|
|
28
|
+
for name, raw_index in re.findall(r"([^.\[\]]+)(?:\[(\d+)\])?", path[2:]):
|
|
29
|
+
if not isinstance(current, dict) or name not in current:
|
|
30
|
+
return _MISSING
|
|
31
|
+
current = current[name]
|
|
32
|
+
if raw_index:
|
|
33
|
+
index = int(raw_index)
|
|
34
|
+
if not isinstance(current, list) or index >= len(current):
|
|
35
|
+
return _MISSING
|
|
36
|
+
current = current[index]
|
|
37
|
+
return current
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _match(value: Any, config: dict[str, Any]) -> bool:
|
|
41
|
+
operations = [
|
|
42
|
+
operation
|
|
43
|
+
for operation in ("equals", "contains", "matches", "is_instance", "exists")
|
|
44
|
+
if operation in config
|
|
45
|
+
]
|
|
46
|
+
if len(operations) != 1:
|
|
47
|
+
raise AssertionConfigurationError("matcher requires exactly one operation")
|
|
48
|
+
operation = operations[0]
|
|
49
|
+
expected = config[operation]
|
|
50
|
+
if operation == "equals":
|
|
51
|
+
return value == expected
|
|
52
|
+
if operation == "contains":
|
|
53
|
+
return expected in value if isinstance(value, (str, list, dict)) else False
|
|
54
|
+
if operation == "matches":
|
|
55
|
+
return isinstance(value, str) and re.search(str(expected), value) is not None
|
|
56
|
+
if operation == "exists":
|
|
57
|
+
return (value is not _MISSING) is bool(expected)
|
|
58
|
+
types = {
|
|
59
|
+
"array": list,
|
|
60
|
+
"object": dict,
|
|
61
|
+
"string": str,
|
|
62
|
+
"number": (int, float),
|
|
63
|
+
"integer": int,
|
|
64
|
+
"boolean": bool,
|
|
65
|
+
"null": type(None),
|
|
66
|
+
}
|
|
67
|
+
return expected in types and isinstance(value, types[expected])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _records(result: StepResult, name: str | None = None) -> list[ToolCallRecord]:
|
|
71
|
+
return [
|
|
72
|
+
record
|
|
73
|
+
for record in result.tool_calls
|
|
74
|
+
if name is None or record.call.name == name
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _tool_count_pass(count: int, config: dict[str, Any]) -> bool:
|
|
79
|
+
if "times" in config:
|
|
80
|
+
if "min_times" in config or "max_times" in config:
|
|
81
|
+
raise AssertionConfigurationError(
|
|
82
|
+
"times is mutually exclusive with min_times/max_times"
|
|
83
|
+
)
|
|
84
|
+
return count == config["times"]
|
|
85
|
+
minimum = config.get("min_times", 1)
|
|
86
|
+
maximum = config.get("max_times")
|
|
87
|
+
return count >= minimum and (maximum is None or count <= maximum)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _diagnostic(
|
|
91
|
+
*,
|
|
92
|
+
passed: bool,
|
|
93
|
+
label: str,
|
|
94
|
+
expected: str,
|
|
95
|
+
actual: str,
|
|
96
|
+
reason: str,
|
|
97
|
+
detail: str | None = None,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
return {
|
|
100
|
+
"passed": passed,
|
|
101
|
+
"label": label,
|
|
102
|
+
"expected": expected,
|
|
103
|
+
"actual": actual,
|
|
104
|
+
"reason": reason,
|
|
105
|
+
"detail": detail or (actual if passed else reason),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _render_value(value: Any, *, limit: int = 240) -> str:
|
|
110
|
+
if value is _MISSING:
|
|
111
|
+
return "<missing>"
|
|
112
|
+
rendered = repr(value)
|
|
113
|
+
if len(rendered) <= limit:
|
|
114
|
+
return rendered
|
|
115
|
+
return f"{rendered[: limit - 3]}..."
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _count_expectation(config: dict[str, Any]) -> str:
|
|
119
|
+
if "times" in config:
|
|
120
|
+
return f"exactly {config['times']} time(s)"
|
|
121
|
+
minimum = config.get("min_times", 1)
|
|
122
|
+
maximum = config.get("max_times")
|
|
123
|
+
if maximum is None:
|
|
124
|
+
return f"at least {minimum} time(s)"
|
|
125
|
+
return f"between {minimum} and {maximum} time(s)"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _matcher(config: dict[str, Any]) -> tuple[str, Any, str]:
|
|
129
|
+
operations = [
|
|
130
|
+
operation
|
|
131
|
+
for operation in ("equals", "contains", "matches", "is_instance", "exists")
|
|
132
|
+
if operation in config
|
|
133
|
+
]
|
|
134
|
+
if len(operations) != 1:
|
|
135
|
+
raise AssertionConfigurationError("matcher requires exactly one operation")
|
|
136
|
+
operation = operations[0]
|
|
137
|
+
expected = config[operation]
|
|
138
|
+
descriptions = {
|
|
139
|
+
"equals": f"equal {_render_value(expected)}",
|
|
140
|
+
"contains": f"contain {_render_value(expected)}",
|
|
141
|
+
"matches": f"match regex {_render_value(str(expected))}",
|
|
142
|
+
"is_instance": f"be an instance of {_render_value(expected)}",
|
|
143
|
+
"exists": "exist" if expected else "be absent",
|
|
144
|
+
}
|
|
145
|
+
return operation, expected, descriptions[operation]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _selected_values(values: list[tuple[int, Any]]) -> str:
|
|
149
|
+
if not values:
|
|
150
|
+
return "no values selected"
|
|
151
|
+
return "; ".join(
|
|
152
|
+
f"call #{call_number}: {_render_value(value)}"
|
|
153
|
+
for call_number, value in values
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def run_hard_assertions(
|
|
158
|
+
assertions: list[Any],
|
|
159
|
+
*,
|
|
160
|
+
result: StepResult,
|
|
161
|
+
transcript: list[dict[str, Any]],
|
|
162
|
+
state: dict[str, Any],
|
|
163
|
+
plugin_root: Path,
|
|
164
|
+
relative_root: Path | None = None,
|
|
165
|
+
) -> list[dict[str, Any]]:
|
|
166
|
+
checks: list[dict[str, Any]] = []
|
|
167
|
+
for assertion in assertions:
|
|
168
|
+
if not isinstance(assertion, dict) or len(assertion) != 1:
|
|
169
|
+
raise AssertionConfigurationError(
|
|
170
|
+
"each hard assertion must contain exactly one type"
|
|
171
|
+
)
|
|
172
|
+
kind, config = next(iter(assertion.items()))
|
|
173
|
+
diagnostic = await _run_one(
|
|
174
|
+
kind,
|
|
175
|
+
config,
|
|
176
|
+
result=result,
|
|
177
|
+
transcript=transcript,
|
|
178
|
+
state=state,
|
|
179
|
+
plugin_root=plugin_root,
|
|
180
|
+
relative_root=relative_root,
|
|
181
|
+
)
|
|
182
|
+
checks.append({"type": kind, **diagnostic})
|
|
183
|
+
return checks
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def _run_one(
|
|
187
|
+
kind: str,
|
|
188
|
+
config: Any,
|
|
189
|
+
*,
|
|
190
|
+
result: StepResult,
|
|
191
|
+
transcript: list[dict[str, Any]],
|
|
192
|
+
state: dict[str, Any],
|
|
193
|
+
plugin_root: Path,
|
|
194
|
+
relative_root: Path | None,
|
|
195
|
+
) -> dict[str, Any]:
|
|
196
|
+
assistant = str(result.assistant_message.get("content", ""))
|
|
197
|
+
if kind == "tool_called":
|
|
198
|
+
if isinstance(config, str):
|
|
199
|
+
config = {"name": config}
|
|
200
|
+
records = _records(result, config["name"])
|
|
201
|
+
passed = _tool_count_pass(len(records), config)
|
|
202
|
+
expected_count = _count_expectation(config)
|
|
203
|
+
return _diagnostic(
|
|
204
|
+
passed=passed,
|
|
205
|
+
label=f"tool {config['name']!r} call count",
|
|
206
|
+
expected=f"tool {config['name']!r} called {expected_count}",
|
|
207
|
+
actual=f"tool {config['name']!r} called {len(records)} time(s)",
|
|
208
|
+
reason=(
|
|
209
|
+
"call count satisfied the configured expectation"
|
|
210
|
+
if passed
|
|
211
|
+
else f"expected {expected_count}, but observed {len(records)} time(s)"
|
|
212
|
+
),
|
|
213
|
+
detail=f"{config['name']} called {len(records)} time(s)",
|
|
214
|
+
)
|
|
215
|
+
if kind == "tool_not_called":
|
|
216
|
+
name = config if isinstance(config, str) else config["name"]
|
|
217
|
+
count = len(_records(result, name))
|
|
218
|
+
passed = count == 0
|
|
219
|
+
return _diagnostic(
|
|
220
|
+
passed=passed,
|
|
221
|
+
label=f"tool {name!r} is not called",
|
|
222
|
+
expected=f"tool {name!r} called 0 time(s)",
|
|
223
|
+
actual=f"tool {name!r} called {count} time(s)",
|
|
224
|
+
reason=(
|
|
225
|
+
"tool was not called"
|
|
226
|
+
if passed
|
|
227
|
+
else f"tool {name!r} was called {count} unexpected time(s)"
|
|
228
|
+
),
|
|
229
|
+
detail=f"{name} called {count} time(s)",
|
|
230
|
+
)
|
|
231
|
+
if kind == "tool_arguments":
|
|
232
|
+
name = config["name"]
|
|
233
|
+
records = _records(result, name)
|
|
234
|
+
call_number = config.get("call")
|
|
235
|
+
if call_number is not None:
|
|
236
|
+
selected_records = (
|
|
237
|
+
[(call_number, records[call_number - 1])]
|
|
238
|
+
if 0 < call_number <= len(records)
|
|
239
|
+
else []
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
selected_records = list(enumerate(records, 1))
|
|
243
|
+
values = [
|
|
244
|
+
(number, _select_jsonpath(record.call.arguments, config["jsonpath"]))
|
|
245
|
+
for number, record in selected_records
|
|
246
|
+
]
|
|
247
|
+
_, _, matcher_description = _matcher(config)
|
|
248
|
+
passed = any(_match(value, config) for _, value in values)
|
|
249
|
+
scope = f"call #{call_number}" if call_number is not None else "any call"
|
|
250
|
+
expected = (
|
|
251
|
+
f"{scope} to tool {name!r} has {config['jsonpath']} that "
|
|
252
|
+
f"{matcher_description}"
|
|
253
|
+
)
|
|
254
|
+
actual = _selected_values(values)
|
|
255
|
+
if passed:
|
|
256
|
+
reason = "at least one selected argument value satisfied the matcher"
|
|
257
|
+
elif not records:
|
|
258
|
+
reason = f"tool {name!r} was never called, so no arguments could be checked"
|
|
259
|
+
elif call_number is not None and not selected_records:
|
|
260
|
+
reason = (
|
|
261
|
+
f"tool {name!r} had {len(records)} call(s), so call #{call_number} "
|
|
262
|
+
"does not exist"
|
|
263
|
+
)
|
|
264
|
+
elif all(value is _MISSING for _, value in values):
|
|
265
|
+
reason = (
|
|
266
|
+
f"JSONPath {config['jsonpath']!r} was missing from every selected "
|
|
267
|
+
f"call to tool {name!r}"
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
reason = (
|
|
271
|
+
f"none of the {len(values)} selected value(s) "
|
|
272
|
+
f"{matcher_description}"
|
|
273
|
+
)
|
|
274
|
+
return _diagnostic(
|
|
275
|
+
passed=passed,
|
|
276
|
+
label=(
|
|
277
|
+
f"tool {name!r} argument {config['jsonpath']} must "
|
|
278
|
+
f"{matcher_description}"
|
|
279
|
+
),
|
|
280
|
+
expected=expected,
|
|
281
|
+
actual=actual,
|
|
282
|
+
reason=reason,
|
|
283
|
+
detail=f"selected values: {actual}",
|
|
284
|
+
)
|
|
285
|
+
if kind == "tool_call_order":
|
|
286
|
+
if ("names" in config) == ("groups" in config):
|
|
287
|
+
raise AssertionConfigurationError(
|
|
288
|
+
"tool_call_order must choose names or groups"
|
|
289
|
+
)
|
|
290
|
+
actual_calls = [record.call.name for record in result.tool_calls]
|
|
291
|
+
if "names" in config:
|
|
292
|
+
expected = config["names"]
|
|
293
|
+
if config.get("exact", False):
|
|
294
|
+
passed = actual_calls == expected
|
|
295
|
+
expectation = f"exact tool sequence {expected!r}"
|
|
296
|
+
else:
|
|
297
|
+
cursor = iter(actual_calls)
|
|
298
|
+
passed = all(
|
|
299
|
+
any(item == wanted for item in cursor) for wanted in expected
|
|
300
|
+
)
|
|
301
|
+
expectation = f"ordered tool subsequence {expected!r}"
|
|
302
|
+
return _diagnostic(
|
|
303
|
+
passed=passed,
|
|
304
|
+
label="tool call order",
|
|
305
|
+
expected=expectation,
|
|
306
|
+
actual=f"tool sequence {actual_calls!r}",
|
|
307
|
+
reason=(
|
|
308
|
+
"tool sequence satisfied the configured order"
|
|
309
|
+
if passed
|
|
310
|
+
else f"expected {expectation}, but it was not found in the actual sequence"
|
|
311
|
+
),
|
|
312
|
+
detail=f"actual order: {actual_calls}",
|
|
313
|
+
)
|
|
314
|
+
groups = config["groups"]
|
|
315
|
+
expected_groups = " -> ".join(repr(group) for group in groups)
|
|
316
|
+
# Existential matching: the assertion passes when one call per group
|
|
317
|
+
# member can be picked such that every pick of a later group lands in
|
|
318
|
+
# a strictly later request round than the previous group's completion.
|
|
319
|
+
# Calls outside the picked ones (repeats, interleaved extras) never
|
|
320
|
+
# fail the order on their own. Greedily picking the earliest eligible
|
|
321
|
+
# round per member minimizes each group's completion round, so if the
|
|
322
|
+
# greedy walk fails no other assignment can succeed.
|
|
323
|
+
completions: list[int] = []
|
|
324
|
+
boundary = -1
|
|
325
|
+
failure_reason = ""
|
|
326
|
+
failure_detail = ""
|
|
327
|
+
for index, group in enumerate(groups, 1):
|
|
328
|
+
rounds_by_name = {
|
|
329
|
+
name: [
|
|
330
|
+
record.request_index
|
|
331
|
+
for record in result.tool_calls
|
|
332
|
+
if record.call.name == name and record.request_index > boundary
|
|
333
|
+
]
|
|
334
|
+
for name in group
|
|
335
|
+
}
|
|
336
|
+
missing = [
|
|
337
|
+
name
|
|
338
|
+
for name, rounds in rounds_by_name.items()
|
|
339
|
+
if not rounds and not _records(result, name)
|
|
340
|
+
]
|
|
341
|
+
if missing:
|
|
342
|
+
failure_reason = (
|
|
343
|
+
f"group #{index} was incomplete; missing tool(s): {missing!r}"
|
|
344
|
+
)
|
|
345
|
+
failure_detail = f"group not fully called: {group}"
|
|
346
|
+
break
|
|
347
|
+
late = [name for name, rounds in rounds_by_name.items() if not rounds]
|
|
348
|
+
if late:
|
|
349
|
+
failure_reason = (
|
|
350
|
+
f"group #{index} could not be matched: {late!r} had no call "
|
|
351
|
+
f"after request {boundary}, where group #{index - 1} completed"
|
|
352
|
+
)
|
|
353
|
+
failure_detail = f"group order not matchable for: {late}"
|
|
354
|
+
break
|
|
355
|
+
boundary = max(min(rounds) for rounds in rounds_by_name.values())
|
|
356
|
+
completions.append(boundary)
|
|
357
|
+
passed = not failure_reason
|
|
358
|
+
return _diagnostic(
|
|
359
|
+
passed=passed,
|
|
360
|
+
label="tool call order by groups",
|
|
361
|
+
expected=f"groups matchable in request order: {expected_groups}",
|
|
362
|
+
actual=(
|
|
363
|
+
f"matched group request indexes {completions!r}; "
|
|
364
|
+
f"tool sequence {actual_calls!r}"
|
|
365
|
+
),
|
|
366
|
+
reason=(
|
|
367
|
+
"every group was matched by calls in strictly later requests "
|
|
368
|
+
"than the previous group"
|
|
369
|
+
if passed
|
|
370
|
+
else failure_reason
|
|
371
|
+
),
|
|
372
|
+
detail=(
|
|
373
|
+
f"matched group request indexes: {completions}"
|
|
374
|
+
if passed
|
|
375
|
+
else failure_detail
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
if kind == "assistant_contains":
|
|
379
|
+
target = str(config)
|
|
380
|
+
position = assistant.find(target)
|
|
381
|
+
passed = position >= 0
|
|
382
|
+
return _diagnostic(
|
|
383
|
+
passed=passed,
|
|
384
|
+
label=f"assistant contains {_render_value(target)}",
|
|
385
|
+
expected=f"assistant response contains literal text {_render_value(target)}",
|
|
386
|
+
actual=(
|
|
387
|
+
f"text found at character {position}"
|
|
388
|
+
if passed
|
|
389
|
+
else f"text not found in assistant response ({len(assistant)} characters)"
|
|
390
|
+
),
|
|
391
|
+
reason=(
|
|
392
|
+
"required text was present"
|
|
393
|
+
if passed
|
|
394
|
+
else f"required literal text {_render_value(target)} was absent"
|
|
395
|
+
),
|
|
396
|
+
detail=f"assistant contains {config!r}: {passed}",
|
|
397
|
+
)
|
|
398
|
+
if kind == "assistant_not_contains":
|
|
399
|
+
target = str(config)
|
|
400
|
+
position = assistant.find(target)
|
|
401
|
+
passed = position < 0
|
|
402
|
+
return _diagnostic(
|
|
403
|
+
passed=passed,
|
|
404
|
+
label=f"assistant omits {_render_value(target)}",
|
|
405
|
+
expected=f"assistant response omits literal text {_render_value(target)}",
|
|
406
|
+
actual=(
|
|
407
|
+
f"text not found in assistant response ({len(assistant)} characters)"
|
|
408
|
+
if passed
|
|
409
|
+
else f"text found at character {position}"
|
|
410
|
+
),
|
|
411
|
+
reason=(
|
|
412
|
+
"forbidden text was absent"
|
|
413
|
+
if passed
|
|
414
|
+
else f"forbidden literal text {_render_value(target)} was present"
|
|
415
|
+
),
|
|
416
|
+
detail=f"assistant omits {config!r}: {passed}",
|
|
417
|
+
)
|
|
418
|
+
if kind == "assistant_matches":
|
|
419
|
+
pattern = str(config)
|
|
420
|
+
match = re.search(pattern, assistant)
|
|
421
|
+
passed = match is not None
|
|
422
|
+
return _diagnostic(
|
|
423
|
+
passed=passed,
|
|
424
|
+
label="assistant matches regex",
|
|
425
|
+
expected=f"assistant response matches regex {_render_value(pattern)}",
|
|
426
|
+
actual=(
|
|
427
|
+
f"matched {_render_value(match.group(0))} at characters "
|
|
428
|
+
f"{match.start()}..{match.end()}"
|
|
429
|
+
if match
|
|
430
|
+
else f"no match in assistant response ({len(assistant)} characters)"
|
|
431
|
+
),
|
|
432
|
+
reason=(
|
|
433
|
+
"regular expression matched the assistant response"
|
|
434
|
+
if passed
|
|
435
|
+
else f"regular expression {_render_value(pattern)} did not match"
|
|
436
|
+
),
|
|
437
|
+
detail=f"assistant matches regex: {passed}",
|
|
438
|
+
)
|
|
439
|
+
if kind == "assistant_semantic_count":
|
|
440
|
+
target = config["target"]
|
|
441
|
+
count = len(re.findall(re.escape(target), assistant, flags=re.IGNORECASE))
|
|
442
|
+
if "equals" in config:
|
|
443
|
+
passed = count == config["equals"]
|
|
444
|
+
expected_count = f"exactly {config['equals']} occurrence(s)"
|
|
445
|
+
else:
|
|
446
|
+
passed = count >= config.get("min", 0) and count <= config.get("max", count)
|
|
447
|
+
minimum = config.get("min", 0)
|
|
448
|
+
maximum = config.get("max")
|
|
449
|
+
expected_count = (
|
|
450
|
+
f"at least {minimum} occurrence(s)"
|
|
451
|
+
if maximum is None
|
|
452
|
+
else f"between {minimum} and {maximum} occurrence(s)"
|
|
453
|
+
)
|
|
454
|
+
return _diagnostic(
|
|
455
|
+
passed=passed,
|
|
456
|
+
label=f"assistant semantic count for {target!r}",
|
|
457
|
+
expected=f"literal marker {target!r} occurs {expected_count}",
|
|
458
|
+
actual=f"literal marker {target!r} occurred {count} time(s)",
|
|
459
|
+
reason=(
|
|
460
|
+
"semantic marker count satisfied the configured expectation"
|
|
461
|
+
if passed
|
|
462
|
+
else f"expected {expected_count}, but observed {count} occurrence(s)"
|
|
463
|
+
),
|
|
464
|
+
detail=f"literal semantic marker {target!r} occurred {count} time(s)",
|
|
465
|
+
)
|
|
466
|
+
if kind == "python":
|
|
467
|
+
if not isinstance(config, dict) or not isinstance(config.get("checker"), str):
|
|
468
|
+
raise AssertionConfigurationError("python assertion requires checker")
|
|
469
|
+
checker = load_python_callable(
|
|
470
|
+
config["checker"], plugin_root, relative_root=relative_root
|
|
471
|
+
)
|
|
472
|
+
value = checker(
|
|
473
|
+
step_result=result,
|
|
474
|
+
transcript=tuple(deepcopy(transcript)),
|
|
475
|
+
state=state,
|
|
476
|
+
config=deepcopy(config.get("with", {})),
|
|
477
|
+
)
|
|
478
|
+
value = await value if inspect.isawaitable(value) else value
|
|
479
|
+
if isinstance(value, dict):
|
|
480
|
+
passed = bool(value.get("passed"))
|
|
481
|
+
detail = str(value.get("detail", value))
|
|
482
|
+
return _diagnostic(
|
|
483
|
+
passed=passed,
|
|
484
|
+
label=str(value.get("label", f"python checker {config['checker']}")),
|
|
485
|
+
expected=str(value.get("expected", "custom checker returns passed=true")),
|
|
486
|
+
actual=str(value.get("actual", detail)),
|
|
487
|
+
reason=str(
|
|
488
|
+
value.get(
|
|
489
|
+
"reason",
|
|
490
|
+
"custom checker passed" if passed else detail,
|
|
491
|
+
)
|
|
492
|
+
),
|
|
493
|
+
detail=detail,
|
|
494
|
+
)
|
|
495
|
+
if isinstance(value, tuple) and len(value) == 2:
|
|
496
|
+
passed, detail = bool(value[0]), str(value[1])
|
|
497
|
+
else:
|
|
498
|
+
passed, detail = bool(value), f"checker returned {value!r}"
|
|
499
|
+
return _diagnostic(
|
|
500
|
+
passed=passed,
|
|
501
|
+
label=f"python checker {config['checker']}",
|
|
502
|
+
expected="custom checker returns a passing result",
|
|
503
|
+
actual=detail,
|
|
504
|
+
reason="custom checker passed" if passed else detail,
|
|
505
|
+
detail=detail,
|
|
506
|
+
)
|
|
507
|
+
raise AssertionConfigurationError(f"unknown hard assertion: {kind}")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
async def run_judge(
|
|
511
|
+
config: dict[str, Any],
|
|
512
|
+
*,
|
|
513
|
+
client: LLMClient,
|
|
514
|
+
step_result: StepResult,
|
|
515
|
+
transcript_before_step: list[dict[str, Any]],
|
|
516
|
+
default_model: dict[str, Any],
|
|
517
|
+
) -> dict[str, Any]:
|
|
518
|
+
dimensions = config.get("dimensions", [])
|
|
519
|
+
if not isinstance(dimensions, list) or not dimensions:
|
|
520
|
+
raise AssertionConfigurationError("judge.dimensions must be non-empty")
|
|
521
|
+
model = deepcopy(default_model)
|
|
522
|
+
if "model" in config:
|
|
523
|
+
model["name"] = config["model"]
|
|
524
|
+
model["temperature"] = config.get("temperature", 0)
|
|
525
|
+
prompt = {
|
|
526
|
+
"dimensions": dimensions,
|
|
527
|
+
"transcript_before_step": transcript_before_step,
|
|
528
|
+
"user_message": step_result.user_message,
|
|
529
|
+
"assistant_message": step_result.assistant_message,
|
|
530
|
+
"tool_calls": [record.to_dict() for record in step_result.tool_calls],
|
|
531
|
+
}
|
|
532
|
+
response = await client.complete(
|
|
533
|
+
messages=[
|
|
534
|
+
{
|
|
535
|
+
"role": "system",
|
|
536
|
+
"content": (
|
|
537
|
+
"You are an evaluation judge. Score every requested dimension from 1 to 5. "
|
|
538
|
+
'Return only JSON: {"dimensions":[{"name":str,"score":int,'
|
|
539
|
+
'"reason":str,"evidence":str}]}'
|
|
540
|
+
),
|
|
541
|
+
},
|
|
542
|
+
{"role": "user", "content": json.dumps(prompt, ensure_ascii=False)},
|
|
543
|
+
],
|
|
544
|
+
tools=[],
|
|
545
|
+
model=model,
|
|
546
|
+
)
|
|
547
|
+
text = response.content.strip()
|
|
548
|
+
if text.startswith("```"):
|
|
549
|
+
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.DOTALL)
|
|
550
|
+
try:
|
|
551
|
+
judged = json.loads(text)
|
|
552
|
+
except json.JSONDecodeError as exc:
|
|
553
|
+
raise AssertionConfigurationError(
|
|
554
|
+
f"judge returned invalid JSON: {text}"
|
|
555
|
+
) from exc
|
|
556
|
+
scores = {item["name"]: item for item in judged.get("dimensions", [])}
|
|
557
|
+
weighted = 0.0
|
|
558
|
+
total_weight = 0.0
|
|
559
|
+
passed = True
|
|
560
|
+
for dimension in dimensions:
|
|
561
|
+
item = scores.get(dimension["name"])
|
|
562
|
+
if (
|
|
563
|
+
not item
|
|
564
|
+
or not isinstance(item.get("score"), int)
|
|
565
|
+
or not 1 <= item["score"] <= 5
|
|
566
|
+
):
|
|
567
|
+
raise AssertionConfigurationError(
|
|
568
|
+
f"judge omitted dimension: {dimension['name']}"
|
|
569
|
+
)
|
|
570
|
+
weight = float(dimension.get("weight", 1.0))
|
|
571
|
+
weighted += ((item["score"] - 1) / 4) * weight
|
|
572
|
+
total_weight += weight
|
|
573
|
+
if item["score"] < dimension.get("min_score", 1):
|
|
574
|
+
passed = False
|
|
575
|
+
judged["overall"] = weighted / total_weight
|
|
576
|
+
judged["passed"] = passed
|
|
577
|
+
return judged
|