evalkeep 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.
- evalkeep/__init__.py +12 -0
- evalkeep/__main__.py +6 -0
- evalkeep/adapters/__init__.py +45 -0
- evalkeep/adapters/base.py +92 -0
- evalkeep/adapters/jsonl.py +164 -0
- evalkeep/adapters/langsmith.py +436 -0
- evalkeep/adapters/otlp.py +442 -0
- evalkeep/adapters/semconv.py +208 -0
- evalkeep/analysis.py +174 -0
- evalkeep/analysis_run.py +160 -0
- evalkeep/analyzers/__init__.py +52 -0
- evalkeep/analyzers/anthropic.py +145 -0
- evalkeep/analyzers/stub.py +34 -0
- evalkeep/cache.py +122 -0
- evalkeep/cli.py +1933 -0
- evalkeep/clustering.py +383 -0
- evalkeep/clusters.py +101 -0
- evalkeep/commands/__init__.py +1 -0
- evalkeep/commands/analyze_cmd.py +100 -0
- evalkeep/commands/compare_cmd.py +169 -0
- evalkeep/commands/dataset_cmd.py +182 -0
- evalkeep/commands/detect_cmd.py +154 -0
- evalkeep/commands/discover_cmd.py +274 -0
- evalkeep/commands/ingest_cmd.py +50 -0
- evalkeep/commands/init_cmd.py +151 -0
- evalkeep/commands/pipeline_cmd.py +156 -0
- evalkeep/commands/review_cmd.py +141 -0
- evalkeep/commands/run_cmd.py +131 -0
- evalkeep/commands/target_cmd.py +109 -0
- evalkeep/commands/trace_cmd.py +58 -0
- evalkeep/comparison.py +432 -0
- evalkeep/config.py +209 -0
- evalkeep/detection.py +94 -0
- evalkeep/detectors.py +182 -0
- evalkeep/discovery.py +208 -0
- evalkeep/embeddings/__init__.py +31 -0
- evalkeep/embeddings/base.py +32 -0
- evalkeep/embeddings/hashing.py +98 -0
- evalkeep/errors.py +42 -0
- evalkeep/examples/__init__.py +37 -0
- evalkeep/examples/langsmith/runs.jsonl +18 -0
- evalkeep/examples/opentelemetry/spans.json +898 -0
- evalkeep/examples/refund-agent/agents/baseline.py +66 -0
- evalkeep/examples/refund-agent/agents/candidate.py +66 -0
- evalkeep/examples/refund-agent/traces.jsonl +5 -0
- evalkeep/examples/tau-bench/prepare.py +230 -0
- evalkeep/exporters/__init__.py +45 -0
- evalkeep/exporters/generic.py +31 -0
- evalkeep/exporters/promptfoo.py +219 -0
- evalkeep/failures.py +95 -0
- evalkeep/generation.py +303 -0
- evalkeep/hashing.py +56 -0
- evalkeep/ingest.py +257 -0
- evalkeep/prompts.py +127 -0
- evalkeep/pseudonyms.py +82 -0
- evalkeep/py.typed +0 -0
- evalkeep/redaction.py +333 -0
- evalkeep/regression.py +409 -0
- evalkeep/review.py +309 -0
- evalkeep/runner.py +302 -0
- evalkeep/runs.py +185 -0
- evalkeep/storage/__init__.py +37 -0
- evalkeep/storage/clusters.py +163 -0
- evalkeep/storage/failures.py +254 -0
- evalkeep/storage/migrations.py +370 -0
- evalkeep/storage/regression.py +136 -0
- evalkeep/storage/runs.py +223 -0
- evalkeep/storage/store.py +429 -0
- evalkeep/targets.py +205 -0
- evalkeep/trace.py +238 -0
- evalkeep-0.1.0.dist-info/METADATA +221 -0
- evalkeep-0.1.0.dist-info/RECORD +75 -0
- evalkeep-0.1.0.dist-info/WHEEL +4 -0
- evalkeep-0.1.0.dist-info/entry_points.txt +3 -0
- evalkeep-0.1.0.dist-info/licenses/LICENSE +202 -0
evalkeep/analysis.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Structured failure analysis and the provider interface behind it.
|
|
2
|
+
|
|
3
|
+
Detection says *that* a trace failed and points at the evidence. Analysis says
|
|
4
|
+
*what kind* of failure it is, so that similar failures can be grouped and a
|
|
5
|
+
representative can be chosen. That description is a judgement, so every analysis
|
|
6
|
+
records who made it -- a person, or a named model at a named prompt version --
|
|
7
|
+
and keeps the raw response for audit.
|
|
8
|
+
|
|
9
|
+
The vocabularies below are deliberately closed. Free-text labels do not cluster:
|
|
10
|
+
"wrong order id", "refunded the wrong order" and "bad tool arg" are one failure
|
|
11
|
+
family that three different analysts would name three different ways.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from typing import Any, ClassVar, Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
from evalkeep.detectors import Signal
|
|
22
|
+
from evalkeep.trace import NormalizedTrace
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FailureType(StrEnum):
|
|
26
|
+
"""What went wrong."""
|
|
27
|
+
|
|
28
|
+
WRONG_TOOL_ARGUMENT = "wrong_tool_argument"
|
|
29
|
+
WRONG_TOOL_SELECTION = "wrong_tool_selection"
|
|
30
|
+
MISSING_TOOL_CALL = "missing_tool_call"
|
|
31
|
+
UNNECESSARY_ACTION = "unnecessary_action"
|
|
32
|
+
INCORRECT_ANSWER = "incorrect_answer"
|
|
33
|
+
INCOMPLETE_ANSWER = "incomplete_answer"
|
|
34
|
+
UNSUPPORTED_CLAIM = "unsupported_claim"
|
|
35
|
+
FORMAT_VIOLATION = "format_violation"
|
|
36
|
+
POLICY_VIOLATION = "policy_violation"
|
|
37
|
+
UNWARRANTED_REFUSAL = "unwarranted_refusal"
|
|
38
|
+
EXECUTION_ERROR = "execution_error"
|
|
39
|
+
OTHER = "other"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Component(StrEnum):
|
|
43
|
+
"""Where in the agent it went wrong."""
|
|
44
|
+
|
|
45
|
+
PLANNING = "planning"
|
|
46
|
+
TOOL_SELECTION = "tool_selection"
|
|
47
|
+
TOOL_ARGUMENTS = "tool_arguments"
|
|
48
|
+
TOOL_EXECUTION = "tool_execution"
|
|
49
|
+
RETRIEVAL = "retrieval"
|
|
50
|
+
RESPONSE_GENERATION = "response_generation"
|
|
51
|
+
POLICY = "policy"
|
|
52
|
+
UNKNOWN = "unknown"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Severity(StrEnum):
|
|
56
|
+
LOW = "low"
|
|
57
|
+
MEDIUM = "medium"
|
|
58
|
+
HIGH = "high"
|
|
59
|
+
CRITICAL = "critical"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
#: Ordered worst-first, for selecting high-severity representatives in 8F.
|
|
63
|
+
SEVERITY_ORDER: tuple[Severity, ...] = (
|
|
64
|
+
Severity.CRITICAL,
|
|
65
|
+
Severity.HIGH,
|
|
66
|
+
Severity.MEDIUM,
|
|
67
|
+
Severity.LOW,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class ProviderAnalysis:
|
|
73
|
+
"""What a provider produced, before the pipeline stamps its provenance."""
|
|
74
|
+
|
|
75
|
+
failure_type: FailureType
|
|
76
|
+
component: Component
|
|
77
|
+
severity: Severity
|
|
78
|
+
summary: str
|
|
79
|
+
raw_response: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class FailureAnalysis:
|
|
84
|
+
"""A provider analysis plus the provenance that makes it auditable."""
|
|
85
|
+
|
|
86
|
+
failure_type: FailureType
|
|
87
|
+
component: Component
|
|
88
|
+
severity: Severity
|
|
89
|
+
summary: str
|
|
90
|
+
#: Who decided: ``manual:alex``, ``anthropic:claude-opus-5``, ``stub``.
|
|
91
|
+
analyzer: str
|
|
92
|
+
prompt_version: int
|
|
93
|
+
analyzed_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
94
|
+
#: Set when a person labelled by hand.
|
|
95
|
+
labeler: str | None = None
|
|
96
|
+
#: The provider's own words, redacted. Kept so a surprising label can be
|
|
97
|
+
#: checked against what the model actually said.
|
|
98
|
+
raw_response: str | None = None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def manual(self) -> bool:
|
|
102
|
+
return self.analyzer.startswith("manual")
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def severity_rank(self) -> int:
|
|
106
|
+
return SEVERITY_ORDER.index(self.severity)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def from_provider(
|
|
110
|
+
cls,
|
|
111
|
+
produced: ProviderAnalysis,
|
|
112
|
+
*,
|
|
113
|
+
analyzer: str,
|
|
114
|
+
prompt_version: int,
|
|
115
|
+
) -> FailureAnalysis:
|
|
116
|
+
return cls(
|
|
117
|
+
failure_type=produced.failure_type,
|
|
118
|
+
component=produced.component,
|
|
119
|
+
severity=produced.severity,
|
|
120
|
+
summary=produced.summary,
|
|
121
|
+
analyzer=analyzer,
|
|
122
|
+
prompt_version=prompt_version,
|
|
123
|
+
raw_response=produced.raw_response,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def to_dict(self) -> dict[str, Any]:
|
|
127
|
+
return {
|
|
128
|
+
"failure_type": self.failure_type.value,
|
|
129
|
+
"component": self.component.value,
|
|
130
|
+
"severity": self.severity.value,
|
|
131
|
+
"summary": self.summary,
|
|
132
|
+
"analyzer": self.analyzer,
|
|
133
|
+
"prompt_version": self.prompt_version,
|
|
134
|
+
"analyzed_at": self.analyzed_at.isoformat(),
|
|
135
|
+
"labeler": self.labeler,
|
|
136
|
+
"raw_response": self.raw_response,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_dict(cls, payload: dict[str, Any]) -> FailureAnalysis:
|
|
141
|
+
return cls(
|
|
142
|
+
failure_type=FailureType(payload["failure_type"]),
|
|
143
|
+
component=Component(payload["component"]),
|
|
144
|
+
severity=Severity(payload["severity"]),
|
|
145
|
+
summary=payload["summary"],
|
|
146
|
+
analyzer=payload["analyzer"],
|
|
147
|
+
prompt_version=int(payload["prompt_version"]),
|
|
148
|
+
analyzed_at=datetime.fromisoformat(payload["analyzed_at"]),
|
|
149
|
+
labeler=payload.get("labeler"),
|
|
150
|
+
raw_response=payload.get("raw_response"),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class AnalyzerError(Exception):
|
|
155
|
+
"""A provider could not analyze this failure. Never fatal to a run."""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@runtime_checkable
|
|
159
|
+
class AnalyzerProvider(Protocol):
|
|
160
|
+
"""Provider-independent analysis.
|
|
161
|
+
|
|
162
|
+
``identity`` is part of the cache key, so it must change whenever the thing
|
|
163
|
+
producing the answer changes -- a different model is a different analyst.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
name: ClassVar[str]
|
|
167
|
+
description: ClassVar[str]
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def identity(self) -> str: ...
|
|
171
|
+
|
|
172
|
+
def analyze_failure(self, trace: NormalizedTrace, signals: list[Signal]) -> ProviderAnalysis:
|
|
173
|
+
"""Describe one failure. Raises :class:`AnalyzerError` on failure."""
|
|
174
|
+
...
|
evalkeep/analysis_run.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The analysis pass: describe failures, cached, without ever losing a run.
|
|
2
|
+
|
|
3
|
+
Two properties matter more than speed here:
|
|
4
|
+
|
|
5
|
+
* **A provider failure is never fatal.** One trace the model chokes on must not
|
|
6
|
+
abandon the other four hundred. Failures are counted and reported; the run
|
|
7
|
+
continues.
|
|
8
|
+
* **The provider's own words are kept, redacted.** The model only ever sees a
|
|
9
|
+
redacted trace, but its response is redacted again before storage -- a model
|
|
10
|
+
can quote its input, and "it only saw redacted text" is an argument, not a
|
|
11
|
+
guarantee.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
|
|
19
|
+
from evalkeep.analysis import AnalyzerError, AnalyzerProvider, FailureAnalysis
|
|
20
|
+
from evalkeep.cache import AnalysisCache, cache_key
|
|
21
|
+
from evalkeep.failures import FailureStatus
|
|
22
|
+
from evalkeep.hashing import content_hash
|
|
23
|
+
from evalkeep.prompts import FAILURE_ANALYSIS_PROMPT_VERSION
|
|
24
|
+
from evalkeep.redaction import RedactionSummary, Redactor
|
|
25
|
+
from evalkeep.storage import TraceStore
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AnalysisReport:
|
|
30
|
+
"""What one analysis pass did."""
|
|
31
|
+
|
|
32
|
+
analyzer: str
|
|
33
|
+
prompt_version: int
|
|
34
|
+
considered: int = 0
|
|
35
|
+
analyzed: int = 0
|
|
36
|
+
from_cache: int = 0
|
|
37
|
+
skipped: int = 0
|
|
38
|
+
manual_kept: int = 0
|
|
39
|
+
failed: int = 0
|
|
40
|
+
redactions: int = 0
|
|
41
|
+
errors: list[tuple[str, str]] = field(default_factory=list)
|
|
42
|
+
by_type: dict[str, int] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def changed(self) -> bool:
|
|
46
|
+
return bool(self.analyzed)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
#: Analysis describes failures worth keeping; a dismissed one is not.
|
|
50
|
+
ANALYZABLE = (FailureStatus.CANDIDATE, FailureStatus.CONFIRMED)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def analyze_failures(
|
|
54
|
+
store: TraceStore,
|
|
55
|
+
provider: AnalyzerProvider,
|
|
56
|
+
cache: AnalysisCache,
|
|
57
|
+
*,
|
|
58
|
+
redactor: Redactor | None = None,
|
|
59
|
+
reanalyze: bool = False,
|
|
60
|
+
overwrite_manual: bool = False,
|
|
61
|
+
limit: int | None = None,
|
|
62
|
+
) -> AnalysisReport:
|
|
63
|
+
"""Analyze every failure that needs it, reusing cached answers."""
|
|
64
|
+
redactor = redactor or Redactor()
|
|
65
|
+
report = AnalysisReport(
|
|
66
|
+
analyzer=provider.identity, prompt_version=FAILURE_ANALYSIS_PROMPT_VERSION
|
|
67
|
+
)
|
|
68
|
+
failures = store.failures
|
|
69
|
+
|
|
70
|
+
for failure in failures.iter_all():
|
|
71
|
+
if failure.status not in ANALYZABLE:
|
|
72
|
+
continue
|
|
73
|
+
if limit is not None and report.analyzed + report.from_cache >= limit:
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
report.considered += 1
|
|
77
|
+
existing = failures.get_analysis(failure.failure_id)
|
|
78
|
+
if existing is not None and not _needs_analysis(
|
|
79
|
+
existing, provider, reanalyze=reanalyze, overwrite_manual=overwrite_manual
|
|
80
|
+
):
|
|
81
|
+
report.skipped += 1
|
|
82
|
+
if existing.manual:
|
|
83
|
+
report.manual_kept += 1
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
stored = store.get(failure.trace_id)
|
|
87
|
+
if stored is None: # pragma: no cover - the foreign key prevents this
|
|
88
|
+
report.failed += 1
|
|
89
|
+
report.errors.append((failure.failure_id, "trace is missing from the store"))
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
key = cache_key(
|
|
93
|
+
content_hash(stored.trace), provider.identity, FAILURE_ANALYSIS_PROMPT_VERSION
|
|
94
|
+
)
|
|
95
|
+
cached = cache.get(key)
|
|
96
|
+
if cached is not None and not reanalyze:
|
|
97
|
+
analysis = FailureAnalysis.from_dict(cached)
|
|
98
|
+
analysis.analyzed_at = datetime.now(UTC)
|
|
99
|
+
failures.save_analysis(failure.failure_id, analysis)
|
|
100
|
+
report.from_cache += 1
|
|
101
|
+
_count_type(report, analysis)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
produced = provider.analyze_failure(stored.trace, failure.signals)
|
|
106
|
+
except AnalyzerError as exc:
|
|
107
|
+
report.failed += 1
|
|
108
|
+
report.errors.append((failure.failure_id, str(exc)))
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
analysis = FailureAnalysis.from_provider(
|
|
112
|
+
produced,
|
|
113
|
+
analyzer=provider.identity,
|
|
114
|
+
prompt_version=FAILURE_ANALYSIS_PROMPT_VERSION,
|
|
115
|
+
)
|
|
116
|
+
report.redactions += _redact_in_place(analysis, redactor)
|
|
117
|
+
|
|
118
|
+
failures.save_analysis(failure.failure_id, analysis)
|
|
119
|
+
cache.put(key, analysis.to_dict())
|
|
120
|
+
report.analyzed += 1
|
|
121
|
+
_count_type(report, analysis)
|
|
122
|
+
|
|
123
|
+
return report
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _needs_analysis(
|
|
127
|
+
existing: FailureAnalysis,
|
|
128
|
+
provider: AnalyzerProvider,
|
|
129
|
+
*,
|
|
130
|
+
reanalyze: bool,
|
|
131
|
+
overwrite_manual: bool,
|
|
132
|
+
) -> bool:
|
|
133
|
+
"""Re-analyze when forced, or when the analysis is stale -- never over a label.
|
|
134
|
+
|
|
135
|
+
``--reanalyze`` refreshes *machine* analyses. Replacing something a person
|
|
136
|
+
wrote takes its own flag: refreshing model output after a prompt change is
|
|
137
|
+
routine, and it must not quietly discard hand-written labels along the way.
|
|
138
|
+
"""
|
|
139
|
+
if existing.manual:
|
|
140
|
+
return overwrite_manual
|
|
141
|
+
if reanalyze:
|
|
142
|
+
return True
|
|
143
|
+
return (
|
|
144
|
+
existing.analyzer != provider.identity
|
|
145
|
+
or existing.prompt_version != FAILURE_ANALYSIS_PROMPT_VERSION
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _redact_in_place(analysis: FailureAnalysis, redactor: Redactor) -> int:
|
|
150
|
+
"""Redact what the provider wrote, before it is stored anywhere."""
|
|
151
|
+
summary = RedactionSummary()
|
|
152
|
+
analysis.summary = redactor.redact_text(analysis.summary, summary)
|
|
153
|
+
if analysis.raw_response is not None:
|
|
154
|
+
analysis.raw_response = redactor.redact_text(analysis.raw_response, summary)
|
|
155
|
+
return summary.total
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _count_type(report: AnalysisReport, analysis: FailureAnalysis) -> None:
|
|
159
|
+
key = analysis.failure_type.value
|
|
160
|
+
report.by_type[key] = report.by_type.get(key, 0) + 1
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Analyzer providers and the registry the project configuration resolves against.
|
|
2
|
+
|
|
3
|
+
``manual`` is the default and is deliberately *not* a provider: it means nobody
|
|
4
|
+
analyzes automatically, and failures are labelled by hand. Resolving it returns
|
|
5
|
+
``None`` so callers must handle the offline case explicitly rather than silently
|
|
6
|
+
falling back to a machine label.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from evalkeep.analysis import AnalyzerError, AnalyzerProvider
|
|
12
|
+
from evalkeep.analyzers.stub import StubAnalyzer
|
|
13
|
+
from evalkeep.config import AnalyzerConfig
|
|
14
|
+
from evalkeep.errors import CommandError
|
|
15
|
+
|
|
16
|
+
MANUAL_PROVIDER = "manual"
|
|
17
|
+
|
|
18
|
+
KNOWN_PROVIDERS: dict[str, str] = {
|
|
19
|
+
MANUAL_PROVIDER: "No automatic analysis; label failures by hand",
|
|
20
|
+
"anthropic": "Claude via the Anthropic Messages API (needs an API key)",
|
|
21
|
+
StubAnalyzer.name: StubAnalyzer.description,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_analyzer(config: AnalyzerConfig) -> AnalyzerProvider | None:
|
|
26
|
+
"""Build the configured provider, or ``None`` for manual labelling."""
|
|
27
|
+
if config.provider == MANUAL_PROVIDER:
|
|
28
|
+
return None
|
|
29
|
+
if config.provider == StubAnalyzer.name:
|
|
30
|
+
return StubAnalyzer()
|
|
31
|
+
if config.provider == "anthropic":
|
|
32
|
+
# Imported lazily: the SDK is an optional dependency.
|
|
33
|
+
from evalkeep.analyzers.anthropic import AnthropicAnalyzer
|
|
34
|
+
|
|
35
|
+
return AnthropicAnalyzer(
|
|
36
|
+
model=config.model, effort=config.effort, max_tokens=config.max_tokens
|
|
37
|
+
)
|
|
38
|
+
known = ", ".join(sorted(KNOWN_PROVIDERS))
|
|
39
|
+
raise CommandError(
|
|
40
|
+
f"Unknown analyzer provider {config.provider!r}.",
|
|
41
|
+
hint=f"Known providers: {known}.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"KNOWN_PROVIDERS",
|
|
47
|
+
"MANUAL_PROVIDER",
|
|
48
|
+
"AnalyzerError",
|
|
49
|
+
"AnalyzerProvider",
|
|
50
|
+
"StubAnalyzer",
|
|
51
|
+
"get_analyzer",
|
|
52
|
+
]
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The Anthropic analyzer.
|
|
2
|
+
|
|
3
|
+
Uses the Messages API with structured outputs, so the model is constrained to
|
|
4
|
+
the JSON schema in :mod:`evalkeep.prompts` rather than asked politely for JSON
|
|
5
|
+
and parsed hopefully. The model only ever sees a redacted trace, and the
|
|
6
|
+
pipeline redacts the response again before storing it.
|
|
7
|
+
|
|
8
|
+
The ``anthropic`` package is an optional dependency: Evalkeep runs entirely
|
|
9
|
+
offline with manual labelling, and nothing in the core imports this module
|
|
10
|
+
unless the project configures ``provider: anthropic``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from typing import Any, ClassVar
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ValidationError
|
|
19
|
+
|
|
20
|
+
from evalkeep.analysis import (
|
|
21
|
+
AnalyzerError,
|
|
22
|
+
Component,
|
|
23
|
+
FailureType,
|
|
24
|
+
ProviderAnalysis,
|
|
25
|
+
Severity,
|
|
26
|
+
)
|
|
27
|
+
from evalkeep.detectors import Signal
|
|
28
|
+
from evalkeep.prompts import (
|
|
29
|
+
FAILURE_ANALYSIS_SCHEMA,
|
|
30
|
+
FAILURE_ANALYSIS_SYSTEM,
|
|
31
|
+
failure_analysis_prompt,
|
|
32
|
+
)
|
|
33
|
+
from evalkeep.trace import NormalizedTrace
|
|
34
|
+
|
|
35
|
+
DEFAULT_MODEL = "claude-opus-5"
|
|
36
|
+
DEFAULT_MAX_TOKENS = 16000
|
|
37
|
+
DEFAULT_EFFORT = "medium"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _AnalysisResponse(BaseModel):
|
|
41
|
+
"""The schema the model is constrained to, validated again on arrival."""
|
|
42
|
+
|
|
43
|
+
failure_type: FailureType
|
|
44
|
+
component: Component
|
|
45
|
+
severity: Severity
|
|
46
|
+
summary: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AnthropicAnalyzer:
|
|
50
|
+
name: ClassVar[str] = "anthropic"
|
|
51
|
+
description: ClassVar[str] = "Claude via the Anthropic Messages API (needs an API key)"
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
model: str = DEFAULT_MODEL,
|
|
57
|
+
effort: str = DEFAULT_EFFORT,
|
|
58
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
59
|
+
client: Any = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.model = model
|
|
62
|
+
self.effort = effort
|
|
63
|
+
self.max_tokens = max_tokens
|
|
64
|
+
self._client = client
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def identity(self) -> str:
|
|
68
|
+
"""A different model is a different analyst, so it keys the cache."""
|
|
69
|
+
return f"{self.name}:{self.model}"
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def client(self) -> Any:
|
|
73
|
+
if self._client is None:
|
|
74
|
+
self._client = _build_client()
|
|
75
|
+
return self._client
|
|
76
|
+
|
|
77
|
+
def analyze_failure(self, trace: NormalizedTrace, signals: list[Signal]) -> ProviderAnalysis:
|
|
78
|
+
try:
|
|
79
|
+
response = self.client.messages.create(
|
|
80
|
+
model=self.model,
|
|
81
|
+
max_tokens=self.max_tokens,
|
|
82
|
+
system=FAILURE_ANALYSIS_SYSTEM,
|
|
83
|
+
thinking={"type": "adaptive"},
|
|
84
|
+
output_config={
|
|
85
|
+
"effort": self.effort,
|
|
86
|
+
"format": {"type": "json_schema", "schema": FAILURE_ANALYSIS_SCHEMA},
|
|
87
|
+
},
|
|
88
|
+
messages=[{"role": "user", "content": failure_analysis_prompt(trace, signals)}],
|
|
89
|
+
)
|
|
90
|
+
except Exception as exc: # SDK exception classes are not importable here
|
|
91
|
+
raise AnalyzerError(f"{self.identity} request failed: {exc}") from exc
|
|
92
|
+
|
|
93
|
+
return _parse(response, self.identity)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse(response: Any, identity: str) -> ProviderAnalysis:
|
|
97
|
+
stop_reason = getattr(response, "stop_reason", None)
|
|
98
|
+
if stop_reason == "refusal":
|
|
99
|
+
raise AnalyzerError(f"{identity} declined to analyze this trace")
|
|
100
|
+
if stop_reason == "max_tokens":
|
|
101
|
+
raise AnalyzerError(f"{identity} hit max_tokens before finishing")
|
|
102
|
+
|
|
103
|
+
text = next(
|
|
104
|
+
(block.text for block in response.content if getattr(block, "type", None) == "text"),
|
|
105
|
+
None,
|
|
106
|
+
)
|
|
107
|
+
if not text:
|
|
108
|
+
raise AnalyzerError(f"{identity} returned no text content")
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
payload = json.loads(text)
|
|
112
|
+
except json.JSONDecodeError as exc:
|
|
113
|
+
raise AnalyzerError(f"{identity} returned text that is not JSON: {exc}") from exc
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
parsed = _AnalysisResponse.model_validate(payload)
|
|
117
|
+
except ValidationError as exc:
|
|
118
|
+
raise AnalyzerError(
|
|
119
|
+
f"{identity} returned JSON that does not match the schema:\n{exc}"
|
|
120
|
+
) from exc
|
|
121
|
+
|
|
122
|
+
return ProviderAnalysis(
|
|
123
|
+
failure_type=parsed.failure_type,
|
|
124
|
+
component=parsed.component,
|
|
125
|
+
severity=parsed.severity,
|
|
126
|
+
summary=parsed.summary.strip(),
|
|
127
|
+
raw_response=text,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _build_client() -> Any:
|
|
132
|
+
try:
|
|
133
|
+
import anthropic
|
|
134
|
+
except ImportError as exc:
|
|
135
|
+
raise AnalyzerError(
|
|
136
|
+
"The 'anthropic' package is not installed. "
|
|
137
|
+
"Install it with: uv add 'evalkeep[anthropic]'"
|
|
138
|
+
) from exc
|
|
139
|
+
try:
|
|
140
|
+
return anthropic.Anthropic()
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
raise AnalyzerError(
|
|
143
|
+
f"Could not create an Anthropic client: {exc}. "
|
|
144
|
+
"Set ANTHROPIC_API_KEY, or run 'ant auth login'."
|
|
145
|
+
) from exc
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""A deterministic analyzer for development, with no network and no key.
|
|
2
|
+
|
|
3
|
+
It exists so the analysis pipeline -- caching, storage, reporting -- can be
|
|
4
|
+
exercised end to end offline. It is not analysis: it reads the detector evidence
|
|
5
|
+
back to you and labels everything ``other``/``unknown``. Its output is stamped
|
|
6
|
+
``stub`` so nothing downstream can mistake it for a real judgement.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import ClassVar
|
|
12
|
+
|
|
13
|
+
from evalkeep.analysis import Component, FailureType, ProviderAnalysis, Severity
|
|
14
|
+
from evalkeep.detectors import Signal
|
|
15
|
+
from evalkeep.trace import NormalizedTrace
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StubAnalyzer:
|
|
19
|
+
name: ClassVar[str] = "stub"
|
|
20
|
+
description: ClassVar[str] = "Deterministic placeholder for offline development"
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def identity(self) -> str:
|
|
24
|
+
return self.name
|
|
25
|
+
|
|
26
|
+
def analyze_failure(self, trace: NormalizedTrace, signals: list[Signal]) -> ProviderAnalysis:
|
|
27
|
+
summary = "; ".join(signal.summary for signal in signals) or "no evidence recorded"
|
|
28
|
+
return ProviderAnalysis(
|
|
29
|
+
failure_type=FailureType.OTHER,
|
|
30
|
+
component=Component.UNKNOWN,
|
|
31
|
+
severity=Severity.MEDIUM,
|
|
32
|
+
summary=f"[stub] {summary}",
|
|
33
|
+
raw_response=None,
|
|
34
|
+
)
|
evalkeep/cache.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""On-disk cache for analyzer answers.
|
|
2
|
+
|
|
3
|
+
Keyed by the three things that can change the answer: the *content* of the trace
|
|
4
|
+
(already redacted), which analyst produced it, and which prompt version was
|
|
5
|
+
asked. Change any one and the key changes, so a prompt edit or a model swap
|
|
6
|
+
never serves a stale label -- and re-running analysis after a database reset
|
|
7
|
+
costs nothing.
|
|
8
|
+
|
|
9
|
+
The cache lives under ``.evalkeep/cache/``, which is not committed. It is a
|
|
10
|
+
speed and money optimisation, never a source of truth: deleting it loses
|
|
11
|
+
nothing that the database does not already hold.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
ANALYSIS_DIRNAME = "analysis"
|
|
23
|
+
EMBEDDING_DIRNAME = "embeddings"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cache_key(content_hash: str, analyzer_identity: str, prompt_version: int) -> str:
|
|
27
|
+
"""A stable key over the three inputs that determine an answer."""
|
|
28
|
+
material = "\n".join([content_hash, analyzer_identity, str(prompt_version)])
|
|
29
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CacheStats:
|
|
34
|
+
hits: int = 0
|
|
35
|
+
misses: int = 0
|
|
36
|
+
writes: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class JsonFileCache:
|
|
40
|
+
"""A content-addressed JSON file cache. Corrupt entries are treated as misses."""
|
|
41
|
+
|
|
42
|
+
dirname: str = "cache"
|
|
43
|
+
|
|
44
|
+
def __init__(self, root: Path, *, enabled: bool = True) -> None:
|
|
45
|
+
self.root = root / self.dirname
|
|
46
|
+
self.enabled = enabled
|
|
47
|
+
self.stats = CacheStats()
|
|
48
|
+
|
|
49
|
+
def path_for(self, key: str) -> Path:
|
|
50
|
+
# Sharded by the first two characters so one directory never holds
|
|
51
|
+
# a hundred thousand files.
|
|
52
|
+
return self.root / key[:2] / f"{key}.json"
|
|
53
|
+
|
|
54
|
+
def get(self, key: str) -> dict[str, Any] | None:
|
|
55
|
+
if not self.enabled:
|
|
56
|
+
return None
|
|
57
|
+
path = self.path_for(key)
|
|
58
|
+
try:
|
|
59
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
60
|
+
except (OSError, json.JSONDecodeError):
|
|
61
|
+
self.stats.misses += 1
|
|
62
|
+
return None
|
|
63
|
+
if not isinstance(payload, dict):
|
|
64
|
+
self.stats.misses += 1
|
|
65
|
+
return None
|
|
66
|
+
self.stats.hits += 1
|
|
67
|
+
return payload
|
|
68
|
+
|
|
69
|
+
def put(self, key: str, payload: dict[str, Any]) -> None:
|
|
70
|
+
if not self.enabled:
|
|
71
|
+
return
|
|
72
|
+
path = self.path_for(key)
|
|
73
|
+
try:
|
|
74
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
# Write-then-rename so a crash cannot leave a half-written entry
|
|
76
|
+
# that would later be read back as a valid answer.
|
|
77
|
+
temporary = path.with_suffix(".tmp")
|
|
78
|
+
temporary.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
|
|
79
|
+
temporary.replace(path)
|
|
80
|
+
except OSError:
|
|
81
|
+
# A cache is an optimisation; failing to write one is not an error.
|
|
82
|
+
return
|
|
83
|
+
self.stats.writes += 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AnalysisCache(JsonFileCache):
|
|
87
|
+
"""Cached analyzer answers, keyed by trace content, analyst and prompt version."""
|
|
88
|
+
|
|
89
|
+
dirname = ANALYSIS_DIRNAME
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class EmbeddingCache(JsonFileCache):
|
|
93
|
+
"""Cached vectors, keyed by the embedded text and the embedding space.
|
|
94
|
+
|
|
95
|
+
Embedding the same failure description twice is wasted work with a local
|
|
96
|
+
provider and wasted money with a hosted one, and re-running ``discover``
|
|
97
|
+
after editing one cluster should not re-embed everything.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
dirname = EMBEDDING_DIRNAME
|
|
101
|
+
|
|
102
|
+
def get_vector(self, key: str) -> list[float] | None:
|
|
103
|
+
payload = self.get(key)
|
|
104
|
+
if payload is None:
|
|
105
|
+
return None
|
|
106
|
+
vector = payload.get("vector")
|
|
107
|
+
if not isinstance(vector, list) or not all(
|
|
108
|
+
isinstance(value, int | float) for value in vector
|
|
109
|
+
):
|
|
110
|
+
self.stats.hits -= 1
|
|
111
|
+
self.stats.misses += 1
|
|
112
|
+
return None
|
|
113
|
+
return [float(value) for value in vector]
|
|
114
|
+
|
|
115
|
+
def put_vector(self, key: str, vector: list[float]) -> None:
|
|
116
|
+
self.put(key, {"vector": vector})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def embedding_key(text: str, embedder_identity: str) -> str:
|
|
120
|
+
"""A key over the exact text embedded and the space it was embedded into."""
|
|
121
|
+
material = "\n".join([embedder_identity, text])
|
|
122
|
+
return hashlib.sha256(material.encode("utf-8")).hexdigest()
|