fix:处理模型错误添加大量文本的bug

This commit is contained in:
2026-10-14 01:08:50 -07:00
parent a80727b7a0
commit 41b769adde
35 changed files with 388 additions and 149 deletions
+32 -8
View File
@@ -8,7 +8,12 @@ from copy import deepcopy
from app.actions.layout import EXPANDERS from app.actions.layout import EXPANDERS
from app.actions.registry import apply_action from app.actions.registry import apply_action
from app.actions.update import fit_existing_path, same_value 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.actions import Action, AddAction, AgentAction, UpdateAction
from app.schemas.capabilities import DATA_BINDING_ERROR, DATA_BINDING_SUPPORTED from app.schemas.capabilities import DATA_BINDING_ERROR, DATA_BINDING_SUPPORTED
from app.schemas.component_resource import ComponentCatalog 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: def complete_add(action: AddAction, catalog: ComponentCatalog) -> AddAction:
"""按 node.type 找物料补全新增组件,模型显式写出的字段优先 """按 node.type 找物料补全新增组件,模型显式写出的字段优先
sample_data 模型写了就以模型为准(只有它知道是否换成了自己的数据),没写时按物料设置 显式写的字段先按字段定义检查,和 update 是同一套规则;sample_data 保持模型的声明,
交付时再由 mark_sample_data 按组件上是否还有示例数据补上
""" """
material = find_material(catalog, action.node.type) material = find_material(catalog, action.node.type)
if not DATA_BINDING_SUPPORTED and action.node.model_extra.get("dataId"): if not DATA_BINDING_SUPPORTED and action.node.model_extra.get("dataId"):
raise ValueError(DATA_BINDING_ERROR) raise ValueError(DATA_BINDING_ERROR)
overrides = action.node.model_dump(mode="json", exclude_unset=True) 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)) node = ScreenNode.model_validate(merge_config(material.template(), overrides))
sample_data = ( return action.model_copy(update={"node": node})
action.sample_data
if "sample_data" in action.model_fields_set
else material.sample_data def mark_sample_data(
) actions: list[Action], screen: Screen, catalog: ComponentCatalog | None
return action.model_copy(update={"node": node, "sample_data": sample_data}) ) -> 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]: def misplaced_props(actions: list[AgentAction], catalog: ComponentCatalog) -> list[str]:
+2 -8
View File
@@ -14,7 +14,7 @@ from pydantic import JsonValue
from app.actions.batch import BatchError from app.actions.batch import BatchError
from app.agent.data_tools import DataContext from app.agent.data_tools import DataContext
from app.config import settings 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.actions import AddAction, AgentAction, UpdateAction
from app.schemas.component_resource import ComponentCatalog from app.schemas.component_resource import ComponentCatalog
from app.schemas.material_field import MaterialField from app.schemas.material_field import MaterialField
@@ -56,7 +56,6 @@ def fill_queries(
"""替换 add 的 node 和 update 的 value 里所有的 $query;查询失败抛 BatchError,整批不生效 """替换 add 的 node 和 update 的 value 里所有的 $query;查询失败抛 BatchError,整批不生效
catalog 和 screen 用来查写入字段的定义,决定结果的形状;没有时都按对象数组 catalog 和 screen 用来查写入字段的定义,决定结果的形状;没有时都按对象数组
add 用了 $query 又没写 sample_data 时设为 false:数据来自用户上传的文件,不是示例数据
""" """
# update 的目标可能是这一批前面新增的组件,类型按执行顺序记下 # update 的目标可能是这一批前面新增的组件,类型按执行顺序记下
types = {node.id: node.type for node in screen.nodes} if screen else {} types = {node.id: node.type for node in screen.nodes} if screen else {}
@@ -78,7 +77,6 @@ def fill_queries(
if found: if found:
fields = action.model_dump(mode="json", exclude_unset=True) fields = action.model_dump(mode="json", exclude_unset=True)
fields["node"] = node fields["node"] = node
fields.setdefault("sample_data", False)
action = AddAction.model_validate(fields) action = AddAction.model_validate(fields)
elif isinstance(action, UpdateAction): elif isinstance(action, UpdateAction):
target = _Target( target = _Target(
@@ -117,11 +115,7 @@ def _fields(
component = find_material(catalog, node_type) component = find_material(catalog, node_type)
except ValueError: # 物料不存在,补全或执行时会报错 except ValueError: # 物料不存在,补全或执行时会报错
return {} return {}
return { return {field.path: field for field in component_fields(catalog, component)}
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: def _fill(value: JsonValue, target: _Target, path: str, found: Found) -> JsonValue:
+91 -66
View File
@@ -4,6 +4,7 @@
读取、修改和校验都基于同一份草稿:原始大屏加上之前写入草稿的修改;回复 done 时草稿交付给用户 读取、修改和校验都基于同一份草稿:原始大屏加上之前写入草稿的修改;回复 done 时草稿交付给用户
""" """
import json
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Annotated, NotRequired 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.actions.batch import BatchError, misplaced_props, run_batch
from app.agent.data_tools import DataContext from app.agent.data_tools import DataContext
from app.agent.query_values import ( from app.agent.query_values import (
SOURCE_PATH,
EmbeddedQuery, EmbeddedQuery,
encode_problems, encode_problems,
fill_queries, fill_queries,
rebound_nodes, rebound_nodes,
) )
from app.resources.material_fields import paragraph_texts, retained_examples
from app.schemas.actions import ( from app.schemas.actions import (
Action, Action,
AddAction, AddAction,
@@ -73,11 +74,10 @@ def _result_text(
before: Screen, before: Screen,
after: Screen, after: Screen,
actions: list[Action], actions: list[Action],
undeclared_sample: frozenset[str] = frozenset(), catalog: ComponentCatalog | None = None,
queries: Sequence[EmbeddedQuery] = (), queries: Sequence[EmbeddedQuery] = (),
unchanged: bool = False, unchanged: bool = False,
repeated: bool = False, repeated: bool = False,
relabeled: Sequence[str] = (),
) -> str: ) -> str:
"""执行结果:除了动作数量,还给出执行后的画布和每个组件的布局 """执行结果:除了动作数量,还给出执行后的画布和每个组件的布局
@@ -107,25 +107,34 @@ def _result_text(
lines = [ lines = [
f"执行成功,共 {len(actions)} 个动作已写入草稿;回复 done 时草稿交付给用户,不需要再提交确认" f"执行成功,共 {len(actions)} 个动作已写入草稿;回复 done 时草稿交付给用户,不需要再提交确认"
] ]
sample = [ if declared := [
action.node.id action.node.id
for action in actions for action in actions
if isinstance(action, AddAction) and action.sample_data if isinstance(action, AddAction) and action.sample_data
] ]:
# 模型没写 sample_data 时是后端按物料猜的;说明这一点和怎么改,否则模型只看到“用了示例数据”,
# 以为是自己的数据没生效,会反复提交同样的内容
if guessed := [item for item in sample if item in undeclared_sample]:
lines.append( lines.append(
f"未写 sample_data,按物料默认标成了示例数据:{'、'.join(guessed)};" f"声明为自己编写数据的新增组件,交付时标为示例数据:{'、'.join(declared)}"
"如果这些组件展示的数据都来自大屏上已有的组件或上传的数据文件,"
"用 $query 整体替换它们的 option.dataset.source,或再提交一批:先 delete,再用 sample_data=false 重新 add,否则不用处理"
) )
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( 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 和数据量 # 模型提交时只写了 SQL,看不到结果;给出列名和行数,用来核对 encode 和数据量
if queries: if queries:
lines.append("$query 已替换为查询结果:") lines.append("$query 已替换为查询结果:")
@@ -158,6 +167,68 @@ def _result_text(
return "\n".join(lines) 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( def _reply(
runtime: ToolRuntime, text: str, *, error: bool = False, **update runtime: ToolRuntime, text: str, *, error: bool = False, **update
) -> Command: ) -> Command:
@@ -264,74 +335,28 @@ def apply_actions(
f"- {item}" for item in problems f"- {item}" for item in problems
) )
return _reply(runtime, message + hint, error=True) 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", []) log = runtime.state.get("actions", [])
delivered, relabeled = _relabel_real_data( delivered = [*log, *completed]
[*log, *completed], _replaced_sources(actions, queries)
)
# 按交付结果判断有没有变化,不比较参数:同样的 move 再执行一次会继续移动,同样的 update 再执行一次没有变化 # 按交付结果判断有没有变化,不比较参数:同样的 move 再执行一次会继续移动,同样的 update 再执行一次没有变化
# 交付结果除了大屏还有只在动作上的示例数据标记,只比大屏会把"重新 add 改 sample_data"当成没变化而丢掉 # 交付结果除了大屏还有只在动作上的"自己编写的数据"声明,只比大屏会把"重新 add 改 sample_data"当成没变化而丢掉
unchanged = after == before and _sample_marks(delivered) == _sample_marks(log) unchanged = after == before and _sample_marks(delivered) == _sample_marks(log)
# 这次运行之前已有成功提交时,没有变化多半是在重复自己的修改;还没有时说明原始大屏本来就是这样 # 这次运行之前已有成功提交时,没有变化多半是在重复自己的修改;还没有时说明原始大屏本来就是这样
text = _result_text( text = _result_text(
before, before,
after, after,
delivered[len(log) :], completed,
undeclared, runtime.context.catalog,
queries, queries,
unchanged, unchanged,
repeated=bool(log), repeated=bool(log),
relabeled=relabeled,
) )
if unchanged: if unchanged:
return _reply(runtime, text + hint) return _reply(runtime, text + hint)
return _reply(runtime, text + hint, working_screen=after, actions=delivered) 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]: def _sample_marks(actions: Sequence[Action]) -> dict[str, bool]:
"""按交付记录,当前每个新增组件是否标为示例数据;这个标记只在 add 动作上,不在大屏里""" """按交付记录,每个新增组件是否声明为自己编写的数据;这个声明只在 add 动作上,不在大屏里"""
marks: dict[str, bool] = {} marks: dict[str, bool] = {}
for action in actions: for action in actions:
if isinstance(action, AddAction): if isinstance(action, AddAction):
+1
View File
@@ -51,5 +51,6 @@ class InputNode(BaseNode):
updated_state["actions"] = None updated_state["actions"] = None
updated_state["clarification"] = None updated_state["clarification"] = None
updated_state["answer"] = None updated_state["answer"] = None
updated_state["suggestions"] = None
updated_state["status"] = "running" updated_state["status"] = "running"
return updated_state return updated_state
+5
View File
@@ -18,6 +18,9 @@ from app.processor.summary import data_file_summary
# 模型在一次回答里分几次输出文字(例如查询前后各说一段)时,段落之间空一行 # 模型在一次回答里分几次输出文字(例如查询前后各说一段)时,段落之间空一行
PARAGRAPH_BREAK = "\n\n" PARAGRAPH_BREAK = "\n\n"
# 带数据的问答回答完后给的后续请求:意图判断会把"展示一下 9 月销售额"这类说法分到问答,
# 用户其实想在大屏上看到;回答之后给一个按钮,点了按普通请求走大屏 agent,会话历史里有这次回答
PUT_ON_SCREEN = "把上面的结果放到大屏上"
class QANode(BaseNode): class QANode(BaseNode):
@@ -93,4 +96,6 @@ class QANode(BaseNode):
updated_state["answer"] = answer updated_state["answer"] = answer
updated_state["status"] = "completed" updated_state["status"] = "completed"
if context.data_files:
updated_state["suggestions"] = [PUT_ON_SCREEN]
return updated_state return updated_state
+14 -8
View File
@@ -9,6 +9,7 @@ import json
from langchain_core.messages import HumanMessage from langchain_core.messages import HumanMessage
from app.actions.batch import mark_sample_data
from app.agent.screen_agent import ( from app.agent.screen_agent import (
AgentReply, AgentReply,
finalize_reply, finalize_reply,
@@ -21,13 +22,19 @@ from app.processor.history import history_messages
from app.processor.state import QueryState, run_data from app.processor.state import QueryState, run_data
from app.processor.summary import request_summary from app.processor.summary import request_summary
from app.resources.component import get_catalog_cache from app.resources.component import get_catalog_cache
from app.schemas.component_resource import ComponentCatalog
# 评测按这句话识别"草稿因最后一次提交失败没有交付",单独核对草稿本身是否满足要求 # 评测按这句话识别"草稿因最后一次提交失败没有交付",单独核对草稿本身是否满足要求
DRAFT_REJECTED = "修改没有通过校验,这次没有生效,可以换个说法再试" DRAFT_REJECTED = "修改没有通过校验,这次没有生效,可以换个说法再试"
def reply_update(result: dict, *, limit_hit: bool = False) -> QueryState: def reply_update(
"""把 agent 的运行结果转换成 QueryState 的更新""" 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") reply: AgentReply | None = result.get("structured_response")
if reply is None: 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", [])): if last_write_failed(result.get("messages", [])):
return {"status": "failed", "answer": DRAFT_REJECTED} return {"status": "failed", "answer": DRAFT_REJECTED}
# 各次成功提交的动作按顺序拼接;clarify、unable 不交付,下次请求从原始大屏重新执行 # 各次成功提交的动作按顺序拼接;clarify、unable 不交付,下次请求从原始大屏重新执行
return { actions = result.get("actions") or []
"status": "completed", if actions:
"actions": result.get("actions") or [], actions = mark_sample_data(actions, result["working_screen"], catalog)
"answer": reply.message, return {"status": "completed", "actions": actions, "answer": reply.message}
}
class ScreenAgentNode(BaseNode): class ScreenAgentNode(BaseNode):
@@ -91,5 +97,5 @@ class ScreenAgentNode(BaseNode):
) )
reply = await finalize_reply(result, limit_hit=limit_hit) reply = await finalize_reply(result, limit_hit=limit_hit)
result = {**result, "structured_response": reply} 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 return updated_state
+1
View File
@@ -28,6 +28,7 @@ class QueryState(TypedDict):
ClarificationReply | None ClarificationReply | None
] # 待合并的用户回答或取消指令 ] # 待合并的用户回答或取消指令
answer: NotRequired[str | None] # 问答回答,或 agent 给用户的说明 answer: NotRequired[str | None] # 问答回答,或 agent 给用户的说明
suggestions: NotRequired[list[str] | None] # 可以一键发送的后续请求,前端显示成按钮
status: NotRequired[ status: NotRequired[
Literal["running", "waiting_for_input", "completed", "cancelled", "failed"] Literal["running", "waiting_for_input", "completed", "cancelled", "failed"]
] # 后端处理、等待补充、处理完成、用户取消或失败,完成不代表前端已执行 ] # 后端处理、等待补充、处理完成、用户取消或失败,完成不代表前端已执行
+6 -13
View File
@@ -3,6 +3,8 @@
只描述本次请求;会话历史由各使用方另行传入,见 history.py 只描述本次请求;会话历史由各使用方另行传入,见 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.base import JsonObject
from app.schemas.data_file import RequestDataFile from app.schemas.data_file import RequestDataFile
from app.schemas.input_context import InputContext from app.schemas.input_context import InputContext
@@ -14,25 +16,16 @@ SUMMARY_COLUMNS = 30
def request_summary(context: InputContext) -> JsonObject: def request_summary(context: InputContext) -> JsonObject:
"""用户输入、澄清记录、编辑上下文、附件、数据文件和大屏摘要""" """用户输入、澄清记录、编辑上下文、附件、数据文件和大屏摘要"""
nodes: list[JsonObject] = [] nodes: list[JsonObject] = []
catalog = get_catalog_cache()
for node in context.screen.nodes: 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( nodes.append(
{ {
"id": node.id, "id": node.id,
"type": node.type, "type": node.type,
"name": node.name, "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), "layout": node.layout.model_dump(mode="json", exclude_unset=True),
} }
) )
+174 -2
View File
@@ -1,7 +1,10 @@
"""按物料展开字段定义:解析共享分组引用、取字段默认值、派生前端属性面板 setters""" """按物料展开字段定义:解析共享分组引用、取字段默认值、派生前端属性面板 setters"""
import re
from pydantic import JsonValue from pydantic import JsonValue
from app.schemas.base import JsonObject
from app.schemas.component_resource import ( from app.schemas.component_resource import (
ComponentCatalog, ComponentCatalog,
ComponentDefinition, ComponentDefinition,
@@ -61,13 +64,35 @@ def panel_setters(groups: list[FieldGroup]) -> list[dict]:
def frontend_catalog(catalog: ComponentCatalog) -> dict: def frontend_catalog(catalog: ComponentCatalog) -> dict:
"""前端物料接口的数据:在保存的定义上附加由字段定义派生的 setters""" """前端物料接口的数据:在保存的定义上附加由字段定义派生的 setters 和 defaults"""
data = catalog.model_dump(mode="json", by_alias=True) data = catalog.model_dump(mode="json", by_alias=True)
for item, component in zip(data["components"], catalog.components, strict=True): for item, component in zip(data["components"], catalog.components, strict=True):
item["setters"] = panel_setters(resolve_field_groups(catalog, component)) item["setters"] = panel_setters(resolve_field_groups(catalog, component))
item["defaults"] = structural_defaults(catalog, component)
return data 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 工具、动作提示词和动作校验共用同一套逻辑 ---- # ---- 字段查询:MCP 工具、动作提示词和动作校验共用同一套逻辑 ----
@@ -128,7 +153,15 @@ def describe_field(
"""给模型看的字段说明:省略空值和属性面板专用的信息""" """给模型看的字段说明:省略空值和属性面板专用的信息"""
data = field.model_dump( data = field.model_dump(
mode="json", mode="json",
exclude={"default", "panel", "span", "editable", "themed"}, exclude={
"default",
"panel",
"span",
"editable",
"themed",
"example",
"identifies",
},
exclude_none=True, exclude_none=True,
exclude_defaults=True, exclude_defaults=True,
) )
@@ -136,6 +169,9 @@ def describe_field(
if field.themed: if field.themed:
# 默认为 null:没单独设置时画面上按画布主题取色 # 默认为 null:没单独设置时画面上按画布主题取色
data["follows_theme"] = True data["follows_theme"] = True
if field.example:
# 默认值是演示内容,要按实际内容写,不能当成合理的默认
data["default_is_example"] = True
if not field.editable: if not field.editable:
data["editable"] = False data["editable"] = False
data["group"] = group.key data["group"] = group.key
@@ -257,3 +293,139 @@ def check_update_field(
raise ValueError(f"字段 {path}({field.label})不允许修改") raise ValueError(f"字段 {path}({field.label})不允许修改")
if error := field.value_error(action.value): if error := field.value_error(action.value):
raise ValueError(f"字段 {path}({field.label}){error}") 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 []
-1
View File
@@ -12,7 +12,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:graph-new-bold", "icon": "solar:graph-new-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作"
+2 -2
View File
@@ -12,7 +12,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:chart-2-bold", "icon": "solar:chart-2-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作"
@@ -49,7 +48,8 @@
"系列名", "系列名",
"数据名称" "数据名称"
], ],
"panel": false "panel": false,
"example": "label"
}, },
{ {
"path": "props.option.series.0.barWidth", "path": "props.option.series.0.barWidth",
+8 -5
View File
@@ -15,7 +15,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:chart-2-linear", "icon": "solar:chart-2-linear",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作",
@@ -72,7 +71,8 @@
"aliases": [ "aliases": [
"柱子名称" "柱子名称"
], ],
"panel": false "panel": false,
"example": "label"
}, },
{ {
"path": "props.option.series.1.name", "path": "props.option.series.1.name",
@@ -82,7 +82,8 @@
"aliases": [ "aliases": [
"折线名称" "折线名称"
], ],
"panel": false "panel": false,
"example": "label"
}, },
{ {
"path": "props.option.series.0.barWidth", "path": "props.option.series.0.barWidth",
@@ -109,7 +110,8 @@
"span": 12, "span": 12,
"aliases": [ "aliases": [
"左轴标题" "左轴标题"
] ],
"example": "label"
}, },
{ {
"path": "props.option.yAxis.1.name", "path": "props.option.yAxis.1.name",
@@ -119,7 +121,8 @@
"span": 12, "span": 12,
"aliases": [ "aliases": [
"右轴标题" "右轴标题"
] ],
"example": "label"
}, },
{ {
"path": "props.option.yAxis.1.axisLabel.formatter", "path": "props.option.yAxis.1.axisLabel.formatter",
+9 -4
View File
@@ -86,7 +86,9 @@
"图表名称", "图表名称",
"图表标题", "图表标题",
"标题文字" "标题文字"
] ],
"example": "label",
"identifies": true
}, },
{ {
"path": "props.option.title.textStyle.color", "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", "path": "props.option.series.0.smooth",
@@ -534,7 +538,8 @@
"系列名", "系列名",
"数据名称" "数据名称"
], ],
"panel": false "panel": false,
"example": "label"
}, },
{ {
"path": "props.option.series.0.label.show", "path": "props.option.series.0.label.show",
@@ -12,7 +12,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:filter-bold", "icon": "solar:filter-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作"
-1
View File
@@ -12,7 +12,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:speedometer-middle-bold", "icon": "solar:speedometer-middle-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作"
+2 -2
View File
@@ -12,7 +12,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:align-left-bold", "icon": "solar:align-left-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作"
@@ -133,7 +132,8 @@
"系列名", "系列名",
"数据名称" "数据名称"
], ],
"panel": false "panel": false,
"example": "label"
}, },
{ {
"path": "props.option.series.0.barWidth", "path": "props.option.series.0.barWidth",
@@ -15,7 +15,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:widget-bold", "icon": "solar:widget-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作",
-1
View File
@@ -12,7 +12,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:graph-up-bold", "icon": "solar:graph-up-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作"
@@ -15,7 +15,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:battery-charge-bold", "icon": "solar:battery-charge-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作"
@@ -120,7 +119,8 @@
"系列名", "系列名",
"数据名称" "数据名称"
], ],
"panel": false "panel": false,
"example": "label"
} }
] ]
}, },
-1
View File
@@ -12,7 +12,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:pie-chart-2-bold", "icon": "solar:pie-chart-2-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作"
@@ -15,7 +15,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:pie-chart-3-linear", "icon": "solar:pie-chart-3-linear",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作",
-1
View File
@@ -12,7 +12,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:radar-2-bold", "icon": "solar:radar-2-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作"
-1
View File
@@ -12,7 +12,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:pie-chart-3-bold", "icon": "solar:pie-chart-3-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作"
-1
View File
@@ -14,7 +14,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:pie-chart-bold", "icon": "solar:pie-chart-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作"
+2 -2
View File
@@ -12,7 +12,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:chart-square-bold", "icon": "solar:chart-square-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作" "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作"
@@ -63,7 +62,8 @@
"系列名", "系列名",
"数据名称" "数据名称"
], ],
"panel": false "panel": false,
"example": "label"
} }
] ]
}, },
@@ -14,7 +14,6 @@
"renderer": "echarts", "renderer": "echarts",
"group": "charts", "group": "charts",
"icon": "solar:chart-bold", "icon": "solar:chart-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认图表数据仅用于演示,不是真实业务数据", "默认图表数据仅用于演示,不是真实业务数据",
"通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作", "通过顶层 dataId 关联已有数据源,当前后端尚未实现绑定操作",
@@ -65,7 +64,8 @@
"aliases": [ "aliases": [
"图例名称" "图例名称"
], ],
"panel": false "panel": false,
"example": "label"
}, },
{ {
"path": "props.option.series.1.name", "path": "props.option.series.1.name",
@@ -75,7 +75,8 @@
"aliases": [ "aliases": [
"图例名称" "图例名称"
], ],
"panel": false "panel": false,
"example": "label"
}, },
{ {
"path": "props.option.series.2.name", "path": "props.option.series.2.name",
@@ -85,7 +86,8 @@
"aliases": [ "aliases": [
"图例名称" "图例名称"
], ],
"panel": false "panel": false,
"example": "label"
} }
] ]
}, },
+5 -3
View File
@@ -15,7 +15,6 @@
"renderer": "stat", "renderer": "stat",
"group": "info", "group": "info",
"icon": "solar:graph-up-bold", "icon": "solar:graph-up-bold",
"sample_data": true,
"usage_notes": [ "usage_notes": [
"默认数值仅用于演示,不是真实业务数据", "默认数值仅用于演示,不是真实业务数据",
"一个指标卡只显示一个指标,多个指标用多个指标卡并排", "一个指标卡只显示一个指标,多个指标用多个指标卡并排",
@@ -43,7 +42,9 @@
"名称", "名称",
"指标名称", "指标名称",
"卡片标题" "卡片标题"
] ],
"example": "label",
"identifies": true
}, },
{ {
"path": "props.value", "path": "props.value",
@@ -54,7 +55,8 @@
"数字", "数字",
"指标值", "指标值",
"数据" "数据"
] ],
"example": "data"
}, },
{ {
"path": "props.prefix", "path": "props.prefix",
+4 -3
View File
@@ -3,7 +3,7 @@
"key": "text", "key": "text",
"type": "text", "type": "text",
"name": "文本", "name": "文本",
"description": "显示一段文字,常用作大屏标题、说明或标签", "description": "显示文字,用作大屏标题、小标题或标签",
"tags": [ "tags": [
"info", "info",
"文本", "文本",
@@ -12,7 +12,6 @@
"renderer": "text", "renderer": "text",
"group": "info", "group": "info",
"icon": "solar:text-bold", "icon": "solar:text-bold",
"sample_data": false,
"usage_notes": [ "usage_notes": [
"events 中代码只作为资料保存,不由后端执行" "events 中代码只作为资料保存,不由后端执行"
], ],
@@ -33,7 +32,9 @@
"文本内容", "文本内容",
"标题文字", "标题文字",
"名字" "名字"
] ],
"example": "label",
"identifies": true
} }
] ]
}, },
+3 -3
View File
@@ -15,12 +15,12 @@ class AddAction(ActionModel):
node: ScreenNode = Field( node: ScreenNode = Field(
description="新组件。id、type、layout 必填,其余只写需要覆盖物料默认配置的字段;" description="新组件。id、type、layout 必填,其余只写需要覆盖物料默认配置的字段;"
"后端按 type 找到物料,用默认配置补全:对象逐层覆盖,数组整体替换,null 明确覆盖。" "后端按 type 找到物料,用默认配置补全:对象逐层覆盖,数组整体替换,null 明确覆盖。"
"写出的字段和 update 一样按字段定义检查类型和取值(跟随主题的颜色可以写 null)。"
"id 不能与当前大屏上的组件重复(包括之前提交里新增的),先 delete 的 ID 可以再用;type 用 list_components 返回的 type。" "id 不能与当前大屏上的组件重复(包括之前提交里新增的),先 delete 的 ID 可以再用;type 用 list_components 返回的 type。"
'任何字段值都可以写成 {"$query": "SELECT ..."},后端替换成上传数据的查询结果:写入单个值的字段时结果要一行一列,取那个值;写入数字、文字数组的字段时结果要只有一列;其余是对象数组,键是结果的列名' '任何字段值都可以写成 {"$query": "SELECT ..."},后端替换成上传数据的查询结果:写入单个值的字段时结果要一行一列,取那个值;写入数字、文字数组的字段时结果要只有一列;其余是对象数组,键是结果的列名'
) )
sample_data: bool = Field( sample_data: bool = Field(
default=False, default=False,
description="true 表示组件显示的不是真实业务数据(物料示例数据,或自己编写的数据)。不写时按物料设置;" description="true 表示组件展示的是自己编写的数据(不是来自上传的文件或大屏上已有的组件),交付时标为示例数据。"
"只有展示的数据全部来自大屏上已有的组件或上传的数据文件时写 false,例如更换类型时迁移了原组件的数据;" "组件上留着物料示例数据时后端会自动标为示例数据,不用写",
"node 里用了 $query 时不写也会设为 false",
) )
+2
View File
@@ -22,4 +22,6 @@ class AgentResult(BaseModel):
status: Literal["completed", "waiting_for_input", "failed", "cancelled"] status: Literal["completed", "waiting_for_input", "failed", "cancelled"]
actions: list[Action] = Field(default_factory=list) actions: list[Action] = Field(default_factory=list)
answer: str | None = None # 回答,或失败时由模型总结的原因说明 answer: str | None = None # 回答,或失败时由模型总结的原因说明
# 可以一键发送的后续请求,前端显示成按钮,点了就作为下一条消息发出;目前只有问答查了数据后的"放到大屏上"
suggestions: list[str] = Field(default_factory=list)
clarification: Clarification | None = None clarification: Clarification | None = None
+2 -3
View File
@@ -50,9 +50,6 @@ class ComponentDefinition(ExtensibleModel):
"前端属性面板的 setters 由它派生,不单独保存", "前端属性面板的 setters 由它派生,不单独保存",
) )
eventOptions: list[JsonObject] = Field(default_factory=list) eventOptions: list[JsonObject] = Field(default_factory=list)
sample_data: bool = Field(
default=False, description="默认配置是否含示例数据,不能视为真实业务数据"
)
usage_notes: list[NonBlankString] = Field( usage_notes: list[NonBlankString] = Field(
default_factory=list, description="配置、数据绑定及使用限制说明" default_factory=list, description="配置、数据绑定及使用限制说明"
) )
@@ -82,6 +79,8 @@ def _default_problem(field, template: JsonObject) -> str | None:
return "的路径与默认配置结构冲突" return "的路径与默认配置结构冲突"
if field.themed: if field.themed:
return None if current is MISSING else "跟随主题,默认配置里不能写死颜色" return None if current is MISSING else "跟随主题,默认配置里不能写死颜色"
if field.example and current is MISSING:
return "标为示例内容,默认配置里要有这个值"
if current is MISSING: if current is MISSING:
if field.default is None: if field.default is None:
return "在默认配置中不存在,需要声明 default" return "在默认配置中不存在,需要声明 default"
+13
View File
@@ -106,6 +106,15 @@ class MaterialField(ExtensibleModel):
default=False, default=False,
description="颜色跟随画布主题:默认配置里不写值,由前端按画布主题(亮 / 暗)取色;写入 null 恢复跟随", 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="是否允许修改") editable: bool = Field(default=True, description="是否允许修改")
panel: bool = Field(default=True, description="是否显示在前端属性面板") panel: bool = Field(default=True, description="是否显示在前端属性面板")
span: int | None = Field(default=None, description="属性面板栅格宽度,24 为整行") 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}") raise ValueError(f"{self.value_type} 字段没有属性面板控件,panel 必须为 false: {self.path}")
if self.themed and (self.value_type != "color" or self.default is not None): if self.themed and (self.value_type != "color" or self.default is not None):
raise ValueError(f"只有 color 字段可以跟随主题,且不能声明 default: {self.path}") 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 return self
def value_error(self, value: JsonValue) -> str | None: def value_error(self, value: JsonValue) -> str | None:
+1
View File
@@ -221,6 +221,7 @@ class AgentService:
actions=(state.get("actions") or []) if deliver else [], actions=(state.get("actions") or []) if deliver else [],
answer=state.get("answer"), answer=state.get("answer"),
clarification=state.get("clarification") if waiting else None, 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): async def stream(self, thread_id, payload, session_id, user_id=None, context=None):
-1
View File
@@ -31,7 +31,6 @@ describe_data 为准
- 日期按需要的粒度转成文字,例如 strftime (月份, '%Y-%m'),坐标轴上不出现完整的时间 - 日期按需要的粒度转成文字,例如 strftime (月份, '%Y-%m'),坐标轴上不出现完整的时间
- 指标卡(stat-card)的数值、对比的变化量和迷你趋势线的数据(trend.data)也写 $query:数值和变化量的查询结果要一行一列, - 指标卡(stat-card)的数值、对比的变化量和迷你趋势线的数据(trend.data)也写 $query:数值和变化量的查询结果要一行一列,
环比、同比在 SQL 里算好;趋势数据只选数值一列,按时间 ORDER BY 环比、同比在 SQL 里算好;趋势数据只选数值一列,按时间 ORDER BY
- 用户要分析结论时,加一个文本组件写结论,只写查询结果支持的内容
- 用了 $query 的新增组件不用写 sample_data,后端会设为 false;标题按数据的内容写 - 用了 $query 的新增组件不用写 sample_data,后端会设为 false;标题按数据的内容写
## 数据不合适时 ## 数据不合适时
+1
View File
@@ -11,6 +11,7 @@ description: 新建大屏,或在大屏上新增组件、整体重新布局、
2. 需要的查询(get_component_materials、get_nodes 等)互不依赖,在同一轮一起调用 2. 需要的查询(get_component_materials、get_nodes 等)互不依赖,在同一轮一起调用
3. 新建大屏时,根据主题安排一个标题文本和若干图表;图表标题按要展示的内容写,不沿用物料的示例标题 3. 新建大屏时,根据主题安排一个标题文本和若干图表;图表标题按要展示的内容写,不沿用物料的示例标题
4. 销售额、订单数、转化率这类关键数字用指标卡(stat-card),一个指标一张卡,不用文本拼数字 4. 销售额、订单数、转化率这类关键数字用指标卡(stat-card),一个指标一张卡,不用文本拼数字
5. 大屏上的文字只放标题、小标题和简短标签。分析结论、洞察、建议、数据说明和注意事项写在回复里,不写进组件;用户明确要求在大屏上放一段文字(例如"加一段文字写结论")时才写。"把上面的结果放到大屏上"是用指标卡和图表展示结果里的数据
## 布局 ## 布局