343 lines
14 KiB
Python
343 lines
14 KiB
Python
"""将物料默认配置补入新增动作,并检查动作是否符合规划,不执行画布更新"""
|
|
|
|
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.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:
|
|
"""检查规划是否在当前支持范围内,不支持时返回原因"""
|
|
plan = state["screen_plan"]
|
|
resources = state.get("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 "规划未包含可执行的组件或画布修改"
|
|
if additions and (resources is None or resources.status != "resolved"):
|
|
return "组件目录尚未就绪"
|
|
supported = {item.type for item in resources.components} if resources else set()
|
|
if any(item.component_type not in supported for item in additions):
|
|
return "规划中的新增类型未在组件目录中找到"
|
|
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 = {"move", "resize", "update", "restyle", "reflow", "layout"}
|
|
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 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":
|
|
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 "需求包含未支持的操作,当前不能只执行部分规划"
|
|
|
|
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 "规划调整了需求范围外的组件"
|
|
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 []
|
|
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:
|
|
"""用物料默认配置补全新增节点,模型显式提供的字段优先"""
|
|
resources = state.get("component_resources")
|
|
materials = {item.key: item for item in resources.components} if resources else {}
|
|
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 目标,属性更新还需规划中有设计要求"""
|
|
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[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]:
|
|
"""检查整批执行后的组件布局和画布属性是否达到规划要求"""
|
|
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
|