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,1027 @@
|
|
|
1
|
+
"""MCP ``traverse`` tool: join paths between tables in an indexed database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import heapq
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pretensor.core.registry import RegistryEntry
|
|
13
|
+
from pretensor.core.store import KuzuStore
|
|
14
|
+
from pretensor.intelligence.embeddings import (
|
|
15
|
+
EmbeddingClient,
|
|
16
|
+
cosine_similarity,
|
|
17
|
+
embeddings_disabled_via_env,
|
|
18
|
+
get_default_embedding_client,
|
|
19
|
+
)
|
|
20
|
+
from pretensor.intelligence.join_paths import JoinPathEngine, StoredJoinPath
|
|
21
|
+
from pretensor.intelligence.shadow_alias import get_shadow_alias_node_ids
|
|
22
|
+
from pretensor.mcp.tool_registry import McpTool
|
|
23
|
+
from pretensor.visibility.filter import VisibilityFilter
|
|
24
|
+
from pretensor.visibility.kuzu_helpers import visible_schema_table_node_ids
|
|
25
|
+
|
|
26
|
+
from ..payload_types import TraversePathPayload, TraverseStepPayload
|
|
27
|
+
from ..service_context import get_effective_visibility_filter
|
|
28
|
+
from ..service_registry import (
|
|
29
|
+
graph_path_for_entry,
|
|
30
|
+
load_registry,
|
|
31
|
+
open_store_for_entry,
|
|
32
|
+
release_store,
|
|
33
|
+
resolve_registry_entry,
|
|
34
|
+
)
|
|
35
|
+
from .context import qualified_from_node_id, resolve_table_node_id
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sql_hint_from_steps(steps: list[TraverseStepPayload]) -> str:
|
|
41
|
+
"""Build SQL JOIN hints, grouping composite FK columns into one JOIN clause."""
|
|
42
|
+
# Group consecutive steps that share the same tables and constraint_name
|
|
43
|
+
# into a single JOIN with multiple ON conditions.
|
|
44
|
+
groups: list[list[TraverseStepPayload]] = []
|
|
45
|
+
for s in steps:
|
|
46
|
+
cn = s.get("constraint_name")
|
|
47
|
+
if (
|
|
48
|
+
cn
|
|
49
|
+
and groups
|
|
50
|
+
and groups[-1][0].get("constraint_name") == cn
|
|
51
|
+
and groups[-1][0].get("from_table") == s.get("from_table")
|
|
52
|
+
and groups[-1][0].get("to_table") == s.get("to_table")
|
|
53
|
+
):
|
|
54
|
+
groups[-1].append(s)
|
|
55
|
+
else:
|
|
56
|
+
groups.append([s])
|
|
57
|
+
|
|
58
|
+
lines: list[str] = []
|
|
59
|
+
for group in groups:
|
|
60
|
+
first = group[0]
|
|
61
|
+
left = first.get("from_table", "")
|
|
62
|
+
right = first.get("to_table", "")
|
|
63
|
+
left_short = left.split(".")[-1]
|
|
64
|
+
right_short = right.split(".")[-1]
|
|
65
|
+
conditions = [
|
|
66
|
+
f"{left_short}.{g.get('from_column', '')} = {right_short}.{g.get('to_column', '')}"
|
|
67
|
+
for g in group
|
|
68
|
+
]
|
|
69
|
+
lines.append(f"JOIN {right_short} ON {' AND '.join(conditions)}")
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def stored_path_to_payload(path: StoredJoinPath) -> TraversePathPayload:
|
|
74
|
+
steps_out: list[TraverseStepPayload] = []
|
|
75
|
+
for s in path.steps:
|
|
76
|
+
steps_out.append(
|
|
77
|
+
{
|
|
78
|
+
"from_table": f"{s.from_schema}.{s.from_table}",
|
|
79
|
+
"to_table": f"{s.to_schema}.{s.to_table}",
|
|
80
|
+
"from_column": s.from_column,
|
|
81
|
+
"to_column": s.to_column,
|
|
82
|
+
"edge_type": "fk" if s.edge_type == "fk" else "inferred",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
out: TraversePathPayload = {
|
|
86
|
+
"confidence": path.confidence,
|
|
87
|
+
"ambiguous": path.ambiguous,
|
|
88
|
+
"semantic_label": path.semantic_label,
|
|
89
|
+
"steps": steps_out,
|
|
90
|
+
"sql_hint": sql_hint_from_steps(steps_out),
|
|
91
|
+
}
|
|
92
|
+
if path.stale:
|
|
93
|
+
out["stale"] = True
|
|
94
|
+
out["stale_warning"] = (
|
|
95
|
+
"This join path may be outdated due to recent schema changes. "
|
|
96
|
+
"Verify the path before using it in SQL, or run "
|
|
97
|
+
"`pretensor reindex <dsn> --recompute-intelligence`."
|
|
98
|
+
)
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _qualified_visible(
|
|
103
|
+
vf: VisibilityFilter | None,
|
|
104
|
+
connection_name: str,
|
|
105
|
+
qualified: str,
|
|
106
|
+
) -> bool:
|
|
107
|
+
if vf is None:
|
|
108
|
+
return True
|
|
109
|
+
q = qualified.strip()
|
|
110
|
+
if "." not in q:
|
|
111
|
+
return vf.is_table_visible(connection_name, "", q)
|
|
112
|
+
sn, _, tn = q.partition(".")
|
|
113
|
+
return vf.is_table_visible(connection_name, sn, tn)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def traverse_steps_respect_visibility(
|
|
117
|
+
steps: Sequence[Mapping[str, Any]],
|
|
118
|
+
*,
|
|
119
|
+
visibility_filter: VisibilityFilter | None,
|
|
120
|
+
default_connection: str,
|
|
121
|
+
) -> bool:
|
|
122
|
+
"""True when every hop endpoint is visible (cross-db steps may set from_db/to_db).
|
|
123
|
+
|
|
124
|
+
FK steps (``edge_type == "fk"``) are exempt: FK declarations are DDL-level
|
|
125
|
+
join structure, not row data, so surfacing a path through a hidden FK
|
|
126
|
+
waypoint does not leak data. Only inferred edges remain gated.
|
|
127
|
+
"""
|
|
128
|
+
if visibility_filter is None:
|
|
129
|
+
return True
|
|
130
|
+
for s in steps:
|
|
131
|
+
if str(s.get("edge_type", "")).strip() == "fk":
|
|
132
|
+
continue
|
|
133
|
+
ft = str(s.get("from_table", "")).strip()
|
|
134
|
+
tt = str(s.get("to_table", "")).strip()
|
|
135
|
+
fdb = str(s.get("from_db", "") or default_connection).strip()
|
|
136
|
+
tdb = str(s.get("to_db", "") or default_connection).strip()
|
|
137
|
+
if not _qualified_visible(visibility_filter, fdb, ft):
|
|
138
|
+
return False
|
|
139
|
+
if not _qualified_visible(visibility_filter, tdb, tt):
|
|
140
|
+
return False
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def dijkstra_join_path(
|
|
145
|
+
store: KuzuStore,
|
|
146
|
+
*,
|
|
147
|
+
start_id: str,
|
|
148
|
+
end_id: str,
|
|
149
|
+
connection_name: str,
|
|
150
|
+
max_depth: int,
|
|
151
|
+
allowed_table_node_ids: set[str] | None = None,
|
|
152
|
+
shadow_alias_ids: frozenset[str] | None = None,
|
|
153
|
+
edge_kinds: tuple[str, ...] | None = None,
|
|
154
|
+
max_inferred_hops: int = 2,
|
|
155
|
+
) -> tuple[list[tuple[str, str, str, str, str, str | None]], float] | None:
|
|
156
|
+
"""Return the lowest-cost edge list under :func:`edge_cost`.
|
|
157
|
+
|
|
158
|
+
See :func:`pretensor.intelligence.join_paths.on_demand.edge_cost` for the
|
|
159
|
+
canonical cost formula (base hop + inferred penalty + low-confidence
|
|
160
|
+
penalty). Returns ``(edges, min_confidence)`` or *None*.
|
|
161
|
+
|
|
162
|
+
Invariant: FK edges are never gated by ``allowed_table_node_ids``; only
|
|
163
|
+
inferred edges are. Rationale: FK declarations are DDL-level join
|
|
164
|
+
structure, not row-level data exposure, so pre-pruning them would drop
|
|
165
|
+
authoritative join chains whenever a waypoint table is hidden. Do not
|
|
166
|
+
re-introduce a gate on FK edges here — :func:`traverse_payload` relies
|
|
167
|
+
on this invariant for the fk-only probe that promotes a FK chain over a
|
|
168
|
+
stored inferred winner when the precompute pass wrote a short inferred
|
|
169
|
+
shortcut (e.g. pagila ``film → customer`` across cluster boundaries).
|
|
170
|
+
"""
|
|
171
|
+
from pretensor.intelligence.join_paths.on_demand import AdjEdge, edge_cost
|
|
172
|
+
|
|
173
|
+
fk_rows = store.query_all_rows(
|
|
174
|
+
"""
|
|
175
|
+
MATCH (a:SchemaTable)-[r:FK_REFERENCES]->(b:SchemaTable)
|
|
176
|
+
WHERE a.connection_name = $cn AND b.connection_name = $cn
|
|
177
|
+
RETURN a.node_id, b.node_id, r.source_column, r.target_column, r.constraint_name
|
|
178
|
+
""",
|
|
179
|
+
{"cn": connection_name},
|
|
180
|
+
)
|
|
181
|
+
inf_rows = store.query_all_rows(
|
|
182
|
+
"""
|
|
183
|
+
MATCH (a:SchemaTable)-[r:INFERRED_JOIN]->(b:SchemaTable)
|
|
184
|
+
WHERE a.connection_name = $cn AND b.connection_name = $cn
|
|
185
|
+
RETURN a.node_id, b.node_id, r.source_column, r.target_column, r.confidence
|
|
186
|
+
""",
|
|
187
|
+
{"cn": connection_name},
|
|
188
|
+
)
|
|
189
|
+
_shadows = shadow_alias_ids or frozenset()
|
|
190
|
+
# Adjacency mirrors on_demand.build_adjacency: both FK and inferred edges
|
|
191
|
+
# are added **bidirectionally**. Join paths are symmetric (A joins B by
|
|
192
|
+
# the FK iff B joins A by it), so pagila ``film → customer`` needs to
|
|
193
|
+
# walk the inventory FK in reverse — only the directional adjacency
|
|
194
|
+
# would strand the start node when it is the FK parent. Constraint
|
|
195
|
+
# names are preserved on both directions for SQL hint rendering.
|
|
196
|
+
adj: dict[str, list[tuple[AdjEdge, str | None]]] = {}
|
|
197
|
+
for a, b, sc, tc, cn in fk_rows:
|
|
198
|
+
sa, sb = str(a), str(b)
|
|
199
|
+
if sa in _shadows or sb in _shadows:
|
|
200
|
+
continue
|
|
201
|
+
cname = str(cn) if cn is not None else None
|
|
202
|
+
s_col = str(sc)
|
|
203
|
+
t_col = str(tc)
|
|
204
|
+
adj.setdefault(sa, []).append((AdjEdge(sb, s_col, t_col, "fk", 1.0), cname))
|
|
205
|
+
adj.setdefault(sb, []).append((AdjEdge(sa, t_col, s_col, "fk", 1.0), cname))
|
|
206
|
+
for a, b, sc, tc, conf in inf_rows:
|
|
207
|
+
sa, sb = str(a), str(b)
|
|
208
|
+
if sa in _shadows or sb in _shadows:
|
|
209
|
+
continue
|
|
210
|
+
w = float(conf) if conf is not None else 0.5
|
|
211
|
+
s_col = str(sc)
|
|
212
|
+
t_col = str(tc)
|
|
213
|
+
adj.setdefault(sa, []).append((AdjEdge(sb, s_col, t_col, "inferred", w), None))
|
|
214
|
+
adj.setdefault(sb, []).append((AdjEdge(sa, t_col, s_col, "inferred", w), None))
|
|
215
|
+
|
|
216
|
+
counter = 0
|
|
217
|
+
heap: list[
|
|
218
|
+
tuple[
|
|
219
|
+
float,
|
|
220
|
+
int,
|
|
221
|
+
str,
|
|
222
|
+
list[tuple[str, str, str, str, str, str | None]],
|
|
223
|
+
float,
|
|
224
|
+
int,
|
|
225
|
+
]
|
|
226
|
+
] = []
|
|
227
|
+
heapq.heappush(heap, (0.0, counter, start_id, [], 1.0, 0))
|
|
228
|
+
visited: set[str] = set()
|
|
229
|
+
while heap:
|
|
230
|
+
cost, _, cur, path_edges, min_conf, inf_hops = heapq.heappop(heap)
|
|
231
|
+
if cur in visited:
|
|
232
|
+
continue
|
|
233
|
+
visited.add(cur)
|
|
234
|
+
if cur == end_id:
|
|
235
|
+
return path_edges, min_conf
|
|
236
|
+
if len(path_edges) >= max_depth:
|
|
237
|
+
continue
|
|
238
|
+
for ae, cname in adj.get(cur, []):
|
|
239
|
+
if edge_kinds is not None and ae.kind not in edge_kinds:
|
|
240
|
+
continue
|
|
241
|
+
if (
|
|
242
|
+
allowed_table_node_ids is not None
|
|
243
|
+
and ae.kind != "fk"
|
|
244
|
+
and ae.to_id not in allowed_table_node_ids
|
|
245
|
+
):
|
|
246
|
+
continue
|
|
247
|
+
if ae.to_id in visited:
|
|
248
|
+
continue
|
|
249
|
+
new_inf = inf_hops + (1 if ae.kind == "inferred" else 0)
|
|
250
|
+
if new_inf > max_inferred_hops:
|
|
251
|
+
continue
|
|
252
|
+
edge = (cur, ae.to_id, ae.source_column, ae.target_column, ae.kind, cname)
|
|
253
|
+
counter += 1
|
|
254
|
+
heapq.heappush(
|
|
255
|
+
heap,
|
|
256
|
+
(
|
|
257
|
+
cost + edge_cost(ae),
|
|
258
|
+
counter,
|
|
259
|
+
ae.to_id,
|
|
260
|
+
path_edges + [edge],
|
|
261
|
+
min(min_conf, ae.confidence),
|
|
262
|
+
new_inf,
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def step_dict_from_edge(
|
|
269
|
+
store: KuzuStore,
|
|
270
|
+
edge: tuple[str, str, str, str, str, str | None],
|
|
271
|
+
connection_label: str,
|
|
272
|
+
) -> TraverseStepPayload:
|
|
273
|
+
fn, tn, scol, tcol, kind, cname = edge
|
|
274
|
+
qf = qualified_from_node_id(store, fn)
|
|
275
|
+
qt = qualified_from_node_id(store, tn)
|
|
276
|
+
sc = str(scol)
|
|
277
|
+
tc = str(tcol)
|
|
278
|
+
step: TraverseStepPayload = {
|
|
279
|
+
"from_table": qf,
|
|
280
|
+
"to_table": qt,
|
|
281
|
+
"from_column": sc,
|
|
282
|
+
"to_column": tc,
|
|
283
|
+
"from_db": connection_label,
|
|
284
|
+
"to_db": connection_label,
|
|
285
|
+
"via": f"{sc} = {tc}",
|
|
286
|
+
"edge_type": "fk" if kind == "fk" else "inferred",
|
|
287
|
+
}
|
|
288
|
+
if cname is not None:
|
|
289
|
+
step["constraint_name"] = cname
|
|
290
|
+
return step
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# Extension surface: indexing never populates SAME_ENTITY, so this always
|
|
294
|
+
# returns an error in standard deployments. Kept as an integration point for
|
|
295
|
+
# deployments that supply entity-resolution results via external pipelines.
|
|
296
|
+
def traverse_cross_database(
|
|
297
|
+
store: KuzuStore,
|
|
298
|
+
*,
|
|
299
|
+
from_table: str,
|
|
300
|
+
to_table: str,
|
|
301
|
+
from_database_key: str,
|
|
302
|
+
to_database_key: str,
|
|
303
|
+
from_connection: str,
|
|
304
|
+
to_connection: str,
|
|
305
|
+
max_depth: int,
|
|
306
|
+
visibility_filter: VisibilityFilter | None = None,
|
|
307
|
+
) -> dict[str, Any]:
|
|
308
|
+
"""Path via confirmed ``SAME_ENTITY`` between two connections in one Kuzu file."""
|
|
309
|
+
from_id, err = resolve_table_node_id(
|
|
310
|
+
store,
|
|
311
|
+
from_table,
|
|
312
|
+
from_database_key,
|
|
313
|
+
visibility_filter=visibility_filter,
|
|
314
|
+
)
|
|
315
|
+
if err is not None:
|
|
316
|
+
try:
|
|
317
|
+
return json.loads(err)
|
|
318
|
+
except json.JSONDecodeError:
|
|
319
|
+
return {"error": err}
|
|
320
|
+
to_id, err2 = resolve_table_node_id(
|
|
321
|
+
store,
|
|
322
|
+
to_table,
|
|
323
|
+
to_database_key,
|
|
324
|
+
visibility_filter=visibility_filter,
|
|
325
|
+
)
|
|
326
|
+
if err2 is not None:
|
|
327
|
+
try:
|
|
328
|
+
return json.loads(err2)
|
|
329
|
+
except json.JSONDecodeError:
|
|
330
|
+
return {"error": err2}
|
|
331
|
+
if from_id is None or to_id is None:
|
|
332
|
+
return {"error": "Could not resolve one or both table references."}
|
|
333
|
+
|
|
334
|
+
bridge_rows = store.query_all_rows(
|
|
335
|
+
"""
|
|
336
|
+
MATCH (e1:Entity)-[r:SAME_ENTITY]->(e2:Entity)
|
|
337
|
+
WHERE r.status = $st
|
|
338
|
+
MATCH (e1)-[:REPRESENTS]->(ta:SchemaTable)
|
|
339
|
+
MATCH (e2)-[:REPRESENTS]->(tb:SchemaTable)
|
|
340
|
+
WHERE ta.connection_name = $fca AND tb.connection_name = $tca
|
|
341
|
+
RETURN ta.node_id, tb.node_id, r.join_columns, r.score
|
|
342
|
+
""",
|
|
343
|
+
{"st": "confirmed", "fca": from_connection, "tca": to_connection},
|
|
344
|
+
)
|
|
345
|
+
bridge_rows += store.query_all_rows(
|
|
346
|
+
"""
|
|
347
|
+
MATCH (e1:Entity)-[r:SAME_ENTITY]->(e2:Entity)
|
|
348
|
+
WHERE r.status = $st
|
|
349
|
+
MATCH (e1)-[:REPRESENTS]->(ta:SchemaTable)
|
|
350
|
+
MATCH (e2)-[:REPRESENTS]->(tb:SchemaTable)
|
|
351
|
+
WHERE ta.connection_name = $tca AND tb.connection_name = $fca
|
|
352
|
+
RETURN tb.node_id, ta.node_id, r.join_columns, r.score
|
|
353
|
+
""",
|
|
354
|
+
{"st": "confirmed", "fca": from_connection, "tca": to_connection},
|
|
355
|
+
)
|
|
356
|
+
allowed_from = visible_schema_table_node_ids(
|
|
357
|
+
store, from_connection, visibility_filter
|
|
358
|
+
)
|
|
359
|
+
allowed_to = visible_schema_table_node_ids(store, to_connection, visibility_filter)
|
|
360
|
+
shadow_from = get_shadow_alias_node_ids(store, from_database_key)
|
|
361
|
+
shadow_to = get_shadow_alias_node_ids(store, to_database_key)
|
|
362
|
+
|
|
363
|
+
best: tuple[float, list[TraverseStepPayload]] | None = None
|
|
364
|
+
cross_note = (
|
|
365
|
+
"Cross-database paths require application-side joins or a federation layer; "
|
|
366
|
+
"SQL cannot span these connections in a single query."
|
|
367
|
+
)
|
|
368
|
+
for ta_id, tb_id, jcols, sc in bridge_rows:
|
|
369
|
+
ta_s, tb_s = str(ta_id), str(tb_id)
|
|
370
|
+
left = dijkstra_join_path(
|
|
371
|
+
store,
|
|
372
|
+
start_id=from_id,
|
|
373
|
+
end_id=ta_s,
|
|
374
|
+
connection_name=from_connection,
|
|
375
|
+
max_depth=max_depth,
|
|
376
|
+
allowed_table_node_ids=allowed_from,
|
|
377
|
+
shadow_alias_ids=shadow_from,
|
|
378
|
+
)
|
|
379
|
+
right = dijkstra_join_path(
|
|
380
|
+
store,
|
|
381
|
+
start_id=tb_s,
|
|
382
|
+
end_id=to_id,
|
|
383
|
+
connection_name=to_connection,
|
|
384
|
+
max_depth=max_depth,
|
|
385
|
+
allowed_table_node_ids=allowed_to,
|
|
386
|
+
shadow_alias_ids=shadow_to,
|
|
387
|
+
)
|
|
388
|
+
if left is None or right is None:
|
|
389
|
+
continue
|
|
390
|
+
left_edges, left_conf = left
|
|
391
|
+
right_edges, right_conf = right
|
|
392
|
+
bridge_conf = float(sc) if sc is not None else 1.0
|
|
393
|
+
conf = min(left_conf, right_conf, bridge_conf)
|
|
394
|
+
|
|
395
|
+
steps_out: list[TraverseStepPayload] = [
|
|
396
|
+
step_dict_from_edge(store, e, from_connection) for e in left_edges
|
|
397
|
+
]
|
|
398
|
+
bridge_step: TraverseStepPayload = {
|
|
399
|
+
"from_table": qualified_from_node_id(store, ta_s),
|
|
400
|
+
"to_table": qualified_from_node_id(store, tb_s),
|
|
401
|
+
"from_column": "",
|
|
402
|
+
"to_column": "",
|
|
403
|
+
"from_db": from_connection,
|
|
404
|
+
"to_db": to_connection,
|
|
405
|
+
"via": "SAME_ENTITY (confirmed)",
|
|
406
|
+
"edge_type": "entity_link",
|
|
407
|
+
"join_columns": str(jcols) if jcols is not None else None,
|
|
408
|
+
}
|
|
409
|
+
steps_out.append(bridge_step)
|
|
410
|
+
steps_out.extend(
|
|
411
|
+
step_dict_from_edge(store, e, to_connection) for e in right_edges
|
|
412
|
+
)
|
|
413
|
+
if best is None or conf > best[0]:
|
|
414
|
+
best = (conf, steps_out)
|
|
415
|
+
|
|
416
|
+
if best is None:
|
|
417
|
+
return {
|
|
418
|
+
"error": (
|
|
419
|
+
"No cross-database path found via confirmed SAME_ENTITY links. "
|
|
420
|
+
"Entity links are populated by external tooling, not by indexing."
|
|
421
|
+
),
|
|
422
|
+
"from": from_table,
|
|
423
|
+
"to": to_table,
|
|
424
|
+
"from_database": from_connection,
|
|
425
|
+
"to_database": to_connection,
|
|
426
|
+
"cross_database": True,
|
|
427
|
+
}
|
|
428
|
+
conf, steps_out = best
|
|
429
|
+
if visibility_filter is not None and not traverse_steps_respect_visibility(
|
|
430
|
+
steps_out,
|
|
431
|
+
visibility_filter=visibility_filter,
|
|
432
|
+
default_connection=from_connection,
|
|
433
|
+
):
|
|
434
|
+
return {
|
|
435
|
+
"error": (
|
|
436
|
+
"No cross-database path found that respects visibility rules "
|
|
437
|
+
"(a required hop crosses a hidden table)."
|
|
438
|
+
),
|
|
439
|
+
"from": from_table,
|
|
440
|
+
"to": to_table,
|
|
441
|
+
"from_database": from_connection,
|
|
442
|
+
"to_database": to_connection,
|
|
443
|
+
"cross_database": True,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
"from": from_table,
|
|
448
|
+
"to": to_table,
|
|
449
|
+
"from_database": from_connection,
|
|
450
|
+
"to_database": to_connection,
|
|
451
|
+
"cross_database": True,
|
|
452
|
+
"cross_db_note": cross_note,
|
|
453
|
+
"paths": [
|
|
454
|
+
{
|
|
455
|
+
"steps": steps_out,
|
|
456
|
+
"confidence": conf,
|
|
457
|
+
"requires_application_join": True,
|
|
458
|
+
"semantic_label": "cross_database",
|
|
459
|
+
"ambiguous": False,
|
|
460
|
+
"sql_hint": "",
|
|
461
|
+
}
|
|
462
|
+
],
|
|
463
|
+
"used_precomputed": False,
|
|
464
|
+
"warning": None,
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _intermediate_keys(path: TraversePathPayload) -> list[tuple[str, str]]:
|
|
469
|
+
"""Return ``(schema, table)`` pairs for every node a path passes *through*.
|
|
470
|
+
|
|
471
|
+
The ``from`` endpoint is ``steps[0].from_table``; the ``to`` endpoint is
|
|
472
|
+
``steps[-1].to_table``. Intermediates are the ``to_table`` of every step
|
|
473
|
+
except the last (equivalently, the ``from_table`` of every step except
|
|
474
|
+
the first). Table names in step payloads are already ``"schema.table"``.
|
|
475
|
+
"""
|
|
476
|
+
steps = path.get("steps") or []
|
|
477
|
+
if len(steps) <= 1:
|
|
478
|
+
return []
|
|
479
|
+
keys: list[tuple[str, str]] = []
|
|
480
|
+
for step in steps[:-1]:
|
|
481
|
+
qual = str(step.get("to_table", ""))
|
|
482
|
+
if "." in qual:
|
|
483
|
+
sn, tn = qual.split(".", 1)
|
|
484
|
+
else:
|
|
485
|
+
sn, tn = "", qual
|
|
486
|
+
keys.append((sn, tn))
|
|
487
|
+
return keys
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _embedding_tie_break(
|
|
491
|
+
store: KuzuStore,
|
|
492
|
+
*,
|
|
493
|
+
db_key: str,
|
|
494
|
+
from_id: str,
|
|
495
|
+
to_id: str,
|
|
496
|
+
paths: list[TraversePathPayload],
|
|
497
|
+
embedding_client: EmbeddingClient | None = None,
|
|
498
|
+
) -> tuple[list[TraversePathPayload], bool]:
|
|
499
|
+
"""Reorder equal-cost ``paths`` by embedding similarity.
|
|
500
|
+
|
|
501
|
+
Scoring: ``cosine(embed(from.description ⊕ to.description),
|
|
502
|
+
embed(⊕ intermediate.description))`` per path. Embeds all texts in one
|
|
503
|
+
batch call — endpoints at index 0, each path's concatenated intermediate
|
|
504
|
+
text at indices 1..N.
|
|
505
|
+
|
|
506
|
+
Returns ``(paths, False)`` unchanged (pre-PR behavior) when any of:
|
|
507
|
+
* ``PRETENSOR_EMBEDDINGS_DISABLED`` is set (kill switch);
|
|
508
|
+
* fewer than two paths (no tie to break);
|
|
509
|
+
* either endpoint row is missing its ``SchemaTable.embedding``;
|
|
510
|
+
* every path has zero intermediates (direct ``from → to`` hops);
|
|
511
|
+
* the embedding client is missing, unavailable, or raises.
|
|
512
|
+
|
|
513
|
+
Returns ``(reranked, True)`` with paths sorted by descending score.
|
|
514
|
+
Python's ``sorted`` is stable, so paths with identical scores retain
|
|
515
|
+
their incoming order — all top-tied paths are emitted together.
|
|
516
|
+
"""
|
|
517
|
+
if len(paths) <= 1:
|
|
518
|
+
return paths, False
|
|
519
|
+
|
|
520
|
+
# Kill switch wins over everything, including an injected client.
|
|
521
|
+
if embeddings_disabled_via_env():
|
|
522
|
+
return paths, False
|
|
523
|
+
|
|
524
|
+
# Two-stage check: first confirm both endpoints carry an embedding
|
|
525
|
+
# (cheap boolean), then fetch only the descriptions used by the
|
|
526
|
+
# tie-break input. Avoids reading the 384-float vector for both
|
|
527
|
+
# endpoints when we only need a null-check on them — that data is
|
|
528
|
+
# never consulted; the tie-break embeds the descriptions, not the
|
|
529
|
+
# endpoints' own vectors.
|
|
530
|
+
endpoint_rows = store.query_all_rows(
|
|
531
|
+
"""
|
|
532
|
+
MATCH (t:SchemaTable)
|
|
533
|
+
WHERE t.database = $db AND t.node_id IN [$fid, $tid]
|
|
534
|
+
RETURN t.node_id, t.description, t.embedding IS NOT NULL
|
|
535
|
+
""",
|
|
536
|
+
{"db": db_key, "fid": from_id, "tid": to_id},
|
|
537
|
+
)
|
|
538
|
+
by_nid: dict[str, tuple[str, bool]] = {}
|
|
539
|
+
for row in endpoint_rows:
|
|
540
|
+
nid = str(row[0])
|
|
541
|
+
desc = str(row[1] or "")
|
|
542
|
+
has_vec = bool(row[2])
|
|
543
|
+
by_nid[nid] = (desc, has_vec)
|
|
544
|
+
from_entry = by_nid.get(from_id)
|
|
545
|
+
to_entry = by_nid.get(to_id)
|
|
546
|
+
if from_entry is None or to_entry is None:
|
|
547
|
+
return paths, False
|
|
548
|
+
if not from_entry[1] or not to_entry[1]:
|
|
549
|
+
return paths, False
|
|
550
|
+
from_desc = from_entry[0]
|
|
551
|
+
to_desc = to_entry[0]
|
|
552
|
+
|
|
553
|
+
path_intermediates = [_intermediate_keys(p) for p in paths]
|
|
554
|
+
if not any(path_intermediates):
|
|
555
|
+
return paths, False
|
|
556
|
+
|
|
557
|
+
wanted_schemas: set[str] = set()
|
|
558
|
+
wanted_tables: set[str] = set()
|
|
559
|
+
for keys in path_intermediates:
|
|
560
|
+
for sn, tn in keys:
|
|
561
|
+
wanted_schemas.add(sn)
|
|
562
|
+
wanted_tables.add(tn)
|
|
563
|
+
desc_rows = store.query_all_rows(
|
|
564
|
+
"""
|
|
565
|
+
MATCH (t:SchemaTable)
|
|
566
|
+
WHERE t.database = $db
|
|
567
|
+
AND t.schema_name IN $schemas
|
|
568
|
+
AND t.table_name IN $tables
|
|
569
|
+
RETURN t.schema_name, t.table_name, t.description
|
|
570
|
+
""",
|
|
571
|
+
{
|
|
572
|
+
"db": db_key,
|
|
573
|
+
"schemas": list(wanted_schemas),
|
|
574
|
+
"tables": list(wanted_tables),
|
|
575
|
+
},
|
|
576
|
+
)
|
|
577
|
+
desc_by_key: dict[tuple[str, str], str] = {}
|
|
578
|
+
for row in desc_rows:
|
|
579
|
+
desc_by_key[(str(row[0] or ""), str(row[1] or ""))] = str(row[2] or "")
|
|
580
|
+
|
|
581
|
+
# ``LocalEmbeddingClient.__init__`` is I/O-free; a fresh-or-cached
|
|
582
|
+
# construction here cannot raise. Errors from the actual embed()
|
|
583
|
+
# call are caught below, where they route to skipping the tie-break
|
|
584
|
+
# without raising up to the MCP caller.
|
|
585
|
+
client = embedding_client or get_default_embedding_client()
|
|
586
|
+
|
|
587
|
+
texts: list[str] = [f"{from_desc} {to_desc}"]
|
|
588
|
+
for keys in path_intermediates:
|
|
589
|
+
texts.append(" ".join(desc_by_key.get(k, "") for k in keys))
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
vectors = client.embed(texts)
|
|
593
|
+
except ImportError as exc:
|
|
594
|
+
logger.warning("traverse: [embeddings] extra not installed: %s", exc)
|
|
595
|
+
return paths, False
|
|
596
|
+
except Exception as exc: # noqa: BLE001 — tool must never raise
|
|
597
|
+
logger.warning(
|
|
598
|
+
"traverse: embedding client failed (%s); skipping tie-break", exc
|
|
599
|
+
)
|
|
600
|
+
return paths, False
|
|
601
|
+
|
|
602
|
+
if not vectors or len(vectors) != len(texts):
|
|
603
|
+
return paths, False
|
|
604
|
+
if any(not v for v in vectors):
|
|
605
|
+
return paths, False
|
|
606
|
+
|
|
607
|
+
anchor = vectors[0]
|
|
608
|
+
scored: list[tuple[int, float, TraversePathPayload]] = []
|
|
609
|
+
for idx, path in enumerate(paths):
|
|
610
|
+
try:
|
|
611
|
+
score = cosine_similarity(anchor, vectors[idx + 1])
|
|
612
|
+
except ValueError as exc:
|
|
613
|
+
logger.warning("traverse: tie-break dim mismatch (%s); skipping", exc)
|
|
614
|
+
return paths, False
|
|
615
|
+
scored.append((idx, score, path))
|
|
616
|
+
|
|
617
|
+
# Python's sort is stable — paths with identical scores retain
|
|
618
|
+
# input order, preserving the incoming order across all top-tied paths.
|
|
619
|
+
scored.sort(key=lambda item: item[1], reverse=True)
|
|
620
|
+
reranked = [item[2] for item in scored]
|
|
621
|
+
return reranked, True
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def traverse_payload(
|
|
625
|
+
graph_dir: Path,
|
|
626
|
+
*,
|
|
627
|
+
from_table: str,
|
|
628
|
+
to_table: str,
|
|
629
|
+
database: str,
|
|
630
|
+
to_database: str | None = None,
|
|
631
|
+
max_depth: int = 4,
|
|
632
|
+
top_k: int = 3,
|
|
633
|
+
edge_types: tuple[str, ...] | None = None,
|
|
634
|
+
max_inferred_hops: int = 2,
|
|
635
|
+
visibility_filter: VisibilityFilter | None = None,
|
|
636
|
+
embedding_client: EmbeddingClient | None = None,
|
|
637
|
+
) -> dict[str, Any]:
|
|
638
|
+
"""Join paths between two tables (precomputed or on-demand Yen's K-shortest)."""
|
|
639
|
+
edge_kinds: tuple[Any, ...] | None = None
|
|
640
|
+
if edge_types:
|
|
641
|
+
valid = {"fk", "inferred"}
|
|
642
|
+
cleaned = tuple(t for t in edge_types if t in valid)
|
|
643
|
+
if cleaned:
|
|
644
|
+
edge_kinds = cleaned
|
|
645
|
+
reg = load_registry(graph_dir)
|
|
646
|
+
entry = resolve_registry_entry(reg, database)
|
|
647
|
+
if entry is None:
|
|
648
|
+
return {
|
|
649
|
+
"error": "Unknown database connection or name; pass `database` "
|
|
650
|
+
"(connection name or logical database)."
|
|
651
|
+
}
|
|
652
|
+
gp = graph_path_for_entry(entry)
|
|
653
|
+
if not gp.exists():
|
|
654
|
+
return {"error": f"Graph file missing: {gp}"}
|
|
655
|
+
|
|
656
|
+
db_key = str(entry.database)
|
|
657
|
+
vf = visibility_filter or get_effective_visibility_filter()
|
|
658
|
+
store = open_store_for_entry(entry)
|
|
659
|
+
try:
|
|
660
|
+
allowed = visible_schema_table_node_ids(store, entry.connection_name, vf)
|
|
661
|
+
to_entry_target: RegistryEntry | None = None
|
|
662
|
+
if to_database is not None and str(to_database).strip():
|
|
663
|
+
to_entry_target = resolve_registry_entry(reg, str(to_database).strip())
|
|
664
|
+
if to_entry_target is None:
|
|
665
|
+
return {
|
|
666
|
+
"error": f"Unknown to_database connection {to_database!r}.",
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if to_entry_target is None:
|
|
670
|
+
entries = reg.list_entries()
|
|
671
|
+
if len(entries) > 1:
|
|
672
|
+
for cand in entries:
|
|
673
|
+
if cand.connection_name == entry.connection_name:
|
|
674
|
+
continue
|
|
675
|
+
if graph_path_for_entry(cand) != gp:
|
|
676
|
+
continue
|
|
677
|
+
res = traverse_cross_database(
|
|
678
|
+
store,
|
|
679
|
+
from_table=from_table,
|
|
680
|
+
to_table=to_table,
|
|
681
|
+
from_database_key=db_key,
|
|
682
|
+
to_database_key=str(cand.database),
|
|
683
|
+
from_connection=entry.connection_name,
|
|
684
|
+
to_connection=cand.connection_name,
|
|
685
|
+
max_depth=max_depth,
|
|
686
|
+
visibility_filter=vf,
|
|
687
|
+
)
|
|
688
|
+
if "error" not in res:
|
|
689
|
+
return res
|
|
690
|
+
|
|
691
|
+
elif (
|
|
692
|
+
str(to_entry_target.database) != db_key
|
|
693
|
+
or to_entry_target.connection_name != entry.connection_name
|
|
694
|
+
):
|
|
695
|
+
return traverse_cross_database(
|
|
696
|
+
store,
|
|
697
|
+
from_table=from_table,
|
|
698
|
+
to_table=to_table,
|
|
699
|
+
from_database_key=db_key,
|
|
700
|
+
to_database_key=str(to_entry_target.database),
|
|
701
|
+
from_connection=entry.connection_name,
|
|
702
|
+
to_connection=to_entry_target.connection_name,
|
|
703
|
+
max_depth=max_depth,
|
|
704
|
+
visibility_filter=vf,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
from_id, err = resolve_table_node_id(
|
|
708
|
+
store, from_table, db_key, visibility_filter=vf
|
|
709
|
+
)
|
|
710
|
+
if err is not None:
|
|
711
|
+
try:
|
|
712
|
+
return json.loads(err)
|
|
713
|
+
except json.JSONDecodeError:
|
|
714
|
+
return {"error": err}
|
|
715
|
+
to_id, err2 = resolve_table_node_id(
|
|
716
|
+
store, to_table, db_key, visibility_filter=vf
|
|
717
|
+
)
|
|
718
|
+
if err2 is not None:
|
|
719
|
+
try:
|
|
720
|
+
return json.loads(err2)
|
|
721
|
+
except json.JSONDecodeError:
|
|
722
|
+
return {"error": err2}
|
|
723
|
+
if from_id is None or to_id is None:
|
|
724
|
+
return {"error": "Could not resolve one or both table references."}
|
|
725
|
+
|
|
726
|
+
engine = JoinPathEngine(store)
|
|
727
|
+
stored = engine.load_stored_paths(db_key, from_id, to_id)
|
|
728
|
+
used_fallback = False
|
|
729
|
+
tie_break_ran = False
|
|
730
|
+
paths_payload: list[TraversePathPayload] = []
|
|
731
|
+
visible_stored: list[StoredJoinPath] = []
|
|
732
|
+
for p in stored:
|
|
733
|
+
pay = stored_path_to_payload(p)
|
|
734
|
+
steps = pay.get("steps") or []
|
|
735
|
+
if traverse_steps_respect_visibility(
|
|
736
|
+
steps,
|
|
737
|
+
visibility_filter=vf,
|
|
738
|
+
default_connection=entry.connection_name,
|
|
739
|
+
):
|
|
740
|
+
paths_payload.append(pay)
|
|
741
|
+
visible_stored.append(p)
|
|
742
|
+
|
|
743
|
+
shadow_ids = get_shadow_alias_node_ids(store, db_key)
|
|
744
|
+
|
|
745
|
+
# Safety net for stale indexes written before the precompute cross-cluster
|
|
746
|
+
# depth was lifted. If every stored path for this pair goes through an
|
|
747
|
+
# inferred edge, probe for a pure-FK chain within ``max_depth``; when one
|
|
748
|
+
# exists, promote it to the top slot so the authoritative DDL-declared
|
|
749
|
+
# join beats a short inferred shortcut. Fresh indexes (post this PR)
|
|
750
|
+
# already persist the FK chain, so the probe is a no-op.
|
|
751
|
+
if paths_payload and all(
|
|
752
|
+
any(
|
|
753
|
+
str(s.get("edge_type", "")) == "inferred"
|
|
754
|
+
for s in (p.get("steps") or [])
|
|
755
|
+
)
|
|
756
|
+
for p in paths_payload
|
|
757
|
+
):
|
|
758
|
+
fk_probe = dijkstra_join_path(
|
|
759
|
+
store,
|
|
760
|
+
start_id=from_id,
|
|
761
|
+
end_id=to_id,
|
|
762
|
+
connection_name=entry.connection_name,
|
|
763
|
+
max_depth=max_depth,
|
|
764
|
+
allowed_table_node_ids=allowed,
|
|
765
|
+
shadow_alias_ids=shadow_ids,
|
|
766
|
+
edge_kinds=("fk",),
|
|
767
|
+
max_inferred_hops=0,
|
|
768
|
+
)
|
|
769
|
+
if fk_probe is not None:
|
|
770
|
+
fk_edges, fk_min_conf = fk_probe
|
|
771
|
+
fk_steps_out: list[TraverseStepPayload] = [
|
|
772
|
+
step_dict_from_edge(store, e, entry.connection_name)
|
|
773
|
+
for e in fk_edges
|
|
774
|
+
]
|
|
775
|
+
if traverse_steps_respect_visibility(
|
|
776
|
+
fk_steps_out,
|
|
777
|
+
visibility_filter=vf,
|
|
778
|
+
default_connection=entry.connection_name,
|
|
779
|
+
):
|
|
780
|
+
fk_payload: TraversePathPayload = {
|
|
781
|
+
"confidence": fk_min_conf,
|
|
782
|
+
"ambiguous": False,
|
|
783
|
+
"semantic_label": "fk_chain_promoted",
|
|
784
|
+
"steps": fk_steps_out,
|
|
785
|
+
"sql_hint": sql_hint_from_steps(fk_steps_out),
|
|
786
|
+
}
|
|
787
|
+
paths_payload = [fk_payload, *paths_payload]
|
|
788
|
+
used_fallback = True
|
|
789
|
+
logger.info(
|
|
790
|
+
"traverse: fk_chain_promoted over stored inferred path "
|
|
791
|
+
"for %s -> %s (%s); reindex to refresh precomputed winners",
|
|
792
|
+
from_table,
|
|
793
|
+
to_table,
|
|
794
|
+
database,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
# Precompute only persists the single winning path per pair; when it is
|
|
798
|
+
# flagged ambiguous we re-enumerate tied peers so callers see every
|
|
799
|
+
# candidate they may need to pick between.
|
|
800
|
+
if paths_payload and any(p.ambiguous for p in visible_stored):
|
|
801
|
+
tied = engine.find_paths_on_demand(
|
|
802
|
+
db_key,
|
|
803
|
+
from_id,
|
|
804
|
+
to_id,
|
|
805
|
+
max_depth,
|
|
806
|
+
top_k=top_k,
|
|
807
|
+
edge_kinds=edge_kinds,
|
|
808
|
+
max_inferred_hops=max_inferred_hops,
|
|
809
|
+
)
|
|
810
|
+
tied_payloads: list[TraversePathPayload] = []
|
|
811
|
+
for tp in tied:
|
|
812
|
+
tpay = stored_path_to_payload(tp)
|
|
813
|
+
tsteps = tpay.get("steps") or []
|
|
814
|
+
if traverse_steps_respect_visibility(
|
|
815
|
+
tsteps,
|
|
816
|
+
visibility_filter=vf,
|
|
817
|
+
default_connection=entry.connection_name,
|
|
818
|
+
):
|
|
819
|
+
tied_payloads.append(tpay)
|
|
820
|
+
if len(tied_payloads) > 1:
|
|
821
|
+
paths_payload, tie_break_ran = _embedding_tie_break(
|
|
822
|
+
store,
|
|
823
|
+
db_key=db_key,
|
|
824
|
+
from_id=from_id,
|
|
825
|
+
to_id=to_id,
|
|
826
|
+
paths=tied_payloads,
|
|
827
|
+
embedding_client=embedding_client,
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
if not paths_payload:
|
|
831
|
+
bfs = dijkstra_join_path(
|
|
832
|
+
store,
|
|
833
|
+
start_id=from_id,
|
|
834
|
+
end_id=to_id,
|
|
835
|
+
connection_name=entry.connection_name,
|
|
836
|
+
max_depth=max_depth,
|
|
837
|
+
allowed_table_node_ids=allowed,
|
|
838
|
+
shadow_alias_ids=shadow_ids,
|
|
839
|
+
edge_kinds=edge_kinds,
|
|
840
|
+
max_inferred_hops=max_inferred_hops,
|
|
841
|
+
)
|
|
842
|
+
if bfs is not None:
|
|
843
|
+
edges, min_conf = bfs
|
|
844
|
+
steps_out: list[TraverseStepPayload] = [
|
|
845
|
+
step_dict_from_edge(store, e, entry.connection_name) for e in edges
|
|
846
|
+
]
|
|
847
|
+
fallback_path: TraversePathPayload = {
|
|
848
|
+
"confidence": min_conf,
|
|
849
|
+
"ambiguous": False,
|
|
850
|
+
"semantic_label": "visibility_filtered",
|
|
851
|
+
"steps": steps_out,
|
|
852
|
+
"sql_hint": sql_hint_from_steps(steps_out),
|
|
853
|
+
}
|
|
854
|
+
paths_payload = [fallback_path]
|
|
855
|
+
used_fallback = True
|
|
856
|
+
logger.warning(
|
|
857
|
+
"traverse: visibility-filtered on-demand path for %s -> %s (%s)",
|
|
858
|
+
from_table,
|
|
859
|
+
to_table,
|
|
860
|
+
database,
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
if not paths_payload:
|
|
864
|
+
on_demand_paths = engine.find_paths_on_demand(
|
|
865
|
+
db_key,
|
|
866
|
+
from_id,
|
|
867
|
+
to_id,
|
|
868
|
+
max_depth,
|
|
869
|
+
top_k=top_k,
|
|
870
|
+
edge_kinds=edge_kinds,
|
|
871
|
+
max_inferred_hops=max_inferred_hops,
|
|
872
|
+
)
|
|
873
|
+
collected: list[TraversePathPayload] = []
|
|
874
|
+
for od in on_demand_paths:
|
|
875
|
+
pay2 = stored_path_to_payload(od)
|
|
876
|
+
steps2 = pay2.get("steps") or []
|
|
877
|
+
if traverse_steps_respect_visibility(
|
|
878
|
+
steps2,
|
|
879
|
+
visibility_filter=vf,
|
|
880
|
+
default_connection=entry.connection_name,
|
|
881
|
+
):
|
|
882
|
+
collected.append(pay2)
|
|
883
|
+
if collected:
|
|
884
|
+
paths_payload = collected
|
|
885
|
+
used_fallback = True
|
|
886
|
+
logger.warning(
|
|
887
|
+
"traverse: on-demand path for %s -> %s (%s)",
|
|
888
|
+
from_table,
|
|
889
|
+
to_table,
|
|
890
|
+
database,
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
if not paths_payload:
|
|
894
|
+
return {
|
|
895
|
+
"error": (
|
|
896
|
+
f"No join path found between {from_table!r} and {to_table!r} "
|
|
897
|
+
f"within {max_depth} hops that respects visibility rules. "
|
|
898
|
+
"Try increasing max_depth or adjust visibility.yml."
|
|
899
|
+
),
|
|
900
|
+
"from": from_table,
|
|
901
|
+
"to": to_table,
|
|
902
|
+
"database": database,
|
|
903
|
+
}
|
|
904
|
+
# Only include ``tie_break`` when a tie-break actually ran. Always
|
|
905
|
+
# emitting ``"tie_break": None`` would change the response shape
|
|
906
|
+
# for every caller of ``traverse`` — backward-incompatible. Existing
|
|
907
|
+
# tests on the null path assert the key is absent; this preserves
|
|
908
|
+
# that contract.
|
|
909
|
+
out: dict[str, Any] = {
|
|
910
|
+
"from": from_table,
|
|
911
|
+
"to": to_table,
|
|
912
|
+
"database": database,
|
|
913
|
+
"paths": paths_payload,
|
|
914
|
+
"used_precomputed": not used_fallback,
|
|
915
|
+
"warning": (
|
|
916
|
+
"Path computed on demand; re-run index to refresh precomputed join paths."
|
|
917
|
+
if used_fallback
|
|
918
|
+
else None
|
|
919
|
+
),
|
|
920
|
+
}
|
|
921
|
+
if tie_break_ran:
|
|
922
|
+
out["tie_break"] = "embedding"
|
|
923
|
+
return out
|
|
924
|
+
finally:
|
|
925
|
+
release_store(store)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
__all__ = ["create_tool", "traverse_payload"]
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def create_tool(graph_dir: Path) -> McpTool:
|
|
932
|
+
from ._timed import timed_tool
|
|
933
|
+
|
|
934
|
+
async def _handle(args: dict) -> dict:
|
|
935
|
+
from_t = str(args.get("from_table", "")).strip()
|
|
936
|
+
to_t = str(args.get("to_table", "")).strip()
|
|
937
|
+
db_t = str(args.get("database", "")).strip()
|
|
938
|
+
if not from_t or not to_t:
|
|
939
|
+
return {"error": "Missing `from_table` or `to_table`"}
|
|
940
|
+
if not db_t:
|
|
941
|
+
return {"error": "Missing `database`"}
|
|
942
|
+
to_db = args.get("to_database")
|
|
943
|
+
to_db_s = str(to_db).strip() if to_db is not None else None
|
|
944
|
+
if to_db_s == "":
|
|
945
|
+
to_db_s = None
|
|
946
|
+
max_depth = int(args.get("max_depth", 4))
|
|
947
|
+
top_k = max(1, min(10, int(args.get("top_k", 3))))
|
|
948
|
+
max_inf = max(0, min(8, int(args.get("max_inferred_hops", 2))))
|
|
949
|
+
raw_kinds = args.get("edge_types")
|
|
950
|
+
edge_types: tuple[str, ...] | None = None
|
|
951
|
+
if isinstance(raw_kinds, list):
|
|
952
|
+
cleaned = tuple(str(k).strip() for k in raw_kinds if isinstance(k, str))
|
|
953
|
+
if cleaned:
|
|
954
|
+
edge_types = cleaned
|
|
955
|
+
with timed_tool(
|
|
956
|
+
"traverse",
|
|
957
|
+
graph_dir,
|
|
958
|
+
database=db_t,
|
|
959
|
+
to_database=to_db_s,
|
|
960
|
+
max_depth=max_depth,
|
|
961
|
+
top_k=top_k,
|
|
962
|
+
):
|
|
963
|
+
return traverse_payload(
|
|
964
|
+
graph_dir,
|
|
965
|
+
from_table=from_t,
|
|
966
|
+
to_table=to_t,
|
|
967
|
+
database=db_t,
|
|
968
|
+
to_database=to_db_s,
|
|
969
|
+
max_depth=max_depth,
|
|
970
|
+
top_k=top_k,
|
|
971
|
+
edge_types=edge_types,
|
|
972
|
+
max_inferred_hops=max_inf,
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
return McpTool(
|
|
976
|
+
name="traverse",
|
|
977
|
+
description=(
|
|
978
|
+
"Find a join path between two physical tables (precomputed or on-demand), "
|
|
979
|
+
"with SQL JOIN hints."
|
|
980
|
+
),
|
|
981
|
+
input_schema={
|
|
982
|
+
"type": "object",
|
|
983
|
+
"properties": {
|
|
984
|
+
"from_table": {
|
|
985
|
+
"type": "string",
|
|
986
|
+
"description": "Source table (name or schema.table)",
|
|
987
|
+
},
|
|
988
|
+
"to_table": {
|
|
989
|
+
"type": "string",
|
|
990
|
+
"description": "Target table (name or schema.table)",
|
|
991
|
+
},
|
|
992
|
+
"database": {
|
|
993
|
+
"type": "string",
|
|
994
|
+
"description": "Connection name or logical database (required if multiple graphs)",
|
|
995
|
+
},
|
|
996
|
+
"max_depth": {
|
|
997
|
+
"type": "integer",
|
|
998
|
+
"default": 4,
|
|
999
|
+
"minimum": 1,
|
|
1000
|
+
"maximum": 8,
|
|
1001
|
+
"description": "Max hops",
|
|
1002
|
+
},
|
|
1003
|
+
"top_k": {
|
|
1004
|
+
"type": "integer",
|
|
1005
|
+
"default": 3,
|
|
1006
|
+
"minimum": 1,
|
|
1007
|
+
"maximum": 10,
|
|
1008
|
+
"description": "Yen's K — return up to this many ranked alternative paths",
|
|
1009
|
+
},
|
|
1010
|
+
"edge_types": {
|
|
1011
|
+
"type": "array",
|
|
1012
|
+
"items": {"type": "string", "enum": ["fk", "inferred"]},
|
|
1013
|
+
"description": "Restrict adjacency to these edge kinds (default: both)",
|
|
1014
|
+
},
|
|
1015
|
+
"max_inferred_hops": {
|
|
1016
|
+
"type": "integer",
|
|
1017
|
+
"default": 2,
|
|
1018
|
+
"minimum": 0,
|
|
1019
|
+
"maximum": 8,
|
|
1020
|
+
"description": "Cap on inferred-join hops per path (FK hops uncapped beyond max_depth)",
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
"required": ["from_table", "to_table", "database"],
|
|
1024
|
+
"additionalProperties": False,
|
|
1025
|
+
},
|
|
1026
|
+
handler=_handle,
|
|
1027
|
+
)
|