Feat/0.1.0 #1

Merged
jiawei merged 4 commits from feat/0.1.0 into main 2026-10-04 22:23:55 +08:00
11 changed files with 424 additions and 300 deletions
Showing only changes of commit b82dc93404 - Show all commits
+18 -23
View File
@@ -10,10 +10,10 @@ from app.config import settings
from app.processor.nodes.action_node import ActionNode, can_generate_actions
from app.processor.nodes.clarify_node import ClarifyNode
from app.processor.nodes.component_resource_node import ComponentResourceNode
from app.processor.nodes.design_node import DesignNode
from app.processor.nodes.fallback_node import FallbackNode
from app.processor.nodes.input_node import InputNode
from app.processor.nodes.intent_node import IntentNode
from app.processor.nodes.planner_node import PlannerNode
from app.processor.nodes.qa_node import QANode
from app.processor.nodes.reflection_node import ReflectionNode
from app.processor.nodes.requirement_node import RequirementNode
@@ -75,9 +75,9 @@ def route_after_wait(state: QueryState) -> str:
# TODO 目前“资源”只有物料目录,数据源和 RAG 尚未准备好
# 接入后在 Requirement 中增加资源需求,与 needs_planning 作为两个独立开关:
# 规划 -> 按需查资源 -> planner;无需规划但有资源需求 -> 查资源 -> action
# 规划 -> 按需查资源 -> design;无需规划但有资源需求 -> 查资源 -> action
# 并让 component_resource_node 按资源需求决定查什么,而不是固定全量目录
# 接入时把 capabilities.RESOURCE_QUERY_SUPPORTED 改为 True;在此之前 PlannerNode
# 接入时把 capabilities.RESOURCE_QUERY_SUPPORTED 改为 True;在此之前 DesignNode
# 会清空资源需求并改用物料默认配置和示例数据,不因资源需求失败
def route_after_scope(state: QueryState) -> str:
# 已判定无法直接执行,失败收尾
@@ -139,7 +139,7 @@ def build_graph(checkpointer=None):
"wait_for_user_node": WaitForUserNode(),
"qa_node": QANode(),
"scope_node": ScopeNode(),
"planner_node": PlannerNode(),
"design_node": DesignNode(),
"component_resource_node": ComponentResourceNode(),
"action_node": ActionNode(),
"validator_node": ValidatorNode(),
@@ -202,30 +202,25 @@ def build_graph(checkpointer=None):
},
)
# 组件目录查询成功才允许规划,未找到或不支持时进入失败收尾
# 组件目录查询成功才允许设计,未找到或不支持时进入失败收尾
workflow.add_conditional_edges(
"component_resource_node",
lambda state: state["component_resources"].status == "resolved",
{True: "planner_node", False: "fallback_node"},
{True: "design_node", False: "fallback_node"},
)
# 规划完成后交给动作节点转换,动作节点还会检查规划是否可执行
workflow.add_conditional_edges(
"planner_node",
lambda state: "fallback_node" if state["status"] == "failed" else "action_node",
{"action_node": "action_node", "fallback_node": "fallback_node"},
)
# 动作生成后按结果进入校验、修正分析或失败收尾
workflow.add_conditional_edges(
"action_node",
route_after_action,
{
"validator_node": "validator_node",
"reflection_node": "reflection_node",
"fallback_node": "fallback_node",
},
)
# 设计(规划和动作一次生成)与动作生成的结果走同样的去向:校验、修正分析或失败收尾
# 修正时规划已定,由 action_node 只重新生成动作
for node_name in ("design_node", "action_node"):
workflow.add_conditional_edges(
node_name,
route_after_action,
{
"validator_node": "validator_node",
"reflection_node": "reflection_node",
"fallback_node": "fallback_node",
},
)
# 校验成功结束,失败则在重试上限内分析修正原因
workflow.add_conditional_edges(
+90 -68
View File
@@ -65,6 +65,90 @@ def action_block_reason(state: QueryState) -> str | None:
return None
# 模型输出的结构和解析错误,统一转换为校验结果,交给后续修正流程
GENERATION_EXCEPTIONS = (ValidationError, OutputParserException, StructuredOutputError)
def generation_errors(error: Exception) -> list[ActionValidationError]:
"""把 GENERATION_EXCEPTIONS 中的异常转换为统一的校验错误"""
# 查询字段后的输出不符合结构,与直接输出时的处理一致
if isinstance(error, StructuredOutputValidationError):
error = error.source
if isinstance(error, ValidationError):
return schema_errors(error)
# 模型输出无法解析时,提示后续按 ActionBatch 协议重新生成
return [GENERATION_ERROR]
def reset_action_output(updated_state: QueryState) -> None:
"""清空上一轮的动作、校验和失败信息,避免本轮失败后残留旧结果"""
updated_state.update(
actions=None,
validation_passed=None,
validation_errors=[],
simulated_screen=None,
failure_reason=None,
status="running",
)
def block_actions(updated_state: QueryState, reason: str) -> QueryState:
"""规划不满足执行条件时直接失败收尾,不调用模型生成动作"""
updated_state.update(
actions=None,
validation_passed=False,
validation_errors=[],
simulated_screen=None,
failure_reason=reason,
status="failed",
)
return updated_state
def accept_batch(state: QueryState, result: ActionBatch | None) -> ActionBatch:
"""校验模型给出的动作,有规划时用物料默认配置补全新增节点
结构不合法时抛出 GENERATION_EXCEPTIONS 中的异常
"""
# 响应中没有工具调用时解析器会返回 None
if result is None:
raise OutputParserException("模型未返回结构化结果")
raw = result.model_dump()
# 规划生成的新增节点只保留显式提供的字段,避免模型默认值覆盖物料默认配置
if state.get("screen_plan") is not None:
# 将原动作对象与序列化结果逐项对应,只调整新增动作的节点数据
for source, target in zip(result.actions, raw["actions"]):
if isinstance(source, AddAction):
target["node"] = source.node.model_dump(exclude_unset=True)
# 统一校验整批动作的字段和结构,格式不合法时抛出 ValidationError
batch = ActionBatch.model_validate(raw)
# 如果已有规划,使用物料默认配置补全新增节点,并同步示例数据标记
# 模型判断无法完成时没有动作可补全,保留 unsupported_reason 交给失败收尾
if state.get("screen_plan") is not None and not batch.unsupported_reason:
batch = assemble_plan_actions(state, batch)
return batch
def store_batch(updated_state: QueryState, batch: ActionBatch) -> QueryState:
"""保存待校验的动作;模型确认能力不足时直接失败收尾,不进入重试"""
if batch.unsupported_reason:
updated_state.update(
actions=[],
validation_passed=False,
failure_reason=f"动作生成判断无法完成:{batch.unsupported_reason}",
status="failed",
)
return updated_state
# 实际目标、规划一致性及模拟执行结果仍由后续校验节点检查
# 此时 validation_passed 仍为 None,不代表动作已执行或通过校验
updated_state["actions"] = batch.actions
return updated_state
class ActionNode(BaseNode):
name = "action_node"
@@ -76,16 +160,7 @@ class ActionNode(BaseNode):
if state.get("screen_plan") is not None and (
reason := plan_block_reason(state)
):
# 清空旧动作及模拟结果,标记失败,防止后续误用上一轮生成内容
updated_state.update(
actions=None,
validation_passed=False,
validation_errors=[],
simulated_screen=None,
failure_reason=reason,
status="failed",
)
return updated_state
return block_actions(updated_state, reason)
# 将输入上下文、结构化需求和修改范围转成字典,作为本次模型输入
payload = {
@@ -128,12 +203,7 @@ class ActionNode(BaseNode):
content = json.dumps(payload, ensure_ascii=False)
# 旧动作及错误已放入模型输入,现在清空输出状态,避免本轮失败后残留旧结果
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"
reset_action_output(updated_state)
# 捕获输出结构和解析错误,转换成统一校验结果供后续修正流程使用
try:
@@ -149,58 +219,10 @@ class ActionNode(BaseNode):
HumanMessage(content=content),
]
)
# 响应中没有工具调用时解析器会返回 None
if result is None:
raise OutputParserException("模型未返回结构化结果")
raw = result.model_dump()
# 规划生成的新增节点只保留显式提供的字段,避免模型默认值覆盖物料默认配置
if state.get("screen_plan") is not None:
# 将原动作对象与序列化结果逐项对应,只调整新增动作的节点数据
for source, target in zip(result.actions, raw["actions"]):
if isinstance(source, AddAction):
target["node"] = source.node.model_dump(exclude_unset=True)
# 统一校验整批动作的字段和结构,格式不合法时抛出 ValidationError
batch = ActionBatch.model_validate(raw)
# 如果已有规划,使用物料默认配置补全新增节点,并同步示例数据标记
if state.get("screen_plan") is not None:
batch = assemble_plan_actions(state, batch)
except ValidationError as error:
# 将结构校验异常转换为统一错误列表,返回状态以进入后续修正判断
batch = accept_batch(state, result)
except GENERATION_EXCEPTIONS as error:
updated_state["validation_passed"] = False
updated_state["validation_errors"] = schema_errors(error)
return updated_state
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"] = [GENERATION_ERROR]
updated_state["validation_errors"] = generation_errors(error)
return updated_state
# 模型确认现有能力无法满足需求时直接失败收尾,不进入重试
if batch.unsupported_reason:
updated_state.update(
actions=[],
validation_passed=False,
failure_reason=f"动作生成判断无法完成:{batch.unsupported_reason}",
status="failed",
)
return updated_state
# 保存生成的动作,实际目标、规划一致性及模拟执行结果仍由后续校验节点检查
updated_state["actions"] = batch.actions
# 返回待校验状态,此时 validation_passed 仍为 None,不代表动作已执行或通过校验
return updated_state
return store_batch(updated_state, batch)
+163
View File
@@ -0,0 +1,163 @@
"""将需要规划的需求一次设计成规划和动作,修正重试仍交给 ActionNode"""
import json
from app.actions.plan import plan_block_reason
from app.processor.base import BaseNode
from app.processor.material_agent import invoke_with_field_tools
from app.processor.nodes.action_node import (
GENERATION_EXCEPTIONS,
accept_batch,
block_actions,
generation_errors,
reset_action_output,
store_batch,
)
from app.processor.state import QueryState
from app.prompt.design import DESIGN_SYSTEM_PROMPT
from app.schemas.capabilities import (
RESOURCE_FALLBACK_ASSUMPTION,
RESOURCE_QUERY_SUPPORTED,
)
from app.schemas.screen_design import ScreenDesign
from app.schemas.screen_plan import ScreenPlan
# 设计输出未通过解析或规划校验时,带着错误让模型重新设计的最大次数
# TODO 上游节点目前只有这里做了节点内重试,requirement_node 尚未跟进
PLAN_MAX_REPAIRS = 1
def check_plan(plan: ScreenPlan, state: QueryState) -> None:
"""校验规划的组件类型、目标引用和修改范围,不通过时抛出 ValueError"""
requirement = state["requirement"]
scope = state["change_scope"]
context = state["input_context"]
resources = state["component_resources"]
# 校验规划中的物料类型是否真实受支持
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", "replace")
and component.component_type is not None
and component.component_type not in supported_types
):
raise ValueError(
f"规划使用了目录中不存在的组件类型: {component.component_type}"
)
# 收集当前大屏中真实存在的节点 ID,用于检查规划引用的已有目标
existing_ids = {node.id for node in context.screen.nodes}
# 汇总规划中明确引用的目标 ID,新增组件没有已有目标时不计入
planned_ids = {
component.target_id
for component in plan.components
if component.target_id is not None
}
# 如果规划引用了不存在的组件,则抛出异常并列出无效 ID
if unknown := planned_ids - existing_ids:
raise ValueError(f"规划引用了不存在的组件: {sorted(unknown)}")
# 汇总需求明确指定的目标 ID,避免规划遗漏用户提及的组件
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", "replace")
}
# 如果实际修改或删除的目标不在允许范围内,则拒绝扩大修改范围
if outside := changed_ids - set(scope.target_ids):
raise ValueError(f"局部规划修改了范围外的组件: {sorted(outside)}")
class DesignNode(BaseNode):
name = "design_node"
async def process(self, state: QueryState) -> QueryState:
# 浅拷贝状态
updated_state: QueryState = state.copy()
requirement = state["requirement"]
# 图保证组件目录已就绪后才进入设计
resources = state["component_resources"]
# 业务资料放入用户消息;组件只提供摘要,完整物料配置由模型按需查询
content = json.dumps(
{
"input_context": state["input_context"].model_dump(mode="json"),
"requirement": requirement.model_dump(mode="json"),
"change_scope": state["change_scope"].model_dump(mode="json"),
"component_resources": resources.to_planner_context(),
},
ensure_ascii=False,
)
reset_action_output(updated_state)
feedback = ""
for attempt in range(PLAN_MAX_REPAIRS + 1):
design = None
try:
design = await invoke_with_field_tools(
ScreenDesign, DESIGN_SYSTEM_PROMPT, content + feedback
)
if design is None:
raise ValueError("模型未返回结构化结果")
check_plan(design.plan, state)
break
except (ValueError, *GENERATION_EXCEPTIONS) as error:
# 解析失败时还没有规划,只能整份重新设计;超过次数才失败收尾
if attempt == PLAN_MAX_REPAIRS:
return self.fail_on_invalid_output(
state, error, "设计输出多次未通过校验"
)
self.logger.warning(f"设计未通过校验,重新设计: {error}")
feedback = (
f"\n\n上一次输出未通过校验:{error}\n请修正后重新输出完整设计。"
)
if design is not None:
feedback = f"\n\n上一次输出:\n{design.model_dump_json()}{feedback}"
plan = design.plan
# 资源查询接入前没有补充来源,保留会让动作阶段误判为“待获取资源”而失败
if not RESOURCE_QUERY_SUPPORTED and plan.resource_needs:
self.logger.info(
f"资源查询未接入,改用默认配置: {[need.description for need in plan.resource_needs]}"
)
plan.resource_needs = []
plan.assumptions.append(RESOURCE_FALLBACK_ASSUMPTION)
# 合并需求和规划的约束与假设,按首次出现顺序去重,需求在前
plan.constraints = list(
dict.fromkeys([*requirement.constraints, *plan.constraints])
)
plan.assumptions = list(
dict.fromkeys([*requirement.assumptions, *plan.assumptions])
)
# 保存通过校验的规划,后续动作校验和修正重试都以它为准
updated_state["screen_plan"] = plan
# 规划不满足执行条件时不再处理动作
if reason := plan_block_reason(updated_state):
return block_actions(updated_state, reason)
# 动作结构错误交给 reflection 和 ActionNode 按已定的规划修正
try:
batch = accept_batch(updated_state, design.batch)
except GENERATION_EXCEPTIONS as error:
updated_state["validation_passed"] = False
updated_state["validation_errors"] = generation_errors(error)
return updated_state
return store_batch(updated_state, batch)
-152
View File
@@ -1,152 +0,0 @@
"""将需要规划的需求整理为 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.capabilities import (
RESOURCE_FALLBACK_ASSUMPTION,
RESOURCE_QUERY_SUPPORTED,
)
from app.schemas.screen_plan import ScreenPlan
# 规划输出未通过校验时,带着错误让模型重新规划的最大次数
# TODO 上游节点目前只有这里做了节点内重试,requirement_node 尚未跟进
# TODO 将来可改为图级回路:规划校验失败 -> 规划版 reflection -> planner,
# 与动作阶段的重试统一由 route_failure 控制,届时可去掉这里的节点内重试
PLAN_MAX_REPAIRS = 1
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"]
# 获取当前大屏和用户输入,供模型结合已有内容安排布局与组件
context = state["input_context"]
# 图保证组件目录已就绪后才进入规划
resources = state["component_resources"]
messages = [
# 系统消息提供稳定的规划规则和输出边界
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.to_planner_context(),
},
ensure_ascii=False,
)
),
]
llm_client = get_llm(ScreenPlan)
for attempt in range(PLAN_MAX_REPAIRS + 1):
plan = None
try:
plan = await llm_client.ainvoke(messages)
self._check(plan, state)
break
except ValueError as error:
# 解析失败、引用不存在的目标等可由模型自行纠正,超过次数才失败收尾
if attempt == PLAN_MAX_REPAIRS:
return self.fail_on_invalid_output(
state, error, "规划输出多次未通过校验"
)
self.logger.warning(f"规划未通过校验,重新规划: {error}")
feedback = f"上一次输出未通过校验:{error}\n请修正后重新输出完整规划。"
if plan is not None:
feedback = f"上一次输出:\n{plan.model_dump_json()}\n\n{feedback}"
messages = [*messages, HumanMessage(content=feedback)]
# 资源查询接入前没有补充来源,保留会让动作阶段误判为“待获取资源”而失败
if not RESOURCE_QUERY_SUPPORTED and plan.resource_needs:
self.logger.info(
f"资源查询未接入,改用默认配置: {[need.description for need in plan.resource_needs]}"
)
plan.resource_needs = []
plan.assumptions.append(RESOURCE_FALLBACK_ASSUMPTION)
# 合并需求约束和规划约束,按首次出现顺序去重,保留需求约束在前
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
@staticmethod
def _check(plan: ScreenPlan, state: QueryState) -> None:
"""校验规划的组件类型、目标引用和修改范围,不通过时抛出 ValueError"""
requirement = state["requirement"]
scope = state["change_scope"]
context = state["input_context"]
resources = state["component_resources"]
# 校验规划中的物料类型是否真实受支持
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", "replace")
and component.component_type is not None
and component.component_type not in supported_types
):
raise ValueError(
f"规划使用了目录中不存在的组件类型: {component.component_type}"
)
# 收集当前大屏中真实存在的节点 ID,用于检查规划引用的已有目标
existing_ids = {node.id for node in context.screen.nodes}
# 汇总规划中明确引用的目标 ID,新增组件没有已有目标时不计入
planned_ids = {
component.target_id
for component in plan.components
if component.target_id is not None
}
# 如果规划引用了不存在的组件,则抛出异常并列出无效 ID
if unknown := planned_ids - existing_ids:
raise ValueError(f"规划引用了不存在的组件: {sorted(unknown)}")
# 汇总需求明确指定的目标 ID,避免规划遗漏用户提及的组件
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", "replace")
}
# 如果实际修改或删除的目标不在允许范围内,则拒绝扩大修改范围
if outside := changed_ids - set(scope.target_ids):
raise ValueError(f"局部规划修改了范围外的组件: {sorted(outside)}")
+1 -1
View File
@@ -15,7 +15,7 @@ NODE_PROGRESS_MESSAGES = {
"requirement_node": "正在理解你的需求",
"qa_node": "正在整理回答",
"component_resource_node": "正在选择合适的图表和组件",
"planner_node": "正在设计布局",
"design_node": "正在设计大屏",
"action_node": "正在准备画布修改",
"validator_node": "正在检查修改结果",
"reflection_node": "正在调整修改方案",
+27 -15
View File
@@ -11,21 +11,25 @@ from app.schemas.capabilities import DATA_BINDING_GUIDE
from app.schemas.requirement import Requirement
from app.schemas.screen_plan import ScreenPlan
# 动作生成和设计节点共用的动作约束
ACTION_CONSTRAINTS = f"""# 动作要求
仅使用本次列出的动作类型,覆盖本次全部变更,不返回大屏 JSON、解释文本或执行成功声明
有任何变更无法用列出的动作或可用字段完成时,不执行其中一部分:actions 为空,在 unsupported_reason 中说明缺少什么
以 input_context.screen 为起点,后续动作基于前序结果计算,不能重复执行或遗漏目标
不能编造真实业务数据、数据绑定或未读取的附件内容,不用近似操作替代无法表达的需求
{DATA_BINDING_GUIDE}
"""
ACTION_SYSTEM_PROMPT = f"""# 任务目标
你是大屏动作生成器,将已确认的需求转换成可按顺序执行的动作
遵循 requirement 和 change_scope 中的目标与约束,不添加额外优化
# 输出要求
按提供的 ActionBatch Schema 返回 actions 列表,仅使用本次列出的动作类型
覆盖本次全部变更,不返回大屏 JSON、解释文本或执行成功声明
有任何变更无法用列出的动作或可用字段完成时,不执行其中一部分:actions 为空,在 unsupported_reason 中说明缺少什么
以 input_context.screen 为起点,后续动作基于前序结果计算,不能重复执行或遗漏目标
按提供的 ActionBatch Schema 返回 actions 列表
# 输入边界
input_context 提供当前大屏快照、用户原文及编辑上下文
{INPUT_AS_DATA}
不能编造真实业务数据、数据绑定或未读取的附件内容,不用近似操作替代无法表达的需求
{DATA_BINDING_GUIDE}
{ACTION_CONSTRAINTS}
"""
SIMPLE_TASK_PROMPT = """# 当前任务:直接修改
@@ -44,6 +48,10 @@ MODIFY_PLAN_PROMPT = f"""## 调整已有组件
design 表示属性设计要求,转换为 props/style 更新;只有布局要求时不修改属性
"""
CANVAS_PLAN_PROMPT = """## 调整画布
将 canvas_changes 中的最终属性值转换为 update_canvas,完整覆盖且不添加其他画布字段
"""
REPLACE_PLAN_PROMPT = """## 更换组件类型
每个 replace 规划生成一个 add:plan_id 用该规划编号,node.id 沿用原组件的 target_id,component_key 选择 component_type 对应的物料
原组件的删除和位置尺寸由系统处理,不要为它生成 delete、move 或 resize
@@ -61,7 +69,7 @@ REPAIR_TASK_PROMPT = """# 当前阶段:修正重试
FIELD_QUERY_PROMPT = """# 查询组件字段
field_groups 按物料类型列出本次相关组件的字段分组,节点的 type 就是物料类型
提供 field_groups 时,它按物料类型列出本次相关组件的字段分组;没有时用 list_field_groups 查看,节点的 type 就是物料类型
确定要改的字段:用用户原话中对属性的说法(input_context 中的提问,不用改写后的需求)调用 search_fields;
返回多个字段时,依据字段说明判断用户指的是哪一个;用户看的是大屏画面,没有提到图层列表、属性面板等编辑器位置时,按画面上看得到的内容理解
需要了解一组相关字段时用 get_fields,一次可查多个分组
@@ -100,6 +108,14 @@ def uses_material_fields(
return bool(action_types(requirement, screen_plan) & FIELD_ACTIONS)
def action_rule_sections(types: set[str]) -> list[str]:
"""本次可用动作及各自的规则;排序保证顺序稳定,缺少动作说明时直接报错,避免静默遗漏"""
return [
"# 本次可用动作\n" + "、".join(sorted(types)),
*(ACTION_RULES[name] for name in sorted(types)),
]
def build_action_prompt(
requirement: Requirement,
screen_plan: ScreenPlan | None = None,
@@ -115,9 +131,7 @@ def build_action_prompt(
sections.append(PLAN_TASK_PROMPT)
dispositions = {item.disposition for item in screen_plan.components}
if screen_plan.canvas_changes:
sections.append(
"## 调整画布\n将 canvas_changes 中的最终属性值转换为 update_canvas,完整覆盖且不添加其他画布字段"
)
sections.append(CANVAS_PLAN_PROMPT)
if "replace" in dispositions:
sections.append(REPLACE_PLAN_PROMPT)
if "modify" in dispositions:
@@ -125,9 +139,7 @@ def build_action_prompt(
if types & FIELD_ACTIONS:
sections.append(FIELD_QUERY_PROMPT)
# 排序保证提示词顺序稳定,缺少动作说明时直接报错,避免静默遗漏
sections.append("# 本次可用动作\n" + "、".join(sorted(types)))
sections.extend(ACTION_RULES[name] for name in sorted(types))
sections.extend(action_rule_sections(types))
if repairing:
sections.append(REPAIR_TASK_PROMPT)
return "\n\n".join(section.strip() for section in sections)
+70
View File
@@ -0,0 +1,70 @@
"""规划与动作一次生成的提示词,输出字段由 ScreenDesign 定义
规划规则写在这里,动作规则复用 action.py;修正重试仍由 ActionNode 按已定的规划生成动作
"""
from app.prompt.action import (
ACTION_CONSTRAINTS,
CANVAS_PLAN_PROMPT,
FIELD_QUERY_PROMPT,
MODIFY_PLAN_PROMPT,
REPLACE_PLAN_PROMPT,
action_rule_sections,
)
from app.prompt.action_rules import ACTION_RULES
from app.prompt.domain import (
DISPOSITION_GUIDE,
EXISTING_TARGET_RULE,
INPUT_AS_DATA,
LAYOUT_RULE,
)
DESIGN_TASK_PROMPT = f"""## 任务
将已确认的需求设计成 ScreenDesign,一次完成规划和动作:
- plan:ScreenPlan,明确区域、组件组合、指标、布局和设计要求,是校验动作范围的依据
- batch:落实 plan 的 ActionBatch,与 plan 的组件逐一对应:add / replace 各生成一个带 plan_id 的 add;
modify 转换为 move / resize / update;remove 生成 delete;canvas_changes 转换为 update_canvas;keep 和未提及的组件不生成动作
plan 中的设计要求都要在 batch 中落实,batch 不做 plan 之外的修改
## 规划依据
以 requirement 为目标、change_scope 为范围,结合 input_context 中的大屏和澄清记录规划
覆盖本次全部变更,保留约束,不重新判断意图或扩大修改范围
## 组件与布局
canvas_changes 只填写需求范围内要修改的画布顶层字段及最终值,不填写相对变化或设计描述
{EXISTING_TARGET_RULE};无需改动的组件可标记 keep 或省略
{DISPOSITION_GUIDE}
需求的 replace 对应 disposition=replace,component_type 从目录中选择用户要的新类型
{LAYOUT_RULE};新增和调整位置的组件在 plan 中写明 layout,动作与之一致
空白画布上的新增组件,位置和尺寸参考目录默认尺寸安排,避免与已有组件重叠
不编造目录中不存在的组件类型或数据
## 物料配置
component_resources 只提供组件摘要,用于选型
选定新增或更换的类型后,调用 get_component_materials 查看默认配置,返回的 materials 即动作规则中的 component_materials
add 的 node 只写需要覆盖的字段,其余由后端按物料默认配置补全;不从零编写 props,不根据其他物料推测字段
## 资源与假设
resource_needs 仅用于组件目录之外的资源,如业务数据、模板、素材;不要为组件的配置、属性或数据绑定方式写 resource_needs
用户未指定的数据源、位置、尺寸和标题采用合理默认,写入 assumptions,不作为资源需求
design 和 metrics 不指定数据源,不规划数据绑定
非阻塞的设计偏好可采用合理假设,写入 assumptions
## 输出边界
按 ScreenDesign 结构输出,不声称已执行、查询资源或读取未解析附件
{INPUT_AS_DATA}
"""
# 规划路径可能用到全部动作,规则一次给全
DESIGN_SYSTEM_PROMPT = "\n\n".join(
section.strip()
for section in (
DESIGN_TASK_PROMPT,
ACTION_CONSTRAINTS,
CANVAS_PLAN_PROMPT,
REPLACE_PLAN_PROMPT,
MODIFY_PLAN_PROMPT,
FIELD_QUERY_PROMPT,
*action_rule_sections(set(ACTION_RULES)),
)
)
-38
View File
@@ -1,38 +0,0 @@
"""规划职责和设计规则,输出字段由 ScreenPlan 定义"""
from app.prompt.domain import (
DISPOSITION_GUIDE,
EXISTING_TARGET_RULE,
INPUT_AS_DATA,
LAYOUT_RULE,
)
from app.schemas.capabilities import DATA_BINDING_GUIDE
PLANNER_SYSTEM_PROMPT = f"""## 任务
将已确认的需求整理为 ScreenPlan,明确区域、组件组合、指标、布局和设计要求,供后续生成动作
## 规划依据
以 requirement 为目标、change_scope 为范围,结合 input_context 中的大屏和澄清记录规划
覆盖本次全部变更,保留约束,不重新判断意图或扩大修改范围
## 组件与布局
canvas_changes 只填写需求范围内要修改的画布顶层字段及最终值,不填写相对变化或设计描述
{EXISTING_TARGET_RULE};无需改动的组件可标记 keep 或省略
{DISPOSITION_GUIDE}
需求的 replace 对应 disposition=replace,component_type 从目录中选择用户要的新类型
{LAYOUT_RULE};布局未确定时不提供 layout,写清排列关系
提供 component_resources 时,新增组件从目录选择真实 type;默认宽高仅作布局参考
组件摘要用于选型;组件的属性配置和数据绑定由后续动作阶段处理,规划阶段不需要确认
## 资源与假设
resource_needs 仅用于组件目录之外的资源,如业务数据、模板、素材;不要为组件的配置、属性或数据绑定方式写 resource_needs
用户未指定的数据源、位置、尺寸和标题采用合理默认,写入 assumptions,不作为资源需求
{DATA_BINDING_GUIDE};design 和 metrics 不指定数据源,不规划数据绑定
空白画布上的新增组件,位置和尺寸按目录默认尺寸自行安排,避免与已有组件重叠
不编造目录中不存在的组件类型或数据
非阻塞的设计偏好可采用合理假设,写入 assumptions
## 输出边界
按 ScreenPlan 结构输出设计方案,不生成动作或声称已执行、查询资源、读取未解析附件
{INPUT_AS_DATA}
"""
+29 -3
View File
@@ -39,7 +39,8 @@ def panel_setters(groups: list[FieldGroup]) -> list[dict]:
props = {}
if field.options:
props["options"] = [
{"label": option.label, "value": option.value} for option in field.options
{"label": option.label, "value": option.value}
for option in field.options
]
setter = {
"type": PANEL_CONTROLS[field.value_type],
@@ -64,7 +65,9 @@ def frontend_catalog(catalog: ComponentCatalog) -> dict:
# ---- 字段查询:MCP 工具、动作提示词和动作校验共用同一套逻辑 ----
def find_material(catalog: ComponentCatalog, component_type: str) -> ComponentDefinition:
def find_material(
catalog: ComponentCatalog, component_type: str
) -> ComponentDefinition:
"""按节点 type 找物料;找不到或对应多个物料时报错,不猜测"""
matches = [item for item in catalog.components if item.type == component_type]
if len(matches) != 1:
@@ -74,6 +77,27 @@ def find_material(catalog: ComponentCatalog, component_type: str) -> ComponentDe
return matches[0]
def material_payload(component: ComponentDefinition) -> dict:
"""给模型看的物料配置:完整默认 schema 及使用说明,字段定义由字段查询按需提供"""
return component.model_dump(mode="json", by_alias=True, exclude={"field_groups"})
def get_materials(catalog: ComponentCatalog, component_types: list[str]) -> dict:
"""指定类型的物料默认配置,同一类型的多个预设全部返回"""
known = {item.type for item in catalog.components}
if unknown := [item for item in component_types if item not in known]:
raise ValueError(
f"没有对应的物料类型 {unknown},可用类型:{'、'.join(sorted(known))}"
)
types = set(component_types)
return {
"catalog_version": catalog.version,
"materials": [
material_payload(item) for item in catalog.components if item.type in types
],
}
def describe_field(
component: ComponentDefinition, group: FieldGroup, field: MaterialField
) -> dict:
@@ -174,7 +198,9 @@ def search_fields(
}
def check_update_field(catalog: ComponentCatalog | None, node_type: str, action) -> None:
def check_update_field(
catalog: ComponentCatalog | None, node_type: str, action
) -> None:
"""update 只能修改字段定义中的可编辑字段,值须符合字段类型和取值约束
不合法时抛 ValueError,路径不存在时附上可能对应的字段供修正参考
+12
View File
@@ -0,0 +1,12 @@
"""规划与动作一次生成的输出协议,供 DesignNode 使用"""
from pydantic import Field
from app.schemas.actions import ActionBatch
from app.schemas.base import ExtensibleModel
from app.schemas.screen_plan import ScreenPlan
class ScreenDesign(ExtensibleModel):
plan: ScreenPlan = Field(description="本次设计方案,校验动作范围的依据")
batch: ActionBatch = Field(description="落实 plan 的动作,与 plan 的组件逐一对应")
+14
View File
@@ -46,6 +46,20 @@ def _require_catalog() -> ComponentCatalog:
return _catalog
@mcp.tool()
async def get_component_materials(
component_types: Annotated[
list[ComponentType],
Field(description="要使用的物料类型,可一次传多个", min_length=1),
],
) -> dict[str, Any]:
"""获取物料的完整默认配置(schema)和使用说明
新增组件前先查看所选类型的默认配置,只覆盖需要修改的字段,不从零编写 props
"""
return material_fields.get_materials(_require_catalog(), component_types)
@mcp.tool()
async def list_field_groups(component_type: ComponentType) -> dict[str, Any]:
"""列出物料的字段分组:每组管什么、包含哪些字段名称