Files
my-v0-server/app/processor/nodes/scope_node.py
T
2026-09-30 08:57:39 -07:00

156 lines
6.6 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 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())