From 41b769adde9c49f2be6150af5e57d0a05a805641 Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Wed, 14 Oct 2026 01:08:50 -0700 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E5=A4=84=E7=90=86=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=94=99=E8=AF=AF=E6=B7=BB=E5=8A=A0=E5=A4=A7=E9=87=8F?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/actions/batch.py | 40 +++- app/agent/query_values.py | 10 +- app/agent/tools.py | 157 +++++++++------- app/processor/nodes/input_node.py | 1 + app/processor/nodes/qa_node.py | 5 + app/processor/nodes/screen_agent_node.py | 22 ++- app/processor/state.py | 1 + app/processor/summary.py | 19 +- app/resources/material_fields.py | 176 +++++++++++++++++- app/resources/materials/area-chart.json | 1 - app/resources/materials/bar-chart.json | 4 +- app/resources/materials/bar-line-chart.json | 13 +- app/resources/materials/field_groups.json | 13 +- app/resources/materials/funnel-chart.json | 1 - app/resources/materials/gauge-chart.json | 1 - app/resources/materials/hbar-chart.json | 4 +- app/resources/materials/heatmap-chart.json | 1 - app/resources/materials/line-chart.json | 1 - .../materials/pictorial-bar-chart.json | 4 +- app/resources/materials/pie-chart.json | 1 - .../materials/progress-ring-chart.json | 1 - app/resources/materials/radar-chart.json | 1 - app/resources/materials/ring-chart.json | 1 - app/resources/materials/rose-chart.json | 1 - app/resources/materials/scatter-chart.json | 4 +- .../materials/stacked-bar-chart.json | 10 +- app/resources/materials/stat-card.json | 8 +- app/resources/materials/text.json | 7 +- app/schemas/actions/add.py | 6 +- app/schemas/agent.py | 2 + app/schemas/component_resource.py | 5 +- app/schemas/material_field.py | 13 ++ app/services/agent.py | 1 + skills/data-chart/SKILL.md | 1 - skills/screen-design/SKILL.md | 1 + 35 files changed, 388 insertions(+), 149 deletions(-) diff --git a/app/actions/batch.py b/app/actions/batch.py index 58491a3..9c1c97c 100644 --- a/app/actions/batch.py +++ b/app/actions/batch.py @@ -8,7 +8,12 @@ from copy import deepcopy from app.actions.layout import EXPANDERS from app.actions.registry import apply_action from app.actions.update import fit_existing_path, same_value -from app.resources.material_fields import check_update_field, find_material +from app.resources.material_fields import ( + check_add_fields, + check_update_field, + find_material, + retained_examples, +) from app.schemas.actions import Action, AddAction, AgentAction, UpdateAction from app.schemas.capabilities import DATA_BINDING_ERROR, DATA_BINDING_SUPPORTED from app.schemas.component_resource import ComponentCatalog @@ -45,19 +50,38 @@ def merge_config(defaults: dict, overrides: dict) -> dict: def complete_add(action: AddAction, catalog: ComponentCatalog) -> AddAction: """按 node.type 找物料补全新增组件,模型显式写出的字段优先 - sample_data 模型写了就以模型为准(只有它知道是否换成了自己的数据),没写时按物料设置 + 显式写的字段先按字段定义检查,和 update 是同一套规则;sample_data 保持模型的声明, + 交付时再由 mark_sample_data 按组件上是否还有示例数据补上 """ material = find_material(catalog, action.node.type) if not DATA_BINDING_SUPPORTED and action.node.model_extra.get("dataId"): raise ValueError(DATA_BINDING_ERROR) overrides = action.node.model_dump(mode="json", exclude_unset=True) + check_add_fields(catalog, material.type, overrides) node = ScreenNode.model_validate(merge_config(material.template(), overrides)) - sample_data = ( - action.sample_data - if "sample_data" in action.model_fields_set - else material.sample_data - ) - return action.model_copy(update={"node": node, "sample_data": sample_data}) + return action.model_copy(update={"node": node}) + + +def mark_sample_data( + actions: list[Action], screen: Screen, catalog: ComponentCatalog | None +) -> list[Action]: + """交付前给新增组件定 sample_data:模型声明了自己编的数据,或者组件上还留着物料的示例数据 + + 按交付后的大屏判断,不管数据是新增时写的、之后用 update 换的,还是 $query 查来的; + 后来被删掉的组件保持模型的声明。只改动作上的标记,交付记录依次执行仍得到同一个大屏 + """ + nodes = {node.id: node for node in screen.nodes} + result = [] + for action in actions: + if isinstance(action, AddAction) and not action.sample_data: + node = nodes.get(action.node.id) + if node is not None and any( + field.example == "data" + for field, _ in retained_examples(catalog, node.model_dump(mode="json")) + ): + action = action.model_copy(update={"sample_data": True}) + result.append(action) + return result def misplaced_props(actions: list[AgentAction], catalog: ComponentCatalog) -> list[str]: diff --git a/app/agent/query_values.py b/app/agent/query_values.py index 8da2a97..a2db2ec 100644 --- a/app/agent/query_values.py +++ b/app/agent/query_values.py @@ -14,7 +14,7 @@ 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.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 @@ -56,7 +56,6 @@ def fill_queries( """替换 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 {} @@ -78,7 +77,6 @@ def fill_queries( if found: fields = action.model_dump(mode="json", exclude_unset=True) fields["node"] = node - fields.setdefault("sample_data", False) action = AddAction.model_validate(fields) elif isinstance(action, UpdateAction): target = _Target( @@ -117,11 +115,7 @@ def _fields( 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 - } + return {field.path: field for field in component_fields(catalog, component)} def _fill(value: JsonValue, target: _Target, path: str, found: Found) -> JsonValue: diff --git a/app/agent/tools.py b/app/agent/tools.py index 68f2bce..51d11f0 100644 --- a/app/agent/tools.py +++ b/app/agent/tools.py @@ -4,6 +4,7 @@ 读取、修改和校验都基于同一份草稿:原始大屏加上之前写入草稿的修改;回复 done 时草稿交付给用户 """ +import json from collections.abc import Sequence from dataclasses import dataclass, field from typing import Annotated, NotRequired @@ -17,12 +18,12 @@ from pydantic import Field from app.actions.batch import BatchError, misplaced_props, run_batch from app.agent.data_tools import DataContext from app.agent.query_values import ( - SOURCE_PATH, EmbeddedQuery, encode_problems, fill_queries, rebound_nodes, ) +from app.resources.material_fields import paragraph_texts, retained_examples from app.schemas.actions import ( Action, AddAction, @@ -73,11 +74,10 @@ def _result_text( before: Screen, after: Screen, actions: list[Action], - undeclared_sample: frozenset[str] = frozenset(), + catalog: ComponentCatalog | None = None, queries: Sequence[EmbeddedQuery] = (), unchanged: bool = False, repeated: bool = False, - relabeled: Sequence[str] = (), ) -> str: """执行结果:除了动作数量,还给出执行后的画布和每个组件的布局 @@ -107,25 +107,34 @@ def _result_text( lines = [ f"执行成功,共 {len(actions)} 个动作已写入草稿;回复 done 时草稿交付给用户,不需要再提交确认" ] - sample = [ + if declared := [ action.node.id for action in actions if isinstance(action, AddAction) and action.sample_data - ] - # 模型没写 sample_data 时是后端按物料猜的;说明这一点和怎么改,否则模型只看到“用了示例数据”, - # 以为是自己的数据没生效,会反复提交同样的内容 - if guessed := [item for item in sample if item in undeclared_sample]: + ]: lines.append( - f"未写 sample_data,按物料默认标成了示例数据:{'、'.join(guessed)};" - "如果这些组件展示的数据都来自大屏上已有的组件或上传的数据文件," - "用 $query 整体替换它们的 option.dataset.source,或再提交一批:先 delete,再用 sample_data=false 重新 add,否则不用处理" + f"声明为自己编写数据的新增组件,交付时标为示例数据:{'、'.join(declared)}" ) - if declared := [item for item in sample if item not in undeclared_sample]: - lines.append(f"标为示例数据的新增组件:{'、'.join(declared)}") - if relabeled: + # 新增组件没写到的字段沿用默认配置,其中的标题、系列名、数据是演示内容;不说出来时模型以为都按自己的意思写好了, + # 结果真实数据的卡片带着示例的前缀和环比、图例还叫"访问量"。只陈述事实和后果,由模型决定改不改 + if examples := _retained_example_notes(actions, after, catalog): lines.append( - f"数据整体换成了 $query 的查询结果,已改标为非示例数据:{'、'.join(relabeled)}" + "新增组件里这些值还是物料的示例内容:示例文字和组件要展示的内容不符就改写;" + "示例数据会让组件交付时标为示例数据,要展示真实数据就换掉(数据来自上传的文件时用 $query)," + "没有真实数据时可以保留并在回复里说明" ) + lines.extend(examples) + # 问答之后"把结果放到大屏上",模型会把分析结论抄成文本组件;skill 里写了规则仍会发生,写入时再陈述一次, + # 用户明确要在大屏上放这段文字时由模型保留。要说清空出的位置怎么办:只说"改成简短的标题"时, + # 模型把结论块缩成一行小标题,留下一大块空位,反复加删组件去填,直到调用次数用尽 + if paragraphs := _paragraph_notes(actions, after, catalog): + lines.append( + "这一批写入的标题或文字是一段话:大屏上的文字只放标题和简短标签," + "分析结论、洞察、数据说明和注意事项写在回复里。用户明确要求在大屏上放这段文字时保留;" + "否则只放这段文字的文本组件直接删除,原来的位置给图表或让相邻组件占满," + "图表标题、指标名这类改成简短的名称" + ) + lines.extend(paragraphs) # 模型提交时只写了 SQL,看不到结果;给出列名和行数,用来核对 encode 和数据量 if queries: lines.append("$query 已替换为查询结果:") @@ -158,6 +167,68 @@ def _result_text( return "\n".join(lines) +def _retained_example_notes( + actions: Sequence[Action], after: Screen, catalog: ComponentCatalog | None +) -> list[str]: + """这一批新增的组件上还和默认配置一样的示例内容,每个组件一行""" + nodes = {node.id: node for node in after.nodes} + notes = [] + for action in actions: + if not isinstance(action, AddAction) or action.node.id not in nodes: + continue + node = nodes[action.node.id].model_dump(mode="json") + if retained := retained_examples(catalog, node): + notes.append( + f"- {action.node.id}:" + + "、".join( + f"{field.path}({field.label}){_brief(value)}" + for field, value in retained + ) + ) + return notes + + +def _paragraph_notes( + actions: Sequence[Action], after: Screen, catalog: ComponentCatalog | None +) -> list[str]: + """这一批写入的识别字段里成段的文字,每个组件一行 + + 新增的组件看全部识别字段,修改只看这一批写到的字段:只改颜色时不提原有的说明文字 + """ + written: dict[str, set[str] | None] = {} + for action in actions: + if isinstance(action, AddAction): + written[action.node.id] = None + elif ( + isinstance(action, UpdateAction) + and written.get(action.target_id, set()) is not None + ): + written.setdefault(action.target_id, set()).add(action.field_path) + nodes = {node.id: node for node in after.nodes} + notes = [] + for node_id, paths in written.items(): + if node_id not in nodes: + continue + node = nodes[node_id].model_dump(mode="json") + if found := paragraph_texts(catalog, node, paths): + notes.append( + f"- {node_id}:" + + "、".join( + f"{field.path}({field.label}){len(text.strip())} 字 {_brief(' '.join(text.split()))}" + for field, text in found + ) + ) + return notes + + +def _brief(value) -> str: + if isinstance(value, list): + return f"示例数据 {len(value)} 行" + if isinstance(value, str): + return f"“{value if len(value) <= 20 else value[:20] + '…'}”" + return json.dumps(value, ensure_ascii=False) + + def _reply( runtime: ToolRuntime, text: str, *, error: bool = False, **update ) -> Command: @@ -264,74 +335,28 @@ def apply_actions( f"- {item}" for item in problems ) return _reply(runtime, message + hint, error=True) - undeclared = frozenset( - action.node.id - for action in actions - if isinstance(action, AddAction) - and "sample_data" not in action.model_fields_set - ) log = runtime.state.get("actions", []) - delivered, relabeled = _relabel_real_data( - [*log, *completed], _replaced_sources(actions, queries) - ) + delivered = [*log, *completed] # 按交付结果判断有没有变化,不比较参数:同样的 move 再执行一次会继续移动,同样的 update 再执行一次没有变化 - # 交付结果除了大屏还有只在动作上的示例数据标记,只比大屏会把"重新 add 改 sample_data"当成没变化而丢掉 + # 交付结果除了大屏还有只在动作上的"自己编写的数据"声明,只比大屏会把"重新 add 改 sample_data"当成没变化而丢掉 unchanged = after == before and _sample_marks(delivered) == _sample_marks(log) # 这次运行之前已有成功提交时,没有变化多半是在重复自己的修改;还没有时说明原始大屏本来就是这样 text = _result_text( before, after, - delivered[len(log) :], - undeclared, + completed, + runtime.context.catalog, queries, unchanged, repeated=bool(log), - relabeled=relabeled, ) if unchanged: return _reply(runtime, text + hint) return _reply(runtime, text + hint, working_screen=after, actions=delivered) -def _replaced_sources( - actions: Sequence[AgentAction], queries: Sequence[EmbeddedQuery] -) -> set[str]: - """这一批用 update 把整个 dataset.source 换成 $query 结果的组件;只替换其中一部分时不算""" - return { - item.node_id - for item in queries - if item.path == SOURCE_PATH and isinstance(actions[item.index], UpdateAction) - } - - -def _relabel_real_data( - actions: list[Action], node_ids: set[str] -) -> tuple[list[Action], list[str]]: - """数据整体换成查询结果的组件,把它最近一次 add 的示例数据标记改为 false - - 标记只在 add 动作上、不影响大屏,改它不影响"交付记录依次执行得到草稿"; - 原始大屏上的组件没有 add,跨请求的标记要等大屏和前端契约支持 - """ - result = list(actions) - relabeled = [] - for node_id in sorted(node_ids): - index = next( - ( - position - for position in range(len(result) - 1, -1, -1) - if isinstance(result[position], AddAction) - and result[position].node.id == node_id - ), - None, - ) - if index is not None and result[index].sample_data: - result[index] = result[index].model_copy(update={"sample_data": False}) - relabeled.append(node_id) - return result, relabeled - - def _sample_marks(actions: Sequence[Action]) -> dict[str, bool]: - """按交付记录,当前每个新增组件是否标为示例数据;这个标记只在 add 动作上,不在大屏里""" + """按交付记录,每个新增组件是否声明为自己编写的数据;这个声明只在 add 动作上,不在大屏里""" marks: dict[str, bool] = {} for action in actions: if isinstance(action, AddAction): diff --git a/app/processor/nodes/input_node.py b/app/processor/nodes/input_node.py index e00aa75..8b0e7c3 100644 --- a/app/processor/nodes/input_node.py +++ b/app/processor/nodes/input_node.py @@ -51,5 +51,6 @@ class InputNode(BaseNode): updated_state["actions"] = None updated_state["clarification"] = None updated_state["answer"] = None + updated_state["suggestions"] = None updated_state["status"] = "running" return updated_state diff --git a/app/processor/nodes/qa_node.py b/app/processor/nodes/qa_node.py index c52bd6b..f95ac27 100644 --- a/app/processor/nodes/qa_node.py +++ b/app/processor/nodes/qa_node.py @@ -18,6 +18,9 @@ from app.processor.summary import data_file_summary # 模型在一次回答里分几次输出文字(例如查询前后各说一段)时,段落之间空一行 PARAGRAPH_BREAK = "\n\n" +# 带数据的问答回答完后给的后续请求:意图判断会把"展示一下 9 月销售额"这类说法分到问答, +# 用户其实想在大屏上看到;回答之后给一个按钮,点了按普通请求走大屏 agent,会话历史里有这次回答 +PUT_ON_SCREEN = "把上面的结果放到大屏上" class QANode(BaseNode): @@ -93,4 +96,6 @@ class QANode(BaseNode): updated_state["answer"] = answer updated_state["status"] = "completed" + if context.data_files: + updated_state["suggestions"] = [PUT_ON_SCREEN] return updated_state diff --git a/app/processor/nodes/screen_agent_node.py b/app/processor/nodes/screen_agent_node.py index 012a5f1..af95491 100644 --- a/app/processor/nodes/screen_agent_node.py +++ b/app/processor/nodes/screen_agent_node.py @@ -9,6 +9,7 @@ import json from langchain_core.messages import HumanMessage +from app.actions.batch import mark_sample_data from app.agent.screen_agent import ( AgentReply, finalize_reply, @@ -21,13 +22,19 @@ from app.processor.history import history_messages from app.processor.state import QueryState, run_data from app.processor.summary import request_summary from app.resources.component import get_catalog_cache +from app.schemas.component_resource import ComponentCatalog # 评测按这句话识别"草稿因最后一次提交失败没有交付",单独核对草稿本身是否满足要求 DRAFT_REJECTED = "修改没有通过校验,这次没有生效,可以换个说法再试" -def reply_update(result: dict, *, limit_hit: bool = False) -> QueryState: - """把 agent 的运行结果转换成 QueryState 的更新""" +def reply_update( + result: dict, *, limit_hit: bool = False, catalog: ComponentCatalog | None = None +) -> QueryState: + """把 agent 的运行结果转换成 QueryState 的更新 + + 交付的新增组件按交付后的大屏定 sample_data:留着物料示例数据的也标为示例数据(mark_sample_data) + """ reply: AgentReply | None = result.get("structured_response") if reply is None: # 收尾也没有给出合法的结论;按真实原因说明,不再一律说超出上限 @@ -44,11 +51,10 @@ def reply_update(result: dict, *, limit_hit: bool = False) -> QueryState: if last_write_failed(result.get("messages", [])): return {"status": "failed", "answer": DRAFT_REJECTED} # 各次成功提交的动作按顺序拼接;clarify、unable 不交付,下次请求从原始大屏重新执行 - return { - "status": "completed", - "actions": result.get("actions") or [], - "answer": reply.message, - } + actions = result.get("actions") or [] + if actions: + actions = mark_sample_data(actions, result["working_screen"], catalog) + return {"status": "completed", "actions": actions, "answer": reply.message} class ScreenAgentNode(BaseNode): @@ -91,5 +97,5 @@ class ScreenAgentNode(BaseNode): ) reply = await finalize_reply(result, limit_hit=limit_hit) result = {**result, "structured_response": reply} - updated_state.update(reply_update(result, limit_hit=limit_hit)) + updated_state.update(reply_update(result, limit_hit=limit_hit, catalog=catalog)) return updated_state diff --git a/app/processor/state.py b/app/processor/state.py index 6655ffa..49d2e84 100644 --- a/app/processor/state.py +++ b/app/processor/state.py @@ -28,6 +28,7 @@ class QueryState(TypedDict): ClarificationReply | None ] # 待合并的用户回答或取消指令 answer: NotRequired[str | None] # 问答回答,或 agent 给用户的说明 + suggestions: NotRequired[list[str] | None] # 可以一键发送的后续请求,前端显示成按钮 status: NotRequired[ Literal["running", "waiting_for_input", "completed", "cancelled", "failed"] ] # 后端处理、等待补充、处理完成、用户取消或失败,完成不代表前端已执行 diff --git a/app/processor/summary.py b/app/processor/summary.py index 083da87..514de24 100644 --- a/app/processor/summary.py +++ b/app/processor/summary.py @@ -3,6 +3,8 @@ 只描述本次请求;会话历史由各使用方另行传入,见 history.py """ +from app.resources.component import get_catalog_cache +from app.resources.material_fields import identifying_texts from app.schemas.base import JsonObject from app.schemas.data_file import RequestDataFile from app.schemas.input_context import InputContext @@ -14,25 +16,16 @@ SUMMARY_COLUMNS = 30 def request_summary(context: InputContext) -> JsonObject: """用户输入、澄清记录、编辑上下文、附件、数据文件和大屏摘要""" nodes: list[JsonObject] = [] + catalog = get_catalog_cache() for node in context.screen.nodes: - # 画面上的标题帮助判断用户指的是哪个组件:图表在 option.title,指标卡在 props.title; - # 格式不符时按无标题处理 - option = node.props.get("option") - title = option.get("title") if isinstance(option, dict) else None - titles = title if isinstance(title, list) else [title] - texts = [ - item["text"] - for item in titles - if isinstance(item, dict) and isinstance(item.get("text"), str) - ] - if isinstance(node.props.get("title"), str) and node.props["title"]: - texts.append(node.props["title"]) nodes.append( { "id": node.id, "type": node.type, "name": node.name, - "titles": texts, + # 画面上的名字或内容帮助判断用户指的是哪个组件;取哪个字段由字段定义的 identifies 决定, + # 新物料不用在这里加判断。物料目录没载入时为空 + "titles": identifying_texts(catalog, node.model_dump(mode="json")), "layout": node.layout.model_dump(mode="json", exclude_unset=True), } ) diff --git a/app/resources/material_fields.py b/app/resources/material_fields.py index 34eeeff..fb016fc 100644 --- a/app/resources/material_fields.py +++ b/app/resources/material_fields.py @@ -1,7 +1,10 @@ """按物料展开字段定义:解析共享分组引用、取字段默认值、派生前端属性面板 setters""" +import re + from pydantic import JsonValue +from app.schemas.base import JsonObject from app.schemas.component_resource import ( ComponentCatalog, ComponentDefinition, @@ -61,13 +64,35 @@ def panel_setters(groups: list[FieldGroup]) -> list[dict]: def frontend_catalog(catalog: ComponentCatalog) -> dict: - """前端物料接口的数据:在保存的定义上附加由字段定义派生的 setters""" + """前端物料接口的数据:在保存的定义上附加由字段定义派生的 setters 和 defaults""" data = catalog.model_dump(mode="json", by_alias=True) for item, component in zip(data["components"], catalog.components, strict=True): item["setters"] = panel_setters(resolve_field_groups(catalog, component)) + item["defaults"] = structural_defaults(catalog, component) return data +def structural_defaults( + catalog: ComponentCatalog, component: ComponentDefinition +) -> JsonObject: + """默认配置去掉示例内容(字段定义里标了 example 的标题、数据……)后剩下的结构默认值 + + 前端打开大屏时用它给旧节点补上物料后来新增的字段;不用完整的默认配置,否则用户删掉的标题 + 会被补回成示例标题,真实大屏上又出现演示内容 + """ + defaults = component.template() + for field in component_fields(catalog, component): + if field.example: + *parents, last = field.segments + try: + container = lookup(defaults, parents) + except ValueError: + continue + if isinstance(container, dict) and isinstance(last, str): + container.pop(last, None) + return defaults + + # ---- 字段查询:MCP 工具、动作提示词和动作校验共用同一套逻辑 ---- @@ -128,7 +153,15 @@ def describe_field( """给模型看的字段说明:省略空值和属性面板专用的信息""" data = field.model_dump( mode="json", - exclude={"default", "panel", "span", "editable", "themed"}, + exclude={ + "default", + "panel", + "span", + "editable", + "themed", + "example", + "identifies", + }, exclude_none=True, exclude_defaults=True, ) @@ -136,6 +169,9 @@ def describe_field( if field.themed: # 默认为 null:没单独设置时画面上按画布主题取色 data["follows_theme"] = True + if field.example: + # 默认值是演示内容,要按实际内容写,不能当成合理的默认 + data["default_is_example"] = True if not field.editable: data["editable"] = False data["group"] = group.key @@ -257,3 +293,139 @@ def check_update_field( raise ValueError(f"字段 {path}({field.label})不允许修改") if error := field.value_error(action.value): raise ValueError(f"字段 {path}({field.label}){error}") + + +def component_fields( + catalog: ComponentCatalog, component: ComponentDefinition +) -> list[MaterialField]: + return [ + field + for group in resolve_field_groups(catalog, component) + for field in group.fields + ] + + +def check_add_fields( + catalog: ComponentCatalog, node_type: str, written: JsonValue +) -> None: + """新增组件时模型显式写的字段,和 update 用同一套字段定义检查;不合法时一次列出全部问题 + + 没写的字段来自默认配置,载入目录时已经检查过;物料没有定义的路径不检查,新增时可以整段写 option + """ + problems = [] + for field in component_fields(catalog, find_material(catalog, node_type)): + try: + value = lookup(written, field.segments) + except ValueError: + problems.append( + f"字段 {field.path}({field.label})所在的上一层结构和物料不同," + "按 get_component_materials 返回的默认配置写" + ) + continue + if value is not MISSING and (error := field.value_error(value)): + problems.append(f"字段 {field.path}({field.label}){error}") + if problems: + raise ValueError(";".join(problems)) + + +def retained_examples( + catalog: ComponentCatalog | None, node: dict +) -> list[tuple[MaterialField, JsonValue]]: + """组件上还和默认配置一样的示例内容(标题、系列名、数据……),按字段定义的 example 判断 + + 只比较现在的值,不管是新增时没写、写了一样的值,还是之后又改回来;物料不存在时为空 + """ + if catalog is None: + return [] + try: + component = find_material(catalog, node["type"]) + except ValueError: + return [] + template = component.template() + retained = [] + for field in component_fields(catalog, component): + if not field.example: + continue + try: + current = lookup(node, field.segments) + except ValueError: + continue + default = lookup(template, field.segments) + if current is not MISSING and current == default: + retained.append((field, default)) + return retained + + +# 摘要里每段文字的长度上限:文本组件的内容可能很长,认出组件只需要开头 +IDENTITY_MAX_CHARS = 40 + + +def identifying_texts(catalog: ComponentCatalog | None, node: dict) -> list[str]: + """组件在画面上的名字或内容:字段定义里标了 identifies 的字段的文字,过长时截断 + + 路径途经数组而字段按对象写时(例如 ECharts 的多个 title),每一项都取 + """ + if catalog is None: + return [] + try: + component = find_material(catalog, node["type"]) + except ValueError: + return [] + texts = [] + for field in component_fields(catalog, component): + if field.identifies: + texts.extend( + text + if len(text) <= IDENTITY_MAX_CHARS + else text[:IDENTITY_MAX_CHARS] + "…" + for value in _values_at(node, field.segments) + if isinstance(value, str) and (text := value.strip()) + ) + return texts + + +# 识别字段(标题、名称、文本内容)在画面上是一行短文字。换行、超过这个长度或带句号分号时是一段话: +# 线上问答之后"把结果放到大屏上",模型把"关键发现 1. …"、"注:……;……。"写进了文本组件 +LABEL_MAX_CHARS = 60 +SENTENCE_MARKS = re.compile(r"[。;;]") + + +def is_label(text: str) -> bool: + text = text.strip() + return ( + "\n" not in text + and len(text) <= LABEL_MAX_CHARS + and not SENTENCE_MARKS.search(text) + ) + + +def paragraph_texts( + catalog: ComponentCatalog | None, node: dict, paths: set[str] | None = None +) -> list[tuple[MaterialField, str]]: + """组件的识别字段里写成一段话的文字;paths 给出时只看这些字段""" + if catalog is None: + return [] + try: + component = find_material(catalog, node["type"]) + except ValueError: + return [] + return [ + (field, value) + for field in component_fields(catalog, component) + if field.identifies and (paths is None or field.path in paths) + for value in _values_at(node, field.segments) + if isinstance(value, str) and not is_label(value) + ] + + +def _values_at(value: JsonValue, segments: list[str | int]) -> list[JsonValue]: + if not segments: + return [value] + head, *rest = segments + if isinstance(value, dict) and isinstance(head, str) and head in value: + return _values_at(value[head], rest) + if isinstance(value, list) and isinstance(head, int) and 0 <= head < len(value): + return _values_at(value[head], rest) + if isinstance(value, list) and isinstance(head, str): + return [found for item in value for found in _values_at(item, segments)] + return [] diff --git a/app/resources/materials/area-chart.json b/app/resources/materials/area-chart.json index cc1ec36..9a523dd 100644 --- a/app/resources/materials/area-chart.json +++ b/app/resources/materials/area-chart.json @@ -12,7 +12,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:graph-new-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" diff --git a/app/resources/materials/bar-chart.json b/app/resources/materials/bar-chart.json index c3bf984..e6fa600 100644 --- a/app/resources/materials/bar-chart.json +++ b/app/resources/materials/bar-chart.json @@ -12,7 +12,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:chart-2-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" @@ -49,7 +48,8 @@ "系列名", "数据名称" ], - "panel": false + "panel": false, + "example": "label" }, { "path": "props.option.series.0.barWidth", diff --git a/app/resources/materials/bar-line-chart.json b/app/resources/materials/bar-line-chart.json index 3cee14e..db08632 100644 --- a/app/resources/materials/bar-line-chart.json +++ b/app/resources/materials/bar-line-chart.json @@ -15,7 +15,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:chart-2-linear", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作", @@ -72,7 +71,8 @@ "aliases": [ "柱子名称" ], - "panel": false + "panel": false, + "example": "label" }, { "path": "props.option.series.1.name", @@ -82,7 +82,8 @@ "aliases": [ "折线名称" ], - "panel": false + "panel": false, + "example": "label" }, { "path": "props.option.series.0.barWidth", @@ -109,7 +110,8 @@ "span": 12, "aliases": [ "左轴标题" - ] + ], + "example": "label" }, { "path": "props.option.yAxis.1.name", @@ -119,7 +121,8 @@ "span": 12, "aliases": [ "右轴标题" - ] + ], + "example": "label" }, { "path": "props.option.yAxis.1.axisLabel.formatter", diff --git a/app/resources/materials/field_groups.json b/app/resources/materials/field_groups.json index c2ccfef..69ce99c 100644 --- a/app/resources/materials/field_groups.json +++ b/app/resources/materials/field_groups.json @@ -86,7 +86,9 @@ "图表名称", "图表标题", "标题文字" - ] + ], + "example": "label", + "identifies": true }, { "path": "props.option.title.textStyle.color", @@ -362,7 +364,8 @@ "示例数据", "数据集" ], - "panel": false + "panel": false, + "example": "data" } ] }, @@ -443,7 +446,8 @@ "系列名", "数据名称" ], - "panel": false + "panel": false, + "example": "label" }, { "path": "props.option.series.0.smooth", @@ -534,7 +538,8 @@ "系列名", "数据名称" ], - "panel": false + "panel": false, + "example": "label" }, { "path": "props.option.series.0.label.show", diff --git a/app/resources/materials/funnel-chart.json b/app/resources/materials/funnel-chart.json index e6a4862..514beb8 100644 --- a/app/resources/materials/funnel-chart.json +++ b/app/resources/materials/funnel-chart.json @@ -12,7 +12,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:filter-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" diff --git a/app/resources/materials/gauge-chart.json b/app/resources/materials/gauge-chart.json index aa27b26..9b96162 100644 --- a/app/resources/materials/gauge-chart.json +++ b/app/resources/materials/gauge-chart.json @@ -12,7 +12,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:speedometer-middle-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" diff --git a/app/resources/materials/hbar-chart.json b/app/resources/materials/hbar-chart.json index 156ce0d..b3ec357 100644 --- a/app/resources/materials/hbar-chart.json +++ b/app/resources/materials/hbar-chart.json @@ -12,7 +12,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:align-left-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" @@ -133,7 +132,8 @@ "系列名", "数据名称" ], - "panel": false + "panel": false, + "example": "label" }, { "path": "props.option.series.0.barWidth", diff --git a/app/resources/materials/heatmap-chart.json b/app/resources/materials/heatmap-chart.json index 73013fe..610eed7 100644 --- a/app/resources/materials/heatmap-chart.json +++ b/app/resources/materials/heatmap-chart.json @@ -15,7 +15,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:widget-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作", diff --git a/app/resources/materials/line-chart.json b/app/resources/materials/line-chart.json index 3ce87a6..bef8afa 100644 --- a/app/resources/materials/line-chart.json +++ b/app/resources/materials/line-chart.json @@ -12,7 +12,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:graph-up-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" diff --git a/app/resources/materials/pictorial-bar-chart.json b/app/resources/materials/pictorial-bar-chart.json index 3df7c9b..0917ba7 100644 --- a/app/resources/materials/pictorial-bar-chart.json +++ b/app/resources/materials/pictorial-bar-chart.json @@ -15,7 +15,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:battery-charge-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" @@ -120,7 +119,8 @@ "系列名", "数据名称" ], - "panel": false + "panel": false, + "example": "label" } ] }, diff --git a/app/resources/materials/pie-chart.json b/app/resources/materials/pie-chart.json index ee304c7..6a2c90e 100644 --- a/app/resources/materials/pie-chart.json +++ b/app/resources/materials/pie-chart.json @@ -12,7 +12,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:pie-chart-2-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" diff --git a/app/resources/materials/progress-ring-chart.json b/app/resources/materials/progress-ring-chart.json index 6d94006..838ce88 100644 --- a/app/resources/materials/progress-ring-chart.json +++ b/app/resources/materials/progress-ring-chart.json @@ -15,7 +15,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:pie-chart-3-linear", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作", diff --git a/app/resources/materials/radar-chart.json b/app/resources/materials/radar-chart.json index 7bff14c..c3ab6f0 100644 --- a/app/resources/materials/radar-chart.json +++ b/app/resources/materials/radar-chart.json @@ -12,7 +12,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:radar-2-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" diff --git a/app/resources/materials/ring-chart.json b/app/resources/materials/ring-chart.json index 81c0160..6b0079f 100644 --- a/app/resources/materials/ring-chart.json +++ b/app/resources/materials/ring-chart.json @@ -12,7 +12,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:pie-chart-3-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" diff --git a/app/resources/materials/rose-chart.json b/app/resources/materials/rose-chart.json index d5137eb..092fb07 100644 --- a/app/resources/materials/rose-chart.json +++ b/app/resources/materials/rose-chart.json @@ -14,7 +14,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:pie-chart-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" diff --git a/app/resources/materials/scatter-chart.json b/app/resources/materials/scatter-chart.json index 3895ff7..2b99841 100644 --- a/app/resources/materials/scatter-chart.json +++ b/app/resources/materials/scatter-chart.json @@ -12,7 +12,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:chart-square-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" @@ -63,7 +62,8 @@ "系列名", "数据名称" ], - "panel": false + "panel": false, + "example": "label" } ] }, diff --git a/app/resources/materials/stacked-bar-chart.json b/app/resources/materials/stacked-bar-chart.json index 248db05..07fbafc 100644 --- a/app/resources/materials/stacked-bar-chart.json +++ b/app/resources/materials/stacked-bar-chart.json @@ -14,7 +14,6 @@ "renderer": "echarts", "group": "charts", "icon": "solar:chart-bold", - "sample_data": true, "usage_notes": [ "默认图表数据仅用于演示,不是真实业务数据", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作", @@ -65,7 +64,8 @@ "aliases": [ "图例名称" ], - "panel": false + "panel": false, + "example": "label" }, { "path": "props.option.series.1.name", @@ -75,7 +75,8 @@ "aliases": [ "图例名称" ], - "panel": false + "panel": false, + "example": "label" }, { "path": "props.option.series.2.name", @@ -85,7 +86,8 @@ "aliases": [ "图例名称" ], - "panel": false + "panel": false, + "example": "label" } ] }, diff --git a/app/resources/materials/stat-card.json b/app/resources/materials/stat-card.json index 6990a57..8992bab 100644 --- a/app/resources/materials/stat-card.json +++ b/app/resources/materials/stat-card.json @@ -15,7 +15,6 @@ "renderer": "stat", "group": "info", "icon": "solar:graph-up-bold", - "sample_data": true, "usage_notes": [ "默认数值仅用于演示,不是真实业务数据", "一个指标卡只显示一个指标,多个指标用多个指标卡并排", @@ -43,7 +42,9 @@ "名称", "指标名称", "卡片标题" - ] + ], + "example": "label", + "identifies": true }, { "path": "props.value", @@ -54,7 +55,8 @@ "数字", "指标值", "数据" - ] + ], + "example": "data" }, { "path": "props.prefix", diff --git a/app/resources/materials/text.json b/app/resources/materials/text.json index 79ea97f..bb1c32d 100644 --- a/app/resources/materials/text.json +++ b/app/resources/materials/text.json @@ -3,7 +3,7 @@ "key": "text", "type": "text", "name": "文本", - "description": "显示一段文字,常用作大屏标题、说明或标签", + "description": "显示文字,用作大屏标题、小标题或标签", "tags": [ "info", "文本", @@ -12,7 +12,6 @@ "renderer": "text", "group": "info", "icon": "solar:text-bold", - "sample_data": false, "usage_notes": [ "events 中代码只作为资料保存,不由后端执行" ], @@ -33,7 +32,9 @@ "文本内容", "标题文字", "名字" - ] + ], + "example": "label", + "identifies": true } ] }, diff --git a/app/schemas/actions/add.py b/app/schemas/actions/add.py index 36e0aa1..5c8c622 100644 --- a/app/schemas/actions/add.py +++ b/app/schemas/actions/add.py @@ -15,12 +15,12 @@ class AddAction(ActionModel): node: ScreenNode = Field( description="新组件。id、type、layout 必填,其余只写需要覆盖物料默认配置的字段;" "后端按 type 找到物料,用默认配置补全:对象逐层覆盖,数组整体替换,null 明确覆盖。" + "写出的字段和 update 一样按字段定义检查类型和取值(跟随主题的颜色可以写 null)。" "id 不能与当前大屏上的组件重复(包括之前提交里新增的),先 delete 的 ID 可以再用;type 用 list_components 返回的 type。" '任何字段值都可以写成 {"$query": "SELECT ..."},后端替换成上传数据的查询结果:写入单个值的字段时结果要一行一列,取那个值;写入数字、文字数组的字段时结果要只有一列;其余是对象数组,键是结果的列名' ) sample_data: bool = Field( default=False, - description="true 表示组件显示的不是真实业务数据(物料示例数据,或自己编写的数据)。不写时按物料设置;" - "只有展示的数据全部来自大屏上已有的组件或上传的数据文件时写 false,例如更换类型时迁移了原组件的数据;" - "node 里用了 $query 时不写也会设为 false", + description="true 表示组件展示的是自己编写的数据(不是来自上传的文件或大屏上已有的组件),交付时标为示例数据。" + "组件上留着物料示例数据时后端会自动标为示例数据,不用写", ) diff --git a/app/schemas/agent.py b/app/schemas/agent.py index f4e2eed..3b1cdee 100644 --- a/app/schemas/agent.py +++ b/app/schemas/agent.py @@ -22,4 +22,6 @@ class AgentResult(BaseModel): status: Literal["completed", "waiting_for_input", "failed", "cancelled"] actions: list[Action] = Field(default_factory=list) answer: str | None = None # 回答,或失败时由模型总结的原因说明 + # 可以一键发送的后续请求,前端显示成按钮,点了就作为下一条消息发出;目前只有问答查了数据后的"放到大屏上" + suggestions: list[str] = Field(default_factory=list) clarification: Clarification | None = None diff --git a/app/schemas/component_resource.py b/app/schemas/component_resource.py index aadb880..4f65738 100644 --- a/app/schemas/component_resource.py +++ b/app/schemas/component_resource.py @@ -50,9 +50,6 @@ class ComponentDefinition(ExtensibleModel): "前端属性面板的 setters 由它派生,不单独保存", ) eventOptions: list[JsonObject] = Field(default_factory=list) - sample_data: bool = Field( - default=False, description="默认配置是否含示例数据,不能视为真实业务数据" - ) usage_notes: list[NonBlankString] = Field( default_factory=list, description="配置、数据绑定及使用限制说明" ) @@ -82,6 +79,8 @@ def _default_problem(field, template: JsonObject) -> str | None: return "的路径与默认配置结构冲突" if field.themed: return None if current is MISSING else "跟随主题,默认配置里不能写死颜色" + if field.example and current is MISSING: + return "标为示例内容,默认配置里要有这个值" if current is MISSING: if field.default is None: return "在默认配置中不存在,需要声明 default" diff --git a/app/schemas/material_field.py b/app/schemas/material_field.py index e13167d..bf0a77e 100644 --- a/app/schemas/material_field.py +++ b/app/schemas/material_field.py @@ -106,6 +106,15 @@ class MaterialField(ExtensibleModel): default=False, description="颜色跟随画布主题:默认配置里不写值,由前端按画布主题(亮 / 暗)取色;写入 null 恢复跟随", ) + example: Literal["data", "label"] | None = Field( + default=None, + description="默认配置里的值是演示内容,不是结构默认值:data 是示例数据(图表数据、指标值)," + "label 是示例文字(标题、系列名)。新增组件沿用了它们时告诉 agent;沿用示例数据的组件交付时标为示例数据", + ) + identifies: bool = Field( + default=False, + description="这个字段的文字是组件在画面上的名字或内容(图表标题、指标名、文本),大屏摘要用它帮助认出用户说的组件", + ) editable: bool = Field(default=True, description="是否允许修改") panel: bool = Field(default=True, description="是否显示在前端属性面板") span: int | None = Field(default=None, description="属性面板栅格宽度,24 为整行") @@ -122,6 +131,10 @@ class MaterialField(ExtensibleModel): raise ValueError(f"{self.value_type} 字段没有属性面板控件,panel 必须为 false: {self.path}") if self.themed and (self.value_type != "color" or self.default is not None): raise ValueError(f"只有 color 字段可以跟随主题,且不能声明 default: {self.path}") + if self.example and (self.themed or self.default is not None): + raise ValueError(f"示例内容要写在默认配置里,不能跟随主题或声明 default: {self.path}") + if self.identifies and self.value_type != "string": + raise ValueError(f"只有 string 字段可以用来识别组件: {self.path}") return self def value_error(self, value: JsonValue) -> str | None: diff --git a/app/services/agent.py b/app/services/agent.py index d4718c5..96fdb00 100644 --- a/app/services/agent.py +++ b/app/services/agent.py @@ -221,6 +221,7 @@ class AgentService: actions=(state.get("actions") or []) if deliver else [], answer=state.get("answer"), clarification=state.get("clarification") if waiting else None, + suggestions=(state.get("suggestions") or []) if deliver else [], ) async def stream(self, thread_id, payload, session_id, user_id=None, context=None): diff --git a/skills/data-chart/SKILL.md b/skills/data-chart/SKILL.md index a5c198f..a75f42d 100644 --- a/skills/data-chart/SKILL.md +++ b/skills/data-chart/SKILL.md @@ -31,7 +31,6 @@ describe_data 为准 - 日期按需要的粒度转成文字,例如 strftime (月份, '%Y-%m'),坐标轴上不出现完整的时间 - 指标卡(stat-card)的数值、对比的变化量和迷你趋势线的数据(trend.data)也写 $query:数值和变化量的查询结果要一行一列, 环比、同比在 SQL 里算好;趋势数据只选数值一列,按时间 ORDER BY -- 用户要分析结论时,加一个文本组件写结论,只写查询结果支持的内容 - 用了 $query 的新增组件不用写 sample_data,后端会设为 false;标题按数据的内容写 ## 数据不合适时 diff --git a/skills/screen-design/SKILL.md b/skills/screen-design/SKILL.md index ed2694e..3507c2a 100644 --- a/skills/screen-design/SKILL.md +++ b/skills/screen-design/SKILL.md @@ -11,6 +11,7 @@ description: 新建大屏,或在大屏上新增组件、整体重新布局、 2. 需要的查询(get_component_materials、get_nodes 等)互不依赖,在同一轮一起调用 3. 新建大屏时,根据主题安排一个标题文本和若干图表;图表标题按要展示的内容写,不沿用物料的示例标题 4. 销售额、订单数、转化率这类关键数字用指标卡(stat-card),一个指标一张卡,不用文本拼数字 +5. 大屏上的文字只放标题、小标题和简短标签。分析结论、洞察、建议、数据说明和注意事项写在回复里,不写进组件;用户明确要求在大屏上放一段文字(例如"加一段文字写结论")时才写。"把上面的结果放到大屏上"是用指标卡和图表展示结果里的数据 ## 布局