181 lines
8.2 KiB
Python
181 lines
8.2 KiB
Python
"""整批模拟执行:新增组件补全物料默认配置,再按顺序在大屏副本上执行,遇到第一个错误即停止
|
||
|
||
纯函数,不依赖 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
|