@roll-agent/octopus-agent 0.1.2 → 0.2.0
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 +26 -125
- package/SKILL.md +27 -102
- package/package.json +12 -18
- package/references/env.yaml +1 -22
- package/scripts/start-octopus-agent.js +1 -125
- package/src/config.js +13 -0
- package/src/context.js +5 -0
- package/src/mcp-client.js +80 -0
- package/src/orchestrator.js +57 -0
- package/src/server-core.js +67 -0
- package/src/server.js +10 -0
- package/src/stdio.js +29 -0
- package/octopus_skill/__init__.py +0 -11
- package/pyproject.toml +0 -19
- package/scripts/refresh_dictionary.py +0 -67
- package/scripts/verify_binding.py +0 -47
- package/src/octopus_skill/__init__.py +0 -5
- package/src/octopus_skill/__main__.py +0 -4
- package/src/octopus_skill/_version.py +0 -3
- package/src/octopus_skill/agent.py +0 -4971
- package/src/octopus_skill/audit_logger.py +0 -29
- package/src/octopus_skill/config.py +0 -129
- package/src/octopus_skill/context.py +0 -17
- package/src/octopus_skill/device_info.py +0 -7
- package/src/octopus_skill/exporter.py +0 -66
- package/src/octopus_skill/llm_result_renderer.py +0 -101
- package/src/octopus_skill/llm_sql_generator.py +0 -197
- package/src/octopus_skill/main.py +0 -26
- package/src/octopus_skill/mcp_client.py +0 -114
- package/src/octopus_skill/octopus_run.py +0 -752
- package/src/octopus_skill/prompt_builder.py +0 -519
- package/src/octopus_skill/question_analyzer.py +0 -399
- package/src/octopus_skill/result_renderer.py +0 -367
- package/src/octopus_skill/schema_cache.py +0 -49
- package/src/octopus_skill/schema_context_retriever.py +0 -1053
- package/src/octopus_skill/sql_generator.py +0 -2788
|
@@ -1,29 +0,0 @@
|
|
|
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()
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
from dataclasses import dataclass, field
|
|
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 EntityBindingConfig:
|
|
29
|
-
# Compute bindings and log them, but do not change SQL generation yet.
|
|
30
|
-
shadow_mode: bool
|
|
31
|
-
# Entity types whose bindings are allowed to influence SQL (live rollout).
|
|
32
|
-
# In shadow_mode this is ignored.
|
|
33
|
-
enabled_entity_types: tuple[str, ...]
|
|
34
|
-
min_fuzzy_score: float
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@dataclass(frozen=True)
|
|
38
|
-
class SqlConfig:
|
|
39
|
-
max_repair_attempts: int
|
|
40
|
-
default_limit: int
|
|
41
|
-
max_limit: int
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@dataclass(frozen=True)
|
|
45
|
-
class AuditConfig:
|
|
46
|
-
enabled: bool
|
|
47
|
-
log_path: Path
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
@dataclass(frozen=True)
|
|
51
|
-
class SamplingConfig:
|
|
52
|
-
max_tokens: int
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@dataclass(frozen=True)
|
|
56
|
-
class AppConfig:
|
|
57
|
-
sponge_mcp: SpongeMcpConfig
|
|
58
|
-
schema: SchemaConfig
|
|
59
|
-
sql: SqlConfig
|
|
60
|
-
audit: AuditConfig
|
|
61
|
-
sampling: SamplingConfig = field(default_factory=lambda: SamplingConfig(max_tokens=4096))
|
|
62
|
-
binding: EntityBindingConfig | None = None
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def load_config() -> AppConfig:
|
|
66
|
-
return AppConfig(
|
|
67
|
-
sponge_mcp=SpongeMcpConfig(
|
|
68
|
-
base_url=os.getenv("SPONGE_MCP_BASE_URL", DEFAULT_SPONGE_MCP_BASE_URL).rstrip("/"),
|
|
69
|
-
access_token=os.getenv("SPONGE_MCP_ACCESS_TOKEN", DEFAULT_SPONGE_MCP_ACCESS_TOKEN),
|
|
70
|
-
timeout_ms=30000,
|
|
71
|
-
),
|
|
72
|
-
schema=SchemaConfig(
|
|
73
|
-
cache_path=PACKAGE_ROOT / "data/sponge-schema-cache.json",
|
|
74
|
-
refresh_on_start=True,
|
|
75
|
-
refresh_interval_minutes=1440,
|
|
76
|
-
),
|
|
77
|
-
sql=SqlConfig(
|
|
78
|
-
max_repair_attempts=2,
|
|
79
|
-
default_limit=50,
|
|
80
|
-
max_limit=500,
|
|
81
|
-
),
|
|
82
|
-
audit=AuditConfig(
|
|
83
|
-
enabled=True,
|
|
84
|
-
log_path=PACKAGE_ROOT / "logs/octopus-sponge-audit.log",
|
|
85
|
-
),
|
|
86
|
-
sampling=SamplingConfig(
|
|
87
|
-
max_tokens=_env_int("OCTOPUS_SAMPLING_MAX_TOKENS", default=4096),
|
|
88
|
-
),
|
|
89
|
-
binding=None,
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def load_config_from_env() -> AppConfig:
|
|
94
|
-
return load_config()
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def _env_bool(name: str, *, default: bool) -> bool:
|
|
98
|
-
value = os.getenv(name)
|
|
99
|
-
if value is None or value == "":
|
|
100
|
-
return default
|
|
101
|
-
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _env_csv(name: str, *, default: tuple[str, ...] = ()) -> tuple[str, ...]:
|
|
105
|
-
value = os.getenv(name)
|
|
106
|
-
if value is None:
|
|
107
|
-
return default
|
|
108
|
-
return tuple(item.strip() for item in value.split(",") if item.strip())
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _env_float(name: str, *, default: float) -> float:
|
|
112
|
-
value = os.getenv(name)
|
|
113
|
-
if value is None or value == "":
|
|
114
|
-
return default
|
|
115
|
-
try:
|
|
116
|
-
return float(value)
|
|
117
|
-
except ValueError:
|
|
118
|
-
return default
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def _env_int(name: str, *, default: int) -> int:
|
|
122
|
-
value = os.getenv(name)
|
|
123
|
-
if value is None or value == "":
|
|
124
|
-
return default
|
|
125
|
-
try:
|
|
126
|
-
parsed = int(value)
|
|
127
|
-
except ValueError:
|
|
128
|
-
return default
|
|
129
|
-
return parsed if parsed > 0 else default
|
|
@@ -1,17 +0,0 @@
|
|
|
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
|
-
session_id: str
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def new_trace_id(prefix: str = "trace") -> str:
|
|
13
|
-
return f"{prefix}_{uuid4().hex}"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def new_session_id() -> str:
|
|
17
|
-
return f"session_{uuid4().hex}"
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import csv
|
|
4
|
-
import os
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from datetime import datetime
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
9
|
-
from uuid import uuid4
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
DEFAULT_EXPORT_DIR = Path("/tmp/octopus-agent-exports")
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@dataclass(frozen=True)
|
|
16
|
-
class CsvExport:
|
|
17
|
-
path: Path
|
|
18
|
-
row_count: int
|
|
19
|
-
column_count: int
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def export_result_to_csv(result: dict[str, Any], *, prefix: str = "octopus_export") -> CsvExport | None:
|
|
23
|
-
rows = result.get("rows")
|
|
24
|
-
if not isinstance(rows, list) or not rows:
|
|
25
|
-
return None
|
|
26
|
-
columns = _column_names(result.get("columns"), rows)
|
|
27
|
-
if not columns:
|
|
28
|
-
return None
|
|
29
|
-
export_dir = Path(os.getenv("OCTOPUS_EXPORT_DIR") or DEFAULT_EXPORT_DIR)
|
|
30
|
-
export_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
-
path = export_dir / _csv_filename(prefix)
|
|
32
|
-
with path.open("w", newline="", encoding="utf-8-sig") as handle:
|
|
33
|
-
writer = csv.writer(handle)
|
|
34
|
-
writer.writerow(columns)
|
|
35
|
-
for row in rows:
|
|
36
|
-
writer.writerow([_cell_value(row, column) for column in columns])
|
|
37
|
-
return CsvExport(path=path, row_count=len(rows), column_count=len(columns))
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _csv_filename(prefix: str) -> str:
|
|
41
|
-
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
42
|
-
suffix = uuid4().hex[:8]
|
|
43
|
-
safe_prefix = "".join(char if char.isalnum() or char in {"_", "-"} else "_" for char in prefix).strip("_")
|
|
44
|
-
return f"{safe_prefix or 'octopus_export'}_{timestamp}_{suffix}.csv"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _column_names(columns: Any, rows: list[Any]) -> list[str]:
|
|
48
|
-
names: list[str] = []
|
|
49
|
-
if isinstance(columns, list):
|
|
50
|
-
for column in columns:
|
|
51
|
-
if isinstance(column, dict) and column.get("name"):
|
|
52
|
-
names.append(str(column["name"]))
|
|
53
|
-
elif isinstance(column, str):
|
|
54
|
-
names.append(column)
|
|
55
|
-
if not names and rows and isinstance(rows[0], dict):
|
|
56
|
-
names = [str(key) for key in rows[0].keys()]
|
|
57
|
-
return names
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _cell_value(row: Any, column: str) -> str:
|
|
61
|
-
if not isinstance(row, dict):
|
|
62
|
-
return str(row)
|
|
63
|
-
value = row.get(column, "")
|
|
64
|
-
if value is None:
|
|
65
|
-
return ""
|
|
66
|
-
return str(value)
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
from .llm_sql_generator import Sampler
|
|
7
|
-
from .prompt_builder import filter_agent_instructions_by_stage, format_numbered_instruction_lines
|
|
8
|
-
from .result_renderer import RESULT_ROW_DISPLAY_LIMIT, normalize_result_format, render_result
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
RESULT_RENDER_SYSTEM_PROMPT = """你是丸子Agent(Octopus Agent)的查询结果渲染专家。
|
|
12
|
-
业务规则的唯一来源是 payload 中的 mandatoryAgentInstructions;必须逐条遵守,不得使用训练记忆中的业务口径替代 schema 要求。
|
|
13
|
-
根据用户问题、outputFormat 和 result 数据,输出最终用户可见答复。
|
|
14
|
-
直接输出渲染后的文本,不要 JSON 包装,不要解释渲染过程。"""
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def render_result_with_llm(
|
|
18
|
-
*,
|
|
19
|
-
question: str,
|
|
20
|
-
result: dict[str, Any],
|
|
21
|
-
agent_instructions: list[dict[str, Any]],
|
|
22
|
-
sampler: Sampler,
|
|
23
|
-
result_format: str | None = None,
|
|
24
|
-
) -> str:
|
|
25
|
-
output_format = normalize_result_format(question, result_format)
|
|
26
|
-
if not result.get("success", True):
|
|
27
|
-
return render_result(question, result, result_format)
|
|
28
|
-
|
|
29
|
-
if _should_render_detail_rows_locally(result):
|
|
30
|
-
local_format = "markdown" if output_format == "text" else output_format
|
|
31
|
-
return render_result(question, result, local_format)
|
|
32
|
-
|
|
33
|
-
instruction_lines = format_numbered_instruction_lines(
|
|
34
|
-
filter_agent_instructions_by_stage(agent_instructions, "after")
|
|
35
|
-
)
|
|
36
|
-
row_count = int(result.get("rowCount", len(result.get("rows", []))))
|
|
37
|
-
rows = result.get("rows", [])
|
|
38
|
-
sample = rows[:RESULT_ROW_DISPLAY_LIMIT] if isinstance(rows, list) else []
|
|
39
|
-
payload = {
|
|
40
|
-
"mandatoryAgentInstructions": instruction_lines,
|
|
41
|
-
"instruction": "按 mandatoryAgentInstructions 渲染 result,并匹配 outputFormat。",
|
|
42
|
-
"question": question,
|
|
43
|
-
"outputFormat": output_format,
|
|
44
|
-
"result": {
|
|
45
|
-
"rowCount": row_count,
|
|
46
|
-
"limited": bool(result.get("limited", False)),
|
|
47
|
-
"columns": result.get("columns", []),
|
|
48
|
-
"rows": sample,
|
|
49
|
-
},
|
|
50
|
-
}
|
|
51
|
-
try:
|
|
52
|
-
rendered = sampler(RESULT_RENDER_SYSTEM_PROMPT, json.dumps(payload, ensure_ascii=False)).strip()
|
|
53
|
-
except Exception:
|
|
54
|
-
return render_result(question, result, result_format)
|
|
55
|
-
if not rendered:
|
|
56
|
-
return render_result(question, result, result_format)
|
|
57
|
-
return rendered
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _should_render_detail_rows_locally(result: dict[str, Any]) -> bool:
|
|
61
|
-
try:
|
|
62
|
-
row_count = int(result.get("rowCount", len(result.get("rows", []))))
|
|
63
|
-
except (TypeError, ValueError):
|
|
64
|
-
row_count = 0
|
|
65
|
-
rows = result.get("rows", [])
|
|
66
|
-
if row_count <= 0 or not isinstance(rows, list) or not rows:
|
|
67
|
-
return False
|
|
68
|
-
return True
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _result_column_names(columns: Any, rows: list[Any]) -> list[str]:
|
|
72
|
-
names: list[str] = []
|
|
73
|
-
if isinstance(columns, list):
|
|
74
|
-
for column in columns:
|
|
75
|
-
if isinstance(column, dict) and column.get("name"):
|
|
76
|
-
names.append(str(column["name"]))
|
|
77
|
-
elif isinstance(column, str):
|
|
78
|
-
names.append(column)
|
|
79
|
-
if not names and rows and isinstance(rows[0], dict):
|
|
80
|
-
names = [str(key) for key in rows[0].keys()]
|
|
81
|
-
return names
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _is_aggregate_column_name(column: str) -> bool:
|
|
85
|
-
lowered = column.lower()
|
|
86
|
-
aggregate_terms = (
|
|
87
|
-
"count",
|
|
88
|
-
"sum",
|
|
89
|
-
"avg",
|
|
90
|
-
"min",
|
|
91
|
-
"max",
|
|
92
|
-
"数量",
|
|
93
|
-
"总数",
|
|
94
|
-
"人数",
|
|
95
|
-
"次数",
|
|
96
|
-
"平均",
|
|
97
|
-
"最大",
|
|
98
|
-
"最小",
|
|
99
|
-
"合计",
|
|
100
|
-
)
|
|
101
|
-
return any(term in lowered or term in column for term in aggregate_terms)
|
|
@@ -1,197 +0,0 @@
|
|
|
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 (
|
|
9
|
-
NL2SQL_SYSTEM_PROMPT,
|
|
10
|
-
build_nl2sql_prompt,
|
|
11
|
-
build_sql_expert_review_prompt,
|
|
12
|
-
build_sql_repair_prompt,
|
|
13
|
-
)
|
|
14
|
-
from .schema_context_retriever import SchemaContext
|
|
15
|
-
from .sql_generator import GeneratedSql, SqlGenerationError, contains_forbidden_dml_keyword
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
Sampler = Callable[[str, str], str]
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@dataclass
|
|
22
|
-
class SamplingSqlGenerator:
|
|
23
|
-
sampler: Sampler
|
|
24
|
-
default_limit: int
|
|
25
|
-
max_limit: int
|
|
26
|
-
confirmed_query_plan: dict[str, Any] | None = None
|
|
27
|
-
|
|
28
|
-
def generate(
|
|
29
|
-
self,
|
|
30
|
-
*,
|
|
31
|
-
question: str,
|
|
32
|
-
schema_context: SchemaContext,
|
|
33
|
-
) -> GeneratedSql:
|
|
34
|
-
prompt = build_nl2sql_prompt(
|
|
35
|
-
question=question,
|
|
36
|
-
schema_context=schema_context,
|
|
37
|
-
default_limit=self.default_limit,
|
|
38
|
-
max_limit=self.max_limit,
|
|
39
|
-
confirmed_query_plan=self.confirmed_query_plan,
|
|
40
|
-
)
|
|
41
|
-
return _parse_generated_sql(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
|
|
42
|
-
|
|
43
|
-
def repair(
|
|
44
|
-
self,
|
|
45
|
-
*,
|
|
46
|
-
question: str,
|
|
47
|
-
original_sql: str,
|
|
48
|
-
validation_error: dict[str, Any],
|
|
49
|
-
schema_context: SchemaContext,
|
|
50
|
-
) -> GeneratedSql:
|
|
51
|
-
prompt = build_sql_repair_prompt(
|
|
52
|
-
question=question,
|
|
53
|
-
original_sql=original_sql,
|
|
54
|
-
validation_error=validation_error,
|
|
55
|
-
schema_context=schema_context,
|
|
56
|
-
default_limit=self.default_limit,
|
|
57
|
-
max_limit=self.max_limit,
|
|
58
|
-
confirmed_query_plan=self.confirmed_query_plan,
|
|
59
|
-
)
|
|
60
|
-
return _parse_generated_sql(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
|
|
61
|
-
|
|
62
|
-
def review_sql(
|
|
63
|
-
self,
|
|
64
|
-
*,
|
|
65
|
-
question: str,
|
|
66
|
-
sql: str,
|
|
67
|
-
schema_context: SchemaContext,
|
|
68
|
-
) -> "SqlExpertReview":
|
|
69
|
-
prompt = build_sql_expert_review_prompt(
|
|
70
|
-
question=question,
|
|
71
|
-
sql=sql,
|
|
72
|
-
schema_context=schema_context,
|
|
73
|
-
default_limit=self.default_limit,
|
|
74
|
-
max_limit=self.max_limit,
|
|
75
|
-
confirmed_query_plan=self.confirmed_query_plan,
|
|
76
|
-
)
|
|
77
|
-
return _parse_sql_expert_review(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
@dataclass(frozen=True)
|
|
81
|
-
class SqlExpertReview:
|
|
82
|
-
executable: bool
|
|
83
|
-
issues: list[dict[str, str]]
|
|
84
|
-
repaired_sql: str | None = None
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _parse_generated_sql(text: str) -> GeneratedSql:
|
|
88
|
-
try:
|
|
89
|
-
payload = _load_json_object(text)
|
|
90
|
-
except SqlGenerationError:
|
|
91
|
-
sql = _extract_sql_text(text)
|
|
92
|
-
if sql is None:
|
|
93
|
-
raise
|
|
94
|
-
_require_select_sql(sql)
|
|
95
|
-
return GeneratedSql(sql=sql, tables=[], placeholders=[])
|
|
96
|
-
sql = payload.get("sql")
|
|
97
|
-
if isinstance(sql, str) and not sql.strip():
|
|
98
|
-
clarification = payload.get("clarification") if isinstance(payload.get("clarification"), dict) else None
|
|
99
|
-
return GeneratedSql(
|
|
100
|
-
sql="",
|
|
101
|
-
tables=[],
|
|
102
|
-
placeholders=[],
|
|
103
|
-
used_columns=[],
|
|
104
|
-
used_joins=[],
|
|
105
|
-
metric_refs=[],
|
|
106
|
-
clarification=clarification,
|
|
107
|
-
)
|
|
108
|
-
if not isinstance(sql, str):
|
|
109
|
-
fallback_sql = _extract_sql_text(text)
|
|
110
|
-
if fallback_sql is None:
|
|
111
|
-
raise SqlGenerationError("LLM output must contain sql")
|
|
112
|
-
_require_select_sql(fallback_sql)
|
|
113
|
-
return GeneratedSql(sql=fallback_sql, tables=[], placeholders=[])
|
|
114
|
-
sql = sql.strip()
|
|
115
|
-
_require_select_sql(sql)
|
|
116
|
-
tables = payload.get("tables") if isinstance(payload.get("tables"), list) else []
|
|
117
|
-
placeholders = payload.get("placeholders") if isinstance(payload.get("placeholders"), list) else []
|
|
118
|
-
used_columns = payload.get("usedColumns") if isinstance(payload.get("usedColumns"), list) else []
|
|
119
|
-
used_joins = payload.get("usedJoins") if isinstance(payload.get("usedJoins"), list) else []
|
|
120
|
-
metric_refs = payload.get("metricRefs") if isinstance(payload.get("metricRefs"), list) else []
|
|
121
|
-
clarification = payload.get("clarification") if isinstance(payload.get("clarification"), dict) else None
|
|
122
|
-
return GeneratedSql(
|
|
123
|
-
sql=sql,
|
|
124
|
-
tables=[str(table) for table in tables],
|
|
125
|
-
placeholders=[str(placeholder) for placeholder in placeholders],
|
|
126
|
-
used_columns=[str(column) for column in used_columns],
|
|
127
|
-
used_joins=[str(join) for join in used_joins],
|
|
128
|
-
metric_refs=[str(metric) for metric in metric_refs],
|
|
129
|
-
clarification=clarification,
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def _parse_sql_expert_review(text: str) -> SqlExpertReview:
|
|
134
|
-
payload = _load_json_object(text)
|
|
135
|
-
executable = bool(payload.get("executable", False))
|
|
136
|
-
issues_payload = payload.get("issues")
|
|
137
|
-
issues: list[dict[str, str]] = []
|
|
138
|
-
if isinstance(issues_payload, list):
|
|
139
|
-
for item in issues_payload:
|
|
140
|
-
if not isinstance(item, dict):
|
|
141
|
-
continue
|
|
142
|
-
issues.append(
|
|
143
|
-
{
|
|
144
|
-
"code": str(item.get("code") or ""),
|
|
145
|
-
"message": str(item.get("message") or ""),
|
|
146
|
-
"repairHint": str(item.get("repairHint") or ""),
|
|
147
|
-
}
|
|
148
|
-
)
|
|
149
|
-
repaired_sql = payload.get("repairedSql")
|
|
150
|
-
if isinstance(repaired_sql, str) and repaired_sql.strip():
|
|
151
|
-
repaired_sql = repaired_sql.strip()
|
|
152
|
-
_require_select_sql(repaired_sql)
|
|
153
|
-
else:
|
|
154
|
-
repaired_sql = None
|
|
155
|
-
return SqlExpertReview(executable=executable, issues=issues, repaired_sql=repaired_sql)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def _load_json_object(text: str) -> dict[str, Any]:
|
|
159
|
-
cleaned = text.strip()
|
|
160
|
-
if cleaned.startswith("```"):
|
|
161
|
-
cleaned = cleaned.strip("`")
|
|
162
|
-
if cleaned.lower().startswith("json"):
|
|
163
|
-
cleaned = cleaned[4:].strip()
|
|
164
|
-
try:
|
|
165
|
-
payload = json.loads(cleaned)
|
|
166
|
-
except json.JSONDecodeError as exc:
|
|
167
|
-
start = cleaned.find("{")
|
|
168
|
-
end = cleaned.rfind("}")
|
|
169
|
-
if start == -1 or end == -1 or end <= start:
|
|
170
|
-
raise SqlGenerationError("LLM output is not valid JSON") from exc
|
|
171
|
-
payload = json.loads(cleaned[start : end + 1])
|
|
172
|
-
if not isinstance(payload, dict):
|
|
173
|
-
raise SqlGenerationError("LLM output must be a JSON object")
|
|
174
|
-
return payload
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def _extract_sql_text(text: str) -> str | None:
|
|
178
|
-
cleaned = text.strip()
|
|
179
|
-
fenced = re.search(r"```(?:sql)?\s*(.*?)```", cleaned, flags=re.IGNORECASE | re.DOTALL)
|
|
180
|
-
if fenced:
|
|
181
|
-
cleaned = fenced.group(1).strip()
|
|
182
|
-
match = re.search(r"\bselect\b.*", cleaned, flags=re.IGNORECASE | re.DOTALL)
|
|
183
|
-
if not match:
|
|
184
|
-
return None
|
|
185
|
-
sql = match.group(0).strip()
|
|
186
|
-
sql = re.split(r"\n\s*(?:解释|说明|备注)[::]", sql, maxsplit=1)[0].strip()
|
|
187
|
-
if sql.endswith(";"):
|
|
188
|
-
sql = sql[:-1].strip()
|
|
189
|
-
return sql
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def _require_select_sql(sql: str) -> None:
|
|
193
|
-
normalized = sql.strip().lower()
|
|
194
|
-
if not normalized.startswith("select "):
|
|
195
|
-
raise SqlGenerationError("LLM SQL must be SELECT only")
|
|
196
|
-
if contains_forbidden_dml_keyword(sql):
|
|
197
|
-
raise SqlGenerationError("LLM SQL contains a forbidden keyword")
|
|
@@ -1,26 +0,0 @@
|
|
|
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-agent")
|
|
11
|
-
parser.add_argument("question")
|
|
12
|
-
parser.add_argument("--session-id")
|
|
13
|
-
args = parser.parse_args()
|
|
14
|
-
|
|
15
|
-
config = load_config_from_env()
|
|
16
|
-
agent = SpongeAgent.from_config(config)
|
|
17
|
-
print(
|
|
18
|
-
agent.answer(
|
|
19
|
-
question=args.question,
|
|
20
|
-
session_id=args.session_id,
|
|
21
|
-
)
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if __name__ == "__main__":
|
|
26
|
-
main()
|
|
@@ -1,114 +0,0 @@
|
|
|
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
|
-
session_id: str | None = None,
|
|
25
|
-
schema_version: str | None = None,
|
|
26
|
-
) -> dict[str, Any]:
|
|
27
|
-
payload = {
|
|
28
|
-
"traceId": trace_id,
|
|
29
|
-
"sessionId": session_id,
|
|
30
|
-
"schemaVersion": schema_version,
|
|
31
|
-
}
|
|
32
|
-
return self._post("get_schema", payload)
|
|
33
|
-
|
|
34
|
-
def search_entities(
|
|
35
|
-
self,
|
|
36
|
-
*,
|
|
37
|
-
trace_id: str,
|
|
38
|
-
session_id: str | None = None,
|
|
39
|
-
entity_types: list[str] | tuple[str, ...] | None = None,
|
|
40
|
-
name: str | None = None,
|
|
41
|
-
) -> dict[str, Any]:
|
|
42
|
-
payload = {
|
|
43
|
-
"traceId": trace_id,
|
|
44
|
-
"sessionId": session_id,
|
|
45
|
-
"entityTypes": list(entity_types) if entity_types else None,
|
|
46
|
-
"name": name,
|
|
47
|
-
}
|
|
48
|
-
return self._post("search_entities", payload)
|
|
49
|
-
|
|
50
|
-
def validate_sql(
|
|
51
|
-
self,
|
|
52
|
-
*,
|
|
53
|
-
trace_id: str,
|
|
54
|
-
session_id: str,
|
|
55
|
-
question: str,
|
|
56
|
-
sql: str,
|
|
57
|
-
) -> dict[str, Any]:
|
|
58
|
-
payload = {
|
|
59
|
-
"traceId": trace_id,
|
|
60
|
-
"sessionId": session_id,
|
|
61
|
-
"question": question,
|
|
62
|
-
"sql": sql,
|
|
63
|
-
}
|
|
64
|
-
return self._post("validate_sql", payload)
|
|
65
|
-
|
|
66
|
-
def execute_sql(
|
|
67
|
-
self,
|
|
68
|
-
*,
|
|
69
|
-
trace_id: str,
|
|
70
|
-
session_id: str,
|
|
71
|
-
question: str,
|
|
72
|
-
sql: str,
|
|
73
|
-
timeout_ms: int | None = None,
|
|
74
|
-
) -> dict[str, Any]:
|
|
75
|
-
payload = {
|
|
76
|
-
"traceId": trace_id,
|
|
77
|
-
"sessionId": session_id,
|
|
78
|
-
"question": question,
|
|
79
|
-
"sql": sql,
|
|
80
|
-
"timeoutMs": timeout_ms or self.timeout_ms,
|
|
81
|
-
}
|
|
82
|
-
return self._post("execute_sql", payload)
|
|
83
|
-
|
|
84
|
-
def _post(self, tool_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
85
|
-
body = json.dumps({key: value for key, value in payload.items() if value is not None}).encode(
|
|
86
|
-
"utf-8"
|
|
87
|
-
)
|
|
88
|
-
request = urllib.request.Request(
|
|
89
|
-
url=f"{self.base_url}/mcp/tools/{tool_name}",
|
|
90
|
-
data=body,
|
|
91
|
-
method="POST",
|
|
92
|
-
headers={
|
|
93
|
-
"Content-Type": "application/json",
|
|
94
|
-
"Authorization": f"Bearer {self.access_token}",
|
|
95
|
-
},
|
|
96
|
-
)
|
|
97
|
-
try:
|
|
98
|
-
with urllib.request.urlopen(request, timeout=self.timeout_ms / 1000) as response:
|
|
99
|
-
data = json.loads(response.read().decode("utf-8"))
|
|
100
|
-
except urllib.error.HTTPError as exc:
|
|
101
|
-
message = exc.read().decode("utf-8", errors="replace")
|
|
102
|
-
if tool_name == "validate_sql":
|
|
103
|
-
try:
|
|
104
|
-
error_payload = json.loads(message)
|
|
105
|
-
except json.JSONDecodeError:
|
|
106
|
-
error_payload = None
|
|
107
|
-
if isinstance(error_payload, dict) and isinstance(error_payload.get("error"), dict):
|
|
108
|
-
return {"valid": False, "error": error_payload["error"]}
|
|
109
|
-
raise SpongeMcpError(f"MCP {tool_name} failed: HTTP {exc.code} {message}") from exc
|
|
110
|
-
except urllib.error.URLError as exc:
|
|
111
|
-
raise SpongeMcpError(f"MCP {tool_name} failed: {exc.reason}") from exc
|
|
112
|
-
if not isinstance(data, dict):
|
|
113
|
-
raise SpongeMcpError(f"MCP {tool_name} returned non-object JSON")
|
|
114
|
-
return data
|