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/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
+ )