sqlserver-semantic-mcp 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. sqlserver_semantic_mcp/__init__.py +1 -0
  2. sqlserver_semantic_mcp/config.py +78 -0
  3. sqlserver_semantic_mcp/domain/__init__.py +0 -0
  4. sqlserver_semantic_mcp/domain/enums.py +48 -0
  5. sqlserver_semantic_mcp/domain/models/__init__.py +0 -0
  6. sqlserver_semantic_mcp/domain/models/column.py +14 -0
  7. sqlserver_semantic_mcp/domain/models/object.py +13 -0
  8. sqlserver_semantic_mcp/domain/models/relationship.py +11 -0
  9. sqlserver_semantic_mcp/domain/models/table.py +29 -0
  10. sqlserver_semantic_mcp/infrastructure/__init__.py +0 -0
  11. sqlserver_semantic_mcp/infrastructure/background.py +59 -0
  12. sqlserver_semantic_mcp/infrastructure/cache/__init__.py +0 -0
  13. sqlserver_semantic_mcp/infrastructure/cache/semantic.py +132 -0
  14. sqlserver_semantic_mcp/infrastructure/cache/store.py +152 -0
  15. sqlserver_semantic_mcp/infrastructure/cache/structural.py +203 -0
  16. sqlserver_semantic_mcp/infrastructure/connection.py +78 -0
  17. sqlserver_semantic_mcp/infrastructure/queries/__init__.py +0 -0
  18. sqlserver_semantic_mcp/infrastructure/queries/comment_queries.py +18 -0
  19. sqlserver_semantic_mcp/infrastructure/queries/metadata_queries.py +70 -0
  20. sqlserver_semantic_mcp/infrastructure/queries/object_queries.py +15 -0
  21. sqlserver_semantic_mcp/main.py +90 -0
  22. sqlserver_semantic_mcp/policy/__init__.py +0 -0
  23. sqlserver_semantic_mcp/policy/analyzer.py +194 -0
  24. sqlserver_semantic_mcp/policy/enforcer.py +104 -0
  25. sqlserver_semantic_mcp/policy/intents/__init__.py +16 -0
  26. sqlserver_semantic_mcp/policy/intents/ast_analyzer.py +24 -0
  27. sqlserver_semantic_mcp/policy/intents/base.py +17 -0
  28. sqlserver_semantic_mcp/policy/intents/regex_analyzer.py +11 -0
  29. sqlserver_semantic_mcp/policy/intents/router.py +21 -0
  30. sqlserver_semantic_mcp/policy/loader.py +90 -0
  31. sqlserver_semantic_mcp/policy/models.py +43 -0
  32. sqlserver_semantic_mcp/server/__init__.py +0 -0
  33. sqlserver_semantic_mcp/server/app.py +125 -0
  34. sqlserver_semantic_mcp/server/compact.py +74 -0
  35. sqlserver_semantic_mcp/server/prompts/__init__.py +5 -0
  36. sqlserver_semantic_mcp/server/prompts/analysis.py +56 -0
  37. sqlserver_semantic_mcp/server/prompts/discovery.py +55 -0
  38. sqlserver_semantic_mcp/server/prompts/execution.py +64 -0
  39. sqlserver_semantic_mcp/server/prompts/registry.py +41 -0
  40. sqlserver_semantic_mcp/server/resources/__init__.py +1 -0
  41. sqlserver_semantic_mcp/server/resources/schema.py +144 -0
  42. sqlserver_semantic_mcp/server/tools/__init__.py +42 -0
  43. sqlserver_semantic_mcp/server/tools/cache.py +24 -0
  44. sqlserver_semantic_mcp/server/tools/metadata.py +167 -0
  45. sqlserver_semantic_mcp/server/tools/metrics.py +44 -0
  46. sqlserver_semantic_mcp/server/tools/object_tool.py +113 -0
  47. sqlserver_semantic_mcp/server/tools/policy.py +48 -0
  48. sqlserver_semantic_mcp/server/tools/query.py +159 -0
  49. sqlserver_semantic_mcp/server/tools/relationship.py +104 -0
  50. sqlserver_semantic_mcp/server/tools/semantic.py +112 -0
  51. sqlserver_semantic_mcp/server/tools/shape.py +204 -0
  52. sqlserver_semantic_mcp/server/tools/workflow.py +307 -0
  53. sqlserver_semantic_mcp/services/__init__.py +0 -0
  54. sqlserver_semantic_mcp/services/metadata_service.py +173 -0
  55. sqlserver_semantic_mcp/services/metrics_service.py +124 -0
  56. sqlserver_semantic_mcp/services/object_service.py +187 -0
  57. sqlserver_semantic_mcp/services/policy_service.py +59 -0
  58. sqlserver_semantic_mcp/services/query_service.py +321 -0
  59. sqlserver_semantic_mcp/services/relationship_service.py +160 -0
  60. sqlserver_semantic_mcp/services/semantic_service.py +277 -0
  61. sqlserver_semantic_mcp/workflows/__init__.py +26 -0
  62. sqlserver_semantic_mcp/workflows/bundle.py +157 -0
  63. sqlserver_semantic_mcp/workflows/contracts.py +64 -0
  64. sqlserver_semantic_mcp/workflows/discovery_flow.py +116 -0
  65. sqlserver_semantic_mcp/workflows/facade.py +117 -0
  66. sqlserver_semantic_mcp/workflows/query_flow.py +120 -0
  67. sqlserver_semantic_mcp/workflows/recommendations.py +161 -0
  68. sqlserver_semantic_mcp/workflows/router.py +59 -0
  69. sqlserver_semantic_mcp-0.5.0.dist-info/METADATA +679 -0
  70. sqlserver_semantic_mcp-0.5.0.dist-info/RECORD +74 -0
  71. sqlserver_semantic_mcp-0.5.0.dist-info/WHEEL +5 -0
  72. sqlserver_semantic_mcp-0.5.0.dist-info/entry_points.txt +2 -0
  73. sqlserver_semantic_mcp-0.5.0.dist-info/licenses/LICENSE +21 -0
  74. sqlserver_semantic_mcp-0.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,116 @@
1
+ """Discovery path — narrows the candidate set before explore/describe."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from ..config import Config, get_config
7
+ from ..services import metadata_service, semantic_service
8
+ from .contracts import ToolEnvelope
9
+
10
+
11
+ _STOPWORDS = {
12
+ "the", "a", "an", "of", "to", "and", "or", "for", "on", "in", "by",
13
+ "with", "is", "are", "what", "which", "how", "show", "list", "me",
14
+ "my", "our", "their", "please", "need", "want", "find",
15
+ }
16
+
17
+
18
+ def _tokenize(goal: str) -> list[str]:
19
+ if not goal:
20
+ return []
21
+ tokens = [t.lower().strip(".,;:!?()[]{}\"'`") for t in goal.split()]
22
+ return [t for t in tokens if t and t not in _STOPWORDS and len(t) > 1]
23
+
24
+
25
+ def _score(table: dict, tokens: list[str]) -> tuple[float, list[str]]:
26
+ """Return (score, reasons) for a candidate table."""
27
+ name = f"{table['schema_name']}.{table['table_name']}".lower()
28
+ bare = table["table_name"].lower()
29
+ reasons: list[str] = []
30
+ score = 0.0
31
+
32
+ for tok in tokens:
33
+ if tok == bare:
34
+ score += 0.6
35
+ reasons.append(f"exact table match: {tok}")
36
+ elif tok in bare:
37
+ score += 0.35
38
+ reasons.append(f"table name contains '{tok}'")
39
+ elif tok in name:
40
+ score += 0.2
41
+ reasons.append(f"qualified name contains '{tok}'")
42
+
43
+ return min(score, 1.0), reasons
44
+
45
+
46
+ async def discover_relevant_tables(
47
+ goal: str,
48
+ *,
49
+ schemas: Optional[list[str]] = None,
50
+ keyword: Optional[str] = None,
51
+ limit: int = 10,
52
+ classify: bool = False,
53
+ cfg: Optional[Config] = None,
54
+ ) -> dict:
55
+ """Return a small ranked candidate set for a natural-language ask.
56
+
57
+ The server intentionally stays dumb (keyword scoring only) so the
58
+ response is cheap. Agents can follow up with ``describe_table`` or
59
+ ``classify_table`` for the short list.
60
+ """
61
+ cfg = cfg or get_config()
62
+ db_path = cfg.cache_path
63
+ database = cfg.mssql_database
64
+
65
+ tokens = _tokenize(goal)
66
+ if keyword and keyword.lower() not in tokens:
67
+ tokens.append(keyword.lower())
68
+
69
+ tables = await metadata_service.list_tables(
70
+ db_path, database,
71
+ schemas=schemas, keyword=keyword if keyword else None,
72
+ )
73
+
74
+ scored: list[dict] = []
75
+ for t in tables:
76
+ score, reasons = _score(t, tokens)
77
+ if score <= 0 and not keyword:
78
+ continue
79
+ scored.append({
80
+ "table": f"{t['schema_name']}.{t['table_name']}",
81
+ "schema": t["schema_name"],
82
+ "name": t["table_name"],
83
+ "score": round(score, 3),
84
+ "why": reasons,
85
+ })
86
+
87
+ scored.sort(key=lambda x: x["score"], reverse=True)
88
+ top = scored[:limit] if limit else scored
89
+
90
+ if classify:
91
+ for row in top:
92
+ cls = await semantic_service.classify_table(
93
+ db_path, database, row["schema"], row["name"],
94
+ )
95
+ row["classification"] = cls.get("type")
96
+ row["classification_confidence"] = cls.get("confidence")
97
+
98
+ return ToolEnvelope(
99
+ kind="discover_relevant_tables",
100
+ detail="brief",
101
+ next_action=(
102
+ "describe_table" if top else "broaden_search"
103
+ ),
104
+ recommended_tool=(
105
+ "describe_table" if top else "get_tables"
106
+ ),
107
+ data={
108
+ "goal": goal,
109
+ "token_hits": tokens,
110
+ "total_scanned": len(tables),
111
+ "candidates": [
112
+ {k: v for k, v in row.items() if k not in ("schema", "name")}
113
+ for row in top
114
+ ],
115
+ },
116
+ ).to_dict()
@@ -0,0 +1,117 @@
1
+ """Single workflow entry point exposed to ``server.app.Context``."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from ..config import Config, get_config
7
+ from ..services.policy_service import PolicyService
8
+ from ..services.query_service import QueryService
9
+ from .bundle import bundle_context_for_next_step
10
+ from .discovery_flow import discover_relevant_tables
11
+ from .query_flow import plan_or_execute_query
12
+ from .recommendations import estimate_execution_risk, suggest_next_tool
13
+ from .router import route_query
14
+
15
+
16
+ class WorkflowFacade:
17
+ """Thin wrapper so tool modules reach workflow helpers via one object."""
18
+
19
+ def __init__(
20
+ self,
21
+ cfg: Config,
22
+ policy: PolicyService,
23
+ query: QueryService,
24
+ ) -> None:
25
+ self.cfg = cfg
26
+ self.policy = policy
27
+ self.query = query
28
+
29
+ # ---- synchronous helpers ------------------------------------------------
30
+
31
+ def route_query(self, query: Optional[str]) -> dict:
32
+ return route_query(
33
+ query, policy=self.policy, database=self.cfg.mssql_database,
34
+ ).to_dict()
35
+
36
+ def plan_or_execute_query(
37
+ self,
38
+ query: str,
39
+ *,
40
+ mode: str = "auto",
41
+ max_rows: Optional[int] = None,
42
+ return_mode: Optional[str] = None,
43
+ detail: str = "brief",
44
+ token_budget_hint: Optional[str] = None,
45
+ affected_rows_policy: Optional[str] = None,
46
+ ) -> dict:
47
+ return plan_or_execute_query(
48
+ query,
49
+ policy=self.policy,
50
+ query_service=self.query,
51
+ mode=mode,
52
+ max_rows=max_rows,
53
+ return_mode=return_mode,
54
+ detail=detail,
55
+ token_budget_hint=token_budget_hint,
56
+ affected_rows_policy=affected_rows_policy,
57
+ cfg=self.cfg,
58
+ )
59
+
60
+ def preview_safe_query(
61
+ self,
62
+ query: str,
63
+ *,
64
+ max_rows: Optional[int] = None,
65
+ ) -> dict:
66
+ preview = self.query.preview_query(
67
+ query, max_rows=max_rows, database=self.cfg.mssql_database,
68
+ )
69
+ return {
70
+ "kind": "preview_safe_query",
71
+ "detail": "brief",
72
+ "next_action": preview["next_action"],
73
+ "recommended_tool": (
74
+ "plan_or_execute_query" if preview["allowed"]
75
+ else "validate_query"
76
+ ),
77
+ "data": preview,
78
+ }
79
+
80
+ def suggest_next_tool(self, **kwargs) -> dict:
81
+ return suggest_next_tool(policy=self.policy, cfg=self.cfg, **kwargs)
82
+
83
+ def estimate_execution_risk(self, query: str) -> dict:
84
+ return estimate_execution_risk(
85
+ query, policy=self.policy, cfg=self.cfg,
86
+ )
87
+
88
+ # ---- async helpers ------------------------------------------------------
89
+
90
+ async def discover_relevant_tables(
91
+ self,
92
+ goal: str,
93
+ *,
94
+ schemas: Optional[list[str]] = None,
95
+ keyword: Optional[str] = None,
96
+ limit: int = 10,
97
+ classify: bool = False,
98
+ ) -> dict:
99
+ return await discover_relevant_tables(
100
+ goal,
101
+ schemas=schemas,
102
+ keyword=keyword,
103
+ limit=limit,
104
+ classify=classify,
105
+ cfg=self.cfg,
106
+ )
107
+
108
+ async def bundle_context_for_next_step(
109
+ self,
110
+ items: list[dict],
111
+ *,
112
+ goal: str = "joining",
113
+ detail: str = "brief",
114
+ ) -> dict:
115
+ return await bundle_context_for_next_step(
116
+ items, goal=goal, detail=detail, cfg=self.cfg,
117
+ )
@@ -0,0 +1,120 @@
1
+ """Direct-execution fast path for SQL-ready agents."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from ..config import Config, get_config
7
+ from ..services.policy_service import PolicyService
8
+ from ..services.query_service import QueryService
9
+ from .contracts import ToolEnvelope
10
+ from .router import route_query
11
+
12
+
13
+ def plan_or_execute_query(
14
+ query: str,
15
+ *,
16
+ policy: PolicyService,
17
+ query_service: QueryService,
18
+ mode: str = "auto",
19
+ max_rows: Optional[int] = None,
20
+ return_mode: Optional[str] = None,
21
+ detail: str = "brief",
22
+ token_budget_hint: Optional[str] = None,
23
+ affected_rows_policy: Optional[str] = None,
24
+ cfg: Optional[Config] = None,
25
+ ) -> dict:
26
+ """Single entry point for agents holding ready-to-run SQL.
27
+
28
+ mode:
29
+ * ``auto`` — execute if safe, otherwise return plan
30
+ * ``validate_only`` — validate and stop
31
+ * ``dry_run`` — return preview (validation + shape, no side effects)
32
+ * ``execute_if_safe``— same as ``auto`` (kept as alias for clarity)
33
+ """
34
+ cfg = cfg or get_config()
35
+ database = cfg.mssql_database
36
+
37
+ # Explicit sub-modes short-circuit routing.
38
+ if mode == "validate_only":
39
+ payload = query_service.validate_query(query, database=database)
40
+ return ToolEnvelope(
41
+ kind="plan_or_execute_query",
42
+ detail=detail,
43
+ confidence=payload["intent"]["confidence"],
44
+ next_action=payload["next_action"],
45
+ recommended_tool=(
46
+ "plan_or_execute_query" if payload["allowed"] else "validate_query"
47
+ ),
48
+ data={"path": "direct_validate", "executed": False, **payload},
49
+ ).to_dict()
50
+
51
+ if mode == "dry_run":
52
+ preview = query_service.preview_query(
53
+ query, max_rows=max_rows, database=database,
54
+ )
55
+ return ToolEnvelope(
56
+ kind="plan_or_execute_query",
57
+ detail=detail,
58
+ next_action=preview["next_action"],
59
+ recommended_tool=(
60
+ "plan_or_execute_query" if preview["allowed"] else "validate_query"
61
+ ),
62
+ data={"path": "dry_run", "executed": False, **preview},
63
+ ).to_dict()
64
+
65
+ decision = route_query(query, policy=policy, database=database)
66
+
67
+ if decision.route == "direct_execute" and cfg.direct_execute_enabled:
68
+ result = query_service.execute_query(
69
+ query,
70
+ max_rows=max_rows,
71
+ response_mode=return_mode,
72
+ token_budget_hint=token_budget_hint,
73
+ affected_rows_policy=affected_rows_policy,
74
+ database=database,
75
+ )
76
+ return ToolEnvelope(
77
+ kind="plan_or_execute_query",
78
+ detail=detail,
79
+ confidence=decision.confidence,
80
+ next_action=result.get("next_action", "done"),
81
+ recommended_tool=None,
82
+ data={
83
+ "path": "direct_execute",
84
+ **result,
85
+ "route": decision.to_dict(),
86
+ },
87
+ ).to_dict()
88
+
89
+ if decision.route == "direct_validate":
90
+ # Policy denied — don't execute even under mode=auto.
91
+ payload = query_service.validate_query(query, database=database)
92
+ return ToolEnvelope(
93
+ kind="plan_or_execute_query",
94
+ detail=detail,
95
+ confidence=decision.confidence,
96
+ next_action=payload["next_action"],
97
+ recommended_tool="validate_query",
98
+ data={
99
+ "path": "direct_validate",
100
+ "executed": False,
101
+ **payload,
102
+ "route": decision.to_dict(),
103
+ },
104
+ ).to_dict()
105
+
106
+ # discovery / policy_only
107
+ return ToolEnvelope(
108
+ kind="plan_or_execute_query",
109
+ detail=detail,
110
+ confidence=decision.confidence,
111
+ next_action="discover",
112
+ recommended_tool=decision.recommended_tools[0]
113
+ if decision.recommended_tools else "discover_relevant_tables",
114
+ data={
115
+ "path": decision.route,
116
+ "executed": False,
117
+ "reason": decision.reason,
118
+ "route": decision.to_dict(),
119
+ },
120
+ ).to_dict()
@@ -0,0 +1,161 @@
1
+ """Recommendation + risk-estimation helpers."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from ..config import Config, get_config
7
+ from ..services.policy_service import PolicyService
8
+ from .contracts import ToolEnvelope
9
+ from .router import route_query
10
+
11
+
12
+ def suggest_next_tool(
13
+ *,
14
+ policy: PolicyService,
15
+ cfg: Optional[Config] = None,
16
+ query: Optional[str] = None,
17
+ goal: Optional[str] = None,
18
+ have_candidates: bool = False,
19
+ have_join_path: bool = False,
20
+ have_object: Optional[str] = None,
21
+ ) -> dict:
22
+ """Look at the agent's current state and recommend the next call.
23
+
24
+ This does not invoke any DB tools — it only applies routing logic.
25
+ """
26
+ cfg = cfg or get_config()
27
+ rationale: list[str] = []
28
+
29
+ if query:
30
+ decision = route_query(query, policy=policy, database=cfg.mssql_database)
31
+ rationale.append(
32
+ f"query routed to '{decision.route}' ({decision.reason})"
33
+ )
34
+ return ToolEnvelope(
35
+ kind="suggest_next_tool",
36
+ detail="brief",
37
+ confidence=decision.confidence,
38
+ next_action=decision.route,
39
+ recommended_tool=(decision.recommended_tools[0]
40
+ if decision.recommended_tools else None),
41
+ data={
42
+ "recommended_tools": list(decision.recommended_tools),
43
+ "route": decision.to_dict(),
44
+ "rationale": rationale,
45
+ },
46
+ ).to_dict()
47
+
48
+ recommended: list[str] = []
49
+ next_action: str
50
+ if have_object:
51
+ recommended = ["trace_object_dependencies", "bundle_context_for_next_step"]
52
+ next_action = "trace_impact"
53
+ rationale.append("object context available — trace its dependencies")
54
+ elif have_join_path:
55
+ recommended = ["plan_or_execute_query", "preview_safe_query"]
56
+ next_action = "execute"
57
+ rationale.append("join path ready — draft SQL and execute via fast path")
58
+ elif have_candidates:
59
+ recommended = ["describe_table", "find_join_path", "score_join_candidate"]
60
+ next_action = "inspect_or_join"
61
+ rationale.append("candidates narrowed — inspect and compute join path")
62
+ elif goal:
63
+ recommended = ["discover_relevant_tables", "get_tables"]
64
+ next_action = "discover"
65
+ rationale.append("no candidates yet — start from discovery")
66
+ else:
67
+ recommended = ["get_tables", "get_execution_policy"]
68
+ next_action = "orient"
69
+ rationale.append("no query, goal, or candidates — orient first")
70
+
71
+ return ToolEnvelope(
72
+ kind="suggest_next_tool",
73
+ detail="brief",
74
+ next_action=next_action,
75
+ recommended_tool=recommended[0] if recommended else None,
76
+ data={
77
+ "recommended_tools": recommended,
78
+ "rationale": rationale,
79
+ },
80
+ ).to_dict()
81
+
82
+
83
+ def estimate_execution_risk(
84
+ query: str,
85
+ *,
86
+ policy: PolicyService,
87
+ cfg: Optional[Config] = None,
88
+ ) -> dict:
89
+ cfg = cfg or get_config()
90
+ intent = policy.analyze(query)
91
+ constraints = policy.current_policy().constraints
92
+
93
+ risks: list[dict] = []
94
+ level = "low"
95
+
96
+ def bump(new_level: str) -> None:
97
+ nonlocal level
98
+ order = {"low": 0, "medium": 1, "high": 2, "critical": 3}
99
+ if order[new_level] > order[level]:
100
+ level = new_level
101
+
102
+ if intent.risk_level.value in ("critical", "high"):
103
+ bump(intent.risk_level.value)
104
+ risks.append({
105
+ "kind": "policy_risk",
106
+ "detail": f"operation {intent.primary_operation.value} is "
107
+ f"{intent.risk_level.value}-risk",
108
+ })
109
+
110
+ if intent.is_multi_statement and not constraints.allow_multi_statement:
111
+ bump("high")
112
+ risks.append({
113
+ "kind": "policy_risk",
114
+ "detail": "multi-statement query is disallowed",
115
+ })
116
+
117
+ if intent.has_unqualified_tables:
118
+ bump("medium")
119
+ risks.append({
120
+ "kind": "schema_qualification_risk",
121
+ "detail": "query references unqualified tables",
122
+ })
123
+
124
+ if intent.contains_dynamic_sql:
125
+ bump("high")
126
+ risks.append({
127
+ "kind": "dynamic_sql_risk",
128
+ "detail": "query executes dynamic SQL; analyzer cannot inspect it",
129
+ })
130
+
131
+ if intent.primary_operation.value == "SELECT" \
132
+ and not intent.has_top_clause \
133
+ and not intent.has_where_clause:
134
+ bump("medium")
135
+ risks.append({
136
+ "kind": "payload_risk",
137
+ "detail": "SELECT without TOP or WHERE may return large payloads",
138
+ })
139
+
140
+ validation = policy.validate(query, database=cfg.mssql_database)
141
+ allowed = validation["allowed"]
142
+
143
+ return ToolEnvelope(
144
+ kind="estimate_execution_risk",
145
+ detail="brief",
146
+ confidence=intent.confidence,
147
+ next_action="execute" if allowed and level in ("low", "medium") else "revise_query",
148
+ recommended_tool=(
149
+ "plan_or_execute_query" if allowed else "validate_query"
150
+ ),
151
+ data={
152
+ "operation": intent.primary_operation.value,
153
+ "tables": intent.affected_tables,
154
+ "risk_level": level,
155
+ "risks": risks,
156
+ "allowed_by_policy": allowed,
157
+ "policy_reason": validation["reason"],
158
+ "max_rows_returned": constraints.max_rows_returned,
159
+ "max_rows_affected": constraints.max_rows_affected,
160
+ },
161
+ ).to_dict()
@@ -0,0 +1,59 @@
1
+ """Route the agent's request down the shortest safe path."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from ..policy.analyzer import SqlIntent
7
+ from ..services.policy_service import PolicyService
8
+ from .contracts import RouteDecision
9
+
10
+
11
+ def route_query(
12
+ query: Optional[str],
13
+ *,
14
+ policy: PolicyService,
15
+ database: str = "",
16
+ ) -> RouteDecision:
17
+ """Decide which path a ``query`` argument belongs to.
18
+
19
+ * ``direct_execute`` — SQL-ready and currently allowed by policy
20
+ * ``direct_validate`` — SQL-ready but policy denies → agent should revise
21
+ * ``discovery`` — natural-language / unparseable → agent must explore
22
+ * ``object_analysis`` — identified procedure/view reference (future hook)
23
+ * ``policy_only`` — empty input / explicit policy inspection
24
+ """
25
+ if not query or not query.strip():
26
+ return RouteDecision(
27
+ route="policy_only",
28
+ reason="empty query; nothing to execute or validate",
29
+ recommended_tools=["get_execution_policy"],
30
+ confidence=1.0,
31
+ )
32
+
33
+ intent: SqlIntent = policy.analyze(query)
34
+ if not intent.is_sql_like or intent.requires_discovery:
35
+ return RouteDecision(
36
+ route="discovery",
37
+ reason="input does not look like executable SQL",
38
+ recommended_tools=[
39
+ "discover_relevant_tables",
40
+ "describe_table",
41
+ "find_join_path",
42
+ ],
43
+ confidence=max(intent.confidence, 0.4),
44
+ )
45
+
46
+ validation = policy.validate(query, database=database)
47
+ if validation["allowed"]:
48
+ return RouteDecision(
49
+ route="direct_execute",
50
+ reason="policy allows direct execution",
51
+ recommended_tools=["plan_or_execute_query", "run_safe_query"],
52
+ confidence=intent.confidence,
53
+ )
54
+ return RouteDecision(
55
+ route="direct_validate",
56
+ reason=validation["reason"],
57
+ recommended_tools=["validate_query", "estimate_execution_risk"],
58
+ confidence=intent.confidence,
59
+ )