feat: 接入canvas_changes,优化部分代码

This commit is contained in:
2026-09-27 20:29:13 +08:00
parent eda1db1f88
commit 3ce1c1ae61
5 changed files with 156 additions and 71 deletions
+147 -70
View File
@@ -12,8 +12,11 @@ from app.schemas.actions import (
MoveAction, MoveAction,
ResizeAction, ResizeAction,
UpdateAction, UpdateAction,
UpdateCanvasAction,
) )
from app.schemas.component_resource import ComponentDefinition
from app.schemas.screen import Screen, ScreenNode from app.schemas.screen import Screen, ScreenNode
from app.schemas.screen_plan import PlanComponent
def plan_block_reason(state: QueryState) -> str | None: def plan_block_reason(state: QueryState) -> str | None:
@@ -22,8 +25,6 @@ def plan_block_reason(state: QueryState) -> str | None:
resources = state.get("component_resources") resources = state.get("component_resources")
if state["requirement"].needs_clarification: if state["requirement"].needs_clarification:
return "需求尚未澄清,不能生成规划动作" return "需求尚未澄清,不能生成规划动作"
if plan.canvas_changes:
return "规划中的画布修改尚未接入,当前仅支持无需规划的画布修改"
if plan.resource_needs: if plan.resource_needs:
return "规划仍有待获取资源,不能直接生成动作" return "规划仍有待获取资源,不能直接生成动作"
additions = [item for item in plan.components if item.disposition == "add"] additions = [item for item in plan.components if item.disposition == "add"]
@@ -33,8 +34,8 @@ def plan_block_reason(state: QueryState) -> str | None:
modifications = { modifications = {
item.target_id: item for item in plan.components if item.disposition == "modify" item.target_id: item for item in plan.components if item.disposition == "modify"
} }
if not additions and not modifications and not removals: if not additions and not modifications and not removals and not plan.canvas_changes:
return "规划未包含可执行的新增、调整或删除组件" return "规划未包含可执行的组件或画布修改"
if additions and (resources is None or resources.status != "resolved"): if additions and (resources is None or resources.status != "resolved"):
return "组件目录尚未就绪" return "组件目录尚未就绪"
supported = {item.type for item in resources.components} if resources else set() supported = {item.type for item in resources.components} if resources else set()
@@ -54,6 +55,7 @@ def plan_block_reason(state: QueryState) -> str | None:
# 已有目标的修改诉求不能通过 keep 或遗漏目标绕过 # 已有目标的修改诉求不能通过 keep 或遗漏目标绕过
supported_operations = {"move", "resize", "update", "restyle", "reflow", "layout"} supported_operations = {"move", "resize", "update", "restyle", "reflow", "layout"}
changes = state["requirement"].changes changes = state["requirement"].changes
# 删除必须逐个目标获得明确授权,整体布局调整不隐含删除权限 # 删除必须逐个目标获得明确授权,整体布局调整不隐含删除权限
allowed_removals = { allowed_removals = {
target target
@@ -61,10 +63,19 @@ def plan_block_reason(state: QueryState) -> str | None:
if change.target_scope == "nodes" and change.operation == "delete" if change.target_scope == "nodes" and change.operation == "delete"
for target in change.target_ids for target in change.target_ids
} }
if removals != allowed_removals: if removals != allowed_removals:
return "规划删除目标与需求不一致,不能遗漏或扩大删除范围" return "规划删除目标与需求不一致,不能遗漏或扩大删除范围"
for change in changes: for change in changes:
if change.target_scope == "screen" and change.operation == "add": 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: if not additions:
return "规划遗漏新增需求" return "规划遗漏新增需求"
elif change.target_scope == "nodes" and change.operation == "delete": elif change.target_scope == "nodes" and change.operation == "delete":
@@ -79,6 +90,7 @@ def plan_block_reason(state: QueryState) -> str | None:
return "规划遗漏需要调整的组件" return "规划遗漏需要调整的组件"
else: else:
return "需求包含未支持的操作,当前不能只执行部分规划" return "需求包含未支持的操作,当前不能只执行部分规划"
if ( if (
modifications modifications
and changes and changes
@@ -140,111 +152,167 @@ def assemble_plan_actions(state: QueryState, batch: ActionBatch) -> ActionBatch:
return result 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( def validate_plan_actions(
state: QueryState, batch: ActionBatch state: QueryState, batch: ActionBatch
) -> list[ActionValidationError]: ) -> list[ActionValidationError]:
"""检查动作是否覆盖规划,禁止修改 keep 或未提及的组件""" """检查动作是否覆盖规划,禁止修改 keep 或未提及的组件"""
# 先检查规划是否可执行,不支持时直接返回原因
if reason := plan_block_reason(state): if reason := plan_block_reason(state):
return [ActionValidationError(code="scope_error", message=reason)] return [ActionValidationError(code="scope_error", message=reason)]
# 新增动作通过 plan_id 查找对应规划
expected = { expected = {
item.plan_id: item item.plan_id: item
for item in state["screen_plan"].components for item in state["screen_plan"].components
if item.disposition == "add" if item.disposition == "add"
} }
# 修改动作通过已有组件 ID 查找对应规划
modifications = { modifications = {
item.target_id: item item.target_id: item
for item in state["screen_plan"].components for item in state["screen_plan"].components
if item.disposition == "modify" if item.disposition == "modify"
} }
# 物料按 key 索引,用于核对新增组件的类型和示例数据标记
resources = state.get("component_resources") resources = state.get("component_resources")
materials = {item.key: item for item in resources.components} if resources else {} materials = {item.key: item for item in resources.components} if resources else {}
seen = set() seen: set[str] = set() # 已关联的新增规划编号
modified = set() modified: set[str] = set() # 已覆盖的修改目标
# 只有规划标为 remove 的组件才允许删除
removals = { removals = {
item.target_id item.target_id
for item in state["screen_plan"].components for item in state["screen_plan"].components
if item.disposition == "remove" if item.disposition == "remove"
} }
removed = set() removed: set[str] = set() # 已覆盖的删除目标
canvas_fields: set[str] = set() # 已覆盖的画布字段
planned_canvas = state[
"screen_plan"
].canvas_changes # 规划要求修改的画布字段及最终值
errors = [] errors = []
# 保存原有组件 ID,防止新增组件复用已有 ID
existing_ids = {node.id for node in state["input_context"].screen.nodes}
# 按顺序校验每个动作,保留索引以便定位错误
for index, action in enumerate(batch.actions): for index, action in enumerate(batch.actions):
message = None if isinstance(action, UpdateCanvasAction):
if isinstance(action, DeleteAction): # 检查画布字段是否超出规划,并记录已覆盖字段
if action.target_id not in removals: message = _validate_canvas_action(
message = f"不能删除非 remove 规划目标: {action.target_id}" action, set(planned_canvas), canvas_fields
elif action.target_id in removed: )
message = f"不能重复删除组件: {action.target_id}" elif isinstance(action, DeleteAction):
else: # 检查删除目标是否允许、是否重复,并记录已删除目标
removed.add(action.target_id) message = _validate_delete_action(action, removals, removed)
elif not isinstance(action, AddAction): elif isinstance(action, AddAction):
if not isinstance(action, (MoveAction, ResizeAction, UpdateAction)): # 核对新增规划、ID、物料和布局,并记录已关联的规划编号
message = "规划调整仅支持 move、resize 和 update" message = _validate_add_action(
elif action.target_id not in modifications: action, expected, materials, existing_ids, seen
message = f"不能修改保留或未提及的组件: {action.target_id}" )
elif ( elif isinstance(action, (MoveAction, ResizeAction, UpdateAction)):
isinstance(action, UpdateAction) # 检查目标是否允许修改,更新属性时还需有设计要求
and not modifications[action.target_id].design message = _validate_modify_action(action, modifications, modified)
):
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}"
elif action.node.id in {
node.id for node in state["input_context"].screen.nodes
}:
message = f"新增组件不能复用原有 ID: {action.node.id}"
else: else:
seen.add(action.plan_id) # 未接入规划校验的动作返回错误,避免静默放行
item = expected[action.plan_id] message = "规划调整仅支持 move、resize 和 update"
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: if message:
# 将错误与动作索引关联,继续收集其余动作的问题
errors.append( errors.append(
ActionValidationError( ActionValidationError(
action_index=index, code="scope_error", message=message action_index=index, code="scope_error", message=message
) )
) )
if missing := set(expected) - seen:
errors.append( # 逐条合法还不够,整批动作必须覆盖全部规划目标
ActionValidationError( for label, missing in (
code="scope_error", message=f"遗漏新增规划组件: {sorted(missing)}" ("新增规划组件", 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)}"
)
) )
)
if missing := set(modifications) - modified:
errors.append(
ActionValidationError(
code="scope_error", message=f"遗漏调整规划组件: {sorted(missing)}"
)
)
if missing := removals - removed:
errors.append(
ActionValidationError(
code="scope_error", message=f"遗漏删除规划组件: {sorted(missing)}"
)
)
return errors return errors
def validate_plan_result( def validate_plan_result(
state: QueryState, screen: Screen state: QueryState, screen: Screen
) -> list[ActionValidationError]: ) -> list[ActionValidationError]:
"""整批执行后检查已有组件的最终布局,避免动作合法但没有达到规划位置""" """检查整批执行后的组件布局和画布属性是否达到规划要求"""
nodes = {node.id: node for node in screen.nodes} nodes = {node.id: node for node in screen.nodes}
return [ errors = [
ActionValidationError( ActionValidationError(
code="scope_error", message=f"执行后的布局与规划不一致: {item.target_id}" code="scope_error", message=f"执行后的布局与规划不一致: {item.target_id}"
) )
@@ -263,3 +331,12 @@ def validate_plan_result(
for key, value in item.layout.model_dump().items() 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
+1
View File
@@ -194,6 +194,7 @@ def build_graph(checkpointer=None):
] ]
) )
checkpointer = InMemorySaver(serde=serde) checkpointer = InMemorySaver(serde=serde)
return workflow.compile(checkpointer=checkpointer).with_config( return workflow.compile(checkpointer=checkpointer).with_config(
{ {
"recursion_limit": max(25, 12 + 3 * max_retries), "recursion_limit": max(25, 12 + 3 * max_retries),
+5
View File
@@ -59,6 +59,11 @@ def build_action_prompt(
else: else:
sections.append(PLAN_TASK_PROMPT) sections.append(PLAN_TASK_PROMPT)
action_types = set() action_types = set()
if screen_plan.canvas_changes:
sections.append(
"## 调整画布\n将 canvas_changes 中的最终属性值转换为 update_canvas,完整覆盖且不添加其他画布字段"
)
action_types.add("update_canvas")
if any(item.disposition == "add" for item in screen_plan.components): if any(item.disposition == "add" for item in screen_plan.components):
action_types.add("add") action_types.add("add")
if any(item.disposition == "remove" for item in screen_plan.components): if any(item.disposition == "remove" for item in screen_plan.components):
+1
View File
@@ -8,6 +8,7 @@ PLANNER_SYSTEM_PROMPT = """## 任务
覆盖本次全部变更,保留约束,不重新判断意图或扩大修改范围 覆盖本次全部变更,保留约束,不重新判断意图或扩大修改范围
## 组件与布局 ## 组件与布局
canvas_changes 只填写需求范围内要修改的画布顶层字段及最终值,不填写相对变化或设计描述
已有组件使用真实 target_id;无需改动的组件可保留或省略,省略不代表删除 已有组件使用真实 target_id;无需改动的组件可保留或省略,省略不代表删除
remove 仅用于需求明确指定的删除目标,不能因为重新布局而擅自移除组件 remove 仅用于需求明确指定的删除目标,不能因为重新布局而擅自移除组件
布局未确定时写清排列关系;提供 layout 时表示最终的画布绝对坐标和尺寸,单位 px 布局未确定时写清排列关系;提供 layout 时表示最终的画布绝对坐标和尺寸,单位 px
+2 -1
View File
@@ -78,7 +78,8 @@ class ScreenPlan(ExtensibleModel):
description="区域关系、布局安排及保留内容" description="区域关系、布局安排及保留内容"
) )
canvas_changes: JsonObject = Field( canvas_changes: JsonObject = Field(
default_factory=dict, description="建议的画布属性变化,为空表示不改画布" default_factory=dict,
description="要修改的画布顶层字段及最终值,宽高为绝对值,字段整体替换,为空表示不改画布",
) )
regions: list[PlanRegion] = Field(default_factory=list) regions: list[PlanRegion] = Field(default_factory=list)
components: list[PlanComponent] = Field(default_factory=list) components: list[PlanComponent] = Field(default_factory=list)