boring-agent-memory 0.2.2__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.
- boring_agent_memory/__init__.py +12 -0
- boring_agent_memory/api.py +23 -0
- boring_agent_memory/benchmark.py +524 -0
- boring_agent_memory/canonical.py +73 -0
- boring_agent_memory/chunking.py +425 -0
- boring_agent_memory/cli.py +298 -0
- boring_agent_memory/config.py +162 -0
- boring_agent_memory/embeddings.py +253 -0
- boring_agent_memory/eval.py +271 -0
- boring_agent_memory/index.py +449 -0
- boring_agent_memory/ingest.py +175 -0
- boring_agent_memory/privacy.py +84 -0
- boring_agent_memory/query.py +233 -0
- boring_agent_memory/schema.py +212 -0
- boring_agent_memory/server.py +41 -0
- boring_agent_memory/snippets.py +26 -0
- boring_agent_memory-0.2.2.dist-info/METADATA +162 -0
- boring_agent_memory-0.2.2.dist-info/RECORD +22 -0
- boring_agent_memory-0.2.2.dist-info/WHEEL +5 -0
- boring_agent_memory-0.2.2.dist-info/entry_points.txt +2 -0
- boring_agent_memory-0.2.2.dist-info/licenses/LICENSE +22 -0
- boring_agent_memory-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Boring Agent Memory."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .api import memory_query
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("boring-agent-memory")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
__version__ = "0+unknown"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__", "memory_query"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .query import QueryResult, query_memory
|
|
6
|
+
from .schema import DEFAULT_DB_PATH
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def memory_query(
|
|
10
|
+
query: str,
|
|
11
|
+
limit: int = 5,
|
|
12
|
+
source_type: str | None = None,
|
|
13
|
+
db_path: Path | str = DEFAULT_DB_PATH,
|
|
14
|
+
workspace: Path | str | None = None,
|
|
15
|
+
) -> list[QueryResult]:
|
|
16
|
+
"""Small agent-facing query API."""
|
|
17
|
+
return query_memory(
|
|
18
|
+
db_path=db_path,
|
|
19
|
+
query=query,
|
|
20
|
+
limit=limit,
|
|
21
|
+
source_type=source_type,
|
|
22
|
+
workspace=workspace,
|
|
23
|
+
)
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import statistics
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from collections import Counter, defaultdict
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .embeddings import (
|
|
16
|
+
DenseIndex,
|
|
17
|
+
EmbeddingAdapter,
|
|
18
|
+
load_embedding_documents,
|
|
19
|
+
reciprocal_rank_fusion,
|
|
20
|
+
)
|
|
21
|
+
from .index import build_index
|
|
22
|
+
from .ingest import IngestedRecord, ingest_file, iter_candidate_files
|
|
23
|
+
from .privacy import SECRET_PATTERNS
|
|
24
|
+
from .query import QueryResult, query_memory
|
|
25
|
+
from .schema import connect, init_db
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class BenchmarkCase:
|
|
30
|
+
id: str
|
|
31
|
+
category: str
|
|
32
|
+
query: str
|
|
33
|
+
expected_source: str | None
|
|
34
|
+
expected_heading: str | None = None
|
|
35
|
+
expected_start_line: int | None = None
|
|
36
|
+
expected_end_line: int | None = None
|
|
37
|
+
forbidden_sources: tuple[str, ...] = ()
|
|
38
|
+
forbidden_terms: tuple[str, ...] = ()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_benchmark(
|
|
42
|
+
corpus_dir: Path | str,
|
|
43
|
+
cases_path: Path | str,
|
|
44
|
+
limit: int = 3,
|
|
45
|
+
benchmark_name: str = "benchmark-v2",
|
|
46
|
+
chunk_size: int = 1600,
|
|
47
|
+
embedding_adapter: EmbeddingAdapter | None = None,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
corpus_dir = Path(corpus_dir).expanduser().resolve()
|
|
50
|
+
cases_path = Path(cases_path).expanduser().resolve()
|
|
51
|
+
cases = load_benchmark_cases(cases_path)
|
|
52
|
+
identity_namespace = f"benchmark:{benchmark_name}"
|
|
53
|
+
|
|
54
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
55
|
+
workspace = (Path(tmp) / "corpus").resolve()
|
|
56
|
+
shutil.copytree(corpus_dir, workspace)
|
|
57
|
+
whole_db = Path(tmp) / "whole.db"
|
|
58
|
+
chunked_db = Path(tmp) / "chunked.db"
|
|
59
|
+
|
|
60
|
+
whole_index = build_benchmark_index(
|
|
61
|
+
whole_db,
|
|
62
|
+
workspace,
|
|
63
|
+
chunk_size=0,
|
|
64
|
+
identity_namespace=identity_namespace,
|
|
65
|
+
)
|
|
66
|
+
chunked_index = build_benchmark_index(
|
|
67
|
+
chunked_db,
|
|
68
|
+
workspace,
|
|
69
|
+
chunk_size=chunk_size,
|
|
70
|
+
identity_namespace=identity_namespace,
|
|
71
|
+
)
|
|
72
|
+
records = load_redacted_records(workspace, identity_namespace)
|
|
73
|
+
|
|
74
|
+
whole_bm25 = evaluate_strategy(
|
|
75
|
+
cases,
|
|
76
|
+
workspace,
|
|
77
|
+
lambda case: query_memory(
|
|
78
|
+
whole_db,
|
|
79
|
+
case.query,
|
|
80
|
+
limit=limit,
|
|
81
|
+
source_type="benchmark_fixture",
|
|
82
|
+
workspace=workspace,
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
whole_bm25["index"] = whole_index
|
|
86
|
+
def chunked_retrieve(case: BenchmarkCase) -> list[QueryResult]:
|
|
87
|
+
return query_memory(
|
|
88
|
+
chunked_db,
|
|
89
|
+
case.query,
|
|
90
|
+
limit=limit,
|
|
91
|
+
source_type="benchmark_fixture",
|
|
92
|
+
workspace=workspace,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
chunked_bm25 = evaluate_strategy(cases, workspace, chunked_retrieve)
|
|
96
|
+
chunked_bm25["index"] = chunked_index
|
|
97
|
+
exact_grep = evaluate_strategy(
|
|
98
|
+
cases,
|
|
99
|
+
workspace,
|
|
100
|
+
lambda case: exact_phrase_grep(records, case.query, limit),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if embedding_adapter is None:
|
|
104
|
+
dense: dict[str, Any] = {
|
|
105
|
+
"status": "not_run",
|
|
106
|
+
"reason": "no explicit local embedding adapter was provided",
|
|
107
|
+
}
|
|
108
|
+
hybrid: dict[str, Any] = {
|
|
109
|
+
"status": "not_run",
|
|
110
|
+
"reason": "hybrid retrieval requires an explicit local embedding adapter",
|
|
111
|
+
}
|
|
112
|
+
else:
|
|
113
|
+
candidate_depth = max(50, limit * 10)
|
|
114
|
+
dense_started = time.perf_counter()
|
|
115
|
+
dense_index = DenseIndex.build(
|
|
116
|
+
embedding_adapter,
|
|
117
|
+
load_embedding_documents(
|
|
118
|
+
chunked_db,
|
|
119
|
+
source_type="benchmark_fixture",
|
|
120
|
+
workspace=workspace,
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
dense_build_ms = elapsed_ms(dense_started)
|
|
124
|
+
dense = evaluate_strategy(
|
|
125
|
+
cases,
|
|
126
|
+
workspace,
|
|
127
|
+
lambda case: dense_index.query(case.query, limit=limit),
|
|
128
|
+
)
|
|
129
|
+
dense["index"] = {
|
|
130
|
+
"build_ms": dense_build_ms,
|
|
131
|
+
"vectors": len(dense_index.vectors),
|
|
132
|
+
"dimensions": len(dense_index.vectors[0]) if dense_index.vectors else 0,
|
|
133
|
+
"size_bytes_estimate": sum(
|
|
134
|
+
len(vector) * 8 for vector in dense_index.vectors
|
|
135
|
+
),
|
|
136
|
+
"model_id": embedding_adapter.model_id,
|
|
137
|
+
"model_revision": getattr(embedding_adapter, "model_revision", None),
|
|
138
|
+
"model_license": getattr(embedding_adapter, "model_license", None),
|
|
139
|
+
"model_source": (
|
|
140
|
+
"explicit_download"
|
|
141
|
+
if bool(getattr(embedding_adapter, "allow_download", False))
|
|
142
|
+
else "local_path"
|
|
143
|
+
),
|
|
144
|
+
"text_leaves_machine": False,
|
|
145
|
+
"download_allowed": bool(
|
|
146
|
+
getattr(embedding_adapter, "allow_download", False)
|
|
147
|
+
),
|
|
148
|
+
}
|
|
149
|
+
hybrid = evaluate_strategy(
|
|
150
|
+
cases,
|
|
151
|
+
workspace,
|
|
152
|
+
lambda case: reciprocal_rank_fusion(
|
|
153
|
+
query_memory(
|
|
154
|
+
chunked_db,
|
|
155
|
+
case.query,
|
|
156
|
+
limit=candidate_depth,
|
|
157
|
+
source_type="benchmark_fixture",
|
|
158
|
+
workspace=workspace,
|
|
159
|
+
),
|
|
160
|
+
dense_index.query(case.query, limit=candidate_depth),
|
|
161
|
+
limit=limit,
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
hybrid["parameters"] = {
|
|
165
|
+
"fusion": "weighted_rrf",
|
|
166
|
+
"lexical_weight": 0.6,
|
|
167
|
+
"dense_weight": 0.4,
|
|
168
|
+
"rrf_k": 60,
|
|
169
|
+
"lexical_candidate_depth": candidate_depth,
|
|
170
|
+
"dense_candidate_depth": candidate_depth,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"schema_version": 2,
|
|
175
|
+
"benchmark": benchmark_name,
|
|
176
|
+
"corpus": {
|
|
177
|
+
"documents": whole_index["documents"],
|
|
178
|
+
"cases": len(cases),
|
|
179
|
+
"category_counts": dict(
|
|
180
|
+
sorted(Counter(case.category for case in cases).items())
|
|
181
|
+
),
|
|
182
|
+
"corpus_sha256": directory_hash(corpus_dir),
|
|
183
|
+
"cases_sha256": file_hash(cases_path),
|
|
184
|
+
},
|
|
185
|
+
"strategies": {
|
|
186
|
+
"whole_document_bm25": whole_bm25,
|
|
187
|
+
"chunked_bm25": chunked_bm25,
|
|
188
|
+
"exact_phrase_grep": exact_grep,
|
|
189
|
+
"dense": dense,
|
|
190
|
+
"hybrid_rrf": hybrid,
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def build_benchmark_index(
|
|
196
|
+
db_path: Path,
|
|
197
|
+
workspace: Path,
|
|
198
|
+
chunk_size: int,
|
|
199
|
+
identity_namespace: str,
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
started = time.perf_counter()
|
|
202
|
+
build = build_index(
|
|
203
|
+
db_path=db_path,
|
|
204
|
+
includes=[workspace.as_posix()],
|
|
205
|
+
workspace=workspace,
|
|
206
|
+
source_type="benchmark_fixture",
|
|
207
|
+
chunk_size=chunk_size,
|
|
208
|
+
identity_namespace=identity_namespace,
|
|
209
|
+
)
|
|
210
|
+
build_ms = elapsed_ms(started)
|
|
211
|
+
conn = connect(db_path)
|
|
212
|
+
init_db(conn)
|
|
213
|
+
metadata = conn.execute(
|
|
214
|
+
"SELECT config_json FROM index_metadata WHERE singleton = 1"
|
|
215
|
+
).fetchone()
|
|
216
|
+
conn.close()
|
|
217
|
+
portable_config = json.loads(metadata["config_json"])
|
|
218
|
+
portable_config["workspace"] = "<BENCHMARK_CORPUS>"
|
|
219
|
+
portable_config["includes"] = ["<BENCHMARK_CORPUS>"]
|
|
220
|
+
portable_rendered = json.dumps(
|
|
221
|
+
portable_config,
|
|
222
|
+
sort_keys=True,
|
|
223
|
+
separators=(",", ":"),
|
|
224
|
+
)
|
|
225
|
+
return {
|
|
226
|
+
"build_ms": build_ms,
|
|
227
|
+
"size_bytes": database_size(db_path),
|
|
228
|
+
"documents": build["indexed"],
|
|
229
|
+
"chunks": build["chunks"],
|
|
230
|
+
"config_fingerprint": hashlib.sha256(portable_rendered.encode("utf-8")).hexdigest(),
|
|
231
|
+
"config": portable_config,
|
|
232
|
+
"chunk_size": chunk_size,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def load_benchmark_cases(path: Path) -> list[BenchmarkCase]:
|
|
237
|
+
cases: list[BenchmarkCase] = []
|
|
238
|
+
ids: set[str] = set()
|
|
239
|
+
for line_number, line in enumerate(
|
|
240
|
+
path.read_text(encoding="utf-8").splitlines(), start=1
|
|
241
|
+
):
|
|
242
|
+
if not line.strip():
|
|
243
|
+
continue
|
|
244
|
+
payload = json.loads(line)
|
|
245
|
+
case = BenchmarkCase(
|
|
246
|
+
id=payload["id"],
|
|
247
|
+
category=payload["category"],
|
|
248
|
+
query=payload["query"],
|
|
249
|
+
expected_source=payload.get("expected_source"),
|
|
250
|
+
expected_heading=payload.get("expected_heading"),
|
|
251
|
+
expected_start_line=payload.get("expected_start_line"),
|
|
252
|
+
expected_end_line=payload.get("expected_end_line"),
|
|
253
|
+
forbidden_sources=tuple(payload.get("forbidden_sources", ())),
|
|
254
|
+
forbidden_terms=tuple(payload.get("forbidden_terms", ())),
|
|
255
|
+
)
|
|
256
|
+
if case.id in ids:
|
|
257
|
+
raise ValueError(f"{path}:{line_number} duplicate case id {case.id}")
|
|
258
|
+
ids.add(case.id)
|
|
259
|
+
cases.append(case)
|
|
260
|
+
if not cases:
|
|
261
|
+
raise ValueError(f"{path} does not contain any cases")
|
|
262
|
+
return cases
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def load_redacted_records(
|
|
266
|
+
workspace: Path,
|
|
267
|
+
identity_namespace: str,
|
|
268
|
+
) -> list[IngestedRecord]:
|
|
269
|
+
records: list[IngestedRecord] = []
|
|
270
|
+
for path in iter_candidate_files([workspace.as_posix()], workspace=workspace):
|
|
271
|
+
record = ingest_file(
|
|
272
|
+
path,
|
|
273
|
+
workspace=workspace,
|
|
274
|
+
source_type="benchmark_fixture",
|
|
275
|
+
identity_namespace=identity_namespace,
|
|
276
|
+
)
|
|
277
|
+
if record:
|
|
278
|
+
records.append(record)
|
|
279
|
+
return records
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def exact_phrase_grep(
|
|
283
|
+
records: list[IngestedRecord], query: str, limit: int
|
|
284
|
+
) -> list[QueryResult]:
|
|
285
|
+
needle = query.casefold().strip()
|
|
286
|
+
if not needle:
|
|
287
|
+
return []
|
|
288
|
+
matches: list[QueryResult] = []
|
|
289
|
+
for record in records:
|
|
290
|
+
text = f"{record.title}\n{record.content}"
|
|
291
|
+
if needle not in text.casefold() and needle not in record.source_path.casefold():
|
|
292
|
+
continue
|
|
293
|
+
result_id = hashlib.sha256(
|
|
294
|
+
f"{record.id}\0exact_phrase_grep\0{record.content_hash}".encode()
|
|
295
|
+
).hexdigest()
|
|
296
|
+
matches.append(
|
|
297
|
+
QueryResult(
|
|
298
|
+
id=record.id,
|
|
299
|
+
chunk_id=result_id,
|
|
300
|
+
source_type="benchmark_fixture",
|
|
301
|
+
source_path=record.source_path,
|
|
302
|
+
workspace=record.workspace,
|
|
303
|
+
title=record.title,
|
|
304
|
+
heading="",
|
|
305
|
+
start_line=1,
|
|
306
|
+
end_line=max(1, len(text.splitlines())),
|
|
307
|
+
citation=(
|
|
308
|
+
f"{record.source_path}:L1-L{max(1, len(text.splitlines()))}"
|
|
309
|
+
),
|
|
310
|
+
score=0.0,
|
|
311
|
+
snippet="[exact phrase match]",
|
|
312
|
+
strategy="exact_phrase_grep",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
return matches[:limit]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def evaluate_strategy(
|
|
319
|
+
cases: list[BenchmarkCase],
|
|
320
|
+
workspace: Path,
|
|
321
|
+
retrieve: Callable[[BenchmarkCase], list[QueryResult]],
|
|
322
|
+
) -> dict[str, Any]:
|
|
323
|
+
rows: list[dict[str, Any]] = []
|
|
324
|
+
latencies: list[float] = []
|
|
325
|
+
for case in cases:
|
|
326
|
+
started = time.perf_counter()
|
|
327
|
+
results = retrieve(case)
|
|
328
|
+
latencies.append(elapsed_ms(started))
|
|
329
|
+
rows.append(evaluate_case(case, results, workspace))
|
|
330
|
+
|
|
331
|
+
positive = [row for row in rows if row["expected_source"] is not None]
|
|
332
|
+
negative = [row for row in rows if row["expected_source"] is None]
|
|
333
|
+
categories: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
334
|
+
for row in rows:
|
|
335
|
+
categories[row["category"]].append(row)
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
"status": "run",
|
|
339
|
+
"metrics": summarize(positive, negative, rows),
|
|
340
|
+
"latency_ms": {
|
|
341
|
+
"median": round(statistics.median(latencies), 3),
|
|
342
|
+
"p95": round(percentile(latencies, 0.95), 3),
|
|
343
|
+
},
|
|
344
|
+
"categories": {
|
|
345
|
+
category: summarize(
|
|
346
|
+
[row for row in category_rows if row["expected_source"] is not None],
|
|
347
|
+
[row for row in category_rows if row["expected_source"] is None],
|
|
348
|
+
category_rows,
|
|
349
|
+
)
|
|
350
|
+
for category, category_rows in sorted(categories.items())
|
|
351
|
+
},
|
|
352
|
+
"cases": rows,
|
|
353
|
+
"failures": [row for row in rows if not row["correct"]],
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def evaluate_case(
|
|
358
|
+
case: BenchmarkCase, results: list[QueryResult], workspace: Path
|
|
359
|
+
) -> dict[str, Any]:
|
|
360
|
+
relative_results = [relative_source(result.source_path, workspace) for result in results]
|
|
361
|
+
expected = case.expected_source
|
|
362
|
+
rank = relative_results.index(expected) + 1 if expected in relative_results else None
|
|
363
|
+
evidence_rank = None
|
|
364
|
+
if expected is not None:
|
|
365
|
+
for index, result in enumerate(results, start=1):
|
|
366
|
+
if relative_source(result.source_path, workspace) != expected:
|
|
367
|
+
continue
|
|
368
|
+
heading_matches = (
|
|
369
|
+
case.expected_heading is None or result.heading == case.expected_heading
|
|
370
|
+
)
|
|
371
|
+
start_matches = (
|
|
372
|
+
case.expected_start_line is None
|
|
373
|
+
or result.start_line == case.expected_start_line
|
|
374
|
+
)
|
|
375
|
+
end_matches = (
|
|
376
|
+
case.expected_end_line is None or result.end_line == case.expected_end_line
|
|
377
|
+
)
|
|
378
|
+
if heading_matches and start_matches and end_matches:
|
|
379
|
+
evidence_rank = index
|
|
380
|
+
break
|
|
381
|
+
no_answer_correct = expected is None and not results
|
|
382
|
+
forbidden_ranks = [
|
|
383
|
+
relative_results.index(source) + 1
|
|
384
|
+
for source in case.forbidden_sources
|
|
385
|
+
if source in relative_results
|
|
386
|
+
]
|
|
387
|
+
forbidden_source_hit = any(
|
|
388
|
+
rank is None or forbidden_rank <= rank for forbidden_rank in forbidden_ranks
|
|
389
|
+
)
|
|
390
|
+
privacy_leaks = count_privacy_leaks(results, case.forbidden_terms)
|
|
391
|
+
expected_found = no_answer_correct if expected is None else evidence_rank is not None
|
|
392
|
+
correct = expected_found and not forbidden_source_hit and privacy_leaks == 0
|
|
393
|
+
return {
|
|
394
|
+
"id": case.id,
|
|
395
|
+
"category": case.category,
|
|
396
|
+
"query": case.query,
|
|
397
|
+
"expected_source": expected,
|
|
398
|
+
"expected_heading": case.expected_heading,
|
|
399
|
+
"expected_start_line": case.expected_start_line,
|
|
400
|
+
"expected_end_line": case.expected_end_line,
|
|
401
|
+
"top_source": relative_results[0] if relative_results else None,
|
|
402
|
+
"rank": rank,
|
|
403
|
+
"evidence_rank": evidence_rank,
|
|
404
|
+
"no_answer_correct": no_answer_correct,
|
|
405
|
+
"forbidden_source_hit": forbidden_source_hit,
|
|
406
|
+
"privacy_leak_count": privacy_leaks,
|
|
407
|
+
"correct": correct,
|
|
408
|
+
"results": [
|
|
409
|
+
{
|
|
410
|
+
"source": relative_source(result.source_path, workspace),
|
|
411
|
+
"chunk_id": result.chunk_id,
|
|
412
|
+
"heading": result.heading,
|
|
413
|
+
"start_line": result.start_line,
|
|
414
|
+
"end_line": result.end_line,
|
|
415
|
+
"citation": relative_citation(result, workspace),
|
|
416
|
+
}
|
|
417
|
+
for result in results
|
|
418
|
+
],
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def summarize(
|
|
423
|
+
positive: list[dict[str, Any]],
|
|
424
|
+
negative: list[dict[str, Any]],
|
|
425
|
+
rows: list[dict[str, Any]],
|
|
426
|
+
) -> dict[str, Any]:
|
|
427
|
+
return {
|
|
428
|
+
"cases": len(rows),
|
|
429
|
+
"recall_at_1": ratio(sum(row["rank"] == 1 for row in positive), len(positive)),
|
|
430
|
+
"recall_at_3": ratio(
|
|
431
|
+
sum(row["rank"] is not None and row["rank"] <= 3 for row in positive),
|
|
432
|
+
len(positive),
|
|
433
|
+
),
|
|
434
|
+
"evidence_recall_at_1": ratio(
|
|
435
|
+
sum(row["evidence_rank"] == 1 for row in positive), len(positive)
|
|
436
|
+
),
|
|
437
|
+
"evidence_recall_at_3": ratio(
|
|
438
|
+
sum(
|
|
439
|
+
row["evidence_rank"] is not None and row["evidence_rank"] <= 3
|
|
440
|
+
for row in positive
|
|
441
|
+
),
|
|
442
|
+
len(positive),
|
|
443
|
+
),
|
|
444
|
+
"mrr": ratio(
|
|
445
|
+
sum(1 / row["rank"] for row in positive if row["rank"]), len(positive)
|
|
446
|
+
),
|
|
447
|
+
"source_accuracy": ratio(
|
|
448
|
+
sum(row["rank"] == 1 for row in positive), len(positive)
|
|
449
|
+
),
|
|
450
|
+
"no_answer_precision": ratio(
|
|
451
|
+
sum(row["no_answer_correct"] for row in negative), len(negative)
|
|
452
|
+
),
|
|
453
|
+
"stale_conflict_accuracy": ratio(
|
|
454
|
+
sum(
|
|
455
|
+
row["rank"] is not None
|
|
456
|
+
and not row["forbidden_source_hit"]
|
|
457
|
+
and row["privacy_leak_count"] == 0
|
|
458
|
+
for row in rows
|
|
459
|
+
if row["category"] == "stale_conflict"
|
|
460
|
+
),
|
|
461
|
+
sum(row["category"] == "stale_conflict" for row in rows),
|
|
462
|
+
),
|
|
463
|
+
"privacy_leak_count": sum(row["privacy_leak_count"] for row in rows),
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def count_privacy_leaks(results: list[QueryResult], forbidden_terms: tuple[str, ...]) -> int:
|
|
468
|
+
leaks = 0
|
|
469
|
+
for result in results:
|
|
470
|
+
for pattern in SECRET_PATTERNS:
|
|
471
|
+
leaks += sum(
|
|
472
|
+
1
|
|
473
|
+
for match in pattern.finditer(result.snippet)
|
|
474
|
+
if "[REDACTED]" not in match.group(0)
|
|
475
|
+
)
|
|
476
|
+
leaks += sum(
|
|
477
|
+
term.casefold() in result.snippet.casefold() for term in forbidden_terms
|
|
478
|
+
)
|
|
479
|
+
return leaks
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def relative_source(source_path: str, workspace: Path) -> str:
|
|
483
|
+
try:
|
|
484
|
+
return Path(source_path).resolve().relative_to(workspace).as_posix()
|
|
485
|
+
except ValueError:
|
|
486
|
+
return source_path
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def relative_citation(result: QueryResult, workspace: Path) -> str:
|
|
490
|
+
source = relative_source(result.source_path, workspace)
|
|
491
|
+
heading = f"#{result.heading}" if result.heading else ""
|
|
492
|
+
return f"{source}{heading}:L{result.start_line}-L{result.end_line}"
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def database_size(path: Path) -> int:
|
|
496
|
+
return sum(candidate.stat().st_size for candidate in path.parent.glob(f"{path.name}*"))
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def directory_hash(path: Path) -> str:
|
|
500
|
+
digest = hashlib.sha256()
|
|
501
|
+
for candidate in sorted(item for item in path.rglob("*") if item.is_file()):
|
|
502
|
+
digest.update(candidate.relative_to(path).as_posix().encode("utf-8"))
|
|
503
|
+
digest.update(b"\0")
|
|
504
|
+
digest.update(candidate.read_bytes())
|
|
505
|
+
digest.update(b"\0")
|
|
506
|
+
return digest.hexdigest()
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def file_hash(path: Path) -> str:
|
|
510
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def elapsed_ms(started: float) -> float:
|
|
514
|
+
return round((time.perf_counter() - started) * 1000, 3)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def percentile(values: list[float], percentile_value: float) -> float:
|
|
518
|
+
ordered = sorted(values)
|
|
519
|
+
index = min(len(ordered) - 1, max(0, int(len(ordered) * percentile_value) - 1))
|
|
520
|
+
return ordered[index]
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def ratio(numerator: float, denominator: int) -> float:
|
|
524
|
+
return round(numerator / denominator, 4) if denominator else 0.0
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .schema import connect, init_db, require_index
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def verify_canonical_source(db_path: Path | str, source_path: Path | str) -> dict[str, object]:
|
|
11
|
+
"""Compare an indexed record with the current canonical file on disk."""
|
|
12
|
+
resolved = Path(source_path).expanduser().resolve()
|
|
13
|
+
conn = connect(require_index(db_path))
|
|
14
|
+
init_db(conn)
|
|
15
|
+
try:
|
|
16
|
+
row = conn.execute(
|
|
17
|
+
"SELECT * FROM documents WHERE source_path = ?",
|
|
18
|
+
(resolved.as_posix(),),
|
|
19
|
+
).fetchone()
|
|
20
|
+
finally:
|
|
21
|
+
conn.close()
|
|
22
|
+
|
|
23
|
+
if row is None:
|
|
24
|
+
return {
|
|
25
|
+
"source_path": resolved.as_posix(),
|
|
26
|
+
"indexed": False,
|
|
27
|
+
"exists": resolved.exists(),
|
|
28
|
+
"content_hash_match": False,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
current_hash = None
|
|
32
|
+
read_error = None
|
|
33
|
+
if resolved.exists():
|
|
34
|
+
try:
|
|
35
|
+
current_hash = hashlib.sha256(resolved.read_bytes()).hexdigest()
|
|
36
|
+
except OSError as exc:
|
|
37
|
+
read_error = str(exc)
|
|
38
|
+
raw_hash_available = bool(row["source_hash"])
|
|
39
|
+
result: dict[str, object] = {
|
|
40
|
+
"source_path": resolved.as_posix(),
|
|
41
|
+
"indexed": True,
|
|
42
|
+
"exists": resolved.exists(),
|
|
43
|
+
"content_hash_match": current_hash == row["source_hash"] if raw_hash_available else None,
|
|
44
|
+
"verification_available": raw_hash_available,
|
|
45
|
+
"indexed_hash": row["source_hash"] or None,
|
|
46
|
+
"current_hash": current_hash,
|
|
47
|
+
"indexed_at": row["updated_at"],
|
|
48
|
+
}
|
|
49
|
+
if not raw_hash_available:
|
|
50
|
+
result["reason"] = "legacy_raw_hash_unavailable"
|
|
51
|
+
if read_error:
|
|
52
|
+
result["read_error"] = read_error
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def list_canonical_sources(db_path: Path | str, limit: int = 50) -> list[dict[str, object]]:
|
|
57
|
+
conn = connect(require_index(db_path))
|
|
58
|
+
init_db(conn)
|
|
59
|
+
try:
|
|
60
|
+
rows = conn.execute(
|
|
61
|
+
"""
|
|
62
|
+
SELECT source_type, source_path, title, workspace, updated_at
|
|
63
|
+
FROM documents
|
|
64
|
+
ORDER BY source_path ASC
|
|
65
|
+
LIMIT ?
|
|
66
|
+
""",
|
|
67
|
+
(limit,),
|
|
68
|
+
).fetchall()
|
|
69
|
+
except sqlite3.DatabaseError:
|
|
70
|
+
return []
|
|
71
|
+
finally:
|
|
72
|
+
conn.close()
|
|
73
|
+
return [dict(row) for row in rows]
|