@roll-agent/octopus-agent 0.0.1
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.
- package/README.md +109 -0
- package/SKILL.md +47 -0
- package/octopus_sponge_skill/__init__.py +11 -0
- package/package.json +37 -0
- package/pyproject.toml +17 -0
- package/references/env.yaml +8 -0
- package/src/octopus_sponge_skill/__init__.py +5 -0
- package/src/octopus_sponge_skill/__main__.py +4 -0
- package/src/octopus_sponge_skill/_version.py +3 -0
- package/src/octopus_sponge_skill/agent.py +288 -0
- package/src/octopus_sponge_skill/audit_logger.py +29 -0
- package/src/octopus_sponge_skill/config.py +73 -0
- package/src/octopus_sponge_skill/context.py +20 -0
- package/src/octopus_sponge_skill/llm_sql_generator.py +112 -0
- package/src/octopus_sponge_skill/main.py +30 -0
- package/src/octopus_sponge_skill/mcp_client.py +116 -0
- package/src/octopus_sponge_skill/prompt_builder.py +77 -0
- package/src/octopus_sponge_skill/result_renderer.py +227 -0
- package/src/octopus_sponge_skill/roll_server.py +391 -0
- package/src/octopus_sponge_skill/schema_cache.py +43 -0
- package/src/octopus_sponge_skill/schema_context_retriever.py +115 -0
- package/src/octopus_sponge_skill/sql_generator.py +93 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import asdict, is_dataclass
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .agent import SpongeAgent
|
|
10
|
+
from .config import AppConfig, load_config_from_env
|
|
11
|
+
from .schema_cache import SchemaCacheError
|
|
12
|
+
|
|
13
|
+
SERVER_NAME = "octopus-agent"
|
|
14
|
+
SERVER_VERSION = __version__
|
|
15
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
16
|
+
|
|
17
|
+
Json = dict[str, Any]
|
|
18
|
+
AgentFactory = Callable[[], SpongeAgent]
|
|
19
|
+
SamplerFactory = Callable[[str, str], str]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
serve_stdio()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def serve_stdio() -> None:
|
|
27
|
+
session = StdioSession(sys.stdin.buffer, sys.stdout.buffer)
|
|
28
|
+
while True:
|
|
29
|
+
message = session.read_message()
|
|
30
|
+
if message is None:
|
|
31
|
+
return
|
|
32
|
+
response = handle_request(message, sampler=session.sample)
|
|
33
|
+
if response is not None:
|
|
34
|
+
session.write_message(response)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def handle_request(
|
|
38
|
+
message: Json,
|
|
39
|
+
agent_factory: AgentFactory | None = None,
|
|
40
|
+
sampler: SamplerFactory | None = None,
|
|
41
|
+
) -> Json | None:
|
|
42
|
+
method = message.get("method")
|
|
43
|
+
if method is None:
|
|
44
|
+
return _error_response(message.get("id"), -32600, "Invalid request")
|
|
45
|
+
if "id" not in message:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
request_id = message["id"]
|
|
49
|
+
try:
|
|
50
|
+
if method == "initialize":
|
|
51
|
+
return _result_response(request_id, initialize_result())
|
|
52
|
+
if method == "ping":
|
|
53
|
+
return _result_response(request_id, {})
|
|
54
|
+
if method == "tools/list":
|
|
55
|
+
return _result_response(request_id, {"tools": tool_definitions()})
|
|
56
|
+
if method == "tools/call":
|
|
57
|
+
params = message.get("params") or {}
|
|
58
|
+
return _result_response(request_id, call_tool(params, agent_factory=agent_factory, sampler=sampler))
|
|
59
|
+
return _error_response(request_id, -32601, f"Method not found: {method}")
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
return _error_response(request_id, -32603, str(exc), {"type": type(exc).__name__})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def initialize_result() -> Json:
|
|
65
|
+
return {
|
|
66
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
67
|
+
"capabilities": {"tools": {}},
|
|
68
|
+
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def tool_definitions() -> list[Json]:
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
"name": "diagnostic_status",
|
|
76
|
+
"description": "检查 Octopus Sponge Agent 配置、缓存和本地持久化状态",
|
|
77
|
+
"inputSchema": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {},
|
|
80
|
+
"additionalProperties": False,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"name": "refresh_schema",
|
|
85
|
+
"description": "调用 Sponge MCP Server 刷新本地 Schema Cache(表结构缓存)",
|
|
86
|
+
"inputSchema": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"required": ["traceId"],
|
|
89
|
+
"properties": {
|
|
90
|
+
"traceId": {
|
|
91
|
+
"type": "string",
|
|
92
|
+
"description": "调用链路 ID,必填,会原样传给 Sponge get_schema",
|
|
93
|
+
},
|
|
94
|
+
"refreshReason": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"description": "刷新原因,默认 manual(人工触发)",
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"additionalProperties": False,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "query_sponge",
|
|
104
|
+
"description": "把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行",
|
|
105
|
+
"inputSchema": {
|
|
106
|
+
"type": "object",
|
|
107
|
+
"required": ["question", "userId"],
|
|
108
|
+
"properties": {
|
|
109
|
+
"question": {"type": "string", "description": "用户自然语言问题"},
|
|
110
|
+
"userId": {"type": "integer", "description": "当前登录用户对应的 Duliri userId"},
|
|
111
|
+
"sessionId": {"type": "string", "description": "可选会话 ID"},
|
|
112
|
+
"deviceId": {"type": "string", "description": "可选设备 ID"},
|
|
113
|
+
"resultFormat": {
|
|
114
|
+
"type": "string",
|
|
115
|
+
"enum": ["text", "yaml", "md", "markdown", "html"],
|
|
116
|
+
"description": "结果输出格式;不传则根据问题自动识别",
|
|
117
|
+
},
|
|
118
|
+
"includeSql": {
|
|
119
|
+
"type": "boolean",
|
|
120
|
+
"description": "是否在回参中包含 generatedSql、normalizedSql、repairedSql 等调试信息",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
"additionalProperties": False,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def call_tool(
|
|
130
|
+
params: Json,
|
|
131
|
+
agent_factory: AgentFactory | None = None,
|
|
132
|
+
sampler: SamplerFactory | None = None,
|
|
133
|
+
) -> Json:
|
|
134
|
+
tool_name = params.get("name")
|
|
135
|
+
arguments = params.get("arguments") or {}
|
|
136
|
+
try:
|
|
137
|
+
if tool_name == "diagnostic_status":
|
|
138
|
+
return _tool_json(_diagnostic_status(_load_config()))
|
|
139
|
+
if tool_name == "refresh_schema":
|
|
140
|
+
agent = _make_agent(agent_factory)
|
|
141
|
+
return _tool_json(
|
|
142
|
+
_refresh_schema(
|
|
143
|
+
agent,
|
|
144
|
+
trace_id=_required_string(arguments, "traceId"),
|
|
145
|
+
refresh_reason=str(arguments.get("refreshReason") or "manual"),
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
if tool_name == "query_sponge":
|
|
149
|
+
agent = _make_agent(agent_factory)
|
|
150
|
+
answer_kwargs = {
|
|
151
|
+
"question": _required_string(arguments, "question"),
|
|
152
|
+
"user_id": _required_int(arguments, "userId"),
|
|
153
|
+
"session_id": arguments.get("sessionId"),
|
|
154
|
+
"device_id": arguments.get("deviceId"),
|
|
155
|
+
"result_format": arguments.get("resultFormat"),
|
|
156
|
+
}
|
|
157
|
+
if sampler is not None:
|
|
158
|
+
answer_kwargs["sampler"] = sampler
|
|
159
|
+
query_result = agent.query(**answer_kwargs)
|
|
160
|
+
if arguments.get("includeSql") is True:
|
|
161
|
+
return _tool_json(
|
|
162
|
+
{
|
|
163
|
+
"answer": query_result.answer,
|
|
164
|
+
"generatedSql": query_result.generated_sql,
|
|
165
|
+
"normalizedSql": query_result.normalized_sql,
|
|
166
|
+
"repairedSql": query_result.repaired_sql,
|
|
167
|
+
"validation": query_result.validation,
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
return _tool_text(query_result.answer)
|
|
171
|
+
return _tool_error(f"Unknown tool: {tool_name}", code="unknown_tool")
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
return _tool_error(str(exc), code=type(exc).__name__)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _diagnostic_status(config: AppConfig) -> Json:
|
|
177
|
+
cache_exists = config.schema.cache_path.exists()
|
|
178
|
+
schema_version: str | None = None
|
|
179
|
+
cache_error: str | None = None
|
|
180
|
+
try:
|
|
181
|
+
schema_version = SpongeAgent.from_config(config).schema_cache.version()
|
|
182
|
+
except SchemaCacheError as exc:
|
|
183
|
+
cache_error = str(exc)
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
"server": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
187
|
+
"spongeMcp": {
|
|
188
|
+
"baseUrl": config.sponge_mcp.base_url,
|
|
189
|
+
"accessTokenConfigured": bool(config.sponge_mcp.access_token),
|
|
190
|
+
"timeoutMs": config.sponge_mcp.timeout_ms,
|
|
191
|
+
},
|
|
192
|
+
"schema": {
|
|
193
|
+
"cachePath": str(config.schema.cache_path),
|
|
194
|
+
"cacheExists": cache_exists,
|
|
195
|
+
"schemaVersion": schema_version,
|
|
196
|
+
"cacheError": cache_error,
|
|
197
|
+
},
|
|
198
|
+
"audit": {
|
|
199
|
+
"enabled": config.audit.enabled,
|
|
200
|
+
"logPath": str(config.audit.log_path),
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _refresh_schema(agent: SpongeAgent, *, trace_id: str, refresh_reason: str) -> Json:
|
|
206
|
+
cached_schema = agent.schema_cache.load()
|
|
207
|
+
current_version = str(cached_schema.get("schemaVersion")) if cached_schema and cached_schema.get("schemaVersion") else None
|
|
208
|
+
schema = agent.mcp_client.get_schema(
|
|
209
|
+
trace_id=trace_id,
|
|
210
|
+
schema_version=current_version,
|
|
211
|
+
refresh_reason=refresh_reason,
|
|
212
|
+
)
|
|
213
|
+
response_changed = schema.get("changed")
|
|
214
|
+
if schema.get("changed") is False and cached_schema is not None:
|
|
215
|
+
schema = cached_schema
|
|
216
|
+
elif schema.get("changed") is False and cached_schema is None:
|
|
217
|
+
schema = agent.mcp_client.get_schema(
|
|
218
|
+
trace_id=trace_id,
|
|
219
|
+
schema_version=None,
|
|
220
|
+
refresh_reason=refresh_reason,
|
|
221
|
+
)
|
|
222
|
+
if "tables" in schema:
|
|
223
|
+
agent.schema_cache.save(schema)
|
|
224
|
+
schema_source = "cache" if response_changed is False and cached_schema is not None else "response"
|
|
225
|
+
agent.audit_logger.log(
|
|
226
|
+
{
|
|
227
|
+
"traceId": trace_id,
|
|
228
|
+
"tool": "get_schema",
|
|
229
|
+
"schemaVersion": schema.get("schemaVersion"),
|
|
230
|
+
"refreshReason": refresh_reason,
|
|
231
|
+
"changed": response_changed,
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
return {
|
|
235
|
+
"changed": response_changed,
|
|
236
|
+
"request": {
|
|
237
|
+
"traceId": trace_id,
|
|
238
|
+
"schemaVersion": current_version,
|
|
239
|
+
"refreshReason": refresh_reason,
|
|
240
|
+
},
|
|
241
|
+
"schema": {
|
|
242
|
+
"schemaVersion": schema.get("schemaVersion"),
|
|
243
|
+
"generatedAt": schema.get("generatedAt"),
|
|
244
|
+
"source": schema_source,
|
|
245
|
+
},
|
|
246
|
+
"cachePath": str(agent.schema_cache.path),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _load_config() -> AppConfig:
|
|
251
|
+
return load_config_from_env()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _make_agent(agent_factory: AgentFactory | None) -> SpongeAgent:
|
|
255
|
+
if agent_factory is not None:
|
|
256
|
+
return agent_factory()
|
|
257
|
+
return SpongeAgent.from_config(_load_config())
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _required_string(arguments: Json, key: str) -> str:
|
|
261
|
+
value = arguments.get(key)
|
|
262
|
+
if not isinstance(value, str) or not value:
|
|
263
|
+
raise ValueError(f"{key} is required")
|
|
264
|
+
return value
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _required_int(arguments: Json, key: str) -> int:
|
|
268
|
+
value = arguments.get(key)
|
|
269
|
+
if not isinstance(value, int):
|
|
270
|
+
raise ValueError(f"{key} is required")
|
|
271
|
+
return value
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _tool_text(text: str) -> Json:
|
|
275
|
+
return {"content": [{"type": "text", "text": text}]}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _tool_json(data: Any) -> Json:
|
|
279
|
+
return {
|
|
280
|
+
"content": [
|
|
281
|
+
{
|
|
282
|
+
"type": "text",
|
|
283
|
+
"text": json.dumps(_jsonable(data), ensure_ascii=False, sort_keys=True),
|
|
284
|
+
}
|
|
285
|
+
]
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _tool_error(message: str, *, code: str) -> Json:
|
|
290
|
+
return {
|
|
291
|
+
"isError": True,
|
|
292
|
+
"content": [
|
|
293
|
+
{
|
|
294
|
+
"type": "text",
|
|
295
|
+
"text": json.dumps({"code": code, "message": message}, ensure_ascii=False, sort_keys=True),
|
|
296
|
+
}
|
|
297
|
+
],
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _jsonable(value: Any) -> Any:
|
|
302
|
+
if is_dataclass(value):
|
|
303
|
+
return _jsonable(asdict(value))
|
|
304
|
+
if isinstance(value, dict):
|
|
305
|
+
return {str(key): _jsonable(item) for key, item in value.items()}
|
|
306
|
+
if isinstance(value, list):
|
|
307
|
+
return [_jsonable(item) for item in value]
|
|
308
|
+
return value
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _result_response(request_id: Any, result: Json) -> Json:
|
|
312
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _error_response(request_id: Any, code: int, message: str, data: Json | None = None) -> Json:
|
|
316
|
+
error: Json = {"code": code, "message": message}
|
|
317
|
+
if data is not None:
|
|
318
|
+
error["data"] = data
|
|
319
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": error}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class StdioSession:
|
|
323
|
+
def __init__(self, input_stream: Any, output_stream: Any) -> None:
|
|
324
|
+
self.input_stream = input_stream
|
|
325
|
+
self.output_stream = output_stream
|
|
326
|
+
self.next_request_id = 1
|
|
327
|
+
|
|
328
|
+
def read_message(self) -> Json | None:
|
|
329
|
+
return _read_message(self.input_stream)
|
|
330
|
+
|
|
331
|
+
def write_message(self, message: Json) -> None:
|
|
332
|
+
_write_message(self.output_stream, message)
|
|
333
|
+
|
|
334
|
+
def sample(self, system_prompt: str, prompt: str) -> str:
|
|
335
|
+
request_id = f"sampling_{self.next_request_id}"
|
|
336
|
+
self.next_request_id += 1
|
|
337
|
+
self.write_message(
|
|
338
|
+
{
|
|
339
|
+
"jsonrpc": "2.0",
|
|
340
|
+
"id": request_id,
|
|
341
|
+
"method": "sampling/createMessage",
|
|
342
|
+
"params": {
|
|
343
|
+
"systemPrompt": system_prompt,
|
|
344
|
+
"messages": [{"role": "user", "content": {"type": "text", "text": prompt}}],
|
|
345
|
+
"temperature": 0,
|
|
346
|
+
"maxTokens": 1600,
|
|
347
|
+
"includeContext": "none",
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
while True:
|
|
352
|
+
message = self.read_message()
|
|
353
|
+
if message is None:
|
|
354
|
+
raise RuntimeError("Sampling failed: client closed stdio")
|
|
355
|
+
if message.get("id") == request_id:
|
|
356
|
+
if "error" in message:
|
|
357
|
+
raise RuntimeError(f"Sampling failed: {message['error']}")
|
|
358
|
+
return _extract_sampling_text(message.get("result") or {})
|
|
359
|
+
response = handle_request(message, sampler=self.sample)
|
|
360
|
+
if response is not None:
|
|
361
|
+
self.write_message(response)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _extract_sampling_text(result: Json) -> str:
|
|
365
|
+
content = result.get("content")
|
|
366
|
+
if isinstance(content, dict) and content.get("type") == "text":
|
|
367
|
+
return str(content.get("text") or "")
|
|
368
|
+
if isinstance(content, list):
|
|
369
|
+
return "\n".join(
|
|
370
|
+
str(item.get("text") or "") for item in content if isinstance(item, dict) and item.get("type") == "text"
|
|
371
|
+
)
|
|
372
|
+
raise RuntimeError("Sampling response did not contain text content")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _read_message(stream: Any) -> Json | None:
|
|
376
|
+
while True:
|
|
377
|
+
line = stream.readline()
|
|
378
|
+
if line == b"":
|
|
379
|
+
return None
|
|
380
|
+
if line.strip():
|
|
381
|
+
return json.loads(line.decode("utf-8"))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _write_message(stream: Any, message: Json) -> None:
|
|
385
|
+
body = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
386
|
+
stream.write(body + b"\n")
|
|
387
|
+
stream.flush()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
main()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SchemaCacheError(RuntimeError):
|
|
10
|
+
"""Raised when the persisted schema cache cannot be used."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SchemaCache:
|
|
15
|
+
path: Path
|
|
16
|
+
|
|
17
|
+
def load(self) -> dict[str, Any] | None:
|
|
18
|
+
if not self.path.exists():
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
22
|
+
except json.JSONDecodeError as exc:
|
|
23
|
+
raise SchemaCacheError(f"Invalid schema cache JSON: {self.path}") from exc
|
|
24
|
+
if not isinstance(data, dict):
|
|
25
|
+
raise SchemaCacheError(f"Schema cache must be a JSON object: {self.path}")
|
|
26
|
+
return data
|
|
27
|
+
|
|
28
|
+
def save(self, schema: dict[str, Any]) -> None:
|
|
29
|
+
if not isinstance(schema, dict):
|
|
30
|
+
raise TypeError("schema must be a dict")
|
|
31
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
self.path.write_text(
|
|
33
|
+
json.dumps(schema, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
34
|
+
encoding="utf-8",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def version(self) -> str | None:
|
|
38
|
+
schema = self.load()
|
|
39
|
+
if schema is None:
|
|
40
|
+
return None
|
|
41
|
+
version = schema.get("schemaVersion")
|
|
42
|
+
return str(version) if version is not None else None
|
|
43
|
+
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class SchemaContext:
|
|
9
|
+
tables: list[dict[str, Any]]
|
|
10
|
+
joins: list[dict[str, Any]]
|
|
11
|
+
glossary: list[dict[str, Any]]
|
|
12
|
+
metrics: list[dict[str, Any]]
|
|
13
|
+
examples: list[dict[str, Any]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def retrieve_schema_context(schema: dict[str, Any], question: str, *, max_tables: int = 8) -> SchemaContext:
|
|
17
|
+
glossary = _matching_items(schema.get("glossary", []), question, keys=("term", "description"))
|
|
18
|
+
metrics = _matching_items(schema.get("metrics", []), question, keys=("name", "metric", "description", "definition"))
|
|
19
|
+
examples = _matching_items(schema.get("examples", []), question, keys=("question", "description"))
|
|
20
|
+
table_names_from_context = _target_table_names(glossary + metrics + examples)
|
|
21
|
+
tables = _rank_tables(schema.get("tables", []), question, table_names_from_context)[:max_tables]
|
|
22
|
+
selected_table_names = {_table_name(table) for table in tables}
|
|
23
|
+
joins = _filter_joins(schema.get("joins", []), selected_table_names)
|
|
24
|
+
return SchemaContext(tables=tables, joins=joins, glossary=glossary, metrics=metrics, examples=examples)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _rank_tables(tables: Any, question: str, preferred_names: set[str]) -> list[dict[str, Any]]:
|
|
28
|
+
if not isinstance(tables, list):
|
|
29
|
+
return []
|
|
30
|
+
ranked: list[tuple[int, dict[str, Any]]] = []
|
|
31
|
+
for table in tables:
|
|
32
|
+
if not isinstance(table, dict):
|
|
33
|
+
continue
|
|
34
|
+
name = _table_name(table)
|
|
35
|
+
score = 5 if name in preferred_names else 0
|
|
36
|
+
text = " ".join(
|
|
37
|
+
[
|
|
38
|
+
name,
|
|
39
|
+
str(table.get("comment") or ""),
|
|
40
|
+
" ".join(_column_text(column) for column in table.get("columns", []) if isinstance(column, dict)),
|
|
41
|
+
]
|
|
42
|
+
).lower()
|
|
43
|
+
for token in _question_tokens(question):
|
|
44
|
+
if token and token in text:
|
|
45
|
+
score += 1
|
|
46
|
+
if score > 0 or not preferred_names:
|
|
47
|
+
ranked.append((score, table))
|
|
48
|
+
ranked.sort(key=lambda item: item[0], reverse=True)
|
|
49
|
+
return [table for _, table in ranked]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _matching_items(items: Any, question: str, *, keys: tuple[str, ...]) -> list[dict[str, Any]]:
|
|
53
|
+
if not isinstance(items, list):
|
|
54
|
+
return []
|
|
55
|
+
tokens = _question_tokens(question)
|
|
56
|
+
matches: list[dict[str, Any]] = []
|
|
57
|
+
for item in items:
|
|
58
|
+
if not isinstance(item, dict):
|
|
59
|
+
continue
|
|
60
|
+
text = " ".join(str(item.get(key) or "") for key in keys).lower()
|
|
61
|
+
if any(token and token in text for token in tokens):
|
|
62
|
+
matches.append(item)
|
|
63
|
+
return matches[:8]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _target_table_names(items: list[dict[str, Any]]) -> set[str]:
|
|
67
|
+
names: set[str] = set()
|
|
68
|
+
for item in items:
|
|
69
|
+
for key in ("tables", "targetTables"):
|
|
70
|
+
value = item.get(key)
|
|
71
|
+
if isinstance(value, list):
|
|
72
|
+
names.update(str(name) for name in value)
|
|
73
|
+
targets = item.get("targets")
|
|
74
|
+
if isinstance(targets, list):
|
|
75
|
+
for target in targets:
|
|
76
|
+
if isinstance(target, str) and "." in target:
|
|
77
|
+
names.add(target.split(".", 1)[0])
|
|
78
|
+
return names
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _filter_joins(joins: Any, selected_table_names: set[str]) -> list[dict[str, Any]]:
|
|
82
|
+
if not isinstance(joins, list):
|
|
83
|
+
return []
|
|
84
|
+
result: list[dict[str, Any]] = []
|
|
85
|
+
for join in joins:
|
|
86
|
+
if not isinstance(join, dict):
|
|
87
|
+
continue
|
|
88
|
+
join_tables = {
|
|
89
|
+
str(join.get("leftTable") or join.get("from") or ""),
|
|
90
|
+
str(join.get("rightTable") or join.get("to") or ""),
|
|
91
|
+
}
|
|
92
|
+
if join_tables & selected_table_names:
|
|
93
|
+
result.append(join)
|
|
94
|
+
return result[:12]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _table_name(table: dict[str, Any]) -> str:
|
|
98
|
+
return str(table.get("tableName") or table.get("name") or "")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _column_text(column: dict[str, Any]) -> str:
|
|
102
|
+
return " ".join(
|
|
103
|
+
str(column.get(key) or "") for key in ("columnName", "name", "dataType", "type", "comment")
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _question_tokens(question: str) -> list[str]:
|
|
108
|
+
lowered = question.lower()
|
|
109
|
+
tokens = [lowered]
|
|
110
|
+
tokens.extend(part for part in lowered.replace("_", " ").replace("-", " ").split() if part)
|
|
111
|
+
for word in ("品牌", "项目", "岗位", "门店", "招聘", "在招", "数量", "列表"):
|
|
112
|
+
if word in question:
|
|
113
|
+
tokens.append(word)
|
|
114
|
+
return list(dict.fromkeys(tokens))
|
|
115
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SqlGenerationError(RuntimeError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class GeneratedSql:
|
|
14
|
+
sql: str
|
|
15
|
+
tables: list[str]
|
|
16
|
+
placeholders: list[str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RuleBasedSqlGenerator:
|
|
21
|
+
default_limit: int
|
|
22
|
+
max_limit: int
|
|
23
|
+
|
|
24
|
+
def generate(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
question: str,
|
|
28
|
+
schema: dict[str, Any],
|
|
29
|
+
scope_hints: dict[str, Any],
|
|
30
|
+
) -> GeneratedSql:
|
|
31
|
+
table_names = _table_names(schema)
|
|
32
|
+
lowered = question.lower()
|
|
33
|
+
|
|
34
|
+
if ("品牌" in question or "brand" in lowered) and {"brand", "brand_project"}.issubset(table_names):
|
|
35
|
+
sql = (
|
|
36
|
+
"SELECT b.id AS brand_id, b.name AS brand_name "
|
|
37
|
+
"FROM brand b "
|
|
38
|
+
"JOIN brand_project bp ON bp.brand_id = b.id "
|
|
39
|
+
"WHERE bp.project_id IN (:scope.project_ids) "
|
|
40
|
+
"AND b.id IN (:scope.brand_ids) "
|
|
41
|
+
f"LIMIT {self.default_limit}"
|
|
42
|
+
)
|
|
43
|
+
return GeneratedSql(sql, ["brand", "brand_project"], _placeholders(scope_hints))
|
|
44
|
+
|
|
45
|
+
if ("项目" in question or "project" in lowered) and "sponge_project" in table_names:
|
|
46
|
+
sql = (
|
|
47
|
+
"SELECT p.id AS project_id, p.name AS project_name "
|
|
48
|
+
"FROM sponge_project p "
|
|
49
|
+
"WHERE p.id IN (:scope.project_ids) "
|
|
50
|
+
f"LIMIT {self.default_limit}"
|
|
51
|
+
)
|
|
52
|
+
return GeneratedSql(sql, ["sponge_project"], [":scope.project_ids"])
|
|
53
|
+
|
|
54
|
+
raise SqlGenerationError("No safe SQL template matched the question")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
|
|
58
|
+
normalized = sql.strip().lower()
|
|
59
|
+
if not normalized.startswith("select "):
|
|
60
|
+
raise SqlGenerationError("SQL must be SELECT only")
|
|
61
|
+
if ";" in normalized.rstrip(";"):
|
|
62
|
+
raise SqlGenerationError("SQL must be a single statement")
|
|
63
|
+
if re.search(r"\b(insert|update|delete|drop|alter|create|truncate)\b", normalized):
|
|
64
|
+
raise SqlGenerationError("SQL contains a forbidden keyword")
|
|
65
|
+
if re.search(r"\b(password|token|secret|credential|private_key)\b", normalized):
|
|
66
|
+
raise SqlGenerationError("SQL contains a sensitive field")
|
|
67
|
+
limit = re.search(r"\blimit\s+(\d+)\b", normalized)
|
|
68
|
+
if limit is None:
|
|
69
|
+
raise SqlGenerationError("SQL must contain LIMIT")
|
|
70
|
+
if int(limit.group(1)) > max_limit:
|
|
71
|
+
raise SqlGenerationError(f"SQL LIMIT must not exceed {max_limit}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _table_names(schema: dict[str, Any]) -> set[str]:
|
|
75
|
+
tables = schema.get("tables", [])
|
|
76
|
+
if not isinstance(tables, list):
|
|
77
|
+
return set()
|
|
78
|
+
names: set[str] = set()
|
|
79
|
+
for table in tables:
|
|
80
|
+
if not isinstance(table, dict):
|
|
81
|
+
continue
|
|
82
|
+
name = table.get("name") or table.get("tableName")
|
|
83
|
+
if name:
|
|
84
|
+
names.add(str(name))
|
|
85
|
+
return names
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _placeholders(scope_hints: dict[str, Any]) -> list[str]:
|
|
89
|
+
placeholders: list[str] = []
|
|
90
|
+
for value in scope_hints.values():
|
|
91
|
+
if isinstance(value, dict) and value.get("placeholder"):
|
|
92
|
+
placeholders.append(str(value["placeholder"]))
|
|
93
|
+
return placeholders
|