feat:架构重构

This commit is contained in:
2026-10-01 23:08:06 -07:00
parent b82dc93404
commit 68588d1f92
74 changed files with 1131 additions and 3492 deletions
-3
View File
@@ -2,7 +2,6 @@
from .input_context import InputContext
from .request import DesignRequest, RequestContext
from .requirement import Requirement, RequirementChange
from .screen import Canvas, Layout, Screen, ScreenNode
__all__ = [
@@ -11,8 +10,6 @@ __all__ = [
"InputContext",
"Layout",
"RequestContext",
"Requirement",
"RequirementChange",
"Screen",
"ScreenNode",
]
-19
View File
@@ -1,19 +0,0 @@
"""动作错误的修正建议"""
from typing import Self
from pydantic import BaseModel, Field, model_validator
from app.schemas.base import NonBlankString
class ActionRepairPlan(BaseModel):
retryable: bool = Field(description="是否可在现有需求和快照下修正")
reason: NonBlankString = Field(description="错误原因或无法修正的原因")
suggestions: list[NonBlankString] = Field(default_factory=list, description="重新生成动作时的具体修正建议")
@model_validator(mode="after")
def require_suggestions(self) -> Self:
if self.retryable and not self.suggestions:
raise ValueError("可重试时必须提供修正建议")
return self
-30
View File
@@ -1,30 +0,0 @@
"""动作校验错误,供后续修正节点使用"""
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
class ActionValidationError(BaseModel):
action_index: int | None = Field(
default=None, ge=0, description="失败动作的下标,从 0 开始,整批错误为 null"
)
code: Literal[
"schema_error", "generation_error", "scope_error", "field_error", "execution_error"
]
message: str
def schema_errors(error: ValidationError) -> list[ActionValidationError]:
errors = []
for item in error.errors(include_url=False, include_input=False):
location = item["loc"]
index = location[1] if len(location) > 1 and type(location[1]) is int else None
errors.append(
ActionValidationError(
action_index=index,
code="schema_error",
message=f"{' / '.join(map(str, location))}: {item['msg']}",
)
)
return errors
+9 -21
View File
@@ -1,8 +1,8 @@
"""动作类型统一入口,新增操作时在这里显式加入联合类型"""
from typing import Annotated, Self
from typing import Annotated
from pydantic import Field, TypeAdapter, model_validator
from pydantic import Field
from .add import AddAction
from .base import ActionModel, BaseAction
@@ -14,29 +14,18 @@ from .update_canvas import UpdateCanvasAction
# 按 type 选择对应模型,不维护重复的操作名称映射
Action = Annotated[
AddAction | DeleteAction | MoveAction | ResizeAction | UpdateAction | UpdateCanvasAction,
AddAction
| DeleteAction
| MoveAction
| ResizeAction
| UpdateAction
| UpdateCanvasAction,
Field(discriminator="type"),
]
action_adapter = TypeAdapter(Action)
class ActionBatch(ActionModel):
actions: list[Action] = Field(description="按数组顺序执行的动作列表,允许为空")
unsupported_reason: str | None = Field(
default=None,
description="需求中有任何部分无法用现有动作或查到的字段完成时,说明缺少什么能力,此时 actions 为空;能完成时为 null",
)
@model_validator(mode="after")
def unsupported_has_no_actions(self) -> Self:
if self.unsupported_reason and self.actions:
raise ValueError("填写 unsupported_reason 时 actions 必须为空,不能只执行需求的一部分")
return self
__all__ = [
"Action",
"ActionBatch",
"ActionModel",
"AddAction",
"BaseAction",
"DeleteAction",
@@ -44,5 +33,4 @@ __all__ = [
"ResizeAction",
"UpdateAction",
"UpdateCanvasAction",
"action_adapter",
]
+10 -8
View File
@@ -10,16 +10,18 @@ from app.schemas.screen import ScreenNode
class AddAction(ActionModel):
"""按物料新增组件"""
type: Literal["add"] = "add"
component_key: NonBlankString = Field(
description="物料目录编号,即 list_components 返回的 key"
)
node: ScreenNode = Field(
description="待新增的完整组件,包含新 ID、类型、布局和配置"
)
plan_id: NonBlankString | None = Field(
default=None, description="对应的规划组件编号,独立模拟时可省略"
)
component_key: NonBlankString | None = Field(
default=None, description="使用的物料目录编号,规划生成时必填"
description="新组件。id、type、layout 必填,其余只写需要覆盖物料默认配置的字段;"
"后端按物料默认配置补全:对象逐层覆盖,数组整体替换,null 明确覆盖。"
"id 不能与现有组件重复,同一批里先 delete 的 ID 可以再用;type 必须与物料一致"
)
sample_data: bool = Field(
default=False, description="物料含示例数据,不代表已获取真实业务数据"
default=False,
description="由后端按物料设置,不用填写;true 表示组件使用物料示例数据,不是真实业务数据",
)
+2
View File
@@ -6,4 +6,6 @@ from .base import BaseAction
class DeleteAction(BaseAction):
"""删除已有组件"""
type: Literal["delete"] = "delete"
+2
View File
@@ -10,6 +10,8 @@ from .base import BaseAction
class MoveAction(BaseAction):
"""按相对位移移动组件。目标是绝对位置时,用目标坐标减去当前坐标,例如当前 x=100、目标 x=150 时 delta_x=50"""
type: Literal["move"] = "move"
delta_x: Number = Field(
default=0, description="水平位移,单位 px,正数向右,负数向左"
+2
View File
@@ -10,6 +10,8 @@ from .base import BaseAction
class ResizeAction(BaseAction):
"""设置组件的绝对宽高,不改变位置"""
type: Literal["resize"] = "resize"
width: PositiveNumber | None = Field(
default=None, description="目标宽度,单位 px,省略或 null 表示保持原值"
+3 -1
View File
@@ -12,12 +12,14 @@ PathSegment = NonBlankString | Annotated[int, Field(ge=0)]
class UpdateAction(BaseAction):
"""替换组件的一个字段,不做深度合并;组件中还没有的中间对象由后端补齐。位置和尺寸用 move / resize,不用 update"""
type: Literal["update"] = "update"
section: Literal["name", "props", "style"] = Field(
description="字段路径的第一段:name 为组件名称,props / style 为组件属性区域"
)
path: list[PathSegment] = Field(
description="字段路径第一段之后的各段,字符串为对象键,整数为数组下标;name 没有后续路径,传空列表"
description='字段路径第一段之后的各段,字符串为对象键,整数为数组下标;name 没有后续路径,传空列表。例如字段 props.option.title.text 写成 section=props、path=["option", "title", "text"]'
)
value: JsonValue = Field(description="替换该字段的值,必须符合字段定义的类型和取值")
+3 -1
View File
@@ -10,8 +10,10 @@ from .base import ActionModel
class UpdateCanvasAction(ActionModel):
"""修改画布属性"""
type: Literal["update_canvas"] = "update_canvas"
properties: dict[NonBlankString, JsonValue] = Field(
min_length=1,
description="要替换的 canvas 顶层字段,如 width、height、backgroundColor;未提供的字段保持不变",
description="要替换的 canvas 顶层字段及最终值,如 width、height、backgroundColor;未提供的字段保持不变。改变画布尺寸不会自动缩放或移动组件",
)
+2 -17
View File
@@ -1,22 +1,7 @@
"""当前后端能力边界的单一来源,接入新能力时只改这里,提示词和校验自动同步"""
"""当前后端能力边界:接入数据源后去掉 dataId 检查"""
# 数据源绑定尚未接入,新增组件只能使用物料自带的示例数据
DATA_BINDING_SUPPORTED = False
# 写进提示词的能力说明,避免各节点各写一套
DATA_BINDING_GUIDE = (
""
if DATA_BINDING_SUPPORTED
else "目前尚未接入数据源绑定,组件只使用物料自带的示例数据,不生成 dataId;"
"用户提到具体数据源时,不视为必须满足的需求,在 assumptions 中说明使用示例数据,不向用户提供数据源选项"
)
# 校验不通过时返回的说明
# 新增组件带 dataId 时返回给模型的说明
DATA_BINDING_ERROR = "当前尚未接入数据绑定,不能生成 dataId,请使用物料示例数据"
# 目前唯一能查询的资源是物料目录,进入规划前已完整获取;业务数据、模板、素材等查询尚未接入
# 模型提出的资源需求在接入前不阻塞执行:组件类已由目录满足,其余缺口使用物料示例数据并写入假设
RESOURCE_QUERY_SUPPORTED = False
RESOURCE_FALLBACK_ASSUMPTION = (
"尚未接入物料目录之外的资源查询,所需内容使用物料默认配置和示例数据"
)
-38
View File
@@ -1,38 +0,0 @@
"""修改范围及后续能力需求,由需求按规则推导,不调用模型"""
from typing import Literal, Self
from pydantic import Field
from app.schemas.base import ExtensibleModel, NonBlankString
from app.schemas.operations import is_screen_wide, requires_planning
from app.schemas.requirement import Requirement
class ChangeScope(ExtensibleModel):
scope: Literal["local", "screen"] = Field(
description="local=只能改动 target_ids 中的已有组件;screen=整体调整,可涉及任意已有组件"
)
target_ids: list[NonBlankString] = Field(
default_factory=list, description="需求明确指定的已有组件 ID,新增内容和画布属性可为空"
)
needs_planning: bool = Field(description="是否需要布局、组件组合或设计规划")
@classmethod
def from_requirement(cls, requirement: Requirement) -> Self:
"""范围和是否规划都由需求变更决定,规则集中在 operations"""
changes = requirement.changes
return cls(
scope=(
"screen"
if any(is_screen_wide(c.target_scope, c.operation) for c in changes)
else "local"
),
# 保持需求中的出现顺序并去重
target_ids=list(
dict.fromkeys(node_id for c in changes for node_id in c.target_ids)
),
# 新建大屏总是先规划;修改大屏时按操作能力判断
needs_planning=requirement.intent == "create_screen"
or any(requires_planning(c.target_scope, c.operation) for c in changes),
)
+4 -2
View File
@@ -8,7 +8,9 @@ from app.schemas.base import ExtensibleModel, NonBlankString
class ClarificationOption(ExtensibleModel):
id: NonBlankString = Field(description="本题内唯一的选项 ID")
id: NonBlankString = Field(
description="本题内唯一的选项 ID;选项对应某个组件时用该组件的 ID"
)
label: NonBlankString = Field(description="简短选项名称")
description: NonBlankString = Field(description="一句话说明选择的影响或推荐依据")
recommended: bool = False
@@ -16,7 +18,7 @@ class ClarificationOption(ExtensibleModel):
class Clarification(ExtensibleModel):
question: NonBlankString = Field(
description="一个简短、具体、便于用户回答的澄清问题"
description="一个简短、具体、便于用户回答的澄清问题,一次只问一件事"
)
options: list[ClarificationOption] = Field(
default_factory=list,
+7 -39
View File
@@ -1,6 +1,6 @@
"""前端组件目录及查询结果"""
from typing import Literal, Self
from typing import Self
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -119,13 +119,17 @@ class ComponentCatalog(ExtensibleModel):
for group in component.field_groups
if isinstance(group, str) and group not in shared
]:
raise ValueError(f"物料 {component.key} 引用了不存在的共享分组: {missing}")
raise ValueError(
f"物料 {component.key} 引用了不存在的共享分组: {missing}"
)
if shadowed := [
group.key
for group in component.field_groups
if isinstance(group, FieldGroup) and group.key in shared
]:
raise ValueError(f"物料 {component.key} 的独有分组与共享分组重名: {shadowed}")
raise ValueError(
f"物料 {component.key} 的独有分组与共享分组重名: {shadowed}"
)
by_key = {group.key: group for group in self.field_groups}
fields = [
field
@@ -141,39 +145,3 @@ class ComponentCatalog(ExtensibleModel):
if problem:
raise ValueError(f"物料 {component.key} 字段 {field.path} {problem}")
return self
class ComponentQuery(ExtensibleModel):
key: NonBlankString | None = Field(default=None, description="精确匹配目录编号")
type: NonBlankString | None = Field(
default=None, description="精确匹配前端节点类型,可能对应多个预设"
)
keyword: NonBlankString | None = Field(
default=None, description="按名称、说明和标签查询,多个条件同时满足"
)
class ComponentResult(ExtensibleModel):
status: Literal["resolved", "not_found", "unsupported"]
components: list[ComponentDefinition] = Field(default_factory=list)
catalog_version: NonBlankString | None = None
message: NonBlankString
def to_planner_context(self) -> JsonObject:
# 显式提取摘要,避免默认数据、事件和扩展字段进入规划提示词
return {
"status": self.status,
"catalog_version": self.catalog_version,
"message": self.message,
"components": [
ComponentSummary(
key=component.key,
type=component.type,
name=component.name,
description=component.description,
default_width=component.schema_.layout.width,
default_height=component.schema_.layout.height,
).model_dump(mode="json")
for component in self.components
],
}
-1
View File
@@ -15,7 +15,6 @@ class Turn(BaseModel):
prompt: str # 用户原始输入
outcome: Literal["completed", "waiting_for_input", "failed", "cancelled"]
summary: str # 助手侧摘要,只描述后端生成了什么,不代表前端已执行
offer: str | None = None # 助手提出、等待用户确认的修改指令,下一轮同意时直接执行
created_at: datetime
+2
View File
@@ -10,6 +10,8 @@ from .base import NonBlankString
Content = str | dict[str, JsonValue] | list[JsonValue]
# 概率和置信度都限制在 0~1
Probability = Annotated[float, Field(ge=0, le=1)]
# 意图门控后的去向:明确的问答走问答节点,其余交给 screen agent
Route = Literal["qa", "screen"]
class DecisionModel(BaseModel):
-24
View File
@@ -1,24 +0,0 @@
"""助手提出的待确认修改,下一轮用户同意时直接执行,不再从历史文本里猜"""
from pydantic import Field
from app.schemas.base import ExtensibleModel, NonBlankString
# 提议字段的定义只写一次,直接生成和从回答中提取共用
OFFER_DESCRIPTION = (
"用户同意后要执行的独立完整修改指令,写明目标组件和修改内容,例如“把销售额统计从柱状图改成折线图”;"
"回复没有询问用户是否需要执行某个具体修改时为 null"
)
class InformReply(ExtensibleModel):
answer: NonBlankString = Field(
description="面向用户的回复:说明原因;提供 offer 时以询问是否需要执行的问句结尾"
)
offer: NonBlankString | None = Field(default=None, description=OFFER_DESCRIPTION)
class ExtractedOffer(ExtensibleModel):
"""从已经流式输出的普通回答中提取的提议"""
offer: NonBlankString | None = Field(default=None, description=OFFER_DESCRIPTION)
-69
View File
@@ -1,69 +0,0 @@
"""需求操作名词表的单一来源,需求协议、提示词、能力检查和范围校验都引用这里"""
# 模型可能使用的同义操作名,统一成词表中的标准名称;新增别名只改这里
OPERATION_ALIASES: dict[str, str] = {
"restyle": "update",
"change_type": "replace",
"convert": "replace",
}
# 直接修改已有组件支持的操作,每一项都要有对应的动作规则
NODE_OPERATIONS: tuple[str, ...] = ("move", "resize", "update", "delete")
# 直接修改画布支持的操作,最终都转换为 update_canvas 动作
CANVAS_OPERATIONS: tuple[str, ...] = ("update", "resize", "update_canvas")
# 规划路径中,对已有组件做调整的操作;reflow、layout 表示整体重排,只走规划
PLAN_ADJUST_OPERATIONS: tuple[str, ...] = (
"move",
"resize",
"update",
"reflow",
"layout",
)
# 只能通过规划完成的操作:新增要从物料目录选型并安排位置,重排要整体布局,
# 更换组件类型实际是换成另一种物料,要从目录选型,不能靠修改属性完成
PLAN_ONLY_OPERATIONS: tuple[str, ...] = ("add", "reflow", "layout", "replace")
# 写进协议描述和提示词的操作说明,词表变化时自动同步
OPERATION_GUIDE = (
f"修改已有组件用 {'、'.join(NODE_OPERATIONS)},"
"修改画布用 update 或 resize,新增用 add;"
"更换已有组件的类型(如折线图改饼图)用 replace,目标类型写入 parameters,不用 update;"
"颜色、背景、文字等样式和属性修改统一用 update,具体内容写入 parameters"
)
def normalize_operation(operation: str) -> str:
"""把同义操作名转换成标准名称,未登记的名称原样返回"""
return OPERATION_ALIASES.get(operation, operation)
def is_direct_operation(target_scope: str, operation: str) -> bool:
"""判断是否为无需规划即可直接转换为动作的操作"""
if target_scope == "nodes":
return operation in NODE_OPERATIONS
if target_scope == "canvas":
return operation in CANVAS_OPERATIONS
return False
def requires_planning(target_scope: str, operation: str) -> bool:
"""判断是否必须先规划:新增、重排、更换类型,以及没有指定组件的整体调整
未登记的操作不在此列,由能力检查判为不支持"""
if is_direct_operation(target_scope, operation):
return False
return operation in PLAN_ONLY_OPERATIONS or (
target_scope == "screen" and operation in PLAN_ADJUST_OPERATIONS
)
def is_screen_wide(target_scope: str, operation: str) -> bool:
"""没有指定组件的整体调整(如整体重排、统一风格),可以涉及大屏上任意已有组件"""
return target_scope == "screen" and operation != "add"
def is_canvas_operation(target_scope: str, operation: str) -> bool:
"""判断是否为画布属性修改"""
return target_scope == "canvas" and operation in CANVAS_OPERATIONS
-87
View File
@@ -1,87 +0,0 @@
"""需求理解的输出协议,描述用户诉求,不包含前端执行动作。"""
from typing import Literal, Self
from pydantic import Field, field_validator, model_validator
from .base import ExtensibleModel, JsonObject, NonBlankString
from .operations import OPERATION_GUIDE, normalize_operation
Intent = Literal["create_screen", "modify_screen", "qa", "unclear"]
TargetScope = Literal["nodes", "canvas", "screen", "unresolved"]
class RequirementChange(ExtensibleModel):
"""一项需求变更。目标范围显式声明,避免空 ID 被理解成修改整个大屏。"""
target_scope: TargetScope = Field(
description="nodes=已有节点;canvas=画布属性;screen=整体或新增内容;unresolved=目标待确认"
)
target_ids: list[NonBlankString] = Field(
default_factory=list, description="已有节点的 ID;新增组件不能虚构 ID"
)
target_description: NonBlankString | None = Field(
default=None, description="目标的自然语言描述,例如右侧区域、用户所说的那个图"
)
candidate_ids: list[NonBlankString] = Field(
default_factory=list,
description="仅 unresolved 时填写:当前大屏中可能是该目标的真实组件 ID;没有可能的组件时留空,不虚构",
)
operation: NonBlankString = Field(description=f"需求操作。{OPERATION_GUIDE}")
parameters: JsonObject = Field(
default_factory=dict,
description="需求参数,例如 delta_x=50;保留用户要求的单位与相对变化语义;属性修改用用户的说法描述改什么、改成什么,例如 {\"名字\": \"月度销售\"},不推断字段路径",
)
@field_validator("operation")
@classmethod
def normalize_operation_name(cls, value: str) -> str:
# 在协议层统一同义操作名,模型输出、测试构造和检查点恢复都经过这里
return normalize_operation(value)
@model_validator(mode="after")
def validate_target(self) -> Self:
if len(self.target_ids) != len(set(self.target_ids)):
raise ValueError("target_ids 不能包含重复 ID")
if self.target_scope == "nodes":
if not self.target_ids:
raise ValueError("nodes 范围必须提供 target_ids")
elif self.target_ids:
raise ValueError("只有 nodes 范围可以提供 target_ids")
if self.target_scope == "unresolved" and self.target_description is None:
raise ValueError("目标待确认时必须提供 target_description")
# 目标已确定时候选没有意义,直接丢弃,避免模型多填字段导致整份需求失效
if self.target_scope != "unresolved" and self.candidate_ids:
self.candidate_ids = []
return self
class Requirement(ExtensibleModel):
intent: Intent
goal: NonBlankString = Field(
description="结合上下文改写后的独立完整需求,补全可确定的指代,保留目标、操作和约束,不扩大修改范围"
)
changes: list[RequirementChange] = Field(default_factory=list)
needs_clarification: bool
clarification_question: NonBlankString | None = None
assumptions: list[NonBlankString] = Field(
default_factory=list,
description="非阻塞信息采用的默认假设,不可用来猜测删除目标",
)
constraints: list[NonBlankString] = Field(
default_factory=list,
description="用户明确提出的约束,例如只修改右侧、保留其他组件",
)
@model_validator(mode="after")
def validate_consistency(self) -> Self:
if self.needs_clarification != (self.clarification_question is not None):
raise ValueError("需要澄清时必须提供问题,不需要澄清时问题应为 null")
if self.intent == "unclear" and not self.needs_clarification:
raise ValueError("意图不明确时必须澄清")
if any(change.target_scope == "unresolved" for change in self.changes):
if not self.needs_clarification:
raise ValueError("存在待确认目标时必须澄清")
if self.intent == "qa" and self.changes:
raise ValueError("纯问答需求不应包含大屏变更")
return self
+4 -2
View File
@@ -16,8 +16,10 @@ class Canvas(ExtensibleModel):
class Layout(ExtensibleModel):
x: Number
y: Number
"""组件在画布上的绝对坐标和尺寸,单位 px"""
x: Number = Field(description="左上角横坐标")
y: Number = Field(description="左上角纵坐标")
width: PositiveNumber
height: PositiveNumber
-12
View File
@@ -1,12 +0,0 @@
"""规划与动作一次生成的输出协议,供 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 的组件逐一对应")
-130
View File
@@ -1,130 +0,0 @@
"""大屏规划协议,描述设计方案,不直接执行动作"""
from typing import Literal, Self
from pydantic import Field, model_validator
from app.schemas.base import ExtensibleModel, JsonObject, NonBlankString
from app.schemas.screen import Layout
class PlanRegion(ExtensibleModel):
region_id: NonBlankString = Field(description="规划内的区域编号,不是前端组件 ID")
name: NonBlankString = Field(description="区域名称,例如顶部指标区")
purpose: NonBlankString = Field(description="区域用途和展示重点")
layout_description: NonBlankString = Field(
description="区域位置、排列和空间分配说明"
)
layout: Layout | None = Field(
default=None, description="可选的建议位置和尺寸,画布绝对坐标,单位 px"
)
class PlanComponent(ExtensibleModel):
plan_id: NonBlankString = Field(
description="规划内的组件编号,用于关联资源,不作为新增组件的实际 ID"
)
disposition: Literal["keep", "modify", "add", "remove", "replace"] = Field(
description="保留、调整、新增、移除或更换类型,只表达规划意图,不代表执行能力已经支持;"
"replace 表示把 target_id 换成 component_type 对应的物料,保留原位置"
)
target_id: NonBlankString | None = Field(
default=None, description="已有组件 ID,新增时必须为空"
)
region_id: NonBlankString | None = Field(
default=None, description="所属规划区域,可为空,例如移除组件或未分区"
)
purpose: NonBlankString = Field(description="展示目的或调整原因")
component_type: NonBlankString | None = Field(
default=None, description="建议组件类型,不代表已在组件库中确认可用"
)
metrics: list[NonBlankString] = Field(
default_factory=list, description="展示的指标和维度描述,不是数据绑定"
)
design: JsonObject = Field(
default_factory=dict, description="样式、交互及内容等设计要求,保留扩展字段"
)
layout: Layout | None = Field(
default=None, description="可选的建议布局,画布绝对坐标,不是区域内相对坐标"
)
@model_validator(mode="after")
def validate_target(self) -> Self:
if self.disposition == "add":
if self.target_id is not None:
raise ValueError("新增组件不能指定已有 target_id")
elif self.target_id is None:
raise ValueError("保留、调整、移除或更换组件必须提供 target_id")
if self.disposition == "replace" and self.component_type is None:
raise ValueError("更换组件必须指定新的 component_type")
return self
class PlanResourceNeed(ExtensibleModel):
resource_id: NonBlankString = Field(
description="本次规划中的资源需求编号,不是已获取资源的 ID"
)
kind: NonBlankString = Field(
description="资源类别,例如 data、component、template、asset、knowledge,允许扩展"
)
description: NonBlankString = Field(description="需要查询或补充的内容")
purpose: NonBlankString = Field(description="资源用途和查询原因")
component_ids: list[NonBlankString] = Field(
default_factory=list, description="关联的 plan_id 列表,为空表示整个规划使用"
)
class ScreenPlan(ExtensibleModel):
goal: NonBlankString = Field(description="规划目标,遵循已确认需求")
design_summary: NonBlankString = Field(description="整体或局部设计思路")
layout_description: NonBlankString = Field(
description="区域关系、布局安排及保留内容"
)
canvas_changes: JsonObject = Field(
default_factory=dict,
description="要修改的画布顶层字段及最终值,宽高为绝对值,字段整体替换,为空表示不改画布",
)
regions: list[PlanRegion] = Field(default_factory=list)
components: list[PlanComponent] = Field(default_factory=list)
resource_needs: list[PlanResourceNeed] = Field(
default_factory=list, description="规划细化后的待查询资源,不能声称已经获取"
)
constraints: list[NonBlankString] = Field(
default_factory=list, description="沿用用户约束,不擅自放宽"
)
assumptions: list[NonBlankString] = Field(
default_factory=list, description="非阻塞设计偏好的默认假设"
)
@model_validator(mode="after")
def validate_references(self) -> Self:
region_ids = [region.region_id for region in self.regions]
plan_ids = [component.plan_id for component in self.components]
target_ids = [
component.target_id
for component in self.components
if component.target_id is not None
]
resource_ids = [resource.resource_id for resource in self.resource_needs]
for name, values in (
("region_id", region_ids),
("plan_id", plan_ids),
("target_id", target_ids),
("resource_id", resource_ids),
):
if len(values) != len(set(values)):
raise ValueError(f"规划中存在重复的 {name}")
for component in self.components:
if (
component.region_id is not None
and component.region_id not in region_ids
):
raise ValueError(f"组件引用了不存在的区域: {component.region_id}")
for resource in self.resource_needs:
if len(resource.component_ids) != len(set(resource.component_ids)):
raise ValueError(
f"资源 {resource.resource_id} 的 component_ids 不能重复"
)
if unknown := set(resource.component_ids) - set(plan_ids):
raise ValueError(f"资源引用了不存在的规划组件: {sorted(unknown)}")
return self