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.
- casebench/__init__.py +5 -0
- casebench/capture/__init__.py +1 -0
- casebench/capture/assemble.py +92 -0
- casebench/capture/backend.py +47 -0
- casebench/capture/cli.py +36 -0
- casebench/capture/config.py +28 -0
- casebench/capture/conformance.py +33 -0
- casebench/capture/context.py +53 -0
- casebench/capture/corpus_routes.py +50 -0
- casebench/capture/functions.py +115 -0
- casebench/capture/interface.py +23 -0
- casebench/capture/otlp/__init__.py +1 -0
- casebench/capture/otlp/decode.py +82 -0
- casebench/capture/otlp/mapping.py +105 -0
- casebench/capture/parsers/__init__.py +1 -0
- casebench/capture/parsers/anthropic/__init__.py +86 -0
- casebench/capture/parsers/anthropic/casebench-plugin.json +5 -0
- casebench/capture/parsers/anthropic/samples/plain-with-tool-result.json +76 -0
- casebench/capture/parsers/anthropic/samples/streamed-tool-call.json +97 -0
- casebench/capture/parsers/gemini/__init__.py +77 -0
- casebench/capture/parsers/gemini/casebench-plugin.json +5 -0
- casebench/capture/parsers/gemini/samples/plain-with-function-response.json +86 -0
- casebench/capture/parsers/gemini/samples/streamed-function-call.json +86 -0
- casebench/capture/parsers/openai/__init__.py +106 -0
- casebench/capture/parsers/openai/casebench-plugin.json +5 -0
- casebench/capture/parsers/openai/samples/plain-with-tool-result.json +72 -0
- casebench/capture/parsers/openai/samples/streamed-tool-call.json +104 -0
- casebench/capture/proxy/__init__.py +1 -0
- casebench/capture/proxy/forward.py +143 -0
- casebench/capture/proxy/redact.py +18 -0
- casebench/capture/proxy/routes.py +45 -0
- casebench/capture/proxy/server.py +38 -0
- casebench/capture/proxy/sse.py +23 -0
- casebench/capture/read.py +17 -0
- casebench/capture/recorder.py +82 -0
- casebench/capture/redirect.py +83 -0
- casebench/capture/routing.py +16 -0
- casebench/capture/session.py +165 -0
- casebench/capture/sizes.py +11 -0
- casebench/capture/step.py +75 -0
- casebench/cases/__init__.py +1 -0
- casebench/cases/build.py +98 -0
- casebench/cases/cli.py +214 -0
- casebench/cases/draft.py +42 -0
- casebench/cases/resolve.py +39 -0
- casebench/cases/suite.py +31 -0
- casebench/cli.py +60 -0
- casebench/config.py +128 -0
- casebench/core/__init__.py +1 -0
- casebench/core/canonical.py +20 -0
- casebench/core/case.py +140 -0
- casebench/core/jurisdiction.py +57 -0
- casebench/core/model.py +15 -0
- casebench/core/score.py +95 -0
- casebench/corpus/__init__.py +1 -0
- casebench/corpus/add.py +115 -0
- casebench/corpus/add_cli.py +607 -0
- casebench/corpus/binding.py +122 -0
- casebench/corpus/bundled.py +75 -0
- casebench/corpus/cache.py +118 -0
- casebench/corpus/check.py +77 -0
- casebench/corpus/cli.py +105 -0
- casebench/corpus/conformance.py +43 -0
- casebench/corpus/connectors/__init__.py +1 -0
- casebench/corpus/connectors/folder/__init__.py +68 -0
- casebench/corpus/connectors/folder/casebench-plugin.json +5 -0
- casebench/corpus/connectors/folder/samples/README.md +4 -0
- casebench/corpus/connectors/folder/samples/config.json +1 -0
- casebench/corpus/connectors/folder/samples/corpus/sample-1.json +1 -0
- casebench/corpus/connectors/folder/samples/corpus/sample-2.json +1 -0
- casebench/corpus/connectors/http/__init__.py +86 -0
- casebench/corpus/connectors/mcp/__init__.py +170 -0
- casebench/corpus/corpora.py +76 -0
- casebench/corpus/from_profile.py +135 -0
- casebench/corpus/guard.py +39 -0
- casebench/corpus/ids.py +21 -0
- casebench/corpus/interface.py +56 -0
- casebench/corpus/mapping.py +24 -0
- casebench/corpus/passage.py +55 -0
- casebench/corpus/probe_cli.py +77 -0
- casebench/corpus/profile.py +55 -0
- casebench/corpus/profiles/README.md +13 -0
- casebench/corpus/record.py +27 -0
- casebench/corpus/results.py +56 -0
- casebench/corpus/routing.py +69 -0
- casebench/corpus/suggest.py +80 -0
- casebench/envfiles.py +94 -0
- casebench/exporters/__init__.py +1 -0
- casebench/exporters/interface.py +11 -0
- casebench/extract/__init__.py +1 -0
- casebench/extract/agreement.py +52 -0
- casebench/extract/cli.py +150 -0
- casebench/extract/compare.py +58 -0
- casebench/extract/config.py +19 -0
- casebench/extract/estimate.py +93 -0
- casebench/extract/prompt.py +30 -0
- casebench/extract/reading.py +54 -0
- casebench/extract/run.py +162 -0
- casebench/formats/__init__.py +1 -0
- casebench/formats/extensions.py +20 -0
- casebench/formats/files.py +28 -0
- casebench/formats/read.py +39 -0
- casebench/formats/registry.py +47 -0
- casebench/formats/schema.py +29 -0
- casebench/formats/schema_cli.py +38 -0
- casebench/formats/schemas/case/1.0.json +320 -0
- casebench/formats/schemas/document/1.0.json +57 -0
- casebench/formats/schemas/index.json +3 -0
- casebench/formats/schemas/profile/1.0.json +142 -0
- casebench/formats/schemas/report/1.0.json +819 -0
- casebench/formats/schemas/suite/1.0.json +84 -0
- casebench/formats/validate_cli.py +96 -0
- casebench/formats/version.py +62 -0
- casebench/judge/__init__.py +1 -0
- casebench/judge/budget.py +33 -0
- casebench/judge/client.py +85 -0
- casebench/judge/config.py +33 -0
- casebench/judge/judges.py +21 -0
- casebench/judge/memo.py +37 -0
- casebench/judge/prompts.py +81 -0
- casebench/judge/resolve.py +135 -0
- casebench/key_record.py +37 -0
- casebench/keys.py +48 -0
- casebench/loading.py +70 -0
- casebench/metrics/__init__.py +1 -0
- casebench/metrics/authorities.py +102 -0
- casebench/metrics/base.py +73 -0
- casebench/metrics/builtin/__init__.py +1 -0
- casebench/metrics/builtin/citation_integrity/__init__.py +102 -0
- casebench/metrics/builtin/criteria/__init__.py +53 -0
- casebench/metrics/builtin/outcome/__init__.py +45 -0
- casebench/metrics/builtin/precedent/__init__.py +8 -0
- casebench/metrics/builtin/statute/__init__.py +8 -0
- casebench/metrics/criteria.py +94 -0
- casebench/metrics/loading.py +31 -0
- casebench/plugins/__init__.py +1 -0
- casebench/plugins/cli.py +66 -0
- casebench/plugins/conformance.py +90 -0
- casebench/plugins/kinds.py +67 -0
- casebench/plugins/manifest.py +59 -0
- casebench/plugins/registry.py +67 -0
- casebench/plugins/samples.py +43 -0
- casebench/plugins/scaffold.py +111 -0
- casebench/plugins/templates.py +130 -0
- casebench/providers/__init__.py +1 -0
- casebench/providers/builtin/__init__.py +1 -0
- casebench/providers/builtin/anthropic/__init__.py +72 -0
- casebench/providers/builtin/anthropic/casebench-plugin.json +5 -0
- casebench/providers/builtin/anthropic/samples/config.json +1 -0
- casebench/providers/builtin/anthropic/samples/list.json +38 -0
- casebench/providers/builtin/azure/__init__.py +60 -0
- casebench/providers/builtin/bedrock/__init__.py +66 -0
- casebench/providers/builtin/google/__init__.py +65 -0
- casebench/providers/builtin/openai/__init__.py +64 -0
- casebench/providers/builtin/openai/casebench-plugin.json +5 -0
- casebench/providers/builtin/openai/samples/list.json +35 -0
- casebench/providers/call.py +44 -0
- casebench/providers/conformance.py +38 -0
- casebench/providers/interface.py +57 -0
- casebench/providers/send.py +86 -0
- casebench/providers/settings.py +31 -0
- casebench/py.typed +0 -0
- casebench/references/__init__.py +1 -0
- casebench/references/court.py +43 -0
- casebench/references/interface.py +12 -0
- casebench/report/__init__.py +1 -0
- casebench/report/agreement.py +32 -0
- casebench/report/baselines.py +24 -0
- casebench/report/citations.py +24 -0
- casebench/report/cli.py +89 -0
- casebench/report/comparable.py +71 -0
- casebench/report/compare.py +45 -0
- casebench/report/funnel.py +156 -0
- casebench/report/record.py +49 -0
- casebench/report/render.py +142 -0
- casebench/report/statistics.py +60 -0
- casebench/report/tables.py +99 -0
- casebench/runs/__init__.py +1 -0
- casebench/runs/cli.py +175 -0
- casebench/runs/dry_run.py +128 -0
- casebench/runs/log.py +136 -0
- casebench/runs/producer.py +74 -0
- casebench/runs/progress.py +32 -0
- casebench/runs/rescore.py +69 -0
- casebench/runs/runner.py +89 -0
- casebench/runs/scoring.py +152 -0
- casebench/setup/__init__.py +1 -0
- casebench/setup/advice.py +30 -0
- casebench/setup/catalog.py +112 -0
- casebench/setup/check_cli.py +74 -0
- casebench/setup/checks.py +264 -0
- casebench/setup/corpus_question.py +216 -0
- casebench/setup/guide.md +142 -0
- casebench/setup/guide_cli.py +38 -0
- casebench/setup/init_cli.py +437 -0
- casebench/setup/judge_question.py +89 -0
- casebench/setup/key_question.py +136 -0
- casebench/setup/keys_cli.py +58 -0
- casebench/setup/models.py +68 -0
- casebench/setup/models_cli.py +56 -0
- casebench/setup/picker.py +112 -0
- casebench/setup/routes.py +106 -0
- casebench/setup/scan.py +276 -0
- casebench/setup/screen.py +109 -0
- casebench/setup/sdks.py +76 -0
- casebench/setup/summary.py +79 -0
- casebench/setup/update.py +80 -0
- casebench/suites/__init__.py +1 -0
- casebench/suites/cli.py +133 -0
- casebench/suites/court.py +143 -0
- casebench/suites/freeze.py +147 -0
- casebench/suites/generate.py +41 -0
- casebench/suites/qualify.py +36 -0
- casebench/suites/resolve.py +52 -0
- casebench/targets/__init__.py +1 -0
- casebench/targets/answer.py +32 -0
- casebench/targets/builtin/__init__.py +1 -0
- casebench/targets/builtin/command/__init__.py +52 -0
- casebench/targets/builtin/command/casebench-plugin.json +5 -0
- casebench/targets/builtin/command/samples/config.json +1 -0
- casebench/targets/builtin/http/__init__.py +105 -0
- casebench/targets/builtin/http/casebench-plugin.json +5 -0
- casebench/targets/builtin/http/samples/config.json +1 -0
- casebench/targets/builtin/python/__init__.py +144 -0
- casebench/targets/builtin/python/casebench-plugin.json +5 -0
- casebench/targets/builtin/python/entry.py +30 -0
- casebench/targets/builtin/python/framing.py +31 -0
- casebench/targets/builtin/python/samples/config.json +1 -0
- casebench/targets/builtin/python/trace_context.py +21 -0
- casebench/targets/builtin/python/worker.py +57 -0
- casebench/targets/cli.py +53 -0
- casebench/targets/interface.py +22 -0
- casebench/targets/load.py +19 -0
- casebench/targets/placeholders.py +63 -0
- casebench/targets/sample.py +34 -0
- casebench/tasks/__init__.py +1 -0
- casebench/tasks/interface.py +16 -0
- casebench-0.1.0.dist-info/METADATA +82 -0
- casebench-0.1.0.dist-info/RECORD +242 -0
- casebench-0.1.0.dist-info/WHEEL +4 -0
- casebench-0.1.0.dist-info/entry_points.txt +32 -0
- casebench-0.1.0.dist-info/licenses/LICENSE +211 -0
casebench/__init__.py
ADDED
|
@@ -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
|
+
)
|
casebench/capture/cli.py
ADDED
|
@@ -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)
|