feat:接入mcp,为模型提供图表信息
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""需要物料字段的模型调用:模型按需调用字段查询工具,最后按指定结构输出
|
||||
|
||||
动作生成和修正建议共用;字段查询走物料字段 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
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
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
|
||||
@@ -9,13 +13,18 @@ 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
|
||||
from app.resources.material_fields import editable_fields
|
||||
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:
|
||||
# 没有阻塞原因时才允许生成动作,路由和节点入口复用同一套能力检查
|
||||
@@ -59,28 +68,6 @@ def action_block_reason(state: QueryState) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def update_fields(state: QueryState) -> dict[str, list[dict]]:
|
||||
"""按组件 ID 汇总可能被属性更新的已有组件的可编辑字段,没有字段的组件省略"""
|
||||
plan = state.get("screen_plan")
|
||||
if plan is None:
|
||||
target_ids = [
|
||||
target
|
||||
for change in state["requirement"].changes
|
||||
if change.target_scope == "nodes"
|
||||
for target in change.target_ids
|
||||
]
|
||||
else:
|
||||
target_ids = [
|
||||
item.target_id for item in plan.components if item.disposition == "modify"
|
||||
]
|
||||
nodes = {node.id: node for node in state["input_context"].screen.nodes}
|
||||
result = {}
|
||||
for target in dict.fromkeys(target_ids):
|
||||
if target in nodes and (fields := editable_fields(nodes[target].type)):
|
||||
result[target] = fields
|
||||
return result
|
||||
|
||||
|
||||
class ActionNode(BaseNode):
|
||||
name = "action_node"
|
||||
|
||||
@@ -115,10 +102,6 @@ class ActionNode(BaseNode):
|
||||
payload["screen_plan"] = state["screen_plan"].model_dump(mode="json")
|
||||
payload["component_materials"] = plan_materials(state)
|
||||
|
||||
# 提供待修改组件的属性面板字段,让模型把用户说的“标题”“颜色”等对应到真实路径
|
||||
if fields := update_fields(state):
|
||||
payload["editable_fields"] = fields
|
||||
|
||||
# 如果本轮是动作修正,提供修正建议、上一轮动作和校验错误供模型参考
|
||||
if state.get("repair_plan") is not None:
|
||||
payload.update(
|
||||
@@ -133,7 +116,17 @@ class ActionNode(BaseNode):
|
||||
],
|
||||
)
|
||||
|
||||
llm_client = get_llm(ActionBatch)
|
||||
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)
|
||||
|
||||
# 旧动作及错误已放入模型输入,现在清空输出状态,避免本轮失败后残留旧结果
|
||||
updated_state["actions"] = None
|
||||
@@ -146,19 +139,12 @@ class ActionNode(BaseNode):
|
||||
# 捕获输出结构和解析错误,转换成统一校验结果供后续修正流程使用
|
||||
try:
|
||||
# 异步调用模型,根据需求、规划及是否正在修正选择对应的系统规则
|
||||
result = await llm_client.ainvoke(
|
||||
[
|
||||
SystemMessage(
|
||||
content=build_action_prompt(
|
||||
state["requirement"],
|
||||
state.get("screen_plan"),
|
||||
repairing=state.get("repair_plan") is not None,
|
||||
)
|
||||
),
|
||||
# 将本次具体资料放入用户消息,保留中文以便阅读和排查
|
||||
HumanMessage(content=json.dumps(payload, ensure_ascii=False)),
|
||||
]
|
||||
)
|
||||
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)]
|
||||
)
|
||||
|
||||
# 响应中没有工具调用时解析器会返回 None
|
||||
if result is None:
|
||||
@@ -184,15 +170,19 @@ class ActionNode(BaseNode):
|
||||
updated_state["validation_passed"] = False
|
||||
updated_state["validation_errors"] = schema_errors(error)
|
||||
return updated_state
|
||||
except OutputParserException:
|
||||
except StructuredOutputValidationError as error:
|
||||
# 查询字段后的输出不符合结构,与直接输出时的处理一致
|
||||
updated_state["validation_passed"] = False
|
||||
updated_state["validation_errors"] = (
|
||||
schema_errors(error.source)
|
||||
if isinstance(error.source, ValidationError)
|
||||
else [GENERATION_ERROR]
|
||||
)
|
||||
return updated_state
|
||||
except (OutputParserException, StructuredOutputError):
|
||||
# 如果模型输出无法解析,记录生成错误,提示后续按 ActionBatch 协议重新生成
|
||||
updated_state["validation_passed"] = False
|
||||
updated_state["validation_errors"] = [
|
||||
ActionValidationError(
|
||||
code="generation_error",
|
||||
message="模型输出无法解析为 ActionBatch,请按协议重新生成",
|
||||
)
|
||||
]
|
||||
updated_state["validation_errors"] = [GENERATION_ERROR]
|
||||
return updated_state
|
||||
|
||||
# 保存生成的动作,实际目标、规划一致性及模拟执行结果仍由后续校验节点检查
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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
|
||||
@@ -9,7 +10,9 @@ 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.schemas.capabilities import USER_FAILURE_MESSAGE
|
||||
from app.prompt.reflection import REFLECTION_SYSTEM_PROMPT
|
||||
from app.schemas.action_repair import ActionRepairPlan
|
||||
@@ -22,7 +25,6 @@ class ReflectionNode(BaseNode):
|
||||
# 浅拷贝状态
|
||||
updated_state: QueryState = state.copy()
|
||||
|
||||
llm_client = get_llm(ActionRepairPlan)
|
||||
# 汇总原始上下文、需求和失败信息,让模型基于实际错误提出修正建议
|
||||
payload = {
|
||||
"input_context": state["input_context"].model_dump(mode="json"),
|
||||
@@ -46,21 +48,30 @@ class ReflectionNode(BaseNode):
|
||||
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:
|
||||
# 异步生成修正建议,本节点不直接修改动作
|
||||
plan = await llm_client.ainvoke(
|
||||
[
|
||||
# 系统消息提供稳定的失败分析和修正规则
|
||||
SystemMessage(content=REFLECTION_SYSTEM_PROMPT),
|
||||
# 用户消息承载本轮待分析的具体资料,序列化时保留中文
|
||||
HumanMessage(content=json.dumps(payload, ensure_ascii=False)),
|
||||
]
|
||||
)
|
||||
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):
|
||||
except (ValidationError, OutputParserException, StructuredOutputError):
|
||||
# 如果未能得到合法建议,清空旧修正计划,避免再次使用已失效的建议
|
||||
updated_state["repair_plan"] = None
|
||||
# 记录无法继续修正的原因,交给后续路由和失败处理流程
|
||||
|
||||
@@ -4,10 +4,18 @@ 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, UpdateCanvasAction
|
||||
from app.schemas.actions import (
|
||||
ActionBatch,
|
||||
AddAction,
|
||||
UpdateAction,
|
||||
UpdateCanvasAction,
|
||||
)
|
||||
from app.schemas.operations import is_canvas_operation
|
||||
|
||||
|
||||
@@ -121,8 +129,32 @@ class ValidatorNode(BaseNode):
|
||||
# 在副本上试执行,不改变用户传入的大屏,也不操作前端画布
|
||||
screen = state["input_context"].screen.model_copy(deep=True)
|
||||
|
||||
catalog = get_catalog_cache()
|
||||
# 保存补齐路径后的动作,前端按它执行
|
||||
actions = list(batch.actions)
|
||||
|
||||
# 按动作顺序逐项执行,每一步都使用上一步更新后的大屏副本
|
||||
for index, action in enumerate(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)
|
||||
@@ -147,8 +179,8 @@ class ValidatorNode(BaseNode):
|
||||
updated_state["simulated_screen"] = None
|
||||
return updated_state
|
||||
|
||||
# 全部检查通过后,保存重新校验过的动作对象,统一后续读取的数据结构
|
||||
updated_state["actions"] = batch.actions
|
||||
# 全部检查通过后,保存重新校验并补齐路径的动作对象,统一后续读取的数据结构
|
||||
updated_state["actions"] = actions
|
||||
# 标记整批动作校验通过,并清空之前可能残留的错误
|
||||
updated_state["validation_passed"] = True
|
||||
updated_state["validation_errors"] = []
|
||||
|
||||
Reference in New Issue
Block a user