"""判断需求涉及的范围、规划和资源需求""" import json from langchain_core.messages import HumanMessage, SystemMessage from app.clients.llm import get_llm from app.processor.base import BaseNode from app.processor.nodes.action_node import action_block_reason from app.processor.state import QueryState from app.prompt.scope import SCOPE_SYSTEM_PROMPT from app.schemas.capabilities import RESOURCE_QUERY_SUPPORTED from app.schemas.change_scope import ChangeScope from app.schemas.operations import requires_planning class ScopeNode(BaseNode): name = "scope_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() # 获取需求节点提取的结构化需求,作为范围判断的依据 requirement = state["requirement"] # 获取当前大屏和用户输入,帮助模型结合已有组件判断影响范围 context = state["input_context"] llm_client = get_llm(ChangeScope) # 异步调用模型,判断涉及哪些组件、是否需要规划以及需要补充哪些资源 scope = await llm_client.ainvoke( [ # 系统消息提供稳定的范围、规划和资源判断规则 SystemMessage(content=SCOPE_SYSTEM_PROMPT), # 用户消息承载本次结构化需求和输入上下文,序列化为 JSON 供模型读取 HumanMessage( content=json.dumps( { "requirement": requirement.model_dump(mode="json"), "input_context": context.model_dump( mode="json", exclude_unset=True ), }, ensure_ascii=False, ) ), ] ) # 收集当前大屏中真实存在的节点 ID,用于检查模型是否引用了不存在的组件 existing_ids = {node.id for node in context.screen.nodes} # 如果范围中的目标 ID 不在当前大屏中,则抛出异常并列出无效 ID if unknown_ids := set(scope.target_ids) - existing_ids: raise ValueError(f"范围引用了不存在的节点: {sorted(unknown_ids)}") # 汇总需求中明确指定的目标 ID,使用集合去除多项变更之间重复引用的节点 required_ids = { node_id for change in requirement.changes for node_id in change.target_ids } # 如果需求指定的节点未被纳入范围,则抛出异常,避免后续处理遗漏用户要求 if missing_ids := required_ids - set(scope.target_ids): raise ValueError(f"范围遗漏了需求中的节点: {sorted(missing_ids)}") # 新增、重排等操作只能通过规划完成,这是代码已知的能力边界,不依赖模型判断 if not scope.needs_planning and any( requires_planning(change.target_scope, change.operation) for change in requirement.changes ): scope = scope.model_copy(update={"needs_planning": True}) # 资源查询接入前没有补充来源,资源需求不阻塞执行,改用物料默认配置和示例数据 if not RESOURCE_QUERY_SUPPORTED and scope.resource_needs: self.logger.info(f"资源查询未接入,改用默认配置: {scope.resource_needs}") scope = scope.model_copy(update={"resource_needs": []}) # 保存校验后的范围,下面的动作能力检查也需要读取该结果 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 if __name__ == "__main__": import asyncio from pydantic import BaseModel from app.processor.nodes.input_node import InputNode from app.schemas.requirement import Requirement async def main(): # 直接提供已理解的需求,单独测试范围判断,仅需 LLM 配置 state: QueryState = { "request": { "prompt": "把销售额统计向右移动 50px,其他不变", "screen": { "canvas": {"width": 1920, "height": 1080}, "nodes": [ { "id": "sales-chart", "type": "bar-chart", "name": "销售额统计", "layout": {"x": 26, "y": 34.8, "width": 400, "height": 260}, } ], }, } } state = InputNode().process(state) state["requirement"] = Requirement( intent="modify_screen", goal="销售额统计向右移动 50px", changes=[ { "target_scope": "nodes", "target_ids": ["sales-chart"], "operation": "move", "parameters": {"delta_x": 50}, } ], needs_clarification=False, constraints=["其他不变"], ) node = ScopeNode() final_state = await node.process(state) print( json.dumps( { key: value.model_dump(mode="json") if isinstance(value, BaseModel) else value for key, value in final_state.items() }, ensure_ascii=False, indent=2, ) ) asyncio.run(main())