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,253 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Iterable, Sequence
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Protocol, cast
|
|
8
|
+
|
|
9
|
+
from .privacy import redact_secrets
|
|
10
|
+
from .query import QueryResult
|
|
11
|
+
from .schema import connect, init_db, require_index
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EmbeddingAdapter(Protocol):
|
|
15
|
+
model_id: str
|
|
16
|
+
|
|
17
|
+
def embed_documents(self, texts: Sequence[str]) -> list[list[float]]: ...
|
|
18
|
+
|
|
19
|
+
def embed_query(self, text: str) -> list[float]: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FastEmbedAdapter:
|
|
23
|
+
"""Optional FastEmbed adapter with a strict no-download default."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
model_name: str,
|
|
28
|
+
model_path: Path | str | None = None,
|
|
29
|
+
allow_download: bool = False,
|
|
30
|
+
cache_dir: Path | str | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
if model_path is None and not allow_download:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"a local embedding model path is required unless allow_download=True"
|
|
35
|
+
)
|
|
36
|
+
resolved_model_path = Path(model_path).expanduser().resolve() if model_path else None
|
|
37
|
+
if resolved_model_path is not None and not resolved_model_path.exists():
|
|
38
|
+
raise FileNotFoundError(f"embedding model path does not exist: {resolved_model_path}")
|
|
39
|
+
try:
|
|
40
|
+
from fastembed import TextEmbedding # type: ignore[import-not-found]
|
|
41
|
+
except ImportError as exc:
|
|
42
|
+
raise RuntimeError(
|
|
43
|
+
"optional embeddings require: pip install 'boring-agent-memory[embeddings]'"
|
|
44
|
+
) from exc
|
|
45
|
+
|
|
46
|
+
resolved_cache_dir = (
|
|
47
|
+
Path(cache_dir).expanduser().resolve().as_posix()
|
|
48
|
+
if cache_dir is not None
|
|
49
|
+
else None
|
|
50
|
+
)
|
|
51
|
+
self._model = TextEmbedding(
|
|
52
|
+
model_name=model_name,
|
|
53
|
+
cache_dir=resolved_cache_dir,
|
|
54
|
+
local_files_only=not allow_download,
|
|
55
|
+
specific_model_path=(
|
|
56
|
+
resolved_model_path.as_posix()
|
|
57
|
+
if resolved_model_path is not None
|
|
58
|
+
else None
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
self.model_id = model_name
|
|
62
|
+
self.allow_download = allow_download
|
|
63
|
+
self.model_path = resolved_model_path
|
|
64
|
+
|
|
65
|
+
def embed_documents(self, texts: Sequence[str]) -> list[list[float]]:
|
|
66
|
+
redacted_texts = [redact_secrets(text)[0] for text in texts]
|
|
67
|
+
return [_vector(values) for values in self._model.embed(redacted_texts)]
|
|
68
|
+
|
|
69
|
+
def embed_query(self, text: str) -> list[float]:
|
|
70
|
+
text = redact_secrets(text)[0]
|
|
71
|
+
query_embed = getattr(self._model, "query_embed", None)
|
|
72
|
+
values = next(iter(query_embed(text) if query_embed else self._model.embed([text])))
|
|
73
|
+
return _vector(values)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class EmbeddingDocument:
|
|
78
|
+
result: QueryResult
|
|
79
|
+
text: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class DenseIndex:
|
|
84
|
+
adapter: EmbeddingAdapter
|
|
85
|
+
documents: tuple[EmbeddingDocument, ...]
|
|
86
|
+
vectors: tuple[tuple[float, ...], ...]
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def build(
|
|
90
|
+
cls,
|
|
91
|
+
adapter: EmbeddingAdapter,
|
|
92
|
+
documents: Sequence[EmbeddingDocument],
|
|
93
|
+
) -> DenseIndex:
|
|
94
|
+
vectors = adapter.embed_documents(
|
|
95
|
+
[redact_secrets(document.text)[0] for document in documents]
|
|
96
|
+
)
|
|
97
|
+
if len(vectors) != len(documents):
|
|
98
|
+
raise ValueError("embedding adapter returned the wrong number of vectors")
|
|
99
|
+
dimensions = {len(vector) for vector in vectors}
|
|
100
|
+
if len(dimensions) > 1 or dimensions == {0}:
|
|
101
|
+
raise ValueError("embedding adapter returned inconsistent vector dimensions")
|
|
102
|
+
return cls(
|
|
103
|
+
adapter=adapter,
|
|
104
|
+
documents=tuple(documents),
|
|
105
|
+
vectors=tuple(tuple(vector) for vector in vectors),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def query(self, text: str, limit: int = 5) -> list[QueryResult]:
|
|
109
|
+
query_vector = self.adapter.embed_query(redact_secrets(text)[0])
|
|
110
|
+
if self.vectors and len(query_vector) != len(self.vectors[0]):
|
|
111
|
+
raise ValueError("query vector dimension does not match the dense index")
|
|
112
|
+
scored = [
|
|
113
|
+
(cosine_similarity(query_vector, vector), document.result)
|
|
114
|
+
for document, vector in zip(self.documents, self.vectors)
|
|
115
|
+
]
|
|
116
|
+
scored.sort(key=lambda item: (-item[0], item[1].chunk_id))
|
|
117
|
+
seen_documents: set[str] = set()
|
|
118
|
+
results: list[QueryResult] = []
|
|
119
|
+
for score, result in scored:
|
|
120
|
+
if result.id in seen_documents:
|
|
121
|
+
continue
|
|
122
|
+
seen_documents.add(result.id)
|
|
123
|
+
results.append(replace(result, score=-score, strategy="dense"))
|
|
124
|
+
if len(results) >= limit:
|
|
125
|
+
break
|
|
126
|
+
return results
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_embedding_documents(
|
|
130
|
+
db_path: Path | str,
|
|
131
|
+
source_type: str | None = None,
|
|
132
|
+
workspace: Path | str | None = None,
|
|
133
|
+
) -> list[EmbeddingDocument]:
|
|
134
|
+
conn = connect(require_index(db_path))
|
|
135
|
+
init_db(conn)
|
|
136
|
+
try:
|
|
137
|
+
filters: list[str] = []
|
|
138
|
+
params: list[object] = []
|
|
139
|
+
if source_type:
|
|
140
|
+
filters.append("d.source_type = ?")
|
|
141
|
+
params.append(source_type)
|
|
142
|
+
if workspace:
|
|
143
|
+
filters.append("d.workspace = ?")
|
|
144
|
+
params.append(Path(workspace).expanduser().resolve().as_posix())
|
|
145
|
+
where = "WHERE " + " AND ".join(filters) if filters else ""
|
|
146
|
+
rows = conn.execute(
|
|
147
|
+
f"""
|
|
148
|
+
SELECT
|
|
149
|
+
d.id, c.id AS chunk_id, d.source_type, d.source_path,
|
|
150
|
+
d.workspace, d.title, c.heading, c.start_line, c.end_line,
|
|
151
|
+
c.content
|
|
152
|
+
FROM chunks c
|
|
153
|
+
JOIN documents d ON d.id = c.document_id
|
|
154
|
+
{where}
|
|
155
|
+
ORDER BY d.source_path, c.ordinal
|
|
156
|
+
""",
|
|
157
|
+
params,
|
|
158
|
+
).fetchall()
|
|
159
|
+
finally:
|
|
160
|
+
conn.close()
|
|
161
|
+
|
|
162
|
+
documents: list[EmbeddingDocument] = []
|
|
163
|
+
for row in rows:
|
|
164
|
+
location = f"L{row['start_line']}-L{row['end_line']}"
|
|
165
|
+
citation = f"{row['source_path']}:{location}"
|
|
166
|
+
if row["heading"]:
|
|
167
|
+
citation = f"{row['source_path']}#{row['heading']}:{location}"
|
|
168
|
+
result = QueryResult(
|
|
169
|
+
id=row["id"],
|
|
170
|
+
chunk_id=row["chunk_id"],
|
|
171
|
+
source_type=row["source_type"],
|
|
172
|
+
source_path=row["source_path"],
|
|
173
|
+
workspace=row["workspace"],
|
|
174
|
+
title=row["title"],
|
|
175
|
+
heading=row["heading"],
|
|
176
|
+
start_line=int(row["start_line"]),
|
|
177
|
+
end_line=int(row["end_line"]),
|
|
178
|
+
citation=citation,
|
|
179
|
+
score=0.0,
|
|
180
|
+
snippet=row["content"],
|
|
181
|
+
strategy="dense",
|
|
182
|
+
)
|
|
183
|
+
embedding_fields = (
|
|
184
|
+
redact_secrets(value)[0]
|
|
185
|
+
for value in (row["title"], row["heading"], row["content"])
|
|
186
|
+
if value
|
|
187
|
+
)
|
|
188
|
+
text = "\n".join(embedding_fields)
|
|
189
|
+
documents.append(EmbeddingDocument(result=result, text=text))
|
|
190
|
+
return documents
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def reciprocal_rank_fusion(
|
|
194
|
+
lexical: Sequence[QueryResult],
|
|
195
|
+
dense: Sequence[QueryResult],
|
|
196
|
+
limit: int,
|
|
197
|
+
lexical_weight: float = 0.6,
|
|
198
|
+
dense_weight: float = 0.4,
|
|
199
|
+
rrf_k: int = 60,
|
|
200
|
+
) -> list[QueryResult]:
|
|
201
|
+
scores: dict[str, float] = {}
|
|
202
|
+
results: dict[str, QueryResult] = {}
|
|
203
|
+
tie_breaks: dict[str, tuple[int, int, str]] = {}
|
|
204
|
+
missing_rank = 10**9
|
|
205
|
+
lexical_ranks = {result.chunk_id: rank for rank, result in enumerate(lexical, start=1)}
|
|
206
|
+
dense_ranks = {result.chunk_id: rank for rank, result in enumerate(dense, start=1)}
|
|
207
|
+
for result in [*lexical, *dense]:
|
|
208
|
+
results.setdefault(result.chunk_id, result)
|
|
209
|
+
for chunk_id, result in results.items():
|
|
210
|
+
lexical_rank = lexical_ranks.get(chunk_id)
|
|
211
|
+
dense_rank = dense_ranks.get(chunk_id)
|
|
212
|
+
score = 0.0
|
|
213
|
+
if lexical_rank is not None:
|
|
214
|
+
score += lexical_weight / (rrf_k + lexical_rank)
|
|
215
|
+
if dense_rank is not None:
|
|
216
|
+
score += dense_weight / (rrf_k + dense_rank)
|
|
217
|
+
scores[chunk_id] = score
|
|
218
|
+
tie_breaks[chunk_id] = (
|
|
219
|
+
lexical_rank or missing_rank,
|
|
220
|
+
dense_rank or missing_rank,
|
|
221
|
+
chunk_id,
|
|
222
|
+
)
|
|
223
|
+
ordered = sorted(results, key=lambda key: (-scores[key], tie_breaks[key]))
|
|
224
|
+
seen_documents: set[str] = set()
|
|
225
|
+
fused: list[QueryResult] = []
|
|
226
|
+
for chunk_id in ordered:
|
|
227
|
+
result = results[chunk_id]
|
|
228
|
+
if result.id in seen_documents:
|
|
229
|
+
continue
|
|
230
|
+
seen_documents.add(result.id)
|
|
231
|
+
fused.append(replace(result, score=-scores[chunk_id], strategy="hybrid_rrf"))
|
|
232
|
+
if len(fused) >= limit:
|
|
233
|
+
break
|
|
234
|
+
return fused
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cosine_similarity(left: Sequence[float], right: Sequence[float]) -> float:
|
|
238
|
+
if len(left) != len(right):
|
|
239
|
+
raise ValueError("vector dimensions do not match")
|
|
240
|
+
numerator = sum(a * b for a, b in zip(left, right))
|
|
241
|
+
left_norm = math.sqrt(sum(value * value for value in left))
|
|
242
|
+
right_norm = math.sqrt(sum(value * value for value in right))
|
|
243
|
+
if left_norm == 0 or right_norm == 0:
|
|
244
|
+
return 0.0
|
|
245
|
+
return numerator / (left_norm * right_norm)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _vector(values: object) -> list[float]:
|
|
249
|
+
if hasattr(values, "tolist"):
|
|
250
|
+
values = values.tolist()
|
|
251
|
+
if not isinstance(values, Iterable):
|
|
252
|
+
raise TypeError("embedding adapter returned a non-iterable vector")
|
|
253
|
+
return [float(value) for value in cast(Iterable[Any], values)]
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .canonical import verify_canonical_source
|
|
12
|
+
from .index import build_index
|
|
13
|
+
from .privacy import SECRET_PATTERNS
|
|
14
|
+
from .query import QueryResult, query_memory
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class EvalCase:
|
|
19
|
+
id: str
|
|
20
|
+
query: str
|
|
21
|
+
expected_source: str
|
|
22
|
+
expected_terms: tuple[str, ...] = ()
|
|
23
|
+
forbidden_sources: tuple[str, ...] = ()
|
|
24
|
+
forbidden_terms: tuple[str, ...] = ()
|
|
25
|
+
expected_rank_at_most: int = 3
|
|
26
|
+
limit: int = 3
|
|
27
|
+
mutate_after_build: dict[str, str] | None = None
|
|
28
|
+
expect_stale: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_eval(
|
|
32
|
+
fixture_dir: Path | str,
|
|
33
|
+
golden_path: Path | str | None = None,
|
|
34
|
+
limit: int = 3,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
fixture_dir = Path(fixture_dir).expanduser().resolve()
|
|
37
|
+
golden_path = Path(golden_path).expanduser().resolve() if golden_path else fixture_dir.parent / "golden.jsonl"
|
|
38
|
+
cases = load_cases(golden_path)
|
|
39
|
+
|
|
40
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
41
|
+
work_fixture = Path(tmp) / "fixtures"
|
|
42
|
+
shutil.copytree(fixture_dir, work_fixture)
|
|
43
|
+
eval_db_path = Path(tmp) / "memory.db"
|
|
44
|
+
|
|
45
|
+
build_stats = build_index(
|
|
46
|
+
db_path=eval_db_path,
|
|
47
|
+
includes=[work_fixture.as_posix()],
|
|
48
|
+
workspace=work_fixture,
|
|
49
|
+
source_type="eval_fixture",
|
|
50
|
+
)
|
|
51
|
+
apply_mutations(work_fixture, cases)
|
|
52
|
+
|
|
53
|
+
rows = [
|
|
54
|
+
evaluate_case(
|
|
55
|
+
case=case,
|
|
56
|
+
db_path=eval_db_path,
|
|
57
|
+
workspace=work_fixture,
|
|
58
|
+
limit=max(limit, case.limit, case.expected_rank_at_most),
|
|
59
|
+
)
|
|
60
|
+
for case in cases
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
metrics = summarize(rows)
|
|
64
|
+
return {
|
|
65
|
+
"fixture_dir": fixture_dir.as_posix(),
|
|
66
|
+
"golden_path": golden_path.as_posix(),
|
|
67
|
+
"build": build_stats,
|
|
68
|
+
"metrics": metrics,
|
|
69
|
+
"cases": rows,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def evaluate_gates(
|
|
74
|
+
report: dict[str, Any],
|
|
75
|
+
min_recall_at_1: float | None = None,
|
|
76
|
+
min_recall_at_3: float | None = None,
|
|
77
|
+
min_source_accuracy: float | None = None,
|
|
78
|
+
min_snippet_term_rate: float | None = None,
|
|
79
|
+
min_stale_detection_rate: float | None = None,
|
|
80
|
+
max_privacy_leaks: int | None = None,
|
|
81
|
+
) -> list[str]:
|
|
82
|
+
metrics = report["metrics"]
|
|
83
|
+
failures: list[str] = []
|
|
84
|
+
threshold_checks = {
|
|
85
|
+
"recall_at_1": min_recall_at_1,
|
|
86
|
+
"recall_at_3": min_recall_at_3,
|
|
87
|
+
"source_accuracy": min_source_accuracy,
|
|
88
|
+
"snippet_term_rate": min_snippet_term_rate,
|
|
89
|
+
"stale_detection_rate": min_stale_detection_rate,
|
|
90
|
+
}
|
|
91
|
+
for metric, minimum in threshold_checks.items():
|
|
92
|
+
if minimum is not None and float(metrics[metric]) < minimum:
|
|
93
|
+
failures.append(f"{metric}={metrics[metric]} is below required {minimum}")
|
|
94
|
+
|
|
95
|
+
if max_privacy_leaks is not None and int(metrics["privacy_leak_count"]) > max_privacy_leaks:
|
|
96
|
+
failures.append(
|
|
97
|
+
f"privacy_leak_count={metrics['privacy_leak_count']} exceeds allowed {max_privacy_leaks}"
|
|
98
|
+
)
|
|
99
|
+
return failures
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_cases(golden_path: Path) -> list[EvalCase]:
|
|
103
|
+
cases: list[EvalCase] = []
|
|
104
|
+
for line_number, line in enumerate(golden_path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
105
|
+
if not line.strip():
|
|
106
|
+
continue
|
|
107
|
+
payload = json.loads(line)
|
|
108
|
+
try:
|
|
109
|
+
cases.append(
|
|
110
|
+
EvalCase(
|
|
111
|
+
id=payload["id"],
|
|
112
|
+
query=payload["query"],
|
|
113
|
+
expected_source=payload["expected_source"],
|
|
114
|
+
expected_terms=tuple(payload.get("expected_terms", ())),
|
|
115
|
+
forbidden_sources=tuple(payload.get("forbidden_sources", ())),
|
|
116
|
+
forbidden_terms=tuple(payload.get("forbidden_terms", ())),
|
|
117
|
+
expected_rank_at_most=int(payload.get("expected_rank_at_most", payload.get("limit", 3))),
|
|
118
|
+
limit=int(payload.get("limit", 3)),
|
|
119
|
+
mutate_after_build=payload.get("mutate_after_build"),
|
|
120
|
+
expect_stale=bool(payload.get("expect_stale", False)),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
except KeyError as exc:
|
|
124
|
+
raise ValueError(f"{golden_path}:{line_number} missing required field {exc}") from exc
|
|
125
|
+
if not cases:
|
|
126
|
+
raise ValueError(f"{golden_path} does not contain any eval cases")
|
|
127
|
+
return cases
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def apply_mutations(work_fixture: Path, cases: list[EvalCase]) -> None:
|
|
131
|
+
work_fixture = work_fixture.resolve()
|
|
132
|
+
for case in cases:
|
|
133
|
+
if not case.mutate_after_build:
|
|
134
|
+
continue
|
|
135
|
+
source_path = case.mutate_after_build["source_path"]
|
|
136
|
+
content = case.mutate_after_build["content"]
|
|
137
|
+
target = (work_fixture / source_path).resolve()
|
|
138
|
+
if not target.is_relative_to(work_fixture):
|
|
139
|
+
raise ValueError(f"mutation target escapes fixture directory: {source_path}")
|
|
140
|
+
target.write_text(content, encoding="utf-8")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def evaluate_case(
|
|
144
|
+
case: EvalCase,
|
|
145
|
+
db_path: Path,
|
|
146
|
+
workspace: Path,
|
|
147
|
+
limit: int,
|
|
148
|
+
) -> dict[str, Any]:
|
|
149
|
+
workspace = workspace.resolve()
|
|
150
|
+
results = query_memory(
|
|
151
|
+
db_path=db_path,
|
|
152
|
+
query=case.query,
|
|
153
|
+
limit=limit,
|
|
154
|
+
source_type="eval_fixture",
|
|
155
|
+
workspace=workspace,
|
|
156
|
+
)
|
|
157
|
+
expected_path = (workspace / case.expected_source).resolve().as_posix()
|
|
158
|
+
rank = rank_of_expected(results, expected_path)
|
|
159
|
+
top = results[0] if results else None
|
|
160
|
+
snippet_terms_present = terms_present(top, case.expected_terms) if top else False
|
|
161
|
+
privacy_leaks = count_privacy_leaks(results, case.forbidden_terms)
|
|
162
|
+
forbidden_source_ranks = ranked_forbidden_sources(results, workspace, case.forbidden_sources)
|
|
163
|
+
stale_detected = False
|
|
164
|
+
if case.expect_stale or case.forbidden_sources:
|
|
165
|
+
stale_detected = stale_sources_did_not_outrank_expected(rank, forbidden_source_ranks)
|
|
166
|
+
if case.expect_stale and top:
|
|
167
|
+
verification = verify_canonical_source(db_path, top.source_path)
|
|
168
|
+
stale_detected = stale_detected and verification.get("content_hash_match") is False
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"id": case.id,
|
|
172
|
+
"query": case.query,
|
|
173
|
+
"expected_source": case.expected_source,
|
|
174
|
+
"top_source": relative_source(top.source_path, workspace) if top else None,
|
|
175
|
+
"rank": rank,
|
|
176
|
+
"recall_at_1": rank == 1,
|
|
177
|
+
"recall_at_k": rank is not None and rank <= case.expected_rank_at_most,
|
|
178
|
+
"source_accuracy": top is not None and top.source_path == expected_path,
|
|
179
|
+
"snippet_terms_present": snippet_terms_present,
|
|
180
|
+
"privacy_leak_count": privacy_leaks,
|
|
181
|
+
"forbidden_source_ranks": forbidden_source_ranks,
|
|
182
|
+
"stale_expected": case.expect_stale or bool(case.forbidden_sources),
|
|
183
|
+
"stale_detected": stale_detected,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def rank_of_expected(results: list[QueryResult], expected_path: str) -> int | None:
|
|
188
|
+
for index, result in enumerate(results, start=1):
|
|
189
|
+
if result.source_path == expected_path:
|
|
190
|
+
return index
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def terms_present(result: QueryResult, terms: tuple[str, ...]) -> bool:
|
|
195
|
+
if not terms:
|
|
196
|
+
return True
|
|
197
|
+
haystack = normalize_text(f"{result.title}\n{result.snippet}")
|
|
198
|
+
return all(normalize_text(term) in haystack for term in terms)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def count_privacy_leaks(results: list[QueryResult], forbidden_terms: tuple[str, ...] = ()) -> int:
|
|
202
|
+
count = 0
|
|
203
|
+
for result in results:
|
|
204
|
+
for pattern in SECRET_PATTERNS:
|
|
205
|
+
for match in pattern.finditer(result.snippet):
|
|
206
|
+
if "[REDACTED]" not in match.group(0):
|
|
207
|
+
count += 1
|
|
208
|
+
lowered = result.snippet.lower()
|
|
209
|
+
for term in forbidden_terms:
|
|
210
|
+
if term.lower() in lowered:
|
|
211
|
+
count += 1
|
|
212
|
+
return count
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def ranked_forbidden_sources(
|
|
216
|
+
results: list[QueryResult],
|
|
217
|
+
workspace: Path,
|
|
218
|
+
sources: tuple[str, ...],
|
|
219
|
+
) -> dict[str, int | None]:
|
|
220
|
+
forbidden = set(sources)
|
|
221
|
+
ranks: dict[str, int | None] = {source: None for source in sources}
|
|
222
|
+
for result in results:
|
|
223
|
+
relative = relative_source(result.source_path, workspace)
|
|
224
|
+
if relative in forbidden:
|
|
225
|
+
ranks[relative] = rank_of_expected(results, result.source_path)
|
|
226
|
+
return ranks
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def stale_sources_did_not_outrank_expected(
|
|
230
|
+
expected_rank: int | None,
|
|
231
|
+
forbidden_source_ranks: dict[str, int | None],
|
|
232
|
+
) -> bool:
|
|
233
|
+
if expected_rank is None:
|
|
234
|
+
return False
|
|
235
|
+
return all(rank is None or rank > expected_rank for rank in forbidden_source_ranks.values())
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def relative_source(source_path: str, workspace: Path) -> str:
|
|
239
|
+
try:
|
|
240
|
+
return Path(source_path).resolve().relative_to(workspace).as_posix()
|
|
241
|
+
except ValueError:
|
|
242
|
+
return source_path
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def summarize(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|
246
|
+
total = len(rows)
|
|
247
|
+
stale_cases = [row for row in rows if row["stale_expected"]]
|
|
248
|
+
mrr_total = sum((1 / row["rank"]) for row in rows if row["rank"])
|
|
249
|
+
return {
|
|
250
|
+
"cases": total,
|
|
251
|
+
"recall_at_1": ratio(sum(1 for row in rows if row["recall_at_1"]), total),
|
|
252
|
+
"recall_at_3": ratio(sum(1 for row in rows if row["recall_at_k"]), total),
|
|
253
|
+
"mrr": ratio(mrr_total, total),
|
|
254
|
+
"source_accuracy": ratio(sum(1 for row in rows if row["source_accuracy"]), total),
|
|
255
|
+
"snippet_term_rate": ratio(sum(1 for row in rows if row["snippet_terms_present"]), total),
|
|
256
|
+
"privacy_leak_count": sum(row["privacy_leak_count"] for row in rows),
|
|
257
|
+
"stale_detection_rate": ratio(
|
|
258
|
+
sum(1 for row in stale_cases if row["stale_detected"]),
|
|
259
|
+
len(stale_cases),
|
|
260
|
+
),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def ratio(numerator: float, denominator: int) -> float:
|
|
265
|
+
if denominator == 0:
|
|
266
|
+
return 0.0
|
|
267
|
+
return round(numerator / denominator, 4)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def normalize_text(value: str) -> str:
|
|
271
|
+
return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
|