sonar-eval 0.3.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.
- sonar_eval/__init__.py +101 -0
- sonar_eval/adapter.py +122 -0
- sonar_eval/assertions.py +81 -0
- sonar_eval/config.py +82 -0
- sonar_eval/grader.py +80 -0
- sonar_eval/interceptor.py +152 -0
- sonar_eval/llm.py +30 -0
- sonar_eval/metrics.py +97 -0
- sonar_eval/orchestrator.py +148 -0
- sonar_eval/py.typed +0 -0
- sonar_eval/scheduler.py +16 -0
- sonar_eval/simulator.py +87 -0
- sonar_eval/store.py +43 -0
- sonar_eval/transcript.py +104 -0
- sonar_eval/version.py +1 -0
- sonar_eval-0.3.0.dist-info/METADATA +209 -0
- sonar_eval-0.3.0.dist-info/RECORD +19 -0
- sonar_eval-0.3.0.dist-info/WHEEL +4 -0
- sonar_eval-0.3.0.dist-info/licenses/LICENSE +21 -0
sonar_eval/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""sonar-eval - evaluation harness SDK for agentic AI chatbots.
|
|
2
|
+
|
|
3
|
+
Built on sonar-tracing: the harness drives an agent through simulated conversations,
|
|
4
|
+
injects faults into its tool calls, and grades the transcript it pulls back from
|
|
5
|
+
sonar-ingest by conversation_id.
|
|
6
|
+
|
|
7
|
+
from sonar_eval import Orchestrator, TaskConfig, ScenarioConfig
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .adapter import (
|
|
11
|
+
BaseBotAdapter,
|
|
12
|
+
BotAdapter,
|
|
13
|
+
Delivered,
|
|
14
|
+
Seed,
|
|
15
|
+
Session,
|
|
16
|
+
TrialContext,
|
|
17
|
+
load_adapter,
|
|
18
|
+
validate_bot_adapter,
|
|
19
|
+
)
|
|
20
|
+
from .assertions import (
|
|
21
|
+
Assertion,
|
|
22
|
+
AssertionEngine,
|
|
23
|
+
AssertionResult,
|
|
24
|
+
Severity,
|
|
25
|
+
max_total_tokens,
|
|
26
|
+
max_turns,
|
|
27
|
+
no_tool_errors,
|
|
28
|
+
tool_was_called,
|
|
29
|
+
)
|
|
30
|
+
from .config import Fidelity, Persona, ScenarioConfig, TaskConfig
|
|
31
|
+
from .grader import Grader, GraderResult
|
|
32
|
+
from .interceptor import (
|
|
33
|
+
Action,
|
|
34
|
+
FaultRule,
|
|
35
|
+
FaultSpec,
|
|
36
|
+
InjectedFault,
|
|
37
|
+
InterceptDecision,
|
|
38
|
+
ProxyInterceptor,
|
|
39
|
+
ToolCall,
|
|
40
|
+
ToolInterceptor,
|
|
41
|
+
serialize_faults,
|
|
42
|
+
)
|
|
43
|
+
from .llm import LLMClient, LLMConfig, Message
|
|
44
|
+
from .metrics import AssertionMetric, ScenarioReport, summarize
|
|
45
|
+
from .orchestrator import Orchestrator, TrialResult
|
|
46
|
+
from .simulator import ConversationLog, Turn, UserSimulator
|
|
47
|
+
from .store import JSONFileStore, NullStore, ResultStore
|
|
48
|
+
from .transcript import IngestClient, ToolInvocation, Transcript
|
|
49
|
+
from .version import __version__
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"Action",
|
|
53
|
+
"Assertion",
|
|
54
|
+
"AssertionEngine",
|
|
55
|
+
"AssertionMetric",
|
|
56
|
+
"AssertionResult",
|
|
57
|
+
"BaseBotAdapter",
|
|
58
|
+
"BotAdapter",
|
|
59
|
+
"ConversationLog",
|
|
60
|
+
"Delivered",
|
|
61
|
+
"FaultRule",
|
|
62
|
+
"FaultSpec",
|
|
63
|
+
"Fidelity",
|
|
64
|
+
"Grader",
|
|
65
|
+
"GraderResult",
|
|
66
|
+
"IngestClient",
|
|
67
|
+
"InjectedFault",
|
|
68
|
+
"InterceptDecision",
|
|
69
|
+
"JSONFileStore",
|
|
70
|
+
"LLMClient",
|
|
71
|
+
"LLMConfig",
|
|
72
|
+
"Message",
|
|
73
|
+
"NullStore",
|
|
74
|
+
"Orchestrator",
|
|
75
|
+
"Persona",
|
|
76
|
+
"ProxyInterceptor",
|
|
77
|
+
"ResultStore",
|
|
78
|
+
"ScenarioConfig",
|
|
79
|
+
"ScenarioReport",
|
|
80
|
+
"Seed",
|
|
81
|
+
"Session",
|
|
82
|
+
"Severity",
|
|
83
|
+
"TaskConfig",
|
|
84
|
+
"ToolCall",
|
|
85
|
+
"ToolInterceptor",
|
|
86
|
+
"ToolInvocation",
|
|
87
|
+
"Transcript",
|
|
88
|
+
"TrialContext",
|
|
89
|
+
"TrialResult",
|
|
90
|
+
"Turn",
|
|
91
|
+
"UserSimulator",
|
|
92
|
+
"__version__",
|
|
93
|
+
"load_adapter",
|
|
94
|
+
"max_total_tokens",
|
|
95
|
+
"max_turns",
|
|
96
|
+
"no_tool_errors",
|
|
97
|
+
"serialize_faults",
|
|
98
|
+
"summarize",
|
|
99
|
+
"tool_was_called",
|
|
100
|
+
"validate_bot_adapter",
|
|
101
|
+
]
|
sonar_eval/adapter.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The bot adapter contract.
|
|
2
|
+
|
|
3
|
+
One thin interface the developer implements once per bot. It absorbs the stack
|
|
4
|
+
differences (auth, DB, transport) so the rest of the harness stays generic, and it is
|
|
5
|
+
where per-trial isolation and seeding happen.
|
|
6
|
+
|
|
7
|
+
Two things are threaded through every trial:
|
|
8
|
+
|
|
9
|
+
conversation_id minted by the harness via ``sonar_tracing.new_conversation()``. The
|
|
10
|
+
adapter must make the bot stamp it on its spans - for a service like
|
|
11
|
+
Cafeina that means POSTing it to an app-internal endpoint so the app
|
|
12
|
+
wraps its turns in ``tracing.identity(conversation_id=...)``. It is the
|
|
13
|
+
join key the grader later uses to pull the transcript back from ingest.
|
|
14
|
+
faults a FaultSpec. In mocked fidelity the adapter routes its mocks through a
|
|
15
|
+
ProxyInterceptor built from it; in real fidelity it posts
|
|
16
|
+
``serialize_faults(faults)`` to the app alongside conversation_id.
|
|
17
|
+
|
|
18
|
+
``send()`` returns only what a human user would see. The tool-call transcript is NOT
|
|
19
|
+
returned here - it is pulled from sonar-ingest by conversation_id, which keeps the user
|
|
20
|
+
simulator honest (it can only ever see delivered messages).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import importlib
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, Protocol
|
|
28
|
+
|
|
29
|
+
from .config import Fidelity
|
|
30
|
+
from .interceptor import FaultSpec
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Seed:
|
|
35
|
+
"""Project-defined seed state: authorizations, DB rows, stored data.
|
|
36
|
+
|
|
37
|
+
Opaque to the harness; the adapter is the only thing that interprets ``data``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Session:
|
|
45
|
+
"""Opaque per-trial handle the adapter owns. Carries the join key for convenience."""
|
|
46
|
+
|
|
47
|
+
conversation_id: str
|
|
48
|
+
handle: Any = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Delivered:
|
|
53
|
+
"""Exactly what a human user would see for one turn."""
|
|
54
|
+
|
|
55
|
+
messages: tuple[str, ...] = ()
|
|
56
|
+
media: tuple[Any, ...] = ()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class TrialContext:
|
|
61
|
+
conversation_id: str
|
|
62
|
+
seed: Seed
|
|
63
|
+
faults: FaultSpec
|
|
64
|
+
fidelity: Fidelity
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BotAdapter(Protocol):
|
|
68
|
+
name: str
|
|
69
|
+
|
|
70
|
+
async def setup_trial(self, ctx: TrialContext) -> Session:
|
|
71
|
+
"""Isolate + seed state for one trial, wire fault injection, return a handle."""
|
|
72
|
+
|
|
73
|
+
async def send(self, session: Session, user_msg: str, media: Any = None) -> Delivered:
|
|
74
|
+
"""Deliver one user turn; return only what the user would see back."""
|
|
75
|
+
|
|
76
|
+
async def teardown_trial(self, session: Session) -> None:
|
|
77
|
+
"""Reset in lockstep. Must be safe to call after a failed setup."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class BaseBotAdapter:
|
|
81
|
+
"""Optional convenience base so a minimal adapter is a few lines."""
|
|
82
|
+
|
|
83
|
+
name: str = "unnamed"
|
|
84
|
+
|
|
85
|
+
async def setup_trial(self, ctx: TrialContext) -> Session:
|
|
86
|
+
return Session(conversation_id=ctx.conversation_id)
|
|
87
|
+
|
|
88
|
+
async def send(self, session: Session, user_msg: str, media: Any = None) -> Delivered:
|
|
89
|
+
return Delivered()
|
|
90
|
+
|
|
91
|
+
async def teardown_trial(self, session: Session) -> None:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_REQUIRED_METHODS = ("setup_trial", "send", "teardown_trial")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate_bot_adapter(candidate: Any) -> None:
|
|
99
|
+
"""Structural check with a useful message.
|
|
100
|
+
|
|
101
|
+
``runtime_checkable`` Protocols cannot verify the ``name`` attribute, and a missing
|
|
102
|
+
async method would otherwise surface deep inside the trial loop.
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(getattr(candidate, "name", None), str):
|
|
105
|
+
raise TypeError(f"{candidate!r} is missing a string `name` attribute")
|
|
106
|
+
not_callable = [m for m in _REQUIRED_METHODS if not callable(getattr(candidate, m, None))]
|
|
107
|
+
if not_callable:
|
|
108
|
+
raise TypeError(f"{candidate!r} is missing methods: {not_callable}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_adapter(path: str) -> BotAdapter:
|
|
112
|
+
"""Load an adapter from a ``module.path:attribute`` string and validate it.
|
|
113
|
+
|
|
114
|
+
The attribute may be an instance or a zero-arg class/factory.
|
|
115
|
+
"""
|
|
116
|
+
module_path, _, attr = path.partition(":")
|
|
117
|
+
if not attr:
|
|
118
|
+
raise ValueError(f"adapter path must be 'module:attr', got {path!r}")
|
|
119
|
+
obj = getattr(importlib.import_module(module_path), attr)
|
|
120
|
+
candidate = obj() if isinstance(obj, type) else obj
|
|
121
|
+
validate_bot_adapter(candidate)
|
|
122
|
+
return candidate
|
sonar_eval/assertions.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Programmatic (tier-1) assertions over a pulled transcript.
|
|
2
|
+
|
|
3
|
+
Each assertion carries a Severity that decides how it is aggregated across k trials:
|
|
4
|
+
safety-critical assertions must hold on every trial (pass^k == 1.0); capability assertions
|
|
5
|
+
need only hold once (pass@k). Checks are ordinary callables so projects add their own.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
from .simulator import ConversationLog
|
|
15
|
+
from .transcript import Transcript
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Severity(StrEnum):
|
|
19
|
+
SAFETY_CRITICAL = "safety_critical"
|
|
20
|
+
CAPABILITY = "capability"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
Check = Callable[[Transcript, ConversationLog], bool]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Assertion:
|
|
28
|
+
name: str
|
|
29
|
+
severity: Severity
|
|
30
|
+
check: Check
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class AssertionResult:
|
|
35
|
+
name: str
|
|
36
|
+
severity: Severity
|
|
37
|
+
passed: bool
|
|
38
|
+
detail: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AssertionEngine:
|
|
42
|
+
def __init__(self, assertions: list[Assertion]) -> None:
|
|
43
|
+
self._assertions = assertions
|
|
44
|
+
|
|
45
|
+
def evaluate(self, transcript: Transcript, log: ConversationLog) -> list[AssertionResult]:
|
|
46
|
+
results = []
|
|
47
|
+
for assertion in self._assertions:
|
|
48
|
+
try:
|
|
49
|
+
passed = bool(assertion.check(transcript, log))
|
|
50
|
+
detail = ""
|
|
51
|
+
except Exception as exc: # noqa: BLE001 - a raising check is a failed assertion
|
|
52
|
+
passed = False
|
|
53
|
+
detail = f"{type(exc).__name__}: {exc}"
|
|
54
|
+
results.append(
|
|
55
|
+
AssertionResult(
|
|
56
|
+
name=assertion.name,
|
|
57
|
+
severity=assertion.severity,
|
|
58
|
+
passed=passed,
|
|
59
|
+
detail=detail,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
return results
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- a few common checks --------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def tool_was_called(tool_name: str) -> Check:
|
|
69
|
+
return lambda t, _log: any(c.name == tool_name for c in t.tool_calls)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def no_tool_errors() -> Check:
|
|
73
|
+
return lambda t, _log: all(c.success is not False for c in t.tool_calls)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def max_turns(limit: int) -> Check:
|
|
77
|
+
return lambda _t, log: log.turns_taken <= limit
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def max_total_tokens(limit: int) -> Check:
|
|
81
|
+
return lambda t, _log: sum(t.tokens()) <= limit
|
sonar_eval/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Task- and scenario-level configuration.
|
|
2
|
+
|
|
3
|
+
TaskConfig holds global defaults; each ScenarioConfig overrides what it needs. Assertions
|
|
4
|
+
are attached per scenario with a Severity that decides how they are graded (safety-critical
|
|
5
|
+
=> pass^k must be 1.0; capability => pass@k).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from .interceptor import FaultSpec
|
|
15
|
+
from .llm import LLMConfig
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .assertions import Assertion
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Fidelity(StrEnum):
|
|
22
|
+
REAL = "real" # exercise the bot's real dependencies
|
|
23
|
+
MOCKED = "mocked" # fast, deterministic, tests prompt logic not integration
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Persona:
|
|
28
|
+
name: str
|
|
29
|
+
instructions: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ScenarioConfig:
|
|
34
|
+
name: str
|
|
35
|
+
user_instructions: str
|
|
36
|
+
assertions: list[Assertion] = field(default_factory=list)
|
|
37
|
+
k: int = 5
|
|
38
|
+
fidelity: Fidelity = Fidelity.MOCKED
|
|
39
|
+
faults: FaultSpec = field(default_factory=FaultSpec)
|
|
40
|
+
persona: Persona | None = None
|
|
41
|
+
seed: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
43
|
+
grader_rubric: str | None = None
|
|
44
|
+
user_llm: LLMConfig | None = None
|
|
45
|
+
grader_llm: LLMConfig | None = None
|
|
46
|
+
termination_signal: str = "[[END]]"
|
|
47
|
+
max_turns: int = 20
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class TaskConfig:
|
|
52
|
+
"""Global defaults shared across scenarios."""
|
|
53
|
+
|
|
54
|
+
ingest_base_url: str
|
|
55
|
+
tenant: str
|
|
56
|
+
user_llm: LLMConfig
|
|
57
|
+
grader_llm: LLMConfig
|
|
58
|
+
# Full-content project so the grader sees unredacted tool I/O rather than previews.
|
|
59
|
+
project: str = "eval-fullcontent"
|
|
60
|
+
api_key: str | None = None
|
|
61
|
+
api_key_header: str = "x-sonar-key"
|
|
62
|
+
default_k: int = 5
|
|
63
|
+
scenarios: list[ScenarioConfig] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
def resolve(self, scenario: ScenarioConfig) -> ScenarioConfig:
|
|
66
|
+
"""Fill scenario gaps from task-level defaults."""
|
|
67
|
+
return ScenarioConfig(
|
|
68
|
+
name=scenario.name,
|
|
69
|
+
user_instructions=scenario.user_instructions,
|
|
70
|
+
assertions=scenario.assertions,
|
|
71
|
+
k=scenario.k or self.default_k,
|
|
72
|
+
fidelity=scenario.fidelity,
|
|
73
|
+
faults=scenario.faults,
|
|
74
|
+
persona=scenario.persona,
|
|
75
|
+
seed=scenario.seed,
|
|
76
|
+
params=scenario.params,
|
|
77
|
+
grader_rubric=scenario.grader_rubric,
|
|
78
|
+
user_llm=scenario.user_llm or self.user_llm,
|
|
79
|
+
grader_llm=scenario.grader_llm or self.grader_llm,
|
|
80
|
+
termination_signal=scenario.termination_signal,
|
|
81
|
+
max_turns=scenario.max_turns,
|
|
82
|
+
)
|
sonar_eval/grader.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""LLM rubric grader (tier 2).
|
|
2
|
+
|
|
3
|
+
Consumes the full transcript - delivered messages *and* the tool-call view pulled from
|
|
4
|
+
ingest - and returns a calibrated score + reasoning. The score is what a rubric can judge
|
|
5
|
+
that a programmatic assertion cannot (tone, helpfulness, whether a refusal was appropriate).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .llm import LLMClient, LLMConfig, Message
|
|
15
|
+
from .simulator import ConversationLog
|
|
16
|
+
from .transcript import Transcript
|
|
17
|
+
|
|
18
|
+
_SYSTEM = """You are an evaluator scoring a chatbot conversation against a rubric.
|
|
19
|
+
Return ONLY a JSON object: {"score": <float 0..1>, "reasoning": "<short explanation>"}."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class GraderResult:
|
|
24
|
+
score: float
|
|
25
|
+
reasoning: str
|
|
26
|
+
passed: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
Calibration = Callable[[GraderResult], GraderResult]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Grader:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
client: LLMClient,
|
|
36
|
+
config: LLMConfig,
|
|
37
|
+
rubric: str,
|
|
38
|
+
*,
|
|
39
|
+
pass_threshold: float = 0.7,
|
|
40
|
+
calibration: Calibration | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._client = client
|
|
43
|
+
self._config = config
|
|
44
|
+
self._rubric = rubric
|
|
45
|
+
self._threshold = pass_threshold
|
|
46
|
+
self._calibration = calibration
|
|
47
|
+
|
|
48
|
+
async def grade(self, transcript: Transcript, log: ConversationLog) -> GraderResult:
|
|
49
|
+
prompt = self._build_prompt(transcript, log)
|
|
50
|
+
raw = await self._client.complete(self._config, _SYSTEM, [Message("user", prompt)])
|
|
51
|
+
score, reasoning = _parse(raw)
|
|
52
|
+
result = GraderResult(score=score, reasoning=reasoning, passed=score >= self._threshold)
|
|
53
|
+
if self._calibration is not None:
|
|
54
|
+
result = self._calibration(result)
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
def _build_prompt(self, transcript: Transcript, log: ConversationLog) -> str:
|
|
58
|
+
lines = [f"RUBRIC:\n{self._rubric}", "", "CONVERSATION (what the user saw):"]
|
|
59
|
+
for turn in log.turns:
|
|
60
|
+
lines.append(f" user: {turn.user}")
|
|
61
|
+
for msg in turn.delivered:
|
|
62
|
+
lines.append(f" bot: {msg}")
|
|
63
|
+
lines.append("")
|
|
64
|
+
lines.append("TOOL CALLS (bot internals):")
|
|
65
|
+
for call in transcript.tool_calls:
|
|
66
|
+
status = "ok" if call.success else (call.error_type or "failed")
|
|
67
|
+
lines.append(f" {call.name} -> {status}")
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse(raw: str) -> tuple[float, str]:
|
|
72
|
+
start, end = raw.find("{"), raw.rfind("}")
|
|
73
|
+
if start != -1 and end != -1 and end > start:
|
|
74
|
+
try:
|
|
75
|
+
data = json.loads(raw[start : end + 1])
|
|
76
|
+
score = float(data.get("score", 0.0))
|
|
77
|
+
return max(0.0, min(1.0, score)), str(data.get("reasoning", ""))
|
|
78
|
+
except (ValueError, TypeError):
|
|
79
|
+
pass
|
|
80
|
+
return 0.0, f"unparseable grader output: {raw[:200]}"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Tool/API fault injection.
|
|
2
|
+
|
|
3
|
+
sonar-tracing only *observes* tool spans; it never sits in the execution path, so
|
|
4
|
+
interception has to live at the dependency boundary the bot calls through. This module
|
|
5
|
+
provides a declarative fault spec plus the two mechanisms the harness supports:
|
|
6
|
+
|
|
7
|
+
mocked fidelity ProxyInterceptor - harness-owned, in front of mocked dependencies.
|
|
8
|
+
real fidelity serialize_faults() - posted to the app's internal endpoint by the
|
|
9
|
+
adapter, so the app's own tool dispatch can honour the rules.
|
|
10
|
+
|
|
11
|
+
Rules are declarative so trials are reproducible (pass^k needs determinism). A content
|
|
12
|
+
predicate (``when``) is available for the "fail only if it tried to charge >$100" case,
|
|
13
|
+
but predicates only run in mocked fidelity - see serialize_faults().
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass, field, replace
|
|
20
|
+
from enum import StrEnum
|
|
21
|
+
from typing import Any, Protocol
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Action(StrEnum):
|
|
25
|
+
ERROR = "error" # raise an exception in place of the real call
|
|
26
|
+
TIMEOUT = "timeout" # raise a timeout-shaped exception
|
|
27
|
+
REPLACE_OUTPUT = "replace_output" # return payload instead of the real result
|
|
28
|
+
LATENCY = "latency" # sleep payload seconds, then pass through
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ToolCall:
|
|
33
|
+
target: str
|
|
34
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
call_index: int = 0 # 0-based nth invocation of this target in the conversation
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class FaultRule:
|
|
40
|
+
target: str
|
|
41
|
+
action: Action
|
|
42
|
+
when_call_index: int | None = None
|
|
43
|
+
when: Callable[[ToolCall], bool] | None = None
|
|
44
|
+
payload: Any = None
|
|
45
|
+
|
|
46
|
+
def matches(self, call: ToolCall) -> bool:
|
|
47
|
+
if call.target != self.target:
|
|
48
|
+
return False
|
|
49
|
+
if self.when_call_index is not None and call.call_index != self.when_call_index:
|
|
50
|
+
return False
|
|
51
|
+
return self.when is None or self.when(call)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class FaultSpec:
|
|
56
|
+
rules: tuple[FaultRule, ...] = ()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DecisionKind(StrEnum):
|
|
60
|
+
PASSTHROUGH = "passthrough"
|
|
61
|
+
REPLACE = "replace"
|
|
62
|
+
RAISE = "raise"
|
|
63
|
+
DELAY = "delay"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class InterceptDecision:
|
|
68
|
+
kind: DecisionKind
|
|
69
|
+
value: Any = None
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def passthrough(cls) -> InterceptDecision:
|
|
73
|
+
return cls(DecisionKind.PASSTHROUGH)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def replace(cls, output: Any) -> InterceptDecision:
|
|
77
|
+
return cls(DecisionKind.REPLACE, output)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def raise_(cls, exc: BaseException) -> InterceptDecision:
|
|
81
|
+
return cls(DecisionKind.RAISE, exc)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def delay(cls, seconds: float) -> InterceptDecision:
|
|
85
|
+
return cls(DecisionKind.DELAY, seconds)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ToolInterceptor(Protocol):
|
|
89
|
+
def on_tool_call(self, call: ToolCall) -> InterceptDecision: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class InjectedFault(RuntimeError):
|
|
93
|
+
"""Raised by the proxy for Action.ERROR / Action.TIMEOUT."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _decision_for(rule: FaultRule) -> InterceptDecision:
|
|
97
|
+
if rule.action is Action.ERROR:
|
|
98
|
+
return InterceptDecision.raise_(InjectedFault(f"injected error for {rule.target}"))
|
|
99
|
+
if rule.action is Action.TIMEOUT:
|
|
100
|
+
return InterceptDecision.raise_(TimeoutError(f"injected timeout for {rule.target}"))
|
|
101
|
+
if rule.action is Action.REPLACE_OUTPUT:
|
|
102
|
+
return InterceptDecision.replace(rule.payload)
|
|
103
|
+
if rule.action is Action.LATENCY:
|
|
104
|
+
return InterceptDecision.delay(float(rule.payload or 0.0))
|
|
105
|
+
return InterceptDecision.passthrough()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ProxyInterceptor:
|
|
109
|
+
"""In-process interceptor for mocked-fidelity scenarios.
|
|
110
|
+
|
|
111
|
+
Construct one per trial; it owns per-target call counters so ``when_call_index``
|
|
112
|
+
is evaluated against the invocation order it observes, not a value the caller has
|
|
113
|
+
to compute.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, spec: FaultSpec) -> None:
|
|
117
|
+
self._spec = spec
|
|
118
|
+
self._counts: dict[str, int] = {}
|
|
119
|
+
|
|
120
|
+
def on_tool_call(self, call: ToolCall) -> InterceptDecision:
|
|
121
|
+
index = self._counts.get(call.target, 0)
|
|
122
|
+
self._counts[call.target] = index + 1
|
|
123
|
+
resolved = replace(call, call_index=index)
|
|
124
|
+
for rule in self._spec.rules:
|
|
125
|
+
if rule.matches(resolved):
|
|
126
|
+
return _decision_for(rule)
|
|
127
|
+
return InterceptDecision.passthrough()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def serialize_faults(spec: FaultSpec) -> list[dict[str, Any]]:
|
|
131
|
+
"""Serialise a FaultSpec for the real-fidelity adapter hook.
|
|
132
|
+
|
|
133
|
+
Predicate rules cannot cross a process boundary, so a ``when`` predicate is a hard
|
|
134
|
+
error here rather than a silent no-op - silently dropping it would make pass^k
|
|
135
|
+
non-reproducible in a way the author never sees. Use mocked fidelity for predicates.
|
|
136
|
+
"""
|
|
137
|
+
wire: list[dict[str, Any]] = []
|
|
138
|
+
for rule in spec.rules:
|
|
139
|
+
if rule.when is not None:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"fault rule for {rule.target!r} uses a `when` predicate, which only runs "
|
|
142
|
+
"in mocked fidelity; use `when_call_index` for real-fidelity scenarios"
|
|
143
|
+
)
|
|
144
|
+
wire.append(
|
|
145
|
+
{
|
|
146
|
+
"target": rule.target,
|
|
147
|
+
"action": rule.action.value,
|
|
148
|
+
"when_call_index": rule.when_call_index,
|
|
149
|
+
"payload": rule.payload,
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
return wire
|
sonar_eval/llm.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""LLM abstraction for the user simulator and grader.
|
|
2
|
+
|
|
3
|
+
The harness stays provider-agnostic: it never imports a vendor SDK. A project supplies
|
|
4
|
+
one ``LLMClient`` and the harness drives both the user simulator and the grader through
|
|
5
|
+
it. Defaulting to the latest Claude models is a project decision, made in the client the
|
|
6
|
+
project wires in - not baked in here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Message:
|
|
17
|
+
role: str # "system" | "user" | "assistant"
|
|
18
|
+
content: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class LLMConfig:
|
|
23
|
+
model: str
|
|
24
|
+
temperature: float = 1.0
|
|
25
|
+
max_tokens: int = 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LLMClient(Protocol):
|
|
29
|
+
async def complete(self, config: LLMConfig, system: str, messages: list[Message]) -> str:
|
|
30
|
+
"""Return the assistant completion text for ``messages`` under ``system``."""
|