feat:节点合并
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)}")
|
||||
Reference in New Issue
Block a user