feat:优化意图识别

This commit is contained in:
2026-09-30 21:31:20 -07:00
parent 1b727cde42
commit 8e37d30ef2
7 changed files with 138 additions and 52 deletions
+104 -42
View File
@@ -3,6 +3,9 @@
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 (
@@ -44,15 +47,18 @@ def plan_block_reason(state: QueryState) -> str | None:
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 and not modifications and not removals and not plan.canvas_changes:
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):
return "规划中的新增类型未在组件目录中找到"
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}
@@ -65,9 +71,10 @@ def plan_block_reason(state: QueryState) -> str | None:
if item.target_id not in nodes:
return f"规划目标不存在: {item.target_id}"
# 如果规划显式指定类型,必须与已有节点相同,不能借修改操作更换类型
# 更换类型只能通过 replace,其余处置显式指定类型时必须与已有节点相同
if (
item.component_type is not None
item.disposition != "replace"
and item.component_type is not None
and item.component_type != nodes[item.target_id].type
):
return "当前不支持更换已有组件类型"
@@ -88,6 +95,16 @@ def plan_block_reason(state: QueryState) -> str | None:
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:
# 画布更新需求必须对应非空的画布变更规划
@@ -98,8 +115,11 @@ def plan_block_reason(state: QueryState) -> str | None:
# 新增需求至少需要一个新增组件规划
if not additions:
return "规划遗漏新增需求"
elif change.target_scope == "nodes" and change.operation == "delete":
# 删除目标已在上面通过集合相等检查,此处不重复处理
elif change.target_scope == "nodes" and change.operation in (
"delete",
"replace",
):
# 删除和更换目标已在上面通过集合相等检查,此处不重复处理
continue
elif (
change.target_scope in {"nodes", "screen"}
@@ -134,14 +154,14 @@ def plan_block_reason(state: QueryState) -> str | None:
def plan_materials(state: QueryState) -> list[dict]:
"""提取规划需要的完整物料配置,供动作生成和修正使用"""
# 只收集新增组件所需的类型,使用集合去除重复类型
# 只收集新增和更换组件所需的类型,使用集合去除重复类型
types = {
item.component_type
for item in state["screen_plan"].components
if item.disposition == "add"
if item.disposition in ("add", "replace")
}
# 如果没有新增组件,就不需要向模型提供物料配置
# 如果没有新增或更换组件,就不需要向模型提供物料配置
if not types:
return []
@@ -170,31 +190,61 @@ def merge_config(defaults: dict, overrides: dict) -> dict:
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}
# 复制整批动作,补全配置时不改动调用方提供的动作对象
result = batch.model_copy(deep=True)
for action in result.actions:
actions = []
for source in batch.actions:
# 原组件的删除统一由系统在对应新增之前生成,丢弃模型自行生成的删除,避免重复或顺序错误
if isinstance(source, DeleteAction) and source.target_id in replaced_ids:
continue
# 非新增动作或找不到物料时不补全,物料合法性由规划动作校验负责
if not isinstance(action, AddAction) or action.component_key not in materials:
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]
node = merge_config(
material.schema_.model_dump(mode="json"),
action.node.model_dump(mode="json", exclude_unset=True),
)
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 = ScreenNode.model_validate(node)
action.node = node
# 保守标记物料含示例数据,不能由模型声称已取得真实数据
action.sample_data = material.sample_data
actions.append(action)
return result
return ActionBatch(actions=actions)
def _validate_canvas_action(
@@ -218,7 +268,7 @@ def _validate_delete_action(
"""只允许删除 remove 目标,通过后记录目标以防重复删除"""
# 如果目标未被规划标记为删除,拒绝该动作
if action.target_id not in planned_ids:
return f"不能删除非 remove 规划目标: {action.target_id}"
return f"不能删除非 remove 或 replace 规划目标: {action.target_id}"
# 如果前面的动作已经删除该目标,拒绝重复删除
if action.target_id in removed_ids:
@@ -254,11 +304,11 @@ def _validate_add_action(
action: AddAction,
planned: dict[str, PlanComponent],
materials: dict[str, ComponentDefinition],
existing_ids: set[str],
existing: dict[str, ScreenNode],
seen_plan_ids: set[str],
) -> str | None:
"""检查新增规划关联、ID、物料类型、布局及数据标记"""
# 新增动作必须关联有效的新增规划编号
"""检查新增或更换规划关联、ID、物料类型、布局及数据标记"""
# 新增动作必须关联有效的新增或更换规划编号
if action.plan_id not in planned:
return f"动作未关联有效的新增规划: {action.plan_id}"
@@ -266,15 +316,19 @@ def _validate_add_action(
if action.plan_id in seen_plan_ids:
return f"同一规划组件不能重复新增: {action.plan_id}"
# 新组件不能复用原大屏已有的节点 ID
if action.node.id in existing_ids:
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)
# 获取关联规划与具体物料,核对三者声明的组件类型
item = planned[action.plan_id]
# 获取具体物料,核对规划、物料和节点三者声明的组件类型
material = materials.get(action.component_key)
if (
@@ -283,8 +337,11 @@ def _validate_add_action(
or action.node.type != item.component_type
):
return "新增节点类型、物料和规划类型不一致"
# 如果规划已明确新增布局,动作必须完整沿用该布局
if item.layout is not None and action.node.layout != item.layout:
# 如果规划已明确新增布局,动作必须完整沿用该布局;更换未规划布局时保持原位置
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}"
# 示例数据标记必须与物料一致,不能将示例内容标记为真实业务数据
@@ -306,11 +363,11 @@ def validate_plan_actions(
if reason := plan_block_reason(state):
return [ActionValidationError(code="scope_error", message=reason)]
# 新增动作通过 plan_id 查找对应规划
# 新增和更换动作通过 plan_id 查找对应规划,更换生成的也是新增动作
expected = {
item.plan_id: item
for item in state["screen_plan"].components
if item.disposition == "add"
if item.disposition in ("add", "replace")
}
# 修改动作通过已有组件 ID 查找对应规划
@@ -331,6 +388,12 @@ def validate_plan_actions(
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() # 已覆盖的画布字段
@@ -341,8 +404,8 @@ def validate_plan_actions(
# 累计所有动作及覆盖范围错误,便于一次性反馈给修正流程
errors = []
# 保存原有组件 ID,防止新增组件复用已有 ID
existing_ids = {node.id for node in state["input_context"].screen.nodes}
# 按 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):
@@ -352,12 +415,10 @@ def validate_plan_actions(
)
elif isinstance(action, DeleteAction):
# 检查删除目标是否允许、是否重复,并记录已删除目标
message = _validate_delete_action(action, removals, removed)
message = _validate_delete_action(action, removals | replaced, removed)
elif isinstance(action, AddAction):
# 核对新增规划、ID、物料和布局,并记录已关联的规划编号
message = _validate_add_action(
action, expected, materials, existing_ids, seen
)
message = _validate_add_action(action, expected, materials, existing, seen)
elif isinstance(action, (MoveAction, ResizeAction, UpdateAction)):
# 检查目标是否允许修改,更新属性时还需有设计要求
message = _validate_modify_action(action, modifications, modified)
@@ -377,6 +438,7 @@ def validate_plan_actions(
("新增规划组件", set(expected) - seen),
("调整规划组件", set(modifications) - modified),
("删除规划组件", removals - removed),
("更换前删除的组件", replaced - removed),
("画布规划字段", set(planned_canvas) - canvas_fields),
):
if missing: