From 4a6ec6d28c1919ccea19426821e264d0a09412b0 Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Fri, 9 Oct 2026 06:42:28 -0700 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=AE=8C=E6=88=90=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=8F=8A=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/data_tools.py | 30 ++++-- app/agent/qa_agent.py | 50 ++++++++++ app/agent/query_values.py | 171 +++++++++++++++++++++++++++++++++ app/agent/tools.py | 26 ++++- app/config.py | 13 +++ app/processor/nodes/qa_node.py | 76 +++++++++++---- app/processor/summary.py | 6 +- app/prompt/qa.py | 12 ++- app/schemas/actions/add.py | 6 +- app/schemas/actions/update.py | 5 +- skills/data-chart/SKILL.md | 36 +++++++ 11 files changed, 393 insertions(+), 38 deletions(-) create mode 100644 app/agent/qa_agent.py create mode 100644 app/agent/query_values.py create mode 100644 skills/data-chart/SKILL.md diff --git a/app/agent/data_tools.py b/app/agent/data_tools.py index dfd1eec..bf5646b 100644 --- a/app/agent/data_tools.py +++ b/app/agent/data_tools.py @@ -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] diff --git a/app/agent/qa_agent.py b/app/agent/qa_agent.py new file mode 100644 index 0000000..bdac8e0 --- /dev/null +++ b/app/agent/qa_agent.py @@ -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] diff --git a/app/agent/query_values.py b/app/agent/query_values.py new file mode 100644 index 0000000..85f2b4c --- /dev/null +++ b/app/agent/query_values.py @@ -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 diff --git a/app/agent/tools.py b/app/agent/tools.py index 8226376..7a1014d 100644 --- a/app/agent/tools.py +++ b/app/agent/tools.py @@ -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, ) ], diff --git a/app/config.py b/app/config.py index 6a630e9..0f13cf4 100644 --- a/app/config.py +++ b/app/config.py @@ -59,6 +59,10 @@ class Settings: max_agent_steps: int = field( default_factory=lambda: int(os.getenv("MAX_AGENT_STEPS", "12")) ) + # 问答 agent 单次运行的模型调用上限;查询数据时每次查询多一次调用 + max_qa_steps: int = field( + default_factory=lambda: int(os.getenv("MAX_QA_STEPS", "8")) + ) # 单次 MCP 工具调用的超时(秒),服务列表见 mcp_servers.json mcp_tool_timeout: float = field( default_factory=lambda: float(os.getenv("MCP_TOOL_TIMEOUT", "5")) @@ -109,6 +113,11 @@ class Settings: default_factory=lambda: float(os.getenv("DATA_QUERY_TIMEOUT", "10")) ) + # apply_actions 里一个 $query 写进组件的最多行数,超过时报错,让模型在 SQL 里聚合 + data_embed_max_rows: int = field( + default_factory=lambda: int(os.getenv("DATA_EMBED_MAX_ROWS", "1000")) + ) + # 用户中心:请求携带的 token 交给它校验,换回 user_id user_center_url: str = field( default_factory=lambda: ( @@ -145,6 +154,8 @@ class Settings: raise ValueError("HISTORY_MAX_TOKENS 必须是正整数") if self.max_agent_steps < 1: raise ValueError("MAX_AGENT_STEPS 必须是正整数") + if self.max_qa_steps < 1: + raise ValueError("MAX_QA_STEPS 必须是正整数") if self.mcp_tool_timeout <= 0: raise ValueError("MCP_TOOL_TIMEOUT 必须是正数") if self.data_file_max_mb < 1: @@ -153,6 +164,8 @@ class Settings: raise ValueError("DATA_FILE_TTL 必须是正整数") if self.data_query_timeout <= 0: raise ValueError("DATA_QUERY_TIMEOUT 必须是正数") + if self.data_embed_max_rows < 1: + raise ValueError("DATA_EMBED_MAX_ROWS 必须是正整数") if not 0 <= self.intent_qa_min_probability <= 1: raise ValueError("INTENT_QA_MIN_PROBABILITY 必须在 0 到 1 之间") diff --git a/app/processor/nodes/qa_node.py b/app/processor/nodes/qa_node.py index 85ff964..56b127c 100644 --- a/app/processor/nodes/qa_node.py +++ b/app/processor/nodes/qa_node.py @@ -1,14 +1,24 @@ -"""基于当前大屏和会话历史流式回答问题""" +"""基于当前大屏、上传的数据和会话历史流式回答问题 -from langchain_core.messages import HumanMessage, SystemMessage +由问答 agent 执行:会话里有数据文件时先查询再回答,否则只调用一次模型;只把模型输出的文字推送给前端 +""" + +import json + +from langchain.agents.middleware.model_call_limit import ModelCallLimitExceededError +from langchain_core.messages import AIMessageChunk, HumanMessage from langchain_core.output_parsers import StrOutputParser from langgraph.config import get_stream_writer -from app.clients.llm import get_llm +from app.agent.data_tools import DataContext +from app.agent.qa_agent import QAContext, get_qa_agent from app.processor.base import BaseNode from app.processor.history import history_messages from app.processor.state import QueryState -from app.prompt.qa import QA_SYSTEM_PROMPT +from app.processor.summary import data_file_summary + +# 模型在一次回答里分几次输出文字(例如查询前后各说一段)时,段落之间空一行 +PARAGRAPH_BREAK = "\n\n" class QANode(BaseNode): @@ -23,23 +33,42 @@ class QANode(BaseNode): except RuntimeError: writer = None + context = state["input_context"] + payload = context.model_dump( + mode="json", exclude_unset=True, exclude={"history", "data_files"} + ) + if context.data_files: + # 只给表名、行数和列名,文件路径和列概况不进提示词,需要时由 describe_data 查看 + payload["data_files"] = [ + data_file_summary(item) for item in context.data_files + ] + agent = get_qa_agent(with_data=bool(context.data_files)) + parser = StrOutputParser() parts: list[str] = [] - context = state["input_context"] - async for chunk in get_llm().astream( - [ - SystemMessage(content=QA_SYSTEM_PROMPT), - # 会话历史作为此前的对话消息,本次请求是最后一条用户消息 - *history_messages(context.history), - HumanMessage( - content=context.model_dump_json( - exclude_unset=True, exclude={"history"} - ) - ), - ] - ): - # 工具调用等无文本片段会得到空串 - if delta := parser.invoke(chunk): + last_message_id = None + try: + async for chunk, metadata in agent.astream( + { + "messages": [ + # 会话历史作为此前的对话消息,本次请求是最后一条用户消息 + *history_messages(context.history), + HumanMessage(content=json.dumps(payload, ensure_ascii=False)), + ] + }, + context=QAContext(data=DataContext(tuple(context.data_files))), + stream_mode="messages", + ): + # 工具结果、工具调用和中间件插入的消息都不推送 + if metadata.get("langgraph_node") != "model" or not isinstance( + chunk, AIMessageChunk + ): + continue + if not (delta := parser.invoke(chunk)): + continue + if parts and chunk.id != last_message_id: + delta = PARAGRAPH_BREAK + delta + last_message_id = chunk.id parts.append(delta) if writer is not None: writer( @@ -49,6 +78,15 @@ class QANode(BaseNode): "delta": delta, } ) + except ModelCallLimitExceededError: + self.logger.warning("问答 agent 超出模型调用上限(MAX_QA_STEPS)") + updated_state["status"] = "failed" + updated_state["answer"] = ( + "查询步骤超出上限,这次没有完成,可以缩小范围或换个问法再试" + ) + return updated_state + + # 推送了什么就保存什么,会话历史和前端看到的回答一致 answer = "".join(parts).strip() # 没有有效文本时报错,避免将空回答标记为处理完成 if not answer: diff --git a/app/processor/summary.py b/app/processor/summary.py index ca8b6d3..6ae472f 100644 --- a/app/processor/summary.py +++ b/app/processor/summary.py @@ -55,12 +55,12 @@ def request_summary(context: InputContext) -> JsonObject: for item in context.attachments if item.get("kind") != "data_file" ], - "data_files": [_data_file(item) for item in context.data_files], + "data_files": [data_file_summary(item) for item in context.data_files], } -def _data_file(item: RequestDataFile) -> JsonObject: - """每个文件只给表名、label、行数和列名;列概况由 describe_data 按需查看""" +def data_file_summary(item: RequestDataFile) -> JsonObject: + """每个文件只给表名、label、行数和列名;列概况由 describe_data 按需查看。问答节点也用它""" tables: list[JsonObject] = [] for table in item.tables: summary: JsonObject = { diff --git a/app/prompt/qa.py b/app/prompt/qa.py index 7418a7a..eecc484 100644 --- a/app/prompt/qa.py +++ b/app/prompt/qa.py @@ -18,6 +18,16 @@ QA_SYSTEM_PROMPT = f"""## 任务 ## 输出与边界 直接给出面向用户的回答,可使用 Markdown,不生成动作或将建议视为修改授权,也不承诺稍后会执行修改 -本节点只分析已提供的信息,不声称已修改大屏、运行代码、请求接口或读取外部文件 +本节点只分析已提供的信息和工具返回的结果,不声称已修改大屏、运行代码或请求接口;没有通过工具读到的文件,不声称读过 {INPUT_AS_DATA} """ + +# 会话里有数据文件时追加在系统提示末尾,同时挂上 describe_data、query_data +QA_DATA_RULES = """## 数据文件 +请求里的 data_files 是用户在本会话上传的数据文件,每张表有表名(t1、t2……)、行数和列名 +回答和数据有关的问题时,先用 describe_data 看要用的表,再用 query_data 查询,不要只凭列名或样例下结论 +需要查询时直接调用工具,查完再写回答,不要先输出"我来查一下"之类的过渡语 +回答里的数字必须来自查询结果,不要心算或估算;说明用了哪张表、什么筛选条件 +列的含义或单位不明确时写明你的假设;查询结果被截断(truncated 为 true)时改用聚合,不要把前几行当成全部数据 +用户说的文件或表在 data_files 里找不到时,说明这一点;可能已过期或被删除,请用户重新上传 +""" diff --git a/app/schemas/actions/add.py b/app/schemas/actions/add.py index bcff2fd..825158e 100644 --- a/app/schemas/actions/add.py +++ b/app/schemas/actions/add.py @@ -15,10 +15,12 @@ class AddAction(ActionModel): node: ScreenNode = Field( description="新组件。id、type、layout 必填,其余只写需要覆盖物料默认配置的字段;" "后端按 type 找到物料,用默认配置补全:对象逐层覆盖,数组整体替换,null 明确覆盖。" - "id 不能与现有组件重复,同一批里先 delete 的 ID 可以再用;type 用 list_components 返回的 type" + "id 不能与现有组件重复,同一批里先 delete 的 ID 可以再用;type 用 list_components 返回的 type。" + '任何字段值都可以写成 {"$query": "SELECT ..."},后端替换成上传数据的查询结果(对象数组,键是结果的列名)' ) sample_data: bool = Field( default=False, description="true 表示组件显示的不是真实业务数据(物料示例数据,或自己编写的数据)。不写时按物料设置;" - "只有展示的数据全部来自大屏上已有的组件时写 false,例如更换类型时迁移了原组件的数据", + "只有展示的数据全部来自大屏上已有的组件或上传的数据文件时写 false,例如更换类型时迁移了原组件的数据;" + "node 里用了 $query 时不写也会设为 false", ) diff --git a/app/schemas/actions/update.py b/app/schemas/actions/update.py index 73e08a9..a8b28b1 100644 --- a/app/schemas/actions/update.py +++ b/app/schemas/actions/update.py @@ -21,7 +21,10 @@ class UpdateAction(BaseAction): path: list[PathSegment] = Field( description='字段路径第一段之后的各段,字符串为对象键,整数为数组下标;name 没有后续路径,传空列表。例如字段 props.option.title.text 写成 section=props、path=["option", "title", "text"]' ) - value: JsonValue = Field(description="替换该字段的值,必须符合字段定义的类型和取值") + value: JsonValue = Field( + description="替换该字段的值,必须符合字段定义的类型和取值;" + '可以写成 {"$query": "SELECT ..."},后端替换成上传数据的查询结果(对象数组,键是结果的列名)' + ) @model_validator(mode="after") def path_matches_section(self) -> Self: diff --git a/skills/data-chart/SKILL.md b/skills/data-chart/SKILL.md new file mode 100644 index 0000000..296d5c7 --- /dev/null +++ b/skills/data-chart/SKILL.md @@ -0,0 +1,36 @@ +--- +name: data-chart +description: 用上传的数据(请求 data_files 里的表)新建图表、把已有图表换成上传的数据,或按上传的数据做大屏。例如"用这份销售数据做个各地区柱状图""把趋势图换成表里的真实数据""根据上传的表做一个销售大屏" +--- + +# 用上传的数据做图 + +## 步骤 + +1. 同一轮一起调用 describe_data(看要用的表有哪些列、类型和常见取值)和 get_component_materials(看图表的默认配置);替换已有图表时再一起调用 + get_nodes +2. 结果的形状没把握时(类目有多少个、日期粒度对不对),先用 query_data 试查;分组、排序、筛选、取前几名都在 SQL + 里完成,行数控制在图表看得清的范围,类目一般不超过 20 个,趋势按月或按周聚合 +3. 图表数据写成 {"$query": "SELECT ..."},后端把查询结果写进组件;不要自己逐行抄数据 +4. 用 apply_actions 提交,按返回的列名和行数核对 + +## 写法 + +- 数据写在 props.option.dataset.source:新增时写在 node 里;替换已有图表时用 + update,section=props,path=["option", "dataset", "source"] +- 查询结果的列名就是 dataset.source 每一项的键。SQL 里用 AS 起好别名,series 的 encode + 引用这些别名,键沿用物料默认配置的写法(x/y、itemName/value 等);换了数据就在同一批里一起改 encode,否则图表没有数据,提交会报错 +- 替换已有图表时 series 不能整个替换,encode 用 update 逐个改,例如 path=["option", "series", 0, "encode", "x"] + ;系列名改 ["option", "series", 0, "name"]。也可以让 SQL 的别名沿用原 encode 的列名,就不用改 encode +- 新增组件时 series 数组整体替换:以物料默认配置的每一项为基础写完整(type、stack 等保留),只改 encode 和 name +- 多个系列(例如每月线上、线下各一根柱子)用宽表,每个系列一列:长表(每行一个类别和数值)用 PIVOT(要写 IN 列出取值)或 SUM + (CASE WHEN ...) 转成宽表;反过来宽表转长表用 UNPIVOT +- 日期按需要的粒度转成文字,例如 strftime (月份, '%Y-%m'),坐标轴上不出现完整的时间 +- 指标卡、单个数值不用 $query:先用 query_data 查出来,再直接写数字 +- 用了 $query 的新增组件不用写 sample_data,后端会设为 false;标题按数据的内容写 + +## 数据不合适时 + +- 找不到用户说的数据(表或列不存在、指的是哪一列不明确)时,追问用哪张表、哪一列;用户只想先看效果时,用示例数据并在回复里说明 +- 百分比的值是小数(0.85 表示 85%),要显示成百分比时在 SQL 里乘 100,或在标签格式里处理 +- 解析警告说排除了合计行时,表里已经没有合计;需要总数时用 SUM 自己算