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,48 @@
1
+ from mcp.types import Tool
2
+
3
+ from ..app import get_context, register_tool
4
+
5
+
6
+ def register() -> None:
7
+ register_tool(
8
+ Tool(
9
+ name="get_execution_policy",
10
+ description="Return the active execution policy.",
11
+ inputSchema={"type": "object", "properties": {}},
12
+ ),
13
+ _get_policy,
14
+ )
15
+ register_tool(
16
+ Tool(
17
+ name="validate_sql_against_policy",
18
+ description="Validate SQL against active policy WITHOUT executing.",
19
+ inputSchema={
20
+ "type": "object",
21
+ "properties": {"query": {"type": "string"}},
22
+ "required": ["query"],
23
+ },
24
+ ),
25
+ _validate_sql,
26
+ )
27
+ register_tool(
28
+ Tool(
29
+ name="refresh_policy",
30
+ description="Reload policy file from disk.",
31
+ inputSchema={"type": "object", "properties": {}},
32
+ ),
33
+ _refresh,
34
+ )
35
+
36
+
37
+ async def _get_policy(args: dict) -> dict:
38
+ return get_context().policy.current_policy().model_dump()
39
+
40
+
41
+ async def _validate_sql(args: dict) -> dict:
42
+ return get_context().policy.validate(args["query"])
43
+
44
+
45
+ async def _refresh(args: dict) -> dict:
46
+ ctx = get_context()
47
+ ctx.policy.reload()
48
+ return {"reloaded": True, "profile": ctx.policy.current_policy().profile_name}
@@ -0,0 +1,159 @@
1
+ from mcp.types import Tool
2
+
3
+ from ..app import get_context, register_tool
4
+
5
+
6
+ _DETAIL_PROP = {
7
+ "type": "string", "enum": ["brief", "standard", "full"], "default": "brief",
8
+ }
9
+ _BUDGET_PROP = {
10
+ "type": "string", "enum": ["tiny", "low", "medium", "high"],
11
+ }
12
+ _RESPONSE_MODE_PROP = {
13
+ "type": "string", "enum": ["summary", "rows", "sample", "count_only"],
14
+ "description": "summary=columns+count; rows=full page; "
15
+ "sample=columns+first N; count_only=row_count only.",
16
+ }
17
+ _AFFECTED_POLICY_PROP = {
18
+ "type": "string", "enum": ["strict", "report"],
19
+ "description": "strict = roll back if affected rows exceed cap; "
20
+ "report = execute and report exceeded_cap.",
21
+ }
22
+
23
+
24
+ def register() -> None:
25
+ register_tool(
26
+ Tool(
27
+ name="validate_query",
28
+ description=(
29
+ "Analyze a SQL query and report intent + whether policy allows "
30
+ "it. Use this when you want to test a query without executing."
31
+ ),
32
+ inputSchema={
33
+ "type": "object",
34
+ "properties": {"query": {"type": "string"}},
35
+ "required": ["query"],
36
+ },
37
+ ),
38
+ _validate,
39
+ )
40
+ register_tool(
41
+ Tool(
42
+ name="run_safe_query",
43
+ description=(
44
+ "Execute SQL after policy validation. Result rows are truncated "
45
+ "to max_rows_returned. Prefer plan_or_execute_query for the "
46
+ "shortest safe path when SQL is already known."
47
+ ),
48
+ inputSchema={
49
+ "type": "object",
50
+ "properties": {
51
+ "query": {"type": "string"},
52
+ "max_rows": {"type": "integer", "minimum": 1},
53
+ },
54
+ "required": ["query"],
55
+ },
56
+ ),
57
+ _run_safe,
58
+ )
59
+ register_tool(
60
+ Tool(
61
+ name="plan_or_execute_query",
62
+ description=(
63
+ "v0.5 main entry for SQL-ready agents. mode=auto validates then "
64
+ "executes if safe; mode=validate_only stops after validation; "
65
+ "mode=dry_run returns preview without side effects. Do not use "
66
+ "this for schema discovery — use discover_relevant_tables first "
67
+ "when the target tables are unknown."
68
+ ),
69
+ inputSchema={
70
+ "type": "object",
71
+ "properties": {
72
+ "query": _required_query(),
73
+ "mode": {
74
+ "type": "string",
75
+ "enum": ["auto", "validate_only", "dry_run",
76
+ "execute_if_safe"],
77
+ "default": "auto",
78
+ },
79
+ "max_rows": {"type": "integer", "minimum": 1},
80
+ "return_mode": _RESPONSE_MODE_PROP,
81
+ "detail": _DETAIL_PROP,
82
+ "token_budget_hint": _BUDGET_PROP,
83
+ "affected_rows_policy": _AFFECTED_POLICY_PROP,
84
+ },
85
+ "required": ["query"],
86
+ },
87
+ ),
88
+ _plan_or_execute,
89
+ )
90
+ register_tool(
91
+ Tool(
92
+ name="preview_safe_query",
93
+ description=(
94
+ "Return a minimal plan — operation, affected tables, policy "
95
+ "outcome, applied row caps — without executing."
96
+ ),
97
+ inputSchema={
98
+ "type": "object",
99
+ "properties": {
100
+ "query": _required_query(),
101
+ "max_rows": {"type": "integer", "minimum": 1},
102
+ },
103
+ "required": ["query"],
104
+ },
105
+ ),
106
+ _preview,
107
+ )
108
+ register_tool(
109
+ Tool(
110
+ name="estimate_execution_risk",
111
+ description=(
112
+ "Estimate payload / policy / qualification risks for a SQL "
113
+ "string without executing it."
114
+ ),
115
+ inputSchema={
116
+ "type": "object",
117
+ "properties": {"query": {"type": "string"}},
118
+ "required": ["query"],
119
+ },
120
+ ),
121
+ _estimate_risk,
122
+ )
123
+
124
+
125
+ def _required_query() -> dict:
126
+ return {"type": "string", "description": "SQL to execute / validate."}
127
+
128
+
129
+ async def _validate(args: dict) -> dict:
130
+ return get_context().query.validate(args["query"])
131
+
132
+
133
+ async def _run_safe(args: dict) -> dict:
134
+ return get_context().query.run_safe_query(
135
+ args["query"], max_rows=args.get("max_rows"),
136
+ )
137
+
138
+
139
+ async def _plan_or_execute(args: dict) -> dict:
140
+ ctx = get_context()
141
+ return ctx.workflow.plan_or_execute_query(
142
+ args["query"],
143
+ mode=args.get("mode", "auto"),
144
+ max_rows=args.get("max_rows"),
145
+ return_mode=args.get("return_mode"),
146
+ detail=args.get("detail", "brief"),
147
+ token_budget_hint=args.get("token_budget_hint"),
148
+ affected_rows_policy=args.get("affected_rows_policy"),
149
+ )
150
+
151
+
152
+ async def _preview(args: dict) -> dict:
153
+ return get_context().workflow.preview_safe_query(
154
+ args["query"], max_rows=args.get("max_rows"),
155
+ )
156
+
157
+
158
+ async def _estimate_risk(args: dict) -> dict:
159
+ return get_context().workflow.estimate_execution_risk(args["query"])
@@ -0,0 +1,104 @@
1
+ from mcp.types import Tool
2
+
3
+ from ...services import relationship_service
4
+ from ..app import get_context, register_tool
5
+
6
+
7
+ def register() -> None:
8
+ register_tool(
9
+ Tool(
10
+ name="get_table_relationships",
11
+ description="List inbound + outbound FK relationships for a table.",
12
+ inputSchema={
13
+ "type": "object",
14
+ "properties": {
15
+ "schema": {"type": "string"},
16
+ "table": {"type": "string"},
17
+ },
18
+ "required": ["schema", "table"],
19
+ },
20
+ ),
21
+ _rels,
22
+ )
23
+ register_tool(
24
+ Tool(
25
+ name="find_join_path",
26
+ description=(
27
+ "Find a shortest FK-based join path between two tables "
28
+ "(BFS, bidirectional edges). Use after candidate tables are "
29
+ "known. For ranking multiple reasonable paths, call "
30
+ "score_join_candidate next."
31
+ ),
32
+ inputSchema={
33
+ "type": "object",
34
+ "properties": {
35
+ "from_schema": {"type": "string"},
36
+ "from_table": {"type": "string"},
37
+ "to_schema": {"type": "string"},
38
+ "to_table": {"type": "string"},
39
+ "max_hops": {"type": "integer", "minimum": 1, "default": 5},
40
+ },
41
+ "required": ["from_schema", "from_table", "to_schema", "to_table"],
42
+ },
43
+ ),
44
+ _path,
45
+ )
46
+ register_tool(
47
+ Tool(
48
+ name="get_dependency_chain",
49
+ description=(
50
+ "List all tables reachable from a given table via FKs. "
51
+ "schemas param limits the BFS frontier to allowed schemas "
52
+ "(start table always included)."
53
+ ),
54
+ inputSchema={
55
+ "type": "object",
56
+ "properties": {
57
+ "schema": {"type": "string"},
58
+ "table": {"type": "string"},
59
+ "max_depth": {"type": "integer", "default": 10},
60
+ "schemas": {"oneOf": [{"type": "string"},
61
+ {"type": "array",
62
+ "items": {"type": "string"}}]},
63
+ },
64
+ "required": ["schema", "table"],
65
+ },
66
+ ),
67
+ _chain,
68
+ )
69
+
70
+
71
+ async def _rels(args: dict) -> list[dict]:
72
+ ctx = get_context()
73
+ return await relationship_service.get_table_relationships(
74
+ ctx.cfg.cache_path, ctx.cfg.mssql_database,
75
+ args["schema"], args["table"],
76
+ )
77
+
78
+
79
+ async def _path(args: dict) -> dict:
80
+ ctx = get_context()
81
+ path = await relationship_service.find_join_path(
82
+ ctx.cfg.cache_path, ctx.cfg.mssql_database,
83
+ args["from_schema"], args["from_table"],
84
+ args["to_schema"], args["to_table"],
85
+ max_hops=args.get("max_hops", 5),
86
+ )
87
+ return {"found": path is not None, "path": path or []}
88
+
89
+
90
+ async def _chain(args: dict) -> list[dict]:
91
+ ctx = get_context()
92
+ raw = args.get("schemas")
93
+ if isinstance(raw, str):
94
+ schemas = [raw] if raw else None
95
+ elif isinstance(raw, list):
96
+ schemas = [s for s in raw if isinstance(s, str) and s] or None
97
+ else:
98
+ schemas = None
99
+ return await relationship_service.get_dependency_chain(
100
+ ctx.cfg.cache_path, ctx.cfg.mssql_database,
101
+ args["schema"], args["table"],
102
+ max_depth=args.get("max_depth", 10),
103
+ schemas=schemas,
104
+ )
@@ -0,0 +1,112 @@
1
+ from typing import Optional
2
+
3
+ from mcp.types import Tool
4
+
5
+ from ...services import semantic_service
6
+ from ..app import get_context, register_tool
7
+ from .shape import project_classify, resolve_detail
8
+
9
+
10
+ _DETAIL_PROP = {
11
+ "type": "string", "enum": ["brief", "standard", "full"],
12
+ "default": "brief",
13
+ "description": "brief = type+confidence only; standard/full include reasons.",
14
+ }
15
+
16
+
17
+ def register() -> None:
18
+ register_tool(
19
+ Tool(
20
+ name="classify_table",
21
+ description="Classify a table (fact / dimension / lookup / bridge / audit).",
22
+ inputSchema={
23
+ "type": "object",
24
+ "properties": {
25
+ "schema": {"type": "string"},
26
+ "table": {"type": "string"},
27
+ "force": {"type": "boolean", "default": False},
28
+ "detail": _DETAIL_PROP,
29
+ },
30
+ "required": ["schema", "table"],
31
+ },
32
+ ),
33
+ _classify,
34
+ )
35
+ register_tool(
36
+ Tool(
37
+ name="analyze_columns",
38
+ description="Return semantic labels for each column (audit, status, etc.).",
39
+ inputSchema={
40
+ "type": "object",
41
+ "properties": {
42
+ "schema": {"type": "string"},
43
+ "table": {"type": "string"},
44
+ },
45
+ "required": ["schema", "table"],
46
+ },
47
+ ),
48
+ _columns,
49
+ )
50
+ register_tool(
51
+ Tool(
52
+ name="detect_lookup_tables",
53
+ description=(
54
+ "Scan DB and return likely lookup tables. Supports schema / "
55
+ "keyword / confidence_min filters to limit the sweep."
56
+ ),
57
+ inputSchema={
58
+ "type": "object",
59
+ "properties": {
60
+ "schema": {"oneOf": [{"type": "string"},
61
+ {"type": "array",
62
+ "items": {"type": "string"}}]},
63
+ "keyword": {"type": "string"},
64
+ "confidence_min": {"type": "number",
65
+ "minimum": 0.0, "maximum": 1.0,
66
+ "default": 0.0},
67
+ },
68
+ },
69
+ ),
70
+ _lookups,
71
+ )
72
+
73
+
74
+ def _normalize_schema_filter(raw) -> Optional[list[str]]:
75
+ if raw is None:
76
+ return None
77
+ if isinstance(raw, str):
78
+ return [raw] if raw else None
79
+ if isinstance(raw, list):
80
+ vals = [s for s in raw if isinstance(s, str) and s]
81
+ return vals or None
82
+ return None
83
+
84
+
85
+ async def _classify(args: dict) -> dict:
86
+ ctx = get_context()
87
+ detail = resolve_detail(args)
88
+ classification = await semantic_service.classify_table(
89
+ ctx.cfg.cache_path, ctx.cfg.mssql_database,
90
+ args["schema"], args["table"],
91
+ force=args.get("force", False),
92
+ )
93
+ return project_classify(classification, detail)
94
+
95
+
96
+ async def _columns(args: dict) -> list[dict]:
97
+ ctx = get_context()
98
+ return await semantic_service.analyze_columns(
99
+ ctx.cfg.cache_path, ctx.cfg.mssql_database,
100
+ args["schema"], args["table"],
101
+ )
102
+
103
+
104
+ async def _lookups(args: dict) -> list[dict]:
105
+ ctx = get_context()
106
+ schemas = _normalize_schema_filter(args.get("schema"))
107
+ keyword = args.get("keyword") or None
108
+ confidence_min = float(args.get("confidence_min", 0.0))
109
+ return await semantic_service.detect_lookup_tables(
110
+ ctx.cfg.cache_path, ctx.cfg.mssql_database,
111
+ schemas=schemas, keyword=keyword, confidence_min=confidence_min,
112
+ )
@@ -0,0 +1,204 @@
1
+ """Detail-tier projection helpers for P1 response contract reset.
2
+
3
+ See docs/superpowers/specs/2026-04-19-p1-response-contract-reset-design.md.
4
+ """
5
+ from typing import Any, Optional
6
+
7
+ VALID_DETAILS: frozenset[str] = frozenset({"brief", "standard", "full"})
8
+
9
+ _IMPORTANT_COLS_CAP = 8
10
+
11
+
12
+ class DetailError(ValueError):
13
+ """Raised when an invalid `detail` value is passed."""
14
+
15
+
16
+ def resolve_detail(args: dict) -> str:
17
+ val = args.get("detail", "brief")
18
+ if val not in VALID_DETAILS:
19
+ raise DetailError(
20
+ f"invalid detail '{val}'; expected one of {sorted(VALID_DETAILS)}"
21
+ )
22
+ return val
23
+
24
+
25
+ def _important_columns(
26
+ columns: list[dict], pk: list[str], fks: list[dict],
27
+ semantic_map: dict[str, str],
28
+ ) -> list[str]:
29
+ seen: set[str] = set()
30
+ order: list[str] = []
31
+
32
+ def push(name: str) -> None:
33
+ if name in seen or name is None:
34
+ return
35
+ seen.add(name)
36
+ order.append(name)
37
+ if len(order) >= _IMPORTANT_COLS_CAP:
38
+ raise StopIteration
39
+
40
+ try:
41
+ for name in pk:
42
+ push(name)
43
+ for fk in fks:
44
+ push(fk.get("column_name"))
45
+ # columns with a non-generic semantic tag, in ordinal order
46
+ for col in columns:
47
+ name = col["column_name"]
48
+ sem = semantic_map.get(name)
49
+ if sem and sem != "generic":
50
+ push(name)
51
+ # fill remaining with next columns in ordinal order
52
+ for col in columns:
53
+ push(col["column_name"])
54
+ except StopIteration:
55
+ pass
56
+
57
+ return order[:_IMPORTANT_COLS_CAP]
58
+
59
+
60
+ def project_describe_table(
61
+ full: dict, detail: str,
62
+ classification: Optional[dict],
63
+ column_semantics: dict[str, str],
64
+ ) -> dict:
65
+ schema = full.get("schema_name", "")
66
+ table = full.get("table_name", "")
67
+ columns = full.get("columns", [])
68
+ pk = full.get("primary_key", []) or []
69
+ fks = full.get("foreign_keys", []) or []
70
+ cls_type = (classification or {}).get("type", "unknown")
71
+
72
+ fk_to = sorted({
73
+ f"{fk.get('ref_schema')}.{fk.get('ref_table')}"
74
+ for fk in fks
75
+ if fk.get("ref_schema") and fk.get("ref_table")
76
+ })
77
+
78
+ brief: dict[str, Any] = {
79
+ "table": f"{schema}.{table}",
80
+ "column_count": len(columns),
81
+ "pk": list(pk),
82
+ "fk_to": fk_to,
83
+ "important_columns": _important_columns(columns, pk, fks, column_semantics),
84
+ "classification": cls_type,
85
+ }
86
+ if detail == "brief":
87
+ return brief
88
+
89
+ # standard: brief + full columns (name/type/nullable) + full FK rows
90
+ standard_cols = [
91
+ {"name": c["column_name"], "type": c.get("data_type"),
92
+ "is_nullable": bool(c.get("is_nullable"))}
93
+ for c in columns
94
+ ]
95
+ standard: dict[str, Any] = {
96
+ **brief,
97
+ "columns": standard_cols,
98
+ "foreign_keys": list(fks),
99
+ }
100
+ if detail == "standard":
101
+ return standard
102
+
103
+ # full: standard + indexes + description + per-column default_value + description
104
+ full_cols = []
105
+ for c in columns:
106
+ full_cols.append({
107
+ "name": c["column_name"],
108
+ "type": c.get("data_type"),
109
+ "is_nullable": bool(c.get("is_nullable")),
110
+ "max_length": c.get("max_length"),
111
+ "default_value": c.get("default_value"),
112
+ "description": c.get("description"),
113
+ })
114
+ return {
115
+ **brief,
116
+ "columns": full_cols,
117
+ "foreign_keys": list(fks),
118
+ "indexes": full.get("indexes", []),
119
+ "description": full.get("description"),
120
+ }
121
+
122
+
123
+ def project_get_columns(
124
+ columns: list[dict], detail: str,
125
+ semantic_map: dict[str, str],
126
+ ) -> list[dict]:
127
+ def semantic_for(name: str) -> str:
128
+ return semantic_map.get(name) or "generic"
129
+
130
+ if detail == "brief":
131
+ return [
132
+ {"name": c["column_name"], "semantic": semantic_for(c["column_name"])}
133
+ for c in columns
134
+ ]
135
+ if detail == "standard":
136
+ return [
137
+ {"name": c["column_name"], "type": c.get("data_type"),
138
+ "is_nullable": bool(c.get("is_nullable")),
139
+ "semantic": semantic_for(c["column_name"])}
140
+ for c in columns
141
+ ]
142
+ # full
143
+ return [
144
+ {"name": c["column_name"], "type": c.get("data_type"),
145
+ "max_length": c.get("max_length"),
146
+ "is_nullable": bool(c.get("is_nullable")),
147
+ "default_value": c.get("default_value"),
148
+ "description": c.get("description"),
149
+ "semantic": semantic_for(c["column_name"])}
150
+ for c in columns
151
+ ]
152
+
153
+
154
+ def project_classify(classification: dict, detail: str) -> dict:
155
+ if detail == "brief":
156
+ return {
157
+ "type": classification.get("type"),
158
+ "confidence": classification.get("confidence"),
159
+ }
160
+ return dict(classification)
161
+
162
+
163
+ def project_describe_object(
164
+ obj: dict, detail: str, include_definition: bool,
165
+ ) -> dict:
166
+ schema = obj.get("schema", "")
167
+ name = obj.get("object_name", "")
168
+ obj_type = obj.get("object_type") or obj.get("type")
169
+
170
+ brief: dict[str, Any] = {
171
+ "object": f"{schema}.{name}",
172
+ "type": obj_type,
173
+ "depends_on": list(obj.get("dependencies", []) or []),
174
+ "definition_bytes": obj.get("definition_bytes"),
175
+ }
176
+ if obj.get("status") == "error":
177
+ brief["status"] = "error"
178
+ brief["error_message"] = obj.get("error_message")
179
+
180
+ # include_definition explicit true overrides brief
181
+ if detail == "brief":
182
+ if include_definition and obj.get("definition"):
183
+ brief["definition"] = obj["definition"]
184
+ brief["definition_hash"] = obj.get("definition_hash")
185
+ return brief
186
+
187
+ # standard / full share more fields
188
+ standard: dict[str, Any] = {
189
+ **brief,
190
+ "definition_hash": obj.get("definition_hash"),
191
+ "read_tables": list(obj.get("read_tables", []) or []),
192
+ "write_tables": list(obj.get("write_tables", []) or []),
193
+ "affected_tables": list(obj.get("affected_tables", []) or []),
194
+ "description": obj.get("description"),
195
+ "status": obj.get("status"),
196
+ }
197
+
198
+ if detail == "standard":
199
+ if include_definition and obj.get("definition"):
200
+ standard["definition"] = obj["definition"]
201
+ return standard
202
+
203
+ # full: always include definition
204
+ return {**standard, "definition": obj.get("definition")}