Archived
Compare commits
88
Commits
e1d8d86b16
...
1373eb4980
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1373eb4980 | ||
|
|
f0789a0f13 | ||
|
|
18de7653bd | ||
|
|
64fe4b43c9 | ||
|
|
0f40bae0d1 | ||
|
|
e38631224a | ||
|
|
4076621e04 | ||
|
|
45cec82984 | ||
|
|
9f5b1c99d3 | ||
|
|
764dab342d | ||
|
|
9e2f7387b2 | ||
|
|
8586f7dac8 | ||
|
|
d420284887 | ||
|
|
6636abfc39 | ||
|
|
67fa226380 | ||
|
|
832c662fdb | ||
|
|
57870329e6 | ||
|
|
d9bcce9516 | ||
|
|
a408542bed | ||
|
|
882f22a01e | ||
|
|
716f17ee2a | ||
|
|
77df79d6fb | ||
|
|
4bd1a04511 | ||
|
|
04675e814d | ||
|
|
59afcbfdbd | ||
|
|
10f529d03c | ||
|
|
abe92e0531 | ||
|
|
40daa6c9f7 | ||
|
|
fdcd1f88a7 | ||
|
|
c60a2adf43 | ||
|
|
b2159022bc | ||
|
|
392d085f51 | ||
|
|
725f1ecb0c | ||
|
|
cd88d0e026 | ||
|
|
8c125042ba | ||
|
|
1c6978818c | ||
|
|
89f0be8b81 | ||
|
|
60955171da | ||
|
|
18d7147ebd | ||
|
|
02e0d1206d | ||
|
|
defa048270 | ||
|
|
ab0dfd8b49 | ||
|
|
f22402e155 | ||
|
|
f00ed5c011 | ||
|
|
d244864317 | ||
|
|
7b8d3b559b | ||
|
|
94b664bc85 | ||
|
|
65a4c7a7dd | ||
|
|
c3fed573ee | ||
|
|
c60c882f06 | ||
|
|
642bdcef3b | ||
|
|
588d5c2dd4 | ||
|
|
2d20202b1d | ||
|
|
647b86753a | ||
|
|
a09acc7504 | ||
|
|
198d12af1b | ||
|
|
6212b34e39 | ||
|
|
3190392a1d | ||
|
|
fcf1822233 | ||
|
|
bd26756da9 | ||
|
|
6cbac3220c | ||
|
|
dba6071772 | ||
|
|
2bb974a10d | ||
|
|
fc48920c32 | ||
|
|
154b222860 | ||
|
|
09885c3dbd | ||
|
|
35ef0f1790 | ||
|
|
6f721ffc6d | ||
|
|
81fb8179c2 | ||
|
|
9ab2f4b695 | ||
|
|
3636496774 | ||
|
|
f59e237035 | ||
|
|
343a281af5 | ||
|
|
80e9b01f84 | ||
|
|
fbd5867206 | ||
|
|
e9da8d25f5 | ||
|
|
fe8de5319c | ||
|
|
b0a3cc3a55 | ||
|
|
58ff4f1b90 | ||
|
|
f950a4419d | ||
|
|
fc8b3c504b | ||
|
|
16fe8ba71b | ||
|
|
04234d2fda | ||
|
|
b9951483bc | ||
|
|
2d1a2ff442 | ||
|
|
91840067d4 | ||
|
|
a370616f01 | ||
|
|
2cef6f7489 |
@@ -1607,6 +1607,33 @@
|
||||
比对依据可见,模型照它写影响、不自己重新解释值;契约描述与 S3 / P-08 剧本明写读法(示例用中性数 1~10,
|
||||
不用真数据里的 4.5~40 —— 模型会把示例当这单的值)。W05 升版装上。3 测(11+7 种写法)。
|
||||
|
||||
- **AI 把原 PN 的规格书填到了候选料号头上:人填的表要声明,判据不摆给还欠卡的那一步**(2026-08-28
|
||||
柴浩实测单 6ddcecf8,连撞五拍熔断):P-10 资料记录的标题 / 版本 / 链接与台账里 `LM2596S-5.0` 那一行**逐字相同**
|
||||
—— 不是幻觉,是「手边有一行看着对的就拿来用」(那一步 reads 摊开了 `parts.document`,视野里唯一一份真资料
|
||||
就是原 PN 的)。三层合谋:①把 P-08 的回卡认成 P-10 的(P-10 的卡从没发过);②我 08-27 给这一步加的 doneWhen
|
||||
把「资料必填不缺」摆成目标、后面还跟「成立了就 finish」,**与同一份提示词里「唯一合法的动作是先发那一张卡」
|
||||
直接打架**;③它顺手把 `exists_status` 改成 created,按剧本发卡名单自己空了 —— 一个合法动作都不剩。
|
||||
止血两刀:**①`PlanStep.humanWrites`**(契约)—— 声明的台账 `write` 当场拒并指路「去发那张卡,值由人填」,
|
||||
`drop` 不拦(写错了得撤得掉);判法认点名的项、点不出退回当下在办的那一步(压拍时骨架写在上一步名下正好放行)。
|
||||
**②还欠一张卡的那一步不摆 doneWhen** —— 判据同时是摆在模型眼前的目标,而它此刻只许发卡。
|
||||
W05@1.6.5 声明 `humanWrites: ['parts.document']` 并从 writes 去掉。4 测(墙 / 放行 / drop / 声明)。
|
||||
**一个闲着的模型 + 一个它够得着的判据 = 它一定会去凑那个判据。**
|
||||
用户同时点出更大的一条(记 debt,未做):**存在性核对 + 派数据工单是确定性流程,根本不该交给模型** ——
|
||||
平台缺「确定性步骤」(纯工具只读、判定表不写台账、doneWhen 只收步),所以只能写进 guide;
|
||||
引擎三样能力都有(taskView / draftWrite / issueExecution),缺的只是声明面。
|
||||
|
||||
- **确定性步骤已出设计,未动代码**(2026-08-28,ADR-014):盘完 W05 九步 ——
|
||||
**三步整个确定**(S0_bind / S0d_p10 / S5)、四步八成确定、只有 S3 是 agent 主场;一单十来拍里七八拍在做确定的活。
|
||||
agent 真正不可替代的只有四类:自然语言→结构化 · 读人的意见落到具体行 · 写给人看的话 · 非结构化抽取(实跑中一次没发生)。
|
||||
设计要点:**声明式配方进 manifest,不执行包代码**(ADR-004「不执行代码即知包为何物」;artifact 至今零执行,
|
||||
不为这个功能开沙箱面);三个动词 `project` / `card` / `say`,条件复用 S-FEEL、聚合复用基座读数,不发明第二套求值;
|
||||
`auto` 是「问模型之前先把确定的事做了」,作用域到交出控制权为止;**一步要么确定要么不是**(判断点先切出去,
|
||||
不做「一半 auto」);出错交人不重试不退回模型;静态审把地址/列名/S-FEEL 在装的时候审死。
|
||||
**回溯验算:已记录的八条事故落在会被 auto 掉的三步半上**(编号抄别单 / 判在库判错 / 派给在库的料 /
|
||||
没写骨架就发卡 / 清单漏列 / 库里有值当缺口 / 抄别的料的资料 / 撞墙五拍熔断)。
|
||||
立场要改口:**agent 只在真需要判断的地方出现,与「人只在责任点与放行点出现」是同一条原则。**
|
||||
五条待定见 ADR §11(决策卡给不给配方 / 工具串接 / humanWrites 细到列 / S2 与比对工具的 join 重复 / T_release 第三条裁量该删)。
|
||||
|
||||
- **运营中心活动详情沙箱换成「关键台账数据」看板,数据接真台账**(2026-08-27 晚,用户定:只摆最近一次已放行
|
||||
实例、替换掉全流程分支图):模版搬自 `docs/ss02-act-05/ACT-WORK-05-关键台账数据.html`(P-01 KV / P-08 逐颗 +
|
||||
参数瓷贴 / P-11 主记录 + 差异明细 / P-10 / P-06 本单 + 同料号历史),徽标文案全由 `data-class-of` 投影。
|
||||
@@ -1619,6 +1646,86 @@
|
||||
service 3 + store 1 + 包内 4;真库渲染 `v-ready`、INS-21 / `W05-20260827215148`。欠账见 debt(挑单 / 行级来源 /
|
||||
`vtok` 三份 / `data` 装包静态审)。
|
||||
|
||||
- **确定性步骤已落地**(2026-08-29,ADR-014):`PlanStep.auto` 声明式配方,五个动词
|
||||
(`project` / `analyze` / `card` / `submit` / `say`)、四种地址、嵌套两层封顶;引擎在拍首跑,
|
||||
**发了卡就收拍(零 token)**,有 `guide` 的照常问模型。`analyze` 是**一次结构化 AI 调用** ——
|
||||
系统组装入参、声明出参 schema、系统落表,**不给工具不给记忆不多轮**。
|
||||
三条纪律进了规范 §4.6:**行由确定的那一侧决定不由 AI 决定** / 判据不许编数不出来的数 /
|
||||
每一笔之前刷读数。静态审在装包时把地址、列名、模型名、视图、submit 的验收清单逐条核死。
|
||||
配套四件:`data-options`(下拉选项从台账现读)· 件上保存后 `wake`(「保存即铺格」)·
|
||||
审核卡放宽到单选项 · 卡答复即收步。**E04 / I01 / W05 一个字没改** —— 它们的剧本里没有 `auto`。
|
||||
- **ACT-WORK-05 从『什么都丢给 AI』重写成『系统确定性取数 → AI 只在真需要判断处出场一次』**
|
||||
(2026-08-29/30,`part-substitution-assessment@2.1.6`,design spec
|
||||
`docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`,用户原话立场:「不是把
|
||||
所有东西都丢给 ai 来处理…一定是系统先确定性的从数据库中取出数据后交给 ai 分析」)。
|
||||
九步砍成**四步**(S1_inputs 核对入参与补数据 → S2_assess **整个活动唯一一次** `analyze` →
|
||||
S3_review 结论审核 → T_release 落账放行),**全程零 `guide`**,行为整体搬进 `PlanStep.auto`
|
||||
声明式配方(ADR-014 第一个真实用户);零工具、零技能,两枚旧工具与两张 DMN 一并退役 ——
|
||||
规则只许有一处真源,活在配方的 `when` 链里。新建两张类型字典
|
||||
(`parts.chip_type`/`parts.type_param`):按芯片类型(不是按料号)标一次核心参数字段与
|
||||
硬软约束,「两颗料必须用同一套参数名」从此是**结构保证**,原先靠测三次都没拦住的
|
||||
自指引用墙退休。三张件(数据工单 · 结论审核 · 落账确认),数据工单按缺口条件发卡,
|
||||
零卡直通是常态路径之一。
|
||||
**e2e 五案例的真实战果**(2026-08-30,连续多轮真跑,详见
|
||||
`.superpowers/sdd/2026-08-29-act-work-05-rewrite/task-11-report.md`):候选缺库·新建类型 /
|
||||
都在库·零卡直分析 / 原PN缺库三例 `released`、**被拒 0**、**模型调用恰 1 次**、发口
|
||||
`assessment-released` 全对——**「候选缺库·新建类型」这条记录后来被全分支终审推翻**
|
||||
(见下方「全分支终审」条):那次 `released` 靠脚本走 API **显式传 `type_id:'90'`**,
|
||||
没有走真人在浏览器里点「新建类型」那条路;`e2e-a2.mjs` 自己的注释原话是
|
||||
「这条路径连真人在浏览器里都走不通」。**该走的那条路当时藏着 Critical 1**(见下方条目),
|
||||
这里的「released」只证明了 API 直传能通,不证明「新建类型端到端通了」。
|
||||
类型不一致·硬拦那例撞上**已记入 `docs/debt.md`** 的平台死锁
|
||||
(`prune` 与「数据工单确认即入库」的架构性冲突:人先交一次、字段已落地,再改类型选择,
|
||||
`prune` 想撤已落地的陈旧行被显式挡住,两条机制各自都对、合起来死锁);两颗都缺库那例
|
||||
卡在「决策卡答完之后不再推进」,`advancing_until` 反复核过是空、租约与陈旧进程两种假说
|
||||
均已排除,**真因未定位**,如实记录未解决,不强装成功。**`judgement_class` 逐行核对通过**
|
||||
(7/7:使能阈值 / 工作温度 / 开关频率 / 输入电压范围 / 输出电压 / 输出电流 / 静态电流,
|
||||
AI 判的 `difference_status` 与规则链算出的 `judgement_class` 逐行对上,`risk_level`
|
||||
联动也对)—— 这条验证之所以要点名贴出来,是因为它曾经**逐行全错**。
|
||||
**地基缺陷**:活动清单存进 `sys.activity_package.manifest` 这个 jsonb 列后,PostgreSQL
|
||||
按「先短后长、同长字典序」重排了所有嵌套对象的键,而 `project`/`analyze` 的 `fields`
|
||||
原来靠 `Object.entries` **声明顺序**求值(后一字段靠 `ctx.computed` 看前一字段已算出的
|
||||
值)——「声明序 = 依赖序」这条从没写下来的假设一进数据库就被打破,且**不报错**、
|
||||
隔离测试也复现不出来(它直接读 `dist/manifest.json`,不经数据库往返),只有真跑 e2e
|
||||
才现形。W05 实证:合成列 `judgement_class` 被排到它依赖的 `difference_status` 前面,
|
||||
读到 `undefined`,七行**全部**误判(`difference_status` 本身走 `lookup` 不依赖字段序,
|
||||
所以是对的 ——「一个对一个全错」的怪现象由此而来)。**修法是把字段求值从『声明序』
|
||||
改成『依赖拓扑排序』**(Kahn 算法,新增 `packages/utils` 的 `topoOrder`,契约纯新增);
|
||||
真正的环在装包静态审当场拒装,不兜底、不静默放行。四条新纪律入规范 §4.6(六条→十条):
|
||||
`fields` 顺序不可依赖拓扑排序之外的任何假设 / 跨笔顺序仍要人自己排(一笔读的是另一笔
|
||||
写出来的数就得排在后面)/ 方向相反的依赖拆两笔写、中间夹子表 / `gateLedgers` 同时管
|
||||
「闸要求非空」与「确认时并进正式表」两件事,漏想一半就会悬空引用或永久卡死。
|
||||
`pnpm gates` 退出码 0(2447 passed | 34 skipped)。
|
||||
- **W05 数据工单换版面 + 两条渲染机制的实锚**(2026-08-30,`part-substitution-assessment@2.2.2`,
|
||||
用户给了新目标件 `docs/ss02-act-05-new/ACT-WORK-05-数据工单-PN主数据创建.html`):
|
||||
`input-supplement` 整张照它重排 —— **两条并列轨道**(每颗料一条:档案卡 + 主数据 + 类型选择)
|
||||
+ `<details>` 新增类型面板(chip_type / type_param 两张表原样搬入,独立「核心参数字段」区退役)
|
||||
+ 核心参数值**瓷贴单块平铺**;绑定面一列不差(四张台账 / 四道闸 / 两处加行 / 终审 C1 的
|
||||
disabled type_id / 主键钥匙)。目标件脚本驱动的状态全部无脚本翻译:保存即铺格顶掉「选类型
|
||||
即时加载」、details 顶掉 `.newtype.show`、步骤圆点按基座数点亮、gateprog 进度条如实退掉
|
||||
(分母不是基座现有的数)。**三条机制账**,全有测钉死:
|
||||
①**`data-rows` 宿主的闭合标签就是行模版的截刀** —— ROWS 正则按宿主自己的闭合标签非贪婪
|
||||
截取,`<div class="lanes">` 被行模版里第一个 `</div>` 截半,第二行嵌进第一行没闭合的标签里
|
||||
档案卡当场串行(A2 立的「行容器用行内不会再出现的标签」说的正是**宿主**,它自己就是
|
||||
`<article>` 包 `<section>`);防再犯钉用**配平判法**(「不含闭合标签」是空话 —— 非贪婪截取
|
||||
本来就停在闭合标签之前,那条断言永远不会红);
|
||||
②**行内 `data-class-of` 只认行作用域**(renderRows 末段 renderTemplate 拿的是行对象),
|
||||
作用域级读数在行内投不出 —— 圆点的投影挂在行外的 `.pj` 包裹层,后代选择器点亮;
|
||||
③**`data-by` 行缺键落回外层同名字段**(终审 I3)真咬人:XL 没选类型,`sub_eval.case`
|
||||
摊平的 `chip_type_id` 顶进来,查名 pill 摆出它没有的类型 —— 守卫认 `selectCurrent` 按
|
||||
**这一行自己的值**写下的 `option[selected]`,行里没真选上名字不摆(判断全在样式表)。
|
||||
同批逮到并修死:profile 的编辑闸在第一版 body 里**整个漏掉**(宿主在而闸不在,XL 必填
|
||||
永远补不上 —— 「四道闸」旧断言只查 data-edit 宿主没查 [data-edit-for] 闸,补强)。
|
||||
另一半探针钉进平台测试:嵌套 data-rows 在唯一 data-edit 宿主里**必然把一颗料的格子并成
|
||||
一条记录**(rowOf 认宿主直接子元素;真实 renderView+collect 实测,
|
||||
`view-container-collect-div-rows.test.ts`)—— 步骤③不按轨道分两栏是机制不是偏好。
|
||||
**验收**:活动自测 62 → 74(新钉逐条变异验证);`pnpm gates` 退出码 0(2497 测);
|
||||
2.2.0/2.2.1/2.2.2 三连装库 SQL 逐次核过;浏览器全流程真人路 —— 起单 → 两轨道核对 →
|
||||
保存即铺格(XL 七格当场铺出)→ 填参数 → 确认写入办结 → S2 分析推进;新建类型走种子行
|
||||
续号 `03`、DC-DC 原行未被覆盖、下拉即时长出;类型不一致护栏真亮 v-2 再熄灭。
|
||||
终审评审 0 Critical / 0 Important / 1 Minor(注释漂移,已修)。跑完 reset-runs,
|
||||
主数据与活动缓存零残留。
|
||||
|
||||
## 文档权威地图
|
||||
|
||||
| 层 | 文件 | 说明 |
|
||||
@@ -1631,8 +1738,9 @@
|
||||
| | `docs/前端对接说明-邮件收发.md` | **前端接邮箱配置 / 来信 / 发信记录照这份**(ADR-013):账号表单两形、四个动作、投递状态、附件与原件的票据下载、SSE 失效、腾讯侧前置 |
|
||||
| | `docs/API文档-邮件收发.md` | 邮件收发 15 条接口的人话版 + schema 附录 + curl;活动怎么收(事件链)怎么发(`mail.send` 2.0.0) |
|
||||
| | `docs/API文档-鉴权与人员.md` | 鉴权 / 人员 / 角色→端接口的人话版 + 全部路由归属总表;契约不一致时以 `packages/contracts` 为准 |
|
||||
| | `docs/ACT-WORK-05-端到端测试记录.md` | **ACT-WORK-05 差异评估的走查账**:六轮逐轮结论 + A–K 十一条发现总表(现象 / 病根 / 治法 / 层) |
|
||||
| | `docs/数据域字段说明-W05.md` | ACT-WORK-05 的十张台账 / 110 列 / 人填列 27 —— 建活动时照它对字段 |
|
||||
| | `docs/ACT-WORK-05-端到端测试记录.md` | **历史记录,已停止更新**(2026-08-30 标注):旧九步设计从零到 released 的走查账,六轮逐轮结论 + A–Z 二十六条发现总表(现象 / 病根 / 治法 / 层);四步重写(2026-08-29)之后的发现不再往里追加,散落在源码注释与 `docs/debt.md` |
|
||||
| | `docs/数据域字段说明-W05.md` | ACT-WORK-05 重写后九张台账 / 66 列 / 人填列 20 —— 建活动时照它对字段 |
|
||||
| | `docs/adr/ADR-014-deterministic-steps.md` | **确定性步骤(已实现,2026-08-29)**:W05 九步的确定/判断盘点、`PlanStep.auto` 声明式配方(五动词:`project`/`analyze`/`card`/`submit`/`say`)、引擎拍内次序、回溯验算、迁移次序、§11 待定前两条已定(决策卡给了 / 工具串接换成 `analyze`) |
|
||||
| | `docs/debt.md` | 欠账台账(只增,销账划线) |
|
||||
| | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 |
|
||||
| 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,67 +1,116 @@
|
||||
/**
|
||||
* 步骤剧本与呈现面 —— 这段错了不报错,只是活动开始转圈 / 界面开始撒谎(与 E04 / I01 同一套惯例)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import { ASSESSMENT_PHASES, ASSESSMENT_STEPS, CMMN_XML, NARRATIVE } from '../src/case-model.ts';
|
||||
import { manifest, D } from '../src/manifest.ts';
|
||||
import { planStepSchema } from '@contracts';
|
||||
import { ASSESSMENT_STEPS, ASSESSMENT_PHASES, CMMN_XML, NARRATIVE } from '../src/case-model.ts';
|
||||
import { manifest } from '../src/manifest.ts';
|
||||
|
||||
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map((m) => m[1] ?? '');
|
||||
|
||||
function doneOf(item: string): string {
|
||||
const found = ASSESSMENT_STEPS.find((s) => s.item === item)?.doneWhen;
|
||||
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
|
||||
return found;
|
||||
const ITEMS = ['S1_inputs', 'S2_assess', 'S3_review', 'T_release'];
|
||||
/*
|
||||
* **泛型 `Extract`,不是 `PlanStep` 标注**(2026-08-29,评审 fix round 1 改判)——
|
||||
* `ASSESSMENT_STEPS` 不是元组,`.find()` 的返回类型是数组**元素类型**(四步 `auto.do` 里所有动作形状的并集),
|
||||
* 与查的是哪个 `item` 无关。第一版把返回类型标成契约 `PlanStep`,而 `PlanStep['auto']` 落在
|
||||
* `fields: Readonly<Record<string, AutoExpr>>` 这个**开放索引签名**上 —— 拼写保护是回来了,但整个
|
||||
* 文件里按方括号取字段值的 14 处(含 S1 原有 `:155`/`:156`/`:166` 三条)全部失去编译期拼写检查,
|
||||
* `evidence_note` 手滑打成 `evidence_noet` 之类的错字,`tsc` 会**静默放行**,只能等 `vitest` 断言
|
||||
* 落空才发现,反馈反而往后挪了一步。
|
||||
*
|
||||
* 现在按查的 `item` 精确 narrow:四步各自的 `item` 字段已经在 `case-model.ts` 加了 `as const`,
|
||||
* `(typeof ASSESSMENT_STEPS)[number]` 因此是四种带字面量 `item` 标签的形状的并集;`stepOf('S2_assess')`
|
||||
* 传的是字符串字面量,泛型 `T` 原样推成 `'S2_assess'`,`Extract<并集, {item: T}>` 精确挑出**那一步自己的**
|
||||
* 形状 —— 别的步的 `project`/`analyze` 形状不再混进来,`.fields['xxx']` 落回的是那一步真实写的字面量对象
|
||||
* 类型(有名字的那几个属性),不是开放索引签名。拼错字段名会报 `TS2551` 并带拼写建议。
|
||||
*/
|
||||
function stepOf<T extends string>(item: T) {
|
||||
return ASSESSMENT_STEPS.find(
|
||||
(s): s is Extract<(typeof ASSESSMENT_STEPS)[number], { item: T }> => s.item === item,
|
||||
);
|
||||
}
|
||||
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径 */
|
||||
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
|
||||
try {
|
||||
return evaluateCondition(expression, scope);
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
describe('四步案例模型', () => {
|
||||
it('恰好四步,次序即业务次序', () => {
|
||||
expect(ASSESSMENT_STEPS.map((s) => s.item)).toEqual(ITEMS);
|
||||
});
|
||||
|
||||
it('每一步都过得了 PlanStep 契约', () => {
|
||||
for (const step of ASSESSMENT_STEPS) expect(planStepSchema.safeParse(step).success).toBe(true);
|
||||
});
|
||||
|
||||
it('**全程零 guide、零工具** —— 这是这次重写的立场', () => {
|
||||
for (const step of ASSESSMENT_STEPS) {
|
||||
expect(step.guide).toBe('');
|
||||
expect(step.tools).toEqual([]);
|
||||
expect(step.auto).toBeDefined();
|
||||
}
|
||||
}
|
||||
/** 开局局面:入参 + 所有基座读数归零 */
|
||||
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
|
||||
...input,
|
||||
demand_request_rows: 0,
|
||||
demand_request_missing: 0,
|
||||
[`${D}_verdict_rows`]: 0,
|
||||
[`${D}_pn_check_rows`]: 0,
|
||||
[`${D}_pn_check_missing`]: 0,
|
||||
[`${D}_pn_check_exists_status_absent`]: 0,
|
||||
[`${D}_pn_check_exists_status_abandoned`]: 0,
|
||||
[`${D}_input_gap_rows`]: 0,
|
||||
[`${D}_input_gap_missing`]: 0,
|
||||
input_confirmed_total: 0,
|
||||
review_approved_total: 0,
|
||||
landing_prepared_total: 0,
|
||||
substitution_relation_rows: 0,
|
||||
substitution_relation_missing: 0,
|
||||
substitution_relation_analysis_status_fae_confirmed: 0,
|
||||
substitution_difference_item_rows: 0,
|
||||
substitution_difference_item_missing: 0,
|
||||
substitution_difference_item_distinct_substitution_relation_id: 0,
|
||||
substitution_difference_item_difference_status_match: 0,
|
||||
substitution_difference_item_difference_status_mismatch: 0,
|
||||
substitution_difference_item_difference_status_unknown: 0,
|
||||
substitution_difference_item_difference_status_conditional: 0,
|
||||
tool_part_substitution_assessment_compare_parameters_calls: 0,
|
||||
tool_part_substitution_assessment_recommend_calls: 0,
|
||||
});
|
||||
|
||||
describe('剧本与案例模型对得上', () => {
|
||||
it('每一条剧本都指向一个真实存在的计划项,图上每一步也都有剧本', () => {
|
||||
for (const step of ASSESSMENT_STEPS) expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
|
||||
for (const id of ITEM_IDS) expect(ASSESSMENT_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
|
||||
});
|
||||
|
||||
it('清单里挂的就是这份剧本与这份阶段', () => {
|
||||
expect(manifest.assets.businessModel.steps).toBe(ASSESSMENT_STEPS);
|
||||
expect(manifest.assets.businessModel.phases).toBe(ASSESSMENT_PHASES);
|
||||
it('CMMN 里四个计划项都在,且触达世界的两步标了人工激活', () => {
|
||||
for (const item of ITEMS) expect(CMMN_XML).toContain(`id="${item}"`);
|
||||
/* 审核与放行触达的是人 —— 完成与否不该由数据形状推出来 */
|
||||
for (const item of ['S3_review', 'T_release']) {
|
||||
expect(new RegExp(`id="${item}"[^>]*manualActivation="true"`).test(CMMN_XML)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('**触达世界的两步不给 doneWhen** —— 给了等于给引擎一把替人签字的钥匙', () => {
|
||||
expect(stepOf('S1_inputs')?.doneWhen).toBeTruthy();
|
||||
expect(stepOf('S2_assess')?.doneWhen).toBeTruthy();
|
||||
expect(stepOf('S3_review')?.doneWhen).toBeUndefined();
|
||||
expect(stepOf('T_release')?.doneWhen).toBeUndefined();
|
||||
});
|
||||
|
||||
it('**判据不许编一个数不出来的数**:doneWhen 里不出现「本类型有几个字段」这类', () => {
|
||||
/* `parts_type_param_rows` 数的是本单草稿写下的字段行(人新建的那几个),不是整本字典 —— 拿它当分母是错的 */
|
||||
for (const step of ASSESSMENT_STEPS) {
|
||||
expect(step.doneWhen ?? '').not.toContain('parts_type_param_rows');
|
||||
}
|
||||
});
|
||||
|
||||
it('S1 收步:两颗都在库、都归了类型、且是同一个类型、主数据与参数都不缺', () => {
|
||||
const done = stepOf('S1_inputs')?.doneWhen ?? '';
|
||||
expect(done).toContain('sub_eval_part_exists_status_found = 2');
|
||||
expect(done).toContain('sub_eval_part_missing = 0');
|
||||
expect(done).toContain('sub_eval_part_distinct_chip_type_id = 1');
|
||||
expect(done).toContain('parts_profile_missing = 0');
|
||||
expect(done).toContain('parts_parameter_missing = 0');
|
||||
});
|
||||
|
||||
it('S2 收步:明细写了且不缺、关系一行、验证记录一行', () => {
|
||||
const done = stepOf('S2_assess')?.doneWhen ?? '';
|
||||
expect(done).toContain('substitution_difference_item_rows > 0');
|
||||
expect(done).toContain('substitution_difference_item_missing = 0');
|
||||
expect(done).toContain('substitution_relation_rows = 1');
|
||||
expect(done).toContain('validation_record_rows = 1');
|
||||
});
|
||||
|
||||
it('两张卡各带人工验收清单 —— 一条一句人眼核得动的动作', () => {
|
||||
for (const item of ['S3_review', 'T_release']) {
|
||||
const list = stepOf(item)?.acceptanceChecklist ?? [];
|
||||
expect(list.length).toBeGreaterThanOrEqual(4);
|
||||
for (const line of list) expect(line.length).toBeGreaterThan(6);
|
||||
}
|
||||
expect(stepOf('S3_review')?.cardVariant).toBe('review');
|
||||
});
|
||||
|
||||
it('阶段:除放行外每一项恰好属于一个阶段', () => {
|
||||
const covered = ASSESSMENT_PHASES.flatMap((p) => p.items);
|
||||
expect(covered.sort()).toEqual(['S1_inputs', 'S2_assess', 'S3_review'].sort());
|
||||
expect(new Set(covered).size).toBe(covered.length);
|
||||
});
|
||||
|
||||
it('说明书只讲整单的事 —— 逐步做法归配方,而配方不是给模型看的', () => {
|
||||
expect(NARRATIVE.length).toBeLessThan(2000);
|
||||
/* **示例不引具体料号与参数名** —— 模型会把示例当成这一单的值(2026-08-27 复现三次) */
|
||||
for (const forbidden of ['LM2596S', 'XL2596S', 'AMS1117', '使能阈值']) {
|
||||
expect(NARRATIVE).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 恢复自 Task 2 遗留的两条 `it.skip`(原住在这份文件里,注释指名「Task 2 → Task 3 补回」)——
|
||||
* 旧版断言的是旧九步剧本引旧台账地址 / 旧两枚工具,那两个前提随四步重写一起撤了,
|
||||
* 这里按新的四步剧本与九张新台账重新写实(逻辑不变:读写面必须落在清单声明里、
|
||||
* 工具必须落在声明过的工具里),不是新造的断言。
|
||||
*/
|
||||
describe('剧本声明的读写面 ⊆ 清单声明', () => {
|
||||
const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`));
|
||||
const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name));
|
||||
@@ -69,404 +118,329 @@ describe('剧本声明的读写面 ⊆ 清单声明', () => {
|
||||
it('steps[].writes / reads(台账那几个)全落在声明过的对象上', () => {
|
||||
for (const step of ASSESSMENT_STEPS) {
|
||||
for (const address of step.writes) expect(declaredAddresses.has(address), `${step.item} 要写 ${address}`).toBe(true);
|
||||
/* reads 里混着入参名(originalInternalPn 等)与 case: 前缀伪读数,只有「域.对象」形状的才对声明表 */
|
||||
for (const address of step.reads.filter((r) => r.includes('.') && !r.startsWith('case:'))) {
|
||||
expect(declaredAddresses.has(address), `${step.item} 要读 ${address}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('steps[].tools 全部是声明过的工具;只有 S3 调工具', () => {
|
||||
it('steps[].tools 全部是声明过的工具 —— 且四步全是空数组(零工具是这次重写的立场,不是疏漏)', () => {
|
||||
for (const step of ASSESSMENT_STEPS) {
|
||||
for (const tool of step.tools) expect(declaredTools.has(tool), `${step.item} 要调 ${tool}`).toBe(true);
|
||||
if (step.item !== 'S3_assess') expect(step.tools, step.item).toEqual([]);
|
||||
expect(step.tools, step.item).toEqual([]);
|
||||
}
|
||||
expect(ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.tools).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('customer_approval_status 在任何剧本里都不许写(只读边界)', () => {
|
||||
for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toMatch(/customer_approval_status\s*=/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doneWhen —— 开局不许真,干完必须真', () => {
|
||||
it('**开局一步都不该被自动收掉** —— 五条样例的入参形状逐一试', () => {
|
||||
for (const sample of manifest.samples) {
|
||||
for (const step of ASSESSMENT_STEPS) {
|
||||
if (step.doneWhen === undefined) continue;
|
||||
expect(safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>)), `样例「${sample.name}」下 ${step.item} 开局就为真`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
describe('S1 配方', () => {
|
||||
const recipe = stepOf('S1_inputs')?.auto;
|
||||
const acts = recipe?.do ?? [];
|
||||
const kinds = acts.map((a) => a.kind);
|
||||
|
||||
it('S0:两颗候选 + 原 PN = 3 行核对齐、需求与结论都写了 → 收得掉;少写一颗 → 收不掉', () => {
|
||||
const base = { candidateInternalPns: ['XL2596S-5.0', 'LM2596S-ADJ'], demand_request_rows: 1, demand_request_missing: 0, [`${D}_verdict_rows`]: 1, [`${D}_pn_check_missing`]: 0 };
|
||||
expect(safeEval(doneOf('S0_bind'), { ...base, [`${D}_pn_check_rows`]: 3 })).toBe(true);
|
||||
expect(safeEval(doneOf('S0_bind'), { ...base, [`${D}_pn_check_rows`]: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('S2:未放弃的候选各一条关系且每条都有明细 → 收得掉;放弃一颗后关系数随之减一', () => {
|
||||
const two = { candidateInternalPns: ['A', 'B'], [`${D}_pn_check_exists_status_abandoned`]: 0, substitution_difference_item_rows: 14 };
|
||||
expect(safeEval(doneOf('S2_normalize'), { ...two, substitution_relation_rows: 2, substitution_difference_item_distinct_substitution_relation_id: 2 })).toBe(true);
|
||||
expect(safeEval(doneOf('S2_normalize'), { ...two, substitution_relation_rows: 2, substitution_difference_item_distinct_substitution_relation_id: 1 })).toBe(false);
|
||||
expect(safeEval(doneOf('S2_normalize'), { ...two, [`${D}_pn_check_exists_status_abandoned`]: 1, substitution_relation_rows: 1, substitution_difference_item_distinct_substitution_relation_id: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('S3:四态之和 = 明细行数、必填齐、两枚工具都调过 → 收得掉;工具没调过 → 收不掉', () => {
|
||||
const ok = {
|
||||
substitution_difference_item_missing: 0,
|
||||
substitution_relation_missing: 0,
|
||||
substitution_difference_item_rows: 7,
|
||||
substitution_difference_item_difference_status_match: 4,
|
||||
substitution_difference_item_difference_status_mismatch: 1,
|
||||
substitution_difference_item_difference_status_unknown: 1,
|
||||
substitution_difference_item_difference_status_conditional: 1,
|
||||
tool_part_substitution_assessment_compare_parameters_calls: 1,
|
||||
tool_part_substitution_assessment_recommend_calls: 1,
|
||||
};
|
||||
expect(safeEval(doneOf('S3_assess'), ok)).toBe(true);
|
||||
expect(safeEval(doneOf('S3_assess'), { ...ok, tool_part_substitution_assessment_recommend_calls: 0 })).toBe(false);
|
||||
expect(safeEval(doneOf('S3_assess'), { ...ok, substitution_difference_item_difference_status_match: 3 })).toBe(false);
|
||||
});
|
||||
|
||||
it('S5:旗 + 全部关系 FAE 确认 + P-01 回写齐 → 收得掉;缺一头都收不掉', () => {
|
||||
const ok = { landing_prepared_total: 1, substitution_relation_analysis_status_fae_confirmed: 2, substitution_relation_rows: 2, substitution_relation_missing: 0, demand_request_missing: 0 };
|
||||
expect(safeEval(doneOf('S5_prepare_landing'), ok)).toBe(true);
|
||||
expect(safeEval(doneOf('S5_prepare_landing'), { ...ok, substitution_relation_analysis_status_fae_confirmed: 1 })).toBe(false);
|
||||
expect(safeEval(doneOf('S5_prepare_landing'), { ...ok, demand_request_missing: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('说明书与剧本的骨架', () => {
|
||||
it('五张卡的骨架都住在说明书里(压拍之后只有说明书还在)', () => {
|
||||
expect(NARRATIVE).toContain('"kind":"entry","itemId":"S0_bind"');
|
||||
expect(NARRATIVE).not.toContain('"kind":"decide","itemId":"S0d_p08_master"');
|
||||
// 两张数据工单都得标 variant:"data" —— 对话区凭它换成「数据工单」那副面孔
|
||||
expect(NARRATIVE.match(/"variant":"data"/g)?.length).toBe(2);
|
||||
// 但模型抄卡会把它丢掉(2026-08-27 实测)—— 真正作数的是剧本上的声明,引擎按它决定
|
||||
for (const item of ['S0d_p08_master', 'S0d_p10_document']) {
|
||||
expect(ASSESSMENT_STEPS.find((s) => s.item === item)?.cardVariant, item).toBe('data');
|
||||
}
|
||||
expect(ASSESSMENT_STEPS.find((s) => s.item === 'S4_review')?.cardVariant).toBe('review');
|
||||
for (const step of ASSESSMENT_STEPS) if (!['S0d_p08_master', 'S0d_p10_document', 'S4_review'].includes(step.item)) expect(step.cardVariant, step.item).toBeUndefined();
|
||||
expect(NARRATIVE).toContain('"title":"数据工单 · 芯片主数据创建(P-08)');
|
||||
expect(NARRATIVE).toContain('"title":"数据工单 · 原厂技术资料创建(P-10)');
|
||||
expect(NARRATIVE).toContain('"title":"评估输入与资料补齐确认');
|
||||
expect(NARRATIVE).toContain('"kind":"decide","itemId":"S4_review"');
|
||||
});
|
||||
|
||||
it('剧本里不引反例原话;S3 固定三拍且写明「工具后同拍作废」', () => {
|
||||
const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? '';
|
||||
expect(s3).toContain('第一拍');
|
||||
expect(s3).toContain('第二拍');
|
||||
expect(s3).toContain('第三拍');
|
||||
expect(s3).toContain('工具调用后同拍其余动作作废');
|
||||
/*
|
||||
* 次序是承重的,不是随手写的(2026-08-29 评审 Important 修正):案卷投影(`sub_eval.case`)
|
||||
* 的 `gate_reason` 要读 `parts_profile_missing`/`parts_parameter_missing` —— 这两个数由
|
||||
* 主数据/参数骨架两笔撑出来,案卷这一笔必须排在它们**之后**,否则读到的是这一拍刷新之前
|
||||
* 的旧数。病根写在 case-model.ts ④ 那一笔头上;引擎级的复现(旧次序真的会让 gate_reason
|
||||
* 变成空字符串)见 `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts`
|
||||
* ——这份文件只做结构断言,不跑引擎(活动包不许 import apps/*)。
|
||||
*/
|
||||
it('八笔,次序是:两颗料 → 类型字典自投 → 字段清单自投 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代', () => {
|
||||
expect(kinds).toEqual(['project', 'project', 'project', 'project', 'project', 'project', 'card', 'say']);
|
||||
expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
|
||||
'sub_eval.part', 'parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter', 'sub_eval.case',
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-27 晚,用户:「这个做差异评估比对的 harness 肯定有问题,要让它认真比对每一条」。实测(0bddd862)模型把剧本示例里的
|
||||
* 「4.5~32 / >= 40」当成这单的值念出来;比对入参改由工具从台账现读,模型只报料号与客户约束,示例里不再有任何具体参数值。
|
||||
* 2026-08-30,Ruling H35:`parts.chip_type`/`parts.type_param` 各自把「选中类型的那一行」
|
||||
* 原样投一遍(值不变,幂等)——目的不是产出新数据,是让 `mine:` 这两张表不再天然为零行,
|
||||
* 好让它们能安全回到下面 ⑦ 卡的 `gateLedgers` 上(闸不会再拿「零行」误判)。
|
||||
*
|
||||
* **不带 `prune`**(2026-08-30 第二处修正,e2e 案例 2 撞出来的):这两张表在 ⑦ 的
|
||||
* `gateLedgers` 上,一旦人交回数据工单就随「数据工单确认即入库」落进正式表——已落地的行
|
||||
* `draftDrop`(`prune` 的撤行机制)撤不掉,配方原样抛错。字典类的表允许历史选型留在
|
||||
* 草稿层(不算错),不像 `parts.profile`/`parts.parameter` 那几笔(它们仍然 `prune:true`,
|
||||
* 见下面「主数据骨架」那笔)必须清掉不再相关的陈旧行。
|
||||
*/
|
||||
it('S3 第一拍只报料号与客户约束:入参形状是 originalInternalPn + candidates[].requirements,示例里没有具体参数值', () => {
|
||||
const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? '';
|
||||
expect(s3).toContain('"originalInternalPn":"<原 PN>"');
|
||||
expect(s3).toContain('"requirements"');
|
||||
expect(s3).not.toContain('"pairs"');
|
||||
expect(s3).not.toMatch(/4\.5~32|4\.5~40|32V|40V/);
|
||||
expect(s3).toMatch(/一个参数值都不抄/);
|
||||
// 写盘照抄出参:值与 compare_result 都不许换
|
||||
expect(s3).toMatch(/compare_result 也照抄/);
|
||||
it('②③ 两笔自投影都读 sub_eval.part 的 chip_type_id 去查字典/字段清单,原样抄回自己(不带 prune)', () => {
|
||||
const chipType = acts[1];
|
||||
expect(chipType?.kind === 'project' && chipType.into).toBe('parts.chip_type');
|
||||
expect(chipType?.kind === 'project' && chipType.prune).toBeUndefined();
|
||||
expect(chipType?.kind === 'project' && chipType.from).toMatchObject({
|
||||
each: 'mine:sub_eval.part',
|
||||
then: { each: 'parts.chip_type', where: { type_id: { outer: 'chip_type_id' } } },
|
||||
});
|
||||
const typeParam = acts[2];
|
||||
expect(typeParam?.kind === 'project' && typeParam.into).toBe('parts.type_param');
|
||||
expect(typeParam?.kind === 'project' && typeParam.prune).toBeUndefined();
|
||||
expect(typeParam?.kind === 'project' && typeParam.from).toMatchObject({
|
||||
each: 'mine:sub_eval.part',
|
||||
then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } },
|
||||
});
|
||||
});
|
||||
|
||||
it('S3 第三拍写盘前先自检两枚工具的调用次数(2026-08-27 实测:漏调 recommend 会让 doneWhen 恒假、模型死循环重写同样的行)', () => {
|
||||
const s3 = ASSESSMENT_STEPS.find((s) => s.item === 'S3_assess')?.guide ?? '';
|
||||
const thirdPass = s3.slice(s3.indexOf('第三拍'));
|
||||
expect(thirdPass).toContain('tool_part_substitution_assessment_recommend_calls');
|
||||
expect(thirdPass).toContain('tool_part_substitution_assessment_compare_parameters_calls');
|
||||
expect(thirdPass).toMatch(/自检|先.*找这两个数|必须\s*>\s*0/);
|
||||
// 第二拍那一段要点出「推荐等级只能由工具算,不调它收不了步」,别让模型自己拍推荐等级
|
||||
const secondPass = s3.slice(s3.indexOf('第二拍'), s3.indexOf('第三拍'));
|
||||
expect(secondPass).toContain('推荐等级只能由它算');
|
||||
expect(secondPass).toMatch(/收不了步|收不掉/);
|
||||
it('案号由 let 铸,只从本单开案时刻取材', () => {
|
||||
expect(recipe?.let?.['evalRef']).toEqual({ cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] });
|
||||
});
|
||||
|
||||
it('两颗料由字面两行立起来 —— 这张表是后面每一笔遍历的唯一来源', () => {
|
||||
const part = acts[0];
|
||||
expect(part?.kind === 'project' && 'rows' in part.from && part.from.rows).toHaveLength(2);
|
||||
/* 在库与否是一次 lookup 的结果,不是一句断言:先取依据,再按依据判 */
|
||||
const fields = part?.kind === 'project' ? part.fields : undefined;
|
||||
expect(fields?.['found_pn_ref']).toMatchObject({ lookup: 'parts.profile', take: 'internal_pn' });
|
||||
expect(fields?.['exists_status']).toEqual({ when: 'found_pn_ref != ""', then: 'found', else: 'absent' });
|
||||
});
|
||||
|
||||
/*
|
||||
* 数据工单确认即入库(2026-08-27 用户定:「数据工单里填的数据,确认后就应该入库」)。
|
||||
* 基座在确认那一刻把卡上 gateLedgers 那几张表并进正式表;剧本这一头不能再教模型「已经落草稿层」——
|
||||
* 那句话会让它以为放行前还能改、还能 drop,而在库的键写下来会被当场拒。
|
||||
* **2026-08-30 修 Critical 2**:两个启动参数改成必填之后(`manifest.ts` 的
|
||||
* `contract.input.required`),`sub_eval_part_rows < 2` 只剩一条能触发它的路径 ——
|
||||
* 两颗料号填成了同一个(`internal_pn` 撞成同一把主键,两行覆盖成一行)。旧文案
|
||||
* 「原料号与候选料号还没填全 —— 在下面把两格补上」指向一条不存在的路(块① 没有
|
||||
* 「加一行」),必须换成说实情、给真出路的话:没法比出差异,终止重开。
|
||||
*/
|
||||
it('两张数据工单的剧本都写明:确认后已入正式表,别再写、别 drop 那几行;P-10 的卡骨架也说「确认后即入库」', () => {
|
||||
for (const item of ['S0d_p08_master', 'S0d_p10_document']) {
|
||||
const guide = ASSESSMENT_STEPS.find((s) => s.item === item)?.guide ?? '';
|
||||
expect(guide, item).toContain('入了正式表');
|
||||
expect(guide, item).not.toContain('已经落草稿层');
|
||||
expect(guide, item).toContain('别去 drop');
|
||||
}
|
||||
expect(NARRATIVE).toContain('确认后这批主数据即入库');
|
||||
expect(NARRATIVE).toContain('确认后这批资料记录即入库');
|
||||
it('gate_reason 第一档:两参必填之后只剩"填成同一个号"这条残余路径,文案不再指向不存在的"补两格"', () => {
|
||||
const kase = acts[5];
|
||||
const fields = kase?.kind === 'project' ? kase.fields : undefined;
|
||||
const gateReason = fields?.['gate_reason'] as { when?: string; then?: string } | undefined;
|
||||
expect(gateReason?.when).toBe('sub_eval_part_rows < 2');
|
||||
expect(gateReason?.then).not.toContain('还没填全');
|
||||
expect(gateReason?.then).not.toContain('把两格补上');
|
||||
expect(gateReason?.then).toContain('终止');
|
||||
});
|
||||
|
||||
it('参数骨架:两颗料都铺、值回填现值 —— 「在库但当年参数没录全」也要被拦住', () => {
|
||||
const params = acts[4];
|
||||
expect(params?.kind === 'project' && params.prune).toBe(true);
|
||||
const from = params?.kind === 'project' ? params.from : undefined;
|
||||
/* 外层是本单两颗料,里层是该类型的字段清单 —— 人没挑类型时 where 一行不命中,什么也不铺 */
|
||||
expect(from).toMatchObject({ each: 'mine:sub_eval.part', then: { each: 'parts.type_param' } });
|
||||
const fields = params?.kind === 'project' ? params.fields : undefined;
|
||||
expect(fields?.['param_value']).toMatchObject({ lookup: 'parts.parameter', take: 'param_value' });
|
||||
});
|
||||
|
||||
it('**发卡的条件全是基座读数** —— 不许拿 `gate_reason != ""` 当条件', () => {
|
||||
const card = acts[6];
|
||||
const when = card?.kind === 'card' ? (card.when ?? '') : '';
|
||||
/*
|
||||
* S-FEEL 里**未绑定的名字 `!= ""` 求值为 true**,而 `gate_reason` 为空串时那一列压根不写、
|
||||
* 于是未绑定 —— 拿它当条件,恰恰在一切正常时把卡发出去。条件只用恒有值的基座读数。
|
||||
*/
|
||||
expect(when).not.toContain('gate_reason');
|
||||
expect(when).toContain('sub_eval_part_exists_status_found < 2');
|
||||
expect(when).toContain('parts_parameter_missing > 0');
|
||||
});
|
||||
|
||||
/*
|
||||
* S1 的「待确认」行只许来自真写着「未披露」的参数(2026-08-27 用户:「我在第一步已经补充了
|
||||
* XL2596S-5.0 使能阈值,当前这一步还要填写一次?」)—— 剧本从前给了一句原话示例
|
||||
* 「input_item 如『XL2596S-5.0 使能阈值』」,模型照抄示例、不读现值,把人刚填的 2.5 摆成缺口。
|
||||
* 提示词里唯一该出现的原话,是你想听到的那句;示例行一律不引具体料号 + 参数名。
|
||||
* 2026-08-30,Ruling H35(推翻 2026-08-29 评审 Critical 那条旧结论,不是又改主意):
|
||||
* 从前 `parts.chip_type` 永久不列进这张卡的闸,理由是「它没有编辑手段,`mine:` 天然
|
||||
* 为空,列了就永久卡死」——那条理由**在 ②③ 两笔自投影加上之后不再成立**。
|
||||
* 而不列进闸的代价是真实存在的:「数据工单确认即入库」按 `gateLedgers` 整批并入正式表,
|
||||
* `parts.chip_type` 不在名单里,人新建的类型字典行就只留在草稿层、从不落地,
|
||||
* 引用它的 `parts.profile`/`parts.type_param` 却随名单一起落地 —— 一条悬空引用
|
||||
* (2026-08-30 真跑 e2e 撞见)。现在四张表都在闸上、也都会一起落地。
|
||||
*/
|
||||
/*
|
||||
* 第三次同一病(2026-08-27):措辞改了两遍照样复现 —— 台账里每个参数都有值,模型还是写了一行
|
||||
* 「使能阈值 · 原厂未披露」。参数值有没有是台账里的事实,不该由模型在清单里宣布:
|
||||
* S1 从此**一行参数都不写**,件上摆一张基座现读的「候选参数现值」表;说明书全文不再出现「使能阈值」。
|
||||
*/
|
||||
it('S1 不写参数行(item_key 不许以 unknown_ 开头);说明书全文没有「使能阈值」这个可照抄的名字', () => {
|
||||
const s1 = ASSESSMENT_STEPS.find((s) => s.item === 'S1_input_confirm')?.guide ?? '';
|
||||
expect(s1).toContain('候选参数一行都不写');
|
||||
expect(s1).toContain('不许以 unknown_ 开头');
|
||||
expect(s1).not.toContain('待确认');
|
||||
expect(NARRATIVE).not.toContain('使能阈值');
|
||||
for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toContain('使能阈值');
|
||||
const skeleton = NARRATIVE.slice(NARRATIVE.indexOf('⑤ 执行工单 01'), NARRATIVE.indexOf('"standard":"每一条「待补」行'));
|
||||
expect(skeleton).toContain('候选参数现值');
|
||||
expect(skeleton).not.toContain('待确认');
|
||||
it('卡:数据工单、四张表都在闸上(含 parts.chip_type/parts.type_param,Ruling H35)、且有 basis(基座拒发 basis 为空的工单)', () => {
|
||||
const card = acts[6];
|
||||
expect(card?.kind === 'card' && card.as).toBe('execution');
|
||||
expect(card?.kind === 'card' && card.variant).toBe('data');
|
||||
expect(card?.kind === 'card' && card.gateLedgers).toEqual(['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter']);
|
||||
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
|
||||
expect((card?.kind === 'card' ? card.guide ?? [] : []).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('S0 剧本写明都在库时数据工单两项本就不会进「当下可为」;S0d_p08_master 剧本写明 found 的一颗都不建', () => {
|
||||
const s0 = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
|
||||
expect(s0).toContain('当下可为');
|
||||
expect(s0).toMatch(/都在库/);
|
||||
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(s0d).toContain('absent');
|
||||
expect(s0d).toMatch(/found" 的一行都不建/);
|
||||
});
|
||||
|
||||
it('S0d_p08_master:查无记录一律派数据工单,不问人 —— 决策卡退役(2026-08-27 用户定:「只要有缺失就要自动派数据工单不需要人选择」)', () => {
|
||||
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(s0d).toMatch(/一律建,不问人/);
|
||||
expect(s0d).toMatch(/exists_status = "found" 的一行都不建/);
|
||||
expect(s0d).not.toMatch(/发一张决策卡问人/);
|
||||
expect(s0d).not.toMatch(/候选总数\s*≥\s*2/);
|
||||
});
|
||||
|
||||
it('s_bound 哨兵真的检查了「有料号不在库」这件事(都在库时门不该开;退回旧写法会红)', () => {
|
||||
expect(CMMN_XML).toContain('sub_eval_pn_check_exists_status_absent > 0');
|
||||
const sBoundBlock = /<sentry id="s_bound">[\s\S]*?<\/sentry>/.exec(CMMN_XML)?.[0] ?? '';
|
||||
expect(sBoundBlock).toContain('caseFileItemRef="sub_eval_pn_check_exists_status_absent"');
|
||||
expect(sBoundBlock).toContain('sub_eval_pn_check_exists_status_absent > 0');
|
||||
});
|
||||
|
||||
it('说明书与全部剧本里不会教模型对计划项调用 disable(允许出现在「不要 disable」这类禁止句里)', () => {
|
||||
// 只逮「disable + 计划项 id」这种祈使形(教模型真的去调用它);
|
||||
// 「不要…用 `disable`」「Disabled 只能从 Enabled 来」这类禁止句/状态名不含「disable + id」,不会被误伤。
|
||||
const disableCallPattern = /disable\s+(S\d[a-zA-Z_]*|T_release)/;
|
||||
expect(NARRATIVE).not.toMatch(disableCallPattern);
|
||||
for (const step of ASSESSMENT_STEPS) expect(step.guide, step.item).not.toMatch(disableCallPattern);
|
||||
});
|
||||
|
||||
it('S0d 拆成两项各自绑自己的 view(P-10 那份件不会永远推不出来)', () => {
|
||||
const p08 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master');
|
||||
const p10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document');
|
||||
expect(p08?.view).toBe('p08-master-create');
|
||||
expect(p10?.view).toBe('p10-document-create');
|
||||
});
|
||||
|
||||
/*
|
||||
* **两张数据工单的 doneWhen 只读入库事实**(2026-08-27 晚):引擎只在人答过卡之后求它 —— 值是人在件上填的、
|
||||
* 引擎数得出来,不再靠模型记得 finish(剧本写了两处它照样忘,件停在 P-08)。
|
||||
*/
|
||||
it('P-08:要建的每颗料主数据与参数都落了、必填不缺 → 收得掉;少建一颗 / 还缺必填 / 参数行对不上账 → 收不掉', () => {
|
||||
const ok = { parts_profile_rows: 2, [`${D}_pn_check_exists_status_absent`]: 2, [`${D}_pn_check_exists_status_created`]: 0, parts_profile_missing: 0, parts_parameter_missing: 0, parts_parameter_rows: 14, parts_parameter_distinct_param_name: 7 };
|
||||
expect(safeEval(doneOf('S0d_p08_master'), ok)).toBe(true);
|
||||
// 模型把其中一颗改成 created 也一样算(absent + created 才是要建的料数)
|
||||
expect(safeEval(doneOf('S0d_p08_master'), { ...ok, [`${D}_pn_check_exists_status_absent`]: 1, [`${D}_pn_check_exists_status_created`]: 1 })).toBe(true);
|
||||
expect(safeEval(doneOf('S0d_p08_master'), { ...ok, parts_profile_rows: 1, parts_parameter_rows: 7 })).toBe(false);
|
||||
expect(safeEval(doneOf('S0d_p08_master'), { ...ok, parts_profile_missing: 1 })).toBe(false);
|
||||
expect(safeEval(doneOf('S0d_p08_master'), { ...ok, parts_parameter_rows: 13 })).toBe(false);
|
||||
});
|
||||
|
||||
it('P-10:建过的每颗料都有一行资料记录且必填不缺 → 收得掉;资料少一行 / 骨架还空着 → 收不掉', () => {
|
||||
const ok = { parts_document_rows: 2, parts_profile_rows: 2, parts_document_missing: 0 };
|
||||
expect(safeEval(doneOf('S0d_p10_document'), ok)).toBe(true);
|
||||
expect(safeEval(doneOf('S0d_p10_document'), { ...ok, parts_document_rows: 1 })).toBe(false);
|
||||
expect(safeEval(doneOf('S0d_p10_document'), { ...ok, parts_document_missing: 3 })).toBe(false);
|
||||
});
|
||||
|
||||
it('S1 的入场哨兵不再等模型写 created 旗:入库事实齐了门就开', () => {
|
||||
const cond = /<sentry id="s_pns_resolved">[\s\S]*?<condition>([\s\S]*?)<\/condition>/.exec(CMMN_XML)?.[1] ?? '';
|
||||
expect(cond).not.toContain('sub_eval_pn_check_exists_status_absent = 0');
|
||||
expect(cond).toContain('sub_eval_pn_check_exists_status_absent + sub_eval_pn_check_exists_status_created');
|
||||
});
|
||||
|
||||
it('P-08 交回之后的收步不再靠模型记得 finish:剧本写明引擎按入库事实自己收,finish 只是省一拍(2026-08-27 晚:「顺序写死」两处模型照样忘)', () => {
|
||||
const s0d = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(s0d).toContain('finish S0d_p08_master');
|
||||
expect(s0d).toMatch(/引擎也会自己收/);
|
||||
expect(s0d).not.toMatch(/顺序不能反/);
|
||||
const s0d10 = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p10_document')?.guide ?? '';
|
||||
expect(s0d10).toMatch(/引擎按入库事实自己收/);
|
||||
});
|
||||
|
||||
it('「发卡前清单必须先有行」写在说明书里,不只在剧本里(2026-08-27 实测:模型把 P-10 收步与 S1 发卡压成一拍,那一拍 S1 剧本没摊开,卡发出去时 input_gap 一行都没有,人对着空表无从下手)', () => {
|
||||
expect(NARRATIVE).toContain('sub_eval.input_gap` 必须先有行');
|
||||
expect(NARRATIVE).toMatch(/压拍/);
|
||||
expect(NARRATIVE).toMatch(/顺序不能反/);
|
||||
});
|
||||
|
||||
it('存在性核对的剧本教的是「填得出依据才算在库」,不是「你看着办」(结构治,配 found_pn_ref 那道墙)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
|
||||
expect(g).toContain('found_pn_ref');
|
||||
expect(g).toMatch(/查无此值当场拒/);
|
||||
expect(g).toMatch(/名单里没有就是没有/);
|
||||
expect(g).toMatch(/把这一行改成 absent/);
|
||||
});
|
||||
|
||||
it('数据工单的名单只认 absent,「建哪几颗」与「要不要问人」分开写(2026-08-27 实测:原 PN 缺库那单,模型把在库的候选派成了数据工单,缺的那颗没人建)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(g).toMatch(/【名单】/);
|
||||
expect(g).toMatch(/一律建,不问人/);
|
||||
expect(g).toMatch(/exists_status = "found" 的一行都不建/);
|
||||
});
|
||||
|
||||
it('数据录入卡自带 basis,不让基座兜上内部家具(2026-08-27 实测:没声明 basis 的那张卡,参考信息栏摆的是「这一步 / 当下可为 / 目标缺口」加九个计划项全名)', () => {
|
||||
const i = NARRATIVE.indexOf('"kind":"entry"');
|
||||
expect(i).toBeGreaterThan(-1);
|
||||
const block = NARRATIVE.slice(i, i + 900);
|
||||
expect(block).toContain('"basis"');
|
||||
expect(block).toContain('触发原因');
|
||||
});
|
||||
|
||||
it('三张执行工单各自声明 gateLedgers —— 零输入卡不声明,闸就盯着与它无关的表(2026-08-27 e2e 实测:两张数据工单空手交卷都放行了)', () => {
|
||||
for (const [item, ledger] of [
|
||||
['芯片主数据创建', '"gateLedgers":["parts.profile","parts.parameter"]'],
|
||||
['原厂技术资料创建', '"gateLedgers":["parts.document"]'],
|
||||
['评估输入与资料补齐确认', '"gateLedgers":["sub_eval.input_gap"]'],
|
||||
] as const) {
|
||||
expect(NARRATIVE, item).toContain(ledger);
|
||||
}
|
||||
});
|
||||
|
||||
it('数据工单也要「先写骨架再发卡」写进说明书,且哨兵不认零行主数据(2026-08-27 e2e:模型压拍没写骨架就发卡,人打开是空表,零行同时骗过闸与哨兵,一路走到 S2 才被引用检查拦下)', () => {
|
||||
// 两张数据工单各要一条(P-08 的 profile/parameter、P-10 的 document)
|
||||
expect(NARRATIVE.match(/必须先有骨架行/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
|
||||
expect(NARRATIVE).toContain('parts.document` 必须先有骨架行');
|
||||
expect(NARRATIVE).toMatch(/压拍/);
|
||||
// 标了几颗建好,主数据就得有几行 —— 逐颗对账(第一刀写成「≥1 行就算数」太松,三候选那单从缝里过去了)
|
||||
expect(CMMN_XML).toContain('parts_profile_rows >= sub_eval_pn_check_exists_status_absent + sub_eval_pn_check_exists_status_created');
|
||||
});
|
||||
|
||||
it('评估输入那张卡的**前提**写在说明书的卡骨架上(2026-08-27 e2e:两颗缺库,模型只问只建了第一颗,CN 还 absent 就把 S1 的卡发了出去 —— 发卡的授权面是调人预算,绕得过哨兵)', () => {
|
||||
expect(NARRATIVE).toContain('一颗 absent 都不剩');
|
||||
expect(NARRATIVE).toMatch(/有几颗 absent 就要办几颗/);
|
||||
});
|
||||
|
||||
it('发数据录入卡那一拍不许顺手写需求行(客户还没落草稿层,引用检查必拒)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0_bind')?.guide ?? '';
|
||||
expect(g).toMatch(/那一拍只发卡/);
|
||||
expect(g).toContain('引用检查');
|
||||
});
|
||||
|
||||
it('absent 没清零之前不许收掉数据工单那一项(2026-08-27 e2e:建完第一颗就 finish,再想给第二颗发卡撞上「这一步已经办完收掉了」,连拒四次)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(g).toMatch(/还有没有 absent 的料号/);
|
||||
expect(g).toMatch(/这一项千万别 finish/);
|
||||
expect(g).toMatch(/收掉了就回不去了/);
|
||||
});
|
||||
|
||||
it('参数名一律中文、与库里既有行同名(2026-08-27 e2e:模型写成 input_voltage_range,而原 PN 是「输入电压范围」—— 主键的一半对不上,27 条里 14 条判成 UNKNOWN)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(g).toMatch(/参数名一律用中文/);
|
||||
// 措辞写过两遍都没拦住 → 上结构:把「同一套名字」变成基座算得出的数
|
||||
expect(CMMN_XML).toContain('parts_parameter_rows = parts_parameter_distinct_param_name * (sub_eval_pn_check_exists_status_absent + sub_eval_pn_check_exists_status_created)');
|
||||
expect(g).toContain('input_voltage_range');
|
||||
expect(g).toMatch(/不是同一行/);
|
||||
});
|
||||
|
||||
it('第二颗缺库的候选也要先问(每一颗都有被放弃的权利)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(g).toMatch(/不问人,直接建/);
|
||||
expect(g).not.toMatch(/这一颗也要先发一张决策卡问人/);
|
||||
});
|
||||
|
||||
it('created 认的是「卡交回来了」,不是「表里有行」—— 写骨架 ≠ 建好了(2026-08-27 e2e:模型铺完骨架就标 created、直接 finish,撞「从没给它发过卡」连拒五拍)', () => {
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S0d_p08_master')?.guide ?? '';
|
||||
expect(g).toMatch(/写骨架 ≠ 建好了/);
|
||||
expect(g).toMatch(/写骨架 → \*\*发卡\*\* → 这一拍结束 → 人交回/);
|
||||
expect(g).not.toMatch(/只有它在 \\`parts\.profile\\` 里真有一行/);
|
||||
});
|
||||
|
||||
it('件上三处样式毛病钉死(2026-08-27 用户:表显示不全不能右滑 / 编辑态内部料号只读字与输入框并排 / input 与 select 没对齐)', () => {
|
||||
const html = manifest.assets.viewTemplates.find((v) => v.name === 'p08-master-create')?.html ?? '';
|
||||
// 两种包装都要能横滑:验收件用目标件的 .tbl-wrap(它的验收版写着 overflow:hidden),2026-08-27 用户在结论审核页撞上
|
||||
// 运营侧的看板也在这条纪律里(它六张表都宽)
|
||||
for (const v of manifest.assets.viewTemplates) expect(v.html, v.name).toContain('.jc-tbl-wrap, .tbl-wrap { overflow-x: auto;');
|
||||
expect(html).toContain('#tbl1.on td > .jc-vcell:not(.ro) { display: none; }');
|
||||
expect(html).toContain('input.jc-cell, select.jc-cell { height: 32px;');
|
||||
// 徽标不再包 .ro(编辑态要随只读格一起藏,只留下拉框)
|
||||
expect(html).not.toContain('<span class="jc-vcell ro"><span class="stx"');
|
||||
});
|
||||
|
||||
it('表头与「字段」格只留中文:没有英文列名、没有 .thd 小字 —— 五张件一律(2026-08-27 用户在数据工单页、审核页各指出一次)', () => {
|
||||
for (const v of manifest.assets.viewTemplates) {
|
||||
expect(v.html, v.name).not.toContain('class="thd"');
|
||||
for (const th of v.html.match(/<th[^>]*>[^<]*<\/th>/g) ?? []) expect(th, `${v.name} th`).not.toMatch(/[a-z]_[a-z]/);
|
||||
for (const td of v.html.match(/<td class="cust">[^<]*<\/td>/g) ?? []) expect(td, `${v.name} 字段格`).not.toMatch(/[a-z]_[a-z]/);
|
||||
}
|
||||
});
|
||||
|
||||
it('审核通过与否读基座数 decision_last,不由模型自写的旗说了算;驳回分支明写不许写旗、不许 finish(2026-08-27 实测:人答「重做」它照样推到放行)', () => {
|
||||
expect(CMMN_XML).toContain('decision_last = "approve"');
|
||||
expect(CMMN_XML).toContain('<caseFileItem id="decision_last"');
|
||||
const g = ASSESSMENT_STEPS.find((s) => s.item === 'S4_review')?.guide ?? '';
|
||||
expect(g).toMatch(/不许写 review_approved_total、不许 finish/);
|
||||
expect(g).toMatch(/ask 的开头必须写明这一轮改了什么/);
|
||||
});
|
||||
|
||||
it('五段进度,每段名 ≤10 字;除放行外每项恰属一段', () => {
|
||||
expect(ASSESSMENT_PHASES).toHaveLength(5);
|
||||
for (const p of ASSESSMENT_PHASES) expect(p.name.length, p.name).toBeLessThanOrEqual(10);
|
||||
const all = ASSESSMENT_PHASES.flatMap((p) => p.items);
|
||||
expect(new Set(all).size).toBe(all.length);
|
||||
expect([...all].sort()).toEqual([...ITEM_IDS.filter((i) => i !== 'T_release')].sort());
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 人工验收清单(2026-08-27 用户定):验收卡上的条目是人核这份工单要走的步骤,不是系统判的判据;
|
||||
* 每份验收工单固定 —— 差异评估结论审核(S4_review 审核卡)与评审放行与落账确认(T_release 终局验收卡)各一份。
|
||||
*/
|
||||
describe('两张验收工单的人工验收清单', () => {
|
||||
it('S4_review 与 T_release 各声明一份固定清单:至少 4 条,每条是一句可核对的动作,互不重复', () => {
|
||||
for (const item of ['S4_review', 'T_release']) {
|
||||
const list = ASSESSMENT_STEPS.find((s) => s.item === item)?.acceptanceChecklist ?? [];
|
||||
expect(list.length, item).toBeGreaterThanOrEqual(4);
|
||||
expect(new Set(list).size, item).toBe(list.length);
|
||||
for (const line of list) expect(line.length, `${item}: ${line}`).toBeGreaterThan(12);
|
||||
}
|
||||
// 别的步骤不发验收卡,不该有清单
|
||||
for (const step of ASSESSMENT_STEPS) {
|
||||
if (step.item === 'S4_review' || step.item === 'T_release') continue;
|
||||
expect(step.acceptanceChecklist, step.item).toBeUndefined();
|
||||
it('发卡条件与 doneWhen 是同一件事的正反面', () => {
|
||||
const when = acts[6]?.kind === 'card' ? (acts[6].when ?? '') : '';
|
||||
for (const reading of [
|
||||
'sub_eval_part_exists_status_found', 'sub_eval_part_missing',
|
||||
'sub_eval_part_distinct_chip_type_id', 'parts_profile_missing', 'parts_parameter_missing',
|
||||
]) {
|
||||
expect(when).toContain(reading);
|
||||
expect(stepOf('S1_inputs')?.doneWhen).toContain(reading);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 评估输入清单每行六列都要写(2026-08-27 实测:模型 11 行全漏了必填列 input_item,件上红带点名「缺输入项」,
|
||||
* 人把别的格填满也过不了闸;而剧本从前只在括号里提过一次 input_item,逐行的做法里没再说)。
|
||||
describe('S2 配方 —— AI 只出场一次', () => {
|
||||
const recipe = stepOf('S2_assess')?.auto;
|
||||
const acts = recipe?.do ?? [];
|
||||
|
||||
it('六笔:分析 → 关系(先摆一遍满足 refersTo)→ 明细 → 关系(重算)→ 验证记录 → 交代;analyze 恰好一笔', () => {
|
||||
expect(acts.map((a) => a.kind)).toEqual(['analyze', 'project', 'project', 'project', 'project', 'say']);
|
||||
expect(acts.filter((a) => a.kind === 'analyze')).toHaveLength(1);
|
||||
/* 两次 project 到 substitution.relation 是同一个引用(RELATION_PROJECTION),不是抄了两份 */
|
||||
expect(acts[1]).toBe(acts[3]);
|
||||
expect(acts[1]?.kind === 'project' && acts[1].into).toBe('substitution.relation');
|
||||
expect(acts[2]?.kind === 'project' && acts[2].into).toBe('substitution.difference_item');
|
||||
});
|
||||
|
||||
it('**行由字典决定,不由 AI 决定** —— 明细 each 的是 type_param,不是 result:items', () => {
|
||||
const items = acts[2];
|
||||
expect(items?.kind === 'project' && items.from).toMatchObject({ each: 'parts.type_param' });
|
||||
/*
|
||||
* **`: undefined`,不是 `: {}`**(2026-08-29,评审 fix round 1 逼出来的第二处修正)——
|
||||
* `SomeShape | {}` 在按方括号取值时会被 TS 整体收窄成裸 `{}`(`{}` 是任何对象的父类型,
|
||||
* 联合化简会把更具体的那一支吃掉),于是**全部**字段访问失去编译期保护、报
|
||||
* 「不能索引类型 {}」,与 `stepOf` 标没标 `Extract` 无关(受控实验见任务报告)。
|
||||
* `: undefined` 不会被这样吃掉,后续按 `?.[key]` 取值,与 S1 既有的
|
||||
* `:164`/`:172`/`:175` 三处同一写法 —— 断言真跑到的值不变(条件为真时 `?.` 无副作用)。
|
||||
*/
|
||||
describe('S1 清单:每行六列都要写', () => {
|
||||
it('剧本逐行写明 input_item 的人读名与 source_ledger,并让模型发卡前核 _missing = 0', () => {
|
||||
const s1 = ASSESSMENT_STEPS.find((s) => s.item === 'S1_input_confirm')?.guide ?? '';
|
||||
expect(s1).toContain('每一行六列都要写');
|
||||
for (const name of ['customer「客户」', 'request「客户需求」', 'original_pn「原 PN」', 'application_scenario「应用场景」', 'bom_position「BOM 位」', 'original_doc_version「原 PN 资料版本」']) {
|
||||
expect(s1, name).toContain(name);
|
||||
const fields = items?.kind === 'project' ? items.fields : undefined;
|
||||
/* AI 的出参只是一张查表:漏一条,那一格就是空 —— required 立刻把它变成 _missing > 0 */
|
||||
expect(fields?.['difference_status']).toMatchObject({ lookup: 'result:items', take: 'difference_status' });
|
||||
expect(fields?.['impact_statement']).toMatchObject({ lookup: 'result:items' });
|
||||
expect(fields?.['evidence_note']).toMatchObject({ lookup: 'result:items' });
|
||||
});
|
||||
|
||||
it('**值不取 AI 出参,一律 lookup 台账**', () => {
|
||||
const fields = acts[2]?.kind === 'project' ? acts[2].fields : undefined;
|
||||
for (const col of ['original_value', 'candidate_value'] as const) {
|
||||
expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' });
|
||||
}
|
||||
/* 约束类型来自字典这一行本身,不是 AI 说的 */
|
||||
expect(fields?.['constraint_type']).toEqual({ col: 'constraint_type' });
|
||||
});
|
||||
|
||||
it('judgement_class 是合成列:约束类型 × 四态,规则写在配方里', () => {
|
||||
const fields = acts[2]?.kind === 'project' ? acts[2].fields : undefined;
|
||||
const jc = JSON.stringify(fields?.['judgement_class']);
|
||||
expect(jc).toContain('hard_mismatch');
|
||||
expect(jc).toContain('soft_mismatch');
|
||||
/* 顺序要紧:先认四态,最后才按约束类型分硬软 */
|
||||
expect(jc.indexOf('"match"')).toBeLessThan(jc.indexOf('hard_mismatch'));
|
||||
});
|
||||
|
||||
it('推荐等级读 judgement_class 的 tally 计数 —— 不读 AI 出参、不读自报列', () => {
|
||||
// acts[1] 与 acts[3] 是同一个 RELATION_PROJECTION 引用,查哪个都一样
|
||||
const rel = acts[1]?.kind === 'project' ? acts[1].fields : undefined;
|
||||
const level = JSON.stringify(rel?.['recommendation_level']);
|
||||
expect(level).toContain('substitution_difference_item_judgement_class_hard_mismatch > 0');
|
||||
expect(level).toContain('not_recommended');
|
||||
/* 硬约束不符必须直接落到「不推荐」,排在最前 */
|
||||
expect(level.indexOf('hard_mismatch')).toBeLessThan(level.indexOf('unknown'));
|
||||
});
|
||||
|
||||
it('P-06 一单固定一条,状态 not_started —— 本活动只写「还得去验什么」,不虚构实测结果', () => {
|
||||
const val = acts[4];
|
||||
expect(val?.kind === 'project' && val.into).toBe('validation.record');
|
||||
expect(val?.kind === 'project' && val.from).toEqual({ once: true });
|
||||
const fields = val?.kind === 'project' ? val.fields : undefined;
|
||||
expect(fields?.['validation_status']).toBe('not_started');
|
||||
expect(fields?.['validation_type']).toBe('parametric');
|
||||
});
|
||||
|
||||
it('提示词:三条硬规矩都写进去了,且不引任何真实料号与参数名', () => {
|
||||
const analyze = acts[0];
|
||||
const prompt = analyze?.kind === 'analyze' ? analyze.instruction : '';
|
||||
expect(prompt).toContain('不要复述');
|
||||
expect(prompt).toContain('unknown');
|
||||
expect(prompt).toContain('硬约束');
|
||||
for (const forbidden of ['LM2596S', 'XL2596S', 'AMS1117', '使能阈值', '输入电压范围']) {
|
||||
expect(prompt).not.toContain(forbidden);
|
||||
}
|
||||
/* 出参 schema 顶层是对象,数组字段名与 into 对得上 */
|
||||
const out = analyze?.kind === 'analyze' ? analyze.output : undefined;
|
||||
expect(out?.['type']).toBe('object');
|
||||
expect(analyze?.kind === 'analyze' && analyze.into).toBe('items');
|
||||
});
|
||||
|
||||
it('AI 的入参**每一条都带说明** —— 参数名本身说不清「它为什么要紧」', () => {
|
||||
const analyze = acts[0];
|
||||
const fields = analyze?.kind === 'analyze' ? analyze.input.fields : undefined;
|
||||
expect(fields?.['param_desc']).toEqual({ col: 'param_desc' });
|
||||
expect(fields?.['constraint_type']).toEqual({ col: 'constraint_type' });
|
||||
for (const col of ['original_value', 'candidate_value'] as const) {
|
||||
expect(fields?.[col]).toMatchObject({ lookup: 'parts.parameter' });
|
||||
}
|
||||
expect(s1).toContain(`${D}_input_gap_missing`);
|
||||
});
|
||||
});
|
||||
|
||||
/* 放行那一步只 submit,不发工单(2026-08-27 实测:先一张执行单「评审放行与落账确认」,人答完才 submit 又一张验收单) */
|
||||
describe('T_release 只 submit', () => {
|
||||
it('剧本写明不发执行工单 / 决策卡', () => {
|
||||
const t = ASSESSMENT_STEPS.find((s) => s.item === 'T_release')?.guide ?? '';
|
||||
expect(t).toContain('不发执行工单');
|
||||
expect(t).toContain('submit');
|
||||
describe('S3 / S4 配方', () => {
|
||||
const s3 = stepOf('S3_review')?.auto?.do ?? [];
|
||||
const s4 = stepOf('T_release')?.auto?.do ?? [];
|
||||
const s2 = stepOf('S2_assess')?.auto?.do ?? [];
|
||||
|
||||
it('S3 五笔:差异明细重算 → 关系 → 验证记录 → 卡 → 交代 —— 人改的四态先落地,结论才有真数据可读', () => {
|
||||
expect(s3.map((a) => a.kind)).toEqual(['project', 'project', 'project', 'card', 'say']);
|
||||
expect(s3[0]?.kind === 'project' && s3[0].into).toBe('substitution.difference_item');
|
||||
expect(s3[1]?.kind === 'project' && s3[1].into).toBe('substitution.relation');
|
||||
expect(s3[2]?.kind === 'project' && s3[2].into).toBe('validation.record');
|
||||
});
|
||||
|
||||
/*
|
||||
* **这是 2026-08-30 task-12「结论纹丝不动」缺陷的回归钉**:S3 从前直接从
|
||||
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 起步,人改的 `difference_status`
|
||||
* 没有任何一笔把它转译成 `judgement_class`,两笔投影读到的 tally 纹丝不动。
|
||||
* `s3[0]` 必须是**排在关系 / 验证记录投影之前**的差异明细重算笔。
|
||||
*/
|
||||
it('差异明细重算笔:只列主键与两个派生列,不碰 difference_status 自己、也不碰人能改的叙事列', () => {
|
||||
const recompute = s3[0];
|
||||
expect(recompute?.kind === 'project' && recompute.from).toEqual({ each: 'mine:substitution.difference_item' });
|
||||
/* `recompute` 经 `s3[0]` 的 tuple 定位精确 narrow 到这一笔自己的字面量类型,它压根没有
|
||||
`prune` 这个键(不是「值为 undefined」,是键不存在)—— `in` 判存在性,不做属性访问 */
|
||||
expect('prune' in (recompute ?? {})).toBe(false);
|
||||
const fields = recompute?.kind === 'project' ? recompute.fields : undefined;
|
||||
/* 只有两把主键(定位这一行)+ 两个派生列(judgement_class/risk_level)—— 一个都不多写:
|
||||
difference_status / impact_statement / evidence_note / unknown_disposition(人可以就地改的那几列)
|
||||
一个都不在这份 fields 里,靠 draftWrite 的稀疏 patch 语义保证不被这一笔碰到。 */
|
||||
expect(Object.keys(fields ?? {}).sort()).toEqual(['difference_item_id', 'judgement_class', 'risk_level', 'substitution_relation_id']);
|
||||
});
|
||||
|
||||
/*
|
||||
* **判定分类 / 风险等级的 when 链只许有一处真源**:S3 的重算笔与 S2 差异明细创建笔的
|
||||
* `judgement_class`/`risk_level` 必须是**同一个对象引用**(都来自 `...JUDGEMENT_DERIVED_FIELDS`
|
||||
* 展开)——各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的规则。
|
||||
*/
|
||||
it('S3 重算笔与 S2 差异明细创建笔共用同一份 judgement_class / risk_level when 链', () => {
|
||||
const s3Fields = s3[0]?.kind === 'project' ? s3[0].fields : undefined;
|
||||
const s2Fields = s2[2]?.kind === 'project' ? s2[2].fields : undefined;
|
||||
expect(s2Fields?.['judgement_class']).toBeDefined();
|
||||
expect(s3Fields?.['judgement_class']).toBe(s2Fields?.['judgement_class']);
|
||||
expect(s3Fields?.['risk_level']).toBe(s2Fields?.['risk_level']);
|
||||
});
|
||||
|
||||
it('**S2 与 S3 用的是同一份投影** —— 各写一份早晚分叉', () => {
|
||||
// S2 的 do 数组里 RELATION_PROJECTION 出现两次(acts[1]/acts[3],同一个引用)——
|
||||
// 与 S3 比对随便挑一个都行,这里挑 acts[3](紧跟在差异明细之后、值是真的那一次)
|
||||
expect(s3[1]).toBe(s2[3]);
|
||||
expect(s3[2]).toBe(s2[4]);
|
||||
});
|
||||
|
||||
/*
|
||||
* **这一条读的是真 `case-model.ts`,不是任何镜像**(2026-08-29 复审 Important fix round 2)——
|
||||
* `w05-s3-review-recompute.test.ts` 证明的是机制(S3 少了 `let`,复用的投影主键就算不出来,
|
||||
* 写会被静默丢掉、或拼出一行垃圾);但那份测试手写镜像、不 import 这份真文件(架构上也不能),
|
||||
* 红绿与 `case-model.ts` 此刻写的是什么**完全脱钩**——明天有人把 `S3_review.auto.let` 删掉,
|
||||
* 原样重犯这次的 bug,`AutoRecipe.let` 是可选字段(typecheck 不报),镜像测试也不会变红。
|
||||
* 这一条补上「读真文件」那一半:S3 与 S2 的 `auto.let` 必须是**同一个引用**,少了就在这里当场炸。
|
||||
*/
|
||||
it('S3 与 S2 共用同一个 `let` 引用 —— 少了它,复用的投影主键在 S3 那一拍就是未绑定的空串', () => {
|
||||
expect(stepOf('S3_review')?.auto?.let).toBe(stepOf('S2_assess')?.auto?.let);
|
||||
});
|
||||
|
||||
it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
|
||||
const card = s3[3];
|
||||
expect(card?.kind === 'card' && card.as).toBe('decide');
|
||||
expect(card?.kind === 'card' && card.variant).toBe('review');
|
||||
expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1);
|
||||
expect((card?.kind === 'card' ? card.options ?? [] : [])[0]?.value).toBe('approve');
|
||||
expect((card?.kind === 'card' ? card.basis ?? [] : []).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('S4 两笔:写结论一句话 → 提交放行', () => {
|
||||
expect(s4.map((a) => a.kind)).toEqual(['project', 'submit']);
|
||||
expect(s4[0]?.kind === 'project' && s4[0].into).toBe('sub_eval.case');
|
||||
expect(s4[1]?.kind === 'submit' && typeof s4[1].rationale !== 'undefined').toBe(true);
|
||||
});
|
||||
|
||||
it('`submit` 所在的步声明了人工验收清单 —— 系统提交不比模型提交宽松', () => {
|
||||
expect((stepOf('T_release')?.acceptanceChecklist ?? []).length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('两步都不给 doneWhen —— 收步由人答卡决定', () => {
|
||||
expect(stepOf('S3_review')?.doneWhen).toBeUndefined();
|
||||
expect(stepOf('T_release')?.doneWhen).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,13 @@
|
||||
/** 清单与案例模型共用的常量 —— 单独成文件,免得 manifest ↔ case-model 循环 import 撞 TDZ。 */
|
||||
export const ACTIVITY_ID_TEXT = 'part-substitution-assessment';
|
||||
/** 活动自持单据的域名 */
|
||||
/** 活动自持单据(案卷)的域名 */
|
||||
export const D = 'sub_eval';
|
||||
/** GOV 差异规则版本 —— 随包冻结,随每条结论落账(rule_version) */
|
||||
export const RULE_VERSION = 'R-2026.07';
|
||||
export const TOOL_COMPARE = 'part-substitution-assessment.compare-parameters';
|
||||
export const TOOL_RECOMMEND = 'part-substitution-assessment.recommend';
|
||||
/*
|
||||
* `TOOL_COMPARE` / `TOOL_RECOMMEND` 两个常量已随 2026-08-29 的整体重写退役
|
||||
* (spec `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §1/§8):
|
||||
* 逐项四态改由 S2 唯一一次 `analyze` 出场判定,推荐等级改由配方按 §3.5 的规则算 ——
|
||||
* 两枚工具已在 Task 10 于工具注册表里停用(墓志铭见 `apps/api/src/app/tools/first-party.ts`)。
|
||||
* 案例模型(`case-model.ts`)全程零工具调用,没有第二处字面量引用留着。
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* ACT-WORK-05 · 差异评估 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
|
||||
*
|
||||
* 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
|
||||
* 案例模型 + 说明书 + 步骤剧本 + 三条技能 + 台账声明 + 五张 HTML 报告模版。
|
||||
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
|
||||
* 案例模型 + 说明书 + 步骤剧本(全 `auto`,零 `guide`)+ 台账声明 + 三张 HTML 报告模版
|
||||
* (**0 条技能** —— 2026-08-29 整体重写后全程无 agent 临场编排,技能戳随两枚工具、
|
||||
* 两张 DMN 一并退役)。这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
|
||||
* 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
|
||||
*/
|
||||
import { defineActivity } from '@sdk';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* ACT-WORK-05 差异评估的技能 —— 载方法,不载权力。
|
||||
* 剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、拿不准时按哪条走」。
|
||||
*/
|
||||
import { skillIdSchema } from '@contracts';
|
||||
import type { Skill } from '@contracts';
|
||||
|
||||
export const ASSESSMENT_SKILLS: Skill[] = [
|
||||
{
|
||||
id: skillIdSchema.parse('substitution.normalize-parameters'),
|
||||
label: '把多来源资料整成可比对的标准参数对照表',
|
||||
applicability: '输入确认之后、逐项比对之前:原 PN 与每个候选的参数集要摆成同名同单位的一对一。',
|
||||
method: [
|
||||
'参数集是独立子表 `parts.parameter`(P-08 的结构子表),一行一参数,按 `internal_pn` 归属主记录:列是 param_name / param_value / param_unit / constraint_type / param_note。',
|
||||
'对照表按**原 PN 名下的那几行参数名**为骨架:原 PN 有的参数,候选名下没有同名那一行 → 候选值写「未披露」(它会成为 UNKNOWN);',
|
||||
'候选多出来的参数不比(比对只回答「能不能替」,多出来的能力不构成差异)。',
|
||||
'单位以原 PN 那一行的 param_unit 为准:候选单位不同先换算(mV→V、kHz→MHz),换算不了就不改值、在说明里写明 —— 不许静默改数。',
|
||||
'约束类型以原 PN 那一行的 constraint_type 为准;原 PN 没标的按 GOV 口径:封装 / 工艺 / 电气边界(电压 / 电流 / 温度 / 阈值)一律 hard,其余 soft。',
|
||||
'**缺参数不得伪造**:查不到的写「未披露」,不写估计值、不写「约」。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['标准化参数子表结构', '硬/软约束口径'],
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('substitution.difference-judgement'),
|
||||
label: '逐项四态由工具算,证据与影响由你写',
|
||||
applicability: '对照表齐了,要出 MATCH / MISMATCH / UNKNOWN / CONDITIONAL 时。',
|
||||
method: [
|
||||
'四态**不许自己判**:调 `part-substitution-assessment.compare-parameters`,把每个候选的全部参数对一次性传进去(candidates 数组,一次一批)。',
|
||||
'每对参数都带 `evidenceRef`(值从哪份记录来:「P-08」「P-10 Rev H §6.5」「数据工单 <单号> · 确认人 <人>」)——硬约束没有证据引用,工具不判 MATCH。',
|
||||
'客户在应用场景里给了边界(如「输入上限 40V」)就写进那一对的 `requirement`(`>= 40`),候选不满足直接 MISMATCH。',
|
||||
'工具出参照抄进 `substitution.difference_item`:difference_status / compare_result / risk_level 一字不改;',
|
||||
'`impact_statement` 你写:MATCH 写「无」,其余说清对应用 / 验证 / 报价的影响;UNKNOWN 必须写 `unknown_disposition`(挂验证任务号或补来源动作),不许悬空。',
|
||||
'推荐等级同样由工具定(`part-substitution-assessment.recommend`,传每个候选的 counts),`recommendation_reason` 照工具的 reason 扩写,不许把不推荐改成推荐。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['四态口径', '推荐等级四档'],
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
id: skillIdSchema.parse('substitution.landing'),
|
||||
label: '落账只写分析层,客户批准不碰',
|
||||
applicability: '审核通过之后拟 P-11 / 回写 P-01 时。',
|
||||
method: [
|
||||
'`analysis_status` 从 `ai_draft` 推到 `fae_confirmed`;`customer_approval_status` 由独立审批流维护 —— 本活动**没有这一列的写权**,写了会被拒。',
|
||||
'P-01 回写五项:risk_level(取各候选里最高的)、verification_requirement(把所有验证任务号列全)、output_ref(替代分析报告号)、next_action(「提交 ACT-GATE-03 评审放行」)、current_stage 写 `gate03`。',
|
||||
'规则版本随结论落账:每条 P-11 主记录与差异明细都写 rule_version(工具出参的 ruleVersion),中途规则更新不影响进行中的评估。',
|
||||
'被打回重做时改的是**台账本身**(理由 / 依据 / 去向),不是把同一份原样再递一遍。',
|
||||
].join('\n'),
|
||||
crystallized: [],
|
||||
knowledgeRefs: ['analysis_status 与 customer_approval_status 分离'],
|
||||
version: '1.0.0',
|
||||
},
|
||||
];
|
||||
@@ -1,52 +1,74 @@
|
||||
/**
|
||||
* 运营中心 L2 沙箱视图 —— ACT-WORK-05「关键台账数据」看板(2026-08-27)。
|
||||
* 运营中心 L2 沙箱视图 —— ACT-WORK-05「关键台账数据」看板(2026-08-27 初版,2026-08-29 Task 9 重绑)。
|
||||
*
|
||||
* 版式与样式逐字节搬自 `docs/ss02-act-05/ACT-WORK-05-关键台账数据.html`(只改两处:`.tbl-wrap` 放开横滑;
|
||||
* 徽标文案由 `data-class-of` 投影 + 生成式 `::after` 出,不再写死)。溯源口径照 `…-数据溯源.md`:
|
||||
* 页头 / P-01 一条 / P-08 逐颗 + 参数瓷贴(一格一列,不叠灰字次列 —— 2026-08-27 用户定)/ P-11 主记录 + 差异明细 / P-10(同样一格一列)/ P-06(本单 + 同料号历史)。
|
||||
* **2026-08-29 Task 9「运营中心看板重绑」**:2026-08-29 整体重写(spec
|
||||
* `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`)把九步案例模型砍成四步、
|
||||
* 台账从旧的 `sub_eval.pn_check`/`sub_eval.verdict` 三张单据 + `demand.request`/`customer.profile`/
|
||||
* `parts.document` 五张外域表,换成本活动自持的 `sub_eval.case`/`sub_eval.part` 两张 + 新增的
|
||||
* `parts.chip_type`/`parts.type_param` 两张类型字典 —— 这份看板原样引着已退役的五张地址,
|
||||
* 装包会被静态审拦下。本轮把 `LEDGER_BOARD_DATA` 与 `LEDGER_BOARD` 一并**重绑**到新九张表:
|
||||
* 案卷(`sub_eval.case`)→ 两颗料(`sub_eval.part`)→ 类型与字段字典(`parts.chip_type`/
|
||||
* `parts.type_param`)→ 参数对照(`parts.profile`/`parts.parameter`)→ 关系与明细
|
||||
* (`substitution.relation`/`substitution.difference_item`)→ P-06(`validation.record`,
|
||||
* 含同料号历史 `validation.history`)。P-01 需求主台账 / P-10 原厂资料两个板块随之整段撤下 ——
|
||||
* 本活动这次重写已经不碰这两张表(见 manifest.ts「这个活动不碰 P-01 / P-10 / 客户档案」)。
|
||||
* 新设计是**一次评估恰一对一候选**(不再是「多候选,部分放弃」),`substitution.relation`
|
||||
* 一单一行、`sub_eval.part` 一单两行(原/候选各一),「候选」不再是需要 `data-rows` 逐个复制的
|
||||
* 集合,页头 / P-08 两处原来的 `.cands`(多候选并排)、`.aband`(放弃候选的候选行)两个概念
|
||||
* 一并撤下 —— 新模型里没有「本轮放弃的候选」这件事。
|
||||
*
|
||||
* 版式与样式**沿用**(重绑不是重做):页头 / 数据来源图例 / 分层图例 / 表格样式 / 三态横幅
|
||||
* 这几段版面语言原样保留;`.tbl-wrap` 放开横滑、徽标文案由 `data-class-of` 投影 + 生成式
|
||||
* `::after` 出的既有约定不变。溯源口径参照 `…-数据溯源.md`(该文档写在旧九步年代,
|
||||
* 域名对照以本文件为准)。
|
||||
*
|
||||
* **它摆的是最近一次已放行实例的落账快照**(用户定):行由基座按 `LEDGER_BOARD_DATA` 的声明从**正式表**圈出 ——
|
||||
* 每个 `data-rows` 键都是声明里的一个 `as`,不是整表;`<as>.<列>` 是那一集合首行的标量,`<as 去符号>_rows` /
|
||||
* `_<列>_<值>` 是基座数出来的读数(`apps/api/src/app/activity-ops/view-scope.ts`)。模版仍**不算不判**:
|
||||
* MATCH 行淡显、放弃的候选、三态横幅,全是 `data-class-of` 投出的 class 交样式表。
|
||||
* MATCH 行淡显、三态横幅,全是 `data-class-of` 投出的 class 交样式表。
|
||||
* 无脚本、无外链(过安装静态审);报高脚本由服务端出口注入。
|
||||
*/
|
||||
import type { ViewData } from '@contracts';
|
||||
import { badgeCss } from './badge.ts';
|
||||
import { D } from '../constants.ts';
|
||||
|
||||
/** 案号 `W05-<开案时刻 14 位>`(与说明书让模型铸 `eval_ref` 的写法同源);行集按案号 → 核对表 → 料号 / 需求逐表圈 */
|
||||
/**
|
||||
* 案号 `W05-<开案时刻 14 位>`(与说明书让模型铸 `eval_ref` 的写法同源);行集按案号 → 案卷 →
|
||||
* 两颗料 → 类型字典(按案卷锁定的 `chip_type_id` 查)→ 主数据 / 参数(按两颗料的 `internal_pn` 查)→
|
||||
* 关系(按案号查,一单一行)→ 明细(按关系的主键查,`by.from` 必须指向**前面**已求出的集合 ——
|
||||
* 明细依赖关系的主键,所以 `rows` 数组里关系排在明细**之前**,即便「关系」是这一签最终的结论、
|
||||
* 「明细」是支撑结论的逐条证据,版面上想先摆哪个都行,数据依赖顺序是另一件事)→ P-06(按案号查,
|
||||
* 一单固定一条;同料号历史另开一集,按两颗料的 `internal_pn` 查、排除本单自己那一条)。
|
||||
*/
|
||||
export const LEDGER_BOARD_DATA: ViewData = {
|
||||
case: { ledger: `${D}.verdict`, key: 'eval_ref', prefix: 'W05-' },
|
||||
case: { ledger: `${D}.case`, key: 'eval_ref', prefix: 'W05-' },
|
||||
rows: [
|
||||
{ as: `${D}.verdict`, ledger: `${D}.verdict`, by: { column: 'eval_ref', from: 'case' } },
|
||||
/* 原 PN 排第一(original > candidate),下游按料号圈的表都随这个顺序 */
|
||||
{ as: `${D}.pn_check`, ledger: `${D}.pn_check`, by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }, { field: 'internal_pn', dir: 'asc' }] },
|
||||
{ as: 'demand.request', ledger: 'demand.request', by: { column: 'request_id', from: `${D}.verdict.request_id` } },
|
||||
{ as: 'customer.profile', ledger: 'customer.profile', by: { column: 'customer_code', from: 'demand.request.customer_code' } },
|
||||
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: `${D}.pn_check.internal_pn` } },
|
||||
{ as: 'parts.parameter', ledger: 'parts.parameter', by: { column: 'internal_pn', from: `${D}.pn_check.internal_pn` }, orderBy: [{ field: 'param_name', dir: 'asc' }] },
|
||||
{ as: 'parts.document', ledger: 'parts.document', by: { column: 'internal_pn', from: `${D}.pn_check.internal_pn` }, orderBy: [{ field: 'doc_seq', dir: 'asc' }] },
|
||||
{ as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'substitution_relation_id', dir: 'asc' }] },
|
||||
{ as: `${D}.case`, ledger: `${D}.case`, by: { column: 'eval_ref', from: 'case' } },
|
||||
{ as: `${D}.part`, ledger: `${D}.part`, by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }, { field: 'internal_pn', dir: 'asc' }] },
|
||||
{ as: 'parts.chip_type', ledger: 'parts.chip_type', by: { column: 'type_id', from: `${D}.case.chip_type_id` } },
|
||||
{ as: 'parts.type_param', ledger: 'parts.type_param', by: { column: 'type_id', from: `${D}.case.chip_type_id` }, orderBy: [{ field: 'param_name', dir: 'asc' }] },
|
||||
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: `${D}.part.internal_pn` } },
|
||||
{ as: 'parts.parameter', ledger: 'parts.parameter', by: { column: 'internal_pn', from: `${D}.part.internal_pn` }, orderBy: [{ field: 'param_name', dir: 'asc' }] },
|
||||
{ as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' } },
|
||||
{
|
||||
as: 'substitution.difference_item',
|
||||
ledger: 'substitution.difference_item',
|
||||
by: { column: 'substitution_relation_id', from: 'substitution.relation.substitution_relation_id' },
|
||||
orderBy: [{ field: 'difference_item_id', dir: 'asc' }],
|
||||
},
|
||||
{ as: 'validation.record', ledger: 'validation.record', by: { column: 'request_id', from: `${D}.verdict.request_id` }, orderBy: [{ field: 'validation_id', dir: 'asc' }] },
|
||||
/* 同料号在别单 / 别处留下的验证记录:本次评估的历史证据引用(目标件 P-06 里 VAL-2026-0040 / 0032 那两行的位置) */
|
||||
{ as: 'validation.record', ledger: 'validation.record', by: { column: 'eval_ref', from: 'case' } },
|
||||
/* 同料号在别单 / 别处留下的验证记录:本次评估的历史证据引用,排除本单自己那一条 */
|
||||
{
|
||||
as: 'validation.history',
|
||||
ledger: 'validation.record',
|
||||
by: { column: 'validation_internal_pn', from: `${D}.pn_check.internal_pn` },
|
||||
not: { column: 'request_id', from: `${D}.verdict.request_id` },
|
||||
by: { column: 'validation_internal_pn', from: `${D}.part.internal_pn` },
|
||||
not: { column: 'eval_ref', from: 'case' },
|
||||
orderBy: [{ field: 'validation_id', dir: 'desc' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/* ==================== 目标件样式(逐字节搬;两处改动标了 [改]) ==================== */
|
||||
/* ==================== 目标件样式(基座壳与版面语言沿用旧版;域名相关内容按新九张表改写) ==================== */
|
||||
const TARGET_CSS = `
|
||||
:root{
|
||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
||||
@@ -80,15 +102,14 @@ body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,
|
||||
.dot.full{background:var(--brand)}
|
||||
.dot.half{background:var(--amber)}
|
||||
|
||||
/* 分层图例 */
|
||||
.layers{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin:14px 0 18px}
|
||||
/* 分层图例 —— 新九张表收成四层(P-01/P-10 两层随重写撤下) */
|
||||
.layers{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:14px 0 18px}
|
||||
.layer{background:#fff;border:1px solid var(--line-soft);border-radius:11px;padding:11px 14px;font-size:12px;color:var(--ink-2);border-top:3px solid var(--brand)}
|
||||
.layer b{display:block;font-size:12.5px;color:var(--ink);margin-bottom:2px}
|
||||
.layer .lg{font-family:var(--mono);font-weight:800;color:var(--brand-deep)}
|
||||
.layer.core-out{border-top-color:var(--ledger)} .layer.core-out .lg{color:var(--ledger)}
|
||||
.layer.evid{border-top-color:var(--human)} .layer.evid .lg{color:var(--human)}
|
||||
.layer.loop{border-top-color:var(--amber)} .layer.loop .lg{color:var(--amber)}
|
||||
.layer.ctx{border-top-color:var(--ink-4)} .layer.ctx .lg{color:var(--ink-3)}
|
||||
|
||||
/* 区块 */
|
||||
section{background:#fff;border:1px solid var(--line-soft);border-radius:14px;padding:18px 22px;margin-bottom:16px}
|
||||
@@ -100,11 +121,10 @@ section{background:#fff;border:1px solid var(--line-soft);border-radius:14px;pad
|
||||
.ltag.out{color:var(--ledger);background:var(--ledger-soft);border-color:var(--ledger-line)}
|
||||
.ltag.evid{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||
.ltag.loop{color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line)}
|
||||
.ltag.ctx{color:var(--ink-3);background:var(--soft);border-color:var(--line)}
|
||||
.sec-head .why{font-size:12px;color:var(--ink-4)}
|
||||
.sec-head .cnt{margin-left:auto;font-size:11.5px;color:var(--ink-4);font-family:var(--mono)}
|
||||
|
||||
/* 表格 —— [改] 目标件写 overflow:hidden,列一多右边被裁且滑不动;本仓 2026-08-23 定:表宽超出就得能横滑 */
|
||||
/* 表格 —— 表宽超出就得能横滑(本仓 2026-08-23 定) */
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow-x:auto;overflow-y:hidden}
|
||||
.jc-tbl-wrap, .tbl-wrap { overflow-x: auto; overflow-y: hidden; }
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
@@ -115,6 +135,7 @@ tbody tr{transition:background .15s}
|
||||
tbody tr:hover{background:var(--brand-wash)}
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
.pn{font-weight:700}
|
||||
.cust{font-weight:700}
|
||||
|
||||
/* 状态 pill */
|
||||
.st{display:inline-block;padding:1px 9px;border-radius:999px;font-size:11px;font-weight:800;white-space:nowrap;border:1px solid transparent}
|
||||
@@ -125,13 +146,6 @@ tbody tr:hover{background:var(--brand-wash)}
|
||||
.st.gray{color:var(--ink-3);background:var(--soft);border-color:var(--line)}
|
||||
.st.warn{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||
|
||||
/* P-01 单记录 KV 卡 */
|
||||
.kv{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line-soft);border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
.kv .cell{background:#fff;padding:10px 14px}
|
||||
.kv .k{font-size:11px;font-weight:800;color:var(--ink-4);margin-bottom:2px}
|
||||
.kv .v{font-size:12.5px;color:var(--ink);line-height:1.5}
|
||||
.kv .span2{grid-column:span 2}
|
||||
|
||||
/* 参数集摘要 chips */
|
||||
.pchips{display:flex;flex-wrap:wrap;gap:4px}
|
||||
.pchips span{font-size:11px;font-family:var(--mono);background:var(--soft);border:1px solid var(--line-soft);border-radius:6px;padding:1px 7px;color:var(--ink-2);white-space:nowrap}
|
||||
@@ -143,48 +157,40 @@ tbody tr:hover{background:var(--brand-wash)}
|
||||
.minor .m .lid{font-family:var(--mono);color:var(--ink-4);font-weight:700;margin-right:6px}
|
||||
|
||||
footer{margin-top:18px;font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:900px){.layers{grid-template-columns:repeat(2,1fr)}.kv{grid-template-columns:1fr 1fr}.kv .span2{grid-column:span 2}}
|
||||
@media(max-width:900px){.layers{grid-template-columns:repeat(2,1fr)}}
|
||||
`;
|
||||
|
||||
/* ==================== 绑定层:徽标别名 / 三态横幅 / 淡显 —— 只取值,不算不判 ==================== */
|
||||
/* ==================== 绑定层:徽标别名 —— 只取值,不算不判 ==================== */
|
||||
const OK = 'color:var(--ok);background:var(--ok-soft);border-color:#bfe6b4;';
|
||||
const MISS = 'color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5;';
|
||||
const UNKNOWN = 'color:var(--amber);background:var(--amber-soft);border-color:var(--amber-line);';
|
||||
const COND = 'color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft);';
|
||||
const GRAY = 'color:var(--ink-3);background:var(--soft);border-color:var(--line);';
|
||||
const WARN = 'color:var(--human);background:var(--human-soft);border-color:var(--human-line);';
|
||||
|
||||
const BIND_CSS = `
|
||||
/* 徽标:值域各归各族(同一个词在不同列意思不同,如 pending 在资料状态是待生效、在需求状态是待处理),
|
||||
文案与颜色由 data-class-of 投出的 v-<token> 决定;没投出 class 的格子摆「—」,客户批准那格摆「未提交」 */
|
||||
/* 徽标:值域各归各族(同一个词在不同列意思不同),文案与颜色由 data-class-of 投出的 v-<token> 决定;
|
||||
没投出 class 的格子摆「—」 */
|
||||
.st::after{content:"—"}
|
||||
.st.ap::after{content:"未提交"}
|
||||
${badgeCss('.st.lc', [
|
||||
['active', 'Active', OK],
|
||||
['nrnd', 'NRND', MISS],
|
||||
['eol', 'EOL', MISS],
|
||||
['discontinued', '停产', MISS],
|
||||
['risk', '风险', WARN],
|
||||
['risk', '风险', UNKNOWN],
|
||||
])}
|
||||
${badgeCss('.st.rec', [
|
||||
['recommended', '推荐', OK],
|
||||
['conditional', '条件推荐', WARN],
|
||||
['alternate', '备选', COND],
|
||||
['conditional', '条件推荐', UNKNOWN],
|
||||
['not_recommended', '不推荐', MISS],
|
||||
])}
|
||||
/* analysis_status 这次重写没有再声明 tally(本活动只写这一个固定值,分析层与客户批准层分离) */
|
||||
${badgeCss('.st.an', [
|
||||
['ai_draft', 'AI 草稿', GRAY],
|
||||
['fae_confirmed', 'FAE确认', COND],
|
||||
['pending_customer', '待客户验证', WARN],
|
||||
['customer_approved', '客户已批准', OK],
|
||||
['rejected', '不批准', MISS],
|
||||
['void', '作废', GRAY],
|
||||
['fae_confirmed', 'FAE已确认', COND],
|
||||
])}
|
||||
${badgeCss('.st.rk', [
|
||||
['low', '低', OK],
|
||||
['medium', '中', WARN],
|
||||
['medium', '中', UNKNOWN],
|
||||
['high', '高', MISS],
|
||||
['critical', '危急', MISS],
|
||||
])}
|
||||
${badgeCss('.st.dst', [
|
||||
['match', 'MATCH', OK],
|
||||
@@ -192,36 +198,15 @@ ${badgeCss('.st.dst', [
|
||||
['unknown', 'UNKNOWN', UNKNOWN],
|
||||
['conditional', 'CONDITIONAL', COND],
|
||||
])}
|
||||
/* validation_status 同样没有声明 tally(本活动只写 not_started —— 验证是否通过不是这个活动的事) */
|
||||
${badgeCss('.st.vs', [
|
||||
['not_started', '未开始', GRAY],
|
||||
['in_progress', '进行中', COND],
|
||||
['passed', '通过', OK],
|
||||
['failed', '未通过', MISS],
|
||||
['conditional', '条件通过', WARN],
|
||||
['pending_evidence', '待证据', UNKNOWN],
|
||||
])}
|
||||
${badgeCss('.st.ds', [
|
||||
['valid', '有效', OK],
|
||||
['expired', '过期', MISS],
|
||||
['pending', '待生效', WARN],
|
||||
['void', '作废', GRAY],
|
||||
])}
|
||||
${badgeCss('.st.rq', [
|
||||
['pending', '待处理', GRAY],
|
||||
['responded', '已响应', COND],
|
||||
['won', '已成交', OK],
|
||||
['abandoned', '已放弃', MISS],
|
||||
['closed', '已关闭', GRAY],
|
||||
])}
|
||||
${badgeCss('.st.stg', [
|
||||
['assessing', '评估中', GRAY],
|
||||
['gate03', '提交 ACT-GATE-03', OK],
|
||||
])}
|
||||
/* 在库状态(sub_eval.part.exists_status)—— 这次重写只剩两档(found/absent),
|
||||
「本单建档」「已放弃」两档随旧的多候选设计一起退役 */
|
||||
${badgeCss('.st.ex', [
|
||||
['found', '在库直读', COND],
|
||||
['created', '本单建档', OK],
|
||||
['absent', '缺库', MISS],
|
||||
['abandoned', '已放弃', GRAY],
|
||||
])}
|
||||
${badgeCss('.st.rl', [
|
||||
['original', '原 PN', COND],
|
||||
@@ -229,36 +214,20 @@ ${badgeCss('.st.rl', [
|
||||
])}
|
||||
/* 纯文字的枚举列:只补文案 */
|
||||
.enumtxt::after{content:"—"}
|
||||
${badgeCss('.enumtxt.ty', [
|
||||
['pin_to_pin', 'Pin-to-Pin'],
|
||||
['functional', '功能替代'],
|
||||
['parametric', '参数替代'],
|
||||
['other', '其他'],
|
||||
])}
|
||||
${badgeCss('.enumtxt.con', [
|
||||
['hard', '硬约束'],
|
||||
['soft', '软约束'],
|
||||
])}
|
||||
/* validation_type 这次重写多了 parametric 一档(S2 的验证记录投影固定写它) */
|
||||
${badgeCss('.enumtxt.vt', [
|
||||
['pin_to_pin', 'Pin-to-Pin'],
|
||||
['functional', '功能'],
|
||||
['reliability', '可靠性'],
|
||||
['certification', '认证'],
|
||||
['retest', '复测'],
|
||||
])}
|
||||
${badgeCss('.enumtxt.dt', [
|
||||
['datasheet', 'Datasheet'],
|
||||
['app_note', '应用笔记'],
|
||||
['reference_design', '参考设计'],
|
||||
['white_paper', '白皮书'],
|
||||
['sample_doc', '样品资料'],
|
||||
['commitment_letter', '承诺函'],
|
||||
['other', '其他'],
|
||||
['parametric', '参数验证'],
|
||||
])}
|
||||
|
||||
/* 页头候选料号:一行内逐个摆,用顿号连;一个都没有就摆「—」 */
|
||||
.cands b+b::before{content:"、";font-weight:400;color:var(--ink-3)}
|
||||
.cands:empty::after{content:"—"}
|
||||
/* 参数瓷贴:参数名淡、值粗、单位紧跟 */
|
||||
.pchips i{font-style:normal;color:var(--ink-4);margin-right:3px}
|
||||
.pchips b{font-weight:700}
|
||||
@@ -269,12 +238,6 @@ ${badgeCss('.enumtxt.dt', [
|
||||
.pnrow .st{font-size:10px;padding:0 7px}
|
||||
/* 差异明细:MATCH 行淡显(不藏:那也是比对过的事实),非 MATCH 才是要看的 */
|
||||
tr.v-match td{opacity:.55}
|
||||
/* 已放弃的候选:只在有 abandoned 行时露面,别的核对行不显 */
|
||||
.aband{display:none;font-size:12px;color:var(--ink-3);margin:8px 0 0}
|
||||
.aband i{font-style:normal;font-family:var(--mono);color:var(--ink-2)}
|
||||
.aband i:not(.v-abandoned){display:none}
|
||||
.aband i+i.v-abandoned::before{content:"、"}
|
||||
.aband:has(i.v-abandoned){display:block}
|
||||
/* P-06 历史行:首格挂一枚「历史」灰签 */
|
||||
tbody.hist td:first-child::after{content:"历史";display:inline-block;margin-left:6px;font-size:10px;font-weight:800;padding:0 6px;border-radius:999px;color:var(--ink-3);background:var(--soft);border:1px solid var(--line);font-family:Inter,ui-sans-serif,system-ui,sans-serif}
|
||||
td .sub{display:block;font-size:11px;color:var(--ink-3);font-weight:400}
|
||||
@@ -305,8 +268,8 @@ export const LEDGER_BOARD = `<!doctype html>
|
||||
<header class="mast">
|
||||
<div class="kick">SS02 需求分析与方案匹配 · 活动台账视图</div>
|
||||
<h1><span class="code">ACT-WORK-05</span>关键台账数据</h1>
|
||||
<p class="sub case">视角:本活动最近一次已放行实例 <b data-slot="case:instanceNo"></b>(<b data-slot="case:openedAt"></b> 开案 · <b data-slot="case:releasedAt"></b> 放行 · 负责人 <b data-slot="case:owner" data-blank></b>)的落账快照 · 案号 <b class="mono" data-slot="case:ref"></b> · 关联需求 <b data-slot="demand.request.request_id"></b>(客户 <b data-slot="demand.request.customer_code"></b> <span data-slot="customer.profile.name" data-blank></span>)· 评估基准 <b data-slot="${D}.verdict.original_internal_pn"></b>(<span data-slot="parts.profile.brand_name" data-blank></span>)→ 候选 <span class="cands" data-rows="substitution.relation"><b data-slot="candidate_internal_pn"></b></span> · 规则版本 <b data-slot="${D}.verdict.rule_version"></b></p>
|
||||
<p class="sub verdict">评估结论:<span data-slot="${D}.verdict.summary" data-blank></span></p>
|
||||
<p class="sub case">视角:本活动最近一次已放行实例 <b data-slot="case:instanceNo"></b>(<b data-slot="case:openedAt"></b> 开案 · <b data-slot="case:releasedAt"></b> 放行 · 负责人 <b data-slot="case:owner" data-blank></b>)的落账快照 · 案号 <b class="mono" data-slot="case:ref"></b> · 评估基准 <b data-slot="substitution.relation.original_internal_pn"></b> → 候选 <b data-slot="substitution.relation.candidate_internal_pn"></b> · 芯片类型 <span data-slot="parts.chip_type.type_name" data-blank></span> · 规则版本 <b data-slot="substitution.relation.rule_version"></b></p>
|
||||
<p class="sub verdict">评估结论:<span data-slot="${D}.case.summary" data-blank></span></p>
|
||||
<div class="legend">
|
||||
<span class="li"><span class="dot full"></span>正式台账直读 —— 放行落地后的事实,按案号圈出这一单的行</span>
|
||||
<span class="li"><span class="dot half"></span>同料号的历史验证记录(别单 / 线下留下的,作本次评估的证据引用)</span>
|
||||
@@ -319,64 +282,57 @@ export const LEDGER_BOARD = `<!doctype html>
|
||||
<!-- 重要性分层 -->
|
||||
<div class="layers">
|
||||
<div class="layer"><b><span class="lg">P-08</span> 芯片主数据</b>核心输入 · 评估原材料,查无即硬阻塞</div>
|
||||
<div class="layer evid"><b><span class="lg">字段字典</span> 类型与核心参数</b>比对基准 · 按芯片类型定义要比的字段与硬软约束</div>
|
||||
<div class="layer core-out"><b><span class="lg">P-11</span> 替代批准关系库</b>核心产出 · 结论与明细的落账地</div>
|
||||
<div class="layer evid"><b><span class="lg">P-10</span> 原厂技术资料库</b>证据链 · 每项结论的版本化出处</div>
|
||||
<div class="layer loop"><b><span class="lg">P-06</span> 样品验证记录</b>闭环去向 · UNKNOWN 的实测收口</div>
|
||||
<div class="layer ctx"><b><span class="lg">P-01</span> 客户需求主台账</b>上下文 · 需求源头与回写对象</div>
|
||||
</div>
|
||||
|
||||
<!-- P-01 -->
|
||||
<section id="p01">
|
||||
<div class="sec-head">
|
||||
<h2><span class="lid">P-01</span>客户需求主台账</h2>
|
||||
<span class="ltag ctx">上下文 · R+W</span>
|
||||
<span class="why">启动时新建需求;结束时回写风险 / 验证要求 / 输出链接 / 下一步 / 当前阶段</span>
|
||||
<span class="cnt">正式表 · <i data-slot="demand_request_rows"></i> 条</span>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<div class="cell"><div class="k">需求编号</div><div class="v mono" data-slot="demand.request.request_id"></div></div>
|
||||
<div class="cell"><div class="k">客户</div><div class="v mono"><span data-slot="demand.request.customer_code"></span> <span class="sub" data-slot="customer.profile.name" data-blank></span></div></div>
|
||||
<div class="cell"><div class="k">需求版本</div><div class="v mono" data-slot="demand.request.requirement_version"></div></div>
|
||||
<div class="cell"><div class="k">需求状态</div><div class="v"><span class="st rq" data-class-of="demand.request.request_status"></span></div></div>
|
||||
<div class="cell span2"><div class="k">需求描述</div><div class="v" data-slot="demand.request.request_description"></div></div>
|
||||
<div class="cell"><div class="k">应用场景</div><div class="v" data-slot="demand.request.application_scenario" data-blank></div></div>
|
||||
<div class="cell"><div class="k">BOM 位</div><div class="v mono" data-slot="demand.request.bom_position" data-blank></div></div>
|
||||
<div class="cell"><div class="k">原内部料号</div><div class="v mono" data-slot="demand.request.original_internal_pn"></div></div>
|
||||
<div class="cell"><div class="k">候选内部料号</div><div class="v mono cands" data-rows="substitution.relation"><b data-slot="candidate_internal_pn"></b></div></div>
|
||||
<div class="cell"><div class="k">风险等级(回写)</div><div class="v"><span class="st rk" data-class-of="demand.request.risk_level"></span></div></div>
|
||||
<div class="cell"><div class="k">验证要求(回写)</div><div class="v mono" data-slot="demand.request.verification_requirement" data-blank></div></div>
|
||||
<div class="cell"><div class="k">当前阶段</div><div class="v"><span class="st stg" data-class-of="demand.request.current_stage"></span></div></div>
|
||||
<div class="cell"><div class="k">标准输出链接</div><div class="v mono" data-slot="demand.request.output_ref" data-blank></div></div>
|
||||
<div class="cell span2"><div class="k">下一步动作</div><div class="v" data-slot="demand.request.next_action" data-blank></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- P-08 -->
|
||||
<section id="p08">
|
||||
<div class="sec-head">
|
||||
<h2><span class="lid">P-08</span>芯片主数据台账</h2>
|
||||
<span class="ltag in">核心输入 · R(查无 → 数据工单创建)</span>
|
||||
<span class="why">原 PN 与候选 PN 的物料基准 + 标准化参数集(子表 P-08 参数),逐项比对的原材料</span>
|
||||
<span class="cnt">正式表 · <i data-slot="parts_profile_rows"></i> 条(本活动相关)</span>
|
||||
<span class="cnt">正式表 · <i data-slot="parts_profile_rows"></i> 条(本次评估相关)</span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
${th('内部料号', '品牌', '规格描述', '封装', '生命周期', 'Datasheet 版本', '标准化参数集', '适配场景')}
|
||||
${th('内部料号', '品牌', '规格描述', '生命周期', 'Datasheet 版本', '标准化参数集')}
|
||||
<tbody data-rows="parts.profile">
|
||||
<tr>
|
||||
<td class="pn mono"><span data-slot="internal_pn"></span><span class="pnrow" data-rows="${D}.pn_check" data-by="internal_pn"><i class="st rl" data-class-of="role"></i><i class="st ex" data-class-of="exists_status"></i></span></td>
|
||||
<td class="pn mono"><span data-slot="internal_pn"></span><span class="pnrow" data-rows="${D}.part" data-by="internal_pn"><i class="st rl" data-class-of="role"></i><i class="st ex" data-class-of="exists_status"></i></span></td>
|
||||
<td data-slot="brand_name" data-blank></td>
|
||||
<td data-slot="specification_description" data-blank></td>
|
||||
<td class="mono" data-slot="package" data-blank></td>
|
||||
<td><span class="st lc" data-class-of="lifecycle_status"></span></td>
|
||||
<td class="mono" data-slot="datasheet_version" data-blank></td>
|
||||
<td><div class="pchips" data-rows="parts.parameter" data-by="internal_pn"><span><i data-slot="param_name"></i><b data-slot="param_value" data-blank></b><em data-slot="param_unit" data-blank></em></span></div></td>
|
||||
<td data-slot="application_scenarios" data-blank></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="aband">本轮放弃的候选(未建档、未评估):<span data-rows="${D}.pn_check"><i data-class-of="exists_status" data-slot="internal_pn"></i></span></p>
|
||||
</section>
|
||||
|
||||
<!-- 字段字典 -->
|
||||
<section id="dict">
|
||||
<div class="sec-head">
|
||||
<h2><span class="lid">字段字典</span>芯片类型与核心参数</h2>
|
||||
<span class="ltag evid">比对基准 · R</span>
|
||||
<span class="why">两颗料共同归属的芯片类型,及该类型定义的核心参数字段(名字 / 说明 / 硬软约束 / 单位)</span>
|
||||
<span class="cnt">类型 · <b data-slot="parts.chip_type.type_name" data-blank></b> · 字段 <i data-slot="parts_type_param_rows"></i> 条</span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
${th('参数名', '参数说明', '约束', '参考单位')}
|
||||
<tbody data-rows="parts.type_param">
|
||||
<tr>
|
||||
<td class="cust" data-slot="param_name"></td>
|
||||
<td class="sub" data-slot="param_desc" data-blank></td>
|
||||
<td><span class="enumtxt con" data-class-of="constraint_type"></span></td>
|
||||
<td class="mono" data-slot="param_unit" data-blank></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- P-11 -->
|
||||
@@ -384,71 +340,44 @@ export const LEDGER_BOARD = `<!doctype html>
|
||||
<div class="sec-head">
|
||||
<h2><span class="lid">P-11</span>替代选型与批准关系库 · 替代关系主记录</h2>
|
||||
<span class="ltag out">核心产出 · R+W</span>
|
||||
<span class="why">每个候选一条关系记录;客户批准状态为只读边界,本活动不写</span>
|
||||
<span class="why">原 PN → 候选 PN 一条关系记录;推荐等级 / 风险 / 摘要由规则算出,客户批准状态为只读边界,本活动不写</span>
|
||||
<span class="cnt">正式表 · <i data-slot="substitution_relation_rows"></i> 条</span>
|
||||
</div>
|
||||
<div class="tbl-wrap" style="margin-bottom:14px">
|
||||
<table>
|
||||
${th('替代关系 ID', '原 → 候选', '替代类型', '推荐等级', '分析状态', '风险', '差异摘要', '验证要求 / 回链', '规则版本', '客户批准(只读)')}
|
||||
${th('替代关系 ID', '原 → 候选', '推荐等级', '分析状态', '风险', '差异摘要 / 理由', '验证要求 / 回链', '规则版本')}
|
||||
<tbody data-rows="substitution.relation">
|
||||
<tr>
|
||||
<td class="mono" data-slot="substitution_relation_id"></td>
|
||||
<td class="mono"><span data-slot="original_internal_pn"></span> → <b data-slot="candidate_internal_pn"></b></td>
|
||||
<td><span class="enumtxt ty" data-class-of="substitution_type"></span></td>
|
||||
<td><span class="st rec" data-class-of="recommendation_level"></span></td>
|
||||
<td><span class="st an" data-class-of="analysis_status"></span></td>
|
||||
<td><span class="st rk" data-class-of="risk_level"></span></td>
|
||||
<td><span data-slot="difference_summary" data-blank></span><span class="sub" data-slot="recommendation_reason" data-blank></span></td>
|
||||
<td class="mono"><span data-slot="validation_id" data-blank></span><span class="sub" data-slot="verification_requirement" data-blank></span></td>
|
||||
<td class="mono" data-slot="rule_version" data-blank></td>
|
||||
<td><span class="st ap" data-class-of="customer_approval_status"></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="sec-head" style="margin-bottom:10px">
|
||||
<h2 style="font-size:13.5px"><span class="lid">P-11</span>差异明细子表(由双方 P-08 标准化参数集逐项比对得出;MATCH 项淡显)</h2>
|
||||
<h2 style="font-size:13.5px"><span class="lid">P-11</span>差异明细子表(按字段字典逐项比对得出;MATCH 项淡显)</h2>
|
||||
<span class="cnt"><i data-slot="substitution_difference_item_rows"></i> 条 · MATCH <i data-slot="substitution_difference_item_difference_status_match"></i> · MISMATCH <i data-slot="substitution_difference_item_difference_status_mismatch"></i> · UNKNOWN <i data-slot="substitution_difference_item_difference_status_unknown"></i> · CONDITIONAL <i data-slot="substitution_difference_item_difference_status_conditional"></i></span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
${th('所属关系', '参数项', '原值 → 候选值', '约束', '结论', '风险', '证据引用', '影响说明')}
|
||||
${th('参数项', '约束', '原值 → 候选值', '单位', '结论', '风险', '判断依据', '影响说明')}
|
||||
<tbody data-rows="substitution.difference_item">
|
||||
<tr data-class-of="difference_status">
|
||||
<td class="mono" data-slot="substitution_relation_id"></td>
|
||||
<td data-slot="parameter_name"></td>
|
||||
<td class="mono"><span data-slot="original_value" data-blank></span> → <span data-slot="candidate_value" data-blank></span> <span data-slot="parameter_unit" data-blank></span></td>
|
||||
<td><span class="enumtxt con" data-class-of="constraint_type"></span></td>
|
||||
<td class="mono"><span data-slot="original_value" data-blank></span> → <span data-slot="candidate_value" data-blank></span></td>
|
||||
<td data-slot="parameter_unit" data-blank></td>
|
||||
<td><span class="st dst" data-class-of="difference_status"></span></td>
|
||||
<td><span class="st rk" data-class-of="risk_level"></span></td>
|
||||
<td class="mono" data-slot="evidence_ref" data-blank></td>
|
||||
<td><span data-slot="impact_statement" data-blank></span><span class="sub" data-slot="unknown_disposition" data-blank></span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- P-10 -->
|
||||
<section id="p10">
|
||||
<div class="sec-head">
|
||||
<h2><span class="lid">P-10</span>原厂技术资料库</h2>
|
||||
<span class="ltag evid">证据链 · R(查无 → 数据工单创建)</span>
|
||||
<span class="why">每项结论的 datasheet 出处,版本回链</span>
|
||||
<span class="cnt">正式表 · <i data-slot="parts_document_rows"></i> 条(本活动相关料号)</span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
${th('关联料号', '品牌', '资料类型', '资料文件', '版本', '状态')}
|
||||
<tbody data-rows="parts.document">
|
||||
<tr>
|
||||
<td class="mono"><span data-slot="internal_pn"></span> · <span data-slot="doc_seq" data-blank></span></td>
|
||||
<td data-slot="brand_name" data-blank></td>
|
||||
<td><span class="enumtxt dt" data-class-of="document_type"></span></td>
|
||||
<td class="mono" data-slot="document_ref" data-blank></td>
|
||||
<td class="mono" data-slot="document_version" data-blank></td>
|
||||
<td><span class="st ds" data-class-of="document_status"></span></td>
|
||||
<td class="mono" data-slot="evidence_note" data-blank></td>
|
||||
<td data-slot="impact_statement" data-blank></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -460,33 +389,29 @@ export const LEDGER_BOARD = `<!doctype html>
|
||||
<div class="sec-head">
|
||||
<h2><span class="lid">P-06</span>样品交付与芯片验证记录台账</h2>
|
||||
<span class="ltag loop">闭环去向 · R + 条件写(不可创建,只能实测产生)</span>
|
||||
<span class="why">UNKNOWN / CONDITIONAL 的收口处;验证完成后回链 P-11</span>
|
||||
<span class="why">UNKNOWN / CONDITIONAL 的收口处;一单固定一条</span>
|
||||
<span class="cnt">本单挂的 · <i data-slot="validation_record_rows"></i> 条 · 同料号历史 · <i data-slot="validation_history_rows"></i> 条</span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
${th('验证 ID', '关联需求', '验证料号', '验证类型', '测试条件', '结果 / 状态', '问题与风险', '后续动作')}
|
||||
${th('验证 ID', '验证料号', '验证类型', '测试条件', '结果 / 状态', '后续动作')}
|
||||
<tbody data-rows="validation.record">
|
||||
<tr>
|
||||
<td class="mono" data-slot="validation_id"></td>
|
||||
<td class="mono" data-slot="request_id" data-blank></td>
|
||||
<td class="mono" data-slot="validation_internal_pn"></td>
|
||||
<td><span class="enumtxt vt" data-class-of="validation_type"></span></td>
|
||||
<td data-slot="test_condition" data-blank></td>
|
||||
<td><span class="st vs" data-class-of="validation_status"></span><span class="sub" data-slot="test_result" data-blank></span><span class="sub mono" data-slot="evidence_ref" data-blank></span></td>
|
||||
<td data-slot="issue_risk" data-blank></td>
|
||||
<td><span class="st vs" data-class-of="validation_status"></span></td>
|
||||
<td data-slot="next_validation_action" data-blank></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody class="hist" data-rows="validation.history">
|
||||
<tr>
|
||||
<td class="mono" data-slot="validation_id"></td>
|
||||
<td class="mono" data-slot="request_id" data-blank></td>
|
||||
<td class="mono" data-slot="validation_internal_pn"></td>
|
||||
<td><span class="enumtxt vt" data-class-of="validation_type"></span></td>
|
||||
<td data-slot="test_condition" data-blank></td>
|
||||
<td><span class="st vs" data-class-of="validation_status"></span><span class="sub" data-slot="test_result" data-blank></span><span class="sub mono" data-slot="evidence_ref" data-blank></span></td>
|
||||
<td data-slot="issue_risk" data-blank></td>
|
||||
<td><span class="st vs" data-class-of="validation_status"></span></td>
|
||||
<td data-slot="next_validation_action" data-blank></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -500,15 +425,13 @@ export const LEDGER_BOARD = `<!doctype html>
|
||||
<h2>次要台账(只读引用,不进主看板)</h2>
|
||||
</div>
|
||||
<div class="minor">
|
||||
<div class="m"><b><span class="lid">P-09</span>客户技术与机会档案</b>提供 BOM 位与客户技术栈背景;缺口由执行工单补齐。</div>
|
||||
<div class="m"><b><span class="lid">EXT-02</span>外部来源清单</b>登记本次评估用到的可信外部来源(原厂官网 datasheet 等)。</div>
|
||||
<div class="m"><b><span class="lid">GOV</span>评估规则库</b>四态判定口径;本次锁定版本 <span class="mono" data-slot="${D}.verdict.rule_version"></span>,随结论一并落账。</div>
|
||||
<div class="m"><b><span class="lid">GOV</span>评估规则库</b>四态判定与推荐等级口径;本次锁定版本 <span class="mono" data-slot="substitution.relation.rule_version"></span>,随结论一并落账。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>SS02 · ACT-WORK-05 差异评估 · 关键台账数据视图</span>
|
||||
<span style="font-family:var(--mono)">数据来源:正式台账(parts / demand / substitution / validation / sub_eval)· 实例 <span data-slot="case:instanceNo"></span> · 案号 <span data-slot="case:ref"></span></span>
|
||||
<span style="font-family:var(--mono)">数据来源:正式台账(parts / substitution / validation / sub_eval)· 实例 <span data-slot="case:instanceNo"></span> · 案号 <span data-slot="case:ref"></span></span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,211 +1,183 @@
|
||||
/**
|
||||
* ACT-WORK-05 差异评估的四份目标件样式表 —— **机械抽取,一个数值都不许改**
|
||||
* ACT-WORK-05 差异评估的目标件样式表 —— **机械抽取,一个数值都不许改**
|
||||
* (《HTML报告生成规范》§7:有目标件时「搬」,不要「照着做」;样式表整段搬,标记逐块搬)。
|
||||
*
|
||||
* 来源(`docs/SS02-ACT-WORK-05-html/`):
|
||||
* · `P08_CSS` ← `ACT-WORK-05-数据工单-01-芯片主数据创建.html` 的两段 `<style>`
|
||||
* (该文件头部连续放了两个 `<style>` 元素:第一段是基座壳,第二段标着
|
||||
* 「视觉补充段 v3」——`.hic`/`.zic`/`.thd`/`#tbl1`/`#tbl2` 等数据工单专属样式。
|
||||
* 两段按文档原有顺序首尾相接存成一个常量;等价于浏览器解析两个 `<style>` 标签,
|
||||
* 级联结果与原文件逐字节一致,已用 diff 验证,见 task-5-report.md);
|
||||
* · `EXEC_CSS` ← `ACT-WORK-05-执行工单-01-评估输入与资料补齐确认.html` 的 `<style>`;
|
||||
* 来源:
|
||||
* · `P08_CSS` ← `docs/ss02-act-05-new/ACT-WORK-05-数据工单-PN主数据创建.html` 的
|
||||
* 整段 `<style>`(**2026-08-30 换版面**:用户给了新目标件,`input-supplement`
|
||||
* 整张照它重排 —— 步骤轨道 `.steps`/`.stepbox`、档案卡 `.idcard`、参数瓷贴
|
||||
* `.pvgrid`/`.pv`、新增类型面板 `.newtype`。旧来源
|
||||
* `docs/SS02-ACT-WORK-05-html/ACT-WORK-05-数据工单-01-芯片主数据创建.html`
|
||||
* 的两段 `<style>` 随之整段退役 —— 那一版带的 `.jc-vcell`/`.jc-cell` 孪生件
|
||||
* 显隐规则不在新目标件里,平台绑定所需的那部分改由 `report.ts` 的 `BIND_*`
|
||||
* 承载,见那边「编辑机制」一节);抽取与源文件 `<style>` 区间 diff 零差异;
|
||||
* · `ACC1_CSS` ← `ACT-WORK-05-验收工单-01-差异评估结论审核.html` 的 `<style>`;
|
||||
* · `ACC2_CSS` ← `ACT-WORK-05-验收工单-02-评审放行与落账确认.html` 的 `<style>`。
|
||||
*
|
||||
* `p10-document-create`(P-10 原厂技术资料创建)**没有自己的目标件**——规格里数据工单是
|
||||
* 「一个统一模板按目标台账实例化」,复用 `P08_CSS`(同一套壳,只是表头列换成 P-10 的字段)。
|
||||
* `EXEC_CSS`(执行工单-01 的样式)已随 Task 7 删除:它原来专属的 `eval-input-list`
|
||||
* 局面已并入 `input-supplement`(改用 `P08_CSS`),此后再没有消费方 —— target-style.ts
|
||||
* 只留「没有消费方就删」这条规矩下**还有人用**的常量,ACC1_CSS / ACC2_CSS 虽然此刻
|
||||
* 也没有工单件在用,但被 `activity.test.ts`「两张验收件的主题色」那条测试直接导入校验
|
||||
* (Task 8 / 9 会把它们重新接回 `difference-review` / `landing-confirm`),留着。
|
||||
*
|
||||
* 抽取用 `sed -n` 按行号截取 `<style>`/`</style>` 之间的原文,再拿抽出来的内容包回
|
||||
* `<style>…</style>` 与源文件同一行区间做 `diff`,四份全部「零差异」——过程写在
|
||||
* task-5-report.md,这里不重复贴证据。
|
||||
* `p10-document-create`(P-10 原厂技术资料创建)原来**没有自己的目标件**、复用 `P08_CSS` ——
|
||||
* Task 7 重写后 P-10 不再是这个活动的数据工单范围(本活动不碰 P-10),这句历史说明留作背景。
|
||||
*
|
||||
* 抽取用 `sed -n` 按行号截取 `<style>`/`</style>` 之间的原文,再拿抽出来的内容与
|
||||
* 源文件同一行区间做 `diff` 验「零差异」(ACC1/ACC2 的过程在 task-5-report.md;
|
||||
* P08 新版 2026-08-30 换版面时同法重验)。
|
||||
*
|
||||
* 这份文件只装**目标件自己的样式**,不装平台绑定用的开关(三态占位、缺项红带、
|
||||
* `data-class-of` 的 `v-*` 别名这类)——那些是本活动自己加出来的东西,活在
|
||||
* `report.ts` 的 `BIND_CSS_*` 常量里,两处不要混。
|
||||
* `report.ts` 的 `BIND_*` 常量里,两处不要混。
|
||||
*/
|
||||
|
||||
export const P08_CSS = `
|
||||
:root{
|
||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
||||
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
||||
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
||||
--data:#3370ff; --data-soft:#e1eaff; --data-line:#c3d6ff;
|
||||
--brand:#3370ff; --brand-deep:#245bdb;
|
||||
--data:#3370ff; --data-deep:#245bdb; --data-soft:#e1eaff; --data-line:#c3d6ff; --data-wash:#f0f4ff;
|
||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||
--amber:#8f6a12; --amber-soft:#fdf3d7; --amber-line:#ecd9a0;
|
||||
--mono:"SF Mono",Menlo,Consolas,monospace;
|
||||
/* 8px 间距节奏(dense) */
|
||||
--s1:8px; --s2:16px; --s3:24px; --s4:32px;
|
||||
--r-sm:8px; --r-md:12px;
|
||||
--tr:.22s cubic-bezier(.2,.7,.3,1);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0}
|
||||
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
||||
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#fff;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
||||
.page{margin:0;padding:0}
|
||||
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
|
||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#f0f5ff 0%,#fff 55%,#eef4ff 100%)}
|
||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--data);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(51,112,255,.25)}
|
||||
.tit{flex:1;min-width:0}
|
||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--data);background:var(--data-soft);border:1px solid var(--data-line)}
|
||||
.step{font-size:12px;color:var(--ink-3)}
|
||||
.deadline{font-size:12px;color:var(--ink-3)}
|
||||
.deadline b{color:var(--danger)}
|
||||
.card-bd{padding:20px 22px}
|
||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
||||
.zone b{color:var(--ink-3)}
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--data);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
||||
.note p{margin:0 0 6px;font-size:13px;color:var(--ink-2)}
|
||||
.note p:last-child{margin-bottom:0}
|
||||
.note b{color:var(--ink)}
|
||||
.note .done{color:var(--ok);font-weight:700}
|
||||
.note .need{color:var(--data);font-weight:700}
|
||||
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0 10px;padding:9px 12px;border-radius:9px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3)}
|
||||
.jc-editgate-t{flex:1 1 auto;min-width:0}
|
||||
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
|
||||
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:7px;padding:6px 13px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s}
|
||||
.jc-egbtn:hover{border-color:var(--brand);color:var(--brand)}
|
||||
.jc-egbtn.save{color:#fff;background:var(--data);border-color:var(--data)}
|
||||
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
|
||||
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
|
||||
.jc-editgate.on{background:var(--brand-wash);border-color:var(--data-line);color:#1e40af}
|
||||
.jc-editgate.on .eg-open{display:none}
|
||||
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.03em;font-weight:800;color:var(--ink-3);padding:9px 10px;border-bottom:1px solid var(--line)}
|
||||
th.req::after{content:" *";color:var(--danger);font-size:11px}
|
||||
td{padding:8px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td.rn{position:relative;width:38px;white-space:nowrap;font-family:var(--mono);font-size:11px;color:var(--ink-4);text-align:center}
|
||||
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.5;overflow-wrap:anywhere}
|
||||
.jc-vcell.mono{font-family:var(--mono);font-size:11.5px;color:var(--ink-2)}
|
||||
.jc-cell{display:none;width:100%;min-width:60px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
|
||||
.jc-cell.mono{font-family:var(--mono);font-size:11.5px}
|
||||
.jc-cell:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash);outline:none}
|
||||
table.on td>.jc-vcell{display:none}
|
||||
table.on td>.jc-cell{display:block}
|
||||
td.rn .jc-rowdel{display:none;width:22px;height:22px;margin:0 auto;cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:0;font:inherit;font-size:14px;line-height:1;color:var(--ink-3);background:#fff}
|
||||
td.rn .jc-rowdel:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
|
||||
table.on td.rn .jc-rn-num{display:none}
|
||||
table.on td.rn .jc-rowdel{display:block}
|
||||
.jc-addrow{display:none;margin:8px 0 0;padding:8px 12px;border-radius:9px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.jc-addrow.show{display:flex;align-items:center;gap:8px}
|
||||
.jc-addrow button{cursor:pointer;border:1px solid var(--brand-soft);border-radius:7px;padding:5px 12px;font:inherit;font-size:12px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash)}
|
||||
.jc-addrow button:hover{background:var(--brand-soft)}
|
||||
.jc-addrow em{font-style:normal;font-size:11.5px;color:var(--ink-4)}
|
||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.flow b{color:var(--ink)}
|
||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
||||
/* 补充:数据工单专用标识(仅追加,不改动基件样式) */
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
.pre{font-style:normal;font-size:10.5px;font-weight:800;color:var(--data);background:var(--data-soft);border:1px solid var(--data-line);border-radius:999px;padding:0 7px;margin-left:6px;white-space:nowrap}
|
||||
table.on td>.pre{display:none}
|
||||
.ct{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||
.ct.hard{color:var(--danger);background:var(--danger-soft);border-color:#f3b9b5}
|
||||
.ct.soft{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
|
||||
.tbl-gap{margin-top:18px}
|
||||
/* ===== 视觉补充段 v3(通用批量模板:字段即表头、每行一条;不改基件样式与 JS 钩子) ===== */
|
||||
.hic{flex:none;width:26px;height:26px;color:var(--data)}
|
||||
.zic{width:14px;height:14px;color:var(--ink-3);vertical-align:-2px;margin-right:3px}
|
||||
/* 表头内字段说明(第二行小字) */
|
||||
.thd{display:block;font-size:10px;font-weight:600;color:var(--ink-4);letter-spacing:0;margin-top:1px;line-height:1.4}
|
||||
/* 表 1:字段即表头,内容多列,允许横向滚动 */
|
||||
#tbl1{min-width:1240px}
|
||||
#tbl1 .jc-vcell{display:inline}
|
||||
/* 表 2 列宽 */
|
||||
#tbl2 th:nth-child(2){width:150px}
|
||||
#tbl2 th:nth-child(3){width:18%}
|
||||
#tbl2 th:nth-child(4){width:14%}
|
||||
#tbl2 th:nth-child(5){width:8%;text-align:center;white-space:nowrap}
|
||||
#tbl2 th:nth-child(6){width:13%;text-align:center;white-space:nowrap}
|
||||
#tbl2 td:nth-child(5),#tbl2 td:nth-child(6){text-align:center;white-space:nowrap}
|
||||
/* 说明列(只读注释,编辑态保持可见) */
|
||||
.desc{color:var(--ink-3);font-size:12px;line-height:1.5}
|
||||
table.on td>.jc-vcell.ro{display:block}
|
||||
.tbl-wrap{overflow-x:auto}
|
||||
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
|
||||
`;
|
||||
|
||||
export const EXEC_CSS = `
|
||||
:root{
|
||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
||||
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
||||
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
|
||||
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
|
||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||
--mono:"SF Mono",Menlo,Consolas,monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0}
|
||||
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#eef1f6;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
||||
.page{margin:0;padding:0}
|
||||
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
|
||||
.card-head{display:flex;align-items:center;gap:14px;padding:18px 22px;border-bottom:2px solid var(--ink);background:linear-gradient(120deg,#fffaf1 0%,#fff 55%,#fbf6ea 100%)}
|
||||
.badge{flex:none;width:44px;height:44px;border-radius:10px;display:grid;place-items:center;background:var(--human);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(169,113,30,.25)}
|
||||
/* ===== 卡头 ===== */
|
||||
.card-head{display:flex;align-items:center;gap:14px;padding:20px 24px;border-bottom:1px solid var(--line-soft);background:linear-gradient(120deg,#f0f4ff 0%,#fff 55%,#eef3ff 100%)}
|
||||
.badge{flex:none;width:44px;height:44px;border-radius:11px;display:grid;place-items:center;background:var(--data);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(51,112,255,.28)}
|
||||
.tit{flex:1;min-width:0}
|
||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||
.tit h1{margin:2px 0 0;font-size:19px;font-weight:800;line-height:1.3}
|
||||
.tit h1{margin:2px 0 0;font-size:20px;font-weight:800;line-height:1.3;letter-spacing:-.01em}
|
||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--human);background:var(--human-soft);border:1px solid var(--human-line)}
|
||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--data-deep);background:var(--data-soft);border:1px solid var(--data-line)}
|
||||
.step{font-size:12px;color:var(--ink-3)}
|
||||
.deadline{font-size:12px;color:var(--ink-3)}
|
||||
.deadline b{color:var(--danger)}
|
||||
.card-bd{padding:20px 22px}
|
||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 8px}
|
||||
.card-bd{padding:var(--s3) 24px var(--s4)}
|
||||
|
||||
/* ===== 区标 / 说明带 ===== */
|
||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 var(--s1)}
|
||||
.zone b{color:var(--ink-3)}
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--human);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--data);border-radius:var(--r-sm);background:var(--soft);padding:14px 16px;margin-bottom:var(--s2)}
|
||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
||||
.note p+p{margin-top:6px}
|
||||
.note b{color:var(--ink)}
|
||||
.note .done{color:var(--ok);font-weight:700}
|
||||
.note .need{color:var(--human);font-weight:700}
|
||||
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:14px 0 10px;padding:9px 12px;border-radius:9px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3)}
|
||||
.jc-editgate-t{flex:1 1 auto;min-width:0}
|
||||
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
|
||||
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:7px;padding:6px 13px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color .16s,color .16s,background .16s}
|
||||
.jc-egbtn:hover{border-color:var(--brand);color:var(--brand)}
|
||||
.jc-egbtn.save{color:#fff;background:var(--human);border-color:var(--human)}
|
||||
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
|
||||
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
|
||||
.jc-editgate.on{background:var(--human-soft);border-color:var(--human-line);color:#7c4a12}
|
||||
.jc-editgate.on .eg-open{display:none}
|
||||
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.03em;font-weight:800;color:var(--ink-3);padding:9px 10px;border-bottom:1px solid var(--line)}
|
||||
th.req::after{content:" *";color:var(--danger);font-size:11px}
|
||||
td{padding:8px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td.rn{position:relative;width:38px;white-space:nowrap;font-family:var(--mono);font-size:11px;color:var(--ink-4);text-align:center}
|
||||
.jc-vcell{display:block;font-size:12.5px;color:var(--ink);line-height:1.5;overflow-wrap:anywhere}
|
||||
.jc-vcell.mono{font-family:var(--mono);font-size:11.5px;color:var(--ink-2)}
|
||||
.jc-cell{display:none;width:100%;min-width:60px;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;color:var(--ink);background:#fff}
|
||||
.jc-cell.mono{font-family:var(--mono);font-size:11.5px}
|
||||
.jc-cell:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-wash);outline:none}
|
||||
table.on td>.jc-vcell{display:none}
|
||||
table.on td>.jc-cell{display:block}
|
||||
td.rn .jc-rowdel{display:none;width:22px;height:22px;margin:0 auto;cursor:pointer;border:1px solid var(--line);border-radius:6px;padding:0;font:inherit;font-size:14px;line-height:1;color:var(--ink-3);background:#fff}
|
||||
td.rn .jc-rowdel:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
|
||||
table.on td.rn .jc-rn-num{display:none}
|
||||
table.on td.rn .jc-rowdel{display:block}
|
||||
.jc-addrow{display:none;margin:8px 0 0;padding:8px 12px;border-radius:9px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.jc-addrow.show{display:flex;align-items:center;gap:8px}
|
||||
.jc-addrow button{cursor:pointer;border:1px solid var(--brand-soft);border-radius:7px;padding:5px 12px;font:inherit;font-size:12px;font-weight:700;color:var(--brand-deep);background:var(--brand-wash)}
|
||||
.jc-addrow button:hover{background:var(--brand-soft)}
|
||||
.jc-addrow em{font-style:normal;font-size:11.5px;color:var(--ink-4)}
|
||||
.flow{margin-top:16px;padding:9px 12px;border-radius:8px;background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.flow b{color:var(--ink)}
|
||||
.card-foot{padding:13px 22px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:600px){.card-head{flex-wrap:wrap}.side{align-items:flex-start}}
|
||||
/* 补充:评估输入清单状态标识(仅追加,不改动基件样式) */
|
||||
.note .need{color:var(--data-deep);font-weight:700}
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
.st{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||
.st.bound{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
|
||||
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#c9ecc2}
|
||||
.st.miss{color:var(--human);background:var(--human-soft);border-color:var(--human-line)}
|
||||
.st.def{color:var(--brand-deep);background:var(--brand-wash);border-color:var(--brand-soft)}
|
||||
tr.ro td{background:var(--soft)}
|
||||
table.on td>.jc-vcell.ro{display:block}
|
||||
.st.new{color:var(--data-deep);background:var(--data-soft);border-color:var(--data-line)}
|
||||
.st.lock{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
|
||||
|
||||
/* ===== 编辑闸 + 必填进度 ===== */
|
||||
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 var(--s2);padding:10px 14px;border-radius:10px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3);transition:background var(--tr),border-color var(--tr)}
|
||||
.jc-editgate-t{flex:1 1 auto;min-width:0}
|
||||
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
|
||||
.gateprog{flex:1 1 100%;display:flex;align-items:center;gap:10px;margin-top:6px}
|
||||
.gateprog .track{flex:1;height:5px;border-radius:3px;background:var(--line-soft);overflow:hidden}
|
||||
.gateprog .fill{display:block;height:100%;width:0;border-radius:3px;background:linear-gradient(90deg,var(--data),var(--data-deep));transition:width .35s cubic-bezier(.2,.7,.3,1)}
|
||||
.gateprog .pct{font-family:var(--mono);font-size:11px;color:var(--ink-4);min-width:64px;text-align:right}
|
||||
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:8px;padding:7px 14px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color var(--tr),color var(--tr),background var(--tr),transform .12s}
|
||||
.jc-egbtn:hover{border-color:var(--data);color:var(--data)}
|
||||
.jc-egbtn:active{transform:scale(.97)}
|
||||
.jc-egbtn.save{color:#fff;background:var(--data);border-color:var(--data)}
|
||||
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
|
||||
.jc-egbtn:focus-visible{outline:2px solid var(--data-deep);outline-offset:2px}
|
||||
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
|
||||
.jc-editgate.on{background:var(--data-wash);border-color:var(--data-line);color:#1e40af}
|
||||
.jc-editgate.on .eg-open{display:none}
|
||||
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
|
||||
|
||||
/* ===== 渲染态:档案卡 ===== */
|
||||
.idcard{border:1px solid var(--line-soft);border-radius:var(--r-md);margin-bottom:var(--s2);background:linear-gradient(150deg,#fff 60%,var(--data-wash));display:grid;grid-template-columns:1.2fr 1fr;overflow:hidden}
|
||||
.idcard .cell{padding:18px 22px}
|
||||
.idcard .cell+.cell{border-left:1px solid var(--line-soft);background:rgba(255,255,255,.55)}
|
||||
.idcard .lab{font-size:11.5px;font-weight:800;color:var(--ink-3);letter-spacing:.08em;display:flex;align-items:center;gap:6px}
|
||||
.idcard .lab::before{content:"";width:8px;height:8px;border-radius:2.5px;background:var(--data)}
|
||||
.idcard .val{margin-top:8px;font-size:24px;font-weight:800;font-family:var(--mono);letter-spacing:-.01em;line-height:1.25;color:var(--ink)}
|
||||
.idcard .val.empty{font-size:14px;font-family:inherit;font-weight:400;color:var(--ink-4)}
|
||||
.idcard .val .tpill{display:inline-block;font-family:inherit;font-size:15px;font-weight:800;color:var(--data-deep);background:var(--data-soft);border:1px solid var(--data-line);border-radius:999px;padding:4px 14px;vertical-align:2px}
|
||||
.idcard .val .st{margin-left:8px;vertical-align:3px}
|
||||
|
||||
/* ===== 参数表(渲染态) ===== */
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.04em;font-weight:800;color:var(--ink-3);padding:10px 14px;border-bottom:1px solid var(--line)}
|
||||
td{padding:10px 14px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:0}
|
||||
tbody tr{transition:background .15s}
|
||||
tbody tr:hover{background:var(--data-wash)}
|
||||
.empty-row td{color:var(--ink-4);text-align:center;padding:22px}
|
||||
.empty-row td:hover{background:#fff}
|
||||
|
||||
/* ===== 编辑态:步骤轨道 ===== */
|
||||
.editarea{display:none}
|
||||
body.editing .editarea{display:block}
|
||||
body.editing .viewarea{display:none}
|
||||
.steps{position:relative}
|
||||
.steps::before{content:"";position:absolute;left:12px;top:20px;bottom:20px;width:2px;background:var(--line-soft);border-radius:1px}
|
||||
.stepbox{position:relative;padding-left:36px;margin-bottom:var(--s3)}
|
||||
.stepbox:last-child{margin-bottom:0}
|
||||
.sdot{position:absolute;left:0;top:0;width:26px;height:26px;border-radius:50%;background:#fff;border:2px solid var(--data-line);color:var(--data-deep);font-size:12px;font-weight:800;display:grid;place-items:center;transition:background var(--tr),border-color var(--tr),color var(--tr)}
|
||||
.stepbox.done .sdot{background:var(--data);border-color:var(--data);color:#fff}
|
||||
.scard{border:1px solid var(--line-soft);border-radius:var(--r-md);background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
|
||||
.stepbox.done .scard{border-color:var(--data-line)}
|
||||
.scard .sh{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line-soft);font-size:13.5px;font-weight:800;letter-spacing:-.01em}
|
||||
.scard .sh .req-note{margin-left:auto;font-size:11px;color:var(--ink-4);font-weight:400}
|
||||
.scard .sb{padding:16px}
|
||||
.fld{display:flex;align-items:flex-start;gap:12px;font-size:12.5px}
|
||||
.fld .fk{width:88px;flex:none;color:var(--ink-3);font-weight:700;font-size:12px;padding-top:9px}
|
||||
.fld .fk.req::after{content:" *";color:var(--danger)}
|
||||
.fld .fv{flex:1;min-width:0}
|
||||
.fld input,.fld select{width:100%;border:1px solid var(--line);border-radius:var(--r-sm);padding:9px 11px;font:inherit;font-size:13px;color:var(--ink);background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
|
||||
.fld input:focus,.fld select:focus{border-color:var(--data);box-shadow:0 0 0 3px var(--data-wash);outline:none}
|
||||
.hint{font-size:11.5px;color:var(--ink-4);margin-top:6px}
|
||||
|
||||
/* ===== 新增类型面板 ===== */
|
||||
.newtype{display:none;margin-top:12px;border:1px dashed var(--data-line);border-radius:10px;background:var(--data-wash);padding:14px 16px}
|
||||
.newtype.show{display:block;animation:rise .25s cubic-bezier(.2,.7,.3,1)}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
||||
.newtype h4{margin:0 0 10px;font-size:12.5px;color:var(--data-deep)}
|
||||
.prow{display:flex;gap:8px;align-items:center;margin-top:8px;animation:rise .2s cubic-bezier(.2,.7,.3,1)}
|
||||
.prow input{flex:1;border:1px solid var(--line);border-radius:7px;padding:8px 10px;font:inherit;font-size:12.5px;background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
|
||||
.prow input:focus{border-color:var(--data);box-shadow:0 0 0 3px rgba(51,112,255,.12);outline:none}
|
||||
.prow .del{flex:none;width:28px;height:28px;cursor:pointer;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink-3);font-size:14px;line-height:1;transition:border-color var(--tr),color var(--tr),background var(--tr)}
|
||||
.prow .del:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
|
||||
.nt-actions{display:flex;gap:8px;margin-top:12px;align-items:center}
|
||||
.btn{cursor:pointer;border:1px solid var(--data-line);border-radius:8px;padding:7px 14px;font:inherit;font-size:12px;font-weight:700;color:var(--data-deep);background:#fff;transition:background var(--tr),transform .12s}
|
||||
.btn:hover{background:var(--data-soft)}
|
||||
.btn:active{transform:scale(.97)}
|
||||
.btn.primary{color:#fff;background:var(--data);border-color:var(--data)}
|
||||
.btn.primary:hover{filter:brightness(1.07)}
|
||||
.btn:focus-visible{outline:2px solid var(--data-deep);outline-offset:2px}
|
||||
|
||||
/* ===== 参数值瓷贴 ===== */
|
||||
.pvgrid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
|
||||
.pv{border:1px solid var(--line-soft);border-radius:10px;padding:10px 12px;background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
|
||||
.pv:focus-within{border-color:var(--data-line);box-shadow:0 0 0 3px rgba(51,112,255,.08)}
|
||||
.pv .pn2{font-size:12px;font-weight:800;color:var(--ink-2);margin-bottom:6px;display:flex;align-items:baseline;gap:6px}
|
||||
.pv .pn2::after{content:"*";color:var(--danger);font-size:11px}
|
||||
.pv input{width:100%;border:1px solid var(--line);border-radius:7px;padding:7px 10px;font:inherit;font-size:12.5px;background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
|
||||
.pv input:focus{border-color:var(--data);box-shadow:0 0 0 3px rgba(51,112,255,.12);outline:none}
|
||||
.pv-empty{border:1px dashed var(--line);border-radius:10px;padding:20px;text-align:center;color:var(--ink-4);font-size:12px}
|
||||
|
||||
/* ===== 底 ===== */
|
||||
.flow{margin-top:var(--s2);padding:10px 14px;border-radius:var(--r-sm);background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.flow b{color:var(--ink)}
|
||||
.card-foot{padding:14px 24px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:760px){.pvgrid{grid-template-columns:1fr}.card-head{flex-wrap:wrap}.side{align-items:flex-start}.idcard{grid-template-columns:1fr}.idcard .cell+.cell{border-left:0;border-top:1px solid var(--line-soft)}}
|
||||
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||
`;
|
||||
|
||||
/*
|
||||
@@ -248,6 +220,22 @@ body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
||||
.note b{color:var(--ink)}
|
||||
/*
|
||||
* 补三条:report.ts 的 zone()/note() 助手把 P08 目标件「视觉补充段 v3」的图标
|
||||
* (.zic)与强调色(.note .done / .note .need)跨文档复用到了这两张验收件的
|
||||
* 区块头与提示文案上(见 report.ts 的 ZIC_TASK / ZIC_TABLE 常量与 note() 调用处)——
|
||||
* 原验收工单目标件自己的 style 段从未定义过它们(核对过
|
||||
* docs/SS02-ACT-WORK-05-html/ 下两份验收工单目标件,原型的 zone 段从没带过图标,
|
||||
* note 里也没有 done/need 分级)——补两条,值照 P08_CSS 抄
|
||||
* (2026-08-30 task-12,缺陷③;--data 这份 :root 没有,用同色的 --brand 顶替,
|
||||
* 数值仍是 #3370ff,不算改动目标件写死的颜色)。
|
||||
* 漏掉 .zic 的后果曾经是灾难性的:三枚 viewBox="0 0 24 24" 的图标在没有 .zic 约束
|
||||
* 宽高时会撑满容器宽、再按比例撑高,量出来是 946×946 像素、把整份文档撑到 4625px 高,
|
||||
* 正文全被挤出可视区(人打开只看见三块灰色巨块加大片空白)。
|
||||
*/
|
||||
.zic{width:14px;height:14px;color:var(--ink-3);vertical-align:-2px;margin-right:3px}
|
||||
.note .done{color:var(--ok);font-weight:700}
|
||||
.note .need{color:var(--brand);font-weight:700}
|
||||
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
|
||||
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
|
||||
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
|
||||
@@ -328,6 +316,22 @@ body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--ledger);border-radius:8px;background:var(--soft);padding:14px 16px;margin-bottom:16px}
|
||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
||||
.note b{color:var(--ink)}
|
||||
/*
|
||||
* 补三条:report.ts 的 zone()/note() 助手把 P08 目标件「视觉补充段 v3」的图标
|
||||
* (.zic)与强调色(.note .done / .note .need)跨文档复用到了这两张验收件的
|
||||
* 区块头与提示文案上(见 report.ts 的 ZIC_TASK / ZIC_TABLE 常量与 note() 调用处)——
|
||||
* 原验收工单目标件自己的 style 段从未定义过它们(核对过
|
||||
* docs/SS02-ACT-WORK-05-html/ 下两份验收工单目标件,原型的 zone 段从没带过图标,
|
||||
* note 里也没有 done/need 分级)——补两条,值照 P08_CSS 抄
|
||||
* (2026-08-30 task-12,缺陷③;--data 这份 :root 没有,用同色的 --brand 顶替,
|
||||
* 数值仍是 #3370ff,不算改动目标件写死的颜色)。
|
||||
* 漏掉 .zic 的后果曾经是灾难性的:三枚 viewBox="0 0 24 24" 的图标在没有 .zic 约束
|
||||
* 宽高时会撑满容器宽、再按比例撑高,量出来是 946×946 像素、把整份文档撑到 4625px 高,
|
||||
* 正文全被挤出可视区(人打开只看见三块灰色巨块加大片空白)。
|
||||
*/
|
||||
.zic{width:14px;height:14px;color:var(--ink-3);vertical-align:-2px;margin-right:3px}
|
||||
.note .done{color:var(--ok);font-weight:700}
|
||||
.note .need{color:var(--brand);font-weight:700}
|
||||
.criteria{border:1px solid var(--ledger-line);border-radius:10px;background:var(--ledger-soft);padding:13px 16px;margin-bottom:18px}
|
||||
.criteria .c-title{font-size:11px;font-weight:800;letter-spacing:.08em;color:var(--ledger);margin-bottom:7px}
|
||||
.criteria ul{margin:0;padding-left:18px;font-size:12.5px;color:var(--ink-2)}
|
||||
|
||||
+1
-1
@@ -7,6 +7,6 @@ export type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStor
|
||||
export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts';
|
||||
export { createInteractionService, visibleTo } from './internal/usecase/interaction-service.ts';
|
||||
export { CONTROL_HOLDERS, checkTemplate, handoverHappens, inputControl, INTERACTION_PHASES, outputControl, templateIsSafe, } from './internal/domain/control-handover.ts';
|
||||
export { renderView, renderTemplate, renderRows, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
||||
export { renderView, renderTemplate, renderRows, fillOptions, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
||||
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
||||
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
export type { AgentAction, AgentTurn, Narration, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts';
|
||||
export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, TURN_ACTION_CAP, } from './contract.ts';
|
||||
export type { AgentPort, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
|
||||
export type { AgentPort, AnalyzeGateway, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
|
||||
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
|
||||
export { createTurnRunner } from './internal/usecase/turn-runner.ts';
|
||||
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
|
||||
|
||||
@@ -38,6 +38,12 @@ export type TaskDataGateway = {
|
||||
object: string;
|
||||
label: string;
|
||||
keys: string[];
|
||||
/**
|
||||
* 单标识列(`declarations.ledger[].caseKey`)—— **摆给人看的行标识要去掉这一段**
|
||||
* (2026-08-25 柴浩:「COV-…/CU-0142-01 只保留斜杠右边」):人正在这一单里看,
|
||||
* 单号是脚下的地面,不是区分行的信息。基座那一侧同款做法见 `rowLabel`。
|
||||
*/
|
||||
caseKey?: string | undefined;
|
||||
fields: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
@@ -56,8 +62,23 @@ export type TaskDataGateway = {
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
/**
|
||||
* **值没变就别落笔**(2026-08-29 终审 I2)—— 配方每一拍都把同一张表重投一遍,
|
||||
* 而草稿层的 upsert 尾巴上有 `landed_at = null`(「再写一笔就不再是已落地」)。
|
||||
* 照写的话:数据工单确认入库之后,下一拍配方一重跑,那些行的「已落地」记号就被**悄悄擦掉**,
|
||||
* 整单放行时会被再写一遍正式表、再抛一遍 data 事件 —— 而承诺是「整单放行不再写它、不抛事件」。
|
||||
* 模型那一路不传这个旗:它的「空转写」要被看见(如实回 `changed:false`),行为一个字不变。
|
||||
*/
|
||||
skipUnchanged?: boolean | undefined;
|
||||
/**
|
||||
* **「只建不改」的表碰到已在库的键:跳过,不拒**(2026-08-29 终审 I2)。
|
||||
* 那句拒回是写给模型看的(「别再写它,用 drop 把骨架撤掉」)—— 摆到人面前毫无意义,
|
||||
* 而配方抛出去就是永久停单。对配方来说「这颗料已经在库了」是**常态**,不是错。
|
||||
*/
|
||||
skipExisting?: boolean | undefined;
|
||||
}): Promise<{
|
||||
changed: boolean;
|
||||
skipped?: boolean;
|
||||
}>;
|
||||
/**
|
||||
* **撤掉本单草稿层的一行** —— 写错了得有退路。
|
||||
@@ -389,3 +410,25 @@ export type TaskContext = {
|
||||
*/
|
||||
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
|
||||
};
|
||||
/**
|
||||
* **一次结构化 AI 调用** —— 配方的 `analyze` 动词(spec §5.2)。
|
||||
*
|
||||
* 与 `AgentPort` 的分工是死的:`AgentPort` 是「问它下一步干什么」,它能调工具、能写台账;
|
||||
* 这一件**只是一个纯函数**:系统组装好入参,它出一份结构化判断,系统把结果落表。
|
||||
* **不给工具、不给记忆、不多轮。** 出参不合 schema 由实现方就地重问一次,再不成就抛。
|
||||
*/
|
||||
export type AnalyzeGateway = {
|
||||
run(input: {
|
||||
readonly taskId: TaskId;
|
||||
/** 活动声明过的模型名 */
|
||||
readonly model: string;
|
||||
readonly instruction: string;
|
||||
/** 系统组装好的入参 —— 一行一条 */
|
||||
readonly payload: readonly unknown[];
|
||||
readonly outputSchema: Readonly<Record<string, unknown>>;
|
||||
/** 归因:`<步名>#<第几笔>` */
|
||||
readonly label: string;
|
||||
}): Promise<{
|
||||
readonly object: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -2237,6 +2237,8 @@ export declare const installActivityRoute: {
|
||||
review: "review";
|
||||
}>>;
|
||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
auto: z.ZodOptional<z.ZodType<import("../auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("../auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 确定性步骤的**声明式配方**(ADR-014 / spec 2026-08-29 §5.1)。
|
||||
*
|
||||
* 配方是**数据不是代码**:装进来仍然只是数据,可静态审、可回放、可在治理台上摆出
|
||||
* 「这一步系统会做什么」。执行第三方代码则相反 —— ADR-004 的立场是「不执行包代码即知包为何物」。
|
||||
*
|
||||
* **只声明真实现了的那几式。** 表达力有上限是刻意的:上限之外的不要硬塞,
|
||||
* 退回模型,或者把那一段判断从这一步里切出去。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/** 表达式 —— 递归,`z.lazy` 是唯一写法(zod 的自引用) */
|
||||
export type AutoExpr = string | number | boolean
|
||||
/** 入参 / 基座读数 / `let` 里算好的值 —— 三者同一个命名空间,`let` 优先 */
|
||||
| {
|
||||
readonly in: string;
|
||||
}
|
||||
/** 本行的列 */
|
||||
| {
|
||||
readonly col: string;
|
||||
}
|
||||
/** 外层那一行的列(两层嵌套时) */
|
||||
| {
|
||||
readonly outer: string;
|
||||
}
|
||||
/** 拼接,一律按字符串 */
|
||||
| {
|
||||
readonly cat: readonly AutoExpr[];
|
||||
}
|
||||
/** 只留数字(铸号:`case:openedAt` → `20260829215103`) */
|
||||
| {
|
||||
readonly fmt: AutoExpr;
|
||||
readonly as: 'digits';
|
||||
}
|
||||
/** 查另一张表的一列;查不到给 `else`(缺省空串) */
|
||||
| {
|
||||
readonly lookup: string;
|
||||
readonly by: Readonly<Record<string, AutoExpr>>;
|
||||
readonly take: string;
|
||||
readonly else?: AutoExpr | undefined;
|
||||
}
|
||||
/** S-FEEL 条件(可嵌套)—— 已算出的同行字段进它的作用域 */
|
||||
| {
|
||||
readonly when: string;
|
||||
readonly then: AutoExpr;
|
||||
readonly else: AutoExpr;
|
||||
};
|
||||
export declare const autoExprSchema: z.ZodType<AutoExpr>;
|
||||
/**
|
||||
* 数据源 —— 这一笔要对着几行办。
|
||||
*
|
||||
* 地址只有四种(spec §5.1):`域.对象`(**任务视图 = 正式表 + 本单草稿叠加**,默认该用的那一种)·
|
||||
* `mine:域.对象`(只有本单草稿写下的行)· `in:入参数组` · `result:xxx`(本拍 analyze 的出参)。
|
||||
* **没有「纯正式表」那一种** —— 要区分「本单写的」与「别人放行的」,`mine:` 就够了。
|
||||
*/
|
||||
export type AutoSource = {
|
||||
readonly once: true;
|
||||
} | {
|
||||
readonly rows: readonly Readonly<Record<string, AutoExpr>>[];
|
||||
} | {
|
||||
readonly each: string;
|
||||
readonly where?: Readonly<Record<string, AutoExpr>> | undefined;
|
||||
/** 第二层,**到此为止** —— 三层以上就别 auto 了 */
|
||||
readonly then?: {
|
||||
readonly each: string;
|
||||
readonly where?: Readonly<Record<string, AutoExpr>> | undefined;
|
||||
} | undefined;
|
||||
};
|
||||
export declare const autoSourceSchema: z.ZodType<AutoSource>;
|
||||
/** 五个动词 —— `when` 为假就跳过这一笔 */
|
||||
export type AutoAction = {
|
||||
readonly kind: 'project';
|
||||
readonly when?: string | undefined;
|
||||
/** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */
|
||||
readonly into: string;
|
||||
/** 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层 */
|
||||
readonly prune?: boolean | undefined;
|
||||
readonly from: AutoSource;
|
||||
readonly fields: Readonly<Record<string, AutoExpr>>;
|
||||
} | {
|
||||
readonly kind: 'analyze';
|
||||
readonly when?: string | undefined;
|
||||
/** 活动声明过的模型名 */
|
||||
readonly model: string;
|
||||
/** 出参落进拍内作用域 `result:<into>`,供后面的 project 用 `each`/`lookup` 消费 */
|
||||
readonly into: string;
|
||||
/** 给模型的 system 段 —— 说清判什么、四态各是什么意思 */
|
||||
readonly instruction: string;
|
||||
/** 入参由声明组装:一行一条,`fields` 决定每条摆哪几个键 */
|
||||
readonly input: {
|
||||
readonly from: AutoSource;
|
||||
readonly fields: Readonly<Record<string, AutoExpr>>;
|
||||
};
|
||||
/** 出参 JSON Schema —— 顶层必须是 object */
|
||||
readonly output: Readonly<Record<string, unknown>>;
|
||||
} | {
|
||||
readonly kind: 'card';
|
||||
readonly when?: string | undefined;
|
||||
readonly as: 'execution' | 'decide';
|
||||
readonly variant?: ('data' | 'review') | undefined;
|
||||
readonly title: AutoExpr;
|
||||
/** 决策卡问的那句话 */
|
||||
readonly ask?: AutoExpr | undefined;
|
||||
/** 作业指导书 —— 一条一句,静态文本 */
|
||||
readonly guide?: readonly string[] | undefined;
|
||||
readonly standard?: AutoExpr | undefined;
|
||||
/** 调用必附上下文 —— **基座拒发 basis 为空的工单**,别指望兜底(它摆的是内部家具) */
|
||||
readonly basis?: readonly {
|
||||
readonly k: string;
|
||||
readonly v: AutoExpr;
|
||||
}[] | undefined;
|
||||
/** 这张卡的提交闸盯哪几张表 */
|
||||
readonly gateLedgers?: readonly string[] | undefined;
|
||||
/** 决策卡的选项;`variant:'review'` 时允许只有一个 */
|
||||
readonly options?: readonly {
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly detail?: string | undefined;
|
||||
}[] | undefined;
|
||||
} | {
|
||||
readonly kind: 'submit';
|
||||
readonly when?: string | undefined;
|
||||
readonly rationale: AutoExpr;
|
||||
} | {
|
||||
readonly kind: 'say';
|
||||
readonly when?: string | undefined;
|
||||
readonly text: AutoExpr;
|
||||
};
|
||||
export declare const autoActionSchema: z.ZodType<AutoAction>;
|
||||
export type AutoRecipe = {
|
||||
/** 配方级局部值:先算一遍,后面所有表达式都能 `{in:'名'}` 引用 —— 整单只有一个案号,靠它 */
|
||||
readonly let?: Readonly<Record<string, AutoExpr>> | undefined;
|
||||
/** 按序执行 */
|
||||
readonly do: readonly AutoAction[];
|
||||
};
|
||||
export declare const autoRecipeSchema: z.ZodType<AutoRecipe>;
|
||||
@@ -15,4 +15,5 @@ export * from './manifest.ts';
|
||||
export * from './org.ts';
|
||||
export * from './mail.ts';
|
||||
export * from './activity-ops.ts';
|
||||
export * from './auto-recipe.ts';
|
||||
export * from './api/index.ts';
|
||||
|
||||
+10
@@ -207,6 +207,8 @@ export declare const planStepSchema: z.ZodObject<{
|
||||
review: "review";
|
||||
}>>;
|
||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||
}, z.core.$strip>;
|
||||
export type PlanStep = z.infer<typeof planStepSchema>;
|
||||
/**
|
||||
@@ -272,6 +274,8 @@ export declare const loopModelSchema: z.ZodObject<{
|
||||
review: "review";
|
||||
}>>;
|
||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
@@ -331,6 +335,8 @@ export declare const businessModelSchema: z.ZodObject<{
|
||||
review: "review";
|
||||
}>>;
|
||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
@@ -488,6 +494,8 @@ export declare const manifestAssetsSchema: z.ZodObject<{
|
||||
review: "review";
|
||||
}>>;
|
||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
@@ -793,6 +801,8 @@ export declare const activityManifestSchema: z.ZodObject<{
|
||||
review: "review";
|
||||
}>>;
|
||||
acceptanceChecklist: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
humanWrites: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
auto: z.ZodOptional<z.ZodType<import("./auto-recipe.ts").AutoRecipe, unknown, z.core.$ZodTypeInternals<import("./auto-recipe.ts").AutoRecipe, unknown>>>;
|
||||
}, z.core.$strip>>>;
|
||||
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
|
||||
+22
@@ -10,3 +10,25 @@ export declare function isDefined<T>(value: T | null | undefined): value is T;
|
||||
export declare function groupBy<T, K extends string>(items: readonly T[], key: (item: T) => K): Record<K, T[]>;
|
||||
/** 稳定排序的键值对遍历(避免 Object.keys 顺序依赖) */
|
||||
export declare function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]>;
|
||||
export type TopoOrderResult = {
|
||||
readonly ok: true;
|
||||
readonly order: readonly string[];
|
||||
} | {
|
||||
readonly ok: false;
|
||||
readonly cycle: readonly string[];
|
||||
};
|
||||
/**
|
||||
* 拓扑排序(Kahn 算法)—— 给定节点集合与「这个节点依赖谁」,排出一个安全的处理序
|
||||
* (被依赖的排前面)。
|
||||
*
|
||||
* 同族算法已有一份在 `apps/api/src/modules/process/internal/domain/routing.ts` 的
|
||||
* `advanceOrder`(那是流程图专用的,找不齐拓扑序时把剩下的节点原样缀在末尾 ——
|
||||
* 流程图的容错策略)。这里是它的**通用版**,故意搬到叶子包:两个不同层级的模块
|
||||
* (`activity-registry` 装包静态审 / `loop-engine` 配方求值)都要用同一个算法判「排不排得出来」,
|
||||
* 而模块分层不许它们互相 import ——图算法本身不含业务语义,该住在 `@utils`。
|
||||
*
|
||||
* **不兜底**:排不出来就是这几个节点之间有环,原样报出来,由调用方决定怎么处置
|
||||
* (装包时机检报错 / 运行时交人),不在这一层替调用方决定「怎么办」。
|
||||
* `dependsOn` 返回的名字如果不在 `nodes` 里,当作没这条依赖(忽略,不会因此误报成环)。
|
||||
*/
|
||||
export declare function topoOrder(nodes: readonly string[], dependsOn: (node: string) => readonly string[]): TopoOrderResult;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { taskIdSchema } from '@contracts';
|
||||
import { createAnalyzeGateway } from '../wiring/analyze-gateway.ts';
|
||||
|
||||
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||
const SCHEMA = { type: 'object', required: ['items'], properties: { items: { type: 'array' } } };
|
||||
const call = { taskId: TASK, model: 'deepseek-v4-flash', instruction: '逐条判四态', payload: [{ p: 'A' }], outputSchema: SCHEMA, label: 'S2#1' };
|
||||
|
||||
describe('analyze 网关', () => {
|
||||
it('出参是对象就直接回,入参 JSON 进 prompt、instruction 进 system', async () => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
const gateway = createAnalyzeGateway({
|
||||
generate: (c: Record<string, unknown>) => {
|
||||
seen.push(c);
|
||||
return Promise.resolve({ text: '', object: { items: [{ p: 'A' }] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
|
||||
},
|
||||
} as never);
|
||||
const out = await gateway.run(call);
|
||||
expect(out.object).toEqual({ items: [{ p: 'A' }] });
|
||||
expect(seen[0]?.['system']).toBe('逐条判四态');
|
||||
expect(String(seen[0]?.['prompt'])).toContain('"p": "A"');
|
||||
expect(seen[0]?.['reasoning']).toBe('off');
|
||||
});
|
||||
|
||||
it('出参不成形 → **就地重问一次**,第二次成了就用它', async () => {
|
||||
let n = 0;
|
||||
const gateway = createAnalyzeGateway({
|
||||
generate: () => {
|
||||
n += 1;
|
||||
return Promise.resolve(
|
||||
n === 1
|
||||
? { text: 'not json', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }
|
||||
: { text: '', object: { items: [] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' },
|
||||
);
|
||||
},
|
||||
} as never);
|
||||
const out = await gateway.run(call);
|
||||
expect(n).toBe(2);
|
||||
expect(out.object).toEqual({ items: [] });
|
||||
});
|
||||
|
||||
it('重问了还是不成形 → 抛,交人;**不重问第三次**', async () => {
|
||||
let n = 0;
|
||||
const gateway = createAnalyzeGateway({
|
||||
generate: () => {
|
||||
n += 1;
|
||||
return Promise.resolve({ text: 'nope', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
|
||||
},
|
||||
} as never);
|
||||
await expect(gateway.run(call)).rejects.toThrow(/出参不成形/);
|
||||
expect(n).toBe(2);
|
||||
});
|
||||
|
||||
/*
|
||||
* **两次调用的用量都要交回去记账**(2026-08-29 终审 C1)。
|
||||
* `createModelGateway` 自己不做 `addConsumption` —— 不给 `onUsage`,配方里的 analyze
|
||||
* 就是一条问模型不花钱的路,活动预算对它形同虚设。重问那一次照样烧真钱,一并记。
|
||||
*/
|
||||
it('用量交回 onUsage:第一次成的记一次,重问了记两次', async () => {
|
||||
const billed: Array<{ taskId: string; tokens: number }> = [];
|
||||
const once = createAnalyzeGateway(
|
||||
{
|
||||
generate: () =>
|
||||
Promise.resolve({ text: '', object: { items: [] }, usage: { inputTokens: 30, outputTokens: 12, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }),
|
||||
} as never,
|
||||
(taskId, tokens) => { billed.push({ taskId, tokens }); return Promise.resolve(); },
|
||||
);
|
||||
await once.run(call);
|
||||
expect(billed).toEqual([{ taskId: TASK, tokens: 42 }]);
|
||||
|
||||
billed.length = 0;
|
||||
let n = 0;
|
||||
const twice = createAnalyzeGateway(
|
||||
{
|
||||
generate: () => {
|
||||
n += 1;
|
||||
return Promise.resolve(
|
||||
n === 1
|
||||
? { text: 'not json', usage: { inputTokens: 5, outputTokens: 5, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }
|
||||
: { text: '', object: { items: [] }, usage: { inputTokens: 7, outputTokens: 3, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' },
|
||||
);
|
||||
},
|
||||
} as never,
|
||||
(taskId, tokens) => { billed.push({ taskId, tokens }); return Promise.resolve(); },
|
||||
);
|
||||
await twice.run(call);
|
||||
expect(billed.map((b) => b.tokens)).toEqual([10, 10]);
|
||||
});
|
||||
|
||||
it('两次都不成形也照样记两笔 —— 失败的调用不是免费的', async () => {
|
||||
const billed: number[] = [];
|
||||
const gateway = createAnalyzeGateway(
|
||||
{
|
||||
generate: () =>
|
||||
Promise.resolve({ text: 'nope', usage: { inputTokens: 4, outputTokens: 6, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }),
|
||||
} as never,
|
||||
(_taskId, tokens) => { billed.push(tokens); return Promise.resolve(); },
|
||||
);
|
||||
await expect(gateway.run(call)).rejects.toThrow(/出参不成形/);
|
||||
expect(billed).toEqual([10, 10]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* **决策卡的面孔与人工验收清单,兜底在装配层这一处**(2026-08-29 终审 C2)。
|
||||
*
|
||||
* 病:配方(`auto-runner`)发审核卡时只递了 `variant`,没递 `checklist` ——
|
||||
* 而装配层从前只给 `variant` 兜底。于是那张卡穿着验收工单的衣服、`allowCustom` 也对,
|
||||
* **一条待确认都没有**:回传门的 `checklistMissing` 在 checklist 缺席时恒过,
|
||||
* 「确认通过」当场可按,2026-08-27 用户当面定的那道人工验收闸凭空没了。
|
||||
* 屏上还看不出来 —— 清单区是空的,不是红的。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { checkChatCardReply } from '@modules/human-tool';
|
||||
import { cardIdSchema } from '@contracts';
|
||||
import type { ChatCard, ChatCardReply } from '@contracts';
|
||||
import { decisionCardFace, needsStepLookup } from '../wiring/engines.ts';
|
||||
|
||||
const RELEASE_STEP = {
|
||||
cardVariant: 'review' as const,
|
||||
acceptanceChecklist: ['逐条核对差异明细', '确认推荐等级与依据一致'],
|
||||
};
|
||||
|
||||
describe('决策卡的面孔与清单(装配层兜底)', () => {
|
||||
it('调用方递了清单就用它的 —— 引擎按剧本定好递过来,兜底不抢', () => {
|
||||
const face = decisionCardFace({ variant: 'review', checklist: ['我说了算'] }, RELEASE_STEP);
|
||||
expect(face.checklist).toEqual(['我说了算']);
|
||||
expect(face.reviewFace).toBe(true);
|
||||
});
|
||||
|
||||
it('**配方那一路只递了 variant:清单退回剧本声明的**(C2 的正主)', () => {
|
||||
const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP);
|
||||
expect(face.reviewFace).toBe(true);
|
||||
expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist);
|
||||
});
|
||||
|
||||
it('面孔也照旧退回剧本(2026-08-27 那条不许回退)', () => {
|
||||
const face = decisionCardFace({}, RELEASE_STEP);
|
||||
expect(face.reviewFace).toBe(true);
|
||||
});
|
||||
|
||||
it('剧本没声明清单:就是没有 —— 不发明一条', () => {
|
||||
const face = decisionCardFace({ variant: 'review' }, { cardVariant: 'review' });
|
||||
expect(face.checklist).toBeUndefined();
|
||||
});
|
||||
|
||||
it('空清单当没给:退回剧本(空数组在回传门那儿等于没有闸)', () => {
|
||||
const face = decisionCardFace({ variant: 'review', checklist: [] }, RELEASE_STEP);
|
||||
expect(face.checklist).toEqual(RELEASE_STEP.acceptanceChecklist);
|
||||
});
|
||||
|
||||
/**
|
||||
* **清单兜底得看 `reviewFace`**(2026-08-29 复审 I2 二审)。
|
||||
*
|
||||
* `isReleaseStep`(turn-runner.ts)认的形状是:一步声明了 `acceptanceChecklist`(给终局
|
||||
* `AcceptForm` 用)而 `cardVariant` 不是 `review`(T_release 本身就是这种形状)。配方若在
|
||||
* **这一步**发一张普通 `as:'decide'` 卡(没传 variant),从前这里照样把清单兜上去 ——
|
||||
* 前端只在 `variant === 'review'` 时才渲染清单,人看不到勾可打,回传门却要求
|
||||
* `checked ⊇ checklist`:卡按不下去。
|
||||
*/
|
||||
const NON_REVIEW_STEP_WITH_CHECKLIST = { acceptanceChecklist: ['逐条核对差异明细'] };
|
||||
|
||||
it('**非审核面孔的步声明了清单(T_release 那种形状),配方发普通决策卡:清单不兜上去**(I2 的二审正主)', () => {
|
||||
const face = decisionCardFace({}, NON_REVIEW_STEP_WITH_CHECKLIST);
|
||||
expect(face.reviewFace).toBe(false);
|
||||
expect(face.checklist).toBeUndefined();
|
||||
});
|
||||
|
||||
it('同一步:调用方明确递了 variant:review,清单才兜上去 —— reviewFace 是唯一的开关', () => {
|
||||
const face = decisionCardFace({ variant: 'review' }, NON_REVIEW_STEP_WITH_CHECKLIST);
|
||||
expect(face.reviewFace).toBe(true);
|
||||
expect(face.checklist).toEqual(NON_REVIEW_STEP_WITH_CHECKLIST.acceptanceChecklist);
|
||||
});
|
||||
|
||||
it('查不查剧本:没 itemId 查不了;两样都递全了不必查', () => {
|
||||
expect(needsStepLookup({})).toBe(false);
|
||||
expect(needsStepLookup({ itemId: 'T_release' })).toBe(true);
|
||||
expect(needsStepLookup({ itemId: 'T_release', variant: 'review' })).toBe(true);
|
||||
expect(needsStepLookup({ itemId: 'T_release', variant: 'review', checklist: ['a'] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **闸真的因此有牙** —— 上面挑出来的清单接到回传门上,「确认通过」才拦得住。
|
||||
* 没有这一条,`decisionCardFace` 的断言只证明「函数回了个数组」。
|
||||
*/
|
||||
describe('清单缺席 = 闸恒过(这就是 C2 的后果)', () => {
|
||||
const CARD_ID = cardIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||
const card = (checklist?: ReadonlyArray<string>): ChatCard => ({
|
||||
k: 'decision',
|
||||
id: CARD_ID,
|
||||
at: '2026-08-29T00:00:00.000Z',
|
||||
ask: '差异评估结论审核',
|
||||
basis: [],
|
||||
variant: 'review',
|
||||
allowCustom: false,
|
||||
options: [{ value: 'approve', label: '确认通过' }],
|
||||
...(checklist === undefined ? {} : { checklist: [...checklist] }),
|
||||
});
|
||||
const reply: ChatCardReply = { k: 'decision', cardId: CARD_ID, value: 'approve' };
|
||||
|
||||
it('没有清单:一条都没点也通得过 —— 这正是配方发的那张卡从前的样子', () => {
|
||||
expect(checkChatCardReply(card(), reply)).toEqual([]);
|
||||
});
|
||||
|
||||
it('清单兜底进来之后:一条没点就拦', () => {
|
||||
const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP);
|
||||
const violations = checkChatCardReply(card(face.checklist), reply);
|
||||
expect(violations.map((v) => v.code)).toEqual(['checklist-incomplete']);
|
||||
});
|
||||
|
||||
it('清单点全了才放行', () => {
|
||||
const face = decisionCardFace({ variant: 'review' }, RELEASE_STEP);
|
||||
const violations = checkChatCardReply(card(face.checklist), { ...reply, checked: [...RELEASE_STEP.acceptanceChecklist] });
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,307 +1,26 @@
|
||||
/**
|
||||
* ACT-WORK-05 差异评估的两枚纯工具 —— 「能算的不问 LLM」:同输入必同结论。
|
||||
* 用例逐条对应《ACT-WORK-05-活动流程与工单说明》§八的示例矩阵:
|
||||
* A XL2596S-5.0 条件推荐(使能阈值未披露 → UNKNOWN)/ B LM2596S-ADJ 备选(可调版靠外围配成 5V)/
|
||||
* C 国产 DCDC 不推荐(输入上限 32V < 客户 40V,硬约束 MISMATCH)。
|
||||
* 【ACT-WORK-05 两枚工具的墓志铭】`part-substitution-assessment.compare-parameters`
|
||||
* (逐参数四态判定)与 `part-substitution-assessment.recommend`(推荐等级定级)曾经在
|
||||
* `../tools/first-party.ts` 里跑,这份文件曾逐条钉死它们的比对语义(硬/软约束、范围
|
||||
* 解析、客户约束边界、四档推荐等级……)。
|
||||
*
|
||||
* ADR-014(确定性步骤)落地后,这两步不再需要工具调用:四态判定改成
|
||||
* `activities/part-substitution-assessment` 的 `case-model.ts` 里 `S2_assess.auto`
|
||||
* 唯一一次 `analyze`(系统组装入参、模型只答判断),推荐等级改成同一步 `do` 链里的
|
||||
* 投影按 spec §3.5 的 S-FEEL when 链现算 —— 规则只许有一处真源,这两枚工具与它们
|
||||
* 对应的 D01/D02 两张 DMN 已经同批退役(见 `first-party.ts` 里留的墓志铭)。
|
||||
*
|
||||
* 这份文件不删(§12.5「不删测试」的铁律拦的是「删测试让门禁变绿」),但逐条比对
|
||||
* 用例测的是已经不存在的代码,只能跟着它测的东西一起退场,缩成一条退役断言 ——
|
||||
* 拦住的是「哪天有人手滑又把这两枚工具注册回去」。完整的历史用例见 git 历史。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FIRST_PARTY_TOOLS, compareParametersWith, describeParsed, parseParamValue } from '../tools/first-party.ts';
|
||||
import type { ParameterLedger } from '../tools/first-party.ts';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { FIRST_PARTY_TOOLS } from '../tools/first-party.ts';
|
||||
|
||||
const handler = (name: string) => {
|
||||
const found = FIRST_PARTY_TOOLS.find(([n]) => n === name);
|
||||
if (found === undefined) throw new Error(`没有工具 ${name}`);
|
||||
return found[1];
|
||||
};
|
||||
/** ToolHandler 的入参形状见 `apps/api/src/modules/toolbox/internal/adapter/toolbox-store.ts`:纯工具不用连接目标 */
|
||||
const call = async (name: string, input: Record<string, unknown>) => {
|
||||
const out = await handler(name)({ input, connection: {}, operation: undefined, idempotencyKey: undefined });
|
||||
if (!out.ok) throw new Error(`工具失败:${JSON.stringify(out)}`);
|
||||
return out.output as Record<string, unknown>;
|
||||
};
|
||||
|
||||
type Item = { parameterName: string; compareResult: string; differenceStatus: string; riskLevel: string };
|
||||
type Result = { candidateInternalPn: string; items: Item[]; counts: Record<string, number> };
|
||||
|
||||
const pair = (parameterName: string, originalValue: string, candidateValue: string, constraintType: 'hard' | 'soft', extra: Record<string, string> = {}) => ({
|
||||
parameterName,
|
||||
originalValue,
|
||||
candidateValue,
|
||||
constraintType,
|
||||
evidenceRef: 'P-08 · P-10 Rev H',
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('边界上的 null:模型给 null 不该把工具打崩', () => {
|
||||
it('**requirement / evidenceRef 给 null 照常算**(2026-08-27 W05 e2e 实测:模型写 `requirement: null` 而不是省掉,`null.trim()` 当场崩,整次调用记成「行动被拒」)', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [
|
||||
{
|
||||
candidateInternalPn: 'LM2596S-ADJ',
|
||||
pairs: [
|
||||
{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '1.23~37', constraintType: 'hard', unit: 'V', requirement: null, evidenceRef: null },
|
||||
{ parameterName: '输入电压范围', originalValue: '4.5~40', candidateValue: '4.5~40', constraintType: 'hard', unit: 'V', requirement: '>= 40', evidenceRef: 'P-10 Rev H' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const items = (out as { results: Array<{ items: Array<{ parameterName: string; differenceStatus: string }> }> }).results[0]!.items;
|
||||
expect(items.find((i) => i.parameterName === '输出电压')?.differenceStatus).toBe('conditional');
|
||||
expect(items.find((i) => i.parameterName === '输入电压范围')?.differenceStatus).toBe('match');
|
||||
});
|
||||
|
||||
it('硬约束的 evidenceRef 给 null = 没给证据 → 不判 MATCH,退成 CONDITIONAL', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [
|
||||
{
|
||||
candidateInternalPn: 'X',
|
||||
pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', constraintType: 'hard', evidenceRef: null }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect((out as { results: Array<{ items: Array<{ differenceStatus: string }> }> }).results[0]!.items[0]!.differenceStatus).toBe('conditional');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compare-parameters:逐参数四态', () => {
|
||||
it('候选温域更宽 → covers → MATCH 低风险;固定 vs 可调 → configurable → CONDITIONAL;未披露 → UNKNOWN', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [
|
||||
{
|
||||
candidateInternalPn: 'XL2596S-5.0',
|
||||
pairs: [
|
||||
pair('工作温度', '-40~+85', '-40~+125', 'hard', { unit: '℃' }),
|
||||
pair('输出电压', '5.0', '5.0', 'hard', { unit: 'V' }),
|
||||
pair('使能阈值', '1.4', '未披露', 'hard', { unit: 'V' }),
|
||||
pair('静态电流', '5', '4.2', 'soft', { unit: 'mA' }),
|
||||
],
|
||||
},
|
||||
{
|
||||
candidateInternalPn: 'LM2596S-ADJ',
|
||||
pairs: [pair('输出电压', '5.0', '1.23~37', 'hard', { unit: 'V' })],
|
||||
},
|
||||
],
|
||||
});
|
||||
const results = out['results'] as Result[];
|
||||
const a = results[0]!;
|
||||
const b = results[1]!;
|
||||
expect(out['ruleVersion']).toBe('R-2026.07');
|
||||
const by = (r: Result, name: string) => r.items.find((i) => i.parameterName === name);
|
||||
expect(by(a, '工作温度')).toMatchObject({ compareResult: 'covers', differenceStatus: 'match', riskLevel: 'low' });
|
||||
expect(by(a, '输出电压')).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' });
|
||||
expect(by(a, '使能阈值')).toMatchObject({ compareResult: 'unknown', differenceStatus: 'unknown', riskLevel: 'medium' });
|
||||
expect(by(a, '静态电流')).toMatchObject({ differenceStatus: 'conditional' }); // 软约束数值不同 → 条件
|
||||
expect(a.counts).toMatchObject({ match: 2, unknown: 1, conditional: 1, mismatch: 0, hardMismatch: 0 });
|
||||
expect(by(b, '输出电压')).toMatchObject({ compareResult: 'configurable', differenceStatus: 'conditional' });
|
||||
expect(b.counts['configurable']).toBe(1);
|
||||
});
|
||||
|
||||
it('硬约束范围变窄 → MISMATCH 高风险;客户约束(requirement)不满足也是 MISMATCH', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [
|
||||
{
|
||||
candidateInternalPn: 'CN-DCDC-32V',
|
||||
pairs: [
|
||||
pair('输入电压范围', '4.5~40', '4.5~32', 'hard', { unit: 'V', requirement: '>= 40' }),
|
||||
pair('封装', 'TO-263-5', 'TO-263-5', 'hard'),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const c = (out['results'] as Result[])[0]!;
|
||||
expect(c.items[0]).toMatchObject({ compareResult: 'narrower', differenceStatus: 'mismatch', riskLevel: 'high' });
|
||||
expect(c.items[1]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match' });
|
||||
expect(c.counts).toMatchObject({ mismatch: 1, hardMismatch: 1, match: 1 });
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-27 晚,用户送来 0bddd862:客户写「输入电压在 45V 以内」→ requirement ">= 45",而原 PN 与候选的输入
|
||||
* 上限都是 40V。从前客户约束一票否决:两颗一模一样却判硬约束 MISMATCH、整颗不推荐。差异评估比的是
|
||||
* 「换上去会不会比现在差」—— 原 PN 自己也过不了的客户线,是需求边界的事,不是替代差异。
|
||||
*/
|
||||
it('候选与原 PN 相等、客户约束两颗都不满足 → 仍是 MATCH(风险中、备注点名需求边界),不记在候选头上', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [
|
||||
{
|
||||
candidateInternalPn: 'LM2596S-ADJ',
|
||||
pairs: [
|
||||
pair('输入电压范围', '4.5~40', '4.5~40', 'hard', { unit: 'V', requirement: '>= 45' }),
|
||||
// 候选盖住原范围、客户线两颗都过不去 —— 同理
|
||||
pair('工作温度', '-40~85', '-40~125', 'hard', { unit: '℃', requirement: '-55~125' }),
|
||||
// 候选比原 PN 窄且过不了客户线 —— 仍是 MISMATCH
|
||||
pair('输出电流', '3', '2', 'hard', { unit: 'A', requirement: '>= 3' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const c = (out['results'] as Result[])[0]!;
|
||||
expect(c.items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', riskLevel: 'medium' });
|
||||
expect((c.items[0] as Item & { note: string }).note).toMatch(/需求边界/);
|
||||
expect(c.items[1]).toMatchObject({ compareResult: 'covers', differenceStatus: 'match' });
|
||||
expect(c.items[2]).toMatchObject({ compareResult: 'differs', differenceStatus: 'mismatch', riskLevel: 'high' });
|
||||
expect(c.counts).toMatchObject({ match: 2, mismatch: 1, hardMismatch: 1 });
|
||||
});
|
||||
|
||||
it('硬约束相等但没有证据引用 → 不判 MATCH,降为 CONDITIONAL(硬约束项未经证据支持不得判 MATCH)', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [{ candidateInternalPn: 'X', pairs: [{ parameterName: '封装', originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard' }] }],
|
||||
});
|
||||
const x = (out['results'] as Result[])[0]!;
|
||||
expect(x.items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'conditional' });
|
||||
});
|
||||
|
||||
it('数值带单位/括号注记也认得:「5.0(固定)」「3A」「150 kHz」;生命周期按活跃度比', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [{ candidateInternalPn: 'X', pairs: [pair('输出电压', '5.0(固定)', '5.0 V', 'hard'), pair('输出电流', '3A', '3', 'hard'), pair('开关频率', '150 kHz', '150', 'hard'), pair('生命周期', 'nrnd', 'active', 'soft')] }],
|
||||
});
|
||||
const x = (out['results'] as Result[])[0]!;
|
||||
// 生命周期不是数值:候选比原料更活(nrnd → active)算 covers → MATCH,替 NRND 料本就是为了这个
|
||||
expect(x.items.map((i) => i.differenceStatus)).toEqual(['match', 'match', 'match', 'match']);
|
||||
expect(x.items[3]?.compareResult).toBe('covers');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* **比对入参由基座从台账取**(2026-08-27 晚,用户:「要让它认真比对每一条,然后再输出结论」):
|
||||
* 模型只报料号与客户约束;参数 / 封装 / 生命周期 / 证据引用由工具从 parts.* 现读。
|
||||
*/
|
||||
describe('compare-parameters:按料号从台账取参数', () => {
|
||||
const rows: Record<string, Array<Record<string, unknown>>> = {
|
||||
'parts.parameter|LM2596S-5.0': [
|
||||
{ internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard' },
|
||||
{ internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', constraint_type: 'hard' },
|
||||
{ internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard' },
|
||||
],
|
||||
'parts.parameter|LM2596S-ADJ': [
|
||||
{ internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard' },
|
||||
{ internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', constraint_type: 'hard' },
|
||||
// 候选多出来的参数不进比对(原 PN 定义比对集),只在 extraCandidateParams 里点名
|
||||
{ internal_pn: 'LM2596S-ADJ', param_name: '反馈电压', param_value: '1.23', param_unit: 'V', constraint_type: 'soft' },
|
||||
],
|
||||
'parts.profile|LM2596S-5.0': [{ internal_pn: 'LM2596S-5.0', package: 'TO-263-5', lifecycle_status: 'nrnd' }],
|
||||
'parts.profile|LM2596S-ADJ': [{ internal_pn: 'LM2596S-ADJ', package: 'TO-263-5', lifecycle_status: 'active' }],
|
||||
'parts.document|LM2596S-ADJ': [{ internal_pn: 'LM2596S-ADJ', document_type: 'datasheet', document_version: 'Rev H (2023-09)', document_ref: 'https://x/lm2596.pdf' }],
|
||||
};
|
||||
const seen: string[] = [];
|
||||
const ledger: ParameterLedger = {
|
||||
rows: async (taskId, address, where) => {
|
||||
seen.push(`${taskId ?? '-'}:${address.domain}.${address.object}|${where['internal_pn']}`);
|
||||
return rows[`${address.domain}.${address.object}|${where['internal_pn']}`] ?? [];
|
||||
},
|
||||
};
|
||||
const tool = compareParametersWith(ledger);
|
||||
const run = async (input: Record<string, unknown>, taskId?: TaskId) => {
|
||||
const out = await tool({ input, connection: {}, operation: undefined, idempotencyKey: undefined, taskId });
|
||||
return out;
|
||||
};
|
||||
|
||||
it('只给料号 + 客户约束 → 每一对都是台账原值;封装 / 生命周期从档案来;证据引用从资料记录来;读的是**这一单**的任务视图', async () => {
|
||||
const out = await run({ originalInternalPn: 'LM2596S-5.0', candidates: [{ candidateInternalPn: 'LM2596S-ADJ', requirements: { 输入电压范围: '>= 45' } }] }, 't-1' as TaskId);
|
||||
expect(out.ok).toBe(true);
|
||||
const r = (out.output!['results'] as Array<Record<string, unknown>>)[0]!;
|
||||
expect(r['source']).toBe('ledger');
|
||||
const items = r['items'] as Array<Record<string, unknown>>;
|
||||
const byName = Object.fromEntries(items.map((i) => [i['parameterName'] as string, i]));
|
||||
expect(byName['输入电压范围']).toMatchObject({ originalValue: '4.5~40', candidateValue: '4.5~40', unit: 'V', constraintType: 'hard', requirement: '>= 45', evidenceRef: 'P-08 · P-10 Rev H (2023-09)', differenceStatus: 'match' });
|
||||
expect(byName['输出电压']).toMatchObject({ originalValue: '5.0', candidateValue: '1.23~37', compareResult: 'configurable', differenceStatus: 'conditional' });
|
||||
// 原 PN 有、候选没有 → 候选值空 → UNKNOWN
|
||||
expect(byName['使能阈值']).toMatchObject({ originalValue: '1.4', candidateValue: '', differenceStatus: 'unknown' });
|
||||
expect(byName['封装']).toMatchObject({ originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard', differenceStatus: 'match' });
|
||||
expect(byName['生命周期']).toMatchObject({ originalValue: 'nrnd', candidateValue: 'active', constraintType: 'soft', compareResult: 'covers', differenceStatus: 'match' });
|
||||
expect(r['extraCandidateParams']).toEqual(['反馈电压']);
|
||||
expect(r['counts']).toMatchObject({ match: 3, conditional: 1, unknown: 1, mismatch: 0 });
|
||||
expect(seen.every((s) => s.startsWith('t-1:'))).toBe(true);
|
||||
});
|
||||
|
||||
it('原 PN 台账里没有 → 如实报错,不比一半;没给 originalInternalPn → 拒', async () => {
|
||||
const missing = await run({ originalInternalPn: 'NOPE', candidates: [{ candidateInternalPn: 'LM2596S-ADJ' }] });
|
||||
expect(missing.ok).toBe(false);
|
||||
expect(missing.error?.message).toMatch(/NOPE/);
|
||||
const noOriginal = await run({ candidates: [{ candidateInternalPn: 'LM2596S-ADJ' }] });
|
||||
expect(noOriginal.ok).toBe(false);
|
||||
expect(noOriginal.error?.message).toMatch(/originalInternalPn/);
|
||||
});
|
||||
|
||||
it('自带 pairs 的老形状照旧接(试运行 / 老调用方),source 标 pairs', async () => {
|
||||
const out = await run({ candidates: [{ candidateInternalPn: 'X', pairs: [pair('输出电压', '5.0', '5.0', 'hard')] }] });
|
||||
expect(out.ok).toBe(true);
|
||||
const r = (out.output!['results'] as Array<Record<string, unknown>>)[0]!;
|
||||
expect(r['source']).toBe('pairs');
|
||||
expect((r['items'] as Array<Record<string, unknown>>)[0]).toMatchObject({ originalValue: '5.0', candidateValue: '5.0', differenceStatus: 'match' });
|
||||
});
|
||||
|
||||
it('没有台账的那份(静态名单)遇到不带 pairs 的入参 → 如实说这条通道读不到台账', async () => {
|
||||
const out = await compareParametersWith()({ input: { originalInternalPn: 'A', candidates: [{ candidateInternalPn: 'B' }] }, connection: {}, operation: undefined, idempotencyKey: undefined });
|
||||
expect(out.ok).toBe(false);
|
||||
expect(out.error?.message).toMatch(/读不到台账/);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚,harness 里强调一下是范围值」。
|
||||
* 解析器把各种写法都认成范围,出参 readAs 把「读作什么」摆回去 —— 比对依据可见,模型不必自己再解释一遍值。
|
||||
*/
|
||||
describe('值的读法:「下限~上限」一律是范围', () => {
|
||||
it('分隔符 / 正负号 / 单位写进值里 / Unicode 减号 / 省略号 / 中文「至到」都认成范围;单边界读成半开范围;定值一个数', () => {
|
||||
const range = (v: string) => parseParamValue(v);
|
||||
for (const v of ['4.5~40', '4.5~40', '4.5-40', '4.5 ~ 40', '4.5V~40V', '4.5V ~ 40V (典型)', '4.5…40', '4.5...40', '4.5至40', '4.5到40', '4.5 to 40']) {
|
||||
expect(range(v), v).toEqual({ kind: 'range', lo: 4.5, hi: 40 });
|
||||
}
|
||||
for (const v of ['-40~+85', '-40~85', '−40~+85', '-40℃~+85℃', '−40 ℃ … +85 ℃', '-40 - 85', '-40 to +85']) {
|
||||
expect(range(v), v).toEqual({ kind: 'range', lo: -40, hi: 85 });
|
||||
}
|
||||
expect(range('≥45')).toEqual({ kind: 'range', lo: 45, hi: Number.POSITIVE_INFINITY });
|
||||
expect(range('45 以上')).toEqual({ kind: 'range', lo: 45, hi: Number.POSITIVE_INFINITY });
|
||||
expect(range('<= 40')).toEqual({ kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: 40 });
|
||||
expect(range('40V 以内')).toEqual({ kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: 40 });
|
||||
expect(range('40')).toEqual({ kind: 'number', value: 40 });
|
||||
expect(range('5.0(固定)')).toEqual({ kind: 'number', value: 5 });
|
||||
expect(range('150 kHz')).toEqual({ kind: 'number', value: 150 });
|
||||
expect(range('TO-263-5')).toEqual({ kind: 'text', value: 'to-263-5' });
|
||||
});
|
||||
|
||||
it('readAs 说人话:范围 / 定值 / 单边界 / 文本;纯文本(封装)不啰嗦,像范围却没读成的才提示怎么写', () => {
|
||||
expect(describeParsed(parseParamValue('-40~+85'))).toBe('范围 -40~85');
|
||||
expect(describeParsed(parseParamValue('5.0'))).toBe('定值 5');
|
||||
expect(describeParsed(parseParamValue('≥45'))).toBe('下界 ≥ 45');
|
||||
expect(describeParsed(parseParamValue('TO-263-5'))).toBe('文本「to-263-5」');
|
||||
expect(describeParsed(parseParamValue('约 4.5 ~ 40'))).toMatch(/请写成 下限~上限/);
|
||||
});
|
||||
|
||||
it('两颗都写 4.5V~40V(单位在值里)→ 读成同一段范围 → equal / MATCH,出参 readAs 摆出「范围 4.5~40」', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [{ candidateInternalPn: 'X', pairs: [pair('输入电压范围', '4.5V~40V', '4.5 ~ 40 V', 'hard', { unit: 'V' }), pair('工作温度', '-40℃~+85℃', '−40~+85', 'hard', { unit: '℃' })] }],
|
||||
});
|
||||
const items = (out['results'] as Array<{ items: Array<Item & { readAs: { original: string; candidate: string } }> }>)[0]!.items;
|
||||
expect(items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', readAs: { original: '范围 4.5~40', candidate: '范围 4.5~40' } });
|
||||
expect(items[1]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', readAs: { original: '范围 -40~85', candidate: '范围 -40~85' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('recommend:推荐等级四档', () => {
|
||||
it('硬约束 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐;只有 configurable → 备选;全 MATCH → 推荐', async () => {
|
||||
const out = await call('part-substitution-assessment.recommend', {
|
||||
candidates: [
|
||||
{ candidateInternalPn: 'C', counts: { match: 5, mismatch: 1, unknown: 0, conditional: 0, hardMismatch: 1, softMismatch: 0, configurable: 0 } },
|
||||
{ candidateInternalPn: 'A', counts: { match: 5, mismatch: 0, unknown: 1, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } },
|
||||
{ candidateInternalPn: 'B', counts: { match: 6, mismatch: 0, unknown: 0, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } },
|
||||
{ candidateInternalPn: 'D', counts: { match: 7, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 } },
|
||||
],
|
||||
});
|
||||
const results = out['results'] as Array<{ candidateInternalPn: string; recommendationLevel: string; riskLevel: string }>;
|
||||
expect(results.map((r) => [r.candidateInternalPn, r.recommendationLevel, r.riskLevel])).toEqual([
|
||||
['C', 'not_recommended', 'high'],
|
||||
['A', 'conditional', 'medium'],
|
||||
['B', 'alternate', 'medium'],
|
||||
['D', 'recommended', 'low'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('既有 UNKNOWN 又有 configurable:UNKNOWN 优先 → 条件推荐(验证闭环前不给备选)', async () => {
|
||||
const out = await call('part-substitution-assessment.recommend', {
|
||||
candidates: [{ candidateInternalPn: 'E', counts: { match: 1, mismatch: 0, unknown: 1, conditional: 1, hardMismatch: 0, softMismatch: 0, configurable: 1 } }],
|
||||
});
|
||||
expect((out['results'] as Array<{ recommendationLevel: string }>)[0]?.recommendationLevel).toBe('conditional');
|
||||
describe('ACT-WORK-05 两枚工具已退役', () => {
|
||||
it('不在注册表里 —— 规则活在 S2 的配方里,不再有第二处真源', () => {
|
||||
const names = FIRST_PARTY_TOOLS.map(([n]) => n);
|
||||
expect(names).not.toContain('part-substitution-assessment.compare-parameters');
|
||||
expect(names).not.toContain('part-substitution-assessment.recommend');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { taskIdSchema } from '@contracts';
|
||||
import { afterSave } from '../save-wake.ts';
|
||||
|
||||
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||
|
||||
/** 缺省有一张卡在等人 —— 「保存即铺格」的场景恰恰都是这一种 */
|
||||
function spy(pending: ReadonlyArray<unknown> = [{ id: 'card-1' }]) {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
|
||||
task: { wake: () => { calls.push('wake'); return Promise.resolve(true); } },
|
||||
humanTool: { pendingCards: () => { calls.push('pending'); return Promise.resolve(pending); } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('件上保存之后', () => {
|
||||
it('真落下了行、且有卡在等人:先重推件,再叫醒任务 —— 配方这才有机会按最新的草稿铺格', async () => {
|
||||
const { calls, deps } = spy();
|
||||
await afterSave(deps, TASK, 3);
|
||||
expect(calls).toEqual(['repush', 'pending', 'wake']);
|
||||
});
|
||||
|
||||
it('一行都没落下:什么也不做 —— 没变的东西不值得叫醒谁', async () => {
|
||||
const { calls, deps } = spy();
|
||||
await afterSave(deps, TASK, 0);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
/*
|
||||
* **没有卡在等人就别叫醒**(2026-08-29 终审 F1):`wake` 对 awaiting-event / awaiting-human
|
||||
* 一律拨成 running,推进器随即推一拍并记一步预算 —— 而现有九个活动包一个 auto 都没有,
|
||||
* 人每按一次保存就白烧一步、界面闪一次「AI 正在推进这单」;任务本来 awaiting-human
|
||||
* (等人裁决)时被叫醒,那一拍结束还会把它降级成 awaiting-event:等的明明还是人。
|
||||
*/
|
||||
/*
|
||||
* **这条用例测的是 `pendingCards` 现读为空的那一种局面 —— 不是验收态**(2026-08-29 复审
|
||||
* A 项二审:这里原话写着「验收态就是这一种」,是错的)。`pendingCards`
|
||||
* (`human-tool-service.ts:374`)把**未办结工单**也算在内,而验收态恰恰有一张开着的
|
||||
* 验收工单 —— 真在验收态时 `pendingCards` 不空,`wake` 照样会发,不落进这条分支。
|
||||
*/
|
||||
it('没有卡在等人(pendingCards 现读为空):件照旧重推,**不叫醒**', async () => {
|
||||
const { calls, deps } = spy([]);
|
||||
await afterSave(deps, TASK, 3);
|
||||
expect(calls).toEqual(['repush', 'pending']);
|
||||
});
|
||||
|
||||
it('叫醒失败不抬手,但**不再静默** —— 它是「保存即铺格」的唯一触发器', async () => {
|
||||
const calls: string[] = [];
|
||||
const said: string[] = [];
|
||||
const write = vi.spyOn(globalThis.process.stderr, 'write').mockImplementation((chunk) => { said.push(String(chunk)); return true; });
|
||||
const deps = {
|
||||
views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
|
||||
task: { wake: () => Promise.reject(new Error('库连不上')) },
|
||||
humanTool: { pendingCards: () => Promise.resolve([{ id: 'card-1' }]) },
|
||||
};
|
||||
await expect(afterSave(deps, TASK, 1)).resolves.toBeUndefined();
|
||||
write.mockRestore();
|
||||
expect(calls).toEqual(['repush']);
|
||||
expect(said.join('')).toContain('库连不上');
|
||||
});
|
||||
|
||||
it('问不出有没有卡在等人:也不叫醒,照样说一声', async () => {
|
||||
const said: string[] = [];
|
||||
const write = vi.spyOn(globalThis.process.stderr, 'write').mockImplementation((chunk) => { said.push(String(chunk)); return true; });
|
||||
const calls: string[] = [];
|
||||
const deps = {
|
||||
views: { repush: () => Promise.resolve(true) },
|
||||
task: { wake: () => { calls.push('wake'); return Promise.resolve(true); } },
|
||||
humanTool: { pendingCards: () => Promise.reject(new Error('库连不上')) },
|
||||
};
|
||||
await expect(afterSave(deps, TASK, 1)).resolves.toBeUndefined();
|
||||
write.mockRestore();
|
||||
expect(calls).toEqual([]);
|
||||
expect(said.join('')).toContain('库连不上');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from '../seed-mvp-data.ts';
|
||||
|
||||
const tableOf = (domain: string, object: string) => MASTER_OBJECTS.find((o) => o.domain === domain && o.object === object);
|
||||
const seedRowsOf = (domain: string, object: string) =>
|
||||
MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === domain && r.address.object === object);
|
||||
|
||||
describe('主数据四域', () => {
|
||||
it('八个域:客户 / 项目 / 供应商 / 市场 + 料件 / 需求 / 替代 / 验证(SS02 差异评估 2026-08-26 加后四域)', () => {
|
||||
expect([...MASTER_DOMAINS]).toEqual(['customer', 'project', 'supplier', 'market', 'parts', 'demand', 'substitution', 'validation']);
|
||||
@@ -33,7 +37,8 @@ describe('主数据四域', () => {
|
||||
|
||||
it('样例料号:原 PN 与同族可调版在库,候选 XL2596S-5.0 刻意不播(数据工单那条路要试得出来)', () => {
|
||||
const parts = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile').map((r) => r.key['internal_pn']);
|
||||
expect(parts).toEqual(['LM2596S-5.0', 'LM2596S-ADJ']);
|
||||
// AMS1117-3.3(LDO)是 W05 重写 Task 1 新播的第三颗 —— 「两颗料类型不一致 → 硬拦」那条路的唯一素材
|
||||
expect(parts).toEqual(['LM2596S-5.0', 'LM2596S-ADJ', 'AMS1117-3.3']);
|
||||
expect(parts).not.toContain('XL2596S-5.0');
|
||||
});
|
||||
|
||||
@@ -45,7 +50,9 @@ describe('主数据四域', () => {
|
||||
for (const row of params) {
|
||||
expect(Object.keys(row.key).sort()).toEqual(['internal_pn', 'param_name']);
|
||||
expect(row.values['param_value'], JSON.stringify(row.key)).toBeTruthy();
|
||||
expect(row.values['constraint_type']).toMatch(/^(hard|soft)$/);
|
||||
// 约束类型已搬到 parts.type_param 字典上(W05 重写 Task 1)—— 这张子表不再带这一列,
|
||||
// 留着旧断言会重新允许「同一个参数在两颗料上各标一次约束」,正是这次改造要消灭的东西。
|
||||
expect(row.values['constraint_type']).toBeUndefined();
|
||||
}
|
||||
// parts.profile 不再声明这一列的值 —— 两行样例主记录里它应为空(物理列留在基线,不再写)
|
||||
const profiles = MASTER_SAMPLE_ROWS.filter((r) => r.address.domain === 'parts' && r.address.object === 'profile');
|
||||
@@ -98,3 +105,58 @@ describe('主数据四域', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('芯片类型字典(W05 重写)', () => {
|
||||
it('两张新表都物化了,主键形状对', () => {
|
||||
const chipType = tableOf('parts', 'chip_type');
|
||||
expect(chipType?.fields.find((f) => f.name === 'type_id')).toMatchObject({ type: 'integer', primaryKey: true });
|
||||
expect(chipType?.fields.map((f) => f.name)).toEqual(expect.arrayContaining(['type_id', 'type_name', 'description']));
|
||||
|
||||
const typeParam = tableOf('parts', 'type_param');
|
||||
/* 复合主键:一个类型下多个参数名 —— 主键少一半,同一类型的第二个参数就会覆盖第一个 */
|
||||
expect(typeParam?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['type_id', 'param_name']);
|
||||
expect(typeParam?.fields.map((f) => f.name)).toEqual(
|
||||
expect.arrayContaining(['type_id', 'param_name', 'param_desc', 'constraint_type', 'param_unit']),
|
||||
);
|
||||
});
|
||||
|
||||
it('P-08 上有 chip_type_id,类型与字典的主键对得上(integer)', () => {
|
||||
expect(tableOf('parts', 'profile')?.fields.find((f) => f.name === 'chip_type_id')).toMatchObject({ type: 'integer' });
|
||||
});
|
||||
|
||||
it('种子:两个类型,DC-DC 七个核心参数字段、LDO 五个', () => {
|
||||
const types = seedRowsOf('parts', 'chip_type');
|
||||
expect(types.map((r) => r.values['type_name']).sort()).toEqual(['DC-DC 降压稳压器', 'LDO 线性稳压器']);
|
||||
|
||||
const dcdc = types.find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id'];
|
||||
const params = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === dcdc);
|
||||
expect(params.map((r) => r.values['param_name'])).toEqual([
|
||||
'输入电压范围', '输出电压', '输出电流', '开关频率', '静态电流', '工作温度', '使能阈值',
|
||||
]);
|
||||
/* 约束类型搬到字典上了 —— 同一个参数在两颗料上标法不一致这件事从结构上消失 */
|
||||
expect(params.find((r) => r.values['param_name'] === '静态电流')?.values['constraint_type']).toBe('soft');
|
||||
expect(params.find((r) => r.values['param_name'] === '输入电压范围')?.values['constraint_type']).toBe('hard');
|
||||
/* 每个字段都要有说明 —— AI 分析时读的就是它 */
|
||||
expect(params.every((r) => String(r.values['param_desc'] ?? '') !== '')).toBe(true);
|
||||
|
||||
const ldo = types.find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
|
||||
expect(seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('三颗既有料都归了 DC-DC 类型,新播一颗 LDO 供「类型不一致」那条用例用', () => {
|
||||
const dcdc = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'DC-DC 降压稳压器')?.values['type_id'];
|
||||
const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
|
||||
const parts = seedRowsOf('parts', 'profile');
|
||||
for (const pn of ['LM2596S-5.0', 'LM2596S-ADJ']) {
|
||||
expect(parts.find((r) => r.key['internal_pn'] === pn)?.values['chip_type_id']).toBe(dcdc);
|
||||
}
|
||||
expect(parts.find((r) => r.key['internal_pn'] === 'AMS1117-3.3')?.values['chip_type_id']).toBe(ldo);
|
||||
});
|
||||
|
||||
it('AMS1117-3.3 的参数行按 LDO 的字段清单播齐 —— 它要能被真拿来比', () => {
|
||||
const ldo = seedRowsOf('parts', 'chip_type').find((r) => r.values['type_name'] === 'LDO 线性稳压器')?.values['type_id'];
|
||||
const names = seedRowsOf('parts', 'type_param').filter((r) => r.values['type_id'] === ldo).map((r) => r.values['param_name']);
|
||||
const rows = seedRowsOf('parts', 'parameter').filter((r) => r.key['internal_pn'] === 'AMS1117-3.3');
|
||||
expect(rows.map((r) => r.key['param_name']).sort()).toEqual([...names].sort());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +171,60 @@ describe('件上加行:主键凑得齐才落得下', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **Critical 1(2026-08-30 全分支终审逐行核实的链条)**:一张表**唯一**的主键
|
||||
* 同时是 `auto:true` 又是 `text` 类型(W05 的 `parts.chip_type.type_id` —— 与
|
||||
* `contact_code` 那种「auto 但有前缀主键陪衬」的形状不同,这里没有别的主键列可以
|
||||
* 「凑出半把钥匙」)。这个组合把两条各自成立的保护同时绕开了:
|
||||
* ① `barred` 认的是 `k in cells`(这一列在回传里露没露过面),不是「值是否非空」——
|
||||
* 视图若干脆不给这一列挂 `data-col`,`collect()` 连这一列的空字符串都拿不到,
|
||||
* `barred` 因此永远不会把它算进去;
|
||||
* ② `isSeq` 认的是 declared type 是不是 `integer`/`number` —— 这一列声明成了 `text`
|
||||
* (为了绕开另一条已记 debt 的坑,见 `docs/debt.md`),「序号型主键不走全表一致」
|
||||
* 那条 2026-08-23 的保护对它不成立。
|
||||
* 两条一起缺位,`resolveRowKey` 落到「全表一致」:表里此刻**恰好一行**(活动侧的
|
||||
* 自投影机制保证了这一点)时,新建的那一行会被误判成「就是那唯一一行」,把它的名字
|
||||
* /说明**静默覆盖**到既有那一行头上。
|
||||
*
|
||||
* 这两条用例用**真实、未改动的 `resolveRowKey`**逐字重放两种 `cells` 形状 ——
|
||||
* 「视图漏挂 data-col」(旧版 `part-substitution-assessment/src/views/report.ts`
|
||||
* 的形状)与「视图给了一个禁用、恒空的 data-col」(修复后的形状),证明因果链条本身:
|
||||
* `resolveRowKey` 这个函数一个字没改,活动侧只是换了一种能让平台既有保护生效的
|
||||
* 写法。平台侧的根治(`resolveRowKey` 该把声明了 `auto` 的列直接排除在「全表一致」
|
||||
* 之外,不必等 `k in cells` 这道旁门)仍记在 `docs/debt.md`,下一个建「单一主键又是
|
||||
* auto 列」的活动仍会踩上第一条用例这个坑。
|
||||
*/
|
||||
describe('Critical 1:唯一主键又是 auto 列(无前缀陪衬)——视图漏挂 data-col 会让新建行被误判成改既有行', () => {
|
||||
const KEYS = ['type_id'];
|
||||
const TYPES = { type_id: 'text' };
|
||||
// 活动侧的自投影机制保证此刻 parts.chip_type 恰好一行 —— 种子里的既有类型
|
||||
const ONE_EXISTING = [{ key: { type_id: '1' } }];
|
||||
|
||||
it('**病灶重放**:视图漏挂 data-col → type_id 压根不在 cells 里 → barred 落空 → 「全表一致」把新类型误判成既有那一行的号', () => {
|
||||
const out = resolveRowKey({
|
||||
keys: KEYS,
|
||||
cells: { type_name: '新类型', description: '这是什么' }, // 没有 type_id 这个键 —— 与旧版 ro('type_id') 的产出一致
|
||||
drafts: ONE_EXISTING,
|
||||
types: TYPES,
|
||||
autos: ['type_id'],
|
||||
});
|
||||
// 这正是 Critical 1 的病灶:落下的键是既有那一行的号,`writeEditedRows` 接下来会当成「改行」处理,
|
||||
// 把新类型的 type_name/description 写进 type_id='1' 那一行,静默覆盖种子字典。
|
||||
expect(out).toEqual({ type_id: '1' });
|
||||
});
|
||||
|
||||
it('**修法重放**:视图挂了 data-col、值恒空(禁用输入格)→ type_id 在 cells 里但是空串 → barred 命中,交回 undefined 让 mintable/autoKeys 续新号', () => {
|
||||
const out = resolveRowKey({
|
||||
keys: KEYS,
|
||||
cells: { type_id: '', type_name: '新类型', description: '这是什么' }, // 与修复后 <input data-col="type_id" disabled> 的产出一致
|
||||
drafts: ONE_EXISTING,
|
||||
types: TYPES,
|
||||
autos: ['type_id'],
|
||||
});
|
||||
expect(out).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('提交闸:必填没填就交不了', () => {
|
||||
const gate = (rows: Array<Record<string, unknown>>) =>
|
||||
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
|
||||
@@ -308,6 +362,43 @@ describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声
|
||||
expect(emptyGate({}, [{ domain: 'a', object: 'b' }], [])).toBeUndefined();
|
||||
});
|
||||
|
||||
/*
|
||||
* **W05 `input-supplement` 回归网**(2026-08-29 评审 Critical:确认钮永久按不下去)。
|
||||
*
|
||||
* 那张数据工单卡的 `gateLedgers` 一度带着 `parts.chip_type`,而件上没有任何
|
||||
* `data-edit="parts.chip_type"`(选类型走的是 `data-options` 下拉现读,不写这张表)——
|
||||
* `mine:parts.chip_type` 因而永远是空数组,`emptyGate` 永远判「这一单还一行都没有」,
|
||||
* 人把另外三张表填得再全,提交钮也按不下去。这条钉死两件事:
|
||||
* ①**复现旧配置确实会永久卡死**(不是猜的);②**修完(去掉 chip_type)之后闸真的开得了**——
|
||||
* 同一份 `mine`(chip_type 全程零行,profile/type_param/parameter 都已填好)在新
|
||||
* `gateLedgers` 下必须放行。ledger 的 domain/object/label 抄自
|
||||
* `activities/part-substitution-assessment/src/manifest.ts` 的声明原文。
|
||||
*/
|
||||
it('W05 input-supplement:gateLedgers 若带 parts.chip_type 会永久卡死(它没有编辑手段可写)——去掉之后,三张真被编辑的表填齐即放行', () => {
|
||||
const watched = [
|
||||
{ domain: 'parts', object: 'chip_type', label: '芯片类型字典' },
|
||||
{ domain: 'parts', object: 'type_param', label: '芯片类型核心参数字段' },
|
||||
{ domain: 'parts', object: 'profile', label: '芯片主数据(P-08)' },
|
||||
{ domain: 'parts', object: 'parameter', label: '标准化参数集(P-08 子表)' },
|
||||
];
|
||||
// parts.chip_type 从未被这张件写过 —— 无论闸怎么配,它在 mine 里恒是空数组
|
||||
const filledExceptChipType = {
|
||||
'mine:parts.type_param': [{ type_id: '1', param_name: '输入电压范围' }],
|
||||
'mine:parts.profile': [{ internal_pn: 'LM2596S-5.0' }, { internal_pn: 'XL2596S-5.0' }],
|
||||
'mine:parts.parameter': [{ internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }],
|
||||
};
|
||||
// 旧配置(已删除,这里复现只为证明它确实会卡死):chip_type 也在闸的名单里
|
||||
const buggyDeclared = ['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter'];
|
||||
const stuck = emptyGate(filledExceptChipType, watched, buggyDeclared);
|
||||
expect(stuck?.ready).toBe(false);
|
||||
expect(stuck?.why).toContain('芯片类型字典');
|
||||
expect(stuck?.why).toContain('这张卡就是来建它的');
|
||||
|
||||
// 修完(case-model.ts 现在的真实值):chip_type 不列入闸的名单
|
||||
const fixedDeclared = ['parts.type_param', 'parts.profile', 'parts.parameter'];
|
||||
expect(emptyGate(filledExceptChipType, watched, fixedDeclared)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('优先级:绑的表 > 卡自报 > 交付物;空串与空白不算数', () => {
|
||||
expect(gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b'] }], ['parts.profile'])).toEqual(['quote_intake.record']);
|
||||
expect(gateAddressesOf(undefined, [{ ledgers: ['a.b'] }], ['', ' '])).toEqual(['a.b']);
|
||||
|
||||
@@ -67,8 +67,7 @@ import type { ProcessService } from '@modules/process';
|
||||
import type { TriggerService } from '@modules/trigger';
|
||||
import type { MinuteService } from '@modules/minute';
|
||||
import type { ReportService } from '@modules/report';
|
||||
import { FIRST_PARTY_TOOLS, compareParametersWith } from './tools/first-party.ts';
|
||||
import type { ParameterLedger } from './tools/first-party.ts';
|
||||
import { FIRST_PARTY_TOOLS } from './tools/first-party.ts';
|
||||
import { httpToolHandler } from './tools/http-tool.ts';
|
||||
import { createMastraSpanFactory } from './mastra-spans.ts';
|
||||
import { createTraceService, createTraceStore } from '@modules/trace';
|
||||
@@ -418,22 +417,12 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
|
||||
/** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */
|
||||
// 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去
|
||||
/*
|
||||
* 差异评估的比对工具**从台账取参数**(2026-08-27 晚):静态名单里那份只认 pairs;这里用 ledger 顶掉 ——
|
||||
* 有 taskId 读任务视图(正式版 + 本单草稿),没有(试运行)只读正式版。只读。
|
||||
* 差异评估的两枚比对工具已退役(2026-08-29,墓志铭见 `tools/first-party.ts`)——
|
||||
* 规则改由 `part-substitution-assessment` 的 `S2_assess.auto` 配方现算,这里的
|
||||
* `parameterLedger`(从台账取参数的只读口)随之一并撤,不再有第二处消费方。
|
||||
*/
|
||||
const parameterLedger: ParameterLedger = {
|
||||
rows: async (taskId, address, where) => {
|
||||
const query = { address, where, orderBy: [], limit: 500, offset: 0 };
|
||||
const page = taskId === undefined ? await ledger.query(query) : await ledger.taskView(taskId, query);
|
||||
return page.rows;
|
||||
},
|
||||
};
|
||||
const toolRuntime = createToolRuntime(
|
||||
new Map<string, ToolHandler>([
|
||||
...FIRST_PARTY_TOOLS,
|
||||
['part-substitution-assessment.compare-parameters', compareParametersWith(parameterLedger)],
|
||||
['mail.send', mailSendTool(mail)],
|
||||
]),
|
||||
new Map<string, ToolHandler>([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]),
|
||||
httpToolHandler,
|
||||
);
|
||||
const schemas = createSchemaValidator();
|
||||
|
||||
@@ -32,6 +32,7 @@ import { assetRowsOf, keyText, listArchived, readArchived } from '../asset-archi
|
||||
import { landDataOrder } from '../data-order-landing.ts';
|
||||
import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts';
|
||||
import { toFileDoc } from '../file-doc.ts';
|
||||
import { afterSave } from '../save-wake.ts';
|
||||
import { displayLog } from './log-lines.ts';
|
||||
import { pulse } from '../workers/pulse.ts';
|
||||
import type { Container } from '../container.ts';
|
||||
@@ -1361,8 +1362,8 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
return c.json(body, 409);
|
||||
}
|
||||
const saved = await applyViewTableEdits(container, taskId, parsed.data);
|
||||
// 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值
|
||||
if (saved.rows > 0) await container.views.repush(taskId);
|
||||
// 数据变了,件跟着变;并叫醒任务 —— auto 步的配方这才有机会按最新的草稿铺格
|
||||
await afterSave(container, taskId, saved.rows);
|
||||
/*
|
||||
* **有行没落下就如实说** —— 保存说成功、东西却不在,是这一屏最伤人的一种谎
|
||||
* (2026-08-24 用户实测:件上加的那个客户推到下一步就不见了)。
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* **件上按了保存之后**(2026-08-29,spec §5.4)。
|
||||
*
|
||||
* 两件事按序:
|
||||
* ① 重推件 —— 数据变了,算出来的那些(红带缺几格 / 分布条 / 去向)得重新求值;
|
||||
* ② **叫醒任务** —— auto 步的配方这才有机会按最新的草稿把该铺的行铺出来。
|
||||
* 人挑了芯片类型、按了保存,参数格就在这一下之后出现。
|
||||
*
|
||||
* 从前只有两条回传路由调 `wake`;保存不是回传,于是「保存即铺格」永远等不到那一拍。
|
||||
*
|
||||
* **只在这一单有未答的卡时才叫醒**(2026-08-29 终审 F1)。`TaskService.wake` 对
|
||||
* `awaiting-event` / `awaiting-human` 一律拨成 `running`,推进器随即推一拍并**记一步预算**。
|
||||
* 而现有九个活动包一个 `auto` 都没有:人每按一次保存就烧掉一步预算、界面闪一次
|
||||
* 「AI 正在推进这单」,那一拍走到 `cards.unanswered` 就原路返回;更糟的是任务本来
|
||||
* `awaiting-human`(等人裁决)时被叫醒,那一拍结束会把状态写成 `awaiting-event` ——
|
||||
* 从「等人办事」降级成「等外部回音」,而实际等的仍是人。**这是界面撒谎。**
|
||||
* 而「保存即铺格」的场景恰恰都有卡在场(人正对着那张卡在件上填),验收态一点都不碰。
|
||||
*
|
||||
* **叫不醒不抬手**:保存本身是成功的,不该因为叫不醒把 200 变成 500 ——
|
||||
* 下一次推进器扫到它照样会跑。但**不再静默**:它是「保存即铺格」的唯一触发器,
|
||||
* 吞掉就等于人按了保存、格子没出现、系统一个字不说。
|
||||
*/
|
||||
import type { TaskId } from '@contracts';
|
||||
|
||||
function grumble(what: string, error: unknown): void {
|
||||
const why = error instanceof Error ? error.message : String(error);
|
||||
globalThis.process.stderr.write(`[save-wake] ${what}:${why}\n`);
|
||||
}
|
||||
|
||||
export async function afterSave(
|
||||
deps: {
|
||||
readonly views: { repush(taskId: TaskId): Promise<unknown> };
|
||||
readonly task: { wake(taskId: TaskId): Promise<unknown> };
|
||||
/** 这一单正等人交回的卡(工单没办结的 + 对话卡没作答的)—— 用既有那条,不新造 */
|
||||
readonly humanTool: { pendingCards(taskId: TaskId): Promise<ReadonlyArray<unknown>> };
|
||||
},
|
||||
taskId: TaskId,
|
||||
rows: number,
|
||||
): Promise<void> {
|
||||
if (rows <= 0) return;
|
||||
await deps.views.repush(taskId);
|
||||
/* 问不出来就不叫醒:宁可少推一拍(推进器下一轮照样扫到它),也不平白烧一步预算 */
|
||||
const pending = await deps.humanTool.pendingCards(taskId).catch((error: unknown) => {
|
||||
grumble('问不出这一单有没有卡在等人,这次不叫醒', error);
|
||||
return [];
|
||||
});
|
||||
if (pending.length === 0) return;
|
||||
await deps.task.wake(taskId).catch((error: unknown) => grumble('叫醒失败,件已重推,等推进器下一轮', error));
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { join, resolve } from 'node:path';
|
||||
import { activityManifestSchema } from '@contracts';
|
||||
import type { Container } from './container.ts';
|
||||
import { MASTER_DOMAINS, MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
||||
import { W05_TOOLS, W05_TOOL_SAMPLE } from './tools-w05.ts';
|
||||
import { createActivityOpsStore } from './activity-ops/store.ts';
|
||||
|
||||
/** 已开发完成、要随播种装上的真活动包(目录名 = 包目录,不是 activityId) */
|
||||
@@ -96,16 +95,6 @@ export async function installRealActivities(container: Container): Promise<void>
|
||||
(conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`),
|
||||
);
|
||||
|
||||
/*
|
||||
* **纯工具随活动一起注册**(2026-08-26):从前工具契约只在 db:seed 里注册,只跑 activities:install 的
|
||||
* 开发机上,活动清单声明了工具、白名单也发了,`sys.tool` 里却没有那件工具 —— 第一次跑就撞「工具不存在」。
|
||||
* 「安装即用」包含它声明过的纯工具能调得动。register 按 name+version 幂等,重复装是 no-op。
|
||||
*/
|
||||
for (const contract of W05_TOOLS) {
|
||||
const verdict = await container.toolbox.register({ contract, samples: [W05_TOOL_SAMPLE] });
|
||||
if (!verdict.accepted) log(` 工具 ${contract.name} 未过两审:${verdict.staticFindings.map((f) => f.message).join('; ')}`);
|
||||
}
|
||||
|
||||
const root = activitiesRoot();
|
||||
|
||||
for (const dir of REAL_PACKAGES) {
|
||||
|
||||
@@ -152,6 +152,27 @@ export const MASTER_OBJECTS: readonly ObjectDeclaration[] = [
|
||||
`normalized_parameter_set` 的数据类型本就写的是 `Structure`。物理列 `parts.profile.
|
||||
normalized_parameter_set` 仍留在基线里(不删列),只是活动清单不再声明它、不再写它。
|
||||
老的 market.part(主键 pn)不复用,记 debt 待并。 */
|
||||
master('parts', 'chip_type', '芯片类型', [
|
||||
/*
|
||||
* **整数自增主键**:人在件上「+新建类型」时这一格留空,由基座按「现有最大 + 1」续号
|
||||
* (与 I01 的序号主键同一条路)。**空着的自增列不是身份线索,是「给我续个号」。**
|
||||
*/
|
||||
f('type_id', 'integer', '类型ID', true),
|
||||
f('type_name', 'text', '类型名称'),
|
||||
f('description', 'text', '类型说明'),
|
||||
]),
|
||||
master('parts', 'type_param', '芯片类型核心参数字段', [
|
||||
f('type_id', 'integer', '所属类型', true),
|
||||
f('param_name', 'text', '核心参数名', true),
|
||||
/* **AI 分析时读的就是这一列** —— 参数名本身说不清「它为什么要紧」 */
|
||||
f('param_desc', 'text', '参数说明'),
|
||||
/*
|
||||
* 约束类型从 `parts.parameter`(每颗料各标一次)搬到这里(按类型标一次)——
|
||||
* 「同一个参数在两颗料上标法不一致」这件事从此在结构上不可能发生。
|
||||
*/
|
||||
f('constraint_type', 'text', '约束类型'),
|
||||
f('param_unit', 'text', '参考单位'),
|
||||
]),
|
||||
master('parts', 'profile', '芯片主数据(P-08)', [
|
||||
f('internal_pn', 'text', '内部料号', true),
|
||||
f('customer_pn', 'text', '客户料号'),
|
||||
@@ -167,6 +188,7 @@ export const MASTER_OBJECTS: readonly ObjectDeclaration[] = [
|
||||
f('datasheet_version', 'text', 'Datasheet 版本'),
|
||||
f('application_scenarios', 'text', '适配应用场景'),
|
||||
f('pin_to_pin_relation_ref', 'text', 'Pin-to-Pin 关系引用'),
|
||||
f('chip_type_id', 'integer', '芯片类型'),
|
||||
]),
|
||||
/* P-08 子表:标准化参数集,一行一参数,按 internal_pn 归属主记录(《统一方案》§六:重复明细用子表) */
|
||||
master('parts', 'parameter', '标准化参数集(P-08 子表)', [
|
||||
@@ -475,6 +497,23 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK
|
||||
review_note: '',
|
||||
},
|
||||
},
|
||||
/* 芯片类型字典 —— 字段清单挂在类型上,下面 parts.profile 的 chip_type_id 要指向这里的 type_id,故字典先播。 */
|
||||
{ address: { domain: 'parts', object: 'chip_type' }, key: { type_id: 1 }, values: { type_id: 1, type_name: 'DC-DC 降压稳压器', description: '开关式降压转换器:靠电感储能与高频开关把输入电压降到目标输出,效率高、发热小,外围需要电感与输出电容。' } },
|
||||
{ address: { domain: 'parts', object: 'chip_type' }, key: { type_id: 2 }, values: { type_id: 2, type_name: 'LDO 线性稳压器', description: '低压差线性稳压器:靠调整管压降稳压,外围简单、噪声低,压差与电流之积全部转成热耗。' } },
|
||||
|
||||
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输入电压范围' }, values: { type_id: 1, param_name: '输入电压范围', param_desc: '芯片允许的输入直流电压区间。候选料的上限低于应用输入上限就是硬伤,烧的是芯片。', constraint_type: 'hard', param_unit: 'V' } },
|
||||
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输出电压' }, values: { type_id: 1, param_name: '输出电压', param_desc: '固定版给定值;可调版给可调区间,需外围反馈电阻网络设定。固定换可调要动外围。', constraint_type: 'hard', param_unit: 'V' } },
|
||||
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '输出电流' }, values: { type_id: 1, param_name: '输出电流', param_desc: '满载持续输出能力。低于原料即降额,负载重时会掉压或过热保护。', constraint_type: 'hard', param_unit: 'A' } },
|
||||
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '开关频率' }, values: { type_id: 1, param_name: '开关频率', param_desc: '决定外围电感与输出电容的取值。频率变了外围多半要跟着换,不是换颗料就完。', constraint_type: 'soft', param_unit: 'kHz' } },
|
||||
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '静态电流' }, values: { type_id: 1, param_name: '静态电流', param_desc: '空载待机时自身消耗。电池供电场景敏感,市电场景通常不敏感。', constraint_type: 'soft', param_unit: 'mA' } },
|
||||
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '工作温度' }, values: { type_id: 1, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。工业温域窄于应用要求就是硬伤。', constraint_type: 'hard', param_unit: '℃' } },
|
||||
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 1, param_name: '使能阈值' }, values: { type_id: 1, param_name: '使能阈值', param_desc: 'ON/OFF 引脚的开关门限。上游用 MCU 电平直驱时,阈值对不上会关不掉或开不起来。', constraint_type: 'hard', param_unit: 'V' } },
|
||||
|
||||
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输入电压范围' }, values: { type_id: 2, param_name: '输入电压范围', param_desc: '允许的输入直流电压区间。', constraint_type: 'hard', param_unit: 'V' } },
|
||||
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输出电压' }, values: { type_id: 2, param_name: '输出电压', param_desc: '固定版给定值;可调版需外围分压设定。', constraint_type: 'hard', param_unit: 'V' } },
|
||||
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '输出电流' }, values: { type_id: 2, param_name: '输出电流', param_desc: '满载持续输出能力;线性稳压器受热耗限制,实际可用电流常低于标称。', constraint_type: 'hard', param_unit: 'A' } },
|
||||
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '压差' }, values: { type_id: 2, param_name: '压差', param_desc: '维持稳压所需的最小输入输出电压差。压差不够就稳不住,输出跟着输入掉。', constraint_type: 'hard', param_unit: 'V' } },
|
||||
{ address: { domain: 'parts', object: 'type_param' }, key: { type_id: 2, param_name: '工作温度' }, values: { type_id: 2, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。', constraint_type: 'hard', param_unit: '℃' } },
|
||||
/*
|
||||
* ── SS02 差异评估的样例料号(2026-08-26,2026-08-27 参数集改子表)──
|
||||
* 只播**原 PN**与**同族可调版**两颗:「都在库」那条路靠它们;候选 XL2596S-5.0 与国产 DCDC
|
||||
@@ -487,6 +526,7 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK
|
||||
key: { internal_pn: 'LM2596S-5.0' },
|
||||
values: {
|
||||
internal_pn: 'LM2596S-5.0',
|
||||
chip_type_id: 1,
|
||||
brand_name: 'TI',
|
||||
manufacturer_name: 'Texas Instruments',
|
||||
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
|
||||
@@ -499,18 +539,19 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK
|
||||
application_scenarios: '工业电源;消费电源',
|
||||
},
|
||||
},
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard', param_note: '客户输入上限 40V' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', constraint_type: 'hard', param_note: '固定版' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电流', param_value: '3', param_unit: 'A', constraint_type: 'hard', param_note: '满载能力' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-5.0', param_name: '开关频率', param_value: '150', param_unit: 'kHz', constraint_type: 'hard', param_note: '影响外围电感/电容' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '静态电流', param_value: '5', param_unit: 'mA', constraint_type: 'soft', param_note: '待机功耗' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-5.0', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', constraint_type: 'hard', param_note: '工业温域' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard', param_note: 'ON/OFF 引脚阈值' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', param_note: '客户输入上限 40V' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', param_note: '固定版' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '输出电流', param_value: '3', param_unit: 'A', param_note: '满载能力' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-5.0', param_name: '开关频率', param_value: '150', param_unit: 'kHz', param_note: '影响外围电感/电容' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-5.0', param_name: '静态电流', param_value: '5', param_unit: 'mA', param_note: '待机功耗' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-5.0', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', param_note: '工业温域' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', param_note: 'ON/OFF 引脚阈值' } },
|
||||
{
|
||||
address: { domain: 'parts', object: 'profile' },
|
||||
key: { internal_pn: 'LM2596S-ADJ' },
|
||||
values: {
|
||||
internal_pn: 'LM2596S-ADJ',
|
||||
chip_type_id: 1,
|
||||
brand_name: 'TI',
|
||||
manufacturer_name: 'Texas Instruments',
|
||||
specification_description: '降压型 DCDC 稳压器,3A,可调输出 1.23~37V,150kHz(LM2596 同族)',
|
||||
@@ -522,13 +563,31 @@ export const MASTER_SAMPLE_ROWS: readonly { address: LedgerAddress; key: LedgerK
|
||||
application_scenarios: '工业电源;消费电源',
|
||||
},
|
||||
},
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard', param_note: '与固定版同' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', constraint_type: 'hard', param_note: '可调版,靠外围反馈网络定' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流', param_value: '3', param_unit: 'A', constraint_type: 'hard', param_note: '满载能力' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率', param_value: '150', param_unit: 'kHz', constraint_type: 'hard', param_note: '影响外围电感/电容' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流', param_value: '5', param_unit: 'mA', constraint_type: 'soft', param_note: '待机功耗' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', constraint_type: 'hard', param_note: '工业温域' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard', param_note: 'ON/OFF 引脚阈值' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', param_note: '与固定版同' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', param_note: '可调版,靠外围反馈网络定' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '输出电流', param_value: '3', param_unit: 'A', param_note: '满载能力' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '开关频率', param_value: '150', param_unit: 'kHz', param_note: '影响外围电感/电容' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '静态电流', param_value: '5', param_unit: 'mA', param_note: '待机功耗' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '工作温度', param_value: '-40~+85', param_unit: '℃', param_note: '工业温域' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值' }, values: { internal_pn: 'LM2596S-ADJ', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', param_note: 'ON/OFF 引脚阈值' } },
|
||||
/*
|
||||
* **AMS1117-3.3**(LDO 类型,chip_type_id: 2)—— 「两颗料类型不一致 → 硬拦」那条路的
|
||||
* 唯一素材:不播这颗,「原 PN 是 DC-DC、候选是 LDO,字段清单对不上」这条判断就没有真数据可用。
|
||||
*/
|
||||
{
|
||||
address: { domain: 'parts', object: 'profile' },
|
||||
key: { internal_pn: 'AMS1117-3.3' },
|
||||
values: {
|
||||
internal_pn: 'AMS1117-3.3', chip_type_id: 2, brand_name: 'AMS',
|
||||
manufacturer_name: 'Advanced Monolithic Systems', specification_description: '3.3V 固定输出低压差线性稳压器,SOT-223',
|
||||
lifecycle_status: 'active', datasheet_ref: 'https://example.invalid/ams1117', datasheet_version: 'Rev.C',
|
||||
},
|
||||
},
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输入电压范围' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输入电压范围', param_value: '4.75~15', param_unit: 'V', param_note: '最低受压差限制' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输出电压' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输出电压', param_value: '3.3', param_unit: 'V', param_note: '固定版' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '输出电流' }, values: { internal_pn: 'AMS1117-3.3', param_name: '输出电流', param_value: '1', param_unit: 'A', param_note: '受散热限制' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '压差' }, values: { internal_pn: 'AMS1117-3.3', param_name: '压差', param_value: '1.3', param_unit: 'V', param_note: '满载时' } },
|
||||
{ address: { domain: 'parts', object: 'parameter' }, key: { internal_pn: 'AMS1117-3.3', param_name: '工作温度' }, values: { internal_pn: 'AMS1117-3.3', param_name: '工作温度', param_value: '-40~+125', param_unit: '℃', param_note: '' } },
|
||||
{
|
||||
address: { domain: 'parts', object: 'document' },
|
||||
key: { internal_pn: 'LM2596S-5.0', doc_seq: '01' },
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { Container } from './container.ts';
|
||||
import { installRealActivities } from './seed-activities.ts';
|
||||
import { installSlots } from './seed-mvp-slots.ts';
|
||||
import { seedMvpFlow } from './seed-mvp-flow.ts';
|
||||
import { W05_TOOLS } from './tools-w05.ts';
|
||||
|
||||
/** 第一方功能工具:查历史价 —— MVP 用录制回放替身代真 CRM(README:mock 只在外界) */
|
||||
const HISTORY_TOOL = {
|
||||
@@ -329,7 +328,7 @@ export async function seedMvp(container: Container): Promise<void> {
|
||||
log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`);
|
||||
|
||||
/* ② 功能工具注册两审 + 逐槽位白名单 */
|
||||
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL, ...W05_TOOLS]) {
|
||||
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL]) {
|
||||
const verdict = await container.toolbox.register({
|
||||
contract,
|
||||
samples: [
|
||||
@@ -360,7 +359,7 @@ export async function seedMvp(container: Container): Promise<void> {
|
||||
for (const [activityId, tools] of Object.entries(ALLOWLIST)) {
|
||||
await container.toolbox.setAllowlist(activityId, tools);
|
||||
}
|
||||
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 6,逐活动授权)');
|
||||
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 4,逐活动授权)');
|
||||
|
||||
/*
|
||||
* ③ 活动装入 —— 两批:
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* ACT-WORK-05 差异评估的两枚纯工具契约(活动私有作用域)。
|
||||
* 两处消费:`seed-mvp.ts`(db:seed 全套播种)与 `seed-activities.ts`(activities:install 装活动时顺带注册 ——
|
||||
* 「安装即用」包含它声明过的纯工具能调得动;从前 E04 的工具只在 db:seed 里注册,开发机上只跑
|
||||
* activities:install 的人第一次跑活动就撞「工具不存在」)。
|
||||
*/
|
||||
const PURE = {
|
||||
effect: 'read' as const,
|
||||
errors: [] as Array<{ code: string; retryable: boolean; message: string }>,
|
||||
idempotent: true,
|
||||
timeoutMs: 5_000,
|
||||
retries: 0,
|
||||
connectionKeys: [] as string[],
|
||||
pure: true,
|
||||
ownerActivityId: 'part-substitution-assessment',
|
||||
};
|
||||
|
||||
/** 逐参数比对 —— 出参键与台账列同名(difference_status / risk_level),模型照抄 */
|
||||
export const COMPARE_PARAMETERS_TOOL = {
|
||||
...PURE,
|
||||
name: 'part-substitution-assessment.compare-parameters',
|
||||
displayName: '替代料逐参数比对',
|
||||
/*
|
||||
* 1.1.0(2026-08-27):客户约束原 PN 自己也不满足、候选与原 PN 相等或盖住时不再判 mismatch —— 那是需求边界,不是替代差异。
|
||||
* 1.2.0(同日):**比对入参由基座从台账取** —— 给 originalInternalPn + candidates[].candidateInternalPn,参数 / 封装 / 生命周期 /
|
||||
* 证据引用从 parts.* 现读,模型只译客户约束(requirements);老形状(自带 pairs)照旧接。出参每项带 originalValue / candidateValue / evidenceRef。
|
||||
*/
|
||||
version: '1.3.0',
|
||||
description:
|
||||
'按硬/软约束逐参数比对原 PN 与候选 PN,输出四态 difference_status(match/mismatch/unknown/conditional)、' +
|
||||
'**值的读法**:「下限~上限」一律是范围(4.5~40 = 4.5 到 40 的整段;-40~+85 = -40 到 85 的整段,不是两个数),' +
|
||||
'分隔符认 ~ - … 至 到 to,单位写进值里也认;「≥45 / 45 以上」「≤40 / 40 以内」是单边界;只有一个数是定值。出参每项 readAs 摆出两边各读成了什么。' +
|
||||
'compare_result、risk_level 与各候选的计数。**推荐用法**:只给 originalInternalPn 与 candidates[].candidateInternalPn,' +
|
||||
'参数行从 parts.parameter、封装与生命周期从 parts.profile、证据引用从 parts.document 由工具现读(本单草稿层也算),模型不抄值;' +
|
||||
'candidates[].requirements 是客户给了边界的参数 {参数名: ">= 40" / "-40~85"}。也接老形状(candidates[].pairs 自带值)。' +
|
||||
'硬约束没有证据引用不判 match。候选不比原 PN 差(相等 / 盖住)而客户约束原 PN 自己也不满足的,不算替代差异:仍按比对结果判、备注点名需求边界。规则版本 R-2026.07。',
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
originalInternalPn: { type: 'string', description: '原 PN(内部料号);按料号从台账取参数时必填' },
|
||||
candidates: {
|
||||
type: 'array',
|
||||
description:
|
||||
'[{candidateInternalPn, requirements?: {参数名: 约束}, evidenceRef?}](从台账取值) 或 ' +
|
||||
'[{candidateInternalPn, pairs:[{parameterName, originalValue, candidateValue, unit?, constraintType:"hard"|"soft", requirement?, evidenceRef?}]}](自带值)',
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ruleVersion: { type: 'string' },
|
||||
results: {
|
||||
type: 'array',
|
||||
description: '[{candidateInternalPn, source:"ledger"|"pairs", items:[{parameterName, originalValue, candidateValue, readAs:{original,candidate}, unit, constraintType, requirement, evidenceRef, compareResult, differenceStatus, riskLevel, note}], counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}, extraCandidateParams?}]',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** 推荐定级 —— 出参 recommendationLevel / riskLevel / reason 直接落 P-11 */
|
||||
export const RECOMMEND_TOOL = {
|
||||
...PURE,
|
||||
name: 'part-substitution-assessment.recommend',
|
||||
displayName: '替代推荐定级',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'按每个候选的比对计数定推荐等级:硬约束 mismatch>0 → not_recommended;unknown>0 → conditional;' +
|
||||
'只有可调配成(configurable)→ alternate;其余 conditional → conditional;全 match → recommended。',
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
candidates: { type: 'array', description: '[{candidateInternalPn, counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]' },
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ruleVersion: { type: 'string' },
|
||||
results: { type: 'array', description: '[{candidateInternalPn, recommendationLevel, riskLevel, reason}]' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const W05_TOOLS = [COMPARE_PARAMETERS_TOOL, RECOMMEND_TOOL] as const;
|
||||
|
||||
/** 试运行审的样例(两件共用一份:并集覆盖各家入参) */
|
||||
export const W05_TOOL_SAMPLE = {
|
||||
input: {
|
||||
candidates: [
|
||||
{
|
||||
candidateInternalPn: 'XL2596S-5.0',
|
||||
pairs: [{ parameterName: '输出电压', originalValue: '5.0', candidateValue: '5.0', unit: 'V', constraintType: 'hard', evidenceRef: 'P-08' }],
|
||||
counts: { match: 1, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
expectShape: { type: 'object' },
|
||||
};
|
||||
@@ -4,7 +4,6 @@
|
||||
* 运行时受限:只拿得到**注册申报的连接目标**,没有其它环境能力 —— 这是 `ToolRuntime` 保证的。
|
||||
*/
|
||||
import { evaluateUnaryTest } from '@sfeel';
|
||||
import type { TaskId } from '@contracts';
|
||||
import type { ToolHandler } from '@modules/toolbox';
|
||||
|
||||
/**
|
||||
@@ -333,338 +332,24 @@ const slotPlaceholder: ToolHandler = async ({ input }) => {
|
||||
};
|
||||
};
|
||||
|
||||
/* ── ACT-WORK-05 差异评估:逐参数比对 —— 纯工具(活动私有作用域)─────────────────
|
||||
/* ── ACT-WORK-05 差异评估:逐参数比对与推荐定级 —— 已退役(2026-08-29)──────────
|
||||
*
|
||||
* 「能算的不问 LLM」:同输入必同结论。规则与活动包 manifest 里的 D01/D02 两张 DMN 同一份;
|
||||
* 这里是求值器,那里是随包冻结的文档。`RULE_VERSION` 随结论落账(rule_version)。
|
||||
* 【两枚工具的墓志铭】`part-substitution-assessment.compare-parameters`(逐参数四态判定:
|
||||
* MATCH / MISMATCH / UNKNOWN / CONDITIONAL,按硬/软约束与客户需求边界比对原 PN 与候选料)
|
||||
* 与 `part-substitution-assessment.recommend`(按四态命中条数定推荐等级:推荐 / 条件推荐 /
|
||||
* 备选 / 不推荐)曾经跑在这里,与活动包 manifest 里冻结的 D01(逐参数四态)/ D02(推荐等级)
|
||||
* 两张 DMN 是同一份规则的两处真源。
|
||||
*
|
||||
* 比对语义(按硬/软约束):
|
||||
* equal / covers(候选范围 ⊇ 原范围) → match,低风险;
|
||||
* configurable(原是定值、候选是含它的可调范围)→ conditional(要改外围);
|
||||
* narrower / differs / partial → 硬:mismatch 高风险;软:conditional 中风险;
|
||||
* 任一侧未披露 → unknown 中风险;
|
||||
* 硬约束 equal/covers 但没给 evidenceRef → conditional(硬约束未经证据支持不得判 MATCH);
|
||||
* requirement(客户约束,如 ">= 40")候选不满足 → 一律 mismatch 高风险。
|
||||
* ADR-014(确定性步骤)落地后,这两步不再需要工具调用:四态判定是**整个活动唯一一次
|
||||
* 模型调用**(`case-model.ts` 的 `S2_assess.auto` 用 `analyze` 动词,系统组装入参、模型只答
|
||||
* 判断,不转述台账里已有的值),推荐等级是**能算的事**,由同一步 `do` 链里的投影按
|
||||
* spec §3.5 的 S-FEEL when 链现算(`judgement_class` / `recommendation_level` 两条合成列)。
|
||||
* `D01` / `D02` 两张 DMN 同批退役,理由相同 —— 再留一份 DMN 或一枚工具,就是同一件事
|
||||
* 两处各存一份,改一处就分叉而没人看得出以哪份为准。
|
||||
*
|
||||
* 完整实现见 git 历史(本文件此段落之前的版本);规则表见活动包 spec §3.5,
|
||||
* 求值逻辑见 `activities/part-substitution-assessment/src/case-model.ts`。
|
||||
*/
|
||||
export const SUBSTITUTION_RULE_VERSION = 'R-2026.07';
|
||||
|
||||
type Parsed = { kind: 'number'; value: number } | { kind: 'range'; lo: number; hi: number } | { kind: 'text'; value: string } | { kind: 'unknown' };
|
||||
type CompareResult = 'equal' | 'covers' | 'configurable' | 'narrower' | 'partial' | 'differs' | 'unknown';
|
||||
type DifferenceStatus = 'match' | 'mismatch' | 'unknown' | 'conditional';
|
||||
type RiskLevel = 'low' | 'medium' | 'high';
|
||||
type ParamPair = {
|
||||
parameterName: string;
|
||||
originalValue: string;
|
||||
candidateValue: string;
|
||||
unit?: string;
|
||||
constraintType: 'hard' | 'soft';
|
||||
/**
|
||||
* 这两格**模型爱给 null**(「这一项没有客户约束」它写 `requirement: null` 而不是省掉)——
|
||||
* 工具入参是宽松描述式 schema,null 一路落到这里,`null.trim()` 当场崩,
|
||||
* 整个工具调用记成「行动被拒」(2026-08-27 W05 e2e 实测,一单撞两次)。
|
||||
* 类型如实写成可空,取值一律经 `text()`:**边界上的东西按它真会来的样子接**。
|
||||
*/
|
||||
requirement?: string | null;
|
||||
evidenceRef?: string | null;
|
||||
};
|
||||
/** 边界上的字符串:null / undefined / 非字符串一律归成空串(模型给 null 是常态,见 ParamPair 上的注) */
|
||||
function text(v: unknown): string {
|
||||
return typeof v === 'string' ? v.trim() : '';
|
||||
}
|
||||
|
||||
type CompareCounts = { match: number; mismatch: number; unknown: number; conditional: number; hardMismatch: number; softMismatch: number; configurable: number };
|
||||
|
||||
const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i;
|
||||
const NUM = '[-+]?\\d+(?:\\.\\d+)?';
|
||||
/** 数字后面允许跟一段单位(V / ℃ / °C / kHz / % / mA…)—— 「4.5V~40V」「-40℃~+85℃」这种把单位写进值里的形状要认成范围 */
|
||||
const UNIT = '(?:\\s*[A-Za-z℃°%μµΩ][A-Za-z℃°%μµΩ0-9/]*)?';
|
||||
const RANGE_RE = new RegExp(`^(${NUM})${UNIT}\\s*(?:~|~|-|–|—|…|\\.\\.\\.|至|到|to)\\s*(${NUM})`, 'i');
|
||||
const NUM_RE = new RegExp(`^(${NUM})`);
|
||||
/** 单边界:「≥45」「>= 45」「45 以上」→ [45, ∞);「≤40」「<= 40」「40 以内 / 以下」→ (-∞, 40] */
|
||||
const GE_RE = new RegExp(`^(?:>=|≥)\\s*(${NUM})|^(${NUM})${UNIT}\\s*(?:以上|及以上|起)$`);
|
||||
const LE_RE = new RegExp(`^(?:<=|≤)\\s*(${NUM})|^(${NUM})${UNIT}\\s*(?:以内|以下|及以下|以内)$`);
|
||||
|
||||
/**
|
||||
* 参数值怎么读(2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚,harness 里强调一下是范围值」):
|
||||
* 「下限~上限」一律是**范围**——分隔符认 ~ ~ - – — … ... 至 到 to,正负号认 + - 与 Unicode 减号(−),
|
||||
* 单位写进值里(4.5V~40V / -40℃~+85℃)也认;「≥45 / 45 以上」「≤40 / 40 以内」是单边界,读成半开范围;
|
||||
* 只有一个数是定值;读不出数的按文本原样比。出参会把「读作什么」摆回去,模型与人都看得见。
|
||||
*/
|
||||
export function parseParamValue(raw: unknown): Parsed {
|
||||
const s = String(raw ?? '')
|
||||
.trim()
|
||||
.replace(/[\u2212\u2010\u2011\u2012]/g, '-') // Unicode 减号 / 连字号 → ASCII 减号
|
||||
.replace(/\s+/g, ' ');
|
||||
if (s === '' || UNKNOWN_WORDS.test(s)) return { kind: 'unknown' };
|
||||
const r = RANGE_RE.exec(s);
|
||||
if (r !== null) {
|
||||
const lo = Number(r[1]);
|
||||
const hi = Number(r[2]);
|
||||
if (Number.isFinite(lo) && Number.isFinite(hi)) return { kind: 'range', lo: Math.min(lo, hi), hi: Math.max(lo, hi) };
|
||||
}
|
||||
const ge = GE_RE.exec(s);
|
||||
if (ge !== null) return { kind: 'range', lo: Number(ge[1] ?? ge[2]), hi: Number.POSITIVE_INFINITY };
|
||||
const le = LE_RE.exec(s);
|
||||
if (le !== null) return { kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: Number(le[1] ?? le[2]) };
|
||||
const n = NUM_RE.exec(s);
|
||||
if (n !== null && Number.isFinite(Number(n[1]))) return { kind: 'number', value: Number(n[1]) };
|
||||
return { kind: 'text', value: s.toLowerCase() };
|
||||
}
|
||||
|
||||
/** 「读作什么」—— 回给模型与人看的一句话,不是给机器再解析的 */
|
||||
export function describeParsed(p: Parsed): string {
|
||||
if (p.kind === 'range') {
|
||||
if (p.lo === Number.NEGATIVE_INFINITY) return `上界 ≤ ${p.hi}`;
|
||||
if (p.hi === Number.POSITIVE_INFINITY) return `下界 ≥ ${p.lo}`;
|
||||
return `范围 ${p.lo}~${p.hi}`;
|
||||
}
|
||||
if (p.kind === 'number') return `定值 ${p.value}`;
|
||||
if (p.kind === 'unknown') return '未披露';
|
||||
// 带数字又带范围分隔符却没读成范围的(如「4.5V 到 40 伏」),提示怎么写;纯文本(封装 TO-263-5)不啰嗦
|
||||
const looksLikeRange = /\d[^\d]{0,6}(?:[~~…]|至|到|\bto\b)[^\d]{0,6}\d/.test(p.value) || /^[^\d]+\d.*(?:[~~…]|至|到)/.test(p.value);
|
||||
return `文本「${p.value}」${looksLikeRange ? '(没读成范围;本意是范围请写成 下限~上限,单位放单位列)' : ''}`;
|
||||
}
|
||||
|
||||
function compareParsed(o: Parsed, c: Parsed): CompareResult {
|
||||
if (o.kind === 'unknown' || c.kind === 'unknown') return 'unknown';
|
||||
if (o.kind === 'range' && c.kind === 'range') {
|
||||
if (c.lo === o.lo && c.hi === o.hi) return 'equal';
|
||||
if (c.lo <= o.lo && c.hi >= o.hi) return 'covers';
|
||||
if (c.lo >= o.lo && c.hi <= o.hi) return 'narrower';
|
||||
return c.hi < o.lo || c.lo > o.hi ? 'differs' : 'partial';
|
||||
}
|
||||
if (o.kind === 'number' && c.kind === 'number') return Math.abs(o.value - c.value) <= 1e-9 ? 'equal' : 'differs';
|
||||
if (o.kind === 'number' && c.kind === 'range') return c.lo <= o.value && o.value <= c.hi ? 'configurable' : 'differs';
|
||||
if (o.kind === 'range' && c.kind === 'number') return o.lo <= c.value && c.value <= o.hi ? 'narrower' : 'differs';
|
||||
if (o.kind === 'text' && c.kind === 'text') return o.value === c.value ? 'equal' : 'differs';
|
||||
return 'differs';
|
||||
}
|
||||
|
||||
/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */
|
||||
function satisfiesRequirement(requirement: string | null | undefined, c: Parsed): boolean | undefined {
|
||||
const s = text(requirement);
|
||||
if (s === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
|
||||
const ge = new RegExp(`^(?:>=|≥)\\s*(${NUM})$`).exec(s);
|
||||
const le = new RegExp(`^(?:<=|≤)\\s*(${NUM})$`).exec(s);
|
||||
const hi = c.kind === 'range' ? c.hi : c.value;
|
||||
const lo = c.kind === 'range' ? c.lo : c.value;
|
||||
if (ge !== null) return hi >= Number(ge[1]);
|
||||
if (le !== null) return lo <= Number(le[1]);
|
||||
const req = parseParamValue(s);
|
||||
if (req.kind === 'range') return lo <= req.lo && hi >= req.hi;
|
||||
if (req.kind === 'number') return lo <= req.value && hi >= req.value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */
|
||||
const LIFECYCLE_ORDER: Record<string, number> = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 };
|
||||
function compareLifecycle(o: string, c: string): CompareResult | undefined {
|
||||
const ro = LIFECYCLE_ORDER[text(o).toLowerCase()];
|
||||
const rc = LIFECYCLE_ORDER[text(c).toLowerCase()];
|
||||
if (ro === undefined || rc === undefined) return undefined;
|
||||
return rc === ro ? 'equal' : rc > ro ? 'covers' : 'differs';
|
||||
}
|
||||
|
||||
function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStatus: DifferenceStatus; riskLevel: RiskLevel; note: string } {
|
||||
const o = parseParamValue(p.originalValue);
|
||||
const c = parseParamValue(p.candidateValue);
|
||||
const hard = p.constraintType === 'hard';
|
||||
let compareResult = compareParsed(o, c);
|
||||
if (/生命周期|lifecycle/i.test(p.parameterName)) compareResult = compareLifecycle(p.originalValue, p.candidateValue) ?? compareResult;
|
||||
const req = satisfiesRequirement(p.requirement, c);
|
||||
/*
|
||||
* **客户约束原 PN 自己也不满足时,不是替代差异**(2026-08-27 晚,用户送来 0bddd862:「输入电压范围两个
|
||||
* 不是一样的吗为什么不匹配」):客户写「输入电压在 45V 以内」,模型译成 `>= 45`,而原 PN 与候选的输入上限
|
||||
* 都是 40V —— 从前这里一票否决,候选与在用料一模一样却被记成硬约束 MISMATCH、整颗不推荐。
|
||||
* 差异评估比的是「换上去会不会比现在差」:候选与原 PN 相等 / 盖住原范围,且这条客户边界**原 PN 一样过不去**,
|
||||
* 那是需求边界与在用料之间的差距(该回到需求核对 / 验证),不该记在候选头上。
|
||||
* 候选比原 PN 窄 / 不同而过不了客户线,仍是 MISMATCH;原 PN 一侧算不出(未披露 / 文本)也仍按候选过不了线判。
|
||||
*/
|
||||
if (req === false) {
|
||||
const parity = compareResult === 'equal' || compareResult === 'covers';
|
||||
const originalFailsToo = parity && satisfiesRequirement(p.requirement, o) === false;
|
||||
if (!originalFailsToo) {
|
||||
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
|
||||
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` };
|
||||
}
|
||||
const boundary = `客户约束 ${text(p.requirement)} 原 PN 与候选都不满足 —— 是需求边界与在用料的差距,不是替代差异,请回头核对需求`;
|
||||
if (hard && text(p.evidenceRef) === '') {
|
||||
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: `硬约束缺证据引用:未经证据支持不判 MATCH;${boundary}` };
|
||||
}
|
||||
return { compareResult, differenceStatus: 'match', riskLevel: 'medium', note: `${compareResult === 'covers' ? '候选范围盖住原范围' : '与原 PN 一致'};${boundary}` };
|
||||
}
|
||||
if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' };
|
||||
if (compareResult === 'equal' || compareResult === 'covers') {
|
||||
if (hard && text(p.evidenceRef) === '') {
|
||||
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' };
|
||||
}
|
||||
return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' };
|
||||
}
|
||||
if (compareResult === 'configurable') return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '候选可调,须改外围把它配成原值' };
|
||||
if (compareResult === 'partial') return { compareResult, differenceStatus: 'conditional', riskLevel: hard ? 'medium' : 'low', note: '范围部分重叠,须按应用边界确认' };
|
||||
compareResult = compareResult === 'narrower' ? 'narrower' : 'differs';
|
||||
return hard
|
||||
? { compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: '硬约束不一致' }
|
||||
: { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '软约束不一致,须评估影响' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 比对工具读台账用的口 —— 按地址与等值条件取行(taskId 有就取任务视图 = 正式版 + 本单草稿,没有就只读正式版)。
|
||||
* 只读、不写;由 app 用 ledger 服务实装,测试给假的。
|
||||
*/
|
||||
export type ParameterLedger = {
|
||||
rows(taskId: TaskId | undefined, address: { domain: string; object: string }, where: Record<string, string>): Promise<ReadonlyArray<Record<string, unknown>>>;
|
||||
};
|
||||
|
||||
type CandidateInput = {
|
||||
candidateInternalPn: string;
|
||||
pairs?: ParamPair[] | null;
|
||||
/** 客户给了边界的参数:{ 参数名: ">= 40" / "-40~85" } —— 客户约束在需求原文里,归模型译;值不归它 */
|
||||
requirements?: Record<string, unknown> | null;
|
||||
evidenceRef?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* **比对入参由基座从台账取,模型只报料号与客户约束**(2026-08-27 晚,用户:「这个做差异评估比对的 harness 肯定有问题,
|
||||
* 要让它认真比对每一条」)。从前 pairs 由模型从 `substitution.difference_item` 抄给工具:实测它把剧本示例里的
|
||||
* 「4.5~32 / >= 40」当成这单的值念出来,而抄了什么**不留痕**(只存 inputHash),事后没法审。
|
||||
* 现在给 originalInternalPn + candidateInternalPn,参数行从 `parts.parameter` 现读、封装与生命周期从 `parts.profile` 现读、
|
||||
* 证据引用没给就从 `parts.document` 取 —— 每一对都是台账里的原值;出参把 originalValue / candidateValue / evidenceRef
|
||||
* 一并摆出来,写盘照抄即可。老形状(自带 pairs)照旧接,试运行与老调用方不受影响。
|
||||
*/
|
||||
async function pairsFromLedger(ledger: ParameterLedger, taskId: TaskId | undefined, original: string, candidate: string, cand: CandidateInput) {
|
||||
const PARAM = { domain: 'parts', object: 'parameter' };
|
||||
const PROFILE = { domain: 'parts', object: 'profile' };
|
||||
const DOC = { domain: 'parts', object: 'document' };
|
||||
const [oParams, cParams, oProfiles, cProfiles] = await Promise.all([
|
||||
ledger.rows(taskId, PARAM, { internal_pn: original }),
|
||||
ledger.rows(taskId, PARAM, { internal_pn: candidate }),
|
||||
ledger.rows(taskId, PROFILE, { internal_pn: original }),
|
||||
ledger.rows(taskId, PROFILE, { internal_pn: candidate }),
|
||||
]);
|
||||
const oProfile = oProfiles[0];
|
||||
const cProfile = cProfiles[0];
|
||||
if (oParams.length === 0 && oProfile === undefined) {
|
||||
return { error: `原 PN ${original} 在台账里没有主数据也没有参数行 —— 先把它建进 parts.profile / parts.parameter(数据工单),再比` };
|
||||
}
|
||||
if (cParams.length === 0 && cProfile === undefined) {
|
||||
return { error: `候选 ${candidate} 在台账里没有主数据也没有参数行 —— 先建进 parts.profile / parts.parameter,再比` };
|
||||
}
|
||||
let evidenceRef = text(cand.evidenceRef);
|
||||
if (evidenceRef === '') {
|
||||
const docs = await ledger.rows(taskId, DOC, { internal_pn: candidate });
|
||||
const sheet = docs.find((d) => text(d['document_type']) === 'datasheet' && text(d['document_ref']) !== '') ?? docs.find((d) => text(d['document_ref']) !== '');
|
||||
if (sheet !== undefined) evidenceRef = `P-08 · P-10 ${text(sheet['document_version'])}`.trim();
|
||||
}
|
||||
const requirements = cand.requirements !== null && typeof cand.requirements === 'object' ? cand.requirements : {};
|
||||
const requirementOf = (name: string) => text(requirements[name]);
|
||||
const byName = new Map(cParams.map((r) => [text(r['param_name']), r] as const));
|
||||
const sorted = [...oParams].sort((a, b) => text(a['param_name']).localeCompare(text(b['param_name']), 'zh-Hans-CN'));
|
||||
const pairs: ParamPair[] = sorted
|
||||
.filter((o) => text(o['param_name']) !== '')
|
||||
.map((o) => {
|
||||
const name = text(o['param_name']);
|
||||
const c = byName.get(name);
|
||||
return {
|
||||
parameterName: name,
|
||||
originalValue: text(o['param_value']),
|
||||
candidateValue: c === undefined ? '' : text(c['param_value']),
|
||||
unit: text(o['param_unit']) || (c === undefined ? '' : text(c['param_unit'])),
|
||||
constraintType: text(o['constraint_type']) === 'soft' ? ('soft' as const) : ('hard' as const),
|
||||
requirement: requirementOf(name),
|
||||
evidenceRef,
|
||||
};
|
||||
});
|
||||
// 封装(硬)与生命周期(软)不是参数,住在主数据上 —— 两颗都有档案时才比
|
||||
if (oProfile !== undefined && cProfile !== undefined) {
|
||||
if (text(oProfile['package']) !== '' || text(cProfile['package']) !== '') {
|
||||
pairs.push({ parameterName: '封装', originalValue: text(oProfile['package']), candidateValue: text(cProfile['package']), unit: '', constraintType: 'hard', requirement: requirementOf('封装'), evidenceRef });
|
||||
}
|
||||
if (text(oProfile['lifecycle_status']) !== '' || text(cProfile['lifecycle_status']) !== '') {
|
||||
pairs.push({ parameterName: '生命周期', originalValue: text(oProfile['lifecycle_status']), candidateValue: text(cProfile['lifecycle_status']), unit: '', constraintType: 'soft', requirement: requirementOf('生命周期'), evidenceRef });
|
||||
}
|
||||
}
|
||||
const known = new Set(pairs.map((p) => p.parameterName));
|
||||
const extraCandidateParams = [...byName.keys()].filter((n) => n !== '' && !known.has(n));
|
||||
return { pairs, extraCandidateParams };
|
||||
}
|
||||
|
||||
export function compareParametersWith(ledger?: ParameterLedger): ToolHandler {
|
||||
return async ({ input, taskId }) => {
|
||||
const original = text(input['originalInternalPn']);
|
||||
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as CandidateInput[]) : [];
|
||||
const results: Array<Record<string, unknown>> = [];
|
||||
for (const cand of candidates) {
|
||||
const candidatePn = text(cand.candidateInternalPn);
|
||||
let pairs: ParamPair[];
|
||||
let source: 'pairs' | 'ledger';
|
||||
let extraCandidateParams: string[] = [];
|
||||
if (Array.isArray(cand.pairs) && cand.pairs.length > 0) {
|
||||
pairs = cand.pairs;
|
||||
source = 'pairs';
|
||||
} else {
|
||||
if (ledger === undefined) {
|
||||
return { ok: false, error: { code: 'invalid-input', retryable: false, message: `候选 ${candidatePn} 没给 pairs,而这条通道读不到台账 —— 给 pairs,或经任务里调用` } };
|
||||
}
|
||||
if (original === '') {
|
||||
return { ok: false, error: { code: 'invalid-input', retryable: false, message: '缺 originalInternalPn:按料号从台账取参数,得先说原 PN 是谁' } };
|
||||
}
|
||||
const fetched = await pairsFromLedger(ledger, taskId, original, candidatePn, cand);
|
||||
if ('error' in fetched) return { ok: false, error: { code: 'not-found', retryable: false, message: fetched.error } };
|
||||
pairs = fetched.pairs;
|
||||
extraCandidateParams = fetched.extraCandidateParams;
|
||||
source = 'ledger';
|
||||
}
|
||||
const counts: CompareCounts = { match: 0, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 };
|
||||
const items = pairs.map((p) => {
|
||||
const j = judgePair(p);
|
||||
counts[j.differenceStatus] += 1;
|
||||
if (j.differenceStatus === 'mismatch') counts[p.constraintType === 'hard' ? 'hardMismatch' : 'softMismatch'] += 1;
|
||||
if (j.compareResult === 'configurable') counts.configurable += 1;
|
||||
return {
|
||||
parameterName: p.parameterName,
|
||||
originalValue: text(p.originalValue),
|
||||
candidateValue: text(p.candidateValue),
|
||||
// 两边各读成了什么(范围 / 定值 / 单边界 / 文本)—— 比对的依据摆出来,「4.5~40」是范围不是两个数
|
||||
readAs: { original: describeParsed(parseParamValue(p.originalValue)), candidate: describeParsed(parseParamValue(p.candidateValue)) },
|
||||
unit: p.unit ?? '',
|
||||
constraintType: p.constraintType,
|
||||
requirement: text(p.requirement),
|
||||
evidenceRef: text(p.evidenceRef),
|
||||
...j,
|
||||
};
|
||||
});
|
||||
results.push({
|
||||
candidateInternalPn: candidatePn,
|
||||
source,
|
||||
items,
|
||||
counts,
|
||||
...(extraCandidateParams.length === 0 ? {} : { extraCandidateParams }),
|
||||
});
|
||||
}
|
||||
return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
|
||||
};
|
||||
}
|
||||
|
||||
/** 不带台账的那份(pairs 模式):静态名单里挂它,app 用 `compareParametersWith(ledger)` 顶掉 */
|
||||
const compareParameters: ToolHandler = compareParametersWith();
|
||||
|
||||
/**
|
||||
* 推荐定级(D02,FIRST):硬 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐(等验证闭环);
|
||||
* 有 configurable(改外围配得成)→ 备选;其余 conditional → 条件推荐;全 MATCH → 推荐。与 manifest 里的 D02 表同一份规则。
|
||||
*/
|
||||
const recommend: ToolHandler = async ({ input }) => {
|
||||
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; counts: Partial<CompareCounts> }>) : [];
|
||||
const results = candidates.map((cand) => {
|
||||
const n = (k: keyof CompareCounts) => Number(cand.counts?.[k] ?? 0);
|
||||
if (n('hardMismatch') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'not_recommended', riskLevel: 'high', reason: `硬约束 MISMATCH ${n('hardMismatch')} 项:硬约束失败直接不推荐` };
|
||||
if (n('unknown') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `UNKNOWN ${n('unknown')} 项待验证闭环,通过后可升级推荐` };
|
||||
if (n('configurable') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'alternate', riskLevel: 'medium', reason: `${n('configurable')} 项须改外围配成原值(另有 ${n('conditional') - n('configurable')} 项条件差异):客户接受改设计则启用` };
|
||||
if (n('conditional') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `CONDITIONAL ${n('conditional')} 项须按应用边界确认` };
|
||||
return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'recommended', riskLevel: 'low', reason: `${n('match')} 项全部 MATCH` };
|
||||
});
|
||||
return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
|
||||
};
|
||||
|
||||
export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> = [
|
||||
['crm.history-price-read', historyPriceRead],
|
||||
@@ -677,6 +362,4 @@ export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> =
|
||||
['slot.placeholder', slotPlaceholder],
|
||||
['crm-relationship-coverage.recompute-score', recomputeScore],
|
||||
['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
|
||||
['part-substitution-assessment.compare-parameters', compareParameters],
|
||||
['part-substitution-assessment.recommend', recommend],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* `analyze` 动词的实现 —— **一次结构化调用,没有第二次自由**(spec §5.2)。
|
||||
*
|
||||
* 与开环 Agent 的分工是死的:那一件能调工具、能写台账;这一件只是个纯函数。
|
||||
* 出参不合 schema 就地重问一次(平台在 agent-runner 上早有这条路),再不成就抛 ——
|
||||
* 抛出去由配方执行器留痕交人,**不重试五遍、不退回模型自由发挥**。
|
||||
*
|
||||
* **用量必须交回去记账**(2026-08-29 终审 C1):`createModelGateway` 自己不做
|
||||
* `addConsumption`,计量全靠调用方给的 `onUsage` —— agent 那两条路都接了,
|
||||
* 只有这一条从前接的是**裸网关**:配方里 analyze 烧的 token 一个数都不计入活动预算,
|
||||
* 时间轴上也没有模型 span(那要 `tracedGateway`,由装配层决定传谁进来)。
|
||||
* spec §5.2 写的是「走现有模型网关(四元绑定、凭证隔离、录制回放、**用量计入预算**)」——
|
||||
* 少了这一条,那句话就只是文档里的一厢情愿。
|
||||
*
|
||||
* **两次都要记**:重问那一次照样烧真钱,漏记等于「失败的调用不要钱」。
|
||||
*/
|
||||
import type { ModelGateway } from '@modules/llm';
|
||||
import type { AnalyzeGateway } from '@modules/loop-engine';
|
||||
import type { TaskId } from '@contracts';
|
||||
|
||||
const RETRY_HINT =
|
||||
'上一次的回答没有按要求给出 JSON。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码围栏。';
|
||||
|
||||
export function createAnalyzeGateway(
|
||||
llm: Pick<ModelGateway, 'generate'>,
|
||||
/** 用量回账 —— 与 `engines.ts` 里 agent 那两条路的 `onUsage` 同一个函数 */
|
||||
onUsage?: (taskId: TaskId, tokens: number) => Promise<void>,
|
||||
): AnalyzeGateway {
|
||||
return {
|
||||
async run(input) {
|
||||
const prompt = JSON.stringify(input.payload, null, 2);
|
||||
const call = {
|
||||
model: input.model,
|
||||
system: input.instruction,
|
||||
prompt,
|
||||
/* 判断归判定表与规则,模型只做归类 —— 隐藏推理按秒计,这一档一律关 */
|
||||
reasoning: 'off' as const,
|
||||
temperature: 0.2,
|
||||
outputSchema: input.outputSchema as Record<string, unknown>,
|
||||
taskId: input.taskId,
|
||||
label: input.label,
|
||||
};
|
||||
/* 记账**不吞异常**:记不上账 = 预算这道闸失效,宁可当场停下由配方留痕交人,也不要一单无声无息烧穿 */
|
||||
const bill = async (usage: { inputTokens: number; outputTokens: number }): Promise<void> => {
|
||||
if (onUsage === undefined) return;
|
||||
await onUsage(input.taskId, usage.inputTokens + usage.outputTokens);
|
||||
};
|
||||
const first = await llm.generate(call);
|
||||
await bill(first.usage);
|
||||
if (typeof first.object === 'object' && first.object !== null) {
|
||||
return { object: first.object as Record<string, unknown> };
|
||||
}
|
||||
const second = await llm.generate({ ...call, system: `${input.instruction}\n\n${RETRY_HINT}` });
|
||||
await bill(second.usage);
|
||||
if (typeof second.object === 'object' && second.object !== null) {
|
||||
return { object: second.object as Record<string, unknown> };
|
||||
}
|
||||
throw new Error(`${input.label}:模型出参不成形,重问一次仍不成 —— 交给你处理`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { idempotencyKey } from '@modules/release';
|
||||
|
||||
import { createLoopStateStore, createTurnRunner, criterionKey, readingsOf } from '@modules/loop-engine';
|
||||
import type { LoopEngineService } from '@modules/loop-engine';
|
||||
import { createAnalyzeGateway } from './analyze-gateway.ts';
|
||||
import { createSkillEngineService } from '@modules/skill-engine';
|
||||
import { createTaskService, createTaskStore } from '@modules/task';
|
||||
import type { TaskService } from '@modules/task';
|
||||
@@ -222,6 +223,49 @@ export function createOnlyViolation(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 决策卡的**面孔与人工验收清单**:调用方递了就用它的,没递才退回剧本声明的(按 itemId 查到的那一步)。
|
||||
*
|
||||
* 纯函数:剧本那一步由调用方查好递进来,这里只挑值。
|
||||
*
|
||||
* **清单也要这道兜底**(2026-08-29 终审 C2):从前只有 `variant` 有兜底,`checklist` 没有 ——
|
||||
* 配方(`auto-runner`)发审核卡时只递了 variant,于是那张卡穿着验收工单的衣服、`allowCustom` 也对,
|
||||
* **一条待确认都没有**:回传门 `checkChatCardReply` 的 `checklistMissing` 在 checklist 缺席时**恒过**,
|
||||
* 「确认通过」当场可按 —— 2026-08-27 用户当面定的那道人工验收闸凭空没了,而屏上还看不出来
|
||||
* (清单区是空的,不是红的)。兜在这一处而不是让每个调用方自己记得:**模型路径将来漏传也一并保住**。
|
||||
*
|
||||
* **清单兜底得看 `reviewFace`**(2026-08-29 复审 I2 二审):`isReleaseStep` 那条口径决定了
|
||||
* 一步可以只声明 `acceptanceChecklist`(给终局那张 `AcceptForm` 用)而不带 `cardVariant:'review'`
|
||||
* (T_release 本身就是这种形状)。配方若在**这一步**发一张普通的 `as:'decide'` 卡(既没传
|
||||
* `variant`,`step.cardVariant` 也不是 `review`),从前这里照样把 `acceptanceChecklist` 兜上去 ——
|
||||
* 前端只在 `variant === 'review'` 时才渲染清单(`chat-bubbles.tsx`),人看不到勾可打,
|
||||
* 而 `checkChatCardReply` 又要求 `checked ⊇ checklist`:卡按不下去。**只在 `reviewFace` 为真时**
|
||||
* 才退回剧本声明的清单 —— 非审核面孔的卡,清单就是没有,不发明一条按不下去的。
|
||||
*/
|
||||
export function decisionCardFace(
|
||||
input: { readonly variant?: 'review' | undefined; readonly checklist?: ReadonlyArray<string> | undefined },
|
||||
step: { readonly cardVariant?: string | undefined; readonly acceptanceChecklist?: ReadonlyArray<string> | undefined } | undefined,
|
||||
): { reviewFace: boolean; checklist: ReadonlyArray<string> | undefined } {
|
||||
const reviewFace = input.variant === 'review' || step?.cardVariant === 'review';
|
||||
const checklist =
|
||||
input.checklist !== undefined && input.checklist.length > 0
|
||||
? input.checklist
|
||||
: reviewFace
|
||||
? step?.acceptanceChecklist
|
||||
: undefined;
|
||||
return { reviewFace, checklist };
|
||||
}
|
||||
|
||||
/** 要不要去清单里查那一步:没给 itemId 查不了;面孔与清单都递全了不必查(省一次 manifest 取数) */
|
||||
export function needsStepLookup(input: {
|
||||
readonly itemId?: string | undefined;
|
||||
readonly variant?: 'review' | undefined;
|
||||
readonly checklist?: ReadonlyArray<string> | undefined;
|
||||
}): boolean {
|
||||
if (input.itemId === undefined) return false;
|
||||
return input.variant === undefined || input.checklist === undefined;
|
||||
}
|
||||
|
||||
export function rowReadings(
|
||||
mine: Readonly<Record<string, readonly unknown[]>>,
|
||||
declared: ReadonlyArray<{ domain: string; object: string }> = [],
|
||||
@@ -990,6 +1034,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
object: d.object,
|
||||
label: d.label ?? `${d.domain}.${d.object}`,
|
||||
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||
/* 单标识列 —— 配方留痕点名行时按它削掉单号那一段(与 `rowLabel` 同一条口径) */
|
||||
...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }),
|
||||
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
|
||||
createOnly: d.createOnly === true,
|
||||
}));
|
||||
@@ -1014,7 +1060,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
patch: Record<string, unknown>;
|
||||
/** 只验不写 —— 多行 write 先整批过门再落笔(见 loop 那一侧的两遍) */
|
||||
dryRun?: boolean;
|
||||
}) {
|
||||
/** 值没变就不落笔(配方专用,见 `TaskDataGateway.draftWrite` 上的注释:落笔会擦掉「已落地」记号) */
|
||||
skipUnchanged?: boolean;
|
||||
/** 「只建不改」的表碰到已在库的键:跳过而不是拒(配方专用,同上) */
|
||||
skipExisting?: boolean;
|
||||
}): Promise<{ changed: boolean; skipped?: boolean }> {
|
||||
const allowed = await taskData.writable(input.taskId);
|
||||
const target = allowed.find((a) => a.domain === input.domain && a.object === input.object);
|
||||
if (target === undefined) {
|
||||
@@ -1047,7 +1097,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
.filter((d) => d.landed && d.domain === input.domain && d.object === input.object)
|
||||
.map((d) => d.key);
|
||||
const why = createOnlyViolation(target, input.key, formal.rows as ReadonlyArray<Record<string, unknown>>, landedKeys);
|
||||
if (why !== undefined) throw new Error(why);
|
||||
/*
|
||||
* **对模型是拒回,对配方是常态**(2026-08-29 终审 I2):那句话写的是
|
||||
* 「别再写它,要是本单草稿里已经替它铺了骨架,用 drop 把那一行撤掉」——
|
||||
* 说给模型听的。配方没有「换个做法」这回事,抛出去就是一单永久停在那儿,
|
||||
* 而人看见的是一句他既看不懂也办不了的拒回。它要的答案很简单:这一颗不用建。
|
||||
*/
|
||||
if (why !== undefined) {
|
||||
if (input.skipExisting === true) return { changed: false, skipped: true };
|
||||
throw new Error(why);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1123,6 +1182,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
|
||||
|
||||
if (input.dryRun === true) return { changed };
|
||||
/*
|
||||
* **值没变就不落笔**(2026-08-29 终审 I2,配方专用旗)—— `DraftStore.put` 的 upsert
|
||||
* 尾巴上有 `landed_at = null`:照写会把数据工单已确认入库的那些行**悄悄 un-land**。
|
||||
* 模型那一路不传这个旗(它的空转写要被看见),行为一个字不变。
|
||||
*/
|
||||
if (input.skipUnchanged === true && !changed) return { changed };
|
||||
await deps.ledger.draftWrite({
|
||||
taskId: input.taskId,
|
||||
address: { domain: input.domain, object: input.object },
|
||||
@@ -1399,21 +1464,29 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
const recommended = input.options.find((o) => o.recommended === true);
|
||||
/*
|
||||
* 审核卡(剧本 cardVariant:'review'):按验收工单的样子摆,机制仍是决策卡 —— 剧本说了算,不靠模型抄。
|
||||
* 面孔**引擎按当下那一步定好递过来**(`input.variant`,2026-08-27:模型漏了 itemId,卡退成「需要你定」);
|
||||
* 面孔与**人工验收清单**都由调用方(引擎按当下那一步)定好递过来
|
||||
* (`input.variant` / `input.checklist`,2026-08-27:模型漏了 itemId,卡退成「需要你定」);
|
||||
* 没递才退回按 itemId 查剧本。
|
||||
*
|
||||
* **清单也要这道兜底**(2026-08-29 终审 C2):从前只有 `variant` 有,`checklist` 没有 ——
|
||||
* 配方发的审核卡(`auto-runner` 那一路只递了 variant)于是穿着验收工单的衣服、
|
||||
* `allowCustom` 也对,**一条待确认都没有**:回传门 `checklistMissing` 在 checklist 缺席时恒过,
|
||||
* 「确认通过」当场可按 —— 2026-08-27 用户当面定的那道人工验收闸凭空没了,
|
||||
* 而屏上还看不出来(清单区是空的,不是红的)。
|
||||
* 兜在这一处而不是各调用方自己记得:**模型路径将来漏传也一并保住**。
|
||||
*/
|
||||
const reviewFace =
|
||||
input.variant === 'review' ||
|
||||
(input.itemId !== undefined &&
|
||||
(await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
|
||||
const declaredStep = needsStepLookup(input)
|
||||
? (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
|
||||
(st) => st.item === input.itemId,
|
||||
)?.cardVariant === 'review');
|
||||
)
|
||||
: undefined;
|
||||
const { reviewFace, checklist } = decisionCardFace(input, declaredStep);
|
||||
const issued = await deps.humanTool.issueChatCard(
|
||||
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
|
||||
{
|
||||
k: 'decision',
|
||||
...(reviewFace ? { variant: 'review' as const } : {}),
|
||||
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: [...input.checklist] }),
|
||||
...(checklist === undefined || checklist.length === 0 ? {} : { checklist: [...checklist] }),
|
||||
id: cardIdSchema.parse(ids.next()),
|
||||
at: clock.now().toISOString(),
|
||||
ask: input.ask,
|
||||
@@ -2289,6 +2362,13 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
},
|
||||
tasks: taskContext,
|
||||
clock,
|
||||
/*
|
||||
* **裸网关不行**(2026-08-29 终审 C1):从前这里传的是 `deps.modelGateway` ——
|
||||
* 配方里 `analyze` 烧的 token 一个数都不计入活动预算(计量全靠调用方给的 `onUsage`),
|
||||
* 时间轴上也不产生模型 span。agent 那两条路(:2194 / :2242)一直是 `tracedGateway` + `onAgentUsage`,
|
||||
* 只有这一条漏了 —— 一个能问模型却不花钱的口子,预算这道闸对它就是不存在的。
|
||||
*/
|
||||
analyze: createAnalyzeGateway(tracedGateway, onAgentUsage),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -348,3 +348,477 @@ describe('refersTo 目标要在本活动的声明里 —— 写错的地址会
|
||||
expect(found.map((f) => f.code)).toEqual(['reference-target-unbound']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* ── 配方静态审(2026-08-29)──────────────────────────────────────────
|
||||
*
|
||||
* `autoRecipeFindings` 读的是**整份** `ActivityManifest`(不像本文件其余审读只收窄到
|
||||
* 字段级),所以这里不能再用局部的 `manifest()` / `table()` 这类窄类型 helper ——
|
||||
* 借 `activityManifestSchema.parse()` 把六区的默认值一次性填满,只手写真正必填的那几处。
|
||||
*/
|
||||
import { activityManifestSchema } from '@contracts';
|
||||
import { autoRecipeFindings } from '../internal/domain/static-audit.ts';
|
||||
|
||||
/** 台账地址与列名全部真实声明过 —— `demo.record` 只有 `case_ref` / `note` 两列 */
|
||||
function manifestFixture(): ActivityManifest {
|
||||
return activityManifestSchema.parse({
|
||||
identity: {
|
||||
activityId: 'demo-activity',
|
||||
name: '演示活动',
|
||||
packageVersion: '1.0.0',
|
||||
category: '演示',
|
||||
sdkVersion: '1.0.0',
|
||||
checksum: 'chk',
|
||||
},
|
||||
contract: {
|
||||
activityId: 'demo-activity',
|
||||
version: '1.0.0',
|
||||
input: { data: { type: 'object', properties: {} } },
|
||||
output: {},
|
||||
authorization: { humanCallBudget: 0 },
|
||||
resources: { budget: { steps: 10, tokens: 10000, activeMinutes: 10, humanCalls: 0 } },
|
||||
},
|
||||
declarations: {
|
||||
models: ['m'],
|
||||
ledger: [
|
||||
{
|
||||
domain: 'demo',
|
||||
object: 'record',
|
||||
kind: 'record',
|
||||
caseKey: 'case_ref',
|
||||
label: '演示记录',
|
||||
fields: [
|
||||
{ name: 'case_ref', type: 'text', primaryKey: true },
|
||||
{ name: 'note', type: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
harness: {},
|
||||
},
|
||||
assets: {
|
||||
businessModel: {
|
||||
cmmnXml: '<caseFileItem id="S1" name="x"/>',
|
||||
narrative: '演示说明书',
|
||||
dmn: { drd: [], tables: [] },
|
||||
steps: [],
|
||||
},
|
||||
},
|
||||
samples: [{ name: '样例', input: {}, expect: { ports: ['done'] } }],
|
||||
entry: { file: 'index.js', checksum: 'chk' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', () => {
|
||||
const withRecipe = (auto: unknown) => {
|
||||
const base = manifestFixture();
|
||||
return {
|
||||
...base,
|
||||
assets: {
|
||||
...base.assets,
|
||||
businessModel: {
|
||||
...base.assets.businessModel,
|
||||
steps: [{ item: 'S1', guide: '', writes: [], reads: [], tools: [], auto }],
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
};
|
||||
|
||||
it('台账地址不在 declarations.ledger 里 → error', () => {
|
||||
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }));
|
||||
expect(findings.some((f) => f.severity === 'error' && f.message.includes('nope.nope'))).toBe(true);
|
||||
});
|
||||
|
||||
it('列名不在该对象的声明里 → error(列名写错 = 那一格永远是空的)', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { 不存在的列: 'x' } }] }),
|
||||
);
|
||||
expect(findings.some((f) => f.severity === 'error' && f.message.includes('不存在的列'))).toBe(true);
|
||||
});
|
||||
|
||||
it('each 的地址同样要在声明里', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { each: 'mine:nope.nope' }, fields: {} }] }),
|
||||
);
|
||||
expect(findings.some((f) => f.message.includes('nope.nope'))).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-29 评审(Minor):上面那条「each / lookup 的地址同样要在声明里」的名字曾经名不副实 ——
|
||||
* 用例体只测了 `from.each`,从没有一条真的在 `project.fields` 里放过 `{ lookup, by, take }` 节点。
|
||||
* `walkExpr` 的 lookup 递归分支因此只被间接触到,card/say 那个洞就是这么躲过测试套件的(见下方)。
|
||||
*/
|
||||
it('project.fields 里的 lookup 地址同样要在声明里', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({
|
||||
do: [
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'demo.record',
|
||||
from: { once: true },
|
||||
fields: { note: { lookup: 'nope.nope', by: {}, take: 'x' } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(findings.some((f) => f.severity === 'error' && f.message.includes('nope.nope'))).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-29 评审(Important):`card.title` / `card.ask` / `card.standard` / `card.basis[].v`
|
||||
* 全是 AutoExpr,合法地可以含 lookup —— 从前只核了 `gateLedgers`,这四处没被 `walkExpr` 走过,
|
||||
* 卡面上的一处笔误因此能一声不响地装进来。`title` 这里刻意嵌一层 `when/then` ——
|
||||
* 顺手核实 `walkExpr` 对嵌套结构是不是真的递归到底(不是只查表层)。
|
||||
*/
|
||||
it('card 的 title / ask / standard / basis 里藏的 lookup 地址也要核(含嵌套在 when/then 里的)', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({
|
||||
do: [
|
||||
{
|
||||
kind: 'card',
|
||||
as: 'execution',
|
||||
title: { when: 'a = 1', then: { lookup: 'title.nope', by: {}, take: 'x' }, else: 'x' },
|
||||
ask: { lookup: 'ask.nope', by: {}, take: 'x' },
|
||||
standard: { lookup: 'standard.nope', by: {}, take: 'x' },
|
||||
basis: [{ k: 'b', v: { lookup: 'basis.nope', by: {}, take: 'x' } }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const messages = findings.map((f) => f.message).join('\n');
|
||||
expect(messages).toContain('title.nope');
|
||||
expect(messages).toContain('ask.nope');
|
||||
expect(messages).toContain('standard.nope');
|
||||
expect(messages).toContain('basis.nope');
|
||||
});
|
||||
|
||||
/* 2026-08-29 评审(Important):say.text 同样是 AutoExpr —— 从前压根没有 say 分支 */
|
||||
it('say.text 里藏的 lookup 地址也要核', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({ do: [{ kind: 'say', text: { lookup: 'say.nope', by: {}, take: 'x' } }] }),
|
||||
);
|
||||
expect(findings.some((f) => f.severity === 'error' && f.message.includes('say.nope'))).toBe(true);
|
||||
});
|
||||
|
||||
it('in: 与 result: 不是台账地址,不该被当成台账来审', () => {
|
||||
/* 主键列照给 —— 少了它这份配方本来就是坏的(2026-08-29 新增的那条审读),与本条要验的事无关 */
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { each: 'in:pns' }, fields: { case_ref: { col: 'pn' } } }] }),
|
||||
);
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it('analyze 的出参没人消费 → warning(白烧一次模型调用)', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({ do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { once: true }, fields: {} }, output: { type: 'object' } }] }),
|
||||
);
|
||||
expect(findings.some((f) => f.severity === 'warn' && f.message.includes('items'))).toBe(true);
|
||||
});
|
||||
|
||||
it('submit 那一笔所在的步没有人工验收清单 → error(系统提交不比模型提交宽松)', () => {
|
||||
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'submit', rationale: 'x' }] }));
|
||||
expect(findings.some((f) => f.severity === 'error' && f.message.includes('验收清单'))).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **card 后面还有笔 → warn**(2026-08-29 终审 I3 附带):卡真发出去那一拍到此为止,
|
||||
* 排在后面的笔要等人交回之后的那一拍才跑。写配方的人常按顺序读成「铺格 → 发卡 → 收口整理」
|
||||
* 一拍连做 —— 那是读错的,装的时候提前说一声。
|
||||
*/
|
||||
it('card 后面还有笔 → warn(它们要等人交回之后的那一拍才跑)', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({
|
||||
do: [
|
||||
{ kind: 'card', as: 'execution', title: 'x' },
|
||||
{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'a' } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(findings.some((f) => f.severity === 'warn' && f.message.includes('card 后面还有 1 笔'))).toBe(true);
|
||||
});
|
||||
|
||||
it('card 是最后一笔:不出这条 warn', () => {
|
||||
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }] }));
|
||||
expect(findings.some((f) => f.message.includes('card 后面还有'))).toBe(false);
|
||||
});
|
||||
|
||||
/* ── 2026-08-29 终审 I4:spec §5.7 明列却一条都没审的几处 ────────────────── */
|
||||
|
||||
it('**动作级 when 写不成 S-FEEL → error**(运行时它会抛,整步交人)', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({ do: [{ kind: 'say', when: 'note >>= (', text: 'x' }] }),
|
||||
);
|
||||
expect(findings.some((f) => f.severity === 'error' && f.message.includes('写不成 S-FEEL'))).toBe(true);
|
||||
});
|
||||
|
||||
it('**嵌在表达式里的 when 也审语法** —— 它出错是静默走 else,更难查', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({
|
||||
do: [
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'demo.record',
|
||||
from: { once: true },
|
||||
fields: { case_ref: 'a', note: { when: 'note = = 1', then: 'x', else: 'y' } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(findings.some((f) => f.severity === 'error' && f.message.includes('写不成 S-FEEL'))).toBe(true);
|
||||
});
|
||||
|
||||
it('动作级 when 引用了台账上没有的名字 → error(S-FEEL 求值为假,这一笔永远被跳过)', () => {
|
||||
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'say', when: 'candidatesConfirmed > 0', text: 'x' }] }));
|
||||
expect(findings.some((f) => f.severity === 'error' && f.message.includes('candidatesConfirmed'))).toBe(true);
|
||||
});
|
||||
|
||||
it('内建函数名不算未绑定的台账名 —— 名字取 AST 的 references,不是正则扫标识符', () => {
|
||||
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'say', when: 'count(note) > 0', text: 'x' }] }));
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it('动作级 when 引用台账列 / 基座读数 / 配方 let 都算绑得上', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({
|
||||
let: { evalRef: 'W05-1' },
|
||||
do: [{ kind: 'say', when: 'demo_record_rows > 0 and note != "" and evalRef != ""', text: 'x' }],
|
||||
}),
|
||||
);
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it('**嵌套的 when 不审名字** —— 它看得见行内的列,拿 boundNames 去卡会误报', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({
|
||||
do: [
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'demo.record',
|
||||
from: { each: 'demo.record' },
|
||||
fields: { case_ref: { col: 'case_ref' }, note: { when: '本行的列 = "x"', then: 'a', else: 'b' } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it('submit.rationale 里藏的 lookup 地址也要核', () => {
|
||||
const base = manifestFixture();
|
||||
const manifest = {
|
||||
...base,
|
||||
assets: {
|
||||
...base.assets,
|
||||
businessModel: {
|
||||
...base.assets.businessModel,
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
acceptanceChecklist: ['核一遍'],
|
||||
auto: { do: [{ kind: 'submit', rationale: { lookup: 'rationale.nope', by: {}, take: 'x' } }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
expect(autoRecipeFindings(manifest).some((f) => f.message.includes('rationale.nope'))).toBe(true);
|
||||
});
|
||||
|
||||
it('recipe.let 里藏的 lookup 地址也要核(那一圈从前根本没被 walkExpr 走过)', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({ let: { ref: { lookup: 'let.nope', by: {}, take: 'x' } }, do: [{ kind: 'say', text: 'x' }] }),
|
||||
);
|
||||
expect(findings.some((f) => f.severity === 'error' && f.message.includes('let.nope'))).toBe(true);
|
||||
});
|
||||
|
||||
it('project.fields 少了目标表的主键列 → error(运行时那一行会被静默跳过)', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({ do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { note: 'x' } }] }),
|
||||
);
|
||||
expect(findings.some((f) => f.severity === 'error' && f.message.includes('没给主键列:case_ref'))).toBe(true);
|
||||
});
|
||||
|
||||
it('prune 的表又列在 humanWrites 里 → warn(人加的行会被下一拍静默撤掉)', () => {
|
||||
const base = manifestFixture();
|
||||
const manifest = {
|
||||
...base,
|
||||
assets: {
|
||||
...base.assets,
|
||||
businessModel: {
|
||||
...base.assets.businessModel,
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
humanWrites: ['demo.record'],
|
||||
auto: { do: [{ kind: 'project', into: 'demo.record', prune: true, from: { once: true }, fields: { case_ref: 'a' } }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
expect(autoRecipeFindings(manifest).some((f) => f.severity === 'warn' && f.message.includes('humanWrites'))).toBe(true);
|
||||
});
|
||||
|
||||
it('card 没有 basis → error(基座拒发 basis 为空的工单,跑到那儿就停了)', () => {
|
||||
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'card', as: 'execution', title: 'x' }] }));
|
||||
expect(findings.some((f) => f.severity === 'error' && f.message.includes('没有 basis'))).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* ── 字段求值依赖成环(2026-08-30)──────────────────────────────────────
|
||||
*
|
||||
* `fields` 存进 jsonb 之后键序不再保证:`judgement_class` 依赖同笔内的
|
||||
* `difference_status`,一旦被排到它前面,`when` 链求值恒读到 `undefined`,
|
||||
* 三级判断全部落空却不报错(见 `judgement-class-probe.md`)。运行时已经改成
|
||||
* 按依赖排序求值,但 A 依赖 B、B 又依赖 A 排不出任何安全序 —— 那是配方写错了,
|
||||
* 装的时候就该拒。`demo.record` 默认只有 case_ref/note 两列,这里额外声明 a/b
|
||||
* 两列,专给这组用例摆弄依赖关系(不然「列名不在声明里」那条审读会先报,污染断言)。
|
||||
*/
|
||||
const withExtraColumns = (auto: unknown) => {
|
||||
const base = manifestFixture();
|
||||
const [record] = base.declarations.ledger;
|
||||
return {
|
||||
...base,
|
||||
declarations: {
|
||||
...base.declarations,
|
||||
ledger: [
|
||||
{
|
||||
...record,
|
||||
fields: [...(record?.fields ?? []), { name: 'a', type: 'text', primaryKey: false }, { name: 'b', type: 'text', primaryKey: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
assets: {
|
||||
...base.assets,
|
||||
businessModel: {
|
||||
...base.assets.businessModel,
|
||||
steps: [{ item: 'S1', guide: '', writes: [], reads: [], tools: [], auto }],
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
};
|
||||
|
||||
it('project.fields 两个字段互相依赖(a 的 when 提到 b、b 的 when 提到 a)→ error,点名两个字段', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withExtraColumns({
|
||||
do: [
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'demo.record',
|
||||
from: { once: true },
|
||||
fields: { case_ref: 'x', a: { when: 'b = "y"', then: '1', else: '0' }, b: { when: 'a = "y"', then: '1', else: '0' } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const hit = findings.find((f) => f.severity === 'error' && f.message.includes('循环依赖'));
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.message).toContain('a');
|
||||
expect(hit?.message).toContain('b');
|
||||
});
|
||||
|
||||
it('project.fields 链式依赖(不是环,只是 b 引用 a)→ 不报循环依赖', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withExtraColumns({
|
||||
do: [
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'demo.record',
|
||||
from: { once: true },
|
||||
fields: { case_ref: 'x', a: 'seed', b: { when: 'a = "seed"', then: '1', else: '0' } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
|
||||
});
|
||||
|
||||
it('analyze 入参字段成环 → error(与 project.fields 同一条审读)', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withExtraColumns({
|
||||
do: [
|
||||
{
|
||||
kind: 'analyze',
|
||||
model: 'm',
|
||||
into: 'items',
|
||||
instruction: 'x',
|
||||
input: {
|
||||
from: { once: true },
|
||||
fields: { a: { when: 'b = "y"', then: '1', else: '0' }, b: { when: 'a = "y"', then: '1', else: '0' } },
|
||||
},
|
||||
output: { type: 'object' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(findings.some((f) => f.severity === 'error' && f.message.includes('循环依赖'))).toBe(true);
|
||||
});
|
||||
|
||||
it('三级依赖链(与真实 W05 的 difference_status → judgement_class → risk_level 同形)→ 不报循环依赖', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withExtraColumns({
|
||||
do: [
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'demo.record',
|
||||
from: { once: true },
|
||||
fields: {
|
||||
case_ref: 'x',
|
||||
note: { when: 'a = "hit"', then: '1', else: '0' },
|
||||
a: { when: 'b = "hit"', then: 'hit', else: 'miss' },
|
||||
b: 'hit',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
|
||||
});
|
||||
|
||||
/*
|
||||
* **`recipe.let` 同一条审读**(2026-08-30 全分支终审 Important 1)——`let` 与 `fields`
|
||||
* 的依赖读法不一样(`let` 的官方跨条目读法是 `{in:X}`,不是 `when` 里的裸标识符),
|
||||
* 所以静态审那边是单独一份 `letsCycle`/`letReferences`,这里单独一组测试钉住它,
|
||||
* 不是「已经被 fields 那组测过了」。
|
||||
*/
|
||||
it('recipe.let 两条互相依赖({in:X} 互指)→ error,点名两个 let', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({
|
||||
let: { a: { in: 'b' }, b: { in: 'a' } },
|
||||
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'x' } }],
|
||||
}),
|
||||
);
|
||||
const hit = findings.find((f) => f.severity === 'error' && f.message.includes('循环依赖'));
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.message).toContain('a');
|
||||
expect(hit?.message).toContain('b');
|
||||
expect(hit?.message).toContain('let');
|
||||
});
|
||||
|
||||
it('recipe.let 链式依赖(不是环,只是 tag 用 {in:"stamp"} 读 stamp)→ 不报循环依赖', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({
|
||||
let: { stamp: 'W05-', tag: { cat: [{ in: 'stamp' }, 'X'] } },
|
||||
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: { in: 'tag' } } }],
|
||||
}),
|
||||
);
|
||||
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
|
||||
});
|
||||
|
||||
it('recipe.let 引用起单入参 / case: 这类全局名字不算依赖,不误报循环依赖', () => {
|
||||
const findings = autoRecipeFindings(
|
||||
withRecipe({
|
||||
let: { a: { in: 'someInput' }, b: { in: 'case:openedAt' } },
|
||||
do: [{ kind: 'project', into: 'demo.record', from: { once: true }, fields: { case_ref: 'x' } }],
|
||||
}),
|
||||
);
|
||||
expect(findings.some((f) => f.message.includes('循环依赖'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
* 需要查库的两项(工具契约是否已注册、模型名是否已绑定)由 usecase 补进来 ——
|
||||
* 本文件只做**纯**判定。
|
||||
*/
|
||||
import type { ActivityManifest, StaticAuditFinding } from '@contracts';
|
||||
import type { ActivityManifest, AutoExpr, StaticAuditFinding } from '@contracts';
|
||||
/* domain 只准 @contracts / @sfeel / @utils(编码规范 §5.3)—— S-FEEL 是纯函数包,零 IO */
|
||||
import { staticCheck } from '@sfeel';
|
||||
import { topoOrder } from '@utils';
|
||||
|
||||
export type ExternalChecks = {
|
||||
/** 声明引用但工具箱里没有的工具 */
|
||||
@@ -376,6 +379,401 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归找出表达式里全部 `when` 字符串引用的根路径名(用于判「这个字段依赖同笔内的哪几个字段」)。
|
||||
*
|
||||
* 名字取 S-FEEL AST 的 `references`,不用正则扫标识符 —— 与 `checkWhen` 同一个理由:
|
||||
* 正则会把 `count(...)` 这类内建函数名也当成字段名。`when` 语法本该被 `checkWhen` 先一步
|
||||
* 拦住,这里万一撞见解析不了的表达式,当作没有引用处理(宁可漏报不可误报)。
|
||||
*
|
||||
* **与 `loop-engine/internal/domain/auto-order.ts` 里的 `whenReferences` 是同一个算法,
|
||||
* 写了两遍**:装包静态审(L4 `activity-registry`)不能 import 配方执行器(L5
|
||||
* `loop-engine`,模块分层只准低层被高层引用,§4),这一段小小的、纯粹依附 `AutoExpr`
|
||||
* 形状的逻辑只能各自留一份;真正通用、不含业务语义的图算法(`topoOrder`)已经
|
||||
* 提到 `@utils` 共用了。改动这个函数时,记得看看那边要不要一起改。
|
||||
*/
|
||||
function fieldReferences(expr: AutoExpr): readonly string[] {
|
||||
if (typeof expr !== 'object' || expr === null) return [];
|
||||
if ('when' in expr) {
|
||||
const verdict = staticCheck(expr.when);
|
||||
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
||||
return [...names, ...fieldReferences(expr.then), ...fieldReferences(expr.else)];
|
||||
}
|
||||
if ('lookup' in expr) {
|
||||
const byNames = Object.values(expr.by).flatMap((value) => fieldReferences(value));
|
||||
return expr.else === undefined ? byNames : [...byNames, ...fieldReferences(expr.else)];
|
||||
}
|
||||
if ('cat' in expr) return expr.cat.flatMap((part) => fieldReferences(part));
|
||||
if ('fmt' in expr) return fieldReferences(expr.fmt);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* `fields` 内部字段求值依赖能不能排出安全序(与运行时 `orderFields` 同一件事,见上面
|
||||
* `fieldReferences` 的头注)。排得出来给 `undefined`;排不出来(真正的环)把牵涉的字段名
|
||||
* 摆出来 —— **这条错误 2026-08-30 才补上**:jsonb 会把 `fields` 对象的键序打乱,
|
||||
* `judgement_class`/`risk_level` 这类依赖同笔内另一个字段的写法一旦被排到依赖对象前面,
|
||||
* 求值时读到的是 `undefined`,`when` 链全部落空却不报错(详见
|
||||
* `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`);
|
||||
* 运行时已经改成按依赖排序求值,但**真正的环**(A 依赖 B、B 又依赖 A,排不出任何安全序)
|
||||
* 是配方写错了 —— 别留到运行时才炸,装包这一刻就能看出来。
|
||||
*/
|
||||
function fieldsCycle(fields: Readonly<Record<string, AutoExpr>>): readonly string[] | undefined {
|
||||
const entries = Object.entries(fields);
|
||||
const names = new Set(entries.map(([name]) => name));
|
||||
const deps = new Map<string, readonly string[]>(
|
||||
entries.map(([name, expr]) => [name, [...new Set(fieldReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
||||
);
|
||||
const result = topoOrder(
|
||||
entries.map(([name]) => name),
|
||||
(name) => deps.get(name) ?? [],
|
||||
);
|
||||
return result.ok ? undefined : result.cycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同上,但专为 `recipe.let` 用(2026-08-30 全分支终审 Important 1)——多认一种依赖:
|
||||
* **裸 `{in:X}` 本身就是依赖**。`let` 的官方跨条目读法就是 `{in:X}`(见
|
||||
* `packages/contracts/src/auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能
|
||||
* {in:'名'} 引用」,运行时 `evalExpr` 的 `'in' in expr` 分支优先读 `ctx.lets[X]`)——
|
||||
* 与 `fields` 相反(`fieldReferences` 故意不认 `{in:X}`:那边的 `{in:X}` 只读
|
||||
* `lets`/`scope`,永远不会命中同一个 `fields` 对象里的另一个字段,认了反而误报)。
|
||||
* `when`/`lookup.by`/`cat`/`fmt` 四种嵌套形态与 `fieldReferences` 完全一致。
|
||||
*
|
||||
* **与 `loop-engine/internal/domain/auto-order.ts` 里的 `letReferences` 是同一个算法,
|
||||
* 写了两遍**——理由同 `fieldReferences` 头注(L4 装包静态审不能 import L5 配方执行器)。
|
||||
* 改动这个函数时,记得看看那边要不要一起改。
|
||||
*/
|
||||
function letReferences(expr: AutoExpr): readonly string[] {
|
||||
if (typeof expr !== 'object' || expr === null) return [];
|
||||
if ('in' in expr) return [expr.in];
|
||||
if ('when' in expr) {
|
||||
const verdict = staticCheck(expr.when);
|
||||
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
||||
return [...names, ...letReferences(expr.then), ...letReferences(expr.else)];
|
||||
}
|
||||
if ('lookup' in expr) {
|
||||
const byNames = Object.values(expr.by).flatMap((value) => letReferences(value));
|
||||
return expr.else === undefined ? byNames : [...byNames, ...letReferences(expr.else)];
|
||||
}
|
||||
if ('cat' in expr) return expr.cat.flatMap((part) => letReferences(part));
|
||||
if ('fmt' in expr) return letReferences(expr.fmt);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** `recipe.let` 内部求值依赖能不能排出安全序(与运行时 `orderLets` 同一件事)。装的时候就该看出来,不留到运行时才炸。 */
|
||||
function letsCycle(lets: Readonly<Record<string, AutoExpr>>): readonly string[] | undefined {
|
||||
const entries = Object.entries(lets);
|
||||
const names = new Set(entries.map(([name]) => name));
|
||||
const deps = new Map<string, readonly string[]>(
|
||||
entries.map(([name, expr]) => [name, [...new Set(letReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
||||
);
|
||||
const result = topoOrder(
|
||||
entries.map(([name]) => name),
|
||||
(name) => deps.get(name) ?? [],
|
||||
);
|
||||
return result.ok ? undefined : result.cycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* **配方静态审**(2026-08-29)—— 与 `refersTo` 目标检查同款:**写错地址 = 每一单都错**。
|
||||
*
|
||||
* 代码错是必然的错,模型错是概率的错。必然的错好查 —— 前提是它在装包那一刻就被拦下,
|
||||
* 而不是等到第一单跑起来才现形。
|
||||
*/
|
||||
export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFinding[] {
|
||||
const out: StaticAuditFinding[] = [];
|
||||
const ledgers = new Map(
|
||||
manifest.declarations.ledger.map((l) => [
|
||||
`${l.domain}.${l.object}`,
|
||||
{
|
||||
columns: new Set(l.fields.map((f) => f.name)),
|
||||
/* 主键列 —— 与运行时 `writable()` 同一口径(`fields.filter(primaryKey)`) */
|
||||
keys: l.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||
},
|
||||
]),
|
||||
);
|
||||
/**
|
||||
* 动作级 `when` 求值时的作用域 = 任务视图 + 基座读数 + 起单入参(`boundNames`)+ 配方的 `let`。
|
||||
* **不含**行内的列(`col` / `outer` / `computed`)—— 那些只有嵌在表达式里的 `when` 看得见,
|
||||
* 所以名字绑定只审动作级那一层,嵌套的那些只审语法(见下面 `checkWhen`)。
|
||||
*/
|
||||
const declaredNames = boundNames(manifest);
|
||||
const templates = new Set(manifest.assets.viewTemplates.map((t) => t.name));
|
||||
|
||||
/** 台账地址:剥掉 `mine:` 前缀;`in:` / `result:` 不是台账,跳过 */
|
||||
const checkAddress = (raw: string, at: string): void => {
|
||||
if (raw.startsWith('in:') || raw.startsWith('result:')) return;
|
||||
const address = raw.startsWith('mine:') ? raw.slice('mine:'.length) : raw;
|
||||
if (!ledgers.has(address)) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: `配方引的台账地址不在声明里:${address}(写的授权面是台账声明)`,
|
||||
at,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* **`when` 的 S-FEEL 要能解析**(spec §5.7 第三条,2026-08-29 终审 I4 补 —— 从前一条都没审)。
|
||||
* 运行时两种下场都不好:嵌在表达式里的 `{when}` 出错 → `evalExpr` catch 掉、**静默走 else**;
|
||||
* 动作级的 `when` 出错 → 抛 → 整步交人。写错一个字的代价不该是一单跑不动。
|
||||
*
|
||||
* 给了 `lets` 就连**名字绑得上**一起审(只有动作级那一层能这么审:嵌套的 `when`
|
||||
* 还看得见行内的 `col` / `outer` / `computed`,拿 `boundNames` 去卡会误报)。
|
||||
*/
|
||||
const checkWhen = (expr: string, at: string, lets?: ReadonlySet<string>): void => {
|
||||
const verdict = staticCheck(expr);
|
||||
if (!verdict.ok) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: `配方的 when 过不了 S-FEEL 静态检(写不成 S-FEEL):${expr} —— ${verdict.issues.map((i) => i.message).join(';')}`,
|
||||
at,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (lets === undefined) return;
|
||||
/*
|
||||
* 名字取 `staticCheck` 的 `references`(AST 里的路径节点),不用本文件那个正则 `predicateNames` ——
|
||||
* 正则会把 `count(...)` 这类**内建函数名**也当成台账名报未绑定;AST 分得清函数与路径。
|
||||
*/
|
||||
for (const path of verdict.references) {
|
||||
const name = path.split('.')[0] ?? path;
|
||||
if (declaredNames.has(name) || lets.has(name)) continue;
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message:
|
||||
`配方的 when 引用了 \`${name}\`,而台账、案卷、起单入参与本配方的 let 里都没有这个名字 —— ` +
|
||||
'S-FEEL 对未绑定的名字求值为假,这一笔将**永远被跳过**(而且一声不响)',
|
||||
at,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** 表达式里也可能藏着 lookup 地址与 when,递归找一遍 */
|
||||
const walkExpr = (expr: unknown, at: string): void => {
|
||||
if (typeof expr !== 'object' || expr === null) return;
|
||||
const node = expr as Record<string, unknown>;
|
||||
if (typeof node['when'] === 'string') checkWhen(node['when'], at);
|
||||
if (typeof node['lookup'] === 'string') {
|
||||
checkAddress(node['lookup'], at);
|
||||
for (const value of Object.values((node['by'] ?? {}) as Record<string, unknown>)) walkExpr(value, at);
|
||||
if (node['else'] !== undefined) walkExpr(node['else'], at);
|
||||
}
|
||||
if (Array.isArray(node['cat'])) for (const part of node['cat'] as unknown[]) walkExpr(part, at);
|
||||
if (node['fmt'] !== undefined) walkExpr(node['fmt'], at);
|
||||
if (node['then'] !== undefined) walkExpr(node['then'], at);
|
||||
if (typeof node['when'] === 'string' && node['else'] !== undefined) walkExpr(node['else'], at);
|
||||
};
|
||||
|
||||
const walkSource = (source: unknown, at: string): void => {
|
||||
if (typeof source !== 'object' || source === null) return;
|
||||
const node = source as Record<string, unknown>;
|
||||
if (typeof node['each'] === 'string') checkAddress(node['each'], at);
|
||||
for (const value of Object.values((node['where'] ?? {}) as Record<string, unknown>)) walkExpr(value, at);
|
||||
if (typeof node['then'] === 'object' && node['then'] !== null) walkSource(node['then'], at);
|
||||
if (Array.isArray(node['rows'])) {
|
||||
for (const row of node['rows'] as unknown[]) {
|
||||
for (const value of Object.values(row as Record<string, unknown>)) walkExpr(value, at);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const step of manifest.assets.businessModel.steps ?? []) {
|
||||
const recipe = step.auto;
|
||||
if (recipe === undefined) continue;
|
||||
const at = `assets.businessModel.steps[${step.item}].auto`;
|
||||
const consumed = new Set<string>();
|
||||
/* 配方级 `let`:名字进动作级 `when` 的作用域;**它自己的表达式也是 AutoExpr,一样要走一遍** */
|
||||
const letNames = new Set(Object.keys(recipe.let ?? {}));
|
||||
for (const [name, expr] of Object.entries(recipe.let ?? {})) walkExpr(expr, `${at}.let.${name}`);
|
||||
/*
|
||||
* **`let` 之间的求值依赖不能成环**(2026-08-30 全分支终审 Important 1,与下面 `fields`
|
||||
* 那条 `fieldsCycle` 同一课):`let` 存进 jsonb 之后键序不再保证,运行时已经按依赖
|
||||
* (`{in:X}`)排出安全序求值(见 `orderLets` 头注),但 A 依赖 B、B 又依赖 A 排不出
|
||||
* 任何安全序 —— 那是配方写错了,装的时候就该拒,不留到运行时才炸。
|
||||
*/
|
||||
{
|
||||
const cycle = letsCycle(recipe.let ?? {});
|
||||
if (cycle !== undefined) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: `let 之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个 let 是否互相用 {in:...} 或 when 引用了对方)`,
|
||||
at: `${at}.let`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, action] of recipe.do.entries()) {
|
||||
const where = `${at}.do[${index}]`;
|
||||
/* 动作级 `when`:语法 + 名字都审(它看得见的只有 scope 与 let,没有行内列) */
|
||||
if (action.when !== undefined) checkWhen(action.when, where, letNames);
|
||||
if (action.kind === 'project') {
|
||||
checkAddress(action.into, where);
|
||||
walkSource(action.from, where);
|
||||
const target = ledgers.get(action.into);
|
||||
for (const [name, expr] of Object.entries(action.fields)) {
|
||||
walkExpr(expr, where);
|
||||
if (target !== undefined && !target.columns.has(name)) {
|
||||
out.push({ code: 'schema-invalid', severity: 'error', message: `配方写的列不在 ${action.into} 的声明里:${name}`, at: where });
|
||||
}
|
||||
}
|
||||
/*
|
||||
* **prune 的表不该是人在件上能加行的表**(2026-08-29 终审 F3)。
|
||||
* `prune` 说的是「这一笔投影出来的行就是全部,多出来的撤掉」;而 `humanWrites`
|
||||
* 说的是「这张表归人填,模型没有来源」—— 人加的行配方的数据源里当然没有,
|
||||
* 下一拍对齐就把它撤了。两条声明放在同一张表上,意思是相反的。
|
||||
*/
|
||||
if (action.prune === true && (step.humanWrites ?? []).includes(action.into)) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'warn',
|
||||
message:
|
||||
`${action.into} 既被这一笔 prune 投影、又列在这一步的 humanWrites 里 —— ` +
|
||||
'人在件上加的行配方的数据源里没有,下一拍对齐会把它撤掉(静默);' +
|
||||
'要么别 prune 这张表,要么它就不该是人能加行的表',
|
||||
at: where,
|
||||
});
|
||||
}
|
||||
/*
|
||||
* **主键列一个都不能少**(2026-08-29 终审 I4)。运行时 `auto-runner` 拿
|
||||
* `decl.keys` 逐列取值,取不到就当空主键 —— 那一行**静默 `continue`**:
|
||||
* 一行不写、一条痕都没有。表里少了行,人和判据都查不出为什么。
|
||||
* 装包时判得出来的事,不留到运行时。
|
||||
*/
|
||||
if (target !== undefined) {
|
||||
const missing = target.keys.filter((k) => !(k in action.fields));
|
||||
if (missing.length > 0) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message:
|
||||
`配方往 ${action.into} 投影,却没给主键列:${missing.join('、')}(主键:${target.keys.join('、')})—— ` +
|
||||
'运行时主键算不出来的行会被静默跳过,一行不写、一条痕都没有',
|
||||
at: where,
|
||||
});
|
||||
}
|
||||
}
|
||||
/*
|
||||
* **字段求值依赖不能成环**(2026-08-30)。`fields` 存进 jsonb 之后键序不再保证,
|
||||
* 运行时已经按依赖排出安全序求值(见上面 `fieldsCycle` 的头注),但 A 依赖 B、
|
||||
* B 又依赖 A 排不出任何安全序 —— 那是配方写错了,装的时候就该拒,不留到运行时才炸。
|
||||
*/
|
||||
{
|
||||
const cycle = fieldsCycle(action.fields);
|
||||
if (cycle !== undefined) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: `${action.into} 的 fields 之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`,
|
||||
at: where,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action.kind === 'analyze') {
|
||||
walkSource(action.input.from, where);
|
||||
for (const expr of Object.values(action.input.fields)) walkExpr(expr, where);
|
||||
if (!manifest.declarations.models.includes(action.model)) {
|
||||
out.push({ code: 'schema-invalid', severity: 'error', message: `配方用的模型没在 declarations.models 里声明:${action.model}`, at: where });
|
||||
}
|
||||
{
|
||||
const cycle = fieldsCycle(action.input.fields);
|
||||
if (cycle !== undefined) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: `analyze 入参字段之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个字段的 when 是否互相引用了对方)`,
|
||||
at: where,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action.kind === 'card') {
|
||||
for (const address of action.gateLedgers ?? []) checkAddress(address, where);
|
||||
if (action.as === 'decide' && (action.options ?? []).length === 0) {
|
||||
out.push({ code: 'schema-invalid', severity: 'error', message: '决策卡一个选项都没有', at: where });
|
||||
}
|
||||
/*
|
||||
* **basis 空着的卡基座直接拒发**(契约 `auto-recipe.ts` 的注释自己写着「别指望兜底」)——
|
||||
* 运行时那一拒是抛,抛出去就是整步交人。装包时看得见的事不留到运行时。
|
||||
*/
|
||||
if ((action.basis ?? []).length === 0) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: '配方发的卡没有 basis(调用必附上下文)—— 基座拒发 basis 为空的工单,这一步跑到这儿就停了',
|
||||
at: where,
|
||||
});
|
||||
}
|
||||
/*
|
||||
* **`title`/`ask`/`standard`/`basis[].v` 都是 AutoExpr,合法地可以含 lookup**
|
||||
* (2026-08-29 评审补,原实现只核了 `gateLedgers`,这四处从没被 `walkExpr` 走过 ——
|
||||
* 卡面上的一处笔误因此能一声不响地装进来,只在这一步第一次真跑起来时才现形)。
|
||||
*/
|
||||
walkExpr(action.title, where);
|
||||
if (action.ask !== undefined) walkExpr(action.ask, where);
|
||||
if (action.standard !== undefined) walkExpr(action.standard, where);
|
||||
for (const b of action.basis ?? []) walkExpr(b.v, where);
|
||||
/*
|
||||
* **card 后面还有笔:说清它们什么时候跑**(2026-08-29 终审 I3 附带的一条 warn)。
|
||||
* 卡真发出去那一拍到此为止(`stop`);排在后面的笔要等**人交回之后**的那一拍才跑
|
||||
* (那一拍这一笔 `card` 因为 `carded` 被跳过)。写配方的人常按顺序读成
|
||||
* 「铺格 → 发卡 → 收口整理」一拍连做 —— 那是读错的,提前说一声。
|
||||
*/
|
||||
if (index < recipe.do.length - 1) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'warn',
|
||||
message:
|
||||
`card 后面还有 ${recipe.do.length - index - 1} 笔:卡发出去那一拍到此为止,` +
|
||||
'这几笔要等人交回之后的那一拍才跑(那一拍 card 这一笔被跳过)—— 确认这就是你要的次序',
|
||||
at: where,
|
||||
});
|
||||
}
|
||||
}
|
||||
/* `say.text` 同样是 AutoExpr,同一个理由要核(2026-08-29 评审补) */
|
||||
if (action.kind === 'say') {
|
||||
walkExpr(action.text, where);
|
||||
}
|
||||
/* `submit.rationale` 同样是 AutoExpr,合法地可以含 lookup —— 从前这一支只查 acceptanceChecklist */
|
||||
if (action.kind === 'submit') walkExpr(action.rationale, where);
|
||||
if (action.kind === 'submit' && (step.acceptanceChecklist ?? []).length === 0) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: 'submit 那一步必须声明人工验收清单(acceptanceChecklist)—— 系统提交不比模型提交宽松',
|
||||
at: where,
|
||||
});
|
||||
}
|
||||
/* 找出 `result:xxx` 被谁消费了 —— analyze 的出参没人用,就是白烧一次模型调用 */
|
||||
const text = JSON.stringify(action);
|
||||
for (const hit of text.matchAll(/"result:([A-Za-z0-9_]+)"/g)) consumed.add(hit[1] ?? '');
|
||||
}
|
||||
|
||||
for (const action of recipe.do) {
|
||||
if (action.kind !== 'analyze') continue;
|
||||
if (!consumed.has(action.into)) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'warn',
|
||||
message: `analyze 的出参 result:${action.into} 没有任何一笔消费它 —— 白烧一次模型调用`,
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (step.view !== undefined && step.view !== '' && !templates.has(step.view)) {
|
||||
out.push({ code: 'schema-invalid', severity: 'error', message: `剧本引的视图模版不存在:${step.view}`, at });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function staticAudit(manifest: ActivityManifest, external: ExternalChecks): StaticAuditFinding[] {
|
||||
const out: StaticAuditFinding[] = [];
|
||||
|
||||
@@ -425,6 +823,7 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
|
||||
out.push(...ledgerKindFindings(manifest));
|
||||
out.push(...acceptanceLevelFindings(manifest));
|
||||
out.push(...loopFindings(manifest));
|
||||
out.push(...autoRecipeFindings(manifest));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -333,3 +333,21 @@ describe('人工验收清单:点全才能通过', () => {
|
||||
expect(checkChatCardReply(card, reply('custom:意见', undefined, '意见'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('审核卡的选项数', () => {
|
||||
it('普通决策卡仍要两个分支 —— 一个选项的岔路口不是岔路口', () => {
|
||||
const findings = checkChatCard({ k: 'decision', ask: '往哪走', basis: [{ k: 'a', v: 'b' }], options: [{ value: 'a', label: 'A' }] } as never);
|
||||
expect(findings.some((f) => f.code === 'options-missing')).toBe(true);
|
||||
});
|
||||
|
||||
it('审核卡(variant:review)只有「确认通过」一个选项也发得出去 —— 改在件上,卡上只签字', () => {
|
||||
const findings = checkChatCard({
|
||||
k: 'decision',
|
||||
variant: 'review',
|
||||
ask: '核对过了吗',
|
||||
basis: [{ k: 'a', v: 'b' }],
|
||||
options: [{ value: 'approve', label: '确认通过' }],
|
||||
} as never);
|
||||
expect(findings.some((f) => f.code === 'options-missing')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,17 @@ export function checkChatCard(card: ChatCard): CardViolation[] {
|
||||
switch (card.k) {
|
||||
case 'decision':
|
||||
if (card.basis.length === 0) out.push({ code: 'bare-call', message: '决策卡必附依据快照,基座拒发' });
|
||||
if (card.options.length < 2) out.push({ code: 'options-missing', message: '决策卡至少两个分支' });
|
||||
/*
|
||||
* **审核卡是例外**(2026-08-29):它不是岔路口,是一道签字关 ——
|
||||
* 改全在件上就地做,卡上只有「确认通过」一个动作。
|
||||
* 普通决策卡照旧要两个分支:一个选项的岔路口不是岔路口。
|
||||
*/
|
||||
{
|
||||
const floor = card.variant === 'review' ? 1 : 2;
|
||||
if (card.options.length < floor) {
|
||||
out.push({ code: 'options-missing', message: floor === 1 ? '审核卡至少一个选项' : '决策卡至少两个分支' });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'elicitation':
|
||||
if (card.fields.length === 0) out.push({ code: 'fields-missing', message: '补全卡必须给出要填的字段' });
|
||||
|
||||
@@ -266,3 +266,32 @@ describe('回执与内部观察(PRD:页面交互是三类唤醒事件之一)', (
|
||||
expect(projectChat([entry('observation', { id: 'oa', title: '事件到达(human):abcd-123', detail: { kind: 'human' } })])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **配方的机械痕不进对话区**(2026-08-29 终审 I5,控制端裁定 F2)。
|
||||
*
|
||||
* 上一轮为了让配方留痕进工作日志,把 `internal` 整个去掉了 —— 而 `internal` 是
|
||||
* `machinery()`(工作日志)与本文件(对话区)**共用的一个旗**,去掉它等于同时对两个投影开门:
|
||||
* 「芯片主数据:写下 2 行」「已把 8 条参数交给模型逐条判定」这类一笔一个气泡跑进了对话区,
|
||||
* 而这个仓花了很大代价立起「对话区一轮一句,不是一拍一句」。
|
||||
* 现在配方那几笔照旧标 `internal`(这里挡住),工作日志那一侧另开 `detail.auto` 的例外。
|
||||
* 形状取自 `auto-runner.ts` 真实写下的 detail。
|
||||
*/
|
||||
describe('配方留痕:进工作日志,不进对话区(I5)', () => {
|
||||
it('project / analyze 那两笔标着 internal —— 一个气泡都不该冒出来', () => {
|
||||
const out = projectChat([
|
||||
entry('observation', { id: 'p1', title: '芯片主数据:写下 2 行', detail: { auto: 'S0_bind#1', ledger: 'parts.profile', internal: true } }),
|
||||
entry('observation', { id: 'p2', title: '已把 8 条参数交给模型逐条判定,拿回 8 条结论', detail: { auto: 'S3#2', model: 'm', internal: true } }),
|
||||
entry('observation', { id: 'p3', title: '这一步已经发过卡,不再发第二张', detail: { auto: 'S1#2', internal: true } }),
|
||||
]);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('say 与「办不下去」那两笔不标 internal —— 它们是说给人听的,照进', () => {
|
||||
const out = projectChat([
|
||||
entry('observation', { id: 's1', title: '已核对 3 颗料,2 颗要建档', detail: { auto: 'S0#3' } }),
|
||||
entry('observation', { id: 's2', title: '配方第 2 笔办不下去:台账地址不在本活动的声明里 —— 交给你处理', detail: { auto: 'S1#2' } }),
|
||||
]);
|
||||
expect(out.map((m) => m.k)).toEqual(['system', 'system']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* **「加一行」克隆到的必须是数据行,不是表头行**(2026-08-30,评审逮到的 Critical 回归)。
|
||||
*
|
||||
* 背景(ACT-WORK-05 差异评估 Task 7b):`parts.chip_type` / `parts.type_param` 两张表
|
||||
* 需要在**零草稿行**时也能「加一行」(`activities/part-substitution-assessment/src/views/
|
||||
* report.ts` 的 `CHIP_TYPE_TABLE`/`TYPE_PARAM_TABLE`)。为了让 `ViewContainer.collect()`
|
||||
* 同时收到「已有的动态行(`data-rows`)」与「一条不受行数所限的静态种子行」两处的输入,
|
||||
* `data-edit="域.对象"` 挂在了外层 `<table>` 上(而不是只挂在内层 `data-rows` 的
|
||||
* `<tbody>` 上)。
|
||||
*
|
||||
* 副作用:`ViewContainer.bodyOf()`(`packages/ui-kit/src/ViewContainer.tsx:164-167`,
|
||||
* 单个 `document.querySelector`,按文档序返回第一个匹配)从此解析到**外层 `<table>`**;
|
||||
* `addRow()`(`:303-323`)接着在这个 host 里找 `tr:last-of-type` —— CSS `:last-of-type`
|
||||
* 是**相对各自父元素**判定的,`<thead>` 里那个孤零零的 `<tr>` 在它自己的父元素(`thead`)
|
||||
* 里必然是「最后一个」,若 `<thead>` 在文档序上排在数据行之前,它就会被
|
||||
* `querySelectorAll('tr:last-of-type')` 第一个命中 —— 克隆出来的是四个 `<th>` 表头文字、
|
||||
* **零个** `input[data-col]`,还被 `appendChild` 到 `<table>` 底下、不在任何 `<tbody>` 里。
|
||||
* 按钮点了有反应,结果完全不可用,且不报错。
|
||||
*
|
||||
* 修法(`report.ts`,不改 `ViewContainer.tsx`/`view-render.ts` 这两个共享面):把 `<thead>`
|
||||
* 挪到**两个 `<tbody>` 之后**、`</table>` 之前。CSS 表格布局规范里
|
||||
* `table-header-group`(`<thead>` 的默认 display)**始终排在最前面渲染,不看它在 DOM 里
|
||||
* 排第几**(这是 `tfoot` 允许写在 `thead` 后面、`tbody` 前面这条 HTML5 规则背后同一条
|
||||
* 视觉排版原理)——视觉呈现不变,而 `document.querySelector`/`querySelectorAll` 认的是
|
||||
* **文档序**,不是视觉序:`<thead>` 排在两个 `<tbody>` 后面,`tr:last-of-type` 的搜索
|
||||
* 就再也碰不到表头行(表头行在文档序上比所有数据行/种子行都晚,只有当两个 `<tbody>`
|
||||
* 都彻底没有 `<tr>` 时才轮得到它——而静态种子行保证了这种情况不会发生)。
|
||||
*
|
||||
* **这份测试守的是「W05 这两张表此刻的具体 HTML 形状是对的」,不是「addRow/bodyOf 这两个
|
||||
* 函数本身对不对」——两件事分给了两处测试,缺一不可**(2026-08-30 复审第二轮逼出来的教训:
|
||||
* 第一版这里的 `findAddRowTarget` 是纯手抄的选择器复刻,复审把真实 `ViewContainer.tsx:305`
|
||||
* 的 `tr:last-of-type` 换成明摆着错的 `tr:first-of-type` 重跑这 6 条,6/6 全绿零反应——
|
||||
* 全仓当时没有任何测试真的执行过 `bodyOf`/`addRow`)。
|
||||
*
|
||||
* `bodyOf`/`addRow` 现在**已经 `export`**(`packages/ui-kit/src/ViewContainer.tsx`,
|
||||
* 零行为改动,只是加宽可见性)——**真实函数的行为**由
|
||||
* `packages/ui-kit/__tests__/view-container-addrow.test.ts` 直接 `import` 它们去测
|
||||
* (合成的最小 fixture,不碰任何活动的 HTML;那份文件的变异验证证明了它真的会因为
|
||||
* `ViewContainer.tsx` 里选错选择器而变红)。**这份文件依然不 `import` 那两个函数**——
|
||||
* 不是 depcruise 拦着(`packages-are-leaves` 只禁 `packages → apps` 这一个方向,没禁
|
||||
* `apps → packages`),是**类型系统**拦着:`ViewContainer.tsx` 是 `.tsx`(含 JSX),
|
||||
* 编译它要 `"jsx": "react-jsx"` 这个 compiler option,而 `apps/api/**` 只受
|
||||
* `tsconfig.node.json` 管——那份配置没有 `jsx` 选项(它是 Node 后端,本不该解析 JSX)。
|
||||
* 所以这里仍然用**真实的 `renderView()`**(与 report.ts 的 `CHIP_TYPE_TABLE`/
|
||||
* `TYPE_PARAM_TABLE` 结构逐字节对齐的镜像 fixture,见下面 `CHIP_TYPE_FRAGMENT`/
|
||||
* `TYPE_PARAM_FRAGMENT` 头注)跑出「服务端会真的吐出什么 HTML」,再在 jsdom 里镜像
|
||||
* `bodyOf`/`addRow` 那两行选择器(标了对应的源码行号,与 `view-container-addrow.test.ts`
|
||||
* 里那份文本上完全一致)去读它——这里测的是**渲染结果的形状**,选择器路径本身的正确性已经
|
||||
* 交给上面那份文件去守,这里不重复承担那份责任。
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* apps/api/** 只受 tsconfig.node.json 管(lib: ES2023,无 DOM——它是 Node 后端,本不该有
|
||||
* DOM 全局)。这份测试需要真实 DOM(document/HTMLElement)去挂载 renderView() 的输出、
|
||||
* 复刻 bodyOf()/addRow() 的选择器链——挪去 packages/ui-kit(有 DOM lib)又会撞
|
||||
* packages-are-leaves(packages/* 不许反向 import apps/*),renderView 只活在 apps/api
|
||||
* 里。两难之下用 TS 标准机制解:per-file 的三斜线 lib 引用只给这一份文件额外挂上 DOM 的
|
||||
* 环境类型,不改 tsconfig.node.json 本身(该配置对 apps/api 其余所有文件原样零 DOM)。
|
||||
*/
|
||||
/// <reference lib="dom" />
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderView } from '../internal/domain/view-render.ts';
|
||||
|
||||
/**
|
||||
* 复刻 `ViewContainer.bodyOf()` + `addRow()` 的选择器链(逐行对照
|
||||
* `packages/ui-kit/src/ViewContainer.tsx:164-167` 与 `:303-306`)——两函数现在已经
|
||||
* `export` 了,但这份文件仍不 `import` 它们:`ViewContainer.tsx` 是 `.tsx`(含 JSX),
|
||||
* 编译要 `"jsx": "react-jsx"`,而 `apps/api/**` 只受 `tsconfig.node.json` 管、没有这个
|
||||
* compiler option——类型系统这一关过不去,不是 depcruise 拦着。真实函数的行为已经交给
|
||||
* `packages/ui-kit/__tests__/view-container-addrow.test.ts` 去直接 import 测了,这里
|
||||
* 只镜像**选择器本身**(与那份文件里的写法逐字一致),在真实 jsdom Document 上跑,
|
||||
* 用来验证 `renderView()` 吐出来的 HTML 形状。
|
||||
*/
|
||||
function findAddRowTarget(doc: Document, address: string): HTMLElement | null {
|
||||
// 镜像 bodyOf():单个 querySelector,按文档序返回第一个匹配
|
||||
const host = doc.querySelector<HTMLElement>(`[data-edit="${address}"]`);
|
||||
if (host === null) return null;
|
||||
// 镜像 addRow() 的取「克隆样本」那一步:host 内找 tr:last-of-type
|
||||
return host.querySelector<HTMLElement>('tr:last-of-type');
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 `report.ts` 的 `CHIP_TYPE_TABLE` 逐字节对齐的镜像(4 列:# / 编号 / 类型名称 / 类型说明;
|
||||
* `type_id` 只读不挂 `data-col`——自增列不问人;种子行两格 `data-col`:type_name / description)。
|
||||
* `thead` 排在两个 `tbody` **之后**——这是本测试要钉死的结构。
|
||||
*/
|
||||
const CHIP_TYPE_FRAGMENT = `<div class="jc-tbl-wrap"><table data-edit="parts.chip_type">
|
||||
<tbody data-rows="mine:parts.chip_type" data-edit="parts.chip_type"><tr>
|
||||
<td class="rn"><span class="jc-rn-num"></span></td>
|
||||
<td><span class="jc-vcell ro mono" data-slot="type_id" data-blank></span></td>
|
||||
<td><span class="jc-vcell" data-slot="type_name" data-col="type_name" data-blank></span></td>
|
||||
<td><span class="jc-vcell" data-slot="description" data-col="description" data-blank></span></td>
|
||||
</tr></tbody>
|
||||
<tbody><tr class="jc-row-static">
|
||||
<td class="rn"><span class="jc-rn-num"></span></td>
|
||||
<td><span class="jc-vcell ro">系统生成</span></td>
|
||||
<td><input class="jc-cell" data-col="type_name" placeholder="新类型名称"></td>
|
||||
<td><input class="jc-cell" data-col="description" placeholder="这个类型是什么(供选型参考)"></td>
|
||||
</tr></tbody>
|
||||
<thead><tr>
|
||||
<th style="width:38px">#</th>
|
||||
<th data-h="类型编号">编号</th>
|
||||
<th class="req" data-h="类型名称">类型名称</th>
|
||||
<th data-h="类型说明">类型说明</th>
|
||||
</tr></thead>
|
||||
</table></div>`;
|
||||
|
||||
/**
|
||||
* 与 `report.ts` 的 `TYPE_PARAM_TABLE` 逐字节对齐的镜像(6 列;`type_id` 是下拉
|
||||
* `data-options`,不是裸文本 —— Task 7b 的另一条修改)。同样把 `thead` 挪到两个 `tbody` 之后。
|
||||
*/
|
||||
const TYPE_PARAM_FRAGMENT = `<div class="jc-tbl-wrap"><table data-edit="parts.type_param">
|
||||
<tbody data-rows="mine:parts.type_param" data-edit="parts.type_param"><tr>
|
||||
<td class="rn"><span class="jc-rn-num"></span><button class="jc-rowdel" type="button" data-edit-del="parts.type_param">×</button></td>
|
||||
<td>
|
||||
<span class="jc-vcell"><span data-rows="parts.chip_type" data-by="type_id=type_id"><i data-slot="type_name" data-blank></i></span></span>
|
||||
<select class="jc-cell" data-col="type_id" required data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>
|
||||
</td>
|
||||
<td><span class="jc-vcell" data-slot="param_name" data-col="param_name" data-blank></span></td>
|
||||
<td><span class="jc-vcell" data-slot="param_desc" data-col="param_desc" data-blank></span></td>
|
||||
<td><select class="jc-cell" data-col="constraint_type"><option value=""></option><option value="hard">硬约束</option><option value="soft">软约束</option></select></td>
|
||||
<td><span class="jc-vcell" data-slot="param_unit" data-col="param_unit" data-blank></span></td>
|
||||
</tr></tbody>
|
||||
<tbody><tr class="jc-row-static">
|
||||
<td class="rn"><span class="jc-rn-num"></span></td>
|
||||
<td><select class="jc-cell" data-col="type_id" required data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></td>
|
||||
<td><input class="jc-cell" data-col="param_name" placeholder="参数名"></td>
|
||||
<td><input class="jc-cell" data-col="param_desc" placeholder="这个参数为什么要紧"></td>
|
||||
<td><select class="jc-cell" data-col="constraint_type"><option value=""></option><option value="hard">硬约束</option><option value="soft">软约束</option></select></td>
|
||||
<td><input class="jc-cell" data-col="param_unit" placeholder="单位(选填)"></td>
|
||||
</tr></tbody>
|
||||
<thead><tr>
|
||||
<th style="width:38px">#</th>
|
||||
<th class="req" data-h="所属类型">所属类型</th>
|
||||
<th class="req" data-h="参数名">参数名</th>
|
||||
<th class="req" data-h="参数说明">参数说明</th>
|
||||
<th class="req" data-h="约束类型">约束(硬/软)</th>
|
||||
<th data-h="参考单位">参考单位</th>
|
||||
</tr></thead>
|
||||
</table></div>`;
|
||||
|
||||
describe('parts.chip_type(零行时):「加一行」必须落在种子行上,不是表头', () => {
|
||||
it('renderView() 真实输出,零草稿行 —— 动态 tbody 空,种子行还在', () => {
|
||||
const html = renderView(CHIP_TYPE_FRAGMENT, { 'mine:parts.chip_type': [] });
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
doc.body.innerHTML = html;
|
||||
|
||||
// renderRows() 消费掉 data-rows 属性(只留 data-edit),动态 tbody 按位置认(文档序第一个 <tbody>)
|
||||
const dynamicTbody = doc.querySelectorAll('tbody')[0];
|
||||
expect(dynamicTbody?.querySelectorAll('tr').length).toBe(0);
|
||||
expect(doc.querySelectorAll('tr.jc-row-static')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('走一遍 bodyOf()/addRow() 的真实选择器路径:克隆样本落在 tbody 里,不是 thead', () => {
|
||||
const html = renderView(CHIP_TYPE_FRAGMENT, { 'mine:parts.chip_type': [] });
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
doc.body.innerHTML = html;
|
||||
|
||||
const target = findAddRowTarget(doc, 'parts.chip_type');
|
||||
expect(target).not.toBeNull();
|
||||
// 命中的必须是 <tbody> 的直接子行 —— 不是 <thead> 那一行
|
||||
expect(target?.parentElement?.tagName).toBe('TBODY');
|
||||
expect(target?.closest('thead')).toBeNull();
|
||||
// 且它就是那条种子行(class 对得上)
|
||||
expect(target?.classList.contains('jc-row-static')).toBe(true);
|
||||
// 含 2 个 input[data-col](type_name / description),不是 0 个(表头行会是 0)
|
||||
const inputs = Array.from(target?.querySelectorAll('input[data-col]') ?? []);
|
||||
expect(inputs.map((i) => i.getAttribute('data-col')).sort()).toEqual(['description', 'type_name']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parts.chip_type(已有 1 行时):「加一行」克隆的是那条真数据行', () => {
|
||||
it('走一遍选择器路径:命中动态 tbody 的最后一条真实数据行,不是种子行也不是表头', () => {
|
||||
const html = renderView(CHIP_TYPE_FRAGMENT, {
|
||||
'mine:parts.chip_type': [{ type_id: '1', type_name: 'DC-DC 降压稳压器', description: '开关式降压转换器' }],
|
||||
});
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
doc.body.innerHTML = html;
|
||||
|
||||
const target = findAddRowTarget(doc, 'parts.chip_type');
|
||||
expect(target).not.toBeNull();
|
||||
// renderRows() 消费掉 data-rows 属性 —— 认「不是种子行、且是文档序第一个 tbody(动态那个)的行」
|
||||
expect(target?.parentElement).toBe(doc.querySelectorAll('tbody')[0]);
|
||||
expect(target?.classList.contains('jc-row-static')).toBe(false);
|
||||
expect(target?.closest('thead')).toBeNull();
|
||||
// 这一行真的带着那条料的值(证明不是表头、不是种子行)
|
||||
expect(target?.textContent).toContain('DC-DC 降压稳压器');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parts.type_param(零行时):同款结构,同样要验', () => {
|
||||
it('renderView() 真实输出,零草稿行 —— 动态 tbody 空,种子行还在', () => {
|
||||
const html = renderView(TYPE_PARAM_FRAGMENT, { 'mine:parts.type_param': [] });
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
doc.body.innerHTML = html;
|
||||
|
||||
// renderRows() 消费掉 data-rows 属性(只留 data-edit),动态 tbody 按位置认(文档序第一个 <tbody>)
|
||||
const dynamicTbody = doc.querySelectorAll('tbody')[0];
|
||||
expect(dynamicTbody?.querySelectorAll('tr').length).toBe(0);
|
||||
expect(doc.querySelectorAll('tr.jc-row-static')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('走一遍选择器路径:克隆样本落在 tbody 里、6 个可填格子都在,不是表头(表头会是 0 个)', () => {
|
||||
const html = renderView(TYPE_PARAM_FRAGMENT, { 'mine:parts.type_param': [] });
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
doc.body.innerHTML = html;
|
||||
|
||||
const target = findAddRowTarget(doc, 'parts.type_param');
|
||||
expect(target).not.toBeNull();
|
||||
expect(target?.parentElement?.tagName).toBe('TBODY');
|
||||
expect(target?.closest('thead')).toBeNull();
|
||||
expect(target?.classList.contains('jc-row-static')).toBe(true);
|
||||
const cols = Array.from(target?.querySelectorAll('input[data-col], select[data-col]') ?? [])
|
||||
.map((i) => i.getAttribute('data-col'))
|
||||
.sort();
|
||||
expect(cols).toEqual(['constraint_type', 'param_desc', 'param_name', 'param_unit', 'type_id']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **这是 Task 7b 修复之前的真实回归**(评审逮到:「parts.type_param 那张更要紧:它的
|
||||
* 加行功能在你改动之前是好的」)——`data-edit` 只挂在内层 `<tbody data-rows>` 上(不挂
|
||||
* 外层 `<table>`),`thead` 排在最前面(改回归修复前的原始顺序)。这条测试**不是**用来
|
||||
* 证明当前代码正确——它证明的是「如果 `data-edit` 回到只挂 `<table>` 且 `thead` 排在最前」
|
||||
* 这个错误形状,选择器确实会命中表头(0 个 `input[data-col]`)。留着当变异验证的可读记录:
|
||||
* 不必真的改 `report.ts` 再改回来,这条测试本身就是「变了会怎样」的固化证据。
|
||||
*/
|
||||
describe('回归记录:data-edit 挂外层 table 且 thead 排最前 —— 这个错误形状会命中表头', () => {
|
||||
it('(此形状已不出现在 report.ts 里,这里独立复现,证明它确实是错的)', () => {
|
||||
const brokenFragment = `<table data-edit="parts.chip_type">
|
||||
<thead><tr><th>#</th><th>编号</th><th>类型名称</th><th>类型说明</th></tr></thead>
|
||||
<tbody data-rows="mine:parts.chip_type" data-edit="parts.chip_type"></tbody>
|
||||
<tbody><tr class="jc-row-static"><td class="rn"></td><td><span class="jc-vcell ro">系统生成</span></td><td><input class="jc-cell" data-col="type_name"></td><td><input class="jc-cell" data-col="description"></td></tr></tbody>
|
||||
</table>`;
|
||||
const html = renderView(brokenFragment, { 'mine:parts.chip_type': [] });
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
doc.body.innerHTML = html;
|
||||
|
||||
const target = findAddRowTarget(doc, 'parts.chip_type');
|
||||
expect(target).not.toBeNull();
|
||||
// 命中的是表头那一行:落在 thead 里,零个 input[data-col]
|
||||
expect(target?.closest('thead')).not.toBeNull();
|
||||
expect(target?.querySelectorAll('input[data-col]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* **可编辑区域套两层 `data-rows` 时,渲染器真产出什么** —— W05 数据工单版面探针
|
||||
* (2026-08-30,「核心参数值」想做成"一个候选一个 section、section 里铺参数瓷贴"这形状,
|
||||
* 先问渲染管线真产出什么,再问 `collect()` 拿它怎么办)。
|
||||
*
|
||||
* `collect()` 住在 `packages/ui-kit`,这份文件住在 `apps/api`——`ui-kit` 是 tsconfig.web.json
|
||||
* 那一支(`jsx: react-jsx` + DOM lib),`apps/api` 归 tsconfig.node.json(两者都没有对方要的
|
||||
* 编译选项);`packages-are-leaves` 也不许 `packages/*` 反向 import `apps/*`。两侧各钉一条、
|
||||
* 中间用**同一段渲染出来的 HTML 字符串**接力,防止两份夹具各写各的、悄悄漂移。
|
||||
*
|
||||
* **这一条只钉 `renderView` 的真实输出**——collect() 拿这段 HTML 怎么归行,
|
||||
* 见 `packages/ui-kit/__tests__/view-container-collect-nested.test.ts`
|
||||
* (把下面 `NESTED_RENDERED` 这段字符串原样复制过去当夹具)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderView } from '../internal/domain/view-render.ts';
|
||||
|
||||
/**
|
||||
* 外层 `data-edit="parts.parameter"` 与外层 `data-rows="mine:sub_eval.part"` 共处一个标签
|
||||
* (照仓里既有的可编辑表格惯例,如 `<tbody data-rows="..." data-edit="...">`)——
|
||||
* 每颗候选料复制一个 `<section>`;section 里再嵌一层 `data-rows="mine:parts.parameter"
|
||||
* data-by="internal_pn=internal_pn"` 铺这颗料自己的参数瓷贴,每瓷贴三格
|
||||
* (`param_key` / `param_value` / 主键 `internal_pn`)。
|
||||
*
|
||||
* 行容器标签特意避开重名(`article` 外层、`div` 内层、瓷贴用 `span`)——
|
||||
* `data-rows` 的正则按标签名非贪婪找**同名**闭合标签,内层若也叫 `article`
|
||||
* 会被外层第一个提前撞见的闭合标签截断(`view-render.test.ts` 379 行那条注释记过同一个坑)。
|
||||
*/
|
||||
const NESTED_TPL =
|
||||
'<article data-edit="parts.parameter" data-rows="mine:sub_eval.part">' +
|
||||
'<section>' +
|
||||
'<h3 data-slot="internal_pn"></h3>' +
|
||||
'<div data-rows="mine:parts.parameter" data-by="internal_pn=internal_pn">' +
|
||||
'<span class="pv">' +
|
||||
'<input data-col="param_key" data-slot="param_key" data-value/>' +
|
||||
'<input data-col="param_value" data-slot="param_value" data-value/>' +
|
||||
'<input data-col="internal_pn" data-slot="internal_pn" data-value/>' +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'</section>' +
|
||||
'</article>';
|
||||
|
||||
const NESTED_SCOPE = {
|
||||
'mine:sub_eval.part': [{ internal_pn: 'LM2596S-5.0' }, { internal_pn: 'XL2596S-5.0' }],
|
||||
'mine:parts.parameter': [
|
||||
{ internal_pn: 'LM2596S-5.0', param_key: 'input_voltage', param_value: '4.5~40' },
|
||||
{ internal_pn: 'LM2596S-5.0', param_key: 'output_voltage', param_value: '5' },
|
||||
{ internal_pn: 'LM2596S-5.0', param_key: 'enable_threshold', param_value: '2.5' },
|
||||
{ internal_pn: 'XL2596S-5.0', param_key: 'input_voltage', param_value: '4.5~40' },
|
||||
{ internal_pn: 'XL2596S-5.0', param_key: 'output_voltage', param_value: '5' },
|
||||
{ internal_pn: 'XL2596S-5.0', param_key: 'enable_threshold', param_value: '3.0' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* `renderView` 真实产出的逐字 HTML —— 与下面第一条用例的断言必须**逐字节一致**。
|
||||
* 这段字符串原样复制进 `view-container-collect-nested.test.ts` 当夹具(注释互相点名)。
|
||||
*/
|
||||
export const NESTED_RENDERED =
|
||||
'<article data-edit="parts.parameter"><section><h3>LM2596S-5.0</h3><div>' +
|
||||
'<span class="pv"><input data-col="param_key" value="input_voltage"/>' +
|
||||
'<input data-col="param_value" value="4.5~40"/>' +
|
||||
'<input data-col="internal_pn" value="LM2596S-5.0"/></span>' +
|
||||
'<span class="pv"><input data-col="param_key" value="output_voltage"/>' +
|
||||
'<input data-col="param_value" value="5"/>' +
|
||||
'<input data-col="internal_pn" value="LM2596S-5.0"/></span>' +
|
||||
'<span class="pv"><input data-col="param_key" value="enable_threshold"/>' +
|
||||
'<input data-col="param_value" value="2.5"/>' +
|
||||
'<input data-col="internal_pn" value="LM2596S-5.0"/></span>' +
|
||||
'</div></section><section><h3>XL2596S-5.0</h3><div>' +
|
||||
'<span class="pv"><input data-col="param_key" value="input_voltage"/>' +
|
||||
'<input data-col="param_value" value="4.5~40"/>' +
|
||||
'<input data-col="internal_pn" value="XL2596S-5.0"/></span>' +
|
||||
'<span class="pv"><input data-col="param_key" value="output_voltage"/>' +
|
||||
'<input data-col="param_value" value="5"/>' +
|
||||
'<input data-col="internal_pn" value="XL2596S-5.0"/></span>' +
|
||||
'<span class="pv"><input data-col="param_key" value="enable_threshold"/>' +
|
||||
'<input data-col="param_value" value="3.0"/>' +
|
||||
'<input data-col="internal_pn" value="XL2596S-5.0"/></span>' +
|
||||
'</div></section></article>';
|
||||
|
||||
describe('renderView:外层 data-edit+data-rows 共处一个标签,内层 data-rows+data-by 铺瓷贴', () => {
|
||||
it('产出两个 <section>(host = <article> 的直接子元素),各自嵌一层 <div> 才到瓷贴 —— 逐字节核对', () => {
|
||||
const out = renderView(NESTED_TPL, NESTED_SCOPE);
|
||||
expect(out).toBe(NESTED_RENDERED);
|
||||
});
|
||||
|
||||
it('变异验证:换成 3 颗候选料,输出的 <section> 数跟着变成 3 —— 证明上面的断言真的在读结构,不是凑巧写对了字符串', () => {
|
||||
const out = renderView(NESTED_TPL, {
|
||||
'mine:sub_eval.part': [{ internal_pn: 'A' }, { internal_pn: 'B' }, { internal_pn: 'C' }],
|
||||
'mine:parts.parameter': [
|
||||
{ internal_pn: 'A', param_key: 'k1', param_value: 'v1' },
|
||||
{ internal_pn: 'B', param_key: 'k1', param_value: 'v1' },
|
||||
{ internal_pn: 'C', param_key: 'k1', param_value: 'v1' },
|
||||
],
|
||||
});
|
||||
expect(out.split('<section>')).toHaveLength(4); // split 出 [头, s1, s2, s3]
|
||||
});
|
||||
});
|
||||
@@ -434,4 +434,111 @@ describe('嵌套行模版与 data-by —— 一个候选一张表', () => {
|
||||
const all = renderView(html.replace(' data-by="rel_id"', ''), scope);
|
||||
expect(all.split('使能逻辑').length - 1).toBe(2);
|
||||
});
|
||||
|
||||
/*
|
||||
* **已知问题,尚未修 —— 2026-08-30(ACT-WORK-05 task-12,缺陷①,浏览器真人走查逮到)**。
|
||||
* 现场报的是「候选料一行的芯片类型只读态显示着一个值,而那一格实际是空的,编辑态与 SQL
|
||||
* 双重核实过是空串」。原本怀疑是 `byParent` 对空父键的处理退化成显示第一行 —— 查下来这
|
||||
* 个猜测是错的:上面「父值缺席一行不留」那条已经钉死了"这一行自己把父键显式写成空字符串"
|
||||
* 这个场景(`rel_id: ''`),`byParent` 的 `parent === ''` 守卫恒定正确,不是这次的病根。
|
||||
*
|
||||
* 真正能复现的是**另一种缺席**:这一行压根**没有**这个字段(不是空字符串,是键都不存在)——
|
||||
* 常见于必填但还没填的草稿行(如 ACT-WORK-05 的 input-supplement 件:候选料还没定型号时,
|
||||
* `sub_eval.part` 那一行不会带 `chip_type_id` 这个键)。`renderRows` 递归处理嵌套
|
||||
* `data-rows` 时用 `{ ...scope, ...one }` 合并外层与本行 —— `one` 缺席的键会**悄悄落回
|
||||
* 外层 scope 里同名的字段**。而外层 scope 常常真的有一个同名字段:`engines.ts` 的
|
||||
* `taskData.view()` 会把「各对象最新一行的字段名」铺到顶层(`task.data`),
|
||||
* `part-substitution-assessment` 的 `case-model.ts` 就因为撞过这同一个坑,自己的 `auto`
|
||||
* 配方特地避免用 `{in:'chip_type_id'}`(该文件 134 行注释:「这个名字在三张表上都有,
|
||||
* 顶层同名以后写的赢」)—— 但那条护栏只保护了 case-model 自己的 S-FEEL 求值,没有覆盖到
|
||||
* 这里的 `data-by` 嵌套查表,于是同一类撞名在 `view-render.ts` 这一侧原样重演。
|
||||
*
|
||||
* 用 `it.fails`:这条断言此刻确实会失败(已用最小复现核对过,不是这条测试自己写错)——
|
||||
* 先把复现钉进套件,不在这一轮改 `view-render.ts`(共享面,E04/I01 也用 `data-by`,
|
||||
* 改动需要先报给设计者裁定修法);等修完翻正、去掉 `.fails`,它就是永久回归测试。
|
||||
*/
|
||||
it.fails('父键在本行里压根缺席(不是空字符串)时,不该被外层作用域里恰好同名的字段顶替', () => {
|
||||
const withNestedLookup =
|
||||
'<tbody data-rows="parts"><tr><td data-slot="pn"></td>' +
|
||||
'<span data-rows="dict" data-by="id=type_id"><i data-slot="name"></i></span>' +
|
||||
'</tr></tbody>';
|
||||
const out = renderView(withNestedLookup, {
|
||||
type_id: '5', // 页面顶层恰好有个同名字段(如 sub_eval.case.chip_type_id 铺到顶层的产物)
|
||||
parts: [{ pn: 'XL2596S-5.0' /* 这一行没有 type_id 这个键 —— 该型号还没定型 */ }],
|
||||
dict: [{ id: '5', name: 'DC-DC 降压稳压器' }],
|
||||
});
|
||||
expect(out).not.toContain('DC-DC 降压稳压器');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data-options:下拉的选项从台账现读', () => {
|
||||
const scope = {
|
||||
'parts.chip_type': [
|
||||
{ type_id: 1, type_name: 'DC-DC 降压稳压器' },
|
||||
{ type_id: 2, type_name: 'LDO 线性稳压器' },
|
||||
],
|
||||
chip_type_id: 2,
|
||||
};
|
||||
|
||||
it('把选项铺进去,并留一个空选项(「还没选」要说得出来)', () => {
|
||||
const html = '<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>';
|
||||
const out = renderView(html, scope);
|
||||
expect(out).toContain('<option value=""></option>');
|
||||
expect(out).toContain('<option value="1">DC-DC 降压稳压器</option>');
|
||||
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
|
||||
});
|
||||
|
||||
it('选项文字要转义 —— 台账里的值是人填的', () => {
|
||||
const out = renderView(
|
||||
'<select data-col="c" data-slot="c" data-options="t.t" data-option-value="v" data-option-label="l"></select>',
|
||||
{ 't.t': [{ v: 'x', l: '<b>坏</b>' }], c: '' },
|
||||
);
|
||||
expect(out).toContain('<b>坏</b>');
|
||||
expect(out).not.toContain('<b>坏</b>');
|
||||
});
|
||||
|
||||
it('地址取不到 → 只留那个空选项,不塞假数据(界面不撒谎)', () => {
|
||||
const out = renderView('<select data-col="c" data-options="nope.nope" data-option-value="v" data-option-label="l"></select>', {});
|
||||
expect(out).toContain('<option value=""></option>');
|
||||
expect(out).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('行内的下拉:每一行都铺同一份选项,选中的按各行自己的值', () => {
|
||||
const html =
|
||||
'<tbody data-rows="mine:a.b" data-edit="a.b">' +
|
||||
'<tr><select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></tr>' +
|
||||
'</tbody>';
|
||||
const out = renderView(html, { ...scope, 'mine:a.b': [{ chip_type_id: 1 }, { chip_type_id: 2 }] });
|
||||
expect(out.match(/<option value="1">DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
|
||||
expect(out.match(/<option value="1" selected>DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
/*
|
||||
* 评审逮到的渗透:「块里有没有 selected 这个词」防不住重复处理 ——
|
||||
* 一行被正确处理过、且合法地落在「没有选中项」上(值不在选项里 / 这一格还没填),
|
||||
* 跟「压根没被处理过」在字面上是同一个样子:都没有 selected 这个词。
|
||||
* `renderView` 收尾那一遍拿的是外层作用域,这里的外层 `chip_type_id: 2` 恰好命中
|
||||
* 选项列表里的 LDO —— 若防重失灵,它会被错误地渗进这一行,而这一行自己的数据
|
||||
* 说的明明是「没有选中」。
|
||||
*/
|
||||
const rowHtml =
|
||||
'<tbody data-rows="mine:a.b" data-edit="a.b">' +
|
||||
'<tr><select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></tr>' +
|
||||
'</tbody>';
|
||||
|
||||
it('行内下拉:该行的值不在选项里 —— 不许被外层同名的值渗进来标 selected', () => {
|
||||
const out = renderView(rowHtml, { ...scope, 'mine:a.b': [{ chip_type_id: 99 }] });
|
||||
expect(out).not.toContain('selected');
|
||||
});
|
||||
|
||||
it('行内下拉:新加的空行(这一列还没填)—— 同样不许被外层同名的值渗进来', () => {
|
||||
const out = renderView(rowHtml, { ...scope, 'mine:a.b': [{}] });
|
||||
expect(out).not.toContain('selected');
|
||||
});
|
||||
|
||||
it('行外的独立下拉仍按顶层作用域正确标好 selected(防重不能把这条也防没了)', () => {
|
||||
const html = '<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>';
|
||||
const out = renderView(html, scope);
|
||||
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ export {
|
||||
outputControl,
|
||||
templateIsSafe,
|
||||
} from './internal/domain/control-handover.ts';
|
||||
export { renderView, renderTemplate, renderRows, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
||||
export { renderView, renderTemplate, renderRows, fillOptions, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
||||
|
||||
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
||||
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
||||
|
||||
@@ -272,6 +272,28 @@ function twinInputs(inner: string, rowIndex: number): string {
|
||||
/** 手写的下拉孪生件:`<select … data-col="depth">…</select>` */
|
||||
const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/select>/g;
|
||||
|
||||
/**
|
||||
* **收尾防重的记号**(2026-08-29,评审逮到:「有没有 `selected` 这个词」防不住重复处理)。
|
||||
*
|
||||
* `renderRows` 逐行展开时用**这一行自己的作用域**先调一遍 `selectCurrent`;`renderView`
|
||||
* 收尾时还会再拿**外层作用域**兜底调一遍,专为处理那些根本不在任何 `data-rows` 里的下拉
|
||||
* (见 `selectCurrent` 的文档注释)。两次谁先谁后要能分清「这块处理过没有」——
|
||||
* **不能拿「块里有没有 `selected` 这个词」当代理**:一行被正确处理过、且合法地落在
|
||||
* 「没有选中项」上(这一行的值不在选项里,或这一格还没填),跟「压根没被处理过」在字面上
|
||||
* 是同一个样子,都没有这个词。若外层作用域里恰好有一个同名的裸键、且它的值命中了某个选项
|
||||
* (「行内 `chip_type_id` 与外层同名」这种情况在这仓的用法下并不罕见),收尾那一遍会把它
|
||||
* **错误地** 渗进这一行 —— 界面撒了谎:下拉显示一个这一行的数据里根本不存在的选中态,
|
||||
* 值是从一个无关字段借来的。这个词也可能出现在 `<option>` 自己的**显示文字**里
|
||||
* (枚举标签写成「Selected Value」照样会误判)。
|
||||
*
|
||||
* 所以改记一个**显式的、跟内容无关的标记属性**:处理过(不管有没有真标出选中项)就打上,
|
||||
* 下一遍见到标记就跳过。它是渲染期的内部记号,不该出现在交给沙箱的成品里 ——
|
||||
* `renderView` 收尾前把它整个剥掉(`stripSelScoped`)。
|
||||
*/
|
||||
const SEL_SCOPED = /\bdata-sel-scoped\b/;
|
||||
/** 剥掉 `SEL_SCOPED` 标记 —— 连同它前面那个空格一起,免得成品里留一段多余空白 */
|
||||
const SEL_SCOPED_ATTR = /\s*\bdata-sel-scoped\b/g;
|
||||
|
||||
/**
|
||||
* **下拉的孪生件要带上当下的值**(2026-08-21 用户实测截图:「点了编辑后直接就变白了」)。
|
||||
*
|
||||
@@ -283,17 +305,29 @@ const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/se
|
||||
* 找到 `value` 与当下取值相等的那个 `<option>`,补一个 `selected`。**没有值就照旧留白**
|
||||
* (原样交给「没答=不改」那条规矩——这里只补「有值就该看得见」,不改「没答不算话」的语义:
|
||||
* 人如果确实没碰这一格,交出去的还是当下这个值,原样写回等于没变)。
|
||||
*
|
||||
* 处理完(不管有没有真标出 `selected`)都打上 `SEL_SCOPED` 标记 —— 已经打过标记的块原样放过,
|
||||
* 见 `SEL_SCOPED` 的文档注释。
|
||||
*/
|
||||
function selectCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return inner.replace(SELECT_CELL, (block, col: string) => {
|
||||
if (SEL_SCOPED.test(block)) return block;
|
||||
const raw = resolveSlot(scope, col);
|
||||
if (raw === undefined || raw === null || raw === '') return block;
|
||||
let marked = block;
|
||||
if (raw !== undefined && raw !== null && raw !== '') {
|
||||
const value = String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const optionRe = new RegExp(`(<option\\b[^>]*\\bvalue\\s*=\\s*"${value}"[^>]*?)(/?>)`);
|
||||
return optionRe.test(block) ? block.replace(optionRe, (_m, attrs: string, close: string) => `${attrs} selected${close}`) : block;
|
||||
if (optionRe.test(marked)) marked = marked.replace(optionRe, (_m, attrs: string, close: string) => `${attrs} selected${close}`);
|
||||
}
|
||||
return marked.replace(/^(<select\b[^>]*?)>/, (_m, open: string) => `${open} data-sel-scoped>`);
|
||||
});
|
||||
}
|
||||
|
||||
/** `renderView` 收尾前剥掉 `selectCurrent` 留下的内部记号 —— 交出去的是干净 HTML */
|
||||
function stripSelScoped(html: string): string {
|
||||
return html.replace(SEL_SCOPED_ATTR, '');
|
||||
}
|
||||
|
||||
/** 手写的多行文本孪生件:`<textarea … data-col="result_summary">…</textarea>` */
|
||||
const TEXTAREA_CELL = /<textarea\b([^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*)>[\s\S]*?<\/textarea>/g;
|
||||
|
||||
@@ -312,7 +346,59 @@ function textareaCurrent(inner: string, scope: Readonly<Record<string, unknown>>
|
||||
});
|
||||
}
|
||||
|
||||
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
|
||||
/** `<select … data-options="域.对象" data-option-value="列" data-option-label="列">` */
|
||||
const OPTIONS_SELECT = /<select\b([^>]*?)\bdata-options\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/select>/g;
|
||||
|
||||
/**
|
||||
* **下拉的选项从台账现读**(2026-08-29,spec §5.3)。
|
||||
*
|
||||
* 与 `data-rows` 同一层机制:**只取值,不判断**。地址写法也一致
|
||||
* (`域.对象` = 任务视图,`mine:域.对象` = 本单草稿)。
|
||||
*
|
||||
* 为什么必须是现读而不是写死在模版里:芯片类型是**人在件上现造**的,
|
||||
* 写死等于「只能选我出包那天就有的那几种」。
|
||||
*
|
||||
* **在行展开之前跑一遍**(`renderView` 的最内层):选项与行无关,一次铺好,
|
||||
* 行展开时整段复制进每一行;哪一个 `selected` 由 `selectCurrent` 按各行自己的值定。
|
||||
* 空选项一律留着 ——「还没选」得说得出来。
|
||||
*
|
||||
* `data-slot` 若与 `data-options` 同时出现在同一个 `<select>` 上,这里要把它摘掉:
|
||||
* `renderTemplate` 的 `SLOT` 规则认标签名不认标签种类,凡带 `data-slot` 的元素一律
|
||||
* 把**整段内容**换成取值的文本 —— `<select>` 的内容是一串 `<option>`,真被当成文本槽
|
||||
* 处理,选项会被冲成一行数字/文字(踩过一次:`<select>2</select>`)。
|
||||
*/
|
||||
export function fillOptions(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return html.replace(OPTIONS_SELECT, (_all, before: string, address: string, after: string) => {
|
||||
const raw = `${before}${after}`;
|
||||
const valueCol = /\bdata-option-value\s*=\s*"([^"]+)"/.exec(raw)?.[1] ?? 'value';
|
||||
const labelCol = /\bdata-option-label\s*=\s*"([^"]+)"/.exec(raw)?.[1] ?? valueCol;
|
||||
const attrs = raw
|
||||
.replace(/\s*\bdata-option-value\s*=\s*"[^"]*"/, '')
|
||||
.replace(/\s*\bdata-option-label\s*=\s*"[^"]*"/, '')
|
||||
.replace(/\s*\bdata-slot\s*=\s*"[^"]*"/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const rows = resolveSlot(scope, address);
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
const options = list
|
||||
.map((row) => {
|
||||
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
|
||||
const value = escapeHtml(displayValue(one[valueCol]));
|
||||
const label = escapeHtml(displayValue(one[labelCol]));
|
||||
return `<option value="${value}">${label}</option>`;
|
||||
})
|
||||
.join('');
|
||||
return `<select${attrs === '' ? '' : ` ${attrs}`}><option value=""></option>${options}</select>`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 先铺下拉选项(行展开之前,一次铺好)→ 展开行(行内的下拉照各行自己的值定 `selected`)
|
||||
* → **收尾再兜底调一遍 `selectCurrent`**(那些压根不在任何 `data-rows` 里的下拉,
|
||||
* 没有「行」这一步替它调,靠这里用外层作用域补上;已经被行内那一遍定过的原样放过,
|
||||
* 见 `SEL_SCOPED` 的文档注释)→ 填标量槽 → **剥掉 `selectCurrent` 留下的内部记号**
|
||||
* (`stripSelScoped`,渲染期的判重记号,不该出现在交给沙箱的成品里)。顺序不能反。
|
||||
*/
|
||||
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return renderTemplate(renderRows(html, scope), scope);
|
||||
return stripSelScoped(renderTemplate(selectCurrent(renderRows(fillOptions(html, scope), scope), scope), scope));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- 错误:`CmmnParseError` `IllegalTransitionError` `ForbiddenTerminalError`
|
||||
`AutomaticOnWorldTouchingError`
|
||||
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway`(含 `writable` 授权面)`AgentPort` `ToolGateway`
|
||||
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext`
|
||||
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext` `AnalyzeGateway`
|
||||
- 用例工厂:`createTurnRunner(deps): LoopEngineService`
|
||||
- 纯函数:`parseCmmn` `parseXml` `publishEnabled` `navigate` `evaluateCriterion`
|
||||
`transitionItem` `occur` `achieveMilestones` `reenter` `autoCompleteReady` `canTransitionItem`
|
||||
@@ -67,8 +67,20 @@
|
||||
(剧本两处写死「先 finish 再发下一张」,模型照样忘,两步同时 Active、件停在上一步)。
|
||||
拍首扫一遍,**拍尾再扫一遍**(人答卡那一拍里模型写完东西 doneWhen 才真;这一拍刚发卡的项排除;
|
||||
提交放行那一拍不扫,免得把在途验收信封擦掉)。没声明 doneWhen 的人工激活项一个字不变。
|
||||
- **人填的表模型不许写**(2026-08-28):剧本 `PlanStep.humanWrites` 声明的台账地址,`write` 当场拒并指路
|
||||
「去发那张数据工单,值由人填」;`drop` 不拦(写错了得撤得掉)。判法认行动点名的项,点不出退回当下在办的那一步。
|
||||
起因:P-10 资料记录那三格只可能从人那儿来,而模型把台账里**原 PN 那一行**的规格书逐字抄到了候选料号头上 ——
|
||||
行找对了、料找错了,这份假证据会喂进差异比对当「硬约束有证据支持」。**只声明「模型永远没有来源」的表**。
|
||||
- **还欠一张卡的那一步,提示词不摆 `doneWhen`**(同日):判据同时也是**摆在模型眼前的一个目标**,后面还跟着
|
||||
「成立了就 finish」—— 而人工激活的项在卡发出去之前根本不许 finish。同一份提示词里两句话打架,实测它选了判据那句、
|
||||
去凑数据,然后 finish 连撞五拍墙熔断。卡发出去、人答过之后判据照常摆。
|
||||
- **画面跟着这一拍发出去的卡走**(同日):human / entry / decide 把「卡是哪一步的」交回拍内,
|
||||
那一步声明了画面就推它,压过「第一步没干完的」—— 卡是发给人的那件事,件必须是那件事的件。
|
||||
- **auto 步不问模型**(2026-08-29,`turn-runner` 拍首接线 `createAutoRunner`):`PlanStep.auto` 存在时,
|
||||
拍首先跑配方;发了卡 / submit 就收拍(零 token)。有 `guide` 的照常问模型(半 auto);
|
||||
两者都没有且 `doneWhen` 不成立 → 交人(配方没做到位,是包的 bug)。
|
||||
- **有未答的卡在场时,配方只跑 `project` / `say`** —— 只算、只写,不发新卡、不 submit、不调 AI。
|
||||
- **配方每一笔之前刷新任务视图**:后一笔常要数前一笔刚写下的行,不刷新就恒为 0。
|
||||
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
|
||||
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
|
||||
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
|
||||
@@ -152,3 +164,26 @@
|
||||
决策卡的回答进基座读数 `decision_last` / `decision_<值>_total`(按 card-reply 数)—— 审核通过与否不再由模型自写的旗说了算;
|
||||
拒回 / 观察 / 事实行按答案分岔(`chosenGuidance`):`custom:` 不是通过,不许写通过旗、不许 finish。
|
||||
|
||||
近变(2026-08-29,终审修复波):`TaskDataGateway` 两处扩面,都是**配方专用**,模型那一路一个字不变 ——
|
||||
|
||||
- `draftWrite` 多两个可选入参与一个可选出参:
|
||||
- `skipUnchanged` —— 值没变就**不落笔**。`DraftStore.put` 的 upsert 尾巴上有 `landed_at = null`
|
||||
(「再写一笔就不再是已落地」),而配方每一拍都把同一张表重投一遍:照写会把数据工单确认入库的那些行
|
||||
**悄悄 un-land**,整单放行时再写一遍正式表、再抛一遍 data 事件 —— 而对 landed 行的承诺是
|
||||
「整单放行不再写它、不抛事件」。模型不传这个旗:它的「空转写」要被看见(如实回 `changed:false`)。
|
||||
- `skipExisting` —— 「只建不改」(`createOnly`)的表碰到已在正式表里的键,回 `{changed:false, skipped:true}`
|
||||
而不是抛。那句拒回是**写给模型看的**(「别再写它,用 drop 把骨架撤掉」),摆到人面前毫无意义;
|
||||
而配方抛出去就是一单永久停在那儿。对配方来说「这颗料已经在库了」是常态,不是错。
|
||||
跳过的行照旧算「这一笔要的行」,`prune` 不许撤它。
|
||||
- `writable` 多一个可选 `caseKey`(单标识列):摆给人看的行标识要**去掉单号那一段**
|
||||
(2026-08-25 那条口径,基座那一侧是 `rowLabel`)—— 人正在这一单里看,单号是脚下的地面。
|
||||
|
||||
同批三条不变量:
|
||||
- **配方由 `liveStep` 驱动,不由 `current` 的兜底驱动**:兜底(在场的第一个有剧本的项)不过滤
|
||||
`settledIds`,它只服务提示词;让它驱动副作用 = 已收掉的步再写一遍台账、再 prune 一遍、甚至再发一张卡。
|
||||
- **配方的 `submit` 与模型的 submit 同一条处置**:`raiseAcceptance` 抛了不许让回合崩(留痕 + `running`)——
|
||||
裸着调的话,`engine.advance` / `task-service.tick` / `task-advancer` 一路都不捕获,任务留在 `running`,
|
||||
推进器无限热循环:不烧预算、不换状态、界面纹丝不动。重做闸的「改过台账」也认配方的写
|
||||
(`observation` + `detail.auto` + `detail.ledger`),否则那堵墙对配方永远开不了。
|
||||
- **配方办不下去 = 发一张卡交人**,不是 `awaiting-event` 干等:推进器只捡 `running` 的单,
|
||||
卡已答完、没立监听、没有时限时再没有任何东西会叫醒它,而界面写着「等外部回音」。
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { evalExpr, evalText, rowsAt } from '../internal/domain/auto-expr.ts';
|
||||
|
||||
/** 假的 S-FEEL:只认这份测试用得到的两句,够验证「条件走的是注入进来的求值器」 */
|
||||
const condition = (expr: string, scope: Readonly<Record<string, unknown>>): boolean => {
|
||||
if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== '';
|
||||
if (expr === 'n > 0') return Number(scope['n'] ?? 0) > 0;
|
||||
throw new Error(`没教过这句:${expr}`);
|
||||
};
|
||||
const base = { scope: {}, lets: {}, condition };
|
||||
|
||||
describe('rowsAt:地址取行', () => {
|
||||
const scope = {
|
||||
'parts.profile': [{ internal_pn: 'A', chip_type_id: 1 }],
|
||||
'mine:sub_eval.part': [{ internal_pn: 'B' }],
|
||||
pns: ['X', 'Y'],
|
||||
'result:items': [{ param_name: 'p' }],
|
||||
};
|
||||
it('域.对象 取任务视图整表', () => {
|
||||
expect(rowsAt(scope, 'parts.profile')).toEqual([{ internal_pn: 'A', chip_type_id: 1 }]);
|
||||
});
|
||||
it('mine: 只取本单草稿', () => {
|
||||
expect(rowsAt(scope, 'mine:sub_eval.part')).toEqual([{ internal_pn: 'B' }]);
|
||||
});
|
||||
it('in: 取入参数组;字符串元素归一成 { value }', () => {
|
||||
expect(rowsAt(scope, 'in:pns')).toEqual([{ value: 'X' }, { value: 'Y' }]);
|
||||
});
|
||||
it('result: 取本拍 AI 出参', () => {
|
||||
expect(rowsAt(scope, 'result:items')).toEqual([{ param_name: 'p' }]);
|
||||
});
|
||||
it('取不到就是空数组,不抛 —— 首拍很多表本来就还没有行', () => {
|
||||
expect(rowsAt(scope, 'nope.nope')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evalExpr:八种形状', () => {
|
||||
it('常量原样', () => {
|
||||
expect(evalExpr('W05', base)).toBe('W05');
|
||||
expect(evalExpr(7, base)).toBe(7);
|
||||
expect(evalExpr(true, base)).toBe(true);
|
||||
});
|
||||
it('{in} 先看 let,再看作用域', () => {
|
||||
const ctx = { ...base, scope: { pn: 'FROM-SCOPE', other: 'S' }, lets: { pn: 'FROM-LET' } };
|
||||
expect(evalExpr({ in: 'pn' }, ctx)).toBe('FROM-LET');
|
||||
expect(evalExpr({ in: 'other' }, ctx)).toBe('S');
|
||||
});
|
||||
it('{col} 取本行,{outer} 取外层那一行', () => {
|
||||
const ctx = { ...base, row: { a: 1 }, outer: { b: 2 } };
|
||||
expect(evalExpr({ col: 'a' }, ctx)).toBe(1);
|
||||
expect(evalExpr({ outer: 'b' }, ctx)).toBe(2);
|
||||
});
|
||||
it('取不到的列是空串,不是 undefined —— 空串写进草稿层等于「这一列没写」', () => {
|
||||
expect(evalExpr({ col: 'nope' }, { ...base, row: {} })).toBe('');
|
||||
});
|
||||
it('{cat} 按字符串拼', () => {
|
||||
expect(evalExpr({ cat: ['W05-', { in: 'd' }] }, { ...base, lets: { d: 20260829 } })).toBe('W05-20260829');
|
||||
});
|
||||
it('{fmt as digits} 只留数字', () => {
|
||||
expect(evalExpr({ fmt: { in: 't' }, as: 'digits' }, { ...base, lets: { t: '2026-08-29T21:51:03Z' } })).toBe('20260829215103');
|
||||
});
|
||||
it('{lookup} 查另一张表的一列', () => {
|
||||
const ctx = { ...base, scope: { 'parts.profile': [{ internal_pn: 'A', chip_type_id: 3 }] }, row: { pn: 'A' } };
|
||||
expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id' }, ctx)).toBe(3);
|
||||
});
|
||||
it('{lookup} 查不到给 else,缺省空串', () => {
|
||||
const ctx = { ...base, scope: { 'parts.profile': [] }, row: { pn: 'A' } };
|
||||
expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id' }, ctx)).toBe('');
|
||||
expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'x', else: 'absent' }, ctx)).toBe('absent');
|
||||
});
|
||||
it('{lookup} 比值按字符串 —— 台账里数字与文本混着存', () => {
|
||||
const ctx = { ...base, scope: { 'a.b': [{ k: 1, v: 'hit' }] }, row: { k: '1' } };
|
||||
expect(evalExpr({ lookup: 'a.b', by: { k: { col: 'k' } }, take: 'v' }, ctx)).toBe('hit');
|
||||
});
|
||||
it('{when} 走注入的 S-FEEL,已算出的同行字段进它的作用域', () => {
|
||||
const ctx = { ...base, computed: { found_ref: 'LM2596S-5.0' } };
|
||||
expect(evalExpr({ when: 'found_ref != ""', then: 'found', else: 'absent' }, ctx)).toBe('found');
|
||||
expect(evalExpr({ when: 'found_ref != ""', then: 'found', else: 'absent' }, { ...base, computed: { found_ref: '' } })).toBe('absent');
|
||||
});
|
||||
it('{when} 谓词算不出来时走 else,不把整拍拖垮', () => {
|
||||
expect(evalExpr({ when: '这句没教过', then: 'a', else: 'b' }, base)).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evalText:一律出字符串', () => {
|
||||
it('数字与布尔照实转', () => {
|
||||
expect(evalText(3, base)).toBe('3');
|
||||
expect(evalText(true, base)).toBe('true');
|
||||
});
|
||||
it('空与 undefined 都是空串', () => {
|
||||
expect(evalText({ col: 'nope' }, { ...base, row: {} })).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 配方字段求值序 —— `orderFields` 的单元测试。
|
||||
*
|
||||
* 背景见 `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`:
|
||||
* `fields: {...}` 经一趟 jsonb 存取后键序不再保证,`auto-runner` 逐字段求值本依赖
|
||||
* 「声明序 = 依赖序」这条隐藏假设。这里直接测 `orderFields` 本身(端到端的
|
||||
* 「模拟 jsonb 重排 + 真跑 auto-runner」回归测试在 `auto-runner.test.ts`)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AutoExpr } from '@contracts';
|
||||
import { AutoFieldCycleError, orderFields, orderLets } from '../internal/domain/auto-order.ts';
|
||||
|
||||
/** 只看排出来的字段名序列,不看表达式本身 */
|
||||
const names = (fields: Readonly<Record<string, AutoExpr>>) => orderFields(fields).map(([name]) => name);
|
||||
/** 同上,给 `orderLets` 用 */
|
||||
const letNames = (lets: Readonly<Record<string, AutoExpr>>) => orderLets(lets).map(([name]) => name);
|
||||
|
||||
describe('orderFields', () => {
|
||||
it('没有 when 的字段:谁先谁后都行,数量对得上就好', () => {
|
||||
const fields: Record<string, AutoExpr> = { b: { col: 'x' }, a: 'literal', c: { in: 'y' } };
|
||||
expect(names(fields).sort()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('B 依赖 A(when 里提到 A 的名字):A 排到 B 前面,不管声明序', () => {
|
||||
// 声明序故意把依赖方 b 摆在被依赖方 a 前面 —— 就是 jsonb 重排会制造出的形状
|
||||
const fields: Record<string, AutoExpr> = {
|
||||
b: { when: 'a = "x"', then: 'hit', else: 'miss' },
|
||||
a: 'x',
|
||||
};
|
||||
const order = names(fields);
|
||||
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
|
||||
});
|
||||
|
||||
/* 逐字照抄 activities/part-substitution-assessment 的 S2_assess 那笔真实配方的依赖形状:
|
||||
difference_status(叶子)→ judgement_class(依赖 difference_status)→ risk_level(依赖 judgement_class) */
|
||||
it('三级链:C 依赖 B、B 依赖 A —— 排出 A、B、C,不管输入是哪种乱序', () => {
|
||||
const chain: Record<string, AutoExpr> = {
|
||||
/* A:不依赖同笔任何字段 */
|
||||
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
|
||||
/* B:when 链条里提到 A */
|
||||
judgement_class: {
|
||||
when: 'difference_status = "match"',
|
||||
then: 'match',
|
||||
else: { when: 'difference_status = "unknown"', then: 'unknown', else: 'mismatch' },
|
||||
},
|
||||
/* C:when 里提到 B */
|
||||
risk_level: { when: 'judgement_class = "match"', then: 'low', else: 'high' },
|
||||
};
|
||||
|
||||
// 用「先短后长、同长字典序」的 jsonb 重排顺序喂进去(与生产实测的重排结果同构)
|
||||
const jsonbLike: Record<string, AutoExpr> = {};
|
||||
for (const key of ['risk_level', 'judgement_class', 'difference_status']) jsonbLike[key] = chain[key] as AutoExpr;
|
||||
|
||||
const order = names(jsonbLike);
|
||||
expect(order.indexOf('difference_status')).toBeLessThan(order.indexOf('judgement_class'));
|
||||
expect(order.indexOf('judgement_class')).toBeLessThan(order.indexOf('risk_level'));
|
||||
});
|
||||
|
||||
it('依赖藏在 lookup.by / else 里也认得出', () => {
|
||||
const fields: Record<string, AutoExpr> = {
|
||||
target: { when: 'source = "x"', then: 'hit', else: 'miss' },
|
||||
source: { lookup: 'result:items', by: { k: 'v' }, take: 't', else: { when: 'flag = "y"', then: 'a', else: 'b' } },
|
||||
flag: 'y',
|
||||
};
|
||||
const order = names(fields);
|
||||
expect(order.indexOf('source')).toBeLessThan(order.indexOf('target'));
|
||||
expect(order.indexOf('flag')).toBeLessThan(order.indexOf('source'));
|
||||
});
|
||||
|
||||
it('依赖藏在 cat / fmt 里也认得出', () => {
|
||||
const fields: Record<string, AutoExpr> = {
|
||||
label: { cat: ['前缀-', { when: 'base != ""', then: { fmt: { in: 'x' }, as: 'digits' }, else: 'x' }] },
|
||||
base: '5',
|
||||
};
|
||||
const order = names(fields);
|
||||
expect(order.indexOf('base')).toBeLessThan(order.indexOf('label'));
|
||||
});
|
||||
|
||||
it('只认同一个 fields 对象内的名字 —— 引用台账列名 / 基座读数这类全局名字不算依赖,不参与排序', () => {
|
||||
const fields: Record<string, AutoExpr> = {
|
||||
a: { when: 'some_ledger_column = "x"', then: '1', else: '0' },
|
||||
b: { when: 'sub_eval_part_rows > 0', then: '1', else: '0' },
|
||||
};
|
||||
// 两个字段互不相干,不该抛错,数量对得上即可
|
||||
expect(names(fields).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('真正的环(A 依赖 B、B 依赖 A):抛 AutoFieldCycleError,不默默给错答案', () => {
|
||||
const fields: Record<string, AutoExpr> = {
|
||||
a: { when: 'b = "x"', then: '1', else: '0' },
|
||||
b: { when: 'a = "x"', then: '1', else: '0' },
|
||||
};
|
||||
expect(() => orderFields(fields)).toThrow(AutoFieldCycleError);
|
||||
try {
|
||||
orderFields(fields);
|
||||
expect.unreachable();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AutoFieldCycleError);
|
||||
expect([...(err as AutoFieldCycleError).cycle].sort()).toEqual(['a', 'b']);
|
||||
expect((err as Error).message).toContain('循环依赖');
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **自引用不算环,故意的**:字段 `a` 的 `when` 提到 `a` 时,那个名字未必是「我自己算出来的值」——
|
||||
* `evalExpr` 的合并作用域里 `row`(源行本身的列)也在,常见写法是「拿源行同名列做校验后原样投影」
|
||||
* (如 `status: { when: 'status = "active"', then: 'A', else: 'B' }`,这里的 `status` 指的是
|
||||
* `ctx.row.status`)。把自引用当环拦下会把这种常见写法误判成装不上的坏配方 ——
|
||||
* 排序对它也没有意义(不管排在哪一位,求值那一刻自己的 `computed` 值必然还没写进去,
|
||||
* 这是求值时序的必然,不是「顺序没排对」)。
|
||||
*/
|
||||
it('自己依赖自己(when 里提到自己的名字)不算环 —— 常见写法是拿源行同名列做校验', () => {
|
||||
const fields: Record<string, AutoExpr> = { a: { when: 'a = "x"', then: '1', else: '0' } };
|
||||
expect(() => orderFields(fields)).not.toThrow();
|
||||
expect(names(fields)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('返回的 [name, expr] 就是原表达式(调用方拿它当 Object.entries 的替代品)', () => {
|
||||
const expr: AutoExpr = { col: 'pn' };
|
||||
const out = orderFields({ only: expr });
|
||||
expect(out).toEqual([['only', expr]]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `orderLets` —— `recipe.let` 版的求值序(2026-08-30 全分支终审 Important 1)。
|
||||
*
|
||||
* `let` 与 `fields` 的依赖读法**不一样**:`fields` 的字段互相看见只能靠 `when` 字符串
|
||||
* 里的裸标识符(`{in:X}` 在 `fields` 里读 `lets`/`scope`,读不到同一个 `fields` 对象里
|
||||
* 的另一个字段);`let` 的官方跨条目读法**就是** `{in:X}`(`packages/contracts/src/
|
||||
* auto-recipe.ts` 的注释「先算一遍,后面所有表达式都能 {in:'名'} 引用」)。所以这里
|
||||
* 单独一组测试,不是照抄 `orderFields` 那组把函数名换一下 —— 头两条专门钉住这个差异。
|
||||
*/
|
||||
describe('orderLets', () => {
|
||||
it('**`{in:X}` 本身就是依赖**(let 与 fields 的关键差异):B 用 {in:"a"} 读 A,A 必须排到 B 前面', () => {
|
||||
// 声明序故意把依赖方 b 摆在被依赖方 a 前面 —— 就是 jsonb 重排会制造出的形状
|
||||
const lets: Record<string, AutoExpr> = {
|
||||
b: { cat: [{ in: 'a' }, '-suffix'] },
|
||||
a: 'x',
|
||||
};
|
||||
const order = letNames(lets);
|
||||
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
|
||||
});
|
||||
|
||||
it('三级链,全靠 {in:X} 串起来:C 依赖 B、B 依赖 A —— 排出 A、B、C,不管输入是哪种乱序', () => {
|
||||
const chain: Record<string, AutoExpr> = {
|
||||
evalRef: { in: 'case:openedAt' },
|
||||
/* B:{in:X} 提到 A */
|
||||
stamp: { cat: ['W05-', { in: 'evalRef' }] },
|
||||
/* C:{in:X} 提到 B */
|
||||
tag: { cat: [{ in: 'stamp' }, '-X'] },
|
||||
};
|
||||
// 用「先短后长、同长字典序」的 jsonb 重排顺序喂进去
|
||||
const jsonbLike: Record<string, AutoExpr> = {};
|
||||
for (const key of ['tag', 'stamp', 'evalRef']) jsonbLike[key] = chain[key] as AutoExpr;
|
||||
|
||||
const order = letNames(jsonbLike);
|
||||
expect(order.indexOf('evalRef')).toBeLessThan(order.indexOf('stamp'));
|
||||
expect(order.indexOf('stamp')).toBeLessThan(order.indexOf('tag'));
|
||||
});
|
||||
|
||||
it('when 字符串里的裸标识符同样算依赖(与 fields 那半条规矩一致,let 的 when 也进含 ...lets 的合并作用域)', () => {
|
||||
const lets: Record<string, AutoExpr> = {
|
||||
b: { when: 'a = "x"', then: 'hit', else: 'miss' },
|
||||
a: 'x',
|
||||
};
|
||||
const order = letNames(lets);
|
||||
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
|
||||
});
|
||||
|
||||
it('依赖藏在 lookup.by / else / cat / fmt 里也认得出(与 fields 那组同款覆盖)', () => {
|
||||
const lets: Record<string, AutoExpr> = {
|
||||
target: { lookup: 'parts.chip_type', by: { type_id: { in: 'source' } }, take: 'type_name', else: '' },
|
||||
source: { fmt: { in: 'raw' }, as: 'digits' },
|
||||
raw: '5',
|
||||
};
|
||||
const order = letNames(lets);
|
||||
expect(order.indexOf('source')).toBeLessThan(order.indexOf('target'));
|
||||
expect(order.indexOf('raw')).toBeLessThan(order.indexOf('source'));
|
||||
});
|
||||
|
||||
it('没有互相依赖的 let:谁先谁后都行,数量对得上就好', () => {
|
||||
const lets: Record<string, AutoExpr> = { b: { in: 'x' }, a: 'literal', c: { in: 'y' } };
|
||||
expect(letNames(lets).sort()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('只认同一个 let 对象内的名字 —— {in:X} 引用起单入参 / case: / 基座读数这类全局名字不算依赖,不参与排序', () => {
|
||||
const lets: Record<string, AutoExpr> = {
|
||||
a: { in: 'originalInternalPn' },
|
||||
b: { in: 'case:openedAt' },
|
||||
};
|
||||
// 两条互不相干,不该抛错,数量对得上即可
|
||||
expect(letNames(lets).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('真正的环({in:X} 互指):抛 AutoFieldCycleError,话里点名是 let(noun 参数),不默默给错答案', () => {
|
||||
const lets: Record<string, AutoExpr> = { a: { in: 'b' }, b: { in: 'a' } };
|
||||
expect(() => orderLets(lets)).toThrow(AutoFieldCycleError);
|
||||
try {
|
||||
orderLets(lets);
|
||||
expect.unreachable();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AutoFieldCycleError);
|
||||
expect([...(err as AutoFieldCycleError).cycle].sort()).toEqual(['a', 'b']);
|
||||
expect((err as Error).message).toContain('循环依赖');
|
||||
expect((err as Error).message).toContain('let');
|
||||
}
|
||||
});
|
||||
|
||||
it('自己依赖自己({in:X} 提到自己的名字)不算环 —— 常见写法是把同名入参"提升"进 let 命名空间', () => {
|
||||
const lets: Record<string, AutoExpr> = { chip_type_id: { in: 'chip_type_id' } };
|
||||
expect(() => orderLets(lets)).not.toThrow();
|
||||
expect(letNames(lets)).toEqual(['chip_type_id']);
|
||||
});
|
||||
|
||||
it('返回的 [name, expr] 就是原表达式(调用方拿它当 Object.entries 的替代品)', () => {
|
||||
const expr: AutoExpr = { in: 'evalRef' };
|
||||
const out = orderLets({ only: expr });
|
||||
expect(out).toEqual([['only', expr]]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { expandSource } from '../internal/domain/auto-source.ts';
|
||||
|
||||
const condition = (): boolean => false;
|
||||
const ctxWith = (scope: Record<string, unknown>) => ({ scope, lets: {}, condition });
|
||||
|
||||
describe('expandSource', () => {
|
||||
it('once:恰一行,行是空的', () => {
|
||||
expect(expandSource({ once: true }, ctxWith({}))).toEqual([{ row: {} }]);
|
||||
});
|
||||
|
||||
it('rows:字面几行,每格现算', () => {
|
||||
const out = expandSource(
|
||||
{ rows: [{ pn: { in: 'a' }, role: 'original' }, { pn: { in: 'b' }, role: 'candidate' }] },
|
||||
{ scope: { a: 'LM2596S-5.0', b: 'XL2596S-5.0' }, lets: {}, condition },
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{ row: { pn: 'LM2596S-5.0', role: 'original' } },
|
||||
{ row: { pn: 'XL2596S-5.0', role: 'candidate' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('each:按地址逐行', () => {
|
||||
const out = expandSource({ each: 'mine:sub_eval.part' }, ctxWith({ 'mine:sub_eval.part': [{ pn: 'A' }, { pn: 'B' }] }));
|
||||
expect(out.map((e) => e.row)).toEqual([{ pn: 'A' }, { pn: 'B' }]);
|
||||
});
|
||||
|
||||
it('each + where:等值过滤,比值按字符串', () => {
|
||||
const out = expandSource(
|
||||
{ each: 'mine:sub_eval.part', where: { exists_status: 'absent' } },
|
||||
ctxWith({ 'mine:sub_eval.part': [{ pn: 'A', exists_status: 'found' }, { pn: 'B', exists_status: 'absent' }] }),
|
||||
);
|
||||
expect(out.map((e) => e.row['pn'])).toEqual(['B']);
|
||||
});
|
||||
|
||||
it('两层:里层的 where 认得出外层那一行', () => {
|
||||
const out = expandSource(
|
||||
{ each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } },
|
||||
ctxWith({
|
||||
'mine:sub_eval.part': [{ pn: 'A', chip_type_id: 1 }, { pn: 'B', chip_type_id: 2 }],
|
||||
'parts.type_param': [
|
||||
{ type_id: 1, param_name: '输入电压范围' },
|
||||
{ type_id: 1, param_name: '输出电流' },
|
||||
{ type_id: 2, param_name: '压差' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out).toEqual([
|
||||
{ outer: { pn: 'A', chip_type_id: 1 }, row: { type_id: 1, param_name: '输入电压范围' } },
|
||||
{ outer: { pn: 'A', chip_type_id: 1 }, row: { type_id: 1, param_name: '输出电流' } },
|
||||
{ outer: { pn: 'B', chip_type_id: 2 }, row: { type_id: 2, param_name: '压差' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('外层的 chip_type_id 还是空的时候,里层一行都不命中 —— 「保存即铺格」靠的就是这一条', () => {
|
||||
const out = expandSource(
|
||||
{ each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } },
|
||||
ctxWith({
|
||||
'mine:sub_eval.part': [{ pn: 'A', chip_type_id: '' }],
|
||||
'parts.type_param': [{ type_id: 1, param_name: '输入电压范围' }],
|
||||
}),
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { liveStepOf } from '../internal/domain/live-step.ts';
|
||||
|
||||
const steps = [
|
||||
{ item: 'S1', guide: '' },
|
||||
{ item: 'S2', guide: '' },
|
||||
{ item: 'T_release', guide: '' },
|
||||
];
|
||||
|
||||
describe('liveStepOf:这一单此刻在办哪一步', () => {
|
||||
it('在场的第一个还没收掉的项', () => {
|
||||
expect(liveStepOf(steps, new Set(), [{ id: 'S1' }, { id: 'S2' }])?.item).toBe('S1');
|
||||
});
|
||||
|
||||
it('**收掉的项要跳过** —— 带 repetitionRule 的项收了之后仍是 Enabled,不跳就整单钉在第一步', () => {
|
||||
expect(liveStepOf(steps, new Set(['S1']), [{ id: 'S1' }, { id: 'S2' }])?.item).toBe('S2');
|
||||
});
|
||||
|
||||
it('在场的都收完了 → undefined', () => {
|
||||
expect(liveStepOf(steps, new Set(['S1', 'S2']), [{ id: 'S1' }, { id: 'S2' }])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('在场的项没有剧本 → undefined(没写剧本的活动照旧跑)', () => {
|
||||
expect(liveStepOf(steps, new Set(), [{ id: 'X' }])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('次序按 onStage 给的次序,不按 steps 的次序', () => {
|
||||
expect(liveStepOf(steps, new Set(), [{ id: 'S2' }, { id: 'S1' }])?.item).toBe('S2');
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { deriveSide, structuredLog } from '@modules/memory';
|
||||
import type { EntryKind, GovernanceMark, MemoryEntry, VersionStamp } from '@modules/memory';
|
||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, earlyFinishTitleOf } from '../internal/usecase/turn-runner.ts';
|
||||
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
|
||||
import type { PendingAcceptance } from '../port.ts';
|
||||
@@ -14,6 +16,30 @@ import type { CaseRuntime } from '../internal/domain/lifecycle.ts';
|
||||
|
||||
const TASK = 't-1' as TaskId;
|
||||
|
||||
/** 合成的信封字段 —— 与 `task-memory.test.ts` 的 `entry()` 工厂同一套写法 */
|
||||
const STAMP: VersionStamp = { contractVersion: '1', businessModelVersion: '1', skillVersions: [] };
|
||||
const GOVERNANCE: GovernanceMark = { masked: false, learnable: true };
|
||||
|
||||
/**
|
||||
* **把捕获到的一笔真实留痕包成完整 `MemoryEntry`,喂给 `structuredLog` 验收**(2026-08-29,R10)。
|
||||
* 与 `auto-runner.test.ts` 的同名助手同一套写法 —— 见那边的注释。
|
||||
*/
|
||||
function asLogEntry(captured: { kind: string; title: string; detail: Record<string, unknown> | undefined }): MemoryEntry {
|
||||
const kind = captured.kind as EntryKind;
|
||||
return {
|
||||
id: 'e-1',
|
||||
taskId: TASK,
|
||||
seq: 1,
|
||||
at: '2026-08-29T00:00:00.000Z',
|
||||
kind,
|
||||
side: deriveSide(kind),
|
||||
title: captured.title,
|
||||
stamp: STAMP,
|
||||
governance: GOVERNANCE,
|
||||
...(captured.detail === undefined ? {} : { detail: captured.detail }),
|
||||
};
|
||||
}
|
||||
|
||||
/** 空案例模型:必做项全无 + autoComplete → 一进 runTurn 就「判齐」 */
|
||||
const READY_MODEL: CaseModel = { name: '空案', autoComplete: true, items: [], sentries: [] } as never;
|
||||
const READY_RUNTIME: CaseRuntime = { items: [] } as never;
|
||||
@@ -37,8 +63,10 @@ type Harness = {
|
||||
readonly pushed: string[];
|
||||
/** 每次推帧时「已写下几行」—— 帧推在行动之前的话,它是 0 */
|
||||
readonly writesAtPush: number[];
|
||||
/** 每次推帧时携带的 scope(`showView` 现读的 `deps.data.view()`)—— 断言「画面里真的有新行」走这里 */
|
||||
readonly pushedScopes: Array<Record<string, unknown>>;
|
||||
/** 这一拍写下的全部留痕 —— 断言「被拒/无事发生」走这里 */
|
||||
readonly appended: Array<{ kind: string; title: string }>;
|
||||
readonly appended: Array<{ kind: string; title: string; detail: Record<string, unknown> | undefined }>;
|
||||
/** 真被调到的工具名 —— 断言「拦没拦住调用」走这里 */
|
||||
readonly toolCalls: string[];
|
||||
/** 真立起来的监听 —— 断言「拦没拦住立监听」走这里 */
|
||||
@@ -73,6 +101,15 @@ function harness(
|
||||
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
|
||||
/** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */
|
||||
unanswered?: ReadonlyArray<{ id: string; title: string }>;
|
||||
/** `deps.cards.issue` 这一句改抛(测「兜底卡自己也发不出去」——2026-08-29 复审 I1 二审) */
|
||||
issueRejects?: string;
|
||||
/**
|
||||
* **只让 `workOrder:'acceptance'` 那一句抛**(2026-08-30 终审 I4)——模拟落地预检
|
||||
* 拒发验收卡(`raiseAcceptance` 内部 `deps.cards.issue({workOrder:'acceptance',…})`
|
||||
* 抛出的那种);与 `issueRejects`(不分 workOrder 一律抛)分开,好让「验收卡发不出去、
|
||||
* 但兜底的执行工单发得出去」这个场景测得出来。
|
||||
*/
|
||||
acceptanceIssueRejects?: string;
|
||||
} = {},
|
||||
): Harness {
|
||||
const issued: Harness['issued'] = [];
|
||||
@@ -82,6 +119,7 @@ function harness(
|
||||
const dropped: Harness['dropped'] = [];
|
||||
const pushed: string[] = [];
|
||||
const writesAtPush: number[] = [];
|
||||
const pushedScopes: Harness['pushedScopes'] = [];
|
||||
const appended: Harness['appended'] = [];
|
||||
const toolCalls: string[] = [];
|
||||
const listened: Array<{ eventType: string }> = [];
|
||||
@@ -93,6 +131,7 @@ function harness(
|
||||
dropped,
|
||||
pushed,
|
||||
writesAtPush,
|
||||
pushedScopes,
|
||||
appended,
|
||||
toolCalls,
|
||||
listened,
|
||||
@@ -139,6 +178,7 @@ function harness(
|
||||
} as never;
|
||||
},
|
||||
} as never,
|
||||
analyze: { run: async () => ({ object: {} }) } as never,
|
||||
tools: {
|
||||
invoke: async (input: { tool: string }) => {
|
||||
toolCalls.push(input.tool);
|
||||
@@ -152,6 +192,10 @@ function harness(
|
||||
} as never,
|
||||
cards: {
|
||||
issue: async (input: { workOrder: string; card: Record<string, unknown> }) => {
|
||||
if (over.issueRejects !== undefined) throw new Error(over.issueRejects);
|
||||
if (over.acceptanceIssueRejects !== undefined && input.workOrder === 'acceptance') {
|
||||
throw new Error(over.acceptanceIssueRejects);
|
||||
}
|
||||
issued.push({ workOrder: input.workOrder, card: input.card });
|
||||
return { id: `card-${issued.length}` };
|
||||
},
|
||||
@@ -173,17 +217,18 @@ function harness(
|
||||
declared: async () => over.declaredListeners ?? [{ eventType: 'data', keyFilter: {} }],
|
||||
} as never,
|
||||
memory: {
|
||||
append: async (entry: { kind: string; title: string }) => {
|
||||
appended.push({ kind: entry.kind, title: entry.title });
|
||||
append: async (entry: { kind: string; title: string; detail?: Record<string, unknown> }) => {
|
||||
appended.push({ kind: entry.kind, title: entry.title, detail: entry.detail });
|
||||
},
|
||||
loadActivityMemory: async () => [],
|
||||
window: async () => over.trail ?? [],
|
||||
} as never,
|
||||
views: {
|
||||
push: async (input: { template: string }) => {
|
||||
push: async (input: { template: string; scope?: Record<string, unknown> }) => {
|
||||
pushed.push(input.template);
|
||||
// 推这一帧时已经写下了几行 —— 帧推早了就是 0(见「推在行动之后」那条)
|
||||
writesAtPush.push(written.length);
|
||||
pushedScopes.push(input.scope ?? {});
|
||||
return { pushed: (over.templates ?? ['case-overview']).includes(input.template), templates: over.templates ?? ['case-overview'] };
|
||||
},
|
||||
} as never,
|
||||
@@ -1430,6 +1475,48 @@ describe('画面不再静默丢帧', () => {
|
||||
expect(h.pushed).toEqual(['step2-view']);
|
||||
});
|
||||
|
||||
/*
|
||||
* **人填的表模型不许写**(2026-08-28,用户送来的实测单):数据工单要人在件上补的表,值只可能从人那儿来。
|
||||
* 实测模型把台账里原 PN 那一行的规格书逐字抄到候选料号头上 —— 行找对了、料找错了,而这份假证据
|
||||
* 会直接喂进差异比对当「硬约束有证据支持」。
|
||||
*/
|
||||
it('剧本声明 humanWrites 的台账:write 当场拒,拒回指向「去发那张卡」', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
steps: [{ item: 'i1', intent: '派数据工单', guide: '做法', writes: [], reads: [], tools: [], view: 'x', humanWrites: ['parts.document'] }],
|
||||
action: { kind: 'write', itemId: 'i1', address: 'parts.document', rows: [{ key: { internal_pn: 'X' }, patch: { document_ref: '抄来的' } }] },
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.written).toHaveLength(0);
|
||||
const rejected = h.appended.find((e) => e.title.startsWith('行动被拒'));
|
||||
expect(rejected?.title).toMatch(/归人填/);
|
||||
expect(rejected?.title).toMatch(/数据工单发出去/);
|
||||
});
|
||||
|
||||
it('没声明的表照写;drop 不拦(写错了得撤得掉)', async () => {
|
||||
const steps = [{ item: 'i1', intent: '派数据工单', guide: '做法', writes: [], reads: [], tools: [], view: 'x', humanWrites: ['parts.document'] }];
|
||||
const ok = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
steps,
|
||||
action: { kind: 'write', itemId: 'i1', address: 'parts.profile', rows: [{ key: { internal_pn: 'X' }, patch: { brand_name: 'TI' } }] },
|
||||
});
|
||||
await ok.runner.runTurn(TASK);
|
||||
expect(ok.written).toHaveLength(1);
|
||||
expect(ok.appended.some((e) => e.title.startsWith('行动被拒'))).toBe(false);
|
||||
|
||||
const dropped = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
steps,
|
||||
action: { kind: 'drop', itemId: 'i1', address: 'parts.document', key: { internal_pn: 'X' } },
|
||||
});
|
||||
await dropped.runner.runTurn(TASK);
|
||||
// drop 可能因「本来就没有这一行」被拒,但**不该**是被这道墙拒的
|
||||
expect(dropped.appended.some((e) => e.title.startsWith('行动被拒') && e.title.includes('归人填'))).toBe(false);
|
||||
});
|
||||
|
||||
it('活步都收完 → 一帧都不推,最后摆上去的那一张(放行页)留在档案里', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
@@ -2109,3 +2196,728 @@ describe('终局放行那一步:human 被拒并指向 submit', () => {
|
||||
expect(h.issued.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto 步:配方在拍首跑', () => {
|
||||
/** 一步的活动:那一步声明 auto */
|
||||
const AUTO_MODEL: CaseModel = {
|
||||
name: '一步的活动',
|
||||
autoComplete: false,
|
||||
items: [{ id: 'S1', name: '补数据', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
|
||||
sentries: [],
|
||||
} as never;
|
||||
const AUTO_RUNTIME: CaseRuntime = { items: [{ id: 'S1', state: 'Available' }] } as never;
|
||||
|
||||
it('配方发了卡 → 这一拍一次都不问模型,状态 awaiting', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: AUTO_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: { do: [{ kind: 'card', as: 'execution', title: '补数据', basis: [{ k: '料号', v: 'XL2596S-5.0' }] }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.decided).toHaveLength(0);
|
||||
expect(out.status).toBe('awaiting');
|
||||
expect(h.issued.map((i) => i.workOrder)).toEqual(['execution']);
|
||||
});
|
||||
|
||||
it('配方只写不发卡、这一步因此办完 → 收步,还是不问模型', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: AUTO_RUNTIME,
|
||||
sfeelCondition: (_expr, scope) => Number(scope['quote_intake_record_rows'] ?? 0) > 0,
|
||||
viewFn: (written) => ({ quote_intake_record_rows: written.length }),
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
doneWhen: 'quote_intake_record_rows > 0',
|
||||
auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'LM2596S-5.0', eau: 1000 } }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.decided).toHaveLength(0);
|
||||
expect(h.written.map((w) => w.key)).toEqual([{ pn: 'LM2596S-5.0' }]);
|
||||
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
|
||||
});
|
||||
|
||||
it('有卡在等人交回:配方照样写,但一张新卡都不发', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: AUTO_RUNTIME,
|
||||
unanswered: [{ id: 'card-9', title: '补数据' }],
|
||||
sfeelCondition: () => false,
|
||||
viewFn: (written) => ({ n: written.length }),
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: {
|
||||
do: [
|
||||
{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } },
|
||||
{ kind: 'card', as: 'execution', title: '再来一张', basis: [{ k: 'a', v: 'b' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.written).toHaveLength(1);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
});
|
||||
|
||||
/*
|
||||
* **这一拍真写下过东西,收拍前要把画面重推一遍**(2026-08-30,W05 数据工单浏览器走查实测:
|
||||
* 人给候选料选好芯片类型、按保存,配方铺出 7 行新参数、红带也点名要填这 7 格 —— 而这条路
|
||||
* 从不推帧,任务停在 `awaiting-event`,人刷新页面看到的仍是保存前那份快照(件是服务端渲好
|
||||
* 推来的快照,重放不重渲),被点名的格子屏上根本不存在,走查到此卡死。
|
||||
* 上一条用例(不发新卡)只断言了 `written`/`issued`,从没问过画面有没有跟上;这一条补上。
|
||||
*/
|
||||
it('有卡在等人交回:配方写下新行 → 收拍前重推画面,画面里带着刚写的那一行', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: AUTO_RUNTIME,
|
||||
unanswered: [{ id: 'card-9', title: '补数据' }],
|
||||
sfeelCondition: () => false,
|
||||
templates: ['input-supplement'],
|
||||
// 画面的 scope 现读「已写下的那几行」—— 与 `showView` 实际调用 `deps.data.view()` 同一条路
|
||||
viewFn: (written) => ({ pns: written.map((w) => w.key['pn']) }),
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
view: 'input-supplement',
|
||||
auto: {
|
||||
do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'MP2596-5.0-WALK', eau: 1 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.written).toHaveLength(1);
|
||||
expect(out.status).toBe('awaiting');
|
||||
// 画面真被重推了一次 —— 不是「一帧不推,留着保存前那份快照」
|
||||
expect(h.pushed).toEqual(['input-supplement']);
|
||||
// 推的时候真带着刚写的那一行,不是拍首、写之前的那份旧 scope
|
||||
expect(h.pushedScopes.at(-1)?.['pns']).toEqual(['MP2596-5.0-WALK']);
|
||||
});
|
||||
|
||||
it('配方抛错(地址不在台账声明里)→ 交人,不问模型、不重试', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: AUTO_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: { do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.decided).toHaveLength(0);
|
||||
expect(out.status).toBe('awaiting');
|
||||
expect(h.appended.some((a) => a.title.includes('nope.nope'))).toBe(true);
|
||||
});
|
||||
|
||||
it('配方跑完了、这一步还没算办完(没 guide、没发卡、doneWhen 不成立)→ 交人,且这条消息真的到得了工作日志(R10 第六处)', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: AUTO_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
// say 不写不撤、不发卡 —— 配方跑完仍落到「没算办完,交人」那条分支
|
||||
auto: { do: [{ kind: 'say', text: '已核对,尚缺关键信息' }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.decided).toHaveLength(0);
|
||||
expect(out.status).toBe('awaiting');
|
||||
const line = h.appended.find((a) => a.title.includes('还没算办完'));
|
||||
expect(line).toBeDefined();
|
||||
/*
|
||||
* **这一条是五处里最不能隐身的**(2026-08-29,coordinator 裁定):它是交人的那一句 ——
|
||||
* 配方没做到位、单子就停在这儿了,人打开工作日志想知道的第一件事就是「为什么停了」。
|
||||
* 从前 kind: 'result' 不在 SKELETON 里,这一笔永远进不了工作日志,单子看着像凭空卡住。
|
||||
*/
|
||||
const lines = structuredLog([asLogEntry(line!)]);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.who).toBe('system');
|
||||
});
|
||||
|
||||
it('这一步有 guide:配方跑完照常问模型(半 auto)', async () => {
|
||||
const h = harness({
|
||||
parsed: OPEN_MODEL,
|
||||
runtime: OPEN_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
viewFn: (written) => ({ n: written.length }),
|
||||
steps: [
|
||||
{
|
||||
item: 'i1',
|
||||
guide: '把结论写实',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } }] },
|
||||
},
|
||||
],
|
||||
/*
|
||||
* brief 原文这里写的是 `{ kind: 'idle', rationale: '看过了' }` ——
|
||||
* `AgentAction` 判别联合里没有 `'idle'` 这一档(全仓 grep 零命中),
|
||||
* `applyAction` 的 switch 又没有 default 分支,不认得的 kind 会让它
|
||||
* 隐式 return undefined,下一行 `applied.runtime` 当场抛 TypeError。
|
||||
* 这条用例要测的是「配方写完之后模型仍被正常问了一次」,与模型具体
|
||||
* 回了哪个动作无关 —— 换成场上已有测试反复验证过的 `finish`(收掉
|
||||
* i1,i1 没声明 manualActivation / doneWhen,收得干净)。
|
||||
*/
|
||||
action: { kind: 'finish', itemId: 'i1', rationale: '看过了' },
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.written).toHaveLength(1);
|
||||
expect(h.decided).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('卡答过了、这一步又没有 doneWhen → 收步(人已经签了,收步是照实记录)', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
|
||||
unanswered: [],
|
||||
sfeelCondition: () => false,
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
|
||||
/*
|
||||
* **配方留痕真的到得了工作日志**(2026-08-29,R10,spec §5.9 验收)。
|
||||
* 上一轮(Task 12)只测过 actorOf 这个纯函数;这里把「自动收步」这一笔捕获到的
|
||||
* 真实 {kind, title, detail} 喂给 structuredLog 全程 —— 从前它带着 `internal: true`,
|
||||
* 会被 `machinery()` 挡在骨架之外,`actorOf` 判得再对也没用。
|
||||
*/
|
||||
const settled = h.appended.find((e) => e.title.startsWith('自动收步:'));
|
||||
expect(settled).toBeDefined();
|
||||
const lines = structuredLog([asLogEntry(settled!)]);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.who).toBe('system');
|
||||
});
|
||||
|
||||
/*
|
||||
* **Important 2(2026-08-30 全分支终审,先坐实、不修)**:上面那条「卡答过了…→ 收步」
|
||||
* 只证明了「答过就收」这个结构本身存在,没证明它对**裁决**视而不见 —— 这条把裁决
|
||||
* 摆明白:审核卡只有一个选项(`approve`),人按的是「重做」,交的是
|
||||
* `custom:<理由>`(`review-face.ts` 的 `redoReply`:写了理由且 `allowCustom` 就交
|
||||
* `custom:` 走**对话卡族**,不是 `approve`)。`card-rules.ts` 的 `checkChatCardReply`
|
||||
* 认这个答复合法(`allowCustom` 对审核卡默认真,见 `engines.ts:1507`
|
||||
* `allowCustom: input.allowCustom ?? reviewFace`)、`worldTouched` 照样落 true ——
|
||||
* 于是这条「卡答过了就收步」的判断(:2179,四个前提:`noDoneWhen && carded &&
|
||||
* recipeIssuesCard(recipe) && current.guide.trim() === ''`)一个字都不看
|
||||
* `reply.value`/`chosen.value` 是 `approve` 还是 `custom:...`,一样收步、一样往前推。
|
||||
*
|
||||
* **为什么不能靠活动侧加一个 `reject` 选项治**:上面那四个前提里没有一条查了选项或
|
||||
* 选中的值,给卡加选项不会改变这个判断的走向——同一张卡不管选中哪一个,只要
|
||||
* `worldTouched` 变真,这一步就被收。
|
||||
*
|
||||
* **为什么 2026-08-27 那次「chosenGuidance 分岔」治不了这一条**:那次治的是**半 auto**
|
||||
* 路径(`guide` 非空、真的会去问模型的那条)—— `answeredCardFact`/`chosenGuidance`
|
||||
* 拼进的是**喂给模型的提示词**(:2255 `staged` 数组),而这里根本不问模型
|
||||
* (`current.guide.trim() === ''` 正是触发这条近道的四个前提之一)。**没有模型可问,
|
||||
* 就没有提示词可以说服** —— 同一症状("审核那一步被收掉、一路推到放行",
|
||||
* `chosenGuidance` 头注里 2026-08-27 W05 实测的原话)在一条新的(fully-auto)代码路径上
|
||||
* 重演,而那次的修法结构性地够不到这条路径。
|
||||
*
|
||||
* **建议修法方向**(未实现,留给专门的一次改动):line 2179 那道门至少要能分清
|
||||
* 「答的是不是 approve 选项」——但这只是半个修法:即便挡住了误收,S3_review 的
|
||||
* `card` 动作在 `context.carded === true` 时会**直接跳过、不再发新卡**
|
||||
* (`auto-runner.ts` 的 card 分支),重做之后没有任何机制会把 `worldTouched` 重置、
|
||||
* 也没有任何机制会再发一张新的审核卡 —— 单子会卡死在「答过但没收步」的死胡同,
|
||||
* 与 T_release 那条**已经做好**的重做闭环(`raiseAcceptance`,2026-08-22/23:意见落轨迹、
|
||||
* 在途验收清掉、同一拍照常问模型)不是同一件事、也没有对应机制。真正修好需要给
|
||||
* 这条 auto-card 路径补一个类似 `raiseAcceptance` 的「重做即重开」闭环,不是一行判断。
|
||||
*/
|
||||
it('Important 2 坐实:审核卡答的是"重做"(custom:<理由>),不是 approve —— 「卡答过了就收步」照样收步,意见对它不可见', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
|
||||
unanswered: [],
|
||||
sfeelCondition: () => false,
|
||||
// 人真答的是"重做",理由写在 note 里 —— 这条 trail 证明"裁决是拒答"这件事**在系统里查得到**
|
||||
trail: [{ kind: 'card-reply', title: '结论审核', detail: { value: 'custom:结论算错了,请重算', by: 's-1' } }],
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
/*
|
||||
* **这就是 Important 2 的病灶**:trail 里明明白白记着人写的是"结论算错了,请重算"
|
||||
* (一次拒答),这一步却仍然被判 Completed —— 与「人按了确认通过」会得到的结果
|
||||
* 完全相同。`out.runtime` 对这条 trail 里的裁决视而不见。
|
||||
*/
|
||||
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
|
||||
});
|
||||
|
||||
/*
|
||||
* 这条原名「卡还没发过就不收 —— 那才是替人签字」——评审 trace 出它验的其实是 Task 7
|
||||
* 既有的「还没发过就发卡」那条路(:1998 `auto.issuedCardId !== undefined` 提前返回),
|
||||
* 从没走到 Task 8 新加的 `carded` 门(:2019 起那一段)。名字改准,逻辑不动 ——
|
||||
* 它仍是一条有效回归(证明配方在没发过卡时确实把卡发了出去),只是不再冒充「验过 carded 门」。
|
||||
*/
|
||||
it('还没发过卡 → 这一拍把卡发出去(Task 7 既有路径,不是 carded 门 —— 见下一条)', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: AUTO_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
|
||||
});
|
||||
|
||||
/*
|
||||
* **真正把执行推到 :2019 且 carded === false 的用例**(评审 Important)。
|
||||
*
|
||||
* 配方两笔:`project` 无条件照写(worldTouched 不受它影响)、`card` 带 `when` 这一拍算假、
|
||||
* 被跳过 —— 卡**没有**发出去,`autoRunner.run` 回的是 `issuedCardId: undefined`。
|
||||
* `auto.wrote > 0` 触发一次 doneWhen 重扫,可这一步没声明 doneWhen,重扫不改变局面,
|
||||
* 于是**第一次**真的落到收步判断那一段:`recipeIssuesCard(current.auto)` 因为配方里
|
||||
* 结构上确实有一笔 `card`(不看这一拍它有没有被 `when` 挡住)算出 true,`carded` 却是假
|
||||
* ——这道门就是靠 `carded` 才没把它错收。`carded &&` 这个条件若被误删,这条会变红。
|
||||
*/
|
||||
it('配方这一拍卡被 when 挡住没发、只有别的笔照写 → carded 是假,这一步不该被收', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: AUTO_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: {
|
||||
do: [
|
||||
{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } },
|
||||
{
|
||||
kind: 'card',
|
||||
when: '恒假',
|
||||
as: 'decide',
|
||||
variant: 'review',
|
||||
title: '结论审核',
|
||||
options: [{ value: 'approve', label: '确认通过' }],
|
||||
basis: [{ k: 'a', v: 'b' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
// project 照写,card 被 when 挡掉没发
|
||||
expect(h.written).toHaveLength(1);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
|
||||
});
|
||||
|
||||
/*
|
||||
* **R9**(基于评审 Minor #1 的裁定):半 auto(声明了非空 `guide`)的步,卡答过了也不该
|
||||
* 被这段收掉 —— 半 auto 存在的意义就是「确定的先做完,剩下的判断仍问模型」,人答完卡之后
|
||||
* 那份判断(读人写的自由文本、落到具体的行上)恰恰是模型该干的活。
|
||||
* 用默认的 `submit` 动作(harness 不传 `action` 时的缺省值):它只发验收卡、不碰项状态,
|
||||
* 这样「S1 没被收成 Completed」这条断言测的确实是「没被这段收掉」,不是被模型自己收掉的假阳性。
|
||||
*/
|
||||
it('R9:半 auto(有 guide)的步卡也答过了 —— 不该被收在模型之前,那份判断仍是模型的活', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
|
||||
unanswered: [],
|
||||
sfeelCondition: () => false,
|
||||
steps: [
|
||||
{
|
||||
item: 'S1',
|
||||
guide: '把结论落进台账,再判断该不该收步',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.decided).toHaveLength(1);
|
||||
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **配方 submit 的三条**(2026-08-29 终审 C3)。
|
||||
*
|
||||
* 从前 `raiseAcceptance` 在配方那一路是**裸着**调的:`engine.advance` 不捕获、
|
||||
* `task-service.tick` 也不捕获(且 `addConsumption` 排在 advance 之后,一步都不记)、
|
||||
* `task-advancer` 只写 stderr 就 break、任务留在 `running` —— 下一个 interval 再来一遍:
|
||||
* 无限热循环,不烧预算、不换状态、界面纹丝不动。而它**必然**会抛:重做闸认的
|
||||
* 「改过台账」只看 `kind:'action'`,配方的写留痕是 `observation` + `detail.auto`。
|
||||
*/
|
||||
describe('auto 步 · submit 抛错不许让回合崩(C3)', () => {
|
||||
const AUTO_RELEASE: CaseModel = {
|
||||
name: '一步放行',
|
||||
autoComplete: false,
|
||||
items: [{ id: 'T_release', name: '放行', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
|
||||
sentries: [],
|
||||
} as never;
|
||||
const AUTO_RELEASE_RUNTIME: CaseRuntime = { items: [{ id: 'T_release', state: 'Available' }] } as never;
|
||||
const RELEASE_STEP = {
|
||||
item: 'T_release',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
acceptanceChecklist: ['逐条核对'],
|
||||
auto: { do: [{ kind: 'submit', rationale: '配方判齐了' }] },
|
||||
};
|
||||
const REDO = { kind: 'result', title: '验收被打回重做:结论写得太糊' };
|
||||
/** 配方写下的那一笔:`observation` + `detail.auto` + `detail.ledger` + `detail.changed:true`(auto-runner 的真实形状) */
|
||||
const AUTO_WROTE = {
|
||||
kind: 'observation',
|
||||
title: '差异明细:写下 2 行',
|
||||
detail: { auto: 'T_release#1', ledger: 'substitution.difference_item', changed: true },
|
||||
};
|
||||
/**
|
||||
* **空转的那一笔**(2026-08-29 复审 M1/M2 二审):`localExisting`/`localUnkeyed` 也会留痕
|
||||
* (M1/M2 的「一行没写也要出声」),而台账其实一个字没动 —— `detail.changed` 是 `false`。
|
||||
*/
|
||||
const AUTO_NOOP = {
|
||||
kind: 'observation',
|
||||
title: '差异明细:写下 0 行,2 行已在库、这一笔不用建',
|
||||
detail: { auto: 'T_release#1', ledger: 'substitution.difference_item', changed: false },
|
||||
};
|
||||
|
||||
it('打回后配方原样重提 → 留痕拒回、**状态 running**(不抛,推进器不再空转热循环)', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_RELEASE,
|
||||
runtime: AUTO_RELEASE_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
trail: [REDO],
|
||||
steps: [RELEASE_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('running');
|
||||
expect(h.issued).toHaveLength(0);
|
||||
const refused = h.appended.find((e) => e.title.startsWith('提交放行被拒'));
|
||||
expect(refused?.title).toContain('一个字没改');
|
||||
});
|
||||
|
||||
it('那条拒回痕就是解锁凭据 —— 下一拍再提交就放行(墙要配路,不把单子锁死)', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_RELEASE,
|
||||
runtime: AUTO_RELEASE_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
trail: [REDO, { kind: 'result', title: '提交放行被拒:人打回了这一份成果,而打回之后台账一个字没改' }],
|
||||
steps: [RELEASE_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.issued.map((i) => i.workOrder)).toEqual(['acceptance']);
|
||||
expect(out.status).toBe('awaiting');
|
||||
});
|
||||
|
||||
it('**配方改过台账就算改过** —— observation + detail.auto + detail.ledger + detail.changed:true 也解得开重做闸', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_RELEASE,
|
||||
runtime: AUTO_RELEASE_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
trail: [REDO, AUTO_WROTE],
|
||||
steps: [RELEASE_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(h.issued.map((i) => i.workOrder)).toEqual(['acceptance']);
|
||||
expect(out.status).toBe('awaiting');
|
||||
expect(h.appended.some((e) => e.title.startsWith('提交放行被拒'))).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* **「一行没写」也能解开重做闸**(2026-08-29 复审 M1/M2 二审,复审跟到底的那条):
|
||||
* 人点了「重做」,配方重跑、一个字没改(全 skipExisting / 主键算不出来),只要留了
|
||||
* M1/M2 那条「出声」的痕,从前的 `revised` 判据一样为真 —— 闸放行了原样重提。
|
||||
*/
|
||||
it('空转的那一笔(全在库/主键算不出来)解不开重做闸 —— 拒回原样再来一遍', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_RELEASE,
|
||||
runtime: AUTO_RELEASE_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
trail: [REDO, AUTO_NOOP],
|
||||
steps: [RELEASE_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('running');
|
||||
expect(h.issued).toHaveLength(0);
|
||||
const refused = h.appended.find((e) => e.title.startsWith('提交放行被拒'));
|
||||
expect(refused?.title).toContain('一个字没改');
|
||||
});
|
||||
|
||||
/*
|
||||
* **Important 4(2026-08-30 全分支终审)**:上面四条测的都是「打回重做」那条路
|
||||
* (`trail` 里有 `REDO_MARK`)。这三条测**从没被打回过、纯粹是验收判据(L1/L2 机检)
|
||||
* 没过**的那条路——这一步没有 guide(全 auto),没有模型会读「提交放行被拒」这句话,
|
||||
* 台账在更早一步就已经收口、没有活的编辑面能让人就地改:下一拍重跑只会拿到同一份
|
||||
* 数据、撞回同一堵墙,纯粹烧步数预算直到 exhausted(task-service 的 markProgress
|
||||
* 每拍都记,stallAfterMs 那道熔断因此不会提前触发)。用 `acceptanceIssueRejects`
|
||||
* 模拟 `raiseAcceptance` 内部 `deps.cards.issue({workOrder:'acceptance',…})`
|
||||
* 被落地预检拒发(这正是 W05 一颗 UNKNOWN 差异行漏填去向时,`every-param-judged`[L2]
|
||||
* 会真实抛出的那种错误)。
|
||||
*/
|
||||
it('Important 4 · 头一次撞验收判据的墙(不是打回重做):留痕、状态仍 running —— 给它一次机会(软化那半段)', async () => {
|
||||
const reason = '还不能提交放行:除「人放行」外还有 1 条判据没过 —— 「每条核心参数都有明确状态」[L2] 判据 …';
|
||||
const h = harness({
|
||||
parsed: AUTO_RELEASE,
|
||||
runtime: AUTO_RELEASE_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
acceptanceIssueRejects: reason,
|
||||
steps: [RELEASE_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('running');
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(h.listened).toHaveLength(0);
|
||||
const refused = h.appended.find((e) => e.title.startsWith('提交放行被拒'));
|
||||
expect(refused?.title).toContain('每条核心参数都有明确状态');
|
||||
});
|
||||
|
||||
it('Important 4 · 同一堵墙第二次(trail 里已有同一句拒回痕)→ 不再空转到 exhausted,发兜底执行工单交人、立监听', async () => {
|
||||
const reason = '还不能提交放行:除「人放行」外还有 1 条判据没过 —— 「每条核心参数都有明确状态」[L2] 判据 …';
|
||||
const h = harness({
|
||||
parsed: AUTO_RELEASE,
|
||||
runtime: AUTO_RELEASE_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
acceptanceIssueRejects: reason,
|
||||
// 模拟"上一拍已经在这堵墙上留过一条一模一样的痕"
|
||||
trail: [{ kind: 'result', title: `提交放行被拒:${reason}` }],
|
||||
steps: [RELEASE_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('awaiting');
|
||||
/* 发的是兜底的**执行工单**,不是 workOrder:'acceptance'(那正是刚撞过的那堵墙,再发一遍只会再撞一次) */
|
||||
expect(h.issued).toHaveLength(1);
|
||||
expect(h.issued[0]?.workOrder).toBe('execution');
|
||||
expect(String(h.issued[0]?.card['title'])).toContain('T_release');
|
||||
const basis = h.issued[0]?.card['basis'] as ReadonlyArray<{ k: string; v: string }>;
|
||||
expect(basis.find((b) => b.k === '这一步')?.v).toBe('T_release');
|
||||
expect(basis.find((b) => b.k === '拒回原话')?.v).toContain('每条核心参数都有明确状态');
|
||||
// 立了人事件监听,人一答就叫得醒(不是伪装成"等外部回音"却没人会来)
|
||||
expect(h.listened).toEqual([{ eventType: 'human' }]);
|
||||
});
|
||||
|
||||
it('Important 4 · 兜底执行工单自己也发不出去(如调人预算触顶):不抛、状态仍 awaiting、留痕两层原因', async () => {
|
||||
const reason = '调人预算已经触顶(3/3)';
|
||||
const h = harness({
|
||||
parsed: AUTO_RELEASE,
|
||||
runtime: AUTO_RELEASE_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
// issueRejects 对任何 workOrder 都抛 —— 验收卡(raiseAcceptance 内部)与
|
||||
// 兜底执行工单(下面新增的那一支)两次尝试都会撞上同一句话
|
||||
issueRejects: reason,
|
||||
trail: [{ kind: 'result', title: `提交放行被拒:${reason}` }],
|
||||
steps: [RELEASE_STEP],
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('awaiting');
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(h.listened).toHaveLength(0);
|
||||
const note = h.appended.find((e) => e.title.includes('连兜底卡都没能发出去'));
|
||||
expect(note).toBeDefined();
|
||||
expect(note?.title).toContain('调人预算已经触顶(3/3)');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **配方办不下去 = 交人,而「交人」得真有一张卡**(2026-08-29 终审 I1)。
|
||||
*
|
||||
* 从前失败只 `status: 'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡
|
||||
* `running` 的单:卡已答完、没立监听、没有时限,再没有任何东西会叫醒它。
|
||||
* 人在工作台上看到的是「等外部回音」,而外面没有任何回音会来。
|
||||
*/
|
||||
describe('auto 步 · 配方办不下去要给人一条路(I1)', () => {
|
||||
const AUTO_MODEL: CaseModel = {
|
||||
name: '一步的活动',
|
||||
autoComplete: false,
|
||||
items: [{ id: 'S1', name: '补数据', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
|
||||
sentries: [],
|
||||
} as never;
|
||||
const AUTO_RUNTIME: CaseRuntime = { items: [{ id: 'S1', state: 'Available' }] } as never;
|
||||
const BROKEN = {
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: { do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] },
|
||||
};
|
||||
|
||||
it('发一张卡给负责人 + 立人事件监听 —— 原话摆在卡上,不再伪装成「等外部回音」', async () => {
|
||||
const h = harness({ parsed: AUTO_MODEL, runtime: AUTO_RUNTIME, sfeelCondition: () => false, steps: [BROKEN] });
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('awaiting');
|
||||
expect(h.decided).toHaveLength(0);
|
||||
expect(h.issued).toHaveLength(1);
|
||||
expect(h.issued[0]?.workOrder).toBe('execution');
|
||||
expect(String(h.issued[0]?.card['title'])).toContain('配方办不下去');
|
||||
const basis = h.issued[0]?.card['basis'] as ReadonlyArray<{ k: string; v: string }>;
|
||||
expect(basis.find((b) => b.k === '这一步')?.v).toBe('S1');
|
||||
expect(basis.find((b) => b.k === '办不下去的原因')?.v).toContain('nope.nope');
|
||||
// 立了人事件监听,人一答就叫得醒
|
||||
expect(h.listened).toEqual([{ eventType: 'human' }]);
|
||||
});
|
||||
|
||||
it('这一单还有卡没交回时不重复发 —— 叫了人就等人(runTurn 开头那道门先把住)', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: AUTO_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
unanswered: [{ id: 'card-9', title: '配方办不下去:「S1」第 1 笔' }],
|
||||
steps: [BROKEN],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* **兜底卡自己也会抛 —— 精确复现 C3 那种无限热循环**(2026-08-29 复审 I1 二审)。
|
||||
*
|
||||
* `deps.cards.issue` 经 `human-tool-service.assertBudget`,调人预算触顶时抛
|
||||
* `HumanCallBudgetExceededError`。这一句从前**裸着**调:抛出去 `runTurn` 崩、
|
||||
* `engine.advance` / `task-service.tick` 都不捕获、`task-advancer` 只写 stderr 就
|
||||
* break,任务停在 `running`,下一个 2 秒 interval 再来一遍 —— 不烧预算、不换状态、
|
||||
* 界面纹丝不动。这条用例断言 `runTurn` **不抛**、状态落 `awaiting`、留了一条痕。
|
||||
*/
|
||||
it('连兜底卡都发不出去(如调人预算触顶):不抛、状态落 awaiting、留痕', async () => {
|
||||
const h = harness({
|
||||
parsed: AUTO_MODEL,
|
||||
runtime: AUTO_RUNTIME,
|
||||
sfeelCondition: () => false,
|
||||
steps: [BROKEN],
|
||||
issueRejects: '调人预算已经触顶(3/3)',
|
||||
});
|
||||
const out = await h.runner.runTurn(TASK);
|
||||
expect(out.status).toBe('awaiting');
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(h.listened).toHaveLength(0);
|
||||
const note = h.appended.find((e) => e.title.includes('连兜底卡都没能发出去'));
|
||||
expect(note).toBeDefined();
|
||||
expect(note?.title).toContain('调人预算已经触顶(3/3)');
|
||||
expect(String(note?.detail?.['hint'])).toContain('nope.nope');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **配方由 `liveStep` 驱动,不由 `current` 的兜底驱动**(2026-08-29 终审 I6)。
|
||||
*
|
||||
* `current` 有一条兜底(`liveStep ?? 在场的第一个有剧本的项`),它**不过滤 settledIds**。
|
||||
* 当初这只影响提示词内容(读错一段无非白费点上下文);配方接进来之后它驱动**副作用**:
|
||||
* 再写一遍台账、再 prune 一遍,carded 为假时甚至再发一张卡。
|
||||
*
|
||||
* 两者要分岔,得有一个项**既在 settledIds 里、又在场**。局面形状取自 `live-step.ts`
|
||||
* 那条注释描述的那一种:同一个 id 留着一份收掉的实例、又有一份重新入场的
|
||||
* (`publishEnabled` 的 `byId` 取后者 → 在场;`settledIds` 见到前者 → 已收掉)。
|
||||
* **注:这一形状在当前 lifecycle 里我没能构造出可达的来路**(`reenter` 是就地改状态、
|
||||
* 不留旧实例),所以这几条守的是**不变式**而不是复现过的事故 —— 见最终报告 I6 那一段。
|
||||
*/
|
||||
describe('auto 步 · 只认 liveStep(I6)', () => {
|
||||
const REPEAT_MODEL: CaseModel = {
|
||||
name: '收了还在场',
|
||||
autoComplete: false,
|
||||
items: [{ id: 'S1', name: '归一', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
|
||||
sentries: [],
|
||||
} as never;
|
||||
/** 收掉的那份实例 + 重新入场的那份 —— 「既已收掉又在场」 */
|
||||
const SETTLED_YET_ONSTAGE: CaseRuntime = {
|
||||
items: [
|
||||
{ id: 'S1', state: 'Completed', instance: 1 },
|
||||
{ id: 'S1', state: 'Enabled', instance: 2 },
|
||||
],
|
||||
} as never;
|
||||
const STEP = {
|
||||
item: 'S1',
|
||||
guide: '',
|
||||
writes: [],
|
||||
reads: [],
|
||||
tools: [],
|
||||
auto: {
|
||||
do: [
|
||||
{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } },
|
||||
{ kind: 'card', as: 'execution', title: '再来一张', basis: [{ k: 'a', v: 'b' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('既已收掉又在场的步:配方不跑 —— 不重写台账、不再发一张卡', async () => {
|
||||
const h = harness({ parsed: REPEAT_MODEL, runtime: SETTLED_YET_ONSTAGE, sfeelCondition: () => false, steps: [STEP] });
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.written).toHaveLength(0);
|
||||
expect(h.issued.some((i) => String(i.card['title']) === '再来一张')).toBe(false);
|
||||
});
|
||||
|
||||
it('同一个局面下,没收掉的那一步照跑 —— 这道门拦的是「已收掉」,不是「所有兜底」', async () => {
|
||||
const h = harness({
|
||||
parsed: REPEAT_MODEL,
|
||||
runtime: { items: [{ id: 'S1', state: 'Enabled', instance: 1 }] } as never,
|
||||
sfeelCondition: () => false,
|
||||
steps: [STEP],
|
||||
});
|
||||
await h.runner.runTurn(TASK);
|
||||
expect(h.written).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* **这份文件测的是什么、不测什么**(2026-08-29,评审二轮把靶子写准)。
|
||||
*
|
||||
* 测:**引擎逐 action 刷新时机**的回归 —— 一笔 `project` 若读了另一笔 `project` 才会撑出来的
|
||||
* 基座读数(`_missing` / `_rows` / `_distinct_` 这类),它必须排在被读的那几笔**之后**,否则读到
|
||||
* 的是这一拍刷新之前的旧数。这一条只有把真引擎(`createAutoRunner`)跑起来才测得出 ——
|
||||
* 纯结构断言看不出"次序对不对会不会导致某个值算错"。
|
||||
*
|
||||
* 不测:`case-model.ts` 里 `S1_inputs.auto.do` 六笔**当前逐字长什么样**。那件事已经由
|
||||
* `activities/part-substitution-assessment/__tests__/steps.test.ts` 的"六笔,次序是…"那条用例
|
||||
* 用精确数组相等钉在**真** `ASSESSMENT_STEPS` 上了。下面手写的六个 `ACT_*` 只是**在形状上有
|
||||
* 代表性**的样本(覆盖 S1 实际用到的四种地址写法:字面 `rows` / 单层 `each+where` / 两层
|
||||
* `each+where+outer` / `once`)——它们不随 `case-model.ts` 自动同步,也**不必**逐字同步:
|
||||
* 那是个没人能守的承诺。谁改了 S1 配方的**形状**(不是次序),该顺手核一遍这份文件还有没有
|
||||
* 代表性,欠账记在 `docs/debt.md`。
|
||||
*
|
||||
* **为什么手写这六笔,不直接 import 真配方**:`activities/*` 与 `apps/*` 互相不许 import
|
||||
* (`.dependency-cruiser.cjs` 的 `activities-isolated` / `apps-not-into-activities`,ADR-004)——
|
||||
* 测试文件不例外。这一条是真的躲不掉。
|
||||
*
|
||||
* **读数层四个函数不手写,直接 import 真身**(评审二轮指出的修正)——`rowReadings` /
|
||||
* `missingReadings` / `tallyReadings` / `distinctReadings` 是 `apps/api/src/app/wiring/engines.ts`
|
||||
* 的具名导出;本文件住在 `apps/api/src/modules/loop-engine/__tests__/`,import 它们是
|
||||
* **`apps` → `apps`**,`activities-isolated` 与 `apps-not-into-activities` 一条都不沾,
|
||||
* `modules/loop-engine` 也不会因此反向依赖 `app/wiring`(这份 import 只在测试文件里,
|
||||
* `depcruise apps packages activities` 的产出模块图会照实反映出来)。第一版误判"读数层也躲不掉"
|
||||
* 而手抄了一份,结果就在同一笔提交里漂了:`missingReadings` 拼缺项名的真身是
|
||||
* `gaps.map((f) => f.label ?? f.name)`(`engines.ts:378`),手抄件漏了 `label ?? ` 那半句 ——
|
||||
* 这个仓已经有四个既有测试文件在做同样的事(`apps/api/src/app/__tests__/{ledger-coerce,
|
||||
* decision-card-face,view-table-edit,acceptance-gate}.test.ts` 都 import 自 `../wiring/engines.ts`),
|
||||
* 抄反而是舍近求远。
|
||||
*
|
||||
* **路径走的是 `@app/*` 别名,不是相对路径**:那四个既有测试文件住在 `apps/api/src/app/__tests__/`,
|
||||
* 与 `engines.ts` 只隔一层(`../wiring/engines.ts`);本文件住在 `apps/api/src/modules/loop-engine/
|
||||
* __tests__/`,相对路径要爬三层(`../../../app/wiring/engines.ts`),撞上 `eslint.config.js` 的
|
||||
* `no-restricted-imports`(`'../../../*'` 一律拒,提示"跨模块请用路径别名")—— 仓里此前没有别名
|
||||
* 能到 `apps/api/src/app/*`(该目录只被同目录下的测试用一层相对路径引用过),所以新增了
|
||||
* `@app/*`(`tsconfig.base.json` + `vitest.config.ts` 各一行,与既有的 `@modules/*` / `@shared/*`
|
||||
* 同一种写法、同一种粒度)。取名 `@app` 而不撞现有的 pnpm 工作区包名 `@app/api` / `@app/web`——
|
||||
* 那两个名字只在 `package.json` scripts 里当 `pnpm --filter` 的过滤参数用,从未在任何 `.ts` 源码里
|
||||
* 被 `import` 过(已用 grep 核过),且 `apps/api/package.json` 的 `exports` 只暴露了 `"."` 这一个
|
||||
* 入口(整个 server 的 `main.ts`),没有暴露任何子路径,两套解析各管各的,不会互相截胡。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AutoRecipe } from '@contracts';
|
||||
import { taskIdSchema, staffIdSchema } from '@contracts';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import { distinctReadings, missingReadings, rowReadings, tallyReadings } from '@app/wiring/engines.ts';
|
||||
import { createAutoRunner } from '../internal/usecase/auto-runner.ts';
|
||||
|
||||
const TASK = taskIdSchema.parse('33333333-3333-4333-8333-333333333333');
|
||||
const OWNER = staffIdSchema.parse('44444444-4444-4444-8444-444444444444');
|
||||
|
||||
/*
|
||||
* **四张台账的字段元数据**(形状与 `manifest.ts` 一致,与真配方同款"镜像而非 import"取舍)——
|
||||
* 写法照抄 `apps/api/src/app/__tests__/ledger-coerce.test.ts` 里 `DECL` 那几个 fixture 的样子:
|
||||
* 不需要显式类型标注,直接把这份数据交给 import 进来的真函数,由调用点结构匹配。
|
||||
*/
|
||||
const DECLARED = [
|
||||
{
|
||||
domain: 'sub_eval',
|
||||
object: 'part',
|
||||
caseKey: 'eval_ref',
|
||||
fields: [
|
||||
{ name: 'eval_ref', primaryKey: true },
|
||||
{ name: 'internal_pn', primaryKey: true },
|
||||
{ name: 'role', primaryKey: false, required: true, label: '角色', tally: ['original', 'candidate'] },
|
||||
{ name: 'found_pn_ref', primaryKey: false, label: '在库核对依据', requiredWhen: { column: 'exists_status', value: 'found' } },
|
||||
{ name: 'exists_status', primaryKey: false, required: true, label: '在库状态', tally: ['found', 'absent'] },
|
||||
{ name: 'chip_type_id', primaryKey: false, required: true, label: '芯片类型', distinct: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: 'sub_eval',
|
||||
object: 'case',
|
||||
caseKey: 'eval_ref',
|
||||
fields: [
|
||||
{ name: 'eval_ref', primaryKey: true },
|
||||
{ name: 'chip_type_id', primaryKey: false },
|
||||
{ name: 'gate_reason', primaryKey: false },
|
||||
{ name: 'summary', primaryKey: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: 'parts',
|
||||
object: 'profile',
|
||||
fields: [
|
||||
{ name: 'internal_pn', primaryKey: true },
|
||||
{ name: 'chip_type_id', primaryKey: false, required: true, label: '芯片类型' },
|
||||
{ name: 'brand_name', primaryKey: false, required: true, label: '品牌名称' },
|
||||
{ name: 'lifecycle_status', primaryKey: false, required: true, label: '生命周期状态', tally: ['active', 'nrnd', 'eol', 'discontinued', 'risk'] },
|
||||
{ name: 'specification_description', primaryKey: false, expect: true, label: '规格描述' },
|
||||
{ name: 'manufacturer_name', primaryKey: false, expect: true, label: '制造商名称' },
|
||||
{ name: 'datasheet_ref', primaryKey: false, expect: true, label: 'Datasheet 引用' },
|
||||
{ name: 'datasheet_version', primaryKey: false, expect: true, label: 'Datasheet 版本' },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: 'parts',
|
||||
object: 'parameter',
|
||||
fields: [
|
||||
{ name: 'internal_pn', primaryKey: true },
|
||||
{ name: 'param_name', primaryKey: true },
|
||||
{ name: 'param_value', primaryKey: false, required: true, label: '值' },
|
||||
{ name: 'param_unit', primaryKey: false, expect: true, label: '单位' },
|
||||
{ name: 'param_note', primaryKey: false, expect: true, label: '说明' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 假端口 —— 手写的依赖注入替身(不是 mock 框架),`view()` 按生产 `taskData.view()` 同一份配方
|
||||
* 拼作用域:`{...seed, ...读数四件套(真函数), ...mine, ...flattened}`。这一层组合逻辑本身
|
||||
* (`engines.ts` 里内嵌在 `wireEngines(deps).taskData.view` 闭包中的那一段)没有被抽成可 import
|
||||
* 的纯函数、且与 `deps.ledger`/`deps.registry` 等真实 IO 端口绑在一起 —— 手写这部分组合与
|
||||
* `ACT_*` 六个常量是同一类"躲不掉的镜像"(纯读数计算才是真正能 import 的那一半)。
|
||||
*/
|
||||
function harness(seed: Record<string, unknown>) {
|
||||
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
|
||||
const cards: Array<Record<string, unknown>> = [];
|
||||
const notes: string[] = [];
|
||||
|
||||
const view = (): Record<string, unknown> => {
|
||||
const mine: Record<string, unknown[]> = {};
|
||||
for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch });
|
||||
/* 草稿字段展平到顶层(与生产 `taskData.view()` 同一条:`{in:'gate_reason'}` 靠它读到值) */
|
||||
const flattened: Record<string, unknown> = {};
|
||||
for (const d of drafts.values()) {
|
||||
for (const [field, value] of Object.entries({ ...d.key, ...d.patch })) {
|
||||
if (value !== null && value !== undefined) flattened[field] = value;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...seed,
|
||||
...rowReadings(mine, DECLARED),
|
||||
...missingReadings(mine, DECLARED),
|
||||
...tallyReadings(mine, DECLARED),
|
||||
...distinctReadings(mine, DECLARED),
|
||||
...mine,
|
||||
...flattened,
|
||||
};
|
||||
};
|
||||
|
||||
const deps = {
|
||||
data: {
|
||||
view: () => Promise.resolve(view()),
|
||||
writable: () =>
|
||||
Promise.resolve(
|
||||
DECLARED.map((d) => ({
|
||||
domain: d.domain,
|
||||
object: d.object,
|
||||
label: `${d.domain}.${d.object}`,
|
||||
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||
...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }),
|
||||
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name })),
|
||||
})),
|
||||
),
|
||||
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown>; skipUnchanged?: boolean; skipExisting?: boolean }) => {
|
||||
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||
const before = drafts.get(id);
|
||||
const changed = before === undefined || Object.entries(i.patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
|
||||
if (i.skipUnchanged === true && !changed) return Promise.resolve({ changed });
|
||||
drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: { ...(before?.patch ?? {}), ...i.patch } });
|
||||
return Promise.resolve({ changed });
|
||||
},
|
||||
draftDrop: (i: { domain: string; object: string; key: Record<string, string> }) => {
|
||||
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||
return Promise.resolve({ dropped: drafts.delete(id) });
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
issue: (i: Record<string, unknown>) => {
|
||||
cards.push(i);
|
||||
return Promise.resolve({ id: 'card-1' });
|
||||
},
|
||||
decide: () => Promise.reject(new Error('本用例不该发决策卡')),
|
||||
entry: () => Promise.reject(new Error('本用例不该发录入卡')),
|
||||
ask: () => Promise.reject(new Error('本用例不该发补全卡')),
|
||||
unanswered: () => Promise.resolve([]),
|
||||
},
|
||||
memory: {
|
||||
append: (m: { title: string }) => {
|
||||
notes.push(m.title);
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
|
||||
sfeel: {
|
||||
/* 真实 S-FEEL 求值器(与生产 `sfeel.condition` 同一条:异常按不成立处置) */
|
||||
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
|
||||
try {
|
||||
return evaluateCondition(expr, scope);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
expression: () => undefined,
|
||||
unaryTest: () => false,
|
||||
},
|
||||
};
|
||||
const ctx = { taskId: TASK, stepId: 'S1_inputs', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
|
||||
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 与 auto-runner.test.ts 同款既有做法
|
||||
return { runner: createAutoRunner(deps as never), drafts, cards, notes, ctx };
|
||||
}
|
||||
|
||||
/** 起单入参 + 台账种子:LM2596S-5.0 原本就在库且齐全,XL2596S-5.0 已归同一类型但一条参数都没录过 */
|
||||
function seedScenario(): Record<string, unknown> {
|
||||
return {
|
||||
originalInternalPn: 'LM2596S-5.0',
|
||||
candidateInternalPn: 'XL2596S-5.0',
|
||||
'case:openedAt': '2026-08-29T00:00:00.000Z',
|
||||
'parts.type_param': [{ type_id: 1, param_name: '输入电压范围', param_desc: '决定能否直接替换而不改外围', constraint_type: 'hard', param_unit: 'V' }],
|
||||
'parts.profile': [
|
||||
{ internal_pn: 'LM2596S-5.0', chip_type_id: 1, brand_name: 'TI', lifecycle_status: 'active', specification_description: '同步降压', manufacturer_name: 'TI', datasheet_ref: 'https://example.com/lm2596.pdf', datasheet_version: 'Rev-J' },
|
||||
/* candidate 的类型已经归好(chip_type_id=1)—— 与原厂同类型,才走得到 gate_reason 最深那层判据 */
|
||||
{ internal_pn: 'XL2596S-5.0', chip_type_id: 1, brand_name: 'XLSEMI', lifecycle_status: 'active', specification_description: '同步降压', manufacturer_name: 'XLSEMI', datasheet_ref: 'https://example.com/xl2596.pdf', datasheet_version: 'Rev-1' },
|
||||
],
|
||||
/* candidate 一条参数都没有 —— 铺骨架那一笔跑之前,mine:parts.parameter 里压根没有它的行 */
|
||||
'parts.parameter': [{ internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5V~40V', param_unit: 'V', param_note: '' }],
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* **六笔,在形状上代表 `S1_inputs.auto` —— 覆盖它用到的四种地址写法**(字面 rows / 单层
|
||||
* each+where / 两层 each+where+outer / once),不是逐字同步的承诺(见文件头注)。
|
||||
*/
|
||||
const ACT_PART = {
|
||||
kind: 'project',
|
||||
into: 'sub_eval.part',
|
||||
from: {
|
||||
rows: [
|
||||
{ pn: { in: 'originalInternalPn' }, role: 'original' },
|
||||
{ pn: { in: 'candidateInternalPn' }, role: 'candidate' },
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
eval_ref: { in: 'evalRef' },
|
||||
internal_pn: { col: 'pn' },
|
||||
role: { col: 'role' },
|
||||
found_pn_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'internal_pn', else: '' },
|
||||
exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' },
|
||||
chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id', else: '' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const ACT_PROFILE = {
|
||||
kind: 'project',
|
||||
into: 'parts.profile',
|
||||
prune: true,
|
||||
from: { each: 'mine:sub_eval.part' },
|
||||
fields: {
|
||||
internal_pn: { col: 'internal_pn' },
|
||||
chip_type_id: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'chip_type_id', else: '' },
|
||||
brand_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'brand_name', else: '' },
|
||||
lifecycle_status: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'lifecycle_status', else: '' },
|
||||
specification_description: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'specification_description', else: '' },
|
||||
manufacturer_name: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'manufacturer_name', else: '' },
|
||||
datasheet_ref: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_ref', else: '' },
|
||||
datasheet_version: { lookup: 'parts.profile', by: { internal_pn: { col: 'internal_pn' } }, take: 'datasheet_version', else: '' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const ACT_PARAMETER = {
|
||||
kind: 'project',
|
||||
into: 'parts.parameter',
|
||||
prune: true,
|
||||
from: { each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } },
|
||||
fields: {
|
||||
internal_pn: { outer: 'internal_pn' },
|
||||
param_name: { col: 'param_name' },
|
||||
param_unit: { col: 'param_unit' },
|
||||
param_value: { lookup: 'parts.parameter', by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
|
||||
param_note: { lookup: 'parts.parameter', by: { internal_pn: { outer: 'internal_pn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const ACT_CASE = {
|
||||
kind: 'project',
|
||||
into: 'sub_eval.case',
|
||||
from: { once: true },
|
||||
fields: {
|
||||
eval_ref: { in: 'evalRef' },
|
||||
chip_type_id: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'chip_type_id', else: '' },
|
||||
gate_reason: {
|
||||
when: 'sub_eval_part_rows < 2',
|
||||
then: '原料号与候选料号还没填全 —— 在下面把两格补上。',
|
||||
else: {
|
||||
when: 'sub_eval_part_exists_status_absent > 0',
|
||||
then: '有料号库里查不到 —— 在下面把它的主数据建起来。',
|
||||
else: {
|
||||
when: 'sub_eval_part_missing > 0',
|
||||
then: '有料号还没归芯片类型 —— 在下面给它选一个;没有合适的就新建一个,并把它的核心参数字段一并定下来。',
|
||||
else: {
|
||||
when: 'sub_eval_part_distinct_chip_type_id > 1',
|
||||
then: '两颗料不是同一个芯片类型,逐项比对无从谈起 —— 换一颗料,或者把归错的那颗类型改对。',
|
||||
else: {
|
||||
when: 'parts_profile_missing + parts_parameter_missing > 0',
|
||||
then: '还有必填格空着 —— 红带点名的那几格填上就能提交。',
|
||||
else: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const ACT_CARD = {
|
||||
kind: 'card',
|
||||
when: 'sub_eval_part_exists_status_found < 2 or sub_eval_part_missing > 0 or sub_eval_part_distinct_chip_type_id > 1 or parts_profile_missing > 0 or parts_parameter_missing > 0',
|
||||
as: 'execution',
|
||||
variant: 'data',
|
||||
title: '数据工单 · 差异评估的输入还差几格',
|
||||
basis: [
|
||||
{ k: '原料号', v: { in: 'originalInternalPn' } },
|
||||
{ k: '候选料号', v: { in: 'candidateInternalPn' } },
|
||||
{ k: '还差什么', v: { in: 'gate_reason' } },
|
||||
],
|
||||
} as const;
|
||||
|
||||
const ACT_SAY = { kind: 'say', text: '已核对两颗料的在库状态与参数齐备情况。' } as const;
|
||||
|
||||
/** 修复后的正确次序(与 `case-model.ts` 当前的 `S1_inputs.auto.do` 一致) */
|
||||
const FIXED_ORDER: AutoRecipe = {
|
||||
let: { evalRef: 'W05-20260829000000' },
|
||||
do: [ACT_PART, ACT_PROFILE, ACT_PARAMETER, ACT_CASE, ACT_CARD, ACT_SAY],
|
||||
};
|
||||
|
||||
describe('S1 配方次序:gate_reason 必须读到这一拍刷新之后的缺口读数', () => {
|
||||
it('新次序(part→profile→parameter→case→card→say):gate_reason 读到真实缺口,不是空串', async () => {
|
||||
const { runner, drafts, cards, ctx } = harness(seedScenario());
|
||||
const out = await runner.run(FIXED_ORDER, ctx);
|
||||
|
||||
// 卡确实发了出去(与旧次序下的行为一致 —— 收步与发卡的把关本身没坏)
|
||||
expect(out.issuedCardId).toBe('card-1');
|
||||
expect(cards).toHaveLength(1);
|
||||
|
||||
// 案卷里存的 gate_reason:必须非空、且点到真实缺口(不是「一切正常」的假象)
|
||||
const caseRow = [...drafts.values()].find((d) => d.object === 'case');
|
||||
expect(caseRow?.patch['gate_reason']).toBe('还有必填格空着 —— 红带点名的那几格填上就能提交。');
|
||||
|
||||
// 卡面 basis 里「还差什么」那一格 —— 评审报告里点名的那个空字符串,这里必须不是空的
|
||||
const card = cards[0];
|
||||
const basis = (card?.['card'] as { basis?: Array<{ k: string; v: string }> } | undefined)?.basis ?? [];
|
||||
const gapLine = basis.find((b) => b.k === '还差什么');
|
||||
expect(gapLine?.v).toBe('还有必填格空着 —— 红带点名的那几格填上就能提交。');
|
||||
expect(gapLine?.v).not.toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* **这份文件测的是什么**(2026-08-29,评审 Critical 逼出来的执行级回归)。
|
||||
*
|
||||
* `S3_review.auto` 复用 S2 算好的两笔投影(`RELATION_PROJECTION`/`VALIDATION_PROJECTION`,
|
||||
* `case-model.ts`)—— 它们的主键 / 外键全靠 `{in:'relationId'}`/`{in:'evalRef'}`/
|
||||
* `{in:'originalPn'}`/`{in:'candidatePn'}`/`{in:'typeId'}` 这几个名字,而这几个名字**只在
|
||||
* `let` 里绑定**。`createAutoRunner.run()` 每次调用都从**它收到的那份** `recipe.let` 现算一遍
|
||||
* `lets`(`auto-runner.ts:93-97`)——步骤之间不传递 `let`。S3 若没有自己的 `let`,这几个名字
|
||||
* 在 S3 那一拍就是未绑定,`{in:X}` 退回 `''`(`auto-expr.ts:44-47`)。后果两条:
|
||||
* ① 关系表 `substitution_relation_id` 算成空串 → 整笔写被「主键算不出来先不写」悄悄丢掉 ——
|
||||
* **人在审核件上改了四态,那一行永远不会更新**,这正是 S3 存在的全部理由;
|
||||
* ② 验证记录的主键 `cat:['VAL-', {in:'evalRef'}]` 反而拼出非空的 `'VAL-'`,被当成一个新主键
|
||||
* 写出**第二行垃圾**,破了「P-06 一单固定一条」的不变量。
|
||||
* (① ② 两条已修:S3 有了自己的 `let`,见 `S3_LET_MIRROR`/`S3_RECIPE`。)
|
||||
*
|
||||
* **2026-08-30 task-12 加第二条覆盖,同一份文件、同一套机制,但是另一层缺陷**(浏览器走查
|
||||
* 发现⑤,`docs/superpowers/specs/.../progress.md` H 系列 ruling 之后的那条):①②修好之后,
|
||||
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION` 的主键与外键**绑得上了**,可它们算结论用的
|
||||
* `substitution_difference_item_judgement_class_*` tally 读的是**判定分类**列,不是四态列 ——
|
||||
* 人在审核件上把某一行的 `difference_status` 从 `mismatch` 改成 `match` 并保存,
|
||||
* **没有任何一笔把这个新值翻译成 `judgement_class`**,tally 纹丝不动,两笔投影算出同样的结论。
|
||||
* 件上白纸黑字承诺「你改动下表里任何一条四态,这五项当场重算」——这句话曾经是假的。
|
||||
*
|
||||
* **这份文件原本的 `HUMAN_EDIT` 恰好把这层缺陷藏起来了**:它在同一笔 `project` 里**同时**
|
||||
* 写了 `difference_status`(`{col:'status'}`)**和** `judgement_class`(`{col:'jclass'}`)——
|
||||
* 等于替被测系统把「四态 → 判定分类」的翻译**预先做完**,断言看到的「结论正确重算」其实是
|
||||
* 这笔 setup 自己写对的,不是 S3 配方推出来的。人在真实审核件上**只能改
|
||||
* `difference_status`/`impact_statement`/`evidence_note`/`unknown_disposition` 这几列**
|
||||
* (`difference-review` 目标件的 `data-edit` 绑定只挂了这几个 `data-col`)——`judgement_class`
|
||||
* 从来不在人能碰的字段里。现在的 `HUMAN_EDIT` 已改为只写 `difference_status` + `evidence_note`
|
||||
* 两列,与真实的就地编辑同源;`S3_RECIPE` 补上 `RECOMPUTE_MIRROR`(镜像 S2/S3 新共用的
|
||||
* `DIFFERENCE_ITEM_RECOMPUTE`),让「翻译」这一步真的在被测流程里发生一遍。
|
||||
*
|
||||
* 只有把真引擎(`createAutoRunner`)跑起来才测得出 ——
|
||||
* `activities/part-substitution-assessment/__tests__/steps.test.ts` 的用例全是结构断言
|
||||
* (查 `kind`、查 `into`、`toBe` 引用相等),从来没有对配方求过值,漏不出这一类"名字没绑上"
|
||||
* 或"两张表的值没对齐"的洞。
|
||||
*
|
||||
* **手写镜像,不 import 真配方** —— 与 `w05-gate-reason-order.test.ts` 同一条理由:
|
||||
* `activities/*` 与 `apps/*` 互相不许 import(ADR-004,`.dependency-cruiser.cjs` 的
|
||||
* `activities-isolated` / `apps-not-into-activities`)。
|
||||
* `REL_MIRROR`/`VAL_MIRROR`/`S3_LET_MIRROR`/`JUDGEMENT_FIELDS_MIRROR`/`RECOMPUTE_MIRROR`
|
||||
* 逐字取自 `case-model.ts` 当前的
|
||||
* `RELATION_PROJECTION`/`VALIDATION_PROJECTION`/`SHARED_LET`/`JUDGEMENT_DERIVED_FIELDS`/
|
||||
* `DIFFERENCE_ITEM_RECOMPUTE` ——在形状上代表,不是逐字同步的承诺(谁改了这五者的**形状**,
|
||||
* 该顺手核一遍这份文件还有没有代表性,欠账记在 `docs/debt.md`,与 S1 那份镜像同一条纪律)。
|
||||
*
|
||||
* **读数层四个函数直接 import 真身**,理由与路径别名 `@app/*` 同 `w05-gate-reason-order.test.ts`
|
||||
* 头注:`apps/api/src/modules/loop-engine/__tests__/` import `apps/api/src/app/wiring/engines.ts`
|
||||
* 是 `apps → apps`,两条隔离规则都不沾。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AutoAction, AutoRecipe } from '@contracts';
|
||||
import { taskIdSchema, staffIdSchema } from '@contracts';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import { distinctReadings, missingReadings, rowReadings, tallyReadings } from '@app/wiring/engines.ts';
|
||||
import { createAutoRunner } from '../internal/usecase/auto-runner.ts';
|
||||
|
||||
const TASK = taskIdSchema.parse('55555555-5555-4555-8555-555555555555');
|
||||
const OWNER = staffIdSchema.parse('66666666-6666-4666-8666-666666666666');
|
||||
|
||||
/** 五张台账的字段元数据 —— 写法与 `w05-gate-reason-order.test.ts` 的 `DECLARED` 同款镜像取舍。 */
|
||||
const DECLARED = [
|
||||
{
|
||||
domain: 'sub_eval',
|
||||
object: 'case',
|
||||
caseKey: 'eval_ref',
|
||||
fields: [
|
||||
{ name: 'eval_ref', primaryKey: true },
|
||||
{ name: 'chip_type_id', primaryKey: false },
|
||||
{ name: 'gate_reason', primaryKey: false },
|
||||
{ name: 'summary', primaryKey: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: 'sub_eval',
|
||||
object: 'part',
|
||||
caseKey: 'eval_ref',
|
||||
fields: [
|
||||
{ name: 'eval_ref', primaryKey: true },
|
||||
{ name: 'internal_pn', primaryKey: true },
|
||||
{ name: 'role', primaryKey: false, required: true, label: '角色', tally: ['original', 'candidate'] },
|
||||
{ name: 'found_pn_ref', primaryKey: false, label: '在库核对依据' },
|
||||
{ name: 'exists_status', primaryKey: false, required: true, label: '在库状态', tally: ['found', 'absent'] },
|
||||
{ name: 'chip_type_id', primaryKey: false, required: true, label: '芯片类型', distinct: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: 'substitution',
|
||||
object: 'relation',
|
||||
fields: [
|
||||
{ name: 'substitution_relation_id', primaryKey: true },
|
||||
{ name: 'eval_ref', primaryKey: false, required: true, label: '评估案号' },
|
||||
{ name: 'original_internal_pn', primaryKey: false, required: true, label: '原内部料号' },
|
||||
{ name: 'candidate_internal_pn', primaryKey: false, required: true, label: '候选内部料号' },
|
||||
{ name: 'chip_type_id', primaryKey: false, required: true, label: '芯片类型' },
|
||||
{ name: 'recommendation_level', primaryKey: false, required: true, label: '推荐等级', tally: ['recommended', 'conditional', 'not_recommended'] },
|
||||
{ name: 'recommendation_reason', primaryKey: false, required: true, label: '推荐理由' },
|
||||
{ name: 'difference_summary', primaryKey: false, required: true, label: '差异摘要' },
|
||||
{ name: 'risk_level', primaryKey: false, required: true, label: '替代风险等级', tally: ['low', 'medium', 'high'] },
|
||||
{ name: 'verification_requirement', primaryKey: false, label: '验证要求' },
|
||||
{ name: 'validation_id', primaryKey: false, label: '关联验证ID' },
|
||||
{ name: 'analysis_status', primaryKey: false, required: true, label: '分析状态' },
|
||||
{ name: 'rule_version', primaryKey: false, required: true, label: '差异规则版本' },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: 'substitution',
|
||||
object: 'difference_item',
|
||||
fields: [
|
||||
{ name: 'substitution_relation_id', primaryKey: true, distinct: true, label: '替代关系ID' },
|
||||
{ name: 'difference_item_id', primaryKey: true },
|
||||
{ name: 'parameter_name', primaryKey: false, required: true, label: '标准参数名' },
|
||||
{ name: 'parameter_desc', primaryKey: false, expect: true, label: '参数说明' },
|
||||
{ name: 'constraint_type', primaryKey: false, required: true, label: '约束类型', tally: ['hard', 'soft'] },
|
||||
{ name: 'original_value', primaryKey: false, required: true, label: '原物料参数值' },
|
||||
{ name: 'candidate_value', primaryKey: false, required: true, label: '候选物料参数值' },
|
||||
{ name: 'parameter_unit', primaryKey: false, expect: true, label: '参数单位' },
|
||||
{ name: 'difference_status', primaryKey: false, required: true, label: '差异状态', tally: ['match', 'mismatch', 'unknown', 'conditional'] },
|
||||
{ name: 'judgement_class', primaryKey: false, required: true, label: '判定分类', tally: ['match', 'soft_mismatch', 'hard_mismatch', 'unknown', 'conditional'] },
|
||||
{ name: 'impact_statement', primaryKey: false, required: true, label: '影响说明' },
|
||||
{ name: 'evidence_note', primaryKey: false, required: true, label: '判断依据' },
|
||||
{ name: 'unknown_disposition', primaryKey: false, label: 'UNKNOWN 去向' },
|
||||
{ name: 'risk_level', primaryKey: false, required: true, label: '风险等级', tally: ['low', 'medium', 'high'] },
|
||||
{ name: 'rule_version', primaryKey: false, required: true, label: '差异规则版本' },
|
||||
],
|
||||
},
|
||||
{
|
||||
domain: 'validation',
|
||||
object: 'record',
|
||||
fields: [
|
||||
{ name: 'validation_id', primaryKey: true },
|
||||
{ name: 'eval_ref', primaryKey: false, required: true, label: '评估案号' },
|
||||
{ name: 'validation_internal_pn', primaryKey: false, required: true, label: '验证内部料号' },
|
||||
{ name: 'validation_type', primaryKey: false, required: true, label: '验证类型', tally: ['pin_to_pin', 'functional', 'reliability', 'certification', 'retest', 'parametric'] },
|
||||
{ name: 'validation_status', primaryKey: false, required: true, label: '验证状态' },
|
||||
{ name: 'test_condition', primaryKey: false, expect: true, label: '测试条件' },
|
||||
{ name: 'next_validation_action', primaryKey: false, required: true, label: '后续验证动作' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 假端口 —— 手写依赖注入替身,`view()` 拼作用域的配方与 `w05-gate-reason-order.test.ts` 一致。 */
|
||||
function harness() {
|
||||
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
|
||||
const cards: Array<Record<string, unknown>> = [];
|
||||
const notes: string[] = [];
|
||||
|
||||
const view = (): Record<string, unknown> => {
|
||||
const mine: Record<string, unknown[]> = {};
|
||||
for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch });
|
||||
const flattened: Record<string, unknown> = {};
|
||||
for (const d of drafts.values()) {
|
||||
for (const [field, value] of Object.entries({ ...d.key, ...d.patch })) {
|
||||
if (value !== null && value !== undefined) flattened[field] = value;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...rowReadings(mine, DECLARED),
|
||||
...missingReadings(mine, DECLARED),
|
||||
...tallyReadings(mine, DECLARED),
|
||||
...distinctReadings(mine, DECLARED),
|
||||
...mine,
|
||||
...flattened,
|
||||
};
|
||||
};
|
||||
|
||||
const deps = {
|
||||
data: {
|
||||
view: () => Promise.resolve(view()),
|
||||
writable: () =>
|
||||
Promise.resolve(
|
||||
DECLARED.map((d) => ({
|
||||
domain: d.domain,
|
||||
object: d.object,
|
||||
label: `${d.domain}.${d.object}`,
|
||||
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
|
||||
...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }),
|
||||
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name })),
|
||||
})),
|
||||
),
|
||||
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown>; skipUnchanged?: boolean; skipExisting?: boolean }) => {
|
||||
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||
const before = drafts.get(id);
|
||||
const changed = before === undefined || Object.entries(i.patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v));
|
||||
if (i.skipUnchanged === true && !changed) return Promise.resolve({ changed });
|
||||
drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: { ...(before?.patch ?? {}), ...i.patch } });
|
||||
return Promise.resolve({ changed });
|
||||
},
|
||||
draftDrop: (i: { domain: string; object: string; key: Record<string, string> }) => {
|
||||
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
||||
return Promise.resolve({ dropped: drafts.delete(id) });
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
issue: () => Promise.reject(new Error('本用例不该发执行工单')),
|
||||
decide: () => Promise.reject(new Error('quiet 拍不该发决策卡 —— 卡这一笔该被跳过')),
|
||||
entry: () => Promise.reject(new Error('本用例不该发录入卡')),
|
||||
ask: () => Promise.reject(new Error('本用例不该发补全卡')),
|
||||
unanswered: () => Promise.resolve([]),
|
||||
},
|
||||
memory: {
|
||||
append: (m: { title: string }) => {
|
||||
notes.push(m.title);
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
|
||||
sfeel: {
|
||||
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
|
||||
try {
|
||||
return evaluateCondition(expr, scope);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
expression: () => undefined,
|
||||
unaryTest: () => false,
|
||||
},
|
||||
};
|
||||
const ctx = { taskId: TASK, stepId: 'S3_review', ownerId: OWNER, humanCallCap: 8, quiet: true, carded: true };
|
||||
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 与 auto-runner.test.ts 同款既有做法
|
||||
return { runner: createAutoRunner(deps as never), drafts, cards, notes, ctx };
|
||||
}
|
||||
|
||||
const EVAL_REF = 'W05-20260829000000';
|
||||
const RELATION_ID = `SUB-${EVAL_REF}`;
|
||||
const VALIDATION_ID = `VAL-${EVAL_REF}`;
|
||||
|
||||
/**
|
||||
* 阶段一(setup,镜像 S1+S2 已经跑完的样子):两颗料在库、两条差异明细都判 `match`、
|
||||
* 关系表的初版结论是 `recommended`、验证记录已经落了一行 `not_started`。
|
||||
*/
|
||||
const SETUP_S1S2: AutoRecipe = {
|
||||
do: [
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'sub_eval.case',
|
||||
from: { once: true },
|
||||
fields: { eval_ref: EVAL_REF, chip_type_id: 1, gate_reason: '', summary: '' },
|
||||
},
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'sub_eval.part',
|
||||
from: {
|
||||
rows: [
|
||||
{ pn: 'LM2596S-5.0', role: 'original' },
|
||||
{ pn: 'XL2596S-5.0', role: 'candidate' },
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
eval_ref: EVAL_REF,
|
||||
internal_pn: { col: 'pn' },
|
||||
role: { col: 'role' },
|
||||
found_pn_ref: { col: 'pn' },
|
||||
exists_status: 'found',
|
||||
chip_type_id: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'substitution.difference_item',
|
||||
from: {
|
||||
rows: [
|
||||
{
|
||||
item_id: `${RELATION_ID}-输入电压范围`, name: '输入电压范围', desc: '决定能否直接替换而不改外围',
|
||||
status: 'match', jclass: 'match', orig: '4.5V~40V', cand: '4.5V~40V', unit: 'V', note: '范围相同', risk: 'low',
|
||||
},
|
||||
{
|
||||
item_id: `${RELATION_ID}-静态电流`, name: '静态电流', desc: '影响待机功耗',
|
||||
status: 'match', jclass: 'match', orig: '50uA', cand: '50uA', unit: 'uA', note: '数值相同', risk: 'low',
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
substitution_relation_id: RELATION_ID,
|
||||
difference_item_id: { col: 'item_id' },
|
||||
parameter_name: { col: 'name' },
|
||||
parameter_desc: { col: 'desc' },
|
||||
constraint_type: 'hard',
|
||||
original_value: { col: 'orig' },
|
||||
candidate_value: { col: 'cand' },
|
||||
parameter_unit: { col: 'unit' },
|
||||
difference_status: { col: 'status' },
|
||||
judgement_class: { col: 'jclass' },
|
||||
impact_statement: '无',
|
||||
evidence_note: { col: 'note' },
|
||||
unknown_disposition: '',
|
||||
risk_level: { col: 'risk' },
|
||||
rule_version: 'R-TEST',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'substitution.relation',
|
||||
from: { once: true },
|
||||
fields: {
|
||||
substitution_relation_id: RELATION_ID,
|
||||
eval_ref: EVAL_REF,
|
||||
original_internal_pn: 'LM2596S-5.0',
|
||||
candidate_internal_pn: 'XL2596S-5.0',
|
||||
chip_type_id: 1,
|
||||
recommendation_level: 'recommended',
|
||||
recommendation_reason: '每一项核心参数都判为一致,没有待确认项,可直接替代。',
|
||||
difference_summary: '共 2 项核心参数:2 项一致 / 0 项硬约束不符 / 0 项软约束不符 / 0 项待确认 / 0 项有条件。',
|
||||
risk_level: 'low',
|
||||
verification_requirement: '',
|
||||
validation_id: VALIDATION_ID,
|
||||
analysis_status: 'fae_confirmed',
|
||||
rule_version: 'R-TEST',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'validation.record',
|
||||
from: { once: true },
|
||||
fields: {
|
||||
validation_id: VALIDATION_ID,
|
||||
eval_ref: EVAL_REF,
|
||||
validation_internal_pn: 'XL2596S-5.0',
|
||||
validation_type: 'parametric',
|
||||
validation_status: 'not_started',
|
||||
test_condition: '无',
|
||||
next_validation_action: '本轮无待验项。',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 阶段二(setup,镜像人在审核件上就地改了一格):把「静态电流」那一行的四态从 `match`
|
||||
* 改成 `mismatch`(审核人对着候选规格书发现待机功耗超预算,原厂的硬约束没扛住),
|
||||
* 并顺手把判断依据写清楚。
|
||||
*
|
||||
* **只写人在审核件上真正碰得到的那几列**(2026-08-30 task-12 修正,见文件头注)——
|
||||
* `difference_status` + `evidence_note`。**绝不写 `judgement_class`/`risk_level`**:
|
||||
* 那是 S3 配方该重算的两列,人手上的表单压根没有这两格(`difference-review` 目标件的
|
||||
* `data-edit` 绑定只挂了 `difference_status`/`impact_statement`/`evidence_note`/
|
||||
* `unknown_disposition` 四个 `data-col`)。写它们等于替被测系统预先把「四态 → 判定分类」
|
||||
* 的翻译做完,会让本用例测不出「S3 到底有没有真的重算」这件事本身 ——
|
||||
* 这正是这份文件原本的写法把 task-12 那层缺陷藏起来的原因。
|
||||
*
|
||||
* 与真实的就地编辑同源:只改这一行,不碰另一行,不用 `prune`。
|
||||
*/
|
||||
const HUMAN_EDIT: AutoRecipe = {
|
||||
do: [
|
||||
{
|
||||
kind: 'project',
|
||||
into: 'substitution.difference_item',
|
||||
from: {
|
||||
rows: [
|
||||
{
|
||||
item_id: `${RELATION_ID}-静态电流`,
|
||||
status: 'mismatch',
|
||||
note: '审核人核对候选规格书 §6.2 实测值,静态电流是原厂 4 倍,超待机功耗预算',
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
substitution_relation_id: RELATION_ID,
|
||||
difference_item_id: { col: 'item_id' },
|
||||
difference_status: { col: 'status' },
|
||||
evidence_note: { col: 'note' },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* `JUDGEMENT_DERIVED_FIELDS` 的镜像 —— 逐字取自 `case-model.ts` 当前的常量。
|
||||
* S2 的差异明细创建笔与 S3 的重算笔(`RECOMPUTE_MIRROR`,下面)共用同一份 ——
|
||||
* 判定规则只许有一处真源,各写一份早晚分叉。
|
||||
*/
|
||||
const JUDGEMENT_FIELDS_MIRROR = {
|
||||
judgement_class: {
|
||||
when: 'difference_status = "match"', then: 'match',
|
||||
else: {
|
||||
when: 'difference_status = "unknown"', then: 'unknown',
|
||||
else: {
|
||||
when: 'difference_status = "conditional"', then: 'conditional',
|
||||
else: { when: 'constraint_type = "hard"', then: 'hard_mismatch', else: 'soft_mismatch' },
|
||||
},
|
||||
},
|
||||
},
|
||||
risk_level: {
|
||||
when: 'judgement_class = "hard_mismatch"', then: 'high',
|
||||
else: { when: 'judgement_class = "match"', then: 'low', else: 'medium' },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `DIFFERENCE_ITEM_RECOMPUTE` 的镜像 —— **这是本用例要钉的核心新增笔**(2026-08-30
|
||||
* task-12):S3 若没有这一笔(或它排在 `REL_MIRROR`/`VAL_MIRROR` 之后),人改的
|
||||
* `difference_status` 就没有任何桥转成 `judgement_class`,两笔投影读到的 tally 纹丝不动 ——
|
||||
* 这正是「结论纹丝不动」那条要害缺陷的机制。只列主键 + 两个派生列,不碰
|
||||
* `difference_status` 自己、也不碰人可以改的叙事列(`impact_statement`/`evidence_note`/
|
||||
* `unknown_disposition`)。
|
||||
*/
|
||||
const RECOMPUTE_MIRROR: AutoAction = {
|
||||
kind: 'project',
|
||||
into: 'substitution.difference_item',
|
||||
from: { each: 'mine:substitution.difference_item' },
|
||||
fields: {
|
||||
substitution_relation_id: { col: 'substitution_relation_id' },
|
||||
difference_item_id: { col: 'difference_item_id' },
|
||||
...JUDGEMENT_FIELDS_MIRROR,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `RELATION_PROJECTION` 的镜像 —— 逐字取自 `case-model.ts` 当前的常量(不代表逐字同步承诺,
|
||||
* 见文件头注)。**主键 / 外键全靠 `let` 绑定的名字**,这正是这份测试要钉住的那一段。
|
||||
*/
|
||||
const REL_MIRROR: AutoAction = {
|
||||
kind: 'project',
|
||||
into: 'substitution.relation',
|
||||
from: { once: true },
|
||||
fields: {
|
||||
substitution_relation_id: { in: 'relationId' },
|
||||
eval_ref: { in: 'evalRef' },
|
||||
original_internal_pn: { in: 'originalPn' },
|
||||
candidate_internal_pn: { in: 'candidatePn' },
|
||||
chip_type_id: { in: 'typeId' },
|
||||
recommendation_level: {
|
||||
when: 'substitution_difference_item_judgement_class_hard_mismatch > 0', then: 'not_recommended',
|
||||
else: {
|
||||
when: 'substitution_difference_item_judgement_class_unknown > 0', then: 'conditional',
|
||||
else: {
|
||||
when: 'substitution_difference_item_judgement_class_conditional > 0', then: 'conditional',
|
||||
else: { when: 'substitution_difference_item_judgement_class_soft_mismatch > 0', then: 'conditional', else: 'recommended' },
|
||||
},
|
||||
},
|
||||
},
|
||||
risk_level: {
|
||||
when: 'recommendation_level = "not_recommended"', then: 'high',
|
||||
else: { when: 'recommendation_level = "recommended"', then: 'low', else: 'medium' },
|
||||
},
|
||||
difference_summary: {
|
||||
cat: [
|
||||
'共 ', { in: 'substitution_difference_item_rows' }, ' 项核心参数:',
|
||||
{ in: 'substitution_difference_item_judgement_class_match' }, ' 项一致 / ',
|
||||
{ in: 'substitution_difference_item_judgement_class_hard_mismatch' }, ' 项硬约束不符 / ',
|
||||
{ in: 'substitution_difference_item_judgement_class_soft_mismatch' }, ' 项软约束不符 / ',
|
||||
{ in: 'substitution_difference_item_judgement_class_unknown' }, ' 项待确认 / ',
|
||||
{ in: 'substitution_difference_item_judgement_class_conditional' }, ' 项有条件。',
|
||||
],
|
||||
},
|
||||
recommendation_reason: {
|
||||
when: 'recommendation_level = "not_recommended"',
|
||||
then: '有硬约束不满足 —— 逐条见差异明细里判为 MISMATCH 且约束类型为硬的那几行;硬性边界不满足,不因措辞改判。',
|
||||
else: {
|
||||
when: 'recommendation_level = "recommended"',
|
||||
then: '每一项核心参数都判为一致,没有待确认项,可直接替代。',
|
||||
else: '没有硬约束不满足,但仍有待确认或需附条件的项 —— 逐条见差异明细;闭环之后方可采用。',
|
||||
},
|
||||
},
|
||||
verification_requirement: {
|
||||
when: 'recommendation_level = "conditional"',
|
||||
then: '实测差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,并把实测值回填 P-08 标准化参数集;回填后重跑本评估。',
|
||||
else: '',
|
||||
},
|
||||
validation_id: { cat: ['VAL-', { in: 'evalRef' }] },
|
||||
analysis_status: 'fae_confirmed',
|
||||
rule_version: 'R-TEST',
|
||||
},
|
||||
};
|
||||
|
||||
/** `VALIDATION_PROJECTION` 的镜像 —— 同上取舍;这是拼出 `'VAL-'` 垃圾行那半个 bug 的主角。 */
|
||||
const VAL_MIRROR: AutoAction = {
|
||||
kind: 'project',
|
||||
into: 'validation.record',
|
||||
from: { once: true },
|
||||
fields: {
|
||||
validation_id: { cat: ['VAL-', { in: 'evalRef' }] },
|
||||
eval_ref: { in: 'evalRef' },
|
||||
validation_internal_pn: { in: 'candidatePn' },
|
||||
validation_type: 'parametric',
|
||||
validation_status: 'not_started',
|
||||
test_condition: {
|
||||
when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0',
|
||||
then: '差异明细里判为 UNKNOWN 与 CONDITIONAL 的每一项,按原厂规格书条件实测。',
|
||||
else: '无',
|
||||
},
|
||||
next_validation_action: {
|
||||
when: 'substitution_difference_item_judgement_class_unknown + substitution_difference_item_judgement_class_conditional > 0',
|
||||
then: '实测上述各项并把实测值回填 P-08 标准化参数集,回填后重跑本评估。',
|
||||
else: '本轮无待验项。',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** S3 的卡与交代 —— `quiet:true` 时两者都不会真正生效(卡被外层过滤,`say` 会跑但不影响断言)。 */
|
||||
const CARD_MIRROR: AutoAction = {
|
||||
kind: 'card',
|
||||
as: 'decide',
|
||||
variant: 'review',
|
||||
title: '差异评估结论审核',
|
||||
ask: '逐条差异结论已备好。请在工作区的件上逐条核对;要改的就地改(四态、影响说明、判断依据都可改),改完回到这里确认。',
|
||||
options: [{ value: 'approve', label: '确认通过', detail: '逐条核对无误,可以进入落账放行' }],
|
||||
basis: [
|
||||
{ k: '原料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' } },
|
||||
{ k: '候选料号', v: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' } },
|
||||
{ k: '本轮结论', v: { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' } },
|
||||
],
|
||||
};
|
||||
const SAY_MIRROR: AutoAction = { kind: 'say', text: '差异结论已送审,请在件上逐条核对。' };
|
||||
|
||||
/**
|
||||
* `SHARED_LET` 的镜像 —— 逐字取自 `case-model.ts`。**这是本用例要钉的核心**:
|
||||
* S3 recipe 若不带这份 `let`(或带一份不完整的),`REL_MIRROR`/`VAL_MIRROR` 里
|
||||
* `{in:X}` 引用的名字就是未绑定的。
|
||||
*/
|
||||
const S3_LET_MIRROR = {
|
||||
evalRef: { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' },
|
||||
typeId: { lookup: 'mine:sub_eval.case', by: {}, take: 'chip_type_id', else: '' },
|
||||
originalPn: { lookup: 'mine:sub_eval.part', by: { role: 'original' }, take: 'internal_pn', else: '' },
|
||||
candidatePn: { lookup: 'mine:sub_eval.part', by: { role: 'candidate' }, take: 'internal_pn', else: '' },
|
||||
relationId: { cat: ['SUB-', { lookup: 'mine:sub_eval.case', by: {}, take: 'eval_ref', else: '' }] },
|
||||
};
|
||||
|
||||
/**
|
||||
* **S3 当前(修复后)的样子**:带上自己的 `let`,并且在关系 / 验证记录投影之前先跑一遍
|
||||
* 差异明细重算笔(`RECOMPUTE_MIRROR`)。case-model.ts 现在就是这样写的 ——
|
||||
* `let: SHARED_LET, do: [DIFFERENCE_ITEM_RECOMPUTE, RELATION_PROJECTION, VALIDATION_PROJECTION, card, say]`
|
||||
* (2026-08-30 task-12 在原有的 `REL_MIRROR, VAL_MIRROR, ...` 前面插入了 `RECOMPUTE_MIRROR`)。
|
||||
*/
|
||||
const S3_RECIPE: AutoRecipe = {
|
||||
let: S3_LET_MIRROR,
|
||||
do: [RECOMPUTE_MIRROR, REL_MIRROR, VAL_MIRROR, CARD_MIRROR, SAY_MIRROR],
|
||||
};
|
||||
|
||||
/**
|
||||
* **S3 修复前的样子**(2026-08-30 task-12 之前 `case-model.ts` 实际写的):没有
|
||||
* `RECOMPUTE_MIRROR` 那一笔,直接从关系 / 验证记录投影起步。留着它专门给下面「同一行
|
||||
* 自相矛盾」那条用例做对照 —— 不靠临时改文件做变异验证,这份对照本身就是常驻回归。
|
||||
*/
|
||||
const S3_RECIPE_PRE_FIX: AutoRecipe = {
|
||||
let: S3_LET_MIRROR,
|
||||
do: [REL_MIRROR, VAL_MIRROR, CARD_MIRROR, SAY_MIRROR],
|
||||
};
|
||||
|
||||
describe('S3 审核卡配方:复用 S2 投影时必须自带 let,否则两笔投影的主键在 S3 那一拍是空的', () => {
|
||||
it('S3 recipe 带 SHARED_LET:关系表那一行真的被重算更新,验证记录仍然只有一行', async () => {
|
||||
const { runner, drafts, cards, ctx } = harness();
|
||||
await runner.run(SETUP_S1S2, ctx);
|
||||
await runner.run(HUMAN_EDIT, ctx);
|
||||
|
||||
// 重算之前:关系表还是 AI 的初版结论(recommended),这是本用例要推翻的起点
|
||||
const beforeRelation = [...drafts.values()].find((d) => d.domain === 'substitution' && d.object === 'relation');
|
||||
expect(beforeRelation?.patch['recommendation_level']).toBe('recommended');
|
||||
|
||||
const out = await runner.run(S3_RECIPE, ctx);
|
||||
|
||||
// ① 关系表那一行真的被重算更新了 —— 不是「写下 0 行」,推荐等级跟着人改的四态翻了
|
||||
const relationRows = [...drafts.values()].filter((d) => d.domain === 'substitution' && d.object === 'relation');
|
||||
expect(relationRows).toHaveLength(1);
|
||||
expect(relationRows[0]?.key['substitution_relation_id']).toBe(RELATION_ID);
|
||||
expect(relationRows[0]?.patch['recommendation_level']).toBe('not_recommended');
|
||||
expect(relationRows[0]?.patch['risk_level']).toBe('high');
|
||||
|
||||
// ② 验证记录仍然只有一行,没有 `validation_id:'VAL-'` 那种拼出来的垃圾行
|
||||
const validationRows = [...drafts.values()].filter((d) => d.domain === 'validation' && d.object === 'record');
|
||||
expect(validationRows).toHaveLength(1);
|
||||
expect(validationRows[0]?.key['validation_id']).toBe(VALIDATION_ID);
|
||||
expect(validationRows.some((d) => d.key['validation_id'] === 'VAL-')).toBe(false);
|
||||
|
||||
// quiet:true 时卡不该真正发出去(外层过滤;cards.decide 若被调用会直接 reject 炸掉这条用例)
|
||||
expect(cards).toHaveLength(0);
|
||||
expect(out.issuedCardId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* **2026-08-30 task-12 加的一组**:钉住「结论纹丝不动」那条要害缺陷本身 ——
|
||||
* 人改了 `difference_status`,**这一行自己的** `judgement_class`/`risk_level` 有没有真的
|
||||
* 跟着重算,以及重算有没有误伤人可以改的叙事列。上一组的断言只看关系表 / 验证记录的
|
||||
* 结果,没有直接看差异明细这一行自己;而「同一行自相矛盾」正是浏览器走查实测到的现象
|
||||
* ——`difference_status=match` 与 `judgement_class=hard_mismatch` 同时摆在一行上。
|
||||
*
|
||||
* 第一条用 `S3_RECIPE_PRE_FIX`(没有 `RECOMPUTE_MIRROR` 那一笔)重现修复前的样子,
|
||||
* 第二条用 `S3_RECIPE`(现在的样子)证明修复生效 —— 两条对照就是常驻的变异验证,
|
||||
* 不必临时改这份文件本身来复现「摘掉修复」的红。
|
||||
*/
|
||||
describe('S3 审核卡配方:差异明细重算 —— 人改的四态要转成判定分类,不能只停在人写的那一列', () => {
|
||||
it('修复前的形状(S3_RECIPE_PRE_FIX,没有差异明细重算笔):同一行自相矛盾 —— difference_status 变了,judgement_class 纹丝不动', async () => {
|
||||
const { runner, drafts, ctx } = harness();
|
||||
await runner.run(SETUP_S1S2, ctx);
|
||||
await runner.run(HUMAN_EDIT, ctx);
|
||||
await runner.run(S3_RECIPE_PRE_FIX, ctx);
|
||||
|
||||
const edited = [...drafts.values()].find(
|
||||
(d) =>
|
||||
d.domain === 'substitution' &&
|
||||
d.object === 'difference_item' &&
|
||||
d.key['difference_item_id'] === `${RELATION_ID}-静态电流`,
|
||||
);
|
||||
// 人改的那一列如实落了草稿
|
||||
expect(edited?.patch['difference_status']).toBe('mismatch');
|
||||
// 但没有任何一笔把它翻译成 judgement_class —— 停在 setup 阶段写下的旧值,同一行自相矛盾
|
||||
expect(edited?.patch['judgement_class']).toBe('match');
|
||||
expect(edited?.patch['risk_level']).toBe('low');
|
||||
});
|
||||
|
||||
it('修复后(S3_RECIPE):差异明细这一行自己的 judgement_class / risk_level 真的跟着 difference_status 翻了,且叙事列一个字没被覆盖', async () => {
|
||||
const { runner, drafts, ctx } = harness();
|
||||
await runner.run(SETUP_S1S2, ctx);
|
||||
await runner.run(HUMAN_EDIT, ctx);
|
||||
await runner.run(S3_RECIPE, ctx);
|
||||
|
||||
const edited = [...drafts.values()].find(
|
||||
(d) =>
|
||||
d.domain === 'substitution' &&
|
||||
d.object === 'difference_item' &&
|
||||
d.key['difference_item_id'] === `${RELATION_ID}-静态电流`,
|
||||
);
|
||||
expect(edited?.patch['difference_status']).toBe('mismatch');
|
||||
expect(edited?.patch['judgement_class']).toBe('hard_mismatch');
|
||||
expect(edited?.patch['risk_level']).toBe('high');
|
||||
|
||||
// 人在这一行写的判断依据一个字没被重算笔覆盖(它只列了两个派生列)
|
||||
expect(edited?.patch['evidence_note']).toBe(
|
||||
'审核人核对候选规格书 §6.2 实测值,静态电流是原厂 4 倍,超待机功耗预算',
|
||||
);
|
||||
// 人没碰过的 impact_statement 仍是 SETUP_S1S2 写的那句 —— 证明重算笔没有清空叙事列
|
||||
expect(edited?.patch['impact_statement']).toBe('无');
|
||||
|
||||
// 没被人改过的那一行(输入电压范围)保持原样 —— 重算笔不该误伤没变过的行
|
||||
const untouched = [...drafts.values()].find(
|
||||
(d) =>
|
||||
d.domain === 'substitution' &&
|
||||
d.object === 'difference_item' &&
|
||||
d.key['difference_item_id'] === `${RELATION_ID}-输入电压范围`,
|
||||
);
|
||||
expect(untouched?.patch['difference_status']).toBe('match');
|
||||
expect(untouched?.patch['judgement_class']).toBe('match');
|
||||
expect(untouched?.patch['risk_level']).toBe('low');
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
|
||||
export type {
|
||||
AgentPort,
|
||||
AnalyzeGateway,
|
||||
CardGateway,
|
||||
Clock,
|
||||
ListenerRegistrar,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 配方表达式求值 —— **纯函数,零 IO**(编码规范:domain 层不碰端口)。
|
||||
*
|
||||
* S-FEEL 不在这里实现:`condition` 由调用方注入(与哨兵、`doneWhen` 同一个求值器)。
|
||||
* **不发明第二套求值** 是 ADR-014 的一条硬约束。
|
||||
*/
|
||||
import type { AutoExpr } from '@contracts';
|
||||
|
||||
export type Row = Readonly<Record<string, unknown>>;
|
||||
|
||||
export type ExprCtx = {
|
||||
/** 任务视图(正式 + 本单草稿叠加)+ 基座读数 + 起单入参,外加本拍的 `result:*` */
|
||||
readonly scope: Readonly<Record<string, unknown>>;
|
||||
/** 配方级 `let` 算好的值 —— 与 scope 同一命名空间,它优先 */
|
||||
readonly lets: Readonly<Record<string, unknown>>;
|
||||
readonly row?: Row | undefined;
|
||||
readonly outer?: Row | undefined;
|
||||
/** 同一行里**已经算出来**的字段 —— 「先 lookup 出依据,再按依据判 found/absent」靠它 */
|
||||
readonly computed?: Row | undefined;
|
||||
readonly condition: (expr: string, scope: Readonly<Record<string, unknown>>) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 地址取行 —— 四种地址(spec §5.1),取不到一律空数组。
|
||||
*
|
||||
* **取不到不抛**:首拍很多表本来就一行都还没有,抛了等于每一单开局就交人。
|
||||
*/
|
||||
export function rowsAt(scope: Readonly<Record<string, unknown>>, address: string): readonly Row[] {
|
||||
const raw = address.startsWith('in:') ? scope[address.slice('in:'.length)] : scope[address];
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((item) =>
|
||||
typeof item === 'object' && item !== null ? (item as Row) : ({ value: item } satisfies Row),
|
||||
);
|
||||
}
|
||||
|
||||
/** 一个值参与比较时的归一:台账里数字与文本混着存,一律按字符串比 */
|
||||
function asKey(value: unknown): string {
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
}
|
||||
|
||||
export function evalExpr(expr: AutoExpr, ctx: ExprCtx): unknown {
|
||||
if (typeof expr === 'string' || typeof expr === 'number' || typeof expr === 'boolean') return expr;
|
||||
|
||||
if ('in' in expr) {
|
||||
const fromLet = ctx.lets[expr.in];
|
||||
return fromLet !== undefined ? fromLet : (ctx.scope[expr.in] ?? '');
|
||||
}
|
||||
if ('col' in expr) return ctx.row?.[expr.col] ?? '';
|
||||
if ('outer' in expr) return ctx.outer?.[expr.outer] ?? '';
|
||||
if ('cat' in expr) return expr.cat.map((part) => evalText(part, ctx)).join('');
|
||||
if ('fmt' in expr) return evalText(expr.fmt, ctx).replace(/\D/g, '');
|
||||
|
||||
if ('lookup' in expr) {
|
||||
const pairs = Object.entries(expr.by).map(([column, valueExpr]) => [column, asKey(evalExpr(valueExpr, ctx))] as const);
|
||||
const hit = rowsAt(ctx.scope, expr.lookup).find((row) => pairs.every(([column, want]) => asKey(row[column]) === want));
|
||||
if (hit === undefined) return expr.else === undefined ? '' : evalExpr(expr.else, ctx);
|
||||
return hit[expr.take] ?? (expr.else === undefined ? '' : evalExpr(expr.else, ctx));
|
||||
}
|
||||
|
||||
/*
|
||||
* 条件走注入的 S-FEEL。谓词写坏了**不该拖垮整拍** —— 与 `doneWhen` 同一条处置:
|
||||
* 当作「没算出来」走 else。真写错了,静态审在装包时就该拦下(Task 9)。
|
||||
*/
|
||||
const scope = { ...ctx.scope, ...ctx.lets, ...(ctx.outer ?? {}), ...(ctx.row ?? {}), ...(ctx.computed ?? {}) };
|
||||
let hit = false;
|
||||
try {
|
||||
hit = ctx.condition(expr.when, scope);
|
||||
} catch {
|
||||
hit = false;
|
||||
}
|
||||
return evalExpr(hit ? expr.then : expr.else, ctx);
|
||||
}
|
||||
|
||||
/** 出字符串的那一路 —— 拼接、标题、留痕都要它 */
|
||||
export function evalText(expr: AutoExpr, ctx: ExprCtx): string {
|
||||
const value = evalExpr(expr, ctx);
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 配方字段(与 `let`)求值序 —— 纯函数,零 IO(编码规范 §5.3)。
|
||||
*
|
||||
* `AutoAction.fields` / `analyze.input.fields` / `AutoRecipe.let` 存进
|
||||
* `sys.activity_package.manifest`(`jsonb` 列)之后,对象键的**原始书写顺序不再保证**:
|
||||
* PostgreSQL 的 `jsonb` 按「先短后长、同长字典序」把每一层嵌套对象的键重新排列(实测见
|
||||
* `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`)。
|
||||
*
|
||||
* `auto-runner` 逐字段求值时把已经算出来的值放进 `ctx.computed`,后面的字段能通过
|
||||
* S-FEEL `when` 链读到前面字段的结果(`evalExpr` 唯一把 `computed` 摊进求值作用域的
|
||||
* 分支 —— 见 `auto-expr.ts`)。这条依赖因此**只可能藏在 `when` 字符串里**:字段 A 的
|
||||
* `when`(或它嵌套的 `then`/`else`/`lookup.by`/`cat`/`fmt`)引用了同一个 `fields` 对象
|
||||
* 里另一个字段的名字,A 就依赖那个字段先算。
|
||||
*
|
||||
* 这个文件按**字段名之间的真实依赖**(而不是声明顺序 / jsonb 存取后的顺序)排出一个
|
||||
* 安全的求值序 —— 与存储格式无关,`fields` 不必改成数组(那是破坏性契约变更)。
|
||||
* 真正的环(A 依赖 B、B 依赖 A)排不出来,直接抛错 —— 不默默兜底,兜底正是这次出事的形态。
|
||||
*
|
||||
* **`AutoRecipe.let` 是同一个地基缺陷的第二个受害者**(2026-08-30 全分支终审 Important 1,
|
||||
* H47 当时只治了 `fields` 那一半):`auto-runner.ts` 从前逐 `Object.entries(recipe.let)`
|
||||
* 按声明顺序求值,而 `let` 同样住在这份 jsonb 里、同样会被重排。且 `let` 与 `fields` 的
|
||||
* 依赖读法**不一样**——`fields` 的字段之间只能通过 `when` 字符串里的裸标识符互相看见
|
||||
* (`{in:X}` 在 `fields` 里读的是 `lets`/`scope`,永远读不到同一个 `fields` 对象里的
|
||||
* 另一个字段);而 `let` 的**官方读法就是 `{in:X}`**(见 `packages/contracts/src/
|
||||
* auto-recipe.ts` 的注释:「先算一遍,后面所有表达式都能 {in:'名'} 引用」,运行时
|
||||
* `evalExpr` 的 `'in' in expr` 分支优先读 `ctx.lets[X]`)——`whenReferences` 故意不认
|
||||
* 裸 `{in:X}` 是对的(对 `fields` 而言),但直接照搬给 `let` 用就会**漏掉最主要的那种
|
||||
* 依赖**,排序等于没排。所以 `let` 用的是下面单独一份 `letReferences`/`orderLets`,
|
||||
* 不是简单复用 `whenReferences`/`orderFields`。
|
||||
*/
|
||||
import type { AutoExpr } from '@contracts';
|
||||
import { staticCheck } from '@sfeel';
|
||||
import { topoOrder } from '@utils';
|
||||
|
||||
/**
|
||||
* `noun` 让同一个错误类型给 `fields`(默认「字段」)与 `let`(见下面 `orderLets`)
|
||||
* 各自说准话:两者的依赖读法不一样(`let` 多认 `{in:X}`,提示语也得多提这一句),
|
||||
* 但都是"排不出安全求值序"这同一件事,不必另开一个类。
|
||||
*/
|
||||
export class AutoFieldCycleError extends Error {
|
||||
constructor(
|
||||
readonly cycle: readonly string[],
|
||||
noun: '字段' | 'let' = '字段',
|
||||
) {
|
||||
const how = noun === 'let' ? '互相用 `{in:…}` 或 when 引用了对方' : '互相引用了对方';
|
||||
super(`${noun}之间存在循环依赖,求不出安全的求值序:${cycle.join('、')}(检查这几个${noun}是否${how})`);
|
||||
this.name = 'AutoFieldCycleError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归找出表达式里全部 `when` 字符串引用的根路径名。
|
||||
*
|
||||
* 名字取 S-FEEL AST 的 `references`,不用正则扫标识符 —— 与 `static-audit.ts` 的
|
||||
* `checkWhen` 同一个理由:正则会把 `count(...)` 这类内建函数名也当成字段名。
|
||||
* `when` 语法本该在装包静态审那关就被拦住(`autoRecipeFindings` 的 `checkWhen`),
|
||||
* 这里万一撞见解析不了的表达式,当作没有引用处理(宁可漏报不可误报)。
|
||||
*
|
||||
* **与 `activity-registry/internal/domain/static-audit.ts` 里的 `fieldReferences`
|
||||
* 是同一个算法,写了两遍**:装包静态审(L4)不能 import 配方执行器(L5,模块分层
|
||||
* 只准低层被高层引用),这一段小小的、纯粹依附 `AutoExpr` 形状的逻辑只能各自留一份;
|
||||
* 真正通用、不含业务语义的图算法(`topoOrder`)已经提到 `@utils` 共用了。
|
||||
* 改动这个函数时,记得看看那边要不要一起改。
|
||||
*/
|
||||
function whenReferences(expr: AutoExpr): readonly string[] {
|
||||
if (typeof expr !== 'object' || expr === null) return [];
|
||||
if ('when' in expr) {
|
||||
const verdict = staticCheck(expr.when);
|
||||
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
||||
return [...names, ...whenReferences(expr.then), ...whenReferences(expr.else)];
|
||||
}
|
||||
if ('lookup' in expr) {
|
||||
const byNames = Object.values(expr.by).flatMap((value) => whenReferences(value));
|
||||
return expr.else === undefined ? byNames : [...byNames, ...whenReferences(expr.else)];
|
||||
}
|
||||
if ('cat' in expr) return expr.cat.flatMap((part) => whenReferences(part));
|
||||
if ('fmt' in expr) return whenReferences(expr.fmt);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `fields` 排出一个安全的求值序:被依赖的字段排前面,返回 `[name, expr]` 有序对
|
||||
* (调用方直接拿它替掉 `Object.entries(fields)`,别的都不用变)。
|
||||
*
|
||||
* 只认**同一个 fields 对象内**的字段名当依赖 —— 台账列名、`case:` 这类全局名字与
|
||||
* 求值顺序无关(它们一直都在 `scope` 里,不经 `computed` 这条通道),排了反而可能把
|
||||
* 「本笔内」不存在的依赖当真。找不出安全序(真正的环)抛 `AutoFieldCycleError`。
|
||||
*/
|
||||
export function orderFields(fields: Readonly<Record<string, AutoExpr>>): ReadonlyArray<readonly [string, AutoExpr]> {
|
||||
const entries = Object.entries(fields);
|
||||
const names = new Set(entries.map(([name]) => name));
|
||||
const deps = new Map<string, readonly string[]>(
|
||||
entries.map(([name, expr]) => [name, [...new Set(whenReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
||||
);
|
||||
const byName = new Map(entries);
|
||||
|
||||
const result = topoOrder(
|
||||
entries.map(([name]) => name),
|
||||
(name) => deps.get(name) ?? [],
|
||||
);
|
||||
if (!result.ok) throw new AutoFieldCycleError(result.cycle);
|
||||
return result.order.map((name) => [name, byName.get(name)!] as const);
|
||||
}
|
||||
|
||||
/**
|
||||
* `letReferences` 与 `whenReferences` 几乎同一套遍历,只多认一种入口:
|
||||
* **裸 `{in:X}` 本身就是依赖**——`let` 唯一的官方跨条目读法(见文件头注)。
|
||||
* `when`/`lookup.by`/`cat`/`fmt` 四种嵌套形态与 `whenReferences` 完全一致
|
||||
* (`let` 的 `when` 子句同样进的是含 `...ctx.lets` 的合并作用域,见 `auto-expr.ts`
|
||||
* `evalExpr` 最后那段 `scope` 拼装,裸标识符引用另一个 `let` 的名字同样成立)。
|
||||
*
|
||||
* **与 `activity-registry/internal/domain/static-audit.ts` 里的 `letReferences` 是
|
||||
* 同一个算法,写了两遍**——理由与 `whenReferences` 头注同款(L4 装包静态审不能 import
|
||||
* L5 配方执行器)。改动这个函数时,记得看看那边要不要一起改。
|
||||
*/
|
||||
function letReferences(expr: AutoExpr): readonly string[] {
|
||||
if (typeof expr !== 'object' || expr === null) return [];
|
||||
if ('in' in expr) return [expr.in];
|
||||
if ('when' in expr) {
|
||||
const verdict = staticCheck(expr.when);
|
||||
const names = verdict.references.map((path) => path.split('.')[0] ?? path);
|
||||
return [...names, ...letReferences(expr.then), ...letReferences(expr.else)];
|
||||
}
|
||||
if ('lookup' in expr) {
|
||||
const byNames = Object.values(expr.by).flatMap((value) => letReferences(value));
|
||||
return expr.else === undefined ? byNames : [...byNames, ...letReferences(expr.else)];
|
||||
}
|
||||
if ('cat' in expr) return expr.cat.flatMap((part) => letReferences(part));
|
||||
if ('fmt' in expr) return letReferences(expr.fmt);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `recipe.let` 排出一个安全的求值序 —— 与 `orderFields` 同一个用法(调用方直接拿它
|
||||
* 替掉 `Object.entries(recipe.let)`),只是依赖读法换成 `letReferences`(认 `{in:X}`)。
|
||||
* 找不出安全序(真正的环,如 `a: {in:'b'}, b: {in:'a'}`)抛 `AutoFieldCycleError`
|
||||
* (`noun: 'let'`,提示语说的是 `let` 不是字段)。
|
||||
*/
|
||||
export function orderLets(lets: Readonly<Record<string, AutoExpr>>): ReadonlyArray<readonly [string, AutoExpr]> {
|
||||
const entries = Object.entries(lets);
|
||||
const names = new Set(entries.map(([name]) => name));
|
||||
const deps = new Map<string, readonly string[]>(
|
||||
entries.map(([name, expr]) => [name, [...new Set(letReferences(expr))].filter((ref) => ref !== name && names.has(ref))]),
|
||||
);
|
||||
const byName = new Map(entries);
|
||||
|
||||
const result = topoOrder(
|
||||
entries.map(([name]) => name),
|
||||
(name) => deps.get(name) ?? [],
|
||||
);
|
||||
if (!result.ok) throw new AutoFieldCycleError(result.cycle, 'let');
|
||||
return result.order.map((name) => [name, byName.get(name)!] as const);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 配方数据源展开 —— **纯函数,零 IO**。
|
||||
*
|
||||
* 嵌套**两层封顶**:两层(某几颗料 × 该类型的参数字段)是已知业务的上限;
|
||||
* 超出就别 auto —— 退回模型,或者把那一段切出去。
|
||||
*/
|
||||
import type { AutoExpr, AutoSource } from '@contracts';
|
||||
import { evalExpr, rowsAt, type ExprCtx, type Row } from './auto-expr.ts';
|
||||
|
||||
export type ExpandedRow = { readonly row: Row; readonly outer?: Row | undefined };
|
||||
|
||||
/** where 是等值过滤;比值按字符串(台账里数字与文本混着存) */
|
||||
function matches(
|
||||
row: Row,
|
||||
where: Readonly<Record<string, AutoExpr>> | undefined,
|
||||
ctx: ExprCtx,
|
||||
outer?: Row | undefined,
|
||||
): boolean {
|
||||
if (where === undefined) return true;
|
||||
return Object.entries(where).every(([column, valueExpr]) => {
|
||||
const want = evalExpr(valueExpr, { ...ctx, row, ...(outer === undefined ? {} : { outer }) });
|
||||
const got = row[column];
|
||||
return (got === null || got === undefined ? '' : String(got)) === (want === null || want === undefined ? '' : String(want));
|
||||
});
|
||||
}
|
||||
|
||||
export function expandSource(source: AutoSource, ctx: ExprCtx): readonly ExpandedRow[] {
|
||||
if ('once' in source) return [{ row: {} }];
|
||||
|
||||
if ('rows' in source) {
|
||||
return source.rows.map((literal) => {
|
||||
const row: Record<string, unknown> = {};
|
||||
for (const [name, expr] of Object.entries(literal)) row[name] = evalExpr(expr, { ...ctx, row });
|
||||
return { row };
|
||||
});
|
||||
}
|
||||
|
||||
const outers = rowsAt(ctx.scope, source.each).filter((row) => matches(row, source.where, ctx));
|
||||
if (source.then === undefined) return outers.map((row) => ({ row }));
|
||||
|
||||
const inner = source.then;
|
||||
const out: ExpandedRow[] = [];
|
||||
for (const outer of outers) {
|
||||
for (const row of rowsAt(ctx.scope, inner.each)) {
|
||||
if (matches(row, inner.where, ctx, outer)) out.push({ row, outer });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* **这一单此刻在办哪一步** —— 从 turn-runner 里提纯出来的一句话(纯函数,好测)。
|
||||
*
|
||||
* 为什么要跳过已收掉的项:带 `repetitionRule` 的项被收掉之后**仍是 Enabled**,
|
||||
* 「在场的第一个有剧本的项」于是永远是第一步 —— 实测:跑到判定去向了,
|
||||
* 工作区还停在阶段二那份件,喂给模型的做法也还是第一步的。
|
||||
*/
|
||||
export function liveStepOf<S extends { readonly item: string }>(
|
||||
steps: readonly S[],
|
||||
settledIds: ReadonlySet<string>,
|
||||
onStage: readonly { readonly id: string }[],
|
||||
): S | undefined {
|
||||
for (const entry of onStage) {
|
||||
if (settledIds.has(entry.id)) continue;
|
||||
const step = steps.find((s) => s.item === entry.id);
|
||||
if (step !== undefined) return step;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 配方执行器 —— 逐笔执行 `PlanStep.auto`(spec §5.1)。
|
||||
*
|
||||
* 三条硬规矩:
|
||||
* ① **每一笔之前刷读数**:后一笔常常要数前一笔刚写下的行。不刷新就恒为 0 ——
|
||||
* 推荐等级会永远算成「全 MATCH」,而屏上明明摆着两条不符。
|
||||
* ② **幂等**:`draftWrite` 同 PK 覆盖,值没变**静默跳过**(不留痕、不计数)——
|
||||
* 「写了等于没写」那条判据是拦模型空转的,配方不是模型;
|
||||
* 发卡先看 `carded`(worldTouched),发过就不再发。
|
||||
* ③ **出错 = 交人,不重试、不退回模型**:配方抛错是活动包的 bug。
|
||||
* 退回模型就是让它去凑,那正是这套机制要消灭的东西。
|
||||
*/
|
||||
import type { AutoAction, AutoExpr, AutoRecipe, StaffId, TaskId } from '@contracts';
|
||||
import { AutoFieldCycleError, orderFields, orderLets } from '../domain/auto-order.ts';
|
||||
import { evalExpr, evalText, rowsAt, type ExprCtx } from '../domain/auto-expr.ts';
|
||||
import { expandSource } from '../domain/auto-source.ts';
|
||||
import type { AnalyzeGateway, CardGateway, MemoryRecorder, Sfeel, TaskDataGateway } from '../../port.ts';
|
||||
|
||||
export class AutoRecipeError extends Error {
|
||||
constructor(
|
||||
readonly actionIndex: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AutoRecipeError';
|
||||
}
|
||||
}
|
||||
|
||||
export type AutoRunnerDeps = {
|
||||
readonly data: TaskDataGateway;
|
||||
readonly cards: CardGateway;
|
||||
readonly memory: MemoryRecorder;
|
||||
readonly analyze: AnalyzeGateway;
|
||||
readonly sfeel: Sfeel;
|
||||
};
|
||||
|
||||
export type AutoContext = {
|
||||
readonly taskId: TaskId;
|
||||
readonly stepId: string;
|
||||
readonly ownerId: StaffId;
|
||||
readonly humanCallCap: number;
|
||||
/** 有未答的卡在场:**只跑 project / say** —— 只算、只写,不发新卡、不 submit、不 analyze */
|
||||
readonly quiet: boolean;
|
||||
/** 这一步已经发过卡(worldTouched) */
|
||||
readonly carded: boolean;
|
||||
};
|
||||
|
||||
export type AutoOutcome = {
|
||||
readonly wrote: number;
|
||||
readonly dropped: number;
|
||||
readonly issuedCardId?: string;
|
||||
readonly submit?: { readonly rationale: string };
|
||||
readonly failedAt?: { readonly index: number; readonly message: string };
|
||||
};
|
||||
|
||||
const keyText = (key: Readonly<Record<string, string>>): string => Object.values(key).join('|');
|
||||
|
||||
/**
|
||||
* 摆给人看的行标识 —— **去掉单号那一段**(2026-08-25 柴浩:「只保留斜杠右边」):
|
||||
* 人正在这一单里看,单号是脚下的地面,不是区分行的信息。与基座的 `rowLabel` 同一条口径。
|
||||
* 去完一段都不剩(主键只有单号)就退回整段。
|
||||
*/
|
||||
function rowName(key: Readonly<Record<string, string>>, caseKey: string | undefined): string {
|
||||
const rest = Object.entries(key)
|
||||
.filter(([column]) => column !== caseKey)
|
||||
.map(([, value]) => value);
|
||||
return (rest.length > 0 ? rest : Object.values(key)).join('/');
|
||||
}
|
||||
|
||||
/** 点名封顶 8 个 —— 再多就不是「点名」而是刷屏(拒回主键名单封顶 12 是同一条理由) */
|
||||
function nameList(names: readonly string[]): string {
|
||||
return names.length <= 8 ? names.join('、') : `${names.slice(0, 8).join('、')}…等 ${names.length} 行`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `orderFields` 的环报错翻成 `AutoRecipeError`(与本文件其余显式抛错同一个类 ——
|
||||
* 外层 try/catch 只按 §头注③「出错 = 交人」一条处置,不分错误类型)。
|
||||
* 真正的环本该在装包静态审那关就被拦下(`autoRecipeFindings`),这里只是最后一道兜底。
|
||||
*/
|
||||
function safeFieldOrder(fields: Readonly<Record<string, AutoExpr>>, index: number): ReadonlyArray<readonly [string, AutoExpr]> {
|
||||
try {
|
||||
return orderFields(fields);
|
||||
} catch (err) {
|
||||
if (err instanceof AutoFieldCycleError) throw new AutoRecipeError(index, err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** 这份配方会不会发卡 —— 「卡答过了就收步」那道门要问它 */
|
||||
export function recipeIssuesCard(recipe: AutoRecipe): boolean {
|
||||
return recipe.do.some((action) => action.kind === 'card');
|
||||
}
|
||||
|
||||
export function createAutoRunner(deps: AutoRunnerDeps): {
|
||||
run(recipe: AutoRecipe, ctx: AutoContext): Promise<AutoOutcome>;
|
||||
} {
|
||||
return {
|
||||
async run(recipe, ctx) {
|
||||
const writable = await deps.data.writable(ctx.taskId);
|
||||
const results: Record<string, unknown[]> = {};
|
||||
let scope: Record<string, unknown> = { ...(await deps.data.view(ctx.taskId)), ...results };
|
||||
let dirty = false;
|
||||
let wrote = 0;
|
||||
let dropped = 0;
|
||||
let issuedCardId: string | undefined;
|
||||
let submit: { rationale: string } | undefined;
|
||||
|
||||
const condition = deps.sfeel.condition;
|
||||
const lets: Record<string, unknown> = {};
|
||||
/*
|
||||
* **`let` 算好之前不许任何一笔动作跑**(2026-08-30 终审 Important 1)—— 计算挪到了
|
||||
* 下面主循环第一次迭代的 try 里(见那一段注释),`letsReady` 只是标一下算过没有。
|
||||
*/
|
||||
let letsReady = false;
|
||||
|
||||
async function runAction(
|
||||
action: AutoAction,
|
||||
index: number,
|
||||
base: ExprCtx,
|
||||
context: AutoContext,
|
||||
): Promise<{ wrote: number; dropped: number; cardId?: string; submit?: { rationale: string }; stop?: boolean }> {
|
||||
if (action.kind === 'say') {
|
||||
/*
|
||||
* **kind: 'observation'**(2026-08-29,R10)—— 从前是 `kind: 'result'`,
|
||||
* 而 `result` 不在 `task-log.ts` 的 `SKELETON` 里,这一笔永远进不了工作日志
|
||||
* (spec §5.9 承诺「配方每一笔照常进 task_memory,工作日志里归系统灰」落空)。
|
||||
* `observation` 在骨架里;不带 `detail.say` 时 `actorOf` 本就归系统。
|
||||
*/
|
||||
await deps.memory.append({
|
||||
taskId: context.taskId,
|
||||
kind: 'observation',
|
||||
title: evalText(action.text, base),
|
||||
detail: { auto: `${context.stepId}#${index + 1}` },
|
||||
});
|
||||
return { wrote: 0, dropped: 0 };
|
||||
}
|
||||
|
||||
if (action.kind === 'project') {
|
||||
const [domain, object] = action.into.split('.');
|
||||
const decl = writable.find((w) => w.domain === domain && w.object === object);
|
||||
if (decl === undefined) {
|
||||
throw new AutoRecipeError(index, `台账地址不在本活动的声明里:${action.into}(写的授权面是台账声明)`);
|
||||
}
|
||||
const before = rowsAt(base.scope, `mine:${action.into}`);
|
||||
const kept = new Set<string>();
|
||||
let localWrote = 0;
|
||||
/** 「只建不改」的表里已经在库、这一笔不用建的行数 */
|
||||
let localExisting = 0;
|
||||
/** 主键算不出来、这一笔没写下去的行数 —— 不出声的话表里少了行谁也查不出为什么(M1) */
|
||||
let localUnkeyed = 0;
|
||||
/** 撤掉的是哪几行 —— 「撤要说出来、要点名」(2026-08-02 为人工撤行立的规矩,配方这一侧跟上,M2) */
|
||||
const dropNames: string[] = [];
|
||||
/*
|
||||
* **字段按依赖排序求值,不按声明/存储顺序**(2026-08-30,修 jsonb 重排 bug ——
|
||||
* 见 `.superpowers/sdd/2026-08-29-act-work-05-rewrite/judgement-class-probe.md`)。
|
||||
* `fields` 经一趟 `sys.activity_package.manifest`(jsonb 列)存取,对象键的原始
|
||||
* 书写顺序不再保证;`orderFields` 按字段名之间的真实依赖(`when` 链互相引用)
|
||||
* 排出一个安全序,与存储格式无关。只算一次 —— 排序结果与 `row` 无关,
|
||||
* `action.fields` 的结构对每一行都一样。
|
||||
*/
|
||||
const fieldOrder = safeFieldOrder(action.fields, index);
|
||||
for (const { row, outer } of expandSource(action.from, base)) {
|
||||
const computed: Record<string, unknown> = {};
|
||||
for (const [name, expr] of fieldOrder) {
|
||||
computed[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed });
|
||||
}
|
||||
const key: Record<string, string> = {};
|
||||
for (const column of decl.keys) key[column] = String(computed[column] ?? '');
|
||||
/* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾。**但要数一笔**(M1) */
|
||||
if (decl.keys.some((column) => key[column] === '')) {
|
||||
localUnkeyed += 1;
|
||||
continue;
|
||||
}
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value;
|
||||
/*
|
||||
* 两个旗都是**配方专用**(2026-08-29 终审 I2),见 `TaskDataGateway.draftWrite` 上的注释:
|
||||
* · `skipUnchanged` —— 值没变就别落笔。草稿层的 upsert 尾巴上有 `landed_at = null`,
|
||||
* 而配方每一拍都把同一张表重投一遍:照写会把数据工单已确认入库的那些行**悄悄 un-land**,
|
||||
* 整单放行时再写一遍正式表、再抛一遍 data 事件。
|
||||
* · `skipExisting` —— 「只建不改」的表碰到已在库的键当「不用建」跳过。
|
||||
* 那句拒回是写给模型看的;配方抛出去就是永久停单,而人看见一句他办不了的话。
|
||||
*/
|
||||
const { changed, skipped } = await deps.data.draftWrite({
|
||||
taskId: context.taskId,
|
||||
domain: decl.domain,
|
||||
object: decl.object,
|
||||
key,
|
||||
patch,
|
||||
skipUnchanged: true,
|
||||
skipExisting: true,
|
||||
});
|
||||
if (skipped === true) localExisting += 1;
|
||||
else if (changed) localWrote += 1;
|
||||
/* 已在库的那一颗也算「这一笔要的行」—— prune 不许把它从草稿层撤掉 */
|
||||
kept.add(keyText(key));
|
||||
}
|
||||
let localDropped = 0;
|
||||
/*
|
||||
* **人正在件上编辑的时候不 prune**(2026-08-29 终审 F3)。
|
||||
* `prune` 的语义是「这一笔投影出来的行就是这张表的全部,多出来的撤掉」——
|
||||
* 而人在件上「加一行」加出来的行,配方的数据源里当然没有:
|
||||
* 编辑中途把人刚加的那一行悄悄撤掉,是最不能接受的一种静默数据丢失
|
||||
* (2026-08-02 为人工撤行立过「撤要说出来、要点名哪几行」的规矩,这里连说都没说)。
|
||||
* 有卡在等人(quiet)= 人正对着这张件填,这一拍只铺不撤;等他交回之后那一拍再对齐。
|
||||
*/
|
||||
if (action.prune === true && !context.quiet) {
|
||||
for (const stale of before) {
|
||||
const key: Record<string, string> = {};
|
||||
for (const column of decl.keys) key[column] = String(stale[column] ?? '');
|
||||
if (kept.has(keyText(key))) continue;
|
||||
const { dropped: gone } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key });
|
||||
if (gone) {
|
||||
localDropped += 1;
|
||||
dropNames.push(rowName(key, decl.caseKey));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (localWrote > 0 || localDropped > 0 || localExisting > 0 || localUnkeyed > 0) {
|
||||
/*
|
||||
* **kind: 'observation' + `internal: true`**(2026-08-29 终审 I5,控制端裁定 F2)。
|
||||
* `kind` 必须是 `observation`:`result` 不在 `task-log.ts` 的 `SKELETON` 里,进不了工作日志(R10)。
|
||||
* `internal` 必须留着:它是**对话区**那一侧的开关 —— 去掉之后这类机械痕
|
||||
* (「芯片主数据:写下 2 行」)会一笔一个气泡跑进对话区,而对话区的口径是
|
||||
* 「一轮一句,不是一拍一句」。工作日志那一侧由 `machinery()` 对 `detail.auto`
|
||||
* 开的例外放行(与 `detail.say` 同款)——**两件事各有各的开关**。
|
||||
*
|
||||
* **`detail.changed`**(2026-08-29 复审 M1/M2 二审):这一笔只要有 `localExisting`
|
||||
* 或 `localUnkeyed` 就会留痕(M1/M2 立的规矩——「一行没写也要出声」),而这两种局面
|
||||
* **台账其实一个字没动**。重做闸(`raiseAcceptance` 的 `revised`)认的是
|
||||
* 「配方写下的这一笔」,不点名区分就会把「全表已在库、这一笔不用建」这类空转
|
||||
* 也当成「改过了」放行 —— 人打回重做,配方原样重跑一遍、一个字没改,闸却开了。
|
||||
* 只有真写了或真撤了才算改过世界。
|
||||
*/
|
||||
await deps.memory.append({
|
||||
taskId: context.taskId,
|
||||
kind: 'observation',
|
||||
title:
|
||||
`${decl.label}:写下 ${localWrote} 行` +
|
||||
(localExisting === 0 ? '' : `,${localExisting} 行已在库、这一笔不用建`) +
|
||||
/* 撤了要点名(M2):不点名的话,下一拍的读者(人或模型)对不上是哪几行没了 */
|
||||
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行(${nameList(dropNames)})`) +
|
||||
/* 少写了几行要出声(M1):静默 continue 的那几行,表里少了谁也查不出为什么 */
|
||||
(localUnkeyed === 0 ? '' : `,${localUnkeyed} 行因主键算不出来没写(主键列:${decl.keys.join('、')})`),
|
||||
detail: {
|
||||
auto: `${context.stepId}#${index + 1}`,
|
||||
ledger: action.into,
|
||||
internal: true,
|
||||
changed: localWrote > 0 || localDropped > 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { wrote: localWrote, dropped: localDropped };
|
||||
}
|
||||
|
||||
if (action.kind === 'analyze') {
|
||||
/* 同一个 jsonb 重排问题也可能出在 analyze 的入参字段上(同一种「对象字面量当求值计划」的写法) */
|
||||
const inputFieldOrder = safeFieldOrder(action.input.fields, index);
|
||||
const payload = expandSource(action.input.from, base).map(({ row, outer }) => {
|
||||
const item: Record<string, unknown> = {};
|
||||
for (const [name, expr] of inputFieldOrder) {
|
||||
item[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed: item });
|
||||
}
|
||||
return item;
|
||||
});
|
||||
const { object } = await deps.analyze.run({
|
||||
taskId: context.taskId,
|
||||
model: action.model,
|
||||
instruction: action.instruction,
|
||||
payload,
|
||||
outputSchema: action.output,
|
||||
label: `${context.stepId}#${index + 1}`,
|
||||
});
|
||||
/* `into` 既是出参里那个数组字段的名字,也是拍内作用域的键 —— 一处声明,两处认 */
|
||||
const rows = object[action.into];
|
||||
results[`result:${action.into}`] = Array.isArray(rows) ? rows : [];
|
||||
/* kind: 'observation' + internal —— 理由同上(project 分支那条注释) */
|
||||
await deps.memory.append({
|
||||
taskId: context.taskId,
|
||||
kind: 'observation',
|
||||
title: `已把 ${payload.length} 条参数交给模型逐条判定,拿回 ${results[`result:${action.into}`]?.length ?? 0} 条结论`,
|
||||
detail: { auto: `${context.stepId}#${index + 1}`, model: action.model, internal: true },
|
||||
});
|
||||
/* 出参只活在拍内作用域里,不进草稿层 —— 下一笔要能看见它,所以这里也算「脏」 */
|
||||
scope = { ...scope, ...results };
|
||||
return { wrote: 0, dropped: 0 };
|
||||
}
|
||||
|
||||
if (action.kind === 'card') {
|
||||
/*
|
||||
* **发过就不再发**(幂等)。人工激活的 auto 步,**配方发卡就是启动** ——
|
||||
* 不必再有一个「先 start」的礼仪:学不稳的礼仪就是死法(2026-08-02 已拆过一次)。
|
||||
*/
|
||||
if (context.carded) {
|
||||
/*
|
||||
* **只跳过这一笔,不收拍**(2026-08-29 终审 I3)。从前这里 `stop: true`,
|
||||
* 于是同一份配方 `[project, card, project]` 在两种局面下行为相反:
|
||||
* 人还在填(quiet)时 card 被上面那道 quiet 过滤 `continue` 掉 → **第三笔照跑**;
|
||||
* 人交回之后(非 quiet、carded)→ 走到这儿 break,**第三笔不跑**。
|
||||
* 也就是说唯一一个「人的答案已经在草稿里」的时刻,配方的后半截偏偏不执行 ——
|
||||
* 写配方的人会很自然地写「铺格 → 发卡 → 收口整理」,然后发现第三笔从来没生效过,
|
||||
* 而且静默、没有痕。**「收拍」该留给真发出去了卡那一次**(下面两条 return 带 stop)。
|
||||
*/
|
||||
await deps.memory.append({
|
||||
taskId: context.taskId,
|
||||
kind: 'observation',
|
||||
title: `这一步已经发过卡,不再发第二张`,
|
||||
detail: { auto: `${context.stepId}#${index + 1}`, internal: true },
|
||||
});
|
||||
return { wrote: 0, dropped: 0 };
|
||||
}
|
||||
const basis = (action.basis ?? []).map((b) => ({ k: b.k, v: evalText(b.v, base) }));
|
||||
if (action.as === 'decide') {
|
||||
const options = action.options ?? [];
|
||||
if (options.length === 0) throw new AutoRecipeError(index, '决策卡一个选项都没有');
|
||||
const { id } = await deps.cards.decide({
|
||||
taskId: context.taskId,
|
||||
ownerId: context.ownerId,
|
||||
humanCallCap: context.humanCallCap,
|
||||
ask: evalText(action.ask ?? action.title, base),
|
||||
options: options.map((o) => ({ value: o.value, label: o.label, ...(o.detail === undefined ? {} : { detail: o.detail }) })),
|
||||
basis,
|
||||
itemId: context.stepId,
|
||||
/* `CardGateway.decide` 的 variant 只认 'review'(审核卡);card.variant 的类型口子更宽(还有 'data'),
|
||||
那是给执行工单用的 —— 这里按值窄化,别把 'data' 误传给决策卡 */
|
||||
...(action.variant === 'review' ? { variant: action.variant } : {}),
|
||||
});
|
||||
return { wrote: 0, dropped: 0, cardId: id, stop: true };
|
||||
}
|
||||
const { id } = await deps.cards.issue({
|
||||
taskId: context.taskId,
|
||||
ownerId: context.ownerId,
|
||||
humanCallCap: context.humanCallCap,
|
||||
workOrder: 'execution',
|
||||
itemId: context.stepId,
|
||||
card: {
|
||||
mode: 'execution',
|
||||
title: evalText(action.title, base),
|
||||
ask: evalText(action.title, base),
|
||||
basis,
|
||||
/* 零输入:填报全在工作区的件上(2026-08-02 用户定) */
|
||||
fields: [],
|
||||
...(action.variant === 'data' ? { variant: 'data' } : {}),
|
||||
...(action.guide === undefined ? {} : { steps: [...action.guide] }),
|
||||
...(action.standard === undefined ? {} : { standard: evalText(action.standard, base) }),
|
||||
...(action.gateLedgers === undefined ? {} : { gateLedgers: [...action.gateLedgers] }),
|
||||
},
|
||||
});
|
||||
return { wrote: 0, dropped: 0, cardId: id, stop: true };
|
||||
}
|
||||
|
||||
if (action.kind === 'submit') {
|
||||
return { wrote: 0, dropped: 0, submit: { rationale: evalText(action.rationale, base) }, stop: true };
|
||||
}
|
||||
|
||||
/* 五个动词(say/project/analyze/card/submit)上面已全部处理,这里在类型上不可达 —— 留作穷尽性兜底 */
|
||||
throw new AutoRecipeError(index, '动词尚未实现');
|
||||
}
|
||||
|
||||
for (const [index, action] of recipe.do.entries()) {
|
||||
try {
|
||||
if (dirty) {
|
||||
scope = { ...(await deps.data.view(ctx.taskId)), ...results };
|
||||
dirty = false;
|
||||
}
|
||||
/*
|
||||
* **`let` 与 `fields` 同一个 jsonb 重排隐患,同一套治法**(2026-08-30 终审
|
||||
* Important 1,H47 当时只治了 `fields` 那一半 —— 见 `auto-order.ts` 头注)。
|
||||
* `{in:X}` 是 `let` 唯一的官方写法,后一条 let 靠它读前一条已经算出的值,
|
||||
* 键序被 jsonb 打乱同样会让这条依赖读到 `undefined`(未绑定的名字,不报错、
|
||||
* 静默算错)。改按 `orderLets` 的依赖拓扑序求值,只算一次(`letsReady`)。
|
||||
*
|
||||
* **放进第一次循环的 try 里,不是单独一段**——唯一目的是**复用同一个 catch**:
|
||||
* 真撞上环(该在装包静态审就被拦下,见 `static-audit.ts` 新增的 `letsCycle`
|
||||
* 检查,这里是最后一道兜底)交人的路子,与某一笔配方动作本身办不下去完全一样,
|
||||
* 不必另开一条"let 专属"的失败通道(`failedAt.index` 报的是 0,即"第 1 笔"——
|
||||
* 不完全精确,但 `failed.message` 里的原话已经说清是 let 算不出来,够用)。
|
||||
*/
|
||||
if (!letsReady) {
|
||||
for (const [name, expr] of orderLets(recipe.let ?? {})) {
|
||||
lets[name] = evalExpr(expr, { scope, lets, condition });
|
||||
}
|
||||
letsReady = true;
|
||||
}
|
||||
const base: ExprCtx = { scope, lets, condition };
|
||||
if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue;
|
||||
/* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze */
|
||||
if (ctx.quiet && action.kind !== 'project' && action.kind !== 'say') continue;
|
||||
|
||||
const done = await runAction(action, index, base, ctx);
|
||||
wrote += done.wrote;
|
||||
dropped += done.dropped;
|
||||
if (done.wrote > 0 || done.dropped > 0) dirty = true;
|
||||
if (done.cardId !== undefined) issuedCardId = done.cardId;
|
||||
if (done.submit !== undefined) submit = done.submit;
|
||||
if (done.stop) break;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
/*
|
||||
* **失败这一笔不标 internal** —— 它是说给人听的(「交给你处理」),
|
||||
* 该出现在对话区。`say` 同理(它本来就是人话)。(2026-08-29 终审 I5)
|
||||
*/
|
||||
await deps.memory.append({
|
||||
taskId: ctx.taskId,
|
||||
kind: 'observation',
|
||||
title: `配方第 ${index + 1} 笔办不下去:${message} —— 交给你处理`,
|
||||
detail: { auto: `${ctx.stepId}#${index + 1}` },
|
||||
});
|
||||
return { wrote, dropped, ...(issuedCardId === undefined ? {} : { issuedCardId }), failedAt: { index, message } };
|
||||
}
|
||||
}
|
||||
return {
|
||||
wrote,
|
||||
dropped,
|
||||
...(issuedCardId === undefined ? {} : { issuedCardId }),
|
||||
...(submit === undefined ? {} : { submit }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { AgentAction, LoopEngineService, TurnOutcome } from '../../contract
|
||||
import { ForbiddenTerminalError, TURN_ACTION_CAP } from '../../contract.ts';
|
||||
import type {
|
||||
AgentPort,
|
||||
AnalyzeGateway,
|
||||
CardGateway,
|
||||
Clock,
|
||||
ListenerRegistrar,
|
||||
@@ -24,6 +25,7 @@ import type {
|
||||
import { parseCmmn } from '../domain/cmmn-parse.ts';
|
||||
import type { CaseModel } from '../domain/cmmn-parse.ts';
|
||||
import type { CaseRuntime } from '../domain/lifecycle.ts';
|
||||
import { createAutoRunner, recipeIssuesCard } from './auto-runner.ts';
|
||||
import {
|
||||
achieveMilestones,
|
||||
autoCompleteReady,
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
transitionItem,
|
||||
} from '../domain/lifecycle.ts';
|
||||
import { evaluateTable, evaluationOrder } from '../domain/dmn.ts';
|
||||
import { liveStepOf } from '../domain/live-step.ts';
|
||||
import { phaseProgress } from '../domain/phase.ts';
|
||||
|
||||
export type TurnRunnerDeps = {
|
||||
@@ -53,6 +56,7 @@ export type TurnRunnerDeps = {
|
||||
readonly skills: SkillGateway;
|
||||
readonly tasks: TaskContext;
|
||||
readonly clock: Clock;
|
||||
readonly analyze: AnalyzeGateway;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -153,6 +157,15 @@ const REDO_MARK = '验收被打回重做';
|
||||
* (模型 submit 那条是「行动被拒:…」,判齐快道那条是「提交放行被拒:…」)。
|
||||
*/
|
||||
const REFUSE_MARK = '打回之后台账一个字没改';
|
||||
/** 配方办不下去那张兜底卡的题头 —— 认它靠这个前缀,不靠措辞 */
|
||||
const AUTO_FAIL_MARK = '配方办不下去';
|
||||
/**
|
||||
* 「提交放行被拒」那条痕的抬头(2026-08-30 终审 I4 补的常量,行为不是新写的 ——
|
||||
* 这句话原来就是硬编码在两处:`autoCompleteReady` 判齐快道 / `auto.submit` 那一支,
|
||||
* 见 :156 那条既有注释「判齐快道那条是『提交放行被拒:…』」)。命名它是为了下面
|
||||
* `auto.submit` 那一支新增的「同一堵墙撞第二次就交人」能扫痕认出「这是不是同一句话」。
|
||||
*/
|
||||
const AUTO_SUBMIT_REFUSE_MARK = '提交放行被拒';
|
||||
|
||||
export function toolInputHash(input: unknown): string {
|
||||
const text = JSON.stringify(input) ?? '';
|
||||
@@ -424,6 +437,14 @@ const STALL_LIMIT = 5;
|
||||
const STALL_WINDOW = 60;
|
||||
|
||||
export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
const autoRunner = createAutoRunner({
|
||||
data: deps.data,
|
||||
cards: deps.cards,
|
||||
memory: deps.memory,
|
||||
analyze: deps.analyze,
|
||||
sfeel: deps.sfeel,
|
||||
});
|
||||
|
||||
async function loaded(taskId: TaskId) {
|
||||
const found = await deps.state.load(taskId);
|
||||
if (found === undefined) throw new Error(`loop 未装载:${taskId}`);
|
||||
@@ -656,6 +677,11 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* **这一步派的卡穿哪副面孔**(剧本 `cardVariant`,2026-08-27):按行动里的项号查,项号没给 / 挂不上就按
|
||||
* 当下在办的那一步 —— 从前只按项号查,模型漏了 itemId,审核卡就退成「需要你定」。
|
||||
*/
|
||||
/**
|
||||
* 这张台账「归人填」吗 —— 归人填就返回拒绝理由(剧本 `humanWrites`,见 contracts)。
|
||||
* 只拦 `write`:`drop` 得留着,不然模型写错了撤不掉。
|
||||
*/
|
||||
humanWritesOf?: (itemId: string | undefined, address: string) => string | undefined,
|
||||
faceOf?: (itemId: string | undefined) => { variant?: 'review'; checklist?: ReadonlyArray<string>; release?: true } | undefined,
|
||||
): Promise<{
|
||||
runtime: CaseRuntime;
|
||||
@@ -920,6 +946,14 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
const domain = action.address.slice(0, at);
|
||||
const object = action.address.slice(at + 1);
|
||||
|
||||
/*
|
||||
* **人填的表,模型不许写**(2026-08-28,用户送来的实测单):数据工单要人在件上补的那几张表,
|
||||
* 值只可能从人那儿来。实测模型把台账里原 PN 那一行的规格书逐字抄到了候选料号头上 ——
|
||||
* 它不是凭空编,是「手边有一行看着对的就拿来用」。墙立在这儿,路在拒回里(去发那张卡)。
|
||||
*/
|
||||
const humanOnly = humanWritesOf?.(action.itemId, action.address);
|
||||
if (humanOnly !== undefined) throw new Error(humanOnly);
|
||||
|
||||
// 挂项(尽力):阶段不挂(容器)、挂不上不拦 —— 理由随留痕带出,模型下一拍自己修记账
|
||||
const { parsed: model } = await loaded(taskId);
|
||||
const target = model.items.find((i) => i.id === action.itemId);
|
||||
@@ -1508,7 +1542,28 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
for (const [at, entry] of window.entries()) if (entry.title.startsWith(REDO_MARK)) lastRedo = at;
|
||||
if (lastRedo >= 0) {
|
||||
const after = window.slice(lastRedo + 1);
|
||||
const revised = after.some((e) => e.kind === 'action' && typeof e.detail?.['ledger'] === 'string');
|
||||
/*
|
||||
* **配方写下的那一笔也算「改过台账」**(2026-08-29 终审 C3)。
|
||||
* `kind: 'action'` 只有模型的 write / drop 才写;配方的写留痕是
|
||||
* `kind: 'observation'` + `detail.auto` + `detail.ledger`(见 auto-runner 的 project 分支)。
|
||||
* 只认 action 的话,一个全 auto 的放行步在被打回之后**改了台账这道闸也看不见** ——
|
||||
* 每一拍都抛,而下面那条软化路(`refusedBefore`)靠的是模型路径 catch 里写的痕,
|
||||
* 配方路径从前没有 catch,那条痕永远不写:**这堵墙对配方是永远开不了的**。
|
||||
*
|
||||
* **配方那一路要求真写了或真撤了**(2026-08-29 复审 M1/M2 二审):`project` 动作里
|
||||
* `localExisting`(已在库、这一笔不用建)与 `localUnkeyed`(主键算不出来没写)也会
|
||||
* 留痕(M1/M2 立的「一行没写也要出声」),两者台账其实一个字没动。只看
|
||||
* `detail.auto` + `detail.ledger` 这两个键在的话,一颗料 skipExisting、或一行主键
|
||||
* 算不出来,这条痕就照样解锁 —— 人打回重做,配方原样重跑,闸却开了(正是这道闸
|
||||
* 要拦的空转重提)。auto-runner 现在把「真改过」摆成 `detail.changed`,这里照它认;
|
||||
* `kind:'action'` 那一支不必看这个键 —— 模型的 write/drop 一行没变就直接拒(见
|
||||
* :980/:1075),能落成 `kind:'action'` 的一定是真改过的。
|
||||
*/
|
||||
const revised = after.some((e) => {
|
||||
if (typeof e.detail?.['ledger'] !== 'string') return false;
|
||||
if (e.kind === 'action') return true;
|
||||
return typeof e.detail?.['auto'] === 'string' && e.detail?.['changed'] === true;
|
||||
});
|
||||
const refusedBefore = after.some((e) => e.kind === 'result' && e.title.includes(REFUSE_MARK));
|
||||
if (!revised && !refusedBefore) {
|
||||
throw new Error(
|
||||
@@ -1668,6 +1723,49 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
*/
|
||||
const waiting = await deps.cards.unanswered(taskId);
|
||||
if (waiting.length > 0) {
|
||||
/*
|
||||
* **有卡在等人,配方照样跑 —— 只算、只写**(spec §5.4)。
|
||||
* 人在件上按了保存,系统就该按最新的草稿把该铺的行铺出来(「保存即铺格」);
|
||||
* 但不发新卡、不 submit、不调 AI —— 叫了人就等人。
|
||||
*/
|
||||
/* `steps` 要到第 1842 行才 const 出来,这儿够不着 —— 从 model 上现取 */
|
||||
const quietStep = liveStepOf(
|
||||
model.steps ?? [],
|
||||
new Set(runtime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id)),
|
||||
[...enabled.inProgress, ...enabled.entries],
|
||||
);
|
||||
let quietWrote = false;
|
||||
if (quietStep?.auto !== undefined) {
|
||||
const outcome = await autoRunner.run(quietStep.auto, {
|
||||
taskId,
|
||||
stepId: quietStep.item,
|
||||
ownerId: (await deps.tasks.of(taskId)).ownerId,
|
||||
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
|
||||
quiet: true,
|
||||
carded: true,
|
||||
});
|
||||
quietWrote = outcome.wrote > 0 || outcome.dropped > 0;
|
||||
}
|
||||
/*
|
||||
* **这一拍真写下过东西,收拍前把画面重推一遍**(2026-08-30,W05 数据工单浏览器走查
|
||||
* 实测:人给候选料选好芯片类型、按保存,上面的配方铺出 7 行新参数、红带也点名要填
|
||||
* 这 7 格,而这条路从不推帧 —— 任务停在 `awaiting-event`,人刷新页面看到的仍是保存前
|
||||
* 那份快照(件是服务端渲好推来的快照,重放不重渲),被点名的格子屏上根本不存在,
|
||||
* 走查到此卡死。**这极可能与 e2e 反复撞见的「答完卡不推进」异常同一个短路**:
|
||||
* 数据工单的下一轮填报要看这一步刚铺出来的新字段,画面不重推,人 / 脚本都无从下手,
|
||||
* 之后再没有任何事件会把它唤醒。
|
||||
*
|
||||
* 走到这儿状态机什么都没变(quiet 只跑 project/say,不迁移计划项),`runtime` 仍是
|
||||
* 拍首那份,画面按 `quietStep` 声明的那张推就够 —— 与「拍首跑配方」发卡那条同一份
|
||||
* 判断(下面 `issuedCardId` 那处:「这一步声明了画面才推,没声明就沿用上一帧」)。
|
||||
*/
|
||||
if (quietWrote && quietStep?.view !== undefined && quietStep.view !== '') {
|
||||
try {
|
||||
await showView(taskId, quietStep.view, runtime, quietStep.view);
|
||||
} catch {
|
||||
// 呈现失败不影响回路:画面丢一帧,事实一件不少(与末尾推帧同一条纪律)
|
||||
}
|
||||
}
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
@@ -1909,14 +2007,24 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 都把 S1 当成当前这一步:喂给模型的做法是 S1 的、推给人的画面也是 S1 的。
|
||||
* 实测:跑到判定去向了,工作区还停在阶段二那份询价受理需求。
|
||||
*/
|
||||
/*
|
||||
* **取 sweep 之后的那份局面**(2026-08-29 终审 I6)。从前取的是 `runtime`(sweep 之前),
|
||||
* 而 `liveStepOf` 吃的是 sweep 之后的 `stateEnabled` —— 这一拍刚被 `sweepRipe` 收掉的那一步
|
||||
* 不在 `settledIds` 里,照旧被当成「还没干完的那一步」。从前这只影响提示词内容;
|
||||
* 配方接进来之后它驱动**副作用**(再写一遍台账、再 prune 一遍、甚至再发一张卡)。
|
||||
*/
|
||||
const settledIds = new Set(
|
||||
runtime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
|
||||
stateRuntime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id),
|
||||
);
|
||||
/** 还没干完的那一步 —— 「这一单此刻在办什么」的唯一诚实答案;都干完了就是 undefined */
|
||||
const liveStep = [...stateEnabled.inProgress, ...stateEnabled.entries]
|
||||
.filter((e) => !settledIds.has(e.id))
|
||||
.map((e) => stepOf(e.id))
|
||||
.find((x) => x !== undefined);
|
||||
const liveStep = liveStepOf(steps, settledIds, [...stateEnabled.inProgress, ...stateEnabled.entries]);
|
||||
/**
|
||||
* 认不出在办哪一步时退回「在场的第一个有剧本的项」—— **这个兜底只服务提示词**
|
||||
* (给模型一份做法总比整本说明书强),**不驱动配方**(2026-08-29 终审 I6)。
|
||||
* 带 `repetitionRule` 的项收掉之后仍是 Enabled,于是活步都收完的那一拍这里会退回一个
|
||||
* **已经 Completed 的步** —— 让它的配方再跑一遍就是再写一遍台账、再 prune 一遍,
|
||||
* carded 为假时甚至再发一张卡。提示词读错一段无非白费一点上下文,副作用可不是。
|
||||
*/
|
||||
const current =
|
||||
liveStep ??
|
||||
[...stateEnabled.inProgress, ...stateEnabled.entries].map((e) => stepOf(e.id)).find((x) => x !== undefined);
|
||||
@@ -1929,11 +2037,269 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* worldTouched(见 lifecycle.ts)在引擎这一侧本来就是现成的布尔,直接摆成一句
|
||||
* 事实,模型只需要读,不需要从一堆轨迹文本里自己判断「这条回复是不是答的是这一项」。
|
||||
*/
|
||||
/*
|
||||
* **配方段**(spec §5.8 ③④⑤):当前这一步声明了 `auto` 就先把确定的事做完。
|
||||
* · 发了卡 / submit → 这一拍到此为止(零 token,不问模型);
|
||||
* · 没发卡且这一步没有 `guide` → 重求 doneWhen 收步(下面的 sweepRipe 会做);
|
||||
* · 有 `guide` → 照常往下问模型(半 auto)。
|
||||
* 出错 → 留痕交人,不重试、不退回模型。
|
||||
*/
|
||||
if (liveStep !== undefined && liveStep.auto !== undefined) {
|
||||
/* 配方**只认 `liveStep`** —— 见上面 `current` 兜底那条注释(I6) */
|
||||
const current = liveStep;
|
||||
const recipe = liveStep.auto;
|
||||
const auto = await autoRunner.run(recipe, {
|
||||
taskId,
|
||||
stepId: current.item,
|
||||
ownerId: (await deps.tasks.of(taskId)).ownerId,
|
||||
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
|
||||
quiet: false,
|
||||
carded: stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true,
|
||||
});
|
||||
if (auto.failedAt !== undefined) {
|
||||
/*
|
||||
* **配方办不下去 = 交人,而「交人」得真有一张卡**(2026-08-29 终审 I1)。
|
||||
*
|
||||
* 从前这里只 `status: 'awaiting'` 就走了 —— 映射成 `awaiting-event`,而推进器只捡
|
||||
* `running` 的单:卡已答完、没立监听、没有时限,**再没有任何东西会叫醒它**。
|
||||
* 人在工作台上看到的是「等外部回音」,而外面没有任何回音会来 —— 界面撒了谎。
|
||||
* 照熔断兜底卡的成例:发一张卡把办不下去的那一笔原话摆给负责人,立人事件监听。
|
||||
* 走到这儿时这一单**没有**未答的卡(runTurn 开头 `cards.unanswered` 把住了),
|
||||
* 所以不会堆卡;人答完之后若仍失败,再发一张 —— 那是实情,由调人预算兜底。
|
||||
*
|
||||
* **这一句「由调人预算兜底」本身就是引爆点**(2026-08-29 复审 I1 二审):
|
||||
* `deps.cards.issue` 走的是 `human-tool-service.assertBudget`,调人预算一旦触顶
|
||||
* 就抛 `HumanCallBudgetExceededError` —— 而这一段从前是**裸着**调的,一抛就精确
|
||||
* 复现 C3 那种无限热循环(`engine.advance` / `task-service.tick` 都不捕获,
|
||||
* `task-advancer` 只写 stderr 就 break,任务停在 `running`、界面纹丝不动、每 2 秒重来)。
|
||||
* 与 :2073(C3 那处)同款收场:抛了不能让回合崩,退回旧行为(留痕 + `awaiting`)
|
||||
* 总好过热循环。
|
||||
*/
|
||||
const stuck = await deps.tasks.of(taskId);
|
||||
const failed = auto.failedAt;
|
||||
try {
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: stuck.ownerId,
|
||||
humanCallCap: stuck.humanCallCap,
|
||||
workOrder: 'execution',
|
||||
card: {
|
||||
title: `${AUTO_FAIL_MARK}:「${current.item}」第 ${failed.index + 1} 笔`,
|
||||
ask:
|
||||
'这一步由系统按配方确定性执行,它停在了下面这一笔上 —— 不重试、不退回模型(那正是这套机制要消灭的东西)。' +
|
||||
'看原因判断:是数据还差点什么(补齐了这一单就能接着走),还是活动包的配方写错了(去设计台改完重跑)。',
|
||||
basis: [
|
||||
{ k: '这一步', v: current.item },
|
||||
{ k: '第几笔', v: `${failed.index + 1}` },
|
||||
{ k: '办不下去的原因', v: failed.message },
|
||||
],
|
||||
fields: ['decision'],
|
||||
},
|
||||
});
|
||||
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
|
||||
/*
|
||||
* 这里**不再补一句交代**:办不下去的那句话 auto-runner 已经落成系统观察进了对话区
|
||||
* (「配方第 N 笔办不下去:… —— 交给你处理」),发卡本身也落 `card-sent`。
|
||||
* 再写一条 narration 就是同一件事说三遍,而且 narration 是 AI 蓝 ——
|
||||
* 停下来的是系统按配方走不下去,不是 AI 判断如此(spec §5.9:配方那一笔归系统灰)。
|
||||
*/
|
||||
} catch (error) {
|
||||
/* 兜底卡本身发不出去 —— 人要知道为什么停了,痕里把两层原因都摆出来 */
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `配方办不下去,连兜底卡都没能发出去:${reason}`,
|
||||
detail: {
|
||||
auto: current.item,
|
||||
hint:
|
||||
`本该交给人的那句话:「第 ${failed.index + 1} 笔 —— ${failed.message}」。` +
|
||||
'发不出去多半是调人预算已经触顶(去治理台把这一单的调人预算提上去),' +
|
||||
'也可能是活动包配方写错了(去设计台改完重跑)。',
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
runtime: stateRuntime,
|
||||
enabled: stateEnabled,
|
||||
navigation: { kind: 'breakpoint', gaps: [...stateEnabled.gaps] },
|
||||
status: 'awaiting',
|
||||
};
|
||||
}
|
||||
if (auto.submit !== undefined) {
|
||||
/*
|
||||
* **抛了不能让回合崩**(2026-08-29 终审 C3)—— 与 :1738 模型判齐那处同一条理由,
|
||||
* 那儿的注释白纸黑字写着:「推进器会一遍遍重来,每遍再撞一次同一道门」。
|
||||
* 而这一处从前是**裸着**的:`engine.advance` 不捕获、`task-service.tick` 也不捕获
|
||||
* 且 `addConsumption` 排在 advance 之后(一步都不记)、`task-advancer` 只写 stderr 就 break,
|
||||
* 任务留在 `running` —— 下一个 interval 再来一遍:**无限热循环,不烧预算、不换状态、界面纹丝不动**。
|
||||
* 处置与模型路径一致:拒绝 + 留痕(那条痕同时是重做闸的解锁凭据)+ 这一拍到此为止。
|
||||
*/
|
||||
try {
|
||||
const raised = await raiseAcceptance(taskId, stateRuntime, auto.submit.rationale);
|
||||
return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
/*
|
||||
* **同一堵墙撞第二次就交人,别空转到 exhausted**(2026-08-30 终审 I4)。
|
||||
*
|
||||
* 这一支从没有任何模型会读到「提交放行被拒」这句话:全 auto 的步
|
||||
* (`current.guide.trim() === ''`)压根不问模型;半 auto 的步即便有 `guide`,
|
||||
* `auto.submit !== undefined` 也会在问模型**之前**直接 `return`(见上面这个
|
||||
* `if` 分支所在的位置——它在"半 auto 落回问模型"那段代码之前)。而失败的原因
|
||||
* (`acceptanceCriteria` 里未过的机检/裁量)在**这一步自己**这一层无法自愈:
|
||||
* 台账已经不会再变(往前一步的项都已收掉,没有活的编辑面),下一拍重跑同一份
|
||||
* 配方只会拿到同一份数据、算出同一个 `rationale`、撞回同一堵墙 —— 纯粹烧步数预算,
|
||||
* 直到 `caps.steps` 耗尽才停(task-service 的 `markProgress` 每拍都记,
|
||||
* `stallAfterMs` 那道熔断因此永远不会提前触发)。
|
||||
*
|
||||
* **头一次只留痕,当一次软化**(与「重发已答过的卡」「同一份入参重调」同族的
|
||||
* 软硬两段):万一真有什么会在两拍之间变(如另一进程写了同一份台账),给它一次
|
||||
* 机会。**同一句话第二次出现**(靠 `AUTO_SUBMIT_REFUSE_MARK` 前缀 + 原话逐字比对,
|
||||
* 不是靠拍数计数——串位/措辞抖动过一次就会被误判成"不同的墙")就当死墙:
|
||||
* 发一张兜底卡把原话摆给负责人、立监听,照 `auto.failedAt` 那条(上面)同一个模式——
|
||||
* 两者都是「配方自己走不下去,得请人来看」,卡的形状理应一致。
|
||||
*/
|
||||
const already = (await deps.memory.window(taskId, STALL_WINDOW)).some(
|
||||
(t) => t.kind === 'result' && t.title === `${AUTO_SUBMIT_REFUSE_MARK}:${reason}`,
|
||||
);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `${AUTO_SUBMIT_REFUSE_MARK}:${reason}`,
|
||||
detail: { auto: current.item, hint: '配方的 submit 没过落地预检 —— 按理由补齐必交付物或修正台账,下一拍再提交' },
|
||||
});
|
||||
if (!already) {
|
||||
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' };
|
||||
}
|
||||
const stuck = await deps.tasks.of(taskId);
|
||||
try {
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: stuck.ownerId,
|
||||
humanCallCap: stuck.humanCallCap,
|
||||
workOrder: 'execution',
|
||||
card: {
|
||||
title: `${AUTO_FAIL_MARK}:「${current.item}」提交放行连续撞在同一条判据上`,
|
||||
ask:
|
||||
'这一步由系统按配方确定性提交,连续两拍撞在同一条判据上 —— 这一步没有模型会读这条拒回、' +
|
||||
'也没有可编辑的画面能让你就地改(台账在更早一步就已经收口)。看下面的原话判断该怎么办:' +
|
||||
'数据确实还缺什么就去别处把它补齐(这一单仍开着,补完下一拍会自动重试);' +
|
||||
'判据或配方本身有问题就找开发改完重跑。',
|
||||
basis: [
|
||||
{ k: '这一步', v: current.item },
|
||||
{ k: '拒回原话', v: reason },
|
||||
],
|
||||
fields: ['decision'],
|
||||
},
|
||||
});
|
||||
await deps.listeners.register({ taskId, eventType: 'human', keyFilter: { cardId: card.id }, deadline: 'P3D' });
|
||||
} catch (issueError) {
|
||||
const issueReason = issueError instanceof Error ? issueError.message : String(issueError);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title: `提交放行连续被拒,连兜底卡都没能发出去:${issueReason}`,
|
||||
detail: {
|
||||
auto: current.item,
|
||||
hint: `本该交给人的那句话:「${reason}」。发不出去多半是调人预算已经触顶(去治理台把这一单的调人预算提上去)。`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
runtime: stateRuntime,
|
||||
enabled: stateEnabled,
|
||||
navigation: { kind: 'breakpoint', gaps: [...stateEnabled.gaps] },
|
||||
status: 'awaiting',
|
||||
};
|
||||
}
|
||||
}
|
||||
if (auto.issuedCardId !== undefined) {
|
||||
/* 卡发出去了 = 这一步真动过世界 —— 标 worldTouched,自动收步那道门才诚实 */
|
||||
const touched = markWorldTouched(stateRuntime, current.item);
|
||||
await deps.state.save(taskId, model, parsed, touched);
|
||||
/* 这一步声明了画面才推 —— `showView` 的第二参不可选,没声明就沿用上一帧(与既有行为一致) */
|
||||
if (current.view !== undefined && current.view !== '') {
|
||||
await showView(taskId, current.view, touched, current.view);
|
||||
}
|
||||
return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
||||
}
|
||||
if (auto.wrote > 0 || auto.dropped > 0) {
|
||||
/* 写过东西:重新求一遍 doneWhen —— 这一步可能已经办完了 */
|
||||
const freshScope = await deps.data.view(taskId);
|
||||
const reswept = await sweepRipe(stateRuntime, stateEnabled, freshScope);
|
||||
if (reswept.runtime !== stateRuntime) {
|
||||
await deps.state.save(taskId, model, parsed, reswept.runtime);
|
||||
/* `Navigation.kind` 没有 'progressed' 这一档(只有 proceed/breakpoint/converged)——
|
||||
这一拍到此为止、下一拍重新导航,与「判齐失败仍要重来」那处(1749 行)同一种写法 */
|
||||
return { runtime: reswept.runtime, enabled: reswept.enabled, navigation: { kind: 'converged' }, status: 'running' };
|
||||
}
|
||||
}
|
||||
/*
|
||||
* **卡答过了就收步**(spec §5.5)。走到这儿的四个前提都成立:
|
||||
* 这一步声明了 auto、卡发过(worldTouched)、这一拍没有未答的卡(前面 unanswered 把住)、
|
||||
* 配方这一拍也没再发新卡。**人已经签了字,收步是照实记录,不是替人签字** ——
|
||||
* 「人工激活项不给 doneWhen」那条纪律防的是引擎抢在人前面,这里人在前面。
|
||||
*
|
||||
* **半 auto(声明了非空 `guide`)的步不走这条近道**(R9,评审 Minor #1 逼出来的裁定):
|
||||
* 半 auto 存在的意义就是「确定的先做完,剩下的判断仍问模型」—— 人答完卡之后那份判断
|
||||
* (读人写的自由文本、落到具体的行上)恰恰是模型该干的活。收在它前面,等于把半 auto
|
||||
* 的后半截整个删掉。当前没有活动踩到这个组合,但机制不该埋这个坑。
|
||||
*/
|
||||
const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === '';
|
||||
const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
||||
if (noDoneWhen && carded && recipeIssuesCard(recipe) && current.guide.trim() === '') {
|
||||
const active = readyToWork(parsed, stateRuntime, current.item);
|
||||
const settled = transitionItem(active, current.item, 'Completed');
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
/* 标题别以「完成」开头 —— `task-log.actorOf` 按 /^(完成|开始办)/ 认 AI,而这一步是引擎收的 */
|
||||
title: `自动收步:${current.item} —— 卡已交回,这一步到此为止`,
|
||||
/*
|
||||
* **不再标 internal**(2026-08-29,R10)—— 从前的 `internal: true` 会被
|
||||
* `task-log.ts` 的 `machinery()` 挡下,这一笔永远进不了工作日志。
|
||||
* `engine-transition` 本就在骨架里,标题也不匹配「完成/开始办」的正则,
|
||||
* 只要不被 `machinery()` 滤掉,它就会正确地归系统 —— 不必藏成内部痕。
|
||||
*/
|
||||
detail: { item: current.item, auto: current.item },
|
||||
});
|
||||
await deps.state.save(taskId, model, parsed, settled);
|
||||
/* `Navigation.kind` 没有 'progressed' 这一档(只有 proceed/breakpoint/converged,
|
||||
见上面 2014 行那条注释)—— 这一拍到此为止、下一拍重新导航,同一种写法 */
|
||||
return { runtime: settled, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'running' };
|
||||
}
|
||||
|
||||
if (current.guide.trim() === '') {
|
||||
/*
|
||||
* 没有 guide、没发卡、doneWhen 也不成立 → 配方没做到位,是包的 bug,交人。
|
||||
* **kind: 'observation'**(2026-08-29,R10 第六处)—— 从前是 `kind: 'result'`,
|
||||
* 不在 `SKELETON` 里,这条**交人**的消息永远进不了工作日志,单子看着像凭空卡住,
|
||||
* 而人打开日志想知道的第一件事就是「为什么停了」。只改 kind,detail 不动。
|
||||
*/
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: `「${current.item}」的配方跑完了,可这一步还没算办完 —— 交给你看一眼`,
|
||||
detail: { auto: current.item },
|
||||
});
|
||||
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **这一步还欠一张卡** —— 标了人工激活、而这一单里还没给它发过卡 / 立过监听。
|
||||
* 此刻它唯一能做的就是把那张卡发出去:别的话都不该说给它听(见下面 doneWhen 那段)。
|
||||
*/
|
||||
const owesCard =
|
||||
current !== undefined &&
|
||||
parsed.items.find((i) => i.id === current.item)?.manualActivation === true &&
|
||||
stateRuntime.items.find((i) => i.id === current.item)?.worldTouched !== true;
|
||||
const currentTouch = ((): string => {
|
||||
if (current === undefined) return '';
|
||||
const declared = parsed.items.find((i) => i.id === current.item);
|
||||
if (declared?.manualActivation !== true) return '';
|
||||
const touched = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
||||
const touched = !owesCard;
|
||||
/*
|
||||
* 终局放行那一步的事实行**只指向 submit**(2026-08-27 W05 实测):下面那句「唯一合法的动作是先发
|
||||
* 那一张卡」本是给人工激活的执行 / 数据 / 决策步写的,放行步读到它就去发了一张执行工单。
|
||||
@@ -1973,7 +2339,17 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 差的那个数摆出来,这一步就从死胡同变成一句话能办的事:
|
||||
* 「missing_key_total 现在是 1,要它变成 0」。
|
||||
*/
|
||||
current.doneWhen === undefined
|
||||
/*
|
||||
* **还欠一张卡的那一步,不摆干完的判据**(2026-08-28,用户送来的实测单)。
|
||||
*
|
||||
* 判据本意是「引擎自己核,省一拍」,可它同时是**摆在模型眼前的一个目标**,
|
||||
* 后面还跟着一句「成立了就 finish」—— 而人工激活的项在卡发出去之前根本不许 finish
|
||||
* (上面 currentTouch 那句写着「唯一合法的动作是先发那一张卡」)。同一份提示词里两句话
|
||||
* 直接打架:实测它选了判据那句,把台账里原 PN 的资料抄到候选头上去凑「必填不缺」,
|
||||
* 然后 finish 连撞五拍墙、熔断。**一个闲着的模型 + 一个它够得着的判据 = 它一定会去凑。**
|
||||
* 卡发出去、人答过之后判据照常摆(那时它本来就该收步)。
|
||||
*/
|
||||
current.doneWhen === undefined || owesCard
|
||||
? ''
|
||||
: `干完的判据(引擎会自己核):${current.doneWhen}` +
|
||||
`${readingsOf(current.doneWhen, scope)} —— 成立了就 finish ${current.item},` +
|
||||
@@ -2201,6 +2577,21 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
}
|
||||
},
|
||||
index < planned.length - 1,
|
||||
/*
|
||||
* 归人填的台账:**认行动点名的那一项**,点不出(或那一项没剧本)才退回当下在办的那一步。
|
||||
* 与 faceOf 同一套判法。压拍时骨架写在上一步名下、填值写在这一步名下,正好各归各的。
|
||||
*/
|
||||
(itemId, address) => {
|
||||
const named = itemId === undefined ? undefined : stepOf(itemId);
|
||||
const step = named ?? current;
|
||||
if (step === undefined || !(step.humanWrites ?? []).includes(address)) return undefined;
|
||||
return (
|
||||
`${address} 这张表归人填 —— 它是「${step.item}」这一步的数据工单要人在件上补的表(剧本声明 humanWrites),` +
|
||||
'值只能从人那儿来,你没有来源:别拿别的料号 / 别的行的值顶上(那是假数据,后面会被当证据用)。' +
|
||||
'这一步该做的是把那张数据工单发出去(human + execution + 数据工单面孔),人填完交回,判据自己就成立了。' +
|
||||
'已经写进去的错值用 drop 撤掉 —— drop 不拦。'
|
||||
);
|
||||
},
|
||||
// 面孔与人工验收清单按剧本定:行动点名的项声明了就用它的,没声明就按当下在办的那一步
|
||||
(itemId) => {
|
||||
const named = itemId === undefined ? undefined : stepOf(itemId);
|
||||
|
||||
@@ -44,6 +44,12 @@ export type TaskDataGateway = {
|
||||
object: string;
|
||||
label: string;
|
||||
keys: string[];
|
||||
/**
|
||||
* 单标识列(`declarations.ledger[].caseKey`)—— **摆给人看的行标识要去掉这一段**
|
||||
* (2026-08-25 柴浩:「COV-…/CU-0142-01 只保留斜杠右边」):人正在这一单里看,
|
||||
* 单号是脚下的地面,不是区分行的信息。基座那一侧同款做法见 `rowLabel`。
|
||||
*/
|
||||
caseKey?: string | undefined;
|
||||
fields: Array<{ name: string; label: string; type?: string }>;
|
||||
}>
|
||||
>;
|
||||
@@ -59,7 +65,21 @@ export type TaskDataGateway = {
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
}): Promise<{ changed: boolean }>;
|
||||
/**
|
||||
* **值没变就别落笔**(2026-08-29 终审 I2)—— 配方每一拍都把同一张表重投一遍,
|
||||
* 而草稿层的 upsert 尾巴上有 `landed_at = null`(「再写一笔就不再是已落地」)。
|
||||
* 照写的话:数据工单确认入库之后,下一拍配方一重跑,那些行的「已落地」记号就被**悄悄擦掉**,
|
||||
* 整单放行时会被再写一遍正式表、再抛一遍 data 事件 —— 而承诺是「整单放行不再写它、不抛事件」。
|
||||
* 模型那一路不传这个旗:它的「空转写」要被看见(如实回 `changed:false`),行为一个字不变。
|
||||
*/
|
||||
skipUnchanged?: boolean | undefined;
|
||||
/**
|
||||
* **「只建不改」的表碰到已在库的键:跳过,不拒**(2026-08-29 终审 I2)。
|
||||
* 那句拒回是写给模型看的(「别再写它,用 drop 把骨架撤掉」)—— 摆到人面前毫无意义,
|
||||
* 而配方抛出去就是永久停单。对配方来说「这颗料已经在库了」是**常态**,不是错。
|
||||
*/
|
||||
skipExisting?: boolean | undefined;
|
||||
}): Promise<{ changed: boolean; skipped?: boolean }>;
|
||||
/**
|
||||
* **撤掉本单草稿层的一行** —— 写错了得有退路。
|
||||
*
|
||||
@@ -314,3 +334,24 @@ export type TaskContext = {
|
||||
*/
|
||||
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* **一次结构化 AI 调用** —— 配方的 `analyze` 动词(spec §5.2)。
|
||||
*
|
||||
* 与 `AgentPort` 的分工是死的:`AgentPort` 是「问它下一步干什么」,它能调工具、能写台账;
|
||||
* 这一件**只是一个纯函数**:系统组装好入参,它出一份结构化判断,系统把结果落表。
|
||||
* **不给工具、不给记忆、不多轮。** 出参不合 schema 由实现方就地重问一次,再不成就抛。
|
||||
*/
|
||||
export type AnalyzeGateway = {
|
||||
run(input: {
|
||||
readonly taskId: TaskId;
|
||||
/** 活动声明过的模型名 */
|
||||
readonly model: string;
|
||||
readonly instruction: string;
|
||||
/** 系统组装好的入参 —— 一行一条 */
|
||||
readonly payload: readonly unknown[];
|
||||
readonly outputSchema: Readonly<Record<string, unknown>>;
|
||||
/** 归因:`<步名>#<第几笔>` */
|
||||
readonly label: string;
|
||||
}): Promise<{ readonly object: Readonly<Record<string, unknown>> }>;
|
||||
};
|
||||
|
||||
@@ -450,7 +450,15 @@ describe('日志分得清谁干的', () => {
|
||||
it('引擎自己做主的仍署「系统」—— 起单不是谁办的,是流程走到了这儿', () => {
|
||||
const lines = structuredLog([
|
||||
entry('engine-transition', { seq: 1, title: '起单:A1 · 询价受理' }),
|
||||
entry('engine-transition', { seq: 2, title: '自动收步:查历史价' }),
|
||||
/*
|
||||
* **补上生产环境真实的 detail 形状**(2026-08-29,R10,评审 Important)——
|
||||
* `entry()` 工厂不设默认 `detail`,从前这一条造出来的入参是 `{}`,
|
||||
* 根本没走到 `machinery()` 那道 `internal` 过滤,是因为无关的原因才绿的。
|
||||
* `turn-runner.ts` 真实留下的这一笔带 `item` / `auto` 两个键(见 R10 之后的
|
||||
* `turn-runner.ts:2039` 一带),这里照实给上,让这条测试真的守住「不带 internal
|
||||
* 也能显示」这件事,而不是凑巧躲过了过滤。
|
||||
*/
|
||||
entry('engine-transition', { seq: 2, title: '自动收步:查历史价', detail: { item: '查历史价', auto: '查历史价' } }),
|
||||
]);
|
||||
expect(lines.map((l) => l.actor)).toEqual(['系统', '系统']);
|
||||
});
|
||||
@@ -600,6 +608,30 @@ describe('压下的交代不该消失', () => {
|
||||
it('其余内部痕(排障用的原文留档)照旧不摆', () => {
|
||||
expect(structuredLog([entry('observation', { detail: { internal: true, raw: '{坏 JSON}' } })])).toHaveLength(0);
|
||||
});
|
||||
|
||||
/*
|
||||
* **配方那几笔另开一道例外**(2026-08-29 终审 I5,控制端裁定 F2)。
|
||||
* `internal` 是 `machinery()`(工作日志)与 `chat-projection`(对话区)**共用的一个旗** ——
|
||||
* 上一轮为了让配方留痕进工作日志把它整个去掉了,副作用是那些机械痕一笔一个气泡
|
||||
* 跑进了对话区。现在配方那几笔照旧标 `internal`(对话区据此挡住),
|
||||
* 工作日志这一侧按 `detail.auto` 放行(与 `detail.say` 同款)。
|
||||
*/
|
||||
it('**带 auto 的内部痕进日志** —— 配方做的事正是人回看时最想看的', () => {
|
||||
const line = structuredLog([
|
||||
entry('observation', {
|
||||
seq: 11,
|
||||
title: '芯片主数据:写下 2 行',
|
||||
detail: { internal: true, auto: 'S0_bind#1', ledger: 'parts.profile' },
|
||||
}),
|
||||
])[0];
|
||||
expect(line?.title).toBe('芯片主数据:写下 2 行');
|
||||
// 配方不是 AI 判的,也不是人办的 —— 归系统
|
||||
expect(line?.who).toBe('system');
|
||||
});
|
||||
|
||||
it('auto 不是字符串就不算例外 —— 只认结构化的那个键,不靠有没有这个词', () => {
|
||||
expect(structuredLog([entry('observation', { detail: { internal: true, auto: true } })])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -642,3 +674,26 @@ describe('进对话区的交代也只摆首句', () => {
|
||||
expect(detailOf(one)).toEqual(['这句已进对话区。']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 日志三色:配方做的事归系统(2026-08-29,ADR-014)。
|
||||
*
|
||||
* 用 `action-intent` 造例 —— 它在 `SKELETON` 里(能真的走完 `structuredLog` 全程,
|
||||
* 不像 `result` 那样在骨架这一关就被滤掉、永远到不了 `actorOf`),
|
||||
* 且不带 `detail.auto` 时会落进 `actorOf` 的 `default` 分支(署「任务助手」),
|
||||
* 正好能对照出「加了 `detail.auto` 之后改署系统」这条判定是否生效。
|
||||
*/
|
||||
describe('日志三色:配方做的事归系统', () => {
|
||||
it('带 detail.auto 的那几行是**系统**做的 —— 既不是 AI 判的,也不是人办的', () => {
|
||||
const lines = structuredLog([
|
||||
entry('action-intent', { title: '芯片主数据:写下 2 行', detail: { auto: 'S1#3' } }),
|
||||
]);
|
||||
expect(lines[0]?.who).toBe('system');
|
||||
expect(lines[0]?.actor).toBe('系统');
|
||||
});
|
||||
|
||||
it('没有 detail.auto 的 action-intent 照旧记在 AI 名下 —— 老行为一个字不变', () => {
|
||||
const lines = structuredLog([entry('action-intent', { title: '我判齐了' })]);
|
||||
expect(lines[0]?.who).toBe('agent');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,6 +269,13 @@ function actionOf(e: MemoryEntry): string {
|
||||
* 由上层换成名字;换不到就退回「员工」,不编名字。
|
||||
*/
|
||||
function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined; who: 'owner' | 'agent' | 'system' } {
|
||||
/*
|
||||
* **配方做的事归系统**(2026-08-29,ADR-014)。自主率的分法要认第三档:
|
||||
* 它既不是 AI 判出来的,也不是人办的 —— 是系统按声明做的。
|
||||
* 不加这一条的话,`kind: 'result'` 会掉进 `default` 分支被记在「任务助手」名下,
|
||||
* 日志上看着像 AI 干的 —— **界面不撒谎**。
|
||||
*/
|
||||
if (typeof e.detail?.['auto'] === 'string') return { actor: '系统', actorId: undefined, who: 'system' as const };
|
||||
const by = e.detail?.['by'];
|
||||
const id = typeof by === 'string' && by !== '' ? by : undefined;
|
||||
switch (e.kind) {
|
||||
@@ -349,7 +356,22 @@ function machinery(e: MemoryEntry): boolean {
|
||||
* 现在带 `say` 的放行(它是人话,正是要留的那样东西);
|
||||
* 其余内部痕(重问原文这类给排障用的)照旧不摆。
|
||||
*/
|
||||
if (e.detail?.['internal'] === true && typeof e.detail?.['say'] !== 'string') return true;
|
||||
/*
|
||||
* **`internal` 是两个投影共用的一个旗**(2026-08-29 终审 I5,控制端裁定 F2):
|
||||
* `machinery()` 拿它决定「进不进工作日志」,`chat-projection` 拿同一个键决定「进不进对话区」。
|
||||
* 上一轮为了让配方留痕进工作日志,把它整个去掉了 —— 副作用是「芯片主数据:写下 2 行」
|
||||
* 「已把 8 条参数交给模型逐条判定」这类**机械痕跑进了对话区**,一笔一个气泡,
|
||||
* 而这个仓花了很大代价立起「对话区一轮一句,不是一拍一句」。
|
||||
* 治法与 `say` 同款:给 `detail.auto` 开一个例外 —— 配方那几笔照旧标 `internal`
|
||||
* (对话区据此挡住),工作日志这一侧按 `auto` 放行。**两件事各有各的开关。**
|
||||
*/
|
||||
if (
|
||||
e.detail?.['internal'] === true &&
|
||||
typeof e.detail?.['say'] !== 'string' &&
|
||||
typeof e.detail?.['auto'] !== 'string'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (RESTATED.some((re) => re.test(e.title))) return true;
|
||||
if (e.kind === 'thought') return true;
|
||||
if (e.kind === 'engine-transition' && e.title.startsWith('装载案例模型')) return true;
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# ACT-WORK-05 差异评估 —— 端到端测试记录
|
||||
|
||||
> **历史记录,已停止更新(2026-08-30 标注)**:这份文档记的是**旧九步案例模型**
|
||||
> (`S0_bind` / `S0d_p08_master` / `S0d_p10_document` / `S1_input_confirm` /
|
||||
> `S2_normalize` / `S3_assess` / `S4_review` / `S5_prepare_landing` / `T_release`)
|
||||
> 从零到 released 的走查账,时间跨度到 2026-08-27 为止。**2026-08-29 的整体重写**
|
||||
> (Task 3,`docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §4)
|
||||
> 把九步砍成了四步(`S1_inputs` / `S2_assess` / `S3_review` / `T_release`,行为改由
|
||||
> `PlanStep.auto` 声明式配方接管,ADR-014)——**下面九个计划项的表、逐轮走查、A–Z
|
||||
> 发现表,描述的都是这个已经不存在的旧结构**,不代表当前 `case-model.ts` 的真实形状。
|
||||
> 保留只为存档:六轮浏览器走查与脚本 e2e 逼出的 26 条发现(A–Z)里,相当一部分
|
||||
> (措辞纪律、`refersTo`/`requiredWhen` 这类结构治法、「机检只读基座数」的又一次违反)
|
||||
> **后来被折进了四步重写的设计与 `docs/debt.md`**,不是白跑的,只是不该再被当成
|
||||
> 「这个活动现在长什么样」的说明书来读。**四步重写之后的发现不再追加进这份文档**——
|
||||
> 散落在源码注释(`case-model.ts`/`manifest.ts` 的大注释)、`docs/debt.md`、以及
|
||||
> `.superpowers/sdd/2026-08-29-act-work-05-rewrite/` 下的过程记录里。
|
||||
>
|
||||
> 以下原文未删改。
|
||||
|
||||
---
|
||||
|
||||
> 这份文档记的是**每一轮实跑逼出了什么、改了什么、怎么验证的**。
|
||||
> 活动包:`activities/part-substitution-assessment`;业务出处 `docs/SS02-ACT-WORK-05-html/`。
|
||||
> 台账字段口径见 `docs/数据域字段说明-W05.md`。
|
||||
@@ -239,6 +258,7 @@ P-11 一条(`LM2596S-3.3 → LM2596S-ADJ | alternate | fae_confirmed`);P-01 回
|
||||
| AA | **数据工单确认了,数据没入库**:卡上写着「确认后这批主数据即入库」,人建好的 P-08/P-10 记录要等整条评估放行才进正式表;试跑单(不落主数据)永远不进(2026-08-27 用户指出) | 草稿层通往正式表只有整单放行一道口子(`releaseWrite(taskId)`),回传路由只记办结 + 抛人事件;而引擎的 `mine:` / `_rows` / 哨兵全靠草稿层认「本单写下的行」,清掉就断 | `releaseWrite(taskId, {only, cause})` 范围放行:只并这张卡 `gateLedgers` 那几张表,并过的行**留在草稿层打 `landed` 记号**(读面不变,整单放行不再写);回传路由在保存与回传落定后调它,痕点名几张表各几行;`createOnly` / `drop` 对已落地行改口(别 drop,撤了读面少一行);活动 1.4.8 剧本改口「已入正式表」 | **平台**(结构)+ 活动 |
|
||||
| AC | **两颗输入电压范围一模一样却判 MISMATCH、整颗不推荐**(2026-08-27 用户送来 0bddd862):客户写「输入电压在 45V 以内」,模型译成 requirement `>= 45`,原 PN 与候选上限都是 40V | `judgePair` 客户约束一票否决:候选过不了客户线就 mismatch,不看原 PN 自己过不过得了 —— 需求边界与在用料的差距被记在候选头上 | 工具 1.1.0:候选与原 PN 相等 / 盖住且原 PN 也不满足客户线 → 仍按比对结果判(MATCH,风险中),备注点名「需求边界,请回头核对需求」;候选更窄且过不了线仍 MISMATCH | **平台**(工具) |
|
||||
| AD | **比对没有「认真比每一条」的机制**(2026-08-27 用户:「这个做差异评估比对的 harness 肯定有问题」):模型交代里念出「输入上限 32V 低于客户 40V」—— 这两个数不是这单的,是 S3 剧本示例的原话;工具入参只存哈希,喂了哪 9 对值事后查不到;写回台账时还把工具的 `equal` 改成了 `less` | 比对入参由模型从台账抄给工具(抄错 / 抄示例都拦不住),出参再由模型抄回台账,两头都没有基座核;示例引了具体值 | 工具 1.2.0:只报原 PN + 候选料号 + 客户约束,参数 / 封装 / 生命周期 / 证据引用由工具从 parts.* 现读(taskId 经运行时交给处理器,读任务视图);出参带原值,写盘照抄;≤800 字的工具入参整份留痕;剧本示例只留占位形 | **平台**(工具运行时 + 工具)+ 活动 |
|
||||
| AE | **AI 把原 PN 的规格书填到了候选料号的资料记录上**(2026-08-28 柴浩实测,单 6ddcecf8):P-10 那一行的标题 / 版本 / 链接与台账里 LM2596S-5.0 那一行逐字相同;随后 `finish S0d_p10_document` 连撞五拍墙熔断 | ①它把 P-08 的回卡认成了 P-10 的(卡从没发过);②我 08-27 给这一步加的 doneWhen 把「资料必填不缺」摆成目标、还配一句「成立了就 finish」,与「唯一合法的动作是先发那一张卡」在同一份提示词里打架;③它顺手把 `exists_status` 改成 created,发卡名单自己清空 —— 一个合法动作都不剩 | 剧本 `PlanStep.humanWrites` 声明「这几张表归人填」,模型 write 当场拒、drop 不拦;还欠一张卡的那一步提示词不摆 doneWhen;W05 的 S0d_p10_document 声明 `humanWrites: ['parts.document']` 并从 writes 里去掉 | **平台**(引擎 + 契约)+ 活动 |
|
||||
|
||||
**可推广的三条**(已记 `docs/debt.md`):
|
||||
|
||||
|
||||
@@ -112,10 +112,17 @@
|
||||
| `data-blank` | 与 `data-slot` 同元素 | 空值**真空**(不写「无」)—— 空格子的胶囊、占位交给 CSS `:empty::after` |
|
||||
| `data-class-of="键"` | 任意元素 | 把值消毒成 `v-<小写值>` 追加进 class(数值就是 `v-0`/`v-1`/…)。**判断留在样式表** |
|
||||
| `data-col="台账列名"` | 可改格子(与 `data-slot` 同元素) | 渲染时多渲一个孪生 `<input class="jc-cell">`;写的是**台账列名**不是表头文字(表头改说法不该让写回错位) |
|
||||
| `data-options="域.对象"`(配 `data-option-value` / `data-option-label`) | `<select>` | **选项从台账现读**:一行一个 `<option>`,`value` 取值列、显示取名列,前面固定留一个空选项(「还没选」得说得出来)。地址写法与 `data-rows` 一致。选中哪一个仍由 `selectCurrent` 按这一行的值定 —— 模版这一侧照旧只取值不判断 |
|
||||
| `data-edit="域.对象"` | 可改表的行容器 | 这组行可就地改;保存按它认地址 |
|
||||
| `data-value` | `<input>` | 值进 value 属性(孪生件用,模版一般不手写) |
|
||||
| `data-file-of="主键列"` | 行内任意元素 | 这一格**点开是一份资料文件**:渲成 `data-file="域.对象/主键值"`,外层接住就跳到查看面。那一列空着就整个属性去掉 —— 没有文件的行不该长得像能点开 |
|
||||
|
||||
**`data-options` 与 `data-slot` 不能共存**(2026-08-29):同一个 `<select>` 上若还写了
|
||||
`data-slot`,渲染时会被 `fillOptions` 一并摘掉、不起作用 —— `data-slot` 换的是**文本**,
|
||||
而 `<select>` 的内容是一串 `<option>`,留着它会被后面的槽位填值当成普通文本槽,把刚铺好的
|
||||
选项整段冲成一行数字/文字。**下拉的「当下值」由 `data-col` 决定**(`selectCurrent` 认它),
|
||||
不必也不该再写 `data-slot`。
|
||||
|
||||
**资料文件**(2026-08-23):一行台账还可以是一份**真文件**。台账声明里写
|
||||
`file: { titleColumn, bodyColumn, formatColumn }`(它同时是授权面 —— 不声明就点不开),
|
||||
正文一律 Markdown 原文写在 `bodyColumn`,格式列写 `md` / `html` / `docx` / `xlsx`;
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
# ADR-014 确定性步骤:确定的活由引擎按声明做,不问模型
|
||||
|
||||
状态:**已实现**(2026-08-29;设计见 `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §5)
|
||||
背景出处:ACT-WORK-05 差异评估九步流程盘点(§2)、`docs/ACT-WORK-05-端到端测试记录.md` 发现表 A–AE。
|
||||
关联:ADR-004(活动包 ABI:**系统不执行代码即知包为何物**)、`docs/活动开发规范.md` §4.4(自动收步)、
|
||||
`PlanStep.humanWrites`(2026-08-28 立的墙)、ADR-005(工具契约与落地预检)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题
|
||||
|
||||
用户 2026-08-28 的原话:
|
||||
|
||||
> 「这个不是一个确定的流程吗?根本不需要交给 ai,你直接查库不就知道有没有了吗?」
|
||||
|
||||
指的是 W05 的「逐个料号查库 → 不在库就铺骨架、发数据工单」。这一段没有任何判断余地,却写成了模型的 guide。
|
||||
不是活动作者偷懒 —— **平台里没有别的落点**:
|
||||
|
||||
| 现有机制 | 能做 | 不能做 |
|
||||
|---|---|---|
|
||||
| 纯工具(`pure`) | 算、读台账(1.2.0 起) | 写台账、发卡 |
|
||||
| 判定表(DMN) | 出结论 | 写台账、发卡 |
|
||||
| `doneWhen` | 判「办完没有」,引擎自己收步 | **做事** |
|
||||
| 哨兵(`entryCriteria`) | 判「能不能进这一步」 | **做事** |
|
||||
| 视图绑定语言 | 把台账取出来**摆到屏上** | 写回去 |
|
||||
|
||||
「读台账 + 写草稿 + 发人卡」跨了三样,没有一个机制能整段承接。而引擎这三样能力**全有**
|
||||
(`data.view` / `data.draftWrite` / `humanTool.issueExecution`)—— 缺的只是**声明面**。
|
||||
|
||||
`packages/activity-sdk/src/define.ts` 里 `run?` 的注释早就写着:
|
||||
|
||||
> 留着它是为**确定性子序列(结晶片段)**将来有个落点;不写就是不写,不是缺件。
|
||||
|
||||
这份 ADR 就是给那个落点定形。
|
||||
|
||||
**代价已经付过。** W05 的事故里,至少八条落在「本来就该是确定的」那几步上(§7 逐条验算)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 盘点:W05 九步里哪些是确定的
|
||||
|
||||
| 步 | 确定的部分 | 需判断的部分 |
|
||||
|---|---|---|
|
||||
| **S0_bind** 建上下文 | 铸案号 · 查客户在不在库 · 写需求单(入参搬运+常量)· 逐料号查库写核对表 · 写结论行 | **零** |
|
||||
| **S0d_p08** 派主数据单 | 定名单 · 铺三张表骨架 · 发卡 · 人交回后改旗 | 有 datasheet 附件时从原文抽参数值 |
|
||||
| **S0d_p10** 派资料单 | 定名单 · 发卡 · 人交回后改旗 | **零** |
|
||||
| **S1** 评估输入清单 | 清单九行(四行绑定投影 + 两行固定缺口 + 三行常量默认)· 发执行单 · 把 supplement 抄进需求单 | 人补的自由文本**归纳**成技术约束 |
|
||||
| **S2** 标准化对照表 | 关系骨架 · 逐参数对照行(原 PN 参数集 × 候选同名行的 join)· 补封装/生命周期两行 | 单位换算;`constraint_type` 未标时归类(**应进 DMN,不进配方**) |
|
||||
| **S3** 逐项差异评估 | 工具串接 · 四态照抄 · 计数 · `difference_summary` · 置信取最小 | **客户原话 → 约束式** · 影响陈述 · UNKNOWN 去向与验证条件 · 推荐理由扩写 |
|
||||
| **S4** 结论审核 | 发决策卡(内容是计数投影)· approve 收步 | **读人的自由文本意见 → 定位到具体行 → 改实** |
|
||||
| **S5** 拟落账 | 分析状态推 FAE 确认 · 回写需求单五项(风险取最高档、VAL 号拼接、编号、常量三项) | 只有 summary 一句人话(可模板化) |
|
||||
| **T_release** 放行 | submit · 无分支去向 | 两条裁量判据 · 打回后修订 |
|
||||
|
||||
**agent 真正不可替代的只有四类**:①自然语言 → 结构化;②读人的意见并落到具体行;③写给人看的话;
|
||||
④非结构化来源抽取(datasheet 抽值、单位换算)。第四类在实跑中一次都没发生过(没传附件)。
|
||||
|
||||
**结论:九步里三步整个确定(S0_bind / S0d_p10 / S5),四步八成确定,只有 S3 是 agent 的主场。**
|
||||
按拍数算,一单十来拍里有七八拍在做确定性的活 —— 而每一拍都是一次采样,都有编错号、抄错行、漏一列的机会。
|
||||
|
||||
---
|
||||
|
||||
## 3. 决策
|
||||
|
||||
### 3.1 声明式配方,**不执行活动包代码**
|
||||
|
||||
`sys.activity_package.artifact`(包的 `index.js`)存了库,但**至今一次都没被执行过**。这份设计**不开这条路**:
|
||||
|
||||
- ADR-004 的立场是「系统不执行代码即知包为何物」;配方是**数据**,装进来仍然只是数据,
|
||||
可静态审、可回放、可在治理台上摆出「这一步系统会做什么」。执行代码则相反。
|
||||
- 执行第三方包要配沙箱、超时、内存墙,而 `scanArtifact` 只是文本扫描,拦不住混淆。
|
||||
- 配方要表达的形状**很窄**(§3.2 五个动词),不值得为它开一个能跑任意代码的面。
|
||||
|
||||
**代价**:表达力有上限。上限之外的**不要硬塞** —— 退回模型,或者把那一段切成独立的 agent 步(§3.4)。
|
||||
|
||||
### 3.2 五个动词,复用已有的两套求值
|
||||
|
||||
**改口**(2026-08-29,落地时定):设计之初只列了三个,实现时补了 `analyze` 与 `submit`
|
||||
(§11 待定①②随之销账,见该节按语)。配方有五个动词:
|
||||
|
||||
| 动词 | 做什么 |
|
||||
|---|---|
|
||||
| `project` | 从「某个来源的每一行」投影出台账行,`draftWrite` 落草稿层 |
|
||||
| `analyze` | 一次结构化 AI 调用:系统组装入参、声明出参 JSON Schema,出参落拍内作用域供后续 `project` 消费。不给工具、不给记忆、不多轮 |
|
||||
| `card` | 发一张人类工具的卡(执行工单 / 数据工单 / 决策卡),**至多一张** |
|
||||
| `submit` | 提交放行,发验收卡挂起(走与模型 `submit` 同一条 `raiseAcceptance`) |
|
||||
| `say` | 一句给人看的交代(模板串,不是模型生成) |
|
||||
|
||||
条件与聚合**不发明第二套求值**:
|
||||
- 条件用 **S-FEEL**(`when`,对任务视图求值)—— 与哨兵、`doneWhen` 同一个求值器;
|
||||
- 计数/聚合用**基座读数**(`_rows` / `_missing` / `_distinct_x` / tally 计数)—— 本来就在作用域里。
|
||||
|
||||
### 3.3 `auto` 是「问模型之前先把确定的事做了」,不是「整步替代」
|
||||
|
||||
`PlanStep.auto` 是一个属性。引擎在这一步的**每一拍拍首**先跑配方,然后:
|
||||
|
||||
- 配方发了卡 → 这一拍到此为止(**零 token**,不问模型);
|
||||
- 没发卡且这一步**没有 `guide`** → 求 `doneWhen`,真了收步、进下一步(下一步若也是 auto 就接着跑);
|
||||
- 没发卡且这一步**有 `guide`** → 照常问模型(半 auto:确定的已经做完,模型只面对剩下的判断)。
|
||||
|
||||
这样老步骤一个字不改,可以一步一步搬。
|
||||
|
||||
### 3.4 作用域:**一个计划项内,到交出控制权为止**
|
||||
|
||||
配方跑到「发卡」就结束 —— 与现有 `handsOff` 语义(ask/human/decide/listen/submit 是交出控制权)对齐。
|
||||
人答回来之后,那一步若还有活,归 `guide`。
|
||||
|
||||
**推论(重要)**:一步要么确定,要么不是。**不做「一半 auto」的复杂机制** —— 判断点要先从那一步里切出去。
|
||||
例:S0d_p08 的「datasheet 抽值」应当移出这一步(归到起单读来件),这一步才变成纯 auto。
|
||||
**这条逼着活动作者把判断点想清楚,是特性不是限制。**
|
||||
|
||||
### 3.5 幂等
|
||||
|
||||
配方会被反复进入(人答卡后再 tick),必须幂等:
|
||||
|
||||
- **写**:`draftWrite` 本来就是同 PK 覆盖。值没变 → **静默跳过,不留痕、不算空转被拒**
|
||||
(「写了等于没写」那条判据是拦模型空转的,配方不是模型);
|
||||
- **发卡**:先看 `worldTouched` —— 发过就不再发。人工激活的 auto 步,**配方发卡就是启动**。
|
||||
|
||||
### 3.6 出错 = 交人,不重试、不退回模型
|
||||
|
||||
配方抛错(地址不存在、S-FEEL 算不出、卡骨架不合契约)是**活动包的 bug**:如实留痕 + 交人。
|
||||
**不重试五遍,也不退回模型** —— 退回模型就是让它去凑,那正是这份 ADR 要消灭的东西。
|
||||
|
||||
### 3.7 静态审在装的时候把住
|
||||
|
||||
`staticAudit` 扩到配方(与 `refersTo` 目标检查同款,写错地址 = 每一单都错):
|
||||
|
||||
- `into` / `each` / `lookup` / `joinRows` 的台账地址必须在 `declarations.ledger` 里(与写的授权面同一份名单);
|
||||
- `fields` 的列名必须在该对象的列声明里;
|
||||
- `when` 的 S-FEEL 必须能解析,引用的名字要绑得上(`boundNames`,已有);
|
||||
- 卡骨架的 `view` 必须在 `viewTemplates` 里;
|
||||
- 同一张表既在 `auto.project` 里又在 `humanWrites` 里 → **警告**(铺骨架合法,但要作者确认列不重叠)。
|
||||
|
||||
### 3.8 观测与度量
|
||||
|
||||
- 配方的每一笔照常进 `sys.task_memory`,`detail.auto = '<步名>#<第几笔>'`;
|
||||
工作日志三色里归**系统灰**(已有档,不必新增);
|
||||
- 治理台的活动详情能摆出「这一步系统会做什么」(配方是数据,渲染得出来)—— 这是相对提示词的一大好处;
|
||||
- **自主率的分法要认第三档**:现在两档(AI / 人),配方做的事既不是 AI 判的也不是人办的,是系统做的。
|
||||
|
||||
**改口**(2026-08-29,实现时踩出来的):`docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`
|
||||
§5.9 写的是「`analyze` 那一笔归 AI 蓝」—— 那句要改口,**它也归系统灰**。理由:那一行记的是
|
||||
「系统把 N 条参数交给模型、拿回 M 条结论」,发起与落表都是系统做的,模型只提供内容;
|
||||
内容本身的归属在它落下的台账行上看得见,不必让日志行冒充成「AI 判的」。
|
||||
连带修了一处会让整条留痕**静默消失**的坑:配方留痕最初用的是 `kind: 'result'` —— 那不在工作日志
|
||||
的骨架集合里(`result` 是进模型叙事轨迹那一类,不是给人看的那一类);`project` / `analyze` /
|
||||
「自动收步」那几笔当时又都标了 `internal: true`,会被 `machinery()` 再滤掉一道 ——
|
||||
**三段各自「正确」,合起来一笔都进不了工作日志**,人打开日志想知道「系统刚做了什么」,
|
||||
看到的是空的。现在统一改成 `kind: 'observation'` 且不标 `internal`(「已发过卡,不再发第二张」
|
||||
这一笔仍保留 `internal` —— 它每拍都打一遍,是排障痕,不是给人看的交代)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 声明形状
|
||||
|
||||
> **这一节是 2026-08-28 的原始提案,落地时有出入。精确语法以
|
||||
> `packages/contracts/src/auto-recipe.ts` 与《活动开发规范》§4.6 为准。**
|
||||
> 已知的三处差异(下面 §4.1/§4.2 的例子按原始提案写,未逐行改过,读的时候按这三条心里默认翻译):
|
||||
> `workOrder` → `as`(且没有 `entry`,只有 `execution` / `decide`);**卡上不写 `view`** ——
|
||||
> 画面归 `PlanStep.view`(已有机制,一处声明,§3 没提是因为原提案漏写了这条,不是后来删的);
|
||||
> `{ self: true }` 与 `{ joinRows: … }` **未实现** ——`each: 'in:candidateInternalPns'` 这种
|
||||
> **扁平数组**(元素不是对象、是原始值)的本项,实现里是 `rowsAt` 把每个元素包成 `{ value: 原值 }`
|
||||
> 再取 `{ col: 'value' }`,不是 `self`;`joinRows`(多行一列拼成串)没有对应写法,真要拼多行,
|
||||
> 目前得退回模型或切成独立步骤。
|
||||
|
||||
```ts
|
||||
// PlanStep 新增(可选,老活动一个字不改)
|
||||
auto?: AutoRecipe;
|
||||
|
||||
type AutoRecipe = {
|
||||
/** 配方级局部值:先算一遍,后面所有表达式都能引用({in:'evalRef'})—— 整单只有一个案号,靠它 */
|
||||
readonly let?: Record<string, AutoExpr>;
|
||||
/** 按序执行;每一笔可带 when(S-FEEL),假就跳过这一笔 */
|
||||
readonly do: ReadonlyArray<AutoAction>;
|
||||
};
|
||||
|
||||
type AutoAction =
|
||||
| { kind: 'project'; when?: string; into: string; from: AutoSource; fields: Record<string, AutoExpr> }
|
||||
| { kind: 'card'; when?: string; workOrder: 'execution' | 'entry'; variant?: 'data';
|
||||
title: AutoExpr; gateLedgers?: string[]; view?: string; /* 指导书等静态文本直接写 */ }
|
||||
| { kind: 'say'; when?: string; text: AutoExpr };
|
||||
|
||||
type AutoSource =
|
||||
| { once: true } // 一行
|
||||
| { rows: ReadonlyArray<Record<string, AutoExpr>> } // 固定几行(S1 的默认三项)
|
||||
| { each: string; where?: Record<string, AutoExpr>; // 'in:candidateInternalPns' | 'mine:域.对象' | 'ledger:域.对象'
|
||||
then?: { each: string; where?: Record<string, AutoExpr> } }; // 第二层,**最多两层**
|
||||
|
||||
type AutoExpr =
|
||||
| string | number | boolean // 常量
|
||||
| { in: string } // 入参 / case:* / 基座读数 / let
|
||||
| { self: true } // each 是数组时的本项
|
||||
| { col: string } | { outer: string } // 本行的列 / 外层那一行的列
|
||||
| { cat: AutoExpr[] } // 拼接
|
||||
| { fmt: AutoExpr; as: 'digits' } // 只留数字(铸号)
|
||||
| { lookup: string; by: Record<string, AutoExpr>; take: string; else?: AutoExpr } // 查另一张表的一列
|
||||
| { joinRows: string; where?: Record<string, AutoExpr>; take: string; sep?: string } // 多行一列拼成串
|
||||
| { when: string; then: AutoExpr; else: AutoExpr }; // S-FEEL 条件(可嵌套)
|
||||
```
|
||||
|
||||
**字段按依赖拓扑序求值,不按声明/存储顺序**(2026-08-30 全分支终审 Important 1 改写此段 ——
|
||||
`fields` / `let` 存进 `sys.activity_package.manifest` 这个 jsonb 列之后,对象键的原始书写顺序
|
||||
不再保证,PG 会按「先短后长、同长字典序」重排;`orderFields`/`orderLets`
|
||||
按字段名之间的真实依赖排出一个安全序,真正的环在装包静态审那一关就被拒装,不留到运行时
|
||||
静默算错。见 `apps/api/src/modules/loop-engine/internal/domain/auto-order.ts`),
|
||||
已算出的字段进同一行的 S-FEEL 作用域 —— 「先 lookup 出依据,再按依据判 found/absent」
|
||||
这种写法靠它成立。
|
||||
|
||||
### 4.1 例:S0d_p10_document(整步确定,最小的那个)
|
||||
|
||||
```ts
|
||||
auto: {
|
||||
do: [
|
||||
{ kind: 'card',
|
||||
when: 'sub_eval_pn_check_exists_status_absent > 0',
|
||||
workOrder: 'execution', variant: 'data', view: 'p10-document-create',
|
||||
gateLedgers: ['parts.document'],
|
||||
title: { cat: ['数据工单 · 原厂技术资料创建(P-10):',
|
||||
{ joinRows: 'mine:sub_eval.pn_check', where: { exists_status: 'absent' },
|
||||
take: 'internal_pn', sep: '、' }] } },
|
||||
{ kind: 'say', text: '这几颗料的原厂技术资料库里查无记录,已派数据工单请你登记。' },
|
||||
],
|
||||
}
|
||||
// guide 不写。doneWhen 照旧:parts_document_rows >= parts_profile_rows and parts_document_missing = 0
|
||||
// 人在件上填完确认 → 数据即入库 → 下一拍 doneWhen 成立 → 引擎收步。**模型在这一步一次都不出现。**
|
||||
```
|
||||
|
||||
### 4.2 例:S0_bind 的铸号与存在性核对
|
||||
|
||||
```ts
|
||||
auto: {
|
||||
let: {
|
||||
digits: { fmt: { in: 'case:openedAt' }, as: 'digits' },
|
||||
evalRef: { cat: ['W05-', { in: 'digits' }] },
|
||||
requestId:{ cat: ['REQ-', { in: 'digits' }] },
|
||||
},
|
||||
do: [
|
||||
// 客户不在库 → 发数据录入卡,这一拍到此为止
|
||||
{ kind: 'card', when: 'customer_profile_rows = 0', workOrder: 'entry', /* … */ },
|
||||
{ kind: 'project', into: 'demand.request', from: { once: true },
|
||||
fields: { request_id: { in: 'requestId' }, customer_code: { in: 'customerCode' },
|
||||
requirement_version: 'V1', request_type: '技术需求',
|
||||
request_description: { in: 'requestDescription' },
|
||||
input_snapshot_ref: { cat: ['SNAP-', { in: 'evalRef' }] },
|
||||
original_internal_pn: { in: 'originalInternalPn' },
|
||||
request_status: 'pending', current_activity: 'ACT-WORK-05', current_stage: 'assessing' } },
|
||||
{ kind: 'project', into: 'sub_eval.pn_check', from: { each: 'in:candidateInternalPns' },
|
||||
fields: { eval_ref: { in: 'evalRef' }, internal_pn: { self: true }, role: 'candidate',
|
||||
found_pn_ref: { lookup: 'ledger:parts.profile', by: { internal_pn: { self: true } },
|
||||
take: 'internal_pn', else: '' },
|
||||
exists_status: { when: 'found_pn_ref != ""', then: 'found', else: 'absent' } } },
|
||||
// 原 PN 那一行同理(from: { once: true },role: 'original')
|
||||
{ kind: 'project', into: 'sub_eval.verdict', from: { once: true }, fields: { /* … */ } },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
「在不在库」从此是一次 `lookup`,不是模型的一句断言 —— 08-27 那条「原 PN 不在库却判在库」结构性消失。
|
||||
|
||||
---
|
||||
|
||||
## 5. 引擎行为(拍内次序)
|
||||
|
||||
**改口**(2026-08-29,与 `turn-runner.ts` 实际次序对齐 —— 落地时多了 `analyze`/`submit`
|
||||
两个动词,分支也比原提案细):
|
||||
|
||||
```
|
||||
tick 开始
|
||||
├─ ⓪ 这一单还有任何卡没交回(不论哪一步发的)→ 这一拍不问模型;
|
||||
│ 若当下这一步声明了 auto → 静默跑一遍它的配方(quiet:只跑 project / say,
|
||||
│ 「保存即铺格」——人在件上按了保存,系统按最新草稿把该铺的行铺出来)→
|
||||
│ 记痕「等人交回」→ status awaiting,这一拍到此为止,不进入下面任何一步
|
||||
├─ ① 拍首自动收步(现有:doneWhen 为真的项,人工激活项需 worldTouched)
|
||||
├─ ② 算 current(当下在办的那一步)
|
||||
├─ ③ current.auto 存在 → 跑配方(走到这里已经过了 ⓪ 那道门,`quiet` 恒为 false;
|
||||
│ `do` 数组按声明顺序逐笔执行,五个动词可任意穿插)
|
||||
│ ├─ 求 let
|
||||
│ ├─ 按序执行每一笔(when 假则跳过;project 写/撤过东西后,下一笔重新取一次读数)
|
||||
│ │ · project → 逐行 draftWrite(同 PK 覆盖;值没变静默跳过)
|
||||
│ │ · analyze → 一次结构化 AI 调用,出参落拍内作用域 `result:<into>`,供后续 project 消费
|
||||
│ │ · card → 先看 worldTouched,发过就当这一笔已完成、跳过(不当被拒);
|
||||
│ │ 发了标 worldTouched、烧 humanCalls,这一笔之后停(交出控制权)
|
||||
│ │ · submit → 记下 rationale,这一笔之后停(交出控制权)
|
||||
│ │ · say → 进对话区
|
||||
│ ├─ 每一笔留痕(by=系统,detail.auto)
|
||||
│ └─ 抛错 → 留痕 + 交人(不重试、不退回模型),这一拍到此为止
|
||||
├─ ④ 配方发了卡 → 标 worldTouched、按声明推画面,status awaiting,收拍(零 token)
|
||||
├─ ⑤ 配方 submit → 发验收卡挂起(走与模型 `submit` 同一条 `raiseAcceptance`),status awaiting,收拍
|
||||
├─ ⑥ 没发卡也没 submit,但配方写过东西(project 落了行/撤了行)→ 重求 doneWhen;
|
||||
│ 真了收步,下一拍重新导航(封顶 8 轮,与现有自动收步同款)
|
||||
├─ ⑦ 卡答过了就收步(近道):doneWhen 未声明 · 卡已发过(worldTouched)· 配方会发卡
|
||||
│ · 这一步**没有** `guide`(全 auto)→ 引擎直接把这一项收掉,不问模型
|
||||
│ (**半 auto 的步 —— 声明了非空 `guide` —— 不走这条**,R9,见 §3.3)
|
||||
├─ ⑧ 这一步有 guide → 照常问模型(半 auto)
|
||||
└─ ⑨ 无 guide、doneWhen 不成立、也没有卡在等 → **交人**(配方没做到位,是包的 bug)
|
||||
```
|
||||
|
||||
**预算**:配方不烧 token(`analyze` 是例外 —— 它是一次真实的模型调用,烧 token、计入预算,
|
||||
但不计入 `stalledTurns` 熔断计数,见 debt.md);发卡烧 `humanCalls`;配方段在同一 tick 内,不额外计 step。
|
||||
|
||||
---
|
||||
|
||||
## 6. 与现有机制的衔接
|
||||
|
||||
| 机制 | 处置 |
|
||||
|---|---|
|
||||
| `doneWhen` | 不变。auto 步几乎都该有 —— 它做的事是可验的 |
|
||||
| 哨兵 `entryCriteria` | 不变。auto 步照样要满足入场前提才在场 |
|
||||
| `worldTouched` | 配方发的卡同样标 —— 它是真发了 |
|
||||
| `humanWrites`(2026-08-28) | **配方豁免**:那道墙防的是模型即兴,配方是审过的声明。铺骨架合法,人填内容不冲突(静态审提示列不要重叠) |
|
||||
| 写三分 | 不变:配方只写**草稿层**,落正式表仍靠放行 / 数据工单确认入库 |
|
||||
| 「一单一卡」 | 不变:配方至多一张卡 |
|
||||
| 熔断 | 配方不会「被拒」,不进 `stalledTurns` |
|
||||
| 试跑 / 回放 | 配方是纯数据求值,同输入同结果 —— 可回放。**配方里没有时钟与随机**(时刻取 `case:openedAt`) |
|
||||
| 记账旗 | **该删的删,不是该 auto** —— `input_confirmed_total` 这类基座本就数得出来 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 回溯验算:历史事故有几条会结构性消失
|
||||
|
||||
| # | 事故 | 落在哪 | auto 后 |
|
||||
|---|---|---|---|
|
||||
| 1 | 编号从正式表里**别单的案号**里挑 | S0_bind 铸号 | 消失(模板铸号) |
|
||||
| 2 | 原 PN 不在库却判「在库」(发现 I) | S0_bind 查库 | 消失(lookup 现查) |
|
||||
| 3 | 数据工单派给了**在库**的那颗(发现 J) | S0d_p08 名单 | 消失(where absent) |
|
||||
| 4 | 压拍没写骨架就发卡,人打开是空表(发现 Q) | S0d_p08/p10 | 消失(配方按序) |
|
||||
| 5 | S1 清单 11 行全漏 `input_item`,人无处可填 | S1 前半 | 消失(字段声明齐全) |
|
||||
| 6 | 库里有值的参数被 S1 当缺口再问一遍(发现 AB,三次复现) | S1 前半 | 消失(清单是投影) |
|
||||
| 7 | P-10 抄**原 PN 的规格书**安在候选头上(发现 AE) | S0d_p10 | 消失(模型不出现) |
|
||||
| 8 | `finish` 撞 worldTouched 墙五拍熔断(发现 AE 同单) | S0d_p10 | 消失(那一步不问模型) |
|
||||
|
||||
**八条落在会被 auto 掉的三步半上。** 剩下的事故(工具没调、比对入参抄错、验收卡面孔、重做循环)
|
||||
不在这个范围内 —— 它们该由别的机制治,这份 ADR 不承诺。
|
||||
|
||||
---
|
||||
|
||||
## 8. 不做什么
|
||||
|
||||
- **不调工具**:工具有副作用面与预算面,先不给(S3 的 `compare → recommend` 串接留作待定;
|
||||
实现时换了个更窄的口子解掉同一个需求,见下方「两处改口」与 §11 待定②的销账)
|
||||
- ~~**不 `finish`、不 `submit`、不发决策卡**:收步归 `doneWhen`,终局归 submit,岔路口本就是判断~~
|
||||
(**`submit` 与发决策卡两条已撤**,见下方「两处改口」;`finish` 这条不变 —— 配方从不
|
||||
显式收步,收步仍旧只有「引擎按 `doneWhen` 自动收」与「半 auto 步问模型、模型 finish」两条路)
|
||||
- **不写正式表**:仍走草稿层
|
||||
- **不做三层以上的嵌套**:两层(关系 × 参数)是 W05 的上限;超出就别 auto
|
||||
- **不在治理台上编辑配方**:那就成了低代码平台;配方随包走,改配方 = 升版本重装
|
||||
- **不承诺消灭 agent**:§2 的四类判断照旧归它
|
||||
|
||||
> **两处改口**(2026-08-29,ACT-WORK-05 重写时用户定):`submit` 与**发决策卡**这两条禁令撤掉。
|
||||
> 它们原本是**假设还有 agent 在场**时的保守话;而全程无 agent 的活动**没有别人来干这两件事**,
|
||||
> 不开口最后两步就没人做。护栏没松:`submit` 只许出现在声明了人工验收清单的那一步,
|
||||
> 提交门(判据全绿)照旧把关 —— **系统提交不比模型提交宽松**;决策卡只在 `variant:'review'` 时
|
||||
> 放宽到单选项(它不是岔路口,是一道签字关)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 代价与风险
|
||||
|
||||
1. **活动作者要写结构化声明,不能再靠散文。** 但那些散文本来就在(而且更长、还不能测);
|
||||
配方**能单测**(纯数据求值,包内自测钉死)。
|
||||
2. **代码错是必然的错,模型错是概率的错。** 配方写错 = 每一单都错。
|
||||
对策:静态审(§3.7)+ 包内自测 + 安装两审的回放审拿样例入参跑一遍配方。
|
||||
**但必然的错比概率的错好查** —— 这正是把它从模型手里拿走的理由。
|
||||
3. **立场要改口。** 「组织的工作定义为可编排的工作流……活动是完成一项任务的 Agent 机制」——
|
||||
加一句:**agent 只在真需要判断的地方出现,与「人只在责任点与放行点出现」是同一条原则。**
|
||||
确定的事交系统,判断交 AI,责任交人。
|
||||
4. **把有判断的地方硬写成规则的风险。** `constraint_type` 的 hard/soft 归类按品类会变 ——
|
||||
这类**进 DMN 判定表,不进配方**;配方里只许出现「取值、拼装、投影、发卡」。
|
||||
5. **现在的「确定」有一半是靠强制命名一致换来的。** S2 能纯 join,前提是「候选与原 PN 参数名一模一样」
|
||||
(P-08 建骨架时用中文名硬压 + 门上的行数对账)。**一旦料号来自外部导入**,同义归一就回来了 ——
|
||||
那是判断,是 agent 的活。**auto 化不等于永远不需要 agent。**
|
||||
|
||||
---
|
||||
|
||||
## 10. 迁移次序(每搬一步都要五案例 e2e:released + 被拒 0 才算数)
|
||||
|
||||
1. **`S0d_p10_document`** —— 最小、纯粹、刚出过事;拿它验证机制(配方 + 静态审 + 留痕 + 观测)
|
||||
2. **`S5_prepare_landing`** —— 整步确定、不发卡;验证「不发卡时接着走 doneWhen 收步」那条路
|
||||
3. **`S0_bind`** —— 整步确定、含一张录入卡的分岔;验证 `let` 铸号与 `lookup` 存在性
|
||||
4. **`S1_input_confirm` 前半** —— 清单投影 + 发卡;半 auto(收口仍归 guide)
|
||||
5. **`S0d_p08_master`** —— **先把 datasheet 抽值切出去**,再 auto
|
||||
6. **`S2_normalize`** —— 两层嵌套,最难;搬之前先确认它与比对工具的 join 是否重复(§11)
|
||||
|
||||
---
|
||||
|
||||
## 11. 待定(要用户定的)
|
||||
|
||||
1. ~~**决策卡要不要给配方**~~:S4 第一张卡的 ask 内容是计数投影,确定;但决策卡是岔路口,给了会不会滑坡?
|
||||
**已定:给了**——`card.as: 'decide'`(`variant:'review'` 时放宽到单选项),见 §8「两处改口」。
|
||||
2. ~~**工具串接要不要做第四个动词**~~:S3 的 `compare → recommend` 之间模型只是转手 counts,却占掉整整一拍。
|
||||
**已定:没照原样给**——没有做「按序调用多个工具」那个第四动词,换成了更窄的 `analyze`
|
||||
(一次结构化 AI 调用,不给工具不给记忆不多轮),见 §3.2 与 `docs/debt.md`「平台缺『确定性步骤』」条。
|
||||
3. **`humanWrites` 该不该细到列**:现在是表级。auto 铺骨架 + 人填内容之后,列级更准。
|
||||
4. **S2 与比对工具的 join 是否重复**:`compare-parameters` 1.2.0 起自己从台账取参数对,
|
||||
而 S2 让模型抄的 `difference_item` 对照表是**同一份 join**。一份事实做了两遍,一遍还是手抄的 ——
|
||||
要么 S2 auto 化,要么让工具的出参直接落 `difference_item`(那是另一个机制:工具出参绑台账)。
|
||||
5. **`T_release` 第三条裁量判据**「推荐等级与差异明细一致」就是工具的定级规则本身,
|
||||
让模型再裁一次是给自己盖章 —— 该删(与 08-01 在 A1 上拆掉 L4 同款)。
|
||||
+402
@@ -1277,6 +1277,101 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人
|
||||
但也有活动是让人在件上加第一行的(那时零行合法)—— 得由活动声明区分。
|
||||
**现状**:活动侧把「发卡前清单必须先有行」写进说明书(每拍都在),配测试。
|
||||
|
||||
## `modules → app` 是全仓第一条这个方向的 import(2026-08-30,Ruling H9,Task 4/7b 装 `@app/*` 别名时记的)
|
||||
|
||||
`apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts` 与
|
||||
`w05-s3-review-recompute.test.ts` 两份测试用新增的路径别名 `@app/*`(`tsconfig.base.json` +
|
||||
`vitest.config.ts` 各加一行,与既有 `@modules/*`/`@shared/*`/`@features/*`/`@web-shared/*`
|
||||
同一种写法)直接 import `apps/api/src/app/wiring/engines.ts` 的具名导出(`rowReadings`/
|
||||
`missingReadings`/`tallyReadings`/`distinctReadings`)——按本仓的模块层级(`docs/编码规范.md`:
|
||||
`apps/api/src/app/` 是「唯一组装层」,`modules/*` 是被组装的那一层,依赖方向只许
|
||||
`app → modules`),这是**全仓第一条 `modules → app`** 的 import,且目前只出现在测试文件里
|
||||
(production 代码里一条都没有)。
|
||||
|
||||
**为什么会有它**:这两份测试要把真 `createAutoRunner` 跑起来验证「引擎逐 action 刷新
|
||||
时机」这类只有真求值才测得出的回归;读数层四个函数(`missingReadings` 等)只在
|
||||
`engines.ts` 里有实现,第一版手抄了一份副本,**同一笔提交里就漂了**(`missingReadings`
|
||||
拼缺项名的真身多带一句 `label ?? `,手抄件漏掉)——「手抄会漂」这个仓已经踩过三次
|
||||
(读数函数 / `let` 镜像 / 选择器复刻),这次选择 import 真身而不是再抄一遍。
|
||||
|
||||
**要治**(方向,未裁定,是全仓架构决定不是单个活动包能裁的):
|
||||
- `.dependency-cruiser.cjs` 现在只有 `packages-are-leaves`(禁 `packages → apps`)管这类
|
||||
方向,`modules → app` 完全不受约束——该不该补一条规则(禁 `modules → app`,但要给
|
||||
测试文件开口子,否则这条测试会被打回手抄、重蹈「手抄会漂」的覆辙),需要专门讨论。
|
||||
- 或者认为「测试文件例外」本就是可接受的常态(`packages/ui-kit/__tests__/
|
||||
view-container-collect.test.ts` 已有 `apps → packages` 方向的先例),把这条也当先例
|
||||
记下就够,不必新增规则。
|
||||
|
||||
**排查法**:`grep -rn "from '@app/" apps/api/src/modules` 排除 `__tests__` 目录,
|
||||
确认这个方向至今只活在测试里。
|
||||
|
||||
## `<thead>` 排在 `<tbody>` 之后:违反 HTML5 表格内容模型、读屏器列头关联失效(2026-08-30,Ruling H31)
|
||||
|
||||
`activities/part-substitution-assessment/src/views/report.ts` 的 `CHIP_TYPE_TABLE` /
|
||||
`TYPE_PARAM_TABLE`(Task 7b 修「新建行按钮克隆到表头」那次 Critical 回归)把 `<thead>`
|
||||
写在两个 `<tbody>` 之后——**挪位本身是那次修复的正确解法**(`data-edit` 两处写法让
|
||||
`ViewContainer.bodyOf()` 解析到外层 `<table>`,`addRow()` 在这个 host 里找
|
||||
`tr:last-of-type`;`<thead>` 若排在数据行前面会被优先命中、克隆出表头文字而不是数据格),
|
||||
但这个修法本身留了一笔新账:
|
||||
|
||||
- **违反 HTML5 的 table 内容模型**:标准写法是 `<thead>` 排最前(`<tfoot>` 可以排在
|
||||
`<thead>` 之后、`<tbody>` 之前,但 `<thead>` 本身不许排在 `<tbody>` 之后)。
|
||||
- **会让读屏器的自动列头关联失效**:无障碍算法按**文档序**从数据格向上扫、找同列的
|
||||
`<th>` 来读出「这一格是哪一列」,`<thead>` 排在最后时这条自动关联找不到表头。
|
||||
|
||||
呈现不受影响(CSS `table-header-group` 让 `<thead>` **视觉上**永远排最前、不看它在
|
||||
DOM 里排第几,`querySelector`/`:last-of-type` 认的却是文档序——呈现与选择器认的不是
|
||||
同一回事),走查看不出来,只有读屏器 / 标记校验器能测出来。复审当时判断:本仓从未
|
||||
提出过可访问性(a11y)要求,故非违反既有标准、是此前不存在的隐性缺口;真要治得动
|
||||
`bodyOf`/`addRow` 或重构成单个 `<tbody>`,都超出了那次修复「零平台改动」的前提。
|
||||
|
||||
**要治**(方向,未验证,留给认真做无障碍的时候一起评估):
|
||||
- 平台侧治本:`bodyOf`/`addRow` 改成不依赖 `<thead>`/`<tbody>` 的相对顺序(直接按
|
||||
`data-rows` 标记的那个 `<tbody>` 取,不用 `tr:last-of-type` 在整个 `<table>` 范围里找),
|
||||
`<thead>` 就能挪回最前面,两全。
|
||||
- 或者把两个 `<tbody>`(动态行 + 静态种子行)合并成一个,`<thead>` 天然留在最前——但
|
||||
种子行「不受 `data-rows` 管辖、行数再少也在」这条设计意图需要另一种表达方式。
|
||||
|
||||
**影响面**:目前只有这两张表这么写(其余活动的可编辑表 `<thead>` 都在最前,因为它们
|
||||
没有「零行也要有种子行」这个需求、`data-edit` 也没有写两遍)。
|
||||
|
||||
**排查法**:`grep -n '<thead>' activities/*/src/views/*.ts`,看它是不是排在最后一个
|
||||
`</tbody>` 之后——目前全仓只有这两张表命中。
|
||||
|
||||
## `data-by` 嵌套查表:父键在本行里缺席时会悄悄落回外层同名字段(2026-08-30,终审 I3,浏览器真人走查缺陷①逮到)
|
||||
|
||||
**现象**(ACT-WORK-05 task-12,浏览器走查):数据工单件上,候选料那一行的「芯片类型」
|
||||
只读态**显示着一个值**,而红带同时说它「缺芯片类型」——编辑态与直接查库都核实过
|
||||
那一格确实是空的,屏上两处自相矛盾。
|
||||
|
||||
**病根**:`apps/api/src/modules/interaction/internal/domain/view-render.ts` 的
|
||||
`renderRows`(:191)递归处理嵌套 `data-rows` 时用 `{ ...scope, ...one }` 合并「外层
|
||||
作用域」与「本行」——`one`(本行)**没有**的字段会悄悄落回 `scope`(外层)里同名的
|
||||
那一个。这个「悄悄落回」平时无害(未填的草稿行缺一个键,落回外层同名字段多半也是
|
||||
空的),但当外层作用域**恰好**有一个同名、非空的字段时,`data-by` 嵌套查表
|
||||
(`byParent`,:201)会拿这个不该被拿到的值去查表,查出一个不属于这一行的结果。
|
||||
|
||||
ACT-WORK-05 恰好撞上这个「恰好」:`engines.ts` 的 `taskData.view()` 会把「各对象最新
|
||||
一行的字段名」铺到顶层(`task.data`),而 `chip_type_id` 这个名字同时是 `sub_eval.part`
|
||||
/ `sub_eval.case` / `parts.profile` 三张表的列名——`case-model.ts:134` 那条注释早就为
|
||||
同一个撞名问题给 S-FEEL 求值加过护栏(「这个名字在三张表上都有,顶层同名以后写的赢,
|
||||
改用对准一张表的 `lookup`,不歧义」),但那条护栏只保护了 `case-model.ts` 自己的配方
|
||||
求值,没有覆盖到 `view-render.ts` 这一侧的 `data-by` 嵌套查表——同一类撞名在这里
|
||||
原样重演。
|
||||
|
||||
**已钉进套件、未修**(`apps/api/src/modules/interaction/__tests__/view-render.test.ts:460`
|
||||
的 `it.fails`,已用最小复现核对过不是测试自己写错):这是共享面(E04/I01 也用
|
||||
`data-by`),改动需要先报给设计者裁定修法,当时只坐实、不改 `view-render.ts`。
|
||||
|
||||
**要治**(方向,未验证):`renderRows` 合并作用域时,`one` 里**没有**的键不该落回
|
||||
`scope` 同名字段——要么显式置 `undefined`(阻断落回),要么 `byParent` 取父键值时
|
||||
只认 `one`(本行)自己有没有这个键,不看合并后的结果。两条都要过现有测试(「父值
|
||||
缺席一行不留」那条已经钉死了「本行显式给出空字符串」这个不同的场景,不能被误伤)。
|
||||
|
||||
**排查法**:嵌套 `data-by` 查出来的值与红带/编辑态对不上,先怀疑这条——尤其当查表用
|
||||
的那个列名在**多张表上撞名**、且当前这一行**没有**这个字段(不是空字符串,是键都不
|
||||
存在)时。
|
||||
|
||||
## 运营中心 · 活动模块接口(2026-08-27 落地时记下的)
|
||||
|
||||
- **实例清单的过滤 / 分页在内存里做**:`listInstances` 全量取该活动的 `sys.task`,非终态行逐单读记忆与
|
||||
@@ -1308,3 +1403,310 @@ pnpm 硬链到全局 store,真实占盘远小于这个数,但 `du` 看着吓人
|
||||
现在只有 W05 包内测试钉着 + 运行时「那一块空着 + stderr 点名」。该并进 `static-audit`(与 `refersTo` 目标核对同款)。
|
||||
- **`docs/数据域字段说明-W05.md` §二 / §十二.4 的参数集口径过时**:仍写 `normalized_parameter_set` 是 text 列「不建子表」,
|
||||
而清单早已拆成子表 `parts.parameter`(库里那一列全 null)。一览表也少 `parts.parameter` 一行。
|
||||
|
||||
- ~~**平台缺「确定性步骤」:确定的流程只能交给模型**~~(2026-08-29 已实现,销账)。
|
||||
2026-08-28 用户点出:「这个不是一个确定的流程吗?根本不需要交给 ai,你直接查库不就知道有没有了吗」。
|
||||
W05 的「逐个料号查库 → 不在库就铺骨架、发数据工单」是纯规则,却写成了模型的 guide —— 因为一个计划项的做法
|
||||
只有一个落点(模型的 guide):纯工具只读、判定表不写台账也不发卡、`doneWhen` 只收步、哨兵只开门,
|
||||
**没有一个机制能整段承接**「读台账 + 写草稿 + 发人卡」。引擎这三样能力全有
|
||||
(`taskView` / `draftWrite` / `issueExecution`),缺的只是**声明面**。
|
||||
代价已经付过两次:08-27 原 PN 不在库却判「在库」(用 `found_pn_ref` 的 refersTo 兜了一半 ——
|
||||
「说不在库」那一侧至今没有校验);08-28 模型把原 PN 的规格书抄到候选头上(已用 `humanWrites` 立墙)。
|
||||
**`docs/adr/ADR-014-deterministic-steps.md` 已实现**(状态 提案 → 已实现):`PlanStep.auto` 声明式配方,
|
||||
五个动词(`project`/`analyze`/`card`/`submit`/`say`,比 2026-08-28 出设计时的「三动词」多了 `analyze` 与 `submit`)。
|
||||
§11 待定的前两条随实现落了地:①「决策卡要不要给配方」—— 给了,`card.as: 'decide'`;
|
||||
②「工具串接要不要做第四个动词」—— 没照原样给(那会把工具的副作用面与预算面一起引进配方),
|
||||
换成了 `analyze`:一次结构化 AI 调用(系统组装入参、声明出参 schema、系统落表,不给工具不给记忆不多轮),
|
||||
比工具串接窄但够用。第③④⑤条(`humanWrites` 细到列 / S2 与比对工具的 join 重复 /
|
||||
`T_release` 第三条裁量该删)是 W05 具体步骤的事,留给下一份计划。规范 §4.6 记了怎么写。
|
||||
**机制先在纯函数与假端口上钉死**——这一轮 `git diff --stat b995148...HEAD -- activities/` 为空,九个现有活动包一个字没改
|
||||
(它们的剧本里没有 `auto`,新代码在结构上就进不去);机制的第一个真实用户是下一份计划(W05 重写)。
|
||||
|
||||
- **配方没有 `drop` 动词**(2026-08-29):撤行只能靠 `project` 的 `prune`(「这一笔投影出来的就是全部」)。
|
||||
要撤一行而不重投整张表的场合,现在写不出来。等真出现第二个用例再加。
|
||||
- **配方的 `analyze` 不计入 `stalledTurns`,也不进单拍工具缝**(2026-08-29):它烧 token、
|
||||
**用量已真的计入活动预算**(2026-08-29 终审 C1 修:装配层从裸网关改成
|
||||
`createAnalyzeGateway(tracedGateway, onAgentUsage)` —— 在那之前这半句是假的,
|
||||
`createModelGateway` 自己不做 `addConsumption`,配方问模型是不花钱的),
|
||||
但不算「被拒」——熔断看不见它。一份写坏的 `instruction` 会每拍烧一次而不触发熔断;
|
||||
目前靠「auto 步发了卡就收拍」封住(analyze 那一步不发卡,`doneWhen` 不成立就交人),
|
||||
真出现空转再补一道计数。
|
||||
- **治理台的活动详情还摆不出「这一步系统会做什么」**(2026-08-29):配方是数据,渲染得出来 ——
|
||||
这是相对提示词白赚的一件,但要动前端,不在这一轮(后端已经把 `auto` 随清单存进库了)。
|
||||
- **`.stable/**` 进了 `eslint.config.js` 的 `ignores`**(2026-08-29):它是部署快照目录
|
||||
(`stable.sh` 放的旧 commit 源码拷贝,已在 `.gitignore` 里),与既有的 `dist` / `.build` /
|
||||
`api-report` / `.api-tmp` 同类 ——「产物不是源码」,不该被门禁扫。加它是因为本轮门禁曾被
|
||||
`.stable/` 下的旧代码拖红(那份代码不受这次改动约束,红也修不掉)。这条动了门禁配置,
|
||||
按铁规矩③要在这里留痕;要推翻只需删掉这一条 ignore。
|
||||
- **配方留痕的可见性,靠的是「kind 选对 + 不标 internal」,没有机制保证**(2026-08-29,
|
||||
R10 的教训 —— `result` 不进工作日志骨架、`internal:true` 会被 `machinery()` 再滤一道,
|
||||
三段各自「正确」、合起来一笔都看不见,踩了才发现):将来新增一类配方留痕时,若照手感
|
||||
写成 `kind: 'result'` 或顺手加 `internal: true`,它会**静默地**从工作日志上消失 ——
|
||||
没有测试会红、装包也不会拒,人只会发现「这一步好像没留痕」。目前靠代码注释提醒
|
||||
(`turn-runner.ts` R10 附近),没有静态检查兜底。
|
||||
|
||||
## 收尾复审二审(2026-08-29,B 项记账)
|
||||
|
||||
- **「保存后唤醒的收窄」几乎不生效**:`afterSave`(`save-wake.ts`)的 F1 门槛是
|
||||
`pendingCards` 为空才不叫醒(见 `apps/api/src/app/__tests__/save-wake.test.ts` 那条改过的
|
||||
用例标题)——而 `pendingCards`(`human-tool-service.ts:374`)把**未办结工单**也算在内,
|
||||
人能在件上按保存的时刻(工单开着让他填)几乎恒有一张这样的工单还没结。也就是说
|
||||
「没有卡在等人」这个分支在人真正会走到的场景里几乎从不成立,`wake` 照发、一步预算照烧 ——
|
||||
F1 那道门在设计上是对的,只是它保护到的场景比想象的窄得多。真要治得重新定义
|
||||
「什么时候算需要唤醒」(比如分清「这个工单是不是这次保存所在的那一张」),是新设计,
|
||||
不在本分支拍。
|
||||
- **模型路径的写同样会 un-land,只补了配方那一半**(复审判**中等偏高**):`DraftStore.put`
|
||||
尾部 `landed_at = null` 是无差别的 —— 同一行落草稿层一次,不论落笔的是配方(`project`
|
||||
动作)还是模型自己的 `write` 行动。本轮把配方那一侧堵上了(`skipUnchanged: true` +
|
||||
`detail.changed` 只在真写了/真撤了时为真,见 `auto-runner.ts` 的 `project` 分支与
|
||||
`turn-runner.ts` 的 `revised` 判据),**模型那一侧完全没动**——模型的 `write` 行动没有
|
||||
`skipUnchanged` 这层,值没变时 `draftWrite` 照样把 `landed_at` 打回 null。
|
||||
而 W05 的数据工单**今天**就在跑「确认即入库」(2026-08-27 的 `landed` 记号机制),
|
||||
模型只要对已入库的同一行同值重写一次(比如没读现值、把台账里已有的值原样再报一遍),
|
||||
landed 记号就没了 —— 整单放行会再写一遍正式表、再抛一遍 data 事件,而 CLAUDE.md 对
|
||||
landed 行的承诺是「不再写它、不抛事件」。**同一个洞只补了还没人走的那半边** ——
|
||||
配方(`auto`)目前一个活动包都没在用,模型路径每天都在跑。**判中等偏高**(复审原话):
|
||||
不在本分支动,原因两条 —— ①这是**共享面**(`turn-runner.ts` 的 `write` 行动、
|
||||
`engines.ts:1055` 的 `draftWrite`),改法要么给模型路径也接上 `skipUnchanged`
|
||||
(得先核实它是否会悄悄影响 :980 那道「一行都没变的写不是一个行动」拒回门 ——
|
||||
逐行看目前是不会,`changed` 在 `skipUnchanged` 判断之前就已经按值算好了,但没有测试钉死
|
||||
这条不变式,改了要重新证明一遍并补测)、要么另开一条更窄的路径,两种都要走全量回归
|
||||
+ 三套 e2e;②本轮改的是配方那一条新路,`git diff --stat b995148...HEAD -- activities/`
|
||||
为空这条硬约束意味着不能顺手把模型路径也捎带上、再靠一次浏览器走查收尾 ——
|
||||
排查与验收的工作量不是这一条闭合修复能捎带的。**应当独立起一条、排在 W05 重写之前**。
|
||||
|
||||
## S1 配方回归测试里躲不掉的手抄(2026-08-29,Task 4 评审二轮)
|
||||
|
||||
`apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts` 证的是**引擎逐 action
|
||||
刷新时机**这件事(案卷投影必须排在读它的基座读数所依赖的那几笔之后)—— 这一条只有把真引擎跑起来
|
||||
才测得出;`activities/part-substitution-assessment/__tests__/steps.test.ts` 的"六笔,次序是…"用例
|
||||
用精确数组相等钉的是**真** `ASSESSMENT_STEPS` 本身,两份测试各守各的,互不重复。
|
||||
|
||||
这份引擎级测试**手写了六个 `ACT_*` 常量**,在形状上代表 `case-model.ts` 里 `S1_inputs.auto.do`
|
||||
当前用到的四种地址写法(字面 `rows` / 单层 `each+where` / 两层 `each+where+outer` / `once`)——
|
||||
这是**躲不掉**的手抄:`.dependency-cruiser.cjs` 的 `activities-isolated` / `apps-not-into-activities`
|
||||
两条规则对测试文件没有例外,`apps/*` 与 `activities/*` 互相不许 import,所以测试文件不能直接
|
||||
`import { ASSESSMENT_STEPS } from '.../case-model.ts'`。
|
||||
|
||||
(读数层四个纯函数 `rowReadings` / `missingReadings` / `tallyReadings` / `distinctReadings` **不在
|
||||
此列**——它们是 `apps/api/src/app/wiring/engines.ts` 的具名导出,从 `apps/api/src/modules/
|
||||
loop-engine/__tests__/` import 它们是 `apps → apps`,两条隔离规则一条都不沾。第一版误判"读数层也
|
||||
躲不掉"而手抄了一份,评审查出与生产版已经漂了一处(`missingReadings` 拼缺项名用
|
||||
`f.label ?? f.name`,手抄件漏了 `?? f.name` 那半句),已改为直接 import 真身,删掉约 150 行手抄,
|
||||
换成 `@app/*` 路径别名〔`tsconfig.base.json` + `vitest.config.ts` 各新增一行,与既有 `@modules/*`
|
||||
/ `@shared/*` 同一种写法〕——之前的三层相对路径撞了 `eslint.config.js` 的 `no-restricted-imports`。)
|
||||
|
||||
**欠账**:这份镜像**不会随 `case-model.ts` 自动同步**——它守的是引擎机制而不是"逐字等于当前配方"
|
||||
这个没人能守的承诺,所以次序变化不影响它的有效性,但如果 S1 配方的**形状**变了(比如新增了
|
||||
`analyze` 动词、某一笔的 `from` 换了一种从没出现过的地址写法、或某个 `when` 链换了结构),这份
|
||||
文件的代表性可能跟不上而不自知——没有测试会为此变红,装包也不会拒。
|
||||
|
||||
**谁改了 `S1_inputs.auto` 的形状(不是次序),该顺手做的事**:打开
|
||||
`w05-gate-reason-order.test.ts`,核一遍 `ACT_*` 六个常量描述的地址/字段写法是否还能代表
|
||||
`case-model.ts` 当前用到的构造种类;不必逐字同步(那不是这份测试的目标),但要能回答
|
||||
"如果生产那边这一类写法出了刷新时机的 bug,这份镜像能不能测得出来"。
|
||||
|
||||
## `analyze` 出参靠 `requiredWhen` 兜底的字段,漏填时全 auto 步没有就近恢复点(2026-08-29,Task 5 评审)
|
||||
|
||||
`S2_assess` 的 `analyze` 出参 schema 里 `unknown_disposition` 不在 `required` 名单——只靠提示词
|
||||
约束模型「判了 unknown 就必须在这一格写清楚」,漏填由 `manifest.ts:397` 的
|
||||
`requiredWhen: {column: 'difference_status', value: 'unknown'}` 兜底拦成
|
||||
`substitution_difference_item_missing > 0`(评审已核过:机制是对的,提交闸与放行门都看得见)。
|
||||
但 `S2_assess` 全程 `auto`、零 `guide`、零人工卡口——模型这一拍真漏填,当下没有任何东西能就近
|
||||
接住它,只能等清单声明的 `harness`(`memoryWindow:24, stallAfter:'PT4H',
|
||||
nudge:[{after:'PT2H',action:'nudge'}], retry:{times:2,backoff:'PT30S'}`)慢慢兜底,
|
||||
比人工激活步「重发已答过的卡=无事发生」那类近距离恢复慢得多。
|
||||
|
||||
**这是 ADR-014 `analyze` 动词的通用基础设施特性,不是 W05 这一个活动的病**——凡是拿 `analyze`
|
||||
出参配合 `requiredWhen` 兜底可选字段的活动,都会有同一个洞。**把它改进 `required` 是错的**:
|
||||
`unknown_disposition` 只有 `difference_status = unknown` 的那几行才该填,MATCH 的行填了反而是
|
||||
编造,逼模型给不需要的行也填一格。
|
||||
|
||||
**要治得在 ADR-014 层面**:给 `analyze` 一个「出参不合规(含 `requiredWhen` 这类 JSON Schema
|
||||
本身表达不了的跨字段约束)就地重问一次」的档,与 `agent-runner` 对模型输出解析不成形时
|
||||
「就地重问一次,原文经 `note` 存工作日志内部痕,第二次仍不成形才交人」同一族做法
|
||||
(2026-08-03,见 CLAUDE.md「兜底卡不再摆模型原话」条)——`analyze` 目前没有对应机制。
|
||||
|
||||
## 静态审核认不出未绑定的 `{in:X}`(2026-08-29,Task 6 评审 Critical 逼出来的平台缺口)
|
||||
|
||||
`activity-registry` 的 `static-audit.ts`(`walkExpr`,约 `:458-471`)会递归校验一条 `AutoExpr`
|
||||
里的 `when`(→ `checkWhen`,核名字绑得上绑不上)与 `lookup`(→ `checkAddress`,核地址声明过没有),
|
||||
也会递归进 `cat`/`fmt`/`then`/`else` 继续往下找 —— **唯独没有 `'in' in node` 这一支**。裸的
|
||||
`{in:X}` 引用(配方级 `let` 算出来的名字、或起单入参、或 `case:` 读数)在装包那一刻**一声不响**,
|
||||
名字到底绑不绑得上、活着不活着,静态审一个字都不查。
|
||||
|
||||
这个洞在 W05 上真的咬过人(见本文件件头 Critical 记录 / `case-model.ts` 的 `SHARED_LET` 头注 /
|
||||
`apps/api/src/modules/loop-engine/__tests__/w05-s3-review-recompute.test.ts`):`S3_review.auto`
|
||||
一度没有自己的 `let`,而它的两笔投影(`RELATION_PROJECTION`/`VALIDATION_PROJECTION`)里五个
|
||||
`{in:X}` 引用的名字**只在 S2 的 `let` 里绑过**——装包、`pnpm gates`(2395 条全绿)、六条纯结构
|
||||
断言(`steps.test.ts`)全部放行,只有把真引擎(`createAutoRunner`)跑起来求值才现形:一条主键算成
|
||||
空串、整笔写被「主键算不出来先不写」悄悄丢掉;另一条主键因为 `cat` 拼接把空串垫成非空前缀
|
||||
(`'VAL-'`),反而写出一行垃圾,破了「一单固定一条」的表不变量。
|
||||
|
||||
**这是平台缺口,不是 W05 一个活动的病**——凡是拿 `{in:X}` 却没在**这一步自己的** `let` 里定义
|
||||
过(哪怕在别的步的 `let` 里定义过,`let` 不跨步传递,`auto-runner.ts:93-97`),都会在装包时蒙混
|
||||
过关、只在运行期以「写丢了」或「拼出垃圾行」两种面目现形,且不出声、不留痕说清原因(只在
|
||||
`memory.append` 的机械留痕里能看到「主键算不出来没写」,人不会去读那一行)。
|
||||
|
||||
**要治**:`walkExpr` 补 `'in' in node` 这一支,核 `node['in']` 这个名字是否落在
|
||||
`letNames ∪ declaredNames`(与现有 `checkWhen` 同一套判断)里,绑不上就 `schema-invalid` 拒装 ——
|
||||
与 `checkWhen` 现有的「S-FEEL 对未绑定的名字求值为假,这一笔将永远被跳过」同一条纪律,只是
|
||||
`{in:X}` 的失败面目更隐蔽(空串会被拼接、被当合法值继续往下算,不像 S-FEEL 谓词那样直接短路)。
|
||||
`auto` 是 ADR-014 新落地的机制,这个洞会在下一个用到跨步共享投影的活动上重复咬人。
|
||||
|
||||
## `auto:true` 又是整数型的主键,平台两处独立机制都会踩(2026-08-30,Task 7b 评审 Critical 逼出来的发现)
|
||||
|
||||
`resolveRowKey`(`apps/api/src/app/routes/desk.ts:111-181`)第③段「续号」只判断
|
||||
declared type 是不是 `integer`/`number`(`isSeq`),**不看这一列是否 `auto:true`** ——
|
||||
它是为 `req_seq` 这类「非自增、按行数续的序号型主键」(I01 的 `req_seq`)设计的,却对**任何**
|
||||
整数型主键一视同仁。若某张表的主键**恰好**既是 `auto:true` 又是 `type:'integer'`,这一段会
|
||||
**抢在** `writeEditedRows` 的 `mintable`/`autoKeys()`(真正为 `auto:true` 设计的续号器,数
|
||||
`taskView` 正式版+草稿叠加、按声明的前缀+两位补零规则铸号)**前面**动手,拿「这一单自己的
|
||||
草稿行数+1」铸一个号——这个数不数**正式表**里已有的行,新建的第一行几乎必然撞上任何已存在的
|
||||
`type_id=1`(种子数据、或早前任务放行落地的行),**静默覆盖**那一行的其余字段。
|
||||
|
||||
`autoKeys()` 补零续出的号(如 `'03'`)还有第二个坑:**引用这一列的其它表**(外键列)若也声明
|
||||
`type:'integer'`,写入时会被 `coerceToColumn`(`apps/api/src/app/wiring/engines.ts:590-598`)
|
||||
按 `Number()` 解析,**削掉前导零**——`'03'` 存成整数 `3`,而被引用的原表仍是字符串 `'03'`,
|
||||
`refersTo` 校验(`engines.ts:1119-1143`)与 `data-by` 嵌套查表(`view-render.ts` 的
|
||||
`byParent`)全按 `String(...)` 比对,两边永远对不上:引用列的写入会被 `refersTo` 拒(「在字典里
|
||||
不存在」),或下拉/查表显示不出刚建的那一行。
|
||||
|
||||
**本仓迄今唯一一个 `auto:true` 又是 `integer` 的主键是 `part-substitution-assessment`(W05)的
|
||||
`parts.chip_type.type_id`**(`auto:true` 的既有先例 `contact_code`——E04/quote-intake 两处——
|
||||
从来都是 `type:'text'`,没人先撞上这个组合)。Task 7b 补「件① 新建芯片类型」时踩上了(种子数据
|
||||
`type_id: 1/2` 会被第一次新建的类型静默覆盖;引用列若也是 `integer` 会导致新建的类型写不进
|
||||
`parts.profile.chip_type_id` 等)。**这次只是把 `type_id` 自身与五处引用列(`type_param.type_id`
|
||||
/ `profile.chip_type_id` / `sub_eval.part.chip_type_id` / `sub_eval.case.chip_type_id` /
|
||||
`substitution.relation.chip_type_id`)在活动自己的声明层统一改成 `text` 绕开——`resolveRowKey`
|
||||
与 `coerceToColumn` 这两处平台代码本身一行没改,坑原样留着**。
|
||||
|
||||
**要治**(两处,各自独立就够,不必都做):
|
||||
- `resolveRowKey` 第③段续号加一条 `if (input.autos?.includes(k)) continue;`(与第②段
|
||||
`barred` 检查同一条精神:`auto:true` 的列有自己专用的续号器,不该被这段通用的
|
||||
「整数型主键续号」抢跑)。
|
||||
- 或者更保守地,给 `writeEditedRows`/`static-audit` 加一条装包期检查:某张表的主键**同时**
|
||||
`primaryKey:true`、`auto:true`、`type:'integer'|'number'` 时报一条 warning,提醒作者
|
||||
「这个组合会撞上 `resolveRowKey` 的续号步骤,改成 `text` 或联系平台修 `resolveRowKey`」——
|
||||
比事后重新发现这条坑更便宜。
|
||||
|
||||
**排查法留一条**:某张 `auto:true` 主键表的**种子/早前数据在没人手动改过的情况下自己变了
|
||||
名字/内容**,且改动发生在**该表第一次被本单新建一行**前后,先怀疑这条坑,查该主键是否
|
||||
`type:'integer'` 且没有其它非 auto 主键列(单主键、无前缀的场景下,`resolveRowKey` 续出的
|
||||
号从 `1` 起,最容易撞上种子数据里最早的几行)。
|
||||
|
||||
## `resolveRowKey` 的「全表一致」没有排除 `auto` 列 —— 第二形(2026-08-30,全分支终审 Critical 1)
|
||||
|
||||
上一条记的是 `type:'integer'` 那一形(§③ 续号步骤抢跑);把 `type_id` 改成 `text` 绕开它
|
||||
之后,**同一个函数的另一段**(`resolveRowKey` §② 「全表一致」,`apps/api/src/app/routes/
|
||||
desk.ts:144-168`)在**一个更窄的组合**下现出第二形:一张表的主键**唯一**一列就是
|
||||
`auto:true`(没有别的非 auto 主键列陪衬,如 W05 的 `parts.chip_type.type_id`)。
|
||||
|
||||
**病灶链条**(`part-substitution-assessment@2.1.8` 的 `views/report.ts` 曾经这样写):
|
||||
视图把这一列的种子行(件上「新建一行」时用来兜底提交的那一条静态行)干脆不给 `data-col`
|
||||
(想法是「自增列不问人,索性连格子都不摆」)——`ViewContainer.collect()` 只认
|
||||
`input/select/textarea[data-col]`,这一列于是**压根不出现在回传的 `cells` 里**。
|
||||
`resolveRowKey` 的 `barred` 判断认的是 `k in cells`(这一列在回传里露没露过面),**不是
|
||||
「值是否非空」**——漏挂 `data-col` 在它眼里等同「这张表压根没有这一列」,专门为「auto 列
|
||||
件上摆着却空着」设计的 `barred` 保护(:135)因此从未触发,`known` 退回
|
||||
`findKnownRow`(对空 `key` 恒返回 `undefined`),`isSeq('type_id')` 因为声明是 `text`
|
||||
也是假 —— 两条已有保护都不成立,直接落进「全表一致」:表里此刻**恰好一行**(活动侧
|
||||
的自投影机制保证了这一点,是巧合但不是意外)时,`seen.size===1` 把新建的那一行判成
|
||||
「就是那唯一一行」,一次保存就把既有那一行的其余字段**静默覆盖**。
|
||||
|
||||
**这次只在活动侧绕开**(给种子行的这一格换一个真的、`disabled` 的 `<input data-col=
|
||||
"type_id">`,值恒是空字符串——`barred` 就会正确命中,交回 `undefined` 让
|
||||
`mintable`/`autoKeys()` 接手续号),`resolveRowKey` 一行没改,见 `views/report.ts` 里
|
||||
`CHIP_TYPE_TABLE` 头上的大注释与 `apps/api/src/app/__tests__/view-table-edit.test.ts`
|
||||
「Critical 1」那组用真实函数逐字重放的两种 `cells` 形状。
|
||||
|
||||
**要治**(与上一条「§③ 续号加 `if (input.autos?.includes(k)) continue;`」是**同一刀**,
|
||||
建议一起做):`resolveRowKey` 的 §②(全表一致循环,:144-168)在**进入这个 `for` 循环给
|
||||
某一列赋值之前**,先判 `input.autos?.includes(k)` 就直接 `continue`(与 §③ 那条建议
|
||||
补的守卫完全对称)。这样一来,声明了 `auto` 的列会**无条件**被这两段循环跳过 ——
|
||||
不再依赖视图是否记得给它挂一个「看不见但露面」的 `data-col`;§① 那段「件上摆了、人也
|
||||
填了」的正常改行路径不受影响(它在这两段循环**之前**就已经把值定下来,轮不到 §②③)。
|
||||
补了这一刀,`barred` 这个专门的旁路判断其实就可以退休(它存在的唯一理由就是在 §②③
|
||||
没有这条守卫时,给「视图记得开个空位」这种个例开个后门)——但退休 `barred` 是更大的
|
||||
改动,不必和这次的守卫补丁绑在一起做。
|
||||
|
||||
**排查法**:与上一条同一句判词,但**不要求** `type:'integer'`——只要该表的主键里
|
||||
`auto:true` 的那一列**是唯一的主键列**(没有别的非 auto 主键列陪衬,靠不上
|
||||
`findKnownRow` 的「至少一格对得上」),且视图的种子/新增行**没有**给它挂 `data-col`,
|
||||
就该怀疑这条坑,不论声明的类型是 `integer` 还是 `text`。
|
||||
|
||||
## 范围放行(`releaseWrite` 的 `only` 形)没有对 `refersTo` 做传递闭包(2026-08-30,ACT-WORK-05 e2e 撞见,H35 落地时在活动侧绕开)
|
||||
|
||||
**现象**:「数据工单确认即入库」(`apps/api/src/app/data-order-landing.ts`,2026-08-27)
|
||||
把**这张卡的提交闸盯着的那几张表**(`dataOrderLedgers` 算出的 `only` 名单,取自卡绑的
|
||||
`blocks` 或它自报的 `gateLedgers`)并进正式表(`ledger.releaseWrite(taskId,
|
||||
{ only, cause })`)——只并名单上的表,不看这些表**引用**着哪些别的表。
|
||||
|
||||
如果 A 表(如 `parts.profile`)在 `only` 名单里、被并进正式表,而 A 表某一列声明了
|
||||
`refersTo: 'B.x'`(如 `parts.profile.chip_type_id` 指向 `parts.chip_type.type_id`),
|
||||
B 表却**不在**同一份 `only` 名单里——B 表对应的那一行仍留在草稿层,**正式表里就会
|
||||
出现一条引用着「只在草稿层存在、正式表查无此号」的悬空引用**:下一个任务(或同一个
|
||||
任务的下一步)在正式表侧查 B 表,查无此值,`refersTo` 校验拒它,或 `data-by` 嵌套
|
||||
查表显示不出来。
|
||||
|
||||
**这一次没有真的炸出悬空引用**——`part-substitution-assessment` 在活动自己的声明层
|
||||
把 A/B 两张表都放进了同一张卡的 `gateLedgers`(Ruling H35,`case-model.ts` 那一步
|
||||
`card` 定义处的大注释),绕开了这个触发点。但那是**这一个活动的选择**,不是平台
|
||||
保证:下一个活动如果只把「主表」列进某张卡的 `gateLedgers`、忘了把它引用的「字典表」
|
||||
也列进去,同样的悬空引用会在**正式表**里真实出现,不是测试环境才有的问题。
|
||||
|
||||
**要治**(方向,未验证):`releaseWrite` 的 `only` 形在计算真正要并的表集合时,应该
|
||||
对 `only` 名单里每张表声明的 `refersTo` 做一次**传递闭包**——A 引用 B、B 又引用 C,
|
||||
落地范围就该自动扩成 {A, B, C},而不是原样信任调用方给的 `only` 名单已经把引用链
|
||||
配全了。
|
||||
|
||||
**同族**(下一条,紧接在后):都是「数据工单确认即入库」这个 2026-08-27 新加的机制,
|
||||
与**别的**既有机制(这一条是 `refersTo` 的传递闭包,下一条是 `prune`)之间没有对齐
|
||||
接缝造成的。
|
||||
|
||||
**排查法**:数据工单确认之后,别的任务查某张「被引用」的表查无此值(而草稿层里其实
|
||||
有),先看确认那张卡的 `gateLedgers`/绑定 `blocks` 是不是只列了「主表」、漏了它
|
||||
`refersTo` 指向的「字典表」。
|
||||
|
||||
## `prune` 与「数据工单确认即入库」的架构性冲突(2026-08-30,ACT-WORK-05 案例 2 真跑撞见)
|
||||
|
||||
**现象**:数据工单**确认落地之后**再改动会影响行集合的字段(如 W05 的芯片类型),
|
||||
`project` 动作的 `prune: true` 要把「新计算出的集合里没有」的陈旧行撤掉——而那些行如果
|
||||
已经因为「数据工单确认即入库」(`landsOnConfirm`,2026-08-27)落进了正式表,`draftDrop`
|
||||
在 `apps/api/src/app/wiring/engines.ts:1217-1231` 那道显式检查前会被拒
|
||||
(`「…已入库的,撤不掉;要改由人在件上改」`),配方抛 `AutoRecipeError`,这一步再也办不下去,
|
||||
单子吊死在 `awaiting`,只能靠人工干预。
|
||||
|
||||
复现路径(W05 `part-substitution-assessment@2.1.5`,案例「两颗都在库但分属不同芯片类型」):
|
||||
①人对着两颗类型不一致但各自字段都齐的料,第一次提交(哪怕什么都没改,提交闸也放行,
|
||||
因为两颗料本就不缺格子)——这一步**已经落地**,把其中一颗按它*当时*的类型字段集
|
||||
(如 7 项)写进了正式表;②人接着把它转挂到*另一个*类型(字段集不同,如 5 项)、补上新类型
|
||||
要求的字段;③配方按新类型重新铺 `parts.parameter` 骨架,`prune:true` 想把①落地的那 7 项里、
|
||||
不在新的 5 项字段集中的几项(如 3 项)撤掉——它们已经落地,③在这里被 `engines.ts:1217`
|
||||
拒,`AutoRecipeError`。
|
||||
|
||||
**这是真人会走的路径,不是测试造出来的**:人确认一张数据工单之后才发现类型挑错了、
|
||||
需要改类型,是完全正常的操作顺序。
|
||||
|
||||
**两条机制各自都对**:`prune` 是活动清理陈旧行的正当手段(design spec §4.1 就是这么设计的:
|
||||
「计算出的新集合就是这张表的全部,多出来的撤掉」);「已落地行不许 `draftDrop`」是防止
|
||||
撤掉之后任务视图少一行、`mine:`/`_rows` 这类读数说谎。**合起来对同一批行形成死锁**:
|
||||
`prune` 想撤、平台不让撤。
|
||||
|
||||
与上一条(**范围放行没有对 `refersTo` 做传递闭包**)同族——两条都是「数据工单确认即
|
||||
入库」这个 2026-08-27 新加的机制与**别的**既有机制(这一条是 `prune`,上一条是
|
||||
`refersTo` 的传递闭包)之间没有对齐接缝造成的。
|
||||
|
||||
**要治**(方向,未验证,留给真正动手时评估):
|
||||
- 已落地行改为「先撤『已落地』记号,再撤行」——即 `prune` 遇到已落地行时先做等价于
|
||||
`markLanded` 的反操作(清 `landed_at`),把它重新变回「本单还没最终确定」的草稿状态,
|
||||
再走正常的 `draftDrop`;risk:这一改会让正式表暂时保留一条其实已经不该存在的行,
|
||||
直到下一次真正放行(`releaseWrite`)才会被撤下——需要确认这段「暂时不一致」窗口对下游
|
||||
(读表的其它任务、报表)是否可接受。
|
||||
- 或者 `prune` 对已落地行降级成「跳过、留着但记一条观察」,不当错误——把「这几行已经落地、
|
||||
这一笔没法帮你清」的实情说给人听,交给他在**放行那一步**的判据/清单里手动确认要不要紧,
|
||||
而不是让整单卡死。
|
||||
|
||||
**排查法留一条**:「配方办不下去」类的 `AutoRecipeError`,若原文提到「已入库/已落地…撤不掉」,
|
||||
先看是不是这条——尤其当那一步的 `project` 动作带 `prune: true`、且它写向的表**也**在
|
||||
某张数据工单的 `gateLedgers` 上时,概率最高。
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>数据工单 · PN 主数据创建(类型驱动 Demo) · SS02 ACT-WORK-05 差异评估</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
|
||||
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
|
||||
--brand:#3370ff; --brand-deep:#245bdb;
|
||||
--data:#3370ff; --data-deep:#245bdb; --data-soft:#e1eaff; --data-line:#c3d6ff; --data-wash:#f0f4ff;
|
||||
--ok:#278717; --ok-soft:#e8f8e4; --danger:#d83931; --danger-soft:#feeae9;
|
||||
--amber:#8f6a12; --amber-soft:#fdf3d7; --amber-line:#ecd9a0;
|
||||
--mono:"SF Mono",Menlo,Consolas,monospace;
|
||||
/* 8px 间距节奏(dense) */
|
||||
--s1:8px; --s2:16px; --s3:24px; --s4:32px;
|
||||
--r-sm:8px; --r-md:12px;
|
||||
--tr:.22s cubic-bezier(.2,.7,.3,1);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0}
|
||||
body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#fff;color:var(--ink);font-size:14px;line-height:1.7;-webkit-font-smoothing:antialiased}
|
||||
.page{margin:0;padding:0}
|
||||
.card{background:#fff;overflow:hidden;border:0;border-radius:0;box-shadow:none}
|
||||
|
||||
/* ===== 卡头 ===== */
|
||||
.card-head{display:flex;align-items:center;gap:14px;padding:20px 24px;border-bottom:1px solid var(--line-soft);background:linear-gradient(120deg,#f0f4ff 0%,#fff 55%,#eef3ff 100%)}
|
||||
.badge{flex:none;width:44px;height:44px;border-radius:11px;display:grid;place-items:center;background:var(--data);color:#fff;font-weight:800;font-size:13px;box-shadow:0 6px 16px rgba(51,112,255,.28)}
|
||||
.tit{flex:1;min-width:0}
|
||||
.tit .kick{font-size:11px;letter-spacing:.08em;color:var(--ink-4);font-weight:700}
|
||||
.tit h1{margin:2px 0 0;font-size:20px;font-weight:800;line-height:1.3;letter-spacing:-.01em}
|
||||
.tit .sub{font-size:12px;color:var(--ink-3);margin-top:3px}
|
||||
.side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex:none}
|
||||
.tag{font-size:11px;font-weight:800;padding:2px 10px;border-radius:999px;color:var(--data-deep);background:var(--data-soft);border:1px solid var(--data-line)}
|
||||
.step{font-size:12px;color:var(--ink-3)}
|
||||
.deadline{font-size:12px;color:var(--ink-3)}
|
||||
.deadline b{color:var(--danger)}
|
||||
.card-bd{padding:var(--s3) 24px var(--s4)}
|
||||
|
||||
/* ===== 区标 / 说明带 ===== */
|
||||
.zone{font-size:11px;font-weight:800;letter-spacing:.12em;color:var(--ink-4);margin:0 0 var(--s1)}
|
||||
.zone b{color:var(--ink-3)}
|
||||
.note{border:1px solid var(--line);border-left:3px solid var(--data);border-radius:var(--r-sm);background:var(--soft);padding:14px 16px;margin-bottom:var(--s2)}
|
||||
.note p{margin:0;font-size:13px;color:var(--ink-2)}
|
||||
.note p+p{margin-top:6px}
|
||||
.note b{color:var(--ink)}
|
||||
.note .need{color:var(--data-deep);font-weight:700}
|
||||
.mono{font-family:var(--mono);font-size:12px;color:var(--ink-2)}
|
||||
.st{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;border:1px solid transparent;white-space:nowrap}
|
||||
.st.ok{color:var(--ok);background:var(--ok-soft);border-color:#c9ecc2}
|
||||
.st.new{color:var(--data-deep);background:var(--data-soft);border-color:var(--data-line)}
|
||||
.st.lock{color:var(--ink-3);background:#eef1f6;border-color:var(--line)}
|
||||
|
||||
/* ===== 编辑闸 + 必填进度 ===== */
|
||||
.jc-editgate{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 var(--s2);padding:10px 14px;border-radius:10px;background:var(--soft);border:1px solid var(--line-soft);font-size:12px;color:var(--ink-3);transition:background var(--tr),border-color var(--tr)}
|
||||
.jc-editgate-t{flex:1 1 auto;min-width:0}
|
||||
.jc-editgate-t b{color:var(--ink-2);font-weight:700}
|
||||
.gateprog{flex:1 1 100%;display:flex;align-items:center;gap:10px;margin-top:6px}
|
||||
.gateprog .track{flex:1;height:5px;border-radius:3px;background:var(--line-soft);overflow:hidden}
|
||||
.gateprog .fill{display:block;height:100%;width:0;border-radius:3px;background:linear-gradient(90deg,var(--data),var(--data-deep));transition:width .35s cubic-bezier(.2,.7,.3,1)}
|
||||
.gateprog .pct{font-family:var(--mono);font-size:11px;color:var(--ink-4);min-width:64px;text-align:right}
|
||||
.jc-egbtn{flex:none;display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:1px solid var(--line);border-radius:8px;padding:7px 14px;font:inherit;font-size:12px;font-weight:700;color:var(--ink-2);background:#fff;transition:border-color var(--tr),color var(--tr),background var(--tr),transform .12s}
|
||||
.jc-egbtn:hover{border-color:var(--data);color:var(--data)}
|
||||
.jc-egbtn:active{transform:scale(.97)}
|
||||
.jc-egbtn.save{color:#fff;background:var(--data);border-color:var(--data)}
|
||||
.jc-egbtn.save:hover{filter:brightness(1.06);color:#fff}
|
||||
.jc-egbtn:focus-visible{outline:2px solid var(--data-deep);outline-offset:2px}
|
||||
.jc-editgate .eg-cancel,.jc-editgate .eg-save{display:none}
|
||||
.jc-editgate.on{background:var(--data-wash);border-color:var(--data-line);color:#1e40af}
|
||||
.jc-editgate.on .eg-open{display:none}
|
||||
.jc-editgate.on .eg-cancel,.jc-editgate.on .eg-save{display:inline-flex}
|
||||
|
||||
/* ===== 渲染态:档案卡 ===== */
|
||||
.idcard{border:1px solid var(--line-soft);border-radius:var(--r-md);margin-bottom:var(--s2);background:linear-gradient(150deg,#fff 60%,var(--data-wash));display:grid;grid-template-columns:1.2fr 1fr;overflow:hidden}
|
||||
.idcard .cell{padding:18px 22px}
|
||||
.idcard .cell+.cell{border-left:1px solid var(--line-soft);background:rgba(255,255,255,.55)}
|
||||
.idcard .lab{font-size:11.5px;font-weight:800;color:var(--ink-3);letter-spacing:.08em;display:flex;align-items:center;gap:6px}
|
||||
.idcard .lab::before{content:"";width:8px;height:8px;border-radius:2.5px;background:var(--data)}
|
||||
.idcard .val{margin-top:8px;font-size:24px;font-weight:800;font-family:var(--mono);letter-spacing:-.01em;line-height:1.25;color:var(--ink)}
|
||||
.idcard .val.empty{font-size:14px;font-family:inherit;font-weight:400;color:var(--ink-4)}
|
||||
.idcard .val .tpill{display:inline-block;font-family:inherit;font-size:15px;font-weight:800;color:var(--data-deep);background:var(--data-soft);border:1px solid var(--data-line);border-radius:999px;padding:4px 14px;vertical-align:2px}
|
||||
.idcard .val .st{margin-left:8px;vertical-align:3px}
|
||||
|
||||
/* ===== 参数表(渲染态) ===== */
|
||||
.tbl-wrap{border:1px solid var(--line-soft);border-radius:10px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||
th{text-align:left;background:var(--soft);font-size:11px;letter-spacing:.04em;font-weight:800;color:var(--ink-3);padding:10px 14px;border-bottom:1px solid var(--line)}
|
||||
td{padding:10px 14px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
||||
tr:last-child td{border-bottom:0}
|
||||
tbody tr{transition:background .15s}
|
||||
tbody tr:hover{background:var(--data-wash)}
|
||||
.empty-row td{color:var(--ink-4);text-align:center;padding:22px}
|
||||
.empty-row td:hover{background:#fff}
|
||||
|
||||
/* ===== 编辑态:步骤轨道 ===== */
|
||||
.editarea{display:none}
|
||||
body.editing .editarea{display:block}
|
||||
body.editing .viewarea{display:none}
|
||||
.steps{position:relative}
|
||||
.steps::before{content:"";position:absolute;left:12px;top:20px;bottom:20px;width:2px;background:var(--line-soft);border-radius:1px}
|
||||
.stepbox{position:relative;padding-left:36px;margin-bottom:var(--s3)}
|
||||
.stepbox:last-child{margin-bottom:0}
|
||||
.sdot{position:absolute;left:0;top:0;width:26px;height:26px;border-radius:50%;background:#fff;border:2px solid var(--data-line);color:var(--data-deep);font-size:12px;font-weight:800;display:grid;place-items:center;transition:background var(--tr),border-color var(--tr),color var(--tr)}
|
||||
.stepbox.done .sdot{background:var(--data);border-color:var(--data);color:#fff}
|
||||
.scard{border:1px solid var(--line-soft);border-radius:var(--r-md);background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
|
||||
.stepbox.done .scard{border-color:var(--data-line)}
|
||||
.scard .sh{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line-soft);font-size:13.5px;font-weight:800;letter-spacing:-.01em}
|
||||
.scard .sh .req-note{margin-left:auto;font-size:11px;color:var(--ink-4);font-weight:400}
|
||||
.scard .sb{padding:16px}
|
||||
.fld{display:flex;align-items:flex-start;gap:12px;font-size:12.5px}
|
||||
.fld .fk{width:88px;flex:none;color:var(--ink-3);font-weight:700;font-size:12px;padding-top:9px}
|
||||
.fld .fk.req::after{content:" *";color:var(--danger)}
|
||||
.fld .fv{flex:1;min-width:0}
|
||||
.fld input,.fld select{width:100%;border:1px solid var(--line);border-radius:var(--r-sm);padding:9px 11px;font:inherit;font-size:13px;color:var(--ink);background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
|
||||
.fld input:focus,.fld select:focus{border-color:var(--data);box-shadow:0 0 0 3px var(--data-wash);outline:none}
|
||||
.hint{font-size:11.5px;color:var(--ink-4);margin-top:6px}
|
||||
|
||||
/* ===== 新增类型面板 ===== */
|
||||
.newtype{display:none;margin-top:12px;border:1px dashed var(--data-line);border-radius:10px;background:var(--data-wash);padding:14px 16px}
|
||||
.newtype.show{display:block;animation:rise .25s cubic-bezier(.2,.7,.3,1)}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
||||
.newtype h4{margin:0 0 10px;font-size:12.5px;color:var(--data-deep)}
|
||||
.prow{display:flex;gap:8px;align-items:center;margin-top:8px;animation:rise .2s cubic-bezier(.2,.7,.3,1)}
|
||||
.prow input{flex:1;border:1px solid var(--line);border-radius:7px;padding:8px 10px;font:inherit;font-size:12.5px;background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
|
||||
.prow input:focus{border-color:var(--data);box-shadow:0 0 0 3px rgba(51,112,255,.12);outline:none}
|
||||
.prow .del{flex:none;width:28px;height:28px;cursor:pointer;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink-3);font-size:14px;line-height:1;transition:border-color var(--tr),color var(--tr),background var(--tr)}
|
||||
.prow .del:hover{border-color:var(--danger);color:var(--danger);background:var(--danger-soft)}
|
||||
.nt-actions{display:flex;gap:8px;margin-top:12px;align-items:center}
|
||||
.btn{cursor:pointer;border:1px solid var(--data-line);border-radius:8px;padding:7px 14px;font:inherit;font-size:12px;font-weight:700;color:var(--data-deep);background:#fff;transition:background var(--tr),transform .12s}
|
||||
.btn:hover{background:var(--data-soft)}
|
||||
.btn:active{transform:scale(.97)}
|
||||
.btn.primary{color:#fff;background:var(--data);border-color:var(--data)}
|
||||
.btn.primary:hover{filter:brightness(1.07)}
|
||||
.btn:focus-visible{outline:2px solid var(--data-deep);outline-offset:2px}
|
||||
|
||||
/* ===== 参数值瓷贴 ===== */
|
||||
.pvgrid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
|
||||
.pv{border:1px solid var(--line-soft);border-radius:10px;padding:10px 12px;background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
|
||||
.pv:focus-within{border-color:var(--data-line);box-shadow:0 0 0 3px rgba(51,112,255,.08)}
|
||||
.pv .pn2{font-size:12px;font-weight:800;color:var(--ink-2);margin-bottom:6px;display:flex;align-items:baseline;gap:6px}
|
||||
.pv .pn2::after{content:"*";color:var(--danger);font-size:11px}
|
||||
.pv input{width:100%;border:1px solid var(--line);border-radius:7px;padding:7px 10px;font:inherit;font-size:12.5px;background:#fff;transition:border-color var(--tr),box-shadow var(--tr)}
|
||||
.pv input:focus{border-color:var(--data);box-shadow:0 0 0 3px rgba(51,112,255,.12);outline:none}
|
||||
.pv-empty{border:1px dashed var(--line);border-radius:10px;padding:20px;text-align:center;color:var(--ink-4);font-size:12px}
|
||||
|
||||
/* ===== 底 ===== */
|
||||
.flow{margin-top:var(--s2);padding:10px 14px;border-radius:var(--r-sm);background:var(--soft);border:1px dashed var(--line);font-size:12px;color:var(--ink-3)}
|
||||
.flow b{color:var(--ink)}
|
||||
.card-foot{padding:14px 24px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--ink-4);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
||||
@media(max-width:760px){.pvgrid{grid-template-columns:1fr}.card-head{flex-wrap:wrap}.side{align-items:flex-start}.idcard{grid-template-columns:1fr}.idcard .cell+.cell{border-left:0;border-top:1px solid var(--line-soft)}}
|
||||
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
|
||||
<header class="card-head">
|
||||
<div class="badge">数据</div>
|
||||
<div class="tit">
|
||||
<div class="kick">SS02 · ACT-WORK-05 差异评估 · 数据工单 · 类型驱动 Demo</div>
|
||||
<h1>PN 主数据创建</h1>
|
||||
<div class="sub">收件人:<span data-slot="meta.task.owner">FAE · 李工</span> · 关联任务:<span data-slot="meta.task.id">TASK-BT02-20260828-003</span></div>
|
||||
</div>
|
||||
<div class="side">
|
||||
<span class="tag">数据工单</span>
|
||||
<span class="step" data-slot="meta.phase">步骤 2/5 · 读取并标准化资料(读 P-08 查无触发)</span>
|
||||
<span class="deadline">最迟交回:<b data-slot="card.deadline">1 个工作日</b></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card-bd">
|
||||
|
||||
<p class="zone"><b>【触发原因】</b>—— 系统查 P-08 芯片主数据无该 PN 记录(完全没有,非字段缺失)</p>
|
||||
<div class="note">
|
||||
<p><b>流程</b>:填 PN 名称 → 选 PN 类型(下拉首行「+新增类型」)→ 按类型自动加载该类型的核心参数名 → 逐项填值。找不到类型就现场新建:填类型名称 + 它的核心参数名,保存后参数名自动加载成输入框。</p>
|
||||
<p><span class="need">AI 预填情况</span>:未提供 datasheet → 本单全手填(合法路径)。<b>纪律</b>:确认即正式入库(无草稿、无审核),确认人 = 数据责任人并留痕;只创建主数据/资料类记录。</p>
|
||||
</div>
|
||||
|
||||
<div class="jc-editgate" id="editgate">
|
||||
<span class="jc-editgate-t"><b>必填未完成</b>:PN 名称、PN 类型、全部核心参数值为必填;点「编辑」进入编辑态</span>
|
||||
<button class="jc-egbtn eg-open" type="button">编辑</button>
|
||||
<button class="jc-egbtn eg-cancel" type="button">取消</button>
|
||||
<button class="jc-egbtn eg-save" type="button">保存</button>
|
||||
<span class="gateprog"><span class="track"><span class="fill" id="gp-fill"></span></span><span class="pct" id="gp-pct">0 / 3 项</span></span>
|
||||
</div>
|
||||
|
||||
<!-- 渲染态 -->
|
||||
<div class="viewarea">
|
||||
<div class="idcard">
|
||||
<div class="cell">
|
||||
<div class="lab">PN 名称</div>
|
||||
<div class="val empty" id="v-pn">(未填写)</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="lab">PN 类型</div>
|
||||
<div class="val empty" id="v-type">(未选择)</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="zone"><b>核心参数</b>(由 PN 类型决定参数名)</p>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th style="width:220px">参数名</th><th>参数值</th></tr></thead>
|
||||
<tbody id="v-params" data-rows="in.PN核心参数">
|
||||
<tr class="empty-row"><td colspan="2">未选择 PN 类型,暂无参数</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑态 -->
|
||||
<div class="editarea">
|
||||
<div class="steps">
|
||||
|
||||
<div class="stepbox" id="sb1">
|
||||
<span class="sdot">1</span>
|
||||
<div class="scard">
|
||||
<div class="sh">PN 名称<span class="req-note">必填 · 内部料号</span></div>
|
||||
<div class="sb">
|
||||
<div class="fld"><span class="fk req">PN 名称</span><div class="fv"><input id="e-pn" data-field="PN名称" placeholder="如 SGM6132-5.0"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stepbox" id="sb2">
|
||||
<span class="sdot">2</span>
|
||||
<div class="scard">
|
||||
<div class="sh">PN 类型<span class="req-note">必填 · 类型决定第 3 步加载哪些核心参数</span></div>
|
||||
<div class="sb">
|
||||
<div class="fld"><span class="fk req">PN 类型</span><div class="fv">
|
||||
<select id="e-type" data-field="PN类型">
|
||||
<option value="__new__">+ 新增类型</option>
|
||||
<option>DCDC 降压稳压器</option>
|
||||
<option>LDO 线性稳压器</option>
|
||||
<option>运算放大器</option>
|
||||
</select>
|
||||
<div class="hint">类型库里没有的类型,选首行「+ 新增类型」现场定义。</div>
|
||||
</div></div>
|
||||
|
||||
<div class="newtype" id="newtype">
|
||||
<h4>新增 PN 类型</h4>
|
||||
<div class="fld"><span class="fk req">类型名称</span><div class="fv"><input id="nt-name" data-field="新类型名称" placeholder="如 负载开关"></div></div>
|
||||
<div class="fld" style="margin-top:10px"><span class="fk req">核心参数名</span><div class="fv">
|
||||
<div id="nt-params"></div>
|
||||
<div class="nt-actions">
|
||||
<button type="button" class="btn" id="nt-addrow">+ 加一行参数名</button>
|
||||
<button type="button" class="btn primary" id="nt-save">保存类型并加载参数</button>
|
||||
</div>
|
||||
<div class="hint">保存后:类型进入下拉库(本单生效),下方自动加载这些参数名的输入框。</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stepbox" id="sb3">
|
||||
<span class="sdot">3</span>
|
||||
<div class="scard">
|
||||
<div class="sh">核心参数值<span class="req-note" id="pv-note">先选类型</span></div>
|
||||
<div class="sb">
|
||||
<div class="pv-empty" id="pv-empty">已在第 2 步选择类型后,这里按类型列出核心参数输入框</div>
|
||||
<div class="pvgrid" id="pv-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow"><b>审批 / 提交:</b>本工单只承载内容;「确认」请在对话区的【数据卡】中进行。<b>确认后去向:</b>PN 主数据正式入库 P-08(无草稿、无审核),活动从断点续跑;本单不复用。<b>留痕:</b>确认人 / 本单编号 / 所属活动随记录入库。</div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="card-foot">
|
||||
<span>SS02 · ACT-WORK-05 差异评估 · 数据工单 · PN 主数据创建(类型驱动 Demo)</span>
|
||||
<span style="font-family:var(--mono)">工单类型:数据 · <span data-slot="card.id">WO-ACT-WORK-05-D02</span></span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
// ===== 类型库:类型名 → 核心参数名列表 =====
|
||||
var PN_TYPES = {
|
||||
"DCDC 降压稳压器": ["输入电压范围","输出电压","输出电流","开关频率","静态电流","工作温度","使能阈值","反馈网络"],
|
||||
"LDO 线性稳压器": ["输入电压范围","输出电压","输出电流","压差电压","静态电流","工作温度"],
|
||||
"运算放大器": ["供电电压范围","增益带宽积","压摆率","输入失调电压","静态电流","工作温度"]
|
||||
};
|
||||
var BUILTIN = {"DCDC 降压稳压器":1,"LDO 线性稳压器":1,"运算放大器":1};
|
||||
|
||||
var body = document.body;
|
||||
var gate = document.getElementById('editgate');
|
||||
var ePn = document.getElementById('e-pn');
|
||||
var eType = document.getElementById('e-type');
|
||||
var newtype = document.getElementById('newtype');
|
||||
var ntName = document.getElementById('nt-name');
|
||||
var ntParams = document.getElementById('nt-params');
|
||||
var pvGrid = document.getElementById('pv-grid');
|
||||
var pvEmpty = document.getElementById('pv-empty');
|
||||
var pvNote = document.getElementById('pv-note');
|
||||
var gpFill = document.getElementById('gp-fill');
|
||||
var gpPct = document.getElementById('gp-pct');
|
||||
var values = {}; // 参数名 → 值(编辑态暂存,切类型时保留同名值)
|
||||
var snap = null; // 取消用快照
|
||||
|
||||
// ===== 必填完成度 =====
|
||||
function progress(){
|
||||
var total = 2, done = 0; // PN 名称 + PN 类型
|
||||
if(ePn.value.trim()) done++;
|
||||
var names = (eType.value && eType.value !== '__new__') ? PN_TYPES[eType.value] : null;
|
||||
if(names){ done++; total += names.length; names.forEach(function(pn){ if(values[pn] && values[pn].trim()) done++; }); }
|
||||
var pct = total ? Math.round(done/total*100) : 0;
|
||||
gpFill.style.width = pct + '%';
|
||||
gpPct.textContent = done + ' / ' + total + ' 项';
|
||||
document.getElementById('sb1').classList.toggle('done', !!ePn.value.trim());
|
||||
document.getElementById('sb2').classList.toggle('done', !!names);
|
||||
document.getElementById('sb3').classList.toggle('done', !!names && names.every(function(pn){ return values[pn] && values[pn].trim(); }));
|
||||
}
|
||||
|
||||
// ===== 编辑态:参数值区渲染 =====
|
||||
function clearParams(msg, note){
|
||||
pvGrid.innerHTML = '';
|
||||
pvEmpty.textContent = msg;
|
||||
pvEmpty.style.display = 'block';
|
||||
pvNote.textContent = note || '待类型确定';
|
||||
}
|
||||
function renderParams(typeName){
|
||||
pvGrid.innerHTML = '';
|
||||
var names = PN_TYPES[typeName];
|
||||
if(!names){ clearParams('先选类型','先选类型'); progress(); return; }
|
||||
pvEmpty.style.display='none';
|
||||
pvNote.textContent = typeName + ' · ' + names.length + ' 项核心参数';
|
||||
names.forEach(function(pn){
|
||||
var div = document.createElement('div');
|
||||
div.className = 'pv';
|
||||
div.innerHTML = '<div class="pn2"></div><input data-field="参数值" placeholder="填写 ' + pn + '">';
|
||||
div.querySelector('.pn2').textContent = pn;
|
||||
var inp = div.querySelector('input');
|
||||
inp.value = values[pn] || '';
|
||||
inp.addEventListener('input', function(){ values[pn] = inp.value; progress(); });
|
||||
pvGrid.appendChild(div);
|
||||
});
|
||||
progress();
|
||||
}
|
||||
|
||||
// 类型下拉:选中即加载参数;选「+新增类型」展开新建面板并清空参数区(等保存后按新类型重渲染)
|
||||
eType.addEventListener('change', function(){
|
||||
if(eType.value === '__new__'){
|
||||
newtype.classList.add('show'); ntName.focus();
|
||||
clearParams('正在新增类型——保存后这里按新类型的参数名加载输入框');
|
||||
progress();
|
||||
return;
|
||||
}
|
||||
newtype.classList.remove('show');
|
||||
renderParams(eType.value);
|
||||
});
|
||||
ePn.addEventListener('input', progress);
|
||||
|
||||
// 新增类型:参数名行动态增删
|
||||
function addNtRow(v){
|
||||
var row = document.createElement('div');
|
||||
row.className = 'prow';
|
||||
row.innerHTML = '<input data-field="新类型参数名" placeholder="参数名,如 导通电阻"><button type="button" class="del" title="删除">×</button>';
|
||||
row.querySelector('input').value = v || '';
|
||||
row.querySelector('.del').addEventListener('click', function(){ row.remove(); });
|
||||
ntParams.appendChild(row);
|
||||
}
|
||||
document.getElementById('nt-addrow').addEventListener('click', function(){ addNtRow(''); });
|
||||
|
||||
// 保存类型:入库(本单生效)→ 进下拉并选中 → 加载参数名
|
||||
document.getElementById('nt-save').addEventListener('click', function(){
|
||||
var name = ntName.value.trim();
|
||||
var names = Array.prototype.map.call(ntParams.querySelectorAll('input'), function(i){ return i.value.trim(); }).filter(Boolean);
|
||||
if(!name){ ntName.focus(); return; }
|
||||
if(names.length === 0){ addNtRow(''); return; }
|
||||
PN_TYPES[name] = names;
|
||||
var opt = document.createElement('option');
|
||||
opt.textContent = name; opt.value = name;
|
||||
eType.insertBefore(opt, eType.children[1]); // 首行固定为「+新增类型」,新类型插到其后
|
||||
eType.value = name;
|
||||
newtype.classList.remove('show');
|
||||
renderParams(name);
|
||||
});
|
||||
|
||||
// ===== 渲染态回写 =====
|
||||
function paint(){
|
||||
var vPn = document.getElementById('v-pn');
|
||||
var vType = document.getElementById('v-type');
|
||||
vPn.textContent = ePn.value.trim() || '(未填写)';
|
||||
vPn.classList.toggle('empty', !ePn.value.trim());
|
||||
vType.innerHTML = '';
|
||||
if(eType.value && eType.value !== '__new__'){
|
||||
var pill = document.createElement('span'); pill.className = 'tpill'; pill.textContent = eType.value;
|
||||
vType.appendChild(pill);
|
||||
if(!BUILTIN[eType.value]){ var s=document.createElement('span'); s.className='st new'; s.textContent='本单新建类型'; vType.appendChild(s); }
|
||||
vType.classList.remove('empty');
|
||||
} else { vType.textContent='(未选择)'; vType.classList.add('empty'); }
|
||||
var tb = document.getElementById('v-params');
|
||||
tb.innerHTML = '';
|
||||
var names = PN_TYPES[eType.value] || [];
|
||||
if(names.length === 0){
|
||||
tb.innerHTML = '<tr class="empty-row"><td colspan="2">未选择 PN 类型,暂无参数</td></tr>';
|
||||
} else {
|
||||
names.forEach(function(pn){
|
||||
var tr = document.createElement('tr');
|
||||
tr.setAttribute('data-field','参数名');
|
||||
var val = values[pn] && values[pn].trim() ? values[pn] : '(未填写)';
|
||||
tr.innerHTML = '<td><b></b></td><td class="mono"></td>';
|
||||
tr.children[0].querySelector('b').textContent = pn;
|
||||
tr.children[1].textContent = val;
|
||||
if(val === '(未填写)'){ tr.children[1].style.color = 'var(--ink-4)'; tr.children[1].classList.remove('mono'); }
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 编辑闸 =====
|
||||
gate.querySelector('.eg-open').addEventListener('click', function(){
|
||||
snap = { pn: ePn.value, type: eType.value, values: JSON.parse(JSON.stringify(values)) };
|
||||
body.classList.add('editing'); gate.classList.add('on');
|
||||
if(eType.value && eType.value !== '__new__'){ renderParams(eType.value); }
|
||||
else { clearParams('先选类型','先选类型'); }
|
||||
progress();
|
||||
});
|
||||
gate.querySelector('.eg-cancel').addEventListener('click', function(){
|
||||
if(snap){ ePn.value = snap.pn; eType.value = snap.type; values = snap.values; }
|
||||
newtype.classList.remove('show');
|
||||
body.classList.remove('editing'); gate.classList.remove('on');
|
||||
progress();
|
||||
});
|
||||
gate.querySelector('.eg-save').addEventListener('click', function(){
|
||||
paint();
|
||||
body.classList.remove('editing'); gate.classList.remove('on');
|
||||
});
|
||||
|
||||
// 初始:新增类型面板预置 3 行参数名输入
|
||||
addNtRow(''); addNtRow(''); addNtRow('');
|
||||
progress();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,632 @@
|
||||
# SS02—SS03—SS04 Demo台账字段细化与统一方案
|
||||
|
||||
## 一、结论摘要
|
||||
|
||||
本成果以《SS01-02-03-04-台账体系全景-18Activity关联矩阵》的台账编号为唯一编号标准,对六组Demo Activity进行字段级反推和补齐:[1] [2] [3] [4] [5] [6]。结论是:**当前全景体系的台账编号总体可承载Demo,但核心内容描述仍不足以直接支撑执行,必须补充字段级定义、条件必填规则和主记录/子表结构。**
|
||||
|
||||
本方案将Demo中的细分编号按全景标准归并,例如P-01-01→P-01、P-08-01→P-08、P-11-01/P-11-02→P-11的不同关系状态或子对象;不新增与全景体系冲突的台账编号。[1] [2] [3] 对于逐参数差异、逐报价阶梯和逐份单证等重复明细,采用子表承载,避免在单一文本字段中堆叠数据。
|
||||
|
||||
## 二、编号归并与建模原则
|
||||
|
||||
> **编号原则:**全景矩阵的44个台账编号是主键标准;Demo中的-01/-02属于局部架构或对象层标识,除非全景矩阵明确存在对应台账,否则不作为新的全景台账编号。
|
||||
|
||||
|Demo原编号/写法|统一全景编号|统一后的对象/说明|
|
||||
|---|---|---|
|
||||
|P-01-01|P-01|客户具体需求与项目任务主台账;任务、需求版本、报价上下文和Activity回链统一归入。|
|
||||
|P-06-01|P-06|样品交付与芯片验证记录台账;验证事实与样品追溯统一归入。|
|
||||
|P-08-01|P-08|芯片主数据台账;全链路只认internal_pn。|
|
||||
|P-09-02|P-09|客户技术与机会档案;技术档案字段统一挂客户ID。|
|
||||
|P-10-01/02/03|P-10|原厂技术资料库;资料类型由document_type区分。|
|
||||
|P-11-01/P-11-02|P-11|替代选型与批准关系库;analysis_status与customer_approval_status分离,差异明细设子表。|
|
||||
|P-04-01/P-04-02|P-04|交易框架与承诺台账;框架主记录与承诺子记录分层。|
|
||||
|D-01物流F组/交接E组|D-01|交易与执行台账;交接事实与物流节点作为不同对象层,但共享order_id。|
|
||||
|
||||
## 三、Activity覆盖校验
|
||||
|
||||
下表说明每个Demo的关键读写台账、字段补足点和覆盖结论。[1] [2] [3] [4] [5] [6]
|
||||
|
||||
|Activity|原矩阵台账编号|统一台账编号|主写入台账|关键新增/细化字段|覆盖结论|
|
||||
|---|---|---|---|---|---|
|
||||
|ACT-WORK-05 差异评估|P-01-01、P-06-01、P-08-01、P-09-02、P-10-01/02/03、P-11-01、P-11-02、GOV|P-01、P-06、P-08、P-09、P-10、P-11、GOV|P-01、P-11;条件写P-06|requirement_version、input_snapshot_ref、bom_position、normalized_parameter_set、difference_item_id、difference_status、impact_statement、constraint_type、rule_version、confidence_score|已覆盖;差异明细由P-11差异明细子表承载,客户批准状态与差异分析状态分离。|
|
||||
|ACT-WORK-24 报价方案制定|P-01-01、P-02-01、P-03-01、P-04-01/02、P-06-01、P-08-01、P-11-02、GOV|P-01、P-02、P-03、P-04、P-06、P-08、P-11、GOV|P-02;回写P-01|quote_version、quote_type、quote_valid_until、delivery_commitment、payment_terms、tax_terms、incoterms、exchange_rate、gross_margin_status、rule_version、QuoteLine、blocking_reason、substitution_approval_status|已覆盖;P-02采用主记录+QuoteLine明细结构,正式发布与AI草稿以状态和版本区分。|
|
||||
|SS03 Activity 1 发起询价|P-01、D-15、P-08、S-03、S-05、S-04|P-01、D-15、P-08、S-03、S-05、S-04|S-04 RFQ层;条件写S-03|source_type/source_id、rfq_batch_id、rfq_line_id、quote_due_at、outbound_content_ref、sent_evidence_ref、reminder_count、last_reminded_at、overdue_flag、temporary_supplier_flag|已覆盖;最小粒度固定为询价批次×供应商×内部料号。|
|
||||
|SS03 Activity 2 回填报价/比价/定点|P-01、D-15、P-08、S-03、S-05、S-04|P-01、D-15、P-08、S-03、S-05、S-04|S-04 Quote/Decision层|supplier_quote_id、quote_revision_no、comparability_status、comparison_basis_snapshot_ref、baseline_match_status、baseline_unit_price、comparison_currency_code、conversion_rate、price_variance_pct、selected_supplier_id、selected_quote_id、unselected_reason、notification_recipient_ref、score_rule_version|已覆盖;跨币种、无基准、不可比、涨幅>10%、代理证及临时供应商门禁均有字段承载。|
|
||||
|ACT-11-A2 提货交接与风险转移|D-01、D-03、D-10、D-11、L-02、P-08、D-04|D-01、D-03、D-10、D-11、L-02、P-08、D-04|D-01、D-03;触发D-04相关门禁|allocation_ref、handover_no、receiver_name、sign_time、risk_transfer_time、handover_diff、handover_file_ref、handover_doc_status、handover_notes、document_set_complete、consistency_check_status、allocation_status、last_ship_date|已覆盖;交接事实、风险转移派生、文件状态、单证门禁和库存同步均已落台账。|
|
||||
|ACT-11-A3 物流执行信息跟踪|D-01、D-10|D-01、D-10|D-01物流节点事实层|logi_pickup/logi_clearance/logi_release/logi_departure(统一节点结构)、track_note、track_visible、logistics_confirmation_channel、logistics_confirmation_interval_days、logistics_max_reminder_count、alert_thresholds|已覆盖;每个节点包含预计时间、实际时间、来源、采集方式、确认状态、异常标记和证据引用。|
|
||||
|
||||
## 四、字段统一总表
|
||||
|
||||
### 4.1 通用治理字段
|
||||
|
||||
以下字段建议作为所有涉及Demo台账的通用治理字段继承,但不要求在每张业务台账中重复手工维护。`record_status`只管理记录生命周期;`*_status`管理具体业务过程;`evidence_ref`用于AI和人工结果的可审计回链。
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|维护/来源|Demo使用|定义/取值/校验规则|
|
||||
|---|---|---|---|---|---|---|
|
||||
|record_id|记录ID|Text/AutoNumber|是|系统|所有Demo|每条台账记录的唯一主键;业务编号不能替代数据库主键。|
|
||||
|record_status|记录状态|Enum|是|台账Owner|所有Demo|有效/草稿/归档/作废;不得用业务过程状态替代记录生命周期状态。|
|
||||
|record_version|记录版本|Text/Integer|是|系统/Owner|W05/W24/SS03/11-A2/11-A3|每次影响业务判断的修改递增;报价、价格、规则、客户映射必须可回溯。|
|
||||
|source_type|来源类型|Enum|条件必填|系统/上游Owner|W05/W24/SS03/11-A2|来源对象的类型,如客户需求、采购计划、价格维护、异常补货、订单、外部回复。|
|
||||
|source_id|来源对象ID|Text/Link|条件必填|系统/上游Owner|W05/W24/SS03/11-A2|与source_type成对出现;不得把说明文字写入ID字段。|
|
||||
|data_owner|数据Owner|Link|是|台账Owner|所有Demo|负责定义、质量和变更审批的角色或人员。|
|
||||
|evidence_ref|证据引用|URL/Attachment/Structure|条件必填|AI/PW/Owner|W05/W24/SS03/11-A2/11-A3|指向原始邮件、聊天记录、签署件、数据表、资料页码或其他证据;可多值。|
|
||||
|created_at|创建时间|DateTime|系统|系统|所有Demo|系统生成。|
|
||||
|updated_at|更新时间|DateTime|系统|系统|所有Demo|系统生成。|
|
||||
|last_verified_at|最后核验时间|DateTime|条件必填|Owner/PW/FAE/采购|所有Demo|对影响下游决策的事实标记最近一次人工或规则核验时间。|
|
||||
|
||||
### 4.2 按全景台账编号展开的字段
|
||||
|
||||
#### P-01 客户具体需求与项目任务主台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|request_id|需求编号|Text/AutoNumber|是|主记录|SS02|W05/W24/SS03-1/SS03-2/D-01|客户需求与项目任务主记录的统一业务键;SS03写入S-04.source_id。|重命名/统一|任务ID、request_id、需求编号|
|
||||
|customer_id|客户ID|Link|是|主记录|L-02/SS02|W05/W24/SS03-1/SS03-2|统一关联L-02;需求没有客户时需明确内部需求标识。|保留/细化|customer_id、客户|
|
||||
|project_id|项目ID|Link|条件必填|主记录|P-05/SS02|W05/W24/SS03-1/SS03-2|有项目场景必填;与客户ID共同确定需求归属和结果通知对象。|新增/细化|project_id、客户/项目|
|
||||
|requirement_version|需求版本|Text|是|主记录|SS02/系统|W05/W24|需求基线版本;差异评估与报价均按版本回溯。|新增/统一|任务描述/需求版本、版本|
|
||||
|request_type|需求类型|Enum|是|主记录|SS02/系统|W05/W24/SS03|客户需求/RFQ/询价/技术需求/价格维护/其他;采购来源可由source_type补充。|新增/统一|需求类型、RFQ类型|
|
||||
|request_description|需求描述|LongText|是|主记录|SS02/需求Owner|W05/W24|保留原始需求语义,不能只保留结构化字段。|新增/细化|任务描述|
|
||||
|input_snapshot_ref|输入快照|URL/Attachment/Structure|W05必填;W24条件|主记录|系统/AI|W05/W24|记录本次评估或报价运行时的输入快照,支持版本回溯。|新增|输入快照|
|
||||
|bom_id|BOM ID|Link|条件必填|主记录|SS02|W05/W24|有BOM场景必填;与bom_position一并确定物料位置。|新增|BOM范围|
|
||||
|bom_position|BOM位|Text|条件必填|主记录|SS02/FAE|W05/W24|按BOM位记录原物料、替代物料和报价明细;不同BOM位批准边界不得混用。|新增|BOM位、PN/BOM范围|
|
||||
|original_internal_pn|原内部料号|Link(P-08)|W05必填;其他条件|主记录|P-08/FAE|W05/W24|差异评估基准;引用P-08.internal_pn。|重命名/统一|原型号/原PN、原PN|
|
||||
|candidate_internal_pn|候选内部料号|Link(P-08)|W05条件;W24条件|主记录|P-08/FAE|W05/W24|候选替代物料;多个候选应进入P-11子记录而非逗号拼接。|重命名/统一|候选PN、Candidate_PN|
|
||||
|customer_pn|客户料号|Text|条件必填|主记录|L-02/D-10映射|W24/SS03/D-01|若客户使用客户料号,保留并与internal_pn分开。|新增/统一|customer_mpn、客户料号|
|
||||
|application_scenario|应用场景|LongText/Enum|W05必填;W24条件|主记录|FAE/客户|W05/W24|解释需求约束、验证条件与报价适用场景。|保留/细化|应用场景、适配场景|
|
||||
|target_qty|目标数量|Number/Structure|W24/SS03必填|主记录|需求Owner/采购|W24/SS03-1/SS03-2|正式报价、询价与比价的数量基准;阶梯放入数量阶梯子表。|重命名/统一|数量、target_qty、报价类型/数量|
|
||||
|target_delivery_date|目标交期|Date|W24/SS03必填|主记录|需求Owner/采购|W24/SS03-1/SS03-2|正式报价、询价与比价的日期基准。|重命名/统一|target_delivery_date、目标交期|
|
||||
|target_price_internal|内部目标价|Currency|否|主记录|SS02|W24/SS03|仅内部判断;默认不对外发送;币种必须同时记录。|保留/细化|target_price_internal、目标价|
|
||||
|target_price_currency_code|内部目标价币种|ISO 4217 Enum|target_price有值时必填|主记录|SS02|W24/SS03|与target_price_internal成对出现。|新增/统一|currency|
|
||||
|requested_price_types|所需价格类型|MultiEnum|SS03必填;W24条件|主记录|需求Owner/采购|W24/SS03-1/SS03-2|参考价/量产价/现货价/样品价;上游默认值可补充,删除或替换需留痕。|重命名/统一|price_types_default、price_types_requested、报价类型|
|
||||
|quote_due_at|期望报价返回时间|DateTime|SS03必填;W24条件|主记录|需求Owner/采购|W24/SS03-1/SS03-2|客户需求来源从P-01读取;内部或维护型询价由采购填写。|重命名/统一|quote_return_due_at、quote_due_at、截止时间|
|
||||
|technical_constraints|技术约束|Structure|W05/W24条件|主记录|FAE/AI|W05/W24|包含参数、单位、版本、硬约束/软约束,供差异评估和报价前提检查。|新增|参数边界、硬/软约束|
|
||||
|risk_level|风险等级|Enum|W05/W24条件|主记录|FAE/BDPM|W05/W24|低/中/高/关键;必须记录确认人和时间。|统一|FAE人工判断/风险等级、风险等级|
|
||||
|verification_requirement|验证/复测/确认要求|LongText/Structure|W05条件;W24条件|主记录|FAE|W05/W24|用于关闭UNKNOWN或CONDITIONAL,并可回链P-06验证记录。|重命名/统一|验证/复测/确认要求、验证要求|
|
||||
|latest_quote_id|最新报价ID|Link(P-02)|W24/SS03条件|主记录|系统|W24/SS03-2|聚合P-02最新有效报价,不直接复制价格。|新增|关联报价、最新报价状态|
|
||||
|latest_quote_status|最新报价状态|Lookup/Enum|W24/SS03条件|主记录|系统|W24/SS03-2|由P-02.quote_status/publish_status聚合。|重命名/统一|最新报价状态、报价状态|
|
||||
|request_status|需求状态|Enum|是|主记录|SS02|W05/W24/SS03|待处理/已响应/已成交/已放弃/关闭;不得与Activity运行状态混用。|重命名/统一|状态机、需求状态|
|
||||
|current_activity|当前Activity|Link/Enum|W05/W24条件|主记录|系统|W05/W24|如ACT-WORK-05、ACT-WORK-24;用于流程位置显示。|重命名/统一|当前Activity|
|
||||
|current_stage|当前阶段|Enum/Text|W05/W24条件|主记录|系统/Owner|W05/W24|评估、报价、审批、发布等阶段。|新增/统一|当前阶段|
|
||||
|next_action|下一步动作|LongText|W05/W24条件|主记录|FAE/BDPM/系统|W05/W24|明确责任人、动作和截止时间。|统一|下一步动作|
|
||||
|output_ref|标准输出链接|URL/Attachment|W05/W24条件|主记录|系统/FAE/BDPM|W05/W24|差异报告或报价文件的正式输出链接。|重命名/统一|标准输出类型/输出文件链接、输出链接|
|
||||
|
||||
#### P-02 报价与商务记录台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|quote_id|报价ID|Text/AutoNumber|是|报价主记录|系统|W24/SS03-2|报价主记录唯一标识。|保留/细化|报价ID|
|
||||
|request_id|关联需求编号|Link(P-01)|是|报价主记录|系统|W24/SS03-2|回链P-01.request_id。|重命名/统一|关联任务、关联需求号|
|
||||
|quote_version|报价版本|Text|是|报价主记录|系统/BDPM|W24|同一需求条件变化创建新版本;旧版本保留不可覆盖。|新增/统一|报价版本、版本|
|
||||
|quote_type|报价类型|Enum|是|报价主记录|BDPM|W24|正式报价/参考价/项目报价/其他;与price_type(明细价格类型)区分。|新增/统一|报价类型|
|
||||
|quote_status|报价状态|Enum|是|报价主记录|系统/BDPM|W24/SS03-2|草稿/待审核/已批准/已发布/已过期/已撤回。|重命名/统一|报价发布状态、报价状态|
|
||||
|quote_currency_code|报价币种|ISO 4217 Enum|是|报价主记录|系统/BDPM|W24|报价金额的统一币种;明细价格若币种不同必须换算并留痕。|重命名/统一|报价价格/币种、currency|
|
||||
|valid_from|报价生效时间|Date/DateTime|条件必填|报价主记录|BDPM|W24|正式发布报价必须有生效时间。|新增/统一|生效期|
|
||||
|quote_valid_until|报价有效期至|Date|条件必填|报价主记录|BDPM|W24|不得与P-03价格来源有效期混用。|重命名/统一|报价有效期、valid_until|
|
||||
|delivery_commitment|报价交期承诺|Date/Text/Structure|是|报价主记录|PW/BDPM|W24|记录交期数值、计算口径、来源和是否为承诺。|重命名/统一|报价交期、交期|
|
||||
|payment_terms|付款条件|Text/Structure|条件必填|报价主记录|BDPM/P-04|W24|来源于P-04或本次商务确认。|新增/统一|付款|
|
||||
|tax_terms|税费条件|Text/Structure|条件必填|报价主记录|BDPM|W24|税率、含税/未税口径必须明确。|新增|税费|
|
||||
|incoterms|贸易术语|Enum|条件必填|报价主记录|BDPM/P-04|W24|使用标准Incoterms枚举并记录版本/地点。|新增/统一|Incoterms、交货条款|
|
||||
|quote_assumptions|报价前提条件|LongText/Structure|是|报价主记录|BDPM|W24|含付款、税费、Incoterms、MOQ/SPQ、供货边界等。|重命名/细化|报价前提条件|
|
||||
|technical_boundary|技术边界|LongText/Structure|条件必填|报价主记录|FAE/BDPM|W24|记录替代、验证、规格限制与客户批准状态。|新增|技术边界|
|
||||
|cost_basis|成本基准|Currency/Structure|条件必填|报价主记录|BDPM/采购|W24|记录成本来源、币种、数量口径和时点。|新增|成本|
|
||||
|exchange_rate|报价汇率|Number|跨币种时必填|报价主记录|系统/BDPM|W24|统一比较币种时使用;必须关联exchange_rate_source与as_of。|新增|汇率|
|
||||
|exchange_rate_source|汇率来源|Text/URL|exchange_rate有值时必填|报价主记录|系统/BDPM|W24|记录汇率来源。|新增|汇率|
|
||||
|exchange_rate_as_of|汇率时点|Date|exchange_rate有值时必填|报价主记录|系统|W24|统一币种计算基准日。|新增|汇率|
|
||||
|gross_margin_status|毛利状态|Enum/Structure|条件必填|报价主记录|系统/BDPM|W24|未计算/通过/低于阈值/缺成本;口径由GOV规则维护。|新增|毛利状态|
|
||||
|rule_version|报价规则版本|Text/Link|是|报价主记录|GOV/系统|W24|记录计算、毛利、价格优先级使用的规则版本。|新增/统一|规则版本|
|
||||
|approval_status|商务审批状态|Enum|是|报价主记录|Gate/审批人|W24|未提交/待审批/已通过/已驳回;通过前不得发布。|重命名/统一|商务联审结论/审批记录|
|
||||
|approval_comment|商务审批意见|LongText|条件必填|报价主记录|审批人|W24|驳回或有条件通过时必须填写。|新增/统一|审批记录、审批意见|
|
||||
|publish_status|报价发布状态|Enum|是|报价主记录|系统/Gate|W24|草稿/待审批/已批准/已发布/过期/撤回。|重命名/统一|报价发布状态|
|
||||
|quote_document_ref|正式报价文件链接|URL/Attachment|已发布时必填|报价主记录|系统/BDPM|W24|只指向正式发布版本。|重命名/统一|报价文件、台账链接|
|
||||
|blocking_reason|阻断原因|LongText/Structure|条件必填|报价主记录|AI/BDPM|W24|价格过期、冲突、阶梯缺失、技术批准缺失等逐项记录。|新增|阻断原因|
|
||||
|quote_line_id|报价明细ID|Text/AutoNumber|是|报价明细QuoteLine|系统|W24|最小粒度为报价版本×PN×BOM位×数量阶梯。|新增|QuoteLine|
|
||||
|internal_pn|内部料号|Link(P-08)|是|报价明细QuoteLine|P-08/FAE|W24|报价物料统一引用P-08。|统一|PN|
|
||||
|bom_position|BOM位|Text|条件必填|报价明细QuoteLine|FAE|W24|同一报价中不同BOM位不能混淆。|统一|BOM位|
|
||||
|quantity_tier|数量阶梯|Structure|是|报价明细QuoteLine|AI/BDPM|W24|含min_qty、max_qty或阶梯描述。|拆分/结构化|数量阶梯|
|
||||
|price_type|价格类型|Enum|是|报价明细QuoteLine|BDPM|W24|参考价/量产价/现货价/样品价等。|新增/统一|price_type|
|
||||
|unit_price|报价单价|Currency|是|报价明细QuoteLine|系统/BDPM|W24|与currency_code成对存在。|重命名/统一|报价价格、单价|
|
||||
|currency_code|明细币种|ISO 4217 Enum|是|报价明细QuoteLine|系统/BDPM|W24|明细币种;若与主报价币种不同必须记录换算。|重命名/统一|币种|
|
||||
|lead_time_days|供货交期天数|Number|条件必填|报价明细QuoteLine|供应商/采购/BDPM|W24|统一为自然日或工作日一种口径,口径写入字段说明。|重命名/统一|LT、报价交期|
|
||||
|moq_qty|最小起订量|Number|条件必填|报价明细QuoteLine|P-08/S-05/采购|W24|数量比较与前提条件校验。|拆分/统一|MOQ|
|
||||
|spq_qty|标准包装量|Number|条件必填|报价明细QuoteLine|P-08/S-05/采购|W24|数量比较与包装约束校验。|拆分/统一|SPQ|
|
||||
|price_source_ref|价格来源引用|Link(P-03/S-05/S-04)|是|报价明细QuoteLine|系统|W24|指向Price List、采购基准或专项询价记录。|新增/统一|来源报价、价格来源|
|
||||
|substitution_approval_status|替代批准状态|Enum|条件必填|报价明细QuoteLine|P-11|W24|未检查/待验证/已批准/不批准;未满足时阻断发布。|新增|技术替代批准状态|
|
||||
|
||||
#### P-03 Price List台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|price_list_id|PriceList ID|Text/AutoNumber|是|价格记录|系统/BDPM|W24|价格来源唯一标识。|重命名/统一|PriceListID|
|
||||
|internal_pn|内部料号|Link(P-08)|是|价格记录|P-08/价格Owner|W24|统一引用P-08。|重命名/统一|PN/料号|
|
||||
|customer_id|适用客户ID|Link(L-02)|条件必填|价格记录|价格Owner|W24|客户专属价必填;公共渠道价可用scope_type标识。|新增|客户或渠道|
|
||||
|price_scope_type|价格适用范围|Enum|是|价格记录|BDPM|W24|客户专属/渠道/公共/项目。|新增|客户或渠道|
|
||||
|price_type|价格类型|Enum|是|价格记录|BDPM|W24|与报价所需价格类型对齐。|新增/统一|价格类型|
|
||||
|list_unit_price|Price List单价|Currency|是|价格记录|价格Owner|W24|与currency_code成对。|重命名/统一|客户专属价/单价|
|
||||
|currency_code|价格币种|ISO 4217 Enum|是|价格记录|价格Owner|W24|统一币种编码。|重命名/统一|币种|
|
||||
|release_version|Release版本|Text|是|价格记录|价格Owner|W24|报价时按as_of选取有效版本。|重命名/统一|Release版本、版本|
|
||||
|valid_from|生效时间|Date|是|价格记录|价格Owner|W24|与valid_to组成有效区间。|保留/细化|生效期|
|
||||
|valid_to|失效时间|Date|是|价格记录|价格Owner|W24|报价时点必须落在有效区间内。|重命名/统一|失效期、有效期|
|
||||
|quantity_tier|数量阶梯|Structure|条件必填|价格记录|价格Owner|W24|含适用数量和折扣条件。|拆分/结构化|数量阶梯/折扣条款|
|
||||
|discount_terms|折扣条款|LongText/Structure|条件必填|价格记录|价格Owner|W24|结构化记录折扣条件和例外。|细化|折扣条款|
|
||||
|source_quote_id|来源报价ID|Link(P-02/S-04)|条件必填|价格记录|系统/BDPM|W24|能追溯价格来源。|重命名/统一|来源报价|
|
||||
|price_status|价格状态|Enum|是|价格记录|价格Owner|W24|草稿/有效/暂停/过期/替代。|新增/统一|状态|
|
||||
|
||||
#### P-04 交易框架与承诺台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|framework_id|交易框架ID|Text/AutoNumber|是|框架主记录|商务Owner|W24|统一框架合同主键。|重命名/统一|框架ID|
|
||||
|customer_id|客户ID|Link(L-02)|是|框架主记录|商务Owner|W24|交易框架归属客户。|保留/统一|客户名称|
|
||||
|contract_version|合同版本|Text|是|框架主记录|商务Owner|W24|报价采用的合同/框架版本。|重命名/统一|合同版本|
|
||||
|contract_status|合同状态|Enum|是|框架主记录|商务Owner|W24|草稿/有效/到期/终止。|保留/细化|合同状态|
|
||||
|account_status|交易账户状态|Enum|是|框架主记录|商务Owner|W24|正常/冻结/待准入/关闭。|重命名/统一|交易账户状态|
|
||||
|payment_terms|账期与付款条款|Text/Structure|条件必填|框架主记录|商务Owner|W24|供报价继承;与D-04信用台账区分。|重命名/统一|账期条款、付款条件|
|
||||
|credit_limit|信用额度|Currency|条件必填|框架主记录|商务Owner|W24/11-A2条件|可供信用门禁使用。|保留/细化|信用额度|
|
||||
|cooperation_scope|合作范围|LongText/Structure|条件必填|框架主记录|商务Owner|W24|定义品牌、产品线、区域等边界。|保留/细化|合作范围|
|
||||
|incoterms|默认贸易术语|Enum|条件必填|框架主记录|商务Owner|W24|报价若覆盖本条件,回链本字段。|新增/统一|交易条款、Incoterms|
|
||||
|effective_from|框架生效时间|Date|是|框架主记录|商务Owner|W24|有效区间。|新增/统一|生效期|
|
||||
|effective_to|框架失效时间|Date|条件必填|框架主记录|商务Owner|W24|有效区间。|新增/统一|生效期|
|
||||
|commitment_id|承诺ID|Text/AutoNumber|条件必填|承诺子记录|系统/商务Owner|W24|每条价格、交期、数量或条件承诺一个ID。|重命名/统一|承诺ID|
|
||||
|commitment_party|承诺方|Enum/Link|是|承诺子记录|商务Owner|W24|我方/客户/供应商/其他。|统一|承诺方|
|
||||
|commitment_type|承诺类型|Enum|是|承诺子记录|商务Owner|W24|价格/交期/数量/质量/付款/其他。|新增/统一|承诺内容|
|
||||
|commitment_content|承诺内容|LongText/Structure|是|承诺子记录|商务Owner|W24|结构化值+说明,避免只写自由文本。|细化|承诺内容|
|
||||
|commitment_due_date|承诺期限|Date|条件必填|承诺子记录|商务Owner|W24|到期前可触发提醒。|重命名/统一|期限|
|
||||
|commitment_status|承诺状态|Enum|是|承诺子记录|商务Owner/系统|W24|未生效/生效/已兑现/逾期/取消。|重命名/统一|状态|
|
||||
|fulfillment_record_ref|兑现记录引用|Link/URL|条件必填|承诺子记录|系统/履约|W24|回链D-01/D-09等执行事实。|重命名/统一|兑现记录|
|
||||
|unresolved_risk_list|未决商务风险清单|LongText/Structure|条件必填|主记录|商务Owner|W24|报价前置风险;未关闭时可阻断审批。|重命名/细化|未决风险清单、商务风险|
|
||||
|
||||
#### P-06 样品交付与芯片验证记录台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|validation_id|验证ID|Text/AutoNumber|是|验证主记录|系统|W05/W24|验证记录唯一标识。|保留/统一|Validation_ID、验证编号|
|
||||
|request_id|关联需求编号|Link(P-01)|条件必填|验证主记录|系统/FAE|W05/W24|回链需求与验证目的。|新增/统一|需求号|
|
||||
|project_id|关联项目ID|Link(P-05)|条件必填|验证主记录|系统/FAE|W05/W24|项目场景验证必填。|新增/统一|项目|
|
||||
|customer_id|客户ID|Link(L-02)|条件必填|验证主记录|系统/FAE|W05/W24|客户验证必填。|新增/统一|客户ID|
|
||||
|validation_internal_pn|验证内部料号|Link(P-08)|是|验证主记录|FAE|W05/W24|统一引用P-08.internal_pn。|重命名/统一|Validation_PN、验证PN|
|
||||
|validation_type|验证类型|Enum|是|验证主记录|FAE|W05/W24|Pin-to-Pin/功能/可靠性/认证/复测。|新增/统一|验证类型|
|
||||
|test_condition|测试条件|LongText/Structure|是|验证主记录|FAE/验证Owner|W05|环境、输入、负载、样品状态等。|重命名/统一|测试条件|
|
||||
|test_parameters|测试参数|Structure|是|验证主记录|FAE/验证Owner|W05|参数名、值、单位、版本必须结构化。|重命名/统一|测试参数|
|
||||
|test_result|测试结果|LongText/Structure|是|验证主记录|验证Owner|W05|实测数据与结论,必要时附原始数据。|重命名/统一|测试结果|
|
||||
|validation_status|验证状态|Enum|是|验证主记录|FAE/验证Owner|W05/W24|未开始/进行中/通过/不通过/条件通过/待补证。|新增/统一|通过状态、验证状态|
|
||||
|issue_risk|问题与风险|LongText/Structure|条件必填|验证主记录|AI/FAE|W05/W24|记录未关闭问题、风险等级、责任人和关闭条件。|重命名/统一|问题与风险|
|
||||
|sample_version|样品版本|Text|条件必填|验证主记录|验证Owner|W05|样品追溯。|重命名/统一|样品版本|
|
||||
|sample_lot|样品批次|Text|条件必填|验证主记录|验证Owner|W05|与D-07或样品出库记录回链。|重命名/统一|样品批次、批次|
|
||||
|evidence_ref|验证证据引用|URL/Attachment/Structure|是|验证主记录|验证Owner/FAE|W05/W24|原始数据、照片、报告、资料页码等。|统一|证据引用|
|
||||
|next_validation_action|后续验证动作|LongText/Structure|条件必填|验证主记录|FAE|W05/W24|UNKNOWN/CONDITIONAL时必须有关闭动作。|新增|验证建议/缺口|
|
||||
|
||||
#### P-08 芯片主数据台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|internal_pn|内部料号|Text|是|物料主记录|SS02/FAE|W05/W24/SS03/11-A2|全链路料号统一基准;不可使用自由文本替代。|重命名/统一|标准型号PN、pn、mpn|
|
||||
|customer_pn|客户料号|Text|条件必填|物料主记录|SS02/D-10映射|W24/SS03/11-A2|客户料号映射优先维护在D-10,P-08可作为别名索引。|新增/统一|customer_mpn|
|
||||
|brand_name|品牌名称|Text/Link|是|物料主记录|FAE/数据Owner|W05/W24/SS03/11-A2|品牌统一引用;外部供应商品牌与原厂制造商分开。|重命名/统一|brand、manufacturer_brand、原厂或品牌|
|
||||
|manufacturer_name|制造商名称|Text/Link|条件必填|物料主记录|数据Owner|W05/W24|品牌与制造商不一致时分开记录。|新增/统一|原厂|
|
||||
|specification_description|规格描述|LongText|是|物料主记录|数据Owner/原厂资料|W05/W24|标准规格文本。|重命名/统一|Specification Description、产品描述|
|
||||
|normalized_parameter_set|标准化参数集|Structure|W05必填;W24条件|物料主记录|FAE/数据Owner|W05/W24|参数名、值、单位、精度、版本,用于硬/软约束比较。|新增|参数名称、单位、版本|
|
||||
|package|封装|Enum/Text|条件必填|物料主记录|数据Owner|W05/W24/SS03|影响硬约束和报价识别。|保留/细化|封装|
|
||||
|process_technology|工艺|Text/Enum|条件必填|物料主记录|数据Owner|W05|硬约束比较项。|重命名/统一|工艺|
|
||||
|lifecycle_status|生命周期状态|Enum|是|物料主记录|数据Owner|W05/W24/SS03|Active/NRND/EOL/停产/风险。|重命名/统一|生命周期状态、lifecycle_status、产品状态|
|
||||
|eol_pcn_status|EOL/PCN状态|Enum/Structure|条件必填|物料主记录|数据Owner/原厂|W05/W24|记录通知状态、影响和生效时间。|新增/统一|EoL/PCN状态|
|
||||
|datasheet_ref|Datasheet引用|URL/Link(P-10)|条件必填|物料主记录|P-10/原厂|W05/W24|官方资料链接。|重命名/统一|Datasheet链接|
|
||||
|datasheet_version|Datasheet版本|Text|datasheet_ref有值时必填|物料主记录|P-10/原厂|W05/W24|用于证据版本回链。|重命名/统一|Datasheet版本/版本|
|
||||
|application_scenarios|适配应用场景|MultiEnum/Structure|条件必填|物料主记录|FAE/数据Owner|W05/W24|判断物料实际适用性。|重命名/统一|应用场景、适配场景|
|
||||
|pin_to_pin_relation_ref|Pin-to-Pin关系引用|Link(P-11)|条件必填|物料主记录|FAE|W05/W24|引用P-11关系记录,避免在P-08复制批准结论。|新增/统一|Pin-to-Pin替代关系|
|
||||
|
||||
#### P-09 客户技术与机会档案
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|customer_id|客户ID|Link(L-02)|是|客户技术档案|SS02/FAE|W05/W24|区别于L-02的商务画像,技术档案以客户ID为主键。|保留/统一|客户 ID|
|
||||
|technical_stack|客户技术栈|Structure|条件必填|客户技术档案|FAE|W05/W24|记录平台、架构、关键技术约束。|重命名/统一|技术栈|
|
||||
|product_line_direction|产品线技术方向|Text/Structure|条件必填|客户技术档案|FAE/BDPM|W24|报价上下文与应用场景。|重命名/统一|产品线方向|
|
||||
|bom_brand_usage|BOM品牌使用情况|Structure|条件必填|客户技术档案|FAE|W05/W24|记录客户当前BOM品牌、料号及替代空间。|重命名/统一|BOM品牌|
|
||||
|validation_process|客户验证流程|LongText/Structure|条件必填|客户技术档案|FAE/客户|W05/W24|支持验证要求和报价前提判断。|重命名/统一|验证流程|
|
||||
|technical_decision_maker_ref|技术决策人引用|Link(L-08)|条件必填|客户技术档案|FAE|W05/W24|引用联系人,不复制姓名。|重命名/统一|技术决策人|
|
||||
|rd_capability_assessment|研发能力评估|Enum/LongText|条件必填|客户技术档案|FAE|W05/W24|用于技术支持与验证路径判断。|重命名/统一|研发能力|
|
||||
|technical_preference_tags|技术偏好标签|MultiEnum|条件必填|客户技术档案|FAE|W05/W24|用于推荐和方案匹配。|重命名/统一|技术偏好标签|
|
||||
|opportunity_id|机会ID|Text/AutoNumber|条件必填|机会子记录|系统/BDPM|W05/W24|需求或报价对应的机会对象。|新增/统一|机会记录|
|
||||
|
||||
#### P-10 原厂技术资料库
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|document_id|资料ID|Text/AutoNumber|是|资料主记录|系统|W05/W24|资料唯一标识。|新增/统一|资料记录|
|
||||
|document_type|资料类型|Enum|是|资料主记录|FAE|W05/W24|Datasheet/应用笔记/参考设计/白皮书/样片资料/其他。|新增/统一|资料类型|
|
||||
|internal_pn|关联内部料号|Link(P-08)|条件必填|资料主记录|P-08/FAE|W05/W24|料号相关资料必须回链P-08。|新增/统一|PN|
|
||||
|brand_name|品牌名称|Text/Link|是|资料主记录|数据Owner|W05/W24|统一引用P-08.brand_name。|重命名/统一|原厂|
|
||||
|document_title|资料标题|Text|是|资料主记录|资料Owner|W05/W24|资料可识别名称。|新增|资料|
|
||||
|document_version|资料版本|Text|是|资料主记录|资料Owner|W05/W24|评估必须回链版本。|重命名/统一|版本|
|
||||
|document_date|资料发布日期|Date|条件必填|资料主记录|资料Owner|W05/W24|用于时效判断。|新增|更新记录|
|
||||
|document_ref|资料文件链接|URL/Attachment|是|资料主记录|资料Owner|W05/W24|官方资料文件。|重命名/统一|Datasheet链接、资料|
|
||||
|citation_locator|证据定位|Text/Structure|W05必填;W24条件|资料主记录|AI/FAE|W05/W24|页码、章节、表格或段落,支持参数证据引用。|新增|证据引用、页码或证据段落|
|
||||
|source_contact_ref|原厂支持联系人|Link(L-08)/Text|条件必填|资料主记录|FAE|W05/W24|需要补证时的联系人。|重命名/统一|原厂支持联系人|
|
||||
|document_status|资料状态|Enum|是|资料主记录|资料Owner|W05/W24|有效/过期/待核实/作废。|新增/统一|状态|
|
||||
|
||||
#### P-11 替代选型与批准关系库
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|substitution_relation_id|替代关系ID|Text/AutoNumber|是|替代关系主记录|系统|W05/W24|原P-11-01与P-11-02统一后的关系主键。|新增/统一|P-11-01/P-11-02记录ID|
|
||||
|original_internal_pn|原内部料号|Link(P-08)|是|替代关系主记录|P-08/FAE|W05/W24|评估基准物料。|重命名/统一|Original_PN、原PN、目标替代料号|
|
||||
|candidate_internal_pn|候选内部料号|Link(P-08)|是|替代关系主记录|P-08/FAE|W05/W24|被评估物料。|重命名/统一|Candidate_PN、候选PN|
|
||||
|substitution_type|替代类型|Enum|是|替代关系主记录|FAE|W05/W24|Pin-to-Pin/功能替代/参数替代/其他。|重命名/统一|替代类型、关系类型|
|
||||
|recommendation_level|推荐等级|Enum|是|替代关系主记录|AI/FAE|W05/W24|推荐/条件推荐/备选/不推荐。|重命名/统一|替代等级、推荐|
|
||||
|recommendation_reason|推荐理由|LongText|是|替代关系主记录|AI/FAE|W05/W24|说明技术和商务依据。|新增/统一|推荐理由|
|
||||
|difference_summary|差异摘要|LongText|是|替代关系主记录|AI/FAE|W05|供P-01和后续流程消费。|新增/统一|评论、差异摘要|
|
||||
|difference_item_id|差异明细ID|Text/AutoNumber|是|差异明细子表|系统|W05|一条参数差异一条记录。|新增|逐参数差异明细|
|
||||
|parameter_name|标准参数名|Text/Link(GOV)|是|差异明细子表|AI/FAE|W05|参数名称标准化。|新增/统一|参数名称|
|
||||
|original_value|原物料参数值|Text/Number/Structure|是|差异明细子表|AI|W05|引用P-08或资料证据。|新增|参数A|
|
||||
|candidate_value|候选物料参数值|Text/Number/Structure|是|差异明细子表|AI|W05|引用P-08或资料证据。|新增|参数B|
|
||||
|parameter_unit|参数单位|Text/Link(GOV)|条件必填|差异明细子表|AI/FAE|W05|统一单位后比较。|新增/统一|单位|
|
||||
|constraint_type|约束类型|Enum|是|差异明细子表|GOV/AI|W05|硬约束/软约束。|新增/统一|硬/软约束标识|
|
||||
|difference_status|差异状态|Enum|是|差异明细子表|AI/FAE|W05|MATCH/MISMATCH/UNKNOWN/CONDITIONAL。|新增/统一|Status、差异状态|
|
||||
|impact_statement|影响说明|LongText|MISMATCH/CONDITIONAL必填|差异明细子表|AI/FAE|W05|说明对应用、验证、报价的影响。|新增|Impact、影响说明|
|
||||
|evidence_ref|差异证据引用|URL/Attachment/Structure|是|差异明细子表|AI/FAE|W05|具体资料、页码、测试记录。|统一|证据引用|
|
||||
|rule_version|差异规则版本|Text/Link(GOV)|是|差异明细子表|GOV/系统|W05|记录评估时采用的硬/软约束规则版本。|新增/统一|GOV规则版本|
|
||||
|risk_level|替代风险等级|Enum|是|替代关系主记录|FAE/RI|W05/W24|低/中/高/关键。|统一|风险等级|
|
||||
|verification_requirement|验证要求|LongText/Structure|CONDITIONAL/UNKNOWN必填|替代关系主记录|FAE|W05/W24|与P-06.validation_id或待验证任务回链。|统一|验证要求|
|
||||
|validation_id|关联验证ID|Link(P-06)|条件必填|替代关系主记录|系统/FAE|W05/W24|验证完成后回链事实。|新增/统一|验证编号、验证结果|
|
||||
|analysis_status|分析状态|Enum|是|替代关系主记录|系统/FAE|W05/W24|AI草稿/FAE确认/待客户验证/客户已批准/不批准/作废。|新增/统一|写入权限与时机、批准状态|
|
||||
|customer_approval_status|客户批准状态|Enum|是|替代关系主记录|审批流程|W05/W24|未提交/待批准/已批准/不批准/有条件批准;不由差异评估自动产生。|重命名/统一|批准状态、客户批准|
|
||||
|customer_approval_at|客户批准时间|DateTime|客户已批准时必填|替代关系主记录|审批流程|W05/W24|客户批准事实时间。|新增/统一|批准时间|
|
||||
|customer_approval_ref|客户批准凭证|URL/Attachment|客户已批准时必填|替代关系主记录|审批流程|W05/W24|签核邮件、系统审批单或客户文件。|新增/统一|批准凭证|
|
||||
|customer_feedback|客户反馈|LongText|条件必填|替代关系主记录|FAE/BDPM|W05/W24|记录客户对候选物料的反馈。|保留/细化|客户反馈|
|
||||
|request_id|关联需求编号|Link(P-01)|条件必填|替代关系主记录|系统|W05/W24|明确候选关系适用的需求。|新增/统一|关联需求|
|
||||
|project_id|关联项目ID|Link(P-05)|条件必填|替代关系主记录|系统|W05/W24|不同项目批准边界可不同。|新增/统一|关联项目|
|
||||
|bom_position|关联BOM位|Text|条件必填|替代关系主记录|FAE|W05/W24|批准边界定位。|新增/统一|BOM位|
|
||||
|analysis_report_ref|替代分析报告|URL/Attachment|条件必填|替代关系主记录|系统/FAE|W05|正式结构化结果或附件回链。|重命名/统一|替代分析报告、报告链接|
|
||||
|confidence_score|证据可信度|Percent/Enum|条件必填|替代关系主记录|AI/FAE|W05|AI输出可信度,需与人工确认区分。|新增/统一|证据可信度|
|
||||
|
||||
#### S-03 供应商主台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|supplier_id|供应商ID|Text/AutoNumber|是|供应商主记录|SS03|SS03-1/SS03-2|统一供应商主体键;临时供应商定点前必须转正式ID。|保留/统一|supplier_id、Supplier ID|
|
||||
|supplier_name|供应商名称|Text|是|供应商主记录|采购/SS03|SS03-1/SS03-2|临时供应商可先占位,定点前补齐正式名称。|保留/统一|supplier_name、供应商名称|
|
||||
|supplier_type|供应商分类|Enum|是|供应商主记录|SS03|SS03-1/SS03-2|原厂/授权代理/贸易/分销/其他。|保留/细化|supplier_type、供应商分类|
|
||||
|supplier_status|供应商状态|Enum|是|供应商主记录|SS03|SS03-1/SS03-2|临时/正式/停用;临时状态不可定点。|新增/统一|supplier_status|
|
||||
|temporary_supplier_flag|临时供应商标记|Boolean|是|供应商主记录|采购|SS03-1/SS03-2|临时新增立即可询价,但必须受定点门禁约束。|新增|临时供应商|
|
||||
|sales_contact_ref|业务联系人引用|Link/Text|SS03-1必填|供应商主记录|采购/供应商Owner|SS03-1|至少一名联系人,用于邮件或即时通讯外发。|重命名/统一|sales_contact、联系人|
|
||||
|contact_channel|首选联系渠道|Enum|条件必填|供应商主记录|供应商Owner|SS03-1|邮件/即时通讯/电话;需与外发记录一致。|新增/统一|send_channel、确认通道|
|
||||
|rfq_product_scope|当前询价品牌/料号范围|Structure|临时供应商定点前必填|供应商主记录|采购|SS03-1/SS03-2|允许不预建完整品牌覆盖,但必须覆盖当前询价。|新增/统一|rfq_product_scope|
|
||||
|authorized_brand_scope|授权品牌范围|Structure|代理/原厂条件必填|供应商主记录|SS03|SS03-1/SS03-2|判断品牌授权和可供范围。|新增/统一|品牌授权|
|
||||
|agency_certificate_ref|代理证凭证|URL/Attachment|supplier_type=授权代理时必填|供应商主记录|供应商Owner/采购|SS03-2|无有效代理证不得提交定点审批。|保留/细化|agency_certificate_ref、代理证|
|
||||
|agency_certificate_valid_to|代理证有效期至|Date|代理证有值时必填|供应商主记录|供应商Owner|SS03-2|定点前检查有效期。|新增|代理证|
|
||||
|qualification_status|准入资质状态|Enum|定点前必填|供应商主记录|SS03|SS03-2|待准入/合格/限制/不合格。|新增/统一|资质/准入|
|
||||
|compliance_status|合规状态|Enum|定点前必填|供应商主记录|SS03|SS03-2|通过/待核查/不通过。|新增/统一|合规|
|
||||
|commercial_terms_ref|长期商务规则引用|Link/Structure|条件必填|供应商主记录|SS03|SS03-2|长期条款不在本次询价Activity维护,但应有引用。|新增/统一|商务规则、长期条款|
|
||||
|supplier_health_score|供应商健康度|Percent/Enum|条件必填|供应商主记录|SS03/SS04|SS03-2|由履约数据计算,不作为单次询价事实覆盖。|新增/统一|供应商绩效/健康度|
|
||||
|
||||
#### S-04 询价、报价、比价与采购决策记录台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|rfq_batch_id|询价批次号|Text/AutoNumber|是|RFQ层|系统/采购|SS03-1/SS03-2|同一批次可含多料号、多供应商;品牌或料号变化必须新开批次。|保留/统一|rfq_batch_id、询价批次号|
|
||||
|rfq_line_id|询价行ID|Text/AutoNumber|是|RFQ层|系统|SS03-1/SS03-2|最小粒度为询价批次×供应商×内部料号。|保留/统一|rfq_line_id、询价行ID|
|
||||
|source_type|需求来源类型|Enum|是|RFQ层|采购/上游台账|SS03-1/SS03-2|P-01客户需求/D-15采购计划/价格维护/异常补货。|统一|source_type|
|
||||
|source_id|来源对象ID|Text/Link|条件必填|RFQ层|系统/上游台账|SS03-1/SS03-2|与source_type成对;P-01写request_id,D-15写purchase_plan_id。|统一|source_id|
|
||||
|customer_id|客户ID|Link(L-02)|条件必填|RFQ层|P-01/D-15|SS03-1/SS03-2|用于需求归属与通知。|新增/统一|customer_id|
|
||||
|project_id|项目ID|Link(P-05)|条件必填|RFQ层|P-01/D-15|SS03-1/SS03-2|用于需求归属与通知。|新增/统一|project_id|
|
||||
|brand_name|询价品牌|Text/Link(P-08)|是|RFQ层|P-08/采购|SS03-1/SS03-2|与internal_pn精确匹配;品牌变化新开批次。|重命名/统一|brand、manufacturer_brand|
|
||||
|internal_pn|询价内部料号|Link(P-08)|是|RFQ层|P-08/采购|SS03-1/SS03-2|统一引用P-08.internal_pn。|重命名/统一|pn、料号|
|
||||
|target_qty|目标数量|Number|是|RFQ层|P-01/D-15/采购|SS03-1/SS03-2|比价基准;来自P-01.target_qty或D-15.trigger_qty。|统一|target_qty、purchase_trigger_qty|
|
||||
|target_delivery_date|目标交期|Date|是|RFQ层|P-01/D-15/采购|SS03-1/SS03-2|比价基准;来自P-01或D-15.requirement_date。|统一|target_delivery_date、requirement_date|
|
||||
|requested_price_types|所需价格类型|MultiEnum|是|RFQ层|P-01/采购|SS03-1|参考价/量产价/现货价/样品价。|重命名/统一|price_types_requested|
|
||||
|quote_due_at|报价回复截止时间|DateTime|是|RFQ层|P-01/采购|SS03-1|供应商回复时效基准。|重命名/统一|quote_due_at、期望报价返回时间|
|
||||
|target_price_internal|内部目标价|Currency|否|RFQ层|P-01|SS03-1/SS03-2|仅内部判断;如实际披露需留痕。|统一|target_price_internal|
|
||||
|supplier_id|供应商ID|Link(S-03)|是|RFQ层|S-03/采购|SS03-1/SS03-2|临时供应商定点前转正式ID。|统一|supplier_id|
|
||||
|send_channel|询价发送渠道|Enum|是|RFQ层|采购/系统|SS03-1|邮件/即时通讯/电话。|统一|send_channel|
|
||||
|sent_at|询价发送时间|DateTime|是|RFQ层|系统|SS03-1|系统生成;未发送不得标记已发送。|保留/细化|sent_at|
|
||||
|outbound_content_ref|外发内容引用|URL/Attachment|是|RFQ层|系统/采购|SS03-1|记录对外披露内容和附件。|新增/统一|对外披露内容、附件|
|
||||
|sent_evidence_ref|外发凭证|URL/Attachment|是|RFQ层|系统|SS03-1|原始邮件、聊天发送记录等。|新增/统一|发送凭证、evidence|
|
||||
|rfq_status|询价状态|Enum|是|RFQ层|系统/采购|SS03-1|草稿/已发送/部分回复/全部回复/已到期未回复/催询中/关闭。|保留/细化|rfq_status|
|
||||
|reminder_count|催询次数|Integer|是|RFQ层|系统|SS03-1|自动提醒与人工催询均计数。|新增|跟进回复时效|
|
||||
|last_reminded_at|最后催询时间|DateTime|条件必填|RFQ层|系统/采购|SS03-1|有催询时必填。|新增|催询|
|
||||
|overdue_flag|询价超期标记|Boolean|是|RFQ层|系统|SS03-1|quote_due_at已过且未完成回复或未发出时触发。|新增|已到期未回复、发起超期|
|
||||
|supplier_quote_id|供应商报价ID|Text/AutoNumber|是|Quote层|系统|SS03-2|同一询价行可有报价修订历史。|新增/统一|报价记录ID|
|
||||
|quote_status|供应商报价状态|Enum|是|Quote层|采购|SS03-2|已报价/拒绝报价/未回复/待确认。|保留/细化|quote_status|
|
||||
|price_type|报价价格类型|Enum|条件必填|Quote层|采购|SS03-2|必须与requested_price_types匹配。|保留/统一|price_type|
|
||||
|unit_price|报价单价|Currency|已报价时必填|Quote层|采购|SS03-2|与currency_code成对。|重命名/统一|unit_price、价格|
|
||||
|currency_code|报价币种|ISO 4217 Enum|已报价时必填|Quote层|采购|SS03-2|统一币种比较前保留原币种。|重命名/统一|currency|
|
||||
|actual_qty|报价适用数量|Number|已报价时条件必填|Quote层|采购|SS03-2|判断与target_qty可比性。|重命名/统一|actual_qty|
|
||||
|lead_time_days|报价交期天数|Number|已报价时条件必填|Quote层|采购|SS03-2|统一工作日/自然日口径;原始文字可放lead_time_note。|重命名/统一|lead_time、LT|
|
||||
|lead_time_note|交期说明|LongText|条件必填|Quote层|采购|SS03-2|无法标准化时保留供应商原文。|新增|lead_time|
|
||||
|moq_qty|MOQ|Number|条件必填|Quote层|采购|SS03-2|最小起订量。|拆分/统一|MOQ-SPQ、MOQ|
|
||||
|spq_qty|SPQ|Number|条件必填|Quote层|采购|SS03-2|标准包装量。|拆分/统一|MOQ-SPQ、SPQ|
|
||||
|quote_valid_until|报价有效期至|Date|条件必填|Quote层|采购|SS03-2|报价是否仍可用。|重命名/统一|valid_until、报价有效期|
|
||||
|special_terms|本次特殊商务条款|LongText/Structure|否|Quote层|采购|SS03-2|一次性条款;长期供应商条款回链S-03。|重命名/细化|special_terms、特殊条款|
|
||||
|response_at|实际回复时间|DateTime|已回复时必填|Quote层|系统/采购|SS03-2|回复时间事实。|保留/细化|response_at|
|
||||
|no_quote_reason|未报价原因|LongText|拒绝/未回复时必填|Quote层|采购|SS03-2|拒绝报价或未回复必须填写。|保留/细化|no_quote_reason|
|
||||
|quote_evidence_ref|原始报价凭证|URL/Attachment|报价/拒绝均必填|Quote层|系统/采购|SS03-2|邮件、聊天截图、供应商报价单。|重命名/统一|evidence、原始沟通凭证|
|
||||
|quote_revision_no|同轮报价修订号|Integer|是|Quote层|系统/采购|SS03-2|同轮修改保留修订历史,当前报价可聚合展示。|新增|报价与版本|
|
||||
|comparison_group_id|比价组ID|Text/AutoNumber|是|Decision层|系统|SS03-2|由rfq_batch_id+internal_pn+target_qty+target_delivery_date组成。|重命名/统一|comparison_group|
|
||||
|comparability_status|可比性状态|Enum|是|Decision层|AI/采购|SS03-2|可直接比较/条件可比/不可比;不可比必须说明原因。|新增|正式比价组、不可直接比较|
|
||||
|comparison_basis_snapshot_ref|比价基准快照|Structure/URL|是|Decision层|系统|SS03-2|固化目标数量、目标交期、价格类型、汇率和基准版本。|新增|比价组|
|
||||
|system_score|系统评分|Number|是|Decision层|系统|SS03-2|按询价类型权重计算,0-100。|重命名/统一|score|
|
||||
|system_recommendation|系统推荐|Enum|是|Decision层|系统|SS03-2|推荐/备选/不推荐。|重命名/统一|recommendation|
|
||||
|procurement_confirm|采购确认意见|Enum|意见确认时必填|Decision层|采购|SS03-2|认可/调整/否决。|保留/细化|procurement_confirm|
|
||||
|confirm_reason|确认或调整理由|LongText|与系统推荐不一致时必填|Decision层|采购|SS03-2|采购调整或否决系统建议时必填。|重命名/统一|confirm_reason|
|
||||
|baseline_id|采购基准ID|Link(S-05)|有基准时必填|Decision层|系统|SS03-2|与品牌、料号、价格类型、数量和有效期匹配。|新增/统一|baseline_id|
|
||||
|baseline_match_status|基准匹配状态|Enum|是|Decision层|系统|SS03-2|已匹配/无基准/条件匹配/不匹配。|新增|无基准可比、基准比较|
|
||||
|baseline_unit_price|比较基准单价|Currency|有基准时必填|Decision层|系统|SS03-2|复制当次采用的S-05基准快照,避免历史重算漂移。|新增/统一|benchmark_unit_price|
|
||||
|comparison_currency_code|比价统一币种|ISO 4217 Enum|跨币种时必填|Decision层|系统|SS03-2|统一币种并回链汇率。|新增|汇率/统一币种|
|
||||
|conversion_rate|币种换算汇率|Number|跨币种时必填|Decision层|系统|SS03-2|记录换算基准和来源。|新增|汇率|
|
||||
|price_variance_pct|相对基准涨幅|Percent|有基准时派生|Decision层|系统|SS03-2|(报价-基准)/基准;涨幅>10%标红。|重命名/统一|price_variance_pct、涨幅|
|
||||
|variance_reason|涨价原因|LongText|涨幅>10%时必填|Decision层|采购|SS03-2|说明供应商涨价、市场、交期或其他原因。|保留/细化|variance_reason、涨价原因|
|
||||
|selected_supplier_id|中选供应商ID|Link(S-03)|定点通过时必填|Decision层|系统/采购|SS03-2|审批通过的定点对象。|新增/统一|推荐供应商、中选供应商|
|
||||
|selected_quote_id|中选报价ID|Link(S-04 Quote)|定点通过时必填|Decision层|系统|SS03-2|关联被选中的供应商报价。|新增/统一|定点结论|
|
||||
|approval_status|定点审批状态|Enum|是|Decision层|审批流|SS03-2|待审批/已通过/已驳回。|重命名/统一|approval_status|
|
||||
|approval_comment|定点审批意见|LongText|驳回/条件通过时必填|Decision层|审批人|SS03-2|审批意见。|重命名/统一|approval_comment|
|
||||
|decision_status|定点结论状态|Enum|是|Decision层|系统/采购|SS03-2|待定点/已定点/无法定点/建议关闭/已变更。|重命名/统一|decision_status、定点结论|
|
||||
|unselected_reason|未选/无法定点原因|LongText|无法定点或未选时必填|Decision层|采购|SS03-2|完整说明比较、准入或供应条件原因。|新增/统一|未选原因、无法定点原因|
|
||||
|notification_status|结果通知状态|Enum|是|Decision层|系统|SS03-2|待通知/已通知/通知失败。|重命名/统一|notification_status|
|
||||
|notification_recipient_ref|结果通知对象|Link/Text|定点结论产生时必填|Decision层|系统|SS03-2|按source_type通知P-01负责人、D-15负责人或其他发起人。|新增/统一|通知需求发起人|
|
||||
|score_rule_version|评分规则版本|Text/Link(GOV)|是|Decision层|GOV/系统|SS03-2|记录不同询价类型的权重版本。|新增/统一|规则版本|
|
||||
|
||||
#### S-05 采购基准与供应能力台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|baseline_id|采购基准ID|Text/AutoNumber|是|基准主记录|系统|SS03-1/SS03-2|基准唯一标识。|保留/统一|baseline_id|
|
||||
|brand_name|基准品牌|Text/Link(P-08)|是|基准主记录|采购/SS03|SS03-1/SS03-2|与internal_pn精确匹配。|重命名/统一|brand|
|
||||
|internal_pn|基准内部料号|Link(P-08)|是|基准主记录|P-08/采购|SS03-1/SS03-2|统一引用P-08。|重命名/统一|pn|
|
||||
|supplier_id|基准供应商ID|Link(S-03)|是|基准主记录|S-03/采购|SS03-1/SS03-2|当前采购基准的供应商。|统一|supplier_id|
|
||||
|price_type|基准价格类型|Enum|是|基准主记录|采购|SS03-2|必须与本次报价价格类型一致后比较。|统一|price_type|
|
||||
|baseline_unit_price|采购基准单价|Currency|是|基准主记录|采购|SS03-2|历史/当前有效采购基准,不被单次询价直接覆盖。|重命名/统一|benchmark_unit_price|
|
||||
|currency_code|基准币种|ISO 4217 Enum|是|基准主记录|采购|SS03-2|与单价成对。|重命名/统一|currency|
|
||||
|quantity_tier|适用数量阶梯|Structure|是|基准主记录|采购|SS03-2|匹配目标数量。|拆分/结构化|applicable_qty、数量阶梯|
|
||||
|valid_from|基准生效时间|Date|是|基准主记录|采购|SS03-2|有效期判断。|重命名/统一|effective_from|
|
||||
|valid_to|基准失效时间|Date|是|基准主记录|采购|SS03-2|有效期判断。|重命名/统一|effective_to|
|
||||
|lead_time_days|基准交期天数|Number|条件必填|基准主记录|采购|SS03-1/SS03-2|供应能力比较。|重命名/统一|lead_time、LT|
|
||||
|moq_qty|基准MOQ|Number|条件必填|基准主记录|采购|SS03-2|供应能力比较。|拆分/统一|MOQ|
|
||||
|spq_qty|基准SPQ|Number|条件必填|基准主记录|采购|SS03-2|供应能力比较。|拆分/统一|SPQ|
|
||||
|availability_status|可供状态|Enum|是|基准主记录|采购|SS03-1/SS03-2|可供/部分可供/不可供/待确认。|重命名/统一|availability_status、可供状态|
|
||||
|supply_risk_level|供应风险等级|Enum|条件必填|基准主记录|采购|SS03-2|低/中/高/关键。|新增/统一|供应风险|
|
||||
|baseline_status|基准状态|Enum|是|基准主记录|采购|SS03-2|Active/暂停/过期/替代。|保留/细化|baseline_status|
|
||||
|source_quote_ref|基准来源报价引用|Link(S-04/P-02)|条件必填|基准主记录|系统/采购|SS03-2|记录基准形成依据。|重命名/统一|来源报价|
|
||||
|locked_at|基准锁定时间|DateTime|条件必填|基准主记录|采购|SS03-2|锁定后单次询价不得直接覆盖。|新增|报价锁定基准|
|
||||
|
||||
#### D-15 采购计划与执行需求台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|purchase_plan_id|采购计划ID|Text/AutoNumber|是|采购计划主记录|SS04|SS03-1/SS03-2|内部采购需求唯一引用;写入S-04.source_id。|保留/统一|purchase_plan_id|
|
||||
|customer_id|客户ID|Link(L-02)|条件必填|采购计划主记录|SS04|SS03-1/SS03-2|客户需求型采购必填。|统一|customer_id|
|
||||
|project_id|项目ID|Link(P-05)|条件必填|采购计划主记录|SS04|SS03-1/SS03-2|项目型采购必填。|统一|project_id|
|
||||
|internal_pn|内部料号|Link(P-08)|是|采购计划主记录|P-08/SS04|SS03-1/SS03-2|统一引用P-08。|重命名/统一|pn|
|
||||
|forecast_version|Forecast版本|Text/Link(D-02)|条件必填|采购计划主记录|D-02/SS04|SS03-1/SS03-2|Forecast来源时必填。|统一|Forecast版本、版本号|
|
||||
|customer_pickup_demand|客户提货需求|Number/Date/Structure|条件必填|采购计划主记录|SS04|SS03-1/SS03-2|客户提货需求的数量和时间。|重命名/统一|提货需求|
|
||||
|requirement_date|内部需求日期|Date|是|采购计划主记录|SS04|SS03-1/SS03-2|作为询价目标交期或内部需求时点。|重命名/统一|requirement_date、需求日期|
|
||||
|inventory_ref|库存引用|Link(D-03)|条件必填|采购计划主记录|系统|SS03-1/SS03-2|用于采购建议量判断。|新增/统一|库存引用|
|
||||
|in_transit_ref|在途引用|Link(D-03/D-16)|条件必填|采购计划主记录|系统|SS03-1/SS03-2|用于采购建议量判断。|新增/统一|在途引用|
|
||||
|recommended_purchase_qty|建议采购量|Number|是|采购计划主记录|系统/采购|SS03-1/SS03-2|形成询价目标数量的依据。|新增/统一|建议采购量|
|
||||
|trigger_qty|采购触发数量|Number|是|采购计划主记录|SS04|SS03-1/SS03-2|进入询价的正式数量输入。|重命名/统一|purchase_trigger_qty、触发数量|
|
||||
|purchase_type|采购类型|Enum|是|采购计划主记录|SS04|SS03-1/SS03-2|量产/样品/现货/异常补货。|保留/细化|purchase_type|
|
||||
|urgency_flag|紧急程度|Enum/Boolean|是|采购计划主记录|SS04|SS03-1/SS03-2|普通/紧急/特急;影响评分权重。|保留/细化|urgency_flag|
|
||||
|plan_status|采购计划状态|Enum|是|采购计划主记录|SS04|SS03-1/SS03-2|有效/待执行/询价中/已满足/关闭。|保留/细化|plan_status|
|
||||
|triggered_at|触发询价时间|DateTime|条件必填|采购计划主记录|系统|SS03-1|记录进入询价流程的时间。|新增/统一|触发时间|
|
||||
|po_ref|关联采购订单引用|Link(D-16)|条件必填|采购计划主记录|系统|SS03-2|定点/执行后回链D-16。|重命名/统一|关联后续PO|
|
||||
|
||||
#### L-02 客户主台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|customer_id|客户ID|Text/AutoNumber|是|客户主记录|SS01|W24/SS03/11-A2|所有客户侧台账统一锚点。|保留/统一|唯一ID、customer_id|
|
||||
|customer_name|客户名称|Text|是|客户主记录|SS01|W05/W24/11-A2|展示名称;法律主体与展示名称如不同需分开。|新增/统一|客户名称、客户|
|
||||
|legal_entity_name|客户法律主体|Text|条件必填|客户主记录|SS01|11-A2条件|单证、合同或制裁筛查需要时必填。|新增|主体信息|
|
||||
|customer_status|客户状态|Enum|是|客户主记录|SS01|W24/11-A2|有效/暂停/终止。|新增/统一|状态机|
|
||||
|default_contact_ref|默认联系人引用|Link(L-08)|条件必填|客户主记录|SS01|11-A2|与D-10提货联系人区分;D-10可覆盖为履约联系人。|新增/统一|联系人|
|
||||
|
||||
#### D-01 交易与执行台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|order_id|订单号|Text/AutoNumber|是|订单主记录|SS04|11-A2/11-A3|订单执行主键。|保留/统一|order_id、订单号|
|
||||
|request_id|需求编号|Link(P-01)|条件必填|订单主记录|系统|11-A2|回链P-01需求。|统一|需求编号|
|
||||
|customer_id|客户ID|Link(L-02)|是|订单主记录|系统|11-A2/11-A3|客户名称通过L-02展示。|统一|customer_id|
|
||||
|project_id|项目ID|Link(P-05)|条件必填|订单主记录|系统|11-A2|项目追溯。|统一|项目|
|
||||
|internal_pn|内部料号|Link(P-08)|是|订单主记录|P-08/SS04|11-A2|内部料号统一引用P-08。|重命名/统一|mpn、料号|
|
||||
|customer_pn|客户料号|Text/Link(D-10)|条件必填|订单主记录|D-10/SS04|11-A2|通过D-10映射获得。|重命名/统一|customer_mpn|
|
||||
|order_qty|订单数量|Number|是|订单主记录|订单/系统|11-A2|交接实发数量核对基准。|保留/统一|order_qty|
|
||||
|allocation_ref|分配批次引用|Link(D-03)|条件必填|订单主记录|D-03/SS04|11-A2|指向D-03批次分配记录。|新增/统一|inventory_lot|
|
||||
|promised_delivery_date|承诺交期|Date|条件必填|订单主记录|P-04/SS04|11-A2|订单承诺交期。|重命名/统一|承诺交期|
|
||||
|trade_term|贸易术语|Enum|条件必填|订单主记录|D-10/P-04|11-A2|EXW等;风险转移逻辑依赖。|新增/统一|BT类型、贸易术语|
|
||||
|milestones|履约里程碑|Structure|是|订单主记录|系统/SS04|11-A2/11-A3|至少含交接;每个节点有状态、时间、来源、证据。|细化|milestones、里程碑|
|
||||
|pickup_status|提货状态|Enum|是|提货交接事实层|PW/系统|11-A2|待提货/已预约/已提货/待重新预约/取消。|保留/细化|pickup_status|
|
||||
|ship_date|发货日期|Date|现场交接时必填|提货交接事实层|PW|11-A2|现场事实;由PW唯一录入。|保留/细化|ship_date|
|
||||
|shipped_qty|实发数量|Number|现场交接时必填|提货交接事实层|PW|11-A2|现场事实;与order_qty及D-03同步校验。|保留/细化|shipped_qty|
|
||||
|handover_no|交接单号|Text|交接完成时必填|提货交接事实层|系统|11-A2|格式HD-YYYYMMDD-NNN。|保留/细化|handover_no|
|
||||
|receiver_name|签收人姓名|Text|交接完成时必填|提货交接事实层|PW|11-A2|双方签署事实。|重命名/统一|receiver、签收人姓名|
|
||||
|sign_time|交接签署时间|DateTime|交接完成时必填|提货交接事实层|PW|11-A2|EXW风险转移时点的事实基础。|保留/细化|sign_time、签署时间|
|
||||
|risk_transfer_time|风险转移时点|DateTime|签署后派生|提货交接事实层|系统/AI/PW确认|11-A2/11-A3|EXW场景默认等于sign_time;作为开票和后续追踪锚点。|保留/细化|risk_transfer_time|
|
||||
|handover_diff|交接差异|Enum/Structure|条件必填|提货交接事实层|PW/RI|11-A2|无差异/数量差异/型号差异/批次差异/包装差异/其他;关联差异单。|保留/细化|handover_diff|
|
||||
|handover_file_ref|交接单影像/文件|URL/Attachment|交接完成时必填|提货交接事实层|PW|11-A2|必须区分草稿、已签署、已归档。|重命名/统一|handover_file|
|
||||
|handover_doc_status|交接单文件状态|Enum|有handover_file_ref时必填|提货交接事实层|PW/系统|11-A2|草稿/已签署/已归档/作废;下游只消费已签署或已归档。|新增/统一|文档状态|
|
||||
|handover_notes|交接注意事项|LongText|条件必填|提货交接事实层|D-10/PW|11-A2|模板或现场特殊注意事项。|新增|交接注意事项|
|
||||
|logi_pickup|提货节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|含estimated_time、actual_time、source、collection_method、confirmation_status、anomaly_flag、evidence_ref。|保留/结构化|logi_pickup|
|
||||
|logi_clearance|报关节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|同上;异常如报关受阻、查验。|保留/结构化|logi_clearance|
|
||||
|logi_release|放行节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|同上。|保留/结构化|logi_release|
|
||||
|logi_departure|离港节点事实|Structure|11-A3必填|物流节点事实层|AI/PW|11-A3|同上;异常如离港延误。|保留/结构化|logi_departure|
|
||||
|track_note|物流跟踪/决策备注|LongText|条件必填|物流节点事实层|PW/RI|11-A3|货代不配合时记录降级为到货确认或销售协调等决策。|重命名/统一|track_note、决策记录|
|
||||
|track_visible|物流信息可查状态|Boolean|四节点完成后必填|物流节点事实层|系统|11-A3|四节点实际事实齐备后为True;不是人工主观勾选。|保留/细化|track_visible|
|
||||
|invoice_status|开票状态|Enum|是|订单状态层|系统/财务|11-A2|待开票/已触发/已开票/异常;risk_transfer_time为触发锚点。|新增/统一|开票状态|
|
||||
|acceptance_status|验收状态|Enum|是|订单状态层|履约Owner|11-A2|待验收/已确认/争议/不适用。|新增/统一|验收状态|
|
||||
|order_status|订单状态|Enum|是|订单状态层|系统/SS04|11-A2/11-A3|已放行/已交接/物流跟踪中/已完成/异常/关闭。|重命名/统一|订单状态机|
|
||||
|closed_flag|关闭标记|Boolean|是|订单状态层|系统/SS04|11-A2/11-A3|所有必需节点或异常处置完结后关闭。|新增/统一|关闭状态|
|
||||
|close_reason|关闭原因|LongText|关闭时必填|订单状态层|SS04|11-A2/11-A3|正常完成/取消/转异常流程等。|新增|关闭状态|
|
||||
|pickup_appointment_time|预约提货时间|DateTime|分支2场景必填|提货交接事实层|D-10/PW|11-A2|用于预约提醒、未按预约提货判断及重新预约。|新增/统一|提货预约时间|
|
||||
|physical_check_status|实物核对状态|Enum|交接前必填|提货交接事实层|PW|11-A2|未核对/已核对/不一致;核对数量、型号、批次标签和包装。|新增|实物一致性核对|
|
||||
|diff_handling_result|差异处理结果|Enum/Structure|有差异时必填|提货交接事实层|RI/PW|11-A2|补齐后交接/按实发量交接并标注/暂停并转后续Activity。|新增/统一|差异处理结果|
|
||||
|ri_gate_decision|RI门禁判断|Enum/LongText|差异或超时升级时必填|提货交接事实层|RI|11-A2/11-A3|放行/补齐后放行/暂停/转异常/降级追踪/联系客户核实。|新增/统一|RI门禁判断、决策记录|
|
||||
|ri_escalated_at|RI升级时间|DateTime|发生升级时必填|提货交接事实层|系统|11-A2/11-A3|差异、预约超时或节点反馈超时的升级事实。|新增|升级时限|
|
||||
|node_type|物流节点类型|Enum|是|物流节点子表(适用于logi_*)|系统|11-A3|提货/报关/放行/离港;对应logi_*字段。|新增/统一|四个关键节点|
|
||||
|estimated_time|节点预计时间|DateTime|是|物流节点子表(适用于logi_*)|系统/AI|11-A3|到达预计时间后触发确认请求。|新增/统一|节点预计时间|
|
||||
|actual_time|节点实际时间|DateTime|收到事实时必填|物流节点子表(适用于logi_*)|AI/PW|11-A3|收到反馈或人工电话确认的实际时间。|新增/统一|时间|
|
||||
|source|节点事实来源|Text/Link|是|物流节点子表(适用于logi_*)|AI/PW|11-A3|回复单位、系统或具体联系人;不得只写“外部”。|重命名/统一|来源、Source|
|
||||
|collection_method|节点采集方式|Enum|是|物流节点子表(适用于logi_*)|AI/PW|11-A3|邮件/微信/即时通讯/电话/人工录入。|重命名/统一|采集方式、Method|
|
||||
|confirmation_status|节点确认状态|Enum|是|物流节点子表(适用于logi_*)|系统/PW|11-A3|待确认/已确认/超时/异常/已升级。|新增/统一|确认状态|
|
||||
|anomaly_flag|节点异常标记|Boolean|是|物流节点子表(适用于logi_*)|AI/PW|11-A3|报关受阻、查验、离港延误等为True。|新增/统一|Anomaly Flag、异常节点标记|
|
||||
|anomaly_type|节点异常类型|Enum/Text|anomaly_flag=True时必填|物流节点子表(适用于logi_*)|AI/PW|11-A3|报关受阻/查验/离港延误/其他。|新增/统一|异常节点|
|
||||
|node_evidence_ref|节点事实证据|URL/Attachment|已确认或异常时必填|物流节点子表(适用于logi_*)|AI/PW|11-A3|回复原文、电话记录或其他节点证据。|新增/统一|证据引用|
|
||||
|reminder_count|节点提醒次数|Integer|是|物流节点子表(适用于logi_*)|系统|11-A3|按D-10最大提醒次数控制。|新增/统一|最大提醒次数|
|
||||
|last_request_at|最后请求时间|DateTime|有请求时必填|物流节点子表(适用于logi_*)|系统|11-A3|支持间隔天数和超时判断。|新增|确认请求|
|
||||
|node_ri_escalation_status|节点RI升级状态|Enum|超时/异常时必填|物流节点子表(适用于logi_*)|系统/RI|11-A3|未升级/已升级/已核实/已降级。|新增/统一|升级RI门禁|
|
||||
|
||||
#### D-04 回款与信用台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|credit_record_id|信用记录ID|Text/AutoNumber|是|信用门禁主记录|系统|11-A2|回款信用门禁记录主键。|新增/统一|信用记录|
|
||||
|customer_id|客户ID|Link(L-02)|是|信用门禁主记录|SS04|11-A2|关联客户信用主体。|统一|客户ID|
|
||||
|payment_terms|账期条款|Text/Structure|是|信用门禁主记录|P-04/SS04|11-A2|从交易框架读取,作为信用判断依据。|重命名/统一|账期条款|
|
||||
|credit_limit|信用额度|Currency|条件必填|信用门禁主记录|商务/财务|11-A2|与currency_code成对。|统一|信用额度|
|
||||
|outstanding_amount|应收金额|Currency|是|信用门禁主记录|财务|11-A2|当前应收,需记录币种。|新增/统一|应收|
|
||||
|paid_amount|已回款金额|Currency|是|信用门禁主记录|财务|11-A2|当前已回款,需记录币种。|新增/统一|已回款|
|
||||
|overdue_days|逾期天数|Integer|是|信用门禁主记录|系统/财务|11-A2|信用预警触发依据。|保留/细化|逾期天数|
|
||||
|credit_rating|信用评级|Enum|是|信用门禁主记录|财务/商务|11-A2|正常/关注/高风险/冻结。|保留/细化|信用评级|
|
||||
|warning_status|信用预警状态|Enum|是|信用门禁主记录|系统/财务|11-A2|无预警/预警/拦截/已解除。|保留/细化|预警状态|
|
||||
|
||||
#### D-03 库存与在途台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|inventory_record_id|库存记录ID|Text/AutoNumber|是|库存批次记录|系统|11-A2|库存事实主键。|新增/统一|库存记录|
|
||||
|internal_pn|内部料号|Link(P-08)|是|库存批次记录|P-08/SS04|11-A2|统一引用P-08。|重命名/统一|料号|
|
||||
|manufacturer_lot|原厂批次|Text|条件必填|库存批次记录|供应商/仓库|11-A2|交接单/COC披露。|重命名/统一|mfr_lot、lot|
|
||||
|date_code|日期码DC|Text|条件必填|库存批次记录|供应商/仓库|11-A2|交接单/COC披露。|重命名/统一|dc、Date Code|
|
||||
|available_qty|可用数量|Number|是|库存批次记录|仓库|11-A2|可出库库存。|新增/统一|可用|
|
||||
|inspection_qty|待检数量|Number|是|库存批次记录|仓库|11-A2|状态数量。|新增/统一|待检|
|
||||
|frozen_qty|冻结数量|Number|是|库存批次记录|仓库|11-A2|状态数量。|新增/统一|冻结|
|
||||
|in_transit_qty|在途数量|Number|是|库存批次记录|系统|11-A2|与D-16履约关联。|新增/统一|在途|
|
||||
|allocation_id|分配记录ID|Text/AutoNumber|是|库存批次记录|系统|11-A2|批次占用与订单绑定。|新增/统一|子表2|
|
||||
|allocated_qty|已分配数量|Number|是|库存批次记录|系统|11-A2|与allocation_status配套。|新增/统一|分配数量|
|
||||
|allocation_status|分配状态|Enum|是|库存批次记录|系统/PW确认|11-A2|已分配/部分出库/已出库/释放。|重命名/统一|alloc_status|
|
||||
|shipped_qty|已出库数量|Number|是|库存批次记录|系统/PW确认|11-A2|与D-01.shipped_qty同步。|保留/细化|shipped_qty|
|
||||
|last_ship_date|最后出库日期|Date|条件必填|库存批次记录|系统/PW确认|11-A2|该批次最后一次出库事实。|保留/细化|last_ship_date|
|
||||
|moq_qty|库存批次MOQ|Number|条件必填|库存批次记录|S-05/供应商|11-A2|备货决策参考。|新增/统一|MOQ|
|
||||
|lead_time_days|库存批次交期天数|Number|条件必填|库存批次记录|S-05/供应商|11-A2|备货与在途判断。|新增/统一|交期|
|
||||
|stocking_decision|备货决策|Enum/Structure|条件必填|库存批次记录|履约Owner|11-A2|维持/补货/冻结/退出。|新增/统一|备货决策|
|
||||
|exit_plan|退出方案|LongText/Structure|stocking_decision=退出时必填|库存批次记录|履约Owner|11-A2|异常PN或库存退出动作。|新增/统一|退出方案|
|
||||
|
||||
#### D-10 客户履约主数据台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|execution_profile_id|履约参数档案ID|Text/AutoNumber|是|执行参数主记录|SS04|11-A2/11-A3|客户履约参数的版本化主键。|新增/统一|执行参数|
|
||||
|customer_id|客户ID|Link(L-02)|是|执行参数主记录|SS04|11-A2/11-A3|履约参数按客户维护。|统一|客户|
|
||||
|incoterms|贸易术语|Enum|是|执行参数主记录|履约Owner|11-A2|EXW等;影响风险转移规则。|新增/统一|贸易术语|
|
||||
|packaging_requirements|包装要求|LongText/Structure|条件必填|执行参数主记录|履约Owner|11-A2|生成交接单和实物核对依据。|重命名/统一|包装|
|
||||
|label_requirements|标签要求|LongText/Structure|条件必填|执行参数主记录|履约Owner|11-A2|实物核对依据。|重命名/统一|标签|
|
||||
|document_requirements|单证要求|Structure|条件必填|执行参数主记录|履约Owner|11-A2|客户格式与单证清单要求。|重命名/统一|单证要求|
|
||||
|logistics_preference|物流偏好|Structure|条件必填|执行参数主记录|履约Owner|11-A3|节点采集偏好。|重命名/统一|物流偏好|
|
||||
|service_level|服务等级|Enum/Structure|条件必填|执行参数主记录|履约Owner|11-A3|节点可见性和响应要求。|新增/统一|服务等级|
|
||||
|alert_thresholds|预警阈值|Structure|条件必填|执行参数主记录|履约Owner|11-A3|节点超时、异常延误等阈值。|新增/统一|预警阈值|
|
||||
|pickup_contact_ref|提货联系人引用|Link(L-08)/Text|是|执行参数主记录|履约Owner|11-A2/11-A3|用于交接单和未按预约提货提醒。|重命名/统一|pickup_contact|
|
||||
|pickup_appointment_mode|预约提货方式|Enum|是|执行参数主记录|履约Owner|11-A2|预约/临时/固定窗口等。|重命名/统一|pickup_appt_mode|
|
||||
|logistics_confirmation_channel|物流节点确认渠道|Enum|是|执行参数主记录|履约Owner|11-A3|邮件/微信/即时通讯/电话。|重命名/统一|logi_channel、确认通道|
|
||||
|logistics_confirmation_interval_days|节点确认间隔天数|Integer|是|执行参数主记录|履约Owner|11-A3|默认3天;可按客户配置。|重命名/统一|logi_interval|
|
||||
|logistics_max_reminder_count|最大提醒次数|Integer|是|执行参数主记录|履约Owner|11-A3|默认3次;达到后升级RI。|重命名/统一|logi_max_retry|
|
||||
|handover_template_ref|交接单模板引用|URL/Attachment|是|执行参数主记录|履约Owner|11-A2|生成交接单草稿。|重命名/统一|handover_tpl|
|
||||
|handover_notes|交接注意事项|LongText|条件必填|执行参数主记录|履约Owner|11-A2|客户特殊注意事项;生成交接单。|新增/统一|交接注意事项|
|
||||
|mapping_id|料号映射ID|Text/AutoNumber|是|料号映射子表|系统|11-A2|客户料号与内部料号一对一版本记录。|新增|料号映射|
|
||||
|customer_pn|客户料号|Text|是|料号映射子表|履约Owner|11-A2|客户侧料号。|重命名/统一|客户料号、customer_mpn|
|
||||
|internal_pn|内部料号|Link(P-08)|是|料号映射子表|履约Owner|11-A2|内部统一料号。|重命名/统一|internal_pn|
|
||||
|mapping_version|映射版本|Text|是|料号映射子表|履约Owner|11-A2|料号映射版本受控。|新增/统一|版本|
|
||||
|mapping_status|映射状态|Enum|是|料号映射子表|履约Owner|11-A2|有效/待确认/过期/作废。|新增/统一|状态|
|
||||
|difference_flag|映射差异标记|Boolean/Structure|是|料号映射子表|系统/履约Owner|11-A2|客户料号与内部料号无法一一对应时标记并说明。|新增/统一|差异标注|
|
||||
|
||||
#### D-11 出口单证台账
|
||||
|
||||
|统一字段名|中文字段名|数据类型|必填性|对象层|维护/来源|Demo使用|定义/取值/校验规则|处理建议|原字段/来源名称|
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
|document_set_id|单证包ID|Text/AutoNumber|是|单证包主记录|系统|11-A2|订单级单证包唯一标识。|新增/统一|证据包|
|
||||
|order_id|订单号|Link(D-01)|是|单证包主记录|系统|11-A2|回链D-01订单。|新增/统一|订单号|
|
||||
|invoice_ref|发票文件引用|URL/Attachment|条件必填|单证包主记录|履约/财务|11-A2|随货单证之一。|重命名/统一|发票|
|
||||
|packing_list_ref|箱单文件引用|URL/Attachment|条件必填|单证包主记录|履约|11-A2|随货单证之一。|重命名/统一|箱单|
|
||||
|coc_ref|COC文件引用|URL/Attachment|条件必填|单证包主记录|履约/质量|11-A2|随货单证之一。|重命名/统一|COC|
|
||||
|certificate_of_origin_ref|产地证文件引用|URL/Attachment|条件必填|单证包主记录|履约|11-A2|按客户或贸易场景要求。|重命名/统一|产地证|
|
||||
|warranty_certificate_ref|质保书文件引用|URL/Attachment|条件必填|单证包主记录|履约|11-A2|随货单证之一。|重命名/统一|质保书|
|
||||
|required_document_types|应提供单证类型|MultiEnum|是|单证包主记录|D-10/履约|11-A2|根据D-10.document_requirements生成。|新增/统一|单证清单、客户格式要求|
|
||||
|document_set_complete|单证包齐套状态|Enum|是|单证包主记录|系统/PW|11-A2|未齐套/已齐套;已齐套至少满足本订单required_document_types。|保留/细化|doc_set_complete|
|
||||
|consistency_check_status|单证一致性审核状态|Enum|是|单证包主记录|系统/PW|11-A2|未审核/通过/不通过/待补正;核对订单、料号、数量、批次、客户信息。|新增/统一|一致性审核|
|
||||
|consistency_check_result|单证一致性审核结果|LongText/Structure|审核后必填|单证包主记录|系统/PW|11-A2|记录差异项、责任人和补正动作。|新增/统一|一致性审核记录|
|
||||
|document_archive_ref|单证包归档引用|URL/Attachment|归档时必填|单证包主记录|系统/履约|11-A2|正式证据包统一归档。|新增/统一|证据包|
|
||||
|archive_due_date|归档期限|Date|条件必填|单证包主记录|履约Owner|11-A2|依据客户或合规要求。|新增/统一|归档期限|
|
||||
|document_type|单证类型|Enum|是|单证明细子表|系统|11-A2|发票/箱单/COC/产地证/质保书/其他。|新增/统一|doc_list|
|
||||
|document_ref|单证文件引用|URL/Attachment|是|单证明细子表|履约|11-A2|单证文件。|重命名/统一|doc_list|
|
||||
|document_status|单证文件状态|Enum|是|单证明细子表|系统/履约|11-A2|草稿/已核验/已归档/作废。|新增/统一|文档状态|
|
||||
|document_version|单证版本|Text|是|单证明细子表|系统/履约|11-A2|单证修改必须递增。|新增/统一|版本|
|
||||
|
||||
|
||||
## 五、重叠字段与统一命名清单
|
||||
|
||||
下表列出不同Activity或台账中“含义相同但写法不同”“层级不同却使用同名”“同一字段承载多个语义”的主要问题,以及本次建议的统一口径。
|
||||
|
||||
|冲突主题|涉及台账/Activity|原有写法|统一字段名|统一口径|
|
||||
|---|---|---|---|---|
|
||||
|客户ID|P-01/P-02/P-04/P-06/P-09/S-04/D-01/D-10/L-02;W05/W24/SS03/11-A2|customer_id、客户ID、客户/项目、客户名称|customer_id;展示字段customer_name|ID只存L-02.customer_id;名称通过关联展示,不在业务台账重复维护。|
|
||||
|项目关联|P-01/P-02/P-04/P-06/P-09/S-04/D-15/D-01;W05/W24/SS03/11-A2|project_id、客户/项目、项目|project_id|项目对象只保留ID关联;项目名称由P-05展示。|
|
||||
|内部料号|P-01/P-02/P-03/P-06/P-08/P-11/S-04/S-05/D-15/D-01/D-03/D-10;W05/W24/SS03/11-A2|pn、mpn、标准型号PN、Validation_PN、Original_PN、Candidate_PN|internal_pn;原物料用original_internal_pn,候选物料用candidate_internal_pn|全链路只认P-08.internal_pn;不得将mpn、pn、型号混作同一字段。|
|
||||
|客户料号|P-01/P-08/D-01/D-10;W05/W24/11-A2|customer_mpn、客户料号|customer_pn|客户料号与internal_pn分开;正式映射落D-10料号映射子表。|
|
||||
|品牌/原厂|P-08/P-10/S-04/S-05/SS03|brand、manufacturer_brand、原厂或品牌|brand_name;制造商另用manufacturer_name|品牌、制造商、供应商不是同一对象;S-04/S-05引用brand_name与supplier_id。|
|
||||
|数量|P-01/S-04/D-15/D-01/D-03/W24/SS03/11-A2|数量、target_qty、purchase_trigger_qty、order_qty、shipped_qty、actual_qty|target_qty、trigger_qty、order_qty、actual_qty、shipped_qty、allocated_qty|按业务语义分开;数量阶梯统一用quantity_tier子表,不用文本拼接。|
|
||||
|交期/时间窗|P-01/S-04/D-15/P-02/S-05/W24/SS03|target_delivery_date、requirement_date、quote_return_due_at、quote_due_at、lead_time/LT|target_delivery_date、requirement_date、quote_due_at、lead_time_days|目标日期、需求日期、报价截止时间、供货时长四类概念分开;原始说明写lead_time_note。|
|
||||
|价格类型|P-01/P-02/P-03/S-04/S-05/W24/SS03|price_types_default、price_types_requested、price_type、报价类型|requested_price_types(需求层);price_type(价格明细层);quote_type(报价主记录层)|需求要什么价格、这一行是什么价格、这份报价属于什么业务类型分别建字段。|
|
||||
|价格/币种|P-01/P-02/P-03/S-04/S-05/W24/SS03|报价价格/币种、unit_price、benchmark_unit_price、currency|金额字段按对象命名,币种统一currency_code;跨币种另记comparison_currency_code/conversion_rate|金额与币种成对出现;currency不再作为模糊字段名。|
|
||||
|有效期|P-02/P-03/S-04/S-05/W24/SS03|生效期、失效期、报价有效期、valid_until、effective_from/to|valid_from、valid_to;报价主记录用quote_valid_until|Price List/采购基准的有效区间用valid_from/valid_to;报价对外有效期用quote_valid_until。|
|
||||
|状态|P-01/P-02/P-03/P-06/P-11/S-04/S-05/D-01/D-03/D-10/D-11/W05/W24/SS03/11-A2/11-A3|状态机、状态、approval_status、publish_status、doc_set_complete|对象业务状态与record_status分离;各对象保留*_status命名|record_status只管记录生命周期;quote_status、approval_status等只管业务流程。|
|
||||
|版本|P-01/P-02/P-03/P-06/P-08/P-10/P-11/D-10/S-04/W05/W24/SS03/11-A2|版本、需求版本、Release版本、报价版本、Datasheet版本、规则版本|对象字段用requirement_version/quote_version/release_version/document_version/mapping_version;通用用record_version;规则用rule_version|版本必须绑定对象和用途,不再使用裸字段version。|
|
||||
|证据/附件/链接|P-01/P-02/P-06/P-10/P-11/S-04/D-01/D-11/W05/W24/SS03/11-A2/11-A3|evidence、证据引用、输出文件链接、handover_file、doc_list|evidence_ref;对象文件用*_ref;输出用output_ref;交接单用handover_file_ref|证据引用可多值且必须可定位;业务对象文件链接按对象命名。|
|
||||
|供应商主体/联系人|S-03/S-04/S-05/SS03|supplier_id、供应商ID、sales_contact、联系人|supplier_id;sales_contact_ref;contact_channel|供应商主体与联系人分离;联系渠道记录在供应商主数据,实际发送渠道记录在S-04.send_channel。|
|
||||
|询价批次/比价组|S-04/SS03|rfq_batch_id、comparison_group|rfq_batch_id;comparison_group_id|批次是询价过程容器;比价组是同条件可比集合,两者不可互代。|
|
||||
|MOQ/SPQ|P-02/S-04/S-05/D-03/W24/SS03/11-A2|MOQ-SPQ、MOQ、SPQ|moq_qty、spq_qty|拆分为两个数值字段,适用对象前缀由台账区分。|
|
||||
|交期LT|S-04/S-05/P-02/SS03/W24|LT、lead_time、交期|lead_time_days;lead_time_note|标准化时长与原始说明分开;统一日历口径由GOV定义。|
|
||||
|替代关系与批准|P-01/P-02/P-06/P-08/P-11/W05/W24|替代等级、推荐、批准状态、验证结果、P-11-01/P-11-02|recommendation_level、analysis_status、customer_approval_status、validation_status|推荐、分析、验证、客户批准四个阶段分开,P-11作为统一关系主对象。|
|
||||
|风险等级|P-01/P-02/P-06/P-11/S-05/D-18/W05/W24/SS03|风险等级、供应风险、问题与风险|risk_level(通用决策风险);supply_risk_level(供应能力风险);issue_risk(问题明细)|不同风险对象不共用一个自由文本字段。|
|
||||
|交接/物流节点|D-01/D-03/D-10/D-11/11-A2/11-A3|milestones、ship_date、logi_pickup、track_note、交接|milestones;交接事实字段;logi_*节点结构;track_note|交接是法律/责任事实;物流节点是后续过程事实,统一回链同一order_id但不混成一个字段。|
|
||||
|
||||
## 六、必须落地的结构化规则
|
||||
|
||||
|标准项|标准定义|示例|
|
||||
|---|---|---|
|
||||
|ID命名|业务对象主键使用<对象>_id;跨台账引用保留同名ID并采用Link。|request_id、supplier_id、order_id|
|
||||
|料号命名|internal_pn表示内部标准料号;customer_pn表示客户料号;original_internal_pn/candidate_internal_pn只用于替代关系上下文。|P-08.internal_pn、D-10.customer_pn|
|
||||
|金额与币种|所有金额字段必须与currency_code成对;跨币种比较在决策记录中另记comparison_currency_code与conversion_rate。|unit_price + currency_code|
|
||||
|数量阶梯|数量阶梯使用结构化子表,至少包含min_qty、max_qty、price或discount;禁止把多个阶梯拼接为自由文本。|P-02 QuoteLine.quantity_tier|
|
||||
|时间字段|发生时点用*_at(DateTime),业务日期用*_date(Date),持续时长用*_days(Number)。|quote_due_at、target_delivery_date、lead_time_days|
|
||||
|版本字段|不使用裸version;必须指明对象:requirement_version、quote_version、release_version、document_version、record_version、rule_version。|P-02.quote_version|
|
||||
|状态字段|record_status管记录生命周期;对象业务流程用*_status;派生可见性用*_flag或*_visible。|record_status、approval_status、track_visible|
|
||||
|证据字段|evidence_ref表示证据集合;对象文件按对象命名为*_ref;证据必须支持URL/附件/定位信息。|quote_evidence_ref、handover_file_ref、citation_locator|
|
||||
|节点结构|物流节点统一包含estimated_time、actual_time、source、collection_method、confirmation_status、anomaly_flag、evidence_ref。|D-01.logi_pickup|
|
||||
|主记录与子表|主记录保存上下文、状态和汇总;逐PN/BOM位/阶梯/参数/单证的重复明细使用子表。|P-02 QuoteLine、P-11差异明细、D-11单证明细|
|
||||
|
||||
## 七、实施优先级与待确认事项
|
||||
|
||||
**P0:**先落实P-01、P-02、P-11、S-04、D-01的主记录与子表结构,并建立统一ID、料号、版本、状态和证据字段。P-02如果不建立QuoteLine,PN×BOM位×数量阶梯无法稳定支撑报价和版本管理;P-11如果不建立差异明细,差异评估只能保留摘要,无法被AI复核或下游消费;S-04如果不拆RFQ/Quote/Decision三层,询价事实、供应商回复和定点结论会互相覆盖。
|
||||
|
||||
**P1:**落实条件必填与校验规则,包括代理证、涨价原因、跨币种换算、报价/基准有效期、风险转移时点、交接文件状态、物流节点异常及提醒次数。
|
||||
|
||||
**P1:**明确以下业务口径后固化到GOV规则:lead_time_days按自然日还是工作日;毛利计算公式和阈值;价格来源优先级;汇率来源与as_of;Incoterms及地点字段;客户批准与FAE分析结论的权限边界;D-10履约参数的客户级/物流公司级配置粒度。
|
||||
|
||||
**P2:**将S-05基准、D-03库存与D-16采购履约的数据同步关系做成系统接口;将D-01物流节点结构接入客户可查询视图,并将异常节点回链D-18或既有异常归集Activity。
|
||||
|
||||
|
||||
## 八、交付物说明
|
||||
|
||||
本Markdown用于评审和阅读;配套Excel提供可直接继续维护的“字段总表”“Activity覆盖矩阵”“重叠字段统一”“字段标准”四张工作表,并保留“说明”页作为使用口径。
|
||||
|
||||
|
||||
## 参考附件
|
||||
|
||||
[1]: /home/ubuntu/upload/SS01-02-03-04-台账体系全景-18Activity关联矩阵(2).html "SS01-02-03-04-台账体系全景:18 Activity关联矩阵"
|
||||
|
||||
[2]: /home/ubuntu/upload/SS02_ACT-WORK-05_差异评估_E04-I01架构版_V1.2(1).html "SS02 ACT-WORK-05 差异评估"
|
||||
|
||||
[3]: /home/ubuntu/upload/SS02_ACT-WORK-24_报价方案制定_E04-I01架构版_V1.2(1).html "SS02 ACT-WORK-24 报价方案制定"
|
||||
|
||||
[4]: /home/ubuntu/upload/SS03询价ActivityDemo(1).html "SS03 询价 Activity Demo"
|
||||
|
||||
[5]: /home/ubuntu/upload/20260826-11-A2架构版(E04-I01v1.2).html "SS04 ACT-11-A2 提货交接与风险转移"
|
||||
|
||||
[6]: /home/ubuntu/upload/20260826-SS04-SOP-11活动描述样板(11-A3).html "SS04 11-A3 物流执行信息跟踪活动描述样板"
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
# `input-supplement` 换版面 —— 照新目标件重做数据工单那一屏
|
||||
|
||||
> **状态**:设计已定、**已获用户批准**(2026-08-30)。这份文档是**为上下文压缩而写的交接件** ——
|
||||
> 压缩之后照它直接开工,不必重新 brainstorm、不必再问用户。
|
||||
>
|
||||
> **分类**:bounded(要改的流程仓里已经有;换版面、功能不变)。按 brainstorming 技能的 bounded 路径,
|
||||
> **不写 spec、不走 writing-plans**;这份文档只是上下文的替身,不是流程意义上的实施计划。
|
||||
|
||||
---
|
||||
|
||||
## 一句话
|
||||
|
||||
把 `input-supplement`(S1 派的那张数据工单)的**视图层**换成
|
||||
`docs/ss02-act-05-new/ACT-WORK-05-数据工单-PN主数据创建.html` 的版面,**绑定面与功能一个不动**。
|
||||
|
||||
用户原话:「功能应该是一样的」「注意页面样式要和这个模版html几乎一样」。
|
||||
|
||||
---
|
||||
|
||||
## 用户已定的三件事(不要再问)
|
||||
|
||||
| # | 问题 | 用户的选择 |
|
||||
|---|---|---|
|
||||
| 1 | 目标件是单颗 PN 三步向导,而 W05 天生两颗料 | **每颗料一条轨道(两条并列)** |
|
||||
| 2 | 目标件的新增类型面板只收参数名,而 W05 的硬/软约束必填 | **面板里每行扩成四格**(参数名/说明/硬软约束/单位),**独立的「核心参数字段」表去掉** |
|
||||
| 3 | 目标件是一道总闸切整页,平台的闸按台账地址走 | **不动平台:每个步骤卡自带一道闸** |
|
||||
|
||||
**第 1 条我提过风险**(类型要在两条轨道各选一次、而它们必须相同,容易选出不一致再被判据拦下),
|
||||
用户仍选了它 —— 按他的决定走,但**加一道无脚本护栏**(见下)。
|
||||
|
||||
**第 2 条的连带代价**(要在实现时留意):独立字段表去掉之后,**已有类型的字段清单在件上看不到也改不了**。
|
||||
现有件那张表的第二个用途是「在库但当年参数没录全 → 给已有类型补一个字段」。
|
||||
**缓解**:把新增类型那个 `<details>` 面板对**已有类型**也开放(标题写「类型库里没有?或要给这个类型补一个字段」),
|
||||
同一个面板兼两用,版面不变而能力不丢。**这一条是我在设计里补的,用户没有单独批,但它属于「功能一样」的承诺范围。**
|
||||
|
||||
---
|
||||
|
||||
## 现场事实(已逐条核实,不必重查)
|
||||
|
||||
### 目标件
|
||||
`docs/ss02-act-05-new/ACT-WORK-05-数据工单-PN主数据创建.html` —— 444 行 / 26KB。
|
||||
同目录另有 `SS02_ACT-WORK-05_差异评估_E04-I01架构版_V1.2.html` 等,**不在本次范围**。
|
||||
旧目标件在 `docs/SS02-ACT-WORK-05-html/ACT-WORK-05-数据工单-01-芯片主数据创建.html`(现有件是照它平移的)。
|
||||
|
||||
目标件结构:
|
||||
- `.card-head`(`.badge`「数据」· `.kick`/`h1`/`.sub` · `.side` 里 tag/step/deadline)
|
||||
- 【触发原因】`.zone` + `.note`(流程 / AI 预填情况)
|
||||
- **`.jc-editgate` 带 `.gateprog` 进度条**(`.track`/`.fill`/`.pct` = `0 / 3 项`)
|
||||
- **渲染态 `.viewarea`**:`.idcard` 两格(PN 名称 / PN 类型,大号 mono 值)+ 核心参数表 `.tbl-wrap`
|
||||
- **编辑态 `.editarea`**:`.steps` 步骤轨道(`.stepbox` / `.sdot` / `.scard` / `.fld`)
|
||||
- ① PN 名称 ② PN 类型(select 首行 `+新增类型`)+ `.newtype` 面板 ③ 核心参数值 `.pvgrid` 瓷贴(2 列)
|
||||
- `.flow` 底部说明 + `.card-foot`
|
||||
- 尾部 `<script>` 驱动:类型库 `PN_TYPES` → 选类型即时加载参数框、新增类型面板、进度条
|
||||
|
||||
**目标件的闸结构与平台逐字一样**(`.jc-editgate` / `.eg-open` / `.eg-cancel` / `.eg-save` / `.on`)——
|
||||
它本来就是照平台做的,只有「一道 vs 四道」这一处对不上。
|
||||
|
||||
### 现有件
|
||||
`activities/part-substitution-assessment/src/views/report.ts`(953 行):
|
||||
|
||||
| 片段 | 行 | 绑定 |
|
||||
|---|---|---|
|
||||
| `PART_TABLE` | :298 | `<tbody data-rows="mine:sub_eval.part" data-edit="parts.profile">` |
|
||||
| `CHIP_TYPE_TABLE` | :398 | `<table data-edit="parts.chip_type">` + `<tbody data-rows="mine:parts.chip_type" data-edit="parts.chip_type">`(**两处都写**,见下)+ `addrow` |
|
||||
| `TYPE_PARAM_TABLE` | :447 | `<table data-edit="parts.type_param">` + `<tbody data-rows="mine:parts.type_param" …>` + `addrow` |
|
||||
| `PARAMETER_TABLE` | :496 | `<tbody data-rows="mine:parts.parameter" data-edit="parts.parameter">` |
|
||||
| `INPUT_SUPPLEMENT` | :515 | `doc({ css: P08_CSS, alias: BIND_P08, badge:'数据', … })` |
|
||||
|
||||
四道 `editGate` + 两处 `addrow`(`parts.chip_type` / `parts.type_param`)。
|
||||
|
||||
只读态查类型人读名的写法(已验证可用):
|
||||
`<span data-rows="parts.chip_type" data-by="type_id=chip_type_id"><i data-slot="type_name" data-blank></i></span>`
|
||||
|
||||
### 样式
|
||||
`activities/part-substitution-assessment/src/views/target-style.ts`:
|
||||
`P08_CSS`(:34,数据工单)· `ACC1_CSS`(:144,结论审核)· `ACC2_CSS`(:241,落账确认)。
|
||||
**本次只换 `P08_CSS`**,另两段不动。
|
||||
|
||||
### 平台机制(会影响怎么写)
|
||||
- `ViewContainer.tsx:548` —— `documentElement.classList.toggle('editable', editable)`;
|
||||
**`editable` 只让闸现身**,开不开由人按闸,开了给那张表的宿主挂 `.on`(`table.on td>.jc-vcell{display:none}`)。
|
||||
- **`bodyOf` 是单个 `querySelector`**(`ViewContainer.tsx:164-167`)——**一个地址只找得到一个宿主**。
|
||||
这是终审 C1 与 Task 7b 的教训来源,**本次版面成立与否就卡在这一条**(见风险)。
|
||||
- `addRow` 靠克隆 `tr:last-of-type`,**零行时返回 false**(Task 7b 的旧账,已由种子行绕开)。
|
||||
- 件是**零脚本沙箱**;交互靠 CSS(`:has` / `<details>` / `:focus-within`)与外层容器。
|
||||
|
||||
### 当前版本与闸的名单
|
||||
- `packageVersion: '2.1.9'`(`manifest.ts:57`)→ 本次改完升 **2.2.0**。
|
||||
- S1 卡的 `gateLedgers`(`case-model.ts:592`):
|
||||
`['parts.chip_type', 'parts.type_param', 'parts.profile', 'parts.parameter']` —— **四张,不要动**。
|
||||
- 现有自测:`__tests__/activity.test.ts` 共 70 条 `it(`。
|
||||
|
||||
---
|
||||
|
||||
## 设计
|
||||
|
||||
### 版面
|
||||
|
||||
```
|
||||
卡头(数据 badge · kick/h1/sub · 右侧 tag/step/deadline)
|
||||
【触发原因】zone + note(流程 / AI 预填情况)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
原 PN 轨道 候选 PN 轨道
|
||||
① 名称·在库·品牌·生命周期 ① 同左 ← 一道闸(parts.profile)
|
||||
② 芯片类型 [下拉] ② 芯片类型 [下拉]
|
||||
▸ 类型库里没有?在这里新建 / 补字段 ← 一道闸(chip_type + type_param)
|
||||
类型名 + 逐行四格(参数名/说明/硬软约束/单位)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
③ 核心参数值:左右两组瓷贴,按料分 ← 一道闸(parts.parameter)
|
||||
.flow 说明 + 页脚
|
||||
```
|
||||
|
||||
**两条并列轨道怎么出来的**:`<div class="tracks" data-rows="mine:sub_eval.part" data-edit="parts.profile">`
|
||||
里放 `<section class="track">…</section>`,`.tracks{display:grid;grid-template-columns:1fr 1fr}`。
|
||||
`data-rows` 逐项复制内部结构,CSS 把它们排成两栏 —— 这是 A2 已验证的写法
|
||||
(「`<article data-rows>` 包 `<section class="cand">`」逐颗料一张卡)。
|
||||
|
||||
**行容器的坑**:行展开按**第一个同名闭合标签**截行内容 —— 行模版里不许再出现同名标签。
|
||||
用 `<section>` / `<tbody>` 这类行内不会重复的标签。
|
||||
|
||||
### 无脚本翻译
|
||||
|
||||
| 目标件(靠 `<script>`) | 平台等价物 |
|
||||
|---|---|
|
||||
| 选类型 → 即时加载参数输入框 | **保存即铺格**(配方按类型的字段清单铺出参数行,已实现且走查验过) |
|
||||
| `+新增类型` 选中才显示面板 | **`<details>` 常驻折叠条**(A2 方法论条同款,零脚本) |
|
||||
| `.stepbox.done` 点亮 | `data-class-of` 投影 + 样式表判断(**模版不许算、不许判**) |
|
||||
| 进度条 `n / m 项` | 分母**必须是真数**:由配方算进 `sub_eval.case` 两列(如 `fill_done`/`fill_total`);<br>**算不出就只摆「还差 N 项」+ 二态条,不许编分母**(界面不撒谎) |
|
||||
| `body.editing` 整页切 | 平台的 `editable` + 每卡 `.on` —— 用户选了「每步骤卡一道闸」 |
|
||||
|
||||
### 加的一道护栏(方案 1 的风险缓解)
|
||||
|
||||
两条轨道各选一次类型、而它们必须相同(`distinct = 1` 那条判据)。
|
||||
基座本来就出 `sub_eval_part_distinct_chip_type_id` —— 把它投出来,
|
||||
**两处选得不一样时件上直接出红带点名**,不必等提交才被判据拦。
|
||||
用 `data-class-of` 投影 + 样式表出显隐(`v-1` 那档 `display:none`),**模版不算不判**。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 要先验证再往下写的一条(这版面成立的前提)
|
||||
|
||||
步骤③ 的两组瓷贴要共用**一个** `data-edit="parts.parameter"` 宿主
|
||||
——因为 **`bodyOf` 只找得到一个宿主**(终审 C1 / Task 7b 的教训:同一地址挂两处,
|
||||
`querySelector` 只命中第一个,后面那组的值**一格都收不上去,而且不报错**)。
|
||||
|
||||
宿主内部靠**嵌套 `data-rows` + `data-by` 父键过滤**按料分栏。
|
||||
|
||||
**必须先钉住的是收值归行**:`cellsOf` 按宿主内所有 `[data-col]` 取格子,`rowOf` 按行容器归行。
|
||||
嵌套之后 `rowOf` 认的是内层瓷贴组还是外层?**认错就会把两颗料的参数值混成一行**。
|
||||
|
||||
**做法**:先写一条测试跑真实的 `cellsOf`/`rowOf`(或经 `resolveRowKey` 走一遍),
|
||||
**证明两颗料的瓷贴各归各行**。
|
||||
- 成立 → 按上面的版面写;
|
||||
- **不成立 → 退回「③ 单独一块、内部分两栏」的形状**(轨道只到 ②),并**告诉用户这一处与目标件的差别**。
|
||||
|
||||
顺带留意 `data-by` 的已知缺陷(终审 I3,已记 debt 未修):
|
||||
**行内没有该键时会落回外层同名字段** —— `internal_pn` 在多张表上撞名,分栏过滤要用真有值的键。
|
||||
|
||||
---
|
||||
|
||||
## 动的文件
|
||||
|
||||
| 文件 | 改什么 |
|
||||
|---|---|
|
||||
| `src/views/target-style.ts` | `P08_CSS` 整段换成从新目标件**机械抽取**的样式(数值不改;`ACC1_CSS`/`ACC2_CSS` 不动) |
|
||||
| `src/views/report.ts` | 重写 `INPUT_SUPPLEMENT` 与它的片段(`PART_TABLE`/`CHIP_TYPE_TABLE`/`TYPE_PARAM_TABLE`/`PARAMETER_TABLE`) |
|
||||
| `src/manifest.ts` | `packageVersion` → `2.2.0` |
|
||||
| `__tests__/activity.test.ts` | 断言跟着新版面走(**不许放松**) |
|
||||
|
||||
**不动**:平台(`ViewContainer` / `view-render` / `desk.ts`)· 配方(`case-model.ts`)· `gateLedgers` · 另两张件。
|
||||
|
||||
---
|
||||
|
||||
## 必须留着的东西(动了就是回归)
|
||||
|
||||
1. **`<input class="jc-cell mono" data-col="type_id" placeholder="由系统生成" disabled>`**(`report.ts:407` 一带)
|
||||
—— 这是**终审 C1 的修复**:没有它,人新建的类型会被「全表一致」认成既有行、**把既有字典行改名并落进共享主数据**。
|
||||
新版面里这一格换了位置也**必须带着走**,并保留「由系统生成」那句占位
|
||||
(2026-08-02 立的规矩:自增列不问人,但**不能一声不吭地拿掉**)。
|
||||
2. **主键列必挂 `data-col`** —— 它不是编辑面,是**行的钥匙**;不挂,服务端按「全表一致」猜,会把两行对到同一行上互相覆盖。
|
||||
3. `CHIP_TYPE_TABLE` 那个「`data-edit` 外层 `<table>` 与内层 `<tbody>` 两处都写」的写法有原文注释解释原因
|
||||
(`bodyOf` 要同时收到「已有行」与「加一行」出来的行)—— 换版面时**读那段注释再决定怎么摆**。
|
||||
4. `gateLedgers` 四张表与件上的可编辑面**同源** —— 闸盯的表,件上必须给得出填的手段
|
||||
(Task 7 那颗「按不下去的确认钮」就是这么来的)。
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
**现有断言继续绿,一条都不许放松**:可改面只有哪几列 · 三份件用到的 class 必须都能在自己那段 CSS 里找到定义 ·
|
||||
零脚本(`not.toContain('<script')`)· 主键挂 `data-col` · 编辑闸外壳那六个属性 · `gateLedgers` 与件同源。
|
||||
|
||||
**新增**:
|
||||
- 两条轨道各出一颗料(`mine:sub_eval.part` 两行 → 两个 `.track`);
|
||||
- **步骤③ 瓷贴按料归行正确**(上面那条风险的钉子);
|
||||
- 类型不一致时红带现身(`distinct > 1` → `data-class-of` 投影命中)。
|
||||
|
||||
**每条新断言都要做变异验证**(拿掉修复/改坏一处 → 测试必须真红;复原 → 绿),两段输出贴进报告。
|
||||
本轮已连吃三次「测试测的是副本」「变异测试本身是空的」「夹具替被测系统干了活」的亏。
|
||||
|
||||
**门禁**:`pnpm gates` **以退出码验,不许接管道**(`cmd | tail` 的退出码是 `tail` 的)。
|
||||
|
||||
**最后必须做的两步**:
|
||||
1. `pnpm activities:install` 装包,**用 SQL 核库里那一行**(版本号 / 视图 HTML)—— 装完看库,不看脚本的话;
|
||||
2. **浏览器走一遍**(起单 → 数据工单 → 新建类型 → 保存即铺格 → 填参数 → 提交)——
|
||||
**版面对不对只有真看一眼才算数**;脚本 e2e 逮不到「一个图标 946 像素高」这类事。
|
||||
跑试跑要用 AI 服务账号或用户授权的账号,跑完 `pnpm --filter @app/api reset-runs` 清运行数据。
|
||||
|
||||
---
|
||||
|
||||
## 环境备忘(压缩后会用到)
|
||||
|
||||
- 开发后端 `:8787`、前端 `:5173`;**稳定版 `:8790` 目前是停着的**(用户授权,大改期间不动它)。
|
||||
- 起后端要先 `set -a; . .stable/env; set +a`,否则「缺 KEYCLOAK_URL…」起不来。
|
||||
- **长跑的 dev 进程可能在跑几小时前的代码** —— 本轮踩过三次;`tsup --watch` 监视进程本身也可能已死,
|
||||
`touch` 无效,要**完整重启** `pnpm dev:api`;杀进程要杀到 `--onSuccess` 拉起的那个 node(否则 `EADDRINUSE`)。
|
||||
- 查库:`docker exec jcai-postgres psql -U jcai -d jcai_wos -c "…"`(`.stable/env` 里的 `DATABASE_URL` 指向 5432,
|
||||
而 docker 映的是 54329,直接进容器最省事)。
|
||||
- 浏览器登录走 Keycloak 用户名密码;**密码那一格我不能替用户输**,要他自己登。
|
||||
|
||||
---
|
||||
|
||||
## 上下文:这条分支此刻的状态
|
||||
|
||||
`feat/w05-rewrite`,HEAD `45cec82`,**工作树干净,`pnpm gates` 退出码 0(2476 测)**。
|
||||
ACT-WORK-05 重写(Task 1–12 + 7b + 全分支终审 + 修复波 + 定向复审)**已全部完成**,
|
||||
**未合并、未推送** —— 合并方式等用户定(选项已摆给他:本地合进 `mvp-foundation` / 推送开 PR / 先放着)。
|
||||
`feat/deterministic-steps`(第一份计划)**已并在这条分支里**,合它即合两者。
|
||||
|
||||
逐任务账本与全部裁定:`.superpowers/sdd/2026-08-29-act-work-05-rewrite/progress.md`(用户尚未决定删留)。
|
||||
|
||||
**本次换版面是这之后的新活,与上面那条分支的收尾是两件事。**
|
||||
@@ -0,0 +1,618 @@
|
||||
# ACT-WORK-05 差异评估 · 整体重写设计
|
||||
|
||||
状态:**已评审通过**(2026-08-29,用户逐节点头)
|
||||
落点:`activities/part-substitution-assessment@2.0.0` + 平台侧「确定性步骤」机制
|
||||
关联:ADR-014(确定性步骤,本设计是它的第一个落地者与两处改口)、ADR-004(不执行包代码)、
|
||||
`docs/活动开发规范.md`、`docs/HTML报告生成规范.md`、`docs/ss02-act-05-new/`(业务素材)
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么推倒重来
|
||||
|
||||
现版 `@1.7.3` 是**九步、两枚工具、全程 agent 驱动**:每一拍问模型「下一步干什么」,
|
||||
它自己 `write` 台账、自己 `finish`、自己发卡。`docs/ACT-WORK-05-端到端测试记录.md` 的
|
||||
A–AE 发现表里,八条事故落在「本来就该是确定的」那几步上 —— 编号抄别单、判在库判错、
|
||||
数据工单派给在库的料、没写骨架就发卡、库里有值当缺口、抄别的料的规格书。
|
||||
|
||||
用户 2026-08-29 定的新立场:
|
||||
|
||||
> 「不是把所有东西都丢给 ai 来处理,不要给 ai 暴露任何的工具,全部由系统确定性的来控制,
|
||||
> 一定是系统先确定性的从数据库中取出数据后交给 ai 分析。」
|
||||
|
||||
**agent 只在真需要判断的地方出现,与「人只在责任点与放行点出现」是同一条原则。**
|
||||
这个活动里,真需要判断的只有一件事:**这一条参数,候选跟原厂比算不算事**。别的全是确定的。
|
||||
|
||||
新版:**四步、零工具、零 `guide`、AI 出场恰好一次**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已定的八条(评审逐条通过)
|
||||
|
||||
| # | 决定 |
|
||||
|---|---|
|
||||
| 1 | **驱动**:落地 ADR-014 声明式配方 + 第四动词「一次结构化 AI 调用」。活动内部不再引第二套引擎(Mastra 仍在流程包编排与模型网关的原位) |
|
||||
| 2 | **台账**:两张新字典表 + P-08 主记录 + 参数子表 + P-11 主/明细 + P-06 + 一张活动自持案卷。**P-01 需求主记录 / P-10 原厂资料 / 客户档案本活动不碰** |
|
||||
| 3 | **P-06 芯片验证记录**:每条替代关系**固定落一条**,全推荐的写「本轮无待验项」 |
|
||||
| 4 | **结论谁定**:逐条四态由 AI 判;**推荐等级、风险、摘要、推荐理由由规则与模板算** |
|
||||
| 5 | **补数据的执行工单**:保存即铺格 —— 人选定/新建类型后按一次保存,系统按该类型的字段清单把参数值的行铺出来,人只填值,不加行不删行 |
|
||||
| 6 | **两颗料类型不一致**:硬拦,与「PN 不在库」同一条路(派工单让人改),保住「两边字段清单一定是同一套」这个前提 |
|
||||
| 7 | **结论审核卡**:只有「确认通过」一个钮,改全在件上;**人改完不重跑 AI**,只重算等级与摘要 |
|
||||
| 8 | **机制规模**:只实现本活动真用得到的那几式;没实现的形状装包时静态审当场拒 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 两张新字典表(`parts` 域,与 P-08 同域)
|
||||
|
||||
**`parts.chip_type` · 芯片类型**
|
||||
|
||||
| 列 | 型 | 档 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `type_id` | integer | 主键 · `auto` | 人不填,基座续号(现有最大 +1) |
|
||||
| `type_name` | text | 必填 | 「DC-DC 降压稳压器」;件上下拉显示它 |
|
||||
| `description` | text | 建议 | 这个类型是干什么的 |
|
||||
|
||||
**`parts.type_param` · 芯片类型核心参数字段**
|
||||
|
||||
| 列 | 型 | 档 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `type_id` | integer | 主键 · `refersTo parts.chip_type.type_id` | 属于哪个类型 |
|
||||
| `param_name` | text | 主键 | 「输入电压范围」 |
|
||||
| `param_desc` | text | 必填 | 这个参数是什么意思 —— **AI 分析时读它** |
|
||||
| `constraint_type` | text | 必填 · tally `hard`/`soft` | 硬约束 / 软约束 |
|
||||
| `param_unit` | text | 建议 | 参考单位 |
|
||||
|
||||
关联:一行 P-08 关联一个类型;多行 `type_param` 关联一个类型。
|
||||
|
||||
### 3.2 P-08 及其子表的改动
|
||||
|
||||
**`parts.profile`**(声明后为八列,物理表其余列不声明 = 本活动不碰):
|
||||
|
||||
`internal_pn`(主键)· **`chip_type_id` integer 必填 refersTo `parts.chip_type.type_id`** ·
|
||||
`brand_name` 必填 · `lifecycle_status` 必填 tally · `specification_description` 建议 ·
|
||||
`manufacturer_name` 建议 · `datasheet_ref` 建议 · `datasheet_version` 建议
|
||||
|
||||
**`createOnly` 撤掉**(现版标着它,只许建新料、不许改已有行)。它当初是为了拦模型凭空建料;
|
||||
模型现在不在场,**行由配方按本单两颗料铺出来,凭空建在结构上不可能**。而撤掉它换来一件必需的事:
|
||||
**给在库的老料补上「芯片类型」这一格** —— 迁移前建的行没有类型,不许改就永远补不了,单子卡死。
|
||||
|
||||
**不声明**:`package` 封装 · `process_technology` 工艺 · `eol_pcn_status` · `application_scenarios`。
|
||||
**封装和工艺本来就是「核心参数」** —— 它们该以类型字段的身份出现在字典里,不在主记录上另开一列。
|
||||
|
||||
**`parts.parameter`**:`internal_pn`(主键,refersTo profile)· `param_name`(主键)·
|
||||
`param_value` 必填 · `param_unit` 建议 · `param_note` 建议(**值说明,AI 也读它**)
|
||||
|
||||
`parts.parameter` 的 **`createOnly` 同样撤掉**(理由同上:④那一笔要把在库那颗料的现值回填进草稿层,
|
||||
只许建新就写不进去)。
|
||||
|
||||
两处退休:
|
||||
- **`constraint_type` 退休** —— 搬去 `type_param`。同一个参数在两颗料上标法不一致这件事从结构上消失。
|
||||
- **`param_name` 的自指引用墙退休**(`refersTo: 'parts.parameter.param_name'`)。它当初是为了硬逼两颗料
|
||||
用同一套参数名,措辞拦了三次没拦住才砌的;现在字段清单由类型字典给、**行由系统按字典铺**,
|
||||
名字对不上根本不可能发生。`distinct` 一并退休。
|
||||
|
||||
### 3.3 活动自持两张(顶掉现在的 `pn_check` / `input_gap` / `verdict` 三张)
|
||||
|
||||
**`sub_eval.part` · 本单两颗料**,`kind: record`,`caseKey: eval_ref` —— 一单**恰两行**
|
||||
|
||||
`eval_ref`(主键)· `internal_pn`(主键)· `role` 必填 tally `original`/`candidate` ·
|
||||
**`found_pn_ref`**(`refersTo parts.profile.internal_pn`,`requiredWhen exists_status = found`)·
|
||||
`exists_status` 必填 tally `found`/`absent` · `chip_type_id` integer **必填** · **`distinct: true`**
|
||||
(「两颗料是不是同一个类型」于是成了基座数得出来的数:不同取值恰好 1 种)
|
||||
|
||||
**`sub_eval.case` · 本单案卷**,同上 —— 一单**一行**
|
||||
|
||||
`eval_ref`(主键)· `chip_type_id`(本单锁定的类型)· `gate_reason`(入参未就绪的原因,件上摆的就是它)·
|
||||
`summary`(结论一句话)
|
||||
|
||||
**`found_pn_ref` 是「在不在库」的依据**:判 `found` 就得拿得出一个基座查得到的料号,
|
||||
`refersTo` 让查无此值当场拒 —— 「在库」从此是一次 `lookup` 的结果,不是一句断言。
|
||||
`exists_status` 与两处 `chip_type_id` **每一拍由 `lookup` 重算** —— 人把料建好之后自己变 `found`,
|
||||
不设 `created` 档、不靠谁记账。
|
||||
|
||||
**为什么非拆成两张不可**:`part` 是**配方唯一能拿来遍历「哪几颗料要建」的数据源** ——
|
||||
`each: 'mine:sub_eval.part' where exists_status = absent`。挤进一行案卷里就没法 `where` 过滤,
|
||||
而不能过滤就会给已经在库的那颗料也铺一遍空参数行,`_missing` 当场报缺、单子死在那儿。
|
||||
|
||||
### 3.4 结论三张
|
||||
|
||||
**`substitution.relation`**(P-11 主记录,一单一行)
|
||||
|
||||
`substitution_relation_id`(主键)· `eval_ref` 必填 · `original_internal_pn` / `candidate_internal_pn`
|
||||
必填 refersTo profile · `chip_type_id` 必填 · `recommendation_level` 必填 tally
|
||||
`recommended`/`conditional`/`not_recommended` · `recommendation_reason` 必填 ·
|
||||
`difference_summary` 必填 · `risk_level` 必填 tally `low`/`medium`/`high` ·
|
||||
`verification_requirement`(`requiredWhen recommendation_level = conditional`)· `validation_id` ·
|
||||
`analysis_status` 必填(固定 `fae_confirmed`)· `rule_version` 必填
|
||||
|
||||
退休:`substitution_type`(新流程里没有它的定夺人)· `confidence_score` · `analysis_report_ref` ·
|
||||
`recommendation_level` 的 `alternate` 档(原 D02 的 `configurable` 来路已不存在)。
|
||||
|
||||
**`substitution.difference_item`**(P-11 明细,一参数一行)
|
||||
|
||||
`substitution_relation_id`(主键 · `distinct` · refersTo relation)· `difference_item_id`(主键)·
|
||||
`parameter_name` 必填 · `parameter_desc` 建议(字段说明快照,归档件不必 join)· `constraint_type` 必填 tally ·
|
||||
`original_value` / `candidate_value` 必填 · `parameter_unit` 建议 ·
|
||||
**`difference_status` 必填 tally `match`/`mismatch`/`unknown`/`conditional`(AI 判)** ·
|
||||
**`judgement_class` 必填 tally `match`/`soft_mismatch`/`hard_mismatch`/`unknown`/`conditional`(系统合成)** ·
|
||||
`impact_statement` 必填(AI)· `evidence_note` 必填(AI)·
|
||||
`unknown_disposition`(`requiredWhen difference_status = unknown`,AI)· `risk_level` 必填 tally · `rule_version` 必填
|
||||
|
||||
**`judgement_class` 是这套设计的关键一列**:推荐等级要数的是「硬约束不符有几条」,
|
||||
而基座读数只会数单列 —— 把「约束类型 × 四态」先合成一列,规则才算得出来。
|
||||
`evidence_ref` 改名 `evidence_note`「判断依据」:不写 P-10 了,AI 能给的就是依据不是文件引用,名字得说实话。
|
||||
|
||||
**`validation.record`**(P-06,一单一行)
|
||||
|
||||
`validation_id`(主键)· **`eval_ref` 必填**(顶掉 `request_id` —— P-01 不在本活动里)·
|
||||
`validation_internal_pn` 必填 refersTo profile · `validation_type` 必填 tally(**追加一档 `parametric`**)·
|
||||
`validation_status` 必填(固定 `not_started`)· `test_condition` 建议 · `next_validation_action` 必填
|
||||
|
||||
### 3.5 规则(唯一真源在配方里,不再另留 DMN)
|
||||
|
||||
**`judgement_class`**:`match` → match;`unknown` → unknown;`conditional` → conditional;
|
||||
`mismatch` 且硬 → `hard_mismatch`;`mismatch` 且软 → `soft_mismatch`。
|
||||
|
||||
**明细 `risk_level`**:`hard_mismatch` → high;`unknown`/`conditional`/`soft_mismatch` → medium;`match` → low。
|
||||
|
||||
**`recommendation_level`**(读 `judgement_class` 的 tally 计数,自上而下第一条命中):
|
||||
|
||||
| 条件 | 等级 |
|
||||
|---|---|
|
||||
| `hard_mismatch > 0` | `not_recommended` |
|
||||
| `unknown > 0` | `conditional` |
|
||||
| `conditional > 0` | `conditional` |
|
||||
| `soft_mismatch > 0` | `conditional` |
|
||||
| 其余 | `recommended` |
|
||||
|
||||
**关系 `risk_level`**:`not_recommended` → high;`conditional` → medium;`recommended` → low。
|
||||
|
||||
**模板串**:
|
||||
- `difference_summary` = 「共 N 项核心参数:x 一致 / y 不一致(硬 z)/ u 待确认 / c 有条件」
|
||||
- `recommendation_reason` = 按等级三形:点名硬约束不符的参数 / 点名待确认与有条件的项 / 「N 项全部一致,无待确认项」
|
||||
- `verification_requirement` = 「实测并回填:<UNKNOWN 与 CONDITIONAL 的参数名,顿号连>」
|
||||
- P-06 `test_condition` = 同上参数名列表(没有就写「无」);`next_validation_action` =
|
||||
「实测上述参数并回填 P-08 标准化参数集」/「本轮无待验项」
|
||||
|
||||
---
|
||||
|
||||
## 4. 四步案例模型
|
||||
|
||||
进度条四格:**校验入参 → 差异分析 → 结论审核 → 落账放行**。
|
||||
全程 `guide: ''`、`tools: []`、`declarations.tools: []`。
|
||||
|
||||
### 4.1 启动参数(运营中心活动详情一键启动)
|
||||
|
||||
两格,**都选填**:
|
||||
|
||||
- `originalInternalPn`「原内部料号」—— `x-control: combo`,`x-options` 从 `parts.profile.internal_pn` 现读;可选库里的,也可自己写一个新的
|
||||
- `candidateInternalPn`「候选内部料号」—— 同款
|
||||
|
||||
### 4.2 S1 校验入参与补数据 —— 纯系统 +(条件)一张数据工单
|
||||
|
||||
```
|
||||
let evalRef = cat('W05-', fmt(case:openedAt, digits))
|
||||
|
||||
① project sub_eval.part from rows[{pn:入参原PN, role:'original'}, {pn:入参候选PN, role:'candidate'}]
|
||||
eval_ref = {in:'evalRef'};internal_pn = {col:'pn'};role = {col:'role'}
|
||||
exists_status = when lookup('parts.profile' by internal_pn={col:'pn'} take internal_pn) != ''
|
||||
then 'found' else 'absent'
|
||||
chip_type_id = lookup('parts.profile' by internal_pn={col:'pn'} take chip_type_id, else '')
|
||||
|
||||
② project parts.profile prune from each('mine:sub_eval.part') ← **两颗料都铺**
|
||||
internal_pn = {col:'internal_pn'}
|
||||
chip_type_id / brand_name / lifecycle_status / specification_description /
|
||||
manufacturer_name / datasheet_ref / datasheet_version
|
||||
= lookup('parts.profile' by internal_pn={col:'internal_pn'} take 同名列, else '') ← 回填现值
|
||||
↑ 与③同构。在库那颗回填出它此刻的样子;要建那颗全空等人填。
|
||||
**「在库的老料还没归类型」于是也被拦住** —— 那一格回填出来是空的,而 chip_type_id 必填。
|
||||
人不必在件上「加一行」:要建哪颗料是启动参数就定死的事,行由系统摆好,他只填格子。
|
||||
|
||||
③ project parts.parameter prune
|
||||
from each('mine:sub_eval.part') ← **两颗料都铺,不只是缺库那颗**
|
||||
then each('parts.type_param' where type_id={outer:'chip_type_id'})
|
||||
internal_pn = {outer:'internal_pn'};param_name / param_unit ← {col:…}
|
||||
param_value = lookup('parts.parameter' by internal_pn={outer:'internal_pn'},
|
||||
param_name={col:'param_name'} take param_value, else '') ← **回填现值**
|
||||
param_note = 同上 take param_note
|
||||
↑ 三件事一笔办完:
|
||||
· 人还没挑类型 → {outer:'chip_type_id'} 是空串,where 一行不命中,什么也不铺;
|
||||
他挑了、按了保存,行就出来 —— **「保存即铺格」不是特判,是这一笔的自然结果**;
|
||||
· 在库那颗料**也**铺,值回填现值 → 屏上是它此刻的样子,`_missing` 不会误报;
|
||||
· **「在库但参数没录全」于是自动被拦住** —— 那一格回填出来是空的,红带点名、闸按不下。
|
||||
|
||||
④ project sub_eval.case from once
|
||||
eval_ref = {in:'evalRef'}
|
||||
chip_type_id = lookup('mine:sub_eval.part' by role='original' take chip_type_id, else '')
|
||||
gate_reason = when 链(**全读基座数**):
|
||||
两颗 PN 有一格为空 → 「原 / 候选料号还没填」
|
||||
sub_eval_part_exists_status_absent > 0 → 「<PN> 库里没有,请在下面建」
|
||||
sub_eval_part_distinct_chip_type_id > 1 → 「两颗料类型不一致,换一颗或把类型改对」
|
||||
parts_profile_missing + parts_parameter_missing > 0 → 「还差 n 格没填」
|
||||
其余 → ''
|
||||
↑ **这一笔必须排在②③之后,不许提前**(2026-08-29 走查实测):`gate_reason` 是**摆给人看的那句解释**,
|
||||
而它引的 `parts_profile_missing` / `parts_parameter_missing` 要等②③把骨架行铺出来才有值。
|
||||
排在前面时,引擎在②③写入**之前**给它读到 0 —— 卡照样正确发出(`card.when` 排在更后面,读到的是真缺口),
|
||||
但卡上那句「还差什么」是**空白的**,恰在人最想要一句解释的时候。
|
||||
**一笔读的是另一笔写出来的数,它就得排在那一笔后面。**
|
||||
|
||||
⑤ card 执行工单 · variant data · view 'input-supplement' when gate_reason != ''
|
||||
gateLedgers = [parts.chip_type, parts.type_param, parts.profile, parts.parameter]
|
||||
|
||||
⑥ say
|
||||
|
||||
doneWhen sub_eval_part_exists_status_found = 2
|
||||
and sub_eval_part_missing = 0 ← 两行的 chip_type_id 都有值
|
||||
and sub_eval_part_distinct_chip_type_id = 1 ← 且是同一个类型
|
||||
and parts_profile_missing = 0
|
||||
and parts_parameter_missing = 0
|
||||
```
|
||||
|
||||
**`doneWhen` 里没有「行数 = 字段数×2」这条对账,是故意的**:参数行由 `prune` + `each 字典`
|
||||
铺出来,**人不加行不删行**,少一行在结构上不可能发生(真发生就是配方 bug,归静态审与包内自测)。
|
||||
而「本类型有几个字段」根本不是基座数得出来的数 —— `parts_type_param_rows` 数的是**本单写下的**
|
||||
那几行(人新建的字段),不是整本字典。**给判据编一个数不出来的数,是这个仓踩过六次的同一个坑。**
|
||||
|
||||
- **两颗都在库、类型一致、参数齐 → 一张卡都不发**,首拍 `doneWhen` 即真,直接进 S2。
|
||||
- 人在件上按「保存」→ 系统 tick → 配方**原样重跑**:类型这会儿知道了,④把参数行铺出来;
|
||||
⑤因为卡发过(`worldTouched`)不再发;件就地刷新。
|
||||
**幂等靠同 PK 覆盖 + 发过不再发 + `prune`**,不靠谁记得住。
|
||||
- 人点「提交」= 数据工单确认 → **`chip_type` / `type_param` / `profile` / `parameter` 当场进正式表**
|
||||
(平台 2026-08-27 的范围放行)。它们是主数据,该立刻可用,不等整单放行。
|
||||
|
||||
> **2026-08-30 补记**:实现是**八笔**,不是上面写的六笔 ——「提交」那一句已经点出原因:
|
||||
> `chip_type` / `type_param` 要跟着数据工单一起进正式表(范围放行只并 `gateLedgers`
|
||||
> 名单上的表,见 `docs/debt.md`「范围放行没有对 `refersTo` 做传递闭包」),而它们此刻
|
||||
> 只在人新建类型时才有草稿行——挑一个已有类型时天然零行,闸拿「零行」误判成没干完。
|
||||
> Ruling H35(e2e 真跑撞见)在②③之间插了两笔**自投影**(原样把选中的类型字典行 / 字段
|
||||
> 清单行抄回自己身上,值不变、幂等,唯一目的是让 `mine:` 不再天然为空),使这两张表
|
||||
> 能安全回到 `gateLedgers` 上。次序变成:①两颗料 → ②类型字典自投 → ③字段清单自投 →
|
||||
> ④主数据骨架 → ⑤参数骨架 → ⑥案卷 → ⑦卡 → ⑧交代(与上面①③④⑤⑥对应,原④⑤⑥ 顺移成
|
||||
> ⑥⑦⑧)。这两笔**不带 `prune`**(与③节 §11.4 那条改口同一个理由:字典/字段清单是共享
|
||||
> 的,历史选型的陈旧行留着不算错)。详见 `case-model.ts` 该两笔头上的大注释。
|
||||
|
||||
### 4.3 S2 差异分析 —— 纯系统 + AI 唯一一次出场
|
||||
|
||||
```
|
||||
① analyze model: deepseek-v4-flash into 'result:items'
|
||||
input from each('parts.type_param' where type_id=本单类型):
|
||||
{ 字段名, 字段说明, 约束(硬/软), 单位,
|
||||
原PN值 = lookup('parts.parameter' by internal_pn=原PN, param_name=col),
|
||||
原PN值说明 = 同上 take param_note,
|
||||
候选值 = lookup('parts.parameter' by internal_pn=候选PN, param_name=col),
|
||||
候选值说明 = 同上 take param_note }
|
||||
output schema { items: [{ param_name, difference_status, impact_statement,
|
||||
evidence_note, unknown_disposition? }] }
|
||||
|
||||
② project substitution.difference_item prune
|
||||
from each('parts.type_param' where type_id=本单类型) ← **行由字典决定,不由 AI 决定**
|
||||
parameter_name / parameter_desc / constraint_type / parameter_unit ← {col:…}
|
||||
original_value / candidate_value ← lookup('parts.parameter' …)
|
||||
difference_status / impact_statement / evidence_note / unknown_disposition
|
||||
← lookup('result:items' by param_name={col:'param_name'}, else '')
|
||||
judgement_class = when 链(见 §3.5);risk_level 由它映射
|
||||
|
||||
③ project substitution.relation from once
|
||||
recommendation_level / risk_level / difference_summary /
|
||||
recommendation_reason / verification_requirement ← §3.5 规则与模板
|
||||
|
||||
④ project validation.record from once
|
||||
|
||||
⑤ say
|
||||
|
||||
doneWhen substitution_difference_item_rows > 0
|
||||
and substitution_difference_item_missing = 0
|
||||
and substitution_relation_rows = 1
|
||||
and validation_record_rows = 1
|
||||
```
|
||||
|
||||
> **为什么不写 `= 本类型字段数`**(这份 spec 早先的伪码里写过,是错的):
|
||||
> 「本类型有几个字段」**不是基座数得出来的数** —— `parts_type_param_rows` 数的是**本单草稿写下的**
|
||||
> 字段行(人新建类型时加的那几个),不是整本字典。拿它当分母就是「编一个数不出来的数」,
|
||||
> 本仓在这上面栽过六次。行数由 `prune` + `each 字典` 结构保证;判据只查「写了」与「不缺」。
|
||||
|
||||
**这一步一张卡都不发,人不出现。** 三处刻意的克制:
|
||||
|
||||
- **AI 决定不了「有几行」。** 明细的行由类型字典 `each` 出来,AI 的出参只是拿 `param_name` 去
|
||||
`lookup` 的一张查表;它漏了哪一条,那一行的四态就是空 —— `required` 立刻把它变成
|
||||
`_missing > 0`,提交闸与放行门当场拦住。**从前是 `from each(AI 出参)`,AI 少给两条就少两行,
|
||||
而每一行看着都合规。**
|
||||
- AI 只答「这一条算什么、为什么」,**值是多少一个字都不重述** —— 基座读得到的事实不让模型转述。
|
||||
- 推荐等级、风险、摘要、推荐理由**全由规则与模板拼**。永远不会出现「有硬约束不符却写着推荐」。
|
||||
- 出参不合 schema → **就地重问一次**;第二次仍不成 → 留痕交人,不退回模型自由发挥。
|
||||
|
||||
### 4.4 S3 结论审核 —— 一张验收工单,人就地改
|
||||
|
||||
**2026-08-30 改口**:这一节写下时以为配方只有一笔(`card`),下一段自己就说漏了 ——
|
||||
真正的配方是**五笔**:①差异明细**重算笔**(2026-08-30 task-12「结论纹丝不动」缺陷补的,
|
||||
见下)→ ②③ 复制自 S2 的 `RELATION_PROJECTION`/`VALIDATION_PROJECTION`(同一个对象引用,
|
||||
不是抄了两份)→ ④ `card` 审核卡(`variant: 'review'`,件 `difference-review`,**单一
|
||||
选项「确认通过」**,带人工验收清单四条)→ ⑤ `say`。
|
||||
|
||||
人在件上改四态 / 影响 / 依据 → 保存 → tick → 配方重跑 → **①重算笔**先把人改的
|
||||
`difference_status` 转译成 `judgement_class`/`risk_level`(这一步的存在理由:走查发现⑤
|
||||
逮到的真实缺陷——最初的设计只复制了②③,人改的四态没有任何一笔把它转成
|
||||
`judgement_class`,②③读到的 tally 纹丝不动,人在件上改了却什么都没变;`docs/debt.md`
|
||||
已记「S3 存在的全部意义就是让人纠正 AI;纠正流不下去,S3 就是摆设」)→ ②③把推荐等级、
|
||||
摘要、风险**重算一遍**(同 PK 覆盖)→ 件刷新。改几次都行,**不重跑 AI**。
|
||||
|
||||
> ②③ 复制的是 S2 那一步配方**同一个对象引用**(纯投影、幂等),不去回退步骤 —— S2 已
|
||||
> 收步,S3 不能跨步回跳,只能在自己的配方里再摆一份同样的投影逻辑。这是刻意的:
|
||||
> 一步一份配方,判定分类/风险等级的 `when` 链只许有一处真源(共用引用而非各写一份)。
|
||||
|
||||
这一步**不给 `doneWhen`**(它触达世界),人点了「确认通过」即收步。
|
||||
|
||||
### 4.5 S4 落账放行 —— 纯系统派单
|
||||
|
||||
```
|
||||
① project sub_eval.case.summary from once ← 模板串
|
||||
② submit view 'landing-confirm' + 人工验收清单五条
|
||||
```
|
||||
|
||||
人签 → 合并 `substitution.relation` / `difference_item` / `validation.record` / `sub_eval.case`
|
||||
进正式表 → 发口 `assessment-released`。
|
||||
|
||||
**出口载荷**:`evalRef` · `relationId` · `originalPn` · `candidatePn` · `recommendationLevel` ·
|
||||
`matchTotal` / `mismatchTotal` / `unknownTotal` / `conditionalTotal` · `validationId`。
|
||||
|
||||
### 4.6 预算
|
||||
|
||||
调人 3 次(补数据 / 审核 / 放行),cap **8**;模型调用 **1** 次,tokens **30 万**;
|
||||
步数 **40**(保存一次 = 一拍,给人反复保存留余量);`activeMinutes` 60。
|
||||
对照现版:90 步 / 200 万 token / 16 次调人。
|
||||
|
||||
---
|
||||
|
||||
## 5. 平台要补的能力
|
||||
|
||||
全是**加法**。E04 / I01 一个字不用改 —— 有 `guide` 的步照常问模型。
|
||||
|
||||
### 5.1 `PlanStep.auto` 配方 + 求值器
|
||||
|
||||
契约照 ADR-014 §4 的形状,**只实现这几式**:
|
||||
|
||||
- **动词**:`project` · `card` · `analyze` · `submit` · `say`
|
||||
- **表达式**:常量 · `{in:入参|基座读数|let}` · `{col:本行列}` / `{outer:外层行列}` ·
|
||||
`{lookup:地址, by, take, else}` · `{when:S-FEEL, then, else}` · `{cat:[…]}` · `{fmt:…, as:'digits'}`
|
||||
—— `lookup` 的地址除了 `域.对象`(任务视图)/ `mine:域.对象`,还能是**拍内作用域的数组** `result:xxx`
|
||||
(`analyze` 的出参当查表用,S2 的明细就靠这一条)
|
||||
- **数据源**:`{once}` · `{rows:[…]}` · `{each:…, where?, then?{each:…}}`(**两层封顶**)
|
||||
- **地址只有四种**,配方里出现的每一处都得是其中之一:
|
||||
- **`域.对象`** —— **任务视图 = 正式表 + 本单草稿叠加**。这是配方默认该用的那一种。
|
||||
(它不是新东西:`engines.ts` 的 `taskData.view` 早就把 `ledger.taskView` 的结果铺在这个键上。
|
||||
**这一点是这套设计的命门** —— 人在件上新建的料与字段,按保存那一刻只在草稿层;
|
||||
拿纯正式表去看永远看不见它们,参数格就永远铺不出来。)
|
||||
- `mine:域.对象` —— 只有本单草稿写下的行。**「哪几颗料是这一单要办的」只有它答得出**
|
||||
- `in:入参数组` —— 起单入参里的数组
|
||||
- `result:xxx` —— 本拍 `analyze` 的出参
|
||||
**没有「纯正式表」这一种**,配方不需要它:要区分「本单写的」与「别人放行的」,用 `mine:` 就够了。
|
||||
- **`project` 加 `prune: true`**:这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。
|
||||
只动本单草稿层,正式表一个字不碰。(换类型时上一个类型铺的参数行必须消失。)
|
||||
|
||||
求值器是纯函数(域层、零 IO);执行器在 usecase,拿 ledger / humanTool / llm 端口。
|
||||
|
||||
**必须写死的一条**:每一笔 `do` 执行**之前**刷新基座读数。S2 的第③笔要数第②笔刚写下的行 ——
|
||||
不刷新就恒为 0,等级永远算成「推荐」。
|
||||
|
||||
### 5.2 `analyze` 动词 —— 声明式 AI 调用
|
||||
|
||||
入参由声明组装、出参给 JSON schema、结果落进拍内作用域 `result:*` 供后面的 `project` 消费。
|
||||
走现有模型网关(四元绑定、凭证隔离、录制回放、用量计入预算),**不给工具、不给记忆、不多轮**:
|
||||
一次 generate,结构化出参。出参不合 schema → 就地重问一次 → 仍不成 → 留痕交人。
|
||||
|
||||
### 5.3 视图绑定加一件:`data-options`
|
||||
|
||||
`data-options="parts.chip_type"` + `data-option-value="type_id"` + `data-option-label="type_name"`
|
||||
→ 渲染时把 `<option>` 铺进那个 `<select>`,与 `data-rows` 同一层机制:**只取值,不判断**。
|
||||
地址写法与 `data-rows` 一致(`域.对象` = 任务视图,`mine:域.对象` = 本单草稿)。
|
||||
|
||||
只读那一份**不用新东西** —— 走已有的 `data-by` 查表
|
||||
(`<span data-rows="parts.chip_type" data-by="type_id=chip_type_id">` 摆 `type_name`)。
|
||||
「枚举列上下两态得说同一个字」那条账因此不会重演:上面是 label,下面也是 label。
|
||||
|
||||
### 5.4 件上「保存」之后 tick 一次
|
||||
|
||||
保存路由落草稿后调 `TaskService.wake`(现在只有两条回传路由调它)。
|
||||
|
||||
**护栏:有未答的卡在场时,配方只跑 `project` / `say`,不发新卡、不 submit、不 analyze。**
|
||||
—— 只算、只写。
|
||||
|
||||
### 5.5 审核卡放宽到单选项 + 卡答复即收步
|
||||
|
||||
- 决策卡「少于两个选项拒发」对 `variant: 'review'` 放宽到 1。
|
||||
- auto 步发过的卡被答复、且这一步没有 `doneWhen` → **收步**。
|
||||
人已经签了,收步是照实记录,不是替人签字 ——「人工激活项不给 `doneWhen`」那条纪律
|
||||
防的是引擎抢在人前面,这里人在前面。
|
||||
|
||||
### 5.6 `submit` 动词
|
||||
|
||||
ADR-014 §8 原写「不 submit、不发决策卡」。**那是假设还有 agent 在场时的保守话**;
|
||||
这个活动全程无 agent,没有别人来干这两件事,不开口最后两步就没人做。
|
||||
|
||||
护栏:`submit` 只许出现在声明了 `acceptanceChecklist` 的那一步;提交门(判据全绿)照旧把关 ——
|
||||
**系统提交不比模型提交宽松**。
|
||||
|
||||
### 5.7 静态审在装包时把住
|
||||
|
||||
与 `refersTo` 目标检查同款:
|
||||
|
||||
- `into` / `each` / `lookup` 的台账地址必须在 `declarations.ledger` 里(与写的授权面同一份名单)
|
||||
- `fields` 的列名必须在该对象的列声明里
|
||||
- `when` 的 S-FEEL 必须能解析、引用的名字绑得上(`boundNames`,已有)
|
||||
- `card` / `submit` 的 `view` 必须在 `viewTemplates` 里
|
||||
- `analyze` 的出参 schema 必须是对象,`into` 指向的键必须被后面某一笔消费
|
||||
- **没实现的表达式形式当场拒** —— 说好「只做真用得到的那几式」,静态审就是这句话的执行人
|
||||
|
||||
### 5.8 引擎拍内次序
|
||||
|
||||
```
|
||||
tick
|
||||
├─ ① 拍首自动收步(doneWhen 为真的项;人工激活项需 worldTouched)
|
||||
├─ ② 算 current
|
||||
├─ ③ current.auto → 跑配方(每一笔之前刷新基座读数)
|
||||
│ · 有未答卡在场 → 只跑 project / say
|
||||
│ · 抛错 → 留痕 + 交人(不重试、不退回模型)
|
||||
├─ ④ 发了卡 / submit → 收拍(零 token)
|
||||
├─ ⑤ 没发卡 → 重求 doneWhen;真了收步,回 ②(封顶 8 轮)
|
||||
├─ ⑥ 这一步有 guide → 照常问模型(老活动走这条)
|
||||
└─ ⑦ 无 guide、doneWhen 不成立、也没有卡在等 → 交人(配方没做到位,是包的 bug)
|
||||
```
|
||||
|
||||
### 5.9 观测
|
||||
|
||||
配方每一笔进 `sys.task_memory`,`detail.auto = '<步名>#<第几笔>'`,工作日志归**系统灰**;
|
||||
`analyze` 那一笔**也**归系统灰(2026-08-29 改口)—— 那一行说的是「系统把 N 条参数交给模型、
|
||||
拿回 M 条结论」,**发起与落表都是系统做的**,模型只提供了其中的内容;内容的归属在它落下的
|
||||
台账行上看得见,不必让日志行冒充。归属规则保持单一(`detail.auto` → 系统),不必为一个动词开例外。
|
||||
(实测踩过的病根:`kind: 'result'` 不在工作日志的骨架集合里,带 `internal` 的又会被
|
||||
`machinery()` 再滤一道 —— 配方留痕能不能被看见,靠的是「kind 选对 + 不标 internal」,
|
||||
没有机制保证。)治理台的活动详情能把「这一步系统会做什么」直接摆出来 ——
|
||||
配方是数据,渲染得出来。
|
||||
|
||||
---
|
||||
|
||||
## 6. 三张件 + 一张看板
|
||||
|
||||
### 6.1 `input-supplement` · 评估输入补全(数据工单)
|
||||
|
||||
三块竖排,一张贯穿(在办可编辑 ↔ 办结 `finalHtml` 静态归档)。
|
||||
|
||||
1. **要评估什么** —— 原 PN / 候选 PN 两行(行由系统铺,人不加行)。在库的挂品牌与类型徽;
|
||||
不在库的那一行摊开成可填的主数据格(类型下拉 / 品牌 / 生命周期 / 规格描述…)。
|
||||
顶上一条红带说清 `gate_reason`。
|
||||
2. **芯片类型** —— 一个下拉(`data-options` 从 `parts.chip_type` 现读)+ 下面
|
||||
「这个类型的核心参数字段」表。选已有类型 → 表只读回显;**+新建类型** →
|
||||
类型表加一行(`type_id` 空着由基座续号),字段表加行(字段名 / 说明 / 硬软 / 单位)。
|
||||
3. **参数值** —— 左右两栏(原 PN / 候选 PN),一字段一行,**两栏都可填**:在库那颗回填现值
|
||||
(它的参数当年没录全的话,那一格就是空的,照样点名要填),要建那颗全是空格。
|
||||
行由系统铺,**人不加行不删行**。
|
||||
|
||||
底下编辑闸 + 缺项红带,与提交闸、S1 的 `doneWhen` **同一份数**。
|
||||
|
||||
### 6.2 `difference-review` · 差异评估结论审核(验收工单)
|
||||
|
||||
- **顶**:对决头(原 PN → 候选 PN、类型徽)+ 四态分布带 + **推荐等级大徽章**
|
||||
(规则算的,人改四态它当场跟着变)。
|
||||
- **中**:逐条差异明细表,一行一参数 —— 字段名 · 说明 · 硬/软徽 · 原值 · 候选值 · 单位 ·
|
||||
**四态(下拉,可改)** · **影响说明(可改)** · **判断依据(可改)** ·
|
||||
UNKNOWN 去向(判 UNKNOWN 才必填)。
|
||||
- **下**:推荐理由 / 差异摘要 / 风险 / 验证要求 —— **只读**。
|
||||
它们是系统算出来的,**要改就去改上面那一条四态** —— 让人直接改摘要,
|
||||
等于让他给自己没做的判断盖章。
|
||||
|
||||
### 6.3 `landing-confirm` · 落账确认(终局验收工单)
|
||||
|
||||
全只读,答「这一签下去发生什么」:进哪几张表各几行(摆人读名)· 结论卡(等级 + 一句话)·
|
||||
四态分布,硬约束不符的**逐条点名** · 待验项清单(P-06 的测试条件与后续动作)· 发哪一口。
|
||||
|
||||
### 6.4 运营中心看板 `activity-ledger`
|
||||
|
||||
重绑到新表(最近一次已放行实例:案卷 → 两颗料 → 类型与字段 → 参数对照 → 明细 → 关系 → P-06)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 判据与人工验收清单
|
||||
|
||||
**机检(全部基座数)**
|
||||
|
||||
| key | level | 谓词大意 |
|
||||
|---|---|---|
|
||||
| `input-ready` | L1 | 两颗都在库 · 类型一致 · 主数据与参数值都不缺必填(**不写**「参数行数 = 字段数×2」——2026-08-30 与实现一起改口:那不是基座数得出来的数,§4.2 doneWhen 尾注已经讲过同一条道理,这里当时漏改) |
|
||||
| `every-param-judged` | L2 | `substitution_difference_item_missing = 0` 且四态四档计数之和 = `_rows`(AI 漏一条 → 那行四态为空 → 两边都不成立) |
|
||||
| `level-by-rule` | L2 | 推荐等级与 `judgement_class` 计数一致(硬不符>0 必须是「不推荐」…) |
|
||||
| `unknown-has-disposition` | L2 | `substitution_difference_item_missing = 0` |
|
||||
| `validation-landed` | L2 | P-06 一行且 `_missing = 0` |
|
||||
| `evidence-credible` | L4 | 影响说明与判断依据站得住(裁量) |
|
||||
| `values-traceable` | L4 | 参数值与原厂资料对得上(裁量) |
|
||||
| `gate03-release` | L5 | 责任关,不上卡 |
|
||||
|
||||
**人工验收清单**
|
||||
|
||||
- S3 审核卡四条:逐条对过原值与候选值 / 硬约束项逐条看过 / UNKNOWN 都写了去向 / 影响说明能站住
|
||||
- S4 放行卡五条:等级与四态一致 / 待验项清单认可 / 落账内容核对过 / 参数值来源可追 / 签了就发给下游
|
||||
|
||||
---
|
||||
|
||||
## 8. 包与退役
|
||||
|
||||
**原地重写 `part-substitution-assessment@2.0.0`**。跟着退役的:
|
||||
|
||||
- 两枚工具 `compare-parameters` / `recommend`(全仓只有 W05 用)—— 注册表停用,留墓志铭;
|
||||
- **判定表 D01 退役**(四态改 AI 判);**D02 的规则搬进配方的 `when` 链后 DMN 也退役** ——
|
||||
规则只许有一处真源,再留一份 DMN 就是同一件事两处各存一份;
|
||||
- 说明书 `NARRATIVE` 瘦成「这个活动是干什么的」(全程无 `guide`,它不再进提示词;
|
||||
`analyze` 的提示词是那个动词自己的字段);
|
||||
- 现有五张视图模版全部作废,重做三张 + 看板重绑;
|
||||
- `sub_eval.pn_check` / `input_gap` / `verdict` 三张单据退役,由 `sub_eval.case` 一张顶掉。
|
||||
|
||||
**样例集五条**(名 ≤14 字,与 e2e 案例一一对应):
|
||||
「都在库·零卡直分析」「候选缺库·类型已有」「候选缺库·新建类型」「类型不一致·硬拦」「两颗都缺库」
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试与验收线
|
||||
|
||||
- **包内自测**:配方每一笔的口径钉死(字段齐全 / `when` 链 / 模板串 / 静态审能过 /
|
||||
说明书不出现具体料号与参数名)。
|
||||
- **平台侧**:求值器纯函数测(七式表达式 × 三种数据源 × 两层嵌套 × `prune`)·
|
||||
`analyze` 出参不合 schema 就地重问 · 卡答复即收步 · 保存后 tick ·
|
||||
`data-options` 渲染 · 未答卡在场时不发第二张卡 · `submit` 只许在有验收清单的步 ·
|
||||
静态审逐条拒。
|
||||
- **e2e `w05` 五案例**,验收线:**5/5 released · 配方零出错 · 提交闸每单真拦 · 模型调用恰 1 次**。
|
||||
(「行动被拒 0」这条对无 agent 的活动自动成立,改数**模型调用次数**。)
|
||||
- **浏览器走查**:「候选缺库·新建类型」那条最长的路走完(新建类型 → 加字段 → 保存铺格 →
|
||||
填值 → 提交 → 结论审核就地改 → 确认 → 落账放行 → released)。
|
||||
- **五道门禁**以退出码验:`pnpm gates`。契约变更走四步(depcruise 反查 → CONTRACT.md →
|
||||
`pnpm api:check --update && git add api-report`)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 迁移次序
|
||||
|
||||
1. **契约与求值器**:`PlanStep.auto`、五动词、七式表达式、`prune` —— 纯函数先行,单测钉死。
|
||||
2. **引擎接线**:拍内次序 §5.8、留痕、观测;拿一个**最小假活动**(两步:project + card)验证机制。
|
||||
3. **`analyze` 动词**:走模型网关,schema 重问,用量计预算。
|
||||
4. **平台三件小的**:`data-options` 渲染、保存后 tick、审核卡单选项 + 卡答复即收步、`submit` 动词。
|
||||
5. **静态审**:扩到配方,把没实现的形状拒死。
|
||||
6. **数据模型**:两张新表 + `chip_type_id` 由声明合并物化;种子给库里两颗料建
|
||||
「DC-DC 降压稳压器」+ 核心参数字段,并把 `parts.parameter` 现有行的 `constraint_type`
|
||||
迁进字段表;**再加一颗 LDO 料**(如 `AMS1117-3.3`)+「LDO 线性稳压器」类型,
|
||||
供「类型不一致·硬拦」那条 e2e 用。
|
||||
7. **活动包 2.0.0**:四步配方 + 三张件 + 判据 + 样例。
|
||||
8. **e2e 五案例 + 浏览器走查**;旧 W05 跑出来的运行数据 `reset-runs` 清掉。
|
||||
|
||||
---
|
||||
|
||||
## 11. 风险与代价
|
||||
|
||||
1. **代码错是必然的错,模型错是概率的错。** 配方写错 = 每一单都错。
|
||||
对策:静态审 + 包内自测 + 五案例 e2e。**但必然的错比概率的错好查** —— 这正是把它从模型手里拿走的理由。
|
||||
2. **AI 那一步是唯一的自由面。** 提示词与 schema 是它的全部约束;出参不合 schema 就地重问一次,
|
||||
仍不成交人。值一律 lookup 台账,所以它就算胡说也污染不了数据,只会污染判断 ——
|
||||
而判断正是人在 S3 要核的东西。
|
||||
3. **类型字典会被建脏**(同一类型建两遍、字段名不一致)。件上下拉优先选已有、新建是次选;
|
||||
MVP **不做去重合并**,记 debt。
|
||||
4. **`prune` 会撤本单草稿层的行**(2026-08-30 改口:这一条与实现一起对照过 —— 实际用在**三处**
|
||||
`project`,不是一处:S1 的 `parts.profile`(④ 主数据骨架)/ `parts.parameter`(⑤ 参数骨架)、
|
||||
S2 的 `substitution.difference_item`(②,§4.3)。§4.2/§4.3 的伪码从写下那一刻起就是这三处
|
||||
都带 `prune`,这里当时漏改成了「只一处」。真正的约束不是「只许一处」,是**只许用在
|
||||
「这一笔的数据源就是这张表在本单该有的全部」这一类骨架/明细投影上**——H35 后来新增的
|
||||
`parts.chip_type`/`parts.type_param` 两笔**自投影**(4.2 节之外,原样抄字典行那两笔)
|
||||
就**刻意不带** `prune`:它们写的是共享字典/字段清单,不是这一单独占的骨架,历史选型的
|
||||
陈旧行留着不算错,带 `prune` 反而会在人改选类型后把已经落地的陈旧行撤成死锁——静态审
|
||||
要放行的是「哪一类数据源」,不是「哪一个固定笔数」)。
|
||||
5. **保存一次 = 一拍**。人反复保存十几次会吃步数,预算给到 40。
|
||||
6. **表达力有上限**。上限之外的不要硬塞 —— 退回模型,或者把那一段切成独立的 agent 步。
|
||||
|
||||
---
|
||||
|
||||
## 12. 不做什么
|
||||
|
||||
- **不给 AI 任何工具、不多轮、不给它写台账的口** —— 它只出一份结构化判断
|
||||
- **不做类型的去重与合并**(记 debt)
|
||||
- **不迁 E04 / I01** —— 机制先在一个活动上跑通
|
||||
- **不碰 P-01 需求主记录 / P-10 原厂技术资料 / 客户档案**
|
||||
- **不在治理台上编辑配方** —— 那就成了低代码平台;配方随包走,改配方 = 升版本重装
|
||||
- **不做三层以上的嵌套** —— 两层(类型字段 × 料)是本活动的上限
|
||||
- **不在活动内部再引一套 Mastra** —— 它在流程包编排与模型网关的原位不动
|
||||
+353
-353
@@ -1,472 +1,472 @@
|
||||
# 数据域字段说明 —— ACT-WORK-05 差异评估
|
||||
|
||||
> 对应设计中心 → 数据域「ACT-WORK-05 · 差异评估」分组。字段以活动清单的台账声明为准
|
||||
> (`activities/part-substitution-assessment/src/manifest.ts` 的 `declarations.ledger`);
|
||||
> 主数据六张(`customer.profile` / `parts.profile` / `parts.document` / `demand.request` /
|
||||
> `substitution.relation` + `difference_item` / `validation.record`)的**物理结构**由平台基线
|
||||
> `apps/api/src/app/seed-mvp-data.ts` 的 `MASTER_OBJECTS` 物化(字段名与《SS02-SS03-SS04_Demo
|
||||
> 台账字段细化与统一方案》§4.2 一字不差),声明合并对基线是**纯 add-column**:
|
||||
> 活动只追加自己要用的列与标记(required / tally / refersTo / requiredWhen / distinct),
|
||||
> 不改基线已有列的语义。那份声明同时是**授权面**:没声明的列写不进去,也不会出现在件上、
|
||||
> 判据里、数据域的活动分组里。
|
||||
> (`activities/part-substitution-assessment/src/manifest.ts` 的 `declarations.ledger`)。
|
||||
> **2026-08-29 整体重写**(design spec `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`)
|
||||
> 把九步案例模型砍成四步、全程零 `guide`(行为改由 `PlanStep.auto` 声明式配方接管,ADR-014),
|
||||
> 台账从旧的十张收成**九张**:两张原有的三单据(`sub_eval.pn_check` / `sub_eval.input_gap` /
|
||||
> `sub_eval.verdict`)与三张外域表(`customer.profile` / `demand.request` / `parts.document`)
|
||||
> 一并退役 —— **本活动这次重写不再声明、不再碰这五张表**;它们的存在性核对与录入不再是
|
||||
> 这个活动的事(manifest.ts 头注原话)。老文档(见 git 历史)里那几张表的段落已随重写整篇撤下,
|
||||
> 不是遗漏。
|
||||
>
|
||||
> 九张里有七张(除 `sub_eval.part` / `sub_eval.case` 两张本活动自持的工作台账外)的**物理结构**
|
||||
> 由平台基线 `apps/api/src/app/seed-mvp-data.ts` 的 `MASTER_OBJECTS` 物化,声明合并对基线
|
||||
> 基本是**纯 add-column**:活动只追加自己要用的列与标记(required / expect / tally / refersTo /
|
||||
> requiredWhen / distinct / auto),不改基线已有列的语义。**唯一的例外是 `type_id` 这条身份链路**
|
||||
> (`parts.chip_type.type_id` / `parts.type_param.type_id` / `parts.profile.chip_type_id` /
|
||||
> `sub_eval.part.chip_type_id` / `sub_eval.case.chip_type_id` / `substitution.relation.chip_type_id`
|
||||
> 六列同源同改)——声明把基线原本 `integer` 的这几列**加宽**成了 `text`,理由与 PG 类型转换
|
||||
> 细节见 manifest.ts 该处的大注释与本文「怎么读这份文档」倒数第二条。那份声明同时是
|
||||
> **授权面**:没声明的列写不进去,也不会出现在件上、判据里、数据域的活动分组里。
|
||||
|
||||
## 怎么读这份文档
|
||||
|
||||
- **★ = 重点字段**。每张表挑 5–8 个,在「重点字段」小节里**按重要程度从高到低**排;
|
||||
数据结构表里在列名后打 ★。
|
||||
- **【人填】= 人在活动过程中亲手填、提交后入库的字段**(执行工单 / 数据工单的件上编辑,
|
||||
或数据录入卡)。这类字段**一律进重点**:它们是这一单里唯一不能靠 AI 补出来的事实。
|
||||
- 「写入者」一栏的口径与「约束」一栏的词表,与 `docs/数据域字段说明-E04-I01.md`
|
||||
「怎么读这份文档」是**同一套**,不重复抄一遍:人(件上)/ 人(录入卡)/ AI / 工具 / 基座 /
|
||||
承继;主键 / 自增 / 必填 / 建议填 / 取值域 / 引用 / 去重计数 / 脱敏。
|
||||
requiredWhen 类字段(某一列到某个值才必填)统一写「条件必填 · <字段>=<值> 时」——
|
||||
与 E04-I01 的「发布时必填」是同一种约束机制,ACT-WORK-05 的触发条件不止「发布」一种,
|
||||
所以逐列写清条件,不套用那个专用词。
|
||||
- ACT-WORK-05 比 E04/I01 多两种「写入者」写法,单列在这里:
|
||||
- **AI(据人的意见改)** —— S4 审核决策卡驳回、T_release 打回重做时,人写的是自然语言意见,
|
||||
不是结构化字段值;基座按字面比对定位到哪一行,AI 照意见改那一行的具体列(同 PK 覆盖写),
|
||||
意见本身不落进任何一张台账的某一列。
|
||||
- **AI(抄自 input_gap.supplement)** —— `demand.request` 的 `application_scenario` /
|
||||
`bom_position` 两列,人是在 `sub_eval.input_gap` 的「补充内容」列作答,S1 收口那一拍才由
|
||||
AI 抄进 P-01;人没有直接编辑过 `demand.request` 本身。
|
||||
数据结构表里在列名后打 ★。字段少于 5 个的表(字典、案卷)全部列入重点。
|
||||
- **【人填】= 人在活动过程中亲手填、提交后入库的字段**(数据工单的件上编辑)。这类字段
|
||||
**一律进重点**:它们是这一单里唯一不能靠配方补出来的事实。**S3 审核卡的四态/影响说明/
|
||||
判断依据/UNKNOWN 去向不带这个标记**——那四格的初值是 AI 判出来的,人只是「审核时可以
|
||||
就地改」,不是「唯一不能靠 AI 补出来的事实」;两者含义不同,详见各自表格的写入者说明。
|
||||
- **这个活动的「写入者」词表与 `docs/数据域字段说明-E04-I01.md` 不同,单独在这里定义**——
|
||||
E04/I01 全程多轮 agent 编排,W05 重写后**全程零 `guide`、AI 只在 S2 出场恰好一次**,
|
||||
中间没有「模型每拍决定写什么」这件事,套用旧词表反而说不清楚:
|
||||
- **人(件上)** —— 工作区那份 HTML 上点「编辑」改的格子,保存即落草稿层(与 E04/I01 同义)
|
||||
- **AI** —— 整个活动**唯一一次**模型调用(S2 的 `analyze`)的出参,系统按 `lookup: result:items`
|
||||
原样抄进台账;S3 人可在件上就地改
|
||||
- **配方** —— `auto.do` 里 `project` 动作按声明(遍历 / `lookup` 现值 / `when` 链 / 字面量)
|
||||
算出来的值,**不经模型**,每一拍都会重新求值一遍(幂等,值不变则不产生写入痕迹)
|
||||
- **基座** —— 平台自己生成,不问人也不经模型(自增序号等)
|
||||
- 没有「人(录入卡)」「AI(据人的意见改)」「承继」—— 本活动没有录入卡,也没有
|
||||
「人写自由文本意见 → AI 读意见改具体行」这一步:S3 要改结论,人直接在件上改那一行;
|
||||
T_release 全只读,打回重做要改的只有 `sub_eval.case`(且它也是配方算的,不是人填的)。
|
||||
- 「约束」一栏的词表与 `docs/数据域字段说明-E04-I01.md`「怎么读这份文档」是**同一套**
|
||||
(主键 / 自增 / 必填 / 建议填 / 取值域 / 引用 / 去重计数 / 脱敏),不重复抄一遍。
|
||||
`requiredWhen` 类字段统一写「条件必填 · `<字段>=<值>` 时」。
|
||||
- **`refersTo` 拒得很硬**:值必须在目标表里真实存在,查无此值 `draftWrite` 当场拒
|
||||
(不是留到验收才发现)。装包时静态审核也会核 `refersTo` 目标地址本身是否存在。
|
||||
|
||||
### 一览
|
||||
|
||||
| 全景 | 台账地址 | 人读名 | 性质 | 本活动用到的列 | 人填列 |
|
||||
|---|---|---|---|---|---|
|
||||
| L-02 | `customer.profile` | 客户档案 | 主数据 · 共享 | 4 | 3(录入卡,仅新客户) |
|
||||
| P-08 | `parts.profile` | 芯片主数据 | 主数据 · 共享 | 12 | 12(数据工单件上) |
|
||||
| P-10 | `parts.document` | 原厂技术资料库 | 主数据 · 共享 | 9 | 9(数据工单件上) |
|
||||
| P-01 | `demand.request` | 客户需求主记录 | 主数据 · 共享 | 17 | 0(应用场景 / BOM 位经执行单由 AI 抄入) |
|
||||
| P-11 | `substitution.relation` | 替代关系主记录 | 主数据 · 共享 | 17 | 0(只审不改) |
|
||||
| P-11 子 | `substitution.difference_item` | 差异明细 | 主数据 · 共享 | 15 | 0 |
|
||||
| P-06 | `validation.record` | 芯片验证记录 | 主数据 · 共享 | 8 | 0(只写验证要求) |
|
||||
| — | `sub_eval.pn_check` | 料号存在性核对 | 本活动工作台账 | 6 | 0(决策卡定放弃) |
|
||||
| — | `sub_eval.input_gap` | 评估输入清单 | 本活动工作台账 | 8 | 3(补充内容 / 加行) |
|
||||
| — | `sub_eval.verdict` | 本次评估结论 | 本活动工作台账 | 14 | 0 |
|
||||
| — | `parts.chip_type` | 芯片类型字典 | 主数据 · W05 唯一读写者 | 3 | 2(新建类型时,件上) |
|
||||
| — | `parts.type_param` | 芯片类型核心参数字段 | 主数据 · W05 唯一读写者 | 5 | 5(新建类型 / 追加字段时,件上) |
|
||||
| P-08 | `parts.profile` | 芯片主数据 | 主数据 · W05 唯一读写者 | 8 | 7(在库回填现值 · 缺库从零填,件上) |
|
||||
| P-08 子 | `parts.parameter` | 标准化参数集 | 主数据 · W05 唯一读写者 | 5 | 2(值 · 说明,件上;主键两列虽挂 `data-col` 但由配方铺,人正常操作不改) |
|
||||
| — | `sub_eval.part` | 本单两颗料(存在性与类型核对) | 本活动工作台账 | 6 | 0(配方每拍按入参与 `parts.profile` 现查重算,件上没有它的编辑入口) |
|
||||
| — | `sub_eval.case` | 本单案卷 | 本活动工作台账 | 4 | 0(全部配方算) |
|
||||
| P-11 | `substitution.relation` | 替代关系主记录 | 主数据 · W05 唯一读写者 | 13 | 0(配方按规则算,人不直接改) |
|
||||
| P-11 子 | `substitution.difference_item` | 差异明细 | 主数据 · W05 唯一读写者 | 15 | 4(S3 就地改四态/影响说明/判断依据/UNKNOWN 去向;AI 先判,人可改) |
|
||||
| P-06 | `validation.record` | 芯片验证记录 | 主数据 · W05 唯一读写者 | 7 | 0(配方按四态分布算) |
|
||||
|
||||
九步(0.5 拆两项数据工单):绑定与存在性核对(S0_bind)→(等待数据:S0d_p08_master →
|
||||
S0d_p10_document,人)→ 人补评估输入(S1_input_confirm)→ 标准化对照表(S2_normalize)→
|
||||
逐项评估(S3_assess)→ 人审结论(S4_review,可循环)→ 拟落账(S5_prepare_landing)→
|
||||
人放行(T_release)。四道人环里 S0d_p08_master / S0d_p10_document / S1_input_confirm 三道
|
||||
往台账里写下人亲手填的事实,S4_review / T_release 两道是审核与签字(人写意见,AI 据意见改台账)。
|
||||
**四步**(全 `auto`,零 `guide`,AI 只在 S2 出场恰好一次):
|
||||
`S1_inputs`(核对两颗料在不在库 / 是否归了同一芯片类型 / 参数是否齐,**缺什么才发一张
|
||||
数据工单**——五条样例里「都在库·同型·齐备」那一条走的是零卡直通,不是特例)→
|
||||
`S2_assess`(**整个活动唯一一次模型调用**:逐条核心参数判 MATCH/MISMATCH/UNKNOWN/CONDITIONAL
|
||||
四态;推荐等级 / 风险 / 摘要 / 验证要求全部由配方按规则算,AI 不参与、不改一字)→
|
||||
`S3_review`(结论审核,人工激活,件上就地改四态与三个自由文本格,可循环打回重做)→
|
||||
`T_release`(落账放行,人工激活,全只读,签字后正式落账并发口 `assessment-released`,
|
||||
可循环打回重做)。三道入场哨兵全读基座数(`sub_eval_part_*` / `substitution_*_rows` /
|
||||
`_missing`),**案例模型里一面记账旗都没有**——「机检只读基座数」这条纪律在本仓被违反过
|
||||
六次,W05 重写从设计上就不给它第七次机会。
|
||||
|
||||
---
|
||||
|
||||
## 一、`customer.profile` 客户档案(L-02 · 共享主数据)
|
||||
## 一、`parts.chip_type` 芯片类型字典
|
||||
|
||||
本活动平时**不建这张表**,只在客户不在库(启动入参 `customerCode` 在 `customer.profile`
|
||||
里查无记录)那一刻代行建档职责:S0_bind 发一张数据录入卡,人核对名称与行业区域后确认。
|
||||
除此之外整个活动流程里再不碰这张表。
|
||||
按芯片类型(而不是按料号)标一次「这一类芯片要比哪几个核心参数、每个参数的说明与硬软
|
||||
约束」——同一类型下两颗料从此**结构上**共用同一套参数名与约束档,不必再靠说明书措辞去
|
||||
硬逼「两颗料对齐同一套参数名」(旧设计靠 `parts.parameter.param_name` 自指引用墙撑着,
|
||||
测三次都没拦住,这次直接把墙的存在理由从结构上抹掉了)。件①型下拉里没有合适的,人在
|
||||
`input-supplement` 的件②a 块现造一个;S1 配方选中之后**原样自投影一遍**(不论新建还是
|
||||
挑既有类型都投,幂等、不产生多余写入),保证这一单的草稿层里**恒有这一行**——这既是
|
||||
「保存即铺格」的前提,也是数据工单确认落地时不留悬空引用的前提(见 `docs/活动开发规范.md`
|
||||
§4.6 关于 `gateLedgers` 的那条纪律)。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `customer_code` ★ 主键 —— 值取自启动入参 `customerCode`,AI 不许自编;人在录入卡上核对确认,不是自由填写
|
||||
2. `name` ★【人填】必填 —— 无名档案进不了正式表(与 E04 同一条墙);`customerName` 有给就 AI 预填,人核对
|
||||
3. `industry` ★【人填】建议填 —— 所属行业,录入卡上从零填
|
||||
4. `region` ★【人填】建议填 —— 区域,录入卡上从零填
|
||||
1. `type_id` ★ 主键 · 自增 —— 声明成 `text` 而不是 `integer`(见文档头注),写入时按
|
||||
「现有最大 + 1」续号,不进表单、只读态摆「系统生成」
|
||||
2. `type_name` ★【人填】必填 —— 新建类型时件上现填,已有类型从下拉挑
|
||||
3. `description` ★【人填】建议填 —— 这个类型是什么(供选型参考)
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `customer_code` ★ | 客户编码 | text | 主键 | AI(抄入参 customerCode)/ 人(录入卡确认) | 老客户沿用,不经这张卡 |
|
||||
| `name` ★ | 客户名称 | text | 必填 | AI 预填(`customerName`)/ 人(录入卡) | 没传 `customerName` 就整格空着让人填 |
|
||||
| `industry` ★ | 所属行业 | text | 建议填 | 人(录入卡) | |
|
||||
| `region` ★ | 区域 | text | 建议填 | 人(录入卡) | |
|
||||
| `type_id` ★ | 类型编号 | text | 主键 · 自增 | 基座(续号)| 基线原为 `integer`,声明加宽为 `text`(见头注) |
|
||||
| `type_name` ★ | 类型名称 | text | 必填 | **人(件上)** | 新建类型时填 |
|
||||
| `description` ★ | 类型说明 | text | 建议填 | **人(件上)** | |
|
||||
|
||||
---
|
||||
|
||||
## 二、`parts.profile` 芯片主数据(P-08 · 共享主数据)
|
||||
## 二、`parts.type_param` 芯片类型核心参数字段
|
||||
|
||||
一张表:料号一行,标准化参数集是它的一个字段(一行一参数
|
||||
`参数名 | 值 | 单位 | hard/soft | 说明`,值是「未披露」的进 UNKNOWN)。库里按 `internal_pn`
|
||||
查无记录 → 发**数据工单**,人在工作区的件上从零建整条(可加行);有上传 datasheet 就
|
||||
AI 先按文本预填、人核对,没有就人全填;确认落草稿层,T_release 放行时才与其余表一起并入
|
||||
正式版。
|
||||
一类芯片一套字段清单,复合主键 `type_id + param_name`。新建类型(或给已有类型追加字段)时,
|
||||
人在 `input-supplement` 的件②b 块逐条定下字段名 / 说明 / 硬软约束 / 参考单位;S1 配方同样
|
||||
把选中类型此刻的字段清单**原样自投影一遍**(与 `parts.chip_type` 同一条道理、同一次修正 ——
|
||||
挑已有类型时这张表天然可能零行,不投一遍会被「零行 = 没干完」的闸误判)。**这张表是 S2
|
||||
唯一一次 AI 调用的入参来源**:逐条参数连同 `param_desc`(为什么要紧)一起交给模型判四态。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `internal_pn` ★【人填】主键 —— 全链路只认它,不可自由文本
|
||||
2. `normalized_parameter_set` ★【人填】必填 —— 差异评估逐项比对的唯一输入;「未披露」进 UNKNOWN
|
||||
3. `lifecycle_status` ★【人填】必填 · 取值域 `active/nrnd/eol/discontinued/risk`
|
||||
4. `package` ★【人填】必填 —— 硬约束
|
||||
5. `specification_description` ★【人填】必填
|
||||
6. `brand_name` ★【人填】必填
|
||||
1. `type_id` ★【人填,选择】主键之一 · 引用 `parts.chip_type.type_id`
|
||||
2. `param_name` ★【人填】主键之一 —— 参数名
|
||||
3. `param_desc` ★【人填】必填 —— **AI 分析时读的就是它**:参数名本身说不清「它为什么要紧」,
|
||||
写成参数名的复述等于没写
|
||||
4. `constraint_type` ★【人填,选择】必填 · 取值域 `hard`/`soft` —— 硬约束 = 不满足就不能用
|
||||
(烧芯片 / 超温域 / 装不上);软约束 = 影响表现但可权衡(功耗 / 噪声)
|
||||
5. `param_unit` ★【人填】建议填 —— 参考单位;**S1 铺参数骨架、S2 判定都直接抄它**
|
||||
(不是回填每颗料自己记的单位)——同一类型下两颗料必须按同一个单位比,这是这次比对的基准
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `type_id` ★ | 所属类型 | text | 主键之一 · 引用 `parts.chip_type.type_id` | **人(件上,下拉选)** | 基线原为 `integer`,声明加宽为 `text` |
|
||||
| `param_name` ★ | 参数名 | text | 主键之一 | **人(件上)** | |
|
||||
| `param_desc` ★ | 参数说明 | text | 必填 | **人(件上)** | AI 判定时读它 |
|
||||
| `constraint_type` ★ | 约束类型 | text | 必填 · 取值域 | **人(件上,下拉选)** | 从 `parts.parameter` 搬到这里按类型标一次(旧设计每颗料各标一次,同一参数两颗料标法不一致就成了可能) |
|
||||
| `param_unit` ★ | 参考单位 | text | 建议填 | **人(件上)** | `parts.parameter` 铺骨架 / S2 判定均直接抄它 |
|
||||
|
||||
---
|
||||
|
||||
## 三、`parts.profile` 芯片主数据(P-08)
|
||||
|
||||
一颗料一行。S1 配方按启动参数(`originalInternalPn` / `candidateInternalPn`)给两颗料各铺
|
||||
一行骨架:**在库的那颗回填此刻的现值**(人不必重填,但看得到、能改),**要建的那颗全空**
|
||||
等人填。`chip_type_id` 必填意味着「在库但还没归类型的老料」也会被拦住——那一格回填出来是
|
||||
空的,人得先给它选一个类型。人不必在件上「加一行」:要建哪两颗料是启动参数定死的事,
|
||||
行由配方摆好,他只填格子。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `internal_pn` ★ 主键 —— 全链路只认它;值随两颗料由启动参数定,件上虽挂 `data-col`
|
||||
(行的钥匙)但人正常操作不改它
|
||||
2. `chip_type_id` ★【人填,选择】必填 · 引用 `parts.chip_type.type_id` —— 两颗料**必须**
|
||||
归到同一个类型,`sub_eval.part.chip_type_id` 的 `distinct` 判据钉着这一条
|
||||
3. `brand_name` ★【人填】必填
|
||||
4. `lifecycle_status` ★【人填,选择】必填 · 取值域 `active`/`nrnd`/`eol`/`discontinued`/`risk`
|
||||
5. `manufacturer_name` ★【人填】建议填
|
||||
6. `specification_description` ★【人填】建议填
|
||||
7. `datasheet_ref` / `datasheet_version` ★【人填】建议填(成对)—— 证据回链
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `internal_pn` ★ | 内部料号 | text | 主键 | 人(件上)/ 种子 | 已在库的候选(如样例里预播的 LM2596S-5.0)不经这张单 |
|
||||
| `brand_name` ★ | 品牌名称 | text | 必填 | 人(件上)/ AI 预填 | |
|
||||
| `manufacturer_name` | 制造商名称 | text | 建议填 | 人(件上) | 与品牌不一致时分开记 |
|
||||
| `specification_description` ★ | 规格描述 | text | 必填 | 人(件上)/ AI 预填 | |
|
||||
| `normalized_parameter_set` ★ | 标准化参数集 | text | 必填 | 人(件上)/ AI 预填 | 一行一参数,不建子表 |
|
||||
| `package` ★ | 封装 | text | 必填 | 人(件上)/ AI 预填 | 硬约束 |
|
||||
| `process_technology` | 工艺 | text | 建议填 | 人(件上) | 硬约束 |
|
||||
| `lifecycle_status` ★ | 生命周期状态 | text | 必填 · 取值域 | 人(件上,下拉) | |
|
||||
| `eol_pcn_status` | EOL/PCN 状态 | text | 建议填 | 人(件上) | |
|
||||
| `datasheet_ref` ★ | Datasheet 引用 | text | 建议填 | 人(件上)/ AI 预填 | |
|
||||
| `datasheet_version` ★ | Datasheet 版本 | text | 建议填 | 人(件上)/ AI 预填 | 有引用就该有版本 |
|
||||
| `application_scenarios` | 适配应用场景 | text | 建议填 | 人(件上) | |
|
||||
| `internal_pn` ★ | 内部料号 | text | 主键 | 配方(按入参铺行)| 在库的沿用现值,不在库的等人填别的格子后自然成立 |
|
||||
| `chip_type_id` ★ | 芯片类型 | text | 必填 · 引用 `parts.chip_type.type_id` | 配方(在库回填现值)/ **人(件上,下拉选)** | 基线原为 `integer`,声明加宽为 `text` |
|
||||
| `brand_name` ★ | 品牌名称 | text | 必填 | 配方(在库回填现值)/ **人(件上)** | |
|
||||
| `lifecycle_status` ★ | 生命周期状态 | text | 必填 · 取值域 | 配方(在库回填现值)/ **人(件上,下拉选)** | |
|
||||
| `manufacturer_name` ★ | 制造商名称 | text | 建议填 | 配方(在库回填现值)/ **人(件上)** | |
|
||||
| `specification_description` ★ | 规格描述 | text | 建议填 | 配方(在库回填现值)/ **人(件上)** | |
|
||||
| `datasheet_ref` ★ | Datasheet 引用 | text | 建议填 | 配方(在库回填现值)/ **人(件上)** | |
|
||||
| `datasheet_version` ★ | Datasheet 版本 | text | 建议填 | 配方(在库回填现值)/ **人(件上)** | |
|
||||
|
||||
> 基线物理表里还留着这次重写不再声明的旧列(`customer_pn` / `normalized_parameter_set` /
|
||||
> `package` / `process_technology` / `eol_pcn_status` / `application_scenarios` /
|
||||
> `pin_to_pin_relation_ref`)——那是**同一张表被本活动更早的旧九步版本用过**的痕迹
|
||||
> (封装/工艺这次已经变成按类型标在字典里,不再是主记录上单独一列),不是别的活动在共享它;
|
||||
> add-column 合并不删旧列,记录浏览里看到它们空着是正常的,判断一列还算不算数,以
|
||||
> **当前这份声明**为准。
|
||||
|
||||
---
|
||||
|
||||
## 三、`parts.document` 原厂技术资料库(P-10 · 共享主数据)
|
||||
## 四、`parts.parameter` 标准化参数集(P-08 子表)
|
||||
|
||||
按 `internal_pn` 查无资料记录 → 发**数据工单**(与 P-08 那张分开,一张表一张单,
|
||||
在 P-08 那张确认之后再发);S0d_p08_master 那一步先写骨架(`document_type` /
|
||||
`document_status` / `brand_name` 三格能填就填),人在 P-10 数据工单的件上补齐其余列。
|
||||
同一料号多份资料就多加几行,序号 `01`/`02`… 人自己续(不是自增列)。
|
||||
一颗料一个核心参数一行,复合主键 `internal_pn + param_name`。S1 配方按「这两颗料 ×
|
||||
选中类型的字段清单」铺骨架(`prune:true`,换类型会把不再相关的陈旧参数行清掉):
|
||||
`param_value`/`param_note` 若在库已有记录就回填现值,没有就留空等人填;`param_unit`
|
||||
**永远**从 `parts.type_param` 字典直接复制,不回填每颗料自己记的单位、也不可编辑
|
||||
(与块①/②b 不同,这一列连孪生输入框都不给)——同一类型下两颗料必须按同一个基准单位比,
|
||||
S2 判定用的 `unit` 与件上摆的是同一处来源,不允许分叉。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `document_ref` ★【人填】必填 —— 资料文件链接,差异评估的证据引用要指到它
|
||||
2. `internal_pn` ★【人填】主键之一 —— 关联料号,与 P-08 同一个内部料号
|
||||
3. `doc_seq` ★【人填】主键之一 —— 同一料号下 01/02… 人自己续号
|
||||
4. `document_type` ★【人填】必填 · 取值域 —— `datasheet/app_note/reference_design/white_paper/sample_doc/commitment_letter/other`
|
||||
5. `document_title` ★【人填】必填
|
||||
6. `document_version` ★【人填】必填
|
||||
7. `document_status` ★【人填】必填 · 取值域 `valid/expired/pending/void`
|
||||
8. `citation_locator` ★【人填】建议填 —— 页码 / 章节 / 表格,证据定位精确到这里
|
||||
1. `internal_pn` ★ 主键之一 · 引用 `parts.profile.internal_pn` —— 挂 `data-col` 是因为它是
|
||||
行的钥匙,不是人正常操作会去改的格子
|
||||
2. `param_name` ★ 主键之一 —— 同上,由配方按字典铺,不是人填的自由文本
|
||||
3. `param_value` ★【人填】必填 —— 差异评估逐项比对的唯一输入;原厂规格书查不到就留空,
|
||||
**留空会被红带点名,但编一个值比留空坏得多**(说明书明写的禁令)
|
||||
4. `param_unit` ★ 建议填 —— **恒等于字典值**,配方每拍覆盖,手改也会在下一拍被盖回去
|
||||
5. `param_note` ★【人填】建议填 —— 值的说明
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `internal_pn` ★ | 关联内部料号 | text | 主键之一 | 人(件上) | 与 P-08 那一行同一个键 |
|
||||
| `doc_seq` ★ | 资料序号(同一料号下 01/02…) | text | 主键之一 | 人(件上) | 不是自增列,人自己续号 |
|
||||
| `document_type` ★ | 资料类型 | text | 必填 · 取值域 | AI 预填骨架(S0d_p08_master)/ 人(件上,下拉) | |
|
||||
| `brand_name` | 品牌名称 | text | 建议填 | AI 预填骨架 / 人(件上) | |
|
||||
| `document_title` ★ | 资料标题 | text | 必填 | 人(件上) | |
|
||||
| `document_version` ★ | 资料版本 | text | 必填 | 人(件上) | |
|
||||
| `document_ref` ★ | 资料文件链接 | text | 必填 | 人(件上) | |
|
||||
| `citation_locator` ★ | 证据定位(页码/章节/表格) | text | 建议填 | 人(件上) | |
|
||||
| `document_status` ★ | 资料状态 | text | 必填 · 取值域 | AI 预填骨架(pending)/ 人(件上,下拉) | |
|
||||
| `internal_pn` ★ | 所属料号 | text | 主键之一 · 引用 `parts.profile.internal_pn` | 配方(按字典 × 两颗料铺骨架) | 挂 `data-col` 只为行的钥匙 |
|
||||
| `param_name` ★ | 参数名 | text | 主键之一 | 配方 | 同上 |
|
||||
| `param_value` ★ | 值 | text | 必填 | 配方(在库回填现值)/ **人(件上)** | 查不到留空,不许估、不许拿别的参数的值顶替 |
|
||||
| `param_unit` ★ | 单位 | text | 建议填 | 配方(恒抄 `parts.type_param` 字典) | 只读,连孪生输入框都不给 |
|
||||
| `param_note` ★ | 说明 | text | 建议填 | 配方(在库回填现值)/ **人(件上)** | |
|
||||
|
||||
> 基线物理表里还留着一列这次重写不再声明的 `constraint_type`(旧设计每颗料各标一次约束,
|
||||
> 这次已搬到 `parts.type_param` 按类型标一次)——同一条「add-column 不删旧列」的道理。
|
||||
|
||||
---
|
||||
|
||||
## 四、`demand.request` 客户需求主记录(P-01 · 共享主数据)
|
||||
## 五、`sub_eval.part` 本单两颗料(存在性与类型核对,本活动工作台账)
|
||||
|
||||
S0_bind 新建 V1(客户 / 需求描述 / 原 PN 三项照入参原样落,启动时未收的 `application_scenario`
|
||||
/ `bom_position` 先不写);S1_input_confirm 人在评估输入清单上把这两项的「补充内容」补上,
|
||||
AI 收口那一拍原样抄回本表;S5_prepare_landing 放行前回写风险等级 / 验证要求 / 输出链接 /
|
||||
下一步动作 / 当前阶段五项(`current_stage = "gate03"` 那一刻这五项转为必填)。**本表没有一列
|
||||
是人直接编辑的**——人的手笔经由 `sub_eval.input_gap.supplement` 间接进来。
|
||||
S1 配方**每一拍都重算**的一张表(不是「写一次就不再动」):按启动参数铺两行(`role`
|
||||
恰有 `original`/`candidate` 各一行),`found_pn_ref` 现查 `parts.profile` 是否已有这个
|
||||
`internal_pn`、`exists_status` 据此判 `found`/`absent`、`chip_type_id` 现查在库现值。
|
||||
**这张表没有任何人工编辑入口**——它是「要评估什么」那张件的**行来源**
|
||||
(`data-rows="mine:sub_eval.part" data-edit="parts.profile"`),人在件上改的是
|
||||
`parts.profile`,行会在下一拍被这张表原样重铺。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `request_id` ★ 主键 —— 需求编号,与 `eval_ref` 用同一串时刻数字铸出
|
||||
2. `customer_code` ★ 必填 · 引用 `customer.profile.customer_code`
|
||||
3. `request_description` ★ 必填 —— 客户需求原话,S0 落一次,不许改写
|
||||
4. `original_internal_pn` ★ 必填
|
||||
5. `application_scenario` ★ 建议填 —— 启动时未收,人在评估输入清单补的「补充内容」由 AI 抄进这一格(本表唯一带着人手笔的两列之一)
|
||||
6. `bom_position` ★ 建议填 —— 同上
|
||||
7. `risk_level` ★ 条件必填 · `current_stage=gate03` 时 —— 放行回写五项之一,取各候选风险最高档
|
||||
8. `verification_requirement` ★ 条件必填 · `current_stage=gate03` 时 —— 列出挂的验证任务号
|
||||
1. `exists_status` ★ 必填 · 取值域 `found`/`absent` —— 存在性核对的核心结论,S1 收步与
|
||||
S2 入场哨兵都读它(`sub_eval_part_exists_status_found = 2` 才算「两颗都在库」)
|
||||
2. `internal_pn` ★ 主键之一
|
||||
3. `eval_ref` ★ 主键之一 —— 本单案号,`W05-` + 开案时刻 14 位数字,串联本活动自持的两张
|
||||
工作台账
|
||||
4. `role` ★ 必填 · 取值域 `original`/`candidate`
|
||||
5. `chip_type_id` ★ 必填 · **去重计数**(`distinct`)—— 「两颗料是不是同一个类型」从此是
|
||||
基座数得出来的数(`sub_eval_part_distinct_chip_type_id`),S1 收步与 S2 入场的硬门
|
||||
6. `found_pn_ref` ★ **条件必填**(`exists_status=found` 时)· 引用 `parts.profile.internal_pn` ——
|
||||
「在库」是一次 `lookup` 的结果,不是一句断言;判 `found` 就必须拿得出一个基座查得到
|
||||
的料号
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `request_id` ★ | 需求编号 | text | 主键 | AI | `REQ-` + 案号数字串 |
|
||||
| `customer_code` ★ | 客户编码 | text | 必填 · 引用 `customer.profile.customer_code` | AI | |
|
||||
| `requirement_version` | 需求版本 | text | 必填 | AI | 恒 `V1` |
|
||||
| `request_type` | 需求类型 | text | 必填 | AI | 恒「技术需求」 |
|
||||
| `request_description` ★ | 需求描述 | text | 必填 | AI | 入参原话,不改写 |
|
||||
| `input_snapshot_ref` | 输入快照 | text | 必填 | AI | `SNAP-` + 案号数字串 |
|
||||
| `original_internal_pn` ★ | 原内部料号 | text | 必填 | AI | |
|
||||
| `bom_position` ★ | BOM 位 | text | 建议填 | AI(抄自 `sub_eval.input_gap.supplement`) | 启动时未收 |
|
||||
| `application_scenario` ★ | 应用场景 | text | 建议填 | AI(抄自 `sub_eval.input_gap.supplement`) | 启动时未收 |
|
||||
| `technical_constraints` | 技术约束 | text | — | AI | 人加的 input_gap 行汇总成一句 |
|
||||
| `risk_level` ★ | 风险等级 | text | 条件必填 · `current_stage=gate03` | AI | 取各候选 `risk_level` 最高档 |
|
||||
| `verification_requirement` ★ | 验证/复测/确认要求 | text | 条件必填 · `current_stage=gate03` | AI | 列出所有挂的 VAL 号 |
|
||||
| `request_status` | 需求状态 | text | 必填 · 取值域 | AI | `pending → responded` |
|
||||
| `current_activity` | 当前 Activity | text | 条件必填 · `current_stage=gate03` | AI | 恒 `ACT-WORK-05` |
|
||||
| `current_stage` | 当前阶段 | text | — | AI | `assessing → gate03` |
|
||||
| `next_action` | 下一步动作 | text | 条件必填 · `current_stage=gate03` | AI | 恒「提交 ACT-GATE-03 评审放行」 |
|
||||
| `output_ref` | 标准输出链接 | text | 条件必填 · `current_stage=gate03` | AI | `RPT-` + 案号数字串 |
|
||||
| `eval_ref` ★ | 评估案号 | text | 主键之一 | 配方 | |
|
||||
| `internal_pn` ★ | 内部料号 | text | 主键之一 | 配方(按启动参数铺) | |
|
||||
| `role` ★ | 角色 | text | 必填 · 取值域 | 配方 | `original` 恰一行 / `candidate` 恰一行 |
|
||||
| `found_pn_ref` ★ | 在库核对依据 | text | 条件必填 · `exists_status=found` · 引用 `parts.profile.internal_pn` | 配方(现查 `parts.profile`) | 查无此值 `exists_status` 判 `absent` |
|
||||
| `exists_status` ★ | 在库状态 | text | 必填 · 取值域 `found`/`absent` | 配方 | 每拍现算,不设 `created` 档、不靠谁记账 |
|
||||
| `chip_type_id` ★ | 芯片类型 | text | 必填 · 去重计数 | 配方(现查 `parts.profile`) | 基线原为 `integer`,声明加宽为 `text` |
|
||||
|
||||
---
|
||||
|
||||
## 五、`substitution.relation` 替代关系主记录(P-11 · 共享主数据)
|
||||
## 六、`sub_eval.case` 本单案卷(本活动工作台账)
|
||||
|
||||
一个未放弃的候选一条:S2_normalize 写骨架(不含推荐/风险/摘要)、S3_assess 用两枚工具的
|
||||
出参回填推荐等级与风险(**AI 原样抄,不改一字**)、S4_review 只审不改(人写意见,AI 据
|
||||
意见改具体列)、S5_prepare_landing 把 `analysis_status` 推到 `fae_confirmed`。**本活动没有
|
||||
一列是人直接编辑的** —— 人的动作是通过 S4/T_release 的决策卡意见间接改台账。
|
||||
一单一行的案卷,挂着摆给人看的一句话(`gate_reason`)与收口摘要(`summary`)。**没有一面
|
||||
记账旗**——完成与否全部由别处的基座读数(`sub_eval_part_*` / `substitution_*_rows` /
|
||||
`_missing`)判定,这张表只是「说清此刻是什么状况」的一处落点。由两步接力写:S1 每拍
|
||||
写 `eval_ref`/`chip_type_id`/`gate_reason`(供数据工单卡面的「还差什么」摆),T_release
|
||||
再补一笔 `summary`(供落账确认页的结论卡摘要)。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
**重点字段**
|
||||
|
||||
1. `substitution_relation_id` ★ 主键
|
||||
2. `eval_ref` ★ 必填 —— **本活动新增列(基线没有)**,评估案号,与 `pn_check` / `verdict` 同一个 `eval_ref` 串联全单
|
||||
3. `candidate_internal_pn` ★ 必填 · 引用 `parts.profile.internal_pn`
|
||||
4. `original_internal_pn` ★ 必填 · 引用 `parts.profile.internal_pn`
|
||||
5. `recommendation_level` ★ 必填 · 取值域 `recommended/conditional/alternate/not_recommended` —— 工具算,AI 照抄不改一字
|
||||
6. `analysis_status` ★ 必填 · 取值域 —— 分析层状态,S5 推到 `fae_confirmed`
|
||||
7. `risk_level` ★ 必填 · 取值域 `low/medium/high/critical` —— 工具算
|
||||
8. `rule_version` ★ 必填 —— 随结论落账,锁定 `R-2026.07`
|
||||
1. `eval_ref` ★ 主键 —— `W05-` + 开案时刻 14 位数字(与引擎注给模型铸主键用的
|
||||
`case:openedAt` 同一函数,跨单唯一)
|
||||
2. `gate_reason` ★ —— **摆在数据工单件上给人看的那句话**;`when` 链逐级判断此刻缺什么
|
||||
(两颗料没填全 / 有料号查无记录 / 有料号没归类型 / 两颗料类型不一致 / 还有必填格空着),
|
||||
都不缺时是空串。**这一笔排在铺骨架那两笔(`parts.profile`/`parts.parameter`)之后
|
||||
不是随手写的次序**——它读的 `parts_profile_missing`/`parts_parameter_missing`
|
||||
是骨架刚建出来的基座读数,排前面会读到刷新之前的旧数(详见 `docs/活动开发规范.md`
|
||||
§4.6 新增纪律)
|
||||
3. `chip_type_id` ★ —— 本单锁定的芯片类型(原料那一颗的),供 S2 组装入参时 `lookup`
|
||||
4. `summary` ★ —— T_release 写的收口一句话(`<候选PN> 替代 <原PN>:<差异摘要>`),落账
|
||||
确认页「结论卡」的摘要来源
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `substitution_relation_id` ★ | 替代关系ID | text | 主键 | AI | `REL-<案号数字串>-01/02…` |
|
||||
| `eval_ref` ★ | 评估案号 | text | 必填 | AI | 本活动新增列(基线没有) |
|
||||
| `request_id` | 关联需求编号 | text | 必填 | AI | |
|
||||
| `bom_position` | 关联 BOM 位 | text | 建议填 | AI | 照 `demand.request` 现值抄 |
|
||||
| `original_internal_pn` ★ | 原内部料号 | text | 必填 · 引用 `parts.profile.internal_pn` | AI | |
|
||||
| `candidate_internal_pn` ★ | 候选内部料号 | text | 必填 · 引用 `parts.profile.internal_pn` | AI | |
|
||||
| `substitution_type` | 替代类型 | text | 必填 · 取值域 | AI | `pin_to_pin/functional/parametric/other` |
|
||||
| `recommendation_level` ★ | 推荐等级 | text | 必填 · 取值域 | 工具(`recommend`)→ AI 照抄 | |
|
||||
| `recommendation_reason` | 推荐理由 | text | 必填 | AI(可按人的驳回意见改) | 工具 reason 扩写成人话 |
|
||||
| `difference_summary` | 差异摘要 | text | 必填 | AI | 「7 项比对:4 MATCH / 1 MISMATCH / 1 UNKNOWN / 1 CONDITIONAL」句式 |
|
||||
| `risk_level` ★ | 替代风险等级 | text | 必填 · 取值域 | 工具(`recommend`)→ AI 照抄 | |
|
||||
| `verification_requirement` | 验证要求 | text | 条件必填 · `recommendation_level=conditional` | AI | 列出 VAL 号 |
|
||||
| `validation_id` | 关联验证ID | text | — | AI | |
|
||||
| `analysis_status` ★ | 分析状态 | text | 必填 · 取值域 | AI | `ai_draft → fae_confirmed`(其余三档本活动不写) |
|
||||
| `analysis_report_ref` | 替代分析报告 | text | 建议填 | AI | `RPT-<案号数字串>` |
|
||||
| `confidence_score` | 证据可信度 | number | 建议填 | AI | 取各行 `confidence` 最小值 |
|
||||
| `rule_version` ★ | 差异规则版本 | text | 必填 | AI | 锁定 `R-2026.07` |
|
||||
| `eval_ref` ★ | 评估案号 | text | 主键 | 配方(S1,按开案时刻铸) | |
|
||||
| `chip_type_id` ★ | 本单锁定的芯片类型 | text | — | 配方(S1) | 供 S2 `lookup` 用 |
|
||||
| `gate_reason` ★ | 入参未就绪原因(件上摆的就是它) | text | — | 配方(S1,`when` 链) | 都不缺时是空串;S1 发卡条件读的正是「非空」 |
|
||||
| `summary` ★ | 结论摘要 | text | — | 配方(T_release) | `<候选PN> 替代 <原PN>:<差异摘要>` |
|
||||
|
||||
---
|
||||
|
||||
## 七、`substitution.relation` 替代关系主记录(P-11)
|
||||
|
||||
一单一行(旧设计是「一个未放弃的候选一条」,新设计**一次评估恰一对一候选**,不再有
|
||||
「多候选、部分放弃」)。S2 与 S3 共用**同一份**投影(`RELATION_PROJECTION`):S2 首算,
|
||||
人在 S3 就地改了差异明细的某条四态之后,同一份投影重算一遍——两处各写一份早晚分叉,
|
||||
分叉的那一刻屏上会同时摆着两个互相矛盾的等级。**推荐等级 / 风险 / 摘要 / 理由 / 验证
|
||||
要求全部由规则与模板拼,不让 AI 编**——于是不会出现「有硬约束不符却写着推荐」这种事;
|
||||
人在 S3 改的是差异明细的四态,这一行跟着重算,人**不直接改**这张表本身。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `substitution_relation_id` ★ 主键 —— `SUB-<案号>`
|
||||
2. `eval_ref` ★ 必填 —— **本活动新增列(基线没有)**,串联本单
|
||||
3. `recommendation_level` ★ 必填 · 取值域 `recommended`/`conditional`/`not_recommended`
|
||||
(旧设计还有 `alternate` 档,新设计的判定分类三分,这一档随之退役)—— 配方按 §3.5 的
|
||||
规则算:有硬约束不符必 `not_recommended`,否则有 UNKNOWN/CONDITIONAL/软约束不符任一
|
||||
即 `conditional`,全一致才 `recommended`
|
||||
4. `original_internal_pn` / `candidate_internal_pn` ★ 必填 · 引用 `parts.profile.internal_pn`
|
||||
5. `risk_level` ★ 必填 · 取值域 `low`/`medium`/`high` —— 随 `recommendation_level` 联动算
|
||||
6. `verification_requirement` ★ **条件必填**(`recommendation_level=conditional` 时)
|
||||
7. `rule_version` ★ 必填 —— 随结论落账,锁定 `R-2026.07`
|
||||
8. `validation_id` ★ —— 关联验证ID(`VAL-<案号>`)
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `substitution_relation_id` ★ | 替代关系ID | text | 主键 | 配方 | `SUB-<案号>` |
|
||||
| `eval_ref` ★ | 评估案号 | text | 必填 | 配方 | 本活动新增列(基线没有) |
|
||||
| `original_internal_pn` ★ | 原内部料号 | text | 必填 · 引用 `parts.profile.internal_pn` | 配方 | |
|
||||
| `candidate_internal_pn` ★ | 候选内部料号 | text | 必填 · 引用 `parts.profile.internal_pn` | 配方 | |
|
||||
| `chip_type_id` ★ | 芯片类型 | text | 必填 | 配方 | 基线原为 `integer`,声明加宽为 `text` |
|
||||
| `recommendation_level` ★ | 推荐等级 | text | 必填 · 取值域 | 配方(按规则算) | 不让 AI 编 |
|
||||
| `recommendation_reason` | 推荐理由 | text | 必填 | 配方(按规则拼句) | |
|
||||
| `difference_summary` | 差异摘要 | text | 必填 | 配方(按规则拼句) | 「共 N 项核心参数:X 项一致 / … 」句式 |
|
||||
| `risk_level` ★ | 替代风险等级 | text | 必填 · 取值域 | 配方(按规则算) | |
|
||||
| `verification_requirement` ★ | 验证要求 | text | 条件必填 · `recommendation_level=conditional` | 配方 | |
|
||||
| `validation_id` | 关联验证ID | text | — | 配方 | `VAL-<案号>` |
|
||||
| `analysis_status` ★ | 分析状态 | text | 必填 | 配方 | 恒 `fae_confirmed`(基线其余档位本活动不写) |
|
||||
| `rule_version` ★ | 差异规则版本 | text | 必填 | 配方 | 锁定 `R-2026.07` |
|
||||
|
||||
> **只读边界**:基线在这张表上还建了 `customer_approval_status` / `customer_approval_at` /
|
||||
> `customer_approval_ref` 三列,由**独立审批流**维护;本活动的声明里没有这三列的写权
|
||||
> (禁令 `no-customer-approval-write`)。落账确认那一屏那一格是静态文案
|
||||
> 「未提交 · 由审批流程维护,本活动不产生」,不是可取值的 `data-slot`
|
||||
> ——审批流做起来后才改成真读数(见 `docs/debt.md`)。
|
||||
> `customer_approval_ref` / `customer_feedback` 四列,由**独立审批流**维护;本活动的声明
|
||||
> 里没有这四列的写权(禁令 `no-customer-approval-write`)。基线物理表里还留着这次重写
|
||||
> 不再声明的 `request_id` / `project_code` / `bom_position` / `substitution_type` /
|
||||
> `analysis_report_ref` / `confidence_score`——那是旧九步版本用过的列,同一条
|
||||
> 「add-column 不删旧列」的道理。
|
||||
|
||||
---
|
||||
|
||||
## 六、`substitution.difference_item` 差异明细(P-11 子表 · 共享主数据)
|
||||
## 八、`substitution.difference_item` 差异明细(P-11 子表)
|
||||
|
||||
一条参数一条记录。S2_normalize 按原 PN 的参数集写骨架(不含四态/影响)、S3_assess 第一拍
|
||||
调 `compare-parameters` 拿到四态与风险后**原样抄回** `difference_status` / `compare_result` /
|
||||
`risk_level`,AI 自己只补写证据引用 / 影响说明 / UNKNOWN 去向;S4_review 驳回时人写的意见由
|
||||
AI 定位到具体行后改 `impact_statement` / `unknown_disposition` / `evidence_ref`。
|
||||
`substitution_relation_id` 标了 `distinct`,「几条关系已经有明细」就是数它的不同值数
|
||||
(S2 收步、S3 收步都读这个数)。
|
||||
一颗料的一条核心参数一行,主键 `substitution_relation_id + difference_item_id`;行由
|
||||
`parts.type_param` 字典 `each` 出来(`prune:true`)——**字段清单由字典给,AI 的出参只是
|
||||
拿 `param_name` 去 `lookup` 的一张查表**,它漏一条,那一行的四态就是空,`required` 立刻
|
||||
变成 `_missing > 0`,提交闸与放行门当场拦住(不会像「AI 出参驱动行」那样漏两条就少两行、
|
||||
每一行看着都合规)。**S3 是这张表唯一的人工编辑入口**:四态 / 影响说明 / 判断依据 /
|
||||
UNKNOWN 去向可就地改,其余(参数项 / 说明 / 约束 / 原值 / 候选值 / 单位)只读。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `substitution_relation_id` ★ 主键之一 · 去重计数 —— 「几条关系已有明细」就是数它的不同值
|
||||
1. `substitution_relation_id` ★ 主键之一 · **去重计数** · 引用 `substitution.relation.substitution_relation_id` ——
|
||||
「几条关系已经有明细」就是数它的不同值(恰应为 1)
|
||||
2. `difference_item_id` ★ 主键之一
|
||||
3. `difference_status` ★ 必填 · 取值域 `match/mismatch/unknown/conditional` —— 工具算,AI 原样抄
|
||||
4. `compare_result` ★ 建议填 —— **本活动新增列(基线没有)**,工具出参原样存底,供复核
|
||||
5. `constraint_type` ★ 必填 · 取值域 `hard/soft` —— 决定这一项差异判得严不严
|
||||
6. `evidence_ref` ★ 必填 —— 证据引用,验收判据「证据链可信」逐行核对它
|
||||
7. `impact_statement` ★ 必填 —— MATCH 写「无」,其余写清对应用 / 验证 / 报价的影响
|
||||
8. `unknown_disposition` ★ 条件必填 · `difference_status=unknown` 时 —— **本活动新增列(基线没有)**,UNKNOWN 的去向(挂验证任务号 / 补来源动作)
|
||||
3. `difference_status` ★ 必填 · 取值域 `match`/`mismatch`/`unknown`/`conditional` ——
|
||||
**S2 那一次 AI 判定的核心出参**
|
||||
4. `judgement_class` ★ 必填 · 取值域 `match`/`soft_mismatch`/`hard_mismatch`/`unknown`/`conditional` ——
|
||||
**合成列**(先认 `difference_status`,`mismatch` 再按 `constraint_type` 分硬/软):
|
||||
「硬约束不符有几条」不是任何单列 `tally` 数得出来的数,`recommendation_level` 的判据
|
||||
全靠这一列
|
||||
5. `impact_statement` ★ 必填 —— MATCH 写「无」,其余写「换上去会不会比现在差、差在哪儿」
|
||||
(不复述参数值)
|
||||
6. `evidence_note` ★ 必填 —— 判断依据
|
||||
7. `unknown_disposition` ★ **条件必填**(`difference_status=unknown` 时)—— UNKNOWN 的去向
|
||||
8. `constraint_type` ★ 必填 · 取值域 `hard`/`soft` —— 承自 `parts.type_param` 字典这一行本身
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `substitution_relation_id` ★ | 替代关系ID | text | 主键之一 · 去重计数 | AI | |
|
||||
| `difference_item_id` ★ | 差异明细ID | text | 主键之一 | AI | `<关系id>-P01/P02…` |
|
||||
| `parameter_name` | 标准参数名 | text | 必填 | AI | |
|
||||
| `original_value` | 原物料参数值 | text | 必填 | AI | |
|
||||
| `candidate_value` | 候选物料参数值 | text | 必填 | AI | 候选没有这个参数写「未披露」 |
|
||||
| `parameter_unit` | 参数单位 | text | 建议填 | AI | 以原 PN 为准 |
|
||||
| `constraint_type` ★ | 约束类型 | text | 必填 · 取值域 | AI | 原 PN 那一行的 hard/soft |
|
||||
| `compare_result` ★ | 比对结果(工具出参) | text | 建议填 | 工具(`compare-parameters`)→ AI 照抄 | 本活动新增列 |
|
||||
| `difference_status` ★ | 差异状态 | text | 必填 · 取值域 | 工具(`compare-parameters`)→ AI 照抄 | |
|
||||
| `impact_statement` ★ | 影响说明(MATCH 写「无」) | text | 必填 | AI | |
|
||||
| `unknown_disposition` ★ | UNKNOWN 去向(验证任务号 / 补来源动作) | text | 条件必填 · `difference_status=unknown` | AI(可按人的驳回意见改) | 本活动新增列 |
|
||||
| `risk_level` | 风险等级 | text | 必填 · 取值域 `low/medium/high` | 工具(`compare-parameters`)→ AI 照抄 | |
|
||||
| `evidence_ref` ★ | 差异证据引用 | text | 必填 | AI(可按人的驳回意见改) | |
|
||||
| `confidence` | AI 置信度 | number | 建议填 | AI | 0~1 |
|
||||
| `rule_version` | 差异规则版本 | text | 必填 | AI | 锁定 `R-2026.07` |
|
||||
| `substitution_relation_id` ★ | 替代关系ID | text | 主键之一 · 去重计数 · 引用 `substitution.relation.substitution_relation_id` | 配方 | 隐藏在件上(`.rowkey`),仅供 `resolveRowKey` 凑主键 |
|
||||
| `difference_item_id` ★ | 差异明细ID | text | 主键之一 | 配方 | `<关系id>-<参数名>` |
|
||||
| `parameter_name` | 标准参数名 | text | 必填 | 配方(抄字典) | |
|
||||
| `parameter_desc` | 参数说明 | text | 建议填 | 配方(抄字典) | 归档件不必回查字典,快照一份 |
|
||||
| `constraint_type` | 约束类型 | text | 必填 · 取值域 | 配方(抄字典) | |
|
||||
| `original_value` | 原物料参数值 | text | 必填 | 配方(`lookup` `parts.parameter`) | |
|
||||
| `candidate_value` | 候选物料参数值 | text | 必填 | 配方(`lookup` `parts.parameter`) | |
|
||||
| `parameter_unit` | 参数单位 | text | 建议填 | 配方(抄字典) | |
|
||||
| `difference_status` ★ | 差异状态 | text | 必填 · 取值域 | **AI**(S2 `analyze` 出参) | |
|
||||
| `judgement_class` ★ | 判定分类 | text | 必填 · 取值域 | 配方(合成列) | 由 `difference_status` × `constraint_type` 算 |
|
||||
| `impact_statement` ★ | 影响说明(MATCH 写「无」) | text | 必填 | **AI** / **人(件上,S3 可改)** | |
|
||||
| `evidence_note` ★ | 判断依据 | text | 必填 | **AI** / **人(件上,S3 可改)** | 基线里这一列旧名是 `evidence_ref`,本活动改用新名 `evidence_note`(不写 P-10 了,AI 能给的是依据不是文件引用) |
|
||||
| `unknown_disposition` ★ | UNKNOWN 去向(验证任务号 / 补来源动作) | text | 条件必填 · `difference_status=unknown` | **AI** / **人(件上,S3 可改)** | |
|
||||
| `risk_level` | 风险等级 | text | 必填 · 取值域 | 配方 | `hard_mismatch`→high,`match`→low,其余 medium |
|
||||
| `rule_version` | 差异规则版本 | text | 必填 | 配方 | 锁定 `R-2026.07` |
|
||||
|
||||
> 基线物理表里还留着两列这次重写不再声明的旧列:`evidence_ref`(已被 `evidence_note`
|
||||
> 取代,不是同一列改名,是新声明了一列、旧列作废不写)与 `confidence`(AI 置信度,旧设计
|
||||
> 的工具出参字段,新设计没有这个概念)。
|
||||
|
||||
---
|
||||
|
||||
## 七、`validation.record` 芯片验证记录(P-06 · 共享主数据)
|
||||
## 九、`validation.record` 芯片验证记录(P-06)
|
||||
|
||||
事实类表:本活动只在 S3_assess 给每个 UNKNOWN 差异项挂一行「验证要求」占位
|
||||
(`validation_status` 恒写 `not_started`),不经数据工单建 —— 真正测完、写测试结果是
|
||||
另一件事(下游活动或线下流程的事)。S4_review 驳回若要补验证任务,同样由 AI 加一行。
|
||||
事实类表:本活动只在 S2/S3 给每单挂**一行固定的**「验证要求」占位
|
||||
(`validation_status` 恒 `not_started`,`validation_type` 恒 `parametric`)——它不是实测
|
||||
结果,**不许虚构实测**;真正测完、写测试结果是另一件事(下游或线下流程的事)。**一单固定
|
||||
一条**,即使全部参数都 MATCH、没有待验项,也要写「本轮无待验项」而不是不写这一行
|
||||
(验收判据 `validation-landed` 钉着 `validation_record_rows = 1`)。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `validation_id` ★ 主键
|
||||
2. `validation_internal_pn` ★ 必填 · 引用 `parts.profile.internal_pn`
|
||||
3. `validation_status` ★ 必填 · 取值域 —— 本活动只写 `not_started`(占位待测)
|
||||
4. `next_validation_action` ★ 必填 —— 要测什么、什么条件
|
||||
5. `validation_type` ★ 必填 · 取值域
|
||||
6. `request_id` ★ 必填 —— 关联需求编号
|
||||
1. `validation_id` ★ 主键 —— `VAL-<案号>`
|
||||
2. `eval_ref` ★ 必填 —— **本活动新增列(基线没有)**
|
||||
3. `validation_internal_pn` ★ 必填 · 引用 `parts.profile.internal_pn` —— 恒为候选料号
|
||||
4. `validation_status` ★ 必填 —— 恒 `not_started`
|
||||
5. `next_validation_action` ★ 必填 —— 要测什么、什么条件;没有待验项写「本轮无待验项」
|
||||
6. `validation_type` ★ 必填 · 取值域(基线六档,本活动只写 `parametric`)
|
||||
7. `test_condition` ★ 建议填 —— 有待验项才写测试条件,否则「无」
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `validation_id` ★ | 验证ID | text | 主键 | AI | `VAL-<案号数字串>-01/02…` |
|
||||
| `request_id` ★ | 关联需求编号 | text | 必填 | AI | |
|
||||
| `validation_internal_pn` ★ | 验证内部料号 | text | 必填 · 引用 `parts.profile.internal_pn` | AI | 候选 |
|
||||
| `validation_type` ★ | 验证类型 | text | 必填 · 取值域 | AI | 恒 `pin_to_pin`(本活动只挂这一种) |
|
||||
| `validation_status` ★ | 验证状态 | text | 必填 · 取值域 | AI | 恒 `not_started` |
|
||||
| `test_condition` | 测试条件 | text | 建议填 | AI | |
|
||||
| `next_validation_action` ★ | 后续验证动作 | text | 必填 | AI(可按人的驳回意见改) | 要测什么、什么条件 |
|
||||
| `evidence_ref` | 验证证据引用 | text | 建议填 | AI | |
|
||||
| `validation_id` ★ | 验证ID | text | 主键 | 配方 | `VAL-<案号>` |
|
||||
| `eval_ref` ★ | 评估案号 | text | 必填 | 配方 | 本活动新增列(基线没有) |
|
||||
| `validation_internal_pn` ★ | 验证内部料号 | text | 必填 · 引用 `parts.profile.internal_pn` | 配方 | 恒为候选料号 |
|
||||
| `validation_type` ★ | 验证类型 | text | 必填 · 取值域 | 配方 | 恒 `parametric`(本活动只挂这一种) |
|
||||
| `validation_status` ★ | 验证状态 | text | 必填 | 配方 | 恒 `not_started` |
|
||||
| `test_condition` | 测试条件 | text | 建议填 | 配方 | 有 UNKNOWN/CONDITIONAL 才写,否则「无」 |
|
||||
| `next_validation_action` ★ | 后续验证动作 | text | 必填 | 配方 | 没有待验项写「本轮无待验项」 |
|
||||
|
||||
> 基线物理表里还留着这次重写不再声明的一批旧列(`request_id` / `customer_code` /
|
||||
> `test_parameters` / `test_result` / `issue_risk` / `sample_version` / `sample_lot` /
|
||||
> `evidence_ref`)——那批列是「真验证发生后才有的事实」(测试参数、测试结果、样品批次),
|
||||
> 本活动只写「验证要求」占位阶段,不写实测结果,同一条「add-column 不删旧列」的道理。
|
||||
|
||||
---
|
||||
|
||||
## 八、`sub_eval.pn_check` 料号存在性核对(本活动工作台账)
|
||||
|
||||
S0_bind 逐个料号(原 PN + 每个候选)在 `parts.profile` 里核对一遍写下的表:原 PN 一行、
|
||||
候选各一行。`exists_status` 是这张表的核心结论 —— 两项数据工单的入场哨兵、S1 的入场哨兵
|
||||
都读它。候选 ≥2 且不在库时,S0d_p08_master 发决策卡问放弃还是创建,人选的答案决定这一行
|
||||
`exists_status` 改成 `abandoned` 还是等数据工单确认后改成 `created`。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `exists_status` ★ 必填 · 取值域 `found/absent/created/abandoned` —— 存在性检查的核心结论,S0d 两项与 S1 的入场哨兵都读它
|
||||
2. `internal_pn` ★ 主键之一
|
||||
3. `eval_ref` ★ 主键之一 —— 串联本单十张表的案号
|
||||
4. `role` ★ 必填 · 取值域 `original/candidate`
|
||||
5. `abandon_reason` ★ 条件必填 · `exists_status=abandoned` 时 —— 人在决策卡选放弃、没写理由时,AI 补写「负责人本轮放弃(未注明原因)」
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `eval_ref` ★ | 评估案号 | text | 主键之一 | AI | |
|
||||
| `internal_pn` ★ | 内部料号 | text | 主键之一 | AI | |
|
||||
| `role` ★ | 角色 | text | 必填 · 取值域 | AI | `original`(恰一行)/ `candidate` |
|
||||
| `exists_status` ★ | 在库状态 | text | 必填 · 取值域 | AI | `found`(在库)→ 或 `absent` → `created`/`abandoned`(数据工单/决策卡确认后改) |
|
||||
| `abandon_reason` | 放弃原因 | text | 条件必填 · `exists_status=abandoned` | AI(据人的决策卡选项改) | 人选放弃未写理由时 AI 补写默认句 |
|
||||
| `note` | 备注 | text | — | AI | 如「PCN 转 NRND」 |
|
||||
|
||||
---
|
||||
|
||||
## 九、`sub_eval.input_gap` 评估输入清单(本活动工作台账)
|
||||
|
||||
S1_input_confirm 一拍写全:已绑定四项(status=ok)、待补两项(应用场景/BOM 位,status=gap)、
|
||||
默认可改三项(status=default)、逐颗候选「未披露」参数各一行(status=gap)、齐备项(原 PN 资料
|
||||
版本,status=ok)。**这张表是本活动唯一让人直接在件上补内容的执行工单载体**:人在
|
||||
「补充内容」列把 status=gap 的行填上答案,也可以加一行补充 AI 没列出的资料项、删一行标
|
||||
「不适用」。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `supplement` ★【人填】条件必填 · `status=gap` 时 —— 缺口行的答案,这一单里唯一不能靠 AI 补出来的事实
|
||||
2. `status` ★ 必填 · 取值域 `gap/default/ok` —— 决定哪一行必须有 `supplement`
|
||||
3. `item_key` ★【人填,加行时】主键之一 —— 英文短键;已绑定 / 待补 / 默认 / 待确认各占一批行
|
||||
4. `input_item` ★【人填,加行时】必填 —— 人读名,加一行 = 补充 AI 没列出的资料项
|
||||
5. `origin` ★ —— `ai` / `human-added`,标出哪几行是人加的(与 E04 `candidate.origin` 同一口径)
|
||||
6. `source_ledger` ★ 建议填 —— 缺口来自哪张台账(P-01/P-08/P-09/P-10)
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `eval_ref` | 评估案号 | text | 主键之一 | AI | |
|
||||
| `item_key` ★ | 输入项键 | text | 主键之一 | AI / **人(件上,加行时)** | 英文短键,如 `application_scenario` |
|
||||
| `input_item` ★ | 输入项 | text | 必填 | AI / **人(件上,加行时)** | 人读名 |
|
||||
| `source_ledger` ★ | 来源台账 | text | 建议填 | AI | |
|
||||
| `status` ★ | 状态 | text | 必填 · 取值域 | AI | `gap`(待补)/`default`(默认·可改)/`ok`(齐备) |
|
||||
| `detail` | 绑定值 / 缺口说明 | text | 建议填 | AI | |
|
||||
| `supplement` ★ | 补充内容 | text | 条件必填 · `status=gap` | **人(件上)** | 唯一的人手笔答案 |
|
||||
| `origin` ★ | 来源(ai/human-added) | text | — | AI | |
|
||||
|
||||
---
|
||||
|
||||
## 十、`sub_eval.verdict` 本次评估结论(本活动工作台账)
|
||||
|
||||
一单一行的收口结论,挂着几面「人环走到哪儿了」的记账旗 —— 它们是下一步能不能开工的哨兵
|
||||
(与 E04 `crm_coverage.verdict` 同一种设计)。
|
||||
|
||||
**重点字段(按重要程度)**
|
||||
|
||||
1. `input_confirmed_total` ★ —— 评估输入已确认(人环凭据),S2 标准化那一步的入场哨兵之一
|
||||
2. `review_approved_total` ★ —— 差异结论已审核通过(仅通过时置一),S5 拟落账那一步的入场哨兵
|
||||
3. `landing_prepared_total` ★ —— 拟落账已备好,T_release 放行门的入场哨兵
|
||||
4. `summary` ★ —— 本次评估结论,归档件上的那段话
|
||||
5. `relation_total` ★ —— 替代关系条数
|
||||
6. `recommended_total` / `conditional_total` / `alternate_total` / `not_recommended_total` ★ —— 四档分布,归档件摘要与放行口 `assessment-released` 载荷都用它
|
||||
|
||||
**数据结构**
|
||||
|
||||
| 列名 | 人读名 | 类型 | 约束 | 写入者 | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| `eval_ref` | 评估案号 | text | 主键 | AI | |
|
||||
| `request_id` | 需求编号 | text | — | AI | |
|
||||
| `rule_version` | 规则版本 | text | — | AI | 锁定 `R-2026.07` |
|
||||
| `original_internal_pn` | 原内部料号 | text | — | AI | |
|
||||
| `candidate_total` | 候选数 | integer | — | AI | 候选数组长度 |
|
||||
| `input_confirmed_total` ★ | 评估输入已确认 | integer | — | AI | 人环凭据 |
|
||||
| `review_approved_total` ★ | 差异结论已审核通过 | integer | — | AI | 人环凭据(仅通过时置一) |
|
||||
| `landing_prepared_total` ★ | 拟落账已备好 | integer | — | AI | S5 写完时置一 |
|
||||
| `relation_total` ★ | 替代关系条数 | integer | — | AI | |
|
||||
| `recommended_total` ★ | 推荐 | integer | — | AI | |
|
||||
| `conditional_total` ★ | 条件推荐 | integer | — | AI | |
|
||||
| `alternate_total` ★ | 备选 | integer | — | AI | |
|
||||
| `not_recommended_total` ★ | 不推荐 | integer | — | AI | |
|
||||
| `summary` ★ | 本次评估结论 | text | — | AI | 归档件上的那段话 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、人填字段总表(按人办事的那一屏)
|
||||
## 十、人填字段总表(按人办事的那一屏)
|
||||
|
||||
| 活动 · 屏 | 卡的形式 | 落进哪张表 | 人亲手填的列 |
|
||||
|---|---|---|---|
|
||||
| S0_bind · 新客户建档(仅客户不在库) | 数据录入卡(entry) | `customer.profile` | `name` · `industry` · `region`(`customer_code` 由入参给,人核对) |
|
||||
| S0d_p08_master · 候选放弃/创建抉择(候选≥2 且部分缺库) | 决策卡(decide) | 间接改 `sub_eval.pn_check.exists_status` | 选项值(`create`/`abandon`/`custom:`,不直接写字段) |
|
||||
| S0d_p08_master · 数据工单·芯片主数据创建(P-08) | 执行工单(件上编辑 + 增删行) | `parts.profile` | 全部 12 列(`internal_pn` / `brand_name` / `manufacturer_name` / `specification_description` / `normalized_parameter_set` / `package` / `process_technology` / `lifecycle_status` / `eol_pcn_status` / `datasheet_ref` / `datasheet_version` / `application_scenarios`) |
|
||||
| S0d_p10_document · 数据工单·原厂技术资料创建(P-10) | 执行工单(件上编辑 + 增删行) | `parts.document` | 全部 9 列(`internal_pn` / `doc_seq` / `document_type` / `brand_name` / `document_title` / `document_version` / `document_ref` / `citation_locator` / `document_status`) |
|
||||
| S1_input_confirm · 评估输入与资料补齐确认 | 执行工单(件上编辑 + 增删行) | `sub_eval.input_gap` | `supplement`(缺口行必填);加一行时另填 `item_key` / `input_item` |
|
||||
| S4_review · 差异评估结论审核 | 决策卡循环(通过 / 驳回 + 意见) | 间接改 `substitution.difference_item` / `substitution.relation` / `validation.record` | 意见文本(AI 据此改 `evidence_ref` / `impact_statement` / `unknown_disposition` / `recommendation_reason`;推荐等级不许改) |
|
||||
| T_release · 评审放行与落账确认 | 验收工单(签字,可打回重做) | `parts.profile` / `parts.document` / `demand.request` / `substitution.relation` + `difference_item` / `validation.record` 并入正式版 | 裁决与意见(打回后 AI 按意见改台账本身,不开新行) |
|
||||
| `S1_inputs` · 数据工单「差异评估的输入还差几格」(条件发卡,零卡直通是常态路径之一) | 执行工单(零输入卡,件上编辑 + 增删行) | `parts.chip_type`(新建类型时)/ `parts.type_param`(新建类型 / 追加字段时)/ `parts.profile` / `parts.parameter` | chip_type:`type_name`/`description`;type_param:`type_id`(选)/`param_name`/`param_desc`/`constraint_type`(选)/`param_unit`;profile:`chip_type_id`(选)/`brand_name`/`lifecycle_status`(选)/`manufacturer_name`/`specification_description`/`datasheet_ref`/`datasheet_version`;parameter:`param_value`/`param_note` |
|
||||
| `S3_review` · 差异评估结论审核(可循环打回重做) | 决策卡(`decide`,`variant:'review'`)+ 件上就地改 | `substitution.difference_item` | `difference_status`(选)/`impact_statement`/`evidence_note`/`unknown_disposition`(AI 先判,人核对后可改) |
|
||||
| `T_release` · 落账放行(可循环打回重做) | 终局验收工单(签字,人工验收清单五条) | 无(全只读) | 无 —— 人只签字或打回重做。**这一步本身不提供改内容的手段**(`writes` 只声明了 `sub_eval.case`,且这张表没有人工编辑入口、全由配方算);要改结论内容,退回 `S3_review` 的件上直接改 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、在数据域里看这些表时的几条提醒
|
||||
## 十一、在数据域里看这些表时的几条提醒
|
||||
|
||||
1. **主数据是共享的,物理表里还有别的用途留下的列,本活动不写它们**:
|
||||
- `customer.profile` 还有 `grade` / `owner` / `payment_terms` / `credit_limit` /
|
||||
`profile_tags` / `status`(报价链早先声明的客户主台账列)
|
||||
- `parts.profile` 还有 `customer_pn` / `pin_to_pin_relation_ref`
|
||||
- `parts.document` 还有 `document_date` / `source_contact_ref`
|
||||
- `demand.request` 还有 `project_code` / `bom_id` / `customer_pn`
|
||||
- `substitution.relation` 还有 `project_code`,以及客户批准三列 + `customer_feedback`
|
||||
(见下条,单独说明只读边界)
|
||||
- `validation.record` 还有 `customer_code` / `test_parameters` / `test_result` /
|
||||
`issue_risk` / `sample_version` / `sample_lot` —— 本活动只写「验证要求」占位阶段,
|
||||
不写实测结果(那是真验证发生后才有的事实)。
|
||||
1. **`sub_eval.part` / `sub_eval.case` 是本活动自持的工作台账**(domain `sub_eval`,与 E04
|
||||
`crm_coverage.*`、I01 `content_brand.*` 同一类地位),其余七张是共享主数据的物理表 ——
|
||||
但截至这次重写,**只有 W05 声明并读写它们**(全仓 grep `domain: 'parts'` /
|
||||
`'substitution'` / `'validation'` 唯一命中处就是这个包),称「共享」容易让人以为另有
|
||||
活动在用,这份文档统一改称「W05 唯一读写者」。
|
||||
|
||||
记录浏览里看到它们空着是正常的;判断一列还算不算数,以**活动清单的声明**为准,
|
||||
不以物理表结构为准。
|
||||
2. **`type_id` 这条身份链路是本仓唯一一处「integer 加宽成 text」的声明**(六列同源:
|
||||
`parts.chip_type.type_id` / `parts.type_param.type_id` / `parts.profile.chip_type_id` /
|
||||
`sub_eval.part.chip_type_id` / `sub_eval.case.chip_type_id` /
|
||||
`substitution.relation.chip_type_id`)。原因是平台的自增续号器只认「整数型主键走一条路、
|
||||
字符串型主键走另一条路」,而「`auto:true` 又是 `integer` 的主键」这个组合会撞上两处
|
||||
平台级坑(`resolveRowKey` 抢跑续号 / `coerceToColumn` 按 `Number()` 解析掉前导零导致
|
||||
`refersTo` 比对失配)——声明层加宽成 `text` 绕开,不是修复那两处平台代码(它们仍带着
|
||||
坑,见 `docs/debt.md`)。字段说明详见 `parts.chip_type.type_id` 那一行与
|
||||
manifest.ts 对应大注释。
|
||||
|
||||
2. **本活动往两张主数据表新增了列(基线没有,声明里 add-column 长出来的)**:
|
||||
`substitution.relation.eval_ref`(评估案号,串联本单十张表)与
|
||||
`substitution.difference_item.compare_result` / `unknown_disposition`
|
||||
(工具出参原样存底、UNKNOWN 去向)。这三列只对 ACT-WORK-05 生效,不影响其它活动
|
||||
对这两张表的读写。
|
||||
3. **基线物理表里普遍还留着旧九步版本的列**(见各表小节末尾的提醒框)——`add-column`
|
||||
合并不删旧列,记录浏览里看到它们空着是正常的;判断一列还算不算数,**以活动清单当前
|
||||
这份声明为准**,不以物理表结构为准。
|
||||
|
||||
3. **`customer_approval_status` 是只读边界,不是本活动没做完**:基线在
|
||||
`substitution.relation` 上建了 `customer_approval_status` / `customer_approval_at` /
|
||||
`customer_approval_ref` 三列,由**独立审批流**维护;本活动的声明里没有这三列的写权
|
||||
(禁令 `no-customer-approval-write`)。放行前那一屏那一格是静态文案
|
||||
「未提交 · 由审批流程维护,本活动不产生」,不是取值的 `data-slot`(见 `docs/debt.md`)。
|
||||
4. **`substitution.relation.customer_approval_status` 等四列是只读边界,不是本活动没做完**——
|
||||
由**独立审批流**维护,本活动的声明里没有写权(禁令 `no-customer-approval-write`)。
|
||||
|
||||
4. **`parts.profile` 的标准化参数集是一个 text 列,不是子表**:`normalized_parameter_set`
|
||||
存的是一行一参数的行语法「参数名 | 值 | 单位 | hard/soft | 说明」,四态比对工具的入参
|
||||
都靠**解析这段文本**,不是关系型的多行记录 —— 用户当场定的口径,《SS02-SS03-SS04》
|
||||
字面上写的 Structure / 子表在本平台落成这样(见 `docs/debt.md`「参数集是 text 列的行语法」)。
|
||||
5. **试跑单默认也落真库**:与 E04 / I01 同一个开关(`sys.task.land_master`,测试台起单
|
||||
默认打开)——跑一遍活动,新建的芯片类型 / 料号 / 参数都会真的进正式表;要它不落,
|
||||
起单时显式关掉(脚本化 e2e 要求自己传 `false`)。
|
||||
|
||||
5. **数据工单是零输入执行工单 + 工作区可加行的件**:料号在 `parts.profile` /
|
||||
`parts.document` 里查无记录时,人从零建整条记录;确认落草稿层,T_release 放行那一刻
|
||||
才与其余表一起并入正式版(平台铁律②:写只有草稿与意向,终局唯放行)。一张表一张单:
|
||||
P-08 一张、P-10 一张。
|
||||
|
||||
6. **试跑单默认也落真库**:与 E04 / I01 同一个开关(`sys.task.land_master`,测试台起单
|
||||
默认打开)—— 跑一遍活动,新建的客户档案、料号、资料记录都会真的进正式表;要它不落,
|
||||
起单时显式关掉。
|
||||
6. **本活动全程无 agent 多轮编排,零技能、零工具**——`contract.resources.tools` 与
|
||||
`declarations.tools` 都是 `[]`,`assets.skills` 也是 `[]`(旧两枚工具、两张 DMN、三条
|
||||
技能戳已随重写整体退役)。全部行为写在 `PlanStep.auto` 声明式配方里,详见
|
||||
`docs/活动开发规范.md` §4.6。
|
||||
|
||||
---
|
||||
|
||||
*字段口径以 `activities/part-substitution-assessment/src/manifest.ts` 的
|
||||
`declarations.ledger` 为准;六张主数据的物理结构见 `apps/api/src/app/seed-mvp-data.ts` 的
|
||||
`MASTER_OBJECTS`;计划项与卡骨架见 `activities/part-substitution-assessment/src/case-model.ts`
|
||||
的 `ASSESSMENT_STEPS` 与 `NARRATIVE`;业务设计源见 `docs/SS02-ACT-WORK-05-html/`
|
||||
(活动流程与工单说明 / 一键启动入参说明)与《SS02-SS03-SS04_Demo台账字段细化与统一方案》。*
|
||||
`declarations.ledger` 为准;七张共享主数据的基线物理结构见
|
||||
`apps/api/src/app/seed-mvp-data.ts` 的 `MASTER_OBJECTS`;四步剧本与配方见
|
||||
`activities/part-substitution-assessment/src/case-model.ts` 的 `ASSESSMENT_STEPS` 与
|
||||
`NARRATIVE`;确定性步骤(`auto` 五动词)的通用机制见 `docs/adr/ADR-014-deterministic-steps.md`
|
||||
与 `docs/活动开发规范.md` §4.6;业务设计源见 `docs/SS02-ACT-WORK-05-html/`
|
||||
(活动流程与工单说明)、`docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`
|
||||
与《SS02-SS03-SS04_Demo台账字段细化与统一方案》。*
|
||||
|
||||
+128
@@ -295,6 +295,14 @@ start 已收项无事发生同族)。剧本里也别再教 start —— 教它
|
||||
(与模型同拍撞车时:引擎收过了,模型这拍的 finish 当已完成,不抛非法迁移);
|
||||
**拍尾再求一次**(人答卡那一拍里模型写完东西 doneWhen 才真,不等下一拍;这一拍刚发卡的项不算)。
|
||||
|
||||
**人填的表要声明 `humanWrites`**(2026-08-28 立):数据工单要人在件上补、而模型**永远没有来源**的那几张表
|
||||
(P-10 的资料标题 / 版本 / 文件链接),写进剧本的 `humanWrites` —— 模型 write 当场拒、drop 仍可撤。
|
||||
不声明的后果实测过:模型被 doneWhen 逼着凑「必填不缺」,把台账里**原 PN 那一行**的规格书逐字抄到候选料号头上,
|
||||
行找对了料找错了,而这份假证据会喂进差异比对当「硬约束有证据支持」。
|
||||
**只声明模型没有来源的表** —— 有附件时该预填的(P-08 的参数值从 datasheet 抽)是它的活,别拦。
|
||||
配套:**还欠一张卡的那一步,提示词不摆 doneWhen** —— 判据同时是摆在模型眼前的目标,而它此刻只许发卡。
|
||||
**一个闲着的模型 + 一个它够得着的判据 = 它一定会去凑那个判据。**
|
||||
|
||||
**人工激活的项也可以给 doneWhen,引擎只在人答过卡之后才求它**(2026-08-27 晚立):
|
||||
门槛是 `worldTouched`(卡真发过)且没有未答的卡 —— 卡没发过时 doneWhen 再真也不收,
|
||||
那才是替人签字。答过之后,「办完了没有」若是基座数得出来的事实,就写成 doneWhen 交给引擎:
|
||||
@@ -308,6 +316,109 @@ W05 的两张数据工单(人填的值已入库、必填不缺、逐颗对账)
|
||||
|
||||
---
|
||||
|
||||
## 4.6 确定性步骤:这一步系统自己做(2026-08-29)
|
||||
|
||||
一步要么确定,要么不是。**不做「一半 auto」** —— 判断点先从那一步里切出去。
|
||||
|
||||
`PlanStep.auto` 是一份**声明式配方**,五个动词按序执行:
|
||||
|
||||
| 动词 | 做什么 |
|
||||
|---|---|
|
||||
| `project` | 从某个来源的每一行投影出台账行,落草稿层。带 `prune` 时,这一笔投影出来的行**就是这张表在本单草稿里的全部** |
|
||||
| `analyze` | **一次结构化 AI 调用**:系统组装入参、声明出参 schema、系统把结果落表。不给工具、不给记忆、不多轮 |
|
||||
| `card` | 发一张卡(执行工单 / 数据工单 / 决策卡)。**至多一张**,发过就不再发 |
|
||||
| `submit` | 提交放行(只许出现在声明了人工验收清单的那一步) |
|
||||
| `say` | 一句给人看的交代 |
|
||||
|
||||
地址只有四种:`域.对象`(任务视图 = 正式 + 本单草稿叠加,**默认该用的那一种**)· `mine:域.对象` ·
|
||||
`in:入参数组` · `result:xxx`(本拍 analyze 的出参)。
|
||||
|
||||
**guide 可以留空,但只有声明了 auto 才行**:`planStepSchema` 原来要求 `guide` 非空
|
||||
(`.min(1)`)—— 那是防「作者忘了写做法」的老规矩,可它顺手把「这一步一次都不问模型」堵死了。
|
||||
现在改成 `superRefine`:没声明 `auto` 的步照旧非空;声明了 `auto` 的步允许 `guide` 写空串
|
||||
(`guide.trim() === ''`)——引擎正是按这个空串分「全 auto(一次都不问模型)」与
|
||||
「半 auto(确定的先做完,剩下的判断仍问模型)」。**全 auto 的步就把 `guide` 写成空串**,
|
||||
别为了过 schema 塞一句废话。
|
||||
|
||||
**半 auto 的步,人答完卡之后那一截判断仍然是模型的活**:配方发过一张卡、`worldTouched`
|
||||
为真、这一拍没有未答的卡、配方这一拍也没再发新卡 —— 这几个条件都成立时,引擎本可以
|
||||
「卡答过了就收步」抄近道直接收掉这一项。但**这条近道只走没有 `guide` 的步**(全 auto);
|
||||
只要 `guide` 非空(半 auto),就算前面几个条件都成立,也仍然要往下问模型 ——
|
||||
半 auto 存在的意义就是「确定的先做完,剩下的判断仍问模型」:人答完卡之后那份判断
|
||||
(读人写的自由文本、落到具体的行上)恰恰是模型该干的活,收在它前面等于把半 auto 的
|
||||
后半截整个删掉。写半 auto 的步时记住这条:`guide` 不空,这一步答完卡不会被引擎抢先收掉。
|
||||
|
||||
**十条纪律**(每一条都是踩出来的):
|
||||
|
||||
1. **行由确定的那一侧决定,不由 AI 决定。** 明细表要按类型字典 `each` 出来,AI 的出参只是拿主键
|
||||
`lookup` 的一张查表 —— 它漏一条,那一格就是空,`required` 立刻变成 `_missing > 0`,闸与放行门当场拦住。
|
||||
写成 `from each(AI 出参)` 的话,它少给两条就少两行,而每一行看着都合规。
|
||||
2. **判据不许编一个数不出来的数。** 「本类型有几个字段」不是基座数得出来的数(`_rows` 数的是**本单写下的**行);
|
||||
行数对账交给 `prune` 的结构保证,别写进 `doneWhen`。
|
||||
3. **每一笔之前刷读数。** 后一笔常要数前一笔刚写下的行。
|
||||
4. **出错交人,不重试、不退回模型。** 配方抛错是包的 bug;退回模型就是让它去凑。
|
||||
5. **被 `prune` 投影的表,不该是人在件上能加行的表**(2026-08-29 立)。`prune` 说的是
|
||||
「这一笔投影出来的行就是全部,多出来的撤掉」;而人在件上「加一行」加出来的行,配方的数据源里
|
||||
当然没有 —— 下一拍对齐就把它**静默撤掉**了(2026-08-02 为人工撤行立过「撤要说出来、点名哪几行」
|
||||
的规矩,这条路连说都没说)。基座给了两道护栏:有卡在等人(人正对着件填)那几拍**不 prune**;
|
||||
同一张表既被 `prune` 又列在这一步 `humanWrites` 里,装包时报 warn。护栏不是许可 ——
|
||||
要人加行的表就别 `prune` 它。
|
||||
6. **`card` 之后的那几笔,要等人交回之后的那一拍才跑。** 卡真发出去那一拍到此为止;
|
||||
人交回之后那一拍,`card` 这一笔被跳过、后面的照跑。所以「铺格 → 发卡 → 收口整理」读起来像一拍连做,
|
||||
其实是两拍 —— 收口那一笔看得见人填的值,这正是它该在的位置。装包时会为此报一条 warn 提醒。
|
||||
7. **`fields` 里的字段书写顺序不是依赖顺序,不能拿文本次序当保证**(2026-08-30,真跑 e2e
|
||||
逼出的地基缺陷)。活动清单存进 `sys.activity_package.manifest` 这个 **jsonb 列**之后,
|
||||
PostgreSQL 按「先短后长、同长字典序」**重排**了每一个嵌套对象的键 —— 而 `project`/`analyze`
|
||||
的 `fields`(以及 `analyze.input.fields`)原来是靠 `Object.entries` 逐字段求值,后一个
|
||||
字段能通过 `ctx.computed` 看见前一个已经算出的值,「声明序 = 依赖序」是一条从没写下来、
|
||||
一进数据库就被打破的隐藏假设,而且**不报错**,只是答案算错了(W05 实证:合成列
|
||||
`judgement_class` 依赖 `difference_status`,被 jsonb 重排到它前面,读到 `undefined`,
|
||||
三级 `when` 全部落空、摔进兜底档;`difference_status` 本身走 `lookup:'result:items'`
|
||||
不依赖字段序,所以是对的 —— 「一个对一个全错」的怪现象由此而来。**隔离测试复现不出来**,
|
||||
因为它直接读 `dist/manifest.json`,从不经数据库往返 —— 只有真跑 e2e 才会现形)。
|
||||
**现在字段求值改按依赖关系做拓扑排序**(Kahn 算法,`packages/utils` 的 `topoOrder`),
|
||||
不是「迭代到不动点」—— 后者是数值收敛判据,会把「结构上真有环、只是两个字段的
|
||||
`else` 分支恰好在错误状态下互相稳定」这种配方误判成排好了,拓扑排序不会有这个假阴性。
|
||||
真正的环在**装包静态审**那一关当场拒装,不兜底、不静默放行。**写配方时不要假设
|
||||
「后一个字段能看见前一个」是因为你把它写在了后面**—— 它能看见,是因为拓扑排序算出来
|
||||
该这么排;把 `when`/`lookup`/`cat` 引用了哪些字段名想清楚,排序自然是对的,想不清楚就
|
||||
会在装包时被拒并点名牵涉的字段。
|
||||
8. **跨笔的依赖仍然是你自己排的顺序**(拓扑排序只管同一笔 `fields` 对象内部的字段序,
|
||||
`do` 数组里 `project`/`analyze`/`card`/`say` 各笔之间的次序不受它管)—— **一笔读的是
|
||||
另一笔写出来的数,就必须排在那一笔之后**。W05 的 S1 曾把摆给人看的 `gate_reason`
|
||||
(案卷那一笔)排在铺主数据/参数骨架那两笔之前:`gate_reason` 的 `when` 链要读
|
||||
`parts_profile_missing`/`parts_parameter_missing`,而这两个基座读数是骨架那两笔刚
|
||||
建出来的行撑出来的 —— 排在前面,它读到的是**这一拍刷新之前**的旧数(参数骨架那一刻
|
||||
还不存在,`_missing` 读到 0),片刻之后骨架一建好又变了。收步与发卡的把关本身没坏
|
||||
(`card.when` 用的是当下真实读数),坏的是**摆给人看那句话**—— 人最需要解释「还差
|
||||
什么」的时候,案卷里存的、卡面上摆的,是一句空话。
|
||||
9. **两笔之间可能存在方向相反的依赖,一趟写不出这个顺序**—— S2 曾把关系表
|
||||
`substitution.relation` 整笔排在差异明细 `substitution.difference_item` 前面:
|
||||
差异明细的主键列声明了 `refersTo`(要求指向的关系行已经真实存在),而关系表的
|
||||
推荐等级/风险/摘要/理由这几个必填列又要读明细表的合成列分布**才算得出来**—— 关系表
|
||||
排前面,`refersTo` 拦住明细表(「要指的那一行还不存在」);排后面,关系表自己那几个
|
||||
必填计算列无从算起。**治法是把关系表拆成同主键的两笔投影,中间夹着差异明细**:
|
||||
第一笔只把主键与不依赖统计的上下文列先摆出来(满足 `refersTo`,这一刻明细表还没有
|
||||
行,所有分布读数都是基座铺的 0,`when` 链落到默认分支 —— 字段值不对但写得出、不报错);
|
||||
第二笔排在明细表已经真实写入之后,用**同一份**投影重算一遍,把第一笔的占位值覆盖成
|
||||
真值。这不是新发明的招式 —— 与 S3 每次人改了四态就要重算关系表,是同一套幂等重算
|
||||
机制,写配方时复用同一个常量,不要抄两份(抄两份早晚分叉)。
|
||||
10. **`gateLedgers` 同时管两件事,漏想一半就会出真人会踩到的坑**:①闸要求哪几张表非空
|
||||
(`_rows > 0`)—— 但「零行 = 没干完」这句话只对**这张卡一定会写**的表成立,列进一张
|
||||
**只是有时候才写**的表会在合法路径上误拦(W05 吃过这个亏:`parts.chip_type` 若不给
|
||||
编辑手段就列进闸,挑一个已有类型是主路,这条路上它天然零行,闸永久判「没干完」,
|
||||
人把别的表填得再全,提交也是 400);②数据工单确认那一刻把闸上点名的表**并进正式表**
|
||||
(`landsOnConfirm`,§3.1)—— 从闸的名单里删掉一张表只顾了①,却会让「引用它的表已经
|
||||
落地、它自己没有落地」:别的表用 `refersTo` 指着它,落地那一刻起正式表里就有一行悬空
|
||||
引用。W05 的解法是让配方**自己把选中的行原样自投影一遍**(不论新建还是选既有的都投,
|
||||
幂等、不产生多余写入)—— 这样 `mine:` 恒非空,该表**同时**满足「闸不会误拦」与
|
||||
「确认时有行可落地」两个要求,可以放心留在 `gateLedgers` 里。**卡上闸住的每一张表,
|
||||
件上必须真有一种写它的手段**(人在件上直接编辑,或配方自己投影进去)—— 否则那颗
|
||||
「确认」钮永远按不下去,而且错误提示只会说「这张卡就是来建它的」,指向一个界面上
|
||||
压根摸不到的操作。
|
||||
|
||||
---
|
||||
|
||||
## 5. 活动包怎么写
|
||||
|
||||
### 5.1 包结构
|
||||
@@ -499,6 +610,21 @@ A1 是 `customerEmail / subject / body / attachments`)。
|
||||
「稳定的活动出错」比新功能不 work 伤得多。
|
||||
8. **用户重复提的要求,说明上一轮没打中病根**:往下再挖一层,别在同一层再修一遍
|
||||
(「工作成果 1:1」挖了三层才到「办结那一刻存快照」这个真解)。
|
||||
9. **测试要能咬住,不能只是好看**(2026-08-30,ACT-WORK-05 重写全程验证方法论沉淀):
|
||||
- **镜像测试证明的是机制,只有读真文件的测试才证明「那个文件此刻是对的」**—— 两码事,
|
||||
要写两条测试。手写数据喂给一个函数、验证它的行为符合预期,证明的是这段逻辑本身没错;
|
||||
但生产代码会漂移,fixture 常常把漂移的差别悄悄抹平(实测账:真身是 `f.label ?? f.name`,
|
||||
手抄件是 `f.name`,fixture 从没暴露过这个差别)。少了一条「import 真身」或「读真文件
|
||||
逐字比对」的测试,这个洞永远合不上。
|
||||
- **变异测试本身也可能是空的**—— 撞见过单行行组下 `tr:last-of-type` 与
|
||||
`tr:first-of-type` 天然等价(表只有一行时,「最后一个」与「第一个」是同一个元素);
|
||||
往真代码里改一个明摆着错的选择器,6 条「变异」测试全绿零反应,证明测试从没真正执行到
|
||||
那段逻辑。写变异测试要**先让它红过一次**,不红就是测试没咬住东西,不是代码没问题。
|
||||
- **能 import 真东西的,一律 import,不要手抄**—— 手抄件从诞生那一刻就开始漂(同一轮
|
||||
连吃三次这个亏:配方读数函数的手抄漂移、`let` 绑定的手抄漂移、DOM 选择器复刻的手抄
|
||||
漂移)。跨模块分层边界(如 `activities/` 与 `apps/api/` 互不静态 import)是少数几个
|
||||
不得不手抄的理由之一;分层边界**内部**(如 `apps/api` 各模块之间)没有理由手抄 ——
|
||||
通常只是少加了一个 `export`。
|
||||
|
||||
## 7. 排障速查表(症状 → 根因 → 处置;全部实测账)
|
||||
|
||||
@@ -545,6 +671,8 @@ A1 是 `customerEmail / subject / body / attachments`)。
|
||||
| 收口前一两拍预算耗尽 | budget 给太紧 | §5.2-⑦ 留一倍余量 |
|
||||
| 代答脚本交执行卡被拒「必填字段未回填:结果」 | 卡没声明 `fields` 时基座兜底加必填「结果」回执字段 | 交卷 `values.result` 带一句 |
|
||||
| 验收 pass 后落地判「选择组未选出口」 | routedPort 只在前端回传里顶上(已修:服务端发事件前算有效口) | 升级基座;临时:回传自带 `selectedPorts` |
|
||||
| 排障时按协议等了、`advancing_until` 也查过是空,还是查不到那条旧任务的证据 | 诊断前先跑了 `reset-runs`,任务行与 `task_memory` 一并清空了 | 排障留痕要在 `reset-runs` 之前捞;要腾新案例的跑道,先把要查的证据记下来再清(2026-08-30) |
|
||||
| 修完平台代码,行为却和源码对不上,`touch` 源文件也没用 | dev 进程跑的是几小时前的旧代码,连自动重建的 `tsup --watch` 监视进程本身都可能已经退出 | 核 `ps -p <pid> -o lstart=` 与最近一次改动的时间对不对得上;不对就整套重启(kill + 重新 `pnpm dev:api`),别指望 `touch`(2026-08-30) |
|
||||
|
||||
## 8. 待实现与已知不一致
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ export default tseslint.config(
|
||||
// api:check 生成声明用的临时盘;它和 api-report 一样是**产物不是源码**。
|
||||
// 不排除的话,api:check 中途失败留下的半成品会让 lint 报上千条假错。
|
||||
'.api-tmp/**',
|
||||
// 稳定版实例的**部署快照**(`.stable/src` 是某个旧 commit 的源码拷贝,`stable.sh` 放的,
|
||||
// gitignore 里也排着)。它与上面三条同类:**产物不是源码** —— 那里的代码改不动
|
||||
// (下一次 `stable.sh update` 就覆盖掉),它的真源是它构建自的那个 commit。
|
||||
// 不排除的话 `pnpm lint` 对谁都是红的,而**长红的门禁等于没有门禁**:
|
||||
// 人会学会忽略 lint 输出,那才是真正的损失。
|
||||
'.stable/**',
|
||||
'tools/depcruise-tiers.generated.cjs',
|
||||
'docs/**',
|
||||
// 验证性原型(spike):一次性的事实探针,跑完留证据即弃 —— 不进产线,也不受产线规矩约束
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { autoRecipeSchema } from '../auto-recipe.ts';
|
||||
|
||||
describe('auto 配方契约', () => {
|
||||
it('收下一份用全了五个动词的配方', () => {
|
||||
const parsed = autoRecipeSchema.safeParse({
|
||||
let: { evalRef: { cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] } },
|
||||
do: [
|
||||
{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: { in: 'originalInternalPn' } }] }, fields: { internal_pn: { col: 'pn' } } },
|
||||
{ kind: 'analyze', model: 'deepseek-v4-flash', into: 'items', instruction: '逐条判四态', input: { from: { each: 'parts.type_param' }, fields: { name: { col: 'param_name' } } }, output: { type: 'object' } },
|
||||
{ kind: 'card', as: 'execution', variant: 'data', title: '补数据', basis: [{ k: '原 PN', v: { in: 'originalInternalPn' } }], gateLedgers: ['parts.profile'] },
|
||||
{ kind: 'submit', rationale: '结论已备好' },
|
||||
{ kind: 'say', text: '这一步办完了' },
|
||||
],
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('不认识的动词当场拒 —— 没实现的形状不许装进来', () => {
|
||||
const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'drop', into: 'a.b' }] });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it('嵌套只许两层:第三层当场拒', () => {
|
||||
const parsed = autoRecipeSchema.safeParse({
|
||||
do: [{ kind: 'project', into: 'a.b', from: { each: 'a.b', then: { each: 'c.d', then: { each: 'e.f' } } }, fields: {} }],
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it('prune 只许挂在 project 上', () => {
|
||||
const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'say', text: 'x', prune: true }] });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
import { planStepSchema } from '../manifest.ts';
|
||||
|
||||
describe('全 auto 的步可以没有 guide', () => {
|
||||
/* brief 原样缺了 intent(required,非空)—— 缺了它 safeParse 恒失败,前两条测试就都验不到「guide 允不允许空」这件事本身 */
|
||||
const base = { item: 'S1', intent: '这一步交出什么', guide: '', writes: [], reads: [], tools: [] };
|
||||
|
||||
it('声明了 auto 就允许 guide 为空 —— 那一步压根不问模型', () => {
|
||||
const parsed = planStepSchema.safeParse({
|
||||
...base,
|
||||
auto: { do: [{ kind: 'say', text: '办完了' }] },
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('没有 auto 又不写 guide → 拒 —— 「作者忘了写做法」这个老错照旧要拦', () => {
|
||||
const parsed = planStepSchema.safeParse(base);
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it('有 auto 但 guide 写了内容 → 照收(半 auto:确定的先做完,剩下的仍问模型)', () => {
|
||||
const parsed = planStepSchema.safeParse({
|
||||
...base,
|
||||
guide: '把结论写实',
|
||||
auto: { do: [{ kind: 'say', text: 'x' }] },
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 确定性步骤的**声明式配方**(ADR-014 / spec 2026-08-29 §5.1)。
|
||||
*
|
||||
* 配方是**数据不是代码**:装进来仍然只是数据,可静态审、可回放、可在治理台上摆出
|
||||
* 「这一步系统会做什么」。执行第三方代码则相反 —— ADR-004 的立场是「不执行包代码即知包为何物」。
|
||||
*
|
||||
* **只声明真实现了的那几式。** 表达力有上限是刻意的:上限之外的不要硬塞,
|
||||
* 退回模型,或者把那一段判断从这一步里切出去。
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
/** 表达式 —— 递归,`z.lazy` 是唯一写法(zod 的自引用) */
|
||||
export type AutoExpr =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
/** 入参 / 基座读数 / `let` 里算好的值 —— 三者同一个命名空间,`let` 优先 */
|
||||
| { readonly in: string }
|
||||
/** 本行的列 */
|
||||
| { readonly col: string }
|
||||
/** 外层那一行的列(两层嵌套时) */
|
||||
| { readonly outer: string }
|
||||
/** 拼接,一律按字符串 */
|
||||
| { readonly cat: readonly AutoExpr[] }
|
||||
/** 只留数字(铸号:`case:openedAt` → `20260829215103`) */
|
||||
| { readonly fmt: AutoExpr; readonly as: 'digits' }
|
||||
/** 查另一张表的一列;查不到给 `else`(缺省空串) */
|
||||
| {
|
||||
readonly lookup: string;
|
||||
readonly by: Readonly<Record<string, AutoExpr>>;
|
||||
readonly take: string;
|
||||
readonly else?: AutoExpr | undefined;
|
||||
}
|
||||
/** S-FEEL 条件(可嵌套)—— 已算出的同行字段进它的作用域 */
|
||||
| { readonly when: string; readonly then: AutoExpr; readonly else: AutoExpr };
|
||||
|
||||
export const autoExprSchema: z.ZodType<AutoExpr> = z.lazy(() =>
|
||||
z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.object({ in: z.string().min(1) }).strict(),
|
||||
z.object({ col: z.string().min(1) }).strict(),
|
||||
z.object({ outer: z.string().min(1) }).strict(),
|
||||
z.object({ cat: z.array(autoExprSchema).min(1) }).strict(),
|
||||
z.object({ fmt: autoExprSchema, as: z.literal('digits') }).strict(),
|
||||
z
|
||||
.object({
|
||||
lookup: z.string().min(1),
|
||||
by: z.record(z.string(), autoExprSchema),
|
||||
take: z.string().min(1),
|
||||
else: autoExprSchema.optional(),
|
||||
})
|
||||
.strict(),
|
||||
z.object({ when: z.string().min(1), then: autoExprSchema, else: autoExprSchema }).strict(),
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* 数据源 —— 这一笔要对着几行办。
|
||||
*
|
||||
* 地址只有四种(spec §5.1):`域.对象`(**任务视图 = 正式表 + 本单草稿叠加**,默认该用的那一种)·
|
||||
* `mine:域.对象`(只有本单草稿写下的行)· `in:入参数组` · `result:xxx`(本拍 analyze 的出参)。
|
||||
* **没有「纯正式表」那一种** —— 要区分「本单写的」与「别人放行的」,`mine:` 就够了。
|
||||
*/
|
||||
export type AutoSource =
|
||||
| { readonly once: true }
|
||||
| { readonly rows: readonly Readonly<Record<string, AutoExpr>>[] }
|
||||
| {
|
||||
readonly each: string;
|
||||
readonly where?: Readonly<Record<string, AutoExpr>> | undefined;
|
||||
/** 第二层,**到此为止** —— 三层以上就别 auto 了 */
|
||||
readonly then?:
|
||||
| { readonly each: string; readonly where?: Readonly<Record<string, AutoExpr>> | undefined }
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const autoSourceSchema: z.ZodType<AutoSource> = z.union([
|
||||
z.object({ once: z.literal(true) }).strict(),
|
||||
z.object({ rows: z.array(z.record(z.string(), autoExprSchema)).min(1) }).strict(),
|
||||
z
|
||||
.object({
|
||||
each: z.string().min(1),
|
||||
where: z.record(z.string(), autoExprSchema).optional(),
|
||||
then: z
|
||||
.object({ each: z.string().min(1), where: z.record(z.string(), autoExprSchema).optional() })
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
/** 五个动词 —— `when` 为假就跳过这一笔 */
|
||||
export type AutoAction =
|
||||
| {
|
||||
readonly kind: 'project';
|
||||
readonly when?: string | undefined;
|
||||
/** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */
|
||||
readonly into: string;
|
||||
/** 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层 */
|
||||
readonly prune?: boolean | undefined;
|
||||
readonly from: AutoSource;
|
||||
readonly fields: Readonly<Record<string, AutoExpr>>;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'analyze';
|
||||
readonly when?: string | undefined;
|
||||
/** 活动声明过的模型名 */
|
||||
readonly model: string;
|
||||
/** 出参落进拍内作用域 `result:<into>`,供后面的 project 用 `each`/`lookup` 消费 */
|
||||
readonly into: string;
|
||||
/** 给模型的 system 段 —— 说清判什么、四态各是什么意思 */
|
||||
readonly instruction: string;
|
||||
/** 入参由声明组装:一行一条,`fields` 决定每条摆哪几个键 */
|
||||
readonly input: { readonly from: AutoSource; readonly fields: Readonly<Record<string, AutoExpr>> };
|
||||
/** 出参 JSON Schema —— 顶层必须是 object */
|
||||
readonly output: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'card';
|
||||
readonly when?: string | undefined;
|
||||
readonly as: 'execution' | 'decide';
|
||||
readonly variant?: ('data' | 'review') | undefined;
|
||||
readonly title: AutoExpr;
|
||||
/** 决策卡问的那句话 */
|
||||
readonly ask?: AutoExpr | undefined;
|
||||
/** 作业指导书 —— 一条一句,静态文本 */
|
||||
readonly guide?: readonly string[] | undefined;
|
||||
readonly standard?: AutoExpr | undefined;
|
||||
/** 调用必附上下文 —— **基座拒发 basis 为空的工单**,别指望兜底(它摆的是内部家具) */
|
||||
readonly basis?: readonly { readonly k: string; readonly v: AutoExpr }[] | undefined;
|
||||
/** 这张卡的提交闸盯哪几张表 */
|
||||
readonly gateLedgers?: readonly string[] | undefined;
|
||||
/** 决策卡的选项;`variant:'review'` 时允许只有一个 */
|
||||
readonly options?:
|
||||
| readonly { readonly value: string; readonly label: string; readonly detail?: string | undefined }[]
|
||||
| undefined;
|
||||
}
|
||||
| { readonly kind: 'submit'; readonly when?: string | undefined; readonly rationale: AutoExpr }
|
||||
| { readonly kind: 'say'; readonly when?: string | undefined; readonly text: AutoExpr };
|
||||
|
||||
export const autoActionSchema: z.ZodType<AutoAction> = z.discriminatedUnion('kind', [
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('project'),
|
||||
when: z.string().min(1).optional(),
|
||||
into: z.string().regex(/^[a-z_]+\.[a-z_]+$/, '台账地址写成「域.对象」'),
|
||||
prune: z.boolean().optional(),
|
||||
from: autoSourceSchema,
|
||||
fields: z.record(z.string(), autoExprSchema),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('analyze'),
|
||||
when: z.string().min(1).optional(),
|
||||
model: z.string().min(1),
|
||||
into: z.string().min(1),
|
||||
instruction: z.string().min(1),
|
||||
input: z.object({ from: autoSourceSchema, fields: z.record(z.string(), autoExprSchema) }).strict(),
|
||||
output: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('card'),
|
||||
when: z.string().min(1).optional(),
|
||||
as: z.enum(['execution', 'decide']),
|
||||
variant: z.enum(['data', 'review']).optional(),
|
||||
title: autoExprSchema,
|
||||
ask: autoExprSchema.optional(),
|
||||
guide: z.array(z.string().min(1)).optional(),
|
||||
standard: autoExprSchema.optional(),
|
||||
basis: z.array(z.object({ k: z.string().min(1), v: autoExprSchema }).strict()).optional(),
|
||||
gateLedgers: z.array(z.string().min(1)).optional(),
|
||||
options: z
|
||||
.array(z.object({ value: z.string().min(1), label: z.string().min(1), detail: z.string().optional() }).strict())
|
||||
.optional(),
|
||||
})
|
||||
.strict(),
|
||||
z.object({ kind: z.literal('submit'), when: z.string().min(1).optional(), rationale: autoExprSchema }).strict(),
|
||||
z.object({ kind: z.literal('say'), when: z.string().min(1).optional(), text: autoExprSchema }).strict(),
|
||||
]);
|
||||
|
||||
export type AutoRecipe = {
|
||||
/** 配方级局部值:先算一遍,后面所有表达式都能 `{in:'名'}` 引用 —— 整单只有一个案号,靠它 */
|
||||
readonly let?: Readonly<Record<string, AutoExpr>> | undefined;
|
||||
/** 按序执行 */
|
||||
readonly do: readonly AutoAction[];
|
||||
};
|
||||
|
||||
export const autoRecipeSchema: z.ZodType<AutoRecipe> = z
|
||||
.object({
|
||||
let: z.record(z.string(), autoExprSchema).optional(),
|
||||
do: z.array(autoActionSchema).min(1),
|
||||
})
|
||||
.strict();
|
||||
@@ -16,4 +16,5 @@ export * from './manifest.ts';
|
||||
export * from './org.ts';
|
||||
export * from './mail.ts';
|
||||
export * from './activity-ops.ts';
|
||||
export * from './auto-recipe.ts';
|
||||
export * from './api/index.ts';
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { activityIdSchema, skillIdSchema } from './id.ts';
|
||||
import { autoRecipeSchema } from './auto-recipe.ts';
|
||||
import { jsonSchemaSchema, sevenPartContractSchema } from './contract7.ts';
|
||||
import { listenerSpecSchema } from './envelope.ts';
|
||||
import { objectDeclarationSchema } from './ledger.ts';
|
||||
@@ -118,7 +119,7 @@ export const planStepSchema = z.object({
|
||||
/** 一句话:这一步交出什么 */
|
||||
intent: z.string().min(1),
|
||||
/** 这一步的做法(从说明书里搬过来的那一段)—— 只在轮到它时进上下文 */
|
||||
guide: z.string().min(1),
|
||||
guide: z.string(),
|
||||
/** 这一步可写哪几个台账地址「域.对象」;空 = 这一步不写台账 */
|
||||
writes: z.array(z.string()).default([]),
|
||||
/**
|
||||
@@ -175,7 +176,46 @@ export const planStepSchema = z.object({
|
||||
* 卡上一条一枚「达标 / 不达标」开关,全部达标才能「确认通过」。
|
||||
*/
|
||||
acceptanceChecklist: z.array(z.string().min(1)).optional(),
|
||||
});
|
||||
/**
|
||||
* **这一步的这几张台账归人填,模型不许写**(2026-08-28 用户实测逼出来的)。
|
||||
*
|
||||
* 数据工单要人在件上补的那几张表 —— 值只可能从人那儿来,模型没有任何来源。
|
||||
* 实测:P-10 原厂技术资料那一步,模型以为卡已经答过了,又被 `doneWhen`(资料必填不缺)
|
||||
* 摆成目标,就把**台账里原 PN 那一行的规格书**(标题 / 版本 / 链接逐字)抄到了候选料号头上 ——
|
||||
* 行找对了、料找错了,而这份假证据会直接喂进差异比对当「硬约束有证据支持」。
|
||||
*
|
||||
* 一个闲着的模型 + 一个它够得着的判据 = 它一定会去凑那个判据。所以这里立墙:
|
||||
* 声明了的地址,`write` 当场拒并指路「发那张卡去,值由人填」。`drop` 不拦(写错了得撤得掉)。
|
||||
*
|
||||
* **只声明「模型永远没有来源」的表**。有附件时该预填的(P-08 的参数值从 datasheet 抽)别声明 ——
|
||||
* 那是它的活。判法认点名的项,点不出退回当下在办的那一步。
|
||||
*/
|
||||
/* 可选:老活动一个字都不用改(`writes`/`reads` 用 default([]) 是历史,这里不跟 —— 加默认值会逼六个包全改) */
|
||||
humanWrites: z.array(z.string()).optional(),
|
||||
/**
|
||||
* **这一步系统按声明自己做**(2026-08-29,ADR-014 / spec §5)。
|
||||
*
|
||||
* 引擎在这一步的每一拍拍首跑配方:发了卡就收拍(零 token);没发卡且这一步没有 `guide`
|
||||
* 就重求 `doneWhen` 收步;有 `guide` 的照常问模型(半 auto)。
|
||||
* **老活动一个字不用改** —— 不写就是不写,不是缺件。
|
||||
*/
|
||||
auto: autoRecipeSchema.optional(),
|
||||
})
|
||||
.superRefine((step, ctx) => {
|
||||
/*
|
||||
* **全 auto 的步没有 guide 是正常形态**(2026-08-29):引擎按 `guide.trim() === ''`
|
||||
* 分「半 auto(确定的先做完,剩下的仍问模型)」与「全 auto(这一步一次都不问模型)」。
|
||||
* 而 `.min(1)` 让后者压根写不出来 —— 四步全 auto 的活动会在装包那一刻被拒。
|
||||
* 非空这条要求仍留给没有 auto 的步:**「作者忘了写做法」是个老错,照旧要拦**。
|
||||
*/
|
||||
if (step.auto === undefined && step.guide.trim() === '') {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['guide'],
|
||||
message: '这一步的做法不能为空 —— 只有声明了 auto 配方的步才允许没有 guide',
|
||||
});
|
||||
}
|
||||
});
|
||||
export type PlanStep = z.infer<typeof planStepSchema>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* **`addRow()`/`bodyOf()` 对 `<thead>` 位置敏感 —— 这条测试守的是通用机制,不是某个活动的
|
||||
* HTML**(2026-08-30,复审用变异逮到:`view-render-addrow-thead.test.ts` 里那 6 条只 import
|
||||
* 了一份手抄的选择器复刻,把 `addRow` 真实实现里的 `tr:last-of-type` 换成明摆着错的
|
||||
* `tr:first-of-type` 重跑,6/6 照样全绿零反应 —— 全仓当时没有任何测试真的执行 `bodyOf`/
|
||||
* `addRow` 这两个函数)。
|
||||
*
|
||||
* 起因(ACT-WORK-05 Task 7b):当 `data-edit="域.对象"` 挂在**外层 `<table>`**(不是只挂在
|
||||
* `<tbody data-rows>` 上)时,`bodyOf()` 的单个 `document.querySelector` 解析到的是这个
|
||||
* `<table>` 本身;`addRow()` 接着在这个 host 里找 `tr:last-of-type`。CSS `:last-of-type`
|
||||
* **按各自父元素**判定,`<thead>` 里孤零零的那个 `<tr>` 在它自己的父元素(`thead`)里必然
|
||||
* 「最后」——若 `<thead>` 排在数据行**前面**(常规写法),`querySelectorAll` 会**优先命中它**
|
||||
* (文档序在前):克隆出来的是表头文字、零个 `input[data-col]`,不是一行能填的空白。
|
||||
* 把 `<thead>` 挪到数据行**之后**就避开了(CSS `table-header-group` 视觉上仍永远排最前,
|
||||
* 呈现不变,`querySelector` 认的是文档序不是视觉序)。
|
||||
*
|
||||
* `bodyOf`/`addRow` 原本没有 `export`(零行为改动,只是加宽可见性)——两函数本就是纯粹按
|
||||
* `Document`/字符串地址操作,不依赖任何活动的具体内容,合成的最小 fixture 就测得出这条
|
||||
* 通用机制,不必碰任何真活动的 HTML(那条路会撞 `apps-not-into-activities`)。
|
||||
*
|
||||
* **重要一点(写这份测试时自己踩过两次,记下来免得下一个人重踩)**:
|
||||
* ① `addRow()` 的 `host.appendChild(fresh)` 把克隆出来的新行直接挂在 `host`
|
||||
* (即那张 `<table>`)下面,不是插回原来那个 `<tbody>` 或 `<thead>`——所以判断
|
||||
* 「克隆对不对」不能查新行的**落点**(它永远是 `<table>` 的直接子节点,这本身不是
|
||||
* bug,HTML5 允许 `<tr>` 直接挂在 `<table>` 下,浏览器按匿名行组渲染),只能查新行的
|
||||
* **内容**(有没有 `<th>`、`input[data-col]` 携带的是哪一行的值)。
|
||||
* ② `tr:last-of-type`(真实实现)与 `tr:first-of-type`(变异后)在**只有一行**的
|
||||
* 行组里**永远等价**(只有一个元素,天然同时是「第一个」与「最后一个」)——早期版本
|
||||
* 每个 `<tbody>` 都只放一行,拿 `tr:first-of-type` 去跑照样全绿,等于白测。要让两个
|
||||
* 选择器分道扬镳,动态 `<tbody>` 必须**至少两行**,且两行要带得出「究竟克隆了哪一行」
|
||||
* 的证据(`data-marker`)——`withDataRows: 2` 那组就是干这个的。
|
||||
*
|
||||
* **这份文件与另外两处测试各守各的**(缺一不可,写清楚免得再被合并成一条):
|
||||
* ①这里守**机制**——`addRow`/`bodyOf` 面对「data-edit 挂外层 table + thead 在前/在后 +
|
||||
* 多行数据」这几种通用形状时的真实行为;②`apps/api/src/modules/interaction/__tests__/
|
||||
* view-render-addrow-thead.test.ts` 守**W05 这两张表此刻的具体 HTML 形状是对的**
|
||||
* (真实 `renderView()` 输出,但选择器那一步仍是手抄复刻,不覆盖机制本身);
|
||||
* ③`activities/part-substitution-assessment/__tests__/activity.test.ts` 里那条
|
||||
* 「回归安全网」守**源码字符串层面** thead 确实排在两个 tbody 之后。
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { addRow, bodyOf } from '../src/ViewContainer.tsx';
|
||||
|
||||
/**
|
||||
* 最小合成 fixture:`data-edit="d.o"` 只挂外层 `<table>`(W05 那两张表的真实挂法,
|
||||
* 为了让 `collect()` 同时收到静态种子行与动态行);`<thead>` 位置可控,动态 `<tbody>`
|
||||
* 里的数据行数可控(每行带 `data-marker` 标出「我是第几行」,用来证明克隆的到底是哪一行)。
|
||||
*/
|
||||
function table(opts: { theadFirst: boolean; dataRows: number }): Document {
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
const thead = '<thead><tr><th>#</th><th data-h="名称">名称</th></tr></thead>';
|
||||
const dataRowsHtml = Array.from({ length: opts.dataRows }, (_, i) => i + 1)
|
||||
.map((n) => `<tr data-marker="row-${n}"><td class="rn"></td><td><input class="jc-cell" data-col="name" value="existing-${n}"></td></tr>`)
|
||||
.join('');
|
||||
const dynamicTbody = `<tbody data-rows data-edit="d.o">${dataRowsHtml}</tbody>`;
|
||||
const seedRow = '<tr class="jc-row-static"><td class="rn"></td><td><input class="jc-cell" data-col="name" placeholder="新的"></td></tr>';
|
||||
const staticTbody = `<tbody>${seedRow}</tbody>`;
|
||||
const inner = opts.theadFirst
|
||||
? `${thead}${dynamicTbody}${staticTbody}`
|
||||
: `${dynamicTbody}${staticTbody}${thead}`;
|
||||
doc.body.innerHTML = `<div class="jc-tbl-wrap"><table data-edit="d.o">${inner}</table></div>`;
|
||||
return doc;
|
||||
}
|
||||
|
||||
describe('bodyOf():data-edit 挂外层 <table> 时,解析到的就是那个 <table> 本身', () => {
|
||||
it('无论 thead 在前在后,bodyOf 命中的都是 <table>(唯一挂了 data-edit 的外层元素)', () => {
|
||||
for (const theadFirst of [true, false]) {
|
||||
const doc = table({ theadFirst, dataRows: 0 });
|
||||
const host = bodyOf(doc, 'd.o');
|
||||
expect(host?.tagName, `theadFirst=${theadFirst}`).toBe('TABLE');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRow():<thead> 排在数据行前面 —— 克隆到表头(坏的那一形,零行时)', () => {
|
||||
it('克隆出来的新行内容是表头(带 <th>),零个 input[data-col] —— 按钮点了有反应,结果不可用', () => {
|
||||
const doc = table({ theadFirst: true, dataRows: 0 });
|
||||
const before = doc.querySelectorAll('tr').length;
|
||||
|
||||
const ok = addRow(doc, 'd.o');
|
||||
|
||||
expect(ok).toBe(true); // 找到了「样本」去克隆,只是克隆错了行 —— 不报错、不失败,静默产出垃圾
|
||||
expect(doc.querySelectorAll('tr').length).toBe(before + 1);
|
||||
const fresh = doc.querySelector('.jc-row-new');
|
||||
expect(fresh).not.toBeNull();
|
||||
// 克隆出来的新行是 <table> 的直接子节点(addRow 的 host.appendChild 就是这么接的,
|
||||
// 不是插回原来的 <thead>——判断靠内容不靠落点,见头注①)
|
||||
expect(fresh?.parentElement?.tagName).toBe('TABLE');
|
||||
expect(fresh?.querySelectorAll('th')).toHaveLength(2); // 内容是表头文字(#、名称)
|
||||
expect(fresh?.querySelectorAll('input[data-col]')).toHaveLength(0); // 零个可填格子
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRow():<thead> 排在两个 <tbody> 之后 —— 克隆到数据行(好的那一形,零行时)', () => {
|
||||
it('克隆出来的新行是种子行的内容:1 个具名 input[data-col],不含 <th>', () => {
|
||||
const doc = table({ theadFirst: false, dataRows: 0 });
|
||||
|
||||
const ok = addRow(doc, 'd.o');
|
||||
|
||||
expect(ok).toBe(true);
|
||||
const fresh = doc.querySelector('.jc-row-new');
|
||||
expect(fresh).not.toBeNull();
|
||||
expect(fresh?.querySelectorAll('th')).toHaveLength(0);
|
||||
const inputs = Array.from(fresh?.querySelectorAll<HTMLInputElement>('input[data-col]') ?? []);
|
||||
expect(inputs.map((i) => i.getAttribute('data-col'))).toEqual(['name']);
|
||||
// 新克隆的行值清空(种子行原样是空的,这里额外钉一遍「不是抄了旧值」)
|
||||
expect(inputs[0]?.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **这一组是让 `tr:last-of-type` 与 `tr:first-of-type` 真正分道扬镳的那一组**
|
||||
* (见头注②)——动态 `<tbody>` 摆两行(`existing-1`/`existing-2`),`addRow` 该续克隆
|
||||
* 的是**最后**那一行。真实实现命中 `existing-2`;若选择器被换成 `tr:first-of-type`,
|
||||
* 会命中 `existing-1`——这正是复审要求的变异要能让测试变红的落点。
|
||||
*/
|
||||
describe('addRow():动态 tbody 有两条真实数据行(post-first-save 状态)—— 续克隆的必须是最后一条', () => {
|
||||
it('克隆样本携带 row-2 的标记(不是 row-1、不是种子行、不是表头)', () => {
|
||||
const doc = table({ theadFirst: false, dataRows: 2 });
|
||||
|
||||
const ok = addRow(doc, 'd.o');
|
||||
|
||||
expect(ok).toBe(true);
|
||||
const fresh = doc.querySelector('.jc-row-new');
|
||||
expect(fresh?.querySelectorAll('th')).toHaveLength(0);
|
||||
// 克隆样本原本挂着 data-marker="row-2"(cloneNode 会带过来,addRow 没有清它)
|
||||
expect(fresh?.getAttribute('data-marker')).toBe('row-2');
|
||||
const input = fresh?.querySelector<HTMLInputElement>('input[data-col="name"]');
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.hasAttribute('placeholder')).toBe(false); // 种子行的 input 才带 placeholder
|
||||
expect(input?.value).toBe(''); // 值已被 addRow 清空(不是抄 "existing-2" 过来)
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRow():真的一整张表连表头都没有一行 —— 找不到样本可克隆,如实返回 false', () => {
|
||||
it('host 存在但一个 <tr> 都没有(连 thead 也没有)时,addRow 不产出垃圾,直接说做不到', () => {
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
doc.body.innerHTML = '<table data-edit="d.o"></table>';
|
||||
expect(bodyOf(doc, 'd.o')?.tagName).toBe('TABLE');
|
||||
expect(addRow(doc, 'd.o')).toBe(false);
|
||||
expect(doc.querySelectorAll('.jc-row-new')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* **平坦退路形状:瓷贴是 `data-edit` 宿主的直接子元素,行容器是 `<div>` 不是 `<tr>`**
|
||||
* (2026-08-30,W05 数据工单版面探针)。
|
||||
*
|
||||
* 对照 `view-container-collect-nested.test.ts` 那条「外层按料分组、内层铺瓷贴」的嵌套形状——
|
||||
* 那种形状里瓷贴与 host 之间隔了两层(候选料的 `<section>` + 内层 `data-rows` 留下的 `<div>`),
|
||||
* `rowOf()` 爬到 host 直接子元素时只能到 `<section>`,把一颗料的 3 个瓷贴全部并成一条记录、
|
||||
* 后写的列覆盖先写的,悄悄丢数据。
|
||||
*
|
||||
* 这里是**能用的退路**:`data-rows="mine:parts.parameter"` 直接铺在 host(`data-edit` 那个元素)
|
||||
* 身上,不分料、不套内层 —— 瓷贴(`<div class="pv">`)因此是 host 的**直接子元素**,`rowOf()`
|
||||
* 第一步往上爬就撞到 host,一个瓷贴就是一行,互不干扰。W05 若要「一个候选一个卡片」的排版,
|
||||
* 换个思路做(每颗候选各给一张单独的 `data-edit` 宿主,或纯展示的分组包着这一层可编辑的平铺表),
|
||||
* 不能靠嵌套 `data-rows` 省一层。
|
||||
*
|
||||
* `rowOf()`(`../src/ViewContainer.tsx:178`)按 `parentElement` 一路爬,不认标签名 ——
|
||||
* 现有 `view-container-collect.test.ts` 的用例全是 `<tr>` 行(表格),这里换成 `<div class="pv">`
|
||||
* 的瓷贴卡,证明它不是"认表格"这条窄路,是通用的"host 的直接子元素就是行"。
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collect } from '../src/ViewContainer.tsx';
|
||||
|
||||
/** 两颗候选料、每颗 3 个参数瓷贴(与嵌套形状那条用同一组事实数据,方便对照) */
|
||||
const TILES_HOST =
|
||||
'<div data-edit="parts.parameter">' +
|
||||
'<div class="pv"><input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="param_key" value="input_voltage"/><input data-col="param_value" value="4.5~40"/></div>' +
|
||||
'<div class="pv"><input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="param_key" value="output_voltage"/><input data-col="param_value" value="5"/></div>' +
|
||||
'<div class="pv"><input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="param_key" value="enable_threshold"/><input data-col="param_value" value="2.5"/></div>' +
|
||||
'<div class="pv"><input data-col="internal_pn" value="XL2596S-5.0"/><input data-col="param_key" value="input_voltage"/><input data-col="param_value" value="4.5~40"/></div>' +
|
||||
'<div class="pv"><input data-col="internal_pn" value="XL2596S-5.0"/><input data-col="param_key" value="output_voltage"/><input data-col="param_value" value="5"/></div>' +
|
||||
'<div class="pv"><input data-col="internal_pn" value="XL2596S-5.0"/><input data-col="param_key" value="enable_threshold"/><input data-col="param_value" value="3.0"/></div>' +
|
||||
'</div>';
|
||||
|
||||
function docOf(html: string): Document {
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
doc.body.innerHTML = html;
|
||||
return doc;
|
||||
}
|
||||
|
||||
describe('collect():瓷贴(<div class="pv">)是 data-edit 宿主的直接子元素 —— 一瓷贴一记录,互不覆盖', () => {
|
||||
it('6 个瓷贴收到 6 条记录(不是并成 2 条候选级记录)', () => {
|
||||
const got = collect(docOf(TILES_HOST), 'parts.parameter');
|
||||
expect(got).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('每条记录三格各自带对:主键 internal_pn + param_key + param_value,不互相覆盖', () => {
|
||||
const got = collect(docOf(TILES_HOST), 'parts.parameter');
|
||||
// 同一颗料的 3 条参数各自独立成行,3 个键值全部保留(与嵌套形状那条"只剩最后一瓷贴"相反)
|
||||
const lm = got.filter((r) => r['internal_pn'] === 'LM2596S-5.0');
|
||||
expect(lm).toHaveLength(3);
|
||||
const byKey = new Map(lm.map((r) => [r['param_key'], r['param_value']]));
|
||||
expect(byKey.get('input_voltage')).toBe('4.5~40');
|
||||
expect(byKey.get('output_voltage')).toBe('5');
|
||||
expect(byKey.get('enable_threshold')).toBe('2.5');
|
||||
|
||||
const xl = got.filter((r) => r['internal_pn'] === 'XL2596S-5.0');
|
||||
expect(xl).toHaveLength(3);
|
||||
expect(new Map(xl.map((r) => [r['param_key'], r['param_value']])).get('enable_threshold')).toBe('3.0');
|
||||
});
|
||||
|
||||
/*
|
||||
* 行容器换一个跟表格毫无关系的标签(`<li>`)结果不变 —— 证明 `rowOf` 认的是
|
||||
* "host 的直接子元素",不是专挑某个标签名。
|
||||
*
|
||||
* 特意不拿 `<tr>` 来比:裸 `<tr>`(不包在 `<table><tbody>` 里)会撞上 HTML 解析算法的
|
||||
* "foster parenting"(表格内容规则规定 `<tr>` 只能直接装 `<td>/<th>`,`<input>` 这类
|
||||
* 不合规内容会被解析器整体挪到 `<tr>` 外面)——真拿裸 tr 包 input 试过,jsdom 吐出的是
|
||||
* 9 条(3 个空 `<tr>` + 6 个被挪出来的 `<input>` 各自成行),这是 HTML 解析的固有行为,
|
||||
* 与 `rowOf` 是否"认标签"无关,拿来当反例只会误导。已有的 `view-container-collect.test.ts`
|
||||
* 用 `<table><tbody data-edit>` 包着的 `<tr>` 早就证明过 tr 这条路是对的。
|
||||
*/
|
||||
it('行容器换成完全不相干的标签(<li>)结果不变 —— 证明 rowOf 认的是"host 的直接子元素",不是专挑某个标签名', () => {
|
||||
const liHost =
|
||||
'<div data-edit="parts.parameter">' +
|
||||
'<li class="pv"><input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="param_key" value="input_voltage"/><input data-col="param_value" value="4.5~40"/></li>' +
|
||||
'<li class="pv"><input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="param_key" value="output_voltage"/><input data-col="param_value" value="5"/></li>' +
|
||||
'<li class="pv"><input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="param_key" value="enable_threshold"/><input data-col="param_value" value="2.5"/></li>' +
|
||||
'</div>';
|
||||
const got = collect(docOf(liHost), 'parts.parameter');
|
||||
expect(got).toHaveLength(3);
|
||||
expect(new Set(got.map((r) => r['param_key']))).toEqual(new Set(['input_voltage', 'output_voltage', 'enable_threshold']));
|
||||
});
|
||||
|
||||
it('变异验证:去掉一个瓷贴整行,记录数跟着从 6 变成 5 —— 证明「6 条」不是巧合,是按瓷贴数记', () => {
|
||||
const oneLess = TILES_HOST.replace(
|
||||
'<div class="pv"><input data-col="internal_pn" value="XL2596S-5.0"/><input data-col="param_key" value="enable_threshold"/><input data-col="param_value" value="3.0"/></div>',
|
||||
'',
|
||||
);
|
||||
const got = collect(docOf(oneLess), 'parts.parameter');
|
||||
expect(got).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('变异验证:摘掉一个瓷贴的主键 data-col(internal_pn)—— 只有那一条记录缺这一列,其余 5 条不受影响', () => {
|
||||
const withoutOnePk = TILES_HOST.replace(
|
||||
'<input data-col="internal_pn" value="LM2596S-5.0"/><input data-col="param_key" value="enable_threshold"/>',
|
||||
'<input value="LM2596S-5.0"/><input data-col="param_key" value="enable_threshold"/>',
|
||||
);
|
||||
const got = collect(docOf(withoutOnePk), 'parts.parameter');
|
||||
expect(got).toHaveLength(6);
|
||||
const missingPk = got.filter((r) => !('internal_pn' in r));
|
||||
expect(missingPk).toHaveLength(1);
|
||||
expect(missingPk[0]?.['param_key']).toBe('enable_threshold');
|
||||
expect(missingPk[0]?.['param_value']).toBe('2.5');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* **嵌套 `data-rows`(外层按料分组、内层铺参数瓷贴)喂给真实 `collect()`,会并成一条记录**
|
||||
* (2026-08-30,W05 数据工单版面探针 —— 探的是「一个候选一个 section、section 里铺参数瓷贴」
|
||||
* 这形状能不能拿来做可编辑版面)。
|
||||
*
|
||||
* 这段夹具字符串 **原样照抄自** `apps/api/src/modules/interaction/__tests__/
|
||||
* view-render-nested-editable.test.ts` 里 `renderView(NESTED_TPL, NESTED_SCOPE)` 的真实产出
|
||||
* (该文件断言过逐字节相等,变量名同为 `NESTED_RENDERED`)——两侧各钉一条是因为 `collect()`
|
||||
* 住在 `@ui`(tsconfig.web.json:`jsx: react-jsx` + DOM lib)、`renderView` 住在 `apps/api`
|
||||
* (tsconfig.node.json:两者都没有对方要的编译选项),`packages-are-leaves` 门禁也不许
|
||||
* `packages/*` 反向 import `apps/*`——两侧不能用同一份代码/常量接力,只能用**同一段字符串**
|
||||
* 接力,不一致就靠这条注释与两处的断言互相盯着。
|
||||
*
|
||||
* ── 结论 ──────────────────────────────────────────────────────────────────
|
||||
* `rowOf()` 从格子往上爬,爬到 host(`[data-edit="parts.parameter"]`,即 `<article>`)的
|
||||
* **直接子元素**为止——嵌套形状里那正是每颗候选料的 `<section>`,不是瓷贴自己的 `<span class="pv">`
|
||||
* (瓷贴与 section 之间还隔着内层 `data-rows` 留下的那层 `<div>`)。于是一颗料的 3 个瓷贴全部
|
||||
* 归到同一个 `<section>` 名下:`collect()` 只吐出 **2 条记录**(candidate 数,不是瓷贴数),
|
||||
* 且同一列被后一个瓷贴的值**悄悄覆盖**前一个——`param_key`/`param_value` 最终只剩最后一颗瓷贴
|
||||
* (`enable_threshold`)的值,`input_voltage`/`output_voltage` 两条参数的编辑结果整个丢失,
|
||||
* 界面上却看不出任何报错。**所以数据工单版面不许这么嵌**——要么瓷贴与 host 之间只隔一层
|
||||
* (见 `view-container-collect-div-rows.test.ts` 的平坦退路形状),要么给每个瓷贴单独一张
|
||||
* `data-edit` 宿主。
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collect } from '../src/ViewContainer.tsx';
|
||||
|
||||
/** 与 view-render-nested-editable.test.ts 的 `NESTED_RENDERED` 逐字节相同 —— 见该文件头注 */
|
||||
const NESTED_RENDERED =
|
||||
'<article data-edit="parts.parameter"><section><h3>LM2596S-5.0</h3><div>' +
|
||||
'<span class="pv"><input data-col="param_key" value="input_voltage"/>' +
|
||||
'<input data-col="param_value" value="4.5~40"/>' +
|
||||
'<input data-col="internal_pn" value="LM2596S-5.0"/></span>' +
|
||||
'<span class="pv"><input data-col="param_key" value="output_voltage"/>' +
|
||||
'<input data-col="param_value" value="5"/>' +
|
||||
'<input data-col="internal_pn" value="LM2596S-5.0"/></span>' +
|
||||
'<span class="pv"><input data-col="param_key" value="enable_threshold"/>' +
|
||||
'<input data-col="param_value" value="2.5"/>' +
|
||||
'<input data-col="internal_pn" value="LM2596S-5.0"/></span>' +
|
||||
'</div></section><section><h3>XL2596S-5.0</h3><div>' +
|
||||
'<span class="pv"><input data-col="param_key" value="input_voltage"/>' +
|
||||
'<input data-col="param_value" value="4.5~40"/>' +
|
||||
'<input data-col="internal_pn" value="XL2596S-5.0"/></span>' +
|
||||
'<span class="pv"><input data-col="param_key" value="output_voltage"/>' +
|
||||
'<input data-col="param_value" value="5"/>' +
|
||||
'<input data-col="internal_pn" value="XL2596S-5.0"/></span>' +
|
||||
'<span class="pv"><input data-col="param_key" value="enable_threshold"/>' +
|
||||
'<input data-col="param_value" value="3.0"/>' +
|
||||
'<input data-col="internal_pn" value="XL2596S-5.0"/></span>' +
|
||||
'</div></section></article>';
|
||||
|
||||
function docOf(html: string): Document {
|
||||
const doc = document.implementation.createHTMLDocument('');
|
||||
doc.body.innerHTML = html;
|
||||
return doc;
|
||||
}
|
||||
|
||||
describe('collect():外层 data-rows 分组、内层 data-rows 铺瓷贴 —— 一个候选的三个瓷贴并成一条记录', () => {
|
||||
it('两颗候选料只收到 2 条记录,不是 6 条(3 瓷贴 × 2 料)——瓷贴被 section 并行掉了', () => {
|
||||
const got = collect(docOf(NESTED_RENDERED), 'parts.parameter');
|
||||
expect(got).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('同一 section 内后一个瓷贴的列值覆盖前一个:param_key/param_value 只剩最后一瓷贴(enable_threshold),前两条参数的编辑结果丢失', () => {
|
||||
const got = collect(docOf(NESTED_RENDERED), 'parts.parameter');
|
||||
const byPn = new Map(got.map((r) => [r['internal_pn'], r]));
|
||||
// 3 个瓷贴按 DOM 序是 input_voltage → output_voltage → enable_threshold,最后写的赢
|
||||
expect(byPn.get('LM2596S-5.0')).toEqual({ param_key: 'enable_threshold', param_value: '2.5', internal_pn: 'LM2596S-5.0' });
|
||||
expect(byPn.get('XL2596S-5.0')).toEqual({ param_key: 'enable_threshold', param_value: '3.0', internal_pn: 'XL2596S-5.0' });
|
||||
// input_voltage / output_voltage 两条参数的值,在 collect() 的输出里完全找不到痕迹
|
||||
expect(JSON.stringify(got)).not.toContain('input_voltage');
|
||||
expect(JSON.stringify(got)).not.toContain('output_voltage');
|
||||
});
|
||||
|
||||
it('变异验证:去掉一整颗候选料的 3 个瓷贴,记录数跟着从 2 变成 1 —— 证明上面「2 条」不是巧合,是真的按 section 数记', () => {
|
||||
const oneSection = NESTED_RENDERED.slice(0, NESTED_RENDERED.indexOf('</section>') + '</section>'.length) + '</article>';
|
||||
const got = collect(docOf(oneSection), 'parts.parameter');
|
||||
expect(got).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('变异验证:摘掉主键 data-col(internal_pn)—— 记录里就再也找不到这一列(证明断言真的在读 data-col,不是巧合)', () => {
|
||||
const withoutPk = NESTED_RENDERED.replaceAll('<input data-col="internal_pn" value="LM2596S-5.0"/>', '<input value="LM2596S-5.0"/>').replaceAll(
|
||||
'<input data-col="internal_pn" value="XL2596S-5.0"/>',
|
||||
'<input value="XL2596S-5.0"/>',
|
||||
);
|
||||
const got = collect(docOf(withoutPk), 'parts.parameter');
|
||||
for (const row of got) expect(row).not.toHaveProperty('internal_pn');
|
||||
});
|
||||
});
|
||||
@@ -162,7 +162,7 @@ function replaceInPlace(doc: Document, html: string): void {
|
||||
}
|
||||
|
||||
/** 一张可改的表的容器(tbody)—— 按 `data-edit` 地址找 */
|
||||
function bodyOf(doc: Document, address: string): HTMLElement | null {
|
||||
export function bodyOf(doc: Document, address: string): HTMLElement | null {
|
||||
return doc.querySelector<HTMLElement>(`[data-edit="${CSS.escape(address)}"]`);
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ export function dropRow(row: HTMLElement): 'removed' | 'marked' | 'unmarked' {
|
||||
* 一张空表(一行都没有)加不了行 —— 没有可照的样子;那种情形本来也不该由人开头
|
||||
* (行由活动写下,人改的是它写错的地方)。
|
||||
*/
|
||||
function addRow(doc: Document, address: string): boolean {
|
||||
export function addRow(doc: Document, address: string): boolean {
|
||||
const host = bodyOf(doc, address);
|
||||
const last = host?.querySelector<HTMLElement>('tr:last-of-type');
|
||||
if (host === null || last == null) return false;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { assertNever, groupBy, isDefined, sortedEntries } from '../src/index.ts';
|
||||
import { assertNever, groupBy, isDefined, sortedEntries, topoOrder } from '../src/index.ts';
|
||||
|
||||
describe('@utils', () => {
|
||||
it('isDefined 滤掉 null 与 undefined', () => {
|
||||
@@ -29,3 +29,60 @@ describe('@utils', () => {
|
||||
expect(() => assertNever('x' as never)).toThrow('未覆盖的分支');
|
||||
});
|
||||
});
|
||||
|
||||
describe('topoOrder', () => {
|
||||
it('无依赖:随便什么序都放行,谁都不欠谁', () => {
|
||||
const result = topoOrder(['b', 'a', 'c'], () => []);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.ok && [...result.order].sort()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
/* 入参故意按「被依赖的排在后面」给 —— 证明排序看的是依赖关系,不是输入数组的次序 */
|
||||
it('链式依赖:按「被依赖的排前面」重排,不管输入次序', () => {
|
||||
const deps: Record<string, string[]> = { c: ['b'], b: ['a'], a: [] };
|
||||
const result = topoOrder(['c', 'b', 'a'], (n) => deps[n] ?? []);
|
||||
expect(result).toEqual({ ok: true, order: ['a', 'b', 'c'] });
|
||||
});
|
||||
|
||||
it('菱形依赖:d 依赖 b 和 c,b 与 c 都依赖 a —— a 最先,d 最后,b/c 顺序不拘', () => {
|
||||
const deps: Record<string, string[]> = { a: [], b: ['a'], c: ['a'], d: ['b', 'c'] };
|
||||
const result = topoOrder(['a', 'b', 'c', 'd'], (n) => deps[n] ?? []);
|
||||
expect(result.ok).toBe(true);
|
||||
const order = result.ok ? result.order : [];
|
||||
expect(order.indexOf('a')).toBeLessThan(order.indexOf('b'));
|
||||
expect(order.indexOf('a')).toBeLessThan(order.indexOf('c'));
|
||||
expect(order.indexOf('b')).toBeLessThan(order.indexOf('d'));
|
||||
expect(order.indexOf('c')).toBeLessThan(order.indexOf('d'));
|
||||
});
|
||||
|
||||
it('真正的环(a 依赖 b、b 依赖 a):当场报出来,不默默兜底', () => {
|
||||
const deps: Record<string, string[]> = { a: ['b'], b: ['a'] };
|
||||
const result = topoOrder(['a', 'b'], (n) => deps[n] ?? []);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(!result.ok && [...result.cycle].sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('自依赖(a 依赖自己)同样算环', () => {
|
||||
const result = topoOrder(['a'], () => ['a']);
|
||||
expect(result).toEqual({ ok: false, cycle: ['a'] });
|
||||
});
|
||||
|
||||
it('三元环:a 依赖 c、c 依赖 b、b 依赖 a,三个一起报出来', () => {
|
||||
const deps: Record<string, string[]> = { a: ['c'], b: ['a'], c: ['b'] };
|
||||
const result = topoOrder(['a', 'b', 'c'], (n) => deps[n] ?? []);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(!result.ok && [...result.cycle].sort()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('环之外互不相干的节点不受连累 —— 只有环上那两个排不出来', () => {
|
||||
const deps: Record<string, string[]> = { y: [], a: ['b'], b: ['a'] };
|
||||
const result = topoOrder(['y', 'a', 'b'], (n) => deps[n] ?? []);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(!result.ok && [...result.cycle].sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('依赖了一个不在 nodes 里的名字:当没有这条依赖,不误报成环', () => {
|
||||
const result = topoOrder(['a'], () => ['not-a-node']);
|
||||
expect(result).toEqual({ ok: true, order: ['a'] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,3 +27,52 @@ export function groupBy<T, K extends string>(items: readonly T[], key: (item: T)
|
||||
export function sortedEntries<V>(record: Readonly<Record<string, V>>): Array<[string, V]> {
|
||||
return Object.entries(record).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
}
|
||||
|
||||
export type TopoOrderResult =
|
||||
| { readonly ok: true; readonly order: readonly string[] }
|
||||
| { readonly ok: false; readonly cycle: readonly string[] };
|
||||
|
||||
/**
|
||||
* 拓扑排序(Kahn 算法)—— 给定节点集合与「这个节点依赖谁」,排出一个安全的处理序
|
||||
* (被依赖的排前面)。
|
||||
*
|
||||
* 同族算法已有一份在 `apps/api/src/modules/process/internal/domain/routing.ts` 的
|
||||
* `advanceOrder`(那是流程图专用的,找不齐拓扑序时把剩下的节点原样缀在末尾 ——
|
||||
* 流程图的容错策略)。这里是它的**通用版**,故意搬到叶子包:两个不同层级的模块
|
||||
* (`activity-registry` 装包静态审 / `loop-engine` 配方求值)都要用同一个算法判「排不排得出来」,
|
||||
* 而模块分层不许它们互相 import ——图算法本身不含业务语义,该住在 `@utils`。
|
||||
*
|
||||
* **不兜底**:排不出来就是这几个节点之间有环,原样报出来,由调用方决定怎么处置
|
||||
* (装包时机检报错 / 运行时交人),不在这一层替调用方决定「怎么办」。
|
||||
* `dependsOn` 返回的名字如果不在 `nodes` 里,当作没这条依赖(忽略,不会因此误报成环)。
|
||||
*/
|
||||
export function topoOrder(nodes: readonly string[], dependsOn: (node: string) => readonly string[]): TopoOrderResult {
|
||||
const known = new Set(nodes);
|
||||
const indegree = new Map<string, number>(nodes.map((n) => [n, 0]));
|
||||
const dependents = new Map<string, string[]>();
|
||||
for (const node of nodes) {
|
||||
for (const dep of dependsOn(node)) {
|
||||
if (!known.has(dep)) continue;
|
||||
indegree.set(node, (indegree.get(node) ?? 0) + 1);
|
||||
(dependents.get(dep) ?? dependents.set(dep, []).get(dep)!).push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const ready = nodes.filter((n) => indegree.get(n) === 0);
|
||||
const order: string[] = [];
|
||||
const placed = new Set<string>();
|
||||
while (ready.length > 0) {
|
||||
const id = ready.shift()!;
|
||||
if (placed.has(id)) continue;
|
||||
placed.add(id);
|
||||
order.push(id);
|
||||
for (const dependent of dependents.get(id) ?? []) {
|
||||
const left = (indegree.get(dependent) ?? 0) - 1;
|
||||
indegree.set(dependent, left);
|
||||
if (left === 0) ready.push(dependent);
|
||||
}
|
||||
}
|
||||
|
||||
const stranded = nodes.filter((n) => !placed.has(n));
|
||||
return stranded.length > 0 ? { ok: false, cycle: stranded } : { ok: true, order };
|
||||
}
|
||||
|
||||
@@ -4,27 +4,41 @@
|
||||
# 开发那边改代码、重启、崩掉,这边一个字都不动;要发新版,显式跑一次 update。
|
||||
#
|
||||
# ./stable.sh 起(没有快照就先构建;已在跑的不动)
|
||||
# ./stable.sh update 用当前代码重建快照,然后重启两个 —— 这才是「发一版」
|
||||
# ./stable.sh update 重建快照 + 重启两个 —— 这才是「发一版」
|
||||
# ./stable.sh update <ref> **从某个分支/标签/commit 发版**(例:update backup/20260828)。
|
||||
# 记住这个 ref,以后不带参数的 update 继续跟它走;回到当前工作树:update local
|
||||
# ./stable.sh restart 不重建,只重启
|
||||
# ./stable.sh stop 停掉两个
|
||||
# ./stable.sh status 只看状态,一个进程都不动
|
||||
# ./stable.sh logs [api|web] 看日志(tail -f)
|
||||
# ./stable.sh env-from-dev 把开发服务(:8787)进程里的环境变量抄成 .stable/env
|
||||
#
|
||||
# **给了 ref 就在独立 git worktree(.stable/src)里签出并构建 —— 你的工作树一个字不动**,
|
||||
# 想改什么改什么、切分支也随意;稳定版跑的是那个 ref 定死的代码。
|
||||
# worktree 一律 detached 签出:那个分支仍归你,别处照样 checkout 得动。
|
||||
# lockfile 与主仓库一致时 `pnpm install` 是秒级的(全从同一个 store 硬链)。
|
||||
#
|
||||
# 落盘位置(全部 gitignore):
|
||||
# apps/api/.build/stable/ 后端快照(tsup 一次性构建,不 watch)—— 必须住在 apps/api 下,
|
||||
# 产物里 `import "hono"` 这类裸名要从 apps/api/node_modules 解析
|
||||
# apps/web/.build/stable/ 前端快照(vite build),由 vite preview 托静态 + /api 反代到稳定版后端
|
||||
# (跟 ref 发版时,这两处在 .stable/src/ 里那一份)
|
||||
# .stable/src/ 跟 ref 发版用的独立 worktree(detached)
|
||||
# .stable/REF 现在跟着哪个 ref;`local` = 当前工作树
|
||||
# .stable/env 环境变量(KEY=VALUE,600 权限)。缺它起不来 —— Keycloak 三样是硬门
|
||||
# .stable/logs/ api.log / web.log / build.log
|
||||
# .stable/RELEASE 这份快照是哪个 commit、什么时候构的
|
||||
# .stable/RELEASE 这份快照是哪个 ref / commit、什么时候构的
|
||||
#
|
||||
# .stable/env 长这样(env-from-dev 会自动照开发进程抄一份;要单独一套自己改):
|
||||
# KEYCLOAK_URL=https://… KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=task-dispatcher
|
||||
# KEYCLOAK_PROVISIONING_CLIENT_ID=… KEYCLOAK_PROVISIONING_CLIENT_SECRET=…(可选,名册同步)
|
||||
# JCAI_TEST_ACCOUNTS=… JCAI_SECRET_KEY=…(开发没设的话这里也别设 —— 库里的密文
|
||||
# 是用哪把钥匙封的就得用哪把开)
|
||||
# DATABASE_URL=… (不填 = 与开发共用一个库,见下)
|
||||
# STABLE_DATABASE_URL=… 稳定版自己的库(不填 = 与开发共用一个,见下)。
|
||||
# **注意名字带 STABLE_ 前缀**:这个文件是给人 source 的,
|
||||
# 写成通用的 DATABASE_URL 会让 source 过它的人误把
|
||||
# activities:install / db:migrate / reset-runs 打到稳定库上(踩过)
|
||||
# STABLE_ATTACHMENT_ROOT=… 稳定版自己的附件根(同上,别写成 JCAI_ATTACHMENT_ROOT)
|
||||
# STABLE_API_PORT=8790 STABLE_WEB_PORT=5180
|
||||
# STABLE_ALLOWED_HOSTS=jcai-bpos.akhr-ai.com 经域名/反代访问时要列在这儿(逗号分隔,`*` 全放)——
|
||||
# vite preview 有宿主校验,不列会 403「This host is not allowed」
|
||||
@@ -44,10 +58,8 @@ REPO=$(pwd)
|
||||
STABLE="$REPO/.stable"
|
||||
ENV_FILE="$STABLE/env"
|
||||
LOGDIR="$STABLE/logs"
|
||||
API_DIR="$REPO/apps/api"
|
||||
WEB_DIR="$REPO/apps/web"
|
||||
API_OUT="$API_DIR/.build/stable"
|
||||
WEB_OUT="$WEB_DIR/.build/stable"
|
||||
REF_FILE="$STABLE/REF"
|
||||
WORKTREE="$STABLE/src"
|
||||
DEV_API_PORT=8787
|
||||
mkdir -p "$LOGDIR"
|
||||
WHAT=${1:-start}
|
||||
@@ -55,6 +67,25 @@ WHAT=${1:-start}
|
||||
say() { printf '%s\n' "$*"; }
|
||||
die() { say " ✗ $*"; exit 1; }
|
||||
|
||||
# ── 源码从哪儿来 ────────────────────────────────────────────────────────
|
||||
# `local` = 当前工作树(默认,老行为);别的 = .stable/src 那个 detached worktree。
|
||||
# 记在 .stable/REF 里:不带参数的 update / restart 继续跟着它,不会悄悄跳回工作树 ——
|
||||
# 「稳定版跑的是哪份代码」这件事,得由上一次显式的决定说了算。
|
||||
REF=$(cat "$REF_FILE" 2>/dev/null || echo local)
|
||||
|
||||
use_source() {
|
||||
if [ "$REF" = 'local' ]; then
|
||||
SRC="$REPO"
|
||||
else
|
||||
SRC="$WORKTREE"
|
||||
fi
|
||||
API_DIR="$SRC/apps/api"
|
||||
WEB_DIR="$SRC/apps/web"
|
||||
API_OUT="$API_DIR/.build/stable"
|
||||
WEB_OUT="$WEB_DIR/.build/stable"
|
||||
}
|
||||
use_source
|
||||
|
||||
# ── 环境 ────────────────────────────────────────────────────────────────
|
||||
ENV_KEYS='KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_SECRET JCAI_TEST_ACCOUNTS JCAI_SECRET_KEY JCAI_DEEPSEEK_API_KEY JCAI_LLM_API_KEY JCAI_LLM_BASE_URL DATABASE_URL JCAI_ATTACHMENT_ROOT JCAI_DISPATCH_INTERVAL_MS JCAI_MAIL_POLL_TICK_MS NODE_ENV'
|
||||
|
||||
@@ -94,6 +125,18 @@ load_env() {
|
||||
set +a
|
||||
API_PORT=${STABLE_API_PORT:-8790}
|
||||
WEB_PORT=${STABLE_WEB_PORT:-5180}
|
||||
# 库与附件根**只在这里**翻成通用名,而且只翻给马上要 exec 的那个进程。
|
||||
#
|
||||
# 这条是踩出来的:头一版直接把 `DATABASE_URL=…jcai_stable` 写进 .stable/env,
|
||||
# 而这个文件是设计成给人 `source` 的(应急起后端、抄环境都这么干)——
|
||||
# 于是任何人 source 完再跑 `pnpm activities:install` / `db:migrate` / `reset-runs`,
|
||||
# 就**毫无察觉地打在稳定库上**。实测发生过一次:2026-08-28 15:32:59
|
||||
# 一个新活动包版本被装进了本该冻住的稳定库(15:30 刚装过开发库,2 分半后又中一枪)。
|
||||
# **通用变量名放在会被 source 的文件里,等于给每个 source 它的人都上了一发膛**。
|
||||
export DATABASE_URL=${STABLE_DATABASE_URL:-${DATABASE_URL:-}}
|
||||
export JCAI_ATTACHMENT_ROOT=${STABLE_ATTACHMENT_ROOT:-${JCAI_ATTACHMENT_ROOT:-}}
|
||||
[ -z "$DATABASE_URL" ] && unset DATABASE_URL
|
||||
[ -z "$JCAI_ATTACHMENT_ROOT" ] && unset JCAI_ATTACHMENT_ROOT
|
||||
for k in KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID; do
|
||||
eval "v=\${$k:-}"
|
||||
# shellcheck disable=SC2154
|
||||
@@ -101,28 +144,124 @@ load_env() {
|
||||
done
|
||||
}
|
||||
|
||||
# ── 冻库:不许把活动包装进稳定库 ────────────────────────────────────────
|
||||
#
|
||||
# 为什么是**触发器**而不是权限:开发用的 `jcai` 是**超级用户**,REVOKE 对它无效
|
||||
# (超级用户绕过一切权限检查)。触发器是数据逻辑,超级用户照样被 RAISE EXCEPTION 拦下 —— 实测过。
|
||||
#
|
||||
# 冻的是「代码与配置」那几张表(装活动包 / 装工具 / 播种会写它们),
|
||||
# **不冻业务表** —— 稳定版自己还要写任务、台账、事件,冻了它就跑不动了。
|
||||
#
|
||||
# 起因:2026-08-28 15:32:59 一个新活动包被装进了本该冻住的稳定库。
|
||||
# 而且 `install-activities` 的失败路径是「打印一行然后 continue,永远 exit 0」——
|
||||
# 拦下 INSERT 它也不会停,还会接着写别的表,所以**得把这一族一起冻上**。
|
||||
# **只冻 INSERT**(= 装进来一个新东西),不冻 UPDATE ——
|
||||
# 运行时会更新这些表的状态(触发器的上次触发、模型的可用性…),治理台上启停也是 UPDATE;
|
||||
# 冻了 UPDATE 稳定版自己就跑不动了。要防的是「装新版本」,那正是 INSERT。
|
||||
FREEZE_TABLES='sys.activity_package sys.activity_asset sys.tool sys.tool_allowlist sys.llm_instance sys.process_package sys."trigger"'
|
||||
|
||||
db_url_of() { printf '%s' "${STABLE_DATABASE_URL:-}"; }
|
||||
|
||||
# 在容器里对稳定库跑一段 SQL(共用开发那个 PG 实例,只是库不同)
|
||||
stable_psql() {
|
||||
local db
|
||||
db=$(printf '%s' "$(db_url_of)" | sed 's#.*/##')
|
||||
[ -n "$db" ] || die "没有 STABLE_DATABASE_URL —— 稳定版还在跟开发共用一个库,冻不了"
|
||||
docker exec -i jcai-postgres psql -U jcai -d "$db" -v ON_ERROR_STOP=1
|
||||
}
|
||||
|
||||
db_freeze() {
|
||||
local t n=0
|
||||
say "冻结稳定库(不许装活动包 / 工具 / 播种):"
|
||||
{
|
||||
cat <<'SQL'
|
||||
CREATE OR REPLACE FUNCTION sys.frozen_guard() RETURNS trigger AS $fn$
|
||||
BEGIN
|
||||
RAISE EXCEPTION '稳定库已冻结:不许对 %.% 做 %。装活动包 / 播种请对开发库做;确实要动稳定库,先 ./stable.sh db-unfreeze',
|
||||
TG_TABLE_SCHEMA, TG_TABLE_NAME, TG_OP
|
||||
USING HINT = '这道墙是触发器不是权限 —— 超级用户同样拦';
|
||||
END $fn$ LANGUAGE plpgsql;
|
||||
SQL
|
||||
for t in $FREEZE_TABLES; do
|
||||
printf 'DROP TRIGGER IF EXISTS zz_frozen ON %s;\n' "$t"
|
||||
printf 'CREATE TRIGGER zz_frozen BEFORE INSERT ON %s FOR EACH ROW EXECUTE FUNCTION sys.frozen_guard();\n' "$t"
|
||||
done
|
||||
} | stable_psql > "$LOGDIR/db-freeze.log" 2>&1 || {
|
||||
# 表不存在之类的照说,但别把整件事说成成功
|
||||
grep -iE 'error|不存在' "$LOGDIR/db-freeze.log" | head -3
|
||||
say ' ! 有语句没跑成(全文 .stable/logs/db-freeze.log)'
|
||||
}
|
||||
# 数一遍再报 —— stable_psql 是交互式格式,这里单开一条 -tA 的连接现数
|
||||
n=$(docker exec jcai-postgres psql -U jcai -d "$(printf '%s' "$(db_url_of)" | sed 's#.*/##')" \
|
||||
-tAc "select count(*) from pg_trigger where tgname='zz_frozen'" 2>/dev/null | tr -d ' \n')
|
||||
say " ✓ 已冻 ${n:-?} 张表(只拦 INSERT):$FREEZE_TABLES"
|
||||
}
|
||||
|
||||
db_unfreeze() {
|
||||
local t
|
||||
say '解冻稳定库(记得办完再 ./stable.sh db-freeze 冻回去):'
|
||||
for t in $FREEZE_TABLES; do printf 'DROP TRIGGER IF EXISTS zz_frozen ON %s;\n' "$t"; done | stable_psql > "$LOGDIR/db-freeze.log" 2>&1
|
||||
say ' ✓ 已解冻 —— 现在装得进去了,**这扇门开着的时候开发侧的 install 会打中它**'
|
||||
}
|
||||
|
||||
# ── 跟 ref 发版:独立 worktree ──────────────────────────────────────────
|
||||
# detached 签出 —— 那个分支仍归你,别处照样 checkout 得动(worktree 占着分支会拦下别人)。
|
||||
prepare_worktree() {
|
||||
local target=$1 sha
|
||||
sha=$(git -C "$REPO" rev-parse --short "$target^{commit}" 2>/dev/null) \
|
||||
|| die "认不出这个 ref:$target(分支/标签/commit 都试过了)"
|
||||
if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then
|
||||
say " worktree .stable/src → $target ($sha)"
|
||||
git -C "$WORKTREE" checkout --detach --force "$sha" > "$LOGDIR/build.log" 2>&1 \
|
||||
|| { tail -10 "$LOGDIR/build.log"; die 'worktree 切不过去(全文 .stable/logs/build.log)'; }
|
||||
else
|
||||
say " 新建 worktree .stable/src → $target ($sha)"
|
||||
rm -rf "$WORKTREE"
|
||||
git -C "$REPO" worktree add --detach "$WORKTREE" "$sha" > "$LOGDIR/build.log" 2>&1 \
|
||||
|| { tail -10 "$LOGDIR/build.log"; die 'worktree 建不起来(全文 .stable/logs/build.log)'; }
|
||||
fi
|
||||
# 依赖装在 worktree 自己的 node_modules 里。lockfile 与主仓库一致时是秒级(同一个 store 硬链);
|
||||
# 不加 --ignore-scripts:esbuild 的二进制要靠 postinstall 落地,少了它 tsup/vite 一步都跑不动
|
||||
say ' pnpm install(worktree)…'
|
||||
( cd "$WORKTREE" && pnpm install --frozen-lockfile ) >> "$LOGDIR/build.log" 2>&1 \
|
||||
|| { tail -20 "$LOGDIR/build.log"; die 'worktree 装依赖失败(全文 .stable/logs/build.log)'; }
|
||||
}
|
||||
|
||||
# 这份快照到底是哪份代码 —— ref、commit、脏没脏,一行说清
|
||||
snapshot_id() {
|
||||
local sha dirty
|
||||
sha=$(git -C "$SRC" rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
if [ "$REF" = 'local' ]; then
|
||||
dirty=$([ -n "$(git -C "$SRC" status --porcelain 2>/dev/null)" ] && echo '+未提交改动' || echo '')
|
||||
printf 'local %s%s' "$sha" "$dirty"
|
||||
else
|
||||
printf '%s %s' "$REF" "$sha"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 构建快照 ────────────────────────────────────────────────────────────
|
||||
build() {
|
||||
local sha dirty at
|
||||
local id at where
|
||||
where=$([ "$REF" = 'local' ] && echo 'apps' || echo '.stable/src/apps')
|
||||
say '构建快照:'
|
||||
say " api → apps/api/.build/stable …"
|
||||
say " api → $where/api/.build/stable …"
|
||||
# --out-dir 盖过 tsup.config 的 outDir;config 里的 clean:true 清的就是这个目录,碰不到开发的 dist/
|
||||
if ! (cd "$API_DIR" && pnpm exec tsup --out-dir .build/stable) > "$LOGDIR/build.log" 2>&1; then
|
||||
# >> 而不是 > :跟 ref 发版时,前面 worktree 那几步的日志还要留着
|
||||
if ! (cd "$API_DIR" && pnpm exec tsup --out-dir .build/stable) >> "$LOGDIR/build.log" 2>&1; then
|
||||
tail -20 "$LOGDIR/build.log"
|
||||
die 'api 构建失败(全文 .stable/logs/build.log)'
|
||||
fi
|
||||
[ -f "$API_OUT/main.js" ] || die "构建说成功,可 $API_OUT/main.js 不在"
|
||||
say " web → apps/web/.build/stable …"
|
||||
say " web → $where/web/.build/stable …"
|
||||
if ! (cd "$WEB_DIR" && pnpm exec vite build --outDir .build/stable --emptyOutDir) >> "$LOGDIR/build.log" 2>&1; then
|
||||
tail -20 "$LOGDIR/build.log"
|
||||
die 'web 构建失败(全文 .stable/logs/build.log)'
|
||||
fi
|
||||
[ -f "$WEB_OUT/index.html" ] || die "构建说成功,可 $WEB_OUT/index.html 不在"
|
||||
sha=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
dirty=$([ -n "$(git status --porcelain 2>/dev/null)" ] && echo '+未提交改动' || echo '')
|
||||
id=$(snapshot_id)
|
||||
at=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
printf 'commit=%s%s\nbuilt_at=%s\n' "$sha" "$dirty" "$at" > "$STABLE/RELEASE"
|
||||
say " ✓ 快照:$sha$dirty · $at"
|
||||
printf 'source=%s\nbuilt_at=%s\n' "$id" "$at" > "$STABLE/RELEASE"
|
||||
say " ✓ 快照:$id · $at"
|
||||
}
|
||||
|
||||
have_snapshot() { [ -f "$API_OUT/main.js" ] && [ -f "$WEB_OUT/index.html" ]; }
|
||||
@@ -299,6 +438,7 @@ finish() {
|
||||
status() {
|
||||
local ip
|
||||
say '稳定版状态:'
|
||||
say " 跟着 $REF$([ "$REF" = 'local' ] && echo '(当前工作树)' || echo "(独立 worktree .stable/src,你的工作树随便改)")"
|
||||
[ -f "$STABLE/RELEASE" ] && sed 's/^/ 快照 /' "$STABLE/RELEASE" || say ' 快照 (还没构建过)'
|
||||
say " api :$API_PORT $(http_code "http://127.0.0.1:$API_PORT/api/health") $(curl -s -m 3 "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null) pid $(owner_of "$API_PORT")"
|
||||
say " web :$WEB_PORT $(http_code "http://127.0.0.1:$WEB_PORT/") pid $(owner_of "$WEB_PORT")"
|
||||
@@ -322,6 +462,16 @@ case "$WHAT" in
|
||||
status
|
||||
exit 0
|
||||
;;
|
||||
db-freeze)
|
||||
load_env
|
||||
db_freeze
|
||||
exit 0
|
||||
;;
|
||||
db-unfreeze)
|
||||
load_env
|
||||
db_unfreeze
|
||||
exit 0
|
||||
;;
|
||||
stop)
|
||||
load_env
|
||||
say '停止稳定版:'
|
||||
@@ -331,13 +481,22 @@ case "$WHAT" in
|
||||
;;
|
||||
start)
|
||||
load_env
|
||||
[ -n "${2:-}" ] && { REF=$2; use_source; printf '%s\n' "$REF" > "$REF_FILE"; }
|
||||
[ "$REF" != 'local' ] && [ ! -d "$WORKTREE/apps" ] && prepare_worktree "$REF"
|
||||
have_snapshot || build
|
||||
say "起稳定版(api :$API_PORT · web :$WEB_PORT):"
|
||||
say "起稳定版(api :$API_PORT · web :$WEB_PORT · 源 $REF):"
|
||||
start_api
|
||||
start_web
|
||||
;;
|
||||
update)
|
||||
load_env
|
||||
# 给了 ref 就换源并记住;没给就沿用 .stable/REF 上一次的决定
|
||||
if [ -n "${2:-}" ]; then
|
||||
REF=$2
|
||||
use_source
|
||||
printf '%s\n' "$REF" > "$REF_FILE"
|
||||
fi
|
||||
[ "$REF" != 'local' ] && prepare_worktree "$REF"
|
||||
build
|
||||
say '重启稳定版:'
|
||||
stop_one api "$API_PORT" "$STABLE/api.pid"
|
||||
@@ -355,7 +514,7 @@ case "$WHAT" in
|
||||
start_web
|
||||
;;
|
||||
*)
|
||||
say "不认识的参数:$WHAT(可用:start / update / restart / stop / status / logs [api|web] / env-from-dev)"
|
||||
say "不认识的参数:$WHAT(可用:start / update [ref|local] / restart / stop / status / logs [api|web] / env-from-dev)"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
+411
-178
@@ -37,9 +37,14 @@ const E2E_CLIENT_SECRET = process.env.KEYCLOAK_E2E_CLIENT_SECRET ?? '';
|
||||
* 两处代价说在前头,别不知不觉踩上:
|
||||
* ①**卡会落到那个人的真工作台上**(服务账号那条路才是「绝不派到用户工作台」的);
|
||||
* ②**不会自动续期** —— 过期了整套当场 401,得换一枚重跑。
|
||||
* **给了就用它** —— 显式设了这个变量就是要走旁路,哪怕环境里还留着一对
|
||||
* `KEYCLOAK_E2E_CLIENT_ID/SECRET`(常见于 `.stable/env` 里的历史残留,还未必有效);
|
||||
* 不设它才走服务账号那条正路(ADR-012)。
|
||||
*
|
||||
* **2026-08-30 取信优先级改成服务账号优先**(实测踩过一次:`.stable/env` 里留着一枚
|
||||
* chaihao 的旧 token,`KEYCLOAK_E2E_CLIENT_ID/SECRET` 那时也已经配好能用,而脚本原来
|
||||
* 「给了就用它」的写法让旁路无条件盖过正路 —— 每次 e2e 都会悄悄绕开服务账号,把人类卡
|
||||
* 派到那个人的真实工作台上,而日志只有一行不起眼的提示,容易被漏看)。
|
||||
* **服务账号三个变量(`KEYCLOAK_URL`/`KEYCLOAK_E2E_CLIENT_ID`/`KEYCLOAK_E2E_CLIENT_SECRET`)
|
||||
* 全齐时走正路**;`JCAI_E2E_BEARER` 只在**任意一个缺席**时才顶上来兜底,兜底那一刻把
|
||||
* 代价喊得更响一点(见 `login()`)。
|
||||
*/
|
||||
const DIRECT_BEARER = (process.env.JCAI_E2E_BEARER ?? '').replace(/^Bearer\s+/i, '').trim();
|
||||
/**
|
||||
@@ -53,21 +58,41 @@ const LAND_MASTER = process.env.JCAI_E2E_LAND_MASTER === '1';
|
||||
const TOKEN_REFRESH_MS = 4 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 标准化参数子表 `parts.parameter` 的行(w05 专用)—— 按 [参数名, 值, 单位, 约束, 说明]
|
||||
* 五元组批量转成保存载荷的行对象;数值照抄各案例原先写在 `normalized_parameter_set`
|
||||
* 那个文本列里的那几行,一个数都不改(2026-08-27 结构改:参数集拆成独立子表)。
|
||||
* 标准化参数子表 `parts.parameter` 的行(w05 专用)—— 按 [参数名, 值, 单位, 说明]
|
||||
* 四元组批量转成保存载荷的行对象。
|
||||
*
|
||||
* **2026-08-29 Task 11 四元组(从前是五元组)**:`constraint_type` 已随四步重写搬去
|
||||
* `parts.type_param`(约束按类型标一次,不再逐颗料各标一遍 —— 见 manifest.ts 该字段的头注)。
|
||||
* `parts.parameter` 现在只有 internal_pn / param_name / param_value / param_unit / param_note
|
||||
* 五列,没有 `constraint_type` 这一列了;继续按老的五元组喂会把一个物理表没有的列塞进 patch。
|
||||
*/
|
||||
function paramRows(internalPn, rows) {
|
||||
return rows.map(([param_name, param_value, param_unit, constraint_type, param_note]) => ({
|
||||
return rows.map(([param_name, param_value, param_unit, param_note]) => ({
|
||||
internal_pn: internalPn,
|
||||
param_name,
|
||||
param_value,
|
||||
param_unit,
|
||||
constraint_type,
|
||||
param_note,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* **跨案例隔离用的直连 SQL**(2026-08-30,协调者裁定①)—— 只给 `kase.isolationCleanup`
|
||||
* 用,不是常规测试路径(常规路径一律走 API,不绕开授权/校验)。这里要做的事本身就是
|
||||
* API 够不着的:修正**别的案例**已经落地的正式表行,不是这一单自己的草稿。
|
||||
* 连接串默认值与 `apps/api/src/app/config.ts` 的 `DEFAULT_DATABASE_URL` 一致。
|
||||
*/
|
||||
async function withDb(fn) {
|
||||
const { Client } = await import('pg');
|
||||
const client = new Client({ connectionString: process.env.DATABASE_URL ?? 'postgres://jcai:jcai@localhost:54329/jcai_wos' });
|
||||
await client.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** 每套活动的案例:样例序号 + 人在件上要填的格子(按 pn 对行) */
|
||||
const SUITES = {
|
||||
a1: {
|
||||
@@ -543,235 +568,322 @@ const SUITES = {
|
||||
],
|
||||
},
|
||||
/*
|
||||
* ACT-WORK-05 差异评估 —— 1 数据(条件,P-08 / P-10 各一张)+ 1 执行 + 2 验收(审核走决策卡循环、放行终局)。
|
||||
* 库里只播了 LM2596S-5.0 / LM2596S-ADJ:XL2596S-5.0、CN-DCDC-32V、LM2596S-3.3 刻意不在,
|
||||
* 「候选缺库 / 原 PN 缺库 / 硬约束不推荐」三条路靠它们。e2e 关落真库,主数据不落正式表,案例可反复跑。
|
||||
* ACT-WORK-05 差异评估 —— **2026-08-29 Task 11 随四步重写整体换掉**(spec
|
||||
* `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`)。旧套件是九步 /
|
||||
* 两枚工具 / 三张数据工单(P-08 主数据 + P-10 资料 + 评估输入清单)那个模型的素材,
|
||||
* 新模型只有一张数据工单(`input-supplement`,三块竖排:要评估什么 / 芯片类型核心参数
|
||||
* 字段 / 参数值,合在一张卡上、一次保存四张表)+ 一次 AI 分析(S2,零人环)+
|
||||
* 一张审核决策卡(S3,可循环)+ 一张终局放行验收卡(T_release)。
|
||||
* 库里播了 LM2596S-5.0 / LM2596S-ADJ(DC-DC 降压稳压器 type_id=1)与
|
||||
* AMS1117-3.3(LDO 线性稳压器 type_id=2)三颗;XL2596S-5.0 / CN-DCDC-32V / LM2596S-3.3
|
||||
* 刻意不在库。e2e 关落真库,主数据不落正式表,案例可反复跑。
|
||||
*
|
||||
* **两处平台机制这一次是第一个用到的调用方,`runCase`/`answerDecisions` 主循环里
|
||||
* 补了对应支持**(其余八套件都没用过,行为不变):
|
||||
* ① **人工验收清单**(2026-08-27 用户定,`PlanStep.acceptanceChecklist`):S3 审核卡
|
||||
* 与 T_release 终局验收卡都带 `checklist`,选「通过」/`verdict:'pass'` 必须
|
||||
* `checked ⊇ checklist`,不然回传吃 `checklist-incomplete`(400)。清单原文由
|
||||
* 卡自己带下来(`card.checklist` / `workOrders[].checklist`),代答脚本原样照抄
|
||||
* 回传,不必知道清单具体写的是哪几句话。
|
||||
* ② **零数据卡直分析**(样例 1):S1 的 `doneWhen` 一开局就成立,`auto.do` 的
|
||||
* `card` 那一笔永不触发 —— 断言机制是 `expectNoDataCard`(见 `stage` 定义处)。
|
||||
*/
|
||||
w05: {
|
||||
activityId: 'part-substitution-assessment',
|
||||
address: 'sub_eval.input_gap',
|
||||
blankCol: 'supplement',
|
||||
address: 'parts.profile',
|
||||
blankCol: 'brand_name',
|
||||
stages: [
|
||||
{
|
||||
// 这张卡摆两张表(表 1 主记录 + 表 2 标准化参数子表)—— 主表照旧,子表挂在 extraTables 里
|
||||
// 加性表达(见 buildEdits 顶注),保存时两张表的行一次并进同一个 `tables` 载荷。
|
||||
match: /数据工单 · 芯片主数据创建/,
|
||||
// 唯一的数据工单:一张卡摆三块内容,写向四张表(主表 parts.profile,
|
||||
// extraTables 里另外三张)——四张表随同一次「保存」一并发出,不分次交。
|
||||
match: /差异评估的输入还差几格/,
|
||||
address: 'parts.profile',
|
||||
rowKey: 'internal_pn',
|
||||
blankCol: 'brand_name',
|
||||
expectGateBlock: true,
|
||||
// 数据工单确认即入库(2026-08-27 用户定)—— 交卷那一刻这张卡的表就该进正式表
|
||||
landsOnConfirm: true,
|
||||
edits: [],
|
||||
// 案例没点名的料号兜底(不该走到这里 —— 每个案例都在 stageEdits 里逐颗给了)
|
||||
fillAllBlank: {
|
||||
brand_name: '兜底品牌',
|
||||
specification_description: '兜底规格(案例没给参数集)',
|
||||
package: 'TO-263-5',
|
||||
lifecycle_status: 'active',
|
||||
},
|
||||
extraTables: [
|
||||
/*
|
||||
* `parts.chip_type` **没有任何 `data-edit` 绑定**(manifest.ts / case-model.ts
|
||||
* 头注明写:新建类型时人在 `parts.type_param` 那张表加行,`parts.chip_type` 那
|
||||
* 张字典表本身没有编辑面 —— `ViewContainer.addRow` 靠克隆末行实现「加一行」,
|
||||
* 而它在任何一单里都是从零行起,没有可克隆的样子)。写的授权面是**台账声明**
|
||||
* 不是「当下卡上有没有绑定」(`writesOf` 只认 `declarations.ledger`),所以这条
|
||||
* 写入走得通 —— 只是脚本代脚本走 API,不是真人代替点了一个并不存在的按钮。
|
||||
* 只有样例 0(候选缺库·新建类型)会喂 `edits`,其余案例这里留空、这张表不写。
|
||||
*/
|
||||
{ address: 'parts.chip_type', rowKey: 'type_id', blankCol: 'type_name', edits: [] },
|
||||
{
|
||||
// 主键是 (internal_pn, param_name) 复合列 —— 两颗料同名参数(如都有「工作温度」)
|
||||
// 复合主键 (type_id, param_name) —— 同一类型两个字段不能靠单列区分。
|
||||
address: 'parts.type_param',
|
||||
rowKey: ['type_id', 'param_name'],
|
||||
blankCol: 'param_desc',
|
||||
edits: [],
|
||||
},
|
||||
{
|
||||
// 复合主键 (internal_pn, param_name) —— 两颗料同名参数(如都有「工作温度」)
|
||||
// 不这样区分,rows.find 会永远命中第一条同名行、把不同料的值互相顶掉。
|
||||
address: 'parts.parameter',
|
||||
rowKey: ['internal_pn', 'param_name'],
|
||||
blankCol: 'param_value',
|
||||
edits: [],
|
||||
// 案例没点名的参数行兜底(模型骨架只给了 internal_pn+param_name、案例没接住时才会用到)
|
||||
fillAllBlank: { param_value: '未披露', constraint_type: 'hard', param_note: '兜底(案例没给这一条)' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
match: /数据工单 · 原厂技术资料创建/,
|
||||
address: 'parts.document',
|
||||
rowKey: 'internal_pn',
|
||||
blankCol: 'document_title',
|
||||
expectGateBlock: true,
|
||||
landsOnConfirm: true,
|
||||
edits: [],
|
||||
fillAllBlank: {
|
||||
doc_seq: '01',
|
||||
document_type: 'datasheet',
|
||||
document_title: 'Datasheet(e2e 兜底)',
|
||||
document_version: 'Rev A',
|
||||
document_ref: 'https://example.com/datasheet.pdf',
|
||||
citation_locator: 'p.1',
|
||||
document_status: 'valid',
|
||||
},
|
||||
},
|
||||
{
|
||||
match: /评估输入与资料补齐确认/,
|
||||
address: 'sub_eval.input_gap',
|
||||
rowKey: 'item_key',
|
||||
blankCol: 'supplement',
|
||||
expectGateBlock: true,
|
||||
edits: [
|
||||
{ item_key: 'application_scenario', supplement: '12V→5V、工业温域(-40~85℃)、输入上限 40V' },
|
||||
{ item_key: 'bom_position', supplement: 'U3 电源位' },
|
||||
],
|
||||
// 其余 gap 行(原厂未披露的参数)统一「留 UNKNOWN」—— 补不了也是合法答案
|
||||
fillWhere: { column: 'status', equals: 'gap', values: { supplement: '原厂未披露,留 UNKNOWN 并挂验证任务' } },
|
||||
},
|
||||
],
|
||||
cases: [
|
||||
{
|
||||
sample: 0,
|
||||
name: '候选缺库 · XL2596S-5.0 自动派数据工单(P-08 + P-10)· 审核先点名驳回再通过',
|
||||
// 决策卡序(2026-08-27 起缺库不再问人,数据工单自动派):审核第一张点名意见驳回;第二张通过
|
||||
name: '候选缺库 · XL2596S-5.0 新建芯片类型(自增 type_id,字段清单沿用原型口径)入库,'
|
||||
+ '原 PN 一并转挂新类型才够得上 distinct=1 · 审核先点名意见驳回再通过',
|
||||
decideSeq: ['custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'],
|
||||
expectDecision: true,
|
||||
expectPort: 'assessment-released',
|
||||
stageEdits: {
|
||||
// 全套只该有一次模型调用(S2 唯一一次 analyze)—— S3 审核循环走的是纯投影重算,不重问模型
|
||||
expectAnalyzeCalls: 1,
|
||||
/*
|
||||
* **真走「新建芯片类型」这条路**——但**显式给 `type_id`,不靠 `autoKeys()` 续号**
|
||||
* (2026-08-30,run8 撞见的平台侧发现,已报未改,见任务报告「发现的问题 · 平台侧」):
|
||||
* `resolveRowKey`(`apps/api/src/app/routes/desk.ts`)在**续号之前**先试「全表一致」
|
||||
* ——本单草稿里这张表若已经**恰好一种** `type_id` 取值,新行(`type_id` 留空)就会被
|
||||
* 认成「编辑那唯一的一行」,而不是「新增一行」。H35 让 S1 的配方在①之后立刻把
|
||||
* **当下选中的既有类型**自投影回 `parts.chip_type`(治空表闸的必要之举)——这却把
|
||||
* 「全表一致」的前提条件提前铺好了:候选缺库这条路里,原 PN 已经有类型(投影出恰
|
||||
* 一行),此时若把新行的 `type_id` 留空指望走 `autoKeys()`,会被「全表一致」抢先
|
||||
* 命中那**唯一的既有行**,新建类型变成了**改写既有类型的名字**(实测复现:
|
||||
* `type_id=1` 的「DC-DC 降压稳压器」被整个改名成候选新类型的名字,且 `LAND_MASTER`
|
||||
* 落地后连累了所有挂在 type_1 下的既有料号)。**这条路径连真人在浏览器里都走不通**
|
||||
* ——件上那颗种子行没有 `type_id` 输入格,人没有办法像这里一样显式指定,遇到「原
|
||||
* PN 已有类型」的常见场景,真人点「新建类型」实际上会正中同一个坑。这是平台侧
|
||||
* 的问题(`resolveRowKey` 续号前的全表一致判定没把「auto 列」排除在外),已经报给
|
||||
* 协调者、按裁定不在这一轮治。**这里显式给一个测试专用、不会与种子数据碰撞的
|
||||
* `type_id`,只是绕开这一条平台坑去验证后续链路,不代表浏览器里的真人也这样能走通。**
|
||||
*/
|
||||
customFill: async (taskId) => {
|
||||
const typeId = '90';
|
||||
// ① 新建芯片类型:显式给 type_id(见上方大注释,不走 autoKeys 续号)
|
||||
await api(`/api/desk/tasks/${taskId}/table`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tables: {
|
||||
'parts.chip_type': [
|
||||
{
|
||||
type_id: typeId,
|
||||
type_name: 'DC-DC 降压稳压器(评估自定义)',
|
||||
description: '本次评估自定义的降压转换器类型,核心参数字段清单沿用原类型口径,供原 PN 与候选料共用比对。',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
console.log(` 新建类型已保存,显式指定 type_id=${typeId}(绕开全表一致平台坑,已报未改)`);
|
||||
// ③ 核心参数字段清单(逐字照抄 type_id=1 的字典行,见 seed-mvp-data.ts)+
|
||||
// 两颗料都转挂新类型(distinct 要求同一个值)+ 候选料的参数值。
|
||||
// LM2596S-5.0 的既有参数值按同名 lookup 自动带过来,不必在这里重填。
|
||||
await api(`/api/desk/tasks/${taskId}/table`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tables: {
|
||||
'parts.type_param': [
|
||||
{ type_id: typeId, param_name: '输入电压范围', param_desc: '芯片允许的输入直流电压区间。候选料的上限低于应用输入上限就是硬伤,烧的是芯片。', constraint_type: 'hard', param_unit: 'V' },
|
||||
{ type_id: typeId, param_name: '输出电压', param_desc: '固定版给定值;可调版给可调区间,需外围反馈电阻网络设定。固定换可调要动外围。', constraint_type: 'hard', param_unit: 'V' },
|
||||
{ type_id: typeId, param_name: '输出电流', param_desc: '满载持续输出能力。低于原料即降额,负载重时会掉压或过热保护。', constraint_type: 'hard', param_unit: 'A' },
|
||||
{ type_id: typeId, param_name: '开关频率', param_desc: '决定外围电感与输出电容的取值。频率变了外围多半要跟着换,不是换颗料就完。', constraint_type: 'soft', param_unit: 'kHz' },
|
||||
{ type_id: typeId, param_name: '静态电流', param_desc: '空载待机时自身消耗。电池供电场景敏感,市电场景通常不敏感。', constraint_type: 'soft', param_unit: 'mA' },
|
||||
{ type_id: typeId, param_name: '工作温度', param_desc: '保证参数指标的环境温度区间。工业温域窄于应用要求就是硬伤。', constraint_type: 'hard', param_unit: '℃' },
|
||||
{ type_id: typeId, param_name: '使能阈值', param_desc: 'ON/OFF 引脚的开关门限。上游用 MCU 电平直驱时,阈值对不上会关不掉或开不起来。', constraint_type: 'hard', param_unit: 'V' },
|
||||
],
|
||||
'parts.profile': [
|
||||
{ internal_pn: 'LM2596S-5.0', chip_type_id: typeId },
|
||||
{
|
||||
internal_pn: 'XL2596S-5.0',
|
||||
chip_type_id: typeId,
|
||||
brand_name: 'XLSEMI',
|
||||
manufacturer_name: 'XLSEMI',
|
||||
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
|
||||
package: 'TO-263-5',
|
||||
process_technology: 'BCD',
|
||||
lifecycle_status: 'active',
|
||||
datasheet_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf',
|
||||
datasheet_version: 'Rev C',
|
||||
application_scenarios: '工业电源;消费电源',
|
||||
},
|
||||
],
|
||||
'parts.parameter': paramRows('XL2596S-5.0', [
|
||||
['输入电压范围', '4.5~40', 'V', 'hard', '客户上限 40V'],
|
||||
['输出电压', '5.0', 'V', 'hard', '固定版'],
|
||||
['输出电流', '3', 'A', 'hard', '满载能力'],
|
||||
['开关频率', '150', 'kHz', 'hard', '影响外围'],
|
||||
['静态电流', '4.2', 'mA', 'soft', '待机功耗'],
|
||||
['工作温度', '-40~+125', '℃', 'hard', '工业温域'],
|
||||
['使能阈值', '未披露', 'V', 'hard', '原厂未给'],
|
||||
['输入电压范围', '4.5~40', 'V', '客户上限 40V'],
|
||||
['输出电压', '5.0', 'V', '固定版'],
|
||||
['输出电流', '3', 'A', '满载能力'],
|
||||
['开关频率', '150', 'kHz', '影响外围'],
|
||||
['静态电流', '4.2', 'mA', '待机功耗'],
|
||||
['工作温度', '-40~+125', '℃', '工业温域'],
|
||||
['使能阈值', '未披露', 'V', '原厂未给'],
|
||||
]),
|
||||
'parts.document': [
|
||||
{
|
||||
internal_pn: 'XL2596S-5.0',
|
||||
doc_seq: '01',
|
||||
document_type: 'datasheet',
|
||||
brand_name: 'XLSEMI',
|
||||
document_title: 'XL2596S 150KHz 3A Buck DC/DC Converter',
|
||||
document_version: 'Rev C',
|
||||
document_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf',
|
||||
citation_locator: 'p.3 Electrical Characteristics',
|
||||
document_status: 'valid',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
},
|
||||
/*
|
||||
* **这不是 `expectRevised`(归属)检查,故意换了字段名**(2026-08-30,协调者裁定:
|
||||
* 这条断言原来借用了 E04 那条「点名驳回要落在被点名那一行」的字段/话术,而 W05
|
||||
* 全程零 agent 轮次(`case-model.ts` 四步 `guide` 全是空串)、唯一的 `analyze`
|
||||
* 发生在这条驳回意见写下**之前**——没有模型读意见挑行这一步,测不出归属,
|
||||
* 继续叫 `expectRevised` 会在真失败时打出「归属又认错了」这种指错方向的话。
|
||||
* 这里改用 `expectFinalValue`:只收尾核对一个**规则算出来的结论**
|
||||
* (使能阈值未披露 → S2 一次 analyze 就判 UNKNOWN → 有条件推荐)扛住了「驳回→重提」
|
||||
* 这一趟往返没被揉坏——是内容正确性检查,不是归属检查。
|
||||
*/
|
||||
expectFinalValue: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' },
|
||||
/*
|
||||
* **跨案例隔离**(2026-08-30,协调者裁定①)—— 这一例为了让 `distinct=1` 成立,
|
||||
* 把**原 PN(LM2596S-5.0,案例 1/2 也在用它)**转挂到新建的 type_id=90;这一改
|
||||
* 随「数据工单确认即入库」落进正式表(`JCAI_E2E_LAND_MASTER=1`)。不清理的话,
|
||||
* 案例 1(靠 LM2596S-5.0 + LM2596S-ADJ 同型)会看见 LM2596S-5.0 已经不在 type_1 了——
|
||||
* 一个假的「两颗料分属不同类型」,实测复现过(run9:案例 1 平白冒出一张数据工单、
|
||||
* 最终吊死超时)。这不是案例 0 本身办错了(它确实需要转挂原 PN 才够得上
|
||||
* `distinct=1`,那是这条「新建类型」路径的题中之义),是**套件没有做案例间隔离**——
|
||||
* 跑完把 LM2596S-5.0 转回 type_1、把这一例专属新建的字典行清掉,不论这一例成败。
|
||||
*/
|
||||
isolationCleanup: async () => {
|
||||
/*
|
||||
* **第一版只清了原 PN 那一半,漏了候选料本身**(2026-08-30,协调者纠正 Keycloak
|
||||
* 问题之后重跑逼出来的第二个洞):`XL2596S-5.0` 是这一例**全新建出来**的候选料
|
||||
* (候选缺库场景),案例 4 也用它当另一单的候选(不同的原 PN),前提同样是「两颗
|
||||
* 都缺库」。放行(`JCAI_E2E_LAND_MASTER=1`)把它连同评估结果一起落进正式表后,
|
||||
* 只转回 `LM2596S-5.0` 的类型、删掉 `type_id=90` 字典行是不够的——`XL2596S-5.0`
|
||||
* 自己的 `parts.profile` 行还悬空指着已删的 `chip_type_id='90'`,且它作为一颗
|
||||
* "正式存在的料"本身就与案例 4「两颗都缺库」的前提矛盾。实测复现:案例 4 卡在
|
||||
* 「配方第 4 笔办不下去:芯片类型「90」在芯片类型字典里不存在」——根子在这里,
|
||||
* 不是案例 4 自己的问题。这一例产出的东西(候选料本身 + 评估结果)整体都是
|
||||
* 这一例专属的测试脚手架,不是该保留的既有主数据,一并清空。
|
||||
*/
|
||||
await withDb(async (db) => {
|
||||
await db.query("update ledger.parts__profile set chip_type_id = '1' where internal_pn = 'LM2596S-5.0'");
|
||||
const evalRefs = await db.query("select distinct eval_ref from ledger.sub_eval__part where internal_pn = 'XL2596S-5.0'");
|
||||
for (const { eval_ref } of evalRefs.rows) {
|
||||
await db.query('delete from ledger.sub_eval__part where eval_ref = $1', [eval_ref]);
|
||||
await db.query('delete from ledger.sub_eval__case where eval_ref = $1', [eval_ref]);
|
||||
}
|
||||
await db.query(
|
||||
"delete from ledger.substitution__difference_item where substitution_relation_id in " +
|
||||
"(select substitution_relation_id from ledger.substitution__relation where candidate_internal_pn = 'XL2596S-5.0')",
|
||||
);
|
||||
await db.query("delete from ledger.substitution__relation where candidate_internal_pn = 'XL2596S-5.0'");
|
||||
await db.query("delete from ledger.validation__record where validation_internal_pn = 'XL2596S-5.0'");
|
||||
await db.query("delete from ledger.parts__parameter where internal_pn = 'XL2596S-5.0'");
|
||||
await db.query("delete from ledger.parts__profile where internal_pn = 'XL2596S-5.0'");
|
||||
await db.query("delete from ledger.parts__type_param where type_id = '90'");
|
||||
await db.query("delete from ledger.parts__chip_type where type_id = '90'");
|
||||
});
|
||||
console.log(' 跨案例隔离:LM2596S-5.0 已转回 type_1;这一例全新建的 XL2596S-5.0(候选料本身、评估结果、type_id=90 字典行)已整体清空');
|
||||
},
|
||||
// 使能阈值未披露 → UNKNOWN → 条件推荐(工具算的,不是模型说的)
|
||||
expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' },
|
||||
},
|
||||
{
|
||||
sample: 1,
|
||||
name: '都在库 · 常路 · 可调版 → 备选 · 审核一次通过',
|
||||
name: '都在库 · 两颗同型且齐备 · 零数据卡直接进 S2 分析 · 审核一次通过',
|
||||
// S1 的 doneWhen 起手就成立(两颗都在库、同型、参数不缺)—— 断言一张数据工单都不发
|
||||
expectNoDataCard: true,
|
||||
decide: 'approve',
|
||||
expectPort: 'assessment-released',
|
||||
expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'LM2596S-ADJ', column: 'recommendation_level', contains: 'alternate' },
|
||||
expectAnalyzeCalls: 1,
|
||||
},
|
||||
{
|
||||
sample: 2,
|
||||
name: '原 PN 缺库 · 硬阻塞建档(不问人)· 审核通过',
|
||||
name: '两颗都在库但分属不同芯片类型 · 硬拦(填别的格子绕不过去,只有改类型才行)· 审核通过',
|
||||
// gate_reason 要点名「不是同一个芯片类型」——数据卡一出现就查这句话,latch 住
|
||||
expectGateReason: /芯片类型/,
|
||||
// 唯一的修复是把其中一颗转挂到另一颗的类型上;缺的那一个新必填参数一并补上,
|
||||
// 不改类型光填别的格子过不去(S1 的 doneWhen/distinct 与提交闸的必填检查不是同一件事——
|
||||
// 提交闸不查 distinct,这条路真正的硬拦在 S2 的入场哨兵,详见任务报告)。
|
||||
stageEdits: {
|
||||
'parts.profile': [{ internal_pn: 'LM2596S-5.0', chip_type_id: '2' }],
|
||||
// 转去 LDO 类型后,LM2596S-5.0 多出一条它从没记过的必填参数「压差」
|
||||
'parts.parameter': [
|
||||
{ internal_pn: 'LM2596S-5.0', param_name: '压差', param_value: '0.4', param_unit: 'V', param_note: '满载时(本次评估按 LDO 口径补充,DC-DC 拓扑本无此规格)' },
|
||||
],
|
||||
},
|
||||
decide: 'approve',
|
||||
expectPort: 'assessment-released',
|
||||
expectAnalyzeCalls: 1,
|
||||
},
|
||||
{
|
||||
sample: 3,
|
||||
name: '原 PN 缺库(候选在库)· 自动派数据工单建档 · 审核通过',
|
||||
decide: 'approve',
|
||||
expectPort: 'assessment-released',
|
||||
expectAnalyzeCalls: 1,
|
||||
stageEdits: {
|
||||
'parts.profile': [
|
||||
{
|
||||
internal_pn: 'LM2596S-3.3',
|
||||
chip_type_id: '1',
|
||||
brand_name: 'TI',
|
||||
manufacturer_name: 'Texas Instruments',
|
||||
specification_description: '降压型 DCDC 稳压器,3A,固定 3.3V,150kHz',
|
||||
package: 'TO-263-5',
|
||||
process_technology: 'BCD',
|
||||
lifecycle_status: 'nrnd',
|
||||
datasheet_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
|
||||
datasheet_version: 'Rev H (2023-09)',
|
||||
},
|
||||
],
|
||||
'parts.parameter': paramRows('LM2596S-3.3', [
|
||||
['输入电压范围', '4.5~40', 'V', 'hard', '客户上限 40V'],
|
||||
['输出电压', '3.3', 'V', 'hard', '固定版'],
|
||||
['输出电流', '3', 'A', 'hard', '满载能力'],
|
||||
['开关频率', '150', 'kHz', 'hard', '影响外围'],
|
||||
['静态电流', '5', 'mA', 'soft', '待机功耗'],
|
||||
['工作温度', '-40~+85', '℃', 'hard', '工业温域'],
|
||||
['使能阈值', '1.4', 'V', 'hard', 'ON/OFF 阈值'],
|
||||
['输入电压范围', '4.5~40', 'V', '客户上限 40V'],
|
||||
['输出电压', '3.3', 'V', '固定版'],
|
||||
['输出电流', '3', 'A', '满载能力'],
|
||||
['开关频率', '150', 'kHz', '影响外围'],
|
||||
['静态电流', '5', 'mA', '待机功耗'],
|
||||
['工作温度', '-40~+85', '℃', '工业温域'],
|
||||
['使能阈值', '1.4', 'V', 'ON/OFF 阈值'],
|
||||
]),
|
||||
'parts.document': [
|
||||
{
|
||||
internal_pn: 'LM2596S-3.3',
|
||||
doc_seq: '01',
|
||||
document_type: 'datasheet',
|
||||
brand_name: 'TI',
|
||||
document_title: 'LM2596 SIMPLE SWITCHER Power Converter',
|
||||
document_version: 'Rev H (2023-09)',
|
||||
document_ref: 'https://www.ti.com/lit/ds/symlink/lm2596.pdf',
|
||||
citation_locator: '§6.5 Electrical Characteristics(3.3V 列)',
|
||||
document_status: 'valid',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
sample: 3,
|
||||
name: '三候选 · 两颗缺库一律自动派单 · 国产 32V 硬约束不推荐',
|
||||
decideSeq: ['approve'],
|
||||
expectDecision: true,
|
||||
// 缺库不再问人(2026-08-27 用户定):这一例一张「查无记录」决策卡都不该出现
|
||||
expectAsks: [{ re: /查无记录/, count: 0, max: 0 }],
|
||||
expectPort: 'assessment-released',
|
||||
stageEdits: {
|
||||
'parts.profile': [
|
||||
{
|
||||
internal_pn: 'XL2596S-5.0',
|
||||
brand_name: 'XLSEMI',
|
||||
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
|
||||
package: 'TO-263-5',
|
||||
lifecycle_status: 'active',
|
||||
},
|
||||
{
|
||||
internal_pn: 'CN-DCDC-32V',
|
||||
brand_name: '国产某厂',
|
||||
specification_description: '降压型 DCDC,3A,固定 5.0V,输入上限 32V',
|
||||
package: 'TO-263-5',
|
||||
lifecycle_status: 'active',
|
||||
},
|
||||
],
|
||||
'parts.parameter': [
|
||||
...paramRows('XL2596S-5.0', [
|
||||
['输入电压范围', '4.5~40', 'V', 'hard', '客户上限 40V'],
|
||||
['输出电压', '5.0', 'V', 'hard', '固定版'],
|
||||
['输出电流', '3', 'A', 'hard', '满载能力'],
|
||||
['开关频率', '150', 'kHz', 'hard', '影响外围'],
|
||||
['静态电流', '4.2', 'mA', 'soft', '待机功耗'],
|
||||
['工作温度', '-40~+125', '℃', 'hard', '工业温域'],
|
||||
['使能阈值', '未披露', 'V', 'hard', '原厂未给'],
|
||||
]),
|
||||
...paramRows('CN-DCDC-32V', [
|
||||
['输入电压范围', '4.5~32', 'V', 'hard', '上限 32V'],
|
||||
['输出电压', '5.0', 'V', 'hard', '固定版'],
|
||||
['输出电流', '3', 'A', 'hard', '满载能力'],
|
||||
['开关频率', '150', 'kHz', 'hard', '影响外围'],
|
||||
['静态电流', '6', 'mA', 'soft', '待机功耗'],
|
||||
['工作温度', '-40~+85', '℃', 'hard', '工业温域'],
|
||||
['使能阈值', '1.3', 'V', 'hard', 'ON/OFF 阈值'],
|
||||
]),
|
||||
],
|
||||
'parts.document': [
|
||||
{ internal_pn: 'XL2596S-5.0', doc_seq: '01', document_type: 'datasheet', document_title: 'XL2596S Datasheet', document_version: 'Rev C', document_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf', citation_locator: 'p.3', document_status: 'valid' },
|
||||
{ internal_pn: 'CN-DCDC-32V', doc_seq: '01', document_type: 'datasheet', document_title: 'CN-DCDC-32V Datasheet', document_version: 'V1.0', document_ref: 'https://example.com/cn-dcdc-32v.pdf', citation_locator: 'p.2', document_status: 'valid' },
|
||||
],
|
||||
},
|
||||
expectRevised: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'CN-DCDC-32V', column: 'recommendation_level', contains: 'not_recommended' },
|
||||
},
|
||||
{
|
||||
sample: 4,
|
||||
name: '新客户 · 录入卡先建档 · 审核通过',
|
||||
entryValues: { name: '深圳恒星电源有限公司', industry: '工业电源', region: '广东' },
|
||||
name: '两颗都缺库 · 自动派数据工单各自建档(原 PN 32V 上限 · 候选 40V 盖住它)· 审核通过',
|
||||
decide: 'approve',
|
||||
expectPort: 'assessment-released',
|
||||
expectAnalyzeCalls: 1,
|
||||
stageEdits: {
|
||||
'parts.profile': [
|
||||
{
|
||||
internal_pn: 'CN-DCDC-32V',
|
||||
chip_type_id: '1',
|
||||
brand_name: '国产某厂',
|
||||
manufacturer_name: '国产某厂',
|
||||
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz,输入上限 32V',
|
||||
lifecycle_status: 'active',
|
||||
datasheet_ref: 'https://example.invalid/cn-dcdc-32v.pdf',
|
||||
datasheet_version: 'V1.0',
|
||||
},
|
||||
{
|
||||
internal_pn: 'XL2596S-5.0',
|
||||
chip_type_id: '1',
|
||||
brand_name: 'XLSEMI',
|
||||
manufacturer_name: 'XLSEMI',
|
||||
specification_description: '降压型 DCDC 稳压器,3A,固定 5.0V,150kHz',
|
||||
lifecycle_status: 'active',
|
||||
datasheet_ref: 'https://www.xlsemi.com/datasheet/XL2596S.pdf',
|
||||
datasheet_version: 'Rev C',
|
||||
},
|
||||
],
|
||||
'parts.parameter': [
|
||||
...paramRows('CN-DCDC-32V', [
|
||||
['输入电压范围', '4.5~32', 'V', '上限 32V'],
|
||||
['输出电压', '5.0', 'V', '固定版'],
|
||||
['输出电流', '3', 'A', '满载能力'],
|
||||
['开关频率', '150', 'kHz', '影响外围'],
|
||||
['静态电流', '6', 'mA', '待机功耗'],
|
||||
['工作温度', '-40~+85', '℃', '工业温域'],
|
||||
['使能阈值', '1.3', 'V', 'ON/OFF 阈值'],
|
||||
]),
|
||||
...paramRows('XL2596S-5.0', [
|
||||
['输入电压范围', '4.5~40', 'V', '客户上限 40V'],
|
||||
['输出电压', '5.0', 'V', '固定版'],
|
||||
['输出电流', '3', 'A', '满载能力'],
|
||||
['开关频率', '150', 'kHz', '影响外围'],
|
||||
['静态电流', '4.2', 'mA', '待机功耗'],
|
||||
['工作温度', '-40~+125', '℃', '工业温域'],
|
||||
['使能阈值', '未披露', 'V', '原厂未给'],
|
||||
]),
|
||||
],
|
||||
},
|
||||
// 原 PN(CN-DCDC-32V)输入上限只有 32V,候选 XL2596S-5.0 上限 40V 盖住原 PN —— 不该拦;
|
||||
// 真正的硬约束不符落在别处案例,这条只钉「两颗都缺库仍能走完全程」,故不设 expectRevised。
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -803,13 +915,22 @@ async function api(path, init = {}) {
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (DIRECT_BEARER !== '') {
|
||||
if (bearer === '') console.log(' 身份:JCAI_E2E_BEARER 直供的 token(不续期;卡会落到这枚 token 主人的工作台)');
|
||||
const svcReady = KC_URL !== '' && E2E_CLIENT_ID !== '' && E2E_CLIENT_SECRET !== '';
|
||||
/*
|
||||
* **服务账号三个变量全齐时走正路,`JCAI_E2E_BEARER` 只在缺任意一个时才兜底**
|
||||
* (2026-08-30 改序,见 `DIRECT_BEARER` 声明处的大注释)—— 从前 `DIRECT_BEARER !== ''`
|
||||
* 无条件优先,环境里留着一枚旧 token 就会悄悄绕开服务账号一整轮,还只有一行不起眼的日志。
|
||||
*/
|
||||
if (!svcReady && DIRECT_BEARER !== '') {
|
||||
if (bearer === '') {
|
||||
console.log(' ⚠️ 身份:JCAI_E2E_BEARER 直供的 token(服务账号三个变量缺了至少一个,降级用它)——');
|
||||
console.log(' 不续期;卡会落到这枚 token 主人的真实工作台上,不是「绝不派到用户工作台」那条正路。');
|
||||
}
|
||||
bearer = `Bearer ${DIRECT_BEARER}`;
|
||||
bearerAt = Date.now();
|
||||
return;
|
||||
}
|
||||
if (KC_URL === '' || E2E_CLIENT_ID === '' || E2E_CLIENT_SECRET === '') {
|
||||
if (!svcReady) {
|
||||
throw new Error('缺 KEYCLOAK_URL / KEYCLOAK_E2E_CLIENT_ID / KEYCLOAK_E2E_CLIENT_SECRET —— e2e 靠服务账号拿 token(ADR-012);临时可用 JCAI_E2E_BEARER 直供一枚');
|
||||
}
|
||||
const res = await fetch(`${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token`, {
|
||||
@@ -843,6 +964,18 @@ function rejectedCount(trail) {
|
||||
return trail.filter((t) => t.kind === 'action-rejected' || /行动被拒|被拒[::]|拒绝执行/.test(t.title ?? '')).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* **AI 出场次数**(w05 专用,ADR-014 的 `analyze` 那一笔)—— 轨迹里数
|
||||
* 「已把 N 条参数交给模型逐条判定,拿回 M 条结论」这句话(`auto-runner.ts` 的
|
||||
* `kind:'observation'` 留痕,字面原样)。W05 全程零 agent,唯一一次模型调用长在 S2;
|
||||
* 「行动被拒 0」这条验收线对没有 agent 决策的确定性步骤活动自动成立(没有行动可拒),
|
||||
* 所以这里改守的是这个数 —— 它才是这次真正防回归的那条线(S3 审核循环 / T_release
|
||||
* 重做都不该让它超过 1,S2 的入场哨兵 `s_inputs_ready` 也不该被绕过去多算一次)。
|
||||
*/
|
||||
function analyzeCallCount(trail) {
|
||||
return trail.filter((t) => /已把 \d+ 条参数交给模型逐条判定/.test(t.title ?? '')).length;
|
||||
}
|
||||
|
||||
/** 草稿层的行(带主键),按台账地址取 */
|
||||
function draftRows(drafts, address) {
|
||||
return (drafts ?? []).filter((d) => d.address === address).map((d) => ({ ...d.key, ...d.patch }));
|
||||
@@ -916,6 +1049,14 @@ async function expectGateBlocks(cardId, taskId, stage) {
|
||||
*
|
||||
* 选项按案例声明的 value 认;认不上退「推荐项 → 第一项」——
|
||||
* 代答脚本不该因为模型换了个选项名就判活动失败。
|
||||
*
|
||||
* **人工验收清单**(2026-08-27 用户定,W05 的 S3 审核卡第一个用到这条机制):
|
||||
* 审核卡(`variant:'review'`)可能带 `card.checklist` ——选**通过**那一项时必须
|
||||
* `checked ⊇ checklist`,否则回传吃 `checklist-incomplete`(400)。这里**原样照抄**
|
||||
* `card.checklist` 回传(不必知道清单具体写的是哪几句话,清单原文由卡自己带下来);
|
||||
* 只在选中的是「通过」那个值时才带(驳回/自定义意见不需要点全清单,与服务端
|
||||
* `reviewApproveValue` 同一条判法:优先 value==='approve',否则退回推荐项、再退第一项)。
|
||||
* 没有 `checklist` 的卡(其余八套件)`checked` 不出现在载荷里,行为与从前一字不差。
|
||||
*/
|
||||
async function answerDecisions(taskId, kase, answered) {
|
||||
const { body } = await api(`/api/desk/tasks/${taskId}`);
|
||||
@@ -934,14 +1075,17 @@ async function answerDecisions(taskId, kase, answered) {
|
||||
const picked = custom
|
||||
? { value: want, label: `意见:${want.slice(7)}` }
|
||||
: (options.find((o) => o.value === want) ?? options.find((o) => o.recommended === true) ?? options[0]);
|
||||
await api('/api/desk/chat-cards/reply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reply: { k: 'decision', cardId: card.id, value: picked.value, note: custom ? want.slice(7) : '试跑代答' } }),
|
||||
});
|
||||
const approveValue = (options.find((o) => o.value === 'approve') ?? options.find((o) => o.recommended === true) ?? options[0])?.value;
|
||||
const reply = { k: 'decision', cardId: card.id, value: picked.value, note: custom ? want.slice(7) : '试跑代答' };
|
||||
if (Array.isArray(card.checklist) && card.checklist.length > 0 && picked.value === approveValue) reply.checked = card.checklist;
|
||||
await api('/api/desk/chat-cards/reply', { method: 'POST', body: JSON.stringify({ reply }) });
|
||||
answered.add(card.id);
|
||||
asked.push(String(card.ask ?? card.title ?? ''));
|
||||
count += 1;
|
||||
console.log(` 决策卡拍板:${card.ask?.slice(0, 40) ?? ''} → ${picked.label ?? picked.value}`);
|
||||
console.log(
|
||||
` 决策卡拍板:${card.ask?.slice(0, 40) ?? ''} → ${picked.label ?? picked.value}` +
|
||||
(reply.checked ? `(人工验收清单 ${reply.checked.length} 条已点全)` : ''),
|
||||
);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -1191,12 +1335,29 @@ async function runCase(suite, kase) {
|
||||
let addedRows = false;
|
||||
let planned = false;
|
||||
let revised = false;
|
||||
/**
|
||||
* **不是 `expectRevised` 的「归属」检查**(2026-08-30,协调者裁定:两者外形相同但
|
||||
* 语义不同,不能共用同一句失败话术)——`expectRevised` 测的是 E04 那类「有 agent 轮次
|
||||
* 读驳回意见、按字面挑对行」的场景(归属认错了才失败)。W05 全程零 agent 轮次
|
||||
* (S3_review/T_release 都是 `guide: ''`,唯一的 `analyze` 发生在驳回意见产生**之前**),
|
||||
* 不存在「模型读意见挑行」这一步,所以案例 0 的驳回循环测不出归属,也不该冒充在测归属。
|
||||
* 这里只是**收尾核对一个规则算出来的值扛住了「驳回→重提」这一趟往返**
|
||||
* (使能阈值未披露 → S2 一次 analyze 就判 UNKNOWN → 有条件推荐,这个结论在驳回意见
|
||||
* 写下之前就已成立;重提后重新读一遍确认它没被这一趟揉坏)。
|
||||
*/
|
||||
let finalValueSeen = false;
|
||||
let acceptEdited = false;
|
||||
let minted = false;
|
||||
let forbidHit = false;
|
||||
let bailed = false;
|
||||
let receiptChecked = false;
|
||||
let receiptMismatch = false;
|
||||
/** w05「都在库·零数据卡直分析」:任意一张匹配 stages 的执行卡出现过(不论此刻答没答)就算见过 */
|
||||
let dataCardSeen = false;
|
||||
/** w05「类型不一致·硬拦」:`sub_eval.case.gate_reason` 命中过 `kase.expectGateReason` 那个词 */
|
||||
let gateReasonMatched = false;
|
||||
/** `kase.customFill` 只跑一次(w05「候选缺库·新建类型」:两阶段写 —— 先铸 type_id,读回来再引用它) */
|
||||
let customFilled = false;
|
||||
const deadline = Date.now() + 20 * 60 * 1000;
|
||||
let last;
|
||||
|
||||
@@ -1265,6 +1426,22 @@ async function runCase(suite, kase) {
|
||||
(r) => String(r[where] ?? '') === equals && String(r[column] ?? '').includes(contains),
|
||||
);
|
||||
}
|
||||
// 收尾值核对(非归属,见上方 finalValueSeen 声明处的大注释)—— 匹配逻辑与 expectRevised 相同,故意不合并成一个字段:失败话术要分得开。
|
||||
if (kase.expectFinalValue && !finalValueSeen) {
|
||||
const { address, where, equals, column, contains } = kase.expectFinalValue;
|
||||
finalValueSeen = draftRows(body.drafts ?? [], address).some(
|
||||
(r) => String(r[where] ?? '') === equals && String(r[column] ?? '').includes(contains),
|
||||
);
|
||||
}
|
||||
// w05「都在库·零数据卡直分析」:S1 的 doneWhen 起手就成立,断言一张数据工单都没出现过
|
||||
if (kase.expectNoDataCard && !dataCardSeen) {
|
||||
dataCardSeen = (body.cards ?? []).some((c) => (suite.stages ?? []).some((s) => s.match.test(c.title ?? '')));
|
||||
}
|
||||
// w05「类型不一致·硬拦」:gate_reason 那句话要点了名(latch 住,答完卡草稿就清了,过了这一拍再查就晚了)
|
||||
if (kase.expectGateReason && !gateReasonMatched) {
|
||||
const caseRow = draftRows(body.drafts ?? [], 'sub_eval.case')[0];
|
||||
gateReasonMatched = caseRow !== undefined && kase.expectGateReason.test(String(caseRow['gate_reason'] ?? ''));
|
||||
}
|
||||
|
||||
// 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的
|
||||
decisions += await answerDecisions(taskId, kase, decided);
|
||||
@@ -1314,6 +1491,18 @@ async function runCase(suite, kase) {
|
||||
: ` ⚠️ 缺着必填还放过去了:提交闸失效`,
|
||||
);
|
||||
}
|
||||
/*
|
||||
* **两阶段自定义填表**(w05「候选缺库·新建类型」专用)—— 在空手交卷的闸测过**之后**、
|
||||
* 常规 `buildEdits` 之前跑一次:先铸 `parts.chip_type` 的自增 `type_id`,读回来才知道
|
||||
* 引用它的 `parts.type_param`/`parts.profile` 该填哪个值(与真人在浏览器里「先建类型 →
|
||||
* 保存 → 新类型出现在下拉里 → 再选它」的两步顺序同构,不是脚本抄近道)。跑过一次就不再跑,
|
||||
* 跑完之后 `buildEdits` 对这张卡照常算(该案例没有别的 `stageEdits`,算出来是空,
|
||||
* 只会去交卷)。
|
||||
*/
|
||||
if (kase.customFill && !customFilled) {
|
||||
customFilled = true;
|
||||
await kase.customFill(taskId);
|
||||
}
|
||||
const lines = buildEdits(stage, body.drafts ?? [], kase);
|
||||
// 人加行:与真人在件上「加一行」同一条链 —— 新主键的行落进保存载荷即是新增
|
||||
if (kase.adds && kase.adds.address === stage.address && !addedRows) {
|
||||
@@ -1402,11 +1591,19 @@ async function runCase(suite, kase) {
|
||||
: ` ⚠️ 确认屏上补的联系人行没落下(或编码没续上)`,
|
||||
);
|
||||
}
|
||||
await api('/api/desk/work-orders/reply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reply: { mode: 'acceptance', workOrderId: card.id, verdict: 'pass', values: {} } }),
|
||||
});
|
||||
console.log(` 验收通过:${card.title}`);
|
||||
/*
|
||||
* **人工验收清单**(同上,W05 的 T_release 终局验收卡):试跑观测面的 `cards`
|
||||
* 摘要故意瘦身(`{id,mode,title,done,result}`,见 governance-ops.ts),没有
|
||||
* `checklist` 这一格 —— 要拿清单原文得走**任务详情**的 `workOrders`(与 `card` 同一个
|
||||
* `id`,那边走的是完整契约 `acceptanceWorkOrderSchema`)。没有清单的卡(其余套件)
|
||||
* 这次多查的一趟落空,`checked` 不进载荷,行为不变。
|
||||
*/
|
||||
const detail = await api(`/api/desk/tasks/${taskId}`);
|
||||
const workOrder = (detail.body.workOrders ?? []).find((w) => w.id === card.id);
|
||||
const acceptReply = { mode: 'acceptance', workOrderId: card.id, verdict: 'pass', values: {} };
|
||||
if (Array.isArray(workOrder?.checklist) && workOrder.checklist.length > 0) acceptReply.checked = workOrder.checklist;
|
||||
await api('/api/desk/work-orders/reply', { method: 'POST', body: JSON.stringify({ reply: acceptReply }) });
|
||||
console.log(` 验收通过:${card.title}${acceptReply.checked ? `(人工验收清单 ${acceptReply.checked.length} 条已点全)` : ''}`);
|
||||
replied.add(card.id);
|
||||
}
|
||||
}
|
||||
@@ -1466,10 +1663,30 @@ async function runCase(suite, kase) {
|
||||
console.log(` ⚠️ 点名驳回的改动没落在被点名那一行(${kase.expectRevised.equals})—— 归属又认错了,判失败`);
|
||||
ok = false;
|
||||
}
|
||||
if (kase.expectFinalValue && !finalValueSeen) {
|
||||
console.log(` ⚠️ 收尾核对没通过:${kase.expectFinalValue.equals} 的 ${kase.expectFinalValue.column} 应含「${kase.expectFinalValue.contains}」,没读到 —— 判失败(这不是归属问题,W05 全程零 agent 轮次测不出归属,见 finalValueSeen 声明处注释)`);
|
||||
ok = false;
|
||||
}
|
||||
if (kase.acceptEdits && !minted) {
|
||||
console.log(' ⚠️ 确认屏补录的联系人没拿到基座续的号 —— 判失败');
|
||||
ok = false;
|
||||
}
|
||||
if (kase.expectNoDataCard && dataCardSeen) {
|
||||
console.log(' ⚠️ 这一例本该是零数据卡直分析,却见到了一张数据工单 —— 判失败');
|
||||
ok = false;
|
||||
}
|
||||
if (kase.expectGateReason && !gateReasonMatched) {
|
||||
console.log(` ⚠️ gate_reason 没点名(期望命中 ${kase.expectGateReason})—— 判失败`);
|
||||
ok = false;
|
||||
}
|
||||
if (kase.expectAnalyzeCalls !== undefined) {
|
||||
const analyzeCalls = analyzeCallCount(last?.trail ?? []);
|
||||
console.log(` 模型调用 ${analyzeCalls} 次`);
|
||||
if (analyzeCalls !== kase.expectAnalyzeCalls) {
|
||||
console.log(` ⚠️ 模型调用该恰 ${kase.expectAnalyzeCalls} 次,实际 ${analyzeCalls} 次 —— 判失败`);
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (receiptMismatch) ok = false;
|
||||
if (forbidHit) {
|
||||
console.log(` ⚠️ 计划里出现了不该有的字样(${(kase.forbidInPlan ?? []).join('/')})—— 占位人名又回来了,判失败`);
|
||||
@@ -1529,6 +1746,22 @@ async function main() {
|
||||
} catch (error) {
|
||||
console.log(` ✗ 这一例中途出错,判失败,接着跑下一例:${String(error.message ?? error).slice(0, 200)}`);
|
||||
}
|
||||
/*
|
||||
* **跨案例隔离**(2026-08-30,协调者裁定①,run9 撞出来的套件级缺陷):不论这一例
|
||||
* 成败,`isolationCleanup` 都要跑——`JCAI_E2E_LAND_MASTER=1` 之下,某些案例会把
|
||||
* **共享的主数据行**改到一个只有它自己认得的状态(如案例 0「新建类型」必须把
|
||||
* distinct=1 用到的原 PN 一并转挂新类型,这一改随数据工单确认落进正式表)。
|
||||
* 不清理的话,下一个共用同一颗料的案例(如案例 1)会看见一个假的局面——
|
||||
* 这不是活动的错,也不是平台的错,是**套件本身没有做案例间隔离**,该在脚本层修。
|
||||
* 往后加案例:凡是会*改动*而不只是*新增*共享主数据行的,都要配一条 `isolationCleanup`。
|
||||
*/
|
||||
if (kase.isolationCleanup) {
|
||||
try {
|
||||
await kase.isolationCleanup();
|
||||
} catch (error) {
|
||||
console.log(` ⚠️ 跨案例隔离清理失败(${String(error.message ?? error).slice(0, 160)})—— 后续案例可能受这一例污染`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`\n${pass}/${total} 通过;记得 pnpm --filter @app/api reset-runs 清运行数据`);
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@utils": ["./packages/utils/src/index.ts"],
|
||||
"@testing": ["./tools/it-database.ts"],
|
||||
"@modules/*": ["./apps/api/src/modules/*"],
|
||||
"@app/*": ["./apps/api/src/app/*"],
|
||||
"@features/*": ["./apps/web/src/features/*"],
|
||||
"@shared/*": ["./apps/api/src/shared/*"],
|
||||
"@web-shared/*": ["./apps/web/src/shared/*"]
|
||||
|
||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
||||
'@features': p('./apps/web/src/features'),
|
||||
'@testing': p('./tools/it-database.ts'),
|
||||
'@modules': p('./apps/api/src/modules'),
|
||||
'@app': p('./apps/api/src/app'),
|
||||
'@shared': p('./apps/api/src/shared'),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user