"""输入解析、意图分流和需求理解流程""" import inspect from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer from langgraph.graph import END, StateGraph 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.fallback_node import FallbackNode from app.processor.nodes.input_node import InputNode from app.processor.nodes.intent_node import IntentNode from app.processor.nodes.planner_node import PlannerNode 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.wait_for_user_node import WaitForUserNode from app.processor.state import QueryState def route_after_intent(state: QueryState) -> str: # 按最终意图分流:问答进入 QA,意图不明进入澄清,新建或修改进入需求理解 return state["intent"] def route_after_requirement(state: QueryState) -> bool: # True 进入澄清,False 进入修改范围判断 return state["requirement"].needs_clarification def route_after_wait(state: QueryState) -> str: # 用户补充后回到输入节点合并上下文,取消则结束流程 return "input_node" if state["status"] == "running" else END def route_after_scope(state: QueryState) -> str: # 需要规划时先获取组件能力,供规划节点选择可用组件 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): from app.config import settings 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" 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" # 定义工作流 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(), "planner_node": PlannerNode(), "component_resource_node": ComponentResourceNode(), "action_node": ActionNode(), "validator_node": ValidatorNode(), "reflection_node": ReflectionNode(), "fallback_node": FallbackNode(), } # 注册节点,异步节点使用 BaseNode 的异步入口 for node_name, node in nodes.items(): 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", }, ) workflow.add_conditional_edges( source="requirement_node", path=route_after_requirement, path_map={True: "clarify_node", False: "scope_node"}, ) 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", }, ) workflow.add_conditional_edges( "component_resource_node", lambda state: state["component_resources"].status == "resolved", {True: "planner_node", False: "fallback_node"}, ) workflow.add_edge("planner_node", "action_node") workflow.add_conditional_edges( "action_node", 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", END) workflow.add_conditional_edges( "wait_for_user_node", route_after_wait, {"input_node": "input_node", END: END}, ) # TODO 本地默认保存在内存,可传入持久化 checkpointer if checkpointer is None: 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"), ] ) checkpointer = InMemorySaver(serde=serde) return workflow.compile(checkpointer=checkpointer).with_config( { "recursion_limit": max(25, 12 + 3 * max_retries), } ) # 创建全局图实例 query_app = build_graph() if __name__ == "__main__": import argparse import asyncio import json from uuid import uuid4 from langgraph.types import Command from pydantic import BaseModel from app.clients.decision import close_decision_engine from app.observability.tracing import ainvoke_screen, flush_tracing # 选择场景时只切换用户需求,共用同一份画布,方便比较执行结果 examples = { "move": "把销售额统计向右移动 50px,其他内容保持不变", "layout": ( "重新排列现有两个图表,销售额统计放在左侧,销售占比放在右侧," "统一尺寸为 600×400,画布改为 1600×900,不新增或删除组件" ), "add": "保留现有组件,在下方新增一个柱状图,使用物料示例数据,不需要真实数据绑定", "clarify": "这个图不太对", } parser = argparse.ArgumentParser(description="运行大屏 Agent 完整流程示例") parser.add_argument("--case", choices=examples, default="move", help="选择联调场景") args = parser.parse_args() async def main(): # 1. 创建请求,每次运行使用独立会话,避免混入之前的澄清历史 state: QueryState = { "request": { "prompt": examples[args.case], "screen": { "canvas": {"width": 1920, "height": 1080}, "nodes": [ { "id": "sales-chart", "type": "bar-chart", "name": "销售额统计", "layout": {"x": 26, "y": 34.8, "width": 400, "height": 260}, "props": {"option": {"title": {"text": "销售额统计"}}}, }, { "id": "sales-share", "type": "pie-chart", "name": "销售占比", "layout": { "x": 500, "y": 34.8, "width": 400, "height": 260, }, "props": {"option": {"title": {"text": "销售占比"}}}, }, ], "dataSource": [], }, "context": {"selected_node_ids": ["sales-chart"]}, } } # 2. 执行图,程序退出时关闭共享连接 thread_id = str(uuid4()) session_id = ( state["request"].get("context", {}).get("conversation_id") or thread_id ) print(f"场景:{args.case}\n需求:{examples[args.case]}") if args.case == "clarify": print("澄清回答示例:把销售额统计向右移动 50px,其他内容保持不变") try: final_state = await ainvoke_screen( query_app, state, thread_id=thread_id, session_id=session_id ) # 3. 展示真实澄清问题,用同一个 thread_id 恢复流程 while final_state.get("__interrupt__"): pending = final_state["__interrupt__"][0].value print(pending["question"]) options = pending.get("options", []) for index, option in enumerate(options, 1): marker = "(推荐)" if option["recommended"] else "" 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): reply = {"option_id": options[int(answer) - 1]["id"]} notes = input("补充说明(可留空):").strip() if notes: reply["answer"] = notes final_state = await ainvoke_screen( query_app, Command(resume=reply), thread_id=thread_id, session_id=session_id, ) finally: await close_decision_engine() flush_tracing() # 4. 聚焦最终结果,校验失败时 simulated_screen 应为 None result = { "status": final_state.get("status"), "intent": final_state.get("intent"), # 保留需求与规划,方便定位模型输出和执行能力之间的不一致 "requirement": final_state.get("requirement"), "screen_plan": final_state.get("screen_plan"), "validation_passed": final_state.get("validation_passed"), "retry_count": final_state.get("retry_count", 0), "failure_reason": final_state.get("failure_reason"), "validation_errors": final_state.get("validation_errors", []), "actions": final_state.get("actions", []), "simulated_screen": final_state.get("simulated_screen"), "answer": final_state.get("answer"), } print( json.dumps( result, ensure_ascii=False, indent=2, default=lambda value: ( value.model_dump(mode="json") if isinstance(value, BaseModel) else str(value) ), ) ) # 原始坐标单独展示,确认后端试执行没有修改请求中的画布 original = state["request"]["screen"]["nodes"][0]["layout"] print(f"原始销售额坐标:{original['x']},{original['y']}") asyncio.run(main())