deeptutor-knowledge 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 (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,244 @@
1
+ """QA-set schema for knowledge-base retrieval evaluation.
2
+
3
+ An evaluation set is a list of cases; each case pairs one natural-language
4
+ query with the source passages an ideal retrieval should return for it. The
5
+ passages ("gold") are verbatim excerpts of the indexed documents, so a case
6
+ stays meaningful across re-indexing, re-embedding, and engine changes — a chunk
7
+ identifier does not survive an index rebuild, but document text does.
8
+
9
+ Two on-disk shapes are supported:
10
+
11
+ * ``.jsonl`` / ``.ndjson`` — one case object per line (recommended: a long set
12
+ stays reviewable in a diff);
13
+ * ``.json`` — either a bare list of cases or ``{"name": ..., "cases": [...]}``.
14
+
15
+ Case fields:
16
+
17
+ * ``query`` (required) — the question or phrase handed to retrieval.
18
+ * ``gold`` (required) — one excerpt or a list of excerpts that should be
19
+ retrieved. A single string is accepted as a one-passage list.
20
+ * ``id`` (optional) — a stable label used in reports; defaults to ``q1``, ``q2``…
21
+ * ``notes`` (optional) — free text carried through to the report.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from pathlib import Path
28
+ from typing import Any, Iterator, Sequence
29
+
30
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
31
+
32
+ JSONL_SUFFIXES = frozenset({".jsonl", ".ndjson"})
33
+
34
+
35
+ class EvalDatasetError(ValueError):
36
+ """Raised when an evaluation set cannot be read or is structurally invalid."""
37
+
38
+
39
+ class EvalCase(BaseModel):
40
+ """One query with the gold passages that should be retrieved for it."""
41
+
42
+ model_config = ConfigDict(extra="forbid", frozen=True)
43
+
44
+ query: str
45
+ gold: list[str] = Field(min_length=1)
46
+ id: str = ""
47
+ notes: str = ""
48
+
49
+ @field_validator("query")
50
+ @classmethod
51
+ def _require_query(cls, value: str) -> str:
52
+ """Reject a blank query — there is nothing to retrieve for it."""
53
+ text = (value or "").strip()
54
+ if not text:
55
+ raise ValueError("query must be a non-empty string")
56
+ return text
57
+
58
+ @field_validator("gold", mode="before")
59
+ @classmethod
60
+ def _coerce_gold(cls, value: Any) -> Any:
61
+ """Accept a single excerpt string as a one-passage gold list."""
62
+ if isinstance(value, str):
63
+ return [value]
64
+ return value
65
+
66
+ @field_validator("gold")
67
+ @classmethod
68
+ def _require_gold(cls, value: list[str]) -> list[str]:
69
+ """Reject an empty or blank gold list — matching would be meaningless."""
70
+ passages = [(item or "").strip() for item in value]
71
+ if not passages or any(not passage for passage in passages):
72
+ raise ValueError("gold must hold at least one non-empty passage")
73
+ return passages
74
+
75
+ @field_validator("id", "notes", mode="before")
76
+ @classmethod
77
+ def _coerce_text(cls, value: Any) -> str:
78
+ """Treat a missing/null label or note as an empty string."""
79
+ return "" if value is None else str(value).strip()
80
+
81
+
82
+ class EvalDataset(BaseModel):
83
+ """A named collection of evaluation cases."""
84
+
85
+ model_config = ConfigDict(extra="forbid", frozen=True)
86
+
87
+ name: str = "eval-set"
88
+ cases: list[EvalCase] = Field(min_length=1)
89
+ source: str = ""
90
+
91
+ def __len__(self) -> int:
92
+ """Number of cases in the set."""
93
+ return len(self.cases)
94
+
95
+ def iter_cases(self) -> Iterator[EvalCase]:
96
+ """Iterate the cases in file order."""
97
+ return iter(self.cases)
98
+
99
+ def limited(self, limit: int | None) -> EvalDataset:
100
+ """Return a copy holding at most ``limit`` cases (``None`` keeps all)."""
101
+ if limit is None or limit >= len(self.cases):
102
+ return self
103
+ return self.model_copy(update={"cases": self.cases[: max(0, limit)]})
104
+
105
+ @classmethod
106
+ def from_cases(cls, cases: Sequence[EvalCase | dict], *, name: str = "eval-set") -> EvalDataset:
107
+ """Build a dataset programmatically (used by tests and scripts)."""
108
+ return cls(name=name, cases=[_coerce_case(case, index) for index, case in enumerate(cases)])
109
+
110
+
111
+ def _coerce_case(case: EvalCase | dict, index: int) -> EvalCase:
112
+ """Validate one in-memory case, filling in a default id."""
113
+ if isinstance(case, EvalCase):
114
+ return case if case.id else case.model_copy(update={"id": f"q{index + 1}"})
115
+ validated = EvalCase.model_validate(case)
116
+ return validated if validated.id else validated.model_copy(update={"id": f"q{index + 1}"})
117
+
118
+
119
+ def _first_error(exc: ValidationError) -> str:
120
+ """Flatten a pydantic error into one readable line."""
121
+ error = exc.errors()[0]
122
+ location = ".".join(str(part) for part in error.get("loc", ())) or "case"
123
+ return f"{location}: {error.get('msg', 'invalid value')}"
124
+
125
+
126
+ def _case_context(raw: Any) -> str:
127
+ """A short excerpt of the offending case, to make errors actionable."""
128
+ if isinstance(raw, dict):
129
+ query = raw.get("query")
130
+ if isinstance(query, str) and query.strip():
131
+ return f" (query: {query.strip()[:48]!r})"
132
+ return ""
133
+
134
+
135
+ def _build_case(raw: Any, index: int, path: Path) -> EvalCase:
136
+ """Validate one raw case object, raising with file/line context."""
137
+ if not isinstance(raw, dict):
138
+ raise EvalDatasetError(
139
+ f"{path}: case #{index + 1} must be a JSON object, got {type(raw).__name__}."
140
+ )
141
+ try:
142
+ case = EvalCase.model_validate(raw)
143
+ except ValidationError as exc:
144
+ raise EvalDatasetError(
145
+ f"{path}: case #{index + 1}{_case_context(raw)} is invalid — {_first_error(exc)}"
146
+ ) from exc
147
+ return case if case.id else case.model_copy(update={"id": f"q{index + 1}"})
148
+
149
+
150
+ def _parse_jsonl(text: str, path: Path) -> list[Any]:
151
+ """Parse one case object per non-blank line, reporting the failing line."""
152
+ cases: list[Any] = []
153
+ for line_number, line in enumerate(text.splitlines(), start=1):
154
+ if not line.strip():
155
+ continue
156
+ try:
157
+ cases.append(json.loads(line))
158
+ except json.JSONDecodeError as exc:
159
+ raise EvalDatasetError(
160
+ f"{path}: line {line_number} is not valid JSON — {exc.msg}"
161
+ ) from exc
162
+ return cases
163
+
164
+
165
+ def _parse_json(text: str, path: Path) -> tuple[list[Any], str]:
166
+ """Parse either a bare case list or an object with ``name`` and ``cases``."""
167
+ try:
168
+ payload = json.loads(text)
169
+ except json.JSONDecodeError as exc:
170
+ raise EvalDatasetError(f"{path}: is not valid JSON — {exc.msg}") from exc
171
+ if isinstance(payload, list):
172
+ return payload, ""
173
+ if isinstance(payload, dict):
174
+ cases = payload.get("cases")
175
+ if not isinstance(cases, list):
176
+ raise EvalDatasetError(f"{path}: object form must hold a 'cases' list.")
177
+ name = payload.get("name")
178
+ return cases, str(name).strip() if isinstance(name, str) else ""
179
+ raise EvalDatasetError(f"{path}: must hold a list of cases or an object with 'cases'.")
180
+
181
+
182
+ def _reject_duplicate_ids(cases: Sequence[EvalCase], path: Path) -> None:
183
+ """Fail on repeated case ids so report rows cannot be ambiguous."""
184
+ seen: set[str] = set()
185
+ for case in cases:
186
+ if case.id in seen:
187
+ raise EvalDatasetError(f"{path}: duplicate case id {case.id!r}.")
188
+ seen.add(case.id)
189
+
190
+
191
+ def load_dataset(path: str | Path, *, name: str = "") -> EvalDataset:
192
+ """Read an evaluation set from ``path``.
193
+
194
+ Args:
195
+ path: A ``.jsonl``, ``.ndjson`` or ``.json`` file.
196
+ name: Overrides the set name; defaults to the file's ``name`` field,
197
+ then to the file stem.
198
+
199
+ Returns:
200
+ The parsed, validated dataset.
201
+
202
+ Raises:
203
+ EvalDatasetError: The file is missing, unreadable, of an unsupported
204
+ format, empty, or holds an invalid or duplicated case.
205
+ """
206
+ target = Path(path).expanduser()
207
+ if not target.exists():
208
+ raise EvalDatasetError(f"Evaluation set not found: {target}")
209
+ if target.is_dir():
210
+ raise EvalDatasetError(f"Evaluation set must be a file, not a directory: {target}")
211
+
212
+ suffix = target.suffix.lower()
213
+ if suffix not in JSONL_SUFFIXES and suffix != ".json":
214
+ raise EvalDatasetError(
215
+ f"Unsupported evaluation set format {target.suffix!r}; use .jsonl or .json."
216
+ )
217
+
218
+ try:
219
+ text = target.read_text(encoding="utf-8")
220
+ except OSError as exc:
221
+ raise EvalDatasetError(f"Could not read {target}: {exc}") from exc
222
+ except UnicodeDecodeError as exc:
223
+ raise EvalDatasetError(f"{target} is not UTF-8 text: {exc}") from exc
224
+
225
+ if suffix in JSONL_SUFFIXES:
226
+ raw_cases, set_name = _parse_jsonl(text, target), ""
227
+ else:
228
+ raw_cases, set_name = _parse_json(text, target)
229
+
230
+ if not raw_cases:
231
+ raise EvalDatasetError(f"Evaluation set {target} holds no cases.")
232
+
233
+ cases = [_build_case(raw, index, target) for index, raw in enumerate(raw_cases)]
234
+ _reject_duplicate_ids(cases, target)
235
+ return EvalDataset(name=name or set_name or target.stem, cases=cases, source=str(target))
236
+
237
+
238
+ __all__ = [
239
+ "EvalCase",
240
+ "EvalDataset",
241
+ "EvalDatasetError",
242
+ "JSONL_SUFFIXES",
243
+ "load_dataset",
244
+ ]
@@ -0,0 +1,312 @@
1
+ """Compare two retrieval-evaluation reports to quantify a settings change.
2
+
3
+ A single ``kb eval`` run reports absolute metrics; it cannot tell you whether a
4
+ change to the retrieval stack (an embedding model, a reranker, chunk size, or the
5
+ query-rewrite layer) actually helped. This module answers that by diffing two
6
+ stored reports: a *baseline* recorded before the change and a *candidate* recorded
7
+ after. It aligns the two by query id and reports the per-metric delta plus the
8
+ per-case movement, so a reordered or truncated set still compares like-for-like.
9
+
10
+ Nothing here calls a model: every number is recomputed from the two reports, so
11
+ the diff is reproducible and cannot drift.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ from .metrics import metric_keys
21
+ from .report import format_metric
22
+
23
+ #: A delta above this magnitude counts as a move (symmetric for regressions).
24
+ _IMPROVE_EPSILON = 1e-9
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class _Normalized:
29
+ """A report reduced to just what a diff needs: ``k``, metric keys, metrics, cases.
30
+
31
+ Both a live :class:`~deeptutor.services.rag.eval.report.EvalReport` object and a
32
+ serialized ``to_dict`` (e.g. loaded from a ``--save`` JSON file) normalize to the
33
+ same shape, so a diff works without reconstructing report objects.
34
+ """
35
+
36
+ k: int
37
+ keys: tuple[str, ...]
38
+ metrics: dict[str, float]
39
+ cases: dict[str, dict[str, Any]]
40
+
41
+
42
+ def _normalize(report: Any) -> _Normalized:
43
+ """Reduce a report object or its ``to_dict`` to the fields a diff compares.
44
+
45
+ Args:
46
+ report: A report object or its serialized ``to_dict`` mapping.
47
+
48
+ Returns:
49
+ A :class:`_Normalized` holding the macro metrics and per-case scores.
50
+
51
+ Raises:
52
+ TypeError: If ``report`` is neither a report object nor a mapping.
53
+ """
54
+ if isinstance(report, dict):
55
+ k = int(report.get("k") or 0)
56
+ aggregate: dict[str, Any] = report.get("metrics") or {}
57
+ raw_cases: Sequence[Any] = report.get("queries") or []
58
+ object_form = False
59
+ else:
60
+ k = int(getattr(report, "k", 0) or 0)
61
+ aggregate = dict(report.aggregate())
62
+ raw_cases = list(getattr(report, "evaluations", ()))
63
+ object_form = True
64
+
65
+ keys = metric_keys(k) if k >= 1 else ()
66
+ metrics = {key: float(aggregate.get(key, 0.0) or 0.0) for key in keys}
67
+
68
+ cases: dict[str, dict[str, Any]] = {}
69
+ for case in raw_cases:
70
+ if object_form:
71
+ case_id = str(case.case_id)
72
+ query = str(case.query)
73
+ failed = case.failed
74
+ values = case.metrics.values()
75
+ else:
76
+ case_id = str(case.get("case_id") or case.get("query_id") or case.get("query"))
77
+ query = str(case.get("query") or case_id)
78
+ failed = bool(case.get("error"))
79
+ values = case
80
+ metrics_by_key = {key: float(values.get(key, 0.0) or 0.0) for key in keys}
81
+ cases[case_id] = {"query": query, "failed": failed, "metrics": metrics_by_key}
82
+
83
+ return _Normalized(k=k, keys=keys, metrics=metrics, cases=cases)
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class MetricDelta:
88
+ """One metric's baseline value, candidate value, and signed change."""
89
+
90
+ key: str
91
+ baseline: float
92
+ candidate: float
93
+
94
+ @property
95
+ def delta(self) -> float:
96
+ """``candidate - baseline``; positive means the change improved this metric."""
97
+ return self.candidate - self.baseline
98
+
99
+ @property
100
+ def improved(self) -> bool:
101
+ """Whether the metric went up by more than a rounding epsilon."""
102
+ return self.delta > _IMPROVE_EPSILON
103
+
104
+ @property
105
+ def regressed(self) -> bool:
106
+ """Whether the metric went down by more than a rounding epsilon."""
107
+ return self.delta < -_IMPROVE_EPSILON
108
+
109
+ def to_dict(self) -> dict[str, Any]:
110
+ """A JSON-serializable row for this metric's delta."""
111
+ return {
112
+ "metric": self.key,
113
+ "baseline": round(self.baseline, 6),
114
+ "candidate": round(self.candidate, 6),
115
+ "delta": round(self.delta, 6),
116
+ "direction": "improved" if self.improved else "regressed" if self.regressed else "flat",
117
+ }
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class CaseDelta:
122
+ """One query's metric deltas, comparing only where both runs scored it."""
123
+
124
+ query: str
125
+ baseline_failed: bool
126
+ candidate_failed: bool
127
+ metrics: dict[str, MetricDelta] = field(default_factory=dict)
128
+
129
+ @property
130
+ def comparable(self) -> bool:
131
+ """Whether both runs produced a score for this query."""
132
+ return not (self.baseline_failed or self.candidate_failed)
133
+
134
+ @property
135
+ def worst_key(self) -> str | None:
136
+ """The metric with the most negative delta, if the case has metrics."""
137
+ if not self.metrics:
138
+ return None
139
+ return min(self.metrics, key=lambda key: self.metrics[key].delta)
140
+
141
+ def to_dict(self) -> dict[str, Any]:
142
+ """A JSON-serializable row for this case's per-metric deltas."""
143
+ return {
144
+ "query": self.query,
145
+ "comparable": self.comparable,
146
+ "baseline_failed": self.baseline_failed,
147
+ "candidate_failed": self.candidate_failed,
148
+ "metrics": {key: delta.to_dict() for key, delta in self.metrics.items()},
149
+ }
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class EvalDiff:
154
+ """The per-metric and per-case movement between a baseline and a candidate run.
155
+
156
+ Macro deltas cover the scored cases of each run. Per-case deltas are only
157
+ computed where *both* runs scored the query; a case one run failed (a transient
158
+ engine error) is reported as added/removed rather than pretending a zero-vs-score
159
+ delta is meaningful.
160
+ """
161
+
162
+ baseline_label: str
163
+ candidate_label: str
164
+ k: int
165
+ metrics: dict[str, MetricDelta] = field(default_factory=dict)
166
+ cases: dict[str, CaseDelta] = field(default_factory=dict)
167
+ added_cases: tuple[str, ...] = ()
168
+ removed_cases: tuple[str, ...] = ()
169
+
170
+ @property
171
+ def comparable_cases(self) -> tuple[CaseDelta, ...]:
172
+ """Cases both runs scored, the ones whose per-case movement is meaningful."""
173
+ return tuple(case for case in self.cases.values() if case.comparable)
174
+
175
+ @property
176
+ def improved_cases(self) -> tuple[CaseDelta, ...]:
177
+ """Comparable cases where some metric rose and none fell."""
178
+ return tuple(
179
+ case
180
+ for case in self.comparable_cases
181
+ if any(delta.improved for delta in case.metrics.values())
182
+ and not any(delta.regressed for delta in case.metrics.values())
183
+ )
184
+
185
+ @property
186
+ def regressed_cases(self) -> tuple[CaseDelta, ...]:
187
+ """Comparable cases where any metric fell."""
188
+ return tuple(
189
+ case
190
+ for case in self.comparable_cases
191
+ if any(delta.regressed for delta in case.metrics.values())
192
+ )
193
+
194
+ @property
195
+ def has_regression(self) -> bool:
196
+ """Whether any macro metric or comparable case regressed."""
197
+ if any(delta.regressed for delta in self.metrics.values()):
198
+ return True
199
+ return bool(self.regressed_cases)
200
+
201
+ def to_dict(self) -> dict[str, Any]:
202
+ """A JSON-serializable form of the whole diff."""
203
+ return {
204
+ "baseline": self.baseline_label,
205
+ "candidate": self.candidate_label,
206
+ "k": self.k,
207
+ "metrics": {key: delta.to_dict() for key, delta in self.metrics.items()},
208
+ "cases": {case_id: case.to_dict() for case_id, case in self.cases.items()},
209
+ "added_cases": list(self.added_cases),
210
+ "removed_cases": list(self.removed_cases),
211
+ }
212
+
213
+ def summary_lines(self) -> list[str]:
214
+ """Plain-text lines suitable for a CLI: macro table then case movement."""
215
+ lines = [
216
+ f"Baseline: {self.baseline_label}",
217
+ f"Candidate: {self.candidate_label}",
218
+ f"k={self.k}",
219
+ "",
220
+ f"{'metric':<12} {'baseline':>10} {'candidate':>10} {'delta':>10}",
221
+ ]
222
+ for key, delta in self.metrics.items():
223
+ lines.append(
224
+ f"{key:<12} "
225
+ f"{format_metric(delta.baseline):>10} "
226
+ f"{format_metric(delta.candidate):>10} "
227
+ f"{format_metric(delta.delta):>10} {delta.to_dict()['direction']}"
228
+ )
229
+ lines.append("")
230
+ lines.append(
231
+ f"cases: {len(self.comparable_cases)} comparable, "
232
+ f"{len(self.improved_cases)} improved, {len(self.regressed_cases)} regressed"
233
+ )
234
+ if self.added_cases:
235
+ lines.append(f"added (candidate only): {', '.join(self.added_cases)}")
236
+ if self.removed_cases:
237
+ lines.append(f"removed (baseline only): {', '.join(self.removed_cases)}")
238
+ return lines
239
+
240
+
241
+ def diff_reports(
242
+ baseline: Any,
243
+ candidate: Any,
244
+ *,
245
+ baseline_label: str = "baseline",
246
+ candidate_label: str = "candidate",
247
+ ) -> EvalDiff:
248
+ """Diff two evaluation reports (objects or ``to_dict`` mappings).
249
+
250
+ Args:
251
+ baseline: The pre-change report (object or serialized mapping).
252
+ candidate: The post-change report (object or serialized mapping).
253
+ baseline_label: Human label for the baseline in the summary.
254
+ candidate_label: Human label for the candidate in the summary.
255
+
256
+ Returns:
257
+ An :class:`EvalDiff` with per-metric and per-case movement. Cases are
258
+ aligned by query id; a case scored by only one run is reported as
259
+ added/removed rather than as a spurious delta.
260
+
261
+ Raises:
262
+ ValueError: If the two reports were evaluated at different ``k`` and so
263
+ cannot be compared like-for-like.
264
+ """
265
+ base = _normalize(baseline)
266
+ cand = _normalize(candidate)
267
+ if base.k != cand.k:
268
+ raise ValueError(
269
+ f"reports use different k (baseline k={base.k}, candidate k={cand.k}); "
270
+ "re-evaluate both at the same k to compare"
271
+ )
272
+
273
+ metrics = {
274
+ key: MetricDelta(
275
+ key=key, baseline=base.metrics.get(key, 0.0), candidate=cand.metrics.get(key, 0.0)
276
+ )
277
+ for key in base.keys
278
+ }
279
+
280
+ base_ids = set(base.cases)
281
+ cand_ids = set(cand.cases)
282
+ shared = sorted(base_ids & cand_ids)
283
+
284
+ cases: dict[str, CaseDelta] = {}
285
+ for case_id in shared:
286
+ base_case = base.cases[case_id]
287
+ cand_case = cand.cases[case_id]
288
+ comparable = not (base_case["failed"] or cand_case["failed"])
289
+ per_metric = {
290
+ key: MetricDelta(
291
+ key=key,
292
+ baseline=base_case["metrics"].get(key, 0.0),
293
+ candidate=cand_case["metrics"].get(key, 0.0),
294
+ )
295
+ for key in base.keys
296
+ }
297
+ cases[case_id] = CaseDelta(
298
+ query=cand_case["query"] or base_case["query"],
299
+ baseline_failed=base_case["failed"],
300
+ candidate_failed=cand_case["failed"],
301
+ metrics=per_metric if comparable else {},
302
+ )
303
+
304
+ return EvalDiff(
305
+ baseline_label=baseline_label,
306
+ candidate_label=candidate_label,
307
+ k=base.k,
308
+ metrics=metrics,
309
+ cases=cases,
310
+ added_cases=tuple(sorted(cand_ids - base_ids)),
311
+ removed_cases=tuple(sorted(base_ids - cand_ids)),
312
+ )