@roll-agent/octopus-agent 0.0.1 → 0.0.2

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.
@@ -24,12 +24,10 @@ class SamplingSqlGenerator:
24
24
  *,
25
25
  question: str,
26
26
  schema_context: SchemaContext,
27
- scope_hints: dict[str, Any],
28
27
  ) -> GeneratedSql:
29
28
  prompt = build_nl2sql_prompt(
30
29
  question=question,
31
30
  schema_context=schema_context,
32
- scope_hints=scope_hints,
33
31
  default_limit=self.default_limit,
34
32
  max_limit=self.max_limit,
35
33
  )
@@ -42,14 +40,13 @@ class SamplingSqlGenerator:
42
40
  original_sql: str,
43
41
  validation_error: dict[str, Any],
44
42
  schema_context: SchemaContext,
45
- scope_hints: dict[str, Any],
46
43
  ) -> GeneratedSql:
47
44
  prompt = build_sql_repair_prompt(
48
45
  question=question,
49
46
  original_sql=original_sql,
50
47
  validation_error=validation_error,
51
48
  schema_context=schema_context,
52
- scope_hints=scope_hints,
49
+ default_limit=self.default_limit,
53
50
  max_limit=self.max_limit,
54
51
  )
55
52
  return _parse_generated_sql(self.sampler(NL2SQL_SYSTEM_PROMPT, prompt))
@@ -62,19 +59,31 @@ def _parse_generated_sql(text: str) -> GeneratedSql:
62
59
  sql = _extract_sql_text(text)
63
60
  if sql is None:
64
61
  raise
62
+ _require_select_sql(sql)
65
63
  return GeneratedSql(sql=sql, tables=[], placeholders=[])
66
64
  sql = payload.get("sql")
67
- if not isinstance(sql, str) or not sql.strip():
65
+ if isinstance(sql, str) and not sql.strip():
66
+ return GeneratedSql(sql="", tables=[], placeholders=[], used_columns=[], used_joins=[], metric_refs=[])
67
+ if not isinstance(sql, str):
68
68
  fallback_sql = _extract_sql_text(text)
69
69
  if fallback_sql is None:
70
70
  raise SqlGenerationError("LLM output must contain sql")
71
+ _require_select_sql(fallback_sql)
71
72
  return GeneratedSql(sql=fallback_sql, tables=[], placeholders=[])
73
+ sql = sql.strip()
74
+ _require_select_sql(sql)
72
75
  tables = payload.get("tables") if isinstance(payload.get("tables"), list) else []
73
76
  placeholders = payload.get("placeholders") if isinstance(payload.get("placeholders"), list) else []
77
+ used_columns = payload.get("usedColumns") if isinstance(payload.get("usedColumns"), list) else []
78
+ used_joins = payload.get("usedJoins") if isinstance(payload.get("usedJoins"), list) else []
79
+ metric_refs = payload.get("metricRefs") if isinstance(payload.get("metricRefs"), list) else []
74
80
  return GeneratedSql(
75
- sql=sql.strip(),
81
+ sql=sql,
76
82
  tables=[str(table) for table in tables],
77
83
  placeholders=[str(placeholder) for placeholder in placeholders],
84
+ used_columns=[str(column) for column in used_columns],
85
+ used_joins=[str(join) for join in used_joins],
86
+ metric_refs=[str(metric) for metric in metric_refs],
78
87
  )
79
88
 
80
89
 
@@ -110,3 +119,11 @@ def _extract_sql_text(text: str) -> str | None:
110
119
  if sql.endswith(";"):
111
120
  sql = sql[:-1].strip()
112
121
  return sql
122
+
123
+
124
+ def _require_select_sql(sql: str) -> None:
125
+ normalized = sql.strip().lower()
126
+ if not normalized.startswith("select "):
127
+ raise SqlGenerationError("LLM SQL must be SELECT only")
128
+ if re.search(r"\b(insert|update|delete|drop|alter|create|truncate)\b", normalized):
129
+ raise SqlGenerationError("LLM SQL contains a forbidden keyword")
@@ -9,9 +9,7 @@ from .config import load_config_from_env
9
9
  def main() -> None:
10
10
  parser = argparse.ArgumentParser(prog="octopus-sponge")
11
11
  parser.add_argument("question")
12
- parser.add_argument("--user-id", type=int, required=True)
13
12
  parser.add_argument("--session-id")
14
- parser.add_argument("--device-id")
15
13
  args = parser.parse_args()
16
14
 
17
15
  config = load_config_from_env()
@@ -19,9 +17,7 @@ def main() -> None:
19
17
  print(
20
18
  agent.answer(
21
19
  question=args.question,
22
- user_id=args.user_id,
23
20
  session_id=args.session_id,
24
- device_id=args.device_id,
25
21
  )
26
22
  )
27
23
 
@@ -21,46 +21,28 @@ class SpongeMcpClient:
21
21
  self,
22
22
  *,
23
23
  trace_id: str,
24
- schema_version: str | None,
25
- refresh_reason: str,
24
+ session_id: str | None = None,
25
+ schema_version: str | None = None,
26
26
  ) -> dict[str, Any]:
27
27
  payload = {
28
28
  "traceId": trace_id,
29
+ "sessionId": session_id,
29
30
  "schemaVersion": schema_version,
30
- "refreshReason": refresh_reason,
31
31
  }
32
32
  return self._post("get_schema", payload)
33
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
34
  def validate_sql(
53
35
  self,
54
36
  *,
55
- user_id: int,
56
37
  trace_id: str,
57
38
  session_id: str,
39
+ question: str,
58
40
  sql: str,
59
41
  ) -> dict[str, Any]:
60
42
  payload = {
61
- "userId": user_id,
62
43
  "traceId": trace_id,
63
44
  "sessionId": session_id,
45
+ "question": question,
64
46
  "sql": sql,
65
47
  }
66
48
  return self._post("validate_sql", payload)
@@ -68,16 +50,16 @@ class SpongeMcpClient:
68
50
  def execute_sql(
69
51
  self,
70
52
  *,
71
- user_id: int,
72
53
  trace_id: str,
73
54
  session_id: str,
55
+ question: str,
74
56
  sql: str,
75
57
  timeout_ms: int | None = None,
76
58
  ) -> dict[str, Any]:
77
59
  payload = {
78
- "userId": user_id,
79
60
  "traceId": trace_id,
80
61
  "sessionId": session_id,
62
+ "question": question,
81
63
  "sql": sql,
82
64
  "timeoutMs": timeout_ms or self.timeout_ms,
83
65
  }
@@ -8,37 +8,59 @@ from .schema_context_retriever import SchemaContext
8
8
 
9
9
  NL2SQL_SYSTEM_PROMPT = """你是 Octopus 的 Sponge 数据查询 SQL 生成器。
10
10
  你只能基于提供的 schema context 生成 MySQL SELECT 查询。
11
- SQL 必须使用 :scope.project_ids :scope.brand_ids 表示用户可访问范围。
12
- 不得硬编码真实 projectId 或 brandId。
11
+ 返回 JSON 中的 sql 字段必须直接以 SELECT 开头,不能返回非 SQL 文本。
12
+ 只生成业务 SQL,不要生成权限占位符或权限条件。
13
+ 不得使用 :scope.project_ids、:scope.brand_ids 或任何 :scope.* placeholder(占位符)。
14
+ 如果用户没有明确要求返回条数,LIMIT 必须使用 defaultLimit。
13
15
  不得查询 auth 库、密码、令牌、密钥、凭证字段。
14
- 不得生成 INSERT、UPDATE、DELETE、DDL 或多语句 SQL。
16
+ 不得生成 INSERT、UPDATE、DELETE、DDL、非 SELECT SQL 或多语句 SQL。
17
+ SELECT 中每个输出字段都必须使用 AS 中文含义,例如 b.name AS 品牌名称。
18
+ 禁止使用英文别名,例如 brand_name、project_name。
19
+ 用户点名要求输出的字段,必须优先使用 schema context 中含义匹配的真实字段或真实关联表生成。
20
+ 不得为了凑齐字段写 NULL AS 字段名、CAST(NULL AS ...) AS 字段名 等空占位表达式。
21
+ 只有 schema context 中完全没有相关真实字段或关联表时,才允许不返回该字段;不要用空占位字段替代。
22
+ 涉及业务指标或业务口径时,必须优先使用 schemaContext.metrics 中的 definition、aggregation 和 filters。
23
+ 如果 schemaContext.metrics 没有给出对应业务口径,不得自行推断状态枚举、有效性过滤或跨轮次分割规则。
24
+ 所有业务字段、状态枚举、状态映射、表关联方式必须来自 schemaContext.tables、schemaContext.joins、schemaContext.glossary、schemaContext.metrics 或 schemaContext.examples。
25
+ 不得自行猜测状态值;例如“面试成功”“进行中”“已上岗”等状态必须使用 schemaContext 中给出的字段和枚举。
26
+ 所有 JOIN 必须优先使用 schemaContext.joins;如果相关 JOIN 只出现在 schemaContext.metrics.filters 或 schemaContext.examples 中,也必须严格照用。
27
+ 如果 schemaContext 没有明确给出表、字段、JOIN 或指标口径,不要猜测;返回 {"sql":"","tables":[],"placeholders":[],"usedColumns":[],"usedJoins":[],"metricRefs":[]}。
28
+ 报名/工单/候选人状态/姓名/岗位/项目明细查询,必须优先使用 schemaContext 中的报名工单表和示例,不得退化为只查询 brand 品牌列表。
29
+ 报名工单与岗位的关联方式必须来自 schemaContext;如果 schemaContext 指明 job_id 关联 job_basic_info.job_id,不得改用 job_basic_info.id。
30
+ 地点条件必须先按业务字典判断层级:province.name 省份优先,其次 city.name 城市,再其次 region.name 区域。
31
+ 用户提到南京、上海、江苏等地点时,不要先猜测为道路、商圈、门店或地址关键词;只有省/城市/区域都无法匹配时,才按地址关键词模糊查询。
32
+ 岗位或门店地点查询优先使用 job_address.city_id、job_address.region_id、store.city_id、store.region_id 等结构化字段。
33
+ 用户问题包含时间范围时,SQL 的 WHERE 条件必须直接包含对应时间字段过滤,不得先查询全量数据再补充时间范围。
34
+ 时间字段按语义选择:创建/新建/新增使用 create_at,发布/上架使用 publish_at,下架使用 off_at,更新/修改使用 update_at。
15
35
  必须包含 LIMIT,且 LIMIT 不超过 500。
16
- 只输出 JSON,不输出解释文本。"""
36
+ 不要使用 WITH;低版本 MySQL 不支持公共表表达式,请使用 JOIN 或派生表。
37
+ 只输出 JSON,不输出解释文本、拒绝文本或 Markdown。"""
17
38
 
18
39
 
19
40
  def build_nl2sql_prompt(
20
41
  *,
21
42
  question: str,
22
43
  schema_context: SchemaContext,
23
- scope_hints: dict[str, Any],
24
44
  default_limit: int,
25
45
  max_limit: int,
26
46
  ) -> str:
27
47
  payload = {
28
48
  "question": question,
29
- "scopeHints": scope_hints,
30
49
  "limits": {"defaultLimit": default_limit, "maxLimit": max_limit},
31
50
  "schemaContext": {
32
51
  "tables": schema_context.tables,
33
52
  "joins": schema_context.joins,
34
53
  "glossary": schema_context.glossary,
35
54
  "metrics": schema_context.metrics,
36
- "examples": schema_context.examples,
55
+ "examples": _business_examples(schema_context.examples),
37
56
  },
38
57
  "outputSchema": {
39
- "sql": "SELECT ... LIMIT 100",
58
+ "sql": f"SELECT column AS 中文含义 ... LIMIT {default_limit}",
40
59
  "tables": ["table_name"],
41
- "placeholders": [":scope.project_ids", ":scope.brand_ids"],
60
+ "placeholders": [],
61
+ "usedColumns": ["table.column"],
62
+ "usedJoins": ["left_table.left_column = right_table.right_column"],
63
+ "metricRefs": ["schemaContext.metrics.name"],
42
64
  },
43
65
  }
44
66
  return json.dumps(payload, ensure_ascii=False, indent=2)
@@ -50,7 +72,7 @@ def build_sql_repair_prompt(
50
72
  original_sql: str,
51
73
  validation_error: dict[str, Any],
52
74
  schema_context: SchemaContext,
53
- scope_hints: dict[str, Any],
75
+ default_limit: int,
54
76
  max_limit: int,
55
77
  ) -> str:
56
78
  payload = {
@@ -58,20 +80,25 @@ def build_sql_repair_prompt(
58
80
  "question": question,
59
81
  "originalSql": original_sql,
60
82
  "validationError": validation_error,
61
- "scopeHints": scope_hints,
62
- "limits": {"maxLimit": max_limit},
83
+ "limits": {"defaultLimit": default_limit, "maxLimit": max_limit},
63
84
  "schemaContext": {
64
85
  "tables": schema_context.tables,
65
86
  "joins": schema_context.joins,
66
87
  "glossary": schema_context.glossary,
67
88
  "metrics": schema_context.metrics,
68
- "examples": schema_context.examples,
89
+ "examples": _business_examples(schema_context.examples),
69
90
  },
70
91
  "outputSchema": {
71
- "sql": "SELECT ... LIMIT 100",
92
+ "sql": f"SELECT column AS 中文含义 ... LIMIT {default_limit}",
72
93
  "tables": ["table_name"],
73
- "placeholders": [":scope.project_ids", ":scope.brand_ids"],
94
+ "placeholders": [],
95
+ "usedColumns": ["table.column"],
96
+ "usedJoins": ["left_table.left_column = right_table.right_column"],
97
+ "metricRefs": ["schemaContext.metrics.name"],
74
98
  },
75
99
  }
76
100
  return json.dumps(payload, ensure_ascii=False, indent=2)
77
101
 
102
+
103
+ def _business_examples(examples: list[dict[str, Any]]) -> list[dict[str, Any]]:
104
+ return [example for example in examples if ":scope." not in str(example.get("sql") or "")]
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ SQL_GENERATION_CACHE_VERSION = "v9"
13
+
14
+
15
+ @dataclass
16
+ class QuerySqlCache:
17
+ path: Path
18
+ ttl_minutes: int
19
+
20
+ def get(self, *, question: str, schema_version: str | None) -> str | None:
21
+ if self.ttl_minutes <= 0:
22
+ return None
23
+ entry = self._entries().get(_cache_key(question, schema_version))
24
+ if not isinstance(entry, dict):
25
+ return None
26
+ created_at = entry.get("createdAt")
27
+ sql = entry.get("sql")
28
+ if not isinstance(created_at, (int, float)) or not isinstance(sql, str):
29
+ return None
30
+ if time.time() - float(created_at) > self.ttl_minutes * 60:
31
+ return None
32
+ return sql
33
+
34
+ def delete(self, *, question: str, schema_version: str | None) -> None:
35
+ entries = self._entries()
36
+ key = _cache_key(question, schema_version)
37
+ if key not in entries:
38
+ return
39
+ del entries[key]
40
+ self.path.parent.mkdir(parents=True, exist_ok=True)
41
+ self.path.write_text(json.dumps(entries, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
42
+
43
+ def set(self, *, question: str, schema_version: str | None, sql: str) -> None:
44
+ if self.ttl_minutes <= 0:
45
+ return
46
+ entries = self._entries()
47
+ entries[_cache_key(question, schema_version)] = {
48
+ "questionKey": _normalize_question(question),
49
+ "schemaVersion": schema_version,
50
+ "sql": sql,
51
+ "createdAt": time.time(),
52
+ }
53
+ self.path.parent.mkdir(parents=True, exist_ok=True)
54
+ self.path.write_text(json.dumps(entries, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
55
+
56
+ def _entries(self) -> dict[str, Any]:
57
+ if not self.path.exists():
58
+ return {}
59
+ try:
60
+ data = json.loads(self.path.read_text(encoding="utf-8"))
61
+ except json.JSONDecodeError:
62
+ return {}
63
+ return data if isinstance(data, dict) else {}
64
+
65
+
66
+ def _cache_key(question: str, schema_version: str | None) -> str:
67
+ raw = f"{SQL_GENERATION_CACHE_VERSION}:{schema_version or ''}:{_normalize_question(question)}"
68
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
69
+
70
+
71
+ def _normalize_question(question: str) -> str:
72
+ return re.sub(r"\s+", "", question.strip().lower())
@@ -2,10 +2,12 @@ from __future__ import annotations
2
2
 
3
3
  import html
4
4
  import json
5
+ import re
5
6
  from typing import Any
6
7
 
7
8
 
8
- SUPPORTED_RESULT_FORMATS = {"text", "yaml", "md", "markdown", "html"}
9
+ SUPPORTED_RESULT_FORMATS = {"text", "json", "yaml", "md", "markdown", "html"}
10
+ RESULT_ROW_DISPLAY_LIMIT = 500
9
11
 
10
12
 
11
13
  def normalize_result_format(question: str, result_format: str | None = None) -> str:
@@ -15,12 +17,16 @@ def normalize_result_format(question: str, result_format: str | None = None) ->
15
17
  raise ValueError(f"Unsupported resultFormat: {result_format}")
16
18
  return "markdown" if normalized == "md" else normalized
17
19
  lowered = question.lower()
20
+ if "json" in lowered:
21
+ return "json"
18
22
  if "yaml" in lowered:
19
23
  return "yaml"
20
24
  if "html" in lowered:
21
25
  return "html"
22
26
  if "markdown" in lowered or " md" in lowered or "表格" in question:
23
27
  return "markdown"
28
+ if _is_match_effect_report_question(question):
29
+ return "markdown"
24
30
  return "text"
25
31
 
26
32
 
@@ -33,10 +39,14 @@ def render_result(question: str, result: dict[str, Any], result_format: str | No
33
39
  columns = result.get("columns", [])
34
40
  if row_count == 0:
35
41
  return _format_empty(output_format)
36
- sample = rows[:5] if isinstance(rows, list) else []
42
+ sample = rows[:RESULT_ROW_DISPLAY_LIMIT] if isinstance(rows, list) else []
43
+ if output_format == "json":
44
+ return _render_json(row_count, bool(result.get("limited", False)), sample)
37
45
  if output_format == "yaml":
38
46
  return _render_yaml(question, row_count, bool(result.get("limited", False)), sample)
39
47
  if output_format == "markdown":
48
+ if _is_match_effect_report_question(question):
49
+ return _render_match_effect_report(question, row_count, bool(result.get("limited", False)), sample, columns)
40
50
  return _render_markdown(question, row_count, bool(result.get("limited", False)), sample, columns)
41
51
  if output_format == "html":
42
52
  return _render_html(question, row_count, bool(result.get("limited", False)), sample, columns)
@@ -44,6 +54,8 @@ def render_result(question: str, result: dict[str, Any], result_format: str | No
44
54
 
45
55
 
46
56
  def _format_failure(output_format: str) -> str:
57
+ if output_format == "json":
58
+ return json.dumps({"success": False, "message": "查询失败。"}, ensure_ascii=False, indent=2) + "\n"
47
59
  if output_format == "yaml":
48
60
  return "success: false\nmessage: 查询失败。\n"
49
61
  if output_format == "markdown":
@@ -54,6 +66,12 @@ def _format_failure(output_format: str) -> str:
54
66
 
55
67
 
56
68
  def _format_empty(output_format: str) -> str:
69
+ if output_format == "json":
70
+ return json.dumps(
71
+ {"success": True, "rowCount": 0, "rows": [], "message": "没有查询到符合条件的数据。"},
72
+ ensure_ascii=False,
73
+ indent=2,
74
+ ) + "\n"
57
75
  if output_format == "yaml":
58
76
  return "success: true\nrowCount: 0\nrows: []\nmessage: 没有查询到符合条件的数据。\n"
59
77
  if output_format == "markdown":
@@ -63,6 +81,104 @@ def _format_empty(output_format: str) -> str:
63
81
  return "没有查询到符合条件的数据。"
64
82
 
65
83
 
84
+ def _is_match_effect_report_question(question: str) -> bool:
85
+ return bool(
86
+ ("匹配效果" in question or "分析报告" in question)
87
+ and "岗位" in question
88
+ and any(term in question for term in ("候选人", "报名", "入职", "上岗"))
89
+ )
90
+
91
+
92
+ def _render_match_effect_report(
93
+ question: str,
94
+ row_count: int,
95
+ limited: bool,
96
+ rows: list[Any],
97
+ columns: Any,
98
+ ) -> str:
99
+ table_columns = _column_names(columns, rows)
100
+ period = _extract_report_period(question)
101
+ lines = [
102
+ f"# {period}岗位与候选人匹配效果分析报告",
103
+ "",
104
+ "## 完整分析报告",
105
+ "",
106
+ f"数据范围:{period};维度:品牌、项目、城市;口径:入职按上岗结果近似统计。",
107
+ f"返回 {row_count} 行。limited: {str(limited).lower()}",
108
+ ]
109
+ if table_columns:
110
+ lines.extend(["", "|" + "|".join(_escape_markdown(column) for column in table_columns) + "|"])
111
+ lines.append("|" + "|".join("---" for _ in table_columns) + "|")
112
+ for row in rows:
113
+ lines.append("|" + "|".join(_escape_markdown(_cell_value(row, column)) for column in table_columns) + "|")
114
+ else:
115
+ lines.extend(["", "```json", json.dumps(rows, ensure_ascii=False, indent=2), "```"])
116
+ lines.extend(["", "## 总结关键发现", ""])
117
+ findings = _match_effect_findings(rows)
118
+ lines.extend(findings or ["- 暂未发现明显异常组合。"])
119
+ return "\n".join(lines)
120
+
121
+
122
+ def _extract_report_period(question: str) -> str:
123
+ relative_month = re.search(r"(今年|去年|前年|明年|后年)\s*(\d{1,2})\s*月", question)
124
+ if relative_month is not None:
125
+ return f"{relative_month.group(1)}{int(relative_month.group(2))}月"
126
+ explicit_month = re.search(r"(20\d{2})\s*年\s*(\d{1,2})\s*月", question)
127
+ if explicit_month is not None:
128
+ return f"{explicit_month.group(1)}年{int(explicit_month.group(2))}月"
129
+ return "本期"
130
+
131
+
132
+ def _match_effect_findings(rows: list[Any]) -> list[str]:
133
+ if not rows:
134
+ return []
135
+ buckets = {
136
+ "报名多但入职少": [],
137
+ "在招多但报名少": [],
138
+ "候选人多但岗位少": [],
139
+ }
140
+ for row in rows:
141
+ if not isinstance(row, dict):
142
+ continue
143
+ issue = str(row.get("问题类型", ""))
144
+ label = _dimension_label(row)
145
+ for bucket in buckets:
146
+ if bucket in issue:
147
+ buckets[bucket].append(label)
148
+ findings: list[str] = []
149
+ for bucket, labels in buckets.items():
150
+ if labels:
151
+ findings.append(f"- {bucket}:{_join_limited(labels)}。")
152
+ return findings
153
+
154
+
155
+ def _dimension_label(row: dict[str, Any]) -> str:
156
+ parts = [
157
+ str(row.get("品牌名称") or "未知品牌"),
158
+ str(row.get("项目名称") or "未知项目"),
159
+ str(row.get("城市名称") or "未知城市"),
160
+ ]
161
+ return " / ".join(parts)
162
+
163
+
164
+ def _join_limited(values: list[str], limit: int = 3) -> str:
165
+ visible = values[:limit]
166
+ suffix = f"等 {len(values)} 项" if len(values) > limit else ""
167
+ return "、".join(visible + ([suffix] if suffix else []))
168
+
169
+
170
+ def _render_json(row_count: int, limited: bool, rows: list[Any]) -> str:
171
+ return json.dumps(
172
+ {
173
+ "rowCount": row_count,
174
+ "limited": limited,
175
+ "rows": rows,
176
+ },
177
+ ensure_ascii=False,
178
+ indent=2,
179
+ ) + "\n"
180
+
181
+
66
182
  def _render_yaml(question: str, row_count: int, limited: bool, rows: list[Any]) -> str:
67
183
  lines = [
68
184
  f"rowCount: {row_count}",
@@ -73,7 +73,7 @@ def tool_definitions() -> list[Json]:
73
73
  return [
74
74
  {
75
75
  "name": "diagnostic_status",
76
- "description": "检查 Octopus Sponge Agent 配置、缓存和本地持久化状态",
76
+ "description": "调试专用:检查 Octopus Sponge Agent 配置、缓存和本地持久化状态。普通业务查询不要先调用本工具,直接调用 query_sponge。",
77
77
  "inputSchema": {
78
78
  "type": "object",
79
79
  "properties": {},
@@ -82,7 +82,7 @@ def tool_definitions() -> list[Json]:
82
82
  },
83
83
  {
84
84
  "name": "refresh_schema",
85
- "description": "调用 Sponge MCP Server 刷新本地 Schema Cache(表结构缓存)",
85
+ "description": "调试/运维专用:手动刷新本地 Schema Cache(表结构缓存)。只有用户明确要求刷新 schema 时才调用;普通业务查询不要先调用本工具。",
86
86
  "inputSchema": {
87
87
  "type": "object",
88
88
  "required": ["traceId"],
@@ -90,10 +90,6 @@ def tool_definitions() -> list[Json]:
90
90
  "traceId": {
91
91
  "type": "string",
92
92
  "description": "调用链路 ID,必填,会原样传给 Sponge get_schema",
93
- },
94
- "refreshReason": {
95
- "type": "string",
96
- "description": "刷新原因,默认 manual(人工触发)",
97
93
  }
98
94
  },
99
95
  "additionalProperties": False,
@@ -101,18 +97,28 @@ def tool_definitions() -> list[Json]:
101
97
  },
102
98
  {
103
99
  "name": "query_sponge",
104
- "description": "把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行",
100
+ "description": "业务查询唯一入口:把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行。上层模型必须直接调用本工具;不要先调用 diagnostic_status 或 refresh_schema;不要自行生成 SQL;不要解释 schema;不要要求用户提供 SQL;不要改写用户问题。originalQuestion 必须传用户原始话术;如果 question 被上层改写,Agent 会忽略改写版并优先用 originalQuestion 生成 SQL、审计和缓存。对“匹配效果/分析报告/报名多但入职少/在招多但报名少/候选人多但岗位少”等报告类问题,必须只调用一次 query_sponge 处理完整原始问题,不要拆成多个品牌/项目/城市子查询。工具返回后应把 answer 原文复制到最终答复正文完整展示,不要只放在折叠步骤、思考过程或工具调用详情中;如果 answer 已包含分析报告,必须先展示完整报告,再展示总结关键发现;不要自行汇总替代 answer,不要追加“是否继续查询/是否需要更多字段/我可以继续帮你”等引导。默认 fastMode=true、finalAnswerOnly=true,会优先使用缓存和规则模板。",
105
101
  "inputSchema": {
106
102
  "type": "object",
107
- "required": ["question", "userId"],
103
+ "required": ["question", "originalQuestion"],
108
104
  "properties": {
109
- "question": {"type": "string", "description": "用户自然语言问题"},
110
- "userId": {"type": "integer", "description": "当前登录用户对应的 Duliri userId"},
105
+ "question": {"type": "string", "description": "当前要处理的问题;兼容旧调用方"},
106
+ "originalQuestion": {
107
+ "type": "string",
108
+ "description": "必填,用户原始话术。上层不得摘要、翻译、拆分或改写;即使 question 与原话一致也要原样传入。",
109
+ },
110
+ "fastMode": {
111
+ "type": "boolean",
112
+ "description": "是否启用快路径;默认 true。true 时优先使用本地 schema 缓存、SQL 缓存和规则模板,失败才走 LLM;false 时优先让 LLM 生成 SQL。",
113
+ },
114
+ "finalAnswerOnly": {
115
+ "type": "boolean",
116
+ "description": "是否只返回最终业务答案;默认 true。普通上层 Agent 应保持 true,并在最终回复正文原文复制 answer,不要只放在折叠步骤里,不要追加后续引导。includeSql=true 的显式调试请求仍会返回 SQL 调试信息。",
117
+ },
111
118
  "sessionId": {"type": "string", "description": "可选会话 ID"},
112
- "deviceId": {"type": "string", "description": "可选设备 ID"},
113
119
  "resultFormat": {
114
120
  "type": "string",
115
- "enum": ["text", "yaml", "md", "markdown", "html"],
121
+ "enum": ["text", "json", "yaml", "md", "markdown", "html"],
116
122
  "description": "结果输出格式;不传则根据问题自动识别",
117
123
  },
118
124
  "includeSql": {
@@ -142,18 +148,18 @@ def call_tool(
142
148
  _refresh_schema(
143
149
  agent,
144
150
  trace_id=_required_string(arguments, "traceId"),
145
- refresh_reason=str(arguments.get("refreshReason") or "manual"),
146
151
  )
147
152
  )
148
153
  if tool_name == "query_sponge":
149
154
  agent = _make_agent(agent_factory)
150
155
  answer_kwargs = {
151
156
  "question": _required_string(arguments, "question"),
152
- "user_id": _required_int(arguments, "userId"),
157
+ "original_question": _required_string(arguments, "originalQuestion"),
158
+ "fast_mode": _optional_bool(arguments, "fastMode", default=True),
153
159
  "session_id": arguments.get("sessionId"),
154
- "device_id": arguments.get("deviceId"),
155
160
  "result_format": arguments.get("resultFormat"),
156
161
  }
162
+ final_answer_only = _optional_bool(arguments, "finalAnswerOnly", default=True)
157
163
  if sampler is not None:
158
164
  answer_kwargs["sampler"] = sampler
159
165
  query_result = agent.query(**answer_kwargs)
@@ -202,23 +208,16 @@ def _diagnostic_status(config: AppConfig) -> Json:
202
208
  }
203
209
 
204
210
 
205
- def _refresh_schema(agent: SpongeAgent, *, trace_id: str, refresh_reason: str) -> Json:
211
+ def _refresh_schema(agent: SpongeAgent, *, trace_id: str) -> Json:
206
212
  cached_schema = agent.schema_cache.load()
207
213
  current_version = str(cached_schema.get("schemaVersion")) if cached_schema and cached_schema.get("schemaVersion") else None
208
214
  schema = agent.mcp_client.get_schema(
209
215
  trace_id=trace_id,
210
216
  schema_version=current_version,
211
- refresh_reason=refresh_reason,
212
217
  )
213
218
  response_changed = schema.get("changed")
214
219
  if schema.get("changed") is False and cached_schema is not None:
215
220
  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
221
  if "tables" in schema:
223
222
  agent.schema_cache.save(schema)
224
223
  schema_source = "cache" if response_changed is False and cached_schema is not None else "response"
@@ -227,7 +226,6 @@ def _refresh_schema(agent: SpongeAgent, *, trace_id: str, refresh_reason: str) -
227
226
  "traceId": trace_id,
228
227
  "tool": "get_schema",
229
228
  "schemaVersion": schema.get("schemaVersion"),
230
- "refreshReason": refresh_reason,
231
229
  "changed": response_changed,
232
230
  }
233
231
  )
@@ -236,7 +234,6 @@ def _refresh_schema(agent: SpongeAgent, *, trace_id: str, refresh_reason: str) -
236
234
  "request": {
237
235
  "traceId": trace_id,
238
236
  "schemaVersion": current_version,
239
- "refreshReason": refresh_reason,
240
237
  },
241
238
  "schema": {
242
239
  "schemaVersion": schema.get("schemaVersion"),
@@ -264,6 +261,24 @@ def _required_string(arguments: Json, key: str) -> str:
264
261
  return value
265
262
 
266
263
 
264
+ def _optional_string(arguments: Json, key: str) -> str | None:
265
+ value = arguments.get(key)
266
+ if value is None:
267
+ return None
268
+ if not isinstance(value, str):
269
+ raise ValueError(f"{key} must be a string")
270
+ return value or None
271
+
272
+
273
+ def _optional_bool(arguments: Json, key: str, *, default: bool) -> bool:
274
+ value = arguments.get(key)
275
+ if value is None:
276
+ return default
277
+ if not isinstance(value, bool):
278
+ raise ValueError(f"{key} must be a boolean")
279
+ return value
280
+
281
+
267
282
  def _required_int(arguments: Json, key: str) -> int:
268
283
  value = arguments.get(key)
269
284
  if not isinstance(value, int):