feat:节点合并

This commit is contained in:
2026-10-01 20:14:54 -07:00
parent 1f98fac478
commit b82dc93404
11 changed files with 424 additions and 300 deletions
+90 -68
View File
@@ -65,6 +65,90 @@ def action_block_reason(state: QueryState) -> str | None:
return None
# 模型输出的结构和解析错误,统一转换为校验结果,交给后续修正流程
GENERATION_EXCEPTIONS = (ValidationError, OutputParserException, StructuredOutputError)
def generation_errors(error: Exception) -> list[ActionValidationError]:
"""把 GENERATION_EXCEPTIONS 中的异常转换为统一的校验错误"""
# 查询字段后的输出不符合结构,与直接输出时的处理一致
if isinstance(error, StructuredOutputValidationError):
error = error.source
if isinstance(error, ValidationError):
return schema_errors(error)
# 模型输出无法解析时,提示后续按 ActionBatch 协议重新生成
return [GENERATION_ERROR]
def reset_action_output(updated_state: QueryState) -> None:
"""清空上一轮的动作、校验和失败信息,避免本轮失败后残留旧结果"""
updated_state.update(
actions=None,
validation_passed=None,
validation_errors=[],
simulated_screen=None,
failure_reason=None,
status="running",
)
def block_actions(updated_state: QueryState, reason: str) -> QueryState:
"""规划不满足执行条件时直接失败收尾,不调用模型生成动作"""
updated_state.update(
actions=None,
validation_passed=False,
validation_errors=[],
simulated_screen=None,
failure_reason=reason,
status="failed",
)
return updated_state
def accept_batch(state: QueryState, result: ActionBatch | None) -> ActionBatch:
"""校验模型给出的动作,有规划时用物料默认配置补全新增节点
结构不合法时抛出 GENERATION_EXCEPTIONS 中的异常
"""
# 响应中没有工具调用时解析器会返回 None
if result is None:
raise OutputParserException("模型未返回结构化结果")
raw = result.model_dump()
# 规划生成的新增节点只保留显式提供的字段,避免模型默认值覆盖物料默认配置
if state.get("screen_plan") is not None:
# 将原动作对象与序列化结果逐项对应,只调整新增动作的节点数据
for source, target in zip(result.actions, raw["actions"]):
if isinstance(source, AddAction):
target["node"] = source.node.model_dump(exclude_unset=True)
# 统一校验整批动作的字段和结构,格式不合法时抛出 ValidationError
batch = ActionBatch.model_validate(raw)
# 如果已有规划,使用物料默认配置补全新增节点,并同步示例数据标记
# 模型判断无法完成时没有动作可补全,保留 unsupported_reason 交给失败收尾
if state.get("screen_plan") is not None and not batch.unsupported_reason:
batch = assemble_plan_actions(state, batch)
return batch
def store_batch(updated_state: QueryState, batch: ActionBatch) -> QueryState:
"""保存待校验的动作;模型确认能力不足时直接失败收尾,不进入重试"""
if batch.unsupported_reason:
updated_state.update(
actions=[],
validation_passed=False,
failure_reason=f"动作生成判断无法完成:{batch.unsupported_reason}",
status="failed",
)
return updated_state
# 实际目标、规划一致性及模拟执行结果仍由后续校验节点检查
# 此时 validation_passed 仍为 None,不代表动作已执行或通过校验
updated_state["actions"] = batch.actions
return updated_state
class ActionNode(BaseNode):
name = "action_node"
@@ -76,16 +160,7 @@ class ActionNode(BaseNode):
if state.get("screen_plan") is not None and (
reason := plan_block_reason(state)
):
# 清空旧动作及模拟结果,标记失败,防止后续误用上一轮生成内容
updated_state.update(
actions=None,
validation_passed=False,
validation_errors=[],
simulated_screen=None,
failure_reason=reason,
status="failed",
)
return updated_state
return block_actions(updated_state, reason)
# 将输入上下文、结构化需求和修改范围转成字典,作为本次模型输入
payload = {
@@ -128,12 +203,7 @@ class ActionNode(BaseNode):
content = json.dumps(payload, ensure_ascii=False)
# 旧动作及错误已放入模型输入,现在清空输出状态,避免本轮失败后残留旧结果
updated_state["actions"] = None
updated_state["validation_passed"] = None
updated_state["validation_errors"] = []
updated_state["simulated_screen"] = None
updated_state["failure_reason"] = None
updated_state["status"] = "running"
reset_action_output(updated_state)
# 捕获输出结构和解析错误,转换成统一校验结果供后续修正流程使用
try:
@@ -149,58 +219,10 @@ class ActionNode(BaseNode):
HumanMessage(content=content),
]
)
# 响应中没有工具调用时解析器会返回 None
if result is None:
raise OutputParserException("模型未返回结构化结果")
raw = result.model_dump()
# 规划生成的新增节点只保留显式提供的字段,避免模型默认值覆盖物料默认配置
if state.get("screen_plan") is not None:
# 将原动作对象与序列化结果逐项对应,只调整新增动作的节点数据
for source, target in zip(result.actions, raw["actions"]):
if isinstance(source, AddAction):
target["node"] = source.node.model_dump(exclude_unset=True)
# 统一校验整批动作的字段和结构,格式不合法时抛出 ValidationError
batch = ActionBatch.model_validate(raw)
# 如果已有规划,使用物料默认配置补全新增节点,并同步示例数据标记
if state.get("screen_plan") is not None:
batch = assemble_plan_actions(state, batch)
except ValidationError as error:
# 将结构校验异常转换为统一错误列表,返回状态以进入后续修正判断
batch = accept_batch(state, result)
except GENERATION_EXCEPTIONS as error:
updated_state["validation_passed"] = False
updated_state["validation_errors"] = schema_errors(error)
return updated_state
except StructuredOutputValidationError as error:
# 查询字段后的输出不符合结构,与直接输出时的处理一致
updated_state["validation_passed"] = False
updated_state["validation_errors"] = (
schema_errors(error.source)
if isinstance(error.source, ValidationError)
else [GENERATION_ERROR]
)
return updated_state
except (OutputParserException, StructuredOutputError):
# 如果模型输出无法解析,记录生成错误,提示后续按 ActionBatch 协议重新生成
updated_state["validation_passed"] = False
updated_state["validation_errors"] = [GENERATION_ERROR]
updated_state["validation_errors"] = generation_errors(error)
return updated_state
# 模型确认现有能力无法满足需求时直接失败收尾,不进入重试
if batch.unsupported_reason:
updated_state.update(
actions=[],
validation_passed=False,
failure_reason=f"动作生成判断无法完成:{batch.unsupported_reason}",
status="failed",
)
return updated_state
# 保存生成的动作,实际目标、规划一致性及模拟执行结果仍由后续校验节点检查
updated_state["actions"] = batch.actions
# 返回待校验状态,此时 validation_passed 仍为 None,不代表动作已执行或通过校验
return updated_state
return store_batch(updated_state, batch)
+163
View File
@@ -0,0 +1,163 @@
"""将需要规划的需求一次设计成规划和动作,修正重试仍交给 ActionNode"""
import json
from app.actions.plan import plan_block_reason
from app.processor.base import BaseNode
from app.processor.material_agent import invoke_with_field_tools
from app.processor.nodes.action_node import (
GENERATION_EXCEPTIONS,
accept_batch,
block_actions,
generation_errors,
reset_action_output,
store_batch,
)
from app.processor.state import QueryState
from app.prompt.design import DESIGN_SYSTEM_PROMPT
from app.schemas.capabilities import (
RESOURCE_FALLBACK_ASSUMPTION,
RESOURCE_QUERY_SUPPORTED,
)
from app.schemas.screen_design import ScreenDesign
from app.schemas.screen_plan import ScreenPlan
# 设计输出未通过解析或规划校验时,带着错误让模型重新设计的最大次数
# TODO 上游节点目前只有这里做了节点内重试,requirement_node 尚未跟进
PLAN_MAX_REPAIRS = 1
def check_plan(plan: ScreenPlan, state: QueryState) -> None:
"""校验规划的组件类型、目标引用和修改范围,不通过时抛出 ValueError"""
requirement = state["requirement"]
scope = state["change_scope"]
context = state["input_context"]
resources = state["component_resources"]
# 校验规划中的物料类型是否真实受支持
supported_types = {component.type for component in resources.components}
for component in plan.components:
# 如果新增组件没有指定类型,无法选择对应物料,立即报错
if component.disposition == "add" and component.component_type is None:
raise ValueError(f"新增规划组件未指定物料类型: {component.plan_id}")
# 新增、修改或更换组件时,只要明确指定了类型,就必须在目录中存在
if (
component.disposition in ("add", "modify", "replace")
and component.component_type is not None
and component.component_type not in supported_types
):
raise ValueError(
f"规划使用了目录中不存在的组件类型: {component.component_type}"
)
# 收集当前大屏中真实存在的节点 ID,用于检查规划引用的已有目标
existing_ids = {node.id for node in context.screen.nodes}
# 汇总规划中明确引用的目标 ID,新增组件没有已有目标时不计入
planned_ids = {
component.target_id
for component in plan.components
if component.target_id is not None
}
# 如果规划引用了不存在的组件,则抛出异常并列出无效 ID
if unknown := planned_ids - existing_ids:
raise ValueError(f"规划引用了不存在的组件: {sorted(unknown)}")
# 汇总需求明确指定的目标 ID,避免规划遗漏用户提及的组件
required_ids = {
target for change in requirement.changes for target in change.target_ids
}
# 如果需求目标未在规划中出现,则停止处理并指出遗漏
if missing := required_ids - planned_ids:
raise ValueError(f"规划遗漏了需求中的组件: {sorted(missing)}")
# 如果是局部规划,还需保证对已有组件的修改和删除不超出指定范围
if scope.scope == "local":
# 只统计会改变已有组件的目标,保留和新增组件不纳入此项检查
changed_ids = {
component.target_id
for component in plan.components
if component.disposition in ("modify", "remove", "replace")
}
# 如果实际修改或删除的目标不在允许范围内,则拒绝扩大修改范围
if outside := changed_ids - set(scope.target_ids):
raise ValueError(f"局部规划修改了范围外的组件: {sorted(outside)}")
class DesignNode(BaseNode):
name = "design_node"
async def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
requirement = state["requirement"]
# 图保证组件目录已就绪后才进入设计
resources = state["component_resources"]
# 业务资料放入用户消息;组件只提供摘要,完整物料配置由模型按需查询
content = json.dumps(
{
"input_context": state["input_context"].model_dump(mode="json"),
"requirement": requirement.model_dump(mode="json"),
"change_scope": state["change_scope"].model_dump(mode="json"),
"component_resources": resources.to_planner_context(),
},
ensure_ascii=False,
)
reset_action_output(updated_state)
feedback = ""
for attempt in range(PLAN_MAX_REPAIRS + 1):
design = None
try:
design = await invoke_with_field_tools(
ScreenDesign, DESIGN_SYSTEM_PROMPT, content + feedback
)
if design is None:
raise ValueError("模型未返回结构化结果")
check_plan(design.plan, state)
break
except (ValueError, *GENERATION_EXCEPTIONS) as error:
# 解析失败时还没有规划,只能整份重新设计;超过次数才失败收尾
if attempt == PLAN_MAX_REPAIRS:
return self.fail_on_invalid_output(
state, error, "设计输出多次未通过校验"
)
self.logger.warning(f"设计未通过校验,重新设计: {error}")
feedback = (
f"\n\n上一次输出未通过校验:{error}\n请修正后重新输出完整设计。"
)
if design is not None:
feedback = f"\n\n上一次输出:\n{design.model_dump_json()}{feedback}"
plan = design.plan
# 资源查询接入前没有补充来源,保留会让动作阶段误判为“待获取资源”而失败
if not RESOURCE_QUERY_SUPPORTED and plan.resource_needs:
self.logger.info(
f"资源查询未接入,改用默认配置: {[need.description for need in plan.resource_needs]}"
)
plan.resource_needs = []
plan.assumptions.append(RESOURCE_FALLBACK_ASSUMPTION)
# 合并需求和规划的约束与假设,按首次出现顺序去重,需求在前
plan.constraints = list(
dict.fromkeys([*requirement.constraints, *plan.constraints])
)
plan.assumptions = list(
dict.fromkeys([*requirement.assumptions, *plan.assumptions])
)
# 保存通过校验的规划,后续动作校验和修正重试都以它为准
updated_state["screen_plan"] = plan
# 规划不满足执行条件时不再处理动作
if reason := plan_block_reason(updated_state):
return block_actions(updated_state, reason)
# 动作结构错误交给 reflection 和 ActionNode 按已定的规划修正
try:
batch = accept_batch(updated_state, design.batch)
except GENERATION_EXCEPTIONS as error:
updated_state["validation_passed"] = False
updated_state["validation_errors"] = generation_errors(error)
return updated_state
return store_batch(updated_state, batch)
-152
View File
@@ -1,152 +0,0 @@
"""将需要规划的需求整理为 ScreenPlan"""
import json
from langchain_core.messages import HumanMessage, SystemMessage
from app.clients.llm import get_llm
from app.processor.base import BaseNode
from app.processor.state import QueryState
from app.prompt.planner import PLANNER_SYSTEM_PROMPT
from app.schemas.capabilities import (
RESOURCE_FALLBACK_ASSUMPTION,
RESOURCE_QUERY_SUPPORTED,
)
from app.schemas.screen_plan import ScreenPlan
# 规划输出未通过校验时,带着错误让模型重新规划的最大次数
# TODO 上游节点目前只有这里做了节点内重试,requirement_node 尚未跟进
# TODO 将来可改为图级回路:规划校验失败 -> 规划版 reflection -> planner,
# 与动作阶段的重试统一由 route_failure 控制,届时可去掉这里的节点内重试
PLAN_MAX_REPAIRS = 1
class PlannerNode(BaseNode):
name = "planner_node"
async def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
# 获取已提取的需求和范围,作为规划内容及修改边界的依据
requirement = state["requirement"]
scope = state["change_scope"]
# 获取当前大屏和用户输入,供模型结合已有内容安排布局与组件
context = state["input_context"]
# 图保证组件目录已就绪后才进入规划
resources = state["component_resources"]
messages = [
# 系统消息提供稳定的规划规则和输出边界
SystemMessage(content=PLANNER_SYSTEM_PROMPT),
# 用户消息承载本次上下文、需求、范围及组件能力摘要
HumanMessage(
content=json.dumps(
{
"input_context": context.model_dump(mode="json"),
"requirement": requirement.model_dump(mode="json"),
"change_scope": scope.model_dump(mode="json"),
# 仅提供规划需要的组件摘要,省略完整默认配置
"component_resources": resources.to_planner_context(),
},
ensure_ascii=False,
)
),
]
llm_client = get_llm(ScreenPlan)
for attempt in range(PLAN_MAX_REPAIRS + 1):
plan = None
try:
plan = await llm_client.ainvoke(messages)
self._check(plan, state)
break
except ValueError as error:
# 解析失败、引用不存在的目标等可由模型自行纠正,超过次数才失败收尾
if attempt == PLAN_MAX_REPAIRS:
return self.fail_on_invalid_output(
state, error, "规划输出多次未通过校验"
)
self.logger.warning(f"规划未通过校验,重新规划: {error}")
feedback = f"上一次输出未通过校验:{error}\n请修正后重新输出完整规划。"
if plan is not None:
feedback = f"上一次输出:\n{plan.model_dump_json()}\n\n{feedback}"
messages = [*messages, HumanMessage(content=feedback)]
# 资源查询接入前没有补充来源,保留会让动作阶段误判为“待获取资源”而失败
if not RESOURCE_QUERY_SUPPORTED and plan.resource_needs:
self.logger.info(
f"资源查询未接入,改用默认配置: {[need.description for need in plan.resource_needs]}"
)
plan.resource_needs = []
plan.assumptions.append(RESOURCE_FALLBACK_ASSUMPTION)
# 合并需求约束和规划约束,按首次出现顺序去重,保留需求约束在前
plan.constraints = list(
dict.fromkeys([*requirement.constraints, *plan.constraints])
)
# 同样合并原始假设与规划补充的假设,避免重复记录或丢失前置条件
plan.assumptions = list(
dict.fromkeys([*requirement.assumptions, *plan.assumptions])
)
# 保存通过校验的规划,供后续动作生成使用
updated_state["screen_plan"] = plan
# 返回更新后的状态,由图继续处理规划对应的动作
return updated_state
@staticmethod
def _check(plan: ScreenPlan, state: QueryState) -> None:
"""校验规划的组件类型、目标引用和修改范围,不通过时抛出 ValueError"""
requirement = state["requirement"]
scope = state["change_scope"]
context = state["input_context"]
resources = state["component_resources"]
# 校验规划中的物料类型是否真实受支持
supported_types = {component.type for component in resources.components}
for component in plan.components:
# 如果新增组件没有指定类型,无法选择对应物料,立即报错
if component.disposition == "add" and component.component_type is None:
raise ValueError(f"新增规划组件未指定物料类型: {component.plan_id}")
# 新增、修改或更换组件时,只要明确指定了类型,就必须在目录中存在
if (
component.disposition in ("add", "modify", "replace")
and component.component_type is not None
and component.component_type not in supported_types
):
raise ValueError(
f"规划使用了目录中不存在的组件类型: {component.component_type}"
)
# 收集当前大屏中真实存在的节点 ID,用于检查规划引用的已有目标
existing_ids = {node.id for node in context.screen.nodes}
# 汇总规划中明确引用的目标 ID,新增组件没有已有目标时不计入
planned_ids = {
component.target_id
for component in plan.components
if component.target_id is not None
}
# 如果规划引用了不存在的组件,则抛出异常并列出无效 ID
if unknown := planned_ids - existing_ids:
raise ValueError(f"规划引用了不存在的组件: {sorted(unknown)}")
# 汇总需求明确指定的目标 ID,避免规划遗漏用户提及的组件
required_ids = {
target for change in requirement.changes for target in change.target_ids
}
# 如果需求目标未在规划中出现,则停止处理并指出遗漏
if missing := required_ids - planned_ids:
raise ValueError(f"规划遗漏了需求中的组件: {sorted(missing)}")
# 如果是局部规划,还需保证对已有组件的修改和删除不超出指定范围
if scope.scope == "local":
# 只统计会改变已有组件的目标,保留和新增组件不纳入此项检查
changed_ids = {
component.target_id
for component in plan.components
if component.disposition in ("modify", "remove", "replace")
}
# 如果实际修改或删除的目标不在允许范围内,则拒绝扩大修改范围
if outside := changed_ids - set(scope.target_ids):
raise ValueError(f"局部规划修改了范围外的组件: {sorted(outside)}")