layaeval 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.
- layaeval/__init__.py +133 -0
- layaeval/_version.py +1 -0
- layaeval/cli.py +96 -0
- layaeval/dataset.py +86 -0
- layaeval/evaluator.py +338 -0
- layaeval/mcp/__init__.py +5 -0
- layaeval/mcp/client.py +310 -0
- layaeval/metrics/__init__.py +109 -0
- layaeval/metrics/_base.py +175 -0
- layaeval/metrics/agent.py +500 -0
- layaeval/metrics/rag.py +463 -0
- layaeval/metrics/registry.py +217 -0
- layaeval/py.typed +0 -0
- layaeval/report.py +141 -0
- layaeval-0.1.0.dist-info/METADATA +395 -0
- layaeval-0.1.0.dist-info/RECORD +19 -0
- layaeval-0.1.0.dist-info/WHEEL +4 -0
- layaeval-0.1.0.dist-info/entry_points.txt +3 -0
- layaeval-0.1.0.dist-info/licenses/LICENSE +17 -0
layaeval/mcp/client.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""MCP stdio client connecting to the Laya MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MCPClientError(Exception):
|
|
16
|
+
"""Raised when an MCP client or server interaction fails."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LayaMCPClient:
|
|
20
|
+
"""Manages an MCP stdio subprocess connection to `laya-mcp-server`."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
command: Sequence[str] | None = None,
|
|
25
|
+
timeout: float = 60.0,
|
|
26
|
+
env: dict[str, str] | None = None,
|
|
27
|
+
):
|
|
28
|
+
"""Initialize the client.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
command: Command list to launch the MCP server. Defaults to
|
|
32
|
+
`["laya-mcp-server"]` or `[sys.executable, "-m", "laya.mcp.server"]`.
|
|
33
|
+
timeout: Subprocess read/write timeout in seconds.
|
|
34
|
+
env: Custom environment variables to pass to the subprocess.
|
|
35
|
+
"""
|
|
36
|
+
self.command = list(command) if command else self._default_command()
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self.env = env
|
|
39
|
+
self.process: subprocess.Popen | None = None
|
|
40
|
+
self._msg_id = 0
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def _default_command() -> list[str]:
|
|
45
|
+
# Try binary on PATH first, otherwise fallback to python module
|
|
46
|
+
if shutil.which("laya-mcp-server"):
|
|
47
|
+
return ["laya-mcp-server"]
|
|
48
|
+
return [sys.executable, "-m", "laya.mcp.server"]
|
|
49
|
+
|
|
50
|
+
def start(self) -> None:
|
|
51
|
+
"""Start the MCP server subprocess and perform the initialize handshake."""
|
|
52
|
+
if self.process is not None and self.process.poll() is None:
|
|
53
|
+
return # Already running
|
|
54
|
+
|
|
55
|
+
cmd_env = dict(os.environ)
|
|
56
|
+
if self.env:
|
|
57
|
+
cmd_env.update(self.env)
|
|
58
|
+
# Ensure UTF-8 IO
|
|
59
|
+
cmd_env.setdefault("PYTHONIOENCODING", "utf-8")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
self.process = subprocess.Popen(
|
|
63
|
+
self.command,
|
|
64
|
+
stdin=subprocess.PIPE,
|
|
65
|
+
stdout=subprocess.PIPE,
|
|
66
|
+
stderr=subprocess.PIPE,
|
|
67
|
+
text=True,
|
|
68
|
+
bufsize=1,
|
|
69
|
+
env=cmd_env,
|
|
70
|
+
)
|
|
71
|
+
except FileNotFoundError as exc:
|
|
72
|
+
raise MCPClientError(
|
|
73
|
+
f"Failed to execute command '{' '.join(self.command)}'. "
|
|
74
|
+
"Ensure `laya[mcp]` is installed (`pip install 'layaeval[laya]'` or "
|
|
75
|
+
"`pip install 'laya[mcp]'`)."
|
|
76
|
+
) from exc
|
|
77
|
+
|
|
78
|
+
# Perform MCP initialize handshake
|
|
79
|
+
self._initialize()
|
|
80
|
+
|
|
81
|
+
def stop(self) -> None:
|
|
82
|
+
"""Gracefully terminate the subprocess."""
|
|
83
|
+
if self.process is None:
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
if self.process.stdin and not self.process.stdin.closed:
|
|
87
|
+
self.process.stdin.close()
|
|
88
|
+
self.process.terminate()
|
|
89
|
+
self.process.wait(timeout=2.0)
|
|
90
|
+
except Exception:
|
|
91
|
+
if self.process.poll() is None:
|
|
92
|
+
self.process.kill()
|
|
93
|
+
finally:
|
|
94
|
+
self.process = None
|
|
95
|
+
|
|
96
|
+
def __enter__(self) -> LayaMCPClient:
|
|
97
|
+
self.start()
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
101
|
+
self.stop()
|
|
102
|
+
|
|
103
|
+
def _next_id(self) -> int:
|
|
104
|
+
self._msg_id += 1
|
|
105
|
+
return self._msg_id
|
|
106
|
+
|
|
107
|
+
def _send_request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
108
|
+
"""Send JSON-RPC request and wait for the response."""
|
|
109
|
+
if self.process is None:
|
|
110
|
+
self.start()
|
|
111
|
+
|
|
112
|
+
if self.process is None or self.process.poll() is not None:
|
|
113
|
+
raise MCPClientError("MCP server process is not running.")
|
|
114
|
+
|
|
115
|
+
req_id = self._next_id()
|
|
116
|
+
msg: dict[str, Any] = {
|
|
117
|
+
"jsonrpc": "2.0",
|
|
118
|
+
"id": req_id,
|
|
119
|
+
"method": method,
|
|
120
|
+
}
|
|
121
|
+
if params is not None:
|
|
122
|
+
msg["params"] = params
|
|
123
|
+
|
|
124
|
+
payload = json.dumps(msg) + "\n"
|
|
125
|
+
with self._lock:
|
|
126
|
+
try:
|
|
127
|
+
assert self.process.stdin is not None
|
|
128
|
+
self.process.stdin.write(payload)
|
|
129
|
+
self.process.stdin.flush()
|
|
130
|
+
except (BrokenPipeError, OSError) as exc:
|
|
131
|
+
stderr_text = ""
|
|
132
|
+
if self.process.stderr:
|
|
133
|
+
try:
|
|
134
|
+
stderr_text = self.process.stderr.read()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
raise MCPClientError(
|
|
138
|
+
f"Failed writing to MCP server stdin: {exc}. Server stderr: {stderr_text}"
|
|
139
|
+
) from exc
|
|
140
|
+
|
|
141
|
+
# Read response lines until we get the matching ID
|
|
142
|
+
while True:
|
|
143
|
+
assert self.process.stdout is not None
|
|
144
|
+
line = self.process.stdout.readline()
|
|
145
|
+
if not line:
|
|
146
|
+
stderr_msg = ""
|
|
147
|
+
if self.process.stderr:
|
|
148
|
+
try:
|
|
149
|
+
stderr_msg = self.process.stderr.read()
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
raise MCPClientError(
|
|
153
|
+
f"MCP server closed stdout unexpectedly. Stderr: {stderr_msg}"
|
|
154
|
+
)
|
|
155
|
+
line = line.strip()
|
|
156
|
+
if not line:
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
resp = json.loads(line)
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if resp.get("id") == req_id:
|
|
164
|
+
if "error" in resp:
|
|
165
|
+
raise MCPClientError(f"MCP server error: {resp['error']}")
|
|
166
|
+
return resp.get("result", {})
|
|
167
|
+
|
|
168
|
+
def _send_notification(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
169
|
+
if self.process is None or self.process.poll() is not None:
|
|
170
|
+
return
|
|
171
|
+
msg: dict[str, Any] = {
|
|
172
|
+
"jsonrpc": "2.0",
|
|
173
|
+
"method": method,
|
|
174
|
+
}
|
|
175
|
+
if params is not None:
|
|
176
|
+
msg["params"] = params
|
|
177
|
+
payload = json.dumps(msg) + "\n"
|
|
178
|
+
with self._lock:
|
|
179
|
+
try:
|
|
180
|
+
assert self.process.stdin is not None
|
|
181
|
+
self.process.stdin.write(payload)
|
|
182
|
+
self.process.stdin.flush()
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
def _initialize(self) -> None:
|
|
187
|
+
"""Execute MCP initialize sequence."""
|
|
188
|
+
init_params = {
|
|
189
|
+
"protocolVersion": "2024-11-05",
|
|
190
|
+
"capabilities": {},
|
|
191
|
+
"clientInfo": {
|
|
192
|
+
"name": "layaeval",
|
|
193
|
+
"version": "0.1.0",
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
self._send_request("initialize", init_params)
|
|
197
|
+
self._send_notification("notifications/initialized")
|
|
198
|
+
|
|
199
|
+
def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
|
|
200
|
+
"""Invoke an MCP tool by name and return its parsed output."""
|
|
201
|
+
params = {
|
|
202
|
+
"name": name,
|
|
203
|
+
"arguments": arguments,
|
|
204
|
+
}
|
|
205
|
+
result = self._send_request("tools/call", params)
|
|
206
|
+
if result.get("isError"):
|
|
207
|
+
content = result.get("content", [])
|
|
208
|
+
err_text = content[0].get("text", "Unknown tool error") if content else "Error"
|
|
209
|
+
raise MCPClientError(f"Tool {name} failed: {err_text}")
|
|
210
|
+
|
|
211
|
+
content = result.get("content", [])
|
|
212
|
+
if not content:
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
first = content[0]
|
|
216
|
+
if first.get("type") == "text":
|
|
217
|
+
text = first.get("text", "")
|
|
218
|
+
try:
|
|
219
|
+
return json.loads(text)
|
|
220
|
+
except json.JSONDecodeError:
|
|
221
|
+
return text
|
|
222
|
+
return content
|
|
223
|
+
|
|
224
|
+
def predict(
|
|
225
|
+
self,
|
|
226
|
+
state: dict[str, Any],
|
|
227
|
+
questions: dict[str, Any],
|
|
228
|
+
model: str = "auto",
|
|
229
|
+
**kwargs,
|
|
230
|
+
) -> dict[str, Any]:
|
|
231
|
+
"""Convenience method calling the `laya_predict` MCP tool."""
|
|
232
|
+
args: dict[str, Any] = {
|
|
233
|
+
"state": state,
|
|
234
|
+
"questions": questions,
|
|
235
|
+
"model": model,
|
|
236
|
+
}
|
|
237
|
+
args.update(kwargs)
|
|
238
|
+
res = self.call_tool("laya_predict", args)
|
|
239
|
+
if isinstance(res, dict) and "answers" in res:
|
|
240
|
+
return res["answers"]
|
|
241
|
+
return res
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class DemoMCPClient:
|
|
245
|
+
"""Lightweight simulated client for offline demonstrations, testing, and notebooks."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
def start(self) -> None:
|
|
251
|
+
"""No-op for demo client."""
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
def stop(self) -> None:
|
|
255
|
+
"""No-op for demo client."""
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
def __enter__(self) -> DemoMCPClient:
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
def predict(
|
|
265
|
+
self,
|
|
266
|
+
state: dict[str, Any],
|
|
267
|
+
questions: dict[str, Any],
|
|
268
|
+
model: str = "auto",
|
|
269
|
+
**kwargs: Any,
|
|
270
|
+
) -> dict[str, Any]:
|
|
271
|
+
"""Simulate realistic calibrated neural answers for demo execution."""
|
|
272
|
+
answers: dict[str, Any] = {}
|
|
273
|
+
resp = str(state.get("response", state.get("final_result", ""))).lower()
|
|
274
|
+
ctx = str(state.get("retrieved_context", state.get("agent_trace", ""))).lower()
|
|
275
|
+
|
|
276
|
+
# Simple semantic overlap heuristic to provide natural outputs
|
|
277
|
+
has_overlap = (
|
|
278
|
+
any(w in ctx for w in resp.replace(".", " ").replace(",", " ").split() if len(w) > 3)
|
|
279
|
+
if (resp and ctx)
|
|
280
|
+
else True
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
for q_key, q_spec in questions.items():
|
|
284
|
+
q_type = q_spec.get("type", "score")
|
|
285
|
+
is_risk = any(
|
|
286
|
+
k in q_key.lower()
|
|
287
|
+
for k in ("risk", "contradiction", "conflict", "injection", "hallucination")
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if q_type == "score":
|
|
291
|
+
score = round(3.8 if has_overlap else 1.8, 2)
|
|
292
|
+
answers[q_key] = {
|
|
293
|
+
"score": score,
|
|
294
|
+
"confidence": 0.94,
|
|
295
|
+
"answer_confidence": 0.91,
|
|
296
|
+
}
|
|
297
|
+
elif q_type == "noul":
|
|
298
|
+
noul = round(0.06 if is_risk else (0.94 if has_overlap else 0.25), 3)
|
|
299
|
+
answers[q_key] = {
|
|
300
|
+
"noul": noul,
|
|
301
|
+
"confidence": 0.95,
|
|
302
|
+
"answer_confidence": 0.92,
|
|
303
|
+
}
|
|
304
|
+
elif q_type == "choice":
|
|
305
|
+
choices = q_spec.get("criteria", q_spec.get("choices", ["default"]))
|
|
306
|
+
answers[q_key] = {
|
|
307
|
+
"choice": choices[0] if choices else "selected",
|
|
308
|
+
"confidence": 0.96,
|
|
309
|
+
}
|
|
310
|
+
return answers
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""LayaEval metric classes — RAG and Agent evaluation metrics."""
|
|
2
|
+
|
|
3
|
+
from .agent import (
|
|
4
|
+
AgentAbstention,
|
|
5
|
+
AgentAnswerCompleteness,
|
|
6
|
+
AgentCorrectness,
|
|
7
|
+
AgentGroundedness,
|
|
8
|
+
AgentHallucinationRisk,
|
|
9
|
+
AgentResponseRelevance,
|
|
10
|
+
GoalAchievement,
|
|
11
|
+
IndirectPromptInjectionDetection,
|
|
12
|
+
IntentResolution,
|
|
13
|
+
NavigationEfficiency,
|
|
14
|
+
PolicyCompliance,
|
|
15
|
+
PromptInjectionDetection,
|
|
16
|
+
TaskAdherence,
|
|
17
|
+
TaskCompletion,
|
|
18
|
+
ToolCallAccuracy,
|
|
19
|
+
ToolInputAccuracy,
|
|
20
|
+
ToolNecessity,
|
|
21
|
+
ToolOutputUtilization,
|
|
22
|
+
ToolRedundancy,
|
|
23
|
+
ToolSelection,
|
|
24
|
+
ToolSelectionCorrectness,
|
|
25
|
+
ToolSequenceCorrectness,
|
|
26
|
+
)
|
|
27
|
+
from .rag import (
|
|
28
|
+
AbstentionCorrectness,
|
|
29
|
+
Answerability,
|
|
30
|
+
CitationCompleteness,
|
|
31
|
+
CitationCorrectness,
|
|
32
|
+
CitationRelevance,
|
|
33
|
+
ClaimSupport,
|
|
34
|
+
ContextConflictDetection,
|
|
35
|
+
ContextCoverage,
|
|
36
|
+
ContextSufficiency,
|
|
37
|
+
ContextUtilization,
|
|
38
|
+
ContradictionDetection,
|
|
39
|
+
DocumentRelevance,
|
|
40
|
+
FactualCorrectness,
|
|
41
|
+
Groundedness,
|
|
42
|
+
HallucinationRisk,
|
|
43
|
+
ResponseCompleteness,
|
|
44
|
+
ResponseRelevance,
|
|
45
|
+
RetrievalCompleteness,
|
|
46
|
+
RetrievalRelevance,
|
|
47
|
+
StrictGroundedness,
|
|
48
|
+
)
|
|
49
|
+
from .registry import (
|
|
50
|
+
AGENT_METRICS,
|
|
51
|
+
ALL_METRICS,
|
|
52
|
+
RAG_METRICS,
|
|
53
|
+
get_metric,
|
|
54
|
+
list_metrics,
|
|
55
|
+
resolve_metrics,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"get_metric",
|
|
60
|
+
"resolve_metrics",
|
|
61
|
+
"list_metrics",
|
|
62
|
+
"ALL_METRICS",
|
|
63
|
+
"RAG_METRICS",
|
|
64
|
+
"AGENT_METRICS",
|
|
65
|
+
# RAG
|
|
66
|
+
"RetrievalRelevance",
|
|
67
|
+
"DocumentRelevance",
|
|
68
|
+
"ContextSufficiency",
|
|
69
|
+
"ContextCoverage",
|
|
70
|
+
"RetrievalCompleteness",
|
|
71
|
+
"Groundedness",
|
|
72
|
+
"StrictGroundedness",
|
|
73
|
+
"HallucinationRisk",
|
|
74
|
+
"ResponseRelevance",
|
|
75
|
+
"ResponseCompleteness",
|
|
76
|
+
"FactualCorrectness",
|
|
77
|
+
"Answerability",
|
|
78
|
+
"AbstentionCorrectness",
|
|
79
|
+
"ContextUtilization",
|
|
80
|
+
"CitationCorrectness",
|
|
81
|
+
"CitationCompleteness",
|
|
82
|
+
"CitationRelevance",
|
|
83
|
+
"ClaimSupport",
|
|
84
|
+
"ContradictionDetection",
|
|
85
|
+
"ContextConflictDetection",
|
|
86
|
+
# Agent
|
|
87
|
+
"IntentResolution",
|
|
88
|
+
"TaskCompletion",
|
|
89
|
+
"TaskAdherence",
|
|
90
|
+
"GoalAchievement",
|
|
91
|
+
"ToolSelection",
|
|
92
|
+
"ToolSelectionCorrectness",
|
|
93
|
+
"ToolCallAccuracy",
|
|
94
|
+
"ToolInputAccuracy",
|
|
95
|
+
"ToolOutputUtilization",
|
|
96
|
+
"ToolNecessity",
|
|
97
|
+
"ToolRedundancy",
|
|
98
|
+
"ToolSequenceCorrectness",
|
|
99
|
+
"NavigationEfficiency",
|
|
100
|
+
"AgentGroundedness",
|
|
101
|
+
"AgentHallucinationRisk",
|
|
102
|
+
"AgentResponseRelevance",
|
|
103
|
+
"AgentAnswerCompleteness",
|
|
104
|
+
"AgentCorrectness",
|
|
105
|
+
"AgentAbstention",
|
|
106
|
+
"PolicyCompliance",
|
|
107
|
+
"PromptInjectionDetection",
|
|
108
|
+
"IndirectPromptInjectionDetection",
|
|
109
|
+
]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Base class and data containers for LayaEval metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class EvalResult:
|
|
11
|
+
"""The result of evaluating a single metric on an input state."""
|
|
12
|
+
|
|
13
|
+
metric_id: str
|
|
14
|
+
metric_name: str
|
|
15
|
+
score: float | None = None
|
|
16
|
+
noul: float | None = None
|
|
17
|
+
choice: str | None = None
|
|
18
|
+
confidence: float | None = None
|
|
19
|
+
answer_confidence: float | None = None
|
|
20
|
+
passed: bool = False
|
|
21
|
+
threshold: float = 0.0
|
|
22
|
+
reason: str | None = None
|
|
23
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
state: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
"""Convert the result to a JSON-serializable dictionary."""
|
|
28
|
+
return {
|
|
29
|
+
"metric_id": self.metric_id,
|
|
30
|
+
"metric_name": self.metric_name,
|
|
31
|
+
"score": self.score,
|
|
32
|
+
"noul": self.noul,
|
|
33
|
+
"choice": self.choice,
|
|
34
|
+
"confidence": self.confidence,
|
|
35
|
+
"answer_confidence": self.answer_confidence,
|
|
36
|
+
"passed": self.passed,
|
|
37
|
+
"threshold": self.threshold,
|
|
38
|
+
"reason": self.reason,
|
|
39
|
+
"raw": self.raw,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Metric:
|
|
44
|
+
"""Abstract base class for all LayaEval evaluation metrics."""
|
|
45
|
+
|
|
46
|
+
metric_id: str = "GEN-000"
|
|
47
|
+
metric_name: str = "generic_metric"
|
|
48
|
+
question_type: str = "score" # "score", "noul", "choice"
|
|
49
|
+
state_fields: tuple[str, ...] = ()
|
|
50
|
+
optional_fields: tuple[str, ...] = ()
|
|
51
|
+
_question_key: str = ""
|
|
52
|
+
_question_template: dict[str, Any] = {}
|
|
53
|
+
|
|
54
|
+
def __init__(self, threshold: float | None = None):
|
|
55
|
+
"""Initialize metric with optional default threshold."""
|
|
56
|
+
if threshold is not None:
|
|
57
|
+
self.default_threshold = float(threshold)
|
|
58
|
+
elif self.question_type == "score":
|
|
59
|
+
self.default_threshold = 3.0 # default 3 on 0..4 rubric
|
|
60
|
+
elif self.question_type == "noul":
|
|
61
|
+
self.default_threshold = 0.5 # default P(true) >= 0.5
|
|
62
|
+
else:
|
|
63
|
+
self.default_threshold = 0.0
|
|
64
|
+
|
|
65
|
+
def __call__(
|
|
66
|
+
self,
|
|
67
|
+
evaluator: Any | None = None,
|
|
68
|
+
threshold: float | None = None,
|
|
69
|
+
**state_kwargs: Any,
|
|
70
|
+
) -> EvalResult:
|
|
71
|
+
"""Evaluate this metric directly as a callable.
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
groundedness = Groundedness(threshold=3.0)
|
|
75
|
+
res = groundedness(retrieved_context="...", response="...")
|
|
76
|
+
"""
|
|
77
|
+
from ..evaluator import default_evaluator
|
|
78
|
+
|
|
79
|
+
ev = evaluator or default_evaluator()
|
|
80
|
+
return ev.evaluate(self, threshold=threshold, **state_kwargs)
|
|
81
|
+
|
|
82
|
+
def evaluate(
|
|
83
|
+
self,
|
|
84
|
+
evaluator: Any | None = None,
|
|
85
|
+
threshold: float | None = None,
|
|
86
|
+
**state_kwargs: Any,
|
|
87
|
+
) -> EvalResult:
|
|
88
|
+
"""Evaluate this metric against input state."""
|
|
89
|
+
return self(evaluator=evaluator, threshold=threshold, **state_kwargs)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def question_key(self) -> str:
|
|
93
|
+
"""The key used in the questions dict."""
|
|
94
|
+
return self._question_key or self.metric_name
|
|
95
|
+
|
|
96
|
+
def build_state(self, **kwargs) -> dict[str, Any]:
|
|
97
|
+
"""Validate required state fields and return the state dict for LAYA."""
|
|
98
|
+
missing = [f for f in self.state_fields if f not in kwargs or kwargs[f] is None]
|
|
99
|
+
if missing:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Metric {self.metric_name} ({self.metric_id}) requires state fields: "
|
|
102
|
+
f"{', '.join(missing)}"
|
|
103
|
+
)
|
|
104
|
+
state: dict[str, Any] = {}
|
|
105
|
+
for key in self.state_fields:
|
|
106
|
+
state[key] = kwargs[key]
|
|
107
|
+
for key in self.optional_fields:
|
|
108
|
+
if key in kwargs and kwargs[key] is not None:
|
|
109
|
+
state[key] = kwargs[key]
|
|
110
|
+
return state
|
|
111
|
+
|
|
112
|
+
def build_questions(self) -> dict[str, Any]:
|
|
113
|
+
"""Generate the typed question dictionary to send to LAYA."""
|
|
114
|
+
return dict(self._question_template)
|
|
115
|
+
|
|
116
|
+
def parse_result(
|
|
117
|
+
self,
|
|
118
|
+
answer: dict[str, Any],
|
|
119
|
+
threshold: float | None = None,
|
|
120
|
+
state: dict[str, Any] | None = None,
|
|
121
|
+
) -> EvalResult:
|
|
122
|
+
"""Parse raw LAYA answer output into a structured EvalResult."""
|
|
123
|
+
t = self.default_threshold if threshold is None else float(threshold)
|
|
124
|
+
raw_ans = answer.get(self.question_key, answer)
|
|
125
|
+
|
|
126
|
+
score_val: float | None = None
|
|
127
|
+
noul_val: float | None = None
|
|
128
|
+
choice_val: str | None = None
|
|
129
|
+
passed = False
|
|
130
|
+
reason = None
|
|
131
|
+
|
|
132
|
+
if self.question_type == "score":
|
|
133
|
+
if "score" in raw_ans and raw_ans["score"] is not None:
|
|
134
|
+
score_val = float(raw_ans["score"])
|
|
135
|
+
passed = score_val >= t
|
|
136
|
+
reason = f"score {score_val} {'≥' if passed else '<'} threshold {t}"
|
|
137
|
+
elif self.question_type == "noul":
|
|
138
|
+
if "noul" in raw_ans and raw_ans["noul"] is not None:
|
|
139
|
+
noul_val = float(raw_ans["noul"])
|
|
140
|
+
# For risk/hallucination/contradiction metrics, pass condition may be <= max
|
|
141
|
+
if (
|
|
142
|
+
"risk" in self.metric_name
|
|
143
|
+
or "contradiction" in self.metric_name
|
|
144
|
+
or "conflict" in self.metric_name
|
|
145
|
+
or "injection" in self.metric_name
|
|
146
|
+
or "hallucination" in self.metric_name
|
|
147
|
+
or "redundant" in self.metric_name
|
|
148
|
+
):
|
|
149
|
+
passed = noul_val <= t
|
|
150
|
+
reason = f"risk {noul_val} {'≤' if passed else '>'} threshold {t}"
|
|
151
|
+
else:
|
|
152
|
+
passed = noul_val >= t
|
|
153
|
+
reason = f"noul P(true) {noul_val} {'≥' if passed else '<'} threshold {t}"
|
|
154
|
+
elif self.question_type == "choice":
|
|
155
|
+
choice_val = raw_ans.get("choice")
|
|
156
|
+
passed = bool(choice_val is not None)
|
|
157
|
+
reason = f"selected choice: {choice_val}"
|
|
158
|
+
|
|
159
|
+
conf = raw_ans.get("confidence")
|
|
160
|
+
ans_conf = raw_ans.get("answer_confidence")
|
|
161
|
+
|
|
162
|
+
return EvalResult(
|
|
163
|
+
metric_id=self.metric_id,
|
|
164
|
+
metric_name=self.metric_name,
|
|
165
|
+
score=score_val,
|
|
166
|
+
noul=noul_val,
|
|
167
|
+
choice=choice_val,
|
|
168
|
+
confidence=float(conf) if conf is not None else None,
|
|
169
|
+
answer_confidence=float(ans_conf) if ans_conf is not None else None,
|
|
170
|
+
passed=passed,
|
|
171
|
+
threshold=t,
|
|
172
|
+
reason=reason,
|
|
173
|
+
raw=raw_ans,
|
|
174
|
+
state=state or {},
|
|
175
|
+
)
|