applexus-iris-evals 1.0.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.
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: applexus-iris-evals
3
+ Version: 1.0.0
4
+ Summary: Reusable evaluation framework for IRIS SAP Joule A2A agents
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx>=0.27.0
8
+ Requires-Dist: python-dotenv>=1.0.1
9
+ Requires-Dist: rich>=13.0.0
10
+ Requires-Dist: pydantic>=2.0.0
11
+ Requires-Dist: anthropic>=0.30.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
14
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
15
+
16
+ # iris-evals
17
+
18
+ Self-evaluation library for Applexus IRIS SAP Joule agents.
19
+
20
+ Install once — every agent evaluates itself on startup, runs a background loop every 5 minutes, and exposes a `/health/evals` CI gate endpoint. All golden datasets, rubrics, and thresholds are bundled inside the library. Zero eval data files needed per agent.
21
+
22
+ ---
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install git+https://github.com/applexus/iris-evals.git@v1.0.0
28
+ ```
29
+
30
+ Add to your agent's `requirements.txt`:
31
+
32
+ ```
33
+ iris-evals @ git+https://github.com/applexus/iris-evals.git@v1.0.0
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Quick Start
39
+
40
+ Add three blocks to your agent's `app.py`:
41
+
42
+ ```python
43
+ import asyncio, os
44
+ from iris_evals import run_self_eval, self_eval_loop
45
+
46
+ AGENT_URL = os.getenv("AGENT_URL", "http://localhost:8080")
47
+
48
+ @app.on_event("startup")
49
+ async def startup_self_eval():
50
+ report = await run_self_eval(agent_url=AGENT_URL)
51
+ if not report.overall_passed:
52
+ logger.warning("[IRIS EVALS] Self-eval FAILED — %s", report.summary)
53
+
54
+ @app.on_event("startup")
55
+ async def start_eval_loop():
56
+ asyncio.create_task(
57
+ self_eval_loop(agent_url=AGENT_URL, interval_seconds=300)
58
+ )
59
+
60
+ @app.route("/health/evals", methods=["GET"])
61
+ async def eval_health(request):
62
+ from starlette.responses import JSONResponse
63
+ report = await run_self_eval(agent_url=AGENT_URL)
64
+ return JSONResponse({"passed": report.overall_passed, "summary": report.summary})
65
+ ```
66
+
67
+ Add to your agent's `agent-card.json`:
68
+
69
+ ```json
70
+ "x_evals": {
71
+ "evaluators": ["deterministic", "safety"],
72
+ "dataset": "builtin",
73
+ "threshold_profile": "deterministic_strict"
74
+ }
75
+ ```
76
+
77
+ ---
78
+
79
+ ## 7 Eval Types
80
+
81
+ | # | Type | Needs AI | Use when |
82
+ |---|------|----------|----------|
83
+ | 1 | `deterministic` | No | Rule-based agents — exact verdict match |
84
+ | 2 | `probabilistic` | No | Classifiers — accuracy / F1 ≥ 85% |
85
+ | 3 | `llm_as_judge` | Yes (AI Core) | Free-text output quality — rubric scored |
86
+ | 4 | `agent_as_judge` | Yes (A2A) | Complex SAP domain — peer agent reviews |
87
+ | 5 | `trace` | No | Multi-step agents — correct tool call sequence |
88
+ | 6 | `safety` | No | Every agent — PII + injection checks |
89
+ | 7 | `performance` | No | SLA-critical — latency p95 ≤ 5000ms |
90
+
91
+ **Quick picks:**
92
+ - Rule-based agent → `1, 6`
93
+ - Triage / classifier → `2, 3, 6`
94
+ - Multi-step → `1, 5, 6`
95
+ - SLA-critical → `1, 5, 6, 7`
96
+
97
+ ---
98
+
99
+ ## How it works
100
+
101
+ ```
102
+ cf push
103
+ └─ pip installs iris-evals from GitHub
104
+ └─ startup: run_self_eval() calls agent via A2A with golden records
105
+ └─ CI polls GET /health/evals → passed: true → promote
106
+ └─ background: self_eval_loop() re-runs every 5 minutes
107
+ ```
108
+
109
+ iris-evals runs **inside your agent's process** as an asyncio task — no separate BTP service, no extra cost.
110
+
111
+ ---
112
+
113
+ ## LLM Judge (type 3)
114
+
115
+ Uses SAP AI Core orchestration. Requires these env vars in your agent's `manifest.yml` (gitignored):
116
+
117
+ ```
118
+ AICORE_CLIENT_ID
119
+ AICORE_CLIENT_SECRET
120
+ AICORE_AUTH_URL
121
+ AICORE_BASE_URL
122
+ AICORE_RESOURCE_GROUP
123
+ ORCHESTRATION_URL
124
+ AICORE_JUDGE_MODEL (default: gpt-4o)
125
+ ```
126
+
127
+ If your agent already has AI Core credentials, iris-evals picks them up automatically — no extra config.
128
+
129
+ ---
130
+
131
+ ## Skill Integration
132
+
133
+ The `iris-sap-agent-skill` asks **Q_EVAL** as its last interview question:
134
+
135
+ ```
136
+ Which evaluation types does this agent need?
137
+ → deterministic / probabilistic / llm_as_judge / agent_as_judge / trace / safety / performance
138
+ ```
139
+
140
+ Answering generates all wiring automatically — `x_evals` block, `app.py` startup/loop/health, and `requirements.txt` entry.
141
+
142
+ ---
143
+
144
+ ## Running Tests
145
+
146
+ ```bash
147
+ pip install -e ".[dev]"
148
+ pytest
149
+ ```
150
+
151
+ ---
152
+
153
+ ## Project Structure
154
+
155
+ ```
156
+ src/iris_evals/
157
+ ├── aicore.py SAP AI Core orchestration client
158
+ ├── registry.py Built-in golden datasets, rubrics, thresholds
159
+ ├── schemas.py EvalType, GoldenRecord, EvalConfig, EvalReport
160
+ ├── self_eval.py run_self_eval() and self_eval_loop()
161
+ ├── runner.py EvalSuite base class
162
+ ├── _transport.py A2A JSON-RPC call helper
163
+ └── evaluators/
164
+ ├── deterministic.py
165
+ ├── probabilistic.py
166
+ ├── llm_judge.py
167
+ ├── safety.py
168
+ ├── trace.py
169
+ └── performance.py
170
+ ```
171
+
172
+ ---
173
+
174
+ ## Versioning
175
+
176
+ | Version | Notes |
177
+ |---------|-------|
178
+ | v1.0.0 | Initial release — 7 eval types, built-in registry, AI Core LLM judge |
179
+
180
+ Tag a new version and update `requirements.txt` in each agent to upgrade:
181
+
182
+ ```bash
183
+ git tag v1.0.1
184
+ git push --tags
185
+ ```
@@ -0,0 +1,19 @@
1
+ iris_evals/__init__.py,sha256=yAhT45gnSJdlLz-m4qrFLd_u5BMJzf5LlirWVoI2d14,2402
2
+ iris_evals/_transport.py,sha256=vhSDUylNLEa-UlRxyfZlUdHhPW7Pzj6OZ0Nj8FtA4Tw,2391
3
+ iris_evals/aicore.py,sha256=GcSYkrTcVhEqKVNgn_xWnP1PHZY1IIhOZWxcluUUxPI,3568
4
+ iris_evals/registry.py,sha256=ELxjug3HyRoTzDvbT0x6p3BvhCL8TqCzPI66JoRauk8,12032
5
+ iris_evals/results.py,sha256=52jKjKpvb3FB-gX0pDp7WoUkSO1fE26kC074yeXJ6Jg,2706
6
+ iris_evals/runner.py,sha256=9tYwSdo2G5SjHlpEFRzmWAtZY0SnFKdzYlBw5_XAV_M,3917
7
+ iris_evals/schemas.py,sha256=Xj8yQWwmRG5QlqpKDPkDEA5oHInGs79yfWBHUy_3tyU,5340
8
+ iris_evals/self_eval.py,sha256=R9q1Dmvu-UGexXoCZpI5BlFGN5yPoylTl9qSUEp-v-g,10870
9
+ iris_evals/evaluators/__init__.py,sha256=rF3sJzuHKdm9hYhsPynllsgNYs9b2pYVJ6bRWb-TLHE,251
10
+ iris_evals/evaluators/deterministic.py,sha256=Yj_ZmCq5OPlTjODHRQXgSkBghhUdC3NFCh02C-L9ZnA,2281
11
+ iris_evals/evaluators/llm_judge.py,sha256=_d4rFJH1HU8ycGC8z_QQbwtrmHtTaQB7mujWLWz3rwI,4512
12
+ iris_evals/evaluators/performance.py,sha256=CU-vopv8AneuSPrs6LBjNpHe47kHlxakhv2aVIYTdgg,3043
13
+ iris_evals/evaluators/probabilistic.py,sha256=p1MVuKXWguS0LQpR-vlUaC6tx5R8_OMZqzzeC-J0bgM,4135
14
+ iris_evals/evaluators/safety.py,sha256=wYdcAf6GXBR1pcTcDViSzrfaO9J6iCbBjRnmLFaJjFU,3776
15
+ iris_evals/evaluators/trace.py,sha256=OQ-pFRNSdZ2j4DvvAcc477JFJdhE_5vAZTj5uJN1sbw,3383
16
+ applexus_iris_evals-1.0.0.dist-info/METADATA,sha256=Ex2pLM4e1plPPr1uGTgIZtq6w--KxJvmpIf9kEz7ZHY,5158
17
+ applexus_iris_evals-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
18
+ applexus_iris_evals-1.0.0.dist-info/top_level.txt,sha256=fly3XfJNLI8pg1in9fHDx3JCol1Zj7_O72ObWcqtEl4,11
19
+ applexus_iris_evals-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ iris_evals
iris_evals/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """
2
+ iris-evals — Reusable evaluation framework for IRIS SAP Joule A2A agents.
3
+
4
+ ── Startup self-eval (simplest usage) ──────────────────────────────────────────
5
+ from iris_evals import run_self_eval
6
+
7
+ @app.on_event("startup")
8
+ async def startup():
9
+ report = await run_self_eval(agent_url="http://localhost:8080")
10
+ if not report.overall_passed:
11
+ logger.warning(f"[IRIS EVALS] {report.summary}")
12
+
13
+ ── Background loop ──────────────────────────────────────────────────────────────
14
+ from iris_evals import self_eval_loop
15
+
16
+ @app.on_event("startup")
17
+ async def startup():
18
+ asyncio.create_task(self_eval_loop(agent_url="http://localhost:8080", interval_seconds=300))
19
+
20
+ ── Health endpoint ───────────────────────────────────────────────────────────────
21
+ @app.get("/health/evals")
22
+ async def eval_health():
23
+ report = await run_self_eval(agent_url="http://localhost:8080")
24
+ return {"passed": report.overall_passed, "summary": report.summary}
25
+
26
+ ── Manual / CLI ─────────────────────────────────────────────────────────────────
27
+ iris-evals run --agent http://localhost:8080
28
+ iris-evals run-pipeline --pipeline pipeline.json
29
+ """
30
+
31
+ from iris_evals.runner import EvalSuite
32
+ from iris_evals.self_eval import run_self_eval, self_eval_loop
33
+ from iris_evals.schemas import (
34
+ EvalConfig,
35
+ EvalType,
36
+ EvalReport,
37
+ EvalResult,
38
+ GoldenRecord,
39
+ JudgeRubric,
40
+ JudgeCriterion,
41
+ )
42
+ from iris_evals.results import print_report, save_json
43
+ from iris_evals.registry import register_agent
44
+
45
+ __all__ = [
46
+ # self-eval (primary API)
47
+ "run_self_eval",
48
+ "self_eval_loop",
49
+ # manual suite
50
+ "EvalSuite",
51
+ # schemas
52
+ "EvalConfig",
53
+ "EvalType",
54
+ "EvalReport",
55
+ "EvalResult",
56
+ "GoldenRecord",
57
+ "JudgeRubric",
58
+ "JudgeCriterion",
59
+ # output
60
+ "print_report",
61
+ "save_json",
62
+ # registry extension
63
+ "register_agent",
64
+ ]
@@ -0,0 +1,80 @@
1
+ """
2
+ _transport.py — Agent call transport layer.
3
+
4
+ All evaluators call the agent through this single function.
5
+ Supports:
6
+ - A2A JSON-RPC (message/send) — default for IRIS agents
7
+ - Plain HTTP POST (for non-A2A agents or direct endpoint testing)
8
+
9
+ The agent URL and transport mode come from EvalConfig.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import uuid
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ from iris_evals.schemas import EvalConfig
21
+
22
+ _TIMEOUT = 60.0
23
+
24
+
25
+ async def call_agent(config: EvalConfig, input_data: str | dict) -> dict:
26
+ """Send input to the agent and return the parsed response dict."""
27
+ payload = input_data if isinstance(input_data, str) else json.dumps(input_data)
28
+
29
+ if config.use_a2a:
30
+ return await _call_a2a(config.agent_url, payload)
31
+ else:
32
+ return await _call_http(config.agent_url, payload)
33
+
34
+
35
+ async def _call_a2a(base_url: str, text: str) -> dict:
36
+ """
37
+ SAP Joule A2A JSON-RPC 2.0 transport.
38
+ Mirrors the pattern used by a2a_client.py in every IRIS agent.
39
+ """
40
+ request_id = str(uuid.uuid4())
41
+ body = {
42
+ "jsonrpc": "2.0",
43
+ "id": request_id,
44
+ "method": "message/send",
45
+ "params": {
46
+ "message": {
47
+ "messageId": str(uuid.uuid4()),
48
+ "contextId": str(uuid.uuid4()),
49
+ "role": "user",
50
+ "parts": [{"kind": "text", "text": text}],
51
+ }
52
+ },
53
+ }
54
+
55
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
56
+ resp = await client.post(base_url, json=body)
57
+ resp.raise_for_status()
58
+ rpc = resp.json()
59
+
60
+ result = rpc.get("result") or {}
61
+ artifacts = result.get("artifacts", [])
62
+ if not artifacts:
63
+ return {"raw_rpc": rpc}
64
+
65
+ parts = artifacts[0].get("parts", [{}])
66
+ text_out = parts[0].get("text", "") if parts else ""
67
+ try:
68
+ parsed = json.loads(text_out)
69
+ parsed["result"] = result # keep A2A envelope for trace evaluator
70
+ return parsed
71
+ except json.JSONDecodeError:
72
+ return {"result": result, "text": text_out}
73
+
74
+
75
+ async def _call_http(url: str, text: str) -> dict[str, Any]:
76
+ """Plain HTTP POST — for non-A2A endpoints."""
77
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
78
+ resp = await client.post(url, json={"input": text})
79
+ resp.raise_for_status()
80
+ return resp.json()
iris_evals/aicore.py ADDED
@@ -0,0 +1,118 @@
1
+ """
2
+ aicore.py — SAP AI Core orchestration client for iris-evals LLM judge.
3
+
4
+ Handles OAuth2 token fetch (client credentials) and calls the
5
+ AI Core orchestration completion endpoint.
6
+
7
+ Required env vars:
8
+ AICORE_CLIENT_ID
9
+ AICORE_CLIENT_SECRET
10
+ AICORE_AUTH_URL
11
+ ORCHESTRATION_URL
12
+ AICORE_RESOURCE_GROUP (default: "default")
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import os
19
+ import time
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+
25
+ # ── Token cache ───────────────────────────────────────────────────────────────
26
+
27
+ _token: str | None = None
28
+ _token_expiry: float = 0.0 # epoch seconds
29
+ _TOKEN_BUFFER_SECS = 60 # refresh 60 s before actual expiry
30
+
31
+
32
+ def _fetch_token() -> str:
33
+ client_id = os.environ["AICORE_CLIENT_ID"]
34
+ client_secret = os.environ["AICORE_CLIENT_SECRET"]
35
+ auth_url = os.environ["AICORE_AUTH_URL"]
36
+
37
+ creds = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
38
+
39
+ resp = httpx.post(
40
+ auth_url,
41
+ headers={
42
+ "Authorization": f"Basic {creds}",
43
+ "Content-Type": "application/x-www-form-urlencoded",
44
+ },
45
+ data={"grant_type": "client_credentials"},
46
+ timeout=15,
47
+ )
48
+ resp.raise_for_status()
49
+ body = resp.json()
50
+ return body["access_token"], int(body.get("expires_in", 3600))
51
+
52
+
53
+ def _get_token() -> str:
54
+ global _token, _token_expiry
55
+ now = time.monotonic()
56
+ if _token is None or now >= _token_expiry:
57
+ token, expires_in = _fetch_token()
58
+ _token = token
59
+ _token_expiry = now + expires_in - _TOKEN_BUFFER_SECS
60
+ return _token
61
+
62
+
63
+ # ── Completion call ───────────────────────────────────────────────────────────
64
+
65
+ def chat_complete(
66
+ system: str,
67
+ user: str,
68
+ *,
69
+ model_name: str = "gpt-4o",
70
+ max_tokens: int = 512,
71
+ temperature: float = 0.0,
72
+ ) -> str:
73
+ """
74
+ Call the SAP AI Core orchestration service and return the assistant text.
75
+
76
+ Uses the deployment at ORCHESTRATION_URL env var.
77
+ The model_name must be deployed in the AI Core resource group.
78
+ """
79
+ url = os.environ["ORCHESTRATION_URL"].rstrip("/") + "/completion"
80
+ resource_group = os.environ.get("AICORE_RESOURCE_GROUP", "default")
81
+
82
+ payload: dict[str, Any] = {
83
+ "orchestration_config": {
84
+ "module_configurations": {
85
+ "llm_module_config": {
86
+ "model_name": model_name,
87
+ "model_params": {
88
+ "max_tokens": max_tokens,
89
+ "temperature": temperature,
90
+ },
91
+ }
92
+ }
93
+ },
94
+ "messages_history": [
95
+ {"role": "system", "content": system},
96
+ {"role": "user", "content": user},
97
+ ],
98
+ }
99
+
100
+ resp = httpx.post(
101
+ url,
102
+ headers={
103
+ "Authorization": f"Bearer {_get_token()}",
104
+ "AI-Resource-Group": resource_group,
105
+ "Content-Type": "application/json",
106
+ },
107
+ json=payload,
108
+ timeout=60,
109
+ )
110
+ resp.raise_for_status()
111
+ data = resp.json()
112
+
113
+ # SAP AI Core orchestration wraps the OpenAI-style response
114
+ choices = (
115
+ data.get("orchestration_result", data)
116
+ .get("choices", [{}])
117
+ )
118
+ return choices[0].get("message", {}).get("content", "")
@@ -0,0 +1,17 @@
1
+ from iris_evals.evaluators import (
2
+ deterministic,
3
+ probabilistic,
4
+ llm_judge,
5
+ trace,
6
+ safety,
7
+ performance,
8
+ )
9
+
10
+ __all__ = [
11
+ "deterministic",
12
+ "probabilistic",
13
+ "llm_judge",
14
+ "trace",
15
+ "safety",
16
+ "performance",
17
+ ]
@@ -0,0 +1,66 @@
1
+ """
2
+ deterministic.py — Exact-match evaluation against a golden dataset.
3
+
4
+ Sends each golden record to the agent, compares actual output to
5
+ expected_output / expected_verdict. No LLM involved.
6
+
7
+ Suitable for: P1.11 Duplicate Detection, P2.5 Tolerance Resolution,
8
+ and any fully deterministic rule-based agent.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from iris_evals.schemas import EvalConfig, EvalResult, EvalType, GoldenRecord
15
+ from iris_evals._transport import call_agent
16
+
17
+
18
+ async def run(config: EvalConfig, records: list[GoldenRecord]) -> list[EvalResult]:
19
+ results: list[EvalResult] = []
20
+
21
+ for rec in records:
22
+ t0 = time.monotonic()
23
+ try:
24
+ raw = await call_agent(config, rec.input)
25
+ latency_ms = (time.monotonic() - t0) * 1000
26
+
27
+ actual = _extract_verdict_or_text(raw)
28
+ expected = rec.expected_verdict or rec.expected_output or ""
29
+
30
+ passed = _matches(actual, expected)
31
+ results.append(EvalResult(
32
+ record_id=rec.id,
33
+ eval_type=EvalType.DETERMINISTIC,
34
+ passed=passed,
35
+ score=1.0 if passed else 0.0,
36
+ actual_output=actual,
37
+ expected_output=expected,
38
+ reason="" if passed else f"Expected '{expected}', got '{actual}'",
39
+ latency_ms=latency_ms,
40
+ ))
41
+ except Exception as exc:
42
+ results.append(EvalResult(
43
+ record_id=rec.id,
44
+ eval_type=EvalType.DETERMINISTIC,
45
+ passed=False,
46
+ reason=f"Agent call failed: {exc}",
47
+ ))
48
+
49
+ return results
50
+
51
+
52
+ def _extract_verdict_or_text(raw: dict) -> str:
53
+ """Pull verdict / invoice_resolution / text from the agent response."""
54
+ # structured SAP agent response shapes
55
+ for key in ("verdict", "invoice_resolution", "resolution", "status"):
56
+ if key in raw:
57
+ return str(raw[key])
58
+ # A2A artifact text fallback
59
+ if "result" in raw:
60
+ parts = raw["result"].get("artifacts", [{}])[0].get("parts", [{}])
61
+ return parts[0].get("text", str(raw)) if parts else str(raw)
62
+ return str(raw)
63
+
64
+
65
+ def _matches(actual: str, expected: str) -> bool:
66
+ return actual.strip().upper() == expected.strip().upper()