diff --git a/app/processor/base.py b/app/processor/base.py index c1a2996..6700caf 100644 --- a/app/processor/base.py +++ b/app/processor/base.py @@ -55,9 +55,9 @@ class BaseNode(ABC): """子类实现节点核心逻辑,返回状态更新""" def fail_on_invalid_output(self, state: T, error: ValueError, reason: str) -> T: - """模型输出未通过校验时收尾为失败结果,详细原因只写日志""" + """模型输出未通过校验时收尾为失败结果,原因交给失败收尾由模型向用户说明""" self.logger.warning(f"{reason}: {error}") - return {**state, "status": "failed", "failure_reason": reason} + return {**state, "status": "failed", "failure_reason": f"{reason}:{error}"} def log_step(self, step_name: str, message: str = ""): """记录步骤日志。""" diff --git a/app/processor/graph.py b/app/processor/graph.py index 9e766d1..807a9c7 100644 --- a/app/processor/graph.py +++ b/app/processor/graph.py @@ -248,8 +248,8 @@ def build_graph(checkpointer=None): }, ) - # 失败节点统一写入最终失败状态后结束流程 - workflow.add_edge("fallback_node", END) + # 失败节点写入最终失败状态后,由回答节点向用户说明原因 + workflow.add_edge("fallback_node", "qa_node") # 用户补充后重新合并输入并判断意图,取消则直接结束 workflow.add_conditional_edges( diff --git a/app/processor/nodes/action_node.py b/app/processor/nodes/action_node.py index 5a598ab..979c203 100644 --- a/app/processor/nodes/action_node.py +++ b/app/processor/nodes/action_node.py @@ -192,6 +192,16 @@ class ActionNode(BaseNode): updated_state["validation_errors"] = [GENERATION_ERROR] return updated_state + # 模型确认现有能力无法满足需求时直接失败收尾,不进入重试 + if batch.unsupported_reason: + updated_state.update( + actions=[], + validation_passed=False, + failure_reason=f"动作生成判断无法完成:{batch.unsupported_reason}", + status="failed", + ) + return updated_state + # 保存生成的动作,实际目标、规划一致性及模拟执行结果仍由后续校验节点检查 updated_state["actions"] = batch.actions diff --git a/app/processor/nodes/fallback_node.py b/app/processor/nodes/fallback_node.py index fac2a43..0b2dbaf 100644 --- a/app/processor/nodes/fallback_node.py +++ b/app/processor/nodes/fallback_node.py @@ -1,31 +1,40 @@ -"""统一收尾能力不足、资源缺失和动作修正失败""" +"""统一收尾能力不足、资源缺失和动作修正失败 + +这里只整理内部失败原因,面向用户的说明由回答节点交给模型总结,代码中不写死给用户的文案 +""" from app.processor.base import BaseNode from app.processor.state import QueryState +def failure_notice(state: QueryState) -> str: + """汇总上游记录的失败原因、最近一次修正分析和校验错误,作为交给模型的事实说明""" + facts = [] + if reason := state.get("failure_reason"): + facts.append(reason) + plan = state.get("repair_plan") + if plan is not None and plan.reason not in facts: + facts.append(f"失败分析:{plan.reason}") + if errors := state.get("validation_errors"): + facts.append("校验错误:" + ";".join(error.message for error in errors)) + facts.append(f"已自动修正 {state.get('retry_count', 0)} 次") + detail = "\n".join(facts) if facts else "没有记录具体原因" + return f"本次修改请求没有执行,内部原因如下:\n{detail}" + + class FallbackNode(BaseNode): name = "fallback_node" def process(self, state: QueryState) -> QueryState: # 浅拷贝状态 updated_state: QueryState = state.copy() - # 读取上游记录的具体失败原因 - reason = state.get("failure_reason") + notice = failure_notice(state) + self.logger.warning(notice) - # 如果上游没有提供有效原因,则根据校验错误补充失败说明 - if not reason: - # 如果仍有校验错误,使用达到自动修正上限的说明,并带上已重试次数 - if state.get("validation_errors"): - reason = f"动作校验未通过,已达到自动修正上限,共重试 {state.get('retry_count', 0)} 次" - else: - # 如果既没有具体原因也没有校验错误,使用通用说明,避免返回空原因 - reason = "当前需求未能完成,缺少具体失败原因" - - # 将任务标记为最终失败,结束本轮处理 + # 将任务标记为最终失败,由回答节点向用户说明原因 updated_state["status"] = "failed" - # 保存上游原因或本节点补充的说明,供后续输出给用户 - updated_state["failure_reason"] = reason + updated_state["failure_reason"] = notice + updated_state["notice"] = notice # 显式标记校验未通过,防止残留动作被当作可执行结果 updated_state["validation_passed"] = False # 清空旧模拟快照,避免失败结果中仍携带之前的成功快照 diff --git a/app/processor/nodes/planner_node.py b/app/processor/nodes/planner_node.py index 1ce2e24..bff5496 100644 --- a/app/processor/nodes/planner_node.py +++ b/app/processor/nodes/planner_node.py @@ -65,7 +65,7 @@ class PlannerNode(BaseNode): # 解析失败、引用不存在的目标等可由模型自行纠正,超过次数才失败收尾 if attempt == PLAN_MAX_REPAIRS: return self.fail_on_invalid_output( - state, error, "未能生成可用的大屏规划,请调整需求后重试" + state, error, "规划输出多次未通过校验" ) self.logger.warning(f"规划未通过校验,重新规划: {error}") feedback = f"上一次输出未通过校验:{error}\n请修正后重新输出完整规划。" diff --git a/app/processor/nodes/qa_node.py b/app/processor/nodes/qa_node.py index 9aae076..6e5384f 100644 --- a/app/processor/nodes/qa_node.py +++ b/app/processor/nodes/qa_node.py @@ -38,7 +38,7 @@ class QANode(BaseNode): content = state["input_context"].model_dump_json(exclude_unset=True) offer = None - # 落地策略判定请求无法执行时,附上事实说明,由本节点如实告知用户 + # 请求无法执行或执行失败时,附上事实说明,由本节点如实告知用户 if notice := state.get("notice"): content += f"\n\n系统说明:{notice}" # 回复中的替代修改需要结构化保存,下一轮用户同意时直接执行,因此不流式输出 @@ -59,8 +59,9 @@ class QANode(BaseNode): updated_state["answer"] = answer # 保存等待用户确认的提议,没有提议时清空 updated_state["offer"] = offer - # 回答生成完成,问答分支在此结束 - updated_state["status"] = "completed" + # 回答生成完成;失败收尾后的说明保持失败状态,不交付动作 + if state.get("status") != "failed": + updated_state["status"] = "completed" # 返回包含回答和完成状态的更新结果 return updated_state diff --git a/app/processor/nodes/reflection_node.py b/app/processor/nodes/reflection_node.py index fa778c9..f3fa01e 100644 --- a/app/processor/nodes/reflection_node.py +++ b/app/processor/nodes/reflection_node.py @@ -15,7 +15,6 @@ from app.processor.state import QueryState from app.prompt.action import FIELD_QUERY_PROMPT, uses_material_fields from app.prompt.reflection import REFLECTION_SYSTEM_PROMPT from app.schemas.action_repair import ActionRepairPlan -from app.schemas.capabilities import USER_FAILURE_MESSAGE class ReflectionNode(BaseNode): @@ -88,11 +87,9 @@ class ReflectionNode(BaseNode): updated_state["repair_plan"] = plan # 仅在建议允许重试时将次数加一,缺少历史计数时从零开始,不可重试时保持原次数 updated_state["retry_count"] = state.get("retry_count", 0) + int(plan.retryable) - # 可以重试时清空旧失败原因;不可重试时建议中的分析含内部术语,只记录日志,对用户使用统一说明 - if not plan.retryable: - self.logger.warning("动作无法修正: {}", plan.reason) + # 可以重试时清空旧失败原因;不可重试时保留分析,由失败收尾交给模型向用户说明 updated_state["failure_reason"] = ( - None if plan.retryable else USER_FAILURE_MESSAGE + None if plan.retryable else f"修正分析判断无法完成:{plan.reason}" ) # 返回修正建议、重试次数和失败原因,由后续路由决定重新生成动作还是结束 diff --git a/app/processor/nodes/requirement_node.py b/app/processor/nodes/requirement_node.py index b57278a..709a8a1 100644 --- a/app/processor/nodes/requirement_node.py +++ b/app/processor/nodes/requirement_node.py @@ -18,9 +18,7 @@ class RequirementNode(BaseNode): return await self._generate(state) except ValueError as error: # 结构化输出解析失败或引用校验不通过(含 ValidationError、OutputParserException) - return self.fail_on_invalid_output( - state, error, "未能可靠理解你的需求,请换个说法再试" - ) + return self.fail_on_invalid_output(state, error, "需求理解的输出未通过校验") async def _generate(self, state: QueryState) -> QueryState: # 浅拷贝状态 diff --git a/app/processor/nodes/scope_node.py b/app/processor/nodes/scope_node.py index 03023bd..b90a347 100644 --- a/app/processor/nodes/scope_node.py +++ b/app/processor/nodes/scope_node.py @@ -22,9 +22,7 @@ class ScopeNode(BaseNode): return await self._generate(state) except ValueError as error: # 结构化输出解析失败或引用校验不通过(含 ValidationError、OutputParserException) - return self.fail_on_invalid_output( - state, error, "未能确定需要修改的范围,请描述得更具体一些" - ) + return self.fail_on_invalid_output(state, error, "修改范围的输出未通过校验") async def _generate(self, state: QueryState) -> QueryState: # 浅拷贝状态 diff --git a/app/prompt/action.py b/app/prompt/action.py index 92a16d7..dfb7cbc 100644 --- a/app/prompt/action.py +++ b/app/prompt/action.py @@ -12,6 +12,7 @@ ACTION_SYSTEM_PROMPT = f"""# 任务目标 # 输出要求 按提供的 ActionBatch Schema 返回 actions 列表,仅使用本次列出的动作类型 覆盖本次全部变更,不返回大屏 JSON、解释文本或执行成功声明 +有任何变更无法用列出的动作或可用字段完成时,不执行其中一部分:actions 为空,在 unsupported_reason 中说明缺少什么 以 input_context.screen 为起点,后续动作基于前序结果计算,不能重复执行或遗漏目标 # 输入边界 @@ -58,7 +59,7 @@ field_groups 按物料类型列出本次相关组件的字段分组,节点的 确定要改的字段:用用户原话中对属性的说法(input_context 中的提问,不用改写后的需求)调用 search_fields; 返回多个字段时,依据字段说明判断用户指的是哪一个;用户看的是大屏画面,没有提到图层列表、属性面板等编辑器位置时,按画面上看得到的内容理解 需要了解一组相关字段时用 get_fields,一次可查多个分组 -只能使用查询到的字段路径,值须符合字段的类型、可选值和取值范围;查不到对应字段说明不支持,不自行编造路径 +只能使用查询到的字段路径,值须符合字段的类型、可选值和取值范围;查不到对应字段说明物料不支持,按无法完成处理,不自行编造路径 """ # 这些动作会写组件属性,需要先查物料字段 diff --git a/app/prompt/qa.py b/app/prompt/qa.py index 0c1d2cb..20756c5 100644 --- a/app/prompt/qa.py +++ b/app/prompt/qa.py @@ -19,8 +19,9 @@ QA_SYSTEM_PROMPT = """## 任务 可以简短提示还能继续帮忙,不列举能力清单 ## 无法执行的请求 -输入附带“系统说明”时,表示用户的修改请求因目标不存在而没有执行 -用一两句话如实说明原因,不声称已修改 +输入附带“系统说明”时,表示用户的修改请求没有执行,系统说明记录了内部原因 +用一两句话如实说明原因,不声称已修改;用用户能理解的话转述,不提字段路径、节点 ID、错误代码、重试次数等内部信息 +原因是当前能力不支持时直接说明做不到什么;原因是生成或校验出错时说明这次没能完成,可以换个说法再试 有明显可行的替代修改时,只提一个,answer 以是否需要执行的问句结尾,例如“需要我新增一张折线图吗?”;不提供配置建议等非修改类替代 同时把该替代修改写成 offer:一条独立完整、可直接执行的修改指令,例如“在当前大屏新增一张折线图”;没有替代修改时 offer 为 null 新增组件使用物料示例数据,不要求用户提供数据;不要求用户离开对话去选中或操作组件 diff --git a/app/resources/materials/area-chart.json b/app/resources/materials/area-chart.json index fe447ed..666d080 100644 --- a/app/resources/materials/area-chart.json +++ b/app/resources/materials/area-chart.json @@ -42,6 +42,7 @@ } ] }, + "chart-dataset", "chart-encode-xy" ], "eventOptions": [ diff --git a/app/resources/materials/bar-chart.json b/app/resources/materials/bar-chart.json index 6d02315..55a1242 100644 --- a/app/resources/materials/bar-chart.json +++ b/app/resources/materials/bar-chart.json @@ -118,6 +118,7 @@ } ] }, + "chart-dataset", "chart-encode-xy" ], "eventOptions": [ diff --git a/app/resources/materials/bar-line-chart.json b/app/resources/materials/bar-line-chart.json index b6361ae..b52b8f3 100644 --- a/app/resources/materials/bar-line-chart.json +++ b/app/resources/materials/bar-line-chart.json @@ -143,6 +143,7 @@ } ] }, + "chart-dataset", { "key": "bar-line-data", "label": "数据映射", diff --git a/app/resources/materials/field_groups.json b/app/resources/materials/field_groups.json index 099a15c..dd247d5 100644 --- a/app/resources/materials/field_groups.json +++ b/app/resources/materials/field_groups.json @@ -293,6 +293,28 @@ } ] }, + { + "key": "chart-dataset", + "label": "图表数据", + "description": "图表画面上展示的数据,柱子高度、折线点、扇区大小等数值都来自这里", + "fields": [ + { + "path": "props.option.dataset.source", + "label": "数据", + "description": "图表展示的数据表,每个对象是一行(一个类目或数据项),键是列名,列名与数据映射字段对应;修改时提交完整的新数组:保留不需要修改的行和列,只改用户提到的数值或行;新增列或改列名时同时修改数据映射字段", + "value_type": "array", + "item_type": "object", + "aliases": [ + "数据", + "图表数据", + "数据值", + "示例数据", + "数据集" + ], + "panel": false + } + ] + }, { "key": "chart-encode-xy", "label": "数据映射", diff --git a/app/resources/materials/funnel-chart.json b/app/resources/materials/funnel-chart.json index e303ea8..860dc6f 100644 --- a/app/resources/materials/funnel-chart.json +++ b/app/resources/materials/funnel-chart.json @@ -69,6 +69,7 @@ } ] }, + "chart-dataset", "chart-encode-item" ], "eventOptions": [ diff --git a/app/resources/materials/gauge-chart.json b/app/resources/materials/gauge-chart.json index dccc2eb..b9b310f 100644 --- a/app/resources/materials/gauge-chart.json +++ b/app/resources/materials/gauge-chart.json @@ -82,6 +82,7 @@ } ] }, + "chart-dataset", "chart-encode-item" ], "eventOptions": [ diff --git a/app/resources/materials/hbar-chart.json b/app/resources/materials/hbar-chart.json index bf8eae6..38931ad 100644 --- a/app/resources/materials/hbar-chart.json +++ b/app/resources/materials/hbar-chart.json @@ -168,6 +168,7 @@ } ] }, + "chart-dataset", "chart-encode-xy" ], "eventOptions": [ diff --git a/app/resources/materials/heatmap-chart.json b/app/resources/materials/heatmap-chart.json index cf74de4..348096e 100644 --- a/app/resources/materials/heatmap-chart.json +++ b/app/resources/materials/heatmap-chart.json @@ -137,6 +137,7 @@ } ] }, + "chart-dataset", "chart-encode-xy", { "key": "heatmap-value", diff --git a/app/resources/materials/line-chart.json b/app/resources/materials/line-chart.json index 87b23b6..0a75f1b 100644 --- a/app/resources/materials/line-chart.json +++ b/app/resources/materials/line-chart.json @@ -24,6 +24,7 @@ "chart-grid", "chart-axis", "chart-line-series", + "chart-dataset", "chart-encode-xy" ], "eventOptions": [ diff --git a/app/resources/materials/pictorial-bar-chart.json b/app/resources/materials/pictorial-bar-chart.json index 3ef60bc..59fbe98 100644 --- a/app/resources/materials/pictorial-bar-chart.json +++ b/app/resources/materials/pictorial-bar-chart.json @@ -124,6 +124,7 @@ } ] }, + "chart-dataset", "chart-encode-xy" ], "eventOptions": [ diff --git a/app/resources/materials/pie-chart.json b/app/resources/materials/pie-chart.json index 9130080..2efd65e 100644 --- a/app/resources/materials/pie-chart.json +++ b/app/resources/materials/pie-chart.json @@ -23,6 +23,7 @@ "chart-legend", "chart-palette", "chart-item-series", + "chart-dataset", "chart-encode-item" ], "eventOptions": [ diff --git a/app/resources/materials/progress-ring-chart.json b/app/resources/materials/progress-ring-chart.json index 70ced2e..12c77f0 100644 --- a/app/resources/materials/progress-ring-chart.json +++ b/app/resources/materials/progress-ring-chart.json @@ -65,6 +65,7 @@ } ] }, + "chart-dataset", "chart-encode-item" ], "eventOptions": [ diff --git a/app/resources/materials/radar-chart.json b/app/resources/materials/radar-chart.json index 1032793..d04d7ac 100644 --- a/app/resources/materials/radar-chart.json +++ b/app/resources/materials/radar-chart.json @@ -102,6 +102,7 @@ } ] }, + "chart-dataset", { "key": "radar-data", "label": "数据映射", diff --git a/app/resources/materials/ring-chart.json b/app/resources/materials/ring-chart.json index 90778b1..d5e11e1 100644 --- a/app/resources/materials/ring-chart.json +++ b/app/resources/materials/ring-chart.json @@ -43,6 +43,7 @@ } ] }, + "chart-dataset", "chart-encode-item" ], "eventOptions": [ diff --git a/app/resources/materials/rose-chart.json b/app/resources/materials/rose-chart.json index 1f82950..2b90653 100644 --- a/app/resources/materials/rose-chart.json +++ b/app/resources/materials/rose-chart.json @@ -64,6 +64,7 @@ } ] }, + "chart-dataset", "chart-encode-item" ], "eventOptions": [ diff --git a/app/resources/materials/scatter-chart.json b/app/resources/materials/scatter-chart.json index 082eb07..c65b743 100644 --- a/app/resources/materials/scatter-chart.json +++ b/app/resources/materials/scatter-chart.json @@ -67,6 +67,7 @@ } ] }, + "chart-dataset", "chart-encode-xy" ], "eventOptions": [ diff --git a/app/resources/materials/stacked-bar-chart.json b/app/resources/materials/stacked-bar-chart.json index de15880..ff6a21e 100644 --- a/app/resources/materials/stacked-bar-chart.json +++ b/app/resources/materials/stacked-bar-chart.json @@ -89,6 +89,7 @@ } ] }, + "chart-dataset", { "key": "stacked-data", "label": "数据映射", diff --git a/app/schemas/actions/__init__.py b/app/schemas/actions/__init__.py index db6b701..bf27f44 100644 --- a/app/schemas/actions/__init__.py +++ b/app/schemas/actions/__init__.py @@ -1,8 +1,8 @@ """动作类型统一入口,新增操作时在这里显式加入联合类型""" -from typing import Annotated +from typing import Annotated, Self -from pydantic import Field, TypeAdapter +from pydantic import Field, TypeAdapter, model_validator from .add import AddAction from .base import ActionModel, BaseAction @@ -22,6 +22,16 @@ action_adapter = TypeAdapter(Action) class ActionBatch(ActionModel): actions: list[Action] = Field(description="按数组顺序执行的动作列表,允许为空") + unsupported_reason: str | None = Field( + default=None, + description="需求中有任何部分无法用现有动作或查到的字段完成时,说明缺少什么能力,此时 actions 为空;能完成时为 null", + ) + + @model_validator(mode="after") + def unsupported_has_no_actions(self) -> Self: + if self.unsupported_reason and self.actions: + raise ValueError("填写 unsupported_reason 时 actions 必须为空,不能只执行需求的一部分") + return self __all__ = [ diff --git a/app/schemas/agent.py b/app/schemas/agent.py index 03c4b5b..f4e2eed 100644 --- a/app/schemas/agent.py +++ b/app/schemas/agent.py @@ -21,6 +21,5 @@ class AgentResult(BaseModel): message_id: str # 本次用户输入的 ID,不是助手回复 ID status: Literal["completed", "waiting_for_input", "failed", "cancelled"] actions: list[Action] = Field(default_factory=list) - answer: str | None = None + answer: str | None = None # 回答,或失败时由模型总结的原因说明 clarification: Clarification | None = None - failure_reason: str | None = None diff --git a/app/schemas/capabilities.py b/app/schemas/capabilities.py index 5e08d01..f64841d 100644 --- a/app/schemas/capabilities.py +++ b/app/schemas/capabilities.py @@ -20,6 +20,3 @@ RESOURCE_QUERY_SUPPORTED = False RESOURCE_FALLBACK_ASSUMPTION = ( "尚未接入物料目录之外的资源查询,所需内容使用物料默认配置和示例数据" ) - -# 面向用户的失败说明,不暴露内部校验术语 -USER_FAILURE_MESSAGE = "抱歉,这次没能完成修改,请换个说法或稍后再试" diff --git a/app/services/agent.py b/app/services/agent.py index c97bfa4..41bd1d2 100644 --- a/app/services/agent.py +++ b/app/services/agent.py @@ -158,7 +158,7 @@ class AgentService: if result.status == "cancelled": return "用户取消了本次补充" if result.status == "failed": - return _clip(f"处理失败:{result.failure_reason or '未知原因'}") + return _clip(f"处理失败:{result.answer or '未知原因'}") if result.answer: return _clip(result.answer) requirement = state.get("requirement") @@ -176,8 +176,10 @@ class AgentService: prompt=state["request"]["prompt"], outcome=result.status, summary=self._summarize(state, result), - # 只有正常结束的回复中的提议才等待用户确认 - offer=state.get("offer") if result.status == "completed" else None, + # 回答或失败说明中的提议等待用户确认,等待补充和取消时没有提议 + offer=state.get("offer") + if result.status in ("completed", "failed") + else None, created_at=datetime.now(timezone.utc), ) ) @@ -198,7 +200,6 @@ class AgentService: actions=state["actions"] if deliver else [], answer=state.get("answer"), clarification=state.get("clarification") if waiting else None, - failure_reason=state.get("failure_reason"), ) async def stream(self, thread_id, payload, session_id, user_id=None):