@roll-agent/octopus-agent 0.0.10 → 0.0.11
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 +1 -0
- package/SKILL.md +25 -1
- package/package.json +1 -1
- package/pyproject.toml +4 -2
- package/references/env.yaml +17 -0
- package/scripts/refresh_dictionary.py +67 -0
- package/scripts/verify_binding.py +47 -0
- package/src/octopus_skill/_version.py +1 -1
- package/src/octopus_skill/agent.py +1099 -146
- package/src/octopus_skill/config.py +58 -10
- package/src/octopus_skill/entity_binding.py +523 -0
- package/src/octopus_skill/entity_dictionary.py +59 -0
- package/src/octopus_skill/entity_resolver.py +446 -0
- package/src/octopus_skill/llm_sql_generator.py +3 -0
- package/src/octopus_skill/mcp_client.py +16 -0
- package/src/octopus_skill/octopus_run.py +369 -17
- package/src/octopus_skill/prompt_builder.py +74 -31
- package/src/octopus_skill/schema_context_retriever.py +199 -150
- package/src/octopus_skill/sql_generator.py +432 -801
- package/src/octopus_skill/query_sql_cache.py +0 -72
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
4
5
|
import sys
|
|
6
|
+
import time
|
|
5
7
|
from dataclasses import asdict, is_dataclass
|
|
8
|
+
from pathlib import Path
|
|
6
9
|
from typing import Any, Callable
|
|
7
10
|
|
|
8
11
|
from . import __version__
|
|
@@ -17,6 +20,10 @@ PROTOCOL_VERSION = "2024-11-05"
|
|
|
17
20
|
Json = dict[str, Any]
|
|
18
21
|
AgentFactory = Callable[[], SpongeAgent]
|
|
19
22
|
SamplerFactory = Callable[[str, str], str]
|
|
23
|
+
_DEFAULT_CLARIFICATION_SESSION = "__default__"
|
|
24
|
+
_PENDING_CLARIFICATIONS: dict[str, Json] = {}
|
|
25
|
+
_CLARIFICATION_CACHE_PATH = Path.cwd() / "data/sponge-clarification-cache.json"
|
|
26
|
+
_CLARIFICATION_CACHE_TTL_SECONDS = 10 * 60
|
|
20
27
|
|
|
21
28
|
|
|
22
29
|
def main() -> None:
|
|
@@ -97,7 +104,7 @@ def tool_definitions() -> list[Json]:
|
|
|
97
104
|
},
|
|
98
105
|
{
|
|
99
106
|
"name": "query_sponge",
|
|
100
|
-
"description": "业务查询唯一入口:把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行。本工具内部第一步会检查本地 schemaVersion,并调用 Sponge get_schema 按需刷新 schema cache;普通业务查询不得额外调用 refresh_schema。上层模型必须直接调用本工具;不要先调用 diagnostic_status 或 refresh_schema;query_sponge 返回“目前不支持该查询”后也不要自行 retry fastMode=false 或再调用 refresh_schema,除非用户明确要求调试;不要自行生成 SQL;不要解释 schema;不要要求用户提供 SQL;不要改写用户问题。originalQuestion 必须传用户原始话术;如果 question 被上层改写,Agent 会忽略改写版并优先用 originalQuestion 生成 SQL
|
|
107
|
+
"description": "业务查询唯一入口:把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行。本工具内部第一步会检查本地 schemaVersion,并调用 Sponge get_schema 按需刷新 schema cache;普通业务查询不得额外调用 refresh_schema。上层模型必须直接调用本工具;不要先调用 diagnostic_status 或 refresh_schema;query_sponge 返回“目前不支持该查询”后也不要自行 retry fastMode=false 或再调用 refresh_schema,除非用户明确要求调试;不要自行生成 SQL;不要解释 schema;不要要求用户提供 SQL;不要改写用户问题。originalQuestion 必须传用户原始话术;如果 question 被上层改写,Agent 会忽略改写版并优先用 originalQuestion 生成 SQL 和审计。对“匹配效果/分析报告/报名多但入职少/在招多但报名少/候选人多但岗位少”等报告类问题,必须只调用一次 query_sponge 处理完整原始问题,不要拆成多个品牌/项目/城市子查询。工具返回后必须把 content[0].text 或 structuredContent.answer 原文作为最终用户可见答复完整展示,最终消息必须直接包含完整业务答案;不要只放在折叠步骤、思考过程、工具详情或可折叠步骤里,不要自行改写成摘要;finalAnswerOnly=true(默认)时只能使用 structuredContent.answer 作为最终回复,不得改写、删减或追加内容;如果 structuredContent.needsClarification=true,必须立即停止,用户未确认前不得再次调用 query_sponge,不得自行选择品牌/项目/地点候选,不得把品牌+地点拼成项目名,必须原样展示 structuredContent.answer 并等待用户确认;用户确认品牌/项目后,不要重新发现工具,不要调用 diagnostic_status 或 refresh_schema,不要解释 schema,不要自行判断“schema 不支持项目维度”,必须直接再次调用 query_sponge 并传 clarificationSelection;clarificationSelection 必须包含 baseQuestion、entityType、entityId、entityName,优先从 structuredContent.clarificationContext 构造,避免依赖进程内存。如果 answer 已包含分析报告,必须先展示完整报告,再展示总结关键发现;不要自行汇总替代 answer,不要追加“是否继续查询/是否需要更多字段/我可以继续帮你”等引导。如果 answer 开头包含“已识别并用于筛选:…”这类实体识别说明(说明系统已把口语/简称/错字归一为标准品牌、项目、地点并据此过滤),必须在最终回复中原样保留并展示这条说明,不得删除或改写,便于用户确认筛选口径。默认 fastMode=true、finalAnswerOnly=true,structuredContent.responseMode=verbatim,便于上层 Agent 直接把 answer 展示给用户;复杂查询会使用 schema context 和 LLM 生成。",
|
|
101
108
|
"inputSchema": {
|
|
102
109
|
"type": "object",
|
|
103
110
|
"required": ["question", "originalQuestion"],
|
|
@@ -109,11 +116,11 @@ def tool_definitions() -> list[Json]:
|
|
|
109
116
|
},
|
|
110
117
|
"fastMode": {
|
|
111
118
|
"type": "boolean",
|
|
112
|
-
"description": "是否启用快路径;默认 true。true 时优先使用本地 schema
|
|
119
|
+
"description": "是否启用快路径;默认 true。true 时优先使用本地 schema 缓存和规则模板,失败才走 LLM;false 时优先让 LLM 生成 SQL。",
|
|
113
120
|
},
|
|
114
121
|
"finalAnswerOnly": {
|
|
115
122
|
"type": "boolean",
|
|
116
|
-
"description": "是否只返回最终业务答案;默认 true
|
|
123
|
+
"description": "是否只返回最终业务答案;默认 true。true 时 content 与 structuredContent.answer 均为 verbatim 业务答复,上层 Agent 必须原样展示给用户,不要只放在折叠步骤里,不要改写成摘要或追加后续引导。includeSql=true 的显式调试请求仍会返回 SQL 调试信息。",
|
|
117
124
|
},
|
|
118
125
|
"sessionId": {"type": "string", "description": "可选会话 ID"},
|
|
119
126
|
"resultFormat": {
|
|
@@ -125,6 +132,31 @@ def tool_definitions() -> list[Json]:
|
|
|
125
132
|
"type": "boolean",
|
|
126
133
|
"description": "是否在回参中包含 generatedSql、normalizedSql、repairedSql 等调试信息",
|
|
127
134
|
},
|
|
135
|
+
"clarificationSelection": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"required": ["entityType", "entityId"],
|
|
138
|
+
"description": "用户已确认候选项时传入的结构化选择。未经用户确认不得传;无状态调用必须同时传 baseQuestion 和 entityName。",
|
|
139
|
+
"properties": {
|
|
140
|
+
"baseQuestion": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"description": "原始业务问题,优先使用上一轮 structuredContent.clarificationContext.baseQuestion",
|
|
143
|
+
},
|
|
144
|
+
"entityType": {
|
|
145
|
+
"type": "string",
|
|
146
|
+
"enum": ["brand", "project"],
|
|
147
|
+
"description": "实体类型:brand=品牌,project=项目",
|
|
148
|
+
},
|
|
149
|
+
"entityId": {
|
|
150
|
+
"type": ["integer", "string"],
|
|
151
|
+
"description": "候选实体 ID,必须来自上一轮 clarificationPayload",
|
|
152
|
+
},
|
|
153
|
+
"entityName": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"description": "候选实体名称,必须来自上一轮 clarificationPayload",
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
"additionalProperties": False,
|
|
159
|
+
},
|
|
128
160
|
},
|
|
129
161
|
"additionalProperties": False,
|
|
130
162
|
},
|
|
@@ -152,28 +184,45 @@ def call_tool(
|
|
|
152
184
|
)
|
|
153
185
|
if tool_name == "query_sponge":
|
|
154
186
|
agent = _make_agent(agent_factory)
|
|
187
|
+
question = _required_string(arguments, "question")
|
|
188
|
+
original_question = _required_string(arguments, "originalQuestion")
|
|
189
|
+
session_id = arguments.get("sessionId")
|
|
190
|
+
resolved_question = _resolve_pending_clarification(
|
|
191
|
+
question=question,
|
|
192
|
+
original_question=original_question,
|
|
193
|
+
session_id=session_id,
|
|
194
|
+
clarification_selection=arguments.get("clarificationSelection"),
|
|
195
|
+
)
|
|
196
|
+
if resolved_question is not None:
|
|
197
|
+
question = resolved_question
|
|
198
|
+
original_question = resolved_question
|
|
199
|
+
_forget_pending_clarification(session_id)
|
|
200
|
+
elif not _is_clarification_reply(original_question):
|
|
201
|
+
_forget_pending_clarification(session_id)
|
|
155
202
|
answer_kwargs = {
|
|
156
|
-
"question":
|
|
157
|
-
"original_question":
|
|
203
|
+
"question": question,
|
|
204
|
+
"original_question": original_question,
|
|
158
205
|
"fast_mode": _optional_bool(arguments, "fastMode", default=True),
|
|
159
|
-
"session_id":
|
|
206
|
+
"session_id": session_id,
|
|
160
207
|
"result_format": arguments.get("resultFormat"),
|
|
161
208
|
}
|
|
162
209
|
final_answer_only = _optional_bool(arguments, "finalAnswerOnly", default=True)
|
|
163
210
|
if sampler is not None:
|
|
164
211
|
answer_kwargs["sampler"] = sampler
|
|
165
212
|
query_result = agent.query(**answer_kwargs)
|
|
213
|
+
_sync_pending_clarification(
|
|
214
|
+
query_result,
|
|
215
|
+
question=question,
|
|
216
|
+
original_question=original_question,
|
|
217
|
+
session_id=session_id,
|
|
218
|
+
)
|
|
219
|
+
base_question = original_question or question
|
|
166
220
|
if arguments.get("includeSql") is True:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
"repairedSql": query_result.repaired_sql,
|
|
173
|
-
"validation": query_result.validation,
|
|
174
|
-
}
|
|
175
|
-
)
|
|
176
|
-
return _tool_text(query_result.answer)
|
|
221
|
+
payload = _query_result_payload(query_result, include_sql=True, base_question=base_question)
|
|
222
|
+
return _tool_json(payload, structured_content=payload)
|
|
223
|
+
if final_answer_only:
|
|
224
|
+
return _tool_final_answer(query_result, base_question=base_question)
|
|
225
|
+
return _tool_query_answer(query_result, final_answer_only=False, base_question=base_question)
|
|
177
226
|
return _tool_error(f"Unknown tool: {tool_name}", code="unknown_tool")
|
|
178
227
|
except Exception as exc:
|
|
179
228
|
return _tool_error(str(exc), code=type(exc).__name__)
|
|
@@ -205,6 +254,22 @@ def _diagnostic_status(config: AppConfig) -> Json:
|
|
|
205
254
|
"enabled": config.audit.enabled,
|
|
206
255
|
"logPath": str(config.audit.log_path),
|
|
207
256
|
},
|
|
257
|
+
"binding": _binding_status(config),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _binding_status(config: AppConfig) -> Json:
|
|
262
|
+
binding = config.binding
|
|
263
|
+
dictionary = config.dictionary
|
|
264
|
+
return {
|
|
265
|
+
"configured": binding is not None,
|
|
266
|
+
"shadowMode": bool(binding.shadow_mode) if binding is not None else None,
|
|
267
|
+
"enabledEntityTypes": list(binding.enabled_entity_types) if binding is not None else [],
|
|
268
|
+
"minFuzzyScore": binding.min_fuzzy_score if binding is not None else None,
|
|
269
|
+
"dictionaryCachePath": str(dictionary.cache_path) if dictionary is not None else None,
|
|
270
|
+
"dictionaryCacheExists": dictionary.cache_path.exists() if dictionary is not None else False,
|
|
271
|
+
"geoAliasPath": str(dictionary.geo_alias_path) if dictionary is not None else None,
|
|
272
|
+
"geoAliasExists": dictionary.geo_alias_path.exists() if dictionary is not None else False,
|
|
208
273
|
}
|
|
209
274
|
|
|
210
275
|
|
|
@@ -286,12 +351,296 @@ def _required_int(arguments: Json, key: str) -> int:
|
|
|
286
351
|
return value
|
|
287
352
|
|
|
288
353
|
|
|
354
|
+
def _clarification_key(session_id: Any) -> str:
|
|
355
|
+
if isinstance(session_id, str) and session_id.strip():
|
|
356
|
+
return session_id
|
|
357
|
+
return _DEFAULT_CLARIFICATION_SESSION
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _forget_pending_clarification(session_id: Any) -> None:
|
|
361
|
+
_PENDING_CLARIFICATIONS.pop(_clarification_key(session_id), None)
|
|
362
|
+
_delete_cached_clarification(session_id)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _load_pending_clarification(session_id: Any) -> Json | None:
|
|
366
|
+
key = _clarification_key(session_id)
|
|
367
|
+
cached = _read_clarification_cache().get(key)
|
|
368
|
+
if not isinstance(cached, dict):
|
|
369
|
+
return None
|
|
370
|
+
updated_at = cached.get("updatedAt")
|
|
371
|
+
if not isinstance(updated_at, (int, float)) or time.time() - float(updated_at) > _CLARIFICATION_CACHE_TTL_SECONDS:
|
|
372
|
+
_delete_cached_clarification(session_id)
|
|
373
|
+
return None
|
|
374
|
+
question = cached.get("question")
|
|
375
|
+
payload = cached.get("payload")
|
|
376
|
+
if not isinstance(question, str) or not isinstance(payload, dict):
|
|
377
|
+
_delete_cached_clarification(session_id)
|
|
378
|
+
return None
|
|
379
|
+
pending = {"question": question, "payload": payload}
|
|
380
|
+
_PENDING_CLARIFICATIONS[key] = pending
|
|
381
|
+
return pending
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _save_pending_clarification(session_id: Any, pending: Json) -> None:
|
|
385
|
+
question = pending.get("question")
|
|
386
|
+
payload = pending.get("payload")
|
|
387
|
+
if not isinstance(question, str) or not isinstance(payload, dict):
|
|
388
|
+
return
|
|
389
|
+
items = _read_clarification_cache()
|
|
390
|
+
items[_clarification_key(session_id)] = {
|
|
391
|
+
"updatedAt": time.time(),
|
|
392
|
+
"question": question,
|
|
393
|
+
"payload": payload,
|
|
394
|
+
}
|
|
395
|
+
_write_clarification_cache(items)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _delete_cached_clarification(session_id: Any) -> None:
|
|
399
|
+
items = _read_clarification_cache()
|
|
400
|
+
key = _clarification_key(session_id)
|
|
401
|
+
if key not in items:
|
|
402
|
+
return
|
|
403
|
+
items.pop(key, None)
|
|
404
|
+
_write_clarification_cache(items)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _read_clarification_cache() -> dict[str, Json]:
|
|
408
|
+
try:
|
|
409
|
+
if not _CLARIFICATION_CACHE_PATH.exists():
|
|
410
|
+
return {}
|
|
411
|
+
data = json.loads(_CLARIFICATION_CACHE_PATH.read_text(encoding="utf-8"))
|
|
412
|
+
except (OSError, json.JSONDecodeError):
|
|
413
|
+
return {}
|
|
414
|
+
if not isinstance(data, dict):
|
|
415
|
+
return {}
|
|
416
|
+
items = data.get("items")
|
|
417
|
+
return items if isinstance(items, dict) else {}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _write_clarification_cache(items: dict[str, Json]) -> None:
|
|
421
|
+
try:
|
|
422
|
+
if items:
|
|
423
|
+
_CLARIFICATION_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
424
|
+
_CLARIFICATION_CACHE_PATH.write_text(
|
|
425
|
+
json.dumps({"items": items}, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
426
|
+
encoding="utf-8",
|
|
427
|
+
)
|
|
428
|
+
elif _CLARIFICATION_CACHE_PATH.exists():
|
|
429
|
+
_CLARIFICATION_CACHE_PATH.unlink()
|
|
430
|
+
except OSError:
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _sync_pending_clarification(
|
|
435
|
+
query_result: Any,
|
|
436
|
+
*,
|
|
437
|
+
question: str,
|
|
438
|
+
original_question: str,
|
|
439
|
+
session_id: Any,
|
|
440
|
+
) -> None:
|
|
441
|
+
payload = getattr(query_result, "clarification_payload", None)
|
|
442
|
+
if (
|
|
443
|
+
bool(getattr(query_result, "needs_clarification", False))
|
|
444
|
+
and getattr(query_result, "clarification_type", None) == "entity_resolution"
|
|
445
|
+
and isinstance(payload, dict)
|
|
446
|
+
and (payload.get("brands") or payload.get("projects"))
|
|
447
|
+
):
|
|
448
|
+
pending = {
|
|
449
|
+
"question": original_question or question,
|
|
450
|
+
"payload": _jsonable(payload),
|
|
451
|
+
}
|
|
452
|
+
_PENDING_CLARIFICATIONS[_clarification_key(session_id)] = pending
|
|
453
|
+
_save_pending_clarification(session_id, pending)
|
|
454
|
+
return
|
|
455
|
+
_forget_pending_clarification(session_id)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _resolve_pending_clarification(
|
|
459
|
+
*,
|
|
460
|
+
question: str,
|
|
461
|
+
original_question: str,
|
|
462
|
+
session_id: Any,
|
|
463
|
+
clarification_selection: Any = None,
|
|
464
|
+
) -> str | None:
|
|
465
|
+
pending = _PENDING_CLARIFICATIONS.get(_clarification_key(session_id)) or _load_pending_clarification(session_id)
|
|
466
|
+
if clarification_selection is not None:
|
|
467
|
+
selected = None
|
|
468
|
+
if pending is not None:
|
|
469
|
+
selected = _select_structured_clarification_candidate(clarification_selection, pending.get("payload"))
|
|
470
|
+
if selected is None:
|
|
471
|
+
return _stateless_clarification_question(clarification_selection)
|
|
472
|
+
else:
|
|
473
|
+
text = original_question or question
|
|
474
|
+
if not _is_clarification_reply(text):
|
|
475
|
+
return None
|
|
476
|
+
if pending is None:
|
|
477
|
+
return None
|
|
478
|
+
selected = _select_clarification_candidate(text, pending.get("payload"))
|
|
479
|
+
if selected is None:
|
|
480
|
+
return None
|
|
481
|
+
entity_type, candidate = selected
|
|
482
|
+
label = "品牌" if entity_type == "brand" else "项目"
|
|
483
|
+
name = str(candidate.get("name") or "")
|
|
484
|
+
entity_id = candidate.get("id")
|
|
485
|
+
base_question = str(pending.get("question") or question)
|
|
486
|
+
if entity_id is None:
|
|
487
|
+
return f"{base_question},按{label}「{name}」查询"
|
|
488
|
+
return f"{base_question},按{label}「{name}」(id: {entity_id})查询"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _is_clarification_reply(text: str) -> bool:
|
|
492
|
+
cleaned = _normalize_clarification_reply(text)
|
|
493
|
+
if len(cleaned) > 30:
|
|
494
|
+
return False
|
|
495
|
+
return bool(
|
|
496
|
+
re.fullmatch(r"(?:按)?(?:品牌|项目)(?:查询)?(?:id)?\d*", cleaned)
|
|
497
|
+
or re.fullmatch(r"(?:品牌|项目)\d+", cleaned)
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _normalize_clarification_reply(text: str) -> str:
|
|
502
|
+
return re.sub(r"[\s::,,。;;()()「」“”\"'《》【】]+", "", text or "").lower()
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _select_clarification_candidate(text: str, payload: Any) -> tuple[str, Json] | None:
|
|
506
|
+
if not isinstance(payload, dict):
|
|
507
|
+
return None
|
|
508
|
+
cleaned = _normalize_clarification_reply(text)
|
|
509
|
+
wants_brand = "品牌" in cleaned
|
|
510
|
+
wants_project = "项目" in cleaned
|
|
511
|
+
if wants_brand == wants_project:
|
|
512
|
+
return None
|
|
513
|
+
entity_type = "brand" if wants_brand else "project"
|
|
514
|
+
candidates = payload.get("brands" if entity_type == "brand" else "projects")
|
|
515
|
+
if not isinstance(candidates, list) or not candidates:
|
|
516
|
+
return None
|
|
517
|
+
id_match = re.search(r"(?<!\d)(\d+)(?!\d)", cleaned)
|
|
518
|
+
if id_match is not None:
|
|
519
|
+
wanted_id = id_match.group(1)
|
|
520
|
+
for candidate in candidates:
|
|
521
|
+
if isinstance(candidate, dict) and str(candidate.get("id")) == wanted_id:
|
|
522
|
+
return entity_type, candidate
|
|
523
|
+
return None
|
|
524
|
+
for candidate in candidates:
|
|
525
|
+
if isinstance(candidate, dict):
|
|
526
|
+
return entity_type, candidate
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _select_structured_clarification_candidate(selection: Any, payload: Any) -> tuple[str, Json] | None:
|
|
531
|
+
if not isinstance(selection, dict):
|
|
532
|
+
raise ValueError("clarificationSelection must be an object")
|
|
533
|
+
if not isinstance(payload, dict):
|
|
534
|
+
return None
|
|
535
|
+
entity_type = selection.get("entityType")
|
|
536
|
+
if entity_type not in {"brand", "project"}:
|
|
537
|
+
raise ValueError("clarificationSelection.entityType must be brand or project")
|
|
538
|
+
if "entityId" not in selection:
|
|
539
|
+
raise ValueError("clarificationSelection.entityId is required")
|
|
540
|
+
candidates = payload.get("brands" if entity_type == "brand" else "projects")
|
|
541
|
+
if not isinstance(candidates, list) or not candidates:
|
|
542
|
+
return None
|
|
543
|
+
wanted_id = str(selection.get("entityId"))
|
|
544
|
+
for candidate in candidates:
|
|
545
|
+
if isinstance(candidate, dict) and str(candidate.get("id")) == wanted_id:
|
|
546
|
+
return entity_type, candidate
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _stateless_clarification_question(selection: Any) -> str:
|
|
551
|
+
if not isinstance(selection, dict):
|
|
552
|
+
raise ValueError("clarificationSelection must be an object")
|
|
553
|
+
entity_type = selection.get("entityType")
|
|
554
|
+
if entity_type not in {"brand", "project"}:
|
|
555
|
+
raise ValueError("clarificationSelection.entityType must be brand or project")
|
|
556
|
+
if "entityId" not in selection:
|
|
557
|
+
raise ValueError("clarificationSelection.entityId is required")
|
|
558
|
+
base_question = _required_selection_string(selection, "baseQuestion")
|
|
559
|
+
entity_name = _required_selection_string(selection, "entityName")
|
|
560
|
+
entity_id = selection.get("entityId")
|
|
561
|
+
label = "品牌" if entity_type == "brand" else "项目"
|
|
562
|
+
return f"{base_question},按{label}「{entity_name}」(id: {entity_id})查询"
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _required_selection_string(selection: Json, key: str) -> str:
|
|
566
|
+
value = selection.get(key)
|
|
567
|
+
if not isinstance(value, str) or not value.strip():
|
|
568
|
+
raise ValueError(f"clarificationSelection.{key} is required for stateless clarification")
|
|
569
|
+
return value.strip()
|
|
570
|
+
|
|
571
|
+
|
|
289
572
|
def _tool_text(text: str) -> Json:
|
|
290
573
|
return {"content": [{"type": "text", "text": text}]}
|
|
291
574
|
|
|
292
575
|
|
|
293
|
-
def
|
|
576
|
+
def _tool_final_answer(query_result: Any, *, base_question: str | None = None) -> Json:
|
|
577
|
+
payload = _query_result_payload(query_result, base_question=base_question)
|
|
578
|
+
payload.update(
|
|
579
|
+
{
|
|
580
|
+
"finalAnswerOnly": True,
|
|
581
|
+
"responseMode": "verbatim",
|
|
582
|
+
}
|
|
583
|
+
)
|
|
584
|
+
return {
|
|
585
|
+
"content": [{"type": "text", "text": str(getattr(query_result, "answer", ""))}],
|
|
586
|
+
"structuredContent": payload,
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _tool_query_answer(query_result: Any, *, final_answer_only: bool, base_question: str | None = None) -> Json:
|
|
591
|
+
payload = _query_result_payload(query_result, base_question=base_question)
|
|
592
|
+
payload["finalAnswerOnly"] = final_answer_only
|
|
593
|
+
payload["responseMode"] = "verbatim"
|
|
294
594
|
return {
|
|
595
|
+
"content": [{"type": "text", "text": str(getattr(query_result, "answer", ""))}],
|
|
596
|
+
"structuredContent": payload,
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _query_result_payload(query_result: Any, *, include_sql: bool = False, base_question: str | None = None) -> Json:
|
|
601
|
+
payload: Json = {
|
|
602
|
+
"answer": str(getattr(query_result, "answer", "")),
|
|
603
|
+
"needsClarification": bool(getattr(query_result, "needs_clarification", False)),
|
|
604
|
+
"clarificationType": getattr(query_result, "clarification_type", None),
|
|
605
|
+
"clarificationPayload": getattr(query_result, "clarification_payload", None),
|
|
606
|
+
}
|
|
607
|
+
clarification_context = _clarification_context(payload["clarificationPayload"], base_question)
|
|
608
|
+
if clarification_context is not None:
|
|
609
|
+
payload["clarificationContext"] = clarification_context
|
|
610
|
+
if include_sql:
|
|
611
|
+
payload.update(
|
|
612
|
+
{
|
|
613
|
+
"generatedSql": getattr(query_result, "generated_sql", ""),
|
|
614
|
+
"normalizedSql": getattr(query_result, "normalized_sql", ""),
|
|
615
|
+
"repairedSql": getattr(query_result, "repaired_sql", None),
|
|
616
|
+
"validation": getattr(query_result, "validation", {}),
|
|
617
|
+
}
|
|
618
|
+
)
|
|
619
|
+
return _jsonable(payload)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _clarification_context(clarification_payload: Any, base_question: str | None) -> Json | None:
|
|
623
|
+
if not isinstance(clarification_payload, dict) or not base_question:
|
|
624
|
+
return None
|
|
625
|
+
brands = clarification_payload.get("brands")
|
|
626
|
+
projects = clarification_payload.get("projects")
|
|
627
|
+
if not brands and not projects:
|
|
628
|
+
return None
|
|
629
|
+
return {
|
|
630
|
+
"baseQuestion": base_question,
|
|
631
|
+
"brands": brands or [],
|
|
632
|
+
"projects": projects or [],
|
|
633
|
+
"selectionFields": {
|
|
634
|
+
"baseQuestion": "原始业务问题",
|
|
635
|
+
"entityType": "实体类型:brand=品牌,project=项目",
|
|
636
|
+
"entityId": "候选实体 ID",
|
|
637
|
+
"entityName": "候选实体名称",
|
|
638
|
+
},
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _tool_json(data: Any, *, structured_content: Any | None = None) -> Json:
|
|
643
|
+
result = {
|
|
295
644
|
"content": [
|
|
296
645
|
{
|
|
297
646
|
"type": "text",
|
|
@@ -299,6 +648,9 @@ def _tool_json(data: Any) -> Json:
|
|
|
299
648
|
}
|
|
300
649
|
]
|
|
301
650
|
}
|
|
651
|
+
if structured_content is not None:
|
|
652
|
+
result["structuredContent"] = _jsonable(structured_content)
|
|
653
|
+
return result
|
|
302
654
|
|
|
303
655
|
|
|
304
656
|
def _tool_error(message: str, *, code: str) -> Json:
|
|
@@ -1,46 +1,41 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import re
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
6
7
|
from .schema_context_retriever import SchemaContext
|
|
8
|
+
from .sql_generator import normalize_text_filters_to_like
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
NL2SQL_SYSTEM_PROMPT = """你是丸子Agent(Octopus Agent)的海绵数据 SQL 生成器。
|
|
10
|
-
你只能基于提供的
|
|
12
|
+
你只能基于提供的 schemaContext 生成 MySQL SELECT 查询,所有表、字段、关联方式、状态枚举、默认过滤和业务口径都必须来自 schemaContext,不得自行猜测或记忆任何业务规则。
|
|
11
13
|
返回 JSON 中的 sql 字段必须直接以 SELECT 开头,不能返回非 SQL 文本。
|
|
12
|
-
只生成业务 SQL
|
|
13
|
-
不得使用 :scope.project_ids、:scope.brand_ids 或任何 :scope.* placeholder(占位符)。
|
|
14
|
+
只生成业务 SQL,不要生成权限占位符或权限条件;不得使用 :scope.project_ids、:scope.brand_ids 或任何 :scope.* placeholder(占位符)。
|
|
14
15
|
如果用户没有明确要求返回条数,LIMIT 必须使用 defaultLimit。
|
|
15
|
-
如果用户要求所有数据、全部数据、全量数据、完整数据,或要求导出/生成 Excel/xlsx/表格文件,LIMIT 必须使用 maxLimit
|
|
16
|
+
如果用户要求所有数据、全部数据、全量数据、完整数据,或要求导出/生成 Excel/xlsx/表格文件,LIMIT 必须使用 maxLimit。
|
|
16
17
|
不得查询 auth 库、密码、令牌、密钥、凭证字段。
|
|
17
|
-
不得输出敏感字段,包括手机号、电话、联系方式、身份证、密码、token、secret、credential、private_key
|
|
18
|
-
如果用户要求敏感字段,必须从 SELECT 中排除这些字段;还有其他非敏感字段时继续生成去敏后的 SQL。
|
|
18
|
+
不得输出敏感字段,包括手机号、电话、联系方式、身份证、密码、token、secret、credential、private_key 等;如果用户要求敏感字段,必须从 SELECT 中排除,仍有其他非敏感字段时继续生成去敏后的 SQL。
|
|
19
19
|
不得生成 INSERT、UPDATE、DELETE、DDL、非 SELECT SQL 或多语句 SQL。
|
|
20
|
-
SELECT 中每个输出字段都必须使用 AS 中文含义,例如 b.name AS
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
如果 schemaContext
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
地点条件必须先按业务字典判断层级:province.name 省份优先,其次 city.name 城市,再其次 region.name 区域。
|
|
40
|
-
用户提到南京、上海、江苏等地点时,不要先猜测为道路、商圈、门店或地址关键词;只有省/城市/区域都无法匹配时,才按地址关键词模糊查询。
|
|
41
|
-
岗位或门店地点查询优先使用 job_address.city_id、job_address.region_id、store.city_id、store.region_id 等结构化字段。
|
|
42
|
-
用户问题包含时间范围时,SQL 的 WHERE 条件必须直接包含对应时间字段过滤,不得先查询全量数据再补充时间范围。
|
|
43
|
-
时间字段按语义选择:创建/新建/新增使用 create_at,发布/上架使用 publish_at,下架使用 off_at,更新/修改使用 update_at。
|
|
20
|
+
SELECT 中每个输出字段都必须使用 AS 中文含义,例如 b.name AS 品牌名称;禁止使用英文别名,例如 brand_name、project_name。
|
|
21
|
+
不得为了凑齐字段写 NULL AS 字段名、CAST(NULL AS ...) AS 字段名 等空占位表达式;schemaContext 中完全没有相关真实字段或关联表时,才允许不返回该字段。
|
|
22
|
+
名称类字段筛选必须使用 LIKE '%值%'(例如 name、job_name、status_text、status_name),禁止对名称类字段使用 = '值' 精确匹配;即使 schemaContext.examples 中出现名称类字段 = '值',也必须改写为 LIKE '%值%'。
|
|
23
|
+
数字 ID、数字状态枚举、删除标记、时间范围和 JOIN ON 表关联条件使用等值或比较符,不要改成 LIKE。
|
|
24
|
+
所有表关联必须使用 schemaContext.joins 给出的关联键;如果关联只出现在 schemaContext.metrics.filters 或 schemaContext.examples 中,也必须严格照用,不要自行假设外键。
|
|
25
|
+
所有默认过滤(软删除、有效性等)必须使用 schemaContext.tableDefaults 给出的条件。
|
|
26
|
+
所有业务指标口径必须使用 schemaContext.metrics 的 definition、aggregation、filters;所有状态枚举、状态映射必须使用 schemaContext.enums,不得自行推断状态值。
|
|
27
|
+
岗位总数、岗位数、职位总数、职位数只表示岗位记录数量,不等于在招岗位数;用户只说“总数”“查询总数”“统计总数”且未提在招/已发布/已上架时,按全部岗位记录计数,不要默认加 status=在招 过滤,SELECT 别名必须使用用户原词(例如 AS 总数),禁止写成 AS 在招岗位数。
|
|
28
|
+
除非用户明确说在招、已发布、已上架等状态词,或 schemaContext.metrics 的指标定义明确要求状态过滤,否则不要默认加岗位状态过滤,也不要把输出别名写成在招/已发布。
|
|
29
|
+
用户明确说在招、已发布、已上架、未发布、已下架等状态词时,必须从 schemaContext.enums 找到对应字段和值并用于 WHERE;例如枚举 label 命中“已发布/已上架/在招”时,只能使用该 label 对应的枚举值,不得把未发布、已下架等其他状态一起放入 IN。
|
|
30
|
+
只有用户明确要求多个状态(例如“岗位状态包括已发布、已下架、未发布”)时,才允许使用这些状态对应枚举值组成 IN。
|
|
31
|
+
字段语义、时间字段选择、地点层级、实体口径等都必须遵循 schemaContext.glossary 的说明。
|
|
32
|
+
如果 schemaContext 没有明确给出所需的表、字段、JOIN、枚举或指标口径,不要猜测;返回 {"sql":"","tables":[],"placeholders":[],"usedColumns":[],"usedJoins":[],"metricRefs":[]}。
|
|
33
|
+
业务查询必须一次生成完整 SQL,不要先查单表列表找 ID,也不要要求外部先提供 ID;需要按名称过滤时直接 JOIN 对应表并使用 name/job_name LIKE。
|
|
34
|
+
如果输入提供了 resolvedEntities,这些是已校正的真实实体(口语、简称、别名、错字已归一到标准名);必须使用 resolvedEntities 中的标准名称或 id 作为过滤条件,并通过 schemaContext.joins / schemaContext.examples 关联到对应业务表,不要再用用户原始口语词做 LIKE,也不要自行猜测实体所属的表或层级。
|
|
35
|
+
resolvedEntities 给出的 table/column/id 表示该实体在主数据表中的位置;若主数据表与业务事实表之间存在关联表,必须按 schemaContext.joins 给出的关联路径连接。
|
|
36
|
+
resolvedEntities.location 可能同时给出 province、city、region 多个层级;必须按其中最细粒度过滤(有 region 用 region,没有才退到 city,再退到 province)。
|
|
37
|
+
resolvedEntities 中给出的每一个实体都必须在 WHERE 中体现为过滤条件,一个都不能省略。
|
|
38
|
+
用户问题包含时间范围时,SQL 的 WHERE 必须直接包含对应时间字段过滤;时间字段按 schemaContext.glossary 的说明选择。
|
|
44
39
|
必须包含 LIMIT,且 LIMIT 不超过 500。
|
|
45
40
|
不要使用 WITH;低版本 MySQL 不支持公共表表达式,请使用 JOIN 或派生表。
|
|
46
41
|
只输出 JSON,不输出解释文本、拒绝文本或 Markdown。"""
|
|
@@ -52,6 +47,7 @@ def build_nl2sql_prompt(
|
|
|
52
47
|
schema_context: SchemaContext,
|
|
53
48
|
default_limit: int,
|
|
54
49
|
max_limit: int,
|
|
50
|
+
resolved_entities: dict[str, Any] | None = None,
|
|
55
51
|
) -> str:
|
|
56
52
|
payload = {
|
|
57
53
|
"question": question,
|
|
@@ -59,6 +55,8 @@ def build_nl2sql_prompt(
|
|
|
59
55
|
"schemaContext": {
|
|
60
56
|
"tables": schema_context.tables,
|
|
61
57
|
"joins": schema_context.joins,
|
|
58
|
+
"tableDefaults": schema_context.table_defaults,
|
|
59
|
+
"enums": schema_context.enums,
|
|
62
60
|
"glossary": schema_context.glossary,
|
|
63
61
|
"metrics": schema_context.metrics,
|
|
64
62
|
"examples": _business_examples(schema_context.examples),
|
|
@@ -72,6 +70,8 @@ def build_nl2sql_prompt(
|
|
|
72
70
|
"metricRefs": ["schemaContext.metrics.name"],
|
|
73
71
|
},
|
|
74
72
|
}
|
|
73
|
+
if resolved_entities:
|
|
74
|
+
payload["resolvedEntities"] = resolved_entities
|
|
75
75
|
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
76
76
|
|
|
77
77
|
|
|
@@ -83,6 +83,7 @@ def build_sql_repair_prompt(
|
|
|
83
83
|
schema_context: SchemaContext,
|
|
84
84
|
default_limit: int,
|
|
85
85
|
max_limit: int,
|
|
86
|
+
resolved_entities: dict[str, Any] | None = None,
|
|
86
87
|
) -> str:
|
|
87
88
|
payload = {
|
|
88
89
|
"task": "repair_sql",
|
|
@@ -93,6 +94,8 @@ def build_sql_repair_prompt(
|
|
|
93
94
|
"schemaContext": {
|
|
94
95
|
"tables": schema_context.tables,
|
|
95
96
|
"joins": schema_context.joins,
|
|
97
|
+
"tableDefaults": schema_context.table_defaults,
|
|
98
|
+
"enums": schema_context.enums,
|
|
96
99
|
"glossary": schema_context.glossary,
|
|
97
100
|
"metrics": schema_context.metrics,
|
|
98
101
|
"examples": _business_examples(schema_context.examples),
|
|
@@ -106,8 +109,48 @@ def build_sql_repair_prompt(
|
|
|
106
109
|
"metricRefs": ["schemaContext.metrics.name"],
|
|
107
110
|
},
|
|
108
111
|
}
|
|
112
|
+
if resolved_entities:
|
|
113
|
+
payload["resolvedEntities"] = resolved_entities
|
|
109
114
|
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
110
115
|
|
|
111
116
|
|
|
112
117
|
def _business_examples(examples: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
113
|
-
|
|
118
|
+
"""Prepare examples for the LLM prompt.
|
|
119
|
+
|
|
120
|
+
The MCP examples carry data-scope predicates (``... IN (:scope.project_ids)``)
|
|
121
|
+
that the LLM must never emit. Rather than dropping every example that has
|
|
122
|
+
them (which removed almost all join guidance), strip the scope predicates
|
|
123
|
+
and keep the rest of the pattern. If an example still contains ``:scope.``
|
|
124
|
+
after stripping, drop it to stay safe.
|
|
125
|
+
"""
|
|
126
|
+
result: list[dict[str, Any]] = []
|
|
127
|
+
for example in examples:
|
|
128
|
+
sql = str(example.get("sql") or "")
|
|
129
|
+
original_sql = sql
|
|
130
|
+
if ":scope." in sql:
|
|
131
|
+
sql = _strip_scope_predicates(sql)
|
|
132
|
+
if ":scope." in sql:
|
|
133
|
+
continue
|
|
134
|
+
sql = _normalize_example_text_filters(sql)
|
|
135
|
+
if sql != original_sql:
|
|
136
|
+
example = {**example, "sql": sql}
|
|
137
|
+
result.append(example)
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _strip_scope_predicates(sql: str) -> str:
|
|
142
|
+
# Remove "AND <column> IN (:scope.<name>)" predicates, then tidy a WHERE
|
|
143
|
+
# clause that may be left starting with a stray AND.
|
|
144
|
+
cleaned = re.sub(
|
|
145
|
+
r"\s+AND\s+[A-Za-z_][\w.]*\s+IN\s*\(\s*:scope\.\w+\s*\)",
|
|
146
|
+
"",
|
|
147
|
+
sql,
|
|
148
|
+
flags=re.IGNORECASE,
|
|
149
|
+
)
|
|
150
|
+
cleaned = re.sub(r"\bWHERE\s+AND\s+", "WHERE ", cleaned, flags=re.IGNORECASE)
|
|
151
|
+
return cleaned
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _normalize_example_text_filters(sql: str) -> str:
|
|
155
|
+
"""Align business examples with SQL guardrails before prompting."""
|
|
156
|
+
return normalize_text_filters_to_like(sql)
|