92 lines
3.6 KiB
Python
92 lines
3.6 KiB
Python
"""需要物料字段的模型调用:模型按需调用字段查询工具,最后按指定结构输出
|
|
|
|
动作生成和修正建议共用;字段查询走物料字段 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
|