@roll-agent/octopus-agent 0.0.11 → 0.1.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.
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, field
5
5
  from pathlib import Path
6
6
 
7
7
 
@@ -24,15 +24,6 @@ class SchemaConfig:
24
24
  refresh_interval_minutes: int
25
25
 
26
26
 
27
- @dataclass(frozen=True)
28
- class DictionaryConfig:
29
- cache_path: Path
30
- geo_alias_path: Path
31
- refresh_on_start: bool
32
- refresh_interval_minutes: int
33
- entity_types: tuple[str, ...]
34
-
35
-
36
27
  @dataclass(frozen=True)
37
28
  class EntityBindingConfig:
38
29
  # Compute bindings and log them, but do not change SQL generation yet.
@@ -56,13 +47,18 @@ class AuditConfig:
56
47
  log_path: Path
57
48
 
58
49
 
50
+ @dataclass(frozen=True)
51
+ class SamplingConfig:
52
+ max_tokens: int
53
+
54
+
59
55
  @dataclass(frozen=True)
60
56
  class AppConfig:
61
57
  sponge_mcp: SpongeMcpConfig
62
58
  schema: SchemaConfig
63
59
  sql: SqlConfig
64
60
  audit: AuditConfig
65
- dictionary: DictionaryConfig | None = None
61
+ sampling: SamplingConfig = field(default_factory=lambda: SamplingConfig(max_tokens=4096))
66
62
  binding: EntityBindingConfig | None = None
67
63
 
68
64
 
@@ -78,29 +74,19 @@ def load_config() -> AppConfig:
78
74
  refresh_on_start=True,
79
75
  refresh_interval_minutes=1440,
80
76
  ),
81
- dictionary=DictionaryConfig(
82
- cache_path=PACKAGE_ROOT / "data/sponge-entity-cache.json",
83
- geo_alias_path=PACKAGE_ROOT / "data/geo-aliases.json",
84
- refresh_on_start=True,
85
- refresh_interval_minutes=1440,
86
- entity_types=("brand", "province", "city", "region", "project"),
87
- ),
88
77
  sql=SqlConfig(
89
78
  max_repair_attempts=2,
90
- default_limit=200,
79
+ default_limit=50,
91
80
  max_limit=500,
92
81
  ),
93
82
  audit=AuditConfig(
94
83
  enabled=True,
95
84
  log_path=PACKAGE_ROOT / "logs/octopus-sponge-audit.log",
96
85
  ),
97
- binding=EntityBindingConfig(
98
- shadow_mode=_env_bool("OCTOPUS_BINDING_SHADOW", default=False),
99
- enabled_entity_types=_env_csv(
100
- "OCTOPUS_BINDING_ENABLED", default=("brand", "location", "project")
101
- ),
102
- min_fuzzy_score=_env_float("OCTOPUS_BINDING_MIN_SCORE", default=0.6),
86
+ sampling=SamplingConfig(
87
+ max_tokens=_env_int("OCTOPUS_SAMPLING_MAX_TOKENS", default=4096),
103
88
  ),
89
+ binding=None,
104
90
  )
105
91
 
106
92
 
@@ -130,3 +116,14 @@ def _env_float(name: str, *, default: float) -> float:
130
116
  return float(value)
131
117
  except ValueError:
132
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
@@ -0,0 +1,66 @@
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)
@@ -0,0 +1,101 @@
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)
@@ -5,9 +5,14 @@ import re
5
5
  from dataclasses import dataclass
6
6
  from typing import Any, Callable
7
7
 
8
- from .prompt_builder import NL2SQL_SYSTEM_PROMPT, build_nl2sql_prompt, build_sql_repair_prompt
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
+ )
9
14
  from .schema_context_retriever import SchemaContext
10
- from .sql_generator import GeneratedSql, SqlGenerationError
15
+ from .sql_generator import GeneratedSql, SqlGenerationError, contains_forbidden_dml_keyword
11
16
 
12
17
 
13
18
  Sampler = Callable[[str, str], str]
@@ -18,7 +23,7 @@ class SamplingSqlGenerator:
18
23
  sampler: Sampler
19
24
  default_limit: int
20
25
  max_limit: int
21
- resolved_entities: dict[str, Any] | None = None
26
+ confirmed_query_plan: dict[str, Any] | None = None
22
27
 
23
28
  def generate(
24
29
  self,
@@ -31,7 +36,7 @@ class SamplingSqlGenerator:
31
36
  schema_context=schema_context,
32
37
  default_limit=self.default_limit,
33
38
  max_limit=self.max_limit,
34
- resolved_entities=self.resolved_entities,
39
+ confirmed_query_plan=self.confirmed_query_plan,
35
40
  )
36
41
  return _parse_generated_sql(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
37
42
 
@@ -50,10 +55,34 @@ class SamplingSqlGenerator:
50
55
  schema_context=schema_context,
51
56
  default_limit=self.default_limit,
52
57
  max_limit=self.max_limit,
53
- resolved_entities=self.resolved_entities,
58
+ confirmed_query_plan=self.confirmed_query_plan,
54
59
  )
55
60
  return _parse_generated_sql(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
56
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
+
57
86
 
58
87
  def _parse_generated_sql(text: str) -> GeneratedSql:
59
88
  try:
@@ -66,7 +95,16 @@ def _parse_generated_sql(text: str) -> GeneratedSql:
66
95
  return GeneratedSql(sql=sql, tables=[], placeholders=[])
67
96
  sql = payload.get("sql")
68
97
  if isinstance(sql, str) and not sql.strip():
69
- return GeneratedSql(sql="", tables=[], placeholders=[], used_columns=[], used_joins=[], metric_refs=[])
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
+ )
70
108
  if not isinstance(sql, str):
71
109
  fallback_sql = _extract_sql_text(text)
72
110
  if fallback_sql is None:
@@ -80,6 +118,7 @@ def _parse_generated_sql(text: str) -> GeneratedSql:
80
118
  used_columns = payload.get("usedColumns") if isinstance(payload.get("usedColumns"), list) else []
81
119
  used_joins = payload.get("usedJoins") if isinstance(payload.get("usedJoins"), list) else []
82
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
83
122
  return GeneratedSql(
84
123
  sql=sql,
85
124
  tables=[str(table) for table in tables],
@@ -87,9 +126,35 @@ def _parse_generated_sql(text: str) -> GeneratedSql:
87
126
  used_columns=[str(column) for column in used_columns],
88
127
  used_joins=[str(join) for join in used_joins],
89
128
  metric_refs=[str(metric) for metric in metric_refs],
129
+ clarification=clarification,
90
130
  )
91
131
 
92
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
+
93
158
  def _load_json_object(text: str) -> dict[str, Any]:
94
159
  cleaned = text.strip()
95
160
  if cleaned.startswith("```"):
@@ -128,5 +193,5 @@ def _require_select_sql(sql: str) -> None:
128
193
  normalized = sql.strip().lower()
129
194
  if not normalized.startswith("select "):
130
195
  raise SqlGenerationError("LLM SQL must be SELECT only")
131
- if re.search(r"\b(insert|update|delete|drop|alter|create|truncate)\b", normalized):
196
+ if contains_forbidden_dml_keyword(sql):
132
197
  raise SqlGenerationError("LLM SQL contains a forbidden keyword")
@@ -31,21 +31,21 @@ class SpongeMcpClient:
31
31
  }
32
32
  return self._post("get_schema", payload)
33
33
 
34
- def get_entity_dictionary(
34
+ def search_entities(
35
35
  self,
36
36
  *,
37
37
  trace_id: str,
38
38
  session_id: str | None = None,
39
- dict_version: str | None = None,
40
39
  entity_types: list[str] | tuple[str, ...] | None = None,
40
+ name: str | None = None,
41
41
  ) -> dict[str, Any]:
42
42
  payload = {
43
43
  "traceId": trace_id,
44
44
  "sessionId": session_id,
45
- "dictVersion": dict_version,
46
45
  "entityTypes": list(entity_types) if entity_types else None,
46
+ "name": name,
47
47
  }
48
- return self._post("get_entity_dictionary", payload)
48
+ return self._post("search_entities", payload)
49
49
 
50
50
  def validate_sql(
51
51
  self,