mindzeval 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.
- mindzeval/__init__.py +63 -0
- mindzeval/_version.py +1 -0
- mindzeval/adapters/__init__.py +58 -0
- mindzeval/adapters/base.py +101 -0
- mindzeval/adapters/http.py +190 -0
- mindzeval/adapters/outputs.py +81 -0
- mindzeval/adapters/python.py +140 -0
- mindzeval/adapters/resilient.py +66 -0
- mindzeval/cli.py +295 -0
- mindzeval/db.py +270 -0
- mindzeval/errors.py +78 -0
- mindzeval/loading.py +641 -0
- mindzeval/py.typed +0 -0
- mindzeval/quickstart.py +328 -0
- mindzeval/report.py +3032 -0
- mindzeval/results.py +225 -0
- mindzeval/runner.py +393 -0
- mindzeval/schema/__init__.py +52 -0
- mindzeval/schema/case.py +383 -0
- mindzeval/schema/config.py +318 -0
- mindzeval/schema/response.py +138 -0
- mindzeval/scoring/behavior.py +106 -0
- mindzeval/scoring/compare.py +433 -0
- mindzeval/scoring/custom.py +64 -0
- mindzeval/scoring/execution.py +244 -0
- mindzeval/scoring/safety.py +86 -0
- mindzeval/scoring/values.py +105 -0
- mindzeval/sql/__init__.py +10 -0
- mindzeval/sql/guard.py +181 -0
- mindzeval/validate.py +337 -0
- mindzeval-0.1.0.dist-info/METADATA +137 -0
- mindzeval-0.1.0.dist-info/RECORD +35 -0
- mindzeval-0.1.0.dist-info/WHEEL +4 -0
- mindzeval-0.1.0.dist-info/entry_points.txt +2 -0
- mindzeval-0.1.0.dist-info/licenses/LICENSE +202 -0
mindzeval/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""MindzEval: evaluation framework for SQL agents.
|
|
2
|
+
|
|
3
|
+
Grades the data an agent returns rather than the SQL it writes, validates the
|
|
4
|
+
reference answers before trusting them, and scores behaviour (answer / clarify /
|
|
5
|
+
decline) in both directions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from mindzeval._version import __version__
|
|
9
|
+
from mindzeval.db import Database, QueryResult
|
|
10
|
+
from mindzeval.errors import (
|
|
11
|
+
ConfigError,
|
|
12
|
+
DatabaseError,
|
|
13
|
+
Issue,
|
|
14
|
+
MindzEvalError,
|
|
15
|
+
QueryTimeoutError,
|
|
16
|
+
SQLGuardError,
|
|
17
|
+
)
|
|
18
|
+
from mindzeval.loading import Project, find_config, load_cases, load_config, load_project
|
|
19
|
+
from mindzeval.results import TurnResult, Verdict
|
|
20
|
+
from mindzeval.runner import RunResult, run, run_project, select_cases
|
|
21
|
+
from mindzeval.schema import (
|
|
22
|
+
AgentResponse,
|
|
23
|
+
Behavior,
|
|
24
|
+
Case,
|
|
25
|
+
CompareMode,
|
|
26
|
+
Config,
|
|
27
|
+
Expectation,
|
|
28
|
+
Tolerance,
|
|
29
|
+
ToleranceOverride,
|
|
30
|
+
Turn,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"AgentResponse",
|
|
35
|
+
"Behavior",
|
|
36
|
+
"Case",
|
|
37
|
+
"CompareMode",
|
|
38
|
+
"Config",
|
|
39
|
+
"ConfigError",
|
|
40
|
+
"Database",
|
|
41
|
+
"DatabaseError",
|
|
42
|
+
"Expectation",
|
|
43
|
+
"Issue",
|
|
44
|
+
"MindzEvalError",
|
|
45
|
+
"Project",
|
|
46
|
+
"QueryResult",
|
|
47
|
+
"QueryTimeoutError",
|
|
48
|
+
"RunResult",
|
|
49
|
+
"SQLGuardError",
|
|
50
|
+
"Tolerance",
|
|
51
|
+
"ToleranceOverride",
|
|
52
|
+
"Turn",
|
|
53
|
+
"TurnResult",
|
|
54
|
+
"Verdict",
|
|
55
|
+
"__version__",
|
|
56
|
+
"find_config",
|
|
57
|
+
"load_cases",
|
|
58
|
+
"load_config",
|
|
59
|
+
"load_project",
|
|
60
|
+
"run",
|
|
61
|
+
"run_project",
|
|
62
|
+
"select_cases",
|
|
63
|
+
]
|
mindzeval/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Agent adapters: Python function, HTTP endpoint, or pre-generated outputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mindzeval.adapters.base import (
|
|
6
|
+
Adapter,
|
|
7
|
+
AgentError,
|
|
8
|
+
AgentErrorKind,
|
|
9
|
+
AgentRequest,
|
|
10
|
+
Message,
|
|
11
|
+
coerce_response,
|
|
12
|
+
)
|
|
13
|
+
from mindzeval.adapters.http import HttpAdapter
|
|
14
|
+
from mindzeval.adapters.outputs import OutputsAdapter
|
|
15
|
+
from mindzeval.adapters.python import PythonAdapter, load_target
|
|
16
|
+
from mindzeval.adapters.resilient import ResilientAdapter
|
|
17
|
+
from mindzeval.schema.config import (
|
|
18
|
+
Config,
|
|
19
|
+
HttpAgentConfig,
|
|
20
|
+
OutputsAgentConfig,
|
|
21
|
+
PythonAgentConfig,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_adapter(config: Config) -> Adapter:
|
|
26
|
+
"""Create the adapter described by ``config.agent``, with retries and rate limiting."""
|
|
27
|
+
agent = config.agent
|
|
28
|
+
if isinstance(agent, OutputsAgentConfig):
|
|
29
|
+
return OutputsAdapter(config.resolve_path(agent.path))
|
|
30
|
+
inner: Adapter
|
|
31
|
+
if isinstance(agent, PythonAgentConfig):
|
|
32
|
+
inner = PythonAdapter(load_target(agent.target, config.base_dir), name=agent.target)
|
|
33
|
+
elif isinstance(agent, HttpAgentConfig):
|
|
34
|
+
inner = HttpAdapter(agent)
|
|
35
|
+
else: # pragma: no cover - the union is exhaustive
|
|
36
|
+
raise TypeError(f"unsupported agent config {type(agent).__name__}")
|
|
37
|
+
return ResilientAdapter(
|
|
38
|
+
inner,
|
|
39
|
+
timeout_s=agent.timeout_s,
|
|
40
|
+
retries=agent.retries,
|
|
41
|
+
requests_per_minute=agent.requests_per_minute,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"Adapter",
|
|
47
|
+
"AgentError",
|
|
48
|
+
"AgentErrorKind",
|
|
49
|
+
"AgentRequest",
|
|
50
|
+
"HttpAdapter",
|
|
51
|
+
"Message",
|
|
52
|
+
"OutputsAdapter",
|
|
53
|
+
"PythonAdapter",
|
|
54
|
+
"ResilientAdapter",
|
|
55
|
+
"build_adapter",
|
|
56
|
+
"coerce_response",
|
|
57
|
+
"load_target",
|
|
58
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The adapter interface: how MindzEval calls an agent, whatever it is built with."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from mindzeval.schema.response import AgentResponse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Message:
|
|
17
|
+
"""One message of the conversation so far."""
|
|
18
|
+
|
|
19
|
+
role: str # "user" or "assistant"
|
|
20
|
+
content: str
|
|
21
|
+
|
|
22
|
+
def as_dict(self) -> dict[str, str]:
|
|
23
|
+
return {"role": self.role, "content": self.content}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class AgentRequest:
|
|
28
|
+
"""Everything an adapter passes to the agent for one turn."""
|
|
29
|
+
|
|
30
|
+
question: str
|
|
31
|
+
history: list[Message] = field(default_factory=list)
|
|
32
|
+
session_id: str = ""
|
|
33
|
+
"""Stable across the turns of one case, unique per case and run."""
|
|
34
|
+
case_id: str = ""
|
|
35
|
+
turn: int = 0
|
|
36
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
"""Config ``params`` merged with the current matrix combination."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AgentErrorKind(StrEnum):
|
|
41
|
+
TIMEOUT = "timeout"
|
|
42
|
+
RATE_LIMITED = "rate_limited"
|
|
43
|
+
CONNECTION = "connection"
|
|
44
|
+
CRASH = "crash"
|
|
45
|
+
"""The agent raised an exception or returned a server error."""
|
|
46
|
+
INVALID_RESPONSE = "invalid_response"
|
|
47
|
+
"""The agent replied, but not with a valid result record."""
|
|
48
|
+
MISSING_OUTPUT = "missing_output"
|
|
49
|
+
"""Pre-generated outputs have no entry for this turn."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
RETRYABLE = frozenset(
|
|
53
|
+
{AgentErrorKind.TIMEOUT, AgentErrorKind.RATE_LIMITED, AgentErrorKind.CONNECTION}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AgentError(Exception):
|
|
58
|
+
"""The agent call failed. These are agent-side failures, never MindzEval bugs."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, kind: AgentErrorKind, message: str) -> None:
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.kind = kind
|
|
63
|
+
self.message = message
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def retryable(self) -> bool:
|
|
67
|
+
return self.kind in RETRYABLE
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Adapter(ABC):
|
|
71
|
+
"""Calls one agent. Implementations must be safe to call concurrently."""
|
|
72
|
+
|
|
73
|
+
name: str = "agent"
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
async def call(self, request: AgentRequest) -> AgentResponse:
|
|
77
|
+
"""Return the agent's result for one turn, or raise :class:`AgentError`."""
|
|
78
|
+
|
|
79
|
+
async def aclose(self) -> None: # noqa: B027 - optional hook
|
|
80
|
+
"""Release resources (connections, clients)."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def coerce_response(value: Any) -> AgentResponse:
|
|
84
|
+
"""Turn whatever an agent returned into an :class:`AgentResponse`, or raise AgentError."""
|
|
85
|
+
if isinstance(value, AgentResponse):
|
|
86
|
+
return value
|
|
87
|
+
if value is None:
|
|
88
|
+
raise AgentError(AgentErrorKind.INVALID_RESPONSE, "the agent returned nothing (None)")
|
|
89
|
+
if hasattr(value, "model_dump") and not isinstance(value, dict):
|
|
90
|
+
value = value.model_dump()
|
|
91
|
+
try:
|
|
92
|
+
return AgentResponse.model_validate(value)
|
|
93
|
+
except ValidationError as exc:
|
|
94
|
+
problems = "; ".join(
|
|
95
|
+
f"{'.'.join(str(p) for p in e['loc']) or 'response'}: "
|
|
96
|
+
f"{str(e['msg']).removeprefix('Value error, ')}"
|
|
97
|
+
for e in exc.errors(include_url=False)
|
|
98
|
+
)
|
|
99
|
+
raise AgentError(
|
|
100
|
+
AgentErrorKind.INVALID_RESPONSE, f"invalid result record: {problems}"
|
|
101
|
+
) from None
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Call an agent over HTTP. Works for agents written in any language.
|
|
2
|
+
|
|
3
|
+
Request body: by default ``{"question", "history", "session_id", "params"}`` as
|
|
4
|
+
JSON. A custom ``body`` template is filled per call: a string that is exactly a
|
|
5
|
+
placeholder (``"{history}"``) is replaced by the value itself, keeping its type;
|
|
6
|
+
placeholders inside longer strings are substituted as text.
|
|
7
|
+
|
|
8
|
+
Placeholders: ``{question}``, ``{history}``, ``{session_id}``, ``{case_id}``,
|
|
9
|
+
``{turn}``, ``{params}`` and ``{params.<name>}``.
|
|
10
|
+
|
|
11
|
+
Response: each result field is read from the JSON response with a dotted path
|
|
12
|
+
(``data.rows``, ``choices.0.text``). Agent status values can be translated with
|
|
13
|
+
``status_map``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from mindzeval.adapters.base import (
|
|
25
|
+
Adapter,
|
|
26
|
+
AgentError,
|
|
27
|
+
AgentErrorKind,
|
|
28
|
+
AgentRequest,
|
|
29
|
+
coerce_response,
|
|
30
|
+
)
|
|
31
|
+
from mindzeval.schema.config import HttpAgentConfig, ResponseMapping
|
|
32
|
+
from mindzeval.schema.response import AgentResponse
|
|
33
|
+
|
|
34
|
+
_PLACEHOLDER = re.compile(r"\{(question|history|session_id|case_id|turn|params(?:\.[\w.]+)?)\}")
|
|
35
|
+
_MISSING = object()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HttpAdapter(Adapter):
|
|
39
|
+
def __init__(self, config: HttpAgentConfig, *, client: httpx.AsyncClient | None = None) -> None:
|
|
40
|
+
self._config = config
|
|
41
|
+
self.name = config.url
|
|
42
|
+
self._client = client or httpx.AsyncClient(timeout=config.timeout_s, follow_redirects=False)
|
|
43
|
+
|
|
44
|
+
async def call(self, request: AgentRequest) -> AgentResponse:
|
|
45
|
+
config = self._config
|
|
46
|
+
values = _template_values(request)
|
|
47
|
+
body = (
|
|
48
|
+
render_template(config.body, values)
|
|
49
|
+
if config.body is not None
|
|
50
|
+
else {
|
|
51
|
+
"question": request.question,
|
|
52
|
+
"history": values["history"],
|
|
53
|
+
"session_id": request.session_id,
|
|
54
|
+
"params": request.params,
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
headers = {"Accept": "application/json", **config.headers}
|
|
58
|
+
try:
|
|
59
|
+
if config.method == "GET":
|
|
60
|
+
params = body if isinstance(body, dict) else None
|
|
61
|
+
response = await self._client.get(
|
|
62
|
+
config.url, params=_flat(params), headers=headers, timeout=config.timeout_s
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
response = await self._client.request(
|
|
66
|
+
config.method, config.url, json=body, headers=headers, timeout=config.timeout_s
|
|
67
|
+
)
|
|
68
|
+
except httpx.TimeoutException as exc:
|
|
69
|
+
raise AgentError(
|
|
70
|
+
AgentErrorKind.TIMEOUT, f"no response within {config.timeout_s:g}s"
|
|
71
|
+
) from exc
|
|
72
|
+
except httpx.HTTPError as exc:
|
|
73
|
+
raise AgentError(AgentErrorKind.CONNECTION, f"{type(exc).__name__}: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
if response.status_code == 429:
|
|
76
|
+
raise AgentError(AgentErrorKind.RATE_LIMITED, "the agent returned HTTP 429")
|
|
77
|
+
if response.status_code >= 400:
|
|
78
|
+
kind = (
|
|
79
|
+
AgentErrorKind.CRASH
|
|
80
|
+
if response.status_code >= 500
|
|
81
|
+
else AgentErrorKind.INVALID_RESPONSE
|
|
82
|
+
)
|
|
83
|
+
raise AgentError(kind, f"HTTP {response.status_code}: {response.text[:300]}")
|
|
84
|
+
try:
|
|
85
|
+
payload = response.json()
|
|
86
|
+
except (json.JSONDecodeError, ValueError):
|
|
87
|
+
raise AgentError(
|
|
88
|
+
AgentErrorKind.INVALID_RESPONSE,
|
|
89
|
+
f"the response is not JSON: {response.text[:200]!r}",
|
|
90
|
+
) from None
|
|
91
|
+
return coerce_response(map_response(payload, config.response))
|
|
92
|
+
|
|
93
|
+
async def aclose(self) -> None:
|
|
94
|
+
await self._client.aclose()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def map_response(payload: Any, mapping: ResponseMapping) -> dict[str, Any]:
|
|
98
|
+
"""Pick each result field out of a JSON payload using the mapping's dotted paths."""
|
|
99
|
+
out: dict[str, Any] = {}
|
|
100
|
+
for field in (
|
|
101
|
+
"answer",
|
|
102
|
+
"sql",
|
|
103
|
+
"params",
|
|
104
|
+
"rows",
|
|
105
|
+
"columns",
|
|
106
|
+
"status",
|
|
107
|
+
"reason",
|
|
108
|
+
"tool_calls",
|
|
109
|
+
"trace",
|
|
110
|
+
"usage",
|
|
111
|
+
):
|
|
112
|
+
path = getattr(mapping, field)
|
|
113
|
+
if not path:
|
|
114
|
+
continue
|
|
115
|
+
value = get_path(payload, path, _MISSING)
|
|
116
|
+
if value is _MISSING or value is None:
|
|
117
|
+
continue
|
|
118
|
+
out[field] = value
|
|
119
|
+
|
|
120
|
+
status = out.get("status")
|
|
121
|
+
if status is not None:
|
|
122
|
+
mapped = mapping.status_map.get(str(status))
|
|
123
|
+
if mapped is not None:
|
|
124
|
+
out["status"] = mapped.value
|
|
125
|
+
elif str(status).lower() in ("answer", "clarify", "decline"):
|
|
126
|
+
out["status"] = str(status).lower()
|
|
127
|
+
else:
|
|
128
|
+
raise AgentError(
|
|
129
|
+
AgentErrorKind.INVALID_RESPONSE,
|
|
130
|
+
f"unknown status {status!r}; map it with agent.response.status_map",
|
|
131
|
+
)
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_path(data: Any, path: str, default: Any = None) -> Any:
|
|
136
|
+
"""Follow a dotted path through dicts and lists (``a.b.0.c``)."""
|
|
137
|
+
current = data
|
|
138
|
+
for part in path.split("."):
|
|
139
|
+
if isinstance(current, dict) and part in current:
|
|
140
|
+
current = current[part]
|
|
141
|
+
elif (
|
|
142
|
+
isinstance(current, list)
|
|
143
|
+
and part.lstrip("-").isdigit()
|
|
144
|
+
and -len(current) <= int(part) < len(current)
|
|
145
|
+
):
|
|
146
|
+
current = current[int(part)]
|
|
147
|
+
else:
|
|
148
|
+
return default
|
|
149
|
+
return current
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def render_template(template: Any, values: dict[str, Any]) -> Any:
|
|
153
|
+
if isinstance(template, dict):
|
|
154
|
+
return {k: render_template(v, values) for k, v in template.items()}
|
|
155
|
+
if isinstance(template, list):
|
|
156
|
+
return [render_template(v, values) for v in template]
|
|
157
|
+
if not isinstance(template, str):
|
|
158
|
+
return template
|
|
159
|
+
whole = _PLACEHOLDER.fullmatch(template)
|
|
160
|
+
if whole:
|
|
161
|
+
return _lookup(values, whole.group(1))
|
|
162
|
+
|
|
163
|
+
def text(match: re.Match[str]) -> str:
|
|
164
|
+
value = _lookup(values, match.group(1))
|
|
165
|
+
return value if isinstance(value, str) else json.dumps(value, default=str)
|
|
166
|
+
|
|
167
|
+
return _PLACEHOLDER.sub(text, template)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _template_values(request: AgentRequest) -> dict[str, Any]:
|
|
171
|
+
return {
|
|
172
|
+
"question": request.question,
|
|
173
|
+
"history": [m.as_dict() for m in request.history],
|
|
174
|
+
"session_id": request.session_id,
|
|
175
|
+
"case_id": request.case_id,
|
|
176
|
+
"turn": request.turn,
|
|
177
|
+
"params": dict(request.params),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _lookup(values: dict[str, Any], name: str) -> Any:
|
|
182
|
+
if name.startswith("params."):
|
|
183
|
+
return get_path(values["params"], name[len("params.") :])
|
|
184
|
+
return values[name]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _flat(params: dict[str, Any] | None) -> dict[str, str] | None:
|
|
188
|
+
if params is None:
|
|
189
|
+
return None
|
|
190
|
+
return {k: v if isinstance(v, str) else json.dumps(v, default=str) for k, v in params.items()}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Score answers an agent produced earlier, without calling it.
|
|
2
|
+
|
|
3
|
+
The file is JSONL (one record per line) or a JSON list. Each record names the
|
|
4
|
+
case and turn, and holds the result record either under ``response`` or at the
|
|
5
|
+
top level::
|
|
6
|
+
|
|
7
|
+
{"case_id": "customers_de", "turn": 0, "response": {"answer": "...", "sql": "..."}}
|
|
8
|
+
{"case_id": "order_count", "answer": "4", "sql": "SELECT count(*) FROM orders"}
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from mindzeval.adapters.base import (
|
|
18
|
+
Adapter,
|
|
19
|
+
AgentError,
|
|
20
|
+
AgentErrorKind,
|
|
21
|
+
AgentRequest,
|
|
22
|
+
coerce_response,
|
|
23
|
+
)
|
|
24
|
+
from mindzeval.errors import ConfigError, Issue
|
|
25
|
+
from mindzeval.schema.response import AgentResponse
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OutputsAdapter(Adapter):
|
|
29
|
+
def __init__(self, path: Path) -> None:
|
|
30
|
+
self.name = f"outputs:{path.name}"
|
|
31
|
+
self._records = _load(path)
|
|
32
|
+
|
|
33
|
+
async def call(self, request: AgentRequest) -> AgentResponse:
|
|
34
|
+
record = self._records.get((request.case_id, request.turn))
|
|
35
|
+
if record is None:
|
|
36
|
+
raise AgentError(
|
|
37
|
+
AgentErrorKind.MISSING_OUTPUT,
|
|
38
|
+
f"no saved output for case '{request.case_id}' turn {request.turn}",
|
|
39
|
+
)
|
|
40
|
+
return coerce_response(record)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load(path: Path) -> dict[tuple[str, int], Any]:
|
|
44
|
+
source = str(path)
|
|
45
|
+
try:
|
|
46
|
+
text = path.read_text(encoding="utf-8")
|
|
47
|
+
except OSError as exc:
|
|
48
|
+
raise ConfigError([Issue(source, f"cannot read outputs file: {exc}")]) from None
|
|
49
|
+
|
|
50
|
+
items: list[tuple[int | None, Any]] = []
|
|
51
|
+
try:
|
|
52
|
+
if path.suffix.lower() == ".json":
|
|
53
|
+
loaded = json.loads(text)
|
|
54
|
+
if not isinstance(loaded, list):
|
|
55
|
+
raise ConfigError([Issue(source, "an outputs JSON file must contain a list")])
|
|
56
|
+
items = [(None, item) for item in loaded]
|
|
57
|
+
else:
|
|
58
|
+
items = [
|
|
59
|
+
(n, json.loads(line)) for n, line in enumerate(text.splitlines(), 1) if line.strip()
|
|
60
|
+
]
|
|
61
|
+
except json.JSONDecodeError as exc:
|
|
62
|
+
raise ConfigError([Issue(source, f"invalid JSON: {exc.msg}", line=exc.lineno)]) from None
|
|
63
|
+
|
|
64
|
+
records: dict[tuple[str, int], Any] = {}
|
|
65
|
+
issues: list[Issue] = []
|
|
66
|
+
for line, item in items:
|
|
67
|
+
if not isinstance(item, dict) or "case_id" not in item:
|
|
68
|
+
issues.append(Issue(source, "each output needs a 'case_id'", line=line))
|
|
69
|
+
continue
|
|
70
|
+
key = (str(item["case_id"]), int(item.get("turn", 0)))
|
|
71
|
+
if key in records:
|
|
72
|
+
issues.append(
|
|
73
|
+
Issue(source, f"duplicate output for case '{key[0]}' turn {key[1]}", line=line)
|
|
74
|
+
)
|
|
75
|
+
continue
|
|
76
|
+
records[key] = item.get(
|
|
77
|
+
"response", {k: v for k, v in item.items() if k not in ("case_id", "turn")}
|
|
78
|
+
)
|
|
79
|
+
if issues:
|
|
80
|
+
raise ConfigError(issues)
|
|
81
|
+
return records
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Call an agent that is a Python function (LangChain, LangGraph, CrewAI, plain code...).
|
|
2
|
+
|
|
3
|
+
The function can take any subset of these keyword arguments, and MindzEval
|
|
4
|
+
passes only the ones it declares:
|
|
5
|
+
|
|
6
|
+
question: str the question for this turn
|
|
7
|
+
history: list[dict] earlier turns as {"role": "user"|"assistant", "content": str}
|
|
8
|
+
session_id: str stable across the turns of one case
|
|
9
|
+
case_id: str, turn: int identity of the turn being evaluated
|
|
10
|
+
params: dict config params plus the current matrix combination
|
|
11
|
+
|
|
12
|
+
Alternatively it can take a single ``request`` argument (an
|
|
13
|
+
:class:`~mindzeval.adapters.AgentRequest`). It may be sync or async, and may
|
|
14
|
+
return an :class:`~mindzeval.AgentResponse`, a dict, a pydantic model or a string.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import importlib
|
|
21
|
+
import inspect
|
|
22
|
+
import sys
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from mindzeval.adapters.base import (
|
|
28
|
+
Adapter,
|
|
29
|
+
AgentError,
|
|
30
|
+
AgentErrorKind,
|
|
31
|
+
AgentRequest,
|
|
32
|
+
coerce_response,
|
|
33
|
+
)
|
|
34
|
+
from mindzeval.errors import ConfigError, Issue
|
|
35
|
+
from mindzeval.schema.response import AgentResponse
|
|
36
|
+
|
|
37
|
+
_ARGUMENTS = ("question", "history", "session_id", "case_id", "turn", "params")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_target(target: str, base_dir: Path | None = None) -> Callable[..., Any]:
|
|
41
|
+
"""Import ``module.path:attribute``. ``base_dir`` is put on ``sys.path`` first."""
|
|
42
|
+
module_name, _, attribute = target.partition(":")
|
|
43
|
+
if base_dir is not None and str(base_dir) not in sys.path:
|
|
44
|
+
sys.path.insert(0, str(base_dir))
|
|
45
|
+
try:
|
|
46
|
+
obj: Any = importlib.import_module(module_name)
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
raise ConfigError([Issue("agent.target", f"cannot import '{module_name}': {exc}")]) from exc
|
|
49
|
+
for part in attribute.split("."):
|
|
50
|
+
if not hasattr(obj, part):
|
|
51
|
+
raise ConfigError([Issue("agent.target", f"'{module_name}' has no '{attribute}'")])
|
|
52
|
+
obj = getattr(obj, part)
|
|
53
|
+
if not callable(obj):
|
|
54
|
+
raise ConfigError([Issue("agent.target", f"'{target}' is not callable")])
|
|
55
|
+
return obj # type: ignore[no-any-return]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PythonAdapter(Adapter):
|
|
59
|
+
def __init__(self, func: Callable[..., Any], *, name: str | None = None) -> None:
|
|
60
|
+
self._func = func
|
|
61
|
+
self.name = name or str(getattr(func, "__qualname__", "python-agent"))
|
|
62
|
+
self._is_async = inspect.iscoroutinefunction(func) or inspect.iscoroutinefunction(
|
|
63
|
+
getattr(func, "__call__", None) # noqa: B004 - callable objects
|
|
64
|
+
)
|
|
65
|
+
self._mode, self._accepted = _inspect_signature(func)
|
|
66
|
+
|
|
67
|
+
def _arguments(self, request: AgentRequest) -> dict[str, Any]:
|
|
68
|
+
if self._mode == "request":
|
|
69
|
+
return {"request": request}
|
|
70
|
+
values: dict[str, Any] = {
|
|
71
|
+
"question": request.question,
|
|
72
|
+
"history": [m.as_dict() for m in request.history],
|
|
73
|
+
"session_id": request.session_id,
|
|
74
|
+
"case_id": request.case_id,
|
|
75
|
+
"turn": request.turn,
|
|
76
|
+
"params": dict(request.params),
|
|
77
|
+
}
|
|
78
|
+
if self._mode == "kwargs":
|
|
79
|
+
return values
|
|
80
|
+
return {k: v for k, v in values.items() if k in self._accepted}
|
|
81
|
+
|
|
82
|
+
async def call(self, request: AgentRequest) -> AgentResponse:
|
|
83
|
+
kwargs = self._arguments(request)
|
|
84
|
+
try:
|
|
85
|
+
if self._is_async:
|
|
86
|
+
value = await self._func(**kwargs)
|
|
87
|
+
else:
|
|
88
|
+
value = await asyncio.to_thread(self._func, **kwargs)
|
|
89
|
+
if inspect.isawaitable(value):
|
|
90
|
+
value = await value
|
|
91
|
+
except AgentError:
|
|
92
|
+
raise
|
|
93
|
+
except Exception as exc: # the agent's own failure
|
|
94
|
+
raise AgentError(_classify(exc), f"{type(exc).__name__}: {exc}") from exc
|
|
95
|
+
return coerce_response(value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _inspect_signature(func: Callable[..., Any]) -> tuple[str, frozenset[str]]:
|
|
99
|
+
try:
|
|
100
|
+
signature = inspect.signature(func)
|
|
101
|
+
except (TypeError, ValueError):
|
|
102
|
+
return "kwargs", frozenset()
|
|
103
|
+
params = signature.parameters
|
|
104
|
+
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
|
105
|
+
return "kwargs", frozenset()
|
|
106
|
+
names = [n for n, p in params.items() if p.kind is not inspect.Parameter.VAR_POSITIONAL]
|
|
107
|
+
if names == ["request"]:
|
|
108
|
+
return "request", frozenset()
|
|
109
|
+
accepted = frozenset(n for n in names if n in _ARGUMENTS)
|
|
110
|
+
required = [
|
|
111
|
+
n
|
|
112
|
+
for n, p in params.items()
|
|
113
|
+
if p.default is inspect.Parameter.empty
|
|
114
|
+
and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
115
|
+
]
|
|
116
|
+
unknown = [n for n in required if n not in _ARGUMENTS]
|
|
117
|
+
if unknown or "question" not in accepted:
|
|
118
|
+
raise ConfigError(
|
|
119
|
+
[
|
|
120
|
+
Issue(
|
|
121
|
+
"agent.target",
|
|
122
|
+
f"the agent function must accept 'question' (and optionally "
|
|
123
|
+
f"{', '.join(_ARGUMENTS[1:])}); it has required parameters {required}",
|
|
124
|
+
)
|
|
125
|
+
]
|
|
126
|
+
)
|
|
127
|
+
return "named", accepted
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _classify(exc: BaseException) -> AgentErrorKind:
|
|
131
|
+
if isinstance(exc, TimeoutError):
|
|
132
|
+
return AgentErrorKind.TIMEOUT
|
|
133
|
+
status = getattr(exc, "status_code", None) or getattr(
|
|
134
|
+
getattr(exc, "response", None), "status_code", None
|
|
135
|
+
)
|
|
136
|
+
if status == 429 or "ratelimit" in type(exc).__name__.lower():
|
|
137
|
+
return AgentErrorKind.RATE_LIMITED
|
|
138
|
+
if isinstance(exc, ConnectionError):
|
|
139
|
+
return AgentErrorKind.CONNECTION
|
|
140
|
+
return AgentErrorKind.CRASH
|