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.
Files changed (109) hide show
  1. bce/__init__.py +8 -0
  2. bce/api/__init__.py +6 -0
  3. bce/api/mcp/__init__.py +11 -0
  4. bce/api/mcp/server.py +61 -0
  5. bce/api/mcp/tools.py +262 -0
  6. bce/api/rest/__init__.py +11 -0
  7. bce/api/rest/app.py +239 -0
  8. bce/api/rest/deps.py +67 -0
  9. bce/api/rest/routes.py +581 -0
  10. bce/api/rest/schemas.py +130 -0
  11. bce/api/rest/static/assets/index-DrLRaTMU.css +1 -0
  12. bce/api/rest/static/assets/index-RAztiMtn.js +322 -0
  13. bce/api/rest/static/favicon.svg +17 -0
  14. bce/api/rest/static/index.html +24 -0
  15. bce/api/rest/ui/__init__.py +18 -0
  16. bce/api/rest/ui/queries.py +308 -0
  17. bce/api/rest/ui/router.py +156 -0
  18. bce/api/rest/ui/schemas.py +138 -0
  19. bce/api/rest/ui/trace.py +300 -0
  20. bce/bench/__init__.py +17 -0
  21. bce/bench/runner.py +226 -0
  22. bce/cli.py +401 -0
  23. bce/config.py +97 -0
  24. bce/core/__init__.py +1 -0
  25. bce/core/assembler/__init__.py +10 -0
  26. bce/core/assembler/assembler.py +103 -0
  27. bce/core/auth/__init__.py +10 -0
  28. bce/core/auth/scope.py +74 -0
  29. bce/core/coverage/__init__.py +14 -0
  30. bce/core/coverage/confidence.py +142 -0
  31. bce/core/i18n/__init__.py +12 -0
  32. bce/core/i18n/catalogs/en.json +37 -0
  33. bce/core/i18n/catalogs/tr.json +37 -0
  34. bce/core/i18n/locale.py +63 -0
  35. bce/core/i18n/translator.py +70 -0
  36. bce/core/logging.py +101 -0
  37. bce/core/orchestrator/__init__.py +17 -0
  38. bce/core/orchestrator/anchors.py +119 -0
  39. bce/core/orchestrator/expand.py +167 -0
  40. bce/core/orchestrator/orchestrator.py +77 -0
  41. bce/core/scoring/__init__.py +21 -0
  42. bce/core/scoring/engine.py +106 -0
  43. bce/domain/__init__.py +35 -0
  44. bce/domain/enums.py +96 -0
  45. bce/domain/models.py +199 -0
  46. bce/indexing/__init__.py +1 -0
  47. bce/indexing/embedder/__init__.py +11 -0
  48. bce/indexing/embedder/embedder.py +75 -0
  49. bce/indexing/embedder/encoder.py +151 -0
  50. bce/indexing/extractor/__init__.py +5 -0
  51. bce/indexing/extractor/bridges.py +149 -0
  52. bce/indexing/extractor/designnote.py +105 -0
  53. bce/indexing/extractor/extractor.py +118 -0
  54. bce/indexing/extractor/routes.py +69 -0
  55. bce/indexing/gitsync/__init__.py +36 -0
  56. bce/indexing/gitsync/bitbucket.py +73 -0
  57. bce/indexing/gitsync/local.py +129 -0
  58. bce/indexing/gitsync/remote.py +149 -0
  59. bce/indexing/indexer.py +471 -0
  60. bce/indexing/linker/__init__.py +11 -0
  61. bce/indexing/linker/linker.py +325 -0
  62. bce/indexing/parser/__init__.py +6 -0
  63. bce/indexing/parser/_treesitter.py +58 -0
  64. bce/indexing/parser/base.py +77 -0
  65. bce/indexing/parser/languages/__init__.py +1 -0
  66. bce/indexing/parser/languages/csharp_provider.py +564 -0
  67. bce/indexing/parser/languages/go_provider.py +420 -0
  68. bce/indexing/parser/languages/java_provider.py +511 -0
  69. bce/indexing/parser/languages/jsts_provider.py +764 -0
  70. bce/indexing/parser/languages/python_provider.py +677 -0
  71. bce/indexing/parser/registry.py +81 -0
  72. bce/indexing/parser/scip/__init__.py +27 -0
  73. bce/indexing/parser/scip/resolver.py +211 -0
  74. bce/indexing/parser/symbol_id.py +73 -0
  75. bce/indexing/upserter/__init__.py +5 -0
  76. bce/indexing/upserter/upserter.py +40 -0
  77. bce/jobs/__init__.py +30 -0
  78. bce/jobs/store.py +156 -0
  79. bce/jobs/worker.py +129 -0
  80. bce/storage/__init__.py +1 -0
  81. bce/storage/graph/__init__.py +11 -0
  82. bce/storage/graph/client.py +85 -0
  83. bce/storage/graph/repository.py +474 -0
  84. bce/storage/relational/__init__.py +1 -0
  85. bce/storage/relational/db.py +37 -0
  86. bce/storage/relational/migrations/0001_extensions_graph.sql +21 -0
  87. bce/storage/relational/migrations/0002_relational.sql +59 -0
  88. bce/storage/relational/migrations/0003_vector.sql +20 -0
  89. bce/storage/relational/migrations/0004_rls.sql +36 -0
  90. bce/storage/relational/migrations/0005_embeddings_dim.sql +14 -0
  91. bce/storage/relational/migrations/0006_fts.sql +26 -0
  92. bce/storage/relational/migrations/0007_jobs.sql +17 -0
  93. bce/storage/relational/migrator.py +139 -0
  94. bce/storage/relational/queries.py +63 -0
  95. bce/storage/vector/__init__.py +10 -0
  96. bce/storage/vector/store.py +110 -0
  97. bce/tools/__init__.py +6 -0
  98. bce/tools/layer1/__init__.py +19 -0
  99. bce/tools/layer1/primitives.py +222 -0
  100. bce/tools/layer2/__init__.py +10 -0
  101. bce/tools/layer2/search.py +277 -0
  102. bce/tools/layer3/__init__.py +22 -0
  103. bce/tools/layer3/orchestration.py +488 -0
  104. bgts_context_engine-0.1.0.dist-info/METADATA +361 -0
  105. bgts_context_engine-0.1.0.dist-info/RECORD +109 -0
  106. bgts_context_engine-0.1.0.dist-info/WHEEL +5 -0
  107. bgts_context_engine-0.1.0.dist-info/entry_points.txt +2 -0
  108. bgts_context_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
  109. 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
@@ -0,0 +1,6 @@
1
+ """Interface layer (thin adapters).
2
+
3
+ MCP and REST call the same core/tools; logic is never duplicated here (spec section 7). These
4
+ adapters only translate transport <-> tool calls and resolve the request locale for the i18n
5
+ presentation layer.
6
+ """
@@ -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}
@@ -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)