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
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
|
coderag/api/__init__.py
ADDED
|
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
|