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.
Files changed (73) hide show
  1. coderag/__init__.py +12 -0
  2. coderag/analyzers/__init__.py +0 -0
  3. coderag/analyzers/base.py +36 -0
  4. coderag/analyzers/flake8_adapter.py +43 -0
  5. coderag/analyzers/pylint_adapter.py +50 -0
  6. coderag/analyzers/workflow.py +107 -0
  7. coderag/api/__init__.py +0 -0
  8. coderag/api/app.py +351 -0
  9. coderag/api/dashboard.py +237 -0
  10. coderag/api/schemas.py +181 -0
  11. coderag/cli/__init__.py +0 -0
  12. coderag/cli/main.py +359 -0
  13. coderag/context/__init__.py +0 -0
  14. coderag/context/builder.py +206 -0
  15. coderag/context/package.py +100 -0
  16. coderag/core/__init__.py +0 -0
  17. coderag/core/config.py +100 -0
  18. coderag/core/logging.py +58 -0
  19. coderag/core/text.py +80 -0
  20. coderag/core/tokens.py +66 -0
  21. coderag/db/__init__.py +0 -0
  22. coderag/db/base.py +58 -0
  23. coderag/db/migrations/env.py +77 -0
  24. coderag/db/migrations/script.py.mako +26 -0
  25. coderag/db/migrations/versions/.gitkeep +0 -0
  26. coderag/db/migrations/versions/9a973932b94d_measure_baseline_tokens.py +41 -0
  27. coderag/db/migrations/versions/c8a40feeda41_initial_schema.py +227 -0
  28. coderag/db/models.py +275 -0
  29. coderag/embeddings/__init__.py +0 -0
  30. coderag/embeddings/base.py +53 -0
  31. coderag/embeddings/hashing.py +43 -0
  32. coderag/embeddings/pipeline.py +87 -0
  33. coderag/embeddings/registry.py +38 -0
  34. coderag/embeddings/sentence_transformer.py +47 -0
  35. coderag/evaluation/__init__.py +0 -0
  36. coderag/evaluation/datasets.py +36 -0
  37. coderag/evaluation/harness.py +246 -0
  38. coderag/git/__init__.py +0 -0
  39. coderag/git/repo.py +139 -0
  40. coderag/indexing/__init__.py +0 -0
  41. coderag/indexing/ignore.py +101 -0
  42. coderag/indexing/indexer.py +445 -0
  43. coderag/llm/__init__.py +0 -0
  44. coderag/llm/anthropic.py +117 -0
  45. coderag/llm/base.py +60 -0
  46. coderag/llm/null.py +16 -0
  47. coderag/llm/registry.py +17 -0
  48. coderag/localdb.py +100 -0
  49. coderag/mcp_server.py +209 -0
  50. coderag/parsing/__init__.py +0 -0
  51. coderag/parsing/base.py +76 -0
  52. coderag/parsing/python.py +294 -0
  53. coderag/parsing/registry.py +40 -0
  54. coderag/retrieval/__init__.py +0 -0
  55. coderag/retrieval/base.py +60 -0
  56. coderag/retrieval/engine.py +176 -0
  57. coderag/retrieval/fusion.py +41 -0
  58. coderag/retrieval/graph.py +139 -0
  59. coderag/retrieval/lexical.py +57 -0
  60. coderag/retrieval/reranking.py +99 -0
  61. coderag/retrieval/semantic.py +53 -0
  62. coderag/retrieval/symbol.py +91 -0
  63. coderag/security/__init__.py +0 -0
  64. coderag/security/authz.py +35 -0
  65. coderag/security/secrets.py +110 -0
  66. coderag/service.py +160 -0
  67. coderag/telemetry.py +72 -0
  68. coderag_ai-0.3.0.dist-info/METADATA +364 -0
  69. coderag_ai-0.3.0.dist-info/RECORD +73 -0
  70. coderag_ai-0.3.0.dist-info/WHEEL +4 -0
  71. coderag_ai-0.3.0.dist-info/entry_points.txt +3 -0
  72. coderag_ai-0.3.0.dist-info/licenses/LICENSE +202 -0
  73. coderag_ai-0.3.0.dist-info/licenses/NOTICE +5 -0
coderag/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """CodeRAG: token-efficient, structure-aware Code Intelligence + RAG."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _version
5
+
6
+ try:
7
+ # Distribution name is `coderag-ai`; the import name is `coderag`.
8
+ __version__ = _version("coderag-ai")
9
+ except PackageNotFoundError: # running from a source tree without an install
10
+ __version__ = "0.0.0.dev0"
11
+
12
+ __all__ = ["__version__"]
File without changes
@@ -0,0 +1,36 @@
1
+ """Static-analysis adapter interface (Phase 10).
2
+
3
+ A ``StaticAnalyzer`` turns tool output into normalized ``Finding``s. Adapters run
4
+ the tool as a subprocess (never importing/linking it), so licensing stays clean
5
+ (see docs/licenses.md re: pylint GPL). Future adapters: SonarQube, mypy, Ruff,
6
+ Bandit.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass
16
+ class Finding:
17
+ file_path: str # repository-relative
18
+ line: int
19
+ code: str # rule id, e.g. "F401", "W0611"
20
+ message: str
21
+ tool: str
22
+
23
+ def describe(self) -> str:
24
+ return f"[{self.tool}:{self.code}] {self.file_path}:{self.line} — {self.message}"
25
+
26
+
27
+ class StaticAnalyzer(ABC):
28
+ name: str
29
+
30
+ @abstractmethod
31
+ def available(self) -> bool:
32
+ """Whether the underlying tool is installed/runnable."""
33
+
34
+ @abstractmethod
35
+ def analyze(self, root: str, paths: list[str] | None = None) -> list[Finding]:
36
+ """Run the analyzer over ``root`` (optionally limited to ``paths``)."""
@@ -0,0 +1,43 @@
1
+ """Flake8 adapter (runs flake8 as a subprocess)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+
8
+ from coderag.analyzers.base import Finding, StaticAnalyzer
9
+
10
+ _FMT = "%(path)s\t%(row)d\t%(code)s\t%(text)s"
11
+
12
+
13
+ class Flake8Analyzer(StaticAnalyzer):
14
+ name = "flake8"
15
+
16
+ def available(self) -> bool:
17
+ try:
18
+ subprocess.run(
19
+ [sys.executable, "-m", "flake8", "--version"],
20
+ capture_output=True, check=True,
21
+ )
22
+ return True
23
+ except (subprocess.CalledProcessError, FileNotFoundError):
24
+ return False
25
+
26
+ def analyze(self, root: str, paths: list[str] | None = None) -> list[Finding]:
27
+ targets = paths or ["."]
28
+ result = subprocess.run(
29
+ [sys.executable, "-m", "flake8", f"--format={_FMT}", *targets],
30
+ cwd=root, capture_output=True, text=True,
31
+ )
32
+ findings: list[Finding] = []
33
+ for line in result.stdout.splitlines():
34
+ parts = line.split("\t")
35
+ if len(parts) != 4:
36
+ continue
37
+ path, row, code, text = parts
38
+ path = path.lstrip("./").replace("\\", "/")
39
+ findings.append(
40
+ Finding(file_path=path, line=int(row), code=code, message=text,
41
+ tool=self.name)
42
+ )
43
+ return findings
@@ -0,0 +1,50 @@
1
+ """Pylint adapter (runs pylint as a subprocess; its JSON output is consumed).
2
+
3
+ Pylint is GPL-2.0 but is invoked only as an external tool — CodeRAG neither
4
+ imports nor links it (docs/licenses.md).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import subprocess
11
+ import sys
12
+
13
+ from coderag.analyzers.base import Finding, StaticAnalyzer
14
+
15
+
16
+ class PylintAnalyzer(StaticAnalyzer):
17
+ name = "pylint"
18
+
19
+ def available(self) -> bool:
20
+ try:
21
+ subprocess.run(
22
+ [sys.executable, "-m", "pylint", "--version"],
23
+ capture_output=True, check=True,
24
+ )
25
+ return True
26
+ except (subprocess.CalledProcessError, FileNotFoundError):
27
+ return False
28
+
29
+ def analyze(self, root: str, paths: list[str] | None = None) -> list[Finding]:
30
+ targets = paths or ["."]
31
+ result = subprocess.run(
32
+ [sys.executable, "-m", "pylint", "--output-format=json", "--score=n", *targets],
33
+ cwd=root, capture_output=True, text=True,
34
+ )
35
+ try:
36
+ items = json.loads(result.stdout or "[]")
37
+ except json.JSONDecodeError:
38
+ return []
39
+ findings: list[Finding] = []
40
+ for it in items:
41
+ findings.append(
42
+ Finding(
43
+ file_path=str(it.get("path", "")).replace("\\", "/"),
44
+ line=int(it.get("line", 1)),
45
+ code=it.get("message-id", it.get("symbol", "")),
46
+ message=it.get("message", ""),
47
+ tool=self.name,
48
+ )
49
+ )
50
+ return findings
@@ -0,0 +1,107 @@
1
+ """Analyzer-driven fix workflow (Phase 10).
2
+
3
+ Flow (spec §21): finding -> identify file/line -> identify enclosing symbol ->
4
+ Code-RAG context -> (optional) LLM patch. Patch *application* and re-verification
5
+ are intentionally left to the caller as an interface; the model is never allowed
6
+ to loop unbounded — attempts are capped by ``MAX_FIX_ATTEMPTS``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from sqlalchemy import select
14
+ from sqlalchemy.orm import Session
15
+
16
+ from coderag.analyzers.base import Finding
17
+ from coderag.context.package import ContextPackage
18
+ from coderag.core.config import Settings, get_settings
19
+ from coderag.db.models import Repository, Symbol
20
+
21
+
22
+ def map_finding_to_symbol(session: Session, repository_id: int, finding: Finding):
23
+ """Return the smallest symbol whose line range encloses the finding's line."""
24
+ enclosing = session.scalars(
25
+ select(Symbol).where(
26
+ Symbol.repository_id == repository_id,
27
+ Symbol.file_path == finding.file_path,
28
+ Symbol.start_line <= finding.line,
29
+ Symbol.end_line >= finding.line,
30
+ )
31
+ ).all()
32
+ if enclosing:
33
+ # smallest span = most specific; prefer non-module
34
+ return min(
35
+ enclosing,
36
+ key=lambda s: (s.symbol_type == "module", s.end_line - s.start_line),
37
+ )
38
+ # fall back to the module symbol for that file
39
+ return session.scalar(
40
+ select(Symbol).where(
41
+ Symbol.repository_id == repository_id,
42
+ Symbol.file_path == finding.file_path,
43
+ Symbol.symbol_type == "module",
44
+ )
45
+ )
46
+
47
+
48
+ @dataclass
49
+ class FixContext:
50
+ finding: Finding
51
+ symbol_qualified_name: str | None
52
+ package: ContextPackage
53
+
54
+
55
+ def build_fix_context(
56
+ session: Session, repo: Repository, finding: Finding,
57
+ settings: Settings | None = None,
58
+ ) -> FixContext:
59
+ from coderag.context.builder import ContextBuilder
60
+ from coderag.service import get_engine
61
+
62
+ settings = settings or get_settings()
63
+ symbol = map_finding_to_symbol(session, repo.id, finding)
64
+ query = symbol.qualified_name if symbol else finding.message
65
+ finding_text = (
66
+ finding.describe()
67
+ + "\n\nFix this finding WITHOUT changing existing behavior. "
68
+ + "Return a minimal patch and explain the change."
69
+ )
70
+ engine = get_engine(settings, semantic=True, graph=True)
71
+ outcome = engine.search(session, repo.id, query, top_n=None)
72
+ package = ContextBuilder(session, settings=settings).build(
73
+ query, outcome.candidates, repo,
74
+ changed_symbol_ids={symbol.id} if symbol else None,
75
+ finding=finding_text,
76
+ )
77
+ return FixContext(
78
+ finding=finding,
79
+ symbol_qualified_name=symbol.qualified_name if symbol else None,
80
+ package=package,
81
+ )
82
+
83
+
84
+ def propose_fix(fix: FixContext, provider, max_output_tokens: int = 1024) -> str:
85
+ """Single LLM attempt to propose a patch for a finding (requires a provider)."""
86
+ from coderag.llm.base import LLMRequest
87
+
88
+ return provider.generate(
89
+ LLMRequest(prompt=fix.package.prompt_text, max_tokens=max_output_tokens)
90
+ ).text
91
+
92
+
93
+ def run_fix_loop(fixes: list[FixContext], provider, settings: Settings | None = None):
94
+ """Bounded fix proposals — capped at MAX_FIX_ATTEMPTS to prevent infinite loops.
95
+
96
+ Application/re-verification of patches is the caller's responsibility (pass
97
+ verified patches back in); this MVP produces proposals only.
98
+ """
99
+ settings = settings or get_settings()
100
+ proposals = []
101
+ for fix in fixes[: settings.max_fix_attempts]:
102
+ proposals.append({
103
+ "finding": fix.finding.describe(),
104
+ "symbol": fix.symbol_qualified_name,
105
+ "patch": propose_fix(fix, provider, settings.llm_max_output_tokens),
106
+ })
107
+ return proposals
File without changes
coderag/api/app.py ADDED
@@ -0,0 +1,351 @@
1
+ """FastAPI application.
2
+
3
+ Endpoints for repository registration/indexing, retrieval (`/search`,
4
+ `/context` — no LLM needed), `/ask` (needs an LLM), symbol inspection, and
5
+ metrics. Every repository-scoped request is authorized via
6
+ ``AuthorizationProvider`` and retrieval is always bound to a ``repository_id``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterator
12
+
13
+ from fastapi import Depends, FastAPI, Header, HTTPException
14
+ from fastapi.responses import HTMLResponse
15
+ from sqlalchemy import func, select
16
+ from sqlalchemy.orm import Session
17
+
18
+ from coderag import __version__ as _coderag_version
19
+ from coderag.api import schemas as s
20
+ from coderag.db.models import (
21
+ IndexingRun,
22
+ LLMRequest,
23
+ QueryRecord,
24
+ Repository,
25
+ Symbol,
26
+ SymbolEmbedding,
27
+ SymbolRelationship,
28
+ )
29
+ from coderag.security.authz import get_authorization_provider
30
+ from coderag.service import (
31
+ RepositoryNotFound,
32
+ resolve_repository,
33
+ run_ask,
34
+ run_context,
35
+ run_search,
36
+ )
37
+
38
+ app = FastAPI(title="CodeRAG", version=_coderag_version)
39
+
40
+
41
+ def get_session() -> Iterator[Session]:
42
+ from coderag.db.base import get_session_factory
43
+
44
+ session = get_session_factory()()
45
+ try:
46
+ yield session
47
+ session.commit()
48
+ except Exception:
49
+ session.rollback()
50
+ raise
51
+ finally:
52
+ session.close()
53
+
54
+
55
+ def principal(x_user: str | None = Header(default=None)) -> str | None:
56
+ return x_user
57
+
58
+
59
+ def _resolve_authorized(session: Session, name: str | None, who: str | None) -> Repository:
60
+ try:
61
+ repo = resolve_repository(session, name)
62
+ except RepositoryNotFound as exc:
63
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
64
+ if not get_authorization_provider().can_access(who, repo.id):
65
+ raise HTTPException(status_code=403, detail="not authorized for this repository")
66
+ return repo
67
+
68
+
69
+ @app.get("/health")
70
+ def health() -> dict:
71
+ return {"status": "ok"}
72
+
73
+
74
+ @app.post("/repositories", response_model=s.RepositoryOut)
75
+ def register_repository(
76
+ req: s.RegisterRepoRequest, session: Session = Depends(get_session)
77
+ ) -> s.RepositoryOut:
78
+ from coderag.indexing.indexer import get_or_create_repository
79
+
80
+ repo = get_or_create_repository(session, req.name, req.path, req.url)
81
+ session.flush()
82
+ return s.RepositoryOut(
83
+ id=repo.id, name=repo.name, local_path=repo.local_path,
84
+ default_branch=repo.default_branch, indexed_commit_sha=repo.indexed_commit_sha,
85
+ )
86
+
87
+
88
+ @app.post("/repositories/{repo_id}/index", response_model=s.IndexStatusOut)
89
+ def index_repository_endpoint(
90
+ repo_id: int, session: Session = Depends(get_session),
91
+ who: str | None = Depends(principal),
92
+ ) -> s.IndexStatusOut:
93
+ from coderag.indexing.indexer import Indexer
94
+
95
+ repo = session.get(Repository, repo_id)
96
+ if repo is None:
97
+ raise HTTPException(status_code=404, detail="repository not found")
98
+ if not get_authorization_provider().can_access(who, repo.id):
99
+ raise HTTPException(status_code=403, detail="not authorized")
100
+ run, stats = Indexer(session).full_index(repo)
101
+ return s.IndexStatusOut(
102
+ repository_id=repo.id, status=run.status, mode=run.mode,
103
+ files_indexed=stats.files_indexed, symbols_indexed=stats.symbols_indexed,
104
+ embeddings_created=stats.embeddings_created,
105
+ relationships=stats.relationships_created,
106
+ duration_seconds=stats.duration_seconds, to_commit=stats.commit_sha,
107
+ )
108
+
109
+
110
+ @app.get("/repositories/{repo_id}/index/status", response_model=s.IndexStatusOut)
111
+ def index_status(
112
+ repo_id: int, session: Session = Depends(get_session),
113
+ ) -> s.IndexStatusOut:
114
+ run = session.scalar(
115
+ select(IndexingRun).where(IndexingRun.repository_id == repo_id)
116
+ .order_by(IndexingRun.id.desc())
117
+ )
118
+ if run is None:
119
+ raise HTTPException(status_code=404, detail="no indexing run for repository")
120
+ return s.IndexStatusOut(
121
+ repository_id=repo_id, status=run.status, mode=run.mode,
122
+ files_indexed=run.files_indexed, symbols_indexed=run.symbols_indexed,
123
+ embeddings_created=run.embeddings_created, duration_seconds=run.duration_seconds,
124
+ to_commit=run.to_commit, error=run.error,
125
+ )
126
+
127
+
128
+ @app.post("/search", response_model=s.SearchResponse)
129
+ def search_endpoint(
130
+ req: s.SearchRequest, session: Session = Depends(get_session),
131
+ who: str | None = Depends(principal),
132
+ ) -> s.SearchResponse:
133
+ repo = _resolve_authorized(session, req.repository, who)
134
+ _repo, outcome = run_search(
135
+ session, req.query, repo.name, top_n=req.limit,
136
+ semantic=req.semantic, graph=req.graph, record=True,
137
+ )
138
+ return s.SearchResponse(
139
+ repository=repo.name, latency_ms=outcome.latency_ms,
140
+ candidates=[
141
+ s.CandidateOut(
142
+ symbol_id=c.symbol_id, qualified_name=c.qualified_name,
143
+ symbol_type=c.symbol_type, file_path=c.file_path,
144
+ start_line=c.start_line, end_line=c.end_line,
145
+ score=round(c.fused_score, 6), reasons=sorted(c.reasons),
146
+ )
147
+ for c in outcome.candidates
148
+ ],
149
+ )
150
+
151
+
152
+ @app.post("/context", response_model=s.ContextResponse)
153
+ def context_endpoint(
154
+ req: s.ContextRequest, session: Session = Depends(get_session),
155
+ who: str | None = Depends(principal),
156
+ ) -> s.ContextResponse:
157
+ repo = _resolve_authorized(session, req.repository, who)
158
+ _repo, package, _outcome = run_context(
159
+ session, req.query, repo.name, max_tokens=req.max_tokens, finding=req.finding,
160
+ )
161
+ return s.ContextResponse(
162
+ repository=repo.name,
163
+ entries=[
164
+ s.ContextEntryOut(
165
+ category=e.category, qualified_name=e.candidate.qualified_name,
166
+ file_path=e.candidate.file_path, start_line=e.candidate.start_line,
167
+ end_line=e.candidate.end_line, tokens=e.tokens,
168
+ reasons=sorted(e.candidate.reasons),
169
+ )
170
+ for e in package.entries
171
+ ],
172
+ accounting=s.AccountingOut(**package.accounting.as_dict()),
173
+ prompt=package.prompt_text if req.include_prompt else None,
174
+ )
175
+
176
+
177
+ @app.post("/ask", response_model=s.AskResponse)
178
+ def ask_endpoint(
179
+ req: s.AskRequest, session: Session = Depends(get_session),
180
+ who: str | None = Depends(principal),
181
+ ) -> s.AskResponse:
182
+ repo = _resolve_authorized(session, req.repository, who)
183
+ try:
184
+ _repo, package, response, _outcome = run_ask(
185
+ session, req.query, repo.name, max_tokens=req.max_tokens,
186
+ max_output_tokens=req.max_output_tokens,
187
+ )
188
+ except RuntimeError as exc:
189
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
190
+ u = response.usage
191
+ return s.AskResponse(
192
+ repository=repo.name, answer=response.text,
193
+ usage=s.UsageOut(
194
+ input_tokens=u.input_tokens, output_tokens=u.output_tokens,
195
+ cached_input_tokens=u.cached_input_tokens, model=u.model,
196
+ latency_ms=u.latency_ms,
197
+ ),
198
+ accounting=s.AccountingOut(**package.accounting.as_dict()),
199
+ )
200
+
201
+
202
+ @app.get("/symbols/{symbol_id}", response_model=s.SymbolOut)
203
+ def get_symbol(
204
+ symbol_id: int, session: Session = Depends(get_session),
205
+ who: str | None = Depends(principal),
206
+ ) -> s.SymbolOut:
207
+ sym = session.get(Symbol, symbol_id)
208
+ if sym is None:
209
+ raise HTTPException(status_code=404, detail="symbol not found")
210
+ if not get_authorization_provider().can_access(who, sym.repository_id):
211
+ raise HTTPException(status_code=403, detail="not authorized")
212
+ return s.SymbolOut(
213
+ id=sym.id, qualified_name=sym.qualified_name, symbol_name=sym.symbol_name,
214
+ symbol_type=sym.symbol_type, file_path=sym.file_path, start_line=sym.start_line,
215
+ end_line=sym.end_line, signature=sym.signature, docstring=sym.docstring,
216
+ token_count=sym.token_count, source_code=sym.source_code,
217
+ )
218
+
219
+
220
+ @app.get("/symbols/{symbol_id}/relationships", response_model=list[s.RelationshipOut])
221
+ def get_symbol_relationships(
222
+ symbol_id: int, session: Session = Depends(get_session),
223
+ who: str | None = Depends(principal),
224
+ ) -> list[s.RelationshipOut]:
225
+ sym = session.get(Symbol, symbol_id)
226
+ if sym is None:
227
+ raise HTTPException(status_code=404, detail="symbol not found")
228
+ if not get_authorization_provider().can_access(who, sym.repository_id):
229
+ raise HTTPException(status_code=403, detail="not authorized")
230
+ rels = session.scalars(
231
+ select(SymbolRelationship).where(
232
+ (SymbolRelationship.source_symbol_id == symbol_id)
233
+ | (SymbolRelationship.target_symbol_id == symbol_id)
234
+ )
235
+ ).all()
236
+ qual: dict[int, str] = {
237
+ row[0]: row[1]
238
+ for row in session.execute(
239
+ select(Symbol.id, Symbol.qualified_name).where(
240
+ Symbol.repository_id == sym.repository_id
241
+ )
242
+ ).all()
243
+ }
244
+ out = []
245
+ for r in rels:
246
+ outgoing = r.source_symbol_id == symbol_id
247
+ out.append(
248
+ s.RelationshipOut(
249
+ relationship_type=r.relationship_type, confidence=r.confidence,
250
+ target_symbol_id=r.target_symbol_id,
251
+ target_qualified_name=(
252
+ qual.get(r.target_symbol_id) if r.target_symbol_id is not None else None
253
+ ),
254
+ target_name=r.target_name,
255
+ direction="outgoing" if outgoing else "incoming",
256
+ )
257
+ )
258
+ return out
259
+
260
+
261
+ @app.get("/metrics", response_model=s.MetricsOut)
262
+ def metrics(session: Session = Depends(get_session)) -> s.MetricsOut:
263
+ def count(model) -> int:
264
+ return session.scalar(select(func.count()).select_from(model)) or 0
265
+
266
+ avg_ctx = session.scalar(select(func.avg(QueryRecord.context_tokens))) or 0.0
267
+ avg_lat = session.scalar(select(func.avg(QueryRecord.retrieval_latency_ms))) or 0.0
268
+ in_tok = session.scalar(select(func.coalesce(func.sum(LLMRequest.input_tokens), 0))) or 0
269
+ out_tok = session.scalar(select(func.coalesce(func.sum(LLMRequest.output_tokens), 0))) or 0
270
+ cand_sum = session.scalar(
271
+ select(func.coalesce(func.sum(QueryRecord.candidate_tokens), 0))
272
+ ) or 0
273
+ ctx_sum = session.scalar(
274
+ select(func.coalesce(func.sum(QueryRecord.context_tokens), 0))
275
+ ) or 0
276
+ saved = int(cand_sum) - int(ctx_sum)
277
+ reduction = round(100.0 * saved / cand_sum, 1) if cand_sum else 0.0
278
+ base_sum = int(session.scalar(
279
+ select(func.coalesce(func.sum(QueryRecord.baseline_tokens), 0))
280
+ ) or 0)
281
+ # only queries that actually recorded a baseline contribute to this comparison
282
+ base_ctx = int(session.scalar(
283
+ select(func.coalesce(func.sum(QueryRecord.context_tokens), 0)).where(
284
+ QueryRecord.baseline_tokens > 0
285
+ )
286
+ ) or 0)
287
+ saved_vs_files = base_sum - base_ctx
288
+ reduction_vs_files = round(100.0 * saved_vs_files / base_sum, 1) if base_sum else 0.0
289
+ return s.MetricsOut(
290
+ repositories=count(Repository), symbols=count(Symbol),
291
+ embeddings=count(SymbolEmbedding), relationships=count(SymbolRelationship),
292
+ queries=count(QueryRecord), llm_requests=count(LLMRequest),
293
+ avg_context_tokens=round(float(avg_ctx), 1),
294
+ avg_retrieval_latency_ms=round(float(avg_lat), 2),
295
+ total_llm_input_tokens=int(in_tok), total_llm_output_tokens=int(out_tok),
296
+ total_candidate_tokens=int(cand_sum), total_context_tokens=int(ctx_sum),
297
+ total_tokens_saved=saved, avg_token_reduction_percent=reduction,
298
+ total_baseline_tokens=base_sum, total_saved_vs_files=saved_vs_files,
299
+ reduction_vs_files_percent=reduction_vs_files,
300
+ )
301
+
302
+
303
+ @app.get("/queries", response_model=list[s.QueryRow])
304
+ def recent_queries(
305
+ limit: int = 100, session: Session = Depends(get_session)
306
+ ) -> list[s.QueryRow]:
307
+ rows = session.execute(
308
+ select(QueryRecord, Repository.name)
309
+ .join(Repository, Repository.id == QueryRecord.repository_id)
310
+ .order_by(QueryRecord.id.desc())
311
+ .limit(limit)
312
+ ).all()
313
+ # sum LLM tokens per query in one pass
314
+ llm = {
315
+ qid: (int(itok), int(otok))
316
+ for qid, itok, otok in session.execute(
317
+ select(
318
+ LLMRequest.query_id,
319
+ func.coalesce(func.sum(LLMRequest.input_tokens), 0),
320
+ func.coalesce(func.sum(LLMRequest.output_tokens), 0),
321
+ ).group_by(LLMRequest.query_id)
322
+ ).all()
323
+ }
324
+ out: list[s.QueryRow] = []
325
+ for q, repo_name in rows:
326
+ saved = q.candidate_tokens - q.context_tokens
327
+ reduction = round(100.0 * saved / q.candidate_tokens, 1) if q.candidate_tokens else 0.0
328
+ li, lo = llm.get(q.id, (None, None))
329
+ out.append(s.QueryRow(
330
+ id=q.id, repository=repo_name, mode=q.mode, query=q.query_text,
331
+ candidates_found=q.candidates_found, candidates_selected=q.candidates_selected,
332
+ candidate_tokens=q.candidate_tokens, context_tokens=q.context_tokens,
333
+ tokens_saved=saved, reduction_percent=reduction,
334
+ baseline_tokens=q.baseline_tokens, baseline_files=q.baseline_files,
335
+ saved_vs_files=q.baseline_tokens - q.context_tokens if q.baseline_tokens else 0,
336
+ reduction_vs_files=(
337
+ round(100.0 * (q.baseline_tokens - q.context_tokens) / q.baseline_tokens, 1)
338
+ if q.baseline_tokens else 0.0
339
+ ),
340
+ retrieval_latency_ms=round(q.retrieval_latency_ms, 2),
341
+ llm_input_tokens=li, llm_output_tokens=lo,
342
+ created_at=q.created_at.isoformat() if q.created_at else "",
343
+ ))
344
+ return out
345
+
346
+
347
+ @app.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
348
+ def dashboard() -> str:
349
+ from coderag.api.dashboard import DASHBOARD_HTML
350
+
351
+ return DASHBOARD_HTML