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.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- 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
|
+
)
|