@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
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Octopus Sponge NL2SQL Agent
|
|
2
|
+
|
|
3
|
+
本仓库实现 Octopus 对接 Sponge MCP Server 的第一版 MVP。
|
|
4
|
+
|
|
5
|
+
## 能力
|
|
6
|
+
|
|
7
|
+
- Schema Cache(表结构缓存):本地保存 Sponge 返回的 schema。
|
|
8
|
+
- MCP Client(MCP 客户端):调用 `get_schema`、`get_allowed_context`、`validate_sql`、`execute_sql`。
|
|
9
|
+
- MCP Sampling(采样):由 roll-core 代调 LLM 生成和修正 SQL。
|
|
10
|
+
- SQL Guard(SQL 保护规则):只允许单条 `SELECT`,必须带 `LIMIT`。
|
|
11
|
+
- Audit Log(审计日志):记录 trace、session、SQL hash、执行摘要。
|
|
12
|
+
|
|
13
|
+
## 本地运行
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
python -m octopus_sponge_skill "查询我能看到的品牌列表" --user-id 456
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
运行参数由 Agent 内置;用户本地只需要配置 Sponge MCP Server 地址和 Token。
|
|
20
|
+
|
|
21
|
+
## roll-core 接入
|
|
22
|
+
|
|
23
|
+
本仓库支持纯 Python stdio MCP 子 Agent 接入 roll-core。
|
|
24
|
+
|
|
25
|
+
### npm 包安装
|
|
26
|
+
|
|
27
|
+
发布到 npm 后,可通过 `roll agent install` 安装:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
roll agent install @roll-agent/octopus-agent
|
|
31
|
+
roll run octopus-agent diagnostic_status --json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
该 npm package(npm 包)直接包含 Python 源码;`roll agent install` 不会再从 PyPI 下载 `octopus-sponge-skill`。
|
|
35
|
+
安装机器上的 `python3` 需要是 Python 3.11+。
|
|
36
|
+
|
|
37
|
+
### 本地源码注册
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
roll agent add .
|
|
41
|
+
roll run octopus-agent diagnostic_status --json
|
|
42
|
+
roll run octopus-agent refresh_schema --json \
|
|
43
|
+
--input-json '{"traceId":"trace_schema_10001","refreshReason":"manual"}'
|
|
44
|
+
roll run octopus-agent query_sponge --json \
|
|
45
|
+
--question "查询我能看到的品牌列表" \
|
|
46
|
+
--user-id 456
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`refresh_schema` 必填 `traceId`。Agent 会自动读取本地缓存里的 `schemaVersion` 并传给 Sponge。
|
|
50
|
+
|
|
51
|
+
指定输出格式:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
roll run octopus-agent query_sponge --json \
|
|
55
|
+
--input-json '{"question":"查询我能看到的品牌列表","userId":456,"resultFormat":"markdown"}'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
支持 `text`、`yaml`、`md`、`markdown`、`html`。不传时会从问题里自动识别。
|
|
59
|
+
|
|
60
|
+
调试 SQL:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
roll run octopus-agent query_sponge --json \
|
|
64
|
+
--input-json '{"question":"查询我能看到的品牌列表","userId":456,"includeSql":true}'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`includeSql=true` 时会返回 `generatedSql`、`normalizedSql`、`repairedSql` 和 `validation`。
|
|
68
|
+
|
|
69
|
+
`package.json` 中声明了 roll-core 启动方式:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"rollAgent": {
|
|
74
|
+
"runtime": {"ownership": "on-demand", "transport": "stdio"},
|
|
75
|
+
"start": {
|
|
76
|
+
"command": "python3",
|
|
77
|
+
"args": ["-m", "octopus_sponge_skill.roll_server"]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
环境变量可放到 `roll.config.yaml`:
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
agents:
|
|
87
|
+
env:
|
|
88
|
+
octopus-agent:
|
|
89
|
+
SPONGE_MCP_BASE_URL: "https://sponge-mcp.duliday.com"
|
|
90
|
+
SPONGE_MCP_ACCESS_TOKEN: "xxxx"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`SPONGE_MCP_BASE_URL` 只填域名根地址,不要带 `/mcp/admin/health`;Agent 会调用 `/mcp/tools/...`。
|
|
94
|
+
|
|
95
|
+
LLM 配置读取 roll-core 现有 `llm` 配置;Agent 不读取模型 API Key。
|
|
96
|
+
`query_sponge` 会通过 MCP Sampling 请求 roll-core 代调模型。
|
|
97
|
+
|
|
98
|
+
## 本地持久化
|
|
99
|
+
|
|
100
|
+
- `data/sponge-schema-cache.json`:schema 缓存。
|
|
101
|
+
- `logs/octopus-sponge-audit.log`:审计日志。
|
|
102
|
+
|
|
103
|
+
不新建数据库,不保存真实项目 ID、品牌 ID、数据库账号密码。
|
|
104
|
+
|
|
105
|
+
## 自测
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
109
|
+
```
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: octopus-agent
|
|
3
|
+
description: Octopus Sponge NL2SQL Agent,把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行。
|
|
4
|
+
metadata:
|
|
5
|
+
roll-env-file: references/env.yaml
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Octopus Sponge Agent
|
|
9
|
+
|
|
10
|
+
## 职责
|
|
11
|
+
|
|
12
|
+
本 Agent 只负责 Sponge 数据查询:
|
|
13
|
+
|
|
14
|
+
- 读取和刷新 Schema Cache(表结构缓存)。
|
|
15
|
+
- 基于用户问题和 schema context,通过 MCP Sampling(采样)请求 roll-core 代调 LLM 生成只读 SQL。
|
|
16
|
+
- 调用 Sponge MCP Server 做权限上下文、SQL 校验和 SQL 执行。
|
|
17
|
+
- 记录 Audit Log(审计日志)。
|
|
18
|
+
|
|
19
|
+
## Tools
|
|
20
|
+
|
|
21
|
+
| Tool | 中文解释 | 用途 |
|
|
22
|
+
|-|-|-|
|
|
23
|
+
| `diagnostic_status` | 诊断状态 | 检查配置、缓存和日志路径 |
|
|
24
|
+
| `refresh_schema` | 刷新表结构 | 人工刷新本地 schema 缓存 |
|
|
25
|
+
| `query_sponge` | 查询 Sponge 数据 | 自然语言查询入口 |
|
|
26
|
+
|
|
27
|
+
`refresh_schema` 必填 `traceId`;Agent 会自动读取本地缓存里的 `schemaVersion` 并传给 Sponge。
|
|
28
|
+
|
|
29
|
+
`query_sponge` 支持 `resultFormat`:`text`、`yaml`、`md`、`markdown`、`html`。不传时按用户问题自动识别。
|
|
30
|
+
调试时可传 `includeSql=true` 返回 `generatedSql`、`normalizedSql`、`repairedSql` 和 `validation`;默认不返回 SQL。
|
|
31
|
+
|
|
32
|
+
## 上层调用规则
|
|
33
|
+
|
|
34
|
+
1. 查询前如需排查环境,先调用 `diagnostic_status`。
|
|
35
|
+
2. schema 过期或缺失时,调用 `refresh_schema`。
|
|
36
|
+
3. 用户提出业务数据查询时,调用 `query_sponge`。
|
|
37
|
+
4. 不要求上层提供真实项目 ID 或品牌 ID;权限由 Sponge MCP Server 处理。
|
|
38
|
+
5. 不允许绕过本 Agent 直接连业务数据库。
|
|
39
|
+
6. 运行参数由 Agent 内置;本地只通过环境变量配置 Sponge 地址和 Token。
|
|
40
|
+
7. LLM 由 roll-core 通过 MCP Sampling 代调,本 Agent 不读取模型 API Key。
|
|
41
|
+
|
|
42
|
+
## 持久化
|
|
43
|
+
|
|
44
|
+
- `data/sponge-schema-cache.json`:Schema Cache(表结构缓存)。
|
|
45
|
+
- `logs/octopus-sponge-audit.log`:Audit Log(审计日志)。
|
|
46
|
+
|
|
47
|
+
不新建数据库,不保存真实项目 ID、品牌 ID、数据库账号密码。
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
_src_package = Path(__file__).resolve().parent.parent / "src" / "octopus_sponge_skill"
|
|
6
|
+
if _src_package.exists():
|
|
7
|
+
__path__.append(str(_src_package))
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@roll-agent/octopus-agent",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Octopus Sponge NL2SQL Agent",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"roll-agent",
|
|
8
|
+
"mcp",
|
|
9
|
+
"nl2sql"
|
|
10
|
+
],
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"octopus_sponge_skill",
|
|
16
|
+
"src",
|
|
17
|
+
"references",
|
|
18
|
+
"SKILL.md",
|
|
19
|
+
"pyproject.toml",
|
|
20
|
+
"!**/__pycache__/**",
|
|
21
|
+
"!**/*.pyc",
|
|
22
|
+
"!**/*.egg-info/**"
|
|
23
|
+
],
|
|
24
|
+
"rollAgent": {
|
|
25
|
+
"runtime": {
|
|
26
|
+
"ownership": "on-demand",
|
|
27
|
+
"transport": "stdio"
|
|
28
|
+
},
|
|
29
|
+
"start": {
|
|
30
|
+
"command": "python3",
|
|
31
|
+
"args": [
|
|
32
|
+
"-m",
|
|
33
|
+
"octopus_sponge_skill.roll_server"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "octopus-skill"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Octopus Sponge NL2SQL Agent"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[project.scripts]
|
|
10
|
+
octopus-sponge = "octopus_sponge_skill.main:main"
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["setuptools>=68"]
|
|
14
|
+
build-backend = "setuptools.build_meta"
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.packages.find]
|
|
17
|
+
where = ["src"]
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .audit_logger import AuditLogger, sql_hash
|
|
8
|
+
from .config import AppConfig
|
|
9
|
+
from .context import UserContext, new_session_id, new_trace_id
|
|
10
|
+
from .llm_sql_generator import Sampler, SamplingSqlGenerator
|
|
11
|
+
from .mcp_client import SpongeMcpClient
|
|
12
|
+
from .result_renderer import render_result
|
|
13
|
+
from .schema_cache import SchemaCache
|
|
14
|
+
from .schema_context_retriever import retrieve_schema_context
|
|
15
|
+
from .sql_generator import RuleBasedSqlGenerator, enforce_sql_rules
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class QueryResult:
|
|
20
|
+
answer: str
|
|
21
|
+
generated_sql: str
|
|
22
|
+
normalized_sql: str
|
|
23
|
+
repaired_sql: str | None
|
|
24
|
+
validation: dict[str, Any]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class SpongeAgent:
|
|
29
|
+
config: AppConfig
|
|
30
|
+
mcp_client: SpongeMcpClient
|
|
31
|
+
schema_cache: SchemaCache
|
|
32
|
+
audit_logger: AuditLogger
|
|
33
|
+
_last_validation_error: dict[str, Any] = field(default_factory=dict, init=False)
|
|
34
|
+
_last_validation_result: dict[str, Any] = field(default_factory=dict, init=False)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_config(cls, config: AppConfig) -> "SpongeAgent":
|
|
38
|
+
return cls(
|
|
39
|
+
config=config,
|
|
40
|
+
mcp_client=SpongeMcpClient(
|
|
41
|
+
base_url=config.sponge_mcp.base_url,
|
|
42
|
+
access_token=config.sponge_mcp.access_token,
|
|
43
|
+
timeout_ms=config.sponge_mcp.timeout_ms,
|
|
44
|
+
),
|
|
45
|
+
schema_cache=SchemaCache(config.schema.cache_path),
|
|
46
|
+
audit_logger=AuditLogger(config.audit.log_path, enabled=config.audit.enabled),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def answer(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
question: str,
|
|
53
|
+
user_id: int,
|
|
54
|
+
session_id: str | None = None,
|
|
55
|
+
device_id: str | None = None,
|
|
56
|
+
sampler: Sampler | None = None,
|
|
57
|
+
result_format: str | None = None,
|
|
58
|
+
) -> str:
|
|
59
|
+
return self.query(
|
|
60
|
+
question=question,
|
|
61
|
+
user_id=user_id,
|
|
62
|
+
session_id=session_id,
|
|
63
|
+
device_id=device_id,
|
|
64
|
+
sampler=sampler,
|
|
65
|
+
result_format=result_format,
|
|
66
|
+
).answer
|
|
67
|
+
|
|
68
|
+
def query(
|
|
69
|
+
self,
|
|
70
|
+
*,
|
|
71
|
+
question: str,
|
|
72
|
+
user_id: int,
|
|
73
|
+
session_id: str | None = None,
|
|
74
|
+
device_id: str | None = None,
|
|
75
|
+
sampler: Sampler | None = None,
|
|
76
|
+
result_format: str | None = None,
|
|
77
|
+
) -> QueryResult:
|
|
78
|
+
context = UserContext(
|
|
79
|
+
user_id=user_id,
|
|
80
|
+
session_id=session_id or new_session_id(),
|
|
81
|
+
device_id=device_id,
|
|
82
|
+
)
|
|
83
|
+
sql_question = _strip_result_format_instruction(question)
|
|
84
|
+
schema = self._ensure_schema()
|
|
85
|
+
allowed_context = self._get_allowed_context(context, sql_question)
|
|
86
|
+
scope_hints = allowed_context.get("scopeHints", {})
|
|
87
|
+
if sampler is not None:
|
|
88
|
+
schema_context = retrieve_schema_context(schema, sql_question)
|
|
89
|
+
generator = SamplingSqlGenerator(
|
|
90
|
+
sampler=sampler,
|
|
91
|
+
default_limit=self.config.sql.default_limit,
|
|
92
|
+
max_limit=self.config.sql.max_limit,
|
|
93
|
+
)
|
|
94
|
+
generated = generator.generate(
|
|
95
|
+
question=sql_question,
|
|
96
|
+
schema_context=schema_context,
|
|
97
|
+
scope_hints=scope_hints,
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
schema_context = None
|
|
101
|
+
generator = RuleBasedSqlGenerator(
|
|
102
|
+
default_limit=self.config.sql.default_limit,
|
|
103
|
+
max_limit=self.config.sql.max_limit,
|
|
104
|
+
)
|
|
105
|
+
generated = generator.generate(
|
|
106
|
+
question=sql_question,
|
|
107
|
+
schema=schema,
|
|
108
|
+
scope_hints=scope_hints,
|
|
109
|
+
)
|
|
110
|
+
enforce_sql_rules(generated.sql, max_limit=self.config.sql.max_limit)
|
|
111
|
+
normalized_sql = self._validate_sql(context, question, generated.sql)
|
|
112
|
+
repaired_sql: str | None = None
|
|
113
|
+
if sampler is not None and normalized_sql is None and schema_context is not None:
|
|
114
|
+
repaired = generator.repair(
|
|
115
|
+
question=sql_question,
|
|
116
|
+
original_sql=generated.sql,
|
|
117
|
+
validation_error=self._last_validation_error,
|
|
118
|
+
schema_context=schema_context,
|
|
119
|
+
scope_hints=scope_hints,
|
|
120
|
+
)
|
|
121
|
+
repaired_sql = repaired.sql
|
|
122
|
+
enforce_sql_rules(repaired.sql, max_limit=self.config.sql.max_limit)
|
|
123
|
+
normalized_sql = self._validate_sql(context, question, repaired.sql, raise_on_error=True)
|
|
124
|
+
if normalized_sql is None:
|
|
125
|
+
raise RuntimeError("SQL validation failed")
|
|
126
|
+
result = self._execute_sql(context, question, normalized_sql)
|
|
127
|
+
return QueryResult(
|
|
128
|
+
answer=render_result(question, result, result_format),
|
|
129
|
+
generated_sql=generated.sql,
|
|
130
|
+
normalized_sql=normalized_sql,
|
|
131
|
+
repaired_sql=repaired_sql,
|
|
132
|
+
validation=self._last_validation_result,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _ensure_schema(self) -> dict[str, Any]:
|
|
136
|
+
cached_schema = self.schema_cache.load()
|
|
137
|
+
cached_version = str(cached_schema.get("schemaVersion")) if cached_schema and cached_schema.get("schemaVersion") else None
|
|
138
|
+
trace_id = new_trace_id("trace_schema")
|
|
139
|
+
try:
|
|
140
|
+
schema_response = self.mcp_client.get_schema(
|
|
141
|
+
trace_id=trace_id,
|
|
142
|
+
schema_version=cached_version,
|
|
143
|
+
refresh_reason="startup",
|
|
144
|
+
)
|
|
145
|
+
except Exception:
|
|
146
|
+
if cached_schema is not None:
|
|
147
|
+
self.audit_logger.log(
|
|
148
|
+
{
|
|
149
|
+
"traceId": trace_id,
|
|
150
|
+
"tool": "get_schema",
|
|
151
|
+
"schemaVersion": cached_version,
|
|
152
|
+
"fallback": "cached_schema",
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
return cached_schema
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
if schema_response.get("changed") is False and cached_schema is not None:
|
|
159
|
+
self.audit_logger.log(
|
|
160
|
+
{
|
|
161
|
+
"traceId": trace_id,
|
|
162
|
+
"tool": "get_schema",
|
|
163
|
+
"schemaVersion": cached_version,
|
|
164
|
+
"changed": False,
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
return cached_schema
|
|
168
|
+
|
|
169
|
+
if schema_response.get("changed") is False and cached_schema is None:
|
|
170
|
+
schema_response = self.mcp_client.get_schema(
|
|
171
|
+
trace_id=new_trace_id("trace_schema"),
|
|
172
|
+
schema_version=None,
|
|
173
|
+
refresh_reason="startup",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if "tables" in schema_response:
|
|
177
|
+
self.schema_cache.save(schema_response)
|
|
178
|
+
self.audit_logger.log(
|
|
179
|
+
{
|
|
180
|
+
"traceId": trace_id,
|
|
181
|
+
"tool": "get_schema",
|
|
182
|
+
"schemaVersion": schema_response.get("schemaVersion"),
|
|
183
|
+
"changed": schema_response.get("changed"),
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
return schema_response
|
|
187
|
+
|
|
188
|
+
if cached_schema is not None:
|
|
189
|
+
return cached_schema
|
|
190
|
+
raise RuntimeError("Sponge get_schema did not return tables and no local schema cache exists")
|
|
191
|
+
|
|
192
|
+
def _get_allowed_context(self, context: UserContext, question: str) -> dict[str, Any]:
|
|
193
|
+
trace_id = new_trace_id()
|
|
194
|
+
result = self.mcp_client.get_allowed_context(
|
|
195
|
+
user_id=context.user_id,
|
|
196
|
+
trace_id=trace_id,
|
|
197
|
+
session_id=context.session_id,
|
|
198
|
+
device_id=context.device_id,
|
|
199
|
+
question=question,
|
|
200
|
+
)
|
|
201
|
+
self.audit_logger.log(
|
|
202
|
+
{
|
|
203
|
+
"traceId": trace_id,
|
|
204
|
+
"sessionId": context.session_id,
|
|
205
|
+
"userId": context.user_id,
|
|
206
|
+
"question": question,
|
|
207
|
+
"tool": "get_allowed_context",
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
return result
|
|
211
|
+
|
|
212
|
+
def _validate_sql(
|
|
213
|
+
self,
|
|
214
|
+
context: UserContext,
|
|
215
|
+
question: str,
|
|
216
|
+
sql: str,
|
|
217
|
+
*,
|
|
218
|
+
raise_on_error: bool = False,
|
|
219
|
+
) -> str | None:
|
|
220
|
+
trace_id = new_trace_id()
|
|
221
|
+
result = self.mcp_client.validate_sql(
|
|
222
|
+
user_id=context.user_id,
|
|
223
|
+
trace_id=trace_id,
|
|
224
|
+
session_id=context.session_id,
|
|
225
|
+
sql=sql,
|
|
226
|
+
)
|
|
227
|
+
self._last_validation_result = result
|
|
228
|
+
self.audit_logger.log(
|
|
229
|
+
{
|
|
230
|
+
"traceId": trace_id,
|
|
231
|
+
"sessionId": context.session_id,
|
|
232
|
+
"userId": context.user_id,
|
|
233
|
+
"question": question,
|
|
234
|
+
"tool": "validate_sql",
|
|
235
|
+
"sqlHash": sql_hash(sql),
|
|
236
|
+
"validateResult": result,
|
|
237
|
+
"errorCode": result.get("error", {}).get("code") if isinstance(result.get("error"), dict) else None,
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
if not result.get("valid"):
|
|
241
|
+
error = result.get("error", {})
|
|
242
|
+
self._last_validation_error = error if isinstance(error, dict) else {"code": "UNKNOWN"}
|
|
243
|
+
code = error.get("code") if isinstance(error, dict) else "UNKNOWN"
|
|
244
|
+
if raise_on_error:
|
|
245
|
+
raise RuntimeError(f"SQL validation failed: {code}")
|
|
246
|
+
return None
|
|
247
|
+
return str(result.get("normalizedSql") or sql)
|
|
248
|
+
|
|
249
|
+
def _execute_sql(self, context: UserContext, question: str, sql: str) -> dict[str, Any]:
|
|
250
|
+
trace_id = new_trace_id()
|
|
251
|
+
result = self.mcp_client.execute_sql(
|
|
252
|
+
user_id=context.user_id,
|
|
253
|
+
trace_id=trace_id,
|
|
254
|
+
session_id=context.session_id,
|
|
255
|
+
sql=sql,
|
|
256
|
+
timeout_ms=self.config.sponge_mcp.timeout_ms,
|
|
257
|
+
)
|
|
258
|
+
self.audit_logger.log(
|
|
259
|
+
{
|
|
260
|
+
"traceId": trace_id,
|
|
261
|
+
"sessionId": context.session_id,
|
|
262
|
+
"userId": context.user_id,
|
|
263
|
+
"question": question,
|
|
264
|
+
"tool": "execute_sql",
|
|
265
|
+
"sqlHash": sql_hash(sql),
|
|
266
|
+
"executeResult": {
|
|
267
|
+
"success": result.get("success"),
|
|
268
|
+
"rowCount": result.get("rowCount"),
|
|
269
|
+
"elapsedMs": result.get("elapsedMs"),
|
|
270
|
+
},
|
|
271
|
+
"rowCount": result.get("rowCount"),
|
|
272
|
+
"elapsedMs": result.get("elapsedMs"),
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _strip_result_format_instruction(question: str) -> str:
|
|
279
|
+
cleaned = question
|
|
280
|
+
patterns = [
|
|
281
|
+
r"[,,。;;]?\s*输出\s*(?:yaml|html|markdown|md)\s*(?:格式)?",
|
|
282
|
+
r"[,,。;;]?\s*用\s*表格\s*展示",
|
|
283
|
+
r"[,,。;;]?\s*以\s*(?:yaml|html|markdown|md)\s*(?:格式)?\s*输出",
|
|
284
|
+
r"[,,。;;]?\s*return\s+(?:yaml|html|markdown|md)",
|
|
285
|
+
]
|
|
286
|
+
for pattern in patterns:
|
|
287
|
+
cleaned = re.sub(pattern, "", cleaned, flags=re.IGNORECASE)
|
|
288
|
+
return cleaned.strip() or question
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AuditLogger:
|
|
13
|
+
path: Path
|
|
14
|
+
enabled: bool = True
|
|
15
|
+
|
|
16
|
+
def log(self, event: dict[str, Any]) -> None:
|
|
17
|
+
if not self.enabled:
|
|
18
|
+
return
|
|
19
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
record = {
|
|
21
|
+
"occurredAt": datetime.now(timezone.utc).isoformat(),
|
|
22
|
+
**event,
|
|
23
|
+
}
|
|
24
|
+
with self.path.open("a", encoding="utf-8") as file:
|
|
25
|
+
file.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sql_hash(sql: str) -> str:
|
|
29
|
+
return hashlib.sha256(sql.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
PACKAGE_ROOT = Path.cwd()
|
|
9
|
+
DEFAULT_SPONGE_MCP_BASE_URL = "https://sponge-mcp.duliday.com"
|
|
10
|
+
DEFAULT_SPONGE_MCP_ACCESS_TOKEN = ""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class SpongeMcpConfig:
|
|
15
|
+
base_url: str
|
|
16
|
+
access_token: str
|
|
17
|
+
timeout_ms: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SchemaConfig:
|
|
22
|
+
cache_path: Path
|
|
23
|
+
refresh_on_start: bool
|
|
24
|
+
refresh_interval_minutes: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class SqlConfig:
|
|
29
|
+
max_repair_attempts: int
|
|
30
|
+
default_limit: int
|
|
31
|
+
max_limit: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class AuditConfig:
|
|
36
|
+
enabled: bool
|
|
37
|
+
log_path: Path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class AppConfig:
|
|
42
|
+
sponge_mcp: SpongeMcpConfig
|
|
43
|
+
schema: SchemaConfig
|
|
44
|
+
sql: SqlConfig
|
|
45
|
+
audit: AuditConfig
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config() -> AppConfig:
|
|
49
|
+
return AppConfig(
|
|
50
|
+
sponge_mcp=SpongeMcpConfig(
|
|
51
|
+
base_url=os.getenv("SPONGE_MCP_BASE_URL", DEFAULT_SPONGE_MCP_BASE_URL).rstrip("/"),
|
|
52
|
+
access_token=os.getenv("SPONGE_MCP_ACCESS_TOKEN", DEFAULT_SPONGE_MCP_ACCESS_TOKEN),
|
|
53
|
+
timeout_ms=30000,
|
|
54
|
+
),
|
|
55
|
+
schema=SchemaConfig(
|
|
56
|
+
cache_path=PACKAGE_ROOT / "data/sponge-schema-cache.json",
|
|
57
|
+
refresh_on_start=True,
|
|
58
|
+
refresh_interval_minutes=1440,
|
|
59
|
+
),
|
|
60
|
+
sql=SqlConfig(
|
|
61
|
+
max_repair_attempts=2,
|
|
62
|
+
default_limit=100,
|
|
63
|
+
max_limit=500,
|
|
64
|
+
),
|
|
65
|
+
audit=AuditConfig(
|
|
66
|
+
enabled=True,
|
|
67
|
+
log_path=PACKAGE_ROOT / "logs/octopus-sponge-audit.log",
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_config_from_env() -> AppConfig:
|
|
73
|
+
return load_config()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class UserContext:
|
|
9
|
+
user_id: int
|
|
10
|
+
session_id: str
|
|
11
|
+
device_id: str | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def new_trace_id(prefix: str = "trace") -> str:
|
|
15
|
+
return f"{prefix}_{uuid4().hex}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def new_session_id() -> str:
|
|
19
|
+
return f"session_{uuid4().hex}"
|
|
20
|
+
|