feat: 完成资源接入

This commit is contained in:
2026-09-24 11:58:25 +08:00
parent 5f5d733a9b
commit 57ed92aeba
26 changed files with 3471 additions and 121 deletions
+54 -45
View File
@@ -3,12 +3,15 @@
import json
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.exceptions import OutputParserException
from pydantic import BaseModel, ValidationError
from app.clients.llm import get_llm
from app.processor.base import BaseNode
from app.processor.state import QueryState
from app.prompt.action import ACTION_SYSTEM_PROMPT
from app.schemas.actions import ActionBatch
from app.schemas.action_validation import ActionValidationError, schema_errors
def can_generate_actions(state: QueryState) -> bool:
@@ -38,57 +41,63 @@ class ActionNode(BaseNode):
name = "action_node"
async def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
if not can_generate_actions(state):
raise ValueError(
"ActionNode 当前只支持无需规划和资源的 move、resize、update 需求"
)
batch = await get_llm(ActionBatch).ainvoke(
[
SystemMessage(content=ACTION_SYSTEM_PROMPT),
HumanMessage(
content=json.dumps(
{
"input_context": state["input_context"].model_dump(
mode="json"
),
"requirement": state["requirement"].model_dump(mode="json"),
"change_scope": state["change_scope"].model_dump(
mode="json"
),
},
ensure_ascii=False,
)
),
]
)
batch = ActionBatch.model_validate(batch.model_dump())
allowed = {}
for change in state["requirement"].changes:
for target_id in change.target_ids:
allowed.setdefault(target_id, set()).add(change.operation)
existing = {node.id for node in state["input_context"].screen.nodes}
for action in batch.actions:
if action.target_id not in existing:
raise ValueError(f"动作引用了不存在的组件: {action.target_id}")
if action.type not in allowed.get(action.target_id, set()):
raise ValueError(
f"动作超出需求范围: {action.target_id} / {action.type}"
)
expected = {
(target, operation) for target, ops in allowed.items() for operation in ops
payload = {
"input_context": state["input_context"].model_dump(mode="json"),
"requirement": state["requirement"].model_dump(mode="json"),
"change_scope": state["change_scope"].model_dump(mode="json"),
}
actual = {(action.target_id, action.type) for action in batch.actions}
if expected - actual:
raise ValueError(
f"动作遗漏了需求中的目标或操作: {sorted(expected - actual)}"
if state.get("repair_plan") is not None:
payload.update(
repair_plan=state["repair_plan"].model_dump(mode="json"),
previous_actions=[
action.model_dump(mode="json")
for action in state.get("actions") or []
],
validation_errors=[
error.model_dump() for error in state.get("validation_errors", [])
],
)
return {
**state,
"actions": batch.actions,
"validation_passed": None,
"validation_errors": [],
"simulated_screen": None,
}
# 配置和网络错误继续抛出,仅模型输出错误进入内容修正循环
llm_client = get_llm(ActionBatch)
updated_state["actions"] = None
updated_state["validation_passed"] = None
updated_state["validation_errors"] = []
updated_state["simulated_screen"] = None
updated_state["failure_reason"] = None
updated_state["status"] = "running"
try:
result = await llm_client.ainvoke(
[
SystemMessage(content=ACTION_SYSTEM_PROMPT),
HumanMessage(content=json.dumps(payload, ensure_ascii=False)),
]
)
batch = ActionBatch.model_validate(
result.model_dump() if isinstance(result, BaseModel) else result
)
except ValidationError as error:
updated_state["validation_passed"] = False
updated_state["validation_errors"] = schema_errors(error)
return updated_state
except OutputParserException:
updated_state["validation_passed"] = False
updated_state["validation_errors"] = [
ActionValidationError(
code="generation_error",
message="模型输出无法解析为 ActionBatch,请按协议重新生成",
)
]
return updated_state
updated_state["actions"] = batch.actions
return updated_state
if __name__ == "__main__":
+9 -5
View File
@@ -15,24 +15,28 @@ class ClarifyNode(BaseNode):
name = "clarify_node"
async def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
intent = state["intent"]
print(intent)
print("=================")
payload = {
"intent": intent,
"input_context": state["input_context"].model_dump(mode="json"),
}
if intent != "unclear":
# 以需求节点的问题为依据补齐选项,不重新判断意图
payload["requirement"] = state["requirement"].model_dump(mode="json")
llm = get_llm(Clarification)
clarification = await llm.ainvoke(
llm_client = get_llm(Clarification)
clarification = await llm_client.ainvoke(
[
SystemMessage(content=CLARIFY_SYSTEM_PROMPT),
HumanMessage(content=json.dumps(payload, ensure_ascii=False)),
]
)
return {**state, "clarification": clarification}
updated_state["clarification"] = clarification
return updated_state
if __name__ == "__main__":
@@ -0,0 +1,40 @@
"""为规划提供前端实际支持的物料目录"""
from app.processor.base import BaseNode
from app.processor.state import QueryState
from app.resources.component import ComponentProvider
from app.schemas.component_resource import ComponentQuery
class ComponentResourceNode(BaseNode):
name = "component_resource_node"
def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
# 当前目录较小,先提供全量,避免关键词筛选遗漏可用组件
result = ComponentProvider().query(ComponentQuery())
updated_state["component_resources"] = result
updated_state["screen_plan"] = None
if result.status != "resolved":
updated_state["status"] = "failed"
updated_state["failure_reason"] = result.message
else:
updated_state["status"] = "running"
updated_state["failure_reason"] = None
return updated_state
if __name__ == "__main__":
import json
state: QueryState = {"request": {}}
node = ComponentResourceNode()
final_state = node.process(state)
print(
json.dumps(
final_state,
ensure_ascii=False,
indent=2,
default=lambda value: value.model_dump(mode="json"),
)
)
+37
View File
@@ -0,0 +1,37 @@
"""停止自动修正并保留诊断信息"""
from app.processor.base import BaseNode
from app.processor.state import QueryState
class FallbackNode(BaseNode):
name = "fallback_node"
def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
reason = state.get("failure_reason")
if not reason:
reason = f"动作校验未通过,已达到自动修正上限,共重试 {state.get('retry_count', 0)} 次"
# actions 仅保留作诊断,不交付部分结果
updated_state["status"] = "failed"
updated_state["failure_reason"] = reason
updated_state["validation_passed"] = False
updated_state["simulated_screen"] = None
return updated_state
if __name__ == "__main__":
from app.schemas.action_validation import ActionValidationError
state = {
"request": {},
"retry_count": 2,
"actions": None,
"validation_errors": [
ActionValidationError(code="generation_error", message="动作生成失败")
],
}
node = FallbackNode()
final_state = node.process(state)
print(final_state)
+30 -13
View File
@@ -11,13 +11,16 @@ class InputNode(BaseNode):
name = "input_node"
def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
reply = state.get("clarification_reply")
if reply is not None:
data = state["input_context"].model_dump(mode="json")
turn = {
"question": state["clarification"].question,
"answer": reply.answer,
}
if reply.option_id is not None:
option = next(
option
@@ -26,6 +29,7 @@ class InputNode(BaseNode):
)
turn["selected_option"] = option.model_dump(mode="json")
data["clarification_history"].append(turn)
if reply.screen is not None:
data["screen"] = reply.screen.model_dump(mode="json")
# 新快照不沿用可能已经失效的选中节点
@@ -35,22 +39,35 @@ class InputNode(BaseNode):
if reply.context is not None:
data["context"] = reply.context.model_dump(mode="json")
context = InputContext.model_validate(data)
# 显式清空旧推导结果,LangGraph 的状态合并不会删除省略字段
return {
**state,
"input_context": context,
"clarification_reply": None,
"intent_decision": None,
"intent": None,
"requirement": None,
"change_scope": None,
"clarification": None,
"answer": None,
"status": "running",
}
updated_state["input_context"] = context
updated_state["clarification_reply"] = None
updated_state["intent_decision"] = None
updated_state["intent"] = None
updated_state["requirement"] = None
updated_state["change_scope"] = None
updated_state["screen_plan"] = None
updated_state["component_resources"] = None
updated_state["actions"] = None
updated_state["validation_passed"] = None
updated_state["validation_errors"] = []
updated_state["simulated_screen"] = None
updated_state["retry_count"] = 0
updated_state["repair_plan"] = None
updated_state["failure_reason"] = None
updated_state["clarification"] = None
updated_state["answer"] = None
updated_state["status"] = "running"
return updated_state
# 深拷贝隔离原始请求,后续修改上下文不会改变提交时的快照
context = InputContext.model_validate(state["request"]).model_copy(deep=True)
updated_state: QueryState = {**state, "input_context": context}
updated_state["input_context"] = context
updated_state["retry_count"] = 0
updated_state["repair_plan"] = None
updated_state["failure_reason"] = None
return updated_state
+4 -5
View File
@@ -36,6 +36,7 @@ class IntentNode(BaseNode):
"layout": node.layout.model_dump(mode="json", exclude_unset=True),
}
)
return {
"prompt": context.prompt,
"clarification_history": context.clarification_history,
@@ -66,6 +67,7 @@ class IntentNode(BaseNode):
}
async def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
context = state["input_context"]
# 构建jev请求
request = DecisionRequest(
@@ -89,11 +91,8 @@ class IntentNode(BaseNode):
else:
intent = cast(Intent, decision.choice)
updated_state: QueryState = {
**state,
"intent_decision": decision_res,
"intent": intent,
}
updated_state["intent_decision"] = decision_res
updated_state["intent"] = intent
return updated_state
+156
View File
@@ -0,0 +1,156 @@
"""将需要规划的需求整理为 ScreenPlan"""
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.state import QueryState
from app.prompt.planner import PLANNER_SYSTEM_PROMPT
from app.schemas.screen_plan import ScreenPlan
class PlannerNode(BaseNode):
name = "planner_node"
async def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
requirement = state["requirement"]
scope = state["change_scope"]
if (
requirement.needs_clarification
or requirement.intent
not in (
"create_screen",
"modify_screen",
)
or not scope.needs_planning
):
raise ValueError("PlannerNode 只处理信息完整且需要规划的新建或修改需求")
context = state["input_context"]
resources = state.get("component_resources")
if resources is not None and resources.status != "resolved":
raise ValueError("组件目录尚未就绪,不能继续规划")
result = await get_llm(ScreenPlan).ainvoke(
[
SystemMessage(content=PLANNER_SYSTEM_PROMPT),
HumanMessage(
content=json.dumps(
{
"input_context": context.model_dump(mode="json"),
"requirement": requirement.model_dump(mode="json"),
"change_scope": scope.model_dump(mode="json"),
"component_resources": resources.model_dump(mode="json")
if resources is not None
else None,
},
ensure_ascii=False,
)
),
]
)
plan = ScreenPlan.model_validate(result.model_dump())
if resources is not None:
supported_types = {component.type for component in resources.components}
for component in plan.components:
if component.disposition == "add" and component.component_type is None:
raise ValueError(f"新增规划组件未指定物料类型: {component.plan_id}")
if (
component.disposition in ("add", "modify")
and component.component_type is not None
):
if component.component_type not in supported_types:
raise ValueError(
f"规划使用了目录中不存在的组件类型: {component.component_type}"
)
existing_ids = {node.id for node in context.screen.nodes}
planned_ids = {
component.target_id
for component in plan.components
if component.target_id is not None
}
if unknown := planned_ids - existing_ids:
raise ValueError(f"规划引用了不存在的组件: {sorted(unknown)}")
required_ids = {
target for change in requirement.changes for target in change.target_ids
}
if missing := required_ids - planned_ids:
raise ValueError(f"规划遗漏了需求中的组件: {sorted(missing)}")
if scope.scope == "local":
changed_ids = {
component.target_id
for component in plan.components
if component.disposition in ("modify", "remove")
}
if outside := changed_ids - set(scope.target_ids):
raise ValueError(f"局部规划修改了范围外的组件: {sorted(outside)}")
# 原始约束和假设不能在规划阶段被无声丢弃
plan.constraints = list(
dict.fromkeys([*requirement.constraints, *plan.constraints])
)
plan.assumptions = list(
dict.fromkeys([*requirement.assumptions, *plan.assumptions])
)
updated_state["screen_plan"] = plan
return updated_state
if __name__ == "__main__":
import asyncio
from pydantic import BaseModel
from app.processor.nodes.component_resource_node import ComponentResourceNode
from app.processor.nodes.input_node import InputNode
from app.schemas.change_scope import ChangeScope
from app.schemas.requirement import Requirement
async def main():
# 1. 创建 state,直接提供需求和范围,单独测试规划
state = InputNode().process(
{
"request": {
"prompt": "创建销售大屏,上方展示销售总额,下方展示月度趋势",
"screen": {"canvas": {"width": 1920, "height": 1080}, "nodes": []},
}
}
)
state["requirement"] = Requirement(
intent="create_screen",
goal="创建销售总览大屏",
needs_clarification=False,
changes=[
{
"target_scope": "screen",
"operation": "add",
"parameters": {"metrics": ["销售总额", "月度销售额"]},
}
],
constraints=["总额在上,趋势在下"],
)
state["change_scope"] = ChangeScope(
scope="screen",
needs_planning=True,
resource_needs=["销售总额和月度销售数据,用于指标与趋势展示"],
reason="需要安排区域和组件组合",
)
# 2. 执行节点并打印完整 state,仅调用 LLM
node = PlannerNode()
state = ComponentResourceNode().process(state)
final_state = await node.process(state)
print(
json.dumps(
final_state,
ensure_ascii=False,
indent=2,
default=lambda value: (
value.model_dump(mode="json")
if isinstance(value, BaseModel)
else str(value)
),
)
)
asyncio.run(main())
+3 -1
View File
@@ -13,6 +13,7 @@ class QANode(BaseNode):
name = "qa_node"
async def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
llm_client = get_llm()
llm_res = await llm_client.ainvoke(
[
@@ -26,7 +27,8 @@ class QANode(BaseNode):
answer = StrOutputParser().invoke(llm_res).strip()
if not answer:
raise ValueError("问答模型未返回文本回答")
return {**state, "answer": answer}
updated_state["answer"] = answer
return updated_state
if __name__ == "__main__":
+128
View File
@@ -0,0 +1,128 @@
"""分析失败动作,生成下一次修正建议"""
import json
from langchain_core.exceptions import OutputParserException
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, ValidationError
from app.clients.llm import get_llm
from app.processor.base import BaseNode
from app.processor.state import QueryState
from app.prompt.reflection import REFLECTION_SYSTEM_PROMPT
from app.schemas.action_repair import ActionRepairPlan
class ReflectionNode(BaseNode):
name = "reflection_node"
async def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
llm_client = get_llm(ActionRepairPlan)
payload = {
"input_context": state["input_context"].model_dump(mode="json"),
"requirement": state["requirement"].model_dump(mode="json"),
"actions": [
action.model_dump(mode="json") for action in state.get("actions") or []
],
"validation_errors": [
error.model_dump() for error in state["validation_errors"]
],
"previous_repair_plan": state["repair_plan"].model_dump()
if state.get("repair_plan")
else None,
}
try:
result = await llm_client.ainvoke(
[
SystemMessage(content=REFLECTION_SYSTEM_PROMPT),
HumanMessage(content=json.dumps(payload, ensure_ascii=False)),
]
)
plan = ActionRepairPlan.model_validate(
result.model_dump() if isinstance(result, BaseModel) else result
)
except (ValidationError, OutputParserException):
updated_state["repair_plan"] = None
updated_state["failure_reason"] = "未能生成有效的动作修正建议"
return updated_state
updated_state["repair_plan"] = plan
updated_state["retry_count"] = state.get("retry_count", 0) + int(plan.retryable)
updated_state["failure_reason"] = None if plan.retryable else plan.reason
return updated_state
if __name__ == "__main__":
import asyncio
from app.processor.nodes.input_node import InputNode
from app.schemas.action_validation import ActionValidationError
from app.schemas.actions import UpdateAction
from app.schemas.requirement import Requirement
async def main():
state = InputNode().process(
{
"request": {
"prompt": "把标题改成月度销售额",
"screen": {
"canvas": {"width": 1920, "height": 1080},
"nodes": [
{
"id": "chart",
"type": "chart",
"layout": {
"x": 100,
"y": 200,
"width": 400,
"height": 300,
},
"props": {"title": "销售额"},
}
],
},
}
}
)
state["requirement"] = Requirement(
intent="modify_screen",
goal="修改标题",
needs_clarification=False,
changes=[
{
"target_scope": "nodes",
"target_ids": ["chart"],
"operation": "update",
"parameters": {"title": "月度销售额"},
}
],
)
state["actions"] = [
UpdateAction(
target_id="chart",
section="props",
path=["option", "title"],
value="月度销售额",
)
]
state["validation_passed"] = False
state["validation_errors"] = [
ActionValidationError(
action_index=0,
code="execution_error",
message="中间路径不存在: props ['option']",
)
]
node = ReflectionNode()
final_state = await node.process(state)
print(
json.dumps(
final_state,
ensure_ascii=False,
indent=2,
default=lambda value: value.model_dump(mode="json"),
)
)
asyncio.run(main())
+4 -1
View File
@@ -15,12 +15,14 @@ class RequirementNode(BaseNode):
name = "requirement_node"
async def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
context = state["input_context"]
intent = state["intent"]
payload = {
"intent": intent,
"input_context": context.model_dump(mode="json", exclude_unset=True),
}
llm_client = get_llm(Requirement)
llm_res = await llm_client.ainvoke(
[
@@ -38,7 +40,8 @@ class RequirementNode(BaseNode):
if unknown_ids := set(change.target_ids) - node_ids:
raise ValueError(f"需求引用了不存在的节点: {sorted(unknown_ids)}")
return {**state, "requirement": requirement}
updated_state["requirement"] = requirement
return updated_state
if __name__ == "__main__":
+9 -1
View File
@@ -15,12 +15,15 @@ class ScopeNode(BaseNode):
name = "scope_node"
async def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
requirement = state["requirement"]
if requirement.needs_clarification or requirement.intent not in (
"create_screen",
"modify_screen",
):
raise ValueError("ScopeNode 只处理信息完整的新建或修改需求")
context = state["input_context"]
llm_client = get_llm(ChangeScope)
scope = await llm_client.ainvoke(
@@ -39,15 +42,20 @@ class ScopeNode(BaseNode):
),
]
)
existing_ids = {node.id for node in context.screen.nodes}
if unknown_ids := set(scope.target_ids) - existing_ids:
raise ValueError(f"范围引用了不存在的节点: {sorted(unknown_ids)}")
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)}")
return {**state, "change_scope": scope}
updated_state["change_scope"] = scope
return updated_state
if __name__ == "__main__":
+47 -36
View File
@@ -5,7 +5,7 @@ from pydantic import BaseModel, ValidationError
from app.actions.registry import apply_action
from app.processor.base import BaseNode
from app.processor.state import QueryState
from app.schemas.action_validation import ActionValidationError
from app.schemas.action_validation import ActionValidationError, schema_errors
from app.schemas.actions import ActionBatch
@@ -13,6 +13,7 @@ class ValidatorNode(BaseNode):
name = "validator_node"
def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
actions = state["actions"]
try:
# 重新校验模型内容,避免赋值后绕过字段校验
@@ -27,27 +28,42 @@ class ValidatorNode(BaseNode):
}
)
except ValidationError as error:
errors = []
for item in error.errors(include_url=False, include_input=False):
location = item["loc"]
index = (
location[1]
if len(location) > 1 and isinstance(location[1], int)
else None
errors = schema_errors(error)
updated_state["validation_passed"] = False
updated_state["validation_errors"] = errors
updated_state["simulated_screen"] = None
return updated_state
# 需求范围检查集中在校验节点,错误统一进入修正循环
requirement = state.get("requirement")
if requirement is not None:
allowed = {
(target, change.operation)
for change in requirement.changes
for target in change.target_ids
}
actual = {(action.target_id, action.type) for action in batch.actions}
errors = [
ActionValidationError(
action_index=index,
code="scope_error",
message=f"动作超出需求范围: {action.target_id} / {action.type}",
)
for index, action in enumerate(batch.actions)
if (action.target_id, action.type) not in allowed
]
if missing := allowed - actual:
errors.append(
ActionValidationError(
action_index=index,
code="schema_error",
message=item["msg"],
code="scope_error",
message=f"动作遗漏了需求中的目标或操作: {sorted(missing)}",
)
)
return {
**state,
"validation_passed": False,
"validation_errors": errors,
"simulated_screen": None,
}
if errors:
updated_state["validation_passed"] = False
updated_state["validation_errors"] = errors
updated_state["simulated_screen"] = None
return updated_state
screen = state["input_context"].screen.model_copy(deep=True)
for index, action in enumerate(batch.actions):
@@ -55,25 +71,20 @@ class ValidatorNode(BaseNode):
screen = apply_action(screen, action)
except ValueError as error:
# 首个失败即停止,后续动作可能依赖前序结果,不返回部分成功快照
return {
**state,
"validation_passed": False,
"validation_errors": [
ActionValidationError(
action_index=index,
code="execution_error",
message=str(error),
)
],
"simulated_screen": None,
}
return {
**state,
"actions": batch.actions,
"validation_passed": True,
"validation_errors": [],
"simulated_screen": screen,
}
updated_state["validation_passed"] = False
updated_state["validation_errors"] = [
ActionValidationError(
action_index=index, code="execution_error", message=str(error)
)
]
updated_state["simulated_screen"] = None
return updated_state
updated_state["actions"] = batch.actions
updated_state["validation_passed"] = True
updated_state["validation_errors"] = []
updated_state["simulated_screen"] = screen
return updated_state
if __name__ == "__main__":
+5 -5
View File
@@ -12,6 +12,7 @@ class WaitForUserNode(BaseNode):
name = "wait_for_user_node"
def process(self, state: QueryState) -> QueryState:
updated_state: QueryState = state.copy()
payload = state["clarification"].model_dump(mode="json")
while True:
# interrupt 的控制异常必须交给 LangGraph 处理
@@ -31,8 +32,7 @@ class WaitForUserNode(BaseNode):
**payload,
"error": "请选择本题有效选项或填写回答,并提供有效的大屏数据;也可设置 cancel=true 取消",
}
return {
**state,
"clarification_reply": reply,
"status": "cancelled" if reply.cancel else "running",
}
updated_state["clarification_reply"] = reply
updated_state["status"] = "cancelled" if reply.cancel else "running"
return updated_state