Compare commits

...
13 Commits
Author SHA1 Message Date
xcsandClaude Fable 5 7ed997187e fix(engine): 确认闸条件档在发卡装配被丢 —— 类型不一致的卡从前点得动
CI / ci (push) Failing after 11s
活动声明了 gateWhen,发卡装配的手写清单没点它的名:落库载荷 undefined,
按钮禁用与服务端拒收一起无声失效(手写字段清单第四次丢值,与 variant
上次被丢的同一段)。抽 cardConditionGate 校验着转交(5 测);e2e w05
数据卡阶段补末端断言 expectCardGateWhen。活体验证:载荷带条件 → 闸关 →
API 强行确认 400;e2e 案例 0 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 11:09:48 +08:00
xcsandClaude Fable 5 0e02bb9411 fix(task): 终态收尾不等蒸馏 —— 终止接口曾被模型调用吊 13-45 秒到报错
CI / ci (push) Failing after 10s
四单法证:finished_at 与蒸馏条目 created_at 差 13.2/36.3/43.4/44.6 秒。
finish 把终态落库并广播之后同步 await memory.distill(一次真模型调用),
终止路由与放行收尾拍(连带推进器租约)都陪着模型等。改后台跑:
封卷仍在前,失败照旧吞掉不掀终态;三测钉死,改前即 30s 超时。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 03:36:53 +08:00
xcsandClaude Fable 5 3b9c559a47 fix(w05): 落账席那枚印章不再压住旁边的结论 —— 徽章按圈的内径收
用户:「评审放行与落账确认,中间这个有条件推荐这个 svg 挡住旁边的字了」。

在浏览器里量的账:徽章那一坨 .seal-core 139px 宽,而印章圈定死 112px —— 溢出 26px;
右边那句一句话结论从 603px 起,正好被压住。病根是徽章按自己的字宽撑开、又带着
white-space:nowrap 缩不了,而圈是定死的圆(place-items:center 只让它对称地溢出去)。

两条一起治:核心不许超过圈的内径(max-width:calc(100% - 16px));圈里那一枚字号收小、
去掉字距、允许折行(以后有更长的等级名就两行,圆里两行照样是圆)。
改完再量:核心 94px、圈内还余 9px、「有条件推荐」一行放得下、结论那段完全露出来。

验收法记一笔:视觉的账在**真 DOM** 上量 —— 离线渲一份真件挂本地服务,
浏览器里 getBoundingClientRect 前后各取一次,不在脑子里推。包内 +1 测(变异验证过)。
2.24.0 已装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 03:06:42 +08:00
xcsandClaude Fable 5 4174c5f194 fix(w05): 调人预算提到 24;「一条都没点到」的打回不再整表重判
用户:「怎么直接最后落帐的步骤直接没了?」—— 那一单终态是 exhausted:结论审核连打回四次,
每打回一次配方就重发一张审核卡、每张 +1 humanCalls,第八次触顶,放行那一步再没机会出现。
屏上看着像「那一步没了」,其实是单子先被烧死了。

① 预算 8 → 24(用户当场定「那你就把预算提高」):常路只要 3 次(补数据 / 审核 / 放行),
   而审核每打回一次就多叫一次人。授权面与资源面同一个数;测里改成「两处一致 + 下限 12」,
   不再钉死某个具体数字。**这是配置不是机制** —— 机制上的正解是「人自己打回引出的那张卡
   不算打扰」(他不是被打扰,是他叫的),已记 debt。

② 「一条都没点到 → 整表算被点名」当天上线、当天撤掉:他那句是「收口结论里面的推荐理由
   改为非常不推荐」—— 说的根本不是某条参数,是收口那四格(本来就能在件上直接改)。
   整表重判的下场:AI 拿这句话把 5 条参数的结论改成「不匹配」,每条依据都写着
   「按审核人意见调整为不匹配:推荐理由改为非常不推荐」——一句与它们无关的话,改了五行。
   现在点不到名就一条都不改,并照实说清怎么才办得成(点参数名 / 收口四格在件上改)。
   重判与写回同批加「真点到了名」的前提,免得白烧一次模型。

验收:e2e w05 5/5 released、被拒 0,案例 0 四条断言全绿;2.23.0 装库核过(humanCalls=24)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 02:55:05 +08:00
xcsandClaude Fable 5 3c563525f1 fix(engine): 那面「打不回」的旗在容器里被扔了 —— 手写字段清单第三次丢值,改成整包转交
用户:「评审放行与落账确认这一步的验收卡怎么还有重做??你没改上吗?」——
库里的卡确实没有那面旗:装配层算对了、release 转对了、契约也声明了,而**容器那个显式对象**
只转它点到名的那几样,`redoable` 没被点名就在这最后一关静默落地(编译不报、运行时无声)。

同一处已经吃过三次:①fields 写死成 [](验收报告只能看不能改)②blocks 漏一行(卡面块恒空)
③这次。那几行旁边就写着两条「少写一行就静默落地」的警告 —— 措辞治不了的结构治:
context 三样拆出去、**其余整包转交**,以后端口新增可选字段不改这里也到得了。
断言钉在最末端:试跑观测面的卡带上 redoable,e2e 断言「一路到了卡上」。

同批修另一种时序的卡死(同一夜第二次,停掉了一整个 e2e 案例):
**决策卡的回传不发事件、只调 wake**,而 wake 只翻停着的单子 —— 卡刚发出那一刻这一拍还在
running,它空转了一次;随后这一拍把任务 park 回 awaiting,再没有第三样东西会来叫
(实测:卡 02:06:13.250 发出、219ms 后被答,这一拍 .636 才收尾,停到 20 分钟超时)。
修:配方发卡收尾前再看一眼刚发的那张卡还在不在等 —— 已经答了就照实说「还在跑」;
只认这一张卡,别的「等外部回音」照旧是等,不会空转成热循环。
假端口同批改老实:发出去的卡默认就在等(从前发完卡还回「一张待答的都没有」)。

验收:e2e w05 5/5 released、被拒 0,案例 0 四条断言全绿(不摆重做 / 对话区说了话 /
点名要的结论落下去了 / 只动被点名的行);两处变异验证;契约变更 api-report 已刷新。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 02:36:53 +08:00
xcsandClaude Fable 5 e474275b28 fix(w05): 复核轮审核人说了算,并把这一轮改了什么说进对话区
用户第二次实测:「重做还是没有修改掉内容」—— 他写「静态电流结论改成不匹配」,而两颗料的
静态电流都是 5mA,AI 搬出第一轮那份规矩里的第 3 条(硬约束不因措辞变成 match)把他顶了回去:
evidence_note 白纸黑字「维持上一轮 match…硬规矩要求按实际值判定,不能因措辞改变结论」。

**行的范围这次是对的**(只把那一条交给 AI、只写回那一条),错在**谁说了算**。三处想岔了:
① 那条规矩管的是它自己判的时候别被措辞带偏,不是用来否决审核人的;
② 方向也反了 —— 它守的是「不许把不满足说成满足」(危险方向),而他要的是收紧结论;
③ 更根本:审核人在件上本来就能亲手改任何一条四态,AI 在重判这条路上顶回去,
   等于同一件事在两条路上给出相反的答案。
改:复核指令明写这条覆盖 —— 他点名要什么结论就落什么,不要顶回去;落的同时 evidence_note
记两句「按谁的意见」+「数据面是什么」,下一个人回看能一眼分清数据判的还是人定的。

同批(用户接着提:「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,不然都不知道发生了什么」):
复核完在对话区说一句「按你的意见复核了 N 条 —— <参数名>:<判断依据>」,摆的是件上那一行
自己的话,不另编一句。**它必须排在卡之前** —— 发了卡这一拍就收了,排在卡后面的 say 要等
下一拍才跑:原来那句「差异结论已送审,请核对」实测正是在人答完之后才出现的;而且它与卡自己的
ask 本来就是同一件事,一并退役。

验收:e2e w05 5/5 released、被拒 0;案例 0 三条断言全绿 ——
「复核那一轮在对话区说了话」「打回点名要的结论真落下去了(使能阈值 = conditional)」
「打回只动了被点名的那几条」。包内 +2 测。W05@2.21.0 已装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 01:57:52 +08:00
xcsandClaude Fable 5 cec19adc85 fix(engine): 打不回的验收卡不摆那颗钮 —— 全 auto 的放行步没有模型能接住那句意见
用户:「评审放行与落账确认这一步的验收卡不应该有重做的选项,因为这里都没有接入 ai」。

W05 的 T_release 整个由配方跑完(guide 是空串,不问模型),件又是只读的 ——
打回之后没有任何东西能接住那句意见:配方只会原样再提交一次,撞在「打回之后台账没动过
就不许再提交」那道闸上,连撞两拍落成一张「连续撞在同一条判据上」的兜底卡。
一颗按下去只会把单子推进死胡同的钮,不如不摆(墙要配路;没有路就别立那扇门)。

· 契约:AcceptanceWorkOrder.redoable(不写 = 打得回,老卡老行为);
· 装配层按放行那一步的声明现算 —— releaseRedoable 纯函数(有 auto 且 guide 空 → 打不回;
  半 auto 与模型驱动照旧打得回,那儿真有模型会读意见),与 decisionCardFace /
  executionCardVariant 同一种做法、同一个测试位;
· 卡面收起理由框与那颗钮,换一句真路:「这一步只落账,退不回上一步 —— 有异议就别确认,
  去终止这一单重开」;
· **服务端才是门**:redoable:false 的卡收到非 pass 的回传当场拒(redo-unavailable),
  直调 API 绕不过;拒回把真路指出来。

S3 结论审核那张审核卡不受影响 —— 它的重做真会带着意见重判(昨夜那一轮)。
测:card-rules 3 条 + releaseRedoable 4 条,两处变异验证;e2e w05 5/5 released、被拒 0。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 01:34:16 +08:00
xcsandClaude Fable 5 8314148ebc fix(w05): 重判的行由确定的一侧圈 —— 打回点名工作温度,不许去改静态电流
用户实测:「我打回让修改工作温度的结论,给我改了静态电流的结论」。库里的证据一字不差:
工作温度它没改(还给了理由:候选值 1℃ 远窄于 -40~+85℃,硬约束不满足 —— 这半截是对的),
而没人问过的静态电流被它从 soft_mismatch 翻成 match,evidence_note 里却写着「维持原判」。

根因不是提示词不够严,是**整表交出去、整表写回来**:没有任何东西限制它只动被点名的那一行,
而「我只改了那一条」这句话本身也出自它嘴里。规范 §4.6 纪律一(行由确定的那一侧决定,
不由 AI 决定)—— 昨天刚加的重判轮自己栽在这条上。

S3 复核轮两笔变四笔(全部只在 case_redo_note != "" 那一轮跑):
① 圈范围:按字面点名(contains,与基座给模型摆的「意见点名对上了哪几行」同一种认法)标
   named / kept(明细表新增 redo_scope 列);
② 兜底:一条都没点到(意见说的是整批)→ 整表算被点名,读上一笔刚写下的 tally,不重算;
③ 重判只喂 named 的行 —— 没点名的行 AI 连见都见不到,自然改不着(也省 token);
④ 写回也只写这几行;模型漏答某一条时 else 兜回它上一轮的值。

同批修一条挡了两次路的老毛病(昨夜刚记 debt,今晚又撞停、还卡住了我自己的验证):
人答得比发卡那一拍收尾还快时,回传被判 stale、wake 又被那一拍的 park 覆盖,单子停在
awaiting-event 再没人叫醒。deliver 判了 stale 照旧不跑这一拍,但把停着的单子拨回 running。

验收:e2e w05 5/5 released、被拒 0;案例 0 新断言「打回只动了被点名的那几条,其余行一个字没变」
真跑通过(途中逼出脚本自己的坑:放行会清空草稿层,快照得留在放行之前);
模型调用 案例 0 = 2 次、其余 = 1 次。四处变异验证。W05@2.18.0 已装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 01:17:09 +08:00
xcsandClaude Fable 5 4d2c3657f9 feat(w05): 重做的意见交回给 AI 重跑那一步 —— 不再只是把派生列重算一遍
用户:「唯一调用 ai 的这一步,使用重做后是否没有将重做原因带回去重新让 ai 重跑这一步?」
确实没有:S3 那一轮只跑三笔纯投影重算,人写的那句话除了留痕之外无人读 ——
AI 判过的四态一个字不会变,除非他自己在件上逐格改。

平台:引擎把「人这次打回写了什么」注给配方(`case_redo_note`)。
· 下划线不是 case: 前缀 —— S-FEEL 的名字不许带冒号,而它最要紧的用法就是当 when 条件用;
· **没打回也永远绑着空串**:未绑定的名字 `!= ""` 求值为真,不绑就等于那几笔每一轮都跑
  (拿真 @sfeel 钉了这条,桩也照真语义写,否则钉不住);
· 静态审认这个名字 —— 它当场拦下过一次未登记的引用,这道门做对了事。

W05@2.17.0:S3 多两笔,都钉在 `case_redo_note != ""` 上 ——
带意见重判(入参多带上一轮四态/影响/依据/UNKNOWN 去向 + 他那句话;判法与第一轮同一份
ASSESS_RULES,后接复核纪律:没被点到、也确实没判错的行原样带回,别趁重判把人改过的冲掉)
+ 判完落回明细(与第一轮同一笔投影,只多一个 when)。判法、出参形状、落明细抽成三个
共享常量,S2 与 S3 共用 —— 各写一份早晚分叉。

验收:e2e w05 5/5 released、被拒 0;案例 0(先点名驳回、再通过)模型调用恰 2 次,
其余四例仍是 1 次 —— 没被打回就不多花一次模型。auto-runner 3 测(含变异验证)、包内 +1。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 00:33:23 +08:00
xcsandClaude Fable 5 f5435f4c9c fix(engine): 人点「重做」不是「办完了」—— 打回即重开一轮,不再一路推到放行
用户实测:「我重做后怎么直接推进了?不应该是重做刚才那一步吗?」那一单的轨迹逐条对得上:
审核卡发出 → 人带意见交回 →「这一步已经发过卡,不再发第二张」→「自动收步:S3_review」
→ 下一拍发出落账放行的验收卡。意见落进了轨迹(还被 note-anchors 逐行对上了),
而再没有任何东西会去读它。

这条缺陷 2026-08-30 全分支终审已记录成 Important 2、并留着一条坐实病灶的测试,
连「半个修法会把单子卡死」都预判对了。照那个方向修完,两半缺一不可:
① 收步那道门认答复(answerSettlesStep:custom:(人没选任何选项、写了自己的话)与
   约定的 reject 都不算办完;空答复照旧算「照原样确认」);
② 同一句判断也让 carded 让位 —— 幂等挡的是「同一问被重复发出去」,而人点重做正是说
   「这一轮不算,改了再问我一遍」:配方重跑(该重算的重算)再发一张,日志里说一句
   「人把这一步打回重做:<意见>」。

测:坐实那条改写成钉死修复后的行为(历史诊断原样留着);新增「重做但发不出新卡也不收步」
(这一条专管收步门那一半)与「通过照旧收步」;3 条纯函数测。两半各自变异验证。
e2e w05 5/5 released —— 案例 0 现在真的发出两张审核卡(先带意见驳回、再确认通过)。

顺带撞见一条独立的老毛病,已记 debt 未修:人答得比发卡那一拍收尾还快时,回传被判 stale、
wake 又被那一拍的 park 覆盖,单子停在 awaiting-event 再没人叫醒(手工拨回 running 立刻走完)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-09-01 00:03:33 +08:00
xcsandClaude Fable 5 445aa5ef22 fix(w05): 验收工单的面孔在装配层被丢掉 —— 两头都有测,中间那一段把值扔了
用户实测:「你怎么给我发成了执行工单,我需要的是验收工单,然后标题那一块需要是
验收卡的那种绿色」。

一、面孔:engines.ts 发执行工单那一处是一句写死只认 'data' 的三目,配方递下来的
'accept' 静默丢掉 —— 对话区照旧摆「执行工单」。**配方那一头有测(variant 原样递出去)、
前端那一头有测(accept 该穿哪副面孔)、e2e 按标题驱动也全绿,唯独中间没有。**
修:抽成纯函数 executionCardVariant(与旁边 decisionCardFace 同一种做法、同一个测试位),
认 data / accept、拒 review、认不出的当没写;4 测变异验证过。
e2e 同批改**认面孔不认标题**(试跑观测面的 cards 补 variant,契约变更)——
标题是活动自己写的字符串,卡穿哪副面孔由装配层决定,那一次正是两者对不上。

二、颜色:件① 验收工单那一副整套强调色换成验收绿 #0aa294(令牌从 ACCENT_LEDGER
现改选择器得来,与件②③、对话区验收卡一处真源),题头那条写死的浅蓝渐变单独给一条绿的;
数据工单那一副仍是蓝。真渲染核过两副 class 与两套令牌。

验收:e2e w05 5/5 released、被拒 0(面孔断言这一轮真在跑);2563 测、四门禁绿;
2.16.0 装库。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 23:26:09 +08:00
xcsandClaude Fable 5 ce72da77eb feat(w05): 数据齐了也叫人一次 —— S1 改派只读的验收工单,件同一份、编辑钮收起
用户:「就算数据是足的也进入这个数据工单,就派发一个验收工单,界面和数据要和现在的
第一个数据工单一致,只不过把芯片卡片上的编辑按钮去掉,只展示不能编辑,然后把标题旁边的
数据工单改成验收工单」。

病根在引擎拍首那道 doneWhen 扫描:输入本来就齐的单,S1 的 doneWhen 第一拍就为真,
配方一笔都还没跑,这一步当场被收掉 —— 卡永远发不出去,人一眼没看过就进了 S2。
治法:CMMN 给 S1 标 manualActivation,那道扫描多一个门槛(worldTouched),次序变成
「配方发卡 → 人答 → 下一拍 doneWhen 为真才收」;doneWhen 仍是真门,只是不抢在人前面。

平台:执行工单第三副面孔 variant:'accept'(件只读、人只确认「确认无误」)——
与 mode:'acceptance'(submit 抬起来的收口验收,带打回重做)分清;三副面孔的判断
抽成纯函数 order-face.ts(有测),chat-bubbles 只摆。
活动:S1 两笔互斥的 card(carded 幂等保证一步只发一张)+ 案卷粘着的旗 order_kind;
一张件两副面孔 —— 题头/标题/页脚三处名字与卡角编辑钮按 v-data / v-accept 由样式表挑
(藏的是容器 .fabs,逐枚去盖打不完)。

测:引擎测钉死那一对(标了人工激活 → 卡发得出去、步不收;没标 → 步当场被收、卡发不出);
auto-runner variant 透传;order-face 4 条;包内 2 条(两笔卡 + 粘旗)、view 1 条;
两处变异验证。e2e w05 5/5 released、被拒 0、模型调用恰 1 次 —— 都在库那例摆的正是验收工单。
2558 测、四门禁绿;2.15.0 装库核过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 22:44:00 +08:00
xcsandClaude Fable 5 e47ce92702 fix(w05): 两颗料不能是同一颗 —— 起单这一刻立墙,因为路只在这里
用户:「创建活动的启动参数里面不应该能选到同样的两个芯片」。
查下来这不只是没意义,是**把单子做死**:sub_eval.part 的主键是 eval_ref+internal_pn
(role 不在主键里),S1 配方铸的两行撞成同一个主键、后一笔覆盖前一笔,两行收成一行 ——
sub_eval_part_exists_status_found = 2 永不成立,S1 收不了步,而件上那两行人加不出来
(行源 mine:,没有「+加一行」),唯一出路是终止重开。与「两格留空」是同一条链。

平台加互斥声明 x-distinct(契约 distinctGroup)——与 x-link 正相反:那个是引导,这个是墙。
· 前端:别的格占着的候选这一格不列(自己填着的那个永远留着,否则 select 当场变空白),
  并说清是谁占了哪个;走「新增」自己写一个同样的 → 小红字 + 启动按不下去;
· 服务端 validateLaunchParams 独立拦(脚本 / API 绕不过),400 点名后填的那格;
· 「算不算同一个」= 去空白 + 不分大小写(launchDistinctKey,前后端同一把尺)——
  只挡精确相等的话 lm2596s-5.0 照样进去,混一颗垃圾主数据出来。
W05 两格挂 x-distinct: 'part',2.14.0 已装库核过。

测:服务端 6 条 + 前端 8 条(含联动与互斥合起来的真形状)+ 包内 1 条,三处变异验证过;
四道门禁绿(2548 测)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-31 22:03:56 +08:00
53 changed files with 2534 additions and 295 deletions
+220
View File
@@ -1834,6 +1834,226 @@
.dossiers 三列网格(1fr·76px·1fr),中间列是实体动画道 —— ⇄ 徽呼吸 + 双点绕徽轨道
对转(sw-orbit),总线虚线在道里连两卡;窄屏单列收道。浏览器两帧相位实证。
- **复核轮审核人说了算 —— AI 不许拿硬规矩顶回去**(2026-09-01 用户第二次实测:「重做还是没有
修改掉内容」):他写「静态电流结论改成不匹配」,而两颗料的静态电流都是 5mA,AI 于是搬出
第一轮那份规矩里的第 3 条(硬约束不因措辞变成 match)把他顶了回去 ——
`evidence_note` 白纸黑字:「维持上一轮 match…硬规矩要求按实际值判定,不能因措辞改变结论」。
**行的范围这次是对的**(只把那一条交给 AI、只写回那一条,其余六条一个字没动),错在**谁说了算**。
两处想岔了:①那条规矩管的是**它自己判**的时候别被措辞带偏,不是用来否决审核人的;
②方向也反了 —— 那条守的是「不许把不满足说成满足」(危险方向),而他要的是**收紧**结论;
③更根本的是:审核人在件上**本来就能亲手改任何一条四态**,AI 在重判这条路上顶回去,
只是让同一件事在两条路上给出相反的答案。
改:复核指令明写这条覆盖 —— **他点名要什么结论就落什么**(「不匹配」= mismatch、「有条件」=
conditional…,不要跟他讲道理、不要顶回去);落的同时 `evidence_note` 记两句
「按谁的意见」+「数据面是什么」(例:「按审核人意见调整为 mismatch:<原话>。数据面:两侧均为 5mA。」)
—— 下一个人回看时要能一眼分清**这是数据判出来的**还是**这是人定的**;
他只是提疑问 / 要补依据的,照旧由 AI 复核后自判。
e2e 案例 0 的打回意见同批换成**点名要结论**那一种,新断言逐字核那一行真变成了他要的值
(「打回点名要的结论真落下去了(使能阈值.difference_status = conditional)」)。
**同批把这一轮的交代摆进对话区**(用户接着提:「需要把 ai 的修改意见通过右边 ai 聊天窗口
展示出来,不然都不知道发生了什么」):复核完说一句「按你的意见复核了 N 条 —— <参数名>:<判断依据>」,
摆的是**件上那一行自己的话**,不另编一句(对话区与件上说的是同一件事,就该是同一句话)。
**它必须排在卡之前** —— 发了卡这一拍就收了,排在卡后面的 `say` 要等下一拍才跑:
原来那句「差异结论已送审,请核对」实测正是在**人答完之后**才出现的,而且它与卡自己的 `ask`
本来就是同一件事,一并退役。
- **落账席那枚印章把旁边的结论压住了**(2026-09-01 用户:「有条件推荐这个 svg 挡住旁边的字了」,
`@2.24.0`):**在浏览器里量出来的账** —— 徽章那一坨 `.seal-core` **139px**,而印章圈定死 **112px**,
溢出 26px;右边那句一句话结论从 603px 起,正好被压住。病根是徽章按自己的字宽撑开、
又带着 `white-space:nowrap` 缩不了,而圈是定死的圆(`place-items:center` 只会让它对称地溢出去)。
治法两条一起:核心不许超过圈的内径(`max-width:calc(100% - 16px)`)、圈里那一枚字号收小并
**允许折行**(以后有更长的等级名就两行,圆里两行照样是圆)。改完再量:核心 94px、圈内余 9px、
「有条件推荐」一行放得下、结论那段完全露出来。**视觉的账在真 DOM 上量,不在脑子里推**
—— 离线渲一份真件挂本地服务、浏览器里 `getBoundingClientRect` 取数,前后各一次。
- **「最后落账的步骤直接没了」= 单子先被烧死了**(2026-09-01 用户送来的 `INS-80`,终态 `exhausted`):
他在结论审核上连打回四次,每打回一次配方就重发一张审核卡、每张 +1 `humanCalls`,第八次触顶 ——
放行那一步再没机会出现。屏上看着像「那一步没了」,实际是**预算见底**。两处一起治:
①**预算提高**(用户当场定):8 → 24(授权面与资源面同一个数,测里改成「两处一致 + 下限 12」,
不再把某个具体数字钉死)。**这是配置不是机制** —— 机制上的正解是「人自己打回引出的那张卡
不算打扰」(他不是被打扰,是他叫的),记在 debt。
②**「一条都没点到」不再整表重判**(当天上线、当天撤):当时想的是「说的是整批」,
而他那句实际是 **「收口结论里面的推荐理由改为非常不推荐」** —— 说的根本不是某条参数,
是收口那四格(本来就能在件上直接改)。整表重判的下场:AI 拿这句话把 **5 条参数**的结论
改成「不匹配」,每条依据都写着「按审核人意见调整为不匹配:推荐理由改为非常不推荐」——
**一句与它们无关的话,改了五行**。现在点不到名就**一条都不改**,并照实说:
「你这句意见我没能对上具体哪一条参数,这一轮一条都没改;要重判某一条请写上参数名,
收口那四格在件上直接改就行。」—— **「猜他指的是全部」比「承认没对上」贵得多。**
- **「怎么还有重做??你没改上吗?」—— 手写字段清单第三次把值扔了**(2026-09-01):
上一条算对了、release 转对了、契约也声明了,而**容器那个显式对象**只转它点到名的那几样,
`redoable` 没被点名就在这最后一关静默落地(编译不报、运行时无声)。**同一处已经吃过三次**:
①`fields` 写死成 `[]`(验收报告只能看不能改)②`blocks` 漏一行(卡面正文块恒空)③这次。
那几行旁边就写着两条「少写一行就静默落地」的警告 —— **措辞治不了的结构治**:
改成把 context 三样拆出去、**其余整包转交**,以后端口上新增可选字段不改这里也到得了;
剩下两处 `as never` 是类型收窄,不是丢值。断言钉在**最末端**:试跑观测面的卡带上 `redoable`,
e2e 断言「放行验收卡不摆重做(redoable=false 一路到了卡上)」—— 这类丢值只有末端断言拦得住。
- **人答得比这一拍收尾还快,这一拍就别报「等人」**(2026-09-01,同一夜第二种时序,又停掉一整例):
**决策卡的回传不发事件、只调 `wake`**,而 `wake` 只翻停着的单子 —— 卡刚发出那一刻这一拍还在
`running`,它空转了一次;随后这一拍把任务 park 回 `awaiting-event`,再没有第三样东西会来叫
(工单回传那一路还有人事件兜底 + 昨夜补的 stale 那一下,决策卡这一路**一样都没有**)。
实测:卡 02:06:13.250 发出、**219ms 后**被答,这一拍 .636 才收尾,单子停到 20 分钟超时。
修:配方发卡收尾前再看一眼**刚发的这张卡还在不在等** —— 已经答了就照实说「还在跑」,
推进器下一轮接着处理那份答复;**只认这一张卡**,别的「等外部回音」照旧是等,不会空转成热循环。
同批把假端口改老实:**发出去的卡默认就在等**(从前发完卡还回「一张待答的都没有」,
那是生产里不会有的局面 —— 新那道门正是靠这个分辨的)。
- **打不回的验收卡就别摆那颗钮**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该
有重做的选项,因为这里都没有接入 ai」):W05 的 T_release 整个由配方跑完(`guide` 是空串,
不问模型),件又是只读的 —— 打回之后**没有任何东西能接住那句意见**:配方只会原样再提交一次,
撞在「打回之后台账没动过就不许再提交」那道闸上,连撞两拍落成一张「连续撞在同一条判据上」的
兜底卡。**一颗按下去只会把单子推进死胡同的钮,不如不摆**(墙要配路;没有路就别立那扇门)。
契约加 `AcceptanceWorkOrder.redoable`(不写 = 打得回,老卡老行为);装配层按放行那一步的声明现算
(`releaseRedoable` 纯函数:有 `auto` 且 `guide` 是空串 → 打不回;半 auto 与模型驱动照旧打得回);
卡面收起理由框与那颗钮,换一句**真路**:「这一步只落账,退不回上一步 —— 有异议就别确认,
去终止这一单重开」;**服务端才是门**:`redoable === false` 的卡收到非 pass 的回传当场拒
(`redo-unavailable`),直调 API 也绕不过。两处变异验证;S3 结论审核那张审核卡不受影响 ——
它的重做现在真会带着意见重判(见上一条)。
- **答得太快就没人再叫醒它 —— stale 那条守卫补最后一下**(2026-09-01,一夜之内撞停两次,
第二次直接把我自己的验证卡住 15 分钟):卡发出 **270ms** 后人就答了,而**把 `lastProgressAt`
往前挪的那一拍,正是发出这张卡的那一拍** —— 它不可能消费一条当时还不存在的回传,
2026-08-27 那条 stale 守卫的前提(「推进过 = 回传已被消费」)在这种时序下不成立;
回传口的 `wake` 也白做了(那一拍随后收尾,把任务 park 回 `awaiting-event`,晚到的写盖了早到的)。
三样凑齐:卡答完了、没有新卡在等、单子停着 —— **再没有第三样东西会叫醒它**
(手工把 runtime 拨回 running,同一份代码立刻把该走的几拍走完)。
修:`deliver` 判了 stale **照旧不跑这一拍**(它的本意不变),但把停着的单子拨回 `running`
交给推进器;真没事可干,它自己再停回去(代价一拍空转)。两条测钉死 + 变异验证。
真人点卡要几秒,撞不上这条窗口;**e2e 是 3 秒轮询 + 秒答,专把这种窗口撞出来** ——
它替人踩的正是这一类。
- **「我打回让改工作温度,它给我改了静态电流」—— 重判的行由确定的一侧圈,不由 AI 决定**
(2026-09-01 用户实测,`part-substitution-assessment@2.18.0`)。上一条刚把意见交回给 AI 重判,
当天就被这一单打脸,而库里的证据一字不差:**工作温度它没改**(还给了理由:候选值 1℃ 远窄于
-40~+85℃,硬约束不满足 —— 这半截是对的),而**没人问过的静态电流**被它从 soft_mismatch
翻成 match,`evidence_note` 里却写着「**维持原判**」。两张审核卡的「本轮结论」都还停在
S2 那一版(0 项一致 / 5 硬 / 2 软),而草稿此刻是 1 match / 5 硬 / 1 软 —— 差的那一项就是它。
**根因不是提示词不够严,是整表交出去、整表写回来**:没有任何东西限制它只动被点名的那一行,
而「我只改了那一条」这句话本身也出自它嘴里(它说了「维持原判」,可它改了)。规范 §4.6 纪律一
——**行由确定的那一侧决定,不由 AI 决定** —— 这次栽在自己手上。
治法(S3 复核轮从两笔变四笔,全部只在 `case_redo_note != ""` 那一轮跑):
①**圈范围**:按字面点名(`contains(case_redo_note, parameter_name)`,与基座给模型摆的
「意见点名对上了哪几行」同一种认法)把行标成 `named` / `kept`(明细表新增 `redo_scope` 列,tally);
②**兜底**:一条都没点到(「结论整体判得太松」这类意见说的是整批)→ 整表算被点名,
读的是上一笔刚写下的 tally 读数,不重新算一遍;③**重判只喂 `named` 的行** ——
没点名的行 AI 连见都见不到,自然改不着(也省 token);④**写回也只写这几行**,
模型漏答某一条时 `else` 兜回它上一轮的值(漏答不该变成「把它清空」)。
两处变异各自验证(写回不过滤 / 圈范围时一律标 named,都当场变红)。
e2e 案例 0 加一条**跨轮比对**断言:打回前存一份明细,放行前留最后一份,
逐行比「名字没出现在意见里的行,四态与三段叙述一个字都不许变」——
途中还逼出脚本自己的一个坑:**放行会清空草稿层**,收工时再取明细什么都没有,快照得留在放行之前。
- **重做的意见现在真交回给 AI 重跑那一步**(2026-09-01 用户:「唯一调用 ai 的这一步,使用重做后
是否没有将重做原因带回去重新让 ai 重跑?」——**确实没有**:S3 那一轮只把派生列重算一遍,
人写的那句话除了留痕之外无人读,AI 判过的四态一个字不会变,除非他自己在件上逐格改)。
平台加一样东西:引擎把「人这次打回写了什么」注给配方,名字 **`case_redo_note`** ——
下划线不是 `case:` 前缀,因为 S-FEEL 的名字不许带冒号(`lexer.ts` 的 NAME_PART),
而这句话最要紧的用法恰恰是当 `when` 条件用;**没打回也永远绑着空串** ——
未绑定的名字 `!= ""` 求值为**真**,不绑就等于「只在被打回时才跑」的那几笔每一轮都跑
(拿真 @sfeel 钉了这条,桩也照真语义写,否则钉不住)。静态审同批认这个名字
(它当场拦下过一次:「配方的 when 引用了 case_redo_note,而台账、案卷、起单入参里都没有这个名字」——
这道门做对了事)。
W05@2.17.0:S3 多两笔,都钉在 `case_redo_note != ""` 上 —— **带意见重判**(入参比第一轮多两样:
上一轮的四态/影响/依据/UNKNOWN 去向,以及他写的那句话;判法与第一轮**同一份** `ASSESS_RULES`,
后面只接一段复核纪律:**没被点到、也确实没判错的行原样带回**,别趁重判把人改过的东西悄悄冲掉)
+ **判完落回明细**(与第一轮同一笔投影,只多一个 `when`)。为此把判法、出参形状、落明细那一笔
抽成三个共享常量,S2 与 S3 共用 —— 各写一份早晚分叉。
**验收**:e2e w05 **5/5 released、被拒 0**,案例 0(先点名驳回、再通过)**模型调用恰 2 次**,
其余四例仍是 1 次 —— 没被打回就不多花一次模型。
- **「我重做后怎么直接推进了?」—— 打回与通过在收步那道门眼里一模一样**(2026-08-31 夜,
柴浩实测的那一单逐条对得上):审核卡发出 → 人带意见交回 →「这一步已经发过卡,不再发第二张」
→「**自动收步:S3_review —— 卡已交回,这一步到此为止**」→ 下一拍就发出了落账放行的验收卡。
意见落进了轨迹(还被 note-anchors 逐行对上了),而再没有任何东西会去读它。
**这条缺陷 2026-08-30 全分支终审时已被记录成 Important 2、还留着一条坐实病灶的测试**,
连「半个修法会把单子卡死」都预判对了 —— 这次照它的方向修完:
①收步那道门认答复(`answerSettlesStep`:`custom:`(人没选任何选项、写了自己的话)与约定的
`reject` 都不算办完;空答复照旧算「照原样确认」);②**同一句判断也让 `carded` 让位** ——
幂等挡的是「同一问被重复发出去」,而人点重做正是说「这一轮不算,改了再问我一遍」,
那不是重复,是下一轮:配方重跑(该重算的重算)再发一张,并在日志里说一句「人把这一步打回重做:…」。
只做①就是那个被预判过的半修法:挡住误收,单子卡死在「答过但没收步」的死胡同。
三条引擎测(重做→重开 / 重做但发不出卡→也不收 / 通过→照旧收)+ 3 条纯函数测,
两个半边各自变异验证;**e2e w05 5/5 released**,案例 0 现在真的发出**两张**审核卡
(先带意见驳回、再确认通过)。
**顺带撞见一条独立的老毛病并记账**(未修,见 debt.md):人答得比发卡那一拍收尾还快时,
回传被判 `stale`、`wake` 又被那一拍的 park 覆盖,单子停在 `awaiting-event` 再没人叫醒 ——
手工拨回 `running` 立刻走完。2026-08-27 那条 stale 守卫的前提(「推进过 = 回传已被消费」)
在这种时序下不成立:把时刻往前挪的那一拍,正是发出这张卡的那一拍。
- **「你怎么给我发成了执行工单」—— 面孔在真发卡那条路上被丢掉**(2026-08-31 用户实测,
`part-substitution-assessment@2.16.0`):装配层发执行工单那一处是一句**写死只认 `'data'`**
的三目(`engines.ts`),配方递下来的 `variant: 'accept'` 静默丢掉 —— 对话区照旧摆「执行工单」。
**两头都有测,唯独中间没有**:配方那一头断言了「variant 原样递出去」,前端那一头断言了
「accept 该穿哪副面孔」,e2e 按卡的**标题**驱动也照样全绿 —— 三处都对,而它们中间那一段把值扔了。
修:抽成纯函数 `executionCardVariant`(与旁边的 `decisionCardFace` 同一种做法、同一个测试位),
认 data / accept、拒 review、认不出的当没写;新测 4 条变异验证过。
e2e 同批**改认面孔不认标题**(试跑观测面的 `cards` 补 `variant`,契约变更):标题是活动自己写的
字符串,卡穿哪副面孔由装配层决定,两者可以对不上 —— 那一次正是「标题叫验收工单、发出去是执行工单」。
**面孔要在真发卡那条路上断言。**
同批(用户第二句「标题那一块需要是验收卡的那种绿色」):件① 验收工单那一副整套强调色换成
**验收绿 #0aa294**(令牌从 `ACCENT_LEDGER` 现改选择器得来,数值与件②③、对话区验收卡一处真源),
题头那条写死的浅蓝渐变单独给一条绿的;数据工单那一副仍是蓝。真渲染核过两副 class 与两套令牌。
- **起单不许选到同一颗料;S1 数据齐了也叫人一次**(2026-08-31 用户两条,
`part-substitution-assessment@2.15.0`):
①**两格互斥**(「创建活动的启动参数里面不应该能选到同样的两个芯片」)—— 查下来不只是没意义,
是**把单子做死**:`sub_eval.part` 主键 `eval_ref + internal_pn`(role 不在主键里),配方铸的两行
撞成同一个主键、后一笔覆盖前一笔,两行收成一行 → `sub_eval_part_exists_status_found = 2` 永不成立、
S1 收不了步,而件上那两行人加不出来(行源 `mine:`,没有「+加一行」)—— 唯一出路是终止重开
(与「两格留空」同一条链,清单头注早写着)。平台加互斥声明 `x-distinct`(契约 `distinctGroup`)——
与 `x-link` 正相反:那个是引导,**这个是墙**。前端把别的格占着的候选从这一格下拉里抽掉
(唯独自己填着的那个永远留着,否则 `<select>` 当场变空白),走「新增」自己写一个同样的 →
小红字 + 启动按不下去;服务端 `validateLaunchParams` 独立拦(脚本 / API 绕不过),400 点名后填那格。
「算不算同一个」= 去空白 + 不分大小写(`launchDistinctKey`,前后端同一把尺;只挡精确相等的话
`lm2596s-5.0` 照样进去,混一颗垃圾主数据出来)。
②**数据齐了也叫人一次**(「就算数据是足的也进入这个数据工单,就派发一个验收工单…把芯片卡片上的
编辑按钮去掉,只展示不能编辑,标题旁边的数据工单改成验收工单」)——病根在**引擎拍首那道
`doneWhen` 扫描**:输入本来就齐的单,S1 的 doneWhen 第一拍就为真,配方一笔都还没跑,步骤当场被收掉,
卡永远发不出去。治法是 CMMN 给 S1 标 **`manualActivation`** —— 那道扫描多一个门槛(`worldTouched`),
次序变成「配方发卡 → 人答 → 下一拍 doneWhen 为真才收」;doneWhen 照旧是真门,只是不抢在人前面。
平台加**执行工单第三副面孔** `variant: 'accept'`(验收工单:件只读、人只确认,「确认无误」)——
与 `mode:'acceptance'`(submit 抬起来的收口验收,带打回重做)分清;三副面孔的判断抽成纯函数
`desk-task/internal/order-face.ts`(有测)。活动侧:S1 配方两笔互斥的 `card`(引擎 `carded` 幂等
保证一步只发一张 —— 数据工单发过之后验收工单永远轮不到,人不会被叫第二次),案卷加**粘着的旗**
`order_kind`(`data`/`accept`,第一档读上一拍写下的自己;不粘着的话人把最后一格填上、还没点确认,
件的题头就当场改名、笔也没了);**一张件两副面孔** —— 题头 / 标题 / 页脚三处名字与卡角编辑钮
按 `data-class-of="order_kind"` 投出的 `v-data`/`v-accept` 由样式表挑(藏的是容器 `.fabs`,
不是逐枚钮:那几枚各自的 display 由 `html.editable` 一串更特异的规则给着)。
**验收**:5 处变异验证;引擎测钉死那一对(标了人工激活 → 卡发得出去、步不收;没标 → 步当场被收、
卡一张发不出);e2e w05 **5/5 released、被拒 0、模型调用恰 1 次** —— 都在库那例摆的正是
「验收工单 · 差异评估的输入核对」,缺东西那三例照旧是数据工单;真渲染核过 `.card` 两副 class。
2558 测 / 五门禁绿;2.15.0 装库六探针核过。
**副产**:e2e 样例 3「原PN缺库」的前提已被污染(`LM2596S-3.3` 现在真在库里,多半是白天 ops 起单
落的主数据),那一例实际走的也是验收工单那条路 —— 名字与实测不符,清库存那件事记在这儿。
- **「类型不一致还能点下一步」的病根:确认闸条件在发卡装配被丢 —— 手写字段清单第四次丢值**
(2026-09-01 用户:「两个芯片类型不一样,页面上方有红色提示,右边派发的卡片还可以点击下一步」):
活动白纸黑字声明了 `gateWhen`(2026-08-31 加的确认闸条件档),而**发卡装配那段手写清单**
(engines.ts 的 human 口 —— `variant` 上次被丢的同一段)逐项点名转交 `gateLedgers`/`variant`,
没点 `gateWhen`/`gateWhenSay` 的名:落库工单载荷里它是 undefined(稳定库任务 43 法证),
按钮禁用与服务端拒收读的都是它,**一起无声失效**;红带是视图画的所以照亮 ——
屏上两处说着相反的话。此前 fields / blocks / redoable 三次同款,这次的教训:
**中段是不可信卡骨架的缝,不能整包 spread,那就抽纯函数校验着转交并在 variant 的
测试位旁边钉死**(`cardConditionGate`,5 测:转交原样 / 不发明键 / 脏值不端 /
say 单独无意义 / when 可无 say)。**末端断言照 redoable 那课补上**:e2e w05 数据卡
阶段 `expectCardGateWhen` —— 卡一到手查 desk 载荷,丢了当场判失败。
活体验证(测试台真单):载荷带 `gateWhen` → detail 闸 `ready:false` 带活动那句话 →
API 强行确认 **400**;e2e w05 案例 0 全绿(released、被拒 0、模型 2 次)。
活动侧零改动(`GATE_OPEN_WHEN` 含类型不一致,mismatch 时发的必是带闸的数据卡;
⑧ 验收卡的件只读、碰不上这形)。门禁 exit 0(2595 测)。
- **「终止活动」接口被蒸馏吊 13–45 秒到报错:收尾不等模型**(2026-09-01 用户:「点击终止后
接口迟迟不返回,还会报错,但实例确实终止了」):法证在库里 —— 四单被终止的任务,
`finished_at` 与蒸馏条目 `created_at` 差 13.2 / 36.3 / 43.4 / 44.6 秒:`finish` 把终态
落库并广播**之后**,同步 `await` 了 `memory.distill`(整单记忆 + JSON schema 结构化输出的
一次真模型调用),HTTP 响应吊在半空 —— 屏上早显示已终止(SSE 那一刻就发了),响应拖到
任何一环断连(watcher 重启进程 / 代理 / 人重试)就成一次报错。吊着的不止终止接口:
**放行收尾那一拍同样走 `finish`,推进器与任务租约陪模型等 40 秒**。修:蒸馏后台跑
(`void …distill().catch()`),封卷仍在前、失败照旧吞掉不掀终态;三测钉死(悬着的蒸馏不挡
control 返回 / 拒绝不外泄 / seal 在 distill 前),改前第一条即 30 秒超时 —— 变异天然验证。
CONTRACT.md 同步。门禁 exit 0(2590 测);:8787 已换上(watcher 只在重建时拉起、不守护
死进程 —— 杀了要 touch 源码触发重建才回来)。
- **W05 红条可灭:gate_state 开关列**(2026-08-31 用户:「没有必填了红色区域就不要显示」,
`part-substitution-assessment@2.13.0`):病根 —— gate_reason 清空串按列归型不落笔
(jsonb 保旧值),红条亮过灭不了。治:案卷加 `gate_state`(tally open/clear,双向真写),
@@ -113,14 +113,14 @@ describe('清单本身合规', () => {
/*
* 2026-08-29 重写:必交付物从三件(评估输入清单 / 差异评估结论 / 拟落账内容)收到两件 ——
* 「评估输入清单」那件连同它绑的三张旧单据(pn_check/input_gap/verdict)一起退休。
* 人环预算从 16(候选放弃/创建决策等岔路)收到 8(补数据 / 审核 / 放行三次调人,零工具、
* AI 只出场一次)—— 见「契约 > 预算」那条把 8 钉死;这里只需两个数上下一致。
* 人环预算:常路只要 3 次(补数据 / 审核 / 放行),**而审核每打回一次就多叫一次** ——
* 8 次实测三四轮就见底(2026-09-01 用户连打回四次,单子落 exhausted,屏上看着像「落账那一步没了」),
* 用户当场定「那你就把预算提高」→ 24。这里只需两个数上下一致(授权面与资源面不许分叉)。
*/
it('两件必交付物都有台账地址;人环预算恰为 8', () => {
it('两件必交付物都有台账地址;授权面与资源面的调人预算是同一个数', () => {
expect(manifest.contract.requiredDeliverables).toHaveLength(2);
for (const d of manifest.contract.requiredDeliverables) expect(d.ledger, d.key).toBeDefined();
expect(manifest.contract.authorization.humanCallBudget).toBe(8);
expect(manifest.contract.resources.budget.humanCalls).toBe(8);
expect(manifest.contract.authorization.humanCallBudget).toBe(manifest.contract.resources.budget.humanCalls);
});
/*
@@ -309,6 +309,20 @@ describe('契约', () => {
}
});
/*
* 两格不能是同一颗料(2026-08-31 用户定:「不应该能选到同样的两个芯片」)—— 与联动不同,
* 这一条**是墙**,且理由与上面「留空会卡死」那条是同一条链:同一个料号 = 同一个主键
* (`eval_ref + internal_pn`,`role` 不在主键里),两行撞成一行,`exists_status_found = 2`
* 永不成立,而件上那两行人加不出来 —— 唯一出路是终止重开。**墙立在起单这一刻,因为路只在这里。**
*/
it('两格互斥:同一颗料不能填两遍(x-distinct 同名 = 一组,服务端真拦)', () => {
const props = manifest.contract.input.data['properties'] as Record<string, Record<string, unknown>>;
for (const key of ['originalInternalPn', 'candidateInternalPn']) expect(props[key]?.['x-distinct']).toBe('part');
/* 主键真是「案号 + 料号」——「同一个料号 = 同一个主键」这条链的第一环,它变了这道墙就该重新论证 */
const part = manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === 'sub_eval.part');
expect(part?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['eval_ref', 'internal_pn']);
});
it('零工具 —— 这是这次重写的立场,不是疏漏', () => {
expect(manifest.contract.resources.tools).toEqual([]);
expect(manifest.declarations.tools).toEqual([]);
@@ -329,9 +343,14 @@ describe('契约', () => {
}
});
it('预算:调人三次(补数据 / 审核 / 放行),模型只出场一次', () => {
expect(manifest.contract.resources.budget.humanCalls).toBe(8);
expect(manifest.contract.authorization.humanCallBudget).toBe(8);
/*
* 常路调人三次(补数据 / 审核 / 放行),模型只出场一次 —— 而**审核可以打回,每打回一次就多叫一次人**,
* 所以预算按「常路 3 + 二十来轮打回」给(2026-09-01 用户定,见 manifest 那一行的注释)。
* 下限钉在 12:低于它,人点几次重做就把单子烧成 exhausted —— 落账那一步再也没机会出现。
*/
it('预算:常路调人三次,留足打回的余量;模型只出场一次', () => {
expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThanOrEqual(12);
expect(manifest.contract.authorization.humanCallBudget).toBe(manifest.contract.resources.budget.humanCalls);
expect(manifest.contract.resources.models).toEqual(['deepseek-v4-flash']);
});
});
@@ -470,6 +489,20 @@ describe('报告模版:编辑面与只读面各就各位', () => {
expect(viewHtml('landing-confirm')).not.toContain('data-edit-add=');
});
/*
* **印章里的徽章不许溢出圈外**(2026-09-01 用户:「有条件推荐这个 svg 挡住旁边的字了」)。
* 浏览器里量的账:徽章那一坨 139px 宽,而印章圈定死 112px —— 溢出 26px,
* 右边那句一句话结论从 603px 起,正好被压住;病根是徽章按自己的字宽撑开、
* 又带着 white-space:nowrap 缩不了。两条一起治,改完再量:核心 94px、圈内还余 9px、
* 「有条件推荐」一行放得下(以后有更长的等级名就折行,圆里两行照样是圆)。
*/
it('落账席的印章:徽章按圈的内径收,不许再溢出去压住那句结论', () => {
const html = viewHtml('landing-confirm');
expect(html).toContain('.seal-core{max-width:calc(100% - 16px)');
/* 缩不了才会溢出 —— 圈里这一枚必须允许折行 */
expect(html).toMatch(/\.seal \.rec\.rec-lg\{[^}]*white-space:normal/);
});
it('每个可编辑格子的 data-col 都在对应台账对象的声明里(input-supplement 一屏四张表,四张台账对象的声明并起来看;difference-review 一张表)', () => {
const cases: Array<[string, Array<[string, string]>]> = [
['input-supplement', [['parts', 'profile'], ['parts', 'parameter']]],
@@ -829,6 +862,42 @@ describe('件① 评估输入补全', () => {
expect(colOf('sub_eval.case', 'gate_state')?.tally).toEqual(['open', 'clear']);
});
/*
* 一张件两副面孔(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个验收工单,
* 界面和数据要和现在的第一个数据工单一致,只不过把芯片卡片上的编辑按钮去掉…标题旁边的
* 数据工单改成验收工单」)—— 数据与版面一个字不改,只有题头/标题/页脚三处名字与卡角那组
* 编辑钮随案卷的 `order_kind` 换。**收的是容器 `.fabs`**,不是逐枚钮:那几枚各自的 display
* 由 `html.editable …` 一串更特异的规则给着,逐枚去盖打不完。
*/
it('验收工单那一副:题头/标题/页脚换名 + 卡角编辑钮整组收起(同一张件,数据不动)', () => {
/* 换脸的钩子挂在最外层 .card 上 —— 判断留在样式表,模版只投类 */
expect(html).toContain('data-class-of="order_kind"');
for (const pair of ['<span class="k-data">数据工单</span><span class="k-acc">验收工单</span>', '<span class="k-data">数据</span><span class="k-acc">验收</span>']) {
expect(html).toContain(pair);
}
expect(html).toContain('<span class="k-data">差异评估的输入还差几格</span><span class="k-acc">差异评估的输入核对</span>');
/* 默认(旗还没写过 / 老单)是数据工单那一副 —— 与从前一模一样 */
expect(html).toContain('.k-acc { display: none; }');
expect(html).toContain('.v-accept .k-data { display: none; }');
expect(html).toContain('.v-accept .fabs { display: none; }');
expect(colOf('sub_eval.case', 'order_kind')?.tally).toEqual(['data', 'accept']);
});
/*
* 2026-08-31 用户:「标题那一块需要是验收卡的那种绿色」—— 验收工单那一副整套强调色换成
* 验收绿 #0aa294(与件②③、对话区验收卡同一枚),题头那条写死的浅蓝渐变单独给一条绿的。
* 令牌从 `ACCENT_LEDGER` 现改选择器得来:**数值只有一处真源**,件②③ 改色时这一副跟着走。
*/
it('验收工单那一副是验收绿:整套令牌换成 #0aa294,题头渐变也换掉(数值与件②③ 同源)', () => {
expect(html).toContain('.card.v-accept{');
expect(html).toContain('.card.v-accept.hd-light .card-head{background:linear-gradient(');
/* 蓝那一套仍在(数据工单那一副),两套各就各位 */
expect(html).toContain('--acc:#2e6bff');
const acceptBlock = html.slice(html.indexOf('.card.v-accept{'), html.indexOf('.card.v-accept.hd-light'));
expect(acceptBlock).toContain('--acc:#0aa294');
expect(acceptBlock).toContain('--ledger:#0aa294');
});
it('缺项点名全并进顶条「还差什么」,正文不再有逐块红带', () => {
expect(html).toContain('class="jc-gate-sub" data-class-of="parts_profile_missing"');
expect(html).toContain('data-slot="parts_profile_missing_keys"');
@@ -146,13 +146,47 @@ describe('S1 配方', () => {
* 变成空字符串)见 `apps/api/src/modules/loop-engine/__tests__/w05-gate-reason-order.test.ts`
* ——这份文件只做结构断言,不跑引擎(活动包不许 import apps/*)。
*/
it('六笔,次序是:两颗料 → 主数据骨架 → 参数骨架 → 案卷 → 卡 → 交代(类型字典/字段清单两笔自投影已随「只选不建」退役)', () => {
expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'say']);
it('七笔,次序是:两颗料 → 主数据骨架 → 参数骨架 → 案卷 → 数据工单 → 验收工单 → 交代', () => {
expect(kinds).toEqual(['project', 'project', 'project', 'project', 'card', 'card', 'say']);
expect(acts.map((a) => (a.kind === 'project' ? a.into : undefined)).filter(Boolean)).toEqual([
'sub_eval.part', 'parts.profile', 'parts.parameter', 'sub_eval.case',
]);
});
/*
* **数据齐了也叫人一次**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个
* 验收工单…把芯片卡片上的编辑按钮去掉,只展示不能编辑」)。两张卡是互斥的两条路,不是先后:
* 引擎的 `carded` 幂等保证一步只发一张 —— 数据工单发过之后,验收工单这一笔永远轮不到
* (补完数据不会再被叫第二次)。
*/
it('两张卡:缺东西派数据工单(读闸)· 都齐了派只读验收工单(读上一笔刚写下的粘旗)', () => {
const cards = acts.filter((a) => a.kind === 'card');
expect(cards.map((c) => (c.kind === 'card' ? c.variant : undefined))).toEqual(['data', 'accept']);
/* 验收工单不重算一遍闸 —— 读的是案卷刚写下的旗,同一件事只许有一处判定 */
expect(cards[1]?.kind === 'card' && cards[1].when).toBe('sub_eval_case_order_kind_accept > 0');
/* 两张都是执行工单(验收工单是它的变体,不是第三种 mode);零输入,填报/核对都在件上 */
for (const c of cards) expect(c.kind === 'card' && c.as).toBe('execution');
/* 闸同一份:件只读也要在确认那一刻再数一遍「数据是齐的」 */
for (const c of cards) expect(c.kind === 'card' && c.gateLedgers).toEqual(['parts.profile', 'parts.parameter']);
});
/*
* 旗**粘着**:第一档读的是上一拍写下的自己(tally 读数)。不粘着的后果实打实 ——
* 人在数据工单上把最后一格填上、还没点确认,闸当场变 clear,件的题头会当场改名叫
* 「验收工单」、卡角的笔也没了,而他手里那张卡还开着。
*/
it('order_kind 粘着:已经是 data 就一直是 data,否则按此刻的闸决定(与发卡条件同一句)', () => {
const caseAct = acts.find((a) => a.kind === 'project' && a.into === 'sub_eval.case');
const kind = caseAct?.kind === 'project' ? caseAct.fields['order_kind'] : undefined;
const gate = acts[4]?.kind === 'card' ? acts[4].when : undefined;
expect(kind).toEqual({
when: 'sub_eval_case_order_kind_data > 0',
then: 'data',
/* 第二档与数据工单那张卡读的是同一句闸(一处真源) */
else: { when: gate, then: 'data', else: 'accept' },
});
});
/*
* 2026-08-30,Ruling H35:`parts.chip_type`/`parts.type_param` 各自把「选中类型的那一行」
* 原样投一遍(值不变,幂等)——目的不是产出新数据,是让 `mine:` 这两张表不再天然为零行,
@@ -372,11 +406,96 @@ describe('S3 / S4 配方', () => {
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');
it('S3 九笔:圈范围 → 复核重判 → 落明细 → 明细重算 → 关系 → 验证记录 → 两句交代 → 卡', () => {
expect(s3.map((a) => a.kind)).toEqual(['project', 'analyze', 'project', 'project', 'project', 'project', 'say', 'say', 'card']);
for (const at of [0, 2, 3]) expect(s3[at]?.kind === 'project' && s3[at].into).toBe('substitution.difference_item');
expect(s3[4]?.kind === 'project' && s3[4].into).toBe('substitution.relation');
expect(s3[5]?.kind === 'project' && s3[5].into).toBe('validation.record');
});
/*
* **人打回时,意见要交回给 AI 重判**(2026-09-01 用户定:「重做后是否没有将重做原因带回去
* 重新让 ai 重跑这一步?」)—— 在此之前 S3 那一轮只把派生列重算一遍,人写的那句话除了留痕
* 之外**无人读**:AI 判过的四态一个字不会变,除非人自己在件上逐格改。
*
* 只在被打回的那一轮跑:`case_redo_note` 是引擎注给配方的那句话(没打回就是空串)——
* 常路(第一次送审)这两笔跳过,整个活动仍然只调一次模型。
*/
it('复核轮四笔都钉在「人打回了才跑」上 —— 常路一笔都不跑,整单仍只调一次模型', () => {
/* `when` 不是每一种笔都有的键 —— 按「有没有这个键」取,不做属性访问(与本文件既有的 `'prune' in …` 同一写法) */
const whenOf = (at: number): unknown => {
const act = s3[at];
return act !== undefined && 'when' in act ? act.when : undefined;
};
for (const at of [0, 1, 2]) expect(String(whenOf(at) ?? ''), `第 ${at} 笔`).toContain('case_redo_note != ""');
/* 后面那几笔(重算 / 关系 / 验证)照旧每一轮都跑 —— 人就地改了四态也要跟着重算 */
for (const at of [3, 4, 5]) expect(whenOf(at), `第 ${at} 笔`).toBeUndefined();
/*
* **点不到名就一条都不改**(2026-09-01 当天上线当天撤的那一笔:整表重判)——
* 重判与写回都多一个前提「真点到了名」,免得一句与参数无关的话(实测:「收口结论里面的
* 推荐理由改为非常不推荐」)把 5 条参数的结论一起改掉,还白烧一次模型。
*/
for (const at of [1, 2]) {
expect(String(whenOf(at) ?? ''), `第 ${at} 笔`).toContain('substitution_difference_item_redo_scope_named > 0');
}
});
/*
* **谁可以变,由确定的一侧圈**(2026-09-01 用户实测:「我打回让修改工作温度的结论,给我改了
* 静态电流的结论」)—— 第一版把整表交给 AI 重判、整表写回,没有任何东西限制它只动被点名的
* 那一行;它改了没人问过的那条,`evidence_note` 里还写着「维持原判」。
* 现在:①按字面点名圈出 `named`;③入参**只有** `named` 的行;④写回也只写这几行。
*/
it('圈范围:点名的标 named,其余 kept —— 一条都没点到时什么都不改,只把话说清楚', () => {
const mark = s3[0];
const markFields: Readonly<Record<string, unknown>> = mark?.kind === 'project' ? mark.fields : {};
expect(markFields['redo_scope']).toEqual({ when: 'contains(case_redo_note, parameter_name)', then: 'named', else: 'kept' });
/* 没点到名的那一轮:一句交代,告诉他怎么才办得成 —— 而不是猜「他指的是全部」 */
const noHit = s3[7];
expect(noHit?.kind === 'say' && noHit.when).toBe('case_redo_note != "" and substitution_difference_item_redo_scope_named = 0');
const text = noHit?.kind === 'say' ? String(noHit.text) : '';
expect(text).toContain('一条都没改');
expect(text).toContain('参数名');
/* 收口四格不归 AI 判 —— 那几格人在件上直接改(2026-08-31 立的口径) */
expect(text).toContain('件上直接改');
});
it('重判与写回都只认 named 的行 —— 没点名的行 AI 连见都见不到,自然改不着', () => {
const redo = s3[1];
const land = s3[2];
const named = { each: 'mine:substitution.difference_item', where: { redo_scope: 'named' } };
expect(redo?.kind === 'analyze' ? redo.input.from : undefined).toEqual(named);
expect(land?.kind === 'project' ? land.from : undefined).toEqual(named);
/* 判法一处真源:复核指令以第一轮那份规矩打头,后面才接复核纪律 */
const rules = s2[0]?.kind === 'analyze' ? s2[0].instruction : '';
expect(redo?.kind === 'analyze' && redo.instruction.startsWith(rules)).toBe(true);
/*
* **复核轮审核人说了算**(2026-09-01 用户第二次实测:「重做还是没有修改掉内容」)——
* 他写「静态电流结论改成不匹配」,而两颗料的静态电流都是 5mA,AI 搬出第一轮那条
* 「硬约束不因措辞变成 match」把他顶了回去。那条规矩管的是**它自己判**的时候别被措辞带偏;
* 审核人是这一步的责任人(要签他的字,他在件上本来就能亲手改任何一条四态),不归它管。
* 指令里必须**明写这条覆盖**,否则前面那份规矩排在前面、模型照旧照它办。
*/
const tail = redo?.kind === 'analyze' ? redo.instruction.slice(rules.length) : '';
expect(tail).toContain('这一轮不归它管');
expect(tail).toContain('他点名要什么结论,就落什么');
expect(tail).toContain('不要顶回去');
/* 落的同时要把「按谁的意见、数据面是什么」记进依据 —— 下一个人得能分清人定的与数据判的 */
expect(tail).toContain('数据面');
/* 入参:上一轮结论 + 他写的意见 —— 少了任何一样,重判要么无据可依,要么读不到意见 */
const fields: Readonly<Record<string, unknown>> = redo?.kind === 'analyze' ? redo.input.fields : {};
for (const key of ['last_status', 'last_impact', 'last_evidence', 'last_unknown_disposition', 'reviewer_note']) {
expect(Object.keys(fields)).toContain(key);
}
expect(fields['reviewer_note']).toEqual({ in: 'case_redo_note' });
/* 模型漏答某一条:兜回这一行上一轮的值 —— 漏答不该变成「把它清空」 */
const landFields: Readonly<Record<string, unknown>> = land?.kind === 'project' ? land.fields : {};
for (const col of ['difference_status', 'impact_statement', 'evidence_note', 'unknown_disposition']) {
expect(landFields[col]).toMatchObject({ lookup: 'result:items', else: { col } });
}
/* 派生列与两处创建笔共用同一份 when 链 */
const s2Fields: Readonly<Record<string, unknown>> = s2[2]?.kind === 'project' ? s2[2].fields : {};
expect(landFields['judgement_class']).toBe(s2Fields['judgement_class']);
});
/*
@@ -386,7 +505,7 @@ describe('S3 / S4 配方', () => {
* `s3[0]` 必须是**排在关系 / 验证记录投影之前**的差异明细重算笔。
*/
it('差异明细重算笔:只列主键与两个派生列,不碰 difference_status 自己、也不碰人能改的叙事列', () => {
const recompute = s3[0];
const recompute = s3[3];
expect(recompute?.kind === 'project' && recompute.from).toEqual({ each: 'mine:substitution.difference_item' });
/* `recompute` 经 `s3[0]` 的 tuple 定位精确 narrow 到这一笔自己的字面量类型,它压根没有
`prune` 这个键(不是「值为 undefined」,是键不存在)—— `in` 判存在性,不做属性访问 */
@@ -404,7 +523,7 @@ describe('S3 / S4 配方', () => {
* 展开)——各写一份早晚分叉,而分叉的那一刻屏上会同时摆着两个互相矛盾的规则。
*/
it('S3 重算笔与 S2 差异明细创建笔共用同一份 judgement_class / risk_level when 链', () => {
const s3Fields = s3[0]?.kind === 'project' ? s3[0].fields : undefined;
const s3Fields = s3[3]?.kind === 'project' ? s3[3].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']);
@@ -425,7 +544,7 @@ describe('S3 / S4 配方', () => {
* 「人改四态 → 等级跟着变」这条链不能断),且与 S2 那份是**同一个 when 链引用**(不许分叉)。
*/
it('**S3 的关系笔只重算判定** —— 收口四格(理由/摘要/风险/验证要求)一列都不许出现,否则人改完一保存就被冲掉', () => {
const relation = s3[1];
const relation = s3[4];
expect(relation?.kind).toBe('project');
const fieldsOf = (act: (typeof s3)[number] | undefined): Readonly<Record<string, unknown>> =>
act?.kind === 'project' ? act.fields : {};
@@ -440,7 +559,7 @@ describe('S3 / S4 配方', () => {
/* 主键带回:同 PK 覆盖,不是新开一行 */
expect(fields['substitution_relation_id']).toBeDefined();
/* 验证记录那一笔仍与 S2 同一份(它不涉及人可改的格子) */
expect(s3[2]).toBe(s2[4]);
expect(s3[5]).toBe(s2[4]);
});
/*
@@ -455,8 +574,31 @@ describe('S3 / S4 配方', () => {
expect(stepOf('S3_review')?.auto?.let).toBe(stepOf('S2_assess')?.auto?.let);
});
/*
* **交代必须排在卡之前**(2026-09-01 用户:「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,
* 不然都不知道发生了什么」)—— 发了卡这一拍就收了,排在卡后面的 `say` 要等**下一拍**才跑,
* 那时人早看完卡了。实测:原来那句「差异结论已送审,请核对」出现在人答完之后。
* 两句互斥:被打回那一轮说「按你的意见改了哪一条、改成什么」(摆的是件上那一行自己的
* `evidence_note`,不另编一句);第一轮说「结论已备好,请核对」。
*/
it('打回那一轮的交代排在卡之前,摆的是件上那一行的判断依据', () => {
const kinds = s3.map((a) => a.kind);
/* **排在卡之前**:发了卡这一拍就收了,排在后面的 say 要等下一拍 —— 那时人早看完卡了 */
expect(kinds.lastIndexOf('say')).toBeLessThan(kinds.indexOf('card'));
/* 只在被打回那一轮说 —— 第一轮那句与卡自己的 ask 是同一件事,退役了 */
const redoSay = s3[6];
expect(redoSay?.kind === 'say' && redoSay.when).toBe('case_redo_note != "" and substitution_difference_item_redo_scope_named > 0');
const text = redoSay?.kind === 'say' ? redoSay.text : undefined;
const parts = JSON.stringify(text);
expect(parts).toContain('按你的意见复核了');
/* 摆的是件上那一行自己的话与名字 —— 对话区与件上说的是同一件事,就该是同一句话 */
expect(parts).toContain('evidence_note');
expect(parts).toContain('parameter_name');
expect(parts).toContain('substitution_difference_item_redo_scope_named');
});
it('S3 的卡:审核面孔、单一选项、挂本步的件', () => {
const card = s3[3];
const card = s3[8];
expect(card?.kind === 'card' && card.as).toBe('decide');
expect(card?.kind === 'card' && card.variant).toBe('review');
expect(card?.kind === 'card' ? card.options ?? [] : []).toHaveLength(1);
@@ -57,8 +57,18 @@ export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<ifPart id="if_r"><condition>substitution_relation_rows = 1 and substitution_relation_missing = 0 and validation_record_missing = 0</condition></ifPart>
</sentry>
<!-- 1 校验入参与补数据:核对两颗料在不在库、归没归同一类型、参数齐不齐;缺什么派数据工单让人补(全 auto,doneWhen 收步) -->
<humanTask id="S1_inputs" name="校验入参与补数据"/>
<!--
1 校验入参与补数据:核对两颗料在不在库、归没归同一类型、参数齐不齐。
缺什么派**数据工单**让人补;都齐了派一张只读的**验收工单**交人核对(2026-08-31 用户定)。
**manualActivation 是这条「都齐了也叫人」的命门**:引擎在拍首先扫一遍 doneWhen,
为真就自己收步(turn-runner 的 sweepRipe)—— 数据本来就齐的单,S1 的 doneWhen
第一拍就为真,配方一笔都还没跑,这一步当场被收掉,卡永远发不出去。
标了人工激活,那道扫描就多一个门槛:worldTouched(卡真发过)—— 于是次序变成
「配方发卡 → 人答 → 下一拍 doneWhen 为真才收」。doneWhen 照旧保留:它仍是这一步的真门,
只是不再抢在人前面(2026-08-27 立的口径:人工激活项在 worldTouched 之后照常求 doneWhen)。
-->
<humanTask id="S1_inputs" name="校验入参与补数据" manualActivation="true"/>
<!-- 2 逐项差异分析:核心参数逐条比对,四态、推荐等级、风险由系统按规则算(全 auto,doneWhen 收步) -->
<humanTask id="S2_assess" name="逐项差异分析">
@@ -95,7 +105,8 @@ export const NARRATIVE = `§0 本活动只回答一件事:**候选内部料号
**四步各自的产出**:
① 校验入参与补数据(S1_inputs)—— 核对两颗料在不在库、是否归了同一个芯片类型、核心参数
(如 <参数名>)是否齐备;缺什么就派数据工单让人补。产出:两颗料的存在性与类型核对、
(如 <参数名>)是否齐备;缺什么就派**数据工单**让人补,都齐了就派一张只读的**验收工单**
交人核对 —— 两颗料的资料总要经人过目一次,再交给系统逐项比。产出:两颗料的存在性与类型核对、
补齐的芯片主数据与标准化参数。
② 逐项差异分析(S2_assess)—— 把两颗料的核心参数逐条比对,判定 MATCH / MISMATCH / UNKNOWN /
CONDITIONAL 四态,依规则算出推荐等级与风险。产出:替代关系一行 + 差异明细逐参数一行 +
@@ -338,10 +349,205 @@ const GATE_OPEN_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';
/**
* **逐参数差异判定的规矩** —— S2 第一轮判、S3 被人打回后复核,**两处共用同一份**:
* 各写一份早晚分叉,而分叉的那天,屏上会同时摆着两套判法判出来的结论。
*/
const ASSESS_RULES = [
'你在做芯片替代的**逐参数差异判定**。输入是一组核心参数,每一条给了:参数名、这个参数为什么要紧(说明)、',
'硬约束还是软约束、单位,以及原料与候选料各自的值和值的备注。',
'',
'对**每一条**给出四态之一:',
'· match —— 候选满足原料在这个参数上的要求(相等,或候选的范围盖住原料的范围);',
'· mismatch —— 候选达不到(范围更窄、方向相反、量级不足);',
'· unknown —— 任一侧的值缺失或语义不明,判不了;',
'· conditional —— 改外围、限定工况、或加一道验证之后可以成立。',
'',
'三条硬规矩:',
'1. **值是多少不要复述** —— 系统手里已经有值了。影响说明要答的是「换上去会不会比现在差、差在哪儿」。',
'2. **判不了就判 unknown,不要猜**;判了 unknown 就必须在 unknown_disposition 里写清楚:实测哪一项、或去哪儿补这个值。',
'3. **硬约束不因为措辞变成 match** —— 硬约束不满足就是 mismatch,证据不足就是 unknown。',
'',
'范围值的读法:「下限~上限」是一个区间(分隔符可能是 ~ 或 - 或 至);只给一边的是半开区间;',
'单位可能写在值里。判「盖不盖得住」按区间比,不要拿端点当定值。',
'',
'只输出 JSON,不要解释、不要 markdown 代码围栏。',
].join('\n');
/** 判定出参的形状 —— 同上,两处共用一份 */
const ASSESS_OUTPUT = {
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: {
type: 'object',
required: ['param_name', 'difference_status', 'impact_statement', 'evidence_note'],
properties: {
param_name: { type: 'string' },
difference_status: { type: 'string', enum: ['match', 'mismatch', 'unknown', 'conditional'] },
impact_statement: { type: 'string' },
evidence_note: { type: 'string' },
unknown_disposition: { type: 'string' },
},
},
},
},
} as const;
/**
* **AI 判完之后落明细的那一笔** —— S2 第一轮、S3 被人打回后的复核轮,**共用同一份**。
* 行由字典 `each` 出来,AI 的出参只是拿 `param_name` 去查的一张表:它漏一条,那一行的四态
* 就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住(从前 `from each(AI 出参)`,
* 它少给两条就少两行,**而每一行看着都合规**)。
*/
const DIFFERENCE_ITEM_FROM_RESULT = {
kind: 'project' as const,
into: 'substitution.difference_item',
prune: true,
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: {
substitution_relation_id: { in: 'relationId' },
difference_item_id: { cat: [{ in: 'relationId' }, '-', { col: 'param_name' }] },
parameter_name: { col: 'param_name' },
parameter_desc: { col: 'param_desc' },
parameter_unit: { col: 'param_unit' },
/* 约束来自字典这一行本身;值一律 lookup 台账 —— 基座读得到的事实不让模型转述 */
constraint_type: { col: 'constraint_type' },
original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
impact_statement: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'impact_statement', else: '' },
evidence_note: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'evidence_note', else: '' },
unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' },
/* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 —— 与 S3 重算那一笔共用同一份 when 链,不许各写一份 */
...JUDGEMENT_DERIVED_FIELDS,
rule_version: RULE_VERSION,
},
} as const;
/**
* ── 复核轮(人把结论打回那一轮)的四笔 ──────────────────────────────────
*
* **背景**:2026-09-01 第一版做成了「把意见连同整表交回给 AI 重判」,当天就被用户实测打脸:
* 「我打回让修改**工作温度**的结论,给我改了**静态电流**的结论」。库里的证据一字不差 ——
* 工作温度它没改(还给了理由:候选值 1℃ 远窄于 -40~+85℃,硬约束不满足),
* 而没人问过的静态电流被它从 soft_mismatch 翻成了 match,`evidence_note` 里却写着「**维持原判**」。
*
* **根因不是提示词不够严,是行由 AI 决定**:整表交出去、整表写回来,没有任何一样东西限制它
* 只动被点名的那一行;「我只改了那一条」这句话本身也出自它嘴里,而它说的不作数。
* 这正是规范 §4.6 纪律一 —— **行由确定的那一侧决定,不由 AI 决定**。
*
* **治法**:先由确定的一侧圈出「这一轮谁可以变」(①②),再把**只有这几行**交给 AI(③),
* 写回也只写这几行(④)。没被点名的行 AI 连见都见不到,自然也就改不着。
*/
/** ① 圈范围:人打回的那句话里点了名的行标 `named`,其余 `kept` —— 与基座给模型摆的「意见点名对上了哪几行」同一种认法(字面包含) */
const REDO_SCOPE_MARK = {
kind: 'project' as const,
when: 'case_redo_note != ""',
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' },
redo_scope: { when: 'contains(case_redo_note, parameter_name)', then: 'named', else: 'kept' },
},
} as const;
/*
* ~~② 一条都没点到 → 整表都算被点名~~(2026-09-01 当天上线、当天撤掉)。
*
* 当时想的是「『结论整体判得太松』这类意见说的是整批」。实测打脸(用户那一单):他写的是
* **「收口结论里面的推荐理由改为非常不推荐」** —— 说的根本不是某一条参数,而是收口那四格
* (推荐理由 / 差异摘要 / 风险 / 验证要求,那几格本来就能在件上直接改)。整表重判的下场:
* AI 拿着这句话把 **5 条参数**的结论改成了「不匹配」,每一条的依据都写着
* 「按审核人意见调整为不匹配:推荐理由改为非常不推荐。数据面:原料 4.5~40V…」——
* 一句与它们无关的话,改了五行。
*
* **点不到名的意见,宁可什么都不改**:这一轮不重判,把话说清楚(见下面那句 `say`)——
* 要重判某一条就点它的名,收口四格在件上直接改。**「猜他指的是全部」比「承认没对上」贵得多。**
*/
/**
* ③ **带着意见重判 —— 只判被点名的那几条**(整个活动第二次、也是最后一次模型出场)。
*
* 入参直接来自明细表自己的行(参数名/说明/约束/单位/两侧的值/上一轮结论都在上面),
* 外加人写的那句话;**没被点名的行压根不在这一批里**,所以「别去动它」不需要靠提示词求它。
* 判法与第一轮**同一份** `ASSESS_RULES`,后面只接一段复核纪律。
*/
const REVIEW_REDO_ANALYZE = {
kind: 'analyze' as const,
when: 'case_redo_note != "" and substitution_difference_item_redo_scope_named > 0',
model: 'deepseek-v4-flash',
into: 'items',
instruction: [
ASSESS_RULES,
'',
'【这一轮是复核,审核人说了算】下面**只列了他这次点到的那几条**(他写的话在 reviewer_note,每条都带着上一轮的结论 last_*)。',
'',
'**上面第 3 条(硬约束不因措辞变成 match)管的是你自己判的时候别被措辞带偏;这一轮不归它管** ——',
'审核人是这一步的责任人,这份结论要签他的字,而且他在界面上本来就能亲手改任何一条的四态。',
'',
'· **他点名要什么结论,就落什么**:「改成不匹配 / 不符」= mismatch,「一致 / 匹配」= match,',
' 「判不了 / 待确认」= unknown,「有条件 / 加验证后可用」= conditional。不要跟他讲道理、不要顶回去。',
'· 落的同时把实情记在 evidence_note 里,**一句按谁的意见、一句数据面是什么**,例如:',
' 「按审核人意见调整为 X:<他的原话>。数据面:原料 5mA / 候选 5mA,两侧一致。」',
' —— 下一个人回看时要能一眼分清「这是数据判出来的」还是「这是人定的」。',
'· 他只是提疑问、要补依据、要改写措辞(没点名要哪个结论):你复核后自己判,evidence_note 写清这一轮改了什么。',
'· **列出来的每一条都要回**,一条不多、一条不少。',
].join('\n'),
input: {
from: { each: 'mine:substitution.difference_item', where: { redo_scope: 'named' } },
fields: {
param_name: { col: 'parameter_name' },
param_desc: { col: 'parameter_desc' },
constraint_type: { col: 'constraint_type' },
unit: { col: 'parameter_unit' },
original_value: { col: 'original_value' },
candidate_value: { col: 'candidate_value' },
/* 值的备注不在明细表上,回台账现取 —— 与第一轮同一份事实来源 */
original_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'parameter_name' } }, take: 'param_note', else: '' },
candidate_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'parameter_name' } }, take: 'param_note', else: '' },
/* 上一轮的结论 —— 含人就地改过的那几行(读的是本单草稿的当下值) */
last_status: { col: 'difference_status' },
last_impact: { col: 'impact_statement' },
last_evidence: { col: 'evidence_note' },
last_unknown_disposition: { col: 'unknown_disposition' },
/* 人这次打回写的那句话 —— 每一行都带一份(入参只有行这一种形状) */
reviewer_note: { in: 'case_redo_note' },
},
},
output: ASSESS_OUTPUT,
} as const;
/**
* ④ 判完写回 —— **同样只写被点名的那几行**(`from` 里压根没有别的行)。
* 模型漏答某一条时 `else` 兜回这一行上一轮的值:漏答不该变成「把它清空」。
*/
const DIFFERENCE_ITEM_FROM_REDO = {
kind: 'project' as const,
when: 'case_redo_note != "" and substitution_difference_item_redo_scope_named > 0',
into: 'substitution.difference_item',
from: { each: 'mine:substitution.difference_item', where: { redo_scope: 'named' } },
fields: {
substitution_relation_id: { col: 'substitution_relation_id' },
difference_item_id: { col: 'difference_item_id' },
constraint_type: { col: 'constraint_type' },
difference_status: { lookup: 'result:items', by: { param_name: { col: 'parameter_name' } }, take: 'difference_status', else: { col: 'difference_status' } },
impact_statement: { lookup: 'result:items', by: { param_name: { col: 'parameter_name' } }, take: 'impact_statement', else: { col: 'impact_statement' } },
evidence_note: { lookup: 'result:items', by: { param_name: { col: 'parameter_name' } }, take: 'evidence_note', else: { col: 'evidence_note' } },
unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'parameter_name' } }, take: 'unknown_disposition', else: { col: 'unknown_disposition' } },
/* 派生列与两处创建笔共用同一份 when 链 —— 四态一变,判定分类与风险跟着变 */
...JUDGEMENT_DERIVED_FIELDS,
},
} as const;
export const ASSESSMENT_STEPS = [
{
item: 'S1_inputs' as const,
intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么就派一张数据工单让人补',
intent: '核对两颗料在不在库、归没归同一个类型、参数值齐不齐;缺什么派数据工单让人补,都齐了派只读的验收工单交人核对',
guide: '',
/* Task 7:发给人的数据工单摆的就是这一张 —— 三块竖排(存在性与主数据 / 芯片类型字段 / 参数值),
与 auto.do 第 ⑥ 笔 `card` 的 `gateLedgers` 同源(parts.chip_type / parts.type_param /
@@ -501,6 +707,22 @@ export const ASSESSMENT_STEPS = [
* 这面开关两个值都非空、两个方向都真写 —— 件上按它收红条,与发卡条件同一句判定。
*/
gate_state: { when: GATE_OPEN_WHEN, then: 'open', else: 'clear' },
/*
* **这一步派的是哪种工单**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,
* 就派发一个验收工单…把芯片卡片上的编辑按钮去掉,只展示不能编辑」)。
* 件按它换脸:`data` → 题头「数据工单」+ 卡角笔/软盘;`accept` → 题头「验收工单」、
* 编辑钮整组收起(只读)。
*
* **必须粘着**(第一档读的是上一拍写下的自己):一步只发一张卡(引擎 `carded` 幂等),
* 派出去是哪种,这一步到底就是哪种。不粘着的后果实打实:人在数据工单上把最后一格
* 填上、还没点确认,闸当场变 clear —— 旗跟着翻,他手里那张卡还开着,而屏上的件
* 已经改名叫「验收工单」、笔也没了,想改回一个填错的值都没有入口。
*/
order_kind: {
when: 'sub_eval_case_order_kind_data > 0',
then: 'data',
else: { when: GATE_OPEN_WHEN, then: 'data', else: 'accept' },
},
gate_reason: {
when: 'sub_eval_part_rows < 2',
then: '原料号与候选料号填成了同一个 —— 没法比出差异,请终止本单,换两个不同的料号重开。',
@@ -564,6 +786,37 @@ export const ASSESSMENT_STEPS = [
gateWhen: 'parts_profile_distinct_chip_type_id <= 1',
gateWhenSay: '两颗料不是同一个芯片类型 —— 逐项比对无从谈起:换一颗料,或把归错的那颗类型改对,再来确认。',
},
/*
* ⑧ **数据本来就齐的单,也要经人过目一次**(2026-08-31 用户定)——
* 派一张**验收工单**:同一份件、同一批数据,只是卡角的编辑钮整组收起,题头改叫「验收工单」。
*
* 与 ⑦ 是互斥的两条路,而不是先后:引擎 `carded` 幂等保证**一步只发一张卡** ——
* ⑦ 发过数据工单之后这一笔永远轮不到(人补完数据不会再被叫第二次)。
* 条件读上一笔刚写下的旗(每一笔之前刷读数),不重新算一遍闸:同一件事只许有一处判定。
*
* `gateLedgers` 与 ⑦ 同一份:件是只读的,这道闸在确认那一刻再数一遍
* ——「我核过了,数据是齐的」这句话得有人替它数,不能只凭人点了一下。
*/
{
kind: 'card' as const,
when: 'sub_eval_case_order_kind_accept > 0',
as: 'execution' as const,
variant: 'accept' as const,
title: '验收工单 · 差异评估的输入核对',
basis: [
{ k: '原料号', v: { in: 'originalInternalPn' } },
{ k: '候选料号', v: { in: 'candidateInternalPn' } },
{ k: '本单结论', v: '两颗料都在库、归了同一个芯片类型、核心参数值齐备 —— 没有要补的格子' },
],
guide: [
'两张芯片档案卡上的**主数据**核一遍:品牌 / 生命周期 / 制造商 / 封装 —— 库里的现值就是马上要拿去比对的值。',
'点卡翻到背面,把**核心参数值**核一遍:值取自原厂规格书;有存疑的先别确认,去主数据里改对再回来。',
'两颗料必须归在**同一个芯片类型**下 —— 顶部「比对基准」摆的就是本轮要逐项比的字段清单。',
'这一屏只读:确认无误后回对话区按「确认无误」,系统随即开始逐项差异分析。',
],
standard: '两张卡的主数据与核心参数值都核过、类型一致 —— 没有异议就确认。',
gateLedgers: ['parts.profile', 'parts.parameter'],
},
{ kind: 'say' as const, text: '已核对两颗料的在库状态与参数齐备情况。' },
],
},
@@ -590,26 +843,7 @@ export const ASSESSMENT_STEPS = [
kind: 'analyze' as const,
model: 'deepseek-v4-flash',
into: 'items',
instruction: [
'你在做芯片替代的**逐参数差异判定**。输入是一组核心参数,每一条给了:参数名、这个参数为什么要紧(说明)、',
'硬约束还是软约束、单位,以及原料与候选料各自的值和值的备注。',
'',
'对**每一条**给出四态之一:',
'· match —— 候选满足原料在这个参数上的要求(相等,或候选的范围盖住原料的范围);',
'· mismatch —— 候选达不到(范围更窄、方向相反、量级不足);',
'· unknown —— 任一侧的值缺失或语义不明,判不了;',
'· conditional —— 改外围、限定工况、或加一道验证之后可以成立。',
'',
'三条硬规矩:',
'1. **值是多少不要复述** —— 系统手里已经有值了。影响说明要答的是「换上去会不会比现在差、差在哪儿」。',
'2. **判不了就判 unknown,不要猜**;判了 unknown 就必须在 unknown_disposition 里写清楚:实测哪一项、或去哪儿补这个值。',
'3. **硬约束不因为措辞变成 match** —— 硬约束不满足就是 mismatch,证据不足就是 unknown。',
'',
'范围值的读法:「下限~上限」是一个区间(分隔符可能是 ~ 或 - 或 至);只给一边的是半开区间;',
'单位可能写在值里。判「盖不盖得住」按区间比,不要拿端点当定值。',
'',
'只输出 JSON,不要解释、不要 markdown 代码围栏。',
].join('\n'),
instruction: ASSESS_RULES,
input: {
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: {
@@ -624,26 +858,7 @@ export const ASSESSMENT_STEPS = [
candidate_note: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_note', else: '' },
},
},
output: {
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: {
type: 'object',
required: ['param_name', 'difference_status', 'impact_statement', 'evidence_note'],
properties: {
param_name: { type: 'string' },
difference_status: { type: 'string', enum: ['match', 'mismatch', 'unknown', 'conditional'] },
impact_statement: { type: 'string' },
evidence_note: { type: 'string' },
unknown_disposition: { type: 'string' },
},
},
},
},
},
output: ASSESS_OUTPUT,
},
/*
* ②→④ **`RELATION_PROJECTION` 写两遍,中间夹着差异明细** —— 不是笔误
@@ -672,30 +887,7 @@ export const ASSESSMENT_STEPS = [
* 它漏一条,那一行的四态就是空 → `required` 立刻变成 `_missing > 0`,提交闸与放行门当场拦住。
* 从前是 `from each(AI 出参)`:它少给两条就少两行,**而每一行看着都合规**。
*/
{
kind: 'project' as const,
into: 'substitution.difference_item',
prune: true,
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: {
substitution_relation_id: { in: 'relationId' },
difference_item_id: { cat: [{ in: 'relationId' }, '-', { col: 'param_name' }] },
parameter_name: { col: 'param_name' },
parameter_desc: { col: 'param_desc' },
parameter_unit: { col: 'param_unit' },
/* 约束来自字典这一行本身;值一律 lookup 台账 —— 基座读得到的事实不让模型转述 */
constraint_type: { col: 'constraint_type' },
original_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'originalPn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
candidate_value: { lookup: 'parts.parameter', by: { internal_pn: { in: 'candidatePn' }, param_name: { col: 'param_name' } }, take: 'param_value', else: '' },
difference_status: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status', else: '' },
impact_statement: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'impact_statement', else: '' },
evidence_note: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'evidence_note', else: '' },
unknown_disposition: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'unknown_disposition', else: '' },
/* 合成列:先认四态,最后才按约束类型把 mismatch 分成硬 / 软 —— 与 S3 重算那一笔共用同一份 when 链,不许各写一份 */
...JUDGEMENT_DERIVED_FIELDS,
rule_version: RULE_VERSION,
},
},
DIFFERENCE_ITEM_FROM_RESULT,
RELATION_PROJECTION,
VALIDATION_PROJECTION,
{ kind: 'say' as const, text: '逐条差异结论已备好,推荐等级由规则按四态算出。' },
@@ -740,6 +932,15 @@ export const ASSESSMENT_STEPS = [
*/
let: SHARED_LET,
do: [
/*
* ①-④ **人打回那一轮:先圈出他点名的是哪几条,只把这几条交回给 AI 重判**
* (2026-09-01;第一版整表重判,当天被实测打脸 —— 见 `REDO_SCOPE_MARK` 上面那段头注)。
* 常路(第一次送审)这四笔全跳过(`case_redo_note` 是空串),整单仍然只调一次模型。
* 判完落回明细,后面三笔重算照旧走:派生列跟着新四态变,等级、风险、验证记录跟着变。
*/
REDO_SCOPE_MARK,
REVIEW_REDO_ANALYZE,
DIFFERENCE_ITEM_FROM_REDO,
/*
* **人改了四态,先把这一行自己的判定分类 / 风险等级重算,关系与验证记录的投影才有
* 真数据可读**(2026-08-30 修「结论纹丝不动」的要害缺陷,详见 `DIFFERENCE_ITEM_RECOMPUTE`
@@ -751,6 +952,51 @@ export const ASSESSMENT_STEPS = [
这一步只重算判定(推荐等级跟着四态走),四格交给人,见 `RELATION_RECOMPUTE` 的头注。 */
RELATION_RECOMPUTE,
VALIDATION_PROJECTION,
/*
* **重判完要在对话区说清「按你的意见改了哪一条、改成什么」**(2026-09-01 用户:
* 「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,不然都不知道发生了什么」)。
*
* **必须排在卡之前** —— 发了卡这一拍就收了,排在卡后面的笔要等下一拍才跑
* (那时人早就看完卡了,这句话就成了马后炮:原来那句「差异结论已送审,请核对」
* 实测正是在**人答完之后**才出现的)。
*
* 摆的是件上那一行自己的话(`evidence_note` —— 复核指令要求它写成
* 「按审核人意见调整为 X:<原话>。数据面:…」),不是另编一句:
* 对话区与件上说的是同一件事,就该是同一句话。
*/
{
kind: 'say' as const,
when: 'case_redo_note != "" and substitution_difference_item_redo_scope_named > 0',
text: {
cat: [
'按你的意见复核了 ',
{ in: 'substitution_difference_item_redo_scope_named' },
' 条 —— ',
{ lookup: 'mine:substitution.difference_item', by: { redo_scope: 'named' }, take: 'parameter_name', else: '' },
':',
{ lookup: 'mine:substitution.difference_item', by: { redo_scope: 'named' }, take: 'evidence_note', else: '' },
' 件上那几行已经改好,再核一遍,没问题就点「确认通过」。',
],
},
},
/*
* **点不到名就照实说** —— 这一轮一个字都没改,得让他知道为什么,以及怎么才办得成
* (2026-09-01 实测:他写「收口结论里面的推荐理由改为非常不推荐」,而那不是任何一条参数)。
*/
{
kind: 'say' as const,
when: 'case_redo_note != "" and substitution_difference_item_redo_scope_named = 0',
text:
'你这句意见我没能对上具体哪一条参数,所以这一轮**一条都没改**。' +
'要我重判某一条,请在意见里写上它的参数名(如「工作温度」);' +
'收口结论那四格(推荐理由 / 差异摘要 / 风险等级 / 验证要求)不归 AI 判 —— 在工作区的件上直接改就行,改完点「确认通过」。',
},
/*
* ~~第一轮那句「差异结论已送审,请在件上逐条核对」~~(2026-09-01 退役):
* 它与卡自己的 `ask` 说的是同一件事(「逐条差异结论已备好。请在工作区的件上逐条核对…」)——
* 摆两遍是复读。而且它排在卡后面,要等下一拍才跑:实测那句话出现在**人答完之后**。
* 打回那一轮不同 —— 那时有新东西要交代(改了哪一条、改成什么),所以只留上面那一句。
*/
{
kind: 'card' as const,
as: 'decide' as const,
@@ -764,7 +1010,8 @@ export const ASSESSMENT_STEPS = [
{ k: '本轮结论', v: { lookup: 'mine:substitution.relation', by: {}, take: 'difference_summary', else: '' } },
],
},
{ kind: 'say' as const, text: '差异结论已送审,请在件上逐条核对。' },
/* ~~卡后面那一句 `say`~~(2026-09-01 退役):发了卡这一拍就收了,它要等下一拍才跑 ——
实测「差异结论已送审,请在件上逐条核对」出现在**人答完之后**。两句交代都提到卡之前去了。 */
/* `] as const`:这个数组也引用了 RELATION_PROJECTION/VALIDATION_PROJECTION —— 理由同 S2 的 do 数组头上那条注释 */
] as const,
},
@@ -54,7 +54,7 @@ export const manifest: ActivityManifest = {
activityId: ACTIVITY_ID,
name: 'ACT-WORK-05 · 差异评估',
/* 重写不是改 —— 同版重装是 no-op(静默丢失,踩过) */
packageVersion: '2.13.0',
packageVersion: '2.24.0',
category: '需求分析与方案匹配',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-part-substitution-assessment',
@@ -96,7 +96,17 @@ export const manifest: ActivityManifest = {
* 组内**先填的那格是锚**(摆全量),另一格只列同类型的料 + 库里未标类型的(殿后一组)+「新增」。
* 锚没有类型、或锚填的是库里没有的新料号 → 不收窄(两颗都还没建档时不该拦着人)。
* **这只是表单的引导,不是墙**:服务端照收两格不同类型的组合(`launch-params.ts` 头注),
* 类型一致由 S1 的收步判据 `sub_eval_part_distinct_chip_type_id = 1` 管 —— 那才是数得出来的门。requestDescription / candidateInternalPns(数组)/
* 类型一致由 S1 的收步判据 `sub_eval_part_distinct_chip_type_id = 1` 管 —— 那才是数得出来的门。
*
* **两格不能是同一颗料**(`x-distinct: 'part'`,2026-08-31 用户定:「不应该能选到同样的两个芯片」)——
* 这一条**是墙**,不是引导,理由与上面「留空」那段是同一条链:两格同一个料号 → 上面那笔 `project`
* 铸的两行撞上同一个主键(`eval_ref + internal_pn`,`role` 不在主键里)→ 后一笔覆盖前一笔 →
* `sub_eval.part` 收成一行 → `sub_eval_part_exists_status_found = 2` 永不成立,S1 收不了步;
* 而件上那两行人加不出来(块① 行源 `mine:parts.profile`,没有「+加一行」),**唯一出路是终止重开**。
* 墙立在起单这一刻,是因为**路只在这里** —— 那时换一格就好了。运营端表单据此不把另一格已经选走的
* 料号列出来;自己写一个同样的、或脚本 / API 直接起单,由服务端 `validateLaunchParams` 拒。
*
* requestDescription / candidateInternalPns(数组)/
* datasheets 三键已随重写撤下 —— 这一版没有「需求主记录」「N 候选并行」「预填
* datasheet」这几件事。
*/
@@ -113,6 +123,7 @@ export const manifest: ActivityManifest = {
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
'x-order': 1,
'x-link': 'chip-type',
'x-distinct': 'part',
},
candidateInternalPn: {
type: 'string',
@@ -123,6 +134,7 @@ export const manifest: ActivityManifest = {
'x-options': { ledger: 'parts.profile', value: 'internal_pn', label: 'internal_pn', group: { column: 'chip_type_id', ledger: 'parts.chip_type', value: 'type_id', label: 'type_name' } },
'x-order': 2,
'x-link': 'chip-type',
'x-distinct': 'part',
},
},
},
@@ -257,15 +269,25 @@ export const manifest: ActivityManifest = {
authorization: {
discretion: [],
preauthorizedActions: [],
/* 调人三次(补数据 / 审核 / 放行);零工具、AI 只出场一次 —— 预算随之从 16/90 步收紧到 8/40 步 */
humanCallBudget: 8,
/* 与下面 `resources.budget.humanCalls` 同一个数 —— 常路调人三次(补数据 / 审核 / 放行),
余量给审核打回:每打回一次就多叫一次人(2026-09-01 用户定,见那一行的注释) */
humanCallBudget: 24,
},
resources: {
tools: [],
skills: [],
models: ['deepseek-v4-flash'],
roles: ['FAE/RI'],
budget: { steps: 40, tokens: 300_000, activeMinutes: 60, humanCalls: 8 },
/*
* **调人次数给足**(2026-09-01 用户定「那你就把预算提高」)—— 8 次在真人手里三四轮就见底:
* 常路已经要 3 次(数据/验收工单 1 + 结论审核 1 + 落账放行 1),而**审核每打回一次就多叫一次**,
* 实测他连打回四次,单子落 `exhausted` —— 屏上看着像「最后落账那一步直接没了」。
* 24 次 = 常路 3 + 二十来轮打回,人自己点得累了也到不了顶。
*
* **这是配置,不是机制**:机制上的正解是「人自己打回引出的那张卡不算打扰」(他不是被打扰,
* 是他叫的)—— 那要在发卡那条路上分「主动叫人」与「应他要求再问一遍」,记在 debt。
*/
budget: { steps: 40, tokens: 300_000, activeMinutes: 60, humanCalls: 24 },
},
},
@@ -447,6 +469,13 @@ export const manifest: ActivityManifest = {
* 两个方向都写得动;件上 data-class-of 投影 v-clear 收红条,判断留在样式表。
*/
col({ name: 'gate_state', type: 'text', label: '入参闸门状态', tally: ['open', 'clear'] }),
/*
* **这一步派的是哪种工单**(2026-08-31 用户定「数据齐了也叫人一次」):
* `data` = 数据工单(有格子要填)· `accept` = 验收工单(只读,只核对)。
* 件按它换脸(题头四字 + 卡角编辑钮显隐),配方按它决定发哪张卡 —— 见 case-model
* 那一笔 `order_kind` 的注释(**粘着**:派出去是哪种,这一步到底就是哪种)。
*/
col({ name: 'order_kind', type: 'text', label: '这一步派的工单种类', tally: ['data', 'accept'] }),
col({ name: 'summary', type: 'text', label: '结论摘要' }),
],
},
@@ -510,6 +539,14 @@ export const manifest: ActivityManifest = {
col({ name: 'unknown_disposition', type: 'text', label: 'UNKNOWN 去向(验证任务号 / 补来源动作)', requiredWhen: { column: 'difference_status', value: 'unknown' } }),
col({ name: 'risk_level', type: 'text', label: '风险等级', required: true, tally: ['low', 'medium', 'high'] }),
col({ name: 'rule_version', type: 'text', label: '差异规则版本', required: true }),
/*
* **这一轮复核点到了哪几条**(2026-09-01 用户实测:「我打回让修改工作温度的结论,
* 给我改了静态电流的结论」)—— 人打回时写的那句话里点了名的行标 `named`,其余 `kept`。
* 谁可以变由这一列说了算(**行由确定的那一侧决定,不由 AI 决定** —— 规范 §4.6 纪律一):
* 只有 `named` 的行才进重判的入参、也只有它们会被写回;`kept` 的行 AI 连见都见不到。
* 意见一条都没点到时另有一笔把整表标 `named`(那种意见说的是整批,不是某一条)。
*/
col({ name: 'redo_scope', type: 'text', label: '本轮复核范围', tally: ['named', 'kept'] }),
],
},
/* P-06 验证记录:一单一行,关联的是本单案号(P-01 不在本活动里,顶掉 request_id) */
@@ -23,7 +23,7 @@
* 印章 hero + 落账去向 + 四态计数与硬约束点名 + 待验项。
*/
import { vtok, badgeCss } from './badge.ts';
import { SHELL_CSS, ACCENT_DATA, ACCENT_LEDGER } from './style.ts';
import { SHELL_CSS, ACCENT_DATA, ACCENT_LEDGER, ACCENT_ACCEPT } from './style.ts';
import { D } from '../constants.ts';
/* ==================== 徽标配色(值与文案成对交给 badgeCss,选择器从值算,不手写) ==================== */
@@ -109,9 +109,26 @@ const gateBar = (): string => `<div class="jc-gate-bar" data-class-of="gate_stat
* 收件人 / 关联任务两格由引擎注入;工单编号即本次评估案号 `eval_ref`(页脚)。
* `stage` 是每张件自己的 hero 场景(SVG 舞台),`accent` 决定这张件的强调色。
*/
const doc = (input: { accent: string; alias: string; tag: string; kicker: string; title: string; step: string; stage: string; body: string; footTag: string; headTone?: 'light' }): string =>
const doc = (input: {
accent: string;
alias: string;
tag: string;
kicker: string;
title: string;
step: string;
stage: string;
body: string;
footTag: string;
headTone?: 'light';
/**
* **这份件按哪一列换脸**(2026-08-31):列名给进来,渲染时投成 `v-<值>` 挂在最外层 `.card` 上,
* 件里凡是要随之变的(题头四字 / 标题 / 卡角编辑钮)都由样式表按这个类挑 —— 判断仍留在样式表,
* 模版不算不判。S1 那张件用它区分「数据工单(有格子要填)」与「验收工单(只读)」。
*/
kindOf?: string;
}): string =>
`<style>${SHELL_CSS}${input.accent}${input.alias}</style>
<div class="page"><div class="card${input.headTone === 'light' ? ' hd-light' : ''}">
<div class="page"><div class="card${input.headTone === 'light' ? ' hd-light' : ''}"${input.kindOf === undefined ? '' : ` data-class-of="${input.kindOf}"`}>
<header class="card-head">
<div class="hero-top">
<span class="hero-sys">${input.kicker}</span>
@@ -175,6 +192,20 @@ ${badgeCss('.rl', [
['original', '原内部料号'],
['candidate', '候选内部料号'],
])}
/*
* **一张件两副面孔**(2026-08-31 用户定)—— 最外层 .card 上由 data-class-of=order_kind
* 投出 v-data / v-accept,这里只管挑:
* · 题头 / 标题 / 页脚三处的四字换名(.k-data ↔ .k-acc);
* · **验收工单把卡角那组编辑钮整组收起** —— 藏的是容器 .fabs,不是逐枚钮:
* 那几枚各自的 display 由 html.editable 开头那一串更特异的规则给着,逐枚去盖是打不完的;
* 父容器一藏,里面的按钮连渲染都不渲染,件就此只读(人点不开编辑,孪生件也就不现身)。
* 旗还没写过(老单 / 第一帧)时两个类都不在 → 落回默认的「数据工单」那一副,与从前一模一样。
*/
.k-acc { display: none; }
.v-accept .k-data { display: none; }
.v-accept .k-acc { display: inline; }
.v-accept .fabs { display: none; }
${ACCENT_ACCEPT}
`;
@@ -293,28 +324,35 @@ const BASIS_CARD = `<article class="basis" data-rows="mine:${D}.case"><section c
export const INPUT_SUPPLEMENT = doc({
accent: ACCENT_DATA,
alias: ALIAS_INPUT,
tag: '数据工单',
/*
* **一张件两副面孔**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个验收工单,
* 界面和数据要和现在的第一个数据工单一致,只不过把芯片卡片上的编辑按钮去掉…标题旁边的数据工单
* 改成验收工单」)—— 数据与画面一个字不改,只有这三处名字与卡角那组编辑钮随 `order_kind` 换。
* 谁是哪一副由配方在派卡那一刻定死(粘着的旗),件只负责摆出来。
*/
kindOf: 'order_kind',
tag: '<span class="k-data">数据工单</span><span class="k-acc">验收工单</span>',
kicker: 'SS02 · ACT-WORK-05 差异评估',
title: '差异评估的输入还差几格',
title: '<span class="k-data">差异评估的输入还差几格</span><span class="k-acc">差异评估的输入核对</span>',
step: '步骤 1/4 · 校验入参与补数据',
stage: '',
headTone: 'light',
footTag: '工单类型:数据',
footTag: '工单类型:<span class="k-data">数据</span><span class="k-acc">验收</span>',
body:
gateBar() +
`<details class="lab lab-brief"><summary class="lab-sm"><b>【做什么】</b>核对两颗料在不在库、归没归同一个芯片类型、参数值齐不齐 —— 展开看说明与纪律<span class="lab-caret"></span></summary>` +
note([
'<span class="need">模板定位</span>:本单是「数据工单」——目标台账 = 芯片主数据(P-08 主记录 + 标准化参数子表),核心参数字段清单挂在芯片类型字典上。',
'<span class="need">触发原因</span>:下面电路板上两张档案卡就是本次要比对的原 PN 与候选 PN —— 至少有一颗不在库、两颗归的类型不一致、或必填格还空着,顶部那条红带才会出现。',
'<span class="need">模板定位</span>:目标台账 = 芯片主数据(P-08 主记录 + 标准化参数子表),核心参数字段清单挂在芯片类型字典上。',
'<span class="need">两副面孔</span>:资料还差东西 → 这是**数据工单**,顶部红带点名缺什么,卡角的笔可就地补;三样都齐了 → 这是**验收工单**,只读,交你核对无误后确认。',
'<span class="done">已绑定</span>:两颗料的内部料号已按启动参数铺上档案卡,不必你重填料号本身,只需核对 / 补齐其余各格;在库的料现值已回填,只是查漏补缺。',
'<span class="need">纪律</span>:两颗料必须归到同一个芯片类型下才谈得上逐项比对;类型只从字典里选,本活动不新增类型 —— 字典里没有合适的,先请数据管理员补进字典;查不到的参数值留空(会被红带点名),不要编一个值顶替。',
]) +
`</details>` +
zone('比对基准', '芯片类型 + 它的核心参数字段 —— 两颗料归到哪个类型,就按这些字段逐项比;字段说明悬浮即看', ZIC_TABLE) +
BASIS_CARD +
zone('芯片档案', '一颗芯片一张卡:正面主数据、背面核心参数值 —— 点卡翻面看另一面;右上角的笔 = 只编辑这张卡当前这一面,软盘 = 保存', ZIC_TABLE) +
zone('芯片档案', '一颗芯片一张卡:正面主数据、背面核心参数值 —— 点卡翻面看另一面;可编辑时右上角的笔 = 只编辑当前这一面,软盘 = 保存', ZIC_TABLE) +
DOSSIERS +
flow('<b>提交 / 确认:</b>本工单只承载内容;确认请在对话区的【执行卡】中进行。确认后活动自动从「等待数据」唤醒。'),
flow('<b>提交 / 确认:</b>本工单只承载内容;确认请在对话区那张卡上进行。确认后活动接着往下推进。'),
});
/* ==================== 局面:差异评估结论审核(difference-review,S3 的验收工单) ==================== */
@@ -42,6 +42,16 @@ export const ACCENT_LEDGER = `
}
`;
/**
* **验收工单那一副的强调色**(2026-08-31 用户:「标题那一块需要是验收卡的那种绿色」)——
* 件① 一张两副面孔:数据工单蓝、验收工单绿。整套令牌从 `ACCENT_LEDGER` **现改选择器**得来,
* 数值只有一处真源(件②③ 与对话区验收卡同一枚 #0aa294);题头那条浅色渐变是写死的蓝白,
* 单独给一条绿的override —— `.card.v-accept`(0,2,0)比 `:root`(0,1,0)特异,先后顺序不吃亏。
*/
export const ACCENT_ACCEPT = `${ACCENT_LEDGER.replace(':root{', '.card.v-accept{')}
.card.v-accept.hd-light .card-head{background:linear-gradient(120deg,#e9f9f5 0%,#fff 55%,#e3f7f1 100%);border-bottom-color:var(--acc-deep)}
`;
export const SHELL_CSS = `
/* ================= 令牌与底 ================= */
:root{
@@ -107,6 +117,15 @@ i{font-style:normal}
.seal{position:relative;flex:none;width:112px;height:112px;border-radius:999px;display:grid;place-items:center}
.seal::before{content:"";position:absolute;inset:0;border:1.6px dashed rgba(255,255,255,.34);border-radius:999px;animation:sealspin 16s linear infinite}
.seal-core{display:grid;place-items:center;padding:7px 11px;border-radius:999px;background:rgba(255,255,255,.97);box-shadow:0 12px 26px rgba(3,10,18,.5)}
/*
* **印章里的徽章按圈的内径收**(2026-09-01 用户:「有条件推荐这个 svg 挡住旁边的字了」——
* 浏览器里量的账:seal-core 139px 宽,而印章圈只有 112px,**溢出 26px**,
* 右边那句结论从 603px 起,正好被压住)。
* 病根是徽章按自己的字宽撑开、又带着 white-space:nowrap 缩不了,而印章圈是定死的圆。
* 治法两条一起:核心不许超过圈的内径;圈里的徽章字号收小、允许折行(圆里两行照样是圆)。
*/
.seal-core{max-width:calc(100% - 16px);padding:6px 8px}
.seal .rec.rec-lg{font-size:12px;letter-spacing:0;padding:5px 8px;white-space:normal;text-align:center;line-height:1.3}
.seal-sum{flex:1 1 240px;min-width:200px;max-width:420px;margin:0;font-size:13px;line-height:1.85;color:var(--hero-dim)}
.seal-sum:empty::after{content:"—"}
+2
View File
@@ -28,6 +28,8 @@ export type SubmitForReleaseInput = {
readonly criteria: readonly CriterionCheck[];
/** 人工验收清单(剧本声明,固定):卡上一条一枚开关,全部达标才能通过 */
readonly checklist?: ReadonlyArray<string>;
/** 这张卡打回得了吗 —— `false` = 打回之后没人接得住(见契约 `AcceptanceWorkOrder.redoable`) */
readonly redoable?: boolean;
/** 报告可修订:AI 拟值已填的表单字段 */
readonly fields?: ReadonlyArray<Record<string, unknown>>;
/**
+2
View File
@@ -93,6 +93,8 @@ export type CardGateway = {
}>;
/** 人工验收清单 —— 原样带上卡 */
checklist?: ReadonlyArray<string> | undefined;
/** 这张卡打回得了吗(`false` = 那颗钮不摆)—— 端口不声明它,值就在装配层被丢掉,这行不能省 */
redoable?: boolean | undefined;
/**
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
+10
View File
@@ -329,8 +329,17 @@ export declare const launchParamSchema: z.ZodObject<{
}, z.core.$strip>>>;
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
linkGroup: z.ZodOptional<z.ZodString>;
distinctGroup: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
export type LaunchParam = z.infer<typeof launchParamSchema>;
/**
* 「算不算同一个」的归一:去空白 + 不分大小写 —— **前后端同一把尺**。
* 大小写要归:料号这类身份不因字母大小写而不同,只挡精确相等的话,`lm2596s-5.0` 会照样进去,
* 混一颗垃圾主数据出来,人还得到一份自己跟自己比的评估。
*/
export declare function launchDistinctKey(value: string): string;
/** 撞上了那一格的话 —— 前端(填的当口)与服务端(400 的 detail)同一句,不许分叉 */
export declare function launchDistinctMessage(otherLabel: string): string;
export declare const launchParamsSchema: z.ZodObject<{
activityId: z.ZodString;
version: z.ZodString;
@@ -355,6 +364,7 @@ export declare const launchParamsSchema: z.ZodObject<{
}, z.core.$strip>>>;
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
linkGroup: z.ZodOptional<z.ZodString>;
distinctGroup: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>;
export type LaunchParams = z.infer<typeof launchParamsSchema>;
@@ -181,6 +181,7 @@ export declare const opsLaunchParamsRoute: {
}, z.core.$strip>>>;
default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
linkGroup: z.ZodOptional<z.ZodString>;
distinctGroup: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>;
};
+5
View File
@@ -2835,6 +2835,11 @@ export declare const testActivityStateRoute: {
execution: "execution";
acceptance: "acceptance";
}>;
variant: z.ZodOptional<z.ZodEnum<{
data: "data";
accept: "accept";
}>>;
redoable: z.ZodOptional<z.ZodBoolean>;
title: z.ZodString;
done: z.ZodBoolean;
result: z.ZodOptional<z.ZodString>;
+8
View File
@@ -660,6 +660,7 @@ export declare const taskDetailSchema: z.ZodObject<{
}>>;
variant: z.ZodOptional<z.ZodEnum<{
data: "data";
accept: "accept";
}>>;
ask: z.ZodString;
standard: z.ZodOptional<z.ZodString>;
@@ -1224,6 +1225,7 @@ export declare const taskDetailSchema: z.ZodObject<{
because: z.ZodString;
}, z.core.$strip>>;
passLabel: z.ZodOptional<z.ZodString>;
redoable: z.ZodOptional<z.ZodBoolean>;
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
at: z.ZodISODateTime;
title: z.ZodString;
@@ -1253,6 +1255,7 @@ export declare const taskDetailSchema: z.ZodObject<{
}>>;
variant: z.ZodOptional<z.ZodEnum<{
data: "data";
accept: "accept";
}>>;
ask: z.ZodString;
standard: z.ZodOptional<z.ZodString>;
@@ -1817,6 +1820,7 @@ export declare const taskDetailSchema: z.ZodObject<{
because: z.ZodString;
}, z.core.$strip>>;
passLabel: z.ZodOptional<z.ZodString>;
redoable: z.ZodOptional<z.ZodBoolean>;
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
at: z.ZodISODateTime;
title: z.ZodString;
@@ -2588,6 +2592,7 @@ export declare const taskDetailRoute: {
}>>;
variant: z.ZodOptional<z.ZodEnum<{
data: "data";
accept: "accept";
}>>;
ask: z.ZodString;
standard: z.ZodOptional<z.ZodString>;
@@ -3152,6 +3157,7 @@ export declare const taskDetailRoute: {
because: z.ZodString;
}, z.core.$strip>>;
passLabel: z.ZodOptional<z.ZodString>;
redoable: z.ZodOptional<z.ZodBoolean>;
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
at: z.ZodISODateTime;
title: z.ZodString;
@@ -3181,6 +3187,7 @@ export declare const taskDetailRoute: {
}>>;
variant: z.ZodOptional<z.ZodEnum<{
data: "data";
accept: "accept";
}>>;
ask: z.ZodString;
standard: z.ZodOptional<z.ZodString>;
@@ -3745,6 +3752,7 @@ export declare const taskDetailRoute: {
because: z.ZodString;
}, z.core.$strip>>;
passLabel: z.ZodOptional<z.ZodString>;
redoable: z.ZodOptional<z.ZodBoolean>;
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
at: z.ZodISODateTime;
title: z.ZodString;
+5 -1
View File
@@ -104,7 +104,11 @@ export type AutoAction = {
readonly kind: 'card';
readonly when?: string | undefined;
readonly as: 'execution' | 'decide';
readonly variant?: ('data' | 'review') | undefined;
/**
* 卡的面孔:`data` 数据工单 / `accept` 验收工单(都是执行工单的变体)· `review` 审核卡(决策卡的)。
* 不写就是普通执行工单 / 普通决策卡。
*/
readonly variant?: ('data' | 'review' | 'accept') | undefined;
readonly title: AutoExpr;
/** 决策卡问的那句话 */
readonly ask?: AutoExpr | undefined;
+4
View File
@@ -615,6 +615,7 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
}>>;
variant: z.ZodOptional<z.ZodEnum<{
data: "data";
accept: "accept";
}>>;
ask: z.ZodString;
standard: z.ZodOptional<z.ZodString>;
@@ -1207,6 +1208,7 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
because: z.ZodString;
}, z.core.$strip>>;
passLabel: z.ZodOptional<z.ZodString>;
redoable: z.ZodOptional<z.ZodBoolean>;
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
at: z.ZodISODateTime;
title: z.ZodString;
@@ -1237,6 +1239,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
}>>;
variant: z.ZodOptional<z.ZodEnum<{
data: "data";
accept: "accept";
}>>;
ask: z.ZodString;
standard: z.ZodOptional<z.ZodString>;
@@ -1801,6 +1804,7 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
because: z.ZodString;
}, z.core.$strip>>;
passLabel: z.ZodOptional<z.ZodString>;
redoable: z.ZodOptional<z.ZodBoolean>;
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
at: z.ZodISODateTime;
title: z.ZodString;
@@ -293,3 +293,61 @@ describe('x-options.group / x-link', () => {
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'AMS1117-3.3' } });
});
});
/**
* 互斥组(2026-08-31 用户定:「不应该能选到同样的两个芯片」)。
* 与 `x-link` 正相反 —— **这一条是墙**:W05 两格同一颗料会把单子做死(两行撞同一个主键收成一行,
* S1 的 `exists_status_found = 2` 永不成立,而件上那两行人加不出来),而唯一能改的时机就是起单这一刻。
*/
describe('x-distinct', () => {
const DISTINCT = {
type: 'object',
required: ['originalInternalPn', 'candidateInternalPn'],
properties: {
originalInternalPn: { type: 'string', title: '原内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, 'x-distinct': 'part', 'x-order': 1 },
candidateInternalPn: { type: 'string', title: '候选内部料号', 'x-control': 'combo', 'x-options': { ledger: 'parts.profile', value: 'internal_pn' }, 'x-distinct': 'part', 'x-order': 2 },
/* 同一张表上不在这一组的格子:填成一样也与它无关 */
note: { type: 'string', title: '备注' },
},
};
const paramsOf = async () => launchParamsOf(DISTINCT, async () => [{ value: 'LM2596S-5.0', label: 'LM2596S-5.0' }, { value: 'XL2596S-5.0', label: 'XL2596S-5.0' }]);
it('读得到组名;**不以有候选为前提** —— 自由输入的两格同样归它管(墙管的是值,不是候选)', async () => {
expect(fieldSpecsOf(DISTINCT).map((s) => s.distinctGroup)).toEqual(['part', 'part', undefined]);
const bare = await launchParamsOf({ properties: { a: { type: 'string', 'x-distinct': 'g' }, b: { type: 'string', 'x-distinct': '' } } }, noResolve);
expect(bare.map((p) => [p.type, p.distinctGroup])).toEqual([
['text', 'g'],
['text', undefined],
]);
});
it('两格同一个值 → 拒,点名**后填的那格**,话里指向它撞上了谁', async () => {
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-5.0' }, fieldSpecsOf(DISTINCT));
expect(out).toEqual({ ok: false, issues: [{ key: 'candidateInternalPn', message: '与「原内部料号」填的是同一个 —— 这两格不能一样,换一个再启动' }] });
});
it('**去空白 + 不分大小写**:只挡精确相等的话,`lm2596s-5.0` 会照样进去,混一颗垃圾主数据出来', async () => {
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: ' lm2596s-5.0 ' }, fieldSpecsOf(DISTINCT));
expect(out.ok).toBe(false);
});
it('两颗不同的料照收;不在这一组的格子填成一样也不管', async () => {
const out = validateLaunchParams(
await paramsOf(),
{ originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0', note: 'LM2596S-5.0' },
fieldSpecsOf(DISTINCT),
);
expect(out).toEqual({ ok: true, data: { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0', note: 'LM2596S-5.0' } });
});
it('空的不算撞上 —— 两格都没填该报的是必填,不是「填了同一个」', async () => {
const out = validateLaunchParams(await paramsOf(), { originalInternalPn: '', candidateInternalPn: '' }, fieldSpecsOf(DISTINCT));
expect(out).toEqual({ ok: false, issues: [{ key: 'originalInternalPn', message: '必填' }, { key: 'candidateInternalPn', message: '必填' }] });
});
it('数组格不参与(契约写死的边界:本期没有这种形状)', async () => {
const arrays = { properties: { a: { type: 'array', items: { type: 'string' }, title: 'A', 'x-distinct': 'g' }, b: { type: 'array', items: { type: 'string' }, title: 'B', 'x-distinct': 'g' } } };
const out = validateLaunchParams(await launchParamsOf(arrays, noResolve), { a: ['X'], b: ['X'] }, fieldSpecsOf(arrays));
expect(out).toEqual({ ok: true, data: { a: ['X'], b: ['X'] } });
});
});
@@ -0,0 +1,44 @@
/**
* **确认闸的条件档要在真发卡那条路上转交**(2026-09-01 用户实测:「两个芯片类型不一样,
* 页面上方有红色提示,右边派发的卡片还可以点击下一步」)。
*
* 病:发卡装配那段手写清单逐项点名转交 `gateLedgers`/`variant`,而 `gateWhen`/`gateWhenSay`
* 没被点名 —— 配方白纸黑字声明了「类型不一致不许确认」,落库的工单载荷里那两个字段是
* undefined(任务 43 法证)。红带是视图画的所以照亮;按钮禁用与服务端拒收读的都是
* 存下的 `order.gateWhen`,一起无声失效。**手写字段清单第四次丢值**(fields / blocks /
* redoable 之后),而且丢在 `variant` 上次被丢的同一段。
*
* 这个缝取的是模型也会路过的不可信卡骨架,逐项校验转交是设计 —— 所以治法不是整包 spread,
* 是把这两项的取用抽成纯函数、在 variant 的测试位旁边钉死。
*/
import { describe, expect, it } from 'vitest';
import { cardConditionGate } from '../wiring/engines.ts';
describe('cardConditionGate —— 条件档从卡骨架到工单载荷', () => {
it('声明了就转交,一个字不改', () => {
expect(cardConditionGate({ gateWhen: 'parts_profile_distinct_chip_type_id <= 1', gateWhenSay: '两颗料不是同一个芯片类型' })).toEqual({
gateWhen: 'parts_profile_distinct_chip_type_id <= 1',
gateWhenSay: '两颗料不是同一个芯片类型',
});
});
it('没声明就什么都不带 —— 不发明键', () => {
expect(cardConditionGate({})).toEqual({});
expect(cardConditionGate({ gateLedgers: ['parts.profile'] })).toEqual({});
});
it('脏值不端下去:非字符串 / 空白串一律丢', () => {
expect(cardConditionGate({ gateWhen: 42, gateWhenSay: '话' })).toEqual({});
expect(cardConditionGate({ gateWhen: ' ', gateWhenSay: '话' })).toEqual({});
expect(cardConditionGate({ gateWhen: null })).toEqual({});
});
it('只有那句话没有条件 = 什么都不带(say 单独存在没有意义,评估端只认 when)', () => {
expect(cardConditionGate({ gateWhenSay: '两颗料不是同一个芯片类型' })).toEqual({});
});
it('条件在、话没写:条件照转交,拒回用兜底话术', () => {
expect(cardConditionGate({ gateWhen: 'a > 1' })).toEqual({ gateWhen: 'a > 1' });
expect(cardConditionGate({ gateWhen: 'a > 1', gateWhenSay: '' })).toEqual({ gateWhen: 'a > 1' });
});
});
@@ -0,0 +1,37 @@
/**
* **执行工单的面孔在装配层这一处定**(2026-08-31,用户实测:「你怎么给我发成了执行工单,
* 我需要的是验收工单」)。
*
* 病:配方递下来 `variant: 'accept'`(验收工单 —— 件只读、人只确认),而装配层这一处
* 是一句写死只认 `'data'` 的三目,别的值**静默丢掉** —— 对话区照旧摆「执行工单」。
*
* 更值得记的是**两头都有测,唯独中间没有**:配方那一头断言了「variant 原样递出去」,
* 前端那一头断言了「accept 该穿哪副面孔」,e2e 按卡的**标题**驱动也照样绿 ——
* 三处都对,而它们中间这一段把值扔了。**面孔要在真发卡那条路上断言。**
*/
import { describe, expect, it } from 'vitest';
import { executionCardVariant } from '../wiring/engines.ts';
describe('执行工单的两副变体面孔', () => {
it('data 数据工单 / accept 验收工单,两个都认', () => {
expect(executionCardVariant('data', undefined)).toBe('data');
expect(executionCardVariant('accept', undefined)).toBe('accept');
});
it('剧本声明优先于卡骨架 —— 模型抄卡会把 variant 抄丢(2026-08-27 实测)', () => {
expect(executionCardVariant(undefined, 'data')).toBe('data');
expect(executionCardVariant('data', 'accept')).toBe('accept');
});
it('`review` 是决策卡那一路的面孔,不许串到执行工单上', () => {
expect(executionCardVariant('review', undefined)).toBeUndefined();
expect(executionCardVariant(undefined, 'review')).toBeUndefined();
});
it('没写 / 认不出的值:普通执行工单,不把脏东西端下去', () => {
expect(executionCardVariant(undefined, undefined)).toBeUndefined();
expect(executionCardVariant('', undefined)).toBeUndefined();
expect(executionCardVariant({ variant: 'accept' }, undefined)).toBeUndefined();
expect(executionCardVariant(42, undefined)).toBeUndefined();
});
});
@@ -0,0 +1,29 @@
/**
* **打不回的放行卡**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有重做的选项,
* 因为这里都没有接入 ai」)。
*
* 判据只有一条:这一步整个由配方跑完、一个字的 `guide` 都没有 —— 那时打回的意见没有模型会去读,
* 配方只会原样再提交一次,撞在「打回之后台账没动过就不许再提交」那道闸上,最后落成一张
* 「连续撞在同一条判据上」的兜底卡。**一颗按下去只会把单子推进死胡同的钮,不如不摆。**
*/
import { describe, expect, it } from 'vitest';
import { releaseRedoable } from '../wiring/engines.ts';
describe('放行卡打回得了吗', () => {
it('全 auto(有配方、guide 是空串)→ 打不回', () => {
expect(releaseRedoable({ auto: { do: [] }, guide: '' })).toBe(false);
expect(releaseRedoable({ auto: { do: [] }, guide: ' ' })).toBe(false);
});
it('半 auto(guide 非空)→ 照旧打得回:那儿真有模型会读那句意见', () => {
expect(releaseRedoable({ auto: { do: [] }, guide: '按意见修订台账,改完重新提交' })).toBe(true);
});
it('模型驱动(压根没声明配方)→ 照旧打得回', () => {
expect(releaseRedoable({ guide: '把这一签下去会发生什么摆清楚' })).toBe(true);
});
it('认不出那一步(没声明人工验收清单的老活动)→ 老行为', () => {
expect(releaseRedoable(undefined)).toBe(true);
});
});
+35 -1
View File
@@ -11,6 +11,9 @@
* 整份写 `{ column, ledger, value, label }` 再从字典表换人读名(`parts.chip_type.type_id → type_name`)
* · `x-link`:联动组名 —— 同名的几格按候选的分组键互相收窄(组内先填的那格是锚,其余按锚的键收窄)。
* **服务端不据此立墙**:combo 本就收候选之外的值(新增料号),类型一致归活动自己的收步判据管
* · `x-distinct`:互斥组名 —— 同名的几格**不能填同一个值**。这条**是墙**(与 `x-link` 正相反):
* 前端不把别的格占了的候选列出来,自己写进来的由这里拒。理由见契约 `distinctGroup` 的注释:
* W05 两格同一颗料会把单子做死,而唯一能改的时机就是起单这一刻
* · `format: 'file'` 的字段**本期不上表单**(文档没有文件控件;W05 的 datasheet 在数据工单里传)
* · `x-order`:表单里的次序(数字,小的在前)。**必须声明**才有稳定次序 —— 清单存在 jsonb 里,
* PG 按键长再按字节序重排对象键,`properties` 的书写顺序到不了这里(实测:原料号跑到了客户需求前面)
@@ -19,7 +22,7 @@
* 纯函数、零 IO;候选的读取由调用方以 `resolve` 注入。
*/
import type { LaunchOption, LaunchParam, LaunchParamType } from '@contracts';
import { LAUNCH_PARAM_TYPES } from '@contracts';
import { LAUNCH_PARAM_TYPES, launchDistinctKey, launchDistinctMessage } from '@contracts';
/**
* 候选的**分组键**(2026-08-31 用户定的联动):`column` 是候选行上取哪一列当键(如 `chip_type_id`),
@@ -46,6 +49,8 @@ export type FieldSpec = {
readonly optionsSource: OptionsSource | undefined;
/** 联动组名(`x-link`);同名的几格按候选的分组键互相收窄 —— 摆法在前端,服务端不据此立墙 */
readonly linkGroup: string | undefined;
/** 互斥组名(`x-distinct`);同名的几格不能填同一个值 —— 这一条服务端真拦 */
readonly distinctGroup: string | undefined;
/** 表单次序(`x-order`);没声明的排在声明了的后面、彼此保持读到的顺序 */
readonly order: number | undefined;
};
@@ -112,6 +117,7 @@ export function fieldSpecsOf(inputSchema: Readonly<Record<string, unknown>>): Fi
defaultValue: spec['default'],
optionsSource: optionsSourceOf(spec['x-options']),
linkGroup: asString(spec['x-link']) === '' ? undefined : asString(spec['x-link']),
distinctGroup: asString(spec['x-distinct']) === '' ? undefined : asString(spec['x-distinct']),
order: typeof spec['x-order'] === 'number' && Number.isFinite(spec['x-order']) ? spec['x-order'] : undefined,
};
});
@@ -192,6 +198,8 @@ export function launchParamOf(spec: FieldSpec, resolved: readonly LaunchOption[]
...(fallback === undefined ? {} : { default: fallback }),
/* 联动只在真有候选时才谈得上 —— 一个候选都没有的 combo 摆了组名,前端也没有可收窄的东西 */
...(withOptions && spec.linkGroup !== undefined ? { linkGroup: spec.linkGroup } : {}),
/* 互斥**不以有候选为前提**:两格都自己写、写成同一个,照样要拦 —— 墙管的是值,不是候选 */
...(spec.distinctGroup === undefined ? {} : { distinctGroup: spec.distinctGroup }),
};
}
@@ -230,9 +238,34 @@ function coerceScalar(value: string, spec: FieldSpec | undefined): { value: unkn
return { value };
}
/**
* 同一互斥组(`x-distinct`)里撞了同一个值的那几格 —— 点名的是**后填的那格**(表单次序在后的),
* 话里指向它撞上了谁。只认标量:数组格不参与(见契约 `distinctGroup` 的注释)。
* 「算不算同一个」由 `launchDistinctKey` 说了算 —— 前端用的是同一把尺。
*/
function distinctIssuesOf(params: readonly LaunchParam[], data: Readonly<Record<string, unknown>>): LaunchIssue[] {
const issues: LaunchIssue[] = [];
const seenPerGroup = new Map<string, Map<string, LaunchParam>>();
for (const param of params) {
const group = param.distinctGroup;
const value = data[param.key];
if (group === undefined || value === undefined || value === null || typeof value === 'object') continue;
const key = launchDistinctKey(String(value));
if (key === '') continue;
const seen = seenPerGroup.get(group) ?? new Map<string, LaunchParam>();
seenPerGroup.set(group, seen);
const first = seen.get(key);
if (first === undefined) seen.set(key, param);
else issues.push({ key: param.key, message: launchDistinctMessage(first.label) });
}
return issues;
}
/**
* 服务端的那一道校验:未知键 / 必填空 / 类型不符 / select 值不在候选 → issues(`key` 点名是哪一格);
* combo 候选之外的值合法(存在性检查在启动后由活动做);可选的空值不落入参。
* **互斥组(`x-distinct`)是这里唯一的跨格校验**:同组两格填了同一个值就拒 —— 前端不列出别人占了的
* 候选只是礼貌,自己写一个同样的值、或压根不经这个表单起单(脚本 / API),拦住的得是这里。
* 通过时返回归一后的 `data`(按清单类型把数字 / 布尔转回真类型),直接交 `task.start`。
*/
export function validateLaunchParams(params: readonly LaunchParam[], input: LaunchWire, specs: readonly FieldSpec[] = []): LaunchValidation {
@@ -287,5 +320,6 @@ export function validateLaunchParams(params: readonly LaunchParam[], input: Laun
}
data[param.key] = coerced.value;
}
issues.push(...distinctIssuesOf(params, data));
return issues.length === 0 ? { ok: true, data } : { ok: false, issues };
}
+25 -22
View File
@@ -674,30 +674,33 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
},
},
cards: {
issueAcceptance: async (input) => {
/*
* **这条链的最后一关:默认是「传过去」,不是「丢掉」**(2026-09-01 第三次踩同一个坑改的结构)。
*
* 从前这儿是一个**手写字段清单**的显式对象:上游(契约 → submit → release)一路传下来的东西,
* 只要这里没被点到名就在这一行静默落地,编译不报、运行时无声。已经吃过三次:
* ① `fields` 被写死成 `[]` —— 验收报告只能看不能改(第 12 章③);
* ② `blocks` 漏了一行 —— 卡面正文块恒空;
* ③ `redoable` 漏了一行(2026-09-01 用户:「怎么还有重做??你没改上吗?」)——
* 装配层算对了、release 转对了、契约也声明了,到这儿被抹平,那颗钮照旧摆着。
* 两条注释就写在那几行旁边警告「少写一行就静默落地」,照样第三次。**措辞治不了的结构治**:
* 把 context 三样拆出去,**其余整包转交** —— 以后端口上新增一个可选字段,这里不改也到得了。
* 剩下的两处 `as never` 是类型收窄(品牌类型 / 结构宽松的块),不是丢值。
*/
issueAcceptance: async ({ taskId, ownerId, humanCallCap, blocks, checklist, ...draft }) => {
const issued = await humanTool.issueAcceptance(
{ taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap },
{ taskId, ownerId, humanCallCap },
{
title: input.title,
basis: [...input.basis],
deliverable: input.deliverable as never,
criteria: [...input.criteria],
actions: input.actions.map((a) => ({ ...a })),
// 人工验收清单照传 —— 与 fields / blocks 同一个坑:这里少一行,上游传了一路的东西就静默落地
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: [...input.checklist] }),
/*
* 可修订字段照传 —— 从前这里写死 `fields: []`,是这条链上的最后一处丢值:
* 契约声明了、submit 传了、release 转了,到这儿被一句空数组抹平,
* 验收报告于是只能看不能改(第 12 章③)。
*/
fields: (input.fields ?? []).map((f) => ({ ...f })) as never,
/*
* 正文块照传 —— 与 `fields` 同一个坑:这个显式对象是这条链的最后一关,
* 少写一行,上游传了一路的东西就在这里静默落地(卡面 blocks 恒空,踩过两次)。
*/
...(input.blocks === undefined || input.blocks.length === 0
? {}
: { blocks: input.blocks.map((b) => ({ ...b })) as never }),
/* **整包转交** —— 端口上新增一个可选字段,这里不改也到得了(见上面那段头注) */
...draft,
/* 下面几行不是「挑字段」,是**类型收窄**:只读转可变、品牌类型、结构宽松的块各要一次 */
basis: [...draft.basis],
deliverable: draft.deliverable as never,
criteria: [...draft.criteria],
actions: draft.actions.map((a) => ({ ...a })),
fields: (draft.fields ?? []).map((f) => ({ ...f })) as never,
...(checklist === undefined || checklist.length === 0 ? {} : { checklist: [...checklist] }),
...(blocks === undefined || blocks.length === 0 ? {} : { blocks: blocks.map((b) => ({ ...b })) as never }),
},
);
return { id: issued.order.id };
@@ -712,6 +712,8 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
cards: orders.map((o) => ({
id: o.id,
mode: o.mode,
...(o.mode === 'execution' && o.variant !== undefined ? { variant: o.variant } : {}),
...(o.mode === 'acceptance' && o.redoable !== undefined ? { redoable: o.redoable } : {}),
title: o.title,
done: o.done !== undefined,
...(o.done?.result === undefined ? {} : { result: String(o.done.result) }),
+73 -4
View File
@@ -242,6 +242,61 @@ export function createOnlyViolation(
* 而 `checkChatCardReply` 又要求 `checked ⊇ checklist`:卡按不下去。**只在 `reviewFace` 为真时**
* 才退回剧本声明的清单 —— 非审核面孔的卡,清单就是没有,不发明一条按不下去的。
*/
/**
* **执行工单的面孔** —— 两副变体:`data` 数据工单(件上的表就是要建的记录,确认即入库)/
* `accept` 验收工单(件只读,人只核对确认)。`review` 是决策卡那一路的面孔,不许串到这儿来;
* 认不出的值一律当没写(普通执行工单),不把脏东西端下去。
*
* **剧本声明(`PlanStep.cardVariant`)优先于卡骨架**:模型抄卡时会把 `variant` 抄丢
* (2026-08-27 实测:同一张卡 gateLedgers 抄到了、variant 没有)。但卡自己带的值同样作数 ——
* 配方那一路没有模型可抄丢,而**一个步骤里两笔条件互斥的 card**(缺东西派数据工单 /
* 都齐了派验收工单,2026-08-31)本来就不可能由一个 step 级的 `cardVariant` 说清。
*
* **这一处此前是内联的三目,写死只认 `'data'`**(2026-08-31 用户实测:「你怎么给我发成了
* 执行工单」)—— 配方递下来的 `accept` 在这儿被静默丢掉,对话区照旧摆「执行工单」。
* 配方那一头与前端那一头都有测,唯独**真发卡这条路**上没有:面孔要在这里断言。
*/
/**
* **这张放行卡打回得了吗**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有
* 重做的选项,因为这里都没有接入 ai」)。
*
* 打不回的判据只有一条:**这一步整个由配方跑完、一个字的 `guide` 都没有**(全 auto = 不问模型)。
* 那时打回的意见没有模型会去读,配方只会原样再提交一次,撞在「打回之后台账没动过就不许再提交」
* 那道闸上,连撞两拍之后落成一张「连续撞在同一条判据上」的兜底卡 —— 一颗按下去只会把单子
* 推进死胡同的钮,不如不摆(墙要配路;没有路就别立那扇门)。有异议的人还有真路:别签,终止重开。
*
* 半 auto(`guide` 非空)与模型驱动的放行步照旧打得回 —— 那儿真有模型会读那句意见。
* 认不出那一步(没声明人工验收清单的老活动)也照旧 —— 不写这面旗就是老行为。
*/
export function releaseRedoable(step: { readonly auto?: unknown; readonly guide?: string } | undefined): boolean {
return !(step?.auto !== undefined && (step.guide ?? '').trim() === '');
}
export function executionCardVariant(fromCard: unknown, fromStep: unknown): 'data' | 'accept' | undefined {
for (const value of [fromStep, fromCard]) {
if (value === 'data' || value === 'accept') return value;
}
return undefined;
}
/**
* **确认闸的条件档从卡骨架到工单载荷**(2026-09-01 用户实测:「两个芯片类型不一样…
* 右边派发的卡片还可以点击下一步」)。配方声明了 `gateWhen`/`gateWhenSay`,发卡装配的
* 手写清单没点它们的名 —— 落库的工单载荷里两个字段是 undefined,按钮禁用与服务端拒收
* 读的都是它,一起无声失效(**手写字段清单第四次丢值**,且丢在 `variant` 上次被丢的同一段)。
* 这个缝取的是模型也会路过的不可信卡骨架,不能整包 spread —— 校验着转交:
* 条件必须是非空字符串;那句话只在有条件时才有意义(评估端只认 `when`)。
*/
export function cardConditionGate(card: Readonly<Record<string, unknown>>): { gateWhen?: string; gateWhenSay?: string } {
const when = card['gateWhen'];
if (typeof when !== 'string' || when.trim() === '') return {};
const say = card['gateWhenSay'];
return {
gateWhen: when,
...(typeof say === 'string' && say.trim() !== '' ? { gateWhenSay: say } : {}),
};
}
export function decisionCardFace(
input: { readonly variant?: 'review' | undefined; readonly checklist?: ReadonlyArray<string> | undefined },
step: { readonly cardVariant?: string | undefined; readonly acceptanceChecklist?: ReadonlyArray<string> | undefined } | undefined,
@@ -1805,14 +1860,27 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
* 终局验收卡的人工验收清单:剧本里声明了 `acceptanceChecklist` 且不是审核卡那一步的(放行那一步)。
* 人核这份工单要走的步骤是固定的,由活动声明、这里带上卡,不靠模型抄。
*/
const releaseChecklist = manifest.assets.businessModel.steps?.find(
const releaseStepDecl = manifest.assets.businessModel.steps?.find(
(st) => st.acceptanceChecklist !== undefined && st.acceptanceChecklist.length > 0 && st.cardVariant !== 'review',
)?.acceptanceChecklist;
);
const releaseChecklist = releaseStepDecl?.acceptanceChecklist;
/*
* **打回之后没人接得住的,就别摆那颗钮**(2026-09-01 用户定:「评审放行与落账确认这一步的
* 验收卡不应该有重做的选项,因为这里都没有接入 ai」)。
*
* 判据是这一步**整个由配方跑完、一个字的 `guide` 都没有**(全 auto = 不问模型):
* 那时打回的意见没有模型会去读,配方只会原样再提交一次,撞在「打回之后台账没动过就不许
* 再提交」那道闸上,连撞两拍之后落成一张「连续撞在同一条判据上」的兜底卡 —— 一颗按下去
* 只会把单子推进死胡同的钮,不如不摆(墙要配路;没有路就别立那扇门)。
* 半 auto(`guide` 非空)与模型驱动的放行步照旧摆着 —— 那儿真有模型会读那句意见。
*/
const redoable = releaseRedoable(releaseStepDecl);
const submitted = await deps.release.submitForRelease({
taskId: input.taskId,
ownerId: input.ownerId,
humanCallCap: input.humanCallCap,
...(releaseChecklist === undefined ? {} : { checklist: releaseChecklist }),
...(redoable ? {} : { redoable: false }),
deliverableId: String(input.card['deliverable'] ?? manifest.contract.requiredDeliverables[0]?.key ?? 'deliverable'),
title,
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型收口' }],
@@ -1913,8 +1981,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
: (await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId)).assets.businessModel.steps?.find(
(s) => s.item === input.itemId,
)?.cardVariant;
// 执行工单这一路只认 data(review 是决策卡那一路的面孔)
const variant = stepVariant === 'data' || input.card['variant'] === 'data' ? ('data' as const) : undefined;
const variant = executionCardVariant(input.card['variant'], stepVariant);
const issued = await deps.humanTool.issueExecution(context, {
title,
basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }],
@@ -1925,6 +1992,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }),
...(gateLedgers.length === 0 ? {} : { gateLedgers }),
...(variant === undefined ? {} : { variant }),
/* 条件档(gateWhen/gateWhenSay)—— 校验着转交,见 cardConditionGate 的账 */
...cardConditionGate(input.card),
});
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
@@ -316,6 +316,12 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
}
// 决策卡回答读数(2026-08-27):人在决策卡上选了什么,基座按留痕数 —— 审核通过与否不再由模型自写的旗说了算
for (const name of ['decision_last', 'decision_approve_total', 'decision_reject_total', 'decision_custom_total']) out.add(name);
/*
* **人把这一步打回重做时写下的那句话**(2026-09-01)—— 引擎每一拍都注给配方(没打回就是空串,
* 见 `auto-runner.ts` 的 `AutoContext.redoNote`)。活动据它决定「这一轮要不要把意见交回给 AI 重判」。
* 名字用下划线而不是 `case:` 前缀:S-FEEL 的名字不许带冒号,而它最要紧的用法就是当 `when` 条件用。
*/
out.add('case_redo_note');
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
const m = /id="([^"]+)"/.exec(id);
if (m?.[1] !== undefined) out.add(m[1]);
@@ -32,6 +32,7 @@
- **调人预算环外强制**:超过契约声明的打扰上限即 `HumanCallBudgetExceededError`,
活动代码无从自查自守
- **落地失败卡是执行工单的 preset**,不构成第五种卡
- **打不回的验收卡**(2026-09-01 用户定):`AcceptanceWorkOrder.redoable === false` 时,`verdict !== 'pass'` 的回传当场拒(`redo-unavailable`)。什么时候是 false:那一步整个由配方跑完、一个字的 `guide` 都没有 —— 打回之后没有模型会读那句意见、件也只读,配方只会原样重提并撞在「打回之后台账没动过」那道闸上。卡面把钮收起来只是给人看的,**这里才是门**;拒回把真路指出来(别签,去终止重开)。判断在装配层 `releaseRedoable`(纯函数,有测)。
- **人工验收清单点全才能通过**(2026-08-27 用户定):验收工单 / 审核卡带 `checklist`(剧本 `acceptanceChecklist`,
每份工单固定)时,「通过」的回传必须带 `checked ⊇ checklist`,否则 `checklist-incomplete` 拒回;驳回不看清单。
审核卡只拦「通过」那一个选项(`reviewApproveValue`:值叫 approve 的 / 推荐的 / 第一个)。点过的条目落进
@@ -39,5 +40,6 @@
- **`pendingCards(taskId)`**(2026-08-27):这一单正等人交回的卡 = 没办结的工单 + 没作答、没被顶替的对话卡
(`CardStore.listOpenChatCards` 按 `superseded_by is null` 取)。引擎每拍开头问它:有卡没答就不问模型
- **数据工单是执行工单的 `variant:'data'`**,同样不构成新的 mode:发卡 / 闸 / 回传全走执行工单那条路,只是对话区换一副面孔(2026-08-27)。
- **验收工单是执行工单的 `variant:'accept'`**(2026-08-31):件只读、人只核对确认(「确认无误」),同样不构成新的 mode。**与 `mode:'acceptance'` 分清**:那个是 `submit` 抬起来的收口验收(签下去落账发口、带打回重做);这个只是「这一步的活干完了,请你过目」,回传即办结。三副面孔的判断在 `apps/web/.../desk-task/internal/order-face.ts`(纯函数,有测)。
**确认即入库**(2026-08-27 下午)不在本模块:回传落定之后由 app 的回传路由调 `landDataOrder`
(`apps/api/src/app/data-order-landing.ts`)把这张卡 `gateLedgers` / 绑的表并进正式表 —— 本模块只管回传本身
@@ -95,6 +95,30 @@ describe('工单形态机检', () => {
});
});
/*
* **打不回的验收卡**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有重做的选项,
* 因为这里都没有接入 ai」)。卡面把钮收起来只是给人看的,这里才是门 —— 直调 API 交「重做」,
* 单子会被推进死胡同:没有模型读那句意见,配方只会原样重提,撞在「打回之后台账没动过」那道闸上。
*/
describe('打不回的验收卡', () => {
const redo: WorkOrderReply = { mode: 'acceptance', workOrderId: OID, verdict: 'redo', values: {}, comment: '再看看', selectedPorts: {} };
it('redoable:false → 交「重做」当场拒,拒回把真路指出来', () => {
const v = checkWorkOrderReply(acceptance({ redoable: false }), redo);
expect(v.map((i) => i.code)).toContain('redo-unavailable');
expect(v.find((i) => i.code === 'redo-unavailable')?.message).toContain('终止');
});
it('同一张卡「通过」照收 —— 拦的是打回,不是这张卡', () => {
const pass: WorkOrderReply = { mode: 'acceptance', workOrderId: OID, verdict: 'pass', values: {}, selectedPorts: {} };
expect(checkWorkOrderReply(acceptance({ redoable: false }), pass).map((i) => i.code)).not.toContain('redo-unavailable');
});
it('没写这面旗(老卡)照旧打得回', () => {
expect(checkWorkOrderReply(acceptance(), redo).map((i) => i.code)).not.toContain('redo-unavailable');
});
});
describe('到达面两路', () => {
it('负责人的卡进任务对话流', () => {
expect(arrivalOf('s1', 's1')).toBe('task-chat');
@@ -133,6 +133,18 @@ export function checkWorkOrderReply(order: WorkOrder, reply: WorkOrderReply): Ca
if (rejectionNeedsComment(reply)) {
out.push({ code: 'comment-required', message: '驳回必附意见(OS 05)' });
}
/*
* **打不回的卡就是打不回**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有
* 重做的选项,因为这里都没有接入 ai」)。卡面把那颗钮收起来只是给人看的,**这里才是门** ——
* 直调 API 把「重做」交回来,单子会被推进死胡同(没有模型读那句意见,配方只会原样重提,
* 撞在「打回之后台账没动过」那道闸上)。拒回把真路指出来:别签、去终止重开。
*/
if (reply.verdict !== 'pass' && (order as AcceptanceWorkOrder).redoable === false) {
out.push({
code: 'redo-unavailable',
message: '这一步打不回:它整个由配方跑完,没有模型会读你的意见,件也只读 —— 有异议就别确认,去终止这一单重开。',
});
}
/*
* **恰选一口,随放行一并确认**(AM 1.2)—— 与「驳回必附意见」同族的收单规矩。
* 工单投影自己声明了选择组(portChoice),放行回传就必须带组内恰一口:
@@ -158,6 +158,7 @@
近变(2026-08-27):`cards.issue` 的入参多一项可选 `itemId`(这张卡挂在哪个计划项上)—— 组装层据此查剧本声明
(`PlanStep.cardVariant`:这一步派的是数据工单),**不靠模型抄卡骨架**(实测它会把 `variant` 抄丢)。
`auto` 配方的 `card` 动作也带 `variant`:执行工单认 `data`(数据工单)与 `accept`(验收工单 —— 件只读、人只确认,2026-08-31),决策卡认 `review`(审核卡);按值窄化,别串台。
引擎自己发的卡(验收 / 兜底)没有项,不传。
近变(2026-08-27):`cards.decide` 同样多一项可选 `itemId`(`cardVariant: 'review'` = 审核卡摆成验收工单的样子,机制仍是决策卡);
@@ -134,6 +134,12 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
if (expr === 'never') return false;
if (expr === 'always') return true;
if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== '';
/*
* 人打回时写的那句话:空串 = 没打回(2026-09-01)。**照真 S-FEEL 的语义写**——
* 未绑定的名字 `!= ""` 求值为 **true**(见本 describe 里那条直接拿 @sfeel 验的用例);
* 桩若在这儿手滑写成「没有就当空串」,「必须永远绑上」这条就永远钉不住。
*/
if (expr === 'case_redo_note != ""') return !('case_redo_note' in scope) || String(scope['case_redo_note']) !== '';
throw new Error(`没教过这句:${expr}`);
},
expression: () => undefined,
@@ -383,6 +389,57 @@ describe('analyze 动词', () => {
});
});
/*
* **人打回时写的那句话要到得了配方**(2026-09-01 用户定:「重做后是否没有将重做原因带回去
* 重新让 ai 重跑这一步?」)—— 配方据它决定这一轮要不要把意见交回给 AI 重判。
* 名字是 `case_redo_note`(下划线,不是 `case:` 前缀):S-FEEL 的名字不许带冒号,
* 而这句话最要紧的用法恰恰是当 `when` 条件用。
*/
describe('case_redo_note —— 人打回写的那句话', () => {
const ROW = { kind: 'project' as const, into: 'sub_eval.part', from: { once: true as const } };
it('打回了:那句话进作用域,`when` 与 `{in:}` 都读得到', async () => {
const { runner, drafts, ctx } = harness({});
const out = await runner.run(
{
do: [{ ...ROW, when: 'case_redo_note != ""', fields: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0', note: { in: 'case_redo_note' } } }],
},
{ ...ctx, redoNote: '使能阈值那条请写明验证任务号' },
);
expect(out.wrote).toBe(1);
expect([...drafts.values()][0]?.patch['note']).toBe('使能阈值那条请写明验证任务号');
});
it('没打回:这个名字**仍然绑着**(空串)—— 只在被打回时才跑的那几笔因此关得住', async () => {
const { runner, drafts, ctx } = harness({});
const out = await runner.run(
{
do: [
{ ...ROW, when: 'case_redo_note != ""', fields: { eval_ref: 'W05-1', internal_pn: 'REDO-ONLY', role: 'x' } },
{ ...ROW, fields: { eval_ref: 'W05-1', internal_pn: 'ALWAYS', note: { in: 'case_redo_note' } } },
],
},
ctx,
);
/* 第一笔被 when 挡住(绑了空串);第二笔照跑,那句话取出来是空串 */
expect(out.wrote).toBe(1);
const rows = [...drafts.values()];
expect(rows.map((d) => d.key['internal_pn'])).toEqual(['ALWAYS']);
expect(rows[0]?.patch['note']).toBe('');
});
/*
* **为什么非绑不可**——用**真** S-FEEL(不是本文件顶部那个只认几句话的桩)把两种下场摆出来:
* 不绑,「只在被打回时才跑」的那几笔**每一轮都会跑**(未绑定的名字 `!= ""` 求值为真);
* 绑上空串才关得住。本仓踩过同一个坑不止一次(S1 的 `doneWhen`、`gate_reason` 那条)。
*/
it('不绑的下场:真 S-FEEL 里未绑定的名字 `!= ""` 求值为 **true**', () => {
expect(evaluateCondition('case_redo_note != ""', {})).toBe(true);
expect(evaluateCondition('case_redo_note != ""', { case_redo_note: '' })).toBe(false);
expect(evaluateCondition('case_redo_note != ""', { case_redo_note: '有意见' })).toBe(true);
});
});
describe('card 与 submit', () => {
it('execution:发一张数据工单,itemId 挂在这一步上,basis 现算', async () => {
const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' });
@@ -425,6 +482,35 @@ describe('card 与 submit', () => {
});
});
/*
* **验收工单 = 执行工单的第三副面孔**(2026-08-31 用户定:「数据是足的也进入这个数据工单,
* 就派发一个验收工单…只展示不能编辑」)。引擎这一侧与数据工单一模一样,只把 `variant`
* 原样带下去 —— 面孔归对话区,件的只读归模版(它按同一面旗换脸)。
*/
it('execution + accept:验收工单,variant 原样带到卡上', async () => {
const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' });
await runner.run(
{
do: [
{
kind: 'card',
as: 'execution',
variant: 'accept',
title: '验收工单 · 差异评估的输入核对',
guide: ['两张卡的主数据核一遍'],
basis: [{ k: '候选料号', v: { in: 'pn' } }],
gateLedgers: ['parts.profile', 'parts.parameter'],
},
],
},
ctx,
);
expect(cards.issued[0]).toMatchObject({
workOrder: 'execution',
card: { mode: 'execution', variant: 'accept', title: '验收工单 · 差异评估的输入核对', fields: [] },
});
});
it('这一步已经发过卡:**不再发第二张**,如实留一条观察', async () => {
const { runner, notes, ctx } = harness({});
const out = await runner.run(
@@ -8,7 +8,7 @@ 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 { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns, toolInputHash, unmetWhy, answeredCardFact, answerSettlesStep, lastCardAnswer, earlyFinishTitleOf } from '../internal/usecase/turn-runner.ts';
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
import type { PendingAcceptance } from '../port.ts';
import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
@@ -101,6 +101,12 @@ function harness(
declaredListeners?: ReadonlyArray<{ eventType: string; keyFilter: Record<string, string> }>;
/** 这一单还有哪几张卡在等人交回(不给 = 一张都没有 —— 老测试照跑) */
unanswered?: ReadonlyArray<{ id: string; title: string }>;
/**
* **发卡之后**那一次 `unanswered` 查询的答案(不给就沿用上面那一份)。
* 「人答得比这一拍收尾还快」测的正是这两次查询给出不同答案的那一刻:
* 发卡时没有待答的卡(所以发得出去),发完再查 —— 那张卡在不在名单里,决定这一拍报「等人」还是「还在跑」。
*/
unansweredAfterIssue?: ReadonlyArray<{ id: string; title: string }>;
/** `deps.cards.issue` 这一句改抛(测「兜底卡自己也发不出去」——2026-08-29 复审 I1 二审) */
issueRejects?: string;
/**
@@ -207,7 +213,17 @@ function harness(
issued.push({ workOrder: 'decision', card: { ask: input.ask, itemId: input.itemId, variant: input.variant, checklist: input.checklist } });
return { id: `card-${issued.length}` };
},
unanswered: async () => over.unanswered ?? [],
/*
* **发出去的卡默认就在等** —— 假端口从前发完卡还回「一张待答的都没有」,
* 那是生产里不会有的局面(2026-09-01「人答得比这一拍收尾还快」那条新门就靠它分辨)。
* 要测「人答得飞快」的,显式给 `unansweredAfterIssue: []`。
*/
unanswered: async () => {
const first = over.unanswered ?? [];
if (issued.length === 0) return first;
if (over.unansweredAfterIssue !== undefined) return over.unansweredAfterIssue;
return [...first, ...issued.map((_, at) => ({ id: `card-${at + 1}`, title: '(刚发出去的卡)' }))];
},
} as never,
listeners: {
register: async (input: { eventType: string }) => {
@@ -2197,6 +2213,32 @@ describe('终局放行那一步:human 被拒并指向 submit', () => {
});
});
/*
* **人答的这句话算不算「办完了」**(2026-08-31)—— 平台只判它知道的两件:
* `custom:`(他没选任何一个选项、写的是自己的话)与约定的 `reject`;选项值的语义平台不知道。
*/
describe('answerSettlesStep / lastCardAnswer', () => {
it('重做的两种形状都不算办完:写了理由的 custom:、以及验收面孔约定的 reject', () => {
expect(answerSettlesStep('custom:结论算错了,请重算')).toBe(false);
expect(answerSettlesStep('reject')).toBe(false);
});
it('通过、空答复(照原样确认)、别的选项值 —— 都算办完', () => {
expect(answerSettlesStep('approve')).toBe(true);
expect(answerSettlesStep(undefined)).toBe(true);
expect(answerSettlesStep('to-sourcing')).toBe(true);
});
it('后面又发了别的卡 = 这条回传已经翻篇,不再算数', () => {
const replied = [
{ kind: 'card-sent', title: '结论审核' },
{ kind: 'card-reply', title: '结论审核', detail: { value: 'custom:重算' } },
];
expect(lastCardAnswer(replied)?.chose).toBe('custom:重算');
expect(lastCardAnswer([...replied, { kind: 'card-sent', title: '落账放行' }])).toBeUndefined();
});
});
describe('auto 步:配方在拍首跑', () => {
/** 一步的活动:那一步声明 auto */
const AUTO_MODEL: CaseModel = {
@@ -2253,6 +2295,99 @@ describe('auto 步:配方在拍首跑', () => {
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
});
/*
* **「数据本来就齐」那条路上,人工激活是命门**(2026-08-31 用户定:「就算数据是足的也进入
* 这个数据工单,就派发一个验收工单」)。
*
* 引擎在拍首先扫一遍 doneWhen(`sweepRipe`),为真就自己收步 —— 数据本来就齐的单,那一步的
* doneWhen 第一拍就为真,**配方一笔都还没跑**,步骤当场被收掉,卡永远发不出去。
* 标了人工激活,那道扫描多一个门槛:`worldTouched`(卡真发过)—— 于是次序变成
* 「配方发卡 → 人答 → 下一拍 doneWhen 为真才收」。doneWhen 照旧是这一步的真门,只是不抢在人前面。
*/
const MANUAL_MODEL: CaseModel = {
name: '一步的活动(人工激活)',
autoComplete: false,
items: [{ id: 'S1', name: '补数据', type: 'humanTask', discretionary: false, manualActivation: true, entryCriteria: [], exitCriteria: [] }],
sentries: [],
} as never;
const RIPE_STEP = {
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
doneWhen: 'ready = true',
auto: { do: [{ kind: 'card' as const, as: 'execution' as const, variant: 'accept' as const, title: '验收工单 · 核对', basis: [{ k: '料号', v: 'XL2596S-5.0' }] }] },
};
it('人工激活 + doneWhen 已为真 + 卡还没发过 → 拍首**不收步**,轮到配方把卡发出去', async () => {
const h = harness({ parsed: MANUAL_MODEL, runtime: AUTO_RUNTIME, sfeelCondition: () => true, steps: [RIPE_STEP] });
const out = await h.runner.runTurn(TASK);
expect(h.issued.map((i) => i.card.variant)).toEqual(['accept']);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
expect(h.decided).toHaveLength(0);
});
it('**同一份配方、同一个 doneWhen,没标人工激活就当场被收掉** —— 卡一张都发不出去(病根本身)', async () => {
const h = harness({ parsed: AUTO_MODEL, runtime: AUTO_RUNTIME, sfeelCondition: () => true, steps: [RIPE_STEP] });
const out = await h.runner.runTurn(TASK);
/* 只数执行工单 —— 这一步被收掉之后整单收口,尾巴上那张 acceptance 是放行验收单,与这条无关 */
expect(h.issued.filter((i) => i.workOrder === 'execution')).toHaveLength(0);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
});
/*
* **人答得比这一拍收尾还快,就别报「等人」**(2026-09-01 实测两次撞停):
* 决策卡的回传**不发事件、只调 `wake`**,而 `wake` 只翻停着的单子 —— 卡刚发出那一刻这一拍
* 还在 running,它空转了一次;随后这一拍把任务 park 回 awaiting,再没有第三样东西会来叫。
* 实测:卡 02:06:13.250 发出、219ms 后被答,这一拍 .636 才收尾,单子停到超时。
*/
it('刚发的卡已经被答了 → 这一拍照实说「还在跑」,推进器下一轮接着处理', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
/* 发卡时没有待答的卡(所以发得出去);发完再查,那张卡已经不在名单里 = 人在这一拍里答了 */
unanswered: [],
unansweredAfterIssue: [],
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.issued).toHaveLength(1);
expect(out.status).toBe('running');
});
it('卡还挂在待答名单里 → 照旧「等人」,不因此空转成热循环', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: AUTO_RUNTIME,
sfeelCondition: () => false,
/* 不给 `unansweredAfterIssue`:假端口默认「发出去的卡就在等」—— 这一拍该报等人 */
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(out.status).toBe('awaiting');
});
it('有卡在等人交回:配方照样写,但一张新卡都不发', async () => {
const h = harness({
parsed: AUTO_MODEL,
@@ -2440,7 +2575,13 @@ describe('auto 步:配方在拍首跑', () => {
});
/*
* **Important 2(2026-08-30 全分支终审,先坐实、不修)**:上面那条「卡答过了…→ 收步」
* **Important 2 已修**(2026-08-31 用户实测撞上它:「我重做后怎么直接推进了?不应该是
* 重做刚才那一步吗?」—— 那一单的轨迹逐条对得上这里描述的每一步:审核卡发出 → 人带意见
* 交回 →「这一步已经发过卡,不再发第二张」→「自动收步:S3_review —— 卡已交回,这一步到
* 此为止」→ 下一拍就发出了落账放行的验收卡)。下面这段诊断原样留着 —— 它把病灶说得很准,
* 连「半个修法会把单子卡死」都预判对了;修法见文末。
*
* 原记录:上面那条「卡答过了…→ 收步」
* 只证明了「答过就收」这个结构本身存在,没证明它对**裁决**视而不见 —— 这条把裁决
* 摆明白:审核卡只有一个选项(`approve`),人按的是「重做」,交的是
* `custom:<理由>`(`review-face.ts` 的 `redoReply`:写了理由且 `allowCustom` 就交
@@ -2463,42 +2604,73 @@ describe('auto 步:配方在拍首跑', () => {
* `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` 的「重做即重开」闭环,不是一行判断。
* **修法**(2026-08-31)—— 正是上面预判的那个「重做即重开」闭环,两半缺一不可:
* ①收步那道门认答复(`answerSettlesStep`:`custom:` 与约定的 `reject` 都不算办完);
* ②同一句判断也让 `carded` 让位 —— 幂等挡的是**同一问被重复发出去**,而人点重做正是说
* 「这一轮不算,改了再问我一遍」,那不是重复,是下一轮:配方重跑(该重算的重算)再发一张。
* 只做①就是那个预判过的半修法:挡住误收,单子卡死在「答过但没收步」的死胡同。
*/
it('Important 2 坐实:审核卡答的是"重做"(custom:<理由>),不是 approve —— 「卡答过了就收步」照样收步,意见对它不可见', async () => {
const REVIEW_STEP = {
item: 'S1',
guide: '',
writes: [],
reads: [],
tools: [],
auto: { do: [{ kind: 'card' as const, as: 'decide' as const, variant: 'review' as const, title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
};
const ANSWERED: CaseRuntime = { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never;
const replyOf = (value: string) => [{ kind: 'card-reply', title: '结论审核', detail: { value, by: 's-1' } }];
it('审核卡答的是「重做」(custom:<理由>):**这一步不收**,配方重开一轮把卡再发一张', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
runtime: ANSWERED,
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' }] }] },
},
],
// 人真答的是「重做」,理由带在值里 —— 裁决是拒答这件事**在系统里查得到**
trail: replyOf('custom:结论算错了,请重算'),
steps: [REVIEW_STEP],
});
const out = await h.runner.runTurn(TASK);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
/* 重开一轮 = 真的再发一张(只挡住误收就成了死胡同 —— 上面预判过) */
expect(h.issued.map((i) => i.workOrder)).toEqual(['decision']);
/* 打回这件事要在日志里说出来:人回头得看得见「它听见了」 */
expect(h.appended.some((a) => a.title.includes('打回重做'))).toBe(true);
});
/*
* **重开那一半发不出卡时,收步这道门也得认答复** —— 两处各管一段:配方能重发时(上一条)
* 这道门根本走不到;发不出来时(卡的 `when` 不成立 / 调人预算见底)才轮到它。
* 少了它就是:人说了重做,卡没发出来,这一步照样被判「办完」,单子往前推 —— 与病灶同款。
*/
it('人说重做、而配方这一拍发不出新卡:也不许收步(那时该交人看一眼,不是替他签字)', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: ANSWERED,
unanswered: [],
sfeelCondition: () => false,
trail: replyOf('custom:结论算错了,请重算'),
/* 卡这一笔带 when,而 sfeelCondition 恒假 —— 这一拍发不出卡 */
steps: [{ ...REVIEW_STEP, auto: { do: [{ ...REVIEW_STEP.auto.do[0]!, when: 'never = 1' }] } }],
});
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).not.toBe('Completed');
});
it('审核卡答的是「确认通过」:照旧收步、不再发第二张(这条近道本来就是为它设的)', async () => {
const h = harness({
parsed: AUTO_MODEL,
runtime: ANSWERED,
unanswered: [],
sfeelCondition: () => false,
trail: replyOf('approve'),
steps: [REVIEW_STEP],
});
const out = await h.runner.runTurn(TASK);
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
expect(h.issued).toHaveLength(0);
});
/*
@@ -43,6 +43,16 @@ export type AutoContext = {
readonly quiet: boolean;
/** 这一步已经发过卡(worldTouched) */
readonly carded: boolean;
/**
* **人把这一步打回重做时写下的那句话**(2026-08-31 用户定:「重做后要把原因带回去,
* 让 AI 重跑这一步」)—— 没有打回就是空串,**永远绑得上**:S-FEEL 里未绑定的名字参与
* 比较会翻车(`!= ""` 求值为真),那正好会让「只在被打回时才跑」的那几笔每一轮都跑。
*
* 配方里的名字是 **`case_redo_note`**(下划线,不是 `case:` 前缀)—— S-FEEL 的名字只认
* `[A-Za-z0-9_一-龥]`(`packages/sfeel/src/lexer.ts` 的 NAME_PART),带冒号的名字
* 写不进 `when` 条件;而这句话最要紧的用法恰恰是当条件用。
*/
readonly redoNote?: string | undefined;
};
export type AutoOutcome = {
@@ -98,7 +108,9 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
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 };
/* 人打回时写的那句话:没有就是空串(见 `AutoContext.redoNote` 的头注 —— 必须永远绑得上) */
const redoNote = { case_redo_note: ctx.redoNote ?? '' };
let scope: Record<string, unknown> = { ...(await deps.data.view(ctx.taskId)), ...redoNote, ...results };
let dirty = false;
let wrote = 0;
let dropped = 0;
@@ -367,7 +379,8 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
basis,
/* 零输入:填报全在工作区的件上(2026-08-02 用户定) */
fields: [],
...(action.variant === 'data' ? { variant: 'data' } : {}),
/* 执行工单认两副变体面孔:`data` 数据工单 / `accept` 验收工单;`review` 是决策卡那边的,按值窄化别串台 */
...(action.variant === 'data' || action.variant === 'accept' ? { variant: action.variant } : {}),
...(action.guide === undefined ? {} : { steps: [...action.guide] }),
...(action.standard === undefined ? {} : { standard: evalText(action.standard, base) }),
...(action.gateLedgers === undefined ? {} : { gateLedgers: [...action.gateLedgers] }),
@@ -390,7 +403,7 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
for (const [index, action] of recipe.do.entries()) {
try {
if (dirty) {
scope = { ...(await deps.data.view(ctx.taskId)), ...results };
scope = { ...(await deps.data.view(ctx.taskId)), ...redoNote, ...results };
dirty = false;
}
/*
@@ -324,9 +324,9 @@ export function resendVerdict(
* 只在「最后一次卡的往来是回传」时摆(后面又发了别的卡就说明有新问题在等,不摆);
* 决策卡摆出选中的值,执行工单如实说「空答复 = 照原样确认」。
*/
export function answeredCardFact(
export function lastCardAnswer(
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
): string {
): { readonly title: string; readonly chose: string | undefined } | undefined {
let at = -1;
for (let i = window.length - 1; i >= 0; i -= 1) {
if (window[i]?.kind === 'card-reply') {
@@ -335,15 +335,40 @@ export function answeredCardFact(
}
}
const reply = at < 0 ? undefined : window[at];
if (reply === undefined) return '';
const newer = window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title));
if (newer) return '';
if (reply === undefined) return undefined;
/* 后面又发了别的卡 = 有新问题在等,这条回传已经翻篇 */
if (window.slice(at + 1).some((t) => t.kind === 'card-sent' && !sameQuestion(t.title, reply.title))) return undefined;
const value = reply.detail?.['value'];
const chose = typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
const head = `【这张卡人已经答过了】「${reply.title.length <= 60 ? reply.title : `${reply.title.slice(0, 60)}…`}」`;
return chose === undefined
return { title: reply.title, chose: typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined };
}
/**
* **人的这条答复算不算「这一步办完了」**(2026-08-31 用户实测:「我重做后怎么直接推进了?
* 不应该是重做刚才那一步吗?」)。
*
* 病:「卡答过了就收步」那条近道(全 auto 的步、人答完就收)**不看人答的是什么** ——
* 审核卡上点「重做」并写了意见,与点「确认通过」在它眼里一模一样:当场收掉审核那一步、
* 发出落账放行的验收卡。意见倒是落进了轨迹,可再没有任何东西会去读它。
*
* 平台只判它知道的那两件(选项值的语义平台不知道 —— 见 `chosenGuidance`):
* · `custom:` 开头 = 他没选任何一个选项、写的是自己的话 —— 「重做」那一钮交上来的正是它;
* · `reject` = 验收面孔约定的驳回项(`review-face.ts` 按这个值找那一钮)。
* 空答复照旧算**照原样确认**(既有口径,见下面 `answeredCardFact` 那句话)。
*/
export function answerSettlesStep(chose: string | undefined): boolean {
const value = chose?.trim() ?? '';
return value !== 'reject' && !value.startsWith('custom:');
}
export function answeredCardFact(
window: ReadonlyArray<{ readonly kind: string; readonly title: string; readonly detail?: Record<string, unknown> | undefined }>,
): string {
const answer = lastCardAnswer(window);
if (answer === undefined) return '';
const head = `【这张卡人已经答过了】「${answer.title.length <= 60 ? answer.title : `${answer.title.slice(0, 60)}…`}」`;
return answer.chose === undefined
? `${head} → 人已交回(**空答复 = 照原样确认**,不是没答)。别再发一张一样的卡 —— 照说明书里「人交回之后」那一段办,办完 finish 这一项。`
: `${head} → ${chosenGuidance(chose)}`;
: `${head} → ${chosenGuidance(answer.chose)}`;
}
/**
@@ -2048,13 +2073,37 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
/* 配方**只认 `liveStep`** —— 见上面 `current` 兜底那条注释(I6) */
const current = liveStep;
const recipe = liveStep.auto;
/*
* **被打回就重开一轮**(2026-08-31 用户实测:「我重做后怎么直接推进了?」)。
*
* 「发过就不再发」的幂等挡的是**同一问被重复发出去**;而人点「重做」正是说
* 「这一轮不算,按我的意见改了再问我一遍」—— 那不是重复,是下一轮。
* 于是这一拍的 `carded` 让位:配方重新跑一遍(该重算的重算),再把卡发出去。
* 收步那条近道下面也认同一件事 —— 两处共用 `answerSettlesStep`,一处真源。
*/
const answer = lastCardAnswer(trail);
const settled = answerSettlesStep(answer?.chose);
const touchedItem = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
if (touchedItem && !settled) {
await deps.memory.append({
taskId,
kind: 'observation',
title: `人把这一步打回重做${answer?.chose?.startsWith('custom:') === true ? `:${answer.chose.slice(7).trim()}` : ''}`,
detail: {
item: current.item,
hint: '这一步不算办完 —— 按他的意见重算之后再问他一遍(卡会重新发一张,不是同一张的复读)。',
},
});
}
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,
carded: touchedItem && settled,
/* 打回那句话交给配方(`case_redo_note`)—— 活动据它决定这一轮要不要把意见交回给 AI 重跑 */
...(settled || answer?.chose === undefined ? {} : { redoNote: answer.chose.startsWith('custom:') ? answer.chose.slice(7).trim() : answer.chose }),
});
if (auto.failedAt !== undefined) {
/*
@@ -2222,7 +2271,29 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
if (current.view !== undefined && current.view !== '') {
await showView(taskId, current.view, touched, current.view);
}
return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
/*
* **人答得比这一拍收尾还快,就别报「等人」**(2026-09-01 实测两次撞停,第二次卡了一整例:
* 卡 02:06:13.250 发出、**219ms 后**就被答了,而这一拍 .636 才收尾)。
*
* 为什么会没人叫醒:**决策卡的回传不发事件,只调 `wake`** —— 而 `wake` 只翻停着的单子,
* 那一刻这一拍还在 `running`,它空转了一次;随后这一拍把任务 park 回 `awaiting-event`,
* 再没有第三样东西会来叫(工单回传那一路还有人事件兜底,决策卡这一路一样都没有)。
*
* 所以收尾前再看一眼**刚发的这张卡还在不在等**:已经答了就照实说「还在跑」,
* 推进器下一轮接着处理那份答复。**只认这一张卡** —— 别的「等外部回音」照旧是等,
* 不会因此空转成热循环。
*/
const stillPending = await deps.cards.unanswered(taskId);
const answeredAlready = !stillPending.some((c) => c.id === auto.issuedCardId);
if (answeredAlready) {
await deps.memory.append({
taskId,
kind: 'observation',
title: '这张卡刚发出去就被答了 —— 这一拍不算等人,接着跑',
detail: { cardId: auto.issuedCardId, item: current.item, internal: true },
});
}
return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: answeredAlready ? 'running' : 'awaiting' };
}
if (auto.wrote > 0 || auto.dropped > 0) {
/* 写过东西:重新求一遍 doneWhen —— 这一步可能已经办完了 */
@@ -2248,7 +2319,14 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
*/
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() === '') {
/*
* **人签了字才叫办完 —— 他说重做就不是**(2026-08-31,见 `answerSettlesStep` 的头注:
* 打回与通过从前在这儿一模一样,于是「重做」当场变成了「推进」)。
* 走到这儿说明配方这一拍没能重新发出卡(条件不成立 / 预算见底)——
* 那就停在这一步不收,下面那条「配方跑完了,可这一步还没算办完」把话说给人听,
* 而不是替他把字签了。
*/
if (noDoneWhen && carded && settled && recipeIssuesCard(recipe) && current.guide.trim() === '') {
const active = readyToWork(parsed, stateRuntime, current.item);
const settled = transitionItem(active, current.item, 'Completed');
await deps.memory.append({
+2
View File
@@ -50,6 +50,8 @@ export type SubmitForReleaseInput = {
readonly criteria: readonly CriterionCheck[];
/** 人工验收清单(剧本声明,固定):卡上一条一枚开关,全部达标才能通过 */
readonly checklist?: ReadonlyArray<string>;
/** 这张卡打回得了吗 —— `false` = 打回之后没人接得住(见契约 `AcceptanceWorkOrder.redoable`) */
readonly redoable?: boolean;
/** 报告可修订:AI 拟值已填的表单字段 */
readonly fields?: ReadonlyArray<Record<string, unknown>>;
/**
@@ -144,6 +144,8 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
actions: report.verified.map((v) => ({ label: v.label, effect: v.effect, detail: v.detail })),
// 人工验收清单:人核这份工单要走的步骤(剧本声明,固定)
...(input.checklist === undefined || input.checklist.length === 0 ? {} : { checklist: input.checklist }),
/* 打回得了吗 —— 只在明确说「打不回」时才带下去,老调用方一个字不用改 */
...(input.redoable === false ? { redoable: false } : {}),
/*
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们。
*
+2
View File
@@ -80,6 +80,8 @@ export type CardGateway = {
actions: ReadonlyArray<{ label: string; effect: 'read' | 'write'; detail: string }>;
/** 人工验收清单 —— 原样带上卡 */
checklist?: ReadonlyArray<string> | undefined;
/** 这张卡打回得了吗(`false` = 那颗钮不摆)—— 端口不声明它,值就在装配层被丢掉,这行不能省 */
redoable?: boolean | undefined;
/**
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
+2 -1
View File
@@ -54,5 +54,6 @@
- **已终结不得再推进**:`tick` / `deliver` 对终态任务抛 `TaskAlreadyTerminalError` ——
流级重做一律重开新实例,不复活旧任务
- **叫停不是终态**:转挂起待纠偏,并记**最重档纠正信号**
- **封卷触发蒸馏**:终态后 `seal` + `distill`,产出活动记忆候选条目
- **封卷触发蒸馏**:终态后 `seal` + `distill`,产出活动记忆候选条目;蒸馏是一次真模型调用,
**后台跑、不吊着收尾的调用方**(2026-09-01:终止接口曾被它吊 13-45 秒到报错),失败吞掉不掀终态
- **断点续跑靠三份持久状态**:任务记忆 / 草稿层 / 运行时实例状态,缺一即不可续
@@ -11,13 +11,13 @@ import type { LandedFacts, Task } from '../contract.ts';
import { createTaskService } from '../internal/usecase/task-service.ts';
import type { TaskServiceDeps } from '../internal/usecase/task-service.ts';
function harness(lastProgressAt: Date | undefined) {
function harness(lastProgressAt: Date | undefined, runtime: Task['runtime'] = 'awaiting-human') {
const task: Task = {
id: 't-1' as TaskId,
activityId: 'w05',
ownerId: 's-1' as StaffId,
title: 'x',
runtime: 'awaiting-human',
runtime,
envelope: {},
data: {},
caps: { steps: 40, tokens: 200000, humanCalls: 4, activeMinutes: 60 },
@@ -88,6 +88,29 @@ describe('迟到的人事件', () => {
expect(arrival?.detail?.['stale']).toBe(true);
});
/*
* **判了 stale 还得管它有没有人叫醒**(2026-09-01,两次实测撞停):卡发出 270ms 后人就答了 ——
* 把 `lastProgressAt` 往前挪的那一拍,**正是发出这张卡的那一拍**,它不可能消费一条当时还不存在的回传;
* 而回传口的 `wake` 也白做了(那一拍随后收尾,把任务 park 回 `awaiting-event`,晚到的写盖了早到的)。
* 于是卡答完了、没有新卡在等、单子停着,**再没有第三样东西会叫醒它** —— 实测停了 15 分钟,
* 手工把 runtime 拨回 running,同一份代码立刻把该走的几拍走完。
* 这一拍照旧不跑(stale 的本意不变),但停着的单子要拨回 running 交给推进器。
*/
it('**stale 但单子停着** → 这一拍照旧不跑,可要把它拨回 running,不然再没人叫醒它', async () => {
const h = harness(new Date('2026-08-27T09:48:00.200Z'));
const out = await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-p10', payload: {}, occurredAt: REPLY_AT });
expect(h.advanced()).toBe(0);
expect(h.onEvents()).toBe(0);
expect(out.task.runtime).toBe('running');
});
it('stale 而单子本来就在跑 → 一个字不动(推进器自己会跑到它)', async () => {
const h = harness(new Date('2026-08-27T09:48:00.200Z'), 'running');
const out = await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-p10', payload: {}, occurredAt: REPLY_AT });
expect(out.task.runtime).toBe('running');
expect(h.advanced()).toBe(0);
});
it('回传之后没推进过 → 照常投递并推一拍(这就是叫醒它的那一下)', async () => {
const h = harness(new Date('2026-08-27T09:47:40.000Z'));
await h.service.deliver('t-1' as TaskId, { kind: 'human', ref: 'card-s1', payload: {}, occurredAt: REPLY_AT });
@@ -0,0 +1,109 @@
/**
* 终态收尾不等蒸馏(2026-09-01 用户实测:运营中心点「终止活动」,接口迟迟不返回最后报错,
* 而实例确实终止了)。
*
* 病根:`finish` 在返回前同步 `await` 了 `memory.distill` —— 那是一次真模型调用
* (整单记忆 + JSON schema 结构化输出,四单法证 13.2s / 36.3s / 43.4s / 44.6s:
* `sys.task.finished_at` 与蒸馏条目 `created_at` 的差就是接口被吊的时长)。
* 终态在它**之前**已落库并广播,所以屏上早就显示已终止,响应却被吊在半空 ——
* 拖到任何一环断连(watcher 重启进程 / 代理 / 人重试)就成一次报错。
* 吊着的不止终止接口:放行收尾那一拍同样走 `finish`,推进器与任务租约陪着蒸馏等。
*
* 蒸馏本来就是**进化管线**的入口、不是终态的一部分(`finish` 里的注释白纸黑字),
* 它的失败早已被容忍 —— 那它的**时长**同样不该由收尾的调用方来付。
*/
import { describe, expect, it } from 'vitest';
import type { StaffId, TaskId } from '@contracts';
import type { LandedFacts, Task } from '../contract.ts';
import { createTaskService } from '../internal/usecase/task-service.ts';
import type { TaskServiceDeps } from '../internal/usecase/task-service.ts';
function harness(distill: TaskServiceDeps['memory']['distill']) {
const trail: string[] = [];
const task: Task = {
id: 't-1' as TaskId,
activityId: 'w05',
ownerId: 's-1' as StaffId,
title: 'x',
runtime: 'awaiting-human',
envelope: {},
data: {},
caps: { steps: 40, tokens: 200000, humanCalls: 2, activeMinutes: 60 },
used: { steps: 0, tokens: 0, humanCalls: 0, activeMs: 0 },
startedAt: '2026-09-01T00:00:00.000Z',
};
const tasks = new Map<string, Task>([[task.id, task]]);
const deps: TaskServiceDeps = {
store: {
get: async (id) => tasks.get(id),
insert: async (t) => void tasks.set(t.id, t),
update: async (id, patch) => {
const next = { ...tasks.get(id)!, ...patch };
tasks.set(id, next);
return next;
},
addConsumption: async (id) => tasks.get(id)!.used,
lastProgressAt: async () => new Date(),
markProgress: async () => undefined,
listByOwner: async () => [],
},
activities: { load: async () => ({ caps: task.caps, envelope: {}, inputSchema: {}, businessModel: {}, stallAfter: 'PT30M' }) },
schemas: { validate: () => ({ ok: true, errors: [] }) },
engine: { load: async () => undefined, advance: async () => ({ status: 'running' }), onEvent: async () => ({ status: 'running' }) },
memory: {
append: async () => undefined,
hasHistory: async () => true,
seal: async () => void trail.push('seal'),
distill: (input) => {
trail.push('distill');
return distill(input);
},
},
landing: { land: async () => ({ converged: true, emittedPorts: [] as string[], ledgerRowsMerged: 0, facts: {} as LandedFacts }) },
listeners: { hasArmedListener: async () => true },
persistence: { hasDrafts: async () => false, hasRuntimeState: async () => true },
events: { publishTerminal: async () => undefined, publishControl: async () => undefined },
notifier: { notify: async () => undefined },
interaction: { setPhase: async () => undefined },
intake: { askCompletion: async () => undefined },
comms: { record: async () => undefined },
clock: { now: () => new Date('2026-09-01T00:00:00.000Z') },
ids: { next: () => 't-x' },
};
return { service: createTaskService(deps), tasks, trail };
}
describe('finish 不把收尾吊在蒸馏上', () => {
it('蒸馏还悬着,terminate 已经返回、终态已落 —— 接口不等模型', async () => {
let release: (() => void) | undefined;
const gate = new Promise<never[]>((resolve) => {
release = () => resolve([]);
});
const h = harness(() => gate as Promise<never[]>);
const outcome = await h.service.control('t-1' as TaskId, 'terminate', 's-1' as StaffId, '手动终止');
expect(outcome.kind).toBe('terminated');
expect(h.tasks.get('t-1')?.terminal).toBe('terminated');
/* 走到这儿 gate 还没放行 —— 蒸馏在后台悬着,control 没有等它 */
expect(h.trail).toEqual(['seal', 'distill']);
release?.();
await gate;
});
it('蒸馏抛了也不掀翻终态,更不外泄成未处理拒绝', async () => {
const h = harness(async () => {
throw new Error('Insufficient Balance');
});
const outcome = await h.service.control('t-1' as TaskId, 'terminate', 's-1' as StaffId, '手动终止');
expect(outcome.kind).toBe('terminated');
expect(h.tasks.get('t-1')?.terminal).toBe('terminated');
/* 让后台那口拒绝走完微任务 —— vitest 对未处理拒绝会当场炸,这里干净跑完即是断言 */
await new Promise((resolve) => setTimeout(resolve, 0));
});
it('封卷在蒸馏之前 —— 只追加不改的守卫先立起来,蒸馏读的是封好的卷', async () => {
const h = harness(async () => []);
await h.service.control('t-1' as TaskId, 'terminate', 's-1' as StaffId, '手动终止');
expect(h.trail).toEqual(['seal', 'distill']);
});
});
@@ -350,7 +350,26 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
detail: { ...event.payload, kind: event.kind, ...(stale ? { stale: true, note: '这条回传在到达前已被推进过的那一拍处理,这次不再推' } : {}) },
});
if (stale) {
/*
* **判了 stale 还得管它有没有人叫醒** —— 不然就是把单子锁死(2026-09-01 实测两次:
* 卡发出 270ms 后人就答了,而**把 `lastProgressAt` 往前挪的那一拍,正是发出这张卡的那一拍**
* ——它不可能消费一条当时还不存在的回传。上面那条 stale 判据的前提在这种时序下不成立。
*
* 更糟的是回传路由的 `wake` 也白做了:它先把任务拨成 `running`,而发卡那一拍**随后**收尾时
* 又把它 park 回 `awaiting-event`(晚到的写覆盖了早到的 wake)。于是三样东西凑齐:
* 卡答完了、没有新卡在等、单子停着,**再没有第三样东西会叫醒它**(实测停了 15 分钟,
* 手工把 runtime 拨回 running,同一份代码立刻把该走的几拍走完)。
*
* 这里只补最后一下:**不跑这一拍**(stale 的本意照旧 —— 这条回传不该再推一遍),
* 但把停着的单子拨回 `running`,交给推进器按它自己的节奏跑。真没事可干,它自己再停回去
* (推进器那一拍会走「有卡在等人 → 只算不问模型」或直接原地返回,代价是一拍空转)。
*/
const held = await require(taskId);
if (held.terminal === undefined && (held.runtime === 'awaiting-event' || held.runtime === 'awaiting-human')) {
await deps.store.update(taskId, { runtime: 'running' });
await deps.interaction.setPhase(taskId, 'active');
return { task: await require(taskId), verdict: { kind: 'ok' }, progressed: false };
}
return { task: held, verdict: { kind: 'ok' }, progressed: false };
}
@@ -479,23 +498,27 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
// 终态封卷 → 归档 → 蒸馏钩子产出活动记忆候选条目
await deps.memory.seal(taskId);
try {
await deps.memory.distill({
/*
* 蒸馏**后台跑,不吊着收尾的调用方**(2026-09-01 用户实测:运营中心点「终止活动」,
* 接口迟迟不返回最后报错,而实例确实终止了)。它是一次真模型调用 —— 整单记忆 +
* JSON schema 结构化输出,四单法证 13-45 秒(`finished_at` 与蒸馏条目 `created_at`
* 的差)。终态在上面几行已落库并广播,把响应吊在这儿,拖到任何一环断连
* (进程重启 / 代理 / 人重试)就成一次报错;放行收尾那一拍同样走这里,
* 推进器与任务租约陪着模型等。封卷仍在前(只追加不改的守卫先立起来)。
*
* 失败照旧吞掉:蒸馏是**进化管线**的入口,不是终态的一部分 —— 它产出的是
* 待回放待放行的候选增量,失败(模型不可达等)绝不能把已经落定的终态掀翻。
* 也不能把失败记进任务记忆:此刻记忆**已封卷**,追加会被只追加不改的守卫拒掉
* (那正是它该做的)。蒸馏的成败属于进化管线的账,不属于这一单的过程留痕。
*/
void deps.memory
.distill({
taskId,
activityId: task.activityId,
trigger: 'terminal',
prompt: `任务「${task.title}」以 ${terminal} 收场(${reason})。归纳可复用的事实、模式、裁决与例外。`,
});
} catch {
/*
* 蒸馏是**进化管线**的入口,不是终态的一部分:它产出的是待回放待放行的候选增量。
* 它失败(模型不可达等)绝不能把已经落定的终态掀翻 —— 那会让任务卡在
* 「实际已终、状态却没落」的中间态,治理台也看不到。
*
* 也不能把失败记进任务记忆:此刻记忆**已封卷**,追加会被只追加不改的守卫拒掉
* (那正是它该做的)。蒸馏的成败属于进化管线的账,不属于这一单的过程留痕。
*/
}
})
.catch(() => undefined);
return updated;
},
@@ -0,0 +1,39 @@
/**
* 工单穿哪副面孔(2026-08-31 用户定「数据齐了也叫人一次,派验收工单」逼出的第三副)。
*
* 三副面孔都是**执行工单**的变体,回传即办结;收口验收单(`mode:'acceptance'`)是另一回事,
* 它有自己的裁决表单(AcceptForm)—— `faceOf` 对它返回 undefined,别顶着执行工单的牌子摆出来。
*/
import { describe, expect, it } from 'vitest';
import { ACCEPT_FACE, DATA_FACE, EXEC_FACE, chipOf, faceOf } from '../internal/order-face.ts';
describe('faceOf —— 没办结的执行工单穿哪副', () => {
it('不写变体 = 执行工单;data = 数据工单;accept = 验收工单', () => {
expect(faceOf('execution', undefined)).toBe(EXEC_FACE);
expect(faceOf('execution', 'data')).toBe(DATA_FACE);
expect(faceOf('execution', 'accept')).toBe(ACCEPT_FACE);
});
it('收口验收单不走这儿 —— 它是 submit 抬起来的裁决,不是「这一步要你办的事」', () => {
expect(faceOf('acceptance', undefined)).toBeUndefined();
});
it('三副面孔的四字与按钮字各不相同 —— 人一眼要能认出这张卡要他干什么', () => {
const faces = [EXEC_FACE, DATA_FACE, ACCEPT_FACE];
expect(new Set(faces.map((f) => f.docTitle)).size).toBe(3);
expect(new Set(faces.map((f) => f.submit)).size).toBe(3);
expect(new Set(faces.map((f) => f.klass)).size).toBe(3);
/* 验收工单的件是只读的,按钮说的就得是「确认」而不是「提交/写入」 */
expect(ACCEPT_FACE.submit).toBe('确认无误');
expect(ACCEPT_FACE.docHint).toContain('只读');
});
});
describe('chipOf —— 已办结 / 没有指导书的那张小卡', () => {
it('数据工单摆「数据」,验收工单与收口验收单同摆「验收」,其余「执行」', () => {
expect(chipOf('execution', 'data')).toEqual({ klass: 't-data', ico: 'database', label: '数据' });
expect(chipOf('execution', 'accept')).toEqual({ klass: 't-acc', ico: 'shield-check', label: '验收' });
expect(chipOf('acceptance', undefined)).toEqual({ klass: 't-acc', ico: 'shield-check', label: '验收' });
expect(chipOf('execution', undefined)).toEqual({ klass: 't-exe', ico: 'clipboard-check', label: '执行' });
});
});
@@ -21,6 +21,8 @@ import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { Btn } from '@web-shared/Btn.tsx';
import { approveReply, checklistMissing, redoReply, reviewChoices } from './review-face.ts';
import { ACCEPT_FACE, DATA_FACE, EXEC_FACE, chipOf } from './order-face.ts';
import type { FormFace } from './order-face.ts';
import type { DecisionMessage } from './review-face.ts';
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
import { atClock } from '@web-shared/at.ts';
@@ -359,6 +361,15 @@ function Msg({
onLocate={onLocate}
{...(orderValues === undefined ? {} : { orderValues })}
/>
) : order.mode === 'execution' && order.variant === 'accept' && !settled ? (
/* 验收工单 = 执行工单的变体(契约 `variant`):件只读,人只确认 */
<AcceptCheckForm
order={order}
onReplyOrder={onReplyOrder}
onLocate={onLocate}
onField={onField}
{...(orderValues === undefined ? {} : { orderValues })}
/>
) : order.mode === 'execution' && order.variant === 'data' && !settled ? (
/* 数据工单 = 执行工单的变体(契约 `variant`):同一套办法,另一副面孔 */
<DataForm
@@ -765,13 +776,13 @@ function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string) => void }) {
const result = order.done?.result;
const bad = result !== undefined && isRejection(result);
const exe = order.mode === 'execution';
const data = order.mode === 'execution' && order.variant === 'data';
/* 三副面孔 + 收口验收单,哪一副由纯函数说了算(`order-face.ts`,有测)—— 这里只摆 */
const chip = chipOf(order.mode, order.mode === 'execution' ? order.variant : undefined);
return (
<div className={'jc-fc ' + (data ? 't-data' : exe ? 't-exe' : 't-acc')}>
<div className={'jc-fc ' + chip.klass}>
<div className="jc-fc-h">
<Ico name={data ? 'database' : exe ? 'file-text' : 'shield-check'} s={13} />
<b>工单 · {data ? '数据' : exe ? '执行' : '验收'}</b>
<Ico name={chip.ico} s={13} />
<b>工单 · {chip.label}</b>
<em className={result === undefined ? '' : bad ? 'bad' : 'ok'}>
{result === undefined ? '待处理' : bad ? '已退回' : '已办结'}
</em>
@@ -962,40 +973,6 @@ function Guide({ steps, standard }: { steps?: readonly string[] | undefined; sta
* 表格那一份值不在这儿收 —— 人是在工作区的件上改的,由外层缓冲带过来
* (与验收裁决同一条路子:编辑与提交分在两个区,缓冲得由壳层持有)。
*/
/** 执行工单与数据工单是同一套办法、两副面孔 —— 面孔在这儿定,办法只写一份 */
type FormFace = {
readonly klass: 't-exe' | 't-data';
readonly headIcon: 'clipboard-check' | 'database';
readonly head: string;
readonly docIcon: 'file-text' | 'table';
readonly docTitle: string;
readonly docHint: string;
readonly submit: string;
};
const EXEC_FACE: FormFace = {
klass: 't-exe',
headIcon: 'clipboard-check',
head: '工单 · 执行',
docIcon: 'file-text',
docTitle: '执行工单',
docHint: '整份清单在工作区,每一格都能就地改;改完回这里提交',
submit: '提交',
};
/**
* 数据工单(2026-08-27 用户:「工单类型新加入了一类数据工单…照着这个大概的样式为数据工单单独做一个组件」):
* 让人**从零建一张主数据表** —— 件上的表就是要建的记录,填齐、保存,回这里确认即入库,活动从「等待数据」唤醒。
* 引擎那一侧与执行工单无异(契约 `ExecutionWorkOrder.variant = 'data'`),所以只换面孔:色、图标、四字、按钮字。
*/
const DATA_FACE: FormFace = {
klass: 't-data',
headIcon: 'database',
head: '工单 · 数据',
docIcon: 'table',
docTitle: '数据工单',
docHint: '件上的表就是要建的记录:每格就地填、保存;填齐后回这里确认,确认即入库',
submit: '确认写入',
};
type ExecFormProps = {
order: Extract<WorkOrder, { mode: 'execution' }>;
onReplyOrder: (reply: WorkOrderReply) => void;
@@ -1009,6 +986,11 @@ function DataForm(props: ExecFormProps) {
return <ExecForm {...props} face={DATA_FACE} />;
}
/** 验收工单卡 —— 执行工单的第三副面孔(见 ACCEPT_FACE) */
function AcceptCheckForm(props: ExecFormProps) {
return <ExecForm {...props} face={ACCEPT_FACE} />;
}
function ExecForm({ order, onReplyOrder, onLocate, orderValues, onField, face = EXEC_FACE }: ExecFormProps & { face?: FormFace }) {
const busy = useReplyBusy();
const val = (f: FormField) => orderValues?.[f.key] ?? f.ai ?? '';
@@ -1148,6 +1130,8 @@ function AcceptForm({
});
const unchecked = checklistMissing(checklist, checked);
const passN = order.criteria.filter((c) => c.pass).length;
/* 不写 = 打得回(老卡照旧);写了 false 才收起那颗钮 —— 见契约 `AcceptanceWorkOrder.redoable` */
const redoable = order.redoable !== false;
return (
<div className="jc-fc t-acc">
<div className="jc-fc-h">
@@ -1231,32 +1215,48 @@ function AcceptForm({
))}
</div>
)}
<textarea
className="jc-note"
placeholder="要重做?写明原因(也可以在下面对话里聊)"
value={why}
onChange={(e) => setWhy(e.target.value)}
/>
{/*
* **打不回的卡不摆理由框、也不摆那颗钮**(2026-09-01 用户定:「评审放行与落账确认这一步的
* 验收卡不应该有重做的选项,因为这里都没有接入 ai」)。这一步整个由配方跑完,打回之后
* 没有模型会读那句意见、件也只读 —— 那颗钮按下去只会把单子推进死胡同。
* 有异议的人还有真路可走:别签,去终止这一单重开 —— 这句话得写在他眼前,
* 不然收起一颗钮就成了「少了个选项,不知道该怎么办」。
*/}
{redoable ? (
<textarea
className="jc-note"
placeholder="要重做?写明原因(也可以在下面对话里聊)"
value={why}
onChange={(e) => setWhy(e.target.value)}
/>
) : (
<p className="jc-fc-note">
<Ico name="info" s={13} />
<span>这一步只落账,退不回上一步 —— 有异议就别确认,去终止这一单重开。</span>
</p>
)}
</div>
<div className="jc-fc-ft">
<Btn
className="jc-btn jc-btn-bad"
disabled={why.trim() === ''}
busy={busy}
onClick={() =>
onReplyOrder({
mode: 'acceptance',
workOrderId: order.id,
verdict: 'redo',
// 驳回也把改过的值带上:人常常一边改一边发现问题,改的痕不该丢
values: { ...(orderValues ?? {}) },
comment: why.trim(),
selectedPorts: {},
})
}
>
重做
</Btn>
{redoable && (
<Btn
className="jc-btn jc-btn-bad"
disabled={why.trim() === ''}
busy={busy}
onClick={() =>
onReplyOrder({
mode: 'acceptance',
workOrderId: order.id,
verdict: 'redo',
// 驳回也把改过的值带上:人常常一边改一边发现问题,改的痕不该丢
values: { ...(orderValues ?? {}) },
comment: why.trim(),
selectedPorts: {},
})
}
>
重做
</Btn>
)}
<span className="jc-fc-sp" />
<Btn
className="jc-btn jc-btn-ok"
@@ -0,0 +1,82 @@
/**
* 工单穿哪副面孔 —— 纯函数,零 React(与 `review-face.ts` 同一条理由:**判断要能钉测**)。
*
* 执行工单一套办法、**三副面孔**(契约 `ExecutionWorkOrder.variant`):
* · 不写 → 执行工单:作业指导书 + 件上就地办,「提交」;
* · `data` → 数据工单(2026-08-27 用户定):件上的表就是要建的记录,「确认写入」即入库;
* · `accept` → 验收工单(2026-08-31 用户定):件只读,人只核对,「确认无误」。
*
* **`accept` 与 `mode:'acceptance'` 不是一回事**:后者是 `submit` 抬起来的收口验收
* (签下去就落账发口,带打回重做那一路,对话区走 `AcceptForm`),这里的三副面孔都只是
* 「这一步要你办的事」—— 回传即办结,放行不了任何东西。
*/
import type { WorkOrderMode } from '@contracts';
export type FormFace = {
readonly klass: 't-exe' | 't-data' | 't-acc';
readonly headIcon: 'clipboard-check' | 'database' | 'shield-check';
readonly head: string;
readonly docIcon: 'file-text' | 'table';
readonly docTitle: string;
readonly docHint: string;
readonly submit: string;
};
export const EXEC_FACE: FormFace = {
klass: 't-exe',
headIcon: 'clipboard-check',
head: '工单 · 执行',
docIcon: 'file-text',
docTitle: '执行工单',
docHint: '整份清单在工作区,每一格都能就地改;改完回这里提交',
submit: '提交',
};
/**
* 数据工单(2026-08-27 用户:「工单类型新加入了一类数据工单…照着这个大概的样式为数据工单单独做一个组件」):
* 让人**从零建一张主数据表** —— 件上的表就是要建的记录,填齐、保存,回这里确认即入库,活动从「等待数据」唤醒。
*/
export const DATA_FACE: FormFace = {
klass: 't-data',
headIcon: 'database',
head: '工单 · 数据',
docIcon: 'table',
docTitle: '数据工单',
docHint: '件上的表就是要建的记录:每格就地填、保存;填齐后回这里确认,确认即入库',
submit: '确认写入',
};
/**
* 验收工单(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个验收工单…
* 把芯片卡片上的编辑按钮去掉,只展示不能编辑」):这一步的活已经齐了,件只读,人过目、确认。
*/
export const ACCEPT_FACE: FormFace = {
klass: 't-acc',
headIcon: 'shield-check',
head: '工单 · 验收',
docIcon: 'file-text',
docTitle: '验收工单',
docHint: '整份内容在工作区,只读不可改;核对无误后回这里确认',
submit: '确认无误',
};
/** 一张**没办结的执行工单**该穿哪副面孔;收口验收单(mode: 'acceptance')不走这儿 —— 它有自己的裁决表单 */
export function faceOf(mode: WorkOrderMode, variant: 'data' | 'accept' | undefined): FormFace | undefined {
if (mode !== 'execution') return undefined;
if (variant === 'data') return DATA_FACE;
if (variant === 'accept') return ACCEPT_FACE;
return EXEC_FACE;
}
/**
* 已办结的、以及没有作业指导书的那张小卡上的三样东西。
* **验收工单与收口验收单在这张小卡上同一副样子** —— 人读的都是「验收」,
* 至于签下去会不会落账,不是这张小卡要答的问题。
*/
export function chipOf(
mode: WorkOrderMode,
variant: 'data' | 'accept' | undefined,
): { readonly klass: FormFace['klass']; readonly ico: FormFace['headIcon']; readonly label: string } {
const face = mode === 'execution' && variant === 'data' ? DATA_FACE : mode === 'execution' && variant !== 'accept' ? EXEC_FACE : ACCEPT_FACE;
return { klass: face.klass, ico: face.headIcon, label: face.head.replace('工单 · ', '') };
}
@@ -1,7 +1,9 @@
/**
* 启动参数的按分组联动(2026-08-31 用户定:原内部料号 / 候选内部料号按芯片类型互相收窄)。
* 启动参数里格与格之间的两件事(2026-08-31 用户定,W05 的原料号 / 候选料号两格两样都有):
* · **按分组联动**(按芯片类型互相收窄)—— 引导,三条口径见下;
* · **互斥**(不能选到同一颗料)—— 墙,末尾那一个 describe。
*
* 三条口径由用户当场定死:
* 联动的三条口径由用户当场定死:
* ①**组内第一个填上值的那格是锚**,锚自己永远摆全量;锚粘着不让位 —— 把它清空,收窄整组解除
* (不把锚顺手交给另一格,否则「清空锚再改主意」当场被另一格锁住);整组都空了才重新认锚。
* ②未标类型的老料**列在末尾单独一组**,不是不列。
@@ -9,7 +11,7 @@
*/
import { describe, expect, it } from 'vitest';
import type { LaunchParam } from '@contracts';
import { comboFace, nextAnchors, staleLinked } from '../internal/launch-link.ts';
import { comboFace, distinctIssues, nextAnchors, staleLinked } from '../internal/launch-link.ts';
const OPTIONS = [
{ value: 'LM2596S-5.0', label: 'LM2596S-5.0', group: '1', groupLabel: 'DC-DC 降压稳压器' },
@@ -123,3 +125,65 @@ describe('staleLinked —— 锚换了类型,另一格里失配的值', () => {
expect(staleLinked(PARAMS, { originalInternalPn: 'AMS1117-3.3', candidateInternalPn: '' }, { 'chip-type': 'originalInternalPn' })).toEqual([]);
});
});
/**
* 互斥(2026-08-31 用户定:「不应该能选到同样的两个芯片」)—— 与联动是两件事:
* 那个是引导(收窄),**这个是墙**(别的格占了的候选这里不列;自己写一个同样的就按不下启动)。
* W05 那两格**两样都声明了**,所以最后一条专测合起来是什么样。
*/
describe('互斥:别的格占走的候选不列', () => {
const both = (key: string, label: string): LaunchParam => ({ ...combo(key, label), distinctGroup: 'part' });
const W05_PARAMS: LaunchParam[] = [both('originalInternalPn', '原内部料号'), both('candidateInternalPn', '候选内部料号')];
const w05Face = (key: string, values: Record<string, string>, anchors: Record<string, string> = {}) =>
comboFace(W05_PARAMS.find((p) => p.key === key)!, [...W05_PARAMS, LOOSE], values, anchors);
it('原料号选了 LM2596S-5.0 → 候选那格不再列它,并说清是谁占了', () => {
const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' });
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['XL2596S-5.0', 'AMS1117-3.3', 'OLD-001']);
expect(f.taken).toEqual([{ by: '原内部料号', value: 'LM2596S-5.0' }]);
});
it('**反过来也一样**(不分先后,谁占了谁的都不列)—— 候选先填,原料号那格就少它一条', () => {
const f = w05Face('originalInternalPn', { candidateInternalPn: 'XL2596S-5.0' });
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toEqual(['LM2596S-5.0', 'AMS1117-3.3', 'OLD-001']);
});
it('**自己此刻填着的那个永远留着** —— 抽掉正在显示的值,下拉当场变空白,人看着像被谁清空了', () => {
const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'LM2596S-5.0' });
expect(f.groups.flatMap((g) => g.options.map((o) => o.value))).toContain('LM2596S-5.0');
});
it('不在互斥组里的格子(没有 distinctGroup)谁也不管', () => {
const f = comboFace(LOOSE, [...W05_PARAMS, LOOSE], { originalInternalPn: 'LM2596S-5.0', customerCode: 'CU-0142' }, {});
expect(f.taken).toEqual([]);
expect(f.groups).toEqual([{ label: undefined, options: LOOSE.options }]);
});
it('**联动与互斥合起来**(W05 的真形状):按锚的类型收窄之后,锚占着的那颗照样不列', () => {
const f = w05Face('candidateInternalPn', { originalInternalPn: 'LM2596S-5.0' }, { 'chip-type': 'originalInternalPn' });
expect(f.narrow).toEqual({ by: '原内部料号', label: 'DC-DC 降压稳压器' });
// 收窄留下 LM / XL / OLD-001,互斥再抽掉锚占着的 LM
expect(f.groups.map((g) => [g.label, g.options.map((o) => o.value)])).toEqual([
['DC-DC 降压稳压器', ['XL2596S-5.0']],
['未标类型', ['OLD-001']],
]);
});
describe('distinctIssues —— 还能撞上的那条路(自己「新增」写了一个同样的)', () => {
it('点名**后填的那格**,说清撞上了谁', () => {
expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'BRAND-NEW', candidateInternalPn: 'BRAND-NEW' })).toEqual([
{ key: 'candidateInternalPn', by: '原内部料号' },
]);
});
it('去空白、不分大小写 —— 与服务端同一把尺', () => {
expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: ' lm2596s-5.0 ' })).toHaveLength(1);
});
it('两颗不同的、空的、不同组的 —— 一个都不算撞上', () => {
expect(distinctIssues(W05_PARAMS, { originalInternalPn: 'LM2596S-5.0', candidateInternalPn: 'XL2596S-5.0' })).toEqual([]);
expect(distinctIssues(W05_PARAMS, { originalInternalPn: '', candidateInternalPn: '' })).toEqual([]);
expect(distinctIssues([...W05_PARAMS, LOOSE], { originalInternalPn: 'CU-0142', customerCode: 'CU-0142' })).toEqual([]);
});
});
});
@@ -1,5 +1,8 @@
/**
* 启动参数的**按分组联动**(2026-08-31 用户定)—— 纯函数,不碰 React。
* 启动参数里**格与格之间**的那些事(2026-08-31 用户定)—— 纯函数,不碰 React。两件,别混:
* · **按分组联动**(`linkGroup`):按候选的分组键互相收窄 —— 引导,填了别的照收;
* · **互斥**(`distinctGroup`):同组的几格不能是同一个值 —— **墙**,别的格占了的候选这里不列,
* 自己写一个同样的值就按不下「启动活动」(真正拦住它的是服务端 `validateLaunchParams`)。
*
* 服务端下发的两样东西就是这里的全部依据:候选各自带 `group` / `groupLabel`(清单 `x-options.group`),
* 参数带 `linkGroup`(清单 `x-link`)。前端**不认识**「原内部料号」「芯片类型」这些名字 ——
@@ -13,16 +16,22 @@
* ②锚没有分组键(未标类型的老料)、或锚填的是库里没有的新值(新增料号)→ 不收窄。
* ③收窄之后,「未标类型」的候选**列在末尾单独一组**,不是不列。
*
* 服务端不据此立墙(`launch-params.ts` 头注):两格填不同类型照样收得下,类型一致归活动自己的收步判据管。
* **联动**这一件服务端不立墙(`launch-params.ts` 头注):两格填不同类型照样收得下,类型一致归活动自己的收步判据管;
* **互斥**那一件服务端真拦(同一把尺 `launchDistinctKey`)—— 这里少列几条只是不让人走到那一步。
*/
import type { LaunchOption, LaunchParam } from '@contracts';
import { launchDistinctKey } from '@contracts';
/** 组名 → 当下坐着锚位的那格字段名 */
export type LinkAnchors = Readonly<Record<string, string>>;
/** 一组候选(`label` 为空 = 这批候选没有分组键,摆一列不分组) */
export type OptionGroup = { readonly label: string | undefined; readonly options: readonly LaunchOption[] };
/** 这一格该摆什么:分好组的候选 + 被谁按什么类型收窄(没收窄就没有) */
export type ComboFace = { readonly groups: readonly OptionGroup[]; readonly narrow: { readonly by: string; readonly label: string } | undefined };
/** 这一格该摆什么:分好组的候选 + 被谁按什么类型收窄(没收窄就没有)+ 被别的格占走、这里不列的那几个 */
export type ComboFace = {
readonly groups: readonly OptionGroup[];
readonly narrow: { readonly by: string; readonly label: string } | undefined;
readonly taken: readonly { readonly by: string; readonly value: string }[];
};
const UNGROUPED = '未标类型';
@@ -95,22 +104,75 @@ function groupOptions(options: readonly LaunchOption[]): readonly OptionGroup[]
return out;
}
/** 这一格摆哪些候选、被谁收窄 —— 锚自己与不联动的格子都摆全量 */
/** 同一互斥组里**别的格**此刻占着的值(空的不算);自己那格不算自己占的 */
function takenByOthers(
param: LaunchParam,
params: readonly LaunchParam[],
values: Readonly<Record<string, string | readonly string[]>>,
): readonly { readonly by: string; readonly value: string }[] {
const group = param.distinctGroup;
if (group === undefined) return [];
const out: { by: string; value: string }[] = [];
for (const other of params) {
if (other.key === param.key || other.distinctGroup !== group) continue;
const value = valueOf(values, other.key);
if (value !== '') out.push({ by: other.label, value });
}
return out;
}
/** 这一格摆哪些候选、被谁收窄 —— 锚自己与不联动的格子都摆全量;别的格占走的一律不列 */
export function comboFace(
param: LaunchParam,
params: readonly LaunchParam[],
values: Readonly<Record<string, string | readonly string[]>>,
anchors: LinkAnchors,
): ComboFace {
const all = param.options ?? [];
const taken = takenByOthers(param, params, values);
const mine = launchDistinctKey(valueOf(values, param.key));
const takenKeys = new Set(taken.map((t) => launchDistinctKey(t.value)));
/*
* 互斥:别的格占了的候选这里不列 —— **唯独自己此刻填着的那个永远留着**。
* 抽掉正在显示的值,`<select>` 当场变空白,人看着像被谁清空了;真撞上了该说的是那句话
* (`distinctIssues` → 小红字 + 按不下启动),不是让画面自己变魔术。
*/
const all = (param.options ?? []).filter((o) => launchDistinctKey(o.value) === mine || !takenKeys.has(launchDistinctKey(o.value)));
const face = (options: readonly LaunchOption[], narrow: ComboFace['narrow']): ComboFace => ({ groups: groupOptions(options), narrow, taken });
const group = param.linkGroup;
const seated = group === undefined ? undefined : anchors[group];
if (group === undefined || seated === undefined || seated === param.key) return { groups: groupOptions(all), narrow: undefined };
if (group === undefined || seated === undefined || seated === param.key) return face(all, undefined);
const anchor = params.find((p) => p.key === seated);
const by = anchor === undefined ? undefined : anchorGroup(anchor, valueOf(values, seated));
if (anchor === undefined || by === undefined) return { groups: groupOptions(all), narrow: undefined };
if (anchor === undefined || by === undefined) return face(all, undefined);
// 收窄:同分组键的 + 未标类型的(殿后)—— 「新增」那条路由控件自己一直留着
return { groups: groupOptions(all.filter((o) => o.group === by.key || o.group === undefined || o.group === '')), narrow: { by: anchor.label, label: by.label } };
return face(all.filter((o) => o.group === by.key || o.group === undefined || o.group === ''), { by: anchor.label, label: by.label });
}
/**
* 撞了同一个值的那几格 —— 点名**后填的那格**(表单次序在后的),`by` 是它撞上了谁。
* 与服务端 `distinctIssuesOf` 同一条规矩、同一把尺(`launchDistinctKey`):这里管「按不下启动」,
* 那里管「拦住」—— 两道都要,既不让人填完一屏才被打回来,也不指望前端自觉。
*
* 下拉里已经不列别人占的值了,还会撞上的只剩两条路:自己「新增」写了一个同样的、或者这格压根是自由输入。
*/
export function distinctIssues(
params: readonly LaunchParam[],
values: Readonly<Record<string, string | readonly string[]>>,
): readonly { readonly key: string; readonly by: string }[] {
const out: { key: string; by: string }[] = [];
const seenPerGroup = new Map<string, Map<string, LaunchParam>>();
for (const param of params) {
const group = param.distinctGroup;
if (group === undefined) continue;
const key = launchDistinctKey(valueOf(values, param.key));
if (key === '') continue;
const seen = seenPerGroup.get(group) ?? new Map<string, LaunchParam>();
seenPerGroup.set(group, seen);
const first = seen.get(key);
if (first === undefined) seen.set(key, param);
else out.push({ key: param.key, by: first.label });
}
return out;
}
/**
@@ -21,6 +21,7 @@ import {
authTicketRoute,
controlTaskRoute,
disableOpsActivityRoute,
launchDistinctMessage,
launchOpsInstanceRoute,
meRoute,
reassignTaskRoute,
@@ -52,7 +53,7 @@ import {
useOpsLaunchParams,
} from '@web-shared/gov/gov-api.ts';
import type { OpsInstance, OpsInstanceQuery } from '@web-shared/gov/gov-api.ts';
import { comboFace, nextAnchors, staleLinked } from './launch-link.ts';
import { comboFace, distinctIssues, nextAnchors, staleLinked } from './launch-link.ts';
import type { ComboFace, LinkAnchors } from './launch-link.ts';
export type HomeProps = { tab: string; nav: Nav };
@@ -449,7 +450,14 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
return next;
});
};
const invalid = specQ.data === undefined || ownerId === '' || spec.some((p) => p.required === true && !paramFilled(p, values[p.key] ?? (p.type === 'list' ? [] : '')));
/* 互斥(2026-08-31 用户定:「不应该能选到同样的两个芯片」):同组两格填了同一个值 —— 点名后填的那格,启动按不下去。
下拉里已经不列别人占的值,还能撞上的只剩「新增」自己写的那条路;真正拦住它的是服务端(脚本 / API 一样过不去) */
const dupeOf: Readonly<Record<string, string>> = Object.fromEntries(distinctIssues(spec, values).map((d) => [d.key, d.by]));
const invalid =
specQ.data === undefined ||
ownerId === '' ||
Object.keys(dupeOf).length > 0 ||
spec.some((p) => p.required === true && !paramFilled(p, values[p.key] ?? (p.type === 'list' ? [] : '')));
const launch = useMutation({
mutationFn: () => {
@@ -529,7 +537,10 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
</button>
</Note>
)}
{spec.map((p) => (
{spec.map((p) => {
/* combo 的面孔(候选摆什么 · 被谁按类型收窄 · 别的格占走了哪几个)一格算一次 —— 下面几处都读它 */
const face = p.type === 'combo' ? comboFace(p, spec, values, anchors) : undefined;
return (
<label key={p.key} className="g-fld">
<span>
{p.label}
@@ -553,7 +564,7 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
))}
</select>
)}
{p.type === 'combo' && <ComboInput p={p} value={(values[p.key] as string) ?? ''} face={comboFace(p, spec, values, anchors)} onChange={(v) => setVal(p.key, v)} />}
{p.type === 'combo' && face !== undefined && <ComboInput p={p} value={(values[p.key] as string) ?? ''} face={face} onChange={(v) => setVal(p.key, v)} />}
{p.type === 'list' && (
<span className="g-lplist">
{((values[p.key] as string[]) ?? ['']).map((v, i, arr) => (
@@ -582,19 +593,21 @@ function LaunchDrawer({ activityId, name, onCancel, onLaunch }: { activityId: st
)}
{p.desc !== undefined && <small>{p.desc}</small>}
{/* 联动(2026-08-31):被收窄的那格要说清是按谁的什么收的,以及怎么换 —— 悄悄少几条候选比不收窄更糟 */}
{p.type === 'combo' &&
(() => {
const narrow = comboFace(p, spec, values, anchors).narrow;
return narrow === undefined ? null : (
<small>
已按「{narrow.by}」收窄:只列同为「{narrow.label}」的候选(库里没标的在末尾一组),要别的就回去改「{narrow.by}」,或在这里新增{p.label}。
</small>
);
})()}
{face?.narrow !== undefined && (
<small>
已按「{face.narrow.by}」收窄:只列同为「{face.narrow.label}」的候选(库里没标的在末尾一组),要别的就回去改「{face.narrow.by}」,或在这里新增{p.label}。
</small>
)}
{/* 互斥(2026-08-31 用户定):别的格占了的候选这里不列 —— 少了几条同样要说出来,同一条道理 */}
{face !== undefined && face.taken.length > 0 && (
<small>{face.taken.map((t) => `「${t.by}」已经选了 ${t.value}`).join('、')} —— 这一格不再列它,两格不能是同一个。</small>
)}
{dupeOf[p.key] !== undefined && <small className="warn">{launchDistinctMessage(dupeOf[p.key] ?? '')}</small>}
{linkNote[p.key] !== undefined && <small className="warn">{linkNote[p.key]}</small>}
{fieldErr[p.key] !== undefined && <small className="warn">{fieldErr[p.key]}</small>}
</label>
))}
);
})}
{err !== undefined && <Note tone="warn">{err}</Note>}
</section>
</div>
+7 -1
View File
@@ -386,6 +386,7 @@ window.addEventListener('message', (e) => {
"desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。",
"placeholder": "LM2596S-5.0",
"linkGroup": "chip-type",
"distinctGroup": "part",
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0", "group": "1", "groupLabel": "DC-DC 降压稳压器" }]
},
{
@@ -396,6 +397,7 @@ window.addEventListener('message', (e) => {
"desc": "被评估物料;库里有就直接选,没有就自己填料号,启动后经数据工单创建。",
"placeholder": "XL2596S-5.0",
"linkGroup": "chip-type",
"distinctGroup": "part",
"options": [
{ "value": "LM2596S-5.0", "label": "LM2596S-5.0", "group": "1", "groupLabel": "DC-DC 降压稳压器" },
{ "value": "AMS1117-3.3", "label": "AMS1117-3.3", "group": "2", "groupLabel": "LDO 线性稳压器" },
@@ -416,7 +418,11 @@ W05 的候选内部料号(2026-08-27 用户定)与原内部料号**同款下拉
> **这是表单的引导,不是墙**:服务端不据 `linkGroup` 校验 —— 两格填不同 `group` 的值照样收得下(combo 本就允许候选之外的值)。类型一致性由活动自己的收步判据管(W05 是 S1 的 `sub_eval_part_distinct_chip_type_id = 1`)。
> 活动清单侧怎么声明(前端不用管,给写活动包的人看):`properties.<key>` 上 `title`→label、`description`→desc、`examples[0]`→placeholder、`default`、`required[]`;`enum`+`enumNames`→select;`'x-control'` 定标量控件(`text|textarea|select|combo`);`'x-options': { ledger:'域.对象', value:'列', label:'列' }` 让候选从正式台账现读;`'x-options'.group` 让候选带上分组键 —— 只写列名(`'chip_type_id'`)= 有键没名,整份写 `{ column, ledger, value, label }` 再从字典表换人读名;`'x-link': '<组名>'` 把几格结成联动组(同组按分组键互相收窄,见上);`type:'array'` 且元素是字符串→list,**数组字段标 `x-control: combo|select` 就摆成单选**(提交单个字符串,后端包成一元数组);**`'x-order': <数字>` 定表单次序**(清单存在 jsonb 里,书写顺序到不了这里);`format:'file'` 的字段本期不上表单;select 没候选降成 text。
**互斥(2026-08-31 用户定:「不应该能选到同样的两个芯片」)**:参数可带 `distinctGroup`(互斥组名)。**同一 `distinctGroup` 的几格不能填同一个值** —— 前端把**别的格此刻占着的候选**从这一格的下拉里抽掉(唯独这一格自己填着的那个永远留着,否则 `<select>` 会当场变空白),并在控件下说清是谁占了哪个;人若走「新增」自己写了一个同样的值,小红字出来、「启动活动」按不下去。**「算不算同一个」= 去空白 + 不分大小写**(契约的 `launchDistinctKey`,前后端同一把尺;那句话是 `launchDistinctMessage`)。只管标量格(text / select / combo),数组格不参与。
> **这一条是墙,与 `linkGroup` 正相反**:服务端 `POST …/instances` 独立校验,撞上就是 400 `invalid`、`detail=[{key,message}]` 点名后填的那格 —— 脚本 / API 绕过表单一样过不去。为什么非拦不可:W05 两格填同一颗料会把单子**做死** —— `sub_eval.part` 的主键是 `eval_ref + internal_pn`,配方铸的两行撞成同一个主键、后一笔覆盖前一笔,两行收成一行,S1 的收步判据 `sub_eval_part_exists_status_found = 2` 永不成立,而件上那两行人加不出来,唯一出路是终止重开。**墙立在起单这一刻,是因为路只在这里**(那时换一格就好了)。
> 活动清单侧怎么声明(前端不用管,给写活动包的人看):`properties.<key>` 上 `title`→label、`description`→desc、`examples[0]`→placeholder、`default`、`required[]`;`enum`+`enumNames`→select;`'x-control'` 定标量控件(`text|textarea|select|combo`);`'x-options': { ledger:'域.对象', value:'列', label:'列' }` 让候选从正式台账现读;`'x-options'.group` 让候选带上分组键 —— 只写列名(`'chip_type_id'`)= 有键没名,整份写 `{ column, ledger, value, label }` 再从字典表换人读名;`'x-link': '<组名>'` 把几格结成联动组(同组按分组键互相收窄,见上);`'x-distinct': '<组名>'` 把几格结成互斥组(同组不能填同一个值 —— **这条是墙**,见上);`type:'array'` 且元素是字符串→list,**数组字段标 `x-control: combo|select` 就摆成单选**(提交单个字符串,后端包成一元数组);**`'x-order': <数字>` 定表单次序**(清单存在 jsonb 里,书写顺序到不了这里);`format:'file'` 的字段本期不上表单;select 没候选降成 text。
## 5. 启动活动(创建实例)—— `POST /api/gov/activities/:activityId/instances`(`launchOpsInstanceRoute`)
+89
View File
@@ -1718,3 +1718,92 @@ B 表却**不在**同一份 `only` 名单里——B 表对应的那一行仍留
**排查法留一条**:「配方办不下去」类的 `AutoRecipeError`,若原文提到「已入库/已落地…撤不掉」,
先看是不是这条——尤其当那一步的 `project` 动作带 `prune: true`、且它写向的表**也**在
某张数据工单的 `gateLedgers` 上时,概率最高。
---
## ~~人答得太快,那条回传被判 `stale`,单子就此没人叫醒~~(2026-08-31 夜发现 → **2026-09-01 已修**)
**已修**:`task-service.deliver` 判了 stale 之后**照旧不跑这一拍**(stale 的本意不变),
但把停着的单子拨回 `running` 交给推进器 —— 三样凑齐时(卡答完了、没有新卡在等、单子停着)
再没有第三样东西会叫醒它,补的就是最后这一下。真没事可干,它自己再停回去(代价一拍空转)。
`deliver-stale.test.ts` 两条钉死(停着的拨回 running / 本来就在跑的一个字不动),变异验证过。
下面是当时的诊断,原样留着 —— 指纹与病理都对。
**实测指纹**(e2e w05 案例 0,ACT-WORK-05):
```
23:53:39.778 card-sent 逐条差异结论已备好…(S3 审核卡)
23:53:40.048 card-reply 人答了(重做,带意见) ← 卡发出去 270ms 后
23:53:40.274 last_progress_at 被更新 ← **发卡那一拍自己**跑完了
此后 4 分钟一拍没跑,
runtime 停在 awaiting-event
```
后来手工把 `runtime` 拨回 `running`,同一份代码立刻把该走的三拍走完(重做那一轮的新卡也发了)
—— 也就是说**逻辑是对的,只是没有任何东西再叫醒它**。
**病根**:2026-08-27 那条 stale 守卫的前提是
「`lastProgressAt > occurredAt` ⇒ 这条回传一定已被那一拍消费(留痕先于事件落)」。
人答得比发卡那一拍收尾还快时,这个前提就不成立:**把 `lastProgressAt` 往前挪的那一拍,
正是发出这张卡的那一拍** —— 它不可能消费一条当时还不存在的回传。
于是两件事叠在一起把单子锁死:
- 回传路由的 `wake`(2026-08-25)先把任务拨成 `running`,可**发卡那一拍随后收尾时又把它
park 回 `awaiting-event`**(晚到的写覆盖了早到的 wake);
- 人事件随后到达,`deliver` 按 `lastProgressAt > occurredAt` 判 stale,「只记到达痕不推」。
再没有第三样东西会叫醒它。
**为什么少见**:真人点一张卡至少要几秒,发卡那一拍早收尾了;e2e 是 3 秒轮询 + 秒答,
才把这条窗口撞得出来。但 CLAUDE.md 里几次「怎么推不动了」的报告有可能是同一条。
**要治**(方向,未验证):park 那一下要认「这一拍开跑之后有没有人叫醒过我」——
比如 `wake` 记一个时刻/世代号,tick 收尾时的 `update` 带上 `where last_wake_at <= <本拍开跑时刻>`;
或者 tick 收尾前再看一眼「有没有新的未消费回传」,有就留在 `running`。
**别去放宽 stale 守卫本身** —— 它治的是另一个真病(晚到十几秒的事件把没人答的卡收掉,
2026-08-27 实测),两件事不要混。
---
## 审核卡上的「本轮结论」在重判之后会过期(2026-09-01,未修)
**实测**(柴浩那一单 f14d94de):两张审核卡的 `本轮结论` 都写着
「共 7 项核心参数:0 项一致 / 5 项硬约束不符 / 2 项软约束不符」,而第二张发出去的那一刻
草稿里已经是 **1 项一致 / 5 硬 / 1 软**(重判把一条从 soft_mismatch 翻成了 match)。
卡上那一行说的是上一轮的数。
**根因不是 bug,是两条正确规矩撞在一起**:
- 卡的 `本轮结论` 取自 `substitution.relation.difference_summary`;
- 而 S3 用的是 `RELATION_RECOMPUTE`(只重算 `recommendation_level`),**故意不碰**收口四格
(推荐理由 / 差异摘要 / 风险 / 验证要求)—— 2026-08-31 用户定「这四格人可以就地改」,
每跑一次就按规则重写会把人写的话冲掉。
于是:等级会跟着重判变(判据核心那条链没断),**摘要这句话不会**。
**要治**(方向,未验证):
- 卡面那一行改成**按基座读数当场拼**(不读 `difference_summary`)—— 但拼法与
`RELATION_PROJECTION.difference_summary` 会成为两处真源,得先把拼法抽成一处;
- 或者给 `difference_summary` 一个「人动过没有」的记号:人没改过就随重判重写,改过就留着。
记号本身要是基座数得出来的(别又变成模型自报)。
**先别做的理由**:它只影响卡上那一句话,件上逐条明细与等级都是真的;而两种治法都要动
「人可改的格子」这条已经踩过坑的边界(2026-08-31:「人改完一保存就被冲掉」),值得单独一轮。
---
## 人自己打回引出的那张卡,不该算作「打扰」(2026-09-01,未修 —— 先用预算兜着)
**实测**(柴浩那一单 `INS-80`):S3 结论审核连打回四次,每打回一次配方就重发一张审核卡、
每张卡 +1 `humanCalls`,第八次触顶 → 任务落 `exhausted`。屏上看着像**「最后落账那一步直接没了」**
—— 其实是单子先被烧死了,放行那一步再也没机会出现。
**为什么这笔账记错了**:调人预算的计量对象是**打扰**(「每发一张卡就是调了一次人」)。
而打回之后重发的那一张,**是他自己叫的** —— 他按下「重做」,要的就是「改完再问我一遍」。
把它算成一次打扰,等于对「你要求再看一遍」这件事收费,而这恰恰是审核循环存在的意义。
**先做的**:W05 的 `humanCalls` 8 → 24(配置,`manifest.ts` 那一行有注释)。够二十来轮打回。
**要治**(方向,未验证):发卡那条路上分「主动叫人」与「应他要求再问一遍」——
`CardGateway.issue` 多一个 `reask` 之类的意思,`assertBudget` 与 `addConsumption` 对它都不计;
判据由引擎给(`AutoContext.redoNote !== undefined` 那一轮发的卡就是再问一遍),不由活动自报。
**别把预算这道门整个拆了** —— 它拦的是「AI 一遍遍叫人」,那件事照旧要拦。
+12 -1
View File
@@ -326,13 +326,24 @@ W05 的两张数据工单(人填的值已入库、必填不缺、逐颗对账)
|---|---|
| `project` | 从某个来源的每一行投影出台账行,落草稿层。带 `prune` 时,这一笔投影出来的行**就是这张表在本单草稿里的全部** |
| `analyze` | **一次结构化 AI 调用**:系统组装入参、声明出参 schema、系统把结果落表。不给工具、不给记忆、不多轮 |
| `card` | 发一张卡(执行工单 / 数据工单 / 决策卡)。**至多一张**,发过就不再发 |
| `card` | 发一张卡(执行工单 / 数据工单 / **验收工单** / 决策卡,`variant` 定面孔)。**至多一张**,发过就不再发 |
| `submit` | 提交放行(只许出现在声明了人工验收清单的那一步) |
| `say` | 一句给人看的交代 |
地址只有四种:`域.对象`(任务视图 = 正式 + 本单草稿叠加,**默认该用的那一种**)· `mine:域.对象` ·
`in:入参数组` · `result:xxx`(本拍 analyze 的出参)。
**「至多一张」是把两条路写成两笔的前提**(2026-08-31,W05「数据齐了也叫人一次」):同一步里
摆两笔条件互斥的 `card`(缺东西 → 数据工单;都齐了 → 只读的验收工单),引擎的幂等保证一步
只发一张 —— 前一张发过之后,后一笔永远轮不到,人不会被叫第二次。
**这一步的活可能本来就干完了,而你仍想叫人过目一次 —— 那就把这一步标 `manualActivation`。**
引擎在拍首先扫一遍 `doneWhen`,为真就自己收步:输入本来就齐的单,那一步的 `doneWhen`
第一拍就为真,**配方一笔都还没跑**,步骤当场被收掉,卡永远发不出去。标了人工激活,那道扫描
多一个门槛(`worldTouched` —— 卡真发过),次序就变成「配方发卡 → 人答 → 下一拍 `doneWhen`
为真才收」。`doneWhen` 照旧保留,它仍是这一步的真门,只是不再抢在人前面。
(引擎测钉着这一对:`turn-runner-acceptance.test.ts`「auto 步:配方在拍首跑」那一组。)
**guide 可以留空,但只有声明了 auto 才行**:`planStepSchema` 原来要求 `guide` 非空
(`.min(1)`)—— 那是防「作者忘了写做法」的老规矩,可它顺手把「这一步一次都不问模型」堵死了。
现在改成 `superRefine`:没声明 `auto` 的步照旧非空;声明了 `auto` 的步允许 `guide` 写空串
+27
View File
@@ -200,9 +200,36 @@ export const launchParamSchema = z.object({
* 服务端**不据此立墙** —— combo 本就收候选之外的值,类型一致性归活动自己的收步判据管。
*/
linkGroup: z.string().optional(),
/**
* **互斥组名**(清单 `x-distinct`,2026-08-31 用户定)—— 同一组的几格**不能填同一个值**:
* 别的格已经占了的候选**不列**在这一格里(前端),自己写进来也拒(服务端 `validateLaunchParams`)。
*
* 与 `linkGroup` 是两件事,别混:那个是引导(收窄候选,填了别的照收),**这个是墙**。
* W05 两格填同一颗料会把单子做死 —— `sub_eval.part` 的主键是 `eval_ref + internal_pn`,
* 配方铸的两行撞成同一个主键,后一笔覆盖前一笔,两行收成一行:`sub_eval_part_exists_status_found = 2`
* 永不成立、S1 收不了步,而件上那两行人加不出来(行源是 `mine:sub_eval.part`,没有「+加一行」)——
* 唯一出路是终止重开。**墙立在起单这一刻,是因为路只在这里**:那时改一格就好了。
*
* 只管**标量格**(text / select / combo);数组格声明了也不算 —— 本期没有这种形状,真要了再补。
*/
distinctGroup: z.string().optional(),
});
export type LaunchParam = z.infer<typeof launchParamSchema>;
/**
* 「算不算同一个」的归一:去空白 + 不分大小写 —— **前后端同一把尺**。
* 大小写要归:料号这类身份不因字母大小写而不同,只挡精确相等的话,`lm2596s-5.0` 会照样进去,
* 混一颗垃圾主数据出来,人还得到一份自己跟自己比的评估。
*/
export function launchDistinctKey(value: string): string {
return value.trim().toLowerCase();
}
/** 撞上了那一格的话 —— 前端(填的当口)与服务端(400 的 detail)同一句,不许分叉 */
export function launchDistinctMessage(otherLabel: string): string {
return `与「${otherLabel}」填的是同一个 —— 这两格不能一样,换一个再启动`;
}
export const launchParamsSchema = z.object({
activityId: z.string(),
version: z.string(),
+13
View File
@@ -1060,6 +1060,19 @@ export const testActivityStateRoute = {
z.object({
id: z.string(),
mode: z.enum(['execution', 'acceptance']),
/**
* 执行工单的**面孔**:`data` 数据工单 / `accept` 验收工单(2026-08-31 补)。
* 观测面得看得见这张卡是以什么身份发出去的 —— 面孔在装配层那一处决定,
* 而它此前恰恰因为「配方那头与前端那头都有测、中间没有」漏过一次
* (配方递 `accept`,装配层写死只认 `data`,人收到的是「执行工单」)。
*/
variant: z.enum(['data', 'accept']).optional(),
/**
* 验收卡**打回得了吗**(2026-09-01 补)。这一路值被静默丢过一次:装配层算对了、
* release 转对了、契约也声明了,到容器那个**手写字段清单**的显式对象里被抹平
* (`fields` / `blocks` 之后的第三次)—— 断言必须钉在**最末端**,才拦得住这类丢值。
*/
redoable: z.boolean().optional(),
title: z.string(),
done: z.boolean(),
result: z.string().optional(),
+6 -2
View File
@@ -128,7 +128,11 @@ export type AutoAction =
readonly kind: 'card';
readonly when?: string | undefined;
readonly as: 'execution' | 'decide';
readonly variant?: ('data' | 'review') | undefined;
/**
* 卡的面孔:`data` 数据工单 / `accept` 验收工单(都是执行工单的变体)· `review` 审核卡(决策卡的)。
* 不写就是普通执行工单 / 普通决策卡。
*/
readonly variant?: ('data' | 'review' | 'accept') | undefined;
readonly title: AutoExpr;
/** 决策卡问的那句话 */
readonly ask?: AutoExpr | undefined;
@@ -177,7 +181,7 @@ export const autoActionSchema: z.ZodType<AutoAction> = z.discriminatedUnion('kin
kind: z.literal('card'),
when: z.string().min(1).optional(),
as: z.enum(['execution', 'decide']),
variant: z.enum(['data', 'review']).optional(),
variant: z.enum(['data', 'review', 'accept']).optional(),
title: autoExprSchema,
ask: autoExprSchema.optional(),
guide: z.array(z.string().min(1)).optional(),
+19 -1
View File
@@ -344,8 +344,14 @@ export const executionWorkOrderSchema = z.object({
* 区别只在**它要人办的是「把一张主数据表建齐」**(件上的表就是要建的记录,确认即入库),
* 所以对话区要长成自己的样子(色/图标/「数据工单」四字/「确认写入」),不能再顶着「执行工单」的牌子。
* 活动在卡骨架上写 `"variant":"data"`,归一层原样带下来;不写就是普通执行工单。
*
* **`accept` = 验收工单**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个
* 验收工单…把芯片卡片上的编辑按钮去掉,只展示不能编辑」)—— 同样是执行工单的变体:件上没有
* 要填的格子,人只核对、确认无误。**与 `mode: 'acceptance'` 不是一回事**:那个是 `submit`
* 抬起来的**收口验收**(签下去就 releaseWrite、发口、落终态,带打回重做那一路);
* 这个只是「这一步的活干完了,请你过目」,回传即办结,一步都不放行。
*/
variant: z.enum(['data']).optional(),
variant: z.enum(['data', 'accept']).optional(),
ask: z.string(),
standard: z.string().optional(),
steps: z.array(z.string()).optional(),
@@ -510,6 +516,18 @@ export const acceptanceWorkOrderSchema = z.object({
})
.optional(),
passLabel: z.string().optional(),
/**
* **这张卡打回得了吗**(2026-09-01 用户定:「评审放行与落账确认这一步的验收卡不应该有重做的选项,
* 因为这里都没有接入 ai」)。不写 = 打得回(老行为);写 `false` = **打回之后没有任何东西能接住它**,
* 那颗钮就不该摆在那儿。
*
* 什么时候是 `false`:这一步整个由配方跑完、一个字的 `guide` 都没有(不问模型),而件又是只读的 ——
* 打回之后既没有模型会去读那句意见,人也没有格子可改,配方只会原样再提交一次、撞在
* 「打回之后台账没动过就不许再提交」那道闸上,最后落成一张「连续撞在同一条判据上」的兜底卡。
* **一颗按下去只会把单子推进死胡同的钮,不如不摆**(墙要配路,没有路就别立那扇门)。
* 有异议的人还有真路可走:别签,去把这一单终止掉重开。
*/
redoable: z.boolean().optional(),
});
export type AcceptanceWorkOrder = z.infer<typeof acceptanceWorkOrderSchema>;
+179 -8
View File
@@ -593,14 +593,35 @@ const SUITES = {
address: 'parts.profile',
blankCol: 'brand_name',
stages: [
{
/*
* **验收工单**(2026-08-31 用户定:「就算数据是足的也进入这个数据工单,就派发一个验收工单」)——
* 输入本来就齐的单走这一张:同一份件、同一批数据,只是件只读、卡角编辑钮整组收起。
* 没有 `edits`(件上没有可填的格子),`buildEdits` 算出来是空 → 一次保存都不发,直接确认。
*/
kind: 'accept',
match: /验收工单 · 差异评估的输入核对/,
address: 'parts.profile',
rowKey: 'internal_pn',
blankCol: 'brand_name',
edits: [],
},
{
// 唯一的数据工单:一张卡摆三块内容,写向四张表(主表 parts.profile,
// extraTables 里另外三张)——四张表随同一次「保存」一并发出,不分次交。
kind: 'data',
match: /差异评估的输入还差几格/,
address: 'parts.profile',
rowKey: 'internal_pn',
blankCol: 'brand_name',
expectGateBlock: true,
/*
* **确认闸的条件档必须跟到工单载荷上**(2026-09-01 用户实测:「两个芯片类型不一样…
* 卡片还可以点击下一步」)。配方声明了 `gateWhen`,发卡装配的手写清单没点它的名 ——
* 落库载荷里是 undefined,按钮禁用与服务端拒收一起无声失效(手写字段清单第四次丢值,
* 与 variant 上次被丢的是同一段)。这类丢值只有末端断言拦得住:卡一到手就查载荷。
*/
expectCardGateWhen: true,
edits: [],
extraTables: [
/*
@@ -623,11 +644,34 @@ const SUITES = {
sample: 0,
name: '候选缺库 · XL2596S-5.0 建档并选已有类型(type_1,2026-08-31 起本活动只选不建)'
+ '· 审核先点名意见驳回再通过',
decideSeq: ['custom:使能阈值那条 UNKNOWN 的去向请写明验证任务号与测试条件', 'approve'],
/*
* **点名要一个结论**(2026-09-01 用户第二次实测:「重做还是没有修改掉内容」——
* 他写「静态电流结论改成不匹配」,AI 搬出「硬约束不因措辞改判」把他顶了回去)。
* 审核人是这一步的责任人:他点名要什么结论就该落什么。这条意见既点了名(使能阈值)
* 又点了结论(conditional),`expectRedoSetsStatus` 收工时逐字核。
*/
decideSeq: ['custom:使能阈值那条不是判不了,改成 conditional(有条件)—— 补一次实测就能用', 'approve'],
expectRedoSetsStatus: { address: 'substitution.difference_item', nameCol: 'parameter_name', param: '使能阈值', column: 'difference_status', equals: 'conditional' },
/*
* 复核完得在对话区说清「按你的意见改了哪一条、改成什么」(2026-09-01 用户:
* 「需要把 ai 的修改意见通过右边 ai 聊天窗口展示出来,不然都不知道发生了什么」)。
* 那句话必须**排在卡之前**才赶得上人看卡 —— 排在卡后面的 say 要等下一拍才跑。
*/
expectRedoNarration: '按你的意见复核了',
/*
* **放行那张验收卡不许还摆着重做**(2026-09-01 用户:「怎么还有重做??你没改上吗?」)——
* 那一步整个由配方跑完,打回之后没有模型会读那句意见。这个值在链上被静默丢过一次
* (容器那个手写字段清单),所以断言钉在**最末端**:卡真发出去之后它还在不在。
*/
expectReleaseNoRedo: true,
expectDecision: true,
expectPort: 'assessment-released',
// 全套只该有一次模型调用(S2 唯一一次 analyze)—— S3 审核循环走的是纯投影重算,不重问模型
expectAnalyzeCalls: 1,
/*
* **两次**:S2 第一轮判 + S3 被打回那一轮的复核重判(2026-09-01 用户定:
* 「重做后要把原因带回去,重新让 AI 重跑这一步」)。这条案例正是「先点名驳回、再通过」——
* 它同时钉住:意见真交回给了 AI(不是只把派生列重算一遍),而没被打回的案例仍然只调一次。
*/
expectAnalyzeCalls: 2,
/*
* **真走「新建芯片类型」这条路**——但**显式给 `type_id`,不靠 `autoKeys()` 续号**
* (2026-08-30,run8 撞见的平台侧发现,已报未改,见任务报告「发现的问题 · 平台侧」):
@@ -691,6 +735,16 @@ const SUITES = {
* 这一趟往返没被揉坏——是内容正确性检查,不是归属检查。
*/
expectFinalValue: { address: 'substitution.relation', where: 'candidate_internal_pn', equals: 'XL2596S-5.0', column: 'recommendation_level', contains: 'conditional' },
/*
* **打回只许动被点名的那几条**(2026-09-01 用户实测:「我打回让修改工作温度的结论,
* 给我改了静态电流的结论」)。这条意见点名的是「使能阈值」——
* 交回之前先把明细存一份,收工时逐行比:名字没出现在意见里的行,四态与三段叙述一个字都不许变。
*/
expectRedoTouchesOnlyNamed: {
address: 'substitution.difference_item',
nameCol: 'parameter_name',
cols: ['difference_status', 'impact_statement', 'evidence_note', 'unknown_disposition'],
},
/*
* **跨案例隔离**(2026-08-30,协调者裁定①)—— 这一例为了让 `distinct=1` 成立,
* 把**原 PN(LM2596S-5.0,案例 1/2 也在用它)**转挂到新建的 type_id=90;这一改
@@ -735,9 +789,15 @@ const SUITES = {
},
{
sample: 1,
name: '都在库 · 两颗同型且齐备 · 零数据卡直接进 S2 分析 · 审核一次通过',
// S1 的 doneWhen 起手就成立(两颗都在库、同型、参数不缺)—— 断言一张数据工单都不发
name: '都在库 · 两颗同型且齐备 · 派只读的验收工单交人核对(不是数据工单)· 审核一次通过',
/*
* 2026-08-31 用户定「数据齐了也叫人一次」之前,这一例是「零卡直分析」——
* S1 的 doneWhen 起手就成立,引擎在拍首把这一步收掉,人一眼没看过就进了 S2。
* 现在 S1 标了人工激活:doneWhen 不再抢在人前面,配方改派一张**只读的验收工单**。
* 两条断言一起钉:数据工单一张都不许有(没东西要填)· 验收工单必须出现过(不许再跳过人)。
*/
expectNoDataCard: true,
expectAcceptCard: true,
decide: 'approve',
expectPort: 'assessment-released',
expectAnalyzeCalls: 1,
@@ -1287,6 +1347,9 @@ async function runCase(suite, kase) {
let fileCheck;
let gateLeaked = false;
const gateChecked = new Set();
/** 确认闸的条件档(gateWhen)没跟到工单载荷上 —— 类型不一致将拦不住(末端断言,见 w05 数据卡阶段) */
let gateWhenMissing = false;
let gateWhenChecked = false;
let decisions = 0;
missedRows.clear();
asked.length = 0;
@@ -1311,8 +1374,16 @@ async function runCase(suite, kase) {
let bailed = false;
let receiptChecked = false;
let receiptMismatch = false;
/** w05「都在库·零数据卡直分析」:任意一张匹配 stages 的执行卡出现过(不论此刻答没答)就算见过 */
/** w05:匹配**数据工单**那一段的执行卡出现过(不论此刻答没答)就算见过 */
let dataCardSeen = false;
/** w05「都在库」:匹配**验收工单**那一段的执行卡出现过 —— 数据齐了也要叫人一次 */
let acceptCardSeen = false;
/** 打回之前的明细快照(`expectRedoTouchesOnlyNamed`)—— 只存第一份 */
let redoBefore;
/** 放行前的最后一份明细快照 —— 放行会清空草稿层,收工时取不到 */
let redoAfter;
/** 那张卡的**面孔**不对(标题是验收工单,发出去却是执行工单)—— 2026-08-31 真撞过一次 */
let acceptFaceWrong = false;
/** w05「类型不一致·硬拦」:`sub_eval.case.gate_reason` 命中过 `kase.expectGateReason` 那个词 */
let gateReasonMatched = false;
/** `kase.customFill` 只跑一次(w05「候选缺库·新建类型」:两阶段写 —— 先铸 type_id,读回来再引用它) */
@@ -1394,7 +1465,23 @@ async function runCase(suite, kase) {
}
// w05「都在库·零数据卡直分析」:S1 的 doneWhen 起手就成立,断言一张数据工单都没出现过
if (kase.expectNoDataCard && !dataCardSeen) {
dataCardSeen = (body.cards ?? []).some((c) => (suite.stages ?? []).some((s) => s.match.test(c.title ?? '')));
dataCardSeen = (body.cards ?? []).some((c) => (suite.stages ?? []).some((s) => s.kind !== 'accept' && s.match.test(c.title ?? '')));
}
if (kase.expectAcceptCard && !acceptCardSeen) {
/*
* **认面孔,不认标题**(2026-08-31 用户实测:「你怎么给我发成了执行工单」)——
* 标题是活动自己写的字符串,卡穿的是哪副面孔由装配层决定,两者可以对不上:
* 那一次配方递了 `accept`、标题也叫「验收工单 · …」,而装配层把 variant 丢了,
* 人收到的是一张执行工单 —— 按标题驱动的这套脚本当时照样全绿。
*/
const seen = (body.cards ?? []).find((c) => (suite.stages ?? []).some((s) => s.kind === 'accept' && s.match.test(c.title ?? '')));
if (seen !== undefined) {
acceptCardSeen = true;
if (seen.variant !== 'accept') {
console.log(` ⚠️ 这张卡的标题是验收工单,发出去穿的却是「${seen.variant ?? '执行工单'}」的面孔 —— 判失败`);
acceptFaceWrong = true;
}
}
}
// w05「类型不一致·硬拦」:gate_reason 那句话要点了名(latch 住,答完卡草稿就清了,过了这一拍再查就晚了)
if (kase.expectGateReason && !gateReasonMatched) {
@@ -1402,6 +1489,19 @@ async function runCase(suite, kase) {
gateReasonMatched = caseRow !== undefined && kase.expectGateReason.test(String(caseRow['gate_reason'] ?? ''));
}
/*
* 打回之前把明细存一份(2026-09-01)—— 收工时拿它逐行比「没点名的行有没有被动过」。
* 存在**答卡之前**:答完那一拍配方就重判了,再存就晚了。
*/
if (kase.expectRedoTouchesOnlyNamed !== undefined) {
const rows = draftRows(body.drafts ?? [], kase.expectRedoTouchesOnlyNamed.address);
/* 第一份 = 打回之前(S2 刚判完那一版);此后每轮覆盖,**留住放行前的最后一份** ——
放行会把草稿并进正式表并清空,收工时再取就什么都没有了(实测踩到) */
if (rows.length > 0) {
if (redoBefore === undefined) redoBefore = rows.map((r) => ({ ...r }));
redoAfter = rows.map((r) => ({ ...r }));
}
}
// 决策卡挂在对话区,不在这一摞里 —— 每轮顺手看一眼有没有等着人点的
decisions += await answerDecisions(taskId, kase, decided);
await answerEntries(taskId, kase, entered);
@@ -1450,6 +1550,15 @@ async function runCase(suite, kase) {
: ` ⚠️ 缺着必填还放过去了:提交闸失效`,
);
}
if (stage.expectCardGateWhen === true && !gateWhenChecked) {
gateWhenChecked = true;
const { body: det } = await api(`/api/desk/tasks/${taskId}`);
const wo = (det?.workOrders ?? []).find((o) => o.id === card.id);
if (wo?.gateWhen === undefined) {
gateWhenMissing = true;
console.log(' ⚠️ 确认闸的条件档(gateWhen)没跟到工单载荷上 —— 类型不一致将拦不住,判失败');
} else console.log(' 确认闸条件已随卡落库(gateWhen 在载荷上)');
}
/*
* **两阶段自定义填表**(w05「候选缺库·新建类型」专用)—— 在空手交卷的闸测过**之后**、
* 常规 `buildEdits` 之前跑一次:先铸 `parts.chip_type` 的自增 `type_id`,读回来才知道
@@ -1585,6 +1694,10 @@ async function runCase(suite, kase) {
console.log(' ⚠️ 数据工单确认即入库那道门没兑现 —— 判失败');
ok = false;
}
if (gateWhenMissing) {
console.log(' ⚠️ 确认闸条件(gateWhen)被链上丢掉 —— 判失败');
ok = false;
}
// 「超限才叫人」是 A4 的要害:该叫的那一单没叫,等于底价门禁是摆设
if (missedRows.size > 0) {
console.log(` ⚠️ 案例点名要填的行,整例跑完也没在件上出现过(${missedRows.size} 处:${[...missedRows].slice(0, 3).join('、')}…)—— 判失败`);
@@ -1631,9 +1744,67 @@ async function runCase(suite, kase) {
ok = false;
}
if (kase.expectNoDataCard && dataCardSeen) {
console.log(' ⚠️ 这一例本该是零数据卡直分析,却见到了一张数据工单 —— 判失败');
console.log(' ⚠️ 这一例没有要补的数据,却派出了一张数据工单 —— 判失败');
ok = false;
}
if (kase.expectReleaseNoRedo === true) {
const acc = (last?.cards ?? []).filter((c) => c.mode === 'acceptance');
const bad = acc.filter((c) => c.redoable !== false);
if (acc.length === 0) {
console.log(' ⚠️ 一张放行验收卡都没见到,「不摆重做」这条没验成 —— 判失败');
ok = false;
} else if (bad.length > 0) {
console.log(` ⚠️ 放行验收卡上还摆着重做(redoable=${String(bad[0].redoable)})—— 判失败(这个值又在链上被丢了?)`);
ok = false;
} else console.log(' 放行验收卡不摆重做(redoable=false 一路到了卡上)');
}
if (kase.expectRedoNarration !== undefined) {
const said = (last?.trail ?? []).some((e) => String(e.title ?? '').includes(kase.expectRedoNarration));
if (said) console.log(` 复核那一轮在对话区说了话(「${kase.expectRedoNarration}…」)`);
else {
console.log(` ⚠️ 复核完对话区一个字没说(找不到「${kase.expectRedoNarration}」)—— 人不知道发生了什么,判失败`);
ok = false;
}
}
if (kase.expectRedoSetsStatus !== undefined) {
const { address, nameCol, param, column, equals } = kase.expectRedoSetsStatus;
const row = (redoAfter ?? draftRows(last?.drafts ?? [], address)).find((r) => String(r[nameCol] ?? '') === param);
const got = String(row?.[column] ?? '');
if (got === equals) console.log(` 打回点名要的结论真落下去了(${param}.${column} = ${equals})`);
else {
console.log(` ⚠️ 打回点名要「${equals}」,${param}.${column} 却是「${got || '(没取到)'}」—— 判失败(AI 又把审核人顶回去了?)`);
ok = false;
}
}
if (kase.expectRedoTouchesOnlyNamed !== undefined) {
/* `address` 在轮询那一头取快照时用过,这儿只比对已经存下来的两份 */
const { nameCol, cols } = kase.expectRedoTouchesOnlyNamed;
const note = (kase.decideSeq ?? []).find((d) => String(d).startsWith('custom:')) ?? '';
const after = redoAfter ?? [];
if (redoBefore === undefined || redoBefore.length === 0 || after.length === 0) {
console.log(' ⚠️ 打回前后的明细没取到,「只动被点名的行」这条没验成 —— 判失败');
ok = false;
} else {
const moved = [];
for (const row of after) {
const name = String(row[nameCol] ?? '');
// 被点名的那几行本来就该变
if (name === '' || note.includes(name)) continue;
const was = redoBefore.find((r) => String(r[nameCol] ?? '') === name);
if (was === undefined) continue;
for (const col of cols) if (String(was[col] ?? '') !== String(row[col] ?? '')) moved.push(`${name}.${col}`);
}
if (moved.length > 0) {
console.log(` ⚠️ 打回只点了名的那几条,没点名的行却被改了:${moved.join('、')} —— 判失败`);
ok = false;
} else console.log(' 打回只动了被点名的那几条,其余行一个字没变');
}
}
if (kase.expectAcceptCard && !acceptCardSeen) {
console.log(' ⚠️ 数据齐了也该叫人一次(只读的验收工单),一张都没见到 —— 判失败(多半是 S1 又被拍首的 doneWhen 扫描收掉了)');
ok = false;
}
if (acceptFaceWrong) ok = false;
if (kase.expectGateReason && !gateReasonMatched) {
console.log(` ⚠️ gate_reason 没点名(期望命中 ${kase.expectGateReason})—— 判失败`);
ok = false;