diff --git a/.gitignore b/.gitignore index df3adf3..f64b746 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ dist/ .env.local coverage/ .vite/ + +# 本地开发日志(./start.sh 写的) +.dev-logs/ diff --git a/CLAUDE.md b/CLAUDE.md index 7dfa485..b4ef2ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,20 +6,393 @@ ## 现状快照(2026-07-30) - **三面一体**:设计中心 `/design`(九区:蓝图/触发器/流程包/活动库/工具箱/数据域/知识库/ - 大模型/测试台)· 运营中心 `/ops`(六区:驾驶舱/工作流/任务/员工/台账/系统)· 工作台 `/`。 + 大模型/**测试台:三格 —— 流程(投一封信)/ 活动(按契约填入参)/ 日志(历次试跑存档)**)· 运营中心 `/ops`(六区:驾驶舱/工作流/任务/员工/台账/系统)· 工作台 `/`。 `/gov` 302 → `/ops`。 - **编排层与流程引擎已跑通并测死**:起单宽进(门口不拒缺项)、数据按字段名流动(无强制 类型)、边只表达顺序与条件(对齐 Mastra)、闭合校验只查结构闭合 + 活动装载、一条流恰一个 生效版本、在途按启动版本跑完、版本号与名归设计人员自己定。 - **活动一律案例模型**(2026-07-29 范式退役):flow-engine 已删,业务模型只有 - CMMN + 说明书 + DMN 一形;`turn-runner` 每拍要 `agent.decide` —— **环境无模型密钥时 + CMMN + 说明书 + DMN 一形;`defineActivity` 已去 `paradigm`,`run` 改为可选 —— + 行为全在清单里。`turn-runner` 每拍要 `agent.decide` —— **环境无模型密钥时 案例活动一步走不动**,三套端到端(unified-quote-flow / new-package-flow / testbed-trace) 整套跳过,恢复条件写在各文件头。 +- **A1 客户RFQ受理已是真活动包**(2026-07-30):`activities/quote-intake@2.0.0` 经**安装通道 + 读文件**装入(`seed-activities.ts` 读 `dist/{manifest.json,index.js}`,不经 import —— + 与 activities 之间只有安装通道)。五对象台账(record / type_table / verdict / alt_request / + rfq_request)、两张判定表(F-01 完整度 · P-03 三分类)、三条技能、四张 HTML 报告模版; + **案例模型照 SOP 图一步一项**(S1…S6 + 放行,7 项,扁平无阶段)—— v2.0.0 重写: + 上一版 12 项(擅自加阶段/里程碑/裁量项)让 Agent 全程跟 harness 较劲,实测无限死循环; + 流程包 A1 节点已指向它。其余**五**槽位照旧占位。 - **模型实例**:只留 `deepseek-v4-flash` / `deepseek-v4-pro` 两条;种子读不到 `JCAI_DEEPSEEK_API_KEY` 就**不绑密钥**(卡上如实显示不可用),去治理台大模型区补。 -- **下一件大事:实现活动与工具调用逻辑** —— 规范已写死在 `docs/活动开发规范.md`, - 按其**第 12 章待实现清单**顺序销账(第一优先级:写类动作意向落地断链、工具未命中转人)。 -- 后端 19 模块 + 6 活动包 + 前端 features;五道门禁全绿(2026-07-30)。 +- **A1 已真跑通**(2026-07-30):起单 → Loop 逐拍 → 写台账草稿 → 提交放行 → 人签 → + releaseWrite 合并进正式版 → 发射 intake 口 → `released`。三分类与双去向旗业务正确 + (A 直通 / B 寻源 / C 选型)。为跑通它补了 Loop 的七个洞:`write` 行动 · `finish` 宣告完成 · + 进行中的项进局面 · 工具清单进单拍提示词 · 里程碑自达成 · 在途验收不被兜底 save 擦掉 · + 必交付物只数本单草稿。**A1 的槽位 `rfq-intake` 已退役卸载**(库里两个同名活动会骗人)。 +- **A1 两条路径都跑通并零卡壳**(2026-07-30):常路五步与最长路径(含 S2 补全卡来回) + 各跑一遍 `released`,**行动被拒 0 次**。补全卡走对话卡族(`ask` 行动); + 连续 5 拍被拒即熔断交人(`stalledTurns`)。 +- **一拍一串行动**(2026-07-30,`AgentTurn.actions`,封顶 6):从前一拍只许一个行动, + 每个 write/finish/start 都单烧一遍完整上下文 —— A1 六步恰花 21 拍(19 拍是该约束下的 + 最短路径)。收成串后 **7 拍 released、56k token**(自 21 拍 173k);纪律引擎强制: + 被拒停在谁那儿(前缀生效)、tool 后截断、挂起型收拍。浏览器 UI 点「确认通过」→ + released 全链亦验证过。 +- **写的授权面是台账声明,不是计划项**(2026-07-30 修死锁):`write` 的 itemId 降为记账, + 挂不上(项已完成/人工激活未 start/阶段/不存在)照写不拦、留痕注明。此前绑死在项生命周期上, + 实测死锁:S5 收完才发现打标没写全,能挂的项全 Completed,每拍补写被「要先 start T_release」 + 拒回,连拒五拍熔断 —— 而数据本身合规。台账声明与空转检查仍是硬门。 + 同批修:`finish` 对非人工激活的项就地过 Active 再收(白按一下省一拍); + 事件到达痕**只记 task 层一条**(此前 task 与 loop 各记一条,同一次回传日志三行像结巴)。 + 最长路径(含补全卡人环)复测:**8 拍 released、64k token、行动被拒 0 次**。 +- **每拍时延 20-56s → 1.5-4.9s**(2026-07-30):trace 拆出 tokIn/tokOut/reasonChars 实证 —— + 慢全在**隐藏推理解码**(最慢拍可见 1538 字、推理 9808 字)。`ModelCall.reasoning: 'off'` + 经 provider-profile 的 `reasoningControl` 译成 DeepSeek `thinking:{type:'disabled'}` + (探针核过;`low` 在该厂无效)。配套:write patch 只带有值列、scope 摊平字段前置整表殿后 + (rest 3000→1800)、轨迹剔除装配技能戳、旧出参截 120 字、技能戳版本不变不重写、温度 0.2。 + off 档一次误判(缺目标价当关键字段)以**说明书写死规则**治(「关键字段只有 PN/EAU/交期」), + 不给思考额度 —— 判断归判定表与说明书,模型只做编排。复测:7 拍 49k token released、零被拒。 +- **工作台交互按 PRD 补全**(2026-07-30):①对话区点卡 → 视图区 locate 跳转(待办跳页位、 + 办结跳成果详情);②工作成果页签真实现(一行一份 → 定稿报告[印章+verdict+嵌 HTML 汇报] → + 返回列表);③执行工单挂作业指导书卡组二;④工单办结回执以人事件进对话流,引擎内部观察 + (internal 标)与裸「事件到达(human)」不再污染对话区;⑤空待处理四态分明(推进中动画 / + 等外部回音 / 都办完了 / 已收尾)。 +- **AI 工作中动画**(2026-07-30):对话区**办事条**(均衡条,语义「在办事」不是「在打字」; + 三点气泡仍留给回话中)+ 头像呼吸环;空工作区**推进场景**(涟漪 + 微光工作线)。 + 纯 CSS、只动 transform/opacity、prefers-reduced-motion 全静止; + 状态源自任务投影 `runtime === 'running'`,等人/等外部/已收尾不亮 —— 界面不撒谎。 +- **活动开发规范已重写为开发手册**(2026-07-30 第二版):销账史砍掉(在 git), + 精华收进建模八纪律与排障速查表;余账在 §15(④受理分支误吞验收裁决 · + ⑤治理台白名单授权口 · ⑦dist 真编译 · ⑧单拍工具缝 · ⑨⑩死代码 · ⑪评价卡暂不做)。 + 语义源两卷(操作系统/活动模型)已归档 `docs/archive/`。 +- **实时同步补齐五处**(2026-07-30 第二轮,测试台为引子):①订阅层不失效 + `['gov','testbed',*]` —— 测试台全靠 2s 轮询而轮询到终态即停,一单跑完那屏就此定格; + ②`testCurrentRoute` 把已终结的试跑单当成「在跑」,旋钮永久锁死(现返回 `live` 分开 + 「摆出来」与「占着仪器」);③试跑起单/销毁不广播 —— 销毁掉的真任务还挂在别人工作台上; + ④**`kind:'system'` 声明了却全仓从未发布** —— 治理台所有配置面(活动/工具/模型/流程包/ + 数据域/驾驶舱)永不实时刷新;现由 `/api/gov/*` 写路由中间件统一发(一处拦住,新路由自动生效), + 任务走到终态也补发一次(台账与指标不以任务为轴);⑤本地 id 活过服务端事实 —— + 别处销毁后面板还钉着旧号,显示「取数失败」的鬼卡片(渲染期对齐,服务端说没了就松手)。 + 订阅映射已抽成纯函数 `invalidateFor` 并按「哪一屏靠什么刷新」逐屏断言(8 条)。 +- **MVP 工作流第一轮跨节点跑通 A1→A2**(2026-07-31),并挖出**七个编排缺陷**(全已修): + ①六节点绑的是退役槽位 id ②流程包入参还是 `customerCode`+`rfqRef` + ③**触发器装配映射是空的** —— 事件触发的 scope **不打底**(只有 registration 才铺 scope), + 「同名会自动带过去」是错觉,`subject`/`body` 同名照样缺 + ④E5 读 `needSourcing` 而 A2 出口不发 ⑤E10/E11/E12 读 `outcome` 而 A6 三口载荷是空对象 + ⑥A2 要 `originalPn` 而 A1 发的是 `altRequests`(上游发一批、下游要一个) + ⑦**自动收步与行动串并行** —— 引擎在拍首收了 T_select,而模型这一拍原本就打算 write 完再 finish, + `Completed → Active` 当场抛非法迁移,连拒五拍熔断;现与 `start` 同款:收过了就当已完成。 + ④⑤⑥ 同一类:**边读什么 / 下游要什么,上游就得发什么** —— 光测单活动测不出来,只有真跑跨节点才现形。 +- **六个活动全部转正为真活动包**(2026-07-31):`REAL_PACKAGES` 六件齐,经安装通道读 + `dist/` 装入 —— 治理台里不再有 `viewTemplates: []` 的占位槽。六份案例模型统一到 A1 的 + 状态机模式(剧本 `steps`:intent / guide / writes / tools / doneWhen): + **A2/A3 各只有「派一张执行单 + 放行」两项**(专家判断,建细了只是跟 harness 较劲); + **A4 定价**三项(算价归判定表 · **超限才叫人**的决策卡 · 放行); + **A6 议价**三项(落单 · **发出去人工激活 + 立监听等回音** · 放行); + **A7 归档**两项(一步落三张表 —— 它们互不依赖,分开只多两次 finish)。 + 凡触达世界的项一律不给 `doneWhen`(给了等于给引擎一把替人签字的钥匙)。 +- **A2/A3 的简化口径**(同日):案例模型从九个 humanTask 砍成**两项** —— + `T_select`/`T_source`(派**一张执行单**,卡面两张可编辑表)+ `T_release`(人工激活)。 + 上一版把「FAE/采购脑子里怎么想」当成了「系统要管几步」;而那一整段在业务上是**一件事**。 + 剧本与 A1 同一套(`doneWhen` 真了引擎自己收步)。`REAL_PACKAGES` 已含这两个, + 不再是治理台里 `viewTemplates: []` 的占位槽。 +- **判据三态 + 表格默认锁着**(2026-08-01,`quote-intake@20.0.0`,用户两问): + ①「这条未达标为啥还在」—— `acceptanceCriterionCheckSchema` 只有 `{label,level,pass}`, + **裁断与理由传不到卡面**,于是「裁了但裁成不满足」与「压根没裁」都是一个红叉。 + 补 `settled` + `rationale`,卡面变三态:达标(绿)/ **AI 判为不满足 · 交你定** + (琥珀 + 左侧色条 + 理由)/ 未达标(红)。更早的注释写着「理由随卡交给人」—— + **那句话描述的行为从来没实现过**,因为契约里没有那两个字段。 + ②**表格默认锁着**:每张可改的表各带一道闸(`[data-edit-for]`),锁着只出「编辑」, + 开了换「取消 / 保存」;取消还原成按下那一刻的值,保存才交给外层缓冲。 + **看与改是两件事** —— 从前一进屏 54 个输入框一起亮着。 + 途中踩了跨 realm 陷阱:`event.target instanceof Element` 在 iframe 元素上**恒为 false** + (`Element` 是父文档的构造器),症状是点「编辑」毫无反应而 class/CSS/选择器逐个查都对。 + **认能力不认血统**;监听挂载也改成幂等的(srcdoc 换文档时机抓不准,每次有机会就挂一遍)。 + 验收:6/6 released;浏览器里走完「编辑→改→取消还原→再改→保存→提交」,落草稿层。 +- **A1 连跑 10/10 全 released、行动被拒 0 次**(2026-08-01,`quote-intake@19.0.0`, + 10-11 拍 / 99-112k token)。基线是 5 轮熔断 1(其中一轮 13 次拒回、23 拍、234k token)—— + 五处 harness 改动逐轮逼出来的(见下两条与 `docs/debt.md`)。 +- **最后一条自报的机检判据也交给基座数**(2026-08-01):台账字段声明加 `required`(可选, + 不建 NOT NULL —— 草稿层的本分就是过程中允许缺),基座据此出 `域_对象_missing` 与 + `_missing_keys`(缺在哪几行、缺哪一列)。A1 的「必填齐(PN/年用量)」原读活动自写的 + `missing_key_total` —— 人在卡上补了年用量、基座已写回草稿层,而那个数要模型自己重算, + **漏算一次这道门就再也开不了**:十轮里三轮死在这。这是「机检只许读基座数得出来的数」 + 这条纪律第二次被违反,第一次是 missing_minor_total 被改小那次。 +- **Harness 三条死胡同已通**(2026-08-01,连跑 A1 摸底逼出来的:5 轮熔断 1、再 6 轮熔断 2)。 + 三条全是同一个病 —— **判据正确地拦住一件办错的事,而模型没有一个动作能把它办对**: + ①**写错一行没有退路** → 加 `drop` 行动(`ledger.draftDrop` → `DraftStore.drop`): + 只撤本单草稿层那一笔,正式版一个字都动不了、不抛事件,**不是第四种写**。 + 起因:客户明说本轮跳过的那颗料被写进有效清单,数全对(9 有效/1 跳过)而表里 10 行, + `record_rows = line_total` 拦住 —— 模型唯一能改的是那个数(改了 skipped_total 又不对), + 13 次拒回、23 拍、234k token; + ②**拒回只给数字** → 读数连**主键名单**一起摆(`域_对象_keys`,封顶 12 行): + 两份名单一比,多的那个 PN 就在里面对着,一句话能办的事; + ③**拒回把模型指上死路** —— 「要先 start,当下是 Completed」,而 start 一个 Completed + 项同样被拒,两堵墙对着撞;现按状态分开说,且 start 已收的项改为无事发生。 + **拒回的话是模型下一拍的行动指南,指错方向比不给指引更糟** —— 同一条也改了提交门的 + 结尾建议:先分清差的是**数**还是**行**,行不对就 drop,别去改数。 + 顺带:**收步判据与验收判据同源**(S2/S5/S6 都改读基座的 `_rows`)—— + 松出来的那一截就是返工;S2 那条把「多写一行」的发现点从第 10 拍提前到**第 2 拍**。 + 还删了孤儿模版 `intake-gaps`(7 处 `view:` 无一指向它)。 +- **对话区一轮一句(不是一拍一句)**(2026-08-01,用户:「太密集了」):形状是 + **设定目标 →(分析现状 → 规划 → 执行)× N 轮 → 一个气泡**,气泡可以是仅结论, + 也可以是**结论 + 功能卡两部分**。三处:①`turn-runner` 的 `handsOff` —— + 只有交出控制权那一拍(ask/human/decide/listen/submit)的交代进对话区, + 别的拍留进工作日志(`internal`);②提示词与 say 字段说明明写这条规矩, + 否则模型照旧每拍写播报、只是被默默丢掉(白烧 token 还写不好总结); + ③`chat-projection` 把交代与卡**并成一个气泡**,并把收尾三条引擎回声 + (裁决 / 发口 / 终态)收成一条。并卡的判断按**来路**认(只有 terminal 不认领卡)—— + 一度写成 `tag !== 'conclusion'`,而交代本身常是结论口吻,于是第一轮并对、第二轮没并。 + 账:同一条 11 拍的 A1,对话区 **9 条 → 5 条**;压下的 8 条交代一条不少留在工作日志。 +- **放行卡不摆 L5、去向不再有 L4 裁量**(2026-08-01,用户当场指出): + L5「BD 放行」是**责任关**,而那张卡本身就是这道关 —— 列进判据表等于摆一条恒为 + 「未达标」的项,「n/m 达标」永远差一条;声明留着、呈现去掉(滤在基座组卡处,六活动通用)。 + L4「去向判断合乎业务常理」删掉 —— 去向已由出口谓词算出,再让模型裁一次是**给自己 + 没做的决定盖章**:裁「满足」是废话,裁「不满足」也改不动什么(能改的是三类条数, + 那已由 route-alt / route-rfq 两条机检看着)。**判据得能指向一个可做的动作。** +- **三个区各归各位:件归工作区,判据与指导书归对话区**(2026-08-01,`quote-intake@14.0.0`, + 用户指出「你用你自己的报告格式嵌套了一层我要的报告格式」):工单报告组件自带一整套外壳 + (题头/编号带/依据带/判据表/章节头),又把活动推的那一屏包在里面 —— 两套版面语言叠着。 + 现在 **待处理与工作成果只放那份 HTML 模版**;作业指导书 · 依据 · 判据 · 填报表单 · + **提交与裁决**全在对话区的卡上。**待处理是可编辑态**:可改的格子在模版里挂 `data-col`、 + 行容器挂 `data-edit="域.对象"`,渲染时每格多渲一个 `` 孪生件, + 只读那份与输入那份**由样式表按根节点的 `editing` 二选一** —— 同一份 HTML 两处用,不存两份。 + 收值在外层(`allow-same-origin` 读得到 DOM),**iframe 里仍然一行脚本都没有**; + 收上来按台账地址对上工单 `blocks[i]` 的 `ledger`/`fields`,仍走老的 `table:N` 回传。 + 同批修两个只在真人点的那条路上现形的洞:①**监听挂在 about:blank 上** —— + `srcDoc` 异步换文档,渲染那刻挂的监听随旧文档作废,人改了两格提交时载荷一个字都没有; + ②**算出来的去向没交回去** —— `routedPort` 那一路 `selectedPorts` 恒空,按「确认通过」 + 当场判「选择组未选出口」,台账都合并进正式表了单子却死在最后一步(`blocked`)。 + 验收:浏览器里走完两道人环 —— 件上改两格 → 提交 → 落草稿层 → 放行 → **released**, + 九行数据(含人补的 eau/brand)落进正式表;工作成果那一屏零输入框、零外壳。 +- **阶段2 补回成一项:受理需求交人审定**(2026-08-01,`quote-intake@13.0.0`,用户指出 + 「我都没确认需求 它就让我验收了」):「经过人」从前不是一项,而是归一那步收步条件里的半句 + `missing_minor_total = 0 or answered_total > 0` —— **只在缺东西的时候才叫人**。 + 一封字段齐全的询价进来,这半句在拆完那一刻就为真,引擎自己把那步收了, + 人一眼没看过受理需求,下一次被叫到已是终局验收。现在 `S3_confirm` 是独立一项: + **人工激活 + 不给 doneWhen**(与放行同款),`view: intake-overview`,一单必发一张; + S4 的入场前提改读 `confirmed_total`(只有人回卡才产生的数)。 + 规范 §7.1 立纪律 3.5:**判据是「这件事办完了没有」,不是「要不要办」**。 + 连带修掉一个零条死锁:**没写过 ≠ 写了 0 行** —— 全 A 类那条路上替代请求表一行没写, + `quote_intake_alt_request_rows` 整个键不存在,而判据 `= type_c_total`(0=0)本该成立, + 未绑定的名字比较为假 → 永远过不去、连撞五拍熔断;现按 `declarations.ledger` 先铺 0。 + 验收:常路 **11 拍 released、被拒 0**;全齐无 C 类那条 **10 拍 released**、去向 `to-sourcing`。 +- **A1 两份报告改为从 PRD 目标件整份搬过来**(2026-08-01,`quote-intake@12.0.0`): + `views/prd-style.ts` 是机械抽取的整段样式表 + 图标精灵(`PRD_CSS`/`PRD_SPRITE`,数值未改), + `views/report.ts` 逐块搬标记、只把写死的数据换成绑定 —— **不再用 `@sdk` 拼装件复述** + (那套外壳有自己的版面语言,「照着做」出来的是第二种版面;前三轮报「25 块到位」都是 + 结构断言,对版面零约束)。规范 §10.3.1 立此法:**视觉目标件只能看,不能用 DOM 断言验收**。 + 连带三件:①**`data-class-of` 不再以 `data-slot` 为前提** —— 纯色块(格里不写字、只按值上色) + 从前整条静默失色,屏上一排灰格子看着像「还没算出来」(补 4 条用例); + ②**去向进视图**:`case:routeKey/routePort/routeTo/routeWhy` 由基座按出口谓词求值后注入, + 与放行发口是同一次判定,报告只摆不算(分布条按条数分宽也走同一招:条数投影成 class + 宽度梯子); + ③`contact_name` 落进 `verdict` —— 老客户不重写主数据,联系人卡从前会显示公司名。 + 端到端复跑:**10 拍 released、96k token、行动被拒 0 次**,去向 `to-alternative(type_c_total > 0)`。 +- **A1 五轮端到端全部 released**(2026-07-31):行动被拒 **0** 次,判据 7/8(只剩 L5 人放行)。 + 跑测逼出并修掉两个「人答了什么 Agent 看不见」:①可编辑表的修改只活在组件局部 state, + 回传载荷一个格子都没有(加 `onTableChange` 链,键 `table:N`、首行列名); + ②决策卡选了哪个不进轨迹(`payloadOf` 只认 values/output/error,补 `detail.value`)。 + 两者都会让 Agent 重发同一张卡 —— 人以为自己白填/白点了一次。 +- **工作台空占位统一**(2026-07-31):任务那一栏的空态从裸 `execution-panel` 改挂 + `jc-panel-card`(其余四模块一直有,只它是画布上飘着的一段文字); + `.jc-empty` 由固定内边距改 `margin:auto` + `justify-content:center`,在栏内真居中。 +- **活动状态机:每步一条剧本,成果已在就自己收**(2026-07-31,治 A1 空转)。 + `LoopModel.steps` 一个计划项一条:`intent` / `guide` / `writes` / `tools` / `doneWhen`。 + 引擎每拍开头先对任务视图求 `doneWhen`,**真了就自己 finish 那一项,不问模型** + (人工激活的项不自动收 —— T_release 触达世界,不给它 doneWhen); + 上下文按状态组织:只摊开当前这一步的做法与它该用的东西,其余步骤各留一行。 + 起因是实测死循环:S1 一次写齐八字段后 S3「补齐八字段」已无活可干,而每拍喂整本说明书, + 模型只能把同样三行再写一遍 → 「写了等于没写」被拒 → 下一拍再来一遍,连拒到熔断。 + **`doneWhen` 的坑**:S-FEEL 里未绑定的名字 `!= null` 求值为 **true**,写它等于那一步开局即收; + 布尔列也不能当判据(分不清「没写」与「写了 false」)—— 一律取同步写下的计数列(测里拦下过)。 + 实测三连:**A1 三次端到端全部 `released`,行动被拒 0 次,S3 每次自动收步**; + 8 拍 / 58k token(状态机之前是 11 拍 / 82-90k)。 +- **决策卡 Agent 侧接通**(2026-07-31):新增 `decide` 行动(contract / port / turn-runner / + engines / 提示词 / 解析)。此前决策卡**只有 UI 没有 Agent 侧**,岔路口只能写成补全卡 —— + 人拿到一张要敲字的表单而不是两个可点的选项。少于两个选项拒发。 +- **真实文件进得了活动**(2026-07-31):`sys.upload`(迁移 0024)存原字节 + 读出来的文本; + `POST /api/gov/upload`(base64,8MB 封顶)/ `GET /api/gov/upload/:id`; + xlsx **不引依赖**自己读(ZIP 中央目录 + `inflateRawSync` + XML,13 条测试); + 契约字段标 `format:'file'` → 测试台出文件选择器。读不出文本的类型如实说读不出。 +- **沟通记录接通**(2026-07-31):`sys.task_comm`(迁移 0025);活动清单声明 + `contract.input.inboundComm`(哪几个入参字段合起来是那封来件),基座**起单那一刻**归档 —— + 人一点开这单,沟通记录里就已经躺着那封信,不必等模型跑几拍。附件那一行照抄基座的读取说明。 +- **测试台改三格 + 试跑日志**(2026-07-30):两档旋钮长在内容里(别处跳不进来、刷新记不住 + 试的是哪一台,第三样东西也塞不进二档),提到页签 —— **流程 / 活动 / 日志**;单在另一台上 + 不抢页签,只挂一句「它在那边」。日志是**拔之前先存一份**:停止仿真照旧连根拔掉,拔前 + `archiveRun` 存一条(逐拍轨迹 + 台账草稿 + 发出去的人卡 + 读数),**一次试跑一条** + (流程包把名下节点任务合成一条,痕带节点名前缀、按时刻排);可单删可清空,`reset-runs` 一并清。 + 库表 `sys.testbed_run`(迁移 0023),路由四条挂 `/api/gov/testbed/logs`(写路由中间件自动广播)。 + 顺手修:单活动那一台的负责人下拉掉进 `.tb-asn-r` 的 40px 那一格(它比流程那台少一列), + 现给 `.solo` 四列版 + 插座列加宽到 184–238px。 +- **台账列归型前移到写入那一刻**(2026-07-30):模型用 `""` 表示「原文没给」,草稿层照收, + 到 `releaseWrite` 才被 PG 掀桌(`invalid input syntax for type double precision: ""`)—— + **而那是人签完字之后**,整单落 `blocked`。现在 `draftWrite` 按声明列型归:空值=不写这一列、 + 数值/布尔字符串转真类型、归不了当场拒(理由喂回下一拍)。台账字典也带上列型给 Agent 看。 +- **前端实时刷新已通**(2026-07-30):前端那一侧一直是好的(单条 SSE + 按前缀失效缓存), + 三处**后端没广播**:①`task-advancer` 只在 runtime/terminal **换名字**时广播,而案例活动 + 连跑十几拍全程 `running`(改为 `progressed` 即广播);②`interaction` 的推送一个订阅者都没有 + (加 `onPush` 系统级转接);③发消息不经事件投递,收件人的消息气泡不动(outbox 处补广播)。 +- **四类人类工具:前三类已通**(2026-07-30):执行(工单 · 报告页填报提交)、 + **补全**(对话卡 · `ask` 行动,两条路必走其一)、验收(工单 · 判据表 + AI 已填好可编辑 + + 裁决在对话区)。决策卡(第四类)UI 已在、Agent 侧未接。 + 验收修订链原来断在五处「声明了没接线」——契约 `revisableAtRelease` 定死哪几个值可改、 + 改完落哪儿;拟值放行前从台账现读,不让模型重述。 +- **A1 的两份执行页照 PRD 目标件重做并跑实**(2026-07-31,`quote-intake@9.5.0`): + `~/coding/JCAI_PRD/docs/活动执行页/` 的两份 HTML 是「办完该长什么样」的答案 —— + **询价受理需求**(来件原文 → 受理要素四卡 → 指标带 → PN 清单 → 本轮不报)与 + **统一受理记录**(要素 → 逐条记录带标 → 分级依据 → 分流去向 → 两类请求 → 结论 → + 这一签下去会发生什么)。`@sdk` 补 `letter` / `factCards` / `actionList` 与列样式 + `req`/`opt`/`num`/`pill`;`data-blank` 让空值真的留空,必填/选填胶囊与缺项标底 + 由模版自己的 CSS 出 —— **模版仍不许算**(分布条宽度、命中高亮这类算出来的画面一律不做假)。 + 顺手删掉 `@sdk` 里五个零调用方的拼装件(kvCard/tableCard/noteCard/stagesCard/critList)。 +- **A1 六轮端到端,末轮九行数据全对**(2026-07-31):每轮 `released`、**行动被拒 0 次**, + 9-10 拍 / 76-89k token。逐轮逼出并修掉五个洞: + ①**机检判据两边都由活动自报** —— 条数报 9 而表里 10 行、三类条数报 7A/2B 而判定器给 + 5A/3B,派单数同源同错于是「2 = 2」顺利过关;现加基座读数 `域_对象_rows`(本单写下几行), + 判据必有一边不是自报,`classify` 出参也带 `counts` 供照抄; + ②**问过 ≠ 答过** —— 收步判据写 `asked_total`(卡一发就为真),人填完提交这一步当场被收, + 答案没轮到写回台账;改等 `answered_total`; + ③**归档件被冲掉** —— 收口后引擎再跑一拍,推固定的 `case-overview` 退到「声明的第一张」, + 把人刚签过的放行页冲成阶段二的执行件;现按剧本 `view` 推、活步收完不推; + ④**说明书与剧本各写一份六步**,分叉出三个案例模型里不存在的计划项名(S1_register 等), + 模型照它报 itemId 一报一个被拒;说明书现在只讲整单的事,逐步做法归剧本(有测试钉死); + ⑤三类数据失真(PN 抄漏后缀→同一料两行、数值缺失写成 0、制表符 BOM 拆错列)—— + 逐条在说明书里写死实测账。 +- **Harness 可靠性:三处「门本身教模型撒谎」已修**(2026-08-01,两条样例路径连跑逼出来的): + ①**机检判据读活动自报的数** —— 一颗料只缺目标价,模型如实记 1、被连拒四次, + 第五拍把那个数**改回 0** 就绿了(台账从此写着「缺选填 0 条」); + **机检只许读基座数得出来的数**,数不出来的别假装机器判得了 —— 该条已降 L4 交人; + ②**裁量判据也被要求「为真」** —— 它如实裁「未满足」并写了理由,提交照样被拒, + 判-提-拒十二轮 21 拍 187k token,最后把裁断翻成「满足」才过去; + 提交门已改:机检必须为真、**裁量裁过即可**,裁断连理由随卡交给签字的人; + ③**熔断数不出「同一堵墙」** —— 拒回标题带着串位(「第 2 个」),整条一比成了 N 堵墙, + 叙事窗 20 条也装不下几次撞墙;一单被同一条判据拒回 **33 次**、40 万 token 烧到 + `exhausted`。已修:削掉串位前缀比对 + 熔断另开 60 条窗。 + 顺带:计划项号认唯一前缀简写(`S4` → `S4_history`)。 + **验收**:Itelma 与新客户两条路径各三轮全部 `released` —— + 10 拍 / 88-90k / 被拒 0 与 9-11 拍 / 71-88k / 被拒 0-1,数据逐轮对得上。 +- **执行页补到「能做的极限」**(2026-08-01):新增 `data-class-of`(把值消毒成 class 后缀, + 仍只是取值,判断留在样式表)—— 于是类标 A/B/C 三色、两枚旗的是/否、**一条料一格的刻度条** + 都真做得出来;要素卡补方/圆标(方=组织、圆=人,里面放图标不放算出来的首字母)与大号计数; + G2 那段改成**三条规则连着本单计数**的规则表 —— 命中由编排层求值,这份件不画对勾替它宣布。 + 仍不做的只剩两类:按条数分宽的分布条(宽度得算)、编辑态(沙箱无脚本,改数走执行卡)。 +- **试跑日志吞主键已修**(2026-08-01):存档那一屏只渲染 `patch`,`key` 整个不显示 —— + 三行受理记录屏上只剩「eau / lead_time」,看的人据此判「PN 没提取到」,而 PN 一条不少。 + 两处观测面已合并到 `gov-testbed/internal/drafts.tsx` 一份实现。 +- **就地改的三处不老实已修**(2026-08-02,同一单实测逼出来的): + ①**保存按地址走,不看当下那张卡** —— 载荷从前是 `{workOrderId, values:{'table:0':…}}`, + 列名由那张工单的 `blocks[0]` 给出,于是「这份件能不能改」被绑在了「此刻开着的那张卡 + 长什么样」上。一单在放行门连拒五拍熔断,引擎发的是**没有 blocks 的熔断卡**,而屏上摆着的 + 正是受理记录那份件 —— 闸在、编辑进得去、保存按得下,前端找不到匹配的块,**一格都没往下送**; + 人要补的恰恰是把这一单卡在那儿的那个必填格。现在收件自己说的话(`data-edit` 地址 + + `data-col` 列名),授权面改由**活动清单的台账声明**把关。 + ②**必填还空着就按不下提交**(`ExecutionWorkOrder.gate`,基座按声明现算, + 与放行门 L1 `域_对象_missing = 0` **同一个数同一套算法**);闸上摆出差在哪一行哪一格, + 人在件上补齐、保存,闸自己开。算不出来(卡上没绑表)就不给闸 —— 没有依据的时候不拦人。 + ③**只写真改了的那几行**:件上按一次保存整张表都回传,照单全写会留「就地修订成果:4 行」 + 的痕而人只改了一格。 + 端到端:12/12 判据、`released`、被拒 0(另一轮 3 次、亦 released)。 +- **日志只有三色**(2026-08-02 用户定):负责人绿 · 智能体蓝 · 系统灰(`LogLine.who`)。 + 从前按归侧上五色 —— 那是**度量**的分法(自主率要把「等外面」与「机器干的」分开), + 对读日志的人不构成区别。连带两件:放行那一刻四条痕说的是同一件事(合并 / 发口 / 流转 / 终态), + 日志只留说得出实情的那一条(带「几行没进正式表」);台账写入认 `by` 定主语 —— + 放行合并归系统,人工修订归人。 +- **件上的表能增删行**(2026-08-02 用户:「编辑列表应该还能增删行」):手改本来就是 + **兜 AI 的错**那一手,而错不止「这一格填错了」——「这一行根本不该在」(客户点名跳过的料 + 被写进了有效清单)与「漏了一行」是同一类错,只给改格子就没有出口。 + 序号那一格编辑态换成 ×(一列两用),表尾一条「加一行」;删是**挂记号不当场抹掉** + (看得见、可后悔),「取消」把加的撤掉、把记号摘掉、把值放回去,三样一起才叫还原。 + **删要说出来**(载荷 `drops`),不靠「没回传就是删了」—— 那种口径会把一次不完整的回传 + 变成一次删表。撤的只是本单草稿层那一笔(`draftDrop`),正式版一个字不动。 + 痕里点名撤了哪几行并写「别再写回去、条目数跟着改对」—— 那条痕是**下一拍模型的行动指南**, + 不说清它会照「条目数 9 而表里 8 行」判成少写了一行,把人刚删的写回去。 + 实测:删一行 + 加一行 + 补必填 → **released、11 拍、被拒 0**。 +- **裁量判据带谓词时谓词说了算**(2026-08-02 用户:「这个价格是我填写的,但是它判错了」): + 「选填缺口:已补齐,或由你放行时确认可以先受理」是复合命题 —— 前半句基座数得出来 + (`域_对象_gaps`),后半句才需要人。从前整条只读裁断,于是模型凭记忆写理由, + 人填好的目标价它照样判「客户未提供」(复现的那单更离谱:两行年用量都填着, + 它写「共 4 处选填缺口」,而 EAU 压根是必填)。现在谓词为真即达标、**那句相反的话一并丢掉**; + 谓词为假才轮到裁量。同批:验收卡的判据改为**读时现算**(`container.checks.of`, + 与提交门同一段 `criterionCheck`)—— 人在成果件上补一格,判据跟着变; + `remark` 不再算选填缺口(它是我方写的注,不是客户该给而没给的)。 +- **作业指导书与验收判据同一套版面**(2026-08-02 用户定):从前指导书是一串松散的圆点、 + 判据是一摞带章的行,同一张卡上两套版面语言。差别只留一处:**指导书不画对勾** —— + 它是要办的事,系统并不知道人做没做。执行卡那一钮也改称「查看工单」。 + 连带挖出:**引擎压根不往下传 `steps` / `standard`** —— 活动照骨架写了,发卡时一个字 + 都没到卡面,人拿到的只有一句「请核对确认」。空着不是简洁,是把交代省掉了。 +- **建客户要公司与联系人都经人确认**(2026-08-02 用户定):录入卡加 `also` 段 —— + 一张卡两段(公司档 + 联系人档),确认一次落两行,值按 `段号.列名` 隔开 + (两张表都有 `name`,平铺会互相盖掉而没人看得出来)。从前联系人由 AI 自己写, + 而 `contact_code` 恰是它编出来的那一个。 +- **该问人的问人,人答不上来的别摆到他面前**(2026-08-02 用户两条): + ①卡面摆的是台账登记的**表人读名**(「客户档案」),不是 `customer.profile`; + 主键行只摆表单里没有的那几格、用中文列名 —— 从前一句「主键 customer_code=待定」 + 既是英文列名,又在替一件他马上就要做的事做旁白。 + ②**自增列不问人**:声明加 `FieldDeclaration.auto`,标了的列压根不进表单, + 写入那一刻由基座按「同组其余主键值-两位序号」生成(`CU-9001-01`,序号数正式版 + 本单草稿, + 同一单连加两位也不撞);卡上另摆一句「由系统按顺序生成,不用你填」—— + **不能一声不吭地拿掉**,那样人会以为这张档少了一格。 +- **放行卡砍掉四条判据,办结后换成落地结果**(2026-08-02 用户定): + `downgrade-safe` · `route-alt` · `route-rfq` · `typed-on-record` 四条都是**内部一致性** + (打标依据对不对、几类料开了几张单、两张表行数齐不齐)—— 该由引擎自己看住, + 而放行卡是给签字的人看的:一屏九条,真正要他过目的那几条反而淹了。 + 落地那一侧不因此变松:同一批基座读数仍在 S5/S6 的收步判据里。 + **办结之后那一屏换成「这一签下去发生了什么」**:进了哪几张表各几行(摆表的人读名)、 + 转去哪儿了(摆出口的 description)、试跑挡下几行 —— 判据说的是签之前要核什么, + 签完再回看,那一摞对勾答不了这个问题。事实取自放行那一刻的**结构化留痕** + (`ledger-write` / `release-verdict` 的 detail),不解析日志句子。 +- **判据措辞写全,两张卡的按钮统一**(2026-08-02 用户定): + 「客户已确认(认出或已建档)」→「客户已确认:发件邮箱认出为老客户,或已为新客户建好档」, + 五条同批改写;执行卡与验收卡那一钮改成同一形状 —— 图标一格、**四字粗体** + (执行工单 / 验收工单)、小字说明、右侧箭头,且四字在上小字在下(从前是反的)。 +- **执行工单说的是「哪几格必须有、哪几格值得追」**(2026-08-02 用户定): + A1 的作业指导书从「怎么核表」改成逐项建议 —— PN/年用量必须有(缺一条走不到放行)、 + 目标价尽量试探出来、客户料号对齐回价单、品类与制造商能填就填; + 收口标准写明「问不到的留空并在备注里写明,放行时由你决定先受理还是退回」。 +- **A1 不再开请求单**(2026-08-02 用户当场定):`alt_request` / `rfq_request` 两张表 + 连同 S6 的开单动作、两个派单计数列、报告里那两张明细表一并退休。原话: + 「这两项数据不在这里产出;随令牌流向下一个节点的只有工作成果验收内的内容; + 进入选型替代后,活动会自己归一需要的数据」。从前 A1 逐条开单交给 A2/A3 —— + 那是**替下游把它的输入先摆好**,而下游本来就要照自己口径归一一遍: + 同一份事实两处各存一份,改一处就分叉而没人看得出以哪份为准。 + S6 的收步判据改读 `route_decided_total`(旗是布尔,分不清「没写」与「写了 false」)。 + 报告同批瘦身三处:两张请求单表 + 那条对账说明、「受理结论」四格表、 + 「本轮不报 0 行」横幅 —— 后两者要么与别处重复,要么九成时候在说「没有」。 +- **消息不再被内部事件刷屏**(2026-08-02 用户:「消息怎么总莫名其妙的出内容」): + 「无主入站待认领」从前只看「有没有主 + 有没有人接住」,于是**台账写一行发的 + `data` 事件**也进兜底 —— 装一次活动包就往三个人收件箱塞十几条 + 「无主入站待认领:(无主题) … 原件:无」。判据加一刀认**来源**不认类型: + 同样是 `data`,从通道进来的是件、台账写出来的是事实;只有 `source.kind === 'channel'` + 才谈得上「无人认领」。顺带:顺利收尾那条 `终态:released` 不再进对话区 + (落地结果那一屏已经说全了,且它摆的是英文状态名);没顺利收尾的仍出声,措辞取 + `TERMINAL_STATE_LABEL`。 +- **一道只认「有人写过」的门等于没有门**(2026-08-02,用户送来一单熔断的实测): + 那一单跳过 S4/S5/S6 直接提交放行 —— 受理记录三行一个标没打、分类表一行没写, + 却进得了放行门。查出来三个洞: + ①**放行门的入场哨兵 `s_routed` 只有 onPart 没有 ifPart** —— 「往 need_sourcing 写过一笔 + 就开门」,模型顺手写了个 `false` 就把门推开了。现在认**办完了没有**, + 且三个数全由基座现数(打了标的条数 = 记录行数、去向定过了)。 + ②「每条都已打标」这条 L2 读的还是活动自报的 `untyped_total` —— + 「机检只许读基座数得出来的数」**第三次**被违反(前两次:missing_key_total / + missing_minor_total)。改读 `_type_tag_a + _b + _c = _rows`,两边都是基座出的数。 + ③**拒回把「活没干完」说成了「数或行对不上」** —— 那一单数是对的(诚实报了 3 条没打标)、 + 行也是对的,而话术只给「改数 / 改行」两条路,还明写「别为了过判据去改那个数」, + 正好堵死它唯一想走的门,于是原样重提五拍熔断。现在判据点名的表若**这一单一行都没写** + (基座 `_rows` = 0),就直说「那一步还没办,回去办了再来」。 + **指向两扇打不开的门,比不指还糟。** +- **验收那一屏是活动的收口,答两件事**(2026-08-02 用户定):①落了什么 —— 按**交付物**点名 + (`RequiredDeliverable.ledgers` 说清一份成果由哪几张表承载:A1 的受理记录 / 分类表 / 结论 + 是**同一份**询价受理记录的内部结构,从前各占一条,人在成果面上看到三样东西); + ②往哪儿去 —— 转出哪一口、**带过去哪些字段**(载荷即已落地的事实,摆列名不摆值), + 一个口都没发就直说「到此为止」。同批:`统一受理记录` 全仓改称 `询价受理记录`; + 成果列表执行蓝 / 验收绿两色;办结卡那一钮改称「打开报告」。 +- **签完到收尾之间有自己的话**(2026-08-02 用户:「点完验收按钮后会闪一下中间态」): + 那不是渲染毛病,是**真的中间态**(合并 / 发口 / 落终态还在跑)。从前它落在通用四态里 —— + 待办清零先闪一句「都办完了」再跳「已收尾」;那句话在那一刻是对的,可它说的是另一件事。 + 现在这一段说「字已签下,正在落地」。**中间态本来就该说出来,只是要说对。** +- 后端 19 模块 + 6 活动包 + 前端 features;五道门禁全绿(2026-08-02)。 +- **别用 `db:seed` 当装活动的手段**:它会重写整套治理台配置。装活动用 `pnpm activities:install`。 ## 文档权威地图 @@ -27,12 +400,12 @@ |---|---|---| | **活文档**(冲突时以代码现状为准) | `docs/编码规范.md` | 工程结构权威:模块层级 L1-L8、依赖门禁、AI 协作协议 §12 | | | `docs/产品设计.md` | 三面一体的产品形态与决策记录 | -| | `docs/活动开发规范.md` | **开发活动照这份**:基座六件、清单六区、工具声明式调用、验收放行、工作台视图逻辑;第 12 章 = 待实现清单 | +| | `docs/活动开发规范.md` | **开发活动照这份**(2026-07-30 重写为开发手册):世界观/四铁律/开发流程/清单六区/案例建模八纪律/判定表/工具/人类工具/运行时参考/自测/**排障速查表**;§15 = 待实现 | | | `docs/debt.md` | 欠账台账(只增,销账划线) | | | `docs/adr/` · 各模块 `CONTRACT.md` | 架构决策与模块级不变量 | -| 语义源(历史语境) | `docs/操作系统.html` `docs/活动模型.html` `docs/MVP示例工作流.html` | 两卷规范 + 业务样例;flow 相关章节为历史记述 | +| 业务样例(活) | `docs/MVP示例工作流.html` | 七节点 SOP + 判定表 + 台账域 —— 开发活动的业务素材 | | 前端语义权威 | `~/coding/JCAI_PRD/workbench` `~/coding/JCAI_PRD/governance` | 原型 1:1 平移(只平移不 import);工作台视图逻辑以 `workbench/.../\_data/model.ts` 统一建模为准 | -| 归档 | `docs/archive/` | 已完成使命的文档(旧 README 规格包、PROGRESS、旧计划),只读 | +| 归档 | `docs/archive/` | 已完成使命的文档(**操作系统/活动模型两卷语义源**、旧 README 规格包、PROGRESS、旧计划),只读;代码注释里的 OS x.x / AM x.x 编号指向归档卷章节 | ## 铁规矩(违者返工) @@ -59,6 +432,7 @@ pnpm db:migrate # 应用迁移(新迁移写 db/migrations/ 并登记 meta/ pnpm db:seed # 播种(五员工 + AI 账号;登录者 = 冯兵) pnpm dev:api # :8787 pnpm dev:web # :5173(/api 反代) pnpm --filter @app/api reset-runs # 清运行数据,留配置 +pnpm activities:install # 只装活动包(不碰治理台配置)—— 开发活动时用这个,别用 db:seed ``` ## 结构速查 diff --git a/activities/quote-alternative/src/case-model.ts b/activities/quote-alternative/src/case-model.ts index 745d87f..0753242 100644 --- a/activities/quote-alternative/src/case-model.ts +++ b/activities/quote-alternative/src/case-model.ts @@ -1,111 +1,138 @@ /** - * A2 替代选型的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。 + * A2 替代选型的案例模型 —— **一张执行单办完**(2026-07-31 用户定,大幅简化)。 + * + * 上一版把 SOP 上的每一小步都建成了计划项(查规格、判品牌、查沉淀、撒网初筛、 + * 逐条核验、确认主推、收规格书、参数对比、风险评估…… 九个 humanTask)。 + * 那是把「FAE 脑子里怎么想」当成了「系统要管几步」—— + * 而这一整段在业务上是**一件事**:拿着原型号去选一个替代料,选完给出对标结论。 + * 系统该做的不是替他把这件事切成九块,是**把该填的表摆好、把结果收进台账**。 + * + * 现在只有两项: + * T_select —— 派一张执行单给 FAE(卡面两张可编辑表),他填完提交; + * T_release —— 放行(触达世界,人工激活)。 + * + * 与 A1 的分工:A1 是**受理归一**,活多、判据多、有确定性规则可依(两张判定表), + * 值得逐步建模;A2 是**一次专家判断**,建模建得再细也换不来业务上的好处, + * 只会让 Agent 全程跟 harness 较劲(A1 上一版 12 项的教训就在这儿)。 */ export const CMMN_XML = ` + - - - - + + - - create - - - update - needAlternative = true - - - create - - - update - candidatesConfirmed >= 2 + + update + benchmark_total > 0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - P2D - - + + + + + `; -export const NARRATIVE = `§0 开案:替代选型一事一议 —— 收到替代评估请求即开案。客户/PM 给出初选物料,或芯片类型与大致参数。 -先核初选(EOL 等风险提示 + 是否我司主推品牌),无风险且主推直接采信初选办结; -需要替代的,先撒网(AI 检索/聚合平台拉初筛清单)后逐条人工核验,常要两三轮, -存活候选按替代等级分级(P2P/功能兼容/其他)圈 2~3 颗,做参数对比与风险评估成报告交回。 -**任何替代必须把差异与风险写清 —— 差异没核清的不放行。** +export const NARRATIVE = `§0 开案:A1 判出一条料要替代(原型号 EOL / 缺货 / 客户点名),这一单就到你手上。 +成品只有一样:**一份能让 BD 拿去跟客户讲的替代对标结论** —— +哪几个候选、推荐哪一个、参数差在哪、有什么风险、要不要打样验证。 -S1 初选核险:一手资料以原厂官网为准(产品页/规格书/PCN·EOL 公告); -查芯片规格书确认初选料有无 EOL/停产风险,生命周期对照 PN物料主库; -对品牌原厂字典判是否我司主推 —— 无风险且主推,直接采信初选、免替代办结。 -顺手查替代料库与技术问题案例库(裁量项),有沉淀方案或已知坑先亮出来。 +**这一单只有一步**:把选型这件事派给 FAE(执行工单),他填完你收进台账,然后提交放行。 +别把它拆成「先查规格、再判品牌、再撒网、再核验……」—— 那是他脑子里的过程, +不是系统要管的步。你替他把表摆好就够了。 -S2 圈选替代:先撒网后核验,常要两三轮。逐条人工核验做全面参数对比 —— -先明确每项数据的来源(一律以原厂官网规格书为准),再逐项对标(Pin-to-Pin); -存活者按替代等级分级;候选过客户技术画像禁用清单;不足 2~3 颗就放宽参数或换角度重新撒网。 +【T_select 派执行单】发 human 行动(workOrder="execution"),卡面 blocks 放**两张可编辑表**: +· 初筛清单:列 = 原型号 / 候选型号 / 品牌 / 生命周期; +· 替代对标表:列 = 候选型号 / 替代等级 / 参数差异项 / 差异风险 / 验证建议 / FAE签字。 +两张都 editable:true;没有的值给空字符串 —— 空格子界面会自己标红「待填写」。 +ask 一句话说清要他做什么;basis 摆上原型号、芯片类型、客户的特殊要求(裸卡基座拒发)。 -S3 对比评估交付:收齐候选规格书,关键参数逐项对比写进替代对标表,标出差异项与替代等级, -写明差异带来的风险;按等级定评估深度 —— P2P 核确免改动,功能兼容/其他逐项评软件开发、 -外围电路与 layout 改动。FAE 签字后交回发起方。 +他提交后,回传值出现在你的轨迹里:表格那一份的键是 **table:0 / table:1**(块序号), +值是整张表的 JSON —— **第一行是列名**,其余每行一条,列序与你派出去时一致。 +照它 write 进 \`quote_alternative.shortlist\` 与 \`quote_alternative.benchmark\`, +并把对标表条数记进 benchmark_total。**写完就 finish T_select,别再发第二张单** —— 他已经答过了。 -全程随时可做:知识入库 —— 规格书入库、替代关系回填替代料库与 PN物料主库、踩到的坑记案例库。 +【提交放行】对标表有行了 —— **先 start T_release**(人工激活:放行触达世界,必须按一下), +然后 submit,BD 验收。终局唯放行:你不能自己宣布完成。 -§Z 结案:必做的事都办完、没有进行中的事,自动算结案(CMMN AutoComplete)。 -无需替代的,确认初选可用即结案;需要替代的,推荐报告(已签字)交回发起方即结案 —— -每办一案,知识库厚一层。`; +【一路上要说话】负责人正看着这一单。每一拍带一句 say,说**业务的话**: +哪颗料要替代、你打算怎么办、FAE 回了什么结论、这一单往哪走。 +一两句、口语,像同事扭头说一声。别报「写草稿 2 行」这种系统词 —— +他明天要拿这些话去跟客户讲。`; + +/** + * 步骤剧本 —— 与 A1 同一套机制:`doneWhen` 为真,引擎自己收步、**不问模型**。 + * + * 只有一条真活。放行那一项不给 `doneWhen` —— 它触达世界,必须人按一下(铁律③)。 + */ +export const ALTERNATIVE_STEPS = [ + { + item: 'T_select', + intent: '派一张执行单给 FAE,收回初筛清单与替代对标结论,并结算汇总数', + writes: ['quote_alternative.shortlist', 'quote_alternative.benchmark', 'quote_alternative.verdict'], + reads: [], + tools: [], + /* + * 对标表有行了才算办完。**不能写 `benchmark_total != null`** —— + * S-FEEL 里未绑定的名字 `!= null` 求值为 true,那一步开局即被收掉(A1 踩过,测里拦下的)。 + */ + doneWhen: 'benchmark_total > 0', + guide: `**照这个骨架发,一个字段都别少**(prose 描述不管用,直接抄): +{"kind":"human","itemId":"T_select","workOrder":"execution","card":{ + "title":"…","ask":"这颗料原型号停产,请挑替代并给对标结论", + "basis":[{"k":"…","v":"…"}], + "blocks":[ + {"t":"table","title":"初筛清单","cols":["原型号","候选型号","品牌","生命周期"],"editable":true, + "rows":[["LM358DR","LM358BIDR","",""],["LM358DR","TLV9062IDR","",""]]}, + {"t":"table","title":"替代对标表","cols":["候选型号","替代等级","参数差异项","差异风险","验证建议","FAE签字"],"editable":true, + "rows":[["","","","","",""]]} + ]}} +**rows 必须有行**:一条料一行,不知道的值给空字符串(界面自己标红「待填写」)。 +只给 cols 不给 rows,人看到的是一排表头加一片空白,一个格子都填不了(实测栽过)。 +**blocks 少了整张卡就废了** —— 基座会用当下局面兜底成一句「需要你判断下一步」, +人拿到一张没有表的空卡,这一步等于没派(实测栽过)。 + +**候选是你提的,不是让 FAE 从零想** —— 初筛清单每个原型号先摆 **2~3 行候选** +(原型号 + 候选型号两列你填,你认得这些料;品牌与生命周期留空让他核)。 +判据「2~3 颗分级候选经确认」机检的就是候选条数,只给一行**过不了门**。 +替代对标表照候选逐行起,basis 摆上原型号、芯片类型、客户特殊要求(**裸卡基座拒发**)。 + +他提交后回传值进你的轨迹:表格的键是 **table:0 / table:1**,值是整张表的 JSON, +**第一行是列名**。照它 write 进 \`shortlist\` 与 \`benchmark\`, +**同一拍再写一行 \`quote_alternative.verdict\`**(验收机检读的就是它): +· candidate_total = 初筛清单条数(要 ≥2); +· benchmark_total = 对标表条数; +· undocumented_total = 对标表里「参数差异项」或「差异风险」还空着的条数(要 = 0); +· recommend_pn 主推候选、summary 一句人话结论。 +这三个数写不对,submit 会被门挡回来 —— **它们不是台账装饰,是判据本身**。 +**写完就 finish,别再发第二张单** —— 他已经答过了。 +**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 +台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉 +(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, + }, + { + item: 'T_release', + intent: '对标结论已齐,提交放行交 BD 验收', + writes: [], + reads: [], + tools: [], + // 放行触达世界 —— 引擎不自动收它(所以这里不给 doneWhen) + guide: `对标表有行了 —— **先 start T_release**(它标了人工激活),然后 submit,BD 验收。 +终局唯放行:你不能自己宣布完成。`, + }, +]; diff --git a/activities/quote-alternative/src/index.ts b/activities/quote-alternative/src/index.ts index 9f06d36..060522f 100644 --- a/activities/quote-alternative/src/index.ts +++ b/activities/quote-alternative/src/index.ts @@ -10,7 +10,6 @@ const BENCHMARK = 'quote_alternative.benchmark'; export default defineActivity({ name: 'quote-alternative', - paradigm: 'loop', async run(base: ActivityBase): Promise { const shortlist = await base.ledger.read(SHORTLIST); diff --git a/activities/quote-alternative/src/manifest.ts b/activities/quote-alternative/src/manifest.ts index 410dfe4..0096f03 100644 --- a/activities/quote-alternative/src/manifest.ts +++ b/activities/quote-alternative/src/manifest.ts @@ -9,7 +9,7 @@ import type { ActivityManifest, DmnModel } from '@contracts'; import { ALTERNATIVE_SKILLS } from './skills.ts'; import { ALTERNATIVE_CASE } from './views/report.ts'; import { activityIdSchema } from '@contracts'; -import { CMMN_XML, NARRATIVE } from './case-model.ts'; +import { ALTERNATIVE_STEPS, CMMN_XML, NARRATIVE } from './case-model.ts'; const DOMAIN = 'quote_alternative'; const ACTIVITY_ID = activityIdSchema.parse('quote-alternative'); @@ -41,7 +41,7 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'A2 · 替代选型', - packageVersion: '0.1.3', + packageVersion: '2.0.0', category: '统一报价', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-quote-alternative', @@ -51,13 +51,24 @@ export const manifest: ActivityManifest = { activityId: ACTIVITY_ID, version: '0.1.0', input: { + /* + * 入参对齐**上游真发的东西**(2026-07-31,MVP 第一轮跑出来的)。 + * + * A1 的 `to-alternative` 口发的是 `altRequests`(一批替代评估请求,一条料一行), + * 不是单个 `originalPn` —— 而这里从前把 `originalPn` 写成必填, + * 于是流程走到 A2 就停在补全卡上:「缺 originalPn」。 + * 上游发一批,下游要一个,这是**契约对不上**,不是数据缺失。 + * + * 一单可能要替代好几颗料,`altRequests` 才是那件事的真形状。 + */ data: { type: 'object', - required: ['originalPn'], + required: ['rfqRef', 'altRequests'], properties: { - originalPn: { type: 'string' }, + rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, + altRequests: { type: 'array', title: '替代评估请求(原 PN + 替代要求)' }, + originalPn: { type: 'string', title: '原型号(单条时的便捷入口)' }, chipType: { type: 'string' }, - params: { type: 'object' }, scenario: { type: 'string' }, }, }, @@ -67,19 +78,43 @@ export const manifest: ActivityManifest = { mandatory: [ { name: 'benchmark', - payload: { type: 'object', required: ['rows'], properties: { rows: { type: 'array' } } }, + payload: { type: 'object', required: ['rfqRef', 'rows'], properties: { + /* + * **下游那条边读的就是它**(流程包 E5:A2→A3 `needSourcing = true`)。 + * 从前这个口只发对标表,而边上写着 needSourcing —— 那条边永远不成立, + * 选完型再要寻价的单子出了 A2 就没路可走(与 A1 当年的 G1 全流死路同一种错)。 + */ + needSourcing: { type: 'boolean', title: '选定的替代料仍需寻价' }, rfqRef: { type: 'string' }, rows: { type: 'array' } } }, description: '替代对标表(已签字)', }, ], selective: [], }, - requiredDeliverables: [{ key: 'benchmark-table', label: '替代对标表(FAE 签字)' }], + /* + * 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 + * 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, + * 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒, + * 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。 + */ + requiredDeliverables: [ + { key: 'benchmark-table', label: '替代对标表(FAE 签字)', ledger: `${DOMAIN}.benchmark` }, + ], + /* + * 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 + * + * 上一版写的是 `candidatesConfirmed >= 2` 与 `undocumentedDiff = 0` —— + * 这两个名字**台账里根本没有**(既不是案卷项,也不是任何一张表的列)。 + * S-FEEL 拿未绑定的名字比大小求值为假,于是这两条判据**永远达不了标**: + * Agent 把活办得再干净也过不了 submit 门,连拒五拍熔断 —— 实测 A2 就死在这儿。 + * 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 + */ acceptanceCriteria: [ - { key: 'candidates', label: '2~3 颗分级候选经确认', level: 'L2', predicate: 'candidatesConfirmed >= 2' }, - { key: 'diff-clear', label: '差异项与风险逐项写清', level: 'L2', predicate: 'undocumentedDiff = 0' }, + { key: 'candidates', label: '2~3 颗分级候选经确认', level: 'L2', predicate: 'candidate_total >= 2' }, + { key: 'diff-clear', label: '差异项与风险逐项写清', level: 'L2', predicate: 'undocumented_total = 0' }, { key: 'source-traceable', label: '每项数据来源可溯(以原厂官网规格书为准)', level: 'L3' }, { key: 'fae-sign', label: 'FAE 签字放行', level: 'L5' }, ], + revisableAtRelease: [], prohibitions: [ { key: 'no-unclear-diff', statement: '差异没核清的不放行' }, { key: 'no-banned', statement: '客户技术画像禁用清单里的型号不得作候选' }, @@ -105,8 +140,11 @@ export const manifest: ActivityManifest = { { domain: DOMAIN, object: 'shortlist', + kind: 'record', + caseKey: 'rfq_ref', label: '初筛清单', fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'original_pn', type: 'text', primaryKey: true, masked: false, label: '原型号' }, { name: 'candidate_pn', type: 'text', primaryKey: true, masked: false, label: '候选型号' }, { name: 'brand', type: 'text', primaryKey: false, masked: false, label: '品牌' }, @@ -116,8 +154,11 @@ export const manifest: ActivityManifest = { { domain: DOMAIN, object: 'benchmark', + kind: 'record', + caseKey: 'rfq_ref', label: '替代对标表', fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'original_pn', type: 'text', primaryKey: true, masked: false }, { name: 'candidate_pn', type: 'text', primaryKey: true, masked: false }, { name: 'grade', type: 'text', primaryKey: false, masked: false, label: '替代等级' }, @@ -127,6 +168,33 @@ export const manifest: ActivityManifest = { { name: 'signed_by', type: 'text', primaryKey: false, masked: false, label: 'FAE 签字' }, ], }, + { + /* + * 选型结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。 + * 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」, + * 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。 + */ + domain: DOMAIN, + object: 'verdict', + kind: 'record', + caseKey: 'rfq_ref', + label: '选型结论', + fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, + { name: 'original_pn', type: 'text', primaryKey: false, masked: false, label: '原型号' }, + { name: 'candidate_total', type: 'integer', primaryKey: false, masked: false, label: '候选条数' }, + { name: 'benchmark_total', type: 'integer', primaryKey: false, masked: false, label: '对标条数' }, + { + name: 'undocumented_total', + type: 'integer', + primaryKey: false, + masked: false, + label: '差异项或风险缺失条数', + }, + { name: 'recommend_pn', type: 'text', primaryKey: false, masked: false, label: '主推候选' }, + { name: 'summary', type: 'text', primaryKey: false, masked: false, label: '选型结论' }, + ], + }, ], listeners: [ { @@ -161,6 +229,8 @@ export const manifest: ActivityManifest = { cmmnXml: CMMN_XML, narrative: NARRATIVE, dmn: GRADE_DMN, + /* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */ + steps: ALTERNATIVE_STEPS, }, skills: ALTERNATIVE_SKILLS, viewTemplates: [{ name: 'case-overview', html: ALTERNATIVE_CASE }], diff --git a/activities/quote-alternative/src/views/report.ts b/activities/quote-alternative/src/views/report.ts index 7a3e012..48b8e3a 100644 --- a/activities/quote-alternative/src/views/report.ts +++ b/activities/quote-alternative/src/views/report.ts @@ -1,26 +1,102 @@ /** - * A2 视图模版 —— 案例全景(AM 07:loop 按案例模型的阶段与里程碑组织汇报)。 + * A2 替代选型的 HTML 汇报模版 —— **活动自己持有的资产**(§9.3),随包版本冻结。 + * + * ── 这一屏给谁看、他此刻要判什么 ────────────────────────────────── + * 看的人是 FAE 与发起的 BD/PM。他此刻只判一件事:**这单还用不用我插手**。 + * 于是版面按「先成果、后局面」排 —— 成果是他要签的东西,局面是他决定插不插手的依据: + * 一 初筛清单(撒了多少网)· 二 替代对标表(交付件本体) + * → 三 案例阶段 · 四 当下可为 · 五 目标缺口(AI 的办案局面) + * 指标带扫一眼就该有结论(候选够不够、差异核没核清),往下五节是「凭什么这么说」。 + * + * ── 画面上的数从哪来(一个都不是编的)──────────────────────────── + * · `mine:quote_alternative.*` 是**本单自己写下的草稿行** —— 别单放行过的记录不混进来; + * 列名逐字取自 `declarations.ledger`,编一个不存在的列名 = 屏上永远空白。 + * · `case:*` / `enabledCount` / `gapCount` / `activeStage` 由语义引擎每拍算出, + * 是确定事实、零 LLM 即可成屏 —— 模型只决定「下一步做什么」,不负责画面。 + * · 指标带上「初筛候选 / 已对标」两格指的是**数组本身**:填数时数组取长度 + * (view-render 的 `displayValue` 约定),所以不必为「几颗」另立一个计数字段。 + * + * ── 两条不撒谎的纪律 ────────────────────────────────────────── + * ① 右上角挂的是「在办」灯不是印章:这一屏是活的局面,它没盖过章,就别画成盖了章的样子; + * 紫(tone `case`)是**案例活动的标记色不是状态色** —— 红绿橙只留给「达标没达标」。 + * ② 表空着就是「本单还没写下这一行」,不是渲染坏了 —— 每张表的说明里都写明空着意味着什么, + * 否则人会把空白读成故障。 + * + * 容器是 `sandbox=""` iframe:无脚本、无外链,样式与图标由 `@sdk` 自带。 */ -import { kvCard, noteCard, stagesCard, viewShell } from '@sdk'; +import { noteOnly, section, stagesOnly, statsBand, tableOnly, viewShell } from '@sdk'; export const ALTERNATIVE_CASE = viewShell({ title: '替代选型 · 案例全景', - kicker: 'A2 · 案例活动(loop)', + kicker: 'A2 · 替代选型 · 案例活动', tone: 'case', + icon: 'search', + stamp: { kind: 'live', text: '案例在办 · 非定稿' }, + sign: 'A2 替代选型 · 本页每拍按当下局面重画', body: - kvCard('本单标的', [ - ['原型号', 'originalPn', 'mono'], - ['芯片类型', 'chipType'], - ['当前阶段', 'activeStage', 'tag'], + statsBand([ + ['初筛候选', 'mine:quote_alternative.shortlist'], + ['已对标', 'mine:quote_alternative.benchmark'], ['当下可为', 'enabledCount'], ['目标缺口', 'gapCount'], ]) + - stagesCard('阶段(CMMN)', 'case:stages', '阶段推进到 Enabled 为止;触达世界的计划项须人工启动。') + - stagesCard('当下可为', 'case:enabled') + - stagesCard('目标缺口', 'case:gaps', '缺口决定下一步是调人还是立监听。') + - noteCard( - '跨人卡', - '选型常要向 BD 补客户口径 —— 那是跨任务征答:卡以系统消息送达对方的消息要素、就地作答,' + - '不占对方的任务清单,也不开放本任务的对话流。它是一次性的解答请求,不是责任转移。', + section('初筛清单(本单撒网所得)', + tableOnly('mine:quote_alternative.shortlist', [ + ['原型号', 'original_pn', 'mono'], + ['候选型号', 'candidate_pn', 'mono'], + ['品牌', 'brand'], + ['生命周期', 'lifecycle'], + ]) + + noteOnly( + '先撒网后核验,常要两三轮:检索与聚合平台拉来的候选先落这一张,逐条人工核验之前都不算数。' + + '生命周期一栏是初筛就要看的第一道险 —— EOL/停产的当场淘汰;' + + '不足 2~3 颗就放宽参数或换角度重新撒网。这张表空着,是本单还没撒网,不是没找着。', + ), + { text: '常要两三轮' }, + ) + + section('替代对标表(本单交付件)', + tableOnly('mine:quote_alternative.benchmark', [ + ['候选型号', 'candidate_pn', 'mono'], + ['替代等级', 'grade'], + ['参数差异项', 'param_diff'], + ['差异风险', 'risk'], + ['验证建议', 'verify_advice'], + ['FAE 签字', 'signed_by'], + ]) + + noteOnly( + '这是要签字交回发起方的东西:差异没核清的不放行 —— 「差异风险」空着的行,' + + '就是此刻拦着放行的那几行。替代等级由判定表出(P2P / 功能兼容 / 其他),不是模型自己说了算;' + + '等级定评估深度:P2P 核确免改动,其余逐项评软件、外围电路与 layout。' + + '每项数据一律以原厂官网规格书为准。', + ), + { text: '差异没核清不放行' }, + ) + + section('案例阶段(CMMN 生命周期)', + stagesOnly('case:stages') + + noteOnly( + '阶段的生命周期态由语义引擎推进到 Enabled 为止;凡触达世界或产出关键交付的计划项' + + '必须人工启动 —— Enabled ≠ Active。选型是一事一议:撒几轮网、哪颗值得深评、' + + '要不要换角度重来,事前排不出谱,所以调度权交给 AI,系统只保证边框:预算、停滞熔断、放行归人。', + ), + { text: '推进到 Enabled 为止' }, + ) + + section('当下可为(AI 的合法选项)', + stagesOnly('case:enabled') + + noteOnly( + 'AI 只能在这个集合里选下一步:可以越谱,不可越权。标「待启动」的是触达世界或' + + '产出关键交付的项 —— 要按一下再动。这一栏空着不是它偷懒,是当下确实无可为:' + + '要么在等人回话,要么在等外面的回音。', + ), + { text: '本拍的合法选项' }, + ) + + section('目标缺口(离交付还差什么)', + stagesOnly('case:gaps') + + noteOnly( + '缺口只有人知道 → 调人。选型常要向 BD 补客户口径,那是跨任务征答:卡以系统消息送达' + + '对方、就地作答,不占对方的任务清单,也不开放本任务的对话流 —— 一次性的解答请求,' + + '不是责任转移。缺口在世界侧 → 立监听(时限必配)。' + + '当下可为 ∩ 目标所需 = 空,就是依赖断点。这一栏空着,是必做的事都办完了。', + ), + { text: '空 = 必做的都办完了' }, ), }); diff --git a/activities/quote-archive/src/case-model.ts b/activities/quote-archive/src/case-model.ts index 72dfada..41d3d65 100644 --- a/activities/quote-archive/src/case-model.ts +++ b/activities/quote-archive/src/case-model.ts @@ -1,61 +1,95 @@ /** - * A7 情报归档的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。 + * A7 情报归档的案例模型 —— **一步落三张表**(2026-07-31 照 A1 的状态机模式重做)。 * - * 2026-07-29 由步序声明(flow)改写而来。归档这件事没有分支:登记结果 → 更新画像 → - * 统计落地率 → 验收。三件事互不依赖,同阶段并列(CMMN 里同阶段的项本就无隐含次序)。 + * 归档这件事没有分支,也没有判断:一单报价有了结果,把它沉淀成下一单用得上的情报 —— + * 历史价、客户画像、落地率统计。上一版把这三样各建成一个计划项, + * 可它们**互不依赖、同一拍就能写完**,分开只换来两次多余的 finish。 + * + * 两项: + * T_archive —— 一步落三张表(历史价 / 客户画像 / 落地率); + * T_release —— 封卷放行(人工激活)。 + * + * 归档的价值全在**下一单**:这一单的拒绝原因,是下一单定价时的判据。 + * 所以「不得空归档」是它唯一的硬判据 —— 三张表都写了才算办完。 */ export const CMMN_XML = ` - - - - + + - - create - - - update - reasonLogged = true + + update + archived_total > 0 - - - - - - - - - - - - - - - - - + + + - - PT12H - + + + + `; export const NARRATIVE = `§0 开案:一单报价有了结果(成交 / 拒绝 / 超时)即开案。 -这件事不产生对外交付,它产生的是**下一单的本钱** —— 报价落地率、客户画像、竞对价位。 +这一单的价值**全在下一单**:今天记下的拒绝原因,是下次定价时的判据; +今天记下的成交价,是下次的基准。所以归档不是收尾的仪式,是情报生产。 -S1 回写沉淀:先登记结果 —— 拒绝的要写清原因与竞对价位,**「客户没回」不是原因**, -问不出来就写「未获反馈」,不许编一个听着合理的理由。 -再按结果更新客户画像标签与等级(利润模型 P-01 的客户等级因子取的就是它), -并把这一单计入当月落地率统计(TypeA 命中率、D+3 回复率一并算)。 +**一步就能办完** —— 三张表互不依赖,同一拍写完: +· \`market.price_history\`:这一单报了什么价、什么时候报的、结果如何、拒绝原因是什么; +· \`customer.profile\`:客户画像该更新什么(价格敏感度、决策周期、常用条款); +· \`shared.landing_stats\`:这个月的落地率、A 类命中、三日回复率。 -§Z 结案:原因已回写、画像已更新、BD 签字放行,自动结案。 -归档做得好不好,不在这一单看得出来 —— 在下一次报价能不能一眼看到「上次为什么丢」看得出来。`; +**不得空归档**:三张表都得有东西。「这一单没什么好记的」不是理由 —— +结果本身就是情报,拒绝的原因尤其是。写完把 archived_total 记上,再 finish。 + +【封卷放行】**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据 +(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。 + +【一路上要说话】每一拍带一句 say,说**业务的话**:这一单最后怎么样、为什么、 +下次遇到同类该注意什么。一两句、口语。别报「写草稿 N 行」这种系统词。`; + +/** + * 步骤剧本 —— 与 A1 同一套。放行那一项不给 `doneWhen`(触达世界)。 + */ +export const ARCHIVE_STEPS = [ + { + item: 'T_archive', + intent: '一步落三张表:历史价、客户画像、落地率统计', + writes: ['market.price_history', 'customer.profile', 'shared.landing_stats', 'quote_archive.verdict'], + reads: [], + tools: [], + // 三张都写了才算归档过。不写 `!= null` —— 未绑定的名字那样求值为 true(A1 踩过) + doneWhen: 'archived_total > 0', + guide: `三张表互不依赖,**同一拍写完**: +· \`market.price_history\`:报了什么价、什么时候、结果如何、拒绝原因; +· \`customer.profile\`:客户画像该更新什么(价格敏感度、决策周期、常用条款); +· \`shared.landing_stats\`:本月落地率、A 类命中、三日回复率。 +**不得空归档** —— 「没什么好记的」不是理由,结果本身就是情报,拒绝原因尤其是。 +**同一拍再写一行 \`quote_archive.verdict\`**(验收机检读的就是它): +· archived_total = 已归档条数; +· reason_logged_total = 已回写拒绝原因/竞对价位的条数(要 > 0); +· profile_updated_total = 已更新客户画像标签的条数(要 > 0); +· summary 一句人话结论。写不对 submit 会被门挡回来。写完再 finish。 +**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 +台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉 +(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, + }, + { + item: 'T_release', + intent: '情报已沉淀,封卷放行', + writes: [], + reads: [], + tools: [], + guide: `**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据,然后 submit。 +终局唯放行:你不能自己宣布完成。`, + }, +]; diff --git a/activities/quote-archive/src/index.ts b/activities/quote-archive/src/index.ts index 2e65234..0d25cba 100644 --- a/activities/quote-archive/src/index.ts +++ b/activities/quote-archive/src/index.ts @@ -7,7 +7,6 @@ import type { ActivityBase, ActivityOutcome } from '@sdk'; export default defineActivity({ name: 'quote-archive', - paradigm: 'flow', async run(base: ActivityBase): Promise { const history = await base.ledger.read('shared.history_price'); diff --git a/activities/quote-archive/src/manifest.ts b/activities/quote-archive/src/manifest.ts index f57b9bb..61b9d59 100644 --- a/activities/quote-archive/src/manifest.ts +++ b/activities/quote-archive/src/manifest.ts @@ -6,17 +6,19 @@ */ import type { ActivityManifest } from '@contracts'; import { ARCHIVE_SKILLS } from './skills.ts'; -import { CMMN_XML, NARRATIVE } from './case-model.ts'; +import { CMMN_XML, NARRATIVE, ARCHIVE_STEPS } from './case-model.ts'; import { activityIdSchema } from '@contracts'; import { ARCHIVE_LOGGED, ARCHIVE_RELEASE } from './views/report.ts'; const ACTIVITY_ID = activityIdSchema.parse('quote-archive'); +/** 本活动自己的数据域 —— 三张沉淀表落 `shared`,只有归档结论落这儿 */ +const DOMAIN = 'quote_archive'; export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'A7 · 情报归档', - packageVersion: '0.1.6', + packageVersion: '2.1.0', category: '统一报价', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-quote-archive', @@ -28,9 +30,10 @@ export const manifest: ActivityManifest = { input: { data: { type: 'object', - required: ['customerCode', 'quoteNo'], + required: ['rfqRef', 'customerCode', 'quoteNo'], properties: { /** 归档写 (pn, quoted_at) 这一行,两个主键都由调用方给定 */ + rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, pn: { type: 'string' }, quotedAt: { type: 'string' }, customerCode: { type: 'string' }, @@ -51,15 +54,28 @@ export const manifest: ActivityManifest = { ], selective: [], }, + /* + * 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 + * 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, + * 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒, + * 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。 + */ requiredDeliverables: [ - { key: 'history-archive', label: '历史价格情报(归档)' }, - { key: 'landing-stats', label: '报价落地率统计' }, + { key: 'history-archive', label: '历史价格情报(归档)', ledger: 'market.price_history' }, + { key: 'landing-stats', label: '报价落地率统计', ledger: 'shared.landing_stats' }, ], + /* + * 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 + * 上一版的名字(reasonLogged / profileUpdated)**台账里根本没有** —— S-FEEL 拿未绑定的名字 + * 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。 + * 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 + */ acceptanceCriteria: [ - { key: 'reason-logged', label: '拒绝原因 / 竞对价位已回写', level: 'L1', predicate: 'reasonLogged = true' }, - { key: 'profile-updated', label: '客户画像标签已更新', level: 'L2', predicate: 'profileUpdated = true' }, + { key: 'reason-logged', label: '拒绝原因 / 竞对价位已回写', level: 'L1', predicate: 'reason_logged_total > 0' }, + { key: 'profile-updated', label: '客户画像标签已更新', level: 'L2', predicate: 'profile_updated_total > 0' }, { key: 'bd-release', label: 'BD 放行', level: 'L5' }, ], + revisableAtRelease: [], prohibitions: [{ key: 'no-blank-archive', statement: '不得空归档 —— 每一单不成交的报价也是情报资产' }], authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 2 }, resources: { @@ -77,8 +93,9 @@ export const manifest: ActivityManifest = { ledger: [ { // 共享域:跨活动的沉淀事实(AM 11.3 暗耦合禁令的两条合法路径之一) - domain: 'shared', - object: 'history_price', + domain: 'market', + object: 'price_history', + kind: 'master', label: '历史价格情报', fields: [ { name: 'pn', type: 'text', primaryKey: true, masked: false }, @@ -88,8 +105,9 @@ export const manifest: ActivityManifest = { ], }, { - domain: 'shared', - object: 'customer', + domain: 'customer', + object: 'profile', + kind: 'master', label: '客户资料库', fields: [ { name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' }, @@ -100,6 +118,7 @@ export const manifest: ActivityManifest = { { domain: 'shared', object: 'landing_stats', + kind: 'derived', label: '报价落地率统计', fields: [ { name: 'period', type: 'text', primaryKey: true, masked: false, label: '月份' }, @@ -108,6 +127,26 @@ export const manifest: ActivityManifest = { { name: 'reply_rate_d3', type: 'number', primaryKey: false, masked: false, label: 'D+3回复率' }, ], }, + { + /* + * 归档结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。 + * 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」, + * 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。 + */ + domain: DOMAIN, + object: 'verdict', + kind: 'record', + caseKey: 'rfq_ref', + label: '归档结论', + fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, + { name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' }, + { name: 'archived_total', type: 'integer', primaryKey: false, masked: false, label: '已归档条数' }, + { name: 'reason_logged_total', type: 'integer', primaryKey: false, masked: false, label: '已回写原因条数' }, + { name: 'profile_updated_total', type: 'integer', primaryKey: false, masked: false, label: '已更新画像条数' }, + { name: 'summary', type: 'text', primaryKey: false, masked: false, label: '归档结论' }, + ], + }, ], listeners: [], // 7 天闭环(MVP示例 §1.7) @@ -120,6 +159,8 @@ export const manifest: ActivityManifest = { cmmnXml: CMMN_XML, narrative: NARRATIVE, dmn: { drd: [], tables: [] }, + /* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */ + steps: ARCHIVE_STEPS, }, skills: ARCHIVE_SKILLS, viewTemplates: [ diff --git a/activities/quote-archive/src/views/report.ts b/activities/quote-archive/src/views/report.ts index 464619f..0c12004 100644 --- a/activities/quote-archive/src/views/report.ts +++ b/activities/quote-archive/src/views/report.ts @@ -1,43 +1,154 @@ /** - * A7 视图模版 —— 归档两屏:结果登记 / 闭环待放行(AM 07)。 - * 归档是这条流的收尾:它写回的是**下一单的判据**,所以要说清「这次学到了什么」。 + * A7 情报归档的 HTML 报告模版 —— **活动自己持有的资产**(§10.3),随包版本冻结。 + * + * 归档最容易被当成走过场:单子已经丢了,再填一遍原因像是给失败写检讨。 + * 所以这两屏从头到尾只讲一件事 —— **这一单留下了什么,下一单谁会用它**。 + * 三张共享域表各自对着下一单的一个判据:历史价格情报喂三分类 P-03 的「距上次报价」、 + * 客户资料库喂利润模型 P-01 的客户等级因子、落地率统计喂驾驶舱。人在这一屏上要判断的 + * 不是「填没填」,是**填的这几个字下一单的机器读不读得懂** —— 写成散文只能被人读, + * 写成字段才进得了决策表。 + * + * 两个局面: + * ① 结果登记 —— 拒绝原因与竞对价位在不在、算不算数; + * ② 闭环待放行 —— 三样沉淀齐不齐;签完这一笔整条流走到终点。 + * + * 这里**没有「案例进程」一节**:归档只有 S1 一个阶段,摆出来是一行凑数的进度条, + * 而版面上每多一节,真正要看的三张表就少一分注意力。 + * + * 容器是 `sandbox=""` iframe:无脚本、无外链、样式自带;数据由基座推送时填 + * (`data-slot` 取值、`data-rows` 逐行展开)。状态章一律用「在办」灯 —— + * 模版是随包冻结的静态字符串,盖不了一个还没发生的章;`verdict` 的橙档专指 + * 「还没发生,等你按」,绿只留给已达标的机检结论。 */ -import { kvCard, noteCard, tableCard, viewShell } from '@sdk'; +import { noteOnly, section, statsBand, tableOnly, verdict, viewShell } from '@sdk'; +/** + * 静态阶梯 —— **判据的写法与沉淀的去向不是数据**,它们随包冻结,不走 `data-rows`。 + * 沿用外壳已有的 `.steps` 版式,与引擎推的阶段列表同一副面孔。 + */ +function ladder(rows: ReadonlyArray): string { + return `
    ${rows + .map( + ([no, term, gloss]) => + `
  • ${no}${term} ${gloss}
  • `, + ) + .join('')}
`; +} + +/** 局面一:S1 回写 —— 人此刻要判的是「这条原因,下一单读不读得懂」 */ export const ARCHIVE_LOGGED = viewShell({ title: '结果登记', - kicker: 'A7 · 情报归档', + kicker: 'A7 · 情报归档 · S1 回写', tone: 'archive', + icon: 'database', + stamp: { kind: 'live', text: '回写中' }, + sign: 'A7 情报归档 · 结果登记(共享域)', body: - kvCard('本单结果', [ - ['型号 PN', 'pn', 'mono'], - ['报价日期', 'quotedAt', 'mono'], - ['拒绝原因', 'lostReason'], + statsBand([ + ['报价日期', 'quoted_at'], ['竞对价位', 'competitor_price'], + ['客户等级', 'grade'], ]) + - noteCard('为什么要登记', '拒绝原因与竞对价位是下一单的判据:它们回写历史价格情报,供 P-03 的「距上次报价 / 行情稳定」与 P-01 的客户等级因子取用。这一单的败仗,是下一单的地形图。'), + section('本单登记的结果', + tableOnly('mine:shared.history_price', [ + ['型号 PN', 'pn', 'mono'], + ['报价日期', 'quoted_at', 'mono'], + ['拒绝原因', 'lost_reason'], + ['竞对价位', 'competitor_price'], + ]) + + noteOnly( + '「客户没回」不是原因。问不出来就写「未获反馈」—— 不许编一个听着合理的理由:' + + '编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。', + ), + { text: '只列本单写下的行' }, + ) + + section('什么样的原因才算数', + ladder([ + ['1', '竞对报价多少、差几个百分点', '要数,不要形容词'], + ['2', '输在价格、交期,还是资质 / 认证', '把败因归到一个维度上'], + ['3', '客户是否明说了可接受区间', '下一单的起点就藏在这句话里'], + ]) + + noteOnly( + '「价格高」不是原因,是结论。判据要能被下一单机器读取:' + + 'P-03 用「距上次报价 / 行情稳定」,P-01 用客户等级。' + + '写成一段散文,它就只能被人读;写成字段,它才进得了决策表。', + ), + { text: '写成字段' }, + ) + + section('这一单往哪儿沉 —— 三张共享域表', + ladder([ + ['1', '历史价格情报 · shared.history_price', '喂给下一单的三分类 P-03'], + ['2', '客户资料库 · shared.customer', '喂给下一单的利润模型 P-01'], + ['3', '报价落地率统计 · shared.landing_stats', '喂给驾驶舱的落地率与 D+3 回复率'], + ]), + { text: '跨活动的沉淀事实' }, + ) + + section('这一单的底线', + verdict('不得空归档 —— 每一单不成交的报价也是情报资产', 'wait', '原因未回写,预检当场退回') + + noteOnly( + '归档做得好不好,不在这一单看得出来 —— 在下一次报价能不能一眼看到「上次为什么丢」看得出来。', + ), + ), }); +/** 局面二:闭环待放行 —— 签完这一笔,整条工作流走到终点 */ export const ARCHIVE_RELEASE = viewShell({ title: '情报归档 · 闭环待放行', - kicker: 'A7 · 成果预览', - tone: 'ok', + kicker: 'A7 · 情报归档 · 成果待签', + tone: 'case', + icon: 'archive', + stamp: { kind: 'live', text: '待签字封卷' }, + sign: 'A7 情报归档 · 闭环成果(待放行)', body: - tableCard('历史价格情报回写', 'mine:shared.history_price', [ - ['型号', 'pn', 'mono'], - ['报价日期', 'quoted_at', 'mono'], - ['拒绝原因', 'lost_reason'], - ]) + - kvCard('画像与落地率', [ - ['客户编码', 'customer_code', 'mono'], - ['画像标签', 'profile_tags'], - ['统计周期', 'period', 'mono'], + statsBand([ + ['统计周期', 'period'], ['落地率', 'landing_rate'], + ['TypeA 命中', 'type_a_hit'], + ['D+3 回复率', 'reply_rate_d3'], ]) + - noteCard( - '闭环的含义', - '放行后这条工作流走到终点节点,状态收敛为「已完成」。' + - '终态封卷触发蒸馏钩子:从任务记忆归纳出活动记忆的候选条目(带证据链),' + - '经回放与人放行才成为下一版业务模型的一部分 —— 记忆不载权力。', + section('历史价格情报回写', + tableOnly('mine:shared.history_price', [ + ['型号 PN', 'pn', 'mono'], + ['报价日期', 'quoted_at', 'mono'], + ['拒绝原因', 'lost_reason'], + ['竞对价位', 'competitor_price'], + ]), + { text: '下一单 P-03 取它' }, + ) + + section('客户画像更新', + tableOnly('mine:shared.customer', [ + ['客户码', 'customer_code', 'mono'], + ['客户等级', 'grade'], + ['画像标签', 'profile_tags'], + ]), + { text: '下一单 P-01 取它' }, + ) + + section('报价落地率统计', + tableOnly('mine:shared.landing_stats', [ + ['月份', 'period', 'mono'], + ['落地率', 'landing_rate'], + ['TypeA 命中', 'type_a_hit'], + ['D+3 回复率', 'reply_rate_d3'], + ]) + + noteOnly( + '三张表都写在共享域:它们是跨活动的沉淀事实,不属于这一单 ——' + + '这也是活动之间除了流程边以外,唯一一条合法的信息通路。', + ), + { text: '驾驶舱取它' }, + ) + + section('放行前要过的三道判据', + ladder([ + ['1', '拒绝原因 / 竞对价位已回写', 'L1 机检 · reasonLogged = true'], + ['2', '客户画像标签已更新', 'L2 机检 · profileUpdated = true'], + ['3', 'BD 放行', 'L5 人签 · 签了才算数'], + ]), + { text: '两机检 · 一人签' }, + ) + + section('封卷之后会发生什么', + verdict('放行即闭环:这条工作流就此收敛为「已完成」', 'wait', '终态封卷触发蒸馏钩子') + + noteOnly( + '蒸馏从任务记忆归纳出活动记忆的候选条目(带证据链),经回放与人放行才成为' + + '下一版业务模型的一部分 —— 记忆不载权力。这一单丢了,但它把下一单的地形描出来了。', + ), ), }); diff --git a/activities/quote-intake/__tests__/activity.test.ts b/activities/quote-intake/__tests__/activity.test.ts index 8d562aa..cc88d96 100644 --- a/activities/quote-intake/__tests__/activity.test.ts +++ b/activities/quote-intake/__tests__/activity.test.ts @@ -1,13 +1,21 @@ /** - * 活动包的自测 —— **只准 import @sdk / @contracts 与白名单纯库**(编码规范 §5.1 / ADR-004)。 - * 安装两审那一侧的验收在 `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道, - * 没有代码通道,depcruise 双向隔离在守着。 + * A1 询价受理的包内自测 —— **只准 import @sdk / @contracts / @sfeel** + * (编码规范 §5.1 / ADR-004)。安装两审那一侧的验收在 + * `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,没有代码通道, + * depcruise 双向隔离在守着。 + * + * 案例活动没有 `run`,所以自测不跑回合(那是 Loop 的事)。这里测的是**清单本身**: + * ① 清单合规 + 判据等级不谎报; + * ② 案例模型双表示同在、触达世界的项标了人工激活; + * ③ 两张判定表逐行求值(能算的不问 LLM); + * ④ **声明与契约对得上** —— 这一段最容易在改动中悄悄错位: + * 必交付物的台账地址、验收谓词读的列、出口载荷要的标量,三处必须都有台账列承载。 */ import { describe, expect, it } from 'vitest'; import { activityManifestSchema } from '@contracts'; import { evaluateUnaryTest } from '@sfeel'; import { createMockHost } from '@sdk'; -import { manifest, P03_DMN } from '../src/manifest.ts'; +import { F01_DMN, INTAKE_DMN, P03_DMN, manifest } from '../src/manifest.ts'; import activity from '../src/index.ts'; describe('清单本身合规', () => { @@ -21,7 +29,7 @@ describe('清单本身合规', () => { it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => { for (const c of manifest.contract.acceptanceCriteria) { - if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef).toBeDefined(); + if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined(); } }); @@ -29,6 +37,12 @@ describe('清单本身合规', () => { expect(manifest.contract.prohibitions.length).toBeGreaterThan(0); expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0); }); + + it('每件必交付物都有台账地址 —— 没地址,「缺一不得提交放行」只能靠活动自报', () => { + for (const d of manifest.contract.requiredDeliverables) { + expect(d.ledger, d.key).toBeDefined(); + } + }); }); describe('案例模型(AM 03/12.2)—— 双表示同版本', () => { @@ -37,119 +51,342 @@ describe('案例模型(AM 03/12.2)—— 双表示同版本', () => { it('CMMN 与说明书同在,不允许分叉', () => { expect(model.cmmnXml).toContain(' { - // 解析归一、追问补全、查历史价 —— 三件都碰外界,一件也不许自动跑 - for (const id of ['T_normalize', 'T_ask_back', 'T_history']) { + it('触达世界的项标了人工激活(AM 4.1 原理一)', () => { + /* + * 只剩放行这一件。定位客户与归一 RFQ 两步**发卡但不落地**: + * 补全卡与执行卡都是「问一句」,回来的值仍旧只进草稿层, + * 真正触达世界的只有放行那一下(铁律③)。 + */ + for (const id of ['T_release']) { expect(cmmn.manualActivation, `${id} 应为人工激活`).toContain(id); } }); + /* + * **图上有几步就是几项**。上一版加了两个阶段、两个里程碑、一个裁量项、 + * 一个用户事件监听器 —— 12 个计划项装 6 步的活,业务一分没多,卡壳的花样多了一堆: + * 每一条「行动被拒」都是结构上的,没有一条是业务判断上的(实测)。 + * 阶段 / 里程碑 / 裁量项是 CMMN 有的东西,但「有」不等于「该用」。 + */ + /* + * 2026-07-31 用户重定前两态: + * ① `S1_customer` 定位客户信息 —— 按发件邮箱认人,认不出发补全卡请人建档; + * ② `S2_normalize` 归一客户 RFQ 输入 —— 解析正文逐条落表。 + * 旧的 S1_register / S2_ask_back / S3_normalize 三项合并进这两态。 + * + * 2026-08-01 补回 `S3_confirm`(**人的门,不是 AI 的步**): + * 追问从前是「归一里缺字段时的动作」,于是不缺字段就不叫人 —— 一封齐整的询价 + * 从起单一路自动跑到终局验收,人一眼没看过受理需求(用户当场指出)。 + * 阶段2 那张执行页(`A1-询价受理需求.html`,页脚是【修改】【确认受理记录】) + * 从来就是一张人签的页,所以它得是**一项**:人工激活、无 doneWhen、一单必经一次。 + */ + it('计划项照状态机一步一项:两态 + 人审定 + 后续三步 + 放行,不多建一件', () => { + expect(items(model.cmmnXml)).toEqual([ + 'S1_customer', + 'S2_normalize', + 'S3_confirm', + 'S4_history', + 'S5_classify', + 'S6_route', + 'T_release', + ]); + }); + + /* + * **触达世界的项必须人工激活** —— 引擎不许自己按下它们。 + * 受理需求那张卡交到人手上、放行那一签发出去,都是外面能看见的动作。 + */ + it('两处人的门都标了人工激活:受理需求审定 · 受理放行', () => { + for (const id of ['S3_confirm', 'T_release']) { + const block = model.cmmnXml.slice(model.cmmnXml.indexOf(`id="${id}"`)); + expect(block.slice(0, block.indexOf('')), `${id} 少了 manualActivationRule`).toContain( + ' { + for (const tag of [' { + for (const id of ['S1_customer', 'S2_normalize']) { + expect(cmmn.repetition, `${id} 应可重复`).toContain(id); + } + }); + it('末尾一张验收单 —— 完成只出自放行通过', () => { expect(model.cmmnXml).toContain('id="T_release"'); }); it('能算的交 DMN,不交 Agent —— 打标是 decisionTask', () => { expect(model.cmmnXml).toContain('decisionRef="p03-type"'); - expect(P03_DMN.tables.some((t) => t.id === 'p03-type')).toBe(true); + expect(INTAKE_DMN.tables.some((t) => t.id === 'p03-type')).toBe(true); + }); + + /* + * 作业指导**一处一份**:逐步的做法在剧本(`steps[].guide`),整单的事在说明书。 + * 从前两处各写一份六步,实测分叉过 —— 说明书里写着 S1_register / S2_ask_back / + * S3_normalize 三个案例模型里根本不存在的计划项,模型照它报 itemId,一报一个被拒。 + * 所以这里改测三件事:每一项都有剧本、剧本说得出做法、说明书**不许再点名计划项**。 + */ + it('每个计划项都有剧本 —— 轮到哪一步才摊开哪一步', () => { + const ids = items(model.cmmnXml); + expect((model.steps ?? []).map((s) => s.item)).toEqual(ids); + for (const step of model.steps ?? []) { + expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80); + expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6); + } + }); + + it('说明书不点名计划项 —— 点了就会与案例模型分叉(踩过)', () => { + // 认的是**计划项 id 的形状**(S数字_字母 / T_字母),不是「S1 → S2」那种路线简写 + const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]); + const ids = new Set(items(model.cmmnXml)); + for (const name of named) { + expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true); + } + }); + + it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => { + for (const topic of ['开案', '要说话', '一拍连做', '结案']) { + expect(model.narrative, topic).toContain(topic); + } }); }); /** 极小取值:只为断言用,不重实现引擎的解析器(那一份在 loop-engine 里有自己的测试) */ -function parseCmmnLite(xml: string): { manualActivation: string[] } { - const ids: string[] = []; - for (const m of xml.matchAll(//g)) { - if (m[0].includes('manualActivationRule')) ids.push(m[1]!); - } - return { manualActivation: ids }; +/** 计划项 id 按出现次序 —— 断言「图上有几步就是几项」 */ +function items(xml: string): string[] { + return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? ''); } -describe('三分类规则 P-03 —— 能算的不问 LLM', () => { - const table = P03_DMN.tables[0]!; +function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } { + const manualActivation: string[] = []; + const repetition: string[] = []; + for (const m of xml.matchAll(//g)) { + if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? ''); + if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? ''); + } + return { manualActivation, repetition }; +} - /** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */ - const decide = (scope: Record) => { - for (const rule of table.rules) { - const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope)); - if (hit) return { type: rule.then[0], play: rule.then[1], rule: rule.id }; - } - return undefined; - }; +/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */ +function decideBy(table: typeof P03_DMN, scope: Record) { + for (const rule of table.rules) { + const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope)); + if (hit) return { outputs: rule.then, rule: rule.id }; + } + return undefined; +} + +describe('受理字段完整度 F-01 —— 三要素齐才放行', () => { + it('缺 PN/EAU/交期任一 → 退回补全', () => { + expect(decideBy(F01_DMN, { missing_key_total: 2, missing_minor_total: 0 })).toMatchObject({ + outputs: ['缺PN/数量/交期', '退回补全'], + rule: 'f01-2', + }); + }); + + it('只缺次要字段 → 受理并追问(不卡住)', () => { + expect(decideBy(F01_DMN, { missing_key_total: 0, missing_minor_total: 3 })).toMatchObject({ + outputs: ['缺次要字段', '受理并追问'], + rule: 'f01-3', + }); + }); + + it('八字段齐 → 往下走', () => { + expect(decideBy(F01_DMN, { missing_key_total: 0, missing_minor_total: 0 })).toMatchObject({ rule: 'f01-1' }); + }); +}); + +describe('三分类规则 P-03 —— 能算的不问 LLM', () => { + const decide = (scope: Record) => decideBy(P03_DMN, scope); it('历史价 ≤90 天且行情稳 → A · 直接快答', () => { - expect(decide({ historyDays: 30, marketStable: true, lifecycle: '在产' })).toEqual({ - type: 'A', - play: '直接快答', + expect(decide({ history_days: 30, market_stable: true, lifecycle: '在产' })).toMatchObject({ + outputs: ['A', '直接快答'], rule: 'p03-a', }); }); it('无历史 / 过期 / 波动 → B · 重估后报', () => { - expect(decide({ historyDays: 200, marketStable: false, lifecycle: '在产' })?.type).toBe('B'); + expect(decide({ history_days: 9999, market_stable: false, lifecycle: '未知' })?.outputs[0]).toBe('B'); }); - it('EOL / 缺货 / 要求替代 → C · 转案处理(优先于 A)', () => { - expect(decide({ historyDays: 10, marketStable: true, lifecycle: 'EOL' })).toMatchObject({ type: 'C', rule: 'p03-c' }); + it('EOL / 缺货 / 要求替代 → C · 转案处理(**优先于 A**:新历史价也不该让 EOL 直通)', () => { + expect(decide({ history_days: 10, market_stable: true, lifecycle: 'EOL' })).toMatchObject({ rule: 'p03-c' }); }); it('同输入必同结论 —— 零方差、零 token', () => { - const scope = { historyDays: 30, marketStable: true, lifecycle: '在产' }; + const scope = { history_days: 30, market_stable: true, lifecycle: '在产' }; expect(decide(scope)).toEqual(decide(scope)); }); }); -describe('代码入口 —— 零环境能力,一切经基座六件', () => { - const contract = { - activityId: 'quote-intake', - version: '0.1.0', - data: { customerCode: 'C001', rfqRef: 'mail:m-1001' }, - message: undefined, - outputPorts: { mandatory: ['intake'], selective: [] }, - prohibitions: [], - discretion: [], - } as const; +/** + * 声明与契约的对齐 —— 三处引用同一批台账列,错位了不会报错,只会在放行那一刻 + * 「判据取不到值 / 出口取不到字段」,而两者都长得像业务问题。所以在这儿钉死。 + */ +describe('声明与契约对得上', () => { + const columns = new Set( + manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]), + ); - it('全部打标后提交放行', async () => { - const host = createMockHost({ - contract, - ledger: { - 'quote_intake.record': [ - { customer_code: 'C001', pn: 'LM358DR', brand: 'TI', eau: 5000, type_tag: 'A' }, - { customer_code: 'C001', pn: 'NE555', brand: 'TI', eau: 2000, type_tag: 'B' }, - ], - 'quote_intake.type_table': [{ pn: 'LM358DR', type_tag: 'A' }], - }, - }); - const outcome = await host.run(activity); - expect(outcome).toMatchObject({ kind: 'submit', deliverable: 'intake-record' }); - expect(host.recording.views).toHaveLength(1); - expect(host.recording.says.join()).toContain('A 1 条直通定价'); + it('必交付物的台账地址都声明过', () => { + for (const d of manifest.contract.requiredDeliverables) { + expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true); + } }); - it('尚有未打标即等待,不提交放行', async () => { - const host = createMockHost({ - contract, - ledger: { 'quote_intake.record': [{ customer_code: 'C001', pn: 'LM358DR' }], 'quote_intake.type_table': [] }, - }); - expect(await host.run(activity)).toMatchObject({ kind: 'await' }); + /* + * 除了台账列,谓词还能读一类**基座数出来的读数**:`域_对象_rows` = 本单在那张表上 + * 写下了几行。它不是列(活动写不了它),正因如此才拦得住「两边都由活动自报」的假对账。 + * 这里照样要核到底:对象没声明过,`quote_intake_recrod_rows` 这种手滑仍要当场炸。 + */ + const rowReadings = new Set([ + ...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`), + /* + * `域_对象_missing` = 本单那张表缺必填的有几行(按声明的 `fields[].required` 现数)。 + * 与 `_rows` 同类:活动写不了它,所以它才拦得住「两边都由活动自报」的假对账 —— + * 「必填齐」那条判据从前读活动自报的 `missing_key_total`,模型漏算一次就永远绿不了。 + */ + ...manifest.declarations.ledger + .filter((d) => d.fields.some((f) => f.required === true)) + .map((d) => `${d.domain}_${d.object}_missing`), + /* + * `域_对象_<列>_<值>` = 本单那张表按值现数的条数(取值域由 `fields[].tally` 声明)。 + * 与 `_rows` / `_missing` 同类:活动写不了它 —— 所以人在放行页改了类标, + * 分布条、去向谓词、派单对账**全都跟着重算**。手工修改本来就是为了纠正 AI 的判断。 + */ + ...manifest.declarations.ledger.flatMap((d) => + d.fields.flatMap((f) => + (f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`), + ), + ), + ]); + + it('L1/L2 谓词读的列都有台账承载 —— 否则机检永远取不到值', () => { + for (const c of manifest.contract.acceptanceCriteria) { + if (c.level !== 'L1' && c.level !== 'L2') continue; + for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) { + // S-FEEL 的保留字不是台账名 —— 漏掉它,`a = 0 or b > 0` 会被当成引用了一列叫 `or` + if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue; + if (rowReadings.has(identifier)) continue; + expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true); + } + } }); - it('视图模版含脚本时宿主拒推 —— 容器无脚本执行权', async () => { - const host = createMockHost({ contract }); - await expect(host.base.interaction.pushHtml('
')).rejects.toThrow(/无脚本执行权/); + it('出口载荷的标量字段都有台账列承载(§7.5;踩过:两枚去向旗没列 → G1 全流死路)', () => { + const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`); + for (const port of manifest.contract.output.mandatory) { + const properties = (port.payload as { properties?: Record }).properties ?? {}; + for (const [field, spec] of Object.entries(properties)) { + if (spec.type === 'array') continue; // 数组字段取整表,不需要单列 + expect(columns.has(snake(field)), `${port.name}.${field} → ${snake(field)}`).toBe(true); + } + } }); - it('裸调用被基座拒发 —— 调用必附上下文', async () => { - const host = createMockHost({ contract }); - await expect( - host.base.interaction.issueWorkOrder({ mode: 'execution', title: 'x', basis: [], ask: 'y', fields: [] }), - ).rejects.toThrow(/裸调用/); + it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => { + const declared = new Set(manifest.declarations.tools.map((t) => t.name)); + for (const skill of manifest.assets.skills) { + for (const fragment of skill.crystallized) { + expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true); + } + } }); +}); - it('监听时限必配 —— 无死等', async () => { - const host = createMockHost({ contract }); - await expect(host.base.tools.listen({ eventType: 'data', keyFilter: {}, deadline: '' })).rejects.toThrow(/时限必配/); +describe('报告模版 —— 活动持有,容器无脚本执行权(§9.3)', () => { + it('四个局面各一张,不在一张里堆条件分支', () => { + expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual([ + 'intake-overview', + 'intake-classified', + 'intake-release', + ]); + }); + + it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => { + for (const template of manifest.assets.viewTemplates) { + expect(template.html, template.name).not.toMatch(/'); + } + }); + + /* + * 放行那张报告要摆出**去向是怎么算出来的** —— 人签字前得看见这一单要往哪走, + * 更要看见凭哪一条走。 + * + * 从前这里断言的是两枚去向旗(`need_alternative` / `need_sourcing`)—— + * 那是「活动自己写下一个布尔,再照它发口」的旧口径:旗立错了没人看得出来。 + * 现在去向由契约里三个出口各自的 `when` 按序求值(首个命中即出), + * 基座把命中结果注入 `case:route*`;报告摆的是**同一次判定**,不是这屏又算一遍。 + * 所以这里改断言那条链:判定表三行、命中注入、以及判据读的那三个数。 + */ + it('放行那张报告摆出分支判定与算出来的去向 —— 不是人点出来的,也不是报告自己算的', () => { + const release = manifest.assets.viewTemplates.find((t) => t.name === 'intake-release')?.html ?? ''; + // 判定表三行俱在,壳上按命中项上色 + expect(release).toContain('data-class-of="case:routeKey"'); + for (const row of ['jc-dmn-r r1', 'jc-dmn-r r2', 'jc-dmn-r r3']) expect(release).toContain(row); + /* + * 去向那句话由基座填,报告不复述。 + * **只摆人话的那一份**(2026-08-02 用户定:报告里不该出现系统内的英文名)—— + * 口名 `to-alternative` 与判据表达式 `…_type_tag_c > 0` 都是给排障看的, + * 摆在人的成果件上等于噪音;人要知道的是「转到哪个环节、为什么」。 + */ + expect(release).toContain('data-slot="case:routeTo"'); + expect(release).not.toContain('case:routePort'); + expect(release).not.toContain('case:routeWhy'); + // 判据读的是三类条数:摆出来的数与算出去向用的是同三列 + // 三类条数读**基座按值现数**的那一份 —— 人改了类标,分布条与去向才跟着变 + for (const col of ['quote_intake_record_type_tag_a', 'quote_intake_record_type_tag_b', 'quote_intake_record_type_tag_c']) + expect(release).toContain(col); + }); + + /* + * **契约里每个出口都得写 `when`** —— 少一口,`routePort` 就整组算不出来, + * 放行卡当场退回「人选一口」,而报告上那张判定表也就没有命中行: + * 一屏三条规则都不高亮,看着像判定没跑,实则是这组分支有一段没有判据。 + */ + it('选择组里每一口都写了判据 —— 去向才算得出来', () => { + const groups = manifest.contract.output.selective; + expect(groups.length).toBeGreaterThan(0); + for (const group of groups) { + expect(group.ports.length).toBeGreaterThanOrEqual(2); + for (const port of group.ports) expect(port.when, `${group.group}/${port.name}`).toBeTruthy(); + } + }); + + it('宿主拒推含脚本的模版 —— 这道门在基座,不靠模版作者自觉', async () => { + const host = createMockHost({ contract: CONTRACT }); + await expect(host.base.interaction.pushHtml('
')).rejects.toThrow(/无脚本执行权/); + }); +}); + +const CONTRACT = { + activityId: 'quote-intake', + version: '1.0.0', + data: { customerEmail: 'purchasing@sz-machinery.com', subject: '【询价】RFQ-2026-0001', body: 'LM358DR 12K/年' }, + message: undefined, + outputPorts: { mandatory: ['intake'], selective: [] }, + prohibitions: [], + discretion: [], +} as const; + +describe('代码入口 —— 案例活动没有 run,不是残包', () => { + it('默认导出是一个有名字的活动定义', () => { + expect(activity.name).toBe('quote-intake'); + }); + + it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => { + const host = createMockHost({ contract: CONTRACT }); + await expect(host.run(activity)).rejects.toThrow(/没有 run/); }); }); diff --git a/activities/quote-intake/__tests__/steps.test.ts b/activities/quote-intake/__tests__/steps.test.ts new file mode 100644 index 0000000..a8ce8f0 --- /dev/null +++ b/activities/quote-intake/__tests__/steps.test.ts @@ -0,0 +1,252 @@ +/** + * 步骤剧本 —— **这段错了不报错,只是活动开始转圈**。 + * + * A1 反复空转的根因是没有它:S1 一次把八字段写齐之后,S3「补齐八字段」已经无活可干, + * 而每一拍喂给模型的是整本说明书,它只能把同样三行再写一遍 —— + * 被「写了等于没写」拒回,下一拍再来一遍,连拒到熔断。 + * + * 剧本把这件事挪进引擎:`doneWhen` 一真,引擎自己收掉那一步,**不问模型**。 + * 所以这几条断言守的是剧本本身的可用性 —— 项 id 对不对得上、判据会不会开局即真、 + * 声明的台账与工具是不是活动真有的。写错任何一条,后果都是运行时才发现的空转。 + */ +import { describe, expect, it } from 'vitest'; +import { evaluateCondition } from '@sfeel'; +import { INTAKE_STEPS } from '../src/case-model.ts'; +import { CMMN_XML } from '../src/case-model.ts'; +import { manifest } from '../src/manifest.ts'; + +/** 从 CMMN XML 里把计划项 id 抠出来 —— 剧本认的是它们 */ +const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map( + (m) => m[1] ?? '', +); + +describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => { + it('每一条剧本都指向一个真实存在的计划项', () => { + for (const step of INTAKE_STEPS) { + expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item); + } + }); + + it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => { + for (const id of ITEM_IDS) { + expect(INTAKE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id); + } + }); + + it('清单里挂的就是这份剧本(不是另抄一份)', () => { + expect(manifest.assets.businessModel.steps).toBe(INTAKE_STEPS); + }); +}); + +describe('doneWhen 是「成果已存在」的判据,不是「该不该做」的判据', () => { + /** 开案那一刻的局面:台账一行没有,汇总列全无值 */ + const FRESH: Record = { + customerEmail: 'buyer@x.com', + subject: '【询价】RFQ-2026-0001', + body: 'LM358DR 12K/年', + }; + + it('**开局一步都不该被自动收掉** —— 这是最容易写错的地方', () => { + for (const step of INTAKE_STEPS) { + if (step.doneWhen === undefined) continue; + const ripe = safeEval(step.doneWhen, FRESH); + expect(ripe, `${step.item} 的 doneWhen「${step.doneWhen}」开局就为真,那一步会在还没干之前被收掉`).toBe(false); + } + }); + + /* ── ① 定位客户信息 ─────────────────────────────────────────────── */ + + it('**老客户**认出来了 → S1 收得掉(不必问人)', () => { + expect(safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 0 })).toBe(true); + }); + + /* + * 这一条是拿一次实测换来的:剧本白纸黑字写着「别拿邮箱域名凑一个客户编码,也别自己编」, + * 模型照样从 purchasing@sz-machinery.com 编出 `CUST-SZ-MACH` 直接把档建了 —— + * 人根本没被问过一句,而客户编码是主数据的主键,编错一次后面每一单都跟着错。 + * **说明书管不住,收步条件管**:新客户没问过人,这一步就收不掉。 + */ + it('**新客户没问过人 → S1 收不掉**(说明书管不住的,让收步条件管)', () => { + expect(safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 1 })).toBe(false); + }); + + it('新客户问过人了 → S1 收得掉', () => { + expect( + safeEval(doneOf('S1_customer'), { ...FRESH, customer_total: 1, customer_new_total: 1, customer_asked_total: 1 }), + ).toBe(true); + }); + + /* + * 这一条守的是那个老坑:**不能写 `customer_code != null`** —— + * S-FEEL 里未绑定的名字 `!= null` 求值为 true,这一步会在还没认人之前就被收掉。 + * 判据必须取同步写下的计数列。 + */ + it('还没认到客户 → S1 收不掉,归一也进不了场', () => { + expect(safeEval(doneOf('S1_customer'), FRESH)).toBe(false); + expect(safeEval(doneOf('S2_normalize'), FRESH)).toBe(false); + }); + + /* ── ② 归一客户 RFQ 输入 ────────────────────────────────────────── */ + + const normalized = (over: Record) => ({ + ...FRESH, + customer_total: 1, + line_total: 3, + // 拆出来几行就报几条 —— 收步与验收同源(见下面那条「多写一行」) + quote_intake_record_rows: 3, + missing_key_total: 0, + missing_minor_total: 0, + asked_total: 0, + ...over, + }); + + it('拆出行来了 → S2 收得掉(拆料是 AI 的活,办完交给下一步请人)', () => { + expect(safeEval(doneOf('S2_normalize'), normalized({}))).toBe(true); + }); + + /* + * **缺不缺字段不再是 S2 的收步条件** —— 缺口是卡上要说清的事,不是拦住拆料的理由。 + * 从前把「缺了就得问过人」写进这里,连带出下面那条真正的病: + * 不缺就等于不必经过人。现在分开:S2 只管拆,S3_confirm 管「经过人」。 + */ + it('缺必填 / 缺选填都不拦 S2 —— 那是 S3 卡上的事', () => { + expect(safeEval(doneOf('S2_normalize'), normalized({ missing_key_total: 2 }))).toBe(true); + expect(safeEval(doneOf('S2_normalize'), normalized({ missing_minor_total: 2 }))).toBe(true); + }); + + it('一条都没归一出来 → S2 收不掉', () => { + expect(safeEval(doneOf('S2_normalize'), normalized({ line_total: 0 }))).toBe(false); + }); + + /* + * **多写一行 → S2 就收不掉**(2026-08-01 加)。 + * + * 实测:客户明说本轮跳过的那颗料被写进了有效清单 —— 数报得全对(9 有效 / 1 跳过), + * 行却有 10 条。从前 S2 只看 `line_total > 0`,照收;错要到第 10 拍提交放行时 + * 才被同一条等式拦住,那时模型只会去改数(改了 skipped_total 又不对),连拒五拍熔断。 + * **同一个错,在第 2 拍发现比在第 10 拍便宜得多**:那时行刚写下,多的是哪一行一目了然。 + */ + it('**多写一行(如把客户说跳过的那条也写了)→ S2 收不掉**,错在第 2 拍就现形', () => { + expect(safeEval(doneOf('S2_normalize'), normalized({ quote_intake_record_rows: 4 }))).toBe(false); + }); + + /* ── ③ 受理需求交人审定:阶段2 那张执行页,一单必经一次 ─────────── */ + + /* + * **这一条是用户 2026-08-01 当场逼出来的**:「我都没确认需求 它就让我验收了」。 + * + * 病根在旧口径:「经过人」是靠 `missing_minor_total = 0 or answered_total > 0` + * 表达的 —— 只有缺东西的时候才叫人。字段齐全的一封询价进来,这个条件在拆完那一刻 + * 就为真,引擎自己把归一那步收了,人一眼没看过受理需求, + * 下一次被叫到已经是终局的验收放行。**确认不是缺口的补丁,是一道恒在的门。** + * + * 所以这一步跟放行同一个做法:**人工激活 + 不给 doneWhen**。 + * 给它写 doneWhen 就是给引擎一把替人签字的钥匙; + * 而「人看过、改过、认了」按定义也不是台账里任何一个数能自己变出来的。 + */ + it('**受理需求那一步不给 doneWhen** —— 引擎不许替人认下这份稿', () => { + expect(INTAKE_STEPS.find((s) => s.item === 'S3_confirm')?.doneWhen).toBeUndefined(); + }); + + it('审定这一步在剧本里,且摆的是阶段2 那份目标件', () => { + const confirm = INTAKE_STEPS.find((s) => s.item === 'S3_confirm'); + expect(confirm, '受理需求交人审定这一步不见了').toBeDefined(); + expect(confirm?.view).toBe('intake-overview'); + // 卡上那张表绑台账整份铺开 —— 人改的是九行,不是缺的那一两行 + expect(confirm?.guide).toContain('"ledger":"quote_intake.record"'); + expect(confirm?.guide).toContain('confirmed_total'); + }); + + /* + * **收步判据与验收判据同源**(2026-08-01 改)。 + * + * 从前 S5 只看自报的 `untyped_total = 0`、S6 只看「写过 alt_request_total 这个数」—— + * 数写对了就收步,而验收比的是**基座数出来的行**(`type_table_rows = record_rows`、 + * `alt_request_rows = type_c_total`)。两处比的不是同一件事,松的那一截就是返工: + * 收了步却过不了验收,模型得回头补写一张已经收掉的表。 + */ + it('打完标且两张表行数对上 → S5 收得掉', () => { + const typed = { line_total: 3, untyped_total: 0, quote_intake_type_table_rows: 3, quote_intake_record_rows: 3 }; + expect(safeEval(doneOf('S5_classify'), typed)).toBe(true); + }); + + it('**数归零了但表没写全 → S5 收不掉** —— 那正是从前「收了步却过不了验收」的口子', () => { + const short = { line_total: 3, untyped_total: 0, quote_intake_type_table_rows: 1, quote_intake_record_rows: 3 }; + expect(safeEval(doneOf('S5_classify'), short)).toBe(false); + }); + + /* + * **S6 不再开单**(2026-08-02 用户当场定:「替代评估请求 / 询价请求这两项数据 + * 不在这里产出;随令牌流向下一个节点的只有工作成果验收内的内容」)。 + * 收步判据随之改成「两枚旗立过了」——它仍取**计数**而非布尔: + * 一单全 A 类时两枚旗都是 false,「写了 false」与「压根没写」分不清。 + */ + it('去向定了 → S6 收得掉', () => { + expect(safeEval(doneOf('S6_route'), { untyped_total: 0, route_decided_total: 1 })).toBe(true); + }); + + it('**没记过那个数 → S6 收不掉** —— 未绑定的名字比大小为假,不会开局即收', () => { + expect(safeEval(doneOf('S6_route'), { untyped_total: 0 })).toBe(false); + }); + + it('全 A 类(两枚旗都是 false)照样收得掉 —— 判据看的是「定过没有」,不是旗本身', () => { + const allA = { untyped_total: 0, route_decided_total: 1, need_alternative: false, need_sourcing: false }; + expect(safeEval(doneOf('S6_route'), allA)).toBe(true); + }); + + it('**S6 不写请求单** —— 那两张表已经不归 A1 产出', () => { + const writes = INTAKE_STEPS.find((x) => x.item === 'S6_route')?.writes ?? []; + expect(writes).toEqual(['quote_intake.verdict']); + }); + + /* + * 放行触达世界 —— 它必须由人按一下(CMMN 里标了 manualActivationRule)。 + * 给它写 doneWhen 就是给引擎一把自动放行的钥匙,那是铁律③ 的红线。 + */ + it('**放行那一项不给 doneWhen** —— 终局唯放行,引擎不许自动收它', () => { + expect(INTAKE_STEPS.find((s) => s.item === 'T_release')?.doneWhen).toBeUndefined(); + }); +}); + +describe('剧本声明的东西活动真有 —— 声明了没有的,那一步照着做必然失败', () => { + const declaredLedger = manifest.declarations.ledger.map((l) => `${l.domain}.${l.object}`); + const declaredTools = manifest.declarations.tools.map((t) => t.name); + + it('每一步要写的台账都在清单的台账声明里', () => { + for (const step of INTAKE_STEPS) { + for (const address of step.writes) { + expect(declaredLedger, `${step.item} 要写 ${address},而活动没声明这个台账对象`).toContain(address); + } + } + }); + + it('每一步要调的工具都在清单的工具声明里', () => { + for (const step of INTAKE_STEPS) { + for (const tool of step.tools) { + expect(declaredTools, `${step.item} 要调 ${tool},而活动没声明这件工具`).toContain(tool); + } + } + }); + + it('每一步都说得出它交出什么(intent 与 guide 都不为空)', () => { + for (const step of INTAKE_STEPS) { + expect(step.intent.trim(), `${step.item} 没写 intent`).not.toBe(''); + expect(step.guide.trim().length, `${step.item} 的 guide 太短,等于没写`).toBeGreaterThan(20); + } + }); +}); + +function doneOf(item: string): string { + const found = INTAKE_STEPS.find((s) => s.item === item)?.doneWhen; + if (found === undefined) throw new Error(`${item} 没有 doneWhen`); + return found; +} + +/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */ +function safeEval(expression: string, scope: Record): boolean { + try { + return evaluateCondition(expression, scope); + } catch { + return false; + } +} diff --git a/activities/quote-intake/src/case-model.ts b/activities/quote-intake/src/case-model.ts index a35581e..aa6924e 100644 --- a/activities/quote-intake/src/case-model.ts +++ b/activities/quote-intake/src/case-model.ts @@ -1,85 +1,716 @@ /** * A1 询价受理的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。 * - * 2026-07-29 由步序声明(flow)改写而来:活动不再分范式,一律案例模型 + Agent 临场调度。 - * 原来的六步在这里各归其位 —— 解析归一与追问补全同属「收料归一」阶段(常来回几轮, - * 所以带 repetitionRule);查历史价与三分类打标属「定性分级」;末尾一张验收单。 + * 业务出处:`docs/MVP示例工作流.html` §1.1 的那张步骤流程图,原样照搬: + * + * ● 接流程起点 → S1 接收登记 → D1 判断完整度 + * ├─ 不完整 → S2 追问补全信息 ─┐ + * └─ 完整 ───────────────────┴→ S3 逐条解析归一 + * → S4 查历史价 → S5 逐条定状况标 → S6 定去向与派单 → G1 + * + * **最长路径就是这六步**(常路五步 —— 字段齐就不走 S2),末尾加一张受理放行(终局唯放行)。 + * + * ── 建模纪律:图上有几步就是几项 ────────────────────────────────────── + * + * 这份模型**重写过一次**。上一版自作主张加了两个阶段(收料归一 / 定性分级)、两个里程碑、 + * 一个裁量项、一个用户事件监听器,12 个计划项去装 6 步的活。业务上一分没多, + * 卡壳的花样却多了一堆:阶段被当成写的落点(收了阶段,里面的项就永远入不了场)、 + * 里程碑要不要收、项该先 start 还是先干、编出不存在的项 id…… + * + * 实测账很清楚:**每一条「行动被拒」都是结构上的,没有一条是业务判断上的** —— + * 模型在跟 harness 较劲,不是在办事;一单烧掉二三十万 token 才收口,还常常收不了口。 + * + * 所以:**图上有几步就是几项,不多建一件**。阶段、里程碑、裁量项都是 CMMN 有的东西, + * 但「有」不等于「该用」—— 它们要能换来业务上说得出的好处,否则就只是给 Agent 添的坑。 */ export const CMMN_XML = ` + - - - - + + + + + + + + + + + + + + + + + + - - create + + + + update + customer_total > 0 and (customer_new_total = 0 or customer_asked_total > 0) - - update - missingKeyFields > 0 + + + update + line_total > 0 + - update - missingKeyFields = 0 + update + line_total > 0 and quote_intake_record_missing = 0 and confirmed_total > 0 + + update + + - update - untyped = 0 + update + quote_intake_record_rows > 0 and quote_intake_record_type_tag_a + quote_intake_record_type_tag_b + quote_intake_record_type_tag_c = quote_intake_record_rows + + + + update + quote_intake_record_rows > 0 and quote_intake_record_type_tag_a + quote_intake_record_type_tag_b + quote_intake_record_type_tag_c = quote_intake_record_rows and route_decided_total > 0 - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - PT12H - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; -export const NARRATIVE = `§0 开案:一封询价来函即开案。信里是 RFQ 正文与 BOM 附件,条目从几条到上百条不等。 -受理这件事的成品只有一样:**一份能往下走的统一受理记录** —— 八字段齐、每条都打了标。 +/** + * 案例说明书 —— **只写整单一以贯之的事**(2026-07-31 砍掉逐步做法)。 + * + * 引擎每一拍已经把「当前这一步怎么做」单独摊开喂进去了(`INTAKE_STEPS[].guide`)。 + * 说明书里再写一遍全部六步,是**两笔账同时亏**: + * · 钱 —— 同一段做法一拍烧两遍,而它是每拍都在的常驻上下文; + * · 准 —— 两份会分叉。实测就分叉过:这里写着 `S1_register` / `S2_ask_back` / + * `S3_normalize` 三个**案例模型里根本不存在的计划项**(真名是 S1_customer / + * S2_normalize),模型照说明书报 itemId,一报一个被拒。 + * + * 所以留下的只有逐步做法**说不了**的那几件:这一单是什么、成品是什么、 + * 一路上怎么跟人说话、怎么把一项干完、结案是什么。逐步的活归剧本。 + */ +export const NARRATIVE = `§0 开案:**一封客户询价来函即开案**。你手上只有这封信的四样东西 —— +客户邮箱(customerEmail)、主题(subject)、邮件内容(body)、邮件附件(attachments,真文件)。 +没有客户编码,没有询价单号:那两样是**受理出来的**,不是随信送来的。 +真实 RFQ 五花八门 —— 俄文 BOM、客户内部码、字段东缺西缺都常见。 +**受理这件事的成品只有一样**:一份能往下走的询价受理记录 —— 逐条落表、每条打了状况标、 +两枚去向旗定了。SLA:受理<15 分钟,判定<45 分钟。判错状况,快单变慢单。 -S1 收料归一:逐条把 BOM 解析成统一格式(PN / 厂牌 / EAU / 目标价 / 交期 / 包装 / 品质要求 / 备注)。 -解析不出的不许臆造 —— 缺关键字段(PN、EAU、交期为必要三要素)就追问客户,常要来回一两轮, -所以这两件事都可重复做。**吃不准的宁可空着去问,也不许填一个看起来合理的值。** +**整条路就这几步**,一步一个计划项,别绕(每一步的做法,轮到它时会单独摊给你, +这里不重复):S1 定位客户 → S2 归一受理 → **S3 受理需求交人审定** → S4 查历史价 → +S5 逐条定状况标 → S6 定去向与派单 → 提交放行。 -S2 定性分级:先查历史价(同客户同料近一年有没有报过、隔了多久、行情稳不稳), -再按三分类规则 P-03 逐条打标 —— A 类历史价直用、B 类需重新核价、C 类需选型或寻源。 -拿不准的一律降 B,不许臆断 A:错标 A 会让一条本该重新核价的料带着旧价一路走到报价单上。 -生命周期核查是裁量项 —— 遇到疑似 EOL 的料随手拉一次。 +**这一单要经过人两次,不多不少**:S3 人审定受理需求(阶段2 那张执行页, +字段齐不齐都要发)、放行时人签字。**别把 S3 当成缺东西时的追问** —— +不缺也发:拆错一格只有人看得出来,而那些错往后每一步都跟着跑。 -§Z 结案:八字段齐、每条都已打标、BD 签字放行,自动结案(CMMN AutoComplete)。 -结论只有两个去向要交出去:需不需要选型、需不需要寻源 —— 下游的两道网关读的就是这两面旗。`; +**附件是真文件,基座已经替你读过一遍**。每一件长这样 +\`{ name, contentType, size, ref, text?, note }\`: +\`text\` 有值就是**读出来的内容**(xlsx 读成制表符分列的行,第一行多半是表头),照它拆条; +\`text\` 没有就是这类文件读不出文本(\`note\` 里写着为什么,比如 pdf、扫描件), +这时以邮件内容为准。**别猜附件里有什么**:文件名叫 BOM 不等于里面有 BOM。 + +【一路上要说话】负责人(BD)正看着这一单。**每一拍都带一句 say** —— 你是在跟他当面说话, +不是播报进度。他明天要拿这些话去跟客户和采购讲,所以说的得是**业务的话**: +料号、交期、用量、价、客户要求;不是「关键字段」「归一化阶段」「写草稿 3 行」这些系统词。 + +一两句就够,口语,像同事扭头说一声。局面变了顺手用 view 换画面: +· S2 逐条落表后 → view = intake-overview。**先说这单是什么**,再说有没有坑: + 「深圳机械这单三颗料,车规 Analog;交期用量都写清楚了,只有 NE555P 没给目标价。」 +· S5 打标完 → view = intake-classified。**说结论对这单的影响**: + 「LM358DR 有近期成交价可以直接报;NE555P 半年多没动过要重新核;TL072CDR 停产了,得找替代。」 +· S6 定完去向 → view = intake-release。**说这单接下来往哪走、谁接手**。 + +别每拍一个腔调复读(「已完成X,现在进行Y」)。事情不同,话就该不同 —— +调工具那一拍说「我去查这三颗的历史价」,拿到结果那一拍说查出了什么, +两句话该是不一样的语气。有风险、要他拿主意、或者你打算叫人,**先说出来**, +别等他自己从表里看出来。不说话的后果是:他的对话区从头到尾空白,只看得见「多了一个任务」。 + +【怎么把一项干完 · 一拍连做】一个计划项常要**两三个动作**才交得出成果(先写记录、 +再结算总数;先调工具拿判据、再把判据写进台账)。write 和 tool 都**不会**自动把那一项收掉 —— +都做完了,用 finish 宣告这一项完成。**这些动作排进同一拍的 actions 串里一口气给**, +别拆拍:「写 record、写 verdict、finish S1」是一拍,不是三拍; +「写 type_table 打标结果、写 verdict 统计、finish S5、写 alt/rfq 请求、写旗、finish S6」 +能排一串就排一串。只有两处必须停拍:调工具要看出参(tool 放串尾,下一拍接着干); +发卡 / 提交会挂起(它们只能是串尾)。**这一单常路 5-7 拍就该收口** —— +每多拆一拍,就是多烧一遍完整上下文。 + +§Z 结案:BD 在验收单上签字,系统把草稿合并成正式记录并发出 intake 口的载荷。`; + +/** + * **步骤剧本** —— 一个计划项一条,把「这一步是什么状态」写成机器读得懂的东西。 + * + * 说明书说的是**整单一以贯之的事**(这一单是什么、怎么说话、怎么收口); + * 逐步的做法只写在这里,轮到哪一步才摊开哪一步 —— **两处不重复写同一件事**, + * 重复的代价是钱(每拍烧两遍)与准(两份会分叉,分叉过一次:说明书里的计划项名 + * 是案例模型里没有的三个,模型照它报 itemId,一报一个被拒)。 + * + * `doneWhen` 是这里的关键:它让引擎自己算得出「这一步的成果已经在台账里了」, + * 算出来就直接收掉,**不问模型**。A1 反复空转正是栽在没有它 —— + * S1 把八字段一次写齐之后,S3「补齐八字段」已经无活可干,而模型只能把同样三行 + * 再写一遍,被「写了等于没写」拒回,下一拍再来一遍(实测连拒到熔断)。 + * + * 写 `doneWhen` 的纪律:**它是「成果已存在」的判据,不是「该不该做」的判据**。 + * 写成开局即为真的式子(如 `missing_key_total = 0`),那一步会在还没干之前就被收掉。 + */ +export const INTAKE_STEPS = [ + { + item: 'S1_customer', + intent: '按发件邮箱认客户;认不出就发补全卡请人建档,建完写进客户主数据', + writes: ['customer.profile', 'customer.contact', 'quote_intake.verdict'], + /* 认客户就靠这两张 —— 没声明的话它们会被整表段的截断刀砍掉(踩过) */ + reads: ['customer.contact', 'customer.profile'], + view: 'intake-overview', + tools: [], + /* + * 认到人才算办完。**不能写 `customer_code != null`** —— + * S-FEEL 里未绑定的名字 `!= null` 求值为 true,这一步会开局即被收掉(A1 踩过)。 + * 一律取同步写下的计数列。 + */ + doneWhen: 'customer_total > 0 and (customer_new_total = 0 or customer_asked_total > 0)', + guide: `这一单的第一件事是**认人**:这封信是谁发来的。 + +① 先查客户主数据:拿入参的 \`customerEmail\` 去 \`customer.contact\` 里找 —— + 找得到就取它那一行的 \`customer_code\`,这一步就完了(照下面第③条记账,不必问人)。 + + **邮箱一个字符都不许改**:用 \`customerEmail\` 的**原文**去比,别照签名里的姓名 + 重新拼一个(实测踩过:入参是 \`e.volkova@itelma.ru\`,模型按签名拼成 + \`ekaterina.volkova@itelma.com\` —— 名字对、域名和缩写全错,于是老客户被当成新客户, + 白发一张建档卡,还差点建出一个重复的客户档案)。 + 比对时忽略大小写与首尾空格,除此之外**逐字符相等才算认出**; + 域名相同但用户名不同**不算认出** —— 同一家公司不同的人,是两个联系人。 + +② **找不到就是新客户** —— 发**数据录入卡**(不是补全卡)请人建档。 + 两者的分别在**谁写库**:补全卡收回来的值要你自己再 write 一遍(中间隔一道传话, + 你可能抄错、可能顺手改一笔);数据录入卡是**人按下确认那一刻基座直接写**,你不经手。 + 客户档案是主数据,建错一次后面每一单都跟着错 —— 那一笔该由人亲手落下。 + + **建档要建两张表:公司档 + 联系人档,两段都摆上这张卡**(2026-08-02 用户定: + 「新建客户 联系人和公司都需要确认」)。只摆公司那一半、联系人由你自己写, + 等于让人确认了一半 —— 而联系人档里的 \`contact_code\` 恰恰是**你编出来**的那一个。 + + **照这个骨架发,一个字段都别少**(散文描述不管用,直接抄): +{"kind":"entry","itemId":"S1_customer","ask":"这是位新客户,请核对建档信息并给客户编码", + "ledger":"customer.profile","entryKey":{"customer_code":""}, + "basis":[{"k":"发件邮箱","v":"chen@sh-elec.com"},{"k":"签名","v":"陈刚 | 上海精密电子有限公司"}], + "fields":[ + {"key":"customer_code","label":"客户编码","required":true}, + {"key":"name","label":"公司名","value":"上海精密电子有限公司","required":true} + ], + "also":[{"title":"联系人档案","ledger":"customer.contact", + "key":{"customer_code":""}, + "fields":[ + {"key":"name","label":"联系人姓名","value":"陈刚","required":true}, + {"key":"email","label":"邮箱","value":"chen@sh-elec.com","required":true} + ]}]} +**联系人编码不用你写** —— 它是内部流水号,基座在人按下确认那一刻按「客户编码-01」生成 +(台账声明里标了自增)。你硬塞一个进 \`fields\`,基座也会把它摘掉: +人哪知道这家客户已经有几位联系人,而你编一个就可能覆盖别人那一行。 +**公司名与联系人姓名、邮箱都必须带 \`value\`** —— 公司名几乎总写在开头那句 +「我是XX公司的张三」或结尾签名档里;联系人姓名从落款认;**邮箱照 \`customerEmail\` 原样抄, +一个字符都别改**。读出来填上,人核对一眼就行。 +**客户编码不给 \`value\`** —— 那是我们内部的编码,来件里不会有,硬凑一个是错的; +标 required:true,人不填就确认不了。 +两段的 \`customer_code\` 都留空:人在第一段填了什么,第二段跟着是什么(同一位客户)。 + +③ **记账两个数,引擎按它们收步**(别绕过去,绕不过去): + · customer_new_total —— 第①步认出来了记 0;认不出、要建档记 1; + · customer_asked_total —— 发过建档补全卡就记 1。 + **新客户没问过人,这一步收不了** —— 实测有过一次:模型从邮箱域名 + 编出 \`CUST-SZ-MACH\` 直接把档建了,人根本没被问过一句,而那个编码是错的。 + 客户编码是主数据的主键,编错一次后面每一单都跟着错。 + +④ 人答完(或第①步就认出来了),同一拍把该写的写上: + · \`customer.profile\` 与 \`customer.contact\` —— **新客户这两张不用你写**: + 人在录入卡上按下确认那一刻基座已经落好了(两段一次落两行)。 + 再写一遍只会被「写了等于没写」拒回。认出来的老客户更不必重写主数据。 + · \`quote_intake.verdict\`:customer_code、**customer_name**(公司名,认出来的取主数据那一行, + 新建的取卡上填的)、**contact_name**(发件人姓名,从落款或邮箱认;认不出就留空,别拿公司名顶)、 + **customer_kind**(照第③条的判断写「老客户」或「新客户」四个字, + 别的写法都不算 —— 报告上那一格与 A4 的首单口径都读它),并把 **customer_total 记 1**。 + +顺带把**询价单号 rfq_ref** 认出来记进 verdict:从主题或正文里原样照抄 +(RFQ-xxxx / 询价单号 / Ref / PO 号),认不出就在②那张卡上一并问,**不要自己编** —— +所有台账都以 rfq_ref 为主键,编错了这一单的行会和别的单撞在一起。`, + }, + { + item: 'S2_normalize', + intent: '解析邮件正文与附件 BOM,逐条落成受理记录;缺哪几格照实记数', + writes: ['quote_intake.record', 'quote_intake.verdict'], + reads: ['customer.profile'], + /* 拆料这一拍工作区就该摆出受理需求那份件 —— 人看得见 AI 拆到哪儿了 */ + view: 'intake-overview', + tools: [], + /* + * **这一步不叫人**:拆料是 AI 的活,拆完就算办完,由下一步(S3_confirm)交给人审定。 + * + * 从前这里既拆料又发卡,收步条件是 + * `line_total > 0 and missing_key_total = 0 and (missing_minor_total = 0 or answered_total > 0)` + * —— 括号里那半句的意思是「缺东西才需要经过人」。于是一封字段齐全的询价进来, + * 条件当场为真,引擎在下一拍开头把这一步自己收了,人一眼没看过受理需求; + * 下一次被叫到已经是终局的验收放行。用户 2026-08-01 当场指出这件事。 + * + * 现在这里只留「拆出行来了没有」:缺不缺字段不是收步的事,是卡上要说清的事。 + */ + /* + * **拆出来几行,就报几条** —— 收步判据与验收判据同源(2026-08-01 加后半句)。 + * + * 从前只看 `line_total > 0`:多写一行也照收,错要到第 10 拍提交放行时 + * 才被 `record_rows = line_total` 拦住 —— 那时上下文早翻篇了, + * 模型只会去改数(实测:客户说本轮跳过的那颗料被写进有效清单, + * 改数又让 skipped_total 不对,连拒五拍熔断)。 + * **同一个错,在第 2 拍发现比在第 10 拍便宜得多**:那时行刚写下、多的是哪一行一目了然。 + */ + doneWhen: 'line_total > 0 and quote_intake_record_rows = line_total', + guide: `客户认出来了,现在把询价内容逐条落成受理记录。 + +【先看料在哪】**正文与附件分工不同,别只读一处**: +· 附件的 \`rows\` 有值 → **基座已经把那张表拆好了**:一行一个对象、键就是表头列名。 + **照它拆条,别自己去数制表符** —— 数格子是基座替你做的事,你只管把列对到我方字段上。 + (\`text\` 仍留着原文:口径要求那些不在表里的东西还得从正文与原文看。) +· 只有 \`text\` 没有 \`rows\` → 那张表不规整(列数对不齐),基座不敢替你拆; + 这时才自己看原文,并且格外小心连着的制表符是「中间那一格空着」。 + 这时正文通常只给**口径要求**(币种/贸易条款/交期单位/认证/替代授权/本轮跳哪条), + 那些不是料,是**落到备注与品质要求里的约束**。 +· 附件没有或读不出 → 以正文为准。**别猜附件里有什么**。 + +【客户说不报的,就不要报】正文里点名「本轮跳过 / not finalised / 暂不报」的那几条, +**不进有效清单** —— 但要在收尾时说清「本轮不报几条、为什么」。 +有效条数 = 附件总行数 − 客户点名跳过的条数,两个数都要说得出。 +**跳过的那条连一行都别写进 \`record\`**:实测栽过一次 —— 数报对了(9 有效 / 1 跳过), +行却写了 10 条,判据 \`record_rows = line_total\` 当场拦住,而那时改数字是改不对的 +(改了 skipped_total 又不对)。真写进去了就用 \`drop\` 把那一行撤掉,别去动数。 + +【表头怎么对】BOM 的列名常是客户本国语言或缩写,**先把列对上再拆行**: +· 年用量那一列叫 EAU / Qty / 年用量 / Годовой объём;**带 (k) 就是千**(30 → 30000); +· 制造商叫 Manufacturer / Производитель;品类叫 Category / Категория; +· 客户自己的编码常在第一列(Itelma Code / Customer P/N)→ 落 customer_pn。 +**对不上列就别猜**:宁可把那一列整列判为缺,也不要把「认证要求」当成「年用量」。 + +【制表符分列:空格子会连着出现,别把后面的值往前挪】 +一行里连着两个制表符(\`\\t\\t\`)就是**中间那一格空着**,不是没有这一格。 +拆每一行前先数:**这一行的格子数必须与表头的列数相同**,数不对就整行重数, +别把右边的值挪到左边的空位上 —— 实测栽过两次,同一行: +制造商那一格空着,于是「浙江嘉康」被读成上一行的厂牌、品类也跟着错位成「集成电路」, +而 BOM 上白纸黑字写着 Антенна(天线)与浙江嘉康。 +行尾连着几个制表符也一样:那是**末尾几列都空着**(年用量、目标价客户没给), +不是这一行到此为止 —— 末尾空列照样要算进格子数里。 +列对上了就**逐行照抄值**,别整列判缺 —— 实测栽过一次:9 条料的年用量明明都在表里, +却被整批报成「缺年用量」,一张卡问了客户 9 个他早就给过的数。 + +【一张卡别铺太长】缺口卡的 basis 与表格**只列真正缺的那几行**,不要把全部条目铺进去: +整份回复有长度上限,铺太长会在半途被截断 —— 截断的 JSON 解析不了, +这一拍作废、还会弹一张「需要你判断下一步」把人叫来(实测)。缺几条就写几条。 +【两种分量 —— **必填只有两项,别的一个都不是**】 +· **必填(只有这两项)**:\`pn\`、\`eau\` 年用量 —— 缺了这一单没法报价, + **这是验收标准**,不齐不许放行,记进 missing_key_total。 +· **选填(除上面两项之外的一切)**:客户料号、品类、制造商、**目标价**、交期、品质要求、备注…… + 缺了照样往下走,只当提醒,记进 missing_minor_total。 + **目标价尤其常缺** —— 客户不给目标价是常态,不是缺陷:实测栽过一次, + 一颗料只缺目标价却被记成必填缺、还派了执行卡去问客户,而按 F-01 它根本不该拦单。 + 拿不准某个字段算哪一档,就问自己一句:**它是不是 PN 或年用量?** 不是就是选填。 + +【怎么拆】逐条写进 \`quote_intake.record\`,一条需求一行(同一 PN 不同数量档算两行)。 +列名照台账字典写,别自己起名: +· \`pn\` 我方料号 · \`customer_pn\` 客户自己的编码(BOM 里那一列,回价单要按它对齐) +· \`category\` 品类 · \`brand\` 制造商 · \`eau\` 年用量 · \`target_price\` 目标价 +· \`quality_req\` 认证/品质要求 · \`remark\` 备注 · \`customer_code\` 上一步认出的那个 +四条纪律: +① **原文没写的不要补** —— 缺就是缺,记进缺口数,别替客户填一个「看起来合理」的值。 + 数值列(年用量 / 目标价)没有就给**空字符串 \`""\`**,基座认得出「这一列没有值」, + 报告上会标成红的「必填」等人补。**绝不写 0** —— 0 是一个真实的数量, + 写下去就等于告诉下游「客户要 0 个」;实测栽过:DAG 那条 BOM 里年用量本来是空的, + 被写成 eau=0,而同一拍的缺口数还记着 1 —— 一份自己跟自己打架的记录。 +② **PN 一个字符都不许改**:后缀、横杠、空格全照抄 + (实测:\`DAG2400L321504XZB01-R0\` 被写成 \`DAG2400L321504XZB01\`,尾巴上的 -R0 丢了 —— + 往后查历史价查不到、开出去的询价请求对不上料,而错处早已在这一步埋下)。 +③ **缺哪一格算哪一格,别整行判缺**:同一行里有值的列照抄 + (实测:那条缺年用量的料,连 BOM 里明明写着的制造商「浙江嘉康」与品类都一并没写 —— + 人拿到卡以为客户什么都没给,白问一轮他早就给过的东西); +④ 单位归一但不换算口径(12K→12000 可以;月用量×12 是业务判断,该问客户); +③ **外文品类照译不照抄**:BOM 常是客户本国语言(Варистор = 压敏电阻, + Антенна = 天线,Дроссель = 电感,Фильтр = 滤波器,Микросхема = 集成电路), + 译的是**品类那一列的词**,不是按料号猜这颗料是什么 + (实测:一颗天线被写成「集成电路」—— BOM 上白纸黑字写着 Антенна); + 译不准的原文照留,别硬编一个。 +④ 正文里的口径逐条落到对应行:点名要证书的写进 \`quality_req\`; + 客户**已预授权的替代件**写进 \`remark\`(那是后面选型的依据,丢了就得回头问客户)。 + +【同一拍记账】把 \`quote_intake.verdict\` 的这几个数写上: +· bom_total = 来件一共几条(附件行数,不含表头); +· skipped_total = 客户点名本轮不报的条数;skipped_note = 是哪几条、为什么(一句话); +· line_total = 落了几条(= bom_total − skipped_total,对不上就是拆漏了); +· **missing_key_total** = 缺 PN 或 年用量 的条数(必填缺); +· **missing_minor_total** = **除 PN / 年用量之外任何字段缺了**的条数(选填缺)—— + 客户料号、品类、制造商、目标价、交期、品质要求、备注,缺哪一个都算,一条只数一次。 + **别只数品类和制造商**:实测栽过一次,NE555P 只缺目标价,两个缺口数报的都是 0, + 于是那一问被整个吞掉 —— 人在成果表里看见一格空白,却没人问过他一句。 + 空不空由他决定要不要去补,不由你替他吞掉。 + +【最要紧的一条:**数就是数,不许为了过判据去改它**】 +两个缺口数如实写。缺一条就是 1,别因为「写 0 才好往下走」就写 0 —— +实测栽过一次:一颗料只缺目标价,如实记了 1,提交被判据拦住, +于是那个数被改回了 0、单子放行了,而台账上从此写着「缺选填 0 条」, +那一格空白再没人提起。**判据拦住你,说明该去办那件事,不是该去改那个数**; +真办不了(客户就是不给),把它留在数里、在 say 里说清楚,放行时由人定。 + +**这一步不发卡**:拆完就 finish,交给下一步(受理需求交人审定)去请人。`, + }, + { + item: 'S3_confirm', + intent: '把整份受理需求交人审定:可就地改,确认后才往下走', + writes: ['quote_intake.verdict'], + reads: [], + /* 阶段2 的执行目标件:询价受理需求(照 JCAI_PRD 的 A1-询价受理需求.html) */ + view: 'intake-overview', + tools: [], + /* + * **不给 doneWhen** —— 这一步触达世界(把件交到人手上),给了就等于给引擎 + * 一把替人签字的钥匙。而它的成果按定义也判不出来:「人看过、改过、认了」 + * 不是台账里任何一个数能自己变出来的。人工激活也一并给上: + * 引擎不会替你按下这一步,你得先 start 再发卡。 + */ + guide: `拆好的九条摆在那儿了,**这一单现在要经过人的手**。 + +【一单必发一张,不管缺不缺】 +这不是「缺东西时的追问」,是**阶段2 那张执行页**:AI 拟了稿,人审定。 +字段齐全也要发 —— 拆错一格(PN 少个后缀、品类译反、年用量看错列)只有人看得出来, +而那些错往后每一步都跟着跑。**别因为「没有缺口」就跳过这一步**: +实测栽过一次(2026-08-01),那一版只在缺东西时才发卡,于是一封齐整的询价进来, +人一眼没看过受理需求,下一次被叫到已经是终局验收 —— 用户当场说 +「我都没确认需求 它就让我验收了」。 + +【怎么发】先 start 这一步(它是人工激活的),再照这个骨架发,别少字段: +{"kind":"human","itemId":"S3_confirm","workOrder":"execution","card":{ + "title":"…","ask":"这是从来件与附件 BOM 归一出来的受理需求,请核对确认;哪一格不对就地改", + "steps":["型号 PN 与年用量 EAU 必须有 —— 缺一条这一单就走不到放行;客户没给的去问,问不到也要在备注里写清「客户未提供」", + "目标价尽量试探出来 —— 有它才谈得上「报得进不进得去」;客户不肯给,问一句「贵司这颗现在的成交价区间」也常有答案", + "抽错的就地改,多出来的行按行首 × 撤掉,漏掉的用表尾「加一行」补上;核完回这张卡提交"], +· **卡面是纯文本**:\`steps\` 与 \`standard\` 里**别写星号那类记号** —— 它们不会变成粗体, + 只会原样摆在人眼前(实测过一次)。要强调就靠句子本身的次序与用词。 + "standard":"必填(PN / 年用量)一条不缺;选填该问的都问过了 —— 问到的填上,问不到的留空并在备注里写明,放行时由你决定先受理还是退回。", + "basis":[{"k":"DAG2400L321504XZB01-R0","v":"缺年用量(必填,须补)"}, + {"k":"UMW AO6604","v":"缺制造商(选填,可先受理)"}], + "blocks":[{"t":"table","title":"有效 PN 清单(可就地改)", + "ledger":"quote_intake.record","editable":true, + "cols":["PN","客户料号","品类","年用量","制造商","目标价","备注"], + "fields":["pn","customer_pn","category","eau","brand","target_price","remark"]}]}} +**\`steps\` 与 \`standard\` 是作业指导书** —— 卡上照原样摆给人看:要核什么、核到什么程度算完。 +照上面抄即可(这一步每一单都一样,不必每次重写)。少了它,人拿到的只有 +「请核对确认」四个字,不知道该核什么。 +· \`ask\` 照当下情形写:**有必填缺**就写「有几条缺 PN/年用量,须向客户问清后填回」; + **只有选填缺**写「有几条缺选填项,可先受理也可去问」;**都齐**写「请核对确认」。 +· **别自己抄行**:绑了 \`ledger\` + \`fields\`,基座发卡那一刻就把这一单**整份清单** + 从草稿层铺进去 —— 人看到的是完整的九行,哪一格不对都能就地改,不只是缺的那一两行。 + 你抄一遍的下场实测过两次:回复有长度上限,铺长了半途截断、整拍作废; + 抄的时候还把 BOM 原样的 30 填了进去(台账里已经是 30000)。 +· \`fields\` 与 \`cols\` **一一对应**,列名照台账字典写。 +· \`basis\` 逐条写清**谁缺什么、是必填还是选填** —— 分量不同,人心里要有数; + 一格不缺就写这一单的要点(几条料、几条本轮不报、客户点名的口径要求)。 +发完卡把 **asked_total 加 1**(记账用:它证明这一问没省)。 + +【人提交之后 —— **表里的值基座已经替你写回了**】 +那张表绑着台账,人按下提交那一刻基座就把改动落进了草稿层(与数据录入卡同一条规矩: +该由人亲手落下的,不经你的手)。所以**别再 write 一遍那几行** —— +值没变的写入会被「写了等于没写」拒回(实测)。 + +你这一拍只做三件事,然后 finish: +① **把 missing_key_total / missing_minor_total 重算** —— 照台账里现在的值, + 补上了就该变小(基座写的是行,汇总列是你的活); +② **confirmed_total 记 1** —— 这一单经过人了。**不写它这一步收不了,整单卡在这儿**; +③ answered_total 加 1(答案已经处理过了)。 +漏掉①的下场实测过:值明明补上了,汇总列还写着「缺 1 条」,提交被判据一次次拦回来, +连拒五拍熔断 —— 而你只要把那个数改对就过去了。 +回填时**按卡上那一列的 PN 对行**:卡上的 PN 就是台账里那一行的主键, +换一个写法就是**新开一行**(实测:补写时把 PN 补全成 \`…-R0\`,于是同一颗料在表里成了两行, +条目数说 9 而表里躺着 10)。真发现第一次的 PN 抄错了,在 say 里说明并交给人, +别自己用新 PN 再写一行。 + +【**必填还缺就不能收**】confirmed_total 记了也没用:必填(PN / 年用量)缺一条, +下一步就是进不去 —— 这是验收标准。人交回来还缺必填,就再发一张卡说清还差哪几条。 +选填缺则不拦:问过就够了,别为它反复发第二张卡。 + +【人把格子留空交回来 = **客户给不了**,那也是一个答案】 +这时候要做的**不是再问一遍**(基座会当场拒:同一件事不许问第二遍),而是: +· confirmed_total 记 1、answered_total 记 1(人已经处理过了); +· 缺口数**照实留着**(缺就是缺,别改小); +· 这一步就算办完了 —— 选填缺不拦单,放行时由人定「先受理还是退回」。 +实测栽过一次:人留空交回,模型又发一张一模一样的卡,被拒; +转头把同一行原样再写一遍,又被「写了等于没写」拒;连拒五拍熔断,一单就这么停了。`, + }, + { + item: 'S4_history', + view: 'intake-classified', + intent: '调历史价工具,把 P-03 的三项判据取齐并落进料号分类表', + writes: ['quote_intake.type_table'], + reads: [], + tools: ['crm.history-price-read'], + /* + * 判据落表了才算取齐。**不能写 `history_days != null`** —— + * S-FEEL 里未绑定的名字 `!= null` 求值为 true,那一步开局就被收掉(踩过,测里拦下的)。 + * 取正向判断:没写过就没有这个键,`>= 0` 自然为假。 + */ + doneWhen: 'history_days >= 0', + guide: `P-03 只认三个入参:距上次报价天数、行情是否稳定、生命周期状态。 +三者齐了结论是**算出来**的,所以这一步唯一该做的事是把判据取齐: +调 \`crm.history-price-read\`,**一次把这一单所有 PN 传进去**(参数 pns 收数组, +别一条一条调 —— 每调一次就多烧一拍)。取不到不是错误:新料号无历史会回 +historyDays=9999、hit=false,那本身就是判据,命中 B 类「重估后报」是对的。 +拿回来的三项判据**原样逐条**写进 \`quote_intake.type_table\`。 + +**这一步一个数都不许自己填**(实测踩过,三条同时犯): +① 工具回什么就写什么 —— historyDays / marketStable / lifecycle 三个值**逐字照抄**。 + 你不知道某颗料的行情,工具知道;你估一个「15 天 · 在产」,判定表就照着你的估算出 A, + 而那颗料实际是客户点名要替代的 C。**估出来的判据比没有判据更坏**:它看起来很像真的。 +② **PN 必须原样**,一个字符都不许截、不许改写(实测:\`DAG2400L321504XZB01-R0\` + 被写成 \`DAG2400L321504XZB01\`,尾巴上的 -R0 丢了 —— 工具当场查不到, + 于是你又给它编了一组判据)。传进去的 PN 与写回台账的 PN 必须与受理记录逐字一致。 +③ 工具回 hit=false(这颗料没有历史)时,**照它回的 9999 / false / 未知 写**, + 那三个值本身就是判据,命中 B 类「重估后报」是对的结论 —— 不是缺数据,不必替它补。`, + }, + { + item: 'S5_classify', + view: 'intake-classified', + intent: '调分类器按 P-03 给每条打 A/B/C,标落两张表,条数照出参抄', + writes: ['quote_intake.type_table', 'quote_intake.record', 'quote_intake.verdict'], + reads: [], + tools: ['quote-intake.classify'], + /* + * **收步判据与验收判据同源**(2026-08-01 改)。 + * + * 从前只看 `untyped_total = 0` —— 那是活动自己写的数。它归零了这一步就收, + * 而验收那条比的是 `type_table_rows = record_rows`(基座数出来的行): + * 数归零了、表没写全,于是**收了步却过不了验收**,模型得回头补写一张已经收掉的表。 + * 收步比验收松,松出来的那一截就是返工。这里补上基座那一边:两处比同一件事。 + */ + doneWhen: 'untyped_total = 0 and line_total > 0 and quote_intake_type_table_rows = quote_intake_record_rows', + guide: `调 \`quote-intake.classify\`(纯工具,P-03 的求值器,同输入必同结论、零 token), +把 type_table 那几行判据一次传进去,拿回每条的 A/B/C 与打法。 + +【标要落**两张表**】 +· \`quote_intake.type_table\`:type_tag + basis —— 这是「凭什么这么判」的底稿; +· \`quote_intake.record\`:**同一个 type_tag 也要写回受理记录**(按 PN 对行)—— + 受理记录才是流去下游的那份交付物,A4 就靠这一列决定哪几条能直接取历史锚点。 + 漏了这一步,报告上的状况标那一列整列是空的,而下游拿到一份没打标的清单(实测)。 + +【条数**照出参抄,不许自己数**】classify 的出参里带 \`counts\`(A/B/C 各几条)与 \`total\`, +照它写 verdict 的 type_a_total / type_b_total / type_c_total,并把 untyped_total 归零。 +实测数错过一次:判定器给的是 5A/3B/1C,报上去的是 7A/2B/1C,派单也跟着按错的数开 —— +而验收那条等式两边都是自报的,于是「2 = 2」顺利过关,真实的第三条 B 类料一张单也没开。 + +**不要绕过决策表自己判** —— 规则表可回放、可追责,临场判断不是。 +\`type_tag\` 一律取出参里的那个字母,**不许自己写一个**:实测有过一次, +它自己给出的判据是「200 天 · 行情不稳」(按 P-03 该判 B),写回台账的标却是 A —— +判据与结论对不上,而验收只看结论,这种错一路走到报价单上都没人拦。 +拿不准的一律降 B,不许臆断 A:错标 A 会让一条本该重新核价的料带着旧价走到报价单上。`, + }, + { + item: 'S6_route', + /* 阶段3 的验收目标件:询价受理记录(照 A1-询价受理记录.html) */ + view: 'intake-release', + intent: '按三类结果立两枚去向旗', + writes: ['quote_intake.verdict'], + reads: [], + tools: [], + /* + * **这一步不再开单**(2026-08-02 用户当场定): + * 「替代评估请求 / 询价请求这两项数据不在这里产出;随令牌流向下一个节点的 + * 只有工作成果验收内的内容;进入选型替代后,活动会自己归一需要的数据。」 + * + * 从前 A1 逐条开 `alt_request` / `rfq_request` 两张单交给 A2 / A3 —— + * 那是替下游把它的输入先摆好,而下游本来就要照自己的口径归一一遍: + * 同一份事实在两个活动里各存一份,改一处就分叉。A1 交出去的是**受理记录**, + * 下游读它即可。 + * + * 收步判据随之改成「两枚旗都立了」。旗是布尔,合法值含 false, + * 拿它判「写没写过」分不清「没写」与「写了 false」—— 所以仍取同步写下的**计数** + * (`route_decided_total`:定完去向记 1),并挂 `untyped_total = 0` 当前置: + * 两个数都得是模型显式写过的,否则「未绑定的名字相等」会让这一步开局即收 + * (包内自测那条「doneWhen 开局就为真」拦过这个)。 + */ + doneWhen: 'untyped_total = 0 and route_decided_total > 0', + guide: `状况标是判断的**输入**,不等于去向。照三类的结果立两枚旗: +· 有 C 类(EOL / 缺货 / 客户点名替代)→ need_alternative = true,这一单要走替代选型; +· 有 B 类(无近期历史价或行情波动)→ need_sourcing = true,这一单要走寻源询价; +· 两者可同时成立 —— 选型定案后仍要为替代料寻价。 +写完把 **route_decided_total 记 1**(记账用:它证明去向定过了,引擎按它收步)。 + +**不要开什么请求单** —— 替代评估请求、询价请求都**不在这一步产出**: +随令牌流去下一个节点的只有**工作成果验收里的那份受理记录**, +A2 / A3 拿到之后会按自己的口径归一需要的数据。你替它们先摆一份, +等于同一份事实在两个活动里各存一份,改一处就分叉。 + +**这两枚旗是下游两道网关唯一读的东西**:不写,整条流出了受理口就没路可走。 + +**去向不用你选,也不用人选** —— 契约给三个出口各写了判据(有没有 C 类、有没有 B 类), +放行那一刻基座按序求值自己定,卡上只如实告诉人走哪一口、凭哪一条。 +你要做的只有一件:**把三类条数与两枚旗写对**(它们正是那几条判据读的数)。 +旗写错,算出来的去向就错 —— 但那是数据错,不是选择错。`, + }, + { + item: 'T_release', + view: 'intake-release', + intent: '判定成果已齐,提交放行交 BD 验收', + writes: [], + reads: [], + tools: [], + // 放行触达世界,必须人按一下 —— 引擎不自动收它(剧本里也不给 doneWhen) + guide: `line_total > 0、missing_key_total = 0、untyped_total = 0、两枚旗已定 —— +**先 start T_release**(它标了人工激活:放行会触达世界,必须按一下)。 + +**start 之后、submit 之前,把清单里**每一条**裁量判据(L3/L4)都裁掉** +(同一拍里排在 start 后面即可)。**一条都不能漏** —— 漏一条,submit 当场被拒, +而拒回来的理由里就写着漏的是哪一条:照它原样抄 criterion 再裁一次即可, +**别原样重提同一个 submit**(实测连提七次,每次都被同一条拒回来)。 + +眼下**只剩一条**,照名字逐字抄(2026-08-02 起「吃不准降 B」那条已从清单里删掉, +别再裁它 —— 裁一个不存在的判据会被拒): +· judge criterion="选填缺口(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理" —— + **只看 \`quote_intake_record_gaps\` 这一个数**(基座现数的:还有几行缺选填; + \`..._gaps_keys\` 写着缺在哪几行的哪一格)。就两种写法,没有第三种: + · **= 0** → satisfied=true,rationale 写「选填已补齐」; + · **> 0** → satisfied=**false**,rationale 照 \`..._gaps_keys\` 逐条写清缺在哪儿 + (例:「DAG2400L321504XZB01-R0 缺目标价」),交放行的人定「先受理还是退回」。 + + **「我发过卡问过了」不算满足** —— 发卡是过程,不是结论;而这一单本来就必发一张 + 审定卡,拿它当理由的话这条判据永远是绿的,等于没有这道门 + (实测:客户没给目标价,它照样裁了满足,人签字时根本不知道还缺着)。 + 裁成不满足**照样能提交**,理由连着卡一起交给签字的人 —— 那正是这条判据的用处。 + **不许为了让它绿去改哪个数** —— 那个数你也改不动,它由基座现数。 +**criterion 照判据名原样写**(整句,含括号里那几个字),写别的名字对不上,那一条仍算未达标。 + +**去向不用你裁**:三个出口各写了判据,放行那一刻基座按序求值、首个命中即出 —— +那是算出来的,不是判出来的。从前这里还有一条「去向判断合乎业务常理」, +那是让你给一个自己没做的决定盖章:裁「满足」是废话,裁「不满足」也改不动什么。 +C 类开没开替代评估请求、B 类开没开询价请求,由两条机检判据当场对账。 + +这一条是机器判不了的(L4 独立评审:缺口真还在时,「先受理还是退回」只有人能定), +只能由你当场裁并留下理由 —— **不裁就提交会被当场拒**:还有裁量判据没裁过时, +提交等于把没办完的活推给人。裁完再 submit,BD 验收。 +(缺口为 0 时基座当它已达标、不必你裁;裁了也不碍事。) +终局唯放行:你不能自己宣布完成。 +验收报告上让 BD 当场改的那两个值(两枚去向旗)由**契约声明**(revisableAtRelease), +基座放行前从台账现读拟值 —— 你不用在 submit 上重述一遍,写好台账就行。`, + }, +]; + +/** + * 阶段 —— **进度条的刻度**,只给人看,引擎一眼都不看。 + * + * 六个计划项是引擎的粒度(拆到能判「干完没有」);阶段是人的粒度。 + * 「查历史价 / 逐条打标 / 定去向」在业务上是**一件事** —— 判这单该怎么走, + * 分成三项是因为引擎要一项一项收,而人只想知道「到判去向这一步了」。 + * + * 只列**激活态**的三段。放行不在这儿:每个活动都以放行收口(铁律③), + * 那是系统的规矩不是 A1 的阶段;进度条自己在末尾加那一格,所以人看到的是 x/4。 + */ +export const INTAKE_PHASES = [ + { name: '定位客户', items: ['S1_customer'] }, + { name: '归一受理', items: ['S2_normalize'] }, + { name: '判定去向', items: ['S4_history', 'S5_classify', 'S6_route'] }, +]; diff --git a/activities/quote-intake/src/index.ts b/activities/quote-intake/src/index.ts index 0223d82..3026af1 100644 --- a/activities/quote-intake/src/index.ts +++ b/activities/quote-intake/src/index.ts @@ -1,63 +1,17 @@ /** - * 统一报价 A1 · RFQ 需求分析 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。 + * 统一报价 A1 · 询价受理 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。 * - * 这是 **flow** 活动:下一步由步序声明给定,引擎照谱推进; - * 本文件承担声明装不下的那部分 —— 视图流控与纯工具(打标)。 - * **零环境能力**:一切能力经基座六件注入。 + * **这个活动没有 `run`,不是缺件。** + * + * 2026-07-29 范式退役后,活动一律案例活动:推进由 Loop 驱动(定位 → 择向 → 决断 → + * 行动 → 写数据),行动全部落在基座六件上 —— 调工具、调人、立监听、写台账草稿。 + * 活动要交代的东西因此**全在清单里**:案例模型(CMMN 子集)+ 案例说明书 + 两张判定表 + + * 三条技能 + 台账声明 + 四张 HTML 报告模版。「清单机器可读,系统不执行代码即知包为何物」。 + * + * 这个文件之所以还存在,是因为清单的 `entry` 指着它 —— 它是**被静态审扫描的产物文本** + * (查 import 与全局引用,证明活动包零环境能力)。将来有确定性子序列要结晶成代码, + * 落点也在这里。现在没有就不写:摆一个空转的 `run` 去假装有实现,更坏。 */ import { defineActivity } from '@sdk'; -import type { ActivityBase, ActivityOutcome } from '@sdk'; -const INTAKE = 'quote_intake.record'; -const TYPE_TABLE = 'quote_intake.type_table'; - -export default defineActivity({ - name: 'quote-intake', - paradigm: 'flow', - - async run(base: ActivityBase): Promise { - const records = await base.ledger.read(INTAKE, { customer_code: String(base.contract.data['customerCode'] ?? '') }); - const typed = await base.ledger.read(TYPE_TABLE); - - // 视图流控:按局面推结构化块 —— 内容归活动,容器归系统 - await base.interaction.pushView([ - { - t: 'kv', - title: '受理概览', - items: [ - { k: '客户', v: String(base.contract.data['customerCode'] ?? '') }, - { k: '已归一', v: `${records.length} 条` }, - { k: '已打标', v: `${typed.length} 条` }, - ], - }, - { - t: 'table', - title: '统一受理记录', - cols: ['PN', '厂牌', 'EAU', 'Type'], - rows: records.map((r) => [ - String(r['pn'] ?? ''), - String(r['brand'] ?? ''), - String(r['eau'] ?? ''), - String(r['type_tag'] ?? '未打标'), - ]), - }, - ]); - - const untyped = records.filter((r) => r['type_tag'] === undefined || r['type_tag'] === null); - if (untyped.length > 0) { - await base.interaction.say(`还有 ${untyped.length} 条未打标,继续按三分类规则 P-03 判定。`, 'conclusion'); - return { kind: 'await', reason: '等待打标步完成' }; - } - - await base.interaction.say( - `${records.length} 条已全部打标:` + - `A ${count(records, 'A')} 条直通定价,B ${count(records, 'B')} 条转寻源,C ${count(records, 'C')} 条转选型。`, - 'criterion', - ); - return { kind: 'submit', deliverable: 'intake-record', rationale: '逐条已打标,必交付物齐备' }; - }, -}); - -function count(rows: ReadonlyArray>, tag: string): number { - return rows.filter((r) => r['type_tag'] === tag).length; -} +export default defineActivity({ name: 'quote-intake' }); diff --git a/activities/quote-intake/src/manifest.ts b/activities/quote-intake/src/manifest.ts index 55b99c7..e77b9f6 100644 --- a/activities/quote-intake/src/manifest.ts +++ b/activities/quote-intake/src/manifest.ts @@ -1,182 +1,730 @@ /** - * 活动包清单 —— 统一报价 · 客户RFQ受理(受理归一)。 + * 活动包清单 —— 统一报价 A1 · 询价受理。 * - * 业务素材出处:docs/MVP示例工作流.html §1.1;按 README《归一化对照表》映射为: - * SOP 节点 → **标准活动(flow)**;作业指导书 S1…S6 → **步序声明**(四种步型); - * 步内判断 D1 完整度 → **条件谓词**;三分类规则 P-03 → **DMN 决策表**; - * SLA(受理<15min、打标<45min)→ Harness 时限与预算。 + * 业务素材出处:`docs/MVP示例工作流.html` §1.1。归一化映射: + * SOP 节点 → **案例活动**(CMMN 子集 + 说明书 + DMN); + * 作业指导书 S1…S6 → 案例说明书的六段(见 `case-model.ts` 的建模口径注); + * D1 判完整度 → **DMN 判定表 F-01**(《询价受理规范》); + * 三分类规则 P-03 → **DMN 判定表**; + * SLA(受理<15min、判定<45min)→ Harness 时限与预算。 + * + * 「清单机器可读,系统不执行代码即知包为何物」—— 这份清单就是这个活动的全部。 */ import type { ActivityManifest, DmnModel } from '@contracts'; -import { INTAKE_SKILLS } from './skills.ts'; -import { CMMN_XML, NARRATIVE } from './case-model.ts'; import { activityIdSchema } from '@contracts'; +import { INTAKE_SKILLS } from './skills.ts'; +import { CMMN_XML, INTAKE_PHASES, INTAKE_STEPS, NARRATIVE } from './case-model.ts'; import { INTAKE_CLASSIFIED, INTAKE_OVERVIEW, INTAKE_RELEASE } from './views/report.ts'; -const INTAKE_DOMAIN = 'quote_intake'; +const D = 'quote_intake'; const ACTIVITY_ID = activityIdSchema.parse('quote-intake'); /** - * 三分类规则 P-03 —— 把「装得下的判断」从 LLM 手里拿走。 - * 由纯工具步 `quote-intake.classify` 逐条求值,命中规则行留痕。 + * 两张判定表 —— **能算的不问 LLM**:同输入必同结论、零 token、命中行留痕。 + * + * F-01 受理字段完整度(《询价受理规范》):三要素齐才放行,缺关键字段退回补全。 + * P-03 三分类:A 历史价≤90天且行情稳 / B 无历史·过期·波动 / C EOL·缺货·要替代。 + * + * 两张都是 `FIRST`:规则从上往下,第一条命中即出结论 —— 顺序本身就是业务优先级, + * 所以 C 的那一行必须在最前面(EOL 的料哪怕历史价很新,也不该判成 A 直接快答)。 */ -export const P03_DMN: DmnModel = { - drd: [{ decision: 'p03-type', requires: [], inputs: ['historyDays', 'marketStable', 'lifecycle'] }], - tables: [{ +export const F01_DMN: DmnModel['tables'][number] = { + id: 'f01-completeness', + label: '受理字段完整度 F-01', + hitPolicy: 'FIRST', + inputs: [ + { name: 'missing_key_total', expression: 'missing_key_total' }, + { name: 'missing_minor_total', expression: 'missing_minor_total' }, + ], + outputs: [{ name: 'completeness' }, { name: 'disposition' }], + rules: [ + { id: 'f01-2', when: ['> 0', '-'], then: ['缺PN/数量/交期', '退回补全'], annotation: '三要素缺任一 → 必须追问补全' }, + { id: 'f01-3', when: ['0', '> 0'], then: ['缺次要字段', '受理并追问'], annotation: '只缺厂牌/包装/备注 → 先受理' }, + { id: 'f01-1', when: ['0', '-'], then: ['八字段齐', '受理转寻源'], annotation: '齐 → 直接往下走' }, + ], +}; + +export const P03_DMN: DmnModel['tables'][number] = { id: 'p03-type', label: '三分类规则 P-03', - hitPolicy: 'FIRST' as const, + hitPolicy: 'FIRST', inputs: [ - { name: 'historyDays', expression: 'historyDays' }, - { name: 'marketStable', expression: 'marketStable' }, + { name: 'history_days', expression: 'history_days' }, + { name: 'market_stable', expression: 'market_stable' }, { name: 'lifecycle', expression: 'lifecycle' }, ], - outputs: [{ name: 'type' }, { name: 'play' }], + outputs: [{ name: 'type_tag' }, { name: 'play' }], rules: [ { id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: ['C', '转案处理'], annotation: 'EOL/缺货/要替代 → C' }, { id: 'p03-a', when: ['<= 90', 'true', '-'], then: ['A', '直接快答'], annotation: '历史价≤90天且行情稳 → A' }, { id: 'p03-b', when: ['-', '-', '-'], then: ['B', '重估后报'], annotation: '无历史/过期/波动 → B' }, ], - }], +}; + +export const INTAKE_DMN: DmnModel = { + drd: [ + { decision: 'f01-completeness', requires: [], inputs: ['missing_key_total', 'missing_minor_total'] }, + { decision: 'p03-type', requires: [], inputs: ['history_days', 'market_stable', 'lifecycle'] }, + ], + tables: [F01_DMN, P03_DMN], }; export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, - name: '客户RFQ受理', - packageVersion: '0.1.9', + name: 'A1 · 询价受理', + packageVersion: '40.0.0', category: '统一报价', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-quote-intake', }, - /* 契约七件 */ + /* ── 契约七件 ────────────────────────────────────────────────── */ contract: { activityId: ACTIVITY_ID, - version: '0.1.0', + version: '1.0.0', input: { + /* + * 入参就是**那封信** —— 客户邮箱 / 主题 / 邮件内容 / 邮件附件,前三样必填。 + * + * 从前这里收的是「客户编码 + 询价单号」,那是**受理之后**才有的东西: + * 一封真的询价来函不会带着我们内部的客户码,单号也常常压根没有。 + * 把它们摆成必填的后果实测过:流程包起单时通道抽不出这两个键,基座当场发补全卡, + * 整单 0 拍停在那儿等人 —— 而信本身是完整的。认客户、认单号是 S1 的活,不是门口的闸。 + * + * 起单仍是宽进:门口不拒缺项(缺 required 由基座发补全卡,不是活动的事)。 + */ data: { type: 'object', - required: ['customerCode', 'rfqRef'], + required: ['customerEmail', 'subject', 'body'], properties: { - customerCode: { type: 'string' }, - rfqRef: { type: 'string' }, - subject: { type: 'string' }, - /** 询价原文 —— 归一步拆解的对象 */ - body: { type: 'string' }, + customerEmail: { type: 'string', title: '客户邮箱' }, + subject: { type: 'string', title: '主题' }, + /** 询价原文 + BOM 转述 —— 归一这一步拆解的对象 */ + body: { type: 'string', title: '邮件内容' }, + /* + * **真文件**(`format: 'file'`):界面给文件选择器,基座在入口把它读成文本。 + * 每一件是 `{ name, contentType, size, ref, text?, note }` —— + * `text` 有值就是读得出内容(xlsx 读成制表符分列的行); + * 读不出的类型 `text` 缺席、`note` 说明为什么。**说明书里写死了怎么用**。 + */ + attachments: { + type: 'array', + format: 'file', + title: '邮件附件', + items: { + type: 'object', + properties: { + name: { type: 'string', title: '文件名' }, + contentType: { type: 'string', title: '类型' }, + size: { type: 'integer', title: '字节数' }, + ref: { type: 'string', title: '存档号' }, + text: { type: 'string', title: '读出来的文本' }, + note: { type: 'string', title: '读取说明' }, + }, + }, + }, }, }, messageAllowed: true, - messageHint: '客户口头急单等特殊性写这里', + messageHint: '客户口头急单、老客户先受理后补单等特殊性写这里', + /* + * **这一单是被一封信叫起来的** —— 声明入参里哪几个字段合起来是那封来件, + * 基座在起单那一刻就把它归进任务的沟通记录(工作台那一屏),原件与附件一并存下。 + * + * 为什么是声明而不是跑起来再认一遍:「入参的 subject 就是来件主题」是写清单时 + * 就知道的事实,没有临场判断的成分 —— 交给模型每单认一遍,是拿一整拍的 token 换一个常量。 + * 而且人一点开这单,沟通记录里就已经躺着那封信,不必等模型跑几拍。 + */ + inboundComm: { + channel: 'mail', + partyField: 'customerEmail', + subjectField: 'subject', + bodyField: 'body', + attachmentsField: 'attachments', + }, }, output: { + /* + * 出口载荷**从落地事实推导**(§7.5):标量字段必须有台账列承载。 + * 两枚去向旗就是下游两道网关唯一读的东西 —— 没有 `need_alternative` / + * `need_sourcing` 这两列,整条流出了受理口就没路可走(踩过:G1 全流死路)。 + */ mandatory: [ { name: 'intake', payload: { type: 'object', - required: ['records'], - properties: { records: { type: 'array' } }, + required: ['records', 'needAlternative', 'needSourcing'], + properties: { + records: { type: 'array', title: '询价受理记录(已打标)' }, + needAlternative: { type: 'boolean', title: '需要选型' }, + needSourcing: { type: 'boolean', title: '需要寻源' }, + }, }, - description: '统一受理记录(已打标)', + description: '询价受理记录(打标)+ 两枚去向旗', + }, + ], + /* + * **三条出边 = 一个 XOR 选择组**(2026-07-31 用户定,对齐流程包 E2/E3/E4)。 + * + * 受理这件事办完,这一单只会往三个地方去其中一个: + * · 只有 A 类(有历史价、行情稳)→ 直通定价; + * · 有 C 类要替代 → 去选型(选型定案后仍可能回头寻价); + * · 无选型但有 B 类 → 去寻源。 + * + * 从前这里是空的:活动契约只说得出「我发一个 intake 口」,三条边的知识 + * 全长在流程包的边条件上 —— 光看活动清单不知道它有三个出口, + * 换一条流来编排就得重新猜。现在**契约自己说得出**。 + * + * 两枚旗照旧留在 `intake` 载荷里:下游两道网关读的是它们, + * 撤掉等于把 G1 全流死路那个坑再踩一遍(踩过)。选口与旗必须自洽 —— + * 说明书里写死了对应关系,放行确认时选口随之落定。 + */ + selective: [ + { + group: 'intake-route', + /* + * **三条按序求值,首个命中即出** —— 与报告上那张 G2 规则表逐条对应。 + * 每一口都写了 `when`,所以这一单往哪走是**算出来的**: + * 放行时基座自己判,卡上只如实告诉人走哪一口、凭哪一条,不摆成选择题。 + * 去向是分类表那几个数的函数;让人凭印象点一下, + * 等于把一个可回放的判定换成一次不可回放的手感。 + */ + ports: [ + { + name: 'to-alternative', + payload: { + type: 'object', + required: ['rfqRef', 'records', 'altRequests'], + properties: { + rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' }, + records: { type: 'array', title: '询价受理记录(已打标)' }, + altRequests: { type: 'array', title: '替代评估请求' }, + }, + }, + description: '有 C 类要替代 —— 去选型', + /* ① 有 C 类就先去选型(替代定案后仍可能回头寻价) */ + when: 'quote_intake_record_type_tag_c > 0', + }, + { + name: 'to-sourcing', + payload: { + type: 'object', + required: ['rfqRef', 'records', 'rfqRequests'], + properties: { + rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' }, + records: { type: 'array', title: '询价受理记录(已打标)' }, + rfqRequests: { type: 'array', title: '询价请求' }, + }, + }, + description: '无选型但有 B 类 —— 去寻源', + /* ② 无 C 类、有 B 类 → 寻源 */ + when: 'quote_intake_record_type_tag_c = 0 and quote_intake_record_type_tag_b > 0', + }, + { + name: 'to-pricing', + payload: { + type: 'object', + required: ['rfqRef', 'records'], + properties: { + rfqRef: { type: 'string', title: '询价单号(单标识 —— 下游台账的主键)' }, + records: { type: 'array', title: '询价受理记录(全 A 类)' }, + }, + }, + description: '只有 A 类:有近期历史价且行情稳 —— 直通定价', + /* ③ 前两条都不命中,才是「全 A 直通定价」 */ + when: 'quote_intake_record_type_tag_c = 0 and quote_intake_record_type_tag_b = 0', + }, + ], }, ], - selective: [], }, + /* + * 必交付物**带可机检依据**:`ledger` 是台账地址,预检自己数行数。 + * 不给地址,「缺一不得提交放行」就只能靠活动自报 —— 自报等于没有门。 + */ requiredDeliverables: [ - { key: 'intake-record', label: '统一受理记录(打标)' }, - { key: 'type-table', label: '料号分类表' }, + { + key: 'intake-record', + label: '询价受理记录', + ledger: `${D}.record`, + /* + * 验收页上让 BD 就地改的那几列 —— **数据是他该管的**。 + * + * **`type_tag` 在其列**(2026-08-01 用户实测逼出来的):三分类是 P-03 算的, + * 但算得对不对该由人说了算 —— 他改一颗料的类,就是在推翻一次判定,那是他的职权。 + * 从前它不在这份名单里,于是人在件上把三条全改成 A、按了保存, + * **改动被整个丢掉**(写回按这份名单摆格子,不在名单里的列压根不进载荷)—— + * 屏上类徽照旧、去向照旧写着「去选型」。他以为自己改了,其实什么也没发生。 + * + * **去向仍不在其列**:那是 `type_tag` 的函数(见 output.selective 的 `when`)—— + * 改类就够了,去向随之重算;再让人单独选一次去向,等于给同一件事开两个入口。 + */ + editableAtRelease: ['pn', 'customer_pn', 'category', 'eau', 'brand', 'target_price', 'remark', 'type_tag'], + /** 这份成果由哪几张表承载 —— 分类表是判据来源、结论是结论栏,都是它的内部结构 */ + ledgers: [`${D}.record`, `${D}.type_table`, `${D}.verdict`], + }, + /* + * ── **料号分类表与受理结论不是两份成果**(2026-08-02 用户当场指出: + * 「不都是包含在询价受理记录的结构里的吗,为啥单独陈列?」)──────────────── + * + * 从前它们各占一条 `requiredDeliverables`,于是人在成果面上看到三样东西 —— + * 而在业务上受理这件事**只交出一份东西**:一份能往下走的询价受理记录。 + * 分类表是它的判据来源、受理结论是它的结论栏,两者都是**同一份成果的内部结构**。 + * + * 现在合成一条,`ledgers` 说清它由哪几张表承载:落地那一屏按交付物点名, + * 三张表的行数并进「询价受理记录」一行,不再各报各的。 + */ ], + /* + * 验收判据逐条标真实等级。L1/L2 必带 predicate —— 当场对任务视图 S-FEEL 求值, + * 不带就等于把「机检」写成了摆设(安装期 acceptance-level-misreported 挡这个)。 + * + * 谓词读的是**汇总列**(line_total / missing_key_total / untyped_total), + * 不是逐行列:任务视图把草稿行摊平到顶层是「后写的赢」,拿逐行的 + * missing_key_fields 求值只会验到最后写的那一行 —— 那不叫机检,叫抽样。 + * 所以汇总列的名字与逐行列**故意不同名**,免得互相盖掉。 + */ acceptanceCriteria: [ - { key: 'fields-complete', label: '八字段齐(PN/EAU/交期为必要三要素)', level: 'L1', predicate: 'missingKeyFields = 0' }, - { key: 'typed', label: '每条都已打标 A/B/C', level: 'L2', predicate: 'untyped = 0' }, - { key: 'downgrade-safe', label: '吃不准降 B,不许臆断 A', level: 'L3' }, - { key: 'owner-release', label: 'BD 放行', level: 'L5' }, + /* ① 定位客户 —— 认得出老客户或人建了新客户,这一单才落得到某个客户名下 */ + { key: 'customer-known', label: '客户已确认:发件邮箱认出为老客户,或已为新客户建好档', level: 'L1', predicate: 'customer_total > 0' }, + /* + * 条目数**与真实行数对得上**。判据不读活动自报的那一个数就完了 —— + * `quote_intake_record_rows` 是基座数出来的本单行数(草稿层),两边同时成立才算。 + * 实测拦的是这个:PN 抄漏了后缀,补写时又成了新的一行,表里 10 行而 line_total 记着 9; + * 从前两边都由活动自己报,自然对得上,人签完字,下游拿到一份多一行的交付物。 + */ + { + key: 'lines-present', + label: '来件已逐条归一:说了几条,受理记录里就躺着几行', + level: 'L1', + predicate: 'line_total > 0 and quote_intake_record_rows = line_total', + }, + /* + * ② 必填与选填**分量不同**(2026-07-31 用户定): + * · 必填(PN / 年用量)**是验收标准** —— 不齐不许放行; + * · 选填(品牌 / 备注)**只提醒** —— 缺了照样放行,但那一问不能省。 + * 所以选填这条判据不看「补齐没有」,看**问过没有**: + * 客户不肯给品牌是客户的事,没去问过是我们的事。 + */ + /* + * **读基座数出来的数,不读活动自报的**(2026-08-01 改)。 + * + * 从前是 `missing_key_total = 0` —— 那个数由模型自己重算。人在卡上补了年用量、 + * 基座已经写回草稿层,而模型漏算一次这条判据就永远绿不了:连跑十轮里三轮死在这, + * 每次都是同一条连拒五拍熔断。`quote_intake_record_missing` 由基座按 + * 台账声明的 `required` 现数(缺在哪几行也一并摆进拒回),模型改不动也不必改 —— + * 它只要把那一格补上。**一条只接受「是」而模型又左右不了的判据,是死胡同。** + */ + { + key: 'required-complete', + label: '必填齐:每一条都有型号 PN 与年用量 EAU', + level: 'L1', + predicate: 'quote_intake_record_missing = 0', + }, + /* + * ── 这一条为什么**不是机检**(2026-08-01,实测逼下来的)────────────── + * + * 它原本是 L2:`missing_minor_total = 0 or asked_total > 0`。两个数都由活动自己写, + * 于是这条判据对模型来说不是「去把那一问办了」,而是**「把这个数改小就能过」**。 + * 实测就是这么走的:NE555P 只缺目标价,模型如实记了 missing_minor_total = 1, + * 提交被这条判据连拒四次,第五拍它把那个数**改回了 0** —— 判据当场变绿、单子放行, + * 而台账上从此写着「缺选填 0 条」,那一格空白再没人提起。 + * + * **机检判据只许读基座数得出来的数**(如 `域_对象_rows`);读活动自报的数, + * 它就不是门,是一句「改哪个数能过」的提示。而「问过客户没有」是过程事实, + * 基座此刻数不出来(卡上不带计划项号,数不清哪张卡是为这件事发的)—— + * 那就**别假装机器判得了**:降到 L4 交人,并把「缺选填几条」摆到放行页的指标带上, + * 签字的人一眼看得见。要它重回机检,得先让基座能按用途数卡(见 debt A1-3)。 + */ + /* + * **「问过」不是结论,是过程**(2026-08-02 用户实测:「我这次没给目标价, + * 你 L4 咋又达标了?」)。 + * + * 原文是「要么**问过客户**,要么放行时确认可以先受理」—— 而 A1 每一单必发一张 + * 受理需求审定卡,「问过」于是恒真:这条判据永远能裁成绿,等于没有这道门。 + * (上一版剧本还明写「发过卡问过就 satisfied=true」,是亲手把它焊死在绿上。) + * + * 结论只有两种:**缺口补齐了**,或者**还缺着、交给签字的人定**。 + * 后者正是这条判据的价值 —— 把「还缺这几格」连同理由摆到他面前, + * 而不是替他宣布「问过了就算数」。裁成不满足照样能提交,门不因此拦人。 + */ + /* + * **前半句由基座数,后半句才交人**(2026-08-02 用户实测逼出来的)。 + * + * 「已补齐」是 `quote_intake_record_gaps = 0` —— 基座照台账声明的 `expect` + * 现数(草稿层实时),模型算不算得对都不影响它。从前整条只读裁断, + * 于是它凭记忆写理由:用户在件上把目标价填好了,它照样裁「未满足」, + * 理由写「NE555P 缺目标价,客户未提供」。用户原话:「这个价格是我填写的, + * 但是它判错了」。复现的那一单更离谱 —— 两行年用量都填着,它写 + * 「共 4 处选填缺口」,而 EAU 压根是必填不是选填。 + * + * 等级仍是 L4:缺口真还在的时候,「先受理还是退回」确实只有人能定。 + * 谓词为真时引擎当它已达标、把那句理由一并丢掉(见 `criterionCheck`)。 + */ + { + key: 'optional-asked', + label: '选填缺口(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理', + level: 'L4', + predicate: 'quote_intake_record_gaps = 0', + }, + /* + * **这条判据读基座数出来的数**(2026-08-02 实测逼出来的,「机检只许读基座数得出来的数」 + * 第三次被违反)。从前读活动自写的 `untyped_total` —— 那个数它改一下就骗过去了。 + * + * 现在比的是:**打了标的条数(基座按 `type_tag` 的取值域现数)= 受理记录行数**。 + * 两边都由基座出,模型碰不着。 + */ + { + key: 'typed', + label: '每条都按三分类规则 P-03 打了标:A 直接报 / B 重估后报 / C 找替代', + level: 'L2', + predicate: + 'quote_intake_record_rows > 0 and quote_intake_record_type_tag_a + quote_intake_record_type_tag_b + quote_intake_record_type_tag_c = quote_intake_record_rows', + }, + /* + * ── 这里**去掉了四条**(2026-08-02 用户当场指出:「这些判据都删了吧」)────── + * + * `downgrade-safe`(L3 吃不准降 B)· `route-alt`(C 类都开了替代评估请求)· + * `route-rfq`(B 类都开了询价请求)· `typed-on-record`(每条记录都带状况标)。 + * + * 四条都是**内部一致性**:打标依据对不对、几类料开了几张单、两张表行数齐不齐。 + * 它们该由引擎自己看住(数对不上就是活没干完,拒回让模型改), + * 而**放行卡是给签字的人看的**——他要判的是「这一单受理得对不对」, + * 不是替引擎复核一遍它自己的账。一屏九条,真正要他过目的那几条反而淹了。 + * + * 落地那一侧不因此变松:派单条数与打标覆盖仍在剧本的收步判据里 + * (S5/S6 的 `doneWhen` 读同一批基座读数),错了照旧走不到放行门。 + * 「几张单开出去了」改在**验收之后**如实呈现(落地结果), + * 那才是人真正关心的:这一签下去,世界上发生了什么。 + */ + /* + * ── 这里**去掉了 `route-judgement`**(L4,2026-08-01 用户当场指出)────── + * + * 原文是「去向判断合乎业务常理(客户特殊要求、急单等)」。 + * **去向已经不是判断了**:三个出口各写了 `when`,放行那一刻基座按序求值、 + * 首个命中即出(`type_c_total > 0` …),人签的字确认的正是算出来的那一口。 + * 再挂一条 L4 让模型裁一次「这个去向合不合理」,是让它给一个自己没做的决定盖章: + * 裁「满足」是废话,裁「不满足」也改不动任何东西 —— 判据读的那几个数才改得动。 + * 要改去向,改的是三类条数;那已经由 route-alt / route-rfq 两条机检看着了。 + * + * 一条判据得能**指向一个可做的动作**,否则它只是一道要人按一下的手续。 + */ + /* + * L5 是**责任关**,不是质量关:它声明「这份交付物必须由人放行」, + * 由 `T_release` 的人工激活 + 终局唯放行落实。 + * 卡面上不摆它 —— **那张卡本身就是这道关**(基座统一滤,见 `engines.ts`)。 + */ + { key: 'owner-release', label: 'BD 放行:签下去这一单即正式受理并转入下游', level: 'L5' }, ], + /* + * 放行时人**可以改什么** —— 2026-08-01 改口径:**去向不在其列**。 + * + * 从前这里摆的是两枚去向旗,让 BD 在放行卡上当场改。那是把「这一单往哪走」 + * 交给了一次手感 —— 而它本是**分类表算得出来的事实**(有没有 C 类、有没有 B 类), + * 现在由契约的出口谓词按序求值定下(见 output.selective 的 `when`), + * 卡上只如实告诉人走哪一口、凭哪一条。可回放的判定不该退化成一次不可回放的点击。 + * + * 那人在验收页上改什么?**改成果本身** —— 那份受理记录清单就绑在卡上, + * 九行逐格可改,改完由基座写回草稿层、随放行合并(AI 不经手)。 + * 数据是他该管的;去向不是。 + */ + revisableAtRelease: [], prohibitions: [ + { key: 'no-guess-field', statement: '原文没写的字段不得凭常识补,缺就记缺' }, { key: 'no-guess-a', statement: '吃不准的一律降 B,不得臆断为 A' }, + { key: 'no-unit-convert', statement: '不得自作主张换算用量口径(月用量×12 是业务判断,须问客户)' }, { key: 'no-external-write', statement: '不得在放行前写任何外部系统' }, ], authorization: { - discretion: [{ key: 'ask-back', statement: '缺次要字段可受理并追问,缺 PN/数量/交期必须退回补全' }], + discretion: [ + { key: 'ask-back', statement: '缺次要字段可受理并追问;缺 PN/EAU/交期必须退回补全' }, + { key: 'lifecycle-check', statement: '疑似 EOL 的料可随手拉一次生命周期核查' }, + ], + /* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨),写了也不会被执行 */ preauthorizedActions: [], humanCallBudget: 4, }, resources: { - tools: [{ name: 'crm.history-price-read' }], + tools: [{ name: 'crm.history-price-read' }, { name: 'quote-intake.classify' }, { name: 'mail.send' }], skills: INTAKE_SKILLS.map((s) => s.id), models: ['deepseek-v4-flash'], roles: ['BD'], - budget: { steps: 40, tokens: 120_000, activeMinutes: 60, humanCalls: 4 }, + /* + * SLA:受理<15min、判定<45min → activeMinutes 60 封顶;打扰上限 4。 + * + * token 预算按**实测**给:一份三条的 BOM 走完全程约 20 拍、18 万 token + * (每拍的提示词都带着案例说明书 + 轨迹,越往后越长)。原来给 20 万, + * 正好卡在收口前一两拍被熔断 —— 预算给得太紧,等于让 Harness 去杀正在干的活。 + * 条目多的单会更贵,这里留一倍余量。 + */ + budget: { steps: 60, tokens: 400_000, activeMinutes: 60, humanCalls: 4 }, }, }, - /* 声明 */ + /* ── 声明 ────────────────────────────────────────────────────── */ declarations: { models: ['deepseek-v4-flash'], - tools: [{ name: 'crm.history-price-read' }], + tools: [{ name: 'crm.history-price-read' }, { name: 'quote-intake.classify' }, { name: 'mail.send' }], ledger: [ + /* + * 客户主数据 —— **A1 只声明它要用的那几列**,全量结构由平台播种 + * (`seed-mvp-data.ts` 的四域)。声明合并是「最大化合并、只增不破坏」, + * 两边并存不冲突;`kind: 'master'` 必须与平台一致,否则治理台会把它归错类。 + * + * 为什么受理活动要写主数据:第一件事就是认人 —— 认得出取它的编码, + * 认不出请人建档。建出来的那一行是**给下一单用的**:下次同一个邮箱来信, + * 第一步就认得出,不必再问一遍。 + */ { - domain: INTAKE_DOMAIN, - object: 'record', - label: '统一受理记录', + domain: 'customer', + object: 'profile', + kind: 'master', + label: '客户档案', fields: [ - { name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' }, - { name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' }, - { name: 'brand', type: 'text', primaryKey: false, masked: false, label: '厂牌' }, - { name: 'eau', type: 'integer', primaryKey: false, masked: false, label: 'EAU' }, - { name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: 'Type 标' }, - { name: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' }, - { name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' }, - { name: 'status', type: 'text', primaryKey: false, masked: false, label: '状态' }, - { name: 'missing_key_fields', type: 'integer', primaryKey: false, masked: false, label: '缺关键字段数' }, + { name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' }, + { name: 'name', type: 'text', primaryKey: false, masked: false, label: '客户名称' }, ], }, { - domain: INTAKE_DOMAIN, + domain: 'customer', + object: 'contact', + kind: 'master', + label: '客户联系人', + fields: [ + { name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' }, + /* + * **自增,不问人**(2026-08-02 用户:「联系人ID自增啊,不用填」)—— + * 它不承载业务含义,只是「这家客户下的第几位」。人哪知道已经有几位; + * 让模型编更糟,编重了就是覆盖别人那一行。基座在写入那一刻按 + * `客户编码-NN` 生成(见 `FieldDeclaration.auto`)。 + */ + { name: 'contact_code', type: 'text', primaryKey: true, masked: false, auto: true, label: '联系人编码' }, + { name: 'name', type: 'text', primaryKey: false, masked: false, label: '姓名' }, + /* 邮箱是**认人的钥匙**,也是个人信息 —— 与平台声明一致标脱敏 */ + { name: 'email', type: 'text', primaryKey: false, masked: true, label: '邮箱' }, + ], + }, + { + domain: D, + object: 'record', + kind: 'record', + caseKey: 'rfq_ref', + label: '询价受理记录', + fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, + { name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' }, + { name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' }, + /* 客户自己的物料编码 —— 回价单必须按它对齐,否则客户那边对不上账 */ + { name: 'customer_pn', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户料号' }, + { name: 'category', type: 'text', primaryKey: false, masked: false, expect: true, label: '品类' }, + { name: 'brand', type: 'text', primaryKey: false, masked: false, expect: true, label: '制造商' }, + /* + * **年用量是必填** —— 标了 `required`,基座就数得出「缺必填的有几行」 + * (读数 `quote_intake_record_missing` / `_missing_keys`)。 + * + * 为什么非要基座来数(2026-08-01,连跑十轮里三轮死在这): + * 「必填齐」是 L1 机检,从前读的是活动自己写的 `missing_key_total` —— + * 人在卡上补了年用量、基座已经写回草稿层,而那个汇总列还要模型自己重算; + * 它算漏一次,这条判据就永远绿不了,连拒五拍熔断。 + * PN 不必标:它是主键,没有 PN 那一行压根不存在。 + */ + { name: 'eau', type: 'integer', primaryKey: false, masked: false, required: true, label: '年用量 EAU' }, + { name: 'target_price', type: 'number', primaryKey: false, masked: false, expect: true, label: '目标价' }, + { name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' }, + { name: 'packaging', type: 'text', primaryKey: false, masked: false, label: '包装' }, + { name: 'quality_req', type: 'text', primaryKey: false, masked: false, label: '品质要求' }, + /* + * 备注**不算选填缺口**(2026-08-02):`expect` 的意思是「客户该给而没给的」—— + * 备注是我方写上去的注,一行没有备注不是缺了什么。 + * 标着它,`_gaps` 几乎恒大于 0,那条 L4 判据于是永远绿不了 —— 门就废了。 + */ + { name: 'remark', type: 'text', primaryKey: false, masked: false, label: '备注' }, + /* + * **分类列:取值域给出来,条数由基座现数** —— `quote_intake_record_type_tag_a/b/c`。 + * + * 三分类条数从前是活动自己写的 `type_a_total` 之流,而分布条、放行时的去向谓词、 + * 验收判据读的全是它们。于是人在放行页把九条料全改成 A:类徽变了, + * 分布条还是 5/3/1、去向还是「去选型」—— 逐行变了,汇总数没人重算。 + * **手工修改的全部意义就是纠正 AI 的判断**:改了类标而流向不跟着变,那个兜底就是假的。 + */ + { name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '状况标', tally: ['A', 'B', 'C'] }, + { name: 'status', type: 'text', primaryKey: false, masked: false, label: '状态' }, + { name: 'missing_key_fields', type: 'integer', primaryKey: false, masked: false, label: '本条缺关键字段数' }, + ], + }, + { + domain: D, object: 'type_table', + kind: 'record', + caseKey: 'rfq_ref', label: '料号分类表', fields: [ - { name: 'pn', type: 'text', primaryKey: true, masked: false }, - { name: 'type_tag', type: 'text', primaryKey: false, masked: false }, - { name: 'basis', type: 'text', primaryKey: false, masked: false, label: '分级依据' }, - { name: 'history_hit', type: 'boolean', primaryKey: false, masked: false, label: '历史价命中' }, - // 打标的三个判据必须落表:S5 的 DMN 从这里读,不落就无米下锅 + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, + { name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' }, + // 打标的三项判据必须落表:P-03 从这里读,不落就无米下锅 { name: 'history_days', type: 'integer', primaryKey: false, masked: false, label: '距上次报价天数' }, { name: 'market_stable', type: 'boolean', primaryKey: false, masked: false, label: '行情稳定' }, { name: 'lifecycle', type: 'text', primaryKey: false, masked: false, label: '生命周期' }, + { name: 'history_hit', type: 'boolean', primaryKey: false, masked: false, label: '历史价命中' }, + { name: 'last_price', type: 'number', primaryKey: false, masked: false, label: '上次成交单价' }, + { name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '状况标' }, + { name: 'basis', type: 'text', primaryKey: false, masked: false, label: '分级依据' }, ], }, + { + /* + * 受理结论 —— **一单一行**。它承担三件别处放不下的事: + * ① 两枚去向旗(下游两道网关唯一读的东西); + * ② 三个汇总数(验收 L1/L2 机检谓词的入参); + * ③ 一句人话结论(验收单上给人看的)。 + */ + domain: D, + object: 'verdict', + kind: 'record', + caseKey: 'rfq_ref', + label: '受理结论', + fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, + { name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' }, + /* + * 客户名与新老 —— **报告上那两格,和 A4 的一条硬约束**。 + * + * 名字:认出来的老客户不必重写 `customer.profile`(那是主数据), + * 于是报告要显示「这一单是哪家公司」就无处可取 —— 落一列在结论上, + * 一份件自己说得出自己是谁,不必去连主数据表。 + * 新老:新客户走首单口径,**A4 不得取历史价锚点**。这是下游的判据,不是装饰。 + */ + { name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' }, + /* + * 联系人姓名 —— 报告「联系人」那张卡上摆的那个人,**本单对外发件的收件人**。 + * + * 它本来在 `customer.contact.name`(主数据)里,但报告取不到:老客户不重写主数据, + * 于是本单的草稿里根本没有那一行;而顶层同名字段又与 `customer.profile.name` + * 撞名(「后写的赢」),摆上去随时可能变成公司名 —— 一张写着公司名的联系人卡 + * 比空着更坏。跟 customer_name 同一笔写下,一份件自己说得出这单是跟谁在谈。 + */ + { name: 'contact_name', type: 'text', primaryKey: false, masked: false, label: '联系人姓名' }, + { name: 'customer_kind', type: 'text', primaryKey: false, masked: false, label: '客户属性(老客户/新客户)' }, + /* ① 定位客户那一步的收步判据 —— 认出老客户或人建了新客户,都记 1 */ + { name: 'customer_total', type: 'integer', primaryKey: false, masked: false, label: '客户已确认' }, + /* + * 「是不是新客户」与「问过人没有」—— 这两个数是**建档必须经过人**的机检形态。 + * 实测:剧本写了「别自己编客户编码」,模型照样从邮箱域名编出 CUST-SZ-MACH + * 直接建了档。说明书管不住,得让收步条件管:新客户没问过人就收不了步。 + */ + { name: 'customer_new_total', type: 'integer', primaryKey: false, masked: false, label: '新客户(需建档)' }, + { name: 'customer_asked_total', type: 'integer', primaryKey: false, masked: false, label: '已就建档问过人' }, + { name: 'line_total', type: 'integer', primaryKey: false, masked: false, label: '有效条目数' }, + /* + * 来件一共几条、本轮不报几条 —— 「有效 PN 9 / 10 行 BOM」那句话的出处。 + * 没有这两个数,「9 条」是个孤零零的数字:人看不出漏没漏, + * 而客户点名跳过的那一条到底有没有被看见,也就无从对账。 + */ + { name: 'bom_total', type: 'integer', primaryKey: false, masked: false, label: '来件条目数' }, + { name: 'skipped_total', type: 'integer', primaryKey: false, masked: false, label: '本轮不报条数' }, + { name: 'skipped_note', type: 'text', primaryKey: false, masked: false, label: '不报的是哪几条·为什么' }, + /* + * ② 「问过没有」—— 选填缺口靠**问过**来解,不靠补齐。 + * 用户定的口径:必填项是验收标准,选填项只提醒,但两种缺失都必须经过人。 + */ + { name: 'asked_total', type: 'integer', primaryKey: false, masked: false, label: '已就缺口问过人的次数' }, + /* + * **问过 ≠ 答过**。收步判据从前只看 `asked_total`:卡一发出去它就为真, + * 于是人填完提交、引擎在下一拍开头就把这一步收了 —— 模型根本没轮到把答案写回台账, + * 那一格白填(实测:人补的制造商落了空,而台账里那一列仍旧是空的)。 + * 收步要等的是**答案落了表**,所以另记一个数。 + */ + { name: 'answered_total', type: 'integer', primaryKey: false, masked: false, label: '人答完并已落表的次数' }, + /* + * **这一单经过人的手了没有** —— 阶段2 那张受理需求执行页签回来才记 1。 + * + * 它存在的理由是一次实测:从前「经过人」是靠 `missing_minor_total = 0 or + * answered_total > 0` 表达的 —— **只在缺东西的时候才叫人**。于是字段齐全的 + * 一封询价进来,这个条件在拆完那一刻就为真,引擎自己把归一那步收了, + * 人一眼没看过受理需求,下一次被叫到已经是终局验收 + * (用户 2026-08-01:「我都没确认需求 它就让我验收了」)。 + * 确认不是缺口的补丁,是一道**恒在**的门,所以它得有自己的一个数。 + */ + { name: 'confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '受理需求已经人审定' }, + { name: 'missing_key_total', type: 'integer', primaryKey: false, masked: false, label: '缺必填字段条数(PN/年用量)' }, + { name: 'missing_minor_total', type: 'integer', primaryKey: false, masked: false, label: '缺选填字段条数(品牌/备注)' }, + { name: 'untyped_total', type: 'integer', primaryKey: false, masked: false, label: '未打标条数' }, + /* + * 分类与派单的条数 —— 「去向旗与状况标对得上」这件事**必须机检**。 + * 它本是 L4 交人判的:实测里人(我)盲签了通过,而 C 类那条的替代评估请求 + * 根本没开 —— 旗立着、单没开,下游 A2 收到一个空活。 + * 算术不该靠人的注意力:把条数落成列,判据就能自己对账。 + */ + { name: 'type_a_total', type: 'integer', primaryKey: false, masked: false, label: 'A 类条数' }, + { name: 'type_b_total', type: 'integer', primaryKey: false, masked: false, label: 'B 类条数' }, + { name: 'type_c_total', type: 'integer', primaryKey: false, masked: false, label: 'C 类条数' }, + /* + * **去向定过了没有**(2026-08-02):从前这儿是 `alt_request_total` / + * `rfq_request_total` 两个派单计数 —— 而 A1 已经不开单了(见下面那段注释), + * 那两个数没了对象。收步仍要一个「这一步办过了」的凭据: + * 旗是布尔(合法值含 false),分不清「没写」与「写了 false」,所以另记一个计数。 + */ + { name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定去向' }, + { name: 'need_alternative', type: 'boolean', primaryKey: false, masked: false, label: '需要选型' }, + { name: 'need_sourcing', type: 'boolean', primaryKey: false, masked: false, label: '需要寻源' }, + { name: 'summary', type: 'text', primaryKey: false, masked: false, label: '受理结论' }, + ], + }, + /* + * ── 这里**去掉了 `alt_request` / `rfq_request` 两张表**(2026-08-02 用户当场定)── + * + * 原话:「替代评估请求 / 询价请求这两项数据不在这里产出;随令牌流向下一个节点的 + * 只有工作成果验收内的内容;进入选型替代后,活动会自己归一需要的数据。」 + * + * 从前 A1 逐条开这两张单交给 A2 / A3 —— 那是**替下游把它的输入先摆好**, + * 而下游本来就要照自己的口径归一一遍:同一份事实在两个活动里各存一份, + * 改一处就分叉,而分叉之后没人看得出以哪一份为准。 + * A1 交出去的是**受理记录**(带状况标与两枚去向旗),下游读它即可。 + * + * 连带退休的:两条派单对账判据(同日删)、`alt_request_total` / `rfq_request_total` + * 两个计数列、报告里那两张明细表、S6 的开单动作。 + */ ], listeners: [ { + /** 客户改需求:整单要回炉 —— 静态监听,时限必配(悬空监听安装时拒绝) */ origin: 'static', - eventType: 'human', - keyFilter: { kind: 'elicitation' }, - semantic: 'complete-step', - // 时限必配 —— 悬空监听安装时拒绝 + eventType: 'data', + keyFilter: { kind: 'rfq-amend' }, + /* + * 语义只有两种:`observe`(记一笔观察,交 Agent 裁量)与 `complete-step`(驱动步完成)。 + * 客户改需求要的是「整单回炉」,那是 Agent 看到这条观察后自己决定重入哪一段 —— + * 监听没有「重入」这一档,也不该有:重入是业务判断,不是事件语义。 + */ + semantic: 'observe', deadline: 'P1D', escalation: ['nudge', 'escalate'], }, ], harness: { memoryWindow: 20, - // SLA:受理<15min、打标<45min —— 停滞检测按此设 + // SLA:判定<45min —— 停滞检测按此设;受理<15min 那一档做催办 stallAfter: 'PT45M', nudge: [{ after: 'PT15M', action: 'nudge' }], retry: { times: 2, backoff: 'PT30S' }, }, }, - /* 资产初始版:flow = 步序声明 */ + /* ── 资产初始版 ──────────────────────────────────────────────── */ assets: { - /* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */ businessModel: { cmmnXml: CMMN_XML, narrative: NARRATIVE, - dmn: P03_DMN, + dmn: INTAKE_DMN, + /* 步骤剧本 —— 引擎按它组织每一拍的上下文,并自己收掉成果已在的步骤 */ + steps: INTAKE_STEPS, + /* 阶段 —— 只给人看的进度刻度:三段激活态 + 放行收口 = 卡上的 x/4 */ + phases: INTAKE_PHASES, }, skills: INTAKE_SKILLS, - // 三张模版对应三种局面(事态全景 / 进展 / 成果预览),由步序声明按位置推送 + // 四张模版对应四个局面;在办与归档同一张(§9.3 第 2 条) viewTemplates: [ { name: 'intake-overview', html: INTAKE_OVERVIEW }, { name: 'intake-classified', html: INTAKE_CLASSIFIED }, @@ -186,16 +734,111 @@ export const manifest: ActivityManifest = { distillHooks: [ { on: 'terminal', - prompt: '归纳本单受理归一的经验:字段缺口形态、打标依据、被驳回的理由。', + prompt: '归纳本单受理归一的经验:字段缺口形态、打标依据、去向判断被驳回的理由。', }, ], }, - /* 样例集 —— 首装审以静态审 + 样例集替代回放审 */ + /* ── 样例集 —— 首装审以静态审 + 样例集替代回放审 ───────────────── */ + /* + * 样例就是**真客户会发的那种信**(2026-07-31 用户定)。 + * + * 从前两个样例都写着「我司客户编码 C001」—— 那是我们内部的编码, + * 客户凭什么知道?真实来函里人只会说**自己是哪个公司的谁**。 + * 拿带编码的样例去试,等于把「定位客户」这一步的难处提前替它做掉了: + * 认不出客户、要请人建档的那条路根本走不到,而那恰恰是这一状态的主路。 + */ samples: [ { - name: '车规 Analog 询价', - input: { customerCode: 'C001', rfqRef: 'mail:m-1001', subject: '询价 RFQ - 车规 Analog' }, + /* + * **新客户** —— 这个邮箱**不在** `customer.contact` 里。 + * + * 它跟样例 2 必须是两家公司、两个邮箱。曾经两条样例用的是同一个邮箱 + * (`purchasing@sz-machinery.com`,正是随主数据播下的那位老客户), + * 于是「认不出 → 发数据录入卡请人建档」这条路**一次也没被走到过** —— + * 两条样例在第一个状态上是同一条路,而卡面上写着它们是两条。 + * 认客户的钥匙只有邮箱:换名字不算换客户,换邮箱才算。 + */ + name: '新客户询价(三条:在产/波动/EOL)—— 认不出客户,要请人建档', + input: { + customerEmail: 'sourcing@qd-marine.com', + subject: '【询价】RFQ-2026-0001 车规 Analog 三项', + body: + '你好,\n我是青岛海洋电子有限公司的李娜,采购部。\n' + + '我们是第一次和贵司询价,本次三项,请按标准交期报价:\n' + + 'LM358DR 12K/年 目标价 8.0 交期8周;NE555P 5K/年 交期6周;TL072CDR 3K/年 目标价 22 交期10周\n' + + '付款方式请一并给个建议,谢谢。\n李娜 | 青岛海洋电子有限公司', + attachments: [], + }, + expect: { ports: ['intake'] }, + }, + { + /* + * ── **老客户案例 · Itelma MD14.1** ──────────────────────────────── + * + * 这一条**不是编的**:正文、附件 BOM、缺口位置、三分类结果,逐项照 + * `~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html`(阶段2 执行目标) + * 与 `A1-询价受理记录.html`(阶段3 验收目标)两份目标件抄下来。 + * 那两份是「这个活动办完该长什么样」的答案,这一条是「照它跑一遍」的题面 —— + * 题面与答案不同源,测出来的东西就不作数。 + * + * 它一条压住 A1 全部要害: + * · 邮箱在 `customer.contact` 里(CU-0142)→ 第一状态认得出,**不发建档卡** + * (与样例 1 的新客户正好两条路); + * · 料在**附件**里不在正文里 —— 正文只给口径要求(CNY含税/EXW/交期按周/ + * AEC-Q200/预授权 Analog/跳过 XTSD),逼着走「读附件」那条路; + * · 附件 10 行,其中 XTSD 那行客户明说本轮不报 → **有效 PN 应是 9 条**; + * · 两处缺口:UMW AO6604 缺制造商(俄文 BOM 那一列为空)、DAG… 缺年用量 + * → 必填缺(年用量)要派执行卡,选填缺(制造商)只提醒; + * · 补齐后三分类判出 **5A / 3B / 1C**,有 C 类 → 去向是选型(to-alternative)。 + * + * 附件给的是**基座读 xlsx 之后的形态**(制表符分列的文本),不是二进制: + * 契约里 `attachments[].text` 就是「读出来的文本」,活动看见的正是这个。 + */ + name: '老客户询价 · Itelma MD14.1(认得出客户 · 料在附件 · 10 行报 9 条)', + input: { + customerEmail: 'e.volkova@itelma.ru', + subject: 'RFQ MD14.1 — 10 positions, BOM attached', + body: + 'Dear Colleagues,\n\n' + + 'We are preparing the sourcing package for our new project MD14.1 and would like to request ' + + 'your quotation for the 10 positions listed in the attached BOM.\n\n' + + 'Please note the following requirements:\n' + + '1. Prices in CNY including VAT, terms EXW.\n' + + '2. Lead time in weeks. Please state SPQ and MOQ separately.\n' + + '3. For position 4-02-01812 (TVM6C160K203R, varistor) AEC-Q200 certification is mandatory ' + + '— please attach the certificate together with your offer.\n' + + '4. Where the column "Analog" is filled in, the listed replacement is already pre-approved ' + + 'by our engineering department. Any other analog requires our written confirmation.\n' + + '5. Please indicate the validity period of your prices.\n\n' + + 'Position 4-00-04187 (XTSD series, SD controller) is not finalised on our side yet ' + + '— please skip it in this round.\n\n' + + 'We would appreciate receiving your offer by 17 July.\n\n' + + 'Best regards,\nEkaterina Volkova\nProcurement Department, Itelma', + attachments: [ + { + name: 'BOM_Itelma_MD14.1.xlsx', + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + size: 18_432, + ref: 'sample-bom-md141', + /* 基座读 xlsx 的产物:首行列名,其后一行一条,制表符分列;空格 = 客户没填 */ + text: [ + 'Itelma Code\tPN\tКатегория\tПроизводитель\tAnalog\tСертификация\tEAU (k)\tTarget price', + '4-02-01812\tTVM6C160K203R\tВаристор\tThinking Electronic\t\tAEC-Q200\t30\t', + '4-02-01733\tPESD2ETH-XR\tДиодная сборка\tNexperia\t\t\t30\t', + '4-14-01389\tBSCH00160808R12J00\tДроссель\tYAGEO\tCLH1608T-R12J-S\t\t60\t', + '4-14-01455\tBWVF005050201R0T00\tДроссель\tYAGEO\tLVF505020-1R0T-N\t\t90\t', + '4-00-04102\tCJTB0101R6N\tМикросхема\tJiangsu Changjing\t\t\t60\t', + '4-00-03987\tSGM2202-ADJYN6G/TR\tМикросхема\tSGMicro\t\t\t30\t', + '4-02-01698\tUMW AO6604\tТранзисторная сборка\t\t\t\t30\t', + '4-14-01502\tBBPY00201209601Y00\tФильтр\tYAGEO\tPBY201209T-601Y-N\t\t30\t', + '9-00-00251\tDAG2400L321504XZB01-R0\tАнтенна\t浙江嘉康\t\t\t\t', + '4-00-04187\tXTSD04GLGEAQG-I\tКонтроллер SD\t\t\t\t\t', + ].join('\n'), + note: 'xlsx 已读出 11 行(含表头),10 条料', + }, + ], + }, expect: { ports: ['intake'] }, }, ], diff --git a/activities/quote-intake/src/skills.ts b/activities/quote-intake/src/skills.ts index 5116652..2a67943 100644 --- a/activities/quote-intake/src/skills.ts +++ b/activities/quote-intake/src/skills.ts @@ -6,6 +6,8 @@ * + 结晶片段脚本(可选:反复出现的确定性子序列)。 * * **载方法,不载权力**:这里提到的工具都在本活动白名单内;技能不扩权、不改约。 + * 渐进披露:常驻上下文只有 `id label:applicability`,正文 Agent 说要才加载 —— + * 一次把所有正文塞进去,上下文会被方法论淹掉,真正的局面反而看不见。 */ import { skillIdSchema } from '@contracts'; import type { Skill } from '@contracts'; @@ -20,17 +22,21 @@ export const INTAKE_SKILLS: Skill[] = [ '目标价常带货币符号或「目标价/target」,交期常带「周/weeks/wks/交期/lead」。', '', '三条纪律:', - '① **原文没写的不要补**。宁可标 missingKeyFields,也不要按常识填一个「合理值」——', + '① **原文没写的不要补**。宁可把 missing_key_fields 记上,也不要按常识填一个「合理值」——', ' 受理阶段编出来的数,会一路流到定价和报价单上,越往后越难发现。', '② **单位归一但不换算口径**:12K → 12000 可以;「月用量 1000」不要自作主张乘 12,', ' 那是业务判断,该走补全卡问客户。', '③ **一条需求一行**。同一 PN 不同数量档是两行,不是一行的两个值。', '', - '拆不动就交人:缺 PN / 数量 / 交期任一,把 missingKeyFields 加一,让 D1 判断把补全卡发出去。', + '写台账时一拍写完:`ledger.write` 的 rows 收数组,一份 BOM 几十条也是一次写完 ——', + '一行一拍等于把同一件事拆成几十次模型调用,每次都要重读一遍上下文。', + '', + '写完顺手把 `quote_intake.verdict` 的 line_total 与 missing_key_total 填上:', + '那两个数是完整度判定表 F-01 的入参,也是验收的机检判据 —— 不写,下一步无从判起。', ].join('\n'), crystallized: [], - knowledgeRefs: ['《询价受理规范》'], - version: '1.0.0', + knowledgeRefs: ['《询价受理规范》F-01'], + version: '2.0.0', }, { id: skillIdSchema.parse('intake.classify-basis'), @@ -39,15 +45,48 @@ export const INTAKE_SKILLS: Skill[] = [ method: [ 'P-03 只认三个入参:距上次报价天数、行情是否稳定、生命周期状态。三者齐了,结论是**算出来**的,不是判出来的。', '', - '所以打标前唯一该做的事是**把判据取齐**:调历史价情报工具,拿回这三项。', - '取不到(新料号无历史)不是错误——工具会回「无历史」,那本身就是判据:', - '距上次报价记为极大值 → 命中 B 类「重估后报」,这是对的,新料号本就该重估。', + '所以打标前唯一该做的事是**把判据取齐**:调 `crm.history-price-read`,', + '**参数 pns 收数组** —— 这一单所有 PN 一次传进去。一条一条调,每调一次就多烧一拍模型,', + '而拿回来的东西一模一样。', '', - '不要绕过决策表自己判。规则表是可回放、可追责的;临场判断不是。', + '取不到不是错误:新料号无历史,工具回 historyDays=9999、hit=false。', + '**那本身就是判据** —— 距上次报价极大 → 命中 B 类「重估后报」,这是对的:新料号本就该重估。', + '', + '判据逐条写进 `quote_intake.type_table`(history_days / market_stable / lifecycle),', + '再调 `quote-intake.classify` 一次把这几行传进去拿回 A/B/C。', + '**不要绕过决策表自己判**:规则表可回放、可追责;临场判断不是。', ].join('\n'), - // 结晶片段:反复出现的确定性子序列 —— 取历史价这一步每单都一样 - crystallized: [{ tool: 'crm.history-price-read', input: { pn: 'pn' } }], + // 结晶片段:取历史价这一步每单都一样 —— 确定性子序列,不必每次让模型重想 + crystallized: [{ tool: 'crm.history-price-read', input: { pns: 'pns' } }], knowledgeRefs: ['三分类规则 P-03'], - version: '1.0.0', + version: '2.0.0', + }, + { + id: skillIdSchema.parse('intake.route-and-dispatch'), + label: '定去向:状况标不等于去向', + applicability: '打标完成、要决定这一单往哪走(要不要选型、要不要寻源)时。', + method: [ + '**状况标是判断的输入,不是去向本身**。同一个 B 标,可能只需寻源,也可能连带选型 ——', + '把标直接当去向用,是这一步最常见的错。', + '', + '两问分开答,可以同时成立:', + '· **要不要选型**:原厂料能正常供货且客户没点名替代 → 不需要;', + ' EOL/停产、长交期缺货,或客户点名要替代 → 需要,交 A2 替代选型。', + '· **要不要寻源**:无近期历史价或行情波动(B 类)→ 需要,交 A3 寻源询价。', + '· 两者可同时成立 —— 选型定案后仍要为替代料寻价。', + '', + '**不要在这一步开什么请求单**:随令牌流去下一个节点的只有**验收过的那份受理记录**,', + 'A2 / A3 拿到之后按自己的口径归一需要的数据。替它们先摆一份,', + '等于同一份事实在两个活动里各存一份 —— 改一处就分叉,而分叉之后没人看得出以哪份为准。', + '', + '答完把 `quote_intake.verdict` 的 need_alternative / need_sourcing 两枚旗写死。', + '**这两枚旗是下游两道网关唯一读的东西**:不写,整条流出了受理口就没路可走', + '(踩过:两枚旗没有台账列承载,G1 全流死路)。', + '', + '吃不准就走判断,不许臆断直通:直通省下的十分钟,错了要用三天返工换。', + ].join('\n'), + crystallized: [], + knowledgeRefs: ['三分类规则 P-03', 'docs/MVP示例工作流.html §1.1 S6/G1'], + version: '3.0.0', }, ]; diff --git a/activities/quote-intake/src/views/prd-style.ts b/activities/quote-intake/src/views/prd-style.ts new file mode 100644 index 0000000..9d2c0b5 --- /dev/null +++ b/activities/quote-intake/src/views/prd-style.ts @@ -0,0 +1,409 @@ +/** + * A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。 + * + * 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 ` +${PRD_SPRITE} +
+
+
+
${ico(input.badge, 17)}
+
+ ${input.kicker} +

${input.title}

+
+ ${input.state} +
+
${input.body}
+
${input.foot}
+
+
`; + +/** 章节头 —— 原型的 `jc-rp-part`:小标签 + 细线 + 右端小签 */ +const part = (label: string, aside = '', tone = ''): string => + `
${label}${aside === '' ? '' : `${aside}`}
`; + +/** 落款 —— 署名取任务负责人(基座填);右端那枚签写「这份件在流里的位置」,不写状态 */ +const sign = (note: string, tag = ''): string => + `
+ ${ico('user', 15)} +
${note}
+
${tag === '' ? '' : `${ico('git-branch', 13)}${tag}`}`; + +/* ==================== 共用件 ==================== */ + +/** + * 来源信件 —— 原型的 `jc-letter`。正文 `pre-wrap` 原样照录:摘要过的原文就不是原文了。 + * 收件签旁那枚归属签(通道入站 → 归了谁)与右端入站时刻都由基座填 —— + * 让活动自报「归属解析 → 冯兵」,等于让被指派的人自己说自己是谁。 + */ +const LETTER = `
+
+ ${ico('mail-open', 12)}收件 + · 归属解析 → + +
+
+

发件人

+
+

+

+
+ ${ico('sheet', 13)} +
+
`; + +/** + * 受理要素四卡 —— 原型的 `jc-four`。 + * `kind` 决定第四张卡下面那条刻度条按什么上色:过程屏按缺口(红格 = 必填未补), + * 归档屏按状况标(A 绿 / B 蓝 / C 橙)。**一格一条料**,每格都对得上清单里的一行。 + */ +const four = (kind: 'gap' | 'type'): string => `
+
+ ${ico('building')}客户公司 +
+ +
+
+ 已命中客户资料库 +
+ +
+ ${ico('user')}联系人 +
+ ${ico('user', 17)} +
+ +
+
+
+ 采购口 · 本单对外发件收件人 +
+ +
+ ${ico('history')}客户属性 + ${ico('shield-check', 13)} + + 成交台账尚未接入 —— 历史价由下游定价环节现查; + 新客户走首单口径,不得取锚点 + +
+ +
+ ${ico('boxes')}有效 PN +
/ 行 BOM
+
+ +
+ + ${ + kind === 'type' + ? '一格一条料 · 按状况标着色 · 条已交下游' + : '一格一条料 · 红格是必填未补 条' + } + +
+
`; + +/** + * 有效 PN 清单 —— 原型那六列,一列不多一列不少。 + * + * 「客户料号」原型明写不收(『Itelma Code 不是本记录的字段』),这里也不摆: + * 报告是给报价这条线看的,客户自己的编码在 A1 出口的载荷里,不占版面。 + * `req`/`opt` 只决定空格子出哪种胶囊;归档屏一律 `dimempty`(那时空就是「客户没给」, + * 再摆「你还能填」的红胶囊就是在催一件已经办完的事)。 + */ +const listTable = (mode: 'draft' | 'final'): string => { + /* + * `data-col` 一挂,这一格就多渲一个输入孪生件(基座 `twinInputs`), + * 待处理那一屏可就地改;写的是**台账列名**,不是表头文字 —— + * 表头改个说法不该让写回错位。归档件不挂:那时它是件,不是表单。 + */ + const cell = (key: string, head: string, kind: 'req' | 'opt', style = ''): string => { + const cls = mode === 'final' ? 'dimempty' : kind; + return ``; + }; + const req = mode === 'final' ? '' : 'req'; + /* + * 序号那一格在编辑态换成「撤掉这一行」的 × —— 一列两用,不为它单开一列。 + * 只读时它是行号(CSS 计数器画的),编辑时人要的是「这一行不该在」的那个动作。 + */ + const del = ``; + return `
+ + + ${mode === 'final' ? '' : ''} + + + + + + + + + ${del} + ${mode === 'final' ? '' : ''} + ${cell('pn', 'PN', 'req', 'mono')} + ${cell('category', '品类', 'opt')} + ${cell('eau', '年用量', 'req', 'mono')} + ${cell('brand', '制造商', 'opt')} + ${cell('target_price', '目标价', 'opt', 'num')} + ${cell('remark', '备注', 'opt')} + +
类PN品类年用量制造商目标价备注
`; +}; + +/** 编辑态那条提示带 —— 原型的 `jc-editbar`,只在待处理那一屏现身 */ +/** + * 一张可改的表的**闸** —— 默认锁着,按「编辑」才开(2026-08-01 用户定)。 + * + * 从前进这一屏整份件就是编辑态:54 个输入框一起亮着,人还没决定要改什么, + * 屏上已经是一张待填的表 —— **看**与**改**是两件事,件首先是给人看的。 + * + * 三个按钮由外层挂事件(容器 `allow-same-origin`,读得到也改得动 iframe 的 DOM); + * **iframe 里仍然一行脚本都没有**。`data-edit-*` 是外层认它们的钩子: + * · `open` → 给根节点加 `editing`,输入孪生件现身; + * · `cancel` → 把每个输入还原成进编辑态那一刻的值,再关掉; + * · `save` → 收值交给外层缓冲(提交那一下才真落库),再关掉。 + * 一屏可能有两张表(需求表 / 分类表),所以闸带 `data-edit-for="域.对象"` —— 各管各的。 + */ +const editGate = (address: string, what: string): string => `
+ ${ico('lock', 12)}${what}已锁定 —— 看的时候不误碰,要改按右边 + + + +
+
${ico('pen', 14)} + 改哪一格点哪一格:整份清单每一格都能覆盖 —— AI 抽错的直接改,缺的补上; + 多出来的行按右边的 × 撤掉,漏了的用底下的「加一行」补。 + 红框是必填未补。改动只落任务草稿层,保存不等于放行。 +
`; + +/** + * 表尾那条「加一行」—— **只在编辑态现身**(样式表按根节点的 `editing` 决定)。 + * + * 为什么人得能加行:手改本来就是**兜 AI 的错**的那一手,而错不止「这一格填错了」—— + * 「BOM 里有而它没抽出来」是同一类错,只给改格子就没有出口。 + */ +const addRowBar = (address: string): string => `
+ + 照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。 +
`; + +/* + * ── 这里**去掉了「本轮不报」那条横幅**(2026-08-02 用户当场指出:「也删了」)──── + * + * 原文:「本轮不报 N 行:… —— 客户补齐规格后可另起一单。 + * 有效条目数 = 来件条目 − 本轮不报,两个数对不上就是拆漏了」。 + * + * 绝大多数单子它是「0 行:—」——**一条永远摆在那儿、九成时候说「没有」的横幅**, + * 读的人每次都得扫过它才知道没事。而后半句是讲给拆料那一步听的对账口诀, + * 不是给验收的人看的:拆漏没拆漏由收步判据 `record_rows = line_total` 当场拦着, + * 拦不住的时候摆一句口诀也没用。 + * + * 题头那一行仍如实写着「N 条 · 已剔除 M」——「剔除了几条」这个事实没有丢。 + */ + +/** + * 料号分类:分布条 + 图例 —— 原型的 `jc-dist`。 + * + * 三段按条数分宽:段宽在原型里是 JS 写的 `style="flex:N"`,模版不许算, + * 于是把**条数本身**投影成 class(`data-class-of` → `v-5`),让样式表按梯子取宽。 + * 段里印的数字与图例读的是同三个汇总列 —— 一处数据两处呈现,对不上就是台账自己错了。 + */ +const DIST = `
+
+ + + +
+
+ A 类 · 行情完备 + B 类 · 需寻源询价 + C 类 · 需替代选型 +
+
`; + +/* ==================== 局面一 · 受理中(阶段2 待处理页嵌的那份)==================== */ + +export const INTAKE_OVERVIEW = doc({ + badge: 'file-text', + kicker: '统一报价 · A1 受理归一 · 过程成果', + title: '询价受理需求', + titleRef: 'rfq_ref', + state: + '缺必填 · 缺选填 ', body: - kvCard('询价来源', [ - ['客户编码', 'customer_code', 'mono'], - ['询价单号', 'rfqRef', 'mono'], - ['主题', 'subject'], - ]) + - kvCard('归一结果', [ - ['型号 PN', 'pn', 'mono'], - ['厂牌', 'brand'], - ['年用量 EAU', 'eau'], - ['目标价', 'target_price'], - ['交期', 'lead_time'], - ['缺关键字段', 'missing_key_fields'], - ]) + - noteCard('这一屏在说什么', '归一由模型从询价原文抽取,逐条落任务级草稿层:本任务可见、正式版不可见、不产生数据事件。放行前的一切都还改得动。'), + part('来源 · 客户 RFQ 邮件', ' 入站', 'jc-mono') + + LETTER + + `
${ico('info', 14)} + 下方受理要素与 PN 清单,均由 AI 从这封来函与附件 BOM 抽取归一而来 —— 正文里的口径要求 + (币种 / 贸易条款 / 交期单位 / 认证 / 预授权替代 / 本轮跳哪条)已逐条落到对应字段与备注; + 附件那一行写的是基座读取的结果,读得出文本才作数。 +
` + + part('受理要素', 'F-01 完整度自检') + + four('gap') + + part('有效 PN 清单', ' 条 · 已剔除 ') + + editGate('quote_intake.record', '有效 PN 清单') + + listTable('draft') + + addRowBar('quote_intake.record') + + `
${ico('info', 13)} + 带 * 的两列是必填(PN / 年用量):缺任一按 F-01 退回补全,不许放行; + 其余列缺了标灰「选填」—— 先受理、后追问,不为它们拦单,但那一问不能省。 + 原文没写的一律不补:受理阶段编出来的数会一路流到定价和报价单上,越往后越难发现。 +
`, + foot: sign('AI 拟稿,你审定 —— 要改哪一格,在「待处理」那张单的需求表上就地改', '统一报价 · A1 受理归一'), }); -export const INTAKE_CLASSIFIED = viewShell({ - title: '料号分类与判据', - kicker: 'A1 · 三分类 P-03', - tone: 'ok', +/* ==================== 局面三 · 已分类 ==================== */ + +export const INTAKE_CLASSIFIED = doc({ + badge: 'shield-check', + kicker: '统一报价 · A1 受理归一 · 三分类 P-03', + title: '料号分类表', + titleRef: 'rfq_ref', + state: '已分级 · 未打标 ', body: - kvCard('分类结论', [ - ['型号 PN', 'pn', 'mono'], - ['Type 标', 'type_tag', 'tag'], - ['分级依据', 'basis'], - ]) + - kvCard('打标判据(DMN 入参)', [ - ['距上次报价', 'history_days'], - ['行情稳定', 'market_stable'], - ['生命周期', 'lifecycle'], - ['历史价命中', 'history_hit'], - ]) + - noteCard( - '为什么是确定性的', - '三分类规则 P-03 是决策表不是模型判断:同样的判据必得同样的结论,零方差零 token,命中的规则行随出参留痕。' + - 'A = 直接快答;B = 重估后报(转寻源);C = 转案处理(转替代选型)。', - ), + part('料号分类', '三分类规则 P-03 · 逐条判定') + + DIST + + part('逐条分级(判据 → 结论)', '按序求值 · 首个命中即出') + + editGate('quote_intake.type_table', '料号分类表') + + `
+ + + + + + + + + + + + + + + + + + +
型号 PN距上次报价(天)行情稳定生命周期类分级依据
` + + `
${ico('info', 13)} + 三分类规则 P-03 是决策表,不是临场判断:同样的输入必得同样的结论,凭哪一条命中都留了痕。 + A = 历史价 ≤ 90 天且行情稳;B = 无历史 / 过期 / 波动;C = EOL / 缺货 / 客户要求替代。 + 规则从上往下第一条命中即出 —— C 那一行排在最前,EOL 的料哪怕历史价很新也不判 A。 + 拿不准的一律降 B:错标一个 A,就是让一条本该重新核价的料带着旧价走到报价单上。 +
`, + foot: sign('状况标由判定表 P-03 求值 —— 判据与结论逐条可回核', '统一报价 · A1 受理归一'), }); -export const INTAKE_RELEASE = viewShell({ - title: '统一受理记录 · 待放行', - kicker: 'A1 · 成果预览', - tone: 'case', +/* ==================== 局面四 · 待放行(阶段3 工作成果里的那份)==================== */ + +/** + * G2 分支网关 —— 三条互斥条件按序求值,只命中一条、只落一个活动。 + * + * **命中哪一条不是这屏自己算的**:契约给每个出口写了判据,基座在放行那一刻按序求值, + * 把结果注入 `case:route*`(与发口用的是同一次判定)。这里只把它摆出来: + * 壳上带 `v-r1/r2/r3`,对应那一行自己上色。 + * 摆成三张并列的去向卡就丢了「按序求值、只落一个」那层意思,所以照原型做成判定表。 + */ +const dmnRow = (n: 1 | 2 | 3, cond: string, node: string, to: string, hit: string): string => + `
+ ${n} + ${cond} + ${node}${to} + ${ico('check', 13)}命中 · ${hit}未命中 · 排他网关只落一口 +
`; + +const G2 = `
+
序条件(按序求值)去向判定
+ ${dmnRow(1, '存在 C 类(需替代选型)', 'A2', '替代选型', 'C 类 条')} + ${dmnRow(2, '无 C 类、存在 B 类(需寻源询价)', 'A3', '渠道寻源', 'B 类 条')} + ${dmnRow(3, '全部为 A 类(行情完备)', 'A4', '成本定价', 'A 类 条')} +
+
+
+ 本次判定 · 按序求值,首个命中即出 +
+

去向是分类表算出来的:三条规则按序求值、首个命中即出, + 在你签字那一刻现算。所以它不是谁点出来的一次手感 —— + 要改去向,就在上面的清单里改料号的类,这一格会跟着变。

+
+ ${ico('arrow-right', 26)} +
+ 转入活动 +
+ ${ico('user', 11)}下游负责人按运营分派落定 +

放行即落地:草稿层合并进正式版、intake 口的数据事件此刻才发射, + 这一口的载荷随之交给下游。门前的都还改得动,门后的都算数了。

+
+
`; + +export const INTAKE_RELEASE = doc({ + badge: 'file-check', + kicker: '统一报价 · A1 受理归一 · 交付成果', + title: '询价受理记录', + titleRef: 'rfq_ref', + /* + * 题头这枚灯**在办与归档同一句话才算数**:这份件放行前嵌在放行卡上、放行后原样进 + * 「工作成果」。写「待放行」进了档案就成了谎,写「受理完成」在放行前就是替人签了字 —— + * 所以只写这份件本身是什么:归一了几条、三分类已定。 + */ + state: '受理记录 · 条 · 三分类已定', body: - tableCard( - '本单交付', - 'mine:quote_intake.record', - [ - ['型号', 'pn', 'mono'], - ['厂牌', 'brand'], - ['年用量', 'eau'], - ['目标价', 'target_price'], - ['交期', 'lead_time'], - ], - '只列本单写下的行 —— 别单放行过的记录不混进来。', - ) + - kvCard('分流去向', [ - ['Type 标', 'type_tag', 'tag'], - ['依据', 'basis'], - ]) + - noteCard( - '放行意味着什么', - '放行通过即刻落地:草稿层合并入正式版、数据事件此刻才发射,G2 网关按 Type 分流。' + - '放行是草稿态与事实态之间的那道门 —— 门前的都还改得动,门后的都算数了。', - ), + /* + * **来件原文留在归档件里**(2026-08-02 用户指出:「成果里为啥没有邮箱原文了」)。 + * + * 待处理与工作成果本来就是**同一份件**,差别只在编辑态开不开。 + * 归档件少一块来件,就等于把「凭什么这么受理」的出处从档案里抽走了 —— + * 三个月后回看这一单,九条料是怎么来的、客户那句「本轮跳过」在哪儿说的, + * 全都无从对起。件要自己说得清自己。 + */ + part('来源 · 客户 RFQ 邮件', ' 入站', 'jc-mono') + + LETTER + + part('受理要素', 'F-01 完整度自检 · 通过', 'ok') + + four('type') + + part('料号分类', '三分类规则 P-03 · 逐条判定') + + DIST + + part('有效 PN 清单', ' 条 · 已剔除 ') + + editGate('quote_intake.record', '有效 PN 清单') + + listTable('final') + + addRowBar('quote_intake.record') + + part('结论与去向', '分支判定 · 起单 ×1', 'ok') + + G2 + + /* + * ── 这里**去掉了两张请求单表与那条对账说明**(2026-08-02 用户当场指出)────── + * + * 原来摆的是「开出的替代评估请求(交 A2)」「开出的询价请求(交 A3)」两张明细表, + * 外加一句「这两条等式在你签字前由系统当场对账,对不上直接拒收」。 + * + * 两个理由: + * · 那句话**已经不成立** —— `route-alt` / `route-rfq` 两条对账判据同日删掉了 + * (它们是内部一致性,该由引擎自己看住,不占放行卡)。件上还写着「系统当场对账」, + * 就是在说一件不再发生的事; + * · 这份件是**受理记录**:它答的是「这一单受理成了什么样」。派出去的两类单是 + * **下游的输入**,不是这一单的成果 —— 要看它们,去 A2 / A3 那两条流水线上看。 + * 摆在这儿只是把同一份数据在两处各画一遍。 + * + * 「这一签下去开出了几张单」仍看得见:放行之后验收卡那一屏(落地结果)按表点名, + * 询价请求 / 替代评估请求各几行都在上面 —— 那才是它该出现的时刻与位置。 + */ + part('放行后即执行的落地动作', '门前 → 门后') + + `
+
${ico('check', 11)} +
草稿层合并进正式版 本任务写下的受理记录、分类表、结论与两类请求单一并落账; + 人工补全与就地修订的那几处随之落定。
+
+
${ico('check', 11)} +
受理结果此刻才对外发出 连同 + 条有效料号与它们的分类一并交出 —— 放行之前,世界上什么都没有发生。
+
+
${ico('check', 11)} +
按分类算出的去向落下游一单 本次转入 + ,已进对方的任务清单;另两条去向本次不起单。
+
+
`, + /* + * ── 这里**去掉了「受理结论」那张表**(2026-08-02 用户当场指出:「这个也多余」)── + * + * 摆的是 `bom_total / skipped_total / skipped_note / summary` 四格,而 + * 前三格与同日删掉的那条「本轮不报」横幅说的是同一件事, + * 第四格是一句 AI 复述的总结 —— 底下的清单、分布条、去向横幅把该说的都说了, + * 再加一句转述只是让人多读一行。 + * + * 一份件里同一个数出现两次,人得停下来对一遍两处一不一样 —— 那是白花的注意力。 + */ + foot: sign('AI 拟稿 · 待审定放行 —— 要改哪一格,在这张放行单的成果表上就地改', '统一报价 · A1 受理归一 → 交下游'), }); diff --git a/activities/quote-negotiate/src/case-model.ts b/activities/quote-negotiate/src/case-model.ts index d374d81..a3d52f3 100644 --- a/activities/quote-negotiate/src/case-model.ts +++ b/activities/quote-negotiate/src/case-model.ts @@ -1,77 +1,115 @@ /** - * A6 报价议价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。 + * A6 报价议价的案例模型 —— **发出去是触达世界的一下,等回音是立监听** + * (2026-07-31 照 A1 的状态机模式重做)。 * - * 2026-07-29 由步序声明(flow)改写而来。发出报价是**触达外界**的一下, - * 恒需人工激活(AM 4.1 原理一);客户回音是外部事件,靠 userEventListener 等, - * 不靠轮询。三个去向(成交 / 有异议 / 拒绝)由验收放行时的选择组给出。 + * 这个活动的要害不是步数,是**两处不可省的边界**: + * · 发正式报价单 = 触达外界 → 必须人工激活(铁律③ / AM 4.1); + * · 客户什么时候回是**外界的事** → 立监听等它,不在这一单里轮询、也不假装等到了。 + * + * 三项: + * T_compose —— 把草案落成正式报价单(条款齐全),写进台账; + * T_send —— 发出并等回音:人工激活 → 登记发信意向 + 立监听; + * T_release —— 放行(人工激活),三个去向由输出选择组给出。 */ export const CMMN_XML = ` - - - + + - - create - - - update - missingQuoteFields = 0 - - - update - sent = true + + update + quote_total > 0 - - - - - - - - - - - - - - - - - - - - - - - - + + + - - P3D - - - P7D - - + + + + + + + + + + `; -export const NARRATIVE = `§0 开案:拿到报价草案即开案。成品是**一份发出去的正式报价单**,外加一个明确的结果。 +export const NARRATIVE = `§0 开案:拿到报价草案即开案。成品是**一份发出去的正式报价单**,外加一个明确的结果 +(成交 / 有异议 / 拒绝)—— 那三个去向由放行时的选择组定。 -S1 成单发出:把草案写成正式报价单(中英双语 PDF)—— 单价、交期、SPQ、MOQ、贸易条款、 -有效期六样缺一不可,有效期按 P-04 注明。**发出这一下必须人工按**:它触达客户, -系统不替人做这个决定。写类动作先登记意向,验收放行的那一刻才真发。 +这一单有两处不能含糊: +· **发出去是触达世界的一下** —— 那一项标了人工激活,人不按你发不出去; +· **客户什么时候回是外界的事** —— 立监听等它,别在这一单里轮询,更别假装等到了。 -S2 跟进议价:没有跟进的报价等于没报 —— D+3、D+7 两记提醒自动到点,人去问。 -客户提异议就议价调整(裁量项:改价、改交期、换料都可能),改完是新一版报价单,重新走发出。 +【T_compose 落正式报价单】把草案落成能发出去的件:单价 / 交期 / SPQ / MOQ / 贸易条款 / +有效期,逐条写进 \`quote_negotiate.formal_quote\`,并把 quote_total(条数)写上。 +条款缺一样都不算一份能发的报价单 —— 缺了就问,别自己填一个"看起来合理"的。写完 finish。 -§Z 结案:验收时**恰选一口**给出去向 —— 成交 / 有异议 / 拒绝或超时。 -选哪一口就是这一单的结论,下游网关读的就是它;不选口不许放行,含糊的结果比没结果更坏。`; +【T_send 发出并等回音】**先 start T_send**(人工激活:发出去触达客户)。 +然后用 \`mail.send\` 登记发信意向(写类工具:过程中只登记,放行那一刻才真发), +接着 listen 一个 data 事件等客户回音,**时限必配**(比如 P7D)—— +不配时限就是个幽灵等待,这一单会永远挂着。 +回音到了再看:成交 / 有异议 / 拒绝,分别对应放行时的三个出口。 + +【提交放行】**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据 +(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。 + +【一路上要说话】每一拍带一句 say,说**业务的话**:报了什么价、什么时候发的、客户回了什么。 +一两句、口语。别报「写草稿 N 行」这种系统词。`; + +/** + * 步骤剧本 —— 与 A1 同一套。 + * `T_send` 与 `T_release` 都不给 `doneWhen`:一个触达客户、一个触达世界,都得人按一下。 + */ +export const NEGOTIATE_STEPS = [ + { + item: 'T_compose', + intent: '把草案落成条款齐全的正式报价单,并结算条数', + writes: ['quote_negotiate.formal_quote', 'quote_negotiate.verdict'], + reads: [], + tools: [], + doneWhen: 'quote_total > 0', + guide: `逐条写进 \`quote_negotiate.formal_quote\`:单价 / 交期 / SPQ / MOQ / 贸易条款 / 有效期, +并把 quote_total 写上。条款缺一样都不算一份能发的报价单 —— +缺了就问,**别自己填一个「看起来合理」的值**。 +**同一拍再写一行 \`quote_negotiate.verdict\`**(验收机检读的就是它): +· quote_total = 报价行条数;missing_field_total = 六项条款还缺的条数(要 = 0); +· validity_days = 报价有效期天数(要 > 0); +· followup_total = 已建跟进条数(**要 ≥2** —— D+3 与 D+7 两条,没有跟进的报价等于没报); +· summary 一句人话结论。写不对 submit 会被门挡回来。写完再 finish。 +**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 +台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉 +(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, + }, + { + item: 'T_send', + intent: '发出报价并立监听等客户回音', + writes: [], + reads: [], + tools: ['mail.send'], + // 触达客户 —— 人工激活,不给 doneWhen + guide: `**先 start T_send**(人工激活:发出去触达客户)。 +用 \`mail.send\` 登记发信意向(写类工具:只登记,放行那一刻才真发), +再 listen 一个 data 事件等回音,**时限必配**(如 P7D)—— 不配就是幽灵等待,这一单会永远挂着。`, + }, + { + item: 'T_release', + intent: '议价有结果,提交放行交 BD 验收', + writes: [], + reads: [], + tools: [], + guide: `**先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据,然后 submit。 +三个去向(成交 / 有异议 / 拒绝)由放行时的选择组定。 +**台账里要写 outcome**(won / revise / lost)—— 下游三条边读的就是它,不写整条流走不下去。`, + }, +]; diff --git a/activities/quote-negotiate/src/index.ts b/activities/quote-negotiate/src/index.ts index 068520a..906b78f 100644 --- a/activities/quote-negotiate/src/index.ts +++ b/activities/quote-negotiate/src/index.ts @@ -11,7 +11,6 @@ const QUOTE = 'quote_negotiate.formal_quote'; export default defineActivity({ name: 'quote-negotiate', - paradigm: 'flow', async run(base: ActivityBase): Promise { const lines = await base.ledger.read(QUOTE); diff --git a/activities/quote-negotiate/src/manifest.ts b/activities/quote-negotiate/src/manifest.ts index 5bcd321..cce20b6 100644 --- a/activities/quote-negotiate/src/manifest.ts +++ b/activities/quote-negotiate/src/manifest.ts @@ -7,7 +7,7 @@ */ import type { ActivityManifest } from '@contracts'; import { NEGOTIATE_SKILLS } from './skills.ts'; -import { CMMN_XML, NARRATIVE } from './case-model.ts'; +import { CMMN_XML, NARRATIVE, NEGOTIATE_STEPS } from './case-model.ts'; import { activityIdSchema } from '@contracts'; import { QUOTE_COMPOSED, QUOTE_RELEASE } from './views/report.ts'; @@ -18,7 +18,7 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'A6 · 报价议价', - packageVersion: '0.1.5', + packageVersion: '2.1.0', category: '统一报价', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-quote-negotiate', @@ -30,8 +30,8 @@ export const manifest: ActivityManifest = { input: { data: { type: 'object', - required: ['customerCode', 'items'], - properties: { customerCode: { type: 'string' }, items: { type: 'array' } }, + required: ['rfqRef', 'customerCode', 'items'], + properties: { rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, customerCode: { type: 'string' }, items: { type: 'array' } }, }, messageAllowed: true, }, @@ -42,23 +42,67 @@ export const manifest: ActivityManifest = { { group: 'negotiation', ports: [ - { name: 'won', payload: { type: 'object' }, description: '成交 → 转订单履约(ORM)' }, - { name: 'revise', payload: { type: 'object' }, description: '有异议 → 返回调整 A4' }, - { name: 'lost', payload: { type: 'object' }, description: '拒绝/超时 → 转 A7 情报归档' }, + /* + * 载荷里**必须带 `outcome`** —— 流程包 E10/E11/E12 三条出边读的就是它 + * (`outcome = "won"/"revise"/"lost"`)。从前三个口的载荷都是空对象 `{}`, + * 于是三条边一条也不成立:整条流走到 A6 就没路可走,而口是"发射"过的。 + * 这与 A1 当年 G1 全流死路是同一种错 —— **边读什么,口就得发什么**。 + */ + { + name: 'won', + payload: { + type: 'object', + required: ['outcome'], + properties: { outcome: { type: 'string', title: '议价结果(固定 won)' } }, + }, + description: '成交 → 转订单履约(ORM)', + }, + { + name: 'revise', + payload: { + type: 'object', + required: ['outcome'], + properties: { outcome: { type: 'string', title: '议价结果(固定 revise)' } }, + }, + description: '有异议 → 返回调整 A4', + }, + { + name: 'lost', + payload: { + type: 'object', + required: ['outcome'], + properties: { outcome: { type: 'string', title: '议价结果(固定 lost)' } }, + }, + description: '拒绝/超时 → 转 A7 情报归档', + }, ], }, ], }, + /* + * 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 + * 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, + * 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒, + * 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。 + */ requiredDeliverables: [ - { key: 'formal-quote', label: '正式报价单(PDF · 中英双语)' }, - { key: 'followup', label: 'D+3/D+7 跟进提醒' }, + { key: 'formal-quote', label: '正式报价单(PDF · 中英双语)', ledger: `${DOMAIN}.formal_quote` }, + // 跟进不单独立表 —— 建了几条记在议价结论的 followup_total 上 + { key: 'followup', label: 'D+3/D+7 跟进提醒', ledger: `${DOMAIN}.verdict` }, ], + /* + * 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 + * 上一版的名字(missingQuoteFields / validityDays / followupSet)**台账里根本没有** —— S-FEEL 拿未绑定的名字 + * 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。 + * 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 + */ acceptanceCriteria: [ - { key: 'quote-complete', label: '单价/交期/SPQ/MOQ/贸易条款/有效期齐备', level: 'L1', predicate: 'missingQuoteFields = 0' }, - { key: 'validity', label: '按报价有效期 P-04 注明有效期', level: 'L2', predicate: 'validityDays > 0' }, - { key: 'followup-set', label: '已建 D+3/D+7 跟进 —— 没有跟进的报价等于没报', level: 'L2', predicate: 'followupSet = true' }, + { key: 'quote-complete', label: '单价/交期/SPQ/MOQ/贸易条款/有效期齐备', level: 'L1', predicate: 'missing_field_total = 0' }, + { key: 'validity', label: '按报价有效期 P-04 注明有效期', level: 'L2', predicate: 'validity_days > 0' }, + { key: 'followup-set', label: '已建 D+3/D+7 跟进 —— 没有跟进的报价等于没报', level: 'L2', predicate: 'followup_total >= 2' }, { key: 'bd-release', label: 'BD 放行', level: 'L5' }, ], + revisableAtRelease: [], prohibitions: [ { key: 'no-break-line', statement: '客户议价超出授权范围的,回 A5 重批,不私自破线' }, { key: 'no-send-before-release', statement: '报价单在放行前不得发出 —— 写类动作集中在放行时落地' }, @@ -85,9 +129,12 @@ export const manifest: ActivityManifest = { { domain: DOMAIN, object: 'formal_quote', + kind: 'record', + caseKey: 'rfq_ref', label: '正式报价单', fields: [ - { name: 'quote_no', type: 'text', primaryKey: true, masked: false, label: '报价单号' }, + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, + { name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' }, { name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' }, { name: 'unit_price', type: 'number', primaryKey: false, masked: false, label: '单价CNY' }, { name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' }, @@ -98,8 +145,9 @@ export const manifest: ActivityManifest = { ], }, { - domain: 'shared', - object: 'history_price', + domain: 'market', + object: 'price_history', + kind: 'master', label: '历史价格情报(共享域)', fields: [ { name: 'pn', type: 'text', primaryKey: true, masked: false }, @@ -108,6 +156,28 @@ export const manifest: ActivityManifest = { { name: 'lead_time', type: 'text', primaryKey: false, masked: false }, ], }, + { + /* + * 议价结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。 + * 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」, + * 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。 + */ + domain: DOMAIN, + object: 'verdict', + kind: 'record', + caseKey: 'rfq_ref', + label: '议价结论', + fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, + { name: 'quote_no', type: 'text', primaryKey: false, masked: false, label: '报价单号' }, + { name: 'quote_total', type: 'integer', primaryKey: false, masked: false, label: '报价行条数' }, + { name: 'missing_field_total', type: 'integer', primaryKey: false, masked: false, label: '条款缺失条数' }, + { name: 'validity_days', type: 'integer', primaryKey: false, masked: false, label: '报价有效期天数' }, + { name: 'followup_total', type: 'integer', primaryKey: false, masked: false, label: '已建跟进条数' }, + { name: 'outcome', type: 'text', primaryKey: false, masked: false, label: '议价结果(won/revise/lost)' }, + { name: 'summary', type: 'text', primaryKey: false, masked: false, label: '议价结论' }, + ], + }, ], listeners: [ { @@ -134,6 +204,8 @@ export const manifest: ActivityManifest = { cmmnXml: CMMN_XML, narrative: NARRATIVE, dmn: { drd: [], tables: [] }, + /* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */ + steps: NEGOTIATE_STEPS, }, skills: NEGOTIATE_SKILLS, viewTemplates: [ diff --git a/activities/quote-negotiate/src/views/report.ts b/activities/quote-negotiate/src/views/report.ts index 88f2846..a2ffc30 100644 --- a/activities/quote-negotiate/src/views/report.ts +++ b/activities/quote-negotiate/src/views/report.ts @@ -1,50 +1,189 @@ /** - * A6 视图模版 —— 议价两屏:正式报价单 / 待放行即发出(AM 07)。 - * 这是**唯一会触达外界**的活动:放行一刻要发信、要回写台账,所以那一屏必须说清后果。 + * A6 报价议价的 HTML 报告模版 —— **活动自己持有的资产**(§10.3),随包版本冻结。 + * + * 议价是**多轮往来**:成单 → 人工按下发出 → D+3 催 → D+7 催 → 客户还价 → 改一版再发。 + * 所以这两屏都要让人看得出**先后**:这一版是第几轮、上一轮报的是多少、下一记提醒在哪天。 + * 只摆一个「当前单价」的屏,人签字时没有参照系,也就无从判断这一让让得值不值 —— + * 于是「历次报价」那一节摆的是共享域整表(含既往单),它不是本单成果,是本单的尺子。 + * + * 两个局面: + * ① 正式报价单(草稿)—— 六样条款齐不齐;这一步还没碰外界,一封信都没发; + * ② 放行即发出 —— 按下的那一刻信就到客户手上,签字前是最后一次能改的机会。 + * + * 容器是 `sandbox=""` iframe:无脚本、无外链、样式自带;数据由基座推送时填 + * (`data-slot` 取值、`data-rows` 逐行展开)。 + * + * 两条贯穿全包的呈现纪律: + * · **状态章一律用「在办」灯,不用「已办」印** —— 模版是随包冻结的静态字符串, + * 它盖不了一个还没发生的章;这两张都是放行前的局面; + * · **`verdict` 的橙档专指「还没发生,等你按」** —— 发出的后果是预告不是既成事实。 + * 整屏的结构色一律品牌蓝/案卷紫,红橙绿只落在「达标没达标」上,不拿来渲染气氛。 */ -import { kvCard, noteCard, tableCard, viewShell } from '@sdk'; +import { noteOnly, section, stagesOnly, statsBand, tableOnly, verdict, viewShell } from '@sdk'; +/** + * 静态阶梯 —— **规则与节拍不是数据**:六样条款的要求、D+3/D+7 的节拍、三个出口的去向 + * 都随包冻结,不该走 `data-rows` 等基座来填(那会让「规矩」看起来像「这一单的结果」)。 + * 沿用外壳已有的 `.steps` 版式,与引擎推的阶段列表同一副面孔。 + */ +function ladder(rows: ReadonlyArray): string { + return `
    ${rows + .map( + ([no, term, gloss]) => + `
  • ${no}${term} ${gloss}
  • `, + ) + .join('')}
`; +} + +/** + * 键值格 —— 报价单的条款是**一份单据的正面**,六样并排比一张六列横向滚动的表好读。 + * `@sdk` 的 `kvCard` 自带一个「·」号章节,与本页「一/二/三」的编号排不到一起, + * 所以这里只借外壳已有的 `.kv` 版式,章节仍交给 `section` 统一编号。 + */ +function kvGrid(items: ReadonlyArray): string { + return `
${items + .map(([label, path, style]) => { + const cls = style === undefined ? '' : ` class="${style === 'mono' ? 'mono' : 'tag'}"`; + return `
${label}
`; + }) + .join('')}
`; +} + +/** 局面一:S1 成单 —— 人此刻要核的是「六样齐没齐、这一版能不能发」 */ export const QUOTE_COMPOSED = viewShell({ - title: '正式报价单', - kicker: 'A6 · 报价议价', + title: '正式报价单(草稿)', + kicker: 'A6 · 报价议价 · S1 成单', tone: 'brand', + icon: 'handshake', + stamp: { kind: 'live', text: '成单中' }, + sign: 'A6 报价议价 · 正式报价单(本单草稿)', body: - kvCard('报价抬头', [ - ['报价单号', 'quoteNo', 'mono'], - ['客户编码', 'customerCode', 'mono'], - ['型号 PN', 'pn', 'mono'], - ]) + - kvCard('报价条款', [ - ['单价', 'unit_price'], + /* `items` 是起单入参的数组,基座渲染数组时给的是条数 —— 正好是「这一单要报几条」 */ + statsBand([ + ['报价条目', 'items'], + ['单价 CNY', 'unit_price'], ['交期', 'lead_time'], - ['最小包装量 SPQ', 'spq'], - ['最小起订量 MOQ', 'moq'], - ['贸易条款', 'trade_terms'], ['有效期', 'valid_until'], ]) + - noteCard('这一步还没碰外界', '报价单此刻只是草稿。发出动作是写类的,过程中只登记为「动作意向」——入参冻结、等放行,放行前一封信都不会发出去。'), -}); - -export const QUOTE_RELEASE = viewShell({ - title: '正式报价单 · 放行即发出', - kicker: 'A6 · 成果预览', - tone: 'danger', - body: - tableCard( - '本单报价', - 'mine:quote_negotiate.formal_quote', - [ + section('报价条款六样 —— 缺一不可', + kvGrid([ + ['单价 CNY', 'unit_price'], + ['交期', 'lead_time'], + ['最小包装量 SPQ', 'spq'], + ['最小起订量 MOQ', 'moq'], + ['贸易条款', 'trade_terms'], + ['有效期', 'valid_until'], + ]) + + noteOnly( + '单价与交期照审批通过的草案抄,不做任何「顺手优化」—— 改价要重新过审批点。' + + '有效期按行情波动定,不要一律写「长期有效」:那等于把汇率与料价风险自己扛了。' + + '拿不准的项宁可空着走补全,不要填一个看起来合理的值。', + ), + { text: '缺一样多一轮' }, + ) + + section('本单已成形的报价行', + tableOnly('mine:quote_negotiate.formal_quote', [ ['报价单号', 'quote_no', 'mono'], - ['型号', 'pn', 'mono'], + ['型号 PN', 'pn', 'mono'], ['单价', 'unit_price'], ['交期', 'lead_time'], - ], + ['有效期', 'valid_until'], + ]), + { text: '只列本单写下的行' }, ) + - noteCard( - '按下放行会发生什么', - '① 落地预检先跑:交付物齐备、动作意向可执行、授权在契约边界内 —— 预检不过不发卡。' + - '② 放行通过即刻落地:台账回写 + 出站发送(报价单发给客户),动作附幂等键。' + - '③ 落地收敛后输出口才发射 —— 下游只见落地完成的任务;已成动作留存不回滚。' + - '④ 出口三选一(成交 / 有异议 / 拒绝)恰发一口,随放行一并确认,G4 网关按它分流。', + section('议价的节拍 —— 一轮一版', + ladder([ + ['1', '写成正式报价单', '中英双语,六样齐备'], + ['2', '人工按下发出', '触达客户的一下,系统不替人做这个决定'], + ['3', 'D+3 提醒到点', '没有跟进的报价等于没报'], + ['4', 'D+7 提醒到点', '第二记催办,仍无回音即等超时'], + ['5', '客户回音 → 议价调整', '改价、改交期、换料都是新一版,重新走发出'], + ]), + { text: '往来看得出先后' }, + ) + + section('案例当下进程', stagesOnly('case:stages'), { text: 'Enabled ≠ Active' }) + + section('这一步还没碰外界', + verdict('报价单此刻只是草稿 —— 一封信都还没发出去', 'wait', '发出这一下必须人工按') + + noteOnly( + '发出是写类动作:过程中只登记为「动作意向」—— 入参冻结、等放行,放行前不触达世界。' + + '预授权是逐项列举的:催办模板可即时发,报价单不行。', + ), + ), +}); + +/** 局面二:待放行 —— 按下的那一刻信就发出去,这是最后一次能改的机会 */ +export const QUOTE_RELEASE = viewShell({ + title: '正式报价单 · 放行即发出', + kicker: 'A6 · 报价议价 · 成果待签', + tone: 'case', + icon: 'send', + stamp: { kind: 'live', text: '待签字放行' }, + sign: 'A6 报价议价 · 正式报价单(待放行即发出)', + body: + statsBand([ + ['单价 CNY', 'unit_price'], + ['交期', 'lead_time'], + ['最小起订量 MOQ', 'moq'], + ['有效期', 'valid_until'], + ]) + + section('本单交付:正式报价单', + tableOnly('mine:quote_negotiate.formal_quote', [ + ['报价单号', 'quote_no', 'mono'], + ['型号 PN', 'pn', 'mono'], + ['单价', 'unit_price'], + ['交期', 'lead_time'], + ['SPQ', 'spq'], + ['MOQ', 'moq'], + ['贸易条款', 'trade_terms'], + ['有效期', 'valid_until'], + ]), + { text: '发出去的就是这几行' }, + ) + + section('历次报价对照 —— 这一轮让了多少', + /* + * 这一张**故意不用 `mine:`**:它取共享域整表,含既往单的行。 + * 本单成果只看 `mine:`(见上一节),而「让了多少」这件事天然要跟别的单比 —— + * 没有上一轮的价摆在旁边,人签字时判断不了这一让的分寸。 + */ + tableOnly('shared.history_price', [ + ['型号 PN', 'pn', 'mono'], + ['报价日期', 'quoted_at', 'mono'], + ['单价', 'unit_price'], + ['交期', 'lead_time'], + ]) + + noteOnly( + '这一节不是本单成果,是共享域里同一料号的历次报价(含既往单)—— 摆在这里只做一件事:' + + '给这一轮的价一个参照系。报价日期在列上,先后按日期对;它由 A7 情报归档一单一单沉下来。', + ), + { text: '含既往单' }, + ) + + section('放行前要过的四道判据', + ladder([ + ['1', '单价 / 交期 / SPQ / MOQ / 贸易条款 / 有效期齐备', 'L1 机检 · missingQuoteFields = 0'], + ['2', '按报价有效期 P-04 注明有效期', 'L2 机检 · validityDays 大于 0'], + ['3', '已建 D+3 / D+7 跟进', 'L2 机检 · 没有跟进的报价等于没报'], + ['4', 'BD 放行', 'L5 人签 · 签了才算数'], + ]), + { text: '三机检 · 一人签' }, + ) + + section('出口三选一 —— 恰发一口', + ladder([ + ['1', '成交', '转订单履约(ORM),这条流走到终点'], + ['2', '有异议', '回 A4 重开新实例重算 —— 旧的已终,不再入算'], + ['3', '拒绝 / 超时', '转 A7 情报归档,把拒绝原因与竞对价位登记下来'], + ]) + + noteOnly( + '三个出口对应三种后续,随放行恰选一口,下游网关读的就是它;不选口不许放行 ——' + + '含糊的结果比没结果更坏。「拒绝」不是失败终态:原因回写历史情报后,下一单的三分类与利润模型会用上它。', + ), + { text: '下游只读这一口' }, + ) + + section('按下放行会发生什么', + verdict('放行即落地:台账回写 + 报价单出站发送', 'wait', '已成动作留存,不回滚') + + noteOnly( + '① 落地预检先跑:交付物齐备、动作意向可执行、授权在契约边界内 —— 预检不过不发卡;' + + '② 放行通过即刻落地,出站动作附幂等键;' + + '③ 落地收敛之后输出口才发射 —— 下游只见落地完成的任务。', + ), ), }); diff --git a/activities/quote-pricing/src/case-model.ts b/activities/quote-pricing/src/case-model.ts index 978b82f..5c524d4 100644 --- a/activities/quote-pricing/src/case-model.ts +++ b/activities/quote-pricing/src/case-model.ts @@ -1,81 +1,137 @@ /** - * A4 成本定价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。 + * A4 成本定价的案例模型 —— **算价归判定表,超限那一下归人**(2026-07-31 照 A1 的状态机模式重做)。 * - * 2026-07-29 由步序声明(flow)改写而来。原六步各归其位:取价基准与逐条定价属「算价」, - * 偏离率与底价门禁属「自检」(两支 DMN:P-01 利润模型、P-02 底价门禁), - * 超限条目调人决策是**触达客户利益的一下**,必须人工激活;末尾一张验收单。 + * 与 A2/A3 的分工:那两个是「一次专家判断」,派一张执行单收回结论就够了; + * 定价不同 —— 它**有确定性规则可依**(P-01 利润模型、P-02 底价门禁两支 DMN), + * 算价这件事该算不该问。所以它照 A1 的样子分步建模,而不是甩一张单给人。 + * + * 三项: + * T_price —— 逐条算价并落草案(拟报单价 / 毛利 / 偏离度 / 门禁处置); + * T_approve —— **只在门禁判超限时入场**:触达客户利益的一下,人工激活,派决策卡; + * T_release —— 放行(人工激活)。 + * + * 「超限才叫人」是这个活动的要害:每条都叫人,人就成了橡皮图章;一条都不叫, + * 底价门禁就是摆设。所以那一项挂前提(`s_over`),由判定表的结论决定它入不入场。 */ export const CMMN_XML = ` + - - - - + + + - - create - - - create - - update - unpriced = 0 + update + draft_total > 0 - - update - breaches > 0 + + + update + over_total > 0 - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - PT8H - + + + + + + + + + + + `; export const NARRATIVE = `§0 开案:拿到一批待定价条目即开案。成品是**一份全条目带偏离度的报价草案** —— -每条有拟报单价与毛利率,且都过了底价门禁。 +人签的不是一个数字,是**这个数字的来路**:基准从哪来、毛利怎么算、偏离多少、门禁怎么判。 -S1 算价:先取价基准(历史成交价 / 供应商回价 / 替代料对标价,按可得性择优), -再按利润模型 P-01 逐条定价 —— 客户等级、数量档、料号类型、行情共同决定加价率。 -基准取不到的条目不许拍脑袋定价,回头找寻源或标注「无基准」。 +**能算的不问人**:P-01(利润模型)与 P-02(底价门禁)两支判定表说了算,你不临场拍价。 +你的活是把判据取齐、把结论落进台账;**只有超限的那几条才叫人**。 -S2 自检:算出偏离度(基准价 ↔ 拟报价),按底价门禁 P-02 逐条判。 -**偏离 ±20% 以内自动放过;超限的一律调人** —— 要么回调价格,要么写明为什么这样报。 -底价超一周没更新的先刷底价再判(裁量项),拿旧底价判新行情等于没判。 +【T_price 逐条算价】基准价取自历史价情报(A3 的比价结论 / CRM 历史价)。新料号无历史时 +基准记 0,由底价门禁按「无基准」处置 —— **不凭空编一个偏离率出来**。 +逐条写进 \`quote_pricing.draft\`:拟报单价 / 目标毛利 / 相对基准偏离 / 门禁处置。 +同一拍接着把 draft_total(条数)与 over_total(门禁判「超限」的条数)写上 —— +这两个数一个是完成判据,一个决定要不要叫人。写完 finish。 -§Z 结案:每条都有拟报单价与毛利率、超限条目已处置、BD 签字放行,自动结案。 -草案交出去就是下游议价的起点 —— 它不是最终报价,但每一条都要经得起问「为什么是这个价」。`; +【T_approve 超限决策】**只在 over_total > 0 时走这一步**。它触达客户利益, +所以标了人工激活:**先 start,再发决策卡**(decide,itemId 填 T_approve)。 +选项按门禁的处置给(照原价报 / 按底价报 / 退回重算),consequence 写清各自的后果; +basis 摆上是哪几条、偏离多少、底价是多少(裸卡基座拒发)。 +人点完,轨迹上会出现「decision 卡回传 —— 人选了:xxx」—— **看到它就照那条路把 draft 改掉**, +别再发第二张卡。改完 finish。 + +【提交放行】草案有行了 —— **先 start T_release**(人工激活),然后 submit,BD 验收。 +提交前把两条裁量判据裁掉(judge,criterion 照判据名原样写);终局唯放行,你不能自己宣布完成。 + +【一路上要说话】每一拍带一句 say,说**业务的话**:这批料算下来什么价、哪几条压线、 +要不要找人拍板。一两句、口语。别报「写草稿 N 行」这种系统词。`; + +/** + * 步骤剧本 —— 与 A1 同一套:`doneWhen` 为真,引擎自己收步、**不问模型**。 + * + * 两处不给 `doneWhen`:`T_approve`(人工激活,要人拍板)与 `T_release`(触达世界)。 + * 给了就等于给引擎一把替人签字的钥匙。 + */ +export const PRICING_STEPS = [ + { + item: 'T_price', + intent: '逐条算价并落草案:拟报单价 / 毛利 / 偏离度 / 门禁处置,并结算条数与超限数', + writes: ['quote_pricing.draft', 'quote_pricing.verdict'], + reads: [], + tools: [], + // 草案有行了才算算完。不写 `!= null` —— 未绑定的名字那样求值为 true(A1 踩过) + doneWhen: 'draft_total > 0', + guide: `基准价取自历史价情报(A3 的比价结论 / CRM 历史价)。新料号无历史时基准记 0, +由底价门禁按「无基准」处置 —— **不凭空编一个偏离率**。 +逐条写进 \`quote_pricing.draft\`:拟报单价 / 目标毛利 / 相对基准偏离 / 门禁处置。 +**同一拍接着**把 draft_total 与 over_total(门禁判超限的条数)写上 —— +前者是完成判据,后者决定要不要叫人。 +**同一拍再写一行 \`quote_pricing.verdict\`**(验收机检读的就是它): +· draft_total / unpriced_total = 草案条数 / 还没定出价的条数(后者要 = 0); +· over_total = 门禁判超限的条数; +· unresolved_over_total = 超限里**还没处置**的条数(要 = 0 —— 回调了或注明了理由都算已处置); +· summary 一句人话结论。写不对 submit 会被门挡回来。三笔写完再 finish。 +**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 +台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉 +(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, + }, + { + item: 'T_approve', + intent: '超限条目要人拍板 —— 发决策卡,照人选的那条路改草案', + writes: ['quote_pricing.draft'], + reads: [], + tools: [], + // 人工激活 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 + guide: `**只在 over_total > 0 时走这一步**。它触达客户利益,标了人工激活: +**先 start T_approve**(与发卡排同一拍即可),再发决策卡(decide,itemId 填 T_approve)。 +选项按门禁处置给(照原价报 / 按底价报 / 退回重算),consequence 写清后果; +basis 摆上哪几条、偏离多少、底价多少(**裸卡基座拒发**)。 +人点完轨迹上会出现「decision 卡回传 —— 人选了:xxx」,**照那条路改 draft,别再发第二张卡**。`, + }, + { + item: 'T_release', + intent: '草案已齐,提交放行交 BD 验收', + writes: [], + reads: [], + tools: [], + guide: `草案有行了 —— **先 start T_release**(人工激活),提交前用 judge 裁掉裁量判据 +(criterion 照判据名原样写),然后 submit。终局唯放行:你不能自己宣布完成。`, + }, +]; diff --git a/activities/quote-pricing/src/index.ts b/activities/quote-pricing/src/index.ts index 04bbb8a..76cb9d6 100644 --- a/activities/quote-pricing/src/index.ts +++ b/activities/quote-pricing/src/index.ts @@ -11,7 +11,6 @@ const DRAFT = 'quote_pricing.draft'; export default defineActivity({ name: 'quote-pricing', - paradigm: 'flow', async run(base: ActivityBase): Promise { const items = await base.ledger.read(DRAFT); diff --git a/activities/quote-pricing/src/manifest.ts b/activities/quote-pricing/src/manifest.ts index a409d67..e1bf687 100644 --- a/activities/quote-pricing/src/manifest.ts +++ b/activities/quote-pricing/src/manifest.ts @@ -7,7 +7,7 @@ */ import type { ActivityManifest, DmnModel } from '@contracts'; import { PRICING_SKILLS } from './skills.ts'; -import { CMMN_XML, NARRATIVE } from './case-model.ts'; +import { CMMN_XML, NARRATIVE, PRICING_STEPS } from './case-model.ts'; import { activityIdSchema } from '@contracts'; import { PRICING_BASIS, PRICING_PRICED, PRICING_RELEASE } from './views/report.ts'; @@ -62,7 +62,7 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'A4 · 成本定价', - packageVersion: '0.1.6', + packageVersion: '2.0.0', category: '统一报价', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-quote-pricing', @@ -74,8 +74,9 @@ export const manifest: ActivityManifest = { input: { data: { type: 'object', - required: ['customerCode', 'records'], + required: ['rfqRef', 'customerCode', 'records'], properties: { + rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, customerCode: { type: 'string' }, records: { type: 'array' }, // B/C 类由 A3/A2 交回的底价与替代方案;A 类直通时为空 @@ -90,21 +91,36 @@ export const manifest: ActivityManifest = { name: 'draft', payload: { type: 'object', - required: ['items'], - properties: { items: { type: 'array' } }, + required: ['rfqRef', 'items'], + properties: { rfqRef: { type: 'string' }, items: { type: 'array' } }, }, description: '报价草案(逐条带偏离度)', }, ], selective: [], }, - requiredDeliverables: [{ key: 'quote-draft', label: '报价草案(全条目带偏离度)' }], + /* + * 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 + * 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, + * 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒, + * 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。 + */ + requiredDeliverables: [ + { key: 'quote-draft', label: '报价草案(全条目带偏离度)', ledger: `${DOMAIN}.draft` }, + ], + /* + * 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 + * 上一版的名字(unpriced;floor-gate 更是连谓词都没有)**台账里根本没有** —— S-FEEL 拿未绑定的名字 + * 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。 + * 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 + */ acceptanceCriteria: [ - { key: 'all-priced', label: '每条都有拟报单价与毛利率', level: 'L1', predicate: 'unpriced = 0' }, + { key: 'all-priced', label: '每条都有拟报单价与毛利率', level: 'L1', predicate: 'unpriced_total = 0' }, // 底价门禁沉为 L2 机检 —— 「约束力收缩」的终点 - { key: 'floor-gate', label: '偏离底价 ≤±20%,超限者已回调或注明理由', level: 'L2', modelRef: 'p02-floor' }, + { key: 'floor-gate', label: '偏离底价 ≤±20%,超限者已回调或注明理由', level: 'L2', modelRef: 'p02-floor', predicate: 'unresolved_over_total = 0' }, { key: 'pm-release', label: 'BD 放行', level: 'L5' }, ], + revisableAtRelease: [], prohibitions: [ { key: 'no-silent-override', statement: '不得静默绕开 P-02 的结论;要破线只能走「人 · 决策」' }, { key: 'no-stale-floor', statement: '底价超一周未更新的,先刷底价再判' }, @@ -115,7 +131,13 @@ export const manifest: ActivityManifest = { humanCallBudget: 3, }, resources: { - tools: [{ name: 'quote-pricing.basis' }, { name: 'crm.history-price-read' }], + /* + * `quote-pricing.basis` 已从声明里去掉(2026-07-31):工具箱里从没注册过它, + * 装包时静态审一直报「运行时转人代办」—— 而重做后的案例模型压根不调它 + * (算价归判定表 P-01/P-02,取基准读历史价)。声明一件不存在也不用的工具, + * 只会让每一单跑到 A4 就弹一张莫名其妙的转人卡。 + */ + tools: [{ name: 'crm.history-price-read' }], skills: PRICING_SKILLS.map((s) => s.id), models: ['deepseek-v4-flash'], roles: ['BD'], @@ -125,13 +147,16 @@ export const manifest: ActivityManifest = { declarations: { models: ['deepseek-v4-flash'], - tools: [{ name: 'crm.history-price-read' }, { name: 'quote-pricing.basis' }], + tools: [{ name: 'crm.history-price-read' }], ledger: [ { domain: DOMAIN, object: 'draft', + kind: 'record', + caseKey: 'rfq_ref', label: '报价草案', fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' }, { name: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '拟报单价' }, { name: 'margin', type: 'number', primaryKey: false, masked: false, label: '毛利率' }, @@ -140,6 +165,27 @@ export const manifest: ActivityManifest = { { name: 'disposition', type: 'text', primaryKey: false, masked: false, label: '门禁处置' }, ], }, + { + /* + * 定价结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。 + * 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」, + * 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。 + */ + domain: DOMAIN, + object: 'verdict', + kind: 'record', + caseKey: 'rfq_ref', + label: '定价结论', + fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, + { name: 'pn', type: 'text', primaryKey: false, masked: false, label: '型号' }, + { name: 'draft_total', type: 'integer', primaryKey: false, masked: false, label: '草案条数' }, + { name: 'unpriced_total', type: 'integer', primaryKey: false, masked: false, label: '未定价条数' }, + { name: 'over_total', type: 'integer', primaryKey: false, masked: false, label: '超限条数' }, + { name: 'unresolved_over_total', type: 'integer', primaryKey: false, masked: false, label: '超限未处置条数' }, + { name: 'summary', type: 'text', primaryKey: false, masked: false, label: '定价结论' }, + ], + }, ], listeners: [ { @@ -166,6 +212,8 @@ export const manifest: ActivityManifest = { cmmnXml: CMMN_XML, narrative: NARRATIVE, dmn: P01_DMN, + /* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */ + steps: PRICING_STEPS, }, skills: PRICING_SKILLS, viewTemplates: [ diff --git a/activities/quote-pricing/src/views/report.ts b/activities/quote-pricing/src/views/report.ts index 0cd8a3d..e42c3e1 100644 --- a/activities/quote-pricing/src/views/report.ts +++ b/activities/quote-pricing/src/views/report.ts @@ -1,67 +1,192 @@ /** - * A4 视图模版 —— 定价的三屏:取价基准 / 定价与门禁 / 报价草案待放行(AM 07)。 - * 定价这件事最需要「把判据摊开」:人签的不是一个数字,是这个数字的来路。 + * A4 成本定价的 HTML 报告模版 —— **活动自己持有的资产**(§10.3),随包版本冻结。 + * + * 定价这件事最需要「把判据摊开」:**人签的不是一个数字,是这个数字的来路**。 + * 所以三张屏不按「概览 / 明细 / 结论」排,按**一个价是怎么长出来的**排: + * ① 取价基准 —— 跟谁比。基准取错,后面每一条偏离率都是假的,而假偏离率长得跟真的一样; + * ② 定价与底价门禁 —— 凭什么是这个价。两张决策表的规则原样摆出来,人能逐条倒着核; + * ③ 报价草案待放行 —— 签字前最后一次能改的机会,门后的都算数了。 + * + * 一个局面一张,局面变了让 Agent 用 `view` 换,不在一张里堆条件分支。 + * 容器是 `sandbox=""` iframe:无脚本、无外链、样式自带;数据由基座推送时填 + * (`data-slot` 取值、`data-rows` 逐行展开)。 + * + * 两条贯穿全包的呈现纪律: + * · **状态章一律用「在办」灯,不用「已办」印** —— 模版是随包冻结的静态字符串, + * 它盖不了一个还没发生的章。三张屏都是放行前的局面,盖「已通过」就是撒谎; + * · **`verdict` 的橙档专指「还没发生,等你按」** —— 绿只留给已达标的机检结论。 + * 放行后果是预告不是既成事实,一律走橙档。 */ -import { kvCard, noteCard, tableCard, viewShell } from '@sdk'; +import { noteOnly, section, stagesOnly, statsBand, tableOnly, verdict, viewShell } from '@sdk'; +/** + * 静态阶梯 —— **规则本身不是数据**:P-01 的毛利档、P-02 的三档处置随包冻结, + * 它们不该走 `data-rows` 等基座来填(那会让「规则」看起来像「这一单的结果」)。 + * 沿用外壳已有的 `.steps` 版式,与引擎推的阶段列表同一副面孔 —— 页面上只该有一套样式。 + */ +function ladder(rows: ReadonlyArray): string { + return `
    ${rows + .map( + ([no, term, gloss]) => + `
  • ${no}${term} ${gloss}
  • `, + ) + .join('')}
`; +} + +/** 局面一:S1 算价刚开场 —— 人此刻要核的是「这一单跟谁比、落在哪一档」 */ export const PRICING_BASIS = viewShell({ title: '取价基准', - kicker: 'A4 · 成本定价', + kicker: 'A4 · 成本定价 · S1 算价', tone: 'brand', + icon: 'coins', + stamp: { kind: 'live', text: '算价中' }, + sign: 'A4 成本定价 · 取价基准(本单草稿)', body: - kvCard('本单标的', [ - ['客户编码', 'customerCode', 'mono'], - ['型号 PN', 'pn', 'mono'], - ['物料类型', 'materialType', 'tag'], - ]) + - kvCard('价格基准', [ - ['上次成交价', 'last_price'], + /* + * 指标带摆的是**利润模型 P-01 的三个入参 + 这一单的工作量**,不是所有字段。 + * 三个档位键取的是清单里 P-01 声明的决策入参名(customerGrade / materialType / + * volumeTier)—— 决策表照这三个名字取值,模版就照这三个名字摆,不另起炉灶。 + * `records` 是起单入参的数组,基座渲染数组时给的是条数,正好是「这一单要定几条价」。 + */ + statsBand([ + ['待定价条目', 'records'], ['客户等级', 'customerGrade'], + ['料号类型', 'materialType'], ['数量档', 'volumeTier'], ]) + - noteCard('基准从哪来', '基准价取自历史价情报(CRM)。新料号无历史时基准记 0,由底价门禁按无基准处置 —— 不凭空编一个偏离率出来。'), + section('基准从哪来 —— 四级优先级', + ladder([ + ['1', '本客户近期成交价', '最贴近这一单的真实议价空间'], + ['2', '同型号他客户成交价', '价位可比,但议价空间要留余量'], + ['3', '供应商底价表', '成本侧的锚,不含客户侧信息'], + ['4', '无基准', '新料号、久未报价的老料号都会落到这里'], + ]) + + noteOnly( + '无基准不是异常,它本身就是一种基准。此时偏离率记 0 —— 没有可偏离的对象,' + + '由底价门禁按「无基准」处置。不要拿一个不相干的价去凑一个偏离率出来:' + + '那比没有基准更危险,因为它看起来像有依据。', + ), + { text: '越靠前越贴近实情' }, + ) + + section('本单待定价条目', + tableOnly('mine:quote_pricing.draft', [ + ['型号 PN', 'pn', 'mono'], + ['基准价', 'last_price'], + ['拟报单价', 'quoted_price'], + ['毛利率', 'margin'], + ]), + { text: '只列本单写下的行' }, + ) + + section('案例当下进程', stagesOnly('case:stages'), { text: 'Enabled ≠ Active' }), }); +/** 局面二:S2 自检 —— 人此刻要核的是「凭什么是这个价、门禁怎么判的」 */ export const PRICING_PRICED = viewShell({ title: '定价与底价门禁', - kicker: 'A4 · DMN 两表', - tone: 'ok', + kicker: 'A4 · 成本定价 · S2 自检', + tone: 'brand', + icon: 'gauge', + stamp: { kind: 'live', text: '自检中' }, + sign: 'A4 成本定价 · 定价自检(判定表 P-01 / P-02)', body: - kvCard('定价结论', [ + /* 标签一律照台账声明的列名(基准价 / 拟报单价 / 毛利率 / 偏离度 / 门禁处置): + 同一个数在指标带上叫一个名、在下面的表里叫另一个名,签字的人就得先花一秒确认它俩是不是一回事 */ + statsBand([ + ['基准价', 'last_price'], ['拟报单价', 'quoted_price'], - ['目标毛利', 'margin'], - ['相对基准偏离', 'deviation'], - ['门禁处置', 'disposition', 'tag'], + ['毛利率', 'margin'], + ['偏离度', 'deviation'], ]) + - noteCard( - '两张决策表各管一段', - '利润模型 P-01:客户等级 × 物料类型 × 数量档 → 目标毛利区间。' + - '底价门禁 P-02:偏离底价 ±20% 即止 —— 超限的不是「拦下」,而是转人裁断(回调还是注明理由)。' + - '两表都是确定性求值,同输入必同结论;命中的规则行随出参留痕,事后可逐条复盘。', + section('逐条定价结论', + tableOnly('mine:quote_pricing.draft', [ + ['型号 PN', 'pn', 'mono'], + ['基准价', 'last_price'], + ['拟报单价', 'quoted_price'], + ['毛利率', 'margin'], + ['偏离度', 'deviation'], + ['门禁处置', 'disposition'], + ]), + { text: '判据在左 · 结论在右' }, + ) + + section('目标毛利怎么定的 —— 利润模型 P-01', + ladder([ + ['1', '战略客户 · A 类标准件 · 大批量', '目标毛利 8%–10%'], + ['2', '普通客户 · A 类标准件', '12%–15%'], + ['3', 'B 类波动件', '15%–20%'], + ['4', 'C 类替代 / EOL', '≥20%,或一事一议'], + ['5', '以上都不命中', '兜底按普通客户标准件 12%–15%'], + ]) + + noteOnly( + '自上而下取第一条命中的规则,同输入必得同结论、零方差零 token,命中的规则行随出参留痕。' + + '不要为了让门禁「过」而回头改毛利参数 —— 那是把判据改成结论,决策表也就失去了它唯一的价值。', + ), + { text: '首条命中' }, + ) + + section('偏离怎么判的 —— 底价门禁 P-02', + ladder([ + ['1', '拟报价 ≥ 底价', '直接放行'], + ['2', '低于底价 ≤ 20%', 'PM 附理由放行'], + ['3', '低于底价 超 20%', '拦截 · BOSS 拍板'], + ]) + + noteOnly( + '门禁不是「拦下」而是分流:超限的一律调人 —— 要么回调价格,要么写明为什么这样报,' + + '两条路都合法,但都要留下理由。这条留痕将来会成为客户等级与利润模型的迭代素材。', + ), + { text: '±20% 即止' }, + ) + + section('本单门禁结论', + /* + * 结论章里嵌 `data-slot`:处置与偏离都是**这一单算出来的事实**, + * 写死一句「已通过」就是替判定表下结论。橙档在此的意思是「还没定,等人处置」。 + */ + verdict('底价门禁 P-02 处置:', 'wait', '偏离度 ') + + noteOnly( + '禁区两条随本单同行:不得静默绕开 P-02 的结论 —— 要破线只能走「人 · 决策」;' + + '底价超一周未更新的,先刷底价再判 —— 拿旧底价判新行情等于没判。', + ), ), }); +/** 局面三:待放行 —— 验收单上人要签的那份成果报告 */ export const PRICING_RELEASE = viewShell({ title: '报价草案 · 待放行', - kicker: 'A4 · 成果预览', + kicker: 'A4 · 成本定价 · 成果待签', tone: 'case', + icon: 'shield-check', + stamp: { kind: 'live', text: '待签字放行' }, + sign: 'A4 成本定价 · 报价草案(待放行)', body: - tableCard( - '本单报价草案', - 'mine:quote_pricing.draft', - [ - ['型号', 'pn', 'mono'], + statsBand([ + ['拟报单价', 'quoted_price'], + ['毛利率', 'margin'], + ['偏离度', 'deviation'], + ['门禁处置', 'disposition'], + ]) + + section('本单交付:报价草案', + tableOnly('mine:quote_pricing.draft', [ + ['型号 PN', 'pn', 'mono'], ['基准价', 'last_price'], - ['拟报价', 'quoted_price'], - ['毛利', 'margin'], - ['偏离', 'deviation'], - ['门禁', 'disposition'], - ], - '逐条列出,便于放行前逐条核对。', + ['拟报单价', 'quoted_price'], + ['毛利率', 'margin'], + ['偏离度', 'deviation'], + ['门禁处置', 'disposition'], + ]) + + noteOnly('只列本单写下的行 —— 别单放行过的记录不混进来。逐条列出,便于放行前逐条核对。'), + { text: '本单草稿' }, ) + - noteCard( - '放行之后', - '放行通过 → 草稿合并入正式版 → 进 AP 定价审批(卡到 PM 工位)。' + - '审批驳回按声明走向回 A4 重开新实例 —— 新任务 ID、留痕关联,不复活旧任务。', + section('放行前要过的三道判据', + ladder([ + ['1', '每条都有拟报单价与毛利率', 'L1 机检 · unpriced = 0'], + ['2', '偏离底价 ≤ ±20%,超限者已回调或注明理由', 'L2 机检 · 底价门禁 P-02'], + ['3', 'BD 放行', 'L5 人签 · 签了才算数'], + ]), + { text: '两机检 · 一人签' }, + ) + + section('放行之后会发生什么', + verdict('放行即落地:草稿合并入正式版,草案提交 A5 定价审批', 'wait', '审批点卡在 PM 工位') + + noteOnly( + '审批驳回按声明走向,不由临场决定:回 A4 重开新实例 —— 新任务 ID、留痕关联,' + + '不复活旧任务。所以这一屏上还改得动的每一格,过了这道门就要花一整个新实例才改得回来。', + ), ), }); diff --git a/activities/quote-sourcing/src/case-model.ts b/activities/quote-sourcing/src/case-model.ts index df3af27..631e008 100644 --- a/activities/quote-sourcing/src/case-model.ts +++ b/activities/quote-sourcing/src/case-model.ts @@ -1,120 +1,132 @@ /** - * A3 渠道寻源的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。 - * 「说明书是 XML 的叙事投影,迭代时一起放行」(AM 3.1)。 + * A3 渠道寻源的案例模型 —— **一张执行单办完**(2026-07-31 用户定,与 A2 同一口径)。 + * + * 上一版按四种询价场景(BOM 早期 / BOM 后期 / 现货 / 样品)把流程铺开成一串计划项。 + * 场景之分在业务上是真的,可它**不改变系统要管的事**:发函、收回价、比价、给结论。 + * 而这四步在一次寻源里是连着做的 —— 采购不会办完「发函」就交回来等系统叫他。 + * 把它切成四项,换来的只是 Agent 每一步都要跟 harness 对一次账。 + * + * 现在只有两项: + * T_source —— 派一张执行单给采购(卡面两张可编辑表:供应商回价 / 寻源比价),他填完提交; + * T_release —— 放行(触达世界,人工激活)。 + * + * 场景之分不进模型,进**说明书**:采购自己知道 BOM 早期该广撒网、现货该问库存。 + * 那是他的判断,不是系统的步。 */ export const CMMN_XML = ` + - - - + + - - create - - - update - needNewQuote = true - - - update - channelsPicked >= 2 - - - create - - - create + + update + compare_total > 0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - P4D - - - P7D - + + + + + `; -export const NARRATIVE = `§0 开案:本案例覆盖四种询价场景 —— BOM早期、BOM后期、现货询价、样品询价。 -场景不同,供应商选择尺度与价格验证规则各异,但都遵循同一主线: -PM 提交询价请求 → 查历史报价判定可否直采 →(须询价时)按场景圈渠道 → 发询验证 → 填入寻源比价表反馈。 +export const NARRATIVE = `§0 开案:A1 判出一条料要寻源(无近期历史价、或行情波动),这一单就到你手上。 +成品只有一样:**一份能拿去定价的比价结论** —— 几家报了什么价、交期多久、推荐哪一家、为什么。 -S1 接需核史:系统带出在供供应商与历史报价。先核历史,按场景规则逐 PN 判定是否可直接采用 —— -可直采则跳过询价直接进入反馈交付;须新询价则进入选渠阶段。 - · 查历史报价:未达 3 次询价但有效期已过的,标记须重询。 - · 判定采用策略:BOM早期有效期内直采、过期浮动≤10%直采、10-20%取均价、>20%重询; - BOM后期历史最低(≥3次)直采,否则降 20% 谈;现货/样品一律重询。 +**这一单只有一步**:把寻源这件事派给采购(执行工单),他填完你收进台账,然后提交放行。 +四种询价场景(BOM 早期 / BOM 后期 / 现货 / 样品)的打法差别是**采购的判断**, +不是系统要管的步 —— 你在 ask 里说清是哪种场景就够了,别替他把流程铺开。 -S2 选渠定策:BOM早期/样品渠道可放宽;BOM后期按主→备选→贸易商顺序圈 2~3 家,贸易商尽量不用; -现货仅选有库存且可提供仓库实拍的供应商。渠道资质核实是「看情况」的裁量项。 +【T_source 派执行单】发 human 行动(workOrder="execution"),卡面 blocks 放**两张可编辑表**: +· 供应商回价:列 = 型号 / 供应商 / 回价 / MOQ / 交期 / 有效期; +· 寻源比价:列 = 型号 / 供应商 / 回价 / 结论。 +两张都 editable:true;没有的值给空字符串 —— 空格子界面会自己标红「待填写」。 +ask 说清要他做什么(哪颗料、什么场景、要几家、什么时候要); +basis 摆上型号、数量、目标价、客户交期要求(裸卡基座拒发)。 -S3 发询比价:同一料所有渠道一次发齐;PN/数量/目标价/回价时限写死在询价里。 -回价逐单登记,一一对应不可缺项。价格验证因场景而异。回价异常或未应答则追价补询(裁量项)。 +他提交后,回传值出现在你的轨迹里:表格那一份的键是 **table:0 / table:1**(块序号), +值是整张表的 JSON —— **第一行是列名**,其余每行一条,列序与你派出去时一致。 +照它 write 进 \`quote_sourcing.supplier_quote\` 与 \`quote_sourcing.compare_table\`, +并把比价表条数记进 compare_total。**写完就 finish T_source,别再发第二张单** —— 他已经答过了。 -S4 反馈交付:确认报价/LT/SPQ/有效期逐项填入寻源比价表,供 PM 直接查阅。 -现货/样品场景多一步下单与跟踪(裁量项)。 +【提交放行】比价表有行了 —— **先 start T_release**(人工激活:放行触达世界,必须按一下), +然后 submit,BD 验收。终局唯放行:你不能自己宣布完成。 -全程随时可做:知识入库 —— 回价与成交沉淀历史价格情报,渠道表现回写供应商渠道目录。 +【一路上要说话】负责人正看着这一单。每一拍带一句 say,说**业务的话**: +哪颗料在寻源、找了几家、回价怎么样、这一单往哪走。 +一两句、口语。别报「写草稿 2 行」这种系统词。`; -§Z 结案:必做的事都办完、没有进行中的事,自动算结案(CMMN AutoComplete)。 -寻源比价表交付 PM 即结案 —— 每询一轮,行情底账厚一层。`; +/** + * 步骤剧本 —— 与 A1/A2 同一套机制:`doneWhen` 为真,引擎自己收步、**不问模型**。 + * 放行那一项不给 `doneWhen` —— 它触达世界,必须人按一下(铁律③)。 + */ +export const SOURCING_STEPS = [ + { + item: 'T_source', + intent: '派一张执行单给采购,收回供应商回价与比价结论', + writes: ['quote_sourcing.supplier_quote', 'quote_sourcing.compare_table', 'quote_sourcing.verdict'], + reads: [], + tools: [], + /* + * 比价表有行了才算办完。**不能写 `compare_total != null`** —— + * S-FEEL 里未绑定的名字 `!= null` 求值为 true,那一步开局即被收掉(A1 踩过)。 + */ + doneWhen: 'compare_total > 0', + guide: `**照这个骨架发,一个字段都别少**(prose 描述不管用,直接抄): +{"kind":"human","itemId":"T_source","workOrder":"execution","card":{ + "title":"…","ask":"这颗料要寻源,请找几家报价并给比价结论", + "basis":[{"k":"…","v":"…"}], + "blocks":[ + {"t":"table","title":"供应商回价","cols":["型号","供应商","回价","MOQ"],"editable":true, + "rows":[["NE555P","","",""],["NE555P","","",""]]}, + {"t":"table","title":"寻源比价","cols":["型号","供应商","回价","结论"],"editable":true, + "rows":[["NE555P","","",""],["NE555P","","",""]]} + ]}} +**一条料至少摆 2 行** —— 判据「BOM后期每料 2~3 家渠道」机检的就是渠道数, +只问一家过不了门(型号那列你先填,供应商与价格留给采购)。 +**rows 必须有行**:一条料一行,不知道的值给空字符串(界面自己标红「待填写」)。 +只给 cols 不给 rows,人看到的是一排表头加一片空白,一个格子都填不了(实测栽过)。 +**blocks 少了整张卡就废了** —— 基座会用当下局面兜底成一句「需要你判断下一步」, +人拿到一张没有表的空卡,这一步等于没派(实测栽过)。 + +**rows 一条料一行**,型号那一列先填上;ask 说清什么场景、要几家、什么时候要; +basis 摆上型号、数量、目标价、交期要求(**裸卡基座拒发**)。 + +他提交后回传值进你的轨迹:表格的键是 **table:0 / table:1**,值是整张表的 JSON, +**第一行是列名**。照它 write 进两张台账,并把比价表条数记进 compare_total。 +**同一拍再写一行 \`quote_sourcing.verdict\`**(验收机检读的就是它): +· supplier_total = 询了几家渠道(判据要 ≥2,**只问一家过不了门**); +· quoted_total / unquoted_total = 已回价 / 未回价条数(未回价要 = 0); +· compare_total = 比价表条数;recommend_supplier 推荐哪家;summary 一句人话结论。 +这几个数写不对,submit 会被门挡回来 —— **它们不是台账装饰,是判据本身**。 +**写完就 finish,别再发第二张单** —— 他已经答过了。 +**每一行都要带 \`rfq_ref\`** —— 它是这一单的单标识(起单入参里就有,原样抄进去)。 +台账主键第一列就是它:不写,这一行会和**别的单**同料号的那一行撞主键,把人家覆盖掉 +(实测栽过:A4 的报价草案主键只有 pn,跑四轮只剩最后一轮的 3 行)。`, + }, + { + item: 'T_release', + intent: '比价结论已齐,提交放行交 BD 验收', + writes: [], + reads: [], + tools: [], + guide: `比价表有行了 —— **先 start T_release**(它标了人工激活),然后 submit,BD 验收。 +终局唯放行:你不能自己宣布完成。`, + }, +]; diff --git a/activities/quote-sourcing/src/index.ts b/activities/quote-sourcing/src/index.ts index 4f4584d..9250438 100644 --- a/activities/quote-sourcing/src/index.ts +++ b/activities/quote-sourcing/src/index.ts @@ -12,7 +12,6 @@ const COMPARE = 'quote_sourcing.compare_table'; export default defineActivity({ name: 'quote-sourcing', - paradigm: 'loop', async run(base: ActivityBase): Promise { const quotes = await base.ledger.read(QUOTES); diff --git a/activities/quote-sourcing/src/manifest.ts b/activities/quote-sourcing/src/manifest.ts index c1fe17c..581d0e5 100644 --- a/activities/quote-sourcing/src/manifest.ts +++ b/activities/quote-sourcing/src/manifest.ts @@ -11,7 +11,7 @@ import type { ActivityManifest, DmnModel } from '@contracts'; import { SOURCING_SKILLS } from './skills.ts'; import { SOURCING_CASE } from './views/report.ts'; import { activityIdSchema } from '@contracts'; -import { CMMN_XML, NARRATIVE } from './case-model.ts'; +import { CMMN_XML, NARRATIVE, SOURCING_STEPS } from './case-model.ts'; const DOMAIN = 'quote_sourcing'; const ACTIVITY_ID = activityIdSchema.parse('quote-sourcing'); @@ -45,7 +45,7 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'A3 · 渠道寻源', - packageVersion: '0.1.3', + packageVersion: '2.0.0', category: '统一报价', sdkVersion: '0.1.0-m5', checksum: 'dev-checksum-quote-sourcing', @@ -57,8 +57,9 @@ export const manifest: ActivityManifest = { input: { data: { type: 'object', - required: ['pn', 'scenario'], + required: ['rfqRef', 'pn', 'scenario'], properties: { + rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, pn: { type: 'string' }, scenario: { type: 'string' }, quantity: { type: 'integer' }, @@ -78,13 +79,28 @@ export const manifest: ActivityManifest = { ], selective: [], }, - requiredDeliverables: [{ key: 'compare-table', label: '寻源比价表' }], + /* + * 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。 + * 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名, + * 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒, + * 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。 + */ + requiredDeliverables: [ + { key: 'compare-table', label: '寻源比价表', ledger: `${DOMAIN}.compare_table` }, + ], + /* + * 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。 + * 上一版的名字(unquoted / channelsPicked)**台账里根本没有** —— S-FEEL 拿未绑定的名字 + * 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。 + * 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。 + */ acceptanceCriteria: [ - { key: 'each-has-quote', label: '每料有有效报价或明确无货', level: 'L2', predicate: 'unquoted = 0' }, - { key: 'channels-enough', label: 'BOM后期每料 2~3 家渠道', level: 'L2', predicate: 'channelsPicked >= 2' }, + { key: 'each-has-quote', label: '每料有有效报价或明确无货', level: 'L2', predicate: 'unquoted_total = 0' }, + { key: 'channels-enough', label: 'BOM后期每料 2~3 家渠道', level: 'L2', predicate: 'supplier_total >= 2' }, { key: 'cross-check', label: '价格验证按场景规则做过', level: 'L3' }, { key: 'prm-release', label: '采购放行', level: 'L5' }, ], + revisableAtRelease: [], prohibitions: [ { key: 'no-stockless-trader', statement: '现货场景不得选无库存转单商' }, { key: 'no-unverified', statement: '不实回价或远低行情的,必须换渠道补询,不得直接采用' }, @@ -110,8 +126,11 @@ export const manifest: ActivityManifest = { { domain: DOMAIN, object: 'supplier_quote', + kind: 'record', + caseKey: 'rfq_ref', label: '供应商回价单', fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'pn', type: 'text', primaryKey: true, masked: false }, { name: 'supplier', type: 'text', primaryKey: true, masked: false, label: '供应商' }, { name: 'price', type: 'number', primaryKey: false, masked: false, label: '回价' }, @@ -124,14 +143,39 @@ export const manifest: ActivityManifest = { { domain: DOMAIN, object: 'compare_table', + kind: 'record', + caseKey: 'rfq_ref', label: '寻源比价表', fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, { name: 'pn', type: 'text', primaryKey: true, masked: false }, { name: 'supplier', type: 'text', primaryKey: false, masked: false }, { name: 'price', type: 'number', primaryKey: false, masked: false }, { name: 'conclusion', type: 'text', primaryKey: false, masked: false, label: '结论' }, ], }, + { + /* + * 寻源结论 —— 一单一行的**汇总表**,验收机检谓词的入参就在这儿。 + * 汇总列与逐行列**故意不同名**:任务视图把草稿行摊平到顶层是「后写的赢」, + * 拿逐行列求值只会验到最后写的那一行,那不叫机检、叫抽样(A1 的教训)。 + */ + domain: DOMAIN, + object: 'verdict', + kind: 'record', + caseKey: 'rfq_ref', + label: '寻源结论', + fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' }, + { name: 'pn', type: 'text', primaryKey: false, masked: false, label: '型号' }, + { name: 'supplier_total', type: 'integer', primaryKey: false, masked: false, label: '已询渠道数' }, + { name: 'quoted_total', type: 'integer', primaryKey: false, masked: false, label: '已回价条数' }, + { name: 'unquoted_total', type: 'integer', primaryKey: false, masked: false, label: '未回价条数' }, + { name: 'compare_total', type: 'integer', primaryKey: false, masked: false, label: '比价条数' }, + { name: 'recommend_supplier', type: 'text', primaryKey: false, masked: false, label: '推荐供应商' }, + { name: 'summary', type: 'text', primaryKey: false, masked: false, label: '寻源结论' }, + ], + }, ], listeners: [ { @@ -167,6 +211,8 @@ export const manifest: ActivityManifest = { // 双表示同版本:XML 是机器面,说明书是叙事面 —— 不允许分叉 narrative: NARRATIVE, dmn: STRATEGY_DMN, + /* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */ + steps: SOURCING_STEPS, }, skills: SOURCING_SKILLS, viewTemplates: [{ name: 'case-overview', html: SOURCING_CASE }], diff --git a/activities/quote-sourcing/src/views/report.ts b/activities/quote-sourcing/src/views/report.ts index 71b3b49..6f4a810 100644 --- a/activities/quote-sourcing/src/views/report.ts +++ b/activities/quote-sourcing/src/views/report.ts @@ -1,25 +1,101 @@ /** - * A3 视图模版 —— 案例全景(AM 07:loop 按案例模型的**阶段与里程碑**组织汇报)。 + * A3 渠道寻源的 HTML 汇报模版 —— **活动自己持有的资产**(§9.3),随包版本冻结。 * - * 画面素材全部来自语义引擎:阶段的生命周期态、当下可为(Enabled 集)、目标缺口。 - * 这些是算出来的确定事实,零 LLM 即可成屏 —— 模型只决定「下一步做什么」,不负责画面。 + * ── 这一屏给谁看、他此刻要判什么 ────────────────────────────────── + * 看的人是采购(PRM)与等着底价的 PM。他此刻只判一件事:**这价能不能拿去往下走**。 + * 于是版面按「先成果、后局面」排: + * 一 供应商回价(收回来多少、都是什么渠道)· 二 寻源比价表(交回 PM 的成果本体) + * → 三 案例阶段 · 四 当下可为 · 五 目标缺口(AI 的办案局面) + * 指标带扫一眼就该有结论(回价够不够、比价表填没填),往下五节是「凭什么这么说」。 + * + * ── 画面上的数从哪来(一个都不是编的)──────────────────────────── + * · `mine:quote_sourcing.*` 是**本单自己写下的草稿行** —— 别单放行过的回价不混进来; + * 列名逐字取自 `declarations.ledger`,编一个不存在的列名 = 屏上永远空白。 + * · `case:*` / `enabledCount` / `gapCount` / `activeStage` 由语义引擎每拍算出, + * 是确定事实、零 LLM 即可成屏 —— 模型只决定「下一步做什么」,不负责画面。 + * · 指标带上「已收回价 / 比价表行数」两格指的是**数组本身**:填数时数组取长度 + * (view-render 的 `displayValue` 约定),不必为「几单」另立一个计数字段。 + * + * ── 两条不撒谎的纪律 ────────────────────────────────────────── + * ① 右上角挂的是「在办」灯不是印章:这一屏是活的局面,它没盖过章,就别画成盖了章的样子; + * 紫(tone `case`)是**案例活动的标记色不是状态色** —— 红绿橙只留给「达标没达标」。 + * ② 表空着就是「本单还没写下这一行」,不是渲染坏了 —— 每张表的说明里都写明空着意味着什么。 + * 寻源尤其怕这个:空白被读成「这家没报价」,而它可能只是还没登记。 + * + * 容器是 `sandbox=""` iframe:无脚本、无外链,样式与图标由 `@sdk` 自带。 */ -import { kvCard, noteCard, stagesCard, viewShell } from '@sdk'; +import { noteOnly, section, stagesOnly, statsBand, tableOnly, viewShell } from '@sdk'; export const SOURCING_CASE = viewShell({ title: '渠道寻源 · 案例全景', - kicker: 'A3 · 案例活动(loop)', + kicker: 'A3 · 渠道寻源 · 案例活动', tone: 'case', + icon: 'mail', + stamp: { kind: 'live', text: '案例在办 · 非定稿' }, + sign: 'A3 渠道寻源 · 本页每拍按当下局面重画', body: - kvCard('本单标的', [ - ['型号 PN', 'pn', 'mono'], - ['场景', 'scenario'], - ['当前阶段', 'activeStage', 'tag'], + statsBand([ + ['已收回价', 'mine:quote_sourcing.supplier_quote'], + ['比价表行数', 'mine:quote_sourcing.compare_table'], ['当下可为', 'enabledCount'], ['目标缺口', 'gapCount'], ]) + - stagesCard('阶段(CMMN)', 'case:stages', '阶段的生命周期态由语义引擎推进到 Enabled 为止;凡触达世界或产出关键交付的计划项必须人工启动 —— Enabled ≠ Active。') + - stagesCard('当下可为', 'case:enabled', 'AI 只能在这个集合里选下一步:可以越谱,不可越权。') + - stagesCard('目标缺口', 'case:gaps', '缺口只有人知道 → 调人;缺口在世界侧 → 立监听(必配时限)。当下可为 ∩ 目标所需 = 空,就是依赖断点。') + - noteCard('这个活动为什么是 loop', '寻源是一事一议:几家供应商、谁先回、要不要追加一轮,事前排不出谱。调度权交给 AI,系统只保证边框 —— 预算、停滞熔断、放行归人。'), + section('供应商回价(本单登记)', + tableOnly('mine:quote_sourcing.supplier_quote', [ + ['型号 PN', 'pn', 'mono'], + ['供应商', 'supplier'], + ['渠道等级', 'grade'], + ['回价', 'price', 'mono'], + ['交期', 'lead_time'], + ['有效期', 'valid_until', 'mono'], + ]) + + noteOnly( + '回价逐单登记、一一对应不可缺项:发出去几家就该收回几条,没回音的也要留一条写明' + + '「无货 / 未应答」—— 少一条,下游读到的就是「没这回事」。渠道等级是场景尺度落到纸面的地方:' + + 'BOM后期按主 → 备选 → 贸易商圈 2~3 家、贸易商尽量不用,现货只选有库存的。' + + '「有效期」是回价到期监听盯的那一列,过期即触发重询。表空着,是本单还没登记回价。', + ), + { text: '一料一渠一行' }, + ) + + section('寻源比价表(本单交付件)', + tableOnly('mine:quote_sourcing.compare_table', [ + ['型号 PN', 'pn', 'mono'], + ['供应商', 'supplier'], + ['回价', 'price', 'mono'], + ['结论', 'conclusion'], + ]) + + noteOnly( + '这是交回 PM 的成果本体:一料一结论,多渠道横比定底。不实回价或远低行情的必须换渠道补询,' + + '不得直接采用;现货场景不得选无库存转单商。采用策略由判定表出(直采 / 取均价 / 重询),' + + '不是模型自己说了算。表空着,是本单还没走到反馈交付。', + ), + { text: '交回 PM 的成果' }, + ) + + section('案例阶段(CMMN 生命周期)', + stagesOnly('case:stages') + + noteOnly( + '阶段的生命周期态由语义引擎推进到 Enabled 为止;凡触达世界或产出关键交付的计划项' + + '必须人工启动 —— Enabled ≠ Active。寻源是一事一议:几家供应商、谁先回、' + + '要不要追加一轮,事前排不出谱,所以调度权交给 AI,系统只保证边框:预算、停滞熔断、放行归人。', + ), + { text: '推进到 Enabled 为止' }, + ) + + section('当下可为(AI 的合法选项)', + stagesOnly('case:enabled') + + noteOnly( + 'AI 只能在这个集合里选下一步:可以越谱,不可越权。发询价信是触达世界的动作 ——' + + '标「待启动」的项要人按一下再动,信发出去就收不回来。这一栏空着不是它偷懒,' + + '是当下确实无可为:要么在等人回话,要么在等供应商的回音。', + ), + { text: '本拍的合法选项' }, + ) + + section('目标缺口(离交付还差什么)', + stagesOnly('case:gaps') + + noteOnly( + '缺口只有人知道 → 调人;缺口在世界侧(等供应商回价)→ 立监听,时限必配 ——' + + '没有时限的等待就是无限期挂起,而回价的时限本就写死在询价信里。' + + '当下可为 ∩ 目标所需 = 空,就是依赖断点。这一栏空着,是必做的事都办完了。', + ), + { text: '空 = 必做的都办完了' }, + ), }); diff --git a/api-report/apps/api/src/modules/activity-registry/index.d.ts b/api-report/apps/api/src/modules/activity-registry/index.d.ts index 2da6f47..7509263 100644 --- a/api-report/apps/api/src/modules/activity-registry/index.d.ts +++ b/api-report/apps/api/src/modules/activity-registry/index.d.ts @@ -6,7 +6,8 @@ export { ActivityNotInstalledError, InstallRejectedError, ToolNotDeclaredError } export type { Clock, IdGenerator, LedgerDeclarationApplier, ModelBindingProbe, RegistryStore, TemplateGuard, ToolContractProbe, } from './port.ts'; export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts'; export { createRegistryService } from './internal/usecase/registry-service.ts'; -export { acceptanceLevelFindings, auditPassed, danglingListeners, loopFindings, scanArtifact, staticAudit, } from './internal/domain/static-audit.ts'; +export type { AcceptanceAuditInput, DeliverableAuditInput } from './internal/domain/static-audit.ts'; +export { acceptanceLevelFindings, deliverableAddressFindings, ledgerKindFindings, auditPassed, danglingListeners, loopFindings, scanArtifact, staticAudit, } from './internal/domain/static-audit.ts'; export { ASSET_KINDS, assertTransition, buildLoadEnvelope, canTransition, IllegalIncrementTransitionError, INCREMENT_STATES, judgeReplay, mayTakeEffect, shouldSeedAsset, } from './internal/domain/versioning.ts'; export { createRegistryStore } from './internal/adapter/registry-store.ts'; export { activityAssetTable, activityPackageTable, assetIncrementTable } from './internal/adapter/registry-schema.ts'; diff --git a/api-report/apps/api/src/modules/human-tool/contract.d.ts b/api-report/apps/api/src/modules/human-tool/contract.d.ts index 8b2b693..521a32c 100644 --- a/api-report/apps/api/src/modules/human-tool/contract.d.ts +++ b/api-report/apps/api/src/modules/human-tool/contract.d.ts @@ -54,6 +54,8 @@ export type IssuedChatCard = { }; export type ReplyOutcome = { readonly order: WorkOrder; + /** 这张卡挂在哪一单 —— 装配层要拿它把「人就地改的那几行」写回草稿层 */ + readonly taskId: TaskId; /** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */ readonly producesCompletion: boolean; /** 驳回:环内回转,任务继续 */ @@ -78,9 +80,39 @@ export type HumanToolService = { }): Promise; issueChatCard(context: IssueContext, card: ChatCard): Promise; replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise; - replyChatCard(reply: ChatCardReply, by: StaffId): Promise; + /** + * 回传带上 `taskId` —— **数据录入卡的确认要当场写草稿**,而写库要知道写给哪一单。 + * 由本模块自己写会让 human-tool 依赖 ledger(越层),所以把事实交出去,由组装层落笔。 + */ + replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{ + card: ChatCard; + taskId: TaskId; + }>; /** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */ rejectAndReask(cardId: string, reason: string): Promise; + /** + * 给已发出的验收工单补上选择组(AM 1.2 恰选一口)。 + * + * 为什么分两步:卡由 `release.submitForRelease` 发 —— 它守的是预检那道门, + * 不该也不必知道「这个活动有哪些出口」。出口是契约的事实,由 app 从 manifest 现读后贴上。 + */ + attachPortChoice(workOrderId: string, choice: { + group: string; + ports: ReadonlyArray<{ + name: string; + label?: string | undefined; + }>; + }): Promise; + /** + * **算出来的去向**上卡 —— 不给人选,只告诉他走哪一口、凭哪一条。 + * 去向是台账那几个数的函数;摆成选择题,等于把可回放的判定换成一次手感。 + */ + attachRoutedPort(workOrderId: string, routed: { + group: string; + name: string; + label?: string | undefined; + because: string; + }): Promise; openWorkOrders(taskId: TaskId): Promise; /** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */ allWorkOrders(taskId: TaskId): Promise; diff --git a/api-report/apps/api/src/modules/human-tool/port.d.ts b/api-report/apps/api/src/modules/human-tool/port.d.ts index 0cc6664..d6b4593 100644 --- a/api-report/apps/api/src/modules/human-tool/port.d.ts +++ b/api-report/apps/api/src/modules/human-tool/port.d.ts @@ -17,6 +17,8 @@ export type CardStore = { } | undefined>; listWorkOrders(taskId: TaskId, openOnly: boolean): Promise; markWorkOrderDone(id: string, done: NonNullable): Promise; + /** 局部改卡面 —— 只增不改是对**已回传**的卡;未答的卡还可补齐契约事实(如选择组) */ + patchWorkOrder(id: string, patch: Partial): Promise; saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise; getChatCard(id: string): Promise<{ taskId: TaskId; diff --git a/api-report/apps/api/src/modules/interaction/contract.d.ts b/api-report/apps/api/src/modules/interaction/contract.d.ts index d7e9bb0..e52f6ab 100644 --- a/api-report/apps/api/src/modules/interaction/contract.d.ts +++ b/api-report/apps/api/src/modules/interaction/contract.d.ts @@ -50,17 +50,36 @@ export type InteractionService = { /** 视图流控:活动按局面推结构化块 */ pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise; /** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */ - pushViewHtml(taskId: TaskId, html: string): Promise; + /** + * 推一帧 HTML。`template` **要一起存下来** —— 件是投影,而投影得能按当下数据**重渲**。 + * + * 只存渲染好的 HTML,那一帧就成了快照:人在件上改完几格,由数据算出来的东西 + * (类徽 A/B/C、分布条、结论横幅那条去向)全停在渲染那一刻 —— + * 屏上一半新一半旧,那不是没更新,是界面在撒谎。记住模版名,才重得出来。 + */ + pushViewHtml(taskId: TaskId, html: string, template?: string): Promise; /** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */ viewOf(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string; + template?: string; } | undefined>; /** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */ setPhase(taskId: TaskId, phase: InteractionPhase): Promise; phaseOf(taskId: TaskId): Promise; /** SSE:单连接、按信封目标失效缓存 */ subscribe(staffId: StaffId, onPush: (envelope: PushEnvelope) => void): Subscription; + /** + * **系统级转接**:装配层把所有推送接出去(转给前端的 SSE 广播),不按人筛。 + * + * 为什么不复用 `subscribe`:那个要一个收件人,而转接是系统的事 —— + * 为它编一个假员工 id 是撒谎。不按人筛也不漏:推送**不带业务载荷** + * (只说「哪个任务动了」),谁该看见由前端自己那几把查询决定(§9.2 前端零过程状态)。 + * + * 没有这个口子的后果实测过:AI 说的每一句、推的每一帧画面都广播给了空气 —— + * `subscribe` 一个订阅者都没有,前端只好靠人点刷新。 + */ + onPush(listener: (envelope: PushEnvelope) => void): Subscription; /** 供上层广播失效信号 */ invalidate(target: 'task' | 'workflow' | 'desk', id: string): void; }; diff --git a/api-report/apps/api/src/modules/interaction/port.d.ts b/api-report/apps/api/src/modules/interaction/port.d.ts index e09fc13..d4c64fb 100644 --- a/api-report/apps/api/src/modules/interaction/port.d.ts +++ b/api-report/apps/api/src/modules/interaction/port.d.ts @@ -16,10 +16,12 @@ export type InteractionStore = { putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string; + template?: string; }): Promise; getView(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string; + template?: string; } | undefined>; setPhase(taskId: TaskId, phase: InteractionPhase): Promise; phaseOf(taskId: TaskId): Promise; diff --git a/api-report/apps/api/src/modules/ledger/contract.d.ts b/api-report/apps/api/src/modules/ledger/contract.d.ts index 6dc50b7..e4ec979 100644 --- a/api-report/apps/api/src/modules/ledger/contract.d.ts +++ b/api-report/apps/api/src/modules/ledger/contract.d.ts @@ -51,6 +51,12 @@ export type DraftWrite = { export type ReleaseWriteResult = { readonly mergedRows: number; readonly emittedEvents: number; + /** + * **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎: + * 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。 + * 非试跑单恒为 0。 + */ + readonly simulatedRows: number; /** * 合并进正式版的那些行(键 + 值)。 * 「落地收敛,输出口才发射」——发射的载荷就该是**这些已落地的事实**, @@ -78,6 +84,22 @@ export type LedgerService = { }>>; systemWrite(write: SystemWrite): Promise; draftWrite(write: DraftWrite): Promise; + /** + * **撤掉本单草稿层的一行** —— 写错了得有退路(2026-08-01 加)。 + * + * 不是第四种写:它只作用在**本单草稿层**,正式版一个字都动不了,也不抛事件 —— + * 撤的是「我这一单还没过门的那一笔」。放行门的位置没变。 + * + * 没有它的后果实测过:客户说本轮跳过的那颗料被写进了有效清单,条目数报 9 而表里躺着 10, + * 判据 `record_rows = line_total` 正确地拦住,而模型**改不动** —— + * 它唯一能改的是那个数(改了 skipped_total 又不对),13 次拒回、23 拍、234k token 才熔断。 + * 门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。 + */ + draftDrop(drop: { + taskId: TaskId; + address: ObjectAddress; + key: Record; + }): Promise; releaseWrite(taskId: TaskId): Promise; /** 正式版查询 —— 跨任务只见过门的事实 */ query(query: LedgerQuery): Promise; diff --git a/api-report/apps/api/src/modules/ledger/port.d.ts b/api-report/apps/api/src/modules/ledger/port.d.ts index 6bbe988..c6db05d 100644 --- a/api-report/apps/api/src/modules/ledger/port.d.ts +++ b/api-report/apps/api/src/modules/ledger/port.d.ts @@ -1,7 +1,7 @@ /** * module: ledger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。 */ -import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow } from '@contracts'; +import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow, LedgerObjectKind } from '@contracts'; import type { MaterializedObject, SchemaChange } from './internal/domain/schema-merge.ts'; import type { DraftEntry } from './internal/domain/task-view.ts'; export type Clock = { @@ -14,6 +14,20 @@ export type IdGenerator = { export type EventPublisher = { publish(envelope: EnvelopeDraft): Promise; }; +/** + * 这一单是不是**试跑** —— app 注入(设计台的两条试跑路径都算,见 `container.ts`)。 + * + * 台账在 L1,认不出「谁起的单」;而放行写要不要落主数据,取决的恰恰是这件事。 + * 所以由 app 把这一个布尔判断喂进来 —— 与 `EventPublisher` 同一个套路: + * 模块只声明「我需要知道什么」,不声明「去哪儿问」。 + * + * 为什么只挡主数据、不挡单据:单据按单标识分行,一次试跑写一行自己的, + * 清运行数据就没了;主数据是**按业务实体**分行的档案,试跑往里建一位客户, + * 「新客户」那条样例就此再也试不出来 —— 它成了老客户。 + */ +export type SimulationProbe = { + isSimulated(taskId: string): Promise; +}; export type ObjectRef = { readonly domain: string; readonly object: string; @@ -26,9 +40,18 @@ export type LedgerStore = { /** 已物化对象的列与主键;未物化返回 undefined */ describe(ref: ObjectRef): Promise; /** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */ + /** 记下对象性质(单据/主数据/派生)与单标识 —— 随声明走,不随建表走 */ + setObjectMeta(ref: ObjectRef, kind: LedgerObjectKind, caseKey: string | undefined): Promise; listObjects(): Promise>; + /** + * 哪些对象是**主数据** —— 放行写要据此把试跑单的主数据挡在正式表外。 + * + * 单独一个方法而不是让调用方 `listObjects` 自己筛:性质记在 `__object_meta` 里, + * 一次 where 就够;`describe` 读的是 information_schema,那里没有性质这一维。 + */ + masterRefs(): Promise>; /** 执行「只增不破坏」的变更计划 */ applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise; select(query: LedgerQuery): Promise<{ @@ -46,5 +69,13 @@ export type DraftStore = { list(taskId: string): Promise>; listForObject(taskId: string, ref: ObjectRef): Promise; put(taskId: string, ref: ObjectRef, entry: DraftEntry): Promise; + /** + * 撤掉本单草稿层的**一行** —— 写错了得有退路(2026-08-01 加)。 + * + * 只动 `taskId` 那一份,正式版碰不到。没有它的后果实测过:客户说本轮跳过的那颗料 + * 被写进了有效清单,条目数报 9 而表里躺着 10 —— 判据正确地拦住,而模型改不动 + * (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。 + */ + drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise; clear(taskId: string): Promise; }; diff --git a/api-report/apps/api/src/modules/llm/contract.d.ts b/api-report/apps/api/src/modules/llm/contract.d.ts index b8d82c5..4c4f12a 100644 --- a/api-report/apps/api/src/modules/llm/contract.d.ts +++ b/api-report/apps/api/src/modules/llm/contract.d.ts @@ -31,6 +31,12 @@ export type ModelCall = { readonly prompt: string; readonly temperature?: number; readonly maxOutputTokens?: number; + /** + * 压推理:`off` 不许想、`low` 少想;缺省由着模型。 + * 只对画像里声明了 `reasoningControl` 的厂家生效 —— 没姿势的厂家如实忽略。 + * 规划型调用(loop 单拍)该关:隐藏思考按秒计,而它的判断本就交给了判定表与工具。 + */ + readonly reasoning?: 'off' | 'low'; /** 出参 schema(JSON Schema);给定即要求结构化出参并校验 */ readonly outputSchema?: Record; /** 录制/回放的定位:任务 × 步序号(§7.3) */ diff --git a/api-report/apps/api/src/modules/loop-engine/contract.d.ts b/api-report/apps/api/src/modules/loop-engine/contract.d.ts index b0452f7..cb67d01 100644 --- a/api-report/apps/api/src/modules/loop-engine/contract.d.ts +++ b/api-report/apps/api/src/modules/loop-engine/contract.d.ts @@ -8,7 +8,8 @@ import type { LoopModel, TaskId } from '@contracts'; import type { CaseModel, PlanItem, PlanItemType } from './internal/domain/cmmn-parse.ts'; import type { CaseRuntime, CaseState, EnabledEntry, EnabledSet, ItemRuntime, ItemState, Navigation, TerminalAuthority } from './internal/domain/lifecycle.ts'; import type { DecisionResult, DecisionTrace, Gap } from './internal/domain/dmn.ts'; -export type { CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, }; +import type { PhaseProgress } from './internal/domain/phase.ts'; +export type { CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopModel, Navigation, PhaseProgress, PlanItem, PlanItemType, TerminalAuthority, }; export { CmmnParseError } from './internal/domain/cmmn-parse.ts'; export { IllegalTransitionError } from './internal/domain/lifecycle.ts'; export declare class ForbiddenTerminalError extends Error { @@ -28,6 +29,48 @@ export type AgentAction = readonly tool: string; readonly input: Record; } +/** + * 写台账草稿 —— **活动过程写的唯一形态**(铁律②:登记那一刻世界上什么都没变)。 + * + * 一拍可写多行:逐条把一份 BOM 解析成几十行,是**一件事**,不该拆成几十拍 + * 各烧一次模型。地址必须在活动清单声明过的台账对象之内(越域写与越权调工具同罪)。 + */ + | { + readonly kind: 'write'; + readonly itemId: string; + /** + * 台账地址「域.对象」—— **一个字符串,不是两个字段**。 + * + * 系统上下都用这一种写法(可写台账字典、`requiredDeliverables[].ledger` 都是它), + * 拆成 domain + object 只会让模型把整个地址塞进其中一格 —— 实测第一拍就这么错。 + */ + readonly address: string; + readonly rows: ReadonlyArray<{ + readonly key: Record; + readonly patch: Record; + }>; +} +/** + * **撤掉自己写错的那一行** —— 写错了得有退路(2026-08-01 加)。 + * + * 只作用在**本单草稿层**:正式版一个字都动不了,也不抛事件 —— + * 撤的是「我这一单还没过门的那一笔」,放行门的位置没变。 + * + * 起因是实测的一次死胡同:客户明说本轮跳过的那颗料被写进了有效清单, + * 条目数报 9(对)而表里躺着 10,判据 `record_rows = line_total` 正确地拦住 —— + * 而模型**改不动**:它唯一能改的是那个数,改了 `skipped_total` 又不对。 + * 13 次拒回、23 拍、234k token 才熔断。**门拦住一件办错的事却不给改正的手段, + * 那扇门就只是个死胡同。** + */ + | { + readonly kind: 'drop'; + readonly itemId: string; + /** 台账地址「域.对象」—— 与 write 同一种写法 */ + readonly address: string; + /** 要撤的那几行的主键 —— 认不出的行如实回「草稿层里没有这一行」 */ + readonly keys: ReadonlyArray>; + readonly reason: string; +} /** 调人:四卡之一 —— 调人 = 立监听听人 */ | { readonly kind: 'human'; @@ -35,6 +78,119 @@ export type AgentAction = readonly card: Record; readonly workOrder: 'execution' | 'acceptance'; } +/** + * **发补全卡** —— 对话卡族之一,与执行工单是两种东西。 + * + * 「按缺口清单追问补全」(A1-S2)要的就是它:缺口清单摆在对话区、带一张表单, + * 人就地填齐。而执行工单是「派一件活给你办」—— 派一张工单去问几个字段, + * 收卡的人得进待处理、办完再回来,重得不成比例。 + * + * 从前 Agent **发不出对话卡**:行动型里只有 `human` 且只能选执行 / 验收两种工单, + * 于是 SOP 上画着「补全卡」的那一步只能拿执行工单顶 —— 形似而神非。 + */ + | { + readonly kind: 'ask'; + readonly itemId: string; + /** 问什么 —— 一句话说清缺口 */ + readonly ask: string; + /** + * 要对方填的格子。`hint` 是格子里的灰字提示; + * **`value` 是预填值** —— AI 从来件里读得出的先替人填上(公司名、联系人姓名), + * 人只需核对一眼、补上填不出的那个,而不是对着一排空格从头敲。 + * 读不出来的就别给:填错了人还得先擦掉再写,比空着更费事。 + */ + readonly fields: ReadonlyArray<{ + readonly key: string; + readonly label: string; + readonly hint?: string; + readonly value?: string; + }>; + /** 依据快照:哪几条缺哪几个字段 —— 调用必附上下文,裸调用拒发 */ + readonly basis?: ReadonlyArray<{ + readonly k: string; + readonly v: string; + }>; +} +/** + * 发**数据录入卡** —— 人类工具的第五类:**人按下确认那一刻就是写入**。 + * + * 与补全卡(`ask`)的分工:那张收的是「答案」,你拿到之后还要自己 `write` 一遍; + * 这张收的是「这一行数据」—— 卡上写明落哪张表、哪个主键,人确认,基座当场落草稿层, + * **你不经手**。建档这类事该用它:主数据建错一次,后面每一单都跟着错, + * 那一笔该由人亲手落下,不该经你转述一遍。 + * + * 你的活是**把能填的先填好**(`value`),填不出的留空 —— 界面会标「待填写」。 + */ + | { + readonly kind: 'entry'; + readonly itemId: string; + readonly ask: string; + /** 落哪张表:`域.对象` */ + readonly ledger: string; + /** 主键值 —— 确认时按它定位那一行 */ + readonly key: Readonly>; + readonly fields: ReadonlyArray<{ + readonly key: string; + readonly label: string; + readonly value?: string; + readonly required?: boolean; + }>; + /** + * **同一次确认还要落的另几张表**(2026-08-02)。 + * + * 建一位新客户落的是**两条主数据**:公司档 + 联系人档。只把公司那一半摆上卡、 + * 联系人由你自己写,等于让人确认了一半 —— 而联系人档的 `contact_code` + * 恰恰是你编出来的那一个。分成两张卡也能办,但那是把一件事拆成两次打断。 + */ + readonly also?: ReadonlyArray<{ + /** 这一段是什么 —— 卡上那行小标题(如「联系人档案」) */ + readonly title: string; + readonly ledger: string; + readonly key: Readonly>; + readonly fields: ReadonlyArray<{ + readonly key: string; + readonly label: string; + readonly value?: string; + readonly required?: boolean; + }>; + }>; + readonly basis?: ReadonlyArray<{ + readonly k: string; + readonly v: string; + }>; +} +/** + * 发**决策卡** —— 对话卡族的第二种:把一个岔路口摆给人,人点一个选项。 + * + * 与补全卡的分工:补全卡问的是**事实**(缺哪几个字段,你填); + * 决策卡问的是**处置**(这一步该往哪走,你选)。A1 的 + * 「字段没齐 —— 要我系统外去问客户,还是替你草拟一封邮件」正是后者: + * 两条路都合法,选哪条是业务判断,不该由模型替人决定。 + * + * 从前决策卡**只有 UI 没有 Agent 侧**:卡面在对话区画得出来,而行动型里发不出它 —— + * 于是所有岔路口都被写成补全卡,人拿到的是一张要自己敲字的表单,而不是两个可点的选项。 + */ + | { + readonly kind: 'decide'; + readonly itemId: string; + /** 问什么 —— 一句话说清这是个什么岔路 */ + readonly ask: string; + /** 选项:一条路一项。`consequence` 写清选了它会发生什么 */ + readonly options: ReadonlyArray<{ + readonly value: string; + readonly label: string; + readonly detail?: string; + readonly consequence?: string; + readonly recommended?: boolean; + }>; + /** 依据快照:凭什么问这一问 —— 调用必附上下文,裸调用拒发 */ + readonly basis?: ReadonlyArray<{ + readonly k: string; + readonly v: string; + }>; + /** 允许人给出选项之外的处置(附理由) */ + readonly allowCustom?: boolean; +} /** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */ | { readonly kind: 'listen'; @@ -66,10 +222,38 @@ export type AgentAction = readonly satisfied: boolean; readonly rationale: string; } -/** 判定成果已齐,提交放行 */ +/** + * 这一项干完了 —— **完成由 Agent 自己宣告,不是某个动作的副作用**。 + * + * 从前 `tool` / `write` 一做完就把计划项收了。可一个计划项常常要好几个动作才交得出成果 + * (先逐条写记录、再结算总数;先调工具取判据、再把判据写进台账)—— + * 一收就再也动不了它,于是那一项该产出的东西永远缺着,而环在原地打转。 + * 实测:20 拍烧掉 120k token,卡在「当下可为 ∩ 目标所需 = 空」。 + * + * 宣告完成不等于成果被认可:终局仍唯放行,人验收才算数。 + */ + | { + readonly kind: 'finish'; + readonly itemId: string; + readonly rationale: string; +} +/** + * 判定成果已齐,提交放行。 + * + * `fields` = 验收报告上**让人当场改**的那几个值(PRD:「AI 已填好、可编辑」)—— + * `ai` 是活动拟的值,`ledger` 是改完落回哪个台账地址(`域.对象`)。 + * 不给 `fields`,验收报告就只能看不能改;给了 `ai` 不给 `ledger`,人改了也只留在 + * 工单留痕里,落地用的还是活动的原值 —— 两样都得有,修订才落得下去。 + */ | { readonly kind: 'submit'; readonly rationale: string; + readonly fields?: ReadonlyArray<{ + readonly key: string; + readonly label: string; + readonly ai?: string; + readonly ledger?: string; + }>; } /** * 本回合**已在工具循环里调过人**:卡已发、监听已立(工具总线执行的)。 @@ -81,6 +265,41 @@ export type AgentAction = readonly cardId: string; readonly rationale: string; }; +/** + * **对人说的一句话** —— 交代进展,并可顺手换一张局面画面。 + * + * 它**不是一型行动,是每一拍的附带** —— 这一点是改过的:一开始把它做成独立行动, + * 结果它占一整拍又不推进局面,预算下的 Agent 理所当然跳过它,对话区照旧空白。 + * 让说话与干活抢拍,是把「必须说」设计成了「有空才说」。 + * + * 现在:一拍 = 一个行动 + 一句交代。说话不花额外的拍,也就没有不说的理由。 + * `view` 是可选的局面模版名 —— 局面变了,人既要听到一句为什么,也要看到新画面。 + */ +export type Narration = { + readonly text: string; + /** 语气签:指导 / 判据 / 结论 —— 前端各有其样式 */ + readonly tag?: 'guidance' | 'criterion' | 'conclusion'; + readonly view?: string; +}; +/** + * 一拍的产出:**一串行动**(按序执行)+ 对人交代一句。 + * + * 为什么是一串不是一件:一拍只许一个行动时,每个 write、每个 finish、每个 start + * 都单烧一次完整模型调用(说明书 + 轨迹整段重读)。实测 A1 六步的活恰好花 21 拍 —— + * 其中 19 拍是这个约束下的**最短路径**,浪费不在模型跑偏,在架构按拍计费。 + * 收成一串后,「写记录、写结算、finish、start 下一项」一口气给,一单 5-8 拍收得了口。 + * + * 执行纪律(引擎强制,不靠模型自觉): + * · 按序执行,谁被拒就停在谁那儿 —— 已执行的前缀落笔留痕,理由喂回下一拍; + * · `tool` 之后截断 —— 出参没看见,后面的行动是闭着眼计划的; + * · `ask` / `decide` / `human` / `listen` / `submit` 让本拍挂起 —— 只能是最后一个; + * · 一串封顶 `TURN_ACTION_CAP` 件,超出的直接丢弃(留痕)。 + */ +export declare const TURN_ACTION_CAP = 6; +export type AgentTurn = { + readonly actions: readonly AgentAction[]; + readonly say?: Narration; +}; export type TurnOutcome = { readonly runtime: CaseRuntime; readonly enabled: EnabledSet; @@ -96,6 +315,14 @@ export type LoopEngineService = { /** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */ load(taskId: TaskId, model: LoopModel): Promise; runtime(taskId: TaskId): Promise; + /** + * 进度刻度:这一单在第几阶段(见 `internal/domain/phase.ts`)。 + * + * 由 loop 出而不是让调用方自己算:阶段声明与计划项状态**同在一行运行时里**, + * 调用方要自己算就得先把模型和运行时都捞出来 —— 那是把内部结构摊给外面。 + * 活动没声明阶段就回 `undefined`:界面照此不画进度条,不编一个出来。 + */ + phaseOf(taskId: TaskId, terminal: boolean): Promise; /** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */ publish(taskId: TaskId): Promise; /** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */ diff --git a/api-report/apps/api/src/modules/loop-engine/index.d.ts b/api-report/apps/api/src/modules/loop-engine/index.d.ts index e5fa97c..98b217b 100644 --- a/api-report/apps/api/src/modules/loop-engine/index.d.ts +++ b/api-report/apps/api/src/modules/loop-engine/index.d.ts @@ -1,13 +1,13 @@ /** * module: loop-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 */ -export type { AgentAction, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts'; -export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, } from './contract.ts'; +export type { AgentAction, AgentTurn, Narration, CaseModel, CaseRuntime, CaseState, DecisionResult, DecisionTrace, EnabledEntry, EnabledSet, Gap, ItemRuntime, ItemState, LoopEngineService, LoopModel, Navigation, PlanItem, PlanItemType, TerminalAuthority, TurnOutcome, } from './contract.ts'; +export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, TURN_ACTION_CAP, } from './contract.ts'; export type { AgentPort, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts'; export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts'; export { createTurnRunner } from './internal/usecase/turn-runner.ts'; export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts'; -export { assertItemTransition, autoCompleteReady, canTransitionCase, canTransitionItem, CASE_STATES, evaluateCriterion, initialRuntime, ITEM_STATES, mayCompleteCase, mayTerminateCase, navigate, occur, publishEnabled, reenter, transitionItem, } from './internal/domain/lifecycle.ts'; +export { assertItemTransition, achieveMilestones, autoCompleteReady, canTransitionCase, canTransitionItem, criterionKey, readingsOf, CASE_STATES, evaluateCriterion, initialRuntime, ITEM_STATES, mayCompleteCase, mayTerminateCase, navigate, occur, publishEnabled, reenter, transitionItem, } from './internal/domain/lifecycle.ts'; export { deduceGaps, evaluateTable, evaluationOrder, isBindingConclusion } from './internal/domain/dmn.ts'; export { createLoopStateStore } from './internal/adapter/loop-state-store.ts'; export { loopStateTable } from './internal/adapter/loop-state-schema.ts'; diff --git a/api-report/apps/api/src/modules/loop-engine/port.d.ts b/api-report/apps/api/src/modules/loop-engine/port.d.ts index 3ae272a..3683729 100644 --- a/api-report/apps/api/src/modules/loop-engine/port.d.ts +++ b/api-report/apps/api/src/modules/loop-engine/port.d.ts @@ -4,7 +4,7 @@ import type { LoopModel, StaffId, TaskId } from '@contracts'; import type { CaseModel } from './internal/domain/cmmn-parse.ts'; import type { CaseRuntime, EnabledSet } from './internal/domain/lifecycle.ts'; -import type { AgentAction } from './contract.ts'; +import type { AgentTurn } from './contract.ts'; export type Clock = { now(): Date; }; @@ -27,13 +27,53 @@ export type Sfeel = { /** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */ export type TaskDataGateway = { view(taskId: TaskId): Promise>; + /** + * 本活动**声明过**的台账对象(域.对象 → 列名与中文标)。 + * + * 两用:喂给 Agent(不知道能写哪儿就只会瞎猜列名),以及校验 `write` 行动 —— + * 没声明过的地址一律拒写。越域写与越权调工具同罪,都得在结构上拦住。 + */ + writable(taskId: TaskId): Promise; + }>>; + /** + * 写草稿,并回报**这一笔到底有没有改变什么**。 + * + * 值和上次一模一样的写是空转。不回报,Agent 就看不出自己在原地打转 —— + * 实测:同一行 verdict 被反复重写十几拍,每拍烧一次模型,预算就这么淌完。 + */ draftWrite(input: { taskId: TaskId; domain: string; object: string; key: Record; patch: Record; - }): Promise; + }): Promise<{ + changed: boolean; + }>; + /** + * **撤掉本单草稿层的一行** —— 写错了得有退路。 + * + * 回 `false` = 草稿层里根本没有这一行(多半是主键写法对不上, + * `DAG…` 与 `DAG…-R0` 是两行)。如实说比静默成功有用:静默成功会让模型 + * 以为撤掉了,下一拍判据照旧不过,它又去撤一次。 + */ + draftDrop(input: { + taskId: TaskId; + domain: string; + object: string; + key: Record; + }): Promise<{ + dropped: boolean; + }>; }; /** * Agent 回路 —— app 注入(Mastra Agent 在 adapter,§6.1)。 @@ -46,7 +86,32 @@ export type AgentPort = { narrative: string; enabled: EnabledSet; scope: Record; - }): Promise; + /** + * 这一单能调什么 —— **声明 × 目录**编译出来的工具清单(`compileToolBus`)。 + * + * 不给它这份清单,它就只知道说明书里提过的工具名,连入参长什么样都得猜 —— + * 实测的后果是:该调工具的那一步改成了发一张卡问人(「请为以下三个PN查询历史价格」), + * 明明系统里就有这件工具。工具是声明式的,清单得摆在它眼前。 + */ + tools: Array<{ + id: string; + description: string; + input: Record; + effect: 'read' | 'write'; + }>; + /** 本活动可写的台账地址与列名 —— 不给它字典,它只会照想象编列名 */ + writable: Array<{ + domain: string; + object: string; + label: string; + keys: string[]; + fields: Array<{ + name: string; + label: string; + type?: string; + }>; + }>; + }): Promise; }; /** 工具 / 人类工具 / 监听 —— 三型行动的落点 */ export type ToolGateway = { @@ -61,6 +126,16 @@ export type ToolGateway = { error?: { message: string; }; + /** + * 已转人代办,等人回填(§6.2)——「人是工具的一种实现」。 + * + * 系统里没有这件工具时,不是失败:基座已开了一张执行卡并立了监听。 + * 这一拍该**挂起等人**,不该把计划项判 Failed —— 判 Failed 等于告诉 Agent + * 「这条路走不通,换一条」,可这条路明明正在走,只是走的人换成了人。 + */ + awaitingHuman?: { + readonly workOrderId: string; + }; }>; isWriteEffect(tool: string): Promise; registerIntent(input: { @@ -69,6 +144,13 @@ export type ToolGateway = { input: Record; label: string; }): Promise; + /** 这一单能调什么(声明 × 目录)—— 单拍路径与开环路径吃同一份清单 */ + busOf(activityId: string): Promise; + effect: 'read' | 'write'; + }>>; }; /** * 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict —— @@ -80,6 +162,39 @@ export type PendingAcceptance = { readonly selectedPorts?: Record; }; export type CardGateway = { + /** 数据录入卡 —— 人确认那一下由基座直接写草稿层,不回到 Agent 手里 */ + entry(input: { + taskId: TaskId; + ownerId: StaffId; + humanCallCap: number; + ask: string; + ledger: string; + key: Readonly>; + fields: ReadonlyArray<{ + key: string; + label: string; + value?: string; + required?: boolean; + }>; + /** 同一次确认还要落的另几张表 —— 建一位新客户 = 公司档 + 联系人档 */ + also?: ReadonlyArray<{ + title: string; + ledger: string; + key: Readonly>; + fields: ReadonlyArray<{ + key: string; + label: string; + value?: string; + required?: boolean; + }>; + }>; + basis?: ReadonlyArray<{ + k: string; + v: string; + }>; + }): Promise<{ + id: string; + }>; issue(input: { taskId: TaskId; ownerId: StaffId; @@ -89,6 +204,56 @@ export type CardGateway = { }): Promise<{ id: string; }>; + /** + * 发**补全卡**(对话卡族)—— 缺口清单摆在对话区、带一张表单,人就地填齐。 + * + * 与执行工单分开是因为它们是两件事:工单是「派一件活给你办」, + * 派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。 + * SOP 上画着「补全卡」的那一步(A1-S2),要的就是这一件。 + */ + ask(input: { + taskId: TaskId; + ownerId: StaffId; + humanCallCap: number; + ask: string; + fields: ReadonlyArray<{ + key: string; + label: string; + hint?: string; + }>; + basis?: ReadonlyArray<{ + k: string; + v: string; + }>; + }): Promise<{ + id: string; + }>; + /** + * 发**决策卡**(对话卡族)—— 岔路口摆给人**点一个**,不是让人敲字。 + * + * 与补全卡的分工:补全卡问事实(缺哪几个字段,你填);决策卡问处置(往哪走,你选)。 + * 两条路都合法、选哪条是业务判断的时候,该发的是这一件。 + */ + decide(input: { + taskId: TaskId; + ownerId: StaffId; + humanCallCap: number; + ask: string; + options: ReadonlyArray<{ + value: string; + label: string; + detail?: string; + consequence?: string; + recommended?: boolean; + }>; + basis?: ReadonlyArray<{ + k: string; + v: string; + }>; + allowCustom?: boolean; + }): Promise<{ + id: string; + }>; }; export type ListenerRegistrar = { register(input: { @@ -107,12 +272,21 @@ export type ListenerRegistrar = { * 零 LLM 即可成屏;模型只决定「下一步做什么」,不负责画面。 */ export type ViewGateway = { + /** + * 推一帧画面。**回报推没推上** —— 从前是 `Promise` 且模版查不到就静默 return: + * 引擎每拍推一个硬编码的 `case-overview`,而 A1 声明的四张模版一个都不叫这个名字, + * 于是**每一帧都被悄悄丢掉**,工作区从头到尾空白,日志里也没有一个字。 + * 静默失败比报错坏得多:它让「没画面」看起来像「活动还没产出」。 + */ push(input: { taskId: TaskId; activityId: string; template: string; scope: Record; - }): Promise; + }): Promise<{ + pushed: boolean; + templates: string[]; + }>; }; /** * 技能 —— app 注入 `skill-engine`(L4)。 @@ -134,7 +308,7 @@ export type SkillGateway = { export type MemoryRecorder = { append(input: { taskId: TaskId; - kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation'; + kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation' | 'narration'; title: string; detail?: Record; }): Promise; @@ -143,10 +317,19 @@ export type MemoryRecorder = { layer: string; statement: string; }>>; - /** 本任务自己的最近轨迹(窗口大小由活动声明的 memoryWindow 定)—— 喂回叙事面,Agent 每拍不失忆 */ - window(taskId: TaskId): Promise | undefined; }>>; }; export type TaskContext = { @@ -155,4 +338,14 @@ export type TaskContext = { ownerId: StaffId; humanCallCap: number; }>; + /** + * 这一单的**机检判据名**(归一化后的 key 与 label 都在里头)。 + * + * 给 `judge` 行动当门用:L1/L2 是拿 S-FEEL 对台账求值的,Agent 裁不动它 —— + * 判据投影压根不读 Agent 对机检项的裁定。可从前引擎照收不误,还往轨迹里 + * 写一句「语义性前提评估:XXX → 满足」:模型于是以为这条已经过了, + * 转头就 submit,被门以同一条判据拒回;再裁、再拒,连拒五拍熔断(实测 A2 死于此)。 + * 轨迹里那句「满足」是**假话** —— 界面不撒谎,对 Agent 也一样。 + */ + machineCriteria(taskId: TaskId): Promise>; }; diff --git a/api-report/apps/api/src/modules/release/contract.d.ts b/api-report/apps/api/src/modules/release/contract.d.ts index 393e63f..774daf6 100644 --- a/api-report/apps/api/src/modules/release/contract.d.ts +++ b/api-report/apps/api/src/modules/release/contract.d.ts @@ -28,6 +28,11 @@ export type SubmitForReleaseInput = { readonly criteria: readonly CriterionCheck[]; /** 报告可修订:AI 拟值已填的表单字段 */ readonly fields?: ReadonlyArray>; + /** + * 验收页的正文块 —— 与执行工单同一套件,可含**绑台账的可编辑表**。 + * 验收是最重的一道门:人得能在成果本身上改,不只是改几个整单级字段。 + */ + readonly blocks?: ReadonlyArray>; }; export type SubmitOutcome = { readonly report: PrecheckReport; diff --git a/api-report/apps/api/src/modules/release/port.d.ts b/api-report/apps/api/src/modules/release/port.d.ts index 6c50205..98555ec 100644 --- a/api-report/apps/api/src/modules/release/port.d.ts +++ b/api-report/apps/api/src/modules/release/port.d.ts @@ -23,6 +23,8 @@ export type LedgerGateway = { releaseWrite(taskId: TaskId): Promise<{ mergedRows: number; emittedEvents: number; + /** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */ + simulatedRows: number; /** 合并进正式版的行 —— 输出口发射的载荷取自这里 */ merged: ReadonlyArray<{ domain: string; @@ -79,12 +81,27 @@ export type CardGateway = { label: string; level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5'; pass: boolean; + settled?: boolean | undefined; + rationale?: string | undefined; }>; actions: ReadonlyArray<{ label: string; effect: 'read' | 'write'; detail: string; }>; + /** + * 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。 + * **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开 + * 不做多余属性检查,于是编译不报、运行时静默丢 —— 卡面 `fields` 恒空(踩过)。 + */ + fields?: ReadonlyArray>; + /** + * 验收页的正文块 —— 绑台账的可编辑成果表走这里。 + * **端口不声明它,值就在装配层被丢掉**(与 `fields` 同一个坑,又踩了一次: + * 契约声明了、submit 传了、release 转了,到装配层那个显式对象里被抹平, + * 编译不报、运行时静默丢 —— 卡面 blocks 恒空)。 + */ + blocks?: ReadonlyArray>; }): Promise<{ id: string; }>; diff --git a/api-report/apps/api/src/modules/task/contract.d.ts b/api-report/apps/api/src/modules/task/contract.d.ts index 85f198f..29c3e64 100644 --- a/api-report/apps/api/src/modules/task/contract.d.ts +++ b/api-report/apps/api/src/modules/task/contract.d.ts @@ -32,6 +32,8 @@ export type Task = { /** 起单来源:工作流节点;登记触发的直起单为 undefined */ readonly workflowId?: string; readonly nodeId?: string; + /** `testbed` = 设计台试跑 —— 认得出才拔得掉(仿真不该在别人工作台上过夜) */ + readonly origin?: string; readonly ownerId: StaffId; readonly title: string; readonly runtime: TaskRuntimeState; @@ -52,6 +54,7 @@ export type StartTaskInput = { readonly title: string; readonly workflowId?: string; readonly nodeId?: string; + readonly origin?: string; /** 输入:data(按 schema 校验,缺项不得启动)+ message(委托消息,可为空) */ readonly data: Record; readonly message?: string; diff --git a/api-report/apps/api/src/modules/task/port.d.ts b/api-report/apps/api/src/modules/task/port.d.ts index 8fbcab4..23d50cc 100644 --- a/api-report/apps/api/src/modules/task/port.d.ts +++ b/api-report/apps/api/src/modules/task/port.d.ts @@ -26,10 +26,34 @@ export type ActivityLoader = { envelope: Record; /** 输入 data 的 JSON Schema —— 缺项不得启动 */ inputSchema: Record; + /** + * 这一单是被一封信叫起来的 —— 活动清单声明的**来件字段映射**。 + * 声明了,起单那一刻就把来件归进沟通记录(工作台那一屏)。 + */ + inboundComm?: { + channel: string; + partyField: string; + subjectField?: string | undefined; + bodyField?: string | undefined; + attachmentsField?: string | undefined; + }; businessModel: unknown; stallAfter: string; }>; }; +/** 沟通记录 —— 一单往来的原件存档(工作台「沟通记录」那一屏读它) */ +export type CommRecorder = { + record(input: { + taskId: TaskId; + direction: 'in' | 'out'; + channel: string; + party: string; + subject: string; + body: string; + attachments: readonly unknown[]; + note: string; + }): Promise; +}; export type SchemaValidator = { validate(schema: Record, value: unknown): { ok: boolean; @@ -76,8 +100,11 @@ export type LandingGateway = { export type MemoryRecorder = { append(input: { taskId: TaskId; - /** control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧 */ - kind: 'engine-transition' | 'terminal' | 'observation' | 'control'; + /** + * control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧。 + * trigger = 触发事件:这一单被什么叫起来的 —— 不是机器产出,也不是谁的动作。 + */ + kind: 'engine-transition' | 'terminal' | 'observation' | 'control' | 'trigger'; title: string; detail?: Record; }): Promise; diff --git a/api-report/apps/api/src/modules/toolbox/index.d.ts b/api-report/apps/api/src/modules/toolbox/index.d.ts index 49ceb17..7f24da1 100644 --- a/api-report/apps/api/src/modules/toolbox/index.d.ts +++ b/api-report/apps/api/src/modules/toolbox/index.d.ts @@ -3,15 +3,17 @@ */ export type { ActionIntent, AuditFinding, CallDisposition, EffectLevel, EvolutionViolation, InvocationTrace, InvokerMode, Preauthorization, RegisterToolInput, RegistrationVerdict, ToolCall, ToolContract, ToolInvoker, ToolResult, ToolboxService, } from './contract.ts'; export { INVOKER_MODES, RegistrationRejectedError, ToolDisabledError, ToolNotAllowedError, ToolNotFoundError, ToolReplayMissError, } from './contract.ts'; -export type { Clock, IdGenerator, SchemaValidator, SecretBox, ToolRegistry, ToolReplayStore, ToolRuntime, } from './port.ts'; +export type { Clock, IdGenerator, SchemaValidator, SecretBox, ToolHandoff, ToolHandoffStore, ToolRegistry, ToolReplayStore, ToolRuntime, } from './port.ts'; export type { ToolInvokerDeps } from './internal/usecase/tool-invoker.ts'; export { createToolInvoker, toolReplayKey } from './internal/usecase/tool-invoker.ts'; export type { ToolboxServiceDeps } from './internal/usecase/toolbox-service.ts'; export { createToolboxService } from './internal/usecase/toolbox-service.ts'; export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts'; export type { BusTool, CatalogEntry, ToolBusInput, ToolBusReport } from './internal/domain/tool-bus.ts'; -export { compileToolBus, HUMAN_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts'; +export { compileToolBus, HUMAN_TOOL, LEDGER_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts'; export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts'; +export type { HandoffField } from './internal/domain/handoff.ts'; +export { callKeyOf, coerceBySchema, handoffFields } from './internal/domain/handoff.ts'; export type { ToolHandler } from './internal/adapter/toolbox-store.ts'; -export { createSchemaValidator, createToolRegistry, createToolReplayStore, createToolRuntime, isAllowed, } from './internal/adapter/toolbox-store.ts'; +export { createSchemaValidator, createToolRegistry, createToolReplayStore, createToolHandoffStore, createToolRuntime, isAllowed, } from './internal/adapter/toolbox-store.ts'; export { replayToolTable, toolAllowlistTable, toolTable } from './internal/adapter/toolbox-schema.ts'; diff --git a/api-report/apps/api/src/modules/toolbox/port.d.ts b/api-report/apps/api/src/modules/toolbox/port.d.ts index 37de32c..6222120 100644 --- a/api-report/apps/api/src/modules/toolbox/port.d.ts +++ b/api-report/apps/api/src/modules/toolbox/port.d.ts @@ -43,6 +43,26 @@ export type ToolReplayStore = { * 工具执行 —— 实现对系统:server 侧执行代码,运行时受限, * **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。 */ +/** + * 工具转人台(§6.2)—— 人是工具的一种实现:发出去的卡、答回来的出参都记在这。 + * 内容地址 `callKey` 保证同一次调用只问人一次。 + */ +export type ToolHandoffStore = { + find(taskId: string, callKey: string): Promise; + open(handoff: Omit): Promise; + /** 人答完:值即出参 */ + answer(workOrderId: string, output: Record): Promise; +}; +export type ToolHandoff = { + readonly id: string; + readonly taskId: string; + readonly tool: string; + readonly callKey: string; + readonly input: Record; + readonly workOrderId: string; + readonly state: 'pending' | 'answered'; + readonly output?: Record | undefined; +}; export type ToolRuntime = { execute(input: { readonly contract: ToolContract; diff --git a/api-report/packages/activity-sdk/src/index.d.ts b/api-report/packages/activity-sdk/src/index.d.ts index a39b727..c507c0d 100644 --- a/api-report/packages/activity-sdk/src/index.d.ts +++ b/api-report/packages/activity-sdk/src/index.d.ts @@ -10,6 +10,6 @@ export { defineActivity } from './define.ts'; export type { ActivityMemoryLayer, ChatCardDraft, FormFieldDraft, LedgerKey, LedgerRow, ViewBlockLike, WorkOrderDraft, } from './types.ts'; export type { HostOptions, HostRecording, MockHost } from './host.ts'; export { createMockHost } from './host.ts'; -export type { Tone } from './view.ts'; -export { kvCard, noteCard, stagesCard, tableCard, viewShell } from './view.ts'; +export type { ColStyle, FactTone, Stamp, Tone } from './view.ts'; +export { actionList, bigCount, factCards, letter, meter, noteOnly, ruleTable, section, stagesOnly, statsBand, tableOnly, verdict, viewShell, } from './view.ts'; export declare const SDK_VERSION = "0.1.0-m5"; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 30c2b6c..f473b24 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -116,9 +116,9 @@ export declare const listGrantsRoute: { }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"governance">; function: z.ZodEnum<{ + trigger: "trigger"; data: "data"; task: "task"; - trigger: "trigger"; system: "system"; tool: "tool"; activity: "activity"; @@ -155,9 +155,9 @@ export declare const setGrantsRoute: { }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"governance">; function: z.ZodEnum<{ + trigger: "trigger"; data: "data"; task: "task"; - trigger: "trigger"; system: "system"; tool: "tool"; activity: "activity"; @@ -187,9 +187,9 @@ export declare const setGrantsRoute: { }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"governance">; function: z.ZodEnum<{ + trigger: "trigger"; data: "data"; task: "task"; - trigger: "trigger"; system: "system"; tool: "tool"; activity: "activity"; @@ -508,6 +508,7 @@ export declare const govTaskLogRoute: { id: z.ZodString; at: z.ZodString; sideLabel: z.ZodString; + action: z.ZodString; title: z.ZodString; }, z.core.$strip>>; }, z.core.$strip>; @@ -1835,12 +1836,20 @@ export declare const installActivityRoute: { data: z.ZodRecord; messageAllowed: z.ZodDefault; messageHint: z.ZodOptional; + inboundComm: z.ZodOptional; + partyField: z.ZodString; + subjectField: z.ZodOptional; + bodyField: z.ZodOptional; + attachmentsField: z.ZodOptional; + }, z.core.$strip>>; }, z.core.$strip>; output: z.ZodObject<{ mandatory: z.ZodDefault; description: z.ZodOptional; + when: z.ZodOptional; }, z.core.$strip>>>; selective: z.ZodDefault; description: z.ZodOptional; + when: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>>>; }, z.core.$strip>; @@ -1855,6 +1865,9 @@ export declare const installActivityRoute: { key: z.ZodString; label: z.ZodString; schema: z.ZodOptional>; + ledger: z.ZodOptional; + ledgers: z.ZodOptional>; + editableAtRelease: z.ZodOptional>; }, z.core.$strip>>>; acceptanceCriteria: z.ZodDefault; modelRef: z.ZodOptional; }, z.core.$strip>>>; + revisableAtRelease: z.ZodDefault>>; prohibitions: z.ZodDefault; + kind: z.ZodDefault>; + caseKey: z.ZodOptional; fields: z.ZodArray; primaryKey: z.ZodDefault; masked: z.ZodDefault; + required: z.ZodOptional; + tally: z.ZodOptional>; + expect: z.ZodOptional; + auto: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>>>; listeners: z.ZodDefault>; }, z.core.$strip>>; }, z.core.$strip>; + steps: z.ZodOptional>; + reads: z.ZodDefault>; + view: z.ZodOptional; + tools: z.ZodDefault>; + doneWhen: z.ZodOptional; + }, z.core.$strip>>>; + phases: z.ZodOptional; + }, z.core.$strip>>>; }, z.core.$strip>; skills: z.ZodDefault; @@ -2139,7 +2181,13 @@ export declare const listActivitiesRoute: { name: z.ZodString; type: z.ZodString; required: z.ZodBoolean; + label: z.ZodOptional; + format: z.ZodOptional; }, z.core.$strip>>; + samples: z.ZodDefault; + }, z.core.$strip>>>; outputPorts: z.ZodArray; @@ -2184,6 +2232,12 @@ export declare const listLedgerObjectsRoute: { domain: z.ZodString; object: z.ZodString; label: z.ZodOptional; + kind: z.ZodOptional>; + caseKey: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>; }; @@ -2355,6 +2409,30 @@ export declare const testRunRoute: { }>; }, z.core.$strip>; }; +/** + * 仿真测试台 · 试跑**一个活动** —— 与试跑流程包并列的第二种试法。 + * + * 为什么要单有一条:开发一个活动时,关心的是「这个活动自己跑不跑得动」—— + * 案例模型推不推得开、判据取不取得齐、台账写不写得对、验收卡长什么样。 + * 走流程包试,前面几个节点的噪音全得先趟一遍,而且每改一次活动就要重跑整条流。 + * + * 起出来的是**真任务**(不挂工作流),照旧落到被指派人的工作台、照旧过 Harness 与放行门 —— + * 试跑不等于假跑:假跑试不出任何东西。区别只在它带 `origin='testbed'`,可以连根拔掉。 + */ +export declare const testRunActivityRoute: { + readonly method: "POST"; + readonly path: "/api/gov/testbed/run-activity"; + readonly body: z.ZodObject<{ + activityId: z.ZodString; + data: z.ZodDefault>; + message: z.ZodDefault; + ownerId: z.ZodDefault; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + taskId: z.ZodString; + title: z.ZodString; + }, z.core.$strip>; +}; /** * 停止仿真 —— **把这一单连根拔掉**:工作流、它的活动任务、工单、实例、trace 全部销毁。 * 仿真不是生产:试跑留下的东西不该在库里、在别人工作台上过夜。 @@ -2362,8 +2440,10 @@ export declare const testRunRoute: { export declare const testStopRoute: { readonly method: "POST"; readonly path: "/api/gov/testbed/stop"; + /** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */ readonly body: z.ZodObject<{ - workflowId: z.ZodString; + workflowId: z.ZodDefault; + taskId: z.ZodDefault; }, z.core.$strip>; readonly output: z.ZodObject<{ destroyed: z.ZodBoolean; @@ -2371,12 +2451,271 @@ export declare const testStopRoute: { }, z.core.$strip>; }; /** 当前在跑的仿真(测试台一次只跑一条)—— 刷新页面也认得回来 */ +/** + * 当前这一台仪器上的试跑单(测试台一次只跑一条)—— 刷新页面也认得回来。 + * + * **返回还在跑的那一单,以及刚跑完的那一单** —— 两者要分得开: + * `live` 为真才锁旋钮(在跑的不许换台);为假表示它已收尾,观测面照旧摆着结果给人看, + * 但可以再起一单了。从前不分,已终结的单照样被当成「在跑」—— + * 跑完之后旋钮永久锁住,人只能刷新整页才起得了下一单(实测)。 + */ export declare const testCurrentRoute: { readonly method: "GET"; readonly path: "/api/gov/testbed/current"; readonly output: z.ZodObject<{ workflowId: z.ZodOptional; subject: z.ZodOptional; + taskId: z.ZodOptional; + activityId: z.ZodOptional; + live: z.ZodDefault; + terminal: z.ZodOptional; + }, z.core.$strip>; +}; +/** + * 单活动试跑的**观测面** —— 一屏看穿这一单: + * 轨迹(每一拍干了什么)· 台账草稿(成果落到哪儿了)· 卡(问了谁什么)· 预算读数。 + * + * 流程包那侧看的是 trace 树(节点 → 步骤);活动这侧要看的是**活动内部**, + * 拿 trace 树看不出「第三拍为什么被拒」。 + */ +export declare const testActivityStateRoute: { + readonly method: "GET"; + readonly path: "/api/gov/testbed/activity/:taskId"; + readonly params: z.ZodObject<{ + taskId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + taskId: z.ZodString; + activityId: z.ZodString; + title: z.ZodString; + runtime: z.ZodEnum<{ + running: "running"; + "awaiting-human": "awaiting-human"; + "awaiting-event": "awaiting-event"; + suspended: "suspended"; + "landing-blocked": "landing-blocked"; + terminal: "terminal"; + }>; + terminal: z.ZodOptional>; + reason: z.ZodOptional; + used: z.ZodObject<{ + steps: z.ZodNumber; + tokens: z.ZodNumber; + humanCalls: z.ZodNumber; + }, z.core.$strip>; + caps: z.ZodObject<{ + steps: z.ZodNumber; + tokens: z.ZodNumber; + humanCalls: z.ZodNumber; + }, z.core.$strip>; + trail: z.ZodArray>; + }, z.core.$strip>>; + drafts: z.ZodArray; + patch: z.ZodRecord; + }, z.core.$strip>>; + cards: z.ZodArray; + title: z.ZodString; + done: z.ZodBoolean; + result: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** + * 上传一个文件 —— 走 base64 而不是 multipart。 + * + * 这一层所有路由都由同一套 zod 契约与 typed-fetch 收着,multipart 得在它旁边另开一条 + * 不受契约管的路;而这里要传的是 BOM、规格书这类几十 KB 的件,base64 那点膨胀 + * 换来「照旧一条契约管到底」是划算的。真大的件不该走界面上传,该走通道。 + */ +export declare const uploadFileRoute: { + readonly method: "POST"; + readonly path: "/api/gov/upload"; + readonly body: z.ZodObject<{ + name: z.ZodString; + contentType: z.ZodDefault; + dataBase64: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ref: z.ZodString; + name: z.ZodString; + contentType: z.ZodString; + size: z.ZodNumber; + text: z.ZodOptional; + note: z.ZodString; + }, z.core.$strip>; +}; +export type UploadedFile = z.infer; +/** 取回原件 —— 存的是真字节,人点得开才算真留住了 */ +export declare const downloadFileRoute: { + readonly method: "GET"; + readonly path: "/api/gov/upload/:id"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + name: z.ZodString; + contentType: z.ZodString; + dataBase64: z.ZodString; + }, z.core.$strip>; +}; +/** + * 一次试跑的存档 —— **销毁那一刻拍的快照**。 + * + * 「停止仿真 = 连根拔掉」这条不变(试跑起的是真任务,不该在别人工作台上过夜); + * 拔掉之前把这一单的观测面存下来。开发活动是反复试的活,「上一版跑了几拍、 + * 卡在哪一步、台账写成什么样」正是要拿来比对的东西 —— 拔干净等于每改一次 + * 就把上一次的账一并烧了。 + */ +export declare const testLogRowSchema: z.ZodObject<{ + id: z.ZodString; + kind: z.ZodEnum<{ + activity: "activity"; + package: "package"; + }>; + subject: z.ZodString; + target: z.ZodString; + taskId: z.ZodOptional; + workflowId: z.ZodOptional; + terminal: z.ZodOptional; + reason: z.ZodOptional; + used: z.ZodObject<{ + steps: z.ZodOptional; + tokens: z.ZodOptional; + humanCalls: z.ZodOptional; + }, z.core.$strip>; + caps: z.ZodObject<{ + steps: z.ZodOptional; + tokens: z.ZodOptional; + humanCalls: z.ZodOptional; + }, z.core.$strip>; + trailCount: z.ZodNumber; + draftCount: z.ZodNumber; + cardCount: z.ZodNumber; + startedAt: z.ZodOptional; + archivedAt: z.ZodString; +}, z.core.$strip>; +export type TestLogRow = z.infer; +export declare const listTestLogsRoute: { + readonly method: "GET"; + readonly path: "/api/gov/testbed/logs"; + readonly output: z.ZodObject<{ + runs: z.ZodArray; + subject: z.ZodString; + target: z.ZodString; + taskId: z.ZodOptional; + workflowId: z.ZodOptional; + terminal: z.ZodOptional; + reason: z.ZodOptional; + used: z.ZodObject<{ + steps: z.ZodOptional; + tokens: z.ZodOptional; + humanCalls: z.ZodOptional; + }, z.core.$strip>; + caps: z.ZodObject<{ + steps: z.ZodOptional; + tokens: z.ZodOptional; + humanCalls: z.ZodOptional; + }, z.core.$strip>; + trailCount: z.ZodNumber; + draftCount: z.ZodNumber; + cardCount: z.ZodNumber; + startedAt: z.ZodOptional; + archivedAt: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 一次试跑的正文 —— 逐拍轨迹 / 台账草稿 / 发过的卡 */ +export declare const testLogDetailRoute: { + readonly method: "GET"; + readonly path: "/api/gov/testbed/logs/:id"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + id: z.ZodString; + kind: z.ZodEnum<{ + activity: "activity"; + package: "package"; + }>; + subject: z.ZodString; + target: z.ZodString; + taskId: z.ZodOptional; + workflowId: z.ZodOptional; + terminal: z.ZodOptional; + reason: z.ZodOptional; + used: z.ZodObject<{ + steps: z.ZodOptional; + tokens: z.ZodOptional; + humanCalls: z.ZodOptional; + }, z.core.$strip>; + caps: z.ZodObject<{ + steps: z.ZodOptional; + tokens: z.ZodOptional; + humanCalls: z.ZodOptional; + }, z.core.$strip>; + trailCount: z.ZodNumber; + draftCount: z.ZodNumber; + cardCount: z.ZodNumber; + startedAt: z.ZodOptional; + archivedAt: z.ZodString; + trail: z.ZodArray>; + }, z.core.$strip>>; + drafts: z.ZodArray; + patch: z.ZodRecord; + }, z.core.$strip>>; + cards: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 删一条 / 清空 —— 日志是给人比对用的,留多久由人说了算 */ +export declare const deleteTestLogRoute: { + readonly method: "DELETE"; + readonly path: "/api/gov/testbed/logs/:id"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + deleted: z.ZodNumber; + }, z.core.$strip>; +}; +export declare const clearTestLogsRoute: { + readonly method: "DELETE"; + readonly path: "/api/gov/testbed/logs"; + readonly output: z.ZodObject<{ + deleted: z.ZodNumber; }, z.core.$strip>; }; /** 全链 Trace:一条工作流的 span 树(节点 → 步骤 / 工具 / 模型 / 人卡) */ diff --git a/api-report/packages/contracts/src/api/workbench.d.ts b/api-report/packages/contracts/src/api/workbench.d.ts index bc89b7b..6ae3d1f 100644 --- a/api-report/packages/contracts/src/api/workbench.d.ts +++ b/api-report/packages/contracts/src/api/workbench.d.ts @@ -34,9 +34,9 @@ export declare const sessionRoute: { }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"governance">; function: z.ZodEnum<{ + trigger: "trigger"; data: "data"; task: "task"; - trigger: "trigger"; system: "system"; tool: "tool"; activity: "activity"; @@ -94,9 +94,9 @@ export declare const loginRoute: { }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"governance">; function: z.ZodEnum<{ + trigger: "trigger"; data: "data"; task: "task"; - trigger: "trigger"; system: "system"; tool: "tool"; activity: "activity"; @@ -158,6 +158,11 @@ export declare const taskCardSchema: z.ZodObject<{ }>>; position: z.ZodNumber; total: z.ZodNumber; + phase: z.ZodOptional>; workflowSubject: z.ZodString; workflowState: z.ZodEnum<{ terminated: "terminated"; @@ -194,6 +199,11 @@ export declare const listTasksRoute: { }>>; position: z.ZodNumber; total: z.ZodNumber; + phase: z.ZodOptional>; workflowSubject: z.ZodString; workflowState: z.ZodEnum<{ terminated: "terminated"; @@ -249,6 +259,11 @@ export declare const taskDetailSchema: z.ZodObject<{ }>>; position: z.ZodNumber; total: z.ZodNumber; + phase: z.ZodOptional>; workflowSubject: z.ZodString; workflowState: z.ZodEnum<{ terminated: "terminated"; @@ -331,6 +346,8 @@ export declare const taskDetailSchema: z.ZodObject<{ rows: z.ZodArray>; editable: z.ZodOptional; editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; linkDonut: z.ZodOptional; @@ -549,6 +566,27 @@ export declare const taskDetailSchema: z.ZodObject<{ src: z.ZodString; }, z.core.$strip>], "kind">; }, z.core.$strip>>; + comms: z.ZodDefault; + channel: z.ZodString; + party: z.ZodString; + subject: z.ZodString; + body: z.ZodString; + attachments: z.ZodArray; + ref: z.ZodOptional; + note: z.ZodOptional; + readable: z.ZodOptional; + }, z.core.$strip>>; + note: z.ZodString; + unread: z.ZodDefault; + }, z.core.$strip>>>; workOrders: z.ZodArray; preset: z.ZodOptional>; editable: z.ZodOptional; editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; linkDonut: z.ZodOptional; @@ -789,11 +829,17 @@ export declare const taskDetailSchema: z.ZodObject<{ group: z.ZodOptional; hint: z.ZodOptional; ai: z.ZodOptional; + ledger: z.ZodOptional; unit: z.ZodOptional; required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gate: z.ZodOptional>; + }, z.core.$strip>>; id: z.core.$ZodBranded; at: z.ZodISODateTime; title: z.ZodString; @@ -826,6 +872,8 @@ export declare const taskDetailSchema: z.ZodObject<{ L5: "L5"; }>; pass: z.ZodBoolean; + settled: z.ZodOptional; + rationale: z.ZodOptional; }, z.core.$strip>>; actions: z.ZodOptional; hint: z.ZodOptional; ai: z.ZodOptional; + ledger: z.ZodOptional; unit: z.ZodOptional; required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>>; + blocks: z.ZodOptional; + tone: z.ZodEnum<{ + ok: "ok"; + warn: "warn"; + danger: "danger"; + brand: "brand"; + muted: "muted"; + }>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"kv">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"badges">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"table">; + title: z.ZodOptional; + cols: z.ZodArray; + rows: z.ZodArray>; + editable: z.ZodOptional; + editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; + linkDonut: z.ZodOptional; + unit: z.ZodOptional; + map: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"bars">; + title: z.ZodOptional; + max: z.ZodOptional; + redline: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"donut">; + title: z.ZodOptional; + unit: z.ZodOptional; + items: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"mail">; + dir: z.ZodEnum<{ + out: "out"; + in: "in"; + }>; + statusLabel: z.ZodOptional; + editable: z.ZodOptional; + from: z.ZodString; + fromAddr: z.ZodOptional; + to: z.ZodString; + cc: z.ZodOptional; + subject: z.ZodString; + time: z.ZodString; + body: z.ZodString; + attachments: z.ZodOptional; + file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">>; + }, z.core.$strip>>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"cards">; + title: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + title: z.ZodString; + code: z.ZodOptional; + swap: z.ZodOptional; + }, z.core.$strip>>; + big: z.ZodString; + bigLabel: z.ZodOptional; + meta: z.ZodOptional>>; + note: z.ZodOptional; + from: z.ZodOptional; + time: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"waiting">; + label: z.ZodString; + hint: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"files">; + title: z.ZodOptional; + items: z.ZodArray; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">; + }, z.core.$strip>>; + }, z.core.$strip>], "t">>>; portChoice: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>>; + landing: z.ZodOptional; + tables: z.ZodDefault>>; + ports: z.ZodDefault>; + carried: z.ZodDefault>; + }, z.core.$strip>>; + routedPort: z.ZodOptional; + because: z.ZodString; + }, z.core.$strip>>; passLabel: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; @@ -916,6 +1191,7 @@ export declare const taskDetailSchema: z.ZodObject<{ control: "control"; }>; text: z.ZodString; + openComms: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ @@ -942,6 +1218,47 @@ export declare const taskDetailSchema: z.ZodObject<{ }, z.core.$strip>>; id: z.core.$ZodBranded; at: z.ZodISODateTime; + }, z.core.$strip>, z.ZodObject<{ + k: z.ZodLiteral<"entry">; + ask: z.ZodString; + basis: z.ZodOptional>>; + ledger: z.ZodString; + ledgerLabel: z.ZodOptional; + key: z.ZodRecord; + keyLabels: z.ZodOptional>; + fields: z.ZodArray; + required: z.ZodDefault; + placeholder: z.ZodOptional; + }, z.core.$strip>>; + autoNote: z.ZodOptional; + also: z.ZodOptional; + key: z.ZodRecord; + keyLabels: z.ZodOptional>; + fields: z.ZodArray; + required: z.ZodDefault; + placeholder: z.ZodOptional; + }, z.core.$strip>>; + autoNote: z.ZodOptional; + }, z.core.$strip>>>; + done: z.ZodOptional; + values: z.ZodRecord; + }, z.core.$strip>>; + id: z.core.$ZodBranded; + at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ k: z.ZodLiteral<"elicitation">; ask: z.ZodString; @@ -953,6 +1270,7 @@ export declare const taskDetailSchema: z.ZodObject<{ key: z.ZodString; label: z.ZodString; placeholder: z.ZodOptional; + value: z.ZodOptional; }, z.core.$strip>>; done: z.ZodOptional>; editable: z.ZodOptional; editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; linkDonut: z.ZodOptional; @@ -1195,8 +1515,15 @@ export declare const taskDetailSchema: z.ZodObject<{ human: "human"; "human-verdict": "human-verdict"; "world-wait": "world-wait"; + trigger: "trigger"; }>; sideLabel: z.ZodString; + who: z.ZodEnum<{ + system: "system"; + owner: "owner"; + agent: "agent"; + }>; + action: z.ZodString; title: z.ZodString; summary: z.ZodOptional; }, z.core.$strip>>; @@ -1230,6 +1557,11 @@ export declare const taskDetailRoute: { }>>; position: z.ZodNumber; total: z.ZodNumber; + phase: z.ZodOptional>; workflowSubject: z.ZodString; workflowState: z.ZodEnum<{ terminated: "terminated"; @@ -1312,6 +1644,8 @@ export declare const taskDetailRoute: { rows: z.ZodArray>; editable: z.ZodOptional; editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; linkDonut: z.ZodOptional; @@ -1530,6 +1864,27 @@ export declare const taskDetailRoute: { src: z.ZodString; }, z.core.$strip>], "kind">; }, z.core.$strip>>; + comms: z.ZodDefault; + channel: z.ZodString; + party: z.ZodString; + subject: z.ZodString; + body: z.ZodString; + attachments: z.ZodArray; + ref: z.ZodOptional; + note: z.ZodOptional; + readable: z.ZodOptional; + }, z.core.$strip>>; + note: z.ZodString; + unread: z.ZodDefault; + }, z.core.$strip>>>; workOrders: z.ZodArray; preset: z.ZodOptional>; editable: z.ZodOptional; editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; linkDonut: z.ZodOptional; @@ -1770,11 +2127,17 @@ export declare const taskDetailRoute: { group: z.ZodOptional; hint: z.ZodOptional; ai: z.ZodOptional; + ledger: z.ZodOptional; unit: z.ZodOptional; required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gate: z.ZodOptional>; + }, z.core.$strip>>; id: z.core.$ZodBranded; at: z.ZodISODateTime; title: z.ZodString; @@ -1807,6 +2170,8 @@ export declare const taskDetailRoute: { L5: "L5"; }>; pass: z.ZodBoolean; + settled: z.ZodOptional; + rationale: z.ZodOptional; }, z.core.$strip>>; actions: z.ZodOptional; hint: z.ZodOptional; ai: z.ZodOptional; + ledger: z.ZodOptional; unit: z.ZodOptional; required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>>; + blocks: z.ZodOptional; + tone: z.ZodEnum<{ + ok: "ok"; + warn: "warn"; + danger: "danger"; + brand: "brand"; + muted: "muted"; + }>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"kv">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"badges">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"table">; + title: z.ZodOptional; + cols: z.ZodArray; + rows: z.ZodArray>; + editable: z.ZodOptional; + editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; + linkDonut: z.ZodOptional; + unit: z.ZodOptional; + map: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"bars">; + title: z.ZodOptional; + max: z.ZodOptional; + redline: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"donut">; + title: z.ZodOptional; + unit: z.ZodOptional; + items: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"mail">; + dir: z.ZodEnum<{ + out: "out"; + in: "in"; + }>; + statusLabel: z.ZodOptional; + editable: z.ZodOptional; + from: z.ZodString; + fromAddr: z.ZodOptional; + to: z.ZodString; + cc: z.ZodOptional; + subject: z.ZodString; + time: z.ZodString; + body: z.ZodString; + attachments: z.ZodOptional; + file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">>; + }, z.core.$strip>>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"cards">; + title: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + title: z.ZodString; + code: z.ZodOptional; + swap: z.ZodOptional; + }, z.core.$strip>>; + big: z.ZodString; + bigLabel: z.ZodOptional; + meta: z.ZodOptional>>; + note: z.ZodOptional; + from: z.ZodOptional; + time: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"waiting">; + label: z.ZodString; + hint: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"files">; + title: z.ZodOptional; + items: z.ZodArray; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">; + }, z.core.$strip>>; + }, z.core.$strip>], "t">>>; portChoice: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>>; + landing: z.ZodOptional; + tables: z.ZodDefault>>; + ports: z.ZodDefault>; + carried: z.ZodDefault>; + }, z.core.$strip>>; + routedPort: z.ZodOptional; + because: z.ZodString; + }, z.core.$strip>>; passLabel: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; @@ -1897,6 +2489,7 @@ export declare const taskDetailRoute: { control: "control"; }>; text: z.ZodString; + openComms: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ @@ -1923,6 +2516,47 @@ export declare const taskDetailRoute: { }, z.core.$strip>>; id: z.core.$ZodBranded; at: z.ZodISODateTime; + }, z.core.$strip>, z.ZodObject<{ + k: z.ZodLiteral<"entry">; + ask: z.ZodString; + basis: z.ZodOptional>>; + ledger: z.ZodString; + ledgerLabel: z.ZodOptional; + key: z.ZodRecord; + keyLabels: z.ZodOptional>; + fields: z.ZodArray; + required: z.ZodDefault; + placeholder: z.ZodOptional; + }, z.core.$strip>>; + autoNote: z.ZodOptional; + also: z.ZodOptional; + key: z.ZodRecord; + keyLabels: z.ZodOptional>; + fields: z.ZodArray; + required: z.ZodDefault; + placeholder: z.ZodOptional; + }, z.core.$strip>>; + autoNote: z.ZodOptional; + }, z.core.$strip>>>; + done: z.ZodOptional; + values: z.ZodRecord; + }, z.core.$strip>>; + id: z.core.$ZodBranded; + at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ k: z.ZodLiteral<"elicitation">; ask: z.ZodString; @@ -1934,6 +2568,7 @@ export declare const taskDetailRoute: { key: z.ZodString; label: z.ZodString; placeholder: z.ZodOptional; + value: z.ZodOptional; }, z.core.$strip>>; done: z.ZodOptional>; editable: z.ZodOptional; editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; linkDonut: z.ZodOptional; @@ -2176,8 +2813,15 @@ export declare const taskDetailRoute: { human: "human"; "human-verdict": "human-verdict"; "world-wait": "world-wait"; + trigger: "trigger"; }>; sideLabel: z.ZodString; + who: z.ZodEnum<{ + system: "system"; + owner: "owner"; + agent: "agent"; + }>; + action: z.ZodString; title: z.ZodString; summary: z.ZodOptional; }, z.core.$strip>>; @@ -2209,6 +2853,73 @@ export declare const replyChatCardRoute: { ok: z.ZodBoolean; }, z.core.$strip>; }; +/** + * 标记沟通记录已读 / 未读 —— **气泡计数跟着这一下走**。 + * 不做「打开即已读」:那是替人宣布他看过了。已读还能改回未读(看了一半想留个记号)。 + */ +export declare const markCommReadRoute: { + readonly method: "POST"; + readonly path: "/api/desk/comms/:commId/read"; + readonly params: z.ZodObject<{ + commId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + read: z.ZodBoolean; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + unread: z.ZodBoolean; + }, z.core.$strip>; +}; +/** + * **就地改完按下「保存」** —— 把件上那张表写回**本单草稿层**,工单不办结。 + * + * 为什么非要立刻落库、而不是攒到提交那一下:件是**服务端投影**。 + * 人改了几格,只读那份跟着变了(那是他自己敲的),而由数据算出来的东西 + * ——类徽 A/B/C、分布条、结论横幅那条去向——全是渲染那一刻的旧值。 + * 于是屏上一半新一半旧:**人把三条料全改成 A,底下的去向还写着「去选型」** + * (用户实测)。那不是没更新,那是界面在撒谎。 + * + * 落库之后前端重取任务投影,服务端拿新数据重渲染整份件 —— 算出来的那些跟着一起变, + * 去向也由同一次谓词求值重算。**放行门的位置没变**:写的仍是草稿层, + * 合并进正式版依旧只发生在人签字那一刻。 + * + * ── 按**地址**收,不按「当下那张卡的第几块」收(2026-08-02 用户实测逼出来)────── + * + * 载荷从前是 `{workOrderId, values:{'table:0': …}}`:列名由**那张工单的 `blocks[0]`** + * 给出,于是「这份件能不能改」被绑在了「当下开着的那张卡长什么样」上。 + * 实测的下场:一单在放行门连拒五拍熔断,引擎发的是一张**没有 blocks 的熔断卡**; + * 而屏上摆着的正是受理记录那份件,闸也在、编辑也进得去 —— 人把那条料的年用量补上、 + * 按了保存,前端找不到匹配的块,**一格都没往下送**。他要补的恰恰就是把这一单 + * 卡在那儿的那个必填格(判据 `quote_intake_record_missing = 0`), + * 而屏上什么反应都没有 —— 界面提供了一个落不了地的编辑口。 + * + * 现在收的是件自己说的话:每张可改的表挂着 `data-edit="域.对象"`,格子挂着 + * `data-col="台账列名"` —— 地址与列名**本来就在件里**,不必再绕一趟工单。 + * 授权面改由**活动清单的台账声明**把关(不在声明里的地址一概不写), + * 比「当下那张卡碰巧带了哪一块」既严格又稳定。 + * + * **改、增、删是同一件事的三种样子**(2026-08-02 用户:「编辑列表应该还能增删行」): + * 人手改本来就是**兜 AI 的错**的那一手 —— 而错不只有「这一格填错了」, + * 还有「这一行根本不该在」(客户点名本轮跳过的料被写了进来)与「漏了一行」。 + * 只给改格子,前两种就没有出口:那一单会一路走到放行门被判据拦住, + * 而人明明一眼就看出问题在哪。 + */ +export declare const saveTableRoute: { + readonly method: "POST"; + readonly path: "/api/desk/tasks/:taskId/table"; + readonly params: z.ZodObject<{ + taskId: z.core.$ZodBranded; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + tables: z.ZodRecord>>; + drops: z.ZodOptional>>>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + rows: z.ZodNumber; + }, z.core.$strip>; +}; /** 负责人的自由输入 —— 人事件的第二形态 */ export declare const sayRoute: { readonly method: "POST"; @@ -2245,6 +2956,7 @@ export declare const sayRoute: { control: "control"; }>; text: z.ZodString; + openComms: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ @@ -2271,6 +2983,47 @@ export declare const sayRoute: { }, z.core.$strip>>; id: z.core.$ZodBranded; at: z.ZodISODateTime; + }, z.core.$strip>, z.ZodObject<{ + k: z.ZodLiteral<"entry">; + ask: z.ZodString; + basis: z.ZodOptional>>; + ledger: z.ZodString; + ledgerLabel: z.ZodOptional; + key: z.ZodRecord; + keyLabels: z.ZodOptional>; + fields: z.ZodArray; + required: z.ZodDefault; + placeholder: z.ZodOptional; + }, z.core.$strip>>; + autoNote: z.ZodOptional; + also: z.ZodOptional; + key: z.ZodRecord; + keyLabels: z.ZodOptional>; + fields: z.ZodArray; + required: z.ZodDefault; + placeholder: z.ZodOptional; + }, z.core.$strip>>; + autoNote: z.ZodOptional; + }, z.core.$strip>>>; + done: z.ZodOptional; + values: z.ZodRecord; + }, z.core.$strip>>; + id: z.core.$ZodBranded; + at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ k: z.ZodLiteral<"elicitation">; ask: z.ZodString; @@ -2282,6 +3035,7 @@ export declare const sayRoute: { key: z.ZodString; label: z.ZodString; placeholder: z.ZodOptional; + value: z.ZodOptional; }, z.core.$strip>>; done: z.ZodOptional; hint: z.ZodOptional; ai: z.ZodOptional; + ledger: z.ZodOptional; unit: z.ZodOptional; required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; @@ -2396,6 +3151,7 @@ export declare const listMessagesRoute: { group: z.ZodOptional; hint: z.ZodOptional; ai: z.ZodOptional; + ledger: z.ZodOptional; unit: z.ZodOptional; required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; diff --git a/api-report/packages/contracts/src/card.d.ts b/api-report/packages/contracts/src/card.d.ts index 115cca1..f4e4f57 100644 --- a/api-report/packages/contracts/src/card.d.ts +++ b/api-report/packages/contracts/src/card.d.ts @@ -70,6 +70,7 @@ export declare const formFieldSchema: z.ZodObject<{ group: z.ZodOptional; hint: z.ZodOptional; ai: z.ZodOptional; + ledger: z.ZodOptional; unit: z.ZodOptional; required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; @@ -144,6 +145,8 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ rows: z.ZodArray>; editable: z.ZodOptional; editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; linkDonut: z.ZodOptional; @@ -379,6 +382,8 @@ export declare const deliverableSchema: z.ZodObject<{ rows: z.ZodArray>; editable: z.ZodOptional; editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; linkDonut: z.ZodOptional; @@ -641,6 +646,8 @@ export declare const executionWorkOrderSchema: z.ZodObject<{ rows: z.ZodArray>; editable: z.ZodOptional; editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; linkDonut: z.ZodOptional; @@ -837,11 +844,17 @@ export declare const executionWorkOrderSchema: z.ZodObject<{ group: z.ZodOptional; hint: z.ZodOptional; ai: z.ZodOptional; + ledger: z.ZodOptional; unit: z.ZodOptional; required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gate: z.ZodOptional>; + }, z.core.$strip>>; id: z.core.$ZodBranded; at: z.ZodISODateTime; title: z.ZodString; @@ -873,6 +886,8 @@ export declare const acceptanceCriterionCheckSchema: z.ZodObject<{ L5: "L5"; }>; pass: z.ZodBoolean; + settled: z.ZodOptional; + rationale: z.ZodOptional; }, z.core.$strip>; export type AcceptanceCriterionCheck = z.infer; /** 待落地动作意向:预检全过才上卡,卡面呈现的即已验证的动作清单(OS 7.3) */ @@ -900,6 +915,8 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{ L5: "L5"; }>; pass: z.ZodBoolean; + settled: z.ZodOptional; + rationale: z.ZodOptional; }, z.core.$strip>>; actions: z.ZodOptional; hint: z.ZodOptional; ai: z.ZodOptional; + ledger: z.ZodOptional; unit: z.ZodOptional; required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>>; + blocks: z.ZodOptional; + tone: z.ZodEnum<{ + ok: "ok"; + warn: "warn"; + danger: "danger"; + brand: "brand"; + muted: "muted"; + }>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"kv">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"badges">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"table">; + title: z.ZodOptional; + cols: z.ZodArray; + rows: z.ZodArray>; + editable: z.ZodOptional; + editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; + linkDonut: z.ZodOptional; + unit: z.ZodOptional; + map: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"bars">; + title: z.ZodOptional; + max: z.ZodOptional; + redline: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"donut">; + title: z.ZodOptional; + unit: z.ZodOptional; + items: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"mail">; + dir: z.ZodEnum<{ + out: "out"; + in: "in"; + }>; + statusLabel: z.ZodOptional; + editable: z.ZodOptional; + from: z.ZodString; + fromAddr: z.ZodOptional; + to: z.ZodString; + cc: z.ZodOptional; + subject: z.ZodString; + time: z.ZodString; + body: z.ZodString; + attachments: z.ZodOptional; + file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">>; + }, z.core.$strip>>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"cards">; + title: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + title: z.ZodString; + code: z.ZodOptional; + swap: z.ZodOptional; + }, z.core.$strip>>; + big: z.ZodString; + bigLabel: z.ZodOptional; + meta: z.ZodOptional>>; + note: z.ZodOptional; + from: z.ZodOptional; + time: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"waiting">; + label: z.ZodString; + hint: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"files">; + title: z.ZodOptional; + items: z.ZodArray; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">; + }, z.core.$strip>>; + }, z.core.$strip>], "t">>>; portChoice: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>>; + landing: z.ZodOptional; + tables: z.ZodDefault>>; + ports: z.ZodDefault>; + carried: z.ZodDefault>; + }, z.core.$strip>>; + routedPort: z.ZodOptional; + because: z.ZodString; + }, z.core.$strip>>; passLabel: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; @@ -1009,6 +1253,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ rows: z.ZodArray>; editable: z.ZodOptional; editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; linkDonut: z.ZodOptional; @@ -1205,11 +1451,17 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ group: z.ZodOptional; hint: z.ZodOptional; ai: z.ZodOptional; + ledger: z.ZodOptional; unit: z.ZodOptional; required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>; submitLabel: z.ZodOptional; + gate: z.ZodOptional>; + }, z.core.$strip>>; id: z.core.$ZodBranded; at: z.ZodISODateTime; title: z.ZodString; @@ -1242,6 +1494,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ L5: "L5"; }>; pass: z.ZodBoolean; + settled: z.ZodOptional; + rationale: z.ZodOptional; }, z.core.$strip>>; actions: z.ZodOptional; hint: z.ZodOptional; ai: z.ZodOptional; + ledger: z.ZodOptional; unit: z.ZodOptional; required: z.ZodOptional; cols: z.ZodOptional, z.ZodLiteral<2>]>>; }, z.core.$strip>>>; + blocks: z.ZodOptional; + tone: z.ZodEnum<{ + ok: "ok"; + warn: "warn"; + danger: "danger"; + brand: "brand"; + muted: "muted"; + }>; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"kv">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"badges">; + title: z.ZodOptional; + items: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"table">; + title: z.ZodOptional; + cols: z.ZodArray; + rows: z.ZodArray>; + editable: z.ZodOptional; + editableCols: z.ZodOptional>; + ledger: z.ZodOptional; + fields: z.ZodOptional>; + linkDonut: z.ZodOptional; + unit: z.ZodOptional; + map: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"bars">; + title: z.ZodOptional; + max: z.ZodOptional; + redline: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"donut">; + title: z.ZodOptional; + unit: z.ZodOptional; + items: z.ZodArray; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"mail">; + dir: z.ZodEnum<{ + out: "out"; + in: "in"; + }>; + statusLabel: z.ZodOptional; + editable: z.ZodOptional; + from: z.ZodString; + fromAddr: z.ZodOptional; + to: z.ZodString; + cc: z.ZodOptional; + subject: z.ZodString; + time: z.ZodString; + body: z.ZodString; + attachments: z.ZodOptional; + file: z.ZodOptional; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">>; + }, z.core.$strip>>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"cards">; + title: z.ZodOptional; + items: z.ZodArray; + tone: z.ZodOptional>; + title: z.ZodString; + code: z.ZodOptional; + swap: z.ZodOptional; + }, z.core.$strip>>; + big: z.ZodString; + bigLabel: z.ZodOptional; + meta: z.ZodOptional>>; + note: z.ZodOptional; + from: z.ZodOptional; + time: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"waiting">; + label: z.ZodString; + hint: z.ZodOptional; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"files">; + title: z.ZodOptional; + items: z.ZodArray; + name: z.ZodString; + html: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"markdown">; + name: z.ZodString; + md: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"excel">; + name: z.ZodString; + sheet: z.ZodString; + rows: z.ZodArray>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"word">; + name: z.ZodString; + blocks: z.ZodArray; + text: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + t: z.ZodLiteral<"ul">; + items: z.ZodArray; + }, z.core.$strip>]>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"pdf">; + name: z.ZodString; + src: z.ZodString; + }, z.core.$strip>], "kind">; + }, z.core.$strip>>; + }, z.core.$strip>], "t">>>; portChoice: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>>; + landing: z.ZodOptional; + tables: z.ZodDefault>>; + ports: z.ZodDefault>; + carried: z.ZodDefault>; + }, z.core.$strip>>; + routedPort: z.ZodOptional; + because: z.ZodString; + }, z.core.$strip>>; passLabel: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; @@ -1355,6 +1836,7 @@ export declare const elicitationCardSchema: z.ZodObject<{ key: z.ZodString; label: z.ZodString; placeholder: z.ZodOptional; + value: z.ZodOptional; }, z.core.$strip>>; done: z.ZodOptional; export type ElicitationCard = z.infer; +export declare const dataEntryCardSchema: z.ZodObject<{ + k: z.ZodLiteral<"entry">; + ask: z.ZodString; + basis: z.ZodOptional>>; + ledger: z.ZodString; + ledgerLabel: z.ZodOptional; + key: z.ZodRecord; + keyLabels: z.ZodOptional>; + fields: z.ZodArray; + required: z.ZodDefault; + placeholder: z.ZodOptional; + }, z.core.$strip>>; + autoNote: z.ZodOptional; + also: z.ZodOptional; + key: z.ZodRecord; + keyLabels: z.ZodOptional>; + fields: z.ZodArray; + required: z.ZodDefault; + placeholder: z.ZodOptional; + }, z.core.$strip>>; + autoNote: z.ZodOptional; + }, z.core.$strip>>>; + done: z.ZodOptional; + values: z.ZodRecord; + }, z.core.$strip>>; + id: z.core.$ZodBranded; + at: z.ZodISODateTime; +}, z.core.$strip>; +export type DataEntryCard = z.infer; /** 评价卡:任务评价(评分 1–5 + 改进建议) */ export declare const ratingCardSchema: z.ZodObject<{ k: z.ZodLiteral<"rating">; @@ -1408,6 +1933,47 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ }, z.core.$strip>>; id: z.core.$ZodBranded; at: z.ZodISODateTime; +}, z.core.$strip>, z.ZodObject<{ + k: z.ZodLiteral<"entry">; + ask: z.ZodString; + basis: z.ZodOptional>>; + ledger: z.ZodString; + ledgerLabel: z.ZodOptional; + key: z.ZodRecord; + keyLabels: z.ZodOptional>; + fields: z.ZodArray; + required: z.ZodDefault; + placeholder: z.ZodOptional; + }, z.core.$strip>>; + autoNote: z.ZodOptional; + also: z.ZodOptional; + key: z.ZodRecord; + keyLabels: z.ZodOptional>; + fields: z.ZodArray; + required: z.ZodDefault; + placeholder: z.ZodOptional; + }, z.core.$strip>>; + autoNote: z.ZodOptional; + }, z.core.$strip>>>; + done: z.ZodOptional; + values: z.ZodRecord; + }, z.core.$strip>>; + id: z.core.$ZodBranded; + at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ k: z.ZodLiteral<"elicitation">; ask: z.ZodString; @@ -1419,6 +1985,7 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ key: z.ZodString; label: z.ZodString; placeholder: z.ZodOptional; + value: z.ZodOptional; }, z.core.$strip>>; done: z.ZodOptional; text: z.ZodString; + openComms: z.ZodOptional; id: z.core.$ZodBranded; at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ @@ -1497,6 +2065,47 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ }, z.core.$strip>>; id: z.core.$ZodBranded; at: z.ZodISODateTime; +}, z.core.$strip>, z.ZodObject<{ + k: z.ZodLiteral<"entry">; + ask: z.ZodString; + basis: z.ZodOptional>>; + ledger: z.ZodString; + ledgerLabel: z.ZodOptional; + key: z.ZodRecord; + keyLabels: z.ZodOptional>; + fields: z.ZodArray; + required: z.ZodDefault; + placeholder: z.ZodOptional; + }, z.core.$strip>>; + autoNote: z.ZodOptional; + also: z.ZodOptional; + key: z.ZodRecord; + keyLabels: z.ZodOptional>; + fields: z.ZodArray; + required: z.ZodDefault; + placeholder: z.ZodOptional; + }, z.core.$strip>>; + autoNote: z.ZodOptional; + }, z.core.$strip>>>; + done: z.ZodOptional; + values: z.ZodRecord; + }, z.core.$strip>>; + id: z.core.$ZodBranded; + at: z.ZodISODateTime; }, z.core.$strip>, z.ZodObject<{ k: z.ZodLiteral<"elicitation">; ask: z.ZodString; @@ -1508,6 +2117,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ key: z.ZodString; label: z.ZodString; placeholder: z.ZodOptional; + value: z.ZodOptional; }, z.core.$strip>>; done: z.ZodOptional; values: z.ZodDefault>; reason: z.ZodOptional; +}, z.core.$strip>, z.ZodObject<{ + k: z.ZodLiteral<"entry">; + cardId: z.core.$ZodBranded; + values: z.ZodRecord; }, z.core.$strip>, z.ZodObject<{ k: z.ZodLiteral<"rating">; cardId: z.core.$ZodBranded; diff --git a/api-report/packages/contracts/src/contract7.d.ts b/api-report/packages/contracts/src/contract7.d.ts index 2845321..5d32169 100644 --- a/api-report/packages/contracts/src/contract7.d.ts +++ b/api-report/packages/contracts/src/contract7.d.ts @@ -16,12 +16,20 @@ export declare const contractInputSchema: z.ZodObject<{ data: z.ZodRecord; messageAllowed: z.ZodDefault; messageHint: z.ZodOptional; + inboundComm: z.ZodOptional; + partyField: z.ZodString; + subjectField: z.ZodOptional; + bodyField: z.ZodOptional; + attachmentsField: z.ZodOptional; + }, z.core.$strip>>; }, z.core.$strip>; export type ContractInput = z.infer; export declare const outputPortSchema: z.ZodObject<{ name: z.ZodString; payload: z.ZodRecord; description: z.ZodOptional; + when: z.ZodOptional; }, z.core.$strip>; export type OutputPort = z.infer; export declare const outputPortsSchema: z.ZodObject<{ @@ -29,6 +37,7 @@ export declare const outputPortsSchema: z.ZodObject<{ name: z.ZodString; payload: z.ZodRecord; description: z.ZodOptional; + when: z.ZodOptional; }, z.core.$strip>>>; selective: z.ZodDefault; description: z.ZodOptional; + when: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>>>; }, z.core.$strip>; @@ -44,6 +54,9 @@ export declare const requiredDeliverableSchema: z.ZodObject<{ key: z.ZodString; label: z.ZodString; schema: z.ZodOptional>; + ledger: z.ZodOptional; + ledgers: z.ZodOptional>; + editableAtRelease: z.ZodOptional>; }, z.core.$strip>; export type RequiredDeliverable = z.infer; export declare const acceptanceCriterionSchema: z.ZodObject<{ @@ -109,12 +122,20 @@ export declare const sevenPartContractSchema: z.ZodObject<{ data: z.ZodRecord; messageAllowed: z.ZodDefault; messageHint: z.ZodOptional; + inboundComm: z.ZodOptional; + partyField: z.ZodString; + subjectField: z.ZodOptional; + bodyField: z.ZodOptional; + attachmentsField: z.ZodOptional; + }, z.core.$strip>>; }, z.core.$strip>; output: z.ZodObject<{ mandatory: z.ZodDefault; description: z.ZodOptional; + when: z.ZodOptional; }, z.core.$strip>>>; selective: z.ZodDefault; description: z.ZodOptional; + when: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>>>; }, z.core.$strip>; @@ -129,6 +151,9 @@ export declare const sevenPartContractSchema: z.ZodObject<{ key: z.ZodString; label: z.ZodString; schema: z.ZodOptional>; + ledger: z.ZodOptional; + ledgers: z.ZodOptional>; + editableAtRelease: z.ZodOptional>; }, z.core.$strip>>>; acceptanceCriteria: z.ZodDefault; modelRef: z.ZodOptional; }, z.core.$strip>>>; + revisableAtRelease: z.ZodDefault>>; prohibitions: z.ZodDefault; primaryKey: z.ZodDefault; masked: z.ZodDefault; + required: z.ZodOptional; + tally: z.ZodOptional>; + expect: z.ZodOptional; + auto: z.ZodOptional; }, z.core.$strip>; export type FieldDeclaration = z.infer; +/** + * 台账对象的**性质** —— 这条数据归谁管、活多久、谁有权改它。 + * + * 从前台账只有「地址 + 列」,没有这一维,于是**没人需要回答「这张表是什么」**—— + * 后果不是不好看,是主键写歪了还没人发现:A4 的报价草案本该一单一份, + * 主键却只有 `pn`,第二单报同一个料号直接 upsert 覆盖上一单 + * (实测:跑了四轮 A4,`quote_pricing.draft` 只剩最后一轮的 3 行; + * 而主键带了单号的 `quote_intake.record` 是 61 行 / 20 单,一单不少)。 + * + * 分三类,判据是**机械的**,不靠感觉: + * + * · `record` 单据 —— 一单一份的过程与结论。主键**必含单标识**(`caseKey`), + * 放行即定稿、只增不改。它不需要「管理」,需要的是查得到、回放得出。 + * · `master` 主数据 —— 跨单积累的经营资产(客户、供应商、料号规格、历史价)。 + * 主键是**业务实体**,会被反复更新,下一单读它做判断。治理都在这一类上: + * 谁能改、依据是什么、怎么去重、多久算过期。 + * · `derived` 派生 —— 能由前两类算出来的指标(落地率、命中率)。 + * **本不该手写**:手写的派生值能和它的来源打架,而没人会发现。 + * 标出来至少让它可被重算校验。 + */ +export declare const LEDGER_OBJECT_KINDS: readonly ["record", "master", "derived"]; +export declare const ledgerObjectKindSchema: z.ZodEnum<{ + record: "record"; + master: "master"; + derived: "derived"; +}>; +export type LedgerObjectKind = z.infer; +export declare const LEDGER_KIND_LABEL: Readonly>; +export declare const LEDGER_KIND_DESC: Readonly>; export declare const objectDeclarationSchema: z.ZodObject<{ domain: z.ZodString; object: z.ZodString; label: z.ZodOptional; + kind: z.ZodDefault>; + caseKey: z.ZodOptional; fields: z.ZodArray; primaryKey: z.ZodDefault; masked: z.ZodDefault; + required: z.ZodOptional; + tally: z.ZodOptional>; + expect: z.ZodOptional; + auto: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>; export type ObjectDeclaration = z.infer; diff --git a/api-report/packages/contracts/src/manifest.d.ts b/api-report/packages/contracts/src/manifest.d.ts index 526d1c7..9b1e4ff 100644 --- a/api-report/packages/contracts/src/manifest.d.ts +++ b/api-report/packages/contracts/src/manifest.d.ts @@ -42,6 +42,12 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{ domain: z.ZodString; object: z.ZodString; label: z.ZodOptional; + kind: z.ZodDefault>; + caseKey: z.ZodOptional; fields: z.ZodArray; primaryKey: z.ZodDefault; masked: z.ZodDefault; + required: z.ZodOptional; + tally: z.ZodOptional>; + expect: z.ZodOptional; + auto: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>>>; listeners: z.ZodDefault>; }, z.core.$strip>; export type DmnModel = z.infer; +/** + * **步骤剧本** —— 一个计划项一条,把「这一步是什么状态」写成机器读得懂的东西。 + * + * 为什么要有它(2026-07-31 加,起因是 A1 反复空转): + * 从前每一拍喂给模型的是**整本说明书 + 全部工具 + 全部可写台账**, + * 模型得自己从六步里认出「我现在在第几步」。认对了还好,认岔了就开始转圈 —— + * 实测 S1 已经把八字段写齐了,而说明书里 S3 写着「把八字段补到齐」, + * 模型于是把同样三行又写一遍,被「写了等于没写」拒回,下一拍再写一遍,如此往复。 + * + * 治法不是把说明书写得更啰嗦,是**把状态挪进引擎**: + * ① `doneWhen` 让引擎自己算得出「这一步的成果已经在那儿了」—— 是就直接收掉, + * **不问模型**(能算的不问 LLM:同输入必同结论、零 token); + * ② `guide` / `writes` / `tools` 让每一拍的上下文**只装当前这一步要用的东西** —— + * 上下文小了,模型跑偏的余地也就小了。 + * + * 不写剧本的活动照旧跑(整本说明书 + 全部工具),只是没有这两样好处。 + */ +export declare const planStepSchema: z.ZodObject<{ + item: z.ZodString; + intent: z.ZodString; + guide: z.ZodString; + writes: z.ZodDefault>; + reads: z.ZodDefault>; + view: z.ZodOptional; + tools: z.ZodDefault>; + doneWhen: z.ZodOptional; +}, z.core.$strip>; +export type PlanStep = z.infer; +/** + * 阶段 —— **给人看的进度刻度**,引擎一眼都不看。 + * + * 计划项是给引擎的:一件事拆到能判「干完没有」的粒度,A1 有六项。 + * 可六格进度条对人没有意义 —— 「查历史价 / 打标 / 定去向」在业务上是**一件事** + * (判这单该怎么走),人关心的是「到哪一步了」,不是引擎拆了几项。 + * 所以另立一层:几个计划项合成一个阶段,进度条按阶段走。 + * + * **只声明激活态的阶段**。结束态不在这里 —— 每个活动都以放行收口(铁律③), + * 那是系统的规矩不是活动的阶段;进度条自己在末尾加这一格。 + */ +export declare const casePhaseSchema: z.ZodObject<{ + name: z.ZodString; + items: z.ZodArray; +}, z.core.$strip>; +export type CasePhase = z.infer; export declare const loopModelSchema: z.ZodObject<{ cmmnXml: z.ZodString; narrative: z.ZodString; @@ -187,6 +241,20 @@ export declare const loopModelSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$strip>>; }, z.core.$strip>; + steps: z.ZodOptional>; + reads: z.ZodDefault>; + view: z.ZodOptional; + tools: z.ZodDefault>; + doneWhen: z.ZodOptional; + }, z.core.$strip>>>; + phases: z.ZodOptional; + }, z.core.$strip>>>; }, z.core.$strip>; export type LoopModel = z.infer; /** @@ -227,6 +295,20 @@ export declare const businessModelSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$strip>>; }, z.core.$strip>; + steps: z.ZodOptional>; + reads: z.ZodDefault>; + view: z.ZodOptional; + tools: z.ZodDefault>; + doneWhen: z.ZodOptional; + }, z.core.$strip>>>; + phases: z.ZodOptional; + }, z.core.$strip>>>; }, z.core.$strip>; export type BusinessModel = z.infer; export declare const skillSchema: z.ZodObject<{ @@ -276,6 +358,20 @@ export declare const manifestAssetsSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$strip>>; }, z.core.$strip>; + steps: z.ZodOptional>; + reads: z.ZodDefault>; + view: z.ZodOptional; + tools: z.ZodDefault>; + doneWhen: z.ZodOptional; + }, z.core.$strip>>>; + phases: z.ZodOptional; + }, z.core.$strip>>>; }, z.core.$strip>; skills: z.ZodDefault; @@ -331,12 +427,20 @@ export declare const activityManifestSchema: z.ZodObject<{ data: z.ZodRecord; messageAllowed: z.ZodDefault; messageHint: z.ZodOptional; + inboundComm: z.ZodOptional; + partyField: z.ZodString; + subjectField: z.ZodOptional; + bodyField: z.ZodOptional; + attachmentsField: z.ZodOptional; + }, z.core.$strip>>; }, z.core.$strip>; output: z.ZodObject<{ mandatory: z.ZodDefault; description: z.ZodOptional; + when: z.ZodOptional; }, z.core.$strip>>>; selective: z.ZodDefault; description: z.ZodOptional; + when: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>>>; }, z.core.$strip>; @@ -351,6 +456,9 @@ export declare const activityManifestSchema: z.ZodObject<{ key: z.ZodString; label: z.ZodString; schema: z.ZodOptional>; + ledger: z.ZodOptional; + ledgers: z.ZodOptional>; + editableAtRelease: z.ZodOptional>; }, z.core.$strip>>>; acceptanceCriteria: z.ZodDefault; modelRef: z.ZodOptional; }, z.core.$strip>>>; + revisableAtRelease: z.ZodDefault>>; prohibitions: z.ZodDefault; + kind: z.ZodDefault>; + caseKey: z.ZodOptional; fields: z.ZodArray; primaryKey: z.ZodDefault; masked: z.ZodDefault; + required: z.ZodOptional; + tally: z.ZodOptional>; + expect: z.ZodOptional; + auto: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>>>; listeners: z.ZodDefault>; }, z.core.$strip>>; }, z.core.$strip>; + steps: z.ZodOptional>; + reads: z.ZodDefault>; + view: z.ZodOptional; + tools: z.ZodDefault>; + doneWhen: z.ZodOptional; + }, z.core.$strip>>>; + phases: z.ZodOptional; + }, z.core.$strip>>>; }, z.core.$strip>; skills: z.ZodDefault; @@ -552,6 +689,9 @@ export declare const staticAuditFindingSchema: z.ZodObject<{ "dynamic-eval": "dynamic-eval"; "ledger-type-conflict": "ledger-type-conflict"; "acceptance-level-misreported": "acceptance-level-misreported"; + "acceptance-predicate-unbound": "acceptance-predicate-unbound"; + "deliverable-address-unbound": "deliverable-address-unbound"; + "ledger-kind-invalid": "ledger-kind-invalid"; }>; severity: z.ZodEnum<{ error: "error"; @@ -590,6 +730,9 @@ export declare const installVerdictSchema: z.ZodObject<{ "dynamic-eval": "dynamic-eval"; "ledger-type-conflict": "ledger-type-conflict"; "acceptance-level-misreported": "acceptance-level-misreported"; + "acceptance-predicate-unbound": "acceptance-predicate-unbound"; + "deliverable-address-unbound": "deliverable-address-unbound"; + "ledger-kind-invalid": "ledger-kind-invalid"; }>; severity: z.ZodEnum<{ error: "error"; diff --git a/api-report/packages/contracts/src/org.d.ts b/api-report/packages/contracts/src/org.d.ts index 609f25f..78f519c 100644 --- a/api-report/packages/contracts/src/org.d.ts +++ b/api-report/packages/contracts/src/org.d.ts @@ -41,9 +41,9 @@ export declare const GRANT_ACTION_LABEL: Readonly>; /** 治理台九功能 —— 授权客体之一 */ export declare const GOVERNANCE_FUNCTIONS: readonly ["task", "staff", "process", "trigger", "activity", "tool", "data", "memory", "system"]; export declare const governanceFunctionSchema: z.ZodEnum<{ + trigger: "trigger"; data: "data"; task: "task"; - trigger: "trigger"; system: "system"; tool: "tool"; activity: "activity"; @@ -61,9 +61,9 @@ export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"governance">; function: z.ZodEnum<{ + trigger: "trigger"; data: "data"; task: "task"; - trigger: "trigger"; system: "system"; tool: "tool"; activity: "activity"; @@ -86,9 +86,9 @@ export declare const grantSchema: z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"governance">; function: z.ZodEnum<{ + trigger: "trigger"; data: "data"; task: "task"; - trigger: "trigger"; system: "system"; tool: "tool"; activity: "activity"; @@ -152,9 +152,9 @@ export declare const sessionSchema: z.ZodObject<{ }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"governance">; function: z.ZodEnum<{ + trigger: "trigger"; data: "data"; task: "task"; - trigger: "trigger"; system: "system"; tool: "tool"; activity: "activity"; diff --git a/api-report/packages/contracts/src/terminal.d.ts b/api-report/packages/contracts/src/terminal.d.ts index d6d2176..318f2a5 100644 --- a/api-report/packages/contracts/src/terminal.d.ts +++ b/api-report/packages/contracts/src/terminal.d.ts @@ -60,12 +60,13 @@ export declare const workflowStateSchema: z.ZodEnum<{ }>; export type WorkflowState = z.infer; export declare const WORKFLOW_STATE_LABEL: Readonly>; -export declare const ATTRIBUTION_SIDES: readonly ["machine", "human", "human-verdict", "world-wait"]; +export declare const ATTRIBUTION_SIDES: readonly ["machine", "human", "human-verdict", "world-wait", "trigger"]; export declare const attributionSideSchema: z.ZodEnum<{ machine: "machine"; human: "human"; "human-verdict": "human-verdict"; "world-wait": "world-wait"; + trigger: "trigger"; }>; export type AttributionSide = z.infer; export declare const ATTRIBUTION_SIDE_LABEL: Readonly>; diff --git a/apps/api/package.json b/apps/api/package.json index e389b21..48c9895 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,7 +13,8 @@ "seed": "node dist/seed.js", "migrate": "node dist/migrate.js", "set-password": "node dist/set-password.js", - "reset-runs": "node dist/reset-runs.js" + "reset-runs": "node dist/reset-runs.js", + "install-activities": "node dist/install-activities.js" }, "dependencies": { "@ai-sdk/openai-compatible": "^3.0.14", diff --git a/apps/api/src/app/__tests__/acceptance-gate.test.ts b/apps/api/src/app/__tests__/acceptance-gate.test.ts new file mode 100644 index 0000000..0b80ea0 --- /dev/null +++ b/apps/api/src/app/__tests__/acceptance-gate.test.ts @@ -0,0 +1,349 @@ +/** + * 验收判据的达标口径 —— **这段错了不报错,只是那张卡上永远挂着消不掉的红叉**。 + * + * 用户实测报的就是它:一单办得干干净净,卡面却停在 5/8 —— 因为 L3/L4 这类 + * 机器判不了的判据被写死成 `false`,而 Agent 的 `judge` 裁定只进了留痕、 + * 从没回流到判据。于是「达标数」这个读数失去意义(反正它永远不满), + * 人签字时面对一排红叉,只能凭感觉签,前面那些机检也就白做了。 + * + * 口径(用户 2026-07-31 定):**提交放行时只许剩「人放行」那一条**。 + * 机检未过、或裁量判据一条没裁,都不该发卡 —— 那不是「请你验收」, + * 是把没办完的活推给人。 + * + * 这里测的是那条判定规则本身(纯函数级),不拉起整个装配层。 + */ +import { describe, expect, it } from 'vitest'; +import { evaluateCondition } from '@sfeel'; +import { machineCheckable } from '@contracts'; +import type { AcceptanceCriterion } from '@contracts'; +/* + * 判据名归一**用生产的那一个**,不再在测里抄一份。 + * 抄一份的下场刚踩过:生产那边加了「削掉引擎自己贴的等级标记」, + * 测里这份还是旧的 —— 测全绿,线上照样连拒五拍熔断。 + */ +import { criterionKey } from '@modules/loop-engine'; +import { criterionCheck } from '../wiring/engines.ts'; + + +/** 与 `engines.ts` 里那段判定同一口径 —— 改一处这里要跟着改,不然测的是旧规矩 */ +function passOf( + c: AcceptanceCriterion, + scope: Record, + judged: ReadonlyMap, +): boolean { + if (machineCheckable(c)) { + if (c.predicate === undefined || c.predicate.trim() === '') return false; + try { + return evaluateCondition(c.predicate, scope); + } catch { + return false; + } + } + // L5 = 人放行:只有人按下去才算,引擎与 Agent 都不许代答 + if (c.level === 'L5') return false; + return judged.get(criterionKey(c.key)) ?? judged.get(criterionKey(c.label)) ?? false; +} + +/** + * 提交门 —— **两类判据把两道不同的关**(与 `engines.ts` 那段同一口径): + * · 机检(L1/L2):数不对就是活没干完 —— 必须为真; + * · 裁量(L3/L4):必须**裁过**,裁成什么不拦。 + * + * 后半句是 2026-08-01 用实测改回来的:从前对裁量项也要求「为真」, + * 而裁量的本义恰恰是可以裁出「不满足」,且不满足正是最该交给人的情形。 + * 于是模型只剩两条路 —— 改数据,或改裁断,两条都是撒谎,它两条都走过 + * (先把 missing_minor_total 从 1 改回 0;换成 L4 后又把「未满足」翻成「满足」)。 + * **一条只接受「是」的判据不是判据,是一句教模型说谎的提示。** + */ +const submittable = ( + criteria: readonly AcceptanceCriterion[], + scope: Record, + judged: ReadonlyMap, +): string[] => + criteria + .filter((c) => { + if (c.level === 'L5') return false; + if (machineCheckable(c)) return !passOf(c, scope, judged); + // 裁量项:裁过就放行提交,裁断本身随卡交给人 + return judged.get(criterionKey(c.key)) === undefined && judged.get(criterionKey(c.label)) === undefined; + }) + .map((c) => c.label); + +/** 照 A1 的判据表建一份(等级与谓词都取真的) */ +const CRITERIA: AcceptanceCriterion[] = [ + { key: 'lines-present', label: '至少归一出一条需求', level: 'L1', predicate: 'line_total > 0' }, + { key: 'fields-complete', label: '三要素齐(PN/EAU/交期)', level: 'L1', predicate: 'missing_key_total = 0' }, + { key: 'typed', label: '每条都已打标 A/B/C', level: 'L2', predicate: 'untyped_total = 0' }, + { key: 'downgrade-safe', label: '吃不准降 B,不许臆断 A', level: 'L3' }, + { key: 'route-judgement', label: '去向判断合乎业务常理', level: 'L4' }, + { key: 'owner-release', label: 'BD 放行', level: 'L5' }, +]; + +/** 台账都写齐了的局面 */ +const DONE: Record = { line_total: 3, missing_key_total: 0, untyped_total: 0 }; + +describe('裁量判据读 Agent 的裁定 —— 不再一律判 false', () => { + it('**没人裁过 ≠ 通过** —— 一条没裁时 L3/L4 仍是未达标', () => { + const judged = new Map(); + expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(false); + expect(passOf(CRITERIA[4]!, DONE, judged)).toBe(false); + }); + + it('裁定为满足 → 达标', () => { + const judged = new Map([[criterionKey('吃不准降 B,不许臆断 A'), true]]); + expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(true); + }); + + /* + * **这一条是拿实测的事故写的**:模型把判据名抄成了全角标点、还吃掉了空格 + * (「吃不准降B,不许臆断A」),逐字比对当然对不上 —— 那条判据永远未达标, + * 提交放行被拒 32 次直到预算烧完(exhausted)。一个标点差异吃掉一整单。 + */ + it('**全角标点 / 少空格照样认得出** —— 一个标点不该吃掉一整单', () => { + const judged = new Map([ + [criterionKey('吃不准降B,不许臆断A'), true], + [criterionKey('去向判断合乎业务常理(客户特殊要求、急单等)'), true], + ]); + expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(true); + const l4: AcceptanceCriterion = { + key: 'route-judgement', + label: '去向判断合乎业务常理(客户特殊要求、急单等)', + level: 'L4', + }; + expect(passOf(l4, DONE, judged)).toBe(true); + }); + + it('归一不做模糊匹配 —— 两条不同的判据不许混成一条', () => { + const judged = new Map([[criterionKey('吃不准降 B'), true]]); + expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(false); + }); + + it('裁定为不满足 → 照旧未达标(裁了不等于过)', () => { + const judged = new Map([[criterionKey('吃不准降 B,不许臆断 A'), false]]); + expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(false); + }); + + /* 模型写 key 还是写中文名都可能 —— 两种都认,免得一个称呼差异吃掉一条判据 */ + it('criterion 用 key 写也认', () => { + const judged = new Map([[criterionKey('downgrade-safe'), true]]); + expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(true); + }); + + /* + * L5 是这道门的底:它必须由人按下去。 + * 给 Agent 留一条「裁定自己放行」的缝,终局唯放行就成了一句空话。 + */ + it('**L5 人放行谁也代答不了** —— 连 judge 裁了也不算', () => { + const judged = new Map([[criterionKey('BD 放行'), true], [criterionKey('owner-release'), true]]); + expect(passOf(CRITERIA[5]!, DONE, judged)).toBe(false); + }); +}); + +describe('提交放行只许剩「人放行」那一条', () => { + it('机检没过就提交 —— 拒,并说得出是哪一条', () => { + const gappy = { line_total: 3, missing_key_total: 2, untyped_total: 0 }; + const judged = new Map([ + [criterionKey('吃不准降 B,不许臆断 A'), true], + [criterionKey('去向判断合乎业务常理'), true], + ]); + expect(submittable(CRITERIA, gappy, judged)).toEqual(['三要素齐(PN/EAU/交期)']); + }); + + it('机检全过但裁量项一条没裁 —— 照样拒(这正是 5/8 那一单)', () => { + expect(submittable(CRITERIA, DONE, new Map())).toEqual(['吃不准降 B,不许臆断 A', '去向判断合乎业务常理']); + }); + + /* + * 裁成「不满足」照样能提交 —— 这一条是 2026-08-01 拿两轮实测换来的: + * 门只接受「是」的时候,模型先改数据、后改裁断,两轮都撒了谎才过得去。 + * 不满足连着理由进验收卡,才是签字的人该看见的东西。 + */ + it('**裁量裁成「不满足」也能提交** —— 判断交给人,不是逼 AI 说是', () => { + const judged = new Map([ + [criterionKey('吃不准降 B,不许臆断 A'), false], + [criterionKey('去向判断合乎业务常理'), false], + ]); + expect(submittable(CRITERIA, DONE, judged)).toEqual([]); + // 但它在卡面上仍是未达标 —— 提交得了,不等于这一条过了 + expect(passOf(CRITERIA[3]!, DONE, judged)).toBe(false); + }); + + it('机检没过时,裁量裁了也拦得住 —— 两道关互不代替', () => { + const gappy = { line_total: 3, missing_key_total: 2, untyped_total: 0 }; + const judged = new Map([ + [criterionKey('吃不准降 B,不许臆断 A'), false], + [criterionKey('去向判断合乎业务常理'), false], + ]); + expect(submittable(CRITERIA, gappy, judged)).toEqual(['三要素齐(PN/EAU/交期)']); + }); + + it('**机检全过 + 裁量都裁了 → 放行,此时只剩人那一条**', () => { + const judged = new Map([ + [criterionKey('吃不准降 B,不许臆断 A'), true], + [criterionKey('去向判断合乎业务常理'), true], + ]); + expect(submittable(CRITERIA, DONE, judged)).toEqual([]); + // 剩下的那一条恰恰是 L5,并且它此刻确实还没达标 —— 等人按 + expect(CRITERIA.filter((c) => !passOf(c, DONE, judged)).map((c) => c.level)).toEqual(['L5']); + }); + + it('L1/L2 漏配 predicate 判 false —— 装不出证据就不算过', () => { + const noPredicate: AcceptanceCriterion[] = [{ key: 'x', label: '没写谓词的机检项', level: 'L2' }]; + expect(submittable(noPredicate, DONE, new Map())).toEqual(['没写谓词的机检项']); + }); +}); + +/** + * **卡面上不摆 L5**(2026-08-01 用户当场指出:「当前卡片就是放行卡, + * 也不该显示在这里 —— 这里是判据」)。 + * + * L5 是责任关,而放行卡本身就是那道关。把它列进判据表,等于在卡上摆一条 + * 恒为「未达标」的项:人一路核到底,最后一行永远是红的,而消掉它的动作 + * 就是按下同一张卡上的「确认通过」;「n/m 达标」那个数也因此永远差一条。 + * 判据是**签字前要核的东西**,不是签字这件事自己。 + * + * 声明照旧留在契约里(它说明这份交付物必须由人放行),提交门与预检也照旧 + * 把它排除在外 —— 少的只是这一处呈现。这一组把两件事分开钉死。 + */ +describe('放行卡的判据表:摆 L1–L4,不摆 L5', () => { + /** 与 `engines.ts` 组卡那一处同款:落到卡面之前滤掉 L5 */ + const onCard = (list: readonly AcceptanceCriterion[]) => list.filter((c) => c.level !== 'L5'); + + it('卡面判据里没有 L5,而 L1–L4 一条不少', () => { + const shown = onCard(CRITERIA); + expect(shown.map((c) => c.level)).toEqual(['L1', 'L1', 'L2', 'L3', 'L4']); + expect(shown.some((c) => c.label === 'BD 放行')).toBe(false); + }); + + it('**「n/m 达标」不再永远差一条** —— 全办完时卡面是满的', () => { + const judged = new Map([ + [criterionKey('吃不准降 B,不许臆断 A'), true], + [criterionKey('去向判断合乎业务常理'), true], + ]); + const shown = onCard(CRITERIA); + const passN = shown.filter((c) => passOf(c, DONE, judged)).length; + expect(`${passN}/${shown.length}`).toBe('5/5'); + // 摆着 L5 的时候它永远是 5/6 —— 最后一行只有按下按钮才会绿 + expect(CRITERIA.filter((c) => passOf(c, DONE, judged)).length).toBe(5); + }); + + it('契约里仍然声明着 L5 —— 少的只是呈现,不是那道关', () => { + expect(CRITERIA.some((c) => c.level === 'L5')).toBe(true); + // 提交门照旧不因为它拦人(它由人按下去消掉,不由 Agent 办) + const judged = new Map([ + [criterionKey('吃不准降 B,不许臆断 A'), true], + [criterionKey('去向判断合乎业务常理'), true], + ]); + expect(submittable(CRITERIA, DONE, judged)).toEqual([]); + }); +}); + +/** + * **裁量判据带谓词时,谓词说了算**(2026-08-02 用户实测逼出来的)。 + * + * 「选填缺口:已补齐,或由你放行时确认可以先受理」是个复合命题: + * 前半句(已补齐)基座数得出来,后半句(人确认先受理)才需要人。 + * 从前整条只读裁断,于是模型凭记忆写理由 —— 用户在件上把目标价填好了, + * 它照样裁「未满足」,理由写「NE555P 缺目标价,客户未提供」。 + * 用户原话:「这个价格是我填写的,但是它判错了」。 + * + * **数得出来的就别让它自报** —— 这条纪律在这个仓里第三次被违反。 + */ +describe('criterionCheck —— 卡面与提交门同一段', () => { + const optional = { + key: 'optional-asked', + label: '选填缺口:已补齐,或由你放行时确认可以先受理', + level: 'L4' as const, + predicate: 'quote_intake_record_gaps = 0', + }; + const stale = { satisfied: false, rationale: 'NE555P 缺目标价,客户未提供,已按历史价估 8 元' }; + + it('**数据说补齐了 → 达标,模型那句相反的话一并丢掉**', () => { + const got = criterionCheck(optional, { quote_intake_record_gaps: 0 }, stale); + expect(got.pass).toBe(true); + expect(got.settled).toBe(true); + expect(got.rationale).toBeUndefined(); // 一件已办成的事,不需要谁再解释为什么没办成 + }); + + it('数据说还缺着 → 轮到裁量:裁断与理由照旧交给签字的人', () => { + const got = criterionCheck(optional, { quote_intake_record_gaps: 2 }, stale); + expect(got.pass).toBe(false); + expect(got.settled).toBe(true); // 裁过了(裁成不满足照样能提交) + expect(got.rationale).toContain('NE555P'); + }); + + it('数据说还缺着、又没人裁过 → 未达标且**未裁**,提交门该拦住', () => { + const got = criterionCheck(optional, { quote_intake_record_gaps: 2 }, undefined); + expect(got.pass).toBe(false); + expect(got.settled).toBe(false); + }); + + it('**不带谓词的裁量项照旧只读裁断** —— 机器判不了的别硬判', () => { + const l3 = { key: 'downgrade-safe', label: '吃不准降 B,不许臆断 A', level: 'L3' as const }; + expect(criterionCheck(l3, {}, undefined).pass).toBe(false); + expect(criterionCheck(l3, {}, { satisfied: true }).pass).toBe(true); + }); + + it('机检项不掺裁断 —— 模型裁「满足」也翻不动一个假的数', () => { + const l1 = { key: 'k', label: '必填齐', level: 'L1' as const, predicate: 'quote_intake_record_missing = 0' }; + expect(criterionCheck(l1, { quote_intake_record_missing: 1 }, { satisfied: true }).pass).toBe(false); + expect(criterionCheck(l1, { quote_intake_record_missing: 1 }, { satisfied: true }).settled).toBeUndefined(); + }); + + it('L5 只有人按得下 —— 引擎与 Agent 都不许代答', () => { + const l5 = { key: 'bd', label: 'BD 放行', level: 'L5' as const, predicate: '1 = 1' }; + expect(criterionCheck(l5, {}, { satisfied: true }).pass).toBe(false); + }); +}); + +/** + * **「活没干完」得说成活没干完**(2026-08-02 实测逼出来的)。 + * + * 那一单跳过了打标与定去向就来提交:判据正确地拦住了,而拒回的话给了两条路 —— + * 改数、改行。两条都不对:数是对的(它诚实地报了「3 条没打标」)、 + * 行也是对的(记录三行一行不多),**真正该做的是回去把那一步办了**。 + * 于是它只能原样重提,连撞五拍熔断。 + * + * 判法很硬:判据里点名的那张表,这一单一行都没写(基座数的 `_rows` 是 0), + * 那就不是数错也不是行错,是那一步压根没办。 + */ +describe('拒回指路:一行都没写 = 那一步还没办', () => { + /** 与 `engines.ts` 那段挑表同一口径 —— 改一处这里要跟着改 */ + const emptyTables = (predicates: readonly string[], scope: Record): string[] => [ + ...new Set( + predicates.flatMap((p) => + [...p.matchAll(/([a-z][a-z0-9_]*)_rows\b/g)] + .map((m) => m[1] ?? '') + .filter((name) => name !== '' && Number(scope[`${name}_rows`] ?? -1) === 0), + ), + ), + ]; + + it('**那张表一行都没写 → 点名它**,别让模型去改数改行', () => { + const got = emptyTables(['quote_intake_type_table_rows = quote_intake_record_rows'], { + quote_intake_type_table_rows: 0, + quote_intake_record_rows: 3, + }); + expect(got).toEqual(['quote_intake_type_table']); + }); + + it('表写过了、只是数对不上 → 不点名,照旧走「数还是行」那条指路', () => { + const got = emptyTables(['quote_intake_type_table_rows = quote_intake_record_rows'], { + quote_intake_type_table_rows: 2, + quote_intake_record_rows: 3, + }); + expect(got).toEqual([]); + }); + + it('读不到那个数就不猜 —— 宁可少说一句,不可编一句', () => { + expect(emptyTables(['quote_intake_type_table_rows = 3'], {})).toEqual([]); + }); + + it('几条判据点到同一张表只说一遍', () => { + const got = emptyTables( + ['quote_intake_type_table_rows = quote_intake_record_rows', 'quote_intake_type_table_rows > 0'], + { quote_intake_type_table_rows: 0, quote_intake_record_rows: 3 }, + ); + expect(got).toEqual(['quote_intake_type_table']); + }); +}); diff --git a/apps/api/src/app/__tests__/agent-action-normalize.test.ts b/apps/api/src/app/__tests__/agent-action-normalize.test.ts new file mode 100644 index 0000000..ce6972b --- /dev/null +++ b/apps/api/src/app/__tests__/agent-action-normalize.test.ts @@ -0,0 +1,222 @@ +/** + * 模型出参归一 —— **宽进到什么程度**,这里说死。 + * + * 每一条宽进都对应一次实测踩坑:模型把字段叫成另一个名字、把地址整串塞进一格、 + * 把行数组写成半个形状。这些差异吃掉的是一整拍(有的还白发一张卡给人), + * 而它们全都不涉及「行动合不合规」—— 合规照旧由引擎拒。 + * + * 反过来也要守住:看不懂就转人,**不许猜着往世界上动手**。 + */ +import { describe, expect, it } from 'vitest'; +import type { EnabledSet } from '@modules/loop-engine'; +import { normalizeAction, normalizeTurn } from '../agent-runner.ts'; + +const ENABLED: EnabledSet = { + entries: [{ id: 'T1', name: '干活', type: 'humanTask', requiresManualStart: false, discretionary: false }], + inProgress: [], + judgements: [], + gaps: ['缺记录'], + activeStages: [], +}; + +describe('行动型的叫法', () => { + it('`kind` 也认 `action` —— 实测模型这么写过,其余字段一字不差', () => { + const action = normalizeAction( + { action: 'write', itemId: 'T1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] }, + ENABLED, + ); + expect(action.kind).toBe('write'); + }); + + it('两个都给时以 `kind` 为准 —— 契约字段优先,别名只是兜底', () => { + expect(normalizeAction({ kind: 'submit', action: 'write', rationale: 'r' }, ENABLED).kind).toBe('submit'); + }); +}); + +describe('台账地址的写法', () => { + const rows = [{ key: { pn: 'X' }, patch: { eau: 1 } }]; + const addressOf = (raw: Record) => { + const action = normalizeAction({ kind: 'write', itemId: 'T1', rows, ...raw }, ENABLED); + return action.kind === 'write' ? action.address : '(不是 write)'; + }; + + it('规范写法:address 一个字符串', () => { + expect(addressOf({ address: 'quote_intake.record' })).toBe('quote_intake.record'); + }); + + it('整串塞进 object 里(实测第一拍就这么错)', () => { + expect(addressOf({ object: 'quote_intake.record' })).toBe('quote_intake.record'); + }); + + it('整串塞进 domain 里', () => { + expect(addressOf({ domain: 'quote_intake.record' })).toBe('quote_intake.record'); + }); + + it('老老实实拆成两格也认', () => { + expect(addressOf({ domain: 'quote_intake', object: 'record' })).toBe('quote_intake.record'); + }); + + it('什么都没给 → 空地址,交引擎当场拒(不猜)', () => { + expect(addressOf({})).toBe(''); + }); +}); + +describe('写行的形状', () => { + const rowsOf = (raw: unknown) => { + const action = normalizeAction({ kind: 'write', itemId: 'T1', address: 'd.o', rows: raw }, ENABLED); + return action.kind === 'write' ? action.rows : []; + }; + + it('形状不对的行**丢掉而不是猜** —— 猜错就往台账落一行来路不明的数', () => { + expect(rowsOf([{ key: { pn: 'X' }, patch: { eau: 1 } }, { pn: 'Y', eau: 2 }, null, 7])).toEqual([ + { key: { pn: 'X' }, patch: { eau: 1 } }, + ]); + }); + + it('主键值归一为字符串或数字 —— 键要能稳定比对', () => { + expect(rowsOf([{ key: { pn: 'X', qty: 3, flag: true }, patch: {} }])).toEqual([ + { key: { pn: 'X', qty: 3, flag: 'true' }, patch: {} }, + ]); + }); +}); + +describe('看不懂就转人,不猜着动手', () => { + it('行动型不认识 → 发执行卡,并把**模型原话**摆上去', () => { + const action = normalizeAction({ kind: '我自己想的行动' }, ENABLED, '{"kind":"我自己想的行动"}'); + expect(action.kind).toBe('human'); + if (action.kind !== 'human') return; + expect(action.card['reason']).toBe('模型未给出合规的行动'); + // 只写「不合规」的话,收卡的人分不清是模型跑偏、行动型少了一种、还是提示词没交代清楚 + expect(String(action.card['modelReply'])).toContain('我自己想的行动'); + expect(action.card['kindSeen']).toBe('我自己想的行动'); + }); + + it('立监听没给时限 → 兜底 P3D,绝不留幽灵等待', () => { + const action = normalizeAction({ kind: 'listen', itemId: 'T1', eventType: 'data' }, ENABLED); + expect(action.kind === 'listen' && action.deadline).toBe('P3D'); + }); +}); + +/** + * 补全卡的格子在哪儿 —— **两处都认**。 + * + * 提示词里执行工单的写法是 `card.fields`,模型很自然把补全卡也那么写(实测就是), + * 于是每一拍都被「fields 为空」拒回来。空不空由引擎验,这里只负责不让一个位置差异吃掉一整拍。 + */ +describe('补全卡的格子', () => { + const fieldsOf = (raw: Record) => { + const a = normalizeAction({ kind: 'ask', itemId: 'T1', ...raw }, ENABLED); + return a.kind === 'ask' ? a.fields : []; + }; + + it('顶层 fields', () => { + expect(fieldsOf({ ask: '补一下', fields: [{ key: 'lead_time', label: '交期' }] })).toEqual([ + { key: 'lead_time', label: '交期' }, + ]); + }); + + it('落在 card 里也认(实测模型这么写过)', () => { + expect(fieldsOf({ card: { ask: '补一下', fields: [{ key: 'eau', label: '年用量' }] } })).toEqual([ + { key: 'eau', label: '年用量' }, + ]); + }); + + it('写成一串字段名也认 —— label 用 key 顶上,宁可显示字段名不显示空白', () => { + expect(fieldsOf({ ask: 'x', fields: ['eau', 'lead_time'] })).toEqual([ + { key: 'eau', label: 'eau' }, + { key: 'lead_time', label: 'lead_time' }, + ]); + }); + + it('没有 key 的格子丢掉 —— 没有 key 就没法回填', () => { + expect(fieldsOf({ ask: 'x', fields: [{ label: '只有标签' }, { key: 'ok', label: '行' }] })).toEqual([ + { key: 'ok', label: '行' }, + ]); + }); + + it('一个都收不到就是空数组,交引擎拒(不替它编格子)', () => { + expect(fieldsOf({ ask: 'x' })).toEqual([]); + }); +}); + +/** + * 验收报告的**可修订字段** —— PRD:「一张工单 → 卡组一(HTML 报告 · AI 已填好、可编辑)」。 + * + * 两样都得有才落得下去:`ai` 是「已填好」的那个值,`ledger` 是改完落回哪个台账地址。 + * 少了 ai 就只能看不能改;少了 ledger,人改了也只留在工单留痕里,落地用的还是活动的原值。 + */ +describe('提交放行带的可修订字段', () => { + const fieldsOf = (raw: Record) => { + const a = normalizeAction({ kind: 'submit', rationale: 'r', ...raw }, ENABLED); + return a.kind === 'submit' ? (a.fields ?? []) : []; + }; + + it('带 ai 与 ledger 一起过来', () => { + expect( + fieldsOf({ fields: [{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' }] }), + ).toEqual([{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' }]); + }); + + it('落在 card 里也认', () => { + expect(fieldsOf({ card: { fields: [{ key: 'x', label: 'X', ai: '1' }] } })).toEqual([ + { key: 'x', label: 'X', ai: '1' }, + ]); + }); + + it('**不替它补 ledger** —— 猜出来的地址会把值写到别的对象上', () => { + expect(fieldsOf({ fields: [{ key: 'x', label: 'X', ai: '1' }] })[0]).not.toHaveProperty('ledger'); + }); + + it('没给字段就不带 —— 验收报告只看不改也是一种合法形态', () => { + const a = normalizeAction({ kind: 'submit', rationale: 'r' }, ENABLED); + expect(a.kind === 'submit' && a.fields).toBeUndefined(); + }); +}); + +/** + * 一拍出参归一 —— 三种来路都认:{actions:[…]}(规范形)、裸行动对象(旧形)、顶层数组。 + * 串里不认识的项丢掉;整串一个都不认识才退化为单张转人卡 —— + * 不能让串中途一个写坏的项给人甩一张卡。 + */ +describe('一拍一串行动的归一', () => { + it('规范形:actions 数组 + 拍级 say', () => { + const turn = normalizeTurn( + { + say: '登记完毕,顺手收场', + tag: 'conclusion', + actions: [ + { kind: 'write', itemId: 'T1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] }, + { kind: 'finish', itemId: 'T1', rationale: '齐了' }, + ], + }, + ENABLED, + ); + expect(turn.actions.map((a) => a.kind)).toEqual(['write', 'finish']); + expect(turn.say?.text).toBe('登记完毕,顺手收场'); + }); + + it('旧形:裸行动对象包成单元素串 —— 换了拍形,旧模型输出不作废', () => { + const turn = normalizeTurn({ kind: 'submit', rationale: 'r', say: '交卷' }, ENABLED); + expect(turn.actions.map((a) => a.kind)).toEqual(['submit']); + expect(turn.say?.text).toBe('交卷'); + }); + + it('串里的垃圾项丢掉,认识的留下 —— 不在批中途甩卡', () => { + const turn = normalizeTurn( + { actions: [{ kind: '胡编的' }, { kind: 'finish', itemId: 'T1' }, null, 7] }, + ENABLED, + ); + expect(turn.actions.map((a) => a.kind)).toEqual(['finish']); + }); + + it('整串一个都不认识 → 单张转人卡,模型原话摆上去', () => { + const turn = normalizeTurn({ actions: [{ kind: '胡编的' }] }, ENABLED, '{"kind":"胡编的"}'); + expect(turn.actions).toHaveLength(1); + expect(turn.actions[0]?.kind).toBe('human'); + }); + + it('超出封顶截到 6 个 —— 引擎侧还会再守一道', () => { + const many = Array.from({ length: 9 }, (_, i) => ({ kind: 'finish', itemId: `T${i}` })); + expect(normalizeTurn({ actions: many }, ENABLED).actions).toHaveLength(6); + }); +}); diff --git a/apps/api/src/app/__tests__/bound-table.test.ts b/apps/api/src/app/__tests__/bound-table.test.ts new file mode 100644 index 0000000..b261740 --- /dev/null +++ b/apps/api/src/app/__tests__/bound-table.test.ts @@ -0,0 +1,87 @@ +/** + * 绑台账的可编辑表 —— **行由基座铺,不由活动塞**。 + * + * 人要在卡上改的是那份清单本身(PRD 的执行页就是整份逐格可改)。 + * 让活动把整份塞进回复里有两处死结,两处都实测栽过: + * · 回复有长度上限,铺长了半途截断,整拍作废; + * · 那些行台账里已经有了,再抄一遍是拿 token 换一次抄错的机会 + * (抄的时候把 BOM 原样的 30 填了进去,而台账里已经是 30000)。 + * + * 这里测的是那段铺行的纯逻辑:同一份实现在 `engines.ts` 的 `fillBoundTables`。 + */ +import { describe, expect, it } from 'vitest'; + +type Draft = { domain: string; object: string; key: Record; patch: Record }; + +/** 与 `engines.ts` 的 `fillBoundTables` 同一口径 —— 改一处这里要跟着改 */ +function fill(drafts: readonly Draft[], blocks: readonly unknown[]): unknown[] { + return blocks.map((block) => { + if (typeof block !== 'object' || block === null) return block; + const b = block as Record; + if (b['t'] !== 'table' || typeof b['ledger'] !== 'string' || !Array.isArray(b['fields'])) return block; + const address = b['ledger']; + const fields = (b['fields'] as unknown[]).map((f) => String(f)); + const rows = drafts + .filter((d) => `${d.domain}.${d.object}` === address) + .map((d) => { + const row = { ...d.key, ...d.patch } as Record; + return fields.map((f) => { + const v = row[f]; + return v === null || v === undefined ? '' : String(v); + }); + }); + return rows.length === 0 ? block : { ...b, rows }; + }); +} + +const DRAFTS: Draft[] = [ + { domain: 'quote_intake', object: 'record', key: { pn: 'LM358DR' }, patch: { eau: 12000, brand: 'TI' } }, + { domain: 'quote_intake', object: 'record', key: { pn: 'NE555P' }, patch: { eau: 5000, brand: '' } }, + { domain: 'quote_intake', object: 'verdict', key: { rfq_ref: 'R1' }, patch: { line_total: 2 } }, +]; + +describe('绑台账的表', () => { + const block = { + t: 'table', + title: '有效 PN 清单', + editable: true, + ledger: 'quote_intake.record', + cols: ['PN', '年用量', '制造商'], + fields: ['pn', 'eau', 'brand'], + }; + + it('**整份铺出来** —— 一条料一行,主键也在(它是对行的钥匙)', () => { + const [out] = fill(DRAFTS, [block]) as [Record]; + expect(out['rows']).toEqual([ + ['LM358DR', '12000', 'TI'], + ['NE555P', '5000', ''], + ]); + }); + + it('只铺这张表的行 —— 别的对象不混进来', () => { + const [out] = fill(DRAFTS, [{ ...block, ledger: 'quote_intake.verdict', cols: ['条目数'], fields: ['line_total'] }]) as [ + Record, + ]; + expect(out['rows']).toEqual([['2']]); + }); + + it('空值铺成空字符串 —— 界面自己标红「待填写」,不写「无」这种话', () => { + const [out] = fill(DRAFTS, [block]) as [Record]; + expect((out['rows'] as string[][])[1]?.[2]).toBe(''); + }); + + it('这一单还没写过那张表 → 原样留着活动给的行,不把卡弄成空表', () => { + const given = { ...block, ledger: 'quote_intake.alt_request', rows: [['X', '1', 'Y']] }; + expect(fill(DRAFTS, [given])).toEqual([given]); + }); + + it('没绑 ledger 的表一个字不动 —— 老卡照旧', () => { + const plain = { t: 'table', cols: ['A'], rows: [['1']], editable: true }; + expect(fill(DRAFTS, [plain])).toEqual([plain]); + }); + + it('不是表的块原样透过', () => { + const note = { t: 'callout', text: '看这里' }; + expect(fill(DRAFTS, [note])).toEqual([note]); + }); +}); diff --git a/apps/api/src/app/__tests__/ledger-coerce.test.ts b/apps/api/src/app/__tests__/ledger-coerce.test.ts new file mode 100644 index 0000000..3e8e16d --- /dev/null +++ b/apps/api/src/app/__tests__/ledger-coerce.test.ts @@ -0,0 +1,344 @@ +/** + * 台账列的归型 —— **最坏的失败点是「人签完字之后」**。 + * + * 实测踩过:模型用 `""` 表示「原文没给目标价」(业务判断完全正确),草稿层照单全收; + * 到 `releaseWrite` 往真表里写时 PG 掀桌 —— + * `invalid input syntax for type double precision: ""` —— 而那已经是人签完字之后, + * 整单落成 `blocked`。前面全对,最后一步炸,这种失败最伤人。 + * + * 所以归型放在写进草稿那一刻:归得了就归,归不了当场拒(理由喂回下一拍)。 + */ +import { describe, expect, it } from 'vitest'; +import { coerceToColumn, localStamp, missingReadings, rowReadings, rowKeyList, tallyReadings } from '../wiring/engines.ts'; + +describe('空值 = 这一列没有值(不写)', () => { + it('空串 / 全空白 / null / undefined 一律不写 —— 「原文没写的不要补」的数据面落点', () => { + for (const v of ['', ' ', null, undefined]) { + expect(coerceToColumn(v, 'number').kind, JSON.stringify(v)).toBe('empty'); + expect(coerceToColumn(v, 'text').kind, JSON.stringify(v)).toBe('empty'); + } + }); + + it('0 与 false 是**有值**,不能被当成空 —— 这两个值最容易被顺手丢掉', () => { + expect(coerceToColumn(0, 'number')).toEqual({ kind: 'value', value: 0 }); + expect(coerceToColumn('0', 'integer')).toEqual({ kind: 'value', value: 0 }); + expect(coerceToColumn(false, 'boolean')).toEqual({ kind: 'value', value: false }); + expect(coerceToColumn('false', 'boolean')).toEqual({ kind: 'value', value: false }); + }); +}); + +describe('数值列', () => { + it('数字字符串转成数字 —— 模型给 "12000" 是常态', () => { + expect(coerceToColumn('12000', 'integer')).toEqual({ kind: 'value', value: 12000 }); + expect(coerceToColumn('8.4', 'number')).toEqual({ kind: 'value', value: 8.4 }); + }); + + it('不是数的当场拒 —— 拒在写入,不拖到放行', () => { + expect(coerceToColumn('待定', 'number').kind).toBe('reject'); + expect(coerceToColumn({ a: 1 }, 'number').kind).toBe('reject'); + expect(coerceToColumn(Number.NaN, 'number').kind).toBe('reject'); + }); +}); + +describe('布尔列', () => { + it('"true"/"false" 转真布尔', () => { + expect(coerceToColumn('true', 'boolean')).toEqual({ kind: 'value', value: true }); + }); + + it('模棱两可的字样当场拒 —— "是"/"1" 各人理解不同,不替它猜', () => { + expect(coerceToColumn('是', 'boolean').kind).toBe('reject'); + expect(coerceToColumn(1, 'boolean').kind).toBe('reject'); + }); +}); + +describe('文本与 json 列', () => { + it('text 收字符串;对象序列化 —— 比把 [object Object] 写进库强', () => { + expect(coerceToColumn('8周', 'text')).toEqual({ kind: 'value', value: '8周' }); + expect(coerceToColumn({ a: 1 }, 'text')).toEqual({ kind: 'value', value: '{"a":1}' }); + expect(coerceToColumn(12, 'text')).toEqual({ kind: 'value', value: '12' }); + }); + + it('json 列什么都装得下,不归型', () => { + expect(coerceToColumn({ rows: [1, 2] }, 'json')).toEqual({ kind: 'value', value: { rows: [1, 2] } }); + }); + + it('日期列按文本走(PG 自己解析格式)', () => { + expect(coerceToColumn('2026-07-30', 'date')).toEqual({ kind: 'value', value: '2026-07-30' }); + }); +}); + +describe('回归:那一单为什么会 blocked', () => { + it('NE555P 没给目标价 → 这一列不写(列留 NULL),而不是把 "" 塞进 numeric', () => { + const patch = { pn: 'NE555P', eau: '5000', target_price: '', lead_time: '6周' }; + const types: Record = { pn: 'text', eau: 'integer', target_price: 'number', lead_time: 'text' }; + const out: Record = {}; + for (const [k, v] of Object.entries(patch)) { + const c = coerceToColumn(v, types[k] ?? 'text'); + if (c.kind === 'value') out[k] = c.value; + } + expect(out).toEqual({ pn: 'NE555P', eau: 5000, lead_time: '6周' }); + expect('target_price' in out).toBe(false); + }); +}); + +/** + * 行数读数 —— **判据的另一边不能也由活动自报**。 + * + * 实测的一次「对账过关而账是错的」:活动把条目数记成 9,而受理记录里躺着 10 行 + * (一条 PN 抄漏了后缀,补写时成了新的一行);三类条数与派单数同样自报同错, + * 于是 `2 = 2` 顺利过关,人签完字,下游拿到一份多一行的交付物。 + */ +describe('行数读数:域_对象_rows', () => { + it('按 mine: 那一份数行,名字换成 S-FEEL 认得的下划线', () => { + expect( + rowReadings({ + 'mine:quote_intake.record': [{}, {}, {}], + 'mine:quote_intake.verdict': [{}], + }), + ).toEqual({ quote_intake_record_rows: 3, quote_intake_verdict_rows: 1 }); + }); + + it('不是 mine: 的键一概不数 —— 别单放行过的正式记录不算本单的行', () => { + expect(rowReadings({ 'quote_intake.record': [{}, {}] })).toEqual({}); + }); + + /* + * **一行没写也是一个读数:0**(2026-08-01,全 A 类那条路逼出来的)。 + * + * 从前只数「写过的表」:一单没有 C 类,替代评估请求那张表一行都没写, + * 于是 `quote_intake_alt_request_rows` 整个键不存在;而判据是 + * `quote_intake_alt_request_rows = type_c_total`(0 = 0 本该成立)—— + * 未绑定的名字参与比较求值为假,这条判据**永远过不去**,模型连撞五拍熔断, + * 而数据本身完全合规。「没写过」与「写了 0 行」在业务上是同一件事。 + */ + it('声明过但一行没写的表读数是 0 —— 零条也是一个数,判据得算得下去', () => { + const declared = [ + { domain: 'quote_intake', object: 'record' }, + { domain: 'quote_intake', object: 'alt_request' }, + ]; + expect(rowReadings({ 'mine:quote_intake.record': [{}, {}] }, declared)).toEqual({ + quote_intake_record_rows: 2, + quote_intake_alt_request_rows: 0, + }); + }); + + it('没给声明就还是只数写过的 —— 铺 0 的名单来自活动的台账声明', () => { + expect(rowReadings({})).toEqual({}); + }); +}); + +/** + * 时刻摆到屏上要**本地的**。 + * + * 直接切 ISO 串那八个字符,屏上写着 `07-31 20:16` 而人这边是 `08-01 04:16` —— + * 差八小时的时刻比没有时刻更坏:它看起来像一个可信的事实(实测:来件那一行就这么错过一次)。 + */ +describe('本地时刻', () => { + it('UTC 串按本地时区落到「月-日 时:分」', () => { + const iso = '2026-07-31T20:16:00.000Z'; + const at = new Date(iso); + const two = (n: number): string => String(n).padStart(2, '0'); + expect(localStamp(iso)).toBe(`${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`); + }); + + it('不是时刻就留空 —— 宁可没有,不摆一个假的', () => { + expect(localStamp('这不是时刻')).toBe(''); + }); + + it('归零到分 —— 秒不进报告(同一单的两份件不该看起来不一样)', () => { + expect(localStamp('2026-08-01T04:16:59.999Z')).toBe(localStamp('2026-08-01T04:16:01.000Z')); + }); +}); + +/** + * 主键名单 —— **行数对不上时,「是哪几行」只有基座数得出来**。 + * + * 判据比的是两张表的行数;模型看得见「10 ≠ 9」,却不知道多的是哪一行。 + * 名单一摆就是一句话能办的事:多的那个 PN 就在名单里对着。 + * 摆的是主键不是整行 —— 认一行只需要它的主键,整行铺出来会把拒回理由撑爆。 + */ +describe('主键名单:域_对象_keys', () => { + const DECL = [ + { + domain: 'quote_intake', + object: 'record', + fields: [ + { name: 'rfq_ref', primaryKey: true }, + { name: 'pn', primaryKey: true }, + { name: 'eau', primaryKey: false }, + ], + }, + { domain: 'quote_intake', object: 'alt_request', fields: [{ name: 'pn', primaryKey: true }] }, + ]; + + /* + * **名单要能直接照抄进 `drop` 的 keys** —— 所以摆 JSON,不摆 `A/B` 拼串。 + * + * 实测栽过:名单写作 `RFQ MD14.1/XTSD04GLGEAQG-I`,模型得自己猜第一段是哪一列; + * 列名它猜对了,值却把空格写成了横杠(`RFQ-MD14.1` 是更常见的写法)—— + * 连撤三次都撤不到,而那一行就在名单里。主键是认行的钥匙,一个字符都不能差。 + */ + it('主键摆成 JSON —— 列名带着值,模型原样粘进 drop 就行', () => { + expect( + rowKeyList({ 'mine:quote_intake.record': [{ rfq_ref: 'RFQ MD14.1', pn: 'LM358DR', eau: 30000 }] }, DECL), + ).toEqual({ quote_intake_record_keys: '{"rfq_ref":"RFQ MD14.1","pn":"LM358DR"}' }); + }); + + it('值里的空格原样留着 —— 转写一次就撤不到那一行了', () => { + const out = rowKeyList({ 'mine:quote_intake.alt_request': [{ pn: 'UMW AO6604' }] }, DECL); + expect(out['quote_intake_alt_request_keys']).toBe('{"pn":"UMW AO6604"}'); + }); + + it('一行没写的表不出名单 —— 空名单比没有更像坏了', () => { + expect(rowKeyList({ 'mine:quote_intake.record': [] }, DECL)).toEqual({}); + expect(rowKeyList({}, DECL)).toEqual({}); + }); + + it('**封顶 12 行**,超出的如实说还有几行 —— 再多就是把台账抄了一遍', () => { + const rows = Array.from({ length: 15 }, (_, i) => ({ pn: `P${i}` })); + const out = rowKeyList({ 'mine:quote_intake.alt_request': rows }, DECL); + expect(out['quote_intake_alt_request_keys']).toContain('{"pn":"P0"}'); + expect(out['quote_intake_alt_request_keys']).toContain('(还有 3 行)'); + expect(out['quote_intake_alt_request_keys']).not.toContain('"P12"'); + }); + + it('主键那一格空着就不摆那一格 —— 摆一个 "" 会让模型照抄一个撤不到的键', () => { + expect(rowKeyList({ 'mine:quote_intake.record': [{ rfq_ref: '', pn: 'X' }] }, DECL)).toEqual({ + quote_intake_record_keys: '{"pn":"X"}', + }); + }); + + it('整行主键都空 → 那一行不进名单,一行都没有就不出这个读数', () => { + expect(rowKeyList({ 'mine:quote_intake.alt_request': [{ pn: '' }] }, DECL)).toEqual({}); + }); + + it('没有主键声明的表不出名单 —— 认不出「一行是谁」就别硬凑', () => { + const noKey = [{ domain: 'd', object: 'o', fields: [{ name: 'x', primaryKey: false }] }]; + expect(rowKeyList({ 'mine:d.o': [{ x: 1 }] }, noKey)).toEqual({}); + }); +}); + +/** + * 缺必填读数 —— **让机检判据读得到基座数出来的数**。 + * + * A1 的「必填齐(PN / 年用量)」是 L1 机检,从前读的却是活动自己写的 `missing_key_total`: + * 人在卡上补了年用量、基座已经写回草稿层,而那个汇总列还要模型自己重算 —— + * 它漏算一次这条判据就永远绿不了(连跑十轮里三轮死在这,每次连拒五拍熔断)。 + * **活动自报的数不是门,是一句「改哪个数能过」的提示。** + */ +describe('缺必填读数:域_对象_missing', () => { + const DECL = [ + { + domain: 'quote_intake', + object: 'record', + fields: [ + { name: 'pn', primaryKey: true, required: false }, + { name: 'eau', primaryKey: false, required: true, label: '年用量' }, + // 选填但缺了要问一句 —— 它进另一组读数 `_gaps` + { name: 'brand', primaryKey: false, expect: true, label: '制造商' }, + ], + }, + ]; + + it('缺必填的行数由基座现数,并说清缺在哪几行、缺什么', () => { + const out = missingReadings( + { 'mine:quote_intake.record': [{ pn: 'A', eau: 30000 }, { pn: 'B', eau: '' }, { pn: 'C' }] }, + DECL, + ); + expect(out['quote_intake_record_missing']).toBe(2); + // 缺口摆**中文列名**:这个数会进拒回与卡面,给人看的地方不摆列名 + expect(out['quote_intake_record_missing_keys']).toBe('B 缺年用量;C 缺年用量'); + }); + + it('**补齐了就归零** —— 人在卡上填回来那一刻,判据自己就绿了', () => { + const out = missingReadings({ 'mine:quote_intake.record': [{ pn: 'A', eau: 30000 }] }, DECL); + expect(out['quote_intake_record_missing']).toBe(0); + expect('quote_intake_record_missing_keys' in out).toBe(false); + }); + + it('一行没写也是 0 —— 零条也是一个数(与 `_rows` 铺 0 同一口径)', () => { + // 两组都铺 0:必填与选填各算各的 + expect(missingReadings({}, DECL)).toEqual({ quote_intake_record_missing: 0, quote_intake_record_gaps: 0 }); + }); + + /* + * **选填缺口另算一组**(2026-08-02 用户实测:「补了目标价为啥 L4 还在那乱判」)。 + * 必填缺一条都不许放行;选填缺不拦单,但那一问不能省 —— 两件事两个数。 + * 从前选填那个数由活动自报,人补齐了它也不会变,于是 L4 照旧裁「不满足」。 + */ + it('选填缺口进 `_gaps`,与必填的 `_missing` 各算各的', () => { + const rows = [{ pn: 'A', eau: 1, brand: '' }, { pn: 'B', eau: '', brand: 'X' }]; + const out = missingReadings({ 'mine:quote_intake.record': rows }, DECL); + expect(out['quote_intake_record_missing']).toBe(1); + expect(out['quote_intake_record_gaps']).toBe(1); + expect(out['quote_intake_record_gaps_keys']).toBe('A 缺制造商'); + }); + + it('**人补齐那一格,这个数当场归零** —— 模型改不动它,所以裁得准', () => { + const out = missingReadings({ 'mine:quote_intake.record': [{ pn: 'A', eau: 1, brand: 'X' }] }, DECL); + expect(out['quote_intake_record_gaps']).toBe(0); + expect('quote_intake_record_gaps_keys' in out).toBe(false); + }); + + it('没有一列标 required 的表不出这个读数 —— 没声明就不该假装数得出来', () => { + const noReq = [{ domain: 'd', object: 'o', fields: [{ name: 'x', primaryKey: true }] }]; + expect(missingReadings({ 'mine:d.o': [{}] }, noReq)).toEqual({}); + }); + + it('空白串按缺算 —— 与写入归型那一侧的空值口径一致', () => { + const out = missingReadings({ 'mine:quote_intake.record': [{ pn: 'A', eau: ' ' }] }, DECL); + expect(out['quote_intake_record_missing']).toBe(1); + }); +}); + +/** + * 按值现数 —— **手工修改的全部意义就是纠正 AI 的判断**。 + * + * 三分类条数从前是活动自己写的 `type_a_total` 之流,而分布条、放行时的去向谓词、 + * 验收判据读的全是它们。于是人在放行页把九条料全改成 A:类徽变了, + * 分布条还是 5/3/1、去向还是「去选型」—— 逐行数据变了,汇总数没人重算。 + * **改了类标而流向不跟着变,那个兜底功能就是假的。** + */ +describe('按值现数:域_对象_列_值', () => { + const DECL = [ + { + domain: 'quote_intake', + object: 'record', + fields: [{ name: 'pn' }, { name: 'type_tag', tally: ['A', 'B', 'C'] }], + }, + ]; + + it('照取值域逐个数,值消毒成小写', () => { + const rows = [{ type_tag: 'A' }, { type_tag: 'A' }, { type_tag: 'B' }, { type_tag: 'C' }]; + expect(tallyReadings({ 'mine:quote_intake.record': rows }, DECL)).toEqual({ + quote_intake_record_type_tag_a: 2, + quote_intake_record_type_tag_b: 1, + quote_intake_record_type_tag_c: 1, + }); + }); + + it('**人全改成 A → C 类归零**,去向谓词 `…_type_tag_c > 0` 当场变假', () => { + const rows = [{ type_tag: 'A' }, { type_tag: 'A' }, { type_tag: 'A' }]; + const out = tallyReadings({ 'mine:quote_intake.record': rows }, DECL); + expect(out['quote_intake_record_type_tag_a']).toBe(3); + expect(out['quote_intake_record_type_tag_c']).toBe(0); + }); + + it('没出现的值也铺 0 —— 零条也是一个数,判据才算得下去', () => { + expect(tallyReadings({ 'mine:quote_intake.record': [] }, DECL)).toEqual({ + quote_intake_record_type_tag_a: 0, + quote_intake_record_type_tag_b: 0, + quote_intake_record_type_tag_c: 0, + }); + }); + + it('没声明取值域的列不出读数 —— 数不出取值域就别假装数得全', () => { + expect(tallyReadings({ 'mine:d.o': [{ x: 'A' }] }, [{ domain: 'd', object: 'o', fields: [{ name: 'x' }] }])).toEqual({}); + }); + + it('值两边的空白不算数上的差别', () => { + expect( + tallyReadings({ 'mine:quote_intake.record': [{ type_tag: ' A ' }] }, DECL)['quote_intake_record_type_tag_a'], + ).toBe(1); + }); +}); diff --git a/apps/api/src/app/__tests__/live-refresh.test.ts b/apps/api/src/app/__tests__/live-refresh.test.ts new file mode 100644 index 0000000..1cbb2c0 --- /dev/null +++ b/apps/api/src/app/__tests__/live-refresh.test.ts @@ -0,0 +1,90 @@ +/** + * **后端发生的事,前端得自己知道** —— 广播面的回归。 + * + * 用户实测报的:任务列表、消息气泡、工作区、对话区都不自己动,得手点刷新。 + * 前端那一侧是好的(单条 SSE + 按前缀失效缓存),缺的全在后端 —— 它压根没说。 + * + * 三处各有各的成因,所以分三段守: + * ① 推进器只在**状态换名字**时广播,而案例活动连跑十几拍全程都是 `running`; + * ② `interaction` 的推送(AI 发言、换画面)一个订阅者都没有,全广播给了空气; + * ③ 发消息不经事件投递(那条路才有广播),于是收件人的消息气泡不动。 + */ +import { describe, expect, it } from 'vitest'; +import { createBroadcaster } from '../broadcast.ts'; +import { worthBroadcasting } from '../workers/task-advancer.ts'; + +describe('① 推进器:推动了就说一声', () => { + const running = { runtime: 'running' }; + + it('状态没换名字但这一拍干了事 → 广播', () => { + // 案例活动的常态:连跑十几拍,runtime 一直是 running,而台账/对话/画面每拍都在变 + expect(worthBroadcasting(running, { progressed: true, task: { runtime: 'running' } })).toBe(true); + }); + + it('空拍不广播 —— 不给在线浏览器发噪声', () => { + expect(worthBroadcasting(running, { progressed: false, task: { runtime: 'running' } })).toBe(false); + }); + + it('挂起去等人:没推动但状态换了 → 广播', () => { + expect(worthBroadcasting(running, { progressed: false, task: { runtime: 'awaiting-human' } })).toBe(true); + }); + + it('走到终态 → 广播(「办完的活自己挪到已完成」靠它)', () => { + expect( + worthBroadcasting(running, { progressed: false, task: { runtime: 'terminal', terminal: 'released' } }), + ).toBe(true); + }); + + it('第一次见到这个任务(没有前态)→ 广播', () => { + expect(worthBroadcasting(undefined, { progressed: false, task: { runtime: 'running' } })).toBe(true); + }); +}); + +describe('广播器:一个订阅者出错不牵连别人', () => { + it('抛错的订阅者被摘掉,其余照收', () => { + const b = createBroadcaster(); + const got: string[] = []; + b.subscribe(() => { + throw new Error('这个订阅者坏了'); + }); + b.subscribe((t) => got.push(t.reason)); + b.publish({ kind: 'task', id: 't1', reason: 'advanced' }); + b.publish({ kind: 'task', id: 't1', reason: 'again' }); + expect(got).toEqual(['advanced', 'again']); + expect(b.size).toBe(1); + }); +}); + +/* + * ④ 收尾这一下动的不只是任务(2026-07-30)。 + * + * 放行那一刻草稿合并进正式版、动作意向落地、输出口发射 —— 数据域多出真行、 + * 驾驶舱的完成数变了。那几屏的键都在 `gov` 下、不以任务为轴,只按任务广播 + * 它们一个也收不到(实测:一单放行完,数据域里还是空的,直到手动刷新)。 + */ +describe('④ 终态要额外发一次 system —— 台账与指标不以任务为轴', () => { + it('走到终态时,除任务/流之外还有一条全局推送', () => { + const b = createBroadcaster(); + const kinds: string[] = []; + b.subscribe((t) => kinds.push(t.kind)); + + // 照 task-advancer 的顺序重放一次「这一拍走到了终态」 + const outcome = { progressed: true, task: { runtime: 'terminal', terminal: 'released', workflowId: 'W1' } }; + if (worthBroadcasting({ runtime: 'running' }, outcome)) { + b.publish({ kind: 'task', id: 'T1', reason: 'advanced' }); + if (outcome.task.workflowId !== undefined) b.publish({ kind: 'workflow', id: 'W1', reason: 'task-advanced' }); + if (outcome.task.terminal !== undefined) b.publish({ kind: 'system', id: 'terminal', reason: outcome.task.terminal }); + } + expect(kinds).toEqual(['task', 'workflow', 'system']); + }); + + it('没到终态就不发 system —— 每拍都发等于让治理台整屏重取', () => { + const b = createBroadcaster(); + const kinds: string[] = []; + b.subscribe((t) => kinds.push(t.kind)); + const outcome = { progressed: true, task: { runtime: 'running' as string, terminal: undefined } }; + b.publish({ kind: 'task', id: 'T1', reason: 'advanced' }); + if (outcome.task.terminal !== undefined) b.publish({ kind: 'system', id: 'terminal', reason: 'x' }); + expect(kinds).toEqual(['task']); + }); +}); diff --git a/apps/api/src/app/__tests__/seed-mvp-data.test.ts b/apps/api/src/app/__tests__/seed-mvp-data.test.ts index 91f8fef..c854677 100644 --- a/apps/api/src/app/__tests__/seed-mvp-data.test.ts +++ b/apps/api/src/app/__tests__/seed-mvp-data.test.ts @@ -1,61 +1,67 @@ /** - * 四域十九对象 —— 声明即物化的输入。守三件事:数量与域别齐、主键齐、按名可取。 + * 主数据四域 —— 客户 / 项目 / 供应商 / 市场。 + * + * 这份测试从前断言的是「四域十九对象」,那个四域是 project / customer / intel / supplier, + * 装的是**退役占位槽**的声明,里面混着一单一份的流转单据(受理记录、比价表、报价草案)。 + * 2026-07-31 重分:单据归各活动自己的域,这个文件只留**主数据**,按常规 CRM / ERP 分四域。 + * + * 守的是三条,每条都能机检: + * · 只有这四个域,不多不少 —— 多一个就说明有人把单据塞进主数据了; + * · 每张表都是 `master` 且**不带单标识** —— 带了就是单据归错类(安装静态审也会拦); + * · 主键是**业务实体**,不是单。 */ import { describe, expect, it } from 'vitest'; -import { MVP_OBJECTS, declarationsFor } from '../seed-mvp-data.ts'; +import { MASTER_DOMAINS, MASTER_OBJECTS } from '../seed-mvp-data.ts'; -describe('MVP_OBJECTS', () => { - it('四域十九对象,不多不少 —— 定价审批表载体是审批流,不入台账', () => { - expect(MVP_OBJECTS).toHaveLength(19); - const count = (domain: string) => MVP_OBJECTS.filter((o) => o.domain === domain).length; - expect(count('project')).toBe(13); - expect(count('customer')).toBe(1); - expect(count('intel')).toBe(3); - expect(count('supplier')).toBe(2); +describe('主数据四域', () => { + it('只有客户 / 项目 / 供应商 / 市场四个域', () => { + expect([...MASTER_DOMAINS]).toEqual(['customer', 'project', 'supplier', 'market']); + expect([...new Set(MASTER_OBJECTS.map((o) => o.domain))].sort()).toEqual([...MASTER_DOMAINS].sort()); }); - it('每个对象至少一个主键字段 —— 只增不破坏靠主键立身', () => { - for (const o of MVP_OBJECTS) { - expect(o.fields.some((f) => f.primaryKey), `${o.domain}.${o.object}`).toBe(true); - } - }); - - it('每个字段都有中文名 —— 界面上不许只见键不见名', () => { - for (const o of MVP_OBJECTS) { - expect(o.label, `${o.domain}.${o.object} 缺对象名`).toBeTruthy(); - for (const f of o.fields) { - expect(f.label, `${o.domain}.${o.object}.${f.name} 缺字段名`).toBeTruthy(); - } + it('四个域各自都有表 —— 空占位也得立得住', () => { + for (const d of MASTER_DOMAINS) { + expect(MASTER_OBJECTS.filter((o) => o.domain === d).length, `${d} 域一张表都没有`).toBeGreaterThan(0); } }); /* - * 受理结论是**一单一行**,主键只有询价单号。 - * - * 这条断言守的是一个会静默出错的设计:出口的标量字段取自 `landed.flat`, - * 而 `flat` 是把这一单所有落地行 reduce 展开成一个平对象。两枚去向旗若挂在 - * 按料号分行的对象上,六个料号就有六行同名列、最后合并的那行赢 —— - * 而归并规则是「任一条命中 C 即需选型」,网关会照着错值分叉且无人察觉。 + * 这一条是分类的要害:主数据按**业务实体**分行,不按单。 + * 带上单标识就说明它其实是一单一份的单据 —— 归错类,治理方式全跟着错。 */ - it('受理结论一单一行 —— 去向旗不能挂在按料号分行的对象上', () => { - const verdict = MVP_OBJECTS.find((o) => o.object === 'intake_verdict'); - expect(verdict, '缺 project.intake_verdict').toBeDefined(); - expect(verdict?.domain).toBe('project'); - expect(verdict?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['rfq_ref']); - for (const flag of ['need_alternative', 'need_sourcing']) { - expect(verdict?.fields.find((f) => f.name === flag)?.type, flag).toBe('boolean'); - } - // 反过来也要守:按料号分行的对象上不许出现去向旗 - for (const o of MVP_OBJECTS) { - if (o.object === 'intake_verdict') continue; - const names = o.fields.map((f) => f.name); - expect(names, `${o.domain}.${o.object} 不该带去向旗`).not.toContain('need_alternative'); - expect(names, `${o.domain}.${o.object} 不该带去向旗`).not.toContain('need_sourcing'); + it('每张表都是 master 且不带单标识', () => { + for (const o of MASTER_OBJECTS) { + expect(o.kind, `${o.domain}.${o.object}`).toBe('master'); + expect(o.caseKey, `${o.domain}.${o.object} 不该有单标识`).toBeUndefined(); } }); - it('declarationsFor 按名取子集,名不存在即抛', () => { - expect(declarationsFor(['project.intake_record', 'intel.history_price'])).toHaveLength(2); - expect(() => declarationsFor(['project.nope'])).toThrow('未知对象'); + it('每张表都有主键 —— 没主键的台账合并与去重无从谈起', () => { + for (const o of MASTER_OBJECTS) { + const pk = o.fields.filter((f) => f.primaryKey).map((f) => f.name); + expect(pk.length, `${o.domain}.${o.object} 没有主键`).toBeGreaterThan(0); + } + }); + + /* 主键得是业务实体键 —— 单标识那几个名字出现在主键里,就是归错了类 */ + it('主键里不出现单标识那几个名字', () => { + for (const o of MASTER_OBJECTS) { + const pk = o.fields.filter((f) => f.primaryKey).map((f) => f.name); + for (const caseish of ['rfq_ref', 'quote_no', 'task_id']) { + expect(pk, `${o.domain}.${o.object} 的主键里有单标识 ${caseish}`).not.toContain(caseish); + } + } + }); + + /* 个人信息必须标脱敏 —— 主数据里存联系人,治理面照 `masked` 裁剪 */ + it('联系人的邮箱与电话标了脱敏', () => { + const contacts = MASTER_OBJECTS.filter((x) => x.object === 'contact'); + expect(contacts.length).toBeGreaterThan(0); + for (const o of contacts) { + for (const name of ['email', 'phone']) { + const field = o.fields.find((x) => x.name === name); + expect(field?.masked, `${o.domain}.${o.object}.${name} 未标脱敏`).toBe(true); + } + } }); }); diff --git a/apps/api/src/app/__tests__/seed-mvp-slots.test.ts b/apps/api/src/app/__tests__/seed-mvp-slots.test.ts index 4f07cd9..8a0d456 100644 --- a/apps/api/src/app/__tests__/seed-mvp-slots.test.ts +++ b/apps/api/src/app/__tests__/seed-mvp-slots.test.ts @@ -1,73 +1,60 @@ /** - * 六个活动槽位 —— 契约就绪、实现留白。守:岗位/出口与 MVP 一致; - * 声明只有名(工具按名引用、域对象按名取)—— 活动与工具的解耦就靠这一条。 - * A5 定价审批是**内置审批点**,零开发、不入库 —— 不在槽位之列。 + * 槽位退役名单 —— 六个占位槽必须一个不漏地被请出去。 + * + * 这份测试从前断言的是「MVP_SLOTS 有 5 份占位清单、各自长什么样」—— + * 那批清单在 2026-07-31 全部退役:六个真活动包(`activities/quote-*`)转正后, + * 占位清单成了会骗人的重影: + * · 活动库与试跑台上并排两个同名活动,选错一个就试了个空壳; + * · 数据域里 12 对同名台账(`project.intake_record` vs `quote_intake.record` …), + * 人分不出该看哪一个 —— 而旧的那 19 张表一行数据都没有; + * · 工具授权面各留一份,治理台的授权清单跟着翻倍。 + * + * 所以断言的对象换了:不再是「占位清单长什么样」,而是**「它们都在退役名单里」**。 + * 名单漏一个,那个槽位下次播种又装回来,重影跟着回来。 */ import { describe, expect, it } from 'vitest'; -import { MVP_SLOTS, SLOT_ARTIFACT } from '../seed-mvp-slots.ts'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; -describe('MVP_SLOTS', () => { - it('六槽位(A5 审批点内置,不入库)', () => { - const ids = MVP_SLOTS.map((m) => m.identity.activityId as string); - expect(MVP_SLOTS).toHaveLength(6); - expect(ids).toEqual( - expect.arrayContaining(['rfq-intake', 'alt-selection', 'sourcing-inquiry', 'cost-pricing', 'quote-negotiation', 'intel-archive']), - ); - expect(ids).not.toContain('pricing-approval'); - }); +const source = readFileSync(fileURLToPath(new URL('../seed-mvp-slots.ts', import.meta.url)), 'utf8'); - /* - * 占位骨架的形状(2026-07-29 范式统一后):**案例模型**—— - * SOP 每件事一个人工任务 + 末尾一张收口验收。两条都是踩过坑才立的: - * · 触达世界的项必须人工激活 —— 占位尤其不许替人自动做事 - * · 没有收口验收 → 活动永远不 release →「等人却没东西给人答」 - */ - it('占位骨架:案例模型双表示齐备,项全人工激活,末尾一张收口验收', () => { - for (const m of MVP_SLOTS) { - const model = m.assets.businessModel; - const id = m.identity.activityId; - expect(model.cmmnXml, `${id} 缺 CMMN`).toContain('/g)) { - expect(task[0], `${id} 的 ${task[1]} 应人工激活`).toContain('manualActivationRule'); - } - // 占位不做业务判断,所以不带决策表 - expect(model.dmn.tables, `${id} 占位期不该有 DMN`).toHaveLength(0); +/** 被真活动包顶替掉的六个槽位 —— 顺序即 SOP 次序 */ +const RETIRED = [ + 'rfq-intake', // A1 → quote-intake + 'alt-selection', // A2 → quote-alternative + 'sourcing-inquiry', // A3 → quote-sourcing + 'cost-pricing', // A4 → quote-pricing + 'quote-negotiation', // A6 → quote-negotiate + 'intel-archive', // A7 → quote-archive +] as const; + +describe('槽位退役', () => { + it('六个槽位一个不漏地在退役名单里', () => { + for (const id of RETIRED) { + expect(source, `${id} 不在退役名单里 —— 下次播种它会装回来`).toContain(`'${id}'`); } }); - it('A1 出口带两枚去向旗;A2 透传 needSourcing —— 网关条件的数据来源', () => { - const a1 = MVP_SLOTS.find((m) => m.identity.activityId === 'rfq-intake')!; - const p1 = (a1.contract.output.mandatory[0]!.payload as { properties?: Record }).properties ?? {}; - expect(Object.keys(p1)).toEqual(expect.arrayContaining(['needAlternative', 'needSourcing'])); - const a2 = MVP_SLOTS.find((m) => m.identity.activityId === 'alt-selection')!; - const p2 = (a2.contract.output.mandatory[0]!.payload as { properties?: Record }).properties ?? {}; - expect(Object.keys(p2)).toContain('needSourcing'); + /* + * 占位清单必须**真的删掉**,不是留在文件里不引用 —— + * 留着它,下一个人很容易「顺手再装回去」,重影就回来了。 + */ + it('占位清单已从源码里移除', () => { + expect(source).not.toContain('MVP_SLOTS'); + expect(source).not.toContain('SLOT_ARTIFACT'); + }); + + it('播种只做退役这一件事 —— 不再装任何槽位', () => { + expect(source).toContain('registry.uninstall'); + expect(source).not.toContain('registry.install('); }); /* - * 三个去向是**选择组**,不是必交口里的一个字段:人在放行时恰选一口, - * 那一口就是这次运行的 outcome(引擎按选口注入),G4 判的正是它。 - * 曾写成 formal 口的必填 outcome + selective: [] —— 引擎只在选择组恰发一口时 - * 才注入 outcome,没有选择组就结构上永远没有判据,G4 三条边全不成立(实测)。 + * 卸不掉要如实说、且不掀翻播种(多半是还有在途任务引着那个活动)。 + * 吞掉错误会让人以为清干净了,而治理台上那个重影还在。 */ - it('A6 三个去向是选择组 —— 放行恰选一口,选口即 G4 的判据', () => { - const a6 = MVP_SLOTS.find((m) => m.identity.activityId === 'quote-negotiation')!; - const groups = a6.contract.output.selective; - expect(groups).toHaveLength(1); - expect(groups[0]!.group).toBe('议价结果'); - expect(groups[0]!.ports.map((p) => p.name)).toEqual(['won', 'revise', 'lost']); - // outcome 不再是必交口的必填字段 —— 它由选口给出,不靠活动自己写 - const formal = a6.contract.output.mandatory[0]!.payload as { required?: string[] }; - expect(formal.required ?? []).not.toContain('outcome'); - }); - - it('槽位不携实现:入口是抛「未实现」的占位,工具声明只有名', () => { - expect(SLOT_ARTIFACT).toContain('未实现'); - for (const m of MVP_SLOTS) { - for (const t of m.declarations.tools) expect(typeof t.name).toBe('string'); - } + it('卸不掉如实报出来,不掀翻播种', () => { + expect(source).toContain('卸不掉'); + expect(source).toContain('catch'); }); }); diff --git a/apps/api/src/app/__tests__/sheet-rows.test.ts b/apps/api/src/app/__tests__/sheet-rows.test.ts new file mode 100644 index 0000000..e1fc0cf --- /dev/null +++ b/apps/api/src/app/__tests__/sheet-rows.test.ts @@ -0,0 +1,87 @@ +/** + * 附件里的表先替模型拆好 —— **数格子是能算的事,不该问 LLM**。 + * + * 实测同一行栽过两次:BOM 上白纸黑字写着「Антенна · 浙江嘉康 · (空) EAU」, + * 模型拆出来是「集成电路 · (空) · 30000」—— 品类错了、制造商丢了、年用量凭空多一个。 + * 连着两个制表符是中间那一格空着,而它把右边的值往前挪了。 + */ +import { describe, expect, it } from 'vitest'; +import { attachmentPart, sheetRows, withSheetRows } from '../agent-runner.ts'; + +const BOM = [ + 'Itelma Code\tPN\tКатегория\tПроизводитель\tAnalog\tСертификация\tEAU (k)\tTarget price', + '4-02-01812\tTVM6C160K203R\tВаристор\tThinking Electronic\t\tAEC-Q200\t30\t', + '9-00-00251\tDAG2400L321504XZB01-R0\tАнтенна\t浙江嘉康\t\t\t\t', +].join('\n'); + +describe('制表符表 → 逐行对象', () => { + it('键就是表头,空格子就是空串 —— 不往前挪', () => { + const rows = sheetRows(BOM); + expect(rows).toHaveLength(2); + expect(rows?.[1]).toEqual({ + 'Itelma Code': '9-00-00251', + PN: 'DAG2400L321504XZB01-R0', + 'Категория': 'Антенна', + 'Производитель': '浙江嘉康', + Analog: '', + 'Сертификация': '', + 'EAU (k)': '', + 'Target price': '', + }); + }); + + it('**行尾连续制表符是末尾几列都空**,不是这一行到此为止', () => { + const rows = sheetRows(BOM); + expect(Object.keys(rows?.[1] ?? {})).toHaveLength(8); + }); + + it('列数不齐就不认这张表 —— 半张读歪的表比没有更坏', () => { + expect(sheetRows('A\tB\tC\n1\t2')).toBeUndefined(); + }); + + it('不成表的文本不认(只有表头 / 单列)', () => { + expect(sheetRows('就一行字')).toBeUndefined(); + expect(sheetRows('A\tB')).toBeUndefined(); + expect(sheetRows('一列\n值1\n值2')).toBeUndefined(); + }); + + it('表头有空列名就按位置补 —— 键不许是空串', () => { + expect(sheetRows('A\t\nx\ty')?.[0]).toEqual({ A: 'x', 列2: 'y' }); + }); +}); + +describe('附件旁边多给一份拆好的行', () => { + it('读得出表就换成 rows —— **原文换下来**,两份并排会把这一段撑爆', () => { + const out = withSheetRows({ attachments: [{ name: 'bom.xlsx', text: BOM }] }); + const one = (out['attachments'] as Array>)[0]!; + expect(one['text']).toBeUndefined(); + expect((one['rows'] as unknown[]).length).toBe(2); + // 换下来了要说一声,别让人以为附件读空了 + expect(String(one['sheet'])).toContain('已拆成 2 行'); + }); + + /* + * 附件**单独成段**:它是这一单的料本身,混进按字符截的整表段里会被砍掉 —— + * 实测加了 rows 之后三轮里那一行照错不误,因为它压根没进提示词。 + */ + it('附件自成一段,不跟整表挤在一起', () => { + const seg = attachmentPart({ attachments: [{ name: 'bom.xlsx', text: BOM }] }); + expect(seg).toContain('随件附件'); + expect(seg).toContain('DAG2400L321504XZB01-R0'); + }); + + it('没有附件就不摆这一段 —— 空标题只占位置', () => { + expect(attachmentPart({})).toBe(''); + expect(attachmentPart({ attachments: [] })).toBe(''); + }); + + it('读不出表就一个字不动', () => { + const scope = { attachments: [{ name: 'a.pdf', note: '这类文件读不出文本' }] }; + expect(withSheetRows(scope)).toEqual(scope); + }); + + it('没有附件的单原样返回 —— 不平白多一个键', () => { + const scope = { subject: 'x' }; + expect(withSheetRows(scope)).toBe(scope); + }); +}); diff --git a/apps/api/src/app/__tests__/tool-bus-assembly.test.ts b/apps/api/src/app/__tests__/tool-bus-assembly.test.ts index 25470b1..c0b0dae 100644 --- a/apps/api/src/app/__tests__/tool-bus-assembly.test.ts +++ b/apps/api/src/app/__tests__/tool-bus-assembly.test.ts @@ -9,8 +9,9 @@ */ import { describe, expect, it } from 'vitest'; import type { StaffId, TaskId } from '@contracts'; -import { HUMAN_TOOL, LLM_TOOLS } from '@modules/toolbox'; +import { HUMAN_TOOL, LEDGER_TOOL, LLM_TOOLS } from '@modules/toolbox'; import type { BusTool } from '@modules/toolbox'; +import { toolCatalog } from '../agent-runner.ts'; import { buildMastraTools } from '../tool-bus.ts'; import type { ToolBusPorts, ToolBusTrace } from '../tool-bus.ts'; @@ -38,10 +39,11 @@ type Calls = { cards: Array<{ workOrder: string; card: Record }>; listens: Array<{ keyFilter: Record }>; generated: Array<{ model: string; label: string; prompt: string }>; + written: Array<{ address: string; rows: number }>; }; function harness(writeTools: string[] = ['mail.send']) { - const calls: Calls = { invoked: [], intents: [], cards: [], listens: [], generated: [] }; + const calls: Calls = { invoked: [], intents: [], cards: [], listens: [], generated: [], written: [] }; const ports: ToolBusPorts = { invoke: async (input) => { calls.invoked.push({ tool: input.tool, input: input.input }); @@ -59,6 +61,10 @@ function harness(writeTools: string[] = ['mail.send']) { calls.listens.push({ keyFilter: input.keyFilter }); return { id: 'lsn-1' }; }, + draftWrite: async (input) => { + calls.written.push({ address: input.address, rows: input.rows.length }); + return { written: input.rows.length }; + }, generate: async (input) => { calls.generated.push({ model: input.model, label: input.label, prompt: input.prompt }); return { text: '{"label":"C"}', object: { label: 'C', rationale: '据规则' } }; @@ -116,6 +122,48 @@ describe('装配:人类工具(系统内化)', () => { }); }); +describe('装配:台账工具(系统内化)', () => { + it('写草稿落草稿层,**不登记动作意向** —— 草稿不触达世界,不该走动作那条路', async () => { + const h = harness(); + const tools = buildMastraTools([LEDGER_TOOL], h.ports, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace); + const out = await run(Object.values(tools)[0], { + address: 'quote_intake.record', + rows: [ + { key: { pn: 'LM358DR' }, patch: { eau: 12000 } }, + { key: { pn: 'NE555P' }, patch: { eau: 5000 } }, + ], + }); + expect(h.calls.written).toEqual([{ address: 'quote_intake.record', rows: 2 }]); + expect(h.calls.intents).toHaveLength(0); + expect(out).toEqual({ ok: true, written: 2 }); + }); + + it('行的形状不对 → 入参 schema 当场拦下,错误原文回给模型让它这一拍改对', async () => { + const h = harness(); + const tools = buildMastraTools([LEDGER_TOOL], h.ports, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace); + const out = await run(Object.values(tools)[0], { + address: 'quote_intake.record', + rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }, { pn: 'Y', eau: 2 }, null], + }); + // 一行不合规就整笔不写:半份数据比没数据更难查 + expect(h.calls.written).toEqual([]); + expect(String(out['message'])).toContain('rows.1.key'); + }); + + it('地址不合规(装配层抛错)如实回报,不掀翻这一拍', async () => { + const h = harness(); + const rejecting: ToolBusPorts = { + ...h.ports, + draftWrite: async () => { + throw new Error('台账地址未声明:other.thing'); + }, + }; + const tools = buildMastraTools([LEDGER_TOOL], rejecting, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace); + const out = await run(Object.values(tools)[0], { address: 'other.thing', rows: [{ key: {}, patch: {} }] }); + expect(out).toEqual({ ok: false, error: '台账地址未声明:other.thing' }); + }); +}); + describe('装配:LLM 五件(系统内化)', () => { it('经模型网关调,用活动声明的用途名;提示词归系统', async () => { const h = harness(); @@ -139,3 +187,28 @@ describe('装配:LLM 五件(系统内化)', () => { ); }); }); + +/** + * 单拍路径的工具清单 —— **声明式工具的提示词面**。 + * + * 从前单拍路径的提示词里**根本没有工具清单**(第 12 章⑧):Agent 只知道说明书里 + * 顺口提过的工具名,入参长什么样得猜。实测的后果是它把该调工具的一步改成发卡问人 + * (「请为以下三个 PN 查询历史价格」),而系统里明明就有这件工具 —— 白占人的时间。 + */ +describe('工具清单进提示词', () => { + it('id · 效应 · 说明 · 入参键名都在,必填带星', () => { + const listing = toolCatalog([ + { id: 'crm.history-price-read', description: '读历史价', input: READ_TOOL.input, effect: 'read' }, + { id: 'mail.send', description: '发邮件', input: WRITE_TOOL.input, effect: 'write' }, + ]); + expect(listing).toContain('crm.history-price-read(读类):读历史价'); + expect(listing).toContain('入参:pn:string*'); + expect(listing).toContain('mail.send(写类)'); + // 写类要如实说清「现在还没生效」,否则 Agent 会当成已经发出去了 + expect(listing).toContain('登记为动作意向'); + }); + + it('一件工具都没有就不摆这一段 —— 不给上下文塞空标题', () => { + expect(toolCatalog([])).toBe(''); + }); +}); diff --git a/apps/api/src/app/__tests__/upload-extract.test.ts b/apps/api/src/app/__tests__/upload-extract.test.ts new file mode 100644 index 0000000..83db16c --- /dev/null +++ b/apps/api/src/app/__tests__/upload-extract.test.ts @@ -0,0 +1,179 @@ +/** + * 上传件读文本 —— **这段错了不报错,只是活动拿到一份读不懂的料**。 + * + * 测法不用录制的样本文件:测里现造一个**真的 xlsx**(ZIP + 那几份 XML,按规范逐字节拼), + * 再让被测代码去读。造的那一侧与读的那一侧互不共享代码,读得回来才算真的读得懂。 + */ +import { describe, expect, it } from 'vitest'; +import { deflateRawSync } from 'node:zlib'; +import { extractText } from '../upload-extract.ts'; + +/* ==================== 现造一个 xlsx ==================== */ + +const enc = (s: string): Uint8Array => new TextEncoder().encode(s); + +/** 按 ZIP 规范拼一个包:本地头 + 数据 + 中央目录 + EOCD */ +function makeZip(entries: ReadonlyArray<{ name: string; body: Uint8Array; store?: boolean }>): Uint8Array { + const locals: Uint8Array[] = []; + const centrals: Uint8Array[] = []; + let offset = 0; + + for (const e of entries) { + const name = enc(e.name); + const data = e.store === true ? e.body : new Uint8Array(deflateRawSync(e.body)); + const method = e.store === true ? 0 : 8; + + const local = new Uint8Array(30 + name.length + data.length); + const lv = new DataView(local.buffer); + lv.setUint32(0, 0x04034b50, true); + lv.setUint16(8, method, true); + lv.setUint32(18, data.length, true); + lv.setUint32(22, e.body.length, true); + lv.setUint16(26, name.length, true); + local.set(name, 30); + local.set(data, 30 + name.length); + locals.push(local); + + const central = new Uint8Array(46 + name.length); + const cv = new DataView(central.buffer); + cv.setUint32(0, 0x02014b50, true); + cv.setUint16(10, method, true); + cv.setUint32(20, data.length, true); + cv.setUint32(24, e.body.length, true); + cv.setUint16(28, name.length, true); + cv.setUint32(42, offset, true); + central.set(name, 46); + centrals.push(central); + + offset += local.length; + } + + const centralSize = centrals.reduce((n, c) => n + c.length, 0); + const eocd = new Uint8Array(22); + const ev = new DataView(eocd.buffer); + ev.setUint32(0, 0x06054b50, true); + ev.setUint16(8, entries.length, true); + ev.setUint16(10, entries.length, true); + ev.setUint32(12, centralSize, true); + ev.setUint32(16, offset, true); + + const total = offset + centralSize + eocd.length; + const out = new Uint8Array(total); + let at = 0; + for (const b of [...locals, ...centrals, eocd]) { + out.set(b, at); + at += b.length; + } + return out; +} + +/** 一份两列三行的 BOM:表头 + 两条料 —— 字符串走共享串表,数字直接落 `` */ +function bomXlsx(): Uint8Array { + const strings = ['型号', '年用量', 'LM358DR', 'NE555P']; + const sharedXml = + `` + + strings.map((s) => `${s}`).join('') + + ``; + const sheetXml = + `` + + `01` + + `212000` + + `35000` + + `` + + ``; + return makeZip([ + { name: '[Content_Types].xml', body: enc('') }, + { name: 'xl/sharedStrings.xml', body: enc(sharedXml) }, + { name: 'xl/worksheets/sheet1.xml', body: enc(sheetXml) }, + ]); +} + +/* ==================== 断言 ==================== */ + +describe('xlsx —— 一份真的表读得成行列', () => { + const got = extractText('BOM.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', bomXlsx()); + + it('读得出内容,而不是回一句「读不出」', () => { + expect(got.text).toBeDefined(); + }); + + it('共享串表查得对 —— 字符串单元格不是数字下标', () => { + expect(got.text).toContain('LM358DR'); + expect(got.text).toContain('型号'); + }); + + it('数字单元格取原值,不查串表(查了会变成别的字)', () => { + expect(got.text).toContain('12000'); + }); + + it('一行一行、制表符分列 —— 行列对齐了模型才拆得动', () => { + expect(got.text?.split('\n')).toEqual(['型号\t年用量', 'LM358DR\t12000', 'NE555P\t5000']); + }); + + it('整行皆空的不留 —— 表尾那些格式化过的空行不该占一行', () => { + expect(got.text?.split('\n')).toHaveLength(3); + }); + + it('读数如实报行数', () => { + expect(got.note).toContain('3 行'); + }); +}); + +describe('不压缩的条目也读得了 —— store(0) 与 deflate(8) 都是 xlsx 的出厂法', () => { + it('store 法存的表照样读得出', () => { + const zip = makeZip([ + { + name: 'xl/worksheets/sheet1.xml', + body: enc('直存'), + store: true, + }, + ]); + expect(extractText('a.xlsx', '', zip).text).toBe('直存'); + }); +}); + +describe('读不出来就说读不出来 —— 绝不编一段内容出来', () => { + it('不是 ZIP 的 .xlsx:如实说打不开', () => { + const got = extractText('坏的.xlsx', '', enc('这不是一个 zip')); + expect(got.text).toBeUndefined(); + expect(got.note).toContain('打不开'); + }); + + it('二进制类型(pdf):说清只有文件名进得了活动', () => { + const got = extractText('规格书.pdf', 'application/pdf', new Uint8Array([1, 2, 3])); + expect(got.text).toBeUndefined(); + expect(got.note).toContain('读不出文本'); + }); + + it('空表:不是「读出 0 行」,是明说这份表是空的', () => { + const zip = makeZip([{ name: 'xl/worksheets/sheet1.xml', body: enc('') }]); + expect(extractText('空.xlsx', '', zip).note).toContain('空的'); + }); +}); + +describe('纯文本族 —— 按 UTF-8 直接解', () => { + it('csv 原样读出(中文不乱码)', () => { + const got = extractText('bom.csv', 'text/csv', enc('型号,年用量\nLM358DR,12000')); + expect(got.text).toBe('型号,年用量\nLM358DR,12000'); + }); + + it('超长的截断,并在读数里说明截了', () => { + const got = extractText('big.txt', 'text/plain', enc('x'.repeat(30_000))); + expect(got.text).toHaveLength(20_000); + expect(got.note).toContain('截断'); + }); +}); + +describe('XML 实体还原 —— `&lt;` 之流不能原样端给模型', () => { + it('转义字符还原成本来的字', () => { + const zip = makeZip([ + // 表里真实存的形态:`A&B <100>` 在 XML 里写作 `A&B <100>` + { name: 'xl/sharedStrings.xml', body: enc('A&B <100>') }, + { + name: 'xl/worksheets/sheet1.xml', + body: enc('0'), + }, + ]); + expect(extractText('e.xlsx', '', zip).text).toBe('A&B <100>'); + }); +}); diff --git a/apps/api/src/app/__tests__/view-table-edit.test.ts b/apps/api/src/app/__tests__/view-table-edit.test.ts new file mode 100644 index 0000000..67b91b5 --- /dev/null +++ b/apps/api/src/app/__tests__/view-table-edit.test.ts @@ -0,0 +1,206 @@ +/** + * **件上就地改** —— 人手改的那几格,是不是落得下去、按不按得下提交。 + * + * 两件事在这儿测,都是用户实测逼出来的(2026-08-02,同一单): + * + * ① **按地址收,不按「当下那张卡的第几块」收**。 + * 载荷从前是 `{workOrderId, values:{'table:0': …}}`:列名由那张工单的 `blocks[0]` + * 给出,于是「这份件能不能改」被绑在了「此刻开着的那张卡长什么样」上。 + * 实测:一单在放行门连拒五拍熔断,引擎发的是一张**没有 blocks 的熔断卡**; + * 屏上摆着的正是受理记录那份件,闸在、编辑进得去、保存按得下 —— + * 前端找不到匹配的块,**一格都没往下送**。而人要补的恰恰是把这一单 + * 卡在那儿的那个必填格。 + * + * ② **必填还空着就按不下提交**。同一单里,受理需求那张卡上一条料缺年用量(必填), + * 人照样交了回来;这一单于是一路走到放行门被 L1 拦住,模型连拒五拍熔断。 + * 同一件事在提交那一刻拦下便宜得多 —— 那时缺的是哪一格就摆在他眼前。 + * + * 测的是生产那两段本身(`writesOf` / `gateOf`),不在测里抄一份实现: + * 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。 + */ +import { describe, expect, it } from 'vitest'; +import { gateOf, rowChanged, writesOf } from '../routes/desk.ts'; +import { missingReadings } from '../wiring/engines.ts'; + +/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */ +const RECORD = { + domain: 'quote_intake', + object: 'record', + fields: [ + { name: 'rfq_ref', type: 'text', primaryKey: true }, + { name: 'pn', type: 'text', primaryKey: true, label: '型号 PN', required: true }, + { name: 'eau', type: 'number', primaryKey: false, label: '年用量 EAU', required: true }, + { name: 'brand', type: 'text', primaryKey: false, label: '制造商', expect: true }, + ], +}; +const VERDICT = { + domain: 'quote_intake', + object: 'verdict', + fields: [{ name: 'rfq_ref', type: 'text', primaryKey: true }], +}; + +describe('件上按保存:按地址收', () => { + it('**地址在声明里就写** —— 当下开着的是哪张卡、带没带块,一概不问', () => { + const got = writesOf( + [RECORD, VERDICT], + { 'quote_intake.record': [{ pn: 'NE555P', eau: '30000' }] }, + ); + expect(got).toHaveLength(1); + expect(got[0]?.declared.object).toBe('record'); + expect(got[0]?.lines).toEqual([{ pn: 'NE555P', eau: '30000' }]); + }); + + it('一次收好几张表 —— 需求表与分类表在同一份件上,各写各的', () => { + const got = writesOf([RECORD, VERDICT], { + 'quote_intake.record': [{ pn: 'A', eau: '1' }], + 'quote_intake.verdict': [{ rfq_ref: 'R1' }], + }); + expect(got.map((x) => x.declared.object)).toEqual(['record', 'verdict']); + }); + + it('**活动没声明过的地址不写** —— 载荷从浏览器来,门得在服务端', () => { + expect(writesOf([RECORD], { 'customer.profile': [{ name: '改我' }] })).toEqual([]); + }); + + it('地址不成形的不写 —— 「域.对象」两段,多一段少一段都不认', () => { + expect(writesOf([RECORD], { record: [{ pn: 'A' }] })).toEqual([]); + expect(writesOf([RECORD], { 'a.b.c': [{ pn: 'A' }] })).toEqual([]); + }); +}); + +describe('提交闸:必填没填就交不了', () => { + const gate = (rows: Array>) => + gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]); + + it('一格不缺 → 交得了', () => { + expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000 }]).ready).toBe(true); + }); + + it('**缺必填 → 交不了,并说清差在哪一行哪一格**', () => { + const got = gate([ + { rfq_ref: 'R1', pn: 'NE555P', eau: 30000 }, + { rfq_ref: 'R1', pn: 'DAG2400L321504XZB01-R0', eau: null }, + ]); + expect(got.ready).toBe(false); + expect(got.why).toContain('1 条必填'); + expect(got.blanks).toEqual(['R1/DAG2400L321504XZB01-R0 缺年用量 EAU']); + }); + + it('**选填缺不拦单** —— 那一问不能省,但它不是交不了的理由', () => { + expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: '' }]).ready).toBe(true); + }); + + it('空串与 null 同等看待 —— 模型用 `""` 表示「原文没给」', () => { + expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: '' }]).ready).toBe(false); + }); + + it('**与放行门的 L1 同一个数** —— 两处各算各的,就会出现「卡上说能交、放行门说不行」', () => { + const rows = [{ rfq_ref: 'R1', pn: 'A', eau: 1 }, { rfq_ref: 'R1', pn: 'B', eau: null }]; + const readings = missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]); + // L1 判据读的就是这个键:`quote_intake_record_missing = 0` + expect(readings['quote_intake_record_missing']).toBe(1); + expect(gateOf(readings, [RECORD]).ready).toBe(false); + }); +}); + +describe('只写真改了的那几行', () => { + const row = { rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: 'TI' }; + + it('**一个字没改就不算一次修订** —— 件上按保存,整张表都回传上来,它不知道人动了哪一格', () => { + expect(rowChanged(row, { eau: 30000, brand: 'TI' })).toBe(false); + }); + + it('改了一格就算 —— 只按值比,不按有没有回传比', () => { + expect(rowChanged(row, { eau: 45000, brand: 'TI' })).toBe(true); + }); + + it('**数字与它的字符串写法相等** —— 台账里躺的是 30000,人手填回来的是 "30000"', () => { + expect(rowChanged(row, { eau: '30000' })).toBe(false); + }); + + it('空与没有相等 —— 空格子回传成 `""`,而台账里那一列压根没写过', () => { + expect(rowChanged(row, { remark: '' })).toBe(false); + expect(rowChanged({ ...row, remark: null }, { remark: '' })).toBe(false); + }); + + it('台账里还没有这一行 → 是新增,当然算改', () => { + expect(rowChanged(undefined, { eau: 30000 })).toBe(true); + }); +}); + +/** + * **增删行**(2026-08-02 用户:「编辑列表应该还能增删行」)。 + * + * 手改本来就是**兜 AI 的错**的那一手,而错不止「这一格填错了」: + * 「这一行根本不该在」(客户点名本轮跳过的料被写进了有效清单)与「漏了一行」 + * 是同一类错 —— 只给改格子,前两种就没有出口,那一单会一路走到放行门被判据拦住, + * 而人明明一眼就看出问题在哪(实测那一单 13 次拒回、234k token 才熔断)。 + */ +describe('增删行:删要说出来,不靠「没回传就是删了」', () => { + it('**删走 `drops`,与改走 `tables` 分开** —— 两份都按地址过同一道声明门', () => { + const drops = writesOf([RECORD], { 'quote_intake.record': [{ pn: 'NE555P', rfq_ref: 'R1' }] }); + expect(drops).toHaveLength(1); + expect(drops[0]?.lines).toEqual([{ pn: 'NE555P', rfq_ref: 'R1' }]); + }); + + it('删的地址也得在声明里 —— 载荷从浏览器来,撤行比改格子更该有门', () => { + expect(writesOf([RECORD], { 'customer.profile': [{ name: '撤我' }] })).toEqual([]); + }); + + it('**回传里没提到的行不算删** —— 一次不完整的回传不该变成一次删表', () => { + // 三行的表只回传一行:那是「这次只说了这一行」,不是「另外两行撤掉」 + const got = writesOf([RECORD], { 'quote_intake.record': [{ pn: 'A' }] }); + expect(got[0]?.lines).toHaveLength(1); // 删与不删由 drops 说了算,tables 不承载删的语义 + }); + + it('新增行照样过归型与必填 —— 加一行只填了 PN,闸照旧拦着', () => { + const readings = missingReadings( + { 'mine:quote_intake.record': [{ rfq_ref: 'R1', pn: '老料', eau: 1 }, { rfq_ref: 'R1', pn: '人加的新料' }] }, + [RECORD], + ); + const got = gateOf(readings, [RECORD]); + expect(got.ready).toBe(false); + expect(got.blanks).toEqual(['R1/人加的新料 缺年用量 EAU']); + }); +}); + +/** + * **自增主键由基座生成**(2026-08-02 用户:「联系人ID自增啊,不用填」)。 + * + * 联系人编码不承载业务含义,只是「这家客户下的第几位」。让人填是白占一格 + * 他答不上来的表单(他哪知道已经有几位);让模型编更糟 —— 编重了就是覆盖别人那一行, + * 而主数据覆盖错了后面每一单都跟着错。 + */ +describe('自增主键:前缀-两位序号', () => { + /** 与 `routes/desk.ts` 的 `autoKeys` 同一口径 —— 改一处这里要跟着改 */ + const gen = (others: string[], given: Record, count: number, names: string[]) => { + const prefix = others.map((k) => given[k] ?? '').filter((v) => v !== '').join('-'); + const out: Record = {}; + for (const [i, n] of names.entries()) { + const seq = String(count + 1 + i).padStart(2, '0'); + out[n] = prefix === '' ? seq : `${prefix}-${seq}`; + } + return out; + }; + + it('**照种子里那个写法**:第一位联系人是 `客户编码-01`', () => { + expect(gen(['customer_code'], { customer_code: 'CU-9001' }, 0, ['contact_code'])).toEqual({ + contact_code: 'CU-9001-01', + }); + }); + + it('这家客户已经有两位 → 新的是 -03,不跟谁撞', () => { + expect(gen(['customer_code'], { customer_code: 'CU-0142' }, 2, ['contact_code'])['contact_code']).toBe('CU-0142-03'); + }); + + it('同一单里连加两位 → 各自拿到 -01 / -02(后一位不覆盖前一位)', () => { + expect(Object.values(gen(['customer_code'], { customer_code: 'CU-1' }, 0, ['a', 'b']))).toEqual([ + 'CU-1-01', + 'CU-1-02', + ]); + }); + + it('没有其余主键(它自己就是唯一那把键)→ 退回纯序号', () => { + expect(gen([], {}, 4, ['seq'])).toEqual({ seq: '05' }); + }); +}); diff --git a/apps/api/src/app/agent-runner.ts b/apps/api/src/app/agent-runner.ts index dce6192..6ea12e8 100644 --- a/apps/api/src/app/agent-runner.ts +++ b/apps/api/src/app/agent-runner.ts @@ -8,19 +8,38 @@ * 见 docs/debt.md · M5-2:§6.1 规定这里用 Mastra `Agent`;当前直接经 ModelGateway, * 端口不变,换实现时 usecase 不动。 */ -import type { AgentAction, EnabledSet } from '@modules/loop-engine'; +import type { AgentAction, AgentTurn, EnabledSet } from '@modules/loop-engine'; +import { TURN_ACTION_CAP } from '@modules/loop-engine'; import type { ModelGateway } from '@modules/llm'; import type { TaskId } from '@contracts'; -/** Agent 只能选三型行动之一(外加规划 / 弃用 / 评估 / 提交)—— schema 把它框住 */ -const ACTION_SCHEMA = { +/** 一个行动项 —— 三型行动之一(外加规划 / 弃用 / 评估 / 完成 / 提交),schema 把它框住 */ +const ACTION_ITEM_SCHEMA = { type: 'object', required: ['kind'], properties: { - kind: { enum: ['start', 'plan', 'disable', 'judge', 'tool', 'human', 'listen', 'submit'] }, + kind: { + enum: ['start', 'plan', 'disable', 'judge', 'tool', 'write', 'drop', 'ask', 'entry', 'decide', 'human', 'listen', 'finish', 'submit'], + }, itemId: { type: 'string' }, tool: { type: 'string' }, input: { type: 'object' }, + /* 写台账草稿:地址 + 多行(键 + 补丁)—— 一拍写完一份 BOM,不一行一拍 */ + address: { type: 'string', description: '台账地址「域.对象」—— 照可写台账字典原样抄一个,不要拆开' }, + rows: { + type: 'array', + items: { + type: 'object', + required: ['key', 'patch'], + properties: { key: { type: 'object' }, patch: { type: 'object' } }, + }, + }, + /* 撤草稿:同一个 address,外加要撤的那几行主键 —— 写错了得有退路 */ + keys: { + type: 'array', + description: 'drop 用:要撤掉的那几行的主键,与写进去时**一字不差**', + items: { type: 'object' }, + }, workOrder: { enum: ['execution', 'acceptance'] }, /* * 卡面结构由 schema 框住 —— 「调用必附上下文,**裸调用不得发出**」(AM 06)。 @@ -35,8 +54,115 @@ const ACTION_SCHEMA = { reason: { type: 'string' }, basis: { type: 'array' }, fields: { type: 'array' }, + /* + * 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。 + * + * 执行工单本来就收 `blocks`(契约里一直有),可这份 schema 里没有它 —— + * 结构化出参只吐 schema 里声明过的键,于是模型写了也会被整个丢掉。 + * 实测:说明书让它派一张「带可编辑需求表」的执行单,它照做了, + * 而人收到的卡上一张表都没有,只剩一句「请向客户补全这两颗料的缺口」。 + * + * 最要紧的是 `table` + `editable: true`:空格子界面会自己标红「待填写」。 + */ + blocks: { + type: 'array', + description: '卡面正文块:table(可 editable)/ kv / callout / ul —— 把数据摆到表单旁边', + items: { + type: 'object', + required: ['t'], + properties: { + t: { enum: ['table', 'kv', 'callout', 'ul'] }, + title: { type: 'string' }, + cols: { type: 'array', items: { type: 'string' }, description: 'table:列名' }, + rows: { + type: 'array', + items: { type: 'array', items: { type: 'string' } }, + description: 'table:一行一条,**没有的值给空字符串**(界面自己标红待填写);绑了 ledger 就不必给', + }, + /* + * 绑台账:**行让基座去铺**,别自己抄一遍。 + * + * 人要在卡上改的是那份清单本身;而把整份塞进这一份回复里有两处死结 —— + * 回复有长度上限,铺长了半途截断整拍作废(实测); + * 那些行台账里已经有了,再抄一遍是拿 token 换一次抄错的机会。 + */ + ledger: { type: 'string', description: 'table:绑台账地址(域.对象),行由基座按本单草稿铺满' }, + fields: { + type: 'array', + items: { type: 'string' }, + description: 'table:与 cols 一一对应的台账列名 —— 绑了 ledger 就必须给', + }, + editable: { type: 'boolean', description: 'table:让人当场改' }, + editableCols: { type: 'array', items: { type: 'integer' }, description: '只让改这几列(下标)' }, + items: { type: 'array', description: 'kv:[{k,v}];ul:字符串数组' }, + tone: { type: 'string', description: 'callout:语气' }, + text: { type: 'string', description: 'callout:正文' }, + }, + }, + }, }, }, + /* 补全卡 / 决策卡共用这一格:问一句(两者都是对话卡族,不是执行工单) */ + ask: { type: 'string', description: '补全卡:一句话说清缺口;决策卡:一句话说清这是个什么岔路' }, + /* + * 决策卡的选项 —— **至少两个**。`consequence` 是「选了它接下来会发生什么」, + * 人靠它做判断;不写等于让人猜,那就跟没给选项差不多。 + */ + options: { + type: 'array', + description: '决策卡:一条路一项,至少两项', + items: { + type: 'object', + required: ['value', 'label'], + properties: { + value: { type: 'string', description: '机器认的值(回传时就是它)' }, + label: { type: 'string', description: '按钮上的话' }, + detail: { type: 'string' }, + consequence: { type: 'string', description: '选了它接下来会发生什么' }, + recommended: { type: 'boolean' }, + }, + }, + }, + allowCustom: { type: 'boolean', description: '决策卡:允许人给选项之外的处置(附理由)' }, + /* + * 两用一格: + * · 补全卡(ask)—— `{key,label,hint}`,人从空白填起; + * · 提交放行(submit)—— `{key,label,ai,ledger}`,验收报告上让人改的那几个值。 + * + * `ai` / `ledger` **必须写进 schema**:结构化出参只吐 schema 里有的键, + * 光在提示词里交代不算 —— 实测模型照着 schema 吐,拟值与落回地址被整个丢掉, + * 验收报告于是只能看不能改。 + */ + /* 数据录入卡:落哪张表、哪个主键 —— 人确认那一下基座照它写 */ + ledger: { type: 'string', description: '数据录入卡:落表地址「域.对象」' }, + entryKey: { type: 'object', description: '数据录入卡:主键值,如 {"customer_code":"C-XXX"}' }, + also: { + type: 'array', + description: '数据录入卡:同一次确认还要落的另几张表,每段 {title, ledger, key, fields} —— 建客户就是公司档 + 联系人档', + items: { type: 'object' }, + }, + fields: { + type: 'array', + description: '补全卡:要对方填的格子;数据录入卡:要写的那一行;提交放行:验收报告上让人改的值', + items: { + type: 'object', + properties: { + key: { type: 'string' }, + label: { type: 'string' }, + hint: { type: 'string', description: '补全卡:格子里的灰字提示' }, + value: { + type: 'string', + description: + '补全卡 / 数据录入卡:**预填值** —— 你从来件里读得出的先替人填上(公司名、联系人姓名等),' + + '人只需核对一眼、补上你填不出的那个。读不出来的别给:填错了人还得先擦掉再写。', + }, + required: { type: 'boolean', description: '数据录入卡:必填(空着不让确认)' }, + ai: { type: 'string', description: '提交放行:AI 拟值 —— 验收报告「已填好」的那个值' }, + ledger: { type: 'string', description: '提交放行:改完落回哪个台账地址(域.对象)' }, + }, + }, + }, + basis: { type: 'array', description: '依据快照:键值对数组' }, eventType: { enum: ['data', 'time'] }, keyFilter: { type: 'object' }, deadline: { type: 'string' }, @@ -47,6 +173,43 @@ const ACTION_SCHEMA = { }, } as const; +/** + * 一拍的出参:一串行动 + 一句交代。 + * + * 行动收**数组**是按实测账改的:一拍只许一个行动时,每个 write / finish / start + * 都单烧一次完整模型调用 —— A1 六步的活恰好 21 拍,其中 19 拍是该约束下的最短路径。 + * 交代(say/tag/view)在拍级不在行动级:它是这一拍的附带,不与行动抢位置。 + */ +const TURN_SCHEMA = { + type: 'object', + required: ['say', 'actions'], + properties: { + /* + * **一轮一句,不是一拍一句**(2026-08-01 用户定)。 + * + * 这句话只在**要把控制权交出去的那一拍**才会进对话区(发卡 / 立监听 / 提交放行); + * 埋头办事的那些拍它照旧留进工作日志,人看不到。所以别写工序播报 + * (「我这就把标写进分类表,收掉这一步」)—— 那种话攒五条也没告诉人一件事。 + * 要写就写**这一轮办完了什么、结论是什么、为什么现在要找他**。 + */ + say: { + type: 'string', + description: + '对负责人说的一句人话。**只在发卡/立监听/提交放行那一拍才会摆到他面前**:' + + '写这一轮的结论与为什么现在需要他,别播报「我这就去写哪张表、收哪一步」。', + }, + tag: { enum: ['guidance', 'criterion', 'conclusion'] }, + view: { type: 'string', description: '可选:换成哪张局面模版(活动清单里的名字)' }, + actions: { + type: 'array', + minItems: 1, + maxItems: TURN_ACTION_CAP, + description: '这一拍按序执行的行动串;要看工具出参才能定的事,别写在工具后面', + items: ACTION_ITEM_SCHEMA, + }, + }, +} as const; + export type AgentRunnerDeps = { readonly gateway: ModelGateway; /** 活动声明的自定义模型名 */ @@ -55,35 +218,332 @@ export type AgentRunnerDeps = { readonly onUsage: (taskId: TaskId, tokens: number) => Promise; }; +/** 可写台账摊成字典:地址 · 中文标 · 主键 · 列名 —— 模型照这个填,不照想象编 */ +export type WritableLedger = Array<{ + domain: string; + object: string; + label: string; + keys: string[]; + fields: Array<{ name: string; label: string; type?: string }>; +}>; + +export function ledgerDictionary(writable: WritableLedger): string { + if (writable.length === 0) return ''; + return [ + '【可写台账(只准写这些地址与列名)】', + ...writable.map( + (w) => + `- ${w.domain}.${w.object} ${w.label}|主键:${w.keys.join('+') || '(无)'}` + + // 列型也摆出来:数值列写不进空串(空 = 不写这一列),它得知道 + `|列:${w.fields + .map((f) => `${f.name}${f.label === f.name ? '' : `(${f.label})`}${f.type === undefined || f.type === 'text' ? '' : `:${f.type}`}`) + .join('、')}`, + ), + ].join('\n'); +} + +/** 本单自己写下的行 —— `mine:` 前缀那一份(与视图模版同一约定) */ +function minePart(scope: Record): Record { + return Object.fromEntries(Object.entries(scope).filter(([k]) => k.startsWith('mine:'))); +} + +/** + * 其余作用域:**摊平字段与起单入参在前,`域.对象` 整表殿后**。 + * + * 顺序就是生死:这一段进提示词前会按字符截断,而整表里躺着**历史所有已放行单**的行, + * 只会随单量无限长 —— 排在前面,截断刀落下时被砍掉的是这一单自己的摊平字段(实测 + * 入参膨胀到 13k token 的那一拍就是这么来的)。整表殿后,砍也先砍旧账。 + */ +function restPart(scope: Record, options?: { withoutAttachments?: boolean }): Record { + const rest = Object.entries(scope).filter( + ([k]) => + !k.startsWith('mine:') && + !k.startsWith('focus:') && + !(options?.withoutAttachments === true && k === 'attachments'), + ); + const flat = rest.filter(([k]) => !k.includes('.')); + const tables = rest.filter(([k]) => k.includes('.')); + return Object.fromEntries([...flat, ...tables]); +} + +/** + * 附件单独一段 —— **它是这一单的料本身,不许被截断刀砍**。 + * + * 表能拆的先替它拆好(`rows`);拆不出的保留原文,并如实说明为什么。 + * 一件都没有就不摆这一段:空标题只会占位置。 + */ +export function attachmentPart(scope: Record): string { + const list = scope['attachments']; + if (!Array.isArray(list) || list.length === 0) return ''; + const shaped = withSheetRows(scope)['attachments']; + return `【随件附件(基座已读过一遍;rows 是替你拆好的表,键就是表头)】\n${JSON.stringify(shaped)}`; +} + +/** + * 附件里的**表**:制表符分列的原文 → **一行一个对象**,键就是表头。 + * + * 为什么非做不可:模型拆制表符会数错格子。连着两个 `\t` 是中间那一格空着, + * 而它常把右边的值往前挪 —— 实测同一行栽过两次:BOM 上白纸黑字写着 + * 「Антенна · 浙江嘉康 · (空) EAU」,拆出来成了「集成电路 · (空) · 30000」: + * 品类错了、制造商丢了、年用量凭空多了一个。**数格子是能算的事,不该问 LLM。** + * + * 只认「首行是表头、列数对得上」的规整表;对不上就原样留着文本, + * 让模型自己看 —— 宁可不帮忙,不要把一份读歪的表塞给它当事实。 + */ +export function sheetRows(text: string): Array> | undefined { + const lines = text.split('\n').filter((l) => l.trim() !== ''); + if (lines.length < 2) return undefined; + const header = (lines[0] ?? '').split('\t'); + if (header.length < 2) return undefined; + const rows: Array> = []; + for (const line of lines.slice(1)) { + const cells = line.split('\t'); + // 列数不齐就不认这张表 —— 半张读歪的表比没有更坏 + if (cells.length !== header.length) return undefined; + rows.push(Object.fromEntries(header.map((h, i) => [h.trim() === '' ? `列${i + 1}` : h.trim(), (cells[i] ?? '').trim()]))); + } + return rows.length === 0 ? undefined : rows; +} + +/** + * 起单入参里的附件:凡是读得出规整表的,**在文本旁边再给一份拆好的行**。 + * 文本照旧留着(口径要求、注释这类不在表里的东西还得从原文看)。 + */ +export function withSheetRows(scope: Record): Record { + const list = scope['attachments']; + if (!Array.isArray(list)) return scope; + let changed = false; + const attachments = list.map((raw) => { + if (typeof raw !== 'object' || raw === null) return raw; + const item = raw as Record; + if (typeof item['text'] !== 'string' || item['rows'] !== undefined) return raw; + const rows = sheetRows(item['text']); + if (rows === undefined) return raw; + changed = true; + /* + * 拆出来了就**把原文换下来**:两份并排会让这一件的体量翻倍, + * 而它进的那一段是按字符截的 —— 塞胖了,拆好的行连同别的字段一起被砍掉 + * (实测:加了 rows 之后三轮里那一行照错不误,因为它压根没进提示词)。 + * 附件的原文本来就只是这张表;正文里的口径要求另在 `body` 里,不受影响。 + */ + const { text, ...rest } = item; + return { ...rest, sheet: `已拆成 ${rows.length} 行(原文 ${String(text).length} 字,拆好的在 rows 里)`, rows }; + }); + return changed ? { ...scope, attachments } : scope; +} + +/** + * **这一步声明要读的那几张表** —— 剧本的 `reads`,由引擎打上 `focus:` 前缀。 + * + * 它单独成段、**不参与那把截断刀**:整表段是按字符截的,而截断是盲的 —— + * 砍掉的可能恰是这一步唯一要用的那张(A1 第一步的 `customer.contact` 就这么丢过, + * 老客户当场被当成新客户)。声明了读什么,就一定看得见什么。 + */ +function focusPart(scope: Record): Record { + return Object.fromEntries( + Object.entries(scope) + .filter(([k]) => k.startsWith('focus:')) + .map(([k, v]) => [k.slice('focus:'.length), v]), + ); +} + +/** 这一单能调什么 —— 声明 × 目录编译出来的清单 */ +export type BusListing = Array<{ id: string; description: string; input: Record; effect: 'read' | 'write' }>; + +/** + * 工具清单摊成提示词一段:id · 效应 · 说明 · 入参键名。 + * + * 只给键名不给完整 Schema:整份 JSON Schema 铺进去会把上下文吃掉一大半, + * 而模型真正需要知道的是「这件工具收哪几个参数」。填错了缝里会校验并如实回报。 + */ +export function toolCatalog(tools: BusListing): string { + if (tools.length === 0) return ''; + const keysOf = (input: Record): string => { + const properties = input['properties']; + if (typeof properties !== 'object' || properties === null) return '(无入参)'; + const required = new Set(Array.isArray(input['required']) ? (input['required'] as unknown[]).map(String) : []); + return Object.entries(properties as Record) + .map(([key, raw]) => { + const spec = (typeof raw === 'object' && raw !== null ? raw : {}) as Record; + const type = String(spec['type'] ?? 'string'); + return `${key}:${type}${required.has(key) ? '*' : ''}`; + }) + .join(', '); + }; + return [ + '【可调工具(只有这些;写类调了不会当场生效,登记为动作意向,放行才落地)】', + ...tools.map((t) => `- ${t.id}(${t.effect === 'write' ? '写类' : '读类'}):${t.description}\n 入参:${keysOf(t.input)}`), + ].join('\n'); +} + export function createAgentRunner(deps: AgentRunnerDeps): { - decide(input: { taskId: TaskId; narrative: string; enabled: EnabledSet; scope: Record }): Promise; + decide(input: { + taskId: TaskId; + narrative: string; + enabled: EnabledSet; + scope: Record; + writable: WritableLedger; + tools: BusListing; + }): Promise; } { return { - async decide({ taskId, narrative, enabled, scope }) { + async decide({ taskId, narrative, enabled, scope, writable, tools }) { // 交集为空即断点 —— 引擎已经算好了,这里只把它讲给模型听 - const breakpoint = enabled.entries.length === 0; + // 断点 = 无路可走:手上还开着活就不算(否则会把没干完的活丢下去等事件) + const breakpoint = enabled.entries.length === 0 && enabled.inProgress.length === 0; const prompt = [ '你是这一单任务的案例工作者。下面是语义引擎发布的**当下可为**与**目标缺口**。', - '你只能选一个行动,且必须落在三型行动之内:调工具 / 调人 / 立监听(外加 start、plan、disable、judge、submit)。', - '规矩:①触达世界的动作只能 tool(写类会被登记为动作意向,放行时才落地)', - '④调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、', - ' basis 给出判断依据(键值对数组)、fields 列出要对方回填的字段名。', - ' 只写「请处理」的卡收卡人无从下手,等于白发一张。', - '②缺口只有人知道 → human;缺口在世界侧 → listen(必须给 deadline,ISO-8601)', - '③成果已齐才 submit。只输出 JSON。', + '这一拍你给**一串行动**(actions 数组,1-6 个,按序执行):write(写台账草稿)/', + 'tool(调工具)/ ask(补全卡)/ decide(决策卡)/ human(调人)/ listen(立监听),外加 start(启动须手动启动的项)、', + 'plan(规划裁量项)、disable(此单不走这条)、judge(评估语义性前提)、finish(宣告一项完成)、submit(提交放行)。', + /* + * **写错了有退路**(2026-08-01 加 drop)。没有它的时候实测过一次死胡同: + * 客户说本轮跳过的那颗料被写进了有效清单,判据正确地拦住,而模型改不动 —— + * 唯一能改的是那个数,改了别的数又不对,13 次拒回、23 拍、234k token 才熔断。 + */ + '', + '**写错一行就撤掉它**:`drop`(address 同 write,keys 给那几行的主键,reason 一句话)——', + '撤的只是本单草稿层那一笔,正式版一个字都动不了。行数判据对不上时先看拒回里那份主键名单:', + '多的那一行就在名单里对着,撤掉它比改数字对 —— **数是台账的结论,别为了过判据去改它**。', + '主键要与写进去时一字不差(补个后缀就是另一行),撤不到基座会如实回绝。', + '', + '**一拍连做,别拆拍**:拍是按模型调用计费的 —— 「写记录、写结算、finish、start 下一项、接着写」', + '一口气排进同一串;每多拆一拍就多烧一遍完整上下文。三条排串纪律:', + '· tool 的出参你要下一拍才看得见 —— 要看结果才能定的事,别排在 tool 后面(排了也会被作废);', + '· ask / human / listen / submit 会让本拍挂起 —— 只能是串里最后一个;', + '· 串按序执行,谁被拒就停在谁那儿,前面的已生效。', + '', + '**出参要瘦** —— 你每多写一个字,人就多等一分:', + '· write 的 patch **只带有值的列**:没有值的列一个都别写(草稿是合并语义,不写 = 不动;', + ' 写一串 "":"" 只是把回答拖长)。没变的行也别重写。', + '· say 一句话说清即可,不复述台账内容。JSON 紧凑输出,不要缩进排版。', + '', + /* + * **说话的节奏 = 一轮一句**(2026-08-01 用户定)。 + * 从前每拍一句,人在对话区看到的是连着五条工序播报;那一段本该由 + * 「AI 工作中」的办事条覆盖 —— 埋头干活时不必每动一下就报一次。 + */ + '**说话按轮,不按拍**:设定目标 →(看现状 → 定做法 → 动手)× N 轮 → 交代一次。', + '你的 say 只在**要把控制权交出去的那一拍**才会摆到人面前 ——', + '发卡问人(ask)、派工单(human)、摆岔路(decide)、立监听等外部(listen)、提交放行(submit)。', + '别的拍照旧留进工作日志,人看不到,所以:', + '· 埋头办事的拍,say 写给日志看就行,**不必也不该**播报「我这就去写哪张表、收哪一步」;', + '· 到了要找人的那一拍,say 写**这一轮办完了什么、结论是什么、为什么现在需要他** ——', + ' 他看到的就这一句加一张卡,前面几拍的过程他不看也不该看。', + '', + '规矩:', + '① **你的成果就是台账里的行**。看懂材料、拆出条目、算出结论之后,用 write 把它们写进台账 ——', + ' 不写就等于没做:验收判据读的是台账,出口载荷也从台账推。一次 rows 可以写多行,', + ' 一份 BOM 一拍写完,不要一行一拍。写的是**草稿层**,放行前世界上什么都没变。', + ' `address` 照「可写台账」里的地址原样抄(形如 `域.对象`,别拆开);', + ' 只准写字典列出的地址与列名,编出来的列会被当场拒。**原文没有的值就别写这一列**', + ' (空着 = 这一列没有值);数值列(:number/:integer)尤其不能写空串。', + ' write 的 `itemId` 是**记账**:填正在干的那一项(它会亮进「进行中」);', + ' 挂不上(项已完成等)写也照落 —— 写的授权在台账声明,不在计划项。', + ' tool / ask 的 `itemId` 则要落在**当下可为**里;阶段(stage)只能 start,', + ' 标了「须你启动」的项要先 start 再调工具。', + '② **系统能干的活不要交给人**:调工具就在「可调工具」里挑,照它的入参键名填 ——', + ' 把一件工具做得到的事发成一张卡问人,是白占人的时间(实测踩过:该调历史价工具,', + ' 却发了一张「请查这三个 PN 的历史价」的卡)。触达世界的动作也只能 tool', + ' (写类会被登记为动作意向,放行那一刻才真执行)。', + '③ 调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、', + ' basis 给出判断依据(键值对数组)、fields 列出要对方回填的字段名。', + ' 只写「请处理」的卡收卡人无从下手,等于白发一张。**人答回的值在轨迹里,', + ' 看到了要用 write 落进台账** —— 系统不会替你写。', + '④ **缺字段要人补 → ask(补全卡)**:缺口清单摆在对话区、带一张表单,人就地填齐。', + ' **能填的先替他填上**:格子给 `value` 预填(公司名、联系人姓名这类来件里写着的),', + ' 人核对一眼比从头敲快得多;你填不出来的(如内部客户编码)留空,别硬凑。', + /* + * 形状里**必须带 `value`** —— 这一条实测栽过: + * 活动剧本让模型"能填的先替人填上",还给了带 value 的 JSON 骨架; + * 模型把骨架里的 ask 一字不差抄了过来,`value` 却全是空的 —— + * 因为**系统提示词这一行写死的形状里没有它**,活动剧本盖不过系统提示词。 + * 形状是模型真正照抄的那一份,漏一个键就等于那个键不存在。 + */ + ' 形状:{kind:"ask", itemId, ask:"一句话说清缺口", fields:[{key,label,hint?,value?}], basis:[{k,v}]}。', + ' `value` = 预填值:来件里读得出的先替人填上(公司名、联系人姓名),读不出的留空。', + '④·2 **要往台账写一行、而那一行该由人过目 → entry(数据录入卡)**:', + ' 与补全卡的分别在**谁写库**:补全卡收回来的值要你自己再 write 一遍;', + ' 数据录入卡是**人按下确认那一刻基座直接写**,你不经手 —— 建档这类事该用它,', + ' 主数据建错一次后面每单都跟着错,那一笔该由人亲手落下。', + ' 形状:{kind:"entry", itemId, ask, ledger:"域.对象", entryKey:{主键列:值},', + ' fields:[{key,label,value?,required?}], basis:[{k,v}]}。', + ' **能填的先填上**(value),填不出的留空 —— 界面会标「待填写」;', + ' 缺了这一格这张档就是残的,就标 required:true。', + '④" **机器判不了的判据 → judge**:验收判据里 L3/L4 那类(「吃不准降 B」「合乎业务常理」)', + ' 机检判不了,只能你当场裁:{kind:"judge", criterion:"照判据名原样写", satisfied:true/false, rationale:"凭什么"}。', + ' **提交放行前必须裁完** —— 除「人放行」外还有判据没达标时,submit 会被当场拒。', + '④\' **岔路口要人定 → decide(决策卡)**:把几条路摆出来,人点一个 —— 不用敲字。', + ' 补全卡问**事实**(缺哪几个字段,你填);决策卡问**处置**(往哪走,你选)。', + ' 两条路都合法、选哪条是业务判断时,发这一张;别拿补全卡去问「你想怎么办」。', + ' 形状:{kind:"decide", itemId, ask, options:[{value,label,consequence?,recommended?}], basis:[{k,v}]}。', + ' `fields` 一个都不给会被拒 —— 空表单等于没问;一个缺口一个格子,label 写人话。', + ' 不要拿执行工单(human)去问几个字段 —— 那是「派一件活」,收卡的人得进待处理、', + ' 办完再回来,重得不成比例。human 留给真要人办一件事或裁断一次的场合。', + ' 缺口在世界侧 → listen(必须给 deadline,ISO-8601)。', + '⑤ **一项干完了要自己宣告**:finish(itemId + rationale)。write / tool 只是干活,', + ' 不会自动把那一项收掉 —— 因为一项常要好几个动作才交得出成果(先逐条写记录、', + ' 再结算总数;先调工具取判据、再把判据写进台账)。都做完了再 finish。', + '⑥ **每一拍都要带一句 `say`** —— 你是在跟负责人**当面说话**,不是在播报进度。', + ' 他正看着这一单:只写台账不说话,他的对话区从头到尾是空的,只看得见「多了一个任务」。', + ' 怎么说:', + ' · **先说这对这一单意味着什么**,再说你做了什么 —— 他关心的是单子,不是你的步骤;', + ' · **一两句、口语**。像同事扭头跟你说一声,不是写工作日报;', + ' · **别报内部词**:关键字段 / 次要字段 / 归一化阶段 / 计划项 / 条数 / 写草稿 ——', + ' 这些是系统的说法。说料号、交期、价格、客户,说他明天要拿去跟人讲的那些话;', + ' · **数字只在有意义时才报**。「三颗料都查到了历史价」有用;「写草稿 3 行」没用;', + ' · **有风险、要他拿主意、或者你打算叫人,先说出来** —— 别等他自己从表里看出来;', + ' · 别每拍一个腔调复读(「已完成X,现在进行Y」)。事情不同,话就该不同。', + ' 反例:「已登记3条需求,缺关键字段0条,直接进入归一化阶段」—— 这是报表不是说话。', + ' 正例:「三颗料都拆出来了,交期和用量都齐;NE555P 客户没给目标价,我先照历史价估。」', + ' `say` 不占额外的拍,与这一拍的行动一起给;局面变了就用 `view` 换一张画面。', + '⑦ 成果已齐才 submit(提交放行,人验收)。submit 可带 `fields` —— 验收报告上', + ' **让人当场改**的那几个值:{key,label,ai:"你拟的值",ledger:"域.对象"}。', + ' `ai` 是「AI 已填好」的那个值,`ledger` 是他改完落回哪儿;缺一样修订就落不下去。', + '只输出 JSON,形如 {"say":"…","tag":"conclusion","view":"…","actions":[{"kind":"write",…},{"kind":"finish",…}]}。', '', `【案例说明书与活动记忆】\n${narrative}`, '', `【当下可为(Enabled 集)】\n${ enabled.entries.map((e) => `- ${e.id} ${e.name}(${e.type}${e.requiresManualStart ? ' · 须你启动' : ''}${e.discretionary ? ' · 裁量项' : ''})`).join('\n') || '(空)' }`, + enabled.inProgress.length === 0 + ? '' + : `【进行中(你已开工,还没交出成果)】\n${enabled.inProgress + .map((e) => `- ${e.id} ${e.name}(${e.type})`) + .join('\n')}\n这些项要么接着干(write / tool),要么干完了就 finish —— 别丢下它们去等事件。`, `【目标缺口】\n${enabled.gaps.join('、') || '(无)'}`, enabled.judgements.length === 0 ? '' : `【待你评估的语义性前提】\n${enabled.judgements.map((j) => `- ${j.criterion}:${j.statement}`).join('\n')}`, - breakpoint ? '【注意】当下可为 ∩ 目标所需 = 空 —— 这是依赖断点,请调人或立监听。' : '', - `【任务数据】\n${JSON.stringify(scope).slice(0, 4000)}`, + breakpoint && enabled.inProgress.length === 0 + ? '【注意】当下可为与进行中都空 —— 这是依赖断点,请调人或立监听。' + : '', + toolCatalog(tools), + ledgerDictionary(writable), + /* + * 任务数据分两半摆,**本单的摆在前面**。 + * + * `mine:域.对象` 是这一单自己写下的行;`域.对象` 是正式版全表 —— + * 里面有别单放行过的记录。不分开摆,Agent 会拿别单的数据当自己的结论讲出来 + * (实测:一单的交代里说的全是另外两单的分类结果)。 + */ + `【本单已写下的行(mine:*)——「我的成果」只看这一份】\n${JSON.stringify(minePart(scope)).slice(0, 3000)}`, + // 这一步声明要读的表:摆在整表段**之前**,且不参与下面那把截断刀 + Object.keys(focusPart(scope)).length === 0 + ? '' + : `【这一步要读的台账(剧本 reads 声明的,已为你取好)】\n${JSON.stringify(focusPart(scope))}`, + /* + * 附件**单独成段,且不参与那把截断刀**。 + * + * 它是这一单的料本身 —— 被砍掉就等于让模型闭着眼拆条。 + * 表能拆的先替它拆好(`rows`,键就是表头):数制表符是能算的事, + * 交给模型只会数错格子(实测:同一行的品类、制造商、年用量三样一起错位)。 + * 这一段与整表段分开摆,正是因为整表段按字符截 —— 混在一起,拆好的行会被砍掉。 + */ + attachmentPart(scope), + `【任务数据(域.对象 是全表,含别单已放行的记录 —— 别当自己的)】\n${JSON.stringify(restPart(scope, { withoutAttachments: true })).slice(0, 1800)}`, ] .filter((line) => line !== '') .join('\n'); @@ -93,12 +553,24 @@ export function createAgentRunner(deps: AgentRunnerDeps): { const result = await deps.gateway.generate({ model, prompt, - outputSchema: ACTION_SCHEMA, + outputSchema: TURN_SCHEMA, + // 规划型出参要的是稳,不是花样 —— 低温少绕弯,解码就是墙上时钟 + temperature: 0.2, + /* + * 关思考 —— 三档都实测过(A1 同一单): + * 由着想:拍 9-56s,最慢拍 9.8k 字隐藏思考,八成五的时间在解码没人读的字; + * 'low':这家厂商上没用 —— 推理仍 5-9.7k 字、拍 24-45s,和不压一样; + * 'off':拍 2-4s(十倍差)。 + * off 暴露过一次业务误判(把缺目标价当关键字段拦单)—— 治法是把规则写死进 + * 说明书(「关键字段只有 PN/EAU/交期 三个」),不是给模型留思考额度: + * 这个架构里判断本就归判定表与说明书,模型只做编排。 + */ + reasoning: 'off', taskId, label: 'loop-turn', }); await deps.onUsage(taskId, result.usage.inputTokens + result.usage.outputTokens); - return normalize(result.object, enabled); + return normalizeTurn(result.object, enabled, result.text); } catch (error) { /* * 模型这一轮问不出结果(端点不可达、鉴权失败、超时…)。 @@ -110,6 +582,9 @@ export function createAgentRunner(deps: AgentRunnerDeps): { */ const detail = error instanceof Error ? error.message : String(error); return { + // 出错也要对人交代一句:不然他只看见工位上多了一张卡,不知道为什么 + say: { text: `这一轮我没能决断:${detail}`, tag: 'conclusion' as const }, + actions: [{ kind: 'human', itemId: enabled.entries[0]?.id ?? 'breakpoint', workOrder: 'execution', @@ -122,18 +597,83 @@ export function createAgentRunner(deps: AgentRunnerDeps): { ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。', fields: ['decision'], }, + }], }; } }, }; } +/** 认得的行动型 —— 与 ACTION_ITEM_SCHEMA 的 enum 同一份名单 */ +const KNOWN_KINDS = new Set([ + 'start', 'plan', 'disable', 'judge', 'tool', 'write', 'drop', 'ask', 'human', 'listen', 'finish', 'submit', + 'decide', + // 数据录入卡(第五类人类工具):人确认那一下由基座直接写库,不回到 Agent 手里 + 'entry', +]); + +/** + * 一拍的产出 = 一串行动 + 一句交代。 + * + * 三种来路都认:`{actions:[…]}`(规范形)、裸行动对象(旧形 —— 包成单元素串)、 + * 顶层就是数组。串里**不认识的项丢掉**(认识不认识由 KNOWN_KINDS 定); + * 整串一个都不认识才退化为单张「转人裁断」卡(把模型原话摆上去)—— + * 不能让串里一个写坏的项在批中途给人甩一张卡。 + * + * 交代**不是一型行动**:一开始把它做成 `kind: 'say'`,结果它占一整拍又不推进局面, + * 预算下的模型理所当然跳过它 —— 对话区照旧空白(实测)。所以它是拍级的附带字段。 + */ +export function normalizeTurn(object: unknown, enabled: EnabledSet, rawText?: string): AgentTurn { + const raw = (Array.isArray(object) ? { actions: object } : (object ?? {})) as Record; + const text = String(raw['say'] ?? raw['text'] ?? '').trim(); + const tag = raw['tag']; + const view = String(raw['view'] ?? '').trim(); + + const source = Array.isArray(raw['actions']) ? raw['actions'] : [raw]; + const recognized = source + .filter((a): a is Record => typeof a === 'object' && a !== null) + .filter((a) => KNOWN_KINDS.has(String(a['kind'] ?? a['action'] ?? ''))); + const actions = + recognized.length > 0 + ? recognized.slice(0, TURN_ACTION_CAP).map((a) => normalizeAction(a, enabled, rawText)) + : [normalizeAction(source[0] ?? {}, enabled, rawText)]; + + return { + actions, + ...(text === '' + ? {} + : { + say: { + text, + ...(tag === 'guidance' || tag === 'criterion' || tag === 'conclusion' ? { tag } : {}), + ...(view === '' ? {} : { view }), + }, + }), + }; +} + /** 模型出参归一为 AgentAction;不合规一律退化为「立监听等人」而不是乱动世界 */ /** * 卡面兜底:模型没填全就用局面补。 * 「调用必附上下文」是硬规矩 —— 与其发一张「请处理」,不如把当下可为与缺口摆上去, * 收卡的人至少知道自己站在哪儿。 */ +/** + * 依据快照的兜底 —— 模型没给 `basis` 时用**当下局面**顶上。 + * + * 与 `fillCard` 同一个道理:宁可信息略多,不发一张裸卡。 + * 摆的是这一步是什么、当下可为哪些、还缺什么 —— 都是人判断时确实用得上的, + * 不是为了凑数把随便什么塞进去。 + */ +function basisFromScene(enabled: EnabledSet, itemId: string): ReadonlyArray<{ k: string; v: string }> { + const item = [...enabled.entries, ...enabled.inProgress].find((e) => e.id === itemId); + return [ + { k: '这一步', v: item?.name ?? itemId }, + { k: '当下可为', v: enabled.entries.map((e) => e.name).join('、') || '(空)' }, + { k: '目标缺口', v: enabled.gaps.join('、') || '(无)' }, + ]; +} + function fillCard( card: Record, enabled: EnabledSet, @@ -155,9 +695,148 @@ function fillCard( }; } -function normalize(object: unknown, enabled: EnabledSet): AgentAction { +/** + * 验收报告的**可修订字段**归一 —— 只收有 key 的;`ai` 与 `ledger` 原样带过去。 + * 少了 `ai` 就没有「已填好」可改;少了 `ledger`,人改了也落不回台账。 + * 这里不替它补:补出来的地址一定是猜的,而猜错会把值写到别的对象上。 + */ +function normalizeRevisable(raw: unknown): Array<{ key: string; label: string; ai?: string; ledger?: string }> { + if (!Array.isArray(raw)) return []; + const out: Array<{ key: string; label: string; ai?: string; ledger?: string }> = []; + for (const item of raw) { + if (typeof item !== 'object' || item === null) continue; + const f = item as Record; + const key = String(f['key'] ?? '').trim(); + if (key === '') continue; + const ai = f['ai'] === undefined || f['ai'] === null ? undefined : String(f['ai']); + const ledger = String(f['ledger'] ?? '').trim(); + out.push({ + key, + label: String(f['label'] ?? key), + ...(ai === undefined ? {} : { ai }), + ...(ledger === '' ? {} : { ledger }), + }); + } + return out; +} + +/** + * 决策卡的选项归一 —— 只收有 `value` 的;`label` 缺省用 value 顶上。 + * `value` 是回传时认的那个值:没有它,人点了也对不上是哪条路。 + */ +function normalizeOptions(raw: unknown): ReadonlyArray<{ + value: string; + label: string; + detail?: string; + consequence?: string; + recommended?: boolean; +}> { + if (!Array.isArray(raw)) return []; + return raw + .filter((o): o is Record => typeof o === 'object' && o !== null && 'value' in o) + .map((o) => ({ + value: String(o['value']), + label: String(o['label'] ?? o['value']), + ...(o['detail'] === undefined ? {} : { detail: String(o['detail']) }), + ...(o['consequence'] === undefined ? {} : { consequence: String(o['consequence']) }), + ...(o['recommended'] === undefined ? {} : { recommended: o['recommended'] === true }), + })); +} + +/** + * 补全卡的格子归一 —— 只收有 key 的;`label` 缺省用 key 顶上(宁可显示字段名,不显示空白)。 + * 一个都收不到就是空数组,引擎那边会拒这一拍:「人不知道该填什么」比发一张空表单好。 + */ +function normalizeFields(raw: unknown): Array<{ key: string; label: string; hint?: string; value?: string; required?: boolean }> { + if (!Array.isArray(raw)) return []; + const out: Array<{ key: string; label: string; hint?: string; value?: string; required?: boolean }> = []; + for (const item of raw) { + if (typeof item === 'string' && item !== '') { + out.push({ key: item, label: item }); + continue; + } + if (typeof item !== 'object' || item === null) continue; + const f = item as Record; + const key = String(f['key'] ?? '').trim(); + if (key === '') continue; + const hint = String(f['hint'] ?? f['placeholder'] ?? '').trim(); + /* + * **预填值要跟着过来** —— 这一行是它一路上最后一个、也是最隐蔽的丢失点。 + * + * 这个函数**逐字段重建**对象(不是原样透传),所以它没挑的键就等于不存在。 + * 实测查了一路:模型出参 schema 有 value、系统提示词的形状有 value、 + * 活动剧本给了带 value 的骨架、卡片契约有 value、engines 也搬了 value —— + * 五处都对,而人拿到的格子还是空的,因为这里把它剥掉了。 + * 逐字段重建的函数每加一个键都得在这儿补一笔,这是它的代价。 + */ + const value = String(f['value'] ?? '').trim(); + out.push({ + key, + label: String(f['label'] ?? key), + ...(hint === '' ? {} : { hint }), + ...(value === '' ? {} : { value }), + // 数据录入卡用它:必填空着不让确认(别的卡型忽略这个键) + ...(f['required'] === true ? { required: true } : {}), + }); + } + return out; +} + +/** + * 台账地址归一 —— 模型会用好几种写法说同一件事。 + * + * 实测:字典里写的是 `quote_intake.record`(一个 token),而它会把整串塞进 `object`, + * 或者干脆拆成空的 domain + object。宽进一点不是纵容:地址对不对,装配层那道门会验; + * 这里只负责把「同一个意思的几种写法」收成一种,省掉一整拍无谓的驳回。 + */ +function addressOf(raw: Record): string { + const address = String(raw['address'] ?? '').trim(); + if (address !== '') return address; + const domain = String(raw['domain'] ?? '').trim(); + const object = String(raw['object'] ?? '').trim(); + // 整串塞进了其中一格 + if (domain.includes('.')) return domain; + if (object.includes('.')) return object; + return domain === '' || object === '' ? `${domain}${object}` : `${domain}.${object}`; +} + +/** + * 写行归一 —— 只收 `{key, patch}` 都是对象的行。 + * 形状不对的行**丢掉而不是猜**:猜错会往台账里落一行谁也说不清来路的数据; + * 全丢完 rows 就是空,引擎那边会拒这一拍并把理由喂回来,比落错数干净。 + */ +function normalizeRows(raw: unknown): Array<{ key: Record; patch: Record }> { + if (!Array.isArray(raw)) return []; + const rows: Array<{ key: Record; patch: Record }> = []; + for (const item of raw) { + if (typeof item !== 'object' || item === null) continue; + const row = item as Record; + const key = row['key']; + const patch = row['patch']; + if (typeof key !== 'object' || key === null || Array.isArray(key)) continue; + if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) continue; + const typedKey: Record = {}; + for (const [k, v] of Object.entries(key as Record)) { + typedKey[k] = typeof v === 'number' ? v : String(v); + } + rows.push({ key: typedKey, patch: patch as Record }); + } + return rows; +} + +/** + * 模型出参归一为 AgentAction —— 导出是为了让「宽进到什么程度」有测试守着。 + * 宽进的每一条都对应一次实测踩坑;不合规的行动仍由引擎拒,这里不放行为。 + */ +export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?: string): AgentAction { const raw = (object ?? {}) as Record; - const kind = String(raw['kind'] ?? ''); + /* + * `kind` 也认 `action` —— 实测里模型把这一格叫 `action`(其余字段一字不差全对), + * 整个行动因此判成不合规,白发一张「请判断下一步」的卡给人。 + * 同义词宽进不是纵容:行动型本身仍由 enum 框死,合不合规照验; + * 这里只是不让一个词的叫法差异吃掉一整拍。 + */ + const kind = String(raw['kind'] ?? raw['action'] ?? ''); const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? ''); switch (kind) { @@ -180,6 +859,121 @@ function normalize(object: unknown, enabled: EnabledSet): AgentAction { tool: String(raw['tool'] ?? ''), input: (raw['input'] ?? {}) as Record, }; + case 'write': + return { kind: 'write', itemId, address: addressOf(raw), rows: normalizeRows(raw['rows']) }; + + /* + * 撤草稿 —— 主键那一份照收,别替它归一:`DAG…` 与 `DAG…-R0` 就是两行, + * 替它「猜哪个才是」等于替它决定撤掉哪一行数据。撤不到基座会如实回绝。 + */ + case 'drop': + return { + kind: 'drop', + itemId, + address: addressOf(raw), + keys: Array.isArray(raw['keys']) + ? raw['keys'].filter((k): k is Record => typeof k === 'object' && k !== null) + : [], + reason: String(raw['reason'] ?? raw['rationale'] ?? '写错了,撤掉'), + }; + case 'ask': { + /* + * 格子与问题**两处都认**:`fields` / `ask` 在顶层,或落在 `card` 里。 + * 提示词里执行工单的写法是 `card.fields`,模型很自然把补全卡也那么写 —— + * 实测它就是这么写的,于是每一拍都被「fields 为空」拒回来。 + * 宽进不是纵容:空不空引擎照验,这里只是不让一个位置差异吃掉一整拍。 + */ + const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record; + return { + kind: 'ask', + itemId, + ask: String(raw['ask'] ?? nested['ask'] ?? ''), + fields: normalizeFields(raw['fields'] ?? nested['fields']), + ...(Array.isArray(raw['basis']) + ? { + basis: raw['basis'] + .filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b) + .map((b) => ({ k: String(b.k), v: String(b.v) })), + } + : {}), + }; + } + case 'entry': { + /* + * 数据录入卡 —— 与补全卡同款的宽进(顶层或 `card` 里都认)。 + * `basis` 缺了照样用当下局面补:裸卡基座拒发,而少写一个字段不该吃掉整拍。 + */ + const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record; + const given = Array.isArray(raw['basis']) + ? raw['basis'] + .filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b) + .map((b) => ({ k: String(b.k), v: String(b.v) })) + : []; + const keyRaw = raw['entryKey'] ?? raw['key'] ?? nested['entryKey'] ?? nested['key']; + const key: Record = {}; + if (typeof keyRaw === 'object' && keyRaw !== null) { + for (const [k, v] of Object.entries(keyRaw as Record)) key[k] = String(v); + } + /* + * **同一次确认还要落的另几张表**(2026-08-02):建一位新客户 = 公司档 + 联系人档。 + * 形状与主段一样(title / ledger / key / fields),缺哪一样就整段丢掉 —— + * 半张段落进卡面只会让人对着一组填不下去的格子。 + */ + const alsoRaw = raw['also'] ?? nested['also']; + const also = (Array.isArray(alsoRaw) ? alsoRaw : []) + .filter((g): g is Record => typeof g === 'object' && g !== null) + .map((g) => { + const k: Record = {}; + const kr = g['key'] ?? g['entryKey']; + if (typeof kr === 'object' && kr !== null) { + for (const [a, b] of Object.entries(kr as Record)) k[a] = String(b); + } + return { + title: String(g['title'] ?? ''), + ledger: String(g['ledger'] ?? ''), + key: k, + fields: normalizeFields(g['fields']), + }; + }) + .filter((g) => g.ledger !== '' && g.title !== '' && g.fields.length > 0); + return { + kind: 'entry', + itemId, + ask: String(raw['ask'] ?? nested['ask'] ?? ''), + ledger: String(raw['ledger'] ?? nested['ledger'] ?? ''), + key, + fields: normalizeFields(raw['fields'] ?? nested['fields']), + ...(also.length === 0 ? {} : { also }), + basis: given.length > 0 ? given : basisFromScene(enabled, itemId), + }; + } + case 'decide': { + // 与补全卡同款的宽进:选项落在顶层或 `card` 里都认(位置差异不该吃掉一整拍) + const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record; + const given = Array.isArray(raw['basis']) + ? raw['basis'] + .filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b) + .map((b) => ({ k: String(b.k), v: String(b.v) })) + : []; + /* + * **依据缺了就用当下局面补**,不要空手发出去。 + * + * 「调用必附上下文,裸调用不得发出」是基座的硬规矩(AM 06):`basis` 为空即拒发。 + * 而模型很容易只写 ask + options 就交卷 —— 实测:连拒 6 拍直接熔断, + * 一单就此停在那儿等人,而它想问的那句话本身完全合理。 + * 拒得对,但让一个「少写一个字段」的失误吃掉整单是不划算的: + * 执行工单那一路早就这么办了(`fillCard`),决策卡照同一个规矩来。 + */ + const basis = given.length > 0 ? given : basisFromScene(enabled, itemId); + return { + kind: 'decide', + itemId, + ask: String(raw['ask'] ?? nested['ask'] ?? ''), + options: normalizeOptions(raw['options'] ?? nested['options']), + ...(raw['allowCustom'] === undefined ? {} : { allowCustom: raw['allowCustom'] === true }), + basis, + }; + } case 'human': return { kind: 'human', @@ -197,14 +991,37 @@ function normalize(object: unknown, enabled: EnabledSet): AgentAction { // 时限必配 —— 模型没给就按三天兜底,绝不留幽灵等待 deadline: typeof raw['deadline'] === 'string' && /^P/.test(raw['deadline']) ? raw['deadline'] : 'P3D', }; - case 'submit': - return { kind: 'submit', rationale: String(raw['rationale'] ?? '判据已收敛') }; + case 'finish': + return { kind: 'finish', itemId, rationale: String(raw['rationale'] ?? '这一项的成果已交出') }; + case 'submit': { + // 验收报告的可修订字段:顶层 fields 或 card.fields 都认(见补全卡那一处的同款注释) + const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record; + const revisable = normalizeRevisable(raw['fields'] ?? nested['fields']); + return { + kind: 'submit', + rationale: String(raw['rationale'] ?? '判据已收敛'), + ...(revisable.length === 0 ? {} : { fields: revisable }), + }; + } default: - // 看不懂就等人,不擅自触达世界 + /* + * 看不懂就等人,不擅自触达世界。 + * + * 但**要把模型的原话摆在卡上**:只写「模型未给出合规的行动」,收卡的人无从判断 + * 是模型跑偏了、还是行动型少了一种、还是提示词没交代清楚 —— 而这三种的处置完全不同。 + * 界面不撒谎的另一半是:不确定的时候,把手上的原始证据交出去。 + */ return { kind: 'human', itemId, - card: { title: '需要你判断下一步', reason: '模型未给出合规的行动' }, + card: { + title: '需要你判断下一步', + reason: '模型未给出合规的行动', + modelReply: rawText === undefined || rawText === '' ? '(空)' : rawText.slice(0, 1200), + kindSeen: kind === '' ? '(未给 kind)' : kind, + ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。', + fields: ['decision'], + }, workOrder: 'execution', }; } diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index 76d13d2..a10c451 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -9,10 +9,12 @@ import { Pool } from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; import { evaluateCondition } from '@sfeel'; import type { Db } from '@shared/db.ts'; +import type { TaskId } from '@contracts'; import type { EventService, TriggerMatcher } from '@modules/event'; import { createEventService, createEventStore } from '@modules/event'; import type { LedgerService } from '@modules/ledger'; +import type { LoopEngineService } from '@modules/loop-engine'; import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger'; import type { OrgService, InFlightWorkProbe } from '@modules/org'; import { @@ -39,6 +41,7 @@ import { createActivityMemoryStore, createMemoryService, createTaskMemoryStore } import type { ToolboxService, ToolHandler, ToolInvoker } from '@modules/toolbox'; import { createSchemaValidator, + createToolHandoffStore, createToolInvoker, createToolRegistry, createToolReplayStore, @@ -103,6 +106,12 @@ export type Container = { readonly registry: ActivityRegistryService; readonly release: ReleaseService; readonly task: TaskService; + /** 案例引擎 —— 工作台按它算阶段进度;推进照旧走 task,不从这里走 */ + readonly loop: LoopEngineService; + /** 视图闸 —— 人在件上改完数据,按同一张模版用新数据重渲一帧(`views.repush`) */ + readonly views: { repush(taskId: TaskId): Promise }; + /** 此刻这一单的判据 —— 按当下数据现算(发卡那一刻算死的话,人补一格判据不会动) */ + readonly checks: { of(taskId: TaskId): Promise> }; readonly process: ProcessService; readonly trigger: TriggerService; readonly minute: MinuteService; @@ -161,6 +170,14 @@ const sfeelEvaluator = (expression: string, scope: Readonly(FIRST_PARTY_TOOLS); export async function createContainer(config: AppConfig): Promise { + /* + * 广播器**先建** —— 装配期就要往它上面接线。 + * + * 它从前是在 `return` 里现造的,于是装配期谁也拿不到它: + * 对话流推送、视图推送、发消息这三条**全都没有广播**, + * 前端只好靠人点刷新(用户实测报的:任务列表 / 消息气泡 / 工作区 / 对话区都不自己动)。 + */ + const broadcaster = createBroadcaster(); const pool = new Pool({ connectionString: config.databaseUrl, max: 10 }); /* * 空闲连接被服务端掐断(重启、`pg_terminate_backend`、网络抖动)会以 'error' 事件抛到池上; @@ -233,6 +250,25 @@ export async function createContainer(config: AppConfig): Promise { events: { publish: async (envelope) => void (await event.publish(envelope)) }, clock: systemClock, ids, + /* + * 试跑判定 —— 设计台有**两条**试跑路径,认漏一条就等于没挡: + * 单活动试跑:起的是不挂工作流的单,标 `task.origin = 'testbed'` + * 流程包试跑:节点任务挂在工作流上,标在**流**身上 `workflow.source->>'ref'` + * 一条 SQL 两条都认。认不出就当生产单(宁可少挡也不误挡:误挡会让真单丢主数据)。 + */ + simulation: { + isSimulated: async (taskId) => { + const res = await pool.query<{ simulated: boolean }>( + `select exists ( + select 1 from sys.task t + left join sys.workflow w on w.id = t.workflow_id + where t.id = $1 and (t.origin = 'testbed' or w.source->>'ref' = 'testbed') + ) as simulated`, + [taskId], + ); + return res.rows[0]?.simulated === true; + }, + }, }); const instances = createLlmInstanceRepository(db); @@ -278,6 +314,8 @@ export async function createContainer(config: AppConfig): Promise { secrets, clock: systemClock, }); + /** 工具转人台(§6.2):系统里没有这件工具时,请人代办 —— 人答回的值就是这次调用的出参 */ + const toolHandoff = createToolHandoffStore(db); const toolInvoker = createToolInvoker({ registry: toolRegistry, runtime: toolRuntime, @@ -334,9 +372,41 @@ export async function createContainer(config: AppConfig): Promise { ids, }); + /* + * **对话流与视图推送接进广播** —— `interaction.subscribe` 从前一个订阅者都没有: + * AI 说的每一句、推的每一帧画面都广播给了空气,前端只好靠人点刷新。 + * + * 这里只做转接,不带业务载荷:推送说「这个任务动了」,画面照旧由服务端投影重建 + * (§9.2 前端零过程状态)。收件人是谁不在信封里,所以按任务广播 —— + * 前端按任务键失效,负责人那一屏自然重取。 + */ + interaction.onPush((envelope) => { + // invalidate 信封自己说目标(task / workflow / desk);其余信封都以任务为轴 + if (envelope.kind === 'invalidate') { + broadcaster.publish({ + kind: envelope.target === 'workflow' ? 'workflow' : 'task', + id: envelope.id, + reason: 'invalidate', + }); + return; + } + broadcaster.publish({ kind: 'task', id: envelope.taskId, reason: envelope.kind }); + }); + const humanTool = createHumanToolService({ cards: createCardStore(db), - outbox: { sendElicitation: (input) => message.sendElicitation(input) }, + outbox: { + /* + * 跨人卡以系统消息送达对方 —— 送到了就**广播给那个人**: + * 他的消息气泡该立刻多一条,而不是等他自己点刷新。 + * 发消息不经事件投递(那条路才有广播),所以这里得自己说一声。 + */ + sendElicitation: async (input) => { + const sent = await message.sendElicitation(input); + broadcaster.publish({ kind: 'staff', id: input.staffId, reason: 'elicitation' }); + return sent; + }, + }, memory: { append: async (input) => void (await memory.append({ @@ -346,6 +416,17 @@ export async function createContainer(config: AppConfig): Promise { }, events: { publishHumanEvent: async (input) => { + /* + * 转人代办的回传**就是工具出参**:先落转人台,再抛事件。 + * + * 顺序不能反 —— 事件会唤起下一拍,那一拍 invoke 必须立刻取到答案; + * 先抛后写就会空跑一拍(拍里还是「等人」,而人其实已经答了)。 + * 卡不是转人卡时 `answer` 查无此行,静默无事发生。 + */ + const values = input.payload['values']; + if (typeof values === 'object' && values !== null && !Array.isArray(values)) { + await toolHandoff.answer(input.cardId, values as Record); + } await event.publish({ type: 'human', correlation: { @@ -461,7 +542,19 @@ export async function createContainer(config: AppConfig): Promise { deliverable: input.deliverable as never, criteria: [...input.criteria], actions: input.actions.map((a) => ({ ...a })), - fields: [], + /* + * 可修订字段照传 —— 从前这里写死 `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 }), }, ); return { id: issued.order.id }; @@ -537,6 +630,7 @@ export async function createContainer(config: AppConfig): Promise { memory, toolbox, toolInvoker, + toolHandoff, humanTool, interaction, message, @@ -581,7 +675,7 @@ export async function createContainer(config: AppConfig): Promise { return { config, - broadcaster: createBroadcaster(), + broadcaster, pool, db, org, @@ -599,6 +693,10 @@ export async function createContainer(config: AppConfig): Promise { registry, release, task: engines.task, + loop: engines.loop, + /** 件是投影 —— 人在件上改完数据,得能按同一张模版用新数据重渲一帧(见 `views.repush`) */ + views: engines.views, + checks: engines.checks, process: engines.process, trigger: engines.trigger, minute: deskElements.minute, diff --git a/apps/api/src/app/install-activities.ts b/apps/api/src/app/install-activities.ts new file mode 100644 index 0000000..5c735fe --- /dev/null +++ b/apps/api/src/app/install-activities.ts @@ -0,0 +1,20 @@ +/** + * 装活动包 —— 独立 CLI(`pnpm --filter @app/api install-activities`)。 + * + * 为什么不复用 `db:seed`:播种把**全套配置**都重写一遍(模型实例、工具、流程包、触发器)。 + * 开发活动时一天要装十几次,每次都顺手改一遍治理台配置太重 —— 更要紧的是, + * 播种会按环境变量重绑模型实例,而开发机上那把密钥常常只存在治理台里、不在环境里。 + * 装活动就只装活动:读 `activities//dist/{manifest.json,index.js}` 过安装两审。 + * + * 「安装恒新增」照旧:改了包内容必须升 `identity.packageVersion`,同版重装是 no-op。 + */ +import { loadConfig } from './config.ts'; +import { createContainer } from './container.ts'; +import { installRealActivities } from './seed-activities.ts'; + +const container = await createContainer(loadConfig(process.env)); +try { + await installRealActivities(container); +} finally { + await container.close(); +} diff --git a/apps/api/src/app/main.ts b/apps/api/src/app/main.ts index 84332ac..75b5997 100644 --- a/apps/api/src/app/main.ts +++ b/apps/api/src/app/main.ts @@ -7,8 +7,9 @@ import { loadConfig } from './config.ts'; import { createContainer } from './container.ts'; import { createServer } from './server.ts'; import { createConsumers } from './workers/consumers.ts'; -import { startDispatcher } from './workers/dispatcher.ts'; -import { startTaskAdvancer } from './workers/task-advancer.ts'; +import { dispatchOnce, startDispatcher } from './workers/dispatcher.ts'; +import { advanceOnce, startTaskAdvancer } from './workers/task-advancer.ts'; +import { registerPulse } from './workers/pulse.ts'; export async function boot(): Promise { const config = loadConfig(); @@ -16,11 +17,20 @@ export async function boot(): Promise { await container.org.ensureSeeded(); // 派发器:投递记录即队列,按目标 ID 取 advisory lock 串行消费(ADR-008) - const dispatcher = startDispatcher({ container, consumers: createConsumers() }, config.dispatchIntervalMs); + const dispatcherDeps = { container, consumers: createConsumers() }; + const dispatcher = startDispatcher(dispatcherDeps, config.dispatchIntervalMs); // 过程在后端:运行中的任务由推进器一拍一拍推,不依赖浏览器在线 const advancer = startTaskAdvancer({ container }, config.dispatchIntervalMs); + /* + * 人一答完就踢一脚 —— **顺序有意义**:先把事件投递下去(任务才醒),再推它一拍。 + * 反过来登记的话,推进那一轮跑在投递之前,任务还没醒,白跑一轮, + * 人还是要等下一个 2 秒。 + */ + registerPulse(() => dispatchOnce(dispatcherDeps)); + registerPulse(() => advanceOnce({ container })); + const app = createServer(container); serve({ fetch: app.fetch, port: config.port }, (info) => { process.stdout.write(`JCAI api · http://localhost:${info.port} · env=${config.nodeEnv}\n`); diff --git a/apps/api/src/app/mastra-agent-runner.ts b/apps/api/src/app/mastra-agent-runner.ts index 2200817..13d6060 100644 --- a/apps/api/src/app/mastra-agent-runner.ts +++ b/apps/api/src/app/mastra-agent-runner.ts @@ -21,9 +21,11 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { Agent } from '@mastra/core/agent'; import { z } from 'zod'; import type { TaskId } from '@contracts'; -import type { AgentAction, EnabledSet } from '@modules/loop-engine'; +import type { AgentAction, AgentTurn, EnabledSet } from '@modules/loop-engine'; import type { ModelGateway } from '@modules/llm'; import type { BusTool } from '@modules/toolbox'; +import { ledgerDictionary } from './agent-runner.ts'; +import type { WritableLedger } from './agent-runner.ts'; import { buildMastraTools } from './tool-bus.ts'; import type { ToolBusContext, ToolBusPorts } from './tool-bus.ts'; @@ -35,6 +37,10 @@ const TURN_RESULT = z.object({ criterion: z.string().optional().describe('judge:评估的是哪条语义性前提'), satisfied: z.boolean().optional().describe('judge:满足与否'), rationale: z.string().optional().describe('结论理由 —— 留痕要看,写实'), + /** 对负责人说的一句人话 —— 每一拍都要带(他正看着这一单) */ + say: z.string().optional().describe('对负责人说的一句人话:这一拍干了什么、拿到什么结论'), + tag: z.enum(['guidance', 'criterion', 'conclusion']).optional(), + view: z.string().optional().describe('可选:换成哪张局面模版'), }); export type MastraAgentRunnerDeps = { @@ -54,10 +60,18 @@ export type MastraAgentRunnerDeps = { }; export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): { - decide(input: { taskId: TaskId; narrative: string; enabled: EnabledSet; scope: Record }): Promise; + decide(input: { + taskId: TaskId; + narrative: string; + enabled: EnabledSet; + scope: Record; + writable: WritableLedger; + /** 开环路径的工具清单来自 `busOf`(它自己会取),这里只为满足同一个端口 */ + tools?: unknown; + }): Promise; } { return { - async decide({ taskId, narrative, enabled, scope }) { + async decide({ taskId, narrative, enabled, scope, writable }) { const modelName = await deps.modelOf(taskId); const base = await deps.contextOf(taskId); const bus = await deps.busOf(taskId); @@ -75,8 +89,16 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): { '③ **终局唯放行**:成果齐了就 submit(提交放行),由人验收 —— 你不能自己宣布完成。', '④ 干完活必须给出这一拍的结论(见输出格式);拿不准就 idle 并说清卡在哪。', '', + '⑥ **每一拍都要带一句 say**:对负责人说人话(他正看着这一单;只干活不说话,他的对话区是空的)。', + '⑤ **成果就是台账里的行**:干出来的结论用 ledger.write 逐行写进台账(草稿层),', + ' 不写就等于没做 —— 验收判据读的是台账,出口载荷也从台账推。只准写字典里的地址与列名。', + '', `【案例说明书与记忆】\n${narrative}`, - ].join('\n'); + '', + ledgerDictionary(writable), + ] + .filter((line) => line !== '') + .join('\n'); const endpoint = await deps.gateway.resolve(modelName); const provider = createOpenAICompatible({ @@ -99,11 +121,16 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): { .map((e) => `- ${e.id} ${e.name}(${e.type}${e.requiresManualStart ? ' · 须你启动' : ''}${e.discretionary ? ' · 裁量项' : ''})`) .join('\n') || '(空)' }`, + enabled.inProgress.length === 0 + ? '' + : `【进行中(已开工,未交成果)】\n${enabled.inProgress.map((e) => `- ${e.id} ${e.name}`).join('\n')}`, `【目标缺口】\n${enabled.gaps.join('、') || '(无)'}`, enabled.judgements.length === 0 ? '' : `【待你评估的语义性前提】\n${enabled.judgements.map((j) => `- ${j.criterion}:${j.statement}`).join('\n')}`, - enabled.entries.length === 0 ? '【注意】当下可为 ∩ 目标所需 = 空 —— 这是依赖断点,调人或说清卡在哪。' : '', + enabled.entries.length === 0 && enabled.inProgress.length === 0 + ? '【注意】当下可为与进行中都空 —— 这是依赖断点,调人或说清卡在哪。' + : '', `【任务数据】\n${JSON.stringify(scope).slice(0, 4000)}`, '', '干这一拍该干的活(可以连续调工具),最后给出结论。', @@ -122,11 +149,16 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): { if (tokens > 0) await deps.onUsage(taskId, tokens); // 调过人就必须收在 asked:卡已发、监听已立,引擎只收尾挂起(再发一张就是重复调人) + const object = (result as { object?: unknown }).object; + const said = sayOf(object); if (trace.askedCardId !== undefined) { - return { kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人,等回传' }; + return { + actions: [{ kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人,等回传' }], + ...(said === undefined ? {} : { say: said }), + }; } - - return normalizeTurn((result as { object?: unknown }).object, enabled); + // 开环一拍 = Mastra 在拍内自主连环调工具,拍末只交一个收束行动 —— 包成单元素串 + return { actions: [normalizeTurn(object, enabled)], ...(said === undefined ? {} : { say: said }) }; } catch (error) { /* * 这一拍问不出结果(端点不可达、鉴权失败、超时、工具连环炸)—— @@ -137,10 +169,12 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): { * 不要再叠一张「模型出错」的卡上去。 */ if (trace.askedCardId !== undefined) { - return { kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人(其后模型出错)' }; + return { actions: [{ kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人(其后模型出错)' }] }; } const detail = error instanceof Error ? error.message : String(error); return { + say: { text: `这一轮我没能决断:${detail}`, tag: 'conclusion' as const }, + actions: [{ kind: 'human', itemId: enabled.entries[0]?.id ?? 'breakpoint', workOrder: 'execution', @@ -154,12 +188,27 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): { ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。', fields: ['decision'], }, + }], }; } }, }; } +/** 拍末那句交代 —— 每一拍都该有;没给就没有,不替它编 */ +function sayOf(object: unknown): { text: string; tag?: 'guidance' | 'criterion' | 'conclusion'; view?: string } | undefined { + const raw = (object ?? {}) as Record; + const text = String(raw['say'] ?? '').trim(); + if (text === '') return undefined; + const tag = raw['tag']; + const view = String(raw['view'] ?? '').trim(); + return { + text, + ...(tag === 'guidance' || tag === 'criterion' || tag === 'conclusion' ? { tag } : {}), + ...(view === '' ? {} : { view }), + }; +} + /** 拍末结论归一为 AgentAction;给不出结论就当空拍(idle → judge 留痕),绝不乱动世界 */ function normalizeTurn(object: unknown, enabled: EnabledSet): AgentAction { const raw = (object ?? {}) as Record; diff --git a/apps/api/src/app/reset-runs.ts b/apps/api/src/app/reset-runs.ts index bd23a60..b9647bd 100644 --- a/apps/api/src/app/reset-runs.ts +++ b/apps/api/src/app/reset-runs.ts @@ -23,6 +23,13 @@ const RUN_TABLES = [ 'sys.chat_card', 'sys.chat_message', 'sys.system_message', + /* + * 沟通记录与上传件是**跑出来的**,而且两张表都没有指向 task 的外键 —— + * `truncate ... cascade` 带不走它们(实测漏了 53 条沟通记录、1 个上传件)。 + * 漏下的是指向已删任务的孤儿:那封信还在沟通记录里躺着,点开却没有单。 + */ + 'sys.task_comm', + 'sys.upload', 'sys.node_instance', // 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿 'sys.trace_span', @@ -48,19 +55,40 @@ const RUN_TABLES = [ /* 纪要是人记的,但开发期同属「跑出来的东西」—— 清库要清得干净,不留半场旧会 */ 'sys.minute', 'sys.minute_resolution', + /* 试跑日志是试跑的存档:单没了,存档留着只会是一堆点不进去的空目录 */ + 'sys.testbed_run', ]; export async function resetRuns(): Promise { const pool = new Pool({ connectionString: loadConfig().databaseUrl, max: 2 }); try { - // 台账的业务表是动态建的,名字问库不写死 + /* + * 台账的业务表是动态建的,名字问库不写死。 + * + * **主数据留着**(2026-07-31):客户 / 项目 / 供应商 / 市场是先于活动存在的档案, + * 活动装了卸了它都在 —— 它属于「配置好的」那一半,不是「跑出来的」。 + * 从前这里把整个 ledger schema 一并 truncate,后果是每清一次运行数据, + * 样例客户就没了,「老客户来函」那条样例第二天就试不出来(而它本该是常在的夹具)。 + * `__object_meta` 同理:性质登记随声明走,不随跑单走;抹了它, + * 治理台上全部表都成了「无人声明」,主数据那一区直接空掉。 + * 单据(record)、派生(derived)、没登记性质的,照旧清 —— 那些确是跑出来的。 + */ const ledger = await pool.query<{ name: string }>( - `select quote_ident(schemaname) || '.' || quote_ident(tablename) as name - from pg_tables where schemaname = 'ledger'`, + `select quote_ident(t.schemaname) || '.' || quote_ident(t.tablename) as name + from pg_tables t + left join ledger.__object_meta m on m.domain || '__' || m.object = t.tablename + where t.schemaname = 'ledger' + and t.tablename <> '__object_meta' + and (m.kind is null or m.kind <> 'master')`, ); const all = [...RUN_TABLES, ...ledger.rows.map((r) => r.name)]; await pool.query(`truncate table ${all.join(', ')} restart identity cascade`); - process.stdout.write(`已清运行数据:${all.length} 张表(配置与组织数据原样保留)\n`); + const kept = await pool.query<{ n: string }>( + `select count(*)::text as n from ledger.__object_meta where kind = 'master'`, + ); + process.stdout.write( + `已清运行数据:${all.length} 张表(配置与组织数据原样保留;主数据 ${kept.rows[0]?.n ?? '0'} 张表未动)\n`, + ); } finally { await pool.end(); } diff --git a/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts index 4fccdaf..25ed341 100644 --- a/apps/api/src/app/routes/desk.ts +++ b/apps/api/src/app/routes/desk.ts @@ -3,18 +3,20 @@ * * 「web 是纯呈现与输入面」:这里出的每一样都是服务端投影,前端零过程状态、刷新即重建(§9.2)。 */ +import { sql } from 'drizzle-orm'; import type { Hono } from 'hono'; -import type { ApiError, ChainNode, ChatMessage, Deliverable, FileDoc, TaskCard, ViewBlock } from '@contracts'; +import type { ApiError, ChainNode, ChatMessage, Deliverable, FileDoc, TaskCard, TaskId, ViewBlock, WorkOrder } from '@contracts'; import { - ATTRIBUTION_SIDE_LABEL, answerMessageRoute, chatCardReplySchema, confirmTriggerPendingRoute, listMessagesRoute, markMessageReadRoute, listTasksRoute, + markCommReadRoute, replyChatCardRoute, replyWorkOrderRoute, + saveTableRoute, sayRoute, taskDetailRoute, staffIdSchema, @@ -23,8 +25,525 @@ import { } from '@contracts'; import { ReplyRejectedError } from '@modules/human-tool'; import { advanceOrder } from '@modules/process'; +import { displayLog } from './log-lines.ts'; +import { pulse } from '../workers/pulse.ts'; import type { Container } from '../container.ts'; + +/** drizzle 的 execute 出参在不同驱动下形状不一 —— 收在一处判 */ +function rowsOf(result: unknown): T[] { + if (Array.isArray(result)) return result as T[]; + const rows = (result as { rows?: unknown }).rows; + return Array.isArray(rows) ? (rows as T[]) : []; +} import type { AppEnv } from '../http-env.ts'; +import { coerceToColumn, missingReadings } from '../wiring/engines.ts'; + + +/** 一张表收上来的样子:`{ 台账列名: 值 }`,一行一条 —— 主键列在里面,但只用来认行 */ +type EditedRow = Readonly>; + +/** 写回的收成:改了几行、新增几行、撤了几行、哪几格归不了型 */ +type WriteBack = { rows: number; added: number; dropped: string[]; bad: string[] }; + +const nothing = (): WriteBack => ({ rows: 0, added: 0, dropped: [], bad: [] }); +const merge = (into: WriteBack, one: WriteBack): void => { + into.rows += one.rows; + into.added += one.added; + into.dropped.push(...one.dropped); + into.bad.push(...one.bad); +}; + +/** + * **把人改过的那几行落进本单草稿层** —— 两条路(件上按保存 / 卡上按提交)共用这一段。 + * + * 主键列取台账声明的主键 —— 人改主键那一格等于换了一行,所以**主键不参与写回**: + * 用它认行,不用它改行(改错一个字符就会凭空多出一行,踩过)。 + * 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。 + */ +async function writeEditedRows( + container: Container, + taskId: TaskId, + declared: { domain: string; object: string; fields: ReadonlyArray<{ name: string; type: string; primaryKey: boolean }> }, + lines: readonly EditedRow[], +): Promise { + const { domain, object } = declared; + const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name); + const out = nothing(); + // 这一单现有的行 —— 三处要用:补主键(件上没摆全的那几列)、认出哪几行真改了、认出哪几行是新增 + const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object); + /* + * **件上压根没摆的那几列主键**(A1 的 `rfq_ref` 就是:一单一个单号,不占版面)—— + * 本单现有行里那一列若全表一致,就是这一单的那个值,新增行照它填。 + * 不一致(一单跨了两个单号)就取不出来:那时新增行认不出自己是谁,如实跳过,不猜。 + */ + const constant = (column: string): string | undefined => { + const seen = new Set(drafts.map((d) => String(d.key[column] ?? ''))); + const only = [...seen][0]; + return seen.size === 1 && only !== undefined && only !== '' ? only : undefined; + }; + + for (const cells of lines) { + /* + * 主键**只用来认行,不参与改行**:人改了主键那一格等于换了一行, + * 落下去就是凭空多一条(实测过同一颗料在表里成了两行)。 + * 表里没摆全主键的(如单号那一列不在件上),从草稿现有的行里补 —— 认不出就跳过。 + */ + const key: Record = {}; + let complete = true; + for (const k of keys) { + const v = cells[k]; + if (v === undefined || v.trim() === '') complete = false; + else key[k] = v; + } + /* + * **人手改的值与 AI 写的值过同一道归型门**(`coerceToColumn`)。 + * + * 不过这道门的代价实测过:夹具往「目标价」那一格填了「客户已确认」, + * 基座原样落进草稿,到 `releaseWrite` 才被 PG 掀桌 + * (`invalid input syntax for type double precision`)—— 而那是**人签完字之后**, + * 整单落 `blocked`。最坏的失败点就是这里:前面全对,最后一步炸。 + * 归得了就归、空值不写、归不了**跳过那一格**(别的格照落): + * 人填错一格不该把他改对的另外八格一起丢掉。 + */ + const patch: Record = {}; + for (const [name, value] of Object.entries(cells)) { + if (keys.includes(name)) continue; + const type = declared.fields.find((f) => f.name === name)?.type ?? 'text'; + const coerced = coerceToColumn(value, type); + if (coerced.kind === 'value') patch[name] = coerced.value; + else if (coerced.kind === 'reject') out.bad.push(`${name}=${value}`); + } + if (!complete) { + /* + * 主键没摆全 —— 两种情形,先按行认,认不出再按列补: + * · 老行:件上给了 PN,单号那一列没摆 → 拿 PN 去本单现有行里找,取它的单号; + * · **新增行**:PN 是新的,现有行里一个都对不上 → 那几列取全表一致的那个值。 + * 少了后一半,人在件上加的行会**静默丢掉**:主键凑不齐,这里一 continue 就没了, + * 而屏上那一行还在 —— 界面说加上了,台账里没有。 + */ + const known = drafts.find((d) => keys.every((k) => key[k] === undefined || String(d.key[k]) === key[k])); + for (const k of keys) { + if (key[k] !== undefined) continue; + const v = known === undefined ? constant(k) : String(known.key[k] ?? ''); + if (v !== undefined && v !== '') key[k] = v; + } + } + if (Object.keys(key).length !== keys.length) continue; + const was = drafts.find((d) => keys.every((k) => String(d.key[k] ?? '') === key[k])); + if (!rowChanged(was === undefined ? undefined : { ...was.key, ...was.patch }, patch)) continue; + await container.ledger.draftWrite({ taskId, address: { domain, object }, key, patch }); + if (was === undefined) out.added += 1; + else out.rows += 1; + } + return out; +} + +/** + * **把人在件上删掉的那几行撤出本单草稿层**。 + * + * 撤的只是这一单还没过门的那一笔(`draftDrop`):正式版一个字都动不了,也不抛事件 —— + * 与 Agent 的 `drop` 同一条路子。人手删行是**兜 AI 的错**的一手: + * 客户点名本轮跳过的料被写进了有效清单,只给他改格子是改不掉的 + * (改数又与另一个数打架 —— 实测那一单 13 次拒回、234k token 才熔断)。 + * + * 主键摆不全的一律不撤:**宁可少撤一行,不要撤错一行**。 + */ +async function dropEditedRows( + container: Container, + taskId: TaskId, + declared: { domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> }, + lines: readonly EditedRow[], +): Promise { + const { domain, object } = declared; + const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name); + const out = nothing(); + const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object); + + for (const cells of lines) { + // 按件上给出的那几列主键找本单的行 —— 找不到(或不止一行)就不动 + const hit = drafts.filter((d) => + keys.every((k) => cells[k] === undefined || cells[k]?.trim() === '' || String(d.key[k] ?? '') === cells[k]), + ); + const one = hit.length === 1 ? hit[0] : undefined; + if (one === undefined) continue; + const gone = await container.ledger.draftDrop({ taskId, address: { domain, object }, key: one.key }); + if (gone) out.dropped.push(Object.values(one.key).map(String).join('/')); + } + return out; +} + +/** + * **这一行真改了吗** —— 件上按一次保存,整张表的格子都回传上来(它不知道人动了哪一格)。 + * + * 照单全写会留一条「就地修订成果:4 行」的痕,而人只改了一格 —— 日志是给人看的, + * 那句话就成了假的(用户实测点出来的:改一格,日志说改了四行)。 + * 比的是**摊平后的整行**:主键在里面也无妨(它按定义不参与改行)。 + * 值一律转字符串再比 —— 台账里躺的是数字 30000,人手填回来的是 "30000", + * 归型之后两者相等,可类型不同;拿类型去比会把「没改」判成「改了」。 + */ +export function rowChanged( + existing: Readonly> | undefined, + patch: Readonly>, +): boolean { + if (existing === undefined) return true; // 台账里还没有这一行 —— 那是新增,当然算改 + return Object.entries(patch).some(([k, v]) => String(existing[k] ?? '') !== String(v ?? '')); +} + +/** + * **自增主键由基座生成**(2026-08-02 用户:「联系人ID自增啊,不用填」)。 + * + * 声明里标了 `auto` 的主键列不进卡面(见 `entryGroup`),值在写入这一刻补上: + * **取同组其余主键的值作前缀,后面跟两位序号** —— `CU-9001-01`、`-02`…… + * 序号数的是同组已有的行(正式版 + 本单草稿),同一单里加两位联系人也不撞。 + * 没有其余主键(它自己就是唯一那把键)时退回纯序号。 + * + * 为什么不让人填:他哪知道这家客户已经有几位联系人。让模型编更糟 —— + * 编重了就是覆盖别人那一行,而主数据覆盖错了后面每一单都跟着错。 + */ +async function autoKeys( + container: Container, + taskId: TaskId, + declared: LedgerDeclaration | undefined, + given: Readonly>, +): Promise> { + const auto = (declared?.fields ?? []).filter((f) => f.primaryKey && f.auto === true).map((f) => f.name); + if (declared === undefined || auto.length === 0) return {}; + const others = declared.fields.filter((f) => f.primaryKey && f.auto !== true).map((f) => f.name); + const prefix = others + .map((k) => given[k] ?? '') + .filter((v) => v !== '') + .join('-'); + /* + * 同组已有几行 —— 正式版与本单草稿都要数。只数正式版的话, + * 同一单里连加两位联系人会拿到同一个号,后一位把前一位覆盖掉。 + */ + const page = await container.ledger + .taskView(taskId, { + address: { domain: declared.domain, object: declared.object }, + where: {}, + orderBy: [], + limit: 500, + offset: 0, + }) + .catch(() => ({ rows: [] as Array> })); + const same = page.rows.filter((r) => others.every((k) => String(r[k] ?? '') === (given[k] ?? ''))); + const out: Record = {}; + for (const [i, name] of auto.entries()) { + const seq = String(same.length + 1 + i).padStart(2, '0'); + out[name] = prefix === '' ? seq : `${prefix}-${seq}`; + } + return out; +} + +/** + * **办结之后:这一签下去,世界上发生了什么**(2026-08-02 用户定)。 + * + * 放行卡上摆的是签字前要核的东西(判据);签完之后人关心的换成了另一件事 —— + * 落了几行、进了哪几张表、转去哪儿了。从前两件事共用一屏判据表, + * 签完再回看,那一摞对勾说的还是签之前的话。 + * + * 事实取自放行那一刻的**结构化留痕**(`ledger-write` / `release-verdict` 的 detail), + * 不是把日志那句话再解析一遍 —— 措辞一改解析就失灵,而没人会发现。 + */ +async function landingOf( + container: Container, + taskId: TaskId, + activityId: string, +): Promise< + | { rows: number; simulated: number; tables: Array<{ label: string; rows: number }>; ports: string[]; carried: string[] } + | undefined +> { + const entries = await container.memory.history(taskId); + const wrote = entries.filter((e) => e.kind === 'ledger-write' && e.detail?.['tables'] !== undefined).at(-1); + const fired = entries.filter((e) => e.kind === 'release-verdict' && e.detail?.['ports'] !== undefined).at(-1); + if (wrote === undefined && fired === undefined) return undefined; + + const objects = await container.ledger.listObjects().catch(() => []); + const manifest = await container.registry.manifestOf(activityId).catch(() => undefined); + /* + * **按交付物点名,不按表点名**(2026-08-02 用户当场指出:「料号分类表和受理结论 + * 不都是包含在询价受理记录的结构里的吗,为啥单独陈列?」)。 + * + * 一份成果常常不止一张表:A1 交出的是一份询价受理记录,而它由受理记录、 + * 料号分类表、受理结论三张表承载 —— 人拿到的是**一份**,不是三份。 + * 清单里 `ledgers` 说了哪几张表归哪份成果,这里照它并; + * 不归任何一份成果的(客户档案这类主数据)按自己的表名摆。 + */ + const owner = new Map(); + for (const d of manifest?.contract.requiredDeliverables ?? []) { + for (const at of d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger])) owner.set(at, d.label); + } + const merged = new Map(); + for (const raw of Array.isArray(wrote?.detail?.['tables']) ? (wrote.detail['tables'] as unknown[]) : []) { + const t = (typeof raw === 'object' && raw !== null ? raw : {}) as { address?: unknown; rows?: unknown }; + const address = String(t.address ?? ''); + const [domain, object] = address.split('.'); + // 摆的是人读名:先看它归哪份成果,再退回表的登记名,最后才退回地址 + const label = owner.get(address) ?? objects.find((o) => o.domain === domain && o.object === object)?.label ?? address; + if (label === '') continue; + merged.set(label, (merged.get(label) ?? 0) + Number(t.rows ?? 0)); + } + const tables = [...merged].map(([label, rows]) => ({ label, rows })); + + /* 出口也摆人读名:契约里每个口都写了 `description`,那才是给人看的那一句 */ + const named = new Map(); + for (const p of manifest?.contract.output.mandatory ?? []) named.set(p.name, p.description ?? p.name); + for (const g of manifest?.contract.output.selective ?? []) { + for (const p of g.ports) named.set(p.name, p.description ?? p.name); + } + const ports = (Array.isArray(fired?.detail?.['ports']) ? (fired.detail['ports'] as unknown[]) : []) + .map((p) => named.get(String(p)) ?? String(p)) + .filter((p) => p !== ''); + /* + * **带过去了什么**(2026-08-02 用户定:「流向下一个活动带了什么数据」)。 + * + * 发射的载荷就是**已落地的那些事实**(见 `release-service` 的 `landedFacts`), + * 摆的是列的中文名 —— 下游活动读到的正是这几个字段。 + * 只摆名字不摆值:值在成果件里,这一屏答的是「带了哪些东西过去」。 + */ + const colLabel = new Map(); + for (const d of manifest?.declarations.ledger ?? []) { + for (const f of d.fields) if (f.label !== undefined && f.label !== '') colLabel.set(f.name, f.label); + } + const carried = (Array.isArray(fired?.detail?.['carried']) ? (fired.detail['carried'] as unknown[]) : []) + .map((k) => colLabel.get(String(k)) ?? String(k)) + .filter((k) => k !== ''); + + return { + rows: Number(wrote?.detail?.['rows'] ?? 0), + simulated: Number(wrote?.detail?.['simulated'] ?? 0), + tables, + ports, + carried, + }; +} + +/** 这一单归哪个活动、它声明过哪几张表 —— 授权面就是这份声明 */ +async function declarationsOf(container: Container, taskId: TaskId) { + const task = await container.task.get(taskId).catch(() => undefined); + if (task === undefined) return undefined; + const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined); + return manifest?.declarations.ledger; +} + +/** + * 就地修订留一条痕 —— 改了几行、加了几行、撤了哪几行、哪几格没归成型。 + * + * **撤掉的那几行要点名**:那一条不只是给人看的账,也是**下一拍模型的行动指南** —— + * 它看不见这一屏,只看得见轨迹。不说清「这一行是人撤的」,它会照着 + * 「条目数 9 而表里 8 行」判成「少写了一行」,转头把人刚删掉的那行写回去。 + */ +async function noteEdits(container: Container, taskId: TaskId, got: WriteBack): Promise { + const touched = got.rows + got.added + got.dropped.length; + if (touched === 0 && got.bad.length === 0) return touched; + const parts: string[] = []; + if (got.rows > 0) parts.push(`改了 ${got.rows} 行`); + if (got.added > 0) parts.push(`新增 ${got.added} 行`); + if (got.dropped.length > 0) parts.push(`撤掉 ${got.dropped.length} 行(${got.dropped.slice(0, 5).join('、')})`); + if (got.bad.length > 0) parts.push(`${got.bad.length} 格填的值与列型对不上,已跳过(${got.bad.slice(0, 3).join('、')})`); + await container.memory.append({ + taskId, + kind: 'human-input', + title: + `就地修订成果:${parts.join(';')}` + + (got.dropped.length === 0 ? '' : ' —— 撤掉的是人定的,别再写回去;条目数跟着改对'), + detail: { + rows: got.rows, + ...(got.added === 0 ? {} : { added: got.added }), + ...(got.dropped.length === 0 ? {} : { dropped: got.dropped }), + ...(got.bad.length === 0 ? {} : { rejected: got.bad }), + }, + // 版本戳:这条不由活动产出,与引擎那几条同一口径 + stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }, + }); + return touched; +} + +/** + * **件上按「保存」** —— 按**地址**写回,不经过「当下那张卡的第几块」(2026-08-02)。 + * + * 件里每张可改的表挂着 `data-edit="域.对象"`、每个格子挂着 `data-col="台账列名"`, + * 地址与列名本来就在件里 —— 前端照原样收上来即可。 + * + * 从前这条路借道工单的 `blocks[i]`:列名由那张卡给出,于是**「这份件能不能改」 + * 被绑在了「此刻开着的那张卡长什么样」上**。实测的下场:一单在放行门连拒五拍熔断, + * 引擎发的是一张没有 `blocks` 的熔断卡,而屏上摆着的正是受理记录那份件 —— + * 闸在、编辑进得去、保存按得下,前端却找不到匹配的块,**一格都没往下送**。 + * 人要补的恰恰是把这一单卡在那儿的那个必填格,而屏上什么反应都没有。 + * + * 授权面改由**活动清单的台账声明**把关:不在声明里的地址一概不写 —— + * 比「当下那张卡碰巧带了哪一块」既严格又稳定。 + */ +async function applyViewTableEdits( + container: Container, + taskId: TaskId, + payload: { + tables: Readonly>; + drops?: Readonly> | undefined; + }, +): Promise { + const declarations = await declarationsOf(container, taskId); + if (declarations === undefined) return 0; + const got = nothing(); + /* + * **先撤后写** —— 顺序有意义:人可能把一行删掉、又在同一次保存里加回一行同主键的。 + * 反过来做的话,新写的那一行会被紧接着的撤掉抹平,屏上有而台账里没有。 + */ + for (const { declared, lines } of writesOf(declarations, payload.drops ?? {})) { + merge(got, await dropEditedRows(container, taskId, declared, lines)); + } + for (const { declared, lines } of writesOf(declarations, payload.tables)) { + merge(got, await writeEditedRows(container, taskId, declared, lines)); + } + return noteEdits(container, taskId, got); +} + +/** 台账声明的最小形状 —— 这一段只用到这几样 */ +type LedgerDeclaration = { + domain: string; + object: string; + fields: ReadonlyArray<{ + name: string; + type: string; + primaryKey: boolean; + label?: string | undefined; + required?: boolean | undefined; + /** 基座生成的内部流水号 —— 不问人、不让模型编(见 `autoKeys`) */ + auto?: boolean | undefined; + }>; +}; + +/** + * **哪几张表准写** —— 授权面是活动清单的台账声明,不是当下那张卡带了哪一块。 + * + * 地址对不上号(不是「域.对象」)、活动没声明过的地址,一概不写: + * 载荷是从浏览器来的,门得在这儿,不能靠件里写着什么就信什么。 + */ +export function writesOf( + declarations: readonly LedgerDeclaration[], + tables: Readonly>, +): Array<{ declared: LedgerDeclaration; lines: readonly EditedRow[] }> { + const out: Array<{ declared: LedgerDeclaration; lines: readonly EditedRow[] }> = []; + for (const [address, lines] of Object.entries(tables)) { + const [domain, object, extra] = address.split('.'); + if (domain === undefined || object === undefined || extra !== undefined) continue; + const declared = declarations.find((l) => l.domain === domain && l.object === object); + if (declared === undefined) continue; // 活动没声明这张表 —— 不是它的地盘,不写 + out.push({ declared, lines }); + } + return out; +} + +/** + * **卡上按「提交」时带着的那张表** —— 回传里第一行是列名,其后一行一条。 + * + * 与数据录入卡同一条规矩:该由人亲手落下的,别在中间加一道传话。 + * 从前这张表的回传只进 AI 的轨迹、由它自己 write 回去 —— 中间隔一道, + * 它可以抄错、可以顺手改一笔(实测:人填 30000 它写回 30;人留空表示「客户不给」, + * 它填了隔壁那条料的价)。验收页上更要紧:那一改本来就是**人对成果的修订**, + * 过一遍 AI 就不叫修订了。 + * + * 只认**绑了 `ledger` + `fields`** 的表:这条路上列名确实由卡给出(卡上摆的是 + * `ViewBlocks`,格子里没有 `data-col`),所以位置对应仍留在这儿。 + */ +async function applyBoundTableEdits( + container: Container, + taskId: TaskId, + order: WorkOrder, + reply: { values: Readonly> }, +): Promise { + const blocks = (order as { blocks?: ReadonlyArray> }).blocks; + if (blocks === undefined || blocks.length === 0) return 0; + const declarations = await declarationsOf(container, taskId); + if (declarations === undefined) return 0; + + const got = nothing(); + for (const [index, block] of blocks.entries()) { + if (block['t'] !== 'table' || typeof block['ledger'] !== 'string' || !Array.isArray(block['fields'])) continue; + const raw = reply.values[`table:${index}`]; + if (raw === undefined || raw.trim() === '') continue; + let table: unknown; + try { + table = JSON.parse(raw); + } catch { + continue; // 解析不了就当没改 —— 宁可少写一次,不要把一份坏数据落进草稿 + } + if (!Array.isArray(table) || table.length < 2) continue; + const [domain, object] = String(block['ledger']).split('.'); + if (domain === undefined || object === undefined) continue; + const declared = declarations.find((l) => l.domain === domain && l.object === object); + if (declared === undefined) continue; + const fields = (block['fields'] as unknown[]).map((f) => String(f)); + + const lines: EditedRow[] = []; + for (const line of (table as unknown[]).slice(1)) { + if (!Array.isArray(line)) continue; + const cells: Record = {}; + fields.forEach((f, i) => { + cells[f] = String((line as unknown[])[i] ?? ''); + }); + lines.push(cells); + } + merge(got, await writeEditedRows(container, taskId, declared, lines)); + } + return noteEdits(container, taskId, got); +} + +/** + * **提交闸:必填还空着就交不了**(2026-08-02 用户定)。 + * + * 「必填」由**台账声明**说了算(`fields[].required`),缺几格由基座数(草稿层现读)—— + * 与放行门那条 L1 机检 `域_对象_missing = 0` **同一个数、同一套算法** + * (都走 `missingReadings`)。同源是要紧的:两处各算各的,就会出现 + * 「卡上说能交、放行门说不行」——那种矛盾人是判不出对错的。 + * + * 只对**绑了台账表**的执行卡出闸:算得出才拦,算不出(卡上没绑表)就不给闸, + * 按钮照常能按 —— **没有依据的时候不拦人**。 + */ +async function submitGate( + container: Container, + taskId: TaskId, + order: WorkOrder, +): Promise<{ ready: boolean; why: string; blanks: string[] } | undefined> { + if (order.mode !== 'execution' || order.done !== undefined) return undefined; + const addresses = (order.blocks ?? []) + .filter((b): b is Extract => b.t === 'table' && typeof b.ledger === 'string') + .map((b) => b.ledger as string); + if (addresses.length === 0) return undefined; + const declarations = await declarationsOf(container, taskId); + if (declarations === undefined) return undefined; + const watched = declarations.filter((l) => addresses.includes(`${l.domain}.${l.object}`)); + if (watched.length === 0) return undefined; + + // 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`) + const mine: Record = {}; + for (const draft of await container.ledger.taskDrafts(taskId)) { + (mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch }); + } + return gateOf(missingReadings(mine, watched), watched); +} + +/** + * 读数 → 闸。**数与名单都来自基座的读数**,这里只负责说人话。 + * + * 一格不缺就是 `ready: true`(闸不出现);缺了就把「几条 / 哪几行」原样摆出来 —— + * 只给一个按不下的灰按钮,人只能猜自己哪儿做错了。 + */ +export function gateOf( + readings: Readonly>, + watched: ReadonlyArray<{ domain: string; object: string }>, +): { ready: boolean; why: string; blanks: string[] } { + let short = 0; + const blanks: string[] = []; + for (const { domain, object } of watched) { + short += Number(readings[`${domain}_${object}_missing`] ?? 0); + const keys = readings[`${domain}_${object}_missing_keys`]; + if (typeof keys === 'string' && keys !== '') blanks.push(...keys.split(';')); + } + return short === 0 + ? { ready: true, why: '', blanks: [] } + : { ready: false, why: `还有 ${short} 条必填没填 —— 在工作区的件上补齐才能提交`, blanks }; +} export function registerDeskRoutes(app: Hono, container: Container): void { /** 当前登录者;缺会话即 401 */ @@ -53,6 +572,13 @@ export function registerDeskRoutes(app: Hono, container: Container): voi } } + /* + * 阶段进度 —— 一行运行时读出来(阶段声明与计划项状态同在那一行)。 + * 每张卡一次读:任务清单是「我手上开着的单」,量级是个位数,与上面 + * 已有的按卡取工作流/流程包同一个形状。活动没声明阶段就没有这一格。 + */ + const phase = await container.loop.phaseOf(task.id, task.terminal !== undefined).catch(() => undefined); + return { id: task.id, activity: task.title, @@ -61,6 +587,7 @@ export function registerDeskRoutes(app: Hono, container: Container): voi ...(task.terminal === undefined ? {} : { terminal: task.terminal }), position, total, + ...(phase === undefined ? {} : { phase }), workflowSubject: subject, workflowState, startedAt: task.startedAt, @@ -149,6 +676,78 @@ export function registerDeskRoutes(app: Hono, container: Container): voi container.interaction.viewOf(taskId), ]); const cards = await container.humanTool.chatCards(taskId); + /* + * **闸与判据都现算** —— 人在件上补一格、撤一行,按下保存,这一屏跟着重取, + * 闸该跟着开、判据该跟着变。发卡那一刻算死是不行的: + * 那时缺的还缺着,而他补的动作正是为了消掉它们。 + * + * 验收卡那一摞判据从前是发卡时钉死的:人在成果件上把目标价填好, + * 卡上照旧写着「缺目标价」—— 用户实测点出来的(「这个价格是我填写的,但是它判错了」)。 + * 现在与提交门读同一段(`criterionCheck`),两处不会各说各话。 + */ + const live = workOrders.some((o) => o.mode === 'acceptance' && o.done === undefined) + ? await container.checks.of(taskId).catch(() => undefined) + : undefined; + const landed = workOrders.some((o) => o.mode === 'acceptance' && o.done !== undefined) + ? await landingOf(container, taskId, task.activityId).catch(() => undefined) + : undefined; + const gated = await Promise.all( + workOrders.map(async (order) => { + if (order.mode === 'acceptance' && order.done === undefined && live !== undefined) { + // L5 照旧不上卡:那张卡本身就是那道关,列进去等于摆一条恒为「未达标」的项 + return { ...order, criteria: live.filter((c) => c.level !== 'L5') }; + } + // 办结的验收卡:判据换成落地结果 —— 签完之后人关心的是「发生了什么」 + if (order.mode === 'acceptance' && order.done !== undefined && landed !== undefined) { + return { ...order, landing: landed }; + } + const gate = await submitGate(container, taskId, order); + return gate === undefined ? order : { ...order, gate }; + }), + ); + /* + * 沟通记录 —— 一单往来的原件。附件那一格照抄基座读出来的 `note`(读出多少字 / + * 为什么读不出),**不改写**:界面上说「已上传 ✓」而活动其实看不见里面,是撒谎。 + */ + const commRows = rowsOf<{ + id: string; + at: Date | string; + direction: string; + channel: string; + party: string; + subject: string; + body: string; + attachments: unknown; + note: string; + read_at: unknown; + }>( + await container.db.execute(sql` + select id, at, direction, channel, party, subject, body, attachments, note, read_at + from sys.task_comm where task_id = ${taskId} order by at`), + ); + const comms = commRows.map((r) => ({ + id: r.id, + at: r.at instanceof Date ? r.at.toISOString() : String(r.at), + direction: r.direction === 'out' ? ('out' as const) : ('in' as const), + channel: r.channel, + party: r.party, + subject: r.subject, + body: r.body, + // read_at 为空 = 未读;只由人按按钮改,不随打开页签自动清 + unread: r.read_at === null || r.read_at === undefined, + attachments: (Array.isArray(r.attachments) ? r.attachments : []).map((a) => { + const one = (typeof a === 'object' && a !== null ? a : {}) as Record; + const text = one['text']; + return { + name: String(one['name'] ?? '未命名'), + ...(typeof one['size'] === 'number' ? { size: one['size'] } : {}), + ...(typeof one['ref'] === 'string' ? { ref: one['ref'] } : {}), + ...(typeof one['note'] === 'string' ? { note: one['note'] } : {}), + readable: typeof text === 'string' && text !== '', + }; + }), + note: r.note, + })); const chain = await toChain(task); /* @@ -218,21 +817,60 @@ export function registerDeskRoutes(app: Hono, container: Container): voi stages: [], deliverables, results, - workOrders, + comms, + workOrders: gated, chat: merged, view: pushed?.blocks !== undefined ? [...pushed.blocks] : view, ...(pushed?.html === undefined ? {} : { viewHtml: pushed.html }), - log: lines.map((l) => ({ - id: l.id, - at: l.at, - side: l.side, - sideLabel: ATTRIBUTION_SIDE_LABEL[l.side], - title: l.title, - ...(l.summary === undefined ? {} : { summary: l.summary }), - })), + log: await displayLog(lines, container.org), }); }); + /** + * **就地改完按下「保存」** —— 写回本单草稿层,并**按新数据把件重渲一遍**。 + * + * 两步缺一不可: + * ① 落库 —— 不落,人改的东西只活在浏览器里,提交前一刷新就没了; + * ② 重渲 —— 件是**服务端投影**:只读那几格是他自己敲的,而由数据算出来的东西 + * (类徽 A/B/C、分布条、结论横幅那条去向)全停在上一次渲染那一刻。 + * 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」—— + * 一半新一半旧,那不是没更新,是界面在撒谎。 + * + * **工单不办结**:保存只是保存。放行门的位置一点没变 —— + * 写的仍是草稿层,合并进正式版依旧只发生在人签字那一刻。 + */ + app.post(saveTableRoute.path, async (c) => { + const staff = await me(c.get('account')); + if (staff === undefined) { + const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; + return c.json(body, 401); + } + const taskId = taskIdSchema.parse(c.req.param('taskId')); + const parsed = saveTableRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '保存载荷不合规' }; + return c.json(body, 400); + } + const task = await container.task.get(taskId); + if (task.ownerId !== staff.id) { + const body: ApiError = { code: 'forbidden', message: '这一单不归你办' }; + return c.json(body, 403); + } + /* + * **这一单还在办**才收得下改动 —— 终态之后草稿层已经并进正式版,再改就是改历史。 + * 门只有这一道:改哪张表由**活动声明**把关(见 `applyViewTableEdits`), + * 不再问「此刻开着的是哪张卡」—— 那道门实测把人真正要补的那一格挡在了外面。 + */ + if (task.terminal !== undefined) { + const body: ApiError = { code: 'conflict', message: '这一单已收尾,改不动了' }; + return c.json(body, 409); + } + const rows = await applyViewTableEdits(container, taskId, parsed.data); + // 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值 + if (rows > 0) await container.views.repush(taskId); + return c.json({ ok: true, rows }); + }); + /** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */ app.post(replyWorkOrderRoute.path, async (c) => { const staff = await me(c.get('account')); @@ -247,6 +885,20 @@ export function registerDeskRoutes(app: Hono, container: Container): voi } try { const outcome = await container.humanTool.replyWorkOrder(parsed.data, staff.id); + /* + * **绑台账的可编辑表:人改了什么,基座当场写回草稿层**(2026-08-01)。 + * + * 与数据录入卡同一条规矩:该由人亲手落下的,别在中间加一道传话。 + * 从前这张表的回传只进 AI 的轨迹,由它自己 write 回去 —— 中间隔一道, + * 它可以抄错、可以顺手改一笔(实测:人填 30000,它写回 30; + * 人留空表示「客户不给」,它填了隔壁那条料的价)。 + * 验收页上更要紧:那一改本来就是**人对成果的修订**,过一遍 AI 就不叫修订了。 + * + * 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。 + */ + await applyBoundTableEdits(container, outcome.taskId, outcome.order, parsed.data); + // 人答完了就接着跑,别让他对着不动的屏幕等两轮轮询(见 workers/pulse.ts) + void pulse(); return c.json({ ok: true, producesCompletion: outcome.producesCompletion }); } catch (error) { if (error instanceof ReplyRejectedError) { @@ -274,7 +926,58 @@ export function registerDeskRoutes(app: Hono, container: Container): voi return c.json(body, 400); } try { - await container.humanTool.replyChatCard(parsed.data, staff.id); + const { card, taskId: cardTaskId } = await container.humanTool.replyChatCard(parsed.data, staff.id); + /* + * **数据录入卡:确认那一下就是写入**(2026-07-31 用户定)。 + * + * 别的卡是「把值交给 AI,AI 再决定写什么」——中间隔一道传话, + * 它可以抄错、可以顺手改一笔,而人不知道自己填的东西最后落成了什么。 + * 这一类不同:卡上写明落哪张表、哪个主键,人按下确认,基座**当场**落进草稿层, + * AI 不经手。建客户档案这类事就该这样 —— 主数据建错一次,后面每一单都跟着错。 + * + * 仍守铁律②:写的是**草稿层**,放行那一刻才并进正式版 —— 人的确认不越过放行门。 + */ + if (parsed.data.k === 'entry' && card.k === 'entry') { + /* + * **一张卡可以落好几张表**(2026-08-02:建一位新客户 = 公司档 + 联系人档)。 + * 值按段隔开(`段号.列名`,主段 `0.`)—— 两张表都有 `name` 这一列, + * 平铺就会互相盖掉,而盖掉之后没人看得出来。 + */ + const groups = [ + { ledger: card.ledger, key: card.key }, + ...(card.also ?? []).map((g) => ({ ledger: g.ledger, key: g.key })), + ]; + const declarations = (await declarationsOf(container, cardTaskId)) ?? []; + for (const [at, group] of groups.entries()) { + const [domain, object] = group.ledger.split('.'); + if (domain === undefined || object === undefined) continue; + const patch: Record = {}; + for (const [k, v] of Object.entries(parsed.data.values)) { + const mark = `${at}.`; + if (k.startsWith(mark)) patch[k.slice(mark.length)] = v; + // 老形状(没段号的平键)只喂主段 —— 一张卡只有一段时它仍旧管用 + else if (at === 0 && !/^\d+\./.test(k)) patch[k] = v; + } + if (Object.keys(patch).length === 0) continue; + const declared = declarations.find((l) => l.domain === domain && l.object === object); + const key = { ...group.key, ...(await autoKeys(container, cardTaskId, declared, group.key)) }; + await container.ledger.draftWrite({ taskId: cardTaskId, address: { domain, object }, key, patch }); + } + { + /* + * 这里**不再另记一条** —— `replyChatCard` 已经把这一下记全了 + * (谁、写入哪张表、几个格子各是什么)。从前两处各记一条, + * 同一次点击在日志里出现两行:「柴浩 确认录入 customer.profile」 + * 紧跟着「entry 卡回传」,读起来像人点了两次。 + */ + } + } + /* + * 人答完了就接着跑 —— **录入卡尤其要紧**:它是人按下确认那一刻, + * 眼睛正盯着屏幕。等两轮轮询(实测 6.8 秒)那段时间对话区一动不动, + * 人以为没点上,会再点一次。见 `workers/pulse.ts`。 + */ + void pulse(); return c.json({ ok: true }); } catch (error) { if (error instanceof ReplyRejectedError) { @@ -287,6 +990,72 @@ export function registerDeskRoutes(app: Hono, container: Container): voi }); /** 自由输入 —— 人事件的第二形态:写入任务记忆,loop 作为观察;不驱动 flow 步 */ + /** + * 标记沟通记录已读 / 未读 —— **只认按钮**。 + * + * 不做「打开页签即已读」:那等于替人宣布他看过了,而他可能只是路过。 + * 反向也留着(已读改回未读):看了一半想留个记号是常事。 + * 只许改**自己负责的那一单**的记录 —— 别人的单轮不到你替他标已读。 + */ + app.post(markCommReadRoute.path, async (c) => { + const staff = await me(c.get('account')); + if (staff === undefined) { + const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; + return c.json(body, 401); + } + const parsed = markCommReadRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '回传不合协议', detail: parsed.error.issues }; + return c.json(body, 400); + } + const commId = c.req.param('commId'); + const rows = rowsOf<{ task_id: string; subject: string; read_at: unknown }>( + await container.db.execute(sql`select task_id, subject, read_at from sys.task_comm where id = ${commId}`), + ); + const taskId = rows[0]?.task_id; + if (taskId === undefined) { + const body: ApiError = { code: 'not-found', message: '沟通记录不存在' }; + return c.json(body, 404); + } + const task = await container.task.get(taskIdSchema.parse(taskId)); + if (task === undefined || task.ownerId !== staff.id) { + const body: ApiError = { code: 'forbidden', message: '这一单不归你,标不了它的沟通记录' }; + return c.json(body, 403); + } + /* + * **第一次看过要留痕**(2026-07-31 用户定)——「谁在什么时候看了这封信」 + * 是回看这一单时最想知道的事之一:客户抱怨没人理他,日志里得答得出 + * 「07-31 13:34 柴浩看过了」。只记**第一次**:反复标来标去不是事实的变化, + * 是人在做记号,记满一屏反而把真事实淹了。 + */ + const firstRead = parsed.data.read && (rows[0]?.read_at === null || rows[0]?.read_at === undefined); + await container.db.execute( + parsed.data.read + ? sql`update sys.task_comm set read_at = now() where id = ${commId}` + : sql`update sys.task_comm set read_at = null where id = ${commId}`, + ); + if (firstRead) { + await container.memory.append({ + taskId: taskIdSchema.parse(taskId), + kind: 'human-input', + // 名字**不拼进标题**:主语由日志统一按 `by` 换成名字 —— 拼死在这里, + // 改个显示口径就得改留痕,而留痕是只增不改的 + title: '看过了邮件', + /* + * 主题落 `summary` 而不是塞进标题 —— 日志一行是「谁干了什么」, + * 副行才是「哪一件」。塞进标题会让这一行长到把别的条目挤扁, + * 而人扫日志时先看的是动作,确认是哪封信是第二眼的事。 + */ + detail: { commId, by: staff.id, act: 'read-comm', summary: rows[0]?.subject ?? '' }, + // 版本戳:这条不由活动产出,与引擎那几条同一口径 + stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] }, + }); + } + // 气泡计数长在任务投影上 —— 标完要让那一屏自己动,不能等人再点一次 + container.broadcaster.publish({ kind: 'task', id: taskId, reason: 'comm-read' }); + return c.json({ ok: true, unread: !parsed.data.read }); + }); + app.post(sayRoute.path, async (c) => { const staff = await me(c.get('account')); if (staff === undefined) { diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index c53af9d..f33a354 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -5,10 +5,10 @@ * 「治理动作本身留痕入事件流 —— 治理也被治理。」 */ import { sql } from 'drizzle-orm'; +import { extractText } from '../upload-extract.ts'; import type { Hono } from 'hono'; import type { ApiError, GovernanceFunction, GrantAction } from '@contracts'; import { - ATTRIBUTION_SIDE_LABEL, browseLedgerRoute, controlTaskRoute, govMetricsRoute, @@ -26,9 +26,17 @@ import { listTriggersRoute, registerToolRoute, saveProcessRoute, + testActivityStateRoute, testCurrentRoute, + testLogDetailRoute, + testRunActivityRoute, testRunRoute, testStopRoute, + listTestLogsRoute, + deleteTestLogRoute, + clearTestLogsRoute, + downloadFileRoute, + uploadFileRoute, traceOfWorkflowRoute, setProcessStateRoute, reassignTaskRoute, @@ -63,6 +71,7 @@ import { } from '@modules/process'; import type { Container } from '../container.ts'; import type { AppEnv } from '../http-env.ts'; +import { displayLog } from './log-lines.ts'; /** `db.execute` 的行读取 —— Drizzle 的返回型随驱动变,窄化收在这一处 */ function rowsOf(result: unknown): T[] { @@ -78,8 +87,180 @@ function rowsOf(result: unknown): T[] { * 用 `source->>'ref' = 'testbed'` 认门:**只动测试台自己起的单**, * 生产单一个字都不碰(它们的来源是触发器或纪要登记)。 */ + +/** + * 归档一次试跑 —— **销毁之前拍下快照**(见 `0023_testbed_log.sql` 的表注)。 + * + * 拔掉运行数据这条不变;拔之前把观测面存下来:逐拍轨迹、台账草稿、发过的卡、 + * 预算读数与终态。开发活动靠的就是「上一版跑了几拍、卡在哪一步」的比对。 + * + * 归档失败**不挡销毁**:清场是人明确要的动作,不能因为存不下账就把仪器占着。 + * 失败如实写进 stderr —— 静默吞掉会让人以为存了,回头找不着。 + */ +async function archiveRun( + container: Container, + input: { + kind: 'activity' | 'package'; + taskIds: readonly string[]; + workflowId?: string; + subject?: string; + target?: string; + /** 流程包一次试跑的终态取流自己的,不是头一个节点任务的 */ + terminal?: string; + }, +): Promise { + if (input.taskIds.length === 0) return; + try { + const trail: { at: string; kind: string; title: string; detail?: Record }[] = []; + const rows: { address: string; key: Record; patch: Record }[] = []; + const cards: { id: string; mode: string; title: string; done: boolean; result?: string }[] = []; + const used = { steps: 0, tokens: 0, humanCalls: 0 }; + const caps = { steps: 0, tokens: 0, humanCalls: 0 }; + let head: { subject: string; target: string; terminal?: string; reason?: string; startedAt: string } | undefined; + + for (const taskId of input.taskIds) { + const task = await container.task.get(taskId as never); + const [memory, drafts, orders] = await Promise.all([ + container.memory.window(taskId as never, 400), + container.ledger.taskDrafts(taskId as never), + container.humanTool.allWorkOrders(taskId as never), + ]); + // 多节点合成一条:每条痕带上是哪一节点的,不然合起来读不出先后归属 + const tag = input.taskIds.length > 1 ? `${task.title} · ` : ''; + for (const m of memory) { + trail.push({ + at: m.at, + kind: m.kind, + title: `${tag}${m.title}`, + ...(m.detail === undefined ? {} : { detail: m.detail }), + }); + } + for (const d of drafts) rows.push({ address: `${d.domain}.${d.object}`, key: d.key, patch: d.patch }); + for (const o of orders) { + cards.push({ + id: o.id, + mode: o.mode, + title: `${tag}${o.title}`, + done: o.done !== undefined, + ...(o.done?.result === undefined ? {} : { result: String(o.done.result) }), + }); + } + used.steps += task.used.steps; + used.tokens += task.used.tokens; + used.humanCalls += task.used.humanCalls; + caps.steps += task.caps.steps; + caps.tokens += task.caps.tokens; + caps.humanCalls += task.caps.humanCalls; + head ??= { + subject: input.subject ?? task.title, + target: input.target ?? task.activityId, + ...(input.terminal ?? task.terminal) === undefined ? {} : { terminal: input.terminal ?? task.terminal }, + ...(task.reason === undefined ? {} : { reason: task.reason }), + startedAt: task.startedAt, + }; + } + if (head === undefined) return; + trail.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)); + + await container.db.execute(sql` + insert into sys.testbed_run + (id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps, trail, drafts, cards, started_at) + values ( + ${crypto.randomUUID()}, ${input.kind}, ${head.subject}, ${head.target}, + ${input.taskIds[0] ?? null}, ${input.workflowId ?? null}, ${head.terminal ?? null}, ${head.reason ?? null}, + ${JSON.stringify(used)}::jsonb, ${JSON.stringify(caps)}::jsonb, + ${JSON.stringify(trail)}::jsonb, ${JSON.stringify(rows)}::jsonb, ${JSON.stringify(cards)}::jsonb, + ${head.startedAt} + )`); + } catch (error) { + process.stderr.write(`[testbed] 归档失败(照旧销毁):${error instanceof Error ? error.message : String(error)}\n`); + } +} + +/** 试跑日志的库行 —— snake 出库、camel 出网,映射收在一处 */ +type TestLogDbRow = { + id: string; + kind: string; + subject: string; + target: string; + task_id: string | null; + workflow_id: string | null; + terminal: string | null; + reason: string | null; + used: Record; + caps: Record; + trail_count: number; + draft_count: number; + card_count: number; + started_at: Date | string | null; + archived_at: Date | string; +}; + +const iso = (v: Date | string | null): string | undefined => + v === null ? undefined : v instanceof Date ? v.toISOString() : String(v); + +function toTestLogRow(r: TestLogDbRow): Record { + return { + id: r.id, + kind: r.kind === 'package' ? 'package' : 'activity', + subject: r.subject, + target: r.target, + ...(r.task_id === null ? {} : { taskId: r.task_id }), + ...(r.workflow_id === null ? {} : { workflowId: r.workflow_id }), + ...(r.terminal === null ? {} : { terminal: r.terminal }), + ...(r.reason === null ? {} : { reason: r.reason }), + used: r.used ?? {}, + caps: r.caps ?? {}, + trailCount: Number(r.trail_count ?? 0), + draftCount: Number(r.draft_count ?? 0), + cardCount: Number(r.card_count ?? 0), + ...(iso(r.started_at) === undefined ? {} : { startedAt: iso(r.started_at) }), + archivedAt: iso(r.archived_at) ?? new Date(0).toISOString(), + }; +} + +/** 一单任务的全部运行痕 —— 拔试跑单要拔干净:留在别人工作台上的卡最讨嫌 */ +async function destroyTask(container: Container, taskId: string): Promise { + for (const table of [ + 'work_order', + 'chat_card', + 'chat_message', + 'task_memory', + 'task_memory_seal', + 'task_view', + 'flow_state', + 'loop_state', + 'action_intent', + 'listener', + 'tool_handoff', + ]) { + await container.db.execute(sql`delete from sys.${sql.raw(table)} where task_id = ${taskId}`); + } + // 草稿层是任务级的:试跑写下的草稿不该留在库里冒充别单的读己之写 + await container.db.execute(sql`delete from ledger.__draft where task_id = ${taskId}`); + await container.db.execute(sql`delete from sys.trace_span where task_id = ${taskId}`); + await container.db.execute(sql`delete from sys.task where id = ${taskId}`); +} + +/** + * 拔掉**单活动试跑**的残留 —— 认的是 `origin = 'testbed'` 且不挂工作流的那些单。 + * 不挂工作流是关键:挂着的那批归 `destroySimulations` 按流拔,两边不重不漏。 + */ +async function destroyActivityRuns(container: Container, taskId?: string): Promise { + const scope = taskId === undefined || taskId === '' ? sql`` : sql` and id = ${taskId}`; + const rows = await container.db.execute<{ id: string }>( + sql`select id from sys.task where origin = 'testbed' and workflow_id is null${scope}`, + ); + const ids = rowsOf<{ id: string }>(rows).map((r) => r.id); + for (const id of ids) { + await archiveRun(container, { kind: 'activity', taskIds: [id] }); + await destroyTask(container, id); + } + return ids.length; +} + async function destroySimulations(container: Container, workflowId?: string): Promise { - const scope = workflowId === undefined ? sql`` : sql` and id = ${workflowId}`; + const scope = workflowId === undefined || workflowId === '' ? sql`` : sql` and id = ${workflowId}`; const wfs = await container.db.execute<{ id: string }>( sql`select id from sys.workflow where source->>'ref' = 'testbed'${scope}`, ); @@ -91,6 +272,18 @@ async function destroySimulations(container: Container, workflowId?: string): Pr const tasks = await container.db.execute<{ id: string }>(sql`select id from sys.task where workflow_id = ${id}`); const taskIds = rowsOf<{ id: string }>(tasks).map((r) => r.id); taskCount += taskIds.length; + // 一次试跑 = 一条账:名下节点任务合成一条,先归档再拔 + const head = rowsOf<{ subject: string; package_id: string; state: string }>( + await container.db.execute<{ subject: string; package_id: string; state: string }>( + sql`select subject, package_id, state from sys.workflow where id = ${id}`, + ), + )[0]; + await archiveRun(container, { + kind: 'package', + taskIds, + workflowId: id, + ...(head === undefined ? {} : { subject: head.subject, target: head.package_id, terminal: head.state }), + }); for (const taskId of taskIds) { await container.db.execute(sql`delete from sys.work_order where task_id = ${taskId}`); await container.db.execute(sql`delete from sys.chat_card where task_id = ${taskId}`); @@ -292,7 +485,14 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai if (denied) return c.json(denied, denyStatus(denied)); const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId'))); return c.json({ - log: lines.map((l) => ({ id: l.id, at: l.at, sideLabel: ATTRIBUTION_SIDE_LABEL[l.side], title: l.title })), + // 与工作台同一份日志、同一套口径:章是四字动作,内容带主语 + log: (await displayLog(lines, container.org)).map((l) => ({ + id: l.id, + at: l.at, + sideLabel: l.sideLabel, + action: l.action, + title: l.title, + })), }); }); @@ -371,6 +571,13 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai data, ...(Object.keys(assignments).length === 0 ? {} : { assignments }), }); + /* + * 起单要说一声 —— 试跑起出来的是**真任务**:它这一刻就落到了负责人的工作台上、 + * 进了运营台的在途口径。不广播,那两处要等到下一次自己重取才看得见 + * (而工作台没有轮询,等于不刷新)。`system` 一并发:测试台自己的当前态也变了。 + */ + container.broadcaster.publish({ kind: 'workflow', id: record.id, reason: 'testbed-start' }); + container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-start' }); return c.json({ workflowId: record.id, state: record.state }); } catch (error) { // 起不来的原因要如实说(不合调用接口 / 无生效版 / 岗位空缺),别让人对着空白猜 @@ -391,19 +598,272 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' }; return c.json(body, 400); } - const tasks = await destroySimulations(container, parsed.data.workflowId); - return c.json({ destroyed: true, tasks }); + const { workflowId, taskId } = parsed.data; + /* + * 两种试法各有其拔法。都不给 = 拔掉所有试跑残留(换一种试法之前先清场): + * 一次只跑一条,不然读数屏上是哪一单的数就说不清了。 + */ + const byFlow = taskId === '' ? await destroySimulations(container, workflowId) : 0; + const byTask = workflowId === '' ? await destroyActivityRuns(container, taskId) : 0; + /* + * **销毁尤其要说一声**:被拔掉的任务此刻还挂在负责人的工作台列表上、还占着运营台的 + * 在途口径。不广播,那些面上会留着一张点进去就 404 的鬼卡片,直到人自己刷新。 + * 用 `system`:销毁是跨面的全局变更(任务没了、工作流没了、trace 也没了), + * 按单个 id 广播反而说不清 —— 说没就是全没了。 + */ + if (byFlow + byTask > 0 || workflowId !== '' || taskId !== '') { + container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-destroyed' }); + } + return c.json({ destroyed: true, tasks: byFlow + byTask }); }); /** 当前在跑的仿真 —— 刷新页面也认得回来(测试台一次只跑一条) */ app.get(testCurrentRoute.path, async (c) => { const denied = await governProcess(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); - const rows = await container.db.execute<{ id: string; subject: string }>( - sql`select id, subject from sys.workflow where source->>'ref' = 'testbed' order by started_at desc limit 1`, + /* + * **在跑与跑完要分得开**(`live`)。 + * + * 从前只答「最近一条试跑单是哪个」,不问它有没有收尾 —— 于是跑完之后 + * 这台仪器还认为自己被占着:旋钮永久锁住,人只能刷新整页才起得了下一单(实测)。 + * 现在照旧把最近那一单交出去(观测面要摆结果给人看),但如实说明它还在不在跑。 + */ + const flows = await container.db.execute<{ id: string; subject: string; state: string }>( + sql`select id, subject, state from sys.workflow + where source->>'ref' = 'testbed' order by started_at desc limit 1`, ); - const hit = rowsOf<{ id: string; subject: string }>(rows)[0]; - return c.json(hit === undefined ? {} : { workflowId: hit.id, subject: hit.subject }); + const flow = rowsOf<{ id: string; subject: string; state: string }>(flows)[0]; + if (flow !== undefined) { + const liveFlow = flow.state === 'running' || flow.state === 'waiting'; + return c.json({ + workflowId: flow.id, + subject: flow.subject, + live: liveFlow, + ...(liveFlow ? {} : { terminal: flow.state }), + }); + } + + // 没有试跑的流,再看单活动试跑(它不挂工作流,认的是 origin) + const solo = await container.db.execute<{ id: string; activity_id: string; title: string; terminal: string | null }>( + sql`select id, activity_id, title, terminal from sys.task + where origin = 'testbed' and workflow_id is null order by started_at desc limit 1`, + ); + const one = rowsOf<{ id: string; activity_id: string; title: string; terminal: string | null }>(solo)[0]; + if (one === undefined) return c.json({ live: false }); + return c.json({ + taskId: one.id, + activityId: one.activity_id, + subject: one.title, + live: one.terminal === null, + ...(one.terminal === null ? {} : { terminal: one.terminal }), + }); + }); + + /** + * 仿真测试台 · 试跑**一个活动** —— 起一单不挂工作流的真任务。 + * + * 真任务:照旧过装载两审、Harness 预算、案例模型推进、放行门。 + * 「试跑不等于假跑」—— 假跑试不出任何东西;它与生产单的区别只有一个 + * `origin='testbed'`,好让人随时连根拔掉。 + */ + app.post(testRunActivityRoute.path, async (c) => { + const denied = await governProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const parsed = testRunActivityRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues }; + return c.json(body, 400); + } + const me = await container.org.findByAccount(c.get('account')); + const { activityId, data, message, ownerId } = parsed.data; + /* + * 派给谁:显式指定优先,否则**派给操作者自己**。 + * 绝不默认派到别人工作台上 —— 试跑单是自己要看的,不是给同事添的活。 + */ + const owner = ownerId !== '' ? ownerId : me?.id; + if (owner === undefined) { + const body: ApiError = { code: 'validation-failed', message: '定不了负责人:既没指派,也认不出操作者' }; + return c.json(body, 400); + } + try { + // 一次只跑一条:先清场(流与单活动两种残留都拔) + await destroySimulations(container); + await destroyActivityRuns(container); + + const manifest = await container.registry.manifestOf(activityId); + const task = await container.task.start({ + activityId, + ownerId: staffIdSchema.parse(owner), + title: manifest.identity.name, + origin: 'testbed', + data, + ...(message === '' ? {} : { message }), + }); + container.broadcaster.publish({ kind: 'task', id: task.id, reason: 'testbed-start' }); + container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-start' }); + return c.json({ taskId: task.id, title: task.title }); + } catch (error) { + // 起不来的原因要如实说(活动未装 / 入参不合契约 / 模型未绑),别让人对着空白猜 + const body: ApiError = { code: 'validation-failed', message: error instanceof Error ? error.message : String(error) }; + return c.json(body, 400); + } + }); + + /** + * 单活动试跑的观测面 —— 一屏看穿这一单。 + * + * 流程包那侧看 trace 树(节点 → 步骤);活动这侧要看的是**活动内部**: + * 每一拍干了什么、拿回了什么、被拒的理由、成果落到台账哪一行、卡问了谁什么。 + * 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。 + */ + app.get(testActivityStateRoute.path, async (c) => { + const denied = await governProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const taskId = taskIdSchema.parse(c.req.param('taskId')); + const task = await container.task.get(taskId); + + const memory = await container.memory.window(taskId, 200); + const drafts = await container.ledger.taskDrafts(taskId); + const orders = await container.humanTool.allWorkOrders(taskId); + + return c.json({ + taskId: task.id, + activityId: task.activityId, + title: task.title, + runtime: task.runtime, + ...(task.terminal === undefined ? {} : { terminal: task.terminal }), + ...(task.reason === undefined ? {} : { reason: task.reason }), + used: { steps: task.used.steps, tokens: task.used.tokens, humanCalls: task.used.humanCalls }, + caps: { steps: task.caps.steps, tokens: task.caps.tokens, humanCalls: task.caps.humanCalls }, + trail: memory.map((m) => ({ + at: m.at, + kind: m.kind, + title: m.title, + ...(m.detail === undefined ? {} : { detail: m.detail }), + })), + drafts: drafts.map((d) => ({ address: `${d.domain}.${d.object}`, key: d.key, patch: d.patch })), + cards: orders.map((o) => ({ + id: o.id, + mode: o.mode, + title: o.title, + done: o.done !== undefined, + ...(o.done?.result === undefined ? {} : { result: String(o.done.result) }), + })), + }); + }); + + + + /* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */ + + /** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */ + const UPLOAD_CAP = 8 * 1024 * 1024; + + app.post(uploadFileRoute.path, async (c) => { + const denied = await governProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const parsed = uploadFileRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues }; + return c.json(body, 400); + } + const { name, contentType, dataBase64 } = parsed.data; + const bytes = new Uint8Array(Buffer.from(dataBase64, 'base64')); + if (bytes.length === 0) { + const body: ApiError = { code: 'validation-failed', message: `${name} 解出来是 0 字节 —— base64 不合法或文件是空的` }; + return c.json(body, 400); + } + if (bytes.length > UPLOAD_CAP) { + const body: ApiError = { + code: 'validation-failed', + message: `${name} 有 ${(bytes.length / 1024 / 1024).toFixed(1)}MB,超过 8MB 上限 —— 这么大的件该走通道进来,不该从界面上传`, + }; + return c.json(body, 400); + } + // 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note + const got = extractText(name, contentType, bytes); + const me = await container.org.findByAccount(c.get('account')); + const ref = crypto.randomUUID(); + await container.db.execute(sql` + insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by) + values (${ref}, ${name}, ${contentType}, ${bytes.length}, ${Buffer.from(bytes)}, + ${got.text ?? null}, ${got.note}, ${me?.id ?? null})`); + return c.json({ + ref, + name, + contentType, + size: bytes.length, + ...(got.text === undefined ? {} : { text: got.text }), + note: got.note, + }); + }); + + app.get(downloadFileRoute.path, async (c) => { + const denied = await governProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const rows = await container.db.execute<{ name: string; content_type: string; bytes: Buffer }>( + sql`select name, content_type, bytes from sys.upload where id = ${c.req.param('id')}`, + ); + const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0]; + if (hit === undefined) { + const body: ApiError = { code: 'not-found', message: '这个文件不在了' }; + return c.json(body, 404); + } + return c.json({ + name: hit.name, + contentType: hit.content_type, + dataBase64: Buffer.from(hit.bytes).toString('base64'), + }); + }); + + /* ── 试跑日志:历次试跑的存档,按实例管理 ────────────────────────── */ + + /** 列表只给读数与计数 —— 一次试跑的轨迹上百条,列表页不需要正文 */ + app.get(listTestLogsRoute.path, async (c) => { + const denied = await governProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const rows = await container.db.execute(sql` + select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps, + jsonb_array_length(trail) as trail_count, + jsonb_array_length(drafts) as draft_count, + jsonb_array_length(cards) as card_count, + started_at, archived_at + from sys.testbed_run order by archived_at desc limit 200`); + return c.json({ runs: rowsOf(rows).map(toTestLogRow) }); + }); + + app.get(testLogDetailRoute.path, async (c) => { + const denied = await governProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const rows = await container.db.execute(sql` + select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps, + jsonb_array_length(trail) as trail_count, + jsonb_array_length(drafts) as draft_count, + jsonb_array_length(cards) as card_count, + trail, drafts, cards, started_at, archived_at + from sys.testbed_run where id = ${c.req.param('id')}`); + const hit = rowsOf(rows)[0]; + if (hit === undefined) { + const body: ApiError = { code: 'not-found', message: '这条试跑日志不存在(可能已被清掉)' }; + return c.json(body, 404); + } + return c.json({ ...toTestLogRow(hit), trail: hit.trail ?? [], drafts: hit.drafts ?? [], cards: hit.cards ?? [] }); + }); + + app.delete(deleteTestLogRoute.path, async (c) => { + const denied = await governProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const rows = await container.db.execute<{ id: string }>( + sql`delete from sys.testbed_run where id = ${c.req.param('id')} returning id`, + ); + return c.json({ deleted: rowsOf<{ id: string }>(rows).length }); + }); + + app.delete(clearTestLogsRoute.path, async (c) => { + const denied = await governProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const rows = await container.db.execute<{ id: string }>(sql`delete from sys.testbed_run returning id`); + return c.json({ deleted: rowsOf<{ id: string }>(rows).length }); }); /** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */ @@ -786,7 +1246,7 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai const faces = await Promise.all( cards.map(async (card) => { const manifest = await container.registry.manifestOf(card.activityId); - type FieldSpec = { type?: string; items?: { type?: string } }; + type FieldSpec = { type?: string; items?: { type?: string }; title?: string; format?: string }; const data = manifest.contract.input.data as { required?: string[]; properties?: Record; @@ -806,7 +1266,13 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai name, type: typeOf(spec), required: required.includes(name), + // 清单里写了中文名就用中文名 —— 表单上摆 `customerEmail` 是给机器看的 + ...(spec?.title === undefined ? {} : { label: spec.title }), + // `format: 'file'` = 这一格收的是**真文件**,界面该给文件选择器而不是文本框 + ...(spec?.format === undefined ? {} : { format: spec.format }), })), + // 包自带的样例集:试跑一个活动时照它预填(对着空表单编,编出来的多半跑不通) + samples: manifest.samples.map((sample) => ({ name: sample.name, input: sample.input })), // 必发口与选择组一并列出:调用方只凭口名与 schema 就能衔接下游 outputPorts: [ ...manifest.contract.output.mandatory, @@ -897,6 +1363,12 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai app.get(listLedgerObjectsRoute.path, async (c) => { const denied = await readData(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); + /* + * 性质随 `listObjects` **一次查询带回**(0026_object_meta)—— + * 这里从前是拿 12 个活动的清单现场重建的:`manifestOf` 是 select *, + * 拉该活动全部版本的清单加产物再整份 zod parse,光 quote-intake 就 798kB、26 版, + * 一次请求约 1.5MB JSON。列表口不该做这种事。 + */ return c.json({ objects: await container.ledger.listObjects() }); }); diff --git a/apps/api/src/app/routes/log-lines.ts b/apps/api/src/app/routes/log-lines.ts new file mode 100644 index 0000000..e7d65e9 --- /dev/null +++ b/apps/api/src/app/routes/log-lines.ts @@ -0,0 +1,65 @@ +/** + * 日志行的呈现口径 —— **主语进内容**(2026-07-31 用户定)。 + * + * 日志一行读起来该是「谁 · 做了什么 · 什么内容」: + * + * 16:25 [触发事件] 系统:一封邮件触发了这条流程,系统指定你负责「A1 · 询价受理」。 + * 16:26 [阶段结论] 任务助手:这封邮件来自青岛海洋电子,是新客户… + * 16:30 [员工确认] 柴浩:写入 customer.profile + * + * 四字动作与归侧由 `memory` 的域函数算出(纯函数,可重算); + * **只有人名算不出来** —— 域里零 IO,不认识人。所以域交出 `actorId`, + * 在这一层换成名字:换不到就退回「员工」,不编名字(界面不撒谎)。 + * + * 放在 routes 而不是 memory 模块里:这是**呈现**口径,不是事实口径。 + * 事实库里存的是 id,谁改了名、谁离职了,回看旧单都该按当时的 id 找当下的名字。 + */ +import type { LogLine } from '@modules/memory'; +import { staffIdSchema } from '@contracts'; +import type { OrgService } from '@modules/org'; + +export type DisplayLogLine = { + readonly id: string; + readonly at: string; + readonly side: LogLine['side']; + readonly sideLabel: string; + /** 谁干的 —— 日志只认三档:负责人(绿)· 智能体(蓝)· 系统(灰) */ + readonly who: LogLine['who']; + readonly action: string; + readonly title: string; + readonly summary?: string; +}; + +/** + * 把 `actorId` 换成名字并拼进内容。 + * + * 一次任务里出现的人极少(通常 0–2 个),所以按**出现过的 id** 逐个查, + * 不整表捞:整表捞在任务详情这条热路径上是白花的一次全表查询。 + */ +export async function displayLog(lines: readonly LogLine[], org: OrgService): Promise { + const ids = [...new Set(lines.flatMap((l) => (l.actorId === undefined ? [] : [l.actorId])))]; + const names = new Map(); + await Promise.all( + ids.map(async (id) => { + const parsed = staffIdSchema.safeParse(id); + if (!parsed.success) return; + const staff = await org.getStaff(parsed.data).catch(() => undefined); + if (staff !== undefined) names.set(id, staff.name); + }), + ); + + return lines.map((l) => { + // 人做的那些:换得到名字就用名字,换不到就写「员工」—— 不编,也不留空 + const actor = l.actor !== '' ? l.actor : (l.actorId === undefined ? '员工' : (names.get(l.actorId) ?? '员工')); + return { + id: l.id, + at: l.at, + side: l.side, + sideLabel: l.sideLabel, + who: l.who, + action: l.action, + title: `${actor}:${l.title}`, + ...(l.summary === undefined ? {} : { summary: l.summary }), + }; + }); +} diff --git a/apps/api/src/app/seed-activities.ts b/apps/api/src/app/seed-activities.ts new file mode 100644 index 0000000..c4bb9da --- /dev/null +++ b/apps/api/src/app/seed-activities.ts @@ -0,0 +1,138 @@ +/** + * 真活动包的安装 —— **经安装通道读文件,不经代码通道**(ADR-004 / 编码规范 §5.1)。 + * + * 活动包在系统外开发:`activities//` 里写清单与资产,`pnpm activities:build` + * 产出 `dist/{manifest.json,index.js}`。api 只读这两个文件 —— 与 activities 之间 + * 只有安装通道,没有 import(depcruise 的 `apps-not-into-activities` 在守)。 + * + * 这就是「系统外开发、安装即用」的机器形态:装的时候过两审(静态审读清单、 + * 回放审重演历史),清单里的台账声明顺带把表物化出来,资产随包播种。 + * + * 装不上不掀翻播种:如实报出来。活动包是可选件 —— 没构建产物就是没装, + * 不是数据库坏了(而假装装上了才是真的坏)。 + */ +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { activityManifestSchema } from '@contracts'; +import type { Container } from './container.ts'; +import { MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts'; + +/** 已开发完成、要随播种装上的真活动包(目录名 = 包目录,不是 activityId) */ +/* + * 真活动包 —— 从 `activities//dist/` 读文件装入,**不经 import**。 + * A2/A3 于 2026-07-31 转正:它们的案例模型已简化为「一张执行单办完」, + * 剧本与 A1 同一套机制,不再是治理台里那两个 viewTemplates 为空的占位槽。 + */ +const REAL_PACKAGES = [ + 'quote-intake', + 'quote-alternative', + 'quote-sourcing', + 'quote-pricing', + 'quote-negotiate', + 'quote-archive', +] as const; + +/** + * 活动包根目录。 + * + * 播种以 `apps/api` 为 cwd 跑(`pnpm --filter @app/api seed`),所以默认往上两级找; + * 容器化或换布局时用 `JCAI_ACTIVITIES_DIR` 顶掉 —— 路径是部署事实,不该写死在代码里。 + */ +function activitiesRoot(): string { + const override = process.env['JCAI_ACTIVITIES_DIR']; + return override === undefined || override.trim() === '' + ? resolve(process.cwd(), '..', '..', 'activities') + : resolve(override); +} + +export async function installRealActivities(container: Container): Promise { + const log = (line: string) => process.stdout.write(line + '\n'); + + /* + * **主数据先于活动物化**(2026-07-31)。 + * + * 客户 / 项目 / 供应商 / 市场四域不挂在任何活动名下:主数据不是谁的产出, + * 它先于活动存在、活动退役了它还在,活动只是读它做判断、提议更新它。 + * 挂在活动清单下的后果刚踩过 —— 那个活动一退役,它声明的表就成了没人认领的空壳。 + * + * 放在装活动**之前**:活动声明里若也提到同一张表(它要写的那几列), + * 声明合并是「最大化合并、只增不破坏」,平台给的全量结构先落地,活动只补自己那部分。 + */ + const applied = await container.ledger.applyDeclarations(MASTER_OBJECTS); + const conflicts = applied.flatMap((r) => r.conflicts); + log( + ` 主数据四域已物化:${MASTER_OBJECTS.length} 张表` + + // 冲突要如实报:静默吞掉的话,表结构和声明对不上而没人知道 + (conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`), + ); + /* + * 一位样例客户 —— 让「老客户认得出」那条路试得出来(见 `MASTER_SAMPLE_ROWS` 的注)。 + * 按主键写,重复播种是覆盖不是堆叠;写正式版(不是草稿层), + * 因为 A1 第一步查的就是正式版 —— 草稿只有本任务自己看得见。 + */ + for (const row of MASTER_SAMPLE_ROWS) { + await container.ledger.systemWrite({ + address: { domain: row.address.domain, object: row.address.object }, + key: { ...row.key }, + values: { ...row.values }, + cause: { kind: 'governance', ref: 'seed:master-sample' }, + }); + } + log(` 样例客户已就位:${MASTER_SAMPLE_ROWS[1]?.values.email ?? ''}(样例 2 用它试老客户那条路)`); + + const root = activitiesRoot(); + + for (const dir of REAL_PACKAGES) { + const dist = join(root, dir, 'dist'); + let manifestJson: string; + let entryJs: string; + try { + [manifestJson, entryJs] = await Promise.all([ + readFile(join(dist, 'manifest.json'), 'utf8'), + readFile(join(dist, 'index.js'), 'utf8'), + ]); + } catch { + log(` 活动包 ${dir} 没有构建产物(${dist})—— 先跑 pnpm activities:build;本次跳过`); + continue; + } + + const parsed = activityManifestSchema.safeParse(JSON.parse(manifestJson)); + if (!parsed.success) { + // 清单不合协议就别装:装进去的残清单会在起单那一刻才炸,那时更难查 + log(` 活动包 ${dir} 的清单不合协议:${parsed.error.issues.map((i) => `${i.path.join('.')} ${i.message}`).join('; ')}`); + continue; + } + + /* + * 安装恒新增、版本并存:同版本重装是 no-op(静默丢失 —— 踩过)。 + * 所以改了内容必须在包里升 `identity.packageVersion`,不然播种看着成功、内容还是旧的。 + */ + try { + const verdict = await container.registry.install(parsed.data, entryJs); + const version = parsed.data.identity.packageVersion; + /* + * **装的时候把声明的工具一并授权**(2026-07-31 补)。 + * + * 从前授权只在 `seed-mvp` 里发 —— 那是治理台的整套配置播种。 + * 于是经安装通道装进来的真活动包一件工具也调不动:A4 的说明书让它去取历史价, + * 清单里也白纸黑字声明了 `crm.history-price-read`,运行时却拒回 + * 「工具不在本活动白名单」,连拒五拍熔断(实测 A4 死于此)。 + * 「系统外开发、安装即用」里的「即用」,包含它声明过的那些工具能调得动; + * 声明了却不授权,等于装了一个结构上办不成事的活动。 + * + * 授权面**就是清单声明**,不多给一件:越权在结构上仍然不可能。 + */ + const declared = parsed.data.declarations.tools.map((t) => t.name); + await container.toolbox.setAllowlist(parsed.data.identity.activityId, declared); + log( + ` 活动包已装:${parsed.data.identity.name}(${parsed.data.identity.activityId}@${version})` + + (declared.length === 0 ? '' : ` · 已授权工具 ${declared.length} 件`) + + (verdict.staticFindings.length === 0 + ? '' + : ` —— 静态审提示:${verdict.staticFindings.map((f) => `[${f.severity}] ${f.message}`).join('; ')}`), + ); + } catch (error) { + log(` 活动包 ${dir} 未过安装两审:${error instanceof Error ? error.message : String(error)}`); + } + } +} diff --git a/apps/api/src/app/seed-mvp-data.ts b/apps/api/src/app/seed-mvp-data.ts index 1d0ff27..bbf3da3 100644 --- a/apps/api/src/app/seed-mvp-data.ts +++ b/apps/api/src/app/seed-mvp-data.ts @@ -1,9 +1,22 @@ /** - * 四域十九对象(docs/MVP示例工作流.html · 动态数据 + 静态底座;定价审批表落审批机制,不入台账)。 + * 主数据四域 —— **客户 / 项目 / 供应商 / 市场**(2026-07-31 用户定,照常规 CRM & ERP 的分法)。 * - * 划分原则(产品决策,2026-07-27):**流转单据(过程)归 project;沉淀资产按内容归 - * customer / intel / supplier**。声明只写活动**要用**的字段;物化走安装通道的声明合并 - * (最大化合并、只增不破坏),这里不建表 —— 建表是 ledger 的事。 + * 为什么主数据要单独立域、由**平台**播种,而不是跟着某个活动走: + * + * 台账原来只按「谁写的」分域(`quote_intake.*` / `quote_alternative.*` …)。那是**实现轴**: + * 六个活动各有一张 verdict,一单的故事散在六处;而真正需要治理的那几张 + * (客户档案、历史价)被挤进一个叫 `shared` 的抽屉 —— 那个名字描述的是机制(跨活动共享), + * 不是性质(主数据)。用机制命名,抽屉必然变杂物间。 + * + * 更要紧的是**所有权**:主数据不是任何一个活动的产出。客户档案先于活动存在、 + * 活动退役了它还在;活动只是**读它做判断、提议更新它**。 + * 所以它由平台播种物化,不挂在谁的清单下 —— 活动装了卸了,主数据都在。 + * + * 与三类性质的关系(见 `ledgerObjectKindSchema`):这里全是 `master`。 + * 单据(`record`)归各活动自己的域;派生指标(`derived`)另说 —— 它本不该手写。 + * + * **现在是空占位**:结构先立起来,还没有活动往里写。 + * 表建出来但一行数据都没有 —— 治理台「主数据」那一区能看见它们等着被接上。 */ import { objectDeclarationSchema } from '@contracts'; import type { LedgerFieldType, ObjectDeclaration } from '@contracts'; @@ -16,184 +29,180 @@ const f = (name: string, type: LedgerFieldType, label: string, primaryKey = fals masked: false, }); -const obj = (domain: string, object: string, label: string, fields: ReturnType[]) => - objectDeclarationSchema.parse({ domain, object, label, fields }); +/** 主数据一律 `kind: 'master'`:按业务实体分行,没有单标识 */ +const master = (domain: string, object: string, label: string, fields: ReturnType[]) => + objectDeclarationSchema.parse({ domain, object, label, kind: 'master', fields }); -export const MVP_OBJECTS: readonly ObjectDeclaration[] = [ - /* ── project:每一单的流转单据(位置签 输入/过程/输出) ─────────────── */ - obj('project', 'rfq_file', 'RFQ/BOM文件', [ - f('rfq_ref', 'text', '询价单号', true), - f('pn', 'text', 'PN', true), - f('qty_eau', 'text', '数量/EAU'), - f('brand', 'text', '厂牌'), - f('analog_flag', 'boolean', 'Analog'), - f('aec_cert', 'text', 'AEC认证'), - f('target_price', 'number', '目标价'), - f('lead_time', 'text', '交期'), - ]), - obj('project', 'intake_record', '统一受理记录', [ - f('customer_code', 'text', '客户码', true), - f('pn', 'text', 'PN', true), - f('brand', 'text', '厂牌'), - f('eau', 'text', 'EAU'), - f('type_tag', 'text', 'Type标'), - f('target_price', 'number', '目标价'), - f('status', 'text', '状态'), - ]), - obj('project', 'pn_class', '料号分类表', [ - f('pn', 'text', 'PN', true), - f('type_tag', 'text', 'Type标'), - f('basis', 'text', '分级依据'), - f('history_hit', 'boolean', '历史价命中'), - f('review_note', 'text', '复核结论'), - ]), - /* - * 受理结论 —— **一单一行**(主键只有询价单号),不是一料一行。 - * - * 它承载 A1 出口的两枚去向旗。为什么必须单独一个对象、不能挂在按料号分行的 - * 「统一受理记录」上:出口载荷的标量字段取自 `landed.flat`,而 `flat` 是把这一单 - * **所有**落地行 reduce 展开成一个平对象(`wiring/engines.ts`)。旗挂在按料号分的行上, - * 六个料号就有六行同名列,**最后合并的那行赢** —— 而归并规则是「任一条命中 C 即需选型」, - * last-write-wins 会静默给出错的去向,网关照着错值分叉,没人看得出来。 - * - * 一单一行就没有这个歧义:G1 / G2 读的是同一行的同一个值。 - * 逐料号的判定仍写「料号分类表」,这里只放归并后的结论。 - */ - obj('project', 'intake_verdict', '受理结论', [ - f('rfq_ref', 'text', '询价单号', true), - f('need_alternative', 'boolean', '需选型'), - f('need_sourcing', 'boolean', '需寻源'), - f('c_count', 'number', 'C类条数'), - f('status', 'text', '状态'), - ]), - obj('project', 'alt_request', '替代评估请求', [ - f('material', 'text', '初选物料', true), - f('chip_type', 'text', '芯片类型'), - f('spec_params', 'text', '参数规格'), - f('scenario', 'text', '应用场景'), - f('requester', 'text', '需求人'), - ]), - obj('project', 'inquiry_request', '询价请求', [ - f('pn', 'text', 'PN', true), - f('brand', 'text', 'Brand'), - f('era', 'text', 'ERA'), - f('project_stage', 'text', '项目阶段'), - f('accept_alt', 'boolean', '接受替代'), - f('unit_usage', 'text', '单机用量'), - f('tp', 'number', 'TP'), - f('scenario', 'text', '场景'), - ]), - obj('project', 'supplier_floor', '供应商底价表', [ - f('pn', 'text', 'PN', true), - f('supplier', 'text', '供应商', true), - f('floor_price', 'number', '底价'), - f('moq', 'text', 'MOQ'), - f('lead_time', 'text', '交期'), - ]), - obj('project', 'alt_benchmark', '替代对标表', [ - f('original_pn', 'text', '原型号', true), - f('candidate_pn', 'text', '候选型号', true), - f('alt_grade', 'text', '替代等级'), - f('param_diff', 'text', '参数差异项'), - f('diff_risk', 'text', '差异风险'), - f('mod_eval', 'text', '改动评估'), - f('verify_advice', 'text', '验证建议'), - ]), - obj('project', 'sourcing_compare', '寻源比价表', [ - f('pn', 'text', 'PN', true), - f('supplier', 'text', '供应商', true), - f('quote_price', 'number', '报价'), - f('moq', 'text', 'MOQ'), - f('lead_time', 'text', '交期'), - f('channel_grade', 'text', '渠道等级'), - f('condition', 'text', '成色'), - f('verdict', 'text', '结论'), - ]), - obj('project', 'quote_draft', '报价草案', [ - f('pn', 'text', 'PN', true), - f('proposed_price', 'number', '拟报单价'), - f('margin', 'number', '毛利率'), - f('deviation', 'number', '偏离度'), - ]), - obj('project', 'formal_quote', '正式报价单', [ - f('pn', 'text', 'PN', true), - f('unit_price_cny', 'number', '单价CNY'), - f('lead_time', 'text', '交期'), - f('spq', 'text', 'SPQ'), - f('moq', 'text', 'MOQ'), - f('trade_terms', 'text', '贸易条款'), - f('valid_until', 'date', '有效期'), - ]), - obj('project', 'followup', '跟进提醒', [ - f('quote_no', 'text', '报价单号', true), - f('customer_code', 'text', '客户'), - f('d3', 'date', 'D+3'), - f('d7', 'date', 'D+7'), - f('reply_status', 'text', '回复状态'), - ]), - obj('project', 'landing_stats', '报价落地率统计', [ - f('period', 'text', '期', true), - f('landing_rate', 'number', '落地率'), - f('type_a_hit', 'number', 'TypeA命中'), - f('d3_reply_rate', 'number', 'D+3回复率'), - f('return_rate', 'number', '退回率'), - ]), - - /* ── customer:客户信息 ────────────────────────────────────────────── */ - obj('customer', 'profile', '客户资料库', [ - f('customer_code', 'text', '客户码', true), +export const MASTER_OBJECTS: readonly ObjectDeclaration[] = [ + /* ── 客户:谁在买 ──────────────────────────────────────────────────── */ + master('customer', 'profile', '客户档案', [ + f('customer_code', 'text', '客户编码', true), + f('name', 'text', '客户名称'), f('grade', 'text', '客户等级'), - f('tags', 'text', '画像标签'), - f('payment_terms', 'text', '付款条款'), - f('industry', 'text', '行业属性'), - f('risk_level', 'text', '风险等级'), - f('annual_purchase', 'number', '年采购额'), + f('industry', 'text', '所属行业'), + f('region', 'text', '区域'), + f('owner', 'text', '负责 BD'), + f('payment_terms', 'text', '付款条件'), + f('credit_limit', 'number', '信用额度'), + /* 画像标签:价格敏感度、决策周期、常用条款 —— A7 归档时回写的就是这一列 */ + f('profile_tags', 'text', '画像标签'), + f('status', 'text', '状态'), + ]), + master('customer', 'contact', '客户联系人', [ + f('customer_code', 'text', '客户编码', true), + f('contact_code', 'text', '联系人编码', true), + f('name', 'text', '姓名'), + f('title', 'text', '职务'), + /* 邮箱与电话是个人信息 —— 主数据里必须标脱敏,治理面照此裁剪 */ + { ...f('email', 'text', '邮箱'), masked: true }, + { ...f('phone', 'text', '电话'), masked: true }, + ]), + master('customer', 'price_terms', '客户价格条款', [ + f('customer_code', 'text', '客户编码', true), + f('category', 'text', '品类', true), + f('target_margin', 'number', '目标毛利'), + f('discount', 'number', '折扣'), + f('valid_until', 'date', '有效期至'), ]), - /* ── intel:市场与销售情报 ─────────────────────────────────────────── */ - obj('intel', 'history_price', '历史价格情报', [ - f('pn', 'text', 'PN', true), - f('quoted_at', 'timestamp', '报价日期', true), - f('brand', 'text', '厂牌'), - f('unit_price_cny', 'number', '单价CNY'), - f('lead_time', 'text', '交期'), - f('moq', 'text', 'MOQ'), - f('valid_until', 'date', '有效期'), + /* ── 项目:为什么买 —— 客户的产品项目,反复询价的载体 ───────────────── */ + master('project', 'profile', '项目档案', [ + f('project_code', 'text', '项目编码', true), + f('customer_code', 'text', '所属客户'), + f('name', 'text', '项目名称'), + /* BOM 早期 / BOM 后期 / 量产 —— A3 的询价打法就按这个分 */ + f('stage', 'text', '项目阶段'), + f('annual_volume', 'integer', '年用量预估'), + f('start_at', 'date', '立项日期'), + f('owner', 'text', '负责人'), + f('status', 'text', '状态'), ]), - obj('intel', 'chip_spec', '芯片规格书', [ - f('pn', 'text', 'PN', true), - f('key_params', 'text', '关键参数'), + master('project', 'bom', '项目 BOM', [ + f('project_code', 'text', '项目编码', true), + f('pn', 'text', '料号', true), + f('qty_per_unit', 'number', '单机用量'), + f('eau', 'integer', '年需求量'), + f('designed_in', 'boolean', '已导入设计'), + f('status', 'text', '状态'), + ]), + + /* ── 供应商:从哪买 ───────────────────────────────────────────────── */ + master('supplier', 'profile', '供应商档案', [ + f('supplier_code', 'text', '供应商编码', true), + f('name', 'text', '供应商名称'), + /* 原厂 / 授权代理 / 贸易商 —— 「现货场景不得选无库存转单商」判据读的就是它 */ + f('type', 'text', '供应商类型'), + f('region', 'text', '区域'), + f('rating', 'text', '评级'), + f('payment_terms', 'text', '账期'), + f('status', 'text', '状态'), + ]), + master('supplier', 'line_card', '代理线卡', [ + f('supplier_code', 'text', '供应商编码', true), + f('brand', 'text', '品牌', true), + f('authorized', 'boolean', '是否授权'), + f('lead_time_typ', 'text', '常规交期'), + ]), + master('supplier', 'contact', '供应商联系人', [ + f('supplier_code', 'text', '供应商编码', true), + f('contact_code', 'text', '联系人编码', true), + f('name', 'text', '姓名'), + { ...f('email', 'text', '邮箱'), masked: true }, + { ...f('phone', 'text', '电话'), masked: true }, + ]), + + /* ── 市场:买什么、行情如何 ────────────────────────────────────────── + 料号主数据放这一域:ERP 里它常自成一域(物料主数据), + 但这门生意关心的是它**在市场上什么状态**(生命周期、有没有货、什么价), + 跟行情摆在一起更顺手。要独立成域也说得通,记在这儿备考。 */ + master('market', 'part', '料号主数据', [ + f('pn', 'text', '料号', true), + f('brand', 'text', '品牌'), + f('category', 'text', '品类'), f('package', 'text', '封装'), - f('version_date', 'date', '版本日期'), - ]), - obj('intel', 'public_select', '公网选型库', [ - f('alt_relation', 'text', '替代关系', true), - f('param_compare', 'text', '参数对比'), - f('spec_ref', 'text', '规格书'), + /* Active / NRND / EOL —— A1 三分类与 A2 选型都读它 */ f('lifecycle', 'text', '生命周期'), - f('price_ref', 'text', '价格参考'), + f('rohs', 'boolean', 'RoHS'), + f('datasheet_url', 'text', '规格书链接'), ]), - - /* ── supplier:供应商信息 ─────────────────────────────────────────── */ - obj('supplier', 'channel_dir', '供应商渠道目录', [ - f('supplier', 'text', '供应商', true), - f('channel_type', 'text', '渠道类型'), - f('brand_lines', 'text', '品牌线'), - f('contact', 'text', '联系人'), + master('market', 'price_history', '历史价格情报', [ + f('pn', 'text', '料号', true), + f('quoted_at', 'date', '报价日期', true), + f('customer_code', 'text', '客户编码'), + f('unit_price', 'number', '成交/报出单价'), + f('lead_time', 'text', '交期'), + f('lost_reason', 'text', '未成交原因'), + f('competitor_price', 'number', '竞对价位'), ]), - obj('supplier', 'dead_stock', '呆滞库存表', [ - f('pn', 'text', 'PN', true), - f('stock_qty', 'number', '库存量'), - f('stock_age', 'text', '库龄'), - f('cost_price', 'number', '成本价'), + master('market', 'quote_index', '市场行情', [ + f('pn', 'text', '料号', true), + f('observed_at', 'date', '观测日期', true), + f('source', 'text', '来源'), + f('price', 'number', '市场价'), + f('stock', 'integer', '可见库存'), + f('trend', 'text', '走势'), ]), ]; -export function declarationsFor(objects: readonly `${string}.${string}`[]): ObjectDeclaration[] { - return objects.map((key) => { - const [domain, object] = key.split('.'); - const hit = MVP_OBJECTS.find((o) => o.domain === domain && o.object === object); - if (hit === undefined) throw new Error(`未知对象:${key} —— 先在 MVP_OBJECTS 登记`); - return hit; - }); -} +/** 四域的呈现次序 —— 谁在买 → 为什么买 → 从哪买 → 买什么 */ +export const MASTER_DOMAINS = ['customer', 'project', 'supplier', 'market'] as const; + +/** + * **样例客户** —— 让「老客户」那条路试得出来。 + * + * 主数据四域建出来是空的,而 A1 的第一个状态有两条路: + * 认得出客户就直接过、认不出才请人建档。库里一个客户都没有的话, + * 两条样例跑起来一模一样 —— **第二条路根本试不到**。 + * + * 所以随主数据一起播一位客户:样例 2 的来信就用他的邮箱发。 + * 只播这一位,且是**幂等的按主键写**:它是让样例跑得起来的最小夹具, + * 不是往主数据里塞演示数据 —— 真实客户由 A1 建档时一位一位落进来。 + */ +export const MASTER_SAMPLE_ROWS = [ + /* + * **Itelma** —— 老客户测试案例的那一位(照 `JCAI_PRD/docs/活动执行页/` 两份目标件)。 + * 俄罗斯车规电子、项目制;客户码 CU-0142 就是需求件上写的「已命中客户资料库」那一条。 + */ + { + address: { domain: 'customer', object: 'profile' }, + key: { customer_code: 'CU-0142' }, + values: { + customer_code: 'CU-0142', + name: 'Itelma', + grade: 'A', + industry: '车规电子', + region: '俄罗斯', + payment_terms: 'EXW · CNY 含税', + profile_tags: '项目制 · 按项目批量询价 · 认证要求严(AEC-Q200)', + status: 'active', + }, + }, + { + address: { domain: 'customer', object: 'contact' }, + key: { customer_code: 'CU-0142', contact_code: 'CU-0142-01' }, + values: { + customer_code: 'CU-0142', + contact_code: 'CU-0142-01', + name: 'Ekaterina Volkova', + title: '采购部', + // **认人的钥匙**:老客户样例的来信就从这个邮箱发,A1 第一步照它认得出 + email: 'e.volkova@itelma.ru', + }, + }, + /* + * 项目档案 —— 这一单询的是 MD14.1 这个项目的料。 + * 播它是因为需求件上「项目制」那句话得有出处:客户属性说得出「按项目询价」, + * 库里就该有那个项目,否则那句话是装出来的。 + */ + { + address: { domain: 'project', object: 'profile' }, + key: { project_code: 'MD14.1' }, + values: { + project_code: 'MD14.1', + customer_code: 'CU-0142', + name: 'MD14.1 车身控制器', + stage: 'BOM 后期', + status: 'active', + }, + }, +] as const; diff --git a/apps/api/src/app/seed-mvp-flow.ts b/apps/api/src/app/seed-mvp-flow.ts index 3e1d623..ed9ef5b 100644 --- a/apps/api/src/app/seed-mvp-flow.ts +++ b/apps/api/src/app/seed-mvp-flow.ts @@ -30,15 +30,20 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ version: 1, state: 'active', callInterface: { + /* + * 起单入参 = **那封信**,与 A1 的契约一致(2026-07-31 跟着改)。 + * 从前这里要 `customerCode` + `rfqRef`,而 A1 早已改成收一封原始来函 —— + * 两边对不上的后果实测过:通道抽不出这两个键,基座当场发补全卡, + * 整条流 0 拍停在那儿等人,而信本身是完整的。 + */ input: { type: 'object', - required: ['customerCode', 'rfqRef'], + required: ['customerEmail', 'subject', 'body'], properties: { - customerCode: { type: 'string' }, - rfqRef: { type: 'string' }, + customerEmail: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' }, - quotedAt: { type: 'string' }, + attachments: { type: 'array' }, }, }, output: { type: 'object', properties: { landingRate: { type: 'number' } } }, @@ -50,10 +55,16 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ */ nodes: [ { id: 'start', kind: 'start', label: '客户RFQ询价', layout: { row: 0, col: 0, x: 30, y: 70 } }, - activityNode('A1', '客户RFQ受理', 'rfq-intake', 1, 0, 110, 60), - activityNode('A2', '替代选型', 'alt-selection', 2, 0, 290, 60), - activityNode('A3', '寻源询价', 'sourcing-inquiry', 3, 0, 470, 60), - activityNode('A4', '成本定价', 'cost-pricing', 4, 1, 110, 310), + /* + * **六个节点全部绑真活动包**(2026-07-31)—— 从前只有 A1 是真的,其余绑的是 + * `alt-selection` / `sourcing-inquiry` / `cost-pricing` / `quote-negotiation` / + * `intel-archive` 这些槽位 id。槽位退役之后再不改这里,整条流会指向一堆装不出来的活动。 + * 编排以**这份代码**为准,不以示例文档为准。 + */ + activityNode('A1', '询价受理', 'quote-intake', 1, 0, 110, 60), + activityNode('A2', '替代选型', 'quote-alternative', 2, 0, 290, 60), + activityNode('A3', '寻源询价', 'quote-sourcing', 3, 0, 470, 60), + activityNode('A4', '成本定价', 'quote-pricing', 4, 1, 110, 310), { id: 'A5', kind: 'approval', @@ -68,8 +79,8 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ nudge: { steps: [{ after: 'PT20M', action: 'nudge' }], deadline: 'PT30M' }, layout: { row: 1, col: 5, x: 290, y: 310 }, }, - activityNode('A6', '报价议价', 'quote-negotiation', 6, 1, 470, 310), - activityNode('A7', '情报归档', 'intel-archive', 7, 2, 630, 410), + activityNode('A6', '报价议价', 'quote-negotiate', 6, 1, 470, 310), + activityNode('A7', '情报归档', 'quote-archive', 7, 2, 630, 410), { id: 'end_won', kind: 'end', label: '转订单履约', layout: { row: 1, col: 7, x: 630, y: 320 } }, { id: 'end_arch', kind: 'end', label: '归档闭环', layout: { row: 2, col: 8, x: 790, y: 420 } }, ], @@ -191,7 +202,7 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ anchors: [], nodes: [ { id: 'start', kind: 'start', label: '客户RFQ询价', x: 40, y: 80, lx: 54, ly: 112 }, - { id: 'A1', kind: 'activity', label: '客户RFQ受理', code: 'A1', x: 120, y: 70 }, + { id: 'A1', kind: 'activity', label: '询价受理', code: 'A1', x: 120, y: 70 }, { id: 'A2', kind: 'activity', label: '替代选型', code: 'A2', x: 300, y: 70 }, { id: 'A3', kind: 'activity', label: '寻源询价', code: 'A3', x: 480, y: 70 }, { id: 'A4', kind: 'activity', label: '成本定价', code: 'A4', x: 120, y: 320 }, @@ -307,6 +318,21 @@ const TRIGGER_EVENT = triggerConfigSchema.parse({ rule: 'channelId = "ch-rfq-mail" and (contains(subject, "询价") or contains(subject, "RFQ"))', eventType: 'data', }, + /* + * 装配映射 —— **每个键都得显式映**(2026-07-31 补,起单一直缺字段的根因)。 + * + * 事件触发的 scope **不打底**:`assembleInput` 只在 `kind: 'registration'` 时 + * 把 scope 整个铺进去,事件那一路从空对象起步、只认 mapping。 + * 所以「同名就会自动带过去」是错觉 —— 实测 subject / body 与邮件载荷同名, + * 照样报「缺 subject、body」,整条流第一个节点就停在补全卡上。 + * 目标键是 A1 契约的那四样(那封信),源是通道抽出来的邮件载荷字段名。 + */ + mapping: [ + { target: 'customerEmail', source: { from: 'expression', sfeel: 'from' } }, + { target: 'subject', source: { from: 'expression', sfeel: 'subject' } }, + { target: 'body', source: { from: 'expression', sfeel: 'body' } }, + { target: 'attachments', source: { from: 'expression', sfeel: 'attachments' } }, + ], }); /** 登记触发器:人确认后起单,「登记人即来源」—— 表单由调用接口 schema 自动渲染 */ diff --git a/apps/api/src/app/seed-mvp-slots.ts b/apps/api/src/app/seed-mvp-slots.ts index c75f46f..1cef7c5 100644 --- a/apps/api/src/app/seed-mvp-slots.ts +++ b/apps/api/src/app/seed-mvp-slots.ts @@ -1,619 +1,49 @@ /** - * 六个活动槽位(docs/MVP示例工作流.html §1.1–1.7)—— **契约就绪、实现留白**。 + * 六个活动槽位的**退役名单** —— 槽位时代到此为止(2026-07-31 清账)。 * - * 槽位 = 活动的契约面:身份 / 七件契约(入参、出口)/ 声明(域对象、工具名)/ 业务模型骨架。 - * 与工具的解耦就在声明:`declarations.tools` 只写**名**,注册、连接值、白名单全在治理侧。 - * A5 定价审批是**内置审批点**(审批不是任务,零开发、不入库),不在槽位之列。 + * 这个文件从前装着六份「契约就绪、实现留白」的占位清单:编排层跑通期, + * 靠它们让流走得动。六个真活动包(`activities/quote-*`)全部转正之后, + * 占位清单就该走 —— 留着它们不是「多一个备份」,是**三处会骗人的重影**: * - * 实现待底座完备后逐个开发:届时以新包版本经安装通道换入,槽位契约不动。 + * · 活动库与试跑台上并排两个同名活动,选错一个就试了个空壳; + * · 它们声明的 `project.*` / `intel.*` / `customer.*` / `supplier.*` 台账, + * 与真活动包的 `quote_*.*` 是**同一批业务对象的两套地址** —— + * 数据域那一屏上「统一受理记录」「替代对标表」「报价草案」各有两份, + * 人分不出该看哪一个(实测 12 对重影,旧的那 19 张表一行数据都没有); + * · 工具授权面也各留一份,治理台的授权清单跟着翻倍。 * - * **2026-07-29:活动不再分范式,一律案例模型**(用户定)。flow 那一路连同 flow-engine - * 整个退役,所以占位骨架也从「机器步落台账 + 一张验收单」改成**案例骨架**: - * 一个「办理」阶段挂着 SOP 每一件事(全部人工激活)+ 一张收口验收。 - * 代价写在明处:案例由 Agent 每拍决策,**没有模型可用时占位活动一步也走不动** —— - * 这是范式统一的直接后果,不是缺陷;真实现时以新包版本换入真案例模型。 + * 所以这里只剩一件事:**把它们请出去**。名单里的活动播种时一律卸载; + * 卸不掉就如实说(多半是还有在途任务引着它),不掀翻播种。 + * 物化出来的表不由这里删 —— 台账的规矩是「只增不破坏」, + * 清空壳表是一次性的人工决定(2026-07-31 已在开发库执行),不该写进播种。 */ -import { createHash } from 'node:crypto'; -import { activityManifestSchema } from '@contracts'; -import type { ActivityManifest } from '@contracts'; import type { Container } from './container.ts'; -import { declarationsFor } from './seed-mvp-data.ts'; -/** - * 槽位占位入口 —— **不是实现**:装载它只会得到「未实现」的诚实报错。 - * mock 只许在外界:这里绝不发既定载荷装作跑过(README 平移规则)。 - */ -export const SLOT_ARTIFACT = [ - '// 活动槽位:契约已就绪,实现待底座完备后逐个开发。', - "throw new Error('活动尚未实现 —— 这是槽位,契约见治理台活动库');", -].join('\n'); - -const checksum = createHash('sha256').update(SLOT_ARTIFACT).digest('hex'); - -/** - * 占位期的业务模型骨架 —— **案例骨架**:SOP 的每件事一个人工任务,末尾一张收口验收。 - * - * 为什么全部 `manualActivationRule`:这些事都触达世界(问客户、查外部、写台账), - * AM 4.1 原理一说得明白 —— 触达世界的项 Enabled ≠ Active,要人按一下才动。 - * 占位期尤其如此:占位不替活动做任何判断,它连「该不该做这件事」都不替人定。 - * - * 收口验收单是活动 release 的唯一出口:没有它,人把前面的事都办完,任务仍不结案。 - */ -const placeholderSkeleton = (sop: readonly string[], name: string) => { - const items = sop - .map( - (label, i) => - ` \n` + - ` \n` + - ` `, - ) - .join('\n'); - return { - cmmnXml: - `\n` + - `\n` + - ` \n` + - ` \n` + - ` \n` + - ` \n` + - ` \n` + - ` \n` + - ` \n` + - ` create\n` + - ` \n` + - ` \n` + - ` \n` + - items.replace(/^ {6}/gm, ' ') + - `\n \n` + - ` \n` + - ` \n` + - ` \n` + - ` \n` + - ` \n` + - ` PT12H\n` + - ` \n` + - ` \n` + - ` \n` + - ``, - narrative: - `§0 开案:${name}的**契约已就绪、实现留白**。这份说明书是槽位占位,不是业务方法 ——\n` + - `它只交代这件事按 MVP 文档分几步办(${sop.join(' → ')}),不替活动做任何判断。\n\n` + - `办理:每一件事都要人按一下才动(触达世界的项恒需人工激活)。\n` + - `占位期没有自动步:占位若替人落一行台账,下游读到的就是一个编出来的事实。\n\n` + - `§Z 结案:事办完、收口验收签字放行即结案。真实现时以新包版本换入真案例模型,\n` + - `这份说明书连同 CMMN 一并被替换 —— 契约面不动。`, - dmn: { drd: [], tables: [] }, - }; -}; - -const identity = (activityId: string, name: string) => ({ - activityId, - name, - /* - * 包版本 —— 改了槽位携带的东西就必须往上走。 - * - * 「安装恒为新增、版本并存」是硬不变量:同 (activityId, version) 再装是 no-op, - * 库里的 manifest 一个字都不会动。所以 0.1.0 期间把卡面从空卡改成自述卡, - * 重播种会「静默成功」—— 打印「已装入」,库里还是旧的(实测踩到)。 - * - * 0.2.0:步骤卡面自述(标题 / 要你答 / 步序依据)。 - * 0.3.0:人工调用预算按各槽位自己的步数给 —— 共用 3 会让 6 步的 A1 走到第 3 步就 - * `exhausted` 终态、结构上永远走不完(实测)。授权面申报值同源。 - * 0.4.0:flow 骨架末步追加**收口验收**—— 没有它活动永远不 release, - * 六步答完就卡在「等人却没东西给人答」(实测)。 - * 0.5.0:A1/A2 声明「受理结论」对象(两枚去向旗有处落地);A2 入参加 rfqRef。 - * 0.6.0:占位期骨架改**机器步落台账 + 一张验收单**(用户定:只留验收要人签)。 - * 六个槽位一律 flow 范式;预算固定 humanCalls: 2。 - * 0.6.1:修验收步的出参映射 —— 键是**台账列名**(小写蛇形),不是回传字段名。 - * 0.7.0:A6 的三个去向改**选择组**(契约 0.2.0)—— outcome 由放行选口给出,G4 才判得了。 - * 0.8.0:对齐 Mastra 数据流 —— A2 的入参 `material` 更名 `pn`(它就是 PN): - * 边不再带映射,数据按字段名流动,上游发的名就得是下游收的名。 - * 0.9.0:范式统一(用户定)—— 业务模型从步序声明改为**案例模型**, - * 身份里的 `paradigm` 字段随之消失;占位不再自动落台账。 - * 在途任务按启动时版本跑完,不受影响。 - */ - packageVersion: '0.9.0', - category: '统一报价', - sdkVersion: '0.1.0-m5', - checksum, -}); - -const harness = { memoryWindow: 20, stallAfter: 'PT45M', nudge: [] }; - -/** 七件契约里逐槽位相同的空段 —— 槽位期没有必交付物/验收细则,实现期再补 */ -const contractRest = { requiredDeliverables: [], acceptanceCriteria: [], prohibitions: [] }; - -/** - * 各槽位的步/项清单 —— **预算与骨架共用同一个数组**,数不上不会漂移。 - * flow 是声明式人类步(一步一张工单);loop 的项由 Agent 临场推进, - * 最坏情况一项问一次人,所以也按项数给预算。 - */ -const STEPS = { - rfqIntake: ['接收登记', '追问补全信息', '逐条解析归一', '查历史价', '逐条定状况标', '定去向与派单'], - altSelection: ['替代对标表', '初筛清单'], - sourcingInquiry: ['寻源比价表', '供应商回价单'], - costPricing: ['取价基准', '逐条定价'], - quoteNegotiation: ['正式报价单'], - intelArchive: ['结果登记', '画像更新', '落地率统计'], -} as const; - -/** - * 预算 —— `humanCalls` 必须**够这个槽位实际要问人的次数**。 - * - * 曾经六个槽位共用 `humanCalls: 3`,而那时 A1 有 6 个人类步:走到第 3 步就 `exhausted` - * 终态、工作流转 abnormal,活动**结构上永远走不完**(实测:used 3 / cap 3,停在 A1)。 - * 现在占位期是**机器步 + 一张验收单**,只问人一次,所以固定给 2(1 张验收 + 1 次余量)。 - * 这条约束本身不随形态变:实现期活动自己声明真实预算时,实际要问人的次数 ≤ humanCalls。 - */ -const budgetFor = () => ({ - steps: 40, - tokens: 200_000, - activeMinutes: 60, - humanCalls: 2, -}); - -/** - * 授权面申报的人工调用预算 —— 与运行时真正吃的那个数**同源**。 - * 运行时只认 `resources.budget.humanCalls`(见 `wiring/engines.ts` 的 `caps`); - * `authorization.humanCallBudget` 只给治理台显示。两者不一致,治理台就会摆一个假数。 - */ -const authorizationFor = () => ({ - discretion: [], - preauthorizedActions: [], - humanCallBudget: budgetFor().humanCalls, -}); - -export const MVP_SLOTS: readonly ActivityManifest[] = [ - /* ── A1 客户RFQ受理(SOP · BD) ──────────────────────────────────── */ - activityManifestSchema.parse({ - identity: identity('rfq-intake', '客户RFQ受理'), - contract: { - activityId: 'rfq-intake', - version: '0.1.0', - input: { - data: { - type: 'object', - required: ['customerCode', 'rfqRef'], - properties: { - customerCode: { type: 'string' }, - rfqRef: { type: 'string' }, - subject: { type: 'string' }, - body: { type: 'string' }, - quotedAt: { type: 'string' }, - }, - }, - messageAllowed: true, - }, - output: { - mandatory: [ - { - name: 'intake', - description: '统一受理记录(打标)+ 两枚去向旗', - payload: { - type: 'object', - required: ['records', 'needAlternative', 'needSourcing'], - properties: { - records: { type: 'array' }, - pn: { type: 'string' }, - type_tag: { type: 'string' }, - needAlternative: { type: 'boolean' }, - needSourcing: { type: 'boolean' }, - }, - }, - }, - ], - selective: [], - }, - ...contractRest, - authorization: authorizationFor(), - resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['BD'], budget: budgetFor() }, - }, - declarations: { - models: [], - listeners: [], - harness, - tools: [{ name: 'crm.history-price-read' }], - ledger: declarationsFor([ - 'project.rfq_file', - 'project.intake_record', - 'project.pn_class', - // 受理结论:两枚去向旗落在这里(一单一行,G1/G2 读同一个值) - 'project.intake_verdict', - 'project.alt_request', - 'project.inquiry_request', - 'intel.history_price', - 'customer.profile', - ]), - }, - assets: { - businessModel: placeholderSkeleton(STEPS.rfqIntake, '客户RFQ受理'), - skills: [], - viewTemplates: [], - cardSchemas: {}, - distillHooks: [], - }, - samples: [ - { - name: '契约样例:受理一单', - input: { customerCode: 'C001', rfqRef: 'RFQ-001' }, - expect: { ports: ['intake'] }, - }, - ], - entry: { file: 'index.js', checksum }, - }), - - /* ── A2 替代选型(Case · FAE) ──────────────────────────────────── */ - activityManifestSchema.parse({ - identity: identity('alt-selection', '替代选型'), - contract: { - activityId: 'alt-selection', - // 0.3.0:material 更名 pn(对齐 Mastra 名流动 —— 上游 A1 发的就是 pn) - version: '0.3.0', - input: { - data: { - type: 'object', - required: ['pn', 'rfqRef'], - properties: { - pn: { type: 'string' }, - chip_type: { type: 'string' }, - spec_params: { type: 'string' }, - scenario: { type: 'string' }, - needSourcing: { type: 'boolean' }, - /* - * 询价单号。声明它是为了**不偷渡**:边上映射一个契约没声明的字段, - * 闭合校验的类型检查会因 `targetType === undefined` 直接跳过 —— 不报错, - * 但那是默许不是正当,而且下一个读代码的人无从知道 A2 还收这个字段。 - */ - rfqRef: { type: 'string' }, - }, - }, - messageAllowed: true, - }, - output: { - mandatory: [ - { - name: 'benchmark', - description: '替代对标表(已签字)+ needSourcing 原样带出(G2 在本路径读它)', - payload: { - type: 'object', - required: ['rows', 'needSourcing'], - properties: { rows: { type: 'array' }, needSourcing: { type: 'boolean' } }, - }, - }, - ], - selective: [], - }, - ...contractRest, - authorization: authorizationFor(), - resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['FAE'], budget: budgetFor() }, - }, - declarations: { - models: [], - listeners: [], - harness, - tools: [{ name: 'crm.history-price-read' }], - // 受理结论同在声明里:A2 要把 needSourcing 回写**同一行**,去向只有一个出处 - ledger: declarationsFor([ - 'intel.chip_spec', - 'intel.public_select', - 'project.alt_benchmark', - 'project.intake_verdict', - ]), - }, - assets: { - businessModel: placeholderSkeleton(STEPS.altSelection, '替代选型'), - skills: [], - viewTemplates: [], - cardSchemas: {}, - distillHooks: [], - }, - samples: [ - { - name: '契约样例:一件替代评估', - input: { pn: 'LM358DR' }, - expect: { ports: ['benchmark'] }, - }, - ], - entry: { file: 'index.js', checksum }, - }), - - /* ── A3 寻源询价(Case · PRM) ──────────────────────────────────── */ - activityManifestSchema.parse({ - identity: identity('sourcing-inquiry', '寻源询价'), - contract: { - activityId: 'sourcing-inquiry', - version: '0.1.0', - input: { - data: { - type: 'object', - required: ['pn'], - properties: { - pn: { type: 'string' }, - brand: { type: 'string' }, - era: { type: 'string' }, - project_stage: { type: 'string' }, - accept_alt: { type: 'boolean' }, - }, - }, - messageAllowed: true, - }, - output: { - mandatory: [ - { - name: 'compare', - description: '寻源比价表 + 供应商底价表', - payload: { - type: 'object', - required: ['rows', 'floors'], - properties: { rows: { type: 'array' }, floors: { type: 'array' } }, - }, - }, - ], - selective: [], - }, - ...contractRest, - authorization: authorizationFor(), - resources: { - tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }], - skills: [], - models: [], - roles: ['PRM'], - budget: budgetFor(), - }, - }, - declarations: { - models: [], - listeners: [], - harness, - tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }], - ledger: declarationsFor([ - 'supplier.channel_dir', - 'supplier.dead_stock', - 'project.sourcing_compare', - 'project.supplier_floor', - ]), - }, - assets: { - businessModel: placeholderSkeleton(STEPS.sourcingInquiry, '渠道寻源'), - skills: [], - viewTemplates: [], - cardSchemas: {}, - distillHooks: [], - }, - samples: [ - { - name: '契约样例:一单询价', - input: { pn: 'LM358DR' }, - expect: { ports: ['compare'] }, - }, - ], - entry: { file: 'index.js', checksum }, - }), - - /* ── A4 成本定价(SOP · BD) ────────────────────────────────────── */ - activityManifestSchema.parse({ - identity: identity('cost-pricing', '成本定价'), - contract: { - activityId: 'cost-pricing', - version: '0.1.0', - input: { - data: { - type: 'object', - required: ['records'], - properties: { - records: { type: 'array' }, - floors: { type: 'array' }, - benchmark: { type: 'array' }, - customerCode: { type: 'string' }, - }, - }, - messageAllowed: true, - }, - output: { - mandatory: [ - { - name: 'draft', - description: '报价草案:逐条定价,先过底价门禁再提审', - payload: { - type: 'object', - required: ['rows'], - properties: { rows: { type: 'array' }, quote_no: { type: 'string' } }, - }, - }, - ], - selective: [], - }, - ...contractRest, - authorization: authorizationFor(), - resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['BD'], budget: budgetFor() }, - }, - declarations: { - models: [], - listeners: [], - harness, - tools: [{ name: 'crm.history-price-read' }], - ledger: declarationsFor(['intel.history_price', 'customer.profile', 'project.quote_draft']), - }, - assets: { - businessModel: placeholderSkeleton(STEPS.costPricing, '成本定价'), - skills: [], - viewTemplates: [], - cardSchemas: {}, - distillHooks: [], - }, - samples: [ - { - name: '契约样例:一批草案', - input: { records: [] }, - expect: { ports: ['draft'] }, - }, - ], - entry: { file: 'index.js', checksum }, - }), - - /* ── A6 报价议价(Case · BD) ───────────────────────────────────── */ - activityManifestSchema.parse({ - identity: identity('quote-negotiation', '报价议价'), - contract: { - activityId: 'quote-negotiation', - version: '0.2.0', - input: { - data: { - type: 'object', - required: ['rows'], - properties: { rows: { type: 'array' }, quote_no: { type: 'string' }, customerCode: { type: 'string' } }, - }, - messageAllowed: true, - }, - output: { - mandatory: [ - { - name: 'formal', - description: '正式报价单(已送出)+ 跟进提醒', - payload: { - type: 'object', - properties: { - quote_no: { type: 'string' }, - customer_code: { type: 'string' }, - rows: { type: 'array' }, - }, - }, - }, - ], - /* - * 三个去向是**选择组**,不是必交口里的一个字段:人在放行时恰选一口, - * 那一口就是这次运行的 outcome(引擎按选口注入),G4 判的正是它。 - * 从前写成 formal 口的必填 outcome + selective: [] —— 引擎只在选择组 - * 恰发一口时才注入 outcome,没有选择组就永远没有 outcome,G4 三条边全不成立。 - */ - selective: [ - { - group: '议价结果', - ports: [ - { name: 'won', description: '成交 —— 转订单履约', payload: { type: 'object' } }, - { name: 'revise', description: '有异议 —— 退回重新定价', payload: { type: 'object' } }, - { name: 'lost', description: '拒绝或超时 —— 归档闭环', payload: { type: 'object' } }, - ], - }, - ], - }, - ...contractRest, - authorization: authorizationFor(), - resources: { - tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }], - skills: [], - models: [], - roles: ['BD'], - budget: budgetFor(), - }, - }, - declarations: { - models: [], - listeners: [], - harness, - tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }], - ledger: declarationsFor(['project.formal_quote', 'project.followup']), - }, - assets: { - businessModel: placeholderSkeleton(STEPS.quoteNegotiation, '报价议价'), - skills: [], - viewTemplates: [], - cardSchemas: {}, - distillHooks: [], - }, - samples: [ - { - name: '契约样例:一单议价', - input: { rows: [] }, - expect: { ports: ['formal'] }, - }, - ], - entry: { file: 'index.js', checksum }, - }), - - /* ── A7 情报归档(SOP · BD) ────────────────────────────────────── */ - activityManifestSchema.parse({ - identity: identity('intel-archive', '情报归档'), - contract: { - activityId: 'intel-archive', - version: '0.1.0', - input: { - data: { - type: 'object', - required: ['outcome'], - properties: { - outcome: { type: 'string' }, - quote_no: { type: 'string' }, - customer_code: { type: 'string' }, - quotedAt: { type: 'string' }, - }, - }, - messageAllowed: true, - }, - output: { - mandatory: [ - { - name: 'stats', - description: '不成交也留资产:情报、客户画像、落地率', - payload: { - type: 'object', - required: ['landing_rate'], - properties: { landing_rate: { type: 'number' } }, - }, - }, - ], - selective: [], - }, - ...contractRest, - authorization: authorizationFor(), - resources: { tools: [], skills: [], models: [], roles: ['BD'], budget: budgetFor() }, - }, - declarations: { - models: [], - listeners: [], - harness, - tools: [], - ledger: declarationsFor(['intel.history_price', 'customer.profile', 'project.landing_stats']), - }, - assets: { - businessModel: placeholderSkeleton(STEPS.intelArchive, '情报归档'), - skills: [], - viewTemplates: [], - cardSchemas: {}, - distillHooks: [], - }, - samples: [ - { - name: '契约样例:一次归档', - input: { outcome: 'lost' }, - expect: { ports: ['stats'] }, - }, - ], - entry: { file: 'index.js', checksum }, - }), -]; +/** 已退役的槽位 —— 真活动包顶替它之后,库里那一份也该走 */ +const RETIRED_SLOTS = [ + 'rfq-intake', + 'alt-selection', + 'sourcing-inquiry', + 'cost-pricing', + 'quote-negotiation', + 'intel-archive', +] as const; export async function installSlots(container: Container): Promise { - for (const manifest of MVP_SLOTS) { - const { activityId, packageVersion, name } = manifest.identity; - /* - * 先问库里有没有这一版 —— 因为「安装恒为新增、版本并存」:同版再装是 no-op, - * manifest 一个字不动。不问就报「已装入」等于**撒谎**:改了槽位内容重播种, - * 屏幕说装好了,库里还是旧的,人拿着旧行为查新代码(实测踩到,那次是卡面没换)。 - */ - const already = await container.registry - .manifestOf(activityId, packageVersion) - .then(() => true) + for (const activityId of RETIRED_SLOTS) { + const installed = await container.registry + .get(activityId) + .then((card) => card !== undefined) .catch(() => false); - - const verdict = await container.registry.install(manifest, SLOT_ARTIFACT); - const errors = verdict.staticFindings.filter((finding) => finding.severity === 'error'); - process.stdout.write( - ` 槽位 ${name}${already ? ` v${packageVersion} 已在库,未改动 —— 要换内容请升包版` : ` v${packageVersion} 已装`}` + - ` · 静态审 ${verdict.staticFindings.length} 条` + - (errors.length > 0 ? ` —— ERROR: ${errors.map((e) => e.message).join('; ')}` : '') + - '\n', - ); + if (!installed) continue; + try { + await container.registry.uninstall(activityId); + process.stdout.write(` 槽位 ${activityId} 已退役卸载(真活动包顶替)\n`); + } catch (error) { + process.stdout.write( + ` 槽位 ${activityId} 卸不掉:${error instanceof Error ? error.message : String(error)}\n`, + ); + } } } diff --git a/apps/api/src/app/seed-mvp.ts b/apps/api/src/app/seed-mvp.ts index f7bee03..fefcf86 100644 --- a/apps/api/src/app/seed-mvp.ts +++ b/apps/api/src/app/seed-mvp.ts @@ -9,6 +9,7 @@ * 活动与工具的解耦就在②③之间:槽位只声明工具**名**,注册、连接值、白名单全在这里。 */ import type { Container } from './container.ts'; +import { installRealActivities } from './seed-activities.ts'; import { installSlots } from './seed-mvp-slots.ts'; import { seedMvpFlow } from './seed-mvp-flow.ts'; @@ -16,16 +17,27 @@ import { seedMvpFlow } from './seed-mvp-flow.ts'; const HISTORY_TOOL = { name: 'crm.history-price-read', displayName: '历史价查询', - version: '1.0.0', - description: '读取历史价格情报:某 PN 最近一次报价距今天数、行情是否平稳、生命周期状态', - input: { type: 'object', required: ['pn'], properties: { pn: { type: 'string' } } }, + version: '1.1.0', + description: + '读取历史价格情报:某 PN 最近一次报价距今天数、行情是否平稳、生命周期状态、上次成交单价。' + + '一次可查一批 —— 传 pns 数组,出参走 rows(一单几十条料不必一条一调)。', + input: { + type: 'object', + properties: { + pn: { type: 'string', description: '单条查询' }, + pns: { type: 'array', items: { type: 'string' }, description: '批量查询(优先于 pn)' }, + }, + }, output: { type: 'object', properties: { + pn: { type: 'string' }, historyDays: { type: 'integer' }, marketStable: { type: 'boolean' }, lifecycle: { type: 'string' }, hit: { type: 'boolean' }, + lastPrice: { type: 'number' }, + rows: { type: 'array', description: '批量查询的逐条结果' }, }, }, effect: 'read' as const, @@ -53,6 +65,57 @@ const MAIL_TOOL = { connectionKeys: ['endpoint'], }; +/** + * 逐条打标 —— **纯工具**(活动私有作用域,`ownerActivityId` 定死是谁的)。 + * + * 它是三分类规则 P-03 的**求值器**:同输入必同结论、零方差零 token、命中规则行随出参留痕。 + * 「能算的不问 LLM」的具体形态就是这样一枚工具 —— 不是让模型照着规则表判, + * 而是把规则表做成工具,模型只负责把判据取齐。 + * + * 出参键名与台账列同名(`type_tag` / `basis`):拿回来直接 write,不必让模型改名 —— + * 那一步交给模型做,它会漏。 + */ +const CLASSIFY_TOOL = { + name: 'quote-intake.classify', + displayName: '料号三分类打标', + version: '1.0.0', + description: + '按三分类规则 P-03 逐条打标(A 历史价≤90天且行情稳 / B 无历史·过期·波动 / C EOL·缺货·要替代)。' + + '一次可打一批 —— 传 rows(每行含 pn 与三项判据),出参走 results。', + input: { + type: 'object', + properties: { + rows: { + type: 'array', + description: '批量:每行 { pn, history_days, market_stable, lifecycle }', + items: { type: 'object' }, + }, + history_days: { type: 'integer', description: '单条:距上次报价天数' }, + market_stable: { type: 'boolean', description: '单条:行情是否稳定' }, + lifecycle: { type: 'string', description: '单条:生命周期状态' }, + }, + }, + output: { + type: 'object', + properties: { + results: { type: 'array', description: '批量结果:每行 { pn, type_tag, play, basis, rule }' }, + type_tag: { type: 'string' }, + play: { type: 'string' }, + basis: { type: 'string' }, + rule: { type: 'string', description: '命中的规则行 —— 留痕靠它' }, + }, + }, + effect: 'read' as const, + errors: [{ code: 'no-match', retryable: false, message: '三分类规则无命中行' }], + idempotent: true, + timeoutMs: 5_000, + retries: 0, + connectionKeys: [] as string[], + // 纯工具:活动私有作用域 —— 别的活动调不到,白名单也给不了 + pure: true, + ownerActivityId: 'quote-intake', +}; + /** * 活动槽位的占位产出 —— **编排层跑通期专用的纯工具**。 * @@ -108,7 +171,8 @@ const PLACEHOLDER_TOOL = { /** 白名单:逐槽位授权 —— 谁能调什么,治理台说了算,活动代码里看不到这张表 */ const ALLOWLIST: Readonly> = { - 'rfq-intake': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name], + // A1 已是真活动:白名单里摘掉占位工具 —— 它一在,模型就会拿它糊弄过去 + 'quote-intake': [HISTORY_TOOL.name], 'alt-selection': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name], 'sourcing-inquiry': [HISTORY_TOOL.name, MAIL_TOOL.name, PLACEHOLDER_TOOL.name], 'cost-pricing': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name], @@ -147,13 +211,20 @@ export async function seedMvp(container: Container): Promise { log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`); /* ② 功能工具注册两审 + 逐槽位白名单 */ - for (const contract of [HISTORY_TOOL, MAIL_TOOL, PLACEHOLDER_TOOL]) { + for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL]) { const verdict = await container.toolbox.register({ contract, samples: [ { - // 样例集一份喂三件工具:并集覆盖各家必填 - input: { pn: 'LM358DR', to: 'x@y.com', subject: '询价' }, + // 样例集一份喂四件工具:并集覆盖各家必填 + input: { + pn: 'LM358DR', + to: 'x@y.com', + subject: '询价', + history_days: 30, + market_stable: true, + lifecycle: '在产', + }, expectShape: { type: 'object' }, }, ], @@ -167,11 +238,17 @@ export async function seedMvp(container: Container): Promise { for (const [activityId, tools] of Object.entries(ALLOWLIST)) { await container.toolbox.setAllowlist(activityId, tools); } - log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 占位纯工具 1,逐槽位授权)'); + log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 2,逐活动授权)'); - /* ③ 六个活动槽位经安装通道装入 —— 声明物化把四域十八对象建出来 */ + /* + * ③ 活动装入 —— 两批: + * · 真活动包经**安装通道读文件**装(系统外开发、安装即用); + * · 尚未开发的槽位照旧从常量表装(契约就绪、实现留白),让编排层能整条走通。 + * 两批都走同一道安装两审,声明物化顺带把台账表建出来。 + */ + await installRealActivities(container); await installSlots(container); - log('③ 六个活动槽位已装入(契约就绪,实现留白)'); + log('③ 活动已装入(真活动包 1 · 槽位 6:契约就绪、实现留白)'); /* ④ 流程包 1:1 + 触发器 + 通道 */ await seedMvpFlow(container); diff --git a/apps/api/src/app/server.ts b/apps/api/src/app/server.ts index b81f74b..f86d6e5 100644 --- a/apps/api/src/app/server.ts +++ b/apps/api/src/app/server.ts @@ -56,6 +56,28 @@ export function createServer(container: Container): Hono { return undefined; }); + /** + * 治理面的写操作 → **一律广播一次 `system`**(2026-07-30)。 + * + * 从前 `kind:'system'` 只在类型里声明过,**全仓一处也没发过** —— 于是治理台的 + * 全部配置面(活动库 / 工具箱 / 大模型 / 流程包 / 数据域 / 驾驶舱指标 / 触发器) + * 只有自己那一次 mutation 之后的本地失效才刷新:换一个浏览器标签、或另一个人改了配置, + * 这边纹丝不动,直到手动刷新。 + * + * 放在中间件而不是逐个路由里,是因为治理写路由有二十几条、还会长 —— + * 挨个加一行的写法,漏一条就是一处**看不见的**不同步(这类漏最难发现: + * 它不报错,只是画面比事实旧)。这里一处拦住,新增路由自动生效。 + * + * 只在**成功**(2xx)之后发;推送不带载荷,前端照旧回服务端重取。 + */ + app.use('/api/gov/*', async (c, next) => { + await next(); + const mutating = c.req.method !== 'GET' && c.req.method !== 'HEAD'; + if (mutating && c.res.status >= 200 && c.res.status < 300) { + container.broadcaster.publish({ kind: 'system', id: 'gov', reason: `${c.req.method} ${c.req.path}` }); + } + }); + app.onError((error, c) => { const body: ApiError = { code: 'internal', diff --git a/apps/api/src/app/tool-bus.ts b/apps/api/src/app/tool-bus.ts index 4d47202..cce313d 100644 --- a/apps/api/src/app/tool-bus.ts +++ b/apps/api/src/app/tool-bus.ts @@ -48,6 +48,13 @@ export type ToolBusPorts = { keyFilter: Record; deadline: string; }) => Promise<{ id: string }>; + /** 台账工具:写草稿(基座六件之 ledger)—— 过程写的唯一形态 */ + readonly draftWrite: (input: { + taskId: TaskId; + /** 台账地址「域.对象」—— 与单拍路径的 `write` 行动同一种写法 */ + address: string; + rows: ReadonlyArray<{ key: Record; patch: Record }>; + }) => Promise<{ written: number }>; /** LLM 工具:经模型网关 */ readonly generate: (input: { model: string; @@ -110,6 +117,18 @@ function toZod(schema: Record | undefined): z.ZodTypeAny { return describe(z.unknown()); } +/** 只收 `{key, patch}` 都是对象的行 —— 形状不对的丢掉,不猜(猜错就往台账落一行来路不明的数) */ +function isDraftRow(row: unknown): row is { key: Record; patch: Record } { + if (typeof row !== 'object' || row === null) return false; + const candidate = row as Record; + const key = candidate['key']; + const patch = candidate['patch']; + return ( + typeof key === 'object' && key !== null && !Array.isArray(key) && + typeof patch === 'object' && patch !== null && !Array.isArray(patch) + ); +} + /** LLM 五件的提示词:系统内化的能力,提示词也归系统 —— 活动不写、也改不了 */ function llmPrompt(id: string, args: Record): { system: string; prompt: string } { const j = (v: unknown) => JSON.stringify(v ?? null); @@ -185,6 +204,24 @@ export function buildMastraTools( return { asked: true, cardId: card.id }; } + if (tool.kind === 'ledger') { + /* + * 台账草稿写 —— 地址与列名的校验在装配层的实现里(它认识活动清单), + * 不合规抛错,由 Mastra 把错误原文回给模型,让它这一拍就改对。 + */ + const rows = Array.isArray(args['rows']) ? args['rows'] : []; + try { + const done = await ports.draftWrite({ + taskId: context.taskId, + address: String(args['address'] ?? ''), + rows: rows.filter(isDraftRow), + }); + return { ok: true, written: done.written }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + } + if (tool.kind === 'llm') { const { system, prompt } = llmPrompt(tool.id, args); const result = await ports.generate({ diff --git a/apps/api/src/app/tools/first-party.ts b/apps/api/src/app/tools/first-party.ts index 33dfb06..709d580 100644 --- a/apps/api/src/app/tools/first-party.ts +++ b/apps/api/src/app/tools/first-party.ts @@ -9,23 +9,60 @@ import type { ToolHandler } from '@modules/toolbox'; /** * 查历史价 —— 外部系统(CRM)的**录制回放替身**。 * README:mock 只允许出现在外界;系统内部机制一律真跑。 + * + * **一次可查一批**(2026-07-30 加 `pns`):一单几十条料,一条一调就是几十拍模型 —— + * 每拍都要重读一遍上下文,而拿回来的东西一模一样。契约演进只增不破坏: + * `pn` 仍收单条(A4 定价那侧照旧),新增的 `pns` 是可选入参,出参多一个 `rows`。 */ -const historyPriceRead: ToolHandler = async ({ input }) => { - const pn = String(input['pn'] ?? ''); +const HISTORY_SAMPLES: Record = { // 录制样本:演示用的历史价情报(真 CRM 接入时换 endpoint,契约不变) // 历史价情报含**上次成交单价** —— 没有价的「历史价工具」是取不出价格基准的 - const table: Record = { - LM358DR: { historyDays: 30, marketStable: true, lifecycle: '在产', lastPrice: 8.4 }, - NE555P: { historyDays: 240, marketStable: false, lifecycle: '在产', lastPrice: 3.25 }, - TL072CDR: { historyDays: 15, marketStable: true, lifecycle: 'EOL', lastPrice: 21.6 }, - }; - const hit = table[pn]; - if (hit === undefined) { - // 无历史 → 走 B(重估后报);这不是错误 - // 无历史 → 走 B(重估后报);没有价格基准,基准价回 0 由下游按「无基准」处置 - return { ok: true, output: { historyDays: 9999, marketStable: false, lifecycle: '未知', hit: false, lastPrice: 0 } }; + LM358DR: { historyDays: 30, marketStable: true, lifecycle: '在产', lastPrice: 8.4 }, + NE555P: { historyDays: 240, marketStable: false, lifecycle: '在产', lastPrice: 3.25 }, + TL072CDR: { historyDays: 15, marketStable: true, lifecycle: 'EOL', lastPrice: 21.6 }, + + /* + * ── Itelma MD14.1 的九条 —— **老客户测试案例的情报底账** ──────────────── + * + * 数字不是编的:逐条照 `JCAI_PRD/docs/活动执行页/A1-询价受理记录.html` 那份 + * 验收目标里写明的台账价与判据抄下来,天数按来件日 07-10 折算。 + * 抄下来是为了让**三分类真判得出** 5A / 3B / 1C —— 而那一分类决定这一单 + * 走哪个出口(有 C 类 → 去 A2 选型)。情报造得随意一点,整条流就走岔了。 + * + * 没录进来的两条(TVM6C160K203R 压敏电阻、DAG2400L321504XZB01-R0 定制天线) + * **是故意不录的**:台账里本来就没有它们的历史价,查不到会回 historyDays=9999, + * 命中 B 类「重估后报」—— 那正是验收目标上写的结论。 + */ + 'PESD2ETH-XR': { historyDays: 59, marketStable: true, lifecycle: '在产', lastPrice: 0.225 }, + BSCH00160808R12J00: { historyDays: 25, marketStable: true, lifecycle: '在产', lastPrice: 0.057 }, + // 过期 121 天:天数一过 90 就落 B,必须重询锁价 + BWVF005050201R0T00: { historyDays: 121, marketStable: true, lifecycle: '在产', lastPrice: 0.088 }, + CJTB0101R6N: { historyDays: 43, marketStable: true, lifecycle: '在产', lastPrice: 0.195 }, + 'SGM2202-ADJYN6G/TR': { historyDays: 38, marketStable: true, lifecycle: '在产', lastPrice: 0.47 }, + 'UMW AO6604': { historyDays: 29, marketStable: true, lifecycle: '在产', lastPrice: 0.27 }, + // 原厂交期 16 周顶不住首批,客户已预授权 Analog —— 生命周期记「要求替代」,P-03 判 C + BBPY00201209601Y00: { historyDays: 47, marketStable: true, lifecycle: '要求替代', lastPrice: 0.061 }, +}; + +/** 无历史 → 走 B(重估后报);**这不是错误**,新料号本就该重估,极大天数本身就是判据 */ +const NO_HISTORY = { historyDays: 9999, marketStable: false, lifecycle: '未知', hit: false, lastPrice: 0 }; + +const lookupHistory = (pn: string) => { + const hit = HISTORY_SAMPLES[pn]; + return hit === undefined ? { ...NO_HISTORY } : { ...hit, hit: true }; +}; + +const historyPriceRead: ToolHandler = async ({ input }) => { + const batch = input['pns']; + if (Array.isArray(batch)) { + const rows = batch.map((raw) => { + const pn = String(raw ?? ''); + return { pn, ...lookupHistory(pn) }; + }); + return { ok: true, output: { rows } }; } - return { ok: true, output: { ...hit, hit: true } }; + const pn = String(input['pn'] ?? ''); + return { ok: true, output: { pn, ...lookupHistory(pn) } }; }; /** @@ -44,23 +81,79 @@ const basis: ToolHandler = async ({ input }) => { }; /** 三分类规则 P-03 的决策表 —— 与活动包里的资产同一份规则 */ +/* + * 三分类规则 P-03 的决策表 —— 与活动包里的资产同一份规则。 + * 出参键名用 `type_tag` 与台账列同名:模型拿回结果直接 write,不必自己改名 + * (改名这一步交给模型做,它会漏)。`basis` 一并给出,分级依据不必再编。 + */ const P03_RULES = [ - { id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: { type: 'C', play: '转案处理' } }, - { id: 'p03-a', when: ['<= 90', 'true', '-'], then: { type: 'A', play: '直接快答' } }, - { id: 'p03-b', when: ['-', '-', '-'], then: { type: 'B', play: '重估后报' } }, + { id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: { type_tag: 'C', play: '转案处理', basis: 'EOL/缺货/要求替代 → C' } }, + { id: 'p03-a', when: ['<= 90', 'true', '-'], then: { type_tag: 'A', play: '直接快答', basis: '历史价≤90天且行情稳 → A' } }, + { id: 'p03-b', when: ['-', '-', '-'], then: { type_tag: 'B', play: '重估后报', basis: '无历史/过期/波动 → B' } }, ] as const; /** * 逐条打标 —— **纯工具**:无外效应、无连接配置、活动私有作用域。 * 「能算的不问 LLM」:同输入必同结论,零方差零 token;命中规则行随出参留痕。 + * + * **一次可打一批**(`rows`):判据是逐条的,结论也是逐条的,但求值不必逐拍 —— + * 一单几十条料分几十拍打标,烧掉的全是重复上下文。 */ -const classify: ToolHandler = async ({ input }) => { - const values = [input['historyDays'], input['marketStable'], input['lifecycle']]; +const classifyOne = (values: readonly unknown[]) => { for (const rule of P03_RULES) { - const hit = rule.when.every((cell, i) => evaluateUnaryTest(cell, values[i], {})); - if (hit) return { ok: true, output: { ...rule.then, rule: rule.id } }; + if (rule.when.every((cell, i) => evaluateUnaryTest(cell, values[i], {}))) { + return { ...rule.then, rule: rule.id }; + } } - return { ok: false, error: { code: 'no-match', retryable: false, message: '三分类规则无命中行' } }; + return undefined; +}; + +/** 判据三件的取值:下划线与驼峰两认 —— 台账列是下划线,模型常写驼峰 */ +const basisOf = (row: Record) => [ + row['history_days'] ?? row['historyDays'], + row['market_stable'] ?? row['marketStable'], + row['lifecycle'], +]; + +const classify: ToolHandler = async ({ input }) => { + const batch = input['rows']; + if (Array.isArray(batch)) { + const results: Array> = []; + for (const raw of batch) { + const row = (typeof raw === 'object' && raw !== null ? raw : {}) as Record; + const decided = classifyOne(basisOf(row)); + if (decided === undefined) { + // 一条判不出就整批不出结论:半份分类表比没有更难查 + return { + ok: false, + error: { code: 'no-match', retryable: false, message: `三分类规则无命中行:${String(row['pn'] ?? '(无 PN)')}` }, + }; + } + results.push({ pn: String(row['pn'] ?? ''), ...decided }); + } + /* + * **条数由判定器自己数**(2026-07-31 实测逼出来的)。 + * + * 三类条数是验收判据的入参(route-alt / route-rfq 拿它跟派单数对账)。 + * 从前只回逐条结论,条数由模型在脑子里数 —— 实测数错过:判定器给的是 5A/3B/1C, + * 模型报的是 7A/2B/1C,而派单也按它自己那个数开(2 张询价请求)。 + * 于是等式 `rfq_request_total = type_b_total` 两边都是它自报的,**2 = 2 顺利过关**, + * 而真实的第三条 B 类料一张单也没开 —— 靠自报两边的对账,防的是疏忽,防不了口径。 + * + * 数数是能算的事,算完摆在出参里,模型照抄即可。 + */ + const counts = { A: 0, B: 0, C: 0 } as Record; + for (const r of results) { + const tag = String(r['type_tag'] ?? ''); + if (tag in counts) counts[tag] = (counts[tag] ?? 0) + 1; + } + return { ok: true, output: { results, counts, total: results.length } }; + } + const decided = classifyOne(basisOf(input)); + if (decided === undefined) { + return { ok: false, error: { code: 'no-match', retryable: false, message: '三分类规则无命中行' } }; + } + return { ok: true, output: decided }; }; /** 利润模型 P-01:品牌 × 类型 × 客户等级 × 数量档 → 目标毛利区间 */ diff --git a/apps/api/src/app/upload-extract.ts b/apps/api/src/app/upload-extract.ts new file mode 100644 index 0000000..785992b --- /dev/null +++ b/apps/api/src/app/upload-extract.ts @@ -0,0 +1,185 @@ +/** + * 上传件**读成文本** —— 附件要能真正喂进活动,不能只剩一个文件名。 + * + * 活动那一侧读得懂的只有文字:S1 要把 BOM 拆成一行一条需求,拿到的必须是行与列, + * 不是一串二进制。所以入口这一步只干一件事:**把文件读成人和模型都看得懂的文本**, + * 读不出来就如实说读不出来(界面不撒谎),绝不返回一段猜出来的内容。 + * + * 不引第三方库:xlsx 本质是一个 ZIP 包着几份 XML,Node 自带的 `zlib.inflateRawSync` + * 就能解;为一件读表的活拉进一整个电子表格库,是拿依赖换五十行代码。 + * + * 这一份是**纯计算**:进字节、出文本,不碰库不碰网,所以能逐条测。 + */ +import { inflateRawSync } from 'node:zlib'; + +/** 读的结果 —— text 缺席不是错误,是「这类文件读不出文本」这件事实 */ +export type Extracted = { + readonly text?: string; + /** 一句人话:读出了什么 / 为什么读不出。会原样摆到界面上,也会进活动的入参 */ + readonly note: string; +}; + +/** 纯文本族:直接按 UTF-8 解 */ +const TEXT_EXT = ['txt', 'csv', 'tsv', 'md', 'json', 'xml', 'html', 'htm', 'log', 'yaml', 'yml']; + +const extOf = (name: string): string => { + const i = name.lastIndexOf('.'); + return i === -1 ? '' : name.slice(i + 1).toLowerCase(); +}; + +/** 表格读出来上限:再大的表也不该整份灌进提示词(超出就截,并在 note 里说清楚) */ +const TEXT_CAP = 20_000; + +export function extractText(name: string, contentType: string, bytes: Uint8Array): Extracted { + const ext = extOf(name); + if (ext === 'xlsx' || contentType.includes('spreadsheetml')) return fromXlsx(bytes); + if (TEXT_EXT.includes(ext) || contentType.startsWith('text/') || contentType === 'application/json') { + const text = new TextDecoder('utf-8').decode(bytes); + return cap(text, `按 UTF-8 文本读出 ${text.length} 字`); + } + return { + note: `${ext === '' ? '这个文件' : `.${ext} 文件`}读不出文本 —— 只有文件名与大小进得了活动,明细请贴进正文`, + }; +} + +function cap(text: string, how: string): Extracted { + if (text.length <= TEXT_CAP) return { text, note: how }; + return { text: text.slice(0, TEXT_CAP), note: `${how},超出 ${TEXT_CAP} 字的部分已截断` }; +} + +/* ==================== xlsx = ZIP + 几份 XML ==================== */ + +/** + * 最小 ZIP 读取 —— 只认这一件事需要的部分: + * 从尾部找中央目录结束记录(EOCD)→ 逐条读中央目录 → 按偏移取本地头 → 取数据段。 + * + * 只支持 store(0)与 deflate(8)两种压缩法 —— xlsx 出厂就这两种。 + * 认不出的条目跳过而不是抛:一份表里混了一条读不了的条目,不该让整份表读不出来。 + */ +function readZip(bytes: Uint8Array): Map { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const files = new Map(); + + // EOCD 的签名 0x06054b50,从尾部往回找(注释段最长 64K) + let eocd = -1; + for (let i = bytes.length - 22; i >= 0 && i >= bytes.length - 22 - 65_536; i--) { + if (view.getUint32(i, true) === 0x06054b50) { + eocd = i; + break; + } + } + if (eocd === -1) return files; + + const count = view.getUint16(eocd + 10, true); + let p = view.getUint32(eocd + 16, true); + const dec = new TextDecoder('utf-8'); + + for (let n = 0; n < count; n++) { + if (p + 46 > bytes.length || view.getUint32(p, true) !== 0x02014b50) break; + const method = view.getUint16(p + 10, true); + const compressed = view.getUint32(p + 20, true); + const nameLen = view.getUint16(p + 28, true); + const extraLen = view.getUint16(p + 30, true); + const commentLen = view.getUint16(p + 32, true); + const localAt = view.getUint32(p + 42, true); + const entryName = dec.decode(bytes.subarray(p + 46, p + 46 + nameLen)); + p += 46 + nameLen + extraLen + commentLen; + + // 本地头的可变段长度与中央目录的未必一致,必须照本地头自己的来 + if (localAt + 30 > bytes.length || view.getUint32(localAt, true) !== 0x04034b50) continue; + const lNameLen = view.getUint16(localAt + 26, true); + const lExtraLen = view.getUint16(localAt + 28, true); + const from = localAt + 30 + lNameLen + lExtraLen; + const raw = bytes.subarray(from, from + compressed); + if (method === 0) files.set(entryName, raw); + else if (method === 8) { + try { + files.set(entryName, new Uint8Array(inflateRawSync(raw))); + } catch { + /* 这一条解不开就跳过 —— 别让一条坏条目把整份表拖垮 */ + } + } + } + return files; +} + +/** XML 实体还原 —— 只有这五个是 XML 预定义的 */ +function unescapeXml(s: string): string { + return s + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code))) + .replace(/&/g, '&'); +} + +/** 共享字符串表:`` 一条一个字符串,里面可能被拆成多个 ``(富文本) */ +function sharedStrings(xml: string): string[] { + return [...xml.matchAll(/([\s\S]*?)<\/si>/g)].map((m) => + [...(m[1] ?? '').matchAll(/]*>([\s\S]*?)<\/t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''), + ); +} + +/** A1 → 列号 0;AB7 → 列号 27。行号不用,行由 `` 自己划 */ +function columnOf(ref: string): number { + const letters = /^([A-Z]+)/.exec(ref)?.[1] ?? 'A'; + let n = 0; + for (const ch of letters) n = n * 26 + (ch.charCodeAt(0) - 64); + return n - 1; +} + +/** + * 一张表读成 TSV —— 给模型看的东西,行列对齐比什么都重要。 + * + * 单元格三种取值:`t="s"` 查共享串表、`t="inlineStr"` 取内联 ``、其余取 `` 原样 + * (数字、日期序列号都在这一类)。日期不转换:转错了比不转更糟,原值至少可以追。 + */ +function sheetToRows(xml: string, shared: readonly string[]): string[][] { + const rows: string[][] = []; + for (const rowMatch of xml.matchAll(/]*>([\s\S]*?)<\/row>/g)) { + const cells: string[] = []; + for (const cell of (rowMatch[1] ?? '').matchAll(/]*)>([\s\S]*?)<\/c>/g)) { + const attrs = cell[1] ?? ''; + const inner = cell[2] ?? ''; + const type = /t="([^"]+)"/.exec(attrs)?.[1]; + const at = columnOf(/r="([A-Z]+\d+)"/.exec(attrs)?.[1] ?? 'A1'); + const v = /([\s\S]*?)<\/v>/.exec(inner)?.[1] ?? ''; + let text: string; + if (type === 's') text = shared[Number(v)] ?? ''; + else if (type === 'inlineStr') { + text = [...inner.matchAll(/]*>([\s\S]*?)<\/t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''); + } else text = unescapeXml(v); + while (cells.length < at) cells.push(''); + cells.push(text); + } + // 整行皆空的不留:表尾常挂着一片格式化过的空行 + if (cells.some((c) => c !== '')) rows.push(cells); + } + return rows; +} + +function fromXlsx(bytes: Uint8Array): Extracted { + const zip = readZip(bytes); + if (zip.size === 0) return { note: '这份 .xlsx 打不开(不是合法的 ZIP 包)' }; + const dec = new TextDecoder('utf-8'); + const sharedRaw = zip.get('xl/sharedStrings.xml'); + const shared = sharedRaw === undefined ? [] : sharedStrings(dec.decode(sharedRaw)); + + const sheets = [...zip.keys()].filter((k) => /^xl\/worksheets\/sheet\d+\.xml$/.test(k)).sort(); + if (sheets.length === 0) return { note: '这份 .xlsx 里没有工作表' }; + + const parts: string[] = []; + let total = 0; + for (const key of sheets) { + const raw = zip.get(key); + if (raw === undefined) continue; + const rows = sheetToRows(dec.decode(raw), shared); + if (rows.length === 0) continue; + total += rows.length; + parts.push(sheets.length > 1 ? `# ${key.replace(/^xl\/worksheets\//, '')}` : ''); + parts.push(rows.map((r) => r.join('\t')).join('\n')); + } + if (total === 0) return { note: '这份 .xlsx 是空的(没有一行有内容)' }; + return cap(parts.filter((s) => s !== '').join('\n'), `读出 ${sheets.length} 张表、共 ${total} 行(制表符分列)`); +} diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 6ec48fa..829f7c4 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -3,15 +3,16 @@ * * 这里把 M4/M5 的模块用**端口**接起来:跨层协作一律经端口,模块之间不互相认识。 */ +import { sql } from 'drizzle-orm'; import type { Db } from '@shared/db.ts'; import type { LandedFacts } from '@modules/task'; import type { EventService } from '@modules/event'; import type { LedgerService } from '@modules/ledger'; import type { MemoryService } from '@modules/memory'; -import type { ToolboxService, ToolInvoker } from '@modules/toolbox'; +import type { ToolboxService, ToolContract, ToolHandoffStore, ToolInvoker } from '@modules/toolbox'; import type { TraceService } from '@modules/trace'; -import { compileToolBus } from '@modules/toolbox'; -import type { HumanToolService } from '@modules/human-tool'; +import { callKeyOf, coerceBySchema, compileToolBus, handoffFields } from '@modules/toolbox'; +import type { ExecutionWorkOrderDraft, HumanToolService } from '@modules/human-tool'; import { renderView } from '@modules/interaction'; import type { InteractionService } from '@modules/interaction'; import type { MessageService } from '@modules/message'; @@ -20,8 +21,10 @@ import type { LlmService, ModelGateway } from '@modules/llm'; import type { ActivityRegistryService } from '@modules/activity-registry'; import type { SkillEngineService } from '@modules/skill-engine'; import type { ReleaseService } from '@modules/release'; +import { idempotencyKey } from '@modules/release'; -import { createLoopStateStore, createTurnRunner } from '@modules/loop-engine'; +import { createLoopStateStore, createTurnRunner, criterionKey, readingsOf } from '@modules/loop-engine'; +import type { LoopEngineService } from '@modules/loop-engine'; import { createSkillEngineService } from '@modules/skill-engine'; import { createTaskService, createTaskStore } from '@modules/task'; import type { TaskService } from '@modules/task'; @@ -47,6 +50,8 @@ export type EngineWiringDeps = { readonly memory: MemoryService; readonly toolbox: ToolboxService; readonly toolInvoker: ToolInvoker; + /** 工具转人台(§6.2 未命中转人)—— 人是工具的一种实现 */ + readonly toolHandoff: ToolHandoffStore; readonly humanTool: HumanToolService; readonly interaction: InteractionService; readonly message: MessageService; @@ -65,6 +70,24 @@ export type EngineWiring = { readonly task: TaskService; readonly process: ProcessService; readonly trigger: TriggerService; + /** 案例引擎本体 —— 工作台要按它算阶段进度(`phaseOf`);推进走 task,不从这里走 */ + readonly loop: LoopEngineService; + /** + * **此刻这一单的判据长什么样** —— 按当下数据现算。 + * + * 发卡那一刻算死是不行的:人在件上补一格、撤一行,判据就该跟着变。 + * 与提交门读的是**同一段**(`criterionCheck`)—— 两处各算各的, + * 就会出现「卡上说达标、提交门说不行」,那种矛盾人是判不出对错的。 + */ + readonly checks: { of(taskId: TaskId): Promise>> }; + /** 视图闸 —— 推一帧;`repush` 按当下数据重渲同一张(人在件上改完要用它) */ + readonly views: { + push(input: { taskId: TaskId; activityId: string; template: string; scope: Record }): Promise<{ + pushed: boolean; + templates: string[]; + }>; + repush(taskId: TaskId): Promise; + }; }; function writeLog(line: string): void { @@ -96,6 +119,284 @@ const sfeel = { }, }; + + +/** + * 台账列的归型 —— **写进草稿之前就把型归好**(见 `draftWrite` 里的注释)。 + * + * 三种结局:`value` 写这个值 / `empty` 这一列没有值(不写)/ `reject` 当场拒。 + * 归不出来宁可拒:留到 `releaseWrite` 才炸,是在人签完字之后炸 —— 最坏的失败点。 + * + * 纯函数、导出可测:这类归型规则错一条,症状是几天后某一单在放行时莫名其妙挂掉。 + */ +/** + * 本单每张表写下了几行 —— `{ 'mine:域.对象': 行[] }` → `{ '域_对象_rows': 行数 }`。 + * + * 名字为什么是下划线:S-FEEL 的路径不认冒号也不认点号,`mine:域.对象` 那一份取不到; + * 而判据要能写成 `quote_intake_record_rows = line_total` 这样一句才有用。 + * + * 它存在的理由只有一个:**让机检判据有一边不是活动自报的**。 + * 两边都由活动写,它数错一次就一起错、一起「对上」(实测:条目数报 9、表里躺着 10, + * 派单数与三类条数同错同过)—— 那样的对账防的是疏忽,防不了口径。 + */ +/** + * **算出这一单走哪一口** —— 选择组内按序求值,首个命中即出(与 DMN 的 FIRST 同一口径)。 + * + * 组内**每一口都得写 `when`** 才算得出来:少一口就说明这组分支有一段没有判据, + * 那时宁可退回「人选一口」,也不要按半套规则替他决定。 + * 谓词求值抛错当作不命中:一条写坏的谓词不该让整单卡在放行门前。 + */ +export function routePort( + group: { + group: string; + ports: ReadonlyArray<{ name: string; description?: string | undefined; when?: string | undefined }>; + }, + scope: Record, + evaluate: (expression: string, scope: Record) => boolean, +): { group: string; name: string; label?: string; because: string } | undefined { + if (!group.ports.every((p) => p.when !== undefined && p.when.trim() !== '')) return undefined; + for (const port of group.ports) { + let hit = false; + try { + hit = evaluate(port.when ?? '', scope); + } catch { + hit = false; + } + if (hit) { + return { + group: group.group, + name: port.name, + ...(port.description === undefined ? {} : { label: port.description }), + because: port.when ?? '', + }; + } + } + return undefined; +} + +/** + * 存的是 UTC,摆到屏上要**本地时刻**。 + * + * 直接切 ISO 串那八个字符,屏上就写着 `07-31 20:16`,而人这边是 `08-01 04:16` —— + * 差八小时的时刻比没有时刻更坏:它看起来像一个可信的事实。 + * 归零到分:报告上摆秒没有意义,还会让同一单的两份件看起来不一样。 + */ +export function localStamp(iso: string): string { + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return ''; + const two = (n: number): string => String(n).padStart(2, '0'); + return `${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`; +} + +export function rowReadings( + mine: Readonly>, + declared: ReadonlyArray<{ domain: string; object: string }> = [], +): Record { + const out: Record = {}; + /* + * **一行没写也是一个读数:0** —— 先按声明把每张表铺成 0,再让实际行数盖上去。 + * + * 只数「写过的表」栽过一次(2026-08-01,全 A 类那条路):一单没有 C 类, + * 于是替代评估请求那张表一行都没写,`quote_intake_alt_request_rows` 整个键**不存在**; + * 而判据是 `quote_intake_alt_request_rows = type_c_total`(0 = 0 本该成立), + * 未绑定的名字参与比较求值为假 —— 这条判据**永远过不去**,模型连撞五拍熔断, + * 而数据本身完全合规。「没写过」与「写了 0 行」在业务上是同一件事,读数上也得是。 + * + * 铺的是**这个活动声明过的表**:声明面就是它的授权面,不在声明里的表本来也数不着。 + */ + for (const { domain, object } of declared) out[`${domain}_${object}_rows`] = 0; + for (const [key, list] of Object.entries(mine)) { + if (!key.startsWith('mine:')) continue; + out[`${key.slice('mine:'.length).replace(/\./g, '_')}_rows`] = list.length; + } + return out; +} + +/** + * 本单每张表写下的**是哪几行** —— `{ '域_对象_keys': 'A、B、C' }`。 + * + * 行数类判据拒回时只给两个数(「10 ≠ 9」),模型看得见对不上、看不见多的是哪一行, + * 于是只能猜:改数、重写、再提 —— 三条路都不对,连拒五拍熔断(实测三种形态全是这一类)。 + * 主键名单一摆就是一句话能办的事:多的那个 PN 就在名单里对着。 + * + * 摆的是**主键**(声明里 `primaryKey` 那几列),不是整行:整行铺出来会把拒回理由撑爆, + * 而认一行只需要它的主键。单行封顶 40 字、整张表封顶 12 行 —— + * 再多就不是「差在哪」而是把台账抄了一遍;超出的如实写「还有 N 行」。 + */ +/** + * 本单每张表**缺必填的有几行,是哪几行** —— `{ '域_对象_missing': N, '…_missing_keys': '…' }`。 + * + * 存在的理由只有一个:**让机检判据读得到基座数出来的数**。 + * A1 的「必填齐(PN / 年用量)」是 L1 机检,读的却是活动自己写的 `missing_key_total` —— + * 人在卡上补了年用量、基座已经写回草稿层,而那个汇总列还要模型自己重算; + * 它算漏一次,这条判据就永远绿不了(连跑十轮里三轮死在这,每次都是连拒五拍熔断)。 + * 活动自报的数不是门,是一句「改哪个数能过」的提示。 + * + * 哪几列算必填由**台账声明**说了算(`fields[].required`)——业务知识写在声明里, + * 基座只管照着数。缺 = 没有这个键、或值是 null / 空串(与 `coerceToColumn` 的空值口径一致)。 + */ +export function missingReadings( + mine: Readonly>, + declared: ReadonlyArray<{ + domain: string; + object: string; + fields: ReadonlyArray<{ + name: string; + label?: string | undefined; + primaryKey: boolean; + required?: boolean | undefined; + expect?: boolean | undefined; + }>; + }>, +): Record { + const out: Record = {}; + for (const { domain, object, fields } of declared) { + const rows = mine[`mine:${domain}.${object}`] ?? []; + const keys = fields.filter((f) => f.primaryKey).map((f) => f.name); + const blank = (one: Record, c: string): boolean => + one[c] === undefined || one[c] === null || String(one[c]).trim() === ''; + /* + * 两组读数同一套算法,只是看哪一个标记: + * · `required` → `_missing` 缺必填几行(**不齐不许放行**,是验收标准); + * · `expect` → `_gaps` 缺选填几行(不拦单,但**那一问不能省**)。 + * 缺口摆的是**中文列名**:这两个数会进拒回与卡面,给人看的地方不摆列名。 + */ + for (const [mark, suffix] of [ + ['required', 'missing'], + ['expect', 'gaps'], + ] as const) { + const watched = fields.filter((f) => f[mark] === true); + if (watched.length === 0) continue; + const bad: string[] = []; + for (const row of rows) { + const one = (typeof row === 'object' && row !== null ? row : {}) as Record; + const gaps = watched.filter((f) => blank(one, f.name)); + if (gaps.length === 0) continue; + const id: Record = {}; + for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]); + bad.push(`${Object.values(id).join('/')} 缺${gaps.map((f) => f.label ?? f.name).join('、')}`); + } + out[`${domain}_${object}_${suffix}`] = bad.length; + if (bad.length > 0) out[`${domain}_${object}_${suffix}_keys`] = bad.slice(0, 12).join(';'); + } + } + return out; +} + +/** + * **按值现数** —— `{ '域_对象_<列>_<值>': 行数 }`,取值域由台账声明的 `tally` 给。 + * + * 存在的理由与 `_rows` / `_missing` 是同一条:**能由数据算出来的,别让活动自报**。 + * 三分类条数从前是活动写的 `type_a_total` 之流,而分布条、放行时的去向谓词、验收判据 + * 读的全是它们 —— 于是人在放行页把九条料全改成 A,类徽变了,分布条还是 5/3/1、 + * 去向还是「去选型」:逐行数据变了,汇总数没人重算。 + * 而**手工修改的全部意义就是纠正 AI 的判断**;改了类标而流向不动,这个兜底就是假的。 + * + * 声明给取值域,所以**没出现的值也铺 0** —— 零条也是一个数,判据才算得下去 + * (与 `rowReadings` 铺 0 同一口径,那一条是全 A 类那条路踩出来的)。 + * 值消毒成小写标识符(与 `classToken` 同一把尺),`A` → `..._type_tag_a`。 + */ +export function tallyReadings( + mine: Readonly>, + declared: ReadonlyArray<{ + domain: string; + object: string; + fields: ReadonlyArray<{ name: string; tally?: readonly string[] | undefined }>; + }>, +): Record { + const out: Record = {}; + for (const { domain, object, fields } of declared) { + for (const field of fields) { + if (field.tally === undefined || field.tally.length === 0) continue; + const rows = mine[`mine:${domain}.${object}`] ?? []; + const slug = (v: string): string => v.toLowerCase().replace(/[^a-z0-9_]/g, ''); + for (const value of field.tally) { + const key = `${domain}_${object}_${field.name}_${slug(value)}`; + if (key.endsWith('_')) continue; // 值里一个合规字符都没有 —— 不给一个歧义的钩子 + out[key] = rows.filter((row) => { + const one = (typeof row === 'object' && row !== null ? row : {}) as Record; + return String(one[field.name] ?? '').trim() === value; + }).length; + } + } + } + return out; +} + +export function rowKeyList( + mine: Readonly>, + declared: ReadonlyArray<{ domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> }>, +): Record { + const out: Record = {}; + for (const { domain, object, fields } of declared) { + const rows = mine[`mine:${domain}.${object}`]; + if (rows === undefined || rows.length === 0) continue; + const keys = fields.filter((f) => f.primaryKey).map((f) => f.name); + if (keys.length === 0) continue; + /* + * **名单要能直接照抄进 `drop` 的 keys** —— 所以摆成 JSON,不摆成 `A/B` 拼串。 + * + * 实测栽过一次:名单写作 `RFQ MD14.1/XTSD04GLGEAQG-I`,模型得自己猜第一段是哪一列; + * 列名它猜对了,值却把空格写成了横杠(`RFQ-MD14.1` 是更常见的写法)—— + * 连撤三次都撤不到,而那一行就在名单里。 + * 主键是**认行的钥匙**,一个字符都不能差;那就别让它转写,给它能原样粘的形状。 + */ + const shown = rows.slice(0, 12).map((row) => { + const one = (typeof row === 'object' && row !== null ? row : {}) as Record; + const pairs: Record = {}; + for (const k of keys) { + const v = one[k]; + if (v === undefined || v === null || String(v) === '') continue; + const text = String(v); + pairs[k] = text.length <= 60 ? text : `${text.slice(0, 60)}…`; + } + return Object.keys(pairs).length === 0 ? '' : JSON.stringify(pairs); + }); + const listed = shown.filter((t) => t !== ''); + if (listed.length === 0) continue; + out[`${domain}_${object}_keys`] = + listed.join('、') + (rows.length > shown.length ? `(还有 ${rows.length - shown.length} 行)` : ''); + } + return out; +} + +export type Coerced = { kind: 'value'; value: unknown } | { kind: 'empty' } | { kind: 'reject' }; + +export function coerceToColumn(value: unknown, type: string): Coerced { + // null / undefined / 空串一律是「没有值」——「原文没写的不要补」在数据面的落点 + if (value === null || value === undefined) return { kind: 'empty' }; + if (typeof value === 'string' && value.trim() === '') return { kind: 'empty' }; + + switch (type) { + case 'number': + case 'integer': { + if (typeof value === 'number') return Number.isFinite(value) ? { kind: 'value', value } : { kind: 'reject' }; + if (typeof value === 'string') { + const n = Number(value.trim()); + return Number.isFinite(n) ? { kind: 'value', value: n } : { kind: 'reject' }; + } + return { kind: 'reject' }; + } + case 'boolean': { + if (typeof value === 'boolean') return { kind: 'value', value }; + if (typeof value === 'string') { + const t = value.trim().toLowerCase(); + if (t === 'true' || t === 'false') return { kind: 'value', value: t === 'true' }; + } + return { kind: 'reject' }; + } + case 'json': + // json 列什么都装得下(对象、数组、标量)—— 不归型 + return { kind: 'value', value }; + default: + // text / date / timestamp:字符串原样;对象序列化(比把 [object Object] 写进库强) + return { + kind: 'value', + value: typeof value === 'object' ? JSON.stringify(value) : String(value), + }; + } +} + /** 延后绑定的流程层引用 —— 只用到「任务终态上报」这一件 */ function createDeferredProcess(): { onTaskTerminal: ProcessService['onTaskTerminal']; @@ -110,9 +411,110 @@ function createDeferredProcess(): { }; } + +/** 录入卡的一格 —— 主段与 `also` 各段同一形状 */ +function entryField(f: { key: string; label: string; value?: string; required?: boolean }) { + return { + key: f.key, + label: f.label, + required: f.required ?? false, + ...(f.value === undefined || f.value === '' ? {} : { value: f.value }), + }; +} + +/** L1/L2 才机检 —— L3 环内实证、L4 独立评审、L5 人放行,都不由引擎代答 */ +function machineCheckable(level: string): boolean { + return level === 'L1' || level === 'L2'; +} + +/** + * 判据机检:带 predicate 的用 S-FEEL 对任务视图求值。 + * **没写 predicate 的 L1/L2 判 false**(不是 true)—— 装不出证据就不算过; + * 安装期 `acceptance-level-misreported` 本就该挡住这种漏配,这里再兜一层。 + */ +function evaluatePredicate(predicate: string | undefined, scope: Readonly>): boolean { + if (predicate === undefined || predicate.trim() === '') return false; + return sfeel.condition(predicate, scope); +} + +/** + * 一条判据此刻算不算达标 —— **卡面与提交门读的是同一段**。 + * + * 三类各有各的口径: + * · **机检(L1/L2)** —— 谓词对任务视图求值,不掺裁断; + * · **人放行(L5)** —— 只有人按下去才算,引擎与 Agent 都不许代答; + * · **裁量(L3/L4)** —— 读 Agent 的裁定(`judge` 行动),没裁过就是没达标。 + * + * ── 裁量项**带谓词时,谓词说了算**(2026-08-02 用户实测逼出来的)──────────── + * + * 「选填缺口:已补齐,或由你放行时确认可以先受理」是个复合命题:前半句 + * (**已补齐**)基座数得出来,后半句(**人确认先受理**)才需要人。 + * 从前整条只读裁断,于是模型凭记忆写理由 —— 用户在件上把目标价填好了, + * 它照样裁「未满足」,理由是「NE555P 缺目标价,客户未提供」。用户原话: + * 「这个价格是我填写的,但是它判错了」。实测复现的那一单更离谱: + * 两行的年用量都填着,它写「CJTB 缺 EAU、LM358DR-NEW 缺 EAU 与客户料号,共 4 处」—— + * 而 EAU 压根是必填不是选填,两行也都有值。 + * + * **数得出来的就别让它自报**(这条纪律第三次被违反)。谓词为真即达标, + * 裁断连同那句理由一起丢掉:一件已经办成的事,不需要谁再解释一遍为什么没办成。 + * 谓词为假才轮到裁量 —— 那时理由才有意义,而它该照基座的 `_gaps_keys` 写。 + */ +export function criterionCheck( + c: { key: string; label: string; level: L; predicate?: string | undefined }, + scope: Readonly>, + judgement: { satisfied: boolean; rationale?: string | undefined } | undefined, +): { key: string; label: string; level: L; pass: boolean; settled?: boolean; rationale?: string } { + const machine = machineCheckable(c.level); + const hasPredicate = c.predicate !== undefined && c.predicate.trim() !== ''; + const settledByData = !machine && c.level !== 'L5' && hasPredicate && evaluatePredicate(c.predicate, scope); + const pass = machine + ? evaluatePredicate(c.predicate, scope) + : c.level === 'L5' + ? false + : settledByData || (judgement?.satisfied ?? false); + return { + key: c.key, + label: c.label, + level: c.level, + pass, + /** 裁量项:裁过没有(裁成什么是另一回事)—— 提交门看的是这个;数据已经说了话的也算裁过 */ + ...(machine || c.level === 'L5' ? {} : { settled: settledByData || judgement !== undefined }), + /* + * **裁断的理由跟着上卡** —— 裁成「不满足」的那一条,理由才是签字的人要看的。 + * 没有它,卡面上「裁了但办不到」与「压根没裁」都是一个红叉,人只能凭感觉签。 + * 但数据已经说了「办成了」的,理由一律丢掉:留着就是一句与事实相反的话。 + */ + ...(settledByData || judgement?.rationale === undefined ? {} : { rationale: judgement.rationale }), + }; +} + export function wireEngines(deps: EngineWiringDeps): EngineWiring { const { db, clock, ids } = deps; + /** + * 必交付物「有没有产出」—— 数**本单自己写下的**草稿行。 + * + * 数行数是硬证据:活动说交付了、台账里一行没有,预检当场拒收。 + * 但数的必须是**本单的**行:任务视图 = 正式版 + 本单草稿,拿视图去数, + * 别单放行过的记录就成了自己的交付 —— 实测踩过:一单什么都没写就提交放行, + * 三件必交付物全判「有」(上一单落地的行还躺在同一张表里),卡照发、人照签。 + * 这道门于是只在**第一单**有效,之后永远形同虚设。 + * + * 没声明地址的退回作用域键判断:有值即算有(作用域顶层铺的也只是本单草稿)。 + */ + function deliverablePresent( + address: string | undefined, + key: string, + scope: Readonly>, + mine: ReadonlySet, + ): boolean { + if (address === undefined) { + const value = scope[key]; + return value !== undefined && value !== null && value !== ''; + } + return mine.has(address); + } + /* ── 任务上下文:活动 ID / 负责人 / 调人预算 —— 引擎共用 ─────────── */ const taskStore = createTaskStore(db); const taskContext = { @@ -121,6 +523,61 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { if (task === undefined) throw new Error(`任务不存在:${taskId}`); return { activityId: task.activityId, ownerId: task.ownerId, humanCallCap: task.caps.humanCalls }; }, + + /** + * Agent 对**裁量判据**的裁定 —— `judge` 行动落在任务记忆里的那些。 + * + * 读的是 `detail.criterion` / `detail.satisfied`(结构化的),不解析标题字符串: + * 标题是给人看的一句话,措辞随时会改,拿它当协议迟早对不上。 + * 同一条判据裁过多次以**最后一次**为准 —— 人改了主意、Agent 补了证据都算数。 + * + * 键一律**归一化后再存**(见 `criterionKey`):判据名是让模型照抄的一句中文, + * 而它抄的时候会顺手把半角标点写成全角、把空格吃掉 —— 一个标点差异就让 + * 这条判据永远对不上,提交被拒到预算烧完(实测:32 次被拒 → exhausted)。 + */ + async judgements(taskId: TaskId): Promise> { + const out = new Map(); + for (const line of await deps.memory.window(taskId, 400)) { + if (line.kind !== 'evaluation') continue; + const detail = line.detail; + if (typeof detail !== 'object' || detail === null) continue; + const criterion = (detail as Record)['criterion']; + const satisfied = (detail as Record)['satisfied']; + /* + * **理由跟着裁断一起收** —— 裁成「不满足」的那一条,理由才是签字的人要看的东西。 + * + * 从前只收 `satisfied` 这个布尔:卡面上于是「裁了但裁成不满足」与「压根没裁」 + * 长得一模一样 —— 都是一个红叉「未达标」。人看到一条永远不绿的项, + * 既不知道 AI 有没有办过,也不知道它为什么办不到,只能凭感觉签。 + * 「AI 说这一条它办不到,理由是……」比一片虚假的绿勾有用得多。 + */ + const rationale = (detail as Record)['rationale']; + if (typeof criterion === 'string' && typeof satisfied === 'boolean') { + out.set(criterionKey(criterion), { + satisfied, + ...(typeof rationale === 'string' && rationale.trim() !== '' ? { rationale: rationale.trim() } : {}), + }); + } + } + return out; + }, + + /** + * 机检判据名一览 —— key 与 label 都归一化后放进来, + * 因为模型两种写法都可能用(`judgements` 认的也是这两种)。 + */ + async machineCriteria(taskId: TaskId): Promise> { + const task = await taskStore.get(taskId); + if (task === undefined) return new Set(); + const manifest = await deps.registry.manifestOf(task.activityId); + const out = new Set(); + for (const c of manifest.contract.acceptanceCriteria) { + if (!machineCheckable(c.level)) continue; + out.add(criterionKey(c.key)); + out.add(criterionKey(c.label)); + } + return out; + }, }; /* @@ -139,10 +596,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { activityId: string; template: string; scope: Record; - }): Promise { + }): Promise<{ pushed: boolean; templates: string[] }> { const manifest = await deps.registry.manifestOf(activityId); + const templates = manifest.assets.viewTemplates.map((t) => t.name); const found = manifest.assets.viewTemplates.find((t) => t.name === template); - if (found === undefined) return; + // 查不到就如实回「没推上」+ 有哪些可推 —— 不再静默丢帧(见 ViewGateway 的注释) + if (found === undefined) return { pushed: false, templates }; /* * 视图作用域 = 任务视图 + **本单自己写下的行**。 @@ -155,7 +614,81 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { const key = `mine:${draft.domain}.${draft.object}`; (own[key] ??= []).push({ ...draft.key, ...draft.patch }); } - await deps.interaction.pushViewHtml(taskId, renderView(found.html, { ...scope, ...own })); + /* + * **这一单是什么时候起来的** —— 来件那一块要写「几时入站」, + * 而入参里没有时刻:信是外面来的,时刻是基座记的。 + * 起单即入站(邮件触发那一路,通道收到信就起单),所以取任务的 startedAt。 + * 只给到分钟:报告上摆秒没有意义,还会让两份同一单的件看起来不一样。 + */ + const task = await taskStore.get(taskId); + const openedAt = task === undefined ? '' : localStamp(task.startedAt); + /* + * **这一单是怎么来的、归了谁** —— 报告上那枚归属签。 + * + * 两样都是基座知道的事实(触发来源、任务负责人),活动写不出也不该写: + * 让它自报「归属解析 → 冯兵」等于让被指派的人自己说自己是谁。 + * 认不出就留空 —— 模版那一格自会空着,不摆一个假名字。 + */ + const owner = task === undefined ? undefined : await deps.org.getStaff(task.ownerId).catch(() => undefined); + const ownerLabel = owner === undefined ? '' : `${owner.name} · ${owner.position.name}`; + const origin = task?.origin === 'testbed' ? '试跑起单' : '通道入站'; + /* + * **这一单算出来要走哪一口** —— 报告上那张分支判定表要标出命中的是哪一行。 + * + * 关键是**别让报告自己再算一遍**:模版里没有表达式(填数只有取值/展开/上色), + * 真让它算就只能把判据抄成静态文字,而抄本与判据是两份东西 —— + * 改了 `when` 忘了改报告,屏上那一行就一直指着旧规则。 + * 这里调的是放行时发口用的同一个 `routePort`,同一份 scope、同一次求值口径: + * 命中第几条 → `r1/r2/r3`(经 `data-class-of` 消毒成 `v-r1`,行自己上色)。 + * 算不出来(有一口没写谓词、或一条都不命中)三个键留空 —— 那一行不高亮、 + * 结论横幅空着,而不是随便点亮一行。 + */ + const group = manifest.contract.output.selective[0]; + const routed = group === undefined ? undefined : routePort(group, scope, (e, s) => evaluatePredicate(e, s)); + const at = group === undefined || routed === undefined ? -1 : group.ports.findIndex((p) => p.name === routed.name); + const route: Record = + routed === undefined || at < 0 + ? {} + : { + 'case:routeKey': `r${at + 1}`, + 'case:routePort': routed.name, + 'case:routeTo': routed.label ?? routed.name, + 'case:routeWhy': routed.because, + }; + await deps.interaction.pushViewHtml( + taskId, + renderView(found.html, { + ...scope, + 'case:openedAt': openedAt, + 'case:origin': origin, + 'case:owner': ownerLabel, + ...route, + ...own, + }), + template, + ); + return { pushed: true, templates }; + }, + /** + * **按当下的数据把同一张模版再渲一遍** —— 人在件上改完东西之后要用它。 + * + * 件是**投影**:只读那几格是他自己敲的,而由数据算出来的东西 + * (类徽 A/B/C、分布条、结论横幅那条去向)全停在上一次渲染那一刻。 + * 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」—— + * 一半新一半旧,那不是没更新,是界面在撒谎。 + * + * 只重渲不换模版:换哪一张是剧本的事,这里只负责「同一张,用新数据」。 + * 没推过帧(还没到那一步)就什么也不做。 + */ + async repush(taskId: TaskId): Promise { + const current = await deps.interaction.viewOf(taskId); + const template = current?.template; + if (template === undefined || template === '') return false; + const task = await taskStore.get(taskId); + if (task === undefined) return false; + const scope = await taskData.view(taskId); + const out = await this.push({ taskId, activityId: task.activityId, template, scope }); + return out.pushed; }, }; @@ -193,14 +726,64 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { * 不能拿「该对象最新一行」来铺:那一行很可能是别的任务放行后的正式版记录, * 一铺就把别人的结论当成了自己的输入 —— 打标会照着上一单的情报出结果。 * 别人的事实仍在 `域.对象` 整表里,要用得显式去取。 + * + * 同时把本单的行**整行**收在 `mine:域.对象` 下(与视图模版同一约定)。 + * 为什么必须有这一份:`域.对象` 是正式版全表 —— 别单放行过的记录也在里面。 + * Agent 分不清哪几行是自己写的,就会拿别单的数据当自己的结论讲出来 + * (实测:一单 RFQ-SSE-01 的交代里,说的全是 RFQ-2026-1007 / 1016 的分类结果)。 */ + const mine: Record = {}; for (const draft of await deps.ledger.taskDrafts(taskId)) { + (mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch }); for (const [field, value] of Object.entries({ ...draft.key, ...draft.patch })) { if (value !== null && value !== undefined) scope[field] = value; } } - return scope; + + /* + * **本单每张表写下了几行** —— 判据要能自己数,不能只信活动报的数。 + * + * 起因是实测的一次「对账过关而账是错的」:活动把条目数记成 9(line_total), + * 而受理记录里实际躺着 10 行(有一条 PN 抄漏了后缀,补写时又成了新的一行 —— + * 主键写错一次就是多一行)。验收的机检判据读的全是活动自己写的汇总列, + * 两边同源,自然对得上;人签完字,下游拿到的是一份多一行的交付物。 + * + * 名字取 `域_对象_rows`:S-FEEL 的路径不认冒号与点号,`mine:域.对象` 那份取不到; + * 而判据要写成 `quote_intake_record_rows = line_total` 这样一句才有用。 + * 只数**本单自己写下的行**(草稿层),别单放行过的记录不算在内。 + */ + const declared = (await deps.registry.manifestOf(task.activityId)).declarations.ledger; + return { ...scope, ...rowReadings(mine, declared), ...rowKeyList(mine, declared), ...missingReadings(mine, declared), ...tallyReadings(mine, declared), ...mine }; }, + /** + * 本活动声明过的台账对象 —— 清单 `declarations.ledger` 就是这份名单。 + * + * 这不是便利函数,是**授权面**:活动能写哪儿由清单定死,与工具白名单同一道理。 + * 同时也是给 Agent 的字典 —— 不告诉它有哪些列,它只会照自己的想象编列名。 + */ + async writable(taskId: TaskId) { + const { activityId } = await taskContext.of(taskId); + const manifest = await deps.registry.manifestOf(activityId); + return manifest.declarations.ledger.map((d) => ({ + domain: d.domain, + object: d.object, + label: d.label ?? `${d.domain}.${d.object}`, + keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name), + fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type })), + })); + }, + /** + * 过程写入 —— 一律落草稿层(放行前不落事实),且**先验地址再落笔**。 + * + * 两道验都不能省: + * · 地址不在活动清单声明的台账对象内 → 拒。越域写与越权调工具同罪, + * 得在结构上拦住,不能靠活动自律。 + * · 列名不在声明的字段里 → 拒。`ledger.draftWrite` 对陌生列是**静默丢弃**, + * 于是「写成功了」而字段没落,验收判据求值取不到值 —— 查起来一层皮。 + * 宁可当场拒,把可用列名摆给它看。 + * + * 拒是抛错:回合那边捕获成「行动被拒」喂回下一拍,Agent 自己改对。 + */ async draftWrite(input: { taskId: TaskId; domain: string; @@ -208,13 +791,91 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { key: Record; patch: Record; }) { - // 一切过程写入落草稿层 —— 放行前不落事实 + const allowed = await taskData.writable(input.taskId); + const target = allowed.find((a) => a.domain === input.domain && a.object === input.object); + if (target === undefined) { + throw new Error( + `台账地址未声明:${input.domain}.${input.object}(本活动可写:${ + allowed.map((a) => `${a.domain}.${a.object}`).join('、') || '无' + })`, + ); + } + const known = new Set([...target.keys, ...target.fields.map((f) => f.name)]); + const strayed = [...Object.keys(input.key), ...Object.keys(input.patch)].filter((c) => !known.has(c)); + if (strayed.length > 0) { + throw new Error(`台账无此列:${strayed.join('、')}(${target.label} 可写列:${[...known].join('、')})`); + } + + /* + * **按列型归型,当场归不了当场拒** —— 不能留到放行那一刻。 + * + * 模型用 JSON 说话:「这个字段原文没给」它写成 `""`。草稿层照单全收没问题, + * 可 `releaseWrite` 是往真表里写 —— 空串进 numeric 列,PG 直接掀桌: + * `invalid input syntax for type double precision: ""`。**而那是人已经签完字之后**, + * 整单落成 `blocked`(实测:NE555P 原文确实没给目标价,业务判断完全正确, + * 却在最后一步炸掉)。最坏的失败点就是这里 —— 前面全对,签完才说不行。 + * + * 归型三条: + * · 空串 = **没有值** → 从补丁里去掉(草稿是合并语义,不写 = 不动,列留 NULL); + * · 数值/布尔列的字符串 → 转成真类型(模型给 `"12000"` 是常态); + * · 转不出来 → 当场抛,理由喂回下一拍让它改 —— 比签完字再炸早得多。 + */ + const typeOf = new Map(target.fields.map((f) => [f.name, f.type])); + const patch: Record = {}; + for (const [column, value] of Object.entries(input.patch)) { + const coerced = coerceToColumn(value, typeOf.get(column) ?? 'text'); + if (coerced.kind === 'reject') { + throw new Error(`列 ${column} 要 ${typeOf.get(column)},给的是 ${JSON.stringify(value)} —— 换个值或留空`); + } + // 'empty' = 没有值:不写这一列(合并语义下等于不动它) + if (coerced.kind === 'value') patch[column] = coerced.value; + } + /* + * 写之前先看一眼:值和现有草稿一模一样就是**空转**。 + * 照写不误也不会错,但 Agent 从此看不出自己在原地打转 —— 实测同一行 + * verdict 被反复重写十几拍。所以照写,但如实回报「这一笔什么也没改」。 + */ + const before = (await deps.ledger.taskDrafts(input.taskId)).find( + (d) => + d.domain === input.domain && + d.object === input.object && + Object.entries(input.key).every(([k, v]) => String(d.key[k] ?? '') === String(v)), + ); + const changed = + before === undefined || + Object.entries(patch).some(([k, v]) => JSON.stringify(before.patch[k]) !== JSON.stringify(v)); + await deps.ledger.draftWrite({ taskId: input.taskId, address: { domain: input.domain, object: input.object }, key: input.key, - patch: input.patch, + patch, }); + return { changed }; + }, + /** + * **撤掉自己写错的那一行** —— 授权面与写同一道:没声明的地址撤不了。 + * + * 撤的只是本单草稿层那一笔,正式版一个字都动不了。 + * 回 `dropped: false` = 草稿层里根本没有这一行 —— 多半是主键写法对不上 + * (`DAG…` 与 `DAG…-R0` 是两行);如实说比静默成功有用。 + */ + async draftDrop(input: { taskId: TaskId; domain: string; object: string; key: Record }) { + const allowed = await taskData.writable(input.taskId); + const target = allowed.find((a) => a.domain === input.domain && a.object === input.object); + if (target === undefined) { + throw new Error( + `台账地址未声明:${input.domain}.${input.object}(本活动可写:${ + allowed.map((a) => `${a.domain}.${a.object}`).join('、') || '无' + })`, + ); + } + const dropped = await deps.ledger.draftDrop({ + taskId: input.taskId, + address: { domain: input.domain, object: input.object }, + key: input.key, + }); + return { dropped }; }, }; @@ -244,7 +905,213 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }, }; + /** + * 录入卡的一段 —— **表名换人读名、自增列不摆给人填**(2026-08-02 用户两条)。 + * + * ① 卡面摆的是台账登记的表注释(「客户档案」),不是 `customer.profile` —— + * 界面不摆系统内的英文名。登记里没有人读名就退回地址:宁可笼统,不可编。 + * ② 声明里标了 `auto` 的列(联系人编码这类内部流水号)**从表单里拿掉**, + * 另摆一句「由系统生成」。让人填是白占一格他答不上来的表单 + * (他哪知道这家客户已经有几位联系人),让模型编更糟 —— 编重了就是覆盖别人那一行。 + * 但不能一声不吭地拿掉:那样他会以为这张档少了一格。 + */ + const entryGroup = async ( + taskId: TaskId, + address: string, + key: Readonly>, + fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>, + ) => { + const [domain, object] = address.split('.'); + const declared = (await deps.ledger.listObjects()).find((o) => o.domain === domain && o.object === object); + const { columns: autoCols, labels: autoLabels, all: colLabels } = await autoColumns(taskId, address); + const kept = fields.filter((f) => !autoCols.has(f.key)); + /* + * 那句话按**声明**说,不按「模型这次塞没塞进来」说 —— 一度写成后者, + * 于是模型照新说明书没写 `contact_code`,卡上就一个字都没有: + * 人看到联系人档只要姓名与邮箱,不知道编码去哪了。 + * 自增是这张表的事实,与模型这一拍写了什么无关。 + */ + const auto = [...autoCols].map((n) => autoLabels.get(n) ?? n); + return { + ledger: address, + ...(declared?.label === undefined || declared.label === '' ? {} : { ledgerLabel: declared.label }), + // 自增的主键也从卡面的主键行里摘掉 —— 那一格此刻还不存在,摆个空值是撒谎 + key: Object.fromEntries(Object.entries(key).filter(([k]) => !autoCols.has(k))), + /* + * 主键那几列的**中文列名** —— 卡面上摆它,不摆 `customer_code`(界面不摆英文列名)。 + * 表单里有的那几列自带 label,这里补的是**只在主键里出现**的那些 + * (如联系人档的客户编码:它由上一段定死,不在这一段的表单里)。 + */ + keyLabels: Object.fromEntries( + Object.keys(key) + .filter((k) => !autoCols.has(k)) + .map((k) => [k, colLabels.get(k) ?? k]), + ), + fields: kept.map(entryField), + ...(auto.length === 0 ? {} : { autoNote: `${auto.join('、')}由系统按顺序生成,不用你填` }), + }; + }; + + /** 这张表哪几列是基座自己生成的(连它们的中文列名)—— 认本单活动清单里的 `auto` 声明 */ + const autoColumns = async ( + taskId: TaskId, + address: string, + ): Promise<{ columns: Set; labels: Map; all: Map }> => { + const [domain, object] = address.split('.'); + const { activityId } = await taskContext.of(taskId); + const declared = (await deps.registry.manifestOf(activityId)).declarations.ledger.find( + (l) => l.domain === domain && l.object === object, + ); + const auto = (declared?.fields ?? []).filter((f) => f.auto === true); + return { + columns: new Set(auto.map((f) => f.name)), + labels: new Map(auto.map((f) => [f.name, f.label ?? f.name])), + all: new Map((declared?.fields ?? []).map((f) => [f.name, f.label ?? f.name])), + }; + }; + const cards = { + /** + * 发**补全卡** —— 对话卡族:缺口清单摆在对话区、带一张表单,人就地填齐。 + * 与执行工单分开:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。 + */ + /** + * 数据录入卡 —— 人按下确认那一刻由**基座**写草稿层(见 `desk.ts` 的回传口), + * 不回到 Agent 手里再转写一遍。这里只管把卡发出去。 + */ + async entry(input: { + taskId: TaskId; + ownerId: StaffId; + humanCallCap: number; + ask: string; + ledger: string; + key: Readonly>; + fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>; + /** 同一次确认还要落的另几张表 —— 建一位新客户 = 公司档 + 联系人档 */ + also?: ReadonlyArray<{ + title: string; + ledger: string; + key: Readonly>; + fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>; + }>; + basis?: ReadonlyArray<{ k: string; v: string }>; + }) { + const issued = await deps.humanTool.issueChatCard( + { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap }, + { + k: 'entry', + id: cardIdSchema.parse(ids.next()), + at: clock.now().toISOString(), + ask: input.ask, + ...(input.basis === undefined || input.basis.length === 0 ? {} : { basis: [...input.basis] }), + ...(await entryGroup(input.taskId, input.ledger, input.key, input.fields)), + ...(input.also === undefined || input.also.length === 0 + ? {} + : { + also: await Promise.all( + input.also.map(async (g) => ({ + title: g.title, + ...(await entryGroup(input.taskId, g.ledger, g.key, g.fields)), + })), + ), + }), + }, + ); + await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); + return { id: issued.card.id }; + }, + + async ask(input: { + taskId: TaskId; + ownerId: StaffId; + humanCallCap: number; + ask: string; + fields: ReadonlyArray<{ key: string; label: string; hint?: string; value?: string }>; + basis?: ReadonlyArray<{ k: string; v: string }>; + }) { + const cardSpan = deps.trace.beginAction({ + taskId: input.taskId, + name: `补全卡:${input.ask.slice(0, 24)}`, + kind: 'human', + input: { fields: input.fields.map((f) => f.key) }, + }); + const issued = await deps.humanTool.issueChatCard( + { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap }, + { + k: 'elicitation', + id: cardIdSchema.parse(ids.next()), + at: clock.now().toISOString(), + ask: input.ask, + ...(input.basis === undefined || input.basis.length === 0 ? {} : { basis: [...input.basis] }), + fields: input.fields.map((f) => ({ + key: f.key, + label: f.label, + ...(f.hint === undefined ? {} : { placeholder: f.hint }), + /* + * **预填值必须搬过来** —— 这里从前只搬 key/label/hint, + * 模型照剧本给的 `value` 到这一步被静默丢掉:剧本让它"尽量先把空填上", + * 它照做了,人拿到的还是一排空格,而两边都不知道东西丢在哪儿。 + */ + ...(f.value === undefined || f.value === '' ? {} : { value: f.value }), + })), + }, + ); + // 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限 + await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); + await cardSpan?.end({ output: { cardId: issued.card.id, arrival: issued.arrival } }); + return { id: issued.card.id }; + }, + + /** + * 发**决策卡** —— 岔路口摆给人点,不是让人敲字。 + * 与补全卡同一条路(对话卡族 → 对话区 → 立监听),差别只在卡面:那边是表单,这边是选项。 + */ + async decide(input: { + taskId: TaskId; + ownerId: StaffId; + humanCallCap: number; + ask: string; + options: ReadonlyArray<{ + value: string; + label: string; + detail?: string; + consequence?: string; + recommended?: boolean; + }>; + basis?: ReadonlyArray<{ k: string; v: string }>; + allowCustom?: boolean; + }) { + const cardSpan = deps.trace.beginAction({ + taskId: input.taskId, + name: `决策卡:${input.ask.slice(0, 24)}`, + kind: 'human', + input: { options: input.options.map((o) => o.value) }, + }); + const recommended = input.options.find((o) => o.recommended === true); + const issued = await deps.humanTool.issueChatCard( + { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap }, + { + k: 'decision', + id: cardIdSchema.parse(ids.next()), + at: clock.now().toISOString(), + ask: input.ask, + basis: input.basis === undefined ? [] : [...input.basis], + ...(recommended === undefined ? {} : { recommend: recommended.value }), + options: input.options.map((o) => ({ + value: o.value, + label: o.label, + ...(o.detail === undefined ? {} : { detail: o.detail }), + ...(o.consequence === undefined ? {} : { consequence: o.consequence }), + ...(o.recommended === undefined ? {} : { recommended: o.recommended }), + })), + allowCustom: input.allowCustom ?? false, + }, + ); + await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); + await cardSpan?.end({ output: { cardId: issued.card.id, arrival: issued.arrival } }); + return { id: issued.card.id }; + }, + async issue(input: { taskId: TaskId; ownerId: StaffId; @@ -281,44 +1148,336 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { * 是这里没填,不是设计上不要。放行时人要修订的值(如「这一单到底要不要选型」) * 就靠它;答完经 `writeOutput` 落台账,覆盖上游步写的同一列。 */ + /* + * 卡面声明的字段 → 表单域。**两种写法都收**: + * · 一串字段名 `['result']` —— 执行工单常见,人从空白填起; + * · 一串对象 `[{key,label,ai,ledger}]` —— 验收工单要的形态: + * `ai` 是**AI 拟值**(PRD:验收报告「AI 已填好、可编辑」), + * `ledger` 是这个值改完该落回哪个台账地址(`域.对象.列`)。 + * + * 没有 `ai`,「可编辑」就没有可编辑的东西;没有 `ledger`,人改了也只留在工单留痕里, + * 落地时用的还是 AI 的原值 —— 修订永远落不下来(第 12 章③)。 + */ const declaredFields = Array.isArray(input.card['fields']) - ? (input.card['fields'] as string[]).map((key) => ({ key, label: key, kind: 'text' as const, required: true })) + ? (input.card['fields'] as unknown[]) + .map((raw) => { + if (typeof raw === 'string') { + return raw === '' ? undefined : { key: raw, label: raw, kind: 'text' as const, required: true }; + } + if (typeof raw !== 'object' || raw === null) return undefined; + const f = raw as Record; + const key = String(f['key'] ?? '').trim(); + if (key === '') return undefined; + const ai = f['ai'] === undefined || f['ai'] === null ? undefined : String(f['ai']); + const ledger = String(f['ledger'] ?? '').trim(); + return { + key, + label: String(f['label'] ?? key), + kind: 'text' as const, + required: f['required'] !== false, + ...(ai === undefined ? {} : { ai }), + ...(ledger === '' ? {} : { ledger }), + }; + }) + .filter((f): f is NonNullable => f !== undefined) : undefined; if (input.workOrder === 'acceptance') { /* - * 选择组上卡:活动契约声明了 selective 出口的,放行必须**恰选一口**(AM 1.2)—— - * 那一口就是这次运行的分支结论,下游异或网关判的正是它。 - * 组不在步的卡面里声明(它是契约的事实),从 manifest 现读。 + * 提交放行**只走 release.submitForRelease** —— 它才是那道门(OS 7.3): + * 预检全过 → 写类意向从 drafted 推到 prechecked → 已验证动作清单上卡。 + * + * 从前这里直接 `issueAcceptance`,绕过了预检:后果是写类动作登记完永远停在 + * drafted,`land()` 只执行 prechecked,于是**放行时一个外部动作都不执行**; + * 卡面 `actions` 恒空、`criteria` 恒是一条硬编码 L5 —— 人在验收单上看不见 + * 「通过之后系统会做什么」,那这一下「通过」就是闭着眼签的。 + * + * 预检不过不发卡(抛 PrecheckFailedError / DeliverablesMissingError), + * 由回合循环捕获成「行动被拒」喂回下一拍 —— 打回环内修正,正是规范要的。 */ const { activityId } = await taskContext.of(input.taskId); const manifest = await deps.registry.manifestOf(activityId); const selective = manifest.contract.output.selective[0]; - const issued = await deps.humanTool.issueAcceptance(context, { + const scope = await taskData.view(input.taskId); + + /* + * 必交付物:按声明的台账地址数行(任务视图 = 正式版 + 本单草稿,读己之写)。 + * 没声明地址的退回作用域键判断 —— 有值即算有,没值即缺。 + */ + // 本单写过的台账地址 —— 「读己之写」的那一份,别单的落地行不算自己的交付 + const mine = new Set((await deps.ledger.taskDrafts(input.taskId)).map((d) => `${d.domain}.${d.object}`)); + const deliverables = manifest.contract.requiredDeliverables.map((d) => ({ + key: d.key, + label: d.label, + present: deliverablePresent(d.ledger, d.key, scope, mine), + })); + + /* + * 判据逐条标注真实等级:L1/L2 带 predicate 的**当场机检**(S-FEEL 对任务视图求值), + * L3–L5 交人 —— 等级不得谎报(安装期 acceptance-level-misreported 已挡住漏配)。 + */ + /* + * 裁量判据(L3/L4)读 **Agent 的裁定**,不再一律判 false。 + * + * 从前这里写死 `machineCheckable ? predicate : false`:L3「吃不准降 B」、 + * L4「去向判断合乎业务常理」这类机器判不了的,于是**永远**是红叉 —— + * 一单办得再干净,卡面也停在 5/8,人签字时面对三条谁也消不掉的未达标, + * 「达标数」这个读数就此失去意义(反正它永远不满)。 + * + * 它们本就有归属:`judge` 行动 —— 「评估留痕,可回放可归因」。 + * 裁定按 key 或 label 认(模型两种都可能写),没裁过的照旧算未达标: + * **没人裁过 ≠ 通过**,那是这道门的意义所在。 + */ + const judged = await taskContext.judgements(input.taskId); + /* 判据 key → 谓词:拒回时好把「这条读的是哪几个数」摆给模型看 */ + const criteriaOf = new Map( + manifest.contract.acceptanceCriteria + .filter((c) => c.predicate !== undefined && c.predicate.trim() !== '') + .map((c) => [c.key, c.predicate ?? '']), + ); + const verdictOf = (c: { key: string; label: string }): { satisfied: boolean; rationale?: string } | undefined => + judged.get(criterionKey(c.key)) ?? judged.get(criterionKey(c.label)); + const criteria = manifest.contract.acceptanceCriteria.map((c) => + criterionCheck(c, scope, verdictOf(c)), + ); + + /* + * **只许剩放行那一条**(2026-07-31 用户定的口径),但两类判据把的是两道不同的关: + * + * · **机检(L1/L2)**:数不对就是活没干完 —— 必须为真才让提交。 + * · **裁量(L3/L4)**:必须**裁过**,裁成什么不拦。 + * + * 后半句是 2026-08-01 用实测改回来的。从前这里对裁量项也要求「为真」, + * 而裁量的本义恰恰是**可以裁出「不满足」**,且不满足正是最该交给人的那种情形。 + * 于是模型只剩两条路:改数据,或者改裁断 —— 两条都是撒谎,它两条都走过: + * ① 一颗料只缺目标价,它如实记 missing_minor_total = 1,被判据连拒四次, + * 第五拍把那个数改回 0,判据当场变绿(台账从此写着「缺选填 0 条」); + * ② 换成 L4 之后,它如实裁了「未满足」并写了理由,提交照样被拒 —— + * 判、提、拒、再判……十二轮 21 拍 187k token,最后一拍把裁断翻成「满足」才过。 + * **一条只接受「是」的判据不是判据,是一句教模型说谎的提示。** + * + * 裁成「不满足」的,连着理由一起进验收卡:那正是签字的人要看的东西 —— + * 「AI 说这一条它办不到,理由是……」比一片虚假的绿勾有用得多。 + */ + const unmet = criteria.filter((c) => + c.level === 'L5' ? false : c.settled === undefined ? !c.pass : !c.settled, + ); + if (unmet.length > 0) { + /* + * 判据名要**用引号夹起来**,等级摆在引号外(2026-07-31 修)。 + * + * 从前写成 `${label}(${level})`,后面又说「criterion 照判据名原样写」—— + * 模型照做了:它把 `(L3)` 一并抄进 criterion,于是裁定的键与判据名对不上, + * 裁了等于没裁,再提交再被拒,连拒五拍熔断(实测 A2 死于此)。 + * 让模型抄什么,就得让那个东西在句子里**看得出边界**。 + */ + /* + * 机检项要**连着它此刻的读数**一起摆(2026-07-31 加,与收步判据同一口径)。 + * 只说「C 类都开了替代评估请求 没达标」,模型看不见 alt_request_total 与 + * type_c_total 现在各是几 —— 它以为自己开齐了,原样再提一遍,连拒五拍熔断 + * (实测 A1 死于此)。差的那个数摆出来,才是一句话能办的事。 + */ + const detail = unmet.map((c) => { + const head = `「${c.label}」[${c.level}]`; + if (c.settled === false) return `${head} 还没裁过`; + const predicate = criteriaOf.get(c.key); + return predicate === undefined ? head : `${head} 判据 ${predicate}${readingsOf(predicate, scope)}`; + }); + /* + * **建议要分清「数不对」与「行不对」**(2026-08-01 实测改)。 + * + * 从前只有一句「去把台账里那几个数改对(通常是汇总表的 _total 计数列)」—— + * 而实测那一单差的根本不是数:客户说本轮跳过的那颗料被多写了一行, + * `line_total = 9` 是对的、表里 10 行是错的。这句话把模型直直引向改数, + * 它照做(把 9 改成 10),`skipped_total` 又不对,再拒 —— 五拍熔断。 + * 读数里已经把两份主键名单摆出来了,一比就看得出多的是哪一行; + * **指路要指到那一行上去**,不是指向那个数。 + */ + /* + * **「活没干完」得说成活没干完**(2026-08-02 实测逼出来的)。 + * + * 那一单跳过了打标与定去向就来提交,判据 `…_type_tag_a + _b + _c = …_rows` + * 正确地拦住了 —— 而拒回的话给了两条路:改数、改行。两条都不对: + * 数是对的(它诚实地报了「3 条没打标」)、行也是对的(记录三行一行不多), + * **真正该做的是回去把那一步办了**。于是它只能原样重提,连撞五拍熔断。 + * + * 判法很硬:判据里点名的那张表,**这一单一行都没写**(基座数的 `_rows` 是 0), + * 那就不是数错也不是行错,是那一步压根没办。 + * 「拒回的话是模型下一拍的行动指南,指错方向比不给指引更糟」—— + * 指向两扇打不开的门,比不指还糟。 + */ + const empty = [ + ...new Set( + unmet.flatMap((c) => + [...(criteriaOf.get(c.key) ?? '').matchAll(/([a-z][a-z0-9_]*)_rows\b/g)] + .map((m) => m[1] ?? '') + .filter((name) => name !== '' && Number(scope[`${name}_rows`] ?? -1) === 0), + ), + ), + ]; + const undone = + empty.length === 0 + ? '' + : `。**${empty.join('、')} 这一单一行都没写** —— 那不是数错也不是行错,` + + '是产出它的那一步还没办:回去把它办了(该调的工具调、该写的行写),再来提交。'; + throw new Error( + `还不能提交放行:除「人放行」外还有 ${unmet.length} 条判据没过 —— ` + + detail.join(';') + + undone + + '。机检项先分清差的是**数**还是**行**:' + + '① 数不对 —— 汇总表的 _total 与实情对不上,把那个数改对;' + + '② 行不对 —— 上面两份主键名单一比就看得出多了或少了哪一行:' + + '多的用 drop 撤掉、少的补写。**名单里那一段 JSON 原样粘进 keys 就行**,' + + '一个字符都别改(值里的空格就是空格,换成横杠就撤不到那一行了)。' + + '**数是台账的结论,别为了过判据去改它** —— 名单说多了一行,那就是真多了一行。' + + '别原样重提;裁量项(L3/L4)用 judge 行动裁一次即可 —— **裁成「不满足」照样能提交**,' + + '把理由写清楚交给放行的人定;criterion 只抄引号里那一句,别把后面的等级标记带上。', + ); + } + + /* + * 可修订字段 = **契约声明的那几个**,拟值从台账现读。 + * + * 设计期定「哪几个值该让人改、改完落哪儿」(`contract.revisableAtRelease`), + * 运行期填「此刻它是什么值」—— 拟值不让模型重述:重述就会和台账里的值不一致, + * 而以哪个为准说不清。活动在 submit 上另给的字段也收,与声明的并到一处。 + */ + const revisable = manifest.contract.revisableAtRelease.map((r) => ({ + key: r.key, + label: r.label, + ledger: r.ledger, + ...(scope[r.key] === undefined || scope[r.key] === null ? {} : { ai: String(scope[r.key]) }), + })); + /* + * 同名字段**只留第一个** —— 表单按 key 索引,两个同 key 的字段本就摆不出来。 + * + * 从前只拿声明字段去重 `revisable`,没拿它去重**自己**: + * A4 一单三条料,模型很自然地把三条的 `quoted_price` 都列成可改字段 —— + * 基座以「表单字段 key 重复」拒发,而模型看不出该删哪一个(三个都是真的), + * 于是原样再提一遍,连拒五拍熔断(实测 A4 死于此)。 + * 逐行改值本来就不走这张表单(它是整单级的修订面),去重是如实的收敛,不是丢数据。 + */ + const seenField = new Set(); + const mergedFields = [...revisable, ...(declaredFields ?? [])] + .filter((f) => { + if (seenField.has(f.key)) return false; + seenField.add(f.key); + return true; + }) + .map((f) => ({ kind: 'text' as const, required: false, ...f })); + + /* + * **验收页上那张可就地改的成果表**(2026-08-01)。 + * + * 必交付物声明了 `editableAtRelease` 的,基座在放行卡上铺一张绑台账的表: + * 整份行都在、逐格可改。验收是最重的一道门 —— 人得能在成果本身上改, + * 而不只是在旁边几个整单级字段上改;改完由基座写回草稿层、随放行合并,AI 不经手。 + */ + const editable = await Promise.all( + manifest.contract.requiredDeliverables + .filter((d) => d.ledger !== undefined && (d.editableAtRelease?.length ?? 0) > 0) + .map(async (d) => { + const address = d.ledger ?? ''; + const declared = manifest.declarations.ledger.find((l) => `${l.domain}.${l.object}` === address); + const fields = d.editableAtRelease ?? []; + return { + t: 'table' as const, + title: `${d.label}(可就地改)`, + editable: true, + ledger: address, + fields: [...fields], + cols: fields.map((f) => declared?.fields.find((x) => x.name === f)?.label ?? f), + rows: [], + }; + }), + ); + const releaseBlocks = await fillBoundTables(input.taskId, editable); + + const submitted = await deps.release.submitForRelease({ + taskId: input.taskId, + ownerId: input.ownerId, + humanCallCap: input.humanCallCap, + deliverableId: String(input.card['deliverable'] ?? manifest.contract.requiredDeliverables[0]?.key ?? 'deliverable'), title, - basis: basis.length > 0 ? basis : [{ k: '来源', v: '步序声明' }], - deliverable: String(input.card['deliverable'] ?? 'deliverable') as never, - criteria: [{ label: '成果齐备', level: 'L5', pass: false }], - fields: declaredFields ?? [], - ...(selective === undefined - ? {} - : { - portChoice: { - group: selective.group, - ports: selective.ports.map((port) => ({ name: port.name, label: port.description })), - }, - }), + basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型收口' }], + deliverables, + /* + * **卡面上不摆 L5**(2026-08-01 用户当场指出:「当前卡片就是放行卡, + * 也不该显示在这里 —— 这里是判据」)。 + * + * L5 是**责任关**,而这张卡本身就是那道关。把它列进判据表,等于在放行卡上 + * 摆一条恒为「未达标」的项:人一路核到底,最后一行永远是红的, + * 而消掉它的动作就是按下同一张卡上的「确认通过」。 + * 判据是**签字前要核的东西**,不是签字这件事自己; + * 「n/m 达标」那个数也因此永远差一条,看着像还有活没干完。 + * + * 声明照旧留在契约里(它说明这份交付物必须由人放行,并由 `T_release` + * 的人工激活落实),提交门与预检也照旧把它排除在外 —— 少的只是这一处呈现。 + */ + criteria: criteria.filter((c) => c.level !== 'L5'), + ...(mergedFields.length === 0 ? {} : { fields: mergedFields }), + ...(releaseBlocks.length === 0 ? {} : { blocks: releaseBlocks as Array> }), }); + /* + * 选择组上卡由 humanTool 侧补齐:组是契约的事实,不在卡面声明。 + * + * **能算就别问**:组内每一口都写了 `when`,按序求值(首个命中即出)—— + * 卡上只如实告诉人走哪一口、凭哪一条。去向多半是台账里那几个数的函数 + * (有没有 C 类、有没有 B 类);让人凭印象点一下,等于把一个可回放的判定 + * 换成一次不可回放的手感。有一口没写谓词就算不出来,退回「人选一口」。 + */ + if (selective !== undefined) { + const routed = routePort(selective, scope, (expression, s) => evaluatePredicate(expression, s)); + if (routed === undefined) { + await deps.humanTool.attachPortChoice(submitted.workOrderId, { + group: selective.group, + ports: selective.ports.map((port) => ({ name: port.name, label: port.description })), + }); + } else { + await deps.humanTool.attachRoutedPort(submitted.workOrderId, routed); + await memoryRecorder.append({ + taskId: input.taskId, + kind: 'engine-transition', + title: `去向已算出:${selective.group} → ${routed.name}(${routed.because})`, + detail: { group: selective.group, port: routed.name, because: routed.because }, + }); + } + } await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); - await cardSpan?.end({ output: { cardId: issued.order.id, arrival: issued.arrival } }); - return { id: issued.order.id }; + await cardSpan?.end({ output: { cardId: submitted.workOrderId, precheck: submitted.report.verified.length } }); + return { id: submitted.workOrderId }; } const fields = declaredFields ?? [{ key: 'result', label: '结果', kind: 'textarea' as const, required: true }]; + /* + * 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。 + * + * 执行工单的契约一直收 `blocks`,而这里从没往下传:活动照说明书派了一张 + * 「带可编辑需求表」的执行单,人收到的却只有一句话,一张表都没有(实测)。 + * 形状交给契约层校验(`viewBlockSchema`),这里只负责别把它弄丢; + * 认不出的块由 zod 拒下,不至于把脏东西端上卡面。 + */ + const raw = Array.isArray(input.card['blocks']) ? input.card['blocks'] : undefined; + const blocks = raw === undefined ? undefined : await fillBoundTables(input.taskId, raw); + /* + * **作业指导书要到得了卡面**(2026-08-02 用户:「执行卡应该是查看工单按钮和作业指导书」)。 + * + * 活动照骨架写了 `steps` / `standard`,这里从前**一个字都不往下传** —— + * 于是卡上只剩一句 ask 和一张表:人拿到「请核对确认」四个字, + * 不知道该核什么、核到什么程度算完。空着不是简洁,是把交代省掉了。 + * (「步序声明」那句旧注说的是 flow 期的步序,与这份给人看的指导书不是一回事。) + */ + const steps = Array.isArray(input.card['steps']) + ? input.card['steps'].map((x) => String(x)).filter((x) => x.trim() !== '') + : []; + const standard = String(input.card['standard'] ?? '').trim(); const issued = await deps.humanTool.issueExecution(context, { title, - basis: basis.length > 0 ? basis : [{ k: '来源', v: '步序声明' }], + basis: basis.length > 0 ? basis : [{ k: '来源', v: '案例模型当下局面' }], ask: String(input.card['ask'] ?? input.card['reason'] ?? title), fields, + ...(steps.length === 0 ? {} : { steps }), + ...(standard === '' ? {} : { standard }), + ...(blocks === undefined ? {} : { blocks: blocks as ExecutionWorkOrderDraft['blocks'] }), }); // 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限 await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); @@ -327,6 +1486,108 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }, }; + /** + * 绑了台账的可编辑表:**行由基座从草稿层铺,不由活动塞进回复里**。 + * + * 人要在卡上改的是那份清单本身(PRD 的执行页就是整份逐格可改)。让活动把整份塞进 + * 回复里有两处死结:回复有长度上限,铺长了半途截断整拍作废(实测); + * 而那些行**台账里已经有了**,让模型再抄一遍是拿 token 换一次抄错的机会。 + * + * 铺不出来(地址写错、这一单还没写过那张表)就**原样留着活动给的行** —— + * 宁可少一份便利,不要把人正在办的那张卡弄成空表。 + */ + const fillBoundTables = async (taskId: TaskId, blocks: readonly unknown[]): Promise => { + const drafts = await deps.ledger.taskDrafts(taskId); + return blocks.map((block) => { + if (typeof block !== 'object' || block === null) return block; + const b = block as Record; + if (b['t'] !== 'table' || typeof b['ledger'] !== 'string' || !Array.isArray(b['fields'])) return block; + const address = b['ledger']; + const fields = (b['fields'] as unknown[]).map((f) => String(f)); + const rows = drafts + .filter((d) => `${d.domain}.${d.object}` === address) + .map((d) => { + const row = { ...d.key, ...d.patch } as Record; + return fields.map((f) => { + const v = row[f]; + return v === null || v === undefined ? '' : String(v); + }); + }); + return rows.length === 0 ? block : { ...b, rows }; + }); + }; + + /** + * 未命中转人(§6.2)—— **人是工具的一种实现**。 + * + * 系统里没有这件工具时,从前是抛错:Agent 拿到一个异常,既不知道该等谁,也没人被通知, + * 于是这一单就停在那儿 —— 名义上「工具未注册」,实际上是**一步走不动**。 + * 现在改成开一张执行卡请人做,人填回的表单值就是这次调用的出参,下一拍直接取到。 + * + * 两种不命中,卡面不一样: + * · `tool-unimplemented` 有契约无实现 —— 卡面 = 契约的 `guide` 段 + 本次入参 + 按出参 Schema 生成的回填表单; + * · `tool-unregistered` 连契约都没有 —— 如实说明系统里没有这件工具,只给一格自由文本。 + * + * 一次调用只发一张卡:入参指纹 `callKey` 做内容地址,重问拿同一份答案。 + */ + const requestHandoff = async ( + input: { tool: string; input: Record; taskId: TaskId }, + contract: ToolContract | undefined, + ): Promise<{ ok: boolean; output?: Record; awaitingHuman?: { workOrderId: string } }> => { + const key = callKeyOf(input.tool, input.input); + const existing = await deps.toolHandoff.find(input.taskId, key); + if (existing?.state === 'answered') { + // 人已答:值即出参(按出参 Schema 归型后交回活动) + return { ok: true, output: coerceBySchema(contract?.output, existing.output ?? {}) }; + } + if (existing !== undefined) return { ok: false, awaitingHuman: { workOrderId: existing.workOrderId } }; + + const context = await taskContext.of(input.taskId); + const name = contract?.displayName ?? input.tool; + const guide = contract?.guide; + const issued = await deps.humanTool.issueExecution( + { taskId: input.taskId, ownerId: context.ownerId, humanCallCap: context.humanCallCap }, + { + title: `请代办:${name}`, + // 依据 = 本次调用的入参原文:人得看见「拿什么去做」,不然只能猜 + basis: [ + { k: '工具', v: name }, + ...Object.entries(input.input).map(([k, v]) => ({ + k, + v: typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v), + })), + ], + ask: + guide?.ask ?? + (contract === undefined + ? `系统里还没有这件工具(${input.tool})。这一步请你直接做,把结果填在下面。` + : `这件工具(${name})系统里还没有可用实现。请你按契约把这一步做掉,把结果填在下面。`), + standard: guide?.standard ?? '照实填写;做不了就说明卡在哪,不要编数。', + ...(guide?.steps === undefined ? {} : { steps: [...guide.steps] }), + fields: handoffFields(contract?.output).map((f) => ({ ...f })), + submitLabel: '提交结果', + }, + ); + await deps.toolHandoff.open({ + id: ids.next(), + taskId: input.taskId, + tool: input.tool, + callKey: key, + input: input.input, + workOrderId: issued.order.id, + }); + // 立监听听人:卡回传驱动下一拍(不立就没人叫醒这一单) + await listeners.register({ taskId: input.taskId, eventType: 'human', keyFilter: { cardId: issued.order.id }, deadline: 'P3D' }); + await taskStore.addConsumption(input.taskId, { humanCalls: 1 }); + await memoryRecorder.append({ + taskId: input.taskId, + kind: 'engine-transition', + title: `${name} 无可用实现 —— 已转人代办`, + detail: { tool: input.tool, workOrderId: issued.order.id, registered: contract !== undefined }, + }); + return { ok: false, awaitingHuman: { workOrderId: issued.order.id } }; + }; + const toolGateway = { invoke: async (input: { tool: string; input: Record; activityId: string; taskId: TaskId; stepNo?: number }) => { // 每次工具调用一个 span —— 观测面要看得见活动内部到底调了什么、花了多久 @@ -343,6 +1604,15 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { await span?.fail(error); throw error; }); + // 未注册 / 有契约无实现:不是失败,是「这件事该由人做」 + if (!result.ok && (result.error?.code === 'tool-unregistered' || result.error?.code === 'tool-unimplemented')) { + const handoff = await requestHandoff(input, await deps.toolbox.get(input.tool)); + await span?.end({ + output: handoff.ok ? (handoff.output ?? {}) : { handoff: handoff.awaitingHuman?.workOrderId }, + attributes: { handoff: 'human' }, + }); + return handoff; + } await span?.end({ output: result.ok ? (result.output ?? {}) : { error: result.error?.message } }); return { ok: result.ok, @@ -351,18 +1621,86 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }; }, isWriteEffect: async (tool: string) => (await deps.toolbox.get(tool))?.effect === 'write', + /** + * 这一单能调什么 —— 声明 × 目录编译而来,**只给功能工具**。 + * + * 系统内化那几件(`human.ask` / `ledger.write` / LLM 五件)在单拍路径上**不是工具**, + * 是独立的行动型(human / write / …)。把它们混进「可调工具」里,Agent 就会 + * 拿 `tool: ledger.write` 去调 —— 缝里查无此工具,当场转人代办: + * 一张「请代办:ledger.write」的卡送到人手上,而那本是它自己一句话就能干的事(实测踩过)。 + * 开环路径反过来:那边它们**就是**工具(经 `busOf(taskId)` 拿全套)。 + */ + busOf: async (activityId: string) => { + const report = await busForActivity(activityId); + return report.tools + .filter((t) => t.kind === 'function') + .map((t) => ({ id: t.id, description: t.description, input: t.input, effect: t.effect })); + }, registerIntent: async (input: { taskId: TaskId; tool: string; input: Record; label: string }) => { // 写类动作登记为动作意向:入参冻结,放行后由系统执行落地 + /* + * 幂等键**只有一个公式** —— `${taskId}:${intentId}`,与落地时 `land()` 用的同一个 + * (`idempotencyKey` 从 release 导出,两边共用)。 + * + * 从前登记侧自拼 `${taskId}:${tool}:${随机}`,落地侧另算一个:两把钥匙对不上, + * 「同一动作至多执行一次」就成了空话 —— 重试会以另一把键再执行一遍。 + */ + const intentId = ids.next(); await deps.release.registerIntent(input.taskId, { - id: ids.next(), + id: intentId, tool: input.tool, input: input.input, - idempotencyKey: `${input.taskId}:${input.tool}:${ids.next()}`, + idempotencyKey: idempotencyKey(input.taskId, intentId), label: input.label, }); }, }; + /** + * 把人在验收报告上改过的值落回草稿层 —— 放行合并前的最后一笔。 + * + * 只认**声明了台账地址**的字段(`FormField.ledger`),且值确实与 AI 拟值不同才写: + * 一个字没改就不该在台账上留一笔「改过」的痕。 + */ + const applyRevisions = async (taskId: TaskId): Promise => { + const orders = await deps.humanTool.allWorkOrders(taskId); + const drafts = await deps.ledger.taskDrafts(taskId); + for (const order of orders) { + if (order.mode !== 'acceptance' || order.done === undefined) continue; + const answered = order.done.values ?? {}; + for (const field of order.fields ?? []) { + const address = field.ledger; + if (address === undefined || address === '') continue; + const value = answered[field.key]; + if (value === undefined || value === field.ai) continue; + + const at = address.indexOf('.'); + if (at <= 0) continue; + const domain = address.slice(0, at); + const object = address.slice(at + 1); + const rows = drafts.filter((d) => d.domain === domain && d.object === object); + if (rows.length !== 1) { + // 不猜是哪一行:如实记痕,人看得见「这一改没落下去」 + await memoryRecorder.append({ + taskId, + kind: 'result', + title: `修订未落台账:${field.label}(${address} 本单有 ${rows.length} 行,改哪一行说不清)`, + detail: { field: field.key, address, rows: rows.length }, + }); + continue; + } + await taskData.draftWrite({ taskId, domain, object, key: rows[0]!.key as never, patch: { [field.key]: value } }); + await memoryRecorder.append({ + taskId, + kind: 'ledger-write' as never, + title: `人工修订:${field.label} → ${String(value)}(落 ${address})`, + // `by` = 这一改是**谁**改的 —— 日志按它认主语(没有它就是基座自己落的账) + detail: { field: field.key, address, from: field.ai, to: value, by: (await taskContext.of(taskId)).ownerId }, + }); + } + } + }; + /* ── skill-engine ─────────────────────────────────────────────── */ const skills = createSkillEngineService({ catalog: { @@ -399,7 +1737,19 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { const result = await deps.modelGateway.generate(call); await span?.end({ output: { chars: result.text.length }, - attributes: { tokens: result.usage.inputTokens + result.usage.outputTokens, mode: result.mode }, + /* + * 入/出分开记,推理长度单独记 —— 排「一拍为什么慢」全靠这三个数: + * 只有总数时,「入参膨胀」与「推理跑长」两种病看起来一模一样 + * (实测 56s 离群拍:可见出参 719 字对不上多出的 4k token, + * 拆开才看得见是隐藏推理在烧解码)。 + */ + attributes: { + tokens: result.usage.inputTokens + result.usage.outputTokens, + tokIn: result.usage.inputTokens, + tokOut: result.usage.outputTokens, + reasonChars: result.reasoning?.length ?? 0, + mode: result.mode, + }, }); return result; } catch (error) { @@ -425,8 +1775,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { * 功能工具按活动白名单查目录;人类工具恒在(Harness 给的能力); * LLM 五件仅当活动声明了模型用途名才给 —— 没声明模型却会问模型,是越权也无从归因。 */ - const busOf = async (taskId: TaskId) => { - const { activityId } = await taskContext.of(taskId); + const busForActivity = async (activityId: string) => { const manifest = await deps.registry.manifestOf(activityId); const catalog = new Map( (await deps.toolbox.list()).map((c) => [ @@ -445,7 +1794,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { catalog, declaredModels: manifest.declarations.models, }); - // 声明了却查无此工具 / 已停用:如实记痕,不静默 —— Agent 手上少了件东西,得看得见 + return report; + }; + + /** + * 开环路径要的那份(带任务上下文)——「缺件如实记痕」发生在这一层: + * 声明了却查无此工具 / 已停用,Agent 手上就少了件东西,得在任务轨迹上看得见。 + */ + const busOf = async (taskId: TaskId) => { + const { activityId } = await taskContext.of(taskId); + const report = await busForActivity(activityId); if (report.missing.length > 0 || report.disabled.length > 0) { await memoryRecorder.append({ taskId, @@ -479,6 +1837,19 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }, isWriteEffect: (tool) => toolGateway.isWriteEffect(tool), registerIntent: (input) => toolGateway.registerIntent(input), + // 台账草稿写:与单拍路径共用同一道把关(地址与列名都验) + draftWrite: async (input) => { + const at = input.address.indexOf('.'); + if (at <= 0 || at === input.address.length - 1) { + throw new Error(`台账地址要写成「域.对象」:收到 ${input.address || '(空)'}`); + } + const domain = input.address.slice(0, at); + const object = input.address.slice(at + 1); + for (const row of input.rows) { + await taskData.draftWrite({ taskId: input.taskId, domain, object, key: row.key, patch: row.patch }); + } + return { written: input.rows.length }; + }, issueCard: (input) => cards.issue(input), listen: (input) => listeners.register(input), generate: async (input) => { @@ -509,10 +1880,16 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { loadActivityMemory: async (activityId: string, limit: number) => (await deps.memory.loadActivityMemory(activityId, { limit })).map((m) => ({ layer: m.layer, statement: m.statement })), // 任务自身的最近轨迹:窗口大小是活动声明的唯一 Harness 记忆配置(manifest.declarations.harness.memoryWindow) - window: async (taskId) => { + window: async (taskId, want) => { const { activityId } = await taskContext.of(taskId); - const size = (await deps.registry.manifestOf(activityId)).declarations.harness.memoryWindow; - return (await deps.memory.window(taskId, size)).map((m) => ({ kind: m.kind, title: m.title })); + // 不指定就按活动声明的窗口(叙事面);熔断检测会显式要一个更大的 + const size = want ?? (await deps.registry.manifestOf(activityId)).declarations.harness.memoryWindow; + // detail 一起带上 —— 人回填的值在 detail.values 里,不给就等于把答案藏了起来 + return (await deps.memory.window(taskId, size)).map((m) => ({ + kind: m.kind, + title: m.title, + ...(m.detail === undefined ? {} : { detail: m.detail }), + })); }, }, tasks: taskContext, @@ -584,11 +1961,26 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { caps: manifest.contract.resources.budget, envelope: { ...envelope }, inputSchema: manifest.contract.input.data, + ...(manifest.contract.input.inboundComm === undefined + ? {} + : { inboundComm: manifest.contract.input.inboundComm }), businessModel: manifest.assets.businessModel, stallAfter: manifest.declarations.harness.stallAfter, }; }, }, + /* + * 沟通记录 —— 一单往来的原件存档。直接落库而不绕模块:它是**任务的一件附属事实**, + * 只有起单归档与工作台那一屏读它两处用,不值得为它立一个模块出口。 + */ + comms: { + async record(input) { + await db.execute(sql` + insert into sys.task_comm (id, task_id, direction, channel, party, subject, body, attachments, note) + values (${ids.next()}, ${input.taskId}, ${input.direction}, ${input.channel}, ${input.party}, + ${input.subject}, ${input.body}, ${JSON.stringify(input.attachments)}::jsonb, ${input.note})`); + }, + }, schemas: deps.schemas, /* * 唯一的引擎(2026-07-29 用户定:活动不再分 flow 和 loop,内部实现都是 Loop)。 @@ -616,6 +2008,17 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { landing: { /** 放行通过即刻落地:草稿合并 + 动作执行(附幂等键)→ 收敛才发射输出口(OS 7.3) */ async land(input) { + /* + * **人在验收报告上改的值,放行前落进草稿层** —— 这样 `releaseWrite` 合并的是修订后的事实。 + * + * 不做这一步的后果:改动只留在工单留痕里,落地时用的还是 AI 的原值 —— + * 「验收报告 AI 已填好、可编辑」就成了摆设(第 12 章③ 的另一半)。 + * + * 地址由字段自己声明(`FormField.ledger` = `域.对象`),只改**本单在该对象的那一行**: + * 该对象本单写了多行时不猜是哪一行,如实记一条痕跳过 —— 逐行的修改得回环里改, + * 在一张验收单上改几十行是另一件事。 + */ + await applyRevisions(input.taskId); const outcome = await deps.release.land({ taskId: input.taskId, ownerId: input.ownerId, @@ -1007,5 +2410,18 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }, }; - return { task, process: tracedProcess, trigger }; + /** 此刻这一单的判据 —— 与提交门同一段(`criterionCheck`),按当下数据现算 */ + const checks = { + of: async (taskId: TaskId) => { + const { activityId } = await taskContext.of(taskId); + const manifest = await deps.registry.manifestOf(activityId); + const scope = await taskData.view(taskId); + const judged = await taskContext.judgements(taskId); + return manifest.contract.acceptanceCriteria.map((c) => + criterionCheck(c, scope, judged.get(criterionKey(c.key)) ?? judged.get(criterionKey(c.label))), + ); + }, + }; + + return { task, process: tracedProcess, trigger, loop, checks, views: viewGateway }; } diff --git a/apps/api/src/app/workers/pulse.ts b/apps/api/src/app/workers/pulse.ts new file mode 100644 index 0000000..5392aa9 --- /dev/null +++ b/apps/api/src/app/workers/pulse.ts @@ -0,0 +1,56 @@ +/** + * 唤醒脉冲 —— **人答完了就立刻接着跑**,不必等下一轮轮询。 + * + * 两个 worker 都是 2 秒一轮(派发器投递事件、推进器推 running 的单)。 + * 平时够用:后台自己转,快两秒慢两秒没人看着。可**人刚点完一张卡**那一下不同 —— + * 他正盯着屏幕等反应。实测点「确认写入」到引擎真转起来要 **6.8 秒** + * (投递等一轮 + 推进等一轮),这 6.8 秒里界面上一动不动: + * 办事条只认 `runtime === 'running'`,而那时它还是 `awaiting-event`。 + * 人看到的是「我点了,没反应」——于是再点一次。 + * + * 所以给路由一个踢一脚的口子:回传落库后 `void pulse()`,两个 worker 就地各跑一轮。 + * **不 await**:推进一拍要调模型,秒级;回传该立刻返回,推进在后台接着办。 + * + * 合并而不排队:正在跑就记一个「还要再来一轮」,跑完补一轮就够 —— + * 连点五下不该叠出五轮推进。 + */ +type Tick = () => Promise; + +const ticks: Tick[] = []; +let running = false; +let again = false; + +/** 由 `main.ts` 在启动时登记(顺序有意义:先投递、后推进) */ +export function registerPulse(tick: Tick): void { + ticks.push(tick); +} + +/** 测试用:清空登记,免得用例之间互相串 */ +export function resetPulse(): void { + ticks.length = 0; + running = false; + again = false; +} + +export async function pulse(): Promise { + if (running) { + again = true; + return; + } + running = true; + try { + do { + again = false; + for (const tick of ticks) { + try { + await tick(); + } catch (error) { + // 一个 worker 出错不该拖垮另一个 —— 它下一轮定时还会再跑 + process.stderr.write(`[pulse] ${error instanceof Error ? error.message : String(error)}\n`); + } + } + } while (again); + } finally { + running = false; + } +} diff --git a/apps/api/src/app/workers/task-advancer.ts b/apps/api/src/app/workers/task-advancer.ts index 2a37b5f..022f1f3 100644 --- a/apps/api/src/app/workers/task-advancer.ts +++ b/apps/api/src/app/workers/task-advancer.ts @@ -18,6 +18,24 @@ export type AdvancerOptions = { readonly maxTicks?: number; }; +/** + * 这一拍值不值得告诉在线的浏览器。 + * + * 判据是「**推动了**」,不是「状态换了名字」—— 一条案例活动会连跑十几拍, + * 全程 `runtime='running'` 一动不动,变的是别的东西:AI 又说了一句、台账多了三行、 + * 画面换了一张、预算读数涨了。只看状态名的话,整个跑动过程**一次广播都没有**, + * 前端只好靠人点刷新(用户实测报的:任务列表 / 消息气泡 / 工作区 / 对话区都不自己动)。 + * + * 空拍不广播:既不再瞒着人,也不会每两秒给在线浏览器发一次噪声。 + */ +export function worthBroadcasting( + before: { runtime: string; terminal?: string } | undefined, + outcome: { progressed: boolean; task: { runtime: string; terminal?: string } }, +): boolean { + if (outcome.progressed) return true; + return outcome.task.runtime !== before?.runtime || outcome.task.terminal !== before?.terminal; +} + export async function advanceOnce(options: AdvancerOptions): Promise { const { container } = options; const rows = await container.db @@ -41,16 +59,30 @@ export async function advanceOnce(options: AdvancerOptions): Promise { advanced += 1; /* - * 推进器改了状态就**广播**:任务从在办走到终态、或挂起去等人, - * 都是在线的人该立刻看见的事。 - * 从前只有派发器(事件投递)广播,而任务是被**这里**推到终态的 —— - * 于是「办完的活不会自己挪到已完成」,得刷页面才认(用户实测报的)。 + * **推动了就广播**,不只在状态换名字时广播。 + * + * 原来的条件是「runtime 或 terminal 变了」—— 而一条案例活动会连跑十几拍, + * 全程 `runtime='running'` 一动不动,变的是别的东西:AI 又说了一句、 + * 台账多了三行、画面换了一张、预算读数涨了。于是整个跑动过程**一次广播都没有**, + * 前端只好靠人点刷新(用户实测报的:任务列表 / 消息气泡 / 工作区 / 对话区都不自己动)。 + * + * `progressed` 正是「这一拍干了事」的信号:干了就说一声,空拍不说 —— + * 既不再瞒着人,也不会每两秒给在线浏览器发一次噪声。 */ - if (outcome.task.runtime !== before?.runtime || outcome.task.terminal !== before?.terminal) { + if (worthBroadcasting(before, outcome)) { container.broadcaster.publish({ kind: 'task', id: row.id, reason: 'advanced' }); if (outcome.task.workflowId !== undefined) { container.broadcaster.publish({ kind: 'workflow', id: outcome.task.workflowId, reason: 'task-advanced' }); } + /* + * **收尾这一下动的不只是任务。** 放行那一刻草稿合并进正式版、动作意向落地、 + * 输出口发射 —— 数据域多出真行、驾驶舱的完成数变了、活动记忆也可能长一条。 + * 那几屏的键都在 `gov` 下、不以任务为轴,只按任务广播它们一个都收不到 + * (实测:一单放行完,数据域里还是空的,直到手动刷新)。 + */ + if (outcome.task.terminal !== undefined) { + container.broadcaster.publish({ kind: 'system', id: 'terminal', reason: outcome.task.terminal }); + } } // 进入等待或终结就停手 —— 挂起在等的任务由事件唤醒,不空转 diff --git a/apps/api/src/modules/activity-registry/CONTRACT.md b/apps/api/src/modules/activity-registry/CONTRACT.md index 0efe435..6274c34 100644 --- a/apps/api/src/modules/activity-registry/CONTRACT.md +++ b/apps/api/src/modules/activity-registry/CONTRACT.md @@ -23,6 +23,11 @@ 依赖: @contracts;@shared/db;port 注入:toolbox(L2)、llm(L1)、ledger(L1)、interaction(L3) 被依赖: loop-engine, flow-engine, skill-engine, task, process, app(治理台 · 活动库管理) +近变(2026-07-30):`list()` **不再列出全部版本都已卸载的活动**,`uninstall` 同时置 + `enabled=false`。卸载只把版本状态置 `uninstalled`(版本行留着,在途任务按启动版跑完), + 但活动库照旧摆着它就是撒谎 —— 实测:被真活动包顶替的槽位卸载后仍在架上, + 与顶替它的那个**同名并排**,试跑台上选错一个就试了个空壳。 + 不变量(OS 11.2 / 11.3,AM 11.3): - **安装两审**:静态审有 error 即不上架;「旧成不坏」是**生效硬门**, 「旧败能成」只入重演报告、不作门槛;首装无历史即免回放审 diff --git a/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts b/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts new file mode 100644 index 0000000..46c7007 --- /dev/null +++ b/apps/api/src/modules/activity-registry/__tests__/acceptance-predicate.test.ts @@ -0,0 +1,271 @@ +/** + * 判据谓词的名字必须在台账上 —— 安装期就挡下,别等跑到放行那一刻才炸。 + * + * 这条门是拿血换来的:A2/A3/A4/A6/A7 五个活动的机检判据全都引用了 + * 台账里根本不存在的名字(`candidatesConfirmed` / `unquoted` / `unpriced` + * / `missingQuoteFields` / `reasonLogged` ……)。S-FEEL 对未绑定的名字求值为假, + * 于是这些判据**永远达不了标**:Agent 把活办得再干净也过不了 submit 门, + * 连拒五拍熔断 —— MVP 整条流三轮都卡死在 A2,而清单本身语法挑不出毛病。 + */ +import { describe, expect, it } from 'vitest'; +import type { ActivityManifest } from '@contracts'; + +/* 两条审读的入参已在 domain 收窄到字段级 —— 这里直接用它,不拼假清单 */ +type PredicateInput = Parameters[0]; +type LedgerTable = ActivityManifest['declarations']['ledger'][number]; +type Deliverable = ActivityManifest['contract']['requiredDeliverables'][number]; +import { acceptanceLevelFindings } from '../internal/domain/static-audit.ts'; + +/** 一份最小可用清单 —— 只摆本测试关心的三处:判据 / 台账 / 案卷 */ +function manifest(over: { + predicate?: string | undefined; + level?: 'L1' | 'L2' | 'L3'; + fields?: string[]; + caseItems?: string[]; + inputProps?: string[]; +}): PredicateInput { + const fields = over.fields ?? ['line_total']; + const caseItems = over.caseItems ?? []; + const inputProps = over.inputProps ?? []; + return { + contract: { + input: { + data: { + type: 'object', + properties: Object.fromEntries(inputProps.map((k) => [k, { type: 'string' }])), + }, + }, + acceptanceCriteria: [ + { + key: 'k', + label: '测试判据', + level: over.level ?? 'L2', + ...(over.predicate === undefined ? {} : { predicate: over.predicate }), + }, + ], + }, + declarations: { + ledger: [ + { + domain: 'd', + object: 'record', + kind: 'record' as const, + caseKey: 'rfq_ref', + label: '表', + fields: fields.map((name) => ({ + name, + type: 'integer' as const, + primaryKey: false, + masked: false, + })), + }, + ], + }, + assets: { + businessModel: { + cmmnXml: caseItems.map((id) => ``).join(''), + }, + }, + }; +} + +const codes = (m: PredicateInput) => acceptanceLevelFindings(m).map((f) => f.code); + +describe('判据谓词的名字必须绑得上', () => { + it('台账有这一列 —— 放行', () => { + expect(codes(manifest({ predicate: 'line_total > 0' }))).toEqual([]); + }); + + it('台账没有这个名字 —— 拒装', () => { + const found = acceptanceLevelFindings(manifest({ predicate: 'candidatesConfirmed >= 2' })); + expect(found.map((f) => f.code)).toContain('acceptance-predicate-unbound'); + // 报错要指名道姓:说清是哪个名字绑不上,不然改的人还得自己猜 + expect(found[0]?.message).toContain('candidatesConfirmed'); + }); + + it('案卷项也算绑得上 —— 案卷项 id 就是任务视图里的键名', () => { + expect(codes(manifest({ predicate: 'benchmark_total > 0', caseItems: ['benchmark_total'] }))).toEqual([]); + }); + + it('起单入参也算绑得上', () => { + expect(codes(manifest({ predicate: 'eau > 0', inputProps: ['eau'] }))).toEqual([]); + }); + + it('两个名字里错一个,也要报出那一个', () => { + const found = acceptanceLevelFindings( + manifest({ predicate: 'alt_request_total = type_c_total', fields: ['alt_request_total'] }), + ); + expect(found).toHaveLength(1); + expect(found[0]?.message).toContain('type_c_total'); + }); + + it('保留字与字面量不当作台账名', () => { + expect(codes(manifest({ predicate: 'need_sourcing = true and line_total > 0', fields: ['need_sourcing', 'line_total'] }))).toEqual([]); + }); + + it('字符串字面量里的词不当作台账名', () => { + expect(codes(manifest({ predicate: 'disposition = "照原价报"', fields: ['disposition'] }))).toEqual([]); + }); + + it('L3 不机检 —— 没谓词也不报', () => { + expect(codes(manifest({ level: 'L3', predicate: undefined }))).toEqual([]); + }); + + it('L2 没谓词照旧报等级谎报,且不误报绑定', () => { + const found = codes(manifest({ level: 'L2', predicate: undefined })); + expect(found).toEqual(['acceptance-level-misreported']); + }); +}); + +/** + * 必交付物必须给产出地址 —— 不给的话放行门永远拒。 + * + * 预检数的是「本单在这张表上写了几行」;地址不给,它退回拿 key 去任务视图里找, + * 而 key 是 `benchmark-table` 这样的短横线别名 —— 视图里永远没有这个键。 + * A2/A3/A4/A6/A7 五个活动全没给,活办完了也放不了行(实测 A2 死于此)。 + */ +import { deliverableAddressFindings } from '../internal/domain/static-audit.ts'; + +type DeliverableInput = Parameters[0]; + +const LEDGER: LedgerTable[] = [ + { domain: 'quote_alternative', object: 'benchmark', kind: 'record' as const, caseKey: 'rfq_ref', label: '表', fields: [] }, +]; + +function withDeliverables(items: Deliverable[]): DeliverableInput { + return { contract: { requiredDeliverables: items }, declarations: { ledger: LEDGER } }; +} + +describe('必交付物的产出地址', () => { + it('地址指向真实台账表 —— 放行', () => { + const found = deliverableAddressFindings( + withDeliverables([{ key: 'benchmark-table', label: '对标表', ledger: 'quote_alternative.benchmark' }]), + ); + expect(found).toEqual([]); + }); + + it('没给地址 —— 拒装', () => { + const found = deliverableAddressFindings(withDeliverables([{ key: 'benchmark-table', label: '对标表' }])); + expect(found.map((f) => f.code)).toEqual(['deliverable-address-unbound']); + expect(found[0]?.message).toContain('对标表'); + }); + + it('地址指向不存在的表 —— 拒装,并把声明过的表摆出来', () => { + const found = deliverableAddressFindings( + withDeliverables([{ key: 'k', label: '对标表', ledger: 'quote_alternative.nope' }]), + ); + expect(found.map((f) => f.code)).toEqual(['deliverable-address-unbound']); + expect(found[0]?.message).toContain('quote_alternative.benchmark'); + }); + + it('多件里错一件,只报那一件', () => { + const found = deliverableAddressFindings( + withDeliverables([ + { key: 'a', label: '对标表', ledger: 'quote_alternative.benchmark' }, + { key: 'b', label: '初筛清单' }, + ]), + ); + expect(found).toHaveLength(1); + expect(found[0]?.message).toContain('初筛清单'); + }); +}); + +/** + * 台账性质要核得住 —— 这条门是拿一次真实的数据丢失换来的。 + * + * A4 的报价草案本该一单一份,主键却只有 `pn`:第二单报同一个料号就 upsert 覆盖上一单。 + * 跑了四轮 A4,`quote_pricing.draft` 只剩最后一轮的 3 行; + * 而主键带单号的 `quote_intake.record` 是 61 行 / 20 单,一单不少。 + * **作者没决定这张表是单据还是主数据,主键就写歪了** —— 而清单语法挑不出毛病。 + */ +import { ledgerKindFindings } from '../internal/domain/static-audit.ts'; + +type LedgerInput = Parameters[0]; +type Kind = 'record' | 'master' | 'derived'; + +function table(over: { kind?: Kind; caseKey?: string; pk?: string[]; cols?: string[] }): LedgerInput { + const cols = over.cols ?? ['rfq_ref', 'pn']; + const pk = over.pk ?? ['rfq_ref', 'pn']; + return { + declarations: { + ledger: [ + { + domain: 'd', + object: 'o', + label: '表', + kind: over.kind ?? 'record', + ...(over.caseKey === undefined ? {} : { caseKey: over.caseKey }), + fields: cols.map((name) => ({ + name, + type: 'text' as const, + primaryKey: pk.includes(name), + masked: false, + })), + }, + ], + }, + }; +} + +const kindCodes = (m: LedgerInput) => ledgerKindFindings(m).map((f) => f.code); + +describe('台账性质', () => { + it('单据:指明单标识且它在主键里 —— 放行', () => { + expect(kindCodes(table({ kind: 'record', caseKey: 'rfq_ref' }))).toEqual([]); + }); + + /* 这一条就是 A4 那次数据丢失的机检形态 */ + it('单据没指明单标识 —— 拒装', () => { + const found = ledgerKindFindings(table({ kind: 'record', cols: ['pn'], pk: ['pn'] })); + expect(found.map((f) => f.code)).toContain('ledger-kind-invalid'); + expect(found[0]?.message).toContain('caseKey'); + }); + + it('单标识不在主键里 —— 拒装(单标识不进主键等于没有)', () => { + const found = ledgerKindFindings(table({ kind: 'record', caseKey: 'rfq_ref', pk: ['pn'] })); + expect(found.map((f) => f.code)).toEqual(['ledger-kind-invalid']); + expect(found[0]?.message).toContain('不是主键的一部分'); + }); + + it('单标识不在自己字段里 —— 拒装', () => { + const found = ledgerKindFindings(table({ kind: 'record', caseKey: 'nope' })); + expect(found[0]?.message).toContain('不在它自己的字段里'); + }); + + it('主数据:按业务实体分行,不要单标识 —— 放行', () => { + expect(kindCodes(table({ kind: 'master', cols: ['customer_code'], pk: ['customer_code'] }))).toEqual([]); + }); + + it('主数据却带着单标识 —— 拒装:它多半其实是单据,归错了类', () => { + const found = ledgerKindFindings(table({ kind: 'master', caseKey: 'rfq_ref' })); + expect(found.map((f) => f.code)).toEqual(['ledger-kind-invalid']); + expect(found[0]?.message).toContain('归错了类'); + }); + + it('派生:按周期分行,不要单标识 —— 放行', () => { + expect(kindCodes(table({ kind: 'derived', cols: ['period'], pk: ['period'] }))).toEqual([]); + }); + + it('一个主键列都没有 —— 拒装:合并与去重无从谈起', () => { + const found = ledgerKindFindings(table({ kind: 'master', cols: ['a'], pk: [] })); + expect(found[0]?.message).toContain('主键列'); + }); +}); + +/** + * 行数读数 `域_对象_rows` —— 基座数出来的那一边。 + * + * 它不是活动写得出的列,却必须是**绑得上的名字**:判据的两边不能都由活动自报, + * 否则活动数错一次就两边一起错、一起「对上」(实测:条目数报 9、表里躺着 10, + * 派单数与三类条数同源同错,`2 = 2` 顺利过关)。 + * 但也不能一律放行 —— 对象名手滑写错,照旧要在装的时候拒。 + */ +describe('行数读数认得出,写错的照旧拒装', () => { + it('声明过的表 → `域_对象_rows` 绑得上', () => { + expect(codes(manifest({ predicate: 'd_record_rows = line_total' }))).toEqual([]); + }); + + it('没声明过的表 → 照旧拒装(手滑一个字母,判据就永远达不了标)', () => { + expect(codes(manifest({ predicate: 'd_recrod_rows = line_total' }))).toContain('acceptance-predicate-unbound'); + }); +}); diff --git a/apps/api/src/modules/activity-registry/index.ts b/apps/api/src/modules/activity-registry/index.ts index 99d8c0f..2651f08 100644 --- a/apps/api/src/modules/activity-registry/index.ts +++ b/apps/api/src/modules/activity-registry/index.ts @@ -32,8 +32,11 @@ export type { export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts'; export { createRegistryService } from './internal/usecase/registry-service.ts'; +export type { AcceptanceAuditInput, DeliverableAuditInput } from './internal/domain/static-audit.ts'; export { acceptanceLevelFindings, + deliverableAddressFindings, + ledgerKindFindings, auditPassed, danglingListeners, loopFindings, diff --git a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts index ba65554..5438d82 100644 --- a/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts +++ b/apps/api/src/modules/activity-registry/internal/domain/static-audit.ts @@ -28,6 +28,26 @@ export type ExternalChecks = { readonly sdkCompatible: boolean; }; +/** + * 两条清单审读**只读清单的一小块** —— 参数就照那一小块声明。 + * 收窄不是为了测试好写:它把「这条审读看的是什么」写进类型里, + * 传全份清单照旧过(结构子类型),而读了什么一目了然。 + */ +export type AcceptanceAuditInput = { + readonly contract: Pick & { + readonly input: Pick; + }; + readonly declarations: Pick; + readonly assets: { + readonly businessModel: Pick; + }; +}; +export type LedgerAuditInput = { readonly declarations: Pick }; +export type DeliverableAuditInput = { + readonly contract: Pick; + readonly declarations: Pick; +}; + /** ISO-8601 duration,与 event 的 deadlineMillis 同一子集 */ const DURATION = /^P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)$|^P\d+D$/; @@ -62,8 +82,9 @@ export function danglingListeners(manifest: ActivityManifest): StaticAuditFindin } /** 验收等级不得谎报:L4 不得标注为 L1–L3;L1/L2 必须给出可机检的表达式 */ -export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAuditFinding[] { +export function acceptanceLevelFindings(manifest: AcceptanceAuditInput): StaticAuditFinding[] { const out: StaticAuditFinding[] = []; + const declared = boundNames(manifest); for (const [index, criterion] of manifest.contract.acceptanceCriteria.entries()) { const at = `contract.acceptanceCriteria[${index}]`; if ((criterion.level === 'L1' || criterion.level === 'L2') && criterion.predicate === undefined && criterion.modelRef === undefined) { @@ -74,6 +95,209 @@ export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAudit at, }); } + /* + * **谓词里的名字必须在台账上**(2026-07-31 立)。 + * + * A2 的判据写的是 `candidatesConfirmed >= 2`,而台账里从来没有这个名字 —— + * S-FEEL 拿未绑定的名字比大小求值为假,于是这条判据**永远达不了标**: + * 活办得再干净也过不了 submit 门,Agent 连拒五拍熔断,整条流卡死在 A2。 + * A3/A4/A6/A7 全是同一个毛病,一个都没跑通过。 + * + * 这种错光看清单是看不出来的(谓词本身语法没问题),只有跑到放行那一刻才炸, + * 而那时人已经签完字了。所以它必须在**安装期**挡下:装不上,好过装上了跑死。 + */ + for (const name of predicateNames(criterion.predicate)) { + if (declared.has(name)) continue; + out.push({ + code: 'acceptance-predicate-unbound', + severity: 'error', + message: + `判据「${criterion.label}」的谓词引用了 \`${name}\`,而台账与案卷里都没有这个名字 —— ` + + 'S-FEEL 对未绑定的名字求值为假,这条判据将永远达不了标,放行门永远拒。' + + '把它改成活动真写得出的列(通常是汇总表里的 _total 计数列)。', + at, + }); + } + } + return out; +} + +/** + * 必交付物的**产出地址**必须指向真实存在的台账表(2026-07-31 立)。 + * + * 预检数的是「本单在这张表上写了几行」。地址不给,它退回拿 key 去任务视图里找 —— + * 而 key 是 `benchmark-table` 这样的短横线别名,视图里永远没有这个键, + * 于是「必交付物缺一不得提交放行」**永远拒**:活办完了也放不了行,连拒五拍熔断。 + * 地址写错(指向不存在的表)是同一个下场,一并挡在安装期。 + */ +export function deliverableAddressFindings(manifest: DeliverableAuditInput): StaticAuditFinding[] { + const out: StaticAuditFinding[] = []; + const tables = new Set(manifest.declarations.ledger.map((t) => `${t.domain}.${t.object}`)); + for (const [index, item] of manifest.contract.requiredDeliverables.entries()) { + const at = `contract.requiredDeliverables[${index}]`; + if (item.ledger === undefined || item.ledger.trim() === '') { + out.push({ + code: 'deliverable-address-unbound', + severity: 'error', + message: + `必交付物「${item.label}」没给 \`ledger\` 产出地址 —— ` + + '预检会退回拿 key 去任务视图里找,而 key 是短横线别名、视图里没有这个键,' + + '这一件将永远算「未产出」,放行门永远拒。给它写上产出落在哪张表。', + at, + }); + continue; + } + if (!tables.has(item.ledger)) { + out.push({ + code: 'deliverable-address-unbound', + severity: 'error', + message: + `必交付物「${item.label}」的产出地址 \`${item.ledger}\` 不是本活动声明的台账表 —— ` + + `声明过的是:${[...tables].join('、') || '(一张都没有)'}`, + at, + }); + } + } + return out; +} + +/** + * 台账对象的性质要**核得住**(2026-07-31 立)。 + * + * 起因是一次真实的数据丢失:A4 的报价草案 `quote_pricing.draft` 本该一单一份, + * 主键却只有 `pn` —— 第二单报同一个料号就 upsert 覆盖了上一单。 + * 跑了四轮 A4,表里只剩最后一轮的 3 行;而主键带单号的 `quote_intake.record` + * 是 61 行 / 20 单,一单不少。**作者没决定这张表是单据还是主数据,主键就写歪了。** + * + * 光加一个 `kind` 字段治不了 —— 声明能填错。所以每一类各核一条: + * · `record` 必须指明单标识,且它得真在主键里(否则「一单一份」是空话); + * · `master` 不该有单标识(有了说明它其实是单据,归错类了); + * · 任何表都得有主键 —— 没主键的台账合并语义无从谈起。 + */ +export function ledgerKindFindings(manifest: LedgerAuditInput): StaticAuditFinding[] { + const out: StaticAuditFinding[] = []; + for (const [index, table] of manifest.declarations.ledger.entries()) { + const at = `declarations.ledger[${index}]`; + const where = `${table.domain}.${table.object}`; + const pk = table.fields.filter((f) => f.primaryKey).map((f) => f.name); + const names = new Set(table.fields.map((f) => f.name)); + + if (pk.length === 0) { + out.push({ + code: 'ledger-kind-invalid', + severity: 'error', + message: `台账 \`${where}\` 一个主键列都没有 —— 合并与去重都无从谈起,写进去的行会越堆越多且认不出重复`, + at, + }); + } + + if (table.kind === 'record') { + if (table.caseKey === undefined) { + out.push({ + code: 'ledger-kind-invalid', + severity: 'error', + message: + `单据表 \`${where}\` 没指明单标识(\`caseKey\`)—— 一单一份就成了空话:` + + '主键里没有「单」,第二单碰到同一个业务键会直接覆盖上一单(实测丢过数据)。' + + '指明哪一列是单标识,或者它其实是主数据就改 `kind: \'master\'`。', + at, + }); + } else if (!names.has(table.caseKey)) { + out.push({ + code: 'ledger-kind-invalid', + severity: 'error', + message: `单据表 \`${where}\` 的单标识 \`${table.caseKey}\` 不在它自己的字段里`, + at, + }); + } else if (!pk.includes(table.caseKey)) { + out.push({ + code: 'ledger-kind-invalid', + severity: 'error', + message: + `单据表 \`${where}\` 的单标识 \`${table.caseKey}\` **不是主键的一部分**(现主键:${pk.join(', ')})—— ` + + '单标识不进主键,等于没有单标识:同一个业务键的第二单照样覆盖第一单。', + at, + }); + } + } + + if (table.kind !== 'record' && table.caseKey !== undefined) { + out.push({ + code: 'ledger-kind-invalid', + severity: 'error', + message: + `\`${where}\` 标成 ${table.kind} 却带着单标识 \`${table.caseKey}\` —— ` + + '跨单的数据不该按单分行;它多半其实是单据,`kind` 归错了类。', + at, + }); + } + } + return out; +} + +/** S-FEEL 保留字与字面量 —— 它们不是台账名,不该被当成未绑定 */ +const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']); + +/** + * 从谓词里摘出**标识符**(粗筛,宁可漏报不可误报)。 + * 只认「字母开头、由字母数字下划线点组成」的片段,去掉数字、字符串字面量与保留字; + * 带点的取根名(`a.b` 的绑定看 `a`)。 + */ +function predicateNames(predicate: string | undefined): string[] { + if (predicate === undefined || predicate.trim() === '') return []; + const withoutStrings = predicate.replace(/"[^"]*"/g, ' ').replace(/'[^']*'/g, ' '); + const found = withoutStrings.match(/[A-Za-z_][A-Za-z0-9_.]*/g) ?? []; + const out = new Set(); + for (const raw of found) { + const root = raw.split('.')[0] ?? raw; + if (SFEEL_WORDS.has(root.toLowerCase())) continue; + out.add(root); + } + return [...out]; +} + +/** + * 这个活动**求值时能绑到的名字**:台账所有列名 + 案卷项 id + 起单入参的字段名。 + * 任务视图正是由这三样摊平出来的,所以判据能读到的也就这三样。 + */ +function boundNames(manifest: AcceptanceAuditInput): ReadonlySet { + const out = new Set(); + for (const table of manifest.declarations.ledger) { + for (const field of table.fields) out.add(field.name); + out.add(table.object); + /* + * **基座数出来的行数读数** `域_对象_rows` —— 活动写不了它,正因如此它才有用: + * 判据的两边不能都由活动自报。实测过一次「对账过关而账是错的」—— + * 条目数报 9、表里躺着 10 行,三类条数与派单数同源同错,`2 = 2` 顺利过关。 + * 它只对**本活动声明过的表**成立,所以在这儿按声明生成: + * 对象名手滑写错,照旧当场拒装。 + */ + out.add(`${table.domain}_${table.object}_rows`); + /* + * 另两类基座读数,与 `_rows` 同一个道理 —— **能由数据算出来的,别让活动自报**: + * · `域_对象_missing` —— 缺必填几行(按 `fields[].required` 现数); + * · `域_对象_<列>_<值>` —— 按值现数几行(取值域由 `fields[].tally` 声明)。 + * 后一类是三分类条数的落点:人在放行页改了类标,分布条、去向谓词、派单对账 + * **全都跟着重算** —— 手工修改本来就是为了纠正 AI 的判断,改了却不影响结果, + * 那个兜底功能就是假的。 + */ + if (table.fields.some((f) => f.required === true)) out.add(`${table.domain}_${table.object}_missing`); + if (table.fields.some((f) => f.expect === true)) out.add(`${table.domain}_${table.object}_gaps`); + for (const field of table.fields) { + for (const value of field.tally ?? []) { + const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, ''); + if (slug !== '') out.add(`${table.domain}_${table.object}_${field.name}_${slug}`); + } + } + } + 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]); + } + const data = manifest.contract.input.data; + if (typeof data === 'object' && data !== null) { + const props = (data as { properties?: Record }).properties; + if (props !== undefined) for (const key of Object.keys(props)) out.add(key); } return out; } @@ -93,6 +317,39 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] { at: 'assets.businessModel.narrative', }); } + + /* + * 剧本与阶段引的计划项 id **必须在 CMMN 里真存在** —— 对不上就在装的时候拒。 + * + * 拼错一个 id 的后果是**静默失效**,而且两处症状都不像「id 写错了」: + * · 剧本对不上 → 那一步永远拿不到自己的做法与工具,模型每拍读整本说明书; + * · 阶段对不上 → 那一格永远收不完,进度条卡在那儿不动,人以为单卡住了。 + * 从 XML 里认 id 用的是属性正则:这里不解析 CMMN(解析在引擎那侧), + * 只要认得出「有没有这个 id」就够。 + */ + const declared = new Set([...model.cmmnXml.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1])); + for (const step of model.steps ?? []) { + if (!declared.has(step.item)) { + out.push({ + code: 'schema-invalid', + severity: 'error', + message: `剧本引的计划项在 CMMN 里不存在:${step.item}`, + at: 'assets.businessModel.steps', + }); + } + } + for (const phase of model.phases ?? []) { + for (const item of phase.items) { + if (!declared.has(item)) { + out.push({ + code: 'schema-invalid', + severity: 'error', + message: `阶段「${phase.name}」引的计划项在 CMMN 里不存在:${item}`, + at: 'assets.businessModel.phases', + }); + } + } + } return out; } @@ -106,8 +363,18 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks out.push({ code: 'sdk-incompatible', severity: 'error', message: `SDK 版本不兼容:${manifest.identity.sdkVersion}` }); } for (const tool of external.missingTools) { - // 工具缺口只阻塞上架,不阻塞开发(OS 7.1 契约先行) - out.push({ code: 'tool-contract-missing', severity: 'error', message: `工具调用契约未注册:${tool}` }); + /* + * 工具缺口是**警告,不是拒装**(2026-07-30 降级)。 + * + * 「设计活动时只考虑需要什么工具,不关心系统里有没有实现」—— + * 声明了系统里没有,那是系统的缺口,不是活动的错误。运行时已有出路: + * 未命中即**转人代办**(§6.2,人是工具的一种实现),这一步照样做得下去。 + * 从前判 error 拒装,等于要求「先把工具全实现完才允许装活动」—— + * 与声明式工具正面冲突,也让契约先行成了空话。 + * + * 仍然如实报出来:治理台看得见「这件活动有几件工具要靠人顶着」。 + */ + out.push({ code: 'tool-contract-missing', severity: 'warn', message: `工具调用契约未注册,运行时转人代办:${tool}` }); } for (const model of external.unboundModels) { out.push({ code: 'model-name-unbound', severity: 'error', message: `模型名未绑定四元:${model}` }); @@ -130,6 +397,8 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks } out.push(...danglingListeners(manifest)); + out.push(...deliverableAddressFindings(manifest)); + out.push(...ledgerKindFindings(manifest)); out.push(...acceptanceLevelFindings(manifest)); out.push(...loopFindings(manifest)); diff --git a/apps/api/src/modules/activity-registry/internal/usecase/registry-service.ts b/apps/api/src/modules/activity-registry/internal/usecase/registry-service.ts index b81339a..abdd470 100644 --- a/apps/api/src/modules/activity-registry/internal/usecase/registry-service.ts +++ b/apps/api/src/modules/activity-registry/internal/usecase/registry-service.ts @@ -132,6 +132,15 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist } const cards: ActivityCard[] = []; for (const [activityId, versions] of byActivity) { + /* + * 全部版本都已卸载的活动**不在架上**。 + * + * 卸载只把版本状态置为 `uninstalled`(版本行留着,在途任务按启动时版本跑完)—— + * 但活动库不该照旧摆着它:实测踩过,一个被真活动包顶替的槽位卸载后仍在库里, + * 与顶替它的那个**同名并排**,试跑台上选错一个就试了个空壳。 + * 「卸了还摆着」就是界面撒谎。 + */ + if (versions.every((v) => v.state === 'uninstalled')) continue; const manifest = await deps.store.manifest(activityId); if (manifest === undefined) continue; cards.push({ @@ -158,6 +167,8 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist for (const pkg of await deps.store.listPackages(activityId)) { await deps.store.setState(activityId, pkg.version, 'uninstalled'); } + // 一个版本都没装了,就谈不上「可用」—— 两处状态得对得上,不然治理台会摆一个假的绿灯 + await deps.store.setEnabled(activityId, false); }, async loadEnvelope(activityId) { diff --git a/apps/api/src/modules/event/__tests__/delivery.test.ts b/apps/api/src/modules/event/__tests__/delivery.test.ts index 9184a1b..3297220 100644 --- a/apps/api/src/modules/event/__tests__/delivery.test.ts +++ b/apps/api/src/modules/event/__tests__/delivery.test.ts @@ -28,19 +28,20 @@ describe('planConsumption —— 三级消费', () => { triggerHits: ['tr1'], ownedTaskId: 't1', hasOwner: true, + fromChannel: true, }); expect(plan.taskTargets).toEqual(['t1']); expect(plan.triggerTargets).toEqual(['tr1']); expect(plan.messageFallback).toBe(false); }); - it('皆未命中且无主 → 消息兜底', () => { - const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false }); + it('**入站件**皆未命中且无主 → 消息兜底(系统自己发的不算,见下面那组)', () => { + const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false, fromChannel: true }); expect(plan.messageFallback).toBe(true); }); it('有主但零监听命中 → 仅记录,不兜底、不降级为无主', () => { - const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7', hasOwner: true }); + const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7', hasOwner: true, fromChannel: true }); expect(plan.messageFallback).toBe(false); expect(plan.recordOnlyTask).toBe('t7'); expect(plan.taskTargets).toEqual([]); @@ -54,6 +55,7 @@ describe('planConsumption —— 三级消费', () => { triggerHits: [], ownedTaskId: undefined, hasOwner: true, + fromChannel: true, }); expect(plan.messageFallback).toBe(false); expect(plan.recordOnlyTask).toBeUndefined(); @@ -65,12 +67,19 @@ describe('planConsumption —— 三级消费', () => { triggerHits: [], ownedTaskId: 't7', hasOwner: true, + fromChannel: true, }); expect(plan.recordOnlyTask).toBeUndefined(); }); it('无主但触发命中 → 不兜底', () => { - const plan = planConsumption({ listenerHits: [], triggerHits: ['tr1'], ownedTaskId: undefined, hasOwner: false }); + const plan = planConsumption({ + listenerHits: [], + triggerHits: ['tr1'], + ownedTaskId: undefined, + hasOwner: false, + fromChannel: true, + }); expect(plan.messageFallback).toBe(false); }); }); @@ -96,3 +105,36 @@ describe('deadlineMillis —— 监听时限必配', () => { expect(() => deadlineMillis('PT0S')).toThrow(/大于零/); }); }); + +/** + * **消息兜底只兜从外面进来的件**(2026-08-02 用户:「消息怎么总莫名其妙的出内容」)。 + * + * 「无主入站待认领」是件具体的事:一封信进来了,没匹配上任何单也没触发任何流, + * 得有个人去认领它。而系统自己发的事件不是这种东西 —— 台账写一行会发 `data` 事件, + * 没人监听它是**常态**,不是待办。 + * + * 不分这一刀的实测下场:装一次活动包(主数据 systemWrite)就往三个人的收件箱里 + * 塞了十几条「无主入站待认领:(无主题) … 原件:无」—— 没有主题、没有原件、 + * 没人能对它做任何事。那不是待认领的件,是一条内部广播。 + */ +describe('消息兜底:只兜入站件', () => { + const plan = (over: Partial[0]> = {}) => + planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false, fromChannel: true, ...over }); + + it('一封信进来、谁都没接住 → 该有人认领', () => { + expect(plan().messageFallback).toBe(true); + }); + + it('**台账写一行发的事件谁都没接住 → 什么也不做**(它不是件)', () => { + expect(plan({ fromChannel: false }).messageFallback).toBe(false); + }); + + it('接住了就不兜底 —— 监听命中或触发命中,任一即可', () => { + expect(plan({ listenerHits: [{ taskId: 't1' }] }).messageFallback).toBe(false); + expect(plan({ triggerHits: ['tr1'] }).messageFallback).toBe(false); + }); + + it('有主的件不兜底 —— 那是「主不是任务」,不是没人认领', () => { + expect(plan({ hasOwner: true }).messageFallback).toBe(false); + }); +}); diff --git a/apps/api/src/modules/event/internal/domain/delivery.ts b/apps/api/src/modules/event/internal/domain/delivery.ts index 70f41be..229f509 100644 --- a/apps/api/src/modules/event/internal/domain/delivery.ts +++ b/apps/api/src/modules/event/internal/domain/delivery.ts @@ -66,6 +66,22 @@ export function planConsumption(input: { * 把它们判成无主,等于把每次起单都当成「没人认领的入站件」塞进别人收件箱。 */ readonly hasOwner: boolean; + /** + * 这条事件**是从外面进来的件**吗 —— 只有它才谈得上「无人认领」。 + * + * 「无主入站待认领」是一件具体的事:一封信进来了、没匹配上任何单也没触发任何流, + * **得有个人去认领它**。而系统自己发的事件不是这种东西: + * 台账写一行会发 `data` 事件、引擎起一单会发工作流事件 —— + * 没人监听它们是**常态**,不是待办。 + * + * 不分这一刀的下场实测过(用户 2026-08-02:「消息怎么总莫名其妙的出内容」): + * 装一次活动包(主数据 `systemWrite`)就往三个人的收件箱里塞了十几条 + * 「无主入站待认领:(无主题) —— 事件 6d17debb… 未命中任何任务监听与触发器。原件:无」。 + * 没有主题、没有原件、没人能对它做任何事 —— 那不是待认领的件,是一条内部广播。 + * + * 判据取**来源**不取类型:同样是 `data`,从通道进来的是件,台账写出来的是事实。 + */ + readonly fromChannel: boolean; }): ConsumptionPlan { const taskTargets = fanOutTargets(input.listenerHits); const triggerTargets = [...new Set(input.triggerHits)]; @@ -73,8 +89,8 @@ export function planConsumption(input: { return { taskTargets, triggerTargets, - // 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主**的件(OS 6.3) - messageFallback: nothingHit && !input.hasOwner, + // 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主的入站件**(OS 6.3) + messageFallback: nothingHit && !input.hasOwner && input.fromChannel, recordOnlyTask: input.ownedTaskId !== undefined && !taskTargets.includes(input.ownedTaskId) ? input.ownedTaskId diff --git a/apps/api/src/modules/event/internal/usecase/event-service.ts b/apps/api/src/modules/event/internal/usecase/event-service.ts index d0bc75f..f41edfa 100644 --- a/apps/api/src/modules/event/internal/usecase/event-service.ts +++ b/apps/api/src/modules/event/internal/usecase/event-service.ts @@ -51,6 +51,8 @@ export function createEventService(deps: EventServiceDeps): EventService { triggerHits, ownedTaskId, hasOwner: envelope.correlation.target !== undefined, + // 只有**从通道进来的件**谈得上「无人认领」—— 台账写一行发的 data 事件不是件 + fromChannel: envelope.source.kind === 'channel', }); // 扇出:每个命中目标一份独立投递,信封「关联 = 单目标」保持单目标 diff --git a/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts b/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts index 18553ed..17adcac 100644 --- a/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts +++ b/apps/api/src/modules/human-tool/__tests__/card-rules.test.ts @@ -228,3 +228,50 @@ describe('完成只出自放行通过', () => { expect(allCriteriaGreen(acceptance({ criteria: [{ label: 'x', level: 'L2', pass: false }] }))).toBe(false); }); }); + +/** + * **一张录入卡可以落好几张表**(2026-08-02 用户:「新建客户 联系人和公司都需要确认」)。 + * + * 建一位新客户落的是两条主数据:公司档 + 联系人档。从前卡上只有公司那一半, + * 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。 + * 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉, + * **而盖掉之后没人看得出来**。 + */ +describe('录入卡分段:段号.列名', () => { + /** 与 `routes/desk.ts` 那段拆分同一口径 —— 改一处这里要跟着改 */ + const split = (values: Record, at: number): Record => { + const out: Record = {}; + for (const [k, v] of Object.entries(values)) { + const mark = `${at}.`; + if (k.startsWith(mark)) out[k.slice(mark.length)] = v; + else if (at === 0 && !/^\d+\./.test(k)) out[k] = v; + } + return out; + }; + const values = { + '0.customer_code': 'CU-9001', + '0.name': '上海精密电子有限公司', + '1.customer_code': 'CU-9001', + '1.contact_code': 'CT-01', + '1.name': '陈刚', + '1.email': 'chen@sh-elec.com', + }; + + it('**同名列不互相盖** —— 公司叫「上海精密电子」,联系人叫「陈刚」', () => { + expect(split(values, 0)['name']).toBe('上海精密电子有限公司'); + expect(split(values, 1)['name']).toBe('陈刚'); + }); + + it('每段只拿自己那几格 —— 联系人编码不该落进公司档', () => { + expect(split(values, 0)['contact_code']).toBeUndefined(); + expect(Object.keys(split(values, 1)).sort()).toEqual(['contact_code', 'customer_code', 'email', 'name']); + }); + + it('**老形状(没段号的平键)仍旧管用** —— 一张卡只有一段时照旧落主段', () => { + expect(split({ customer_code: 'CU-1', name: '老写法' }, 0)).toEqual({ customer_code: 'CU-1', name: '老写法' }); + }); + + it('老形状不喂给后面的段 —— 它说不清自己是哪一张表的', () => { + expect(split({ customer_code: 'CU-1', name: '老写法' }, 1)).toEqual({}); + }); +}); diff --git a/apps/api/src/modules/human-tool/__tests__/human-call-budget.test.ts b/apps/api/src/modules/human-tool/__tests__/human-call-budget.test.ts new file mode 100644 index 0000000..ecd8dac --- /dev/null +++ b/apps/api/src/modules/human-tool/__tests__/human-call-budget.test.ts @@ -0,0 +1,134 @@ +/** + * 调人预算 —— 拦的是「又去问人」,不是「办完了要交差」。 + * + * 这条是拿一次熔断换来的:A4 的预算是 3 次,两张决策卡加一张补全卡用完; + * 三条料的价全算好了、门禁也过了,提交放行却被「调人预算触顶 3/3」拒回 —— + * 再提再拒,连拒五拍熔断。活动可以完全合规地把自己饿死在终点线上。 + * + * 放行不是 Agent 选择的打扰:铁律③终局唯放行,这一单**必须**有人签字才收得了口。 + * 所以验收单不计预算;补全卡、决策卡、执行工单照算。 + */ +import { describe, expect, it } from 'vitest'; +import type { ChatCard, DeliverableId, StaffId, TaskId, WorkOrder } from '@contracts'; +import { cardIdSchema } from '@contracts'; +import type { CardStore, HumanEventPublisher, MemoryRecorder, MessageOutbox } from '../port.ts'; +import type { IssueContext } from '../contract.ts'; +import { createHumanToolService } from '../internal/usecase/human-tool-service.ts'; +import { HumanCallBudgetExceededError } from '../contract.ts'; + +const TASK = 'task-1' as TaskId; +const OWNER = 'staff-1' as StaffId; + +/** 记下「发出去了什么」的假台账 —— 已用次数由测试给定,其余按端口如实实现 */ +function cardStore(used: number, sent: string[]): CardStore { + return { + saveWorkOrder: async (_taskId, order) => { + sent.push(`order:${order.mode}`); + }, + getWorkOrder: async () => undefined, + listWorkOrders: async () => [], + markWorkOrderDone: async () => undefined, + patchWorkOrder: async () => undefined, + saveChatCard: async (_taskId, card) => { + sent.push(`card:${card.k}`); + }, + getChatCard: async () => undefined, + listChatCards: async () => [], + updateChatCard: async () => undefined, + closeChatCard: async () => undefined, + humanCallCount: async () => used, + openCardsOf: async () => [], + }; +} + +const outbox: MessageOutbox = { sendElicitation: async () => ({ id: 'm-1' }) }; +const memory: MemoryRecorder = { append: async () => undefined }; +const events: HumanEventPublisher = { publishHumanEvent: async () => undefined }; + +function service(used: number) { + const sent: string[] = []; + let seq = 0; + const svc = createHumanToolService({ + cards: cardStore(used, sent), + outbox, + memory, + events, + clock: { now: () => new Date('2026-07-31T00:00:00.000Z') }, + ids: { + next: () => { + seq += 1; + return `id-${seq}`; + }, + }, + }); + return { svc, sent }; +} + +const context: IssueContext = { taskId: TASK, ownerId: OWNER, humanCallCap: 3 }; + +const executionDraft = { + title: '请补齐字段', + basis: [{ k: '缺口', v: 'PN' }], + ask: '请向客户追问', + fields: [{ key: 'pn', label: 'PN', kind: 'text' as const, required: true }], +} satisfies Parameters['svc']['issueExecution']>[1]; + +const acceptanceDraft = { + title: '收口验收', + basis: [{ k: '条目数', v: '3' }], + deliverable: 'd-1' as DeliverableId, + criteria: [{ label: '草案完整', level: 'L1' as const, pass: true }], + fields: [], +} satisfies Parameters['svc']['issueAcceptance']>[1]; + +const decisionCard: ChatCard = { + id: cardIdSchema.parse('c-1'), + at: '2026-07-31T00:00:00.000Z', + k: 'decision', + ask: '选一个处置', + basis: [{ k: '偏离', v: '-30%' }], + options: [ + { value: 'a', label: '照原价报' }, + { value: 'b', label: '按底价报' }, + ], + allowCustom: false, +}; + +describe('调人预算', () => { + it('没到顶 —— 执行单发得出去', async () => { + const { svc, sent } = service(2); + await svc.issueExecution(context, executionDraft); + expect(sent).toEqual(['order:execution']); + }); + + it('到顶了 —— 执行单拒发', async () => { + const { svc } = service(3); + await expect(svc.issueExecution(context, executionDraft)).rejects.toBeInstanceOf(HumanCallBudgetExceededError); + }); + + it('到顶了 —— 决策卡拒发', async () => { + const { svc } = service(3); + await expect(svc.issueChatCard(context, decisionCard)).rejects.toBeInstanceOf(HumanCallBudgetExceededError); + }); + + it('到顶了 —— **验收放行照发**:终局唯放行,不该跟提问抢额度', async () => { + const { svc, sent } = service(3); + await svc.issueAcceptance(context, acceptanceDraft); + expect(sent).toEqual(['order:acceptance']); + }); + + it('超顶很多也照发验收单 —— 已经办完的活不该因为问过几次人就交不了差', async () => { + const { svc, sent } = service(99); + await svc.issueAcceptance(context, acceptanceDraft); + expect(sent).toEqual(['order:acceptance']); + }); + + it('验收单免的是预算,不是三规矩 —— 裸卡照旧拒发', async () => { + const { svc } = service(0); + const bare = { ...acceptanceDraft, basis: [] } satisfies Omit< + Extract, + 'id' | 'at' | 'mode' + >; + await expect(svc.issueAcceptance(context, bare)).rejects.toThrow(); + }); +}); diff --git a/apps/api/src/modules/human-tool/contract.ts b/apps/api/src/modules/human-tool/contract.ts index 40c9642..3d86f2f 100644 --- a/apps/api/src/modules/human-tool/contract.ts +++ b/apps/api/src/modules/human-tool/contract.ts @@ -89,6 +89,8 @@ export type IssuedChatCard = { export type ReplyOutcome = { readonly order: WorkOrder; + /** 这张卡挂在哪一单 —— 装配层要拿它把「人就地改的那几行」写回草稿层 */ + readonly taskId: TaskId; /** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */ readonly producesCompletion: boolean; /** 驳回:环内回转,任务继续 */ @@ -110,10 +112,33 @@ export type HumanToolService = { issueChatCard(context: IssueContext, card: ChatCard): Promise; replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise; - replyChatCard(reply: ChatCardReply, by: StaffId): Promise; + /** + * 回传带上 `taskId` —— **数据录入卡的确认要当场写草稿**,而写库要知道写给哪一单。 + * 由本模块自己写会让 human-tool 依赖 ledger(越层),所以把事实交出去,由组装层落笔。 + */ + replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{ card: ChatCard; taskId: TaskId }>; /** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */ rejectAndReask(cardId: string, reason: string): Promise; + /** + * 给已发出的验收工单补上选择组(AM 1.2 恰选一口)。 + * + * 为什么分两步:卡由 `release.submitForRelease` 发 —— 它守的是预检那道门, + * 不该也不必知道「这个活动有哪些出口」。出口是契约的事实,由 app 从 manifest 现读后贴上。 + */ + attachPortChoice( + workOrderId: string, + choice: { group: string; ports: ReadonlyArray<{ name: string; label?: string | undefined }> }, + ): Promise; + /** + * **算出来的去向**上卡 —— 不给人选,只告诉他走哪一口、凭哪一条。 + * 去向是台账那几个数的函数;摆成选择题,等于把可回放的判定换成一次手感。 + */ + attachRoutedPort( + workOrderId: string, + routed: { group: string; name: string; label?: string | undefined; because: string }, + ): Promise; + openWorkOrders(taskId: TaskId): Promise; /** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */ allWorkOrders(taskId: TaskId): Promise; diff --git a/apps/api/src/modules/human-tool/internal/adapter/card-store.ts b/apps/api/src/modules/human-tool/internal/adapter/card-store.ts index 4e472ff..f910f2d 100644 --- a/apps/api/src/modules/human-tool/internal/adapter/card-store.ts +++ b/apps/api/src/modules/human-tool/internal/adapter/card-store.ts @@ -54,6 +54,20 @@ export function createCardStore(db: Db): CardStore { await db.update(workOrderTable).set({ done }).where(eq(workOrderTable.id, id)); }, + /* + * 局部改卡面:整卡存在 `payload` 里,所以读回来合并再写回。 + * 只对**未回传**的卡开放(调用方保证)—— 已答的卡是留痕,只增不改。 + */ + async patchWorkOrder(id, patch) { + const rows = await db.select().from(workOrderTable).where(eq(workOrderTable.id, id)).limit(1); + const row = rows[0]; + if (row === undefined) return; + await db + .update(workOrderTable) + .set({ payload: { ...(row.payload as Record), ...patch } }) + .where(eq(workOrderTable.id, id)); + }, + async saveChatCard(taskId, card, recipientId) { await db.insert(chatCardTable).values({ id: card.id, diff --git a/apps/api/src/modules/human-tool/internal/domain/card-rules.ts b/apps/api/src/modules/human-tool/internal/domain/card-rules.ts index c5100ea..e98eb82 100644 --- a/apps/api/src/modules/human-tool/internal/domain/card-rules.ts +++ b/apps/api/src/modules/human-tool/internal/domain/card-rules.ts @@ -164,6 +164,29 @@ export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardVi } } + /* + * 数据录入卡 —— **确认那一下就是写入**,所以门要在这儿把住: + * 必填空着不让确认(缺一格这张档就是残的,而它是主数据); + * 已确认不得再改(只增不改:要改就再发一张,改动自己也是一条留痕)。 + */ + if (card.k === 'entry' && reply.k === 'entry') { + if (card.done !== undefined) return [{ code: 'already-answered', message: '该录入已确认,只增不改' }]; + /* + * **一张卡可以有好几段**(公司档 + 联系人档):值按 `段号.列名` 隔开 —— + * 两张表都有 `name` 这一列,平铺就会互相盖掉。 + * 老形状(平键)照旧认,只喂主段:一张卡只有一段时它仍旧管用。 + */ + const groups = [{ fields: card.fields }, ...(card.also ?? [])]; + const blank = groups.flatMap((g, at) => + g.fields + .filter((f) => f.required && (reply.values[`${at}.${f.key}`] ?? (at === 0 ? reply.values[f.key] : undefined) ?? '').trim() === '') + .map((f) => f.label), + ); + if (blank.length > 0) { + out.push({ code: 'required-blank', message: `必填还空着:${blank.join('、')}` }); + } + } + if (card.k === 'elicitation' && reply.k === 'elicitation') { if (card.done !== undefined) return [{ code: 'already-answered', message: '该补全已答,只增不改' }]; // 「没法填」旁路:要么填值,要么给理由 —— 两条路必走其一 diff --git a/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts b/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts index e1fab5d..1787258 100644 --- a/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts +++ b/apps/api/src/modules/human-tool/internal/usecase/human-tool-service.ts @@ -7,6 +7,7 @@ import type { ChatCardReply, ExecutionWorkOrder, StaffId, + TaskId, WorkOrder, WorkOrderReply, } from '@contracts'; @@ -38,9 +39,35 @@ export type HumanToolServiceDeps = { readonly ids: IdGenerator; }; +/** + * 录入卡上那一格叫什么 —— 键是 `段号.列名`(一张卡可以落好几张表)。 + * 按段号找回它属于哪一段;认不出的原样留着(宁可笼统,不可编)。 + */ +function entryLabel(card: Extract, key: string): string { + const at = /^(\d+)\.(.+)$/.exec(key); + const groups = [{ title: '', fields: card.fields }, ...(card.also ?? [])]; + if (at === null) return card.fields.find((f) => f.key === key)?.label ?? key; + const group = groups[Number(at[1])]; + const name = at[2] ?? key; + const label = group?.fields.find((f) => f.key === name)?.label ?? name; + return group === undefined || group.title === '' ? label : `${group.title}·${label}`; +} + export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolService { - /** 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(AM 10.1) */ - async function assertBudget(context: IssueContext): Promise { + /** + * 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(AM 10.1)。 + * + * **验收放行那一张不计预算**(2026-07-31 加)。 + * + * 预算管的是「打扰」——Agent 自己选择去问人的那些次数:补全卡、决策卡、执行工单。 + * 而放行不是它选的:铁律③终局唯放行,这一单**必须**由人签字才能收口。 + * 把它和提问算在同一个额度里,后果是活动可以合规地把自己饿死 —— + * 实测 A4:预算 3 次,两张决策卡加一张补全卡用完,活全干完了, + * 提交放行被「调人预算触顶 3/3」拒回,再提再拒,连拒五拍熔断。 + * 该拦的是「又去问人」,不是「办完了要交差」。 + */ + async function assertBudget(context: IssueContext, mode?: WorkOrder['mode']): Promise { + if (mode === 'acceptance') return; const used = await deps.cards.humanCallCount(context.taskId); if (used >= context.humanCallCap) { throw new HumanCallBudgetExceededError(context.taskId, used, context.humanCallCap); @@ -61,7 +88,12 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer taskId: context.taskId, kind: 'card-sent', title, - detail: { kind, id, arrival, recipient }, + /* + * `payload` 里那一个键(对话卡的 `k` / 工单的 `mode`)要一并入痕 —— + * 少了它,日志只知道「发了一张卡」,说不出发的是补全卡还是录入卡, + * 四字动作那一格就只能写「发出卡片」这种等于没说的词。 + */ + detail: { kind, id, arrival, recipient, ...payload }, }); if (arrival === 'task-chat') return { arrival }; // 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」 @@ -79,7 +111,7 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer async function issueOrder(context: IssueContext, order: WorkOrder): Promise { const violations = checkWorkOrder(order); if (violations.length > 0) throw new BareCallError(violations); - await assertBudget(context); + await assertBudget(context, order.mode); const recipient = context.recipientId ?? context.ownerId; await deps.cards.saveWorkOrder(context.taskId, order, recipient); @@ -171,8 +203,8 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer await deps.memory.append({ taskId: found.taskId, kind: reply.mode === 'acceptance' ? 'release-verdict' : 'card-reply', - title: reply.mode === 'acceptance' ? `放行裁决:${reply.verdict}` : `执行工单回传:${found.order.title}`, - detail: { by, values, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) }, + title: reply.mode === 'acceptance' ? `裁决:${reply.verdict}` : found.order.title, + detail: { by, values, mode: reply.mode, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) }, }); // 回传经统一入口以人事件抛回任务 await deps.events.publishHumanEvent({ @@ -186,15 +218,28 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer return { order, + taskId: found.taskId, producesCompletion: producesCompletion(reply), ...(reply.mode === 'acceptance' && reply.verdict !== 'pass' ? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } } : {}), - selectedPorts: reply.mode === 'acceptance' ? reply.selectedPorts : {}, + /* + * 出口:**算出来的优先**。 + * + * 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了 + * (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数, + * 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。 + */ + selectedPorts: + reply.mode !== 'acceptance' + ? {} + : (order as AcceptanceWorkOrder).routedPort !== undefined + ? { [(order as AcceptanceWorkOrder).routedPort!.group]: (order as AcceptanceWorkOrder).routedPort!.name } + : reply.selectedPorts, }; }, - async replyChatCard(reply: ChatCardReply, by: StaffId): Promise { + async replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{ card: ChatCard; taskId: TaskId }> { const found = await deps.cards.getChatCard(reply.cardId); if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '卡不存在' }]); @@ -205,14 +250,45 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer const updated = applyChatReply(found.card, reply, by, at); await deps.cards.updateChatCard(updated); + /* + * 标题写**内容**,不写「entry 卡回传」这种行话 —— 日志里动作已有自己一格 + * (四字章:员工确认 / 员工补全 …),标题再复述一遍卡的类型就是白占地方。 + * 录入卡尤其要说清落到哪张表:人回看时问的是「我那一下写了什么」。 + */ + /* + * **日志里不摆台账地址与列名**(2026-08-02 用户指出: + * 「柴浩:写入 customer.profile / customer_code=chchch;name=青岛海洋电子有限公司」 + * —— 这为啥没换成纯描述)。 + * + * `customer.profile` 与 `customer_code` 是系统内的名字;人回看时问的是 + * **「我那一下办了什么」**,答案该是「建了客户档案 · 青岛海洋电子有限公司」。 + * 卡上本来就有人话:`ask` 是问他的那句,`fields[].label` 是每一格的中文名 —— + * 用它们,不用地址与列名。 + */ + const title = found.card.ask.split('\n')[0] ?? found.card.ask; await deps.memory.append({ taskId: found.taskId, kind: 'card-reply', - title: `${found.card.k} 卡回传`, - detail: { by, ...reply }, + title, + detail: { + by, + ...reply, + ...(reply.k === 'entry' && found.card.k === 'entry' + ? { + /* + * 列名换成卡上那个中文 label;认不出的原样留着(宁可笼统,不可编)。 + * 值的键是 `段号.列名`(一张卡可以落好几张表)—— 按段号找回它属于哪一段, + * 不然「联系人姓名」会显示成公司档那个同名的「公司名」。 + */ + summary: Object.entries(reply.values) + .map(([k, v]) => `${found.card.k === 'entry' ? entryLabel(found.card, k) : k}:${v}`) + .join(' · '), + } + : {}), + }, }); await deps.events.publishHumanEvent({ taskId: found.taskId, cardId: reply.cardId, payload: { ...reply } }); - return updated; + return { card: updated, taskId: found.taskId }; }, /** 驳回重答:新卡实例(附驳回理由),原卡闭合 —— 每次请求与回答都是独立消息,只增不改 */ @@ -239,6 +315,26 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer return { card: fresh, arrival: arrivalOf(found.recipientId, found.recipientId) } satisfies IssuedChatCard; }, + async attachRoutedPort(workOrderId, routed) { + await deps.cards.patchWorkOrder(workOrderId, { + routedPort: { + group: routed.group, + name: routed.name, + ...(routed.label === undefined ? {} : { label: routed.label }), + because: routed.because, + }, + }); + }, + + async attachPortChoice(workOrderId, choice) { + await deps.cards.patchWorkOrder(workOrderId, { + portChoice: { + group: choice.group, + ports: choice.ports.map((p) => ({ name: p.name, ...(p.label === undefined ? {} : { label: p.label }) })), + }, + }); + }, + async openWorkOrders(taskId) { return deps.cards.listWorkOrders(taskId, true); }, @@ -270,6 +366,9 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s done: { at, by, values: reply.values, ...(reply.reason === undefined ? {} : { reason: reply.reason }) }, }; } + if (card.k === 'entry' && reply.k === 'entry') { + return { ...card, done: { at, by, values: reply.values } }; + } if (card.k === 'rating' && reply.k === 'rating') { return { ...card, done: { at, by, score: reply.score, ...(reply.note === undefined ? {} : { note: reply.note }) } }; } diff --git a/apps/api/src/modules/human-tool/port.ts b/apps/api/src/modules/human-tool/port.ts index ba0dbe7..196ce79 100644 --- a/apps/api/src/modules/human-tool/port.ts +++ b/apps/api/src/modules/human-tool/port.ts @@ -11,6 +11,8 @@ export type CardStore = { getWorkOrder(id: string): Promise<{ taskId: TaskId; order: WorkOrder; recipientId: StaffId } | undefined>; listWorkOrders(taskId: TaskId, openOnly: boolean): Promise; markWorkOrderDone(id: string, done: NonNullable): Promise; + /** 局部改卡面 —— 只增不改是对**已回传**的卡;未答的卡还可补齐契约事实(如选择组) */ + patchWorkOrder(id: string, patch: Partial): Promise; saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise; getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>; diff --git a/apps/api/src/modules/interaction/CONTRACT.md b/apps/api/src/modules/interaction/CONTRACT.md index cbee73e..61e74fc 100644 --- a/apps/api/src/modules/interaction/CONTRACT.md +++ b/apps/api/src/modules/interaction/CONTRACT.md @@ -18,12 +18,29 @@ `MemoryRecorder`(app 注入 memory)、`Audience`(app 注入 org) 被依赖: release, loop-engine, flow-engine, task, app(SSE 路由) +近变(2026-07-30):新增 `onPush(listener)` —— **系统级转接**,装配层接走全部推送转给前端 SSE。 + 从前只有 `subscribe(staffId, …)`,而它一个订阅者都没有:AI 说的每一句、 + 推的每一帧画面都广播给了空气,前端只好靠人点刷新。不复用 `subscribe` 是因为 + 转接是系统的事,为它编一个假员工 id 是撒谎;不按人筛也不漏 —— 推送不带业务载荷, + 谁该看见由前端自己那几把查询决定。 + 对话流投影另加 `narration` 一类(AI 对人说的话)。 + 不变量(OS 4.1 / 09,AM 07): - **输入单入口**:`inputControl()` 恒为 system —— 输入侧不参与转交; 视图区不得产生回环输入,浏览性交互由前端容器本地处理 - **控制权按态转交**:活动在跑(active / awaiting-*)归活动,其余归系统; 转交随引擎事件自动发生,活动不感知交接 - **视图区无脚本执行权**:含 `