diff --git a/app/actions/plan.py b/app/actions/plan.py index 5800fa7..c10e4ac 100644 --- a/app/actions/plan.py +++ b/app/actions/plan.py @@ -3,6 +3,9 @@ from copy import deepcopy from math import isclose +from loguru import logger +from pydantic import ValidationError + from app.processor.state import QueryState from app.schemas.action_validation import ActionValidationError from app.schemas.actions import ( @@ -44,15 +47,18 @@ def plan_block_reason(state: QueryState) -> str | None: modifications = { item.target_id: item for item in plan.components if item.disposition == "modify" } + replacements = [item for item in plan.components if item.disposition == "replace"] # 如果没有任何组件或画布变更,则没有可转换为动作的规划内容 - if not additions and not modifications and not removals and not plan.canvas_changes: + if not ( + additions or modifications or removals or replacements or plan.canvas_changes + ): return "规划未包含可执行的组件或画布修改" - # 收集目录支持的类型,拒绝规划虚构前端未注册的新增组件类型 + # 收集目录支持的类型,拒绝规划虚构前端未注册的新增或更换类型 supported = {item.type for item in resources.components} - if any(item.component_type not in supported for item in additions): - return "规划中的新增类型未在组件目录中找到" + if any(item.component_type not in supported for item in additions + replacements): + return "规划中的新增或更换类型未在组件目录中找到" # 按 ID 索引原大屏节点,检查已有目标是否存在及类型是否保持一致 nodes = {node.id: node for node in state["input_context"].screen.nodes} @@ -65,9 +71,10 @@ def plan_block_reason(state: QueryState) -> str | None: if item.target_id not in nodes: return f"规划目标不存在: {item.target_id}" - # 如果规划显式指定类型,必须与已有节点相同,不能借修改操作更换类型 + # 更换类型只能通过 replace,其余处置显式指定类型时必须与已有节点相同 if ( - item.component_type is not None + item.disposition != "replace" + and item.component_type is not None and item.component_type != nodes[item.target_id].type ): return "当前不支持更换已有组件类型" @@ -88,6 +95,16 @@ def plan_block_reason(state: QueryState) -> str | None: if removals != allowed_removals: return "规划删除目标与需求不一致,不能遗漏或扩大删除范围" + # 更换类型会先删除原组件,同样必须与需求逐个目标一致 + allowed_replacements = { + target + for change in changes + if change.target_scope == "nodes" and change.operation == "replace" + for target in change.target_ids + } + if {item.target_id for item in replacements} != allowed_replacements: + return "规划更换目标与需求不一致,不能遗漏或扩大更换范围" + # 逐项核对需求是否被规划覆盖,任一不支持的需求都会阻止整批生成 for change in changes: # 画布更新需求必须对应非空的画布变更规划 @@ -98,8 +115,11 @@ def plan_block_reason(state: QueryState) -> str | None: # 新增需求至少需要一个新增组件规划 if not additions: return "规划遗漏新增需求" - elif change.target_scope == "nodes" and change.operation == "delete": - # 删除目标已在上面通过集合相等检查,此处不重复处理 + elif change.target_scope == "nodes" and change.operation in ( + "delete", + "replace", + ): + # 删除和更换目标已在上面通过集合相等检查,此处不重复处理 continue elif ( change.target_scope in {"nodes", "screen"} @@ -134,14 +154,14 @@ def plan_block_reason(state: QueryState) -> str | None: def plan_materials(state: QueryState) -> list[dict]: """提取规划需要的完整物料配置,供动作生成和修正使用""" - # 只收集新增组件所需的类型,使用集合去除重复类型 + # 只收集新增和更换组件所需的类型,使用集合去除重复类型 types = { item.component_type for item in state["screen_plan"].components - if item.disposition == "add" + if item.disposition in ("add", "replace") } - # 如果没有新增组件,就不需要向模型提供物料配置 + # 如果没有新增或更换组件,就不需要向模型提供物料配置 if not types: return [] @@ -170,31 +190,61 @@ def merge_config(defaults: dict, overrides: dict) -> dict: def assemble_plan_actions(state: QueryState, batch: ActionBatch) -> ActionBatch: - """用物料默认配置补全新增节点,模型显式提供的字段优先""" + """用物料默认配置补全新增节点,模型显式提供的字段优先; + 更换类型的新增节点沿用原 ID 和位置,并在它之前删除原组件""" # 按物料编号建立索引,动作通过 component_key 选择具体预设 materials = {item.key: item for item in state["component_resources"].components} + # 更换规划按 plan_id 索引,原组件按 ID 索引,用于沿用原位置 + replacements = { + item.plan_id: item + for item in state["screen_plan"].components + if item.disposition == "replace" + } + replaced_ids = {item.target_id for item in replacements.values()} + nodes = {node.id: node for node in state["input_context"].screen.nodes} - # 复制整批动作,补全配置时不改动调用方提供的动作对象 - result = batch.model_copy(deep=True) - for action in result.actions: + actions = [] + for source in batch.actions: + # 原组件的删除统一由系统在对应新增之前生成,丢弃模型自行生成的删除,避免重复或顺序错误 + if isinstance(source, DeleteAction) and source.target_id in replaced_ids: + continue # 非新增动作或找不到物料时不补全,物料合法性由规划动作校验负责 - if not isinstance(action, AddAction) or action.component_key not in materials: + if not isinstance(source, AddAction) or source.component_key not in materials: + actions.append(source.model_copy(deep=True)) continue - # 获取所选物料,仅用模型显式提供的节点字段覆盖默认配置 + # 复制动作,补全配置时不改动调用方提供的动作对象 + action = source.model_copy(deep=True) material = materials[action.component_key] - node = merge_config( - material.schema_.model_dump(mode="json"), - action.node.model_dump(mode="json", exclude_unset=True), - ) + defaults = material.schema_.model_dump(mode="json") + overrides = action.node.model_dump(mode="json", exclude_unset=True) + item = replacements.get(action.plan_id) + if item is not None and item.target_id in nodes: + # 更换后仍是同一个组件:沿用原 ID,位置以规划为准,未规划时保持原位置和尺寸 + layout = item.layout or nodes[item.target_id].layout + pinned = {"id": item.target_id, "layout": layout.model_dump(mode="json")} + try: + node = ScreenNode.model_validate( + merge_config(defaults, {**overrides, **pinned}) + ) + except ValidationError: + # 迁移后的配置不合法时退回物料示例数据,保证更换后仍能正常展示 + logger.warning( + f"更换组件配置不合法,改用物料默认配置: {item.target_id}" + ) + node = ScreenNode.model_validate(merge_config(defaults, pinned)) + actions.append(DeleteAction(target_id=item.target_id)) + else: + # 对合并后的完整节点重新校验,防止默认配置与覆盖值组合后不合法 + node = ScreenNode.model_validate(merge_config(defaults, overrides)) - # 对合并后的完整节点重新校验,防止默认配置与覆盖值组合后不合法 - action.node = ScreenNode.model_validate(node) + action.node = node # 保守标记物料含示例数据,不能由模型声称已取得真实数据 action.sample_data = material.sample_data + actions.append(action) - return result + return ActionBatch(actions=actions) def _validate_canvas_action( @@ -218,7 +268,7 @@ def _validate_delete_action( """只允许删除 remove 目标,通过后记录目标以防重复删除""" # 如果目标未被规划标记为删除,拒绝该动作 if action.target_id not in planned_ids: - return f"不能删除非 remove 规划目标: {action.target_id}" + return f"不能删除非 remove 或 replace 规划目标: {action.target_id}" # 如果前面的动作已经删除该目标,拒绝重复删除 if action.target_id in removed_ids: @@ -254,11 +304,11 @@ def _validate_add_action( action: AddAction, planned: dict[str, PlanComponent], materials: dict[str, ComponentDefinition], - existing_ids: set[str], + existing: dict[str, ScreenNode], seen_plan_ids: set[str], ) -> str | None: - """检查新增规划关联、ID、物料类型、布局及数据标记""" - # 新增动作必须关联有效的新增规划编号 + """检查新增或更换规划关联、ID、物料类型、布局及数据标记""" + # 新增动作必须关联有效的新增或更换规划编号 if action.plan_id not in planned: return f"动作未关联有效的新增规划: {action.plan_id}" @@ -266,15 +316,19 @@ def _validate_add_action( if action.plan_id in seen_plan_ids: return f"同一规划组件不能重复新增: {action.plan_id}" - # 新组件不能复用原大屏已有的节点 ID - if action.node.id in existing_ids: + item = planned[action.plan_id] + if item.disposition == "replace": + # 更换后仍是同一个组件,必须沿用原 ID + if action.node.id != item.target_id: + return f"更换组件必须沿用原 ID: {item.target_id}" + elif action.node.id in existing: + # 新组件不能复用原大屏已有的节点 ID 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 ( @@ -283,8 +337,11 @@ def _validate_add_action( or action.node.type != item.component_type ): return "新增节点类型、物料和规划类型不一致" - # 如果规划已明确新增布局,动作必须完整沿用该布局 - if item.layout is not None and action.node.layout != item.layout: + # 如果规划已明确新增布局,动作必须完整沿用该布局;更换未规划布局时保持原位置 + layout = item.layout + if layout is None and item.disposition == "replace": + layout = existing[item.target_id].layout + if layout is not None and action.node.layout != layout: return f"新增布局与规划不一致: {item.plan_id}" # 示例数据标记必须与物料一致,不能将示例内容标记为真实业务数据 @@ -306,11 +363,11 @@ def validate_plan_actions( if reason := plan_block_reason(state): return [ActionValidationError(code="scope_error", message=reason)] - # 新增动作通过 plan_id 查找对应规划 + # 新增和更换动作通过 plan_id 查找对应规划,更换生成的也是新增动作 expected = { item.plan_id: item for item in state["screen_plan"].components - if item.disposition == "add" + if item.disposition in ("add", "replace") } # 修改动作通过已有组件 ID 查找对应规划 @@ -331,6 +388,12 @@ def validate_plan_actions( for item in state["screen_plan"].components if item.disposition == "remove" } + # 更换的原组件要先删除,再以同一 ID 新增 + replaced = { + item.target_id + for item in state["screen_plan"].components + if item.disposition == "replace" + } removed: set[str] = set() # 已覆盖的删除目标 canvas_fields: set[str] = set() # 已覆盖的画布字段 @@ -341,8 +404,8 @@ def validate_plan_actions( # 累计所有动作及覆盖范围错误,便于一次性反馈给修正流程 errors = [] - # 保存原有组件 ID,防止新增组件复用已有 ID - existing_ids = {node.id for node in state["input_context"].screen.nodes} + # 按 ID 索引原有组件,防止新增组件复用已有 ID,并读取更换组件的原位置 + existing = {node.id: node for node in state["input_context"].screen.nodes} # 按顺序校验每个动作,保留索引以便定位错误 for index, action in enumerate(batch.actions): if isinstance(action, UpdateCanvasAction): @@ -352,12 +415,10 @@ def validate_plan_actions( ) elif isinstance(action, DeleteAction): # 检查删除目标是否允许、是否重复,并记录已删除目标 - message = _validate_delete_action(action, removals, removed) + message = _validate_delete_action(action, removals | replaced, removed) elif isinstance(action, AddAction): # 核对新增规划、ID、物料和布局,并记录已关联的规划编号 - message = _validate_add_action( - action, expected, materials, existing_ids, seen - ) + message = _validate_add_action(action, expected, materials, existing, seen) elif isinstance(action, (MoveAction, ResizeAction, UpdateAction)): # 检查目标是否允许修改,更新属性时还需有设计要求 message = _validate_modify_action(action, modifications, modified) @@ -377,6 +438,7 @@ def validate_plan_actions( ("新增规划组件", set(expected) - seen), ("调整规划组件", set(modifications) - modified), ("删除规划组件", removals - removed), + ("更换前删除的组件", replaced - removed), ("画布规划字段", set(planned_canvas) - canvas_fields), ): if missing: diff --git a/app/processor/nodes/planner_node.py b/app/processor/nodes/planner_node.py index feb3e1d..21f2824 100644 --- a/app/processor/nodes/planner_node.py +++ b/app/processor/nodes/planner_node.py @@ -109,9 +109,9 @@ class PlannerNode(BaseNode): # 如果新增组件没有指定类型,无法选择对应物料,立即报错 if component.disposition == "add" and component.component_type is None: raise ValueError(f"新增规划组件未指定物料类型: {component.plan_id}") - # 新增或修改组件时,只要明确指定了类型,就必须在目录中存在 + # 新增、修改或更换组件时,只要明确指定了类型,就必须在目录中存在 if ( - component.disposition in ("add", "modify") + component.disposition in ("add", "modify", "replace") and component.component_type is not None and component.component_type not in supported_types ): @@ -145,7 +145,7 @@ class PlannerNode(BaseNode): changed_ids = { component.target_id for component in plan.components - if component.disposition in ("modify", "remove") + if component.disposition in ("modify", "remove", "replace") } # 如果实际修改或删除的目标不在允许范围内,则拒绝扩大修改范围 if outside := changed_ids - set(scope.target_ids): diff --git a/app/prompt/action.py b/app/prompt/action.py index 3243f6f..57292b9 100644 --- a/app/prompt/action.py +++ b/app/prompt/action.py @@ -37,6 +37,15 @@ layout 表示最终布局,依据当前快照计算位移和尺寸 design 表示属性设计要求,转换为 props/style 更新;只有布局要求时不修改属性 """ +REPLACE_PLAN_PROMPT = """## 更换组件类型 +每个 replace 规划生成一个 add:plan_id 用该规划编号,node.id 沿用原组件的 target_id,component_key 选择 component_type 对应的物料 +系统会在它之前删除原组件并保持原位置和尺寸,不要为它生成 delete、move 或 resize +把原组件快照中的展示数据迁移到新物料的配置结构;原标题只是旧类型名称(如“折线图”)时改为新类型名称(如“饼图”),不用物料示例标题,用户自定义的标题保留:保留原数据的维度和数值,按新物料默认配置的写法改写字段映射, +例如原 encode 的 x/y 改为新图表需要的名称和数值映射;原类型特有而新物料不用的配置(如坐标轴)不要带过去 +series 等数组会整体替换,提供时以物料默认配置中的对应项为基础补全 +原数据无法对应到新类型时,不提供数据相关字段,沿用物料示例数据 +""" + REPAIR_TASK_PROMPT = """# 当前阶段:修正重试 结合 previous_actions、validation_errors 和 repair_plan,修正并重新生成完整动作列表 仍从 input_context.screen 原始快照开始,不在失败动作或部分成功结果上追加执行 @@ -68,6 +77,9 @@ def build_action_prompt( action_types.add("update_canvas") if any(item.disposition == "add" for item in screen_plan.components): action_types.add("add") + if any(item.disposition == "replace" for item in screen_plan.components): + sections.append(REPLACE_PLAN_PROMPT) + action_types.add("add") if any(item.disposition == "remove" for item in screen_plan.components): action_types.add("delete") modifications = [ diff --git a/app/prompt/action_rules.py b/app/prompt/action_rules.py index 94e91b2..b60436b 100644 --- a/app/prompt/action_rules.py +++ b/app/prompt/action_rules.py @@ -24,11 +24,12 @@ properties 只包含需要修改的画布顶层字段,宽高为最终绝对值 依据快照选择 props / style 下的字段路径,不猜测不存在的嵌套结构 path 中字符串是对象键,整数是数组下标;中间路径必须存在,数组下标不能越界 只替换指定字段,不做深度合并;null 表示赋值,不表示删除 +不通过修改属性改变组件类型(如把 series 的 type 改成其他图表类型),组件类型由节点 type 和物料决定 style 为 null 时可用单层路径添加属性,保留未要求修改的配置 """, "add": """## add:按物料新增组件 每个 add 规划对应一个新增动作,用 plan_id 关联规划,component_key 选择 component_materials 中的物料 -node.id 使用不与已有节点冲突的新 ID,node.type 必须与物料和规划一致 +node.id 使用不与已有节点冲突的新 ID(更换类型时沿用原 ID),node.type 必须与物料和规划一致 component_materials 提供物料默认 schema,依据它和设计要求生成 node 后端会用物料默认配置补全 node:对象逐层覆盖,数组整体替换,null 明确覆盖 物料示例数据仅作占位展示,sample_data 由后端依据物料设置,不代表已获取真实数据 diff --git a/app/prompt/planner.py b/app/prompt/planner.py index 1c5d0f9..d5b5cdf 100644 --- a/app/prompt/planner.py +++ b/app/prompt/planner.py @@ -13,6 +13,8 @@ PLANNER_SYSTEM_PROMPT = f"""## 任务 canvas_changes 只填写需求范围内要修改的画布顶层字段及最终值,不填写相对变化或设计描述 已有组件使用真实 target_id;无需改动的组件可保留或省略,省略不代表删除 remove 仅用于需求明确指定的删除目标,不能因为重新布局而擅自移除组件 +需求的 replace 对应 disposition=replace:target_id 为原组件,component_type 从目录中选择用户要的新类型,原位置和尺寸由系统保留 +modify 不能更换组件类型 布局未确定时写清排列关系;提供 layout 时表示最终的画布绝对坐标和尺寸,单位 px 提供 component_resources 时,新增组件从目录选择真实 type;默认宽高仅作布局参考 组件摘要用于选型;组件的属性配置和数据绑定由后续动作阶段处理,规划阶段不需要确认 diff --git a/app/schemas/operations.py b/app/schemas/operations.py index f00bff0..d4a3c0a 100644 --- a/app/schemas/operations.py +++ b/app/schemas/operations.py @@ -1,7 +1,11 @@ """需求操作名词表的单一来源,需求协议、提示词、能力检查和范围校验都引用这里""" # 模型可能使用的同义操作名,统一成词表中的标准名称;新增别名只改这里 -OPERATION_ALIASES: dict[str, str] = {"restyle": "update"} +OPERATION_ALIASES: dict[str, str] = { + "restyle": "update", + "change_type": "replace", + "convert": "replace", +} # 直接修改已有组件支持的操作,每一项都要有对应的动作规则 NODE_OPERATIONS: tuple[str, ...] = ("move", "resize", "update", "delete") @@ -18,13 +22,15 @@ PLAN_ADJUST_OPERATIONS: tuple[str, ...] = ( "layout", ) -# 只能通过规划完成的操作:新增要从物料目录选型并安排位置,重排要整体布局 -PLAN_ONLY_OPERATIONS: tuple[str, ...] = ("add", "reflow", "layout") +# 只能通过规划完成的操作:新增要从物料目录选型并安排位置,重排要整体布局, +# 更换组件类型实际是换成另一种物料,要从目录选型,不能靠修改属性完成 +PLAN_ONLY_OPERATIONS: tuple[str, ...] = ("add", "reflow", "layout", "replace") # 写进协议描述和提示词的操作说明,词表变化时自动同步 OPERATION_GUIDE = ( f"修改已有组件用 {'、'.join(NODE_OPERATIONS)}," "修改画布用 update 或 resize,新增用 add;" + "更换已有组件的类型(如折线图改饼图)用 replace,目标类型写入 parameters,不用 update;" "颜色、背景、文字等样式和属性修改统一用 update,具体内容写入 parameters" ) diff --git a/app/schemas/screen_plan.py b/app/schemas/screen_plan.py index 14d1b17..8bb4743 100644 --- a/app/schemas/screen_plan.py +++ b/app/schemas/screen_plan.py @@ -24,8 +24,9 @@ class PlanComponent(ExtensibleModel): plan_id: NonBlankString = Field( description="规划内的组件编号,用于关联资源,不作为新增组件的实际 ID" ) - disposition: Literal["keep", "modify", "add", "remove"] = Field( - description="保留、调整、新增或移除,只表达规划意图,不代表执行能力已经支持" + disposition: Literal["keep", "modify", "add", "remove", "replace"] = Field( + description="保留、调整、新增、移除或更换类型,只表达规划意图,不代表执行能力已经支持;" + "replace 表示把 target_id 换成 component_type 对应的物料,保留原位置" ) target_id: NonBlankString | None = Field( default=None, description="已有组件 ID,新增时必须为空" @@ -53,7 +54,9 @@ class PlanComponent(ExtensibleModel): if self.target_id is not None: raise ValueError("新增组件不能指定已有 target_id") elif self.target_id is None: - raise ValueError("保留、调整或移除组件必须提供 target_id") + raise ValueError("保留、调整、移除或更换组件必须提供 target_id") + if self.disposition == "replace" and self.component_type is None: + raise ValueError("更换组件必须指定新的 component_type") return self