intelligent-kanban · git:20261004.0dd7831 · 2026-10-04 · sha256 2b30992289fdbceb

intelligent-kanban git:20261004.0dd7831A

Immutable. This exact content never changes and is served at /api/v1/blob/2b30992289fdbceb.

---
name: intelligent-kanban
tags: [data-analysis]
user-invocable: false
description: |
  基于 WeData 搭建交互式运营看板(HtmlContent 承载页面/组件 DSL,SqlSlots 承载 Datasets)。中英文环境通用。
  触发词:看板/仪表盘/驾驶舱/大盘/监控视图 | dashboard/cloud dashboard/platform dashboard/cockpit/operation board/KPI board(复合词皆算)
       | **复合表达**:平台/云端/AI/发布/分享/嵌出 + 仪表盘/dashboard;AI dashboard / 智能仪表盘 / LLM dashboard / GPT dashboard / 生成式 dashboard / Agent dashboard / 大模型仪表盘 / 智能体仪表盘。
  ⚠️ 路由:
    - 数据分析场景内,含「看板 / 仪表盘 / dashboard」任一字样,或上述英文/复合表达 + 创建/搭建/生成/create/build/generate/make → **必走本 skill**。
    - 平台/云端/AI/发布/分享/嵌出等只是限定词,不改变统一进入本 skill 的路由。
  🛑 反路由:查/看/取数/列出/统计(未同时出现看板/仪表盘/dashboard 时);query/show/list/count/fetch → `intelligent-query`。
---

# 当前环境
- **工作空间 文件夹**(workspace_folder):可以在SystemMessage中找到 或者 在整个上下文中最近一次的user对话中找到,<user_info>标签内有定义“Workspace Folder”的值, 如果找不到就取默认值`~/.wedata`

# 智能看板搭建(声明式 Spec 架构)

LLM 唯一编写 `kanban_spec.py`(约 80–150 行),取数 / SQL / 切片 / DSL / Dataset 入库全部由 runner 完成。

---

## 🛑 P0 硬约束(违反 = 执行失败)

> 「流程类」代码无法兜底,必须 LLM 显式遵循;「数据类」DSL/runner 已构造期 raise,按报错文本改即可。

| # | 类 | 规则 |
|---|---|------|
| 1 | 流程 | **必须 spec 模式**:构造 `Spec(...)` + 调 `build_kanban(spec)`;禁止手写 `df_to_slot_data`/`write_kanban_outputs` 调用链 |
| 2 | 流程 | **reference/ 默认只读**:业务个性化走 `Chart.chart_config` 透传 / DSL widget extras,禁为单个 spec 改 reference |
| 3 | 数据 | **raw_sql 列序契约**:用工厂 `raw_sql(title=..., sql=..., slot_columns=[...], kind=...)`(内部自动 `escape_hatch=True`);`slot_columns` 顺序与 `SELECT` 严格一致;`FROM`/`JOIN` 后表名按 `route_hint.connection_type` 分派:**lakehouse/StarRocks/Doris 三段 `catalog.db.table`;MySQL/PG/GaussDB 两段 `db.table`**(禁占位名 `_kb_src`/`main_data`) |
| 4 | 流程 | **图表多样性**:`Spec.charts` ≥ 5 种不同 `chart_type`(用户明确只要某几种除外) |
| 5 | 数据 | **同环比用 Compare**:禁 `chart('line', metrics=[..., 'mom_xxx'])` 假装;必须 `compare(dim=time_dim(...), metric=..., kinds=['mom'])` |
| 6 | 流程 | **Step E 不主动发布**:runner 已自动调 `UpdateAiKanBan` 保存/更新 PREVIEW 并 print 追问语;禁主动调 `SaveAiKanBan` 发布态、禁 turn-id、禁二次总结,等用户输入走 Step G |
| 7 | 流程 | **修改必走 spec 重跑**:禁手改 `kanban_save_params.json` / `kanban_dsl.json`,改 spec 重跑 `build_kanban`(runner 自动保存/更新 PREVIEW,三端统一入库 DSL+Dataset) |
| 8 | 流程 | **发布/预览 helper 分离**:「保存/发布/上线/确认发布」→ `save_to_kanban_list()`(只调 `SaveAiKanBan`,入参仅 `WorkspaceId + AccessKey`,写发布态);「保存预览/更新预览/同步预览」→ `update_to_kanban_list()`(只调 `UpdateAiKanBan`,携带 DSL/Datasets/元信息写 PREVIEW);禁裸调 wedatacli + 拼 stdin JSON |
| 9 | 流程 | **不要查 `workspace_id`**:spec 省略该字段即可(wedatacli 自动注入);禁 `env \| grep workspace` / `cat ~/.wedata/*` / `echo $TENCENTCLOUD_WORKSPACE_ID` 等探测 |
| 10 | 数据 | **DSL 报错按文本改**:看到 `[DSL] Chart "..." (kind=...) ...` 直接按报错文本改 spec(dsl raise 文案已带正例与修法),不改 reference / 不写新脚本 |
| 11 | 流程 | **stop 后发布/预览保存最高优先级**:Pre-routing 命中 → 必须直跳 Step G;禁误判为 Step F 重跑、禁追问、禁闲聊 |
| 12 | 数据 | **数据保真自适应**:能聚合不截断、要截断必配 `order_by`、时间型降粒度、分类基数爆炸换 kind;DSL 构造期硬约束 + Runner 软兜底(500 行/900 单元格/100 类目) |
| 13 | 流程 | **prefetch 单次原则**:每轮最多调 1 次;exit 2/3 必须直接 stop,严禁自行换关键词/扩词/翻译/拆意图重跑(唯一例外:用户回复选定表后重跑) |
| 14 | 流程 | **spec 落地位置**:文件名固定 `kanban_spec.py`,路径固定 `<workspace_folder>/.kanban_output/kanban_spec.py`(与产物同根:`kanban_save_params.json` / `kanban_dsl.json` / 数据快照 CSV);禁写 plugin 目录/cwd 根/`/tmp`;Step F 直接覆盖同一路径 |
| 15 | 数据 | **OLAP 数据源必须走 raw_sql**:Step B `route_hint.sql_type=3`(MySQL/PG/GaussDB/StarRocks/Doris)时:① `Spec.charts` 所有项必须走 `raw_sql(title=..., sql=..., slot_columns=[...], kind=...)`,SQL 用**目标数据源原生方言**手写;② **`compare(...)` 也禁用**(内部展开会引入 `spark_safe_*`),改用 `raw_sql(kind='line', sql='WITH agg AS (...) SELECT time, curr, (curr-LAG(curr) OVER (ORDER BY time))/NULLIF(LAG(curr) OVER (ORDER BY time),0)*100 AS prev_pct FROM agg', slot_columns=['time','curr','prev_pct'])` 手写同环比;③ `Spec.kpis` 可保留 `kpi(expr, label)` 但 `expr` 只允许通用聚合(`SUM/COUNT/AVG/MIN/MAX` + 目标方言函数),禁 `spark_safe_*` / `percentile_approx`;④ `FROM/JOIN` 后表名严格按目标方言段数(MySQL/PG/GaussDB 两段 `db.table`;StarRocks/Doris 三段 `catalog.db.table`)——**cache key 双端已按尾两段归一**,段数任选一致的写法都能命中 prefetch 缓存。违反 runner 写盘前 raise。lakehouse(`sql_type=1`)路径不受影响 |
| 16 | 流程 | **禁臆断数据源不支持**:支持性判定**唯一**以 `prefetch_table.py` 输出为准——`route_hint.sql_type ∈ {1,3}` 即支持;只有脚本显式返回 `status:"route_invalid"` / `status:"not_found"` / `status:"cross_source_not_allowed"` 才是"不支持"。MySQL/PostgreSQL/GaussDB/StarRocks/Doris 全部属于 `sql_type=3` 支持路径。**作用域**:本条只约束「看板创建 / 修改流程」(Step A→G)中的对外表达;未跑 `prefetch_table.py` 或未拿到 `route_hint` 前**禁**出现「不支持 PostgreSQL / PG / MySQL / OLAP / 该数据源类型」「当前看板不支持…」「建议改用 lakehouse」等结论。若脚本已返回明确错误状态,可原样透传该状态给用户 |
| 17 | 流程 | **prefetch 成功即直进 Step C→D**:`prefetch_table.py` exit=0 拿到 schema JSON 后,本轮必须依序完成「Step B 数据概览表 → Step C 布局摘要 → Step D 写 spec + 跑 build_kanban」一次性交付,中途禁 stop / 追问 / 换工具探数。**禁裸调 `wedatacli query-data` / `wedatacli query-sql` / `wedatacli get data` 等取数子命令做数据探查**(这类命令仅 runner 内部使用);需要看真实分布只有一种合法做法:直接把该 SQL 写进 `raw_sql(...)` chart,由 runner 通过取数链路统一执行 |

---

## 🎯 P0-10 速查(DSL 不 raise 的真陷阱)

> ⚠️ **作用域**:`spark_safe_*` / `DATEDIFF` 仅适用 lakehouse(`sql_type=1`)。OLAP(`sql_type=3`)遵 P0-15:raw_sql 用目标方言手写,禁 `spark_safe_*` / `percentile_approx`。

**时间列零返工门禁(lakehouse)**:
- `Spec.source.columns` 必须整列带 `type`;`time_dim()`/`compare()` 会在 `Spec` 构造期从裸列名回填时间类型:`date/date32` → `date`,`timestamp/datetime` 系列 → `timestamp`;缺 `type` 时默认按 `string` 处理。
- `type='date/timestamp/datetime'`:常规趋势/同环比优先 DSL(`time_dim`/`compare`);**不要**因旧版 DuckDB `try_strptime(DATE,...)` 经验盲目改 `raw_sql`,也不要把真实类型伪装成 `string`。若必须写 lakehouse `raw_sql`,物理日期列用原生 `DATE_FORMAT(date_col,'yyyy-MM')`,不要套 `spark_safe_date_format`。
- `type='string'`:自写 metric/raw_sql 里的时间提取、比较、`DATEDIFF` 先包 `spark_safe_to_timestamp(col)`;禁 `TO_TIMESTAMP`。
- 8/14 位紧凑日期字符串:不要直接用 `time_dim`;改 `raw_sql` 显式 `spark_safe_to_timestamp_extended(col)` 后再分桶。
- 10/13 位 Unix 字符串或数值时间戳:不要依赖 `time_dim` 自动解析;需要分桶时用 `raw_sql` 显式 `FROM_UNIXTIME(col)`(毫秒先 `/1000`)后再格式化。
- `raw_sql` 不走 DSL 类型传播;时间函数、`slot_columns` 与本地 DuckDB 兼容由 SQL 自己负责。
- 🛑 **`spark_safe_*` 白名单仅 6 个**:`to_timestamp/to_date/datediff/date_format/week_format/to_timestamp_extended`;禁止发明 `spark_safe_now/spark_safe_year/spark_safe_unix_timestamp`。

**🕒 lakehouse `DATEDIFF`**(DSL raise,仅一种形态):`DATEDIFF(end, start)` 返回 end-start 天数;`string` 列先包 `spark_safe_to_timestamp`;Unix 秒/毫秒先显式 `FROM_UNIXTIME` 转时间。❌ 全 raise:`DATEDIFF(DAY,...)` / `DATE_DIFF(...)`。⚠️ 历史数据集不宜用 `CURRENT_TIMESTAMP` 锚点,完整模板见 [kanban_spec_example.py](./kanban_spec_example.py) 第 5.17 节。**OLAP 对应**:MySQL `TIMESTAMPDIFF(DAY,s,e)`;PG/GaussDB `(e::date-s::date)`;StarRocks/Doris `datediff(e,s)`。

> 💡 **OLAP raw_sql 本地兼容**:runner 已内置 `DATE_FORMAT` 常见日期粒度格式、`TO_CHAR / TIMESTAMPDIFF / STR_TO_DATE / x::type` → DuckDB 自动翻译或原生通过(仅本地体检,远端 sqlSlots 保持目标方言原文)。看到 stderr 有 `[runner] OLAP → DuckDB` 相关日志属正常,**不要**改 spec。

**软告警告知项**(看到**不要**改 spec,runner 已自动处理):`order_by` 含聚合→重写为实际输出别名;`kpi(...)` 传 `emoji/span/slot_key`→静默丢弃;`title` 含 emoji + `emoji=`→自动去重;raw_sql 中 `CURRENT_TIMESTAMP` / `NOW()` 在本地体检期自动改写为 `current_localtimestamp()`(远端目标方言保持原文),可直接使用不必因本地报错放弃时间过滤(历史数据集仍遵上文警示改用快照日锚点)。

**硬告警必改项**:runner 输出 `❌ [本轮 string 时间列解析失败清单]` → 把 `time_col` 换成 `Spec.source.columns` 里的 DATE/TIMESTAMP 列;无真 DATE 列时改走 `raw_sql`。

**Spec 重写门禁**:若报 `[DSL] Spec 全局契约预检失败`,必须按清单**一次性修完全部项**再重跑;重点看 `scatter` 只认 `x/y/category`、`time_dim` 排序别名为 `<col>_<gran>`、普通 DSL 禁窗口/嵌套聚合(累计/自算同环比改 `raw_sql + WITH`)。

**🔍 列引用强校验**(DSL raise):`source.columns` 为 dict 时,`kpi/dim/metric/compare` expr 列名必须已声明;报错时二选一:①拼错→对照 Step B 改 expr;②漏写→补回 `source.columns`(带 `type`)。raw_sql / `kpi(from_sql=)` 不校验。

---
## 📐 数据保真自适应(P0-12 速查)

**4 原则**:① 能聚合不截断 ② 要截断必配 `order_by` ③ 时间型降粒度 ④ 基数爆炸换 kind。**DSL 构造期 raise(违反报错带修法)+ Runner 软兜底(500 行/900 单元格/100 类目阈值,触发时按 Top 截断非随机抽样)**。

| kind | 推荐写法 |
|------|---------|
| `scatter` | N≤500 全量;>500 加 `limit=500, order_by='-y'`;>50K 用 CASE WHEN 分桶聚合(dims[0] 必数值;裸列仅当 `source.columns` 已声明 `type='double/decimal/bigint/int'` 时合法,纯字符串列名场景写 `dim('price * 1.0', alias='price')`) |
| `table` | 优先 `[category, time_dim('time','month')]` 聚合;无聚合 runner 自动 LIMIT 50 |
| `parallel` | 前 N-1 维用 `AVG/SUM` 聚合,最后一维分类 |
| `graph` 边模式 | `limit=30, order_by='-value'` |
| `heatmap` | 两维基数 ≤ 30 直出;高基数先 SQL 降粒度或 raw_sql + CTE Top30 |
| `treemap`/`sunburst` | 单层 ≤ 100;多层用路径 |
| `boxplot` | lakehouse:C≤20 直出;20~100 可用 raw_sql + `percentile_approx`;OLAP:遵循 P0-15,用目标方言分位函数/近似方案,禁 `percentile_approx`;>100 换 `bar` |
| 时间趋势 `line`/`candlestick` | `time_dim(...,'month'/'week')` 降粒度,**不要** `limit` |

> 反例代码见 [kanban_spec_pitfalls.py](kanban_spec_pitfalls.py) 反例 14~22;DSL raise 已含修法,按需查阅。

---

## 🔗 多表场景

用户明提 ≥2 数据来源 → 多表:`Spec.source` 为主表,其他表走 `raw_sql` chart(runner 自动注册为 DuckDB 伴随视图)。硬约束:Step B 一条 `prefetch_table.py --tables "t1,t2,..."` 搞定(拆调用违 P0-13);raw_sql 表名分派同 P0-3。三种写法(独立展示 / JOIN / UNION)见 [kanban_spec_example.py](./kanban_spec_example.py) 第 7.5 节。

> 💡 runner 打 `🔗 多表伴随视图已注册:xxx` 是多表生效信号;`Catalog "xxx" does not exist` 软告警原样重跑即愈。

---

> 🛑 **概念二分**:预览 = 入库 PREVIEW 态(runner 每次 `build_kanban` 后自动调 `UpdateAiKanBan` 保存/更新,三端统一预览源);发布 = 写发布态(用户明说「保存/发布/上线/确认发布」时才调 `SaveAiKanBan`)。单次取数→`intelligent-query`;看板 / 仪表盘 / dashboard(含平台、云端、AI、发布、分享、嵌出等复合诉求)→ 本 skill。

---

## 🚦 Pre-routing · stop 后意图识别门(强制)

Step E/F 的 stop 之后,每收到一条新消息,回答前必走一遍词表(子串匹配、大小写/中英文同效):

| 优先级 | 命中词(任一) | 走向 |
|---|---|---|
| **P0,发布** | 保存/发布/上线/确认发布/发布到看板列表/入库/加到仪表盘;save/publish/release | **Step G1** |
| **P0,预览** | 保存预览/更新预览/同步预览/推送预览;preview/update preview/sync preview/push preview | **Step G2** |
| P1 | 改/换/加/删/调整/重新生成/换风格/换颜色/加指标;modify/change/add/remove/adjust/regenerate/restyle | **Step F** |
| P2 | 看板/驾驶舱/大盘/仪表盘 + 表名或业务词;dashboard/cockpit/board + table/biz term(首次创建) | **Step A → E** |

**他裁**:P0+P1 默认 P0;「先改 x 再发布/保存/更新」只走 F;同时命中“发布”和“预览”默认发布(G1);都不命中 → 常规对话(本轮禁主动调 helper / 重跑 `build_kanban` / 裸调 wedatacli)。

**P0 命中后三件事**:调 helper → 贴 `AccessKey` 1–2 行总结 → stop。禁:重跑 `build_kanban` / 裸调 wedatacli 与 Save/Update RPC / 「好的正在为您保存」「保存还是更新?」等客套追问。

---

## 主流程(Step A → G)

> 🌐 **语言一致性**:跟随用户主语言(表名/列名/SQL 标识符例外,永远原样);单轮面向用户的字面量必同一语言(禁斩杆/括号并列)。Spec 的 `title/label/emoji` 同步。
>
> **标题对照**(中/EN任取一列):`Step A · 需求理解` = `Requirements`;`Step B · 数据分析` = `Data Analysis`;`Step C · 布局摘要` = `Layout`;`Step D · 生成看板` = `Build`;`Step E · 验收与总结` = `Summary`。
>
> **字段对照**:`数据来源/主题/同环比口径` = `Source/Topic/Compare`;表头 `数据源\|时间字段\|关键维度\|关键指标` = `Source\|Time\|Dims\|Metrics`;`看板内容概览/数据特征提示/无` = `Overview/Data Notes/none`。默认 `按月 mom,待 Step B 确认` = `monthly mom, TBD`;追问 `请告知数据来源` = `Please specify data source`。

### Step A · 需求理解

🛑 **输出契约**:
- ✅ 先打小标题(取上方对照表 Step A 行),再进 Step B
- ✅ 标题下 1–3 行 bullet:① `**数据来源**:<原词原样>`(禁翻译) ② `**主题**:<...>` ③ `**同环比口径**:<...>`(未指定 → 用对照表默认值)
- ✅ **进 Step B 二元判定**(机械):能抠出≥1 token(三段式/中英文裸名/模糊业务词)→ 直接进 Step B,原始 token 喂 `prefetch_table.py`;完全无数据线索 → 按对照表追问语 stop
- ❌ 禁 LLM 自判业务词「够不够具体」;禁跳 Step B;禁从历史推断表名;禁人工切多意图

> **P0**:表名解析已下沉到 `prefetch_table.py`,LLM **不要**自行调 `wedatacli search` 找表。

### Step B · 数据分析(对外标题;内部完成 schema 拉取 + 取数预取,无需暴露给用户)

**唯一允许的命令**(原样复制,把 `<用户原始输入>` 替换为用户给的表名/业务词,**支持三段式 / 中文 / 英文裸名 / 多意图`,、,与以及`分隔**;脚本内部已做表名解析 + Schema 拉取 + 后台预取 CSV,三件事一次完成):

```bash
# 任选其一执行(已以重要性排序:先 KANBAN_REFERENCE_DIR、后 CODEBUDDY_PLUGIN_ROOT):
export KANBAN_REFERENCE_DIR="<reference_dir>" && export WEDATA_WORKSPACE_FOLDER="<workspace_folder>" && python3 "$KANBAN_REFERENCE_DIR/prefetch_table.py" --tables "<用户原始输入>" 2>&1
# 或(DataBuddy / WorkBuddy plugin 根目录):
export CODEBUDDY_PLUGIN_ROOT="<plugin_root>" && export WEDATA_WORKSPACE_FOLDER="<workspace_folder>" && \
_REF=""; for sub in "scenarios/data-analysis/skills/intelligent-kanban/reference" "l3-skill-scenario/intelligent-kanban/reference" "intelligent-kanban/reference"; do \
  [ -d "$CODEBUDDY_PLUGIN_ROOT/$sub" ] && _REF="$CODEBUDDY_PLUGIN_ROOT/$sub" && break; \
done; \
[ -n "$_REF" ] || { echo "❌ 未在 CODEBUDDY_PLUGIN_ROOT 下找到 intelligent-kanban/reference 目录" >&2; exit 1; }; \
python3 "$_REF/prefetch_table.py" --tables "<用户原始输入>" 2>&1
```

> `<reference_dir>` = 当前 skill 目录同级的 `reference/` 绝对路径(包含 `prefetch_table.py` / `kanban_runner.py` / `kanban_dsl.py` 的目录);`<plugin_root>` = DataBuddy 沙箱下 plugin 根目录。两个参数按当前环境实际值填入后原样执行,不要探查 env。

**三种返回处理**(按脚本退出码 + stdout 内容分发):

| 退出码 | stdout | LLM 行为 |
|---|---|---|
| **0** | `prefetch_table.py` 输出的 schema JSON | 进入 Step C:每张表给 3 行简报 → 写 spec |
| **2** | `{"status":"need_disambiguation","ambiguous":[{"query":"...","candidates":[...]}],"resolved":["catalog.db.table",...]?}` | **直接 stop**:先(若有 `resolved`)一行告知"已锁定: <三段式列表>",再把每个 query 的候选**原样**展示给用户(每个子意图一组,列出 `full_name` + `comment`),等用户选;用户回复后**把已锁定 + 用户选定一起重跑同一条命令**。**禁止**自行换关键词再跑一次 prefetch |
| **3** | `{"status":"not_found","missing":["..."],"resolved":["catalog.db.table",...]?}` | **直接 stop**:先(若有 `resolved`)一行告知"已识别: <三段式列表>",再 `未找到 "<query1>" / "<query2>",请确认或直接给 catalog.db.table`。**严禁**自行翻译/扩词/猜表名再跑 prefetch(如把"零售"换成 retail/sales/order 试探) |
| 其它 | stderr 第一行 | stop 透传给用户 |

> 🧭 **消歧回复机械处理**:用户回复序号/表名后,只做映射并重跑上述 `prefetch_table.py`;**禁止**在重跑前按候选表的 `connection_type`、catalog 前缀、`comment` 里出现的 "PostgreSQL/MySQL/OLAP" 等字样或经验白名单判断能否创建看板。**唯一**判据是重跑后脚本的退出码与 `route_hint`(P0-16)。
>
> 📌 **`resolved` 字段**:多意图部分已锚定、其他仍歧义/缺失时,脚本仍按"一票否决"返 exit 2/3,但通过 `resolved` 透出已锚定的三段式。LLM 展示给用户(避免重复),用户补全后一起用作下一次 `--tables` 输入。

> 🛡️ **`route_hint` 路由(必看)**:
> - `sql_type=1`(lakehouse: SPARK/DLC):LLM 无感知,直走 DSL
> - `sql_type=3`(OLAP: MySQL/PG/GaussDB/StarRocks/Doris):遵 P0-15,所有 chart 走 `raw_sql` 且用目标方言
>
> 跨源混用由 prefetch 硬闸门拦截(exit 2 提示拆分)。

🛑 **硬约束**:
- 唯一找表/拉 schema/预取入口 = 上述 `prefetch_table.py`;禁裸调 `wedatacli search`、禁绕道脚本、禁 `cat`/`ls`/`echo $CODEBUDDY_PLUGIN_ROOT` 探查
- 失败只读 stderr **首个完整错误块**(含 `[DSL]` / `[Runner]` / `[OLAP sqlSlots 硬拦截]` 等 tag 前缀后的所有修法行)→ 按提示重跑同一条命令(不换工具、不拆调用)
- 多表/多意图必须一条命令搞定(拆调用让缓存 miss)
- 🚫 **P0-13 单次原则**:每轮最多一次 prefetch;exit 2/3 一律 stop 等用户。反例:`--tables "零售"`→exit 3 后换为 `"retail"`/`"sales,order,..."`/`"product"` 重跑都不允许(每多一次调用浪费 4–9s + 冗余后台 fork)
- 🚧 **必输出门槛**(exit=0 后**强制**,缺任一项即视为 Step B 未完成、禁止进入 Step C):
  1. 打印 `**Step B · 数据分析**`(英文:`**Step B · Data Analysis**`)标题;对外仅出现「数据分析」或「Data Analysis」之一,禁 Schema/prefetch 等内部术语
  2. 紧跟以对照表「数据概览」开头 + **一张 Markdown 表**(每表一行,表头取对照表);各列规范:
     - 数据源列:完整三段式(如 `DataLakeCatalog.default.tiny_orders`),禁 `<br/>` 换行
     - 时间字段列:`<col> · <type>`;无时间列填对照表「无」对应词
     - 维度列:离散列 `col:type`,`, ` 分隔,≤4 个(优先类目/地域/状态/ID)
     - 指标列:数值列 `col:type`,≤4 个(优先金额/数量/比率);无数值列用 `count(<主键>)` 兜底
- ✅ `Spec.source.columns` 必须**列与类型双完整**:`name` 逐列**从 prefetch 返回原样抄入**(禁只抄用得上的字段,禁改列名,禁漏列——列缺失会触发 DuckDB sniffer fallback 至 VARCHAR 保底,`SUM(数值列)` 报 `sum(VARCHAR)` binder error);`type` 保持原始表达式(如 `decimal(20,2)`、`bigint`、`string`,禁自行简化为 `decimal`/`int`)。选定 `time_col` / `time_type`(仅填 string/date/timestamp/unix;schema 里的 `datetime` 在 `time_type` 中按 timestamp 填);若时间列 `columns[].type` 是 `date/timestamp/datetime`,保持真实类型,交给 `time_dim/compare` 自动回填,禁为绕过本地体检改假 `string`
- ❌ 禁罗列整张 schema 表(那是 `Spec.source.columns` 的事);❌ 禁"prefetch 命令已完成 → 直接 Step C"——schema JSON 拿到后必须先渲染「数据概览」表
- 🎯 **用户诉求锁定回执**(数据概览表下方**贴 1 行**,Step C 之前最后一句,不换行不解释):`🎯 主诉求=<用户原句核心动词短语,≤12字> · 主图型=<line|bar|pie|scatter|radar|funnel|gauge|heatmap|candlestick|table 中选 1>`;用户点名具体字段/指标时同行追加 ` · 必用字段=<列表>`;表 schema 缺该字段时追加 ` · 字段回退=<用户词>→<实际列名>`(chart title 保留用户原词,SQL 用实际列,禁 `SELECT AS <用户词>` 造字段幻觉)。**主图型判定**(无命中默认 `bar`;多命中按下表**从上到下**取首个):`趋势/走势/变化/每月/月度/时序/trend → line` · `漏斗/流失/转化/环节 → funnel` · `散点/相关性/xx与xx的关系 → scatter` · `达成/达标/完成率/进度 → gauge` · `K线/波动/箱线/分位 → candlestick|boxplot` · `热力/交叉分布 → heatmap` · `占比/份额/构成 → pie|treemap` · `多维对比/雷达 → radar` · `列一下/名单/明细 → table` · `排行/最高/Top → bar`。回执落地后 Step C 表必满足:① 至少 1 张图 `kind` = 主图型;② 必用字段每一个都要出现在某张图的 `metrics/dims` 列
- 分析空间由 `~/.wedata/config.json:analysisSpaceKey` 固化注入,**禁**自行 `export TENCENTCLOUD_ANALYSIS_SPACE_KEY` 或在报错里建议用户改空间

> ⚠️ Spec **不要**手写 `CAST/TRY_CAST` 兜底 'null' 字面量——runner 已在 `_open_duck` 用 `read_csv(columns=..., nullstr=['null','NULL','None','\N',''])` 统一处理。

> ⚠️ **GROUP BY 维度 NULL 自动过滤**:runner 对走 GROUP BY 的图表自动追加 `<dim_sql_expr> IS NOT NULL`,消除时间列解析失败/分类 NULL/LAG 假同环比/多列 GROUP BY 污染四类静默失真。逃生口:`Dim(expr=..., description='__keep_null__')` 显式保留 NULL 桶。

### Step C · 设计看板布局

🔫 **内化检查**(脑内执行,只输出布局摘要):写表前选型查 [kanban_spec_example.py](./kanban_spec_example.py) 第 5 节「选型决策表」(A~Q 17 行);metric 字段必须能在 Step B 「关键指标/维度」中找到,对账失败直接换 kind。

🎯 **聚焦收敛**(脑内自检,防"1 诉求→8 张图"发散):按 Step B 回执行 `主诉求 / 主图型` 分档控图数:
- **单诉求**(用户只提 1 个业务问题:趋势/排行/达成/明细/分布/漏斗):**总图数 ≤ 5**(1~2 张主图 + 1~2 张辅图 + 1 组 KPI),主图型占比 ≥ 40%
- **双诉求**("X 和 Y"/"看 X 也看 Y"):**总图数 ≤ 7**,每条诉求至少 1 张对应主图型
- **复合看板**(用户原句含 `看板/大盘/驾驶舱/多维/全域/综合/dashboard`):本条不生效,按用户期望展开
- **反模式**:主图型未在回执里出现时不主动加 `radar/heatmap/candlestick/boxplot/gauge`;不把趋势拆成饼图+雷达、排行拆成多张明细表

🔗 **回执一致性**(Step C 表格落地前脑内做,NO 则改表,不 stop 不重跑 prefetch):主图型在 `kind` 列出现 ≥ 1 次;必用字段每一个都在 `dims/metrics` 列出现(缺字段走回执行的字段回退);若发生字段回退,Step E 一句话总结加一行 `用户词=<X> · 实际列名=<Y>(表内无 <X>,用同义列 <Y>,如需精确对齐请提供含 <X> 的表)`

🧠 **准入题清单**(脑内自检,DSL 已 raise 的硬契约不重复列,仅列 DSL 不 raise 的业务语义题;任一 NO 即换 kind,**不输出给用户**):

| 场景 | 必答准入题 |
|------|-------------------------------|
| `radar` | 能列出 ≥3 个业务有意义的 `normalize='max-norm'` 数值轴? |
| `funnel` | 同一表能拆出 ≥2 个语义**递减**的阶段?首阶段是转化起点全量基线(不带 where 预筛),各阶段计量口径一致? |
| `radar`/`heatmap` 分组基数 | 分组维度实际 NDV ≥ 3?否则改 `bar`/`table`(低于 3 无对比意义或单元格退化) |
| lakehouse 时间趋势/同环比 | `source.columns` 已带真实 `date/timestamp/datetime` 类型?是则用 DSL `time_dim/compare`,不要因本地体检经验改 `raw_sql` |
| 时间维 `order_by` | 引用 `time_dim(col, gran)` 时用 `<col>_<gran>`(如 `time_day`)而非裸 `<gran>`? |
| `scatter order_by` | 散点实际输出别名固定为 `x/y/category`;排序写 `order_by='-y'`,不要写 `SUM(...)` 或 metric alias |
| `line` 时间趋势内构 | 主图 line 时优先**双轴/多序列同图**(如销售额+订单数双线),别拆成两张独立折线;聚焦少量核心指标 |
| `table` 明细内构 | 主图 table 时 `dims` 必须含实体主键/业务 ID(如 `customer_id`/`order_id`),禁只按地域/类目/时间等**汇总维度**分组代替明细 |

> 其它陷阱(boxplot 聚合 / scatter 裸分类轴 / candlestick 一字 K / parallel 行级混轴 / dim 含聚合 / scatter limit 无 order_by 等)DSL 构造期已 raise,直接按报错文本改 spec。

🛑 **输出契约**:唯一交付物 = `**Step C · 布局摘要**`(英文:`**Step C · Layout**`)标题 + 6 列表格(`# / kind / title / dims / metrics / span`),列全所有图,在 Step D 之前输出。**禁**:一句话概述代替表格;输出对账过程或 ✅ 短语。

**输出模板**(`dims`/`metrics` 列写自然语言简写,严格 DSL 在 Step D):

```
**Step C · 布局摘要**(共 N 张图,覆盖 M 种 chart_type)

- **KPI**:总额 / 用户数 / 转化率 / 毛利率
- **图表**:

| # | kind     | title           | dims                  | metrics                       | span |
|---|----------|------------------|-----------------------|-------------------------------|------|
| 1 | line     | 日趋势           | time(day)             | SUM(sales)                    | 4    |
| 2 | bar      | TopN 类目        | category              | SUM(sales) ↓ Top10            | 2    |
| 3 | pie      | 类目占比         | category              | SUM(sales)                    | 2    |
| 4 | radar    | 类目多维         | category              | 4× normalize 数值轴            | 2    |
| 5 | gauge    | 毛利率           | —                     | 派生比率 (with target)         | 2    |
| 6 | compare  | 月度环比         | time(month)           | SUM(sales) · mom              | 2    |
| 7 | table    | 类目月销         | category, time(month) | SUM(sales) ↓                  | 4    |
```

输出完表立即进 Step D,不要额外解释/贴 spec/贴 SQL。

可用 `kind` 16 种(参考,**不要**在 Step C 输出里复述):`line / bar / pie / scatter / radar / funnel / gauge / heatmap / candlestick / treemap / sankey / boxplot / sunburst / graph / parallel / table`

### Step D · 生成看板

本步只负责「写 spec → 跑 runner → 终端输出 `🎉 看板创建完成并已写入 PREVIEW`」,看到追问语后立即进 Step E;若输出 `❌ 看板本地产物已生成,但 PREVIEW 入库失败` 则按错误修复并重跑,不进入 Step E。

🚨 **runner Traceback 自愈回归**(仅当 runner **未输出** `🎉 看板创建完成并已写入 PREVIEW` 且抛出 `Traceback` 中断时触发;每类最多重跑 1 次 `build_kanban`;禁死循环 / 禁 stop 追问 / 禁裸调 wedatacli 探数 / 禁重跑 prefetch):
- `ValueError: [SQL 体检失败]` → 按告警文本"错误"行改 spec(① 若是 `sum(VARCHAR)` / 列名/别名错,检查 `Spec.source.columns` 是否与 prefetch 返回列名/类型/**列数**完全一致;② 若是 Spark 独家函数报错,改用 `raw_sql(..., escape_hatch=True)`),重跑一次
- `ValueError: [Runner] <kind> 需要 dims/metrics ...` → 按报错要求补齐 dims/metrics 或换 kind,重跑一次
- `LATERAL_COLUMN_ALIAS_IN_WINDOW` / `UNRESOLVED_COLUMN.WITH_SUGGESTION`(Spark 兼容)→ SELECT 别名下移到 WITH/CTE 或把 LAG(...) 内表达式原样重写,重跑一次
- `❌ [本轮 string 时间列解析失败清单]` 失败率 ≥ 60%(**且导致 raise**)→ ① 换 `Spec.source.columns` 里真 `date/timestamp` 列做 `time_col`;② 或该列外包 `spark_safe_to_timestamp(col)` 走 `raw_sql`;schema 无真日期列且用户要月/日粒度时用现有列(如 `order_year || '-Q' || order_quarter`、`substr(dt,1,6)`)构造时间轴,chart title 保留用户原词
- 重跑仍报同类 → 原样透传给用户后 stop,禁反复重试

> ✅ **runner 已输出 `🎉 看板创建完成并已写入 PREVIEW` 时不重跑**:即使伴随 `⚠️ 数据语义校验告警` / `❌ ERROR: 共 N 个核心图表数据为空` / `WARN: <slot> 仅含表头无数据行` 等**软告警**,均为本地 sample 体检提示(prefetch sample 可能天然为空),远端 PREVIEW 已入库、用户拿到可用看板,agent 直接进 Step E,把告警**原样透传**给用户(Step E 一句话总结加一行"本地体检提示 N 项软告警:xxx;远端 PREVIEW 已入库可预览"),禁自作主张删图或改 kind

🛑 **输出契约**:必须先打 `**Step D · 生成看板**`(英文:`**Step D · Build**`)标题;下方仅允许 ① 执行命令 ② runner 原始输出。**禁提及任何 `<workspace_folder>/.kanban_output/` 下的本地文件名或路径**(包括但不限于 `kanban_spec.py` / `kanban_save_params.json` / `kanban_dsl.json` / 快照 CSV),也不要输出“Spec 文件已创建”/“Spec created”之类提示(产物就是看板链接本身,中间产物不展示)。禁末尾追加总结/验收文字。

**唯一允许的 LLM 产物模板**(文件名固定 `kanban_spec.py`,位置固定 `<workspace_folder>/.kanban_output/kanban_spec.py`,Step F 覆盖同名以保幂等):

```python
# spec 落在 <workspace_folder>/.kanban_output/kanban_spec.py;5 件套产物同目录(含 kanban_dsl.json DSL 描述层)。
import os, sys
# reference 目录多档解析:显式目录 > plugin 根下新版、旧版、WorkBuddy 布局
_REF = os.environ.get('KANBAN_REFERENCE_DIR', '').strip()
if not _REF or not os.path.isdir(_REF):
    _PLUGIN_ROOT = os.environ.get('CODEBUDDY_PLUGIN_ROOT', '')
    for _cand in (
        os.path.join(_PLUGIN_ROOT, 'scenarios', 'data-analysis', 'skills', 'intelligent-kanban', 'reference'),
        os.path.join(_PLUGIN_ROOT, 'l3-skill-scenario', 'intelligent-kanban', 'reference'),
        os.path.join(_PLUGIN_ROOT, 'intelligent-kanban', 'reference'),
    ):
        if _PLUGIN_ROOT and os.path.isdir(_cand):
            _REF = _cand
            break
if not _REF or not os.path.isdir(_REF):
    raise SystemExit('❌ 无法定位 intelligent-kanban/reference 目录;请设置 KANBAN_REFERENCE_DIR 或 CODEBUDDY_PLUGIN_ROOT')
sys.path.insert(0, _REF)

from kanban_dsl import (
    Spec, source, kpi, metric, chart, compare, raw_sql,
    Dim, dim, time_dim, Metric,
)
from kanban_runner import build_kanban

SPEC = Spec(
    title='🏢 XXX 驾驶舱',
    # ✅ 省略 workspace_id(wedatacli 自动注入),禁自行查 env / 目录
    source=source(
        table='catalog.db.table',
        columns=[{'name':'time','type':'string'}, {'name':'amount','type':'double'}],
        time_col='time', time_type='string',
    ),
    kpis=[
        kpi('SUM(amount)', '总额', prefix='¥', format=',.0f'),
        kpi('COUNT(DISTINCT user_id)', '用户数', format=',.0f'),
        # 跨表 KPI(主表无该字段)传 from_sql 即可,runner 合并为子查询一次性执行:
        # kpi('SUM(oi.price)', '商品总额',
        #     from_sql='cat.db.orders o JOIN cat.db.items oi ON o.id=oi.oid',
        #     prefix='¥', format=',.0f'),
    ],
    charts=[
        chart('line', '日趋势',
              dims=[time_dim('time','day')],
              metrics=['SUM(amount)'],
              emoji='📈', span=4),
        chart('bar', 'TopN 类目',
              dims=['category'], metrics=['SUM(amount)'],
              order_by='-SUM(amount)', limit=10, emoji='📊', span=2),
        chart('radar', '类目多维',
              dims=['category'],
              metrics=[
                  metric('SUM(amount)', label='金额', normalize='max-norm'),
                  metric('COUNT(*)',    label='单数', normalize='max-norm'),
                  metric('AVG(amount)', label='均值', normalize='max-norm'),
              ],
              span=2, emoji='🎯'),
        chart('gauge', '完成率',
              metrics=[
                  metric('SUM(done)*100.0/NULLIF(SUM(total),0)',
                         label='完成率', format='.1f', suffix='%', target=100.0),
              ],
              span=2, emoji='⏱️'),
        compare(title='月度环比',
                dim=time_dim('time','month'),
                metric=metric('SUM(amount)', label='金额'),
                kinds=['mom'], span=3, emoji='📊'),
        chart('table', '类目月销榜',
              dims=['category', time_dim('time','month')],
              metrics=[metric('SUM(amount)', label='金额')],
              order_by='-SUM(amount)', span=4, emoji='📋'),
    ],
    theme='retail',
)

if __name__ == '__main__':
    build_kanban(SPEC)
```

**执行(spec 写盘后允许且仅允许执行下面一条命令,任选其一)**:
```bash
# 优先:显式指定 reference 目录
export KANBAN_REFERENCE_DIR="<reference_dir>" && export WEDATA_WORKSPACE_FOLDER="<workspace_folder>" && export KANBAN_OUTPUT_DIR="<workspace_folder>/.kanban_output" && mkdir -p "<workspace_folder>/.kanban_output" && ln -sf /bin/bash /bin/sh 2>/dev/null; python3 "<workspace_folder>/.kanban_output/kanban_spec.py" 2>&1
# 或(DataBuddy 沙箱默认目录结构)
export CODEBUDDY_PLUGIN_ROOT="<plugin_root>" && export WEDATA_WORKSPACE_FOLDER="<workspace_folder>" && export KANBAN_OUTPUT_DIR="<workspace_folder>/.kanban_output" && mkdir -p "<workspace_folder>/.kanban_output" && ln -sf /bin/bash /bin/sh 2>/dev/null; python3 "<workspace_folder>/.kanban_output/kanban_spec.py" 2>&1
```

> 📌 **路径说明**:spec 与产物**同根**落在 `<workspace_folder>/.kanban_output/` 下(已依赖 session 目录,WorkBuddy / DataBuddy 两端统一)。spec 文件路径固定 `<workspace_folder>/.kanban_output/kanban_spec.py`,无需 `cd` 切换目录。`kanban_dsl.json` 是本地完整排障态(含 `Datasets`);PREVIEW/发布入库请求源是 `kanban_save_params.json`(`HtmlContent` 为不含 `Datasets` 的页面/组件 DSL,`SqlSlots` 为已 Base64 的 lowerCamelCase Dataset 数组)。

🛑 执行前/失败后**原样重跑上述命令**,禁前置探查/试导入/pip install/查 env(runner 已内置 `_ensure_duckdb()` 三级兜底,用户视角只看「runner 执行 → 🎉 看板创建完成并已写入 PREVIEW」)。

失败处理:读 stderr **首个完整错误块**(含 `[DSL]` / `[Runner]` / `❌ [OLAP sqlSlots ...]` / `[本轮 string 时间列解析失败清单]` 等 tag 前缀后的所有修法行)→ 按提示改 spec → 重跑同一条命令;禁改 `reference/`,禁绕道写新脚本。

### Step E · 验收与总结

PREVIEW 保存/更新完成后输出**唯一一段**概览(标题取对照表「看板内容概览」对应词)即结束,等用户输入。

🛑 **输出契约**:
- ✅ 顶层标题取对照表 Step E 行;下方一段精炼描述 + 图表列表,回答「展示什么 / 帮看清什么 / 同环比口径」(EN 时对应 `what to show / what to see / compare basis`)
- ✅ **看板链接必贴**:runner 输出里含 `→ https://.../dashboard/aiBoard/<AccessKey>...` 一行时,**必须**将该链接原样贴入本段概览末尾(Markdown 链接形式,标题取「打开看板」/`Open dashboard`),供用户点击跳转 DataBuddy 控制台查看。runner 未打印链接(例如缺 workspaceId/regionId)时,退化为一行「AccessKey: `<AccessKey>`」告知用户
- ✅ 若 runner 输出含 `🔄 [自动降级]` / `⚠️  [软告警]` 行 → **原样抄入**小节,小节名取对照表「数据特征提示」对应词,**禁解释/翻译/扩写**
- ❌ 禁输出 ✅ 自检/验收确认/「Spec 构造无异常」「charts=N 种」等内部校验项
- ❌ 禁在正文提及任何 `<workspace_folder>/.kanban_output/` 下的本地文件名或路径(`kanban_spec.py` / `kanban_save_params.json` / `kanban_dsl.json` / 快照 CSV 等)。产物对用户而言只有看板链接,中间产物属于内部实现不暴露
- ❌ 禁主动调 `SaveAiKanBan`·`save_to_kanban_list` 发布态;runner 已自动调 `UpdateAiKanBan` 保存/更新 PREVIEW 并 print 追问语,不要重复

### Step F · 修改(任何变更指令统一走这里)

用户任何修改类指令(颜色/标题/布局/换 kind/增删组件/调 KPI/换风格等)唯一流程:**改 spec → 重跑 `build_kanban(SPEC)`** → runner 自动保存/更新 PREVIEW + print 发布追问语 → stop 等用户。

**强约束**:
- 🛑 复用已有 `AccessKey`:spec 不传任何 file_id 即可(runner 自动读 `kanban_save_params.json` 里的 AccessKey 走 UpdateAiKanBan 覆盖)
- 🛑 禁手改 `kanban_save_params.json` / `kanban_dsl.json`,必须由 runner 产生;入库以 `UpdateAiKanBan.SqlSlots` 的 `Datasets[]` lowerCamelCase 字段为准,`HtmlContent` 只承载页面/组件 DSL
- 🛑 runner 已自动保存/更新后端 PREVIEW;**禁本轮主动覆盖发布态**,等用户回复「保存/发布/上线/确认发布」再走 G1

### Step G · 用户发布 / 预览保存指令处理

> 仅在 Pre-routing 命中 P0 后进入。本轮输出严格遵守 Pre-routing “三件事 + 禁止清单”。

#### G1. 发布到 AI 看板列表   ·   触发:保存 / 发布 / 上线 / 确认发布 等

```python
import os, sys
_REF = os.environ.get('KANBAN_REFERENCE_DIR', '').strip()
if not _REF or not os.path.isdir(_REF):
    _PLUGIN_ROOT = os.environ.get('CODEBUDDY_PLUGIN_ROOT', '') or os.getcwd()
    for _cand in (
        os.path.join(_PLUGIN_ROOT, 'scenarios', 'data-analysis', 'skills', 'intelligent-kanban', 'reference'),
        os.path.join(_PLUGIN_ROOT, 'l3-skill-scenario', 'intelligent-kanban', 'reference'),
        os.path.join(_PLUGIN_ROOT, 'intelligent-kanban', 'reference'),
        os.path.join(os.getcwd(), 'reference'),
    ):
        if os.path.isdir(_cand):
            _REF = _cand
            break
if not _REF or not os.path.isdir(_REF):
    raise SystemExit('❌ 无法定位 intelligent-kanban/reference 目录;请设置 KANBAN_REFERENCE_DIR 或 CODEBUDDY_PLUGIN_ROOT')
sys.path.insert(0, _REF)
from kanban_runner import _B  # builder helper 命名空间

result = _B['save_to_kanban_list']()  # 入参可省略,默认从 <workspace_folder>/.kanban_output 推断;只调 SaveAiKanBan
if result['status'] == 'success':
    if result.get('dashboard_url'):
        print(f'✅ 已发布:AccessKey={result["access_key"]} → {result["dashboard_url"]}')
    else:
        print(f'✅ 已发布:AccessKey={result["access_key"]}')
else:
    print(f'❌ 失败:{result["error"]}')
```

输出后立即 stop。

#### G2. 保存 / 更新 PREVIEW   ·   触发:保存预览 / 更新预览 / 同步预览 / 推送预览 等

> 特别提醒:**不要**重跑 `build_kanban(SPEC)`——上一轮 Step D/F 已生成最新 `kanban_save_params.json`;本 helper 只调 `UpdateAiKanBan`,不会覆盖发布态。

```python
import os, sys
_REF = os.environ.get('KANBAN_REFERENCE_DIR', '').strip()
if not _REF or not os.path.isdir(_REF):
    _PLUGIN_ROOT = os.environ.get('CODEBUDDY_PLUGIN_ROOT', '') or os.getcwd()
    for _cand in (
        os.path.join(_PLUGIN_ROOT, 'scenarios', 'data-analysis', 'skills', 'intelligent-kanban', 'reference'),
        os.path.join(_PLUGIN_ROOT, 'l3-skill-scenario', 'intelligent-kanban', 'reference'),
        os.path.join(_PLUGIN_ROOT, 'intelligent-kanban', 'reference'),
        os.path.join(os.getcwd(), 'reference'),
    ):
        if os.path.isdir(_cand):
            _REF = _cand
            break
if not _REF or not os.path.isdir(_REF):
    raise SystemExit('❌ 无法定位 intelligent-kanban/reference 目录;请设置 KANBAN_REFERENCE_DIR 或 CODEBUDDY_PLUGIN_ROOT')
sys.path.insert(0, _REF)
from kanban_runner import _B  # builder helper 命名空间

result = _B['update_to_kanban_list']()  # 入参可省略,默认从 <workspace_folder>/.kanban_output 推断;只调 UpdateAiKanBan
if result['status'] == 'success':
    if result.get('dashboard_url'):
        print(f'✅ 已保存预览:AccessKey={result["access_key"]} → {result["dashboard_url"]}')
    else:
        print(f'✅ 已保存预览:AccessKey={result["access_key"]}')
else:
    print(f'❌ 失败:{result["error"]}')
```

输出后立即 stop。

---

## DSL API 速查(L3 颗粒度)

```python
# 数据源
source(table=..., columns=[...], time_col=..., time_type=..., where=..., limit=10_000)

# 维度
dim('product_type')                                              # 裸列名
dim('CASE WHEN p<20 THEN "L" ELSE "H" END', alias='price_tier')  # SQL 表达式
time_dim('time','month')                                         # 时间分桶;裸列类型从 source.columns 自动回填
# ⚠️ time_dim 输出别名 = `<col>_<gran>`(如 `time_day`)不是 `<gran>`;order_by 写 `'-time_day'`,禁 `'-day'`

# 度量
metric('SUM(sales)', label='销售额')
metric('SUM(a)/NULLIF(SUM(b),0)*100', label='转化率', format='.1f', suffix='%')  # 派生比率
metric('MIN(price)', role='low')                                                    # K 线角色
metric('SUM(x)', normalize='max-norm', label='X')                                   # 雷达归一化
# string 时间列:所有时间提取/比较必包 spark_safe_to_timestamp(col),禁 TO_TIMESTAMP
metric('AVG(DATEDIFF(spark_safe_to_timestamp(end), spark_safe_to_timestamp(start)))', label='平均时长')

# KPI
kpi('SUM(amount)', '总额', prefix='¥', format=',.0f')
kpi('COUNT(DISTINCT id)', '用户数', format=',.0f')

# 图表(16 类共用 dims+metrics)
chart(kind, title,                  # title 不写 emoji,emoji 只走 emoji= 参数
      dims=[...], metrics=[...],
      span=2, emoji='', order_by='-SUM(x)|alias|-1',
      limit=10, stacked=False, dual_axis=[1], smooth=True,
      extras={...})                                          # 透传 echarts
# ↑ chart() 不接受 from_sql=/sql=;多表 JOIN / 手写 SELECT 请改用下面的 raw_sql()

# 逃生口(OLAP sql_type=3 必用;lakehouse 复杂 SQL 可用)
raw_sql(title='标题', kind='line',
        sql='SELECT month, SUM(amt) AS amt FROM <按 P0-3 写表名> GROUP BY month ORDER BY month',
        slot_columns=['month','amt'],   # 顺序必须与 SELECT 严格一致
        span=4, emoji='📈')

# 同环比
compare(title='...', dim=time_dim(...),
        metric=metric('SUM(...)', label='...'), kinds=['mom'|'yoy'|'wow'])
```

---

## 常见图表数据约定(L3 统一抽象)

> 以下列出的 **dims/metrics 数量、角色、表达式限制** 均为 `kanban_dsl.py` 里 `Chart._CONTRACTS` 的硬契约:
> 违反会在构造 `Chart(...)` 那一刻报 `[DSL] ...`,不会拖到 runner / SQL / 前端。

| 图表 | dims | metrics | 硬契约(写错即报) |
|------|------|---------|--------------------|
| `kpi` | — | `Spec.kpis=[kpi(expr,label,...)]` | label 必填;expr 必须含聚合或为常量;跨表/JOIN 聚合传 `from_sql='cat.db.t1 a JOIN cat.db.t2 b ON ...'` |
| `line/bar` | dims[0]=分组(+可选 group) | n 度量 → n 系列 | dims ∈ [1,2];metrics ≥ 1 |
| `pie` | dims[0]=分组 | 单度量 | dims ∈ [1,1];metrics ∈ [1,1] |
| `scatter` | dims[0]=x,dims[1?]=color | metrics[0]=y | dims ∈ [1,2];metrics ∈ [1,1];**行级 limit 必配 order_by**;dims[0] 禁裸分类 |
| `radar` | dims[0]=分组 | ≥3 个度量,label=轴名,normalize='max-norm' | dims ∈ [1,1];**metrics ≥ 3** |
| `funnel` | — | 每度量一个阶段,label=阶段名 | dims = 0;**metrics ≥ 2** |
| `gauge` | — | metrics[0],用 metric.target 设上限 | dims = 0;metrics ∈ [1,1];**metric.target 必填**(比率型给 100.0 / 绝对值给用户目标数) |
| `heatmap` | dims[0]=x, dims[1]=y | metrics[0]=值 | dims ∈ [2,2];metrics ∈ [1,1] |
| `candlestick` | dims[0]=时间轴 | 4 度量 role='open/close/low/high' | dims ∈ [1,1];metrics = 4 且**四个角色齐备** |
| `treemap/sunburst` | dims=路径(多级) | metrics[0]=值 | dims ≥ 1;metrics ∈ [1,1] |
| `sankey` | dims[0,1]=source/target | metrics[0]=权重 | dims ∈ [2,2];metrics ∈ [1,1] |
| `boxplot` | dims[0]=分组 | metrics[0]=**行级数值表达式** | dims ∈ [1,1];**metrics[0].expr 不能含 SUM/AVG/COUNT/MIN/MAX**(runner 内部已做 percentile_approx) |
| `graph` | dims=[节点] 或 [source,target] | 边模式 metrics[0] 必填 | dims ∈ [1,2];metrics ∈ [0,1];**边模式(dims=2)必配 limit** |
| `parallel` | dims[0..-2]=轴, dims[-1]=分类 | — | **dims ≥ 3**;metrics = 0;**前 N-1 维必聚合 或 配 limit**(禁行级表达式) |
| `table` | dims=列序 | metrics 可选聚合列 | dims ≥ 1;**显式 limit 必配 order_by**(无聚合时 runner 自动 LIMIT 50) |

### ⚠️ 常见误用

👉 22 类反例代码集中在 [kanban_spec_pitfalls.py](kanban_spec_pitfalls.py) `if False:` 区块(文件头按报错关键字索引)——DSL raise 文本已含修法,**多数无需查阅**,仅在 stderr 出现 `[DSL] ...` 时按需定位。

---

## 🛡️ 兜底 / 关键文件

DSL + runner + builder 三层已拦 40+ 类陷阱。LLM 只需:`[DSL] ...` 按报错改 spec;`⚠️ [Runner][软告警]` 忽略;`🔄 [自动降级]` 原样抄入 Step E。

- [kanban_spec_example.py](kanban_spec_example.py) **必读**:正例骨架(16 类 kind + 多表 7.5 节)
- [kanban_spec_pitfalls.py](kanban_spec_pitfalls.py) 按需:`[DSL] ...` 时按文件头索引 grep
- [reference/kanban_dsl.py](reference/kanban_dsl.py) 只读 DSL API;看板创建/修改流程不读不改其余 reference(维护 skill 时以 reference 为唯一真源)

---

## 🧬 DSL 描述层(第一版协议 · 三端统一入库源)

> LLM **无需感知**:DSL 生成、落盘、入库覆盖全部在 runner 内部完成,spec 形态零变化,看板创建/修改流程不需读写 `kanban_dsl.json`。此章节仅作维护/排障参考。

**产物**:`<workspace_folder>/.kanban_output/kanban_dsl.json`(Meta + UiSettings + Pages[Widgets/PageLayout] + Datasets,本地排障完整态),生成时刻 `Meta.Status='DRAFT'` / `Meta.KanbanVersion=1`。

**入库映射(三端 studio / databuddy / ai 看板列表统一使用)**:
- `UpdateAiKanBan.HtmlContent` = base64(不含 `Datasets` 的页面/组件 DSL)(不再是 HTML 原文),写入后端 PREVIEW
- `UpdateAiKanBan.SqlSlots` = base64(`Datasets` 数组),是唯一 Dataset 入库源,写入后端 PREVIEW
- `SaveAiKanBan` 发布入参只传 `WorkspaceId + AccessKey`,后端从同 `AccessKey` 的 PREVIEW 同步 `HtmlContent` / `SqlSlots` / `DisplayName` / `ExecuteResourceId` / `RefreshSchedule` / `SessionTag` 到发布态
- `Datasets[]` 内部字段是第一版 lowerCamelCase 协议:`key/sql/metrics/dimensions/data/columns/refreshInterval/sqlType/dataSourceId/connectionType`;生成端按服务端同口径在 `data` 总量超过 128KB 时整体剥离 `data`
- 覆盖动作由 emitter 内 `_patch_save_params_with_dsl` 完成,`kanban_save_params.json` 里 HtmlContent/SqlSlots 已是 PREVIEW 入库形态;runner 每次 `build_kanban` 成功后自动调用 `UpdateAiKanBan` 写后端 PREVIEW(无 `AccessKey` 时由后端创建 PREVIEW 并返回 `AccessKey`)。

**发布语义**:`Meta.Status='DRAFT'` 只表示本地生成态;运行态以 `GetAiKanBan.Status/ViewStatus/Version` 为准。`UpdateAiKanBan` 只用于保存/更新 PREVIEW;用户明说「保存/发布/上线/确认发布」→ Step G1(`save_to_kanban_list()`,只调 `SaveAiKanBan` 且只提交 `WorkspaceId + AccessKey`)同步 PREVIEW 到发布态;用户明说「保存预览/更新预览/同步预览」→ Step G2(`update_to_kanban_list()`,只调 `UpdateAiKanBan`)不覆盖发布态。

**排障**:若终端未打印 `📄 DSL 描述层已生成: .../kanban_dsl.json` 或打印 `❌ 预览态同步失败` / `❌ 看板本地产物已生成,但 PREVIEW 入库失败` → runner 未成功写入 PREVIEW,会影响三端入库预览;按报错文本改 spec 重跑即可。

- [reference/kanban_dsl_emitter.py](reference/kanban_dsl_emitter.py) 只读 DSL emitter 实现;触点仅两处:runner 编译循环里的 `_dsl_widget_records` 收集 + `write_kanban_outputs` 之后的 `emit_dsl` 调用。