feat:架构重构
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
"""整批模拟执行:新增组件补全物料默认配置,再按顺序在大屏副本上执行,遇到第一个错误即停止
|
||||
|
||||
纯函数,不依赖 agent;agent 的 apply_actions 工具和测试共用
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from app.actions.registry import apply_action
|
||||
from app.actions.update import fit_existing_path
|
||||
from app.resources.material_fields import check_update_field
|
||||
from app.schemas.actions import Action, AddAction, 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 开始)失败,消息里带上序号和目标,模型据此修正"""
|
||||
|
||||
def __init__(self, index: int, action: Action, reason: str):
|
||||
self.index = index
|
||||
self.reason = reason
|
||||
target = getattr(action, "target_id", None)
|
||||
if target is None:
|
||||
target = action.node.id if isinstance(action, AddAction) else "画布"
|
||||
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:
|
||||
"""用物料默认配置补全新增组件,模型显式写出的字段优先,并按物料设置 sample_data"""
|
||||
material = next(
|
||||
(item for item in catalog.components if item.key == action.component_key), None
|
||||
)
|
||||
if material is None:
|
||||
raise ValueError(
|
||||
f"物料 {action.component_key} 不存在,可用物料用 list_components 查看"
|
||||
)
|
||||
if material.type != action.node.type:
|
||||
raise ValueError(
|
||||
f"物料 {material.key} 的类型是 {material.type},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)
|
||||
node = ScreenNode.model_validate(merge_config(material.template(), overrides))
|
||||
return action.model_copy(update={"node": node, "sample_data": material.sample_data})
|
||||
|
||||
|
||||
def run_batch(
|
||||
screen: Screen, catalog: ComponentCatalog, actions: list[Action]
|
||||
) -> tuple[list[Action], Screen]:
|
||||
"""从原始大屏开始按顺序模拟执行,返回前端要执行的动作和执行后的大屏;第一个错误抛 BatchError"""
|
||||
current = screen.model_copy(deep=True)
|
||||
completed: list[Action] = []
|
||||
for index, action in enumerate(actions):
|
||||
try:
|
||||
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:
|
||||
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)
|
||||
return completed, current
|
||||
@@ -1,498 +0,0 @@
|
||||
"""将物料默认配置补入新增动作,并检查动作是否符合规划,不执行画布更新"""
|
||||
|
||||
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 (
|
||||
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"
|
||||
}
|
||||
replacements = [item for item in plan.components if item.disposition == "replace"]
|
||||
|
||||
# 如果没有任何组件或画布变更,则没有可转换为动作的规划内容
|
||||
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 + replacements):
|
||||
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}"
|
||||
|
||||
# 更换类型只能通过 replace,其余处置显式指定类型时必须与已有节点相同
|
||||
if (
|
||||
item.disposition != "replace"
|
||||
and 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 "规划删除目标与需求不一致,不能遗漏或扩大删除范围"
|
||||
|
||||
# 更换类型会先删除原组件,同样必须与需求逐个目标一致
|
||||
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:
|
||||
# 画布更新需求必须对应非空的画布变更规划
|
||||
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 in (
|
||||
"delete",
|
||||
"replace",
|
||||
):
|
||||
# 删除和更换目标已在上面通过集合相等检查,此处不重复处理
|
||||
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 in ("add", "replace")
|
||||
}
|
||||
|
||||
# 如果没有新增或更换组件,就不需要向模型提供物料配置
|
||||
if not types:
|
||||
return []
|
||||
|
||||
# 按类型提取全部匹配物料,使用协议别名输出 schema 等字段;字段定义由查询工具按需提供
|
||||
return [
|
||||
item.model_dump(mode="json", by_alias=True, exclude={"field_groups"})
|
||||
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:
|
||||
"""用物料默认配置补全新增节点,模型显式提供的字段优先;
|
||||
更换类型的新增节点沿用原 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}
|
||||
|
||||
actions = []
|
||||
for source in batch.actions:
|
||||
# 原组件的删除统一由系统在对应新增之前生成,丢弃模型自行生成的删除,避免重复或顺序错误
|
||||
if isinstance(source, DeleteAction) and source.target_id in replaced_ids:
|
||||
continue
|
||||
# 非新增动作或找不到物料时不补全,物料合法性由规划动作校验负责
|
||||
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]
|
||||
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 = node
|
||||
# 保守标记物料含示例数据,不能由模型声称已取得真实数据
|
||||
action.sample_data = material.sample_data
|
||||
actions.append(action)
|
||||
|
||||
return ActionBatch(actions=actions)
|
||||
|
||||
|
||||
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 或 replace 规划目标: {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: dict[str, ScreenNode],
|
||||
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}"
|
||||
|
||||
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)
|
||||
|
||||
# 获取具体物料,核对规划、物料和节点三者声明的组件类型
|
||||
material = materials.get(action.component_key)
|
||||
|
||||
if (
|
||||
material is None
|
||||
or material.type != item.component_type
|
||||
or action.node.type != item.component_type
|
||||
):
|
||||
return "新增节点类型、物料和规划类型不一致"
|
||||
# 如果规划已明确新增布局,动作必须完整沿用该布局;更换未规划布局时保持原位置
|
||||
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}"
|
||||
|
||||
# 示例数据标记必须与物料一致,不能将示例内容标记为真实业务数据
|
||||
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 in ("add", "replace")
|
||||
}
|
||||
|
||||
# 修改动作通过已有组件 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"
|
||||
}
|
||||
# 更换的原组件要先删除,再以同一 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() # 已覆盖的画布字段
|
||||
planned_canvas = state[
|
||||
"screen_plan"
|
||||
].canvas_changes # 规划要求修改的画布字段及最终值
|
||||
|
||||
# 累计所有动作及覆盖范围错误,便于一次性反馈给修正流程
|
||||
errors = []
|
||||
|
||||
# 按 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):
|
||||
# 检查画布字段是否超出规划,并记录已覆盖字段
|
||||
message = _validate_canvas_action(
|
||||
action, set(planned_canvas), canvas_fields
|
||||
)
|
||||
elif isinstance(action, DeleteAction):
|
||||
# 检查删除目标是否允许、是否重复,并记录已删除目标
|
||||
message = _validate_delete_action(action, removals | replaced, removed)
|
||||
elif isinstance(action, AddAction):
|
||||
# 核对新增规划、ID、物料和布局,并记录已关联的规划编号
|
||||
message = _validate_add_action(action, expected, materials, existing, 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),
|
||||
("更换前删除的组件", replaced - 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
|
||||
Reference in New Issue
Block a user