263 lines
10 KiB
Python
263 lines
10 KiB
Python
"""apply_actions 里的 $query:把字段值写成 {"$query": "SELECT ..."},提交时替换成上传数据的查询结果
|
||
|
||
模型不用把查询结果逐行抄进动作,又慢又容易抄错;替换发生在 run_batch 之前,替换后照常做字段校验
|
||
结果的形状按写入字段的定义:单个值的字段(指标卡的数值等)取一行一列的那个值,元素是数字、文字的数组
|
||
(指标卡的趋势数据)取唯一一列;其余是对象数组,键是查询结果的列名,正好对应图表物料的 dataset.source,
|
||
series 的 encode 按列名引用
|
||
"""
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
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 component_fields, find_material
|
||
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 的结果供模型核对
|
||
|
||
写入单个值的字段时 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,
|
||
catalog: ComponentCatalog | None = None,
|
||
screen: Screen | None = None,
|
||
) -> tuple[list[AgentAction], list[EmbeddedQuery]]:
|
||
"""替换 add 的 node 和 update 的 value 里所有的 $query;查询失败抛 BatchError,整批不生效
|
||
|
||
catalog 和 screen 用来查写入字段的定义,决定结果的形状;没有时都按对象数组
|
||
"""
|
||
# 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: Found = []
|
||
try:
|
||
if isinstance(action, AddAction):
|
||
types[action.node.id] = action.node.type
|
||
node = _fill(
|
||
action.node.model_dump(mode="json", exclude_unset=True),
|
||
_Target(
|
||
data, _fields(catalog, action.node.type), index, action.node.id
|
||
),
|
||
"",
|
||
found,
|
||
)
|
||
if found:
|
||
fields = action.model_dump(mode="json", exclude_unset=True)
|
||
fields["node"] = node
|
||
action = AddAction.model_validate(fields)
|
||
elif isinstance(action, UpdateAction):
|
||
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})
|
||
except ValueError as error: # 含 QueryError
|
||
raise BatchError(index, action, str(error)) from error
|
||
embedded.extend(found)
|
||
filled.append(action)
|
||
return filled, embedded
|
||
|
||
|
||
@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 field in component_fields(catalog, component)}
|
||
|
||
|
||
def _fill(value: JsonValue, target: _Target, path: str, found: Found) -> JsonValue:
|
||
if isinstance(value, dict):
|
||
if QUERY_KEY in value:
|
||
return _query(value, target, path, found)
|
||
return {
|
||
key: _fill(item, target, _join(path, key), found)
|
||
for key, item in value.items()
|
||
}
|
||
if isinstance(value, list):
|
||
return [
|
||
_fill(item, target, _join(path, position), found)
|
||
for position, item in enumerate(value)
|
||
]
|
||
return value
|
||
|
||
|
||
def _query(
|
||
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(
|
||
f'{path} 的 $query 要写成 {{"$query": "SELECT ..."}},对象里不能有其他键'
|
||
)
|
||
if not data.files:
|
||
raise ValueError("会话里没有上传的数据文件,不能使用 $query")
|
||
limit = settings.data_embed_max_rows
|
||
result = data.query(sql, max_rows=limit)
|
||
if result.truncated:
|
||
raise ValueError(
|
||
f"{path} 的 $query 结果超过 {limit} 行,组件放不下;在 SQL 里聚合、筛选或 LIMIT"
|
||
)
|
||
if duplicated := sorted(
|
||
{name for name in result.columns if result.columns.count(name) > 1}
|
||
):
|
||
raise ValueError(
|
||
f"{path} 的 $query 结果有重名的列 {'、'.join(duplicated)},用 AS 起不同的别名"
|
||
)
|
||
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]
|
||
|
||
|
||
def _join(path: str, key: str | int) -> str:
|
||
return f"{path}.{key}" if path else str(key)
|
||
|
||
|
||
def _binding(node) -> tuple[Any, Any]:
|
||
option = node.props.get("option")
|
||
if not isinstance(option, dict):
|
||
return None, None
|
||
return option.get("dataset"), option.get("series")
|
||
|
||
|
||
def rebound_nodes(before: Screen, after: Screen) -> set[str]:
|
||
"""这一批改过数据或映射(option.dataset、option.series)的组件,新增的也算
|
||
|
||
只按前后状态比较,不看是哪种动作改的:$query、直接写数据、单独改 encode 都会让两者对不上;没动过的旧组件不核对
|
||
"""
|
||
old = {node.id: _binding(node) for node in before.nodes}
|
||
return {
|
||
node.id
|
||
for node in after.nodes
|
||
if node.id not in old or old[node.id] != _binding(node)
|
||
}
|
||
|
||
|
||
def encode_problems(screen: Screen, node_ids: set[str]) -> list[str]:
|
||
"""指定组件的 series encode 引用的列必须在 dataset.source 里
|
||
|
||
数据换了列名、或 encode 改成了不存在的列时图表是空的,前端也不报错,所以在这里拦下
|
||
"""
|
||
problems = []
|
||
for node in screen.nodes:
|
||
if node.id not in node_ids:
|
||
continue
|
||
option = node.props.get("option")
|
||
if not isinstance(option, dict):
|
||
continue
|
||
dataset = option.get("dataset")
|
||
source = dataset.get("source") if isinstance(dataset, dict) else None
|
||
# 空结果或不是对象数组时无从核对
|
||
if not (
|
||
isinstance(source, list)
|
||
and source
|
||
and all(isinstance(row, dict) for row in source)
|
||
):
|
||
continue
|
||
columns = list(dict.fromkeys(key for row in source for key in row))
|
||
series = option.get("series")
|
||
items = series if isinstance(series, list) else [series]
|
||
wrong = []
|
||
for position, item in enumerate(items):
|
||
encode = item.get("encode") if isinstance(item, dict) else None
|
||
if not isinstance(encode, dict):
|
||
continue
|
||
for key, names in encode.items():
|
||
missing = [
|
||
name
|
||
for name in (names if isinstance(names, list) else [names])
|
||
if isinstance(name, str) and name not in columns
|
||
]
|
||
if missing:
|
||
wrong.append(
|
||
f"props.option.series.{position}.encode.{key} 用了 {'、'.join(missing)}"
|
||
)
|
||
if wrong:
|
||
problems.append(
|
||
f"组件 {node.id}:{';'.join(wrong)},但 props.option.dataset.source 的列是 {'、'.join(columns)};"
|
||
"把 encode 改成 dataset.source 里的列名;数据来自 $query 时也可以在 SQL 里用 AS 起对应的别名"
|
||
)
|
||
return problems
|