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.
Files changed (75) hide show
  1. evalkeep/__init__.py +12 -0
  2. evalkeep/__main__.py +6 -0
  3. evalkeep/adapters/__init__.py +45 -0
  4. evalkeep/adapters/base.py +92 -0
  5. evalkeep/adapters/jsonl.py +164 -0
  6. evalkeep/adapters/langsmith.py +436 -0
  7. evalkeep/adapters/otlp.py +442 -0
  8. evalkeep/adapters/semconv.py +208 -0
  9. evalkeep/analysis.py +174 -0
  10. evalkeep/analysis_run.py +160 -0
  11. evalkeep/analyzers/__init__.py +52 -0
  12. evalkeep/analyzers/anthropic.py +145 -0
  13. evalkeep/analyzers/stub.py +34 -0
  14. evalkeep/cache.py +122 -0
  15. evalkeep/cli.py +1933 -0
  16. evalkeep/clustering.py +383 -0
  17. evalkeep/clusters.py +101 -0
  18. evalkeep/commands/__init__.py +1 -0
  19. evalkeep/commands/analyze_cmd.py +100 -0
  20. evalkeep/commands/compare_cmd.py +169 -0
  21. evalkeep/commands/dataset_cmd.py +182 -0
  22. evalkeep/commands/detect_cmd.py +154 -0
  23. evalkeep/commands/discover_cmd.py +274 -0
  24. evalkeep/commands/ingest_cmd.py +50 -0
  25. evalkeep/commands/init_cmd.py +151 -0
  26. evalkeep/commands/pipeline_cmd.py +156 -0
  27. evalkeep/commands/review_cmd.py +141 -0
  28. evalkeep/commands/run_cmd.py +131 -0
  29. evalkeep/commands/target_cmd.py +109 -0
  30. evalkeep/commands/trace_cmd.py +58 -0
  31. evalkeep/comparison.py +432 -0
  32. evalkeep/config.py +209 -0
  33. evalkeep/detection.py +94 -0
  34. evalkeep/detectors.py +182 -0
  35. evalkeep/discovery.py +208 -0
  36. evalkeep/embeddings/__init__.py +31 -0
  37. evalkeep/embeddings/base.py +32 -0
  38. evalkeep/embeddings/hashing.py +98 -0
  39. evalkeep/errors.py +42 -0
  40. evalkeep/examples/__init__.py +37 -0
  41. evalkeep/examples/langsmith/runs.jsonl +18 -0
  42. evalkeep/examples/opentelemetry/spans.json +898 -0
  43. evalkeep/examples/refund-agent/agents/baseline.py +66 -0
  44. evalkeep/examples/refund-agent/agents/candidate.py +66 -0
  45. evalkeep/examples/refund-agent/traces.jsonl +5 -0
  46. evalkeep/examples/tau-bench/prepare.py +230 -0
  47. evalkeep/exporters/__init__.py +45 -0
  48. evalkeep/exporters/generic.py +31 -0
  49. evalkeep/exporters/promptfoo.py +219 -0
  50. evalkeep/failures.py +95 -0
  51. evalkeep/generation.py +303 -0
  52. evalkeep/hashing.py +56 -0
  53. evalkeep/ingest.py +257 -0
  54. evalkeep/prompts.py +127 -0
  55. evalkeep/pseudonyms.py +82 -0
  56. evalkeep/py.typed +0 -0
  57. evalkeep/redaction.py +333 -0
  58. evalkeep/regression.py +409 -0
  59. evalkeep/review.py +309 -0
  60. evalkeep/runner.py +302 -0
  61. evalkeep/runs.py +185 -0
  62. evalkeep/storage/__init__.py +37 -0
  63. evalkeep/storage/clusters.py +163 -0
  64. evalkeep/storage/failures.py +254 -0
  65. evalkeep/storage/migrations.py +370 -0
  66. evalkeep/storage/regression.py +136 -0
  67. evalkeep/storage/runs.py +223 -0
  68. evalkeep/storage/store.py +429 -0
  69. evalkeep/targets.py +205 -0
  70. evalkeep/trace.py +238 -0
  71. evalkeep-0.1.0.dist-info/METADATA +221 -0
  72. evalkeep-0.1.0.dist-info/RECORD +75 -0
  73. evalkeep-0.1.0.dist-info/WHEEL +4 -0
  74. evalkeep-0.1.0.dist-info/entry_points.txt +3 -0
  75. 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
+ ...
@@ -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()