完善口径管理、主动调查与后台任务,修复通用问题

- 完善口径编辑、只读试运行、统一质量验证与执行语义指纹
- 增加可执行指标约束、有预算的主动调查和过程评测
- 提取发布检查、证据读取与起草服务,支持任务停止、重试和检查点恢复
- 增加版本传递影响分析,修复并发覆盖、敏感值遮罩、异常评分及页面状态问题
- 修复 SQLite 复合查询追溯和 microVM 可选依赖缺失处理
- 纳入当前设计文档及部署配置调整

验证:相关主回归 3822 项通过,4 项跳过、8 项预期失败;补充回归通过。
前端构建、文案检查及 git diff --check 通过。
全量检查仍有环境权限、PostgreSQL 可选依赖和既有部署配置、SQLite 文件基线差异导致的未通过项。
This commit is contained in:
ThearchyHelios
2026-11-06 17:14:45 +08:00
parent 5f0b2359ac
commit 9d8b41430e
94 changed files with 9730 additions and 957 deletions
+234
View File
@@ -0,0 +1,234 @@
# 分析能力一轮总方案:算、想、核
> 状态:已拍板(2026-10-21):六项能力 + 评测一轮做完;问数据里的说法核对打回重写一次,仍不过就标出。开工。代码基准 main `9e5debfb`。
> 依据:五路只读调研(结论落到文件:行号)。细案 01-算子与判定.md 定算子、判定、说法表和阈值;本篇定范围、理念、跨块决定、接入、分包与关口,冲突以本篇为准。真实库名、表名、计数都不进本文;示例、测试、验收一律用虚构零售库。
## 0. 范围
| 块 | 编号 | 一句话 |
|---|---|---|
| 算 | ① 可比性检查 | 比两期之前先确认能比:只差日期、窗口一样长、是整期、星期构成相同 |
| 算 | ② 变化拆解 | 变化从哪来:可加按维度算贡献(闭合核对)、比率拆结构与自身、乘积拆因子、逐层下钻 |
| 算 | ③ 放回历史看 | 波动基线(算不算异常)+ 趋势(是不是持续) |
| 算 | ④ 差异与样本量 | 两组 / 两期的差异站不站得住:区间、检验、小样本、单值敏感、多重比较校正 |
| 想 | ⑤ 分析计划与假设台账 | 分析型问数、平台内置的分析方法、候选维度来自目录、系统写「查了什么、排除了什么、哪些没查」 |
| 核 | ⑥ 分析说法核对 | 判断词要挂对应的分析结论且强度对得上;判定语由系统渲染;因果词没依据标推测 |
| 配套 | 评测 | 两种新期望(贡献、判定),期望由期望 SQL + 同一套算子算出;埋了效应的虚构题集 |
| 配套 | 修口子 | 引用模式下被引指标的输入追不到原始数据时,受管级记缺口,其余标注 |
不在本轮:预测;相关分析;维度层级进目录;数值列剖析;核查 agent(双盲、反方、质询)使用分析工具;分析结论参与口径账本比对与召回。
## 1. 调研结论(为什么要做)
- **能算的太少**:报告里站得住的数只来自 SQL 返回的格、口径卡的白名单标量式(`engine/expressions.py:528`,没有分组、按键排名、方差、检验)、运行输入和知识库原话。沙箱只有标准库(`sandbox/interpreter.py`),算出的数按设计不能引用(`engine/evidence.py:758`)。
- **分析结论没有来源**:维度归因只在两次运行对比里(`engine/compare.py`),不进台账;`data/value_drift.py` 只看目录的行数和码值。
- **要求说原因、却不给原因**:写作者的角色是「挑重点、组织结构、说清变化和原因」(`engine/nodes/report.py:107`);裁判被告知不要复算、只看写作者挂的证据(`engine/judge.py:154`),挑过的证据照样过;问数据建图明写不加反方审稿、claims 不用写(`api/copilot.py:411`)。开发库 160 轮问数据里反方审稿、双盲复算一次都没出现。
- **没有方法**:建图和 agent 提示词里没有基期对比、拆解、下钻、假设验证;种子里的「结构化分析」Skill 从没被挂过。
- **量不出来**:评测「为什么」只能写 text 要点、不计分(`eval/cases.py:9`)。
## 2. 守住的理念
- **照实说**:算不了就说为什么(截断、不闭合、历史不够、本期不完整、不可比、样本小),不硬给结论;判定语写出判定标准。
- **留痕**:每份分析结论是内容寻址的工件,记方法版本、参数、阈值、输入格、现查的表范围、结果、判定;进证据台账、随运行封存、能复算。
- **不挡生成**:算不了是一条结论,不让 agent、节点失败;说法核对在问数据里只重写一次、仍不过就标出。
- **只读与遮罩**:算子只读快照,唯一的现查(表的日期范围)走 data/coverage 的只读探查;遮罩列不当维度、不当度量。
- **一种说法**:一套算子、一种结论形状、一个证据种类、一张判定语表、一张说法表;问数据(agent 工具)和模板(分析卡)两个入口共用。
- **模板定骨架、agent 填关节**:模板里分析卡固定做哪些分析;问数据里 agent 运行时决定拆哪个维度,但方法和阈值由平台定。
- **不可回退,照旧**:叙述层无算术;不做「从指标定义编译 SQL」的语义层;数据目录只放事实,口径公式在口径卡里;口径升版是被迫处置事件。本轮再往前一步:**叙述层也不下分析判定**——「主要来自」「超出常规波动」这类判定和数字一样由系统渲染,写作者只引用。
## 3. 设计
### 3.1 分析结论(契约)
形状见细案 01 §1。要点:kind `analysis_result` 工件、台账 kind `analysis`、写作目录编号 A1…;纯函数可复算;判定语、具名标量、结果表三种引用面。契约放在 `app/analysis/contract.py`(骨架提交),各包按它写。
### 3.2 两个入口
**agent 分析工具**(问数据、画布上的 agent):五个平台工具 `analysis_decompose`、`analysis_baseline`、`analysis_trend`、`analysis_differ`、`analysis_comparable`。
- 参数只有「算什么」:引用本节点查过的结果(`query: "Q3"`,和交回模型的「【证据 Q3】」同一个编号)、列的角色、期次、`headline`、`parent`、`label`、`hypothesis`;没有阈值参数。
- 工具建在查询列表之前(`engine/nodes/llm.py:428` 早于 `:442`),所以经 `ToolContext.extra` 传一个可变的「本节点查询」容器(同 `MASKED_VALUES_KEY` 的做法,`tools/datasource.py:56`),查一次往里放一次。
- 交回 agent:判定语、关键数、caveats、算不了的原因、编号「【分析 A2】」;台账记 kind `analysis`(`_record_call` 的写法,`engine/nodes/llm.py:871-912`)。结果确定、落成工件(tool_step 进 checkpoint,续跑回放同一份)。
- `ToolSpec` 加 `guide` 字段(`tools/registry.py:58`),绑了任一分析工具就把平台的「分析方法」注入 system(`engine/nodes/llm.py:_build_messages:113-141`,supervisor 成员在 `engine/nodes/multi.py:329`),同一份只注入一次。不用 Skill:Skill 是用户可改、可停用的库里的行,seed 只在同名不存在时插入(`seed.py:344`),平台方法要随代码升版。
- `is_platform_query`(`capabilities/registry.py:305`)不认分析工具:它不查库。使用者的问数据白名单(`auth/chat_policy.py:35`)加上分析工具。
**分析卡**(节点类型 `analysis`,和口径卡平级):`items: [{id, op, mode, from: <tool / merge 节点 id>, params...}]`,可写阈值覆盖(写进图、随版本固定)。按合并查询节点的做法从 `state.nodes` 取快照 id、load 复验(`engine/nodes/merge.py:48-79`)。产出 `nodes.<id>.results.<item>` = {status, verdict, values, phrase},下游 branch 能按判定分支(周报「超出常规波动就转人工」)。登记处按 merge 节点的先例(细案见 §5 W1-c 清单)。
### 3.3 证据接入
- 台账循环(`build_catalog`,`engine/evidence.py:937-976`)加 A 计数器;`report_catalog` 的 evidence_from 过滤(`engine/nodes/report.py:153`)放行 analysis。
- 引用:`[[a:A3]]` 判定语(一个可点片段),`[[a:A3.top_share|pct]]` 具名标量,`[[v:A3.r0.share]]`、`[[table:A3 cols=… rows=…]]` 结果表,`[[see:A3]]` 句末依据。改 `_CELL_REF`、`_ROW_REF` 认 A,`_query_entry` 放行 kind analysis,`_Snapshots` 加取 A 结果表的适配;只认 kind query 的几处(`_header_masks`、`_text_names`、`_fields_prompt`、`masked_cells`)逐个过一遍。
- 写作目录加「分析结论」一段:编号、判定语、具名标量、表的前 `table_top_k` 行、caveats、算不了的原因,外加怎么引用、说法规则一句。
- 出处链(`api/evidence.py:_chain:918`)加「分析」一步:判定与判定标准 → 方法与阈值 → 计算(结果表、关键数的代入式,如「占比 = 华东变化 / 总变化 = −120 / −194」)→ 输入(落到 Q 的格,闭合核对对着 headline)→ 复算 → 封存。证据图(`_graph_of:477`)加「派生自」的边。
- 审计、导出 HTML(`report_export/build.py`、`runtime.js` 的 PARTS)、可验证报告(`bundle/build.py` 的 `_CITED_KINDS`、`_card_input_queries`;`verifier.js` 的 KIND_LABEL、引用链、`checkNumber`)都认 A,带上结论工件和它的输入快照;浏览器里只核哈希、展示服务端的复算结果,不在 JS 里重写统计。
- 血缘:分析结论的输入只能是本次运行台账里 kind query 的快照(解析器只认它们),复算即血缘:输入哈希都核对得上、复算逐字段相同才算追到。分析卡的结果**不作为口径卡的输入**(口径卡读 `nodes.<分析卡>.results` 按「追不到原始数据」处理,受管级静态检查给出提示)——分析是叙述的依据,不是口径的原料,见 D16。写死 `("tool_cell", "agent_field")` 的几处(`api/evidence.py:1026/1713`、`bundle/build.py:1040`、`engine/judge.py:527`、`engine/recompute.py:401/427/534/590`、`engine/nodes/dual_check.py:99`、`ledger/labels.py:58`)不用改。
- 裁判:`_excerpt` 加 analysis 一支(方法、判定、关键数、判定标准、输入 SQL),`_candidate`、`_query_alias`、`_judge_loader` 同步。
- 口径账本:分析结论里的数不和口径比对(它们是派生的),摘要行不计它们;前端 `LedgerTurn.tsx:30` 只认四种标签,服务端不新增标签。
### 3.4 可比性挂在哪
1. agent 单独调用 `analysis_comparable`。
2. decompose 两份快照时、baseline / trend 判本期完整时自动做。
3. **口径卡**:一个指标的表达式用到两份不同查询快照里的格、而且两份 SQL 同形只差字面量(本周 vs 上周)时,自动做可比性检查,结果挂在指标上(指标条目加 `comparability`)。写作目录照实提醒;带契约的出口:`incomparable` 记缺口降档(同 sql_check_failed),`conditional` 只标注。两份 SQL 结构不同(退款额 / 销售额)不是同一个量的两期,不查。
### 3.5 分析说法核对
- 说法表、否定、来源与因果的分界、违规代码与处置见细案 01 §9。实现在 `engine/analysis_claims.py`,挂点和缺失断言同一层(`engine/evidence.py:compose_doc` 追加违规,和 `absence_claims:3538` 并列),复用 `iter_units`、`_split_sentences` / `_SEE_TAIL`(句末依据挂到前一句)、按片段 span 涂白引文和实体的办法。
- 报告节点 `analysis_claims`:`check`(缺省)重写一次后标出、不影响档位;`strict` 按 max_repairs 重写、仍有的进出具缺口;`off` 不核。受管级要求 `strict`(G3 加一条,旧版本照常运行、下次发布按新规则)。
- **出口独立复核**:判断词核对进 `verify_doc`(组装和出口共用),报告文档加版本字段,只有新版本文档才核这一项——老文档复核结果不变。
- 写作者的角色改成「挑重点、组织结构、说清变化;有分析结论时说清变化来自哪里;原因只按证据写」,写作规则加一句判断词怎么挂;`claim_priority` 的预筛词(`_DIRECTIONAL`,`engine/evidence.py:2667`)补上「主要、异常、持续、显著」等,裁判优先判这类句子。
### 3.6 分析范围(系统写的一段)
报告里只要用到了分析结论(或这次运行有分析结论),文档末尾由系统追加「分析范围」一段(写作者改不了,随文档封存,点得开):
- **现象**:可比性、基线、趋势结论的判定语。
- **拆过**:每份拆解按链列出维度和判定(主要来自华东 / 分散 / 算不了的原因)。
- **排除**:判定为分散、没有差异的那些——拆了没发现集中也是结论。
- **没拆**:候选维度里没拆过的。候选维度确定地从目录算:拆解所用查询里的事实表上标成 attribute / status、不是标识、不遮罩、取值不多(剖析的取值数 ≤ 50 或码值表完整)的列,加上它关联的维度表;拆解用的列对不上目录时只说「候选维度核对不了」,不乱列。
- **检验**:这次做了几次差异检验、是否已校正。
agent 写的 `hypothesis` 不进这一段(模型写的字不进系统渲染的正文,细案 01 §7.3),只在证据面板显示。报告节点 `analysis_scope: auto`(缺省)/ `off`。
### 3.7 想:分析型问数与平台方法
- **分析方法**(随代码升版,进评测的 prompt_fingerprint,`eval/runner.py:97-110`):
1. 先定现象:取本期和问题对应的基期,做可比性检查;有 8 期以上历史就放回历史看——是常规波动就照实说,不硬找原因。
2. 指标是乘积或比率时先拆因子、拆结构。
3. 按目录里的候选维度由粗到细拆,每次给总数核对闭合;找到主要来源再下钻一层。
4. 每次调用写明在验证什么;拆了没发现集中也是结论。
5. 不作因果断言:数据只说明变化来自哪里;原因要有知识库原话或输入作依据,否则写成待核实的推测。
6. 阈值、方法不由你定;算不了照实说。
- **建图**:问题问「为什么、原因、怎么变了、哪里拖累、是否异常、正常吗、趋势、持续」时,agent 绑分析工具(问数据包装 `api/copilot.py:403-416` 加一种形状,节点手册加分析卡一段、agent 段补分析工具、搭图规则加一条)。问题意图用 `app/analysis/intent.py` 的确定词表判,传进 GraphChecker(`api/copilot_gen.py:712`),自查规则「分析类问题,agent 要绑分析工具」只挡一轮(照 coverage 的样板);`_evidence_source`(`api/copilot.py:1239`)认分析卡。
- **复核**:新日志代码(分析算不了、判断词核对重写后仍有)登记进 `engine/review.py:WARN_CODES`,只写说明、不自动重来。
- **内置模板 ⑪「异动分析(可追溯)」**:取数(整体周序列 + 按地区两周)→ 分析卡(基线 + 拆解)→ 报告(`analysis_claims: strict`)→ 出口(契约)。⑨ 可追溯周报不动。
### 3.8 评测
- 新期望种类(细节在 W2-b 提示词里,按 `eval/cases.py`、`gold.py`、`score.py` 的现有结构):
- `contribution`:期望 SQL + 拆解参数 → 期望的主要来源(driver 或前 k 段)和方向。
- `verdict`:期望 SQL + 基线 / 趋势 / 差异 / 可比性参数 → 期望的判定类(基线:常规 / 异常;趋势:上升 / 下降 / 无;差异:站得住 / 不足以下结论;可比性:可比 / 不可比)。
- 期望值仍不许人填(`HUMAN_VALUE_KEYS` 加 segment、direction、verdict);作答前后各算一次,算子输出规范、稳定(`_fingerprint` 逐字比较)。
- 评分沿用五种结论:答案引用了判定对得上的分析结论 → 对且可追溯;正文按说法表写对了、没引用 → 对但没引用;判定、来源对不上或答成相反 → 错;期望算不了 → 不能评分。分析结论的输入查询并进 `answer.cited`(逐题 E1)。SCORER_VERSION 加一。
- **新的虚构库与题集**:另建 `tests/fixtures/acceptance/retail_trends.py`(数据源名 `retail_trends`,固定种子,从今天往前 26 周、截止昨天),不改现有零售库(现有测试断言了它的比例、地区排序,埋效应会改掉后面全部随机序列)。埋的效应(相对「上一个整周」):华东主导的下降(贡献)、渠道结构变化造成的退款率辛普森(结构)、订单数的常规波动(不该叫异常)、取消单激增(异常)、线上销售额持续上升(趋势)、销售额变化主要来自订单数(因子)、样本很少的门店退款率(差异不足以下结论)、本周不完整(不可比)。题集 `analysis_suite.json` 每种至少一题。
### 3.9 修口子:引用模式的血缘
引用模式的出口(`engine/nodes/io.py:209` 只调 `_check_citations`)收齐被引的指标(`[[m:]]` 片段和句末依据里的 m:),用口径卡上记的输入算 untraced:受管级的正式运行记缺口(降档),其余写进出具声明的 `untraced`、证据面板照旧标「追不到原始数据」。这样「代码节点自称取数」在受管报告里也不再能完整出具,和证据面板的说法一致。只在受管时降档:影响的现有测试只有两条受管运行的缺口列表(档位不变),见 W1-d 提示词。
### 3.10 设置
「设置 → 偏好设置 → 分析」一节(照 `pages/settings/LedgerSettings.tsx` 和 `api/settings.py` ledger 组的写法),键与缺省见细案 01 §10。发起运行时照 `_with_verify_snapshot`(`engine/launch.py:290`)写 `options["analysis"]`,只在图(含子图)里有分析卡、agent 绑了分析工具时写,别的图的 run.started 一字不变。
## 4. 跨块决定
| 编号 | 决定 | 否决的备选与代价 |
|---|---|---|
| D1 | 分析是平台的确定性代码,模型只选算什么 | 沙箱装 pandas 让 agent 写分析代码:算的数按设计引不了,任意代码不可复核,阈值能调到想要的结论 |
| D2 | 算子只吃实际执行过的 SQL 快照,下钻 SQL 由 agent 写,系统用闭合从数上核对 | 改写 SQL 自动下钻:就是语义层,已明确不做;改写带关联、窗口函数的 SQL 也容易错 |
| D3 | 新节点类型「分析卡」,不扩口径卡的函数 | 扩口径卡:口径卡编图时定死,问数据里该拆哪个维度要看了数才知道;分析产出是表和判定;会把受管口径(账本、召回)和一次性分析混在一起 |
| D4 | 阈值只来自设置快照和分析卡上的覆盖,工具参数里没有阈值 | 让 agent 传阈值:能调出想要的结论 |
| D5 | 判定语由系统渲染,`[[a:]]` 引用 | 让写作者照判定自己写:同一个判定十种写法,说法表也核不过来 |
| D6 | 说法核对是确定的词表 + 绑定,不调模型 | 交给裁判:花钱、慢,只看挂了的证据;裁判仍保留,判剩下的细处 |
| D7 | 因果词要知识库原话或输入作依据,否则写推测;拆解里点名的段按「来源」核 | 一律禁因果词:「主要由华东造成」这种记账说法太常见;一律放行:拆解被读成原因 |
| D8 | 问数据缺省 `check`(重写一次、标出、不影响档位),受管要求 `strict`;已发布的版本照常运行 | 缺省 strict:探索运行里多出整篇重写;缺省 off:问数据里得不到这层保障 |
| D9 | 说法核对进 verify_doc,文档加版本字段 | 只在组装时查(像缺失断言):strict 下出口不独立复核,档位凭写作节点一面之词 |
| D10 | 分析卡的结论总能引用;agent 工具的结论在受管正式出具里和 agent 查询的格同一规矩(契约 `cells`) | 一律放行:受管报告里 agent 现场决定的分析绕过 cells 的约束 |
| D11 | 分析结论的数不进口径账本比对,摘要不计 | 给它们新标签:前端摘要只认四种(会算出 NaN),而且它们不是口径 |
| D12 | 平台方法挂在 ToolSpec.guide,随代码升版 | 用 Skill:库里的行、用户可改可停、seed 不更新已有库 |
| D13 | 统计纯 Python,Wilson 从 eval/stats 移到 analysis/stats 共用;测试用预先算好的参考值钉住 | 加 scipy:几十 MB 依赖只为几个函数,本机装包慢;和评测已有的纯 Python 统计也不是一套 |
| D14 | 新建虚构库 retail_trends 承载分析题 | 在现有零售库里埋效应:共用一条随机序列,改一处后面全变,现有断言要大改;60 天也不够 8 周基线 |
| D15 | 引用模式的血缘缺口只在受管时降档 | 所有级别降档:会改动约十条现有测试的档位,探索运行也没必要 |
| D16 | 分析卡的结果不作口径卡的输入 | 让口径卡读分析值:血缘要穿过分析再穿回查询,口径的等价证明、召回、双盲复算都得认一种新来源;分析结论本身已能引用 |
| D17 | 写作目录里 A{n} 条目带整份结论(`contract.catalog_result` 读);口径卡指标的可比性记在 `comparability` 字段;「分析范围」是报告文档的结构化字段 `analysis_scope`,不进正文 | 范围段写成正文:要过裸数字和说法核对,写作者也可能改它 |
## 5. 分包与归属
集成分支 `an/w1`(cd-integ)= main `9e5debfb` + 骨架提交(`app/analysis/contract.py`、`__init__.py` 的 run / recompute 签名、`testing.py` 的假结论、设置缺省)。各包在自己的工作区从骨架拉分支;**不许 git stash**(各工作区共享同一个 stash),临时保存用 `git diff > 自己目录下的补丁`。
### 第 1 波(并行四包)
| 包 | 工作区 / 分支 | 内容 | 主要文件(归属) |
|---|---|---|---|
| W1-a 算子库 | cd-be / an1/a | 五个算子、统计函数、判定语、可比性(含探查)、复算;Wilson 移到 analysis/stats | `app/analysis/*`(除 contract 外全归它)、`eval/stats.py`(只改引用) |
| W1-b 证据接入 | cd-fe / an1/b | 台账 A、写作目录、引用 a: / v:A / table:A / see:A、出处链、证据图、审计、导出 HTML、可验证报告、裁判摘录、血缘 via analysis、账本跳过 | `engine/evidence.py`(除 W1-d 的挂点)、`api/evidence.py`、`engine/judge.py`、`engine/lineage.py`、`report_export/*`、`bundle/*`、`ledger/*`、`engine/recompute.py`、`engine/sql_problems.py`;`engine/nodes/report.py` 只动 evidence_from 一处 |
| W1-c 入口 | nr-c / an1/c | 五个分析工具与 guide、llm 里的查询容器与台账记录与方法注入、分析卡全套登记、口径卡可比性挂钩、运行对比里分析卡按项对齐、设置组与快照、使用者白名单 | `tools/*`、`engine/nodes/llm.py`、`engine/nodes/multi.py`、`engine/nodes/analysis.py`(新)、`engine/nodes/metrics.py`、`engine/schema.py`、`engine/compiler.py`、`engine/governance.py`、`engine/labels.py`、`core/events.py`、`engine/upgrade.py`、`engine/variables.py`、`engine/compare.py`、`data/catalog_impact.py`、`apps/*`、`auth/chat_policy.py`、`plain.py`、`api/settings.py`、`engine/launch.py` |
| W1-d 说法核对 | nr-d / an1/d | 说法表与绑定、check / strict / off、出口独立复核与文档版本、写作者角色与规则、分析范围段(候选维度)、预筛词、修血缘口子、G3 一条 | `engine/analysis_claims.py`(新)、`engine/analysis_scope.py`(新)、`engine/nodes/report.py`、`engine/nodes/io.py`;在 `engine/evidence.py` 的 compose_doc / verify_doc / `_DIRECTIONAL` 只加挂点;`engine/schema.py`、`engine/governance.py`、`plain.py` 只加报告节点配置与 G3 那几行 |
共享文件的规矩:非归属包只在约定的挂点加几行、不重排不改名;合并冲突由编排者解。`api/copilot.py` 第 1 波谁都不动(第 2 波 W2-a 归属)。
### 第 2 波(并行三包,第 1 波合进集成分支后开)
| 包 | 工作区 / 分支 | 内容 | 主要文件 |
|---|---|---|---|
| W2-a 想 + 文档 | cd-be / an2/a | 分析型问数、节点手册、自查与问题意图、方法文本定稿、复核信号、内置模板 ⑪、中英文档(新增 analysis 一篇 + 七对文档 + 升级说明 + README 索引) | `api/copilot.py`、`api/copilot_gen.py`、`app/analysis/intent.py`、`engine/review.py`、`chat/*`、`seed.py`、`docs/zh-CN/*`、`docs/en/*`、`README*.md` |
| W2-b 评测 | cd-fe / an2/b | 两种期望全链路、SCORER_VERSION、虚构库 retail_trends 与题集、评测前端 | `eval/*`、`tests/fixtures/acceptance/retail_trends.py`、`tests/fixtures/eval/analysis_suite.json`、`frontend/src/typesEval.ts`、`lib/termsEval.ts`、`pages/evals/*`、`scripts/check-evals.mjs` |
| W2-c 前端 | nr-c / an2/c | 分析卡节点(定义、表单、卡片、配色)、证据面板分析一步、正文 a: 片段与说法违规、分析范围段、问数据时间线的分析步骤、设置「分析」一节、摘要行防 NaN、检查脚本 | `frontend/src/*`(除评测)、`scripts/check-*.mjs`(除 check-evals)、`scripts/check-all.mjs` |
W2-c 改了检查的项数时,把数告诉编排者,`docs/*/development.md` 由 W2-a 一处改。
### 复核与修复
每波一个复核代理(第 2 波接着用同一个,SendMessage 续上):按 a → b → c → d 试合并、跑合并后的相关测试、读改动找问题;意见用 SendMessage 交回原包代理修,不另起修复代理。
## 6. 验证与关口
- 包代理:开发中只跑相关测试文件(`-n 8`);交付前后端全量一遍;第 1 波不跑浏览器检查,第 2 波只跑自己改到的单项检查;check-all 加锁 `mkdir /tmp/agentlab-fix/checkall.lock`。跑 pytest 去掉模型相关环境变量(`env -u ANTHROPIC_API_KEY -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN -u OPENAI_API_KEY`)。
- 编排者在第 2 波合并后跑一次完整关口:后端全量(SQLite);本轮不加表、不改迁移的话 PostgreSQL 全量不跑(有就跑 `scripts/test-postgres.sh`);tsc;check-copy;check-docs;相关浏览器检查(evidence、report-export、bundle、chat、decode、stream、studio、tokens、evals、manage、ledger);泄露扫描(含本会话见过的真实名做指纹)。
- 真实模型验收:用 retail_trends 题集跑评测(分析题 当前 vs 关 分析工具),人工核对几题的证据链;验收前问用户模型接入怎么配。
- **合 main 前问用户**。
## 7. 风险
| 风险 | 应对 |
|---|---|
| 说法表误判(正常句子被打回) | 只收强判断词;否定、引文、系统渲染的字不扫;check 档只重写一次、不失败;评测里看重写率和标出率 |
| agent 不用分析工具、或乱用 | 方法注入 + 自查规则 + 调用错误文案教它改;`max_calls` 上限;评测「当前 vs 关」量效果 |
| 探查拖慢问数据 | 只在可比性检查时探、走 coverage 的总时限与 5 分钟缓存,失败退回剖析事实、再不行就「核对不了」 |
| 大文件冲突(evidence.py 4441 行、copilot.py 2828 行) | 归属到人、挂点约定、第 1 波不碰 copilot.py |
| 统计函数写错 | 参考值钉住(含边界:并列、n ≤ 10 精确分布、Fisher 小样本、t 分布尾部);拆解恒等式逐位测 |
| 老报告复核结果变化 | 文档版本字段,只核新文档 |
## 8. 实施记录
### 第 1 波交付时认可的偏离(2026-10-21)
- W1-a:differ 三组以上每组和「其余各组合计」比(不和含它自己的整体比,不独立);整月对整月、整季对整季、整年对整年算一样长;同口径不通过只写差在哪些方面、不引 SQL 原文和字面量;新高新低按值本身判;趋势取最后连着的一段;label 消毒也用于目录叫法和当叫法用的列名;比率零分母判 zero_base;比率结果表加 n_base、n_target;乘积因子取整时按同一比例缩放;周次只认 ISO `YYYY-Www`(SQLite `%W` 不是 ISO 周);比率各段分子不超过分母时按百分点写。契约加可选字段:InputSnapshot 的 dialect / checks / no_data / mask_columns / masked_at_query,AnalysisInput 的 dialect / masked,Probe 的 expr / value。parent 按内容哈希认,入口必须原样存结论。
- W1-c:runner 不改(已有 run_options);apps 的 lint / derive 不改(没有对应的绑定种类);compare 的配置键不登记;受管规则分工(输入不对各级报错、非 db_query 的输入画布提醒受管挡、口径卡读分析卡两级都只提示);协作成员能用分析工具但结论不进台账;口径卡的可比性结论不进台账、只挂在指标的 comparability 上(封存加载器要放行它)。
- W1-b:evidence_from 按「谁做的这条分析」过滤(和查询同一口径);报告文档里存的目录只留 A 条目摘要、不带整份结论(需要时用 compose / verify 收到的 catalog 或按工件取回);裁判规则原文不改,摘录里有分析结论时才追加 ANALYSIS_JUDGE_PROMPT(老判定不过期);具名标量的片段种类照数字 / 取值分,只有判定语是 analysis;可验证报告里任一输入快照只给 id 时结论也只给 id;复算的遮罩按快照里记下的查询当时的遮罩列;代入式先做可加拆解、比率拆解、波动基线三类;`see:a:A3` 宽松认;差异检验 0 次时分析范围不显示那一项。契约加 OP_LABELS / MODE_LABELS / method_label / is_result。
### 第 1 波复核后的决定(2026-10-21,复核意见 R1–R37)
- 分析卡里随数据变化的调用错误(上一层没做成、下钻的段这期没有、期次这期没有、总数格越界或为空、要比的组这期没有)不让卡失败:`analysis.run(..., refuse_data_errors=True)` 把它们记成算不了的结论,契约 REASONS 加 `parent_refused`、`input_unavailable`;agent 工具照旧交回调用错误(让它改调用);复算一律按 True 复现。结构性错误(列不存在、遮罩列、比率当可加、重复行、参数写错)照旧让卡失败。
- 差异检验一对都拿不到 p 值(两组各 1 个样本、分母为 0)→ 算不了,REASONS 加 `untestable`。
- 单份快照按期次分组的拆解也核本期完不完整:不完整算不了(partial_target),核对不了附说明。
- 周次:`YYYY-Www` 不再一律按 ISO 算;按 SQL 认出期次列的算法(SQLite strftime %W / %U、MySQL DATE_FORMAT %U / %u、to_char WW / IW),能算真实起止就算,算不了就起止置空、判「核对不了」,绝不判完整;W00 / W53 不走 ISO。
- `_classify` 的结论句判定回到原来的词表,新增的判断词只用于裁判的预筛排序(不改已发布版本的档位)。
- 说法表按复核第 4 节调整(静态构成、建议计划预测、业务上的「异常」名词、「来源于」、「表结构」、「相当」不再误报;补高频漏报写法);还没有线上文档,不升 LEXICON_VERSION。表格正文的格不核。
- 写作者提示词在没有分析的图里也变(新 ROLE + 「没有分析结论就不下这类判断」):照 D8 保留,这是本轮要的效果;升级说明写清。
- 候选维度只认码值表(剖析目前没有每列取值数)、模板 ⑨ 补 analysis_claims strict:记为偏离,认可。
### 合并时要统一的
- 算子的叫法有三份(契约 OP_LABELS / MODE_LABELS、engine/labels.py 的 ANALYSIS_OP_LABEL / ANALYSIS_MODE_LABEL、phrases.py 的「没有做成」那句)→ 只留契约一份。
- tests/bundle_samples.py 补一页带分析结论的样本(第 2 波 check-bundle、check-report-export 要覆盖)。
### 第 2 波要带上的事
- W2-a:review 的 WARN_CODES 登记 analysis_refused、metric_comparability_skipped(以及 W1-b / W1-d 新增的日志代码);方法 guide 定稿时升 id 里的版本号;方法文本写明周序列的期次用每周第一天的日期或 ISO `YYYY-Www`;助手的工具清单从第 1 波起已经列出五个分析工具;`_evidence_source`(`api/copilot.py:1251`)认分析卡。
- W2-b:评测指纹排除 `probes[*].at`(现查时刻)。
- W2-c:节点叫法「分析卡」、字段叫法「分析项」「阈值覆盖」、方法与模式的叫法在 engine/labels.py(ANALYSIS_OP_LABEL / ANALYSIS_MODE_LABEL);新事件 analysis.end;tool.error 新字段 call_error;运行对比新字段 analyses;设置组 analysis。
### 第 2 波交付时的决定(2026-10-21)
- W2-b:期望指纹不比 inputs[*].artifact(查询快照记着 elapsed_ms,同一份数据每次查出来工件都不同);期望 SQL 只返回两期时可以不写基期、本期;算期望的「今天」用服务端当天;编辑器保留多个期望(以前会丢第一个以外的)。说法表不加「可比 / 不可比」一类(不打回谨慎的提醒),评分另用一张小词表认正文里「两期不能直接比」的说法。评测变体加「分析工具」开 / 关和「分析工具贡献」预设(真实模型验收用它量效果)。
- W2-a:自查只在问数据里、只针对查库却没绑分析工具的 agent;分析调用写错后又做成了不再报、一直没改对的记 analysis_call_failed(只写说明,不让整轮重来);模板 ⑪ 的 SQL 只按 SQLite 写;文档里内置工具 23 个、模板 11 个、节点类型 19 种。关分析工具时节点手册连分析卡一整条一起去掉(对照才干净)。
### 第 2 波复核要带上的
- `[[table:An cols=…,share]]` 里占比按原始小数渲染(1.0133380758),不是百分数:结果表要带每列的显示格式(占比、变化率、权重按百分数),证据渲染照它。
- analysis_trend 的工具说明开头写「趋势:」,契约叫法是「趋势判断」。
- termsEval(W2-b)里算子与模式的叫法一份,W2-c 若在 lib/terms.ts 另建一份要统一成一份。
- 新 code 前端要认:analysis_call_failed、analysis_tools_missing、analysis_tools_off。
- development.md / README 的前端检查项数等 W2-c 交数后改;评测文档和 an2/b 最终代码核一遍。
@@ -0,0 +1,303 @@
# 细案 01:分析算子、判定与说法表
> 总方案见 00-总方案.md,冲突以总方案为准。本篇定「算什么、怎么算、怎么判、怎么说」:五个算子的输入、方法、判定、算不了的情形,统一的结果形状,判定语模板,说法表与绑定规则,设置缺省值。例子一律用虚构零售库(地区:华东、华南、华北、西南;渠道:线上、线下)。
## 1. 统一的结果形状:分析结论
一次分析(agent 调一次分析工具,或分析卡里的一项)产出一份**分析结论**,内容寻址存成工件(kind `analysis_result`),在证据台账里记一条 kind `analysis`,写作目录编号 A1、A2……(和 Q、K 同一套编号规则,按台账先后)。
```
{
"op": "decompose" | "baseline" | "trend" | "differ" | "comparable",
"mode": "additive" | "ratio" | "factor" | "rate" | "mean" | null, # 只有 decompose、differ 有
"method": "decompose.additive@1", # 方法 + 版本;改算法就升版本号
"params": {...}, # 规范化后的调用参数(列名、期次、维度、标签……)
"settings": {...}, # 用到的阈值(发起时的快照 + 分析卡上写的覆盖)
"inputs": [ # 输入:都是本次运行里交回过的查询快照
{"ref": "Q3", "artifact": "<sha256>", "columns": [...], "rows": "all" | [i, j, ...]},
{"ref": "Q1.r0.gmv", "artifact": "<sha256>", "value": "1234.5", "role": "headline_target"}
],
"probes": [...], # 可比性检查现查的表范围(列、最小、最大、来源:现查 / 剖析 / 取值观察、时刻)
"status": "ok" | "refused",
"reason_code": null | "truncated" | "history_short" | ..., # 见 §8
"reason": null | "(中文,一种说法)",
"table": {"columns": [...], "labels": {...}, "rows": [[...]]}, # 结果表,形状同查询快照,[[v:]] / [[table:]] 能引
"values": {"total_delta": "...", "top_share": "...", ...}, # 具名标量,[[a:A3.top_share]] 能引
"verdict": {"code": "driver", "subjects": [["华东"]], "direction": "down"} | null,
"criterion": "(判定标准的中文,一句)",
"phrase": "(判定语,由系统按模板渲染,§7)",
"caveats": [{"code": "offset", "text": "..."}],
"hypothesis": "(agent 写的这次在验证什么,只在证据面板显示,§9.3)"
}
```
规则:
- **纯函数**:结论只由 inputs(快照里的格)、probes、params、settings、method 决定;证据面板、导出、可验证报告用同一个函数复算,复算结果和封存的逐字段相同才算核对通过。方法升版后复算按当前版本算,不同就照实写「运行时用的是 v1,现在是 v2,复算结果不同」。
- **数用十进制**:decompose 全程 `Decimal`(同 engine/compare.py 的做法),各段之和逐位等于总变化;统计量(z、p、斜率)用浮点,渲染按 cell_format 的规则。
- **只读快照、不发查询**:算子本身不查库。唯一的例外是可比性检查要现查表的日期范围(§6),走 data/coverage 现成的探查(只读守卫、遮罩列不探、总时限、5 分钟缓存、失败退回剖析事实),探查结果作为 probes 记进结论,复算用记下的值。
- **遮罩**:遮罩列(数据源 mask_columns,以及按 SQL 认出改了名字的遮罩列,同 compare.py `_hidden`)不能当维度、不能当度量;拿它们调用是调用错误(§8.1),结论里绝不出现遮罩值。
- **截断的快照不算**:任一输入快照 `truncated` → 算不了(reason_code `truncated`),原因写「Q3 只取回了前 1000 行,各段之和不是完整的和」。
- **行要唯一**:同一维度键、同一期次只能有一行(还有别的列在拆行就是 SQL 没按这些维度汇总)。重复 → 调用错误,提示「在 SQL 里按 地区、周 汇总」。不在算子里替人求和:存量跨天相加这类错误只有 SQL 那边知道。
- **空值**:度量格为空按「这一段这一期没有值」处理:decompose 记为 0 并在 caveats 写明哪几段为空;统计类算子跳过这一期,跳过后历史不够的照算不了处理。
## 2. 变化拆解(decompose)
回答「变化从哪来」。三种模式,agent 按指标的性质选,算子核对选得对不对。
### 2.1 可加(additive)——按维度算贡献
**输入**
- 一份快照,含维度列 `by`(1–2 列)、期次列 `period`、度量列 `measure`,`base`、`target` 是期次列里的两个值;或者两份快照(`base_query`、`target_query`),各自只有维度列和度量列,此时先做可比性检查(§6),不可比的照样算、判定后附上可比性结论。
- 可选 `headline`:基期、本期总数各一格(如 `Q1.r1.gmv`、`Q1.r0.gmv`),用来核对**拆解闭合**。
- 可选 `parent`:上一层拆解的结论和那一段(如 `{"of": "A3", "key": ["华东"]}`),用来下钻:这一层各段之和要等于 A3 里华东那一段的基期、本期值。
- 目录里度量列标成 ratio(比率)的 → 调用错误:「比率不能按段相加,改用 ratio 模式,给分子、分母两列」。标成 identifier / status / attribute 的 → 调用错误。没标的照算,caveats 写「目录没标这一列的度量类型」。
**计算**
- 每段 `base_i`、`target_i`(缺的一边记 0,标「新出现 / 消失」),`delta_i = target_i − base_i`,`pct_i = delta_i / base_i`(base 为 0 不给)。
- 总数 `B = Σ base_i`、`T = Σ target_i`、`Δ = T − B`。
- 闭合:给了 headline / parent 时,`B`、`T` 要分别等于它们(按十进制精确相等;headline 是取整过的,按它的书写精度容差,同 engine/tolerance)。不闭合 → 照算各段变化,但**不给占比、不下集中类判定**(verdict 为 `unclosed`),caveats 写差多少、可能的原因(有段没取全、两边筛选不同、关联重复计数)。没给 headline 时以各段之和为总数,criterion 写明「以各段之和为总数」。
- 占比 `share_i = delta_i / Δ`(Δ 为 0 不给)。抵消:和 Δ 方向相反的段的变化绝对值之和 `G_opp`。
**判定**(阈值见 §10)
| verdict | 条件 | subjects |
|---|---|---|
| `flat` | `|Δ| / |B| < flat_pct` | 无;有抵消时附抵消 |
| `driver` | 最大的 `share_i ≥ main_share` | 那一段 |
| `concentrated` | 不满足 driver;最小的 k 使前 k 段(按 share 降序)之和 `≥ concentration`,且 `2 ≤ k ≤ max(2, ⌈0.2·段数⌉)` | 前 k 段 |
| `dispersed` | 其余 | 无 |
| `unclosed` | 闭合核对没过 | 无 |
- `G_opp ≥ offset_ratio · |Δ|` 时加 caveat `offset`:「下降的段合计 −120 万,上升的段合计 +40 万,互相抵消了一部分」。有抵消时 share 可以超过 100%(华东 −120、总共 −80,华东占 150%),判定照常,判定语会说清。
- 段数少于 2 → 算不了(`too_few_segments`)。
- 结果表:维度列 + `base`、`target`、`delta`、`pct`、`share`、`flag`(新出现 / 消失 / 为空),按 `|delta|` 降序;全部段都在工件里,写作目录只给前 `table_top_k` 段和「其余 n 段合计」。
- values:`base_total`、`target_total`、`delta_total`、`pct_total`、`top_key`、`top_share`、`top_k`、`top_k_share`、`gross_up`、`gross_down`、`segments`。
### 2.2 比率(ratio)——拆成结构变化和各段自身变化
**输入**:维度列、期次列、分子列 `numerator`、分母列 `denominator`(如退款单数 / 订单数、销售额 / 订单数);可选 headline(整体比率的两格)。
**计算**(中点对称拆法,精确可加)
- 各段权重 `w_i = D_i / ΣD`,比率 `r_i = N_i / D_i`;整体 `R = ΣN / ΣD`,`ΔR = R' − R`。
- 结构效应 `mix_i = (w_i' − w_i) · (r̄_i − R̄)`,自身效应 `rate_i = (r_i' − r_i) · w̄_i`,其中 `r̄_i = (r_i + r_i')/2`、`w̄_i = (w_i + w_i')/2`、`R̄ = (R + R')/2`。
- 恒等式 `Σmix_i + Σrate_i = ΔR`(因为 `Σ(w_i' − w_i) = 0`);单测钉住逐位相等。减去 `R̄` 是为了让单段的结构效应读得懂:一段占比变大,只有它的比率低于平均时才拉低整体。
- 一边分母为 0 的段:那一边权重 0、比率取另一边的(自身效应为 0,变化全记在结构上),表里标出;两边都为 0 的段剔除并写明。
- headline 闭合:整体比率要等于 ΣN/ΣD(按 headline 的书写精度容差)。
**判定**
| verdict | 条件 |
|---|---|
| `flat` | `|ΔR| / |R| < flat_pct` |
| `mix_driven` | `Σmix / ΔR ≥ main_share` 且 `Σrate / ΔR < main_share` |
| `rate_driven` | `Σrate / ΔR ≥ main_share` 且 `Σmix / ΔR < main_share` |
| `both` | 其余 |
- **辛普森**:分母两期都不小于 `min_denominator` 的段里,按 `w̄` 加权有 `≥ 0.8` 的段比率变化方向和 ΔR 相反 → caveat `simpson`,判定语写「各段的退款率大多在下降,整体却上升:变化来自结构,退款率高的段占比从 x 升到 y」。
- subjects:`rate_driven` 取自身效应最大(和 ΔR 同向)的段,`mix_driven` 取结构效应最大的段。
- 分母小于 `min_denominator` 的段在表里标「分母小」,不参与辛普森的多数判断。
- 结果表:维度列 + `d_base`、`d_target`、`w_base`、`w_target`、`r_base`、`r_target`、`mix`、`rate`、`total`、`flag`。values:`r_base_total`、`r_target_total`、`delta`、`mix_total`、`rate_total`、`mix_share`、`rate_share`。
### 2.3 乘积(factor)——拆成因子
**输入**:期次列(或两行),乘积列 `product`(销售额),因子列 `factors`(订单数、客单价;2–4 个)。
- 闭合:每期 `product ≈ Π factors`,相对误差不超过 `max(1e-6, Σ 因子按书写精度的半个末位 / 因子值)`;不闭合 → 调用错误「这几列相乘不等于销售额(差 0.8%),不是乘积关系」。
- 任一值 ≤ 0 → 算不了(`non_positive`):对数拆解要求全为正。
- 方法 LMDI-I:`contrib_k = L(M', M) · ln(F_k' / F_k)`,`L(a, b) = (a − b) / (ln a − ln b)`,`L(a, a) = a`;`Σcontrib_k = M' − M`。
- 判定:`flat` / `driver`(某因子 `contrib_k / ΔM ≥ main_share`,subject 是因子名)/ `both`。
- 结果表:`factor`、`base`、`target`、`pct`、`contrib`、`share`。不按维度拆:要「哪个地区的客单价」就先 factor 再对客单价做 ratio。
### 2.4 下钻
`parent` 把几层拆解连成一条链:A5(华东按省份)是 A3(按地区)里华东那一段的拆解。闭合按 parent 那一段的值核对,链深度不超过 `max_drill_depth`(超了是调用错误)。证据面板按链显示;分析范围(细案 00 §6)按链列「拆过」。
## 3. 波动基线(baseline)
回答「这个变化大不大、算不算异常」:把本期放回它自己的历史里看。
**输入**:一份快照,期次列 `period` + 度量列 `measure`(一条序列;要看某一段就在 SQL 里筛好);可选 `target`(缺省最后一期)、`basis`(`change` 缺省:和上一期比的变化率;`level`:值本身,给存量、比率用)、`grain`(day / week / month / quarter,缺省按期次值推断)、`lag`(缺省 day→7,即和上周同一天比,避开星期效应;week→1;month→1;要同比就写 52 / 12,历史要相应够长)、`label`(度量的叫法,§7.3 消毒)。
**前提**(不满足就算不了,原因照实写)
- 期次认得出、能排序(日期、`YYYY-MM`、`YYYY-Www`、整数序号):认不出 → `period_unreadable`。
- 本期完整:用 §6 的完整性检查;本期没过完或数据没到 → `partial_target`(「本周数据只到 10 月 16 日,还差 3 天,和完整的周比没有意义」)。
- 期次连续:相邻两期中间缺期的,那一对不算变化;本期和它的比较期之间缺期 → `target_gap`。
- 历史够长:有效的历史变化(或值)不少于 `min_history` → 否则 `history_short`(「只有 5 期历史,至少要 8 期」);最多用 `max_history` 期。
- 比较期为 0 不能算变化率 → `zero_base`。
**计算**:历史的中位数 `m`、中位绝对偏差 `MAD`,`σ̂ = 1.4826·MAD`;稳健 z = `(x − m)/σ̂`;常规波动范围 `[m − k_warn·σ̂, m + k_warn·σ̂]`;本期在「历史 + 本期」里的名次;历史最小、最大值。`σ̂ = 0`(历史每期都一样)时:本期也一样判 `within_band`,不一样判 `far_outside_band`,判定语改说「过去 N 期每期都是 x」。
**判定**
| verdict | 条件 |
|---|---|
| `within_band` | `|z| < k_warn` |
| `outside_band` | `k_warn ≤ |z| < k_alert` |
| `far_outside_band` | `|z| ≥ k_alert` |
direction 取 z 的正负;本期是「历史 + 本期」里的最大 / 最小值时加 caveat `record_high` / `record_low`(说法表里「历史新高 / 新低」要它)。values:`x`、`median`、`band_lo`、`band_hi`、`z`、`rank`、`n_history`、`hist_min`、`hist_max`。
为什么用中位数和 MAD、不用均值和标准差:业务序列常有促销周这类极端值,均值和标准差会被它拉宽,正常的周都显得「不异常」,异常的周也显得不异常。
## 4. 趋势(trend)
回答「是持续在变,还是一次波动」。
**输入**:同 baseline 的序列(期次列 + 度量列),取最后 `n` 期(`trend_min_points ≤ n ≤ max_history + 1`)。前提:期次认得出、连续(中间缺期 → `series_gap`),最后一期完整(同 §3)。
**计算**
- Theil–Sen 斜率:所有两两斜率的中位数;相对斜率 = 斜率 / 序列中位数(中位数为 0 只给绝对值)。
- Mann–Kendall:`S = Σ_{i<j} sgn(y_j − y_i)`;无并列且 n ≤ 10 用精确分布(Kendall 统计量的分布用递推算出,不查表),否则正态近似(并列修正方差、连续性校正),双侧 p。
- 末尾连续同向:从最后一期往前数,相邻变化同号的期数(变化为 0 断开)。
**判定**:`p < trend_alpha` 且斜率 > 0 → `rising`;< 0 → `falling`;否则 `no_trend`。values:`slope`、`rel_slope`、`p`、`n`、`run_length`、`run_direction`。日粒度序列加 caveat `weekday_effect`(「日数据有星期效应,看趋势建议按周汇总」)。
已知局限照实写进方法说明:Mann–Kendall 假设各期独立,业务序列常有自相关,p 会偏小;所以缺省 n 至少 6、α 取 0.05,判定语只说「呈上升趋势」,不说「显著上升」。
## 5. 差异检验(differ)
回答「这两组(或两期)真的不一样,还是样本波动」。
**输入**
- `rate` 模式:成功数列 + 试验数列(转化单数 / 访客数),按维度列 `by` 分组;`reference` 指定对照组(缺省整体),或 `pairs` 指定要比的几对;也可以按期次比同一组的两期。
- `mean` 模式:每组的均值、标准差、样本数三列,或者合计、平方和、样本数三列(SQLite 没有标准差函数,`SUM(x)`、`SUM(x*x)`、`COUNT(*)` 都能写)。
- `rows` 模式:逐行的值列 + 分组列(快照不超过 1000 行且没截断),算子自己算均值、方差,另做敏感性。
**计算**
- 比率:每组 Wilson 区间(和评测用同一个函数,从 eval/stats 移到 analysis/stats,评测改为引用);两组之差用 Newcombe 区间;p 值:最小期望频数 ≥ `min_expected` 用合并比例的 z 检验,否则用 Fisher 精确检验(超几何概率用 `math.lgamma` 累加)。
- 均值:Welch t 检验,自由度按 Welch–Satterthwaite;t 分布的分布函数用正则化不完全 β 函数(连分式),分位数用二分求逆。
- 一次调用里比了多对:按 Holm 校正,表里同时给原始 p 和校正后的 p,caveat `multiple_comparisons`(「比了 4 对,已按次数校正」)。整个运行里做了几次检验由分析范围统计(细案 00 §6),不回头改已封存的结论。
- `rows` 模式的敏感性:去掉离均值最远的那一个值后重算,判定变了 → caveat `outlier_sensitive`(「去掉最大的一笔后,差异不再站得住」)。
**判定**:校正后 `p < differ_alpha` → `differs`(direction 按差的正负);否则 `no_evidence`。任一组样本数小于 `min_n`(均值)或用了 Fisher(比率)→ caveat `small_sample`。
判定语不出现「p 值」「显著性」这类术语:`differs` 说「差异站得住」,`no_evidence` 说「差异可能只是样本波动,样本量:A 230、B 40」;p 值、区间在表和证据面板里。
## 6. 可比性检查(comparable)
回答「这两期能不能直接比」。三处用到:agent 单独调用;decompose 两份快照、baseline / trend 的本期完整性;口径卡里跨两份查询算的环比、同比(细案 00 §4.3)。
**检查项**(每项 pass / warn / fail / unknown,unknown 只写「核对不了」,不判不可比)
| 项 | 怎么判 | fail / warn |
|---|---|---|
| 同口径 | 两边 SQL 用口径账本的 `explain_sql_diff` 比:完全相同 / 只差字面量 / 结构不同 | 结构不同 fail(写出差在哪);只差字面量时,非日期的字面量也不同(`'华东'` 对 `'华南'`)→ 比的是两段不是两期,fail「两边除了日期,筛选的还不一样」;同一份快照里按期次分组的天然同口径 |
| 窗口长度 | 用 data/coverage 的 `filters` 取业务日期列(目录 business_date,没有就取时间谓词列)上的区间,现算的界照 coverage 在库里算;算两边天数 | 不相等:度量是 flow(可加流量)fail,ratio / stock / 没标 warn |
| 整期 | 窗口终点和表里这一列的实际最大值、运行当天比(实际范围用 coverage 的现查 + 剖析兜底,记进 probes) | 窗口没过完或数据没到:flow fail(「本期只有 3 天数据」),其余 warn |
| 星期构成 | 日粒度、天数不是 7 的倍数时,数两边的周六、周日天数 | 不同 warn(「两段的周末天数不同:4 天对 6 天」) |
| 数据问题 | 两边查询结果上的 SQL 检查错误、「查询区间里没有数据」 | 没有数据 fail,SQL 检查错误 warn |
**判定**:全部 pass / unknown → `comparable`;有 warn 没有 fail → `conditional`;有 fail → `incomparable`。判定语列出 fail、warn 的原因,一句一个。
## 7. 判定语
### 7.1 由系统渲染
`[[a:A3]]` 渲染成 A3 的判定语,`[[a:A3.top_share|pct]]` 渲染具名标量(换算后缀同 `[[m:]]`)。判定语里的数照 cell_format 渲染、点得开 A3 的出处链。写作者不写判定,只引用判定——这是「叙述层无算术」往前走的一步:分析判定和数字一样由系统写。
### 7.2 模板(一种说法,analysis/phrases.py 一处定义,前后端都从结论里读渲染好的字符串)
| op / verdict | 判定语 |
|---|---|
| decompose additive `driver` | `{label}{变化},主要来自{subject}({subject 的变化},占{share})` |
| `concentrated` | `{label}{变化},集中在{subjects}(合计占{top_k_share})` |
| `dispersed` | `{label}{变化},分散在各{维度名},没有哪一段占到一半` |
| `flat` | `{label}基本持平({pct})`;有抵消时接「,内部此消彼长:{最大的上升段} {变化}、{最大的下降段} {变化}」 |
| `unclosed` | `按{维度名}拆开后各段之和和总数对不上(差 {gap}),不判断变化来自哪里` |
| ratio `mix_driven` | `{label}{变化},主要来自结构变化:{subject}占比从 {w} 到 {w'}` |
| `rate_driven` | `{label}{变化},主要来自各段自身的变化,{subject}影响最大` |
| `both` | `{label}{变化},结构变化和各段自身变化都有影响(各占 {mix_share}、{rate_share})` |
| factor `driver` | `{label}{变化},主要来自{factor}的变化(贡献 {contrib},占{share})` |
| baseline `within_band` | `{label}{本期变化},在过去 {n} {期}的常规波动范围内({lo} 至 {hi})` |
| `outside_band` | `{label}{本期变化},超出过去 {n} {期}的常规波动范围({lo} 至 {hi})` |
| `far_outside_band` | `{label}{本期变化},明显超出过去 {n} {期}的常规波动范围({lo} 至 {hi})` |
| trend `rising` / `falling` | `过去 {n} {期}{label}呈上升 / 下降趋势(每{期}约 {rel_slope})`;`run_length ≥ 3` 时接「,已连续 {run} {期}上升 / 下降」 |
| `no_trend` | `过去 {n} {期}{label}没有明显的上升或下降趋势` |
| differ `differs` | `{A}的{label}({a}){高于 / 低于}{B}({b}),差异站得住` |
| `no_evidence` | `{A}和{B}的{label}差异可能只是样本波动(样本量 {nA}、{nB})` |
| comparable | `两期可比` / `两期有条件可比:{原因}` / `两期不可比:{原因}` |
| 算不了 | `{op 的叫法}没有做成:{reason}` |
caveats 渲染在判定语后面,用「;」接,最多两条,其余在证据面板。
### 7.3 系统渲染的字里不能夹带模型写的数
判定语里的 `label`(度量叫法)、维度值、因子名是 agent 或数据给的。数据里的维度值照原样(遮罩列不当维度,已排除)。agent 给的 `label` 消毒:去掉数字、百分号、单位符号、引用标记,长度不超过 20 字,消毒后为空就用列名。否则模型可以把「销售额(同比 +30%)」塞进 label,借系统渲染绕过裸数字检测。`hypothesis` 不进任何系统渲染的正文(只在证据面板,标「agent 写的假设」)。
## 8. 算不了与调用错误
### 8.1 调用错误(不记结论,像 SQL 写错一样把错误交回 agent 改)
列不存在、列被遮罩、度量列不是数、角色和目录度量类型冲突(比率当可加)、同一键同一期多行、`base` / `target` 不在期次列里、乘积不闭合、`parent` 指向的结论不存在或不是 decompose、下钻超过深度、本运行分析次数用完(`max_calls`)。错误文案一种说法,告诉 agent 怎么改。
### 8.2 算不了(记成结论,status `refused`)
reason_code:`truncated`、`too_few_segments`、`history_short`、`partial_target`、`target_gap`、`series_gap`、`period_unreadable`、`zero_base`、`non_positive`、`incomparable_sides`(decompose 两份快照的口径结构不同,不拆)。「算不了」本身就是结论:写作者可以照实写「历史只有 5 周,判断不了是否异常」,说法表里的「异常」类判断不能挂一个 refused 的结论。
## 9. 说法表与绑定规则
### 9.1 判断词的类
| 类 | 判断词(举例,analysis/claims.py 一处定义,带版本) | 要挂的分析 | 判定要对得上 |
|---|---|---|---|
| 来源 | 主要来自、主要由、主要是…(下降 / 增长)、大部分来自、绝大部分、集中在、主导、拖累、拉动、带动、贡献最大 | decompose | `driver` / `concentrated`;句子里点名的段要在 subjects 里;拖累要那一段变化为负、拉动 / 带动为正 |
| 分散 | 分散、普遍、全面(上升 / 下降)、各…都 | decompose | `dispersed` |
| 抵消 | 抵消、此消彼长、一增一减 | decompose | 有 `offset` caveat 或 `flat` 带抵消 |
| 结构 | 结构变化、结构性、占比变化带来 | decompose ratio | `mix_driven` / `both` |
| 异常 | 异常、反常、罕见、明显偏离、超出正常、大幅、骤降、暴跌、激增、飙升、剧烈 | baseline | `outside_band` / `far_outside_band`;方向对得上 |
| 新高新低 | 历史新高、历史新低、创新高、创新低、前所未有 | baseline | 对应的 `record_high` / `record_low` |
| 正常 | 正常波动、常规波动、正常范围、并不异常 | baseline | `within_band` |
| 趋势 | 趋势、持续(上升 / 下降)、连续…(周 / 月)、一路、逐步、节节、稳步 | trend | `rising` / `falling`,方向对得上;「连续」要 `run_length ≥ 3` 且方向一致 |
| 无趋势 | 没有明显趋势、基本平稳、趋势不明显 | trend | `no_trend` |
| 差异 | 显著高于 / 低于、明显高于 / 低于、确实更高 / 更低、差异明显 | differ | `differs`,方向对得上 |
| 无差异 | 差异不大、差别不大、没有明显差别、相当 | differ | `no_evidence` |
| 因果 | 因为、由于、导致、造成、引起、受…影响、归因于、原因是、源于、所致、得益于 | 知识库原话 `[[q:]]` 或运行输入 `[[i:]]` 在同一句;或用了推测说法(可能、或许、推测、待核实、不排除、疑似) | — |
- **否定**:判断词前 4 个字以内有「不、没有、没、并非、并不、未、无」的翻到对应的反面类(「并不异常」→ 正常,「没有明显趋势」→ 无趋势;「并非主要来自华东」→ 要 decompose 且华东不在 subjects 里)。
- **来源和因果的分界**:句子挂了 decompose,因果词前后点名的是这份结论的段或因子(「下降主要由华东造成」)→ 按来源核,这是记账意义上的「来自」;点名的不是(「由于促销结束导致下降」)→ 按因果核。拆解只能说明变化来自哪里,说明不了为什么。
- **不扫的地方**:知识库原话(`[[q:]]` 的原文整段涂白,同裸数字)、系统渲染的判定语与分析范围块、标题行。
### 9.2 怎么挂
一句话的分析依据 = 这句里出现的 `[[a:An…]]`、`[[v:An.…]]`、`[[table:An …]]`,以及句末 `[[see:An]]`(和 `[[see:Q1]]` 同一种写法)。来源、异常这些类要的 op 必须在这句的依据里出现;有好几个时任一个对得上就算对。
### 9.3 违规与处置
| 代码 | 什么时候 | 说法(给写作者的,给人看的另有一句) |
|---|---|---|
| `analysis_claim_uncited` | 句子有判断词,依据里没有对应种类的分析结论 | 「『主要来自』要挂按维度拆解的结论([[see:A3]]);没有拆解就只写各段的变化,不下『主要』的判断」 |
| `analysis_claim_mismatch` | 挂了,但判定、点名的段、方向对不上(含挂了算不了的结论) | 「A3 判的是变化分散在各地区,没有哪一段占到一半,不能写『主要来自华东』」 |
| `causal_unsupported` | 因果词没有知识库原话或输入作依据,也没用推测说法 | 「『由于促销结束』没有依据:数据只能说明变化来自哪里;要写原因,引用知识库原话,或写成『可能与…有关,待核实』」 |
报告节点 `analysis_claims`:
- `check`(缺省,含问数据):违规交回写作者重写**一次**(同 inline_see、absence_claim 那一类,不按 max_repairs 反复),仍有的标在报告里(证据面板、审计、导出都看得到),不让节点失败、不影响出具档位。
- `strict`:按 `max_repairs` 重写;仍有的记成出具缺口,带出具契约的降档;受管级别要求 `strict`(门禁 G3 加一条)。
- `off`:不核。
## 10. 设置缺省值(设置 → 偏好设置 → 分析;发起时快照进 run.options["analysis"])
| 键 | 缺省 | 用在 |
|---|---|---|
| `main_share` | 0.5 | 「主要」:一段(一个因子、一种效应)占总变化的比重 |
| `concentration` | 0.8 | 「集中」:前 k 段合计占比 |
| `offset_ratio` | 0.25 | 反向段合计超过总变化的这个比例记抵消 |
| `flat_pct` | 0.005 | 总变化小于基期的 0.5% 算基本持平 |
| `k_warn` / `k_alert` | 2 / 3 | 波动基线的稳健 z |
| `min_history` / `max_history` | 8 / 52 | 基线、趋势用的历史期数 |
| `trend_min_points` / `trend_alpha` | 6 / 0.05 | 趋势 |
| `differ_alpha` / `min_n` / `min_expected` | 0.05 / 30 / 5 | 差异检验 |
| `min_denominator` | 30 | 比率拆解里「分母小」 |
| `max_calls` / `max_drill_depth` | 12 / 3 | 一次 agent 运行最多分析几次、下钻几层 |
| `table_top_k` | 10 | 写作目录、判定语里列几段 |
- **agent 改不了阈值**:分析工具的参数里没有阈值,阈值只来自设置快照;分析卡(模板)可以在节点上写覆盖,写进图、随版本固定。这样模型不能把阈值调到想要的结论。
- 每份结论记下实际用的阈值,判定标准(criterion)照这些值写出来,例如「一段占总变化一半以上算主要来源」。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
# 计算口径:作者侧与审阅侧的优化决定
> 2026-11-04 复盘 11-03 至 11-04 的口径相关改动(指标定义卡与四种指标类型、口径目录与试跑、业务文档起草与多轮续跑、descriptor/usage)后的决定。本文只写结论和落地顺序;接口和数据模型的细节以代码与测试为准。
## 缺口
1. **能看,不能改。** 指标定义卡与四种指标类型进了 schema、校验、descriptor、目录和证据标签,但画布的指标编辑器只有 id / 名称 / 单位 / 表达式四栏;草稿页只读;规则编译只有接口。定义卡有形状、没有作者。
2. **审阅对象不对。** 草稿页不显示 SQL,试跑只回标量值;没有结果表、列映射、SQL 差异。业务的人判断不了数对不对,技术的人只能跳画布;目录页试跑成功后连运行记录都留不下。
3. **两处真相。** 草稿 payload 与 workflow graph 各说各话:改 payload 不重建图,画布改图后草稿页不更新。
4. **编排过重。** 10 轮串行、60 条上限、15 分钟轮询,靠截断抢救、问题去重、入口合并、日期预填、提示词补丁兜底;没有取消、重试、单组重试。
5. **概念与状态过载。** 文档起草、接一次运行、可沉淀的问题三套创建路径,7 种草稿状态;`questions_open` 不阻断试跑,`path_pending` 是遗留。
6. **资产面缺管理。** 目录只列每个工作流最新已发布版本,没有版本对比、使用情况、退役、试跑历史。
7. **定位没说清。** 设计文档写「不做从指标定义编译 SQL 的语义层」,起草主路径却由模型写 SQL;docstring 里的重新解释没有写进设计文档和界面文案。
## 决定
- **D1 口径图是唯一真相。** 草稿在第一次建图后,指标、取数、入口都以 workflow 当前版本里的图为准。画布与草稿编辑共用保存逻辑:每次实际修改新增版本,旧快照(含已发布版)不可覆盖。PATCH 在当前图上改口径卡,不从 payload 重建取数节点,保留节点 ID、SQL、入口和其他编排。试运行明确固定当前版本,强制只读,记录图、执行内容和输入的哈希;执行内容修改后旧结果显示待重新验证,只改布局则保留原版本证据。晚结束的旧试运行不能覆盖新结果或并发编辑。payload 里的指标快照只留给起草中途和旧草稿兼容。
- **D2 定义卡分两层。** 核心:名称、同义词、单位、小数位、定义说明、负责人、生效日期、引用原文;高级:适用范围、不包含、分子、分母、维度、过滤。四种指标类型都必须在界面上可编辑;规则与评估的结构先用 JSON 编辑,后续再换结构化表单。指标参数(`params`)目前没有运行时语义,先不承诺、不推广,后续要么实现「按参数生成口径实例」,要么从 schema 和目录里删掉。
- **D3 审阅面必须有证据。** 草稿页与目录页的试跑都要给:取数模板 SQL、执行后的结果表(前若干行、总行数、遮罩格如实显示)、列与指标的对应。目录页的试跑要留下运行链接,刷新不丢。发布前再给一份只读检查清单(图能过发布校验、SQL 单条只读且表在目录里、入口字段与占位符一致、试运行成功、每条指标有引用原文),不拦人,只把没通过的项说清。
- **D4 状态收敛。** 界面只有四种阶段:起草中 / 待审 / 已丢弃 / 失败;`ready`、`questions_open`、`path_pending`、`attached` 都归「待审」,细状态保留在接口和标签的悬停提示里,接口兼容不迁移存量数据。三套创建路径最终收敛为一个「新增口径」向导(从文档 / 从运行 / 手动),输出同一种草稿。
- **D5 起草分组。** 一次模型调用尽量交多组「能共用一条 SQL 的指标」;服务端逐组校验,一组不合格只丢这一组,并只对这一组重写一次(最多三组并行),修好就并入。一轮写不下时按 `uncovered` 再续,最多 4 轮;不再一组一轮、10 轮串行。起草是离线辅助,回答问题时仍不做「定义 → SQL」的编译;发布的是人审过、跑过的 SQL。
- **D6 目录按口径管理。** 目录按口径(工作流)分组,展开是版本列表:版本、发布时间、级别、变更摘要、指标数、入口、依赖、与上一版的差异、使用情况、退役动作、试跑历史。「新增口径」是主按钮,草稿箱独立。
- **D7 口径身份不动。** 不新建「口径」实体表;身份仍是「工作流@版本 / 口径卡」。账本、证据、召回、descriptor 都建在这个身份上,换身份牵一发动全身,收益不抵成本。
## 落地顺序
- **P0(本次)**:补全草稿指标的 grain / time_field / numerator / denominator / dimensions / filters;payload 记 `groups`;多组指标 id 建图前全局重排,payload、图、试跑值指同一件事;试跑响应带查询结果集;草稿页展示 SQL 与结果表;目录试跑留运行链接;画布指标编辑器补定义卡与四种类型。
- **P1(下一轮)**:草稿编辑写图、图回读草稿、界面四阶段、发布前检查、「新增口径」主入口都已落地。三来源向导(从文档 / 从运行 / 从可沉淀的问题)等起草流程稳定后再收成一个入口。
- **P2(已落地)**:起草改成一次交多组、逐组校验、坏组并行重写一次、最多 4 轮;前端按组显示「这条 SQL 出哪几个指标」。「指标清单」单独成阶段留作后续优化,等实际反馈再决定。
- **P3**:目录分组、版本对比、使用情况、退役、试跑历史(D6)。
## 验收
- 后端:相关 pytest 全过;试跑结果集的遮罩与截断有测试。
- 前端:`pnpm build`、`node scripts/check-copy.mjs` 全过;四种指标类型切换不丢字段;草稿页与目录页在旧数据(没有 `queries`)下不报错。
- 人工:拿一份真实业务文档走一遍「起草 → 看 SQL / 结果 / 引用 → 改定义 → 试跑 → 发布」,确认每一步都能拿到判断所需的信息。
## 2026-11-06 架构修复
- **验证分开执行和质量。** 试运行的 `validation` 给出 `pass / fail / unknown`;运行成功不等于口径验证通过。缺数、截断、SQL 错误、复算不一致、显式约束失败和可比性未知,都会进入质量结论与出具缺口。旧证据没有质量版本记录时要求重新验证。试运行指标值和查询表使用相同遮罩,接口不再附带未经处理的原始运行输出。
- **显式约束可以执行。** 指标 `contract` 支持结果形状、粒度唯一性、非空列、单位、数值范围,以及来自同一行的分子/分母、子集关系和倍率。自动起草的标量指标默认要求一行结果,不能静默取多行中的第一行。自然语言粒度、过滤等描述尚未被证明与 SQL 等价时仍显示待审;这不是通用的自然语言到 SQL 编译器。
- **分离指纹用途。** `definition_hash` 覆盖指标声明;`semantic_hash` 加入上游 SQL、数据源和绑定;`execution_hash` 忽略画布布局。旧 `graph_hash` 不改算法,历史封存仍可核验。可比性规则升级到分析方法版本 `@2`,复核历史结果会明确显示方法变化。
- **调查有完成条件和预算。** 追问原因或发现基线异常会触发继续调查,核对闭合、可比性、下钻关联与整体贡献覆盖;子层占比沿父层累计。默认最多追加两轮、最低下钻一层、覆盖程度为 0.8,仍受总步数、查询和费用预算约束。无法补齐时封存停止原因并限制正式出具,完成贡献分析也不等于证明因果。可用 `investigation` 评测期望检查状态、深度、查询预算和停止原因。
- **起草生命周期持久化。** `CaliberDraftJob` 记录属主、状态和尝试次数,支持停止和重试。启动时将遗留任务标为中断;只有用户重试才继续模型调用。同一材料可恢复已校验分组,材料变化则重新规划;并行分组修复使用独立会话。共用任务管理器拒绝重复启动同一任务。
- **应用服务与影响追踪。** 发布检查、封存证据读取和起草编排提取为公共服务。版本使用情况展示传递依赖的工作流与应用,退役时提供影响清单;无法解析的历史图明确列出,不能按无依赖处理。
- **一并修复的通用问题。** SQLite 证据追溯的编译兜底增加授权回调的 SELECT 计数,避免某些版本把复合查询编译成临时表、没有 Yield 时被误认为单表直接投影。microVM 在缺少可选 SDK 时返回明确的执行失败结果,不直接抛导入异常。评分输入拒绝 NaN/Infinity,起草任务操作错误在详情中可见,旧请求不覆盖后来打开的草稿。
接口和回归覆盖上述行为;真实业务口径的审阅、模型是否能选择有效业务维度、以及生产数据上的调查效果仍需用业务样例验收。
+200
View File
@@ -0,0 +1,200 @@
# 数据剖析重新设计:逐表剖析的后台任务
> 状态:方案已定(2026-10-14,§6 四项都按推荐),未动代码。基于 main `fc002bd`。文中表名、列名都是虚构的举例。
## 1. 现状(已对照代码核实)
| 问题 | 代码里的样子 | 用户看到的 |
|---|---|---|
| 一次同步长请求 | `POST /catalog/profile` 做完整个剖析才返回(`api/catalog.py`);进程内集合 `_RUNNING` 防重入 | 进行中只有「已用时长 / 总时长上限」一条横条,代码注释原话「不是进度」;**不能停**;关掉页面报告就没了 |
| 预算按「一次剖析」算 | `max_queries=60`、`max_total_s=120`,注释原话「接口是同步的,不能让一个请求无限地挂着」 | 一张有 4 条关系、6 个码值列的表要 25–30 条查询(行数 1–2;每条关系抽样 1 + 父表核对按 1000 个一批 2 + 唯一性 1–2;码值每列 1;日期 1)。选 50 张表,前两三张做完,其余整张记「查询次数用完」——**「剖析只做了几张」的根源** |
| 默认范围窄 | 不选表时只挑 10 张「目录里有待核实关系」的表;为跳过空表,先拿一半查询次数去估候选表的行数(`_skip_empty`) | 没有关系候选的表(码值、业务日期一样有用)默认永远轮不到 |
| 结果不留痕 | 行数估算、日期范围、跳过了什么只在那次响应里;日志只记查询次数 | 表清单、表详情看不出这张表剖析过没有、什么时候、结论是什么;对业务库发过哪些查询无从查 |
| 和起草没协调 | 起草任务核对有效记录条件也打业务库;剖析与起草可同时跑 | 业务库压力叠加;先剖析后起草时,起草新提的关系没被核对 |
起草在上一轮已经从同步请求改成后台任务(`data/catalog_jobs.py`:任务表 + 逐表表、进程内执行、停止、重启标中断、重试),剖析是同一类问题,应该走同一套做法。
## 2. 设计理念对照
| 理念 | 这次怎么落 |
|---|---|
| 照实说 | 每张表、每个检查项照实写状态和原因;「没查成 ≠ 结论错了」,旧结论原样保留(沿用 `_Skip.carry`);停下时写清已写进目录的和没做的 |
| 留痕 | 任务、逐表结果、发过的查询(字面量去掉)落库;表清单和表详情能看到最近一次剖析 |
| 只读遮罩 | 同一个只读守卫、同一条 `run_query`;遮罩列不进任何查询;留痕的 SQL 去掉字面量,AgentLab 自己的库里也不留抽样到的键值 |
| 一种说法 | 剖析和起草共用一套任务生命周期、状态词、接口形状、界面组件;预算的叫法全站一致 |
| 不挡生成 | 剖析从不挡运行和表助手;只和同源的起草任务排队 |
## 3. 目标体验(产品)
### 3.1 四个场景
1. **新接入一个源**:探查结构 → 起草(后台)→ 点「数据剖析」,范围选「需要剖析的表」→ 剖析排在起草后面自动开始 → 关掉页面去做别的,回来看逐表结果。
2. **日常维护**:表清单多一列「剖析」,一眼看出哪些表没剖析过、上次没做完、剖析之后又冒出了待核实的关系;按它筛出来一键剖析。
3. **单张表**:表详情里「数据剖析」一节,看上次的行数、日期范围、核实了什么、跳过了什么;点「剖析这张表」。
4. **出问题**:连接断了、库响应慢,任务自己停下并写明原因;「重试没做完的表」只跑剩下的。
### 3.2 剖析对话框(照起草对话框的结构重做)
**开始前**
- 范围:已选的表 N 张 / 当前筛选 N 张 / **需要剖析的表 N 张(默认)** / 全部表 N 张(不含视图)。
- 预算按表写:「每张表最多 40 条查询、60 秒;单条查询 10 秒;每条关系抽 2000 个键值」,再给上限的估算「这次最多约 1,600 条查询」。
- 醒目一句:剖析会对业务库发只读查询,一条一条地发,可随时停止。
- 同源有起草任务在跑时:按钮写「排队剖析」,下面一句「起草任务结束后自动开始」。
**进行中**
- 头部:`12 / 40 张表 · 已发 318 条查询 · 用时 4 分 12 秒`,按表数的进度条(真进度),「停止」按钮。
- 逐表一行:
```
[进行中] orders 关系 3/4 · 码值 5/6 · 日期 1/1 23 条查询 · 18 秒 正在核对 customer_id → customers.id
[完成] customers 关系 1/1 · 码值 2/2 6 条查询 · 3 秒 核实 1 条关系,取到 2 列码值
[部分] events 关系 0/2 · 码值 1/3 40 条查询 · 60 秒 这张表的查询次数用完,剩 4 项没查
[跳过] tmp_log 1 条查询 空表
[排队] refunds
```
- 点一行展开:这张表的检查清单(每项的状态、原因、用了几条查询)、发现(沿用现有报告里的关系、码值、业务日期写法)、「看发过的查询」。
**结束后**
- 汇总:`40 张表:完成 33,部分 4,跳过 2,失败 1;核实 14 条关系、取到 51 列码值候选(含义待填写 37 列)`。
- 筛选:有发现 / 有跳过 / 失败;「重试没做完的表」;码值「填写含义」的入口照旧。
- 结束时页面右下角提示,可回到报告(对话框关着也行)。
**开始不了**(409):沿用现有五种情况的说法和下一步(未开启、停用、没有表结构、快照被改动、正忙),「正忙」改成说清楚是谁在跑、跑到哪。
### 3.3 其他位置
- **目录页头部**:起草、剖析各一个进度入口(`起草 12/40` `剖析 排队中`),点开对应对话框。
- **表清单**:新列「剖析」:未剖析 / 3 天前 / 部分完成 / 失败 / 剖析中 / 排队;筛选项「需要剖析」。
- **表详情**:「数据剖析」一节——上次剖析(时间、发起人、所属任务)、行数估算及来源(统计信息 / 数到上限)、日期范围、发现摘要、跳过的项、发过的查询;按钮「剖析这张表」(未开剖析时引到设置)。
- **剖析设置**:「查询次数上限」「总时长上限」换成「每张表最多查询数」「每张表时长上限」;其余三项不变。
## 4. 架构
### 4.1 抽出共用的任务运行时,起草和剖析各自建表
新模块 `data/catalog_job_runtime.py`,从 `catalog_jobs.py` 里抽出来、不改行为:
- `JobManager`:进程内一个 asyncio 任务一件事;`start / stop(grace) / wait / running / shutdown`。
- `SerialWriter`:同一个任务的库写入一条一条来、shield 起来(停止时不打断写到一半的库操作),收尾前 `settle`。
- `SourceLane`:**每个数据源一条队**(FIFO 的 asyncio 锁)。任务建出来是 `queued`,拿到这条队才 `running`。起草和剖析同源串行,不同源并行。
- `interrupt_on_startup(job_model, task_model, message)`:启动时把排队、进行中的标 `interrupted`,没结束的表记 `cancelled`。**不自动续跑**(复制出来的数据目录启动时不能自己去查业务库,比起草更要紧)。
- 状态词常量:任务 `queued / running / succeeded / partial / failed / cancelled / interrupted`,逐表 `pending / running / ok / partial / failed / skipped / cancelled`——起草已有的那套,剖析照用。
`catalog_jobs.py`(起草)改为用这些,**现有起草测试一条不改、全部要过**,这是抽取正确的判据。
剖析的任务在新模块 `data/catalog_profile_jobs.py`,新表(只加不改,迁移是加法):
**`catalog_profile_jobs`**:`id, source_id, status, scope(selected|needed|all), settings(JSON,建任务时的快照), created_by, started_at, finished_at, queries, timeouts, errors, stop_reason(failed|slow|NULL), notice, error, retry_of`。部分唯一索引:同源只有一个排队或进行中的剖析任务(同起草)。
**`catalog_profile_tasks`**:`id, job_id, seq, table_name, status, checks(JSON:[{kind, target, path, state, reason, queries}]), queries, elapsed_ms, current(正在做哪一项的一句话), row_estimate(JSON), findings(JSON), skipped(JSON), date_ranges(JSON), query_log(JSON), added, updated, removed, version, error, started_at, finished_at`。
否决「和起草合成一张通用任务表(kind + JSON)」:要迁移刚稳定的起草数据、丢掉起草进度那些强类型列,换来的只是少两张表;共用的是生命周期代码,不是表。
### 4.2 剖析引擎:从「一次剖析」改成「一张表」
`catalog_profile.py` 保留全部检查逻辑(关系、码值、日期、`_apply` 并入规则、乐观锁写入),只换编排:
- **`profile_table(cx, table, notes, hooks) -> TableResult`** 成为公开入口:先列全检查项(现在的 `_plan`),逐项执行,每项开始、结束经 hooks 回调(任务据此写 `checks / current / queries`),整张表结束写目录(现在的 `_write`)。
- **`_Runner` 拆两层**:
- `SourceRunner`(整个任务一个):只读守卫、单条时限(取设置和数据源时限中小的)、**任务级止损**、查询留痕。
- `TableBudget`(每张表一个):这张表的查询次数、时长。用完抛 `_Stop("budget"|"deadline")`,**只停这张表**——剩下的项记「这张表的查询次数用完 / 时长用完」并保留旧结论,表记 `partial`,下一张表拿满额预算。
- **任务级止损**(替代原来的总量上限,见 §6 问题 1):
- 连续 3 条查询失败(沿用现规则:多半是连接断了、没权限)→ 任务停,`stop_reason=failed`,写出第一行报错;
- 最近 10 条查询里 7 条超时 → 任务停,`stop_reason=slow`,「数据库响应慢,已停止,避免给业务库加压」;
- 剩下的表记 `cancelled` 并带上原因,可重试。
- **空表**:每张表第一条查询就是估行数,确知是 0 行 → 表记 `skipped`「空表」(只花 1 条)。原来挑表前的批量预估(`_skip_empty`、`PROFILE_DEFAULT_TABLES`、`PROFILE_PROBE_TABLES`)删掉。
- **表的状态**:每一项都有结论 → `ok`;有项因为运行原因没查成(次数、时长、超时、出错、守卫拒)→ `partial`;按规则不查的(遮罩、视图、取值太多不像码值、空表)不算没做完。写入一直冲突、整张表出错 → `failed`。
- **停止**:正在跑的那条查询由数据库按单条时限截止(`run_query` 的 `_server_deadline`),这张表做完的项照常写进目录,没做的保留旧结论;后面的表 `cancelled`。
- 表助手的只读查询、起草时的有效记录条件核对继续走单条查询的路径(一条查询、守卫、时限),行为不变。
### 4.3 范围怎么定(服务端定,界面只显示个数)
- **需要剖析的表**:在表结构里、不是视图、有可查的项(关系候选、像码值的列、日期列),并且满足其一:从没剖析过;上次剖析没做完(partial / failed / cancelled);上次剖析之后目录里出现了新的待核实关系。
- **全部表**:表结构里有可查项的表,不含视图。
- 顺序:有待核实关系的在前 → 使用次数 → 表结构顺序(沿用现规则)。
- `GET /catalog` 的每行加 `last_profile`(状态、任务、时间、发现与跳过计数)和 `profile_due`,对话框据此显示个数,表清单据此画「剖析」列。
### 4.4 同源排队与互斥(一条规则)
- 同一个源:起草任务和剖析任务**排队串行**;同一种任务同时只有一个(排队或进行中),再建回 409 并说明正在跑的是哪个、到哪了。
- 从表详情点「剖析这张表」时如果已有剖析任务在跑:409,告诉他这张表在不在那个任务里、排第几。
- 导入目录、同步起草:同源有任一种后台任务时都是 409(`ensure_idle` 把剖析任务也算上)。
- 运行、表助手的对话和只读查询:不受影响。
否决「剖析和起草并行」:业务库压力叠加;先剖析的表看不到起草新提的关系。否决「互斥直接报忙」:「起草完接着剖析」是最常见的顺序,排队让人点一次就能走开。
### 4.5 查询留痕
每张表记下发过的查询:`{at, kind, target, sql, ms, rows, outcome}`。`sql` 经 `sql_semantics.normalize_sql` 把字面量换成 `?`——IN 列表里抽样到的键值不进 AgentLab 的库;遮罩列本来就不进查询。条数受每张表的查询上限约束,大小有界。界面在逐表展开和表详情里「看发过的查询」。
价值:DBA 和数据负责人能回答「这次剖析对业务库查了什么」;出错、超时时能对着具体查询判断原因。
### 4.6 设置迁移
- 保留:`enabled`、`query_timeout_s`、`sample_size`、`max_scan_rows`。
- 新增:`table_max_queries`(默认 40,1–200)、`table_max_s`(默认 60,10–600)。
- 去掉:`max_queries`、`max_total_s`。启动时一次性迁移,把库里数据源 options 中这两个键删掉、记日志。不把旧值折算成新值:旧值按「整次剖析」算,含义不同,硬折算才是不照实。保存时如果还带着旧键(老页面),只摘掉、不报错。
### 4.7 接口
| 接口 | 说明 |
|---|---|
| `POST /catalog/profile-jobs` | `{tables?: string[], scope?: "needed"\|"all"}`,202;未开启、停用、没有表结构、快照被改动、正忙 409(沿用机读码) |
| `GET /catalog/profile-jobs/active` / `latest` / `{id}` | 任务和逐表状态 |
| `POST /catalog/profile-jobs/{id}/cancel` | 停止;排队的直接取消 |
| `POST /catalog/profile-jobs/{id}/retry` | 新任务,只含 failed / partial / cancelled 的表 |
| `GET /catalog` | 每行加 `last_profile`、`profile_due`;`active_job` 旁边加 `active_profile_job` |
| `GET /catalog/{table}` | 加 `last_profile`(完整的逐表结果,含发过的查询) |
| ~~`POST /catalog/profile`~~ | 去掉(见 §6 问题 3) |
### 4.8 保留多久
每个源保留最近 50 个结束了的剖析任务;更早的删掉,**但某张表最近一次剖析所在的任务不删**——表详情的「上次剖析」永远有。起草任务的保留不在这次范围内。
## 5. 否决的备选
| 备选 | 为什么不 |
|---|---|
| 同步接口改流式(SSE)推进度 | 关页面就断;不留痕;不能排队;停止要另做 |
| 前端循环逐表调同步接口 | 最省事,但关页面就停、两个标签页会抢、停止不干净——起草以前「每 12 张表一个请求」就是这条弯路 |
| 保留整次总量上限当主预算,只是调大 | 正是现在「只做了几张」的根源;调大又失去对单张表的约束 |
| 一张表内多条查询并发 | 更快,但违背「少量只读查询、一条一条发」的承诺 |
| 剖析结果写进目录的表级字段 | 行数、日期范围是观测,不是要人确认的事实;写进目录会抬版本、引发冲突、被导出导入带走 |
## 6. 已拍板(2026-10-14,都按推荐)
1. **整次任务要不要总量上限**。推荐**不设**:每张表有上限、任务级止损(连续失败、超时比例)、可随时停止,和起草「不设总时长上限」一致。备选:设一个默认的总查询数上限(如 2,000 条),到了就停、剩下的表记已停止。
2. **起草和剖析同源**:推荐**排队串行**(§4.4)。备选:互斥直接报忙。
3. **同步接口 `POST /catalog/profile`**:推荐**去掉**(前端是唯一调用方,留着要多维护一套互斥)。备选:保留。
4. **查询留痕**:推荐**记**(字面量去掉,§4.5)。备选:不记,只记条数。
## 7. 项目:交付与验证
**做法**:集成分支 `feat/catalog-profile-jobs`(从 main `fc002bd`);workflow 两个完整代理按同一份接口约定并行(后端:运行时抽取 + 剖析任务 + 引擎改编排 + 接口 + 设置迁移;前端:对话框、头部入口、表清单列、表详情一节、设置、检查脚本),一个统一复核,各自修;我合并、跑全量关口、真实验收,合 main 前问你。
**后端测试(虚构夹具)**
- 抽取:现有起草任务测试原样全过。
- 逐表预算:A 表用完自己的次数记 partial,B 表照样拿满额。
- 止损:连续 3 条失败任务停、其余 cancelled 带原因;超时比例触发 `slow`。
- 停止:做完的项写进目录,没做的保留旧结论;重启标中断不续跑;重试只带没做完的表。
- 排队:起草在跑时剖析排队,起草结束自动开始;停掉排队的;同种任务 409;导入、同步起草在剖析时 409。
- 范围:需要剖析 / 全部 / 已选;空表 1 条查询记 skipped;视图跳过;`profile_due` 的三种情形。
- 留痕:IN 列表的值不出现在 `query_log`;遮罩列不出现在任何查询。
- 设置迁移:旧键启动时删掉;老页面带旧键保存不报错;表助手和有效记录条件核对行为不变。
- 保留:超过 50 个时删旧的,但不删某张表的最近一次。
**前端**:新检查脚本 `check-catalog-profile.mjs`(假接口 + 夹具):开始前的范围与个数、排队提示、进行中逐表行与检查项进度、停止、结束后的筛选与展开、重试、表清单列、表详情一节、设置两项新字段;同时把 `check-catalog` 里旧的剖析一节删掉(那一节并行时偶发点击超时,顺带解决)。check-all 变成 30 项。
**真实验收**(数据副本,**副本里 Oracle 源的剖析关掉**,不碰你的真实业务库):两个虚构零售 SQLite 源开剖析(一个带大表、视图、空表、遮罩列)。逐表进度对得上;中途停止不丢已写的;杀进程重启 → 中断 → 重试补齐;起草 + 剖析排队;拿你数据副本里那个带旧设置键的源验证迁移(只看设置,不发查询)。
**规模**:和起草可靠性那一轮相当(后端约 1,500 行含测试,前端约 1,000 行)。
**部署**:只加表、迁移是加法,升级步骤不变。10.0.0.11 还没重新部署:如果不急,等这一轮合入后一次部署到位;急的话按上次的清单先部署,这一轮之后再升级一次。
## 8. 不在这次范围
> **已在下一轮做掉(2026-10,next-round/00-总方案.md 第 7 节)**:剖析结果进 look 表详情(遗留 e、目录 A2,第 2 波);定时剖析改成数据源的定时维护(目录 B4:一条目标为数据源的定时任务,第 3 波接口、第 4 波界面);列级取值漂移改成取值观察(目录 B2,第 3 波)。下面的原文保留作对照。
- 剖析结果喂给表助手和对话助手(如「这张表约 120 万行,下单日 2023-01 至 2026-10」)——有价值,等这一轮和助手按需查看(docs/design/copilot-look-and-caliber-ledger.md)都落地后,放进 look 的表详情(`data/schema_view.py`)里做,不另走一条路;遮罩照 look 的 D6。
- 定时剖析(按周重新剖析需要的表)——可以接到已有的定时任务上,单独一轮。
- 列级取值漂移检测(码值多了新取值)——依赖留痕的历史,下一步。
@@ -0,0 +1,897 @@
# 助手按需查看与口径账本
> 设计文档 · 草案,待拍板 · 2026-10-14
>
> - 本文档只定方案,不含代码改动。
> - 文中的测量来自本机开发环境(开发者自测数据),只读接口取得。数据源名称一律匿名为「库 A」到「库 G」,示例里的表名(orders、refunds、stores 等)都是虚构的。
> - 文中数字来自本机开发环境,按项目约定(真实环境的计数不进公开仓库)不宜原样提交;要提交时先把这些数换成概括的说法。
> - 代码位置按 2026-10-14 的 main(分支 feat/report-html-export 上看到的版本)标注,实施前需要按最新 main 复核。
## 目录
- 摘要
- 第一部分 问题与根因
- 第二部分 近期方案:助手按需查看
- 第三部分 远期设想:口径账本
- 第四部分 待拍板
- 附录 A 代码位置索引 · B 术语 · C 参考资料 · D 测量方法
---
## 摘要
**问题。** 画布助手和问数据用的是同一个助手。每一轮开始前,服务端按库的大小把表结构塞进 system 提示词,模型自己判断不了要不要看。用户只说一句「你好」,助手也会先花 2.7 秒挑表,再带着 57 张表的字段、206 个表名(和数据表有关的内容约 1.5 万字)去生成。
**根因。** 助手的主模型只能一行一行输出操作,没有任何查看表结构的手段。服务端只好替它猜要看什么,在它开口之前全部塞进去。「大库先挑表」「小库全给」「挑表失败退回全部表名」都是在这个前提下打的补丁。
**近期方案(第二部分)。** 让助手按需查看:
- 默认只给数据源概览,每个库一行。
- 模型需要看表时输出一行 `look`。服务端读到这一行就停下,一次给齐挑表结果、全部字段、数据目录、连接条件和补连接用的中间表,然后让模型接着写。
- 闲聊只调一次模型;数据类请求一次查看到位。
- 一个迭代内做完,不分期,也不先上临时补丁。
**远期设想(第三部分)。** 问数和工作流不合并界面,而是合在「口径」上:
- 工作流发布、治理之后就是带版本的口径;问数时,用这些口径和 agent 现场拼出要跑的图。
- 判断一个问题能不能用某份口径回答,靠证明两条 SQL 等价,不靠问题像不像;不等价时自动对账,并给每个数字标明它来自哪份口径。
- 口径变了,把已经给出去的答案追回来。
其中四件事,这次检索的公开资料里没有查到先例。
**需要拍板的事项**见第四部分:近期 4 项,远期 2 项。
---
## 第一部分 问题与根因
### 1.1 现象
在问数据页说「你好」,过程里出现:
```
已完成 7.8 s
过程 2 步
参考了 57 张表的字段
「库 A」「库 B」未能按需求挑选,另有 206 张表只提供了表名 2.7 s
```
模型最后只回了一句问候。
### 1.2 复现与测量
本机后端接了 7 个启用的数据源,按现在的规则分成两类:
| 数据源 | 类型 | 表数 | 现在怎么给 | 「你好」这一轮实际给了多少 |
|---|---|---|---|---|
| 库 A | Oracle | 53 | 超过 40 张,先挑表;挑表返回空,退回只列表名 | 表名约 1,400 字 |
| 库 B | MySQL | 153 | 同上 | 表名约 2,800 字 |
| 库 C | SQLite | 11 | 摘要放得下,全部给 | 摘要 1,078 字 |
| 库 D | SQLite | 3 | 同上 | 183 字 |
| 库 E | MySQL | 39 | 同上 | 3,155 字 |
| 库 F | SQLite | 1 | 同上 | 126 字 |
| 库 G | SQLite | 3 | 同上 | 434 字 |
界面上的数字就是这样来的:57 = 11 + 3 + 39 + 1 + 3;206 = 53 + 153。
「你好」这一轮 system 提示词的构成:
| 内容 | 字数 | 说明 |
|---|---|---|
| 节点手册 `NODE_REFERENCE` | 11,408 | 每轮都带,不在本次范围 |
| 输出协议 `_STREAM_PROTOCOL` | 2,231 | 同上 |
| 小库摘要(库 C 到库 G) | 4,976 | 和这句话无关 |
| 小库的数据目录和连接条件 | 最多 6,000 | 库 C、E、G 共 52 张表有目录,共用一份 6,000 字上限,基本用满 |
| 大库的全部表名(库 A、B) | 约 4,200 | 和这句话无关 |
| 工具清单(每个库列前 12 个表名),加上写 SQL 的硬性要求(534 字) | 约 2,000 | 和这句话无关 |
和数据表有关的内容约 1.5 万字,占 system 提示词将近一半。
部署实例只接了库 B 一个库,每一轮都会先挑表,问题一样。测量方法见附录 D。
### 1.3 这一轮在代码里怎么走
以流式生成(`/generate-stream`,`copilot.py:1800`)为例:
1. **定每个库怎么给。** `plan_context`(`copilot_context.py:279`)读各库的数据目录,`_modes`(`:223`)只按库的大小定:
- 表超过 40 张的库走「挑表」;
- 其余的库按详细摘要从短到长,放进 6,000 字的预算:放得下的「全部给」,放不下的也去挑表。
- 这一步完全不看这一轮要做什么。
2. **准备挑表。** `_picker` 拿挑表用的模型:同一个助手模型,关掉思考,输出额度 1,024。`_pick_need`(`copilot.py:111`)把这一轮的话,和这段对话前 3 轮的问题拼在一起,作为挑表的需求。
3. **挑表。** 开流之后先跑 `plan.resolve`(`copilot.py:1879`),期间发心跳。挑表提示词要求「需求和数据库无关(闲聊、只改提示词或流程结构)时返回空列表」(`copilot_context.py:118`)。模型照做返回了空,`resolve` 却把它记成失败(`:427`「模型未挑出任何表」),走 `fallback`。
4. **组装。** `_assemble`(`:451`):`all` 的库给全部表,`fallback` 的库只列表名。
5. **拼数据源一节。** `section()`(`:599`):
- `all` 的库 → `_detail_block`:摘要、连接条件、数据目录;
- `fallback` 的库 → 只列表名,再补一句「写 SQL 前用 db_schema 确认字段」。
6. **拼 system。** `system_for(context)`(`copilot.py:1816`)= 节点手册 + 工具清单(`_tool_catalog`,`:75`,每个库列前 12 个表名)+ 上面那一节 + 结构要求 + 输出协议。这一份一直用到自查修正结束。
7. **通知界面。** `context.op()`(`copilot_context.py:570`)发给前端,界面显示「参考了 N 张表」。
8. **生成。** 主模型由 `_iter_ops`(`copilot.py:1090`)读流式文本、按行解析成操作,没有绑定任何工具。
### 1.4 根因
| 编号 | 根因 | 位置 | 后果 |
|---|---|---|---|
| R1 | 主模型没有查看手段,只能被喂 | `copilot.py:1090` | 服务端只能事先猜、事先塞 |
| R2 | 给多少只按库的大小定,不看这一轮要做什么 | `copilot_context.py:223` | 闲聊也带着小库的全部摘要 |
| R3 | 挑表说「不需要」,被当成失败 | `copilot_context.py:118` 与 `:427` | 退回列出全部表名,反而给得更多 |
| R4 | 挑表在主模型之前串行跑,只要有大库就跑 | `copilot.py:1864`–`1891` | 每轮多 2–3 秒 |
R3 还有一个旁证:前端为「挑了、但一张都不需要」准备好的说法(`decode.ts:3086` 的 `case 'model'` 分支,0 张表时显示「未挑中与需求相关的表」),后端从来没发过这种情况。
另外五个发现:
- **F1 小库给的不是全部字段。**
- 详细摘要每张表只列前 6 列(`introspect.py:30` `_PREVIEW_COLUMNS = 6`),后面写「等 N 字段」;界面却写「参考了 57 张表的字段」「全部 N 张表」。
- 大库里挑中的表反而给全部字段。结果是闲聊时小库给得太多,真问小库的数据时又给得不够。
- **F2 在对话里接着说话时,挑表看的是前几轮。** `_pick_need` 会拼上前 3 轮的问题。在一段问过数据的对话里说「谢谢」,挑表模型照样按之前的问题挑表、给全部字段。
- **F3 问数据时,助手自己根本不写 SQL。**
- `copilot.py:371` 起的约定:问数据时搭「输入 → agent → 报告 → 输出」。写 SQL 的是运行时的 agent 节点,它自己绑了 `db_schema__`(`tools/datasource.py:647`)。
- 所以在这条路上,提前给助手的字段基本用不上。
- **F4 事后查不到模型当时看了什么。** `context` 操作只用于界面显示(`studio.ts:1896`),对话记录里没有;挑表失败的原因只写在后端日志。
- **F5 改一处要动四处。**
- 四个生成入口共用 `_datasource_context`(`copilot.py:134`):`/generate`(`:730`,界面已不调用,只在检查脚本里探测)、`/generate-stream`(`:1800`)、发布前修复 `assist_publish_fix`(`:2114`)、升级证据 `assist_upgrade`(`:2296`)。
- `copilot.py` 有 2,731 行。
### 1.5 来历
- 2026-10-01 评估语义层时拆过两块工作:「按任务挑表」估 1–2 天,「助手建图前先探查」估 3–5 天。
- 2026-10-03 数据目录阶段 1B(`e74097a`)做了前一块。当时要解决的问题是:大库只列表名,模型照着表名编字段,跑到查库那一步才报「no such column」。
- 之后又一层层补上去:
- 补连接用的中间表;
- 现有工作流用到的表给全部字段;
- 表没探查全时的提示(`52aaaaf`);
- 库没有数据目录时提示先起草(`546d0e9`)。
- 每一层都在回答「服务端怎么猜得更准」,没有哪一层在问「模型这一轮到底要不要看」。
- 当初的痛点,现在已经有两层兜底:
- 搭完自查的 `sql_unknown_column`(`copilot.py:1327`)会把那几张表的真实列交回去让模型改;
- 运行时的 agent 会自己查表结构。
### 1.6 助手在系统里的位置
| 模型 | 位置 | 能不能自己查 | 表结构怎么来 |
|---|---|---|---|
| 运行时的 agent 节点 | `engine/nodes/llm.py` | 能 | 工具说明里列前 25 个表名(`tools/datasource.py:176`),字段要用时自己查 |
| 表助手(在 feat/catalog-loop 上,还没合进 main) | `data/catalog_assistant.py` | 能:原生工具调用,多轮、心跳、出错重试 | 只给这一张表 |
| **画布助手 / 问数据** | `api/copilot.py` | **不能** | 服务端事先挑好、事先塞进去 |
| 挑表、目录起草 | 单次分类调用 | — | — |
助手是唯一一个只能「被喂」的。
---
## 第二部分 近期方案:助手按需查看
### 2.1 设计理念
方案里的每个决定都要对得上下面六条:
| # | 理念 | 出处 | 在本方案里怎么体现 |
|---|---|---|---|
| P1 | 照实说:界面说的就是实际发生的 | 「参考了 N 张表」的文案约定 | 没查看就不出那一行;「查看了 4 张表」说的就是模型看到的那 4 张 |
| P2 | 留痕:模型看到了什么能查到 | 运行侧的表结构快照、查询快照、封存 | 每次查看记一条,进对话记录,也进下一轮的历史 |
| P3 | 不挡生成:辅助步骤失败不能让这一轮失败 | 挑表的约定 | 查看失败时交回表目录,由模型接手 |
| P4 | 只读、遮罩:不碰业务库,模型看不到遮罩值 | K3 修复 | 查看只读表结构缓存和数据目录;遮罩列不给值 |
| P5 | 同一张表只有一种说法 | db_schema、`/schema` 接口、助手 | 三处共用同一份表详情 |
| P6 | 模板定骨架、agent 填关节 | 项目路线 | 问数据时查库交给运行时 agent,助手只在需要时看 |
同时守住路线里不可回退的决定:叙述层不做算术;没发布的图只能跑探索运行;正式运行只能从发布的版本发起。
### 2.2 目标与不做的事
目标:
1. 默认不展开任何表,要不要看由主模型判断。
2. 要看时一次拿齐:挑表结果、全部字段、数据目录、连接条件、补连接用的中间表。
3. 闲聊、改结构只调一次模型;数据类请求一次查看到位,异常情况最多三次。
4. 一个迭代内做完,不先上临时补丁。
不在这次范围内:
- 运行时 agent 工具说明里的 25 个表名:它本来就是自己判断要不要查。
- 向量检索。
- 显式提示缓存:项目里目前没有用,另议。
- 节点手册瘦身:约 1.1 万字,每轮都带,另议。
- 数据目录、表助手本身。
### 2.3 整体流程
```
用户一句话
│
▼
system:节点手册 + 输出协议 + 数据源概览(每个库一行)+ 什么时候该看
│
▼
主模型(流式输出,一行一个操作)
├─ 不需要数据 ──► reply 或改图操作 … done ──► 自查 ──► 结束(只调一次模型)
│
└─ 需要 ──► {"op":"look", "need": …, "tables": …, "sources": …}
│ 服务端读到这一行就停止本次输出,这一行之后的内容丢弃
▼
查看服务
点名的表:直接取,不调挑表模型
只给了需求:候选不超过 12 张就全给;否则用挑表模型按需求挑
补连接用的中间表;遮罩;排成文字;记一条记录
│
├──► 发给前端:look 事件(过程里显示一行)
▼
把结果作为下一条消息交回 ──► 主模型接着输出(已经落地的操作不重复)
… 还可以再 look(每轮最多 3 次)… done
│
▼
自查:修正请求里带上这一轮的查看结果;修正时可以再 look 1 次
│
▼
前端把查看记录存进这一轮的 meta.looks;下一轮的历史里带上「上一轮查看过的表」
```
### 2.4 关键决定
#### D1 机制:在逐行输出的操作里加一种 `look`
- **决定**:模型要看表时输出一行 `{"op":"look",…}`。服务端读到这一行就停止本次输出,执行查看,把结果作为下一条消息交回,再发一次请求让模型接着写。
- **理由**:
- 助手本来就是一行一行输出操作,`catalog_patch`、`reply` 已经是「不改图的操作」,加 `look` 不引入新的约定。`_parse_op_line`(`copilot.py:878`)不限制 op 的名字;前端遇到不认识的操作,不会报错。
- 不依赖接入支持工具调用。`copilot_context._ask`(`:333`)的注释里记过:有的网关一调工具就报错,纯文本却能用。演示模型也能按脚本走这条路。
- 只有一套机制,不需要再留一条「接入不支持工具调用时,退回预先塞表」的路。
- **否决的做法**:
- 原生工具调用(`bind_tools`,运行时 agent 和表助手在用):要给不支持工具调用的接入和演示模型留退路,等于同时维护两套。
- 保留预先塞表、前面加一步意图分类:还是替模型判断。
- **代价**:查看之后要重新发一次请求,模型的思考接不上。走 OpenAI 兼容接入时,原生工具调用一般也不回传思考,差别不大。
#### D2 一次查看拿齐
- **点名的表**:直接取,不调挑表模型。
- **只给了需求**:
- 候选库的表加起来不超过 12 张(`PICK_LIMIT`),就全给;
- 否则用现有的挑表步骤(`PICK_SYSTEM`,关思考,20 秒时限)按需求挑。
- 需求由主模型来写,不再把前 3 轮问题拼在一起。
- **中间表**:沿关系图补上两跳以内的中间表(沿用 `_complete`,`copilot_context.py:493`),一次合计不超过 16 张(`CONTEXT_TABLE_LIMIT`)。
- **挑表失败**(超时、报错、回复认不出、挑出的表都不存在):交回候选库的表目录,每张表一行「表名|中文名|粒度」(沿用 `catalog.render_table_index`),写明失败原因,请模型点名再看。
- **挑表返回空**:如实交回「按这个需求没有挑出相关的表」,附上表目录。
- **字数上限**:每次结果有上限(暂定 12,000 字)。超出部分的表只给字段名,并写明这一点。
- **重复查看同一批表**:直接回「这些表已在上面」,不再重新排一遍。
- **次数上限**:每轮最多查看 3 次,自查修正时另给 1 次。用完之后再 look,回「查看次数已用完,按已查看的结构完成」。
#### D3 默认只给数据源概览
- 每个库一行,格式见 2.5.1。
- 现有工作流用到的表(`graph_tables`,`copilot_context.py:249`)只列表名,注明「来自现有工作流」。
- 工具清单里不再列表名,只写「共 N 张表,用 look 查看」。
- 写 SQL 的硬性要求(534 字)留在 system 里:它短而且固定,改已有 SQL 时也要用。
- 效果:「你好」那一轮,和数据表有关的内容从约 1.5 万字降到 1 千字以内。
#### D4 什么时候该看(写给模型的判断标准)
| 场景 | 怎么做 |
|---|---|
| 闲聊、解释、改提示词、改流程结构、改不碰 SQL 的节点 | 不看 |
| 问数据 | 查库交给 agent 节点,它运行时会自己查结构。下面三种情况才看一次:从概览看不出该用哪个库;用户点了表名;用户说了口径。看到的表名写进 agent 的提示词,运行时少走弯路 |
| 直接写 SQL 的取数节点、修改已有的 SQL | 动笔前看,一次把要用的表都点上 |
| 用户说了一条数据事实 | 先看那张表的现有目录,再提目录修改 |
另外规定:
- 要看就先看,看完再规划,不要先搭节点再回头看。
- 这一段对话里已经查看过的表,直接点名看,不要只给需求。
#### D5 不需要人参与的路径,由系统代为查看
- **发布前修复**(`assist_publish_fix`):要修的图里,调用工具节点的 SQL 用到的表(`graph_tables(base)`)。
- **升级证据**(`assist_upgrade`):要升级的取数节点用到的表。
- **做法**:服务端用同一个查看服务点名这些表,以「系统查看」的身份放进对话。记录格式和排出来的文字,与模型主动查看的完全一样。模型仍然可以再看。
- **理由**:这两条路径没有人在等,而且要查哪些表是确定的,系统直接查能省掉一轮。
#### D6 同一种说法,同一套遮罩规矩
- 在 `data/` 下新增一个只读的表结构视图(暂名 `schema_view.py`),提供三样东西:
- 库概览;
- 表目录;
- 表详情:字段、数据目录、连接条件、两跳的连接路径。
- 助手查看、运行时的 `db_schema__`、`/schema` 接口,共用同一份表详情。
- 遮罩规矩:遮罩列只给列名和类型。它的注释、目录里的码值、带具体取值的备注都不给。这和表助手是同一条规矩;哪些列是遮罩列,以 `masked_columns(source.options)`(`data/engine.py:370`)为准。
- 这样也顺手补上一个现有缺口:现在 `db_schema` 和助手都会把遮罩列的目录原样交给模型。
- **代价**:运行时 agent 看到的目录会少一些(遮罩列那部分),要在 README 里写明。
#### D7 留痕
- 记录字段见 2.5.7、2.5.8。
- 记录去三个地方:
- 实时发给前端;
- 前端存进这一轮的 `meta.looks`。沿用 meta「前端写、前端读」的做法(`db/models.py` 里 ConversationTurn 的 `meta`);这次多了一个服务端的读者;
- 下一轮 `_history_section`(`copilot.py:459`)写一行「上一轮查看过:库 B 的 orders、refunds」,追问时模型可以直接点名看,不用再挑。
#### D8 自查
- 修正请求(`_repair_request`,`copilot.py:1779`)里带上这一轮的查看结果,和起草时看到的是同一份文字。
- 修正时可以再看 1 次,比如 `sql_unknown_column` 指向了一张没查看过的表。
- 不新增「没查看就写 SQL」的拦截:列名写错有 `sql_unknown_column` 拦,目录里的事实有基于目录的 SQL 检查拦。再加一条,只会多一轮修正。
#### D9 四个入口共用一个生成过程
- 把生成过程抽到新模块(暂名 `api/copilot_gen.py`):起草 → 查看(停下、执行、接着写)→ 自查修正,产出一串操作。
- `/generate-stream` 把它转成 SSE;`/generate`、`assist_publish_fix`、`assist_upgrade` 只收结果。
- `copilot.py` 只抽出生成过程和数据访问两块;节点手册、解释、排版、复核不动。测试里 import 的名字保留,从新模块再导出。
### 2.5 规格
#### 2.5.1 数据源概览(system 里每个库一行)
格式示例(虚构):
```
已接入的数据源(要看表结构时用 look,见下):
- 库 B(MySQL,只读):订单业务库(示例说明)|共 153 张表,都有数据目录
- 库 C(SQLite,只读):未填说明|共 11 张表,10 张有数据目录
- 库 H(PostgreSQL):未填说明|共 205 张表,只探查了 200 张
- 库 I(MySQL):未填说明|结构探查失败:连接超时
- 库 J(SQLite,只读):未填说明|设了遮罩
现有工作流用到的表:库 B 的 orders、refunds(字段要用时 look)
```
每行包含:名称、数据库类型、是否只读、说明、表数(没探查全时写明)、有数据目录的表数、有没有设遮罩、没探查或探查失败的原因(沿用 `introspect.why_empty`)。
#### 2.5.2 look 行(模型输出)
```json
{"op":"look","need":"按门店统计 9 月退款金额,要订单、退款和门店维度","tables":[{"source":"库 B","table":"refunds"}],"sources":["库 B"]}
```
| 字段 | 必填 | 说明 |
|---|---|---|
| `need` | 是 | 要查什么,一句话。用作挑表的需求,也写进记录和界面 |
| `tables` | 否 | 点名要看的表。只有一个候选库时可以不写 `source`;`table` 认全名、只写表名、大小写不同这几种写法(沿用 `catalog.resolve_table_name`) |
| `sources` | 否 | 限定在哪些库里找。不写时为全部候选库;无论写不写,都只在用户限定的范围内 |
规则:
- 给了 `tables` 时只取点名的表(加上补连接用的中间表),不调挑表模型;没给 `tables` 时按 `need` 挑表。
- `need` 为空:回「look 要写 need」,不算次数。
- 库名不存在或不在用户限定的范围:记为没找到,在结果里写明。
- `tables` 超过 16 张:只取前 16 张,并写明。
#### 2.5.3 查看服务的处理顺序
```
look(need, tables, sources, by):
候选库 = 用户限定的范围 ∩ sources(不写 sources 时为范围内全部库)
如果给了 tables:
选中 = 逐个解析 tables,解析不了的记入「没找到」
否则:
表池 = 候选库里的全部表
如果表池不超过 PICK_LIMIT 张:选中 = 表池
否则:
选中 = 挑表模型(need, 候选库的表目录)
挑表失败或返回空 → 返回「表目录 + 原因」,status = index
给出的表 = 选中 + 两跳以内的中间表(合计不超过 CONTEXT_TABLE_LIMIT)
文字 = 表详情(遮罩后)+ 连接条件 + 连接路径;超过 LOOK_RESULT_CHARS 的部分只给字段名
记录 = {id, by, need, status, 各库的 named / picked / joined / missing, 表结构同步时间, 目录版本, 用时, 原因}
返回 (文字, 记录)
```
#### 2.5.4 交回给模型的结果(示例,虚构)
```
查看结果(第 1 次,这一轮最多 3 次)
需求:按门店统计 9 月退款金额,要订单、退款和门店维度
库 B(MySQL):按需求从 153 张表中挑出 3 张,另为连接补上 1 张
· refunds[表]:refund_id、order_id、refund_amount、refund_time、status …(全部 14 列)
数据目录:粒度 一行一笔退款;refund_amount 单位 元;status 码值 1=成功 2=失败;有效记录条件 status = 1
· orders[表]:…
· stores[表]:…
· order_items[表](为连接补上):…
表之间的连接条件(写 JOIN … ON … 时照抄):
refunds.order_id = orders.order_id(多对一)
orders.store_id = stores.store_id(多对一)
没找到:库 B 的 refund_detail(表结构里没有这张表,可能没探查到;别拿名字相近的表顶替)
接着输出操作。已经输出、已经落地的操作不要重复。
```
#### 2.5.5 接着写时的消息结构
```
[system] 和起草时相同,不变
[human] 用户这一轮的请求(含历史)
[assistant] 模型到 look 那一行为止的输出(look 之后的内容已丢弃)
[human] 查看结果 + 「接着输出操作,已经落地的不要重复」+ 已落地的节点 id 列表
```
去重规则,以 `_apply_op`(`copilot.py:1004`)现在的行为为基础:
- `add_edge` 现在会直接追加,不查重。接着写时,和已有连线完全相同的 `add_edge` 要忽略,也不转发给前端。
- 同 id 的 `add_node` 现在会整体覆盖。接着写时保持这个行为,但和已落地节点完全相同的不转发。
- `plan` 操作接着写时只保留第一次的。
#### 2.5.6 上限与常量
| 常量 | 值 | 说明 |
|---|---|---|
| `LOOK_LIMIT` | 3 | 起草阶段每轮最多查看几次 |
| `REPAIR_LOOK_LIMIT` | 1 | 自查修正阶段另给几次 |
| `PICK_LIMIT` | 12(沿用) | 挑表最多挑几张;候选表不超过这个数时不挑、全给 |
| `CONTEXT_TABLE_LIMIT` | 16(沿用) | 一次查看最多给几张表的详情(含中间表) |
| `PICK_TIMEOUT_S` | 20(沿用) | 挑表时限 |
| `LOOK_RESULT_CHARS` | 12,000(新,暂定) | 一次查看结果的字数上限 |
#### 2.5.7 发给前端的 look 事件
```json
{"op":"look","id":"L1","by":"model","need":"…","status":"ok","elapsed_ms":2310,
"sources":[{"source":"库 B","source_id":"…","named":[],"picked":["refunds","orders","stores"],
"joined":["order_items"],"missing":["refund_detail"],"total":153,
"catalog_tables":153,"schema_synced_at":"…","catalog_versions":{"refunds":7}}]}
```
| 字段 | 说明 |
|---|---|
| `id` | 这一轮内的编号:L1、L2… |
| `by` | `model`:模型发起;`system`:系统代为查看 |
| `status` | `ok`:给了表详情;`index`:挑表失败或返回空,交回了表目录;`failed`:查看本身出错 |
| `sources[].named / picked / joined` | 点名的、按需求挑出的、为连接补上的(全名) |
| `sources[].missing` | 点了名但没找到的 |
| `total` / `explored` | 这个库的表数;探查截断时多带 `explored` |
| `catalog_tables` | 有数据目录的表数。为 0 时界面提示先去起草(沿用现有入口) |
| `schema_synced_at` / `catalog_versions` | 表结构的同步时间、给出的各表目录版本(留痕用) |
| `reason` | `status` 不是 `ok` 时的原因 |
| `elapsed_ms` | 这次查看用时,含挑表 |
#### 2.5.8 查看记录
- 前端把每个 look 事件原样存进这一轮的 `meta.looks`。
- 下一轮 `_history_section` 读 `meta.looks`,写一行:「上一轮查看过:库 B 的 refunds、orders、stores」。
- 系统查看(`by: system`)照样记录。
### 2.6 界面
| 情况 | 过程里显示 |
|---|---|
| 没查看 | 不出这一行 |
| 模型查看 | 标题「查看了 4 张表」,副标题「库 B · 按需求挑选」,带用时。展开后每个库一行:点名的、按需求挑出的、为连接补上的;另外写没找到的表;库没有数据目录时,给出去起草的入口 |
| 挑表失败 | 标题「没挑出来,已把表目录交给助手」,展开写原因;后面通常紧跟一行点名查看 |
| 系统查看 | 「按要修的 SQL 查看了 3 张表」 |
| 多次查看 | 每次一行,按先后排 |
- 删掉这三条文案(`terms.ts`、`decode.ts`):「参考了 N 张表的字段」「另有 N 张表只提供了表名」「未能按需求挑选,已提供全部表名」。
- 浮条:查看进行中显示「正在查看表结构…」,结束后显示标题。
- 新旧版本混用:
- 新前端遇到老后端的 `context` 操作,照常显示(保留解码);
- 老前端遇到 `look` 事件,不处理(`studio.ts` 对不认识的操作不做任何事)。
### 2.7 改动范围
**后端**
| 文件 | 改动 |
|---|---|
| `data/schema_view.py`(新) | 库概览、表目录、表详情(含遮罩)。从 `copilot_context`、`introspect.describe_table`、`catalog.render_table_notes` 里抽出来 |
| `api/copilot_context.py` | 改写成「概览 + 查看服务 + 记录」。挑表变成查看里的一步;删掉 `_modes`、`section` 里按 `all` / `fallback` 组装的部分,以及 `listed` |
| `api/copilot_gen.py`(新) | 生成过程:起草;读到 look 停下、执行、接着写;次数上限;去重;自查修正 |
| `api/copilot.py` | 四个入口改用生成过程;改 `system_for`;`_tool_catalog` 不列表名;删掉 `_pick_need`;`_history_section` 加上一轮查看过的表;`_repair_request` 带上查看结果;`_STREAM_PROTOCOL` 加 `look` |
| `tools/datasource.py` | `db_schema__` 改用共用的表详情 |
| `api/datasources.py` | `/schema?table=` 改用共用的表详情 |
| `providers/mock_model.py` | 如果需要,演示模型按脚本走一次 look |
**前端**
| 文件 | 改动 |
|---|---|
| `run/decode.ts` | look 事件翻成过程里的步骤;保留 `context` 的解码 |
| `store/studio.ts` | look 的浮条;写 `meta.looks` |
| `lib/terms.ts` | 加新文案,删旧文案 |
| `run/__tests__/synthetic.ts`、`dev/preview.tsx` | 样例换成 look |
| `scripts/check-chat.mjs` 等 | 桩数据里的 `context` 换成 look |
**文档**:README 里助手一节;`copilot_context.py` 的模块说明重写。
**测试**
- 重写:`test_copilot_table_pick.py`(15 个用例),改成查看服务的用例。
- 更新:`test_catalog_impact.py`、`test_catalog_schema_truncated.py`、`test_copilot_sql_alias.py`。
- 新增:
- look 行的解析和校验。
- 查看服务:点名、按需求挑、候选不超过 12 张时全给、挑表失败交回表目录、挑表返回空、没找到、探查截断、用户限定范围、遮罩、字数上限、重复查看。
- 生成过程:读到 look 就停、接着写时的消息结构、已落地的操作不重复、次数上限、输出额度截断、演示模型。
- 四个入口,包括系统查看。
- 历史里的「上一轮查看过」。
- 前端:decode 出 look 步骤、文案、写入 meta。
### 2.8 验收
| 场景 | 现在 | 目标 |
|---|---|---|
| 「你好」 | 挑表 + 生成,2 次调用,7.8 秒;和数据表有关的内容约 1.5 万字 | 1 次调用;没有查看那一行;和数据表有关的内容不到 1 千字 |
| 问数据(库 B) | 挑表 + 生成 | 最多 1 次查看,挑对表;或者不看,直接交给 agent |
| 画布上写周报 SQL | 挑表 + 生成 | 1 次查看;自查不报 `sql_unknown_column` |
| 追问「那上个月呢」 | 拼上前 3 轮问题重新挑表 | 不看,或者点名查看(不调挑表模型) |
| 在问过数据的对话里说「谢谢」 | 照样挑表、给全部字段 | 不看 |
| 说一条数据事实 | — | 查看那一张表之后提目录修改 |
| 挑表失败(模拟超时) | 206 个表名塞进 system | 交回表目录,模型点名再看,查看不超过 2 次 |
| 遮罩列 | 目录原样交给模型 | 只给列名和类型 |
| 发布前修复、升级证据 | 挑表 + 生成 | 系统查看 + 生成,不调挑表模型 |
- **每个场景要记录**:模型调用次数、查看次数、system 字数、第一个操作出来的时间、总用时、自查修正轮数。
- **真实模型**:本机配置的助手模型,加上之前验收用过的 deepseek-v4.1-flash 和 glm-5.3。数据用开发库的副本,放在 /tmp,真实名字不进仓库;复制数据目录需要你放行。
- **关口**:后端全量测试、tsc、文案检查、泄露扫描、check-all(decode、stream、ui、chat)。
### 2.9 实施组织
- **前提**:先把 feat/catalog-loop 合进 main。遮罩规矩和表助手都在那个分支上,先合进来,就不会两边各写一套。之后基于最新的 main 开集成分支。
- **方式**:一个 workflow,两个完整的代理并行:
- 后端包、前端包,按 2.5 的接口约定开发;
- 然后统一复核,各自修;
- 最后由编排者合并、跑全量关口、做真实模型验收,合 main 之前问你。
- 不先上临时补丁。
### 2.10 风险与对策
| 风险 | 对策 |
|---|---|
| 数据类请求多一次主模型调用,慢几秒 | 验收时量出来;点名和追问不调挑表模型;问数据可以不看 |
| 模型该看的时候不看 | 自查兜底;验收统计 `sql_unknown_column` 的出现比例 |
| 模型看的次数太多 | 每轮 3 次上限,重复查看直接短路 |
| 接着写的时候重复输出已落地的操作 | 接着写的提示里列出已落地的节点;完全相同的操作忽略(2.5.5) |
| 接着写时模型要重新思考,成本变高 | 交回结果时提醒「接着写」;验收时记录总 token |
| 遮罩规矩用到 `db_schema` 后,运行时 agent 看到的目录变少 | 符合「模型看不到遮罩值」的原则,在 README 写明 |
| 新旧前后端混用 | 新前端保留 `context` 解码;老前端忽略 look |
### 2.11 为远期预留
查看的对象做成可以扩展的类型:现在只有「表」,第三部分要加上「已治理的口径」。
look 交回的结果按对象类型分块排列。记录里的 `sources` 以后可以并列一个 `calibers`。这样接入口径时不用改协议。
---
## 第三部分 远期设想:口径账本
这一部分回答一个更大的问题:问数和工作流能不能合二为一。这里只提出假设,同时给出反驳和验证办法,不是实施计划。
### 3.1 两者的本质区别:推理和承诺
问数每一轮其实就是:助手搭一张图(输入 → agent → 报告 → 输出),跑一次探索运行,给出答案(`copilot.py:371` 起的约定)。工作流也是「图 + 运行」。两者在引擎上已经是一回事,区别不在「对话还是画布」,而在下面这些:
| | 问数 | 工作流(发布、治理之后) |
|---|---|---|
| 产出 | 一次**推理**:这一次、这份数据、这个模型的判断 | 一份**承诺**:同一个口径,每期都这样算,出具前要过契约 |
| 运行类别 | 探索 | 正式(只能从发布的版本发起) |
| 生命周期 | 跟着对话走,依赖上下文 | 有版本,可以定时,升版要处置 |
| 谁在用 | 要答案的人 | 定口径、对数负责的人 |
### 3.2 现状:两者之间的连接基本是断的
| 方向 | 现在的做法 | 问题 |
|---|---|---|
| 问数 → 工作流 | 「在画布里打开」:把这一轮的图复制成一张新工作流,名字是「问数据:」加问题的前 24 个字(`ChatPage.tsx:298`) | 复制之后,和原来的问题、运行没有关联 |
| 运行 → 模板 | 运行详情页的「提取模板」(`/copilot/from-run`,`copilot.py:2496`):只保留实际执行过的节点,输入改成参数,落成草稿 | 要人自己想到去点 |
| 重复的问题 → 模板 | `/governance/exploratory-clusters`(`governance.py:90`):用本地向量做贪心聚类,提示「用提取模板固定下来」 | 接口已经有了,界面上没有任何地方调用(`client.ts:638` 没有引用) |
| 工作流 → 问数 | 没有 | 问数用不上已治理的口径 |
本机开发库的情况(开发者自测数据,不代表真实使用):
- 33 个问数对话,共 72 轮;
- 47 个工作流,其中 12 个由「在画布里打开」复制而来;6 个已治理、2 个已发布、39 个草稿;
- 正式运行 29 次,探索运行 200 次以上。
### 3.3 「合二为一」的四种可能,逐个看
#### H1 界面合一
- **设想**:一个页面,左边对话、右边画布,问完就是一张可以保存的图。
- **好处**:少一个入口;从问到存只要一步。
- **反驳**:
- 两类人做的事不一样:要答案的人不想看图,定口径的人需要精确控制。
- 更关键的是,界面合在一起会淡化「探索」和「正式」的边界:聊出来的数和周报里的数长得一模一样,而这正是路线前五个阶段花力气分开的东西。
- 旁证:Dify 的 Chatflow 和 Workflow 用同一套画布,照样分成两种应用。Chatflow 有对话记忆、不能定时触发;Workflow 一次跑完、可以定时。
- **结论**:不做。
#### H2 工作流吞掉问数
- **设想**:问数只是一个特殊的「问答工作流」,聊天页是它的前端。
- **反驳**:每个问题需要的图都不一样。固定成一个工作流,就只剩一个什么都干的 agent,这正是路线里只留给探索层的「全动态」。每个问题的结构、证据的细致程度、能沉淀成模板的路径都会丢掉。
- **结论**:不做。
#### H3 问数吞掉工作流
- **设想**:一切靠对话搭,画布退成只读的查看器。
- **反驳**:发布和治理要人逐处审改(钉版本、口径卡、出具契约),对话不适合做精确的逐处审阅。去掉画布,承诺就没人签字了。
- **可取之处**:画布可以换个定位,从「编辑器」变成「承诺工作台」,也就是把推理变成承诺、由人签字的地方。
- **结论**:不做,但吸收它对画布的重新定位。
#### H4 对象合一
- **设想**:「问题」是唯一的对象,按「探索 → 模板 → 出具」逐步成熟。
- **评价**:方向是对的,但业内已经有人做了:
- Hex Threads(2025-10):每段对话背后就是一个 notebook 项目,数据团队可以接手审改;发布出去的应用,又会成为之后回答的上下文。
- Snowflake Cortex Analyst:会根据使用情况和查询历史,推荐值得认证的查询,由人审核。
- **结论**:这一层该做,但做了只是跟上业内,不构成差异。
### 3.4 方案:口径账本
一句话:**工作流是词汇,问数是造句。**
- 发布、治理过的工作流(连同里面的取数节点和口径卡)是带版本的口径。
- 问数时,现场把这些口径(钉住版本的子图)和 agent 拼起来回答。
- 每个数都记着它来自哪份口径。
- 口径和答案之间的关系,记在一本账上。
#### 3.4.1 组合式问数
> **已修订(2026-10,next-round/00-总方案.md X12、02 做法 4、拍板账本-L3)**:不用子图做骨架,改成服务端按固定版本**确定性内联实例化**(`use_caliber`:取数节点照抄、口径卡用 `caliber_from` + `nodes` 改名映射,`app/ledger/derive.py` 是全站唯一的派生图生成器)。原因:子图的证据不交回父图,报告里的数点不到口径的取数;内联之后证据链和普通取数一样。下面的原文保留作对照。
- 助手搭的图 = 钉住版本、已治理的子图做骨架 + agent 填补缺口。这就是「模板定骨架、agent 填关节」用在问数上。
- 子图节点已经能按「工作流 + 版本」调用(`engine/compare.py:123` 列出的配置项:`workflow_id`、`workflow_version`、`input`、`assign_to`)。
- 守住不可回退的决定:拼出来的图仍然是没发布的图,整轮按探索运行算。只有当整个问题在绑定参数之后等价于某一个已治理的工作流时,才去跑那个版本的正式运行,按三档出具。
#### 3.4.2 认口径靠证明等价,而不是看问题像不像
> **已修订(2026-10,X12、02 做法 1–2)**:「字面量换成占位符、所以参数不同不算不同」改成**先绑定参数再证明**:`sql_semantics.bind_template` 从这次的 SQL 里认出口径的参数值(只当启发),按绑定渲染口径的 SQL,再由 `explain_sql_diff` 判等价(执行守门进语料)。只换占位符会把「2024 年」和「2025 年」认成同一个数;绑定之后同一个口径不同参数的答案各自有出处。出错方向仍然只会少给信任。
**业内的「已验证答案」是怎么认的:**
- Databricks Genie:原样用了某条带参数的示例查询,才标为已验证。
- Power BI:按触发短语做精确匹配或语义相似匹配。
- Snowflake Cortex:记下这次用了哪条已验证查询。
**这类做法会往哪个方向出错:**
- 错配了照样打上「已验证」。比如问题里多了「含退款」,还是被认成了已验证。
- 资产过期了还在出数。有人记录过:trusted asset 建在 orders_v2 上,迁到 v3 之后它照样出数,而数是错的。
**这里的做法:** 拿这次实际执行的 SQL,和口径里的 SQL 判断是否等价。
- `sql_semantics.normalize_sql` 把字面量换成占位符,所以日期、门店这类参数不同不算不同。
- `explain_sql_diff` 只有在两条 SQL 规范化之后一字不差时才判为等价,否则一律判为不等价,并给出非空的差异条目。
**这样出错的方向就反过来了**:会出现「本来能认的没认出来」,但只会少给信任,不会错给信任。
#### 3.4.3 口径对账
不等价时也不沉默,要说清差在哪、差多少。示例(虚构):
> 按你的问法是 1,234,按已治理口径「周销售」v3 是 1,180。差别:这次按下单时间筛选,口径按支付时间;这次没有排除部分退款。
- **差在哪**:由 `explain_sql_diff` 给出。双盲复算、运行对比已经在用它。
- **差多少**:要把口径在同一份数据上再跑一次。只读,用户点开时才跑。差值在口径卡里算,不让叙述层做算术。
#### 3.4.4 给每个数字标口径
- 现在答案里的数已经能点开看出处(`[[v:Q1.r0.列名]]`)。
- 在这之上再加一层:这个数来自「已治理口径 vN」(等价已证明),还是「这次的推理」。
- 业内是给整条答案或整张图打一个「已验证」。
#### 3.4.5 双向闭环:沉淀和召回
**沉淀(推理 → 承诺)**
- 触发:同一类问题反复出现(用聚类接口发现),或者某个探索答案被反复引用、导出。
- 流程:进认证队列 → 用现有的「从运行提取模板」提取 → 人在画布上审改、发布。
- 模板会记住自己是从哪些问题沉淀来的。
- 这一块业内有类似的推荐队列(Cortex),不新。
**召回(承诺变了 → 回头处理已经给出去的答案)**
- 触发:口径升版、目录里的事实被驳回、表结构有变动。
- 查找:顺着证据链反查所有建立在旧版本上的答案,包括问数的每一轮、导出的报告、定时推送。
- 处理:在新版本上重算,只有数真的变了才通知,附上差异,可以一键重新出具。
- 这样,路线里「口径升版是必须处置的事件」,就从正式运行延伸到了所有答案。现有的血缘工具只会通知下游报表;已经交付出去的 AI 答案,没查到有人回头追。
#### 3.4.6 一个问题的三条路径
```
问题
│ 助手查看:表 + 已治理的口径(look 的扩展)
▼
有没有一份已治理的口径,绑定参数后与整个问题等价?
├─ 有 ──► 路径一:跑那个版本的正式运行 ──► 三档出具(正式 / 降级 / 不出具)
│
└─ 没有 ──► 拼图:钉住版本的口径子图 + agent 填缺口 ──► 探索运行
│
▼
跑完后,把这次实际执行的 SQL 和相关的口径比对
├─ 等价(字面量不算)──► 路径二a:数字标「来自口径 vN」,可一键按该口径正式出具
├─ 不等价但相关 ────────► 路径二b:口径对账(差在哪、差多少;点开时才算)
└─ 没有相关的口径 ──────► 路径三:纯探索,数字标「这次的推理」
```
#### 3.4.7 守住的决定
| 决定 | 怎么守住 |
|---|---|
| 没发布的图只能跑探索运行;正式运行只能从发布的版本发起 | 拼出来的图整轮是探索运行;只有路径一跑正式运行 |
| 叙述层不做算术 | 对账的差值在口径卡里算 |
| 口径升版是必须处置的事件 | 召回把它延伸到所有已给出的答案 |
| 不做「从指标定义编译出 SQL」的语义层 | 不从自然语言编译 SQL,只比较「实际执行的 SQL」和「口径里的 SQL」 |
| 数据目录只放事实 | 口径公式仍然放在口径卡里 |
#### 3.4.8 账本记什么(概念)
| 记录 | 内容 |
|---|---|
| 答案 → 口径 | 每个数来自哪份口径(`workflow_id@vN`、口径卡、查询快照),或标为「这次的推理」;等价判断的结果和差异条目 |
| 口径 → 答案 | 某份口径的某个版本,被哪些答案用过:问数的轮次、导出的报告、定时推送 |
| 口径 → 来源问题 | 这份口径是从哪些问题沉淀来的 |
物理上建在现有的证据链之上(内容寻址的工件、表结构快照、查询快照、封存事件),不另起一套存储。
### 3.5 和业内对照
| 做法 | 业内现状 | 判断 |
|---|---|---|
| 在对话里问,从认证过的资产里答 | Databricks Genie(verified answer)、Power BI(verified answers)、Snowflake Cortex Analyst(verified query repository) | 不新 |
| 按使用情况推荐要认证的东西 | Snowflake 的 verified query suggestions(2025-10-09 起预览):按使用情况和查询历史推荐,人工审核 | 不新 |
| 对话和可审计的工件是同一个东西的两个视图 | Hex Threads(2025-10-01):每段对话背后是一个 notebook 项目 | 不新 |
| 自由提问改为以指标为锚 | Tableau 的 Ask Data 于 2024 年下线,由 Pulse(指标层)接替 | 业内趋势 |
| 同一套画布分成对话型和一次性两种应用 | Dify 的 Chatflow 和 Workflow(v0.6.0,2024-04) | 不新(佐证不合并界面) |
| 靠证明等价来认口径,失败时只少给信任 | 没查到,业内都是匹配 | **新** |
| 不等价时自动对账:差在哪、差多少 | 专门查过,没查到 | **新** |
| 一个答案里逐个数字标口径 | 没查到,业内是整条答案或整张图打标 | **新** |
| 口径变了,追回已交付的 AI 答案并重新出具 | 血缘工具只通知下游报表 | **新** |
「新」的意思是:这次检索的公开资料里没查到,不保证一定没人做过。
### 3.6 已有的零件和缺口
| 需要的能力 | 已有的零件 | 还缺什么 |
|---|---|---|
| 钉住版本调用已治理的工作流 | 子图节点(工作流 + 版本) | 助手能查看、选用已治理的口径(look 的扩展) |
| 判断等价,参数不算不同 | `sql_semantics.normalize_sql`、`explain_sql_diff` | 和已治理口径批量比对;绑定参数 |
| 说清差异 | 双盲复算、运行对比在用 | 用到问数的答案上 |
| 沉淀 | 从运行提取模板、探索问题聚类接口 | 和问数轮次的关联;认证队列;界面入口 |
| 召回 | 证据链(表结构快照、查询快照、口径版本、封存)、目录漂移事件 | 反查「谁用过这一版」;重算后比对;通知 |
| 问数 → 画布 | 「在画布里打开」 | 复制后保留和原问题、原运行的关联 |
### 3.7 对这个方案本身的反驳和风险
1. **判等价太严,可能几乎认不上。** agent 写的 SQL 和模板的写法常常不一样,结果大多数问题都落到对账,路由的价值就小了。用实验 E1 来量。
2. **冷启动。** 本机只有 6 个已治理的工作流。口径少的时候,这套机制几乎不起作用,所以沉淀要先跑起来。
3. **一个答案里有两种数,界面难做。** 但这正是「照实说」,难在设计,不在方向。
4. **对账要多跑一次查询。** 只在点开时跑,走现有的只读守卫和时限,不自动跑。
5. **召回可能太吵。** 只在重算后数真的变了才通知。
6. **助手搭拼装的图更难。** 前提是助手能查看口径资产,这正好接上第二部分的「查看」。
7. **开发库的数据代表性不够。** 本机是开发者自测数据。结论要等真实使用的数据,来自部署实例或者以后的遥测。
### 3.8 先做实验再决定(不写功能代码)
> **已修订(2026-10,X12、02 做法 10)**:用户决定直接做,不先跑一次性实验;E1–E4 改成账本和评测持续给出的统计(`ledger/stats.py`、`GET /api/ledger/stats`,评测页的「口径命中与重复度」页签),判据照下表作参考,不再作开工的门槛。
脚本放在仓库外,只读。用开发数据的副本(放在 /tmp,复制需要你放行);部署实例只用只读接口。判据是建议值,可以调。
| 实验 | 方法 | 建议判据 |
|---|---|---|
| E1 等价命中率 | 拿问数历史里 agent 实际执行过的 SQL,和已治理工作流里的取数 SQL 批量判等价,统计命中率和差异类型的分布 | 用到相同表的问题里,命中率 ≥ 30% → 值得做路由;< 10% → 先不做路由,只做对账;介于两者之间 → 先加强规范化 |
| E2 对账看不看得懂 | 挑 10 对有对应口径但不等价的,在同一份数据上各跑一次(只读),看差多少、差异说明是否准确易懂 | 至少 8 对的说明既准确又看得懂 |
| E3 问题重复度 | 用聚类接口统计问数问题里,成员在 3 个以上的类占多大比例 | ≥ 20% → 沉淀有价值 |
| E4 召回规模(可选) | 统计每个已治理工作流的版本被多少答案引用,估算一次升版会召回多少 | 用来定通知的方式,不作为是否立项的依据 |
两项关键判据都通过,才按「组合式问数 → 对账 → 召回」的顺序立项;只有 E2 通过,就只做对账。
### 3.9 和近期方案的衔接
- 第二部分的「查看」是这套设想的第一块地基:助手从「被喂」变成「会查」。
- 下一步,可查看的东西从「表」扩展到「已治理的口径」。
- D4 里问数据的那条规则以后会变成:先看有没有已治理的口径,再决定交给 agent 还是走路径一。
- 第二部分 2.11 预留的扩展点,就是为这一步准备的。
---
## 第四部分 待拍板
**近期(第二部分)**
1. **D1**:用逐行输出里的 `look`(推荐),还是原生工具调用?
2. **D6**:遮罩规矩一并用到运行时的 `db_schema__` 和 `/schema` 接口(推荐),还是只用在助手上?
3. **前提**:等 feat/catalog-loop 合进 main 再开工(推荐)?
4. **上限**:每轮最多查看 3 次、自查修正时另给 1 次、一次结果最多 12,000 字,这几个数可以吗?
**远期(第三部分)**
5. 要不要先做实验 E1–E3?需要放行把开发数据目录复制到 /tmp。
6. 3.8 里的判据是否认可?
---
## 附录 A 代码位置索引
| 位置 | 内容 |
|---|---|
| `backend/app/api/copilot_context.py:118` | 挑表提示词:「需求和数据库无关时返回空列表」 |
| `backend/app/api/copilot_context.py:223` | `_modes`:按库的大小定每个库怎么给 |
| `backend/app/api/copilot_context.py:249` | `graph_tables`:现有工作流 SQL 用到的表 |
| `backend/app/api/copilot_context.py:279` | `plan_context` |
| `backend/app/api/copilot_context.py:333` | `_ask`:挑表调用,结构化输出失败时退回纯文本 |
| `backend/app/api/copilot_context.py:427` | 挑表返回空被记成失败「模型未挑出任何表」 |
| `backend/app/api/copilot_context.py:451` | `_assemble` |
| `backend/app/api/copilot_context.py:493` | `_complete`:补连接用的中间表 |
| `backend/app/api/copilot_context.py:570` | `DatasourceContext.op`:发给前端的 context 操作 |
| `backend/app/api/copilot_context.py:599` | `DatasourceContext.section`:system 里的数据源一节 |
| `backend/app/api/copilot.py:75` | `_tool_catalog`:每个库列前 12 个表名 |
| `backend/app/api/copilot.py:111` | `_pick_need`:拼上前 3 轮问题 |
| `backend/app/api/copilot.py:134` | `_datasource_context`:非流式的三个入口共用 |
| `backend/app/api/copilot.py:371` | 问数据(intent=answer)的搭图约定 |
| `backend/app/api/copilot.py:459` | `_history_section` |
| `backend/app/api/copilot.py:730` | `/generate`(非流式) |
| `backend/app/api/copilot.py:838` | `_STREAM_PROTOCOL` |
| `backend/app/api/copilot.py:878` | `_parse_op_line` |
| `backend/app/api/copilot.py:1004` | `_apply_op` |
| `backend/app/api/copilot.py:1090` | `_iter_ops`:主模型只读流式文本 |
| `backend/app/api/copilot.py:1327` | 自查 `sql_unknown_column` |
| `backend/app/api/copilot.py:1779` | `_repair_request` |
| `backend/app/api/copilot.py:1800` | `/generate-stream` |
| `backend/app/api/copilot.py:1816` | `system_for` |
| `backend/app/api/copilot.py:1864`–`1891` | 开流前规划、流里挑表、组装 system |
| `backend/app/api/copilot.py:2114` / `:2296` | `assist_publish_fix` / `assist_upgrade` |
| `backend/app/api/copilot.py:2496` | `/copilot/from-run`:轨迹 → 模板 |
| `backend/app/api/governance.py:90` | `/governance/exploratory-clusters` |
| `backend/app/data/introspect.py:30` | `_PREVIEW_COLUMNS = 6` |
| `backend/app/data/introspect.py:425` | `describe_table` |
| `backend/app/data/catalog.py:1152` | `resolve_table_name` |
| `backend/app/data/catalog.py:2010` | `render_table_index` |
| `backend/app/data/engine.py:370` | `masked_columns` |
| `backend/app/data/sql_semantics.py` | `normalize_sql`、`explain_sql_diff`(模块说明在文件开头) |
| `backend/app/engine/compare.py:123` | 子图节点的配置项 |
| `backend/app/tools/datasource.py:176` | `_query_description`:查询工具说明列前 25 个表名 |
| `backend/app/tools/datasource.py:647` | `_make_schema_tool`:运行时的 `db_schema__` |
| `frontend/src/run/decode.ts:3041` | `CONTEXT_FALLBACK_TEXT` |
| `frontend/src/run/decode.ts:3086` | `case 'model'`:0 张表的说法(从未用到) |
| `frontend/src/store/studio.ts:1896` | `case 'context'` |
| `frontend/src/lib/terms.ts:2863` | 「参考了 N 张表的字段」 |
| `frontend/src/pages/ChatPage.tsx:298` | 「在画布里打开」:复制成新工作流 |
| `frontend/src/pages/runs/RunDetailView.tsx:403` | 「提取模板」 |
| `frontend/src/api/client.ts:638` | 聚类接口(没有调用方) |
## 附录 B 术语
| 术语 | 含义 |
|---|---|
| 一轮 | 用户发一句话,到助手这次答完(含自查修正) |
| 查看(look) | 助手在一轮中主动要求看表结构;一次查看给齐挑表结果、全部字段、数据目录、连接条件 |
| 挑表 | 用挑表模型按需求从表目录里挑出相关的表;新方案里是查看内部的一步 |
| 数据源概览 | system 里每个库一行的介绍,不含任何表 |
| 系统查看 | 不需要人参与的路径上,由服务端代模型点名查看 |
| 口径 | 一个指标的算法定义。在本项目里落在口径卡(metrics 节点)和取数 SQL 上,发布后带版本 |
| 已治理 | 工作流的发布状态之一(draft → published → governed) |
| 探索运行 / 正式运行 | 运行类别。没发布的图只能跑探索运行;正式运行只能从发布的版本发起,按三档出具 |
| 三档出具 | 正式出具、降级出具、不出具 |
| 子图钉版本 | 子图节点按 `workflow_id` + `workflow_version` 调用固定版本的工作流 |
| 等价 | `explain_sql_diff` 判定两条 SQL 规范化之后一字不差;字面量不计 |
| 口径对账 | 不等价时说清差在哪、差多少 |
| 召回 | 口径或目录事实变了以后,找出用过旧版本的答案、重算并通知 |
| 口径账本 | 记录「答案 ↔ 口径 ↔ 来源问题」三方关系的账,建在证据链之上 |
## 附录 C 参考资料
- [Databricks:Tune Genie Agent quality(verified answer)](https://docs.databricks.com/aws/en/genie-agents/tune-quality)
- [Azure Databricks:Use trusted assets in Genie spaces](https://learn.microsoft.com/en-us/azure/databricks/genie/trusted-assets)
- [QueryPlane:Databricks AI/BI Genie in Practice(资产过期问题)](https://queryplane.com/blog/databricks-ai-bi-genie-in-practice/)
- [Kumar Gauraw:Genie Agents setup, testing & governance](https://www.gauraw.com/databricks-genie-agents-setup-testing-governance/)
- [Snowflake:Verified query suggestions](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst/verified-query-suggestions)
- [Snowflake 发布说明 2025-10-09:Verified query suggestions(预览)](https://docs.snowflake.com/en/release-notes/2025/other/2025-10-09-verified-query-suggestions)
- [Snowflake:Cortex Analyst REST API](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-analyst/rest-api)
- [Power BI:Prepare your data for AI — Verified answers](https://learn.microsoft.com/en-us/power-bi/create-reports/copilot-prepare-data-ai-verified-answers)
- [Tableau:Metrics and Natural Language Query Evolve with Tableau Pulse](https://www.tableau.com/blog/tableau-metrics-and-natural-language-query-evolve-tableau-pulse)
- [Tableau:Ask Data](https://help.tableau.com/current/pro/desktop/en-us/ask_data.htm)
- [Dify:Workflow & Chatflow](https://docs.dify.ai/en/cloud/use-dify/build/workflow-chatflow)
- [Hex:Introducing Threads](https://hex.tech/blog/introducing-threads/)
- [Hex:Fall 2025 Launch](https://hex.tech/blog/fall-2025-launch/)
- [ThoughtSpot:Release history](https://docs.thoughtspot.com/cloud/26.9.0.cl/release-history)
- [Sigma:Ad hoc analysis](https://www.sigmacomputing.com/blog/ad-hoc-analysis-and-reporting-definition)
- [Google Cloud:What is data lineage](https://cloud.google.com/discover/what-is-data-lineage)
- [Atlan:Data lineage explained](https://atlan.com/data-lineage-explained/)
## 附录 D 测量方法
- 所有数字都通过本机后端的只读 GET 接口取得,没有复制数据库,也没有调用模型:
- `/api/datasources`
- `/api/datasources/{id}/schema`
- `/api/datasources/{id}/catalog`
- `/api/conversations`
- `/api/workflows`
- `/api/runs`
- 小库的「详细摘要」字数:`/schema` 接口返回的 `summary`,与 `_detail_block` 第一段用的是同一个函数 `introspect.summary(detail=True)`。
- 大库的「只列表名」字数:按表名用「、」连起来,再加约 40 字的标题行估算。
- 数据目录和连接条件那一块没有逐字重建;按小库里有目录的表数(52 张)判断,会用满 6,000 字的共用上限。
- 节点手册、输出协议、写 SQL 的硬性要求、内置工具清单的字数:在后端环境里直接导入模块,取常量的长度。
- 2.7 秒和 7.8 秒来自界面截图。挑表失败的原因没有落库,「挑表返回空」是根据用时(远低于 20 秒时限)和挑表提示词推断的;后端终端日志里应该能看到「助手挑表未成功(模型未挑出任何表)」。
+179
View File
@@ -0,0 +1,179 @@
# 下一轮总方案:评测、口径账本、目录维护、对外集成、遗留
> 状态:已拍板(2026-10-16):P1 英文界面本轮不做(只补集成接口错误码,第 5 波取消);P2 每波过关就合 main 并推送;P3 容器集成测试 + 验收时开发源只读账号;产品取舍表全按推荐。开工。代码基准 main `d135a17`。
> 依据:五路只读调研(结论落到文件:行号,细案见 01–05)。真实库名、表名、计数都不进本文;验收一律用虚构库。
## 0. 范围
用户 2026-10-16 选定:
| 编号 | 块 | 一句话 |
|---|---|---|
| 1 | 效果度量与回归评测 | 一把尺:题集 + 由 SQL / 口径算出的期望答案,换模型、改提示词、改目录后自动回归,能和上一次比、能做 A/B |
| 2 | 口径账本 | 问数据和已治理口径接起来:认口径靠证明等价,答案里逐个数标口径,不等价时对账,口径变了追回已交付的答案,重复的问题沉淀成口径 |
| 4 | 数据接入与目录维护(不含新数据库类型) | 几千张表的库能用;表结构变化、取值漂移看得见、能处理;可定时维护;剖析方言修正;Excel 期 5 四项 |
| 7 | 对外集成与触达 | 访问密钥(服务账号)调接口;邮件与待办汇总;安全头与分享链接;英文界面(层级待定) |
| — | 已知遗留 | 驳回≠失败、使用者选人、导入清单的认证标记、自由形蓝图「谁来做」、剖析结果进 look,外加几处小口 |
不在本轮:行级数据权限(3)、生产化运维(5:保留清理、备份、迁移体系、高可用)、工程治理(6:CI、检查用种子数据、发版),以及新的数据库类型。评测会顺带做「评测运行的清理与工件回收」,但不是通用的保留策略。
## 1. 守住的理念和不可回退的决定
- **照实说**:不夸大、不悄悄降级;失败说清原因和下一步;算不了的写原因,不混进「错」。
- **留痕**:谁、何时、依据什么都查得到;封存的东西不改写。
- **不挡生成**:辅助步骤(比对、召回、观察、计量)失败不让主流程失败。
- **只读与遮罩**:不写业务库;模型、使用者、通知、外部调用方都看不到遮罩值。
- **一种说法**:同一件事一个叫法、一套机制。
- **模板定骨架、agent 填关节**;**界面层不放逻辑**;**生效可查可对账**。
- 不可回退:叙述层无算术;口径升版是被迫处置事件;没发布的图只能跑探索运行,正式运行只从已发布未退役的不可变版本发起;不做「从指标定义编译 SQL」的语义层;数据目录只放事实,口径公式在口径卡里。
## 2. 五块的要点(细节见 01–05)
### 2.1 评测(01)
- **量的就是用户拿到的那一轮**:问数据里「要不要运行、要不要重来、重来说什么、输入怎么填」这几个决定从前端下沉到后端(`chat/turn.py`),问数据页照后端给的 `next` 走;生成过程抽成可直接调用的 `answer_ops`,SSE 只是包一层。评测和问数据页走同一条路。
- **期望答案不靠人填数**:SQL 题(裸图探索运行,封存的查询快照)、口径题(拷出钉住版本的取数节点 + `caliber_from` 口径卡)、从运行认定(拷 SQL,人确认)。作答前后各算一次,数据变了就不判。
- **评分五种结论**:对且可追溯 / 对但没引用 / 错 / 量纲不同 / 不能评分;准确率和可追溯准确率分开报,带 Wilson 区间。
- **对比与 A/B**:逐题转移 + 归因(数据变 / 做法变 / 只参数变 / 路径变),A/B 共用期望、配对表 + 精确 McNemar。「目录的贡献」= 预设 A/B(当前目录 vs 关 vs 只用已核实);起草模型对照用影子起草的目录快照,不动线上目录。
- **不污染**:运行来源 `eval`,聚类、白名单衰减、目录使用次数都只算「计入统计」的运行;评测运行可清理(含工件回收)。
- **成本照实**:助手建图、挑表、复核的用量也记(问数据页这一轮的花费随之补全)。
### 2.2 口径账本(02)
- **认口径**:`sql_semantics` 新增参数绑定 + 证明(绑定只是启发,等价只由 `explain_sql_diff` 判,失败只少给信任)。运行封存后在后台比对,结果存成内容寻址工件 `caliber_match` + 封存后事件 `ledger.matched`,不建新表。
- **逐数标口径**:来自口径 vN(等价已证明 / 助手选用)/ 相关口径(差在哪)/ 这次的推理 / 没比对。证据面板最上面多「口径」一步。
- **三条路径**:整题等价 → 按口径正式出具(自动条件见拍板 L1);不等价但相关 → 口径对账(点开才跑一次确定性派生的只读图,差值在对账卡里算);没有相关口径 → 纯探索。
- **组合式问数**:助手可以 `use_caliber` 选用口径,服务端按固定版本确定性内联实例化(取数节点照抄、口径卡 `caliber_from` + 节点改名映射)。**偏离原设计**:不用子图节点做骨架(子图证据不交回父图、使用者不能用子工作流)。
- **召回**:口径发布新版后自动反查用过旧版的答案(问数轮次、导出、定时、应用),按参数去重成组,在同一份数据上算新旧两版之差,数真变了才开待办 `recall`;目录事实被驳回、表结构变动只标「依据有变」,按新目录跑 SQL 检查出现新错误才通知。
- **沉淀**:认证队列(按 SQL 形状的确定性重复 + 只取问题的聚类 + 被反复用的答案),「固化成口径草稿」把被引用的查询变成固定 SQL,参数按跨轮变化的字面量推出,人审后发布,模板记住来源问题。
### 2.3 数据接入与目录维护(03)
- **大库**:全库对象索引(每种方言一条字典查询,上限 5 万)+ 探查范围规则(缺省 500、最多 2,000 张)+ 后台探查任务(和起草、剖析同源排队);`schema_cache` 只物化范围内的表、形状不变;look / `db_schema` 加 BM25 预筛和「按需加入」(点名范围外的表当场只读反射、落盘留痕)。「表已不在」只在真删除时说,范围外、反射失败、已删除分开说。
- **结构变化**:每张表有结构版本;重新探查后出前后差异(疑似改名只说「可能」)和影响面(目录哪些项、哪些已发布模板写死的 SQL 会坏);发布时记表的结构版本,正式运行发起时提醒(不拦)。
- **取值观察**:码值列(含人工确认、已列全的)、最新业务日期、按日行数、总行数;只和同口径的历史比;新码值、比例剧变、断档、缺天、量跌。机器唯一能改目录的事:数据证明不再列全时撤销「已列全」标记。
- **变化记录**:一张 `source_changes`,审阅页「变化」标签页,处理动作都落到目录已有的写入接口;按 `seq` 游标给召回拉取。
- **定时维护**:按数据源开、缺省关,到点 探查 → 剖析需要的表 → 取值观察,排队串行。
- **方言修正**:IN 两边类型族、列类型存方言完整写法(ENUM 进码值候选)、Oracle 小写引号名、统计过期复核、无效日期过滤、「抽样」改说「按存储顺序取前 N 行」;方言金样 SQL + sqlglot 解析;容器集成测试与开发源只读验收要用户放行。
- **Excel 期 5**:列表按期累积、穿插小计(核对后不进数据)、表格对象起草、CSV 走配方;历史重放和解析器升级比对推后;原件下载、「表格文件」节点不做。
### 2.4 对外集成与触达(04)
- **访问密钥**:服务账号(管理员建,不能登录)+ 个人访问密钥(管理员开关,缺省关);只存哈希、必设到期、可撤销与轮换;**权限一律封顶在使用者边界**(看到的都遮过);按路由白名单 `KEY_ROUTES` 放行,同一张表生成接口说明;发起入口必须带幂等键;视图数据接口 `agentlab.viewdata/1`(口径卡指标原值 + 印章 + 清单哈希);请求记录批量写、审计;开登录时收起 `/docs`;集成要用的接口补齐错误码。
- **触达**:修好个人渠道「我发起的实例完成 / 失败」从不生效的问题;加邮件(SMTP,第五种格式,只发到账号邮箱,照实写「已交给邮件服务器」);待办每日汇总;密钥到期提醒;「对外访问地址」只留一个设置。IM 应用消息推后(和 IM 登录同一套企业应用凭据)。
- **嵌入**:安全响应头(`frame-ancestors` 缺省 `'self'`,白名单可配;严格 `script-src` 先只上报);分享链接(管理员开关缺省关、必设到期、只读、只显示定时出数的视图、出处给下载报告、key 放在 `#` 后面);同站门户嵌整站只做配置和文档。
- **英文界面**:分三层(L0 基础设施 / L1 使用者面 / L2 搭建者面),本轮做到哪层见拍板。
### 2.5 已知遗留(05)
- **驳回≠失败**:人的驳回统一抛 `NodeRejected`,运行终态记 `cancelled` + 新列 `end_reason=rejected`;通知、定时推送、发布单、记录页、问数据都读这一个字段;被驳回的不能「继续运行」。顺带:重启时被改成失败的排队运行补发 `run.failed`。
- **使用者选人**:按用途给候选池(缺省「能用这个应用的人」,授权给所有人时必须输前缀),只给用户名和显示名;开登录时服务端核对,不在池里 422。
- **导入清单的「是否认证」**如实记录(旧清单不改写)。
- **自由形蓝图**:按导入图列出「谁来做」(含核实人),只定由谁做,会签和时限回画布改。
- **剖析事实进 look**:行数、日期范围按当前遮罩重滤,附「只供写查询参考、不能作为回答里的数」,四处同一段文字。
- **小口**:tick 测试改按 id 断言;目录页后台任务进度只放一处;`check-catalog-loop` 去掉剖析旧键。
## 3. 跨块统一的决定(编排者定,细案里的冲突以这里为准)
| 编号 | 决定 | 否决的备选 / 理由 |
|---|---|---|
| X1 运行来源 | `ORIGIN_KINDS` 新增 `eval`、`ledger_issue`、`reconcile`、`recall`,全部只能由服务端写。「计入统计」谓词 `counted_runs()` 排除 `eval`、`reconcile`、`recall`(系统派生的运行);`ledger_issue` 是替用户正式出具,照常计入。聚类、白名单衰减、目录使用次数、「被哪些运行用过」、账本统计都只算它圈进来的运行;记录页默认不列这三类,并写明有几次没列出 | 各块各加各的过滤:漏一处统计就失真 |
| X2 E1–E4 只有一份实现 | 由账本给出(`ledger/stats.py`,从 `caliber_match` 工件、运行、审计现算)。评测页的「口径命中与重复度」页签直接调它;评测里逐题的 SQL 差异用同一个比对函数。**评测细案的 `eval/signals.py` 撤销**。聚类函数全站一份,只取问题原文、不含 history、排除评测运行 | 评测和账本各算一份 E1:两个数对不上 |
| X3 认口径与派生图的内核共用 | `sql_semantics.bind_template / match_template` 一份;「拷出钉住版本的取数节点 + 口径卡」的派生图生成器一份(`ledger/derive.py`),评测的口径题期望、账本的对账、召回都用它 | 评测 gold 和账本 derive 各写一份:口径的取法会走样 |
| X4 定时只有一套 | `Schedule` 加 `target_kind`(`workflow` / `eval_suite` / `source_maintenance`)和 `target_id`;认领、补跑、重叠跳过、触发记录、通知都只有一套。定时页统一列出、标明种类;数据源设置里的「定时维护」是同一条定时的快捷入口。`workflow_id` 是 NOT NULL,非工作流目标存空串(只加不改的迁移改不了约束,代码里一处封装) | 目录细案的单独 `source_maintenance` 表:第二套 cron 认领与补跑;评测已经要泛化 `Schedule`,增量成本共摊 |
| X5 后台任务运行时 | `data/catalog_job_runtime.py` 上移到 `core/job_runtime.py`(原路径再导出)。评测、后台探查都用它;`SourceLanes` 新增 `introspect` 一种,同源串行(起草、剖析、探查、导入、同步起草)。召回不建任务表:计划可从已有召回运行重算 | 各写一套队列:互斥会漏 |
| X6 通知事件一次登记 | `notify/service.EVENTS` 本轮新增:`source.changed`、`ledger.recall`、`eval.regressed`、`eval.failed`、`task.digest`、`key.expiring`。消息只放标题、状态、条数、链接:不放数字,不放表名、列名、取值。被驳回的实例走「实例结束」,文字取自 `instance_state` | 各块各改 `notify/service.py`:同一文件多方冲突 |
| X7 表的说法只有 `schema_view` 一处 | 剖析事实(遗留 e)、取值观察结论(目录 B2)、检索预筛和按需加入(目录 A2)、目录视图开关(评测)都经 `schema_view` / `catalog_view` 出;look、`db_schema`、`/schema`、表助手四处同一段文字,遮罩按渲染那一刻重滤(look D6) | 各处各拼:违背 D6 |
| X8 身份与名录一次改 | `Principal` 加 `via / key_id / scopes / app_ids`(全部带默认值);名录候选池(遗留 b)与服务账号排除(集成 A1)在同一个包里改 `auth/directory.py` | 分两包改同一文件 |
| X9 终态 | 驳回 = `cancelled` + `end_reason=rejected`;评测把驳回记成「路径:被驳回」不算失败;账本、召回、发布单、定时推送都按 `end_reason` 判 | 新增运行状态 `rejected`:终态集合 6 处、前端 112 处引用,漏一处就是一直等终态 |
| X10 叫法 | 访问密钥(不叫令牌、token、凭据)、服务账号、分享链接(不叫快照链接)、结构变化 / 取值变化(和已有的「目录有变化」分开)、评测 / 题集、来自口径(受管 / 已发布)· 等价已证明 / 助手选用、这次的推理、口径对账、召回、可沉淀的问题;出具档位沿用「完整出具 / 降档出具 / 不予出具」。全部进 `lib/terms*.ts`,过 check-copy | — |
| X11 迁移 | 只加不改:新表 `eval_*`、`source_objects`、`table_observations`、`source_changes`、`access_keys`、`access_key_requests`、`api_receipts`、`view_shares`;新列 `runs.end_reason`、`schedules.target_kind/target_id`、`schedule_fires.eval_run_id`、`users.kind/locale`、`workflow_versions.schema_versions`、`catalog_profile_tasks.snapshot_id`、`artifacts.kind` 索引。升级后第一次探查只建基线,不报「类型变了」 | — |
| X12 偏离已定文档 | 口径账本:§3.4.1 子图做骨架 → 内联实例化;§3.4.2 字面量不算不同 → 先绑定参数再证明;§3.8 先做实验 → 持续统计。应用平台:Q6「通知只用 Webhook」→ 加邮件(SMTP 可选、退信不追踪、照实说)。实施时在原文档标「已修订」 | — |
## 4. 需要你拍板的问题
细案里一共约 50 条决定,大部分是架构取舍,我按推荐定了(见各细案「待拍板(本块)」)。下面是**真正需要你定**的:
**影响范围和节奏**
- **P1 英文界面做到哪层**:
- A 本轮不做,只补集成接口的错误码;
- B 做 L0 基础设施 + L1 使用者面(应用、待办、登录、我的设置、通知;服务端按用户语言出视图文案)。约 1.3k 段前端文案、约 470 处服务端字符串,之后每个新功能都要两种语言一起写;
- C 全量(再加约 1 万段搭建者面文案、约 8k 处服务端字符串)。
- 推荐 B:你选了第 7 类,L1 正好覆盖外部使用者碰得到的部分;搭建者面暂不值。
- **P2 合并节奏**:
- A 每一波关口过了就合 main 并推送;
- B 全部做完、真实验收后再问。
- 推荐 A:这一轮有 4–5 波,主干上还有别的会话在合入,长期分支冲突会越积越多。
**需要放行的操作**
- **P3 方言真库验证**:
- A 本机用 docker 起 PostgreSQL、MySQL、MariaDB、Oracle Free 四个容器跑集成测试(只在本机,拉镜像约几 GB);验收时再请你给本机 MySQL / Oracle 开发源的只读账号,只剖析 5–8 张你挑的表;
- B 只做容器测试;
- C 都不做,只靠金样 SQL 和 sqlglot。
- 推荐 A。
- **P4 真实模型验收**:评测和账本要用真实模型跑虚构库。需要一个可用的模型接入,有两种办法:
- 复制开发数据目录(含密钥,验收完删);
- 你在新数据目录里配一个接入。
- 推荐后者。部署实例上的真实题集只在 10.0.0.11 上建、跑;重新部署之后才有。
**产品取舍(都给了推荐,可以一句「全按推荐」)**
| 编号 | 问题 | 推荐 |
|---|---|---|
| 账本-L1 | 整题等价时什么情况自动发起正式运行 | 只在 Agent 独立写的 SQL 被证明等价、整题覆盖、版本受管、图里没有人工节点和写入、当天没超上限时自动;「助手选用」的只给一键 |
| 账本-L2 | 问数据运行时写 SQL 的 Agent 看不看口径定义 | 不看,保持独立解读(不然「等价已证明」只证明它抄对了) |
| 账本-L5 | 使用者能不能按口径正式出具 | 对账都可以;正式出具只限他有权使用的应用固定着的口径版本 |
| 账本-L6 | 口径升版后的召回 | 发布后自动排队重算,每次最多 50 组,数真变了才开待办 |
| 评测-D4 | 允不允许人工填写期望值 | 数值不许人填;文字要点题由裁判判,单列,不计入准确率 |
| 评测-D8 | 评测界面放哪 | 顶级导航「评测」,搭建者以上可见 |
| 评测-D9 | 预算缺省 | 同时 2 题、每题 900 秒、单次 2 美元、每日 5 美元(单独记账),首次真实验收后按实际调 |
| 评测-D12 | 多轮追问题 | 本轮只做单轮,数据模型留位置 |
| 目录-D2 | 一个数据源能不能跨多个 schema | 不能;要多个 schema 用「同一连接再接一个 schema」快捷建源 |
| 目录-D5 | 定时维护 | 做;按数据源开、缺省关、只在设定时段跑 |
| 目录-D6 | 观察人工确认过的码值列 | 照样观察;数据证明不再列全时,机器只撤销「已列全」标记 |
| 集成-I2 / I3 | 谁能持有访问密钥、能写什么 | 服务账号 + 个人密钥(管理员开关,缺省关);读、发起入口、刷新视图(必须带幂等键)、个人密钥可回复审批 / 补充输入 / 编辑稿件;新建时默认只勾读 |
| 集成-I4 / I5 | 通知渠道 | 加邮件,IM 应用消息推后;默认发给待办处理人由管理员开关,缺省关 |
| 集成-I6 | 分享链接 | 做;管理员开关缺省关、必设到期(最长 90 天)、只读、出处给下载报告 |
| 遗留-Q1 | 驳回的终态 | `cancelled` + `end_reason=rejected` |
| 遗留-Q3 / Q4 | 使用者选人 | 候选池缺省「能用这个应用的人」,只给名字;服务端核对直接生效 |
## 5. 实施组织
### 5.1 分波
同一文件只在同一波里归一个包;每波 4 个完整代理并行 → 1 个统一复核 → 修复 → 编排者合并、关口、合 main(按 P2)。代理不拆太细(每包一个完整代理 + 统一复核),一次只开一个 workflow,避免限流。
| 波 | 包(每包一个完整代理,含该包的测试) | 依赖 |
|---|---|---|
| 1 底座与独立件 | **W1-a 评测底座**:运行时上移(X5)、运行来源与计入统计(X1,四种来源一次登记)、目录视图接缝、用量计量、`answer_ops`、问数据决定下沉(前端照做)、账本泛化(三本每日账)、`Schedule.target_kind`(X4)、通知事件登记(X6) · **W1-b 遗留**:驳回≠失败(X9)、导入清单认证、tick、补发 run.failed、使用者选人候选池、自由形「谁来做」 · **W1-c 目录方言与 Excel**:方言修正、金样、容器集成脚本、Excel 期 5 四项 · **W1-d 集成底座**:安全头、对外访问地址统一、个人渠道修复、集成接口错误码、收起 /docs | 无 |
| 2 内核 | **W2-a 大库**:对象索引、范围、后台探查、`schema_view` 预筛与按需加入、剖析事实进 look(遗留 e) · **W2-b 账本内核**:绑定与证明、比对器与工件、标签接口、运行↔轮次来源链、导出审计、E1–E4 统计 · **W2-c 评测题集与期望**:模型、接口、三种期望来源、口径升版处置、目录快照与影子起草、导入导出、零售样例题集 · **W2-d 访问密钥**:服务账号、密钥、门禁、幂等、视图数据、请求记录、接口说明(含前端设置页) | 波 1 |
| 3 功能 | **W3-a 变化**:结构差异与版本、取值观察、变化记录与处理、定时维护、`schema.drift` · **W3-b 账本组合与沉淀**:问数据看口径、`use_caliber`、`caliber_from.nodes`、正式出具、对账、认证队列与固化 · **W3-c 评测执行**:后台评测、作答驱动、评分与重评、统计、对比与 A/B、清理与工件回收、题集定时与通知 · **W3-d 触达与分享**:邮件、待办汇总、密钥到期、分享链接(含 /s 页与管理) | 波 2 |
| 4 收口 | **W4-a 召回**(消费 `source_changes`) · **W4-b 评测前端** · **W4-c 账本前端**(证据「口径」一步、轮次摘要、对账视图、认证队列、recall 待办卡、版本用量与召回预告) · **W4-d 目录前端**(范围面板、分批渲染与搜索、变化标签页、表详情、定时维护、运行横幅、后台任务条) | 波 3 |
| 5 英文界面(按 P1) | **W5-a 前端** L0 + L1 · **W5-b 服务端** 语言解析、消息目录、视图文案、通知按收件人语言 | 波 4(文案定型后) |
### 5.2 每波的关口
- 代理:只跑相关测试和相关单项检查;交付前后端全量一遍(不加 `-q`)。
- 编排者:合并 → 后端全量 `-n auto` → tsc → check-copy → **这一波新增和改动过的检查脚本**(不默认重跑整套 check-all)→ 泄露扫描 → 按 P2 合 main。
- 安全审查若被限流,编排者照例翻转录核对代理的命令。
### 5.3 最终验收(真实后端,全新数据目录 + 虚构库)
- 评测:零售样例题集在 deepseek / glm 上跑「当前目录 vs 关」各 3 次;抽查 5 道题的期望值和 SQL;聚类、使用次数不受评测影响;总成本 = 各运行用量 + 建图 + 复核。
- 账本:受管口径「周销售」→ 等价的问法标「等价已证明」并自动出具;「含退款」走对账,三个数都点得开;`use_caliber` 只给一键;发布 v2 后召回规模和预告一致,只有数变了的开待办;同类问题问 3 次进认证队列,固化发布后再问标「等价已证明」。
- 目录:3,000 张表的合成库(范围、后台探查、look 挑表 ≥ 9/10、按需加入);SQLite 夹具做 ALTER 后的结构变化与影响面;取值观察四类;定时维护;方言容器测试(按 P3)。
- 集成:curl 走通访问密钥全流程(只看得到授权的应用、遮罩、幂等、撤销 401);假 SMTP 收件;分享链接与嵌入白名单;(按 P1)切到英文后使用者面只剩标「原文」的数据是中文。
- 遗留:动作确认驳回后各处都写「被驳回」;使用者选人;自由形发布一次通过。
### 5.4 规模与成本(照实估,没核实)
- 代码:评测约 1.1–1.2 万行,账本约 0.7–0.8 万,目录约 1.4–1.8 万,集成约 0.65 万(英文 L0+L1 另加约 0.4–0.5 万,加翻译),遗留约 0.18 万。合计约 4.2–5 万行,含测试约四成。
- 代理:4–5 波 × (4 个构建 + 1 个复核 + 至多 4 个修复),约 35–45 个代理。按前几轮每波约 300–400 万 token、构建 1.5–3 小时估,总计约 1,500–2,000 万 token、约 10–15 小时的代理时间,外加每波编排者合并、关口约 1 小时。限流风险按每波一个 workflow 控制。
## 6. 风险
- **文件冲突**:`db/models.py`、`db/base.py`、`main.py`、`api/copilot.py`、`notify/service.py`、`lib/terms.ts`、`types.ts` 几乎每包都碰。对策:波内按包分配文件主人;共享注册(事件、来源、迁移列表、路由)都在波 1 一次登记好。
- **错给信任**:账本唯一的错误来源是判等价判错。对策:绑定只当启发;等价只由现有判等价 + 执行守门决定;绑定渲染后的 SQL 进等价语料;自动出具只给独立等价。
- **成本与业务库压力**:评测、召回、观察、定时维护都会查业务库或调模型。对策:各自独立的每日账和上限;观察和定时维护缺省关;召回只跑口径的数据部分、不调模型;评测并发 2。
- **噪声**:漂移误报、召回太吵。对策:最小行数、PSI 阈值、同口径才比、按指纹去重;召回只在差值不为零时通知。
- **大库的 `schema_cache` 体积**:范围 2,000 张约 4–5 MB,要实测;必要时下一轮把冻结快照改成按表内容寻址。
- **单进程前提**:队列、限流、请求缓冲、按需探查锁都在进程内,和现状一致;多进程部署要换成库里的锁(本轮不做,写进 README)。
- **英文界面一旦上线**:之后每个功能都要两种语言一起写,check-copy 分语言。
## 7. 和已有文档的关系
- `copilot-look-and-caliber-ledger.md` 第三部分:按 X12 修订。
- `app-platform-and-look.md`:Q6 修订(邮件);§3.12 权限矩阵加「访问密钥」「分享链接」两行;§3.10 接口表加新接口。
- `catalog-profiling-jobs.md`:§8 的「剖析结果喂给助手」在本轮遗留 e / 目录 A2 做掉;「定时剖析」「列级漂移」在目录 B 做掉。
@@ -0,0 +1,318 @@
> 分块细案(2026-10-16 只读调研整理,代码基准 main d135a17)。和 [00-总方案](00-总方案.md) 冲突的地方以总方案为准;总方案「跨块统一的决定」一节列出了改动过的条目。决定编号在本文件内有效,总方案里引用时带前缀(如 评测-D4)。
# 效果度量与回归评测
## 缺口
- 没有一把尺:没有题集、没有期望答案、没有准确率。换模型、改提示词、改目录之后的好坏,只能靠临时验收脚本和 /tmp 里的配方判断 —— 退步要等用户撞上才知道,改进也说不清改进了多少 [scripts/check-*.mjs(只测界面);记忆 agentlab-verify-suite 的验收配方]
- 后端跑不了一轮问数据。重来、要不要运行、怎么填输入这些决定都写在前端 —— 评测要么照着抄一份(迟早走样,量出来的不是用户拿到的那一轮),要么做不了;这也违反「界面层不放逻辑」 [frontend/src/store/chat.ts:1271–1330、:1407–1456、:1754]
- 期望答案没有确定的来源:既没有「用 SQL 定期望」,也没有「用已治理口径定期望」。口径卡复算只有推演,而推演只在封存过的运行上算 —— 不做的话只能人填数,人填的数会过期、会填错,也说不清是哪个口径 [backend/app/engine/recompute.py;backend/app/look/calibers.py]
- 成本不全:助手建图、挑表、复核都不记 token 和金额 —— 按角色记成本、算每题成本都做不了;生产里问数据的花费也是低报的(违背照实说) [backend/app/api/copilot.py、copilot_gen.py、look/pick.py、engine/review.py]
- 量不了数据目录的贡献:目录在四处被读、没有统一开关、没有历史版本;评测期间目录被起草或剖析改了也不知道 —— 开目录和关目录、只用已核实的项、起草模型 A 和 B 的目录,这些对照都做不了,结果也没法复现 [tools/datasource.py:290;look/tables.py:164;data/sqlcheck.py:1087;engine/nodes/dual_check.py:792]
- 写作模型不是一个独立的角色,没法按变体对照 —— 「换写作模型」这一条对照没有落点 [engine/context.py:82;providers/factory.py:134]
- 评测运行会污染统计:聚类、白名单衰减、目录使用次数、记录页都会被刷高。聚类还把 history 一起拿去聚,E3 会偏高 —— 一跑评测,治理信号和目录的使用热度就失真了 [api/governance.py:90–125;data/catalog.py:2406]
- 删运行不回收工件引用和文件 —— 每次评测都有几十次运行和查询快照,只增不减,「可清理」做不到 [api/runs.py:479;core/artifact_store.py]
- 定时任务只能指向工作流 —— 做不了定期回归,目录被改、表结构变了带来的退步发现不了 [db/models.py:978;scheduler/service.py:280]
- E1、E3 只有设计,没有持续指标。E1 还缺「绑定参数」这一步:口径 SQL 里的 {{ }} 模板 normalize_sql 不认 —— 一次性实验量完就过期,换模型之后没人再去量 [docs/design/copilot-look-and-caliber-ledger.md §3.6、§3.8;data/sql_semantics.py:319]
- 每日账已经有两份重复的代码,评测要是再抄一份就是第三份 —— 违反一种说法;评测要是共用裁判或核查的账,就会挤占它们的额度 [engine/spend.py;engine/judge.py:1050]
- 没有统计上的诚实:模型有随机性,题集又小,没有区间、没有配对检验 —— 差两三题就可能被读成「变好了」或「变差了」 [(无)]
- 相对日期的问法不稳:提示词里没有今天的日期(只查了三个文件,没核实全部节点) —— 「上周」的理解每次可能不一样,评测结果跟着抖 [api/copilot.py;engine/nodes/llm.py;engine/nodes/report.py]
## 做法
### 1. 问数据一轮的「决定」下沉到后端
- 做法:把前端的四条决定写成后端纯函数:next_step(final):有 error 级问题时,已自查过就判失败、老后端才重来一次;autorun=false 只建不跑;reply 和 asked 直接收尾;其余运行。fill_input(graph, question, history):规则和 fillInput 相同。retry_after_review(review, attempt):retry 为真、severity=broken、attempt+1<2。retry_instruction(question, reason, has_base):两种措辞原样搬过来。/copilot/review 的结果加上 retry_instruction,final 加上 next(run|ready|fail)。前端改成照后端的决定走;老后端没有这两个字段时退回现有逻辑。评测用无头驱动 run_turn() 把这几步串起来:answer_ops → next_step → launch_run(origin=eval) → 等终态(停在审批的取消掉,记「评测不代批」)→ 复核(用从 review_run 抽出来的同一个函数)→ 需要时带原图重来一次
- 复用:engine/review.scan/should_retry/review;engine/launch.launch_run;run_manager.wait_idle/cancel;前后端常量对照测试的写法
- 文件:新增 backend/app/chat/__init__.py、backend/app/chat/turn.py、backend/tests/test_chat_turn_policy.py(读 chat.ts 比 MAX_ATTEMPTS、fillInput 的字段规则);改 backend/app/api/copilot.py(review_run 拆出 review_of_run,返回 retry_instruction;final 带 next)、frontend/src/store/chat.ts(plan、settle 照 next、retry_instruction 走)
- 对上的理念:界面层不放逻辑;一种说法:评测量的就是用户拿到的那一轮,不另抄一份;照实说:停在审批照实记
- 否决的备选:在评测里另写一份 Python 版编排:两份迟早走样,量出来的不是用户得到的结果。把整轮编排搬到后端、问数据页只负责播放:方向是对的,但要重做重连、断点、hydrate、取消,应该单独立项,本轮只搬「决定」。用无头浏览器驱动问数据页:慢、脆,成本还翻倍
### 2. 生成过程抽成可直接调用的 answer_ops
- 做法:把 event_stream(copilot.py:1806–1919)原样搬成 async generator answer_ops(payload, *, catalog_view=None),产出的 op dict 不变,SSE 路由只负责包一层 _sse。开流前的准备(_sources、load_checkers、_look_service、_graph_system)一起搬走,用自己的短会话。评测直接迭代它,拿 model/look/check/final/reply/error 这些 op
- 复用:Generation、GraphApplier、GraphChecker、_layout_keeping、validate_graph、authored_issues 都不动
- 文件:backend/app/api/copilot.py;新增 backend/tests/test_copilot_answer_ops.py(同一个剧本下,SSE 输出逐条和改之前一样)
- 对上的理念:一种说法:评测和问数据页走同一条生成路径
- 否决的备选:评测经 HTTP 调自己的 SSE 接口:要伪造请求身份,还要再解析一遍 SSE。另写一份简化的生成:量不准
### 3. 用量计量:助手建图、挑表、复核
- 做法:新增 providers/usage.py,meter() 包住 get_usage_metadata_callback,按模型累计 input/output tokens 和调用次数。金额用 catalog.estimate_cost 算,价格目录里没有的模型记 unpriced_calls,和 _usage_of_events 同一个口径。只包生成(含挑表、接着写、自查修正)和复核这两段,不包 launch:运行任务会继承上下文变量,包进去会把运行里的调用重复计一遍。answer_ops 结束时多发一条 {op:'usage', by_model, cost_usd, unpriced_calls},问数据页把它记进这一轮的 meta.usage,界面上这一轮的花费 = 助手 + 运行 + 复核
- 复用:langchain_core.callbacks.get_usage_metadata_callback(1.6.3 自带);providers/catalog.estimate_cost、unpriced_call
- 文件:新增 backend/app/providers/usage.py、backend/tests/test_usage_meter.py;改 backend/app/api/copilot.py、frontend/src/store/chat.ts(meta.usage)、显示这一轮花费的地方(lib/terms.ts runCostText 一类)
- 对上的理念:照实说:成本不低报,估不出的写「金额无法估算」;生效可查
- 否决的备选:在每个调用点手写累计:挑表、BudgetFallback、复核各一处,迟早漏掉一处。只算运行的用量:问数据一轮里建图那一大块就没算进去
### 4. 运行来源 eval 和「计入统计」谓词
- 做法:ORIGIN_KINDS 加 eval,只能由服务端写,不进 CLIENT_ORIGINS。origin 记 {suite_id, eval_run_id, case_id, role: answer|gold|gold_after, arm, repeat},workflow_name 写成「评测:题集名·第 n 题」。新增一个 SQL 谓词 counted_runs(),写法同 hidden_runs(用 coalesce 防 NULL)。探索问题聚类、工具白名单衰减、目录的使用次数和「被哪些运行用过」,以后口径账本的统计,都只算这个谓词圈进来的运行。记录页默认不列评测运行,筛选里有「评测」,列表底下照实写「另有 N 次评测运行未列出」
- 复用:engine/origin.py;auth/access.hidden_runs 的写法;list_runs 的 origin_kind 筛选
- 文件:backend/app/engine/origin.py、backend/app/auth/access.py、backend/app/api/governance.py、backend/app/data/catalog.py:2406/2444、backend/app/data/catalog_review.py:217、backend/app/api/runs.py:157、frontend/src/typesApp.ts(OriginKind)、frontend/src/lib/terms.ts(ORIGIN_LABEL)、frontend/src/pages/runs/*、backend/tests/test_frontend_type_unions.py、新增 backend/tests/test_eval_origin.py
- 对上的理念:留痕:评测运行是完整的运行,可查、证据点得开;照实说:不悄悄藏起来,写明有几次没列出;生效可查
- 否决的备选:把评测放进单独的库:证据、工件、封存都在主库里,拆开要两套会话、两套封存。跑完就删:丢了「为什么答错」的现场。不做标记:聚类和使用次数马上被评测题刷高
### 5. 目录视图:读目录统一走一个接缝
- 做法:新增 data/catalog_view.py,定义 CatalogView{mode: live|snapshot|off, statuses: 全部(去掉 rejected)| 只要 verified 和 confirmed, snapshot_artifact}。消费目录的四处一律经它取:tools/datasource._catalog_of(运行时工具和冻结快照)、look/tables.TableKind.load(助手查看)、sqlcheck.load_checkers(自查)、dual_check 的两处。运行从 Run.options['catalog_view'] 读视图(launch_run 已经收 options,会进 run.started 和封存),生成过程从 answer_ops 的参数读。缺省是 live,行为和现在一样。visible_notes 加一个 statuses 参数。冻结进表结构快照时,meta 里记下 catalog_view,证据面板能写「本次运行目录:关(评测对照)」。再加一条 lint 测试:这几个消费模块里不许直接 read_catalog,也不许直接查 CatalogNote
- 复用:catalog.visible_notes、frozen_catalog;Run.options 的快照语义
- 文件:新增 backend/app/data/catalog_view.py、backend/tests/test_catalog_view.py;改 backend/app/tools/datasource.py、backend/app/look/tables.py、backend/app/api/copilot_context.py、backend/app/data/sqlcheck.py、backend/app/engine/nodes/dual_check.py、backend/app/data/catalog.py
- 对上的理念:生效可查可对账:这一轮用的是哪份目录写进封存;一种说法:只有一处取目录;数据目录只放事实:不动目录的内容
- 否决的备选:用上下文变量传视图:审批恢复、重启续跑之后就丢了。评测期间把线上目录关掉:真实用户跟着受影响。只关提示词、不关自查:量出来的只是一半的贡献
### 6. 目录快照与影子起草
- 做法:EvalCatalogSnapshot 把选定数据源的目录(每张表的 version 加 notes)存成内容寻址工件(kind=eval_catalog_snapshot),记下谁、什么时候拍的,以及来源:当前目录,或者影子起草(用哪个起草模型、起草要求、是否先去掉模型起草的项)。影子起草调 catalog_draft.run_model_draft,Hooks 把每一段写进内存里的目录副本,合并用 catalog 的纯函数;不调 write_entry、不碰线上目录。起草只看表结构、不查业务库,所以不占数据源的队,只起草题集用得到的表,开始前先估算花费。评测的变体用快照 id 指定目录;同一份快照能被多次评测引用,用来做「起草模型 A 的目录 vs 起草模型 B 的目录」
- 复用:catalog_draft.run_model_draft 和 Hooks;catalog 的合并纯函数;artifact_store.put_json;draft_model 起草模型的取值链
- 文件:新增 backend/app/eval/snapshots.py、backend/tests/test_eval_snapshots.py;改 backend/app/data/catalog_draft.py、backend/app/data/catalog.py(需要的话把合并入口暴露成纯函数)
- 对上的理念:对照不改线上目录(只读的精神);留痕:快照不可变、记来源;一种说法:起草还是同一个起草引擎
- 否决的备选:让用户先用模型 A 起草线上目录、拍快照,再用模型 B 重新起草:为了做对照改了线上,真实用户跟着受影响。给目录加历史版本表:范围太大,评测只需要不可变的快照
### 7. 题集、题目、评测、结果的数据模型
- 做法:新增 db/models_eval.py 和 api/evals.py。
EvalSuite:名称、说明、默认变体、预算、是否归档、乐观锁 version、created_by。
EvalCase:suite_id、seq、kind(ask 问数据题 | workflow 钉版本的工作流题)、question(可以用定时任务的同一套变量)、datasource_ids、workflow 题的 workflow_id@version 和输入模板、expectations、gold 来源(sql: 数据源 + SQL 模板;caliber: workflow_id/version/node_id/metric_id + 输入模板;from_run: run_id、查询编号、确认人和确认时间)、tags(口径陷阱、拒答、清单、相对日期……)、enabled、version、case_hash。
EvalRun:suite_id;status 用后台任务的状态词;trigger manual|schedule|pair;variant 原样存一份,另存实际解析出的模型 id;fingerprint(提示词指纹、目录快照 id 和哈希、各数据源 schema 哈希、题目哈希、评分规则版本);pair_id/arm;baseline_id;预算快照;计数;按角色的用量;stop_reason、error、created_by、起止时间。
EvalResult:eval_run_id、case_id、case_hash、repeat、status(用逐项状态词)、run_id、gold_run_ids、path(reply|asked|ready|build_failed|run_failed|ran)、verdict(pass|fail|ungradable|skipped)、scores、review、tier、judge 计数、looks、calls、分段耗时、按角色的用量、sql_match、details_artifact、current(正在做什么)、error。
题目改过 case_hash 就变,和上一次比时标「题目改过,不可比」
- 复用:TimestampMixin、new_id;部分唯一索引和乐观锁的写法(CatalogProfileJob、Schedule.version)
- 文件:新增 backend/app/db/models_eval.py、backend/app/api/evals.py;改 backend/app/db/base.py(导入新模型,由 create_all 建表)、backend/app/main.py(路由挂 builder 级)
- 对上的理念:留痕;生效可查可对账:每次评测记全指纹;不做从指标定义编译 SQL:期望答案用人写的或治理过的 SQL,不从自然语言生成
- 否决的备选:一张「kind + JSON」的通用任务表:丢掉强类型列,catalog_job_runtime 的说明里已经否决过。题目存成 YAML 进仓库:部署实例上的真实题目不能进公开仓库,改题也没有留痕
### 8. 期望答案:SQL 探针和口径探针
- 做法:每道题作答前算一次期望,作答后再算一次;两次不一样就记「作答期间数据变了,不判」。
SQL 题:建一张 input → 调用工具节点(db_query__数据源,args.sql=渲染后的 SQL)→ output 的裸图,以 role=gold 跑探索运行。得到的是封存的查询快照,期望值取快照里指定的格、列或整张表。
口径题:按 look/calibers 的可复用规则,从钉住版本的图里拷出这张卡读的取数节点(id、配置都不变),后面接一个 caliber_from 指向 workflow_id@version 的口径卡,跑探索运行,期望值就是口径卡的指标(取整、单位、比率都由口径卡自己定)。不可复用的卡不能当口径题,照实写原因。
从运行认定:把某次问数据运行里的一条查询 SQL 拷进 SQL 题,人在界面上确认(记确认人、时间、出自哪次运行);之后每次评测都按这条 SQL 重算,不冻结数值。
变量:题目和 SQL 里的 {{schedule.*}} 用定时任务那份 VARIABLES/template_values,按评测的「截至时间」渲染。
保存题目时试算一次:过只读守卫;用到遮罩列的一律拒收(认法和证据面板的派生遮罩相同,认不准就拒);取不到值、超时都照实说。
A/B 两臂共用同一次算出的期望答案
- 复用:launch_run(裸图探索运行);调用工具节点;metrics 节点的 caliber_from;look/calibers.card_inputs/views_of;scheduler/service.VARIABLES/template_values;data/guard.check;engine/masking 的派生遮罩
- 文件:新增 backend/app/eval/gold.py、backend/tests/test_eval_gold.py;改 backend/app/api/evals.py(试算接口)
- 对上的理念:叙述层无算术:期望里的比率由 SQL 或口径卡算;只读与遮罩;不挡生成:期望算不出来记「无法评分」,不算模型答错;模板定骨架:口径题考的就是骨架里的那一段
- 否决的备选:发起钉版本的正式运行当期望:会被 pick_base 和应用视图的「最近一次封存」取到,正式运行也不该为了评测而发。引用最近一次封存的正式运行:数据一直在变,期望会过期。直接调 run_query 不建运行:期望没有封存快照,事后说不清当时查的是什么。人填数:见 D4
### 9. 评测执行:后台任务、并发、预算、止损
- 做法:新增 eval/runner.py,用后台任务运行时。一次评测是一个任务,每题 × 重复次数 × 臂是一个逐项任务。进度和正在做什么(正在算期望 / 正在建图 / 运行中 / 正在复核 / 正在评分)随时落库;能停,能只重试没做完的;服务重启标中断、不自动续跑,因为复制出来的数据目录启动时不能自己去调模型、查业务库。
并发:同时最多跑 max_parallel 题(缺省 2),且不超过 max_concurrent_runs 的四分之一;A/B 两臂的同一题同时开跑。每题时限缺省 900 秒,超时取消运行、记超时。
预算:开跑前按上一次的每题平均花费估算并显示;另设单次评测金额上限和评测每日金额上限(单独一本账)。每题开跑前预留,结束后按实际找补;触顶就不再开新题,剩下的记「已到上限」。
止损:连续 3 题助手调用失败,或最近 10 题里有 7 题超时,就停下,原因照实写。
不占数据源的队:开始时拍一份目录快照(缺省「当前目录」也拍),这样起草、剖析照常进行,也不影响这次评测的可比性。各数据源的上传表格版本和 schema 哈希,开始和结束各记一次,变了在结果里标出
- 复用:data/catalog_job_runtime.py(上移,见 D13);engine/spend 账本(泛化后);launch_run;run_manager.cancel
- 文件:新增 backend/app/eval/runner.py、backend/tests/test_eval_runner.py;改 backend/app/main.py(startup/shutdown)、backend/app/core/job_runtime.py
- 对上的理念:不挡生成:评测的并发和账本独立,不挤占真实用户的运行和裁判、核查的额度;照实说:停在哪、为什么;留痕
- 否决的备选:每题一个同步 HTTP 请求:几十道题要跑几十分钟,关掉页面就没了。占住数据源的队来保证目录不变:一次评测几十分钟,期间起草、剖析全被挡住。和裁判或核查共用每日账:评测一跑,当天的报告就因为到了上限而降档
### 10. 评分
- 做法:新增 eval/score.py。评分规则版本 SCORER_VERSION 记进结果,可以「按新规则重新评分」:只读封存的运行和期望快照,不再调模型、不再查库。
期望种类:scalar(值 + 允许的相对误差,可以带维度键,如 {region: 华东})、table(多行,按维度键配对,可以要求前 N 名和顺序)、set(名单)、none(期望答「没有数据」:查询返回 0 行,答案里也没有数)、ask(期望反问)、text(见 D4)。
从答案里取数:报告文档里 resolved 的 value 段(取 cite.value 原值,换算不影响),以及指标引用。正文裸写的数按出具校验同一套认法取(_NUM_RE、_SUFFIX_MULT、中文数字),容差按显示精度推。
每个期望有五种结论:对且可追溯(引用的格或指标对上了)、对但没引用、错、量纲不同(比如差 100 倍,单列)、不能评分(遮罩、期望没算出来、数据在变)。
维度配对:读被引用那一格所在快照行的非数值列,和期望的键比。期望值是 0–10 的小整数又没给键时,标「弱匹配」。
题目结论:必需的期望全对就算通过;另外单报「可追溯通过」(全部经引用)。路径不对(该查不查、该答不答)直接不通过,并写明原因。
答错的题,把引用那格所在查询的 SQL 和期望 SQL(口径题用口径取数节点的 SQL)用 explain_sql_diff 比一次,给出等价与否和差在哪个方面,这就是逐题的 E1。
数值比较用双盲复算同一个函数(把 _agree 提到公共模块),设置里也叫「允许的相对误差」
- 复用:engine/evidence 的文档结构和 iter_units;api/evidence 的封存读取(_sealed/_reports 公开成函数);issuance 的数字认法;dual_check._agree/_delta_pct;sql_semantics.explain_sql_diff;遮罩 loader
- 文件:新增 backend/app/eval/score.py、backend/app/engine/tolerance.py(_agree 搬到这里)、backend/tests/test_eval_score.py;改 backend/app/engine/nodes/dual_check.py、backend/app/api/evidence.py
- 对上的理念:叙述层无算术:叙述里自己算出来的数只能算「对但没引用」,不算可追溯;照实说:不能评分的单列,既不算错也不算对;只读与遮罩;一种说法
- 否决的备选:用模型判答案对不对(LLM 当裁判)作主指标:结果是概率性的,换个裁判模型准确率就变。只看答案里有没有这个数:巧合会被算对(3 家门店正好等于期望的 3)。拿答案原文和期望原文比字符串相似度:数字的写法千变万化
### 11. 指标汇总与统计
- 做法:新增 eval/stats.py,所有汇总在后端算好,界面直接显示:
- 准确率和可追溯准确率,附 Wilson 95% 区间和样本数;
- 作答路径的分布;
- 出具档位分布:声明了出具的按 formal/degraded/withheld 分,没声明的单列「探索问答」;
- 复核结论分布(ok/annotated/rewritten/reverted/failed × degraded/broken),以及自动重来的次数;
- 结论裁判:变体里打开了才判,走按需裁判同一条路,事件落在封存之后(post_seal),花费记在评测的账上;按 supported/partial/contradicted/insufficient/未判 分;
- look:每题次数(分模型和系统)、触顶比例;
- 主模型调用次数;
- 耗时:建图、运行(墙钟和执行)、复核、整题,给 P50/P90;
- token 和成本按角色分:助手 = 建图 + 挑表 + 复核;写作 = 运行里的 llm.end,按 model 分;裁判;期望探针为 0;
- 报告引用率:numbers_cited/numbers、uncited_numbers、unresolved、unknown_entities;
- 逐题 E1 命中率。
重复跑 k 次的题给通过率
- 复用:runner._usage_of_events;engine/labels 里现成的叫法(ISSUANCE_LABEL 等);doc.stats
- 文件:新增 backend/app/eval/stats.py、backend/tests/test_eval_stats.py
- 对上的理念:照实说:给区间和样本数;界面层不放逻辑
- 否决的备选:让前端自己算分布:同一个数会有两套算法。只给一个总分:看不出退步出在建图、查库还是写作
### 12. 和上一次比、A/B 对照
- 做法:选基准:同一题集、同一变体(模型、目录模式)里,比这次先发起、已经结束的最近一次;也可以用用户钉住的基线。选法同 pick_base,按发起先后,不按结束时间。
逐题转移:通过→不通过(退步)、不通过→通过(进步)、不变。
变化归因:期望值变了 →「数据变」;期望没变、答案执行的 SQL 规范文本不同 →「做法变」;只有字面量不同 →「只参数变」(多半是时间范围理解变了);作答路径变了 →「路径变」;其余写「说不清」。SQL 的 same/params_only/changed 和运行对比用同一条规则。
顶部列出两次之间指纹里哪几项变了:助手模型、写作模型、目录快照、提示词指纹、表结构、题目、评分规则。
A/B:一次发起两臂(共用 pair_id),共用期望答案,逐题同时跑。给出两臂各自的准确率、配对表(都对 / 都错 / 只 A 对 / 只 B 对)和精确 McNemar 双侧 p 值;p≥0.05 时写「这批题上看不出差别」。
「目录的贡献」就是一组预设的 A/B:当前目录 vs 关,可以再加第三臂「只用已核实和已确认的项」
- 复用:api/compare.pick_base 的选基准规则;engine/compare 的 SQL 三分规则;sql_semantics.normalize_sql
- 文件:新增 backend/app/eval/compare.py;改 backend/app/api/evals.py
- 对上的理念:生效可查可对账;照实说;一种说法:变化的叫法和运行对比一致
- 否决的备选:两臂先后各跑一整批:两批之间数据变了,分不清差别来自目录还是来自数据。只比总准确率:「修好 3 题、弄坏 3 题」会被掩盖
### 13. 持续指标 E1、E3
- 做法:新增 eval/signals.py,做成一个「扫描」后台任务,可以手动跑,也可以跟着题集的定时一起跑。默认窗口 30 天,只看计入统计的问数据运行(counted_runs 且 origin 为 chat)。
E1 口径命中率:口径集合取 CaliberKind(各工作流最新已发布、未退役版本里的源头口径卡)。每张可复用卡的取数 SQL 用 sqlcheck.tool_query 取出,先把 {{ }} 换成占位字面量再规范化,这一步就是设计文档 §3.6 里的「绑定参数」。问数据运行里 agent 执行过的查询(取查询快照,去掉核查角色的)只和同一数据源、用到相同表的口径 SQL 比,explain_sql_diff 带上 schema_cache。指标是用到相同表的查询里等价的比例,附差异方面的分布和最常见的不等价配对(留给以后的口径对账看)。结论按 (SQL 指纹, 口径 SQL 指纹) 缓存。
E3 问题重复度:取问数据各轮的问题原文(conversation_turns.question,不含 history;同一轮里的自动重来只算一次),用本地向量贪心聚类,阈值同现有接口的 0.62。指标是成员不少于 3 的类覆盖的问题占比;另给规范化后原文完全相同的比例,当下限;记下用的是哪个向量模型。
两条都画趋势,参考线用设计文档 §3.8 的判据(E1 ≥30% / <10%,E3 ≥20%)。
顺手修 /governance/exploratory-clusters:改用同一个聚类函数,只取问题,排除评测运行。成员不少于 3 的类可以「加入题集」(只带问题,期望答案仍要按 SQL 或口径给),也可以「提取模板」
- 复用:look/calibers;sqlcheck.tool_query;sql_semantics;memory/embeddings.embed_texts/cosine;governance 的聚类;后台任务运行时
- 文件:新增 backend/app/eval/signals.py、backend/tests/test_eval_signals.py;改 backend/app/api/governance.py
- 对上的理念:生效可查:指标可以复算,扫描结果留痕;照实说:向量模型弱时写明这是下限;不做从指标定义编译 SQL:只比较实际执行的 SQL 和口径里的 SQL
- 否决的备选:照设计文档做一次性实验脚本:量一次就过期,换模型、改提示词之后没人再量。聚类继续用整份 input JSON:history 会把同一段对话里的不同问题聚成一类,E3 偏高
### 14. 口径题的升版处置
- 做法:口径题钉 workflow_id@version。开跑前对探针图调一次 unresolved_caliber_upgrades,和正式运行用的是同一个判断。有了新的已发布版本,或者钉住的版本退役了,这道题就跳过,写「口径已升版到 vN,待处置」。题目上三选一:recompute(改钉新版本,历史在这里断开,和上一次比时标不可比)、dual(新旧两版各算一个期望,都报)、incomparable(旧题归档,按新版本另建新题、重开基线)。没有默认值
- 复用:engine/governance.unresolved_caliber_upgrades;UPGRADE_POLICIES 及其叫法
- 文件:backend/app/eval/gold.py、backend/app/api/evals.py
- 对上的理念:口径升版是被迫处置事件
- 否决的备选:自动跟到最新版本:上周和这周的分数悄悄换了口径。继续用旧版本又不提示:评测一直在量一份已经退役的口径
### 15. 定时与通知
- 做法:Schedule 加 target_kind(workflow | eval_suite)和 target_id。题集的定时把 workflow_id 存成空串(SQLite 放宽不了 NOT NULL,见风险),input_template 留空,run_class 不用。fire_once 按 target_kind 分支:是 eval_suite 就建一次评测,变体用题集的默认,基准用上一次同变体的那次。上一次评测还没结束时记 skipped_overlap;错过的触发按同样的宽限规则补。ScheduleFire 加 eval_run_id。通知:Webhook 和站内渠道各加两类事件,eval.regressed(有退步)和 eval.failed(评测失败)。消息里只有题集名、通过数、退步题数和链接,不放期望值,也不放答案里的数。定时页新建时先选「工作流」还是「评测题集」
- 复用:scheduler/loop 的认领与补跑;cron;ScheduleFire;notify/core 的发送与签名;notify/service 的订阅
- 文件:backend/app/db/models.py(Schedule、ScheduleFire 新列)、backend/app/db/base.py(_COLUMN_MIGRATIONS)、backend/app/scheduler/service.py、backend/app/scheduler/webhook.py、backend/app/api/schedules.py、backend/app/notify/service.py、前端定时页,新增 backend/tests/test_eval_schedule.py
- 对上的理念:一种说法:定时只有一套;遮罩:通知不带数
- 否决的备选:题集自带 cron 和一套调度:两个地方设定时,两套补跑规则。只支持手动:「每周换模型前后」之外的退步(目录被改、表结构变了)发现不了
### 16. 清理与保留
- 做法:设置项 eval.keep_runs:缺省每个题集保留最近 20 次,加上钉住的基线和被 A/B 引用的那一对。更早的评测由清理任务删掉:评测结果行、明细工件、底下的运行(事件跟着级联删),再删这些运行在 artifacts 里的引用行。工件文件要等删完之后一条引用都不剩才删,因为同样的内容可能被别的运行引用。每次评测的总表(分数汇总)永久保留,界面上写「明细已清理」。新增 artifact_store.forget(ids),按这条规则回收。评测运行全是探索运行,删正式运行的那道拦截不受影响
- 复用:delete_run 的规则;artifacts 引用表
- 文件:新增 backend/app/eval/cleanup.py、backend/tests/test_eval_cleanup.py;改 backend/app/core/artifact_store.py
- 对上的理念:可清理;留痕:总表不删;照实说
- 否决的备选:不清理:每次评测几十次运行加查询快照,库和工件目录只涨不降。按时间一刀切:会把还在当基线的那次删掉
### 17. 设置、权限与账本
- 做法:设置加一组 eval:max_parallel、per_case_timeout_s、per_run_max_usd、daily_max_usd、keep_runs、裁判缺省开关,照现有规矩 builder 读、admin 写。engine/spend.py 泛化成 Ledger(key),三本账各管各的:核查 verify_spend、裁判 judge_spend(judge.py 的 _charge/_settle 改用它,行为不变)、评测 eval_spend。新增 GET /api/settings/eval/spend;get_settings 不回写这本账。评测接口整组挂 builder 级(同定时、治理),期望值和查询原文只给 builder 及以上;使用者看不到评测页,也查不到评测运行
- 复用:engine/spend.py;裁判的账;settings 的 role_by_method
- 文件:backend/app/engine/spend.py、backend/app/engine/judge.py、backend/app/api/settings.py、backend/app/main.py,新增 backend/tests/test_spend_ledger.py
- 对上的理念:不挡生成:账本各自独立;一种说法:第三本账不再抄一份代码
- 否决的备选:评测不设上限:一次误操作就可能跑出 3 臂 × 5 次 × 100 题。和核查共用一本账:互相挤占
### 18. 界面
- 做法:顶级导航加「评测」,builder 以上可见,排在设置前面,快捷键 ⌥0。各部分:
- 题集列表:最近一次通过率、趋势、定时。
- 题集详情:题目表。加题有四种方式:写 SQL 定期望(选数据源、写 SQL、点选格或列、试算);选口径(和蓝图助手同一份口径清单:工作流|口径卡|指标);从问数据运行认定;期望没有数据或期望反问。有口径升版待处置的题,顶部给提示。
- 发起评测:选变体(助手模型、写作模型、裁判模型或不判,目录选当前 / 快照 / 只用已核实和已确认 / 关,重复次数),显示预估花费,预设「目录贡献对照」。
- 评测详情:进度和每题正在做什么,能停止、能重试;总览卡片和各项分布;逐题表列出结论、期望和答案里的数、有没有引用、SQL 差在哪、look、耗时、花费,点开去运行详情。
- 对比:指纹差异、转移、归因。
- A/B 配对表。
- 「口径命中与重复度」页签:E1、E3 趋势,最近一次扫描,成员不少于 3 的类,可以加入题集或提取模板。
叫法统一放进 lib/terms.ts 的 EVAL_TEXT。记录页里的评测运行显示来源「评测」,能跳回对应的那道题
- 复用:Tabs、StatusPill;runs/Telemetry 的读数样式;CommandPalette 的 PAGES;对比页的转移展示;剖析任务的进度展示
- 文件:新增 frontend/src/pages/evals/*、frontend/src/typesEval.ts、scripts/check-evals.mjs;改 frontend/src/components/CommandPalette.tsx、frontend/src/App.tsx、frontend/src/api/client.ts、frontend/src/lib/terms.ts、frontend/src/pages/runs/*、前端定时页、scripts/check-shell.mjs(导航高度)
- 对上的理念:界面层不放逻辑;一种说法
- 否决的备选:放进设置页:评测是日常工作,不是配置。放进治理页:现在没有治理页,治理接口在界面上一个调用方都没有
### 19. 题集导入导出与零售样例题集
- 做法:题集可以导出成 JSON(题目、期望来源、变量、标签;不含任何期望值、答案或运行),导入时按数据源名对上。仓库里附一份「零售样例题集」,配 retail.py 的虚构库,包含:支付日归属、退款、取消、华南高于华东;一道问「华中区」、期望答没有数据的题;一道问法含糊、期望反问的题。用于演示、验收和真实模型回归。真实业务的题集只在部署实例上导入,不进公开仓库
- 复用:catalog_transfer 的导出导入做法;tests/fixtures/acceptance/retail.py
- 文件:新增 backend/app/eval/transfer.py、backend/tests/fixtures/eval/retail_suite.json
- 对上的理念:真实数据不进公开仓库;只读
- 否决的备选:把题集硬编码在测试里:部署实例上用不了
## 待拍板(本块)
- D1 问数据一轮的「决定」(要不要运行、要不要重来、重来时说什么、输入怎么填)放在哪|选项:A 下沉到后端(chat/turn.py),问数据页照后端给的 next 和 retry_instruction 走,评测用同一套函数 / B 评测里另写一份 Python 版编排,加前后端对照测试防止走样 / C 整轮编排搬到后端,问数据页只负责播放|推荐:A | 评测必须量用户真正拿到的那一轮。A 只搬「决定」,前端少量改动就能做到一处定义,也符合界面层不放逻辑。B 有两份实现,靠测试只能挡住常量,挡不住流程上的差别。C 方向对,但要重做重连、断点、取消,应该单独立项
- D2 评测运行怎么做到不污染正式数据|选项:A 运行来源记 eval,加一个计入统计的谓词(聚类、白名单、目录使用次数都按它算),记录页默认不列评测运行,并写明有几次没列出 / B 评测用单独的库 / C 跑完就删|推荐:A | 保留现场(证据点得开),统计又不失真。B 要拆证据、封存和工件。C 丢了答错的原因
- D3 口径题的期望答案怎么算|选项:A 口径探针:拷出钉住版本里口径卡读的取数节点,接一个 caliber_from 口径卡,跑探索运行 / B 发起钉版本的正式运行 / C 引用最近一次封存的正式运行|推荐:A | A 用的是同一张卡、同一套取整和单位,结果封存可查,又不会被 pick_base 和应用视图当成「最近一次」。这个机制也正好是以后口径对账「在同一份数据上再跑一次」要用的。B 会污染正式运行的语义和应用视图。C 数据在变,期望会过期。代价是只有可复用的卡(输入全是查库格)能当口径题
- D4 允不允许人工填写期望值|选项:A 数值一律不许人填,只能来自 SQL、口径或从运行认定的 SQL;文字要点题由裁判模型判,单列「模型判断」,不计入准确率 / B 允许人填数,标「人填」 / C 不做文字题|推荐:A | 符合「期望答案不靠人填数」和照实说。文字题本来就是概率性的判断,不能和确定性的准确率混在一起
- D5 目录对照分几档|选项:A 当前目录 / 只用已核实和已确认的项 / 关,加上目录快照;预设对照是「当前 vs 关」 / B 只有开和关 / C 再细分到「只关提示词」「只关自查」|推荐:A | 「只用已核实的项」能量出模型起草、还没审过的那些项是帮忙还是添乱,直接关系到审阅值不值得做。C 增加的组合数和价值不成比例
- D6 「起草模型」怎么做对照|选项:A 影子起草:用指定的起草模型把目录起草进评测专用的快照,不动线上目录 / B 先后起草两次线上目录,各拍一份快照 / C 本轮不做|推荐:A | 做对照不应该改线上目录,真实用户会跟着受影响。影子起草复用同一个起草引擎,约 300–400 行。按表计费,大库很贵:只起草题集用得到的表,开始前先估算
- D7 评测的定时放在哪|选项:A 扩展现有定时任务,加 target_kind=eval_suite,定时页统一设置 / B 题集自带 cron,另起一套调度|推荐:A | 一种说法:认领、补跑、重叠跳过、触发记录、通知都只有一套。代价:Schedule.workflow_id 非空,评测目标要存空串,定时页多一个目标选择
- D8 评测界面放在哪|选项:A 顶级导航新增「评测」(builder 以上可见,⌥0) / B 作为记录页的一个页签 / C 放进设置页|推荐:A | 评测是搭建者经常用的独立工作,E1、E3 和聚类结果现在也没有地方放。代价:导航从 9 项变成 10 项,要在 600、520、480 像素高的窗口里重新量一遍
- D9 预算和并发的默认值|选项:同时 2 题;每题 900 秒;单次评测 2 美元;评测每日 5 美元(单独记账);每个题集保留 20 次 / 其他数值|推荐:按第一项的数值 | 全局运行并发是 20,2 题不会挤占真实用户。部署实例上的写作模型较便宜,100 题一次大约在这个量级。这一点没有实测,第一次真实模型验收后要按实际花费调整
- D10 统计口径和什么情况下发「有退步」通知|选项:A 定时跑 1 次、对照默认跑 3 次;给出 Wilson 区间和精确 McNemar 检验,只展示不卡门;有一道题从通过变成不通过就发「有退步」通知 / B 只在差异显著时才通知 / C 不通知|推荐:A | 退步落到具体某道题上,人能直接去看;小题集上显著性很难达到,只按显著性通知会把真实的退步漏掉
- D11 E1、E3 要不要直接做成持续指标,替代设计文档 §3.8 的一次性实验|选项:A 是:做成扫描任务,按窗口出趋势,判据沿用 §3.8 / B 先在仓库外按 §3.8 做一次性实验,再决定|推荐:A | 有了评测的底座之后,持续指标的增量成本只有约 500 行。一次性实验量完就过期,而且要把开发数据复制到 /tmp,这需要用户放行
- D12 追问题(多轮)本轮做不做|选项:A 不做,只做单轮题;数据模型里留出多轮的位置 / B 做:评测自己建一段 kind=eval 的对话,按轮写进去|推荐:A | 多轮要和问数据页的历史拼法(_history_section 读 meta.looks、上一轮的图)完全一致,前提是轮次由后端落库,这属于 D1 里否决掉的那项整轮搬迁。现在硬做,又会多一份走样的实现
- D13 后台任务运行时怎么复用|选项:A 把 data/catalog_job_runtime.py 上移到 core/job_runtime.py,原路径继续再导出 / B 评测直接从 data.catalog_job_runtime 导入|推荐:A | 运行时已经不只服务数据目录了;原路径再导出可以避免和正在改起草、剖析的工作冲突
- D14 真实问数据也记助手建图、挑表、复核的用量吗|选项:A 记:answer_ops 发 usage,这一轮的 meta 记下来,界面显示这一轮的总花费 / B 只在评测里记|推荐:A | 照实说,现在问数据的花费是低报的;同一套计量顺便就用上了
- D15 真实题集和真实评测在哪里跑|选项:A 真实题集只在部署实例(10.0.0.11)上建和跑;开发机只用零售样例题集 / B 把部署实例的数据拷到开发机上跑|推荐:A | 真实数据不出部署实例,也不进公开仓库。需要用户确认:在部署实例上跑评测会调模型(花钱)、会只读查询业务库,而且要重新部署之后才有评测功能
## 风险
- 走样没有完全消除:决定下沉到后端只覆盖「决定」本身,重连、取消、复核 25 秒不等这些还在前端。评测里的复核总是等完,界面上超过 25 秒的复核用户其实看不到;结果里记下复核用时,超过 25 秒的单列
- 随机性:同一配置跑两次结果不一样,小题集的区间很宽。默认展示区间和配对检验,对照默认重复 3 次,不拿单次总分下结论
- 挤占生产:运行共用全局并发 20、没有优先级(runner.py:65)。评测并发限 2 且不超过四分之一,评测在跑时界面上标出来
- 业务库压力:agent 的查询和期望探针都打业务库,还要作答前后各算一次期望。走数据源现有的查询时限,建议定时放在低峰
- 数据漂移:作答前后期望不同的题不判;A/B 的同一题两臂同时跑
- 目录接缝可能被绕过:以后新加的读目录代码要是绕过视图,对照就漏了。用 lint 测试禁止消费模块直接读目录,冻结快照里记下视图,证据面板看得出来
- 遮罩:评测页会显示期望值和答案里的数。期望用到遮罩列的一律拒收,答案引用了遮罩格的不评分,接口只给 builder,通知不带数
- 相对日期:提示词里没有今天的日期(没核实全部节点),「上周」的理解会抖。建议题目用定时变量写成明确日期,相对问法的题打标签单独看
- 评分误判:巧合的数、认不出维度。用维度配对,小整数标弱匹配;评分规则带版本,可以重新评分
- E1 可能显得偏低:规范化只做保持语义的变换,写法不同就判不等价(只会少给信任)。把模板 {{ }} 换成占位字面量可能改变语义(比如 IN 列表),换不了的记「比不了」,不记成不等价
- E3 依赖向量模型:本地哈希的语义能力弱。同时给原文完全相同的比例当下限,并写明用的是哪个向量模型
- 成本计量没核实:各接入在流式调用里给不给 usage_metadata(OpenAI 兼容接口要 stream_usage);get_usage_metadata_callback 隔着 BudgetFallback 包装能不能都截到。拿不到的照实写「金额无法估算」,要专门测
- SQLite:Schedule.workflow_id 非空,ADD COLUMN 式的迁移放宽不了,只能存空串;评测写入多,单进程写锁会竞争。写入经 SerialWriter 串行,结果明细放进工件
- 进程内任务:和起草、剖析一样,多进程部署时要换成库里的锁
- 停在审批:评测里的运行停在审批(危险工具、人工节点)时,评测不代批,直接取消并记下;带人工节点的工作流题评不了,照实写
- 影子起草的花费:起草模型按表计费,大库很贵。只起草题集用得到的表(从期望 SQL 推),开始前先估算
- 过拟合:照着题集调提示词,分数涨了但泛化没涨。建议题集里留一部分「留出题」,只在定时里跑,调提示词时不看它们的明细
- 和设计文档 §3.8 的关系:E1、E3 从「先做实验」变成持续指标,需要用户确认(D11)
## 测试
- test_chat_turn_policy.py:next_step 的各个分支(有 error 已自查、老后端重来、autorun=false、reply、asked);fill_input 和 chat.ts 的 fillInput 规则一致(读 chat.ts 比字段名正则和 MAX_ATTEMPTS);retry_instruction 的两种措辞;review 返回 retry_instruction
- test_copilot_answer_ops.py:同一个剧本下,SSE 输出和改之前逐条相同(含 reply、asked、check failed、error、截断);末尾多一条 usage
- test_usage_meter.py:剧本模型带 usage_metadata,生成、挑表、复核按模型累计;价格目录外的模型记 unpriced;meter 不包 launch 时,运行里的调用不会被重复计
- test_eval_origin.py:客户端声明 origin=eval 被拒;探索问题聚类、工具白名单衰减、目录使用次数、「被哪些运行用过」都不计评测运行;记录页默认不列、筛选能列出来,接口返回未列出的数量
- test_catalog_view.py:live、snapshot、只核实、off 四种视图在运行时 db_schema 的输出、助手查看结果、自查检查器、双盲复算里一致;冻结快照的 meta 记下视图;审批恢复后视图不变;lint:消费模块不许直接 read_catalog 或查 CatalogNote
- test_eval_snapshots.py:当前目录快照不可变;影子起草不写 catalog_notes(前后 version 不变)、不查业务库;「先去掉模型起草的项」选项生效
- test_eval_gold.py:SQL 探针(变量渲染、只读守卫拒绝写语句、遮罩列拒收、超时照实说);口径探针(可复用的卡建图后算出的值和原工作流一致,不可复用的拒收并给原因);从运行认定会记确认人;作答前后期望不同时不判;口径升版待处置时跳过,三种处置各自生效
- test_eval_score.py:scalar 的对且可追溯、对但没引用、错、量纲不同、遮罩不能评分;table 的维度配对和顺序;set;none;ask;弱匹配;叙述里自己算出的比率算「没引用」;重新评分只读、不调模型
- test_eval_stats.py:Wilson 区间、精确 McNemar(和手算对照);转移与归因(数据变、做法变、只参数变、路径变);列出指纹差异
- test_eval_runner.py:排队、并发上限、停止、只重试没做完的、重启标中断不续跑、单次和每日预算触顶、连续失败止损、每题超时会取消运行、停在审批会取消
- test_eval_e2e.py:dual_check_shop 库加剧本模型,题目是「9/1–9/7 按支付日、不含退款的销售额」,期望 330。臂 A 按支付日答 330:通过、可追溯、SQL 等价;臂 B 按下单日答 310:不通过,差异方面是 time。目录对照:剧本只在查看结果里看到 business_date=paid_date 时才按支付日写,「当前 vs 关」的配对表为 1:0;再测一次和上一次比的转移
- test_eval_signals.py:E1:模板 SQL 换成占位后和问数据 SQL 等价算命中,不同数据源不比,核查角色的查询不算;E3:只取问题、不取 history,同一轮的重来只算一次,有原文完全相同的下限,记下向量模型
- test_eval_cleanup.py:保留规则(最近 N 次、基线、A/B 引用);删掉运行和引用行;被别的运行引用的工件文件不删
- test_eval_schedule.py:题集的定时到点会建评测;重叠时跳过;错过的按宽限补跑;退步通知里不带数
- test_spend_ledger.py:裁判、核查的行为不变;三本账互不挤占
- test_frontend_type_unions.py:OriginKind 加上 eval,和服务端对上
- 前端:新增 scripts/check-evals.mjs,在演示接入下走一遍:建题集、写 SQL 题并试算、遮罩列拒收的提示、发起评测看进度、停止和重试、结果表、对比和 A/B、E1/E3 页签;check-shell 的导航高度;tsc;文案检查
- 真实模型验收(编排者在全部合入后跑):零售样例题集在 deepseek-v4.1-flash 上跑「当前目录 vs 关」各 3 次,人工抽查 5 道题的期望值和 SQL;核对没有污染(聚类、使用次数不变);核对总成本等于各运行的用量加生成和复核
## 验收
- 一键从题集发起评测:界面上看得到每道题的进度和正在做什么,能停、能只重试没做完的;服务重启后标中断,不会自己续跑
- 每道题的期望值都来自 SQL 或口径卡的封存快照(点得开),作答前后各算一次;没有人填的数值期望
- 评分分开五种结论:对且可追溯、对但没引用、错、量纲不同、不能评分;准确率和可追溯准确率分开报,都带样本数和 95% 区间
- 汇总给出作答路径、出具档位、复核结论、裁判结论(打开时)、look 次数、分段耗时、按角色的 token 和成本;成本里含助手建图、挑表、复核
- 和上一次比:列出退步、进步和归因,并写明两次之间指纹的哪几项变了
- A/B:同一题集「当前目录 vs 关目录」共用期望答案,给出配对表和 McNemar p 值;用影子起草的快照能做起草模型对照,线上目录前后的 version 不变
- 助手、写作、裁判模型可以按变体切换,结果里写实际解析出的模型 id;节点自己点了模型的题照实标出
- 评测运行在记录页能筛出来、证据点得开;聚类、工具白名单、目录使用次数都不受评测影响;记录页写明有几次评测运行没列出
- 评测的每日花费单独记账,裁判和核查的每日额度不被占用;触顶时照实说
- 期望用到遮罩列的题保存不了;评测页任何地方都不出现遮罩值
- 口径题钉的口径升版之后,不处置就不会继续评
- 题集能在定时页设定时;有退步时发通知,通知里不带数
- E1、E3 每次扫描都留痕、有趋势,参考线同设计文档 §3.8;聚类接口只聚问题,不含评测运行
- 问数据页行为不变(同一个剧本下 SSE 输出一致),这一轮多记了用量;前端照后端给的决定走
- 清理之后总表还在、明细写明已清理;被共用的工件不会误删
## 工作量
后端:
- 新增约 3,500–4,200 行。其中 eval 包约 3,000 行(gold 500、runner 500、score 600、stats 300、compare 300、signals 400、snapshots 350、cleanup 200、transfer 150、api 700),chat/turn 约 200 行,providers/usage 约 120 行,data/catalog_view 约 200 行,models_eval 约 250 行。
- 改动约 900–1,200 行:copilot.py 抽出 answer_ops 和复核函数约 200 行(多为搬移);目录视图四处约 150 行;来源和计入统计谓词约 120 行;账本泛化约 150 行;定时、Webhook、通知约 350 行;设置约 80 行;artifact_store 约 60 行;evidence 和 dual_check 公开函数约 60 行。
- 测试约 2,800–3,500 行,16–18 个文件。
前端:约 3,000–3,800 行。评测页 6–8 个组件,加类型、client、terms、定时页的目标选择、记录页的提示,chat.ts 照后端的决定走约 80 行;另有 check-evals.mjs 约 500 行。
建议拆成 5 个工作包,每包一个完整代理,最后统一复核一次:
- P1 底座与接缝:后台任务运行时上移、origin=eval 和计入统计谓词、目录视图、用量计量和 usage op、answer_ops 抽出、问数据的决定下沉加前端照做、账本泛化。约 2 天。
- P2 题集与期望答案:模型和接口;SQL、口径、从运行认定三种期望来源;变量;遮罩拒收;试算;口径升版处置;目录快照和影子起草;导入导出;零售样例题集。约 2 天。
- P3 评测执行、评分与对比:后台任务和并发、预算、止损;作答驱动;评分和重新评分;汇总统计;和上一次比;A/B;清理和工件回收;定时和通知。约 3 天。
- P4 持续指标:E1、E3 扫描,修聚类接口。约 1 天。
- P5 前端:评测页全部、导航、定时页、记录页、文案、check-evals。约 3 天。
依赖关系:P1 完成后才能做 P2、P3、P4;P2 完成后才能做 P3。P5 依赖 P2、P3 的接口:先把接口 JSON 的形状定下来,P5 就能和 P3 并行,最后接线。
按代理节奏,并行后约 6–7 个工作日;全部合入后,编排者另花约 1 天做完整关口和真实模型验收。
## 和其他几块的接口
- backend/app/api/copilot.py(抽出 answer_ops、review 返回 retry_instruction、发 usage op):和任何改助手生成、口径账本「组合式问数」的工作同一个文件,这一块要先合
- frontend/src/store/chat.ts(照后端的决定走、记 meta.usage):和改问数据页的工作冲突
- 目录视图接缝改到 tools/datasource.py、look/tables.py、data/sqlcheck.py、engine/nodes/dual_check.py:和数据目录、剖析、按数据库类型(方言)改 sqlcheck 或 introspect 的工作同文件。用户说先不管数据库类型,但同文件仍要排好合并顺序
- engine/origin.py 的 ORIGIN_KINDS、frontend/src/typesApp.ts 的 OriginKind、lib/terms.ts 的 ORIGIN_LABEL、test_frontend_type_unions.py:任何加运行来源的工作都要四处一起改
- api/governance.py 的聚类:口径账本的「沉淀」也要用它。聚类函数只保留一份,评测和账本都调它
- E1 里「口径 SQL 和执行 SQL 判等价、带绑定参数」,和口径账本 §3.4.2「认口径靠证明等价」是同一件事。建议放进一个公共模块(比如 look/calibers.py 旁边的 caliber_match.py),评测和以后的账本共用,不要各写一份
- 口径探针(钉版本的取数节点加 caliber_from 口径卡,跑探索运行)就是口径账本 §3.4.3「口径对账:在同一份数据上再跑一次」要用的机制,账本以后可以直接复用
- data/catalog_job_runtime.py 上移到 core/job_runtime.py 之后,catalog_jobs.py 和 catalog_profile_jobs.py 的 import 要跟着改;原路径保留再导出,可以避免冲突
- engine/spend.py 和 engine/judge.py 的账本泛化:和改裁判、核查的工作同文件
- db/models.py(Schedule、ScheduleFire 的新列)、db/base.py 的迁移列表、main.py 的路由和启动钩子:几乎每个包都会碰,要按合并顺序串行
- 定时相关的 api/schedules.py、scheduler/service.py、scheduler/webhook.py、notify/service.py 和前端定时页:和其他改定时或通知的工作冲突
- 导航改 components/CommandPalette.tsx 的 PAGES、App.tsx 的导航高度和 check-shell 的高度用例:同期如果别的包也加导航项,要一起量
- api/evidence.py 里读封存报告的函数要公开成函数(_sealed/_reports),和证据面板的改动同文件
- 部署实例 10.0.0.11:真实题集只在那里建,重新部署后才有评测功能;在那里跑评测要调模型、只读查业务库,需要用户同意
## 附:现状(代码位置)
- 问数据一轮的编排在前端,后端没有能跑完一整轮问数据的函数。plan() 调 /copilot/generate-stream(intent=answer),收到 final 以后按 error 级问题和 autorun 决定失败、只建不跑还是运行。launch() 用 fillInput 填 {question, history} 后发 POST /api/runs(origin:'chat')。settle() 读库里的成果,调 /copilot/review,复核超过 25 秒就不等。review.retry 且 severity=broken 时,retry() 把原因和原图一起交回去重建一次(MAX_ATTEMPTS=2)。重来的那句话也是前端拼的 [frontend/src/store/chat.ts:1271 plan、:1301 retry、:1407 final 分支、:1500 launch(:1507 origin chat)、:1217 fillInput、:1238 historyOf、:1250 MAX_ATTEMPTS、:1754 settle、:318 REVIEW_TIMEOUT_MS]
- 生成过程全写在 SSE 路由里面:event_stream 内联了取数据源、读目录检查器、取助手模型、拼历史、组查看服务、Generation.draft/repair、自查、排版校验、final{graph, issues, autorun}。GenerateIn.provider/model 可以按请求覆盖助手模型,挑表和复核也走 copilot_model_spec,所以覆盖一次三处都跟着换 [backend/app/api/copilot.py:330 GenerateIn、:570 copilot_model_spec、:1758 generate_stream、:1806 event_stream;backend/app/api/copilot_gen.py:575 Generation(:597 calls 计主模型调用次数)]
- 问数据的搭图约定:input → agent(db_query__) → report → output。数字用 [[v:Q1.r0.列名]] 直接引用 agent 查过的格,比率、增幅才加口径卡。问数是探索运行,report 不写 claims。另有三条路径:直接回答(reply)、建图并执行、建图不执行 [backend/app/api/copilot.py:343 _user_message、:360 起 answer 分支]
- 答案里的数怎么来:report 节点把 [[v:]]、[[m:]] 解析成 segment(kind=value,cite.value 是原值,status=resolved),正文裸写的数记成 uncited_number 违规。doc.stats 里有 numbers、numbers_cited、uncited_numbers、unresolved、unknown_entities。证据接口按封存范围读报告文档 [backend/app/engine/evidence.py:117 Citation、:133 Segment、:3154–3228 stats;backend/app/api/evidence.py:383 evidence_graph]
- 出具档位由 output 节点按契约用 decide_tier 判(formal/degraded/withheld),和运行类别无关;问数答案一般不声明出具。问数据的可信度另看复核:scan 扫信号,severity 分 degraded/broken,RETRYABLE 里的信号才会自动重来 [backend/app/engine/nodes/io.py:275;backend/app/engine/issuance.py:24 _NUM_RE、:423 decide_tier;backend/app/engine/review.py:144 RETRYABLE、:170 scan、:266 should_retry;backend/app/api/copilot.py:2779 review_run]
- 用量:运行按 llm.end 事件加总 tokens、cost_usd、calls、unpriced_calls,每条 llm.end 都带 model。助手建图、挑表、复核这三处完全不记用量(copilot.py、copilot_gen.py、look/pick.py、review.py 里都没有 usage),所以问数据一轮的花费是低报的。环境里的 langchain-core 1.6.3 自带 get_usage_metadata_callback(用上下文变量按模型累计 usage_metadata) [backend/app/engine/runner.py:1438 _usage_of_events;backend/app/engine/nodes/llm.py:159;~/miniforge3/envs/agentlab/.../langchain_core/callbacks/usage.py:81]
- 各角色的模型怎么配:助手是 copilot 设置键(只能在「编排」页助手栏改),证据裁判是 judge 组(judge_model_spec 能按节点覆盖),核查是 verify 组,起草是 catalog_draft 组,门控是 run.tool_gate_*。写作模型没有单独的设置,取值顺序是:节点 config > 图 defaults > 按模型名反查接入 > 第一个启用的真实接入 [backend/app/api/copilot.py:567;backend/app/engine/judge.py:955;backend/app/api/settings.py:428 VERIFY_DEFAULTS、:580 CATALOG_DRAFT_DEFAULTS、:735 DEFAULT_SETTINGS;backend/app/engine/schema.py:122;backend/app/engine/context.py:82;backend/app/providers/factory.py:134、:174]
- 运行来源一共 11 种,客户端只能声明 chat/canvas/launcher/api。launch_run 接受 origin_kind、origin、options、workflow_name,服务端可以直接发起裸图的探索运行。options 是整次运行沿用的快照,会写进 run.started、在封存范围内。所有运行共用一个全局信号量(max_concurrent_runs=20),没有优先级 [backend/app/engine/origin.py:14 ORIGIN_KINDS、:29 CLIENT_ORIGINS;backend/app/engine/launch.py:54;backend/app/db/models.py:687 options;backend/app/engine/runner.py:65、:655 wait_idle;backend/app/core/config.py:99]
- 这些统计会被评测运行污染:探索问题聚类取所有非正式运行,把整份 input JSON(含 history)拿去做本地向量贪心聚类,界面上没有调用方;工具白名单衰减报表;数据目录的「使用次数」和「被哪些运行用过」,按所有运行的查询快照计数;记录页默认列出所有来源。pick_base 和应用视图的「最近一次封存」只看挂在工作流上的运行,裸图运行不受影响 [backend/app/api/governance.py:18 tool_usage、:90 exploratory_clusters;frontend/src/api/client.ts:770;backend/app/data/catalog.py:2406、:2444;backend/app/data/catalog_review.py:217;backend/app/api/runs.py:157;backend/app/api/compare.py:100;backend/app/apps/resolve.py:95]
- 清理:删运行只级联删事件。artifacts 引用行没有外键、不会跟着删,工件文件也不删。artifact_store 没有删除或回收的函数,现在只有上传表格快照有回收 [backend/app/api/runs.py:479;backend/app/core/artifact_store.py:45 put_json;backend/app/db/models.py Artifact;backend/app/data/table_versions.py:2214 gc]
- 定时任务只能指向工作流(workflow_id 非空),到点由 fire_once 调 launch_run。时间变量(today、last_week、last_month_start……)由 VARIABLES/template_values 按触发时间渲染。认领、补跑、重叠跳过、触发记录、Webhook 通知都是现成的 [backend/app/db/models.py:967 Schedule、:978 workflow_id;backend/app/scheduler/service.py:37 VARIABLES、:88 template_values、:280 fire_once;backend/app/scheduler/loop.py;backend/app/scheduler/webhook.py]
- 变化三分法:运行对比按「节点 + 第几次查询 + 工具」给查询配对,再按 normalize_sql 判 same/params_only/changed;指标分成 data_only/caliber_and_data/sql_changed/params_only/added/removed/incomparable。pick_base 按发起先后,挑成功结束并已封存的那次 [backend/app/engine/compare.py:1–70(模块说明)、:70 起的分类常量;backend/app/api/compare.py:100 pick_base]
- SQL 等价:normalize_sql 把字面量换成占位符;explain_sql_diff 要求规范形式一字不差才算等价,不等价时 items 一定不为空。差异方面有 time/time_range/filter/aggregate/expression/join/tables/group_by/limit/other 等。normalize_sql 不认得 {{ }} 模板 [backend/app/data/sql_semantics.py:319 normalize_sql、:5897 FacetDiff、:5920 SqlDiff、:7932 explain_sql_diff]
- 已发布口径的索引现成可用:CaliberKind.load 列出每个工作流最新已发布、未退役版本里的源头口径卡。card_inputs 判断卡能不能复用:输入全是 cell(nodes.X,0,'列'),且 X 是查库的调用工具节点。reuse() 给出 {workflow_id, version, node_id, metric_id, inputs}。metrics 节点的 caliber_from 从不可变的版本快照取卡,但不检查版本有没有退役。sqlcheck.tool_query 能取出调用工具节点里写死的 SQL。unresolved_caliber_upgrades 用来判钉住的版本有没有未处置的新版本 [backend/app/look/calibers.py:82 card_inputs、:124 views_of、:157 CaliberKind.load;backend/app/engine/nodes/metrics.py:235 _pinned_caliber;backend/app/data/sqlcheck.py:1050 tool_query;backend/app/engine/governance.py:679]
- 后台任务运行时:起草和剖析共用一套状态词(queued/running/succeeded/partial/failed/cancelled/interrupted,逐项是 pending/running/ok/partial/failed/skipped/cancelled),以及 SerialWriter、JobRun/JobManager、每个数据源一条队(SourceLanes)。服务重启时标中断、不自动续跑 [backend/app/data/catalog_job_runtime.py:1–30 说明、:123 SourceLanes、:251 interrupt_on_startup、:268 JobRun、:332 JobManager;backend/app/db/models.py:438 CatalogProfileJob]
- 每日账有两份一模一样的写法:核查用 engine/spend.py(verify_spend),裁判在 judge.py 里自己写了 _charge/_settle(judge_spend)。双盲复算的数值比较是 _agree:按相对误差比,原值为 0 时按绝对误差;设置里叫「允许的相对误差」 [backend/app/engine/spend.py:23;backend/app/engine/judge.py:859、:1050;backend/app/engine/nodes/dual_check.py:1001 _agree、:1012 _delta_pct]
- 目录在四处被读取,没有统一的开关:运行时的 db_schema/db_query 工具(目录同时冻结进表结构快照)、助手查看、自查用的 SQL 检查器(直接查 CatalogNote)、双盲复算。目录没有历史版本,只有每张表一个 version。项的状态有 proposed/verified/confirmed/rejected 四种,给模型看时只去掉 rejected [backend/app/tools/datasource.py:280、:290 _catalog_of、:753 frozen_catalog;backend/app/look/tables.py:164;backend/app/data/sqlcheck.py:1087 load_checkers;backend/app/engine/nodes/dual_check.py:792、:1190;backend/app/db/models.py:325;backend/app/data/catalog.py:69、:590 visible_notes]
- 现成的测试和验收零件:MockChatModel 的助手剧本只会搭「输入→查库 agent→成果」,答案是演示文字。dual_check_shop 有已知答案:按支付日、排除退款 330,按下单日 310,不排除退款 380/360。acceptance/retail.py 埋了退款、支付日、取消、华南高于华东几个陷阱,数据按「今天」滚动生成。test_retrieval_quality.py 是检索 hit@k 基线的先例,test_frontend_type_unions.py 是前后端常量逐个对照的先例 [backend/app/providers/mock_model.py:62;backend/tests/fixtures/dual_check_shop.py:1–12;backend/tests/fixtures/acceptance/retail.py:1–20;backend/tests/test_retrieval_quality.py;backend/tests/test_frontend_type_unions.py]
- 权限:治理、定时只给 builder;设置是 builder 读、admin 写;运行对比和质询要交出查询原文,只给 builder。助手和对话按 chat_scope 放行。后台任务里没有请求身份时,current_principal 当作 admin、未认证 [backend/app/main.py:196–258;backend/app/api/deps.py:108]
- 助手、写作、报告的提示词里没有找到注入今天日期的地方,「上周」这类相对问法要靠模型自己理解或用 SQL 的当前日期。只查了 copilot.py、nodes/llm.py、nodes/report.py,别的节点没核实 [backend/app/api/copilot.py;backend/app/engine/nodes/llm.py;backend/app/engine/nodes/report.py]
- 导航现有 9 项,App.tsx 的注释要求加导航项时在 600/520/480 像素高的窗口里各量一遍。向量默认用本地特征哈希,语义泛化弱 [frontend/src/components/CommandPalette.tsx:55 PAGES;frontend/src/App.tsx:334–337;backend/app/memory/embeddings.py:236;README.md「检索为何是混合的」]
+169
View File
@@ -0,0 +1,169 @@
> 分块细案(2026-10-16 只读调研整理,代码基准 main d135a17)。和 [00-总方案](00-总方案.md) 冲突的地方以总方案为准;总方案「跨块统一的决定」一节列出了改动过的条目。决定编号在本文件内有效,总方案里引用时带前缀(如 评测-D4)。
# 口径账本:问数据和已治理口径接起来
## 缺口
- 问数据看不到口径,也没有组合机制 —— 助手不知道有哪些已发布口径,「先看口径再决定」无从谈起;设计文档的 D4 规则落不了地 [backend/app/api/copilot.py:111-118]
- 设计文档的「子图做骨架」前提不成立 —— 子图的证据不交回父图,使用者又不能用子工作流。拼出来的答案里,来自口径的数既点不开出处,使用者也发起不了 [engine/nodes/multi.py:723-731;auth/chat_policy.py:32]
- 认口径缺参数绑定:没有从执行的 SQL 反推口径模板参数的函数 —— 只靠 explain_sql_diff,参数不同就判不等价;只靠 normalize_sql 文本,又会错给信任 [data/sql_semantics.py]
- 等价判定没有触发点,结果没处存,也没处展示 —— 逐数标口径、路径一和二a、召回的反查,都依赖一份可复核的比对记录 [engine/runner.py:1017-1031;api/evidence.py:778-818]
- 等价之后不知道列怎么对应 —— 答案里引用的列名和口径里的列名不同时,要靠位置对应(已实测:等价时列的顺序一致),现在没有接口给出这个映射 [data/sql_semantics.py(规范形式去掉了外层别名)]
- 从问数据发起正式出具:没有服务端来源,也没有使用者的权限规则 —— 路径一、二a 要正式运行,又不能让客户端冒充来源、自己传参数 [engine/origin.py:31;api/runs.py:108-127]
- 对账缺一张确定性派生的只读图;caliber_from 不支持节点改名 —— 差值要在口径卡里算。同一张图里放两份口径(组合多份、召回新旧两版)时,取数节点 id 会撞 [engine/nodes/metrics.py:235-290]
- 召回反查不全:导出没有记录,运行到轮次没有封存的链接,发布没有钩子,没有重算对比,也没有待办种类 —— 「口径变了,追回已交付的答案」现在找不全该追的对象,也没法通知 [api/evidence.py:2794;api/workflows.py:504-546;db/models_process.py:21]
- 沉淀出来的模板永远比对不上 —— from-run 保留 Agent,提取出的模板没有固定 SQL。聚类混着 history 不准,也没有按 SQL 形状的确定性重复检测。「在画布里打开」又丢了来源 [api/copilot.py:2625-2690;api/governance.py:109-117]
- E1–E3 没有数据来源 —— 用户要求实验指标改由账本和评测持续给出 [—]
- 设计文档的用词和全站术语冲突 —— 违反「一种说法」;check-copy 也会拦 [frontend/src/lib/terms.ts:18-22、150-152]
## 做法
### 1. 参数绑定与等价证明(认口径的内核)
- 做法:在 sql_semantics 新增 bind_template(template_sql, slots, sql, dialect, schema) 和 match_template(...),后者返回 TemplateMatch{equivalent, binding, unbound, diff: SqlDiff, level, columns}。步骤如下。① 只认口径取数节点 args.sql 里的 {{ input.<入口字段> }},可以带 trim / compact 过滤器。引用 vars.、nodes.、file_text 的整条判「不可比」,并照实给原因。② 按槽位所在位置生成哨兵:引号里用 '__agl_p{i}__' 字符串,裸写的用不可能出现的整数。依次在 optimizer、light、parsed 三级规范形式上,抹掉 Literal 的值后比指纹;一致就按位置配对字面量。含哨兵的口径字面量转成正则抽参数('{{ input.m }}-01' 这种半截的也行);不含哨兵的字面量必须相等;同一个槽位出现多处时,要取到同一个值。③ 三级都对不上时,退回「同一列、同一运算符的常量」这种启发式配对。它只用于对账说明和预填参数,不作为证据。④ 最后一步一律用 engine/expressions.render_template 按绑定渲染口径模板(和运行时是同一个渲染器),再调 explain_sql_diff(执行的 SQL, 渲染结果, schema=查询当时冻结的表结构, names=catalog_labels(目录))。等价与否只由这一步判。⑤ 等价时按位置给出列映射(已实测:外层别名不计、列顺序计)。SELECT * 按表结构展开之后同样按位置对应。
- 复用:normalize_sql;_canonical_forms / _fingerprint;explain_sql_diff;catalog_labels;render_template;tests/test_sql_equivalence.py 的执行比对守门
- 文件:改 backend/app/data/sql_semantics.py(新增两个函数并加进 __all__);新增 backend/tests/test_sql_binding.py;扩 backend/tests/test_sql_equivalence.py(绑定渲染后的 SQL 进语料)
- 对上的理念:照实说:失败只会少给信任。绑定只是启发,等价只由现有的判等价和执行守门决定。也不违反「不从指标定义编译 SQL」:只比较实际执行的 SQL 和口径里写的 SQL
- 否决的备选:只比 normalize_sql 文本:实测 'paid' 和 'refunded' 文本相同,会错给信任。把所有字面量都当参数:同样的错。在 sqlglot 树上做通用合一来判等价:绕开了现有的保真守卫和执行守门,正确性要从头证明。让模型判问题像不像:设计文档已经否决
### 2. 等价判定的时机和账本的记法
- 做法:新增 ledger/match.py。时机:运行成功结束、封存完、并且有查询快照时,在 runner 封存之后 spawn 去比对(和 notify_run 在同一个位置)。范围:探索运行(chat、canvas、trial、personal_app)。正式运行不比 SQL:它本身就是某一版的承诺,账本直接按 Run.workflow_id / version 记。候选口径:每个工作流最新的已发布、未退役版本(apps/versions.latest_published,和 look 是同一种说法)里查库的调用工具节点;先按同一数据源、sql_tables 有交集预筛。资源:每次运行限时(缺省 8 秒),在线程里跑,同时最多 2 个(照 auto_compare)。超时、解析失败的,照实记 skipped 和原因。只读封存范围内的快照(复用 _sealed 的读法),被改过的快照不比。结果:存一份内容寻址工件,kind=caliber_match,格式 agentlab.caliber_match/1。内容包括 run_id、manifest_hash、算法版本、考虑过的口径版本及其 graph_hash、每条查询的状态(proven / composed / related / none / skipped)和绑定、差异条目、列映射、每个数字的标签、整题判断。工件引用的 meta 记 {calibers:["wf@v"], related, counts}。然后用 run_append 的 sealed 方式追加一条封存后事件 ledger.matched {artifact, manifest_hash, algo}。启动时扫最近 N 天封存了、却没有 ledger.matched 的运行,补比一次;补比只读工件和版本表,不查业务库、不调模型。老运行在打开时按需比对,标「按现在的口径回看」。另外给 artifacts.kind 补一条索引。
- 复用:run_append(封存后批注);api/evidence._sealed;auto_compare 的限时与并发;apps/versions.latest_published;query_snapshot_meta;ledger_enabled
- 文件:新增 backend/app/ledger/__init__.py、match.py、store.py;改 backend/app/engine/runner.py(封存后 spawn)、backend/app/core/events.py(LEDGER_MATCHED、LEDGER_RECALLED、LEDGER_BASIS_CHANGED)、backend/app/db/base.py(_INDEX_MIGRATIONS)、backend/app/main.py(启动时补比)
- 对上的理念:不挡生成:比对在封存后的后台跑,失败只记日志和 skipped。留痕:依据哪一版口径、哪个哈希、哪个算法版本、对着哪一次封存,都查得到。建在证据链上,不另起存储
- 否决的备选:封存前在 runner 里比:运行结束要等 CPU 密集的比对,还会把外部的口径表混进运行自身的封存事实。在报告撰写节点里比:叙述层不该说出处,也拖慢生成。新建 caliber_uses 索引表:成了第二份真相,会和工件漂移。代价是反查要扫 kind=caliber_match 的工件引用,补上 kind 索引后可以接受
### 3. 逐数标口径,进证据渲染
- 做法:新增 ledger/labels.py,逐个处理封存的报告文档里的片段。单元格([[v:]]、[[table:]] 里的格):按目录条目找到查询快照,取那条查询的比对状态。指标([[m:]]):指标集工件里这个指标的每个输入,所在的快照都要对上同一份口径的同一版、绑定一致;指标式还要和口径卡相同(caliber_from 固定到那张卡,或者局部卡的式子按输入映射后 AST 相同)。满足了才算来自口径。标签分四种:caliber(来自口径 vN,how 分 independent「等价已证明」和 composed「助手选用」)、related(相关口径,附差在哪)、reasoning(这次的推理)、unknown(没比对、超时、快照读不出来)。整题:主报告里所有带标签的数都来自同一口径的同一版、绑定一致,就记 whole=equivalent,并附上还缺的入口字段。接口:GET /api/ledger/runs/{run_id} 返回标签、整题判断、能做的动作和不能做的原因。evidence_segment 的返回加一个 ledger 键;没比对过的运行不带这个键,老运行的返回一字不差。报告导出的片段面板带同一份标签,并写明「封存后比对,不在封存范围内」。遮罩:绑定的值等于这次运行查到过的遮罩值时(_Masks 的 secrets),不给值,也不提供代填出具;差异条目里的 SQL 片段走 _Shown 同一套遮罩。
- 复用:build_catalog 的目录条目;_metric_chain 的取法;_Masks / _Shown;report_export.build
- 文件:新增 backend/app/ledger/labels.py;改 backend/app/api/ledger.py、backend/app/api/evidence.py(evidence_segment、export_report_html)、backend/app/report_export/build.py
- 对上的理念:照实说:一个答案里两种数并列,「助手选用」和「等价已证明」分开说。只读与遮罩。界面层不放逻辑。一种说法:同一个面板、同一套遮罩
- 否决的备选:给整条答案打一个「已验证」:这是业内做法,正是要避免的错给信任。写进 turn.meta:那是前端写、前端读的数据,后端不解读,不能当账。写回报告文档:封存的工件不可变
### 4. 组合式问数:口径做骨架,Agent 填缺口
- 做法:① 问数据入口的查看对象加上 caliber:answer 时 kinds 加 caliber;CaliberKind.load 在开流之前用会话读好。问数据里的 CaliberKind 另给入口字段(describe_version 那一套:类型、必填、可选值)和「能不能组合」,仍然不给 SQL。② 协议加一种操作:{"op":"use_caliber","id":"c1","workflow_id","version","metrics":[…],"inputs":{字段:值}}。GraphApplier 不让模型自己写取数节点,由服务端按那一版确定性实例化:card_inputs 认出的取数节点照抄(args.sql 用 render_template 按 inputs 渲染成字面 SQL,节点 id 加前缀 c1_);口径卡用 caliber_from 固定到那一版,再加 nodes 改名映射 {X: c1_X}。inputs 按入口字段校验(类型、可选值、必填),不合规就拒收,并把原因交回模型。③ metrics._pinned_caliber 支持 caliber_from.nodes:按 AST 改写式子里 cell(nodes.X…) 和 nodes.X 的根。schema 校验、升版检查、证据的 _caliber_source、compare、recompute 都要一起认这个映射;没写 nodes 的老图行为一字不差。④ 提示:LOOK_WHEN 里问数据那一条改成「先看已发布口径。问法和口径定义完全对得上时用 use_caliber;口径只覆盖一部分时 use_caliber 加 Agent 补缺口;问法多了口径没有的条件(比如含退款)时不要套口径,交给 Agent,系统会自动对账」。运行时的 Agent 不看口径,保持独立解读。⑤ 节点手册里子工作流补上 workflow_version(copilot.py:200),但不把它当作问数的组合手段。
- 复用:look/calibers.py 的 CaliberView、card_inputs、reuse_for、verify_reuse;blueprints/templates/_common.reuse_nodes 的做法;caliber_from 的运行时和升版处置;render_template;chat_policy 白名单(tool、metrics 本来就放行)
- 文件:改 backend/app/look/calibers.py、backend/app/api/copilot_context.py、backend/app/api/copilot.py、backend/app/api/copilot_gen.py(GraphApplier 处理 use_caliber)、backend/app/engine/nodes/metrics.py、backend/app/engine/schema.py、backend/app/engine/governance.py、backend/app/engine/recompute.py、backend/app/api/evidence.py(_caliber_source);新增 backend/app/ledger/compose.py;前端 run/decode.ts 展示这种操作的过程
- 对上的理念:模板定骨架、agent 填关节:骨架由服务端从固定版本确定性展开,模型只选口径、填参数。没发布的图只能跑探索运行:拼出来的图照旧是探索运行。一种说法:复用蓝图「复用口径卡」的机制
- 否决的备选:子图节点做骨架:子图证据不交回父图(multi.py:723-731),使用者白名单不允许子工作流(chat_policy.py:32),而且整个子工作流连报告一起跑。要走这条路,得先补子图证据外流、再核对放开白名单后的遮罩。让模型自己抄口径的 SQL:容易抄错,还得把 SQL 给模型,而 SQL 里可能写着遮罩值。局部复制口径卡的式子:丢了「定义取自哪一版」和升版处置
### 5. 路径一、二a:按口径正式出具
- 做法:新增 POST /api/ledger/runs/{run_id}/issue {workflow_id, version}。参数由服务端从比对工件里取绑定,不收客户端传的参数,前端只能选「按哪份口径」;缺的字段补入口默认值,再过 apps/forms 同一套校验。经 launch_run 发起正式运行,来源用服务端专用的新 origin_kind=ledger_issue,origin 里记 source_run、conversation_id、turn_id 和绑定的哈希;按 expect_hash 钉住 graph_hash。三档出具照正式运行原样走。自动发起要同时满足:整题等价、how=independent、版本是受管级别、图里没有人工节点和写外部系统的能力(复用 apps/backfill._side_effect,抽成共用函数)、当天没超上限。比对完就自动发起;不满足的只给一键,并写明为什么不能自动。同一运行、同一口径、同一绑定只发起一次(按 input_key 去重)。使用者能不能发起见 D5。结果挂回轮次:GET /api/ledger/runs/{id} 的 issued 列出 origin.source_run 等于这次运行的正式运行(origin_kind 有索引),档位文字用 TIER_TEXT。
- 复用:launch_run(正式运行的全部门禁);apps/forms 校验;apps/resolve.input_key;apps/backfill._side_effect;issuance 三档;TIER_TEXT
- 文件:新增 backend/app/ledger/issue.py;改 backend/app/engine/origin.py(ORIGIN_KINDS 加 ledger_issue、reconcile、recall,只能由服务端写)、backend/app/api/ledger.py、backend/app/apps/backfill.py(_side_effect 抽出共用)、backend/app/api/settings.py(新增 ledger 一组:自动出具开关、每日上限、比对时限、召回上限)
- 对上的理念:正式运行只从已发布、未退役的不可变版本发起(照走 launch_run)。生效可查可对账:来源封存在 run.origin。照实说:不能自动的写明原因
- 否决的备选:生成时由助手判定走路径一、直接发起正式运行:只能凭问题像不像,没有可证明的 SQL。把探索运行的结果「升格」为正式出具:违反「正式只从发布版本发起」。客户端传参数发起:参数必须来自可复核的绑定
### 6. 路径二b:口径对账(差在哪、差多少)
- 做法:「差在哪」在比对时就给出:explain_sql_diff 的条目,labels=("这次","口径"),names 取数据目录里的中文名。这一步不跑查询。「差多少」要点开才跑一次对账运行:ledger/derive.py 按口径那一版确定性派生一张只读小图。图的结构:入口(口径的入口字段,默认值是绑定)→「这次的取数」调用工具节点(执行过的 SQL 原文,节点 id ours_q1)和口径的取数节点(模板里保留 {{ input.x }})→ 口径卡(caliber_from 固定那一版)→ 对账卡(metrics:差值、相对差,式子引用 cell(nodes.ours_q1,0,'列') 和口径卡的指标,on_missing=null)→ 出口(契约写 metrics_from;不加报告,不调模型)。参数如果是启发得来的、或者不全,先让人核对、修改,界面写明「参数按这次的 SQL 推断,请核对」。两边在同一次运行里同时查,差值在口径卡里算。面板上每个数都用 /evidence/metrics 的出处链;另外写出原答案当时的值,如果和这次重查的值不同,照实说「这段时间数据有变化」。列怎么配:差异条目没有兜底时按位置配;有兜底时让人选要对照口径卡上的哪个指标,这个选择记进对账运行的 origin。多行结果:被引用的行按维度列(非遮罩、非数值的列)经合并查询按键对齐,每个被引用的行出一个对账指标,最多 10 行;维度是遮罩列、或者合并键不唯一时照实拒绝。来源 origin_kind=reconcile(服务端写),是探索运行;使用者发起时带上 MASK_RESULTS / READ_ONLY 同一套选项。
- 复用:CaliberView.reuse / card_inputs;caliber_from(含第 4 项的 nodes 映射);metrics 节点和 recompute 的取整、渲染;合并查询节点;evidence_metric 接口;explain_sql_diff 的说法;launch_run
- 文件:新增 backend/app/ledger/derive.py、backend/app/ledger/reconcile.py;改 backend/app/api/ledger.py
- 对上的理念:叙述层无算术:差值在对账卡里算。只读:走只读守卫和时限。点开才跑,不自动多花查询。照实说:推断的参数要人核对,数据变化单独说
- 否决的备选:用 compare.py 在 Python 里算差:差值不在口径卡里,违背设计决定;而且 compare 是给同一工作流的两次运行配对用的,不适用于两条不同的 SQL。拿原答案的值当运行输入去减:混进了数据随时间的变化,原值的出处也断在跨运行这一步。每次比对都自动跑口径:多一倍查询,设计文档已经否决
### 7. 召回:口径升版、目录事实被驳回、表结构变动
- 做法:一、口径升版:commit_publish 提交之后排队处理。发布弹窗先给预告:旧版本被多少问数回答、导出、定时任务、应用用过,预计要重算几组。反查(ledger/uses.py)的对象包括:caliber_match 的 meta 里记着 W@旧版 的运行及其绑定;W 的正式运行;固定了 W@旧版 的工作流版本(子图或 caliber_from,用 apps/versions.pinned_upstream 找)发起的正式运行;定时任务最近 K 期(ScheduleFire.inputs);固定这一版的应用(pinning_apps);导出记录(见第 9 项);对应的问数轮次(运行 origin 里的 turn_id,老运行退回 conversation_turns.run_id 和 meta.runId)。按(旧版本、新版本、input_key(绑定))去重成组,每组发起一次召回运行(origin_kind=recall,探索运行)。派生图的结构:入口=绑定 → 旧版取数 + 旧版口径卡,新版取数 + 新版口径卡(用 caliber_from.nodes 避免撞 id)→ 召回卡算两版之差 → 出口。两版在同一时刻、同一份数据上算,所以差值只反映口径的变化;数据的变化另外拿原答案当时的值对照着说。以下情况不重算,照实记「无法重算:原因」:新版入口要的字段绑定里没有;口径的数据部分不能复用;图里会写外部系统。一次发布最多 N 组(设置,缺省 50),超出的写明「另有 M 组未重算」,可以在账本页手动接着算。计划照 apps/backfill 的写法,从已有的召回运行重算出来:幂等,服务重启后重算计划即可,不建任务表。二、目录事实被驳回或修改(只管影响数字的那一组:有效记录条件、去重、业务日期、关联、业务主键、度量类型):找出查过这张表、而且查询快照冻结的目录里看得到这一项的探索运行,追加封存后事件 ledger.basis_changed,界面标「依据有变」。对这些运行执行过的 SQL,按新目录跑一遍 SQL 检查(data/sqlcheck,纯计算),新出现错误级问题的才通知。受管口径受到的影响,由现有的影响面告知发布人。三、表结构变动(同步之后表或列被删、类型变了):标「依据的表结构已变」。受影响的口径版本,按最近一次正式运行的输入派生数据部分试算一次,算不出来才通知发布人。四、通知:重算后数真变了(召回卡的差值按指标的小数位渲染后不为零),才给答案的主人开待办。待办是新种类 recall,挂在召回运行上;派给对话的 created_by、导出人、定时任务的 created_by、应用管理者;关登录时派给任何人。待办里给旧口径的值、新口径的值、差值和原答案当时的值,每个都点得开;动作有「按新版重新出具」(走第 5 项)和「知道了」。数没变的只记账:在原答案的运行上追加 ledger.recalled,标「已按 vN 核对,不影响」。通知事件新增可订阅的 ledger.recall,消息照规矩只给标题、状态、链接,不放数字。
- 复用:apps/backfill 的计划、去重和无副作用规则;input_key;caliber_from.nodes(第 4 项);派生图(第 6 项);run_append;process/tasks 里 review 的先例;notify_task;catalog_impact.table_impact;data/sqlcheck;scheduler/auto_compare(下一期推送已经会自动报「口径与数据都变」)
- 文件:新增 backend/app/ledger/uses.py、backend/app/ledger/recall.py;改 backend/app/api/workflows.py(发布后排队、预告)、backend/app/api/ledger.py(用量扩展、召回批次)、backend/app/db/models_process.py(TASK_KINDS 加 recall)、backend/app/process/tasks.py(open_recall 和对应处理)、backend/app/notify/service.py(事件、种类文字)、backend/app/data/catalog.py 或 catalog_review.py(审阅写入后的钩子)、backend/app/api/datasources.py(表结构同步之后的钩子,:857 附近)、backend/app/core/events.py
- 对上的理念:口径升版是被迫处置的事件,现在延伸到已交付的答案。叙述层无算术:两版之差在召回卡里算。照实说:数据变化和口径变化分开说,算不了的写原因。不挡生成:召回在后台跑,失败不影响发布。生效可查:每一组召回都是一次封存的运行
- 否决的备选:拿新口径的现值直接和原答案的旧值比:混进数据变化,会大量误报。对「这次的推理」自动重问一遍:结果本来就不确定,变了也分不清是口径还是模型的原因。建召回任务表:运行本身就是记录,计划可以从已有运行重算。发布时同步重算:拖慢发布,还要查业务库
### 8. 沉淀与认证队列
- 做法:新增 GET /api/ledger/certify-queue,builder 以上可见。进队的信号有三种。① 确定性重复:问数探索运行里执行过、被报告引用、又没对上任何口径的查询,按(数据源、normalize_sql 文本)分组;出现在 ≥3 次运行、且来自 ≥2 段对话的进队。跨成员变化的字面量位置是参数候选,不变的是口径常量。② 问题聚类:沿用 exploratory_clusters,但只取 input.question,不含 history。③ 被反复用的答案:导出过 ≥2 次、做成过应用、在画布里打开过的轮次。每项给出代表问题、轮次链接、SQL 形状(展示走遮罩)、参数候选,以及是否已有相关口径(可以对账的那份)。动作「固化成口径草稿」是一种新的提取方式(ledger/certify.py,from-run 加 freeze 选项后转调它):Agent 实际执行过、报告引用了的查询,变成调用工具节点。SQL 用规范化后的文本,参数候选换成 '{{ input.p }}',入口字段的默认值取代表运行里的值。单行结果每个数值列建一项口径卡指标(cell(nodes.fetch,0,'列'))。报告节点保留,契约写 report_from + metrics_from(多行结果加 cells: true),然后过 validate_graph。新工作流的 origin 加 sources:[{run_id, conversation_id, turn_id, question}],这就是「口径 → 来源问题」的记录。之后由人在画布上审改,按受管级别发布。点「不是口径」的写一条审计(AuditEvent,action=ledger.certify.dismiss,subject 是形状哈希),之后不再进队。口径发布之后,对来源运行补比一次,标「之后发布的口径」,E1 也就有了回溯数据。
- 复用:normalize_sql 的 literals;exploratory_clusters;extract_run_path;upgrade.py R3 里报告和契约的写法;Workflow.origin;AuditEvent;validate_graph;governance lint
- 文件:新增 backend/app/ledger/certify.py;改 backend/app/api/ledger.py、backend/app/api/governance.py(聚类只用问题)、backend/app/api/copilot.py(from-run 加 freeze 或转调 certify)
- 对上的理念:模板定骨架:沉淀出的口径是固定 SQL,以后才能被等价比对认出来。人审之后才发布:提取器是作者,不是发布者。留痕:来源问题和「不是口径」的决定都查得到
- 否决的备选:沿用 from-run、保留 Agent:沉淀出的模板没有固定 SQL,以后永远比对不上,冷启动问题永远解决不了。只靠向量聚类:仍是看问题像不像,混着 history 更不准。自动发布:承诺要有人签字
### 9. 补齐来源链:运行↔轮次、画布来源、导出留痕
- 做法:① POST /api/runs 加 origin_ref {conversation_id, turn_id},只在 origin=chat 时收。服务端核对对话访问权和轮次归属,写进 Run.origin 和封存的 run.origin 事件;store/chat.ts 的 launch 带上它。② 「在画布里打开」:WorkflowIn 加 origin(run_id、conversation_id、turn_id,核对规则同 from-run),ChatPage 传上来。③ 报告导出和可验证报告下载,各写一条审计 AuditEvent(action=report.exported, subject=run, data={report_node, doc_artifact})。接口说明里的「不追加任何事件」改成「不追加运行事件,只记一条导出审计」。
- 复用:engine/origin.origin_note;ensure_conversation_access;AuditEvent(有 subject 索引);from-run 的 origin 形状
- 文件:改 backend/app/api/runs.py、frontend/src/store/chat.ts、backend/app/api/workflows.py、frontend/src/pages/ChatPage.tsx、backend/app/api/evidence.py、backend/app/apps/audit.py(或 auth/audit.py)
- 对上的理念:留痕:运行和轮次的对应、谁导出过,都进封存或审计。一种说法:来源一律写 origin
- 否决的备选:继续靠前端写的 meta.runId 反查:后端不解读它,前端也能改写,不能当账。把导出记成运行的封存后事件:运行还没结束时追加不了
### 10. 账本自身给出的统计(替代实验 E1–E4)
- 做法:新增 GET /api/ledger/stats?since=&origin=。E1 等价命中:有候选口径的查询里,proven / composed / related / none / skipped 各自的个数和比率,绑定失败的原因,差异类型的分布(time / filter / aggregate / join / other / parse)。E2 对账:发起、完成、失败的次数,兜底说明的占比,面板上「说明有用 / 看不懂」的反馈(记成对账运行上的封存后事件)。E3 重复度:问数运行里,查询形状出现 ≥3 次(来自 ≥2 段对话)的占比、聚类的占比,认证队列的转化(固化、发布、驳回)。E4 召回:每个口径版本被用了多少次,每次发布召回了几组,其中数变了的比例。全部从工件、运行和审计现算,不另存;评测那边可以直接调同一个函数。
- 复用:caliber_match 工件和 meta;runs.origin_kind;AuditEvent
- 文件:新增 backend/app/ledger/stats.py;改 backend/app/api/ledger.py
- 对上的理念:生效可查可对账。照实说:skipped 单独列,不混进 none
- 否决的备选:另建统计表或埋点:数据和账本会成两份
### 11. 前端
- 做法:证据正文:来自口径的数字加一个小标记,写明受管还是已发布;相关口径的数字不加标记,只在面板里说。面板:出处链最上面加「口径」一步,分三种情况。来自口径:「X」v3(受管)· 参数 · 怎么认的(等价已证明 / 助手选用)· 按口径正式出具。相关口径:差在哪 + 看差多少(打开对账:核对参数 → 发起 → 三个数各自点得开)。这次的推理:写「没有相关的已发布口径」。轮次底部加一行摘要(例如「3 个数来自口径「X」v3,1 个数是这次的推理」)和对应的动作;正式出具的结果挂在轮次下面。版本用量加上问数回答、导出、召回;发布弹窗预告召回规模。待办箱加 recall 种类的卡片。认证队列放在工作流列表页的「可沉淀的问题」抽屉里。文案全部进 lib/terms.ts,用「受管」、「完整出具 / 降档出具 / 不予出具」、「固定版本」,过 check-copy。
- 复用:EvidencePanel 的指标步骤;apps/EvidenceDrawer 按指标打开出处链的做法;versionUsage;TIER_TEXT;check-copy 白名单
- 文件:frontend/src/run/EvidenceDoc.tsx、frontend/src/run/EvidencePanel.tsx、frontend/src/run/AssistantStream.tsx、frontend/src/run/decode.ts、frontend/src/pages/ChatPage.tsx、frontend/src/store/chat.ts、frontend/src/api/apps.ts(ledger 客户端)、frontend/src/canvas/versionUsage.tsx、待办箱页面、新增 frontend/src/pages/ledger/CertifyQueue.tsx、frontend/src/lib/terms.ts、frontend/src/types.ts
- 对上的理念:界面层不放逻辑:标签、能不能出具、不能的原因全由接口给。一种说法
- 否决的备选:前端按 meta 自己推断出处
## 待拍板(本块)
- L1 路径一(整题等价)什么时候自动发起正式运行?|选项:只在 Agent 自己写的 SQL 被证明与口径等价、整题都覆盖、版本是受管级别、图里没有人工节点和写入、当天没超上限时自动;「助手选用」拼出的整题只给一键 / 「助手选用」的整题也自动发起 / 全部只给一键,不自动|推荐:第一种 | 自动给的信任只能来自可证明的独立解读,这和双盲复算是同一个道理。「助手选用」的数确实来自口径,但「这份口径答的就是这个问题」是助手凭问法判断的,由人点一下来代替证明。代价:同一题会多跑一次正式运行(含报告撰写的模型费用),所以要每日上限
- L2 问数据运行时,写 SQL 的 Agent 能不能看到已发布口径的定义?|选项:不看,保持独立解读;口径只给生成阶段的助手,供组合用 / 看口径说明,提高命中率|推荐:不看 | Agent 看过口径后写出的等价 SQL,只能证明它抄对了,证明不了问题和口径对得上,「等价已证明」就失去意义。代价是 E1 命中率偏低,靠 use_caliber 组合兜住常见问题
- L3 组合式问数的骨架用哪种机制?|选项:服务端按固定版本确定性地内联实例化:取数节点照抄,口径卡用 caliber_from 并扩一个 nodes 改名映射 / 沿用子图节点:先补子图证据交回父图、放开使用者的子工作流白名单|推荐:第一种 | 子图证据现在不交回父图,使用者又不能用子工作流;内联实例化只用到 tool 和 metrics 两种已放行的节点,证据照常封存,比对也照常复证。代价:caliber_from 要加改名映射,回归面涉及运行时、校验、升版检查、证据、对比和推演。注意:这一条偏离设计文档的原文
- L4 什么算「口径」,标签上怎么写?|选项:每个工作流最新的已发布、未退役版本,受管和已发布都算,标签写明级别;自动正式出具只限受管 / 只算受管级别|推荐:第一种 | 和 look 的 caliber、蓝图复用口径是同一种说法(latest_published)。已发布级别也有出具契约,照实标明级别比少认更有用;自动出具收紧到受管,因为它过了治理门禁
- L5 使用者(user 角色)能不能从问数据发起「按口径正式出具」、对账和召回后的重新出具?|选项:对账(只读的探索运行)都可以;正式出具只限有权使用的应用固定着的那个口径版本,复用应用授权 / 正式出具只给搭建者以上 / 都可以|推荐:第一种 | 现在使用者只能经应用发起正式运行(api/runs.py:108-127),复用应用授权不另起一套权限。代价:没有被应用固定的口径,使用者只能看到「请搭建者出具」
- L6 口径升版的召回怎么做、做多少?|选项:发布后自动排队重算:按参数去重成组,每次发布最多 50 组,发布弹窗预告规模;数真变了才开待办 recall,没变的只记账 / 发布后由人在账本页点「开始召回」 / 只在账本页列出受影响的答案,不重算、不通知|推荐:第一种 | 「口径升版是被迫处置的事件」延伸到已交付的答案,必须默认就有。重算只跑口径的数据部分,不调模型,成本可控;只在差值不为零时通知,避免太吵。50 是建议值,可以调
- L7 目录事实被驳回、表结构变动之后,「这次的推理」那类答案怎么处理?|选项:只标「依据有变」;按新目录跑 SQL 检查,出现新的错误级问题才通知;不自动重问 / 自动重问一遍再比对|推荐:第一种 | Agent 重答的结果不确定,数变了也分不清是目录变了还是模型变了,自动重问只会花钱制造噪声。SQL 检查是确定性的,结论能落到具体问题上
- L8 账本怎么存?|选项:内容寻址工件 kind=caliber_match,加一条封存后事件 ledger.matched,再给 artifacts.kind 补索引;不建新表 / 另建 caliber_uses 索引表|推荐:第一种 | 守住「建在证据链上、不另起存储」,比对依据可复核。代价:反查要扫 kind=caliber_match 的工件引用,补上索引后可以接受;规模真大了再加派生索引表,从工件重建即可
- L9 沉淀时要不要把 Agent 写的查询固化成固定 SQL?|选项:新增「固化取数」:被引用的查询变成调用工具节点,跨轮变化的字面量自动成为参数,人审之后再发布 / 沿用 from-run,保留 Agent|推荐:第一种 | 保留 Agent 的模板没有固定 SQL,以后永远比对不上,冷启动问题永远解决不了;参数和常量按跨轮变化来区分,是确定性的,还要过人审
## 风险
- 错给信任的唯一来源是 explain_sql_diff 判错了等价:绑定只当启发,等价只由现有的判等价和执行守门决定;绑定渲染后的 SQL 要进 test_sql_equivalence 的语料
- 命中率可能很低(Agent 的写法和口径不同、参数绑不上),路由的价值就小:用账本统计 E1 盯住,靠 use_caliber 组合兜住常见问题,规范化的改进放进 sql_semantics 正常迭代
- 「助手选用」的解读风险:助手可能把「含退款」的问题套到不含退款的口径上,数确实是口径的数,答的却不是问的。对策:标签写明「助手选用」并附上口径说明,提示里要求问法和口径有出入时不套,自动出具只给独立等价的情况
- caliber_from.nodes 改名映射牵涉运行时、校验、升版检查、证据、对比、推演,回归面大:没写 nodes 的老图要一字不差,单独写测试
- 比对耗 CPU:explain_sql_diff 一对可能要几百毫秒甚至更久。对策:按数据源和表预筛、每次运行限时、限并发、放在线程里跑,超时照实记 skipped
- 遮罩:绑定的值、差异条目、认证队列里的 SQL 样例都可能带出遮罩值。统一走 _Masks / _Shown;绑定是遮罩值的不代填出具
- 召回的噪声和成本:按参数去重、设上限、只在差值不为零时通知;目录和表结构的触发只标记,只试算一次
- 标签在封存之后才算出来,不在封存范围内:界面要写明这一点,并提供「复核比对」重算,同一算法版本的结果应当一字不差
- 列映射依赖「选择列的顺序在规范形式里不变」:已实测换顺序判不等价,但规范化里「排好序」的范围要在 sql_semantics 的测试里锁住
- 子图证据不交回父图这个缺口仍然存在:本方案绕开了它,没修。受管模板里用子工作流的报告同样引用不到子图的数,要进遗留清单
- 老运行没有 origin_ref,召回找轮次只能退回 conversation_turns,可能漏掉;按现在的口径回看老运行时,要标明是事后比对
- 使用者发起正式出具,和「正式运行只经应用入口」的现有边界有张力(见 L5)
- 设计文档原文里有本机计数和库名,按项目约定不进仓库;实施时的验收数据用虚构店铺库
## 测试
- sql_semantics 绑定:槽位在引号里、裸写、在半截字面量里,带过滤器,同一槽位出现多处要取到同一个值;别名不同、条件换序、BETWEEN 改写这些写法差异要能认出;IN 列表、写死的码值不同('paid' 对 'refunded')必须判不等价
- 绑定的健全性:在 test_sql_equivalence 的语料和随机数据上,凡是判了 proven 的,两条 SQL 在虚构库上执行结果必须一致;match_template 判等价、而 explain_sql_diff(E, render(T,B)) 判不等价的情况必须为零
- 列映射:外层别名不同时按位置对应;SELECT * 按表结构展开后对应;列数不同时不给映射
- 比对器:封存运行的夹具(含查询快照和口径版本)→ caliber_match 工件的内容和 meta;没封存、快照被改过、超时、解析失败各自记 skipped;绑定是遮罩值时不给值;同一次封存重复比对,工件哈希一致
- 标签:单元格、表格里的格、指标(所有输入都要对上、指标式要相同)、一个答案里两种数并存、整题判断及缺字段;evidence_segment 返回 ledger 键,没比对过的运行返回一字不差;导出 HTML 带标签
- 组合:use_caliber 实例化是确定的(同样的操作得到同样的节点);参数校验拒收后原因交回模型;caliber_from.nodes 改名后运行时取值、证据面板、升版检查、compare、recompute 都正确;chat_policy 放行;报告能引用 [[m:c1_…]];比对器把这些数标成 composed
- 正式出具:整题等价时用 ledger_issue 来源发起,origin 封存且带 expect_hash;以下情况拒绝并写明原因:缺入口字段、有人工节点或写入、版本已退役、权限不够、超过每日上限;同一绑定不重复发起
- 对账:派生图是确定的、能通过 validate、能封存;对账卡的差值和相对差正确;evidence_metric 出处链正常;参数核对后才发起;多行结果按键对齐,遮罩维度、键不唯一时拒绝
- 召回:发布 v2 后的计划分组、去重、上限、幂等(重启后重算计划);召回运行里两版之差的计算;只有变了的才开 recall 待办,没变的记 ledger.recalled;新版入口字段不兼容时照实记;有副作用的拒绝;通知消息里没有数字
- 目录与表结构触发:驳回一项影响数字的事实后,冻结目录里看得到这一项的运行被标「依据有变」;按新目录出现新错误级问题的才通知;表结构同步后口径试算失败才通知发布人
- 来源链:origin_ref 核对对话访问权和轮次归属,伪造的拒收;「在画布里打开」带上来源;每次导出都写审计
- 认证队列:按 SQL 形状分组、阈值、参数和常量的推断;固化出的草稿能通过 validate_graph 和受管 lint;origin.sources 记了来源问题;点「不是口径」写审计后不再进队;聚类只用 question
- 统计:E1–E4 的口径和夹具上的手算一致;skipped 单独计
- 前端:decode / stream / ui 三层检查(新增带标签的真实运行夹具);check-evidence(口径一步)、check-chat(轮次摘要、出具和对账按钮,写请求在浏览器层拦下)、check-report-export、check-versions(用量和召回预告)、check-inbox(recall 卡片)、check-copy;新增 check-ledger.mjs(认证队列、对账流程)
## 验收
- 真实后端,用虚构店铺库,建一个受管口径「周销售」:问一个和它等价的问题,答案里的数标「来自口径「周销售」v1(受管)· 等价已证明」,并按 L1 自动发起正式运行,轮次下显示出具档位
- 问「含退款的周销售」:数标「这次的推理」,面板里有相关口径和差在哪(例如:口径只保留已支付的行,这次没有这个条件);点「看差多少」核对参数后发起对账运行,按你的问法、按口径、差值三个数都点得开出处
- 问一个口径完全覆盖不了的问题:标「这次的推理」,没有任何相关口径的提示
- 助手用 use_caliber 拼出的答案:数标「助手选用」,只给一键出具,不自动发起
- 把「周销售」发布成 v2(改一个筛选条件):召回预告的规模和实际一致;只有数真变了的答案的主人收到 recall 待办,待办里旧口径值、新口径值、差值都点得开;没变的标「已核对,不影响」;推送消息里没有数字
- 导出过的报告在召回里能找到导出人;运行和轮次之间有封存的链接
- 同一类问题在不同对话里问 3 次以上,进认证队列;固化成草稿、按受管级别发布后,再问同样的问题,标签变成等价已证明
- 健全性:语料和随机数据上没有错判的等价;比对超时、失败都不影响运行结束和答案展示
- 遮罩:使用者账号下,标签、差异说明、认证队列、待办里都看不到遮罩值
- 后端全量 pytest,加上与本方案相关的 check-*(由编排者统一跑完整关口)
## 工作量
后端约 5–6k 行,其中测试约 3k 行。各部分的量级:sql_semantics 绑定和证明约 400 行;比对器、工件和标签约 900 行;组合(use_caliber 加 caliber_from.nodes)约 600 行;正式出具和对账派生约 700 行;召回约 800 行;沉淀和固化约 600 行;来源链、统计、设置约 400 行。前端约 2k 行(证据标签、面板的口径一步、轮次摘要和动作、对账视图、版本用量与召回预告、认证队列、待办卡片、文案)。建议拆成 5 个工作包,每包交给一个完整代理。WP1 认口径内核:绑定与证明、比对器、工件和事件、标签接口、证据片段 ledger 键、运行↔轮次 origin_ref、统计。WP2 组合与出具:问数据的 caliber 查看、use_caliber、caliber_from.nodes、正式出具、对账派生运行;依赖 WP1。WP3 召回:发布钩子、反查、召回运行、recall 待办与通知、导出审计、目录和表结构触发;依赖 WP1 和 WP2(要用 nodes 映射和派生图)。WP4 沉淀:认证队列、固化提取、origin.sources、「在画布里打开」带来源;依赖 WP1。WP5 前端:全部界面和检查脚本;接口契约在 WP1 定稿后就能对着夹具先做,最后接真接口。依赖关系:WP1 → WP2 / WP4 并行 → WP3 → WP5 收口;完整关口由编排者最后跑一次。
## 和其他几块的接口
- 评测:E1–E4 由 ledger/stats.py 给出;评测框架可以直接调 ledger.match 里的比对函数;双方共用 test_sql_equivalence 的语料;E2 的「说明有用 / 看不懂」反馈口径要和评测约定
- 目录维护:审阅写入(data/catalog.py、data/catalog_review.py 的 review_item / review_batch)之后要挂召回钩子;本方案依赖表结构快照里冻结的目录(tools/datasource._store_schema),目录那块如果改冻结的格式要同步;影响面 catalog_impact.table_impact 两边共用
- 对外集成:通知事件新增 ledger.recall,和渠道、订阅模型共用(notify/service.py EVENTS);API 令牌要覆盖 /api/ledger 的新接口;视图嵌入里的应用印章已经写了口径,召回的「应用」反查用 pinning_apps
- 遗留:子图证据不交回父图(engine/nodes/multi.py:723-731)、node_prefix 没人读(:680)、节点手册的子工作流缺 workflow_version(api/copilot.py:200),宜放进遗留清单;本方案绕开了,没修
- 蓝图助手:look/calibers.py 两边共用(api/copilot_blueprint.py:289-292),问数据加的入口字段和「能不能组合」不能改变蓝图的行为;caliber_from.nodes 也会影响 blueprints/templates/_common.reuse_nodes。顺带一个机会:蓝图复用口径卡时模型新写的取数 SQL,可以用同一个比对器核对它和源口径是否等价,要和蓝图那块约定
- 可能冲突的文件:api/copilot.py、api/copilot_context.py、api/copilot_gen.py、api/evidence.py、engine/runner.py、engine/nodes/metrics.py、engine/schema.py、api/workflows.py、api/runs.py、db/models_process.py、notify/service.py、api/settings.py、frontend/src/run/EvidencePanel.tsx、frontend/src/run/AssistantStream.tsx、frontend/src/lib/terms.ts、frontend/src/store/chat.ts
- 应用平台:apps/backfill._side_effect 要抽成共用函数;新的 origin_kind(ledger_issue、reconcile、recall)要加进 auth/access 的可见性规则(参照 _VIEW_ORIGINS)和记录页的来源筛选
- 设计文档要改:第三部分 §3.4.1(子图做骨架改成内联实例化)、§3.4.2(字面量不算不同改成先绑定参数再证明)、§3.8(实验改成持续统计),用词按 lib/terms.ts(受管、完整出具 / 降档出具 / 不予出具、固定版本)
## 附:现状(代码位置)
- 问数据(intent=answer)的搭图约定写死在提示里:input → agent → report → output,Agent 运行时自己写 SQL(_user_message)。查看只开 table、capability 两类,没有 caliber。 [backend/app/api/copilot.py:360-427;_look_service 默认 kinds 在 backend/app/api/copilot.py:111-118 → backend/app/api/copilot_context.py:67-80]
- 问数据的运行由前端用裸图发起,是 origin=chat 的探索运行。运行上不记属于哪段对话、哪一轮:StartRunIn 没有这类字段。轮次和运行的对应只存在 conversation_turns.run_id 和前端写的 meta.runId 里,后端不解读 meta。 [frontend/src/store/chat.ts:1500-1508;backend/app/api/runs.py:36-48、89-140;backend/app/api/conversations.py:70-82、139-153]
- look 已经有 caliber 类型,但只给蓝图助手用。它只列每个工作流最新的「已发布且未退役」版本里源头口径卡上的指标。只有卡的每个输入都是 cell(nodes.X,0,'列')、X 又是查库的调用工具节点时才算能复用。工作流版本的详情故意不给 SQL(SQL 里可能写着遮罩值)。 [backend/app/look/base.py:20;backend/app/look/calibers.py:82-122(card_inputs)、124-145(views_of)、148-170(CaliberKind.load);backend/app/api/copilot_blueprint.py:289-292;backend/app/apps/describe.py:1-5]
- 子图节点在运行时会按 workflow_id + workflow_version 执行固定的快照,但交回父图的只有 output 和 vars,子图的证据台账 state["evidence"] 不交回。所以父图的报告引用不到子图里的查询格和口径卡。extra.node_prefix 设了值,全仓没有代码读它。使用者问数据的节点白名单里没有子工作流。助手节点手册里子工作流只写了 {workflow_id, input},没写 workflow_version。子图内部节点的自定义事件会不会进父运行的事件流:没核实。 [backend/app/engine/nodes/multi.py:613-731(updates 在 723-731,node_prefix 在 680);backend/app/engine/compiler.py:127;backend/app/auth/chat_policy.py:32-33;backend/app/api/copilot.py:200]
- caliber_from 把一张口径卡固定到别的工作流某一版的卡上:运行时按那一版取指标式,升版处置和子图走同一套规则。卡上的 cell(nodes.X…) 要求本图的取数节点同名。蓝图复用口径时,取数 SQL 是模型新写的生成关节,没有任何地方核对它和源口径的取数 SQL 是否等价。 [backend/app/engine/nodes/metrics.py:235-290;backend/app/engine/governance.py:679-731;backend/app/blueprints/templates/_common.py:518-528、548-560]
- sql_semantics 已经实现:normalize_sql 把字面量换成占位符;explain_sql_diff 只有规范形式一致才判等价,不等价时一定给出差异条目,另有执行比对守门。我把代码拷到 /tmp 实测:外层列别名不计(AS gmv 和 AS 销售额 判等价);选择列的顺序计(换顺序判不等价);日期字面量不同判不等价;status='paid' 和 'refunded' 两条的 normalize_sql 文本相同(same_shape 为真)。结论:设计文档 §3.4.2 的「字面量不算不同」不成立。explain_sql_diff 不忽略字面量;只比规范化文本,又会把写死的码值不同当成同一口径。 [backend/app/data/sql_semantics.py:319-370(normalize_sql)、5920-5960(SqlDiff)、7932-8008(explain_sql_diff)、8012-8034(diff_facets);backend/tests/test_sql_equivalence.py]
- 「先绑定、再证明」的做法已在 /tmp 实测可行:把口径模板里的 {{ input.x }} 换成哨兵值,在 optimizer 级规范形式上抹掉字面量后比指纹,按位置对齐字面量,取出参数;再用 render_template 渲染口径 SQL,交给 explain_sql_diff 复证。别名不同、条件换序、BETWEEN 改写成比较这几种情况都判等价。不等价时给出的说法可读,例如「口径只保留 status = 'paid' 的行,这次没有这个条件」「这次按 created_at 筛时间,口径按 paid_at」。 [backend/app/data/sql_semantics.py:2974-3030(_Form、_fingerprint)、3091(_canonical_forms);backend/app/engine/expressions.py:172-210(render_template)]
- 运行对比已经区分「只差参数」和「改了写死的值」:执行的 SQL 只差字面量时,再看图里的 SQL 有没有改过。整个模块只读、是纯函数。 [backend/app/engine/compare.py:1-60、844-866(_compare_sql)、916-944(_edited_sql)]
- 证据链:每次查询都存一份查询快照,里面有 SQL、遮罩列、data_version,还有表结构快照(内含当时冻结的目录 {表: version, notes})。工件引用的 meta 记了源和表名。封存范围只认 seq≤manifest_seq 的事件(_Sealed/_sealed)。指标链给出口径卡名、版本、caliber_from 的来源、升版处置和双盲复算。/evidence/metrics/{node}/{metric} 没有报告也能点开指标。封存之后追加批注已有现成做法(run_append 的 sealed 方式,evidence.judged 就是这么写的)。runner 封存之后已经有一个后台 spawn 的位置。 [backend/app/tools/datasource.py:640-700、729-760;backend/app/data/catalog.py:2390-2404;backend/app/api/evidence.py:180-299、820-848、878-1000;backend/app/engine/evidence.py:851-975;backend/app/engine/run_append.py:1-20;backend/app/engine/runner.py:1017-1031]
- 正式运行只能从已发布、未退役的版本发起,按 graph_hash 钉死;口径升版未处置时拒绝发起;目录漂移(CATALOG_DRIFT)只提醒、不拦。运行来源 origin_kind 记在封存的 run.origin 事件里,客户端只能声明 chat/canvas/launcher/api 四种。使用者(restricted)只能发起问数据的探索运行。 [backend/app/engine/launch.py:99-170、186-215;backend/app/engine/origin.py:15-31;backend/app/api/runs.py:108-127]
- 「账本」这个接口命名空间已经有了:/api/ledger 返回某个工作流版本被哪些应用固定、生效记录数、正式运行数。前端只在版本用量里用到它。 [backend/app/api/ledger.py:1-29;backend/app/apps/versions.py:188-233;frontend/src/api/apps.ts:95-100;frontend/src/canvas/versionUsage.tsx:15-24]
- 沉淀:/copilot/from-run 确定性地提取执行路径,保留 Agent,输入值变成入口字段的默认值,新工作流记 origin {run_id, conversation_id?, turn_id?}。「在画布里打开」用 workflows.create 新建工作流,不带来源(WorkflowIn 没有 origin 字段)。探索问题聚类拿整个 Run.input JSON(含 history)做向量贪心聚类,前端定义了接口但没有调用方。 [backend/app/api/copilot.py:2552-2690;backend/app/db/models.py:594;frontend/src/pages/ChatPage.tsx:306-320;backend/app/api/workflows.py:40-44、136-155;backend/app/api/governance.py:91-160;frontend/src/api/client.ts:770]
- 和召回相关的现状:发布只改版本行,不发事件、不写审计。应用有「按新口径补算」:只补没有副作用的工作流,按输入指纹去重。定时运行结束后会自动和上一次对比,分类里已有「口径与数据都变」。报告导出明写「只读:不追加任何事件」,谁导出过哪份报告没有记录。目录项有驳回状态,影响面 table_impact 只看已发布的模板。上传表格发起运行时按当前快照固定数据版本,不能指定历史快照。 [backend/app/api/workflows.py:504-546;backend/app/apps/backfill.py:1-30、108-139;backend/app/apps/resolve.py:125-127;backend/app/scheduler/auto_compare.py:1-30;backend/app/api/evidence.py:2787-2867;backend/app/data/catalog.py:69、748;backend/app/data/catalog_impact.py:1-20;backend/app/engine/runner.py:1168]
- 待办与通知:待办种类里有 review,是「没有审批、挂在一次运行上」的先例,Task.run_id 是非空外键。通知事件共五种,消息里不放数字。 [backend/app/db/models_process.py:21-22、36;backend/app/process/tasks.py:142-165;backend/app/notify/service.py:1-12、30-41]
- 全站文案的叫法和设计文档不一致:界面上叫「受管」,不叫「已治理」;出具三档是「完整出具 / 降档出具 / 不予出具」;版本写「固定版本」,不写「钉住」。 [frontend/src/lib/terms.ts:18-22、150-152;backend/app/apps/resolve.py:50]
@@ -0,0 +1,166 @@
> 分块细案(2026-10-16 只读调研整理,代码基准 main d135a17)。和 [00-总方案](00-总方案.md) 冲突的地方以总方案为准;总方案「跨块统一的决定」一节列出了改动过的条目。决定编号在本文件内有效,总方案里引用时带前缀(如 评测-D4)。
# 数据接入与目录维护(不含新数据库类型)
## 缺口
- 几千张表的库用不了:不能按表名限定范围,超出上限的部分按不稳定的顺序被截掉。目录、look、剖析、起草、SQL 检查对窗口外的表要么看不到,要么说错(「表已不在」「请重新探查」) —— 违背「照实说」。企业 ERP、数仓单个 schema 上千张表很常见,开发环境里已经撞上了 [introspect.py:180-183;api/catalog.py:261;catalog_profile.py:1431]
- 探查是同步的、整份替换、不排队。MySQL 一张表一次往返,范围一大同步请求就会超时;和起草、剖析同时进行时,目录和结构会对不上 —— 大范围必须是后台任务。和起草、剖析同一个源要串行,才能保证「生效可查」 [api/datasources.py:822-865]
- 看不到表结构的变化:没有前后差异,也没有变化记录。删列以后,那一列上人工确认过的目录项、写死了这一列的已发布模板都收不到提醒,正式运行要到查询那一步才报错 —— 缺留痕,缺照实说。口径账本的召回也缺「表结构有变动」这个触发源 [catalog_impact.py:355;engine/launch.py:164-199]
- 列类型丢了长度和精度(Oracle 的 VARCHAR2 / NUMBER、MySQL 的 ENUM):类型变化检测不出来,MySQL 的 ENUM 状态列永远进不了码值候选 —— 结构比对的前提就是类型写全;ENUM 是最典型的状态码列 [introspect.py:272;catalog_profile.py:1081-1094]
- 取值漂移有四个缺口:确认过的码值列不再观察;大表只看最老的 10 万行;没有最新业务日期(新鲜度)、按日行数、总行数的观察和历史;「已列全」过期以后 SQL 检查还会照它提示 —— 码值出现新取值、比例剧变、日期断档都看不到;有效记录条件可能悄悄漏掉新状态 [catalog_profile.py:1115, :843-847, :1191;sqlcheck.py:925-930]
- 没有定时维护:只能手动探查、手动剖析 —— 目录只在点按钮那一刻是对的;用户要的「目录维护」离不开它 [scheduler/loop.py:36-51]
- 变化没有统一的记录:进不了审阅队列,发不了通知,口径账本的召回也没有可以订阅的接口 —— 留痕;一种说法(不能各处各记一份) [catalog_review.py;notify/service.py:30]
- 剖析 SQL 在真库上的语义风险:IN 列表的字面量只按子表列类型写;Oracle 小写带引号的对象;统计信息过期把大表当小表;ENUM 状态列被漏掉;MySQL 零日期、PG infinity 日期;CHAR 列的尾随空格;大表「抽样」其实是物理顺序的前 N 行 —— 剖析是全站唯一主动查业务库的地方,错了要么让关系结论站不住,要么把止损打满 [catalog_profile.py:1011-1060, :265, :285-311, :1184-1207]
- 方言没有真库验证的路径:只有守卫放行和字符串片段两类测试 —— MySQL / PG / Oracle 上的剖析从来没在真库上跑过(数据目录那一轮的遗留,见记忆) [tests/test_catalog_profile_units.py:124-166]
- Excel 期 5:列表类月报(每月一份明细导出)不能按期累积,夹在中间的小计会让规则起草失败,CSV 不能走配方,表格对象这么可靠的边界信号也没用上 —— 真实场景里最常见的就是每月一份明细导出 [recipe_types.py:1125-1180;recipe_suggest.py:770-815]
- 剖析结果(行数、日期范围、最新业务日期)还没进 look 的表详情(已知遗留) —— 助手写 SQL 时不知道表有多大、数据到哪天了 [data/schema_view.py:118-153]
## 做法
### A1 对象索引 + 探查范围 + 后台探查任务(大库的主方案)
- 做法:① 新表 source_objects,一个对象一行:source_id、name(schema_cache 的键)、kind(table / view)、comment、est_rows、in_scope、scope_reason(rule / explicit / pinned / on_demand)、meta(反射到的完整结构,只有范围内的有)、fingerprint、struct_version、first_seen_at、last_seen_at、removed_at、reflected_at。② 对象索引:每种方言一条查询拿到名字、类型、注释(MySQL 查 information_schema.TABLES 带 TABLE_COMMENT / TABLE_ROWS;PG 查 pg_class 加 obj_description;Oracle 查 ALL_TAB_COMMENTS 加 ALL_OBJECTS;SQLite 查 sqlite_master)。查失败就退回 Inspector 的 get_table_names / get_view_names,只是没有注释。索引上限 50,000 个对象。③ 范围放在 options.introspect_scope:{mode: all|rules, include: [通配], exclude: [通配], tables: [显式], views: bool},上限 max_tables 缺省 500、最大 2,000。规则按 name_key 不分大小写匹配,排除优先。有目录的表、已发布模板写死 SQL 用到的表(catalog_impact 里的 _published 加 graph_sql_tables)一律 pinned,规则排不掉,只有显式移出才行,移出时记一条变化。规则命中超过上限时拒绝开始,并说出命中了多少张,不悄悄截断。④ 探查改成后台任务(data/introspect_jobs.py),复用 catalog_job_runtime 的 JobRun / JobManager / SerialWriter / close_job / interrupt_on_startup,SourceLanes 新增一种 kind=introspect,和同源的起草、剖析串行。逐表反射,看得到进度,能停止,服务重启标中断、不续跑。POST /introspect 改为返回 202 和任务;dry_run 预览保留同步,但只刷新索引、返回命中数。⑤ schema_cache 由唯一的一个函数 rebuild_schema_cache 从范围内已反射的行物化出来,形状不变(tables、total、truncated、schema),另加一个 scope 摘要(in_scope、cap、out_of_scope)。220 处调用点不用改;total 改为索引里的对象总数,truncated 的意思变成「有范围外的对象」。⑥ 「表已不在」只在 removed_at 有值时才说;范围外、反射失败(skipped)、已删除,三种情况分开说。
- 复用:introspect._collect(反射核心改成接受一个表名列表)、list_schemas 和 _SCHEMA_STATS_SQL 的写法、catalog_job_runtime 整套、catalog_impact._published 和 graph_sql_tables、names.name_key、health / explain 的报错翻译、db/base._migrate(只加不改)
- 文件:新增 data/source_objects.py、data/introspect_jobs.py;修改 data/introspect.py、api/datasources.py、db/models.py、data/catalog_job_runtime.py(新增 lane kind)、api/coded.py(introspect_busy、scope_too_large、introspect_finished)、api/catalog.py(in_schema 拆成三种原因);前端 pages/DataSourcesTab.tsx(探查范围面板)、api/client.ts、types.ts、lib/terms.ts(SCHEMA_PARTIAL_TEXT 改写)
- 对上的理念:照实说(范围外、反射失败、已删除分开说;超上限就拒绝,不截断)。留痕(范围规则改动前后、谁、什么时候)。只读(只发字典查询和反射)。一种说法(schema_cache 形状不变,所有消费者不改)。生效可查(每次探查是一条任务记录)
- 否决的备选:把 _MAX_TABLES 提到几千、全量反射:schema_cache 是整块 JSON,每次读数据源都整份加载;冻结的快照按张数线性增长;MySQL 每表一次往返;目录页和 look 要全量渲染。按 200 张一页分页探查:PG、Oracle 的反射查询没有 ORDER BY,每页的内容会漂,人也不知道要的表在哪一页。把 schema_cache 彻底拆成按表的行、改掉 220 处调用:代价大,和本轮目标无关,所以改成物化子集。按方言写列指纹 SQL 做增量探查:多出三套必须和 Inspector 结果一模一样的手写字典查询,稍有偏差就报假变化。范围上限内全量反射已经够用,增量留到实测证明慢的时候再做
### A2 大库下 look / db_schema / 目录 / 剖析 / 起草 / SQL 检查的配套
- 做法:① 新增 schema_view.search_tables:BM25 预筛,切词用 memory/embeddings._tokens 加 catalog.name_words,文档内容是表名、注释、目录里的中文名 / 粒度 / 说明、列名、列的中文名(遮罩列只用列名)。look 的 pick 在候选超过 PICK_LIMIT 时,只把前 120 个命中、工作流已用到的表(seeds)、命中表的一跳关联表交给挑选模型,挑选提示词从几万字降到 6,000 字左右。范围外的对象只按名字和注释参与检索,权重低一档。② 按需加入:look 点名或挑中范围外的对象、运行时 db_schema__ 点名范围外的表,都当场只读反射这一张(大约 5 条字典查询,受单条时限约束),落盘为 scope_reason=on_demand,记下谁、哪一轮或哪次运行、need 原文。每次查看最多 4 张,每次运行最多 8 张,超了照实说。look 记录增加 added_to_scope 字段。写 schema_cache 只走 rebuild_schema_cache,按源加进程内锁(和现有回收锁同一个前提:单进程部署)。③ db_schema__ 增加可选的 keyword 参数,和 look 用同一个 search_tables;不传 table 时只列范围内前 50 张,并提示用 keyword 或 table。④ 目录:清单只列范围内的表;搜索框查对象索引,范围外的结果给「加入范围」;前端分批渲染(每次 200 行加「显示更多」),不引入新依赖。⑤ 剖析和起草:从范围内挑表。_relation_ends 区分「被指向的表在探查范围外(可以加入范围)」和「表结构里没有」。⑥ SQL 检查:范围内的不变;查询碰到范围外但在索引里的表时,结果里照实列出「未按目录检查的表」,不新增检查规则。⑦ 已知遗留顺带做掉:schema_view.table_detail 附上剖析和观察的结论(估算行数和依据、各日期列的范围、最新业务日期),遮罩列不给。
- 复用:memory/embeddings.BM25 和 _tokens、catalog.name_words、catalog.render_table_index、look.pick.Picker(提示词不变,只是喂给它的目录变短)、schema_view.complete(补中间表)、A1 的单表反射
- 文件:修改 data/schema_view.py、look/tables.py、look/base.py(新增常量 PREFILTER_LIMIT、ON_DEMAND_PER_LOOK、ON_DEMAND_PER_RUN)、tools/datasource.py(_make_schema_tool、_query_description)、api/catalog.py(新增 GET /catalog/search 和 POST /scope/add)、data/catalog_profile.py:1420-1440;前端 pages/catalog/CatalogPage.tsx、TableIndex.tsx、run/decode.ts(look 记录里的 added_to_scope)
- 对上的理念:照实说(范围外的表照实标「未检查」「按需加入」)。留痕(按需加入记下 need 和发起方)。不挡生成(检索、按需探查失败只是少一点上下文)。遮罩(检索文档里遮罩列只有列名)。一种说法(look、db_schema、/schema 共用 schema_view)
- 否决的备选:look 遇到范围外的表只临时取结构、不落盘:SQL 检查和目录都看不到这张表,下一轮还得再反射一遍。挑表之前先调一次模型做粗筛:多一次模型调用,经 OpenAI 兼容接入的推理模型关不掉思考,45 秒的时限已经很紧。引入向量检索:表名和列名基本是字面匹配,BM25 够用,而且零配置
### B1 表结构变化:前后差异、影响面、结构版本
- 做法:① 纯函数模块 data/schema_diff.py:输入是同一张表前后两次的 meta,输出变化列表。表级:added(新进范围或新建)、removed(索引里也没了)、left_scope、reflect_failed,其中后两种不算删除。列级:added、removed、type(比较按方言编译后的类型,见 C1)、nullable、comment。键:主键、唯一约束、外键的增删。被删的列和新加的列类型相同、位置相同时,标「可能是改名(未确认)」,不下结论。② 影响面:目录方面,列出说到这些列的所有路径(columns.X.*、keys、business_date.column、relations 两端,以及 valid_filter 里引用的列,用 sqlcheck.parse_valid_filter 取列),带上各自的状态;人工确认过的项升为「要处理」。模板方面,用 catalog_impact.table_impact 算直接引用和可能涉及;直接引用的那几条写死的 SQL,再拿新结构跑一遍 sql_unknown_columns(从 api/copilot.py 挪到 data/,两边共用),能说出「这条 SQL 现在会报列不存在」。③ 每张表有 struct_version,fingerprint 变了才加 1。发布时多记一列 WorkflowVersion.schema_versions = {源名: {表名: 结构版本}}(只记直接引用的表,写法照 catalog_versions_for)。正式运行发起时比对,有变化就写一条新事件 schema.drift(只提醒、不拦),界面和 catalog.drift 共用同一块横幅(Banners.tsx、VersionsSheet.tsx)。发布前检查列出这一版用到的表上还没处理的结构变化。④ 升级以后第一次探查只建立基线,不报变化(类型写法从通用改成方言完整写法,不能当成类型变了)。
- 复用:catalog_impact.table_impact、template_refs、catalog_versions_for 的记法;sqlcheck.parse_valid_filter;copilot.sql_unknown_columns;launch.py 写 notes 的位置(:196-199);EventType 的登记方式
- 文件:新增 data/schema_diff.py;修改 data/catalog_impact.py、api/copilot.py(把 sql_unknown_columns 挪到 data/sqlcheck_columns.py)、engine/launch.py、core/events.py(SCHEMA_DRIFT="schema.drift")、db/models.py(WorkflowVersion.schema_versions)、api/workflows.py(发布时记、发布前检查);前端 run/decode.ts、pages/runs/Banners.tsx、canvas/VersionsSheet.tsx、lib/terms.ts
- 对上的理念:照实说(范围外 ≠ 删除,改名只说「可能」)。留痕(结构版本可钉、可对账)。口径升版是被迫处置的事件:结构变化同样钉版本、发起时提醒;但结构变化不是口径,所以只提醒、不拦,和目录漂移同档。正式运行只从已发布版本发起(不变)
- 否决的备选:拦下正式运行:结构变了不一定影响这条 SQL(比如只是加了一列),一刀切会卡住正式出具;真删了列,查询本身就会照实报错。把结构变化塞进 catalog.drift 同一个事件:老的渲染逻辑只认整数的目录版本,混进去会误读;所以分开记,界面上合到一块横幅里显示
### B2 取值观察(列级取值漂移)与新鲜度
- 做法:① 在剖析引擎里加四类检查,计入每张表的预算、止损和留痕:codes_observe——所有码值列都观察,包括人工确认过、标了已列全的列;只读,不改含义。freshness——业务日期列(已确认或已验证)的 MAX:小表直接查,大表只在该列是某个索引的第一列时才查(A1 把索引的首列存进 meta)。daily_volume——最近 35 天按天计数,条件同上。rows——统计信息估算或有上限的计数。② 每条观察都记口径 basis:full、first_rows N、window since D 或 sample p%,只和口径相同的历史比。③ 新表 table_observations:source_id、table、column、kind、basis、data、job_id、at,每张表每种观察保留最近 26 条。④ 纯函数比较器 data/value_drift.py,规则如下。新码值:已列全的码值表出现任何新值都算;没列全的,新值要占非空行的至少 0.5%。比例剧变:PSI ≥ 0.25 记 warning,0.1–0.25 记 info,两次都要至少 1,000 行,并列出变化最大的几个取值。断档:最新业务日期没前进,且距上次的时间超过 max(2×中位间隔, 3 天)。缺天:窗口内有日子没数据,而以前的窗口没有过。量跌:最近一个整天不到前 28 天中位数的 30%。行数:跌了 30% 以上;依据是估算值时,要连续两次都这样才报。⑤ 机器唯一能写进目录的一件事:观察到新取值时撤销码值项上的「已列全」标记。目录版本加 1,现有的 catalog_drift 和冻结快照自然会跟上,SQL 检查也不再按过期的码值表提示。新取值本身、含义、状态都不改,交给人在变化里处理。⑥ 大表的口径按 D7:有可用的业务日期索引就用窗口;PG / Oracle 没有就用块采样(TABLESAMPLE SYSTEM / SAMPLE BLOCK);MySQL 没有就只看前 N 行,照实写口径,不跨口径比较。⑦ 遮罩列不出现在任何一条观察查询里;变化里的码值只记非遮罩列的。
- 复用:catalog_profile 的 TableBudget、SourceRunner、_TableCx.run(留痕、守卫、时限)、estimate_size、value_counts_sql;catalog._merge_codes;catalog.make_item;catalog_profile_jobs 的范围挑选(new_checks 再加一条「有观察项到期」)
- 文件:修改 data/catalog_profile.py(新增 check kind、SqlDialect 新增 date_literal / day_trunc / sample_from)、data/catalog_profile_jobs.py、db/models.py(TableObservation);新增 data/value_drift.py;修改 data/schema_view.py(观察结论进表详情);前端 pages/catalog/ProfileDialog.tsx、TableDetail.tsx(「结构与取值变化」一节)
- 对上的理念:照实说(口径不同不比;大表只看前 N 行就写明)。只读与遮罩。目录里只放事实:撤销「已列全」是数据给出的事实,含义和状态仍然只由人定。不挡生成(观察失败只记跳过)
- 否决的备选:确认过的码值列不观察:新状态永远发现不了,有效记录条件会悄悄漏掉新状态的行。机器把新码值直接写进确认过的码值表:等于替人改了已确认的事实。大表一律只看前 N 行(现状):物理顺序靠前的多半是老数据,正好看不到新取值。用全表 COUNT 做行数漂移:大表上太贵,统计信息加有上限的计数已经够判断大幅变化
### B3 变化记录、审阅、通知,以及给召回的接口
- 做法:① 新表 source_changes:seq(自增,当游标用)、id、source_id、source_name(当时的名字)、table_name、column_name、kind(结构类:table_added、table_removed、table_left_scope、column_added、column_removed、column_type_changed、column_nullable_changed、key_changed、fk_changed、comment_changed、scope_changed;取值类:codes_new、codes_shift、date_stalled、date_gap、volume_drop、rows_drop、rows_jump、coverage_drop)、severity(info / warning / action;action 表示现在大概率已经不对了:已确认的项或写死的 SQL 指向被删的列,或者已列全被撤销)、detected_at、detected_by({job_kind, job_id, task_seq, trigger: manual|schedule|on_demand, actor})、basis({method, query_refs})、before、after、affects({catalog: [{path, status, source}], templates: [{workflow_id, version, impact, nodes}], rules_touched: bool})、struct_version_before / after、fingerprint(去重用:同一个变化再次测到只更新 last_seen_at 和 seen_count)、status(open / acknowledged / resolved / dismissed)、resolution({by, at, action, note, catalog_version})、schema(固定为 agentlab-change/1)。② 审阅页加一个「变化」标签页,排序沿用审阅队列的思路:使用次数权重 × 严重程度。处理动作都落到已有的操作上:清理目录里的这几项(save_human_edit)、加入码值(apply_patch 配 _merge_codes,含义待填写,确认时的依据写这条变化的编号)、重新起草这张表(建起草任务)、重新剖析(建剖析任务)、知道了、误报(必须写理由)。每个动作都写 resolution,涉及目录的还要记下目录版本。③ 站内:数据源卡片上的角标「N 个变化要处理」、目录页头部的计数、表详情里的一节。④ 外部通知:notify 加一个事件 source.changed,只推 severity 为 action 的,按源汇总,消息里只有源名、各级别条数和链接(D9)。⑤ 给口径账本的召回:data/source_changes.py 提供 list_changes(session, *, since_seq, source_id, kinds, status, limit)、affecting(session, source_name, tables, since)、resolve(...)。触发召回的只有结构类、目录事实类(处理时改了目录)、以及 rules_touched=true 的 codes_new;纯取值漂移只算数据质量提醒,不触发召回。召回在调度循环的 process_due 里按 seq 游标拉取,不另建进程内事件总线。
- 复用:catalog_review 的权重和分页;catalog.apply_patch、save_human_edit、_merge_codes;catalog_jobs / catalog_profile_jobs 的建任务入口;notify.service(spawn、渠道订阅、投递记录);scheduler.loop.process_due 的步骤列表;catalog.query_snapshot_tables(召回反查「谁用过这张表」时用)
- 文件:新增 data/source_changes.py、api/source_changes.py(GET /api/datasources/{id}/changes、POST /changes/{id}/resolve);修改 db/models.py(SourceChange)、data/catalog_review.py、notify/service.py(EVENTS、EVENT_LABEL、消息拼法)、api/coded.py;前端 pages/catalog/ReviewQueue.tsx(「变化」标签页)、pages/DataSourcesTab.tsx(角标)、pages/catalog/TableDetail.tsx、lib/terms.ts(统一叫「结构变化」「取值变化」,和已经在用的「目录有变化」分开)
- 对上的理念:留痕(谁测到、什么口径、前后各是什么、谁怎么处理的)。一种说法(全站只有 source_changes 这一份记录;处理动作复用目录已有的写入接口)。生效可查可对账(处理结果连到目录版本)。只读与遮罩(不记遮罩列的取值,通知里不带表名和取值)
- 否决的备选:把变化塞进审阅队列、和推断项混成一张表:两边的动作完全不同(确认 / 驳回 / 撤销,对比 清理 / 加码值 / 知道了),批量操作会混乱。复用 RunEvent 来记:变化不属于任何一次运行,而且需要「处理」这个状态。进程内事件总线推给召回:重启就丢,靠游标拉取更可靠
### B4 定时维护
- 做法:新表 source_maintenance,每个源一行:cron、timezone、enabled、next_run_at、misfire_grace_s、steps(introspect / profile_needed / observe,可多选)、last_fire_at、last_jobs。认领沿用 scheduler.loop 的做法(条件 UPDATE 推进 next_run_at、错过的只补最近一次、积压只记一条),作为一个新步骤放进 process_due。到点按顺序建探查任务 → 剖析任务(范围 needed,再加观察到期的表),任务上记 trigger=schedule 和 fire_at,靠 SourceLanes 串行。剖析没开、数据源停用、AGENTLAB_SCHEDULER 关着时不跑,照实记一条未执行。缺省关闭,开启时要在数据源设置里显式打开,并选一个低峰时段。界面把 ScheduleEditor 里的频率选择器抽成公共组件来复用。
- 复用:scheduler/cron.py(parse、next_fire、last_due、preview、中文描述)、loop._claim 的条件 UPDATE 写法、catalog_job_runtime、ScheduleEditor 的频率选择
- 文件:新增 data/source_maintenance.py;修改 scheduler/loop.py(process_due 加一步)、db/models.py、api/datasources.py(GET / PUT /maintenance)、data/catalog_profile_jobs.py 和 introspect_jobs.py(trigger 字段);前端 pages/DataSourcesTab.tsx、把 pages/schedules/ScheduleEditor.tsx 里的 FreqPicker 抽到 components/
- 对上的理念:只读、不给业务库加压(缺省关、只在设定时段跑、预算和止损照旧)。照实说(没执行要写明原因)。留痕(每次触发对应几条任务记录)。一种说法(cron 和认领沿用调度那一套,不另写)
- 否决的备选:挂在工作流定时任务上:要先建一个工作流去调「剖析」,模型、审批、封存那一套全被卷进来,而且 Schedule.workflow_id 是必填的。把 Schedule 泛化成 kind + target:定时任务页、通知、自动对比都默认它是工作流,改动面太大
### C1 剖析与探查的方言修正
- 做法:① IN 列表的字面量按被指向列的类型族生成(catalog._type_family):父列是文字、子列是数字时写成 '123';父列是数字、子列是文字时,只收整数写法的值;类型族对不上又转不了的,跳过并说明「两端类型不同」。② 列类型改存按方言编译的完整写法:col_type.compile(dialect=conn.dialect),编译失败退回 str。ENUM / SET 另外记下取值列表,enum_values 进 meta。_code_type_ok 认 ENUM(取值列表有限,直接算码值候选)。③ Oracle 的 quoted_name(quote=True) 在 meta 里另记 quote_name: true;SqlDialect.quote 和 stats_sql 按它强制加引号、不做大小写转换。④ 统计信息估出的行数不超过 max_scan_rows 时,再跑一次有上限的计数确认(多 1 条查询),防止统计过期把大表当成小表,对它做整表统计。⑤ MIN/MAX 的结果过滤掉 0000-00-00 和 0001-01-01 / 9999-12-31 这类哨兵值,照实写「有 N 个无效日期被排除」(零日期、infinity 的驱动返回值没核实,按两种可能都防)。⑥ 新增取值观察用的方言方法:date_literal(三家都写 DATE 'YYYY-MM-DD',SQLite 写字符串)、day_trunc(PG 用 CAST(x AS DATE),MySQL 用 DATE(x),Oracle 必须用 TRUNC(x),因为 Oracle 的 CAST(ts AS DATE) 仍带时分秒;SQLite 用 substr(x,1,10))、sample_from(PG 用 TABLESAMPLE SYSTEM(p),Oracle 用 SAMPLE BLOCK(p),MySQL / SQLite 返回 None)。⑦ 说明文字照实写:「抽样」改成「按存储顺序取前 N 行」;MySQL 上覆盖率和唯一性按数据库的排序规则算(可能不分大小写)。
- 复用:SqlDialect、sql_literal、catalog._type_family、introspect._collect、本机已验证过的 SQLAlchemy 编译行为
- 文件:修改 data/catalog_profile.py:218-339, :1011-1060, :1081-1094, :1184-1207、data/introspect.py:264-286、data/catalog.py(_type_family 认 ENUM)、data/schema_diff.py(基线规则)
- 对上的理念:照实说(口径、无效日期、排序规则都写明)。只读(多出来的那条确认计数也有上限)。不挡生成(类型对不上就跳过并写原因,不报错停下)
- 否决的备选:改用 sqlglot 生成各方言的 SQL:它给 Oracle 生成的是 FETCH FIRST,11g 不认,而且会把现有已经对了的拼法全部推翻。类型保留通用写法、另加一个 native_type:同一列出现两种类型说法,下游各取各的
### C2 没有真库时的方言验证,以及需要放行的真库只读验收
- 做法:① 金样 SQL:每种方言(mysql、mariadb、postgresql、oracle、sqlite)× 每类查询(stats、bounded、distinct、match、unique、value_counts、min_max、date_window、day_volume、sample),把完整 SQL 钉住,人审一次,以后任何改动都有 diff。② 逐条用 sqlglot 按方言解析,挡语法回退。③ 类型一致性单测:用 sqlglot 的 qualify 和 annotate_types 配上 schema_cache 的类型,核对 IN 两边的类型族。④ 本机容器集成测试 scripts/dialect-it.sh:不进 CI,要用户同意才跑。镜像用 postgres:16、mysql:8.4、mariadb:11、gvenzl/oracle-free:23-slim(ROWNUM 路径在 23 上同样走得到)。夹具包括:保留字列(LEVEL、DATE、TYPE)、Oracle 小写加引号的表、CHAR(2) 码值、MySQL ENUM、关掉 strict 写入的零日期、手动做旧的统计信息、超过 max_scan_rows 的大表、带索引的业务日期列。全部剖析查询和观察查询跑一遍,断言没有报错、结论和手算的一致。⑤ 用户本机 MySQL / Oracle 开发源的只读验收(这次没有连):1)用户给只读账号,或者同意复制开发数据目录;推荐建一个全新的数据目录 /tmp/dialect-accept/data,只登记这两个源,不复制 agentlab.db,免得复制密钥。2)从快照工作区起验收服务 127.0.0.1:8796,AGENTLAB_SCHEDULER=0。3)剖析设置收紧:table_max_queries 15、query_timeout_s 5、sample_size 200、max_scan_rows 20000,在低峰时段跑。4)用户挑 5–8 张表:含保留字列的、有外键的、有 CHAR / ENUM 码值的、一张大表、一个视图(应该被跳过)、一列遮罩列;Oracle 再加一张小写名的表(如果有)。5)建 selected 范围的剖析任务,读 task_detail 的 query_log,期望没有 ORA- 或 MySQL 报错,遮罩列不出现在任何一条 SQL 里。6)用户在自己的客户端手算两三个数(某个码值的行数、某个日期列的 MAX),和结论对照。7)跑一次 introspect dry_run,核对类型写法。8)MySQL 核对会话是只读的;Oracle 没有会话级只读,由用户确认账号只有 SELECT 权限。9)收尾:停服务、删 /tmp 下的数据,真实表名只留在 /tmp,带进仓库的只有匿名后的概括。
- 复用:tests/test_catalog_profile_units.py 的参数化写法;/tmp/dialect-probe 的探测脚本;tests/fixtures/acceptance/retail.py(虚构零售库);之前 cd-accept / cp-accept 的验收脚本模式
- 文件:新增 backend/tests/test_profile_dialect_golden.py、tests/fixtures/dialect/*.sql、scripts/dialect-it.sh(只在本机跑);修改 tests/test_catalog_profile_units.py
- 对上的理念:只读(验收不写业务库,Oracle 的只读靠账号权限,必须用户确认)。真实数据不进仓库。照实说(能挡什么、挡不了什么写明白)
- 否决的备选:只靠 sqlglot:已实测 8 类 SQL 全部能解析,可类型错配、标识符大小写、统计过期、零日期这些它都看不出来。直接连用户开发库验收而不收紧预算:用户开发数据里的 Oracle 源本来就开着剖析,必须先把范围缩到选定的表
### E Excel 期 5:本轮做 4 项,推后 2 项,不做 2 项
- 做法:要做:① 列表按期累积。ListBlock 增加 period,二选一:{column: 日期列, check: within_context}(每行的日期都要落在统计期内),或者 {synthesize: "统计期"}(自动加统计期起、止两列)。accumulate_blockers 放行满足条件的列表;重叠拒收、替换某一期、回滚、物化并集都复用 recipe_accumulate 和 source_versions。② 穿插小计。ListRows 增加 subtotals: {label_column, pick, scope: since_previous|group_column}:执行器核对每一行小计等于它所管那组明细的和,小计行不进数据,记入 rows_excluded(reason=subtotal),可以另存成 reported_total 表;规则起草把现在记成失败的 stray_totals 改为提议成小计。③ 表格对象起草。xlsx_scan 已经读出表格对象的 ref、表头和汇总行,规则起草优先按它出列表块:锚点仍按表头文字,不记坐标,汇总行映射成 total_row。定义名称只当作统计期格子的提示,不单独做。④ CSV 走配方:把 CSV 适配成 Grid(编码识别复用 tabular 读 CSV 的那一段),格子坐标记成 R{行}C{列},后面的引擎、核对、溯源、按期累积全部复用。推后:⑤ 不兼容变化的历史重放(用新配方重跑保留着的原件、重建并集;原件被清除的期只能照实说回不去),等列表累积有了真实用例再做。⑥ 解析器升级后的重放比对,等 parsers/3 立项时再做。不做:⑦ 原件下载(D13 已定为不提供下载、可以清除)。⑧ 「表格文件」节点(原方案的核心决定:格子变成表必须经过配方和 SQL)。
- 复用:recipe_accumulate.plan_accumulate、source_versions 物化、recipe_engine 两遍对账和格子账、recipe_fixes(加一个小计修复按钮)、xlsx_scan 的表格对象、tabular 的 CSV 读法、provenance 的格子回指
- 文件:修改 data/recipe_types.py、recipe_engine.py、recipe_suggest.py、recipe_accumulate.py、recipe_checks.py、recipe_fixes.py、recipe_imports.py(CSV 的入口)、xlsx_cells.py(Grid 适配)、provenance.py(CSV 坐标);前端 pages/import 向导(统计期的绑定方式、小计的确认卡片)
- 对上的理念:模板定骨架:配方是骨架,起草只提议,人确认后才生效。照实说(小计核对不过就整份拒收)。叙述层无算术(小计只用来核对,不进叙述)。留痕(排除的行写明原因)
- 否决的备选:把穿插小计当成 IgnoreRow 忽略掉:等于丢了一道免费的核对。CSV 另写一套解析:会和配方语言分叉,违背「一种说法」
## 待拍板(本块)
- D1 几千张表的库,总体怎么做|选项:A 范围规则 + 全库对象索引 + 范围内后台完整探查 + 按需加入 / B 把上限提到几千,全量反射 / C 按 200 张一页分页探查 / D 不探查,全靠 agent 查数据字典|推荐:A | B 会让 schema_cache 和冻结的快照线性膨胀,MySQL 一表一往返;C 的页内容会漂(PG、Oracle 的反射查询没有 ORDER BY);D 等于目录、SQL 检查、look 全部失效。A 能让几千张表都查得到、找得到,真正反射的只有几百张
- D2 一个数据源能不能跨多个 schema|选项:A 一个源一个 schema;要多个 schema,就用「同一连接再接一个 schema」快捷建源 / B 一个源跨多个 schema,表名的键改成 schema.表名|推荐:A | 目录的键、工具名 db_query__<源>、resolve_table_name、SQL 检查、使用次数全都按裸表名;B 要改 220 处 schema_cache 的用法,还要迁移已经存下的目录键,两个 schema 里同名的表还会在 resolve 里撞上
- D3 探查范围和对象索引的上限|选项:A 范围缺省 500、最多 2,000;对象索引最多 50,000 / B 范围缺省 1,000、最多 5,000 / C 保持 200|推荐:A | 500 张 × 25 列大约 1 MB JSON,每次读数据源都整份加载,冻结快照也是这么大;2,000 张约 4–5 MB,还能接受;再大就得改存储,不在本轮
- D4 助手查看或运行里的 db_schema__ 点到范围外的表时,要不要当场探查、落盘|选项:A 当场只读探查这一张,落盘为「按需加入」,留痕谁、哪次运行、为什么;每次查看最多 4 张,每次运行最多 8 张 / B 只临时给出结构,不落盘 / C 不按需探查,只提示人去加|推荐:A | B 下 SQL 检查、目录都看不到这张表,下一轮还要重新反射;C 用户点名的表答不了,体验上等于范围外的表不存在。A 有上限、有痕迹,范围的增长看得见
- D5 要不要做定时维护(会改变现有「生产库不该被实验工具定时扫」的立场)|选项:A 按数据源开启、缺省关,只在设定时段跑:重新探查 → 剖析需要的表 → 取值观察 / B 挂在工作流的定时任务上 / C 不做定时,只能手动|推荐:A | 取值漂移和结构变化要靠定期观察才看得到;缺省关、显式开、限定时段、预算和止损照旧,守住「不给业务库加压」;B 会卷进模型、审批、封存那一套
- D6 取值观察要不要也查人工确认过的码值列;新取值出现时机器能改什么|选项:A 照样观察,只读;数据证明不再列全时,机器只撤销「已列全」标记,其余进变化列表等人处理 / B 只观察、记变化,不撤销 / C 不观察确认过的列(现状)|推荐:A | C 下新状态永远发现不了;B 下 SQL 检查还会照着过期的码值表提示。撤销「已列全」是数据给出的事实,不碰取值的含义和项的状态,也不违背「确认只由人做」
- D7 大表的取值观察用什么口径|选项:A 有确认的业务日期、且该列打头有索引:看最近 35 天的窗口;PG / Oracle 没有就用块采样;MySQL 没有就只看前 N 行,标明口径、不跨口径比较 / B 一律只看前 N 行(现状) / C 大表不观察|推荐:A | 前 N 行是物理顺序,多半是老数据,看不到新取值;窗口查询只在有索引时才发,成本可控;采样只有 PG、Oracle 有。每条观察都写明口径,不同口径不比
- D8 表结构变化怎么作用到已发布模板和正式运行|选项:A 发布时记下表的结构版本;正式运行发起时提醒(不拦);发布前检查列出还没处理的结构变化;断档类变化也在发起时提醒 / B 拦下正式运行 / C 只在变化列表里列出|推荐:A | 和目录漂移同一档:只是加了一列不该卡住正式出具;真删了列,查询本身就会照实报错。C 下出具的人看不到
- D9 外部通知带不带表名、列名|选项:A 新事件「数据源有变化要处理」,只推要处理的,消息只有源名、各级别条数和链接 / B 带表名和列名 / C 不发外部通知,只在站内显示|推荐:A | 沿用通知只放标题、状态、链接的规矩;表名、列名可能就是业务敏感信息(开发库的真实表名不进仓库也是同一个道理),点链接进站内看全。要不要更细由用户定
- D10 列类型怎么存|选项:A type 改成按方言编译的完整写法(VARCHAR2(20 CHAR)、ENUM('a','b')),升级后第一次探查只建基线、不报变化 / B 保留 type,另加 native_type / C 不改|推荐:A | C 下类型变化检测不出来、ENUM 列漏掉;B 下同一列有两种类型说法,下游各取各的。A 的代价是新运行的快照哈希会变(旧运行不受影响),以及测试和提示词要跟着调整
- D11 Excel 期 5 这一轮做哪些|选项:A 做列表按期累积 + 穿插小计 + 表格对象起草 + CSV 走配方;历史重放、解析器升级比对推后;原件下载、「表格文件」节点不做 / B 只做列表按期累积 / C 这一轮不做 Excel|推荐:A | 每月一份的明细导出最常见,这 4 项互相依赖、都复用现有引擎;历史重放要等有真实用例;原件下载在 D13 已经定了不提供;「表格文件」节点违背原方案的核心决定
- D12 方言的真库验证放不放行、怎么放行|选项:A 同意在本机用 docker 起四种库的容器跑集成测试;用户再给本机 MySQL / Oracle 开发源的只读账号,在全新数据目录里登记,只剖析 5–8 张选定的表 / B 复制开发数据目录(要放行复制含密钥的 agentlab.db) / C 不做真库验证,只做金样和 sqlglot|推荐:A | C 挡不住类型、大小写、统计过期这类问题;B 要复制密钥,验收结束还得记着删。A 隔离最好,Oracle 的只读要靠账号权限,必须用户确认
## 风险
- schema_cache 和冻结快照的体积:范围到 2,000 张时约 4–5 MB,每次读数据源都整份加载。要实测,必要时把冻结快照改成按表内容寻址(会动证据层的哈希链,本轮不做)
- 按需探查发生在运行当中:和后台探查任务争着写 schema_cache,只靠进程内锁兜,前提仍是单进程部署(和现有的回收锁、SourceLanes 一样);多进程部署要换成库里的锁
- 漂移误报太吵:靠最小行数、PSI 阈值、同口径才比、按指纹去重、行数估算要连续两次才报;第一版阈值要用真实库跑一段时间再调
- 定时维护给业务库加压:缺省关、限定时段、预算和止损照旧;Oracle 没有会话级只读,必须配只读账号
- 类型改成方言完整写法:新运行的快照哈希会变、提示词里的类型变长、一批测试金样要更新;升级后第一次探查必须只建基线,否则所有 Oracle 列都会被报成「类型变了」
- PG / Oracle 的块采样、MySQL 零日期、PG infinity 日期这几处驱动和服务器的行为没核实,要靠容器集成测试或用户的开发源来确认
- 规则命中超过上限就拒绝:配置门槛变高,界面要实时显示命中数,并给出收窄的建议
- 变化里存了非遮罩列的码值(目录里本来也有),存在 AgentLab 自己的库里;外部通知按 D9 不带。泄露扫描的名单要加上新表
- 召回依赖 agentlab-change/1 的格式:字段以后改要升版本,召回那边按版本读
- Excel 列表按期累积:明细行跨期重复(同一订单在两期都出现)这种语义要在配方里显式声明,靠 within_context 只能保证日期不重叠
## 测试
- 范围规则:通配匹配、按 name_key 不分大小写、排除优先、有目录 / 有发布引用的表 pinned 排不掉、命中超过上限时 409 scope_too_large 并给出命中数、规则改动记一条 scope_changed
- 对象索引:五种方言的查询金样;查询失败退回 Inspector(没有注释);索引上限
- 后台探查任务:用 SQLite 生成 3,000 张表的夹具,逐表进度、停止、只重试失败的表、kill -9 后启动标中断、和起草 / 剖析排队串行;schema_cache 重建后形状不变(拿现有的 schema_view、sqlcheck、look 测试回归)
- 反射失败、范围外、真删除三种情况在目录清单、剖析、look 里的说法各不相同;不再出现窗口外的表被标成「表已不在」
- schema_diff 纯函数:加列 / 删列 / 类型 / 可空 / 注释 / 主键 / 唯一 / 外键、疑似改名、left_scope ≠ removed、升级后基线不报变化
- 影响面:valid_filter 引用的列、业务日期、关联两端、人工确认的项升为要处理;已发布模板写死的 SQL 用新结构跑 sql_unknown_columns 报出会坏的列
- 结构版本:发布时记 schema_versions;正式运行发起时写 schema.drift 事件、不拦;发布前检查列出未处理的变化;前端 decode / stream / ui 三层检查都覆盖新事件
- 取值观察:确认过、已列全的码值列出现新取值 → 记一条 codes_new、撤销已列全、目录版本加 1、SQL 检查不再提示 unknown_code;PSI 刚好落在阈值两侧;断档和缺天;口径不同不比、照实说明;遮罩列不出现在任何 SQL 里(检查留痕)
- 大表口径:有索引的业务日期走窗口;PG / Oracle 生成采样 SQL;MySQL 退回前 N 行并写明口径
- 方言金样:五种方言 × 全部查询类型的完整 SQL;sqlglot 逐条解析;IN 两边类型族一致(PG 上 varchar 父列配整数子列时生成带引号的字面量);Oracle 的 quote_name 强制加引号;统计估算 ≤ 上限时多数一次;ENUM 进码值候选
- 容器集成测试(要用户同意,本机跑):四种库上剖析和观察全部查询零报错,结论和手算一致
- 变化记录:同一变化重复测到只更新 last_seen_at;处理动作落到 apply_patch / save_human_edit 并记目录版本;误报必须写理由;按 seq 游标拉取接口
- 定时维护:认领、错过的补最近一次、积压只记一条、AGENTLAB_SCHEDULER 关闭时不跑、剖析没开时照实记未执行
- 通知:用 MockTransport 收到 source.changed,消息里只有源名、条数和链接
- look 大库:3,000 张表的合成库配 10 条脚本化需求,预筛后挑选提示词 < 20,000 字,挑中率 ≥ 9/10;范围外的表被点名时按需加入并留痕;每次查看 / 每次运行的上限
- 前端新检查脚本 check-catalog-changes.mjs(假接口加夹具);扩展 check-catalog(范围的说法、分批渲染)、check-catalog-profile(观察结果那一行);数据源页的范围面板和定时维护
- Excel:三期月度明细的列表累积(重叠拒收、替换、回滚);穿插小计核对通过和不通过各一例;表格对象起草不调用 AI;CSV 走配方后格子溯源到 R{行}C{列};真实文件只在本机验收,测试夹具用假名
## 验收
- 3,000 张表的合成库:对象索引列全 3,000 个;规则选出 400 张,后台探查有逐表进度并完成;目录页 2 秒内打开;没有一张范围外的表被说成「表已不在」
- 助手在这个库上按 10 条需求挑表,至少 9 条挑对,挑选提示词不到 20,000 字;用户点名一张范围外的表,会按需加入,look 记录里看得到
- 重新探查前对 SQLite 夹具做 ALTER(加列、删列、改类型、改名):变化恰好就是这几条,疑似改名只说「可能」;删掉的列上人工确认过的项、写死这一列的已发布模板都列为要处理;从这一版发起的正式运行出现结构变化提醒、运行照常发起
- 确认过且已列全的码值列被插入新取值:下一次观察记一条 codes_new,已列全被撤销,SQL 检查不再按旧码值表提示;比例剧变、最新业务日期不前进、缺天各触发一条;口径不同的不比,并写明原因;所有查询的留痕里都没有遮罩列
- 定时维护在设定时段触发,和起草、剖析排队串行;中途止损照常生效;服务停机期间错过的只补最近一次
- 通知渠道收到「数据源有变化要处理」,消息里只有源名、条数和链接;处理一条变化后,目录版本和 resolution 都查得到
- 方言金样和 sqlglot 解析全过;如果用户放行:四种库容器的集成测试零报错,本机 Oracle / MySQL 开发源上选定表的剖析 query_log 里没有 ORA- 或 MySQL 报错,两三个手算的数对得上
- Excel:三期月度明细累积成一张表,重叠的那期被拒;带穿插小计的文件规则起草成功、小计核对通过、小计行不进数据;带表格对象的文件不调用 AI 也能起草出列表块;CSV 走配方后格子能回指
- 后端全量测试通过、tsc、check-copy、新旧前端检查脚本、泄露扫描干净;按记忆里的分层规矩,代理只跑相关测试,完整关口由编排者跑
## 工作量
估计,按以往节奏推算,没核实:剖析逐表任务那一轮约后端 1,500 行、前端 1,000 行,含测试。本轮拆成 5 个工作包。A 范围、索引与后台探查(含 A2 配套):后端约 2,500–3,000 行(测试约占四成)。C 方言修正与验证:后端约 800–1,200 行,加金样夹具和 dialect-it 脚本。B 变化(结构差异、取值观察、变化记录、审阅、通知、定时维护、结构版本):后端约 3,500–4,500 行。D 前端:范围面板、目录分批渲染与搜索、变化标签页、表详情一节、定时维护、运行横幅、文案,约 3,000–4,000 行,加检查脚本;建议拆成 D1(跟 A 的接口约定)和 D2(跟 B 的接口约定)。E Excel 期 5 四项:后端约 3,000–4,000 行,前端约 800–1,200 行。依赖关系:C 里「类型改成方言完整写法」要在 B 的结构差异建基线之前落地;B 依赖 A 的 source_objects 和结构版本;D1 依赖 A 的接口、D2 依赖 B 的接口;E 和其余各包互不相交(文件都在 recipe_*、xlsx_*、pages/import 下)。建议分三波:第一波 A、C、E 并行;第二波 B、D1;第三波 D2。每包一个完整代理,最后统一复核一次,避免限流。日历上大约 2–3 周,含真实验收;如果放行容器集成测试,另加约 1 天
## 和其他几块的接口
- 口径账本(召回):消费 source_changes,接口是按 seq 游标拉取的 list_changes 和 affecting(source, tables, since);处理时改了目录的会带上目录版本,结构变化带前后的结构版本。只有结构类、目录事实类、触及规则的新码值触发召回,纯取值漂移只算数据质量提醒。反查「谁用过这张表」复用 catalog.query_snapshot_tables。格式是 agentlab-change/1,两边要对齐
- 效果度量与评测(1):大库挑表的命中率可以做成一项评测(3,000 张表的合成库 + 需求集);look 记录里新增的 added_to_scope 和预筛命中数可以当指标
- 对外集成(7):通知渠道新增事件 source.changed(notify/service.py:30 的 EVENTS、EVENT_LABEL,前端的订阅列表);如果做 API 令牌,变化列表和处理接口要纳入权限;做英文界面要覆盖本轮新增的文案
- 已知遗留:「剖析结果进 look 的 schema_view」在本方案 A2 / B2 里一并做;check-catalog-loop 的假数据源设置还带着旧键、目录页头部和起草进度条重复显示进度,这两项都在 CatalogPage.tsx 和检查脚本里,和 D 包会撞文件,要先合或者同包做
- 可能冲突的后端文件:data/introspect.py、api/datasources.py、data/catalog_profile.py、catalog_profile_jobs.py、catalog_job_runtime.py、catalog_review.py、catalog_impact.py、data/schema_view.py、look/tables.py、look/base.py、tools/datasource.py、api/copilot.py(sql_unknown_columns 搬家)、engine/launch.py、core/events.py、db/models.py、scheduler/loop.py、notify/service.py、api/coded.py、api/workflows.py
- 可能冲突的前端文件:lib/terms.ts、types.ts、api/client.ts、pages/DataSourcesTab.tsx、pages/catalog/*(CatalogPage、TableIndex、TableDetail、ReviewQueue、ProfileDialog)、run/decode.ts、pages/runs/Banners.tsx、canvas/VersionsSheet.tsx、pages/schedules/ScheduleEditor.tsx(抽公共组件)
- 部署:新表只加不改,WorkflowVersion 只加一列;升级后第一次启动要重建 schema_cache,第一次探查只建基线;类型写法一改,新运行的快照哈希会变。10.0.0.11 仍待用户自己重新部署,部署清单要加上「升级后对每个源手动点一次探查,建立基线」
- 设计文档:方案落在 docs/design/ 下的 md(不做 artifact);真实库名、表名要匿名,提交前提醒用户
## 附:现状(代码位置)
- 探查结构每个源最多取 200 个对象,先拼表名再拼视图后截断:表超过 200 张时视图一个都不剩。truncated、total 记了总数 [backend/app/data/introspect.py:28, :180-183, :287-288]
- 截断窗口不稳定。PG、Oracle 反射列表名的查询没有 ORDER BY(已核对本机 SQLAlchemy 2.0.52 源码:postgresql/base.py 的 _get_relnames_for_relkinds、oracle/base.py 的 get_table_names),MySQL 用的是 SHOW FULL TABLES。哪 200 张入选取决于数据库返回的顺序,库里加了表就可能换一批;界面文案也只写了「按数据库列出的顺序」 [introspect.py:180-183;frontend/src/lib/terms.ts:2754-2758]
- 能限定范围的只有 options.schema(一个 schema),没有按表名包含 / 排除的规则。datasource_scope_empty 是助手 / 问数据「本轮只查哪些数据源」的机读码,和表的范围无关 [introspect.py:114-119;api/coded.py:12;api/copilot.py:44-67]
- 探查是同步 HTTP 请求,结果整份替换 schema_cache:不比较前后差异,也不进 SourceLanes,起草、剖析进行中照样会被整份换掉。代码注释明确写着「显式动作,不做后台轮询——生产库不该被实验工具定时扫」 [api/datasources.py:822-865(:829、:857)]
- MySQL 方言没有批量反射,每张表一次 SHOW CREATE TABLE。索引读了,但只用来算唯一列组,索引本身不存下来 [introspect.py:185-196, :243-246, :284-286]
- 超出 200 的表在各处的表现:目录清单把还留着目录的表排在最后,标「表已不在」(in_schema=false),其实可能只是落在窗口外;剖析说「表结构里没有被指向的表,请先重新探查结构」,可重新探查也没用;起草记 skipped;SQL 检查的 table_meta 找不到表就静默不查;look 只在详情里附一句 UNEXPLORED_PROMPT;db_schema__ 让 agent 自己去查数据字典;证据层对不全的快照说「核对不了」(这一处是对的) [api/catalog.py:238-262;data/catalog_profile.py:1429-1431;data/catalog.py:2050-2061;data/sqlcheck.py:147-162;look/tables.py:41, :329-330;introspect.py:450-458;engine/evidence.py:22-25]
- look 挑表:候选超过 12 张,就把全部候选的「表名|中文名|粒度」整份交给挑选模型,事先不做任何筛选。db_schema__ 不传 table 时列出全部表名 [look/tables.py:245-257;look/base.py:25-26;tools/datasource.py:798-808]
- 运行冻结的表结构快照包含整个 schema_cache["tables"](按内容寻址),体积随探查张数线性增长。schema_cache 是 DataSource 上的一列 JSON,每次读数据源都整份加载 [tools/datasource.py:729-760;db/models.py:56-57]
- 已有可复用的 BM25 和中英混合切词(英文按词,中文按字再补二元组) [memory/embeddings.py:15-21, :268]
- CATALOG_DRIFT 只比目录版本:发布时记下 direct / possible 两组表的目录版本,正式运行发起时比对,只提醒、不拦,不看表结构。全仓没有任何表结构变化的检测或记录。WorkflowVersion 上同类的钉法已有 catalog_versions、capability_versions 两列 [data/catalog_impact.py:306-402;engine/launch.py:164-199;core/events.py:76-79;db/models.py:620, :629]
- 合并规则:已确认、已驳回的项起草永远不碰。所以表结构删列之后,那一列上人工确认过的项会一直留着。只有本轮完整用模型起草时才删 llm 项。审阅队列不列表结构里已经没有的列上的项 [data/catalog.py:611, :626-692;data/catalog_review.py:30-31]
- 定时调度只认工作流:Schedule.workflow_id 是必填,loop.fire_due 只会发起运行。process_due 是一个可以往里加步骤的列表(待办时限、写入查询、上传清理) [db/models.py:967-1001;scheduler/loop.py:36-51, :123-135]
- 剖析的留痕可以当基线:每张表有 findings(码值的各取值和行数)、date_ranges、row_estimate、query_log。保留最近 50 个任务,但每张表最近一次所在的任务不删 [db/models.py:482-526;data/catalog_profile_jobs.py:62-64]
- 码值漂移的盲区有三处:人工确认 / 驳回过码值的列不再取分布,新取值永远发现不了;大表的抽样和取值分布只看物理顺序的前 max(sample_size, max_scan_rows)=100,000 行,多半是最老的数据;日期的 MIN/MAX 只对不超过 10 万行的表做 [catalog_profile.py:1115, :843-847, :1188-1192]
- 标了「已列全」(complete)的码值表,SQL 检查会据此报 unknown_code(依据全有确证时是 warning)。出现新取值以后,这个标记不会自动撤销 [sqlcheck.py:109-113, :906-955]
- 通知渠道能订阅的事件只有 5 种(待办、实例完成 / 失败、写入结果未知、审核结果);消息只放标题、状态和链接 [notify/service.py:1-12, :30]
- 剖析的方言层 SqlDialect 是手拼 SQL:标识符交给 SQLAlchemy 的 identifier_preparer 按需加引号;Oracle 一律套一层 ROWNUM,不用 FETCH FIRST;行数先读统计信息(TABLE_ROWS / reltuples / NUM_ROWS),≤0 当作不知道,再数到上限为止;IN 列表每批 1000 个,恰好等于 Oracle ORA-01795 的上限。语句时限三家都有:PG 用 SET LOCAL statement_timeout,MySQL 用 max_execution_time(MariaDB 用 max_statement_time),Oracle 用 call_timeout [catalog_profile.py:244-339, :579-609, :617;data/engine.py:780-858]
- 现有的方言测试只核对守卫放行和字符串片段(ROWNUM、系统视图名),没有逐条的语法、类型或真库验证。我在 /tmp 用 agentlab 环境里的 sqlglot,按 mysql / postgres / oracle 解析了 8 类剖析 SQL,每类配 3 种列名(普通、保留字 LEVEL、混合大小写),全部能解析通过。也就是说 sqlglot 只能挡住语法回退,挡不住下面列出的语义问题 [backend/tests/test_catalog_profile_units.py:124-166;/tmp/dialect-probe/probe.py(只读复制了 SqlDialect 的源码,没有导入 app)]
- 列类型存的是 str(SQLAlchemy 类型),按通用方言编译。本机已验证:Oracle VARCHAR2(20) 存成「VARCHAR2」、NUMBER(12,2) 存成「NUMBER」,MySQL ENUM('a','bb') 存成「ENUM」,PG 的 ENUM 存成「VARCHAR(1)」。按方言编译则能拿到 VARCHAR2(20 CHAR)、NUMBER(12, 2)、ENUM('a','bb') [introspect.py:272]
- Oracle 里用小写加引号建的对象:SQLAlchemy 反射回来是 quoted_name(quote=True),存进 JSON 以后这个标记就丢了(本机验证)。剖析 SQL 于是写成不带引号的 visits,Oracle 转成大写 VISITS,报 ORA-00942;读统计信息时同样转成大写,也查不到 [introspect.py:264-281;catalog_profile.py:265-271, :303-309]
- Excel 导入现状:取数配方只走 xlsx,CSV 只能简单导入;列表块不能按期累积(accumulate_blockers 要求表只由交叉表写入);列表只认末尾一行合计,夹在中间的小计在规则起草时记为失败;扫描时已经读出表格对象和定义名称,但起草器不用(只用在公式的结构化引用上);配方有不兼容变化时 restart,此前各期丢弃;没有原件下载接口(D13 定为不提供);PARSER_VER=parsers/2,没有升级后的重放比对;没有「表格文件」节点(原方案明确不做) [data/recipe_imports.py:772-791;data/table_versions.py:86, :250;data/recipe_types.py:303-308, :1125-1180;data/recipe_suggest.py:770-815;data/xlsx_scan.py:183, :217, :606-619;data/tabular.py:1016;data/recipe_accumulate.py:296-308;data/recipe_parsers.py:21;engine/schema.py:21-49]
- 开发环境里已经有库被截断:设计文档里的库 H 是「共 205 张表,只探查了 200 张」 [docs/design/copilot-look-and-caliber-ledger.md:326]
@@ -0,0 +1,217 @@
> 分块细案(2026-10-16 只读调研整理,代码基准 main d135a17)。和 [00-总方案](00-总方案.md) 冲突的地方以总方案为准;总方案「跨块统一的决定」一节列出了改动过的条目。决定编号在本文件内有效,总方案里引用时带前缀(如 评测-D4)。
# 对外集成与触达
## 缺口
- 外部系统没有办法用一个身份调用接口:钩子发起实例后能拿到 run_id,但之后查不了实例状态、读不了视图、取不了待办、下载不了报告。现在只能靠出站 Webhook 被动收「实例完成」 —— 集成只走得了半程。外部门户、OA、BI 想拉封存的数,只能拿个人会话 cookie 去模拟,cookie 7 天过期,也说不清是谁在调 [backend/app/auth/middleware.py:115-134(只认 cookie);backend/app/process/hooks.py:250-260]
- 钩子的收件记录不出接口,验签失败的请求不留痕 —— 违背「留痕」:外部系统说「我发过了」,应用管理者在界面上查不到收到没有、为什么被拒 [backend/app/process/hooks.py:204-240;backend/app/db/models_apps.py:106]
- 开了登录,/docs、/openapi.json 仍然不用登录就能读(按代码推断,没实跑) —— 全部接口的结构对匿名的人敞开。而真正要给集成方的那份「哪些接口能用访问密钥调」的说明又不存在 [backend/app/main.py:178-183;backend/app/auth/middleware.py:131]
- 错误只有中文 detail、没有 code 的有 256 处,包括集成要用的应用、待办、工件接口(api/apps.py 13 处、api/tasks.py 3 处、api/artifacts.py 5 处) —— 外部系统只能按中文句子去认是哪种错,文案一改就悄悄失灵。api/coded.py 的说明里已经讲过这个教训 [backend/app/api/apps.py、backend/app/api/tasks.py、backend/app/api/artifacts.py]
- 个人渠道勾了「实例完成 / 实例失败」永远收不到:notify_run 传给 _channels 的 users 是空列表,个人渠道整段被跳过;而界面和接口都允许个人渠道勾这两项 —— 违背「照实说」:设置看着生效了,其实悄悄没生效 [backend/app/notify/service.py:209、122-129;backend/app/api/notify.py:64-69;frontend/src/pages/settings/NotifyTab.tsx:20、210]
- 个人触达只能填一个群机器人地址:群里人人都看得到,算不上个人;用户表里的 email 没有用上 —— 待办派给了人却找不到人,只能靠催办和升级兜底。没有邮件,也没有 IM 的应用消息 [backend/app/notify/core.py:39;backend/app/db/models_identity.py:29]
- 待办提醒是一条一条发的,没有每天的汇总 —— 一个人身上挂着多条待办时,消息又多又碎;不催又容易被晾着 [backend/app/process/tasks.py:785-806]
- 「对外地址」有两个设置:环境变量一个、设置页 notify 组里一个 —— 违背「一种说法」:只配了环境变量的部署,通知里不带链接。之后的分享链接、集成说明也都要用这个地址 [backend/app/core/config.py:65;backend/app/api/settings.py:613]
- 没有任何安全响应头,任何站点都能把 AgentLab 嵌进 iframe —— 同站的门户页面(SameSite=Lax 的 cookie 会随请求带上),以及关登录的部署,有点击劫持的风险(没实测利用)。也没有一条「允许谁嵌」的白名单可配 [backend/app/web.py:75-80;backend/app/main.py:187-194]
- 没有只读分享或嵌入视图的办法。会话 cookie 是 SameSite=Lax,跨站的 iframe 里带不上,跨站嵌入时靠登录态行不通 —— 想把一块看板放进别的门户,只能让每个看的人都开账号,并且门户和 AgentLab 必须同站 [backend/app/auth/sessions.py:97-99]
- 英文界面没有任何基础:语言写死、没有选语言的入口;一半文案集中在 terms*,另一半散在约 230 个文件里;服务端渲染视图文案,所以只翻前端翻不完使用者看到的界面 —— 只读英文的人用不了应用和待办。工作量大,而且大部分在搭建者界面上 [frontend/index.html:2;frontend/src/lib/terms.ts;backend/app/apps/resolve.py:52-62]
- 新功能缺一个不和现有说法打架的名字:「令牌」被规则禁了,「token」已经指模型用量,「快照」已经指查询快照 —— 违背「一种说法」:要先定叫法再动手,否则文案检查报错,或者让人把访问凭据和模型用量混为一谈 [scripts/check-copy.mjs:68;frontend/src/lib/terms.ts:14]
## 做法
### A1 服务账号与访问密钥(数据模型与签发)
- 做法:users 表加一列 kind(person | service,默认 person)。服务账号由管理员建:用户名统一 svc- 开头,没有口令、不能登录,角色固定 user,停用后它的密钥一起失效。新表 access_keys,字段:id、owner_user_id、name、key_hash(sha256,唯一)、prefix(明文前 8 位,用来认是哪把)、scopes(JSON)、app_ids(JSON,可空,表示不限应用)、expires_at(必填,最长 365 天,默认 90 天)、revoked_at、last_used_at、last_used_ip、created_by、created_at。密钥格式是 agl_ 加 32 字节随机串,只在创建和轮换时显示一次。轮换的做法:签一把新密钥,旧的在 24 小时后到期,换钥匙不用停机。个人访问密钥由本人在「设置 → 我的访问密钥」里建,这个入口要管理员开开关(auth.personal_keys,默认关)
- 复用:auth/sessions.py 的 token_hash、_TOUCH_EVERY 节流,以及「库里只存哈希」的做法;db/base.py 的 _COLUMN_MIGRATIONS(users.kind);auth/audit.record;api/identity.py 的用户管理;core/crypto 这里用不上(只存哈希)
- 文件:新增 backend/app/auth/keys.py、backend/app/api/access_keys.py;改 backend/app/db/models_identity.py、backend/app/db/base.py、backend/app/api/identity.py、backend/app/auth/directory.py(名录里标出服务账号,岗位绑定和派单选人时排除);前端新增 pages/settings/AccessKeysTab.tsx(管理员看服务账号和全部密钥,本人看自己的),改 pages/SettingsPage.tsx、lib/termsApp.ts
- 对上的理念:留痕:谁建的、谁在用、什么时候到期都能查。一种说法:服务账号就是一个用户,授权、组、名录全部沿用现有机制,不另起「API 客户端」对象
- 否决的备选:只做个人密钥:集成依附在某个员工身上,人一离职、停用,集成就断了。只做服务账号:开发自己写脚本拉数时也得找管理员。不过期的密钥:泄露之后没有兜底。限定来源 IP:服务端认的客户端地址是 request.client.host(api/auth.py:66-67),在反向代理后面认不准(uvicorn 只信 127.0.0.1 发来的 X-Forwarded-For,没核实部署时的配置),所以这次不做,靠到期、撤销和请求记录兜底
### A2 认人与门禁:中间件认密钥,按路由白名单放行
- 做法:Principal 加四个有默认值的字段:via(off | session | key | hook | share,默认 session)、key_id、scopes(frozenset)、app_ids。中间件收到 Authorization: Bearer agl_… 时:开了登录就按哈希找密钥,找不到、到期、撤销、拥有者停用都回 401,并分别带 code(key_invalid / key_expired / key_revoked / key_owner_disabled),绝不退回成匿名;认出来的请求不看 cookie,也不查 CSRF(Authorization 头跨站发不出来,要过 CORS 预检,main.py:188-194 只放行登记的前端地址);WebSocket 带密钥一律拒绝。关登录时带密钥的请求回 409,code 是 keys_need_login,说明「未开启登录,访问密钥不生效」:免得调用方以为自己的权限被收窄了,其实服务端把他当成了管理员。门禁写在 auth/keys.py:KEY_ROUTES 是一张 (方法, 路由模板) → scope 的表,不在表里的一律 403(code key_route_forbidden)。在 FastAPI(dependencies=[Depends(key_gate)]) 上挂一次,在依赖里读 request.scope['route'].path,比对的是路由模板,不是正则。路径参数里的 app_id 不在密钥限定的应用里时 403;个人应用一律 403。默认限流每把密钥每分钟 120 次(进程内令牌桶),超了回 429、带 Retry-After
- 复用:api/deps.py 的 _CHAT_ROUTES 白名单模式、current_principal 和 contextvar;auth/sessions.groups_of、principal_of;中间件现有的 _reply;sessions.py 失败退避的进程内计数做法
- 文件:改 backend/app/api/deps.py(Principal 字段)、backend/app/auth/middleware.py、backend/app/main.py(FastAPI 的 dependencies);新增 backend/app/auth/keys.py
- 对上的理念:照实说:密钥无效就明确拒绝并说明原因,不悄悄降级成匿名或管理员。只读与遮罩:默认拒绝,只放行表里的接口。界面层不放逻辑:门禁全在服务端
- 否决的备选:在中间件里用正则匹配路径:会和路由定义重复写一遍,改路由时漏改。在每个接口里各自判断 scope:改动面大、容易漏,A7 当初否决逐个接口加鉴权也是这个理由。用 JWT 自包含的令牌:撤销要靠黑名单,而且不好做到「每次请求按拥有者当前的组和停用状态判」
### A3 密钥的权限封顶在使用者边界
- 做法:按密钥认出来的身份一律是 Principal(username=拥有者, role="user", groups=拥有者当前的组, verified=True, via="key"),不论拥有者是 admin 还是 builder。这样现有的使用者规则全部自动生效:只看得到授权给本人、所在组或所有人的应用,最高到 observe;restricted() 为真,工件按白名单给并做派生遮罩;报告导出和使用者看到的一样;运行事件、实时流、记录页都碰不到。界面上写明:「访问密钥按使用者的权限算:只看得到授权给你(或你所在的组、所有人)的应用,看到的数据一律遮罩」
- 复用:apps/access.py 的 role_of/app_role;api/deps.py 的 restricted、run_events_readable;api/artifacts.py 的 _masked_for_user;auth/access.py 的 run_readable
- 文件:backend/app/auth/keys.py(构造 Principal);不改各接口
- 对上的理念:只读与遮罩:拿着密钥看到的永远是遮过的值,和使用者在界面上看到的完全一样。一种说法:不另写一套「密钥能读什么」的规则
- 否决的备选:继承拥有者的角色:搭建者的一把密钥就能读所有运行、拿到工件原文,一旦泄露,暴露面等于这个搭建者本人。按 scope 细分数据权限:要另写一套读权限,和应用授权两套并存
### A4 可写操作:发起入口、刷新视图(带幂等),以及回复待办(只给个人密钥)
- 做法:scope 只有固定的六个:apps.read、apps.run、tasks.read、tasks.respond、reports.read,加上不用申请就有的 me(GET /api/auth/me 自省)。用密钥 POST /entries/{entry} 时必须带 Idempotency-Key,不带回 400,说明「同一件事重发时要带同一个键」。去重新建 api_receipts 表,按 (拥有者用户 id, 幂等键) 唯一,所以轮换密钥后同一个键照样去重。认领、完成、处理到一半服务停了之后的重新认领,这套状态机从 process/hooks.py 的 _claim/_finish 抽到 process/receipts.py,按表参数化,钩子和密钥共用。钩子的 hook_receipts.hook_id 是非空外键,SQLite 改不了可空性(同 F14),所以那张表不动。表单里有文件控件的入口,用密钥发起回 422,code key_files_unsupported,说明请在应用里发起。刷新视图沿用每个应用每天的次数上限。回复待办只给个人密钥(服务账号不能被派待办,A1),而且只限审批、补充输入、编辑稿件三种;确认写入、核实写入结果、双盲复算裁决、工具和代码审批一律 403,code key_task_kind,因为这几种要人在界面上看过确认卡再按。task.responded 事件和 run.origin 里记 via=key、key_id、密钥名
- 复用:apps/entries.start_entry(入口只有一条发起路径);apps/resolve.run_view;process/tasks.respond;process/hooks 的 _claim/_finish 状态机(抽出来共用);engine/origin.origin_note(来源仍记 app_entry,不新增来源种类)
- 文件:新增 backend/app/process/receipts.py;改 backend/app/process/hooks.py(改用 receipts)、backend/app/apps/entries.py:121-128(origin 加 via、key)、backend/app/api/apps.py:824-840(密钥请求必须带 Idempotency-Key)、backend/app/process/tasks.py(待办种类的限制)、backend/app/db/models_apps.py(ApiReceipt)
- 对上的理念:正式运行只从已发布、未退役的版本发起:用密钥发起走的就是入口,入口只认当前发布版。留痕:在封存范围内记下是哪把密钥发起的。不挡生成:幂等保证外部系统重发时不会多发起一个
- 否决的备选:再加一套 POST /api/integration/start 接口:和入口、钩子三条路并存,违背一种说法。发起时可以不带幂等键:外部系统超时重试一次,就会多出一个实例。允许服务账号被派待办:「等外部系统」本来就由 signal 钩子管,再开一条路是重复的
### A5 视图数据接口 agentlab.viewdata/1
- 做法:新增 GET /api/apps/{app_id}/views/{view_id}/data(scope apps.read,前端也可以用)。按应用当前发布版解析这个视图取的是哪一次封存运行,返回:format、app {id, version}、view {id, kind}、state、run_id、清单哈希、印章(截至、口径、出具档位、来源,就是 stamp_of 算出的那份),以及这次运行封存的所有口径卡指标,每条有卡片节点、口径卡名和版本、指标 id、名称、单位、原始值 value、渲染后的 text、state(ok / masked / missing)、证据指针 {run_id, eid}。遮罩判断和页面用的是同一个函数,遮住的指标 value 为 null、state 为 masked。按需视图用 ?params= 读这组参数缓存的那次结果,不会发起运行,没有就返回 empty 并说明要用 apps.run 刷新。查询表格不放进来,要的话按证据指针走证据接口或工件接口(都已经遮过)。只给正式运行:个人应用、试运行一律拒绝
- 复用:apps/resolve.py 的 _source、_load、stamp_of、_metric_value、_card;api/evidence.py 的 _sealed_cards、_metric_masks、_Masks
- 文件:改 backend/app/apps/resolve.py(加一个 view_data 函数)、backend/app/api/apps.py(新路由);改 frontend/src/typesApp.ts(加类型,前端暂时不用)
- 对上的理念:叙述层无算术:数值直接取自口径卡封存的指标,不另算。数据目录只放事实:口径名和版本来自口径卡。生效可查可对账:带上清单哈希,外部系统拿去对照可验证报告。没发布的不外送
- 否决的备选:让集成方解析页面视图模型:组件 id 和布局一改,集成就坏,而且里面只有渲染好的文字。按指标定义现查现算:那就是语义层编译 SQL,已经明确不做。把查询快照的整表也放进来:遮罩和行数上限要另判一遍,证据接口已经管着这些
### A6 留痕:审计、请求记录、来源,以及钩子的收件记录
- 做法:建、撤销、轮换密钥,建、停用服务账号,开关个人密钥,都写进 audit_events。密钥请求另写一张 access_key_requests 表(key_id、时间、方法、路由模板、状态码、run_id、ip),先在进程内缓冲,每 15 秒(调度循环的一拍)或攒满 200 条时批量写一次,保留 90 天,在一拍里清掉过期的;密钥上的 last_used_at 至少隔 1 分钟才写一次。钩子补一个收件记录接口 GET /api/apps/{id}/hooks/{name}/receipts(要 manage 权限),列最近 100 条:状态、时间、实例或待办、被拒的原因。验签失败只在进程内按钩子计数,界面上显示「近 1 小时验签失败 N 次」,不落库,免得被刷库。前端做一个共用的「最近的请求」组件,钩子面板和访问密钥面板都用它
- 复用:auth/audit.record;hook_receipts 现有的字段;scheduler/loop.process_due 的步骤列表(scheduler/loop.py:130);sessions 的写节流
- 文件:改 backend/app/db/models_identity.py(AccessKeyRequest)、backend/app/auth/keys.py、backend/app/api/apps.py(receipts 路由)、backend/app/process/hooks.py、backend/app/scheduler/loop.py;前端新增 apps/components 或 components 里的 RecentRequests.tsx,改 pages/apps/edit/AccessSections.tsx
- 对上的理念:留痕:谁、什么时候、用哪把密钥、读了哪次运行都查得到。一种说法:钩子和密钥用同一个「最近的请求」组件
- 否决的备选:每个请求同步写一行:会和运行抢 SQLite 的写锁,sessions.py:23-24 已经为此节流过。验签失败也落库:匿名的人能拿它把库刷满
### A7 集成接口说明、收起 /docs、补齐错误码
- 做法:开登录时中间件把 /docs、/redoc、/openapi.json 当成 /api 一样拦:要搭建者登录,或者直接 404。接口说明从 KEY_ROUTES 这一张表生成:GET /api/integration/reference(要登录)给出方法、路径、scope、说明、可能的错误码;前端「设置 → 访问密钥」里放一页「接口说明」,README 里写一节用 curl 调的示例。KEY_ROUTES 涉及的接口的错误全部改成带 code(api/apps.py 13 处、api/tasks.py 3 处、api/artifacts.py 5 处,以及 deps 里的 _forbidden、ensure_role),detail 文字不变,老前端照常显示
- 复用:api/coded.CodedHTTPException;中间件的 _PUBLIC 判断;KEY_ROUTES 一张表同时管门禁和说明
- 文件:改 backend/app/auth/middleware.py、backend/app/api/apps.py、backend/app/api/tasks.py、backend/app/api/artifacts.py、backend/app/api/deps.py、README.md;新增 backend/app/api/integration.py(或者并进 access_keys.py)
- 对上的理念:照实说:说明和真实放行的接口出自同一张表,不会对不上。一种说法:门禁和文档只有一个来源
- 否决的备选:保留 FastAPI 的全量 OpenAPI 当集成文档:会把只给搭建者的内部接口也暴露出去,集成方也分不清哪些接口能用密钥调。另写一份手工文档:迟早和代码对不上
### B1 修复:个人渠道的「实例完成 / 失败」
- 做法:notify_run 在运行的发起人是认证过的身份时(run.origin.actor_verified),把 users 传成 [run.started_by],个人渠道就按「我发起的实例」推送。termsApp 里个人渠道的事件名改成「我发起的实例完成 / 失败」。补一条测试:个人渠道订阅了 run.finished,发起人确实收到
- 复用:notify/service._channels 现有的 users 分支
- 文件:backend/app/notify/service.py:203-223;frontend/src/lib/termsApp.ts;backend/tests 里的通知测试
- 对上的理念:照实说:设置了就要生效
- 否决的备选:在界面上把这两项藏起来:设置能保存却不生效,这个问题仍然在,而且「我发起的实例结束了告诉我」本来就是个合理的需求
### B2 邮件渠道(第五种格式)
- 做法:设置里新增一组 mail(只有管理员能改):enabled、host、port、security(starttls / ssl / none)、username、password(用 core/crypto 加密)、from_addr、from_name,再加一个「发测试邮件」(发到管理员自己的账号邮箱)。notify/core 的 FORMATS 加上 email,界面上叫「邮件」。新增 notify/mail.py:拼 MIME(纯文本和简单的 HTML 各一份,HTML 做转义),在 asyncio.to_thread 里用标准库 smtplib 发,超时 10 秒;SMTP 的 4xx 临时错误和连不上按 1/2/4 秒重试,5xx 不重试;报错里不出现口令;测试时可以替换成假的 SMTP 工厂(同 core._transport 的做法)。渠道表不改结构:个人邮件渠道不填地址,url 存一个占位(密文),发送时取拥有者在名录里的账号邮箱;没有邮箱就记一条失败的投递,原因写「你的账号没有邮箱地址,请联系管理员在「设置 → 身份」里填写」。全局和应用的邮件渠道(搭建者以上能建)填固定的收件地址,最多 20 个。投递记录还是 notify_deliveries,界面上成功写「已交给邮件服务器」(是否进收件箱以邮件服务器为准,退信不追踪,照实写出来)。邮件内容沿用 Message:只有标题、状态、链接,不放数字(P4);带 List-Unsubscribe 头,指向「设置 → 通知」。管理员开关 mail.default_for_assignees(默认关):开了以后,派到待办的人如果还没有个人邮件渠道,第一次推送时自动给他建一个,默认订阅 task.opened、action.unknown、review.done,本人之后可以改或关。这一项改写 Q6,要在设计文档里写明改写的理由:SMTP 只是可选配置,退信照实说不追踪
- 复用:notify/core 的 Message、text_of、Outcome、RETRY_DELAYS 和重试骨架;notify/__init__.send 的分发;notify_deliveries;api/settings 的设置分组和校验(notify_problem 的写法);core/crypto;名录里的 users.email
- 文件:新增 backend/app/notify/mail.py;改 backend/app/notify/core.py、backend/app/notify/__init__.py、backend/app/notify/service.py、backend/app/api/notify.py(校验邮件地址、个人邮件渠道不收地址)、backend/app/api/settings.py(mail 组和测试接口);前端改 pages/settings/NotifyTab.tsx(格式选邮件,个人渠道显示「发到账号邮箱 x@…」)、SettingsPage 新增一节「邮件服务」、lib/termsApp.ts
- 对上的理念:一种说法:邮件只是渠道的一种格式,订阅、投递记录、测试都走同一套。照实说:只说已交给邮件服务器,不说已送达。只读与遮罩:通知里不放数
- 否决的备选:个人渠道可以填任意邮箱:会被拿来当垃圾邮件中继,而且发到的地址不是认证过的身份。另建一张邮件表:违背一种说法。引入 aiosmtplib:多一个依赖,标准库加 to_thread 就够了。IM 应用消息(企业微信应用消息、飞书应用机器人、钉钉工作通知):每个平台都要企业应用的凭据、令牌刷新、AgentLab 用户到 IM 用户 id 的映射,还要过 IM 那边的应用审批。这些和 Q5 的 IM 登录适配器是同一套企业应用凭据和用户映射,应该放到一起做,这次不做。群机器人 @人:同样要 IM 的用户 id 或手机号(个人信息),一并推迟
### B3 待办汇总和密钥到期提醒
- 做法:可订阅的事件加两个:task.digest(待办汇总)和 key.expiring(访问密钥快到期)。在调度循环里加一步:每天到 notify.digest_at(默认 09:00,按服务端时区),给订阅了 task.digest 的个人渠道发一条,内容是还没处理的待办条数、最早的时限、最多 5 条待办(标题用节点在图上的名字,不用按运行数据渲染出来的那句)和去待办箱的链接;按日期在 settings 表里记一行认领,重启了也不会重发。key.expiring:到期前 7 天和 1 天各推一次。个人密钥推给本人的个人渠道;服务账号的密钥推给订阅了这个事件的全局渠道。同时记一条审计
- 复用:scheduler/loop.process_due 的步骤列表;notify/service._push 和 _channels;process/tasks.list_tasks;Setting 表
- 文件:改 backend/app/notify/service.py(EVENTS、两个 notify_*)、backend/app/scheduler/loop.py、backend/app/api/settings.py(digest_at);前端改 lib/termsApp.ts
- 对上的理念:不挡生成:汇总发不出去只记投递失败,不影响待办本身。留痕:每次推送都有投递记录
- 否决的备选:把单条提醒全部换成汇总:会改掉人工节点上已经配好的 on_timeout 语义(属于图里的流程语义)。在浏览器端提醒:人不在页面上就收不到,lib/notify.ts 已经覆盖了在页面上的情形
### B4 统一「对外访问地址」
- 做法:新增 core/public_url.py,提供 public_base_url():环境变量 AGENTLAB_PUBLIC_URL 优先,没有再读设置里的那一项(沿用 notify.public_base_url 这个键,界面上叫「对外访问地址」,挪到「设置 → 通用」里)。值缓存在进程里,改设置时刷新,和登录设置的缓存一样,因为中间件里不查库。通知链接、OIDC 回调、Origin 核对、分享链接、接口说明全部改用它。环境变量设了的时候,设置页上显示「由环境变量指定」,不能改,和 AUTH_MODE 的做法一致
- 复用:auth/__init__.py 的 env 优先加进程内缓存模式(_stored、remember_settings);api/settings 的 _base_url_problem 校验
- 文件:新增 backend/app/core/public_url.py;改 backend/app/notify/service.py:93-96、backend/app/scheduler/webhook.py、backend/app/auth/middleware.py:52-61、backend/app/api/auth.py:115-125、backend/app/api/settings.py;前端改 SettingsPage
- 对上的理念:一种说法:同一件事只有一个设置
- 否决的备选:维持两个设置、只在文档里说明:只配了一处的部署还会继续丢链接
### C1 安全响应头
- 做法:新增 web_security.py,做成纯 ASGI 中间件(同 AuthMiddleware,WebSocket 不经过它),在 http.response.start 上加头:X-Content-Type-Options: nosniff;Referrer-Policy: strict-origin-when-cross-origin(分享页 /s 用 no-referrer);Content-Security-Policy: frame-ancestors,默认 'self',可用 AGENTLAB_FRAME_ANCESTORS 列出允许嵌整站的同站门户,分享页另用 AGENTLAB_EMBED_ORIGINS;只有 frame-ancestors 是 'self' 时才另加 X-Frame-Options: SAMEORIGIN(XFO 写不了白名单,有白名单时以 CSP 为准)。script-src 先只发 Content-Security-Policy-Report-Only:mount_web 启动时给 index.html 里的内联主题脚本算 sha256 写进去,观察一个版本没有误报,再改成强制执行。HSTS 交给反向代理,不在这里加。导出的 HTML 文件已经自带 meta CSP,保持不动
- 复用:main.py 的中间件挂法;web.py 的 mount_web(就在这里算内联脚本的哈希);core/config 的列表型环境变量(cors_origins 的写法)
- 文件:新增 backend/app/web_security.py;改 backend/app/main.py、backend/app/web.py、backend/app/core/config.py、deploy/agentlab.env(写明这两个变量)、README.md 的安全一节
- 对上的理念:只读与遮罩:页面不能被别的站点套着诱导点击。照实说:严格的 script-src 先只上报不拦,确认没有误报再强制执行,不一刀切把页面弄坏
- 否决的备选:一上来就强制严格 CSP:内联的主题脚本会被拦,首帧闪一下深色,Vite 构建会不会有别的内联产物也没核实。只用 X-Frame-Options:写不了白名单,门户嵌入的需求无法满足
### C2 分享链接(只读、可嵌入)
- 做法:新表 view_shares,字段:id、key_hash、app_id、page_id、mode(latest:始终是最近一次封存;fixed:发链接那一刻每个视图取的运行 id,存一份 map)、embed_allowed、created_by、created_at、expires_at(必填,最长 90 天)、revoked_at、open_count、last_opened_at。接口:POST/GET/DELETE /api/apps/{id}/shares,要 manage 权限,而且管理员开了 apps.share_links(默认关)才能用。读的接口 GET /api/shares/page 是公开路由,只认请求头 X-AgentLab-Share;链接写成 /s#<分享 key>,key 放在 URL 的 # 后面,打开页面时不会发给服务器、不进访问日志,SPA 读出来再放进请求头。服务端用分享身份 Principal(username=None, role="user", via="share") 调 resolve_page,并加一个 share 旁路:只解析 latest_sealed 视图(fixed 模式用存下的运行 id);按需视图、实例、待办、发起按钮、记录列表这些流程组件,换成写明原因的占位,比如「分享链接只显示定时出数的视图」;从不发起运行;个人应用、试运行不能分享(本来就是 personal.not_shareable)。出处不走在线的证据接口,分享页上给「下载报告」:GET /api/shares/report.html?run=…,只认这次分享解析出来的那几次运行,内部复用 export_report_html,和使用者看到的一样遮过;报告文件自己能离线点开出处。前端加一个不进外壳的路由 /s(同 App.tsx:134 登录页的写法),用 ViewRenderer 渲染,所有操作禁用,印章一直显示,页脚写「只读 · 由 X 于 … 分享 · … 到期」。建分享、撤销分享写审计;打开次数只做计数。撤销或到期后打开,写明「链接已撤销 / 已到期」,回 410
- 复用:apps/resolve.resolve_page 和页面缓存(缓存键另加 share);api/evidence.export_report_html;前端 apps/ViewRenderer、Stamp、context;auth/audit;Principal 的 via 字段(A2);public_base_url(B4)
- 文件:改 backend/app/db/models_apps.py(ViewShare)、backend/app/apps/resolve.py(share 旁路);新增 backend/app/api/shares.py;改 backend/app/auth/middleware.py(公开前缀 /api/shares/)、backend/app/main.py、backend/app/web_security.py(/s 用嵌入白名单);前端新增 pages/apps/SharePage.tsx,改 App.tsx、pages/apps/edit/AccessSections.tsx(管理分享)、lib/termsApp.ts
- 对上的理念:只读与遮罩:分享出去的就是使用者看到的遮罩视图。生效可查可对账:印章常显,fixed 模式钉在某次封存运行上,下载的报告带清单哈希。没发布的不外送。一种说法:前端和看应用用的是同一个 ViewRenderer,不另写服务端渲染器;界面上叫「分享链接」,不叫「快照链接」,因为「快照」已经指查询快照
- 否决的备选:服务端把视图渲染成一份静态 HTML:等于第二个渲染器,组件一多就和前端对不上。分享页里打开在线证据:等于给匿名的人一个按运行读证据的身份,权限面变大;报告导出已经把出处离线带上了。把 key 放在路径或查询参数里:会进代理日志和 Referer。签发嵌入票据、用 SameSite=None 的 cookie 嵌整站:要重新评估 CSRF,Safari 和 Firefox 默认拦第三方 cookie,Chrome 也在收紧,跨站时不可靠
### C3 同站门户嵌整站(只做配置和文档)
- 做法:门户和 AgentLab 在同一个可注册域名下时(比如 portal.corp.x 和 agentlab.corp.x),把门户的源写进 AGENTLAB_FRAME_ANCESTORS,现有的 Lax 会话 cookie 会随请求带上,单点登录照常。跨站嵌整站明确不支持:README 写清原因,并指向分享链接
- 复用:C1 的头;现有会话
- 文件:README.md、deploy/agentlab.env
- 对上的理念:照实说:做得到的范围写清楚,不承诺做不到的
- 否决的备选:为了跨站嵌入把 cookie 改成 SameSite=None:会削弱整站防 CSRF 的那一层,换来的嵌入在 Safari 和 Firefox 上默认仍然不工作
### D1 英文界面:分层方案和工作量
- 做法:L0 基础设施(不翻译):前端加 lib/i18n.ts(locale 存在本机加用户偏好里,切换语言就重新加载页面),把 terms*.ts 改成每种语言一份、形状相同(en 用 satisfies typeof zh 让 TypeScript 逐键核对),<html lang> 和系统通知的 lang 改成按语言设,format.ts 和日期用 Intl 按语言格式化;服务端在 users 表加 locale 列,按「用户偏好 → Accept-Language → 部署默认(zh-CN)」认出语言,coded 错误加一个 say(code, **参数) 的消息目录(plain.py 本来就是按编号查的结构,正好复用),视图页面的缓存键加上语言;check-copy 的中文规则只对 zh 的文件生效,en 的文件另加一条「不许出现中文」。L1 使用者面:应用、待办、登录、我的设置和通知、错误框架。前端 43 个文件约 1.0k 段,加上 terms.ts 里使用者面用到的约 300 段(估计)。服务端约 470 处,其中 apps/resolve.py 约 115 处视图文案,因为 P7 必须在服务端按语言出,前端没法替它翻。通知按收件人的语言出。L2 搭建者面:画布、记录、数据目录、证据、其余设置,前端约 10k 段,服务端约 8k 处(plain.py 404 条、engine/labels.py 205 条、发布门禁、自动修复、目录、配方……)。一律不翻(属于数据):封存运行事件里的整句、报告正文和模型的产出、数据目录的描述和剖析说明、证据说明、审计数据、口径卡名和公式、应用和工作流名、写给模型的提示词(提示词用什么语言是工作流和版本的事,改了会影响模型行为)。界面上对这些标「原文」,不假装已经翻译。测试:zh 仍是默认语言,现有约 10.3k 行检查脚本断言和约 6.6k 行后端中文断言不用动;en 只加覆盖检查和冒烟检查
- 复用:terms*.ts 的集中结构;plain.py 编号查白话的结构;api/coded 的 code;check-copy 的扫描框架
- 文件:L0:新增 frontend/src/lib/i18n.ts,改 lib/terms*.ts、lib/format.ts、index.html、backend/app/core(locale 解析)、backend/app/api/coded.py、db/models_identity.py、apps/resolve.py(缓存键)、scripts/check-copy.mjs;L1、L2 见工作量估计
- 对上的理念:一种说法:语言只是 terms 多一个维度,叫法仍然只有一处。照实说:没翻译的数据标「原文」。界面层不放逻辑:视图文案仍由服务端出,只是按语言出
- 否决的备选:只在前端翻译:使用者面上服务端渲染的印章和状态翻不到,等于翻了一半,还说翻完了。上 i18next 这类库再全量抽取:引入依赖、改动面极大,而 terms 已经是集中管理的结构。把运行事件和报告也翻译:它们在封存范围内,翻译等于改写记录。全站一次翻完:约 1.15 万段前端文案加约 8.9k 处服务端字符串,目前没有对应的使用者群体
## 待拍板(本块)
- I1 接口访问凭据在界面上叫什么?「令牌」被文案规则禁了,「token」已经指模型用量,同一个词会指两件事|选项:访问密钥(和钩子密钥一样「只显示一次」,叫法成体系) / API token(要在 check-copy 里放一条例外,界面上 token 一词会有两个意思) / 接口凭据(和出站的「服务凭据」容易混)|推荐:访问密钥 | 守住一种说法:「token」专指模型用量,「凭据」专指出站端点的凭据。「密钥」本来就是交给外部系统、只显示一次的东西,和钩子密钥是同一类
- I2 谁可以持有访问密钥?|选项:服务账号(管理员建)+ 个人访问密钥(管理员开关,默认关) / 只做服务账号 / 只做个人访问密钥|推荐:服务账号 + 个人访问密钥(管理员开关,默认关) | 系统间集成不应依附在某个员工身上(离职、停用就断);个人自动化有实际需求,但暴露面更大,所以默认关、由管理员决定
- I3 访问密钥能做哪些写操作?(不论哪种,密钥都封顶在使用者的权限和遮罩边界)|选项:只读:读视图数据、实例、待办、报告 / 读 + 发起入口、刷新视图(必须带幂等键) / 读 + 发起和刷新 + 个人密钥回复待办(只限审批、补充输入、编辑稿件;确认写入、核实、双盲裁决、工具和代码审批不行)|推荐:第三项,但新建密钥时默认只勾读的 scope | 门户、OA 嵌入审批是常见需求;确认写入这几种必须有人在界面上看过确认卡。服务账号不能被派待办,外部系统回调仍走 signal 钩子,保持一种说法
- I4 通知渠道要不要扩?(这会改写 Q6「只用 Webhook」)|选项:加邮件(SMTP,第五种格式),IM 应用消息推迟到和 Q5 的 IM 登录适配器一起做 / 邮件 + IM 应用消息(企业微信、飞书、钉钉)这次都做 / 维持只用 Webhook,只修个人渠道不生效的问题|推荐:加邮件;IM 应用消息推迟 | 用户表里已经有 email(单点登录会自动填),邮件是真正能触达个人的最低成本办法;SMTP 是可选配置,投递照实写「已交给邮件服务器」。IM 应用消息要企业应用凭据、令牌刷新和用户 id 映射,这些和 IM 登录是同一套,分开做会做两遍
- I5 邮件要不要默认发给待办的处理人?|选项:管理员开关,默认关;开了之后第一次派待办时自动给处理人建个人邮件渠道,本人可以关 / 只能由个人自己在「我的通知」里打开 / 配好邮件服务就一律默认发|推荐:管理员开关,默认关 | 「派了待办找不到人」是要触达的核心场景,只靠个人自己开,覆盖率会很低;一律默认发又会让刚配好 SMTP 的部署突然往外发邮件,不合「照实说、不意外」
- I6 视图要不要支持分享链接(拿到链接、不用登录就能看,可以嵌进门户)?|选项:做:manage 权限的人建,管理员开关默认关,必须设到期(最长 90 天),只显示定时出数的视图,出处给「下载报告」 / 不做,只上安全头,门户嵌入只支持同站嵌整站 / 做,而且分享页里也能打开在线证据|推荐:做(第一项) | 嵌入的需求只有分享链接能跨站做到;拿到链接的人看到的就是使用者看到的遮罩视图,印章常显、可以撤销、建分享写审计。打开在线证据等于给匿名的人一个读运行的身份,下载的报告已经把出处带上了
- I7 英文界面这轮做到哪一层?|选项:这轮不做,只把集成接口的错误补上 code(A7),下一轮视是否有明确的英文使用者再做 / 做 L0(基础设施)+ L1(使用者面:应用、待办、登录、我的设置;服务端按用户语言出视图文案和通知) / 全量(L0–L2)|推荐:这轮不做,只补错误码;如果已经有明确的英文使用者,做 L0 + L1 | L1 是使用者真正碰得到的部分,约 1.3k 段前端文案加约 470 处服务端字符串,因为 P7 必须动服务端;L2 是搭建者面,约 1 万段,8 到 12 周的量级。不做的代价只是只读英文的人用不了,集成方可以靠 code 分辨错误,不受影响
## 风险
- 访问密钥是不记名凭证,一旦泄露就能被冒用:库里只存哈希,带 agl_ 前缀方便密钥扫描工具认出来,必须设到期,可以撤销,拥有者停用就失效,权限封顶在使用者的遮罩边界,有请求记录。不限定来源 IP(反向代理后面认不准客户端地址),这一点要在文档里写明
- 不查 CSRF 的条件只能是「请求用 Authorization 头认身份」:同一个请求带了 cookie 也一律不看 cookie。要有测试守住:带 cookie、不带 X-AgentLab-Request 的请求仍然被拒
- Principal 是全站共用的 frozen dataclass,加字段必须都带默认值;hooks._caller、tasks.principal_with_actor 这些用 dataclasses.replace 或直接构造的地方要逐个核对
- SQLite 单写锁:last_used_at 的节流写、请求记录的批量写、api_receipts 都会和运行抢锁。按 sessions 的节流写法做,批量间隔是调度循环的一拍。限流和请求缓冲都在进程内,依赖「单进程」这个前提,和项目现状一致
- 加安全头可能打断已经在用 iframe 嵌 AgentLab 的部署(有没有这样的部署没核实):发版说明要写清楚,并给出 AGENTLAB_FRAME_ANCESTORS 的配法
- 严格的 script-src 可能误伤:index.html 里的内联主题脚本已确认存在,构建还会不会产出别的内联内容没核实。所以先只上报、不拦截
- 分享链接被转发,等于数据出了授权范围:这正是分享的本意,所以靠管理员开关、必须设到期、可撤销、建分享写审计、打开计数、页脚写明是谁分享的来控制。key 放在 # 后面,减少进日志和 Referer 的机会
- 邮件:SMTP 口令加密存放;个人邮件只发到名录里的账号邮箱,避免被当中继;是否送达无法确认,界面上照实写「已交给邮件服务器」;内网 SMTP 由管理员配置,不做 SSRF 检查(和全局 Webhook 同一个信任级别)
- 改写 Q6 是在推翻已拍板的决定:设计文档要写明理由(SMTP 只是可选配置,退信照实说不追踪),并在 app-platform-and-look.md 的待拍板表里标「已修订」
- 英文界面只做一半时,最容易出的问题是「悄悄降级」:没翻译的数据必须标「原文」;而且 en 一旦上线,所有新文案都要两种语言一起写,以后每个功能都多一份维护
- 数量是用 /tmp 下的启发式脚本数的(去注释、按字面量和 AST 分),误差约 ±10%;按人日估算的部分没有实际翻译样本校准过
## 测试
- 密钥签发:只显示一次,库里只有哈希和前 8 位;到期、撤销、拥有者停用、拥有者被删,各自回 401 和对应的 code;轮换后旧密钥 24 小时内仍可用,过了就失效;关登录时带密钥回 409(keys_need_login)
- 门禁的合约测试:遍历 app.routes,每个不在 KEY_ROUTES 里的 /api 路由,用密钥请求都回 403(key_route_forbidden);表里的路由缺 scope 时回 403;限定了应用时,别的应用回 403;个人应用、试运行一律拒绝;带密钥连 WebSocket 被拒
- CSRF 和身份来源:带 Bearer 的 POST 不需要 X-AgentLab-Request 也能过;带 cookie、不带来源标记的 POST 仍然 403;同时带 cookie 和 Bearer 时按密钥认,cookie 不起作用;X-Actor 被忽略
- 封顶:管理员的个人密钥取 /api/artifacts 时,查询快照是遮过的,node_output 回 403;没有授权的应用在列表里看不到;有 observe 授权的服务账号能列全部实例、能下载 report.html,报告里遮罩列写「已遮罩」
- 幂等:同一个 Idempotency-Key 发起两次只得到一个实例,第二次交回同一个 run_id;轮换密钥后同一个键照样去重;处理到一半杀进程,重启后同一个键能认领回已发起的实例;不带键回 400;有文件控件的入口回 422(key_files_unsupported);抽出 receipts 之后,钩子现有的测试全部原样通过
- 留痕:run.origin 里有 via=key、key_id,而且在清单哈希的封存范围内;task.responded 记了 via;建、撤销、轮换密钥都有审计;请求记录在批量写之后能查到,超过保留期的被清掉;钩子收件记录接口只有 manage 权限能读
- 待办回复:个人密钥能回复审批、补充输入、编辑稿件;确认写入、核实写入结果、双盲裁决回 403(key_task_kind)
- 视图数据:latest_sealed 视图返回印章、清单哈希、指标原始值;遮罩列算出来的指标 value 为 null、state 为 masked;按需视图没有缓存时返回 empty,而且没有发起运行;改了页面布局之后接口返回的形状不变
- /docs、/openapi.json:开登录时匿名访问被拒,关登录时照旧 200(不打破 test_provenance_contract.py:376 的断言)
- 通知:个人渠道订阅了 run.finished,发起人收到(B1 的回归测试);用假 SMTP 工厂测邮件:发到名录里的账号邮箱,没有邮箱时记一条失败投递和原因,4xx 重试、5xx 不重试,报错里没有口令;全局邮件渠道的地址个数和格式校验;管理员开关开了之后,第一次派待办自动建个人邮件渠道;待办汇总同一天只发一次(重启后也不重发);key.expiring 在到期前 7 天和 1 天各推一次
- 安全头:SPA 页面、/api 响应都带 nosniff 和 frame-ancestors 'self',白名单配置能生效,有白名单时不发 XFO,/s 用嵌入白名单和 no-referrer;导出文件的响应不受影响
- 分享链接:没开开关、没有 manage 权限时建不了;到期时间必填,超过 90 天被拒;公开接口只认请求头里的 key;只解析定时出数的视图,流程组件换成占位,按需视图不发起运行;fixed 模式钉在建分享时的那几次运行上;report.html 只认这次分享解析出的运行;撤销或到期后回 410;写了审计,打开次数在增加
- 前端检查:扩充 check-capabilities(服务账号、访问密钥只显示一次、撤销、最近的请求、邮件服务一节、个人邮件渠道显示账号邮箱);新增 check-share.mjs(/s 页不进外壳、印章常显、页脚、没有待办、撤销后的说明);check-copy 全部通过(没有「令牌」、没有「快照链接」);新脚本不能叫 check-tokens(这个名字已被设计令牌的检查占用)
- 如果做 L0/L1:terms 的 en 和 zh 逐键对齐(类型检查);en 的文件里没有中文;使用者面在 en 下冒烟检查;zh 下 check-all 全部仍然通过
## 验收
- 真实后端开 local 登录,用虚构的店铺库建一个服务账号,授权某个已发布应用 observe,签一把 apps.read + apps.run + reports.read 的访问密钥,用 curl 依次走通:GET /api/auth/me 看到 scope 和到期时间;列应用只看到被授权的那个;GET 视图数据拿到带印章和清单哈希的指标,遮罩列算出的指标是 masked;用同一个 Idempotency-Key POST 入口两次,得到同一个 run_id;轮询实例直到结束;下载 report.html,里面的遮罩值写「已遮罩」;调 /api/runs、/api/workflows 回 403;撤销后回 401(key_revoked)。审计、请求记录、run.origin(via=key)都查得到
- 开了登录,匿名打开 /docs、/openapi.json 被拒;关登录的部署行为不变
- 邮件:本地起一个假 SMTP 收件(检查脚本自带,不连真实邮件服务),给 alice 派一条待办,alice 的账号邮箱收到,投递记录写「已交给邮件服务器」;把 alice 的邮箱清空后再派一条,投递记录失败并写明原因;alice 的个人渠道订阅「我发起的实例完成」,自己发起的实例跑完,她收到通知
- 分享:管理员打开开关,manage 权限的人建一条 latest 分享;无痕窗口打开 /s#… 看到页面、印章、遮罩值,没有待办、没有发起按钮,能下载报告;从另一个源的测试页 iframe 它,源在白名单里能显示,不在白名单里被拒;主站页面被别的源 iframe 时被拒;撤销后打开写明「链接已撤销」
- 全量回归:后端测试全部通过;check-all 全过(按 agentlab-verify-layering 的分层,完整关口由编排者跑);check-auth 另起开登录的服务单独跑并通过;check-copy 通过
- 如果做了 L0/L1:切到 en,使用者面除了标「原文」的数据之外看不到中文,视图印章、状态、通知都是英文;切回 zh,所有既有检查全部通过
## 工作量
建议拆成 3 个包,英文界面另算。每个包一个完整的代理,最后统一复核,避免拆得太细。
- 包 A:访问密钥与服务账号,含门禁、幂等、视图数据、留痕、接口说明、收起 /docs、补错误码。后端约 1,400 行,测试约 900 行;前端约 800 行(设置里的服务账号和访问密钥、接口说明页、「最近的请求」组件)。
- 包 B:触达,含修个人渠道、邮件、待办汇总、密钥到期提醒、统一对外地址。后端约 800 行,测试约 600 行;前端约 450 行。
- 包 C:嵌入,含安全头和分享链接。后端约 650 行,测试约 450 行;前端约 450 行(/s 页面和分享管理)。
包之间的依赖:
- A 先做。Principal.via 字段、KEY_ROUTES 的门禁模式、process/receipts.py 都是后面要复用的。
- B4(统一对外地址)最好最先合入,或者并进 A:分享链接和接口说明都要用这个地址。
- B 里的密钥到期提醒依赖 A;B 其余部分和 A 互相独立,可以并行。
- C1(安全头)没有依赖,可以第一个合入。C2 依赖 A 的 Principal 扩展和 B4。
英文界面(包 D,看拍板):
- L0 基础设施:前端约 400 行,后端约 250 行。
- L1 使用者面:前端约 1.3k 段文案要搬进 terms 并翻译(约 2,000 行改动),后端约 470 处字符串进消息目录,其中 resolve.py 约 115 处、通知文案约 70 处。约 2 到 3 周。
- L2 搭建者面:前端约 1 万段,后端约 8k 处,8 到 12 周的量级,不建议这轮做。
## 和其他几块的接口
- 和「已知残留」那块的重叠:没有安全头、个人渠道订阅实例完成不生效、/openapi.json 匿名可读、两个对外地址,这几项也可能被残留调研列出来,要去重,归到一处;残留里的「使用者表单选人没有名录」会碰 auth/directory.py,A1 要在名录里标出并排除服务账号,改的是同一个文件
- 和口径账本那块:视图数据接口(A5)对外交出口径卡名和版本,如果账本这一轮给口径定了新的标识(账本 id、口径版本号),A5 的字段要用同一套叫法;以后要开放「问数据接已治理口径」的接口,也走同一张 KEY_ROUTES 表,不另开入口
- 和评测那块:如果评测要从外部批量发起运行或读结果,应当用服务账号加访问密钥,不再各写一套绕过认证的捷径
- 几个包都会改的共享文件:backend/app/api/deps.py(Principal)、backend/app/auth/middleware.py、backend/app/main.py(路由清单和 FastAPI 依赖)、backend/app/db/base.py(_COLUMN_MIGRATIONS)、backend/app/db/models_identity.py、db/models_apps.py、backend/app/api/settings.py(设置分组)、backend/app/scheduler/loop.py(每一拍的步骤)、frontend/src/lib/terms.ts 和 termsApp.ts、frontend/src/App.tsx(路由)、pages/SettingsPage.tsx(分页)、scripts/check-all.mjs、scripts/copy-allow.json、README.md(身份与权限、通知、安全几节)。实施时这些文件要排先后,或者冻结后串行改
- 设计文档:改写 docs/design/app-platform-and-look.md 第九部分的 Q6,§3.12 权限矩阵加上「访问密钥」「分享链接」两行,§3.10 接口表加上新接口;新的设计文档用 md 写在 docs/design/ 下,按约定不提交,真实名称匿名
- 英文界面一旦做,后面每个包新加的文案都要两种语言一起写,check-copy 的规则也要分语言:最好在 A/B/C 开工之前就定下来做不做
## 附:现状(代码位置)
- 对外唯一的入站通道是按应用声明的签名钩子:POST /api/hooks/{hook_id},HMAC-SHA256(ts.body)、5 分钟时间窗、Idempotency-Key 必填、一个签名只认一个幂等键;start 按入口发起实例,signal 按 correlation 解决「等外部系统」的待办;做什么以当前发布版的声明为准 [backend/app/process/hooks.py:85-151、180-230;backend/app/api/hooks.py:19-31]
- 钩子调用方的身份是 Principal(username=None, role="admin", display_name="外部系统(钩子名)", verified=True),实例的发起人一栏为空 [backend/app/process/hooks.py:242-247]
- 钩子的收件记录 hook_receipts(processing/accepted/rejected/signature)只在库里,没有任何接口或界面能看;验签失败的请求不留任何记录 [backend/app/db/models_apps.py:106-120;全仓 grep HookReceipt 只出现在 process/hooks.py 和模型里]
- auth/signing.py 管的是出站方向:替运行发起人签发 5 分钟的 EdDSA 身份断言(X-AgentLab-Assertion),外部系统用 /api/auth/jwks 核验。和入站认证无关 [backend/app/auth/signing.py:1-78]
- 认人只认会话 cookie。中间件开登录时只拦 /api/ 下的路径:没登录 401;非 GET 必须带 X-AgentLab-Request 头、Origin 必须是本站或登记的地址;/api/hooks/ 两样都豁免。不认 Authorization 头 [backend/app/auth/middleware.py:26-30、113-140]
- 关登录(off)时中间件写入的身份等同 admin,署名取自报的 X-Actor;所有路由级依赖都放行 [backend/app/auth/middleware.py:100-111;backend/app/api/deps.py:108-114]
- Principal 是 frozen dataclass,字段只有 username/role/display_name/user_id/groups/verified/mode,没有「经什么方式认出来的」这一项 [backend/app/api/deps.py:38-56]
- 权限挂在 main.py 的 include_router(dependencies=…):应用、待办、通知、工件是 _USER,证据走 evidence_scope(使用者只能 GET 自己能读的运行),可验证报告 bundle.html 只给搭建者,钩子不挂依赖 [backend/app/main.py:197-258]
- 按请求内容收窄已经有现成的模式:问数据开给使用者时,只放 _CHAT_ROUTES 里列出的 (方法, 路径) [backend/app/api/deps.py:319-336]
- 使用者角色的遮罩边界:restricted() 为真时工件只给白名单里的种类,并按派生遮罩处理;视图对所有人都遮(遮罩值不出服务端);运行事件和实时流对使用者只开放在工具边界遮过的问数据运行 [backend/app/api/deps.py:191-205、296-299;backend/app/api/artifacts.py:16-35;backend/app/apps/resolve.py:1-4]
- 应用级权限:admin 对所有应用都是 manage;builder 自动拿到 observe;user 按授权(本人/组/所有人)取最高的一档,最高到 observe;个人应用只有拥有者能用 [backend/app/apps/access.py:60-83]
- 外部系统可能想调的接口都已经有了:页面视图模型 GET /api/apps/{id}/pages/{page}(agentlab.view/1)、发起 POST /entries/{entry}、实例 GET /instances[/{run_id}]、按需刷新 POST /views/{view}/refresh、待办 /api/tasks、报告导出 GET /api/runs/{id}/report.html(经 evidence_scope) [backend/app/api/apps.py:801-916;backend/app/api/tasks.py:45-108;backend/app/api/evidence.py:2787]
- 视图模型里的指标只给渲染好的文字 text,加上证据指针,不给原始数值;数值要再调一次证据接口才拿得到 [backend/app/apps/resolve.py:933-941]
- 运行来源已经有 api 这一种(客户端直接调接口);应用入口发起的运行来源是 app_entry,来源里记 entry、spec_hash、actor_verified、auth_mode,这些都在封存范围内 [backend/app/engine/origin.py:15-31;backend/app/apps/entries.py:121-128]
- 会话 cookie 是 HttpOnly、SameSite=Lax、Path=/,Secure 按请求协议或 AGENTLAB_AUTH_COOKIE_SECURE 定;库里只存 sha256;last_seen_at 至少隔 1 分钟才写一次,免得和运行抢 SQLite 的写锁 [backend/app/auth/sessions.py:22-28、75-77、97-103]
- 服务端不设任何安全响应头:没有 X-Frame-Options,没有 CSP(含 frame-ancestors),没有 Referrer-Policy,页面托管只设了 Cache-Control。nosniff 只在文件下载上设了;导出的报告和可验证报告靠文件里的 meta CSP [backend/app/web.py:75-80;backend/app/main.py:178-194;backend/app/api/files.py:95;backend/app/bundle/template.html:6;backend/app/report_export/template.html:6]
- 构建出来的 index.html 带一段内联脚本(首帧之前定主题)和一段内联样式:要上严格的 script-src,得给它算哈希 [frontend/index.html:9-18(frontend/dist/index.html 同样有)]
- FastAPI 没关默认的 /docs、/openapi.json。中间件只拦 /api/ 开头的路径,所以开了登录这两个地址也不用登录就能读(按代码推断,没在开登录的情况下实跑;关登录时测试断言 /openapi.json 返回 200) [backend/app/main.py:178-183;backend/app/auth/middleware.py:131;backend/tests/test_provenance_contract.py:376]
- 通知发送核心有四种格式(通用 JSON / 飞书 / 企业微信 / 钉钉):配了密钥时通用格式签 HMAC 头,飞书、钉钉按各自平台的算法签;地址加密落库,日志里只留主机名;平台在 HTTP 200 里回的错误码会认出来;失败重试 1/2/4 秒 [backend/app/notify/core.py:39-51、230-268、296-381]
- 渠道表 notify_channels 有 scope global/app/user 三种,events 存订阅了哪些事件;投递记录在 notify_deliveries;个人渠道要开登录才能建,地址要过 SSRF 检查;可订阅的事件是 task.opened(改派/升级/催办也算)、run.finished、run.failed、action.unknown、review.done [backend/app/db/models_process.py:113-145;backend/app/notify/service.py:29-41、60-71;backend/app/api/notify.py:42-61]
- 待办的提醒、升级、超时驳回在调度循环每一拍里由 tasks.tick 处理,从不自动批准;通知只发给 principal_kind=user 的那几份派单(派给组的在开出那一刻已经展开成成员) [backend/app/process/tasks.py:257-295、764-830;backend/app/scheduler/loop.py:130-131]
- 定时任务的 Webhook 单独存(webhook_deliveries),和渠道共用同一个发送核心(Q7、F14) [backend/app/scheduler/webhook.py:1-60]
- 用户表有 email 列,单点登录时从 claims 里填,管理员也能在「身份」里改,但通知完全不用它 [backend/app/db/models_identity.py:29;backend/app/auth/oidc.py:192;backend/app/api/identity.py:52、119-120]
- 拍板记录 Q6:「待办通知只用 Webhook 的四种格式,不做邮件——SMTP 要另配服务器、还要处理退信」;Q5:飞书、企业微信、钉钉不是标准 OIDC,先不做 [docs/design/app-platform-and-look.md:1383-1384]
- 有两个「对外地址」:环境变量 AGENTLAB_PUBLIC_URL 管 OIDC 回调和 Origin 核对;设置里 notify.public_base_url 管通知里的链接 [backend/app/core/config.py:63-65;backend/app/auth/middleware.py:54-56;backend/app/api/settings.py:610-663]
- 文案规范:界面上禁写「令牌」,要写「token」;而「token」在界面上已经指模型用量(「2.8k token」)。另外「快照」指查询快照 [scripts/check-copy.mjs:10、68;frontend/src/lib/terms.ts:14]
- scripts/check-tokens.mjs 查的是设计令牌(CSS 变量),这个名字已经被占了 [scripts/check-tokens.mjs:1]
- 前端文案的集中程度:242 个 ts/tsx 文件,去掉注释后约 12.3k 段中文(约 11.4 万字)。其中 terms.ts、termsApp.ts、termsBlueprint.ts 占约 5.9k 段(48%),其余约 6.4k 段散在约 230 个文件里(run/decode.ts 1.0k、AssistantStream 0.3k、KnowledgePage 0.3k……)。去掉 __tests__ 和 dev 后约 11.6k 段。terms.ts 有 137 个常量导出、9 个函数导出,另有约 900 处按中文语法拼句的箭头函数。用 /tmp 里写的启发式脚本数的,误差约 ±10% [frontend/src/lib/terms.ts(5924 行)、termsApp.ts(668 行)、termsBlueprint.ts(478 行)]
- 使用者能看到的界面(pages/apps、apps/、登录、通知设置、errors、termsApp、validation、components、App 外壳)一共 43 个文件、约 1.0k 段中文 [frontend/src/pages/apps/*、frontend/src/apps/*、frontend/src/pages/LoginPage.tsx、frontend/src/pages/settings/NotifyTab.tsx]
- 语言写死在两处:<html lang="zh-CN">、系统通知 lang: 'zh-CN';数字格式固定用 en-US,日期自己拼中文 [frontend/index.html:2;frontend/src/lib/notify.ts:116;frontend/src/lib/format.ts:102;frontend/src/pages/schedules/model.ts:212]
- 服务端给人看的中文:用 AST 数,去掉 docstring 后约 8.9k 处。按位置分:调用参数 3.4k、大写常量 2.0k、return 1.0k、赋值 1.0k、异常消息 0.7k、直接写在 HTTPException 里 244、日志约 300。白话层 plain.py 有 404 条,已经是「编号 → 一句话」的结构。抛 HTTP 错误的地方 131 处带 code、256 处只有中文 detail(api/notify.py 14 处、api/auth.py 16 处全无 code)。前端把带中文的 detail 原样显示 [backend/app/api/coded.py:1-6;backend/app/plain.py:1-30;frontend/src/lib/errors.ts:40-75]
- 按「界面层不放逻辑」(P7),视图模型里的文字是服务端渲染好的:状态、出具档位、印章、空态说明(STATUS_TEXT、TIER_TEXT、Source.message 等)。页面缓存的键里有角色、用户名,没有语言 [backend/app/apps/resolve.py:52-62、533-541、613-650]
- 运行事件里的整句(LOG message、TOOL_ERROR error)写进 run_events,在封存范围内,是数据,事后不能改写 [backend/app/engine/judge.py:1415;backend/app/engine/nodes/human.py:280-294;backend/app/process/actions.py:372-385]
- 测试里断言中文原文的数量:检查脚本里约 14.8k 行带中文字面量,其中约 10.3k 行像断言或选择器;后端测试 23.1k 行 assert 里约 6.6k 行带中文 [scripts/check-*.mjs(check-manage 1378 行、check-decode 1243 行、check-evidence 1031 行……);backend/tests/]
+163
View File
@@ -0,0 +1,163 @@
> 分块细案(2026-10-16 只读调研整理,代码基准 main d135a17)。和 [00-总方案](00-总方案.md) 冲突的地方以总方案为准;总方案「跨块统一的决定」一节列出了改动过的条目。决定编号在本文件内有效,总方案里引用时带前缀(如 评测-D4)。
# 已知零散遗留
## 缺口
- 驳回在引擎里被记成失败。下游五处按失败处理或靠正则自己改说法:通知、定时 webhook、发布单的试运行判断、记录页的「继续运行」、问数据的终态。 —— 违背照实说(人的决定被说成故障,还给出注定再失败的出口)、一种说法(每个消费方各写一份映射,已经漏了四处)和界面层不放逻辑(前端用正则解析报错原文) [engine/nodes/tools.py:130/304/383、human.py:55/68、dual_check.py:1399;runner.py:1027-1031;webhook.py:336;publish_plan.py:363;explain.ts:299/489]
- 使用者选人没有候选,服务端也不核对;填错的名字会把待办派给一个不存在的人 —— 违背照实说(静默派错,流程卡死没人知道)。名录又不能整份放开给使用者(只读与最小可见) [PrincipalPicker.tsx:52-53;forms.py:164-197;entries.py:38-60;tasks.py:237-240]
- 表格导入清单、快照清单、作废记录、清除原件记录里的「是否认证」写死 False —— 违背留痕:封存证据里的事实记错了;和审计表、待办派单、run.origin 的 verified 口径不一致 [recipe_imports.py:2386;source_versions.py:425/688;table_versions.py:2163]
- 自由形蓝图没有「谁来做」和核实人的绑定处;第一次发布挡在 app.slot_unbound / app.reconciler_unbound 上,白话指向的卡片不存在 —— 模板定骨架、agent 填关节:骨架是导入的图,关节「谁」却没处填;照实说:「怎么改」指错了地方 [freeform.py:104-107;_common.py:337-361/431-440;lint.py:667-682;plain.py:156-161]
- 剖析得到的行数和日期范围到不了模型(look、db_schema、/schema、表助手);存下的剖析结果在之后加遮罩时不会重滤;剖析不记快照,上传表格源的结果分不清属于哪个版本 —— 问数据和助手都不知道数据到哪天、表有多大。另外要守住 D6 的遮罩,以及叙述层无算术 [schema_view.py:118-153;catalog_profile_jobs.py:278-366;models.py:455-512]
- tick 按全库条数断言,-n 8 下必然受别的测试残留影响 —— 关口偶发失败会盖住真回归。tick 只交回条数,也说不清办了哪几条 [tasks.py:764-782;test_process_tasks.py:283-320]
- 起草进度两处显示,剖析一处,两者不对称;窄屏和触屏看不到排队原因与超时提醒;检查脚本带着已删除的设置键 —— 违背一种说法;照实说(提醒只在悬停里) [CatalogPage.tsx:440-467/592/660-690;check-catalog-loop.mjs:219]
- 排队中的运行在重启时改成 failed,但不发 run.failed 事件 —— 留痕:时间线里没有失败原因这一行(界面能收尾,属于小口) [engine/runner.py:101-106]
- 浏览器关口依赖会变的开发数据,靠逐项打合成补丁维持;缺数据和真坏了混在一起报 —— 照实说:数据类失败名单 10-05、10-12、10-14 变了三次,会盖住真回归;真实数据也不该进公开仓库的检查 [check-runs.mjs:207-213/683-720/1600-1625 等;check-ui.mjs:121-124]
## 做法
### (a) 驳回 ≠ 失败:引擎层统一成「因人的决定而结束」
- 做法:1) engine/context.py 新增 NodeRejected(NodeError),字段:node_id、kind(action_confirm/approve/input/edit/code/tool/dual_check)、decision(reject|expired_reject)、actor、note。六处驳回改抛它,原话不变。2) runner._drive 在 except Exception 之前单独接住 NodeRejected:status='cancelled',error 写原话,error_node 写节点,terminal=(RUN_CANCELLED, {reason:'rejected', node_id, label, kind, decision, actor, note})。Run 新增可空列 end_reason(rejected|stopped),走 db/base._COLUMN_MIGRATIONS 加法迁移;手动停止写 stopped;RunOut 带上这一列。_close_approvals、沙箱回收、bus.close、封存对 cancelled 已经生效,不用改。3) 继续运行:cancelled 已经被 _cannot_continue 拒;按 end_reason 改文案为「被驳回的运行不能接着跑:改好内容后重新发起」。存量的 failed+驳回(用 resolve._decision 同一份判断)也拒,避免重跑出同一个驳回。4) 下游统一读 end_reason。instance_state 的 failed+reject 分支只留给存量;notify_run 改用 instance_state 的 code/text(驳回和「已退回」都走「实例结束」,ok=False);webhook._EVENT_OF 让 cancelled+rejected 推「未出具(被驳回)」,手动停止仍然不推;publish_plan 遇到 cancelled+rejected 的试运行不算 trial_failed,记 warning「最近一次试运行在「付款」被驳回、没有走完」。5) 前端:decode.ts、chat.ts 遇到 reason=rejected 时标题写「被驳回」,副标题写谁、备注;lib/status 的标签按 end_reason 区分;explain.ts 的正则只留给存量,并补上三种原话、continuable=false。顺手把 (h) 的小口一起做:重启时被改成 failed 的排队运行补发 run.failed。
- 复用:NodeError;RUN_CANCELLED 和 cancelled 终态的全部收尾路径;apps/resolve._decision/_REJECTIONS/instance_state;plain 层;_COLUMN_MIGRATIONS;check-decode 的事件夹具
- 文件:后端:engine/context.py、engine/runner.py、engine/nodes/{tools,human,dual_check}.py、db/models.py、db/base.py、api/runs.py、apps/resolve.py、notify/service.py、scheduler/webhook.py、blueprints/publish_plan.py。前端:run/decode.ts、store/chat.ts、lib/status.ts、lib/explain.ts、types.ts。测试:test_process_actions.py、test_process_tasks.py、test_app_instance_page.py、webhook 与 publish_plan 的测试。检查脚本:check-decode、check-apps、check-inbox 的伪造数据
- 对上的理念:照实说:驳回是人的决定,不给注定再失败的出口。一种说法:六处驳回一种异常、一种终态,消费方读同一个字段,界面不再正则解析报错原文(界面层不放逻辑)。留痕:谁驳回、备注写进 run.cancelled,进封存。不可回退:已封存的老运行不改写
- 否决的备选:① 维持 failed,让各消费方各自映射:已经漏了四处,以后每加一个消费方都要再记一次。② 新增运行状态 rejected:_TERMINAL/_SETTLED 至少 6 处、前端 112 处 cancelled 引用、部分索引条件都要改,漏一处就是一直等终态的死循环;收益和 end_reason 一样。③ 给动作确认节点加「驳回」出口:是有价值的补充(作者可以把驳回接到「未发送」成果),但解决不了没接出口时的语义,留作后续
### (b) 使用者选人:按用途给的候选池 + 服务端核对
- 做法:1) 新接口 GET /api/directory/candidates,任何登录的人都能调。参数 purpose=form|review;form 用途带 app_id、entry 或 task_id、field;q 前缀搜索,limit≤50。只返回 {users:[{username, display_name}], groups?, truncated},不给是否停用、角色、邮箱、组成员;停用的人直接不列。2) auth/directory.py 新增 candidates() 在服务端算候选池。form 字段默认是「能用这个应用的人」:AppGrant use 及以上,展开组成员,加上管理者;应用授权给所有人时,必须带 q 才返回。字段可以声明 x-principal: {pool: 'app'|'group:<组>'|'slot:<岗位>'},照 x-file 的写法进 forms._FIELD_KEYS 和 validate_form_schema。review 用途是启用的搭建者和管理员,规则同 _review_targets。调用 form 用途时要求这个人能用这个应用(app_role≥use),否则 403。3) 开登录时,start_entry 和待办表单提交按同一个池核对 principal 控件的值;不在池里返回 422,code=principal(FORM_ERROR_CODES 加一项),话写成「「x」不在可选的人里」。关登录照旧手填、照实提示。4) 前端 PrincipalPicker 加 source 属性:搭建者照旧用全量名录;使用者边输入边按 q 取候选(防抖),列表里显示显示名,悬停给用户名。SchemaForm 把表单上下文传下去,personal.tsx 用 purpose=review。/api/directory 仍然只给搭建者
- 复用:auth/directory.load_directory/mismatch;apps/access.app_role;AppGrant;process/tasks._expand 的组展开;forms 的 x-file 扩展写法;personal._review_targets 的角色规则;PrincipalPicker 现有的核对与提示
- 文件:后端:auth/directory.py、api/identity.py、main.py、apps/forms.py、apps/spec.py、apps/entries.py、process/tasks.py(表单提交核对)、blueprints/personal.py。前端:components/PrincipalPicker.tsx、components/SchemaForm.tsx、pages/apps/personal.tsx、api/client.ts、typesApp.ts、lib/termsApp.ts。检查脚本:check-apps、check-inbox、check-personal
- 对上的理念:只读与最小可见:使用者只看到这件事能选的人,而且只给名字。照实说:填错的名字当场拒,不再静默派给不存在的人。一种说法:候选池和核对在同一处算,前端不自己判
- 否决的备选:① 把 /api/directory 放开给所有角色:会暴露全员、停用状态、所有组。② 只做前端候选、不做服务端核对:绕过前端照样会派错。③ 放在发布检查里核:使用者填的是运行时值,发布时还不知道
### (c) 导入清单如实记录「是否认证」
- 做法:api/deps 新增 signer_verified()(开了登录且 principal.verified 时为真),table_imports、source_versions、datasources 的写接口把它和 signed_by 一起往下传。recipe_imports._manifest、累积快照清单、_revoked_record、purge_import_raw 改为写真实值。已封存的旧清单不改写。界面 ManifestBody 按记下的值说「登录身份 / 未认证署名」,用 VersionsDialog.signerLine 的同一套说法
- 复用:api/deps.signer_of;SIGNER_TEXT 的现成说法
- 文件:后端:api/deps.py、api/table_imports.py、api/source_versions.py、api/datasources.py:1107、data/recipe_imports.py:2361-2390、data/source_versions.py:395-427、687-688、data/table_versions.py:2163。前端:pages/import/ManifestBody.tsx:82、VersionsDialog.tsx:453/537/657。测试:test_table_imports_api.py:232、test_source_versions_api.py:256、test_datasources_upload.py:768、test_provenance_*
- 对上的理念:留痕:是谁、是否认证都如实记,和审计表、task_assignments、run.origin 同一个口径。已封存的证据不改写
- 否决的备选:读清单时按审计表补算:证据要在生成那一刻定下,事后推断不可信。把旧清单批量改成 True:会破坏哈希封存
### (d) 自由形蓝图:按导入的图列出「谁来做」
- 做法:1) freeform 的 cards 加 people,备注写「只定谁来做;会签、时限、超时在画布上改」。2) 新增 freeform.slots(bp):对 tree.slot_refs(graph, _writes(graph)) 去重成岗位,每个岗位列出用到它的节点和用途;写入能力没登记查询时,再加一行「写入结果不明时谁去核实」。blueprint_out 给出 derived_people。表达式、发起人、外部系统派单的步骤列成只读行。3) 存储复用现有路径:岗位绑定存在 spec.people[],Step.id 就是岗位名、只写 who,_common.slot_of/bindings_of 原样生效;核实人存在 /writeback/reconciler。needs_reconciler 对 freeform 按图里的写入算(同 freeform._writes 加 write_lookup)。bp_set 对 freeform 的 people 只收 who,其余字段拒收,报 bp.template_mismatch「在画布上改」。4) 检查:blueprint_issues 拿得到图(_context.graph,或新增 graph 参数)。派生岗位没绑时报 bp.people_unbound(试运行 warning、发布 error),路径指向 /people/<i>/who;图里已经没有的岗位,残留的绑定报 warning。publish_plan 照 reconciler 的办法,去掉重复的 app.slot_unbound(slots.<slot>)。5) 前端 BlueprintCards 的 people 卡片对 freeform 按 derived_people 渲染:行名只读,「谁来做」用 PrincipalPicker;核实人一行复用 writeback 的 ItemRow
- 复用:tree.slot_refs;freeform._writes;capabilities.registry.write_lookup;_common.bindings_of/slot_of/needs_reconciler;bp.people_unbound、bp.reconciler_unbound 及其白话;publish_plan 现成的去重办法;BlueprintCards 的行组件
- 文件:后端:blueprints/templates/freeform.py、templates/_common.py、blueprints/ops.py、blueprints/store.py(blueprint_out)、blueprints/publish_plan.py、plain.py。前端:pages/blueprints/BlueprintCards.tsx、editors.tsx、typesBlueprint.ts、lib/termsBlueprint.ts。测试:test_publish_plan.py、test_blueprint_templates.py、test_blueprint_starts.py。检查脚本:check-blueprint.mjs
- 对上的理念:模板定骨架、agent 填关节:自由形的骨架就是导入的图,卡片只填「谁」这个关节,不改图结构。一种说法:同一个岗位名、同一条 bindings_of、同一个核实岗位。照实说:「怎么改」指向真实存在的卡片
- 否决的备选:① 卡片上也能改会签和时限:会改导入的图,图哈希和已发布版本对不上,发布单就要另存一版,违背「改图回画布」。② 不做,让人去应用编辑器绑:第一次发布时应用还不存在,绑不了。③ 导入时把岗位一次性写进 spec.people:图换版本后行就过时了;派生出行、只存绑定更稳
### (e) 剖析事实进 look 的表详情(守 D6 的遮罩)
- 做法:1) catalog_profile_jobs 新增 last_facts(session, source_id, tables=None),复用 _ranked/_SHOWN,一条查询批量取 {at, job_id, row_estimate, date_ranges, snapshot_id}。2) CatalogProfileTask 加可空列 snapshot_id:剖析上传表格源时记当前快照,数据库源为空。只加列,不改已有的。3) schema_view 新增 profile_lines(facts, masked, snapshot_id),table_detail 加可选参数 profile=。渲染成一节:「数据剖析(YYYY-MM-DD):约 N 行(数据库统计估算 / 逐行数出 / 至少 N 行);下单日从 … 到 …」,后面加一句固定提示:「只供写查询参考(定时间范围、估表大小),不是查询结果,不能作为回答里的数」。遮罩按渲染这一刻的 masked_set 重滤;上传表格源只在 snapshot_id 和这次解析到的快照一致时给,不一致时写「剖析早于当前版本,不给」。4) 四处都接:look/tables 的 _Plan 一并取 facts;tools/datasource._make_schema_tool 在建工具时读(和目录同一时机);/schema?table= 接口;表助手的 _catalog_lines 用同一个 profile_lines
- 复用:schema_view.table_detail/masked_set;catalog_profile_jobs._ranked/_SHOWN;TableSize 的 method 叫法(和界面「准数 / 数到上限」一致,fa9739d);look D6 的遮罩规矩
- 文件:后端:data/schema_view.py、data/catalog_profile_jobs.py、data/catalog_profile.py(记 snapshot_id)、db/models.py、db/base.py、look/tables.py、tools/datasource.py、api/datasources.py、data/catalog_assistant.py。测试:schema_view、look tables、datasource tools、catalog_profile_jobs 各自的测试
- 对上的理念:一种说法:D6 规定同一张表一份说法,四处共用。遮罩:按当前遮罩重滤,模型看不到遮罩列的取值。叙述层无算术:明说剖析事实不可引用,裁判照旧标出没有出处的数。照实说:写明剖析日期和怎么数的,过期版本不给。不挡生成:取不到剖析结果就不出这一节
- 否决的备选:① 写进数据目录 notes:目录只放人审过的事实,剖析数字是会过期的观测,还会随目录导出带走。② 只给助手 look、不给运行时 db_schema:两边说法分叉,违背 D6,而问数据最需要知道数据到哪天。③ 在 schema_view 里直接读库:它是同步纯函数,保持由调用方读好再传入的分层
### (f) tick 测试不再受别的测试残留影响
- 做法:tasks.tick 和 actions.tick 改为返回这一拍处理过的 id 列表。调度循环 loop.process_due 不用返回值,不受影响;日志可以带上条数。测试只断言自己的待办:用 task.id in tick(...)、只升一次用 not in,保留对 escalation_level、事件数的断言。先写失败测试:加一个 fixture,预先开一条无关的、到期可升级的待办(模拟别的测试的残留),现有断言立刻失败;改完通过。同步修 test_app_instance_page.py:261,以及 test_process_actions、test_records 里按条数断言 actions.tick 的地方
- 复用:现有测试辅助 _app_run/_task/_events;/tmp 里验证过的两条测试的复现组合
- 文件:backend/app/process/tasks.py:764-782、process/actions.py(tick);backend/tests/test_process_tasks.py、test_app_instance_page.py、test_process_actions.py、test_records.py
- 对上的理念:生效可查可对账:tick 交回办了哪几条。测试只断言自己造的数据
- 否决的备选:① 给 tick 加只给测试用的过滤参数:测试需求漏进生产接口。② 每个测试一个新库:run_manager 和 checkpointer 绑在进程级引擎上,代价大。③ xdist 改成 --dist loadfile:同一进程里不同文件照样会串,只是换了个概率
### (g) 目录页的后台任务进度只放一处;检查脚本去掉旧键
- 做法:页头的「起草」「数据剖析」改回纯入口:进行中换成转圈图标,排队中换成时钟,读屏名称写状态,不写数字。DraftStrip 换成一条 JobStrip,按排队顺序列出这个源进行中和排队的起草、剖析(SourceLane 串行,最多两项)。每项一行:「起草 3/20」或「剖析 排队中,起草结束后开始」,加进度条和提醒,剖析的「最近查询多数超时」也挪到这里;右侧「查看」打开对应对话框。排队顺序取服务端的 waiting_for。check-catalog-loop.mjs:219 换成 table_max_queries: 40、table_max_s: 60,与 check-catalog-profile.mjs:231 的 DEFAULTS 一致
- 复用:draftJob.ts/profileJob.ts 的状态;waitsForProfile;DraftStrip 的样式;data-* 约定改为 data-catalog-job-strip,每项带 data-catalog-job=draft|profile
- 文件:frontend/src/pages/catalog/CatalogPage.tsx、lib/terms*.ts;scripts/check-catalog.mjs、check-catalog-profile.mjs、check-catalog-loop.mjs:219
- 对上的理念:一种说法:起草和剖析用同一套后台任务显示,进度只在一处。照实说:排队原因、超时提醒在窄屏和触屏上也看得见。界面层不放逻辑:排队关系取服务端给的
- 否决的备选:① 保留两处:同一组数字两处维护,而且起草两处、剖析一处,已经不对称。② 只留页头(照设计文档 §66):窄屏页头只剩图标,提醒只在 title 里,触屏看不到
### (h) 早期审查的三项:只核实,不再改
- 做法:h1、h2、h3 已在 main 上解决,并且有测试守着。唯一建议补的是 runner.startup:把排队运行改成 failed 时补发一条 run.failed,并进 (a) 的工作包,约 10 行代码加 1 个测试
- 复用:runner._emit;startup 里已有的补发 server_shutdown 写法
- 文件:backend/app/engine/runner.py:101-115;对应的 startup 测试
- 对上的理念:照实说、留痕:失败要有原因事件
- 否决的备选:不补:界面能收尾,只是时间线里少一行原因。补的代价很小,所以建议补
### (i) 浏览器关口改用自带的种子数据
- 做法:1) 新增确定性的检查数据生成器 scripts/seed-check-data.py(核心逻辑放 backend/app/devseed/)。AGENTLAB_DATA_DIR 指到输出目录,用应用自己的代码真跑:seed.py 的模板、run_manager 加演示模型 mock-fast、tests/fixtures/acceptance/retail.py 的虚构零售库。要造出的形态:≥130 条运行(含排在第 100 名之后的待办)、不属于应用的待处理审批 2 条、等过审批(wait_ms>0)的已完成探索运行、没保存就跑的运行、工作流删掉后留下的运行、有工作流的 401 失败(脚本里起一个回 401 的本地桩,照 fake-erp.py 的写法)、表达式写错的失败、取消的、驳回的(接 (a))、发布过两版以上的工作流、问数据对话、画布对话为空的工作流、数据源与目录。时间戳用固定偏移,随机种子固定。生成完自检:每种形态都造出来才算成功。2) check-all --stacks 支持 CHECK_DATA_SRC=seed:先生成一份到 /tmp,再按现有逻辑每套拷一份;不带 --stacks 时仍连开发服务,作为冒烟。3) 去掉 c6a29bf 的合成补丁,只留一种机制。形态缺失时:seed 模式记 ✗(是生成器的错);真数据模式记「– 跳过:数据里没有 …」,在 check-all 汇总里单列「没覆盖」。4) 「提取模板」一节自己 goto 到运行,不再依赖上一节停在哪
- 复用:check-all 的 --stacks/CHECK_DATA_SRC 隔离;seed.py;providers/mock_model.py;retail.py 生成器;fake-erp.py 的桩写法;各检查现有的写闸门
- 文件:新增 scripts/seed-check-data.py、backend/app/devseed/*;改 scripts/check-all.mjs、check-runs.mjs(207-213、521、618、641、683-720、1600-1625、2422)、check-ui.mjs(121-124);README 的检查一节
- 对上的理念:照实说:分清「坏了」和「没有数据可测」。真实数据不进公开仓库:种子全是虚构的、现场生成,不提交。一种说法:一套数据来源规则,不在各检查里各自合成
- 否决的备选:① 继续逐项打合成补丁:开发数据一变就有新失败,已经发生过三轮(10-05、10-12、10-14)。② 把种子库提交进仓库:是二进制,随代码变化要重新生成,还容易混进真数据。③ 全部改成伪造接口(像 check-blueprint 那样):check-ui 的价值就在于检查真后端接起来对不对
## 待拍板(本块)
- Q1 人工驳回(动作确认、审批驳回即终止、填写驳回、代码和工具审批驳回、双盲驳回)在引擎里记成什么终态?|选项:A 终态 cancelled + 新列 end_reason=rejected,run.cancelled 带上谁、备注、节点 / B 新增运行状态 rejected / C 维持 failed,只修通知、定时、发布单、记录页四处映射|推荐:A | cancelled 的收尾路径(关闭并行审批、回收沙箱、关流、不许继续运行)正好是驳回要的;全站的终态集合已经包含它,不会漏出一直等终态的死循环。B 改动面大、风险高,收益和 A 一样。C 等于每多一个消费方就要多记一次,已经漏了四处
- Q2 被驳回的应用实例、定时任务那一期,要不要推送?推到哪个事件?|选项:A 走「实例结束」(run.finished),文字按 instance_state 写「已退回」或「「付款」未确认(被驳回)」;定时任务 webhook 推「未出具(被驳回)」 / B 新增可订阅事件 run.rejected(订阅设置多一项) / C 不推|推荐:A | 发起人需要知道结果;订阅「实例失败」的人是在盯故障,驳回不该进去。走结束事件不用增加订阅项,还顺带修好「已退回」被说成「已完成」的现有不一致
- Q3 使用者在表单里选人时能看到谁?|选项:A 字段可以声明候选池,默认「能用这个应用的人」;应用授权给所有人时必须输入前缀才出候选;只给用户名和显示名 / B 全部启用的用户 / C 不给候选,只在服务端核对|推荐:A | 选的人通常要在这个应用里处理事情,「能用这个应用的人」最贴近这件事,暴露面也最小。B 等于给全员发通讯录。C 能防派错,但使用者仍要凭记忆填用户名
- Q4 服务端开始核对 principal 控件后,已上线的应用里手填过、不在候选池的名字会被 422 拒收。直接生效,还是先只警告一个版本?|选项:A 直接生效(拒收并说清) / B 先记警告、下个版本再拒|推荐:A | 不在池里的名字派出去本来就没人能处理,拖一个版本只会多出几条卡死的待办;拒收的时候说清楚即可
- Q5 自由形蓝图的「谁来做」卡片上能改什么?|选项:A 只定每个岗位由谁做(含核实人);会签、时限、超时在画布上改 / B 卡片上全都能改|推荐:A | 自由形的骨架就是导入的图。改会签、时限会改图,图哈希和已发布的那一版对不上,发布单就得另存一版,违背「改图回画布」
- Q6 剖析事实(行数、日期范围)给到哪些地方?|选项:A 助手 look、运行时 db_schema、/schema 接口、表助手四处都给(同一段文字) / B 只给助手 look|推荐:A | D6 定的是同一张表一份说法;问数据最需要知道数据到哪天。叙述层无算术靠一句固定提示加裁判守住
- Q7 目录页起草、剖析的进度放在哪?|选项:A 页头只做入口(只换图标),页头下方一条任务条按排队顺序列出两类任务 / B 只留页头(按设计文档 §66),删掉 DraftStrip / C 维持现状|推荐:A | 进度只在一处,排队原因和超时提醒在窄屏、触屏上也看得见;B 会把提醒藏进悬停提示里
- Q8 浏览器关口(check-runs、check-ui)用什么数据?|选项:A check-all 默认用现场生成的种子数据,开发数据只做冒烟(缺数据的项列成「没覆盖」) / B 继续用开发数据拷贝,逐项打补丁|推荐:A | 数据类失败的名单已经变了三轮,会盖住真回归;种子是虚构的、现场生成,也不会把真实数据带进公开仓库
## 风险
- (a) 终态从 failed 改成 cancelled:凡是按 status=='failed' 判断的地方都要逐一核对(webhook、publish_plan、记录页「失败」页签、评测和对比里按状态筛的地方);check-apps、check-inbox、check-decode 的伪造数据要跟上;可验证报告和 HTML 导出按 meta.status 画 FAILED(bundle/build.py:1359),驳回的运行怎么显示要核对
- (a) 定时任务里被驳回的那一期:auto_compare 的终态集合已包含 cancelled,会不会对它做自动对比、webhook 里「下一次照常」怎么写,要在实施时定清楚(没核实现有行为)
- (a) 存量 failed+驳回的老运行保持原状(封存不改写),界面上会同时存在新老两种形状,resolve 和 explain 的存量分支要保留并且有测试
- (b) 名单可见面:使用者能枚举应用授权范围内的人;授权给所有人时靠「必须输入前缀」和条数上限压住;单点登录的人第一次登录前不在名录里,选不到,要照实提示请对方先登录一次
- (b) 新增的 422 会让以前靠手填跑通的入口开始拒收,发布说明里要写明
- (d) 导入图里的岗位名是画布上随手写的 id,行名要靠节点标题拼出来;同一个岗位用在多处时说明要写全;图换版本后残留的绑定只报警告,不静默删除
- (e) prompt 预算:每张表多 1–3 行。模型把剖析数字抄进正文时,裁判会不会标成「无出处」,这一点没核实,实施时要加一条用例。存量剖析记录没有 snapshot_id,上传表格源在重新剖析之前一律不给(照实说)
- (f) 改 tick 的返回类型:已 grep,调用方只有调度循环和测试,风险低
- (g) 改页面结构要同步改三个检查脚本的断言(check-catalog、check-catalog-profile、check-catalog-loop),并且都要跑过
- (i) 生成种子要真跑引擎,模拟模型输出一变就可能让检查不稳;生成器要固定时间和随机种子,自带形态自检;生成耗时会算进 check-all
- 和本轮其他四块改同一批文件(见 cross),合并顺序要排好
## 测试
- (a) 先写会失败的测试:动作确认驳回后,断言 run.status=='cancelled'、end_reason=='rejected'、run.cancelled 带 reason/actor/note/node_id,且 Run.error 原话不变;对六种驳回(工具审批、动作确认、代码审批、审批驳回即终止、填写/编辑驳回、双盲驳回)做参数化
- (a) 驳回的运行 POST /continue 返回 409,话按 end_reason 写;存量 failed+驳回的运行也被拒(直接造一条 failed 运行加驳回的待办)
- (a) 通知:monkeypatch spawn,断言推的是实例结束、文字取自 instance_state,没有 run.failed;走到「已退回」成果的运行推「已退回」,不再写「已完成」
- (a) 定时任务 webhook:驳回的一期推「未出具(被驳回)」,手动停止的不推
- (a) 发布单:试运行被驳回时没有 plan.trial_failed,有 warning;真正失败的试运行照旧挡住
- (a) resolve.instance_state、_timeline 对新形状和存量 failed 形状都给出同样的说法
- (a) 前端:check-decode 加 run.cancelled reason=rejected 的用例(标题「被驳回」、副标题谁和备注);explain 的存量原话三种都是 continuable=false
- (b) 使用者调 /api/directory/candidates:只返回池里的人、只有 username/display_name,停用的不列;对没有使用权的应用 403;授权给所有人时不带 q 不出候选;review 用途只出搭建者和管理员;/api/directory 对使用者仍是 403
- (b) 开登录:start_entry 和待办表单的 principal 不在池里时 422 code=principal;关登录不核对;x-principal 声明非法时 validate_form_schema 报问题
- (c) 开登录导入一期:清单 signed_by.verified 为 True;关登录为 False;作废、清除原件的记录同理;旧清单读取时哈希复验照过
- (d) 从带派给岗位 finance 的人工节点、加上没登记查询的写入的已发布工作流建自由形蓝图:blueprint_out.derived_people 有 finance 和核实人两行;bp_set /people/0/who 之后 bindings_of 绑到 finance;发布单没有 app.slot_unbound;不选人时发布报 bp.people_unbound,路径指向 /people/0/who;改 policy 被拒
- (e) table_detail 带 profile 时渲染行数和日期范围,并带固定提示;剖析后才给某一列加遮罩,那一列的范围不出现;上传表格源快照不一致时不给;没有剖析结果时输出和现在逐字一致(快照断言);look、db_schema、/schema 三处文本一致
- (f) 新 fixture 预先放一条无关的、到期的待办,原来的条数断言立刻失败;改成按 id 断言后通过;在 -n 8 下连跑 test_process_tasks、test_app_instance_page、test_blueprint_trial、test_process_actions、test_records 五遍
- (g) check-catalog:只有一处显示进度;起草排在剖析后、剖析排在起草后,两种顺序的文字都对;窄屏下看得见;check-catalog-loop 用新键
- (i) seed-check-data 生成后自检各形态都在;CHECK_DATA_SRC=seed 时 check-runs、check-ui 全部 ✓、零跳过;连生成两次,结果一致(运行数、待办数、关键字段)
## 验收
- (a) 用真实后端加 fake-erp:在应用里驳回一次动作确认。实例页、记录页、待办箱、站内和 Webhook 通知、定时任务消息、蓝图发布摘要都写「被驳回」,不出现「失败」和「继续运行」。手动停止仍写「已取消」,定时任务不推。老的 failed 驳回运行显示不变,也不能继续运行
- (b) 开本地登录,以使用者身份在入口表单输两个字就出候选(只有名字),选中后发起,待办派给对的人;手填一个不在池里的名字,当场拒收并说清;关登录照旧手填;使用者访问 /api/directory 仍是 403
- (c) 开登录导入一期,清单里 signed_by.verified 为 true,界面写「登录身份」;关登录为 false;旧清单不变、复验通过
- (d) 用已发布的「财务会签(岗位)+ 写回(没登记查询)」工作流建自由形蓝图:卡片上列出两行 → 选好人 → 试运行 → 发布单没有 slot_unbound,一次发布成功;不选人时,发布摘要「你来定」指向这张卡片
- (e) 剖析过的表在助手 look、问数据 db_schema、/schema 里看到同一段剖析事实;给日期列加遮罩后,那一列的范围消失;上传表格换版本后不再给
- (f) pytest -n 8 连跑 5 次相关文件全过;原来能复现的那两条测试组合也通过
- (g) 目录页只有一处显示后台任务进度,窄屏可读;check-catalog、check-catalog-profile、check-catalog-loop 都过
- (i) CHECK_DATA_SRC=seed node scripts/check-all.mjs --stacks 2:check-runs、check-ui 全过、零「没覆盖」;对开发数据跑时,缺数据的项列成「没覆盖」,不算失败
- 整体关口:后端全量 pytest -n auto、tsc、check-copy、相关单项检查由各包自己跑;整套 check-all 由编排者在合并后只跑一次(按 dont-rerun-full-checks);推送前跑泄露扫描
## 工作量
按项估:(a) 后端约 250 行 + 测试约 200,前端约 80 + 检查约 60;(b) 后端约 200 + 测试约 150,前端约 120 + 检查约 60;(c) 后端约 40 + 测试约 40,前端约 15;(d) 后端约 220 + 测试约 180,前端约 150 + check-blueprint 约 60;(e) 后端约 180 + 测试约 150,无前端;(f) 后端约 15 + 测试改约 40;(g) 前端约 120(净减)+ 三个检查脚本约 80;(h) 约 10 + 测试约 20(并进 a);(i) Python 生成器约 500–700 + check-all 约 60 + check-runs/check-ui 约 150。合计:后端约 1100 行(含测试约 800),前端约 500 行,检查脚本和生成器约 1100 行。
按 workflow-granularity,建议拆 4 个工作包,每包一个完整代理,最后一次统一复核:
- L1 运行语义与留痕:(a)、(c)、(f)、(h),纯后端为主加少量前端;
- L2 选人与自由形:(b)、(d),同改 PrincipalPicker 和蓝图,放一包避免冲突;
- L3 目录与 look:(e)、(g);
- L4 检查数据:(i)。
依赖关系:
- L4 的种子要覆盖 L1 的驳回形态,所以 L1 先合,L4 最后做,做完再跑一次完整关口;
- L3 和「数据接入与目录维护」块改同一批文件,建议并进那一块或排在它之后;
- L1、L2、L3 之间没有代码依赖,可以并行。
## 和其他几块的接口
- 和「对外集成(API 令牌、通知渠道、视图嵌入、英文界面)」块的冲突:(a) 改 notify/service.py(notify_run 的事件和文字)和 scheduler/webhook.py(_EVENT_OF、headline),通知渠道那块大概率也改这两处,要排序或合到一个包;(b) 改 api/identity.py、auth/directory.py、main.py 路由权限,API 令牌那块会碰身份和权限依赖(api/deps.py),(c) 也改 api/deps.py;英文界面:(a)(b)(d)(g) 新增的文案都进 lib/terms*.ts,如果同时做 i18n 迁移,要先定新键怎么写
- 和「口径账本:问数据接已治理口径」块的冲突:(e) 改 data/schema_view.py、look/tables.py、tools/datasource.py,口径账本把 look 接进问数据时会改同一批文件;(a) 改问数据的终态处理(store/chat.ts、decode.ts)。建议 (e) 先合,账本基于它做
- 和「数据接入与目录维护(不含新库类型)」块的冲突:(e) 改 catalog_profile_jobs.py、catalog_profile.py、db/models.py(新列 snapshot_id),(g) 改 CatalogPage.tsx 和 check-catalog* 三个脚本,目录维护那块几乎一定会动这些;建议 L3 并进那一块,或者等它合入后再做
- 和「效果度量与回归评测」块的接口:评测要按运行终态统计时,要认 (a) 新增的 end_reason(驳回不算失败);(i) 的种子数据生成器可以和评测的固定数据集共用生成框架(mock 模型、retail.py),避免造两套
- (a) 新增 Run.end_reason 列、(e) 新增 CatalogProfileTask.snapshot_id 列,都走 db/base._COLUMN_MIGRATIONS,只加不改;和其他块同时加迁移时,注意合并顺序和列表冲突
- (f) 只改测试和 tick 的返回值,对其他块唯一的影响是:并行跑的测试会更稳
## 附:现状(代码位置)
- (a) 结论:仍存在。显示层改了,引擎、通知、定时任务、发布单、记录页都没改。动作确认被驳回时,引擎抛普通的 NodeError,运行走 _failure 发 RUN_FAILED,status 记成 failed。同样的写法一共 6 处:工具节点审批驳回、动作确认驳回、代码审批驳回、人工审批「驳回即终止」、填写/编辑模式驳回、双盲复算驳回。 [backend/app/engine/nodes/tools.py:130、:304、:383;human.py:55、:68;dual_check.py:1399;engine/runner.py:833-837(except Exception)、:1271-1291(_failure)]
- (a) 显示层:apps/resolve.instance_state 和 _timeline 按待办结论把 failed+reject 改说成 code=cancelled「「付款」未确认(被驳回)」。docstring 原话是「运行本身的状态不改(驳回动作确认在引擎里就是节点失败、运行结束)」。测试也照这个断言 final.status == 'failed'。 [backend/app/apps/resolve.py:1328-1380、1494-1498、_REJECTIONS 1302;backend/tests/test_process_actions.py:456-467]
- (a) 其他消费方仍按「失败」处理,共四处。① 应用实例结束时推 run.failed,消息写「实例失败」+「失败节点」。② 定时任务 webhook 把 failed 推成「运行失败」。③ 蓝图发布单里,最近一次试运行 status==failed 就挡发布(plan.trial_failed):搭建者在试运行里驳回写入,会被说成「试运行失败了」并挡住发布(按代码推断,没实跑)。④ 记录页 explain.ts 只用正则 /人工(驳回|拒绝)/ 认驳回,「未确认(被驳回)」「工具 X 的调用已被驳回」「代码执行已被驳回」三种原话都会落到兜底 continuable:true,于是给出「继续运行」。runner.continue 允许 failed,按 runner.py:536-541 的注释,从断点重跑会读到同一个驳回答复、再失败一次(推断,没实跑)。另外,界面靠正则解析报错原文本身就违背「界面层不放逻辑」。 [engine/runner.py:1027-1031;notify/service.py:203-221;scheduler/webhook.py:166-175、:336;blueprints/publish_plan.py:363-365;frontend/src/lib/explain.ts:299-306、:489;engine/runner.py:508]
- (a) 附带发现:走「驳回」出口到达「已退回」成果的运行(succeeded),实例页写「已退回」(code=cancelled),推送却写「实例已完成」。两边说法不一致。 [apps/resolve.py:1356-1361 对 notify/service.py:214-219]
- (b) 结论:仍存在。名录接口 /api/directory 只挂给搭建者(_BUILDER),返回用户名、显示名、是否停用和全部组。PrincipalPicker 只有搭建者且开登录时才取名录,使用者只能手填。 [backend/app/main.py:240;api/identity.py:215-224;frontend/src/components/PrincipalPicker.tsx:52-53]
- (b) 入口表单和待办表单的 principal 控件(SchemaForm)在服务端不核对。validate_form 没有 principal 规则,map_inputs 原样传值。用表达式派单时,resolve_assignee 只在算不出名字时记警告,tasks._targets 也不核名录。所以手填错的名字会把待办派给一个不存在的人:没人能处理,也不报错。 [frontend/src/components/SchemaForm.tsx:139-148;backend/app/apps/forms.py:164-197;apps/entries.py:38-60;engine/nodes/human.py:138-155;process/tasks.py:237-240]
- (b) 个人应用「提交审核」也是使用者在选人。服务端 _review_targets 会按名录核对,并要求对方是搭建者,但前端没有候选,使用者要凭记忆填搭建者的用户名。 [frontend/src/pages/apps/personal.tsx:174;backend/app/blueprints/personal.py:473-496]
- (c) 结论:仍存在。位置在表格导入的清单(Excel 导入),不在能力登记、端点或目录导入。导入清单和累积快照清单写死 "signed_by": {"name": …, "verified": False},作废记录、清除原件记录写死 signed_by_verified: False。可署名来自 signer_of:开了登录时就是认证过的用户名。清单是封存证据(取回时 load_manifest 复验哈希),里面却写着「未认证」。import_manifest 返回的 verified: True 指的是哈希复验结果,那个是对的,不是这次的问题。 [backend/app/data/recipe_imports.py:2386;data/source_versions.py:425、:688;data/table_versions.py:2163;api/deps.py:97-104;data/source_versions.py:100-110、:598、:612]
- (d) 结论:仍存在。自由形模板只有目的、多久更新、谁能看三张卡片。但它派生出的应用骨架会按图声明岗位:slot_refs 收人工节点派给谁、超时升级、动作确认,写入能力没登记查询时还会加 RECONCILER。bindings_of 只从 people 和 writeback.reconciler 取绑定,needs_reconciler 只在模板有 writeback 卡片时为真,所以自由形永远是 False。于是应用检查报 app.slot_unbound / app.reconciler_unbound(error),白话的「怎么改」写「在「谁来做」里给这一步选人或组」,可自由形蓝图上没有这张卡片。publish_plan 只对 bp.reconciler_unbound 去了重。 [blueprints/templates/freeform.py:104-107、app_skeleton 45-68;apps/derive.py:99-104;apps/tree.py:90-121;blueprints/templates/_common.py:337-361、:431-440;apps/lint.py:640-682;plain.py:156-161;blueprints/publish_plan.py:348-354]
- (d) 后果:从已发布工作流导入、图里有派给岗位的人工节点或没登记查询的写入的自由形蓝图,第一次发布会被挡死。这时应用还不存在,没法去应用编辑器绑岗位,只能绕开蓝图直接建应用。测试只覆盖了没有人工节点的 example_graph,这条路径没有测试。 [backend/tests/test_publish_plan.py:79-120;frontend/src/pages/blueprints/BlueprintCards.tsx:121(按模板 cards 渲染)]
- (e) 结论:仍存在(设计文档明确留作遗留)。table_detail 只用表结构缓存和数据目录,三处调用(look、运行时 db_schema、/schema)都没传剖析结果。行数估算和日期范围只存在 catalog_profile_tasks 里:last_profiles 带 row_estimate、不带 date_ranges,last_detail 只给界面的表详情。剖析时遮罩列不进查询,但已存下的 date_ranges/findings 在之后加遮罩时不会重滤(task_detail 原样给)。剖析任务没有记快照。现在唯一进到模型的是「只有一个日期列」时业务日期提议的 note,里面带着 min/max。表助手另有一套目录渲染。 [data/schema_view.py:118-153;look/tables.py:318;tools/datasource.py:798;api/datasources.py:887;db/models.py:455-482、:507、:510;data/catalog_profile_jobs.py:278-290、:320-366、:461;data/catalog_profile.py:1185-1208;data/catalog_assistant.py:377;docs/design/catalog-profiling-jobs.md:196]
- (f) 结论:仍存在,根因已确定,是测试之间互相污染,不是产品缺陷。tasks.tick 扫全库所有待处理的待办,返回处理条数,测试却按条数断言。污染源是 test_app_instance_page::test_tasks_with_the_same_title_carry_the_instance_and_when:它留下两条 due=PT1H、超时升级的待办(expense() 图)。串行跑时,字母序在后的 test_blueprint_trial.py:160 那次 tick(+2h) 恰好把它们升级掉了;-n 8 下两者不在同一个进程,残留就漏进 test_process_tasks。我在 /tmp 拷贝里只跑这两条测试,必现 `assert 3 == 1`(test_process_tasks.py:284)。actions.tick 的按条数断言是同类隐患。 [backend/app/process/tasks.py:764-782;backend/tests/test_process_tasks.py:283-285、302、318-320;test_app_instance_page.py:41-50、278-290、261(已写成 >=1);test_blueprint_trial.py:160;test_process_actions.py:176-177、413、484;test_records.py:464]
- (g) 结论:两条都仍存在。目录页上,起草进度同时出现在页头按钮(「起草 3/20」)和页头下方的 DraftStrip(进度条+提醒);剖析只有页头入口,超时提醒只在悬停 title 里。窄屏下页头文字是 hidden sm:inline,只剩图标。设计文档 §66 定的是页头各一个进度入口,DraftStrip 是之前留下的。检查脚本断言了两处。check-catalog-loop 的 PROFILE_ON 还是旧键 max_queries / max_total_s,没有 table_max_*,这里不是有意的旧数据场景;check-catalog-profile.mjs:222 那处才是有意的。 [frontend/src/pages/catalog/CatalogPage.tsx:440-467、:592、:660-690;docs/design/catalog-profiling-jobs.md:66;scripts/check-catalog.mjs:1152-1205、1385-1397、2387-2392;scripts/check-catalog-profile.mjs:1345、1376-1379、222、231;scripts/check-catalog-loop.mjs:219]
- (h1) 结论:已解决(e10c4f2 起)。问数据页的 watch() 认 server_shutdown 日志,收到 stream.end interrupted/suspended 时先查有没有待审批再收尾。streamRun 握手连续 2 次失败后改成轮询,轮询看到落定状态(含 interrupted)就收尾。服务启动时把强杀遗留的 running 改成 interrupted,并补发 server_shutdown。check-chat 有「服务重启」一节。还有一个小口:排队中的运行在重启时直接改成 failed,不发 run.failed 事件。前端靠轮询状态能收尾,但时间线里没有原因这一行。 [frontend/src/store/chat.ts:1601-1735(1674、1713);frontend/src/api/client.ts:1082-1180;backend/app/engine/runner.py:93-115、101-106;scripts/check-chat.mjs:1296-1337]
- (h2) 结论:已解决(62aac18)。MCP 和自定义工具走信任三档,默认 ask,dangerous 模式下问人。工具节点、agent、协作节点都经过 call_policy。能力登记的 confirm/exempt 和三档进同一份发起时快照。 [backend/app/tools/trust.py:1-80;engine/nodes/tools.py:104;llm.py:400;multi.py:381;backend/tests/test_tool_trust.py:270-424]
- (h3) 结论:已解决。GET 时丢掉库里存的 limits,PUT 时跳过 limits;设置页按环境变量只读展示,有测试守着。 [backend/app/api/settings.py:808-810、:920-923;frontend/src/pages/SettingsPage.tsx:1480-1483;backend/tests/test_agent_guards.py:410-421]
- (i) 结论:部分缓解,结构性问题仍在。check-runs、check-ui 连真后端、读真数据。c6a29bf(10-14)打了两处合成补丁:没有待办时合成 2 条(对应记忆里的「沙箱待审批在页签里」「页签数一致」),画布助手的对话 GET 回成 [](对应「空态给出明确邀请」「输入框自动聚焦」)。仍要靠数据形态的有三项:「提取模板」两项(要有 wait_ms>0 的已完成探索运行,前一节没找到时页面不在运行详情上,两项一起挂)、「未保存的工作流」筛选(要有没保存就跑的运行)。另有几项数据缺了就直接记 ✗:有工作流的 401 失败、发布过两版以上、表达式写错、成功的运行。当前 main 上这几项过不过:没核实,要起开发后端、读开发数据,超出只读范围。 [scripts/check-runs.mjs:1-15、207-213、521、618、641、683-720、1600-1625、2422;scripts/check-ui.mjs:1-13、121-145;scripts/check-all.mjs:1-50(CHECK_DATA_SRC 与 --stacks)]
+927
View File
@@ -0,0 +1,927 @@
# 普通用户建应用:方案
> 设计文档 · 草案,待拍板 · 2026-10-15
>
> - 本文档只定方案,不含代码改动。第五部分「实施组织」在本文签字后另行审批,再开工。
> - 代码位置按 **main@69c5c74**(应用平台合入后)标注,三路只读调研逐条核过。
> - 承接《应用平台与助手按需查看:总方案》(`docs/design/app-platform-and-look.md`,下称「总方案」)。本文不改它,引用写作「总方案 §x.y」。
> - 文中出现的数据源名、表名、应用名、人名都是虚构的。本文不含本机开发环境的计数。
## 目录
- 摘要
- 第一部分 现状与问题
- 第二部分 关键决定(N1–N11)
- 第三部分 规格
- 第四部分 界面
- 第五部分 实施组织
- 第六部分 验证与验收
- 第七部分 风险与对策
- 第八部分 待拍板
- 附录 A 代码位置索引 · B 术语
---
## 摘要
**问题。** 应用平台已经能做相当复杂的应用:多级审批、会签、时限与升级、写回外部系统、看板、按新口径补算。但能做出来的只有懂编排的人。普通人卡在这几处:
- 必须先在画布上搭好并发布工作流,应用只能从已发布版本生成;
- 从问数据出发要跳四次:做成应用 → 提取工作流 → 去画布发布 → 回来做成应用;
- 满屏专家词:固定版本、处置、岗位绑定、视图来源、出具契约、口径卡、`cell(nodes.fetch, 0, 'gmv')`、`PT48H`;
- 发布前看不到真数,判断不了「这是不是我要的」;
- 两类最常见的需求做不了:上传文件,以及「填报登记」(每次提交存一行,再汇总着看)。
**做法。** 把「搭应用」改成「说需求 → 看蓝图 → 用真数试 → 确认」,用的就是项目的路线「模板定骨架、助手填关节」:
1. **一条链路,两种终点**(N1)。说需求、蓝图、模板填关节、试运行,两类人走同一条链路,只在最后一步分开:
- 搭建者:一次确认,发布成正式应用;
- 使用者:存成**个人应用**,只自己看得见、只读、跑探索运行,想给别人用就提交给搭建者审核发布。
2. **应用蓝图**(N2):用户的话和工作流、应用之间的中间件,用白话卡片呈现。卡片写的是看哪些数、谁发起谁审批、多久更新一次、要不要写回系统,用户逐张确认或修改。卡片的改动是确定性的操作,不是让模型重写。
3. **场景模板**(N3):首批五个——周期看板、审批流程、异常处置、填报登记、核对写回。骨架锁住,助手只能填模板声明的「关节」:取数的 SQL、口径卡的指标式;其余关节由蓝图确定性地填。
4. **用真数试运行**(N4):跑一次探索运行,草稿应用直接对着这次运行解析,印章写「试运行」。流程类的审批步骤派给试运行的人自己点,写入一律走测试环境或演练。
5. **一次确认发布**(N5):服务端按「发布单」编排——保存版本、发布工作流、建应用、绑岗位、建定时任务、授权、跑第一次——每步可续做。确认前给一页白话摘要;查出的问题分成「你来定」「助手可以修」「要找管理员」三类。
6. **在页面上直接改**(N6):应用页进入编辑状态后点哪改哪;改取数逻辑就回到蓝图,重走试运行和发布单,口径变不变用白话说清。
7. **补两个能力缺口**:
- 记录表(N8):平台托管、只许追加,用序号钉版本,按授权分行可见;
- 文件(N9):按哈希存,以引用进运行输入,可以交给 agent 读,也可以作为一期数据导入。
8. **白话层**(N10)和**生成的耗时与稳定性**(N11):所有检查问题有一份白话说法;补上问数据和画布两端「助手反问」的收尾。
**已拍板**(2026-10-14、10-15):
- 两类人都做,共用一条链路(N1);
- 填报登记要做行级可见(N8);
- 表单只许加字段,改字段或删字段就出新的表版本,旧记录照原样保留(N8);
- 试运行只给有管理权限的人看(N4);
- 先写设计文档,签字后实施;验收结束后实施(已结束)。
**待拍板**:见第八部分,共 8 项,每项附推荐。
---
## 第一部分 现状与问题
### 1.1 现在建一个应用要走的路
一个从零开始的看板应用,最少要按顺序做这些事,每步各自提交:
1. 在画布上搭工作流并保存,形成版本快照;
2. 发布工作流(受管级只有管理员能发);
3. 建定时任务——必须在第 4 步之前,否则派生出来的视图不绑定时任务(`apps/derive.py:85-90`);
4. 派生应用草稿(`POST /api/apps/derive`,不落库);
5. 建应用(`POST /api/apps`);
6. 绑岗位(`PATCH draft_bindings`);
7. 授权(`PUT grants`,立即生效);
8. 发布应用。
中途失败会留下半截状态:已发布但没有应用的工作流、按 cron 在跑的定时任务、停在草稿的应用。草稿应用的授权已经生效,使用者打开会得到 409「还没有发布」(`api/apps.py:83`)。这些步骤分散在画布、定时任务、应用编辑器三处。
### 1.2 普通人卡在哪
| 卡点 | 现象 | 出处 |
|---|---|---|
| 要会搭图 | 应用只能钉已发布版本 | 总方案 A1 |
| 术语 | 口径卡表达式、ISO 8601 时长、出具契约 JSON、视图来源、处置 | 端到端验收「普通用户视角」13 条 |
| 看不到真数 | 草稿预览只读钉住版本已有的封存运行,新应用没有 | `apps/resolve.py:454-476` |
| 路径长 | 问数据做成应用要跳四次 | `ChatPage.tsx:484-492` |
| 做不了 | 没有文件控件;没有平台托管的可写表 | `apps/spec.py:35-38`;`data/table_versions.py:1-33` |
### 1.3 现有零件(直接复用)
- **生成管线**:`Generation` 加 `Applier`/`Checker` 协议,新的生成目标插进来就能用(`api/copilot_gen.py:567`,`look/base.py:87-110`)。
- **按需查看**:表、能力、工作流版本三类对象(`look/service.py`)。
- **护栏**:
- 润色边界 `polish_problem`(`apps/ops.py:264`);
- 自动修复的 `forbidden_changes`(`engine/autofix.py:1240`)。
两者都是「只许改哪些」的确定性护栏,模板关节照这个写法。
- **确定性派生**:`derive_app`(`apps/derive.py`)。
- **从运行提取工作流**:`/copilot/from-run`,带来源记录 `workflows.origin`(`api/copilot.py:2468-2550`)。
- **数据目录**:表的中文名、粒度、业务日期、列的含义和度量类型(`data/catalog.py:12-21`),是把白话对到表的依据。
- **按运行解析**:视图来源 `instance` 已经能按给定的那次运行解析,不查类别和哈希(`apps/resolve.py:541-546`)。
- **所有运行都封存**,探索运行也一样(`engine/runner.py:1013-1045`);探索运行的写入走测试环境或演练(`capabilities/endpoints.py:101`)。
- **问数据对使用者的约束**(`auth/chat_policy.py`):
- 节点和工具的白名单;
- 强加 `MASK_RESULTS`、`READ_ONLY`(`api/runs.py:108-123`);
- 记忆隔离。
- **文件相关**:
- 上传限额 `read_upload`(`api/datasources.py:905-918`);
- 按哈希存原件 `raw_store`(`data/raw_store.py:33-43`);
- 按配方导入「上传新一期」(`api/table_imports.py:160-486`)。
- **表版本链**:快照的版本链,以及「改列不兼容」的判定 `INCOMPATIBLE_KINDS`(`data/recipe_accumulate.py:38-41`)。
- **数据版本**:data_versions 进封存范围和印章(`engine/runner.py:242-262`,`apps/resolve.py:238-240`)。
- **名录**:`auth/directory.py`,按登录方式核对人和组。
- **白话分类的依据**:
- 检查问题已经有 `code`;
- 自动修复有 `kind: auto|choice|assist`(`engine/autofix.py:125-141`);
- 只能由人处理的有 `APP_HUMAN_ONLY`(`api/copilot_gen.py:416-421`)。
### 1.4 核实过的缺口
| # | 缺口 | 出处 |
|---|---|---|
| G1 | 没有带参数的模板,也没有「模板里哪些地方能改」的机制;GraphSpec 只有 nodes/edges/viewport/defaults;「以此模板新建」只是复制 | `engine/schema.py:115`;`WorkflowPicker.tsx:354`;`api/workflows.py:249` |
| G2 | 应用视图只认正式运行;印章没有「探索」「试运行」 | `apps/resolve.py:95,149,203-221` |
| G3 | 探索运行没有版本号和哈希,三种视图来源都取不到它 | `engine/launch.py:166-174` |
| G4 | 跨步没有事务:工作流发布撤不回(只能退役),应用只能归档、不能删 | `api/apps.py:273` |
| G5 | 检查问题只有技术说法,没有白话 | `engine/governance.py:217-271` |
| G6 | 只问不改的一轮:问数据的 store 没有 ask 分支,可能一直停在「进行中」、问题不进历史;画布也不记这一轮 | `store/chat.ts:1433`;`store/studio.ts:2149` |
| G7 | 使用者不能建应用;应用视图、印章、读权限都只认正式运行 | `api/apps.py:191`;`auth/access.py:74-96` |
| G8 | 工件库只存 JSON;运行输入不能引用文件;入口提交是 JSON | `core/artifact_store.py:45-115`;`engine/nodes/io.py:14-33` |
| G9 | 平台托管的表都是不可变快照,只能整期替换或按期累积;没有逐行追加,也没有行级可见 | `data/table_versions.py:1-33`;`engine/masking.py` |
| G10 | 没有按名字检索的口径库;复用口径只能逐级下拉选 caliber_from | `engine/schema.py:1460`;`Inspector.tsx:1065` |
| G11 | 组件没有「隐藏」字段,只能删掉或写可见性表达式;改动要先保存才看得到 | `apps/spec.py:97-107`;`apps/resolve.py:632` |
| G12 | 生成没有并行、没有提示缓存;问数据校验不过时前端整轮重来 | `look/service.py:192`;`store/chat.ts:1241` |
### 1.5 理念对照
本方案不加新理念,把总方案的八条用到「创建」上:
- **P1 照实说**:试运行、探索、个人应用的数都在印章上写明来历;检查问题用白话说清是什么、为什么、谁来改。
- **P2 留痕**:
- 蓝图每一次改动都留痕;
- 发布单每一步的结果都留痕;
- 生成的工作流记下出自哪个蓝图、哪个模板。
- **P3 不挡生成**:模型失败时,模板照样能按蓝图确定性地实例化,缺的关节标「待填」交给人。
- **P4 只读与遮罩**:
- 个人应用只读、结果遮罩;
- 记录表按授权分行可见;
- 文件只给读得到那次运行或那一行的人。
- **P5 一种说法**:白话层只有一份,按问题编号映射。
- **P6 模板定骨架**:模板的节点和连线锁死,助手只填声明过的关节。
- **P7 界面层不放逻辑**:卡片的修改是对蓝图的确定性操作;页面上直接改走的仍是应用操作。
- **P8 生效可查可对账**:记录表的写入走动作(生效记录、幂等键);文件的哈希进封存。
不可回退决定的对照见 §2.12。
---
## 第二部分 关键决定
每个决定写四样:决定、理由、否决的做法、代价。
### 2.1 N1 一条链路,两种终点
**决定**
链路是:说需求 → 蓝图 → 选模板并实例化 → 试运行 → 终点。
- **起点三个**,都落到同一个蓝图:
1. **说说你要什么**:开一段蓝图对话,助手先问清楚,再整理成卡片;
2. **从场景模板开始**:选一个模板,卡片按模板预置;
3. **从已有的开始**:从问数据的一轮回答(运行)开始,或者从一个已发布的工作流版本开始。前者用 from-run 提取,后者直接导入图,模板记为「自由形」。
- **终点两种**:
- **搭建者**:发布单(N5)一次确认,产出已发布的工作流、正式应用、定时任务、授权;
- **使用者**:存成个人应用(N7),自己用。想给别人用就「提交审核」,搭建者打开同一个蓝图,走试运行和发布单。
**理由**:一条链路只维护一份,「问 → 建」的沉淀路径是现成的(P2)。使用者沉淀出来的东西不绕过发布门禁:个人应用永远是探索,正式应用只能由搭建者发布(不可回退决定:正式运行只从已发布版本发起)。
**否决的做法**
- 给使用者一个简化版画布:等于让使用者搭图,权限和门禁都要另开口子;
- 使用者的应用也走正式发布:让 user 角色产出承诺,违背角色分层。
**代价**:个人应用和正式应用是两种对象状态,界面要说清区别。
### 2.2 N2 应用蓝图 `agentlab.blueprint/1`
**决定**
- **定位**:蓝图是用户的话和工作流、应用之间的结构化中间件,存成一个可变对象 `Blueprint`。它有:
- 拥有者、可见范围(个人 / 共享);
- 状态:起草中 / 试运行过 / 已发布 / 个人应用 / 审核中;
- 来源:对话、模板、运行、工作流版本。
- **一张蓝图由几张「卡片」组成**,每张卡片对应用户能理解的一件事:
| 卡片 | 内容 | 落到哪里 |
|---|---|---|
| 目的 | 一句话:这个应用解决什么问题、给谁用 | 应用名称、说明 |
| 场景 | 选定的模板(N3) | 骨架 |
| 看哪些数 | 每一项:白话名、对到哪个库哪张表(或复用哪张已发布的口径卡)、粒度、业务日期、筛选条件、单位 | SQL 关节、口径卡关节 |
| 多久更新 | 每天、每周一、每月 1 日……时区;统计哪一期(本周、上周、上月) | 定时任务、入口字段的模板值 |
| 谁来做 | 发起人、各步处理人(人、组、岗位)、几个人同意算通过、时限、超时给谁 | 人工节点配置、岗位绑定 |
| 填什么 | 表单字段:名称、类型、必填、上下限、选项、文件 | 入口表单、待办表单、记录表的列 |
| 写回哪里 | 目标能力(从能力登记里选)、字段对应、要不要人确认;能力没登记查询的,再定结果不明时谁去核实(只由人定,同「谁能看」) | 调用工具节点、应用的核实岗位 |
| 谁能看 | 使用、查看全部、管理三档的人和组 | 授权 |
- **每一项都带状态**,照数据目录项的写法 `{value, source, status}`:
- `source`:用户填的 / 助手提的 / 模板默认 / 从目录或口径卡对到的;
- `status`:已确认 / 待确认 / 缺。
试运行前,所有「缺」都要补齐;「待确认」可以先试运行,但发布时必须确认。
- **助手只提议**:输出 `bp_*` 操作(`bp_set`、`bp_add`、`bp_remove`、`bp_ask`),由 `BlueprintApplier` 确定性落地。用户在卡片上改,走的也是同一组操作,只是 `source` 记成用户。
- **对到数据靠 look**:助手看「看哪些数」时,先查数据目录(表的中文名、粒度、业务日期、列的度量类型),再查已发布工作流里的口径卡(N2 新增 look 类别 `caliber`,补 G10)。对到已有口径卡时优先复用(caliber_from),不重写指标式。
- **反问**:缺的信息由助手用 `bp_ask` 问,一次问一件事、给出候选(例如「销售额按下单日还是支付日算?」并附两个选项)。这一轮就此结束,回答是下一轮(G6 一并修好,N11)。
**理由**
- 卡片是用户能判断对错的颗粒度(P1);
- 模型产出的是提议,落地是确定性的(P7);
- 每一项都有来源和状态,不会把模型猜的当成用户说的(P2)。
**否决的做法**
- 让模型直接生成工作流再给用户看图:普通人看不懂图;
- 一问一答的长表单向导:问题顺序固定,答不上来就卡住;
- 蓝图只存在前端:刷新就丢,也没法交给搭建者审核。
**代价**:多了一种对象和一套操作;模板变了,蓝图的卡片要跟着兼容(蓝图记下模板版本)。
### 2.3 N3 场景模板 `agentlab.template/1`:骨架锁住,关节可填
**决定**
- **模板是一份登记**,和组件目录同一种做法(总方案 A2):
- 后端 `app/apps/templates/`,每个模板一个模块;
- 开发者扩展时,模板定义、实例化函数、测试三样都要给,缺一样启动即报错。
- **一个模板包含**:
- **骨架**:带占位的图(节点和连线固定);
- **关节**:骨架里允许填的位置,每个都有类型、来源、校验;
- **应用骨架**:页面布局,用派生规则加模板自带的组件排布;
- **卡片映射**:蓝图的哪张卡片填哪些关节;
- **说明和适用条件**:给用户和助手看。
- **关节分两种**:
- **确定性关节**:由蓝图直接填,不经模型。包括:
- 表单字段 → 入口节点 fields;
- 处理人和策略 → 人工节点 assignee/policy/due/on_timeout;
- 金额线这类规则 → 分支节点的比较条件,只许「字段 比较 常量」;
- 更新频率 → 定时任务;
- 写回目标 → 调用工具节点的 tool 和参数映射。
- **生成关节**:只有两类交给模型——
- 取数的 SQL(`sql`);
- 口径卡的指标式(`metric_expr`,只许 `cell()` 取格加口径卡的算术)。
模型在 `TemplateApplier` 下工作,只收 `fill_joint` 操作,而且只收这个模板声明过的关节 id,别的一律拒绝并照实交回(照 `polish_problem` 的写法)。
- **护栏**:实例化后的图和骨架逐节点比对,只许关节位置不同,用 `forbidden_changes` 的同一套比对。之后照常走 validate、GraphChecker(SQL 猜列、数据源范围)和发布前检查。
- **来源记录**:生成的工作流写 `workflows.origin = {blueprint_id, template: "<id>@<version>", run_id?}`,补上「只写不读」的现状:WorkflowOut 和版本历史显示「出自蓝图 X、模板 Y」。
- **首批五个**(详见 §3.3):周期看板、审批流程、异常处置、填报登记、核对写回。外加「自由形」:从已有的运行或工作流导入,没有关节,改图只能回画布。
- **模型失败时**(P3):生成关节留空、标「待填」,蓝图卡片上提示「这一项助手没填上,请补充或换个说法」;确定性关节照样落地。
**理由**
- 模板定骨架、助手填关节,正是项目路线;
- 关节越少,生成越快、越稳(N11),检查也越有效;
- 锁骨架就锁住了治理关注的结构:出具契约、人工节点、写入位置。
**否决的做法**
- 模板只是一张样例图,复制了让助手随便改:等于自由生成,护栏全靠事后检查;
- 用模板引擎在 JSON 上做字符串替换:类型和校验都丢了;
- 一个模板覆盖所有场景、用大量开关:组合爆炸,测不过来。
**代价**:每个模板要写实例化和测试;用户的需求落在模板之外时,只能走「自由形」,或者由搭建者新增模板。
### 2.4 N4 用真数试运行
**决定**
- **试运行**是一次探索运行,跑的是蓝图实例化出来的、还没发布的图,来源记为 `origin_kind=trial`、`origin={blueprint_id}`。
- **草稿应用对着这次运行解析**:
- 新增解析模式 `resolve_page(draft=True, trial=run)`;
- 草稿里所有视图来源(最近封存、按需、本实例)都指向这次运行;
- 钉住项记成 `{workflow_id, version: null, graph_hash: <这次运行的图哈希>}`。
沿用 `instance` 分支「按给定运行解析」的路径(`apps/resolve.py:541-546`)。不改 `agentlab.app/1` 的视图来源种类,试运行只是解析时的旁路参数。
- **印章**:
- 来源写「试运行」;
- 注明「数据是真的;这不是正式结果,发布后才会按定时任务出数」;
- 出具档位照实算;没封存的照实写。
趋势只有一个点时写「试运行只有一次,没有趋势」,不报错(补 `_series` 拿不到钉住项就报错的问题,`apps/resolve.py:813`)。
- **输入**:
- 看板类:按「多久更新」卡片算出最近一期(例如上一 ISO 周),用户可以改;
- 流程类:由用户填一份示例表单。
- **流程类的试运行**:
- 人工步骤派给试运行的人自己(来源记为 `trial`),按钮写「以试运行身份同意/驳回」;
- 写入一律走测试环境;没有测试环境的演练,不发送;
- 时限按真实设置显示,但试运行不触发升级——调度跳过 `origin_kind=trial` 的待办。
- **谁能看**:只有对这个蓝图有管理权的人(拍板项):搭建者看自己的、有 manage 的看共享蓝图的;个人应用的试运行只有拥有者看。试运行是探索运行,遮罩按看的人的角色照常处理。
- **发布时对齐**:发布单按图哈希判断「发布的版本就是试运行过的那张图」:
- 一致:钉住项直接换成版本,节点 id 一一对上;
- 不一致(试运行之后又改过):摘要里写明「发布的内容和最近一次试运行不同」,建议再试一次,不强拦。
**理由**:先看真数再决定(P1);不发布就不产生正式运行(不可回退决定);写入不出测试环境(总方案 A5)。
**否决的做法**
- 先发布一个隐藏版本再试:版本表里满是试验版本,退役和口径升版的判断都被污染;
- 用假数据预览:普通人恰恰要看真数才判断得了。
**代价**:试运行要花一次模型或查询的钱;流程类的试运行要人点一遍。
### 2.5 N5 一次确认发布:发布单
**决定**
- **发布单**(`PublishPlan`)是服务端的编排对象:先**算出**要做的每一步和白话摘要,用户确认后**按序执行**,每步的结果记下来;失败了停在那一步,修好后从那一步**接着做**。
- **步骤**(顺序就是为了让中途失败时留下的状态无害):
1. **保存版本**:按图哈希判断,已有相同的版本就复用;
2. **全量预检**(只读):工作流发布前检查 + 按将要生成的版本跑应用检查 + 定时任务预览 + 名录核对。任何 error 都在这一步停下,什么都没写;
3. **发布工作流**(受管级只有管理员);
4. **建定时任务**(停用状态),这样派生时视图能绑上它;
5. **建应用、派生、写岗位绑定**;
6. **发布应用**:钉住项升版时按蓝图里的处置;第一次发布没有处置;
7. **启用定时任务**;
8. **授权**:最后一步,避免使用者先看到一个打不开的应用;
9. **跑第一次**(看板类默认勾选):立即运行一次,应用一打开就有数。
- **白话摘要**:每一步一句「会发生什么」,例如:
- 「发布『门店周报』:此后每周一 09:00 按这一版自动出数」;
- 「张三、李四可以使用;财务组的人负责审批」;
- 「口径变了:以前的周报按新口径重算(recompute),旧数据留底」。
- **问题分三类**,按问题编号走白话层(N10):
- **你来定**:岗位没绑人、处置没选;
- **助手可以修**:autofix 的 auto/assist、应用检查里不在 `APP_HUMAN_ONLY` 的;
- **要找管理员**:受管级、降级、内网端点、能力治理。
- **补偿**:
- 第 3 步之后的失败不撤回已发布的工作流(发布本来就撤不回),摘要照实说「工作流已发布,应用还没有」;
- 停用的定时任务在应用发布前不会跑,不产生无主的正式运行;
- 应用停在草稿时不授权,使用者看不到。
- **权限**:执行发布单的人要有其中每一步的权限,预检时就按角色算好(例如搭建者发不了受管级,摘要里直接说)。
**理由**:一次确认、照实摘要(P1);每一步留痕(P2);中途失败的状态无害、可续做。
**否决的做法**
- 一个大事务把八步包起来:跨定时任务、应用、工作流的事务在 SQLite 单进程下做得到,但出错后整体回滚会吞掉已经通过的检查结果,用户得从头来;而且发布动作本身就带审计,不应该回滚;
- 前端按顺序调现有接口:刷新页面就丢进度,权限和补偿都在客户端,不可靠。
**代价**:多一个对象和一张表;现有各接口的校验要能被发布单按「只检查不提交」的方式调用。
### 2.6 N6 在页面上直接改
**决定**
- **改呈现**:
- 有 manage 的人在应用页点「编辑」进入编辑状态,ViewRenderer 叠一层编辑层:按 `data-node` 选中组件(`apps/ViewRenderer.tsx:75-84`),就地改文字、隐藏、上下挪、删除。
- 落地走现有的应用操作 `app_update`、`app_move`、`app_remove`,自动存草稿,然后按草稿重新解析。
- **加「隐藏」**:组件加 `hidden: true` 字段(补 G11),解析时跳过、编辑状态下照常画成半透明。这和可见性表达式不是一回事:可见性是按运行时条件显示,隐藏是作者不要它。
- **加一张数据卡片**:候选从钉住版本里列,用同一份派生规则 `_results`(口径卡指标、报告、取数节点、趋势),新接口 `GET /api/apps/{id}/candidates`。用户挑一个,就落成一个 `app_add`。
- **改取数逻辑**(「销售额要去掉退款」):
- 不在页面上改,回到蓝图:改「看哪些数」卡片 → 重新实例化 → 试运行 → 发布单;
- 发布单在摘要里用白话说清口径变了、以前的数怎么处理(处置),沿用总方案的口径升版规则。
- **改完要发布**:呈现的改动只进草稿,用户点「发布」走应用发布(不动工作流),沿用现有规则。
**理由**:普通人改看得见的东西最直接(P1);改取数会动口径,必须经过试运行和处置(不可回退决定:口径升版须处置)。
**否决的做法**
- 页面编辑器允许拖拽任意布局:像素级设计不是目标(总方案 §1.3);
- 页面上直接改 SQL:绕过试运行和处置。
**代价**:编辑状态和查看状态要共用一套渲染,交互细节要打磨。
### 2.7 N7 个人应用
**决定**
- **对象**:
- 个人应用是 `scope=personal` 的 App,拥有者是创建它的使用者;
- 它的图不进工作流表(使用者没有工作流权限),存在蓝图上,带图哈希;
- 没有应用版本,改了就是改了,每次刷新按当前的图跑。
- **能用的东西**,与问数据同一份白名单(`auth/chat_policy.py`):
- 节点:input、llm、agent、report、output、retrieve、transform、branch、metrics、merge;
- 工具:只读内置工具和只读数据源查询;
- 运行时强加 `MASK_RESULTS`、`READ_ONLY`;
- 不能有人工节点、不能写入、不能调写入能力。
能用的模板因此只有:周期看板、异常处置的「只看」部分、填报登记的「只看自己的」汇总。
- **刷新**:
- 打开时用最近一次成功的刷新;
- 点「刷新」发起一次探索运行(`origin_kind=personal_app`),身份是拥有者;
- 受每日上限约束,和按需视图共用设置项 `apps.on_demand_daily_limit`,按人计。
- 是否允许每天自动刷新,见待拍板 Q4。
- **视图来源**:新增 `exploratory`,只在个人应用里合法——取这个应用、这个拥有者、这张图哈希下最近一次成功封存的探索运行。印章来源写「探索 · 未经发布审核」,档位照实写。
- **谁看得见**:只有拥有者。管理员在设置里看得到「谁有几个个人应用」,可以归档,但不看内容(P4)。
- **提交审核**:
- 拥有者点「提交审核」,选一位搭建者,或者交给搭建者组;
- 开一条待办(种类 `review`,派给对方);
- 搭建者打开时得到这个蓝图的一份**共享副本**(原个人应用不动),在副本上走试运行和发布单;
- 发布后通知提交人,提交人自己决定个人应用留不留。
- **开关**:管理员设置 `apps.user_personal_apps`,默认关(同问数据的开关,待拍板 Q3)。
**理由**:使用者能自助(产品目标);个人应用永远是探索、只读、遮罩(P4,不可回退决定:没发布的图只能跑探索);共享必须经搭建者发布(承诺由搭建者签)。
**否决的做法**
- 给使用者开「个人工作流」:工作流表的权限、门禁、定时任务全要另开一套;
- 个人应用允许分享给别人:等于绕过发布门禁。
**代价**:视图来源多一种、印章多一种;个人应用的图和工作流的图是两种存法。
### 2.8 N8 记录表(填报登记)
**决定**
- **存储**:每个记录表一个平台托管的 SQLite 文件(`data/records/<表 id>.db`),只许追加。
- 每行带系统列:`_seq`(自增序号)、`_id`、`_submitted_by`、`_submitted_at`、`_run_id`(哪次运行写的)、`_supersedes`(更正时指向被更正的行)、`_schema_version`。
- 业务列由「填什么」卡片生成。
- **作为数据源**:登记成 `origin=records` 的只读数据源,看板照常用 `db_query__<名字>` 查,数据目录照常起草。
- **写入只经动作**:
- 新增平台内置能力 `records.append`(效果:写入;允许无人值守,因为平台自己的表、幂等键就是动作键);
- 填报登记模板的骨架是:入口 → [可选的审批] → 调用工具节点 `records.append` → 成果;
- 生效记录、幂等、审计全部沿用总方案 A5,一次提交只落一行,重放不会重复写。
- **更正不改原行**:更正是一次新的提交,带 `_supersedes`。默认查询看「当前有效」的行(没被更正过的),更正历史可查。
- **钉版本**:
- 运行开始时,在 data_versions 里记下这张表的高水位 `_seq`;
- 查询时连接上建同名的 **TEMP VIEW**(SQLite 解析不带库名的表名时,先找 temp 库),只露出 `_seq ≤ 高水位` 的行。
于是同一次运行查几遍都是同一份数,印章写「数据截至第 N 条」,按需缓存键也带上它(P8)。
- **行级可见**:
- 同一套临时视图再加一条按人过滤:`use` 只看 `_submitted_by = 自己`,`observe` 和 `manage` 看全部(拍板项);
- 过滤在查询工具的连接上做,模型和 SQL 都绕不过去。
- **看板的汇总**:看板读的是正式运行的封存结果,正式运行以服务身份跑、看到全部行,所以汇总是全量的。这是作者的选择,发布单摘要里写明「使用者能看到全部提交的汇总数」。
- **逐行列表**:用新组件 `records_list`,每次打开按看的人实时过滤(同 `instance_list`),不走封存。
- **表单变了**:
- 只加可选字段:`ALTER TABLE ADD COLUMN`,同一表版本;
- 改类型、删字段、加必填字段:出新的表版本(新文件 `<表 id>.v2.db`),旧记录照原样保留。不兼容的判定沿用 `INCOMPATIBLE_KINDS`。
- 应用版本钉住表版本;看板要跨版本汇总时,由模板生成 `UNION ALL` 的视图,列对不上的补空。
- **遮罩**:业务列可以在「填什么」卡片里标「敏感」,进数据源的遮罩列表(`mask_columns`)。
**理由**:只追加加序号,在单进程 SQLite 下就能做到可复现(P8,不可回退决定:不上 Postgres、不加节点锁);写入走动作,审批和对账都是现成的;行级可见在连接层做,模型和 SQL 都绕不过去(P4)。
**否决的做法**
- 每次提交出一个新快照(复用 table_versions):一次提交一个文件,量一大就扛不住;
- 允许原地改行:数就不可复现了;
- 行级可见交给模型在 SQL 里加 where:靠不住。
**代价**:
- 多一种数据源和一个写入服务;写入走单进程锁,和导入的锁分开;
- 高频填报(每秒多次)不是目标场景,写在 README 里。
### 2.9 N9 文件
**决定**
- **存储**:
- 按 sha256 内容寻址存,复用 `raw_store` 的做法:`data/files/<前 2 位>/<sha>`,文件只读(0444);
- 新表 `files` 记 id、sha256、原名、大小、类型、上传人、上传时间、绑定的运行或记录(可空)、用途;
- 大小限额沿用 `max_upload_mb`;类型白名单见待拍板 Q5。
- **上传两步走**:
1. `POST /api/files` 带上下文(应用 id 加入口,或者待办 id),先上传,拿回引用 `{file_id, name, size, sha256, type}`;
2. 再提交表单。表单字段 `type: object`、`x-widget: file`,值就是引用。
- **提交时绑定**:服务端核对引用属于提交人、还没被别的提交用过,然后把它绑到这次运行(一次性),引用连同 sha256 进运行输入,随封存一起固定(P8)。没绑定的上传 24 小时后清理。
- **怎么用文件**:
- **交给 agent 或报告读**:新增模板函数 `file_text(input.附件)`,运行时按类型抽文本(PDF、Word、表格、纯文本),沿用知识库上传的抽取,有长度上限,超出照实截断并标出;
- **作为一期数据导入**:平台内置能力 `table_import.new_period(source, file)`,按这个上传源已有的配方导入,效果是写入(数据变了),走动作和确认;
- **附在记录上**:记录表的文件列存引用。
- **下载**:`GET /api/files/{id}`,只给读得到绑定的那次运行、或那一行记录的人(复用 `can_read_run` 和记录的行级可见)。文件不做遮罩,界面在上传处写明「能看到这条记录的人都能下载附件」。
- **使用者**:个人应用里可以上传文件交给 agent 读(只读),不能导入。
**理由**:内容寻址加封存,证据站得住(P8);上传和提交分开,表单仍是 JSON,人工节点的表单也能用;读权限跟运行走,没有新的权限面(P4)。
**否决的做法**
- 文件放进工件库:工件库只存 JSON,按运行归属,上传时还没有运行;
- base64 塞进表单值:撑爆输入和事件流。
**代价**:多一张表、一个清理任务;抽文本的质量受文件格式影响,照实标「抽取不完整」。
### 2.10 N10 白话层
**决定**
- 新增 `app/plain.py`:问题编号 → `{title 一句话, why 为什么要紧, who: you|assistant|admin, action 怎么办}`。覆盖:
- 工作流发布门禁(`contract.*`、`governed.*`、`action.*`、`process.*`);
- 应用检查(`app.*`);
- 发布单的步骤失败。
- 没登记白话的问题照旧显示技术说法,并在检查脚本里报「有问题编号没有白话」,防止遗漏。
- 前端的发布摘要、蓝图卡片、试运行页都用白话;画布和应用编辑器保留技术说法,另加一行白话。
- `who` 的判定照这三样:自动修复的 `kind`、`APP_HUMAN_ONLY`、需要管理员的问题编号。
**理由**:P5 一种说法;P1 照实说给听得懂的人。
**否决的做法**:让模型临场解释——每次说法不同,还可能说错。
**代价**:每加一个问题编号都要写白话,检查脚本会提醒。
### 2.11 N11 生成:耗时与稳定性
**决定**
- **调用次数的预算**:
- 起草蓝图:1 次主调用,加最多 2 次查看;
- 填关节:每一组独立的关节(例如两组互不相干的指标)各 1 次调用,**并行**执行(新增,补 G12),各自最多 1 次查看;
- 其余全部是确定性的。
- **模型**:蓝图和关节都用设置里的助手模型;首次使用时提示「推荐用快速模型」。验收按真实模型记录每一步的耗时(第六部分 W4)。
- **反问收尾**(补 G6):
- 问数据:store 加 ask 分支——这一轮记成「助手反问」,问题进历史;输入框提示「回答助手的问题」;
- 画布:这一轮照样记下,问题进历史;
- 蓝图对话:问题和候选画成可点的选项。
- **问数据校验不过时**:不再整轮重来,改为把问题交回同一轮的自查修正(Checker 已有),省一次完整生成。
**理由**:普通人等不了几分钟(验收里有模型在画布写 SQL 首个操作要 486 秒);反问是蓝图的核心交互,不能丢。
**否决的做法**:一次调用生成整张图加应用——慢、不稳、难修。
**代价**:并行调用增加瞬时并发,受模型服务的限流约束,失败时退回串行。
### 2.12 和不可回退决定的对照
| 不可回退决定 | 本方案 |
|---|---|
| 正式运行只从已发布的不可变版本、按 graph_hash 发起 | 试运行和个人应用都是探索运行;发布单发布后才有正式运行;按图哈希对齐试运行和版本 |
| 口径升版必须处置 | 改取数回到蓝图,发布单要求处置,摘要用白话说明 |
| 叙述层不做算术 | 关节里的指标式只许 `cell()` 加口径卡算术;卡片上的数来自绑定 |
| 没发布的图只能跑探索 | 个人应用永远是探索;印章写明 |
| 不上 Postgres、不加节点锁 | 记录表单文件 SQLite、只追加、单进程锁;临时视图钉版本 |
---
## 第三部分 规格
### 3.1 数据模型
**新表**
| 表 | 主要字段 |
|---|---|
| `blueprints` | id、name、owner、scope(personal / shared)、status(drafting / trialed / published / personal / in_review)、spec(`agentlab.blueprint/1`)、template_id、template_version、source(conversation / template / run / workflow_version,带 id)、workflow_id、app_id、last_trial_run_id、graph(个人应用的图)、graph_hash、copied_from(审核副本指向原蓝图)、created_at、updated_at |
| `blueprint_events` | blueprint_id、seq、actor、verified、op(`bp_*`)、before、after、at——蓝图的留痕 |
| `publish_plans` | id、blueprint_id、actor、steps(每步 kind、status、result、error_code、at)、summary(白话摘要)、level、status(planned / running / stopped / done)、created_at |
| `files` | id、sha256、name、size、mime、uploaded_by、uploaded_at、context(app_id、entry 或 task_id)、bound_run_id、bound_record(表 id、`_id`)、purpose |
| `records_tables` | id、app_id、name、source_id(登记的数据源)、version(表版本)、columns(列定义 + 敏感标记)、created_at;表版本另起一行,旧版本只读保留 |
**新列**(走 `_COLUMN_MIGRATIONS`)
- `apps.scope`(默认 shared)、`apps.owner`、`apps.blueprint_id`;
- `runs.origin_kind` 新增取值 `trial`、`personal_app`(总方案 X3 的列表加两项,仍只能由服务端写);
- `tasks.kind` 新增 `review`(个人应用提交审核);
- 组件 spec 加 `hidden`(`apps/spec.py` 的组件模型,`extra=forbid` 下显式加字段)。
### 3.2 蓝图 `agentlab.blueprint/1`
```json
{
"format": "agentlab.blueprint/1",
"purpose": {"value": "每周看各门店销售额、订单数和退款率", "source": "user", "status": "confirmed"},
"template": {"id": "periodic_board", "version": 1},
"data": [
{"id": "gmv", "label": {"value": "销售额"}, "unit": {"value": "元"},
"table": {"value": {"source": "shop", "table": "orders"}, "source": "catalog", "status": "proposed"},
"measure": {"value": "sum(amount)", "source": "assistant", "status": "proposed"},
"date": {"value": {"column": "paid_at", "rule": "按支付日"}, "source": "catalog", "status": "confirmed"},
"filter": {"value": "不含已退款", "source": "user", "status": "confirmed"},
"reuse": null}
],
"cadence": {"value": {"preset": "weekly", "weekday": 1, "time": "09:00", "tz": "Asia/Shanghai", "period": "last_iso_week"}},
"people": [], "fields": [], "writeback": null,
"access": {"use": [{"kind": "group", "name": "门店"}], "observe": [], "manage": []},
"joints": {"sql_main": {"status": "filled", "by": "assistant"}, "metric_gmv": {"status": "pending"}}
}
```
- 每一项都是 `{value, source, status}`;`source` 取 user / assistant / template / catalog / caliber;`status` 取 confirmed / proposed / missing。
- `measure` 是白话或简单聚合的提议,**不是**最终表达式;最终的 SQL 和指标式在关节里,由 `TemplateApplier` 落地并经过检查。
- `reuse`:对到已发布口径卡时写 `{workflow_id, version, node_id, metric_id}`,实例化成 `caliber_from`。
**操作**:`bp_set(path, value)`、`bp_add(path, item)`、`bp_remove(path)`、`bp_ask(question, options[])`、`bp_template(id)`。路径用 JSON 指针。模型和用户共用这组操作;`BlueprintApplier` 按卡片的白名单校验(比如 `people[].policy.kind` 只能是 any / all / quorum)。
### 3.3 模板 `agentlab.template/1` 与首批五个
**模板定义**(后端模块):
```text
id, version, label, description, applies_when(给助手看的适用条件)
cards: 用到哪些卡片、各自的约束
skeleton(bp) -> GraphSpec # 带关节占位
joints: [{id, kind, node_id, field, from_card | generated, check}]
app_skeleton(bp, graph) -> agentlab.app/1 # 在 derive_app 的结果上按模板排布
schedule(bp) -> 定时任务参数 | None
tests: 每个模板至少一张完整蓝图的实例化 + 检查 + 运行
```
**首批五个**
| 模板 | 骨架 | 生成关节 | 确定性关节 | 应用页 |
|---|---|---|---|---|
| 周期看板 `periodic_board` | 入口(统计期)→ 取数(tool)→ 口径卡 → 成果(指标出具) | SQL、指标式 | 统计期字段、定时任务、出具契约的必需指标 | 印章、指标块、趋势、明细表、「按条件查看」(按需) |
| 审批流程 `approval` | 入口(表单)→ [按金额线分支] → 人工(一级或多级)→ [写回] → 已通过 / 已退回 | 无 | 表单、金额线、处理人与策略、时限与升级、写回目标 | 发起按钮、我发起的、实例进度、待办 |
| 异常处置 `exception_handling` | 定时:入口 → 取数(异常清单)→ 分支(有 / 无)→ 人工(负责人填处置说明,表格附在待办上)→ [复核] → 成果 | 异常的 SQL | 负责人、时限、复核策略 | 异常数指标、本期异常表、处置进度 |
| 填报登记 `records` | 入口(表单)→ [审批] → `records.append` → 成果;另配一张汇总看板(周期看板的骨架,查记录表) | 汇总的 SQL、指标式 | 表单与表结构、审批、行级可见 | 填报按钮、我的记录(`records_list`)、汇总看板 |
| 核对写回 `reconcile_writeback` | 定时:取数 → 口径卡(核对差异)→ 人工复核(确认要写回的)→ 写回 → 成果 | 取数的 SQL、差异的指标式 | 复核人、写回目标与字段对应、确认 | 差异指标、待复核清单、写回记录 |
- 每个模板都能被个人应用用到的部分(只读)标出来:周期看板全部、异常处置去掉人工步骤只看清单、填报登记只看汇总和「我的记录」。
- 「自由形」:从已有运行或工作流版本导入,没有关节,蓝图只有目的、谁能看、多久更新三张卡片;要改图只能回画布(搭建者)。
### 3.4 关节类型
| kind | 填什么 | 谁填 | 检查 |
|---|---|---|---|
| `sql` | 调用工具节点的 `args.sql` | 模型(look 表) | 只读守卫、SQL 猜列、数据源范围、目录检查(GraphChecker) |
| `metric_expr` | 口径卡一个指标的 `expression` | 模型 | 只许 `cell()`、口径卡算术、比较;静态追到原始数据(`engine/lineage.static_stops`) |
| `fields` | 入口或人工节点的表单 | 蓝图「填什么」 | `agentlab.form/1` 校验 |
| `assignee` / `policy` / `due` / `on_timeout` | 人工节点配置 | 蓝图「谁来做」 | 流程检查(`engine/lint_process.py`)、岗位派生 |
| `threshold` | 分支条件「字段 比较 常量」 | 蓝图 | 表达式解析器,禁止算术 |
| `capability` | 调用工具节点的 tool 和参数映射 | 蓝图「写回哪里」 | 能力登记、写入只在调用工具节点(总方案 A5)、参数按声明类型 |
| `contract` | 成果节点的出具契约(必需指标) | 蓝图「看哪些数」 | 出具契约检查(含指标出具) |
`TemplateApplier.apply(op)` 只收 `fill_joint {joint, value}`,`joint` 必须是这个模板声明的生成关节;`TemplateChecker.issues()` 跑上表的检查加骨架比对,交回模型改(最多 2 轮,沿用 `Generation` 的修正阶段)。
### 3.5 试运行解析
- `POST /api/blueprints/{id}/trial {inputs?}`:实例化 → 保存草稿图 → 发起探索运行(`origin_kind=trial`)→ 返回 run_id;结束后蓝图状态记 trialed、`last_trial_run_id`。
- `GET /api/blueprints/{id}/preview?page=…&run=…`:派生(或沿用)应用草稿,调 `resolve_page(draft=True, trial=run)`:
- 所有视图来源解析到这次运行;趋势、按需视图只给这一个点;
- 印章 `from = 试运行`、`trial = true`、notes 加「这不是正式结果」;
- 流程组件(实例进度、待办)对着这次运行;待办卡按钮写「以试运行身份…」。
- 权限:蓝图的 manage(共享)或拥有者(个人)。
### 3.6 发布单
- `POST /api/blueprints/{id}/publish-plan {level}`:只读,返回 `{steps[], summary[], problems[{code, plain, who, fix?}], ok}`。
- `POST /api/publish-plans/{plan_id}/run`:按步执行;每步结束写 `steps[i]`;某步失败时状态 stopped,返回白话原因。
- `POST /api/publish-plans/{plan_id}/resume`:修好后从停下的那一步接着做;已完成的步骤按结果里的 id 核对,不重做。
- 每一步调用现有服务函数(发布、定时任务、派生、应用发布、授权),不经 HTTP;预检阶段这些函数以「只检查」方式调用。
- 审计:发布单的开始、每步、结束各写一条(`publish_plan.*`),和各对象自己的审计互相引用。
### 3.7 记录表
- **写入服务**:`records/store.py`。
- `append(table, values, run_id, actor, action_key)`:在单进程锁内校验列、写一行、返回 `_seq` 和 `_id`;
- 同一个 `action_key` 第二次调用返回第一次的结果(幂等)。
- **能力** `records.append`:
- 内置登记;参数是 `{table, values}`;
- 效果是写入,允许无人值守,查询能力是 `records.lookup(action_key)`。
- **查询连接**:
- `data/engine.py` 打开 `origin=records` 的源时,在连接上建 TEMP VIEW:每张业务表一个同名视图;
- 视图条件是 `_seq <= 高水位`,加 `_superseded = 0`,加行级过滤;
- 高水位来自运行的 data_versions;行级过滤取自运行的发起人和这个应用的授权:正式运行以服务身份跑、不过滤;`records_list` 按看的人过滤。
- **表版本**:「填什么」卡片变化时,按 `INCOMPATIBLE_KINDS` 判定,兼容的就 ADD COLUMN,否则新建版本文件并登记一行 `records_tables`。
### 3.8 文件
- **接口**:
- `POST /api/files`(multipart;context 必填)→ 引用;
- `GET /api/files/{id}`(按绑定对象核对读权限);
- `DELETE /api/files/{id}`(只能删自己还没绑定的)。
- **控件**:`x-widget: file`,字段 `type: object`,属性 `{file_id, name, size, sha256, type}`;`accept`、`max_mb` 进 `x-file`。前后端校验同一份:`apps/forms.py`、`lib/schemaForm.ts`。
- **模板函数** `file_text(ref, max_chars=…)`:运行时抽文本,结果记进运行事件(字数、是否截断),抽取的文本按工件存,进证据。
- **清理**:调度循环每小时清一次 24 小时前上传、没有绑定的文件。
### 3.9 个人应用
- `POST /api/blueprints/{id}/save-personal`:校验图满足 `chat_policy`(节点、工具、无人工、无写入),建或更新 `scope=personal` 的 App,图存在蓝图上。
- `POST /api/apps/{id}/refresh`(个人):发起探索运行(`origin_kind=personal_app`,身份是拥有者,强加 `MASK_RESULTS`、`READ_ONLY`),受每日上限约束。
- 视图来源 `exploratory`:只在 `scope=personal` 的应用里合法(应用检查拦),解析时取最近一次成功封存的 `personal_app` 运行。
- `POST /api/apps/{id}/submit-review {to}`:开 `review` 待办;搭建者处理时调 `POST /api/blueprints/{id}/copy` 得到共享副本。
- `can_read_run` 加一条:拥有者能读自己个人应用的运行(`auth/access.py:74-96`)。
### 3.10 接口一览
| 接口 | 权限 |
|---|---|
| `POST /api/blueprints`(起点:对话 / 模板 / 运行 / 版本) | builder;个人:user + 开关 |
| `GET/PATCH /api/blueprints/{id}`(PATCH 收 `bp_*` 操作数组) | 拥有者或共享蓝图的 manage |
| `POST /api/copilot/blueprint-stream` | 同上 |
| `POST /api/copilot/fill-joints-stream` | 同上 |
| `POST /api/blueprints/{id}/trial`、`GET …/preview` | 同上 |
| `POST /api/blueprints/{id}/publish-plan`、`/api/publish-plans/{id}/run|resume` | builder(受管级再要 admin) |
| `POST /api/blueprints/{id}/save-personal`、`/api/apps/{id}/refresh`、`/submit-review` | user + 开关,拥有者 |
| `POST /api/blueprints/{id}/copy` | builder |
| `GET /api/templates` | 登录即可 |
| `GET /api/apps/{id}/candidates` | manage |
| `POST/GET/DELETE /api/files…` | 见 §3.8 |
| `GET /api/apps/{id}/records`(`records_list` 用) | use(只看自己的)/ observe |
### 3.11 事件
- 运行事件:`run.origin` 的 kind 加 trial、personal_app;`file.extracted`(文件抽文本:字数、是否截断);`records.appended` 是动作事件(`action.*`)的一种,不另起。
- 蓝图事件存 `blueprint_events`,不进运行事件。
### 3.12 权限矩阵(开登录时)
| 操作 | admin | builder | user |
|---|---|---|---|
| 建共享蓝图、试运行、发布单(已发布级) | ✓ | ✓ | ✗ |
| 发布单(受管级、降级) | ✓ | ✗ | ✗ |
| 建个人应用、刷新、提交审核 | ✓ | ✓ | 开关开着时 ✓ |
| 审核个人应用(得到共享副本) | ✓ | ✓ | ✗ |
| 看别人的个人应用内容 | ✗ | ✗ | ✗ |
| 上传文件 | 有对应入口或待办的人 | 同左 | 同左 |
| 下载文件 | 读得到绑定的运行或记录的人 | 同左 | 同左 |
| 记录表逐行查看 | 全部 | 全部(有 observe) | 只看自己的(use) |
关登录时全部放行,个人应用的开关照样生效(关着就没有入口)。
### 3.13 新增检查
- **蓝图**(`bp.*`,蓝图卡片和发布单预检里显示):
- `bp.missing`:缺必填卡片项;
- `bp.unconfirmed`:待确认项未确认就发布;
- `bp.data_unmapped`:看哪些数没对到表或口径;
- `bp.people_unbound`:岗位没有人;
- `bp.reconciler_unbound`:写回的能力没登记查询,结果不明时没人核实(同上,只挡发布;发布单上应用检查说同一件事的 `app.reconciler_unbound` 和核实岗位的 `app.slot_unbound` 不再重复列);
- `bp.template_mismatch`:卡片内容超出模板能力。
- **模板**(`tpl.*`):
- `tpl.skeleton_changed`:实例化的图改了骨架;
- `tpl.joint_unfilled`:生成关节没填;
- `tpl.joint_refused`:模型想改非关节的位置。
- **应用**:
- `app.view_source_personal`:`exploratory` 来源出现在共享应用里,error;
- `app.hidden_bound`:被隐藏组件是唯一绑定某个视图的组件,warning。
- `app.records_rows_exposed`:看板的表格绑定的查询直接列出记录表的逐行(没有聚合),warning。正式运行看得到全部行,这样等于所有使用者都能看到别人的记录,绕过了行级可见;逐行查看请用 `records_list`。
- **个人应用**:`personal.policy`,图里有使用者不能用的节点或工具(沿用 `chat_graph_problem` 的说法)。
- **白话**:以上全部登记进 `app/plain.py`。
---
## 第四部分 界面
### 4.1 新建应用
- **入口**:应用首页右上「新建应用」。使用者在开关开着时也有这个入口,只能建个人应用。
- **三个起点卡片**:
- 说说你要什么:大输入框加例句;
- 从场景模板开始:五张模板卡,每张写适用场景、会产出什么、要准备什么;
- 从已有的开始:最近的问数据回答,加上已发布的工作流(后者只给搭建者)。
- **问数据的「做成应用」按钮**:改为「做成应用(蓝图)」,直接带着这次运行进蓝图,不再跳画布发布框。
### 4.2 蓝图页
- **左侧**:对话。助手的反问画成可点的选项,「这一轮在等你回答」常显。
- **右侧**:卡片列,每张卡片头上标状态(已确认、待确认、缺)。卡片里每一项都可以点开改,改完标来源「你」。
- **对到数据的项**:显示对到的表、中文名、粒度、业务日期,带一句「为什么对到这张表」,来自 look 和目录。
- **底部**:「用真数试一次」(缺项时置灰,写清缺什么);试过之后才有「发布」(搭建者)、「存成我的应用」(使用者)。
### 4.3 试运行页
- 就是应用的样子,顶部横幅写「试运行:数据是真的,但这不是正式结果」,配试运行的输入和「换一期再试」。
- 流程类:右侧列出试运行里的待办,自己点同意或驳回;写入步骤标「测试环境」或「演练」。
- 出错时用白话说哪一步、为什么,加「交给助手修」或「回到卡片改」。
### 4.4 发布摘要
- 一页:每一步一句白话。问题分三栏:你来定、助手可以修、要找管理员。
- 确认按钮写清结果,例如「发布并每周一 09:00 自动出数」。
- 执行时逐步打勾;停下时写在哪一步、为什么、修好后「接着做」。
### 4.5 页面上直接改
- 应用页右上「编辑」(manage)。
- 编辑状态下,组件悬停描边,点中出小工具条:改文字、隐藏、上移、下移、删除。
- 页底「加一张数据卡片」,弹出候选列表,按口径卡、报告、明细、趋势分组。
- 顶部「改取数」回到蓝图。
- 改动自动存草稿,顶部写「有未发布的改动」和「发布」。
### 4.6 个人应用
- 应用首页「我的应用」分组,卡片角标「个人」。
- 印章写「探索 · 未经发布审核」,顶部有「刷新」(写明今天还剩几次)和「提交审核」。
- 审核中写状态;审核发布后写「已有正式版本:去看看」。
### 4.7 新组件和控件
- **文件控件**:拖放或点选;显示文件名、大小、上传进度、类型和大小限制;上传失败照实说。
- **`records_list`**:列表、筛选、按时间排序;每行可以展开看全部字段和附件;被更正过的行显示「已更正」,可以展开看历史;使用者只看自己的,标题写「我的记录」。
### 4.8 文案与检查脚本
- **文案**:`termsApp.ts` 新增蓝图、模板、试运行、发布单、个人应用、记录、文件各组说法;后端白话层 `app/plain.py`。
- **新检查脚本**:
- `check-blueprint`:起点、卡片、反问、试运行、发布摘要;
- `check-personal`:使用者建、刷新、提交审核;
- `check-records`:填报、行级可见、更正、表版本;
- `check-files`:上传、绑定、下载权限。
- **要更新的**:`check-apps`(编辑状态、隐藏、候选)、`check-chat`(反问收尾、做成应用改道)、`check-studio`(反问记录)。
- **检查脚本的新约束**:每个问题编号都要有白话。
---
## 第五部分 实施组织(供下次审批,这一步不执行)
- **分支**:从最新 main 开集成分支 `feat/normal-user-apps`,所有包在同一分支、同一工作区。
- **第 0 步契约骨架**:编排者一次提交,包括:
- 新表和新列、`agentlab.blueprint/1` 和 `agentlab.template/1` 的 pydantic 模型;
- 关节类型和操作的白名单、`app/plain.py` 的框架;
- 新路由的桩(501)、`records.append` 和 `table_import.new_period` 的能力登记;
- 前端类型和接口桩、占位路由。
之后冻结热点文件:`db/models*.py`、`db/base.py`、`core/events.py`、`typesApp.ts`。
- **一个 workflow,六个包**,每包一个完整代理,然后一个统一复核、一个集中修复:
| 包 | 范围 |
|---|---|
| B1 蓝图与模板(后端) | Blueprint 对象和操作、模板登记和五个模板、实例化和骨架比对、`BlueprintApplier`/`TemplateApplier` 与 Checker、两个生成入口、look 新类别 `caliber`、并行填关节 |
| B2 试运行与发布单(后端) | 试运行解析(`trial` 旁路)、派生自图、发布单的预检 / 执行 / 续做、白话层的内容、反问收尾的服务端部分 |
| B3 记录表与文件(后端) | 记录表存储与连接层的临时视图、`records.append`、表版本、`records_list` 解析、文件存储 / 接口 / 绑定 / 清理、`file_text`、`table_import.new_period` |
| B4 个人应用(后端) | scope 与拥有者、`exploratory` 视图来源与印章、刷新与上限、提交审核与副本、`can_read_run`、开关 |
| F5 创建链路(前端) | 新建应用三个起点、蓝图页、试运行页、发布摘要、问数据「做成应用」改道、反问在问数据和画布的收尾 |
| F6 页面编辑与新组件(前端) | 编辑层、隐藏、候选、文件控件、`records_list`、个人应用的界面 |
- **合并顺序**:B1 → B2 → B3 → B4 → F5 → F6,编排者做集成修正。
- **验证分层**(沿用):代理只跑相关测试和 tsc、check-copy;完整关口由编排者在合并后跑一次。
- **真实模型验收和端到端验收**:都在关口之后跑。
- **合入 main 之前**:先问用户。
---
## 第六部分 验证与验收
- **W1 看板(搭建者,从一句话开始)**:「每周一看各门店上周销售额、订单数、退款率,不含退款单」。
- 反问一次(按下单日还是支付日);
- 卡片对到正确的表和业务日期;
- 试运行出真数,点数字追到单元格;
- 发布摘要一页,确认后 9 步全部完成,应用打开就有数;
- 从说完需求到看到试运行结果,记录用时和模型调用次数。
- **W2 审批(搭建者,从模板开始)**:报销审批,金额线 5000,财务组全员同意,48 小时升级给总监,通过后写到假外部系统。
- 试运行里自己点完审批,写入走测试环境;
- 发布后真实走一单;
- 中途故意让岗位没绑人:发布单停在预检,白话说「谁来做」缺人。
- **W3 填报登记**:门店每日报损。
- 两位使用者各提交几条,彼此看不到对方的记录;观察者看全部;汇总看板是全量的;
- 更正一条:原行保留,汇总按更正后的算;
- 改表单:加一个可选字段不出新版本;把数字字段改成文字,出 v2,旧记录还在。
- 同一次运行查两遍,数一样(高水位)。
- **W4 真实模型**:本机助手模型、deepseek-v4.1-flash、glm-5.3,跑 W1、W2 的蓝图和关节,记录:
- 调用次数、查看次数、首个操作时间、总用时;
- 反问是否发生、关节检查的修正轮数。
目标:
- 起草蓝图 ≤ 3 次调用;
- 填关节在快速模型上一分钟内完成;
- 卡片和关节出错时,用户能看懂、能改。
- **W5 个人应用(使用者)**:
- 开关打开后,使用者从问数据的一次回答建「我的退款周报」;
- 刷新受每日上限约束;印章写「探索」;结果遮罩;
- 提交审核后,搭建者在副本上发布,提交人收到通知;
- 管理员看不到个人应用的内容。
- **W6 文件**:
- 报销单附发票 PDF,agent 读出金额;
- 门店上传本周报表 Excel,作为一期导入(需确认);
- 不能读那次运行的人下载 403;没绑定的上传 24 小时后被清理(用缩短的时间测)。
- **关口**:后端全量 pytest、tsc、check-copy、泄露扫描、`check-all --stacks 2`(含新增的四个检查)、check-auth;真浏览器端到端按 W1–W6 截图复核。
---
## 第七部分 风险与对策
| 风险 | 对策 |
|---|---|
| 蓝图对错数据(白话对到错的表) | 对到的项默认「待确认」,卡片上写为什么对到这张表;试运行看真数;数据目录越完整越准,卡片上提示补目录 |
| 模型慢或不稳 | 关节少、并行、确定性兜底(P3);验收按真实模型记录耗时 |
| 模板覆盖不了需求 | 「自由形」加搭建者回画布;模板登记可扩展;收集落在模板外的需求 |
| 发布单中途失败 | 步骤顺序让失败无害;可续做;摘要照实说停在哪 |
| 记录表并发写 | 单进程锁、事务短;README 写明不面向高频写入 |
| 行级可见被绕过 | 在连接层建临时视图,模型和 SQL 都看不到底表;测试覆盖直接写 `main.<表>` 的 SQL 被只读守卫拒 |
| 看板把记录逐行列给所有人 | 正式运行看全部行是设计如此;应用检查对「逐行列出记录表」的表格给警告(`app.records_rows_exposed`),发布摘要写明使用者能看到什么 |
| 个人应用变成「影子正式应用」 | 永远探索、印章写明、不能分享;共享只能经审核发布 |
| 文件泄露 | 下载按绑定对象核对读权限;界面写明谁能下载;不做公开链接 |
| 白话说错 | 白话层只有一份,按编号映射,检查脚本防遗漏 |
---
## 第八部分 待拍板
| # | 问题 | 推荐 |
|---|---|---|
| Q1 | 记录表的存储 | 平台托管 SQLite、只追加、序号高水位钉版本、连接层临时视图做行级可见(N8) |
| Q2 | 填报提交走不走工作流 | 走:模板里 `records.append` 写入节点,可以加审批,沿用动作的幂等和对账 |
| Q3 | 使用者建个人应用的开关 | 管理员开关,默认关(同问数据) |
| Q4 | 个人应用的刷新 | 只手动刷新,加每日上限;不做定时自动刷新(自动刷新等于给使用者开定时任务) |
| Q5 | 文件的类型和大小 | 白名单:PDF、Word、Excel、CSV、图片(PNG/JPG)、纯文本;大小沿用 `max_upload_mb`(默认 50MB);未绑定的 24 小时清理 |
| Q6 | 首批模板 | 五个:周期看板、审批流程、异常处置、填报登记、核对写回,外加自由形 |
| Q7 | 流程类试运行的审批 | 派给试运行的人自己点;写入走测试环境或演练;不触发超时升级 |
| Q8 | 发布单里「跑第一次」 | 看板类默认勾选,流程类不跑 |
---
## 附录 A 代码位置索引(main@69c5c74)
| 位置 | 内容 |
|---|---|
| `backend/app/api/copilot_gen.py:567` | Generation;`:589` 起草、`:593` 修正、`:38` 修正轮数 |
| `backend/app/look/base.py:22-27,87-110` | 查看上限;Applied / Applier / Checker 协议 |
| `backend/app/api/copilot.py:1679` | `/generate-stream`;`:1744` 只问不改的一轮;`:2364` `/app-stream`;`:2468-2550` from-run 与来源记录 |
| `backend/app/apps/ops.py:264` | `polish_problem`(护栏的写法) |
| `backend/app/engine/autofix.py:1240` | `forbidden_changes`(骨架比对的写法) |
| `backend/app/apps/derive.py:85-90` | 派生时视图绑定时任务 |
| `backend/app/apps/resolve.py:82-108,454-476,541-546,203-221,813` | 最近封存的规则;草稿解析;按运行解析;印章来源;趋势无钉住项报错 |
| `backend/app/apps/spec.py:35-42,47,82,97-107` | 表单控件、视图来源、组件模型 |
| `backend/app/engine/launch.py:95-176` | 运行类别的判定 |
| `backend/app/auth/chat_policy.py:25-73` | 使用者能用的节点和工具 |
| `backend/app/api/runs.py:108-123` | 使用者运行的限制 |
| `backend/app/auth/access.py:74-96` | `can_read_run` |
| `backend/app/data/catalog.py:12-21` | 数据目录项的形状 |
| `backend/app/data/raw_store.py:33-43` | 按哈希存原件 |
| `backend/app/data/table_versions.py:1-33,344-360` | 表的版本模型和存放 |
| `backend/app/data/recipe_accumulate.py:38-41` | 不兼容的列变化 |
| `backend/app/engine/runner.py:242-262,1013-1045` | data_versions 的固定;封存 |
| `backend/app/api/workflows.py:427-503` | 发布 |
| `backend/app/api/schedules.py:461`,`scheduler/service.py:39-56` | 建定时任务;模板变量 |
| `backend/app/api/apps.py:186,203,251,370,474,273` | 建应用、派生、草稿、发布、授权、归档 |
| `frontend/src/store/chat.ts:1241,1433` | 问数据整轮重来;没有反问分支 |
| `frontend/src/apps/ViewRenderer.tsx:75-84` | 组件包在 `data-node` 里 |
| `frontend/src/pages/ChatPage.tsx:479-523` | 问数据的「做成应用」 |
## 附录 B 术语
- **蓝图**:用户的需求被整理成的一组白话卡片,是生成工作流和应用的依据。
- **卡片**:蓝图里用户能判断对错的一件事(看哪些数、谁来做……)。
- **场景模板**:一类应用的骨架,带可填的关节。
- **关节**:模板骨架里允许填的位置;确定性关节按卡片填,生成关节由助手填。
- **试运行**:跑一次还没发布的图,用真数看应用的样子;不是正式结果。
- **发布单**:一次确认发布的编排,逐步执行、可续做。
- **个人应用**:使用者自己建、只有自己看得见的应用,永远是探索。
- **记录表**:平台托管、只许追加的表,填报登记的每次提交是一行。
- **高水位**:一次运行开始时记录表的最大序号,这次运行只看到这之前的行。
- **白话层**:问题编号到白话说法的唯一映射。
@@ -0,0 +1,82 @@
# 编排完善:问数据整轮服务端化、子工作流证据回传、驳回出口、数据事件触发、多进程与 PostgreSQL
状态:2026-10-18 用户已拍板(多进程做到「多进程 + 换 PostgreSQL」;触发接「上传表格出新一期」「上游工作流跑完」「数据源有要处理的变化」,不接「口径发布」;问数据掉线「整轮放到服务端跑」)。其余决定按本文推荐。
## 0. 调查结论:问数据为什么会因为前端掉线而失败
一轮问数据(建图 → 发起运行 → 复核 → 按复核重来、落库)是浏览器在推(frontend/src/store/chat.ts 的 plan / launch / settle):
- 建图是 `POST /api/copilot/generate-stream` 一条和页面绑在一起的流。网络一抖(`fetch` 抛 TypeError),前端 `onEnd(NETWORK_MESSAGE)` → `fail()`,这一轮记 error;服务端那边 Starlette 1.6 的 StreamingResponse 在客户端断开时取消生成任务,建了一半的图丢掉。代码注释原话:「建图跟着页面那条流走,页面一关就停」。只能整轮重试,已花的建图费用白花。
- 发起运行是页面发的 `POST /api/runs`:断网时直接失败,要手点「重新运行本轮」。
- 运行本身在服务端,页面的 WebSocket 断了会重连回放,这一段不怕掉线。
- 运行结束后的复核(`/copilot/review`)、按复核重来、落库(`PATCH /conversations/{id}/turns/{turn}`)都是页面发起的:页面不在,这一轮就没人复核、不会重来,库里停在 running,15 分钟(BUILD_STALE)后才被判成断了。
根因:问数据一轮的编排放在浏览器里(细案 01 D1 当时的选项 C「整轮编排搬到后端」,判断「方向对,但要重做重连、断点、取消,应单独立项」)。评测已经有一份在服务端跑完整一轮的驱动(app/eval/answer.Driver),问数据没用它。
## 1. 问数据整轮放到服务端(W1-a)
- **一轮是一个后台任务**:`POST /api/chat/turns {conversation_id, question, datasource_ids?, retry_of?, mode?}` → 202 `{turn_id}`。服务端用和评测**同一个驱动**跑:answer_ops → chat/turn.next_step → launch_run(origin=chat)→ 等终态(停在审批的照旧停着等人,不取消——这是和评测唯一的不同,用参数区分)→ 复核 → 按 retry 规则重来。评测、问数据各自的差异写成驱动的参数,不复制流程(总方案上一轮 X「一种说法」)。
- **事件日志**:每一步(助手的操作、心跳、look、final、usage、运行 id、复核结论、重来、终局)按 seq 写进这一轮的事件日志(新表,或复用 run_events 的写法;不进运行的封存范围)。`GET /api/chat/turns/{id}/stream?after=<seq>` 先回放、再推实时(SSE,带心跳);断了按 last seq 重连;刷新、关掉再打开都能接着看。`GET /api/chat/turns/{id}` 给当前状态。
- **停止**:`POST /api/chat/turns/{id}/cancel`(取消生成 / 运行,照现有取消规则)。
- **落库**:conversation_turns 那一行由服务端写(状态、运行 id、答案、复核、meta.usage),前端不再写这一轮的结局;老的 PATCH 接口保留给别的用途(改标题等),不再被这一轮依赖。
- **前端**:store/chat.ts 的 plan / launch / settle / retry 改成「发起 + 播放」:收到的操作照现在的渲染逻辑画(画布边收边长、过程步骤、答案、复核说明);断线自动重连、按 seq 补齐;界面上的「重试本轮」「重新运行本轮」「停止」都走新接口。问数据页的显示不变。
- **服务重启**:还在建图的这一轮标中断(照实说,可「重试本轮」);已经发起运行的,重启后接着等这次运行、照常复核收尾(运行本身的中断规则不变)。第 2 波有了租约后,别的进程也能接手。
- **单进程期间**:任务用 core/job_runtime;并发上限归入「交互」类(第 2 波的优先级)。
- 用户(user 角色)的问数据权限、遮罩、只读规则一字不变(这些在 launch_run 和 answer_ops 里)。
## 2. 子工作流的证据交回父图(W1-b)
现状:子图共享同一个 run 的事件流(节点 id 加前缀 `sub/x`),但只把出口的值交回父图(engine/nodes/multi.run_subgraph 的 `output`),子图里的查询快照、口径卡留在子图自己的状态里。父图报告引用不到子图里的数(ledger/compose.py 写明了这一点,口径账本为此改用内联实例化)。
- **出处随值走**:子图出口的每个字段,按出口模板解析出它的来源(cell(子图里的查询…) / 子图口径卡的指标 / 运行输入 / 别的),子图节点的产出带 `provenance {字段: 出处}`;父图里引用 `nodes.<子图节点>.output.<字段>` 的口径卡、报告,经 engine/lineage 跨子图往下追(同上一轮「转抄口径卡指标跨卡追」的机制:运行时、证据面板、静态检查三处同一套,限深度)。
- **报告能直接引用子图里的查询和口径卡**:报告撰写的目录(build_catalog)收进子图里的查询快照和口径卡(带前缀的节点 id,别名照常编号),引用标记、证据面板一路点到子图里的那一格;封存、可验证报告照常(事件本来就在同一个运行里)。
- **治理**:受管级里子工作流必须钉已发布未退役的版本(现有 lint);子图里追不到原始数据的,父图照样降档;子图的遮罩、只读规则照父图的运行。
- 口径账本的组合(use_caliber)继续用内联实例化,不改;但召回 / 比对按出处能认出子图里的查询(反查用得上)。
## 3. 驳回出口(W1-b)
现状:审批、补充输入、编辑稿件、动作确认、代码 / 工具审批、双盲裁决被驳回时抛 NodeRejected,运行记 cancelled + end_reason=rejected,节点照旧发 node.failed(reason=rejected)。没有「驳回之后走另一条路」。
- 这几类节点在画布上多一个可选的「驳回」出口(sourceHandle="rejected",和分支节点同一套连线机制)。接了它:驳回(含超时驳回 expired_reject)时节点**正常结束**,产出 `{rejected: true, decision, actor, note, kind}`,运行沿驳回那条边往下走;节点事件记「被驳回,走驳回出口」(node.finished 带 outcome=rejected,时间线、画布、航迹照「被驳回」画,颜色用中性色),运行照它的路径结束(成功结束也照实说「途经驳回」)。没接:行为一字不变(cancelled + rejected)。
- 动作确认被驳回走出口时:照旧不发送写入、记「未确认(被驳回)」;下游可以接「通知」「退回修改」等。
- 发布检查:驳回出口那条路要能走到出口或结束;受管级里驳回路径上的出具照常判档(契约照常核对)。应用:实例页、待办、通知照实写「被驳回,转到 X」。
- 「失败重试」「出错时继续」照旧不管驳回(驳回不是故障,X9 不变)。
## 4. 数据事件触发(W1-c)
一套定时机制(X4):Schedule 加触发方式。
- `trigger_kind`:cron(缺省,行为一字不变)| event。event 时 `trigger`:
- `table.version`:某张上传表格启用了新版本(按期累积的新一期,data/table_versions 启用那一下);可选只认某个期次规则;
- `workflow.finished`:某个工作流的正式运行成功结束(可选:只认某个应用 / 某种来源);B 的输入模板可引用 A 的出口字段;
- `source.changed`:某个数据源出现「要处理」级的变化(按源去抖:同一次探查 / 剖析产生的一批只触发一次)。
- 目标只能是工作流(target_kind=workflow);运行来源仍记 origin_kind=schedule,origin 里带 `trigger {kind, event, ...}`,界面写「由事件触发:…」。
- 输入模板变量:`{{ event.* }}`(table、version、period、activated_at;workflow_id、run_id、outputs.<字段>;source、counts)。
- 去抖与重叠:同一触发在 `debounce_s`(缺省 60 秒)内的多次事件合成一次;上一次还没结束照 overlap=skip 跳过(照实记 skipped_overlap)。
- 防环:保存时检查 workflow.finished 的串联不成环(A→B→A 拒收);运行时按 origin 里的触发链长度兜底(> 5 层拒发,照实记)。
- 事件送达:事件在发生处(启用表格版本、运行收尾、变化记录)写一条「待派发事件」(库里一张表),调度循环一拍取走、按触发条件派发、写 ScheduleFire(kind=event,inputs 带事件内容)——不在发生处直接发起运行(事务边界、重复与重启都好说清),也为第 2 波多进程做准备。AGENTLAB_SCHEDULER 关着时不派发(照旧:拷来的数据目录不替人跑)。
- 界面:定时页新建时选「定时 / 事件触发」,事件触发选事件种类和对象;列表、详情、触发历史照实显示;Webhook 通知照旧可配。
## 5. 多进程与 PostgreSQL(第 2 波:W2-a 数据库,W2-b 协调)
### W2-a 数据库可移植 + PostgreSQL
- `AGENTLAB_DATABASE_URL`:缺省 SQLite(单机、单进程照旧);`postgresql+asyncpg://…` 时用 PostgreSQL。
- 审计全部原生 SQL 和 SQLite 专用写法(json_extract、strftime、datetime('now')、INSERT OR REPLACE / ON CONFLICT、PRAGMA、按 rowid、LIKE 大小写、布尔与 JSON 列、时区),改成 SQLAlchemy 表达式或按方言分支;两种库都过全量测试(PostgreSQL 用本机 docker 的 postgres:16 起一个测试库,脚本化)。
- 迁移:现有 add-only 的列 / 索引迁移按方言生效;新增一个从 SQLite 数据目录导入到 PostgreSQL 的命令(`python -m app.db copy-to-postgres`),幂等、可核对行数。
- LangGraph 断点:PostgreSQL 时用官方 `langgraph-checkpoint-postgres`(要装 psycopg 3 / psycopg_pool,写进 pyproject 和 requirements.lock);SQLite 时照旧。
- 数据目录(工件、上传表格的版本库、知识库索引、沙箱)仍是文件:多进程同机直接共用;多台机器要把数据目录放在共享存储上(写进 README,写文件一律先写临时文件再改名)。本轮不做对象存储。
### W2-b 多进程协调
- **租约**:运行、后台任务(探查、起草、剖析、评测、影子起草、问数据一轮、召回、补算、维护)在库里认领:owner、lease_until、心跳;进程死了租约过期,别的进程按各自的规则接手或照实标中断(运行仍是「标中断、人接着跑」,不自动重跑有副作用的步骤)。
- **优先级与并发**:运行分类(交互:问数据、画布试跑;应用:入口、按需视图、钩子、接口;定时;系统:评测、召回、对账、补算、出具),每类有全站并发上限和优先级,按库里的计数认领;评测不再挤占交互。
- **调度循环**:只有一个进程当班(库里的租约),其余待命;定时认领本来就是条件 UPDATE。
- **实时流跨进程**:运行事件已落库;订阅方不在跑这次运行的进程时,从库里按 seq 追(PostgreSQL 用 LISTEN/NOTIFY 唤醒,SQLite 用短轮询);问数据一轮的事件同。
- **进程内的账和缓存**:每日花费账(裁判、核查、评测、按需视图、口径出具上限)、访问密钥的每分钟限流、按需探查锁、设置缓存、向量模型缓存、目录缓存等,改成库里的条件 UPDATE / 版本号失效;逐个列清单核对。
- **验收**:PostgreSQL 上起两个工作进程跑端到端:运行被任一进程认领、另一个进程上的页面收得到实时事件、定时只触发一次、评测和问数据并发时优先级生效、杀掉一个进程后租约过期被接手或照实标中断。
## 6. 分波与关口
- 第 1 波(并行三包):W1-a 问数据整轮服务端化;W1-b 子工作流证据回传 + 驳回出口;W1-c 数据事件触发。骨架先由编排者加好:Schedule 的 trigger_kind / trigger 两列与迁移。
- 第 2 波(顺序两包):W2-a 数据库可移植 + PostgreSQL;W2-b 多进程协调(建在 W2-a 之上)。
- 每包一个完整代理,统一复核一个代理,修复交回原代理(不另起代理)。关口:后端全量(SQLite;第 2 波另加 PostgreSQL 全量)、tsc、check-copy、相关浏览器检查、泄露扫描;第 2 波另加多进程端到端。
- 合 main 前问用户(本轮没有预先放行合 main)。