@roll-agent/octopus-agent 0.0.11 → 0.1.1
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 -1
- package/SKILL.md +76 -35
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/references/env.yaml +4 -0
- package/src/octopus_skill/_version.py +1 -1
- package/src/octopus_skill/agent.py +4267 -1108
- package/src/octopus_skill/config.py +22 -25
- package/src/octopus_skill/exporter.py +66 -0
- package/src/octopus_skill/llm_result_renderer.py +101 -0
- package/src/octopus_skill/llm_sql_generator.py +72 -7
- package/src/octopus_skill/mcp_client.py +4 -4
- package/src/octopus_skill/octopus_run.py +287 -293
- package/src/octopus_skill/prompt_builder.py +444 -81
- package/src/octopus_skill/question_analyzer.py +399 -0
- package/src/octopus_skill/result_renderer.py +38 -14
- package/src/octopus_skill/schema_context_retriever.py +759 -35
- package/src/octopus_skill/sql_generator.py +2120 -763
- package/src/octopus_skill/entity_binding.py +0 -523
- package/src/octopus_skill/entity_dictionary.py +0 -59
- package/src/octopus_skill/entity_resolver.py +0 -446
|
@@ -3,14 +3,13 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import re
|
|
5
5
|
import sys
|
|
6
|
-
import time
|
|
7
6
|
from dataclasses import asdict, is_dataclass
|
|
8
|
-
from pathlib import Path
|
|
9
7
|
from typing import Any, Callable
|
|
10
8
|
|
|
11
9
|
from . import __version__
|
|
12
|
-
from .agent import SpongeAgent
|
|
10
|
+
from .agent import SpongeAgent, ensure_query_plan_summary_answer
|
|
13
11
|
from .config import AppConfig, load_config_from_env
|
|
12
|
+
from .result_renderer import RESULT_ROW_DISPLAY_LIMIT, normalize_result_format, render_result
|
|
14
13
|
from .schema_cache import SchemaCacheError
|
|
15
14
|
|
|
16
15
|
SERVER_NAME = "octopus-agent"
|
|
@@ -20,10 +19,6 @@ PROTOCOL_VERSION = "2024-11-05"
|
|
|
20
19
|
Json = dict[str, Any]
|
|
21
20
|
AgentFactory = Callable[[], SpongeAgent]
|
|
22
21
|
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
|
|
27
22
|
|
|
28
23
|
|
|
29
24
|
def main() -> None:
|
|
@@ -31,7 +26,12 @@ def main() -> None:
|
|
|
31
26
|
|
|
32
27
|
|
|
33
28
|
def serve_stdio() -> None:
|
|
34
|
-
|
|
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
35
|
while True:
|
|
36
36
|
message = session.read_message()
|
|
37
37
|
if message is None:
|
|
@@ -80,7 +80,7 @@ def tool_definitions() -> list[Json]:
|
|
|
80
80
|
return [
|
|
81
81
|
{
|
|
82
82
|
"name": "diagnostic_status",
|
|
83
|
-
"description": "调试专用:检查丸子Agent(Octopus Agent
|
|
83
|
+
"description": "调试专用:检查丸子Agent(Octopus Agent)配置、缓存和本地持久化状态。用户只是说使用、启动、检查、看看丸子Agent,或确认 Agent 是否可用时,只调用本工具并在返回状态后停止,不得主动调用 query_sponge 或编造示例业务问题。普通业务查询不要先调用本工具,直接调用 query_sponge。",
|
|
84
84
|
"inputSchema": {
|
|
85
85
|
"type": "object",
|
|
86
86
|
"properties": {},
|
|
@@ -104,7 +104,7 @@ def tool_definitions() -> list[Json]:
|
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
106
|
"name": "query_sponge",
|
|
107
|
-
"description": "业务查询唯一入口:把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server
|
|
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
108
|
"inputSchema": {
|
|
109
109
|
"type": "object",
|
|
110
110
|
"required": ["question", "originalQuestion"],
|
|
@@ -120,7 +120,7 @@ def tool_definitions() -> list[Json]:
|
|
|
120
120
|
},
|
|
121
121
|
"finalAnswerOnly": {
|
|
122
122
|
"type": "boolean",
|
|
123
|
-
"description": "是否只返回最终业务答案;默认 true。true 时 content 与 structuredContent.answer
|
|
123
|
+
"description": "是否只返回最终业务答案;默认 true。true 时 content 与 structuredContent.answer 均为三段式 verbatim 答复:1.查询实体;2.筛选条件;3.查询结果。上层 Agent 必须原样展示给用户,不要只放在折叠步骤里,不要改写成摘要、截断表格或追加后续引导。includeSql=true 的显式调试请求仍会返回 SQL 调试信息。",
|
|
124
124
|
},
|
|
125
125
|
"sessionId": {"type": "string", "description": "可选会话 ID"},
|
|
126
126
|
"resultFormat": {
|
|
@@ -132,31 +132,6 @@ def tool_definitions() -> list[Json]:
|
|
|
132
132
|
"type": "boolean",
|
|
133
133
|
"description": "是否在回参中包含 generatedSql、normalizedSql、repairedSql 等调试信息",
|
|
134
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
|
-
},
|
|
160
135
|
},
|
|
161
136
|
"additionalProperties": False,
|
|
162
137
|
},
|
|
@@ -183,22 +158,16 @@ def call_tool(
|
|
|
183
158
|
)
|
|
184
159
|
)
|
|
185
160
|
if tool_name == "query_sponge":
|
|
186
|
-
agent = _make_agent(agent_factory)
|
|
187
161
|
question = _required_string(arguments, "question")
|
|
188
162
|
original_question = _required_string(arguments, "originalQuestion")
|
|
189
163
|
session_id = arguments.get("sessionId")
|
|
190
|
-
|
|
164
|
+
blocked_reason = _blocked_query_call_reason(
|
|
191
165
|
question=question,
|
|
192
166
|
original_question=original_question,
|
|
193
|
-
session_id=session_id,
|
|
194
|
-
clarification_selection=arguments.get("clarificationSelection"),
|
|
195
167
|
)
|
|
196
|
-
if
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
_forget_pending_clarification(session_id)
|
|
200
|
-
elif not _is_clarification_reply(original_question):
|
|
201
|
-
_forget_pending_clarification(session_id)
|
|
168
|
+
if blocked_reason is not None:
|
|
169
|
+
return _tool_blocked_query(blocked_reason)
|
|
170
|
+
agent = _make_agent(agent_factory)
|
|
202
171
|
answer_kwargs = {
|
|
203
172
|
"question": question,
|
|
204
173
|
"original_question": original_question,
|
|
@@ -210,15 +179,10 @@ def call_tool(
|
|
|
210
179
|
if sampler is not None:
|
|
211
180
|
answer_kwargs["sampler"] = sampler
|
|
212
181
|
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
182
|
base_question = original_question or question
|
|
220
183
|
if arguments.get("includeSql") is True:
|
|
221
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)
|
|
222
186
|
return _tool_json(payload, structured_content=payload)
|
|
223
187
|
if final_answer_only:
|
|
224
188
|
return _tool_final_answer(query_result, base_question=base_question)
|
|
@@ -254,22 +218,23 @@ def _diagnostic_status(config: AppConfig) -> Json:
|
|
|
254
218
|
"enabled": config.audit.enabled,
|
|
255
219
|
"logPath": str(config.audit.log_path),
|
|
256
220
|
},
|
|
221
|
+
"sampling": {
|
|
222
|
+
"maxTokens": config.sampling.max_tokens,
|
|
223
|
+
},
|
|
257
224
|
"binding": _binding_status(config),
|
|
258
225
|
}
|
|
259
226
|
|
|
260
227
|
|
|
261
228
|
def _binding_status(config: AppConfig) -> Json:
|
|
262
229
|
binding = config.binding
|
|
263
|
-
dictionary = config.dictionary
|
|
264
230
|
return {
|
|
265
|
-
"configured":
|
|
231
|
+
"configured": False,
|
|
232
|
+
"mode": "disabled",
|
|
233
|
+
"entitySource": "schema",
|
|
234
|
+
"dictionaryPersisted": False,
|
|
266
235
|
"shadowMode": bool(binding.shadow_mode) if binding is not None else None,
|
|
267
236
|
"enabledEntityTypes": list(binding.enabled_entity_types) if binding is not None else [],
|
|
268
237
|
"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,
|
|
273
238
|
}
|
|
274
239
|
|
|
275
240
|
|
|
@@ -326,15 +291,6 @@ def _required_string(arguments: Json, key: str) -> str:
|
|
|
326
291
|
return value
|
|
327
292
|
|
|
328
293
|
|
|
329
|
-
def _optional_string(arguments: Json, key: str) -> str | None:
|
|
330
|
-
value = arguments.get(key)
|
|
331
|
-
if value is None:
|
|
332
|
-
return None
|
|
333
|
-
if not isinstance(value, str):
|
|
334
|
-
raise ValueError(f"{key} must be a string")
|
|
335
|
-
return value or None
|
|
336
|
-
|
|
337
|
-
|
|
338
294
|
def _optional_bool(arguments: Json, key: str, *, default: bool) -> bool:
|
|
339
295
|
value = arguments.get(key)
|
|
340
296
|
if value is None:
|
|
@@ -351,230 +307,135 @@ def _required_int(arguments: Json, key: str) -> int:
|
|
|
351
307
|
return value
|
|
352
308
|
|
|
353
309
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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)
|
|
363
357
|
|
|
364
358
|
|
|
365
|
-
def
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if not
|
|
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)
|
|
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:
|
|
378
363
|
return None
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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",
|
|
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 必须传用户原始话术,上层不得摘要、翻译或改写。"
|
|
427
376
|
)
|
|
428
|
-
|
|
429
|
-
_CLARIFICATION_CACHE_PATH.unlink()
|
|
430
|
-
except OSError:
|
|
431
|
-
return
|
|
377
|
+
return None
|
|
432
378
|
|
|
433
379
|
|
|
434
|
-
def
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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)
|
|
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
|
|
456
390
|
|
|
457
391
|
|
|
458
|
-
def
|
|
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
|
|
392
|
+
def _is_agent_meta_request(text: str) -> bool:
|
|
495
393
|
return bool(
|
|
496
|
-
re.
|
|
497
|
-
|
|
394
|
+
re.search(r"(丸子|octopus|agent|roll[- ]?core|工具|技能)", text, flags=re.IGNORECASE)
|
|
395
|
+
and re.search(r"(使用|启动|检查|看看|查看|测试|尝试|状态|健康|诊断|可用|功能)", text)
|
|
498
396
|
)
|
|
499
397
|
|
|
500
398
|
|
|
501
|
-
def
|
|
502
|
-
return
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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})查询"
|
|
399
|
+
def _has_explicit_business_object(text: str) -> bool:
|
|
400
|
+
return bool(
|
|
401
|
+
re.search(
|
|
402
|
+
r"(品牌|项目|岗位|门店|工单|报名|入职|候选人|城市|省份|区域|供应商|员工|薪资|招聘|数据|列表|明细|数量|多少|哪些|有哪些)",
|
|
403
|
+
text,
|
|
404
|
+
)
|
|
405
|
+
)
|
|
563
406
|
|
|
564
407
|
|
|
565
|
-
def
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
+
}
|
|
570
423
|
|
|
571
424
|
|
|
572
425
|
def _tool_text(text: str) -> Json:
|
|
573
426
|
return {"content": [{"type": "text", "text": text}]}
|
|
574
427
|
|
|
575
428
|
|
|
429
|
+
def _answer_content(answer: str) -> list[Json]:
|
|
430
|
+
return [
|
|
431
|
+
{"type": "text", "text": answer},
|
|
432
|
+
]
|
|
433
|
+
|
|
434
|
+
|
|
576
435
|
def _tool_final_answer(query_result: Any, *, base_question: str | None = None) -> Json:
|
|
577
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
|
|
578
439
|
payload.update(
|
|
579
440
|
{
|
|
580
441
|
"finalAnswerOnly": True,
|
|
@@ -582,7 +443,7 @@ def _tool_final_answer(query_result: Any, *, base_question: str | None = None) -
|
|
|
582
443
|
}
|
|
583
444
|
)
|
|
584
445
|
return {
|
|
585
|
-
"content":
|
|
446
|
+
"content": _answer_content(answer),
|
|
586
447
|
"structuredContent": payload,
|
|
587
448
|
}
|
|
588
449
|
|
|
@@ -591,8 +452,10 @@ def _tool_query_answer(query_result: Any, *, final_answer_only: bool, base_quest
|
|
|
591
452
|
payload = _query_result_payload(query_result, base_question=base_question)
|
|
592
453
|
payload["finalAnswerOnly"] = final_answer_only
|
|
593
454
|
payload["responseMode"] = "verbatim"
|
|
455
|
+
answer = _user_visible_answer(query_result, base_question=base_question)
|
|
456
|
+
payload["answer"] = answer
|
|
594
457
|
return {
|
|
595
|
-
"content":
|
|
458
|
+
"content": _answer_content(answer),
|
|
596
459
|
"structuredContent": payload,
|
|
597
460
|
}
|
|
598
461
|
|
|
@@ -604,9 +467,12 @@ def _query_result_payload(query_result: Any, *, include_sql: bool = False, base_
|
|
|
604
467
|
"clarificationType": getattr(query_result, "clarification_type", None),
|
|
605
468
|
"clarificationPayload": getattr(query_result, "clarification_payload", None),
|
|
606
469
|
}
|
|
607
|
-
|
|
608
|
-
if
|
|
609
|
-
payload["
|
|
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
|
|
610
476
|
if include_sql:
|
|
611
477
|
payload.update(
|
|
612
478
|
{
|
|
@@ -619,26 +485,140 @@ def _query_result_payload(query_result: Any, *, include_sql: bool = False, base_
|
|
|
619
485
|
return _jsonable(payload)
|
|
620
486
|
|
|
621
487
|
|
|
622
|
-
def
|
|
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
|
|
488
|
+
def _public_query_plan(query_plan: dict[str, Any]) -> Json:
|
|
629
489
|
return {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
"
|
|
633
|
-
"selectionFields": {
|
|
634
|
-
"baseQuestion": "原始业务问题",
|
|
635
|
-
"entityType": "实体类型:brand=品牌,project=项目",
|
|
636
|
-
"entityId": "候选实体 ID",
|
|
637
|
-
"entityName": "候选实体名称",
|
|
638
|
-
},
|
|
490
|
+
key: value
|
|
491
|
+
for key, value in query_plan.items()
|
|
492
|
+
if not str(key).startswith("_")
|
|
639
493
|
}
|
|
640
494
|
|
|
641
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
|
+
|
|
642
622
|
def _tool_json(data: Any, *, structured_content: Any | None = None) -> Json:
|
|
643
623
|
result = {
|
|
644
624
|
"content": [
|
|
@@ -686,11 +666,22 @@ def _error_response(request_id: Any, code: int, message: str, data: Json | None
|
|
|
686
666
|
return {"jsonrpc": "2.0", "id": request_id, "error": error}
|
|
687
667
|
|
|
688
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
|
+
|
|
689
679
|
class StdioSession:
|
|
690
|
-
def __init__(self, input_stream: Any, output_stream: Any) -> None:
|
|
680
|
+
def __init__(self, input_stream: Any, output_stream: Any, *, max_tokens: int = 4096) -> None:
|
|
691
681
|
self.input_stream = input_stream
|
|
692
682
|
self.output_stream = output_stream
|
|
693
683
|
self.next_request_id = 1
|
|
684
|
+
self.max_tokens = max_tokens
|
|
694
685
|
|
|
695
686
|
def read_message(self) -> Json | None:
|
|
696
687
|
return _read_message(self.input_stream)
|
|
@@ -701,6 +692,9 @@ class StdioSession:
|
|
|
701
692
|
def sample(self, system_prompt: str, prompt: str) -> str:
|
|
702
693
|
request_id = f"sampling_{self.next_request_id}"
|
|
703
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)
|
|
704
698
|
self.write_message(
|
|
705
699
|
{
|
|
706
700
|
"jsonrpc": "2.0",
|
|
@@ -708,9 +702,9 @@ class StdioSession:
|
|
|
708
702
|
"method": "sampling/createMessage",
|
|
709
703
|
"params": {
|
|
710
704
|
"systemPrompt": system_prompt,
|
|
711
|
-
"messages": [{"role": "user", "content": {"type": "text", "text":
|
|
705
|
+
"messages": [{"role": "user", "content": {"type": "text", "text": user_prompt}}],
|
|
712
706
|
"temperature": 0,
|
|
713
|
-
"maxTokens":
|
|
707
|
+
"maxTokens": self.max_tokens,
|
|
714
708
|
"includeContext": "none",
|
|
715
709
|
},
|
|
716
710
|
}
|