bgts-context-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bce/__init__.py +8 -0
- bce/api/__init__.py +6 -0
- bce/api/mcp/__init__.py +11 -0
- bce/api/mcp/server.py +61 -0
- bce/api/mcp/tools.py +262 -0
- bce/api/rest/__init__.py +11 -0
- bce/api/rest/app.py +239 -0
- bce/api/rest/deps.py +67 -0
- bce/api/rest/routes.py +581 -0
- bce/api/rest/schemas.py +130 -0
- bce/api/rest/static/assets/index-DrLRaTMU.css +1 -0
- bce/api/rest/static/assets/index-RAztiMtn.js +322 -0
- bce/api/rest/static/favicon.svg +17 -0
- bce/api/rest/static/index.html +24 -0
- bce/api/rest/ui/__init__.py +18 -0
- bce/api/rest/ui/queries.py +308 -0
- bce/api/rest/ui/router.py +156 -0
- bce/api/rest/ui/schemas.py +138 -0
- bce/api/rest/ui/trace.py +300 -0
- bce/bench/__init__.py +17 -0
- bce/bench/runner.py +226 -0
- bce/cli.py +401 -0
- bce/config.py +97 -0
- bce/core/__init__.py +1 -0
- bce/core/assembler/__init__.py +10 -0
- bce/core/assembler/assembler.py +103 -0
- bce/core/auth/__init__.py +10 -0
- bce/core/auth/scope.py +74 -0
- bce/core/coverage/__init__.py +14 -0
- bce/core/coverage/confidence.py +142 -0
- bce/core/i18n/__init__.py +12 -0
- bce/core/i18n/catalogs/en.json +37 -0
- bce/core/i18n/catalogs/tr.json +37 -0
- bce/core/i18n/locale.py +63 -0
- bce/core/i18n/translator.py +70 -0
- bce/core/logging.py +101 -0
- bce/core/orchestrator/__init__.py +17 -0
- bce/core/orchestrator/anchors.py +119 -0
- bce/core/orchestrator/expand.py +167 -0
- bce/core/orchestrator/orchestrator.py +77 -0
- bce/core/scoring/__init__.py +21 -0
- bce/core/scoring/engine.py +106 -0
- bce/domain/__init__.py +35 -0
- bce/domain/enums.py +96 -0
- bce/domain/models.py +199 -0
- bce/indexing/__init__.py +1 -0
- bce/indexing/embedder/__init__.py +11 -0
- bce/indexing/embedder/embedder.py +75 -0
- bce/indexing/embedder/encoder.py +151 -0
- bce/indexing/extractor/__init__.py +5 -0
- bce/indexing/extractor/bridges.py +149 -0
- bce/indexing/extractor/designnote.py +105 -0
- bce/indexing/extractor/extractor.py +118 -0
- bce/indexing/extractor/routes.py +69 -0
- bce/indexing/gitsync/__init__.py +36 -0
- bce/indexing/gitsync/bitbucket.py +73 -0
- bce/indexing/gitsync/local.py +129 -0
- bce/indexing/gitsync/remote.py +149 -0
- bce/indexing/indexer.py +471 -0
- bce/indexing/linker/__init__.py +11 -0
- bce/indexing/linker/linker.py +325 -0
- bce/indexing/parser/__init__.py +6 -0
- bce/indexing/parser/_treesitter.py +58 -0
- bce/indexing/parser/base.py +77 -0
- bce/indexing/parser/languages/__init__.py +1 -0
- bce/indexing/parser/languages/csharp_provider.py +564 -0
- bce/indexing/parser/languages/go_provider.py +420 -0
- bce/indexing/parser/languages/java_provider.py +511 -0
- bce/indexing/parser/languages/jsts_provider.py +764 -0
- bce/indexing/parser/languages/python_provider.py +677 -0
- bce/indexing/parser/registry.py +81 -0
- bce/indexing/parser/scip/__init__.py +27 -0
- bce/indexing/parser/scip/resolver.py +211 -0
- bce/indexing/parser/symbol_id.py +73 -0
- bce/indexing/upserter/__init__.py +5 -0
- bce/indexing/upserter/upserter.py +40 -0
- bce/jobs/__init__.py +30 -0
- bce/jobs/store.py +156 -0
- bce/jobs/worker.py +129 -0
- bce/storage/__init__.py +1 -0
- bce/storage/graph/__init__.py +11 -0
- bce/storage/graph/client.py +85 -0
- bce/storage/graph/repository.py +474 -0
- bce/storage/relational/__init__.py +1 -0
- bce/storage/relational/db.py +37 -0
- bce/storage/relational/migrations/0001_extensions_graph.sql +21 -0
- bce/storage/relational/migrations/0002_relational.sql +59 -0
- bce/storage/relational/migrations/0003_vector.sql +20 -0
- bce/storage/relational/migrations/0004_rls.sql +36 -0
- bce/storage/relational/migrations/0005_embeddings_dim.sql +14 -0
- bce/storage/relational/migrations/0006_fts.sql +26 -0
- bce/storage/relational/migrations/0007_jobs.sql +17 -0
- bce/storage/relational/migrator.py +139 -0
- bce/storage/relational/queries.py +63 -0
- bce/storage/vector/__init__.py +10 -0
- bce/storage/vector/store.py +110 -0
- bce/tools/__init__.py +6 -0
- bce/tools/layer1/__init__.py +19 -0
- bce/tools/layer1/primitives.py +222 -0
- bce/tools/layer2/__init__.py +10 -0
- bce/tools/layer2/search.py +277 -0
- bce/tools/layer3/__init__.py +22 -0
- bce/tools/layer3/orchestration.py +488 -0
- bgts_context_engine-0.1.0.dist-info/METADATA +361 -0
- bgts_context_engine-0.1.0.dist-info/RECORD +109 -0
- bgts_context_engine-0.1.0.dist-info/WHEEL +5 -0
- bgts_context_engine-0.1.0.dist-info/entry_points.txt +2 -0
- bgts_context_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- bgts_context_engine-0.1.0.dist-info/top_level.txt +1 -0
bce/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""BGTS Context Engine (BCE).
|
|
2
|
+
|
|
3
|
+
A deterministic, multi-language code-graph context engine. See README.md and the architecture
|
|
4
|
+
specification for details. This package is the provider side of the deterministic line (P1): it
|
|
5
|
+
contains no LLM and produces reproducible context packages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
bce/api/__init__.py
ADDED
bce/api/mcp/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""MCP server adapter (spec section 7: agent-native surface).
|
|
2
|
+
|
|
3
|
+
MCP is a *thin* adapter over the same core the REST API uses - no logic is duplicated. The tool
|
|
4
|
+
catalog (:mod:`bce.api.mcp.tools`) is SDK-independent so it can be introspected and tested without
|
|
5
|
+
the optional ``mcp`` package installed; :func:`bce.api.mcp.server.build_server` wires it into a real
|
|
6
|
+
MCP server when the SDK is present.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from bce.api.mcp.tools import TOOL_SPECS, dispatch_tool
|
|
10
|
+
|
|
11
|
+
__all__ = ["TOOL_SPECS", "dispatch_tool"]
|
bce/api/mcp/server.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""MCP server wiring (optional ``mcp`` SDK).
|
|
2
|
+
|
|
3
|
+
Binds the SDK-independent tool catalog (:mod:`bce.api.mcp.tools`) to a real MCP server. The ``mcp``
|
|
4
|
+
package is an optional dependency; importing this module without it raises a clear error only when
|
|
5
|
+
you actually try to build/run the server, so the rest of the package (and tests) stay importable.
|
|
6
|
+
|
|
7
|
+
Each tool call opens a fresh database connection (single DB: graph + vector, P5), dispatches to the
|
|
8
|
+
core, and returns the deterministic envelope as JSON text content.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_server(name: str = "bgts-context-engine") -> Any:
|
|
18
|
+
"""Construct an MCP ``Server`` exposing every BCE tool. Requires the ``mcp`` package."""
|
|
19
|
+
try:
|
|
20
|
+
from mcp.server import Server
|
|
21
|
+
from mcp.types import TextContent, Tool
|
|
22
|
+
except ModuleNotFoundError as exc: # pragma: no cover - depends on optional dep
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"The 'mcp' package is required for the MCP server. Install it with: pip install mcp"
|
|
25
|
+
) from exc
|
|
26
|
+
|
|
27
|
+
from bce.api.mcp.tools import TOOL_SPECS, dispatch_tool
|
|
28
|
+
from bce.storage.relational.db import connection
|
|
29
|
+
|
|
30
|
+
server = Server(name)
|
|
31
|
+
|
|
32
|
+
@server.list_tools()
|
|
33
|
+
async def _list_tools() -> list[Tool]: # pragma: no cover - requires SDK runtime
|
|
34
|
+
return [
|
|
35
|
+
Tool(name=tool, description=spec["description"], inputSchema=spec["schema"])
|
|
36
|
+
for tool, spec in TOOL_SPECS.items()
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
@server.call_tool()
|
|
40
|
+
async def _call_tool(
|
|
41
|
+
name: str, arguments: dict[str, Any]
|
|
42
|
+
) -> list[TextContent]: # pragma: no cover
|
|
43
|
+
with connection() as conn:
|
|
44
|
+
try:
|
|
45
|
+
result = dispatch_tool(conn, name, arguments)
|
|
46
|
+
conn.rollback() # read-only tools never persist
|
|
47
|
+
except Exception:
|
|
48
|
+
conn.rollback()
|
|
49
|
+
raise
|
|
50
|
+
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, default=str))]
|
|
51
|
+
|
|
52
|
+
return server
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def run_stdio(name: str = "bgts-context-engine") -> None: # pragma: no cover - SDK runtime
|
|
56
|
+
"""Run the MCP server over stdio (the common agent transport)."""
|
|
57
|
+
from mcp.server.stdio import stdio_server
|
|
58
|
+
|
|
59
|
+
server = build_server(name)
|
|
60
|
+
async with stdio_server() as (read, write):
|
|
61
|
+
await server.run(read, write, server.create_initialization_options())
|
bce/api/mcp/tools.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""MCP tool catalog + dispatcher (SDK-independent).
|
|
2
|
+
|
|
3
|
+
Declares every Layer 1-2-3 tool as an MCP-style spec (name, description, JSON input schema) and
|
|
4
|
+
maps a tool name + arguments to a call against the deterministic core, using the caller's connection.
|
|
5
|
+
This is the single source of truth the SDK server binds to, and it is importable/testable without the
|
|
6
|
+
optional ``mcp`` package.
|
|
7
|
+
|
|
8
|
+
Every tool returns the standard envelope ``{tool, payload, message, locale}`` (P1: payload is
|
|
9
|
+
language-neutral; only ``message`` is localized).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import psycopg
|
|
17
|
+
|
|
18
|
+
from bce.core.auth.scope import Principal, ScopeFilter
|
|
19
|
+
from bce.storage.graph.client import GraphClient
|
|
20
|
+
from bce.storage.graph.repository import GraphRepository
|
|
21
|
+
from bce.storage.vector.store import VectorStore
|
|
22
|
+
from bce.tools.layer1 import (
|
|
23
|
+
find_implementers,
|
|
24
|
+
find_references,
|
|
25
|
+
get_call_graph,
|
|
26
|
+
get_dependencies,
|
|
27
|
+
get_type_hierarchy,
|
|
28
|
+
resolve_symbol,
|
|
29
|
+
)
|
|
30
|
+
from bce.tools.layer2 import find_similar_code, hybrid_search, semantic_search
|
|
31
|
+
from bce.tools.layer3 import (
|
|
32
|
+
assemble_context,
|
|
33
|
+
expand_blast_radius,
|
|
34
|
+
get_context_for_task,
|
|
35
|
+
select_repos,
|
|
36
|
+
suggest_change_sites,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_STR = {"type": "string"}
|
|
40
|
+
_STR_LIST = {"type": "array", "items": {"type": "string"}}
|
|
41
|
+
_INT = {"type": "integer"}
|
|
42
|
+
_BOOL = {"type": "boolean"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _schema(props: dict[str, Any], required: list[str]) -> dict[str, Any]:
|
|
46
|
+
return {"type": "object", "properties": props, "required": required}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Tool catalog: name -> (description, input schema). Mirrors the REST surface 1:1.
|
|
50
|
+
TOOL_SPECS: dict[str, dict[str, Any]] = {
|
|
51
|
+
"resolve_symbol": {
|
|
52
|
+
"description": "Layer 1: resolve a symbol by name to its definition + symbol_id.",
|
|
53
|
+
"schema": _schema({"name": _STR, "repo_id": _STR, "locale": _STR}, ["name"]),
|
|
54
|
+
},
|
|
55
|
+
"find_references": {
|
|
56
|
+
"description": "Layer 1: find references (callsites) to a symbol_id.",
|
|
57
|
+
"schema": _schema({"symbol_id": _STR, "locale": _STR}, ["symbol_id"]),
|
|
58
|
+
},
|
|
59
|
+
"find_implementers": {
|
|
60
|
+
"description": "Layer 1: types that inherit/implement a type symbol.",
|
|
61
|
+
"schema": _schema({"symbol_id": _STR, "locale": _STR}, ["symbol_id"]),
|
|
62
|
+
},
|
|
63
|
+
"get_call_graph": {
|
|
64
|
+
"description": "Layer 1: callers/callees subgraph around a symbol.",
|
|
65
|
+
"schema": _schema(
|
|
66
|
+
{"symbol_id": _STR, "hops": _INT, "direction": _STR, "locale": _STR}, ["symbol_id"]
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
"get_dependencies": {
|
|
70
|
+
"description": "Layer 1: IMPORTS edges out of a file (optionally transitive).",
|
|
71
|
+
"schema": _schema(
|
|
72
|
+
{"file_id": _STR, "transitive": {"type": "boolean"}, "locale": _STR}, ["file_id"]
|
|
73
|
+
),
|
|
74
|
+
},
|
|
75
|
+
"get_type_hierarchy": {
|
|
76
|
+
"description": "Layer 1: super/sub types of a type symbol.",
|
|
77
|
+
"schema": _schema({"symbol_id": _STR, "locale": _STR}, ["symbol_id"]),
|
|
78
|
+
},
|
|
79
|
+
"semantic_search": {
|
|
80
|
+
"description": "Layer 2: nearest symbols for a natural-language query (anchor finding).",
|
|
81
|
+
"schema": _schema(
|
|
82
|
+
{"query": _STR, "repo_ids": _STR_LIST, "limit": _INT, "locale": _STR}, ["query"]
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
"hybrid_search": {
|
|
86
|
+
"description": "Layer 2: keyword + semantic + structural blended search.",
|
|
87
|
+
"schema": _schema(
|
|
88
|
+
{"query": _STR, "repo_ids": _STR_LIST, "limit": _INT, "locale": _STR}, ["query"]
|
|
89
|
+
),
|
|
90
|
+
},
|
|
91
|
+
"find_similar_code": {
|
|
92
|
+
"description": "Layer 2: nearest symbols to a code fragment.",
|
|
93
|
+
"schema": _schema(
|
|
94
|
+
{"code": _STR, "repo_ids": _STR_LIST, "limit": _INT, "locale": _STR}, ["code"]
|
|
95
|
+
),
|
|
96
|
+
},
|
|
97
|
+
"get_context_for_task": {
|
|
98
|
+
"description": "Layer 3: assembled context package + coverage for a task.",
|
|
99
|
+
"schema": _schema(
|
|
100
|
+
{
|
|
101
|
+
"task_text": _STR,
|
|
102
|
+
"task_id": _STR,
|
|
103
|
+
"max_tokens": _INT,
|
|
104
|
+
"max_candidates": _INT,
|
|
105
|
+
"commit": _STR,
|
|
106
|
+
"repo_ids": _STR_LIST,
|
|
107
|
+
"explicit_symbols": _STR_LIST,
|
|
108
|
+
"route_paths": _STR_LIST,
|
|
109
|
+
"history_file_ids": _STR_LIST,
|
|
110
|
+
"component_repo_ids": _STR_LIST,
|
|
111
|
+
"semantic_candidates": _STR_LIST,
|
|
112
|
+
"auto_semantic": _BOOL,
|
|
113
|
+
"locale": _STR,
|
|
114
|
+
},
|
|
115
|
+
["task_text"],
|
|
116
|
+
),
|
|
117
|
+
},
|
|
118
|
+
"suggest_change_sites": {
|
|
119
|
+
"description": "Layer 3: scored change-site candidates for a task.",
|
|
120
|
+
"schema": _schema(
|
|
121
|
+
{
|
|
122
|
+
"task_text": _STR,
|
|
123
|
+
"max_candidates": _INT,
|
|
124
|
+
"commit": _STR,
|
|
125
|
+
"auto_semantic": _BOOL,
|
|
126
|
+
"locale": _STR,
|
|
127
|
+
},
|
|
128
|
+
["task_text"],
|
|
129
|
+
),
|
|
130
|
+
},
|
|
131
|
+
"expand_blast_radius": {
|
|
132
|
+
"description": "Layer 3: impacted files/repos/symbols for target symbols.",
|
|
133
|
+
"schema": _schema({"target_symbols": _STR_LIST, "locale": _STR}, ["target_symbols"]),
|
|
134
|
+
},
|
|
135
|
+
"select_repos": {
|
|
136
|
+
"description": "Layer 3: candidate repo set for a task.",
|
|
137
|
+
"schema": _schema({"task_text": _STR, "locale": _STR}, ["task_text"]),
|
|
138
|
+
},
|
|
139
|
+
"assemble_context": {
|
|
140
|
+
"description": "Layer 3: dedup a symbol list and fit it into a token budget.",
|
|
141
|
+
"schema": _schema(
|
|
142
|
+
{"symbol_ids": _STR_LIST, "max_tokens": _INT, "locale": _STR}, ["symbol_ids"]
|
|
143
|
+
),
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def dispatch_tool(
|
|
149
|
+
conn: psycopg.Connection,
|
|
150
|
+
name: str,
|
|
151
|
+
arguments: dict[str, Any],
|
|
152
|
+
*,
|
|
153
|
+
user_id: str | None = None,
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
"""Route an MCP tool call to the core, using ``conn`` for graph + vector access (single DB)."""
|
|
156
|
+
if name not in TOOL_SPECS:
|
|
157
|
+
raise KeyError(f"unknown tool: {name}")
|
|
158
|
+
|
|
159
|
+
repository = GraphRepository(GraphClient(conn))
|
|
160
|
+
store = VectorStore(conn)
|
|
161
|
+
args = dict(arguments)
|
|
162
|
+
locale = args.pop("locale", None)
|
|
163
|
+
scope = (
|
|
164
|
+
ScopeFilter.from_scopes(conn, user_id)
|
|
165
|
+
if user_id is not None
|
|
166
|
+
else ScopeFilter(Principal.system())
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if name == "resolve_symbol":
|
|
170
|
+
return resolve_symbol(repository, args["name"], repo_id=args.get("repo_id"), locale=locale)
|
|
171
|
+
if name == "find_references":
|
|
172
|
+
return find_references(repository, args["symbol_id"], locale=locale)
|
|
173
|
+
if name == "find_implementers":
|
|
174
|
+
return find_implementers(repository, args["symbol_id"], locale=locale)
|
|
175
|
+
if name == "get_call_graph":
|
|
176
|
+
return get_call_graph(
|
|
177
|
+
repository,
|
|
178
|
+
args["symbol_id"],
|
|
179
|
+
hops=args.get("hops", 1),
|
|
180
|
+
direction=args.get("direction", "both"),
|
|
181
|
+
locale=locale,
|
|
182
|
+
)
|
|
183
|
+
if name == "get_dependencies":
|
|
184
|
+
return get_dependencies(
|
|
185
|
+
repository, args["file_id"], transitive=args.get("transitive", False), locale=locale
|
|
186
|
+
)
|
|
187
|
+
if name == "get_type_hierarchy":
|
|
188
|
+
return get_type_hierarchy(repository, args["symbol_id"], locale=locale)
|
|
189
|
+
if name == "semantic_search":
|
|
190
|
+
return semantic_search(
|
|
191
|
+
store,
|
|
192
|
+
args["query"],
|
|
193
|
+
repo_ids=args.get("repo_ids"),
|
|
194
|
+
limit=args.get("limit", 10),
|
|
195
|
+
locale=locale,
|
|
196
|
+
)
|
|
197
|
+
if name == "hybrid_search":
|
|
198
|
+
return hybrid_search(
|
|
199
|
+
repository,
|
|
200
|
+
store,
|
|
201
|
+
args["query"],
|
|
202
|
+
repo_ids=args.get("repo_ids"),
|
|
203
|
+
limit=args.get("limit", 10),
|
|
204
|
+
locale=locale,
|
|
205
|
+
)
|
|
206
|
+
if name == "find_similar_code":
|
|
207
|
+
return find_similar_code(
|
|
208
|
+
store,
|
|
209
|
+
args["code"],
|
|
210
|
+
repo_ids=args.get("repo_ids"),
|
|
211
|
+
limit=args.get("limit", 10),
|
|
212
|
+
locale=locale,
|
|
213
|
+
)
|
|
214
|
+
if name == "get_context_for_task":
|
|
215
|
+
return get_context_for_task(
|
|
216
|
+
repository, store=store, scope=scope, locale=locale, **_l3_kwargs(args)
|
|
217
|
+
)
|
|
218
|
+
if name == "suggest_change_sites":
|
|
219
|
+
return suggest_change_sites(
|
|
220
|
+
repository, store=store, scope=scope, locale=locale, **_l3_kwargs(args)
|
|
221
|
+
)
|
|
222
|
+
if name == "expand_blast_radius":
|
|
223
|
+
return expand_blast_radius(
|
|
224
|
+
repository, target_symbols=args["target_symbols"], scope=scope, locale=locale
|
|
225
|
+
)
|
|
226
|
+
if name == "select_repos":
|
|
227
|
+
return select_repos(
|
|
228
|
+
repository,
|
|
229
|
+
task_text=args["task_text"],
|
|
230
|
+
component_repo_ids=args.get("component_repo_ids"),
|
|
231
|
+
history_file_ids=args.get("history_file_ids"),
|
|
232
|
+
scope=scope,
|
|
233
|
+
locale=locale,
|
|
234
|
+
)
|
|
235
|
+
if name == "assemble_context":
|
|
236
|
+
return assemble_context(
|
|
237
|
+
repository,
|
|
238
|
+
symbol_ids=args["symbol_ids"],
|
|
239
|
+
max_tokens=args.get("max_tokens", 4000),
|
|
240
|
+
scope=scope,
|
|
241
|
+
locale=locale,
|
|
242
|
+
)
|
|
243
|
+
raise KeyError(f"unhandled tool: {name}") # pragma: no cover - guarded above
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _l3_kwargs(args: dict[str, Any]) -> dict[str, Any]:
|
|
247
|
+
"""Filter task-oriented Layer-3 kwargs (shared by context/change-sites)."""
|
|
248
|
+
allowed = {
|
|
249
|
+
"task_text",
|
|
250
|
+
"task_id",
|
|
251
|
+
"max_tokens",
|
|
252
|
+
"max_candidates",
|
|
253
|
+
"commit",
|
|
254
|
+
"repo_ids",
|
|
255
|
+
"explicit_symbols",
|
|
256
|
+
"route_paths",
|
|
257
|
+
"history_file_ids",
|
|
258
|
+
"component_repo_ids",
|
|
259
|
+
"semantic_candidates",
|
|
260
|
+
"auto_semantic",
|
|
261
|
+
}
|
|
262
|
+
return {k: v for k, v in args.items() if k in allowed}
|
bce/api/rest/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""REST adapter (FastAPI).
|
|
2
|
+
|
|
3
|
+
A thin transport over the Layer-1 tools. It does not implement retrieval/graph logic itself; it
|
|
4
|
+
opens a per-request database connection, resolves the request locale (i18n presentation layer), and
|
|
5
|
+
delegates to ``bce.tools``. Import :func:`create_app` (factory) or ``app`` (module-level instance,
|
|
6
|
+
for ``uvicorn bce.api.rest.app:app``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from bce.api.rest.app import app, create_app
|
|
10
|
+
|
|
11
|
+
__all__ = ["app", "create_app"]
|
bce/api/rest/app.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""FastAPI application factory.
|
|
2
|
+
|
|
3
|
+
``create_app`` wires the router, the request-logging middleware, and the lifespan that runs the
|
|
4
|
+
background job workers; ``app`` is a module-level instance so the server can be started with
|
|
5
|
+
``uvicorn bce.api.rest.app:app`` (or via ``bce serve``). Every request gets its own database
|
|
6
|
+
connection through the ``get_repository`` dependency.
|
|
7
|
+
|
|
8
|
+
Request logging has two levels:
|
|
9
|
+
|
|
10
|
+
- INFO: one summary line per request (method, path, status, duration).
|
|
11
|
+
- DEBUG (``BCE_LOG_LEVEL=DEBUG``): additionally logs the request start, query params, the JSON
|
|
12
|
+
request body (secrets masked, size-capped) and the response body (size-capped) for **every**
|
|
13
|
+
endpoint.
|
|
14
|
+
|
|
15
|
+
Released distributions carry a compiled frontend which is served under ``/ui``. A source
|
|
16
|
+
checkout has no bundle, so that mount is skipped and the Vite dev server is used instead.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import mimetypes
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import AsyncIterator
|
|
26
|
+
from contextlib import asynccontextmanager
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from fastapi import FastAPI, Request, Response
|
|
31
|
+
from fastapi.responses import RedirectResponse
|
|
32
|
+
from fastapi.staticfiles import StaticFiles
|
|
33
|
+
|
|
34
|
+
from bce import __version__
|
|
35
|
+
from bce.api.rest.routes import router
|
|
36
|
+
from bce.config import get_settings
|
|
37
|
+
from bce.core.logging import get_logger, setup_logging
|
|
38
|
+
|
|
39
|
+
logger = get_logger("api.rest")
|
|
40
|
+
|
|
41
|
+
#: Request/response bodies logged at DEBUG are truncated to this many characters.
|
|
42
|
+
_BODY_LOG_LIMIT = 4000
|
|
43
|
+
|
|
44
|
+
#: Body keys whose values are masked in DEBUG logs (credentials must never reach the logs).
|
|
45
|
+
_SENSITIVE_KEYS = ("token", "password", "secret", "api_key", "username", "authorization")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _mask(value: Any) -> Any:
|
|
49
|
+
"""Recursively mask sensitive fields in a decoded JSON structure."""
|
|
50
|
+
if isinstance(value, dict):
|
|
51
|
+
return {
|
|
52
|
+
key: ("***" if any(s in key.lower() for s in _SENSITIVE_KEYS) and val else _mask(val))
|
|
53
|
+
for key, val in value.items()
|
|
54
|
+
}
|
|
55
|
+
if isinstance(value, list):
|
|
56
|
+
return [_mask(item) for item in value]
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _body_for_log(raw: bytes) -> str:
|
|
61
|
+
"""Masked, truncated representation of a request/response body for DEBUG logs."""
|
|
62
|
+
if not raw:
|
|
63
|
+
return ""
|
|
64
|
+
try:
|
|
65
|
+
text = json.dumps(_mask(json.loads(raw)), ensure_ascii=False)
|
|
66
|
+
except (ValueError, UnicodeDecodeError):
|
|
67
|
+
text = raw[:_BODY_LOG_LIMIT].decode("utf-8", errors="replace")
|
|
68
|
+
if len(text) > _BODY_LOG_LIMIT:
|
|
69
|
+
text = text[:_BODY_LOG_LIMIT] + f"... (+{len(text) - _BODY_LOG_LIMIT} chars)"
|
|
70
|
+
return text
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@asynccontextmanager
|
|
74
|
+
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
75
|
+
"""Start the job worker pool with the server; drain it on shutdown.
|
|
76
|
+
|
|
77
|
+
Shutdown is graceful: workers stop claiming new jobs and the in-flight job is awaited.
|
|
78
|
+
"""
|
|
79
|
+
from bce.jobs.worker import JobWorkerPool
|
|
80
|
+
|
|
81
|
+
setup_logging()
|
|
82
|
+
pool = JobWorkerPool()
|
|
83
|
+
pool.start()
|
|
84
|
+
try:
|
|
85
|
+
yield
|
|
86
|
+
finally:
|
|
87
|
+
pool.stop()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
#: Directory the compiled frontend is vendored into at build time (scripts/build_ui.py).
|
|
91
|
+
UI_DIST_DIR = Path(__file__).parent / "static"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ui_is_bundled() -> bool:
|
|
95
|
+
"""True when a compiled frontend ships with this installation."""
|
|
96
|
+
return (UI_DIST_DIR / "index.html").is_file()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
#: StaticFiles resolves content types through :mod:`mimetypes`, which on Windows consults the
|
|
100
|
+
#: registry -- where installed software commonly maps ``.js`` to ``text/plain``. Browsers apply
|
|
101
|
+
#: strict MIME checking to ``<script type="module">``, which is what Vite emits, so that mapping
|
|
102
|
+
#: makes the UI a blank page. Pin the types the bundle relies on instead of trusting the host.
|
|
103
|
+
_UI_CONTENT_TYPES = {
|
|
104
|
+
".js": "text/javascript",
|
|
105
|
+
".mjs": "text/javascript",
|
|
106
|
+
".css": "text/css",
|
|
107
|
+
".svg": "image/svg+xml",
|
|
108
|
+
".json": "application/json",
|
|
109
|
+
".woff": "font/woff",
|
|
110
|
+
".woff2": "font/woff2",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _mount_ui(app: FastAPI) -> None:
|
|
115
|
+
"""Serve the compiled frontend under ``/ui``.
|
|
116
|
+
|
|
117
|
+
Does nothing when the bundle is absent, which is the normal case for a source checkout:
|
|
118
|
+
developers run the Vite dev server instead (see web/README.md).
|
|
119
|
+
"""
|
|
120
|
+
if not ui_is_bundled():
|
|
121
|
+
logger.debug("ui bundle not present; skipping /ui mount", extra={"path": str(UI_DIST_DIR)})
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
for suffix, content_type in _UI_CONTENT_TYPES.items():
|
|
125
|
+
mimetypes.add_type(content_type, suffix)
|
|
126
|
+
|
|
127
|
+
@app.get("/ui", include_in_schema=False)
|
|
128
|
+
async def ui_root() -> RedirectResponse:
|
|
129
|
+
return RedirectResponse(url="/ui/")
|
|
130
|
+
|
|
131
|
+
# html=True serves index.html for the directory root. There is deliberately no catch-all
|
|
132
|
+
# fallback: the UI is a single view with no client-side router, so a missing asset should
|
|
133
|
+
# surface as a 404 rather than be masked by an HTML response.
|
|
134
|
+
app.mount("/ui/", StaticFiles(directory=UI_DIST_DIR, html=True), name="ui")
|
|
135
|
+
logger.info("ui bundle mounted", extra={"path": str(UI_DIST_DIR), "url": "/ui/"})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def create_app(*, serve_ui: bool = True) -> FastAPI:
|
|
139
|
+
setup_logging()
|
|
140
|
+
app = FastAPI(
|
|
141
|
+
title="BGTS Context Engine",
|
|
142
|
+
version=__version__,
|
|
143
|
+
summary="Deterministic, multi-language code-graph context engine (Layer 1-2-3 REST surface).",
|
|
144
|
+
lifespan=_lifespan,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@app.middleware("http")
|
|
148
|
+
async def log_requests(request: Request, call_next) -> Response:
|
|
149
|
+
debug = logger.isEnabledFor(logging.DEBUG)
|
|
150
|
+
start = time.perf_counter()
|
|
151
|
+
|
|
152
|
+
if debug:
|
|
153
|
+
extra: dict[str, Any] = {
|
|
154
|
+
"method": request.method,
|
|
155
|
+
"path": request.url.path,
|
|
156
|
+
"query": dict(request.query_params),
|
|
157
|
+
"client": request.client.host if request.client else None,
|
|
158
|
+
}
|
|
159
|
+
# Safe with BaseHTTPMiddleware: Starlette caches the body and replays it downstream.
|
|
160
|
+
raw = await request.body()
|
|
161
|
+
if raw:
|
|
162
|
+
extra["body"] = _body_for_log(raw)
|
|
163
|
+
logger.debug("request started", extra=extra)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
response = await call_next(request)
|
|
167
|
+
except Exception:
|
|
168
|
+
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
|
169
|
+
logger.exception(
|
|
170
|
+
"request failed",
|
|
171
|
+
extra={
|
|
172
|
+
"method": request.method,
|
|
173
|
+
"path": request.url.path,
|
|
174
|
+
"duration_ms": duration_ms,
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
duration_ms = round((time.perf_counter() - start) * 1000, 2)
|
|
180
|
+
logger.info(
|
|
181
|
+
"request",
|
|
182
|
+
extra={
|
|
183
|
+
"method": request.method,
|
|
184
|
+
"path": request.url.path,
|
|
185
|
+
"status_code": response.status_code,
|
|
186
|
+
"duration_ms": duration_ms,
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if debug and hasattr(response, "body_iterator"):
|
|
191
|
+
chunks = [chunk async for chunk in response.body_iterator]
|
|
192
|
+
raw_body = b"".join(chunks)
|
|
193
|
+
logger.debug(
|
|
194
|
+
"response body",
|
|
195
|
+
extra={
|
|
196
|
+
"method": request.method,
|
|
197
|
+
"path": request.url.path,
|
|
198
|
+
"status_code": response.status_code,
|
|
199
|
+
"size_bytes": len(raw_body),
|
|
200
|
+
"body": _body_for_log(raw_body),
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
response = Response(
|
|
204
|
+
content=raw_body,
|
|
205
|
+
status_code=response.status_code,
|
|
206
|
+
headers=dict(response.headers),
|
|
207
|
+
media_type=response.media_type,
|
|
208
|
+
background=response.background,
|
|
209
|
+
)
|
|
210
|
+
return response
|
|
211
|
+
|
|
212
|
+
app.include_router(router)
|
|
213
|
+
|
|
214
|
+
# --- UI layer (optional, self-contained; see bce/api/rest/ui/__init__.py) ---
|
|
215
|
+
# To remove the UI layer: delete the bce/api/rest/ui package and this block.
|
|
216
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
217
|
+
|
|
218
|
+
from bce.api.rest.ui import ui_router
|
|
219
|
+
|
|
220
|
+
app.include_router(ui_router)
|
|
221
|
+
app.add_middleware(
|
|
222
|
+
CORSMiddleware,
|
|
223
|
+
allow_origins=list(get_settings().cors_origins),
|
|
224
|
+
allow_methods=["GET", "POST"], # POST: /v1/ui/context-trace (read-only pipeline trace).
|
|
225
|
+
allow_headers=["*"],
|
|
226
|
+
)
|
|
227
|
+
if serve_ui:
|
|
228
|
+
_mount_ui(app)
|
|
229
|
+
# --- end UI layer ---
|
|
230
|
+
|
|
231
|
+
return app
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def create_api_only_app() -> FastAPI:
|
|
235
|
+
"""Application without the ``/ui`` mount, for ``bce serve --no-ui``."""
|
|
236
|
+
return create_app(serve_ui=False)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
app = create_app()
|
bce/api/rest/deps.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""FastAPI dependencies: per-request database repository, vector store, locale, and scope.
|
|
2
|
+
|
|
3
|
+
These are deliberately tiny so they can be overridden in tests (``app.dependency_overrides``)
|
|
4
|
+
without a live database.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
|
|
11
|
+
from fastapi import Depends, Header, Query
|
|
12
|
+
|
|
13
|
+
from bce.core.auth.scope import Principal, ScopeFilter
|
|
14
|
+
from bce.core.i18n import get_translator
|
|
15
|
+
from bce.storage.graph.client import GraphClient
|
|
16
|
+
from bce.storage.graph.repository import GraphRepository
|
|
17
|
+
from bce.storage.relational.db import connection
|
|
18
|
+
from bce.storage.vector.store import VectorStore
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_repository() -> Iterator[GraphRepository]:
|
|
22
|
+
"""Yield a graph repository bound to a fresh connection (closed when the request ends).
|
|
23
|
+
|
|
24
|
+
Reads do not commit. Write endpoints (e.g. ``/index``) commit explicitly; on any error we roll
|
|
25
|
+
back so a half-written transaction never lingers on the pooled connection.
|
|
26
|
+
"""
|
|
27
|
+
with connection() as conn:
|
|
28
|
+
try:
|
|
29
|
+
yield GraphRepository(GraphClient(conn))
|
|
30
|
+
except Exception:
|
|
31
|
+
conn.rollback()
|
|
32
|
+
raise
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_vector_store(
|
|
36
|
+
repository: GraphRepository = Depends(get_repository),
|
|
37
|
+
) -> VectorStore:
|
|
38
|
+
"""Vector store bound to the same request connection as the graph repository (single DB, P5)."""
|
|
39
|
+
return VectorStore(repository.client.conn)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_scope(
|
|
43
|
+
repository: GraphRepository = Depends(get_repository),
|
|
44
|
+
user_id: str | None = Header(default=None, alias="X-BCE-User"),
|
|
45
|
+
) -> ScopeFilter:
|
|
46
|
+
"""Build the scope filter from the ``X-BCE-User`` header via the ``scopes`` table (section 9).
|
|
47
|
+
|
|
48
|
+
No header -> system principal (allow-all), matching pre-Phase-4 behaviour. On any lookup error we
|
|
49
|
+
fail closed to an empty scope so a misconfiguration cannot leak inaccessible repos.
|
|
50
|
+
"""
|
|
51
|
+
if user_id is None:
|
|
52
|
+
return ScopeFilter(Principal.system())
|
|
53
|
+
try:
|
|
54
|
+
return ScopeFilter.from_scopes(repository.client.conn, user_id)
|
|
55
|
+
except Exception:
|
|
56
|
+
return ScopeFilter(Principal(user_id=user_id, allowed_repo_ids=[]))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_locale(
|
|
60
|
+
locale: str | None = Query(default=None, description="Force a locale (en/tr)."),
|
|
61
|
+
accept_language: str | None = Header(default=None),
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Resolve the response locale from ``?locale=`` (wins) then the ``Accept-Language`` header.
|
|
64
|
+
|
|
65
|
+
Returns a supported locale; the deterministic payload is unaffected (i18n is presentation only).
|
|
66
|
+
"""
|
|
67
|
+
return get_translator().resolve(requested=locale, accept_language=accept_language)
|