@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.
- package/README.md +18 -8
- package/SKILL.md +20 -5
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/octopus_sponge_skill/_version.py +1 -1
- package/src/octopus_sponge_skill/agent.py +448 -82
- package/src/octopus_sponge_skill/config.py +5 -1
- package/src/octopus_sponge_skill/context.py +0 -3
- package/src/octopus_sponge_skill/device_info.py +7 -0
- package/src/octopus_sponge_skill/llm_sql_generator.py +23 -6
- package/src/octopus_sponge_skill/main.py +0 -4
- package/src/octopus_sponge_skill/mcp_client.py +7 -25
- package/src/octopus_sponge_skill/prompt_builder.py +42 -15
- package/src/octopus_sponge_skill/query_sql_cache.py +72 -0
- package/src/octopus_sponge_skill/result_renderer.py +118 -2
- package/src/octopus_sponge_skill/roll_server.py +40 -25
- package/src/octopus_sponge_skill/schema_cache.py +6 -0
- package/src/octopus_sponge_skill/schema_context_retriever.py +112 -7
- package/src/octopus_sponge_skill/sql_generator.py +1356 -16
|
@@ -73,7 +73,7 @@ def tool_definitions() -> list[Json]:
|
|
|
73
73
|
return [
|
|
74
74
|
{
|
|
75
75
|
"name": "diagnostic_status",
|
|
76
|
-
"description": "
|
|
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": "
|
|
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": "
|
|
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", "
|
|
103
|
+
"required": ["question", "originalQuestion"],
|
|
108
104
|
"properties": {
|
|
109
|
-
"question": {"type": "string", "description": "
|
|
110
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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 =
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
)
|