diff --git a/app/actions/plan.py b/app/actions/plan.py index 05dd171..e6b1ef7 100644 --- a/app/actions/plan.py +++ b/app/actions/plan.py @@ -12,8 +12,11 @@ from app.schemas.actions import ( MoveAction, ResizeAction, UpdateAction, + UpdateCanvasAction, ) +from app.schemas.component_resource import ComponentDefinition from app.schemas.screen import Screen, ScreenNode +from app.schemas.screen_plan import PlanComponent def plan_block_reason(state: QueryState) -> str | None: @@ -22,8 +25,6 @@ def plan_block_reason(state: QueryState) -> str | None: resources = state.get("component_resources") if state["requirement"].needs_clarification: return "需求尚未澄清,不能生成规划动作" - if plan.canvas_changes: - return "规划中的画布修改尚未接入,当前仅支持无需规划的画布修改" if plan.resource_needs: return "规划仍有待获取资源,不能直接生成动作" additions = [item for item in plan.components if item.disposition == "add"] @@ -33,8 +34,8 @@ def plan_block_reason(state: QueryState) -> str | None: modifications = { item.target_id: item for item in plan.components if item.disposition == "modify" } - if not additions and not modifications and not removals: - return "规划未包含可执行的新增、调整或删除组件" + if not additions and not modifications and not removals and not plan.canvas_changes: + return "规划未包含可执行的组件或画布修改" if additions and (resources is None or resources.status != "resolved"): return "组件目录尚未就绪" supported = {item.type for item in resources.components} if resources else set() @@ -54,6 +55,7 @@ def plan_block_reason(state: QueryState) -> str | None: # 已有目标的修改诉求不能通过 keep 或遗漏目标绕过 supported_operations = {"move", "resize", "update", "restyle", "reflow", "layout"} changes = state["requirement"].changes + # 删除必须逐个目标获得明确授权,整体布局调整不隐含删除权限 allowed_removals = { target @@ -61,10 +63,19 @@ def plan_block_reason(state: QueryState) -> str | None: if change.target_scope == "nodes" and change.operation == "delete" for target in change.target_ids } + if removals != allowed_removals: return "规划删除目标与需求不一致,不能遗漏或扩大删除范围" + for change in changes: - if change.target_scope == "screen" and change.operation == "add": + if change.target_scope == "canvas" and change.operation in { + "update", + "resize", + "update_canvas", + }: + if not plan.canvas_changes: + return "规划遗漏画布修改需求" + elif change.target_scope == "screen" and change.operation == "add": if not additions: return "规划遗漏新增需求" elif change.target_scope == "nodes" and change.operation == "delete": @@ -79,6 +90,7 @@ def plan_block_reason(state: QueryState) -> str | None: return "规划遗漏需要调整的组件" else: return "需求包含未支持的操作,当前不能只执行部分规划" + if ( modifications and changes @@ -140,111 +152,167 @@ def assemble_plan_actions(state: QueryState, batch: ActionBatch) -> ActionBatch: return result +def _validate_canvas_action( + action: UpdateCanvasAction, planned_fields: set[str], covered_fields: set[str] +) -> str | None: + """检查画布字段范围,通过后记录已覆盖字段,最终值在执行后检查""" + if extra := set(action.properties) - planned_fields: + return f"不能修改规划外的画布字段: {sorted(extra)}" + covered_fields.update(action.properties) + return None + + +def _validate_delete_action( + action: DeleteAction, planned_ids: set[str], removed_ids: set[str] +) -> str | None: + """只允许删除 remove 目标,通过后记录目标以防重复删除""" + if action.target_id not in planned_ids: + return f"不能删除非 remove 规划目标: {action.target_id}" + if action.target_id in removed_ids: + return f"不能重复删除组件: {action.target_id}" + removed_ids.add(action.target_id) + return None + + +def _validate_modify_action( + action: MoveAction | ResizeAction | UpdateAction, + planned: dict[str, PlanComponent], + modified_ids: set[str], +) -> str | None: + """只允许调整 modify 目标,属性更新还需规划中有设计要求""" + if action.target_id not in planned: + return f"不能修改保留或未提及的组件: {action.target_id}" + if isinstance(action, UpdateAction) and not planned[action.target_id].design: + return f"规划未要求修改属性: {action.target_id}" + modified_ids.add(action.target_id) + return None + + +def _validate_add_action( + action: AddAction, + planned: dict[str, PlanComponent], + materials: dict[str, ComponentDefinition], + existing_ids: set[str], + seen_plan_ids: set[str], +) -> str | None: + """检查新增规划关联、ID、物料类型、布局及数据标记""" + if action.plan_id not in planned: + return f"动作未关联有效的新增规划: {action.plan_id}" + if action.plan_id in seen_plan_ids: + return f"同一规划组件不能重复新增: {action.plan_id}" + if action.node.id in existing_ids: + return f"新增组件不能复用原有 ID: {action.node.id}" + + # 关联有效即记录出现,后续配置错误单独报告,不再重复报遗漏 + seen_plan_ids.add(action.plan_id) + item = planned[action.plan_id] + material = materials.get(action.component_key) + if ( + material is None + or material.type != item.component_type + or action.node.type != item.component_type + ): + return "新增节点类型、物料和规划类型不一致" + if item.layout is not None and action.node.layout != item.layout: + return f"新增布局与规划不一致: {item.plan_id}" + if action.sample_data != material.sample_data: + return "示例数据标记与物料不一致" + if action.node.model_extra.get("dataId"): + return "当前尚未接入数据绑定,不能生成 dataId" + return None + + def validate_plan_actions( state: QueryState, batch: ActionBatch ) -> list[ActionValidationError]: """检查动作是否覆盖规划,禁止修改 keep 或未提及的组件""" + # 先检查规划是否可执行,不支持时直接返回原因 if reason := plan_block_reason(state): return [ActionValidationError(code="scope_error", message=reason)] + # 新增动作通过 plan_id 查找对应规划 expected = { item.plan_id: item for item in state["screen_plan"].components if item.disposition == "add" } + # 修改动作通过已有组件 ID 查找对应规划 modifications = { item.target_id: item for item in state["screen_plan"].components if item.disposition == "modify" } + # 物料按 key 索引,用于核对新增组件的类型和示例数据标记 resources = state.get("component_resources") materials = {item.key: item for item in resources.components} if resources else {} - seen = set() - modified = set() + seen: set[str] = set() # 已关联的新增规划编号 + modified: set[str] = set() # 已覆盖的修改目标 + # 只有规划标为 remove 的组件才允许删除 removals = { item.target_id for item in state["screen_plan"].components if item.disposition == "remove" } - removed = set() + removed: set[str] = set() # 已覆盖的删除目标 + canvas_fields: set[str] = set() # 已覆盖的画布字段 + planned_canvas = state[ + "screen_plan" + ].canvas_changes # 规划要求修改的画布字段及最终值 errors = [] + + # 保存原有组件 ID,防止新增组件复用已有 ID + existing_ids = {node.id for node in state["input_context"].screen.nodes} + # 按顺序校验每个动作,保留索引以便定位错误 for index, action in enumerate(batch.actions): - message = None - if isinstance(action, DeleteAction): - if action.target_id not in removals: - message = f"不能删除非 remove 规划目标: {action.target_id}" - elif action.target_id in removed: - message = f"不能重复删除组件: {action.target_id}" - else: - removed.add(action.target_id) - elif not isinstance(action, AddAction): - if not isinstance(action, (MoveAction, ResizeAction, UpdateAction)): - message = "规划调整仅支持 move、resize 和 update" - elif action.target_id not in modifications: - message = f"不能修改保留或未提及的组件: {action.target_id}" - elif ( - isinstance(action, UpdateAction) - and not modifications[action.target_id].design - ): - message = f"规划未要求修改属性: {action.target_id}" - else: - modified.add(action.target_id) - elif action.plan_id not in expected: - message = f"动作未关联有效的新增规划: {action.plan_id}" - elif action.plan_id in seen: - message = f"同一规划组件不能重复新增: {action.plan_id}" - elif action.node.id in { - node.id for node in state["input_context"].screen.nodes - }: - message = f"新增组件不能复用原有 ID: {action.node.id}" + if isinstance(action, UpdateCanvasAction): + # 检查画布字段是否超出规划,并记录已覆盖字段 + message = _validate_canvas_action( + action, set(planned_canvas), canvas_fields + ) + elif isinstance(action, DeleteAction): + # 检查删除目标是否允许、是否重复,并记录已删除目标 + message = _validate_delete_action(action, removals, removed) + elif isinstance(action, AddAction): + # 核对新增规划、ID、物料和布局,并记录已关联的规划编号 + message = _validate_add_action( + action, expected, materials, existing_ids, seen + ) + elif isinstance(action, (MoveAction, ResizeAction, UpdateAction)): + # 检查目标是否允许修改,更新属性时还需有设计要求 + message = _validate_modify_action(action, modifications, modified) else: - seen.add(action.plan_id) - item = expected[action.plan_id] - material = materials.get(action.component_key) - if ( - material is None - or material.type != item.component_type - or action.node.type != item.component_type - ): - message = "新增节点类型、物料和规划类型不一致" - elif item.layout is not None and action.node.layout != item.layout: - message = f"新增布局与规划不一致: {item.plan_id}" - elif action.sample_data != material.sample_data: - message = "示例数据标记与物料不一致" - elif action.node.model_extra.get("dataId"): - message = "当前尚未接入数据绑定,不能生成 dataId" + # 未接入规划校验的动作返回错误,避免静默放行 + message = "规划调整仅支持 move、resize 和 update" if message: + # 将错误与动作索引关联,继续收集其余动作的问题 errors.append( ActionValidationError( action_index=index, code="scope_error", message=message ) ) - if missing := set(expected) - seen: - errors.append( - ActionValidationError( - code="scope_error", message=f"遗漏新增规划组件: {sorted(missing)}" + + # 逐条合法还不够,整批动作必须覆盖全部规划目标 + for label, missing in ( + ("新增规划组件", set(expected) - seen), + ("调整规划组件", set(modifications) - modified), + ("删除规划组件", removals - removed), + ("画布规划字段", set(planned_canvas) - canvas_fields), + ): + if missing: + # 遗漏属于整批动作的问题,不关联某一条动作索引 + errors.append( + ActionValidationError( + code="scope_error", message=f"遗漏{label}: {sorted(missing)}" + ) ) - ) - if missing := set(modifications) - modified: - errors.append( - ActionValidationError( - code="scope_error", message=f"遗漏调整规划组件: {sorted(missing)}" - ) - ) - if missing := removals - removed: - errors.append( - ActionValidationError( - code="scope_error", message=f"遗漏删除规划组件: {sorted(missing)}" - ) - ) return errors def validate_plan_result( state: QueryState, screen: Screen ) -> list[ActionValidationError]: - """整批执行后检查已有组件的最终布局,避免动作合法但没有达到规划位置""" + """检查整批执行后的组件布局和画布属性是否达到规划要求""" nodes = {node.id: node for node in screen.nodes} - return [ + errors = [ ActionValidationError( code="scope_error", message=f"执行后的布局与规划不一致: {item.target_id}" ) @@ -263,3 +331,12 @@ def validate_plan_result( for key, value in item.layout.model_dump().items() ) ] + canvas = screen.canvas.model_dump() + for key, value in state["screen_plan"].canvas_changes.items(): + if key not in canvas or canvas[key] != value: + errors.append( + ActionValidationError( + code="scope_error", message=f"执行后的画布属性与规划不一致: {key}" + ) + ) + return errors diff --git a/app/processor/graph.py b/app/processor/graph.py index 6520c21..ac0f756 100644 --- a/app/processor/graph.py +++ b/app/processor/graph.py @@ -194,6 +194,7 @@ def build_graph(checkpointer=None): ] ) checkpointer = InMemorySaver(serde=serde) + return workflow.compile(checkpointer=checkpointer).with_config( { "recursion_limit": max(25, 12 + 3 * max_retries), diff --git a/app/prompt/action.py b/app/prompt/action.py index df168fc..0080abb 100644 --- a/app/prompt/action.py +++ b/app/prompt/action.py @@ -59,6 +59,11 @@ def build_action_prompt( else: sections.append(PLAN_TASK_PROMPT) action_types = set() + if screen_plan.canvas_changes: + sections.append( + "## 调整画布\n将 canvas_changes 中的最终属性值转换为 update_canvas,完整覆盖且不添加其他画布字段" + ) + action_types.add("update_canvas") if any(item.disposition == "add" for item in screen_plan.components): action_types.add("add") if any(item.disposition == "remove" for item in screen_plan.components): diff --git a/app/prompt/planner.py b/app/prompt/planner.py index 14a6f26..a8d8266 100644 --- a/app/prompt/planner.py +++ b/app/prompt/planner.py @@ -8,6 +8,7 @@ PLANNER_SYSTEM_PROMPT = """## 任务 覆盖本次全部变更,保留约束,不重新判断意图或扩大修改范围 ## 组件与布局 +canvas_changes 只填写需求范围内要修改的画布顶层字段及最终值,不填写相对变化或设计描述 已有组件使用真实 target_id;无需改动的组件可保留或省略,省略不代表删除 remove 仅用于需求明确指定的删除目标,不能因为重新布局而擅自移除组件 布局未确定时写清排列关系;提供 layout 时表示最终的画布绝对坐标和尺寸,单位 px diff --git a/app/schemas/screen_plan.py b/app/schemas/screen_plan.py index 8c857e6..14d1b17 100644 --- a/app/schemas/screen_plan.py +++ b/app/schemas/screen_plan.py @@ -78,7 +78,8 @@ class ScreenPlan(ExtensibleModel): description="区域关系、布局安排及保留内容" ) canvas_changes: JsonObject = Field( - default_factory=dict, description="建议的画布属性变化,为空表示不改画布" + default_factory=dict, + description="要修改的画布顶层字段及最终值,宽高为绝对值,字段整体替换,为空表示不改画布", ) regions: list[PlanRegion] = Field(default_factory=list) components: list[PlanComponent] = Field(default_factory=list)