Files
my-v0-server/app/processor/graph.py
T

344 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""输入解析、意图分流和需求理解流程"""
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())