feat:简化部分代码

This commit is contained in:
2026-09-29 07:23:42 -07:00
parent af84485e8d
commit 352d144823
13 changed files with 123 additions and 306 deletions
+22 -47
View File
@@ -1,15 +1,13 @@
import inspect
import time
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, TypeVar
from contextlib import contextmanager
from typing import TypeVar
from langgraph.errors import GraphInterrupt
from loguru import logger
from pydantic import ValidationError
if TYPE_CHECKING:
from app.config import Settings
from app.exceptions import AppError, ImportProcessError
from app.config import Settings, settings
from app.processor.progress import node_progress
T = TypeVar("T")
@@ -18,67 +16,44 @@ T = TypeVar("T")
class BaseNode(ABC):
name: str = "base_node"
def __init__(self, config: "Settings | None" = None):
"""保存显式配置;全局配置在节点实际使用时才加载。"""
self._config = config
def __init__(self, config: Settings | None = None):
self.config = config if config is not None else settings
self.logger = logger.bind(node=self.name)
@property
def config(self) -> "Settings":
if self._config is None:
from app.config import settings
self._config = settings
return self._config
def __call__(self, state: T) -> T:
"""执行节点,统一记录日志、耗时并保留业务异常类型"""
if inspect.iscoroutinefunction(self.process):
return self._acall(state)
@contextmanager
def _instrument(self, state):
"""统一记录日志、耗时和进度事件,异常保持原类型向上抛出"""
start_time = time.perf_counter()
self.logger.info("节点开始")
try:
with node_progress(self.name, state):
result = self.process(state)
yield
except GraphInterrupt:
raise
except (AppError, ValidationError):
except Exception:
self.logger.error("节点执行失败")
raise
except Exception as e:
self.logger.error("节点执行失败")
raise ImportProcessError(str(e), node_name=self.name) from e
else:
self.logger.info("节点完成")
return result
finally:
self.logger.info(f"节点耗时 {time.perf_counter() - start_time:.3f} 秒")
def __call__(self, state: T) -> T:
# 异步节点直接调用时返回协程,日志与进度在 _acall 内等待完成后记录
if inspect.iscoroutinefunction(self.process):
return self._acall(state)
with self._instrument(state):
return self.process(state)
async def _acall(self, state: T) -> T:
"""异步节点入口,由图根据 process 是否为协程选择"""
with self._instrument(state):
return await self.process(state)
@abstractmethod
def process(self, state: T) -> T:
"""子类实现节点核心逻辑,返回状态更新"""
async def _acall(self, state: T) -> T:
"""异步节点等待执行完成后再记录结果和耗时"""
start_time = time.perf_counter()
self.logger.info("节点开始")
try:
with node_progress(self.name, state):
result = await self.process(state)
except GraphInterrupt:
raise
except (AppError, ValidationError):
self.logger.error("节点执行失败")
raise
except Exception as error:
self.logger.error("节点执行失败")
raise ImportProcessError(str(error), node_name=self.name) from error
else:
self.logger.info("节点完成")
return result
finally:
self.logger.info(f"节点耗时 {time.perf_counter() - start_time:.3f} 秒")
def log_step(self, step_name: str, message: str = ""):
"""记录步骤日志。"""
log_msg = f"[{step_name}]"
+1 -4
View File
@@ -6,6 +6,7 @@ from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from langgraph.graph import END, StateGraph
from app.config import settings
from app.processor.nodes.action_node import ActionNode, can_generate_actions
from app.processor.nodes.clarify_node import ClarifyNode
from app.processor.nodes.component_resource_node import ComponentResourceNode
@@ -46,8 +47,6 @@ def route_after_scope(state: QueryState) -> str:
def build_graph(checkpointer=None):
from app.config import settings
# 最大重试次数
max_retries = settings.max_action_retries
@@ -331,8 +330,6 @@ if __name__ == "__main__":
print(
f"{index}. {option['label']}{marker}:{option['description']}"
)
if "error" in pending:
print(pending["error"])
answer = input("输入编号或自由回答(/cancel 取消):")
reply = {"cancel": True} if answer == "/cancel" else {"answer": answer}
if answer.isdigit() and 1 <= int(answer) <= len(options):
+7 -10
View File
@@ -4,7 +4,7 @@ import json
from langchain_core.exceptions import OutputParserException
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, ValidationError
from pydantic import ValidationError
from app.actions.plan import assemble_plan_actions, plan_block_reason, plan_materials
from app.clients.llm import get_llm
@@ -84,12 +84,6 @@ class ActionNode(BaseNode):
)
return updated_state
# 如果未通过动作能力检查,抛出异常,阻止不受支持的需求进入生成流程
if not can_generate_actions(state):
raise ValueError(
"ActionNode 当前只支持无需规划和资源的 move、resize、update、delete、update_canvas 需求"
)
# 将输入上下文、结构化需求和修改范围转成字典,作为本次模型输入
payload = {
"input_context": state["input_context"].model_dump(mode="json"),
@@ -143,11 +137,13 @@ class ActionNode(BaseNode):
]
)
# 将模型对象转成字典,非模型返回值保持原样,统一交给后面的协议校验
raw = result.model_dump() if isinstance(result, BaseModel) else result
# 响应中没有工具调用时解析器会返回 None
if result is None:
raise OutputParserException("模型未返回结构化结果")
raw = result.model_dump()
# 规划生成的新增节点只保留显式提供的字段,避免模型默认值覆盖物料默认配置
if isinstance(result, ActionBatch) and state.get("screen_plan") is not None:
if state.get("screen_plan") is not None:
# 将原动作对象与序列化结果逐项对应,只调整新增动作的节点数据
for source, target in zip(result.actions, raw["actions"]):
if isinstance(source, AddAction):
@@ -277,6 +273,7 @@ if __name__ == "__main__":
)
# 加 --modify 测试规划到已有组件调整,无需新增物料
if "--modify" in sys.argv:
state = ComponentResourceNode().process(state)
state["change_scope"].needs_planning = True
state["screen_plan"] = ScreenPlan(
goal="向右移动 50px",
+20 -41
View File
@@ -21,26 +21,10 @@ class PlannerNode(BaseNode):
requirement = state["requirement"]
scope = state["change_scope"]
# 如果需求仍需澄清、不是新建或修改大屏,或无需规划,则拒绝进入本节点
if (
requirement.needs_clarification
or requirement.intent
not in (
"create_screen",
"modify_screen",
)
or not scope.needs_planning
):
raise ValueError("PlannerNode 只处理信息完整且需要规划的新建或修改需求")
# 获取当前大屏和用户输入,供模型结合已有内容安排布局与组件
context = state["input_context"]
# 获取可选的组件查询结果
resources = state.get("component_resources")
# 如果已有查询结果但目录未就绪,则停止规划,避免忽略资源查询失败
if resources is not None and resources.status != "resolved":
raise ValueError("组件目录尚未就绪,不能继续规划")
# 图保证组件目录已就绪后才进入规划
resources = state["component_resources"]
llm_client = get_llm(ScreenPlan)
result = await llm_client.ainvoke(
@@ -54,10 +38,8 @@ class PlannerNode(BaseNode):
"input_context": context.model_dump(mode="json"),
"requirement": requirement.model_dump(mode="json"),
"change_scope": scope.model_dump(mode="json"),
# 仅提供规划需要的组件摘要,省略完整默认配置,没有目录时传入 None
"component_resources": resources.to_planner_context()
if resources is not None
else None,
# 仅提供规划需要的组件摘要,省略完整默认配置
"component_resources": resources.to_planner_context(),
},
ensure_ascii=False,
)
@@ -65,26 +47,23 @@ class PlannerNode(BaseNode):
]
)
# 将模型结果转成字典后重新校验,确保规划字段及组件关系符合输出协议
plan = ScreenPlan.model_validate(result.model_dump())
plan = result
# 如果提供了组件目录,则额外校验规划中的物料类型是否真实受支持
if resources is not None:
# 提取目录支持的前端组件类型,使用集合进行成员检查
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")
and component.component_type is not None
):
if component.component_type not in supported_types:
raise ValueError(
f"规划使用了目录中不存在的组件类型: {component.component_type}"
)
# 校验规划中的物料类型是否真实受支持
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")
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}
+5 -6
View File
@@ -4,7 +4,7 @@ import json
from langchain_core.exceptions import OutputParserException
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, ValidationError
from pydantic import ValidationError
from app.actions.plan import plan_materials
from app.clients.llm import get_llm
@@ -48,7 +48,7 @@ class ReflectionNode(BaseNode):
# 只捕获输出结构和解析错误,配置、网络等异常继续向上抛出
try:
# 异步生成修正建议,本节点不直接修改动作
result = await llm_client.ainvoke(
plan = await llm_client.ainvoke(
[
# 系统消息提供稳定的失败分析和修正规则
SystemMessage(content=REFLECTION_SYSTEM_PROMPT),
@@ -56,10 +56,9 @@ class ReflectionNode(BaseNode):
HumanMessage(content=json.dumps(payload, ensure_ascii=False)),
]
)
# 将模型对象转成字典后统一校验,非模型返回值直接交给协议校验
plan = ActionRepairPlan.model_validate(
result.model_dump() if isinstance(result, BaseModel) else result
)
# 响应中没有工具调用时解析器会返回 None
if plan is None:
raise OutputParserException("模型未返回结构化结果")
except (ValidationError, OutputParserException):
# 如果未能得到合法建议,清空旧修正计划,避免再次使用已失效的建议
updated_state["repair_plan"] = None
-7
View File
@@ -21,13 +21,6 @@ class ScopeNode(BaseNode):
# 获取需求节点提取的结构化需求,作为范围判断的依据
requirement = state["requirement"]
# 如果需求仍需澄清,或意图不是新建、修改大屏,则不能进入范围判断
if requirement.needs_clarification or requirement.intent not in (
"create_screen",
"modify_screen",
):
raise ValueError("ScopeNode 只处理信息完整的新建或修改需求")
# 获取当前大屏和用户输入,帮助模型结合已有组件判断影响范围
context = state["input_context"]
+25 -15
View File
@@ -1,6 +1,6 @@
"""校验动作范围,再按顺序修改大屏 JSON 副本,检查整批动作能否执行"""
from pydantic import BaseModel, ValidationError
from pydantic import ValidationError
from app.actions.plan import validate_plan_actions, validate_plan_result
from app.actions.registry import apply_action
@@ -22,17 +22,9 @@ class ValidatorNode(BaseNode):
actions = state["actions"]
try:
# 重新校验模型内容,避免赋值后绕过字段校验
# 转回字典重新校验,避免赋值后绕过字段校验
batch = ActionBatch.model_validate(
{
"actions": [
# 将动作模型转回字典,确保重新触发字段校验,字典输入则保持原样
action.model_dump() if isinstance(action, BaseModel) else action
for action in actions
]
if isinstance(actions, list)
else actions
}
{"actions": [action.model_dump() for action in actions]}
)
except ValidationError as error:
# 将结构异常转换成统一错误列表,供后续修正流程定位问题
@@ -43,8 +35,7 @@ class ValidatorNode(BaseNode):
updated_state["simulated_screen"] = None
return updated_state
# 获取可选的结构化需求,没有需求和规划时仅做结构校验与独立模拟执行
requirement = state.get("requirement")
requirement = state["requirement"]
# 如果已有规划,检查动作是否覆盖规划目标,以及是否修改了规划外的内容
if state.get("screen_plan") is not None:
@@ -55,7 +46,7 @@ class ValidatorNode(BaseNode):
updated_state["validation_errors"] = errors
updated_state["simulated_screen"] = None
return updated_state
elif requirement is not None:
else:
# 此分支没有规划,只校验简单修改,新增动作需要走上面的规划分支
if any(isinstance(action, AddAction) for action in batch.actions):
# 为每个新增动作记录下标和范围错误,便于修正时逐项定位
@@ -64,7 +55,7 @@ class ValidatorNode(BaseNode):
ActionValidationError(
action_index=index,
code="scope_error",
message="新增动作尚未接入需求范围校验,当前仅支持独立模拟执行",
message="新增动作需要先经过规划,不能作为简单修改直接校验",
)
for index, action in enumerate(batch.actions)
if isinstance(action, AddAction)
@@ -175,6 +166,7 @@ if __name__ == "__main__":
from app.processor.nodes.input_node import InputNode
from app.schemas.actions import MoveAction, ResizeAction, UpdateAction
from app.schemas.requirement import Requirement
# 1. 创建 state,不需要调用模型
state = InputNode().process(
@@ -195,6 +187,15 @@ if __name__ == "__main__":
}
}
)
state["requirement"] = Requirement(
intent="modify_screen",
goal="移动、调整尺寸并修改标题",
needs_clarification=False,
changes=[
{"target_scope": "nodes", "target_ids": ["sales-chart"], "operation": op}
for op in ("move", "resize", "update")
],
)
state["actions"] = [
MoveAction(target_id="sales-chart", delta_x=50),
ResizeAction(target_id="sales-chart", width=500),
@@ -217,6 +218,15 @@ if __name__ == "__main__":
# 3. 在第二个动作制造错误,验证整批失败且不返回部分结果
failed_state = {
**final_state,
"requirement": Requirement(
intent="modify_screen",
goal="移动两个组件",
needs_clarification=False,
changes=[
{"target_scope": "nodes", "target_ids": [target], "operation": "move"}
for target in ("sales-chart", "missing")
],
),
"actions": [
state["actions"][0],
MoveAction(target_id="missing", delta_x=50),
+4 -20
View File
@@ -1,7 +1,6 @@
"""暂停图并接收用户回答,不在恢复时重复调用模型"""
from langgraph.types import interrupt
from pydantic import ValidationError
from app.processor.base import BaseNode
from app.processor.state import QueryState
@@ -13,25 +12,10 @@ class WaitForUserNode(BaseNode):
def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
payload = state["clarification"].model_dump(mode="json")
while True:
# interrupt 的控制异常必须交给 LangGraph 处理
value = interrupt(payload)
try:
reply = ClarificationReply.model_validate(
{"answer": value} if isinstance(value, str) else value
)
if not reply.cancel and reply.option_id is not None:
if reply.option_id not in {
option.id for option in state["clarification"].options
}:
raise ValueError("无效选项")
break
except (ValidationError, ValueError):
payload = {
**payload,
"error": "请选择本题有效选项或填写回答,并提供有效的大屏数据;也可设置 cancel=true 取消",
}
# 回复的格式与选项有效性已在恢复前由服务层校验,此处只还原为模型
reply = ClarificationReply.model_validate(
interrupt(state["clarification"].model_dump(mode="json"))
)
updated_state["clarification_reply"] = reply
updated_state["status"] = "cancelled" if reply.cancel else "running"