@roll-agent/octopus-agent 0.1.1 → 0.1.3
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 +70 -5
- package/SKILL.md +14 -13
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/octopus_skill/_version.py +1 -1
- package/src/octopus_skill/agent.py +1075 -307
- package/src/octopus_skill/octopus_run.py +5 -2
- package/src/octopus_skill/prompt_builder.py +3 -0
- package/src/octopus_skill/question_analyzer.py +2 -0
- package/src/octopus_skill/sql_generator.py +129 -14
|
@@ -104,7 +104,7 @@ def tool_definitions() -> list[Json]:
|
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
106
|
"name": "query_sponge",
|
|
107
|
-
"description": "业务查询唯一入口:把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行。只有用户给出明确业务数据查询问题时才调用本工具;用户只是说使用、启动、检查、看看丸子Agent,或确认 Agent 是否可用时,不得调用本工具,应调用 diagnostic_status 后停止,不得编造示例业务问题。本工具内部第一步会检查本地 schemaVersion,并调用 Sponge get_schema 按需刷新 schema cache;普通业务查询不得额外调用 refresh_schema。工具会先按 schema 中文语义识别表、字段、筛选条件、枚举和实体,把 search_entities 的真实命中项固化进 queryPlan,再直接生成、校验并执行 SQL,不再要求用户确认查询计划;状态枚举不单独追问,用户未提出状态条件时首次查询保持宽泛,但查询结果必须展示相关状态字段的中文含义;search_entities 只用于实体别称和模糊召回,品牌/项目等多个实体同时命中时不追问用户,queryPlan 会用同组 OR 条件承载。上层模型必须直接调用本工具;不要自行生成 SQL;不要解释 schema;不要改写用户问题。originalQuestion 必须传用户原始话术。对报告类问题必须只调用一次 query_sponge 处理完整原始问题,不要拆成多个子查询。工具返回后必须把 content[0].text 或 structuredContent.answer 原文作为最终用户可见答复完整展示;finalAnswerOnly=true(默认)时不得改写、删减、摘要、截断或追加内容,表格不得只展示前 N 条。content[0].text 只展示三段式用户可见答复(1.查询实体;2.筛选条件;3.查询结果);structuredContent.executionSteps
|
|
107
|
+
"description": "业务查询唯一入口:把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行。只有用户给出明确业务数据查询问题时才调用本工具;用户只是说使用、启动、检查、看看丸子Agent,或确认 Agent 是否可用时,不得调用本工具,应调用 diagnostic_status 后停止,不得编造示例业务问题。本工具内部第一步会检查本地 schemaVersion,并调用 Sponge get_schema 按需刷新 schema cache;普通业务查询不得额外调用 refresh_schema。工具会先按 schema 中文语义识别表、字段、筛选条件、枚举和实体,把 search_entities 的真实命中项固化进 queryPlan,再直接生成、校验并执行 SQL,不再要求用户确认查询计划;状态枚举不单独追问,用户未提出状态条件时首次查询保持宽泛,但查询结果必须展示相关状态字段的中文含义;search_entities 只用于实体别称和模糊召回,品牌/项目等多个实体同时命中时不追问用户,queryPlan 会用同组 OR 条件承载。上层模型必须直接调用本工具;不要自行生成 SQL;不要解释 schema;不要改写用户问题。originalQuestion 必须传用户原始话术。对报告类问题必须只调用一次 query_sponge 处理完整原始问题,不要拆成多个子查询。工具返回后必须把 content[0].text 或 structuredContent.answer 原文作为最终用户可见答复完整展示;finalAnswerOnly=true(默认)时不得改写、删减、摘要、截断或追加内容,表格不得只展示前 N 条。content[0].text 只展示三段式用户可见答复(1.查询实体;2.筛选条件;3.查询结果);structuredContent.executionSteps 会返回每一步状态和 durationMs,structuredContent.timing 会返回 totalDurationMs 与 stepDurationMs,失败时包含实际 error 原因。默认 fastMode=true、finalAnswerOnly=true,structuredContent.responseMode=verbatim;复杂查询会使用 schema context 和 LLM 生成。",
|
|
108
108
|
"inputSchema": {
|
|
109
109
|
"type": "object",
|
|
110
110
|
"required": ["question", "originalQuestion"],
|
|
@@ -116,7 +116,7 @@ def tool_definitions() -> list[Json]:
|
|
|
116
116
|
},
|
|
117
117
|
"fastMode": {
|
|
118
118
|
"type": "boolean",
|
|
119
|
-
"description": "是否启用快路径;默认 true。true
|
|
119
|
+
"description": "是否启用快路径;默认 true。true 时跳过 LLM SQL 专家复核,仍保留本地规则、validate_sql 与 execute_sql 兜底;false 时仅对含聚合/子查询的高风险 SQL 启用专家复核。",
|
|
120
120
|
},
|
|
121
121
|
"finalAnswerOnly": {
|
|
122
122
|
"type": "boolean",
|
|
@@ -473,6 +473,9 @@ def _query_result_payload(query_result: Any, *, include_sql: bool = False, base_
|
|
|
473
473
|
execution_steps = getattr(query_result, "execution_steps", None)
|
|
474
474
|
if isinstance(execution_steps, list):
|
|
475
475
|
payload["executionSteps"] = execution_steps
|
|
476
|
+
timing = getattr(query_result, "timing", None)
|
|
477
|
+
if isinstance(timing, dict) and timing:
|
|
478
|
+
payload["timing"] = timing
|
|
476
479
|
if include_sql:
|
|
477
480
|
payload.update(
|
|
478
481
|
{
|
|
@@ -168,6 +168,9 @@ def _base_sql_prompt_payload(
|
|
|
168
168
|
"JOIN ... ON 只能写 schema relations / joins 中的等值关联键(如 a.id = b.x_id);"
|
|
169
169
|
"confirmedQueryPlan.filters 里的 LIKE/IN 筛选必须写在 WHERE,禁止写在 JOIN ON;"
|
|
170
170
|
"同一 logicalGroup 且 logicalOperator=OR 的筛选必须用 OR 连接,并用括号包裹;"
|
|
171
|
+
"没有 logicalGroup 的筛选默认用 AND 连接;"
|
|
172
|
+
"不同地点维度(city.name 与 region.name)若没有同一 logicalGroup,必须用 AND 连接,"
|
|
173
|
+
"例如 c.name LIKE '%苏州%' AND r.name LIKE '%昆山%';禁止写成 (c.name LIKE '%苏州%' OR r.name LIKE '%昆山%');"
|
|
171
174
|
"confirmedQueryPlan.fields 中 required=true 的字段必须出现在 SELECT 中;"
|
|
172
175
|
"不要把查询动作、状态变化、时间词或范围词当作名称筛选。"
|
|
173
176
|
),
|
|
@@ -63,6 +63,8 @@ QUESTION_ANALYSIS_SYSTEM_PROMPT = """你是丸子Agent(Octopus Agent)的需
|
|
|
63
63
|
- 例如“成都你六姐明天面试的工单数量”:`成都你六姐` 是品牌/项目候选,`明天` 是 timeRange,`面试` 是业务阶段或动作,`工单数量` 是业务对象和指标;`成都你六姐明天面试` 必须放入 noiseValues,不能作为任何时间字段、名称字段或工单字段的筛选值。
|
|
64
64
|
- 遇到“名称 + 有多少 + 时间词 + 阶段/动作词 + 业务对象”时,查询对象必须是最后的业务对象,指标必须是该业务对象数量;例如“成都你六姐有多少明天面试的工单”必须输出 targetEntities=["工单"]、metrics=["工单数量"],不能输出 targetEntities=["品牌"] 或 targetEntities=["项目"]。
|
|
65
65
|
- 当用户未明确说明“品牌”或“项目”,但名称片段像品牌/项目名称时,entityCandidates 同时输出 brand 和 sponge_project 两个候选,后续模块会走实体检索确认。
|
|
66
|
+
- 地级市倾向 type=city;区/县/旗倾向 type=region;用户同时说了地级市和其下区县/县级市时(如苏州昆山市),entityCandidates 分别输出 city=苏州 与 region=昆山市,二者是层级筛选不是二选一。
|
|
67
|
+
- 用户只说了一个「XX市」且可能是县级市(如江阴市),entityCandidates 同时输出 city 和 region 两个候选(同一 value),后续由 search_entities 决定命中。
|
|
66
68
|
- 只有能作为品牌、项目、门店、城市、岗位等真实名称的词,才可作为 entityCandidates 的非噪声实体值。
|
|
67
69
|
- 不要因为一个词是状态或时间就忽略它;必须保留到正确语义字段里。
|
|
68
70
|
|
|
@@ -970,18 +970,55 @@ def _concept_time_range_field_issue(
|
|
|
970
970
|
return None
|
|
971
971
|
|
|
972
972
|
|
|
973
|
+
_TIME_RANGE_DATE_LITERAL = r"'20\d{2}-\d{1,2}-\d{1,2}(?:\s+\d{1,2}:\d{2}:\d{2})?'"
|
|
974
|
+
|
|
975
|
+
|
|
973
976
|
def _sql_has_time_filter_for_range(sql: str, time_range: TimeRange) -> bool:
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
977
|
+
if _scope_has_time_filter_for_range(sql, time_range):
|
|
978
|
+
return True
|
|
979
|
+
for subquery in _sql_derived_subqueries(sql).values():
|
|
980
|
+
if _sql_has_time_filter_for_range(subquery, time_range):
|
|
981
|
+
return True
|
|
982
|
+
return False
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _mask_derived_subqueries(sql: str) -> str:
|
|
986
|
+
spans = _sql_derived_subquery_spans(sql)
|
|
987
|
+
if not spans:
|
|
988
|
+
return sql
|
|
989
|
+
chars = list(sql)
|
|
990
|
+
for start, end in spans:
|
|
991
|
+
for index in range(start, min(end, len(chars))):
|
|
992
|
+
chars[index] = " "
|
|
993
|
+
return "".join(chars)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _scope_has_time_filter_for_range(sql: str, time_range: TimeRange) -> bool:
|
|
997
|
+
"""Return True if the designated time field is compared to a date literal in this
|
|
998
|
+
query scope.
|
|
999
|
+
|
|
1000
|
+
Nested derived subqueries are masked out here and evaluated separately by
|
|
1001
|
+
``_sql_has_time_filter_for_range`` so alias resolution stays scoped. The check
|
|
1002
|
+
scans the whole scope (not just the top-level WHERE) so the field can satisfy the
|
|
1003
|
+
requirement whether it is used in WHERE or inside a conditional aggregate such as
|
|
1004
|
+
``SUM(CASE WHEN sign_up_time >= ... THEN 1 ELSE 0 END)``, which some schema concepts
|
|
1005
|
+
mandate for period metrics.
|
|
1006
|
+
"""
|
|
1007
|
+
scope_text = _mask_derived_subqueries(sql)
|
|
1008
|
+
op = r"(?:>=|<=|>|<|=)"
|
|
1009
|
+
date = _TIME_RANGE_DATE_LITERAL
|
|
1010
|
+
field_patterns: list[str] = [_time_range_sql_field_pattern(scope_text, time_range)]
|
|
1011
|
+
base_tables = set(_sql_top_level_table_aliases(scope_text).values())
|
|
1012
|
+
if not time_range.table_name or base_tables == {time_range.table_name.lower()}:
|
|
1013
|
+
# Single base table in scope: an unqualified column reference is unambiguous.
|
|
1014
|
+
field_patterns.append(rf"(?<![.\w]){re.escape(time_range.field.lower())}\b")
|
|
1015
|
+
for field_pattern in field_patterns:
|
|
1016
|
+
checks = (
|
|
1017
|
+
rf"{field_pattern}\s*{op}\s*{date}",
|
|
1018
|
+
rf"{date}\s*{op}\s*{field_pattern}",
|
|
1019
|
+
rf"{field_pattern}\s+between\s+{date}\s+and\s+{date}",
|
|
1020
|
+
)
|
|
1021
|
+
if any(re.search(pattern, scope_text, flags=re.IGNORECASE) for pattern in checks):
|
|
985
1022
|
return True
|
|
986
1023
|
return False
|
|
987
1024
|
|
|
@@ -2122,9 +2159,8 @@ def sql_missing_query_plan_filters(sql: str, query_plan: dict[str, Any] | None)
|
|
|
2122
2159
|
missing: list[str] = []
|
|
2123
2160
|
normalized_sql = sql.lower()
|
|
2124
2161
|
grouped_filters: dict[str, list[dict[str, Any]]] = {}
|
|
2125
|
-
for flt in _query_plan_sql_filters(query_plan)
|
|
2126
|
-
|
|
2127
|
-
continue
|
|
2162
|
+
plan_filters = [flt for flt in _query_plan_sql_filters(query_plan) if isinstance(flt, dict)]
|
|
2163
|
+
for flt in plan_filters:
|
|
2128
2164
|
if str(flt.get("operator") or "").upper() != "LIKE":
|
|
2129
2165
|
continue
|
|
2130
2166
|
logical_group = str(flt.get("logicalGroup") or "").strip()
|
|
@@ -2156,6 +2192,18 @@ def sql_missing_query_plan_filters(sql: str, query_plan: dict[str, Any] | None)
|
|
|
2156
2192
|
continue
|
|
2157
2193
|
if not _or_group_filters_connected_by_or(normalized_sql, filters):
|
|
2158
2194
|
missing.append(f"{group_name} requires OR between matched entities")
|
|
2195
|
+
and_pairs = _hierarchical_location_and_filter_pairs(plan_filters)
|
|
2196
|
+
for city_flt, region_flt in and_pairs:
|
|
2197
|
+
city_token = f"{city_flt.get('field')} LIKE {city_flt.get('value')}"
|
|
2198
|
+
region_token = f"{region_flt.get('field')} LIKE {region_flt.get('value')}"
|
|
2199
|
+
if city_token in missing or region_token in missing:
|
|
2200
|
+
continue
|
|
2201
|
+
if _filters_connected_by_or(normalized_sql, [city_flt, region_flt]):
|
|
2202
|
+
missing.append(
|
|
2203
|
+
"city.name and region.name require AND for hierarchical location filters "
|
|
2204
|
+
f"({city_flt.get('value')} AND {region_flt.get('value')})"
|
|
2205
|
+
)
|
|
2206
|
+
break
|
|
2159
2207
|
return missing
|
|
2160
2208
|
|
|
2161
2209
|
|
|
@@ -2171,6 +2219,55 @@ def _query_plan_sql_filters(query_plan: dict[str, Any]) -> list[Any]:
|
|
|
2171
2219
|
return filters if isinstance(filters, list) else []
|
|
2172
2220
|
|
|
2173
2221
|
|
|
2222
|
+
def _location_place_key_for_filter(value: str) -> str:
|
|
2223
|
+
cleaned = str(value or "").strip().strip("%")
|
|
2224
|
+
if not cleaned:
|
|
2225
|
+
return ""
|
|
2226
|
+
for suffix in ("自治州", "地区", "市", "区", "县", "旗"):
|
|
2227
|
+
if cleaned.endswith(suffix) and len(cleaned) > len(suffix):
|
|
2228
|
+
return cleaned[: -len(suffix)]
|
|
2229
|
+
return cleaned
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
def _hierarchical_location_and_filter_pairs(
|
|
2233
|
+
filters: list[dict[str, Any]],
|
|
2234
|
+
) -> list[tuple[dict[str, Any], dict[str, Any]]]:
|
|
2235
|
+
"""City/region filters that must be AND-ed (distinct places, not same-place OR)."""
|
|
2236
|
+
city_filters = [
|
|
2237
|
+
flt
|
|
2238
|
+
for flt in filters
|
|
2239
|
+
if str(flt.get("field") or "") == "city.name" and str(flt.get("operator") or "").upper() == "LIKE"
|
|
2240
|
+
]
|
|
2241
|
+
region_filters = [
|
|
2242
|
+
flt
|
|
2243
|
+
for flt in filters
|
|
2244
|
+
if str(flt.get("field") or "") == "region.name" and str(flt.get("operator") or "").upper() == "LIKE"
|
|
2245
|
+
]
|
|
2246
|
+
if not city_filters or not region_filters:
|
|
2247
|
+
return []
|
|
2248
|
+
pairs: list[tuple[dict[str, Any], dict[str, Any]]] = []
|
|
2249
|
+
for city_flt in city_filters:
|
|
2250
|
+
city_key = _location_place_key_for_filter(str(city_flt.get("value") or ""))
|
|
2251
|
+
city_group = str(city_flt.get("logicalGroup") or "").strip()
|
|
2252
|
+
city_op = str(city_flt.get("logicalOperator") or "").upper()
|
|
2253
|
+
for region_flt in region_filters:
|
|
2254
|
+
region_key = _location_place_key_for_filter(str(region_flt.get("value") or ""))
|
|
2255
|
+
region_group = str(region_flt.get("logicalGroup") or "").strip()
|
|
2256
|
+
region_op = str(region_flt.get("logicalOperator") or "").upper()
|
|
2257
|
+
same_or_group = bool(
|
|
2258
|
+
city_group
|
|
2259
|
+
and city_group == region_group
|
|
2260
|
+
and city_op == "OR"
|
|
2261
|
+
and region_op == "OR"
|
|
2262
|
+
)
|
|
2263
|
+
if same_or_group:
|
|
2264
|
+
continue
|
|
2265
|
+
if city_key and region_key and city_key == region_key:
|
|
2266
|
+
continue
|
|
2267
|
+
pairs.append((city_flt, region_flt))
|
|
2268
|
+
return pairs
|
|
2269
|
+
|
|
2270
|
+
|
|
2174
2271
|
def _or_group_filters_connected_by_or(normalized_sql: str, filters: list[dict[str, Any]]) -> bool:
|
|
2175
2272
|
if not re.search(r"\bor\b", normalized_sql):
|
|
2176
2273
|
return False
|
|
@@ -2189,6 +2286,24 @@ def _or_group_filters_connected_by_or(normalized_sql: str, filters: list[dict[st
|
|
|
2189
2286
|
return bool(re.search(r"\bor\b", between))
|
|
2190
2287
|
|
|
2191
2288
|
|
|
2289
|
+
def _filters_connected_by_or(normalized_sql: str, filters: list[dict[str, Any]]) -> bool:
|
|
2290
|
+
if not re.search(r"\bor\b", normalized_sql):
|
|
2291
|
+
return False
|
|
2292
|
+
positions: list[int] = []
|
|
2293
|
+
for flt in filters:
|
|
2294
|
+
value = str(flt.get("value") or "").strip().strip("%").lower()
|
|
2295
|
+
if not value:
|
|
2296
|
+
continue
|
|
2297
|
+
found = normalized_sql.find(value)
|
|
2298
|
+
if found >= 0:
|
|
2299
|
+
positions.append(found)
|
|
2300
|
+
if len(positions) < 2:
|
|
2301
|
+
return False
|
|
2302
|
+
start, end = min(positions), max(positions)
|
|
2303
|
+
between = normalized_sql[start:end]
|
|
2304
|
+
return bool(re.search(r"\bor\b", between))
|
|
2305
|
+
|
|
2306
|
+
|
|
2192
2307
|
def sql_missing_query_plan_select_fields(sql: str, query_plan: dict[str, Any] | None) -> list[str]:
|
|
2193
2308
|
"""Return required SELECT display fields from query_plan that are absent in sql."""
|
|
2194
2309
|
if not isinstance(query_plan, dict):
|