# AI 数据分析：从语义层到可核对的问数实践

审阅稿 v1 · 2026-10-25

本版面向有一定编程或数据库基础的技术用户。正文共40章；各章保留真实测试版本与能力边界，当前教学环境为Launcher 0.1.42 / CLI 0.1.25。

本次准备HTML、Markdown、PDF与样例，尚未公开发表。在线问数与FDP模型维护为第二阶段，Dashboard和报表为第三阶段。

## 目录

- [第 1 章 从自然语言到 SQL](#chapter-1)
- [第 2 章 SQL 能执行结果为什么仍可能不对](#chapter-2)
- [第 3 章 语义层的作用与代价](#chapter-3)
- [第 4 章 自然语言到 DSL 再到 SQL](#chapter-4)
- [第 5 章 怎样选择和组织问数方案](#chapter-5)
- [第 6 章 认识 Foggy，准备第一个问数项目](#chapter-6)
- [第 7 章 让 AI 探查 Chinook 并确认业务口径](#chapter-7)
- [第 8 章 让 AI 生成语义模型并完成首次问数](#chapter-8)
- [第 9 章 让 AI 正确筛选数据：slice 与 having](#chapter-9)
- [第 10 章 聚合与计算指标：数清楚，再算平均和占比](#chapter-10)
- [第 11 章 按日期与月份汇总数据](#chapter-11)
- [第 12 章 从艺术家查到专辑：维度成员与多级关联](#chapter-12)
- [第 13 章 让 AI 只查询用户可见的数据](#chapter-13)
- [第 14 章 在 QM 中关联多个 TM：普通 JOIN 与聚合 JOIN](#chapter-14)
- [第 15 章 用字典连接业务名称与数据取值](#chapter-15)
- [第 16 章 模型维护与自动化回归](#chapter-16)
- [第 17 章 时间分析：比较、累计与滚动窗口](#chapter-17)
- [第 18 章 业务日期、日历与可比期间](#chapter-18)
- [第 19 章 存量与快照：月底还剩多少](#chapter-19)
- [第 20 章 业务耗时：已完成多久，未完成等了多久](#chapter-20)
- [第 21 章 同期群：同一批客户后来还买吗](#chapter-21)
- [第 22 章 父子维：](#chapter-22)
- [第 23 章 沿层级汇总与下钻：为什么总计不能再相加](#chapter-23)
- [第 24 章 Pivot：把多个维度放到一张交叉表](#chapter-24)
- [第 25 章 透视结果再计算：占父级多少，基准又是谁](#chapter-25)
- [第 26 章 把查询结果交给下一步：Compose 与 CTE](#chapter-26)
- [第 27 章 先汇总，再关联两个查询结果](#chapter-27)
- [第 28 章 先圈选对象，再继续分析](#chapter-28)
- [第 29 章 从一份 Excel 开始问数](#chapter-29)
- [第 30 章 不规范的 Excel 怎么整理](#chapter-30)
- [第 31 章 多份文件如何合并与更新](#chapter-31)
- [第 32 章 遗留数据库如何建立语义层](#chapter-32)
- [第 33 章 系统实际与 Excel 目标一起分析](#chapter-33)
- [第 34 章 让文件分析持续复跑](#chapter-34)
- [第 35 章 用 SQL 表达语义查询](#chapter-35)
- [第 36 章 SQL 转成 DSL 后，怎样核对查询含义](#chapter-36)
- [第 37 章 查询慢在哪里：先看执行解释](#chapter-37)
- [第 38 章 重复查询与数据更新：怎样判断缓存](#chapter-38)
- [第 39 章 预聚合：先汇总，再保持同一口径查询](#chapter-39)
- [第 40 章 分页与持续运行：把结果交付完整](#chapter-40)

---

<a id="chapter-1"></a>

# 第 1 章 从自然语言到 SQL

“帮我查一下，2025 年 6 月销售额最高的三个客户是谁。”

把问题、表结构和业务说明交给 AI，让它生成 SQL，再由查询工具执行，就是本章介绍的 Text-to-SQL 路线。

## 先明确销售额怎么算

本章使用 SQLite 和自编的虚构销售数据。销售额按有效发票总金额累计，月份按开票日期统计，只计算 `ISSUED` 状态。

金额以分保存，按人民币元展示时除以 100，因为 100 分等于 1 元；原本以元保存则无需换算。日期使用同一时区，格式为 `YYYY-MM-DD HH:MM:SS`。

实际项目中，应先从业务负责人、字段说明或已有报表确认这些口径。

## 把必要的表结构交给 AI

客户表 `customers`：

| 字段 | 含义 |
| --- | --- |
| `customer_id` | 客户 ID，主键 |
| `customer_name` | 客户名称 |

发票表 `invoices`：

| 字段 | 含义 |
| --- | --- |
| `invoice_id` | 发票 ID，主键 |
| `customer_id` | 对应客户表的客户 ID |
| `invoice_date` | 开票日期，可能包含时分秒 |
| `total_amount_cents` | 发票总金额，单位为分 |
| `status` | `ISSUED` 表示有效，`CANCELLED` 表示作废 |

一位客户可以有多张发票，两表通过 `customer_id` 关联。将上述结构和下面的要求一并交给 AI，也可以让它先通过工具读取表结构：

> 请用 SQLite 查询 2025 年 6 月有效发票金额最高的三个客户。按开票日期统计，只计算 ISSUED 状态，金额从分换算为元。返回客户 ID、名称和销售额，金额相同按客户 ID 升序。

## 看这句话怎样变成 SQL

对应的查询如下：

```sql
SELECT
    c.customer_id,
    c.customer_name,
    ROUND(SUM(i.total_amount_cents) / 100.0, 2) AS sales_amount
FROM invoices AS i
JOIN customers AS c
    ON c.customer_id = i.customer_id
WHERE i.status = 'ISSUED'
  AND i.invoice_date >= '2025-06-01'
  AND i.invoice_date < '2025-07-01'
GROUP BY c.customer_id, c.customer_name
ORDER BY sales_amount DESC, c.customer_id ASC
LIMIT 3;
```

`i`、`c` 是表别名。查询通过 `JOIN` 关联客户，用 `WHERE` 筛选有效发票，再按客户分组求和、排序取前三名。

时间范围写成“大于等于 6 月 1 日、小于 7 月 1 日”，可以包含六月最后一天带时分秒的记录。

`/ 100.0` 将分换算为元，例如 `300000` 分对应 `3000` 元；`ROUND(..., 2)` 将结果四舍五入到小数点后两位。

## 执行 SQL 后才能得到数字

生成 SQL 后，由数据库客户端或应用提供的查询工具执行，得到真实结果：

![查询与建模流程](../assets/diagrams/ch01-01.png)

本例结果如下：

| 客户 ID | 客户名称 | 销售额（元） |
| --- | --- | ---: |
| 1 | 华东食品 | 5500.00 |
| 2 | 远山商贸 | 4200.00 |
| 3 | 青禾便利 | 3000.00 |

可以逐笔核对：华东食品的两张有效发票分别为 3000 元和 2500 元，合计 5500 元。北辰超市六月有一张 99999 元的作废发票，已被排除。

AI 整理回答时，应同时说明统计月份、使用开票日期和排除作废发票的口径。

## 先通过提示词解释数据结构

从这个例子可以看出，Text-to-SQL 首先要通过提示词向 AI 解释数据库的表、字段和关系，让它理解数据结构，再结合业务口径生成查询。

这些信息可以人工整理，也可以由程序读取元数据后补充到提示上下文中。表结构之外，还要说明状态编码、日期口径和金额单位，才能把“销售额”这样的业务说法对应到具体字段与计算方式。

### 让 AI 从源码整理提示词

已有 Java 源码时，可以让 AI 先整理提示词草稿：

- **JPA**：阅读实体类的表、列映射和关联注解，结合字段注释整理结构。例如，[`@Column`](https://jakarta.ee/specifications/persistence/3.2/apidocs/jakarta.persistence/jakarta/persistence/Column.html) 指定属性与数据库列的映射。
- **MyBatis**：阅读 [Mapper XML](https://mybatis.org/mybatis-3/sqlmap-xml.html)、SQL 注解和 `resultMap`，提取字段映射、关联与筛选条件。

枚举和业务代码还能补充状态含义与计算规则。可以这样交代：

> 请阅读客户、发票相关的实体类、Mapper、SQL 和业务代码，整理表、字段、主键及关联，补充金额单位、状态、日期和计算规则。注明来源文件与位置，不确定的列为待核对项，输出供 Text-to-SQL 使用的提示词草稿。

再由人核对源码是否对应当前数据库，以及关联、单位和业务含义是否准确。这样可以减少逐表逐字段编写说明的工作。

## 自己改一次查询

把问题改成“2025 年 7 月销售额最高的两个客户”，调整时间上下界和条数限制，再核对结果。

将[建表与数据](../resources/chapters/examples/sales-mini/seed.sql)、[查询](../resources/chapters/examples/sales-mini/chapter-01-top-customers.sql)和[复跑脚本](../resources/chapters/examples/sales-mini/verify.py)放在同一目录，安装 Python 3 后进入该目录运行：

```powershell
python -X utf8 verify.py
```

脚本使用 Python 自带的 SQLite，在内存中建库，核对六月查询和七月练习，输出结果并保存为 `chapter-01-results.json`。

## 本章小结

这次 Text-to-SQL 查询中，AI 承担了以下工作：

| 环节 | AI 在本例中需要完成的工作 |
| --- | --- |
| 理解问题 | 确定月份、汇总对象和前三名要求 |
| 匹配表和字段 | 从结构说明中选择客户、发票及所需字段 |
| 确定关联与粒度 | 按客户 ID 关联，每张有效发票金额只累计一次 |
| 展开计算规则 | 确定有效状态、时间边界和金额求和方式 |
| 按需换算金额单位 | 以分保存、按元展示时，将金额合计除以 `100.0`；原本以元保存则无需换算 |
| 生成 SQL | 按 SQLite 语法组织关联、筛选、分组、计算、排序和条数限制 |
| 根据结果回答 | 根据查询结果说明客户排名、金额和统计口径 |

查询工具提交 SQL，数据库执行数据读取与计算，AI 再根据结果组织回答。后面对比 DSL 路线时，可以逐项看哪些工作仍由 AI 完成，哪些定义交给语义模型和查询引擎。

下一章加入发票明细，看看 SQL 正常执行时，金额为什么仍可能算错。


---

<a id="chapter-2"></a>

# 第 2 章 SQL 能执行结果为什么仍可能不对

上一章，青禾便利六月的销售额是 3000 元。这次增加一个要求：“把商品明细行数也列出来。”

查询关联发票明细表后，SQL 正常执行，青禾便利却变成了 6600 元，排名超过了远山商贸。

## 加上明细表以后

统计口径沿用上一章：按开票日期统计六月有效发票的总金额。

新增的 `invoice_lines` 每行代表一条商品明细，通过 `invoice_id` 关联发票；`line_id` 是明细 ID，`line_amount_cents` 是明细金额，单位为分。本题统计明细行数，与商品销售件数不同。

如果直接关联明细，再按客户累计发票金额、统计行数，会得到下面的查询：

```sql
SELECT
    c.customer_id,
    c.customer_name,
    ROUND(SUM(i.total_amount_cents) / 100.0, 2) AS sales_amount,
    COUNT(d.line_id) AS detail_line_count
FROM invoices AS i
JOIN customers AS c
    ON c.customer_id = i.customer_id
LEFT JOIN invoice_lines AS d
    ON d.invoice_id = i.invoice_id
WHERE i.status = 'ISSUED'
  AND i.invoice_date >= '2025-06-01'
  AND i.invoice_date < '2025-07-01'
GROUP BY c.customer_id, c.customer_name
ORDER BY sales_amount DESC, c.customer_id ASC;
```

为方便核对，这次列出本期所有有有效发票的客户：

| 客户 | 销售额（元） | 商品明细行数 |
| --- | ---: | ---: |
| 华东食品 | 11000.00 | 4 |
| 青禾便利 | 6600.00 | 4 |
| 远山商贸 | 4200.00 | 1 |
| 南桥百货 | 1800.00 | 2 |

远山商贸的金额没变，其他客户却被多算了。

## 一张发票为什么被加了两次

华东食品的 `1002` 号发票共 3000 元，有两条明细。关联结果如下，金额已换算成元：

| 发票 ID | 明细 ID | 发票总金额（元） | 明细金额（元） |
| --- | --- | ---: | ---: |
| 1002 | 21 | 3000.00 | 1200.00 |
| 1002 | 22 | 3000.00 | 1800.00 |

一张发票关联成两行，两行都带着 3000 元的发票总金额，`SUM(i.total_amount_cents)` 就把它加成了 6000 元。华东食品的两张发票各有两条明细，因此 5500 元变成了 11000 元。

青禾便利的一张 1800 元发票有三条明细，另一张 1200 元发票有一条，错误结果是 `1800 × 3 + 1200 = 6600` 元。

这就是**粒度**问题：一行代表一张发票，还是一条商品明细。数据库按关联后的行求和，查询编写者需要保证发票金额每张只计一次。手写 SQL 同样可能犯这个错误。

## 先把明细汇总回发票这一层

先按发票统计明细条数，让中间结果每张发票只剩一行，再关联回来。将原来的明细 JOIN 替换为：

```sql
LEFT JOIN (
    SELECT invoice_id, COUNT(*) AS detail_line_count
    FROM invoice_lines
    GROUP BY invoice_id
) AS d
    ON d.invoice_id = i.invoice_id
```

SELECT 中原来的 `COUNT(d.line_id)` 也要改为累加已汇总的明细条数：

```sql
SUM(COALESCE(d.detail_line_count, 0)) AS detail_line_count
```

日期、状态和金额求和保持原样，完整 SQL 见[修正查询](../resources/chapters/examples/sales-mini/chapter-02-correct-join.sql)。每张发票只关联一行统计结果；没有明细时，`LEFT JOIN` 保留发票，`COALESCE` 将条数按零处理。

结果恢复为：

| 客户 | 销售额（元） | 商品明细行数 |
| --- | ---: | ---: |
| 华东食品 | 5500.00 | 4 |
| 远山商贸 | 4200.00 | 1 |
| 青禾便利 | 3000.00 | 4 |
| 南桥百货 | 900.00 | 2 |

本样例直接累计 `line_amount_cents` 也能对上，因为明细合计与发票总金额一致。这里保留发票口径，同时增加明细条数；实际项目中需先确认两种金额是否一致。

## 按金额去重还会遇到另一种错误

还有一种看似省事的改法：

```sql
SUM(DISTINCT i.total_amount_cents)
```

当前样例恰好能算对，但 `DISTINCT` 去重的是金额值。

给华东食品增加一张 ID 为 `1099`、金额为 3000 元的有效发票后，三张发票应合计 `3000 + 2500 + 3000 = 8500` 元。金额去重后却只剩 5500 元。

要判断是否同一张发票，应看发票 ID；两张发票金额相同，也需要分别累计。

## 粒度正确以后还要核对哪些口径

避开重复累计，还不等于选对了业务口径：用户问订单金额，却查了发票金额；同样叫“销售额”，也可能有含税、未税之分。

日期也会漏数：若结束条件写成 `invoice_date <= '2025-06-30'`，月底 `23:59:59` 的发票就会被漏掉，青禾便利只剩 1800 元。

空结果也不能直接解释为“没有销售”，还需检查筛选、数据覆盖范围和刷新时点。

## 熟悉 SQL 不等于熟悉你的数据库

教学库的 `total_amount_cents` 把单位写在了字段名里，真实业务库未必这么清楚。假设 `order_amount` 存的是分，`service_fee` 存的是元，同样保存 1000，分别表示 10 元和 1000 元。查询两个字段时，不能统一除以 100。

即使提示词逐项说明，LLM 仍可能漏掉分转元，或把已经是元的字段再除一次。说明能帮助选对字段，却不能代替实际执行换算的计算规则。

旧字段也是陷阱：两个金额字段名字相似，其中一个却早已停止更新。选错后 SQL 照样能执行，数值还可能看着挺像那么回事。字段名、注释和样例值都要结合业务核实。

直接基于物理表生成 SQL，AI 通常需要相关结构和字段说明；工具若开放 Schema 浏览或展示 SQL，使用者也会接触这些物理细节。

说点不那么规范的：有时候写代码写烦了，我也会给字段起个 `xxx_***` 之类的名字，或者加点不方便在客户面前念出来的前缀。这些名字留在库里还好，出现在问数界面上，可能就得先解释一下当时的精神状态了。大家还是多遵守命名规范，出来混都是要还的，指不定哪天就社死了。

准备查询数据时，应只提供当前主题所需的表和字段，排除过时字段；把常用换算、计算和过滤写成可复用的 SQL 表达式或视图，再用简短说明补充业务歧义。表结构或口径变化后，这些定义也要维护。[Databricks 的实践建议](https://docs.databricks.com/aws/en/genie-agents/best-practices)同样优先采用结构化定义和查询示例，避免把所有规则堆进文字说明。

## 复杂 SQL 的复核和权限处理成本

前面只加了一次关联，就需要展开记录才能看清错误。再增加多层子查询、CTE、UNION 和计算字段，复核就要逐层追查字段来源、过滤位置和聚合粒度。

权限条件也有位置之分：把右表的权限条件直接放进 WHERE，可能让 LEFT JOIN 丢掉本应保留的无匹配记录；先计算全公司的前三名再过滤，也不等于本部门的前三名。

Text-to-SQL 可以通过数据库行级策略、受限视图或查询服务落实权限。数据库策略需要身份映射与配置；查询服务自行解析、改写 SQL，则要处理表、别名、子查询和分支。复杂 SQL 增加的是这类改写与验证成本，不意味着数据库原生权限也必须逐条改写查询。[数据库行级策略示例](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)、[查询引擎权限示例](https://trino.io/docs/current/security/opa-access-control.html)。

权限必须来自可信身份和授权配置，不能根据用户在对话里自报的部门判断，也不能只要求 AI 记得加条件。

跨表探索可能需要较宽的只读范围，但仍须按工作需要授权。只读限制写入，不等于限制能读取哪些人的数据。

## 结果仍需要独立复核

本例可以逐笔核对发票金额，按发票 ID 检查是否重复累计；实际项目也要对照原始记录或已确认的报表，核对口径、粒度和结果。AI 可以辅助审查，但让它再解释一遍自己生成的 SQL，还不能算完成了复核。

## 运行本章样例

按[样例说明中的第 2 章](../resources/chapters/examples/sales-mini/README.md)，将所需的六个文件放在同一目录，运行：

```powershell
python -X utf8 verify-chapter-02.py
```

脚本在独立内存库中核对基础样例和边界情况，结果写入 `chapter-02-results.json`，不修改第一章原始数据。

## 本章小结

讲到这里，Text-to-SQL 的实现与限制应该更清楚了：LLM 熟悉 SQL，不代表它熟悉你的数据库。字段含义、业务口径和权限范围，需要有人确认、由系统落实，并用真实数据核对。

下一章介绍怎样把可复用的业务定义组织为语义模型，让其中已经确认的规则由引擎重复执行。


---

<a id="chapter-3"></a>

# 第 3 章 语义层的作用与代价

前两章为了查出六月客户销售额，已经确认了不少事情：金额来自发票，按开票日期筛选，只统计有效发票，分转元，每张发票只累计一次。

换成七月、按月份统计，这些规则大多仍然适用。我们希望建立的语义层，能让 AI 理解这些业务含义，正确调用查询，并依据实际结果如实回答用户。为此，系统需要提供可复用的定义、执行规则，以及 AI 能理解的查询接口和返回信息。

## 语义层是什么

面向分析的语义层，通常会将底层数据整理成**以事实为中心、由维度和指标组织起来的逻辑分析模型**，下文称“语义模型”。它沿用星型模型的核心思想，让 AI 面向业务概念查询，再由系统处理底层关联与计算。

先看一张典型的星型模型图：

![典型星型模型：中心是事实表，周围是维度表](../assets/microsoft-star-schema.png)

图片来源：[Microsoft Learn：理解星型模型](https://learn.microsoft.com/en-us/power-bi/guidance/star-schema)。

中间的 Fact table（事实表）记录业务发生了什么，例如一笔销售；周围的 Dimension table（维度表）提供观察角度，例如客户、商品、日期和地区。

这里的“向星型结构对齐”，发生在逻辑分析层。底层可以仍是现有业务表，日期也可以直接来自发票字段，无须另建日期表。语义模型负责整理来源与关联，并定义有效状态、分转元和汇总规则，为 AI 提供含义明确的销售分析入口。

整个语义层还可以包含销售、回款等多个分析主题，形成多颗相互关联的“星”；跨主题查询仍需协调各自粒度。

### 语义模型的基本构成

沿用客户和发票，几个常见概念可以这样理解：

| 要素 | 回答什么问题 | 本例 |
| --- | --- | --- |
| 业务对象 | 分析围绕什么业务事物 | 发票、客户 |
| 粒度 | 一条事实代表什么 | 一张发票，而非一条商品明细 |
| 主键 | 怎样唯一识别对象 | `invoice_id` 识别发票，`customer_id` 识别客户 |
| 维度 | 从什么角度筛选、分组 | 客户、开票日期 |
| 属性 | 怎样描述业务对象 | 客户名称；同名客户仍靠 ID 区分 |
| 指标 | 要计算什么、怎样汇总 | 有效开票销售额 |
| 关联 | 对象怎样连接、是一对几 | 多张发票对应一位客户 |

属性也可以公开为筛选或分组字段，具体用途由语义模型定义。指标则需要明确计算与聚合规则，不能只列一个字段名。

“六月哪些客户销售额最高”，就是按日期筛选、按客户汇总销售额。

## 先把销售额定义清楚

沿用小销售库，为本例的“有效开票销售额”写一张定义卡：

| 项目 | 本例定义 |
| --- | --- |
| 金额来源 | `invoices.total_amount_cents`，存储单位为分 |
| 统计粒度 | 一张发票，以 `invoice_id` 识别；每张只累计一次 |
| 有效条件 | `status = 'ISSUED'` |
| 日期口径 | 使用 `invoice_date`，按统一时区解释 |
| 汇总与单位 | 累加有效发票金额，再除以 `100.0`，得到元 |
| 客户关系 | 通过 `customer_id` 关联客户，一位客户可以有多张发票 |
| 可见范围 | 按系统提供的当前用户身份和权限限制数据 |

“查六月”“按客户分组”“取前三名”属于本次查询条件。查七月时，指标定义不变，日期范围变了。

含税、未税、退款等业务差异也要明确，必要时分别命名指标，不能指望所有人说的“销售额”天然是同一个数。

## 从定义卡到可执行的语义模型

上面的卡片供人确认口径；要参与执行，还要转换为引擎认识的语义模型。下面是结构化定义示意，不是某个产品可直接加载的配置：

```yaml
name: SalesInvoiceQueryModel
caption: 有效开票销售分析
object: invoice
source: invoices
primaryKey: invoice_id
fixedFilter: "status = 'ISSUED'"
dimensions:
  invoiceDate:
    column: invoice_date
    type: datetime
  customer:
    source: customers
    key: customer_id
    caption: customer_name
    join: "invoices.customer_id = customers.customer_id"
    relationship: many_to_one
metrics:
  salesAmount:
    expression: "invoices.total_amount_cents / 100.0"
    aggregation: sum
    type: decimal
    unit: 元
```

这里包含来源、主键、类型、过滤条件、关联关系和聚合表达式。加载时，系统将它们解析为语义模型对象，检查引用和表达式；查询时按这些定义组织计算。

写“单位为元”只是说明；`expression` 定义了单张发票的分转元，`aggregation: sum` 定义了汇总方式。系统依据这些规则生成计算，AI 不必每次重新决定这个字段是否除以 100。

### 建模人员：准备并验证语义模型

建模人员确认口径、编写语义模型，再用已知结果验证。这些工作可以由 AI 辅助；语法检查通过，仍不代表业务口径正确。

![查询与建模流程](../assets/diagrams/ch03-02.png)

## LLM 看到的是导出的查询元数据

语义模型加载后，系统按当前用户可见范围导出查询元数据，组织成 Markdown、JSON 或工具返回的描述，交给 LLM 使用。这里交付的是查询说明，而非完整的语义模型源文件；定义变更后，导出元数据也要同步更新。

导出的内容重点是：语义模型适合回答什么问题，字段怎样引用，单位和业务含义是什么，哪些字段能筛选、分组或聚合。物理表名、JOIN 表达式和内部权限配置，不必全部交给 LLM。

本例将客户关联的键和名称分别公开为 `customerId`、`customerName`；查询字段名可以与底层列名不同。

下面是该语义模型导出的 Markdown 简化示例。正式元数据还应包含字段的数据类型（如日期、数值、文本）等信息；示例字段名仅作说明，具体接口以实际公开名称为准：

```markdown
# 有效开票销售分析
语义模型标识：SalesInvoiceQueryModel
语义模型名称：有效开票销售分析
适用：按开票日期分析有效发票销售额。

可查询字段：
- customerId：客户 ID，可分组；用它区分同名客户。
- customerName：客户名称，展示时与客户 ID 一起使用。
- invoiceDate：开票日期，支持日期范围筛选。
- salesAmount：单张发票金额，单位元，可求和。
```

LLM 据此把“六月销售额最高的客户”对应到日期、客户分组和金额求和，并用 `SalesInvoiceQueryModel` 指定查询目标。[语义模型元数据怎样用于查询生成](https://docs.cloud.google.com/looker/docs/conversational-analytics-overview)。

## 一次查询怎样使用语义模型

本次查询只需表达：按客户统计六月的 `salesAmount`，金额降序，客户 ID 升序，取前三名。这里先用文字表示意图，下一章再写具体请求。

建模不必随每次提问重复。用户提问后，LLM 结合查询元数据与协议组织请求；问题有歧义时先澄清。下图展示问题已明确的查询过程：

![查询与建模流程](../assets/diagrams/ch03-03.png)

其中，语义层的查询处理包括：

| 环节 | 本例怎样处理 |
| --- | --- |
| 字段绑定 | 将请求中的 `customerId`、`salesAmount` 绑定到语义字段及其物理来源 |
| 计算展开 | 按求和规则展开为 `SUM(invoices.total_amount_cents / 100.0)` |
| 过滤与关联 | 加入语义模型的 `ISSUED` 条件，按声明路径关联客户，并应用六月范围 |
| 聚合规划 | 按客户 ID、名称汇总，确保本次取数不展开为多条明细 |
| 校验与编译 | 拒绝不可用字段或不支持的组合，落实权限，生成目标数据库的查询 |

这些规则无须 LLM 在每次请求中重写：它没有填写有效状态条件，固定过滤仍应生效。报表或应用提交同样的语义请求，也会使用这些规则。

权限同样需要执行链路：可信身份提供用户范围，引擎或数据库施加约束。仅在描述里写“只能查本部门”，不会自动形成授权策略。

有些集成只用语义模型描述辅助 AI 生成物理 SQL。它能改善理解，却没有获得上述查询入口对业务规则的执行约束，两种用法需要分清。

## 指标也不只是字段别名

销售额可以求和，其他指标却未必。假设再定义“平均每张有效发票金额”：

```text
平均每张有效发票金额 = 有效开票销售额 ÷ 有效发票张数
```

按客户查询时，分子和分母都在各客户范围内计算；看六月总计时，要用全期总金额除以总张数。不能把各客户的平均值直接相加，也不能不看张数就取平均。

因此，语义模型不仅要说明字段叫什么，还要表达指标依赖、聚合方式和适用维度；执行器要在当前分组、筛选范围和计算阶段展开这些定义。指标不是预先算好的一个数，也不是给某列换个中文名。[指标在查询上下文中求值的解释](https://cube.dev/blog/how-semantic-sql-works)。

### 字段公开了，不等于能任意组合

发票金额可以按客户汇总；扩展到商品时，一张发票却可能包含多种商品，不能把整张发票金额算给每一种商品。此时要明确按商品分摊的规则，或改用商品明细金额。

语义模型需要声明关联的基数（一对一、多对一或一对多）和指标适用的维度。引擎在支持范围内规划关联与汇总，无法可靠处理的组合应明确拒绝。只声明“发票关联明细”，不会自动解决重复累计。[维度与指标粒度的约束示例](https://docs.snowflake.com/en/user-guide/views-semantic/querying)。

## 查对了，还要答得如实

本例查出的六月前三名客户销售额分别是 5500、4200、3000 元，合计 12700 元。AI 可以说“前三名客户合计 12700 元”，却不能说“六月总销售额为 12700 元”：查询只取了前三名。全体客户的六月合计是 13600 元，需要另一项汇总才能得到。

因此，查询工具返回数值时，还应保留指标、单位、筛选范围、是否截断和执行状态，供 AI 核对和说明。没有记录、金额为零和查询失败要分别处理；问题有歧义时先澄清，只有销售数据时，也不能凭空编出业绩变化的原因。语义层能提供依据与约束，最终回答仍需检查。

## 回看第二章：哪些工作交给了语义层

第二章已经用 SQL 修正了重复累计；视图、公共表达式和数据库策略也能复用规则。下面对比直接面向物理表生成 SQL 与通过语义层查询，看看工作怎样分配：

| 第二章的问题 | 直接生成物理 SQL | 通过语义层查询 |
| --- | --- | --- |
| 分、元混用，口径不一致 | 在 SQL 或视图中落实换算与过滤 | 在语义模型中集中定义，由引擎展开 |
| 关联后重复累计 | 按粒度组织 JOIN 与汇总，核对结果 | 声明粒度与关联，由引擎在支持范围内规划；无法可靠处理时拒绝 |
| 旧字段与内部命名 | 整理 Schema，或用视图提供业务字段 | 公开确认过的语义字段，物理映射留在后台 |
| 权限处理复杂 | 使用数据库策略、受限视图，或解析改写 SQL | 对语义请求统一校验，结合执行引擎或数据库策略落实权限 |
| 查询复核困难 | 追查 SQL 中的字段来源、过滤与聚合 | 先核对语义定义与查询意图，再追溯实际 SQL |

上一章那些不方便念出来的字段名，也终于有机会留在幕后。

可以看到，语义层承担了大部分确定性工作，LLM 不必直接处理复杂的物理表结构、关联与计算细节，减少了犯错的概率。

## 它又带来了哪些问题

规则有了统一的执行位置，LLM 还需要掌握查询接口。沿“自然语言 → DSL → SQL”这条路线，主要新增的门槛在 DSL（领域专用语言）的使用上：**LLM 即使熟悉 SQL，也不能默认它熟悉某个系统自定义的 DSL。**

查询元数据告诉它有哪些语义模型和指标，却不一定告诉它该怎样拼出合法请求。接入时需要提供两类上下文：

| 上下文 | 回答什么问题 |
| --- | --- |
| 查询元数据 | 查哪个语义模型、字段是什么意思、单位与可用分析范围是什么 |
| 查询协议 | 语义模型标识放在哪里，怎样选择字段、写过滤、分组、聚合、排序与分页 |

即使请求是合法 JSON，字段、操作符或计算阶段写错，仍然不能正确查询。第四章会用具体请求说明。

因此，要通过提示词、工具 Schema 和完整示例，让 LLM 知道这个接口的用法，包括支持什么、出错后怎样修正。复杂分析增加新操作后，协议说明与验证题库也要跟上；更换 LLM 时，也要重新验证适配效果。

放在一起看，直接查询物理表时，LLM 可以使用熟悉的 SQL，却要直面业务口径与表关系的复杂度；改用语义 DSL，业务含义清晰了，又需要教会 LLM 怎样使用查询协议。

语义层也可以提供 SQL 入口，让 LLM 面向虚拟业务表查询；本系列先用 DSL 学完整的问数流程，语义 SQL 留到高级篇“语义 SQL 与虚拟业务表”专题展开。

SQL 的表达能力很强，但部分复杂的分阶段或多维分析，用合适的 DSL 可以表达得更简洁。

我个人认为，随着 LLM 理解和推理能力增强，自定义 DSL 的适配门槛有望逐步降低。当 LLM 能够准确理解协议、拆解分析步骤时，就更能发挥 DSL 在复杂分析中表达简洁、边界清晰的优势。这些差异留到高级篇展开。

## 没有数仓，也可以先建语义层

ETL 负责抽取、转换和加载，例如统一日期格式、处理重复记录、汇集不同来源的数据；语义层负责业务含义和查询规则。源数据漏了发票，语义模型再完整也算不回来。

中小企业可以先对现有业务表或视图建模，遇到数据质量、跨来源对齐问题，再补充必要的数据准备。多来源处理放到高级篇展开。

## 一个小练习：客户数量怎么算

为“六月客户数量”补全三项定义：统计什么客户、按什么键去重、使用哪个日期。

在原始样例的全部数据范围内，“客户档案总数”是 5；“六月有有效发票的客户数”是 4。后者需要按开票日期和有效状态筛选，再按 `customer_id` 去重。即使一位客户有多张发票，也只能算一位。

两个数都可能算对，回答的是不同问题。语义层要把这种区别命名、定义清楚。

## 本章小结

语义模型定义业务含义和执行规则，元数据与查询协议帮助 LLM 表达意图，引擎负责校验与编译，数据库执行计算。AI 再依据结果及其范围回答；查询成功，仍需检查答案是否如实。

采用自定义 DSL 时，还要解决 LLM 对查询协议的适配。下一章用同一个销售问题，写出具体请求，逐项对照生成的 SQL。

延伸阅读：[指标定义与分组维度](https://docs.databricks.com/aws/en/uc-semantics/metric-views)、[业务对象与语义映射](https://docs.snowflake.com/en/user-guide/views-semantic/overview)。


---

<a id="chapter-4"></a>

# 第 4 章 自然语言到 DSL 再到 SQL

仍然查询“2025 年六月销售额最高的三位客户”。上一章已经定义了语义模型；现在看 LLM 怎样表达本次查询，以及引擎怎样把它转换为物理 SQL。

这条路线是：自然语言 → 语义 DSL → 物理 SQL → 数据库执行。

## 先选择语义模型，再读取字段

沿用上一章的 `SalesInvoiceQueryModel`：以发票为粒度，只统计有效发票，金额统一为元，并公开以下字段。

| 语义字段 | 含义及本例用法 |
| --- | --- |
| `customerId` | 客户 ID，分组时区分同名客户 |
| `customerName` | 客户名称，用于展示 |
| `invoiceDate` | 开票日期，用于筛选 |
| `salesAmount` | 单张有效发票金额，单位元，可以求和 |

LLM 先从当前用户可见的语义模型中选择，再读取查询元数据，确认字段、类型、单位与可用操作。

本次问题可以拆成：筛选六月、按客户分组、累计销售额、按金额降序取三位。金额并列时按客户 ID 升序，保证结果顺序稳定。

### 把业务用语对应到字段和值

字段选对了，筛选值也要有依据：

- **业务别名。** 用户说“销售额”，应对应已确认的指标；同时存在开票额、回款额时，先按上下文判断，无法确定就澄清。
- **枚举与维度值。** 扩展到地区等维度时，“华东”可能对应一个编码。应从已定义的值映射或工具返回的当前用户可见维度成员中确认，不能让 LLM 凭名称猜编码。
- **相对日期。** “上个月”需要结合当前日期、业务时区和统计日期，转换为明确区间。本例已指定 2025 年六月，无须猜测年份。

查询元数据提供业务含义与已定义的映射；具体维度值可以按需查询，不必一次全部塞给 LLM。

## 用 DSL 表达本次查询

元数据告诉 LLM“能查什么”，查询协议告诉它“怎样写请求”。为说明二者怎样配合，这里约定一套简化 DSL，用 JSON 承载查询意图，不作为某个产品的可执行接口：

```json
{
  "model": "SalesInvoiceQueryModel",
  "columns": [
    "customerId",
    "customerName",
    "SUM(salesAmount) AS totalSales"
  ],
  "slice": [
    {
      "field": "invoiceDate",
      "op": "[)",
      "value": ["2025-06-01", "2025-07-01"]
    }
  ],
  "groupBy": ["customerId", "customerName"],
  "orderBy": ["-totalSales", "customerId"],
  "limit": 3
}
```

即使之前没用过这套协议，看到字段、分组、排序和条数，大概也能读出它想查什么。DSL 不必从零发明一套陌生语法，可以借助 JSON、YAML 等熟悉的格式表达领域含义：上一章用 YAML 定义语义模型，这里用 JSON 描述查询。需要学习的是具体的键名、操作符和执行含义。

在这套约定里，`model` 指定语义模型；`columns` 选择返回字段，支持求和及别名；`slice` 筛选聚合前的记录；`groupBy` 指定分组；`orderBy` 中的负号表示降序；`limit` 限制返回条数。

这里的 `SUM` 适用于可加总的单张发票金额。平均金额、比例等指标，应遵循语义模型公开的计算方式，不能一律求和。

`[)` 表示左闭右开区间，包含六月末尾，不包含七月一日。LLM 需要通过协议说明、工具 Schema 和示例获知这些写法。

## 筛选发生在聚合前还是聚合后

同样是“超过三千元”，筛选对象不同，结果含义也不同：

| 用户问题 | 处理含义 |
| --- | --- |
| 只统计单张金额超过三千元的有效发票 | 先筛选发票，再汇总金额 |
| 找累计有效开票销售额超过三千元的客户 | 先按客户汇总，再筛选客户的汇总金额 |

在 SQL 中，两者通常分别用 `WHERE` 和 `HAVING` 表达。本章的简化 DSL 只示范了 `slice` 聚合前筛选；聚合后筛选需要查询协议另行支持，不能把汇总条件直接塞进 `slice`。

语义模型的有效状态和权限约束仍要生效；请求按需表达行筛选、分组、汇总后筛选、排序和条数。LLM 要分清这些阶段，引擎也要校验相应操作是否支持。

## 引擎怎样落实查询

引擎先检查语义模型和字段是否可用、类型、字段组合与聚合是否合法，再根据可信身份落实权限。随后展开金额公式、加入固定过滤、组织客户关联，编译为目标数据库的 SQL。

下面用 SQLite 写出本例的等价物理 SQL，帮助读者核对规则落在了哪里。它是教学示意，不是某个引擎的真实编译输出。

```sql
SELECT
    c.customer_id,
    c.customer_name,
    SUM(i.total_amount_cents / 100.0) AS total_sales
FROM invoices AS i
JOIN customers AS c ON c.customer_id = i.customer_id
WHERE i.status = 'ISSUED'
  AND i.invoice_date >= '2025-06-01'
  AND i.invoice_date < '2025-07-01'
GROUP BY c.customer_id, c.customer_name
ORDER BY total_sales DESC, c.customer_id ASC
LIMIT 3;
```

其中，客户 JOIN、`ISSUED` 条件和 `/ 100.0` 来自语义模型；六月范围、客户分组、排序与前三名来自本次请求。

在小销售库中执行这段 SQL，结果仍为：

| 客户 | 销售额（元） |
| --- | ---: |
| 华东食品 | 5500.00 |
| 远山商贸 | 4200.00 |
| 青禾便利 | 3000.00 |

本例使用全部样例数据，实际结果还取决于当前用户的数据范围。LLM 回答时，应保留“2025 年六月”“有效开票销售额”“前三名”等信息。

## 字段写错时怎样修正

如果 LLM 把 `customerName` 写成 `clientName`，当前语义模型没有这个字段，请求应在校验阶段被拒绝。

LLM 根据诊断重新读取查询元数据，确认是拼写错误、字段未公开，还是选错了语义模型，再修正请求。不能仅凭名字相近就替换，也不能为绕过权限退回物理表查询。

诊断可以帮助修正无效字段，却未必识别业务选择错误。比如用户问回款，LLM 却选择了有效开票销售额，字段和语法都可能合法，仍需核对查询意图。

## 本章小结

LLM 理解问题、选择语义模型并表达本次查询；引擎依据语义定义校验与编译，数据库完成计算，LLM 再按实际结果回答。业务规则由系统落实，查询意图和回答仍需要核对。

下一章把直接 Text-to-SQL 与语义层查询放在一起，讨论不同场景怎样选择，以及怎样组织完整的问数系统。

[本章样例与核对方法](../resources/chapters/examples/sales-mini/README.md)提供请求示意、等价 SQL 与核对范围。


---

<a id="chapter-5"></a>

# 第 5 章 怎样选择和组织问数方案

前四章介绍了两条路线：直接生成物理 SQL，或通过语义 DSL 查询。本章先根据使用者和问题选择起点，再看怎样组合工具、语义能力和多步分析。

## 什么场景适合直接 SQL，什么场景适合语义层

如果服务的是日常与 SQL 打交道的技术人员或数据分析师，他们熟悉业务库，能够审查生成的 SQL 和结果，直接 Text-to-SQL 是合适的起点，也便于探索新问题。

如果目标用户不懂 SQL，甚至不了解数据库概念，更适合先通过语义层整理业务口径与可查询范围，让他们围绕业务概念问数。即使使用者会 SQL，团队反复使用统一指标、需要多人稳定问数时，也值得采用语义层，减少重复定义与维护。

这有点像用户要查数据时，你是给他一个 SQL 客户端，让他自己组织查询，还是提供口径已经确认的报表，让他直接看业务结果。语义层还可以在已定义的范围内支持灵活提问和追问，而不局限于固定报表。

两种方式可以并存：技术人员先探索新问题，再把确认过的常用定义沉淀到语义层。

## 业务规则可以放在哪里

决定采用语义层后，先看已有工具和数据环境能提供哪些能力：

| 组合方式 | 业务定义与执行在哪里 | LLM 怎样参与 |
| --- | --- | --- |
| 复用已有分析系统 | 使用已经确认的维度、指标、关联和查询接口 | 读取元数据，选择字段与条件，调用已有查询能力 |
| 接入独立语义查询服务 | 由服务加载语义模型，统一校验与执行 | 调用语义查询工具，与报表等入口复用定义 |
| 利用数据平台内的语义能力 | 在数据平台中维护指标及其访问规则 | 围绕平台公开的业务查询面组织请求 |

这些方式可以组合，也可以分步建设。已有合适的语义模型与接口，就先核对能否复用；若要提供语义查询，而手头只有原始业务表，就需要补上业务定义和查询能力。不能因为产品名字里有“AI”或“语义”，就省掉实际核对。

## 工具接通，还要看它执行什么

CLI、API 或工具协议解决的是怎样调用能力。一个工具可能只返回表结构，也可能执行物理 SQL，或接受上一章的语义查询。这些接口都能被 LLM 调用，承担的工作却不同。

接入时至少看清三件事：

- LLM 收到的是原始表结构，还是语义模型的查询元数据。
- 查询是否经过落实口径、单位、关联和权限的执行入口。
- 返回的信息是否足以说明数据范围、结果来源和执行状态。

## 多步分析由谁组织

“六月客户前三名”可能一次查询就够；“销售变化主要来自哪些客户”，往往需要先比较两个月的汇总，再按客户拆解变化，核对客户差额是否与总差额一致。

负责多步任务的 AI 助手，通常称为 Agent，在这里安排步骤、调用工具并检查中间结果。它可以使用同一个语义服务，也可以组合已有分析工具。语义层则负责每次查询所用的业务定义与执行规则，两者可以配合。

## 用一个问题检查整条链路

仍用“2025 年六月销售额最高的三位客户”检查：LLM 提交了什么查询，系统在哪里落实有效状态和分转元，结果适用于哪个用户范围，最后的回答是否保留了这些含义。

再追问“为什么第一名更高”，看系统会不会检查可用数据、补充查询或说明证据不足。界面能回答第一句话，不等于已经具备持续分析的能力。

## 本章小结

选择路线时，要同时看使用者审查 SQL 与结果的能力、指标复用和交付要求。采用语义层后，可以复用已有分析系统，也可以接入独立服务；Agent 可以组织多步分析，查询仍经过相应执行入口。

技术篇到这里结束。应用篇从确定首批问题和验收范围开始，动手完成一个小型问数项目。


---

<a id="chapter-6"></a>

# 第 6 章 认识 Foggy，准备第一个问数项目

前五章讲清了语义层和问数系统的分工。从本章开始，使用 Foggy 把这条链路跑起来：先认识引擎，再选定工具、接入样例数据。

本章先完成环境与数据接入，暂不建模。

## 先认识 Foggy

[Foggy Data MCP Bridge](https://github.com/foggy-projects/foggy-data-mcp-bridge) 是面向 AI 数据分析的语义层引擎。开发者用语义模型定义业务字段、指标和关联，AI 根据公开的查询元数据表达问题；引擎依据定义校验、组织查询并生成物理 SQL，再交数据库执行。

它可以作为独立服务连接已有业务库，也提供 CLI、API 和 MCP 等调用入口。本章下载发行包在本机启动服务，先接一份 SQLite 数据，不需要建设数仓或编译源码。

先对照一下前文概念与 Foggy 中的名称：

| 名称 | 在本项目中的作用 |
| --- | --- |
| TM（表模型） | 定义数据来源、物理字段映射、关联与指标 |
| QM（查询模型） | 基于 TM 组织查询面，选择公开字段并配置查询规则 |
| DSL | 表达本次要查的字段、条件、分组和排序 |
| Foggy 服务（Runtime） | 加载语义模型并处理查询 |
| CLI | 让 AI 通过命令调用接入、探查、建模校验和查询等能力 |
| Skill | 告诉 AI 操作顺序、接口用法和出错时怎样处理 |

下载的发行包叫 **Launcher**，启动后运行的服务叫 **Runtime**，后文统一称为“Foggy 服务”。

本章由 AI 助手调用 CLI，Foggy 服务端无须另配 AI Provider。

## 用 Chinook 做一个小系统

Chinook 模拟一家数字媒体商店，包含客户、账单、曲目、专辑和播放列表等数据，共 11 张业务表。它足够小，查错时还能翻回原始记录，不至于刚开始学问数，就先被数据库劝退。

样例下载：[Chinook v1.4.5 SQLite 文件](https://github.com/lerocha/chinook-database/releases/download/v1.4.5/Chinook_Sqlite.sqlite)。后面的任务会让 Codex 下载并使用副本。整个项目的第一版范围如下：

| 项目 | 约定 |
| --- | --- |
| 使用者 | 开发者先验证，后续交业务用户在线问数 |
| 数据 | 先用客户、账单、账单明细、曲目和内容分类五张表 |
| 期间 | 固定为 2012 年；月间比较先选十月、十一月 |
| 首批问题 | 月度账单金额、客户排名、账单国家占比、内容分类销售、平均账单金额 |
| 暂不回答 | 缺少相应数据和定义的利润、回款、退款，以及对未来的确定性预测 |
| 项目验收 | 先核对结构化查询，再检查终端自然语言问答，最后检查浏览器问答 |

账单金额先看 `Invoice.Total`，明细金额先看 `UnitPrice × Quantity`，后续再核对两者是否一致。金额单位和状态重新确认，不套用技术篇的分转元或 `ISSUED` 条件。

## 把准备工作交给 Codex

这里用 Codex，主要因为我已有订阅，可以先用[订阅内的额度](https://learn.chatgpt.com/docs/pricing)，省一些额外调用 API 的 token 开销。也可以换成 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 之类的 Agent 工具：能读取操作说明、读写本地文件并执行 Foggy CLI，就可以按同样的任务推进。使用 ChatGPT 桌面端时，也要选择支持本机工具执行的任务。

下面以 Codex 为例，读者负责发出任务和确认结果，下载、安装与命令执行交给它。

### 读者准备什么

在 Codex 中打开一个独立练习目录，例如 `D:\ai-data`。Skill、数据库样例和 Python、Java 等依赖，都让 Codex 按下面的任务准备。

### 发给 Codex 什么

在练习目录中发送：

> 请从下面的入口获取 Foggy Skill，并按它的指引准备环境：
> https://raw.githubusercontent.com/foggy-projects/foggy-ai-analysis/main/stack/stable.json
>
> 再下载 [Chinook v1.4.5 SQLite 样例](https://github.com/lerocha/chinook-database/releases/download/v1.4.5/Chinook_Sqlite.sqlite)，保存在当前目录，用副本接入，确认连接成功并列出表。先不建模。

### Codex 会怎样处理

Codex 大致会按以下顺序处理：

1. **下载 `stable.json`。** 读取当前推荐的 Skill、CLI 和服务发行包版本，以及对应的下载入口，确定本次使用的组件组合。
2. **下载并读取 Skill。** 获取正式 ZIP，校验、解压后阅读 `SKILL.md` 和接入指引。后面的安装顺序、命令用法和验证方法，都从这里获得。
3. **准备 CLI 和服务资源。** 按 Skill 指引检查 Python、Java，安装 CLI 和 Skill，再下载 Foggy 服务的 JAR 与启动脚本等资源。已有组件先检查，合适的可以复用。
4. **启动并验证服务。** 选择空闲端口启动，等待服务就绪，再查询 `capabilities`，确认当前服务支持数据源接入和表发现。
5. **下载并接入 Chinook。** 从官方链接下载 SQLite 样例，保留原件、使用副本。添加数据源、测试连接，再将它绑定到项目的 Namespace（命名空间），让后续操作使用同一份数据。
6. **列出表并汇总结果。** 分别通过 Namespace 和指定数据源发现表，核对两份清单，并报告服务地址、文件位置和执行结果。失败或警告也应一并说明。

### 完成后核对什么

读者重点看下面几项。数据源名和 Namespace 可以由 Codex 创建，表中使用本次示例名称：

| 检查项 | 应看到的结果 |
| --- | --- |
| 服务 | 有明确地址，就绪检查成功，所需能力可用 |
| 数据源 | `connected: true`，连接指向 Chinook 练习副本 |
| 项目绑定 | `chinook → chinook-sqlite`，指向刚才测试的数据源 |
| 表清单 | 两种方式发现的表一致，共 11 张业务表 |

## 本次结果与小结

本次实测沿用已有 Skill，CLI 和服务发行包均为 **0.1.24**，服务地址为 `http://127.0.0.1:18067`，只在本机提供服务。连接和绑定通过，发现 11 张业务表：

```text
Album、Artist、Customer、Employee、Genre、Invoice、InvoiceLine、
MediaType、Playlist、PlaylistTrack、Track
```

重启本项目服务后，没有重新添加或绑定数据源，连接和表发现检查仍通过。[验收结果](../resources/lab/evidence/chapter-06/acceptance.json)与[重启记录](../resources/lab/evidence/chapter-06/restart-verification.json)已保存；安装命令、版本细节和复跑方法放在[配套环境说明](../resources/lab/README.md)中。

## 本章 Foggy 知识点

- **服务与 CLI：** 通过 Skill 准备发行资源，启动 Foggy 服务，用就绪检查和 `capabilities` 确认所需能力。
- **数据源与 Namespace：** 添加、测试 SQLite 数据源，将 Namespace 绑定到练习副本。
- **表发现：** 通过 Namespace 或指定数据源列出业务表，确认接入结果。

下一章检查五张核心表的关系、日期和金额口径，第八章再建立语义模型并完成首次问数。


---

<a id="chapter-7"></a>

# 第 7 章 让 AI 探查 Chinook 并确认业务口径

上一章已经接通 Chinook，发现了 11 张表。本章沿用同一环境，检查表关系、字段含义和统计方法，为下一章建模做准备。

## 把探查任务交给 Codex

在原来的练习目录中继续发送：

> 请用 Foggy CLI 看看 Chinook 的表和数据，整理表关系、日期范围和金额含义，核对账单与明细金额，保存结果供我确认，先不建模。

Codex 大致会这样处理：

1. **复查连接。** 确认服务可用，Namespace 仍指向上一章的 Chinook 副本。
2. **读取表结构。** 通过 `tables inspect` 查看字段、类型、主键和外键，确定一行数据代表什么。
3. **检查实际值。** 用 `sql query` 执行少量只读查询，查看日期、分类和金额，检查关联缺失与重复累计。
4. **核对并保存结果。** 逐张核对账单与明细金额，保存统计结果和需要人确认的字段含义。

这里的 SQL 用于探查和独立核对。结构与数值可以验证，字段对应什么业务含义仍需人确认，不能看到字段名就替业务下结论。

## 先看一行数据代表什么

先看与账单分析有关的五张表：

| 表 | 一行代表什么 | 记录数 |
| --- | --- | ---: |
| `Customer` | 一位客户 | 59 |
| `Invoice` | 一张账单 | 412 |
| `InvoiceLine` | 一条账单明细 | 2240 |
| `Track` | 一条曲目或媒体目录记录 | 3503 |
| `Genre` | 一种内容分类 | 25 |

CLI 读到的关联可以简化为：

![查询与建模流程](../assets/diagrams/ch07-04.png)

例如，`Invoice.CustomerId` 指向客户，`InvoiceLine.InvoiceId` 指向账单。本章检查了图中四条核心关联，没有发现空关联键或找不到目标记录的情况。

`Genre` 的实际值既有 Rock、Jazz，也有 TV Shows、Drama，因此建模时称为“内容分类”更准确。表名给了线索，取值才能说明它实际装了什么。

本次查到的账单日期覆盖 **2009-01-01 至 2013-12-22**，后续练习选用其中的 **2012 年**。虽然日期字段声明为 DATETIME，实际保存为文本，格式是 `YYYY-MM-DD HH:MM:SS`；本例按记录日期统计，不自行做时区转换。

## 金额要逐张对，不只看总额

从实际记录中取一张账单来看：编号 252，金额为 **1.98**，有两条明细：

| 明细 ID | 单价 | 数量 | 明细金额 |
| --- | ---: | ---: | ---: |
| 1367 | 0.99 | 1 | 0.99 |
| 1368 | 0.99 | 1 | 0.99 |

本例的账单金额取 `Invoice.Total`，明细金额取 `InvoiceLine.UnitPrice × Quantity`。两条明细合计 1.98，与账单金额一致。`Track` 中也有 `UnitPrice`，统计交易时使用账单明细上的单价。

本次把全部 **412 张账单逐张对账**，没有发现差额，全库合计为 **2328.60**。金额按样例原始单位使用，不除以 100；五张核心表没有币种字段，结果也不自行标成人民币或美元。

第二章讲过的重复累计，在这里也能复现：2012 年实际账单金额是 **477.53**，关联明细后直接累加账单金额，却变成 **4256.25**。按内容分类分析时，应从明细金额汇总。

## 确认字段含义与统计口径

建模前，先确认字段代表什么，以及金额、数量等怎么统计。下面是根据表结构和数据整理的说明，确认后再用于建模：

| 分析内容 | 字段含义与统计口径 |
| --- | --- |
| 统计日期 | 使用 `Invoice.InvoiceDate`；2012 年范围为大于等于 `2012-01-01`、小于 `2013-01-01` |
| 账单金额与张数 | 每张账单只累计一次 `Total`，按 `InvoiceId` 计账单数 |
| 平均账单金额 | 当前范围的账单金额合计 ÷ 账单数 |
| 明细金额与数量 | 逐条累加明细的 `UnitPrice × Quantity` 和 `Quantity` |
| 客户 | 用 `CustomerId` 区分，组合 `FirstName`、`LastName` 展示名称 |
| 账单国家 | 使用账单地址中的 `BillingCountry`，与客户地址中的 `Customer.Country` 区分 |
| 内容分类 | 通过 `InvoiceLine → Track → Genre` 关联，按 `Genre.Name` 分组 |

还有两处容易被 AI“偷懒”处理：样例中所有 `Quantity` 都是 1，数量仍应按求和定义；账单国家与客户国家目前全部相同，建模时也要保留两者的含义区别。数据里暂时相等，不代表以后可以随便互换。

## 保存核对结果，供建模后比较

本次分别统计账单与明细，保存了 2012 年十二个月的结果。这份核对结果就是后续比较用的“基线”。下面摘出几项，金额均为样例原始单位：

| 范围 | 账单数 | 明细数 | 账单金额 | 明细金额 |
| --- | ---: | ---: | ---: | ---: |
| 2012 年全年 | 83 | 447 | 477.53 | 477.53 |
| 2012 年十月 | 7 | 38 | 42.62 | 42.62 |
| 2012 年十一月 | 7 | 38 | 37.62 | 37.62 |

这次还从只读数据文件逐笔取数，用 Python `Decimal` 另算了一遍，结果一致。下一章查询语义模型时，就可以与这份基线比较，而不是拿 AI 的两次回答互相作证。

如果真实数据出现日期混杂、重复键或关联缺失，先做必要的数据整理，再建模；这也是 ETL 会介入的地方。本例没有新增清洗步骤，原件与副本均未修改。

[探查记录与复跑方法](../resources/lab/chapter-07-notes.md)、[完整基线](../resources/lab/evidence/chapter-07/baseline.json)和[验收结果](../resources/lab/evidence/chapter-07/acceptance.json)已保存。

## 本章 Foggy 知识点

- **表结构探查：** 用 `tables inspect` 查看字段、类型、主外键，梳理表关系和数据粒度。
- **建模前查数：** 用 `sql query` 做只读探查，检查实际取值、日期范围和关联后的金额；此时尚未创建语义层模型。
- **建模依据：** 将探查结果整理成待确认的字段含义、统计口径和核对基线，供后续建模与查询验证使用。

下一章据此编写 TM（表模型）和 QM（查询模型），完成第一次语义问数。


---

<a id="chapter-8"></a>

# 第 8 章 让 AI 生成语义模型并完成首次问数

上一章完成了数据探查和口径整理，还没有创建语义层模型。本章先创建账单分析的语义层模型，再查询客户排名；随后增加明细模型，与原始数据核对。

## 第一步：创建账单分析的语义层模型

在原来的练习目录和会话中继续发送：

> 请按上一章整理的口径创建账单分析的语义层模型，校验并加载，列出可查询的字段。

如果换了会话，把上一章保存的字段说明和核对结果一起交给 Codex。

Codex 需要先读取 `foggy-ai-analysis` Skill，编写两个文件：

| 文件 | 本次定义什么 |
| --- | --- |
| `ChinookInvoiceModel.tm` | 以 `Invoice` 为主表，关联客户，定义账单日期、金额和张数 |
| `ChinookInvoiceQueryModel.qm` | 公开本次分析要用的字段，并说明适用范围 |

TM、QM 使用类似 JavaScript 的配置语法。下面是本次验证通过的完整源码，可展开查看。

<details>
<summary>账单 TM：ChinookInvoiceModel.tm（完整源码）</summary>

[打开源文件](../resources/lab/models/model/ChinookInvoiceModel.tm)

```javascript
export const model = {
    name: 'ChinookInvoiceModel',
    caption: 'Chinook 账单',
    description: '一行是一张账单，不关联明细后累计账单金额。金额沿用样例原始单位，币种未指定。',
    tableName: 'Invoice',
    idColumn: 'InvoiceId',
    properties: [
        { column: 'InvoiceId', name: 'invoiceId', caption: '账单 ID', type: 'INTEGER' },
        { column: 'CustomerId', name: 'customerId', caption: '客户 ID', type: 'INTEGER',
          description: '客户识别键，排名时与姓名一起分组，避免同名客户合并。' },
        { column: 'InvoiceDate', name: 'invoiceDate', caption: '账单日期', type: 'DATETIME',
          description: '按账单记录日期统计，不自行做时区转换。' },
        { column: 'BillingCountry', name: 'billingCountry', caption: '账单国家', type: 'STRING',
          description: '账单地址中的国家，不是客户地址国家。' }
    ],
    dimensions: [
        {
            name: 'customer', caption: '客户', tableName: 'Customer',
            foreignKey: 'CustomerId', primaryKey: 'CustomerId',
            captionColumn: 'LastName',
            keyCaption: '客户 ID',
            description: '用客户 ID 区分客户，姓名仅用于展示。',
            properties: [
                { column: 'FirstName', name: 'fullName', caption: '客户姓名', type: 'STRING',
                  description: 'FirstName 与 LastName 拼接；展示时与客户 ID 一起使用。',
                  formulaDef: { builder: (alias) => `${alias}.FirstName || ' ' || ${alias}.LastName` } },
                { column: 'Country', name: 'country', caption: '客户国家', type: 'STRING' }
            ]
        }
    ],
    measures: [
        { column: 'Total', name: 'invoiceAmount', caption: '账单金额', type: 'MONEY', aggregation: 'sum',
          description: '每张账单只累计一次。样例原始单位，不除以 100，不指定币种。' },
        { column: 'InvoiceId', name: 'invoiceCount', caption: '账单数', type: 'INTEGER', aggregation: 'count',
          description: '按账单 ID 计数，只在账单分析中统计。' }
    ]
};
```

</details>

<details>
<summary>账单 QM：ChinookInvoiceQueryModel.qm（完整源码）</summary>

[打开本章版本](../resources/lab/evidence/chapter-08/ChinookInvoiceQueryModel.qm)

```javascript
const invoice = loadTableModel('ChinookInvoiceModel');

export const queryModel = {
    name: 'ChinookInvoiceQueryModel',
    caption: 'Chinook 账单分析',
    description: '按账单日期、客户和账单国家分析账单金额与张数。金额沿用样例原始单位，不指定币种、不做分元换算，不代表利润、回款或退款。分类分析请用明细分析，不按分类分摊账单总额。',
    loader: 'v2',
    model: invoice,
    columnGroups: [
        { caption: '账单与客户', items: [
            { ref: invoice.invoiceId },
            { ref: invoice.invoiceDate },
            { ref: invoice.billingCountry },
            { ref: invoice.customerId },
            { ref: invoice.customer$fullName },
            { ref: invoice.customer$country }
        ] },
        { caption: '账单指标', items: [
            { ref: invoice.invoiceAmount },
            { ref: invoice.invoiceCount }
        ] }
    ],
    accesses: []
};
```

</details>

这里将 `Total` 映射为 `invoiceAmount`，姓名由 `FirstName`、`LastName` 拼接。QM 中的 `loadTableModel` 引用已有 TM，`columnGroups` 决定哪些字段可以查询。

### 校验并加载

Codex 接着通过 CLI 完成三步：

1. **校验模型。** 用 `models validate` 检查 TM/QM，出错先修正。
2. **加载模型。** 将模型目录登记为 Bundle（模型资源包），再用 `models refresh` 加载。
3. **确认公开字段。** 用 `models describe` 实时读取查询说明，确认字段符合定义，供下一步查询使用。

### AI 查询时读到什么

**Markdown 查询说明由引擎根据当前已加载的 TM/QM 代码自动生成。AI 需要时通过 CLI 实时获取，读者无须提前生成或维护 Markdown 文件。** 修改 TM/QM 后，先校验、刷新，再读取更新后的说明。

下面是 `models describe ChinookInvoiceQueryModel --format markdown` 的实际返回节选，对应第三章所讲的查询元数据。保存这份返回内容只是为了方便对照阅读。

<details>
<summary>实时获取的账单 QM 查询说明（Markdown 节选）</summary>

[完整返回记录](../resources/lab/evidence/chapter-08/ChinookInvoiceQueryModel.md)

```markdown
# ChinookInvoiceQueryModel - Chinook 账单分析

## 模型信息
- 表名: Invoice
- 主键: InvoiceId
- 说明: 按账单日期、客户和账单国家分析账单金额与张数。金额沿用样例原始单位，不指定币种、不做分元换算，不代表利润、回款或退款。分类分析请用明细分析，不按分类分摊账单总额。

## 时间维度与字段 (Time Dimensions & Fields)
> **提示**: 在进行时间趋势分析、同环比或窗口函数(Window Functions)时，请优先使用 `business_date` 角色的字段。

| 字段名 | 名称 | 类别 | 时间角色 | 推荐用途 | 说明 |
|--------|------|------|----------|----------|------|
| invoiceDate | 账单日期 | 属性 | business_date | - | 按账单记录日期统计，不自行做时区转换。 |

## 属性字段
| 字段名 | 名称 | 类型 | 说明 |
|--------|------|------|------|
| invoiceId | 账单 ID | 文本 |  |
| billingCountry | 账单国家 | 文本 | 账单地址中的国家，不是客户地址国家。 |
| customerId | 客户 ID | 文本 | 客户识别键，排名时与姓名一起分组，避免同名客户合并。 |
| customer$fullName | 客户姓名 | 文本 | FirstName 与 LastName 拼接；展示时与客户 ID 一起使用。 |
| customer$country | 客户国家 | 文本 |  |

## 度量字段
| 字段名 | 名称 | 类型 | 聚合 | 说明 |
|--------|------|------|------|------|
| invoiceAmount | 账单金额 | 金额 | SUM | 每张账单只累计一次。样例原始单位，不除以 100，不指定币种。 |
| invoiceCount | 账单数 | 文本 | COUNT | 按账单 ID 计数，只在账单分析中统计。 |
```

</details>

查询时，AI 根据说明中的模型名称、字段含义和聚合方式编写 DSL。比如 `customer$fullName` 是公开的语义字段，数据库里没有新增这一列。

当前版本的 Markdown 将部分整数字段显示为“文本”，实际数据类型以结构化 `describe` 输出中的 `type` 为准。

## 第二步：查询客户排名

确认模型加载成功，公开字段符合上一章的定义后，再发送查询任务：

> 请查出 2012 年账单金额最高的五位客户，核对并保存结果。

Codex 使用 `foggy-semantic-query` Skill，通过 CLI 指定 `ChinookInvoiceQueryModel`，生成下面的 DSL 请求：

```json
{
  "columns": ["customerId", "customer$fullName", "sum(invoiceAmount) as totalAmount"],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "groupBy": ["customerId", "customer$fullName"],
  "orderBy": ["-totalAmount", "customerId"],
  "limit": 5
}
```

请求按客户汇总 2012 年金额，金额降序，同额时按客户 ID 升序，取前五位。表关联与姓名拼接由语义模型处理。

请求先经过 `query validate`，通过后再 `query execute`。本次实际返回：

| 客户 ID | 客户姓名 | 账单金额 |
| ---: | --- | ---: |
| 26 | Richard Cunningham | 25.84 |
| 34 | João Fernandes | 24.77 |
| 13 | Fernanda Ramos | 24.75 |
| 51 | Joakim Johansson | 24.75 |
| 55 | Mark Taylor | 22.77 |

用 Python `Decimal` 从原始记录独立累计后，姓名、金额和排序均一致。回答保留“2012 年”“账单金额”和样例原始单位，不改称利润或自行补上币种。

## 第三步：创建明细分析的语义层模型

确认账单结果后，继续发送：

> 请按上一章整理的口径创建明细分析的语义层模型，校验并加载，列出可查询的字段。

Codex 增加明细 TM/QM，沿用第一步的校验、加载流程。两个查询模型的基础记录不同：

| 查询模型 | 基础记录 | 金额来源 |
| --- | --- | --- |
| `ChinookInvoiceQueryModel` | 一张账单 | `Invoice.Total` |
| `ChinookInvoiceLineQueryModel` | 一条明细 | `InvoiceLine.UnitPrice × Quantity` |

明细 TM/QM 的完整源码如下：

<details>
<summary>明细 TM：ChinookInvoiceLineModel.tm（完整源码）</summary>

[打开本章源码快照](../resources/lab/evidence/chapter-08/ChinookInvoiceLineModel.tm)

```javascript
export const model = {
    name: 'ChinookInvoiceLineModel',
    caption: 'Chinook 账单明细',
    description: '一行是一条账单明细。分类分析累计交易明细金额，不累计账单总金额，不改用目录单价。',
    tableName: 'InvoiceLine',
    idColumn: 'InvoiceLineId',
    properties: [
        { column: 'InvoiceLineId', name: 'invoiceLineId', caption: '明细 ID', type: 'INTEGER' },
        { column: 'InvoiceId', name: 'invoiceId', caption: '账单 ID', type: 'INTEGER' },
        { column: 'TrackId', name: 'trackId', caption: '曲目或媒体 ID', type: 'INTEGER' },
        { column: 'UnitPrice', name: 'unitPrice', caption: '交易单价', type: 'MONEY',
          description: '账单明细上的交易单价，不是 Track 目录单价。样例原始单位，币种未指定。' }
    ],
    dimensions: [
        {
            name: 'invoice', caption: '账单', tableName: 'Invoice',
            foreignKey: 'InvoiceId', primaryKey: 'InvoiceId', captionColumn: 'InvoiceId',
            properties: [
                { column: 'InvoiceDate', name: 'invoiceDate', caption: '账单日期', type: 'DATETIME',
                  description: '按账单记录日期统计，不自行做时区转换。' },
                { column: 'BillingCountry', name: 'billingCountry', caption: '账单国家', type: 'STRING' },
                { column: 'CustomerId', name: 'customerId', caption: '客户 ID', type: 'INTEGER' }
            ],
            dimensions: [
                {
                    name: 'customer', caption: '客户', tableName: 'Customer',
                    foreignKey: 'CustomerId', primaryKey: 'CustomerId', captionColumn: 'LastName',
                    properties: [
                        { column: 'FirstName', name: 'fullName', caption: '客户姓名', type: 'STRING',
                          description: 'FirstName 与 LastName 拼接；与客户 ID 一起使用。',
                          formulaDef: { builder: (alias) => `${alias}.FirstName || ' ' || ${alias}.LastName` } }
                    ]
                }
            ]
        },
        {
            name: 'track', caption: '曲目或媒体', tableName: 'Track',
            foreignKey: 'TrackId', primaryKey: 'TrackId', captionColumn: 'Name',
            properties: [
                { column: 'Name', name: 'title', caption: '曲目或媒体名称', type: 'STRING' }
            ],
            dimensions: [
                {
                    name: 'genre', caption: '内容分类', tableName: 'Genre',
                    foreignKey: 'GenreId', primaryKey: 'GenreId', captionColumn: 'Name',
                    description: '包含音乐和影视内容；分类缺失不应丢弃交易金额。',
                    properties: [
                        { column: 'GenreId', name: 'genreId', caption: '分类 ID', type: 'INTEGER' },
                        { column: 'Name', name: 'genreName', caption: '内容分类名称', type: 'STRING' }
                    ]
                }
            ]
        }
    ],
    measures: [
        { column: 'UnitPrice', name: 'lineAmount', caption: '明细金额', type: 'MONEY', aggregation: 'sum',
          description: '交易单价乘数量，沿用样例原始单位，不除以 100，不指定币种。',
          formulaDef: { builder: (alias) => `${alias}.UnitPrice * ${alias}.Quantity` } },
        { column: 'Quantity', name: 'salesQuantity', caption: '销售数量', type: 'INTEGER', aggregation: 'sum',
          description: '累计 Quantity；本样例全部为 1，也不能将数量定义成明细条数。' }
    ]
};
```

</details>

<details>
<summary>明细 QM：ChinookInvoiceLineQueryModel.qm（完整源码）</summary>

[打开本章源码快照](../resources/lab/evidence/chapter-08/ChinookInvoiceLineQueryModel.qm)

```javascript
const line = loadTableModel('ChinookInvoiceLineModel');

export const queryModel = {
    name: 'ChinookInvoiceLineQueryModel',
    caption: 'Chinook 明细分析',
    description: '按账单日期、客户、曲目或媒体、内容分类分析明细金额和销售数量。每行是一条明细，不提供可累计的账单总金额。金额沿用样例原始单位，不指定币种、不做分元换算，不代表利润、回款或退款。',
    loader: 'v2',
    model: line,
    columnGroups: [
        { caption: '交易明细', items: [
            { ref: line.invoiceLineId },
            { ref: line.invoiceId },
            { ref: line.trackId },
            { ref: line.unitPrice },
            { ref: line.invoice$invoiceDate },
            { ref: line.invoice$billingCountry },
            { ref: line.invoice$customerId },
            { ref: line.invoice.customer$fullName },
            { ref: line.track$title },
            { ref: line.track.genre$genreId },
            { ref: line.track.genre$genreName }
        ] },
        { caption: '明细指标', items: [
            { ref: line.lineAmount },
            { ref: line.salesQuantity }
        ] }
    ],
    accesses: []
};
```

</details>

`lineAmount` 的 `formulaDef` 定义交易单价乘数量，查询时直接使用这个字段。明细 QM 不公开可累计的账单总金额，减少误用。

加载后，AI 同样实时获取明细 QM 的说明。嵌套维度的公开名称以返回结果为准，例如源码中的 `line.track.genre$genreName`，对应查询字段 `track_genre$genreName`：

<details>
<summary>实时获取的明细 QM 查询说明（Markdown 节选）</summary>

[完整返回记录](../resources/lab/evidence/chapter-08/ChinookInvoiceLineQueryModel.md)

```markdown
# ChinookInvoiceLineQueryModel - Chinook 明细分析

## 模型信息
- 表名: InvoiceLine
- 主键: InvoiceLineId
- 说明: 按账单日期、客户、曲目或媒体、内容分类分析明细金额和销售数量。每行是一条明细，不提供可累计的账单总金额。金额沿用样例原始单位，不指定币种、不做分元换算，不代表利润、回款或退款。

## 时间维度与字段 (Time Dimensions & Fields)
> **提示**: 在进行时间趋势分析、同环比或窗口函数(Window Functions)时，请优先使用 `business_date` 角色的字段。

| 字段名 | 名称 | 类别 | 时间角色 | 推荐用途 | 说明 |
|--------|------|------|----------|----------|------|
| invoice$invoiceDate | 账单日期 | 属性 | business_date | - | 按账单记录日期统计，不自行做时区转换。 |

## 属性字段
| 字段名 | 名称 | 类型 | 说明 |
|--------|------|------|------|
| invoiceLineId | 明细 ID | 文本 |  |
| invoiceId | 账单 ID | 文本 |  |
| trackId | 曲目或媒体 ID | 文本 |  |
| unitPrice | 交易单价 | 金额 | 账单明细上的交易单价，不是 Track 目录单价。样例原始单位，币种未指定。 |
| invoice$billingCountry | 账单国家 | 文本 |  |
| invoice$customerId | 客户 ID | 文本 |  |
| invoice_customer$fullName | 客户姓名 | 文本 | FirstName 与 LastName 拼接；与客户 ID 一起使用。 |
| track$title | 曲目或媒体名称 | 文本 |  |
| track_genre$genreId | 分类 ID | 文本 |  |
| track_genre$genreName | 内容分类名称 | 文本 |  |

## 度量字段
| 字段名 | 名称 | 类型 | 聚合 | 说明 |
|--------|------|------|------|------|
| lineAmount | 明细金额 | 金额 | SUM | 交易单价乘数量，沿用样例原始单位，不除以 100，不指定币种。 |
| salesQuantity | 销售数量 | 文本 | SUM | 累计 Quantity；本样例全部为 1，也不能将数量定义成明细条数。 |
```

</details>

## 第四步：按内容分类查询

确认明细模型加载成功、日期与分类字段可用后，再发送：

> 请按内容分类统计 2012 年的明细金额，核对全部分类的金额合计是否与同期账单一致，保存结果。

这次使用 `ChinookInvoiceLineQueryModel`，同时返回销售数量供核对。实际执行的 DSL 如下：

```json
{
  "columns": [
    "track_genre$genreId",
    "track_genre$genreName",
    "sum(lineAmount) as totalAmount",
    "sum(salesQuantity) as quantity"
  ],
  "slice": [
    {"field": "invoice$invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "groupBy": ["track_genre$genreId", "track_genre$genreName"],
  "orderBy": ["-totalAmount", "track_genre$genreId"],
  "limit": 100
}
```

日期仍按账单日期筛选，金额改为按分类累计 `lineAmount`。`limit: 100` 足以返回本例全部分类，便于核对合计。请求同样先校验，再执行。

本次返回 22 个有交易的分类，下面节选前三项：

| 分类 ID | 内容分类 | 明细金额 | 销售数量 |
| ---: | --- | ---: | ---: |
| 1 | Rock | 162.36 | 164 |
| 3 | Metal | 65.34 | 66 |
| 7 | Latin | 63.36 | 64 |

全部 22 类金额逐类核对一致，合计 **477.53**，与同期全部账单金额及第七章基线一致。

本次 SQLite 聚合的部分返回值带有浮点尾数，原始响应保留，金额按两位小数核对和展示。

## 写错字段时，先修正再执行

这次还故意把客户姓名写成不存在的 `clientName`。`query validate` 返回 `FIELD_NOT_FOUND`，提示重新读取字段说明，错误查询没有执行。

重新 `describe`，改回 `customer$fullName`，再次校验、执行，排名一致。字段合法只代表可以查询，是否符合用户问题仍需检查。

## 完成后检查什么

本次 4 个模型文件校验通过，两个查询模型均可查询，结果与独立核对一致。

读者重点检查：定义是否符合上一章的口径，查询期间是否正确，结果能否与已有基线对上。

完整模型、请求和验证记录见[配套实操说明](../resources/lab/chapter-08-notes.md)。

## 本章 Foggy 知识点

- **TM 与 QM：** TM 定义字段映射、维度关联、公式与聚合，QM 选择公开字段；账单与明细按各自粒度建模。
- **模型加载与元数据：** 校验 TM/QM，将目录登记为 Bundle 后刷新加载，用 `models describe` 实时获取引擎自动生成的查询说明。
- **基础 DSL：** 用 `columns`、`slice`、`groupBy`、`orderBy` 和 `limit` 表达返回字段、筛选、分组、排序与条数。
- **查询校验：** 先 `query validate` 再 `query execute`；字段错误时重新读取元数据，修正后再执行。

下一章沿用账单查询模型，学习普通筛选、条件组合与聚合后筛选。


---

<a id="chapter-9"></a>

# 第 9 章 让 AI 正确筛选数据：slice 与 having

第八章已经建立了语义层模型。本章沿用 `ChinookInvoiceQueryModel`，学习普通筛选、条件组合和聚合后筛选。金额仍用样例原始单位，国家均指账单地址中的国家。

## 先筛选原始记录：slice

在原来的练习目录和会话中发送：

> 查一下 2012 年账单国家为美国的记录。

Codex 读取 `foggy-semantic-query` Skill，通过 CLI 实时获取查询模型说明，确认日期、国家和金额字段。Markdown 由引擎自动生成，不用读者提前准备。

字段说明能告诉 AI 用 `billingCountry`，却不一定告诉它“美国”存的是 `USA` 还是 `United States`。本次先用语义查询列出国家取值，确认美国为 `USA`、加拿大为 `Canada`，再构造请求：

```json
{
  "columns": ["invoiceId", "invoiceDate", "billingCountry", "invoiceAmount"],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]},
    {"field": "billingCountry", "op": "=", "value": "USA"}
  ],
  "orderBy": ["invoiceId"],
  "limit": 100
}
```

`slice` 筛选原始账单，数组中的两个条件默认按 AND 组合：日期在 2012 年内，且账单国家为美国。这里查询的是逐张账单，没有分组求和。

请求经 `query validate` 校验后执行，共返回 **21 张账单**，以下节选前三张：

| 账单 ID | 账单日期 | 账单金额 |
| ---: | --- | ---: |
| 255 | 2012-01-24 | 5.94 |
| 256 | 2012-01-27 | 8.91 |
| 265 | 2012-03-11 | 0.99 |

本次结果未分页截断，逐条与原始记录核对一致。不是字段名写对了就万事大吉，筛选值也得先认准。

## 组合条件：AND、in 与 OR

继续发送：

> 2012 年美国或加拿大的账单，单张金额超过 10 的有哪些？

保留上一条请求的返回字段和排序，将 `slice` 换成：

```json
[
  {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]},
  {"field": "billingCountry", "op": "in", "value": ["USA", "Canada"]},
  {"field": "invoiceAmount", "op": ">", "value": 10}
]
```

三个条件要同时满足；国家条件内部使用 `in`，表示美国、加拿大任选其一。本次校验、执行后返回全部 **5 张账单**：

| 账单 ID | 账单国家 | 账单金额 |
| ---: | --- | ---: |
| 278 | Canada | 13.86 |
| 298 | USA | 10.91 |
| 299 | USA | 23.86 |
| 311 | USA | 11.94 |
| 320 | USA | 13.86 |

同一字段的多个候选值用 `in` 就很简洁。需要明确表达“或者”时，也可以把国家条件替换为：

```json
{
  "$or": [
    {"field": "billingCountry", "op": "=", "value": "USA"},
    {"field": "billingCountry", "op": "=", "value": "Canada"}
  ]
}
```

日期与金额条件仍放在 `$or` 外面，让两个国家都受它们约束。本次分别执行 `in` 和 `$or` 两种写法，返回的账单完全一致。

## 筛选汇总结果：having

再换一个问题：

> 2012 年所有客户中，累计账单金额超过 20 的有哪些？

这次范围是所有国家，筛选对象也从“一张账单”变成“一个客户的全年累计金额”。Codex 构造的请求如下：

```json
{
  "columns": ["customerId", "customer$fullName", "sum(invoiceAmount) as totalAmount"],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "groupBy": ["customerId", "customer$fullName"],
  "having": [
    {"field": "totalAmount", "op": ">", "value": 20}
  ],
  "orderBy": ["-totalAmount", "customerId"],
  "limit": 100
}
```

执行顺序可以理解为：`slice 选出全年账单 → 按客户累计 → having 筛选累计金额 → 排序返回`。

`having` 引用的是本次求和的别名 `totalAmount`。实际返回 **5 位客户**，金额和顺序与第八章的前五位恰好一致；这次数量由金额条件决定，查询上限是 100。

以 Richard Cunningham 为例，全年累计是 **25.84**。如果错把“金额超过 20”放进 `slice`，先丢掉小额账单再汇总，就只剩 **23.86**，其他四位客户也会漏掉。两种写法都能执行，却回答了不同的问题：

| 筛选方式 | 本次结果 |
| --- | --- |
| 单张账单金额超过 20 | 1 张账单，金额 23.86 |
| 客户全年累计金额超过 20 | 5 位客户，最高累计 25.84 |

所以，`slice` 与 `having` 的差别是筛选发生在哪一步。校验能检查请求是否合法，不能替用户决定“超过 20”指单张还是累计；提问含糊时，AI 应先确认。

## 文字搜索与排除

发送：

> 查一下 2012 年账单国家名称包含 United 的记录。

保留逐张账单查询的字段、日期和排序，增加这个 `slice` 条件：

```json
{"field": "billingCountry", "op": "like", "value": "United"}
```

本次 `like` 自动生成 `%United%`，返回英国的 2 张账单，ID 为 261、283。这里匹配的是数据库中的英文取值；中文名称与原始值的字典映射，第十五章再讲。大小写和通配符行为需按所用数据库验证。

要排除美国和加拿大，则改用：

```json
{"field": "billingCountry", "op": "not in", "value": ["USA", "Canada"]}
```

同样限定 2012 年，实际得到 53 张账单。`not in` 不等于“包括所有未填写的记录”，空值应单独处理。

## 未填写，用空值条件查询

现有 QM 尚未公开账单的州或地区。先发送：

> 给账单分析增加账单州或地区字段，校验并加载。先不查询。

Codex 在 `ChinookInvoiceModel.tm` 的 `properties` 中增加：

```javascript
{ column: 'BillingState', name: 'billingState', caption: '账单州或地区', type: 'STRING',
  description: '账单地址中的州或地区；NULL 表示未填写，不能据此推断客户没有所属地区。' }
```

再在账单 QM 的 `columnGroups` →“账单与客户”中增加 `{ ref: invoice.billingState }`。校验、刷新后，实时元数据应出现 `billingState`。

确认加载成功，再发送：

> 查一下 2012 年没有填写账单州或地区的账单。

请求选择 `invoiceId`、`billingState`，保留日期范围与 ID 排序，增加：

```json
{"field": "billingState", "op": "is null"}
```

本次返回 39 张；改为 `is not null` 返回 44 张，两者合计为全年 83 张。响应中空值字段可能省略，不能因此认定请求没有选择它。

NULL、空字符串和 0 是不同的值。当前版本把 `value: ""` 的等值筛选判为无效请求，不能拿它代替 `is null`；该错误请求未继续执行。筛选文本值 `"0"` 返回空列表，也不代表未填写。

## 核对什么

原有实操通过公开 CLI 完成了 8 个查询用例，包含取值确认、三个业务问题、等价 OR 写法、单张与累计对照，以及空日期范围。用只读原始记录和 Python `Decimal` 独立核对，账单 ID、日期、国家、金额、客户范围及排序均一致；该组查询未修改模型或数据。

读者重点看三件事：**筛选值是否真实存在，AND／OR 的范围是否正确，条件应该在聚合前还是聚合后生效。** 如果结果为空，也要如实说明没有符合条件的记录；本次查询无数据的日期范围时，执行成功，返回空列表。

完整请求、响应和复跑方式见[第九章实操记录](../resources/lab/chapter-09-notes.md)。

本轮另补文字、排除与空值的 5 项查询及 1 项预期校验拒绝，均与只读原始记录核对；只新增公开州字段，数据未修改。见[基础功能补充记录](../resources/lab/basic-supplements-notes.md)。

## 本章 Foggy 知识点

- **聚合前筛选：** `slice` 筛选原始记录，支持日期区间、等值、数值比较和 `in`。
- **条件组合：** 条件数组默认按 AND 组合，`$or` 表达或者，嵌套位置决定哪些条件共同生效。
- **聚合后筛选：** `having` 按聚合别名筛选汇总结果，区别于单张记录的金额筛选。
- **文字与空值：** `like`、`not in`、`is null` / `is not null`，区分未填写、空字符串与实际值。

下一章继续聚合与计算指标。


---

<a id="chapter-10"></a>

# 第 10 章 聚合与计算指标：数清楚，再算平均和占比

上一章解决“筛选哪些数据”，本章继续使用 `ChinookInvoiceQueryModel`，学习求和、计数、去重计数和计算字段。先在请求中完成临时计算，再把常用的平均金额固化为语义层指标。

## 账单数，不等于客户数

在原来的练习目录和会话中发送：

> 统计一下 2012 年的账单金额、账单数和涉及的客户数。

Codex 读取 `foggy-semantic-query` Skill，实时获取模型说明，确认金额、账单 ID 和客户 ID，再构造请求：

```json
{
  "columns": [
    "sum(invoiceAmount) as totalAmount",
    "count(invoiceId) as billCount",
    "count_distinct(customerId) as buyerCount"
  ],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "limit": 2
}
```

三种聚合各管一件事：`sum` 累计金额，`count` 数账单，`count_distinct` 对客户 ID 去重。同一个客户有多张账单，客户数仍只算一位。

请求先校验，再执行，实际返回：

| 账单金额 | 账单数 | 涉及的客户数 |
| ---: | ---: | ---: |
| 477.53 | 83 | 47 |

模型以一张账单为基础记录，因此可以直接数 `invoiceId`。样例客户表有 59 位客户，但本次统计的是 2012 年有账单的 47 位，不能拿客户表总行数代替。

这里没有 `groupBy`，结果是整个筛选范围的一行汇总；`limit` 约束返回行数，不会把参与求和的账单限制成两张。

## 平均金额：先确认分子与分母

继续发送：

> 2012 年平均每张账单金额是多少？

本例定义是“同期账单金额合计 ÷ 账单数”。下面通过 `calculatedFields` 把它写成本次查询的计算字段：

```json
{
  "columns": [
    "sum(invoiceAmount) as totalAmount",
    "count(invoiceId) as billCount",
    "temporaryAverage"
  ],
  "calculatedFields": [
    {
      "name": "temporaryAverage",
      "expression": "sum(invoiceAmount) / nullif(count(invoiceId), 0)"
    }
  ],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "limit": 2
}
```

`name` 是本次计算字段的名字，`expression` 是由引擎解析、执行的表达式。`temporaryAverage` 只在这次请求中使用，不会给 QM 或数据库新增字段。

实际结果为 `477.53 ÷ 83 = 5.753373…`，展示为 **5.75**，金额仍用样例原始单位。当前模型一行一张账单，直接使用 `avg(invoiceAmount)` 也得到相同结果。

同类聚合还有 `min(invoiceAmount)`、`max(invoiceAmount)`，本次最小、最大单张金额分别为 **0.99**、**23.86**。

从分组均额计算整体均额时，要考虑各组账单数。例如，先算出各国平均金额，再把这 20 个均额直接平均，会得到约 **5.84**；各国账单数不同，整体均额仍应按总金额除总账单数计算。

### 没有账单时怎么办

`nullif(count(invoiceId), 0)` 将零分母转为空值。本次把日期改到没有记录的 1990 年，实际返回账单数 0，金额与均额字段未返回。应解释为“该期间没有账单，无法计算平均金额”，不能将均额补成 0。

## 常用指标，固定下来

临时计算适合一次性需求。如果平均账单金额经常要查，就可以固化到语义层模型。本例 TM 已有金额和账单 ID，只需在 QM 中新增计算指标 `averageAmount`。

### 第一步：修改并加载语义层模型

发送：

> 把平均账单金额保存为固定指标，校验并加载。先不查询。

Codex 读取建模 Skill，在账单 QM 的“账单指标”分组中增加 `averageAmount`：

```javascript
{
    name: 'averageAmount',
    caption: '平均账单金额',
    formula: 'sum(invoiceAmount) / nullif(count(invoiceId), 0)',
    type: 'MONEY',
    description: '当前筛选与分组范围的账单总金额除以账单数；无账单时为空，不累计或简单平均分组均额。样例原始单位，币种未指定。'
}
```

公式没有变，变化的是维护位置：从每次查询的 `calculatedFields` 移到 QM。完整源码见[账单 QM](../resources/lab/models/query/ChinookInvoiceQueryModel.qm)，数据库没有新增物理列。

Codex 校验模型、加载刷新，再实时获取查询说明。本次四个建模文件校验通过，返回的元数据确认 `averageAmount` 是预定义计算字段，可直接引用。查询说明由引擎根据已加载的代码自动生成，不需要提前维护 Markdown。

### 第二步：使用固定指标查询

确认加载成功后，再发送：

> 用固定指标查询 2012 年的平均账单金额。

此时 DSL 不再携带公式：

```json
{
  "columns": ["averageAmount"],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "limit": 2
}
```

返回均额仍为 **5.75**。按国家分组的 20 个均额、无账单期间的空值，也都与临时计算一致。预定义公式已经包含聚合，直接选择 `averageAmount` 即可，不要再对它求和或平均，也不要在 `calculatedFields` 中重定义同名字段。

LLM 仍需判断日期和分组范围，但计算公式已由语义层模型统一定义。这样可以减少 LLM 的推导过程，降低公式写错或遗漏空值处理的概率。

## 金额占比：分母跟着什么范围走

再发送：

> 按账单国家统计 2012 年的金额，以及占全年账单总额的比例。

先按国家求和，再计算各国金额占全部分组金额的比例。本次使用 `calculatedFields` 中的 `ratio_to_total`：

```json
{
  "columns": ["billingCountry", "sum(invoiceAmount) as totalAmount", "amountShare"],
  "calculatedFields": [
    {"name": "amountShare", "expression": "ratio_to_total(totalAmount)"}
  ],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "groupBy": ["billingCountry"],
  "orderBy": ["-totalAmount", "billingCountry"],
  "limit": 100
}
```

`ratio_to_total` 使用聚合后的金额计算占比。这次只限定 2012 年，分母为 **477.53**。实际返回 20 个有账单的国家，下面节选前三项：

| 账单国家 | 账单金额 | 占比 |
| --- | ---: | ---: |
| USA | 127.98 | 26.80% |
| Brazil | 53.46 | 11.20% |
| Canada | 42.57 | 8.91% |

引擎返回的是比例，例如美国约为 `0.268004`，展示成百分数时乘以 100。完整占比合计为 1；展示值经过四舍五入，合计可能略有差异。

### 筛选国家与只展示前三名，含义不同

如果在 `slice` 中先限定美国、加拿大，分母会变为两国金额合计 **170.55**，对应美国 **75.04%**、加拿大 **24.96%**。回答时就应说“占美加两国合计的比例”。

如果只把原请求的 `limit` 改为 3，分母仍是全年 **477.53**，前三项占比与上表一致。这三项只是展示了部分国家，不应将它们重新归一化成 100%。两种情况都已实际执行核对。

## 只累计满足条件的账单

发送：

> 统计 2012 年单张金额至少 10 的账单数和金额。

本例用 `if` 表达条件，再交给聚合函数累计。保留全年日期范围，将返回列与计算字段改为：

```json
{
  "columns": ["largeBillCount", "largeBillAmount"],
  "calculatedFields": [
    {"name": "largeBillCount", "expression": "sum(if(invoiceAmount >= 10, 1, 0))"},
    {"name": "largeBillAmount", "expression": "sum(if(invoiceAmount >= 10, invoiceAmount, 0))"}
  ]
}
```

这是请求片段，完整请求仍带日期 `slice`，`limit` 为 2。实际得到 **15 张、216.08**。条件命中时累计 1 或金额，否则累计 0；这不是先汇总再筛选的 `having`。

只查这一类账单，用 `slice` 限定金额后再求和也可以。条件聚合的价值是能在同一范围同时计算不同条件的指标；常用条件应像前面的均额一样固化，并明确门槛。这里的 10 只是练习约定，不是业务上统一的“大额”标准。

## 完成后检查什么

本次 11 个查询用例均通过校验、执行与原始数据的独立核对，覆盖计数、临时与固定均额、原生 AVG/MIN/MAX、国家均额、占比分母和空期间。模型仅在账单 QM 中新增了 `averageAmount`，样例数据未修改。

读者重点检查：**数的是账单还是客户，平均值的分母是什么，占比基于哪个数据范围，空值能不能解释成零。**

完整请求、结果和复跑方式见[第十章实操记录](../resources/lab/chapter-10-notes.md)。

新增条件聚合也已通过 CLI 校验、执行与原始金额独立核对，见[基础功能补充记录](../resources/lab/basic-supplements-notes.md)。

## 本章 Foggy 知识点

- **聚合函数：** `sum`、`count`、`count_distinct`、`avg`、`min`、`max`，区分账单数与去重客户数。
- **计算指标：** 用 `calculatedFields` 临时计算，通过 `nullif` 处理零分母；常用公式可固化到 QM，校验加载后直接引用。
- **分组占比：** `ratio_to_total` 计算分组金额占比；`slice` 改变参与统计的范围，`limit` 只限制展示条数。
- **条件聚合：** `sum(if(...))` 累计满足条件的数量或金额，区分条件聚合与聚合后筛选。

下一章继续按日期与月份汇总。


---

<a id="chapter-11"></a>

# 第 11 章 按日期与月份汇总数据

上一章讲了聚合与计算指标，本章给账单增加年月字段，再按月汇总。仍使用 Chinook 和样例原始金额单位；以下示例沿用 Launcher **0.1.25**、CLI **0.1.24** 的实操记录。

## 第一步：准备时间分析的语义层模型

在原来的练习目录和会话中发送：

> 为账单建立按年、按月分析的语义层模型，校验并加载。先不查询。

原账单模型只公开了原始日期，本章新增一对专门用于时间分析的建模文件：

| 文件 | 定义内容 |
| --- | --- |
| [ChinookInvoiceTimeModel.tm](../resources/lab/models/model/ChinookInvoiceTimeModel.tm) | 一行一张账单，定义日期维度、年月字段、金额与张数 |
| [ChinookInvoiceTimeQueryModel.qm](../resources/lab/models/query/ChinookInvoiceTimeQueryModel.qm) | 公开本次时间分析所需的字段 |

Foggy 支持直接从业务日期派生年月，**不需要独立的日期维表**。本例由 Codex 把 `InvoiceDate` 配置为 `DATETIME` 自维度，Invoice 表保持原样。数据量大、时间分析频繁时，建议考虑[独立日期维表](https://learn.microsoft.com/en-us/fabric/data-warehouse/dimensional-modeling-dimension-tables)，预存年月等属性，减少查询时重复计算；实际性能收益仍取决于数据库、索引和关联计划。

下面是**本章新增的年月属性**，位于 `lab/models/model/ChinookInvoiceTimeModel.tm` 的 `dimensions` → `invoiceDate` → `properties` 中，其余配置省略：

```javascript
export const model = {
    name: 'ChinookInvoiceTimeModel',
    // 其他模型配置省略
    dimensions: [
        {
            name: 'invoiceDate', caption: '账单日期',
            foreignKey: 'InvoiceDate', primaryKey: 'InvoiceDate', captionColumn: 'InvoiceDate',
            type: 'DATETIME', timeRole: 'business_date',
            properties: [
                // year、month 属性省略
                {
                    column: 'InvoiceDate', name: 'yearMonth', caption: '账单年月', type: 'STRING',
                    description: 'YYYY-MM，自然月分组键，保留年份。',
                    dialectFormulaDef: {
                        sqlite: { builder: (alias) => `strftime('%Y-%m', ${alias}.InvoiceDate)` }
                    }
                }
            ]
        }
    ]
};
```

日期表达式由建模人员维护，LLM 查询时直接选用语义字段；更换数据库时需调整相应方言。

Codex 校验、加载并刷新后，实时获取引擎根据代码生成的查询说明，无须提前维护 Markdown。公开的时间字段包括：

| 字段 | 含义与用途 |
| --- | --- |
| `invoiceDate$id` | 原始账单日期，用于日期范围筛选，不是账单编号 |
| `invoiceDate$year` | 年份，例如 2012 |
| `invoiceDate$month` | 月份序号 1—12 |
| `invoiceDate$yearMonth` | 年月，例如 `2012-11`，用于自然月分组 |

后面的 CLI 查询均指定 `ChinookInvoiceTimeQueryModel`，字段名以实时元数据为准。给日期字段随手加个 `$month`，引擎可不会自动领会。

## 第二步：按月统计

确认模型可用后，再发送：

> 按月统计 2012 年的账单金额和张数。

Codex 使用年月分组，按年月升序查询：

```json
{
  "columns": ["invoiceDate$yearMonth", "sum(invoiceAmount) as totalAmount", "count(invoiceId) as billCount"],
  "slice": [
    {"field": "invoiceDate$id", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "groupBy": ["invoiceDate$yearMonth"],
  "orderBy": ["invoiceDate$yearMonth"],
  "limit": 100
}
```

日期范围决定“统计哪些账单”，年月字段决定“怎样分组”。原始日期放进 `groupBy`，不会自动变成月度汇总。

请求先校验，再执行。本次完整返回十二个月，下面节选四项：

| 年月 | 账单金额 | 账单数 |
| --- | ---: | ---: |
| 2012-09 | 46.71 | 6 |
| 2012-10 | 42.62 | 7 |
| 2012-11 | 37.62 | 7 |
| 2012-12 | 37.62 | 7 |

十二个月合计 **477.53、83 张账单**，与第七章基线和上一章全年统计一致。

跨年分析要保留年份。单独按 `month` 分组，会把不同年份的一月合在一起；使用 `yearMonth`，或者同时按 `year`、`month` 分组，才能区分。

## 完成后检查什么

重点检查：**业务日期是否选对，筛选范围是否明确，月份是否保留年份，分月结果能否对回全年。** 月份没有记录，不代表可以直接补零；先确认它表示没有交易，还是缺少数据。

本章保留日期建模与月度汇总。同比、环比、累计、滚动及 LAG 集中到高级篇的“时间分析”大章，届时再讲期间比较和窗口口径。

已有模型、查询和实操记录保留，见[0.1.25 实操记录](../resources/lab/chapter-11-0.1.25-notes.md)。其中还包含已迁出的期间比较与 LAG 验证，不能把原来的全部用例数视为当前正文覆盖范围；[迁出素材](../resources/chapters/references/time-analysis-verified-material-2026-10-21.md)留作后续备课。本轮仅调整编排，未重跑查询。

## 本章 Foggy 知识点

- **日期自维度：** 用主表日期建立 `DATETIME` 自维度，无须为本例新增日期表。
- **派生属性：** 在 TM 中定义年、月、年月，由 QM 公开，查询字段以实时元数据为准。
- **范围与分组：** 原始日期限定统计范围，年月决定分组；跨年分析保留年份，汇总结果与全年基线核对。

下一章从日期转向业务对象，讲维度成员、多级关联与逐层查询。


---

<a id="chapter-12"></a>

# 第 12 章 从艺术家查到专辑：维度成员与多级关联

上一章按日期与月份汇总金额，这一章看看哪些艺术家卖得多，又卖了哪些专辑、曲目。沿用第八章的明细模型，增加两级关联，金额仍按交易明细累计。

## 第一步：扩展专辑与艺术家维度

在原来的练习目录和会话中发送：

> 给明细分析增加专辑和艺术家，校验并加载。先不查询。

Codex 先通过 CLI 查看表结构，确认关系：

```text
InvoiceLine.TrackId → Track.TrackId
Track.AlbumId       → Album.AlbumId
Album.ArtistId      → Artist.ArtistId
```

每条明细通过曲目找到专辑，再找到专辑所属艺术家。这里不使用 `Track.Composer` 作曲者字段；金额仍按明细的 `UnitPrice × Quantity` 计算。

修改已有的两个文件：

| 文件 | 修改位置 |
| --- | --- |
| [ChinookInvoiceLineModel.tm](../resources/lab/models/model/ChinookInvoiceLineModel.tm) | `dimensions → track → dimensions` 内，与 `genre` 并列增加 `album`，再在其下增加 `artist` |
| [ChinookInvoiceLineQueryModel.qm](../resources/lab/models/query/ChinookInvoiceLineQueryModel.qm) | `columnGroups` 中公开专辑、艺术家维度 |

下面从 TM 顶层 `dimensions` 展开：`track` 是已有维度，`album`、`artist` 是本章新增的两层；其他配置省略。

```javascript
export const model = {
    name: 'ChinookInvoiceLineModel',
    tableName: 'InvoiceLine',
    // 其他模型配置省略
    dimensions: [
        // 已有的 invoice 维度省略
        {
            name: 'track', caption: '曲目或媒体', tableName: 'Track',
            foreignKey: 'TrackId', primaryKey: 'TrackId', captionColumn: 'Name',
            // track 的属性省略
            dimensions: [
                // 已有的 genre 维度省略，与 album 并列
                {
                    name: 'album', caption: '专辑', tableName: 'Album',
                    foreignKey: 'AlbumId', primaryKey: 'AlbumId', captionColumn: 'Title',
                    dimensions: [
                        {
                            name: 'artist', caption: '艺术家', tableName: 'Artist',
                            foreignKey: 'ArtistId', primaryKey: 'ArtistId', captionColumn: 'Name'
                        }
                    ]
                }
            ]
        }
    ]
};
```

每层的 `foreignKey` 都取自直接上一级的表：`track` 用 InvoiceLine.TrackId，`album` 用 Track.AlbumId，`artist` 用 Album.ArtistId；`primaryKey` 则是当前维表的关联键。

QM 公开专辑、艺术家的 ID 和名称。`ref` 绑定来源，`name` 用于查询，`alias` 决定结果键；本例让后两者相同：

```javascript
{ caption: '专辑与艺术家', items: [
    { ref: line.track.album$id, name: 'albumId', alias: 'albumId' },
    { ref: line.track.album$caption, name: 'albumName', alias: 'albumName' },
    { ref: line.track.album.artist$id, name: 'albumArtistId', alias: 'albumArtistId' },
    { ref: line.track.album.artist$caption, name: 'albumArtistName', alias: 'albumArtistName' }
] }
```

六个模型文件校验通过、刷新后，Codex 实时读取引擎生成的查询说明。Markdown 无须提前生成或维护，新增查询名如下：

| 字段 | 用途 |
| --- | --- |
| `albumArtistId`、`albumArtistName` | 艺术家 ID、名称 |
| `albumId`、`albumName` | 专辑 ID、名称 |

这些名称配置在具体列上；整维度 ref 的 alias 不会重命名所有展开字段。旧的 `artist$id`、`artist$caption` 及完整路径名仍兼容，**后文统一使用上表中的查询名**。名称用于展示，分组和筛选保留 ID，避免合并同名成员。

## 第二步：找出当年有成交的艺术家

“维度成员”就是该维度的具体对象，例如艺术家 ID `1` 对应 `AC/DC`。查找可选对象时，Codex 可以调用 `members list ChinookInvoiceLineQueryModel albumArtistId`，本例的艺术家目录总数是 **275 位**。

要看某年有哪些艺术家有成交，还需要按日期查询交易记录。

模型加载成功后，再发送：

> 2012 年哪些艺术家有成交？

Codex 查询 `ChinookInvoiceLineQueryModel`，按艺术家分组并统计明细数：

```json
{
  "columns": ["albumArtistId", "albumArtistName", "count(invoiceLineId) as lineCount"],
  "slice": [
    {"field": "invoice$invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "groupBy": ["albumArtistId", "albumArtistName"],
  "orderBy": ["albumArtistId"],
  "limit": 500,
  "returnTotal": true
}
```

先校验再执行，得到 **119 位当年有成交的艺术家**，其中 AC/DC 有 3 条成交明细。它们是目录中的一部分，不能把目录总数当作成交人数。

## 第三步：按艺术家排名，分清一页和全部

继续发送：

> 按 2012 年销售金额给艺术家排名，每页看 5 位。

Codex 查询 `ChinookInvoiceLineQueryModel`，仍使用账单日期筛选：

```json
{
  "columns": [
    "albumArtistId", "albumArtistName",
    "sum(lineAmount) as totalAmount", "sum(salesQuantity) as quantity"
  ],
  "slice": [
    {"field": "invoice$invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "groupBy": ["albumArtistId", "albumArtistName"],
  "orderBy": ["-totalAmount", "albumArtistId"],
  "start": 0,
  "limit": 5,
  "returnTotal": true
}
```

请求只表达日期、分组、指标和排序，曲目到艺术家的关联由引擎按 TM 展开。金额降序、ID 升序，用来固定同金额时的次序。

| 艺术家 ID | 名称 | 金额 | 数量 |
| --- | --- | ---: | ---: |
| 90 | Iron Maiden | 33.66 | 34 |
| 150 | U2 | 27.72 | 28 |
| 156 | The Office | 25.87 | 13 |
| 50 | Metallica | 25.74 | 26 |
| 22 | Led Zeppelin | 23.76 | 24 |

Chinook 也包含影视内容，所以榜单中会出现 The Office 等条目。金额仅在展示时保留两位小数，数量累计的是 `Quantity`。

这里有三个不同的数：

- **本页 5 条**：这次返回了多少个艺术家分组。
- **`total: 119`**：同一筛选范围内共有多少个分组，不是账单数，也不是金额。
- **全年 477.53**：另行核对的全部成交金额，不能把本页五位的合计 136.75 当作全年金额。

再发送：

> 看下一页。

Codex 保留日期、分组和排序，把 `start` 改为 5。第二页从 Lost 开始，`total` 仍为 119。`limit` 限制展示条数，统计范围保持不变。

## 第四步：从实际结果继续查专辑和曲目

先发送：

> 看看排名第一的艺术家当年卖了哪些专辑。

Codex 从完整排名取到第一位 Iron Maiden 的 **ID 90**，沿用 2012 年日期范围，增加筛选：

```json
{"field": "albumArtistId", "op": "=", "value": 90}
```

分组改为 `albumId`、`albumName`，指标仍是明细金额与数量，按金额降序、专辑 ID 升序。本次得到 **19 张当年有成交的专辑**，金额合计 **33.66**，与艺术家汇总一致。

排在第一的是 `Powerslave`，ID 为 **107**，金额 **4.95**、数量 **5**。

再发送：

> 再看第一张专辑的成交曲目。

Codex 保留艺术家和日期条件，增加 `albumId = 107`，按 `trackId`、`track$title` 分组。得到 Aces High、2 Minutes To Midnight 等 **5 首有成交的曲目**，金额合计 **4.95**，与专辑汇总一致。

这就是“下钻”：保留期间和已选对象，把分组从艺术家换成专辑，再换成曲目。

## 去重列表、合计与小计

前面用成员接口查看艺术家目录。若只想返回交易中出现过的不同取值，可以用 `distinct`。以账单国家为例，发送：

> 列出 2012 年有账单的国家，不重复。

Codex 改查 `ChinookInvoiceQueryModel`：

```json
{
  "columns": ["billingCountry"],
  "slice": [{"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}],
  "distinct": true,
  "orderBy": ["billingCountry"],
  "limit": 100,
  "returnTotal": true
}
```

实际返回 20 个国家。`distinct` 对选中列的组合去重，返回不同的值；第十章的 `count_distinct` 返回数量。若再选账单 ID，组合就变了，不能指望仍只有 20 行。去重也不能补救错误关联造成的金额重复。

### 让结果附带总计

发送：

> 按国家汇总 2012 年账单金额和张数，显示全部国家并附总计。

仍查询账单 QM，保留日期，将请求改为：

```json
{
  "columns": ["billingCountry", "sum(invoiceAmount) as totalAmount", "count(invoiceId) as billCount"],
  "groupBy": ["billingCountry"],
  "orderBy": ["billingCountry"],
  "limit": 100,
  "returnTotal": true,
  "withSubtotals": true
}
```

这是保留日期 `slice` 后的变更片段，不再使用 `distinct`。本次返回 20 行国家数据和 1 行总计，按 `_rowType` 区分：普通行为 `data`，总计行为 `grandTotal`。总计 **477.53、83 张**；`total: 20` 仍是国家分组数，不包含总计行，也不是金额。

**总计范围要特别核对。** 本版本的 `withSubtotals` 根据返回的分组生成汇总；同一请求把 `limit` 改成 5，总计便是这 5 国的 **137.61、25 张**，`total` 仍为 20。需要全年金额时，应像前文一样另查全范围总额，或确认已完整返回全部分组，不能仅凭 `grandTotal` 这个名字判断范围。

再按 `billingCountry`、`customerId` 两级分组，本次返回 47 行客户数据、20 行国家 `subtotal` 和 1 行 `grandTotal`。国家小计均与独立国家汇总一致。这里只讲可加的金额和张数；平均值、去重人数和比例不能直接相加。

## 完成后检查什么

新增关联后，完整艺术家汇总仍是 **477.53、数量 447**，与原明细汇总一致；专辑合计能对回艺术家，曲目合计能对回专辑。旧分类查询也通过回归，样例数据未修改。

读者重点检查：**成员 ID 是否来自实际结果，期间和上层筛选是否保留，关联是否重复累计或丢失记录，返回的是一页还是全部。** 本样例关联完整，尚未构造缺失专辑、孤立艺术家等异常数据测试。

本章基于 Launcher **0.1.30**、CLI **0.1.25** 实测：11 项查询先校验、再执行，结果与原始数据基线一致；成员接口的总数与返回页也已独立核对。请求、元数据和复跑方式见[实操记录](../resources/lab/launcher-0.1.30-retest-2026-10-21.md)。

本轮另验证去重、普通分组、完整总计、分页总计与两级小计，共 5 项，见[基础功能补充记录](../resources/lab/basic-supplements-notes.md)。

## 本章 Foggy 知识点

- **多级维度与字段：** TM 嵌套声明 Track → Album → Artist，QM 用具体列的 name/alias 公开查询名与结果键。
- **成员与标识：** 按公开 ID、名称发现成员，用 ID 筛选，区分成交范围与完整目录。
- **逐层分析：** 保留日期与上层对象，从艺术家查到专辑、曲目，金额逐层核对。
- **排序与分页：** 用 `orderBy`、`start`、`limit` 和 `returnTotal`，区分页内条数、分组总数与全范围金额。
- **去重与汇总行：** `distinct` 返回不同组合；`withSubtotals` 追加小计/总计，按 `_rowType` 识别并核对实际范围。

下一章继续讲查询范围与权限。


---

<a id="chapter-13"></a>

# 第 13 章 让 AI 只查询用户可见的数据

上一章能从艺术家查到专辑和曲目，接下来要问：这些数据，当前用户都能看吗？

本章仍用 Chinook，做三个本地演示身份：美国、加拿大、未授权。前两个分别只看账单国家为 `USA`、`Canada` 的交易，都不能直接查询交易单价；未授权身份不能使用这个查询模型。

## 第一步：准备受限模型与演示身份

在原练习会话中发送：

> 为 Chinook 建一个权限演示模型：美国身份只看美国，加拿大身份只看加拿大，并隐藏交易单价。先不查询。

Codex 读取建模 Skill，准备隔离 Runtime 与本地权限服务，再编写模型。本例另用 Namespace `chinook-scope`，原教学模型保持不变：

| 文件 | 作用 |
| --- | --- |
| [ChinookScopedInvoiceLineModel.tm](../resources/lab/examples/chapter-13/models/model/ChinookScopedInvoiceLineModel.tm) | 沿用明细粒度、关联和金额定义 |
| [ChinookScopedInvoiceLineQueryModel.qm](../resources/lab/examples/chapter-13/models/query/ChinookScopedInvoiceLineQueryModel.qm) | 增加模型、字段与行权限 |

本地权限服务只用于教学，按演示身份返回是否允许访问及账单国家。身份值由配套脚本临时生成，通过 CLI 的 `FOGGY_RUNTIME_AUTHORIZATION` 传递，不写进模型或查询 JSON。

这里让 Codex 切换身份是为了验证效果。真实应用应从登录会话取得可信身份，用户在聊天中说“我是管理员”不构成授权。业务身份使用 `Authorization`，与管理 Runtime 的 `--auth-code` 分开。 

## 谁能查、能查什么，由 QM 落实

下面节选 QM 内的权限配置，`line` 是已加载的明细 TM：

```javascript
modelPermissions: {
    mode: 'resolver',
    resolver: (context) => {
        const decision = post({
            url: 'http://127.0.0.1:18070/permissions',
            headers: { Authorization: context.authorization },
            body: {
                namespace: context.namespace,
                model: context.model,
                action: context.action
            },
            responseType: 'map'
        });
        if (decision.allowed !== true) {
            return { allow: false };
        }
        return {
            allow: true,
            attributes: { country: decision.country },
            rowPredicates: [
                context.predicate.eq(line.invoice$billingCountry, decision.country)
            ],
            policyVersion: 'chapter-13-demo-v1'
        };
    }
},
fieldPermissions: {
    defaultVisible: true,
    hiddenFields: [{ fields: ['detailPrice'] }]
}
```

`modelPermissions` 决定当前操作是否允许；`rowPredicates` 把允许的账单国家变成查询约束；`fieldPermissions` 隐藏 `detailPrice`。这个字段来自 `line.unitPrice`，在 QM 中同时配置了 `name: 'detailPrice'` 和 `alias: 'detailPrice'`。

两个模型文件校验通过、加载后，Codex 按不同身份实时读取元数据：

- 美国、加拿大身份能发现并描述模型，金额等允许字段仍可用，单价不出现在公开字段中。
- 未授权或未带身份时，模型不出现在列表里；直接指定模型名读取说明，也返回 `MODEL_ACCESS_DENIED`。
- 请求 `detailPrice` 在查询校验时被拒绝；换回旧名 `unitPrice` 也不能查出单价。

字段隐藏同时约束元数据和查询入口，不能只把页面上的一列藏起来。

## 第二步：同一个问题，换两个身份

确认模型加载成功后，再发送：

> 分别用美国和加拿大身份，查询 2012 年的销售金额。

Codex 对两个身份提交同一份 DSL，只通过调用环境切换身份：

```json
{
  "columns": ["sum(lineAmount) as amount", "count(invoiceLineId) as lines"],
  "slice": [
    {"field": "invoice$invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "limit": 2
}
```

请求里没有国家条件，结果却不同：

| 调用身份 | 允许的账单国家 | 金额 | 明细条数 |
| --- | --- | ---: | ---: |
| 美国 | USA | 127.98 | 102 |
| 加拿大 | Canada | 42.57 | 43 |

日期来自本次问题，国家范围来自权限决策，两者共同限制查询。LLM 不必每次记得补上国家条件，漏写也不会把范围扩大为全部国家。

这两个金额与原始表按对应账单国家独立核算一致。再从美国切到加拿大、切回美国，结果仍分别对应各自范围。

## 第三步：试着越过范围

再发送：

> 用美国身份看看加拿大 2012 年的客户排名。

Codex 在客户排名请求里增加 `invoice$billingCountry = 'Canada'`，但美国身份的权限条件仍是 `USA`。两者取交集，实际返回空列表；这不能解释为加拿大没有成交数据。

允许范围内的客户排名也已核对：美国 13 位、加拿大 4 位；每页看 3 位时，翻页与分组总数都保持各自范围。

未授权身份的查询会在校验阶段被拒绝；本次没有继续执行这些请求。演示权限服务临时返回错误时，查询校验同样失败，不会自动降级为查询全部数据，也不能换用原来的公开模型继续尝试。

## 成员列表也要遵守权限

上一章的成员接口同样携带当前身份。本例增加了指向账单维度 ID 的 `billId`，美国身份查到的账单成员都属于美国，加拿大身份同理。目录本身不继承刚才查询中的 2012 年筛选。

但不能把任何行条件都直接搬到任意维度上。账单国家存在于 Invoice 表，可以用于限制账单目录；同一个条件无法直接映射到 Artist 表。本次用受限身份查询艺术家目录时，引擎返回 `MODEL_PERMISSION_RESOLUTION_FAILED`，没有退回整个艺术家目录。

所以，成员接口拒绝不一定表示“没有成员”。还要看当前权限能否在这个目录上正确执行；无法保证时，应明确拒绝。

## 完成后检查什么

重点核对四件事：**模型不能靠猜名字绕过，隐藏字段不能换个名字查出，聚合与分页不扩大行范围，成员接口不丢掉权限条件。** 模型元数据和查询应使用同一个调用身份。

本章用 Launcher **0.1.30**、CLI **0.1.25** 实操：11 项查询执行并核对通过，5 项查询按预期在校验时拒绝，另验证了模型发现、描述及成员范围。原数据和前面章节的模型未修改，身份值未保存在证据中。复跑、源码与实际诊断见[实操记录](../resources/lab/chapter-13-notes.md)。

本例验证的是 QM 数据面。发布模型、原始 SQL 探查等管理能力不能作为普通用户问数入口；真实身份验证和管理接口隔离由业务平台负责。

## 本章 Foggy 知识点

- **模型权限：** `modelPermissions` 按当前身份和操作解析是否允许访问。
- **字段权限：** `fieldPermissions` 同时影响公开字段与查询校验。
- **行权限：** typed `rowPredicates` 与业务筛选共同生效，约束聚合、排名和分页。
- **成员范围：** 复用来源模型的权限；无法安全映射的条件明确拒绝。

下一章讲如何在同一个 QM 中关联多个 TM，以及为什么常常需要先聚合再 JOIN。模型维护与回归放在其后。


---

<a id="chapter-14"></a>

# 第 14 章 在 QM 中关联多个 TM：普通 JOIN 与聚合 JOIN

前面的 TM 已经映射了事实与维度，也能描述曲目、专辑、艺术家这样的雪花式关联。但现实分析还经常需要关联多个事实，例如订单与支付、账单与明细。它们各有粒度，关联前要先确定：按什么键连接，是否需要先汇总。

本章把账单 TM 与明细 TM 的关联定义在**同一个 QM** 中。准备好模型后，AI 仍然提交普通 JSON DSL，由引擎按 QM 展开 JOIN；需要先聚合的规则也写在 QM 中。

## 先确定结果粒度，再选主 TM

主 TM 是查询起点，先看最终希望“一行代表什么”：

| 查询目标 | 主 TM 与常用关联方式 |
| --- | --- |
| 每张账单及其明细合计 | 以 `ChinookInvoiceModel` 为主，明细先按账单 ID 聚合再 JOIN，保持一账单一行 |
| 每条明细及所属账单的日期、客户等属性 | 以 `ChinookInvoiceLineModel` 为主，按账单唯一键做多对一关联；可在 TM 中定义账单维度，也可在 QM 中 JOIN |

前面明细 TM 的 `invoice` 维度就是第二种做法。若要展开明细，直接 JOIN 也合理；但主 TM 不保证结果保持原粒度，取到的账单总额也不能当作明细金额累计。

本章先用普通 JOIN 看清行数变化，再改成聚合 JOIN，保持账单粒度。

## 第一步：创建普通关联 QM，先不查询

沿用已有的 `ChinookInvoiceModel` 和 `ChinookInvoiceLineModel`，在原练习目录和会话中发送：

> 以账单为主，建一个关联明细的 QM，校验并加载。先不查询。

Codex 读取建模 Skill，检查两侧字段，在 [ChinookInvoiceLineJoinQueryModel.qm](../resources/lab/models/query/ChinookInvoiceLineJoinQueryModel.qm) 中定义：

```javascript
const invoice = loadTableModel('ChinookInvoiceModel');
const line = loadTableModel('ChinookInvoiceLineModel');

export const queryModel = {
    name: 'ChinookInvoiceLineJoinQueryModel',
    caption: '账单与明细直接关联',
    description: '在一个 QM 中关联账单和明细 TM；有明细时一行一条明细，账单金额会重复，不能直接累计。',
    loader: 'v2',
    model: invoice,
    joins: [invoice.leftJoin(line).on(invoice.invoiceId, line.invoiceId)],
    columnGroups: [{ caption: '账单与明细', items: [
        { ref: invoice.invoiceId },
        { ref: invoice.invoiceDate },
        { ref: invoice.customerId },
        { ref: invoice.invoiceAmount },
        { ref: line.invoiceLineId },
        { ref: line.lineAmount },
        { ref: line.salesQuantity }
    ] }],
    accesses: []
};
```

`model` 指定主 TM；`leftJoin` 保留左侧账单，`on(...)` 指定两侧账单 ID。`columnGroups` 选择这个 QM 对外公开的字段。

加载后，Codex 实时获取引擎根据代码生成的字段说明，确认账单与明细字段。Markdown 不需要提前维护。

## 第二步：查账单及明细，看清关联后的粒度

确认 QM 可用后，再发送：

> 查 2012 年的账单及明细，保留账单金额和明细金额。

Codex 指定 `ChinookInvoiceLineJoinQueryModel`，提交普通 DSL：

```json
{
  "columns": ["invoiceId", "customerId", "invoiceAmount", "invoiceLineId", "lineAmount", "salesQuantity"],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "orderBy": ["invoiceId", "invoiceLineId"],
  "limit": 1000
}
```

请求先校验、再执行，完整返回 **447 条明细**。账单 250 的金额为 **13.86**，包含 14 条明细，每条明细行上都会带着这张账单的 13.86。

关联后是一明细一行：累计明细金额为 **477.53**，直接累计重复出现的账单金额却得到 **4256.25**。第二章的重复累计，在语义模型中也一样需要处理。

## 第三步：先聚合明细，再定义关联

要保持账单粒度，一种常见方案是通过 ETL，将 `InvoiceLine` 按账单 ID 加工成汇总表，再与 `Invoice` 关联。小数据量下，额外维护汇总表、刷新任务和变更逻辑，成本未必划算。

**聚合 JOIN 是一种折中：把汇总规则定义在 QM 中，查询时先聚合明细再关联。** 它省去汇总表的维护，但仍有查询时聚合的开销；数据量或查询频率增加后，再评估预汇总方案。

按这个方案，让 Codex 新建 QM：

> 把明细先按账单汇总，再关联账单，另建一个用于对账的 QM。先不查询。

Codex 新建 [ChinookInvoiceLineAggregateJoinQueryModel.qm](../resources/lab/models/query/ChinookInvoiceLineAggregateJoinQueryModel.qm)：

```javascript
const invoice = loadTableModel('ChinookInvoiceModel');
const line = loadTableModel('ChinookInvoiceLineModel');

const detail = line
    .groupBy(line.invoiceId)
    .sum(line.lineAmount, 'detailAmount')
    .sum(line.salesQuantity, 'detailQuantity')
    .count('detailCount')
    .as('detailByInvoice');

export const queryModel = {
    name: 'ChinookInvoiceLineAggregateJoinQueryModel',
    caption: '账单与明细聚合关联',
    description: '明细先按账单 ID 汇总，再在当前 QM 中关联账单 TM，一行一张账单；按账单日期筛选，金额沿用样例原始单位。',
    loader: 'v2',
    model: invoice,
    joins: [invoice.leftJoin(detail).on(invoice.invoiceId, detail.invoiceId)],
    columnGroups: [{ caption: '账单对账', items: [
        { ref: invoice.invoiceId },
        { ref: invoice.invoiceDate },
        { ref: invoice.customerId },
        { ref: invoice.invoiceAmount },
        { ref: detail.detailAmount, name: 'detailAmount', alias: 'detailAmount' },
        { ref: detail.detailQuantity, name: 'detailQuantity', alias: 'detailQuantity' },
        { ref: detail.detailCount, name: 'detailCount', alias: 'detailCount' }
    ] }],
    accesses: []
};
```

这里的 `detail` 是在 QM 中定义的聚合关系：

- `groupBy(line.invoiceId)`：按账单 ID 收敛到一账单一行。
- `sum(...)`、`count(...)`：定义明细金额、数量和条数，结果名分别为 `detailAmount`、`detailQuantity`、`detailCount`。
- `as('detailByInvoice')`：给这份聚合关系命名，随后通过 `invoice.leftJoin(detail)` 关联。

两个 TM 保持原样，聚合关系随新 QM 加载，由引擎在查询时展开。

**这里的 `groupBy` 发生在 JOIN 之前。** 查询 DSL 的 `groupBy` 则作用于关联结果；若账单金额已经随明细重复，外层分组求和仍会重复累计。

## 第四步：使用新 QM 查询与核对

新 QM 校验、加载后，再发送：

> 用对账 QM 查询 2012 年每张账单的金额、明细合计和条数。

Codex 实时获取新 QM 的元数据，确认聚合字段已经公开，再提交：

```json
{
  "columns": ["invoiceId", "customerId", "invoiceAmount", "detailAmount", "detailQuantity", "detailCount"],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "orderBy": ["invoiceId"],
  "limit": 100
}
```

本次返回 **83 张账单**，每张账单金额与明细合计都一致。以账单 250 为例：

| 账单 ID | 账单金额 | 明细合计 | 销售数量 | 明细条数 |
| ---: | ---: | ---: | ---: | ---: |
| 250 | 13.86 | 13.86 | 14 | 14 |

全年核对结果：

| 核对项 | 结果 |
| --- | ---: |
| 账单金额合计 | 477.53 |
| 明细金额合计 | 477.53 |
| 账单数 | 83 |
| 明细条数／销售数量 | 447／447 |

现在两侧都是一账单一行，可以分别累计账单金额和明细合计。两列用于对账，不能相加当作新的销售额。

如果还要按客户统计，可以在这个 QM 上继续用普通 DSL，按 `customerId` 分组，分别求和 `invoiceAmount` 和 `detailAmount`。本次得到 **47 位客户**，两列金额逐客户一致；完整请求见[按客户对账示例](../resources/lab/queries/chapter-14-qm-join/05-aggregate-customer.json)。

## 完成后检查什么

重点检查：**关联键是否正确，右侧聚合键是否唯一，结果行数与两侧金额能否对回原始记录。**

日期仍按账单日期筛选，金额沿用 Chinook 原始单位。若换成订单与支付，订单日期和支付日期往往不同，不能直接照搬本例口径。

正常样例的账单都有明细。另用测试模型将右侧筛空时，83 张左侧账单仍保留，右侧字段未返回；这类空值不能直接解释成金额为零。

本章使用 Launcher **0.1.30**、CLI **0.1.25**，10 项单 QM 查询结果与只读原始记录、`Decimal` 独立核对一致；错误字段在查询校验时被拒绝。当前版本的聚合关系仅支持 LEFT JOIN，改用 INNER JOIN 会在模型校验时明确拒绝。原账单、明细与月度查询的三项回归也通过。

完整模型、实时元数据、DSL、解释结果和复跑方式见[第十四章实操记录](../resources/lab/chapter-14-qm-join-notes.md)。

## 本章 Foggy 知识点

- **一个 QM 关联多个 TM：** 用 `model` 指定主 TM，`joins` 声明关联，`columnGroups` 公开字段。
- **聚合关系：** 在 QM 中用 `groupBy`、`sum`、`count`、`as` 定义右侧，再 JOIN 到主 TM。
- **统一查询面：** AI 选择这个 QM，按实时元数据提交普通 DSL，无须每次重写关联和预聚合规则。
- **粒度核对：** 关联前聚合与关联后汇总是两个阶段，模型能执行不等于金额就能随意累计。

下一章讲完善语义模型后，怎样确认已有问题仍能答对。


---

<a id="chapter-15"></a>

# 第 15 章 用字典连接业务名称与数据取值

第九章查询“美国”时，先确认数据库里存的是 `USA`。这种稳定的取值与名称对应关系，可以用字典写进语义层模型，减少 AI 每次猜测。

本章给账单国家增加中文字典，不改数据库记录，也不改变账单金额的计算。

## 第一步：定义字典，先不查询

在原练习目录和会话中发送：

> 给账单国家增加中文名称字典，保留原始值，校验并加载。先不查询。

Codex 根据 `Invoice.BillingCountry` 的真实取值整理中文名称，交人审核。样例共有 24 种国家取值，全部纳入字典；2012 年有账单的是其中 20 国。

在模型目录新增 `dicts.fsscript`，以下节选两项，完整内容见[字典源码](../resources/lab/models/dicts.fsscript)：

```javascript
import { registerDict } from '@jdbcModelDictService';

export const dicts = {
    billingCountry: registerDict({
        id: 'chinook_billing_country', caption: 'Chinook 账单国家',
        items: [
            { value: 'USA', label: '美国' },
            { value: 'Canada', label: '加拿大' }
            // 其他已确认的国家见完整文件
        ]
    })
};
```

`value` 是数据库中的原始值，`label` 是对应的业务名称。显示中文不需要修改数据库记录。

在 `model/ChinookInvoiceModel.tm` 文件开头导入字典，再给已有的 `billingCountry` 属性增加 `dictRef`：

```javascript
import { dicts } from '../dicts.fsscript';

export const model = {
    name: 'ChinookInvoiceModel',
    // 其他配置省略
    properties: [
        // 其他属性省略
        { column: 'BillingCountry', name: 'billingCountry', caption: '账单国家', type: 'STRING',
          dictRef: dicts.billingCountry,
          description: '账单地址中的国家，不是客户地址国家。' }
    ]
};
```

账单 QM 已有 `{ ref: invoice.billingCountry }`，保留即可。完整 TM 见[账单模型源码](../resources/lab/models/model/ChinookInvoiceModel.tm)。

## 第二步：确认实时元数据

模型校验、刷新后，Codex 通过 CLI 实时获取 `ChinookInvoiceQueryModel` 的说明，确认字典已生效。以下是引擎生成的 Markdown 节选：

```markdown
| 字段名 | 名称 | 类型 | 说明 |
|--------|------|------|------|
| billingCountry | 账单国家 | 文本 | 账单地址中的国家，不是客户地址国家。 (字典:chinook_billing_country) |
| billingCountry$caption | 账单国家(名称) | 文本 | 字典 label；仅支持等值/集合过滤，不支持排序 |

## 字典定义
| ID | 名称 | 取值 |
|----|------|------|
| chinook_billing_country | Chinook 账单国家 | Argentina=阿根廷, …, USA=美国, United Kingdom=英国 |
```

这份 Markdown 由引擎根据已加载代码自动生成，查询时实时获取，无须提前编写。上面省略了部分字典取值，完整返回见[实时 Markdown](../resources/lab/evidence/basic-supplements/15-metadata.md)。

现在，AI 既能看到 `USA=美国`，也知道可用 `billingCountry$caption` 取得名称。这里的名称来自字典；第十二章的维度名称则来自关联表。

## 第三步：查询原值与名称

确认元数据中已有字典和名称字段，再发送：

> 查一下 2012 年美国的账单，同时显示国家原值和中文名称。

Codex 构造请求：

```json
{
  "columns": ["invoiceId", "billingCountry", "billingCountry$caption"],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]},
    {"field": "billingCountry", "op": "=", "value": "USA"}
  ],
  "orderBy": ["invoiceId"],
  "limit": 100
}
```

先校验、再执行，返回 21 张账单。例如账单 255 的 `billingCountry` 为 `USA`，`billingCountry$caption` 为“美国”。

### 中文标签能直接筛选吗

在当前 Launcher 0.1.30 中，这两种写法也已验证，返回相同的 21 张账单：

```json
{"field": "billingCountry", "op": "=", "value": "美国"}
```

```json
{"field": "billingCountry$caption", "op": "=", "value": "美国"}
```

两种写法最终都转换为参数 `USA`，转换由引擎依据字典完成。`billingCountry` 的 `in` 值写为 `["美国", "加拿大"]` 也已验证，返回共 30 张账单。本章主示例使用原始值，方便核对；标签筛选按元数据支持的操作符使用。

字典只转换已收录的名称。例如“美利坚”未收录，用它等值筛选时，当前版本校验、执行成功，但返回空列表。AI 应先核对字典，必要时请用户确认，不能据此回答“美国没有账单”。

## 完成后检查什么

重点检查：**字典是否对应真实取值，定义是否进入实时元数据，筛选与显示是否符合预期。**

本章 7 项查询均与只读原始记录核对；原有 7 项查询回归结果一致，错误字段仍被拒绝，数据库未修改。

步骤与证据见[基础功能补充记录](../resources/lab/basic-supplements-notes.md)。本次由当前 Codex 会话实际调用 CLI，未另开干净会话验收短提示词。

## 本章 Foggy 知识点

- **字典定义：** `registerDict` 维护稳定的 value/label 对应，通过 TM 属性的 `dictRef` 引用。
- **实时上下文：** 引擎自动提供字典定义与名称字段，减少 LLM 对业务取值的猜测。
- **查询与显示：** 原值用于稳定核对，`$caption` 显示名称；标签筛选按支持范围转换，未收录名称不能擅自解释。

下一章用模型维护与旧问题回归，为基础篇收尾。


---

<a id="chapter-16"></a>

# 第 16 章 模型维护与自动化回归

前面的查询已经逐项核对过。本章让 Codex 把它们写成自动化回归脚本，以后改完模型，直接复跑，减少临时重写请求和人工核对的遗漏。

## 第一步：让 Codex 生成回归脚本

在原练习目录和会话中发送：

> 把前面核对过的问题写成 Python 回归脚本，覆盖数值、国家字典和错误请求。先不运行。

Codex 整理查询请求与预期，再编写脚本。本章已有可运行的[脚本](../resources/lab/checks/run-chapter-16-regression.py)和[题库](../resources/lab/queries/chapter-16/question-bank.json)，主要检查：

| 范围 | 自动断言 |
| --- | --- |
| 总额与计数 | 2012 年金额 477.53、83 张账单、47 位购买客户 |
| 平均、月份与关联 | 均额按账单数计算；月度合计及账单/明细金额能对回全年 |
| 原值与中文标签 | USA 与“美国”命中同一批 21 张账单，合计 127.98 |
| 名称列与集合 | 原值 USA 对应“美国”；美加集合命中 30 张账单 |
| 空结果 | 未收录名称返回空列表；无账单期间均额不补零 |
| 错误请求 | 拼错字段返回 FIELD_NOT_FOUND，拒绝后不继续执行 |

预期来自前面独立核对的数据，不是脚本运行后，再让 AI 看着结果判断“应该没问题”。题库里的问题用于说明用例，真正复跑的是固定 DSL 与断言。

### 生成的脚本做什么

本例使用 Python 标准库调用 Foggy CLI，不需要另外安装测试框架。一轮运行包括：

1. **确认环境。** 获取 capabilities 和实时元数据，保存当前源码、请求及数据摘要。
2. **执行测试。** 每条请求先 `query validate`，通过后才 `query execute`；预期拒绝的用例检查错误码。
3. **校验、刷新模型。** 调用 `models validate`、`models refresh`，重新获取实时元数据。
4. **复跑并断言。** 使用相同请求，检查两次结果，并与只读原始记录、`Decimal` 计算独立核对。
5. **保存证据。** 每次创建独立目录，留下响应、命令与验收报告；失败时非零退出。

下面节选脚本中的字典断言，`results` 是本轮各用例的返回行：

```python
# 中文标签筛选必须与原值筛选得到同一批记录
assert results['09-dict-label'] == results['03-usa']

# 返回名称必须与已确认的字典对应
assert all(
    row['billingCountry$caption'] == '美国'
    for row in results['10-dict-display']
)

# 未收录名称的当前行为也是回归检查的一部分
assert results['13-dict-unknown'] == []
```

完整脚本先检查行数和 ID，避免空列表也通过 `all(...)`；金额比较允许微小浮点误差。

## 第二步：运行脚本，留下基线

发送：

> 运行回归脚本，保存报告，有失败先定位原因。

Codex 在项目根目录执行：

```text
python lab/checks/run-chapter-16-regression.py
```

脚本适用于前面已经准备好的教学环境，不负责安装或启动服务。它会校验、刷新现有模型，不修改模型源码或数据库。实时 Markdown 仍由引擎根据已加载代码生成，脚本只获取和检查。

本轮在 Launcher **0.1.30**、CLI **0.1.25** 上实际运行。校验刷新前后各 **12 项查询通过、1 项错误字段按预期拒绝**，结果一致，模型资源与数据库摘要未变。均额说明、24 项国家字典与名称字段也都正确。

证据目录见[最近一次报告](../resources/lab/evidence/chapter-16/latest.json)，具体步骤见[实操记录](../resources/lab/chapter-16-notes.md)。脚本遇到首个失败会停止，按报错和用例编号查看对应响应。最近一次验收报告只在成功时更新，失败时应查看本次输出，避免误读旧报告。

## 第三步：维护模型后，再跑同一脚本

以 `averageAmount` 为例，如果“平均账单金额”的说明还不明确，可以发送：

> 把平均账单金额的说明写清楚，和人均消费区分开，字段名和公式不变。先不查询。

这次只完善分母说明，公式仍为：

```text
sum(invoiceAmount) / nullif(count(invoiceId), 0)
```

修改完成后，再发送：

> 再运行回归脚本，检查旧查询是否仍然正确。

只改说明，原有数值和公开字段应保持兼容；改字典，则重点检查取值转换、名称显示和未收录名称；改公式或统计范围，先确认新的业务定义与预期，再调整断言并记录原因。

当前模型已经具备完善后的均额说明，因此本轮没有再次修改源码。实际完成的是当前模型在校验刷新前后的复验；早先的说明修改过程保留在[原维护记录](../resources/lab/chapter-15-notes.md)。

注意，本脚本每轮比较的是当前模型刷新前后的结果，不会自动比较两次运行之间的源码变更。维护后的正确性由固定业务断言检查，历史报告用于追溯。

## 自动化测试覆盖到哪里

这套脚本验证固定请求下的模型与查询行为。“客单价是多少”是否会主动澄清、“利润是多少”是否会如实说明缺少成本，仍是两项未执行的回答审阅题，不能因为脚本通过就算 AI 也通过。

后续在线问数时，再沿用这些预期，单独验收自然语言理解、追问与最终回答。本轮由当前 Codex 会话实际调用 CLI，未另开干净会话测试短提示词。

## 本章 Foggy 知识点

- **自动化回归：** 把已核对的 DSL、预期结果和错误行为保存为可重复执行的测试。
- **模型维护：** 校验、刷新后实时读取元数据，再检查定义和查询结果。
- **字典与边界：** 同时断言数值、ID、名称转换、空结果与预期拒绝。
- **分层验收：** 脚本验证固定查询，AI 的理解与回答另外验收。

基础篇到这里收尾。留下的除了语义模型，还有一套维护后可以重复运行的测试。


---

<a id="chapter-17"></a>

# 第 17 章 时间分析：比较、累计与滚动窗口

第十一章已经能按月汇总。本章继续问：十一月比十月怎样，今年累计有多少，最近七天又是多少？沿用 Chinook 和 `ChinookInvoiceTimeQueryModel`，先确认时间口径，再让引擎计算。

本系列使用 Launcher **0.1.38 预发布版**、CLI **0.1.25**，见[环境与复跑](../resources/lab/time-series/README.md)。本章沿用已有模型，从查询开始。

## 先确认时间模型

在原练习目录和会话中发送：

> 检查账单时间模型的日期、年月和金额字段，先不查询。

Codex 实时读取模型说明，确认 `invoiceDate$id` 是账单日期，`invoiceDate$year`、`invoiceDate$month` 用于年月分组，`invoiceDate$yearMonth` 表示 YYYY-MM，`invoiceAmount` 默认求和。

日期沿用记录值，不自行转换时区；金额用样例原始单位。Markdown 由引擎根据已加载代码自动生成，查询时实时获取。

## 一、同比与环比：先认准比较对象

发送：

> 看看 2012 年十月、十一月的账单金额，以及环比变化。

Codex 使用 `timeWindow`：

```json
{
  "columns": [
    "invoiceDate$year",
    "invoiceDate$month",
    "invoiceAmount",
    "invoiceAmount__prior",
    "invoiceAmount__diff",
    "invoiceAmount__ratio"
  ],
  "groupBy": ["invoiceDate$year", "invoiceDate$month"],
  "orderBy": ["invoiceDate$year", "invoiceDate$month"],
  "timeWindow": {
    "field": "invoiceDate$id",
    "grain": "month",
    "comparison": "mom",
    "range": "[)",
    "value": ["2012-10-01", "2012-12-01"],
    "targetMetrics": ["invoiceAmount"]
  },
  "limit": 500
}
```

`mom` 比较上月；`__prior` 是上期金额，`__diff` 是本期减上期，`__ratio` 是差额除以上期金额。先校验、再执行，得到：

| 月份 | 本期金额 | 上月金额 | 差额 | 环比变化 |
| --- | ---: | ---: | ---: | ---: |
| 2012-10 | 42.62 | 46.71 | −4.09 | −8.76% |
| 2012-11 | 37.62 | 42.62 | −5.00 | −11.73% |

`value` 与 `[)` 限定当前输出为十月、十一月，引擎还会读取比较期，所以十月可以取得九月金额。额外的原始记录过滤可能截掉上期数据，累计和滚动时也要留意。

再发送：

> 再看这两个月的同比。

同一请求改为 `comparison: "yoy"`，比较上年同月。十一月上年同期是 **23.76**，增加 **13.86**，同比约 **58.33%**。比例由引擎计算，LLM 说明比较对象，展示百分数时才乘以 100。

跨年环比也已核对：2012 年一月与 2011 年十二月均为 **37.62**，变化为 0。没有上年记录时，上期、差额和比例为空或省略，不能当成零。若上期确实为零，增长比例需要按业务口径解释；本轮未另造零上期样例测试。

## 二、累计：展示范围不等于累计起点

比较两期能看增减，累计则回答“从期间起点到现在一共有多少”。

发送：

> 看看 2012 年十月、十一月的账单金额和年内累计。

YTD 从年初累计到当前期间。请求只需选择年、月和指标，累计所需的内部日期依赖由引擎处理：

```json
{
  "columns": ["invoiceDate$year", "invoiceDate$month", "invoiceAmount", "invoiceAmount__ytd"],
  "groupBy": ["invoiceDate$year", "invoiceDate$month"],
  "orderBy": ["invoiceDate$year", "invoiceDate$month"],
  "timeWindow": {
    "field": "invoiceDate$id",
    "grain": "month",
    "comparison": "ytd",
    "range": "[)",
    "value": ["2012-10-01", "2012-12-01"],
    "targetMetrics": ["invoiceAmount"]
  },
  "limit": 500
}
```

实际返回两个月，仍保持每月一行：

| 月份 | 当月金额 | 年内累计 |
| --- | ---: | ---: |
| 2012-10 | 42.62 | 402.29 |
| 2012-11 | 37.62 | 439.91 |

引擎保留年初以来的计算输入，再按 `value` 只展示十月、十一月。

### 月内累计：MTD

发送：

> 看看十一月下半月各交易日的账单金额和月内累计。

改用日粒度和 `mtd`：

```json
{
  "columns": ["invoiceDate$id", "invoiceAmount", "invoiceAmount__mtd"],
  "groupBy": ["invoiceDate$id"],
  "orderBy": ["invoiceDate$id"],
  "timeWindow": {
    "field": "invoiceDate$id",
    "grain": "day",
    "comparison": "mtd",
    "range": "[)",
    "value": ["2012-11-15", "2012-12-01"],
    "targetMetrics": ["invoiceAmount"]
  },
  "limit": 500
}
```

实际返回：

| 日期 | 当日金额 | 月内累计 |
| --- | ---: | ---: |
| 2012-11-27 | 3.96 | 27.72 |
| 2012-11-28 | 3.96 | 31.68 |
| 2012-11-29 | 5.94 | 37.62 |

虽然只展示 15 日以后，累计仍从月初计算。没有交易的日期不会自动补行，所以本次只有三个交易日。

这里要分清两层：**`timeWindow.value` 控制展示范围，`slice` 限制可参与计算的原始数据。** 若用 `slice` 先排除十一月以前的记录，再做 YTD，剩下的数据无法代表全年累计。权限范围同样不能为补历史而放宽。

全年与跨年/跨月累计也已核对：2012 年末累计为 **477.53**，YTD 按年、MTD 按月重新开始。

## 三、LAG：取上一行，不一定是上个月

有时只需要取得有序结果的上一行，例如在月度汇总后继续计算，这可以用 LAG。

发送：

> 按月汇总 2012 年金额，再显示上一行金额。

Codex 保留第十一章的月度查询，先产生 `sum(invoiceAmount) as totalAmount`，再把 `previousAmount` 加入返回列，并增加：

```json
{
  "calculatedFields": [
    {
      "name": "previousAmount",
      "expression": "lag(totalAmount, 1)",
      "windowOrderBy": [{"field": "invoiceDate$yearMonth", "dir": "asc"}]
    }
  ]
}
```

这是月度请求的补充片段。按年月排序后，十一月取得十月的 **42.62**；一月前面没有结果行，因此为空，不会自动读取去年十二月。

若有意只保留九月、十一月，十一月的上一行便是九月 **46.71**。这用筛选制造了结果缺口，说明比较自然月与读取上一行是两种需求：前者用环比，后者用 LAG。

`lag(sum(invoiceAmount), 1)` 的嵌套写法会被校验拒绝，提示先给聚合结果起别名。按诊断调整后重新校验，不继续执行被拒绝的请求。

## 四、滚动窗口：calendar 与 observed

需要汇总最近一段数据时，可以用滚动窗口。窗口按自然日还是实际数据桶计算，由 `rollingMode` 区分。

本例先按 `day` 汇总，每个有账单的日期是一桶；同一天多张账单仍只算一个日期桶。

| 模式 | 窗口口径 |
| --- | --- |
| `calendar` | 包含当天的 N 个自然日；近七天为 D−6 至 D，本章使用 day 粒度 |
| `observed` | 当前桶及之前最多 N−1 个实际数据桶，可能跨越更长的日历范围 |

### 查询七个自然日

发送：

> 查十一月下半月各交易日的账单金额，以及含当天的近七个自然日合计。

Codex 显式选择 `calendar`：

```json
{
  "columns": ["invoiceDate$id", "invoiceAmount", "invoiceAmount__rolling_7d"],
  "groupBy": ["invoiceDate$id"],
  "orderBy": ["invoiceDate$id"],
  "timeWindow": {
    "field": "invoiceDate$id",
    "grain": "day",
    "comparison": "rolling_7d",
    "range": "[)",
    "value": ["2012-11-15", "2012-12-01"],
    "targetMetrics": ["invoiceAmount"],
    "rollingMode": "calendar"
  },
  "limit": 500
}
```

本次只返回 27、28、29 日。

### 改成最近七个交易日期桶

再发送：

> 改成最近七个有账单日期的合计，其他条件不变。

只将 `rollingMode` 改为 `observed`，其他字段、范围与数据保持相同：

| 日期 | 当日金额 | calendar：七个自然日 | observed：七个交易日期桶 |
| --- | ---: | ---: | ---: |
| 2012-11-27 | 3.96 | 3.96 | 41.58 |
| 2012-11-28 | 3.96 | 7.92 | 41.58 |
| 2012-11-29 | 5.94 | 13.86 | 43.56 |

以 27 日为例，同一截止日的两种窗口如下：

![2012年11月27日：calendar取11月21至27日，合计3.96；observed取最近七个有账单的日期桶，合计41.58。日期按自然日等距排列。](../assets/time-analysis/17-calendar-vs-observed.png)

observed 取的是交易日期桶，不能称作“近七天”或“最近七张账单”。

省略模式时默认使用 observed，并在 `data.warnings` 返回 `TIMEWINDOW_OBSERVED_DEFAULT`。查询显式写出模式，避免把默认值当成业务约定。

近三十个自然日可使用同一结构，将 `comparison` 和返回后缀改为 `rolling_30d`，保留 `calendar`；11 月 27 日合计为 **33.66**，也已独立核对。

### 滚动平均和计数，平均的是什么

同一请求可以设置 `rollingAggregator: "avg"` 或 `"count"`。11 月 27 日的 calendar 七日窗口只有一个有金额的日期桶，AVG 为 **3.96**、COUNT 为 **1**；observed 七桶的 AVG 为 **5.94**、COUNT 为 **7**。

这里 AVG 平均的是已经按日汇总的金额，COUNT 数的是非空指标桶，均不是原始账单统计。即使使用 calendar，缺失日期也没有自动补零，不能把 AVG 叫作完整七天的日均。要算完整日均，先按第十八章确认日历和缺失处理。

## 五、把期间口径放进回归脚本

沿用第十六章的做法，发送：

> 把这些时间分析加入回归脚本，核对日期范围、金额和空值。

除了金额，断言还应检查：比较取的是哪一期，累计有没有保留期间起点，LAG 的首行是否为空，以及 calendar/observed 是否按各自口径计算。预期通过只读原始记录与 `Decimal` 独立核算。

本章完整 JSON 示例及主要变体已在 0.1.38 教学环境复跑，金额与原始账单基线一致。[当前验证](../resources/lab/teaching-launcher-0.1.38-upgrade-2026-10-23.md)保存请求、响应和复跑入口，旧版完整初验与故障记录仍保留在 lab。

## 本章 Foggy 知识点

- **期间比较：** mom/yoy 与 prior/diff/ratio，分清本期、上期及变化比例。
- **期间累计：** YTD、MTD 的期间重置，区分展示范围与源数据筛选。
- **有序结果：** 先汇总再 LAG，上一行不等于上个自然期间。
- **滚动模式：** calendar 按自然日，observed 按实际数据桶，显式指定口径并检查警告。
- **自动化核对：** 同时断言日期、金额、空值和预期拒绝，不能只看执行成功。


---

<a id="chapter-18"></a>

# 第 18 章 业务日期、日历与可比期间

上一章确定怎样计算，本章补齐计算前的业务条件：按哪个日期统计，没有记录的日期怎么办，两段时间怎样才算可比？

主例是：**截至 2012 年 11 月 15 日，账单金额比上月同期怎样？** 口径先确定，再交给引擎计算。

沿用 Launcher **0.1.38 预发布版**、CLI **0.1.25**，本章另建日历教学副本。

## 第一步：准备日历与语义模型，先不查询

在原练习目录发送：

> 用 Chinook 准备本章的独立教学副本和日期、日历模型。报告统计到 2012 年 11 月 15 日，先不查询。

Codex 用[样例准备脚本](../resources/lab/checks/prepare-chapter-18.py)生成独立数据库，再编写模型，放在 `lab/chapter-18`，接入 Namespace `chinook-calendar`。原 Chinook 数据和模型保持不变。

样例包含两部分：

| 数据 | 来源与约定 |
| --- | --- |
| 账单 | 复制 Chinook 的账单 ID、日期和金额；额外生成“账单发生后两天”的模拟入库时间，仅用于日期选择对照，**不是付款时间** |
| 日历与完整性 | 连续自然日；周一至周五标为教学工作日，不含法定节假日规则。另设报表完整性声明：11 月 15 日及以前已确认，之后未确认 |

实际项目的完整性声明应由数据同步、对账或业务确认提供，不能根据“有没有记录”推断。统计截止日也不等于报告生成日。

本章创建四个文件，完整源码可直接查看：

| 文件 | 作用 |
| --- | --- |
| [TeachingInvoiceDatesModel.tm](../resources/lab/chapter-18/models/model/TeachingInvoiceDatesModel.tm) | 账单日期、模拟入库时间及金额 |
| [TeachingInvoiceDatesQueryModel.qm](../resources/lab/chapter-18/models/query/TeachingInvoiceDatesQueryModel.qm) | 公开两种时间与账单指标 |
| [TeachingReportCalendarModel.tm](../resources/lab/chapter-18/models/model/TeachingReportCalendarModel.tm) | 日期、年月、周、工作日序和报表完整性 |
| [TeachingCalendarReportQueryModel.qm](../resources/lab/chapter-18/models/query/TeachingCalendarReportQueryModel.qm) | 日历为主表，关联按日汇总的账单，定义报表金额 |

两种时间都作为普通日期属性，放在 `TeachingInvoiceDatesModel.tm` 的 `properties` 中：

```javascript
{
    column: 'InvoiceDate', name: 'invoiceDate', caption: '账单发生时间', type: 'DATETIME',
    timeRole: 'business_date', recommendedUse: '按账单发生时间统计业务金额'
},
{
    column: 'LoadedAt', name: 'loadedAt', caption: '模拟入库时间', type: 'DATETIME',
    timeRole: 'system_time',
    recommendedUse: '仅按模拟入库时间统计，不得代替账单发生时间或付款时间'
}
```

`timeRole` 标明时间角色，`recommendedUse` 说明用途；字段的完整描述见源码。这里显式声明，避免依赖字段名称推断。

关键关联沿用第十四章的聚合 JOIN：

```javascript
const calendar = loadTableModel('TeachingReportCalendarModel');
const invoice = loadTableModel('TeachingInvoiceDatesModel');
const daily = invoice.groupBy(invoice.invoiceDay)
    .sum(invoice.invoiceAmount, 'dayAmount')
    .count('dayCount').as('invoiceByDay');

export const queryModel = {
    // 其他配置见完整源码
    model: calendar,
    joins: [calendar.leftJoin(daily)
        .on(calendar.calendarDate, daily.invoiceDay)]
};
```

日历每天一行，账单先按日汇总，所以关联后仍是每天一行。反过来从账单出发关联日历，不能凭空补出没有账单的日期。

QM 将日汇总公开为 `rawAmount`，并在 `columnGroups` 中固定报表金额的规则：

```javascript
{
    name: 'reportedAmount', alias: 'reportedAmount',
    caption: '已确认账单金额', type: 'MONEY',
    formula: 'if(dataComplete == 1, coalesce(rawAmount, 0), null)'
}
```

`dataComplete` 为 1 时保留金额，无账单则补零；未确认时保留空值。

Codex 校验四个文件、登记模型并刷新，再实时获取引擎生成的 Markdown，无须提前生成或维护。本次 JSON 与 Markdown 均保留了两种时间的角色和用途，后面的字段名按实际元数据使用。

## 第二步：先选对日期

先用完整样本中的十一月演示日期差异，暂不套用后面的报告截止。发送：

> 分别按账单日期和模拟入库时间，统计 2012 年十一月的账单金额。

Codex 在 `TeachingInvoiceDatesQueryModel` 中，分别用 `invoiceDate` 和 `loadedAt` 筛选十一月。角色与说明帮助 AI 选字段，实际查询仍要引用正确日期。

| 统计日期 | 十一月金额 | 账单数 |
| --- | ---: | ---: |
| 账单日期 | 37.62 | 7 |
| 模拟入库时间 | 31.68 | 6 |

例如账单 **325** 的金额为 **5.94**：账单日期是 11 月 29 日，模拟入库时间是 12 月 1 日。两种查询都能正确执行，回答的却是不同问题。

下单、开票、付款同样如此。引擎不会因为它们都是日期就知道该选哪一个；没有付款数据，也不能拿入库时间凑数。

## 第三步：没有账单，不一定是零

发送：

> 把十一月的账单金额按日列全，区分没有账单和数据尚未确认。

Codex 改查 `TeachingCalendarReportQueryModel`。主要字段为：

| 字段 | 含义 |
| --- | --- |
| `calendarDate` | 日历日期 |
| `dataComplete` | 此日的数据是否已确认完整，1 为是，0 为未确认 |
| `rawAmount` | 账单按日汇总的原始金额；没有匹配记录时为空 |
| `reportedAmount` | 已确认的报表金额：完整且无账单时为零，未确认时为空 |

补零规则已经固定，查询只需选择字段。下面是实际请求，用 1—17 日看清边界：

```json
{
  "columns": ["calendarDate", "dataComplete", "rawAmount", "reportedAmount"],
  "slice": [
    {"field": "calendarDate", "op": "[)", "value": ["2012-11-01", "2012-11-18"]}
  ],
  "orderBy": ["calendarDate"],
  "limit": 50
}
```

结果节选，空值统一记作“未确认”或“无记录”：

| 日期 | 已确认完整 | 原始金额 | 报表金额 |
| --- | ---: | ---: | ---: |
| 11 月 1 日 | 1 | 8.91 | 8.91 |
| 11 月 2 日 | 1 | 无记录 | 0 |
| 11 月 14 日 | 1 | 0.99 | 0.99 |
| 11 月 15 日 | 1 | 无记录 | 0 |
| 11 月 16 日 | 0 | 无记录 | 未确认 |

即使未确认日已有部分记录，也不能把它当作完整日总额。本次还检查了 11 月 27—29 日：原始样本有金额，但按教学完整性声明，报表金额仍为空。

日历表提供连续日期，完整性声明决定能不能补零；第十七章的 `calendar` 滚动窗口只规定自然日范围，**不会自动补齐日期或补零**。

## 第四步：比较同样长的期间

发送：

> 截至 2012 年 11 月 15 日，账单金额比上月同期怎样？

这里先约定“上月同期”为 **10 月 1—15 日**，比较 **11 月 1—15 日**。如果业务要比相同工作日数，需要另行说明。

Codex 先检查两段日历各有 15 天、也各有 15 天已确认完整，再查询账单 QM：

```json
{
  "columns": ["invoiceMonth", "sum(invoiceAmount) as amount", "count(invoiceId) as bills"],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-10-01", "2012-11-16"]},
    {"field": "invoiceDayOfMonth", "op": "<=", "value": 15}
  ],
  "groupBy": ["invoiceMonth"],
  "orderBy": ["invoiceMonth"],
  "limit": 5
}
```

范围写成左闭右开，11 月 16 日是排除的上界；月内日序条件同时去掉十月后半月。

| 期间 | 金额 | 账单数 |
| --- | ---: | ---: |
| 10 月 1—15 日 | 28.76 | 3 |
| 11 月 1—15 日 | 23.76 | 3 |

差额 **−5.00**，变化约 **−17.39%**。[差额与比例请求](../resources/lab/chapter-18/queries/06-period-difference.json)通过条件聚合交给引擎计算，沿用第十章的方法，AI 负责说明比较范围。

如果拿十一月前十五天和十月整月的 **42.62** 比，会得到约 **−44.25%**。算术没有错，但不能称为同口径的同期变化。

还有一个容易漏掉的问题：SQL 的 SUM 通常忽略空值。查询十一月整月，已知金额仍会得到 **23.76**，但日历显示 **30 天中只有 15 天已确认**。它只能叫“已确认部分合计”，不能说是十一月总额。完整性需要单独检查，不能只看返回了一个数字。

## 工作日、跨年周与其他边界

十一月前十五天在教学日历中有 11 个工作日。继续发送：

> 改为比较两个月的前 11 个工作日，只统计这些日期发生的账单。

Codex 在日历 QM 中筛选 `isWorkday = 1`、`workdayIndex <= 11`，得到十月 **10.91**、十一月 **23.76**，各 11 天。十月 6 日的周末账单被排除，因此结果和自然日比较不同。

工作日比较会排除周末账单，适不适合要看业务；不能据此推断音乐商店周末不营业。企业使用时，应提供实际营业、调休或节假日日历。

本章另核对了 2 月 29 日，以及 **2012 年 12 月 31 日属于 ISO 2013 年第 1 周**。周年与周号在准备日历时确定，查询直接使用已定义字段，不能只拼“自然年＋周号”。财年、财季、月末短月和闰日也应先确定规则，不能自动套自然年或随手减 365 天。

“最近七天”和“本周”不是同一个范围。历史练习固定日期，实际问数则先确认报告基准日、数据更新截止及业务时区；营业日跨午夜时，日归属应在数据准备或语义模型中统一。本章不展开跨时区转换和财务日历实现。

## 留下可复跑的核对

日历关联后的 2012 年仍为 **366 天、83 张账单、477.53**，没有因补齐或关联重复累计。日期角色、完整性与期间比较的代表查询已在 0.1.38 复跑，见[当前验证](../resources/lab/teaching-launcher-0.1.38-upgrade-2026-10-23.md)；[初验记录](../resources/lab/chapter-18-notes.md)保留逐项独立核对。

## 本章 Foggy 知识点

- 普通日期属性的 `timeRole`、`recommendedUse`，以及实时元数据中的业务用途。
- 日历作为主表，账单先按日汇总再 LEFT JOIN，保留无业务日期。
- QM 固定公式统一处理已确认零值和未确认空值。
- 用普通 DSL 表达可比期间；同时核对覆盖天数、完整性和金额。

引擎执行已定义的规则；日历、完整性与比较口径仍需要业务和数据准备配合。下一专题再看另一类问题：销售额可以跨日相加，余额、库存为什么通常不行？


---

<a id="chapter-19"></a>

# 第 19 章 存量与快照：月底还剩多少

前两章分析一段时间内发生了多少。本章换个问题：截至某天，库存还剩多少？销售额可以跨日累加，每天的库存再加一遍，就把同一批货数了好几次。

沿用 Launcher **0.1.38 预发布版**、CLI **0.1.25**。Chinook 没有库存历史，本章另建少量教学数据，见[实验说明](../resources/lab/time-series/README.md)。

## 第一步：准备历史与语义模型，先不查询

发送：

> 准备库存教学样例和语义模型，说明每张表一行代表什么。先不查询。

Codex 在独立的 `time-business` Namespace 中接入样例，校验、加载模型，再实时读取查询元数据。Markdown 仍由引擎根据已加载代码生成，不需要提前维护。

| 数据 | 一行代表什么 | TM / QM 源码 |
| --- | --- | --- |
| 对象目录 | 一个库存对象，A—D 共四个 | [TM](../resources/lab/time-series/models/model/TeachingStockEntityModel.tm) / [QM](../resources/lab/time-series/models/query/TeachingStockEntityQueryModel.qm) |
| 变更日快照 | 一个对象在某日开始生效的库存，延续至下一次变更 | [TM](../resources/lab/time-series/models/model/TeachingStockSnapshotModel.tm) / [QM](../resources/lab/time-series/models/query/TeachingStockSnapshotQueryModel.qm) |
| 变动流水 | 一笔增加或减少 | [TM](../resources/lab/time-series/models/model/TeachingStockMovementModel.tm) / [QM](../resources/lab/time-series/models/query/TeachingStockMovementQueryModel.qm) |
| 连续日快照 | 一个对象在一个自然日的日末库存 | [TM](../resources/lab/time-series/models/model/TeachingStockDailyModel.tm) / [QM](../resources/lab/time-series/models/query/TeachingStockDailyQueryModel.qm) |

变更日记录很少，直接列出来就能核对：

| 对象 | 生效日 | 库存 |
| --- | --- | ---: |
| A | 2024-01-01 | 100 |
| A | 2024-01-03 | 80 |
| A | 2024-01-05 | 120 |
| B | 2024-01-01 | 50 |
| B | 2024-01-04 | 40 |
| C | 2024-01-04 | 30 |

D 没有历史；C 在 4 日以前的状态未知。连续日快照由[准备脚本](../resources/lab/checks/prepare-time-series.py)按上述延续规则展开，未知保留为空。这是数据准备工作，不是引擎自动恢复了缺失历史。

## 第二步：取每个对象的期末记录

发送：

> 看看截至 1 月 4 日日末各对象的库存，没有历史的也列出来。

Codex 从 `TeachingStockSnapshotQueryModel` 找每个对象 **1 月 5 日之前最大的生效日**，按对象与日期取回对应库存，再以 `TeachingStockEntityQueryModel` 的完整目录为左侧做 LEFT JOIN，保留没有历史的对象。

后一步使用前一步的结果，这就是分阶段查询。[实际 Compose 脚本](../resources/lab/time-series/queries/19-asof-jan4.fsscript)由引擎执行，完整语法留给组合分析系列。先看 A 的库存怎样随记录生效：

![A商品库存从1月1日的100变为3日的80，再变为5日的120。查询4日日末，采用3日记录的80，5日的120不参与。](../assets/time-analysis/19-stock-as-of-step.png)

各对象结果为：

| 对象 | 实际采用的记录日 | 库存 |
| --- | --- | ---: |
| A | 2024-01-03 | 80 |
| B | 2024-01-04 | 40 |
| C | 2024-01-04 | 30 |
| D | 无 | 未知 |

已知部分合计 **150**，覆盖 3/4 个对象，不能称作全部库存。改查 5 日日末，A 变为 120，已知合计 **190**，D 仍然未知。

若截止前完全没有历史，目录中的四个对象仍会返回，库存均为空；不能补成四个零。

两个常见误区：`MAX(quantity)` 找到的是最大库存，不是最新库存；只取全表最大日期，又会漏掉当天没有变更的 A。若同一对象同一时刻允许多条记录，还得先约定版本或顺序，本例用唯一键避免这个歧义。

## 第三步：用流水核对期末

发送：

> 用 A、B 的期初和变动流水，核对 1 月 5 日日末库存。

两者期初共 **150**。A 减 20、加 40，净增 20；B 减 10。于是：

```text
期初 150 + 变动 20 − 10 = 期末 160
```

这与快照中的 A 120、B 40 一致。C、D 缺少完整期初或流水，不能顺手算进去。没有历史，就不能靠一句“查截至当时”把历史补出来。

## 第四步：平均库存要按时间算

发送：

> 计算 A、B 在 1 月 1 日至 5 日的日均总库存。

连续五天，A 的库存为 100、100、80、80、120，日均 **96**；B 为 50、50、50、40、40，日均 **46**。两者每日总量的平均为 **142**。

查询先按日求两者总量，再对五个日总量求平均，见[实际脚本](../resources/lab/time-series/queries/19-14-mean-total.fsscript)。直接对两对象的十行求 AVG 会得到 **71**，那是“对象—日”的平均，不是日均总库存。

若只平均变更日记录，A 为 100、B 为 45，也不对：每条记录覆盖的天数不同。没有连续日快照时，应按有效时长加权。A 在这五天的计算是 `(100×2 + 80×2 + 120×1)÷5 = 96`。真实业务还要确认按日末、营业时段还是连续时间取平均。

## 历史值还依赖历史归属

商品今天属于哪个部门，不一定等于当时的归属。查历史部门库存，需要有效期内的归属记录；只有当前部门字段，无法还原当时。

同样，事后补录后的历史重算值，可能不同于当时发布的报表。需要保留原发布值时，应另存报表版本或快照，不让 AI 猜过去看到了什么。

## 本章 Foggy 知识点

- **存量字段：** 同一时点可按对象汇总，跨日期的 SUM 不代表期末值；字段说明本身不会禁止错误查询。
- **分阶段查询：** 先按对象求最新日期，再关联对应记录；Compose 与基础篇的 QM 建模 JOIN 分开理解。
- **完整性：** 已知部分、未知对象与完整合计分别返回，未知不补零。
- **平均存量：** 先确认日粒度和完整覆盖，再算日均或按有效时长加权。

期末、无历史、流水和日均查询已在 0.1.38 教学端口回归，见[当前验证](../resources/lab/teaching-launcher-0.1.38-upgrade-2026-10-23.md)。原始记录核对与旧版故障保留在实验记录中。


---

<a id="chapter-20"></a>

# 第 20 章 业务耗时：已完成多久，未完成等了多久

日期差看起来只是一道减法，但起止事件、截止时间和统计分母没定好，算得再准也会答错。本章用工单说明自然小时耗时、未完成等待与超时比例。

样例是 **11 条虚构工单**，覆盖同日、跨日、空结束、未来结束和错误时间。沿用上一章独立环境，Launcher **0.1.38 预发布版**、CLI **0.1.25**。

## 第一步：固定统计口径并创建模型

发送：

> 为工单样例建立耗时模型，按 2024-02-02 12:00 截止，超过 24 个自然小时算超时。先不查询。

Codex 读取样例和字段，编写[TM](../resources/lab/time-series/models/model/TeachingWorkTicketModel.tm)与[QM](../resources/lab/time-series/models/query/TeachingWorkTicketQueryModel.qm)，校验、加载，再获取引擎实时生成的查询说明。

本例把分析截止存为固定的 `analysisAt`，保证复跑一致。调整截止时，应生成另一份分析输入或使用经验证的参数方案，不能把这个教学常量当成系统当前时间。

| 字段 | 含义 |
| --- | --- |
| `eligible` | 截至统计时已开始，且起止时间合法 |
| `completed` | 合法且实际完成时间不晚于截止 |
| `completedHours` | 仅已完成工单的实际耗时，其他为空 |
| `waitingHours` | 仅未完成工单截至统计时的等待，其他为空 |
| `elapsedHours` | 已完成取实际耗时，未完成取截至时等待 |
| `overdue` | 合法且已开始，已耗时严格大于 24 小时 |

缺开始时间、结束早于开始，单独列为异常；截止以后才开始的，不进入分母。恰好 24 小时不算超时。全部时间使用同一约定时区，本例不做跨时区转换。

### 把业务耗时固定在模型中

本例时间按秒记录，TM 使用 SQLite 的 `julianday` 相减后乘以 24，保留六位小数小时。完成时间在截止之后时，仍按未完成计算到截止，避免拿后来的结果回答“截至当时”。

示意公式是：

```text
有效结束 = 截止前已完成 ? 实际完成时间 : 统计截止
已耗小时 = (有效结束 − 开始时间)换算为小时
```

实际表达式与异常处理放在 TM；查询时 LLM 选择语义字段。换数据库时适配 TM 的方言表达式，查询仍使用相同语义字段。

## 第二步：先分清日期差与完整小时

发送：

> 对比几条工单的日历日期差和完整小时数。

查询也可以临时使用日期差函数。Codex 选取工单 1—3，通过计算字段表达：

```json
{
  "columns": ["ticketId", "dayDifference", "wholeHours"],
  "slice": [{"field": "ticketId", "op": "in", "value": [1, 2, 3]}],
  "calculatedFields": [
    {"name": "dayDifference", "expression": "DATEDIFF(finishedAt, startedAt)"},
    {"name": "wholeHours", "expression": "TIMESTAMPDIFF('HOUR', startedAt, finishedAt)"}
  ],
  "orderBy": ["ticketId"],
  "limit": 10
}
```

| 工单 | 日历日期差 | 完整小时 |
| --- | ---: | ---: |
| 1 | 0 | 2 |
| 2 | 1 | 12 |
| 3 | 1 | 24 |

`DATEDIFF(结束, 开始)` 比较日历日期，跨午夜两分钟也可能相差 1 天；`TIMESTAMPDIFF(单位, 开始, 结束)` 返回完整单位，向零截断。参数顺序不同，不要写反。

完整小时不等于小数小时。**24 小时零 1 秒仍只返回 24 个完整小时**，若拿它判断“超过 24 小时”，就会漏报。因此后面的超时和平均仍使用模型中保留小数的耗时字段，口径不随查询函数变化。

非法单位、参数数量以及 SQLite 不支持的微秒单位会在校验阶段拒绝，错误定位到表达式；单位错误还返回适用选项。按诊断调整后重新校验，不执行被拒绝的请求。

## 第三步：分别查看完成与等待

发送：

> 查看已完成的平均耗时，再列出还没完成的工单和等待时长。

查询 `TeachingWorkTicketQueryModel`。截至固定时点，**8 条**合法且已开始，其中完成 **5 条**、未完成 **3 条**。

已完成耗时分别为 2、12、24、25、48 小时，平均 **22.2 小时**。未完成单列：

| 工单 | 截至时等待小时 | 已超时 |
| --- | ---: | --- |
| 5 | 28 | 是 |
| 6 | 2 | 否 |
| 7 | 22 | 否 |

工单 7 实际在截止后两小时完成，但截至时仍未完成；不能提前把最终耗时拿来计算。

若把未完成都当成零，均值会被压低；若直接丢掉它们，又看不到积压。本章分别回答“已完成处理了多久”和“未完成等了多久”，不拿两类平均混用。

## 第四步：算超时比例，先说清分母

发送：

> 截至这个时点，已开始的有效工单中，超时占多少？

实际查询：

```json
{
  "columns": [
    "sum(eligible) as eligible_count",
    "sum(completed) as completed_count",
    "sum(overdue) as overdue_count",
    "completedAverageHours",
    "overdueRate"
  ],
  "limit": 10
}
```

超时 **3/8 = 37.5%**，包含已完成超时的 2 条和仍在等待的 1 条。这与“已完成中的最终超时比例” **2/5 = 40%** 是两个问题。

QM 的固定指标采用：

```javascript
formula: 'sum(overdue) * 1.0 / nullif(sum(eligible), 0)'
```

乘以 `1.0` 避免 SQLite 的整数除法；分母为零时返回空。指标写进模型后，LLM 不必每次重新推导分母和除法，但仍要正确理解用户问的是哪一种超时。

## 第五步：看分布与异常

发送：

> 按 24 小时以内和超过 24 小时分组，另列时间异常的记录。

8 条有效工单中，5 条在 24 小时以内、3 条超过。这里分组的是**截至时已耗时**，包括未完成等待，不是完成耗时分布。

另外两条时间异常单列，一条尚未开始被排除。没有有效对象时，平均耗时和超时比例为空，不能补零。

默认响应会省略 NULL 字段。HTTP 调用加 `X-Foggy-Null-Output: explicit` 后，这次空指标查询明确返回 `{"completedAverageHours":null,"overdueRate":null}`。它只改变输出格式，不补零，也不额外生成结果行。

自然小时不扣周末、假期和暂停。若业务要求“两个工作日内处理”，还需要工作日历、上下班时间和暂停记录；不能直接把 48 小时改个名称。

## 本章 Foggy 知识点

- **模型方言表达式：** 在 TM 固化日期差、异常与固定截止规则，查询使用公开语义字段。
- **日期差函数：** DATEDIFF 的日历日期差、TIMESTAMPDIFF 的完整单位，与模型的小数耗时分开。
- **条件指标：** 用合法/已完成/超时标记聚合，明确各自分母。
- **固定计算字段：** QM 封装平均与比例，处理整数除法和零分母。
- **边界回归：** 空值、未来事件、恰好阈值和异常顺序都要验，执行成功不等于口径正确。

耗时、日期差与 24 小时零 1 秒的边界已独立核对，并在 0.1.38 教学端口回归。[实操与复跑](../resources/lab/time-series/README.md)保存空值输出和错误诊断的实际响应。


---

<a id="chapter-21"></a>

# 第 21 章 同期群：同一批客户后来还买吗

月度销售额看整体变化，同期群则跟踪同一批对象：一月首次购买的客户，二月还有多少人购买？本章只做购买同期群和次月复购，不把购买记录解释成登录留存。

沿用 Launcher **0.1.38 预发布版**、CLI **0.1.25**。主例来自 Chinook 原始账单；三名虚构客户另表保存，用于核对重复购买和跨年。

## 第一步：准备购买历史，先不查询

发送：

> 为 Chinook 购买记录建立同期群模型，先不查询。

Codex 从账单副本准备 `Purchase`，增加年月及 `monthIndex`，创建[TM](../resources/lab/time-series/models/model/TeachingPurchaseModel.tm)与[QM](../resources/lab/time-series/models/query/TeachingPurchaseQueryModel.qm)，校验、加载后实时获取查询说明。Markdown 由引擎生成，不另写一份静态说明。

```text
月份序号 = 年 × 12 + 月 − 1
购买后第几个月 = 购买月序号 − 首购月序号
```

这样十二月到次年一月仍相差 1，不能直接把 `202401 − 202312` 当成月份差。

Chinook 只有样本内购买历史，没有完整获客记录。因此下面称**样本内首购**，不声称这些都是真实新客。若用户只能看部分历史，首次购买也只能按该可见范围定义，不能为补齐历史越权查询。

## 第二步：分阶段计算同期群

发送：

> 观察到 2009 年六月底，按样本内首购月份统计客户数，以及首购当月和随后两个月的购买人数。

截止固定为 **2009-07-01 之前**。本例把已提供样本在此前各自然月视为完整，这是教学观察约定；生产中须另确认数据完整性，不能从“最后一条交易日期”推断。

查询 `TeachingPurchaseQueryModel` 时，需要先确定每人首购，再按首购后的月份统计。这里用 Compose 分阶段完成，见[可执行脚本](../resources/lab/time-series/queries/21-01-cohort.fsscript)：

1. 按客户取历史月份最小值，得到每人的样本内首购月。
2. 把购买记录去重到“客户—月”。同月买三次也只算一个购买客户。
3. 按客户关联两份结果，计算相对月份，再按首购月和相对月份统计人数。
4. 从首购结果单独统计同期群规模，作为固定分母。

与第十九章的库存查询一样，这里先理解各阶段的业务含义，Compose 完整语法留到组合分析系列。

## 第三步：固定分母，区分零与未观察

发送：

> 整理成同期群表，计算复购比例，还没到观察期的标为未知。

引擎返回同期群规模、各月实际购买人数；[结果整理脚本](../resources/lab/checks/render-time-reports.py)据固定截止补上 0—2 月格子，再计算人数比例。空格判断和展示是结果处理工作，不是一个原生留存算子。

主例用颜色展示购买比例，格内同时标出人数与固定分母：

![样本内首购同期群热力图：一月至四月的第2月均为已观察的0%；五月第2月和六月第1、2月尚未观察，以灰色斜线区分。](../assets/time-analysis/21-cohort-heatmap.png)

一月同期群 6 人，二月有 1 人再次购买，次月复购比例为 **1/6**。分母不能换成二月全部活跃客户，也不能用账单张数替代购买人数。

把未观察的灰格补零，会让越新的同期群看起来越差。

第 0 月是进入同期群当月，不是“进入后的第一个完整月”。本章统计的是在对应自然月内是否购买，不是未来 30 天购买、连续每月购买或截至某月曾经复购。

## 第四步：核对客户，而不只看比例

发送：

> 抽查客户的首购和后续购买月份，再检查同月重复购买、跨年和观察不足的情况。

例如真实客户 2 在样本中的首购为 2009 年一月，二月再次购买；客户 4 在一月首次出现、四月再次购买，其四月活动属于第 3 月，不该填进第 2 月。

单独的[教学边界模型](../resources/lab/time-series/models/query/TeachingPurchaseBoundaryQueryModel.qm)有三名虚构客户：

| 客户 | 购买月份 |
| --- | --- |
| 1 | 2023-12 两次、2024-01 两次 |
| 2 | 2023-12 一次、2024-02 一次 |
| 3 | 2024-01 两次 |

截至一月底，十二月同期群 2 人、次月购买 1 人，比例 **50%**，没有被重复购买翻倍；第 2 月尚未观察。观察到二月底后，第 2 月变为 **50%**，一月同期群的次月则为已观察的 **0%**。跨年月份差、空样本也已核对。

还有一个容易藏在筛选里的错误：先把输入裁成 2012 年，再取最早购买，只能得到“2012 年第一次出现”，无法判断是否新客。展示近期同期群，应先在允许的历史范围内确定首购，再筛选要展示的首购月份。

## “没再买”还不等于永久流失

低频采购客户连续一个月没买很正常。若要讨论流失或回流，要另定多久未购买、哪些行为算活跃，以及观察期是否足够。现有购买数据不能解释客户为什么离开，也不能证明以后不会回来。

## 本章 Foggy 知识点

- **阶段聚合：** MIN 确定首购、按客户与月份去重，再关联和汇总。
- **Compose：** 复用 QM 的公开字段，分阶段结果与普通单 QM 查询分工明确。
- **固定业务口径：** 连续月份序号、固定同期群分母与可见历史范围。
- **结果完整性：** 已观察且无购买补零，未到观察期保留空值；比例的整理步骤明确可复跑。

主例与边界样例已逐客户核对，主报告及两个观察期边界在 0.1.38 教学端口回归通过。[实操与复跑](../resources/lab/time-series/README.md)、[当前验证](../resources/lab/teaching-launcher-0.1.38-upgrade-2026-10-23.md)。


---

<a id="chapter-22"></a>

# 第 22 章 父子维：

“看看 Nancy 负责范围内的账单”，这里的范围是她本人，还是包括下属？本章把这个区别写进语义模型，让 AI 明确选择，再交给引擎执行。

继续使用 Chinook，当前教学环境为 Launcher **0.1.39**、CLI **0.1.25**。本例统计的是**按当前客户支持归属划分的账单金额**；样例没有历史人员归属，不能据此判断当时是谁完成了销售。

## 第一步：准备员工层级与模型，先不查询

在原练习目录发送：

> 用独立 Chinook 副本建立员工层级和账单范围分析模型，校验并加载，先不查询。

Codex 用[准备脚本](../resources/lab/checks/prepare-chapter-22.py)复制数据库，在副本中新增闭包表 `EmployeeClosure`；原账单、客户和员工记录不改。项目位于 `lab/multidim`，接入 Namespace `chinook-multidim`。

Chinook 已有 8 名员工，`ReportsTo` 指向上级。下面的箭头从上级指向下级：

![查询与建模流程](../assets/diagrams/ch22-05.png)

第十二章的“曲目 → 专辑 → 艺术家”是多张表逐层关联；这里是同一员工表中的自引用树，深度由数据决定。

### 从一层关系，准备全部路径

`ReportsTo` 直接告诉我们 Jane 的上级是 Nancy。要问 Jane 还属于哪些上级范围，准备脚本会沿树向上走，将路径存入闭包表。例如：

| AncestorId：上级（含本人） | EmployeeId：所属人员 | distance：相隔层数 |
| ---: | ---: | ---: |
| 2 | 2 | 0，自身 |
| 2 | 3 | 1，Nancy → Jane |
| 1 | 3 | 2，Andrew → Nancy → Jane |

本例共 **20 条**闭包记录，包含 8 条自身记录；自身记录保证“本人及下属”不会漏掉本人。脚本同时检查上级是否存在、有无环路、上下级关系对是否重复。这是数据准备工作，不能用一句提示词代替。

### 把层级接到业务查询

本章创建两对模型，完整源码如下：

| 模型 | 主表与用途 | 源码 |
| --- | --- | --- |
| 员工目录 | `Employee`，列出员工和上下级，包含没有账单的成员 | [TM](../resources/lab/multidim/models/model/ChinookEmployeeDirectoryModel.tm) / [QM](../resources/lab/multidim/models/query/ChinookEmployeeDirectoryQueryModel.qm) |
| 支持范围账单 | `Invoice`，经客户的当前支持人员归属账单 | [TM](../resources/lab/multidim/models/model/ChinookSupportInvoiceModel.tm) / [QM](../resources/lab/multidim/models/query/ChinookSupportInvoiceQueryModel.qm) |

账单 TM 的关联配置节选如下，从主表保留外层结构；姓名展示、属性与指标见完整源码：

```javascript
export const model = {
    tableName: 'Invoice',
    dimensions: [{
        name: 'customer', tableName: 'Customer',
        foreignKey: 'CustomerId', primaryKey: 'CustomerId',
        dimensions: [{
            name: 'support', tableName: 'Employee',
            foreignKey: 'SupportRepId', primaryKey: 'EmployeeId',
            closureTableName: 'EmployeeClosure',
            parentKey: 'AncestorId', childKey: 'EmployeeId'
        }]
    }]
};
```

`SupportRepId` 来自直接上一层的客户表；`parentKey`、`childKey` 则分别指闭包表的上级列与所属人员列，其中也保留本人到本人的记录。配置关联后，QM 通过 `{ref: invoice.customer.support}` 公开支持人员维度。

四个文件校验、加载后，Codex 实时获取查询说明。Markdown 由引擎根据已加载代码生成，不提前维护。本次公开字段包括 `support$id`、`support$caption`、`invoiceDate`、`invoiceAmount`；JSON 元数据还标明 `support$id` 是层级维度，并列出允许的层级操作符。

## 第二步：先确认成员，再查金额

发送：

> 先列出员工目录，确认 Nancy Edwards 的 ID、上级和直接下级。

Codex 查 `ChinookEmployeeDirectoryQueryModel`：Nancy 的 ID 为 **2**，上级是 Andrew（1），直接下级是 Jane（3）、Margaret（4）、Steve（5）。后面的筛选使用这些 ID，姓名用于展示。

目录从员工表出发；账单分析从账单表出发。账单里没查到某人，不代表员工不存在。

## 第三步：比较本人和本人及下属

再发送：

> 比较 Nancy 本人，以及本人和所有下属负责的客户在 2012 年的账单金额与张数。

Codex 查询 `ChinookSupportInvoiceQueryModel`。日期、指标不变，只改变支持人员筛选：

| 操作符 | 范围 |
| --- | --- |
| `=` | 只匹配本人 |
| `childrenOf` | 直接下级，不含本人 |
| `descendantsOf` | 全部下属，不含本人 |
| `selfAndDescendantsOf` | 本人及全部下属 |

例如“本人及所有下属”的实际 DSL：

```json
{
  "columns": ["sum(invoiceAmount) as amount", "count(invoiceId) as bills"],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]},
    {"field": "support$id", "op": "selfAndDescendantsOf", "value": 2}
  ],
  "limit": 10
}
```

只查本人时，将 `op` 改成 `=`。两条请求分别校验、执行，结果为：

| Nancy 的范围 | 账单张数 | 金额 |
| --- | ---: | ---: |
| 仅本人 | 0 | 空值 |
| 本人及所有下属 | 83 | 477.53 |

样例没有客户直接归属 Nancy，账单由她的三名下属负责。因此本人查询的 COUNT 为 0、SUM 为空；默认响应省略了空的 `amount` 字段，不能把它误读成查询失败。

AI 提交的是成员范围，客户关联与闭包查询由引擎落实；无需让 AI 每次重写递归 SQL。普通 `=` 也不会因为配置了父子维，就自动包含下属。

## 第四步：直接下级不等于所有下属

继续发送：

> 再比较 Andrew 的直接下级和全部下属，看看 2012 年账单范围有什么不同。

Andrew（1）的直接下级只有 Nancy（2）、Michael（6）；Jane 等支持人员在更深一层。目录与账单结果分别为：

| 范围 | 员工目录中的 ID | 账单张数 | 金额 |
| --- | --- | ---: | ---: |
| 直接下级 `childrenOf` | 2、6 | 0 | 空值 |
| 全部下属 `descendantsOf` | 2、3、4、5、6、7、8 | 83 | 477.53 |

层级条件匹配的是账单当前归属的人员，不会自动把每个直接下级的全部下属一起算入。

没有下属的员工同样能说明“包含自身”的作用：Jane（3）本人有 **28 张、146.60**；只查她的下属没有账单，查“本人及下属”仍返回她本人的结果。

### 用 maxDepth 限制层数

再发送：

> 比较 Andrew 下方一层、两层的员工和账单范围。

`maxDepth` 限制经过几层上下级关系，起点算第 0 层。先查员工目录 `ChinookEmployeeDirectoryQueryModel`：

```json
{
  "columns": ["employee$id", "employee$caption", "employee$parentId"],
  "slice": [
    {"field": "employee$id", "op": "descendantsOf", "value": 1, "maxDepth": 1}
  ],
  "orderBy": ["employee$id"],
  "limit": 50
}
```

一层返回 Nancy（2）、Michael（6），与直接下级一致；改成两层，返回 ID 2—8。它限制的是关系层数，不是返回几个人。

账单改查 `ChinookSupportInvoiceQueryModel`，沿用前面的金额、张数请求，将 `support$id` 条件改为 `descendantsOf`、值为 1，并加同样的 `maxDepth`。2012 年范围内，一层为 **0 张、金额空值**，两层为 **83 张、477.53**。真实负责客户的三人都在 Andrew 下方第二层。

## 第五步：向上查各级上级

发送：

> 看看 Jane 的各级上级，再把她本人也列出来。

回到上面的完整员工目录请求，只将 `slice` 替换为：

```json
[
  {"field": "employee$id", "op": "ancestorsOf", "value": 3}
]
```

图中箭头表示向上查找，层数从 Jane 算起：

![查询与建模流程](../assets/diagrams/ch22-06.png)

| 操作 | 返回范围 |
| --- | --- |
| `ancestorsOf` | Nancy、Andrew，不含 Jane |
| `selfAndAncestorsOf` | Jane、Nancy、Andrew |
| `ancestorsOf` 加 `maxDepth: 1` | 只到直接上级 Nancy |
| `selfAndAncestorsOf` 加 `maxDepth: 1` | Jane 与 Nancy |

这里按关系顺序列名字；实际请求按 ID 排序，展示顺序不代表层级。只需直接上级 ID 时，也可读取 `employee$parentId`。

**查出 Jane 的上级，与统计这些上级负责范围的账单，是两件事。** 本节返回员工；下一章才把账单分别汇总到本人及各级上级。

## 留下边界核对

没有下属、没有账单和员工不存在，要分别判断。本次也核对了叶子节点和不存在的 ID 999，避免把空业务结果误当成员目录。

范围用原始 `ReportsTo` 独立遍历，金额用原客户归属、账单行和 `Decimal` 核对。原初验记录保留；本轮在 **0.1.39** 验证了 **12 项向上/层数查询、3 项下钻衔接回归**，均通过，模型与数据库未改。[请求、响应与复跑](../resources/lab/multidim/upward-depth-validation-2026-10-24.md)。

## 本章 Foggy 知识点

- **父子维建模：** 用闭包表记录各级上下级及本人关系，区别于固定的多表嵌套关联。
- **显式查询范围：** 区分本人、直接下级、全部下属和各级上级，明确是否包含本人；用 `maxDepth` 限制关系层数。
- **目录与事实：** 先确认成员，再查业务；无结果不直接等于成员不存在。
- **独立核对：** 用原关系遍历验证范围，用原账单核对张数与金额。

下一章用这些人员范围完成层级汇总与下钻。


---

<a id="chapter-23"></a>

# 第 23 章 沿层级汇总与下钻：为什么总计不能再相加

上一章确定“选哪些人”，包括向下查、向上查和限制层数。本章再问：“这些账单，要汇总到谁名下？”成员范围与汇总分组是两件事。

仍用独立的 `chinook-multidim` 项目，当前为 Launcher **0.1.39**、CLI **0.1.25**。人员归属沿用当前客户支持分配，不能当作历史销售业绩。

## 第一步：公开层级汇总字段，先不查询

发送：

> 为现有账单模型增加层级汇总查询入口，校验并加载，先不查询。

原 TM 和闭包表可以复用。本章新增 [ChinookSupportRollupQueryModel.qm](../resources/lab/multidim/models/query/ChinookSupportRollupQueryModel.qm)，同时公开层级汇总对象与实际归属人员。汇总对象包括人员本人及各级上级，其中两项配置为：

```javascript
{ref:'customer.support$hierarchy$id', name:'ancestorId', alias:'ancestorId'},
{ref:'customer.support$hierarchy$caption', name:'ancestorName', alias:'ancestorName'}
```

这两项放在 QM 的 `columnGroups[].items` 中。`ref` 引用 TM 的层级视角，`name`、`alias` 明确查询字段和返回名称。完整外层结构见源码。

校验、刷新后，Codex 实时获取查询说明，确认 `ancestorId`、`ancestorName` 已公开，再写 DSL。两者分别表示汇总对象的 ID 和名称；本例名称取员工姓氏，完整姓名可按 ID 对照上一章员工目录。

## 第二步：比较实际归属与层级汇总

发送：

> 看看 2012 年账单按实际支持人员、按本人及各级上级汇总，结果有什么区别。

按 `support$id`、`support$caption` 分组，得到实际负责客户的三人：

| 人员 | 金额 | 账单数 |
| --- | ---: | ---: |
| Jane Peacock（3） | 146.60 | 28 |
| Margaret Park（4） | 197.20 | 29 |
| Steve Johnson（5） | 133.73 | 26 |

改按本人及各级上级汇总，使用：

```json
{
  "columns": ["ancestorId", "ancestorName", "sum(invoiceAmount) as amount", "count(invoiceId) as bills"],
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}
  ],
  "groupBy": ["ancestorId", "ancestorName"],
  "orderBy": ["ancestorId"],
  "limit": 50
}
```

实际返回五个有业务的节点：Andrew（1）**477.53**、Nancy（2）**477.53**，以及上面三人的金额。没有账单的分支不会因为员工目录里存在，就自动返回一行。

![查询与建模流程](../assets/diagrams/ch23-07.png)

同一张 Jane 负责的账单，会计入 Jane、Nancy 和 Andrew 三个节点。五行金额相加是 **1,432.59**，却不代表发生了这么多业务；它把同一批账单算了三遍。

本例三名支持人员互不重叠，合计能对回 Nancy 的 477.53。但这不是“父级永远等于下级之和”：如果经理本人也直接负责客户，还要加上经理自身的账单。平均值、去重人数也不能照搬金额相加的做法。

本章另用员工目录计数验证了这个边界：Nancy 子树共 **4 人**，包含她本人和 3 名下属。没有往原账单中补造经理业务。

## 第三步：从上级范围下钻到账单

发送：

> 展开 Nancy 范围内各支持人员的金额，再查 Jane 对应的客户和账单，保持 2012 年范围。

Codex 先用 `support$id selfAndDescendantsOf 2` 限定 Nancy 范围，按实际支持人员分组。下面用柱图演示“点哪里，看到什么”；橙色表示本次点击项。

![点击人员柱查看客户，再点击客户柱查看账单](../assets/hierarchy-drilldown/click-to-drill.png)

[打开可点击演示](../assets/hierarchy-drilldown/interactive.html)：点 **Jane**，看到她在 2012 年有账单的 16 位客户，合计 **28 张、146.60**；再点 **Luís Gonçalves（客户 #1）**，看到他的 **2 张账单、15.84**。点击上方路径可返回；其他人员和客户也可点选。

每次点击都保留 2012 年日期条件：选 Jane 时增加 `support$id = 3`，选客户时再增加 `customerId = 1`。选中一根柱子会缩小范围；若将当前层全部柱子展开到下一层，金额仍应对回。读取明细时不再按各级上级展开，避免同一账单出现多行。

图表负责点选与返回，查询端负责带上日期和成员条件。这份演示预载已验证结果，可离线操作；接入实际问数时，每次点击再提交对应查询。

还有一个容易混淆的地方：把 Andrew 和 Nancy **合并为一个成员筛选范围**，不等于把两个节点的汇总行相加。本次使用 `selfAndDescendantsOf`、值 `[1, 2]`，仍返回 **83 张、477.53**；同一账单只需属于范围中的任一节点。

## 本章核对与 Foggy 知识点

本章 **7 项查询**通过：层级汇总、单一经理、人员展开、客户汇总、账单明细、重叠范围合并及内部节点包含自身。核对依据为原始 `ReportsTo` 遍历、客户归属与账单行，金额用 `Decimal`；完整请求与复跑入口见[实操记录](../resources/lab/multidim/README.md)。

- **层级汇总：** 用闭包关系将账单汇总到人员本人及各级上级，QM 可用明确别名公开字段。
- **范围与粒度：** 层级操作符决定取哪些事实，分组字段决定按谁展示。
- **下钻：** 保留日期与范围，逐步切换人员、客户和账单粒度。
- **避免重复：** 本人与各级上级的汇总行存在重叠，不能直接相加；合并成员范围按事实集合核对。

下一章改看另一种组织方式：把支持人员、国家和月份排成交叉表。


---

<a id="chapter-24"></a>

# 第 24 章 Pivot：把多个维度放到一张交叉表

上一章沿员工树查看业务范围。本章换个问题：“各支持人员负责的账单，在不同国家、不同月份分别有多少？”这里需要的是交叉表。

沿用 `chinook-multidim` 项目，Launcher **0.1.39**、CLI **0.1.25**。

## 第一步：准备月份字段，先不查询

发送：

> 复用支持归属账单分析，增加保留年份的账单月份字段，校验并加载，先不查询。

本章新增 [ChinookSupportPivotModel.tm](../resources/lab/multidim/models/model/ChinookSupportPivotModel.tm) 和 [ChinookSupportPivotQueryModel.qm](../resources/lab/multidim/models/query/ChinookSupportPivotQueryModel.qm)，原模型不改。TM 沿用已有客户、人员关联，在 `properties` 中增加：

```javascript
{
    column:'InvoiceDate', name:'yearMonth', caption:'账单年月', type:'STRING',
    description:'YYYY-MM，按账单日期取自然月，保留年份。',
    dialectFormulaDef:{sqlite:{
        builder:(alias)=>`strftime('%Y-%m', ${alias}.InvoiceDate)`
    }}
}
```

QM 公开 `yearMonth`，查询时 AI 直接选字段；月份表达式由建模代码维护，更换数据库时需调整方言。

Codex 校验、刷新，再实时获取查询说明，确认人员、国家、年月与金额、账单数均已公开。Markdown 由引擎按已加载代码生成，不必提前制作。

## 第二步：先确认要放哪些行、列

发送：

> 比较 2012 年十月、十一月各支持人员的账单金额与张数，再按账单国家展开。

先用普通分组确认金额与张数，再与 Pivot 对照。只看人员和月份，长表转成交叉表，就是把月份移到列上：

![相同金额从人员月份长表转为交叉表](../assets/pivot-analysis/01-long-to-pivot.png)

加上国家后，普通长表共有 **14 个有业务的“人员 × 国家 × 月份”组合**。Pivot 把它们放到三个位置：

| 位置 | 本例选择 |
| --- | --- |
| 行轴 `rows` | 支持人员、账单国家 |
| 列轴 `columns` | 账单年月 |
| 指标 `metrics` | 账单金额、账单数 |

同一国家可出现在多人下面，行身份要同时包含人员和国家。

## 第三步：用 Pivot 表达交叉分析

再发送：

> 把刚才的结果做成交叉表：按支持人员、账单国家分行，月份分列，显示金额和张数。

实际请求如下，目标 QM 为 `ChinookSupportPivotQueryModel`：

```json
{
  "pivot": {
    "rows": ["support$id", "billingCountry"],
    "columns": ["yearMonth"],
    "metrics": ["invoiceAmount", "invoiceCount"],
    "outputFormat": "flat"
  },
  "slice": [
    {"field": "invoiceDate", "op": "[)", "value": ["2012-10-01", "2012-12-01"]}
  ]
}
```

日期由 `slice` 限定，`metrics` 使用已建模的原生指标。`pivot.columns` 定义列轴，与普通查询的顶层 `columns` 含义不同，两者不能同时出现。Pivot 也不与 `timeWindow` 混用。

`flat` 返回逐行记录，金额与张数逐格对回普通分组。改成 `grid` 后，返回行头、列头和单元格矩阵：14 行、4 个指标列，即两个月各放金额、张数。缺少的组合为 `null`，展示时保留为空。

## 第四步：增加小计、总计与 TopN

基础矩阵通过后，再发送：

> 增加人员小计和总计，再单独查看两月合计金额最高的两名支持人员。

在 `pivot.options` 加入 `rowSubtotals: true`、`grandTotal: true`，返回人员小计与各月总计；响应中的 `_sys_meta` 用于区分这些汇总行。

![展开 Jane 的国家明细，金额用色深表示，张数单独显示](../assets/pivot-analysis/02-expandable-matrix.png)

TopN 要配置在轴上。本例将 `rows` 改为：

```json
[
  {"field": "support$id", "orderBy": ["-invoiceAmount"], "limit": 2},
  "billingCountry"
]
```

引擎按两个月合计选择人员，保留入选人员全部月份的国家单元格。顶层 `limit` 截取记录不能替代轴 TopN；加总计时，也只统计入选范围。

![Top2 按两月合计保留 Margaret 和 Jane，仍展示两人各月金额](../assets/pivot-analysis/03-topn-month-bars.png)

[打开可点击演示](../assets/pivot-analysis/interactive.html)：点击人员展开国家，或切换下方柱图的“全部 3 人 / Top2”。演示预载已验证结果，不实时调用引擎；展开不改变范围，Top2 只改变下方柱图的人员范围。

## 选讲：树形输出不等于层级汇总

上面按国家分组；若要沿员工上下级排列结果，可另用树形输出。

先发送创建任务：

> 为支持人员建立树形查询入口，校验并加载，先不查询。

本例新增 [ChinookSupportTreeQueryModel.qm](../resources/lab/multidim/models/query/ChinookSupportTreeQueryModel.qm)，复用本章 TM。嵌套人员的上级属性原名为 `customer_support$parentId`；树形入口将它明确公开为 `support$parentId`，与人员 ID 配套：

```javascript
{ref:invoice.customer.support$parentId,
 name:'support$parentId', alias:'support$parentId'}
```

加载并读取实时说明后，再发送：

> 按员工层级展示 2012 年十月、十一月合计的账单金额。

本次行轴配置 `hierarchyMode: "tree"`，输出用 `tree`，不加交叉补全或总计。结果中 Jane、Margaret、Steve 分别为 **32.70、35.66、11.88**；Nancy 没有直属账单，不会凭树形格式自动生成一笔下属合计。

配套测试另用员工目录核对了完整 8 节点树。树形输出组织节点；需要汇总到人员本人及各级上级，仍使用上一章的层级视角。

## 本章 Foggy 知识点

- **月份属性：** 在 TM 中定义方言计算，QM 公开后由 AI 直接引用。
- **Pivot 结构：** 用行轴、列轴和原生指标表达交叉分析，区别于员工父子树。
- **范围与展示：** 先筛选事实，再核对每格、缺组合、合计与轴 TopN。

本章结果已与原始账单、员工关系独立核对，完整请求与复跑方法见[实操记录](../resources/lab/multidim/validation-0.1.39.md)。下一章继续在交叉表上计算占比与基准比值。


---

<a id="chapter-25"></a>

# 第 25 章 透视结果再计算：占父级多少，基准又是谁

上一章把金额排成了交叉表。本章再看两种关系：各国家占所属人员的多少，各月相对基准月又是多少。两者都要先确定分母。

环境沿用 Launcher **0.1.39**、CLI **0.1.25**。

## 第一步：回到完整交叉表

复用 `ChinookSupportPivotQueryModel`，无需新建模型。回到上一章第三步的完整矩阵：按支持人员、国家分行，年月分列，期间为 2012 年十月、十一月，先不沿用 TopN 或树形入口。Codex 实时读取查询说明后，再构造请求。

这里的“父级”指**行轴的上一层分组**：某国家所属的支持人员，而非员工树中的上级。

## 第二步：国家金额占所属人员多少

发送：

> 加上每个国家占所属支持人员当月账单金额的比例。

在上一章 `pivot.metrics` 中保留 `invoiceAmount`，再加入：

```json
{
  "name": "countryShare",
  "type": "parentShare",
  "of": "invoiceAmount",
  "axis": "rows",
  "level": "billingCountry",
  "parentLevel": "support$id"
}
```

分子与分母保留同一月份：

```text
某国家占比 = 该人员在该国家的当月金额 ÷ 该人员在所有纳入国家的当月金额
```

引擎返回 Jane 十月法国金额 **16.86**、占比约 **68.04%**，与原始账单的 **16.86 ÷ 24.78** 一致。十一月的父级金额是 **7.92**，分母要跟着月份变化。

`level`、`parentLevel` 指定相邻行轴层级，`of` 引用同一指标列表中的原生可加度量。完整请求见[配套 JSON](../resources/lab/multidim/queries/series-25-parent-share.json)。

## 第三步：区分筛选与展示分页

再发送：

> 只看法国，重新查询各支持人员当月的金额占比。

`slice` 只留下法国后，分子与父级金额都只含法国，有业务的月份占比变为 **100%**。若想比较“占所有国家”，就要保留完整分母，不能假设 `parentShare` 会忽略筛选。

如果只想截取展示项，就不要先筛掉事实。恢复全部国家后，本次[分页请求](../resources/lab/multidim/queries/series-parent-share-output-page.json)对每人的国家项跳过一项、取两项，并在占比指标中设置 `denominatorScope: "prePageParent"`，使用分页前的父级金额作分母；缺少这项声明，请求会在校验阶段拒绝。

![完整范围、只筛法国与仅分页展示时的分母对照](../assets/pivot-analysis/04-denominator-scope.png)

筛选决定哪些事实参与计算，分页决定展示哪些项。只有完整、互斥且使用同一分母的子项，占比才适合按 100% 核对。

## 第四步：相对首月，不是环比

上面在同一个月内比较国家与所属人员；下面改为同一个人员跨月比较，分母换成基准月金额。

再发送：

> 恢复全部国家，比较各支持人员十一月相对十月的金额比值。

行轴只保留 `support$id`，列轴为 `yearMonth`。指标列表改为：

```json
[
  "invoiceAmount",
  {
    "name": "index",
    "type": "baselineRatio",
    "of": "invoiceAmount",
    "axis": "columns",
    "baseline": "first"
  }
]
```

本次列序为十月、十一月，Jane 十一月返回 **0.3196…**，即 **7.92 ÷ 24.78**，表示十月的约 **31.96%**。若要表达增长率，还要减 1。

`last` 改用十一月作分母，Jane 十月返回约 **3.1288**。首列或末列按整张表的列轴确定，不会改成每个人各自有数据的第一列或最后一列。

本次另用独立的 9 行教学数据核对边界：分母为零、正负抵消为零或基准缺失时，比例为空，默认响应省略该字段；分母有效而当前金额为零时，才返回 0。人工数据不混入原 Chinook。

“相对首月”始终对比同一基准，环比则逐月换分母；同比、累计和滚动仍回到时间分析系列。

## 本章 Foggy 知识点

- **`parentShare`：** 按相邻行轴分组定义父级分母，列轴与筛选范围也参与口径。
- **`baselineRatio`：** 相对列轴首项或末项的比值，区别于增长率和环比。
- **如实解释：** 先确认分母、列序和缺值，再把数字写成业务结论。

完整请求、原始响应、边界数据与独立核对脚本见[实操记录](../resources/lab/multidim/validation-0.1.39.md)。


---

<a id="chapter-26"></a>

# 第 26 章 把查询结果交给下一步：Compose 与 CTE

前面主要在一个 QM 中完成分析。接下来，有时要先得到一组结果，再筛选、关联或继续计算。Foggy 用 **Compose** 组织这些查询阶段。

本章先做一个小例子：按客户汇总 2012 年账单，筛出年度金额至少为 10 的客户，再带上姓名。这个问题也能用普通分组与 `having` 完成；这里拆开，是为了看清阶段之间怎样传递字段。后两章再处理多个查询结果的组合。

环境使用 Launcher **0.1.42**、CLI **0.1.25**，本章原请求已复跑通过；金额仍用 Chinook 样例原单位。

## 第一步：准备本系列的练习目录

在原练习目录中发送：

> 复用客户目录和账单模型，准备 Compose 练习项目，校验并加载，先不查询。

本章配套项目为 `lab/composition`，Namespace 为 `chinook-composition`。模型定义复用前面的内容，副本加载到这个 Namespace：

| 查询入口 | 本章使用的公开字段 | 对应 TM |
| --- | --- | --- |
| [ChinookInvoiceQueryModel](../resources/lab/composition/models/query/ChinookInvoiceQueryModel.qm) | `customerId`、`invoiceDate`、`invoiceAmount` | [账单 TM](../resources/lab/composition/models/model/ChinookInvoiceModel.tm) |
| [ChinookCustomerQueryModel](../resources/lab/composition/models/query/ChinookCustomerQueryModel.qm) | `customerId`、`customerName` | [客户目录 TM](../resources/lab/composition/models/model/ChinookCustomerModel.tm) |

Codex 校验、加载后，实时读取这两个 QM 的查询说明。Markdown 仍由引擎按已加载代码生成，无须提前编写。已有自己的项目时，使用实际绑定的 Namespace。

## 第二步：用 Compose 串起查询

准备完成后发送：

> 用 Compose 查出 2012 年账单合计至少为 10 的客户，带上姓名。

查询分成下面几步：

![查询与建模流程](../assets/diagrams/ch26-08.png)

Codex 生成的[完整 fsscript 脚本](../resources/lab/composition/queries/chapter-26/03-qualified-with-names.fsscript)如下，由 CLI 的 `compose` 入口执行：

```javascript
const totals = dsl({
    model: 'ChinookInvoiceQueryModel',
    columns: ['customerId as customer_id', 'sum(invoiceAmount) as total_amount'],
    slice: [{ field: 'invoiceDate', op: '[)', value: ['2012-01-01', '2013-01-01'] }],
    groupBy: ['customerId']
});

const selected = totals.query({
    columns: ['customer_id', 'total_amount'],
    slice: [{ field: 'total_amount', op: '>=', value: 10 }]
});

const customers = dsl({
    model: 'ChinookCustomerQueryModel',
    columns: ['customerId as directory_customer_id', 'customerName as customer_name']
});

const result = selected.join(customers, 'inner', [
    { left: 'customer_id', op: '=', right: 'directory_customer_id' }
]).query({
    columns: ['customer_id', 'customer_name', 'total_amount'],
    orderBy: ['customer_id'], limit: 100
});

return { plans: result };
```

`dsl(...)` 从 QM 建立输入计划；`totals.query(...)` 在客户汇总上继续筛选；`join(...)` 按客户 ID 带入目录中的姓名。两侧的 ID 使用不同别名，最后只保留需要返回的列。

在这次投影中，`customerId` 输出为 `customer_id`，汇总金额输出为 `total_amount`。后续使用这两个阶段字段，未输出的 `invoiceDate` 不会自动传下去。

注意这里的两次 `slice`：第一处筛选原账单日期，第二处筛选已经汇总出的 `total_amount`。数据所处的阶段不同，条件的含义也不同。

## 第三步：先校验、看预览，再执行

Codex 按 `compose validate → compose preview → compose execute` 处理。校验检查字段与计划是否成立，预览给出生成的 SQL 和参数，通过后再执行。

**CTE（公用表表达式）**是 SQL 中用 `WITH` 给一个查询结果命名，再供后续查询引用的写法。本次预览的 SQL 使用了 CTE 与子查询，日期和金额门槛作为参数传入。

Compose 描述语义查询阶段，CTE 属于生成的 SQL。`totals` 等脚本变量保存查询计划，SQL 中的 CTE 名由引擎生成；计算交给数据库，AI 无须抄写中间客户 ID。CTE 也不保证中间结果会物化成临时表。

逐阶段独立核对的结果为：

| 阶段 | 客户数 | 账单金额合计 |
| --- | ---: | ---: |
| 年度汇总 `totals` | 47 | 477.53 |
| 金额至少为 10 的 `selected` | 20 | 344.84 |
| 关联姓名后的 `result` | 20 | 344.84 |

最终结果节选：

| 客户 ID | 客户姓名 | 年度账单金额 |
| --- | --- | ---: |
| 1 | Luís Gonçalves | 15.84 |
| 5 | František Wichterlová | 18.84 |
| 7 | Astrid Gruber | 11.88 |

本例账单客户都有目录记录，`inner` 关联没有减少入选人数。把门槛改为 1000 后，完整链路正常返回空列表；它表示没有符合条件的客户。

## 字段写错时怎么处理

本次故意在后续阶段选择 `invoiceDate`，校验返回 `COMPOSE_SCRIPT_INVALID`，指出该字段不在来源输出中，未进入执行。需要限定账单期间，就把条件放在输入账单阶段；若改为按日分析，再相应调整分组与输出。

## 换个问题：没有账单的客户也保留

再发送：

> 列出所有客户及其 2012 年账单合计，没有账单的客户也保留。

这时应以完整客户目录作左侧，`left` 关联未加金额门槛的年度汇总。[本次请求](../resources/lab/composition/queries/chapter-26/04-all-customers-left.fsscript)返回 **59 位客户：47 位有账单，12 位没有当期账单**。未匹配的金额为空，不能解释成一张金额为零的账单。

第十四章把常用多 TM 关联定义在 QM 中；这里组合的是本次查询的阶段结果。两种方式都需要先确认每行代表什么，以及关联键是否唯一。

## 本章 Foggy 知识点

- **Compose：** 用 `dsl`、派生 `query` 和 `join` 组合已有 QM 的查询计划。
- **阶段字段：** 后续使用前一阶段输出的列及别名，筛选条件放在对应的数据阶段。
- **CTE 与执行：** 通过预览查看 SQL 的阶段组织，校验通过后再执行，不把查询计划当作已取回的数据。

本章 5 项查询执行与 1 项预期校验拒绝已实际验证，金额和客户集合独立核对。源码、实时元数据、响应与复跑方式见[本章实操记录](../resources/lab/composition/chapter-26-validation.md)。下一章分别汇总账单与明细，再关联两个结果核对金额。


---

<a id="chapter-27"></a>

# 第 27 章 先汇总，再关联两个查询结果

上一章把查询结果交给下一步。本章让两个 QM 各自汇总，再把结果放在一起核对：**2012 年每位客户的账单金额，与明细金额是否一致？**

沿用 `chinook-composition` 项目，Launcher **0.1.42**、CLI **0.1.25**，本章原请求已复跑通过。这里仍是 Chinook 的账单与明细，金额沿用样例原单位。

## 第一步：确认两侧口径，先不查询

发送：

> 准备账单和明细分析，复用已有模型，校验并加载，先不查询。

本次复用 [账单 QM](../resources/lab/composition/models/query/ChinookInvoiceQueryModel.qm) 和 [明细 QM](../resources/lab/composition/models/query/ChinookInvoiceLineQueryModel.qm)，已有模型直接使用。Codex 实时读取查询说明，确认：

| 查询入口 | 客户键 | 日期 | 金额 |
| --- | --- | --- | --- |
| 账单 QM | `customerId` | `invoiceDate` | `invoiceAmount`，来自账单总额 |
| 明细 QM | `invoice$customerId` | `invoice$invoiceDate` | `lineAmount`，交易单价乘数量 |

两侧都按账单日期限定 2012 年，再按客户 ID 汇总。原来一行分别代表一张账单、一条明细；汇总后，**两侧都变成一位客户一行**，再按客户键关联。

第十四章将聚合 JOIN 规则预先定义在 QM 中；本章临时组合两个 QM 的查询结果，汇总粒度由这次分析决定。

## 第二步：分别汇总，再关联

准备好后发送：

> 用 Compose 分别按客户汇总 2012 年账单和明细，关联后核对金额差异。

![查询与建模流程](../assets/diagrams/ch27-09.png)

[实际执行的完整脚本](../resources/lab/composition/queries/chapter-27/03-reconcile-customers.fsscript)如下：

```javascript
const bills = dsl({
    model: 'ChinookInvoiceQueryModel',
    columns: ['customerId as bill_customer_id', 'sum(invoiceAmount) as bill_amount'],
    slice: [{ field: 'invoiceDate', op: '[)', value: ['2012-01-01', '2013-01-01'] }],
    groupBy: ['customerId']
});

const lines = dsl({
    model: 'ChinookInvoiceLineQueryModel',
    columns: ['invoice$customerId as line_customer_id', 'sum(lineAmount) as line_amount'],
    slice: [{ field: 'invoice$invoiceDate', op: '[)', value: ['2012-01-01', '2013-01-01'] }],
    groupBy: ['invoice$customerId']
});

const joined = bills.join(lines, 'left', [
    { left: 'bill_customer_id', op: '=', right: 'line_customer_id' }
]);

const compared = joined.query({
    columns: ['bill_customer_id', 'line_customer_id', 'bill_amount', 'line_amount',
        'ROUND(bill_amount - line_amount, 2) as amount_difference'],
    orderBy: ['bill_customer_id'], limit: 100
});

return { plans: compared };
```

`bills`、`lines` 分别产生客户金额合计，`joined` 以账单侧为左侧关联。两侧客户 ID 都保留，便于判断有没有匹配到另一侧；不同别名也让金额来源更清楚。

差异列在关联后用列内表达式计算。本例按两位小数核对，先汇总再对差额 `ROUND(..., 2)`，避免浮点求和的小尾数被当成金额差异；实际项目按约定精度处理。

## 第三步：先核对金额，再查未匹配项

Codex 先校验、预览，再执行。本次两侧客户键均唯一，关联后完整返回 47 行。下面按两位小数展示：

| 客户 ID | 账单金额 | 明细金额 | 差异 |
| --- | ---: | ---: | ---: |
| 1 | 15.84 | 15.84 | 0.00 |
| 2 | 0.99 | 0.99 | 0.00 |
| 3 | 5.94 | 5.94 | 0.00 |
| 全部 47 位合计 | 477.53 | 477.53 | 0.00 |

再发送：

> 查出金额有差异的客户，再分别检查两侧有没有未匹配客户。

Codex 在关联结果上继续筛选，分别执行三项检查：

| 检查 | 方法 | 本次结果 |
| --- | --- | --- |
| 已匹配但金额不同 | `amount_difference != 0` | 0 行 |
| 账单侧有、明细侧未匹配 | 保持账单为左侧，查 `line_customer_id` 为空 | 0 行 |
| 明细侧有、账单侧未匹配 | 改以明细为左侧，查 `bill_customer_id` 为空 | 0 行 |

金额差异与未匹配项要分开查。未匹配时，另一侧金额和差额会为空，单查“差额不为零”会漏掉它们。这里三项均为空，才说明本次客户粒度的核对一致。

## 请求合法，期间也可能选错

为了检查这点，我们只把明细输入改为 2012 年十一月，账单仍取全年，原始数据不变。请求通过校验，核对却返回 **5 位已匹配但金额不同的客户，以及 40 位未匹配到明细的客户**。

这些差异来自人为错配期间，应该先检查两侧日期和业务范围。引擎按请求完成计算，核对所要求的共同口径仍要由 AI 和使用者确认。

本例两侧的客户键在汇总后唯一；换成“客户 × 月份”或“客户 × 币种”时，关联键也要带上相应字段。仅有金额相等，还不足以证明分组对象一致。

## 本章 Foggy 知识点

- **聚合后组合：** 两个 QM 各自筛选、汇总，再通过 Compose 关联。
- **结果粒度：** 按共同业务键对齐，保留两侧标识，确认一行代表什么。
- **派生计算与核对：** 关联后计算差额，分别检查金额差异与双向未匹配记录。

本章 10 项执行已与原始账单、明细及 Decimal 独立核对。完整请求、边界变体与复跑方式见[实操记录](../resources/lab/composition/chapter-27-validation.md)。下一章先圈选金额最高的客户，再分析他们的账单。


---

<a id="chapter-28"></a>

# 第 28 章 先圈选对象，再继续分析

上一章分别汇总两个 QM，再关联核对。本章换一个问题：**先找出 2012 年账单金额最高的三位客户，再查看他们当年的全部账单。**

先选客户，再取账单，两个阶段的粒度不同。这里的“前三”限制客户数，最终账单可以超过三张。

继续使用 `chinook-composition`，Launcher **0.1.42**、CLI **0.1.25**，金额沿用 Chinook 样例原单位。

## 第一步：确认模型，先不查询

在原项目和会话中发送：

> 复用账单模型，确认字段，先不查询。

本章不新增 TM/QM。[账单 QM](../resources/lab/composition/models/query/ChinookInvoiceQueryModel.qm)已有客户 ID、账单 ID、日期与金额，Codex 实时读取查询说明即可。若模型还未加载，先完成校验与加载，再发送查询。

## 第二步：先选三位客户，再关联账单

发送：

> 用 Compose 找出 2012 年账单金额最高的三位客户，再列出他们当年的全部账单。

![查询与建模流程](../assets/diagrams/ch28-10.png)

[实际执行的完整脚本](../resources/lab/composition/queries/chapter-28/03-top-customers-bills.fsscript)如下：

```javascript
const totals = dsl({
    model: 'ChinookInvoiceQueryModel',
    columns: ['customerId as customer_id', 'sum(invoiceAmount) as selection_amount'],
    slice: [{ field: 'invoiceDate', op: '[)', value: ['2012-01-01', '2013-01-01'] }],
    groupBy: ['customerId']
});

const selected = totals.query({
    columns: ['customer_id', 'selection_amount'],
    orderBy: ['-selection_amount', 'customer_id'], limit: 3
});

const bills = dsl({
    model: 'ChinookInvoiceQueryModel',
    columns: ['customerId as bill_customer_id', 'invoiceId as invoice_id',
        'invoiceDate as invoice_date', 'invoiceAmount as bill_amount'],
    slice: [{ field: 'invoiceDate', op: '[)', value: ['2012-01-01', '2013-01-01'] }]
});

const result = selected.join(bills, 'inner', [
    { left: 'customer_id', op: '=', right: 'bill_customer_id' }
]).query({
    columns: ['customer_id', 'invoice_id', 'invoice_date', 'bill_amount'],
    orderBy: ['customer_id', 'invoice_date', 'invoice_id'], limit: 100
});

return { plans: result };
```

`totals` 按客户汇总；`selected` 按金额降序取三位，金额相同时按客户 ID 升序确定顺序。本例取固定三位，不额外纳入并列客户。

`bills` 保持一行一张账单，再用入选客户键关联。客户集合是查询计划的输出，AI 不必抄下三个 ID，再拼一个固定的 `in` 条件。

两处 `limit` 的作用不同：`selected` 的 3 决定入选客户；最终的 100 限制返回账单行。本例只有 7 行，未被截断；实际超过上限时，应分页取齐后再核对。若把最后的限制改为 3，只会留下前三行账单，不能说取回了三位客户的全部账单。

## 第三步：核对客户集合与账单

Codex 先校验、预览，再执行。预览中，客户汇总阶段保留了金额排序与 `LIMIT 3`，之后才关联账单。独立核对得到：

| 入选顺序 | 客户 ID | 2012 年账单金额 | 账单张数 |
| --- | ---: | ---: | ---: |
| 1 | 26 | 25.84 | 2 |
| 2 | 34 | 24.77 | 2 |
| 3 | 13 | 24.75 | 3 |
| 合计 | — | 75.36 | 7 |

最终返回账单 ID、日期、金额，按客户 ID 和日期排列，所以展示顺序不必与排名顺序相同。核对时既要检查入选的客户，也要检查他们的账单是否完整、是否重复。

脚本没有把 `selection_amount` 带到最终明细。它是客户年度合计，关联后会随每张账单重复；若需要一起展示，也不能再按账单行累加它。明细合计应累计 `bill_amount`。

直接查“金额最高的三张账单”是另一个问题。本次会得到客户 26、5、43，与前三客户的集合不同。

## 圈选期间与分析期间可以不同

再发送：

> 仍用这三位客户，改看 2013 年的账单。

保留 `totals` 和 `selected` 的 2012 年范围，只把 `bills` 的日期改为 `[2013-01-01, 2014-01-01)`。这样选人的依据没有变，后续分析的期间变了。

[实际请求](../resources/lab/composition/queries/chapter-28/06-selected-next-year.fsscript)只返回客户 26 的账单 354，金额 **8.91**。使用 `inner` 时，客户 13、34 没有匹配账单，不出现在结果中；需要保留全部三位客户时，改为[LEFT JOIN](../resources/lab/composition/queries/chapter-28/07-selected-next-year-left.fsscript)，未匹配账单的字段为空，不补成零金额账单。

这里描述的是样例中的记录。账单数据最晚到 2013 年 12 月 22 日，不能把样本内未匹配直接解释为现实中整年没有消费。也不要重新按 2013 年排名，那会换掉要观察的客户集合。

## 本章 Foggy 知识点

- **阶段 TopN：** 先按业务指标选定对象，再关联这些对象的记录。
- **范围传递：** 用阶段输出的业务键连接，分别表达圈选期间与分析期间。
- **粒度与完整性：** 客户数、账单行数及最终分页分别核对，汇总金额不随明细重复累计。

本章 10 项执行已与原始账单行及 Decimal 独立核对，包含最终截断、跨期间、保留未匹配客户和空集合。完整请求与复跑方式见[实操记录](../resources/lab/composition/chapter-28-validation.md)。至此，Compose 系列完成了“阶段筛选、分别汇总后关联、先圈选再分析”三个常见用法。


---

<a id="chapter-29"></a>

# 第 29 章 从一份 Excel 开始问数

前面分析的数据已经在数据库里。这次从一份规范 Excel 开始，回答“有多少张账单、总金额多少、哪些客户金额最高”。表头混乱和多份文件合并留到后面。

## 第一步：准备文件，先不查询

下载 [ChinookInvoices2012.xlsx](../resources/lab/files/inputs/ChinookInvoices2012.xlsx)，放到自己的练习目录。文件来自 Chinook v1.4.5 的 2012 年账单，只有一张“账单”工作表：

| 列 | 本例含义 |
| --- | --- |
| 账单编号 | 一张账单一个编号，不重复 |
| 客户编号 | 原 CustomerId 补足五位的教学文本编码，例如 `00026`，保留前导零 |
| 账单日期 | 原账单日期，不做时区转换 |
| 账单国家 | 账单地址国家 |
| 账单金额 | 沿用样例原单位，币种未指定，不做分元换算 |

换成真实文件时，也要先确认编号、日期含义和金额单位，不能只凭列名猜测。

在已准备 Foggy Skill 的项目中发送：

> 确认这份 Excel 的字段和单位，接入 Foggy，先不查询。

## Codex 怎样完成接入

本系列使用 Launcher **0.1.42**、CLI **0.1.25**，文件接入路径如下：

![查询与建模流程](../assets/diagrams/ch29-11.png)

Python 配合 openpyxl 读取文件，Foggy 负责语义模型与查询。此版本采用这条组合路线，未使用原生文件上传入口。

Codex 大致按下面的顺序处理：

| 步骤 | 处理内容 | 读者应看到什么 |
| --- | --- | --- |
| 看文件 | 读取工作表、表头、类型与样例，检查编号重复和缺值 | 一行代表一张账单；编号保留为文本，日期和金额类型明确 |
| 准备副本 | 用[解析脚本](../resources/lab/files/tools/import-invoices.py)将固定值加载到独立 SQLite，记录原文件摘要和行号 | 原 Excel 保留，数据表为 `file_invoices`，错误行明确报出 |
| 接入数据源 | 用 CLI 注册、测试并绑定命名数据源 | 连接成功，能检查到实际表结构 |
| 建模与加载 | 写 TM/QM，校验后加载，再实时获取查询说明 | 字段已公开，模型可以查询 |

配套项目为 `lab/files`，服务端口 **18368**，本章 Namespace 为 `file-invoices`。后续案例各用独立 Namespace，前面章节的 18067 服务保留。读者使用自己的路径和实际服务地址即可。

[FileInvoiceModel.tm](../resources/lab/files/models/model/FileInvoiceModel.tm)映射物理字段，[FileInvoiceQueryModel.qm](../resources/lab/files/models/query/FileInvoiceQueryModel.qm)公开业务字段和指标。后续 DSL 使用 `customerCode`、`invoiceAmount` 等语义字段名；Markdown 由引擎根据已加载代码实时生成，无须提前手写。

配套解析器只适配本例，换文件时要调整已确认的表头和类型规则。这里建立的是**普通持久语义模型**，重启后仍保留，需要管理重载与清理；完整能力边界见[配套记录](../resources/2026-10-13/nonstandard-file-qm-capability-review-2026-10-25.md)。

## 第二步：查询，再核对文件

接入完成后发送：

> 统计账单总数和总金额，再列出金额最高的三位客户。

Codex 通过 CLI 先校验，再执行。账单总数与金额的[实际 DSL](../resources/lab/files/queries/01-file-total.json)为：

```json
{
  "columns": ["sum(invoiceAmount) as amount", "count(invoiceId) as bills"],
  "limit": 1
}
```

本次返回 **83 张账单，总金额 477.53**。再按客户编号汇总、金额降序排列，得到：

| 客户编号 | 账单金额 |
| --- | ---: |
| `00026` | 25.84 |
| `00034` | 24.77 |
| `00013` | 24.75 |

结果已与文件逐行核对，并对回原始账单；前导零保留，按账单国家汇总的 20 组结果也一致。执行成功后，还要确认记录和金额与源文件对得上。

## 文件更新后，还要重新加载

本例按完整文件快照替换分析账单表，重复加载后数量和金额不变。文件更新时先重载数据，再查询核对；只刷新 QM 不会重新读取 Excel。多文件追加和更正留到第 31 章。

## 本章 Foggy 知识点

- **文件与引擎的分工：** AI 配合解析工具准备数据，再通过公开 CLI 接入数据源和语义模型。
- **字段与来源：** 将文件列映射为语义字段，保留编号类型、单位、原文件及行号。
- **加载与核对：** 区分数据重载和模型刷新，查询后核对源文件，而非只检查请求是否成功。

规范 Excel 与 CSV 的接入、查询和重复加载已经实际验证；源码、响应及复跑方法见[实操记录](../resources/lab/files/README.md)。下一章再处理一张表里混有标题、小计和不同格式的数据。


---

<a id="chapter-30"></a>

# 第 30 章 不规范的 Excel 怎么整理

上一章第一行是表头，一行一张账单。这次的[报表](../resources/lab/files/series-inputs/Invoices2012Messy.xlsx)夹有标题、空行和合计，日期与金额单位也不统一，需要先整理。

文件基于同一批 Chinook 账单改编。为演示换算，本章将金额标为**教学元 / 分**，不代表 Chinook 原始币种；另加三条待确认的教学记录。

## 第一步：先看文件，确认规则

发送：

> 检查报表，列出需要确认的整理规则，先别导入。

Codex 检查工作表、表头位置、单元格类型和样例。本例确认下面的规则：

| 发现 | 处理约定 |
| --- | --- |
| 前两行是标题和空行 | 第三行才是表头，数据从第四行开始 |
| 数据区夹有空行和“合计” | 按已确认标记排除，保留排除记录；合计不是一张账单 |
| 编号有文本、空格和数字格式 | 去首尾空格；已确认编码固定五位，才可补足前导零 |
| 日期有真实日期、年/月/日文本 | 按明确格式统一；`03/04/2012` 无法确定月日顺序，待确认 |
| 金额混有元、分 | 分除以 100 转为元；未知单位待确认 |
| 金额含公式 | 本例只接收固定值；公式结果需确认后再提交 |

没有“五位编码”的约定，数字 26 原来是不是 `00026`，AI 也猜不回来。公式则要区分公式文本、缓存值与重算结果；本例读到缓存后，仍按只接收固定值的约定保留待确认。

## 第二步：整理可确认部分，先不查询

规则明确后发送：

> 先接入已确认的记录，待确认的单独保留，先不查询。

这是对**部分接入**的明确选择；默认检查遇到待确认记录会停止。

Codex 使用[配套整理代码](../resources/lab/files/tools/series_data.py)，保留原文件，将转换后的数据写入独立分析副本，并保存文件名、原行号、原值和转换结果。本次整理报告为：

| 分类 | 数量 |
| --- | ---: |
| 可确认账单 | 83 |
| 待确认记录 | 3 |
| 排除的空行 | 1 |
| 排除的显示合计 | 1 |

待确认条目和转换过程见[整理报告](../resources/lab/files/series/chapter-30/evidence/normalization-audit.json)。本章 Namespace 为 `file-cleaning`，保留 `sourceRow` 原行号，并补充 `sourceFile` 文件名；源码见 [TM](../resources/lab/files/series/chapter-30/models/model/FileInvoiceModel.tm) 和 [QM](../resources/lab/files/series/chapter-30/models/query/FileInvoiceQueryModel.qm)。入库金额已统一为教学元，查询时不再换算。

## 第三步：核对金额与来源

模型校验、加载后，Codex 实时读取查询说明，再发送：

> 核对已接入的数量和金额，抽查原文件行。

CLI 校验、执行后得到 **83 张、477.53 元**。明细和国家汇总均与原始账单一致，前导零保留，每条记录能对回报表行。

最终回答要注明“仅统计已确认部分，另有 3 条未计入”。这个范围也写在 QM 说明中，避免把部分结果说成整份报表的完整结果。

## 本章 Foggy 知识点

- **准备层与语义层：** 文件格式、编号和单位先按确认规则整理，Foggy 查询规范化后的字段。
- **查询上下文：** 单位与接入范围写进语义说明，Markdown 按已加载代码实时获取。
- **来源追溯：** 公开文件与行号字段，查询结果能回到原记录。

本章 4 项语义查询及默认阻止部分接入的检查已实测，见[系列实操](../resources/lab/files/series/README.md)。下一章处理多份文件之间的重复、追加和更正。


---

<a id="chapter-31"></a>

# 第 31 章 多份文件如何合并与更新

一份文件整理好了，接下来常见的是“每月再发一份”。有的文件重发旧记录，有的改了列名，还有人把更正后的金额单独发来。全部追加，数据迟早翻倍。

本章用[十二份月度 CSV 和更新清单](../resources/lab/files/series-inputs/monthly/manifest-v3.json)继续练习。重叠、新增和更正均为教学改编，金额沿用本系列的教学元。

## 第一步：确认怎样认定同一条记录

发送：

> 检查重复和更正，列出合并规则，先别导入。

本例所有文件来自同一套账单系统，账单编号在这个范围内唯一；来自多个系统时，通常还要带上来源标识，不能只碰巧编号相同就当作一张账单。

本次采用的规则是：

| 情况 | 处理 |
| --- | --- |
| 同编号、同内容 | 同一事实重复出现，只保留一次，记下所有来源 |
| 新编号 | 按明确的追加批次加入 |
| 同编号、不同内容 | 先报冲突；有已确认的更正声明和更高版本，才替换旧内容 |
| 某份文件改了列名 | 按已确认的列映射整理，缺必需列时停止 |
| 月度文件缺失 | 对照接收清单检查，不当作无业务或删除；撤销记录须另有明确规则 |

下载、复制会改变文件时间，不能据此决定更正优先级。文件齐全后，还要对照源业务控制数核对记录是否完整。

## 第二步：按清单合并，先不查询

确认规则后发送：

> 按规则合并，保留处理记录，先不查询。

Codex 使用[合并代码](../resources/lab/files/tools/series_data.py)读取文件清单、批次类型和版本，检查后再写入分析副本。二月文件的列名变化按映射处理，重发的一月账单只保留一次。文件名表示导出批次，统计仍以账单日期为准。

本例按清单重建完整快照，重复运行结果不变，尚未采用文件级增量加载。

Namespace 为 `file-monthly`，使用[账单 TM](../resources/lab/files/series/chapter-31/models/model/FileInvoiceModel.tm) / [QM](../resources/lab/files/series/chapter-31/models/query/FileInvoiceQueryModel.qm)。字段含义不变时，无须每收到一个文件就重新建一套语义模型。

## 第三步：核对每批变更

发送：

> 核对合并后的账单数、金额，以及新增和更正的记录。

本次逐阶段实际执行得到：

| 输入清单 | 账单数 | 金额 |
| --- | ---: | ---: |
| 十二份月度文件，含一条重复 | 83 | 477.53 |
| 加入一张 10 元的教学账单 | 84 | 487.53 |
| 原账单 250 从 13.86 更正为 14.86 | 84 | 488.53 |
| 同一最终清单再次运行 | 84 | 488.53 |

更正增加了 1 元，没有增加账单张数；新增账单同时改变张数和金额。原始文件仍保留，更正来源记为 `correction.csv`，可以通过语义字段追溯。

另用“同键金额不同但没有更正声明”和“缺少六月文件”验证，两者均在写入前被拒绝，原分析副本不变。

## 本章 Foggy 知识点

- **稳定语义入口：** 文件批次变化，查询仍使用同一组业务字段。
- **数据重载：** 数据副本更新后直接核对；字段定义变更时才同时调整模型。
- **可追溯查询：** 用账单键、源文件和版本记录解释结果变化。

本章 7 项查询通过，84 条更新后明细已独立核对，两项准备层拒绝也已验证，见[实操](../resources/lab/files/series/README.md)。下一章把同样的口径确认方法用于遗留数据库。


---

<a id="chapter-32"></a>

# 第 32 章 遗留数据库如何建立语义层

文件有表头混乱，旧系统也可能有含义不明的字段、过时金额和状态码。让 AI 学会 SQL，不代表它知道 `v0`、`v1` 哪个才是现在使用的金额。

本章用同一批账单改编出[教学旧库](../resources/lab/files/series-data/legacy.sqlite)，配有[字段说明](../resources/lab/files/series-inputs/legacy-contract.json)。状态、历史字段及两张额外账单均为教学设定，原 Chinook 数据没有修改。

## 第一步：解释旧字段，先不建模

发送：

> 检查这份旧库和字段说明，整理账单口径，先不建模。

Codex 通过 CLI 查看表结构、键和少量样例，再结合提供的业务说明确认：

| 字段或对象 | 本例解释 |
| --- | --- |
| `id0` | 账单编号 |
| `c0` | 旧客户编码，通过唯一映射表转换为五位客户编号 |
| `d0` | 账单日期 |
| `v0` | 当前金额，存分，转换为教学元时除以 100 |
| `v1` | 人为设置的旧口径金额，不再使用 |
| `s0` | `V` 有效、`X` 作废、`D` 草稿；本次只分析有效账单 |

有 JPA / MyBatis 源码、旧报表或字典时，可以让 AI 协助追溯，再由了解业务的人确认。不能只凭几条样例，就认定字段已废弃或两个编码属于同一客户。

## 第二步：把确认过的规则写进语义模型

发送：

> 按确认口径建立有效账单分析，校验并加载，先不查询。

本章使用 Namespace `file-legacy`。[LegacyBillModel.tm](../resources/lab/files/series/chapter-32/models/model/LegacyBillModel.tm)通过 `viewSql` 定义模型内视图，负责客户映射、状态范围和分转元。以下是该配置中的 SQL 内容：

```sql
SELECT b.id0 AS invoice_id, m.customer_code,
       b.d0 AS invoice_date, b.n0 AS billing_country,
       b.v0 / 100.0 AS invoice_amount
FROM legacy_bill b
JOIN legacy_customer_map m ON b.c0 = m.old_code
WHERE b.s0 = 'V'
```

这段 SQL 是建模人员维护的固定定义。查询时 LLM 使用 `invoiceAmount`、`customerCode` 等公开字段，不必再次判断状态码或除以 100。客户映射已核对为一对一，不能改用非唯一名称关联。

本例只配置 `viewSql`。不要同时填写优先级更高的 `tableName`，否则查询仍会指向原表。

[QM](../resources/lab/files/series/chapter-32/models/query/LegacyBillQueryModel.qm)公开确认后的业务字段，校验、加载后实时获取查询说明。

## 第三步：用普通问题检查口径是否落实

发送：

> 统计有效账单数量和金额，再按客户核对。

原教学旧表有 **85 条**：83 条有效，另有一条作废和一条草稿。语义查询实际返回 **83 张、477.53 元**，47 位客户的金额逐项与原始账单一致。

直接按作废、草稿的教学编号查询，结果为空；尝试选择未公开的 `v1`，在校验阶段被拒绝，没有执行。前者由固定业务范围落实，后者由查询字段边界落实。旧库文件摘要没有变化。

## 本章 Foggy 知识点

- **模型内视图：** 用 `viewSql` 组织已确认的旧字段、固定范围和关联映射。
- **统一业务表达：** 将分转元等确定性工作放到语义定义，查询只使用规范字段。
- **公开面与回归：** QM 只公开当前字段，核对过滤、金额和客户集合是否一致。

本章 3 项查询及一项错误字段拒绝通过，见[实操](../resources/lab/files/series/README.md)。下一章在这套已确认的实际数据旁，再接入一份 Excel 目标。


---

<a id="chapter-33"></a>

# 第 33 章 系统实际与 Excel 目标一起分析

实际账单在系统里，目标却由业务人员维护在 Excel 中。要回答“完成了多少”，先要确认两边说的是同一批客户、同一期间和同一种金额。

本章复用上一章的 83 张有效账单，接入[教学目标文件](../resources/lab/files/series-inputs/Targets2012-v1.xlsx)，不混入第 31 章另设的追加与更正。目标为教学设定，双方金额均按教学元比较。

## 第一步：确认对齐规则，先不查询

发送：

> 检查实际与目标，确认客户、期间和单位，先不查询。

本次确认：

| 对齐项 | 约定 |
| --- | --- |
| 主体 | 五位客户编号精确匹配，不按姓名猜测 |
| 粒度 | 目标一行一客户一年一个版本；实际按客户汇总同年账单 |
| 期间与版本 | 2012 年，目标版本 `v1` |
| 单位 | 双方均为教学元，准备时确认，不在查询结果里临时凑换算 |
| 缺失与零 | 没有目标、目标为零、没有匹配实际分别表示 |

目标唯一键是“客户编号、年份、版本”。同一个键重复，应先确认哪条有效，不能直接求和，也不能任取第一条。

## 第二步：准备共同分析副本

发送：

> 按确认规则准备实际与目标分析，校验并加载，先不计算完成率。

0.1.42 的直接跨源 JOIN 尚有[已记录的校验缺口](../resources/lab/files/findings/cross-source-validation-0.1.42.md)。本例通过系统 QM 提取有效账单，与解析后的目标文件放入同一个独立 SQLite，先完成数据准备，再交给引擎查询。

这条路线使用同库分析副本。提取时另查总数，确认 83 行取齐；超过返回上限时须补齐分页，不能用截断数据计算完成率。

本章 Namespace 为 `file-target-analysis`，源码包括：

| 模型 | 内容 |
| --- | --- |
| [实际 TM](../resources/lab/files/series/chapter-33/models/model/AnalysisInvoiceModel.tm) / [QM](../resources/lab/files/series/chapter-33/models/query/AnalysisInvoiceQueryModel.qm) | 系统有效账单的分析副本，保持账单粒度 |
| [目标 TM](../resources/lab/files/series/chapter-33/models/model/AnalysisTargetModel.tm) / [QM](../resources/lab/files/series/chapter-33/models/query/AnalysisTargetQueryModel.qm) | 公开客户、年份、版本及目标金额 |

两套模型校验、加载后，再实时获取查询说明。

## 第三步：先汇总实际，再关联目标

发送：

> 查看 2012 年 v1 完成情况，并检查未匹配客户。

Codex 按客户汇总实际，筛出目标的年份和版本，再以目标为左侧关联。沿用前面的 Compose 用法，[完整请求](../resources/lab/files/series/chapter-33/queries/33-achievement.fsscript)中完成率表达式为：

```text
actual_amount / NULLIF(target_amount, 0) AS achievement
```

实际值和目标值保留，便于核对。CLI 校验、预览并执行后，按百分比展示如下：

| 客户编号 | 目标 | 实际 | 完成率 |
| --- | ---: | ---: | ---: |
| `00002` | 0.00 | 0.99 | 目标为零，不计算 |
| `00013` | 20.00 | 24.75 | 123.75% |
| `00026` | 30.00 | 25.84 | 86.13% |
| `00034` | 25.00 | 24.77 | 99.08% |
| `00999` | 10.00 | 未匹配 | 不计算 |

`00002` 的目标为零，不能作分母；`00999` 未匹配实际，应先检查是否无交易、编号不一致或数据缺失。本例都保留空值，不能统一解释成 0%。

## 核对两侧范围

只以目标为左侧，看不到目标表以外的客户。本次系统实际有 **47 位**客户，目标表有 **5 位**：4 位匹配，43 位实际客户没有目标，另有 1 位目标客户未匹配实际。

因此又分别执行[实际未匹配目标](../resources/lab/files/series/chapter-33/queries/33-actual-without-target.fsscript)和[目标未匹配实际](../resources/lab/files/series/chapter-33/queries/33-target-without-actual.fsscript)。整体实际 477.53 不能直接除以这份局部目标 85；要算整体完成率，先明确共同范围和缺失处理。

本次还故意传入一个重复目标键，准备层在写入前拒绝，原分析副本保持不变。

## 本章 Foggy 知识点

- **有界提取与同库准备：** 复用系统 QM 的公开字段，确认结果取齐后再生成分析副本。
- **共同粒度与范围：** 分别限定实际期间、目标年份和版本，先汇总再关联。
- **比值与覆盖率：** 用 `NULLIF` 处理零分母，另外核对双向未匹配，避免局部目标冒充整体范围。

本章 6 项查询及重复目标拒绝已实际验证，见[系列实操](../resources/lab/files/series/README.md)。下一章把提取、准备和核对串成可反复运行的脚本。


---

<a id="chapter-34"></a>

# 第 34 章 让文件分析持续复跑

下一版文件来了，提取、校验、加载和回归可以自动复跑；文件版本与口径是否可信，仍需要业务确认。

本章复用上一章的实际和目标，不增加新的分析问题。以独立的 `file-regression-current` 作为当前已验收入口，保留第 33 章的 `v1` 示例。

## 第一步：让 Codex 生成回归脚本

发送：

> 把接入和核对做成脚本，全部通过后再切换到新快照。

Codex 可以参考已有脚本、DSL 和验收结果，生成[本例运行脚本](../resources/lab/files/tools/run-file-regression.py)。它做的是：

![查询与建模流程](../assets/diagrams/ch34-12.png)

每轮生成独立数据快照与模型副本，在候选 Namespace 中验证，通过后切换当前数据源绑定，再复查。旧快照与报告保留。

## 第二步：固定该检查什么

本例的检查来自前几章已经确认的业务约定：

| 检查 | 本例要求 |
| --- | --- |
| 文件 | 客户编号类型、年份、版本、单位和目标唯一键正确 |
| 提取完整性 | 系统 QM 提取行数等于总数，不能因返回上限少取记录 |
| 模型 | 先校验、加载，再实时获取元数据 |
| 实际 | 83 张账单、477.53 元，与只读原始记录独立核对 |
| 目标与关联 | 目标合计、完成率、真实零和双向未匹配符合已审核基线 |
| 切换后 | 当前入口能读到刚验收的目标版本，再更新验收记录 |

期望值应来自审核过的输入和独立核对，不能让脚本先取结果，再把结果保存成“应该得到的答案”。正常业务变化需要审核新基线；数据错误不能靠 AI 自动改期望值放行。

## 第三步：换输入，再运行同一套检查

先使用 [v1 目标](../resources/lab/files/series-inputs/Targets2012-v1.xlsx)运行。之后，本例明确批准一项教学变更：客户 `00026` 的目标从 30 改为 35 元，其他内容不变，版本为 [v2](../resources/lab/files/series-inputs/Targets2012-v2.xlsx)。

发送：

> 改用已确认的 v2 目标文件，复跑并说明变化。

两轮均先在候选快照中校验、查询，再切换和复查，实际结果为：

| 指标 | v1 | v2 |
| --- | ---: | ---: |
| 实际账单数 | 83 | 83 |
| 实际金额 | 477.53 | 477.53 |
| 目标合计 | 85.00 | 90.00 |
| `00026` 完成率 | 86.13% | 73.83% |

变化来自目标更新，不能解释成实际业务下降。实际与目标、数据版本和统计期间应一起报告。

脚本记录输入摘要、提取请求、模型版本、数据快照、响应和验收结果，`latest-accepted.json` 指向通过全部检查的运行。

## 失败时，确认当前入口没有被带坏

本次又验证了两种失败：

- 把 v1 文件按 v2 版本提交，在输入校验时停止，没有切换。
- 在切换后故意模拟复查失败，脚本恢复前一数据源绑定，验收记录保持不变。

失败后再次查询，当前已验收目标合计仍为 **90 元**。这两项是脚本故障检查，不是新发现的引擎 BUG。

排查时先定位失败阶段：输入检查、数据准备、模型加载、查询核对，还是入口切换。

## 保留与清理，也要有边界

原始输入、报告和已验收快照按保留规则管理。候选 Namespace、Bundle、数据源及文件不会自动过期，清理前须确认当前入口没有引用，再通过公开管理入口清理注册和对应文件。

## 本章 Foggy 知识点

- **候选验证：** 在独立数据源与 Namespace 中校验模型、执行固定语义查询。
- **验收后切换：** 使用公开绑定和刷新入口，切换后再复查；失败保留或恢复上次入口。
- **版本与复跑：** 固定查询、独立基线和运行记录共同解释结果变化。

两轮成功流程各完成 8 项执行，输入拒绝和切换后恢复也已验证，完整范围见[实操](../resources/lab/files/series/README.md)。文件系列至此覆盖了规范接入、整理、多文件合并、旧库映射、目标对齐和持续复跑。


---

<a id="chapter-35"></a>

# 第 35 章 用 SQL 表达语义查询

前面让 Codex 直接写 DSL。这次用 SQL 表达同一个问题，仍复用已有语义模型。业务用户也可以继续用自然语言提问。

本章的链路是：**SQL 表达查询意图 → Codex 转为 DSL → Foggy 校验并编译物理 SQL → 数据库计算。** 0.1.42 已核对的公开入口未发现原生语义 SQL 解析器，转换由 AI 完成，详见[入口核对](../resources/2026-10-13/semantic-sql-entry-check-0.1.42-2026-10-25.md)。

## 第一步：读取已有模型，先不查询

复用第32章的 `file-legacy` Namespace 与 `LegacyBillQueryModel`，仍使用文件教学服务。本章不新建模型，也不重新导入数据。

发送：

> 读取 file-legacy 的账单模型，确认字段和单位，先不查询。

Codex 实时读取查询说明，确认：

| 公开字段 | 本例含义 |
| --- | --- |
| `invoiceId` | 账单编号 |
| `customerCode` | 五位客户编码，保留前导零 |
| `invoiceDate` | 账单日期 |
| `billingCountry` | 账单地址国家 |
| `invoiceAmount` | 已换算为教学元的账单金额，可求和 |

语义模型已经限定有效账单，作废和草稿不在查询范围内。Markdown 由引擎根据已加载代码实时生成，无须提前维护。

## 第二步：用 SQL 表达客户排名

仍然查询“2012年金额最高的三位客户”。把下面的 SQL 一起交给 Codex：

> 把这段 SQL 转为 DSL 查询，再核对全年账单数和金额。

```sql
SELECT customerCode, SUM(invoiceAmount) AS amount
FROM LegacyBillQueryModel
WHERE invoiceDate >= '2012-01-01'
  AND invoiceDate < '2013-01-01'
GROUP BY customerCode
ORDER BY amount DESC, customerCode ASC
LIMIT 3;
```

`FROM` 后面是 QM 标识，字段来自刚读取的查询说明。这里把 QM 看作一张“虚拟业务表”，不需要在数据库中创建同名表或视图。

Codex 在当前会话将它转换为：

```json
{
  "columns": ["customerCode", "sum(invoiceAmount) as amount"],
  "slice": [{"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}],
  "groupBy": ["customerCode"],
  "orderBy": ["-amount", "customerCode"],
  "limit": 3
}
```

对应关系如下：

| SQL | 本次转换 |
| --- | --- |
| `FROM` | CLI 的模型参数 `LegacyBillQueryModel` |
| `SELECT` | `columns`，包含求和与结果别名 |
| `WHERE` | `slice`，两个日期条件合成左闭右开区间 |
| `GROUP BY` | 按 `customerCode` 分组 |
| `ORDER BY` | 金额降序，同额时客户编码升序 |
| `LIMIT` | 返回上限 `limit` |

CLI 提交的是这份 JSON，QM 名作为模型参数单独传入。`sql query` 则直接探查物理数据库，本次用 QM 名调用时返回了 `no such table: LegacyBillQueryModel`。

## 第三步：看规则有没有继续生效

Codex 通过 CLI 先提交 `query validate`，通过后再提交同一份 DSL 执行。Foggy 按语义模型编译，数据库计算后返回：

| 客户编码 | 金额（教学元） |
| --- | ---: |
| `00026` | 25.84 |
| `00034` | 24.77 |
| `00013` | 24.75 |

另查总量仍为 **83张、477.53教学元**，客户排名与只读原始账单逐项核对一致。

查看引擎返回的[物理SQL](../resources/lab/semantic-sql/chapter-35/evidence/35-top-physical.sql)，其模型展开部分仍包含：

```sql
FROM (
  SELECT b.id0 AS invoice_id, m.customer_code,
         b.d0 AS invoice_date, b.n0 AS billing_country,
         b.v0 / 100.0 AS invoice_amount
  FROM legacy_bill b
  JOIN legacy_customer_map m ON b.c0 = m.old_code
  WHERE b.s0 = 'V'
) t1
```

这段规则来自第32章的 TM，Foggy 在编译时将其展开。前面的 SQL 引用业务字段，不再重复状态条件或除以100；按作废、草稿编号查询也返回空集。

如果追问“只保留年度金额超过25的客户”，条件应是 `HAVING SUM(invoiceAmount) > 25`。转换时给上面的 DSL 增加 `having`，以下只展示新增属性：

```json
{
  "having": [{"field": "amount", "op": ">", "value": 25}]
}
```

沿用上述日期与分组后，本次只返回 `00026`、25.84。把条件放入 `slice`，会变成先筛单张账单，已经换了问题。

## 转换后仍要检查什么

AI 可能看懂 SQL，却在转换时漏掉条件、用错别名或筛选阶段。引擎校验能检查请求是否合法，不能证明 AI 完整保留了原意。

例如要求查询旧字段 `v1`，Codex 应根据元数据说明它未公开。本次另将含 `v1` 的错误 DSL 送入校验，得到 `FIELD_NOT_FOUND`，没有执行。不能为了完成 SQL 请求而改查物理表。

## 本章 Foggy 知识点

- **语义查询视角：** SQL 使用 QM 标识与公开字段，仍需实时元数据。
- **转换与执行分工：** AI 转换、CLI 提交、Foggy 校验与编译，数据库计算。
- **核对原意：** 除数值外，还要检查日期、分组、筛选阶段与返回范围。

本章4项查询、1项字段拒绝已实测，源码与响应见[实操记录](../resources/lab/semantic-sql/README.md)。下一章将转换前后的含义与约束整理成可复跑的核对案例。


---

<a id="chapter-36"></a>

# 第 36 章 SQL 转成 DSL 后，怎样核对查询含义

上一章已经跑通 SQL 表达、AI 转 DSL、引擎执行的链路。这次检查：请求能执行，是否还在回答原来的问题？

仍使用 `file-legacy` 的 `LegacyBillQueryModel`，不新建模型。当前路线由 Codex 转换，Foggy 校验和编译，数据库计算。

## 第一步：先确定怎样算对

把[本章 SQL 文件](../resources/lab/semantic-sql/chapter-36/queries/index.md)交给 Codex，发送：

> 整理这些 SQL 的含义和核对项，先不查询。

Codex 实时读取模型元数据，确认字段与单位。核对时固定同一份数据、模型版本和调用身份；本例仍按2012年有效账单、教学元统计，空集案例另行指定客户或期间。

排名和 `HAVING` 复用上一章已确认的请求，新增案例的预期值从只读原始账单独立计算。不能让 AI 同时写两份请求，结果相同就算互相证明。

## 第二步：转换后查询，再核对计算含义

清单确认后发送：

> 转成 DSL 查询，并与已确认结果核对。

同一份 DSL 先校验，再执行。本次主要看下面两种情况。

### 平均金额，分子和分母都要保留

SQL 表达如下：

```sql
SELECT SUM(invoiceAmount) AS amount, COUNT(invoiceId) AS bills,
       SUM(invoiceAmount) / NULLIF(COUNT(invoiceId), 0) AS averageAmount
FROM LegacyBillQueryModel
WHERE invoiceDate >= '2012-01-01' AND invoiceDate < '2013-01-01'
LIMIT 1;
```

这个 QM 没有固定的均额指标。Codex 按第10章的方法，在本次请求中定义临时计算字段：

```json
{
  "columns": ["sum(invoiceAmount) as amount", "count(invoiceId) as bills", "averageAmount"],
  "calculatedFields": [{"name": "averageAmount", "expression": "sum(invoiceAmount) / nullif(count(invoiceId), 0)"}],
  "slice": [{"field": "invoiceDate", "op": "[)", "value": ["2012-01-01", "2013-01-01"]}],
  "limit": 1
}
```

`averageAmount` 是这里声明的临时名称，不是给 QM 新增字段。本次返回金额477.53、账单数83、均额5.753373…，展示为 **5.75教学元**。

再按账单国家分组，20组金额、张数和均额也逐项一致。如果把20个国家的均额直接平均，会得到约5.84；整体均额仍是5.75。公式看着像“求平均”，转换前后的计算范围也必须相同。

### 校验通过，筛选阶段仍可能写错

上一章要求“客户年度合计超过25”，正确转换是 `having`。本次故意构造一个错例：去掉 `having`，改为在 `slice` 中加入单张金额条件。以下只展示这个错误属性：

```json
{
  "slice": [{"field": "invoiceAmount", "op": ">", "value": 25}]
}
```

完整错例仍保留2012年日期、客户分组和排序，结果却不同：

| 请求含义 | 校验 | 实际结果 |
| --- | --- | --- |
| 按客户汇总后，合计超过25 | 通过 | `00026`，25.84 |
| 先筛单张金额超过25，再按客户汇总 | 通过 | 空集 |

因为本期没有单张金额超过25的账单。这是为教学故意改错的请求，不是本次观察到的 LLM 生成失败；引擎正确执行了收到的条件，却无从知道它偏离了输入 SQL。

所以要先比较字段、日期、分组、筛选阶段和排序，再比结果。只看“执行成功”，不够。

### 空集、同额与错误别名也要检查

这些边界通常不需要复杂 SQL，却容易在转换或回答时被忽略。本次又验证了：

| 案例 | 实际结果与解释 |
| --- | --- |
| 查客户 `00999` 的账单明细 | 返回空数组，表示当前范围没有匹配记录 |
| 汇总没有账单的1990年 | 返回 `bills: 0`；金额与均额为 NULL，当前默认响应省略这两个键，不能说均额为0 |
| 同额客户排序 | `00013`、`00051` 都是24.75；按客户编码升序，前三名取 `00013`。47位客户完整顺序已核对 |
| 选列别名是 `amount`，排序却写 `totalAmount` | DSL校验返回 `FIELD_NOT_FOUND`，没有执行 |

别名错误由引擎校验发现；把条件放错阶段，则要靠原意与结果核对。AI 不能仅凭名称相近就替换字段，也不能把含义不明的 `WHERE amount > 25` 自动改成 `HAVING`：这里的 `amount` 是聚合结果，应先确认用户要筛单张还是年度合计。

## 第三步：留下可复跑的核对

确认含义和结果后发送：

> 把这些已确认的用例加入回归，以后修改后复跑。

沿用已有回归方法，保存输入 SQL、转换后的 DSL、独立预期和实际响应。本章的[核对脚本](../resources/lab/checks/verify-chapter36-query-meaning.py)重放已保存的请求；它不会重新让 AI 理解 SQL 或生成 DSL。输入含义变了，预期值也需要重新审核。

## 本章 Foggy 知识点

- **计算字段：** 聚合表达式、分组范围与零分母处理必须保留。
- **校验边界：** 字段与别名错误可被拒绝，合法请求仍可能表达错问题。
- **语义回归：** 同时核对请求含义、数值、空值和顺序，保留独立基准。

本章7项正常查询、1项教学错例执行及1项校验拒绝均已验证，见[实操记录](../resources/lab/semantic-sql/chapter-36/README.md)。本系列核对的是保存的转换案例，不据此判断 SQL 或 DSL 哪种更准确；性能与持续运行留待下一系列规划。


---

<a id="chapter-37"></a>

# 第 37 章 查询慢在哪里：先看执行解释

查询结果正确后，才轮到性能。先判断时间花在哪里，再考虑减少扫描、缓存或预聚合，不能看到第二次更快就宣布优化成功。

本系列使用独立的 `chinook-performance` Namespace，沿用0.1.42文件教学服务。数据是2012年83张账单的教学副本，金额477.53教学元；数据很小，用来验证执行行为，不用于证明加速倍数。

## 第一步：准备独立副本

发送：

> 准备独立的账单性能练习项目，保留原数据，校验并加载模型。先不查询。

Codex通过CLI检查连接与表结构，准备[源表TM](../resources/lab/performance/models/model/PerfSourceModel.tm)和[QM](../resources/lab/performance/models/query/PerfSourceQueryModel.qm)。模型一行一张账单，公开国家、日期、金额与张数；完整准备和复跑代码见[实操记录](../resources/lab/performance/README.md)。

## 第二步：先查，再解释这条查询

发送：

> 按国家统计账单金额和张数，再解释它怎样计算。

Codex实时读取元数据，先校验、执行下面的请求：

```json
{
  "columns": ["country$id", "invoiceAmount", "invoiceCount"],
  "groupBy": ["country$id"],
  "orderBy": ["country$id"],
  "limit": 100
}
```

本次返回20个国家，合计83张、477.53。再通过 `query explain` 查看这条请求的解释与物理SQL。金额的 `SUM`、张数的 `COUNT`、国家分组和排序已经进入数据库查询；不是取回全部账单后让LLM逐条算。

解释返回 `basis=RECOMPILED`，表示重新编译这份请求。它有助于理解计划，不能当作此前执行的完整历史轨迹，也不等于数据库真实耗时分析。

## 从哪里判断还能优化

| 观察 | 下一步 |
| --- | --- |
| 返回大量无用明细 | 在含义不变的前提下，只选需要的字段或直接聚合 |
| 日期范围很大 | 先确认业务需要，再检查过滤、数据库索引和计划 |
| 高频重复的同一查询 | 检查是否有结果缓存及可靠的命中证据 |
| 大量重复聚合 | 评估预聚合的粒度、刷新成本与正确性 |

下推只说明某项计算交给了数据库，不保证它已经足够快。索引是否合适，需要数据库计划与真实数据量支撑；不能为了让查询更快而缩小用户要求的范围。

## 本章 Foggy 知识点

- **查询解释：** 针对具体问题调用 `query explain`，区分重新编译与历史执行证据。
- **计算下推：** 核对过滤、聚合和排序在何处执行。
- **先正确再优化：** 优化前后使用同一口径与独立答案，不用小样例推导性能倍数。

本章国家汇总与解释已验证。下一章先看重复查询与数据更新。


---

<a id="chapter-38"></a>

# 第 38 章 重复查询与数据更新：怎样判断缓存

同一个问题第二次回答更快，原因可能是连接已经建立、模型已经加载，或数据库自己的缓存。仅凭耗时，判断不了是否命中Foggy的查询结果缓存。

继续使用上一章的独立副本和 `PerfSourceQueryModel`。

## 先看证据，再谈命中

发送：

> 重复上一条国家汇总，检查结果和缓存证据。

两次国家汇总均为20组、83张、477.53。当前解释中的L1/L2执行信息为 `NOT_EVALUATED`，没有可据此确认的结果缓存命中记录。标准包核对也未发现查询结果缓存模块，不能把资料中其他宿主的缓存配置直接套进来。

这里的L1/L2名称也要看具体实现：不能只凭“一级、二级”推断它是本地内存、Redis或某一种查询键。配置和命中证据应一起核对。

## 在副本中检查数据新鲜度

发送：

> 在练习副本中把一张账单增加1元，再查同一个汇总，验证后恢复。

本例只修改独立教学表，不动原始Chinook库。仍调用同一个源表QM，结果为：

| 状态 | 汇总金额（教学元） |
| --- | ---: |
| 修改前 | 477.53 |
| 副本账单250增加1元 | 478.53 |
| 恢复后 | 477.53 |

本次源查询能读到修改后的数据，没有观察到旧结果阻断更新。这不证明所有部署都没有缓存，更不证明数据修改会自动清除任何宿主的缓存。

## 启用缓存前，要确认哪些事

| 问题 | 为什么要确认 |
| --- | --- |
| 允许结果落后多久 | 缓存过期时间需要符合业务新鲜度要求 |
| 查询键包含什么 | 字段、条件、分页、模型版本和身份范围都可能影响结果 |
| 数据和模型更新后怎么办 | 数据重载、模型刷新与缓存失效是不同动作 |
| 怎么证明命中 | 使用实际执行中的命中标记或统计，而不是只看“变快了” |

具体启用与清理方式由公开接口和宿主配置决定。本章没有给当前DSL添加未经支持的缓存开关，也没有调用旧资料中的私有清理接口。

## 本章 Foggy 知识点

- **证据边界：** 重复结果、执行解释和实际缓存命中是不同结论。
- **数据新鲜度：** 用独立副本验证更新后结果，保留恢复步骤。
- **维护分工：** 数据、模型、缓存分别维护，不能互相代替。

下一章的预聚合会实际保存汇总数据，其新鲜度同样需要检查。


---

<a id="chapter-39"></a>

# 第 39 章 预聚合：先汇总，再保持同一口径查询

如果经常按国家汇总一张很大的账单表，可以先保存国家级汇总，再查询这些结果。它节省了重复扫描的机会，也增加了刷新和维护责任。

本章仍用83张账单演示正确性，不据此判断预聚合一定更快。

## 第一步：准备汇总表与映射，先不查询

发送：

> 为独立账单副本准备国家汇总和预聚合模型，校验并加载，先不查询。

本例由外部准备代码生成 `perf_country_totals`，20行分别保存国家、金额合计和账单数。TM声明让引擎知道这些列能替代哪些计算；声明本身不会创建或自动刷新数据表。

下面是 [PerfAggregateModel.tm](../resources/lab/performance/models/model/PerfAggregateModel.tm) 中的 `preAggregations` 属性，其他定义见完整源码：

```javascript
preAggregations: [{
  name: 'country_totals', tableName: 'perf_country_totals',
  dimensions: ['country'],
  dimensionProperties: { country: ['id'] },
  dimensionPropertyColumnNames: { country: { id: 'billing_country' } },
  measures: [
    { name: 'invoiceAmount', aggregation: 'sum', columnName: 'amount_sum' },
    { name: 'invoiceCount', aggregation: 'count', columnName: 'bill_count' }
  ],
  enabled: true
}]
```

本例显式声明国家ID及物理列映射。最初缺少这部分，解释返回 `PREAGG_DIMENSION_PROPERTY_NOT_MATERIALIZED`，保留源查询；补齐后才选中预聚合。不能仅凭配置了表名就认定已经命中。

## 第二步：用相同业务字段查询

模型校验、加载后实时读取说明，再发送：

> 用预聚合入口按国家统计金额和张数，与源查询核对。

查询字段仍是 `country$id`、`invoiceAmount`、`invoiceCount`，请求无须指定汇总表。源表入口与预聚合入口的20组结果逐项一致，均合计83张、477.53。

本次重新编译的解释选择 `country_totals`，路由为 `PREAGG_ROLLUP`。执行响应里的调试SQL仍保留源查询文本，因此不能仅凭这一段调试SQL断言最终路由；本例还用下面的数据对照验证了执行行为。

明细请求需要账单编号，国家汇总无法提供，实际返回仍来自源表。预聚合没有覆盖的粒度与字段，不能凭空还原。

## 第三步：检查刷新前后

发送：

> 修改副本中的一张账单，对比刷新前后的源查询和预聚合结果，最后恢复。

本次把账单250增加1教学元：

| 状态 | 源查询 | 预聚合查询 |
| --- | ---: | ---: |
| 修改前 | 477.53 | 477.53 |
| 只更新账单，未刷新汇总表 | 478.53 | 477.53 |
| 外部准备代码重建汇总后 | 478.53 | 478.53 |

这次没有依靠模型刷新重算汇总，而是实际更新了汇总表。验证后源表和汇总表都恢复为477.53。原始Chinook数据始终未改。

需要实时口径时，可以先使用已核对的源查询入口，或把汇总刷新与验收纳入发布流程。本例不会自动补齐这次变更，不能只看“预聚合已启用”。

## 什么值得提前汇总

先选常用且口径稳定的维度组合。粒度越细，覆盖的问题可能越多，存储和维护成本也越高；按月汇总后不能再回答某一天的明细。

金额可以再求和，账单数上卷时应累加已保存的计数。平均值要保留金额合计与账单数后重新相除，不能直接平均各组均额。自动刷新、增量、水位补偿及复杂AVG路由，仍须在目标宿主中单独验证，本章只演示已测试的SUM/COUNT路径。

## 本章 Foggy 知识点

- **预聚合定义：** 在TM声明维度、指标和物理列，QM继续公开业务字段。
- **匹配与回退：** 先核对解释与实际结果，覆盖不了的明细返回源查询。
- **刷新责任：** 数据表的更新由准备或刷新链路完成，模型声明不能保证新鲜度。

完整源表/预聚合模型、请求及首次回退记录见[实操](../resources/lab/performance/README.md)。


---

<a id="chapter-40"></a>

# 第 40 章 分页与持续运行：把结果交付完整

查询成功不等于已经取齐数据。问“前20张账单”和“全部账单”是两个需求，返回上限也不是参与汇总的记录数。

本章继续使用独立性能副本，保留同一数据和模型版本。

## 分页要有稳定顺序

发送：

> 分页取齐全部账单，检查重复、遗漏和总金额。

Codex实时获取模型字段，选择账单ID作为唯一排序键，逐页先校验再查询：

```json
{
  "columns": ["invoiceId", "invoiceAmount"],
  "orderBy": ["invoiceId"],
  "start": 0,
  "limit": 20,
  "returnTotal": true
}
```

本次将 `start` 依次设为0、20、40、60、80，实际返回 **20、20、20、20、3行**，每页报告总数83。合并后83个账单编号无重复、无遗漏，金额477.53，与原始记录一致。

同额排名还要追加唯一键排序，否则页边界可能不稳定。分页期间如果源数据变化，简单的位移分页仍可能漏行或重行；本例使用固定快照，不能扩大成实时更新库的一致性保证。

## 几种“限制”要分清

| 限制 | 实际含义 |
| --- | --- |
| DSL的 `limit` | 限制返回行数，不代表只计算这些明细 |
| `returnTotal` | 请求总数；核对它与明细/分组的统计对象是否相同 |
| CLI等待超时 | 客户端等不到响应，不自动证明数据库任务已停止 |
| 宿主或数据库资源策略 | 控制可接受的扫描、执行时间和并发，需要部署侧配置与验证 |

本章没有通过故意制造长查询验证取消能力，也没有把CLI等待时间写成数据库执行上限。取数规模超过约定时，应先明确用途、范围和资源预算，不能无限重试或悄悄截断。

## 更新后，还要检查什么

发送：

> 把模型、数据和汇总更新后的检查整理成日常运行清单。

可以沿用第16、34章的回归方法，不再建另一套框架：

1. 记录数据快照、模型版本、当前绑定和统计截止时间。
2. 数据更新后核对数量与金额；模型变更后先校验、加载，再获取实时元数据。
3. 若有缓存或预聚合，分别确认失效和刷新，不把模型刷新当作数据刷新。
4. 用同一组题目检查正常结果、空集、错误字段和调用身份范围。
5. 失败时报告实际阶段和范围；切换入口前后核对，保留已验收版本。

这些检查解决技术链路的可重复性。指标是否合理、数据是否完整、业务解释是否有依据，仍需要人与业务系统协作。

## 本章 Foggy 知识点

- **结果边界：** 区分返回上限、总数与计算范围，明确分页顺序。
- **运行边界：** 区分客户端等待与数据库执行控制。
- **持续核对：** 数据、模型、缓存、汇总各自更新，用已审核题库回归。

本系列最终12项正常执行、2次解释已完成，见[实操记录](../resources/lab/performance/README.md)。第一阶段至此覆盖概念、AI与CLI实践、高级分析和运行维护；在线问数、FDP模型维护及Dashboard/报表留待后续阶段。
