@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.
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Any, Callable
7
+
8
+ from .prompt_builder import NL2SQL_SYSTEM_PROMPT, build_nl2sql_prompt, build_sql_repair_prompt
9
+ from .schema_context_retriever import SchemaContext
10
+ from .sql_generator import GeneratedSql, SqlGenerationError
11
+
12
+
13
+ Sampler = Callable[[str, str], str]
14
+
15
+
16
+ @dataclass
17
+ class SamplingSqlGenerator:
18
+ sampler: Sampler
19
+ default_limit: int
20
+ max_limit: int
21
+
22
+ def generate(
23
+ self,
24
+ *,
25
+ question: str,
26
+ schema_context: SchemaContext,
27
+ scope_hints: dict[str, Any],
28
+ ) -> GeneratedSql:
29
+ prompt = build_nl2sql_prompt(
30
+ question=question,
31
+ schema_context=schema_context,
32
+ scope_hints=scope_hints,
33
+ default_limit=self.default_limit,
34
+ max_limit=self.max_limit,
35
+ )
36
+ return _parse_generated_sql(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
37
+
38
+ def repair(
39
+ self,
40
+ *,
41
+ question: str,
42
+ original_sql: str,
43
+ validation_error: dict[str, Any],
44
+ schema_context: SchemaContext,
45
+ scope_hints: dict[str, Any],
46
+ ) -> GeneratedSql:
47
+ prompt = build_sql_repair_prompt(
48
+ question=question,
49
+ original_sql=original_sql,
50
+ validation_error=validation_error,
51
+ schema_context=schema_context,
52
+ scope_hints=scope_hints,
53
+ max_limit=self.max_limit,
54
+ )
55
+ return _parse_generated_sql(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
56
+
57
+
58
+ def _parse_generated_sql(text: str) -> GeneratedSql:
59
+ try:
60
+ payload = _load_json_object(text)
61
+ except SqlGenerationError:
62
+ sql = _extract_sql_text(text)
63
+ if sql is None:
64
+ raise
65
+ return GeneratedSql(sql=sql, tables=[], placeholders=[])
66
+ sql = payload.get("sql")
67
+ if not isinstance(sql, str) or not sql.strip():
68
+ fallback_sql = _extract_sql_text(text)
69
+ if fallback_sql is None:
70
+ raise SqlGenerationError("LLM output must contain sql")
71
+ return GeneratedSql(sql=fallback_sql, tables=[], placeholders=[])
72
+ tables = payload.get("tables") if isinstance(payload.get("tables"), list) else []
73
+ placeholders = payload.get("placeholders") if isinstance(payload.get("placeholders"), list) else []
74
+ return GeneratedSql(
75
+ sql=sql.strip(),
76
+ tables=[str(table) for table in tables],
77
+ placeholders=[str(placeholder) for placeholder in placeholders],
78
+ )
79
+
80
+
81
+ def _load_json_object(text: str) -> dict[str, Any]:
82
+ cleaned = text.strip()
83
+ if cleaned.startswith("```"):
84
+ cleaned = cleaned.strip("`")
85
+ if cleaned.lower().startswith("json"):
86
+ cleaned = cleaned[4:].strip()
87
+ try:
88
+ payload = json.loads(cleaned)
89
+ except json.JSONDecodeError as exc:
90
+ start = cleaned.find("{")
91
+ end = cleaned.rfind("}")
92
+ if start == -1 or end == -1 or end <= start:
93
+ raise SqlGenerationError("LLM output is not valid JSON") from exc
94
+ payload = json.loads(cleaned[start : end + 1])
95
+ if not isinstance(payload, dict):
96
+ raise SqlGenerationError("LLM output must be a JSON object")
97
+ return payload
98
+
99
+
100
+ def _extract_sql_text(text: str) -> str | None:
101
+ cleaned = text.strip()
102
+ fenced = re.search(r"```(?:sql)?\s*(.*?)```", cleaned, flags=re.IGNORECASE | re.DOTALL)
103
+ if fenced:
104
+ cleaned = fenced.group(1).strip()
105
+ match = re.search(r"\bselect\b.*", cleaned, flags=re.IGNORECASE | re.DOTALL)
106
+ if not match:
107
+ return None
108
+ sql = match.group(0).strip()
109
+ sql = re.split(r"\n\s*(?:解释|说明|备注)[::]", sql, maxsplit=1)[0].strip()
110
+ if sql.endswith(";"):
111
+ sql = sql[:-1].strip()
112
+ return sql
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from .agent import SpongeAgent
6
+ from .config import load_config_from_env
7
+
8
+
9
+ def main() -> None:
10
+ parser = argparse.ArgumentParser(prog="octopus-sponge")
11
+ parser.add_argument("question")
12
+ parser.add_argument("--user-id", type=int, required=True)
13
+ parser.add_argument("--session-id")
14
+ parser.add_argument("--device-id")
15
+ args = parser.parse_args()
16
+
17
+ config = load_config_from_env()
18
+ agent = SpongeAgent.from_config(config)
19
+ print(
20
+ agent.answer(
21
+ question=args.question,
22
+ user_id=args.user_id,
23
+ session_id=args.session_id,
24
+ device_id=args.device_id,
25
+ )
26
+ )
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ class SpongeMcpError(RuntimeError):
11
+ pass
12
+
13
+
14
+ @dataclass
15
+ class SpongeMcpClient:
16
+ base_url: str
17
+ access_token: str
18
+ timeout_ms: int = 30000
19
+
20
+ def get_schema(
21
+ self,
22
+ *,
23
+ trace_id: str,
24
+ schema_version: str | None,
25
+ refresh_reason: str,
26
+ ) -> dict[str, Any]:
27
+ payload = {
28
+ "traceId": trace_id,
29
+ "schemaVersion": schema_version,
30
+ "refreshReason": refresh_reason,
31
+ }
32
+ return self._post("get_schema", payload)
33
+
34
+ def get_allowed_context(
35
+ self,
36
+ *,
37
+ user_id: int,
38
+ trace_id: str,
39
+ session_id: str,
40
+ question: str,
41
+ device_id: str | None = None,
42
+ ) -> dict[str, Any]:
43
+ payload = {
44
+ "userId": user_id,
45
+ "traceId": trace_id,
46
+ "sessionId": session_id,
47
+ "deviceId": device_id,
48
+ "question": question,
49
+ }
50
+ return self._post("get_allowed_context", payload)
51
+
52
+ def validate_sql(
53
+ self,
54
+ *,
55
+ user_id: int,
56
+ trace_id: str,
57
+ session_id: str,
58
+ sql: str,
59
+ ) -> dict[str, Any]:
60
+ payload = {
61
+ "userId": user_id,
62
+ "traceId": trace_id,
63
+ "sessionId": session_id,
64
+ "sql": sql,
65
+ }
66
+ return self._post("validate_sql", payload)
67
+
68
+ def execute_sql(
69
+ self,
70
+ *,
71
+ user_id: int,
72
+ trace_id: str,
73
+ session_id: str,
74
+ sql: str,
75
+ timeout_ms: int | None = None,
76
+ ) -> dict[str, Any]:
77
+ payload = {
78
+ "userId": user_id,
79
+ "traceId": trace_id,
80
+ "sessionId": session_id,
81
+ "sql": sql,
82
+ "timeoutMs": timeout_ms or self.timeout_ms,
83
+ }
84
+ return self._post("execute_sql", payload)
85
+
86
+ def _post(self, tool_name: str, payload: dict[str, Any]) -> dict[str, Any]:
87
+ body = json.dumps({key: value for key, value in payload.items() if value is not None}).encode(
88
+ "utf-8"
89
+ )
90
+ request = urllib.request.Request(
91
+ url=f"{self.base_url}/mcp/tools/{tool_name}",
92
+ data=body,
93
+ method="POST",
94
+ headers={
95
+ "Content-Type": "application/json",
96
+ "Authorization": f"Bearer {self.access_token}",
97
+ },
98
+ )
99
+ try:
100
+ with urllib.request.urlopen(request, timeout=self.timeout_ms / 1000) as response:
101
+ data = json.loads(response.read().decode("utf-8"))
102
+ except urllib.error.HTTPError as exc:
103
+ message = exc.read().decode("utf-8", errors="replace")
104
+ if tool_name == "validate_sql":
105
+ try:
106
+ error_payload = json.loads(message)
107
+ except json.JSONDecodeError:
108
+ error_payload = None
109
+ if isinstance(error_payload, dict) and isinstance(error_payload.get("error"), dict):
110
+ return {"valid": False, "error": error_payload["error"]}
111
+ raise SpongeMcpError(f"MCP {tool_name} failed: HTTP {exc.code} {message}") from exc
112
+ except urllib.error.URLError as exc:
113
+ raise SpongeMcpError(f"MCP {tool_name} failed: {exc.reason}") from exc
114
+ if not isinstance(data, dict):
115
+ raise SpongeMcpError(f"MCP {tool_name} returned non-object JSON")
116
+ return data
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from .schema_context_retriever import SchemaContext
7
+
8
+
9
+ NL2SQL_SYSTEM_PROMPT = """你是 Octopus 的 Sponge 数据查询 SQL 生成器。
10
+ 你只能基于提供的 schema context 生成 MySQL SELECT 查询。
11
+ SQL 必须使用 :scope.project_ids 和 :scope.brand_ids 表示用户可访问范围。
12
+ 不得硬编码真实 projectId 或 brandId。
13
+ 不得查询 auth 库、密码、令牌、密钥、凭证字段。
14
+ 不得生成 INSERT、UPDATE、DELETE、DDL 或多语句 SQL。
15
+ 必须包含 LIMIT,且 LIMIT 不超过 500。
16
+ 只输出 JSON,不输出解释文本。"""
17
+
18
+
19
+ def build_nl2sql_prompt(
20
+ *,
21
+ question: str,
22
+ schema_context: SchemaContext,
23
+ scope_hints: dict[str, Any],
24
+ default_limit: int,
25
+ max_limit: int,
26
+ ) -> str:
27
+ payload = {
28
+ "question": question,
29
+ "scopeHints": scope_hints,
30
+ "limits": {"defaultLimit": default_limit, "maxLimit": max_limit},
31
+ "schemaContext": {
32
+ "tables": schema_context.tables,
33
+ "joins": schema_context.joins,
34
+ "glossary": schema_context.glossary,
35
+ "metrics": schema_context.metrics,
36
+ "examples": schema_context.examples,
37
+ },
38
+ "outputSchema": {
39
+ "sql": "SELECT ... LIMIT 100",
40
+ "tables": ["table_name"],
41
+ "placeholders": [":scope.project_ids", ":scope.brand_ids"],
42
+ },
43
+ }
44
+ return json.dumps(payload, ensure_ascii=False, indent=2)
45
+
46
+
47
+ def build_sql_repair_prompt(
48
+ *,
49
+ question: str,
50
+ original_sql: str,
51
+ validation_error: dict[str, Any],
52
+ schema_context: SchemaContext,
53
+ scope_hints: dict[str, Any],
54
+ max_limit: int,
55
+ ) -> str:
56
+ payload = {
57
+ "task": "repair_sql",
58
+ "question": question,
59
+ "originalSql": original_sql,
60
+ "validationError": validation_error,
61
+ "scopeHints": scope_hints,
62
+ "limits": {"maxLimit": max_limit},
63
+ "schemaContext": {
64
+ "tables": schema_context.tables,
65
+ "joins": schema_context.joins,
66
+ "glossary": schema_context.glossary,
67
+ "metrics": schema_context.metrics,
68
+ "examples": schema_context.examples,
69
+ },
70
+ "outputSchema": {
71
+ "sql": "SELECT ... LIMIT 100",
72
+ "tables": ["table_name"],
73
+ "placeholders": [":scope.project_ids", ":scope.brand_ids"],
74
+ },
75
+ }
76
+ return json.dumps(payload, ensure_ascii=False, indent=2)
77
+
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ SUPPORTED_RESULT_FORMATS = {"text", "yaml", "md", "markdown", "html"}
9
+
10
+
11
+ def normalize_result_format(question: str, result_format: str | None = None) -> str:
12
+ if result_format:
13
+ normalized = result_format.lower().strip()
14
+ if normalized not in SUPPORTED_RESULT_FORMATS:
15
+ raise ValueError(f"Unsupported resultFormat: {result_format}")
16
+ return "markdown" if normalized == "md" else normalized
17
+ lowered = question.lower()
18
+ if "yaml" in lowered:
19
+ return "yaml"
20
+ if "html" in lowered:
21
+ return "html"
22
+ if "markdown" in lowered or " md" in lowered or "表格" in question:
23
+ return "markdown"
24
+ return "text"
25
+
26
+
27
+ def render_result(question: str, result: dict[str, Any], result_format: str | None = None) -> str:
28
+ output_format = normalize_result_format(question, result_format)
29
+ if not result.get("success", True):
30
+ return _format_failure(output_format)
31
+ row_count = int(result.get("rowCount", len(result.get("rows", []))))
32
+ rows = result.get("rows", [])
33
+ columns = result.get("columns", [])
34
+ if row_count == 0:
35
+ return _format_empty(output_format)
36
+ sample = rows[:5] if isinstance(rows, list) else []
37
+ if output_format == "yaml":
38
+ return _render_yaml(question, row_count, bool(result.get("limited", False)), sample)
39
+ if output_format == "markdown":
40
+ return _render_markdown(question, row_count, bool(result.get("limited", False)), sample, columns)
41
+ if output_format == "html":
42
+ return _render_html(question, row_count, bool(result.get("limited", False)), sample, columns)
43
+ return _render_text(question, row_count, bool(result.get("limited", False)), sample, columns)
44
+
45
+
46
+ def _format_failure(output_format: str) -> str:
47
+ if output_format == "yaml":
48
+ return "success: false\nmessage: 查询失败。\n"
49
+ if output_format == "markdown":
50
+ return "查询失败。"
51
+ if output_format == "html":
52
+ return "<p>查询失败。</p>"
53
+ return "查询失败。"
54
+
55
+
56
+ def _format_empty(output_format: str) -> str:
57
+ if output_format == "yaml":
58
+ return "success: true\nrowCount: 0\nrows: []\nmessage: 没有查询到符合条件的数据。\n"
59
+ if output_format == "markdown":
60
+ return "返回 0 行。\n\n没有查询到符合条件的数据。"
61
+ if output_format == "html":
62
+ return "<p>没有查询到符合条件的数据。</p>"
63
+ return "没有查询到符合条件的数据。"
64
+
65
+
66
+ def _render_yaml(question: str, row_count: int, limited: bool, rows: list[Any]) -> str:
67
+ lines = [
68
+ f"rowCount: {row_count}",
69
+ f"limited: {_yaml_bool(limited)}",
70
+ "rows:",
71
+ ]
72
+ for row in rows:
73
+ lines.append(" -")
74
+ if isinstance(row, dict):
75
+ for key, value in row.items():
76
+ lines.append(f" {key}: {json.dumps(value, ensure_ascii=False)}")
77
+ else:
78
+ lines.append(f" value: {json.dumps(row, ensure_ascii=False)}")
79
+ return "\n".join(lines) + "\n"
80
+
81
+
82
+ def _render_markdown(
83
+ question: str,
84
+ row_count: int,
85
+ limited: bool,
86
+ rows: list[Any],
87
+ columns: Any,
88
+ ) -> str:
89
+ table_columns = _column_names(columns, rows)
90
+ lines = [f"返回 {row_count} 行。", f"limited: {str(limited).lower()}"]
91
+ if not table_columns:
92
+ lines.extend(["", "```json", json.dumps(rows, ensure_ascii=False, indent=2), "```"])
93
+ return "\n".join(lines)
94
+ lines.extend(["", "|" + "|".join(_escape_markdown(column) for column in table_columns) + "|"])
95
+ lines.append("|" + "|".join("---" for _ in table_columns) + "|")
96
+ for row in rows:
97
+ lines.append("|" + "|".join(_escape_markdown(_cell_value(row, column)) for column in table_columns) + "|")
98
+ return "\n".join(lines)
99
+
100
+
101
+ def _render_html(
102
+ question: str,
103
+ row_count: int,
104
+ limited: bool,
105
+ rows: list[Any],
106
+ columns: Any,
107
+ ) -> str:
108
+ table_columns = _column_names(columns, rows)
109
+ parts = [
110
+ "<section>",
111
+ f"<p>返回 {row_count} 行。limited: {str(limited).lower()}</p>",
112
+ ]
113
+ if table_columns:
114
+ parts.append("<table>")
115
+ parts.append("<thead><tr>" + "".join(f"<th>{html.escape(column)}</th>" for column in table_columns) + "</tr></thead>")
116
+ parts.append("<tbody>")
117
+ for row in rows:
118
+ parts.append("<tr>" + "".join(f"<td>{html.escape(_cell_value(row, column))}</td>" for column in table_columns) + "</tr>")
119
+ parts.append("</tbody></table>")
120
+ else:
121
+ parts.append(f"<pre>{html.escape(json.dumps(rows, ensure_ascii=False, indent=2))}</pre>")
122
+ parts.append("</section>")
123
+ return "".join(parts)
124
+
125
+
126
+ def _render_text(
127
+ question: str,
128
+ row_count: int,
129
+ limited: bool,
130
+ rows: list[Any],
131
+ columns: Any,
132
+ ) -> str:
133
+ visible_columns = _text_columns(question, columns, rows)
134
+ if not visible_columns:
135
+ return f"查询到 {row_count} 条结果。"
136
+
137
+ noun = _result_noun(question)
138
+ lines = [f"查询到 {row_count} 个{noun}:", ""]
139
+ include_id = _question_requests_id(question)
140
+ for index, row in enumerate(rows, start=1):
141
+ text = _format_text_row(row, visible_columns, include_id)
142
+ if text:
143
+ lines.append(f"{index}. {text}")
144
+ if limited:
145
+ lines.append("")
146
+ lines.append("结果较多,已按当前限制返回部分数据。")
147
+ return "\n".join(lines)
148
+
149
+
150
+ def _text_columns(question: str, columns: Any, rows: list[Any]) -> list[str]:
151
+ names = _column_names(columns, rows)
152
+ if not names:
153
+ return []
154
+ allow_id = _question_requests_id(question)
155
+ visible = [name for name in names if allow_id or not _is_id_column(name)]
156
+ name_columns = [name for name in visible if _is_name_column(name)]
157
+ if allow_id:
158
+ return visible
159
+ return name_columns or visible
160
+
161
+
162
+ def _format_text_row(row: Any, columns: list[str], include_id: bool) -> str:
163
+ if not isinstance(row, dict):
164
+ return str(row)
165
+ name_column = next((column for column in columns if _is_name_column(column)), None)
166
+ id_column = next((column for column in columns if _is_id_column(column)), None)
167
+ name_value = _cell_value(row, name_column) if name_column else ""
168
+ id_value = _cell_value(row, id_column) if id_column else ""
169
+ if include_id and name_value and id_value:
170
+ return f"{name_value}(ID:{id_value})"
171
+ values = [_cell_value(row, column) for column in columns]
172
+ values = [value for value in values if value]
173
+ return ",".join(values)
174
+
175
+
176
+ def _question_requests_id(question: str) -> bool:
177
+ lowered = question.lower()
178
+ return "id" in lowered or "编号" in question or "主键" in question
179
+
180
+
181
+ def _is_id_column(column: str) -> bool:
182
+ lowered = column.lower()
183
+ return lowered == "id" or lowered.endswith("_id") or lowered.endswith(".id")
184
+
185
+
186
+ def _is_name_column(column: str) -> bool:
187
+ lowered = column.lower()
188
+ return lowered == "name" or lowered.endswith("_name") or lowered.endswith(".name") or "名称" in column
189
+
190
+
191
+ def _result_noun(question: str) -> str:
192
+ if "品牌" in question:
193
+ return "品牌"
194
+ if "岗位" in question:
195
+ return "岗位"
196
+ if "门店" in question:
197
+ return "门店"
198
+ if "项目" in question:
199
+ return "项目"
200
+ return "结果"
201
+
202
+
203
+ def _column_names(columns: Any, rows: list[Any]) -> list[str]:
204
+ names: list[str] = []
205
+ if isinstance(columns, list):
206
+ for column in columns:
207
+ if isinstance(column, dict) and column.get("name"):
208
+ names.append(str(column["name"]))
209
+ elif isinstance(column, str):
210
+ names.append(column)
211
+ if not names and rows and isinstance(rows[0], dict):
212
+ names = [str(key) for key in rows[0].keys()]
213
+ return names
214
+
215
+
216
+ def _cell_value(row: Any, column: str) -> str:
217
+ if isinstance(row, dict):
218
+ return str(row.get(column, ""))
219
+ return str(row)
220
+
221
+
222
+ def _escape_markdown(value: str) -> str:
223
+ return value.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
224
+
225
+
226
+ def _yaml_bool(value: bool) -> str:
227
+ return "true" if value else "false"