"""将物料默认配置补入新增动作,并检查动作是否符合规划,不执行画布更新""" from copy import deepcopy from math import isclose from app.processor.state import QueryState from app.schemas.action_validation import ActionValidationError from app.schemas.actions import ( ActionBatch, AddAction, DeleteAction, MoveAction, ResizeAction, UpdateAction, UpdateCanvasAction, ) from app.schemas.capabilities import DATA_BINDING_ERROR, DATA_BINDING_SUPPORTED from app.schemas.component_resource import ComponentDefinition from app.schemas.operations import PLAN_ADJUST_OPERATIONS, is_canvas_operation from app.schemas.screen import Screen, ScreenNode from app.schemas.screen_plan import PlanComponent def plan_block_reason(state: QueryState) -> str | None: """检查规划是否在当前支持范围内,不支持时返回原因""" # 获取当前规划和组件目录,后续分别检查操作范围与新增物料能力 plan = state["screen_plan"] resources = state["component_resources"] # 如果需求尚未明确,不能根据规划直接生成动作 if state["requirement"].needs_clarification: return "需求尚未澄清,不能生成规划动作" # 如果规划仍依赖待获取的资源,先阻止动作生成 if plan.resource_needs: return "规划仍有待获取资源,不能直接生成动作" # 分别收集新增、删除和修改目标,保留组件不算可执行变更 additions = [item for item in plan.components if item.disposition == "add"] removals = { item.target_id for item in plan.components if item.disposition == "remove" } modifications = { item.target_id: item for item in plan.components if item.disposition == "modify" } # 如果没有任何组件或画布变更,则没有可转换为动作的规划内容 if not additions and not modifications and not removals and not 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 "规划中的新增类型未在组件目录中找到" # 按 ID 索引原大屏节点,检查已有目标是否存在及类型是否保持一致 nodes = {node.id: node for node in state["input_context"].screen.nodes} for item in plan.components: # 新增组件没有已有目标,不参与下面的已有节点检查 if item.disposition == "add": continue # 保留、修改和删除都必须指向真实存在的节点 if item.target_id not in nodes: return f"规划目标不存在: {item.target_id}" # 如果规划显式指定类型,必须与已有节点相同,不能借修改操作更换类型 if ( item.component_type is not None and item.component_type != nodes[item.target_id].type ): return "当前不支持更换已有组件类型" # 已有目标的修改诉求不能通过 keep 或遗漏目标绕过 supported_operations = set(PLAN_ADJUST_OPERATIONS) changes = state["requirement"].changes # 删除必须逐个目标获得明确授权,整体布局调整不隐含删除权限 allowed_removals = { target for change in changes 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 is_canvas_operation(change.target_scope, change.operation): 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": # 删除目标已在上面通过集合相等检查,此处不重复处理 continue elif ( change.target_scope in {"nodes", "screen"} and change.operation in supported_operations ): # 调整需求必须有修改规划,并覆盖需求中明确指定的全部目标 if not modifications or any( target not in modifications for target in change.target_ids ): return "规划遗漏需要调整的组件" else: return "需求包含未支持的操作,当前不能只执行部分规划" # 没有整体调整需求时,已有组件的修改目标只能来自需求明确列出的 ID if ( modifications and changes and not any( change.target_scope == "screen" and change.operation in supported_operations for change in changes ) ): # 汇总需求目标,检查规划是否额外修改了其他组件 allowed = {target for change in changes for target in change.target_ids} if set(modifications) - allowed: return "规划调整了需求范围外的组件" # 所有能力和范围检查通过,None 表示没有阻塞原因 return None def plan_materials(state: QueryState) -> list[dict]: """提取规划需要的完整物料配置,供动作生成和修正使用""" # 只收集新增组件所需的类型,使用集合去除重复类型 types = { item.component_type for item in state["screen_plan"].components if item.disposition == "add" } # 如果没有新增组件,就不需要向模型提供物料配置 if not types: return [] # 按类型提取全部匹配物料,使用协议别名输出 schema 等字段 return [ item.model_dump(mode="json", by_alias=True) for item in state["component_resources"].components if item.type in types ] def merge_config(defaults: dict, overrides: dict) -> dict: # 对象逐层覆盖,数组和标量整体替换,不修改目录或模型输出 result = deepcopy(defaults) # 逐项合并覆盖值,双方都是字典时递归,否则用新值的深拷贝整体替换 for key, value in overrides.items(): result[key] = ( merge_config(result[key], value) if isinstance(result.get(key), dict) and isinstance(value, dict) else deepcopy(value) ) # 返回独立配置,保留未被覆盖的默认字段 return result def assemble_plan_actions(state: QueryState, batch: ActionBatch) -> ActionBatch: """用物料默认配置补全新增节点,模型显式提供的字段优先""" # 按物料编号建立索引,动作通过 component_key 选择具体预设 materials = {item.key: item for item in state["component_resources"].components} # 复制整批动作,补全配置时不改动调用方提供的动作对象 result = batch.model_copy(deep=True) for action in result.actions: # 非新增动作或找不到物料时不补全,物料合法性由规划动作校验负责 if not isinstance(action, AddAction) or action.component_key not in materials: continue # 获取所选物料,仅用模型显式提供的节点字段覆盖默认配置 material = materials[action.component_key] node = merge_config( material.schema_.model_dump(mode="json"), action.node.model_dump(mode="json", exclude_unset=True), ) # 对合并后的完整节点重新校验,防止默认配置与覆盖值组合后不合法 action.node = ScreenNode.model_validate(node) # 保守标记物料含示例数据,不能由模型声称已取得真实数据 action.sample_data = material.sample_data 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 目标,属性更新还需规划中有设计要求""" # 修改目标必须在 modify 规划中,不能修改 keep 或未提及的组件 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}" # 新组件不能复用原大屏已有的节点 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 "示例数据标记与物料不一致" # 当前没有数据绑定能力,拒绝新增节点携带非空的扩展 dataId if not DATA_BINDING_SUPPORTED and action.node.model_extra.get("dataId"): return DATA_BINDING_ERROR 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 索引,用于核对新增组件的类型和示例数据标记 materials = {item.key: item for item in state["component_resources"].components} 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[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): 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: # 未接入规划校验的动作返回错误,避免静默放行 message = "规划调整仅支持 move、resize 和 update" if message: # 将错误与动作索引关联,继续收集其余动作的问题 errors.append( ActionValidationError( action_index=index, code="scope_error", message=message ) ) # 逐条合法还不够,整批动作必须覆盖全部规划目标 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)}" ) ) # 返回全部校验问题,空列表表示动作范围和覆盖检查通过 return errors def validate_plan_result( state: QueryState, screen: Screen ) -> list[ActionValidationError]: """检查整批执行后的组件布局和画布属性是否达到规划要求""" # 按节点 ID 索引模拟执行结果,便于读取每个修改目标的最终布局 nodes = {node.id: node for node in screen.nodes} # 只核对明确提供布局的修改规划,收集最终布局未达到要求的目标 errors = [ ActionValidationError( code="scope_error", message=f"执行后的布局与规划不一致: {item.target_id}" ) for item in state["screen_plan"].components if item.disposition == "modify" and item.layout is not None and not all( # 坐标和尺寸允许极小的浮点误差,避免加减计算造成误报 isclose( getattr(nodes[item.target_id].layout, key), value, rel_tol=0, abs_tol=1e-9, ) if key in {"x", "y", "width", "height"} # 其他布局字段按值精确比较,不使用数值容差 else nodes[item.target_id].layout.model_dump().get(key) == value 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