codebase-brain-mcp 0.1.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.
- codebase_brain_mcp/__init__.py +8 -0
- codebase_brain_mcp/__main__.py +118 -0
- codebase_brain_mcp/analysis.py +427 -0
- codebase_brain_mcp/brain.py +395 -0
- codebase_brain_mcp/chunking.py +196 -0
- codebase_brain_mcp/config.py +58 -0
- codebase_brain_mcp/embeddings.py +79 -0
- codebase_brain_mcp/evals.py +372 -0
- codebase_brain_mcp/history.py +214 -0
- codebase_brain_mcp/py.typed +0 -0
- codebase_brain_mcp/remote.py +112 -0
- codebase_brain_mcp/server.py +415 -0
- codebase_brain_mcp/sources.py +186 -0
- codebase_brain_mcp/store.py +349 -0
- codebase_brain_mcp-0.1.0.dist-info/METADATA +293 -0
- codebase_brain_mcp-0.1.0.dist-info/RECORD +19 -0
- codebase_brain_mcp-0.1.0.dist-info/WHEEL +4 -0
- codebase_brain_mcp-0.1.0.dist-info/entry_points.txt +4 -0
- codebase_brain_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Text embeddings. A real local model by default; a deterministic hashing embedder for tests and offline use."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .config import Settings
|
|
12
|
+
|
|
13
|
+
HASH_DIM = 512
|
|
14
|
+
_TOKEN = re.compile(r"[A-Za-z][a-z]+|[A-Z]+(?![a-z])|\d+")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Embedder(Protocol):
|
|
18
|
+
model_id: str
|
|
19
|
+
|
|
20
|
+
def embed_documents(self, texts: list[str]) -> np.ndarray: ...
|
|
21
|
+
|
|
22
|
+
def embed_query(self, text: str) -> np.ndarray: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FastEmbedEmbedder:
|
|
26
|
+
"""ONNX model run locally by fastembed: no API key, no network after the first download."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, model_name: str):
|
|
29
|
+
self.model_id = f"fastembed:{model_name}"
|
|
30
|
+
self._model_name = model_name
|
|
31
|
+
self._model = None
|
|
32
|
+
|
|
33
|
+
def _load(self):
|
|
34
|
+
if self._model is None:
|
|
35
|
+
from fastembed import TextEmbedding # slow import; only pay for it when embedding
|
|
36
|
+
|
|
37
|
+
self._model = TextEmbedding(model_name=self._model_name)
|
|
38
|
+
return self._model
|
|
39
|
+
|
|
40
|
+
def embed_documents(self, texts: list[str]) -> np.ndarray:
|
|
41
|
+
if not texts:
|
|
42
|
+
return np.zeros((0, 0), dtype=np.float32)
|
|
43
|
+
return _normalise(np.array(list(self._load().passage_embed(texts, batch_size=32)), dtype=np.float32))
|
|
44
|
+
|
|
45
|
+
def embed_query(self, text: str) -> np.ndarray:
|
|
46
|
+
return _normalise(np.array(list(self._load().query_embed(text)), dtype=np.float32))[0]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HashEmbedder:
|
|
50
|
+
"""Bag of words hashed into a fixed vector. Weak semantically, but fast, offline and deterministic."""
|
|
51
|
+
|
|
52
|
+
model_id = f"hash:{HASH_DIM}"
|
|
53
|
+
|
|
54
|
+
def embed_documents(self, texts: list[str]) -> np.ndarray:
|
|
55
|
+
return _normalise(np.stack([self._vector(t) for t in texts])) if texts else np.zeros((0, HASH_DIM), np.float32)
|
|
56
|
+
|
|
57
|
+
def embed_query(self, text: str) -> np.ndarray:
|
|
58
|
+
return _normalise(self._vector(text)[None, :])[0]
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _vector(text: str) -> np.ndarray:
|
|
62
|
+
vector = np.zeros(HASH_DIM, dtype=np.float32)
|
|
63
|
+
for token in _TOKEN.findall(text):
|
|
64
|
+
digest = hashlib.blake2b(token.lower().encode(), digest_size=4).digest()
|
|
65
|
+
vector[int.from_bytes(digest, "little") % HASH_DIM] += 1.0
|
|
66
|
+
return vector
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def make_embedder(settings: Settings) -> Embedder:
|
|
70
|
+
if settings.embedder == "hash":
|
|
71
|
+
return HashEmbedder()
|
|
72
|
+
if settings.embedder == "fastembed":
|
|
73
|
+
return FastEmbedEmbedder(settings.embed_model)
|
|
74
|
+
raise ValueError(f"Unknown CODEBASE_BRAIN_EMBEDDER {settings.embedder!r}; use 'fastembed' or 'hash'.")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _normalise(matrix: np.ndarray) -> np.ndarray:
|
|
78
|
+
norms = np.linalg.norm(matrix, axis=1, keepdims=True)
|
|
79
|
+
return matrix / np.where(norms == 0, 1.0, norms)
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Retrieval evaluation: does search put the right code in front of the model?
|
|
2
|
+
|
|
3
|
+
A dataset is a TOML file of queries, each with the chunks a good answer must come from, against a
|
|
4
|
+
repository pinned to one commit. For every search mode we measure where the first relevant result
|
|
5
|
+
lands, then report standard IR metrics with bootstrap confidence intervals, per-category breakdowns,
|
|
6
|
+
paired win/tie/loss counts between modes, and search latency.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import random
|
|
13
|
+
import statistics
|
|
14
|
+
import time
|
|
15
|
+
import tomllib
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import sources
|
|
21
|
+
from .brain import SEARCH_MODES, Brain, SearchMode
|
|
22
|
+
from .store import StoredChunk
|
|
23
|
+
|
|
24
|
+
CUTOFFS = (1, 3, 5, 10)
|
|
25
|
+
DEPTH = 10
|
|
26
|
+
"""How many results each query retrieves; every metric is computed at or below this depth."""
|
|
27
|
+
BOOTSTRAP_SAMPLES = 2_000
|
|
28
|
+
BOOTSTRAP_SEED = 7
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DatasetError(ValueError):
|
|
32
|
+
"""The dataset file is malformed, or names code that doesn't exist at the pinned commit."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Target:
|
|
37
|
+
path: str
|
|
38
|
+
symbol: str | None = None
|
|
39
|
+
|
|
40
|
+
def matches(self, chunk: StoredChunk) -> bool:
|
|
41
|
+
if chunk.path != self.path:
|
|
42
|
+
return False
|
|
43
|
+
if self.symbol is None:
|
|
44
|
+
return True
|
|
45
|
+
return chunk.symbol == self.symbol or (chunk.symbol or "").startswith(f"{self.symbol}.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Query:
|
|
50
|
+
id: str
|
|
51
|
+
category: str
|
|
52
|
+
text: str
|
|
53
|
+
targets: tuple[Target, ...]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Dataset:
|
|
58
|
+
name: str
|
|
59
|
+
repo: str
|
|
60
|
+
commit: str
|
|
61
|
+
queries: tuple[Query, ...]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class QueryResult:
|
|
66
|
+
query: Query
|
|
67
|
+
mode: str
|
|
68
|
+
first_relevant_rank: int | None
|
|
69
|
+
"""1-based rank of the first relevant result within DEPTH, or None if none was found."""
|
|
70
|
+
targets_found: int
|
|
71
|
+
ndcg: float
|
|
72
|
+
latency_ms: float
|
|
73
|
+
top: tuple[str, ...]
|
|
74
|
+
"""Citations of the top results, for reading failures."""
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def reciprocal_rank(self) -> float:
|
|
78
|
+
return 1.0 / self.first_relevant_rank if self.first_relevant_rank else 0.0
|
|
79
|
+
|
|
80
|
+
def hit(self, k: int) -> bool:
|
|
81
|
+
return self.first_relevant_rank is not None and self.first_relevant_rank <= k
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def recall(self) -> float:
|
|
85
|
+
return self.targets_found / len(self.query.targets)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class ModeSummary:
|
|
90
|
+
mode: str
|
|
91
|
+
queries: int
|
|
92
|
+
hit_at: dict[int, float]
|
|
93
|
+
mrr: float
|
|
94
|
+
ndcg: float
|
|
95
|
+
recall: float
|
|
96
|
+
mrr_ci: tuple[float, float]
|
|
97
|
+
hit5_ci: tuple[float, float]
|
|
98
|
+
latency_p50_ms: float
|
|
99
|
+
latency_p95_ms: float
|
|
100
|
+
by_category: dict[str, dict[str, float]] = field(default_factory=dict)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class EvalReport:
|
|
105
|
+
dataset: Dataset
|
|
106
|
+
embedder: str
|
|
107
|
+
summaries: dict[str, ModeSummary]
|
|
108
|
+
results: dict[str, list[QueryResult]]
|
|
109
|
+
paired: dict[str, tuple[int, int, int]]
|
|
110
|
+
"""'hybrid vs keyword' -> (wins, ties, losses) by reciprocal rank, per query."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---- dataset ----------------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_dataset(path: Path) -> Dataset:
|
|
117
|
+
try:
|
|
118
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
119
|
+
queries = tuple(
|
|
120
|
+
Query(
|
|
121
|
+
id=q["id"],
|
|
122
|
+
category=q["category"],
|
|
123
|
+
text=q["text"],
|
|
124
|
+
targets=tuple(Target(path=t["path"], symbol=t.get("symbol")) for t in q["targets"]),
|
|
125
|
+
)
|
|
126
|
+
for q in raw["query"]
|
|
127
|
+
)
|
|
128
|
+
dataset = Dataset(name=raw["name"], repo=raw["repo"], commit=raw["commit"], queries=queries)
|
|
129
|
+
except (OSError, tomllib.TOMLDecodeError, KeyError, TypeError) as exc:
|
|
130
|
+
raise DatasetError(f"Can't read dataset {path}: {exc}") from exc
|
|
131
|
+
ids = [q.id for q in dataset.queries]
|
|
132
|
+
if duplicates := sorted({i for i in ids if ids.count(i) > 1}):
|
|
133
|
+
raise DatasetError(f"Duplicate query ids: {', '.join(duplicates)}")
|
|
134
|
+
if empty := [q.id for q in dataset.queries if not q.targets]:
|
|
135
|
+
raise DatasetError(f"Queries without targets: {', '.join(empty)}")
|
|
136
|
+
return dataset
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def prepare(brain: Brain, dataset: Dataset) -> str:
|
|
140
|
+
"""Clone the repo, check out the pinned commit, index it, and check every target exists there.
|
|
141
|
+
Returns the index name."""
|
|
142
|
+
root = sources.resolve(dataset.repo, brain.settings.clone_dir / "evals")
|
|
143
|
+
sources.run_git(root, "checkout", "-q", "--detach", dataset.commit)
|
|
144
|
+
name = f"eval-{dataset.name}"
|
|
145
|
+
brain.index(str(root), name=name)
|
|
146
|
+
validate_targets(brain, name, dataset)
|
|
147
|
+
return name
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def validate_targets(brain: Brain, repo: str, dataset: Dataset) -> None:
|
|
151
|
+
"""Fail loudly if the dataset names files or symbols that aren't in the index (dataset rot)."""
|
|
152
|
+
store = brain.store_for(repo)
|
|
153
|
+
files = store.file_paths()
|
|
154
|
+
symbols = set(store.symbols())
|
|
155
|
+
missing = []
|
|
156
|
+
for query in dataset.queries:
|
|
157
|
+
for target in query.targets:
|
|
158
|
+
if target.path not in files:
|
|
159
|
+
missing.append(f"{query.id}: no file {target.path}")
|
|
160
|
+
elif target.symbol and (target.symbol, target.path) not in symbols:
|
|
161
|
+
missing.append(f"{query.id}: no symbol {target.symbol} in {target.path}")
|
|
162
|
+
if missing:
|
|
163
|
+
raise DatasetError("Dataset doesn't match the code:\n " + "\n ".join(missing))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---- running ----------------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run(brain: Brain, repo: str, dataset: Dataset, modes: Sequence[SearchMode] = SEARCH_MODES) -> EvalReport:
|
|
170
|
+
brain.search("warm up", repo=repo, k=1) # load the model before timing anything
|
|
171
|
+
results: dict[str, list[QueryResult]] = {}
|
|
172
|
+
for mode in modes:
|
|
173
|
+
results[mode] = [_run_query(brain, repo, query, mode) for query in dataset.queries]
|
|
174
|
+
summaries = {mode: summarise(mode, rows) for mode, rows in results.items()}
|
|
175
|
+
paired = {}
|
|
176
|
+
if "hybrid" in results:
|
|
177
|
+
for other in modes:
|
|
178
|
+
if other != "hybrid":
|
|
179
|
+
paired[f"hybrid vs {other}"] = compare(results["hybrid"], results[other])
|
|
180
|
+
return EvalReport(
|
|
181
|
+
dataset=dataset, embedder=brain.embedder.model_id, summaries=summaries, results=results, paired=paired
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _run_query(brain: Brain, repo: str, query: Query, mode: SearchMode) -> QueryResult:
|
|
186
|
+
started = time.perf_counter()
|
|
187
|
+
hits = brain.search(query.text, repo=repo, k=DEPTH, mode=mode)
|
|
188
|
+
latency = (time.perf_counter() - started) * 1000
|
|
189
|
+
chunks = [hit.chunk for hit in hits]
|
|
190
|
+
return QueryResult(
|
|
191
|
+
query=query,
|
|
192
|
+
mode=mode,
|
|
193
|
+
first_relevant_rank=first_relevant_rank(chunks, query.targets),
|
|
194
|
+
targets_found=sum(any(t.matches(c) for c in chunks) for t in query.targets),
|
|
195
|
+
ndcg=ndcg(chunks, query.targets, DEPTH),
|
|
196
|
+
latency_ms=latency,
|
|
197
|
+
top=tuple(c.citation for c in chunks[:3]),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---- metrics (pure functions) ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def first_relevant_rank(chunks: Sequence[StoredChunk], targets: Sequence[Target]) -> int | None:
|
|
205
|
+
for rank, chunk in enumerate(chunks, start=1):
|
|
206
|
+
if any(t.matches(chunk) for t in targets):
|
|
207
|
+
return rank
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def ndcg(chunks: Sequence[StoredChunk], targets: Sequence[Target], k: int) -> float:
|
|
212
|
+
"""Binary-gain nDCG@k. Each target earns credit once, at the first result that matches it, so
|
|
213
|
+
returning five chunks of the same function doesn't inflate the score."""
|
|
214
|
+
credited: set[int] = set()
|
|
215
|
+
dcg = 0.0
|
|
216
|
+
for rank, chunk in enumerate(chunks[:k], start=1):
|
|
217
|
+
newly = [i for i, t in enumerate(targets) if i not in credited and t.matches(chunk)]
|
|
218
|
+
if newly:
|
|
219
|
+
credited.add(newly[0])
|
|
220
|
+
dcg += 1.0 / math.log2(rank + 1)
|
|
221
|
+
ideal = sum(1.0 / math.log2(rank + 1) for rank in range(1, min(len(targets), k) + 1))
|
|
222
|
+
return dcg / ideal if ideal else 0.0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def percentile(values: Sequence[float], q: float) -> float:
|
|
226
|
+
"""Linear-interpolated percentile, q in [0, 100]."""
|
|
227
|
+
if not values:
|
|
228
|
+
return 0.0
|
|
229
|
+
ordered = sorted(values)
|
|
230
|
+
position = (len(ordered) - 1) * q / 100
|
|
231
|
+
low, high = math.floor(position), math.ceil(position)
|
|
232
|
+
return ordered[low] + (ordered[high] - ordered[low]) * (position - low)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def bootstrap_ci(values: Sequence[float], samples: int = BOOTSTRAP_SAMPLES, seed: int = BOOTSTRAP_SEED):
|
|
236
|
+
"""95% percentile-bootstrap confidence interval of the mean, resampling queries."""
|
|
237
|
+
if not values:
|
|
238
|
+
return (0.0, 0.0)
|
|
239
|
+
rng = random.Random(seed) # noqa: S311 (statistics, not security: a seeded RNG keeps CIs reproducible)
|
|
240
|
+
n = len(values)
|
|
241
|
+
means = [statistics.fmean(rng.choices(values, k=n)) for _ in range(samples)]
|
|
242
|
+
return (percentile(means, 2.5), percentile(means, 97.5))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def summarise(mode: str, rows: Sequence[QueryResult]) -> ModeSummary:
|
|
246
|
+
rr = [r.reciprocal_rank for r in rows]
|
|
247
|
+
hit5 = [float(r.hit(5)) for r in rows]
|
|
248
|
+
latencies = [r.latency_ms for r in rows]
|
|
249
|
+
by_category: dict[str, dict[str, float]] = {}
|
|
250
|
+
for category in sorted({r.query.category for r in rows}):
|
|
251
|
+
subset = [r for r in rows if r.query.category == category]
|
|
252
|
+
by_category[category] = {
|
|
253
|
+
"queries": len(subset),
|
|
254
|
+
"hit@1": statistics.fmean(float(r.hit(1)) for r in subset),
|
|
255
|
+
"hit@5": statistics.fmean(float(r.hit(5)) for r in subset),
|
|
256
|
+
"mrr": statistics.fmean(r.reciprocal_rank for r in subset),
|
|
257
|
+
}
|
|
258
|
+
return ModeSummary(
|
|
259
|
+
mode=mode,
|
|
260
|
+
queries=len(rows),
|
|
261
|
+
hit_at={k: statistics.fmean(float(r.hit(k)) for r in rows) for k in CUTOFFS},
|
|
262
|
+
mrr=statistics.fmean(rr),
|
|
263
|
+
ndcg=statistics.fmean(r.ndcg for r in rows),
|
|
264
|
+
recall=statistics.fmean(r.recall for r in rows),
|
|
265
|
+
mrr_ci=bootstrap_ci(rr),
|
|
266
|
+
hit5_ci=bootstrap_ci(hit5),
|
|
267
|
+
latency_p50_ms=percentile(latencies, 50),
|
|
268
|
+
latency_p95_ms=percentile(latencies, 95),
|
|
269
|
+
by_category=by_category,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def compare(a: Sequence[QueryResult], b: Sequence[QueryResult]) -> tuple[int, int, int]:
|
|
274
|
+
"""Per-query wins, ties and losses of ``a`` against ``b`` by reciprocal rank."""
|
|
275
|
+
wins = ties = losses = 0
|
|
276
|
+
for x, y in zip(a, b, strict=True):
|
|
277
|
+
if math.isclose(x.reciprocal_rank, y.reciprocal_rank):
|
|
278
|
+
ties += 1
|
|
279
|
+
elif x.reciprocal_rank > y.reciprocal_rank:
|
|
280
|
+
wins += 1
|
|
281
|
+
else:
|
|
282
|
+
losses += 1
|
|
283
|
+
return wins, ties, losses
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---- reporting --------------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def to_markdown(report: EvalReport) -> str:
|
|
290
|
+
d = report.dataset
|
|
291
|
+
lines = [
|
|
292
|
+
f"## Retrieval eval: {d.name} @ `{d.commit[:10]}`",
|
|
293
|
+
"",
|
|
294
|
+
f"{len(d.queries)} queries · embedder `{report.embedder}` · top {DEPTH} results per query · "
|
|
295
|
+
f"95% bootstrap CIs over queries ({BOOTSTRAP_SAMPLES} resamples)",
|
|
296
|
+
"",
|
|
297
|
+
"| Mode | Hit@1 | Hit@3 | Hit@5 | Hit@10 | MRR@10 (95% CI) | nDCG@10 | Recall@10 | p50 / p95 latency |",
|
|
298
|
+
"|---|---|---|---|---|---|---|---|---|",
|
|
299
|
+
]
|
|
300
|
+
for s in report.summaries.values():
|
|
301
|
+
lines.append(
|
|
302
|
+
f"| **{s.mode}** | {_pct(s.hit_at[1])} | {_pct(s.hit_at[3])} | {_pct(s.hit_at[5])} | "
|
|
303
|
+
f"{_pct(s.hit_at[10])} | {s.mrr:.3f} ({s.mrr_ci[0]:.3f}–{s.mrr_ci[1]:.3f}) | {s.ndcg:.3f} | "
|
|
304
|
+
f"{_pct(s.recall)} | {s.latency_p50_ms:.0f} / {s.latency_p95_ms:.0f} ms |"
|
|
305
|
+
)
|
|
306
|
+
categories = sorted({c for s in report.summaries.values() for c in s.by_category})
|
|
307
|
+
lines += [
|
|
308
|
+
"",
|
|
309
|
+
"**By query type** (Hit@5 / MRR)",
|
|
310
|
+
"",
|
|
311
|
+
"| Mode | "
|
|
312
|
+
+ " | ".join(
|
|
313
|
+
f"{c} ({int(next(iter(report.summaries.values())).by_category[c]['queries'])})" for c in categories
|
|
314
|
+
)
|
|
315
|
+
+ " |",
|
|
316
|
+
"|---|" + "---|" * len(categories),
|
|
317
|
+
]
|
|
318
|
+
for s in report.summaries.values():
|
|
319
|
+
cells = [f"{_pct(s.by_category[c]['hit@5'])} / {s.by_category[c]['mrr']:.2f}" for c in categories]
|
|
320
|
+
lines.append(f"| {s.mode} | " + " | ".join(cells) + " |")
|
|
321
|
+
if report.paired:
|
|
322
|
+
lines += ["", "**Paired, per query** (by reciprocal rank)", ""]
|
|
323
|
+
for name, (wins, ties, losses) in report.paired.items():
|
|
324
|
+
lines.append(f"- {name}: {wins} better, {ties} same, {losses} worse")
|
|
325
|
+
misses = [r for r in report.results.get("hybrid", []) if not r.hit(5)]
|
|
326
|
+
if misses:
|
|
327
|
+
lines += ["", "**Hybrid misses (not in top 5)**", ""]
|
|
328
|
+
for r in misses:
|
|
329
|
+
got = ", ".join(f"`{c}`" for c in r.top) or "nothing"
|
|
330
|
+
lines.append(f'- `{r.query.id}` "{r.query.text}": top results {got}')
|
|
331
|
+
return "\n".join(lines) + "\n"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def to_json(report: EvalReport) -> dict:
|
|
335
|
+
return {
|
|
336
|
+
"dataset": report.dataset.name,
|
|
337
|
+
"commit": report.dataset.commit,
|
|
338
|
+
"embedder": report.embedder,
|
|
339
|
+
"depth": DEPTH,
|
|
340
|
+
"summaries": {
|
|
341
|
+
mode: {
|
|
342
|
+
"hit_at": {str(k): v for k, v in s.hit_at.items()},
|
|
343
|
+
"mrr": s.mrr,
|
|
344
|
+
"mrr_ci": list(s.mrr_ci),
|
|
345
|
+
"hit5_ci": list(s.hit5_ci),
|
|
346
|
+
"ndcg": s.ndcg,
|
|
347
|
+
"recall": s.recall,
|
|
348
|
+
"latency_p50_ms": s.latency_p50_ms,
|
|
349
|
+
"latency_p95_ms": s.latency_p95_ms,
|
|
350
|
+
"by_category": s.by_category,
|
|
351
|
+
}
|
|
352
|
+
for mode, s in report.summaries.items()
|
|
353
|
+
},
|
|
354
|
+
"paired": {name: dict(zip(("wins", "ties", "losses"), v, strict=True)) for name, v in report.paired.items()},
|
|
355
|
+
"queries": {
|
|
356
|
+
mode: [
|
|
357
|
+
{
|
|
358
|
+
"id": r.query.id,
|
|
359
|
+
"category": r.query.category,
|
|
360
|
+
"rank": r.first_relevant_rank,
|
|
361
|
+
"ndcg": round(r.ndcg, 4),
|
|
362
|
+
"top": list(r.top),
|
|
363
|
+
}
|
|
364
|
+
for r in rows
|
|
365
|
+
]
|
|
366
|
+
for mode, rows in report.results.items()
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _pct(value: float) -> str:
|
|
372
|
+
return f"{value * 100:.0f}%"
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Read a repository's history with git: commits, who wrote which lines, how a line range evolved, and PRs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .sources import SourceError, run_git
|
|
15
|
+
|
|
16
|
+
FIELD = "\x1f"
|
|
17
|
+
RECORD = "\x1e"
|
|
18
|
+
COMMIT_MARKER = "\x1dcommit\x1d"
|
|
19
|
+
BODY_CHARS = 1_200
|
|
20
|
+
DIFF_CHARS = 1_500
|
|
21
|
+
GITHUB_TIMEOUT_SECONDS = 8
|
|
22
|
+
_PR_PATTERNS = (re.compile(r"\(#(\d{1,7})\)"), re.compile(r"Merge pull request #(\d{1,7})"))
|
|
23
|
+
_GITHUB_REMOTE = re.compile(r"github\.com[:/]([A-Za-z0-9-]+)/([A-Za-z0-9._-]+?)(?:\.git)?/?$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Commit:
|
|
28
|
+
sha: str
|
|
29
|
+
author: str
|
|
30
|
+
date: str
|
|
31
|
+
"""ISO 8601, author date."""
|
|
32
|
+
subject: str
|
|
33
|
+
body: str
|
|
34
|
+
prs: tuple[int, ...] = ()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class LineChange:
|
|
39
|
+
commit: Commit
|
|
40
|
+
diff: str
|
|
41
|
+
"""The part of the commit's diff that touched the range, trimmed."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class BlameShare:
|
|
46
|
+
sha: str
|
|
47
|
+
author: str
|
|
48
|
+
date: str
|
|
49
|
+
subject: str
|
|
50
|
+
lines: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class PullRequest:
|
|
55
|
+
number: int
|
|
56
|
+
url: str
|
|
57
|
+
title: str | None = None
|
|
58
|
+
body: str | None = None
|
|
59
|
+
state: str | None = None
|
|
60
|
+
merged_at: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_git_repo(root: Path) -> bool:
|
|
64
|
+
try:
|
|
65
|
+
return run_git(root, "rev-parse", "--is-inside-work-tree").strip() == "true"
|
|
66
|
+
except SourceError:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def commits(root: Path, since: str | None, limit: int) -> list[Commit]:
|
|
71
|
+
"""Commits reachable from HEAD, newest first; only those after ``since`` when it's an ancestor of HEAD."""
|
|
72
|
+
revision = "HEAD"
|
|
73
|
+
if since and _is_ancestor(root, since):
|
|
74
|
+
revision = f"{since}..HEAD"
|
|
75
|
+
out = run_git(
|
|
76
|
+
root, "log", revision, f"--max-count={limit}", f"--format=%H{FIELD}%an{FIELD}%aI{FIELD}%s{FIELD}%b{RECORD}"
|
|
77
|
+
)
|
|
78
|
+
return [_parse_commit(record) for record in out.split(RECORD) if record.strip()]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def line_history(root: Path, path: str, start: int, end: int, limit: int) -> list[LineChange]:
|
|
82
|
+
"""Commits that changed lines start..end of ``path`` (following the lines as they moved), newest first."""
|
|
83
|
+
out = run_git(
|
|
84
|
+
root,
|
|
85
|
+
"log",
|
|
86
|
+
f"-L{start},{end}:{path}",
|
|
87
|
+
f"--max-count={limit}",
|
|
88
|
+
f"--format={COMMIT_MARKER}%H{FIELD}%an{FIELD}%aI{FIELD}%s{FIELD}%b{RECORD}",
|
|
89
|
+
)
|
|
90
|
+
changes = []
|
|
91
|
+
for block in out.split(COMMIT_MARKER):
|
|
92
|
+
if not block.strip() or RECORD not in block:
|
|
93
|
+
continue
|
|
94
|
+
header, diff = block.split(RECORD, 1)
|
|
95
|
+
changes.append(LineChange(commit=_parse_commit(header), diff=_trim(diff.strip(), DIFF_CHARS)))
|
|
96
|
+
return changes
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def file_history(root: Path, path: str, limit: int) -> list[LineChange]:
|
|
100
|
+
"""Commits that touched ``path`` at all (following renames), newest first, without diffs."""
|
|
101
|
+
out = run_git(
|
|
102
|
+
root,
|
|
103
|
+
"log",
|
|
104
|
+
"--follow",
|
|
105
|
+
f"--max-count={limit}",
|
|
106
|
+
f"--format=%H{FIELD}%an{FIELD}%aI{FIELD}%s{FIELD}%b{RECORD}",
|
|
107
|
+
"--",
|
|
108
|
+
path,
|
|
109
|
+
)
|
|
110
|
+
return [LineChange(commit=_parse_commit(r), diff="") for r in out.split(RECORD) if r.strip()]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def blame(root: Path, path: str, start: int, end: int) -> list[BlameShare]:
|
|
114
|
+
"""Which commits the current lines come from, biggest share first."""
|
|
115
|
+
out = run_git(root, "blame", "--line-porcelain", f"-L{start},{end}", "--", path)
|
|
116
|
+
counts: Counter[str] = Counter()
|
|
117
|
+
info: dict[str, dict[str, str]] = {}
|
|
118
|
+
sha = None
|
|
119
|
+
for line in out.splitlines():
|
|
120
|
+
if re.match(r"^[0-9a-f]{40} ", line):
|
|
121
|
+
sha = line.split(" ", 1)[0]
|
|
122
|
+
counts[sha] += 1
|
|
123
|
+
info.setdefault(sha, {})
|
|
124
|
+
elif sha and line.startswith(("author ", "author-time ", "summary ")):
|
|
125
|
+
key, value = line.split(" ", 1)
|
|
126
|
+
info[sha].setdefault(key, value)
|
|
127
|
+
shares = []
|
|
128
|
+
for sha, lines in counts.most_common():
|
|
129
|
+
meta = info[sha]
|
|
130
|
+
shares.append(
|
|
131
|
+
BlameShare(
|
|
132
|
+
sha=sha,
|
|
133
|
+
author=meta.get("author", ""),
|
|
134
|
+
date=_epoch_to_iso(meta.get("author-time")),
|
|
135
|
+
subject=meta.get("summary", ""),
|
|
136
|
+
lines=lines,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
return shares
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def github_slug(root: Path) -> str | None:
|
|
143
|
+
"""``owner/repo`` when the origin remote is on GitHub."""
|
|
144
|
+
try:
|
|
145
|
+
url = run_git(root, "remote", "get-url", "origin").strip()
|
|
146
|
+
except SourceError:
|
|
147
|
+
return None
|
|
148
|
+
match = _GITHUB_REMOTE.search(url)
|
|
149
|
+
return f"{match.group(1)}/{match.group(2)}" if match else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def pr_numbers(message: str) -> tuple[int, ...]:
|
|
153
|
+
found: list[int] = []
|
|
154
|
+
for pattern in _PR_PATTERNS:
|
|
155
|
+
for match in pattern.finditer(message):
|
|
156
|
+
number = int(match.group(1))
|
|
157
|
+
if number not in found:
|
|
158
|
+
found.append(number)
|
|
159
|
+
return tuple(found)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def fetch_pull_request(slug: str, number: int, token: str | None) -> PullRequest:
|
|
163
|
+
"""The PR's title and description from the GitHub API. On any failure, just the link."""
|
|
164
|
+
url = f"https://github.com/{slug}/pull/{number}"
|
|
165
|
+
request = urllib.request.Request(
|
|
166
|
+
f"https://api.github.com/repos/{slug}/pulls/{number}",
|
|
167
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": "codebase-brain-mcp"},
|
|
168
|
+
)
|
|
169
|
+
if token:
|
|
170
|
+
request.add_header("Authorization", f"Bearer {token}")
|
|
171
|
+
try:
|
|
172
|
+
with urllib.request.urlopen(request, timeout=GITHUB_TIMEOUT_SECONDS) as response: # noqa: S310 (fixed host)
|
|
173
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
174
|
+
except (urllib.error.URLError, TimeoutError, ValueError, OSError):
|
|
175
|
+
return PullRequest(number=number, url=url)
|
|
176
|
+
return PullRequest(
|
|
177
|
+
number=number,
|
|
178
|
+
url=url,
|
|
179
|
+
title=data.get("title"),
|
|
180
|
+
body=_trim(data.get("body") or "", BODY_CHARS),
|
|
181
|
+
state=data.get("state"),
|
|
182
|
+
merged_at=data.get("merged_at"),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_commit(record: str) -> Commit:
|
|
187
|
+
sha, author, date, subject, body = (record.strip("\n").split(FIELD) + [""] * 5)[:5]
|
|
188
|
+
body = body.strip()
|
|
189
|
+
return Commit(
|
|
190
|
+
sha=sha.strip(),
|
|
191
|
+
author=author,
|
|
192
|
+
date=date,
|
|
193
|
+
subject=subject,
|
|
194
|
+
body=_trim(body, BODY_CHARS),
|
|
195
|
+
prs=pr_numbers(f"{subject}\n{body}"),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _is_ancestor(root: Path, sha: str) -> bool:
|
|
200
|
+
try:
|
|
201
|
+
run_git(root, "merge-base", "--is-ancestor", sha, "HEAD")
|
|
202
|
+
return True
|
|
203
|
+
except SourceError:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _trim(text: str, limit: int) -> str:
|
|
208
|
+
return text if len(text) <= limit else text[:limit].rstrip() + "\n…"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _epoch_to_iso(value: str | None) -> str:
|
|
212
|
+
if not value:
|
|
213
|
+
return ""
|
|
214
|
+
return datetime.fromtimestamp(int(value), UTC).isoformat(timespec="seconds")
|
|
File without changes
|