feat:架构重构
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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 表示组件使用物料示例数据,不是真实业务数据",
|
||||
)
|
||||
|
||||
@@ -6,4 +6,6 @@ from .base import BaseAction
|
||||
|
||||
|
||||
class DeleteAction(BaseAction):
|
||||
"""删除已有组件"""
|
||||
|
||||
type: Literal["delete"] = "delete"
|
||||
|
||||
@@ -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,正数向右,负数向左"
|
||||
|
||||
@@ -10,6 +10,8 @@ from .base import BaseAction
|
||||
|
||||
|
||||
class ResizeAction(BaseAction):
|
||||
"""设置组件的绝对宽高,不改变位置"""
|
||||
|
||||
type: Literal["resize"] = "resize"
|
||||
width: PositiveNumber | None = Field(
|
||||
default=None, description="目标宽度,单位 px,省略或 null 表示保持原值"
|
||||
|
||||
@@ -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="替换该字段的值,必须符合字段定义的类型和取值")
|
||||
|
||||
|
||||
@@ -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;未提供的字段保持不变。改变画布尺寸不会自动缩放或移动组件",
|
||||
)
|
||||
|
||||
@@ -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 = (
|
||||
"尚未接入物料目录之外的资源查询,所需内容使用物料默认配置和示例数据"
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 的组件逐一对应")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user