"""整批模拟执行:新增组件补全物料默认配置,再按顺序在大屏副本上执行,遇到第一个错误即停止 纯函数,不依赖 agent;agent 的 apply_actions 工具和测试共用 """ from copy import deepcopy from app.actions.interaction import interaction_problems from app.actions.layout import EXPANDERS from app.actions.registry import apply_action from app.actions.update import fit_existing_path, same_value from app.resources.material_fields import ( check_add_fields, check_update_field, find_material, retained_examples, ) from app.schemas.actions import Action, AddAction, AgentAction, UpdateAction from app.schemas.capabilities import DATA_BINDING_ERROR, DATA_BINDING_SUPPORTED from app.schemas.component_resource import ComponentCatalog from app.schemas.screen import Screen, ScreenNode class BatchError(ValueError): """整批中第 index 个动作(从 0 开始)失败,消息里带上序号和目标,模型据此修正 index 和 action 为 None 时是整批执行完后的检查失败(变量与交互的引用),不对应某一个动作 """ def __init__(self, index: int | None, action: AgentAction | None, reason: str): self.index = index self.reason = reason if action is None: super().__init__(f"整批执行后检查:{reason}") return if isinstance(action, AddAction): target = action.node.id elif ids := getattr(action, "target_ids", None): target = "、".join(ids) else: target = getattr(action, "target_id", "画布") super().__init__(f"第 {index + 1} 个动作({action.type} {target}):{reason}") 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 complete_add(action: AddAction, catalog: ComponentCatalog) -> AddAction: """按 node.type 找物料补全新增组件,模型显式写出的字段优先 显式写的字段先按字段定义检查,和 update 是同一套规则;sample_data 保持模型的声明, 交付时再由 mark_sample_data 按组件上是否还有示例数据补上 """ material = find_material(catalog, action.node.type) if not DATA_BINDING_SUPPORTED and action.node.model_extra.get("dataId"): raise ValueError(DATA_BINDING_ERROR) overrides = action.node.model_dump(mode="json", exclude_unset=True) check_add_fields(catalog, material.type, overrides) node = ScreenNode.model_validate(merge_config(material.template(), overrides)) return action.model_copy(update={"node": node}) def mark_sample_data( actions: list[Action], screen: Screen, catalog: ComponentCatalog | None ) -> list[Action]: """交付前给新增组件定 sample_data:模型声明了自己编的数据,或者组件上还留着物料的示例数据 按交付后的大屏判断,不管数据是新增时写的、之后用 update 换的,还是 $query 查来的; 后来被删掉的组件保持模型的声明。只改动作上的标记,交付记录依次执行仍得到同一个大屏 """ nodes = {node.id: node for node in screen.nodes} result = [] for action in actions: if isinstance(action, AddAction) and not action.sample_data: node = nodes.get(action.node.id) if node is not None and any( field.example == "data" for field, _ in retained_examples(catalog, node.model_dump(mode="json")) ): action = action.model_copy(update={"sample_data": True}) result.append(action) return result def misplaced_props(actions: list[AgentAction], catalog: ComponentCatalog) -> list[str]: """新增组件的 props 里物料没有的键:前端按物料的结构读取,多出来的键不会生效,多半是写错了层级 评测里模型把 series 写在 props 下而不是 props.option 下,物料默认的 series 留着,图表引用的列对不上; 它看到的报错说 encode 用了别的列,和自己写的不一样,反复原样提交。返回给模型的说明,每项一个组件 """ notes = [] for action in actions: if not isinstance(action, AddAction): continue try: known = ( find_material(catalog, action.node.type).template().get("props") or {} ) except ValueError: # 物料不存在,补全时会报错 continue written = ( action.node.model_dump(mode="json", exclude_unset=True).get("props") or {} ) for key in sorted(set(written) - set(known)): parent = next( ( name for name, value in known.items() if isinstance(value, dict) and key in value ), None, ) where = f"(物料里在 props.{parent}.{key})" if parent else "" notes.append(f"{action.node.id} 的 props.{key}{where}") return notes def run_batch( screen: Screen, catalog: ComponentCatalog, actions: list[AgentAction] ) -> tuple[list[Action], Screen]: """从传入的大屏开始按顺序模拟执行,返回前端要执行的动作和执行后的大屏;第一个错误抛 BatchError 布局意图(arrange、align、distribute、set_layout)在这里按当时的大屏展开成 move 和 resize update 写入的值和字段现在的值完全一样时没有变化,跳过:不经过字段契约、也不交付; 模型常把已经正确的值再写一遍(例如已经是 category 的轴类型),字段不在契约里时整批都会失败 全部执行完再检查一次变量与交互的引用(interaction_problems):一批里常常先加组件后声明变量; 只拦这一批新出现的问题,大屏原有的问题(用户在编辑器里保存的)不挡住无关的修改 """ current = screen.model_copy(deep=True) completed: list[Action] = [] for index, action in enumerate(actions): try: if expander := EXPANDERS.get(action.type): expanded = expander(current, action) for primitive in expanded: current = apply_action(current, primitive) completed.extend(expanded) continue if isinstance(action, AddAction): action = complete_add(action, catalog) elif isinstance(action, UpdateAction): # 目标不存在时交给执行器报错 node = next( (item for item in current.nodes if item.id == action.target_id), None, ) if node is not None: if same_value(node, action): continue check_update_field(catalog, node.type, action) # 字段定义允许、但组件中还没有的中间对象,折叠进值里再执行 action = fit_existing_path(node, action) current = apply_action(current, action) except ValueError as error: # 含 pydantic ValidationError raise BatchError(index, action, str(error)) from error completed.append(action) # 按问题的 key 比较:措辞里的列清单这类上下文变了,原有的问题仍是原有的 existing = {item.key for item in interaction_problems(screen, catalog)} if problems := [ item.message for item in interaction_problems(current, catalog) if item.key not in existing ]: # 这一批整个没写入:缺的部分要和原来的动作一起重新提交,分到下一批时这一批永远通不过 raise BatchError( None, None, ";".join(problems) + "。这一批没有写入:把补上的部分和原来的动作放在同一批里重新提交", ) return completed, current