switch-trust-cli 1.7.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 (157) hide show
  1. switch_trust/__init__.py +0 -0
  2. switch_trust/cli/__init__.py +0 -0
  3. switch_trust/cli/__main__.py +3 -0
  4. switch_trust/cli/builtin_config.json +879 -0
  5. switch_trust/cli/console.py +127 -0
  6. switch_trust/cli/eval_cli.py +905 -0
  7. switch_trust/cli/file_filter.py +172 -0
  8. switch_trust/cli/init_cli.py +156 -0
  9. switch_trust/cli/main.py +384 -0
  10. switch_trust/cli/output_formatters.py +482 -0
  11. switch_trust/cli/rich_observer.py +89 -0
  12. switch_trust/cli/runner.py +274 -0
  13. switch_trust/cli/scan_cli.py +314 -0
  14. switch_trust/cli/telemetry.py +430 -0
  15. switch_trust/cli/utils.py +158 -0
  16. switch_trust/cli/version.py +1 -0
  17. switch_trust/data/detector_prompts/llm01_adversarial.txt +59 -0
  18. switch_trust/data/detector_prompts/llm01_fixed.txt +6 -0
  19. switch_trust/data/detector_prompts/llm02_adversarial.txt +79 -0
  20. switch_trust/data/detector_prompts/llm02_fixed.txt +6 -0
  21. switch_trust/data/detector_prompts/llm05_adversarial.txt +67 -0
  22. switch_trust/data/detector_prompts/llm05_fixed.txt +14 -0
  23. switch_trust/data/detector_prompts/llm06_adversarial.txt +139 -0
  24. switch_trust/data/detector_prompts/llm06_fixed.txt +13 -0
  25. switch_trust/data/detector_prompts/llm07_adversarial.txt +189 -0
  26. switch_trust/data/detector_prompts/llm07_fixed.txt +12 -0
  27. switch_trust/data/detector_prompts/llm09_adversarial.txt +83 -0
  28. switch_trust/data/detector_prompts/llm09_fixed.txt +13 -0
  29. switch_trust/data/llm01_prompt_injection.csv +6708 -0
  30. switch_trust/data/llm02_sensitive_information.csv +1069 -0
  31. switch_trust/data/llm05_unsafe_output.csv +3882 -0
  32. switch_trust/data/llm06_excessive_agency.csv +778 -0
  33. switch_trust/data/llm07_system_prompt_leakage.csv +971 -0
  34. switch_trust/data/llm09_hallucination-goals.csv +32419 -0
  35. switch_trust/data/llm09_hallucination.csv +599 -0
  36. switch_trust/data/pii_leakage.csv +706 -0
  37. switch_trust/data/secret_leakage.csv +1380 -0
  38. switch_trust/eval/__init__.py +0 -0
  39. switch_trust/eval/common/__init__.py +0 -0
  40. switch_trust/eval/common/converter_anthropic.py +172 -0
  41. switch_trust/eval/common/converter_genai.py +171 -0
  42. switch_trust/eval/common/converter_openai.py +198 -0
  43. switch_trust/eval/common/log.py +116 -0
  44. switch_trust/eval/common/reference.py +25 -0
  45. switch_trust/eval/common/schema.py +190 -0
  46. switch_trust/eval/common/utils.py +119 -0
  47. switch_trust/eval/core/__init__.py +0 -0
  48. switch_trust/eval/core/detectors/__init__.py +0 -0
  49. switch_trust/eval/core/detectors/detector.py +25 -0
  50. switch_trust/eval/core/detectors/detector_garak.py +112 -0
  51. switch_trust/eval/core/detectors/detector_model.py +123 -0
  52. switch_trust/eval/core/detectors/detector_model_adversarial.py +91 -0
  53. switch_trust/eval/core/detectors/detector_pii.py +67 -0
  54. switch_trust/eval/core/detectors/detector_secret.py +88 -0
  55. switch_trust/eval/core/detectors/detector_topic_guard.py +63 -0
  56. switch_trust/eval/core/detectors/detector_toxicity.py +78 -0
  57. switch_trust/eval/core/eval/__init__.py +0 -0
  58. switch_trust/eval/core/eval/eval_creator.py +166 -0
  59. switch_trust/eval/core/eval/evaluation.py +112 -0
  60. switch_trust/eval/core/eval/evaluation_adversarial.py +412 -0
  61. switch_trust/eval/core/eval/evaluation_garak_module.py +45 -0
  62. switch_trust/eval/core/eval/evaluation_garak_probe.py +308 -0
  63. switch_trust/eval/core/eval/evaluation_message_list.py +46 -0
  64. switch_trust/eval/core/eval/evaluation_multi.py +276 -0
  65. switch_trust/eval/core/eval/evaluation_single.py +89 -0
  66. switch_trust/eval/core/eval/evaluation_single_prompt.py +62 -0
  67. switch_trust/eval/core/eval/evaluation_topic_guard.py +305 -0
  68. switch_trust/eval/core/eval/metric_conciseness.py +179 -0
  69. switch_trust/eval/core/eval/metric_factual_accuracy.py +312 -0
  70. switch_trust/eval/core/eval/metric_instruction_adherence.py +177 -0
  71. switch_trust/eval/core/eval/metric_tone.py +180 -0
  72. switch_trust/eval/core/eval/metric_toxicity.py +146 -0
  73. switch_trust/eval/core/eval/observer.py +137 -0
  74. switch_trust/eval/core/message/__init__.py +0 -0
  75. switch_trust/eval/core/message/message_collection.py +37 -0
  76. switch_trust/eval/core/message/message_collection_csv.py +44 -0
  77. switch_trust/eval/core/message/message_collection_garak.py +46 -0
  78. switch_trust/eval/core/message/message_collection_memory.py +34 -0
  79. switch_trust/eval/core/models/__init__.py +0 -0
  80. switch_trust/eval/core/models/generator_model.py +421 -0
  81. switch_trust/eval/core/models/model.py +172 -0
  82. switch_trust/eval/core/models/model_adk.py +186 -0
  83. switch_trust/eval/core/models/model_anthropic.py +79 -0
  84. switch_trust/eval/core/models/model_anthropic_agent.py +104 -0
  85. switch_trust/eval/core/models/model_gemini.py +146 -0
  86. switch_trust/eval/core/models/model_generic_http.py +120 -0
  87. switch_trust/eval/core/models/model_http.py +135 -0
  88. switch_trust/eval/core/models/model_huggingface.py +75 -0
  89. switch_trust/eval/core/models/model_langserve.py +96 -0
  90. switch_trust/eval/core/models/model_litellm.py +52 -0
  91. switch_trust/eval/core/models/model_ollama.py +65 -0
  92. switch_trust/eval/core/models/model_openai.py +71 -0
  93. switch_trust/eval/core/models/model_openai_agent.py +92 -0
  94. switch_trust/eval/core/models/model_openai_compatible.py +66 -0
  95. switch_trust/eval/core/models/model_per_endpoint_limiter.py +80 -0
  96. switch_trust/eval/core/models/model_retry.py +204 -0
  97. switch_trust/eval/core/models/model_sync_wrapper.py +38 -0
  98. switch_trust/eval/core/models/model_vertex_agent_runtime.py +315 -0
  99. switch_trust/eval/core/models/response_schema.py +148 -0
  100. switch_trust/eval/core/optional_deps.py +57 -0
  101. switch_trust/eval/db/__init__.py +0 -0
  102. switch_trust/eval/db/base/__init__.py +0 -0
  103. switch_trust/eval/db/base/common_types.py +16 -0
  104. switch_trust/eval/db/base/detectors/__init__.py +0 -0
  105. switch_trust/eval/db/base/detectors/detector_helpers.py +88 -0
  106. switch_trust/eval/db/base/detectors/detector_repository.py +96 -0
  107. switch_trust/eval/db/base/detectors/detector_types.py +77 -0
  108. switch_trust/eval/db/base/eval/__init__.py +0 -0
  109. switch_trust/eval/db/base/eval/eval_helpers.py +186 -0
  110. switch_trust/eval/db/base/eval/eval_repository.py +120 -0
  111. switch_trust/eval/db/base/eval/eval_run.py +142 -0
  112. switch_trust/eval/db/base/eval/eval_types.py +127 -0
  113. switch_trust/eval/db/base/eval/model_eval_repository.py +79 -0
  114. switch_trust/eval/db/base/eval/model_eval_run_repository.py +72 -0
  115. switch_trust/eval/db/base/eval/model_eval_run_result_repository.py +42 -0
  116. switch_trust/eval/db/base/eval/model_eval_run_result_types.py +21 -0
  117. switch_trust/eval/db/base/eval/model_eval_run_types.py +78 -0
  118. switch_trust/eval/db/base/eval/model_eval_types.py +69 -0
  119. switch_trust/eval/db/base/message/__init__.py +0 -0
  120. switch_trust/eval/db/base/message/message_collection_helpers.py +50 -0
  121. switch_trust/eval/db/base/message/message_collection_repository.py +95 -0
  122. switch_trust/eval/db/base/message/message_collection_types.py +87 -0
  123. switch_trust/eval/db/base/models/__init__.py +0 -0
  124. switch_trust/eval/db/base/models/model_helpers.py +253 -0
  125. switch_trust/eval/db/base/models/model_repository.py +104 -0
  126. switch_trust/eval/db/base/models/model_types.py +159 -0
  127. switch_trust/eval/db/json/__init__.py +0 -0
  128. switch_trust/eval/db/json/repository_json.py +559 -0
  129. switch_trust/scan/__init__.py +12 -0
  130. switch_trust/scan/agent_scanner.py +812 -0
  131. switch_trust/scan/config/agent_opengrep_rules.yaml +533 -0
  132. switch_trust/scan/config/agent_taxonomy.json +517 -0
  133. switch_trust/scan/config/agentic_cvss_mapping.yaml +136 -0
  134. switch_trust/scan/config/compliance_mappings.json +110 -0
  135. switch_trust/scan/config/triage_prompt.txt +326 -0
  136. switch_trust/scan/constants.py +15 -0
  137. switch_trust/scan/exception_classification.py +179 -0
  138. switch_trust/scan/llm_provider.py +330 -0
  139. switch_trust/scan/opengrep_resolver.py +12 -0
  140. switch_trust/scan/reasoner.py +602 -0
  141. switch_trust/scan/schema.py +315 -0
  142. switch_trust/scan/scorer.py +372 -0
  143. switch_trust/scan/static_scanner.py +681 -0
  144. switch_trust/scan/taxonomy.py +121 -0
  145. switch_trust/scan/tool_dispatcher.py +773 -0
  146. switch_trust/scan/trace_logger.py +118 -0
  147. switch_trust/scan/trace_logger_file.py +143 -0
  148. switch_trust/scan/trace_logger_log.py +100 -0
  149. switch_trust/scan/triage.py +614 -0
  150. switch_trust/schema.py +79 -0
  151. switch_trust/secret_anonymizer.py +72 -0
  152. switch_trust_cli-1.7.0.dist-info/METADATA +495 -0
  153. switch_trust_cli-1.7.0.dist-info/RECORD +157 -0
  154. switch_trust_cli-1.7.0.dist-info/WHEEL +5 -0
  155. switch_trust_cli-1.7.0.dist-info/entry_points.txt +2 -0
  156. switch_trust_cli-1.7.0.dist-info/licenses/LICENSE +210 -0
  157. switch_trust_cli-1.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,159 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ from enum import Enum
4
+
5
+ from dataclasses_json import dataclass_json
6
+
7
+ from switch_trust.eval.common.reference import Reference, ReferenceType
8
+ from switch_trust.eval.common.utils import datetime_config, generate_id, now_utc
9
+ from switch_trust.eval.db.base.common_types import SortDirection
10
+ from switch_trust.eval.db.base.eval.model_eval_run_types import DbModelEvaluationRun
11
+
12
+
13
+ class ModelType(str, Enum):
14
+ """Discriminator for the kind of Model."""
15
+
16
+ ANTHROPIC = "anthropic"
17
+ OPENAI = "openai"
18
+ GEMINI = "gemini"
19
+ LITELLM = "litellm"
20
+ HUGGINGFACE = "huggingface"
21
+ OLLAMA = "ollama"
22
+ ADK = "adk"
23
+ OPENAI_AGENT = "openai_agent"
24
+ ANTHROPIC_AGENT = "anthropic_agent"
25
+ OPENAI_COMPATIBLE = "openai_compatible"
26
+ GENERIC_HTTP = "generic_http"
27
+ LANGSERVE = "langserve"
28
+ VERTEX_AGENT_RUNTIME = "vertex_agent_runtime"
29
+
30
+
31
+ @dataclass_json
32
+ @dataclass
33
+ class DbModel:
34
+ """JSON-serialisable description of a Model."""
35
+
36
+ type: ModelType
37
+ name: str
38
+ model_name: str
39
+ id: str = field(default_factory=generate_id)
40
+ created: datetime = field(
41
+ default_factory=now_utc,
42
+ metadata=datetime_config,
43
+ )
44
+ description: str | None = None
45
+
46
+ tags: dict[str, str] = field(default_factory=dict)
47
+
48
+ # -- common optional --
49
+ key: str | None = None
50
+ temperature: float = 0.0
51
+
52
+ # -- ollama / agent types --
53
+ host: str | None = None
54
+ endpoint: str | None = None
55
+
56
+ # -- adk / vertex_agent_runtime --
57
+ immediate_result: bool = False
58
+
59
+ # -- vertex_agent_runtime --
60
+ user_id: str | None = None
61
+ class_method: str | None = None
62
+
63
+ # -- generic_http / openai_compatible / langserve --
64
+ headers: dict[str, str] = field(default_factory=dict)
65
+ input_path: str | None = None
66
+ output_path: str | None = None
67
+
68
+ def get_ref(self) -> Reference:
69
+ return Reference(
70
+ self.id,
71
+ self.name,
72
+ ReferenceType.MODEL,
73
+ self.description,
74
+ )
75
+
76
+
77
+ class ModelSortField(str, Enum):
78
+ NAME = "name"
79
+ CREATED = "created"
80
+ TYPE = "type"
81
+ SCORE = "score"
82
+
83
+
84
+ @dataclass_json
85
+ @dataclass
86
+ class ModelSortOrder:
87
+ field: ModelSortField = ModelSortField.NAME
88
+ direction: SortDirection = SortDirection.ASC
89
+
90
+
91
+ @dataclass_json
92
+ @dataclass
93
+ class ModelScoreSummary:
94
+ """Aggregate score and run status for a model."""
95
+
96
+ overall_score: float | None = None
97
+ finished_evaluations: int = 0
98
+ running_evaluations: int = 0
99
+ errored_evaluations: int = 0
100
+ pending_evaluations: int = 0
101
+
102
+
103
+ @dataclass_json
104
+ @dataclass
105
+ class ModelListViewItem:
106
+ """A model with its aggregate score summary."""
107
+
108
+ model: DbModel | None = None
109
+ score_summary: ModelScoreSummary = field(
110
+ default_factory=ModelScoreSummary,
111
+ )
112
+
113
+
114
+ @dataclass_json
115
+ @dataclass
116
+ class ModelListView:
117
+ """Paginated list of model configurations with scores."""
118
+
119
+ items: list[ModelListViewItem] = field(
120
+ default_factory=list,
121
+ )
122
+ total: int = 0
123
+
124
+
125
+ @dataclass_json
126
+ @dataclass
127
+ class DbModelListView:
128
+ """Raw paginated list from the repository."""
129
+
130
+ items: list[DbModel] = field(default_factory=list)
131
+ total: int = 0
132
+
133
+
134
+ @dataclass_json
135
+ @dataclass
136
+ class ModelDetailEvaluationView:
137
+ """A single ModelEvaluation with references and last run."""
138
+
139
+ id: str
140
+ model_ref: Reference
141
+ evaluation_ref: Reference
142
+ created: datetime = field(metadata=datetime_config)
143
+ weight: float = 0.5
144
+ evaluation_approach: str = "Probe"
145
+ evaluation_tags: dict[str, str] = field(
146
+ default_factory=dict,
147
+ )
148
+ last_run: DbModelEvaluationRun | None = None
149
+
150
+
151
+ @dataclass_json
152
+ @dataclass
153
+ class ModelDetailView:
154
+ """A model configuration with its evaluations."""
155
+
156
+ model: DbModel | None = None
157
+ evaluations: list[ModelDetailEvaluationView] = field(
158
+ default_factory=list,
159
+ )
File without changes
@@ -0,0 +1,559 @@
1
+ """
2
+ Read-only JSON-backed repositories for CLI evaluation runs.
3
+
4
+ Reads a single JSON configuration file and provides repository
5
+ implementations for models, evaluations, detectors, message
6
+ collections, and model evaluations. All write operations raise
7
+ NotImplementedError.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+
15
+ from switch_trust.eval.common.reference import Reference
16
+ from switch_trust.eval.common.utils import strip_nulls
17
+ from switch_trust.eval.core.message.message_collection import MessageCollection
18
+ from switch_trust.eval.db.base.detectors.detector_repository import DetectorRepository
19
+ from switch_trust.eval.db.base.detectors.detector_types import (
20
+ DbDetector,
21
+ DetectorListView,
22
+ DetectorSortOrder,
23
+ )
24
+ from switch_trust.eval.db.base.eval.eval_repository import EvaluationRepository
25
+ from switch_trust.eval.db.base.eval.eval_types import (
26
+ DbEvaluation,
27
+ EvaluationListView,
28
+ EvaluationSortOrder,
29
+ )
30
+ from switch_trust.eval.db.base.eval.model_eval_repository import (
31
+ ModelEvaluationRepository,
32
+ )
33
+ from switch_trust.eval.db.base.eval.model_eval_types import (
34
+ DbModelEvaluation,
35
+ ModelEvaluationListItem,
36
+ ModelEvaluationListView,
37
+ ModelEvaluationSortOrder,
38
+ )
39
+ from switch_trust.eval.db.base.message.message_collection_helpers import (
40
+ create_message_collection,
41
+ )
42
+ from switch_trust.eval.db.base.message.message_collection_repository import (
43
+ MessageCollectionRepository,
44
+ )
45
+ from switch_trust.eval.db.base.message.message_collection_types import (
46
+ DbMessageCollection,
47
+ MessageCollectionListView,
48
+ MessageCollectionSortOrder,
49
+ MessageCollectionType,
50
+ )
51
+ from switch_trust.eval.db.base.models.model_repository import ModelRepository
52
+ from switch_trust.eval.db.base.models.model_types import (
53
+ DbModel,
54
+ DbModelListView,
55
+ ModelSortOrder,
56
+ )
57
+
58
+ _READ_ONLY = NotImplementedError("JSON store is read-only")
59
+
60
+
61
+ def _filter_by_query(
62
+ items: list,
63
+ query: str | None,
64
+ name_attr: str = "name",
65
+ ) -> list:
66
+ if not query:
67
+ return items
68
+ q = query.lower()
69
+ return [item for item in items if q in getattr(item, name_attr).lower()]
70
+
71
+
72
+ class JsonModelRepository(ModelRepository):
73
+ def __init__(self, models: list[DbModel]):
74
+ self._models = models
75
+ self._by_id = {m.id: m for m in models}
76
+
77
+ def list(self) -> list[DbModel]:
78
+ return list(self._models)
79
+
80
+ def search(
81
+ self,
82
+ query: str | None = None,
83
+ types: list[str] | None = None,
84
+ order: ModelSortOrder | None = None,
85
+ offset: int = 0,
86
+ limit: int = 50,
87
+ ) -> DbModelListView:
88
+ items = _filter_by_query(self._models, query)
89
+ if types:
90
+ items = [m for m in items if m.type.value in types]
91
+ total = len(items)
92
+ items = items[offset : offset + limit]
93
+ return DbModelListView(items=items, total=total)
94
+
95
+ def get(self, id: str) -> DbModel:
96
+ if id not in self._by_id:
97
+ raise KeyError(f"Model {id!r} not found")
98
+ return self._by_id[id]
99
+
100
+ def create(self, config: DbModel) -> DbModel:
101
+ raise _READ_ONLY
102
+
103
+ def update(self, config: DbModel) -> DbModel:
104
+ raise _READ_ONLY
105
+
106
+ def delete(self, id: str) -> None:
107
+ raise _READ_ONLY
108
+
109
+ def clear(self) -> None:
110
+ raise _READ_ONLY
111
+
112
+
113
+ class JsonEvaluationRepository(EvaluationRepository):
114
+ def __init__(self, evaluations: list[DbEvaluation]):
115
+ self._evaluations = evaluations
116
+ self._by_id = {e.id: e for e in evaluations}
117
+
118
+ def list(self) -> list[DbEvaluation]:
119
+ return list(self._evaluations)
120
+
121
+ def search(
122
+ self,
123
+ query: str | None = None,
124
+ types: list[str] | None = None,
125
+ order: EvaluationSortOrder | None = None,
126
+ offset: int = 0,
127
+ limit: int = 50,
128
+ ) -> EvaluationListView:
129
+ items = _filter_by_query(self._evaluations, query)
130
+ if types:
131
+ items = [e for e in items if e.type.value in types]
132
+ total = len(items)
133
+ items = items[offset : offset + limit]
134
+ return EvaluationListView(items=items, total=total)
135
+
136
+ def get(self, id: str) -> DbEvaluation:
137
+ if id not in self._by_id:
138
+ raise KeyError(
139
+ f"Evaluation {id!r} not found",
140
+ )
141
+ return self._by_id[id]
142
+
143
+ def create(self, config: DbEvaluation) -> DbEvaluation:
144
+ raise _READ_ONLY
145
+
146
+ def update(self, config: DbEvaluation) -> DbEvaluation:
147
+ raise _READ_ONLY
148
+
149
+ def delete(self, id: str) -> None:
150
+ raise _READ_ONLY
151
+
152
+ def clear(self) -> None:
153
+ raise _READ_ONLY
154
+
155
+
156
+ class JsonDetectorRepository(DetectorRepository):
157
+ def __init__(self, detectors: list[DbDetector]):
158
+ self._detectors = detectors
159
+ self._by_id = {d.id: d for d in detectors}
160
+
161
+ def list(self) -> list[DbDetector]:
162
+ return list(self._detectors)
163
+
164
+ def search(
165
+ self,
166
+ query: str | None = None,
167
+ types: list[str] | None = None,
168
+ order: DetectorSortOrder | None = None,
169
+ offset: int = 0,
170
+ limit: int = 50,
171
+ ) -> DetectorListView:
172
+ items = _filter_by_query(self._detectors, query)
173
+ if types:
174
+ items = [d for d in items if d.type.value in types]
175
+ total = len(items)
176
+ items = items[offset : offset + limit]
177
+ return DetectorListView(items=items, total=total)
178
+
179
+ def get(self, id: str) -> DbDetector:
180
+ if id not in self._by_id:
181
+ raise KeyError(
182
+ f"Detector {id!r} not found",
183
+ )
184
+ return self._by_id[id]
185
+
186
+ def create(self, config: DbDetector) -> DbDetector:
187
+ raise _READ_ONLY
188
+
189
+ def update(self, config: DbDetector) -> DbDetector:
190
+ raise _READ_ONLY
191
+
192
+ def delete(self, id: str) -> None:
193
+ raise _READ_ONLY
194
+
195
+ def clear(self) -> None:
196
+ raise _READ_ONLY
197
+
198
+
199
+ class JsonMessageCollectionRepository(
200
+ MessageCollectionRepository,
201
+ ):
202
+ def __init__(
203
+ self,
204
+ collections: list[DbMessageCollection],
205
+ ):
206
+ self._collections = collections
207
+ self._by_id = {c.id: c for c in collections}
208
+
209
+ def list(self) -> list[DbMessageCollection]:
210
+ return list(self._collections)
211
+
212
+ def search(
213
+ self,
214
+ query: str | None = None,
215
+ types: list[str] | None = None,
216
+ order: MessageCollectionSortOrder | None = None,
217
+ offset: int = 0,
218
+ limit: int = 50,
219
+ ) -> MessageCollectionListView:
220
+ items = _filter_by_query(
221
+ self._collections,
222
+ query,
223
+ )
224
+ if types:
225
+ items = [c for c in items if c.type.value in types]
226
+ total = len(items)
227
+ items = items[offset : offset + limit]
228
+ return MessageCollectionListView(
229
+ items=items,
230
+ total=total,
231
+ )
232
+
233
+ def get(self, id: str) -> DbMessageCollection:
234
+ if id not in self._by_id:
235
+ raise KeyError(
236
+ f"MessageCollection {id!r} not found",
237
+ )
238
+ return self._by_id[id]
239
+
240
+ def get_message_collection(
241
+ self,
242
+ id: str,
243
+ ) -> MessageCollection:
244
+
245
+ return create_message_collection(self.get(id))
246
+
247
+ def create(
248
+ self,
249
+ db_collection: DbMessageCollection,
250
+ ) -> DbMessageCollection:
251
+ raise _READ_ONLY
252
+
253
+ def update(
254
+ self,
255
+ db_collection: DbMessageCollection,
256
+ ) -> DbMessageCollection:
257
+ raise _READ_ONLY
258
+
259
+ def delete(self, id: str) -> None:
260
+ raise _READ_ONLY
261
+
262
+ def clear(self) -> None:
263
+ raise _READ_ONLY
264
+
265
+
266
+ class JsonModelEvaluationRepository(
267
+ ModelEvaluationRepository,
268
+ ):
269
+ def __init__(
270
+ self,
271
+ model_evaluations: list[DbModelEvaluation],
272
+ model_repo: JsonModelRepository,
273
+ eval_repo: JsonEvaluationRepository,
274
+ ):
275
+ self._items = model_evaluations
276
+ self._by_id = {me.id: me for me in model_evaluations}
277
+ self._model_repo = model_repo
278
+ self._eval_repo = eval_repo
279
+
280
+ def _resolve_refs(
281
+ self,
282
+ me: DbModelEvaluation,
283
+ ) -> ModelEvaluationListItem:
284
+ model_ref: Reference | None = None
285
+ eval_ref: Reference | None = None
286
+ try:
287
+ model_ref = self._model_repo.get(
288
+ me.model_id,
289
+ ).get_ref()
290
+ except KeyError:
291
+ pass
292
+ try:
293
+ eval_ref = self._eval_repo.get(
294
+ me.evaluation_id,
295
+ ).get_ref()
296
+ except KeyError:
297
+ pass
298
+ return ModelEvaluationListItem(
299
+ config=me,
300
+ model_ref=model_ref,
301
+ evaluation_ref=eval_ref,
302
+ )
303
+
304
+ def list_by_model(
305
+ self,
306
+ model_id: str,
307
+ order: ModelEvaluationSortOrder | None = None,
308
+ offset: int = 0,
309
+ limit: int = 50,
310
+ ) -> ModelEvaluationListView:
311
+ filtered = [me for me in self._items if me.model_id == model_id]
312
+ total = len(filtered)
313
+ page = filtered[offset : offset + limit]
314
+ items = [self._resolve_refs(me) for me in page]
315
+ return ModelEvaluationListView(
316
+ items=items,
317
+ total=total,
318
+ )
319
+
320
+ def list_by_evaluation(
321
+ self,
322
+ evaluation_id: str,
323
+ order: ModelEvaluationSortOrder | None = None,
324
+ offset: int = 0,
325
+ limit: int = 50,
326
+ ) -> ModelEvaluationListView:
327
+ filtered = [me for me in self._items if me.evaluation_id == evaluation_id]
328
+ total = len(filtered)
329
+ page = filtered[offset : offset + limit]
330
+ items = [self._resolve_refs(me) for me in page]
331
+ return ModelEvaluationListView(
332
+ items=items,
333
+ total=total,
334
+ )
335
+
336
+ def get(self, id: str) -> DbModelEvaluation:
337
+ if id not in self._by_id:
338
+ raise KeyError(
339
+ f"ModelEvaluation {id!r} not found",
340
+ )
341
+ return self._by_id[id]
342
+
343
+ def list_all(self) -> list[DbModelEvaluation]:
344
+ return list(self._items)
345
+
346
+ def add(self, config: DbModelEvaluation) -> bool:
347
+ """Add a model-evaluation assignment.
348
+
349
+ Returns True if added, False if a matching
350
+ model_id + evaluation_id pair already exists.
351
+ """
352
+ for existing in self._items:
353
+ if (
354
+ existing.model_id == config.model_id
355
+ and existing.evaluation_id == config.evaluation_id
356
+ ):
357
+ return False
358
+ self._items.append(config)
359
+ self._by_id[config.id] = config
360
+ return True
361
+
362
+ def remove(
363
+ self,
364
+ model_id: str | None = None,
365
+ evaluation_id: str | None = None,
366
+ ) -> list[DbModelEvaluation]:
367
+ """Remove assignments matching the given filters.
368
+
369
+ At least one of model_id or evaluation_id must be
370
+ provided. Returns the list of removed items.
371
+ """
372
+ if model_id is None and evaluation_id is None:
373
+ return []
374
+
375
+ removed: list[DbModelEvaluation] = []
376
+ kept: list[DbModelEvaluation] = []
377
+ for me in self._items:
378
+ match = True
379
+ if model_id is not None and me.model_id != model_id:
380
+ match = False
381
+ if evaluation_id is not None and me.evaluation_id != evaluation_id:
382
+ match = False
383
+ if match:
384
+ removed.append(me)
385
+ else:
386
+ kept.append(me)
387
+
388
+ self._items = kept
389
+ self._by_id = {me.id: me for me in kept}
390
+ return removed
391
+
392
+ def create(
393
+ self,
394
+ config: DbModelEvaluation,
395
+ ) -> DbModelEvaluation:
396
+ raise _READ_ONLY
397
+
398
+ def clear(self) -> None:
399
+ raise _READ_ONLY
400
+
401
+
402
+ class JsonRepository:
403
+ """Read-only store backed by a single JSON config file."""
404
+
405
+ def __init__(self, path: str):
406
+ self._path = path
407
+ with open(path) as f:
408
+ data = json.load(f)
409
+ self._init_repos(data)
410
+
411
+ def merge(self, other: JsonRepository) -> JsonRepository:
412
+ """Return a new repository with entries from both.
413
+
414
+ On ID conflicts, entries from ``self`` take precedence.
415
+ """
416
+
417
+ def _combine(self_list: list, other_list: list) -> list:
418
+ self_ids = {item.id for item in self_list}
419
+ combined = list(self_list)
420
+ for item in other_list:
421
+ if item.id not in self_ids:
422
+ combined.append(item)
423
+ return combined
424
+
425
+ merged = object.__new__(JsonRepository)
426
+ merged._path = self._path
427
+
428
+ models = _combine(
429
+ self._models_repo.list(),
430
+ other._models_repo.list(),
431
+ )
432
+ evals = _combine(
433
+ self._evaluations_repo.list(),
434
+ other._evaluations_repo.list(),
435
+ )
436
+ detectors = _combine(
437
+ self._detectors_repo.list(),
438
+ other._detectors_repo.list(),
439
+ )
440
+ collections = _combine(
441
+ self._message_collections_repo.list(),
442
+ other._message_collections_repo.list(),
443
+ )
444
+ assignments = _combine(
445
+ self._model_evaluations_repo.list_all(),
446
+ other._model_evaluations_repo.list_all(),
447
+ )
448
+
449
+ merged._models_repo = JsonModelRepository(models)
450
+ merged._evaluations_repo = JsonEvaluationRepository(evals)
451
+ merged._detectors_repo = JsonDetectorRepository(detectors)
452
+ merged._message_collections_repo = JsonMessageCollectionRepository(collections)
453
+ merged._model_evaluations_repo = JsonModelEvaluationRepository(
454
+ assignments,
455
+ model_repo=merged._models_repo,
456
+ eval_repo=merged._evaluations_repo,
457
+ )
458
+ return merged
459
+
460
+ def _init_repos(self, data: dict) -> None:
461
+ config_dir = os.path.dirname(
462
+ os.path.abspath(self._path),
463
+ )
464
+
465
+ self._models_repo = JsonModelRepository(
466
+ [DbModel.from_dict(m) for m in data.get("models", [])]
467
+ )
468
+ self._evaluations_repo = JsonEvaluationRepository(
469
+ [DbEvaluation.from_dict(e) for e in data.get("evaluations", [])]
470
+ )
471
+ detectors: list[DbDetector] = []
472
+ for d_dict in data.get("detectors", []):
473
+ prompt_file = d_dict.pop("prompt_file", None)
474
+ if prompt_file:
475
+ if not os.path.isabs(prompt_file):
476
+ prompt_file = os.path.join(
477
+ config_dir,
478
+ prompt_file,
479
+ )
480
+ with open(
481
+ prompt_file,
482
+ encoding="utf-8",
483
+ ) as f:
484
+ d_dict["prompt"] = f.read()
485
+ d = DbDetector.from_dict(d_dict)
486
+ detectors.append(d)
487
+ self._detectors_repo = JsonDetectorRepository(detectors)
488
+
489
+ collections: list[DbMessageCollection] = []
490
+ for mc_dict in data.get("message_collections", []):
491
+ mc = DbMessageCollection.from_dict(mc_dict)
492
+ if (
493
+ mc.type == MessageCollectionType.CSV
494
+ and mc.filename
495
+ and not os.path.isabs(mc.filename)
496
+ ):
497
+ mc.filename = os.path.join(
498
+ config_dir,
499
+ mc.filename,
500
+ )
501
+ collections.append(mc)
502
+
503
+ self._message_collections_repo = JsonMessageCollectionRepository(collections)
504
+ self._model_evaluations_repo = JsonModelEvaluationRepository(
505
+ [
506
+ DbModelEvaluation.from_dict(me)
507
+ for me in data.get(
508
+ "model_evaluations",
509
+ [],
510
+ )
511
+ ],
512
+ model_repo=self._models_repo,
513
+ eval_repo=self._evaluations_repo,
514
+ )
515
+
516
+ def save(self) -> None:
517
+ """Write the current state back to the config file."""
518
+ data: dict = {
519
+ "models": [m.to_dict() for m in self._models_repo.list()],
520
+ "evaluations": [e.to_dict() for e in self._evaluations_repo.list()],
521
+ "detectors": [d.to_dict() for d in self._detectors_repo.list()],
522
+ "message_collections": [
523
+ c.to_dict() for c in self._message_collections_repo.list()
524
+ ],
525
+ "model_evaluations": [
526
+ me.to_dict() for me in self._model_evaluations_repo.list_all()
527
+ ],
528
+ }
529
+ data = strip_nulls(data)
530
+ with open(self._path, "w") as f:
531
+ json.dump(data, f, indent=2, default=str)
532
+
533
+ @property
534
+ def path(self) -> str:
535
+ return self._path
536
+
537
+ @property
538
+ def models(self) -> JsonModelRepository:
539
+ return self._models_repo
540
+
541
+ @property
542
+ def evaluations(self) -> JsonEvaluationRepository:
543
+ return self._evaluations_repo
544
+
545
+ @property
546
+ def detectors(self) -> JsonDetectorRepository:
547
+ return self._detectors_repo
548
+
549
+ @property
550
+ def message_collections(
551
+ self,
552
+ ) -> JsonMessageCollectionRepository:
553
+ return self._message_collections_repo
554
+
555
+ @property
556
+ def model_evaluations(
557
+ self,
558
+ ) -> JsonModelEvaluationRepository:
559
+ return self._model_evaluations_repo