diff --git a/app/processor/graph.py b/app/processor/graph.py index 891c00f..870829f 100644 --- a/app/processor/graph.py +++ b/app/processor/graph.py @@ -10,10 +10,10 @@ from app.config import settings from app.processor.nodes.action_node import ActionNode, can_generate_actions from app.processor.nodes.clarify_node import ClarifyNode from app.processor.nodes.component_resource_node import ComponentResourceNode +from app.processor.nodes.design_node import DesignNode from app.processor.nodes.fallback_node import FallbackNode from app.processor.nodes.input_node import InputNode from app.processor.nodes.intent_node import IntentNode -from app.processor.nodes.planner_node import PlannerNode from app.processor.nodes.qa_node import QANode from app.processor.nodes.reflection_node import ReflectionNode from app.processor.nodes.requirement_node import RequirementNode @@ -75,9 +75,9 @@ def route_after_wait(state: QueryState) -> str: # TODO 目前“资源”只有物料目录,数据源和 RAG 尚未准备好 # 接入后在 Requirement 中增加资源需求,与 needs_planning 作为两个独立开关: -# 规划 -> 按需查资源 -> planner;无需规划但有资源需求 -> 查资源 -> action +# 规划 -> 按需查资源 -> design;无需规划但有资源需求 -> 查资源 -> action # 并让 component_resource_node 按资源需求决定查什么,而不是固定全量目录 -# 接入时把 capabilities.RESOURCE_QUERY_SUPPORTED 改为 True;在此之前 PlannerNode +# 接入时把 capabilities.RESOURCE_QUERY_SUPPORTED 改为 True;在此之前 DesignNode # 会清空资源需求并改用物料默认配置和示例数据,不因资源需求失败 def route_after_scope(state: QueryState) -> str: # 已判定无法直接执行,失败收尾 @@ -139,7 +139,7 @@ def build_graph(checkpointer=None): "wait_for_user_node": WaitForUserNode(), "qa_node": QANode(), "scope_node": ScopeNode(), - "planner_node": PlannerNode(), + "design_node": DesignNode(), "component_resource_node": ComponentResourceNode(), "action_node": ActionNode(), "validator_node": ValidatorNode(), @@ -202,30 +202,25 @@ def build_graph(checkpointer=None): }, ) - # 组件目录查询成功才允许规划,未找到或不支持时进入失败收尾 + # 组件目录查询成功才允许设计,未找到或不支持时进入失败收尾 workflow.add_conditional_edges( "component_resource_node", lambda state: state["component_resources"].status == "resolved", - {True: "planner_node", False: "fallback_node"}, + {True: "design_node", False: "fallback_node"}, ) - # 规划完成后交给动作节点转换,动作节点还会检查规划是否可执行 - workflow.add_conditional_edges( - "planner_node", - lambda state: "fallback_node" if state["status"] == "failed" else "action_node", - {"action_node": "action_node", "fallback_node": "fallback_node"}, - ) - - # 动作生成后按结果进入校验、修正分析或失败收尾 - workflow.add_conditional_edges( - "action_node", - route_after_action, - { - "validator_node": "validator_node", - "reflection_node": "reflection_node", - "fallback_node": "fallback_node", - }, - ) + # 设计(规划和动作一次生成)与动作生成的结果走同样的去向:校验、修正分析或失败收尾 + # 修正时规划已定,由 action_node 只重新生成动作 + for node_name in ("design_node", "action_node"): + workflow.add_conditional_edges( + node_name, + route_after_action, + { + "validator_node": "validator_node", + "reflection_node": "reflection_node", + "fallback_node": "fallback_node", + }, + ) # 校验成功结束,失败则在重试上限内分析修正原因 workflow.add_conditional_edges( diff --git a/app/processor/nodes/action_node.py b/app/processor/nodes/action_node.py index 772ab7b..9fbf68c 100644 --- a/app/processor/nodes/action_node.py +++ b/app/processor/nodes/action_node.py @@ -65,6 +65,90 @@ def action_block_reason(state: QueryState) -> str | None: return None +# 模型输出的结构和解析错误,统一转换为校验结果,交给后续修正流程 +GENERATION_EXCEPTIONS = (ValidationError, OutputParserException, StructuredOutputError) + + +def generation_errors(error: Exception) -> list[ActionValidationError]: + """把 GENERATION_EXCEPTIONS 中的异常转换为统一的校验错误""" + # 查询字段后的输出不符合结构,与直接输出时的处理一致 + if isinstance(error, StructuredOutputValidationError): + error = error.source + if isinstance(error, ValidationError): + return schema_errors(error) + # 模型输出无法解析时,提示后续按 ActionBatch 协议重新生成 + return [GENERATION_ERROR] + + +def reset_action_output(updated_state: QueryState) -> None: + """清空上一轮的动作、校验和失败信息,避免本轮失败后残留旧结果""" + updated_state.update( + actions=None, + validation_passed=None, + validation_errors=[], + simulated_screen=None, + failure_reason=None, + status="running", + ) + + +def block_actions(updated_state: QueryState, reason: str) -> QueryState: + """规划不满足执行条件时直接失败收尾,不调用模型生成动作""" + updated_state.update( + actions=None, + validation_passed=False, + validation_errors=[], + simulated_screen=None, + failure_reason=reason, + status="failed", + ) + return updated_state + + +def accept_batch(state: QueryState, result: ActionBatch | None) -> ActionBatch: + """校验模型给出的动作,有规划时用物料默认配置补全新增节点 + + 结构不合法时抛出 GENERATION_EXCEPTIONS 中的异常 + """ + # 响应中没有工具调用时解析器会返回 None + if result is None: + raise OutputParserException("模型未返回结构化结果") + raw = result.model_dump() + + # 规划生成的新增节点只保留显式提供的字段,避免模型默认值覆盖物料默认配置 + if state.get("screen_plan") is not None: + # 将原动作对象与序列化结果逐项对应,只调整新增动作的节点数据 + for source, target in zip(result.actions, raw["actions"]): + if isinstance(source, AddAction): + target["node"] = source.node.model_dump(exclude_unset=True) + + # 统一校验整批动作的字段和结构,格式不合法时抛出 ValidationError + batch = ActionBatch.model_validate(raw) + + # 如果已有规划,使用物料默认配置补全新增节点,并同步示例数据标记 + # 模型判断无法完成时没有动作可补全,保留 unsupported_reason 交给失败收尾 + if state.get("screen_plan") is not None and not batch.unsupported_reason: + batch = assemble_plan_actions(state, batch) + return batch + + +def store_batch(updated_state: QueryState, batch: ActionBatch) -> QueryState: + """保存待校验的动作;模型确认能力不足时直接失败收尾,不进入重试""" + if batch.unsupported_reason: + updated_state.update( + actions=[], + validation_passed=False, + failure_reason=f"动作生成判断无法完成:{batch.unsupported_reason}", + status="failed", + ) + return updated_state + + # 实际目标、规划一致性及模拟执行结果仍由后续校验节点检查 + # 此时 validation_passed 仍为 None,不代表动作已执行或通过校验 + updated_state["actions"] = batch.actions + return updated_state + + class ActionNode(BaseNode): name = "action_node" @@ -76,16 +160,7 @@ class ActionNode(BaseNode): if state.get("screen_plan") is not None and ( reason := plan_block_reason(state) ): - # 清空旧动作及模拟结果,标记失败,防止后续误用上一轮生成内容 - updated_state.update( - actions=None, - validation_passed=False, - validation_errors=[], - simulated_screen=None, - failure_reason=reason, - status="failed", - ) - return updated_state + return block_actions(updated_state, reason) # 将输入上下文、结构化需求和修改范围转成字典,作为本次模型输入 payload = { @@ -128,12 +203,7 @@ class ActionNode(BaseNode): content = json.dumps(payload, ensure_ascii=False) # 旧动作及错误已放入模型输入,现在清空输出状态,避免本轮失败后残留旧结果 - updated_state["actions"] = None - updated_state["validation_passed"] = None - updated_state["validation_errors"] = [] - updated_state["simulated_screen"] = None - updated_state["failure_reason"] = None - updated_state["status"] = "running" + reset_action_output(updated_state) # 捕获输出结构和解析错误,转换成统一校验结果供后续修正流程使用 try: @@ -149,58 +219,10 @@ class ActionNode(BaseNode): HumanMessage(content=content), ] ) - - # 响应中没有工具调用时解析器会返回 None - if result is None: - raise OutputParserException("模型未返回结构化结果") - raw = result.model_dump() - - # 规划生成的新增节点只保留显式提供的字段,避免模型默认值覆盖物料默认配置 - if state.get("screen_plan") is not None: - # 将原动作对象与序列化结果逐项对应,只调整新增动作的节点数据 - for source, target in zip(result.actions, raw["actions"]): - if isinstance(source, AddAction): - target["node"] = source.node.model_dump(exclude_unset=True) - - # 统一校验整批动作的字段和结构,格式不合法时抛出 ValidationError - batch = ActionBatch.model_validate(raw) - - # 如果已有规划,使用物料默认配置补全新增节点,并同步示例数据标记 - if state.get("screen_plan") is not None: - batch = assemble_plan_actions(state, batch) - - except ValidationError as error: - # 将结构校验异常转换为统一错误列表,返回状态以进入后续修正判断 + batch = accept_batch(state, result) + except GENERATION_EXCEPTIONS as error: updated_state["validation_passed"] = False - updated_state["validation_errors"] = schema_errors(error) - return updated_state - except StructuredOutputValidationError as error: - # 查询字段后的输出不符合结构,与直接输出时的处理一致 - updated_state["validation_passed"] = False - updated_state["validation_errors"] = ( - schema_errors(error.source) - if isinstance(error.source, ValidationError) - else [GENERATION_ERROR] - ) - return updated_state - except (OutputParserException, StructuredOutputError): - # 如果模型输出无法解析,记录生成错误,提示后续按 ActionBatch 协议重新生成 - updated_state["validation_passed"] = False - updated_state["validation_errors"] = [GENERATION_ERROR] + updated_state["validation_errors"] = generation_errors(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 - - # 返回待校验状态,此时 validation_passed 仍为 None,不代表动作已执行或通过校验 - return updated_state + return store_batch(updated_state, batch) diff --git a/app/processor/nodes/design_node.py b/app/processor/nodes/design_node.py new file mode 100644 index 0000000..badb568 --- /dev/null +++ b/app/processor/nodes/design_node.py @@ -0,0 +1,163 @@ +"""将需要规划的需求一次设计成规划和动作,修正重试仍交给 ActionNode""" + +import json + +from app.actions.plan import plan_block_reason +from app.processor.base import BaseNode +from app.processor.material_agent import invoke_with_field_tools +from app.processor.nodes.action_node import ( + GENERATION_EXCEPTIONS, + accept_batch, + block_actions, + generation_errors, + reset_action_output, + store_batch, +) +from app.processor.state import QueryState +from app.prompt.design import DESIGN_SYSTEM_PROMPT +from app.schemas.capabilities import ( + RESOURCE_FALLBACK_ASSUMPTION, + RESOURCE_QUERY_SUPPORTED, +) +from app.schemas.screen_design import ScreenDesign +from app.schemas.screen_plan import ScreenPlan + +# 设计输出未通过解析或规划校验时,带着错误让模型重新设计的最大次数 +# TODO 上游节点目前只有这里做了节点内重试,requirement_node 尚未跟进 +PLAN_MAX_REPAIRS = 1 + + +def check_plan(plan: ScreenPlan, state: QueryState) -> None: + """校验规划的组件类型、目标引用和修改范围,不通过时抛出 ValueError""" + requirement = state["requirement"] + scope = state["change_scope"] + context = state["input_context"] + resources = state["component_resources"] + + # 校验规划中的物料类型是否真实受支持 + supported_types = {component.type for component in resources.components} + for component in plan.components: + # 如果新增组件没有指定类型,无法选择对应物料,立即报错 + if component.disposition == "add" and component.component_type is None: + raise ValueError(f"新增规划组件未指定物料类型: {component.plan_id}") + # 新增、修改或更换组件时,只要明确指定了类型,就必须在目录中存在 + if ( + component.disposition in ("add", "modify", "replace") + and component.component_type is not None + and component.component_type not in supported_types + ): + raise ValueError( + f"规划使用了目录中不存在的组件类型: {component.component_type}" + ) + + # 收集当前大屏中真实存在的节点 ID,用于检查规划引用的已有目标 + existing_ids = {node.id for node in context.screen.nodes} + # 汇总规划中明确引用的目标 ID,新增组件没有已有目标时不计入 + planned_ids = { + component.target_id + for component in plan.components + if component.target_id is not None + } + # 如果规划引用了不存在的组件,则抛出异常并列出无效 ID + if unknown := planned_ids - existing_ids: + raise ValueError(f"规划引用了不存在的组件: {sorted(unknown)}") + + # 汇总需求明确指定的目标 ID,避免规划遗漏用户提及的组件 + required_ids = { + target for change in requirement.changes for target in change.target_ids + } + + # 如果需求目标未在规划中出现,则停止处理并指出遗漏 + if missing := required_ids - planned_ids: + raise ValueError(f"规划遗漏了需求中的组件: {sorted(missing)}") + # 如果是局部规划,还需保证对已有组件的修改和删除不超出指定范围 + if scope.scope == "local": + # 只统计会改变已有组件的目标,保留和新增组件不纳入此项检查 + changed_ids = { + component.target_id + for component in plan.components + if component.disposition in ("modify", "remove", "replace") + } + # 如果实际修改或删除的目标不在允许范围内,则拒绝扩大修改范围 + if outside := changed_ids - set(scope.target_ids): + raise ValueError(f"局部规划修改了范围外的组件: {sorted(outside)}") + + +class DesignNode(BaseNode): + name = "design_node" + + async def process(self, state: QueryState) -> QueryState: + # 浅拷贝状态 + updated_state: QueryState = state.copy() + requirement = state["requirement"] + # 图保证组件目录已就绪后才进入设计 + resources = state["component_resources"] + + # 业务资料放入用户消息;组件只提供摘要,完整物料配置由模型按需查询 + content = json.dumps( + { + "input_context": state["input_context"].model_dump(mode="json"), + "requirement": requirement.model_dump(mode="json"), + "change_scope": state["change_scope"].model_dump(mode="json"), + "component_resources": resources.to_planner_context(), + }, + ensure_ascii=False, + ) + reset_action_output(updated_state) + + feedback = "" + for attempt in range(PLAN_MAX_REPAIRS + 1): + design = None + try: + design = await invoke_with_field_tools( + ScreenDesign, DESIGN_SYSTEM_PROMPT, content + feedback + ) + if design is None: + raise ValueError("模型未返回结构化结果") + check_plan(design.plan, state) + break + except (ValueError, *GENERATION_EXCEPTIONS) as error: + # 解析失败时还没有规划,只能整份重新设计;超过次数才失败收尾 + if attempt == PLAN_MAX_REPAIRS: + return self.fail_on_invalid_output( + state, error, "设计输出多次未通过校验" + ) + self.logger.warning(f"设计未通过校验,重新设计: {error}") + feedback = ( + f"\n\n上一次输出未通过校验:{error}\n请修正后重新输出完整设计。" + ) + if design is not None: + feedback = f"\n\n上一次输出:\n{design.model_dump_json()}{feedback}" + + plan = design.plan + # 资源查询接入前没有补充来源,保留会让动作阶段误判为“待获取资源”而失败 + if not RESOURCE_QUERY_SUPPORTED and plan.resource_needs: + self.logger.info( + f"资源查询未接入,改用默认配置: {[need.description for need in plan.resource_needs]}" + ) + plan.resource_needs = [] + plan.assumptions.append(RESOURCE_FALLBACK_ASSUMPTION) + + # 合并需求和规划的约束与假设,按首次出现顺序去重,需求在前 + plan.constraints = list( + dict.fromkeys([*requirement.constraints, *plan.constraints]) + ) + plan.assumptions = list( + dict.fromkeys([*requirement.assumptions, *plan.assumptions]) + ) + # 保存通过校验的规划,后续动作校验和修正重试都以它为准 + updated_state["screen_plan"] = plan + + # 规划不满足执行条件时不再处理动作 + if reason := plan_block_reason(updated_state): + return block_actions(updated_state, reason) + + # 动作结构错误交给 reflection 和 ActionNode 按已定的规划修正 + try: + batch = accept_batch(updated_state, design.batch) + except GENERATION_EXCEPTIONS as error: + updated_state["validation_passed"] = False + updated_state["validation_errors"] = generation_errors(error) + return updated_state + + return store_batch(updated_state, batch) diff --git a/app/processor/nodes/planner_node.py b/app/processor/nodes/planner_node.py deleted file mode 100644 index c835ae6..0000000 --- a/app/processor/nodes/planner_node.py +++ /dev/null @@ -1,152 +0,0 @@ -"""将需要规划的需求整理为 ScreenPlan""" - -import json - -from langchain_core.messages import HumanMessage, SystemMessage - -from app.clients.llm import get_llm -from app.processor.base import BaseNode -from app.processor.state import QueryState -from app.prompt.planner import PLANNER_SYSTEM_PROMPT -from app.schemas.capabilities import ( - RESOURCE_FALLBACK_ASSUMPTION, - RESOURCE_QUERY_SUPPORTED, -) -from app.schemas.screen_plan import ScreenPlan - -# 规划输出未通过校验时,带着错误让模型重新规划的最大次数 -# TODO 上游节点目前只有这里做了节点内重试,requirement_node 尚未跟进 -# TODO 将来可改为图级回路:规划校验失败 -> 规划版 reflection -> planner, -# 与动作阶段的重试统一由 route_failure 控制,届时可去掉这里的节点内重试 -PLAN_MAX_REPAIRS = 1 - - -class PlannerNode(BaseNode): - name = "planner_node" - - async def process(self, state: QueryState) -> QueryState: - # 浅拷贝状态 - updated_state: QueryState = state.copy() - # 获取已提取的需求和范围,作为规划内容及修改边界的依据 - requirement = state["requirement"] - scope = state["change_scope"] - - # 获取当前大屏和用户输入,供模型结合已有内容安排布局与组件 - context = state["input_context"] - # 图保证组件目录已就绪后才进入规划 - resources = state["component_resources"] - - messages = [ - # 系统消息提供稳定的规划规则和输出边界 - SystemMessage(content=PLANNER_SYSTEM_PROMPT), - # 用户消息承载本次上下文、需求、范围及组件能力摘要 - HumanMessage( - content=json.dumps( - { - "input_context": context.model_dump(mode="json"), - "requirement": requirement.model_dump(mode="json"), - "change_scope": scope.model_dump(mode="json"), - # 仅提供规划需要的组件摘要,省略完整默认配置 - "component_resources": resources.to_planner_context(), - }, - ensure_ascii=False, - ) - ), - ] - llm_client = get_llm(ScreenPlan) - - for attempt in range(PLAN_MAX_REPAIRS + 1): - plan = None - try: - plan = await llm_client.ainvoke(messages) - self._check(plan, state) - break - except ValueError as error: - # 解析失败、引用不存在的目标等可由模型自行纠正,超过次数才失败收尾 - if attempt == PLAN_MAX_REPAIRS: - return self.fail_on_invalid_output( - state, error, "规划输出多次未通过校验" - ) - self.logger.warning(f"规划未通过校验,重新规划: {error}") - feedback = f"上一次输出未通过校验:{error}\n请修正后重新输出完整规划。" - if plan is not None: - feedback = f"上一次输出:\n{plan.model_dump_json()}\n\n{feedback}" - messages = [*messages, HumanMessage(content=feedback)] - - # 资源查询接入前没有补充来源,保留会让动作阶段误判为“待获取资源”而失败 - if not RESOURCE_QUERY_SUPPORTED and plan.resource_needs: - self.logger.info( - f"资源查询未接入,改用默认配置: {[need.description for need in plan.resource_needs]}" - ) - plan.resource_needs = [] - plan.assumptions.append(RESOURCE_FALLBACK_ASSUMPTION) - - # 合并需求约束和规划约束,按首次出现顺序去重,保留需求约束在前 - plan.constraints = list( - dict.fromkeys([*requirement.constraints, *plan.constraints]) - ) - # 同样合并原始假设与规划补充的假设,避免重复记录或丢失前置条件 - plan.assumptions = list( - dict.fromkeys([*requirement.assumptions, *plan.assumptions]) - ) - # 保存通过校验的规划,供后续动作生成使用 - updated_state["screen_plan"] = plan - - # 返回更新后的状态,由图继续处理规划对应的动作 - return updated_state - - @staticmethod - def _check(plan: ScreenPlan, state: QueryState) -> None: - """校验规划的组件类型、目标引用和修改范围,不通过时抛出 ValueError""" - requirement = state["requirement"] - scope = state["change_scope"] - context = state["input_context"] - resources = state["component_resources"] - - # 校验规划中的物料类型是否真实受支持 - supported_types = {component.type for component in resources.components} - for component in plan.components: - # 如果新增组件没有指定类型,无法选择对应物料,立即报错 - if component.disposition == "add" and component.component_type is None: - raise ValueError(f"新增规划组件未指定物料类型: {component.plan_id}") - # 新增、修改或更换组件时,只要明确指定了类型,就必须在目录中存在 - if ( - component.disposition in ("add", "modify", "replace") - and component.component_type is not None - and component.component_type not in supported_types - ): - raise ValueError( - f"规划使用了目录中不存在的组件类型: {component.component_type}" - ) - - # 收集当前大屏中真实存在的节点 ID,用于检查规划引用的已有目标 - existing_ids = {node.id for node in context.screen.nodes} - # 汇总规划中明确引用的目标 ID,新增组件没有已有目标时不计入 - planned_ids = { - component.target_id - for component in plan.components - if component.target_id is not None - } - # 如果规划引用了不存在的组件,则抛出异常并列出无效 ID - if unknown := planned_ids - existing_ids: - raise ValueError(f"规划引用了不存在的组件: {sorted(unknown)}") - - # 汇总需求明确指定的目标 ID,避免规划遗漏用户提及的组件 - required_ids = { - target for change in requirement.changes for target in change.target_ids - } - - # 如果需求目标未在规划中出现,则停止处理并指出遗漏 - if missing := required_ids - planned_ids: - raise ValueError(f"规划遗漏了需求中的组件: {sorted(missing)}") - # 如果是局部规划,还需保证对已有组件的修改和删除不超出指定范围 - if scope.scope == "local": - # 只统计会改变已有组件的目标,保留和新增组件不纳入此项检查 - changed_ids = { - component.target_id - for component in plan.components - if component.disposition in ("modify", "remove", "replace") - } - # 如果实际修改或删除的目标不在允许范围内,则拒绝扩大修改范围 - if outside := changed_ids - set(scope.target_ids): - raise ValueError(f"局部规划修改了范围外的组件: {sorted(outside)}") diff --git a/app/processor/progress.py b/app/processor/progress.py index a0df689..f99ba3a 100644 --- a/app/processor/progress.py +++ b/app/processor/progress.py @@ -15,7 +15,7 @@ NODE_PROGRESS_MESSAGES = { "requirement_node": "正在理解你的需求", "qa_node": "正在整理回答", "component_resource_node": "正在选择合适的图表和组件", - "planner_node": "正在设计布局", + "design_node": "正在设计大屏", "action_node": "正在准备画布修改", "validator_node": "正在检查修改结果", "reflection_node": "正在调整修改方案", diff --git a/app/prompt/action.py b/app/prompt/action.py index d64af67..5b5c4f1 100644 --- a/app/prompt/action.py +++ b/app/prompt/action.py @@ -11,21 +11,25 @@ from app.schemas.capabilities import DATA_BINDING_GUIDE from app.schemas.requirement import Requirement from app.schemas.screen_plan import ScreenPlan +# 动作生成和设计节点共用的动作约束 +ACTION_CONSTRAINTS = f"""# 动作要求 +仅使用本次列出的动作类型,覆盖本次全部变更,不返回大屏 JSON、解释文本或执行成功声明 +有任何变更无法用列出的动作或可用字段完成时,不执行其中一部分:actions 为空,在 unsupported_reason 中说明缺少什么 +以 input_context.screen 为起点,后续动作基于前序结果计算,不能重复执行或遗漏目标 +不能编造真实业务数据、数据绑定或未读取的附件内容,不用近似操作替代无法表达的需求 +{DATA_BINDING_GUIDE} +""" + ACTION_SYSTEM_PROMPT = f"""# 任务目标 你是大屏动作生成器,将已确认的需求转换成可按顺序执行的动作 遵循 requirement 和 change_scope 中的目标与约束,不添加额外优化 - -# 输出要求 -按提供的 ActionBatch Schema 返回 actions 列表,仅使用本次列出的动作类型 -覆盖本次全部变更,不返回大屏 JSON、解释文本或执行成功声明 -有任何变更无法用列出的动作或可用字段完成时,不执行其中一部分:actions 为空,在 unsupported_reason 中说明缺少什么 -以 input_context.screen 为起点,后续动作基于前序结果计算,不能重复执行或遗漏目标 +按提供的 ActionBatch Schema 返回 actions 列表 # 输入边界 input_context 提供当前大屏快照、用户原文及编辑上下文 {INPUT_AS_DATA} -不能编造真实业务数据、数据绑定或未读取的附件内容,不用近似操作替代无法表达的需求 -{DATA_BINDING_GUIDE} + +{ACTION_CONSTRAINTS} """ SIMPLE_TASK_PROMPT = """# 当前任务:直接修改 @@ -44,6 +48,10 @@ MODIFY_PLAN_PROMPT = f"""## 调整已有组件 design 表示属性设计要求,转换为 props/style 更新;只有布局要求时不修改属性 """ +CANVAS_PLAN_PROMPT = """## 调整画布 +将 canvas_changes 中的最终属性值转换为 update_canvas,完整覆盖且不添加其他画布字段 +""" + REPLACE_PLAN_PROMPT = """## 更换组件类型 每个 replace 规划生成一个 add:plan_id 用该规划编号,node.id 沿用原组件的 target_id,component_key 选择 component_type 对应的物料 原组件的删除和位置尺寸由系统处理,不要为它生成 delete、move 或 resize @@ -61,7 +69,7 @@ REPAIR_TASK_PROMPT = """# 当前阶段:修正重试 FIELD_QUERY_PROMPT = """# 查询组件字段 -field_groups 按物料类型列出本次相关组件的字段分组,节点的 type 就是物料类型 +提供 field_groups 时,它按物料类型列出本次相关组件的字段分组;没有时用 list_field_groups 查看,节点的 type 就是物料类型 确定要改的字段:用用户原话中对属性的说法(input_context 中的提问,不用改写后的需求)调用 search_fields; 返回多个字段时,依据字段说明判断用户指的是哪一个;用户看的是大屏画面,没有提到图层列表、属性面板等编辑器位置时,按画面上看得到的内容理解 需要了解一组相关字段时用 get_fields,一次可查多个分组 @@ -100,6 +108,14 @@ def uses_material_fields( return bool(action_types(requirement, screen_plan) & FIELD_ACTIONS) +def action_rule_sections(types: set[str]) -> list[str]: + """本次可用动作及各自的规则;排序保证顺序稳定,缺少动作说明时直接报错,避免静默遗漏""" + return [ + "# 本次可用动作\n" + "、".join(sorted(types)), + *(ACTION_RULES[name] for name in sorted(types)), + ] + + def build_action_prompt( requirement: Requirement, screen_plan: ScreenPlan | None = None, @@ -115,9 +131,7 @@ def build_action_prompt( sections.append(PLAN_TASK_PROMPT) dispositions = {item.disposition for item in screen_plan.components} if screen_plan.canvas_changes: - sections.append( - "## 调整画布\n将 canvas_changes 中的最终属性值转换为 update_canvas,完整覆盖且不添加其他画布字段" - ) + sections.append(CANVAS_PLAN_PROMPT) if "replace" in dispositions: sections.append(REPLACE_PLAN_PROMPT) if "modify" in dispositions: @@ -125,9 +139,7 @@ def build_action_prompt( if types & FIELD_ACTIONS: sections.append(FIELD_QUERY_PROMPT) - # 排序保证提示词顺序稳定,缺少动作说明时直接报错,避免静默遗漏 - sections.append("# 本次可用动作\n" + "、".join(sorted(types))) - sections.extend(ACTION_RULES[name] for name in sorted(types)) + sections.extend(action_rule_sections(types)) if repairing: sections.append(REPAIR_TASK_PROMPT) return "\n\n".join(section.strip() for section in sections) diff --git a/app/prompt/design.py b/app/prompt/design.py new file mode 100644 index 0000000..1b3e5ae --- /dev/null +++ b/app/prompt/design.py @@ -0,0 +1,70 @@ +"""规划与动作一次生成的提示词,输出字段由 ScreenDesign 定义 + +规划规则写在这里,动作规则复用 action.py;修正重试仍由 ActionNode 按已定的规划生成动作 +""" + +from app.prompt.action import ( + ACTION_CONSTRAINTS, + CANVAS_PLAN_PROMPT, + FIELD_QUERY_PROMPT, + MODIFY_PLAN_PROMPT, + REPLACE_PLAN_PROMPT, + action_rule_sections, +) +from app.prompt.action_rules import ACTION_RULES +from app.prompt.domain import ( + DISPOSITION_GUIDE, + EXISTING_TARGET_RULE, + INPUT_AS_DATA, + LAYOUT_RULE, +) + +DESIGN_TASK_PROMPT = f"""## 任务 +将已确认的需求设计成 ScreenDesign,一次完成规划和动作: +- plan:ScreenPlan,明确区域、组件组合、指标、布局和设计要求,是校验动作范围的依据 +- batch:落实 plan 的 ActionBatch,与 plan 的组件逐一对应:add / replace 各生成一个带 plan_id 的 add; + modify 转换为 move / resize / update;remove 生成 delete;canvas_changes 转换为 update_canvas;keep 和未提及的组件不生成动作 +plan 中的设计要求都要在 batch 中落实,batch 不做 plan 之外的修改 + +## 规划依据 +以 requirement 为目标、change_scope 为范围,结合 input_context 中的大屏和澄清记录规划 +覆盖本次全部变更,保留约束,不重新判断意图或扩大修改范围 + +## 组件与布局 +canvas_changes 只填写需求范围内要修改的画布顶层字段及最终值,不填写相对变化或设计描述 +{EXISTING_TARGET_RULE};无需改动的组件可标记 keep 或省略 +{DISPOSITION_GUIDE} +需求的 replace 对应 disposition=replace,component_type 从目录中选择用户要的新类型 +{LAYOUT_RULE};新增和调整位置的组件在 plan 中写明 layout,动作与之一致 +空白画布上的新增组件,位置和尺寸参考目录默认尺寸安排,避免与已有组件重叠 +不编造目录中不存在的组件类型或数据 + +## 物料配置 +component_resources 只提供组件摘要,用于选型 +选定新增或更换的类型后,调用 get_component_materials 查看默认配置,返回的 materials 即动作规则中的 component_materials +add 的 node 只写需要覆盖的字段,其余由后端按物料默认配置补全;不从零编写 props,不根据其他物料推测字段 + +## 资源与假设 +resource_needs 仅用于组件目录之外的资源,如业务数据、模板、素材;不要为组件的配置、属性或数据绑定方式写 resource_needs +用户未指定的数据源、位置、尺寸和标题采用合理默认,写入 assumptions,不作为资源需求 +design 和 metrics 不指定数据源,不规划数据绑定 +非阻塞的设计偏好可采用合理假设,写入 assumptions + +## 输出边界 +按 ScreenDesign 结构输出,不声称已执行、查询资源或读取未解析附件 +{INPUT_AS_DATA} +""" + +# 规划路径可能用到全部动作,规则一次给全 +DESIGN_SYSTEM_PROMPT = "\n\n".join( + section.strip() + for section in ( + DESIGN_TASK_PROMPT, + ACTION_CONSTRAINTS, + CANVAS_PLAN_PROMPT, + REPLACE_PLAN_PROMPT, + MODIFY_PLAN_PROMPT, + FIELD_QUERY_PROMPT, + *action_rule_sections(set(ACTION_RULES)), + ) +) diff --git a/app/prompt/planner.py b/app/prompt/planner.py deleted file mode 100644 index fa6e484..0000000 --- a/app/prompt/planner.py +++ /dev/null @@ -1,38 +0,0 @@ -"""规划职责和设计规则,输出字段由 ScreenPlan 定义""" - -from app.prompt.domain import ( - DISPOSITION_GUIDE, - EXISTING_TARGET_RULE, - INPUT_AS_DATA, - LAYOUT_RULE, -) -from app.schemas.capabilities import DATA_BINDING_GUIDE - -PLANNER_SYSTEM_PROMPT = f"""## 任务 -将已确认的需求整理为 ScreenPlan,明确区域、组件组合、指标、布局和设计要求,供后续生成动作 - -## 规划依据 -以 requirement 为目标、change_scope 为范围,结合 input_context 中的大屏和澄清记录规划 -覆盖本次全部变更,保留约束,不重新判断意图或扩大修改范围 - -## 组件与布局 -canvas_changes 只填写需求范围内要修改的画布顶层字段及最终值,不填写相对变化或设计描述 -{EXISTING_TARGET_RULE};无需改动的组件可标记 keep 或省略 -{DISPOSITION_GUIDE} -需求的 replace 对应 disposition=replace,component_type 从目录中选择用户要的新类型 -{LAYOUT_RULE};布局未确定时不提供 layout,写清排列关系 -提供 component_resources 时,新增组件从目录选择真实 type;默认宽高仅作布局参考 -组件摘要用于选型;组件的属性配置和数据绑定由后续动作阶段处理,规划阶段不需要确认 - -## 资源与假设 -resource_needs 仅用于组件目录之外的资源,如业务数据、模板、素材;不要为组件的配置、属性或数据绑定方式写 resource_needs -用户未指定的数据源、位置、尺寸和标题采用合理默认,写入 assumptions,不作为资源需求 -{DATA_BINDING_GUIDE};design 和 metrics 不指定数据源,不规划数据绑定 -空白画布上的新增组件,位置和尺寸按目录默认尺寸自行安排,避免与已有组件重叠 -不编造目录中不存在的组件类型或数据 -非阻塞的设计偏好可采用合理假设,写入 assumptions - -## 输出边界 -按 ScreenPlan 结构输出设计方案,不生成动作或声称已执行、查询资源、读取未解析附件 -{INPUT_AS_DATA} -""" diff --git a/app/resources/material_fields.py b/app/resources/material_fields.py index 2fbe8f5..6fb13a3 100644 --- a/app/resources/material_fields.py +++ b/app/resources/material_fields.py @@ -39,7 +39,8 @@ def panel_setters(groups: list[FieldGroup]) -> list[dict]: props = {} if field.options: props["options"] = [ - {"label": option.label, "value": option.value} for option in field.options + {"label": option.label, "value": option.value} + for option in field.options ] setter = { "type": PANEL_CONTROLS[field.value_type], @@ -64,7 +65,9 @@ def frontend_catalog(catalog: ComponentCatalog) -> dict: # ---- 字段查询:MCP 工具、动作提示词和动作校验共用同一套逻辑 ---- -def find_material(catalog: ComponentCatalog, component_type: str) -> ComponentDefinition: +def find_material( + catalog: ComponentCatalog, component_type: str +) -> ComponentDefinition: """按节点 type 找物料;找不到或对应多个物料时报错,不猜测""" matches = [item for item in catalog.components if item.type == component_type] if len(matches) != 1: @@ -74,6 +77,27 @@ def find_material(catalog: ComponentCatalog, component_type: str) -> ComponentDe return matches[0] +def material_payload(component: ComponentDefinition) -> dict: + """给模型看的物料配置:完整默认 schema 及使用说明,字段定义由字段查询按需提供""" + return component.model_dump(mode="json", by_alias=True, exclude={"field_groups"}) + + +def get_materials(catalog: ComponentCatalog, component_types: list[str]) -> dict: + """指定类型的物料默认配置,同一类型的多个预设全部返回""" + known = {item.type for item in catalog.components} + if unknown := [item for item in component_types if item not in known]: + raise ValueError( + f"没有对应的物料类型 {unknown},可用类型:{'、'.join(sorted(known))}" + ) + types = set(component_types) + return { + "catalog_version": catalog.version, + "materials": [ + material_payload(item) for item in catalog.components if item.type in types + ], + } + + def describe_field( component: ComponentDefinition, group: FieldGroup, field: MaterialField ) -> dict: @@ -174,7 +198,9 @@ def search_fields( } -def check_update_field(catalog: ComponentCatalog | None, node_type: str, action) -> None: +def check_update_field( + catalog: ComponentCatalog | None, node_type: str, action +) -> None: """update 只能修改字段定义中的可编辑字段,值须符合字段类型和取值约束 不合法时抛 ValueError,路径不存在时附上可能对应的字段供修正参考 diff --git a/app/schemas/screen_design.py b/app/schemas/screen_design.py new file mode 100644 index 0000000..46bc571 --- /dev/null +++ b/app/schemas/screen_design.py @@ -0,0 +1,12 @@ +"""规划与动作一次生成的输出协议,供 DesignNode 使用""" + +from pydantic import Field + +from app.schemas.actions import ActionBatch +from app.schemas.base import ExtensibleModel +from app.schemas.screen_plan import ScreenPlan + + +class ScreenDesign(ExtensibleModel): + plan: ScreenPlan = Field(description="本次设计方案,校验动作范围的依据") + batch: ActionBatch = Field(description="落实 plan 的动作,与 plan 的组件逐一对应") diff --git a/mcp_servers/material_server.py b/mcp_servers/material_server.py index 6c1f052..81d4b24 100644 --- a/mcp_servers/material_server.py +++ b/mcp_servers/material_server.py @@ -46,6 +46,20 @@ def _require_catalog() -> ComponentCatalog: return _catalog +@mcp.tool() +async def get_component_materials( + component_types: Annotated[ + list[ComponentType], + Field(description="要使用的物料类型,可一次传多个", min_length=1), + ], +) -> dict[str, Any]: + """获取物料的完整默认配置(schema)和使用说明 + + 新增组件前先查看所选类型的默认配置,只覆盖需要修改的字段,不从零编写 props + """ + return material_fields.get_materials(_require_catalog(), component_types) + + @mcp.tool() async def list_field_groups(component_type: ComponentType) -> dict[str, Any]: """列出物料的字段分组:每组管什么、包含哪些字段名称