pretensor 0.1.0a0__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/cli/__init__.py +1 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +122 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +120 -0
- pretensor/cli/commands/index.py +551 -0
- pretensor/cli/commands/list.py +65 -0
- pretensor/cli/commands/quickstart.py +171 -0
- pretensor/cli/commands/reindex.py +581 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +129 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +156 -0
- pretensor/cli/config_file.py +412 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +81 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +145 -0
- pretensor/connectors/SKILL.md +16 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +463 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +91 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +921 -0
- pretensor/connectors/registry.py +51 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +840 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +268 -0
- pretensor/core/dsn_crypto.py +40 -0
- pretensor/core/ids.py +68 -0
- pretensor/core/portable_export.py +253 -0
- pretensor/core/registry.py +204 -0
- pretensor/core/schema.py +368 -0
- pretensor/core/store.py +1343 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +298 -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 +359 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/graph_models/__init__.py +14 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +75 -0
- pretensor/graph_models/relationship.py +27 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +335 -0
- pretensor/intelligence/clustering.py +162 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +198 -0
- pretensor/intelligence/graph_export.py +139 -0
- pretensor/intelligence/heuristic.py +540 -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 +60 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +254 -0
- pretensor/intelligence/schema_classification.py +306 -0
- pretensor/intelligence/scoring.py +66 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +161 -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 +236 -0
- pretensor/introspection/models/dsn.py +442 -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 +248 -0
- pretensor/mcp/resources/__init__.py +19 -0
- pretensor/mcp/resources/markdown.py +420 -0
- pretensor/mcp/server.py +723 -0
- pretensor/mcp/service.py +47 -0
- pretensor/mcp/service_context.py +126 -0
- pretensor/mcp/service_registry.py +242 -0
- pretensor/mcp/tool_registry.py +126 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/compile_metric.py +87 -0
- pretensor/mcp/tools/context.py +894 -0
- pretensor/mcp/tools/cypher.py +333 -0
- pretensor/mcp/tools/detect_changes.py +226 -0
- pretensor/mcp/tools/impact.py +165 -0
- pretensor/mcp/tools/list.py +110 -0
- pretensor/mcp/tools/schema.py +128 -0
- pretensor/mcp/tools/search.py +108 -0
- pretensor/mcp/tools/traverse.py +753 -0
- pretensor/mcp/tools/validate_sql.py +74 -0
- pretensor/observability.py +203 -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 +346 -0
- pretensor/semantic/yaml_layer.py +182 -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.0a0.dist-info/METADATA +175 -0
- pretensor-0.1.0a0.dist-info/RECORD +138 -0
- pretensor-0.1.0a0.dist-info/WHEEL +4 -0
- pretensor-0.1.0a0.dist-info/entry_points.txt +2 -0
- pretensor-0.1.0a0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""MCP ``impact`` tool payload."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections import deque
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pretensor.intelligence.shadow_alias import get_shadow_alias_node_ids
|
|
11
|
+
from pretensor.visibility.filter import VisibilityFilter
|
|
12
|
+
|
|
13
|
+
from ..payload_types import ImpactItemPayload
|
|
14
|
+
from ..service_context import get_effective_visibility_filter
|
|
15
|
+
from ..service_registry import (
|
|
16
|
+
graph_path_for_entry,
|
|
17
|
+
load_registry,
|
|
18
|
+
open_store_for_entry,
|
|
19
|
+
resolve_registry_entry,
|
|
20
|
+
)
|
|
21
|
+
from .context import qualified_from_node_id, resolve_table_node_id
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def impact_payload(
|
|
25
|
+
graph_dir: Path,
|
|
26
|
+
*,
|
|
27
|
+
table: str,
|
|
28
|
+
database: str,
|
|
29
|
+
column: str | None = None,
|
|
30
|
+
max_depth: int = 3,
|
|
31
|
+
visibility_filter: VisibilityFilter | None = None,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
"""Downstream tables grouped by hop depth (FK + inferred edges)."""
|
|
34
|
+
reg = load_registry(graph_dir)
|
|
35
|
+
entry = resolve_registry_entry(reg, database)
|
|
36
|
+
if entry is None:
|
|
37
|
+
return {
|
|
38
|
+
"error": "Unknown database connection or name; pass `database`.",
|
|
39
|
+
}
|
|
40
|
+
gp = graph_path_for_entry(entry)
|
|
41
|
+
if not gp.exists():
|
|
42
|
+
return {"error": f"Graph file missing: {gp}"}
|
|
43
|
+
|
|
44
|
+
db_key = str(entry.database)
|
|
45
|
+
vf = visibility_filter or get_effective_visibility_filter()
|
|
46
|
+
cn = str(entry.connection_name)
|
|
47
|
+
store = open_store_for_entry(entry)
|
|
48
|
+
try:
|
|
49
|
+
start_id, err = resolve_table_node_id(
|
|
50
|
+
store, table, db_key, visibility_filter=vf
|
|
51
|
+
)
|
|
52
|
+
if err is not None:
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(err)
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
return {"error": err}
|
|
57
|
+
if start_id is None:
|
|
58
|
+
return {"error": "Could not resolve starting table; check the table name and database."}
|
|
59
|
+
|
|
60
|
+
fk_rows = store.query_all_rows(
|
|
61
|
+
"""
|
|
62
|
+
MATCH (a:SchemaTable)-[r:FK_REFERENCES]->(b:SchemaTable)
|
|
63
|
+
WHERE a.database = $db AND b.database = $db
|
|
64
|
+
RETURN a.node_id, b.node_id, r.source_column, r.target_column, 1.0 AS conf
|
|
65
|
+
""",
|
|
66
|
+
{"db": db_key},
|
|
67
|
+
)
|
|
68
|
+
inf_rows = store.query_all_rows(
|
|
69
|
+
"""
|
|
70
|
+
MATCH (a:SchemaTable)-[r:INFERRED_JOIN]->(b:SchemaTable)
|
|
71
|
+
WHERE a.database = $db AND b.database = $db
|
|
72
|
+
RETURN a.node_id, b.node_id, r.source_column, r.target_column, r.confidence
|
|
73
|
+
""",
|
|
74
|
+
{"db": db_key},
|
|
75
|
+
)
|
|
76
|
+
shadow_ids = get_shadow_alias_node_ids(store, db_key)
|
|
77
|
+
|
|
78
|
+
rev: dict[str, list[tuple[str, str, str, float]]] = {}
|
|
79
|
+
for row in fk_rows + inf_rows:
|
|
80
|
+
child, parent, sc, tc, conf = row
|
|
81
|
+
if child is None or parent is None:
|
|
82
|
+
continue
|
|
83
|
+
sa, sb = str(child), str(parent)
|
|
84
|
+
if sa in shadow_ids or sb in shadow_ids:
|
|
85
|
+
continue
|
|
86
|
+
if vf is not None:
|
|
87
|
+
cmeta = store.query_all_rows(
|
|
88
|
+
"""
|
|
89
|
+
MATCH (t:SchemaTable {node_id: $id})
|
|
90
|
+
RETURN t.schema_name, t.table_name
|
|
91
|
+
""",
|
|
92
|
+
{"id": sa},
|
|
93
|
+
)
|
|
94
|
+
if cmeta:
|
|
95
|
+
sn, tn = cmeta[0]
|
|
96
|
+
if not vf.is_table_visible(cn, str(sn or ""), str(tn or "")):
|
|
97
|
+
continue
|
|
98
|
+
w = float(conf) if conf is not None else 1.0
|
|
99
|
+
rev.setdefault(sb, []).append((sa, str(sc or ""), str(tc or ""), w))
|
|
100
|
+
|
|
101
|
+
q: deque[tuple[str, int, float, str, str]] = deque()
|
|
102
|
+
for dependent, scol, _tcol, w in rev.get(start_id, []):
|
|
103
|
+
if column is not None and scol != column:
|
|
104
|
+
continue
|
|
105
|
+
dep_short = qualified_from_node_id(store, dependent).split(".")[-1]
|
|
106
|
+
via = f"{dep_short}.{scol}"
|
|
107
|
+
q.append((dependent, 1, w, via, start_id))
|
|
108
|
+
|
|
109
|
+
seen: dict[str, tuple[int, float, str]] = {}
|
|
110
|
+
while q:
|
|
111
|
+
cur, depth, path_min, via, _parent = q.popleft()
|
|
112
|
+
if cur in seen:
|
|
113
|
+
continue
|
|
114
|
+
seen[cur] = (depth, path_min, via)
|
|
115
|
+
if depth >= max_depth:
|
|
116
|
+
continue
|
|
117
|
+
for dependent, scol, _tcol, w in rev.get(cur, []):
|
|
118
|
+
if dependent in seen:
|
|
119
|
+
continue
|
|
120
|
+
new_d = depth + 1
|
|
121
|
+
new_min = min(path_min, w)
|
|
122
|
+
dep_short = qualified_from_node_id(store, dependent).split(".")[-1]
|
|
123
|
+
new_via = f"{via} → {dep_short}.{scol}"
|
|
124
|
+
q.append((dependent, new_d, new_min, new_via, cur))
|
|
125
|
+
|
|
126
|
+
direct: list[ImpactItemPayload] = []
|
|
127
|
+
two_hop: list[ImpactItemPayload] = []
|
|
128
|
+
three_hop: list[ImpactItemPayload] = []
|
|
129
|
+
low_confidence: list[ImpactItemPayload] = []
|
|
130
|
+
for node_id, (depth, conf, via) in seen.items():
|
|
131
|
+
name = qualified_from_node_id(store, node_id)
|
|
132
|
+
item: ImpactItemPayload = {
|
|
133
|
+
"type": "table",
|
|
134
|
+
"name": name,
|
|
135
|
+
"via": via,
|
|
136
|
+
"confidence": conf,
|
|
137
|
+
}
|
|
138
|
+
if conf < 0.5:
|
|
139
|
+
item["hop"] = depth
|
|
140
|
+
low_confidence.append(item)
|
|
141
|
+
continue
|
|
142
|
+
if depth == 1:
|
|
143
|
+
direct.append(item)
|
|
144
|
+
elif depth == 2:
|
|
145
|
+
two_hop.append(item)
|
|
146
|
+
elif depth == 3:
|
|
147
|
+
three_hop.append(item)
|
|
148
|
+
|
|
149
|
+
total = len(seen)
|
|
150
|
+
return {
|
|
151
|
+
"target": {"table": table, "column": column},
|
|
152
|
+
"database": database,
|
|
153
|
+
"impact": {
|
|
154
|
+
"direct": direct,
|
|
155
|
+
"two_hop": two_hop,
|
|
156
|
+
"three_hop": three_hop,
|
|
157
|
+
"low_confidence": low_confidence,
|
|
158
|
+
},
|
|
159
|
+
"total_affected": total,
|
|
160
|
+
}
|
|
161
|
+
finally:
|
|
162
|
+
store.close()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = ["impact_payload"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""MCP ``list_databases`` tool payload."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pretensor.config import GraphConfig
|
|
9
|
+
from pretensor.visibility.filter import VisibilityFilter
|
|
10
|
+
|
|
11
|
+
from ..payload_types import (
|
|
12
|
+
DIALECT_TO_DB_TYPE,
|
|
13
|
+
DatabaseListItem,
|
|
14
|
+
iso_format,
|
|
15
|
+
stale_threshold_days,
|
|
16
|
+
staleness_days,
|
|
17
|
+
utc_now,
|
|
18
|
+
)
|
|
19
|
+
from ..service_context import (
|
|
20
|
+
get_effective_graph_config,
|
|
21
|
+
get_effective_visibility_filter,
|
|
22
|
+
)
|
|
23
|
+
from ..service_registry import (
|
|
24
|
+
counts_for_graph,
|
|
25
|
+
graph_path_for_entry,
|
|
26
|
+
load_registry,
|
|
27
|
+
open_store_for_entry,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def list_databases_payload(
|
|
32
|
+
graph_dir: Path,
|
|
33
|
+
*,
|
|
34
|
+
config: GraphConfig | None = None,
|
|
35
|
+
visibility_filter: VisibilityFilter | None = None,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
"""Build JSON-serializable payload for ``list_databases``."""
|
|
38
|
+
cfg = get_effective_graph_config(config)
|
|
39
|
+
vf = visibility_filter or get_effective_visibility_filter()
|
|
40
|
+
reg = load_registry(graph_dir)
|
|
41
|
+
items: list[DatabaseListItem] = []
|
|
42
|
+
now = utc_now()
|
|
43
|
+
threshold = stale_threshold_days(cfg)
|
|
44
|
+
for entry in reg.list_entries():
|
|
45
|
+
table_count = 0
|
|
46
|
+
column_count = 0
|
|
47
|
+
row_sum = 0
|
|
48
|
+
schemas: list[str] = []
|
|
49
|
+
has_dbt_manifest: str = "not_attempted"
|
|
50
|
+
has_llm_enrichment: str = "not_attempted"
|
|
51
|
+
has_external_consumers: str = "not_attempted"
|
|
52
|
+
gp = graph_path_for_entry(entry)
|
|
53
|
+
if gp.exists():
|
|
54
|
+
store = open_store_for_entry(entry)
|
|
55
|
+
try:
|
|
56
|
+
counts = counts_for_graph(
|
|
57
|
+
store,
|
|
58
|
+
connection_name=entry.connection_name,
|
|
59
|
+
visibility_filter=vf,
|
|
60
|
+
entry=entry,
|
|
61
|
+
)
|
|
62
|
+
table_count = counts.table_count
|
|
63
|
+
column_count = counts.column_count
|
|
64
|
+
row_sum = counts.row_count
|
|
65
|
+
schemas = counts.schemas
|
|
66
|
+
has_dbt_manifest = counts.has_dbt_manifest
|
|
67
|
+
has_llm_enrichment = counts.has_llm_enrichment
|
|
68
|
+
has_external_consumers = counts.has_external_consumers
|
|
69
|
+
finally:
|
|
70
|
+
store.close()
|
|
71
|
+
days = staleness_days(entry.last_indexed_at, now)
|
|
72
|
+
stale = days > threshold
|
|
73
|
+
capabilities = ["schema", "fk_edges", "inferred_joins", "clustering"]
|
|
74
|
+
if has_dbt_manifest == "present":
|
|
75
|
+
capabilities.append("dbt_lineage")
|
|
76
|
+
capabilities.append("dbt_metrics")
|
|
77
|
+
if has_llm_enrichment == "present":
|
|
78
|
+
capabilities.append("llm_enrichment")
|
|
79
|
+
if has_external_consumers == "present":
|
|
80
|
+
capabilities.append("external_consumers")
|
|
81
|
+
row: DatabaseListItem = {
|
|
82
|
+
"name": entry.connection_name,
|
|
83
|
+
"db_type": DIALECT_TO_DB_TYPE.get(entry.dialect, "postgresql"),
|
|
84
|
+
"table_count": table_count,
|
|
85
|
+
"column_count": column_count,
|
|
86
|
+
"row_count": row_sum,
|
|
87
|
+
"schemas": schemas,
|
|
88
|
+
"has_dbt_manifest": has_dbt_manifest,
|
|
89
|
+
"has_llm_enrichment": has_llm_enrichment,
|
|
90
|
+
"has_external_consumers": has_external_consumers,
|
|
91
|
+
"capabilities": capabilities,
|
|
92
|
+
"last_indexed": iso_format(entry.last_indexed_at),
|
|
93
|
+
"is_stale": stale,
|
|
94
|
+
"staleness_days": days,
|
|
95
|
+
"graph_path": str(graph_path_for_entry(entry)),
|
|
96
|
+
}
|
|
97
|
+
# Only emit ``database`` when it differs from ``name`` — otherwise the
|
|
98
|
+
# duplicate field is noise (e.g. ``{"name": "pagila", "database": "pagila"}``).
|
|
99
|
+
if entry.database and entry.database != entry.connection_name:
|
|
100
|
+
row["database"] = entry.database
|
|
101
|
+
if stale:
|
|
102
|
+
row["stale_warning"] = (
|
|
103
|
+
f"Graph index is {days} days old (threshold {threshold} days). "
|
|
104
|
+
"Consider `pretensor reindex <dsn>` or `pretensor index`."
|
|
105
|
+
)
|
|
106
|
+
items.append(row)
|
|
107
|
+
return {"databases": items}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["list_databases_payload"]
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
|
|
12
|
+
from ..service_registry import (
|
|
13
|
+
graph_path_for_entry,
|
|
14
|
+
load_registry,
|
|
15
|
+
resolve_registry_entry,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
__all__ = ["schema_payload"]
|
|
21
|
+
|
|
22
|
+
_NODE_LABEL_DESC = {label: desc for label, desc in CATALOG_NODE_LABELS}
|
|
23
|
+
_EDGE_INFO = {name: (src, dst, desc) for name, src, dst, desc in CATALOG_EDGE_TYPES}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _list_catalog_tables(store: KuzuStore) -> list[tuple[str, str]]:
|
|
27
|
+
"""Return ``[(name, type), ...]`` from ``CALL show_tables()``.
|
|
28
|
+
|
|
29
|
+
``type`` is ``'NODE'`` or ``'REL'`` per Kuzu.
|
|
30
|
+
"""
|
|
31
|
+
raw = store.execute("CALL show_tables() RETURN name, type")
|
|
32
|
+
if isinstance(raw, list):
|
|
33
|
+
return []
|
|
34
|
+
rows = raw.rows_as_dict()
|
|
35
|
+
out: list[tuple[str, str]] = []
|
|
36
|
+
while rows.has_next():
|
|
37
|
+
row: Any = rows.get_next()
|
|
38
|
+
if isinstance(row, dict):
|
|
39
|
+
out.append((str(row.get("name", "")), str(row.get("type", "")).upper()))
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _table_properties(store: KuzuStore, label: str) -> list[dict[str, str]]:
|
|
44
|
+
"""Return ``[{"name": ..., "type": ...}, ...]`` for one node/rel table."""
|
|
45
|
+
try:
|
|
46
|
+
raw = store.execute(f"CALL table_info('{label}') RETURN name, type")
|
|
47
|
+
except Exception:
|
|
48
|
+
return []
|
|
49
|
+
if isinstance(raw, list):
|
|
50
|
+
return []
|
|
51
|
+
rows = raw.rows_as_dict()
|
|
52
|
+
out: list[dict[str, str]] = []
|
|
53
|
+
while rows.has_next():
|
|
54
|
+
row: Any = rows.get_next()
|
|
55
|
+
if isinstance(row, dict):
|
|
56
|
+
out.append(
|
|
57
|
+
{
|
|
58
|
+
"name": str(row.get("name", "")),
|
|
59
|
+
"type": str(row.get("type", "")),
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def schema_payload(
|
|
66
|
+
graph_dir: Path,
|
|
67
|
+
*,
|
|
68
|
+
database: str,
|
|
69
|
+
label: str | None = None,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""Return the node and edge catalog for the indexed graph of ``database``.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
graph_dir: Graph workspace directory (registry + Kuzu files).
|
|
75
|
+
database: Connection name or logical database name.
|
|
76
|
+
label: If set, return only the matching node label or edge type.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
``{"nodes": [...], "edges": [...]}`` on success, or ``{"error": "..."}``.
|
|
80
|
+
"""
|
|
81
|
+
db = (database or "").strip()
|
|
82
|
+
if not db:
|
|
83
|
+
return {"error": "Missing `database`"}
|
|
84
|
+
|
|
85
|
+
reg = load_registry(graph_dir)
|
|
86
|
+
entry = resolve_registry_entry(reg, db)
|
|
87
|
+
if entry is None:
|
|
88
|
+
return {"error": f"Unknown database: {db!r}"}
|
|
89
|
+
|
|
90
|
+
graph_path = graph_path_for_entry(entry)
|
|
91
|
+
if not graph_path.exists():
|
|
92
|
+
return {"error": f"Graph file not found for database: {db!r}"}
|
|
93
|
+
|
|
94
|
+
store = KuzuStore(graph_path)
|
|
95
|
+
try:
|
|
96
|
+
store.ensure_schema()
|
|
97
|
+
catalog = _list_catalog_tables(store)
|
|
98
|
+
nodes: list[dict[str, Any]] = []
|
|
99
|
+
edges: list[dict[str, Any]] = []
|
|
100
|
+
for name, kind in catalog:
|
|
101
|
+
if label and name != label:
|
|
102
|
+
continue
|
|
103
|
+
properties = _table_properties(store, name)
|
|
104
|
+
if kind == "NODE":
|
|
105
|
+
nodes.append(
|
|
106
|
+
{
|
|
107
|
+
"label": name,
|
|
108
|
+
"description": _NODE_LABEL_DESC.get(name, ""),
|
|
109
|
+
"properties": properties,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
elif kind == "REL":
|
|
113
|
+
src, dst, desc = _EDGE_INFO.get(name, ("", "", ""))
|
|
114
|
+
edges.append(
|
|
115
|
+
{
|
|
116
|
+
"type": name,
|
|
117
|
+
"from": src,
|
|
118
|
+
"to": dst,
|
|
119
|
+
"description": desc,
|
|
120
|
+
"properties": properties,
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
finally:
|
|
124
|
+
store.close()
|
|
125
|
+
|
|
126
|
+
if label and not nodes and not edges:
|
|
127
|
+
return {"error": f"Unknown label: {label!r}"}
|
|
128
|
+
return {"nodes": nodes, "edges": edges}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""MCP ``query`` tool (BM25 search) payload."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from pretensor.observability import log_timed_operation
|
|
10
|
+
from pretensor.search.base import BaseSearchIndex
|
|
11
|
+
from pretensor.search.index import KeywordSearchIndex
|
|
12
|
+
from pretensor.visibility.filter import VisibilityFilter
|
|
13
|
+
|
|
14
|
+
from ..payload_types import QueryHit, snippet
|
|
15
|
+
from ..service_context import (
|
|
16
|
+
get_effective_search_index_cls,
|
|
17
|
+
get_effective_visibility_filter,
|
|
18
|
+
)
|
|
19
|
+
from ..service_registry import load_registry
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hit_visible(hit: QueryHit, vf: VisibilityFilter | None) -> bool:
|
|
25
|
+
if vf is None or hit.get("node_type") != "SchemaTable":
|
|
26
|
+
return True
|
|
27
|
+
name = str(hit.get("name", "")).strip()
|
|
28
|
+
if "." not in name:
|
|
29
|
+
return vf.is_table_visible(str(hit.get("connection_name", "")), "", name)
|
|
30
|
+
sn, _, tn = name.partition(".")
|
|
31
|
+
return vf.is_table_visible(str(hit.get("connection_name", "")), sn, tn)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_search_index(
|
|
35
|
+
graph_dir: Path,
|
|
36
|
+
*,
|
|
37
|
+
search_index_cls: type[BaseSearchIndex],
|
|
38
|
+
) -> BaseSearchIndex:
|
|
39
|
+
"""Load/build a search index using class-level hooks when available."""
|
|
40
|
+
reg = load_registry(graph_dir)
|
|
41
|
+
search_index_cls_any = search_index_cls
|
|
42
|
+
default_path = getattr(search_index_cls_any, "default_path", None)
|
|
43
|
+
index_path = (
|
|
44
|
+
Path(cast(Path, default_path(graph_dir)))
|
|
45
|
+
if callable(default_path)
|
|
46
|
+
else KeywordSearchIndex.default_path(graph_dir)
|
|
47
|
+
)
|
|
48
|
+
load_or_build = getattr(search_index_cls_any, "load_or_build", None)
|
|
49
|
+
if callable(load_or_build):
|
|
50
|
+
built = load_or_build(reg, index_path)
|
|
51
|
+
if not isinstance(built, BaseSearchIndex):
|
|
52
|
+
raise TypeError(
|
|
53
|
+
f"{search_index_cls.__name__}.load_or_build must return BaseSearchIndex"
|
|
54
|
+
)
|
|
55
|
+
return built
|
|
56
|
+
index = cast(Any, search_index_cls_any)(index_path)
|
|
57
|
+
if not isinstance(index, BaseSearchIndex):
|
|
58
|
+
raise TypeError(
|
|
59
|
+
f"{search_index_cls.__name__} constructor must return BaseSearchIndex"
|
|
60
|
+
)
|
|
61
|
+
index.index_graph(reg)
|
|
62
|
+
return index
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def query_payload(
|
|
66
|
+
graph_dir: Path,
|
|
67
|
+
*,
|
|
68
|
+
q: str,
|
|
69
|
+
db: str | None = None,
|
|
70
|
+
limit: int = 10,
|
|
71
|
+
search_index_cls: type[BaseSearchIndex] | None = None,
|
|
72
|
+
visibility_filter: VisibilityFilter | None = None,
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
"""BM25 search over indexed metadata."""
|
|
75
|
+
with log_timed_operation(
|
|
76
|
+
logger,
|
|
77
|
+
event="mcp.query_payload",
|
|
78
|
+
query=q,
|
|
79
|
+
db=db,
|
|
80
|
+
limit=limit,
|
|
81
|
+
graph_dir=str(graph_dir),
|
|
82
|
+
):
|
|
83
|
+
vf = visibility_filter or get_effective_visibility_filter()
|
|
84
|
+
index_cls = get_effective_search_index_cls(search_index_cls)
|
|
85
|
+
idx = _load_search_index(graph_dir, search_index_cls=index_cls)
|
|
86
|
+
raw = idx.search(q, db=db, limit=limit * 4)
|
|
87
|
+
hits: list[QueryHit] = []
|
|
88
|
+
for row in raw:
|
|
89
|
+
if row.node_type != "SchemaTable":
|
|
90
|
+
continue
|
|
91
|
+
item: QueryHit = {
|
|
92
|
+
"node_type": row.node_type,
|
|
93
|
+
"name": row.name,
|
|
94
|
+
"database_name": row.database_name,
|
|
95
|
+
"connection_name": row.connection_name,
|
|
96
|
+
"description": row.description,
|
|
97
|
+
"snippet": snippet(row.description),
|
|
98
|
+
"score": row.score,
|
|
99
|
+
}
|
|
100
|
+
if not _hit_visible(item, vf):
|
|
101
|
+
continue
|
|
102
|
+
hits.append(item)
|
|
103
|
+
if len(hits) >= limit:
|
|
104
|
+
break
|
|
105
|
+
return {"query": q, "db": db, "results": hits}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = ["query_payload"]
|