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,101 @@
|
|
|
1
|
+
"""Which files to index.
|
|
2
|
+
|
|
3
|
+
Responsibilities:
|
|
4
|
+
* skip vendored/generated/build directories (a configurable default list),
|
|
5
|
+
* skip binary files,
|
|
6
|
+
* skip secret files (delegated to ``security.secrets``),
|
|
7
|
+
* map file extensions to a language.
|
|
8
|
+
|
|
9
|
+
`.gitignore` is respected automatically for git repositories because we
|
|
10
|
+
enumerate via ``git ls-files`` (tracked files only) — see ``git/repo.py``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from coderag.security.secrets import is_secret_file
|
|
16
|
+
|
|
17
|
+
DEFAULT_IGNORE_DIRS: frozenset[str] = frozenset(
|
|
18
|
+
{
|
|
19
|
+
"node_modules",
|
|
20
|
+
"vendor",
|
|
21
|
+
"dist",
|
|
22
|
+
"build",
|
|
23
|
+
".venv",
|
|
24
|
+
"venv",
|
|
25
|
+
"__pycache__",
|
|
26
|
+
"generated",
|
|
27
|
+
"migrations",
|
|
28
|
+
".git",
|
|
29
|
+
".mypy_cache",
|
|
30
|
+
".ruff_cache",
|
|
31
|
+
".pytest_cache",
|
|
32
|
+
".tox",
|
|
33
|
+
".idea",
|
|
34
|
+
".vscode",
|
|
35
|
+
"target",
|
|
36
|
+
"site-packages",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
BINARY_EXTENSIONS: frozenset[str] = frozenset(
|
|
41
|
+
{
|
|
42
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".pdf",
|
|
43
|
+
".zip", ".gz", ".tar", ".tgz", ".bz2", ".7z", ".rar",
|
|
44
|
+
".so", ".dylib", ".dll", ".a", ".o", ".class", ".jar", ".pyc", ".pyd",
|
|
45
|
+
".exe", ".bin", ".wasm", ".woff", ".woff2", ".ttf", ".eot",
|
|
46
|
+
".mp3", ".mp4", ".mov", ".avi", ".wav", ".ogg", ".webp",
|
|
47
|
+
".parquet", ".pkl", ".pt", ".onnx", ".npy", ".npz", ".db", ".sqlite",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# extension -> language name
|
|
52
|
+
LANGUAGE_BY_EXTENSION: dict[str, str] = {
|
|
53
|
+
".py": "python",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def language_for_path(path: str) -> str | None:
|
|
58
|
+
dot = path.rfind(".")
|
|
59
|
+
if dot == -1:
|
|
60
|
+
return None
|
|
61
|
+
return LANGUAGE_BY_EXTENSION.get(path[dot:].lower())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_binary_extension(path: str) -> bool:
|
|
65
|
+
dot = path.rfind(".")
|
|
66
|
+
return dot != -1 and path[dot:].lower() in BINARY_EXTENSIONS
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def in_ignored_dir(path: str, extra: frozenset[str] | None = None) -> bool:
|
|
70
|
+
parts = path.replace("\\", "/").split("/")
|
|
71
|
+
dirs = set(parts[:-1])
|
|
72
|
+
if dirs & DEFAULT_IGNORE_DIRS:
|
|
73
|
+
return True
|
|
74
|
+
if extra:
|
|
75
|
+
for frag in extra:
|
|
76
|
+
if frag and frag.strip("/") in dirs:
|
|
77
|
+
return True
|
|
78
|
+
for frag in extra:
|
|
79
|
+
if frag and frag in path:
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def should_index(path: str, extra_ignore: list[str] | None = None) -> bool:
|
|
85
|
+
"""Return True if the path is an indexable source file.
|
|
86
|
+
|
|
87
|
+
Skips ignored dirs, secret files, binaries, and files of unknown language.
|
|
88
|
+
"""
|
|
89
|
+
extra = frozenset(extra_ignore or [])
|
|
90
|
+
if in_ignored_dir(path, extra):
|
|
91
|
+
return False
|
|
92
|
+
if is_secret_file(path):
|
|
93
|
+
return False
|
|
94
|
+
if is_binary_extension(path):
|
|
95
|
+
return False
|
|
96
|
+
return language_for_path(path) is not None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def looks_binary(content: bytes) -> bool:
|
|
100
|
+
"""Heuristic: a NUL byte in the first chunk means binary."""
|
|
101
|
+
return b"\x00" in content[:8192]
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Repository indexing.
|
|
2
|
+
|
|
3
|
+
Full index: enumerate files (git-aware, .gitignore-respecting) -> filter
|
|
4
|
+
ignored/secret/binary -> redact residual secrets -> parse into symbols ->
|
|
5
|
+
persist. Embedding generation is layered on in Phase 3; incremental indexing in
|
|
6
|
+
Phase 8.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from sqlalchemy import delete, func, select
|
|
16
|
+
from sqlalchemy.orm import Session
|
|
17
|
+
|
|
18
|
+
from coderag.core.config import Settings, get_settings
|
|
19
|
+
from coderag.core.logging import get_logger
|
|
20
|
+
from coderag.core.tokens import TokenCounter, get_token_counter
|
|
21
|
+
from coderag.db.models import (
|
|
22
|
+
IndexingRun,
|
|
23
|
+
Repository,
|
|
24
|
+
SourceFile,
|
|
25
|
+
Symbol,
|
|
26
|
+
SymbolRelationship,
|
|
27
|
+
)
|
|
28
|
+
from coderag.git.repo import GitRepo
|
|
29
|
+
from coderag.indexing.ignore import should_index
|
|
30
|
+
from coderag.parsing.base import CALLS, CONTAINS, TESTS
|
|
31
|
+
from coderag.parsing.registry import get_parser_for_path, module_qualified_name
|
|
32
|
+
from coderag.security.secrets import redact
|
|
33
|
+
|
|
34
|
+
log = get_logger("indexer")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class IndexStats:
|
|
39
|
+
files_indexed: int = 0
|
|
40
|
+
files_deleted: int = 0
|
|
41
|
+
symbols_indexed: int = 0
|
|
42
|
+
symbols_deleted: int = 0
|
|
43
|
+
files_skipped: int = 0
|
|
44
|
+
secrets_redacted: int = 0
|
|
45
|
+
embeddings_created: int = 0
|
|
46
|
+
relationships_created: int = 0
|
|
47
|
+
duration_seconds: float = 0.0
|
|
48
|
+
commit_sha: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _hash(text: str) -> str:
|
|
52
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_or_create_repository(
|
|
56
|
+
session: Session, name: str, local_path: str, url: str | None = None
|
|
57
|
+
) -> Repository:
|
|
58
|
+
repo = session.scalar(select(Repository).where(Repository.name == name))
|
|
59
|
+
git = GitRepo(local_path)
|
|
60
|
+
branch = git.default_branch() if git.is_git() else "main"
|
|
61
|
+
if repo is None:
|
|
62
|
+
repo = Repository(name=name, local_path=local_path, url=url, default_branch=branch)
|
|
63
|
+
session.add(repo)
|
|
64
|
+
session.flush()
|
|
65
|
+
else:
|
|
66
|
+
repo.local_path = local_path
|
|
67
|
+
if url:
|
|
68
|
+
repo.url = url
|
|
69
|
+
repo.default_branch = branch
|
|
70
|
+
return repo
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Indexer:
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
session: Session,
|
|
77
|
+
settings: Settings | None = None,
|
|
78
|
+
token_counter: TokenCounter | None = None,
|
|
79
|
+
embed: bool = True,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.session = session
|
|
82
|
+
self.settings = settings or get_settings()
|
|
83
|
+
self.tokens = token_counter or get_token_counter(self.settings)
|
|
84
|
+
self.extra_ignore = self.settings.extra_ignore
|
|
85
|
+
self.embed = embed
|
|
86
|
+
|
|
87
|
+
# -- full index -------------------------------------------------------
|
|
88
|
+
def full_index(self, repo: Repository) -> tuple[IndexingRun, IndexStats]:
|
|
89
|
+
started = time.perf_counter()
|
|
90
|
+
git = GitRepo(repo.local_path)
|
|
91
|
+
commit = git.current_commit()
|
|
92
|
+
run = IndexingRun(repository_id=repo.id, mode="full", status="running",
|
|
93
|
+
to_commit=commit)
|
|
94
|
+
self.session.add(run)
|
|
95
|
+
self.session.flush()
|
|
96
|
+
|
|
97
|
+
# Full reindex: clear prior data for this repository (cascades to
|
|
98
|
+
# symbols/embeddings/relationships via FK ON DELETE CASCADE).
|
|
99
|
+
self.session.execute(
|
|
100
|
+
delete(SourceFile).where(SourceFile.repository_id == repo.id)
|
|
101
|
+
)
|
|
102
|
+
self.session.execute(delete(Symbol).where(Symbol.repository_id == repo.id))
|
|
103
|
+
|
|
104
|
+
# accumulate cross-file graph edges, resolved after all symbols exist
|
|
105
|
+
self._pending_contains: list[tuple[int, int]] = []
|
|
106
|
+
self._pending_rels: list[tuple[int, str, str, float, dict | None]] = []
|
|
107
|
+
|
|
108
|
+
stats = IndexStats(commit_sha=commit)
|
|
109
|
+
try:
|
|
110
|
+
for rel_path in git.list_files():
|
|
111
|
+
if not should_index(rel_path, self.extra_ignore):
|
|
112
|
+
stats.files_skipped += 1
|
|
113
|
+
continue
|
|
114
|
+
self._index_file(repo, rel_path, commit, git, stats)
|
|
115
|
+
|
|
116
|
+
stats.relationships_created = self._persist_relationships(repo)
|
|
117
|
+
|
|
118
|
+
if self.embed:
|
|
119
|
+
stats.embeddings_created = self._embed(repo)
|
|
120
|
+
|
|
121
|
+
stats.duration_seconds = time.perf_counter() - started
|
|
122
|
+
run.status = "success"
|
|
123
|
+
run.files_indexed = stats.files_indexed
|
|
124
|
+
run.symbols_indexed = stats.symbols_indexed
|
|
125
|
+
run.embeddings_created = stats.embeddings_created
|
|
126
|
+
run.duration_seconds = stats.duration_seconds
|
|
127
|
+
|
|
128
|
+
run.finished_at = func.now()
|
|
129
|
+
repo.indexed_commit_sha = commit
|
|
130
|
+
repo.updated_at = func.now()
|
|
131
|
+
log.info(
|
|
132
|
+
"full_index.done", repository=repo.name,
|
|
133
|
+
files=stats.files_indexed, symbols=stats.symbols_indexed,
|
|
134
|
+
skipped=stats.files_skipped, redactions=stats.secrets_redacted,
|
|
135
|
+
seconds=round(stats.duration_seconds, 3),
|
|
136
|
+
)
|
|
137
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
138
|
+
run.status = "failed"
|
|
139
|
+
run.error = str(exc)[:500]
|
|
140
|
+
log.error("full_index.failed", repository=repo.name, error=str(exc)[:200])
|
|
141
|
+
raise
|
|
142
|
+
return run, stats
|
|
143
|
+
|
|
144
|
+
# -- incremental index ------------------------------------------------
|
|
145
|
+
def incremental_index(self, repo: Repository) -> tuple[IndexingRun, IndexStats]:
|
|
146
|
+
"""Update only what changed since ``repo.indexed_commit_sha``.
|
|
147
|
+
|
|
148
|
+
Symbols/embeddings for unchanged files are preserved (unchanged code is
|
|
149
|
+
never re-embedded). The relationship graph is rebuilt from a fast parse
|
|
150
|
+
pass so cross-file edges stay correct. Falls back to a full index when
|
|
151
|
+
there is no prior commit or the path is not a git repo.
|
|
152
|
+
"""
|
|
153
|
+
git = GitRepo(repo.local_path)
|
|
154
|
+
base = repo.indexed_commit_sha
|
|
155
|
+
head = git.current_commit()
|
|
156
|
+
if not git.is_git() or not base:
|
|
157
|
+
return self.full_index(repo)
|
|
158
|
+
|
|
159
|
+
started = time.perf_counter()
|
|
160
|
+
run = IndexingRun(repository_id=repo.id, mode="incremental", status="running",
|
|
161
|
+
from_commit=base, to_commit=head)
|
|
162
|
+
self.session.add(run)
|
|
163
|
+
self.session.flush()
|
|
164
|
+
|
|
165
|
+
self._pending_contains = []
|
|
166
|
+
self._pending_rels = []
|
|
167
|
+
stats = IndexStats(commit_sha=head)
|
|
168
|
+
try:
|
|
169
|
+
diff = git.diff(base, head or "HEAD")
|
|
170
|
+
changed = [p for p in diff.changed if should_index(p, self.extra_ignore)]
|
|
171
|
+
gone = set(changed) | set(diff.deleted)
|
|
172
|
+
for path in gone:
|
|
173
|
+
self._delete_file(repo, path, stats)
|
|
174
|
+
|
|
175
|
+
for path in changed:
|
|
176
|
+
self._index_file(repo, path, head, git, stats)
|
|
177
|
+
|
|
178
|
+
# rebuild the whole-repo graph: collect edges from unchanged files too
|
|
179
|
+
qual_to_id = self._qualified_index(repo)
|
|
180
|
+
changed_set = set(changed)
|
|
181
|
+
for path in self._indexed_paths(repo):
|
|
182
|
+
if path not in changed_set:
|
|
183
|
+
self._collect_edges(repo, path, git, qual_to_id)
|
|
184
|
+
self.session.execute(
|
|
185
|
+
delete(SymbolRelationship).where(
|
|
186
|
+
SymbolRelationship.repository_id == repo.id
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
stats.relationships_created = self._persist_relationships(repo)
|
|
190
|
+
|
|
191
|
+
if self.embed:
|
|
192
|
+
stats.embeddings_created = self._embed(repo)
|
|
193
|
+
|
|
194
|
+
stats.duration_seconds = time.perf_counter() - started
|
|
195
|
+
|
|
196
|
+
run.status = "success"
|
|
197
|
+
run.files_indexed = stats.files_indexed
|
|
198
|
+
run.files_deleted = stats.files_deleted
|
|
199
|
+
run.symbols_indexed = stats.symbols_indexed
|
|
200
|
+
run.symbols_deleted = stats.symbols_deleted
|
|
201
|
+
run.embeddings_created = stats.embeddings_created
|
|
202
|
+
run.duration_seconds = stats.duration_seconds
|
|
203
|
+
run.finished_at = func.now()
|
|
204
|
+
repo.indexed_commit_sha = head
|
|
205
|
+
repo.updated_at = func.now()
|
|
206
|
+
log.info(
|
|
207
|
+
"incremental_index.done", repository=repo.name,
|
|
208
|
+
changed=len(changed), deleted=len(diff.deleted),
|
|
209
|
+
symbols_added=stats.symbols_indexed, symbols_deleted=stats.symbols_deleted,
|
|
210
|
+
embedded=stats.embeddings_created, seconds=round(stats.duration_seconds, 3),
|
|
211
|
+
)
|
|
212
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
213
|
+
run.status = "failed"
|
|
214
|
+
run.error = str(exc)[:500]
|
|
215
|
+
log.error("incremental_index.failed", repository=repo.name, error=str(exc)[:200])
|
|
216
|
+
raise
|
|
217
|
+
return run, stats
|
|
218
|
+
|
|
219
|
+
def _delete_file(self, repo: Repository, path: str, stats: IndexStats) -> None:
|
|
220
|
+
n = self.session.scalar(
|
|
221
|
+
select(func.count()).select_from(Symbol).where(
|
|
222
|
+
Symbol.repository_id == repo.id, Symbol.file_path == path
|
|
223
|
+
)
|
|
224
|
+
) or 0
|
|
225
|
+
existed = self.session.scalar(
|
|
226
|
+
select(SourceFile.id).where(
|
|
227
|
+
SourceFile.repository_id == repo.id, SourceFile.path == path
|
|
228
|
+
)
|
|
229
|
+
) is not None
|
|
230
|
+
self.session.execute(
|
|
231
|
+
delete(Symbol).where(Symbol.repository_id == repo.id, Symbol.file_path == path)
|
|
232
|
+
)
|
|
233
|
+
self.session.execute(
|
|
234
|
+
delete(SourceFile).where(
|
|
235
|
+
SourceFile.repository_id == repo.id, SourceFile.path == path
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
if existed:
|
|
239
|
+
stats.files_deleted += 1
|
|
240
|
+
stats.symbols_deleted += int(n)
|
|
241
|
+
|
|
242
|
+
def _qualified_index(self, repo: Repository) -> dict[str, int]:
|
|
243
|
+
return {
|
|
244
|
+
qual: sid
|
|
245
|
+
for sid, qual in self.session.execute(
|
|
246
|
+
select(Symbol.id, Symbol.qualified_name).where(
|
|
247
|
+
Symbol.repository_id == repo.id
|
|
248
|
+
)
|
|
249
|
+
).all()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
def _indexed_paths(self, repo: Repository) -> list[str]:
|
|
253
|
+
return list(
|
|
254
|
+
self.session.scalars(
|
|
255
|
+
select(SourceFile.path).where(SourceFile.repository_id == repo.id)
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _collect_edges(
|
|
260
|
+
self, repo: Repository, path: str, git: GitRepo, qual_to_id: dict[str, int]
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Parse an unchanged file for its edges only, mapping to existing ids."""
|
|
263
|
+
raw = git.read_text(path)
|
|
264
|
+
parser = get_parser_for_path(path)
|
|
265
|
+
if raw is None or parser is None:
|
|
266
|
+
return
|
|
267
|
+
result = parser.parse(module_qualified_name(path), redact(raw).text)
|
|
268
|
+
local_to_db = {
|
|
269
|
+
ps.local_id: qual_to_id.get(ps.qualified_name) for ps in result.symbols
|
|
270
|
+
}
|
|
271
|
+
for ps in result.symbols:
|
|
272
|
+
db_id = local_to_db.get(ps.local_id)
|
|
273
|
+
if db_id is None:
|
|
274
|
+
continue
|
|
275
|
+
if ps.parent_local_id is not None:
|
|
276
|
+
parent_db = local_to_db.get(ps.parent_local_id)
|
|
277
|
+
if parent_db is not None:
|
|
278
|
+
self._pending_contains.append((parent_db, db_id))
|
|
279
|
+
for pr in result.relationships:
|
|
280
|
+
src_db = local_to_db.get(pr.source_local_id)
|
|
281
|
+
if src_db is not None:
|
|
282
|
+
self._pending_rels.append(
|
|
283
|
+
(src_db, pr.relationship_type, pr.target_name, pr.confidence,
|
|
284
|
+
pr.metadata)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# -- relationships ----------------------------------------------------
|
|
288
|
+
def _persist_relationships(self, repo: Repository) -> int:
|
|
289
|
+
rows = self.session.execute(
|
|
290
|
+
select(
|
|
291
|
+
Symbol.id, Symbol.symbol_name, Symbol.qualified_name,
|
|
292
|
+
Symbol.parent_symbol_id, Symbol.file_path,
|
|
293
|
+
).where(Symbol.repository_id == repo.id)
|
|
294
|
+
).all()
|
|
295
|
+
by_qual: dict[str, int] = {}
|
|
296
|
+
by_name: dict[str, list[int]] = {}
|
|
297
|
+
info: dict[int, tuple[int | None, str, str]] = {}
|
|
298
|
+
for sid, name, qual, parent_id, path in rows:
|
|
299
|
+
by_qual[qual.lower()] = sid
|
|
300
|
+
by_name.setdefault(name.lower(), []).append(sid)
|
|
301
|
+
info[sid] = (parent_id, name, path)
|
|
302
|
+
|
|
303
|
+
def resolve(target_name: str, via: str | None, source_id: int) -> int | None:
|
|
304
|
+
t = target_name.lower()
|
|
305
|
+
if t in by_qual:
|
|
306
|
+
return by_qual[t]
|
|
307
|
+
cands = by_name.get(t.split(".")[-1], [])
|
|
308
|
+
if not cands:
|
|
309
|
+
return None
|
|
310
|
+
if via == "self":
|
|
311
|
+
parent = info[source_id][0]
|
|
312
|
+
same = [c for c in cands if info[c][0] == parent]
|
|
313
|
+
if len(same) == 1:
|
|
314
|
+
return same[0]
|
|
315
|
+
return cands[0] if len(cands) == 1 else None
|
|
316
|
+
|
|
317
|
+
def is_test(sid: int) -> bool:
|
|
318
|
+
_parent, name, path = info[sid]
|
|
319
|
+
p = path.replace("\\", "/")
|
|
320
|
+
return (
|
|
321
|
+
name.lower().startswith("test")
|
|
322
|
+
or "/tests/" in p
|
|
323
|
+
or p.startswith("tests/")
|
|
324
|
+
or p.rsplit("/", 1)[-1].startswith("test_")
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
edges: dict[tuple[int, int | None, str], tuple[float, dict | None]] = {}
|
|
328
|
+
|
|
329
|
+
def add(source: int, target: int | None, rtype: str, conf: float, meta):
|
|
330
|
+
key = (source, target, rtype)
|
|
331
|
+
if key not in edges or conf > edges[key][0]:
|
|
332
|
+
edges[key] = (conf, meta)
|
|
333
|
+
|
|
334
|
+
for parent_db, child_db in self._pending_contains:
|
|
335
|
+
add(parent_db, child_db, CONTAINS, 1.0, None)
|
|
336
|
+
|
|
337
|
+
for src, rtype, target_name, conf, meta in self._pending_rels:
|
|
338
|
+
via = (meta or {}).get("via") if meta else None
|
|
339
|
+
tid = resolve(target_name, via, src)
|
|
340
|
+
if tid is None or tid == src:
|
|
341
|
+
continue # keep the graph correct: only in-repo, non-self-loop edges
|
|
342
|
+
add(src, tid, rtype, conf, meta)
|
|
343
|
+
if rtype == CALLS and is_test(src):
|
|
344
|
+
add(src, tid, TESTS, min(conf, 0.8), None)
|
|
345
|
+
|
|
346
|
+
for (source, target, rtype), (conf, meta) in edges.items():
|
|
347
|
+
self.session.add(
|
|
348
|
+
SymbolRelationship(
|
|
349
|
+
repository_id=repo.id, source_symbol_id=source,
|
|
350
|
+
target_symbol_id=target, relationship_type=rtype,
|
|
351
|
+
confidence=conf, meta=meta,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
self.session.flush()
|
|
355
|
+
return len(edges)
|
|
356
|
+
|
|
357
|
+
def _embed(self, repo: Repository) -> int:
|
|
358
|
+
from coderag.embeddings.pipeline import EmbeddingPipeline
|
|
359
|
+
from coderag.embeddings.registry import get_embedding_provider
|
|
360
|
+
|
|
361
|
+
provider = get_embedding_provider(self.settings)
|
|
362
|
+
return EmbeddingPipeline(self.session, provider).embed_repository(repo)
|
|
363
|
+
|
|
364
|
+
# -- per-file ---------------------------------------------------------
|
|
365
|
+
def _index_file(
|
|
366
|
+
self, repo: Repository, rel_path: str, commit: str | None,
|
|
367
|
+
git: GitRepo, stats: IndexStats,
|
|
368
|
+
) -> None:
|
|
369
|
+
raw = git.read_text(rel_path)
|
|
370
|
+
if raw is None:
|
|
371
|
+
stats.files_skipped += 1
|
|
372
|
+
return
|
|
373
|
+
red = redact(raw)
|
|
374
|
+
if red.count:
|
|
375
|
+
stats.secrets_redacted += red.count
|
|
376
|
+
log.warning("redacted_secret", path=rel_path, count=red.count,
|
|
377
|
+
labels=red.labels)
|
|
378
|
+
content = red.text
|
|
379
|
+
|
|
380
|
+
parser = get_parser_for_path(rel_path)
|
|
381
|
+
if parser is None:
|
|
382
|
+
stats.files_skipped += 1
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
sf = SourceFile(
|
|
386
|
+
repository_id=repo.id, path=rel_path, language=parser.language,
|
|
387
|
+
content_hash=_hash(content), size_bytes=len(content.encode("utf-8")),
|
|
388
|
+
token_count=self.tokens.count(content),
|
|
389
|
+
indexed_commit_sha=commit,
|
|
390
|
+
)
|
|
391
|
+
self.session.add(sf)
|
|
392
|
+
self.session.flush()
|
|
393
|
+
|
|
394
|
+
result = parser.parse(module_qualified_name(rel_path), content)
|
|
395
|
+
local_to_db: dict[int, int] = {}
|
|
396
|
+
for ps in result.symbols:
|
|
397
|
+
parent_db = (
|
|
398
|
+
local_to_db.get(ps.parent_local_id)
|
|
399
|
+
if ps.parent_local_id is not None
|
|
400
|
+
else None
|
|
401
|
+
)
|
|
402
|
+
sym = Symbol(
|
|
403
|
+
repository_id=repo.id,
|
|
404
|
+
source_file_id=sf.id,
|
|
405
|
+
file_path=rel_path,
|
|
406
|
+
language=parser.language,
|
|
407
|
+
symbol_name=ps.symbol_name,
|
|
408
|
+
qualified_name=ps.qualified_name,
|
|
409
|
+
symbol_type=ps.symbol_type,
|
|
410
|
+
signature=ps.signature,
|
|
411
|
+
start_line=ps.start_line,
|
|
412
|
+
end_line=ps.end_line,
|
|
413
|
+
parent_symbol_id=parent_db,
|
|
414
|
+
source_code=ps.source_code,
|
|
415
|
+
docstring=ps.docstring,
|
|
416
|
+
source_hash=ps.source_hash,
|
|
417
|
+
commit_sha=commit,
|
|
418
|
+
token_count=self.tokens.count(ps.source_code),
|
|
419
|
+
search_document=" ".join(ps.search_terms),
|
|
420
|
+
)
|
|
421
|
+
self.session.add(sym)
|
|
422
|
+
self.session.flush()
|
|
423
|
+
local_to_db[ps.local_id] = sym.id
|
|
424
|
+
if parent_db is not None:
|
|
425
|
+
self._pending_contains.append((parent_db, sym.id))
|
|
426
|
+
stats.symbols_indexed += 1
|
|
427
|
+
|
|
428
|
+
for pr in result.relationships:
|
|
429
|
+
src_db = local_to_db.get(pr.source_local_id)
|
|
430
|
+
if src_db is not None:
|
|
431
|
+
self._pending_rels.append(
|
|
432
|
+
(src_db, pr.relationship_type, pr.target_name, pr.confidence, pr.metadata)
|
|
433
|
+
)
|
|
434
|
+
stats.files_indexed += 1
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def index_repository(
|
|
438
|
+
session: Session, name: str, local_path: str, url: str | None = None,
|
|
439
|
+
settings: Settings | None = None,
|
|
440
|
+
) -> tuple[Repository, IndexingRun, IndexStats]:
|
|
441
|
+
"""Convenience: register (or update) a repository and run a full index."""
|
|
442
|
+
repo = get_or_create_repository(session, name, local_path, url)
|
|
443
|
+
indexer = Indexer(session, settings=settings)
|
|
444
|
+
run, stats = indexer.full_index(repo)
|
|
445
|
+
return repo, run, stats
|
coderag/llm/__init__.py
ADDED
|
File without changes
|
coderag/llm/anthropic.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Anthropic Claude provider over the Messages API (HTTP).
|
|
2
|
+
|
|
3
|
+
Because the transport is plain HTTP via httpx, the same provider adapts to the
|
|
4
|
+
Anthropic API, an internal proxy (set ``anthropic_base_url``), or — with a
|
|
5
|
+
subclass swapping auth/signing — AWS Bedrock. Credentials come from config and
|
|
6
|
+
are never logged.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from coderag.core.config import Settings, get_settings
|
|
18
|
+
from coderag.llm.base import LLMProvider, LLMRequest, LLMResponse, Usage
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AnthropicClaudeProvider(LLMProvider):
|
|
22
|
+
name = "anthropic"
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
settings: Settings | None = None,
|
|
27
|
+
client: httpx.Client | None = None,
|
|
28
|
+
transport: httpx.BaseTransport | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.settings = settings or get_settings()
|
|
31
|
+
if not self.settings.anthropic_api_key and client is None and transport is None:
|
|
32
|
+
raise RuntimeError(
|
|
33
|
+
"CODERAG_ANTHROPIC_API_KEY is not set. Retrieval works without an LLM; "
|
|
34
|
+
"only `ask` needs credentials."
|
|
35
|
+
)
|
|
36
|
+
self._client = client or httpx.Client(
|
|
37
|
+
base_url=self.settings.anthropic_base_url,
|
|
38
|
+
timeout=self.settings.llm_timeout_seconds,
|
|
39
|
+
transport=transport,
|
|
40
|
+
headers={
|
|
41
|
+
"x-api-key": self.settings.anthropic_api_key or "",
|
|
42
|
+
"anthropic-version": self.settings.anthropic_version,
|
|
43
|
+
"content-type": "application/json",
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
self._last_usage = Usage()
|
|
47
|
+
|
|
48
|
+
def _body(self, request: LLMRequest, stream: bool) -> dict:
|
|
49
|
+
return {
|
|
50
|
+
"model": request.model or self.settings.anthropic_model,
|
|
51
|
+
"max_tokens": request.max_tokens or self.settings.llm_max_output_tokens,
|
|
52
|
+
"temperature": request.temperature,
|
|
53
|
+
"system": request.system,
|
|
54
|
+
"messages": [{"role": "user", "content": request.prompt}],
|
|
55
|
+
"stream": stream,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def generate(self, request: LLMRequest) -> LLMResponse:
|
|
59
|
+
started = time.perf_counter()
|
|
60
|
+
model = request.model or self.settings.anthropic_model
|
|
61
|
+
try:
|
|
62
|
+
resp = self._client.post("/v1/messages", json=self._body(request, stream=False))
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
data = resp.json()
|
|
65
|
+
text = "".join(
|
|
66
|
+
block.get("text", "")
|
|
67
|
+
for block in data.get("content", [])
|
|
68
|
+
if block.get("type") == "text"
|
|
69
|
+
)
|
|
70
|
+
usage_raw = data.get("usage", {})
|
|
71
|
+
usage = Usage(
|
|
72
|
+
input_tokens=usage_raw.get("input_tokens", 0),
|
|
73
|
+
output_tokens=usage_raw.get("output_tokens", 0),
|
|
74
|
+
cached_input_tokens=usage_raw.get("cache_read_input_tokens"),
|
|
75
|
+
model=data.get("model", model),
|
|
76
|
+
latency_ms=(time.perf_counter() - started) * 1000,
|
|
77
|
+
success=True,
|
|
78
|
+
)
|
|
79
|
+
self._last_usage = usage
|
|
80
|
+
return LLMResponse(text=text, usage=usage)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
usage = Usage(
|
|
83
|
+
model=model, latency_ms=(time.perf_counter() - started) * 1000,
|
|
84
|
+
success=False, error=type(exc).__name__ + ": " + str(exc)[:200],
|
|
85
|
+
)
|
|
86
|
+
self._last_usage = usage
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
def generate_stream(self, request: LLMRequest) -> Iterator[str]:
|
|
90
|
+
started = time.perf_counter()
|
|
91
|
+
model = request.model or self.settings.anthropic_model
|
|
92
|
+
usage = Usage(model=model)
|
|
93
|
+
with self._client.stream(
|
|
94
|
+
"POST", "/v1/messages", json=self._body(request, stream=True)
|
|
95
|
+
) as resp:
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
for line in resp.iter_lines():
|
|
98
|
+
if not line or not line.startswith("data:"):
|
|
99
|
+
continue
|
|
100
|
+
payload = line[len("data:") :].strip()
|
|
101
|
+
if payload in ("", "[DONE]"):
|
|
102
|
+
continue
|
|
103
|
+
event = json.loads(payload)
|
|
104
|
+
etype = event.get("type")
|
|
105
|
+
if etype == "message_start":
|
|
106
|
+
usage.input_tokens = (
|
|
107
|
+
event.get("message", {}).get("usage", {}).get("input_tokens", 0)
|
|
108
|
+
)
|
|
109
|
+
elif etype == "content_block_delta":
|
|
110
|
+
delta = event.get("delta", {})
|
|
111
|
+
if delta.get("type") == "text_delta":
|
|
112
|
+
yield delta.get("text", "")
|
|
113
|
+
elif etype == "message_delta":
|
|
114
|
+
usage.output_tokens = event.get("usage", {}).get("output_tokens", 0)
|
|
115
|
+
usage.latency_ms = (time.perf_counter() - started) * 1000
|
|
116
|
+
usage.success = True
|
|
117
|
+
self._last_usage = usage
|