feat:架构重构

This commit is contained in:
2026-10-01 23:08:06 -07:00
parent b82dc93404
commit 68588d1f92
74 changed files with 1131 additions and 3492 deletions
-5
View File
@@ -54,11 +54,6 @@ class BaseNode(ABC):
def process(self, state: T) -> T:
"""子类实现节点核心逻辑,返回状态更新"""
def fail_on_invalid_output(self, state: T, error: ValueError, reason: str) -> T:
"""模型输出未通过校验时收尾为失败结果,原因交给失败收尾由模型向用户说明"""
self.logger.warning(f"{reason}: {error}")
return {**state, "status": "failed", "failure_reason": f"{reason}:{error}"}
def log_step(self, step_name: str, message: str = ""):
"""记录步骤日志。"""
log_msg = f"[{step_name}]"
+20 -204
View File
@@ -1,4 +1,4 @@
"""输入解析、意图分流和需求理解流程"""
"""输入 → 意图(是不是明确的问答)→ 问答节点或 screen agent;agent 追问时暂停等待用户"""
import inspect
@@ -6,19 +6,10 @@ 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
from app.processor.nodes.design_node import DesignNode
from app.processor.nodes.fallback_node import FallbackNode
from app.processor.nodes.input_node import InputNode
from app.processor.nodes.intent_node import IntentNode
from app.processor.nodes.qa_node import QANode
from app.processor.nodes.reflection_node import ReflectionNode
from app.processor.nodes.requirement_node import RequirementNode
from app.processor.nodes.scope_node import ScopeNode
from app.processor.nodes.validator_node import ValidatorNode
from app.processor.nodes.screen_agent_node import ScreenAgentNode
from app.processor.nodes.wait_for_user_node import WaitForUserNode
from app.processor.state import QueryState
@@ -30,19 +21,12 @@ def build_checkpoint_serde() -> JsonPlusSerializer:
allowed_msgpack_modules=[
("app.schemas.input_context", "InputContext"),
("app.schemas.decision", "DecisionResponse"),
("app.schemas.requirement", "Requirement"),
("app.schemas.change_scope", "ChangeScope"),
("app.schemas.screen_plan", "ScreenPlan"),
("app.schemas.component_resource", "ComponentResult"),
("app.schemas.actions.add", "AddAction"),
("app.schemas.actions.delete", "DeleteAction"),
("app.schemas.actions.move", "MoveAction"),
("app.schemas.actions.resize", "ResizeAction"),
("app.schemas.actions.update", "UpdateAction"),
("app.schemas.actions.update_canvas", "UpdateCanvasAction"),
("app.schemas.action_validation", "ActionValidationError"),
("app.schemas.action_repair", "ActionRepairPlan"),
("app.schemas.screen", "Screen"),
("app.schemas.clarification", "Clarification"),
("app.schemas.clarification_reply", "ClarificationReply"),
]
@@ -50,221 +34,53 @@ def build_checkpoint_serde() -> JsonPlusSerializer:
def route_after_intent(state: QueryState) -> str:
# 按最终意图分流:问答进入 QA,意图不明进入澄清,新建或修改进入需求理解
return state["intent"]
return "qa_node" if state["intent"] == "qa" else "screen_agent_node"
def route_after_requirement(state: QueryState) -> str:
# 需求输出校验失败直接收尾,其余按落地策略的决定分流
if state["status"] == "failed":
return "fallback_node"
outcome = state["requirement_outcome"]
if outcome == "inform":
# 目标不存在时直接告知用户,不执行也不追问
return "qa_node"
if outcome == "clarify":
# 候选选项已由策略生成时直接等待用户,否则由澄清节点组织问题
return "wait_for_user_node" if state.get("clarification") else "clarify_node"
return "scope_node"
def route_after_agent(state: QueryState) -> str:
return "wait_for_user_node" if state["status"] == "waiting_for_input" else END
def route_after_wait(state: QueryState) -> str:
# 用户补充后回到输入节点合并上下文,取消则结束流程
# 用户回答后回到输入节点合并上下文,取消则结束
return "input_node" if state["status"] == "running" else END
# TODO 目前“资源”只有物料目录,数据源和 RAG 尚未准备好
# 接入后在 Requirement 中增加资源需求,与 needs_planning 作为两个独立开关:
# 规划 -> 按需查资源 -> design;无需规划但有资源需求 -> 查资源 -> action
# 并让 component_resource_node 按资源需求决定查什么,而不是固定全量目录
# 接入时把 capabilities.RESOURCE_QUERY_SUPPORTED 改为 True;在此之前 DesignNode
# 会清空资源需求并改用物料默认配置和示例数据,不因资源需求失败
def route_after_scope(state: QueryState) -> str:
# 已判定无法直接执行,失败收尾
if state["status"] == "failed":
return "fallback_node"
# 需要规划时先获取组件能力,供规划节点选择可用组件
if state["change_scope"].needs_planning:
return "component_resource_node"
# 已支持的简单修改直接生成动作,尚不支持的需求进入失败收尾
return "action_node" if can_generate_actions(state) else "fallback_node"
def build_graph(checkpointer=None):
# 最大重试次数
max_retries = settings.max_action_retries
def route_failure(state: QueryState) -> str:
# 上限检查,放在 Reflection 前,避免最后一次无效模型调用
return (
"reflection_node"
if state.get("retry_count", 0) < max_retries
else "fallback_node"
)
def route_after_action(state: QueryState) -> str:
# 如果动作节点已明确标记失败,直接收尾,不再进入内容修正
if state.get("status") == "failed":
return "fallback_node"
# validation_passed 为 False:动作生成时解析或结构校验失败,未达重试上限则进入修正分析,已达上限则失败收尾
# validation_passed 为 None:动作已生成但尚未模拟执行,进入 validator_node 检查动作范围和执行结果
# 必须用 is False 区分失败与尚未校验,不能将 None 也当作失败
return (
route_failure(state)
if state["validation_passed"] is False
else "validator_node"
)
def route_after_validation(state: QueryState) -> str:
# 整批动作通过校验则结束,否则根据剩余重试次数决定修正或失败收尾
return END if state["validation_passed"] else route_failure(state)
def route_after_reflection(state: QueryState) -> str:
# 获取失败分析产生的修正建议,生成建议失败时该字段可能为空
plan = state.get("repair_plan")
# 只有存在建议且明确允许重试时才重新生成动作,其余情况统一失败收尾
return "action_node" if plan is not None and plan.retryable else "fallback_node"
# 使用 QueryState 定义节点之间共享的状态结构
workflow = StateGraph(QueryState)
# 为本次构建的图创建节点实例,字典键同时作为注册名称和路由目标
nodes = {
"input_node": InputNode(),
"intent_node": IntentNode(),
"requirement_node": RequirementNode(),
"clarify_node": ClarifyNode(),
"wait_for_user_node": WaitForUserNode(),
"qa_node": QANode(),
"scope_node": ScopeNode(),
"design_node": DesignNode(),
"component_resource_node": ComponentResourceNode(),
"action_node": ActionNode(),
"validator_node": ValidatorNode(),
"reflection_node": ReflectionNode(),
"fallback_node": FallbackNode(),
"screen_agent_node": ScreenAgentNode(),
"wait_for_user_node": WaitForUserNode(),
}
# 注册节点,异步节点使用 BaseNode 的异步入口
for node_name, node in nodes.items():
# 根据 process 是否为异步函数选择调用入口,保留 BaseNode 的统一执行封装
# 异步节点使用 BaseNode 的异步入口,保留统一的日志和进度封装
action = node._acall if inspect.iscoroutinefunction(node.process) else node
# 将节点名称与实际调用对象绑定,供后面的边引用
workflow.add_node(node_name, action)
# 首次执行从输入节点开始,完成上下文校验后再判断意图
workflow.set_entry_point("input_node")
# 输入处理完成后固定进入意图判断
workflow.add_edge("input_node", "intent_node")
# 新建和修改进入需求理解,意图不明进入澄清
workflow.add_conditional_edges(
source="intent_node",
path=route_after_intent,
path_map={
"create_screen": "requirement_node",
"modify_screen": "requirement_node",
"qa": "qa_node",
"unclear": "clarify_node",
},
"intent_node",
route_after_intent,
{"qa_node": "qa_node", "screen_agent_node": "screen_agent_node"},
)
# 需求信息不足时提问,信息完整时继续判断修改范围
workflow.add_conditional_edges(
source="requirement_node",
path=route_after_requirement,
path_map={
"clarify_node": "clarify_node",
"wait_for_user_node": "wait_for_user_node",
"qa_node": "qa_node",
"scope_node": "scope_node",
"fallback_node": "fallback_node",
},
)
# 先保存澄清问题和等待状态,再进入调用 interrupt 的节点暂停执行
workflow.add_edge("clarify_node", "wait_for_user_node")
# 问答生成文本后直接结束,不进入规划和动作流程
workflow.add_edge("qa_node", END)
# 先读取组件能力再规划,规划后的业务数据查询后续接入
workflow.add_conditional_edges(
"scope_node",
route_after_scope,
{
"component_resource_node": "component_resource_node",
"action_node": "action_node",
"fallback_node": "fallback_node",
},
"screen_agent_node",
route_after_agent,
{"wait_for_user_node": "wait_for_user_node", END: END},
)
workflow.add_conditional_edges(
"wait_for_user_node", route_after_wait, {"input_node": "input_node", END: END}
)
# 组件目录查询成功才允许设计,未找到或不支持时进入失败收尾
workflow.add_conditional_edges(
"component_resource_node",
lambda state: state["component_resources"].status == "resolved",
{True: "design_node", False: "fallback_node"},
)
# 设计(规划和动作一次生成)与动作生成的结果走同样的去向:校验、修正分析或失败收尾
# 修正时规划已定,由 action_node 只重新生成动作
for node_name in ("design_node", "action_node"):
workflow.add_conditional_edges(
node_name,
route_after_action,
{
"validator_node": "validator_node",
"reflection_node": "reflection_node",
"fallback_node": "fallback_node",
},
)
# 校验成功结束,失败则在重试上限内分析修正原因
workflow.add_conditional_edges(
"validator_node",
route_after_validation,
{
END: END,
"reflection_node": "reflection_node",
"fallback_node": "fallback_node",
},
)
# 修正分析允许重试时回到动作生成,形成生成、校验、分析的循环
workflow.add_conditional_edges(
"reflection_node",
route_after_reflection,
{
"action_node": "action_node",
"fallback_node": "fallback_node",
},
)
# 失败节点写入最终失败状态后,由回答节点向用户说明原因
workflow.add_edge("fallback_node", "qa_node")
# 用户补充后重新合并输入并判断意图,取消则直接结束
workflow.add_conditional_edges(
"wait_for_user_node",
route_after_wait,
{"input_node": "input_node", END: END},
)
# 仅在调用方未提供检查点存储时创建默认实现,外部传入的持久化存储(如 MongoDB)直接复用
# 调用方未提供检查点存储时使用进程内存,支持暂停恢复,进程退出后不保留
if checkpointer is None:
# 将检查点保存在当前进程内存中,支持暂停恢复,但进程退出后不会保留
checkpointer = InMemorySaver(serde=build_checkpoint_serde())
# 编译节点和边,并绑定检查点存储,供执行时按 thread_id 保存和恢复状态
return workflow.compile(checkpointer=checkpointer).with_config(
{
# 随修正上限增加图执行步数预算,每轮预留分析、生成、校验三步,至少允许 25 步
"recursion_limit": max(25, 12 + 3 * max_retries),
}
)
return workflow.compile(checkpointer=checkpointer)
# 模块加载时创建共享图实例,复用该实例的调用也共用其检查点存储
+7 -48
View File
@@ -1,53 +1,12 @@
"""意图门控的单一来源:根据分类结果、风险和澄清次数决定最终意图"""
"""意图门控:只回答"是不是明确的问答",其余都交给 screen agent,由它决定执行、追问还是说明做不了"""
from app.schemas.decision import ChoiceResult
from app.schemas.requirement import Intent
# 会修改大屏的意图,判错的代价高,使用较严格的阈值;问答判错代价低,使用宽松阈值
ACTION_INTENTS = ("create_screen", "modify_screen")
from app.schemas.decision import ChoiceResult, Route
def resolve_offer_response(
decision: ChoiceResult,
*,
accept_min_probability: float,
decline_min_probability: float,
) -> Intent | None:
"""用户回应上一轮提议时直接确定意图:同意即执行提议,拒绝即简短回复;其余交给常规意图判断"""
choice = decision.choice
probability = decision.probabilities.get(choice, decision.confidence)
if choice == "accept" and probability >= accept_min_probability:
return "modify_screen"
if choice == "decline" and probability >= decline_min_probability:
return "qa"
return None
def resolve_intent(
decision: ChoiceResult,
*,
action_min_probability: float,
qa_min_probability: float,
clarified_rounds: int,
max_clarify_rounds: int,
option_selected: bool,
) -> Intent:
"""按选中项自身的概率门控,confidence 只表示分布集中度,不作为判据
- 用户刚点选澄清选项时,选择本身已表达意图,直接沿用模型判断
- 模型判定 unclear 时始终澄清;仅因概率不足而存疑时,澄清次数用尽后退回问答
"""
choice = decision.choice
def resolve_intent(decision: ChoiceResult, *, qa_min_probability: float) -> Route:
"""按选中项自身的概率门控,confidence 只表示分布集中度,不作为判据"""
# 缺少该选项的概率时退回 confidence,避免因协议缺字段而误判
probability = decision.probabilities.get(choice, decision.confidence)
minimum = action_min_probability if choice in ACTION_INTENTS else qa_min_probability
# 模型明确判定语义不明时始终澄清,直到用户说清或取消
if choice == "unclear":
return "unclear"
if option_selected or probability >= minimum:
return choice # type: ignore[return-value]
# 只是把握不足时,澄清次数用尽后安全地退回问答,而不是继续追问
if clarified_rounds >= max_clarify_rounds:
probability = decision.probabilities.get(decision.choice, decision.confidence)
if decision.choice == "qa" and probability >= qa_min_probability:
return "qa"
return "unclear"
return "screen"
-91
View File
@@ -1,91 +0,0 @@
"""需要物料字段的模型调用:模型按需调用字段查询工具,最后按指定结构输出
动作生成和修正建议共用;字段查询走物料字段 MCP 服务,服务不可用时直接报错
"""
from typing import TypeVar
from langchain.agents import create_agent
from langchain.agents.middleware import wrap_model_call
from langchain.agents.structured_output import ToolStrategy
from langchain_core.messages import AIMessage, HumanMessage
from pydantic import BaseModel
from app.clients.llm import get_llm
from app.clients.material_mcp import get_material_tools
from app.config import settings
from app.processor.state import QueryState
from app.resources.component import get_catalog_cache
from app.resources.material_fields import list_field_groups
OutputModel = TypeVar("OutputModel", bound=BaseModel)
@wrap_model_call
async def _limit_field_queries(request, handler):
"""查询轮数用尽后只保留输出工具,模型必须给出结果"""
rounds = sum(isinstance(message, AIMessage) for message in request.messages)
if rounds >= settings.max_field_tool_rounds:
request = request.override(tools=[])
return await handler(request)
async def invoke_with_field_tools(
schema: type[OutputModel], system_prompt: str, content: str
) -> OutputModel | None:
"""返回结构化结果;模型未给出结果时返回 None
结果不符合 schema 时抛出 StructuredOutputValidationError,由调用方转换为校验错误
"""
agent = create_agent(
get_llm(),
await get_material_tools(),
system_prompt=system_prompt,
# 不在调用内自动重试,结构错误交给后续校验和修正流程,与不查字段时一致
response_format=ToolStrategy(schema, handle_errors=False),
middleware=[_limit_field_queries],
name=f"{schema.__name__}_with_fields",
)
result = await agent.ainvoke(
{"messages": [HumanMessage(content=content)]},
# 每轮模型调用和工具执行各占一步,轮数由中间件限制,这里只防止异常循环
config={"recursion_limit": 2 * settings.max_field_tool_rounds + 6},
)
return result.get("structured_response")
def field_group_directory(state: QueryState) -> dict[str, dict]:
"""本次相关组件的字段分组目录,按物料类型列出,省去模型先查目录的一轮调用"""
nodes = {node.id: node.type for node in state["input_context"].screen.nodes}
plan = state.get("screen_plan")
if plan is None:
types = [
nodes[target]
for change in state["requirement"].changes
if change.target_scope == "nodes"
for target in change.target_ids
if target in nodes
]
else:
types = [
item.component_type
if item.disposition in ("add", "replace")
else nodes.get(item.target_id)
for item in plan.components
if item.disposition in ("add", "replace", "modify")
]
catalog = get_catalog_cache()
directory = {}
for component_type in dict.fromkeys(item for item in types if item):
try:
if catalog is None:
raise ValueError("物料目录未载入")
directory[component_type] = {
key: value
for key, value in list_field_groups(catalog, component_type).items()
if key not in ("component_type", "catalog_version")
}
except ValueError as error:
# 没有物料定义的组件不能修改属性,告诉模型原因
directory[component_type] = {"error": str(error)}
return directory
-228
View File
@@ -1,228 +0,0 @@
"""将简单修改或包含新增、调整的规划转换为动作列表"""
import json
from langchain.agents.structured_output import (
StructuredOutputError,
StructuredOutputValidationError,
)
from langchain_core.exceptions import OutputParserException
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import ValidationError
from app.actions.plan import assemble_plan_actions, plan_block_reason, plan_materials
from app.clients.llm import get_llm
from app.processor.base import BaseNode
from app.processor.material_agent import field_group_directory, invoke_with_field_tools
from app.processor.state import QueryState
from app.prompt.action import build_action_prompt, uses_material_fields
from app.schemas.action_validation import ActionValidationError, schema_errors
from app.schemas.actions import ActionBatch, AddAction
from app.schemas.operations import is_direct_operation
GENERATION_ERROR = ActionValidationError(
code="generation_error",
message="模型输出无法解析为 ActionBatch,请按协议重新生成",
)
def can_generate_actions(state: QueryState) -> bool:
# 没有阻塞原因时才允许生成动作,路由和节点入口复用同一套能力检查
return action_block_reason(state) is None
def action_block_reason(state: QueryState) -> str | None:
# 如果已有大屏规划,按规划检查资源、目标和操作是否受支持
if state.get("screen_plan") is not None:
return plan_block_reason(state)
# 没有规划时,读取需求和范围,检查是否可以直接生成简单修改动作
requirement = state["requirement"]
scope = state["change_scope"]
# 如果需求尚未澄清完整,不能猜测用户意图并生成动作
if requirement.needs_clarification:
return "需求尚未澄清完整,不能生成动作"
# 如果需要规划或不是修改已有大屏,则必须先完成规划流程
if scope.needs_planning or requirement.intent != "modify_screen":
return "当前需求需要先完成规划,不能直接生成动作"
# 如果没有具体变更,就没有可转换为动作的需求
if not requirement.changes:
return "需求中没有可执行的变更"
# 收集不支持的目标范围与操作组合,防止只执行需求中受支持的部分
# 直接操作仅支持下列已有节点修改和画布属性修改
unsupported = [
f"{change.target_scope}/{change.operation}"
for change in requirement.changes
if not is_direct_operation(change.target_scope, change.operation)
]
# 如果存在不支持的操作,按出现顺序去重后返回原因,便于展示失败信息
if unsupported:
return "当前尚未支持的直接操作:" + "、".join(dict.fromkeys(unsupported))
# 所有检查通过,返回 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"
async def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
# 如果已有规划但不满足执行条件,记录原因并直接结束本节点,避免无效模型调用
if state.get("screen_plan") is not None and (
reason := plan_block_reason(state)
):
return block_actions(updated_state, reason)
# 将输入上下文、结构化需求和修改范围转成字典,作为本次模型输入
payload = {
"input_context": state["input_context"].model_dump(mode="json"),
"requirement": state["requirement"].model_dump(mode="json"),
"change_scope": state["change_scope"].model_dump(mode="json"),
}
# 如果已有规划,补充规划内容及新增组件需要的物料配置
if state.get("screen_plan") is not None:
payload["screen_plan"] = state["screen_plan"].model_dump(mode="json")
payload["component_materials"] = plan_materials(state)
# 如果本轮是动作修正,提供修正建议、上一轮动作和校验错误供模型参考
if state.get("repair_plan") is not None:
payload.update(
repair_plan=state["repair_plan"].model_dump(mode="json"),
# 上一轮可能在解析阶段就失败,没有动作时按空列表传入
previous_actions=[
action.model_dump(mode="json")
for action in state.get("actions") or []
],
validation_errors=[
error.model_dump() for error in state.get("validation_errors", [])
],
)
system_prompt = build_action_prompt(
state["requirement"],
state.get("screen_plan"),
repairing=state.get("repair_plan") is not None,
)
# 需要写组件属性时提供相关组件的字段分组,模型再按需查询字段定义
with_fields = uses_material_fields(
state["requirement"], state.get("screen_plan")
)
if with_fields:
payload["field_groups"] = field_group_directory(state)
# 将本次具体资料放入用户消息,保留中文以便阅读和排查
content = json.dumps(payload, ensure_ascii=False)
# 旧动作及错误已放入模型输入,现在清空输出状态,避免本轮失败后残留旧结果
reset_action_output(updated_state)
# 捕获输出结构和解析错误,转换成统一校验结果供后续修正流程使用
try:
# 异步调用模型,根据需求、规划及是否正在修正选择对应的系统规则
if with_fields:
result = await invoke_with_field_tools(
ActionBatch, system_prompt, content
)
else:
result = await get_llm(ActionBatch).ainvoke(
[
SystemMessage(content=system_prompt),
HumanMessage(content=content),
]
)
batch = accept_batch(state, result)
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)
-80
View File
@@ -1,80 +0,0 @@
"""整理澄清问题,等待用户补充"""
import json
from langchain_core.exceptions import OutputParserException
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import ValidationError
from app.clients.llm import get_llm
from app.processor.base import BaseNode
from app.processor.state import QueryState
from app.prompt.clarify import CLARIFY_SYSTEM_PROMPT
from app.schemas.clarification import Clarification
class ClarifyNode(BaseNode):
name = "clarify_node"
async def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态,保存澄清问题和等待状态时不直接修改传入 state 的顶层字段
updated_state: QueryState = state.copy()
# 获取上游确认的意图,区分意图不明确和具体需求缺少信息两种澄清场景
intent = state["intent"]
# 整理意图和输入上下文,让模型结合用户原话、大屏及已有澄清记录生成问题
payload = {
"intent": intent,
"input_context": state["input_context"].model_dump(mode="json"),
}
# 如果意图已明确,补充需求节点的结果,围绕尚缺的信息提问
if intent != "unclear":
# 以需求节点的问题为依据补齐选项,不重新判断意图
payload["requirement"] = state["requirement"].model_dump(mode="json")
llm_client = get_llm(Clarification)
# 系统消息提供稳定的澄清规则,用户消息承载本次需要分析的具体资料
messages = [
SystemMessage(content=CLARIFY_SYSTEM_PROMPT),
HumanMessage(content=json.dumps(payload, ensure_ascii=False)),
]
# 最多调用两次,仅在输出结构或解析失败时重试一次,其他异常继续向上抛出
for attempt in range(2):
try:
# 异步生成澄清问题和候选项,成功后立即退出重试循环
clarification = await llm_client.ainvoke(messages)
break
except (ValidationError, OutputParserException):
# 如果首次生成格式不合法,追加明确的格式要求,再调用一次模型
if attempt == 0:
# 要求重新生成完整问题,避免截断选项改变原意
messages.append(
HumanMessage(
content=(
"上次输出未通过 Clarification 格式校验,请重新生成完整结果:"
"question 为非空问题,options 为空或含 2–3 项,"
"每项包含唯一 id、非空 label 和 description,最多一个 recommended=true"
)
)
)
# 进入下一次尝试,使用包含格式纠正要求的消息列表重新生成
continue
# 意图不明确时不读取需求,避免使用尚未生成或不适用的需求结果
requirement = state.get("requirement") if intent != "unclear" else None
# 优先使用需求节点已有的澄清问题,没有可用问题时使用通用询问
question = (
requirement.clarification_question if requirement else None
) or "你希望我对当前大屏做什么?可以描述想修改的内容,或想了解的问题"
# 只构建问题,不提供候选项,让用户自由补充,避免继续依赖模型格式修正
clarification = Clarification(question=question)
# 保存模型生成或兜底构建的澄清内容,供后续暂停流程展示给用户
updated_state["clarification"] = clarification
# 在进入 interrupt 前保存等待状态,暂停中的节点尚不会返回更新
updated_state["status"] = "waiting_for_input"
# 返回澄清内容和等待状态,实际暂停由后续调用 interrupt 的节点完成
return updated_state
@@ -1,34 +0,0 @@
"""为规划提供前端实际支持的物料目录"""
from app.processor.base import BaseNode
from app.processor.state import QueryState
from app.resources.component import ComponentProvider
from app.schemas.component_resource import ComponentQuery
class ComponentResourceNode(BaseNode):
name = "component_resource_node"
def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
# 空查询不设置筛选条件,由提供器读取默认组件目录并返回查询结果
result = ComponentProvider().query(ComponentQuery())
# 保存组件定义、目录版本和查询状态,供后续规划和动作生成使用
updated_state["component_resources"] = result
# 清空旧规划,后续需要根据本次查询到的组件能力重新规划
updated_state["screen_plan"] = None
# 如果目录未配置或没有查到组件,标记失败,避免基于未知组件能力继续规划
if result.status != "resolved":
updated_state["status"] = "failed"
# 沿用查询结果中的说明,保留无法获取组件的具体原因
updated_state["failure_reason"] = result.message
else:
# 如果已获取组件定义,保持运行状态,让后续节点继续规划
updated_state["status"] = "running"
# 清空旧的失败原因,避免成功查询后仍携带上一轮错误
updated_state["failure_reason"] = None
# 返回资源查询结果和处理状态,由图决定继续规划还是进入失败处理
return updated_state
-163
View File
@@ -1,163 +0,0 @@
"""将需要规划的需求一次设计成规划和动作,修正重试仍交给 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)
-44
View File
@@ -1,44 +0,0 @@
"""统一收尾能力不足、资源缺失和动作修正失败
这里只整理内部失败原因,面向用户的说明由回答节点交给模型总结,代码中不写死给用户的文案
"""
from app.processor.base import BaseNode
from app.processor.state import QueryState
def failure_notice(state: QueryState) -> str:
"""汇总上游记录的失败原因、最近一次修正分析和校验错误,作为交给模型的事实说明"""
facts = []
if reason := state.get("failure_reason"):
facts.append(reason)
plan = state.get("repair_plan")
if plan is not None and plan.reason not in facts:
facts.append(f"失败分析:{plan.reason}")
if errors := state.get("validation_errors"):
facts.append("校验错误:" + ";".join(error.message for error in errors))
facts.append(f"已自动修正 {state.get('retry_count', 0)} 次")
detail = "\n".join(facts) if facts else "没有记录具体原因"
return f"本次修改请求没有执行,内部原因如下:\n{detail}"
class FallbackNode(BaseNode):
name = "fallback_node"
def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
notice = failure_notice(state)
self.logger.warning(notice)
# 将任务标记为最终失败,由回答节点向用户说明原因
updated_state["status"] = "failed"
updated_state["failure_reason"] = notice
updated_state["notice"] = notice
# 显式标记校验未通过,防止残留动作被当作可执行结果
updated_state["validation_passed"] = False
# 清空旧模拟快照,避免失败结果中仍携带之前的成功快照
updated_state["simulated_screen"] = None
# 返回统一的失败状态,保留原动作和错误信息供排查
return updated_state
+34 -78
View File
@@ -1,4 +1,4 @@
"""输入解析与校验,可直接运行本文件修改示例进行调试"""
"""输入解析与校验;澄清恢复时把用户的回答合并进上下文"""
from app.processor.base import BaseNode
from app.processor.state import QueryState
@@ -11,89 +11,45 @@ class InputNode(BaseNode):
name = "input_node"
def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
# 获取待合并的用户澄清回复
reply = state.get("clarification_reply")
# 如果有澄清回复,将补充信息合并到已有上下文后重新处理
if reply is not None:
# 将已有上下文转成独立的字典,避免直接修改原上下文中的嵌套数据
data = state["input_context"].model_dump(mode="json")
# 记录本轮澄清问题和用户回答,供后续节点理解补充需求
turn = {
"question": state["clarification"].question,
"answer": reply.answer,
}
# 如果用户选择了候选项,按选项 ID 查找并保存完整选项内容
if reply.option_id is not None:
option = next(
option
for option in state["clarification"].options
if option.id == reply.option_id
)
turn["selected_option"] = option.model_dump(mode="json")
# 将本轮问答追加到历史中,保留多轮澄清信息
data["clarification_history"].append(turn)
# 如果回复携带最新大屏快照,用它替换旧快照
if reply.screen is not None:
data["screen"] = reply.screen.model_dump(mode="json")
# 如果没有同时提供交互上下文,清空可能已失效的选中节点和版本号
if reply.context is None:
data["context"]["selected_node_ids"] = []
data["context"]["screen_revision"] = None
# 如果回复携带交互上下文,使用本次提交的选中节点和版本等信息
if reply.context is not None:
data["context"] = reply.context.model_dump(mode="json")
# 重新校验合并后的数据并构建上下文,校验失败时向上抛出 ValidationError
context = InputContext.model_validate(data)
# 保存新上下文并清空已消费的回复,避免后续重复合并
updated_state["input_context"] = context
updated_state["clarification_reply"] = None
# 清空旧意图、需求和规划,让后续节点根据补充信息重新推导
# LangGraph 的状态合并不会删除省略字段,因此需要显式赋空值
updated_state["intent_decision"] = None
updated_state["intent"] = None
updated_state["requirement"] = None
updated_state["requirement_outcome"] = None
updated_state["notice"] = None
updated_state["offer"] = None
updated_state["accepted_offer"] = None
updated_state["change_scope"] = None
updated_state["screen_plan"] = None
updated_state["component_resources"] = None
# 清空旧动作及其校验、模拟结果,避免复用补充需求前的处理结果
updated_state["actions"] = None
updated_state["validation_passed"] = None
updated_state["validation_errors"] = []
updated_state["simulated_screen"] = None
# 重置自动修正次数和失败信息,让本轮处理重新计算重试次数
updated_state["retry_count"] = 0
updated_state["repair_plan"] = None
updated_state["failure_reason"] = None
# 清空上一轮澄清问题和回答,恢复任务处理状态
updated_state["clarification"] = None
updated_state["answer"] = None
if reply is None:
# 首次请求:校验原始输入,并深拷贝隔离提交时的快照
updated_state["input_context"] = InputContext.model_validate(
state["request"]
).model_copy(deep=True)
updated_state["status"] = "running"
# 返回澄清恢复后的状态,不再执行下方首次请求的初始化逻辑
return updated_state
# 没有澄清回复时,按首次请求校验原始输入,并深拷贝隔离提交时的快照
context = InputContext.model_validate(state["request"]).model_copy(deep=True)
data = state["input_context"].model_dump(mode="json")
# 记录本轮澄清问题和用户回答;选了选项时保存完整选项
turn = {"question": state["clarification"].question, "answer": reply.answer}
if reply.option_id is not None:
option = next(
option
for option in state["clarification"].options
if option.id == reply.option_id
)
turn["selected_option"] = option.model_dump(mode="json")
data["clarification_history"].append(turn)
# 保存校验后的上下文,供后续节点统一读取
updated_state["input_context"] = context
# 初始化自动修正次数,并清空修正建议和失败原因
updated_state["retry_count"] = 0
updated_state["repair_plan"] = None
updated_state["failure_reason"] = None
# 首次请求进入处理状态,恢复澄清时也使用同一状态
# 回复携带最新大屏时替换旧快照;没有同时提供交互上下文时,清空可能已失效的选中节点和版本号
if reply.screen is not None:
data["screen"] = reply.screen.model_dump(mode="json")
if reply.context is None:
data["context"]["selected_node_ids"] = []
data["context"]["screen_revision"] = None
if reply.context is not None:
data["context"] = reply.context.model_dump(mode="json")
updated_state["input_context"] = InputContext.model_validate(data)
# LangGraph 的状态合并不会删除省略字段,需要显式清空上一轮的结果,再重新判断意图
updated_state["clarification_reply"] = None
updated_state["intent_decision"] = None
updated_state["intent"] = None
updated_state["actions"] = None
updated_state["clarification"] = None
updated_state["answer"] = None
updated_state["status"] = "running"
# 返回初始化后的状态,由图接续执行后续节点
return updated_state
+15 -130
View File
@@ -1,143 +1,28 @@
"""意图识别节点"""
"""意图识别节点:Jev 判断这次请求是不是明确的问答"""
from app.clients.decision import get_decision_engine
from app.processor.base import BaseNode
from app.processor.intent_policy import resolve_intent, resolve_offer_response
from app.processor.intent_policy import resolve_intent
from app.processor.state import QueryState
from app.prompt.intent import build_intent_question, build_offer_question
from app.schemas.base import JsonObject
from app.processor.summary import request_summary
from app.prompt.intent import build_intent_question
from app.schemas.decision import DecisionRequest
from app.schemas.input_context import InputContext
class IntentNode(BaseNode):
name = "intent_node"
@staticmethod
def build_decision_state(context: InputContext) -> JsonObject:
"""只整理判断意图需要的摘要,完整 Screen 仍保留在输入上下文"""
# 收集每个组件的识别信息和布局,帮助判断用户指的是哪个组件
nodes: list[JsonObject] = []
for node in context.screen.nodes:
# 获取图表配置,组件没有 option 时返回 None
option = node.props.get("option")
# 只有字典配置才能读取 title,其他类型按没有标题处理
title = option.get("title") if isinstance(option, dict) else None
# 将单个标题统一包装成列表,兼容图表配置中的多个标题
titles = title if isinstance(title, list) else [title]
# 保存组件摘要,不传递完整 props,减少意图判断时的无关信息
nodes.append(
{
"id": node.id,
"type": node.type,
"name": node.name,
# 仅提取字典中字符串类型的标题文本,忽略缺失或格式不符的标题
"titles": [
item["text"]
for item in titles
if isinstance(item, dict) and isinstance(item.get("text"), str)
],
# 将布局转成可序列化的字典,只保留显式设置的字段
"layout": node.layout.model_dump(mode="json", exclude_unset=True),
}
)
# 汇总用户需求、澄清历史、交互上下文和大屏摘要,作为意图判断的输入
return {
"prompt": context.prompt,
"history": context.history,
"clarification_history": context.clarification_history,
"context": context.context.model_dump(mode="json", exclude_unset=True),
"screen_summary": {
"canvas": {
"width": context.screen.canvas.width,
"height": context.screen.canvas.height,
},
"nodes": nodes,
# 数据源只保留已有的标识、名称和类型,省略具体数据及其他配置
"data_sources": [
{
key: source[key]
for key in ("id", "name", "type")
if key in source
}
for source in context.screen.dataSource
],
},
# 附件只保留已有的标识、类别和名称,用于判断请求涉及哪些附件
"attachments": [
{
key: attachment[key]
for key in ("id", "kind", "name")
if key in attachment
}
for attachment in context.attachments
],
}
@staticmethod
def pending_offer(context: InputContext) -> str | None:
"""只有紧接着的上一轮留下的提议才等待确认;澄清回答针对的是澄清问题,不是提议"""
if context.clarification_history or not context.history:
return None
offer = context.history[-1].get("offer")
return offer if isinstance(offer, str) and offer.strip() else None
async def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
# 获取输入节点已校验的上下文,包含最新大屏和澄清历史
context = state["input_context"]
# 上一轮助手提出了待确认的修改,且本轮不是澄清回答时,额外判断用户是否同意
offer = self.pending_offer(context)
decision_state = self.build_decision_state(context)
# 使用 intent 作为决策名称,后续通过同名键读取判断结果
decisions = {"intent": build_intent_question()}
if offer is not None:
decision_state["pending_offer"] = offer
decisions["offer_response"] = build_offer_question()
# 构建 Jev 决策请求,同一次调用提交全部判断
request = DecisionRequest(state=decision_state, decisions=decisions)
# 获取决策引擎,用于执行本次意图判断
decision_engine = get_decision_engine()
# 异步等待 Jev 返回决策结果,包含选项、置信度和调用信息
decision_res = await decision_engine.run(request)
# 从响应中取出 intent 对应的判断结果
decision = decision_res.results["intent"]
# 用户明确回应了上一轮提议时,直接确定意图,不再经过常规门控
intent = None
if offer is not None:
intent = resolve_offer_response(
decision_res.results["offer_response"],
accept_min_probability=self.config.intent_min_probability,
decline_min_probability=self.config.intent_qa_min_probability,
)
# 同意时把提议交给需求理解,作为本次要执行的修改
updated_state["accepted_offer"] = offer if intent == "modify_screen" else None
if intent is None:
# 用户刚点选了澄清选项时,选择本身已表达明确意图
history = context.clarification_history
# 按选中项概率、风险和澄清轮数确定最终意图,规则集中在 intent_policy
intent = resolve_intent(
decision,
action_min_probability=self.config.intent_min_probability,
qa_min_probability=self.config.intent_qa_min_probability,
clarified_rounds=len(history),
max_clarify_rounds=self.config.intent_max_clarify_rounds,
option_selected=bool(history and history[-1].get("selected_option")),
)
# 完整保留原始决策,便于查看模型选项及其置信度
updated_state["intent_decision"] = decision_res
# 保存经过置信度判断的最终意图,供后续路由选择处理分支
updated_state["intent"] = intent
# 返回更新后的状态,由图继续执行对应的处理节点
request = DecisionRequest(
state=request_summary(state["input_context"]),
decisions={"intent": build_intent_question()},
)
response = await get_decision_engine().run(request)
# 完整保留原始决策,便于在追踪中查看各选项的概率
updated_state["intent_decision"] = response
updated_state["intent"] = resolve_intent(
response.results["intent"],
qa_min_probability=self.config.intent_qa_min_probability,
)
return updated_state
+24 -79
View File
@@ -1,6 +1,4 @@
"""基于当前大屏和澄清历史回答问题"""
import json
"""基于当前大屏和会话历史流式回答问题"""
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.output_parsers import StrOutputParser
@@ -9,15 +7,13 @@ from langgraph.config import get_stream_writer
from app.clients.llm import get_llm
from app.processor.base import BaseNode
from app.processor.state import QueryState
from app.prompt.qa import OFFER_EXTRACT_PROMPT, QA_SYSTEM_PROMPT
from app.schemas.offer import ExtractedOffer, InformReply
from app.prompt.qa import QA_SYSTEM_PROMPT
class QANode(BaseNode):
name = "qa_node"
async def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
try:
@@ -26,83 +22,32 @@ class QANode(BaseNode):
except RuntimeError:
writer = None
def emit(delta: str) -> None:
if writer is not None:
writer(
{
"type": "answer_delta",
"message_id": state.get("message_id"),
"delta": delta,
}
)
content = state["input_context"].model_dump_json(exclude_unset=True)
offer = None
# 请求无法执行或执行失败时,附上事实说明,由本节点如实告知用户
if notice := state.get("notice"):
content += f"\n\n系统说明:{notice}"
# 回复中的替代修改需要结构化保存,下一轮用户同意时直接执行,因此不流式输出
reply = await get_llm(InformReply).ainvoke(
[SystemMessage(content=QA_SYSTEM_PROMPT), HumanMessage(content=content)]
)
answer, offer = reply.answer.strip(), reply.offer
emit(answer)
else:
answer = await self._stream(content, emit)
# 如果没有有效文本,抛出异常,避免将空回答标记为处理完成
if not answer:
raise ValueError("问答模型未返回文本回答")
# 回答已流式输出,再提取其中等待用户确认的修改,下一轮同意时直接执行
offer = await self._extract_offer(state["input_context"].prompt, answer)
# 保存最终回答,供后续输出给用户
updated_state["answer"] = answer
# 保存等待用户确认的提议,没有提议时清空
updated_state["offer"] = offer
# 回答生成完成;失败收尾后的说明保持失败状态,不交付动作
if state.get("status") != "failed":
updated_state["status"] = "completed"
# 返回包含回答和完成状态的更新结果
return updated_state
async def _extract_offer(self, prompt: str, answer: str) -> str | None:
try:
result = await get_llm(ExtractedOffer).ainvoke(
[
SystemMessage(content=OFFER_EXTRACT_PROMPT),
HumanMessage(
content=json.dumps(
{"user": prompt, "assistant": answer}, ensure_ascii=False
)
),
]
)
except Exception as error:
# 回答已交付,提取失败只影响下一轮能否直接执行,不让本轮失败
self.logger.warning(f"提取回答中的提议失败: {error!r}")
return None
return result.offer
@staticmethod
async def _stream(content: str, emit) -> str:
# 获取普通对话模型,问答只需文本回答,不绑定结构化输出协议
llm_client = get_llm()
parser = StrOutputParser()
parts: list[str] = []
# 异步流式调用模型,结合用户问题、当前大屏和澄清记录生成回答
async for chunk in llm_client.astream(
async for chunk in get_llm().astream(
[
# 系统消息提供稳定的问答规则和回答边界
SystemMessage(content=QA_SYSTEM_PROMPT),
# 输入上下文序列化为 JSON,只保留显式设置的字段
HumanMessage(content=content),
HumanMessage(
content=state["input_context"].model_dump_json(exclude_unset=True)
),
]
):
# 从每个片段提取文本,兼容文本内容块,工具调用等无文本片段会得到空串
delta = parser.invoke(chunk)
if delta:
# 工具调用等无文本片段会得到空串
if delta := parser.invoke(chunk):
parts.append(delta)
emit(delta)
# 拼接完整回答,并去除首尾的空白
return "".join(parts).strip()
if writer is not None:
writer(
{
"type": "answer_delta",
"message_id": state.get("message_id"),
"delta": delta,
}
)
answer = "".join(parts).strip()
# 没有有效文本时报错,避免将空回答标记为处理完成
if not answer:
raise ValueError("问答模型未返回文本回答")
updated_state["answer"] = answer
updated_state["status"] = "completed"
return updated_state
-96
View File
@@ -1,96 +0,0 @@
"""分析失败动作,生成下一次修正建议"""
import json
from langchain.agents.structured_output import StructuredOutputError
from langchain_core.exceptions import OutputParserException
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import ValidationError
from app.actions.plan import plan_materials
from app.clients.llm import get_llm
from app.processor.base import BaseNode
from app.processor.material_agent import field_group_directory, invoke_with_field_tools
from app.processor.state import QueryState
from app.prompt.action import FIELD_QUERY_PROMPT, uses_material_fields
from app.prompt.reflection import REFLECTION_SYSTEM_PROMPT
from app.schemas.action_repair import ActionRepairPlan
class ReflectionNode(BaseNode):
name = "reflection_node"
async def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
# 汇总原始上下文、需求和失败信息,让模型基于实际错误提出修正建议
payload = {
"input_context": state["input_context"].model_dump(mode="json"),
"requirement": state["requirement"].model_dump(mode="json"),
# 动作可能在生成或解析阶段就失败,没有动作时传入空列表
"actions": [
action.model_dump(mode="json") for action in state.get("actions") or []
],
# 将校验错误转成字典,保留错误类型、动作下标和具体原因
"validation_errors": [
error.model_dump() for error in state["validation_errors"]
],
# 如果已有修正建议,一并提供给模型,帮助分析上次修正后仍失败的原因
"previous_repair_plan": state["repair_plan"].model_dump()
if state.get("repair_plan")
else None,
}
# 如果动作来自大屏规划,补充规划和新增组件物料,避免修正时偏离规划或物料定义
if state.get("screen_plan") is not None:
payload["screen_plan"] = state["screen_plan"].model_dump(mode="json")
payload["component_materials"] = plan_materials(state)
# 涉及组件属性时,修正建议须基于查询到的字段定义,与动作生成使用同一组工具
with_fields = uses_material_fields(
state["requirement"], state.get("screen_plan")
)
if with_fields:
payload["field_groups"] = field_group_directory(state)
# 用户消息承载本轮待分析的具体资料,序列化时保留中文
content = json.dumps(payload, ensure_ascii=False)
# 只捕获输出结构和解析错误,配置、网络等异常继续向上抛出
try:
# 异步生成修正建议,本节点不直接修改动作
if with_fields:
plan = await invoke_with_field_tools(
ActionRepairPlan,
REFLECTION_SYSTEM_PROMPT + "\n" + FIELD_QUERY_PROMPT,
content,
)
else:
plan = await get_llm(ActionRepairPlan).ainvoke(
[
SystemMessage(content=REFLECTION_SYSTEM_PROMPT),
HumanMessage(content=content),
]
)
# 响应中没有工具调用时解析器会返回 None
if plan is None:
raise OutputParserException("模型未返回结构化结果")
except (ValidationError, OutputParserException, StructuredOutputError):
# 如果未能得到合法建议,清空旧修正计划,避免再次使用已失效的建议
updated_state["repair_plan"] = None
# 记录无法继续修正的原因,交给后续路由和失败处理流程
updated_state["failure_reason"] = "未能生成有效的动作修正建议"
return updated_state
# 保存通过校验的修正建议,供后续路由判断及动作重新生成使用
updated_state["repair_plan"] = plan
# 仅在建议允许重试时将次数加一,缺少历史计数时从零开始,不可重试时保持原次数
updated_state["retry_count"] = state.get("retry_count", 0) + int(plan.retryable)
# 可以重试时清空旧失败原因;不可重试时保留分析,由失败收尾交给模型向用户说明
updated_state["failure_reason"] = (
None if plan.retryable else f"修正分析判断无法完成:{plan.reason}"
)
# 返回修正建议、重试次数和失败原因,由后续路由决定重新生成动作还是结束
return updated_state
-72
View File
@@ -1,72 +0,0 @@
"""沿用意图判断,提取具体需求"""
from langchain_core.messages import HumanMessage, SystemMessage
from app.clients.llm import get_llm
from app.processor.base import BaseNode
from app.processor.outcome_policy import decide_outcome
from app.processor.state import QueryState
from app.prompt.requirement import REQUIREMENT_SYSTEM_PROMPT, build_requirement_input
from app.schemas.requirement import Requirement
class RequirementNode(BaseNode):
name = "requirement_node"
async def process(self, state: QueryState) -> QueryState:
try:
return await self._generate(state)
except ValueError as error:
# 结构化输出解析失败或引用校验不通过(含 ValidationError、OutputParserException)
return self.fail_on_invalid_output(state, error, "需求理解的输出未通过校验")
async def _generate(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
# 获取输入节点已校验的上下文,包含用户需求、当前大屏和澄清记录
context = state["input_context"]
# 获取意图节点经过置信度判断的最终意图,本节点沿用该结果
intent = state["intent"]
llm_client = get_llm(Requirement)
# 异步调用模型,结合当前上下文提取目标、变更、约束及需要澄清的信息
llm_res = await llm_client.ainvoke(
[
# 系统消息提供稳定的需求提取规则和输出边界
SystemMessage(content=REQUIREMENT_SYSTEM_PROMPT),
# 用户消息承载本次需求、大屏、澄清记录和已确认的意图等具体资料
HumanMessage(
content=build_requirement_input(
context, intent, accepted_offer=state.get("accepted_offer")
)
),
]
)
# 用上游意图覆盖模型返回的 intent,避免需求提取阶段重新分类
# 覆盖后重新校验字段一致性,例如 unclear 必须要求澄清,qa 不能包含变更
requirement = Requirement.model_validate(
{**llm_res.model_dump(), "intent": intent}
)
# 收集当前大屏中真实存在的节点 ID,用于校验模型提取的变更目标
node_ids = {node.id for node in context.screen.nodes}
# 逐项检查需求变更,防止后续节点基于不存在的组件生成方案或动作
for change in requirement.changes:
# 用集合差集找出不在当前大屏中的目标 ID,非空时保存到 unknown_ids
if unknown_ids := set(change.target_ids) - node_ids:
# 如果存在无效目标,立即抛出异常,并排序输出 ID 以便定位问题
raise ValueError(f"需求引用了不存在的节点: {sorted(unknown_ids)}")
# 模型只负责理解需求,本轮执行、澄清还是直接回复统一由落地策略根据真实组件决定
decision = decide_outcome(requirement, context.screen)
# 保存落地后的需求,唯一候选已改写为明确目标,供后续澄清或范围判断使用
updated_state["requirement"] = decision.requirement
updated_state["requirement_outcome"] = decision.outcome
updated_state["notice"] = decision.notice
if decision.clarification is not None:
# 选项来自真实组件,无需再由澄清节点调用模型
updated_state["clarification"] = decision.clarification
updated_state["status"] = "waiting_for_input"
# 返回更新后的状态,由图继续执行后续节点
return updated_state
-27
View File
@@ -1,27 +0,0 @@
"""按需求变更推导修改范围和是否需要规划,不调用模型"""
from app.processor.base import BaseNode
from app.processor.nodes.action_node import action_block_reason
from app.processor.state import QueryState
from app.schemas.change_scope import ChangeScope
class ScopeNode(BaseNode):
name = "scope_node"
def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
# 需求节点已校验目标 ID 并落地唯一候选,范围直接由变更推导
scope = ChangeScope.from_requirement(state["requirement"])
# 保存范围,下面的动作能力检查也需要读取该结果
updated_state["change_scope"] = scope
# 需要规划时先交给后续规划流程,不在此处判断能否直接生成动作
# 无需规划时检查操作能力,无法执行则记录阻塞原因,通过则返回 None
reason = None if scope.needs_planning else action_block_reason(updated_state)
# 写入本次检查结果,没有阻塞原因时同时清空旧的失败说明
updated_state["failure_reason"] = reason
# 有阻塞原因时标记失败,否则保持运行状态,继续后续处理
updated_state["status"] = "failed" if reason else "running"
return updated_state
+76
View File
@@ -0,0 +1,76 @@
"""调用 screen agent,按它的回复和最后一次提交的结果写回状态
kind 由模型决定;代码只在"回复 done 但最后一次提交失败"时不采纳,因为此时没有能交付的动作
"""
import json
from langchain_core.messages import HumanMessage
from app.agent.screen_agent import AgentReply, get_screen_agent
from app.agent.tools import ScreenAgentContext
from app.processor.base import BaseNode
from app.processor.state import QueryState
from app.processor.summary import request_summary
from app.resources.component import get_catalog_cache
def reply_update(result: dict) -> QueryState:
"""把 agent 的运行结果转换成 QueryState 的更新"""
reply: AgentReply | None = result.get("structured_response")
if reply is None:
# 调用次数用尽等情况,agent 没有给出结论
return {
"status": "failed",
"answer": "处理步骤超出上限,这次没有完成,可以换个说法再试",
}
if reply.kind == "clarify":
return {"status": "waiting_for_input", "clarification": reply.clarification}
if reply.kind == "unable":
return {"status": "failed", "answer": reply.message}
if result.get("apply_error"):
return {
"status": "failed",
"answer": "修改没有通过校验,这次没有生效,可以换个说法再试",
}
return {
"status": "completed",
"actions": result.get("actions") or [],
"answer": reply.message,
}
class ScreenAgentNode(BaseNode):
name = "screen_agent_node"
async def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
catalog = get_catalog_cache()
if catalog is None:
updated_state["status"] = "failed"
updated_state["answer"] = "物料目录未载入,暂时无法新建或修改大屏"
return updated_state
context = state["input_context"]
agent = await get_screen_agent()
result = await agent.ainvoke(
{
"messages": [
HumanMessage(
content=json.dumps(request_summary(context), ensure_ascii=False)
)
]
},
context=ScreenAgentContext(screen=context.screen, catalog=catalog),
# 调用次数由 ModelCallLimitMiddleware 限制,这里只防止异常循环
config={"recursion_limit": 4 * self.config.max_agent_steps + 10},
)
if result.get("structured_response") is None:
last = result["messages"][-1]
self.logger.warning(
f"agent 没有给出结论: {last.type} {str(last.content)[:500]} "
f"tool_calls={getattr(last, 'tool_calls', None)} "
f"metadata={getattr(last, 'response_metadata', None)}"
)
updated_state.update(reply_update(result))
return updated_state
-195
View File
@@ -1,195 +0,0 @@
"""校验动作范围,再按顺序修改大屏 JSON 副本,检查整批动作能否执行"""
from pydantic import ValidationError
from app.actions.plan import validate_plan_actions, validate_plan_result
from app.actions.registry import apply_action
from app.actions.update import fit_existing_path
from app.processor.base import BaseNode
from app.processor.state import QueryState
from app.resources.component import get_catalog_cache
from app.resources.material_fields import check_update_field
from app.schemas.action_validation import ActionValidationError, schema_errors
from app.schemas.actions import (
ActionBatch,
AddAction,
UpdateAction,
UpdateCanvasAction,
)
from app.schemas.operations import is_canvas_operation
class ValidatorNode(BaseNode):
name = "validator_node"
def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
# 校验失败仍可能进入修正循环,最终失败由 Fallback 标记
updated_state["status"] = "running"
# 获取本轮待校验的动作,先检查结构,再检查范围和模拟执行结果
actions = state["actions"]
try:
# 转回字典重新校验,避免赋值后绕过字段校验
batch = ActionBatch.model_validate(
{"actions": [action.model_dump() for action in actions]}
)
except ValidationError as error:
# 将结构异常转换成统一错误列表,供后续修正流程定位问题
errors = schema_errors(error)
# 标记本轮未通过并清空旧模拟快照,避免后续读取过期结果
updated_state["validation_passed"] = False
updated_state["validation_errors"] = errors
updated_state["simulated_screen"] = None
return updated_state
requirement = state["requirement"]
# 如果已有规划,检查动作是否覆盖规划目标,以及是否修改了规划外的内容
if state.get("screen_plan") is not None:
errors = validate_plan_actions(state, batch)
# 如果规划动作检查失败,记录错误并返回,不继续模拟执行
if errors:
updated_state["validation_passed"] = False
updated_state["validation_errors"] = errors
updated_state["simulated_screen"] = None
return updated_state
else:
# 此分支没有规划,只校验简单修改,新增动作需要走上面的规划分支
if any(isinstance(action, AddAction) for action in batch.actions):
# 为每个新增动作记录下标和范围错误,便于修正时逐项定位
updated_state["validation_passed"] = False
updated_state["validation_errors"] = [
ActionValidationError(
action_index=index,
code="scope_error",
message="新增动作需要先经过规划,不能作为简单修改直接校验",
)
for index, action in enumerate(batch.actions)
if isinstance(action, AddAction)
]
updated_state["simulated_screen"] = None
return updated_state
# 将需求允许的节点操作表示为三元组:作用域、目标 ID、操作名称
allowed = {
("nodes", target, change.operation)
for change in requirement.changes
if change.target_scope == "nodes"
for target in change.target_ids
}
# 将需求中的画布更新和尺寸调整统一映射为 update_canvas 动作
allowed.update(
("canvas", "", "update_canvas")
for change in requirement.changes
if is_canvas_operation(change.target_scope, change.operation)
)
# 画布没有组件 ID,使用独立作用域避免与同名组件混淆
targets = [
("canvas", "", action.type)
if isinstance(action, UpdateCanvasAction)
else ("nodes", action.target_id, action.type)
for action in batch.actions
]
# 去重后的实际操作集合用于检查遗漏,原 targets 列表保留动作顺序和下标
actual = set(targets)
# 逐项找出需求未允许的目标或操作,防止模型扩大修改范围
errors = [
ActionValidationError(
action_index=index,
code="scope_error",
message=f"动作超出需求范围: {' / '.join(target)}",
)
for index, target in enumerate(targets)
if target not in allowed
]
# 如果需求允许的目标操作没有对应动作,记录遗漏,避免只执行部分需求
if missing := allowed - actual:
errors.append(
ActionValidationError(
code="scope_error",
message=f"动作遗漏了需求中的目标或操作: {sorted(missing)}",
)
)
# 如果存在越界或遗漏,返回全部范围错误,并清空旧模拟结果
if errors:
updated_state["validation_passed"] = False
updated_state["validation_errors"] = errors
updated_state["simulated_screen"] = None
return updated_state
# 在副本上试执行,不改变用户传入的大屏,也不操作前端画布
screen = state["input_context"].screen.model_copy(deep=True)
catalog = get_catalog_cache()
# 保存补齐路径后的动作,前端按它执行
actions = list(batch.actions)
# 按动作顺序逐项执行,每一步都使用上一步更新后的大屏副本
for index, action in enumerate(actions):
# 属性更新先按物料字段定义检查,目标不存在时交给处理器报错
node = (
next(
(item for item in screen.nodes if item.id == action.target_id), None
)
if isinstance(action, UpdateAction)
else None
)
if node is not None:
try:
check_update_field(catalog, node.type, action)
except ValueError as error:
updated_state["validation_passed"] = False
updated_state["validation_errors"] = [
ActionValidationError(
action_index=index, code="field_error", message=str(error)
)
]
updated_state["simulated_screen"] = None
return updated_state
# 字段定义允许、但组件中还没有的中间对象,折叠进值里再执行
action = actions[index] = fit_existing_path(node, action)
try:
# 根据动作类型调用对应处理逻辑,检查目标和参数并返回更新后的大屏
screen = apply_action(screen, action)
except ValueError as error:
# 首个失败即停止,后续动作可能依赖前序结果,不返回部分成功快照
updated_state["validation_passed"] = False
updated_state["validation_errors"] = [
ActionValidationError(
action_index=index, code="execution_error", message=str(error)
)
]
updated_state["simulated_screen"] = None
return updated_state
# 如果已有规划,还需核对整批执行后的大屏是否满足规划,不能只看单步执行成功
if state.get("screen_plan") is not None:
errors = validate_plan_result(state, screen)
# 如果最终结果与规划不符,记录错误并丢弃模拟快照,交给后续修正流程
if errors:
updated_state["validation_passed"] = False
updated_state["validation_errors"] = errors
updated_state["simulated_screen"] = None
return updated_state
# 全部检查通过后,保存重新校验并补齐路径的动作对象,统一后续读取的数据结构
updated_state["actions"] = actions
# 标记整批动作校验通过,并清空之前可能残留的错误
updated_state["validation_passed"] = True
updated_state["validation_errors"] = []
# 保存整批动作执行后的 JSON,供后续检查,不代表前端已经完成更新
updated_state["simulated_screen"] = screen
# 仅表示后端动作已通过校验,前端执行结果需另行反馈
updated_state["status"] = "completed"
# 返回动作、校验结果和完整模拟快照,供后续输出使用
return updated_state
-134
View File
@@ -1,134 +0,0 @@
"""需求落地策略的单一来源:模型只负责理解需求,本轮是执行、澄清还是直接回复由这里决定
决策依据是当前大屏里真实存在的组件,而不是模型对"要不要澄清"的判断:
- 新增组件或目标可唯一确定:执行,缺少的信息采用默认值
- 目标有多个候选:澄清,选项直接由候选组件生成,不由模型编造
- 目标没有任何候选:直接回复,说明原因并可建议替代做法,不执行也不追问
- 没有可执行的变更:交给澄清节点询问
"""
from dataclasses import dataclass
from typing import Literal
from app.schemas.clarification import Clarification, ClarificationOption
from app.schemas.requirement import Requirement, RequirementChange
from app.schemas.screen import Screen, ScreenNode
Outcome = Literal["execute", "clarify", "inform"]
# 默认值补齐的说明,模型提出了非阻塞问题但仍执行时写入 assumptions
DEFAULT_ASSUMPTION = "未明确的位置、尺寸、样式和数据采用默认值,数据使用物料示例数据"
# 候选超过选项上限时不提供选项,只让用户描述
MAX_OPTIONS = 3
@dataclass(frozen=True)
class OutcomeDecision:
outcome: Outcome
requirement: Requirement
# 已由候选组件生成的澄清问题;为 None 且 outcome 为 clarify 时由澄清节点生成
clarification: Clarification | None = None
# 直接回复时交给回答节点的事实说明
notice: str | None = None
def decide_outcome(requirement: Requirement, screen: Screen) -> OutcomeDecision:
nodes = {node.id: node for node in screen.nodes}
unresolved = [c for c in requirement.changes if c.target_scope == "unresolved"]
for change in unresolved:
if not _candidates(change, nodes):
return OutcomeDecision(
"inform", requirement, notice=_missing_target_notice(change, screen)
)
ambiguous = [c for c in unresolved if len(_candidates(c, nodes)) > 1]
if ambiguous:
change = ambiguous[0]
return OutcomeDecision(
"clarify",
requirement,
clarification=_candidate_question(
change, [nodes[i] for i in _candidates(change, nodes)], requirement
),
)
if not requirement.changes:
# 没有可执行的变更时才需要模型组织问题
outcome: Outcome = "clarify" if requirement.needs_clarification else "execute"
return OutcomeDecision(outcome, requirement)
# 所有目标均已确定:唯一候选改写为明确目标,模型提出的非阻塞问题改用默认值
changes = [
_resolve(change, _candidates(change, nodes)[0])
if change.target_scope == "unresolved"
else change
for change in requirement.changes
]
assumptions = list(requirement.assumptions)
if requirement.needs_clarification and DEFAULT_ASSUMPTION not in assumptions:
assumptions.append(DEFAULT_ASSUMPTION)
resolved = Requirement.model_validate(
{
**requirement.model_dump(),
"changes": [change.model_dump() for change in changes],
"needs_clarification": False,
"clarification_question": None,
"assumptions": assumptions,
}
)
return OutcomeDecision("execute", resolved)
def _candidates(change: RequirementChange, nodes: dict[str, ScreenNode]) -> list[str]:
# 只保留真实存在的组件,模型虚构的 ID 视为没有候选
return [
node_id for node_id in dict.fromkeys(change.candidate_ids) if node_id in nodes
]
def _resolve(change: RequirementChange, node_id: str) -> RequirementChange:
return RequirementChange.model_validate(
{
**change.model_dump(),
"target_scope": "nodes",
"target_ids": [node_id],
"candidate_ids": [],
}
)
def _missing_target_notice(change: RequirementChange, screen: Screen) -> str:
target = change.target_description or "目标组件"
if not screen.nodes:
state = "当前画布上还没有任何组件"
else:
names = "、".join(_label(node) for node in screen.nodes[:5])
state = f"当前画布上的组件有:{names}"
return f"本次请求无法执行:没有找到“{target}”对应的组件,{state}"
def _candidate_question(
change: RequirementChange, candidates: list[ScreenNode], requirement: Requirement
) -> Clarification:
target = change.target_description or "组件"
question = requirement.clarification_question or f"你指的是哪个{target}?"
if len(candidates) > MAX_OPTIONS:
return Clarification(question=question)
# 选项 ID 使用组件 ID,用户点选后需求理解可直接定位目标
return Clarification(
question=question,
options=[
ClarificationOption(
id=node.id,
label=_label(node),
description=f"{node.type},位于 ({node.layout.x:g}, {node.layout.y:g})",
)
for node in candidates
],
)
def _label(node: ScreenNode) -> str:
return node.name or node.type
+1 -6
View File
@@ -12,13 +12,8 @@ from app.schemas.node_progress import NodeProgress, NodeProgressStatus
# 将内部节点名映射为用户能理解的进度说明,未配置的节点仍发送事件但不附带 label
NODE_PROGRESS_MESSAGES = {
"requirement_node": "正在理解你的需求",
"qa_node": "正在整理回答",
"component_resource_node": "正在选择合适的图表和组件",
"design_node": "正在设计大屏",
"action_node": "正在准备画布修改",
"validator_node": "正在检查修改结果",
"reflection_node": "正在调整修改方案",
"screen_agent_node": "正在处理大屏",
}
+4 -39
View File
@@ -1,18 +1,11 @@
from typing import Literal, NotRequired, TypedDict
from app.schemas.action_repair import ActionRepairPlan
from app.schemas.action_validation import ActionValidationError
from app.schemas.actions import Action
from app.schemas.base import JsonObject
from app.schemas.change_scope import ChangeScope
from app.schemas.clarification import Clarification
from app.schemas.clarification_reply import ClarificationReply
from app.schemas.component_resource import ComponentResult
from app.schemas.decision import DecisionResponse
from app.schemas.decision import DecisionResponse, Route
from app.schemas.input_context import InputContext
from app.schemas.requirement import Intent, Requirement
from app.schemas.screen import Screen
from app.schemas.screen_plan import ScreenPlan
class QueryState(TypedDict):
@@ -22,41 +15,13 @@ class QueryState(TypedDict):
request: JsonObject # 原始请求,保留提交时的内容
input_context: NotRequired[InputContext] # 校验后的上下文,包含最新大屏和澄清历史
intent_decision: NotRequired[DecisionResponse | None] # Jev 原始意图判断及分数
intent: NotRequired[Intent | None] # 经过置信度门控的最终意图
requirement: NotRequired[Requirement | None] # 结构化需求及信息完整性判断
requirement_outcome: NotRequired[
Literal["execute", "clarify", "inform"] | None
] # 落地策略决定的本轮去向,见 outcome_policy
notice: NotRequired[str | None] # 无法执行时交给回答节点的事实说明
offer: NotRequired[str | None] # 本轮助手提出、等待用户确认的修改指令
accepted_offer: NotRequired[str | None] # 用户本轮同意执行的上一轮提议
change_scope: NotRequired[ChangeScope | None] # 修改范围、规划和资源需求
screen_plan: NotRequired[
ScreenPlan | None
] # 大屏设计规划,尚未获取业务数据或生成动作
component_resources: NotRequired[
ComponentResult | None
] # 已查询的前端物料能力及目录版本
actions: NotRequired[
list[Action] | None
] # 生成的动作列表,是否可模拟执行由校验结果标记
validation_passed: NotRequired[
bool | None
] # 整批模拟执行是否通过,None 表示尚未校验
validation_errors: NotRequired[
list[ActionValidationError]
] # 动作下标、错误类型和原因
simulated_screen: NotRequired[
Screen | None
] # 整批通过后的模拟快照,不覆盖原始上下文
intent: NotRequired[Route | None] # 门控后的去向
actions: NotRequired[list[Action] | None] # screen agent 最后一次成功提交的动作
clarification: NotRequired[Clarification | None] # 当前澄清问题及选项
repair_plan: NotRequired[ActionRepairPlan | None] # 最近一次动作修正建议
retry_count: NotRequired[int] # 已启动的自动修正次数,不含首次生成
failure_reason: NotRequired[str | None] # 停止自动修正后的失败原因
clarification_reply: NotRequired[
ClarificationReply | None
] # 待合并的用户回答或取消指令
answer: NotRequired[str | None] # 问答节点生成的回答或建议
answer: NotRequired[str | None] # 问答回答,或 agent 给用户的说明
status: NotRequired[
Literal["running", "waiting_for_input", "completed", "cancelled", "failed"]
] # 后端处理、等待补充、处理完成、用户取消或失败,完成不代表前端已执行
+50
View File
@@ -0,0 +1,50 @@
"""请求摘要:意图判断和 screen agent 共用,只给组件的识别信息和布局,完整配置按需读取"""
from app.schemas.base import JsonObject
from app.schemas.input_context import InputContext
def request_summary(context: InputContext) -> JsonObject:
"""用户输入、会话历史、澄清记录、编辑上下文、附件和大屏摘要"""
nodes: list[JsonObject] = []
for node in context.screen.nodes:
# 图表标题帮助判断用户指的是哪个组件;没有 option 或标题格式不符时按无标题处理
option = node.props.get("option")
title = option.get("title") if isinstance(option, dict) else None
titles = title if isinstance(title, list) else [title]
nodes.append(
{
"id": node.id,
"type": node.type,
"name": node.name,
"titles": [
item["text"]
for item in titles
if isinstance(item, dict) and isinstance(item.get("text"), str)
],
"layout": node.layout.model_dump(mode="json", exclude_unset=True),
}
)
return {
"prompt": context.prompt,
"history": context.history,
"clarification_history": context.clarification_history,
"context": context.context.model_dump(mode="json", exclude_unset=True),
"screen_summary": {
"canvas": {
"width": context.screen.canvas.width,
"height": context.screen.canvas.height,
},
"nodes": nodes,
# 数据源只保留标识、名称和类型
"data_sources": [
{key: source[key] for key in ("id", "name", "type") if key in source}
for source in context.screen.dataSource
],
},
"attachments": [
{key: item[key] for key in ("id", "kind", "name") if key in item}
for item in context.attachments
],
}