feat:完成数据文件上传解析及测试

This commit is contained in:
2026-10-09 06:42:28 -07:00
parent bb6a9405db
commit 4a6ec6d28c
11 changed files with 393 additions and 38 deletions
+21 -9
View File
@@ -1,5 +1,7 @@
"""数据文件的本地工具:describe_data 查看列概况,query_data 执行只读 SQL;问答和 screen agent 共用
apply_actions 里的 $query 也通过 DataContext.query 执行,见 tools.py
工具只能看到 DataContext 里的表,也就是当前用户在本会话里未过期的文件,查询引擎也只为这些表建视图
工具通过 runtime.context.data 读取 DataContext,用到它们的 agent 上下文都要有 data 字段;
上下文类型因 agent 而异,所以写 ToolRuntime[Any](不写类型参数时按 None 处理,序列化会告警)
@@ -14,7 +16,7 @@ from langchain_core.messages import ToolMessage
from pydantic import Field
from app.config import settings
from app.datafiles.query import QueryError, run_query
from app.datafiles.query import MAX_ROWS, QueryError, QueryResult, run_query
from app.schemas.data_file import RequestDataFile, RequestDataTable
# 列超过这么多时,不指定 columns 只返回列名和类型,避免一次塞进太多概况
@@ -39,6 +41,22 @@ class DataContext:
for table in file.tables
}
def query(self, sql: str, *, max_rows: int = MAX_ROWS) -> QueryResult:
"""执行一条只读 SQL;失败时抛 QueryError,原因可以直接返回给模型修正"""
paths = self.paths()
# 任务执行期间被删除或清理的文件跳过,不影响查询其他表
existing = {name: path for name, path in paths.items() if path.exists()}
try:
return run_query(
sql, existing, timeout=settings.data_query_timeout, max_rows=max_rows
)
except QueryError as error:
if gone := [name for name in paths if name not in existing]:
raise QueryError(
f"{error};{'、'.join(gone)} 的文件已删除或过期,不能再查询"
) from None
raise
def _error(runtime: ToolRuntime, message: str) -> ToolMessage:
# 返回给模型修正,不中断 agent
@@ -111,16 +129,10 @@ def query_data(sql: str, runtime: ToolRuntime[Any]) -> dict[str, Any] | ToolMess
回答和图表里的数字以查询结果为准,不要自己心算
"""
data: DataContext = runtime.context.data
paths = data.paths()
# 任务执行期间被删除或清理的文件跳过,不影响查询其他表
existing = {name: path for name, path in paths.items() if path.exists()}
try:
return run_query(sql, existing, timeout=settings.data_query_timeout).to_dict()
return data.query(sql).to_dict()
except QueryError as error:
message = str(error)
if gone := [name for name in paths if name not in existing]:
message += f";{'、'.join(gone)} 的文件已删除或过期,不能再查询"
return _error(runtime, message)
return _error(runtime, str(error))
DATA_TOOLS = [describe_data, query_data]
+50
View File
@@ -0,0 +1,50 @@
"""问答 agent:回答关于大屏和上传数据的问题
会话里有数据文件时挂 describe_data、query_data,先查询再回答;没有文件时不挂工具,只调用一次模型
"""
from dataclasses import dataclass, field
from typing import Any
from langchain.agents import create_agent
from langchain.agents.middleware import ModelCallLimitMiddleware
from app.agent.data_tools import DATA_TOOLS, DataContext
from app.clients.llm import get_llm
from app.config import settings
from app.prompt.qa import QA_DATA_RULES, QA_SYSTEM_PROMPT
@dataclass(frozen=True)
class QAContext:
data: DataContext = field(default_factory=DataContext) # 本会话可用的数据表
# 是否挂数据工具 → agent
_agents: dict[bool, Any] = {}
def get_qa_agent(with_data: bool = False):
"""第一次使用时组装,之后复用;没有数据文件的请求看不到数据工具和相关提示"""
if with_data not in _agents:
_agents[with_data] = create_agent(
model=get_llm(),
tools=DATA_TOOLS if with_data else [],
system_prompt=(
f"{QA_SYSTEM_PROMPT}\n{QA_DATA_RULES}"
if with_data
else QA_SYSTEM_PROMPT
),
context_schema=QAContext,
middleware=[
# 超过上限时抛 ModelCallLimitExceededError,由节点按失败处理;
# 写成 end 时中间件只插入一条英文说明,节点分不清是正常结束还是用尽了次数
ModelCallLimitMiddleware(
run_limit=settings.max_qa_steps, exit_behavior="error"
),
],
# 不继承外层图的检查点:会话历史只保存回答文本,不保存查询过程
checkpointer=False,
name="qa_agent",
)
return _agents[with_data]
+171
View File
@@ -0,0 +1,171 @@
"""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.schemas.actions import AddAction, AgentAction, UpdateAction
from app.schemas.screen import Screen
QUERY_KEY = "$query"
@dataclass(frozen=True)
class EmbeddedQuery:
"""一处替换:第几个动作、写到哪个字段、结果的列和行数,写进 apply_actions 的结果供模型核对"""
index: int
node_id: str
path: str
columns: list[str]
rows: int
def fill_queries(
actions: list[AgentAction], data: DataContext
) -> tuple[list[AgentAction], list[EmbeddedQuery]]:
"""替换 add 的 node 和 update 的 value 里所有的 $query;查询失败抛 BatchError,整批不生效
add 用了 $query 又没写 sample_data 时设为 false:数据来自用户上传的文件,不是示例数据
"""
filled: list[AgentAction] = []
embedded: list[EmbeddedQuery] = []
for index, action in enumerate(actions):
found: list[tuple[str, list[str], int]] = []
node_id = "" # 只有 add 和 update 有字段值,其他动作不会有替换
try:
if isinstance(action, AddAction):
node = _fill(
action.node.model_dump(mode="json", exclude_unset=True),
data,
"",
found,
)
if found:
fields = action.model_dump(mode="json", exclude_unset=True)
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)
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
)
filled.append(action)
return filled, embedded
def _fill(
value: JsonValue,
data: DataContext,
path: str,
found: list[tuple[str, list[str], int]],
) -> JsonValue:
if isinstance(value, dict):
if QUERY_KEY in value:
return _query(value, data, path, found)
return {
key: _fill(item, data, _join(path, key), found)
for key, item in value.items()
}
if isinstance(value, list):
return [
_fill(item, data, _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]]:
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 起不同的别名"
)
found.append((path, result.columns, len(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 encode_problems(screen: Screen, node_ids: set[str]) -> list[str]:
"""用了 $query 的组件,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"series.{position}.encode.{key} 用了 {'、'.join(missing)}"
)
if wrong:
problems.append(
f"组件 {node.id}:{';'.join(wrong)},但 dataset.source 的列是 {'、'.join(columns)};"
"把 encode 改成查询结果的列名,或在 SQL 里用 AS 起对应的别名"
)
return problems
+23 -3
View File
@@ -3,6 +3,7 @@
它们读写的是本次请求的大屏和 agent 状态,所以放在本地;物料等能力由 MCP 提供
"""
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Annotated, NotRequired
@@ -14,6 +15,7 @@ from pydantic import Field
from app.actions.batch import BatchError, run_batch
from app.agent.data_tools import DataContext
from app.agent.query_values import EmbeddedQuery, encode_problems, fill_queries
from app.schemas.actions import Action, AddAction, AgentAction
from app.schemas.component_resource import ComponentCatalog
from app.schemas.screen import Screen
@@ -58,6 +60,7 @@ def _result_text(
after: Screen,
actions: list[Action],
undeclared_sample: frozenset[str] = frozenset(),
queries: Sequence[EmbeddedQuery] = (),
) -> str:
"""执行结果:除了动作数量,还给出执行后的画布和每个组件的布局
@@ -75,10 +78,19 @@ def _result_text(
if guessed := [item for item in sample if item in undeclared_sample]:
lines.append(
f"未写 sample_data,按物料默认标成了示例数据:{'、'.join(guessed)};"
"如果这些组件展示的数据都来自大屏上已有的组件,把 sample_data 设为 false 重新提交整批,否则不用处理"
"如果这些组件展示的数据都来自大屏上已有的组件或上传的数据文件,把 sample_data 设为 false 重新提交整批,否则不用处理"
)
if declared := [item for item in sample if item not in undeclared_sample]:
lines.append(f"标为示例数据的新增组件:{'、'.join(declared)}")
# 模型提交时只写了 SQL,看不到结果;给出列名和行数,用来核对 encode 和数据量
if queries:
lines.append("$query 已替换为查询结果:")
for item in queries:
note = "(结果为空,组件会没有数据)" if not item.rows else ""
lines.append(
f"- 第 {item.index + 1} 个动作 {item.node_id} 的 {item.path}:"
f"{item.rows} 行{note},列 {'、'.join(item.columns)}"
)
old_layouts = {node.id: node.layout for node in before.nodes}
lines.append(
@@ -114,11 +126,17 @@ def apply_actions(
失败时整批不生效,返回带动作序号的错误,修正后重新提交整批;以最后一次调用的结果为准
"""
try:
actions, queries = fill_queries(actions, runtime.context.data)
completed, after = run_batch(
runtime.context.screen, runtime.context.catalog, actions
)
problems = encode_problems(after, {item.node_id for item in queries})
except BatchError as error:
message = f"执行失败,整批未生效:\n- {error}"
problems = [str(error)]
if problems:
message = "执行失败,整批未生效:\n" + "\n".join(
f"- {item}" for item in problems
)
return Command(
update={
"actions": None,
@@ -155,7 +173,9 @@ def apply_actions(
"apply_error": None,
"messages": [
ToolMessage(
_result_text(runtime.context.screen, after, completed, undeclared),
_result_text(
runtime.context.screen, after, completed, undeclared, queries
),
tool_call_id=runtime.tool_call_id,
)
],