casebench 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.
Files changed (242) hide show
  1. casebench/__init__.py +5 -0
  2. casebench/capture/__init__.py +1 -0
  3. casebench/capture/assemble.py +92 -0
  4. casebench/capture/backend.py +47 -0
  5. casebench/capture/cli.py +36 -0
  6. casebench/capture/config.py +28 -0
  7. casebench/capture/conformance.py +33 -0
  8. casebench/capture/context.py +53 -0
  9. casebench/capture/corpus_routes.py +50 -0
  10. casebench/capture/functions.py +115 -0
  11. casebench/capture/interface.py +23 -0
  12. casebench/capture/otlp/__init__.py +1 -0
  13. casebench/capture/otlp/decode.py +82 -0
  14. casebench/capture/otlp/mapping.py +105 -0
  15. casebench/capture/parsers/__init__.py +1 -0
  16. casebench/capture/parsers/anthropic/__init__.py +86 -0
  17. casebench/capture/parsers/anthropic/casebench-plugin.json +5 -0
  18. casebench/capture/parsers/anthropic/samples/plain-with-tool-result.json +76 -0
  19. casebench/capture/parsers/anthropic/samples/streamed-tool-call.json +97 -0
  20. casebench/capture/parsers/gemini/__init__.py +77 -0
  21. casebench/capture/parsers/gemini/casebench-plugin.json +5 -0
  22. casebench/capture/parsers/gemini/samples/plain-with-function-response.json +86 -0
  23. casebench/capture/parsers/gemini/samples/streamed-function-call.json +86 -0
  24. casebench/capture/parsers/openai/__init__.py +106 -0
  25. casebench/capture/parsers/openai/casebench-plugin.json +5 -0
  26. casebench/capture/parsers/openai/samples/plain-with-tool-result.json +72 -0
  27. casebench/capture/parsers/openai/samples/streamed-tool-call.json +104 -0
  28. casebench/capture/proxy/__init__.py +1 -0
  29. casebench/capture/proxy/forward.py +143 -0
  30. casebench/capture/proxy/redact.py +18 -0
  31. casebench/capture/proxy/routes.py +45 -0
  32. casebench/capture/proxy/server.py +38 -0
  33. casebench/capture/proxy/sse.py +23 -0
  34. casebench/capture/read.py +17 -0
  35. casebench/capture/recorder.py +82 -0
  36. casebench/capture/redirect.py +83 -0
  37. casebench/capture/routing.py +16 -0
  38. casebench/capture/session.py +165 -0
  39. casebench/capture/sizes.py +11 -0
  40. casebench/capture/step.py +75 -0
  41. casebench/cases/__init__.py +1 -0
  42. casebench/cases/build.py +98 -0
  43. casebench/cases/cli.py +214 -0
  44. casebench/cases/draft.py +42 -0
  45. casebench/cases/resolve.py +39 -0
  46. casebench/cases/suite.py +31 -0
  47. casebench/cli.py +60 -0
  48. casebench/config.py +128 -0
  49. casebench/core/__init__.py +1 -0
  50. casebench/core/canonical.py +20 -0
  51. casebench/core/case.py +140 -0
  52. casebench/core/jurisdiction.py +57 -0
  53. casebench/core/model.py +15 -0
  54. casebench/core/score.py +95 -0
  55. casebench/corpus/__init__.py +1 -0
  56. casebench/corpus/add.py +115 -0
  57. casebench/corpus/add_cli.py +607 -0
  58. casebench/corpus/binding.py +122 -0
  59. casebench/corpus/bundled.py +75 -0
  60. casebench/corpus/cache.py +118 -0
  61. casebench/corpus/check.py +77 -0
  62. casebench/corpus/cli.py +105 -0
  63. casebench/corpus/conformance.py +43 -0
  64. casebench/corpus/connectors/__init__.py +1 -0
  65. casebench/corpus/connectors/folder/__init__.py +68 -0
  66. casebench/corpus/connectors/folder/casebench-plugin.json +5 -0
  67. casebench/corpus/connectors/folder/samples/README.md +4 -0
  68. casebench/corpus/connectors/folder/samples/config.json +1 -0
  69. casebench/corpus/connectors/folder/samples/corpus/sample-1.json +1 -0
  70. casebench/corpus/connectors/folder/samples/corpus/sample-2.json +1 -0
  71. casebench/corpus/connectors/http/__init__.py +86 -0
  72. casebench/corpus/connectors/mcp/__init__.py +170 -0
  73. casebench/corpus/corpora.py +76 -0
  74. casebench/corpus/from_profile.py +135 -0
  75. casebench/corpus/guard.py +39 -0
  76. casebench/corpus/ids.py +21 -0
  77. casebench/corpus/interface.py +56 -0
  78. casebench/corpus/mapping.py +24 -0
  79. casebench/corpus/passage.py +55 -0
  80. casebench/corpus/probe_cli.py +77 -0
  81. casebench/corpus/profile.py +55 -0
  82. casebench/corpus/profiles/README.md +13 -0
  83. casebench/corpus/record.py +27 -0
  84. casebench/corpus/results.py +56 -0
  85. casebench/corpus/routing.py +69 -0
  86. casebench/corpus/suggest.py +80 -0
  87. casebench/envfiles.py +94 -0
  88. casebench/exporters/__init__.py +1 -0
  89. casebench/exporters/interface.py +11 -0
  90. casebench/extract/__init__.py +1 -0
  91. casebench/extract/agreement.py +52 -0
  92. casebench/extract/cli.py +150 -0
  93. casebench/extract/compare.py +58 -0
  94. casebench/extract/config.py +19 -0
  95. casebench/extract/estimate.py +93 -0
  96. casebench/extract/prompt.py +30 -0
  97. casebench/extract/reading.py +54 -0
  98. casebench/extract/run.py +162 -0
  99. casebench/formats/__init__.py +1 -0
  100. casebench/formats/extensions.py +20 -0
  101. casebench/formats/files.py +28 -0
  102. casebench/formats/read.py +39 -0
  103. casebench/formats/registry.py +47 -0
  104. casebench/formats/schema.py +29 -0
  105. casebench/formats/schema_cli.py +38 -0
  106. casebench/formats/schemas/case/1.0.json +320 -0
  107. casebench/formats/schemas/document/1.0.json +57 -0
  108. casebench/formats/schemas/index.json +3 -0
  109. casebench/formats/schemas/profile/1.0.json +142 -0
  110. casebench/formats/schemas/report/1.0.json +819 -0
  111. casebench/formats/schemas/suite/1.0.json +84 -0
  112. casebench/formats/validate_cli.py +96 -0
  113. casebench/formats/version.py +62 -0
  114. casebench/judge/__init__.py +1 -0
  115. casebench/judge/budget.py +33 -0
  116. casebench/judge/client.py +85 -0
  117. casebench/judge/config.py +33 -0
  118. casebench/judge/judges.py +21 -0
  119. casebench/judge/memo.py +37 -0
  120. casebench/judge/prompts.py +81 -0
  121. casebench/judge/resolve.py +135 -0
  122. casebench/key_record.py +37 -0
  123. casebench/keys.py +48 -0
  124. casebench/loading.py +70 -0
  125. casebench/metrics/__init__.py +1 -0
  126. casebench/metrics/authorities.py +102 -0
  127. casebench/metrics/base.py +73 -0
  128. casebench/metrics/builtin/__init__.py +1 -0
  129. casebench/metrics/builtin/citation_integrity/__init__.py +102 -0
  130. casebench/metrics/builtin/criteria/__init__.py +53 -0
  131. casebench/metrics/builtin/outcome/__init__.py +45 -0
  132. casebench/metrics/builtin/precedent/__init__.py +8 -0
  133. casebench/metrics/builtin/statute/__init__.py +8 -0
  134. casebench/metrics/criteria.py +94 -0
  135. casebench/metrics/loading.py +31 -0
  136. casebench/plugins/__init__.py +1 -0
  137. casebench/plugins/cli.py +66 -0
  138. casebench/plugins/conformance.py +90 -0
  139. casebench/plugins/kinds.py +67 -0
  140. casebench/plugins/manifest.py +59 -0
  141. casebench/plugins/registry.py +67 -0
  142. casebench/plugins/samples.py +43 -0
  143. casebench/plugins/scaffold.py +111 -0
  144. casebench/plugins/templates.py +130 -0
  145. casebench/providers/__init__.py +1 -0
  146. casebench/providers/builtin/__init__.py +1 -0
  147. casebench/providers/builtin/anthropic/__init__.py +72 -0
  148. casebench/providers/builtin/anthropic/casebench-plugin.json +5 -0
  149. casebench/providers/builtin/anthropic/samples/config.json +1 -0
  150. casebench/providers/builtin/anthropic/samples/list.json +38 -0
  151. casebench/providers/builtin/azure/__init__.py +60 -0
  152. casebench/providers/builtin/bedrock/__init__.py +66 -0
  153. casebench/providers/builtin/google/__init__.py +65 -0
  154. casebench/providers/builtin/openai/__init__.py +64 -0
  155. casebench/providers/builtin/openai/casebench-plugin.json +5 -0
  156. casebench/providers/builtin/openai/samples/list.json +35 -0
  157. casebench/providers/call.py +44 -0
  158. casebench/providers/conformance.py +38 -0
  159. casebench/providers/interface.py +57 -0
  160. casebench/providers/send.py +86 -0
  161. casebench/providers/settings.py +31 -0
  162. casebench/py.typed +0 -0
  163. casebench/references/__init__.py +1 -0
  164. casebench/references/court.py +43 -0
  165. casebench/references/interface.py +12 -0
  166. casebench/report/__init__.py +1 -0
  167. casebench/report/agreement.py +32 -0
  168. casebench/report/baselines.py +24 -0
  169. casebench/report/citations.py +24 -0
  170. casebench/report/cli.py +89 -0
  171. casebench/report/comparable.py +71 -0
  172. casebench/report/compare.py +45 -0
  173. casebench/report/funnel.py +156 -0
  174. casebench/report/record.py +49 -0
  175. casebench/report/render.py +142 -0
  176. casebench/report/statistics.py +60 -0
  177. casebench/report/tables.py +99 -0
  178. casebench/runs/__init__.py +1 -0
  179. casebench/runs/cli.py +175 -0
  180. casebench/runs/dry_run.py +128 -0
  181. casebench/runs/log.py +136 -0
  182. casebench/runs/producer.py +74 -0
  183. casebench/runs/progress.py +32 -0
  184. casebench/runs/rescore.py +69 -0
  185. casebench/runs/runner.py +89 -0
  186. casebench/runs/scoring.py +152 -0
  187. casebench/setup/__init__.py +1 -0
  188. casebench/setup/advice.py +30 -0
  189. casebench/setup/catalog.py +112 -0
  190. casebench/setup/check_cli.py +74 -0
  191. casebench/setup/checks.py +264 -0
  192. casebench/setup/corpus_question.py +216 -0
  193. casebench/setup/guide.md +142 -0
  194. casebench/setup/guide_cli.py +38 -0
  195. casebench/setup/init_cli.py +437 -0
  196. casebench/setup/judge_question.py +89 -0
  197. casebench/setup/key_question.py +136 -0
  198. casebench/setup/keys_cli.py +58 -0
  199. casebench/setup/models.py +68 -0
  200. casebench/setup/models_cli.py +56 -0
  201. casebench/setup/picker.py +112 -0
  202. casebench/setup/routes.py +106 -0
  203. casebench/setup/scan.py +276 -0
  204. casebench/setup/screen.py +109 -0
  205. casebench/setup/sdks.py +76 -0
  206. casebench/setup/summary.py +79 -0
  207. casebench/setup/update.py +80 -0
  208. casebench/suites/__init__.py +1 -0
  209. casebench/suites/cli.py +133 -0
  210. casebench/suites/court.py +143 -0
  211. casebench/suites/freeze.py +147 -0
  212. casebench/suites/generate.py +41 -0
  213. casebench/suites/qualify.py +36 -0
  214. casebench/suites/resolve.py +52 -0
  215. casebench/targets/__init__.py +1 -0
  216. casebench/targets/answer.py +32 -0
  217. casebench/targets/builtin/__init__.py +1 -0
  218. casebench/targets/builtin/command/__init__.py +52 -0
  219. casebench/targets/builtin/command/casebench-plugin.json +5 -0
  220. casebench/targets/builtin/command/samples/config.json +1 -0
  221. casebench/targets/builtin/http/__init__.py +105 -0
  222. casebench/targets/builtin/http/casebench-plugin.json +5 -0
  223. casebench/targets/builtin/http/samples/config.json +1 -0
  224. casebench/targets/builtin/python/__init__.py +144 -0
  225. casebench/targets/builtin/python/casebench-plugin.json +5 -0
  226. casebench/targets/builtin/python/entry.py +30 -0
  227. casebench/targets/builtin/python/framing.py +31 -0
  228. casebench/targets/builtin/python/samples/config.json +1 -0
  229. casebench/targets/builtin/python/trace_context.py +21 -0
  230. casebench/targets/builtin/python/worker.py +57 -0
  231. casebench/targets/cli.py +53 -0
  232. casebench/targets/interface.py +22 -0
  233. casebench/targets/load.py +19 -0
  234. casebench/targets/placeholders.py +63 -0
  235. casebench/targets/sample.py +34 -0
  236. casebench/tasks/__init__.py +1 -0
  237. casebench/tasks/interface.py +16 -0
  238. casebench-0.1.0.dist-info/METADATA +82 -0
  239. casebench-0.1.0.dist-info/RECORD +242 -0
  240. casebench-0.1.0.dist-info/WHEEL +4 -0
  241. casebench-0.1.0.dist-info/entry_points.txt +32 -0
  242. casebench-0.1.0.dist-info/licenses/LICENSE +211 -0
casebench/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Measure the legal accuracy of an AI system against court decisions and lawyers' references."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("casebench")
@@ -0,0 +1 @@
1
+ """Capture: the step model and the parsers that read backend traffic."""
@@ -0,0 +1,92 @@
1
+ """Turn one case's exchanges into steps, pair tool calls and show unaccounted time."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ from casebench.capture.read import ids_in
6
+ from casebench.capture.step import HttpExchange, Step, StepKind, ToolCall
7
+
8
+ ParseExchange = Callable[[HttpExchange], list[Step]]
9
+
10
+
11
+ def paired_tools(steps: list[Step]) -> list[Step]:
12
+ """A tool call asked for in one model response and answered in a later request is a tool
13
+ step: its name and arguments, the result's size, and the time between the two calls."""
14
+ pending: dict[str, tuple[ToolCall, float | None]] = {}
15
+ tools: list[Step] = []
16
+ for step in sorted((s for s in steps if s.kind is StepKind.LLM), key=_start):
17
+ for result in step.tool_results:
18
+ asked = pending.pop(result.id, None)
19
+ if asked is None:
20
+ continue
21
+ call, asked_at = asked
22
+ duration = _ms(asked_at, step.started)
23
+ tools.append(
24
+ Step(
25
+ kind=StepKind.TOOL,
26
+ name=call.name,
27
+ arguments=call.arguments,
28
+ result_size=result.size,
29
+ started=asked_at,
30
+ ended=step.started,
31
+ duration_ms=duration,
32
+ )
33
+ )
34
+ for call in step.requested_tools:
35
+ pending[call.id] = (call, step.ended)
36
+ return tools
37
+
38
+
39
+ def gaps(steps: list[Step]) -> list[Step]:
40
+ """Time between steps that no step covers, named after the steps on either side.
41
+ Gaps are reported to the millisecond, the resolution durations are shown in."""
42
+ timed = sorted((s for s in steps if s.started is not None and s.ended is not None), key=_start)
43
+ found: list[Step] = []
44
+ covered_until: float | None = None
45
+ before: Step | None = None
46
+ for step in timed:
47
+ assert step.started is not None and step.ended is not None
48
+ if covered_until is not None and before is not None:
49
+ missing = round((step.started - covered_until) * 1000)
50
+ if missing > 0:
51
+ found.append(
52
+ Step(
53
+ kind=StepKind.GAP,
54
+ name=f"between {_label(before)} and {_label(step)}",
55
+ started=covered_until,
56
+ ended=step.started,
57
+ duration_ms=float(missing),
58
+ )
59
+ )
60
+ if covered_until is None or step.ended > covered_until:
61
+ covered_until, before = step.ended, step
62
+ return found
63
+
64
+
65
+ def assemble(
66
+ exchanges: list[HttpExchange], parse: ParseExchange, in_process: list[Step]
67
+ ) -> list[Step]:
68
+ parsed = [(exchange, parse(exchange)) for exchange in exchanges]
69
+ known = {i for _, found in parsed for s in found for i in s.doc_ids_out}
70
+ known |= {i for s in in_process for i in s.doc_ids_out}
71
+ steps = [
72
+ step.model_copy(update={"doc_ids_in": ids_in(exchange.request_body, known)})
73
+ if step.kind is StepKind.LLM
74
+ else step
75
+ for exchange, found in parsed
76
+ for step in found
77
+ ] + list(in_process)
78
+ steps += paired_tools(steps)
79
+ steps += gaps(steps)
80
+ return sorted(steps, key=_start)
81
+
82
+
83
+ def _start(step: Step) -> float:
84
+ return step.started if step.started is not None else float("inf")
85
+
86
+
87
+ def _ms(start: float | None, end: float | None) -> float | None:
88
+ return (end - start) * 1000 if start is not None and end is not None else None
89
+
90
+
91
+ def _label(step: Step) -> str:
92
+ return f"{step.kind.value} {step.name or step.model or ''}".rstrip()
@@ -0,0 +1,47 @@
1
+ """Read a backend route's document ids by the JSONPath its config names, or from the path."""
2
+
3
+ import re
4
+ from typing import Any
5
+ from urllib.parse import unquote, urlsplit
6
+
7
+ from jsonpath_ng.ext.parser import parse as jsonpath
8
+
9
+ from casebench.capture.proxy.routes import Route
10
+ from casebench.capture.step import NO_DIAGNOSTIC, HttpExchange, Step, StepKind
11
+
12
+
13
+ def _ids(expression: str | None, document: Any) -> list[str]:
14
+ if expression is None or document is None:
15
+ return []
16
+ return [str(match.value) for match in jsonpath(expression).find(document)]
17
+
18
+
19
+ def fetched(route: Route, url: str) -> str | None:
20
+ """The id a request reads, when its path ends as the route's `fetch_path` does."""
21
+ if route.fetch_path is None or "{id}" not in route.fetch_path:
22
+ return None
23
+ before, _, after = route.fetch_path.partition("{id}")
24
+ pattern = re.escape(before) + r"([^/]+)" + re.escape(after) + "$"
25
+ found = re.search(pattern, urlsplit(url).path)
26
+ return unquote(found.group(1)) if found else None
27
+
28
+
29
+ def backend_step(exchange: HttpExchange, route: Route) -> Step:
30
+ """One retrieval, rerank or document fetch; without ids found, it says so."""
31
+ document_id = fetched(route, exchange.url)
32
+ if document_id is not None:
33
+ kind, ids_in, ids_out = StepKind.DOCUMENT, [], [document_id]
34
+ else:
35
+ kind = StepKind(route.step)
36
+ ids_out = _ids(route.doc_ids, exchange.response_body)
37
+ ids_in = _ids(route.doc_ids_in, exchange.request_body)
38
+ return Step(
39
+ kind=kind,
40
+ name=route.name,
41
+ doc_ids_in=ids_in,
42
+ doc_ids_out=ids_out,
43
+ started=exchange.started,
44
+ ended=exchange.ended,
45
+ duration_ms=exchange.duration_ms,
46
+ note=None if ids_out else NO_DIAGNOSTIC,
47
+ )
@@ -0,0 +1,36 @@
1
+ """`casebench proxy env`: the variables that point an already-running agent at the proxy."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from casebench.capture.config import routes_for
8
+ from casebench.capture.proxy.routes import environment
9
+ from casebench.capture.session import TRACES_ENDPOINT, traces_endpoint
10
+ from casebench.config import DEFAULT_CONFIG, load_config
11
+
12
+
13
+ @click.group()
14
+ def proxy() -> None:
15
+ """The recording proxy that captures model and backend calls."""
16
+
17
+
18
+ @proxy.command()
19
+ @click.option(
20
+ "--config",
21
+ "config_path",
22
+ type=click.Path(dir_okay=False, path_type=Path),
23
+ default=DEFAULT_CONFIG,
24
+ show_default=True,
25
+ )
26
+ def env(config_path: Path) -> None:
27
+ """Print the lines to set in the agent's environment."""
28
+ config = load_config(config_path)
29
+ if config.capture is None or config.capture.port == 0:
30
+ raise click.ClickException(
31
+ "set capture.port in the config so an agent that is already running can reach "
32
+ "the proxy; when Casebench starts the agent it sets these itself"
33
+ )
34
+ for name, value in environment(routes_for(config.capture), config.capture.port).items():
35
+ click.echo(f"{name}={value}")
36
+ click.echo(f"{TRACES_ENDPOINT}={traces_endpoint(config.capture.port)}")
@@ -0,0 +1,28 @@
1
+ """The `capture:` section of casebench.yaml: the proxy's port and its routes."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import Field
6
+
7
+ from casebench.capture.functions import StepFunction
8
+ from casebench.capture.proxy.routes import Route, default_routes
9
+ from casebench.core.model import Record
10
+
11
+
12
+ class CaptureConfig(Record):
13
+ """`port: 0` lets the operating system choose; name a port for an agent already running.
14
+ `traces: true` points the OpenTelemetry exporter of agents Casebench starts at it."""
15
+
16
+ port: int = 0
17
+ routes: list[dict[str, Any]] = Field(default_factory=list)
18
+ functions: list[StepFunction] = Field(default_factory=list)
19
+ traces: bool = False
20
+
21
+
22
+ def routes_for(capture: CaptureConfig | None) -> list[Route]:
23
+ """The parsers' default routes, with the config's fields laid over a route of the same
24
+ name, and the config's new routes added."""
25
+ merged = {route.name: route.model_dump() for route in default_routes()}
26
+ for route in capture.routes if capture else []:
27
+ merged[route["name"]] = {**merged.get(route["name"], {}), **route}
28
+ return [Route.model_validate(fields) for fields in merged.values()]
@@ -0,0 +1,33 @@
1
+ """Conformance for capture parsers, measured as the share of recorded calls parsed.
2
+
3
+ A sample is `{"exchange": <HTTP exchange>, "expected": [<step>, ...]}`.
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ from casebench.capture.interface import CaptureParser
9
+ from casebench.capture.step import HttpExchange, Step
10
+ from casebench.plugins.samples import SuiteResult, ratio, read_json, sample_files
11
+
12
+
13
+ def check(parser: CaptureParser, samples: Path) -> SuiteResult:
14
+ result = SuiteResult()
15
+ parsed = 0
16
+ if not parser.hosts:
17
+ result.problems.append("hosts is empty, so no traffic would reach this parser")
18
+ for path in sample_files(samples):
19
+ result.samples += 1
20
+ sample = read_json(path)
21
+ exchange = HttpExchange.model_validate(sample["exchange"])
22
+ expected = [Step.model_validate(step) for step in sample["expected"]]
23
+ try:
24
+ steps = parser.parse(exchange)
25
+ except Exception as error:
26
+ result.problems.append(f"{path.name}: {type(error).__name__}: {error}")
27
+ continue
28
+ if not all(isinstance(step, Step) for step in steps):
29
+ result.problems.append(f"{path.name}: parse() must return Step records")
30
+ continue
31
+ parsed += steps == expected
32
+ result.figure = ratio(parsed, result.samples)
33
+ return result
@@ -0,0 +1,53 @@
1
+ """Tie every captured step to the case that caused it."""
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from contextvars import ContextVar
6
+ from dataclasses import dataclass, field
7
+
8
+ from casebench.capture.step import Step
9
+
10
+
11
+ @dataclass
12
+ class CaseCapture:
13
+ case_id: str
14
+ traceparent: str | None = None
15
+ worker: str | None = None
16
+ steps: list[Step] = field(default_factory=list)
17
+
18
+
19
+ _current: ContextVar[CaseCapture | None] = ContextVar("casebench_case", default=None)
20
+
21
+
22
+ @contextmanager
23
+ def capturing(case_id: str, traceparent: str | None = None) -> Iterator[CaseCapture]:
24
+ """Steps recorded inside this block belong to this case, even with other cases
25
+ running at the same time, because each asyncio task and thread gets its own copy."""
26
+ capture = CaseCapture(case_id, traceparent)
27
+ token = _current.set(capture)
28
+ try:
29
+ yield capture
30
+ finally:
31
+ _current.reset(token)
32
+
33
+
34
+ def record(step: Step) -> bool:
35
+ """Attach a step to the running case; False when no case is running."""
36
+ capture = _current.get()
37
+ if capture is None:
38
+ return False
39
+ capture.steps.append(step)
40
+ return True
41
+
42
+
43
+ def current_traceparent() -> str | None:
44
+ """The running case's trace context, to pass on to an agent outside this process."""
45
+ capture = _current.get()
46
+ return capture.traceparent if capture is not None else None
47
+
48
+
49
+ def set_worker(worker: str) -> None:
50
+ """Note which worker process answers the running case, so its calls are found by path."""
51
+ capture = _current.get()
52
+ if capture is not None:
53
+ capture.worker = worker
@@ -0,0 +1,50 @@
1
+ """Capture routes for the corpora the config names, so an agent's own searches are recorded.
2
+
3
+ An agent that searches the same document store Casebench checks citations against calls it
4
+ at the corpus's address. A Python agent's calls arrive at the proxy by where they go
5
+ (decision 0044), so a route at that host reads them: a search as a retrieval with the ids
6
+ the corpus's search results hold, a fetch as the document its path names.
7
+ """
8
+
9
+ from typing import Any
10
+ from urllib.parse import urlsplit
11
+
12
+ from casebench.capture.proxy.routes import Route
13
+ from casebench.corpus.binding import CorpusConfig
14
+ from casebench.targets.placeholders import expand_env
15
+
16
+
17
+ def ids_path(options: dict[str, Any]) -> str | None:
18
+ """Where a corpus's search response holds its ids, as one JSONPath."""
19
+ results, fields = options.get("search_results"), options.get("result_fields") or {}
20
+ if results and fields.get("id"):
21
+ return f"{results}{str(fields['id']).removeprefix('$')}"
22
+ return options.get("search_ids")
23
+
24
+
25
+ def corpus_routes(corpora: dict[str, CorpusConfig], taken: list[Route]) -> list[Route]:
26
+ """A route at each HTTP corpus's host that no route covers yet. A base URL that names a
27
+ variable is read as the corpus reads it; one that cannot be read here gets no route."""
28
+ covered = {urlsplit(route.upstream).netloc for route in taken}
29
+ made = []
30
+ for name, corpus in corpora.items():
31
+ if corpus.connector != "http" or not corpus.options.get("base_url"):
32
+ continue
33
+ try:
34
+ base = expand_env({"base_url": str(corpus.options["base_url"])})["base_url"]
35
+ except KeyError:
36
+ continue
37
+ parts = urlsplit(base)
38
+ if parts.scheme not in ("http", "https") or not parts.netloc or parts.netloc in covered:
39
+ continue
40
+ covered.add(parts.netloc)
41
+ made.append(
42
+ Route(
43
+ name=f"corpus-{name}",
44
+ upstream=f"{parts.scheme}://{parts.netloc}",
45
+ step="retrieval",
46
+ doc_ids=ids_path(corpus.options),
47
+ fetch_path=corpus.options.get("fetch_path"),
48
+ )
49
+ )
50
+ return made
@@ -0,0 +1,115 @@
1
+ """Wrap functions the config names so each call records a step with its document ids."""
2
+
3
+ import dataclasses
4
+ import functools
5
+ import inspect
6
+ import json
7
+ import time
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+ from jsonpath_ng.ext.parser import parse as jsonpath
12
+
13
+ from casebench.capture.context import record
14
+ from casebench.capture.step import NO_DIAGNOSTIC, Step, StepKind
15
+ from casebench.core.model import Record
16
+ from casebench.loading import import_location, resolve, split_entry
17
+
18
+ # Set by the capture session for worker processes (decision 0022).
19
+ STEP_FUNCTIONS = "CASEBENCH_STEP_FUNCTIONS"
20
+
21
+
22
+ class StepFunction(Record):
23
+ """`capture.functions` in casebench.yaml."""
24
+
25
+ function: str
26
+ step: StepKind = StepKind.RERANK
27
+ name: str | None = None
28
+ doc_ids_in: str | None = None
29
+ doc_ids_out: str | None = None
30
+
31
+
32
+ def _plain(value: Any) -> Any:
33
+ """Dataclasses, pydantic models and plain objects as their fields, for JSONPath."""
34
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
35
+ return {k: _plain(v) for k, v in dataclasses.asdict(value).items()}
36
+ dump = getattr(value, "model_dump", None)
37
+ if callable(dump) and not isinstance(value, type):
38
+ return _plain(dump())
39
+ if isinstance(value, dict):
40
+ return {str(k): _plain(v) for k, v in value.items()}
41
+ if isinstance(value, list | tuple | set):
42
+ return [_plain(v) for v in value]
43
+ if hasattr(value, "__dict__") and not callable(value):
44
+ return {k: _plain(v) for k, v in vars(value).items() if not k.startswith("_")}
45
+ return value
46
+
47
+
48
+ def _ids(expression: str | None, document: Any) -> list[str]:
49
+ if expression is None:
50
+ return []
51
+ return [str(match.value) for match in jsonpath(expression).find(document)]
52
+
53
+
54
+ def _step(
55
+ spec: StepFunction, arguments: dict[str, Any], result: Any, started: float, begun: float
56
+ ) -> Step:
57
+ """`started` orders the step by the wall clock; `begun` times it by the monotonic one."""
58
+ ended = time.time()
59
+ document = {"arguments": _plain(arguments), "result": _plain(result)}
60
+ ids_out = _ids(spec.doc_ids_out, document)
61
+ return Step(
62
+ kind=spec.step,
63
+ name=spec.name or spec.function.rsplit(":", 1)[-1],
64
+ doc_ids_in=_ids(spec.doc_ids_in, document),
65
+ doc_ids_out=ids_out,
66
+ started=started,
67
+ ended=ended,
68
+ duration_ms=(time.perf_counter() - begun) * 1000,
69
+ note=None if ids_out else NO_DIAGNOSTIC,
70
+ )
71
+
72
+
73
+ def wrap(spec: StepFunction, original: Callable[..., Any]) -> Callable[..., Any]:
74
+ signature = inspect.signature(original)
75
+
76
+ def bound(args: tuple[Any, ...], kwargs: dict[str, Any]) -> dict[str, Any]:
77
+ try:
78
+ return dict(signature.bind(*args, **kwargs).arguments)
79
+ except TypeError:
80
+ return {"args": list(args), **kwargs}
81
+
82
+ if inspect.iscoroutinefunction(original):
83
+
84
+ @functools.wraps(original)
85
+ async def wrapped_async(*args: Any, **kwargs: Any) -> Any:
86
+ started, begun = time.time(), time.perf_counter()
87
+ result = await original(*args, **kwargs)
88
+ record(_step(spec, bound(args, kwargs), result, started, begun))
89
+ return result
90
+
91
+ return wrapped_async
92
+
93
+ @functools.wraps(original)
94
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
95
+ started, begun = time.time(), time.perf_counter()
96
+ result = original(*args, **kwargs)
97
+ record(_step(spec, bound(args, kwargs), result, started, begun))
98
+ return result
99
+
100
+ return wrapped
101
+
102
+
103
+ def install(specs: list[StepFunction]) -> None:
104
+ """Replace each named function on the object that holds it; a missing name stops here."""
105
+ for spec in specs:
106
+ location, attribute, is_file = split_entry(spec.function)
107
+ owner, name = resolve(import_location(location, is_file), attribute)
108
+ original = getattr(owner, name, None)
109
+ if not callable(original):
110
+ raise LookupError(f"capture.functions names {spec.function}, which is not a function")
111
+ setattr(owner, name, wrap(spec, original))
112
+
113
+
114
+ def specs_from_environment(value: str | None) -> list[StepFunction]:
115
+ return [StepFunction.model_validate(item) for item in json.loads(value)] if value else []
@@ -0,0 +1,23 @@
1
+ """The capture parser contract: how to read one backend's traffic."""
2
+
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ from casebench.capture.step import HttpExchange, Step
6
+
7
+
8
+ @runtime_checkable
9
+ class CaptureParser(Protocol):
10
+ """Turns one backend's HTTP exchanges into normalised steps.
11
+
12
+ A parser may also list `packages`: the SDKs whose calls it reads, as their package
13
+ registries spell them, so `casebench init` can name the SDKs it finds in a project.
14
+ """
15
+
16
+ @property
17
+ def hosts(self) -> list[str]:
18
+ """The hosts whose traffic this parser reads, such as `api.example.com`."""
19
+ ...
20
+
21
+ def parse(self, exchange: HttpExchange) -> list[Step]:
22
+ """The steps one exchange shows; an empty list if it shows none."""
23
+ ...
@@ -0,0 +1 @@
1
+ """Receive OpenTelemetry spans a team already emits and turn them into steps."""
@@ -0,0 +1,82 @@
1
+ """Read an OTLP trace export, protobuf or JSON, into plain spans."""
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
8
+ from opentelemetry.proto.common.v1.common_pb2 import AnyValue
9
+
10
+ NANOS = 1_000_000_000
11
+
12
+
13
+ @dataclass
14
+ class RawSpan:
15
+ trace_id: str
16
+ name: str
17
+ started: float
18
+ ended: float
19
+ attributes: dict[str, Any] = field(default_factory=dict)
20
+
21
+
22
+ def _proto_value(value: AnyValue) -> Any:
23
+ kind = value.WhichOneof("value")
24
+ if kind == "array_value":
25
+ return [_proto_value(item) for item in value.array_value.values]
26
+ if kind == "kvlist_value":
27
+ return {item.key: _proto_value(item.value) for item in value.kvlist_value.values}
28
+ if kind == "bytes_value":
29
+ return value.bytes_value.hex()
30
+ return getattr(value, kind) if kind else None
31
+
32
+
33
+ def from_protobuf(body: bytes) -> list[RawSpan]:
34
+ request = ExportTraceServiceRequest.FromString(body)
35
+ return [
36
+ RawSpan(
37
+ trace_id=span.trace_id.hex(),
38
+ name=span.name,
39
+ started=span.start_time_unix_nano / NANOS,
40
+ ended=span.end_time_unix_nano / NANOS,
41
+ attributes={a.key: _proto_value(a.value) for a in span.attributes},
42
+ )
43
+ for resource in request.resource_spans
44
+ for scope in resource.scope_spans
45
+ for span in scope.spans
46
+ ]
47
+
48
+
49
+ def _json_value(value: dict[str, Any]) -> Any:
50
+ if "arrayValue" in value:
51
+ return [_json_value(item) for item in value["arrayValue"].get("values", [])]
52
+ if "kvlistValue" in value:
53
+ return {i["key"]: _json_value(i["value"]) for i in value["kvlistValue"].get("values", [])}
54
+ if "intValue" in value:
55
+ return int(value["intValue"]) # OTLP/JSON writes 64-bit integers as strings
56
+ for key in ("stringValue", "doubleValue", "boolValue", "bytesValue"):
57
+ if key in value:
58
+ return value[key]
59
+ return None
60
+
61
+
62
+ def from_json(body: bytes) -> list[RawSpan]:
63
+ """OTLP/JSON: ids are hex strings, times are nanoseconds written as strings."""
64
+ request = json.loads(body)
65
+ return [
66
+ RawSpan(
67
+ trace_id=span.get("traceId", "").lower(),
68
+ name=span.get("name", ""),
69
+ started=int(span.get("startTimeUnixNano", 0)) / NANOS,
70
+ ended=int(span.get("endTimeUnixNano", 0)) / NANOS,
71
+ attributes={
72
+ a["key"]: _json_value(a.get("value", {})) for a in span.get("attributes", [])
73
+ },
74
+ )
75
+ for resource in request.get("resourceSpans", [])
76
+ for scope in resource.get("scopeSpans", [])
77
+ for span in scope.get("spans", [])
78
+ ]
79
+
80
+
81
+ def decode(body: bytes, content_type: str) -> list[RawSpan]:
82
+ return from_json(body) if "json" in content_type else from_protobuf(body)