Files
my-v0-server/app/actions/plan.py
T

232 lines
9.3 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,
MoveAction,
ResizeAction,
UpdateAction,
)
from app.schemas.screen import Screen, ScreenNode
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.canvas_changes:
return "规划包含画布修改,当前尚未接入对应动作"
if plan.resource_needs:
return "规划仍有待获取资源,不能直接生成动作"
if any(
item.disposition not in {"add", "modify", "keep"} for item in plan.components
):
return "当前规划执行支持新增、调整和保留组件,移除后续接入"
additions = [item for item in plan.components if item.disposition == "add"]
modifications = {
item.target_id: item for item in plan.components if item.disposition == "modify"
}
if not additions and not modifications:
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
for change in changes:
if change.target_scope == "screen" and change.operation == "add":
if not additions:
return "规划遗漏新增需求"
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_plan_actions(
state: QueryState, batch: ActionBatch
) -> list[ActionValidationError]:
"""检查动作是否覆盖规划,禁止修改 keep 或未提及的组件"""
if reason := plan_block_reason(state):
return [ActionValidationError(code="scope_error", message=reason)]
expected = {
item.plan_id: item
for item in state["screen_plan"].components
if item.disposition == "add"
}
modifications = {
item.target_id: item
for item in state["screen_plan"].components
if item.disposition == "modify"
}
resources = state.get("component_resources")
materials = {item.key: item for item in resources.components} if resources else {}
seen = set()
modified = set()
errors = []
for index, action in enumerate(batch.actions):
message = None
if not isinstance(action, AddAction):
if not isinstance(action, (MoveAction, ResizeAction, UpdateAction)):
message = "规划调整仅支持 move、resize 和 update"
elif action.target_id not in modifications:
message = f"不能修改保留或未提及的组件: {action.target_id}"
elif (
isinstance(action, UpdateAction)
and not modifications[action.target_id].design
):
message = f"规划未要求修改属性: {action.target_id}"
else:
modified.add(action.target_id)
elif action.plan_id not in expected:
message = f"动作未关联有效的新增规划: {action.plan_id}"
elif action.plan_id in seen:
message = f"同一规划组件不能重复新增: {action.plan_id}"
else:
seen.add(action.plan_id)
item = expected[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
):
message = "新增节点类型、物料和规划类型不一致"
elif item.layout is not None and action.node.layout != item.layout:
message = f"新增布局与规划不一致: {item.plan_id}"
elif action.sample_data != material.sample_data:
message = "示例数据标记与物料不一致"
elif action.node.model_extra.get("dataId"):
message = "当前尚未接入数据绑定,不能生成 dataId"
if message:
errors.append(
ActionValidationError(
action_index=index, code="scope_error", message=message
)
)
if missing := set(expected) - seen:
errors.append(
ActionValidationError(
code="scope_error", message=f"遗漏新增规划组件: {sorted(missing)}"
)
)
if missing := set(modifications) - modified:
errors.append(
ActionValidationError(
code="scope_error", message=f"遗漏调整规划组件: {sorted(missing)}"
)
)
return errors
def validate_plan_result(
state: QueryState, screen: Screen
) -> list[ActionValidationError]:
"""整批执行后检查已有组件的最终布局,避免动作合法但没有达到规划位置"""
nodes = {node.id: node for node in screen.nodes}
return [
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()
)
]