@roll-agent/octopus-agent 0.1.2 → 0.2.0
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 +26 -125
- package/SKILL.md +27 -102
- package/package.json +12 -18
- package/references/env.yaml +1 -22
- package/scripts/start-octopus-agent.js +1 -125
- package/src/config.js +13 -0
- package/src/context.js +5 -0
- package/src/mcp-client.js +80 -0
- package/src/orchestrator.js +57 -0
- package/src/server-core.js +67 -0
- package/src/server.js +10 -0
- package/src/stdio.js +29 -0
- package/octopus_skill/__init__.py +0 -11
- package/pyproject.toml +0 -19
- package/scripts/refresh_dictionary.py +0 -67
- package/scripts/verify_binding.py +0 -47
- package/src/octopus_skill/__init__.py +0 -5
- package/src/octopus_skill/__main__.py +0 -4
- package/src/octopus_skill/_version.py +0 -3
- package/src/octopus_skill/agent.py +0 -4971
- package/src/octopus_skill/audit_logger.py +0 -29
- package/src/octopus_skill/config.py +0 -129
- package/src/octopus_skill/context.py +0 -17
- package/src/octopus_skill/device_info.py +0 -7
- package/src/octopus_skill/exporter.py +0 -66
- package/src/octopus_skill/llm_result_renderer.py +0 -101
- package/src/octopus_skill/llm_sql_generator.py +0 -197
- package/src/octopus_skill/main.py +0 -26
- package/src/octopus_skill/mcp_client.py +0 -114
- package/src/octopus_skill/octopus_run.py +0 -752
- package/src/octopus_skill/prompt_builder.py +0 -519
- package/src/octopus_skill/question_analyzer.py +0 -399
- package/src/octopus_skill/result_renderer.py +0 -367
- package/src/octopus_skill/schema_cache.py +0 -49
- package/src/octopus_skill/schema_context_retriever.py +0 -1053
- package/src/octopus_skill/sql_generator.py +0 -2788
|
@@ -1,752 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import re
|
|
5
|
-
import sys
|
|
6
|
-
from dataclasses import asdict, is_dataclass
|
|
7
|
-
from typing import Any, Callable
|
|
8
|
-
|
|
9
|
-
from . import __version__
|
|
10
|
-
from .agent import SpongeAgent, ensure_query_plan_summary_answer
|
|
11
|
-
from .config import AppConfig, load_config_from_env
|
|
12
|
-
from .result_renderer import RESULT_ROW_DISPLAY_LIMIT, normalize_result_format, render_result
|
|
13
|
-
from .schema_cache import SchemaCacheError
|
|
14
|
-
|
|
15
|
-
SERVER_NAME = "octopus-agent"
|
|
16
|
-
SERVER_VERSION = __version__
|
|
17
|
-
PROTOCOL_VERSION = "2024-11-05"
|
|
18
|
-
|
|
19
|
-
Json = dict[str, Any]
|
|
20
|
-
AgentFactory = Callable[[], SpongeAgent]
|
|
21
|
-
SamplerFactory = Callable[[str, str], str]
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def main() -> None:
|
|
25
|
-
serve_stdio()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def serve_stdio() -> None:
|
|
29
|
-
config = load_config_from_env()
|
|
30
|
-
session = StdioSession(
|
|
31
|
-
sys.stdin.buffer,
|
|
32
|
-
sys.stdout.buffer,
|
|
33
|
-
max_tokens=config.sampling.max_tokens,
|
|
34
|
-
)
|
|
35
|
-
while True:
|
|
36
|
-
message = session.read_message()
|
|
37
|
-
if message is None:
|
|
38
|
-
return
|
|
39
|
-
response = handle_request(message, sampler=session.sample)
|
|
40
|
-
if response is not None:
|
|
41
|
-
session.write_message(response)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def handle_request(
|
|
45
|
-
message: Json,
|
|
46
|
-
agent_factory: AgentFactory | None = None,
|
|
47
|
-
sampler: SamplerFactory | None = None,
|
|
48
|
-
) -> Json | None:
|
|
49
|
-
method = message.get("method")
|
|
50
|
-
if method is None:
|
|
51
|
-
return _error_response(message.get("id"), -32600, "Invalid request")
|
|
52
|
-
if "id" not in message:
|
|
53
|
-
return None
|
|
54
|
-
|
|
55
|
-
request_id = message["id"]
|
|
56
|
-
try:
|
|
57
|
-
if method == "initialize":
|
|
58
|
-
return _result_response(request_id, initialize_result())
|
|
59
|
-
if method == "ping":
|
|
60
|
-
return _result_response(request_id, {})
|
|
61
|
-
if method == "tools/list":
|
|
62
|
-
return _result_response(request_id, {"tools": tool_definitions()})
|
|
63
|
-
if method == "tools/call":
|
|
64
|
-
params = message.get("params") or {}
|
|
65
|
-
return _result_response(request_id, call_tool(params, agent_factory=agent_factory, sampler=sampler))
|
|
66
|
-
return _error_response(request_id, -32601, f"Method not found: {method}")
|
|
67
|
-
except Exception as exc:
|
|
68
|
-
return _error_response(request_id, -32603, str(exc), {"type": type(exc).__name__})
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def initialize_result() -> Json:
|
|
72
|
-
return {
|
|
73
|
-
"protocolVersion": PROTOCOL_VERSION,
|
|
74
|
-
"capabilities": {"tools": {}},
|
|
75
|
-
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def tool_definitions() -> list[Json]:
|
|
80
|
-
return [
|
|
81
|
-
{
|
|
82
|
-
"name": "diagnostic_status",
|
|
83
|
-
"description": "调试专用:检查丸子Agent(Octopus Agent)配置、缓存和本地持久化状态。用户只是说使用、启动、检查、看看丸子Agent,或确认 Agent 是否可用时,只调用本工具并在返回状态后停止,不得主动调用 query_sponge 或编造示例业务问题。普通业务查询不要先调用本工具,直接调用 query_sponge。",
|
|
84
|
-
"inputSchema": {
|
|
85
|
-
"type": "object",
|
|
86
|
-
"properties": {},
|
|
87
|
-
"additionalProperties": False,
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
{
|
|
91
|
-
"name": "refresh_schema",
|
|
92
|
-
"description": "调试/运维专用:手动刷新本地 Schema Cache(表结构缓存)。只有用户明确要求刷新 schema 时才调用;普通业务查询不要先调用本工具;query_sponge 内部会先检查 schemaVersion 并按需刷新 schema cache,query_sponge 失败后也不要自动调用本工具。",
|
|
93
|
-
"inputSchema": {
|
|
94
|
-
"type": "object",
|
|
95
|
-
"required": ["traceId"],
|
|
96
|
-
"properties": {
|
|
97
|
-
"traceId": {
|
|
98
|
-
"type": "string",
|
|
99
|
-
"description": "调用链路 ID,必填,会原样传给 Sponge get_schema",
|
|
100
|
-
}
|
|
101
|
-
},
|
|
102
|
-
"additionalProperties": False,
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
{
|
|
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 生成。",
|
|
108
|
-
"inputSchema": {
|
|
109
|
-
"type": "object",
|
|
110
|
-
"required": ["question", "originalQuestion"],
|
|
111
|
-
"properties": {
|
|
112
|
-
"question": {"type": "string", "description": "当前要处理的问题;兼容旧调用方"},
|
|
113
|
-
"originalQuestion": {
|
|
114
|
-
"type": "string",
|
|
115
|
-
"description": "必填,用户原始话术。上层不得摘要、翻译、拆分或改写;即使 question 与原话一致也要原样传入。",
|
|
116
|
-
},
|
|
117
|
-
"fastMode": {
|
|
118
|
-
"type": "boolean",
|
|
119
|
-
"description": "是否启用快路径;默认 true。true 时优先使用本地 schema 缓存和规则模板,失败才走 LLM;false 时优先让 LLM 生成 SQL。",
|
|
120
|
-
},
|
|
121
|
-
"finalAnswerOnly": {
|
|
122
|
-
"type": "boolean",
|
|
123
|
-
"description": "是否只返回最终业务答案;默认 true。true 时 content 与 structuredContent.answer 均为三段式 verbatim 答复:1.查询实体;2.筛选条件;3.查询结果。上层 Agent 必须原样展示给用户,不要只放在折叠步骤里,不要改写成摘要、截断表格或追加后续引导。includeSql=true 的显式调试请求仍会返回 SQL 调试信息。",
|
|
124
|
-
},
|
|
125
|
-
"sessionId": {"type": "string", "description": "可选会话 ID"},
|
|
126
|
-
"resultFormat": {
|
|
127
|
-
"type": "string",
|
|
128
|
-
"enum": ["text", "json", "yaml", "md", "markdown", "html"],
|
|
129
|
-
"description": "结果输出格式;不传则根据问题自动识别",
|
|
130
|
-
},
|
|
131
|
-
"includeSql": {
|
|
132
|
-
"type": "boolean",
|
|
133
|
-
"description": "是否在回参中包含 generatedSql、normalizedSql、repairedSql 等调试信息",
|
|
134
|
-
},
|
|
135
|
-
},
|
|
136
|
-
"additionalProperties": False,
|
|
137
|
-
},
|
|
138
|
-
},
|
|
139
|
-
]
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def call_tool(
|
|
143
|
-
params: Json,
|
|
144
|
-
agent_factory: AgentFactory | None = None,
|
|
145
|
-
sampler: SamplerFactory | None = None,
|
|
146
|
-
) -> Json:
|
|
147
|
-
tool_name = params.get("name")
|
|
148
|
-
arguments = params.get("arguments") or {}
|
|
149
|
-
try:
|
|
150
|
-
if tool_name == "diagnostic_status":
|
|
151
|
-
return _tool_json(_diagnostic_status(_load_config()))
|
|
152
|
-
if tool_name == "refresh_schema":
|
|
153
|
-
agent = _make_agent(agent_factory)
|
|
154
|
-
return _tool_json(
|
|
155
|
-
_refresh_schema(
|
|
156
|
-
agent,
|
|
157
|
-
trace_id=_required_string(arguments, "traceId"),
|
|
158
|
-
)
|
|
159
|
-
)
|
|
160
|
-
if tool_name == "query_sponge":
|
|
161
|
-
question = _required_string(arguments, "question")
|
|
162
|
-
original_question = _required_string(arguments, "originalQuestion")
|
|
163
|
-
session_id = arguments.get("sessionId")
|
|
164
|
-
blocked_reason = _blocked_query_call_reason(
|
|
165
|
-
question=question,
|
|
166
|
-
original_question=original_question,
|
|
167
|
-
)
|
|
168
|
-
if blocked_reason is not None:
|
|
169
|
-
return _tool_blocked_query(blocked_reason)
|
|
170
|
-
agent = _make_agent(agent_factory)
|
|
171
|
-
answer_kwargs = {
|
|
172
|
-
"question": question,
|
|
173
|
-
"original_question": original_question,
|
|
174
|
-
"fast_mode": _optional_bool(arguments, "fastMode", default=True),
|
|
175
|
-
"session_id": session_id,
|
|
176
|
-
"result_format": arguments.get("resultFormat"),
|
|
177
|
-
}
|
|
178
|
-
final_answer_only = _optional_bool(arguments, "finalAnswerOnly", default=True)
|
|
179
|
-
if sampler is not None:
|
|
180
|
-
answer_kwargs["sampler"] = sampler
|
|
181
|
-
query_result = agent.query(**answer_kwargs)
|
|
182
|
-
base_question = original_question or question
|
|
183
|
-
if arguments.get("includeSql") is True:
|
|
184
|
-
payload = _query_result_payload(query_result, include_sql=True, base_question=base_question)
|
|
185
|
-
payload["answer"] = _user_visible_answer(query_result, base_question=base_question)
|
|
186
|
-
return _tool_json(payload, structured_content=payload)
|
|
187
|
-
if final_answer_only:
|
|
188
|
-
return _tool_final_answer(query_result, base_question=base_question)
|
|
189
|
-
return _tool_query_answer(query_result, final_answer_only=False, base_question=base_question)
|
|
190
|
-
return _tool_error(f"Unknown tool: {tool_name}", code="unknown_tool")
|
|
191
|
-
except Exception as exc:
|
|
192
|
-
return _tool_error(str(exc), code=type(exc).__name__)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def _diagnostic_status(config: AppConfig) -> Json:
|
|
196
|
-
cache_exists = config.schema.cache_path.exists()
|
|
197
|
-
schema_version: str | None = None
|
|
198
|
-
cache_error: str | None = None
|
|
199
|
-
try:
|
|
200
|
-
schema_version = SpongeAgent.from_config(config).schema_cache.version()
|
|
201
|
-
except SchemaCacheError as exc:
|
|
202
|
-
cache_error = str(exc)
|
|
203
|
-
|
|
204
|
-
return {
|
|
205
|
-
"server": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
206
|
-
"spongeMcp": {
|
|
207
|
-
"baseUrl": config.sponge_mcp.base_url,
|
|
208
|
-
"accessTokenConfigured": bool(config.sponge_mcp.access_token),
|
|
209
|
-
"timeoutMs": config.sponge_mcp.timeout_ms,
|
|
210
|
-
},
|
|
211
|
-
"schema": {
|
|
212
|
-
"cachePath": str(config.schema.cache_path),
|
|
213
|
-
"cacheExists": cache_exists,
|
|
214
|
-
"schemaVersion": schema_version,
|
|
215
|
-
"cacheError": cache_error,
|
|
216
|
-
},
|
|
217
|
-
"audit": {
|
|
218
|
-
"enabled": config.audit.enabled,
|
|
219
|
-
"logPath": str(config.audit.log_path),
|
|
220
|
-
},
|
|
221
|
-
"sampling": {
|
|
222
|
-
"maxTokens": config.sampling.max_tokens,
|
|
223
|
-
},
|
|
224
|
-
"binding": _binding_status(config),
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def _binding_status(config: AppConfig) -> Json:
|
|
229
|
-
binding = config.binding
|
|
230
|
-
return {
|
|
231
|
-
"configured": False,
|
|
232
|
-
"mode": "disabled",
|
|
233
|
-
"entitySource": "schema",
|
|
234
|
-
"dictionaryPersisted": False,
|
|
235
|
-
"shadowMode": bool(binding.shadow_mode) if binding is not None else None,
|
|
236
|
-
"enabledEntityTypes": list(binding.enabled_entity_types) if binding is not None else [],
|
|
237
|
-
"minFuzzyScore": binding.min_fuzzy_score if binding is not None else None,
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def _refresh_schema(agent: SpongeAgent, *, trace_id: str) -> Json:
|
|
242
|
-
cached_schema = agent.schema_cache.load()
|
|
243
|
-
current_version = str(cached_schema.get("schemaVersion")) if cached_schema and cached_schema.get("schemaVersion") else None
|
|
244
|
-
schema = agent.mcp_client.get_schema(
|
|
245
|
-
trace_id=trace_id,
|
|
246
|
-
schema_version=current_version,
|
|
247
|
-
)
|
|
248
|
-
response_changed = schema.get("changed")
|
|
249
|
-
if schema.get("changed") is False and cached_schema is not None:
|
|
250
|
-
schema = cached_schema
|
|
251
|
-
if "tables" in schema:
|
|
252
|
-
agent.schema_cache.save(schema)
|
|
253
|
-
schema_source = "cache" if response_changed is False and cached_schema is not None else "response"
|
|
254
|
-
agent.audit_logger.log(
|
|
255
|
-
{
|
|
256
|
-
"traceId": trace_id,
|
|
257
|
-
"tool": "get_schema",
|
|
258
|
-
"schemaVersion": schema.get("schemaVersion"),
|
|
259
|
-
"changed": response_changed,
|
|
260
|
-
}
|
|
261
|
-
)
|
|
262
|
-
return {
|
|
263
|
-
"changed": response_changed,
|
|
264
|
-
"request": {
|
|
265
|
-
"traceId": trace_id,
|
|
266
|
-
"schemaVersion": current_version,
|
|
267
|
-
},
|
|
268
|
-
"schema": {
|
|
269
|
-
"schemaVersion": schema.get("schemaVersion"),
|
|
270
|
-
"generatedAt": schema.get("generatedAt"),
|
|
271
|
-
"source": schema_source,
|
|
272
|
-
},
|
|
273
|
-
"cachePath": str(agent.schema_cache.path),
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def _load_config() -> AppConfig:
|
|
278
|
-
return load_config_from_env()
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
def _make_agent(agent_factory: AgentFactory | None) -> SpongeAgent:
|
|
282
|
-
if agent_factory is not None:
|
|
283
|
-
return agent_factory()
|
|
284
|
-
return SpongeAgent.from_config(_load_config())
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def _required_string(arguments: Json, key: str) -> str:
|
|
288
|
-
value = arguments.get(key)
|
|
289
|
-
if not isinstance(value, str) or not value:
|
|
290
|
-
raise ValueError(f"{key} is required")
|
|
291
|
-
return value
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def _optional_bool(arguments: Json, key: str, *, default: bool) -> bool:
|
|
295
|
-
value = arguments.get(key)
|
|
296
|
-
if value is None:
|
|
297
|
-
return default
|
|
298
|
-
if not isinstance(value, bool):
|
|
299
|
-
raise ValueError(f"{key} must be a boolean")
|
|
300
|
-
return value
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def _required_int(arguments: Json, key: str) -> int:
|
|
304
|
-
value = arguments.get(key)
|
|
305
|
-
if not isinstance(value, int):
|
|
306
|
-
raise ValueError(f"{key} is required")
|
|
307
|
-
return value
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
_PRESERVATION_SIGNAL_PATTERN = re.compile(
|
|
311
|
-
r"(今天|今日|昨天|昨日|明天|明日|本周|上周|本月|上月|最近\d+天"
|
|
312
|
-
r"|招满|已招满|在招|上架|下架|新发布|新上架"
|
|
313
|
-
r"|\d{4}年\d{1,2}月|\d{1,2}月\d{1,2}日"
|
|
314
|
-
r"|\d+日)"
|
|
315
|
-
)
|
|
316
|
-
_PRESERVATION_SIGNAL_EQUIVALENTS: dict[str, tuple[str, ...]] = {
|
|
317
|
-
"今天": ("今天", "今日"),
|
|
318
|
-
"今日": ("今天", "今日"),
|
|
319
|
-
"昨天": ("昨天", "昨日"),
|
|
320
|
-
"昨日": ("昨天", "昨日"),
|
|
321
|
-
"招满": ("招满", "已招满"),
|
|
322
|
-
"已招满": ("招满", "已招满"),
|
|
323
|
-
}
|
|
324
|
-
_REWRITTEN_SUMMARY_MARKERS = (
|
|
325
|
-
"相关的统计数据",
|
|
326
|
-
"相关统计数据",
|
|
327
|
-
"相关的统计",
|
|
328
|
-
"查询相关数据",
|
|
329
|
-
"查询相关统计",
|
|
330
|
-
)
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
def _extract_preservation_signals(text: str) -> list[str]:
|
|
334
|
-
return list(dict.fromkeys(match.group(0) for match in _PRESERVATION_SIGNAL_PATTERN.finditer(text)))
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def _signal_preserved(signal: str, text: str) -> bool:
|
|
338
|
-
candidates = _PRESERVATION_SIGNAL_EQUIVALENTS.get(signal, (signal,))
|
|
339
|
-
return any(candidate in text for candidate in candidates)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def _missing_preservation_signals(*, source: str, target: str) -> list[str]:
|
|
343
|
-
missing: list[str] = []
|
|
344
|
-
for signal in _extract_preservation_signals(source):
|
|
345
|
-
if not _signal_preserved(signal, target):
|
|
346
|
-
missing.append(signal)
|
|
347
|
-
return missing
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def _looks_like_rewritten_summary(text: str) -> bool:
|
|
351
|
-
normalized = text.strip()
|
|
352
|
-
if len(normalized) > 40 or _extract_preservation_signals(normalized):
|
|
353
|
-
return False
|
|
354
|
-
if not re.search(r"(查询|统计)", normalized):
|
|
355
|
-
return False
|
|
356
|
-
return any(marker in normalized for marker in _REWRITTEN_SUMMARY_MARKERS)
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def _blocked_original_question_reason(*, question: str, original_question: str) -> str | None:
|
|
360
|
-
q = question.strip()
|
|
361
|
-
oq = original_question.strip()
|
|
362
|
-
if not q or not oq:
|
|
363
|
-
return None
|
|
364
|
-
if q == oq and _looks_like_rewritten_summary(oq):
|
|
365
|
-
return (
|
|
366
|
-
"originalQuestion 疑似上层摘要改写(例如“查询岗位相关的统计数据”),"
|
|
367
|
-
"请原样传入用户原始话术,不要摘要、翻译或改写。"
|
|
368
|
-
)
|
|
369
|
-
if q != oq:
|
|
370
|
-
missing = _missing_preservation_signals(source=q, target=oq)
|
|
371
|
-
if missing:
|
|
372
|
-
joined = "、".join(missing)
|
|
373
|
-
return (
|
|
374
|
-
f"originalQuestion 未保留 question 中的关键语义({joined})。"
|
|
375
|
-
"originalQuestion 必须传用户原始话术,上层不得摘要、翻译或改写。"
|
|
376
|
-
)
|
|
377
|
-
return None
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def _blocked_query_call_reason(*, question: str, original_question: str) -> str | None:
|
|
381
|
-
text = (original_question or question or "").strip()
|
|
382
|
-
if not text:
|
|
383
|
-
return "请先输入明确的业务数据查询需求,例如:查询我能看到的品牌列表。"
|
|
384
|
-
if _is_agent_meta_request(text) and not _has_explicit_business_object(text):
|
|
385
|
-
return "请先输入明确的业务数据查询需求;检查或测试丸子Agent时只允许调用 diagnostic_status。"
|
|
386
|
-
rewrite_reason = _blocked_original_question_reason(question=question, original_question=original_question)
|
|
387
|
-
if rewrite_reason is not None:
|
|
388
|
-
return rewrite_reason
|
|
389
|
-
return None
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
def _is_agent_meta_request(text: str) -> bool:
|
|
393
|
-
return bool(
|
|
394
|
-
re.search(r"(丸子|octopus|agent|roll[- ]?core|工具|技能)", text, flags=re.IGNORECASE)
|
|
395
|
-
and re.search(r"(使用|启动|检查|看看|查看|测试|尝试|状态|健康|诊断|可用|功能)", text)
|
|
396
|
-
)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
def _has_explicit_business_object(text: str) -> bool:
|
|
400
|
-
return bool(
|
|
401
|
-
re.search(
|
|
402
|
-
r"(品牌|项目|岗位|门店|工单|报名|入职|候选人|城市|省份|区域|供应商|员工|薪资|招聘|数据|列表|明细|数量|多少|哪些|有哪些)",
|
|
403
|
-
text,
|
|
404
|
-
)
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def _tool_blocked_query(answer: str) -> Json:
|
|
409
|
-
payload: Json = {
|
|
410
|
-
"answer": answer,
|
|
411
|
-
"needsClarification": False,
|
|
412
|
-
"clarificationType": None,
|
|
413
|
-
"clarificationPayload": None,
|
|
414
|
-
"finalAnswerOnly": True,
|
|
415
|
-
"responseMode": "verbatim",
|
|
416
|
-
"blocked": True,
|
|
417
|
-
"blockedReason": answer,
|
|
418
|
-
}
|
|
419
|
-
return {
|
|
420
|
-
"content": _answer_content(answer),
|
|
421
|
-
"structuredContent": payload,
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
def _tool_text(text: str) -> Json:
|
|
426
|
-
return {"content": [{"type": "text", "text": text}]}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
def _answer_content(answer: str) -> list[Json]:
|
|
430
|
-
return [
|
|
431
|
-
{"type": "text", "text": answer},
|
|
432
|
-
]
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
def _tool_final_answer(query_result: Any, *, base_question: str | None = None) -> Json:
|
|
436
|
-
payload = _query_result_payload(query_result, base_question=base_question)
|
|
437
|
-
answer = _user_visible_answer(query_result, base_question=base_question)
|
|
438
|
-
payload["answer"] = answer
|
|
439
|
-
payload.update(
|
|
440
|
-
{
|
|
441
|
-
"finalAnswerOnly": True,
|
|
442
|
-
"responseMode": "verbatim",
|
|
443
|
-
}
|
|
444
|
-
)
|
|
445
|
-
return {
|
|
446
|
-
"content": _answer_content(answer),
|
|
447
|
-
"structuredContent": payload,
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
def _tool_query_answer(query_result: Any, *, final_answer_only: bool, base_question: str | None = None) -> Json:
|
|
452
|
-
payload = _query_result_payload(query_result, base_question=base_question)
|
|
453
|
-
payload["finalAnswerOnly"] = final_answer_only
|
|
454
|
-
payload["responseMode"] = "verbatim"
|
|
455
|
-
answer = _user_visible_answer(query_result, base_question=base_question)
|
|
456
|
-
payload["answer"] = answer
|
|
457
|
-
return {
|
|
458
|
-
"content": _answer_content(answer),
|
|
459
|
-
"structuredContent": payload,
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
def _query_result_payload(query_result: Any, *, include_sql: bool = False, base_question: str | None = None) -> Json:
|
|
464
|
-
payload: Json = {
|
|
465
|
-
"answer": str(getattr(query_result, "answer", "")),
|
|
466
|
-
"needsClarification": bool(getattr(query_result, "needs_clarification", False)),
|
|
467
|
-
"clarificationType": getattr(query_result, "clarification_type", None),
|
|
468
|
-
"clarificationPayload": getattr(query_result, "clarification_payload", None),
|
|
469
|
-
}
|
|
470
|
-
query_plan = getattr(query_result, "query_plan", None)
|
|
471
|
-
if isinstance(query_plan, dict):
|
|
472
|
-
payload["queryPlan"] = _public_query_plan(query_plan)
|
|
473
|
-
execution_steps = getattr(query_result, "execution_steps", None)
|
|
474
|
-
if isinstance(execution_steps, list):
|
|
475
|
-
payload["executionSteps"] = execution_steps
|
|
476
|
-
if include_sql:
|
|
477
|
-
payload.update(
|
|
478
|
-
{
|
|
479
|
-
"generatedSql": getattr(query_result, "generated_sql", ""),
|
|
480
|
-
"normalizedSql": getattr(query_result, "normalized_sql", ""),
|
|
481
|
-
"repairedSql": getattr(query_result, "repaired_sql", None),
|
|
482
|
-
"validation": getattr(query_result, "validation", {}),
|
|
483
|
-
}
|
|
484
|
-
)
|
|
485
|
-
return _jsonable(payload)
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
def _public_query_plan(query_plan: dict[str, Any]) -> Json:
|
|
489
|
-
return {
|
|
490
|
-
key: value
|
|
491
|
-
for key, value in query_plan.items()
|
|
492
|
-
if not str(key).startswith("_")
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
def _user_visible_answer(query_result: Any, *, base_question: str | None = None) -> str:
|
|
497
|
-
answer = str(getattr(query_result, "answer", ""))
|
|
498
|
-
question = base_question or ""
|
|
499
|
-
result_format = getattr(query_result, "result_format", None)
|
|
500
|
-
answer = _ensure_complete_detail_answer(query_result, answer, question, result_format)
|
|
501
|
-
query_plan = getattr(query_result, "query_plan", None)
|
|
502
|
-
return _ensure_two_section_answer(answer, question, result_format, query_plan)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
def _ensure_two_section_answer(
|
|
506
|
-
answer: str,
|
|
507
|
-
question: str,
|
|
508
|
-
result_format: str | None,
|
|
509
|
-
query_plan: Any,
|
|
510
|
-
) -> str:
|
|
511
|
-
if isinstance(query_plan, dict) and query_plan:
|
|
512
|
-
return ensure_query_plan_summary_answer(answer, question, result_format, query_plan)
|
|
513
|
-
if _answer_has_two_section_structure(answer, question, result_format):
|
|
514
|
-
return answer
|
|
515
|
-
return _prepend_missing_query_plan_section(answer, question, result_format)
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
def _answer_has_two_section_structure(answer: str, question: str, result_format: str | None) -> bool:
|
|
519
|
-
output_format = normalize_result_format(question, result_format)
|
|
520
|
-
if output_format == "json":
|
|
521
|
-
try:
|
|
522
|
-
payload = json.loads(answer)
|
|
523
|
-
except json.JSONDecodeError:
|
|
524
|
-
return False
|
|
525
|
-
return (
|
|
526
|
-
isinstance(payload, dict)
|
|
527
|
-
and "查询实体" in payload
|
|
528
|
-
and "筛选条件" in payload
|
|
529
|
-
and "查询结果" in payload
|
|
530
|
-
)
|
|
531
|
-
if output_format == "yaml":
|
|
532
|
-
return "查询实体:" in answer and "筛选条件:" in answer and "查询结果:" in answer
|
|
533
|
-
return "1. 查询实体" in answer and "2. 筛选条件" in answer and "3. 查询结果" in answer
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
def _prepend_missing_query_plan_section(answer: str, question: str, result_format: str | None) -> str:
|
|
537
|
-
output_format = normalize_result_format(question, result_format)
|
|
538
|
-
if output_format == "json":
|
|
539
|
-
try:
|
|
540
|
-
result_payload: Any = json.loads(answer)
|
|
541
|
-
except json.JSONDecodeError:
|
|
542
|
-
result_payload = answer
|
|
543
|
-
return json.dumps(
|
|
544
|
-
{
|
|
545
|
-
"查询实体": ["未返回查询计划"],
|
|
546
|
-
"筛选条件": ["未返回查询计划"],
|
|
547
|
-
"查询结果": result_payload,
|
|
548
|
-
},
|
|
549
|
-
ensure_ascii=False,
|
|
550
|
-
indent=2,
|
|
551
|
-
) + "\n"
|
|
552
|
-
if output_format == "yaml":
|
|
553
|
-
return (
|
|
554
|
-
"查询实体:\n"
|
|
555
|
-
" - 未返回查询计划\n"
|
|
556
|
-
"筛选条件:\n"
|
|
557
|
-
" - 未返回查询计划\n"
|
|
558
|
-
"查询结果:\n"
|
|
559
|
-
+ "\n".join(f" {line}" if line else "" for line in answer.rstrip().splitlines())
|
|
560
|
-
+ "\n"
|
|
561
|
-
)
|
|
562
|
-
if output_format == "html":
|
|
563
|
-
return (
|
|
564
|
-
"<section><p>1. 查询实体:</p><p>未返回查询计划</p></section>"
|
|
565
|
-
"<section><p>2. 筛选条件:</p><p>未返回查询计划</p></section>"
|
|
566
|
-
"<section><p>3. 查询结果:</p>"
|
|
567
|
-
+ answer
|
|
568
|
-
+ "</section>"
|
|
569
|
-
)
|
|
570
|
-
return (
|
|
571
|
-
"1. 查询实体:\n"
|
|
572
|
-
"未返回查询计划\n\n"
|
|
573
|
-
"2. 筛选条件:\n"
|
|
574
|
-
"未返回查询计划\n\n"
|
|
575
|
-
"3. 查询结果:\n"
|
|
576
|
-
f"{answer}"
|
|
577
|
-
)
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
def _ensure_complete_detail_answer(
|
|
581
|
-
query_result: Any,
|
|
582
|
-
answer: str,
|
|
583
|
-
question: str,
|
|
584
|
-
result_format: str | None,
|
|
585
|
-
) -> str:
|
|
586
|
-
raw_result = getattr(query_result, "raw_result", None)
|
|
587
|
-
if not isinstance(raw_result, dict):
|
|
588
|
-
return answer
|
|
589
|
-
rows = raw_result.get("rows")
|
|
590
|
-
if not isinstance(rows, list):
|
|
591
|
-
return answer
|
|
592
|
-
display_count = min(len(rows), RESULT_ROW_DISPLAY_LIMIT)
|
|
593
|
-
if display_count <= 0:
|
|
594
|
-
return answer
|
|
595
|
-
output_format = normalize_result_format(question, result_format)
|
|
596
|
-
if output_format in {"text", "markdown"}:
|
|
597
|
-
if _answer_contains_markdown_table_row(answer, display_count):
|
|
598
|
-
return answer
|
|
599
|
-
return render_result(question, raw_result, "markdown")
|
|
600
|
-
if _answer_contains_display_row(answer, display_count):
|
|
601
|
-
return answer
|
|
602
|
-
local_format = result_format or "markdown"
|
|
603
|
-
return render_result(question, raw_result, local_format)
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
def _answer_contains_markdown_table_row(answer: str, row_number: int) -> bool:
|
|
607
|
-
escaped = re.escape(str(row_number))
|
|
608
|
-
return bool(re.search(rf"(?m)^\|\s*{escaped}\s*\|", answer))
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
def _answer_contains_display_row(answer: str, row_number: int) -> bool:
|
|
612
|
-
escaped = re.escape(str(row_number))
|
|
613
|
-
patterns = (
|
|
614
|
-
rf"(?m)^\|\s*{escaped}\s*\|",
|
|
615
|
-
rf"(?m)^\s*{escaped}[\.\)、]\s+",
|
|
616
|
-
rf"<td>\s*{escaped}\s*</td>",
|
|
617
|
-
rf'"序号"\s*:\s*"?{escaped}"?',
|
|
618
|
-
)
|
|
619
|
-
return any(re.search(pattern, answer) for pattern in patterns)
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
def _tool_json(data: Any, *, structured_content: Any | None = None) -> Json:
|
|
623
|
-
result = {
|
|
624
|
-
"content": [
|
|
625
|
-
{
|
|
626
|
-
"type": "text",
|
|
627
|
-
"text": json.dumps(_jsonable(data), ensure_ascii=False, sort_keys=True),
|
|
628
|
-
}
|
|
629
|
-
]
|
|
630
|
-
}
|
|
631
|
-
if structured_content is not None:
|
|
632
|
-
result["structuredContent"] = _jsonable(structured_content)
|
|
633
|
-
return result
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
def _tool_error(message: str, *, code: str) -> Json:
|
|
637
|
-
return {
|
|
638
|
-
"isError": True,
|
|
639
|
-
"content": [
|
|
640
|
-
{
|
|
641
|
-
"type": "text",
|
|
642
|
-
"text": json.dumps({"code": code, "message": message}, ensure_ascii=False, sort_keys=True),
|
|
643
|
-
}
|
|
644
|
-
],
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
def _jsonable(value: Any) -> Any:
|
|
649
|
-
if is_dataclass(value):
|
|
650
|
-
return _jsonable(asdict(value))
|
|
651
|
-
if isinstance(value, dict):
|
|
652
|
-
return {str(key): _jsonable(item) for key, item in value.items()}
|
|
653
|
-
if isinstance(value, list):
|
|
654
|
-
return [_jsonable(item) for item in value]
|
|
655
|
-
return value
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
def _result_response(request_id: Any, result: Json) -> Json:
|
|
659
|
-
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
def _error_response(request_id: Any, code: int, message: str, data: Json | None = None) -> Json:
|
|
663
|
-
error: Json = {"code": code, "message": message}
|
|
664
|
-
if data is not None:
|
|
665
|
-
error["data"] = data
|
|
666
|
-
return {"jsonrpc": "2.0", "id": request_id, "error": error}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
def _sampling_user_prompt(system_prompt: str, prompt: str) -> str:
|
|
670
|
-
system = (system_prompt or "").strip()
|
|
671
|
-
user = (prompt or "").strip()
|
|
672
|
-
if not system:
|
|
673
|
-
return user
|
|
674
|
-
if not user:
|
|
675
|
-
return system
|
|
676
|
-
return f"{system}\n\n---\n\n{user}"
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
class StdioSession:
|
|
680
|
-
def __init__(self, input_stream: Any, output_stream: Any, *, max_tokens: int = 4096) -> None:
|
|
681
|
-
self.input_stream = input_stream
|
|
682
|
-
self.output_stream = output_stream
|
|
683
|
-
self.next_request_id = 1
|
|
684
|
-
self.max_tokens = max_tokens
|
|
685
|
-
|
|
686
|
-
def read_message(self) -> Json | None:
|
|
687
|
-
return _read_message(self.input_stream)
|
|
688
|
-
|
|
689
|
-
def write_message(self, message: Json) -> None:
|
|
690
|
-
_write_message(self.output_stream, message)
|
|
691
|
-
|
|
692
|
-
def sample(self, system_prompt: str, prompt: str) -> str:
|
|
693
|
-
request_id = f"sampling_{self.next_request_id}"
|
|
694
|
-
self.next_request_id += 1
|
|
695
|
-
# roll sampling handler currently forwards only `messages` to the LLM and
|
|
696
|
-
# ignores `systemPrompt`; embed system instructions in the user payload.
|
|
697
|
-
user_prompt = _sampling_user_prompt(system_prompt, prompt)
|
|
698
|
-
self.write_message(
|
|
699
|
-
{
|
|
700
|
-
"jsonrpc": "2.0",
|
|
701
|
-
"id": request_id,
|
|
702
|
-
"method": "sampling/createMessage",
|
|
703
|
-
"params": {
|
|
704
|
-
"systemPrompt": system_prompt,
|
|
705
|
-
"messages": [{"role": "user", "content": {"type": "text", "text": user_prompt}}],
|
|
706
|
-
"temperature": 0,
|
|
707
|
-
"maxTokens": self.max_tokens,
|
|
708
|
-
"includeContext": "none",
|
|
709
|
-
},
|
|
710
|
-
}
|
|
711
|
-
)
|
|
712
|
-
while True:
|
|
713
|
-
message = self.read_message()
|
|
714
|
-
if message is None:
|
|
715
|
-
raise RuntimeError("Sampling failed: client closed stdio")
|
|
716
|
-
if message.get("id") == request_id:
|
|
717
|
-
if "error" in message:
|
|
718
|
-
raise RuntimeError(f"Sampling failed: {message['error']}")
|
|
719
|
-
return _extract_sampling_text(message.get("result") or {})
|
|
720
|
-
response = handle_request(message, sampler=self.sample)
|
|
721
|
-
if response is not None:
|
|
722
|
-
self.write_message(response)
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
def _extract_sampling_text(result: Json) -> str:
|
|
726
|
-
content = result.get("content")
|
|
727
|
-
if isinstance(content, dict) and content.get("type") == "text":
|
|
728
|
-
return str(content.get("text") or "")
|
|
729
|
-
if isinstance(content, list):
|
|
730
|
-
return "\n".join(
|
|
731
|
-
str(item.get("text") or "") for item in content if isinstance(item, dict) and item.get("type") == "text"
|
|
732
|
-
)
|
|
733
|
-
raise RuntimeError("Sampling response did not contain text content")
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
def _read_message(stream: Any) -> Json | None:
|
|
737
|
-
while True:
|
|
738
|
-
line = stream.readline()
|
|
739
|
-
if line == b"":
|
|
740
|
-
return None
|
|
741
|
-
if line.strip():
|
|
742
|
-
return json.loads(line.decode("utf-8"))
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
def _write_message(stream: Any, message: Json) -> None:
|
|
746
|
-
body = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
747
|
-
stream.write(body + b"\n")
|
|
748
|
-
stream.flush()
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
if __name__ == "__main__":
|
|
752
|
-
main()
|