Skip to content

Commit 1cd7f74

Browse files
committed
feat: support multiple metrics in line, column, and bar charts
1 parent a0544fa commit 1cd7f74

File tree

18 files changed

+897
-477
lines changed

18 files changed

+897
-477
lines changed

backend/apps/chat/task/llm.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -836,10 +836,27 @@ def check_save_chart(self, session: Session, res: str) -> Dict[str, Any]:
836836
if chart.get('axis'):
837837
if chart.get('axis').get('x'):
838838
chart.get('axis').get('x')['value'] = chart.get('axis').get('x').get('value').lower()
839-
if chart.get('axis').get('y'):
840-
chart.get('axis').get('y')['value'] = chart.get('axis').get('y').get('value').lower()
839+
y_axis = chart.get('axis').get('y')
840+
if y_axis:
841+
if isinstance(y_axis, list):
842+
# 数组格式: y: [{name, value}, ...]
843+
for item in y_axis:
844+
if item.get('value'):
845+
item['value'] = item['value'].lower()
846+
elif isinstance(y_axis, dict) and y_axis.get('value'):
847+
# 旧格式: y: {name, value}
848+
y_axis['value'] = y_axis['value'].lower()
841849
if chart.get('axis').get('series'):
842850
chart.get('axis').get('series')['value'] = chart.get('axis').get('series').get('value').lower()
851+
if chart.get('axis') and chart['axis'].get('multi-quota'):
852+
muti_quota = chart['axis']['multi-quota']
853+
if muti_quota.get('value'):
854+
if isinstance(muti_quota['value'], list):
855+
# 将数组中的每个值转换为小写
856+
muti_quota['value'] = [v.lower() if v else v for v in muti_quota['value']]
857+
elif isinstance(muti_quota['value'], str):
858+
# 如果是字符串,也转换为小写
859+
muti_quota['value'] = muti_quota['value'].lower()
843860
elif data['type'] == 'error':
844861
message = data['reason']
845862
error = True
@@ -1451,20 +1468,42 @@ def request_picture(chat_id: int, record_id: int, chart: dict, data: dict):
14511468
x = None
14521469
y = None
14531470
series = None
1471+
muti_quota_fields = []
1472+
muti_quota_name =None
1473+
14541474
if chart.get('axis'):
1455-
x = chart.get('axis').get('x')
1456-
y = chart.get('axis').get('y')
1457-
series = chart.get('axis').get('series')
1475+
axis_data = chart.get('axis')
1476+
x = axis_data.get('x')
1477+
y = axis_data.get('y')
1478+
series = axis_data.get('series')
1479+
# 获取multi-quota字段列表
1480+
if axis_data.get('multi-quota') and 'value' in axis_data.get('multi-quota'):
1481+
muti_quota_fields = axis_data.get('multi-quota').get('value', [])
1482+
muti_quota_name = axis_data.get('multi-quota').get('name')
14581483

14591484
axis = []
14601485
for v in columns:
14611486
axis.append({'name': v.get('name'), 'value': v.get('value')})
14621487
if x:
14631488
axis.append({'name': x.get('name'), 'value': x.get('value'), 'type': 'x'})
14641489
if y:
1465-
axis.append({'name': y.get('name'), 'value': y.get('value'), 'type': 'y'})
1490+
y_list = y if isinstance(y, list) else [y]
1491+
1492+
for y_item in y_list:
1493+
if isinstance(y_item, dict) and 'value' in y_item:
1494+
y_obj = {
1495+
'name': y_item.get('name'),
1496+
'value': y_item.get('value'),
1497+
'type': 'y'
1498+
}
1499+
# 如果是multi-quota字段,添加标志
1500+
if y_item.get('value') in muti_quota_fields:
1501+
y_obj['multi-quota'] = True
1502+
axis.append(y_obj)
14661503
if series:
14671504
axis.append({'name': series.get('name'), 'value': series.get('value'), 'type': 'series'})
1505+
if muti_quota_name:
1506+
axis.append({'name': muti_quota_name, 'value': muti_quota_name, 'type': 'other-info'})
14681507

14691508
request_obj = {
14701509
"path": os.path.join(settings.MCP_IMAGE_PATH, file_name),

backend/common/utils/data_format.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,17 @@ def convert_data_fields_for_pandas(chart: dict, fields: list, data: list):
8989
if chart.get('axis').get('x'):
9090
_fields[chart.get('axis').get('x').get('value')] = chart.get('axis').get('x').get('name')
9191
if chart.get('axis').get('y'):
92-
_fields[chart.get('axis').get('y').get('value')] = chart.get('axis').get('y').get('name')
92+
# _fields[chart.get('axis').get('y').get('value')] = chart.get('axis').get('y').get('name')
93+
y_axis = chart.get('axis').get('y')
94+
if isinstance(y_axis, list):
95+
# y轴是数组的情况(多指标字段)
96+
for y_item in y_axis:
97+
if isinstance(y_item, dict) and 'value' in y_item and 'name' in y_item:
98+
_fields[y_item.get('value')] = y_item.get('name')
99+
elif isinstance(y_axis, dict):
100+
# y轴是对象的情况(单指标字段)
101+
if 'value' in y_axis and 'name' in y_axis:
102+
_fields[y_axis.get('value')] = y_axis.get('name')
93103
if chart.get('axis').get('series'):
94104
_fields[chart.get('axis').get('series').get('value')] = chart.get('axis').get('series').get(
95105
'name')

backend/templates/template.yaml

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,6 @@ template:
138138
<rule>
139139
若涉及多表查询,则生成的SQL内,不论查询的表字段是否有重名,表字段前必须加上对应的表名
140140
</rule>
141-
<rule>
142-
我们目前的情况适用于单指标、多分类的场景(展示table除外)
143-
</rule>
144141
<rule>
145142
是否生成对话标题在<change-title>内,如果为True需要生成,否则不需要生成,生成的对话标题要求在20字以内
146143
</rule>
@@ -323,24 +320,59 @@ template:
323320
必须从 SQL 查询列中提取“columns”
324321
</rule>
325322
<rule>
326-
如果需要柱状图,JSON格式应为(如果有分类则在JSON中返回series):
327-
{{"type":"column", "title": "标题", "axis": {{"x": {{"name":"x轴的{lang}名称", "value": "SQL 查询 x 轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": {{"name":"y轴的{lang}名称","value": "SQL 查询 y 轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}}
328-
柱状图使用一个分类字段(series),一个X轴字段(x)和一个Y轴数值字段(y),其中必须从SQL查询列中提取"x"、"y"与"series"。
323+
<strong>字段类型定义:</strong>
324+
<strong>- 分类字段(series):用于分组数据的离散值字段,如国家、产品类别、用户类型等(非时间、非数值的离散字段)</strong>
325+
<strong>- 指标字段(数值字段/y轴):需要计算或展示的数值字段,通常是数值类型</strong>
326+
<strong>- 维度字段(维度轴/x轴):用于X轴的分类或时间字段,如日期、产品名称、地区等</strong>
327+
</rule>
328+
<rule>
329+
<strong>图表配置决策流程:</strong>
330+
<strong>1. 先判断SQL查询结果中是否存在分类字段(非时间、非数值的离散字段)</strong>
331+
<strong>2. 如果存在分类字段 → 必须使用series配置,此时y轴只能有一个指标字段</strong>
332+
<strong>3. 如果不存在分类字段,但存在多个指标字段 → 必须使用multi-quota配置</strong>
333+
<strong>4. 如果只有一个指标字段且无分类字段 → 直接配置y轴,不使用series和multi-quota</strong>
334+
</rule>
335+
<rule>
336+
如果需要柱状图,JSON格式应为<strong>(series为可选字段,仅当有分类字段时使用)</strong>:
337+
{{"type":"column", "title": "标题", "axis": {{"x": {{"name":"维度轴的{lang}名称", "value": "SQL 查询维度轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": [{{"name":"数值轴的{lang}名称","value": "SQL 查询数值轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}], "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}}
338+
<strong>柱状图配置说明:</strong>
339+
<strong>1. x轴:维度轴,通常放置分类或时间字段(如日期、产品类别)</strong>
340+
<strong>2. y轴:数值轴,放置需要展示的数值指标</strong>
341+
<strong>3. series:当需要对数据进一步分组时使用(如不同产品系列在不同日期的销售额)</strong>
342+
柱状图使用一个分类字段(series),一个<strong>维度轴</strong>字段(x)和一个<strong>数值轴</strong>字段(y),其中必须从SQL查询列中提取"x"、"y"与"series"。
343+
<strong>如果SQL中没有分类列,那么JSON内的series字段不需要出现</strong>
329344
</rule>
330345
<rule>
331-
如果需要条形图,JSON格式应为(如果有分类则在JSON中返回series),条形图相当于是旋转后的柱状图,因此 x 轴仍为维度轴,y 轴仍为指标轴:
332-
{{"type":"bar", "title": "标题", "axis": {{"x": {{"name":"x轴的{lang}名称", "value": "SQL 查询 x 轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": {{"name":"y轴的{lang}名称","value": "SQL 查询 y 轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}}
333-
条形图使用一个分类字段(series),一个X轴字段(x)和一个Y轴数值字段(y),其中必须从SQL查询列中提取"x"和"y"与"series"。
346+
如果需要条形图,JSON格式应为<strong>(series为可选字段,仅当有分类字段时使用)</strong>:
347+
<strong>⚠️ 重要:条形图是柱状图的视觉旋转,但数据映射逻辑保持不变!</strong>
348+
<strong>必须遵循:x轴 = 维度轴(分类),y轴 = 数值轴(指标)</strong>
349+
<strong>不要将条形图的横向展示误解为x轴是数值!</strong>
350+
{{"type":"bar", "title": "标题", "axis": {{"x": {{"name":"维度轴的{lang}名称", "value": "SQL 查询维度轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": [{{"name":"数值轴的{lang}名称","value": "SQL 查询数值轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}], "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}}
351+
<strong>条形图配置原则:</strong>
352+
<strong>1. 条形图只是视觉展示不同,数据逻辑与柱状图相同</strong>
353+
<strong>2. x轴必须是维度字段(分类、时间等)</strong>
354+
<strong>3. y轴必须是数值字段(指标、度量等)</strong>
355+
<strong>4. 如果存在分类字段(如不同产品系列),使用series分组</strong>
356+
条形图使用一个分类字段(series),一个<strong>维度轴</strong>字段(x)和一个<strong>数值轴</strong>字段(y),其中必须从SQL查询列中提取"x"和"y"与"series"。
357+
<strong>如果SQL中没有分类列,那么JSON内的series字段不需要出现</strong>
334358
</rule>
335359
<rule>
336-
如果需要折线图,JSON格式应为(如果有分类则在JSON中返回series):
337-
{{"type":"line", "title": "标题", "axis": {{"x": {{"name":"x轴的{lang}名称","value": "SQL 查询 x 轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": {{"name":"y轴的{lang}名称","value": "SQL 查询 y 轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}}
338-
折线图使用一个分类字段(series),一个X轴字段(x)和一个Y轴数值字段(y),其中必须从SQL查询列中提取"x"、"y"与"series"。
360+
如果需要折线图,JSON格式应为<strong>(series为可选字段,仅当有分类字段时使用)</strong>:
361+
{{"type":"line", "title": "标题", "axis": {{"x": {{"name":"维度轴的{lang}名称","value": "SQL 查询维度轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "y": [{{"name":"数值轴的{lang}名称","value": "SQL 查询数值轴的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}], "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}}
362+
<strong>折线图配置说明:</strong>
363+
<strong>1. x轴:维度轴,通常放置时间字段(如日期、月份)</strong>
364+
<strong>2. y轴:数值轴,放置需要展示趋势的数值指标</strong>
365+
<strong>3. series:当需要对比多个分类的趋势时使用(如不同产品的销售趋势)</strong>
366+
折线图使用一个分类字段(series),一个<strong>维度轴</strong>字段(x)和一个<strong>数值轴</strong>字段(y),其中必须从SQL查询列中提取"x"、"y"与"series"。
367+
<strong>如果SQL中没有分类列,那么JSON内的series字段不需要出现</strong>
339368
</rule>
340369
<rule>
341370
如果需要饼图,JSON格式应为:
342-
{{"type":"pie", "title": "标题", "axis": {{"y": {{"name":"值轴的{lang}名称","value":"SQL 查询数值的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}}
343-
饼图使用一个分类字段(series)和一个数值字段(y),其中必须从SQL查询列中提取"y"与"series"。
371+
{{"type":"pie", "title": "标题", "axis": {{"y": {{"name":"数值轴的{lang}名称","value":"SQL 查询数值的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}, "series": {{"name":"分类的{lang}名称","value":"SQL 查询分类的列(有别名用别名,去掉外层的反引号、双引号、方括号)"}}}}}}
372+
<strong>饼图配置说明:</strong>
373+
<strong>1. y轴:数值字段,表示各部分的大小</strong>
374+
<strong>2. series:分类字段,表示各部分的名称</strong>
375+
饼图使用一个分类字段(series)和一个<strong>数值</strong>字段(y),其中必须从SQL查询列中提取"y"与"series"。
344376
</rule>
345377
<rule>
346378
如果SQL中没有分类列,那么JSON内的series字段不需要出现
@@ -349,7 +381,11 @@ template:
349381
如果SQL查询结果中存在可用于数据分类的字段(如国家、产品类型等),则必须提供series配置。如果不存在,则无需在JSON中包含series字段。
350382
</rule>
351383
<rule>
352-
我们目前的情况适用于单指标、多分类的场景(展示table除外),若SQL中包含多指标列,请选择一个最符合提问情况的指标作为值轴
384+
对于柱状图/条形图/折线图:
385+
1. 如果SQL查询中存在多个指标字段(如"收入"、"支出"、"利润"等数值字段)且不存在分类字段,则必须提供multi-quota配置,形如:"multi-quota":{{"name":"指标类型","value":["指标字段1","指标字段2",...]}}
386+
2. 如果SQL查询中存在多个指标字段且同时存在分类字段,则以分类字段为主,选取多指标字段中的其中一个作为指标即可,不需要multi-quota配置
387+
3. 如果只有一个指标字段,无论是否存在分类字段,都不需要multi-quota配置
388+
<strong>重要提醒:multi-quota和series是互斥的配置,一个图表配置中只能使用其中之一,不能同时存在</strong>
353389
</rule>
354390
<rule>
355391
如果你无法根据提供的内容生成合适的JSON配置,则返回:{{"type":"error", "reason": "抱歉,我无法生成合适的图表配置"}}
@@ -381,6 +417,17 @@ template:
381417
{{"type":"pie","title":"组织人数统计","axis":{{"y":{{"name":"人数","value":"user_count"}},"series":{{"name":"组织名称","value":"org_name"}}}}}}
382418
</output>
383419
</example>
420+
<example>
421+
<input>
422+
<sql>SELECT `s`.`date` AS `date`, `s`.`income` AS `income`, `s`.`expense` AS `expense` FROM `financial_data` `s` ORDER BY `date` ASC LIMIT 1000</sql>
423+
<user-question>展示每月的收入与支出</user-question>
424+
<chart-type> line </chart-type>
425+
</input>
426+
<output>
427+
// 无分类字段,但有多个指标字段的情况
428+
{{"type":"line","title":"财务指标趋势","axis":{{"x":{{"name":"日期","value":"date"}},"y":[{{"name":"收入","value":"income"}}, {{"name":"支出","value":"expense"}}], "multi-quota":{{"name":"财务指标","value":["income","expense"]}}}}}}
429+
</output>
430+
</example>
384431
</chat-examples>
385432
<example>
386433

frontend/src/views/chat/component/BaseChart.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
export interface ChartAxis {
22
name: string
33
value: string
4-
type?: 'x' | 'y' | 'series'
4+
type?: 'x' | 'y' | 'series' | 'other-info'
5+
'multi-quota'?: boolean
56
}
67

78
export interface ChartData {

frontend/src/views/chat/component/ChartComponent.vue

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ const params = withDefaults(
1313
x?: Array<ChartAxis>
1414
y?: Array<ChartAxis>
1515
series?: Array<ChartAxis>
16+
multiQuotaName?: string | undefined
1617
}>(),
1718
{
1819
data: () => [],
1920
columns: () => [],
2021
x: () => [],
2122
y: () => [],
2223
series: () => [],
24+
multiQuotaName: undefined,
2325
}
2426
)
2527
@@ -36,11 +38,19 @@ const axis = computed(() => {
3638
_list.push({ name: column.name, value: column.value, type: 'x' })
3739
})
3840
params.y.forEach((column) => {
39-
_list.push({ name: column.name, value: column.value, type: 'y' })
41+
_list.push({
42+
name: column.name,
43+
value: column.value,
44+
type: 'y',
45+
'multi-quota': column['multi-quota'],
46+
})
4047
})
4148
params.series.forEach((column) => {
4249
_list.push({ name: column.name, value: column.value, type: 'series' })
4350
})
51+
if (params.multiQuotaName) {
52+
_list.push({ name: params.multiQuotaName, value: params.multiQuotaName, type: 'other-info' })
53+
}
4454
return _list
4555
})
4656
@@ -52,7 +62,6 @@ function renderChart() {
5262
chartInstance.init(axis.value, params.data)
5363
chartInstance.render()
5464
}
55-
console.debug(chartInstance)
5665
}
5766
5867
function destroyChart() {

0 commit comments

Comments
 (0)