pretensor 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.
- pretensor/__init__.py +50 -0
- pretensor/benchmark/__init__.py +54 -0
- pretensor/benchmark/cli.py +294 -0
- pretensor/benchmark/fixtures.py +84 -0
- pretensor/benchmark/l1/__init__.py +23 -0
- pretensor/benchmark/l1/metrics.py +141 -0
- pretensor/benchmark/l1/pipeline.py +188 -0
- pretensor/benchmark/l1/runner.py +245 -0
- pretensor/benchmark/l2/__init__.py +27 -0
- pretensor/benchmark/l2/gold.py +236 -0
- pretensor/benchmark/l2/metrics.py +146 -0
- pretensor/benchmark/l2/pipeline.py +124 -0
- pretensor/benchmark/l2/runner.py +530 -0
- pretensor/benchmark/l3/__init__.py +73 -0
- pretensor/benchmark/l3/agent.py +316 -0
- pretensor/benchmark/l3/db.py +188 -0
- pretensor/benchmark/l3/gold.py +85 -0
- pretensor/benchmark/l3/llm_client.py +395 -0
- pretensor/benchmark/l3/mcp_client.py +357 -0
- pretensor/benchmark/l3/pretensor_runner.py +456 -0
- pretensor/benchmark/l3/prompt.py +132 -0
- pretensor/benchmark/l3/runner.py +358 -0
- pretensor/benchmark/l3/sql_equivalence.py +176 -0
- pretensor/benchmark/release_gate.py +448 -0
- pretensor/benchmark/results.py +298 -0
- pretensor/benchmark/runner.py +109 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/_source_runner.py +147 -0
- pretensor/cli/commands/analyze.py +201 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +126 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +131 -0
- pretensor/cli/commands/index.py +559 -0
- pretensor/cli/commands/list.py +76 -0
- pretensor/cli/commands/quickstart.py +207 -0
- pretensor/cli/commands/reindex.py +646 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +144 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +176 -0
- pretensor/cli/config_file.py +442 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +109 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +226 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +468 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +97 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/mysql.py +402 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +938 -0
- pretensor/connectors/registry.py +93 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +908 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +307 -0
- pretensor/core/dsn_crypto.py +51 -0
- pretensor/core/graph_schema_manager.py +246 -0
- pretensor/core/graph_store.py +1226 -0
- pretensor/core/ids.py +101 -0
- pretensor/core/portable_export.py +276 -0
- pretensor/core/query_runner.py +67 -0
- pretensor/core/registry.py +209 -0
- pretensor/core/schema.py +473 -0
- pretensor/core/secure_io.py +93 -0
- pretensor/core/store.py +469 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/analyze/__init__.py +0 -0
- pretensor/enrichment/analyze/classify.py +49 -0
- pretensor/enrichment/analyze/extract_python.py +196 -0
- pretensor/enrichment/analyze/parse.py +141 -0
- pretensor/enrichment/analyze/pipeline.py +195 -0
- pretensor/enrichment/analyze/summary.py +38 -0
- pretensor/enrichment/analyze/walker.py +98 -0
- pretensor/enrichment/analyze/writers.py +214 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +300 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +383 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/errors.py +35 -0
- pretensor/graph_models/__init__.py +17 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/consumer.py +71 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +79 -0
- pretensor/graph_models/relationship.py +33 -0
- pretensor/integrations/__init__.py +42 -0
- pretensor/integrations/_base.py +138 -0
- pretensor/integrations/google_adk.py +49 -0
- pretensor/integrations/langchain.py +55 -0
- pretensor/integrations/llamaindex.py +53 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +425 -0
- pretensor/intelligence/clustering.py +168 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +317 -0
- pretensor/intelligence/graph_export.py +200 -0
- pretensor/intelligence/heuristic.py +544 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +62 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +364 -0
- pretensor/intelligence/role_exemplars.py +263 -0
- pretensor/intelligence/schema_classification.py +360 -0
- pretensor/intelligence/scoring.py +76 -0
- pretensor/intelligence/semantic.py +240 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +191 -0
- pretensor/intelligence/steps_embedding.py +168 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +237 -0
- pretensor/introspection/models/dsn.py +550 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +274 -0
- pretensor/mcp/resources/__init__.py +17 -0
- pretensor/mcp/resources/markdown.py +314 -0
- pretensor/mcp/server.py +285 -0
- pretensor/mcp/service.py +49 -0
- pretensor/mcp/service_context.py +142 -0
- pretensor/mcp/service_registry.py +294 -0
- pretensor/mcp/store_cache.py +43 -0
- pretensor/mcp/tool_registry.py +136 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/_rank.py +244 -0
- pretensor/mcp/tools/_timed.py +26 -0
- pretensor/mcp/tools/compile_metric.py +144 -0
- pretensor/mcp/tools/consumers.py +161 -0
- pretensor/mcp/tools/context.py +1121 -0
- pretensor/mcp/tools/cypher.py +509 -0
- pretensor/mcp/tools/detect_changes.py +254 -0
- pretensor/mcp/tools/impact.py +271 -0
- pretensor/mcp/tools/list.py +131 -0
- pretensor/mcp/tools/schema.py +170 -0
- pretensor/mcp/tools/search.py +316 -0
- pretensor/mcp/tools/semantic_search.py +282 -0
- pretensor/mcp/tools/traverse.py +1027 -0
- pretensor/mcp/tools/validate_sql.py +150 -0
- pretensor/observability.py +203 -0
- pretensor/py.typed +0 -0
- pretensor/quickstart/README.md +29 -0
- pretensor/quickstart/__init__.py +6 -0
- pretensor/quickstart/docker-compose.yml +18 -0
- pretensor/quickstart/pagila_data.sql +63 -0
- pretensor/quickstart/pagila_ddl.sql +92 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +487 -0
- pretensor/semantic/yaml_layer.py +180 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0.dist-info/METADATA +251 -0
- pretensor-0.1.0.dist-info/RECORD +198 -0
- pretensor-0.1.0.dist-info/WHEEL +4 -0
- pretensor-0.1.0.dist-info/entry_points.txt +2 -0
- pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""MCP ``schema`` tool: introspect node labels and edge types in the Kuzu graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pretensor.core.schema import CATALOG_EDGE_TYPES, CATALOG_NODE_LABELS
|
|
10
|
+
from pretensor.core.store import KuzuStore
|
|
11
|
+
from pretensor.mcp.tool_registry import McpTool
|
|
12
|
+
|
|
13
|
+
from ..service_registry import (
|
|
14
|
+
graph_path_for_entry,
|
|
15
|
+
load_registry,
|
|
16
|
+
open_store_for_path,
|
|
17
|
+
release_store,
|
|
18
|
+
resolve_registry_entry,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
__all__ = ["create_tool", "schema_payload"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_tool(graph_dir: Path) -> McpTool:
|
|
27
|
+
from ._timed import timed_tool
|
|
28
|
+
|
|
29
|
+
async def _handle(args: dict) -> dict:
|
|
30
|
+
db_t = str(args.get("database", "")).strip()
|
|
31
|
+
if not db_t:
|
|
32
|
+
return {"error": "Missing `database`"}
|
|
33
|
+
label_raw = args.get("label")
|
|
34
|
+
label_s = str(label_raw).strip() if label_raw is not None else None
|
|
35
|
+
if label_s == "":
|
|
36
|
+
label_s = None
|
|
37
|
+
with timed_tool("schema", graph_dir, database=db_t, label=label_s):
|
|
38
|
+
return schema_payload(graph_dir, database=db_t, label=label_s)
|
|
39
|
+
|
|
40
|
+
return McpTool(
|
|
41
|
+
name="schema",
|
|
42
|
+
description=(
|
|
43
|
+
"Discover node labels, edge types, and properties in the graph. "
|
|
44
|
+
"Call before writing Cypher."
|
|
45
|
+
),
|
|
46
|
+
input_schema={
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"database": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "Connection name or logical database",
|
|
52
|
+
},
|
|
53
|
+
"label": {
|
|
54
|
+
"type": ["string", "null"],
|
|
55
|
+
"description": "Optional node label or edge type to filter",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
"required": ["database"],
|
|
59
|
+
"additionalProperties": False,
|
|
60
|
+
},
|
|
61
|
+
handler=_handle,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_NODE_LABEL_DESC = {label: desc for label, desc in CATALOG_NODE_LABELS}
|
|
66
|
+
_EDGE_INFO = {name: (src, dst, desc) for name, src, dst, desc in CATALOG_EDGE_TYPES}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _list_catalog_tables(store: KuzuStore) -> list[tuple[str, str]]:
|
|
70
|
+
"""Return ``[(name, type), ...]`` from ``CALL show_tables()``.
|
|
71
|
+
|
|
72
|
+
``type`` is ``'NODE'`` or ``'REL'`` per Kuzu.
|
|
73
|
+
"""
|
|
74
|
+
raw = store.execute("CALL show_tables() RETURN name, type")
|
|
75
|
+
if isinstance(raw, list):
|
|
76
|
+
return []
|
|
77
|
+
rows = raw.rows_as_dict()
|
|
78
|
+
out: list[tuple[str, str]] = []
|
|
79
|
+
while rows.has_next():
|
|
80
|
+
row: Any = rows.get_next()
|
|
81
|
+
if isinstance(row, dict):
|
|
82
|
+
out.append((str(row.get("name", "")), str(row.get("type", "")).upper()))
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _table_properties(store: KuzuStore, label: str) -> list[dict[str, str]]:
|
|
87
|
+
"""Return ``[{"name": ..., "type": ...}, ...]`` for one node/rel table."""
|
|
88
|
+
try:
|
|
89
|
+
raw = store.execute(f"CALL table_info('{label}') RETURN name, type")
|
|
90
|
+
except Exception:
|
|
91
|
+
return []
|
|
92
|
+
if isinstance(raw, list):
|
|
93
|
+
return []
|
|
94
|
+
rows = raw.rows_as_dict()
|
|
95
|
+
out: list[dict[str, str]] = []
|
|
96
|
+
while rows.has_next():
|
|
97
|
+
row: Any = rows.get_next()
|
|
98
|
+
if isinstance(row, dict):
|
|
99
|
+
out.append(
|
|
100
|
+
{
|
|
101
|
+
"name": str(row.get("name", "")),
|
|
102
|
+
"type": str(row.get("type", "")),
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def schema_payload(
|
|
109
|
+
graph_dir: Path,
|
|
110
|
+
*,
|
|
111
|
+
database: str,
|
|
112
|
+
label: str | None = None,
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
"""Return the node and edge catalog for the indexed graph of ``database``.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
graph_dir: Graph workspace directory (registry + Kuzu files).
|
|
118
|
+
database: Connection name or logical database name.
|
|
119
|
+
label: If set, return only the matching node label or edge type.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
``{"nodes": [...], "edges": [...]}`` on success, or ``{"error": "..."}``.
|
|
123
|
+
"""
|
|
124
|
+
db = (database or "").strip()
|
|
125
|
+
if not db:
|
|
126
|
+
return {"error": "Missing `database`"}
|
|
127
|
+
|
|
128
|
+
reg = load_registry(graph_dir)
|
|
129
|
+
entry = resolve_registry_entry(reg, db)
|
|
130
|
+
if entry is None:
|
|
131
|
+
return {"error": f"Unknown database: {db!r}"}
|
|
132
|
+
|
|
133
|
+
graph_path = graph_path_for_entry(entry)
|
|
134
|
+
if not graph_path.exists():
|
|
135
|
+
return {"error": f"Graph file not found for database: {db!r}"}
|
|
136
|
+
|
|
137
|
+
store = open_store_for_path(graph_path)
|
|
138
|
+
try:
|
|
139
|
+
catalog = _list_catalog_tables(store)
|
|
140
|
+
nodes: list[dict[str, Any]] = []
|
|
141
|
+
edges: list[dict[str, Any]] = []
|
|
142
|
+
for name, kind in catalog:
|
|
143
|
+
if label and name != label:
|
|
144
|
+
continue
|
|
145
|
+
properties = _table_properties(store, name)
|
|
146
|
+
if kind == "NODE":
|
|
147
|
+
nodes.append(
|
|
148
|
+
{
|
|
149
|
+
"label": name,
|
|
150
|
+
"description": _NODE_LABEL_DESC.get(name, ""),
|
|
151
|
+
"properties": properties,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
elif kind == "REL":
|
|
155
|
+
src, dst, desc = _EDGE_INFO.get(name, ("", "", ""))
|
|
156
|
+
edges.append(
|
|
157
|
+
{
|
|
158
|
+
"type": name,
|
|
159
|
+
"from": src,
|
|
160
|
+
"to": dst,
|
|
161
|
+
"description": desc,
|
|
162
|
+
"properties": properties,
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
finally:
|
|
166
|
+
release_store(store)
|
|
167
|
+
|
|
168
|
+
if label and not nodes and not edges:
|
|
169
|
+
return {"error": f"Unknown label: {label!r}"}
|
|
170
|
+
return {"nodes": nodes, "edges": edges}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""MCP ``query`` tool.
|
|
2
|
+
|
|
3
|
+
BM25 search over table and entity metadata (SQLite FTS5). When the
|
|
4
|
+
``[embeddings]`` extra is installed and at least one indexed table carries a
|
|
5
|
+
vector, the results are fused with a cosine top-K pass via Reciprocal Rank
|
|
6
|
+
Fusion (RRF) and the envelope gains a ``"rerank": "rrf"`` marker. When no
|
|
7
|
+
vectors are present or the embedding client fails at runtime, the envelope is
|
|
8
|
+
byte-identical to the pre-fusion response (no ``rerank`` key).
|
|
9
|
+
|
|
10
|
+
Determinism contract: fusion must never override the BM25 path when
|
|
11
|
+
embeddings are absent.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, cast
|
|
19
|
+
|
|
20
|
+
from pretensor.intelligence.embeddings import (
|
|
21
|
+
EmbeddingClient,
|
|
22
|
+
embeddings_disabled_via_env,
|
|
23
|
+
get_default_embedding_client,
|
|
24
|
+
)
|
|
25
|
+
from pretensor.mcp.tool_registry import McpTool
|
|
26
|
+
from pretensor.observability import log_timed_operation
|
|
27
|
+
from pretensor.search.base import BaseSearchIndex
|
|
28
|
+
from pretensor.search.index import KeywordSearchIndex
|
|
29
|
+
from pretensor.visibility.filter import VisibilityFilter
|
|
30
|
+
|
|
31
|
+
from ..payload_types import QueryHit, snippet
|
|
32
|
+
from ..service_context import (
|
|
33
|
+
get_effective_search_index_cls,
|
|
34
|
+
get_effective_visibility_filter,
|
|
35
|
+
)
|
|
36
|
+
from ..service_registry import load_registry, resolve_registry_entry
|
|
37
|
+
from ._rank import (
|
|
38
|
+
CosineHit,
|
|
39
|
+
any_entry_has_vectors,
|
|
40
|
+
cosine_rank_tables,
|
|
41
|
+
fusion_key,
|
|
42
|
+
rrf_fuse,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _hit_visible(hit: QueryHit, vf: VisibilityFilter | None) -> bool:
|
|
49
|
+
if vf is None or hit.get("node_type") != "SchemaTable":
|
|
50
|
+
return True
|
|
51
|
+
name = str(hit.get("name", "")).strip()
|
|
52
|
+
if "." not in name:
|
|
53
|
+
return vf.is_table_visible(str(hit.get("connection_name", "")), "", name)
|
|
54
|
+
sn, _, tn = name.partition(".")
|
|
55
|
+
return vf.is_table_visible(str(hit.get("connection_name", "")), sn, tn)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_search_index(
|
|
59
|
+
graph_dir: Path,
|
|
60
|
+
*,
|
|
61
|
+
search_index_cls: type[BaseSearchIndex],
|
|
62
|
+
) -> BaseSearchIndex:
|
|
63
|
+
"""Load/build a search index using class-level hooks when available."""
|
|
64
|
+
reg = load_registry(graph_dir)
|
|
65
|
+
search_index_cls_any = search_index_cls
|
|
66
|
+
default_path = getattr(search_index_cls_any, "default_path", None)
|
|
67
|
+
index_path = (
|
|
68
|
+
Path(cast(Path, default_path(graph_dir)))
|
|
69
|
+
if callable(default_path)
|
|
70
|
+
else KeywordSearchIndex.default_path(graph_dir)
|
|
71
|
+
)
|
|
72
|
+
load_or_build = getattr(search_index_cls_any, "load_or_build", None)
|
|
73
|
+
if callable(load_or_build):
|
|
74
|
+
built = load_or_build(reg, index_path)
|
|
75
|
+
if not isinstance(built, BaseSearchIndex):
|
|
76
|
+
raise TypeError(
|
|
77
|
+
f"{search_index_cls.__name__}.load_or_build must return BaseSearchIndex"
|
|
78
|
+
)
|
|
79
|
+
return built
|
|
80
|
+
index = cast(Any, search_index_cls_any)(index_path)
|
|
81
|
+
if not isinstance(index, BaseSearchIndex):
|
|
82
|
+
raise TypeError(
|
|
83
|
+
f"{search_index_cls.__name__} constructor must return BaseSearchIndex"
|
|
84
|
+
)
|
|
85
|
+
index.index_graph(reg)
|
|
86
|
+
return index
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _split_name(name: str) -> tuple[str, str]:
|
|
90
|
+
"""Split ``"schema.table"`` into ``(schema, table)``; tolerate a missing dot."""
|
|
91
|
+
if "." in name:
|
|
92
|
+
schema_name, _, table_name = name.partition(".")
|
|
93
|
+
return schema_name, table_name
|
|
94
|
+
return "", name
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _bm25_key(hit: QueryHit) -> str:
|
|
98
|
+
schema_name, table_name = _split_name(str(hit.get("name", "")))
|
|
99
|
+
return fusion_key(
|
|
100
|
+
connection_name=str(hit.get("connection_name", "")),
|
|
101
|
+
database=str(hit.get("database_name", "")),
|
|
102
|
+
schema_name=schema_name,
|
|
103
|
+
table_name=table_name,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _hit_from_cosine(hit: CosineHit, *, score: float) -> QueryHit:
|
|
108
|
+
item: QueryHit = {
|
|
109
|
+
"node_type": "SchemaTable",
|
|
110
|
+
"name": (
|
|
111
|
+
f"{hit.schema_name}.{hit.table_name}" if hit.schema_name else hit.table_name
|
|
112
|
+
),
|
|
113
|
+
"database_name": hit.database,
|
|
114
|
+
"connection_name": hit.connection_name,
|
|
115
|
+
"description": hit.description,
|
|
116
|
+
"snippet": snippet(hit.description),
|
|
117
|
+
"score": score,
|
|
118
|
+
}
|
|
119
|
+
return item
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def query_payload(
|
|
123
|
+
graph_dir: Path,
|
|
124
|
+
*,
|
|
125
|
+
q: str,
|
|
126
|
+
db: str | None = None,
|
|
127
|
+
limit: int = 10,
|
|
128
|
+
search_index_cls: type[BaseSearchIndex] | None = None,
|
|
129
|
+
visibility_filter: VisibilityFilter | None = None,
|
|
130
|
+
embedding_client: EmbeddingClient | None = None,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
"""BM25 search over indexed metadata; hybrid RRF rerank when embeddings available."""
|
|
133
|
+
with log_timed_operation(
|
|
134
|
+
logger,
|
|
135
|
+
event="mcp.query_payload",
|
|
136
|
+
query=q,
|
|
137
|
+
db=db,
|
|
138
|
+
limit=limit,
|
|
139
|
+
graph_dir=str(graph_dir),
|
|
140
|
+
):
|
|
141
|
+
vf = visibility_filter or get_effective_visibility_filter()
|
|
142
|
+
index_cls = get_effective_search_index_cls(search_index_cls)
|
|
143
|
+
idx = _load_search_index(graph_dir, search_index_cls=index_cls)
|
|
144
|
+
raw = idx.search(q, db=db, limit=limit * 4)
|
|
145
|
+
|
|
146
|
+
# BM25 pool: retain up to limit*4 visible SchemaTable hits so fusion
|
|
147
|
+
# has a reasonable pool to pull from; null path truncates to ``limit``.
|
|
148
|
+
bm25_pool: list[QueryHit] = []
|
|
149
|
+
for row in raw:
|
|
150
|
+
if row.node_type != "SchemaTable":
|
|
151
|
+
continue
|
|
152
|
+
item: QueryHit = {
|
|
153
|
+
"node_type": row.node_type,
|
|
154
|
+
"name": row.name,
|
|
155
|
+
"database_name": row.database_name,
|
|
156
|
+
"connection_name": row.connection_name,
|
|
157
|
+
"description": row.description,
|
|
158
|
+
"snippet": snippet(row.description),
|
|
159
|
+
"score": row.score,
|
|
160
|
+
}
|
|
161
|
+
if not _hit_visible(item, vf):
|
|
162
|
+
continue
|
|
163
|
+
bm25_pool.append(item)
|
|
164
|
+
|
|
165
|
+
fused = _maybe_fuse(
|
|
166
|
+
graph_dir,
|
|
167
|
+
q=q,
|
|
168
|
+
db=db,
|
|
169
|
+
limit=limit,
|
|
170
|
+
bm25_pool=bm25_pool,
|
|
171
|
+
visibility_filter=vf,
|
|
172
|
+
embedding_client=embedding_client,
|
|
173
|
+
)
|
|
174
|
+
if fused is not None:
|
|
175
|
+
return fused
|
|
176
|
+
|
|
177
|
+
# Null path: BM25-only envelope, byte-identical to pre-fusion behavior.
|
|
178
|
+
return {"query": q, "db": db, "results": bm25_pool[:limit]}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _maybe_fuse(
|
|
182
|
+
graph_dir: Path,
|
|
183
|
+
*,
|
|
184
|
+
q: str,
|
|
185
|
+
db: str | None,
|
|
186
|
+
limit: int,
|
|
187
|
+
bm25_pool: list[QueryHit],
|
|
188
|
+
visibility_filter: VisibilityFilter | None,
|
|
189
|
+
embedding_client: EmbeddingClient | None,
|
|
190
|
+
) -> dict[str, Any] | None:
|
|
191
|
+
"""Attempt the hybrid rerank; return ``None`` when the null path applies."""
|
|
192
|
+
# Kill switch wins over everything, including an injected client, so
|
|
193
|
+
# PRETENSOR_EMBEDDINGS_DISABLED=1 forces the BM25-only envelope even
|
|
194
|
+
# when the extra is installed and vectors exist.
|
|
195
|
+
if embeddings_disabled_via_env():
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
reg = load_registry(graph_dir)
|
|
199
|
+
all_entries = reg.list_entries()
|
|
200
|
+
if not all_entries:
|
|
201
|
+
return None
|
|
202
|
+
if db is None:
|
|
203
|
+
entries = all_entries
|
|
204
|
+
else:
|
|
205
|
+
resolved = resolve_registry_entry(reg, db)
|
|
206
|
+
if resolved is None:
|
|
207
|
+
# Unknown db: BM25 already returns empty for this case; fall back
|
|
208
|
+
# to its behavior instead of marking the envelope as reranked.
|
|
209
|
+
return None
|
|
210
|
+
entries = [resolved]
|
|
211
|
+
|
|
212
|
+
# Probe for stored vectors BEFORE embedding the query: when no table
|
|
213
|
+
# carries a vector the fused result is discarded anyway, and on a cold
|
|
214
|
+
# process the first embed() call would trigger the ONNX model download
|
|
215
|
+
# + session init for nothing.
|
|
216
|
+
if not any_entry_has_vectors(entries):
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
# ``LocalEmbeddingClient.__init__`` does no I/O — the heavy lifting
|
|
220
|
+
# happens lazily inside ``embed()``. So a fresh-or-cached construction
|
|
221
|
+
# here cannot raise ImportError; the cached default keeps the ONNX
|
|
222
|
+
# session warm across query calls instead of re-initializing per
|
|
223
|
+
# request.
|
|
224
|
+
client = embedding_client or get_default_embedding_client()
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
vectors = client.embed([q])
|
|
228
|
+
except ImportError as exc:
|
|
229
|
+
logger.warning("query: [embeddings] extra not installed: %s", exc)
|
|
230
|
+
return None
|
|
231
|
+
except Exception as exc: # noqa: BLE001 — tool must never raise
|
|
232
|
+
logger.warning("query: embedding client failed (%s); BM25 only", exc)
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
if not vectors or not vectors[0]:
|
|
236
|
+
return None
|
|
237
|
+
qvec = vectors[0]
|
|
238
|
+
|
|
239
|
+
cosine_hits, any_candidate_had_vector = cosine_rank_tables(
|
|
240
|
+
entries,
|
|
241
|
+
qvec,
|
|
242
|
+
database=db,
|
|
243
|
+
cluster=None,
|
|
244
|
+
visibility_filter=visibility_filter,
|
|
245
|
+
k=max(20, limit * 4),
|
|
246
|
+
)
|
|
247
|
+
if not any_candidate_had_vector:
|
|
248
|
+
# No tables carry vectors → preserve null-path parity.
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
bm25_by_key: dict[str, QueryHit] = {}
|
|
252
|
+
bm25_keys: list[str] = []
|
|
253
|
+
for hit in bm25_pool:
|
|
254
|
+
key = _bm25_key(hit)
|
|
255
|
+
if key in bm25_by_key:
|
|
256
|
+
# Duplicate FQN in the BM25 pool (shouldn't happen, but be safe).
|
|
257
|
+
continue
|
|
258
|
+
bm25_by_key[key] = hit
|
|
259
|
+
bm25_keys.append(key)
|
|
260
|
+
cosine_by_key: dict[str, CosineHit] = {h.key: h for h in cosine_hits}
|
|
261
|
+
cosine_keys = [h.key for h in cosine_hits]
|
|
262
|
+
|
|
263
|
+
fused_results: list[QueryHit] = []
|
|
264
|
+
for key, score in rrf_fuse(bm25_keys=bm25_keys, cosine_keys=cosine_keys):
|
|
265
|
+
if key in bm25_by_key:
|
|
266
|
+
# Reuse BM25 hit (keeps its snippet); overwrite score with RRF.
|
|
267
|
+
hit = dict(bm25_by_key[key])
|
|
268
|
+
hit["score"] = score
|
|
269
|
+
fused_results.append(cast(QueryHit, hit))
|
|
270
|
+
elif key in cosine_by_key:
|
|
271
|
+
fused_results.append(_hit_from_cosine(cosine_by_key[key], score=score))
|
|
272
|
+
if len(fused_results) >= limit:
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
return {"query": q, "db": db, "results": fused_results, "rerank": "rrf"}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
__all__ = ["create_tool", "query_payload"]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def create_tool(graph_dir: Path) -> McpTool:
|
|
282
|
+
from ._timed import timed_tool
|
|
283
|
+
|
|
284
|
+
async def _handle(args: dict) -> dict:
|
|
285
|
+
q = str(args.get("q", "")).strip()
|
|
286
|
+
if not q:
|
|
287
|
+
return {"error": "Missing or empty `q`"}
|
|
288
|
+
limit = int(args.get("limit", 10))
|
|
289
|
+
db = args.get("db")
|
|
290
|
+
db_s = str(db) if db is not None else None
|
|
291
|
+
with timed_tool("query", graph_dir, db=db_s, limit=limit):
|
|
292
|
+
return query_payload(graph_dir, q=q, db=db_s, limit=limit)
|
|
293
|
+
|
|
294
|
+
return McpTool(
|
|
295
|
+
name="query",
|
|
296
|
+
description="BM25 keyword search over table and entity metadata (FTS5).",
|
|
297
|
+
input_schema={
|
|
298
|
+
"type": "object",
|
|
299
|
+
"properties": {
|
|
300
|
+
"q": {"type": "string", "description": "Search query"},
|
|
301
|
+
"db": {
|
|
302
|
+
"type": ["string", "null"],
|
|
303
|
+
"description": "Filter by connection_name or logical database name",
|
|
304
|
+
},
|
|
305
|
+
"limit": {
|
|
306
|
+
"type": "integer",
|
|
307
|
+
"default": 10,
|
|
308
|
+
"minimum": 1,
|
|
309
|
+
"maximum": 50,
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
"required": ["q"],
|
|
313
|
+
"additionalProperties": False,
|
|
314
|
+
},
|
|
315
|
+
handler=_handle,
|
|
316
|
+
)
|