feat:新增物料
This commit is contained in:
+103
-28
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user