聊天窗口里,我会先给出 2016 年上半年销售额最高的 5 个客户:
| 客户 | 客户类别 | 含税销售额 |
|---|---|---|
| Wingtip Toys (Leathersville, GA) | Novelty Shop | 89,085.91 |
| Wingtip Toys (Cale, AR) | Novelty Shop | 84,442.95 |
| Wingtip Toys (Ware Shoals, SC) | Novelty Shop | 82,556.27 |
| Daniel Martensson | Novelty Shop | 82,207.14 |
| Sabine Alksne | Supermarket | 81,890.69 |
接下来通常会有人问:一共有多少客户?只看 Supermarket 怎么看?能不能按金额重新排序?客户名称能不能直接点回业务系统?
问题在于,这 5 行还不够支撑后面的工作。用户点击“查看全部”后,应该进入同一份查询的完整结果,而不是再打开一套临时页面。实测初始结果有 146 个客户分组,增加“客户类别 = Supermarket”后剩 19 个。
先看聊天结果背后的查询
本例使用 Wide World Importers 的发票明细,销售额取 totalIncludingTax,时间范围为 2016-01-01(含)到 2016-06-01(不含)。查询的核心部分如下:
{
"columns": [
"customer$id",
"customer$caption",
"customer$category",
"customer$salesTerritory",
"count(invoiceLineId) as invoiceLineCount",
"sum(totalIncludingTax) as totalIncludingTax"
],
"groupBy": [
{ "field": "customer$id" },
{ "field": "customer$caption" },
{ "field": "customer$category" },
{ "field": "customer$salesTerritory" }
],
"slice": [
{
"field": "invoiceDate$id",
"op": "[)",
"value": ["2016-01-01", "2016-06-01"]
}
],
"orderBy": [
{ "field": "totalIncludingTax", "dir": "DESC" }
]
}
聊天里显示前 5 行,完整列表则使用这份 DSL 创建 Viewer 查询,返回 queryId 和打开地址。列、分组、日期范围、金额口径和默认排序都来自查询本身,表格不需要再从“上半年销售额最高”这句话重新猜一遍。
常见的做法是聊天接口算一次,列表接口再写一条 SQL。金额字段或日期边界只要有一处改动,摘要和列表就会对不上。这里先保留同一份查询,再换一种展示方式,后续的问题也沿着这份查询继续处理。
SQL 能执行,DSL 才方便继续查
这里和传统 Text2SQL 有一个很实际的区别。
Text2SQL 的典型过程是:模型生成 SQL,数据库执行 SQL,页面展示结果。这个过程本身没有问题,SQL 也是最终要执行的语言。但 SQL 返回结果后,用户想继续查什么,往往只能再把问题交回模型,或者让开发人员为下一种筛选、排序和分页补页面代码。
尤其是下面这些信息,单看一段 SQL 并不适合直接交给业务用户:
- 哪些字段可以筛选,字段是日期、金额还是枚举;
- 一个条件应该使用等于、范围还是包含;
- 当前结果是明细、按客户分组,还是按客户汇总;
- 哪些列允许排序、分页和服务端汇总;
- 哪些条件来自用户,哪些条件是系统权限强制追加的。
这些内容当然也可以另写一套 SQL 解析器和页面配置,但那已经是 SQL 之外的应用层协议了。只把 SQL 文本交给前端,前端并不知道怎样安全地修改它,也不知道改完之后是否还保持原来的业务口径。
本例里的 DSL 把这些操作拆成了明确的结构:columns 表示返回哪些列,groupBy 表示统计粒度,slice 表示筛选条件,orderBy 表示排序。Viewer 和业务组件可以读取 QM 的字段元数据,为这些结构生成合适的控件;用户的操作再被转换成 DSL 变化,由服务端重新执行。
| 用户动作 | DSL 变化 | 继续使用的入口 |
|---|---|---|
| 只看 Supermarket | 增加 slice | 表头筛选器 / 查询 API |
| 按金额升序排列 | 修改 orderBy | 表格排序 |
| 查看下一页 | 修改分页位置和数量 | Viewer / 服务端分页 |
| 改看客户类别汇总 | 调整 columns、groupBy | Viewer / 业务组件 |
所以用户可以在看到 AI 摘要后离开 AI:打开表格,按自己的判断增加条件、翻页、换排序,再从客户 ID 回到业务详情。这个过程不需要模型重新猜“上半年”“销售额”或“客户”的含义。
这不是说 DSL 会取代 SQL。请求最终仍然要落成数据库可以执行的 SQL;区别在于,SQL 被留在服务端执行层,用户和上层应用操作的是有字段、类型、粒度和权限边界的查询结构。对还没有成熟 BI 平台的企业,这一层结构正好把“AI 会回答”往“用户可以自己继续查”推进了一步。
表格打开后还能继续筛选
完整结果不需要一次性返回 146 行。Viewer 负责分页,排序和筛选条件继续交给服务端执行。
例如在表头增加一个客户类别条件:
{
"field": "customer$category",
"op": "=",
"value": "Supermarket"
}
实测结果从 146 个客户分组变为 19 个,第一名仍是 Sabine Alksne,含税销售额仍为 81,890.69。原来的日期范围、分组方式和金额口径没有变化。
如果只让 AI 生成一段 HTML,结果可以显示出来,但翻页、换排序或增加条件时,还要另做一条查询链路。查询模型已经把这些动作留在同一个服务端查询里,业务页面只需要接住它。
客户详情仍然由原业务系统负责
Viewer 不需要再做一套客户管理页面。业务系统可以把客户名称这一列包装成自己的详情路由,例如:
<template #column-customer$caption="{ row }">
<RouterLink :to="`/customers/${row['customer$id']}`">
{{ row['customer$caption'] }}
</RouterLink>
</template>
这段代码只是嵌入时的示例,Wide World Importers 本身并没有一个已经接好的客户详情页面。实际项目里,路由、详情内容、跟进和修改权限仍然属于原来的业务系统,Viewer 只提供经过 QM 和权限条件约束的查询结果。
这样从客户销售排行点进客户详情时,不需要先导出 Excel、复制客户名称,再到另一个页面重新搜索。分析结果和业务主数据仍然通过客户 ID 连接。
queryId 不是权限凭证
上一篇已经验证过,同一份 DSL 在不同身份下要返回不同结果。这里多了一个 queryId,也不能把这个约束去掉。
这次创建 Viewer 查询时使用的是东南区测试身份。随后做了三次请求:
- 带东南区身份,返回东南区的 146 个客户分组。
- 不带身份,模型元数据返回空字段,但 data endpoint 会回退到创建查询时缓存的身份上下文,仍能返回东南区数据。
- 带新英格兰区身份,同一个
queryId返回新英格兰区的 33 个客户分组。
因此,短地址解决的是“找到哪份查询”和“查询何时过期”,不是“谁拿到地址都可以看”。生产接入时,业务系统、同源网关或嵌入式组件需要在每次 Viewer 请求中传入当前登录用户的可信身份和 namespace。当前实现存在缓存身份回退语义,部署时还需要结合实际网关和审计方案评估,不能直接当成安全分享链接发布。
所以这里的地址只是查询入口。页面打开之后,仍然要经过原来的权限链路。
目前先停在只读查询
这次跑通的是从问数摘要进入可筛选表格,再回到业务详情页的只读链路。下面这些动作没有放进 AI:
- 给客户打标签、修改状态或发起审批;
- 由一个 URL 代替业务系统的授权;
- 在没有接入登录态和详情路由的情况下,宣称可以直接嵌入任何企业。
对于还没有成熟 BI 平台的企业,第一版先把这段链路接起来就够了:业务人员从一个问题开始,看到可核对的销售数据,在列表里继续缩小范围,再回到熟悉的系统处理客户,不必在聊天窗口、Excel 和业务页面之间来回复制数据。
下一篇再处理一个更容易出现争议的问题:同一批人同时看订单金额和开票销售额时,怎样保证两个入口使用的是清楚、可追溯的口径。