coderag-ai 0.3.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.
- coderag/__init__.py +12 -0
- coderag/analyzers/__init__.py +0 -0
- coderag/analyzers/base.py +36 -0
- coderag/analyzers/flake8_adapter.py +43 -0
- coderag/analyzers/pylint_adapter.py +50 -0
- coderag/analyzers/workflow.py +107 -0
- coderag/api/__init__.py +0 -0
- coderag/api/app.py +351 -0
- coderag/api/dashboard.py +237 -0
- coderag/api/schemas.py +181 -0
- coderag/cli/__init__.py +0 -0
- coderag/cli/main.py +359 -0
- coderag/context/__init__.py +0 -0
- coderag/context/builder.py +206 -0
- coderag/context/package.py +100 -0
- coderag/core/__init__.py +0 -0
- coderag/core/config.py +100 -0
- coderag/core/logging.py +58 -0
- coderag/core/text.py +80 -0
- coderag/core/tokens.py +66 -0
- coderag/db/__init__.py +0 -0
- coderag/db/base.py +58 -0
- coderag/db/migrations/env.py +77 -0
- coderag/db/migrations/script.py.mako +26 -0
- coderag/db/migrations/versions/.gitkeep +0 -0
- coderag/db/migrations/versions/9a973932b94d_measure_baseline_tokens.py +41 -0
- coderag/db/migrations/versions/c8a40feeda41_initial_schema.py +227 -0
- coderag/db/models.py +275 -0
- coderag/embeddings/__init__.py +0 -0
- coderag/embeddings/base.py +53 -0
- coderag/embeddings/hashing.py +43 -0
- coderag/embeddings/pipeline.py +87 -0
- coderag/embeddings/registry.py +38 -0
- coderag/embeddings/sentence_transformer.py +47 -0
- coderag/evaluation/__init__.py +0 -0
- coderag/evaluation/datasets.py +36 -0
- coderag/evaluation/harness.py +246 -0
- coderag/git/__init__.py +0 -0
- coderag/git/repo.py +139 -0
- coderag/indexing/__init__.py +0 -0
- coderag/indexing/ignore.py +101 -0
- coderag/indexing/indexer.py +445 -0
- coderag/llm/__init__.py +0 -0
- coderag/llm/anthropic.py +117 -0
- coderag/llm/base.py +60 -0
- coderag/llm/null.py +16 -0
- coderag/llm/registry.py +17 -0
- coderag/localdb.py +100 -0
- coderag/mcp_server.py +209 -0
- coderag/parsing/__init__.py +0 -0
- coderag/parsing/base.py +76 -0
- coderag/parsing/python.py +294 -0
- coderag/parsing/registry.py +40 -0
- coderag/retrieval/__init__.py +0 -0
- coderag/retrieval/base.py +60 -0
- coderag/retrieval/engine.py +176 -0
- coderag/retrieval/fusion.py +41 -0
- coderag/retrieval/graph.py +139 -0
- coderag/retrieval/lexical.py +57 -0
- coderag/retrieval/reranking.py +99 -0
- coderag/retrieval/semantic.py +53 -0
- coderag/retrieval/symbol.py +91 -0
- coderag/security/__init__.py +0 -0
- coderag/security/authz.py +35 -0
- coderag/security/secrets.py +110 -0
- coderag/service.py +160 -0
- coderag/telemetry.py +72 -0
- coderag_ai-0.3.0.dist-info/METADATA +364 -0
- coderag_ai-0.3.0.dist-info/RECORD +73 -0
- coderag_ai-0.3.0.dist-info/WHEEL +4 -0
- coderag_ai-0.3.0.dist-info/entry_points.txt +3 -0
- coderag_ai-0.3.0.dist-info/licenses/LICENSE +202 -0
- coderag_ai-0.3.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Embedding pipeline: embed a repository's symbols, cached by source hash.
|
|
2
|
+
|
|
3
|
+
Unchanged code is never re-embedded: an embedding is (re)computed only when no
|
|
4
|
+
row exists for (symbol, model, version) or the symbol's ``source_hash`` changed.
|
|
5
|
+
Embedding inputs carry structural context (repo/file/symbol/type/signature/
|
|
6
|
+
docstring/code), not just the raw body (spec §9).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import delete, select
|
|
12
|
+
from sqlalchemy.orm import Session
|
|
13
|
+
|
|
14
|
+
from coderag.core.logging import get_logger
|
|
15
|
+
from coderag.db.models import Repository, Symbol, SymbolEmbedding
|
|
16
|
+
from coderag.embeddings.base import EmbeddingProvider, build_embedding_input
|
|
17
|
+
|
|
18
|
+
log = get_logger("embeddings")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EmbeddingPipeline:
|
|
22
|
+
def __init__(self, session: Session, provider: EmbeddingProvider) -> None:
|
|
23
|
+
self.session = session
|
|
24
|
+
self.provider = provider
|
|
25
|
+
|
|
26
|
+
def embed_repository(self, repo: Repository) -> int:
|
|
27
|
+
model = self.provider.model_name
|
|
28
|
+
version = self.provider.model_version
|
|
29
|
+
|
|
30
|
+
existing: dict[int, str] = {
|
|
31
|
+
row[0]: row[1]
|
|
32
|
+
for row in self.session.execute(
|
|
33
|
+
select(SymbolEmbedding.symbol_id, SymbolEmbedding.source_hash).where(
|
|
34
|
+
SymbolEmbedding.repository_id == repo.id,
|
|
35
|
+
SymbolEmbedding.embedding_model == model,
|
|
36
|
+
SymbolEmbedding.embedding_version == version,
|
|
37
|
+
)
|
|
38
|
+
).all()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
symbols = list(
|
|
42
|
+
self.session.scalars(
|
|
43
|
+
select(Symbol).where(Symbol.repository_id == repo.id)
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
stale: list[Symbol] = [
|
|
47
|
+
s for s in symbols if existing.get(s.id) != s.source_hash
|
|
48
|
+
]
|
|
49
|
+
if not stale:
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
# Drop any prior (stale) rows for these symbols before re-inserting.
|
|
53
|
+
stale_ids = [s.id for s in stale]
|
|
54
|
+
self.session.execute(
|
|
55
|
+
delete(SymbolEmbedding).where(
|
|
56
|
+
SymbolEmbedding.symbol_id.in_(stale_ids),
|
|
57
|
+
SymbolEmbedding.embedding_model == model,
|
|
58
|
+
SymbolEmbedding.embedding_version == version,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
inputs = [
|
|
63
|
+
build_embedding_input(
|
|
64
|
+
repo.name, s.file_path, s.qualified_name, s.symbol_type,
|
|
65
|
+
s.signature, s.docstring, s.source_code,
|
|
66
|
+
)
|
|
67
|
+
for s in stale
|
|
68
|
+
]
|
|
69
|
+
vectors = self.provider.embed_documents(inputs)
|
|
70
|
+
for s, vec in zip(stale, vectors, strict=True):
|
|
71
|
+
self.session.add(
|
|
72
|
+
SymbolEmbedding(
|
|
73
|
+
repository_id=repo.id,
|
|
74
|
+
symbol_id=s.id,
|
|
75
|
+
source_hash=s.source_hash,
|
|
76
|
+
embedding_model=model,
|
|
77
|
+
embedding_version=version,
|
|
78
|
+
embedding_dimension=self.provider.dimension,
|
|
79
|
+
embedding=vec,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
self.session.flush()
|
|
83
|
+
log.info(
|
|
84
|
+
"embed_repository.done", repository=repo.name, model=model,
|
|
85
|
+
embedded=len(stale), skipped=len(symbols) - len(stale),
|
|
86
|
+
)
|
|
87
|
+
return len(stale)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Embedding provider selection + a tiny cache keyed by configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from coderag.core.config import Settings, get_settings
|
|
6
|
+
from coderag.embeddings.base import EmbeddingProvider
|
|
7
|
+
|
|
8
|
+
_cache: dict[tuple, EmbeddingProvider] = {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_embedding_provider(settings: Settings | None = None) -> EmbeddingProvider:
|
|
12
|
+
settings = settings or get_settings()
|
|
13
|
+
key = (
|
|
14
|
+
settings.embedding_provider,
|
|
15
|
+
settings.embedding_model,
|
|
16
|
+
settings.embedding_device,
|
|
17
|
+
settings.embedding_dimension,
|
|
18
|
+
)
|
|
19
|
+
if key in _cache:
|
|
20
|
+
return _cache[key]
|
|
21
|
+
|
|
22
|
+
if settings.embedding_provider == "sentence_transformer":
|
|
23
|
+
from coderag.embeddings.sentence_transformer import (
|
|
24
|
+
SentenceTransformerEmbeddingProvider,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
provider: EmbeddingProvider = SentenceTransformerEmbeddingProvider(
|
|
28
|
+
model_name=settings.embedding_model,
|
|
29
|
+
device=settings.embedding_device,
|
|
30
|
+
batch_size=settings.embedding_batch_size,
|
|
31
|
+
)
|
|
32
|
+
else:
|
|
33
|
+
from coderag.embeddings.hashing import HashingEmbeddingProvider
|
|
34
|
+
|
|
35
|
+
provider = HashingEmbeddingProvider(dimension=settings.embedding_dimension)
|
|
36
|
+
|
|
37
|
+
_cache[key] = provider
|
|
38
|
+
return provider
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Local SentenceTransformers embedding provider (optional `embeddings` extra).
|
|
2
|
+
|
|
3
|
+
Runs entirely on-box — no source code leaves your infrastructure. The model,
|
|
4
|
+
device, and batch size come from configuration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from coderag.embeddings.base import EmbeddingProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SentenceTransformerEmbeddingProvider(EmbeddingProvider):
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
model_name: str = "sentence-transformers/all-MiniLM-L6-v2",
|
|
16
|
+
device: str = "cpu",
|
|
17
|
+
batch_size: int = 32,
|
|
18
|
+
) -> None:
|
|
19
|
+
try:
|
|
20
|
+
from sentence_transformers import SentenceTransformer # noqa: PLC0415
|
|
21
|
+
except ImportError as exc: # pragma: no cover - optional dependency
|
|
22
|
+
raise RuntimeError(
|
|
23
|
+
"sentence-transformers is not installed. Install the 'embeddings' "
|
|
24
|
+
"extra: pip install 'coderag[embeddings]'"
|
|
25
|
+
) from exc
|
|
26
|
+
self._model = SentenceTransformer(model_name, device=device)
|
|
27
|
+
self.model_name = model_name
|
|
28
|
+
self.model_version = "st1"
|
|
29
|
+
self.batch_size = batch_size
|
|
30
|
+
self.dimension = int(self._model.get_sentence_embedding_dimension())
|
|
31
|
+
|
|
32
|
+
def embed_documents(self, texts: list[str]) -> list[list[float]]:
|
|
33
|
+
vectors = self._model.encode(
|
|
34
|
+
texts,
|
|
35
|
+
batch_size=self.batch_size,
|
|
36
|
+
normalize_embeddings=True,
|
|
37
|
+
convert_to_numpy=True,
|
|
38
|
+
show_progress_bar=False,
|
|
39
|
+
)
|
|
40
|
+
return [v.tolist() for v in vectors]
|
|
41
|
+
|
|
42
|
+
def embed_query(self, text: str) -> list[float]:
|
|
43
|
+
vector = self._model.encode(
|
|
44
|
+
[text], normalize_embeddings=True, convert_to_numpy=True,
|
|
45
|
+
show_progress_bar=False,
|
|
46
|
+
)[0]
|
|
47
|
+
return vector.tolist()
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Evaluation dataset format + loader.
|
|
2
|
+
|
|
3
|
+
A dataset is a JSON list of cases; each case has a ``question`` and the
|
|
4
|
+
``expected_symbols`` that should be retrieved. Expected symbols may be full
|
|
5
|
+
qualified names or suffixes (see matching in ``harness``). Point the eval at your
|
|
6
|
+
own dataset to measure retrieval on your codebase.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
DEMO_DATASET = str(
|
|
16
|
+
Path(__file__).resolve().parents[3] / "examples" / "eval" / "eval_dataset.json"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class EvalCase:
|
|
22
|
+
question: str
|
|
23
|
+
expected_symbols: list[str]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_dataset(path: str) -> list[EvalCase]:
|
|
27
|
+
data = json.loads(Path(path).read_text())
|
|
28
|
+
cases = []
|
|
29
|
+
for item in data:
|
|
30
|
+
cases.append(
|
|
31
|
+
EvalCase(
|
|
32
|
+
question=item["question"],
|
|
33
|
+
expected_symbols=list(item["expected_symbols"]),
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
return cases
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Evaluation harness.
|
|
2
|
+
|
|
3
|
+
Retrieval quality (Recall@K, MRR) + token accounting, and a measured comparison
|
|
4
|
+
of a naive baseline ("send whole files") against the budgeted Code-RAG context.
|
|
5
|
+
We report measured numbers only — no unverified savings claims (spec §18/§19).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import statistics
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import select
|
|
15
|
+
from sqlalchemy.orm import Session
|
|
16
|
+
|
|
17
|
+
from coderag.context.builder import ContextBuilder
|
|
18
|
+
from coderag.core.config import Settings, get_settings
|
|
19
|
+
from coderag.core.tokens import get_token_counter
|
|
20
|
+
from coderag.db.models import EvaluationRun, Repository, SourceFile
|
|
21
|
+
from coderag.evaluation.datasets import EvalCase
|
|
22
|
+
from coderag.git.repo import GitRepo
|
|
23
|
+
from coderag.retrieval.engine import build_engine
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _matches(qualified: str, expected: str) -> bool:
|
|
27
|
+
q, e = qualified.lower(), expected.lower()
|
|
28
|
+
return q == e or q.endswith("." + e) or e.endswith("." + q)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _first_hit_rank(candidate_quals: list[str], expected: list[str]) -> int | None:
|
|
32
|
+
for rank, qual in enumerate(candidate_quals, start=1):
|
|
33
|
+
if any(_matches(qual, e) for e in expected):
|
|
34
|
+
return rank
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class RetrievalMetrics:
|
|
40
|
+
n_cases: int = 0
|
|
41
|
+
recall_at: dict[int, float] = field(default_factory=dict)
|
|
42
|
+
mrr: float = 0.0
|
|
43
|
+
avg_candidate_tokens: float = 0.0
|
|
44
|
+
avg_context_tokens: float = 0.0
|
|
45
|
+
avg_retrieval_latency_ms: float = 0.0
|
|
46
|
+
per_case: list[dict] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def evaluate_retrieval(
|
|
50
|
+
session: Session,
|
|
51
|
+
repo: Repository,
|
|
52
|
+
cases: list[EvalCase],
|
|
53
|
+
settings: Settings | None = None,
|
|
54
|
+
ks: tuple[int, ...] = (1, 3, 5, 10),
|
|
55
|
+
*,
|
|
56
|
+
semantic: bool = True,
|
|
57
|
+
graph: bool = True,
|
|
58
|
+
rerank: bool = False,
|
|
59
|
+
) -> RetrievalMetrics:
|
|
60
|
+
settings = settings or get_settings()
|
|
61
|
+
engine = build_engine(
|
|
62
|
+
settings,
|
|
63
|
+
embedding_provider=_provider(settings) if semantic else None,
|
|
64
|
+
with_graph=graph,
|
|
65
|
+
with_reranker=rerank,
|
|
66
|
+
)
|
|
67
|
+
builder = ContextBuilder(session, settings=settings)
|
|
68
|
+
|
|
69
|
+
hits: dict[int, list[int]] = {k: [] for k in ks}
|
|
70
|
+
rrs: list[float] = []
|
|
71
|
+
cand_tokens: list[int] = []
|
|
72
|
+
ctx_tokens: list[int] = []
|
|
73
|
+
latencies: list[float] = []
|
|
74
|
+
per_case: list[dict] = []
|
|
75
|
+
|
|
76
|
+
for case in cases:
|
|
77
|
+
outcome = engine.search(session, repo.id, case.question, top_n=None)
|
|
78
|
+
quals = [c.qualified_name for c in outcome.candidates]
|
|
79
|
+
rank = _first_hit_rank(quals, case.expected_symbols)
|
|
80
|
+
for k in ks:
|
|
81
|
+
hits[k].append(1 if (rank is not None and rank <= k) else 0)
|
|
82
|
+
rrs.append(1.0 / rank if rank else 0.0)
|
|
83
|
+
latencies.append(outcome.latency_ms)
|
|
84
|
+
cand_tokens.append(sum(c.token_count for c in outcome.candidates))
|
|
85
|
+
pkg = builder.build(case.question, outcome.candidates, repo)
|
|
86
|
+
ctx_tokens.append(pkg.accounting.context_tokens)
|
|
87
|
+
per_case.append({
|
|
88
|
+
"question": case.question, "first_hit_rank": rank,
|
|
89
|
+
"candidate_tokens": cand_tokens[-1], "context_tokens": ctx_tokens[-1],
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
n = len(cases)
|
|
93
|
+
return RetrievalMetrics(
|
|
94
|
+
n_cases=n,
|
|
95
|
+
recall_at={k: (sum(hits[k]) / n if n else 0.0) for k in ks},
|
|
96
|
+
mrr=(sum(rrs) / n if n else 0.0),
|
|
97
|
+
avg_candidate_tokens=(statistics.mean(cand_tokens) if cand_tokens else 0.0),
|
|
98
|
+
avg_context_tokens=(statistics.mean(ctx_tokens) if ctx_tokens else 0.0),
|
|
99
|
+
avg_retrieval_latency_ms=(statistics.mean(latencies) if latencies else 0.0),
|
|
100
|
+
per_case=per_case,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class BaselineComparison:
|
|
106
|
+
n_cases: int
|
|
107
|
+
baseline_kind: str
|
|
108
|
+
avg_baseline_tokens: float
|
|
109
|
+
avg_topfiles_tokens: float
|
|
110
|
+
avg_rag_context_tokens: float
|
|
111
|
+
avg_rag_prompt_tokens: float
|
|
112
|
+
token_reduction_percent: float
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def compare_baseline(
|
|
116
|
+
session: Session,
|
|
117
|
+
repo: Repository,
|
|
118
|
+
cases: list[EvalCase],
|
|
119
|
+
settings: Settings | None = None,
|
|
120
|
+
topfiles: int = 3,
|
|
121
|
+
) -> BaselineComparison:
|
|
122
|
+
"""Compare Code-RAG context against naive baselines.
|
|
123
|
+
|
|
124
|
+
Headline baseline is **broad repository context** (send every source file) —
|
|
125
|
+
the thing you do without retrieval. We also report the "top-N whole files"
|
|
126
|
+
baseline. Savings scale with repository size: on a tiny demo the whole-repo
|
|
127
|
+
number is modest, but the *ratio* (RAG context vs the code you'd otherwise
|
|
128
|
+
paste) is what generalises.
|
|
129
|
+
"""
|
|
130
|
+
settings = settings or get_settings()
|
|
131
|
+
engine = build_engine(settings, embedding_provider=_provider(settings), with_graph=True)
|
|
132
|
+
builder = ContextBuilder(session, settings=settings)
|
|
133
|
+
counter = get_token_counter(settings)
|
|
134
|
+
git = GitRepo(repo.local_path)
|
|
135
|
+
|
|
136
|
+
file_cache: dict[str, int] = {}
|
|
137
|
+
|
|
138
|
+
def file_tokens(path: str) -> int:
|
|
139
|
+
if path not in file_cache:
|
|
140
|
+
file_cache[path] = counter.count(git.read_text(path) or "")
|
|
141
|
+
return file_cache[path]
|
|
142
|
+
|
|
143
|
+
all_files = list(
|
|
144
|
+
session.scalars(
|
|
145
|
+
select(SourceFile.path).where(SourceFile.repository_id == repo.id)
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
whole_repo_tokens = sum(file_tokens(f) for f in all_files)
|
|
149
|
+
|
|
150
|
+
topfiles_tok: list[int] = []
|
|
151
|
+
rag_ctx: list[int] = []
|
|
152
|
+
rag_prompt: list[int] = []
|
|
153
|
+
for case in cases:
|
|
154
|
+
outcome = engine.search(session, repo.id, case.question, top_n=None)
|
|
155
|
+
files: list[str] = []
|
|
156
|
+
for c in outcome.candidates:
|
|
157
|
+
if c.file_path not in files:
|
|
158
|
+
files.append(c.file_path)
|
|
159
|
+
if len(files) >= topfiles:
|
|
160
|
+
break
|
|
161
|
+
topfiles_tok.append(sum(file_tokens(f) for f in files))
|
|
162
|
+
pkg = builder.build(case.question, outcome.candidates, repo)
|
|
163
|
+
rag_ctx.append(pkg.accounting.context_tokens)
|
|
164
|
+
rag_prompt.append(pkg.accounting.final_prompt_tokens)
|
|
165
|
+
|
|
166
|
+
avg_ctx = statistics.mean(rag_ctx) if rag_ctx else 0.0
|
|
167
|
+
reduction = (
|
|
168
|
+
round(100.0 * (1.0 - avg_ctx / whole_repo_tokens), 1) if whole_repo_tokens else 0.0
|
|
169
|
+
)
|
|
170
|
+
return BaselineComparison(
|
|
171
|
+
n_cases=len(cases), baseline_kind="whole_repository",
|
|
172
|
+
avg_baseline_tokens=float(whole_repo_tokens),
|
|
173
|
+
avg_topfiles_tokens=(statistics.mean(topfiles_tok) if topfiles_tok else 0.0),
|
|
174
|
+
avg_rag_context_tokens=avg_ctx,
|
|
175
|
+
avg_rag_prompt_tokens=(statistics.mean(rag_prompt) if rag_prompt else 0.0),
|
|
176
|
+
token_reduction_percent=reduction,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class LatencyBenchmark:
|
|
182
|
+
n: int
|
|
183
|
+
p50_ms: float
|
|
184
|
+
p95_ms: float
|
|
185
|
+
mean_ms: float
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def benchmark_latency(
|
|
189
|
+
session: Session, repo: Repository, cases: list[EvalCase],
|
|
190
|
+
settings: Settings | None = None, repeats: int = 3,
|
|
191
|
+
) -> LatencyBenchmark:
|
|
192
|
+
settings = settings or get_settings()
|
|
193
|
+
engine = build_engine(settings, embedding_provider=_provider(settings), with_graph=True)
|
|
194
|
+
samples: list[float] = []
|
|
195
|
+
for _ in range(repeats):
|
|
196
|
+
for case in cases:
|
|
197
|
+
t = time.perf_counter()
|
|
198
|
+
engine.search(session, repo.id, case.question, top_n=10)
|
|
199
|
+
samples.append((time.perf_counter() - t) * 1000)
|
|
200
|
+
samples.sort()
|
|
201
|
+
|
|
202
|
+
def pct(p: float) -> float:
|
|
203
|
+
if not samples:
|
|
204
|
+
return 0.0
|
|
205
|
+
idx = min(len(samples) - 1, int(p * len(samples)))
|
|
206
|
+
return samples[idx]
|
|
207
|
+
|
|
208
|
+
return LatencyBenchmark(
|
|
209
|
+
n=len(samples), p50_ms=pct(0.50), p95_ms=pct(0.95),
|
|
210
|
+
mean_ms=(statistics.mean(samples) if samples else 0.0),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def persist_eval_run(
|
|
215
|
+
session: Session, name: str, dataset: str, metrics: RetrievalMetrics,
|
|
216
|
+
settings: Settings | None = None,
|
|
217
|
+
) -> EvaluationRun:
|
|
218
|
+
settings = settings or get_settings()
|
|
219
|
+
run = EvaluationRun(
|
|
220
|
+
name=name, dataset=dataset,
|
|
221
|
+
recall_at_1=metrics.recall_at.get(1, 0.0),
|
|
222
|
+
recall_at_3=metrics.recall_at.get(3, 0.0),
|
|
223
|
+
recall_at_5=metrics.recall_at.get(5, 0.0),
|
|
224
|
+
recall_at_10=metrics.recall_at.get(10, 0.0),
|
|
225
|
+
mrr=metrics.mrr,
|
|
226
|
+
avg_retrieved_tokens=metrics.avg_candidate_tokens,
|
|
227
|
+
avg_context_tokens=metrics.avg_context_tokens,
|
|
228
|
+
avg_retrieval_latency_ms=metrics.avg_retrieval_latency_ms,
|
|
229
|
+
config={
|
|
230
|
+
"rrf_k": settings.rrf_k,
|
|
231
|
+
"weights": {
|
|
232
|
+
"symbol": settings.weight_symbol, "lexical": settings.weight_lexical,
|
|
233
|
+
"semantic": settings.weight_semantic, "graph": settings.weight_graph,
|
|
234
|
+
},
|
|
235
|
+
"max_context_tokens": settings.max_context_tokens,
|
|
236
|
+
},
|
|
237
|
+
)
|
|
238
|
+
session.add(run)
|
|
239
|
+
session.flush()
|
|
240
|
+
return run
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _provider(settings: Settings):
|
|
244
|
+
from coderag.embeddings.registry import get_embedding_provider
|
|
245
|
+
|
|
246
|
+
return get_embedding_provider(settings)
|
coderag/git/__init__.py
ADDED
|
File without changes
|
coderag/git/repo.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Git-backed repository access.
|
|
2
|
+
|
|
3
|
+
For git repositories we enumerate with ``git ls-files`` so ``.gitignore`` and the
|
|
4
|
+
tracked/untracked distinction are honoured for free. For plain directories we
|
|
5
|
+
fall back to a filesystem walk. Diffs (base..head) power incremental indexing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DiffResult:
|
|
17
|
+
added: list[str] = field(default_factory=list)
|
|
18
|
+
modified: list[str] = field(default_factory=list)
|
|
19
|
+
deleted: list[str] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def changed(self) -> list[str]:
|
|
23
|
+
return self.added + self.modified
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GitRepo:
|
|
27
|
+
def __init__(self, path: str | Path) -> None:
|
|
28
|
+
self.path = Path(path).resolve()
|
|
29
|
+
|
|
30
|
+
# -- git helpers ------------------------------------------------------
|
|
31
|
+
def _git(self, *args: str) -> str:
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
["git", "-C", str(self.path), *args],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
check=True,
|
|
37
|
+
)
|
|
38
|
+
return result.stdout
|
|
39
|
+
|
|
40
|
+
def is_git(self) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
out = self._git("rev-parse", "--is-inside-work-tree").strip()
|
|
43
|
+
return out == "true"
|
|
44
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
def current_commit(self) -> str | None:
|
|
48
|
+
try:
|
|
49
|
+
return self._git("rev-parse", "HEAD").strip()
|
|
50
|
+
except subprocess.CalledProcessError:
|
|
51
|
+
return None # e.g. repo with no commits yet
|
|
52
|
+
|
|
53
|
+
def default_branch(self) -> str:
|
|
54
|
+
try:
|
|
55
|
+
return self._git("rev-parse", "--abbrev-ref", "HEAD").strip() or "main"
|
|
56
|
+
except subprocess.CalledProcessError:
|
|
57
|
+
return "main"
|
|
58
|
+
|
|
59
|
+
# -- enumeration ------------------------------------------------------
|
|
60
|
+
def list_files(self) -> list[str]:
|
|
61
|
+
"""Repository-relative paths of candidate files (POSIX separators).
|
|
62
|
+
|
|
63
|
+
For git repos we include tracked files *and* untracked files that are
|
|
64
|
+
not gitignored (``--exclude-standard``), so a working tree with
|
|
65
|
+
uncommitted files still indexes correctly while honouring .gitignore.
|
|
66
|
+
"""
|
|
67
|
+
if self.is_git():
|
|
68
|
+
tracked = self._git("ls-files", "-z").split("\0")
|
|
69
|
+
untracked = self._git(
|
|
70
|
+
"ls-files", "-z", "--others", "--exclude-standard"
|
|
71
|
+
).split("\0")
|
|
72
|
+
seen: dict[str, None] = {}
|
|
73
|
+
for p in (*tracked, *untracked):
|
|
74
|
+
if p:
|
|
75
|
+
seen[p] = None
|
|
76
|
+
return list(seen)
|
|
77
|
+
return self._walk()
|
|
78
|
+
|
|
79
|
+
def _walk(self) -> list[str]:
|
|
80
|
+
from coderag.indexing.ignore import DEFAULT_IGNORE_DIRS
|
|
81
|
+
|
|
82
|
+
files: list[str] = []
|
|
83
|
+
for p in sorted(self.path.rglob("*")):
|
|
84
|
+
if p.is_dir():
|
|
85
|
+
continue
|
|
86
|
+
rel = p.relative_to(self.path).as_posix()
|
|
87
|
+
if set(rel.split("/")[:-1]) & DEFAULT_IGNORE_DIRS:
|
|
88
|
+
continue
|
|
89
|
+
files.append(rel)
|
|
90
|
+
return files
|
|
91
|
+
|
|
92
|
+
# -- diff (incremental) ----------------------------------------------
|
|
93
|
+
def diff(self, base: str, head: str = "HEAD") -> DiffResult:
|
|
94
|
+
out = self._git("diff", "--name-status", "-z", base, head)
|
|
95
|
+
return _parse_name_status(out)
|
|
96
|
+
|
|
97
|
+
# -- reading ----------------------------------------------------------
|
|
98
|
+
def read_text(self, rel_path: str) -> str | None:
|
|
99
|
+
fp = self.path / rel_path
|
|
100
|
+
try:
|
|
101
|
+
data = fp.read_bytes()
|
|
102
|
+
except (OSError, FileNotFoundError):
|
|
103
|
+
return None
|
|
104
|
+
from coderag.indexing.ignore import looks_binary
|
|
105
|
+
|
|
106
|
+
if looks_binary(data):
|
|
107
|
+
return None
|
|
108
|
+
return data.decode("utf-8", errors="replace")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_name_status(z: str) -> DiffResult:
|
|
112
|
+
"""Parse ``git diff --name-status -z`` output.
|
|
113
|
+
|
|
114
|
+
Records are NUL-separated. Renames/copies (R/C) emit the status token, then
|
|
115
|
+
the old path, then the new path as separate records.
|
|
116
|
+
"""
|
|
117
|
+
res = DiffResult()
|
|
118
|
+
tokens = [t for t in z.split("\0") if t != ""]
|
|
119
|
+
i = 0
|
|
120
|
+
while i < len(tokens):
|
|
121
|
+
status = tokens[i]
|
|
122
|
+
code = status[0]
|
|
123
|
+
if code in ("R", "C"):
|
|
124
|
+
old, new = tokens[i + 1], tokens[i + 2]
|
|
125
|
+
res.deleted.append(old)
|
|
126
|
+
res.added.append(new)
|
|
127
|
+
i += 3
|
|
128
|
+
else:
|
|
129
|
+
path = tokens[i + 1]
|
|
130
|
+
if code == "A":
|
|
131
|
+
res.added.append(path)
|
|
132
|
+
elif code == "M":
|
|
133
|
+
res.modified.append(path)
|
|
134
|
+
elif code == "D":
|
|
135
|
+
res.deleted.append(path)
|
|
136
|
+
else: # T (type change), etc. -> treat as modified
|
|
137
|
+
res.modified.append(path)
|
|
138
|
+
i += 2
|
|
139
|
+
return res
|
|
File without changes
|