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.
- sqlserver_semantic_mcp/__init__.py +1 -0
- sqlserver_semantic_mcp/config.py +78 -0
- sqlserver_semantic_mcp/domain/__init__.py +0 -0
- sqlserver_semantic_mcp/domain/enums.py +48 -0
- sqlserver_semantic_mcp/domain/models/__init__.py +0 -0
- sqlserver_semantic_mcp/domain/models/column.py +14 -0
- sqlserver_semantic_mcp/domain/models/object.py +13 -0
- sqlserver_semantic_mcp/domain/models/relationship.py +11 -0
- sqlserver_semantic_mcp/domain/models/table.py +29 -0
- sqlserver_semantic_mcp/infrastructure/__init__.py +0 -0
- sqlserver_semantic_mcp/infrastructure/background.py +59 -0
- sqlserver_semantic_mcp/infrastructure/cache/__init__.py +0 -0
- sqlserver_semantic_mcp/infrastructure/cache/semantic.py +132 -0
- sqlserver_semantic_mcp/infrastructure/cache/store.py +152 -0
- sqlserver_semantic_mcp/infrastructure/cache/structural.py +203 -0
- sqlserver_semantic_mcp/infrastructure/connection.py +78 -0
- sqlserver_semantic_mcp/infrastructure/queries/__init__.py +0 -0
- sqlserver_semantic_mcp/infrastructure/queries/comment_queries.py +18 -0
- sqlserver_semantic_mcp/infrastructure/queries/metadata_queries.py +70 -0
- sqlserver_semantic_mcp/infrastructure/queries/object_queries.py +15 -0
- sqlserver_semantic_mcp/main.py +90 -0
- sqlserver_semantic_mcp/policy/__init__.py +0 -0
- sqlserver_semantic_mcp/policy/analyzer.py +194 -0
- sqlserver_semantic_mcp/policy/enforcer.py +104 -0
- sqlserver_semantic_mcp/policy/intents/__init__.py +16 -0
- sqlserver_semantic_mcp/policy/intents/ast_analyzer.py +24 -0
- sqlserver_semantic_mcp/policy/intents/base.py +17 -0
- sqlserver_semantic_mcp/policy/intents/regex_analyzer.py +11 -0
- sqlserver_semantic_mcp/policy/intents/router.py +21 -0
- sqlserver_semantic_mcp/policy/loader.py +90 -0
- sqlserver_semantic_mcp/policy/models.py +43 -0
- sqlserver_semantic_mcp/server/__init__.py +0 -0
- sqlserver_semantic_mcp/server/app.py +125 -0
- sqlserver_semantic_mcp/server/compact.py +74 -0
- sqlserver_semantic_mcp/server/prompts/__init__.py +5 -0
- sqlserver_semantic_mcp/server/prompts/analysis.py +56 -0
- sqlserver_semantic_mcp/server/prompts/discovery.py +55 -0
- sqlserver_semantic_mcp/server/prompts/execution.py +64 -0
- sqlserver_semantic_mcp/server/prompts/registry.py +41 -0
- sqlserver_semantic_mcp/server/resources/__init__.py +1 -0
- sqlserver_semantic_mcp/server/resources/schema.py +144 -0
- sqlserver_semantic_mcp/server/tools/__init__.py +42 -0
- sqlserver_semantic_mcp/server/tools/cache.py +24 -0
- sqlserver_semantic_mcp/server/tools/metadata.py +167 -0
- sqlserver_semantic_mcp/server/tools/metrics.py +44 -0
- sqlserver_semantic_mcp/server/tools/object_tool.py +113 -0
- sqlserver_semantic_mcp/server/tools/policy.py +48 -0
- sqlserver_semantic_mcp/server/tools/query.py +159 -0
- sqlserver_semantic_mcp/server/tools/relationship.py +104 -0
- sqlserver_semantic_mcp/server/tools/semantic.py +112 -0
- sqlserver_semantic_mcp/server/tools/shape.py +204 -0
- sqlserver_semantic_mcp/server/tools/workflow.py +307 -0
- sqlserver_semantic_mcp/services/__init__.py +0 -0
- sqlserver_semantic_mcp/services/metadata_service.py +173 -0
- sqlserver_semantic_mcp/services/metrics_service.py +124 -0
- sqlserver_semantic_mcp/services/object_service.py +187 -0
- sqlserver_semantic_mcp/services/policy_service.py +59 -0
- sqlserver_semantic_mcp/services/query_service.py +321 -0
- sqlserver_semantic_mcp/services/relationship_service.py +160 -0
- sqlserver_semantic_mcp/services/semantic_service.py +277 -0
- sqlserver_semantic_mcp/workflows/__init__.py +26 -0
- sqlserver_semantic_mcp/workflows/bundle.py +157 -0
- sqlserver_semantic_mcp/workflows/contracts.py +64 -0
- sqlserver_semantic_mcp/workflows/discovery_flow.py +116 -0
- sqlserver_semantic_mcp/workflows/facade.py +117 -0
- sqlserver_semantic_mcp/workflows/query_flow.py +120 -0
- sqlserver_semantic_mcp/workflows/recommendations.py +161 -0
- sqlserver_semantic_mcp/workflows/router.py +59 -0
- sqlserver_semantic_mcp-0.5.0.dist-info/METADATA +679 -0
- sqlserver_semantic_mcp-0.5.0.dist-info/RECORD +74 -0
- sqlserver_semantic_mcp-0.5.0.dist-info/WHEEL +5 -0
- sqlserver_semantic_mcp-0.5.0.dist-info/entry_points.txt +2 -0
- sqlserver_semantic_mcp-0.5.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|