feat(agent): 完备性硬门禁(2026-09-08用户定夺一C+二A)
意图识别归主循环LLM(system prompt语义引导),完备性硬门禁归代码。 - invoke_model入口check_completeness按能力契约校验必备输入: t2i/t2v/tts要描述,i2v/i2i要输入图,asr要音频,r2v要参考媒体 - 缺失返回QUESTION前缀,不调上游零费用(在llm_infer之前) - v1角色agent+v2会话agent四处工具循环QUESTION硬拦截转 ask_user/ask_question,禁回填靠LLM自觉(deepseek类会空参硬试烧钱) - 22例单测全PASS(齐备/缺失/字符串vs数组形态/未登记能力不误伤)
This commit is contained in:
parent
25c9e7cdb3
commit
ba65d586b1
@ -582,6 +582,7 @@ __TOOLS__
|
||||
产出文档/章节/方案时,遇到需要插图的内容(架构图/拓扑图/流程图示意图/效果图/封面图等),用 invoke_model 调用平台生成模型产出**真实图片**:
|
||||
- 不确定有什么模型时先 list_platform_models(capability=t2i 文生图 / i2v 图生视频 / tts 语音等)。
|
||||
- invoke_model 的 model 可留空(平台按任务自动匹配),task 写清画面内容(主体/风格/构图/文字要求)。
|
||||
- **完备性先于执行(铁律)**:生成类调用真花钱。调 invoke_model 前先自查该能力的必需输入是否齐备(文生图/视频要有画面描述;图生视频/图生图要有输入图;语音识别要有音频;缺媒体素材时先 ask_question 向人类要,拿到再调)。工具返回 QUESTION: 开头 = 平台门禁判定输入不完备、未发生调用,把追问原样转达给用户/按角色规则 ask_question,禁止自行编造素材或空参硬试。
|
||||
- 成功后把返回的产物 URL **原样**以 markdown 图片语法嵌入正文:。禁止改写/自编 URL。
|
||||
- **禁止用 ASCII 字符画/文本框线拼「示意图」代替真实配图**。平台无可用生成模型(invoke_model 返回 FAIL)时,在产出中如实标注「配图缺失:平台无可用文生图模型」,不伪造。
|
||||
|
||||
@ -2391,6 +2392,15 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
|
||||
result = await _load_skill_by_name(sor, project_id, role, org_id, params.get("name", ""), params.get("file_path") or None)
|
||||
else:
|
||||
result = await _exec_agent_tool(tool, params, space_dir, capability_ctx)
|
||||
# 完备性/澄清门禁硬拦截(2026-09-08 一C+二A):QUESTION 前缀 =
|
||||
# 平台判定缺必备输入、已停在花钱动作前。角色 agent 无人在旁,
|
||||
# 转成 ask_question 冒泡给人类(任务挂起 waiting),禁止回填后
|
||||
# 靠 LLM 自觉(会空参硬试烧钱)。
|
||||
if isinstance(result, str) and result.startswith("QUESTION:"):
|
||||
ask_question = result[len("QUESTION:"):].strip()
|
||||
ask_form = None
|
||||
logger.info(f"role_agent 门禁转提问: {tool} -> {ask_question[:80]}")
|
||||
break
|
||||
if tool == "write_file" and params.get("path"):
|
||||
written_files.append(os.path.join(space_dir, params["path"]))
|
||||
msgs.append({"role": "tool", "tool_call_id": tc.get("id", ""), "content": str(result)})
|
||||
@ -2416,6 +2426,12 @@ async def role_agent_run(project_id, role, agent_id=None, model_name=None):
|
||||
result = await _load_skill_by_name(sor, project_id, role, org_id, params.get('name', ''), params.get('file_path') or None)
|
||||
else:
|
||||
result = await _exec_agent_tool(tool, params, space_dir, capability_ctx)
|
||||
# 完备性/澄清门禁硬拦截(同 native 路径,2026-09-08 一C+二A)
|
||||
if isinstance(result, str) and result.startswith("QUESTION:"):
|
||||
ask_question = result[len("QUESTION:"):].strip()
|
||||
ask_form = None
|
||||
logger.info(f"role_agent 门禁转提问(text): {tool} -> {ask_question[:80]}")
|
||||
break
|
||||
if tool == 'write_file' and params.get('path'):
|
||||
written_files.append(os.path.join(space_dir, params['path']))
|
||||
msgs.append({"role": "assistant", "content": raw})
|
||||
|
||||
@ -275,6 +275,19 @@ class AgentExecutor:
|
||||
if not result.startswith("未知工具") and not result.startswith("ERROR"):
|
||||
self._tool_call_count += 1
|
||||
|
||||
# 完备性/澄清门禁硬拦截(2026-09-08 用户定夺:一C+二A):
|
||||
# 工具返回 QUESTION: 前缀 = 平台判定「缺完成任务的必备输入」,
|
||||
# 已停在花钱动作之前(上游未调用、零费用)。必须把它当 ask_user
|
||||
# 直接抛给用户等回答,禁止回填后靠 LLM 自觉转达(deepseek 类
|
||||
# 模型会无视、继续空参硬试烧钱)。ask_user 工具同走此前缀,语义统一。
|
||||
if isinstance(result, str) and result.startswith("QUESTION:"):
|
||||
question = result[len("QUESTION:"):].strip()
|
||||
yield json.dumps({
|
||||
"type": "ask_user", "message": question,
|
||||
}, ensure_ascii=False) + "\n"
|
||||
await self._save_turn(user_input, f"[提问] {question}")
|
||||
return
|
||||
|
||||
yield json.dumps({
|
||||
"type": "tool_result", "tool": tool_name, "result": result[:500],
|
||||
}, ensure_ascii=False) + "\n"
|
||||
@ -341,6 +354,15 @@ class AgentExecutor:
|
||||
if not result.startswith("未知工具") and not result.startswith("ERROR"):
|
||||
self._tool_call_count += 1
|
||||
|
||||
# 完备性/澄清门禁硬拦截(同 native 路径,2026-09-08 一C+二A)
|
||||
if isinstance(result, str) and result.startswith("QUESTION:"):
|
||||
question = result[len("QUESTION:"):].strip()
|
||||
yield json.dumps({
|
||||
"type": "ask_user", "message": question,
|
||||
}, ensure_ascii=False) + "\n"
|
||||
await self._save_turn(user_input, f"[提问] {question}")
|
||||
return
|
||||
|
||||
yield json.dumps({
|
||||
"type": "tool_result", "tool": tool_name,
|
||||
"result": result[:500],
|
||||
|
||||
@ -26,6 +26,99 @@ _CAP_DESC = {
|
||||
"embedding": "向量化", "rerank": "重排序",
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 完备性硬门禁(2026-09-08 用户定夺:一C+二A)
|
||||
#
|
||||
# 用户原则:「意图识别后还要能识别用户输入是否有完成任务的完备条件,
|
||||
# 如果不够,必须询问得到,不能做不好还做,浪费钱财」。
|
||||
#
|
||||
# 分工(一C):意图识别归主循环 LLM(system prompt 引导,零额外调用);
|
||||
# **完备性硬门禁归代码**——放在贵工具入口、上游调用之前。理由:完备性是
|
||||
# 能力契约的确定性检查(该能力要求哪些输入、传没传),不是语义猜测,
|
||||
# 代码判定可靠且不依赖模型自觉(deepseek 类模型无视 prompt 指令有实测前科)。
|
||||
#
|
||||
# 范围(二A):只护花钱/不可逆动作(生成类调用真花钱)。闲聊/查询不设门禁。
|
||||
#
|
||||
# 返回 QUESTION: 前缀 → agent 循环把它当 ask_user 抛给用户(见
|
||||
# agent_loop_v2 run loop / v1 的 ask 处理),不调上游、一分钱不花。
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# 能力 → 必备输入契约(缺任一即不完备,必须先问用户要)
|
||||
# 键名对应调用方 params 的三数组契约字段 / task 文本
|
||||
_CAP_REQUIRED = {
|
||||
"t2i": ("task",), # 文生图:必须有画面描述
|
||||
"t2v": ("task",), # 文生视频:必须有画面描述
|
||||
"tts": ("task",), # 语音合成:必须有要念的文本
|
||||
"i2v": ("image_files",), # 图生视频:必须有输入图
|
||||
"i2i": ("image_files",), # 图生图:必须有输入图
|
||||
"asr": ("audio_files",), # 语音识别:必须有音频
|
||||
"r2v": ("any_media",), # 参考生视频:至少一类参考媒体
|
||||
}
|
||||
_MEDIA_KEYS = ("image_files", "audio_files", "video_files")
|
||||
|
||||
# 必备输入的中文显示名(追问话术里给用户看,不用内部字段名)
|
||||
_REQ_LABEL = {
|
||||
"image_files": "输入图片",
|
||||
"audio_files": "输入音频",
|
||||
"video_files": "参考视频",
|
||||
"any_media": "参考素材(图片/视频/音频任一)",
|
||||
}
|
||||
# task 类必备输入按能力的显示名(文生图要「画面描述」,语音合成要「朗读文本」)
|
||||
_TASK_LABEL = {
|
||||
"t2i": "画面描述", "t2v": "画面描述", "i2v": "运动/画面变化描述",
|
||||
"tts": "朗读文本", "asr": "识别需求说明",
|
||||
}
|
||||
|
||||
# 缺输入时对用户的追问话术(按能力,说清要什么、什么格式)
|
||||
_CAP_ASK = {
|
||||
"t2i": "生成图片需要先有画面描述。请说明:画什么主体、风格(写实/插画/商务示意图等)、"
|
||||
"比例或尺寸、图中是否要有文字及内容。",
|
||||
"t2v": "生成视频需要先有画面描述。请说明:画面内容、时长、分辨率、风格。",
|
||||
"tts": "语音合成需要先有要朗读的文本。请提供文本内容,并说明音色/语速偏好(可选)。",
|
||||
"i2v": "图生视频必须先提供输入图片(公网 URL 或上传)。另外请说明想要的运动/画面变化。",
|
||||
"i2i": "图生图必须先提供输入图片(公网 URL 或上传)。另外请说明要怎么改(风格化/局部重绘等)。",
|
||||
"asr": "语音识别必须先提供音频文件(公网 URL 或上传)。",
|
||||
"r2v": "参考生视频必须先提供至少一类参考素材(图片/视频/音频,公网 URL 或上传),"
|
||||
"并说明想要的画面内容与运动。",
|
||||
}
|
||||
|
||||
|
||||
def _biz_media(biz, key):
|
||||
"""业务参数里的媒体数组是否非空(三数组契约,值可为字符串或数组)。"""
|
||||
v = biz.get(key)
|
||||
if isinstance(v, (list, tuple)):
|
||||
return any(str(x or "").strip() for x in v)
|
||||
return bool(str(v or "").strip())
|
||||
|
||||
|
||||
def check_completeness(capability, task, biz):
|
||||
"""完备性门禁:该能力契约要求的输入是否齐备。
|
||||
|
||||
返回 '' = 完备可执行;否则返回需要向用户追问的话术(QUESTION 内容)。
|
||||
契约未登记的能力(如 embedding/新能力)不拦——宁可放行也别误伤,
|
||||
但生成类主流能力全部登记在册。
|
||||
"""
|
||||
cap = (capability or "").strip().lower()
|
||||
required = _CAP_REQUIRED.get(cap)
|
||||
if not required:
|
||||
return ""
|
||||
missing = []
|
||||
for req in required:
|
||||
if req == "task":
|
||||
if not (task or "").strip():
|
||||
missing.append(_TASK_LABEL.get(cap, "画面/内容描述"))
|
||||
elif req == "any_media":
|
||||
if not any(_biz_media(biz or {}, k) for k in _MEDIA_KEYS):
|
||||
missing.append(_REQ_LABEL["any_media"])
|
||||
else:
|
||||
if not _biz_media(biz or {}, req):
|
||||
missing.append(_REQ_LABEL.get(req, req))
|
||||
if missing:
|
||||
ask = _CAP_ASK.get(cap, "该能力缺少必需输入,请补充:" + "、".join(missing))
|
||||
return ("调用「" + _CAP_DESC.get(cap, cap) + "」模型缺少必需输入("
|
||||
+ "、".join(missing) + "),已停在调用前、未产生任何费用。" + ask)
|
||||
return ""
|
||||
|
||||
|
||||
async def tool_list_platform_models(params, org_id):
|
||||
"""列出平台可用模型(本机构+平台owner机构,含能力类型与描述)。"""
|
||||
@ -54,13 +147,29 @@ async def tool_list_platform_models(params, org_id):
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _resolve_capability(model_name, org_id):
|
||||
"""按模型注册名反查能力类型(门禁需要知道该查哪份输入契约)。
|
||||
|
||||
走 models_catalog(与推理链同一机构可见性),查不到返回 ''。
|
||||
"""
|
||||
try:
|
||||
from pipeline_llm.selection import models_catalog
|
||||
models = await models_catalog(org_id or "0", capabilities=())
|
||||
for m in models:
|
||||
if model_name in (m.get("name"), m.get("vendor_model_id")):
|
||||
return (m.get("capability") or "").strip().lower()
|
||||
except Exception as e:
|
||||
logger.warning("_resolve_capability(%s) 失败: %s", model_name, e)
|
||||
return ""
|
||||
|
||||
|
||||
async def tool_invoke_model(params, org_id, user_id=""):
|
||||
"""调用平台模型完成生成类任务(文生图/视频/语音等非对话能力)。
|
||||
|
||||
流程:解析 task/model/capability/params → 未指定 model 时按 task 自动
|
||||
选型(auto_select_model,候选=本机构+平台owner,capability 空=全能力)
|
||||
→ 组包 payload(messages=[user:task] + 业务参数平铺,媒体三数组契约由
|
||||
inference 层归一)→ llm_infer 统一推理端点 → 提取生成物 URL 返回。
|
||||
→ **完备性硬门禁**(缺必需输入则 QUESTION 追问,不调上游不花钱)
|
||||
→ 组包 payload → llm_infer 统一推理端点 → 提取生成物 URL 返回。
|
||||
失败返回真实可行动错误(禁静默粉饰)。
|
||||
"""
|
||||
p = params or {}
|
||||
@ -105,6 +214,16 @@ async def tool_invoke_model(params, org_id, user_id=""):
|
||||
except Exception as e:
|
||||
return "ERROR: 自动选型失败: " + str(e)[:300]
|
||||
|
||||
# ── 完备性硬门禁(2026-09-08 用户定夺:一C+二A)──
|
||||
# 必须在 llm_infer 之前:缺输入直接 QUESTION 追问用户,上游一分钱不花。
|
||||
# capability 未显式给出时按选中模型反查(门禁需知道查哪份输入契约)。
|
||||
if not cap:
|
||||
cap = await _resolve_capability(model, org_id)
|
||||
gap = check_completeness(cap, task, biz)
|
||||
if gap:
|
||||
logger.info("invoke_model 完备性门禁拦截(%s): %s", cap or "?", gap[:80])
|
||||
return "QUESTION: " + gap
|
||||
|
||||
# 组包:生成类模型从 messages 末条取 prompt 文本(inference._last_prompt_text)
|
||||
payload = {"messages": [{"role": "user", "content": task}]}
|
||||
payload.update(biz)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user