@roll-agent/octopus-agent 0.1.2 → 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.
@@ -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 会返回每一步状态,失败时包含实际 error 原因。默认 fastMode=true、finalAnswerOnly=true,structuredContent.responseMode=verbatim;复杂查询会使用 schema context 和 LLM 生成。",
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 时优先使用本地 schema 缓存和规则模板,失败才走 LLM;false 时优先让 LLM 生成 SQL",
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
 
@@ -2159,9 +2159,8 @@ def sql_missing_query_plan_filters(sql: str, query_plan: dict[str, Any] | None)
2159
2159
  missing: list[str] = []
2160
2160
  normalized_sql = sql.lower()
2161
2161
  grouped_filters: dict[str, list[dict[str, Any]]] = {}
2162
- for flt in _query_plan_sql_filters(query_plan):
2163
- if not isinstance(flt, dict):
2164
- continue
2162
+ plan_filters = [flt for flt in _query_plan_sql_filters(query_plan) if isinstance(flt, dict)]
2163
+ for flt in plan_filters:
2165
2164
  if str(flt.get("operator") or "").upper() != "LIKE":
2166
2165
  continue
2167
2166
  logical_group = str(flt.get("logicalGroup") or "").strip()
@@ -2193,6 +2192,18 @@ def sql_missing_query_plan_filters(sql: str, query_plan: dict[str, Any] | None)
2193
2192
  continue
2194
2193
  if not _or_group_filters_connected_by_or(normalized_sql, filters):
2195
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
2196
2207
  return missing
2197
2208
 
2198
2209
 
@@ -2208,6 +2219,55 @@ def _query_plan_sql_filters(query_plan: dict[str, Any]) -> list[Any]:
2208
2219
  return filters if isinstance(filters, list) else []
2209
2220
 
2210
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
+
2211
2271
  def _or_group_filters_connected_by_or(normalized_sql: str, filters: list[dict[str, Any]]) -> bool:
2212
2272
  if not re.search(r"\bor\b", normalized_sql):
2213
2273
  return False
@@ -2226,6 +2286,24 @@ def _or_group_filters_connected_by_or(normalized_sql: str, filters: list[dict[st
2226
2286
  return bool(re.search(r"\bor\b", between))
2227
2287
 
2228
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
+
2229
2307
  def sql_missing_query_plan_select_fields(sql: str, query_plan: dict[str, Any] | None) -> list[str]:
2230
2308
  """Return required SELECT display fields from query_plan that are absent in sql."""
2231
2309
  if not isinstance(query_plan, dict):