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.
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