@roll-agent/octopus-agent 0.0.1 → 0.0.4

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.
@@ -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):
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import time
4
5
  from dataclasses import dataclass
5
6
  from pathlib import Path
6
7
  from typing import Any
@@ -41,3 +42,8 @@ class SchemaCache:
41
42
  version = schema.get("schemaVersion")
42
43
  return str(version) if version is not None else None
43
44
 
45
+ def is_fresh(self, max_age_minutes: int) -> bool:
46
+ if max_age_minutes <= 0 or not self.path.exists():
47
+ return False
48
+ age_seconds = time.time() - self.path.stat().st_mtime
49
+ return age_seconds <= max_age_minutes * 60
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from dataclasses import dataclass
4
5
  from typing import Any
5
6
 
@@ -13,17 +14,69 @@ class SchemaContext:
13
14
  examples: list[dict[str, Any]]
14
15
 
15
16
 
17
+ LOCATION_RECRUITING_TABLES = (
18
+ "province",
19
+ "city",
20
+ "region",
21
+ "job_basic_info",
22
+ "job_address",
23
+ "job_store",
24
+ "store",
25
+ )
26
+
27
+ WORK_ORDER_DETAIL_TABLES = (
28
+ "mvp_applied_jobs_by_helped_account",
29
+ "brand",
30
+ "job_basic_info",
31
+ "sponge_project",
32
+ )
33
+
34
+
16
35
  def retrieve_schema_context(schema: dict[str, Any], question: str, *, max_tables: int = 8) -> SchemaContext:
17
36
  glossary = _matching_items(schema.get("glossary", []), question, keys=("term", "description"))
18
37
  metrics = _matching_items(schema.get("metrics", []), question, keys=("name", "metric", "description", "definition"))
19
38
  examples = _matching_items(schema.get("examples", []), question, keys=("question", "description"))
20
39
  table_names_from_context = _target_table_names(glossary + metrics + examples)
21
- tables = _rank_tables(schema.get("tables", []), question, table_names_from_context)[:max_tables]
40
+ tables = _select_tables(schema.get("tables", []), question, table_names_from_context, max_tables)
22
41
  selected_table_names = {_table_name(table) for table in tables}
23
42
  joins = _filter_joins(schema.get("joins", []), selected_table_names)
24
43
  return SchemaContext(tables=tables, joins=joins, glossary=glossary, metrics=metrics, examples=examples)
25
44
 
26
45
 
46
+ def _select_tables(tables: Any, question: str, preferred_names: set[str], max_tables: int) -> list[dict[str, Any]]:
47
+ ranked = _rank_tables(tables, question, preferred_names)
48
+ if _needs_work_order_detail_tables(question) and isinstance(tables, list):
49
+ return _prepend_named_tables(tables, ranked, WORK_ORDER_DETAIL_TABLES, max_tables)
50
+ if not _needs_location_recruiting_tables(question) or not isinstance(tables, list):
51
+ return ranked[:max_tables]
52
+
53
+ return _prepend_named_tables(tables, ranked, LOCATION_RECRUITING_TABLES, max_tables)
54
+
55
+
56
+ def _prepend_named_tables(
57
+ tables: Any,
58
+ ranked: list[dict[str, Any]],
59
+ names: tuple[str, ...],
60
+ max_tables: int,
61
+ ) -> list[dict[str, Any]]:
62
+ by_name = {_table_name(table): table for table in tables if isinstance(table, dict)}
63
+ selected: list[dict[str, Any]] = []
64
+ seen: set[str] = set()
65
+ for name in names:
66
+ table = by_name.get(name)
67
+ if table is not None:
68
+ selected.append(table)
69
+ seen.add(name)
70
+ for table in ranked:
71
+ name = _table_name(table)
72
+ if name not in seen:
73
+ selected.append(table)
74
+ seen.add(name)
75
+ if len(selected) >= max_tables:
76
+ break
77
+ return selected[:max_tables]
78
+
79
+
27
80
  def _rank_tables(tables: Any, question: str, preferred_names: set[str]) -> list[dict[str, Any]]:
28
81
  if not isinstance(tables, list):
29
82
  return []
@@ -33,6 +86,8 @@ def _rank_tables(tables: Any, question: str, preferred_names: set[str]) -> list[
33
86
  continue
34
87
  name = _table_name(table)
35
88
  score = 5 if name in preferred_names else 0
89
+ if _needs_work_order_detail_tables(question) and name in WORK_ORDER_DETAIL_TABLES:
90
+ score += 10
36
91
  text = " ".join(
37
92
  [
38
93
  name,
@@ -53,14 +108,29 @@ def _matching_items(items: Any, question: str, *, keys: tuple[str, ...]) -> list
53
108
  if not isinstance(items, list):
54
109
  return []
55
110
  tokens = _question_tokens(question)
56
- matches: list[dict[str, Any]] = []
111
+ matches: list[tuple[int, dict[str, Any]]] = []
57
112
  for item in items:
58
113
  if not isinstance(item, dict):
59
114
  continue
60
- text = " ".join(str(item.get(key) or "") for key in keys).lower()
61
- if any(token and token in text for token in tokens):
62
- matches.append(item)
63
- return matches[:8]
115
+ text = " ".join(str(item.get(key) or "") for key in keys + ("sql",)).lower()
116
+ score = sum(1 for token in tokens if token and token in text)
117
+ if _needs_work_order_detail_tables(question):
118
+ for term in (
119
+ "mvp_applied_jobs_by_helped_account",
120
+ "work_order_status",
121
+ "current_helper_status_id",
122
+ "job_basic_info.job_id",
123
+ "报名",
124
+ "工单",
125
+ "候选人状态",
126
+ "面试成功",
127
+ ):
128
+ if term.lower() in text:
129
+ score += 5
130
+ if score > 0:
131
+ matches.append((score, item))
132
+ matches.sort(key=lambda match: match[0], reverse=True)
133
+ return [item for _, item in matches[:8]]
64
134
 
65
135
 
66
136
  def _target_table_names(items: list[dict[str, Any]]) -> set[str]:
@@ -70,6 +140,9 @@ def _target_table_names(items: list[dict[str, Any]]) -> set[str]:
70
140
  value = item.get(key)
71
141
  if isinstance(value, list):
72
142
  names.update(str(name) for name in value)
143
+ sql = item.get("sql")
144
+ if isinstance(sql, str):
145
+ names.update(referenced_table for referenced_table in _referenced_tables(sql))
73
146
  targets = item.get("targets")
74
147
  if isinstance(targets, list):
75
148
  for target in targets:
@@ -108,8 +181,40 @@ def _question_tokens(question: str) -> list[str]:
108
181
  lowered = question.lower()
109
182
  tokens = [lowered]
110
183
  tokens.extend(part for part in lowered.replace("_", " ").replace("-", " ").split() if part)
111
- for word in ("品牌", "项目", "岗位", "门店", "招聘", "在招", "数量", "列表"):
184
+ for word in (
185
+ "品牌",
186
+ "项目",
187
+ "岗位",
188
+ "门店",
189
+ "招聘",
190
+ "在招",
191
+ "数量",
192
+ "列表",
193
+ "报名",
194
+ "报名情况",
195
+ "报名工单",
196
+ "工单",
197
+ "候选人",
198
+ "候选人状态",
199
+ "面试成功",
200
+ "进行中",
201
+ "姓名",
202
+ ):
112
203
  if word in question:
113
204
  tokens.append(word)
114
205
  return list(dict.fromkeys(tokens))
115
206
 
207
+
208
+ def _referenced_tables(sql: str) -> set[str]:
209
+ return set(match.group(1) for match in re.finditer(r"\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b", sql, re.IGNORECASE))
210
+
211
+
212
+ def _needs_location_recruiting_tables(question: str) -> bool:
213
+ return any(word in question for word in ("岗位", "门店", "招聘", "在招"))
214
+
215
+
216
+ def _needs_work_order_detail_tables(question: str) -> bool:
217
+ return bool(
218
+ any(word in question for word in ("报名", "工单", "候选人"))
219
+ and any(word in question for word in ("姓名", "岗位", "项目", "候选人状态"))
220
+ )