feat:新增物料

This commit is contained in:
2026-10-13 04:16:00 -07:00
parent b117f21e3a
commit a80727b7a0
15 changed files with 174 additions and 54 deletions
+103 -28
View File
@@ -1,7 +1,9 @@
"""apply_actions 里的 $query:把字段值写成 {"$query": "SELECT ..."},提交时替换成上传数据的查询结果
模型不用把查询结果逐行抄进动作,又慢又容易抄错;替换发生在 run_batch 之前,替换后照常做字段校验
结果是对象数组,键是查询结果的列名,正好对应图表物料的 dataset.source;series 的 encode 按列名引用
结果的形状按写入字段的定义:单个值的字段(指标卡的数值等)取一行一列的那个值,元素是数字、文字的数组
(指标卡的趋势数据)取唯一一列;其余是对象数组,键是查询结果的列名,正好对应图表物料的 dataset.source,
series 的 encode 按列名引用
"""
from dataclasses import dataclass
@@ -12,42 +14,64 @@ from pydantic import JsonValue
from app.actions.batch import BatchError
from app.agent.data_tools import DataContext
from app.config import settings
from app.resources.material_fields import find_material, resolve_field_groups
from app.schemas.actions import AddAction, AgentAction, UpdateAction
from app.schemas.component_resource import ComponentCatalog
from app.schemas.material_field import MaterialField
from app.schemas.screen import Screen
QUERY_KEY = "$query"
# 图表物料的数据都在这里,series 的 encode 按它的列名引用;整个换成查询结果时数据就不再是示例数据
SOURCE_PATH = "props.option.dataset.source"
# 字段是这些类型时,$query 的结果取一行一列的值
SCALAR_TYPES = {"string", "number", "integer", "boolean", "color", "enum"}
# 数组元素是这些类型时,$query 的结果取唯一一列
LIST_ITEM_TYPES = {"string", "number", "integer", "boolean", "color"}
@dataclass(frozen=True)
class EmbeddedQuery:
"""一处替换:第几个动作、写到哪个字段、结果的列和行数,写进 apply_actions 的结果供模型核对"""
"""一处替换:第几个动作、写到哪个字段、结果的列和行数,写进 apply_actions 的结果供模型核对
写入单个值的字段时 value 是取到的值,模型据此核对数字、在回复里引用
"""
index: int
node_id: str
path: str
columns: list[str]
rows: int
value: JsonValue = None
Found = list[EmbeddedQuery]
def fill_queries(
actions: list[AgentAction], data: DataContext
actions: list[AgentAction],
data: DataContext,
catalog: ComponentCatalog | None = None,
screen: Screen | None = None,
) -> tuple[list[AgentAction], list[EmbeddedQuery]]:
"""替换 add 的 node 和 update 的 value 里所有的 $query;查询失败抛 BatchError,整批不生效
catalog 和 screen 用来查写入字段的定义,决定结果的形状;没有时都按对象数组
add 用了 $query 又没写 sample_data 时设为 false:数据来自用户上传的文件,不是示例数据
"""
# update 的目标可能是这一批前面新增的组件,类型按执行顺序记下
types = {node.id: node.type for node in screen.nodes} if screen else {}
filled: list[AgentAction] = []
embedded: list[EmbeddedQuery] = []
for index, action in enumerate(actions):
found: list[tuple[str, list[str], int]] = []
node_id = "" # 只有 add 和 update 有字段值,其他动作不会有替换
found: Found = []
try:
if isinstance(action, AddAction):
types[action.node.id] = action.node.type
node = _fill(
action.node.model_dump(mode="json", exclude_unset=True),
data,
_Target(
data, _fields(catalog, action.node.type), index, action.node.id
),
"",
found,
)
@@ -56,49 +80,70 @@ def fill_queries(
fields["node"] = node
fields.setdefault("sample_data", False)
action = AddAction.model_validate(fields)
node_id = action.node.id
elif isinstance(action, UpdateAction):
value = _fill(action.value, data, action.field_path, found)
target = _Target(
data,
_fields(catalog, types.get(action.target_id)),
index,
action.target_id,
)
value = _fill(action.value, target, action.field_path, found)
if found:
action = action.model_copy(update={"value": value})
node_id = action.target_id
except ValueError as error: # 含 QueryError
raise BatchError(index, action, str(error)) from error
embedded.extend(
EmbeddedQuery(index, node_id, path, columns, rows)
for path, columns, rows in found
)
embedded.extend(found)
filled.append(action)
return filled, embedded
def _fill(
value: JsonValue,
data: DataContext,
path: str,
found: list[tuple[str, list[str], int]],
) -> JsonValue:
@dataclass(frozen=True)
class _Target:
"""一个动作的替换上下文:查询用的数据、组件的字段定义、动作序号和组件 ID"""
data: DataContext
fields: dict[str, MaterialField]
index: int
node_id: str
def _fields(
catalog: ComponentCatalog | None, node_type: str | None
) -> dict[str, MaterialField]:
"""组件类型的字段定义,按路径索引;没有目录或类型不认识时为空,结果按对象数组处理"""
if catalog is None or node_type is None:
return {}
try:
component = find_material(catalog, node_type)
except ValueError: # 物料不存在,补全或执行时会报错
return {}
return {
field.path: field
for group in resolve_field_groups(catalog, component)
for field in group.fields
}
def _fill(value: JsonValue, target: _Target, path: str, found: Found) -> JsonValue:
if isinstance(value, dict):
if QUERY_KEY in value:
return _query(value, data, path, found)
return _query(value, target, path, found)
return {
key: _fill(item, data, _join(path, key), found)
key: _fill(item, target, _join(path, key), found)
for key, item in value.items()
}
if isinstance(value, list):
return [
_fill(item, data, _join(path, position), found)
_fill(item, target, _join(path, position), found)
for position, item in enumerate(value)
]
return value
def _query(
value: dict[str, Any],
data: DataContext,
path: str,
found: list[tuple[str, list[str], int]],
) -> list[dict[str, Any]]:
value: dict[str, Any], target: _Target, path: str, found: Found
) -> JsonValue:
data = target.data
sql = value[QUERY_KEY]
if len(value) != 1 or not isinstance(sql, str):
raise ValueError(
@@ -118,7 +163,37 @@ def _query(
raise ValueError(
f"{path} 的 $query 结果有重名的列 {'、'.join(duplicated)},用 AS 起不同的别名"
)
found.append((path, result.columns, len(result.rows)))
shape = (
f"{len(result.rows)} 行 {len(result.columns)} 列({'、'.join(result.columns)})"
)
field = target.fields.get(path)
if field is not None and field.value_type in SCALAR_TYPES:
if len(result.rows) != 1 or len(result.columns) != 1:
raise ValueError(
f"{path}({field.label})是单个值,$query 的结果要一行一列,实际 {shape}"
)
scalar = result.rows[0][0]
found.append(
EmbeddedQuery(target.index, target.node_id, path, result.columns, 1, scalar)
)
return scalar
listed = (
field is not None
and field.value_type == "array"
and field.item_type in LIST_ITEM_TYPES
)
if listed and len(result.columns) != 1:
raise ValueError(
f"{path}({field.label})是一组值,$query 的结果要只有一列,实际 {shape};"
"排序用的列不用选出来,写在 ORDER BY 里"
)
found.append(
EmbeddedQuery(
target.index, target.node_id, path, result.columns, len(result.rows)
)
)
if listed:
return [row[0] for row in result.rows]
return [dict(zip(result.columns, row, strict=True)) for row in result.rows]
+9 -3
View File
@@ -131,9 +131,13 @@ def _result_text(
lines.append("$query 已替换为查询结果:")
for item in queries:
note = "(结果为空,组件会没有数据)" if not item.rows else ""
result = (
f"取值 {item.value}"
if item.value is not None
else f"{item.rows} 行{note},列 {'、'.join(item.columns)}"
)
lines.append(
f"- 第 {item.index + 1} 个动作 {item.node_id} 的 {item.path}:"
f"{item.rows} 行{note},列 {'、'.join(item.columns)}"
f"- 第 {item.index + 1} 个动作 {item.node_id} 的 {item.path}:{result}"
)
old_layouts = {node.id: node.layout for node in before.nodes}
@@ -246,7 +250,9 @@ def apply_actions(
else ""
)
try:
actions, queries = fill_queries(actions, runtime.context.data)
actions, queries = fill_queries(
actions, runtime.context.data, runtime.context.catalog, before
)
completed, after = run_batch(before, runtime.context.catalog, actions)
# 这一批改过数据或映射的组件都核对,不论数据来自这一批的 $query、之前的提交还是直接写入
problems = encode_problems(after, rebound_nodes(before, after))