forked from ChaiHao/BPOS-MVP
人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的: 件上就地改 · 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名, 于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 —— 而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。 · **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。 删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)—— 「没回传就是删了」会把一次不完整的回传变成一次删表。 · **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。 · 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。 判据与门 · 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来, 人填好的目标价它照样判「客户未提供」的毛病由此消掉。 · 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。 · 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开, 于是一单没打标就进了放行门。三个数全由基座现数。 · 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。 **指向两扇打不开的门,比不指还糟。** 界面不撒谎 · 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。 · 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、 往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。 · 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。 · 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。 · 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。 A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。 五道门禁全绿(971 passed)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
@@ -8,3 +8,6 @@ dist/
|
|||||||
.env.local
|
.env.local
|
||||||
coverage/
|
coverage/
|
||||||
.vite/
|
.vite/
|
||||||
|
|
||||||
|
# 本地开发日志(./start.sh 写的)
|
||||||
|
.dev-logs/
|
||||||
|
|||||||
@@ -6,20 +6,393 @@
|
|||||||
## 现状快照(2026-07-30)
|
## 现状快照(2026-07-30)
|
||||||
|
|
||||||
- **三面一体**:设计中心 `/design`(九区:蓝图/触发器/流程包/活动库/工具箱/数据域/知识库/
|
- **三面一体**:设计中心 `/design`(九区:蓝图/触发器/流程包/活动库/工具箱/数据域/知识库/
|
||||||
大模型/测试台)· 运营中心 `/ops`(六区:驾驶舱/工作流/任务/员工/台账/系统)· 工作台 `/`。
|
大模型/**测试台:三格 —— 流程(投一封信)/ 活动(按契约填入参)/ 日志(历次试跑存档)**)· 运营中心 `/ops`(六区:驾驶舱/工作流/任务/员工/台账/系统)· 工作台 `/`。
|
||||||
`/gov` 302 → `/ops`。
|
`/gov` 302 → `/ops`。
|
||||||
- **编排层与流程引擎已跑通并测死**:起单宽进(门口不拒缺项)、数据按字段名流动(无强制
|
- **编排层与流程引擎已跑通并测死**:起单宽进(门口不拒缺项)、数据按字段名流动(无强制
|
||||||
类型)、边只表达顺序与条件(对齐 Mastra)、闭合校验只查结构闭合 + 活动装载、一条流恰一个
|
类型)、边只表达顺序与条件(对齐 Mastra)、闭合校验只查结构闭合 + 活动装载、一条流恰一个
|
||||||
生效版本、在途按启动版本跑完、版本号与名归设计人员自己定。
|
生效版本、在途按启动版本跑完、版本号与名归设计人员自己定。
|
||||||
- **活动一律案例模型**(2026-07-29 范式退役):flow-engine 已删,业务模型只有
|
- **活动一律案例模型**(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)
|
案例活动一步走不动**,三套端到端(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` 两条;种子读不到
|
- **模型实例**:只留 `deepseek-v4-flash` / `deepseek-v4-pro` 两条;种子读不到
|
||||||
`JCAI_DEEPSEEK_API_KEY` 就**不绑密钥**(卡上如实显示不可用),去治理台大模型区补。
|
`JCAI_DEEPSEEK_API_KEY` 就**不绑密钥**(卡上如实显示不可用),去治理台大模型区补。
|
||||||
- **下一件大事:实现活动与工具调用逻辑** —— 规范已写死在 `docs/活动开发规范.md`,
|
- **A1 已真跑通**(2026-07-30):起单 → Loop 逐拍 → 写台账草稿 → 提交放行 → 人签 →
|
||||||
按其**第 12 章待实现清单**顺序销账(第一优先级:写类动作意向落地断链、工具未命中转人)。
|
releaseWrite 合并进正式版 → 发射 intake 口 → `released`。三分类与双去向旗业务正确
|
||||||
- 后端 19 模块 + 6 活动包 + 前端 features;五道门禁全绿(2026-07-30)。
|
(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="域.对象"`,渲染时每格多渲一个 `<input class="jc-cell">` 孪生件,
|
||||||
|
只读那份与输入那份**由样式表按根节点的 `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` | 工程结构权威:模块层级 L1-L8、依赖门禁、AI 协作协议 §12 |
|
||||||
| | `docs/产品设计.md` | 三面一体的产品形态与决策记录 |
|
| | `docs/产品设计.md` | 三面一体的产品形态与决策记录 |
|
||||||
| | `docs/活动开发规范.md` | **开发活动照这份**:基座六件、清单六区、工具声明式调用、验收放行、工作台视图逻辑;第 12 章 = 待实现清单 |
|
| | `docs/活动开发规范.md` | **开发活动照这份**(2026-07-30 重写为开发手册):世界观/四铁律/开发流程/清单六区/案例建模八纪律/判定表/工具/人类工具/运行时参考/自测/**排障速查表**;§15 = 待实现 |
|
||||||
| | `docs/debt.md` | 欠账台账(只增,销账划线) |
|
| | `docs/debt.md` | 欠账台账(只增,销账划线) |
|
||||||
| | `docs/adr/` · 各模块 `CONTRACT.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` 统一建模为准 |
|
| 前端语义权威 | `~/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 db:seed # 播种(五员工 + AI 账号;登录者 = 冯兵)
|
||||||
pnpm dev:api # :8787 pnpm dev:web # :5173(/api 反代)
|
pnpm dev:api # :8787 pnpm dev:web # :5173(/api 反代)
|
||||||
pnpm --filter @app/api reset-runs # 清运行数据,留配置
|
pnpm --filter @app/api reset-runs # 清运行数据,留配置
|
||||||
|
pnpm activities:install # 只装活动包(不碰治理台配置)—— 开发活动时用这个,别用 db:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
## 结构速查
|
## 结构速查
|
||||||
|
|||||||
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||||
<case id="alternative">
|
<case id="alternative">
|
||||||
|
<!--
|
||||||
|
案卷项的 id **就是任务视图里的键名**。这里只认两样:
|
||||||
|
起单入参(altRequests —— A1 发过来的那批替代评估请求)与「对标表写了几行」(benchmark_total)——
|
||||||
|
后者既是放行前提,也是这一单唯一的机检判据。
|
||||||
|
-->
|
||||||
<caseFileModel>
|
<caseFileModel>
|
||||||
<caseFileItem id="request" name="替代评估请求"/>
|
<caseFileItem id="altRequests" name="替代评估请求(开案即有,一条料一行)"/>
|
||||||
<caseFileItem id="shortlist" name="初筛清单"/>
|
<caseFileItem id="benchmark_total" name="对标表条数"/>
|
||||||
<caseFileItem id="benchmark" name="替代对标表"/>
|
|
||||||
<caseFileItem id="datasheet" name="芯片规格书"/>
|
|
||||||
</caseFileModel>
|
</caseFileModel>
|
||||||
<casePlanModel id="plan" name="替代选型" autoComplete="true">
|
<casePlanModel id="plan" name="替代选型" autoComplete="true">
|
||||||
<sentry id="s_request">
|
<sentry id="s_benchmarked">
|
||||||
<onPart id="op_req" caseFileItemRef="request"><standardEvent>create</standardEvent></onPart>
|
<onPart id="op_bm" caseFileItemRef="benchmark_total"><standardEvent>update</standardEvent></onPart>
|
||||||
</sentry>
|
<ifPart id="if_bm"><condition>benchmark_total > 0</condition></ifPart>
|
||||||
<sentry id="s_need_alt">
|
|
||||||
<onPart id="op_alt" caseFileItemRef="request"><standardEvent>update</standardEvent></onPart>
|
|
||||||
<ifPart id="if_alt"><condition>needAlternative = true</condition></ifPart>
|
|
||||||
</sentry>
|
|
||||||
<sentry id="s_shortlist">
|
|
||||||
<onPart id="op_sl" caseFileItemRef="shortlist"><standardEvent>create</standardEvent></onPart>
|
|
||||||
</sentry>
|
|
||||||
<sentry id="s_confirmed">
|
|
||||||
<onPart id="op_cf" caseFileItemRef="shortlist"><standardEvent>update</standardEvent></onPart>
|
|
||||||
<ifPart id="if_cf"><condition>candidatesConfirmed >= 2</condition></ifPart>
|
|
||||||
</sentry>
|
</sentry>
|
||||||
|
|
||||||
<stage id="S1" name="初选核险" autoComplete="true">
|
<!-- 唯一的活:派一张执行单给 FAE,他填完提交 -->
|
||||||
<entryCriterion id="ec_s1" sentryRef="s_request"/>
|
<humanTask id="T_select" name="替代选型:派执行单给 FAE,收回初筛与对标结论">
|
||||||
<humanTask id="T_spec_risk" name="人工查规格核险">
|
<itemControl><repetitionRule id="r_sel"/></itemControl>
|
||||||
<itemControl><manualActivationRule id="m_sr"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<humanTask id="T_brand" name="人工判主推品牌">
|
|
||||||
<itemControl><manualActivationRule id="m_br"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<planningTable id="pt_s1">
|
|
||||||
<humanTask id="T_known_issues" name="人工查沉淀避坑"/>
|
|
||||||
</planningTable>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_initial" name="初选定性"/>
|
|
||||||
|
|
||||||
<stage id="S2" name="圈选替代" autoComplete="true">
|
|
||||||
<entryCriterion id="ec_s2" sentryRef="s_need_alt"/>
|
|
||||||
<humanTask id="T_shortlist" name="人工撒网拉初筛">
|
|
||||||
<itemControl><manualActivationRule id="m_sl"/><repetitionRule id="r_sl"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<humanTask id="T_grade" name="人工逐条核验分级">
|
|
||||||
<entryCriterion id="ec_grade" sentryRef="s_shortlist"/>
|
|
||||||
<itemControl><manualActivationRule id="m_gr"/><repetitionRule id="r_gr"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<humanTask id="T_confirm" name="人工确认主推型号">
|
|
||||||
<itemControl><manualActivationRule id="m_cf"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_candidates" name="候选圈定"/>
|
|
||||||
|
|
||||||
<stage id="S3" name="对比评估交付" autoComplete="true">
|
|
||||||
<entryCriterion id="ec_s3" sentryRef="s_confirmed"/>
|
|
||||||
<humanTask id="T_collect" name="人工收集规格书">
|
|
||||||
<itemControl><manualActivationRule id="m_co"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<humanTask id="T_compare" name="人工参数对比表">
|
|
||||||
<itemControl><manualActivationRule id="m_cp"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<humanTask id="T_risk" name="人工替代风险评估">
|
|
||||||
<itemControl><manualActivationRule id="m_rk"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<humanTask id="T_sign" name="人工签字交出">
|
|
||||||
<itemControl><manualActivationRule id="m_sg"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_signed" name="签字放行"/>
|
|
||||||
|
|
||||||
<humanTask id="T_knowledge" name="人工知识入库">
|
|
||||||
<itemControl><manualActivationRule id="m_kb"/></itemControl>
|
|
||||||
</humanTask>
|
</humanTask>
|
||||||
|
|
||||||
<timerEventListener id="TL_half" name="时限过半">
|
<!-- 放行:触达世界,必须人工激活 -->
|
||||||
<timerExpression>P2D</timerExpression>
|
<humanTask id="T_release" name="替代选型结论验收放行">
|
||||||
</timerEventListener>
|
<entryCriterion id="ec_rel" sentryRef="s_benchmarked"/>
|
||||||
<userEventListener id="UL_change" name="客户改需求"/>
|
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||||
|
</humanTask>
|
||||||
</casePlanModel>
|
</casePlanModel>
|
||||||
</case>
|
</case>
|
||||||
</definitions>`;
|
</definitions>`;
|
||||||
|
|
||||||
export const NARRATIVE = `§0 开案:替代选型一事一议 —— 收到替代评估请求即开案。客户/PM 给出初选物料,或芯片类型与大致参数。
|
export const NARRATIVE = `§0 开案:A1 判出一条料要替代(原型号 EOL / 缺货 / 客户点名),这一单就到你手上。
|
||||||
先核初选(EOL 等风险提示 + 是否我司主推品牌),无风险且主推直接采信初选办结;
|
成品只有一样:**一份能让 BD 拿去跟客户讲的替代对标结论** ——
|
||||||
需要替代的,先撒网(AI 检索/聚合平台拉初筛清单)后逐条人工核验,常要两三轮,
|
哪几个候选、推荐哪一个、参数差在哪、有什么风险、要不要打样验证。
|
||||||
存活候选按替代等级分级(P2P/功能兼容/其他)圈 2~3 颗,做参数对比与风险评估成报告交回。
|
|
||||||
**任何替代必须把差异与风险写清 —— 差异没核清的不放行。**
|
|
||||||
|
|
||||||
S1 初选核险:一手资料以原厂官网为准(产品页/规格书/PCN·EOL 公告);
|
**这一单只有一步**:把选型这件事派给 FAE(执行工单),他填完你收进台账,然后提交放行。
|
||||||
查芯片规格书确认初选料有无 EOL/停产风险,生命周期对照 PN物料主库;
|
别把它拆成「先查规格、再判品牌、再撒网、再核验……」—— 那是他脑子里的过程,
|
||||||
对品牌原厂字典判是否我司主推 —— 无风险且主推,直接采信初选、免替代办结。
|
不是系统要管的步。你替他把表摆好就够了。
|
||||||
顺手查替代料库与技术问题案例库(裁量项),有沉淀方案或已知坑先亮出来。
|
|
||||||
|
|
||||||
S2 圈选替代:先撒网后核验,常要两三轮。逐条人工核验做全面参数对比 ——
|
【T_select 派执行单】发 human 行动(workOrder="execution"),卡面 blocks 放**两张可编辑表**:
|
||||||
先明确每项数据的来源(一律以原厂官网规格书为准),再逐项对标(Pin-to-Pin);
|
· 初筛清单:列 = 原型号 / 候选型号 / 品牌 / 生命周期;
|
||||||
存活者按替代等级分级;候选过客户技术画像禁用清单;不足 2~3 颗就放宽参数或换角度重新撒网。
|
· 替代对标表:列 = 候选型号 / 替代等级 / 参数差异项 / 差异风险 / 验证建议 / FAE签字。
|
||||||
|
两张都 editable:true;没有的值给空字符串 —— 空格子界面会自己标红「待填写」。
|
||||||
|
ask 一句话说清要他做什么;basis 摆上原型号、芯片类型、客户的特殊要求(裸卡基座拒发)。
|
||||||
|
|
||||||
S3 对比评估交付:收齐候选规格书,关键参数逐项对比写进替代对标表,标出差异项与替代等级,
|
他提交后,回传值出现在你的轨迹里:表格那一份的键是 **table:0 / table:1**(块序号),
|
||||||
写明差异带来的风险;按等级定评估深度 —— P2P 核确免改动,功能兼容/其他逐项评软件开发、
|
值是整张表的 JSON —— **第一行是列名**,其余每行一条,列序与你派出去时一致。
|
||||||
外围电路与 layout 改动。FAE 签字后交回发起方。
|
照它 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 验收。
|
||||||
|
终局唯放行:你不能自己宣布完成。`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const BENCHMARK = 'quote_alternative.benchmark';
|
|||||||
|
|
||||||
export default defineActivity({
|
export default defineActivity({
|
||||||
name: 'quote-alternative',
|
name: 'quote-alternative',
|
||||||
paradigm: 'loop',
|
|
||||||
|
|
||||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
||||||
const shortlist = await base.ledger.read(SHORTLIST);
|
const shortlist = await base.ledger.read(SHORTLIST);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { ActivityManifest, DmnModel } from '@contracts';
|
|||||||
import { ALTERNATIVE_SKILLS } from './skills.ts';
|
import { ALTERNATIVE_SKILLS } from './skills.ts';
|
||||||
import { ALTERNATIVE_CASE } from './views/report.ts';
|
import { ALTERNATIVE_CASE } from './views/report.ts';
|
||||||
import { activityIdSchema } from '@contracts';
|
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 DOMAIN = 'quote_alternative';
|
||||||
const ACTIVITY_ID = activityIdSchema.parse('quote-alternative');
|
const ACTIVITY_ID = activityIdSchema.parse('quote-alternative');
|
||||||
@@ -41,7 +41,7 @@ export const manifest: ActivityManifest = {
|
|||||||
identity: {
|
identity: {
|
||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
name: 'A2 · 替代选型',
|
name: 'A2 · 替代选型',
|
||||||
packageVersion: '0.1.3',
|
packageVersion: '2.0.0',
|
||||||
category: '统一报价',
|
category: '统一报价',
|
||||||
sdkVersion: '0.1.0-m5',
|
sdkVersion: '0.1.0-m5',
|
||||||
checksum: 'dev-checksum-quote-alternative',
|
checksum: 'dev-checksum-quote-alternative',
|
||||||
@@ -51,13 +51,24 @@ export const manifest: ActivityManifest = {
|
|||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
version: '0.1.0',
|
version: '0.1.0',
|
||||||
input: {
|
input: {
|
||||||
|
/*
|
||||||
|
* 入参对齐**上游真发的东西**(2026-07-31,MVP 第一轮跑出来的)。
|
||||||
|
*
|
||||||
|
* A1 的 `to-alternative` 口发的是 `altRequests`(一批替代评估请求,一条料一行),
|
||||||
|
* 不是单个 `originalPn` —— 而这里从前把 `originalPn` 写成必填,
|
||||||
|
* 于是流程走到 A2 就停在补全卡上:「缺 originalPn」。
|
||||||
|
* 上游发一批,下游要一个,这是**契约对不上**,不是数据缺失。
|
||||||
|
*
|
||||||
|
* 一单可能要替代好几颗料,`altRequests` 才是那件事的真形状。
|
||||||
|
*/
|
||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['originalPn'],
|
required: ['rfqRef', 'altRequests'],
|
||||||
properties: {
|
properties: {
|
||||||
originalPn: { type: 'string' },
|
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
|
||||||
|
altRequests: { type: 'array', title: '替代评估请求(原 PN + 替代要求)' },
|
||||||
|
originalPn: { type: 'string', title: '原型号(单条时的便捷入口)' },
|
||||||
chipType: { type: 'string' },
|
chipType: { type: 'string' },
|
||||||
params: { type: 'object' },
|
|
||||||
scenario: { type: 'string' },
|
scenario: { type: 'string' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -67,19 +78,43 @@ export const manifest: ActivityManifest = {
|
|||||||
mandatory: [
|
mandatory: [
|
||||||
{
|
{
|
||||||
name: 'benchmark',
|
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: '替代对标表(已签字)',
|
description: '替代对标表(已签字)',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
selective: [],
|
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: [
|
acceptanceCriteria: [
|
||||||
{ key: 'candidates', label: '2~3 颗分级候选经确认', level: 'L2', predicate: 'candidatesConfirmed >= 2' },
|
{ key: 'candidates', label: '2~3 颗分级候选经确认', level: 'L2', predicate: 'candidate_total >= 2' },
|
||||||
{ key: 'diff-clear', label: '差异项与风险逐项写清', level: 'L2', predicate: 'undocumentedDiff = 0' },
|
{ key: 'diff-clear', label: '差异项与风险逐项写清', level: 'L2', predicate: 'undocumented_total = 0' },
|
||||||
{ key: 'source-traceable', label: '每项数据来源可溯(以原厂官网规格书为准)', level: 'L3' },
|
{ key: 'source-traceable', label: '每项数据来源可溯(以原厂官网规格书为准)', level: 'L3' },
|
||||||
{ key: 'fae-sign', label: 'FAE 签字放行', level: 'L5' },
|
{ key: 'fae-sign', label: 'FAE 签字放行', level: 'L5' },
|
||||||
],
|
],
|
||||||
|
revisableAtRelease: [],
|
||||||
prohibitions: [
|
prohibitions: [
|
||||||
{ key: 'no-unclear-diff', statement: '差异没核清的不放行' },
|
{ key: 'no-unclear-diff', statement: '差异没核清的不放行' },
|
||||||
{ key: 'no-banned', statement: '客户技术画像禁用清单里的型号不得作候选' },
|
{ key: 'no-banned', statement: '客户技术画像禁用清单里的型号不得作候选' },
|
||||||
@@ -105,8 +140,11 @@ export const manifest: ActivityManifest = {
|
|||||||
{
|
{
|
||||||
domain: DOMAIN,
|
domain: DOMAIN,
|
||||||
object: 'shortlist',
|
object: 'shortlist',
|
||||||
|
kind: 'record',
|
||||||
|
caseKey: 'rfq_ref',
|
||||||
label: '初筛清单',
|
label: '初筛清单',
|
||||||
fields: [
|
fields: [
|
||||||
|
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||||
{ name: 'original_pn', 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: 'candidate_pn', type: 'text', primaryKey: true, masked: false, label: '候选型号' },
|
||||||
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '品牌' },
|
{ name: 'brand', type: 'text', primaryKey: false, masked: false, label: '品牌' },
|
||||||
@@ -116,8 +154,11 @@ export const manifest: ActivityManifest = {
|
|||||||
{
|
{
|
||||||
domain: DOMAIN,
|
domain: DOMAIN,
|
||||||
object: 'benchmark',
|
object: 'benchmark',
|
||||||
|
kind: 'record',
|
||||||
|
caseKey: 'rfq_ref',
|
||||||
label: '替代对标表',
|
label: '替代对标表',
|
||||||
fields: [
|
fields: [
|
||||||
|
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||||
{ name: 'original_pn', type: 'text', primaryKey: true, masked: false },
|
{ name: 'original_pn', type: 'text', primaryKey: true, masked: false },
|
||||||
{ name: 'candidate_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: '替代等级' },
|
{ 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 签字' },
|
{ 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: [
|
listeners: [
|
||||||
{
|
{
|
||||||
@@ -161,6 +229,8 @@ export const manifest: ActivityManifest = {
|
|||||||
cmmnXml: CMMN_XML,
|
cmmnXml: CMMN_XML,
|
||||||
narrative: NARRATIVE,
|
narrative: NARRATIVE,
|
||||||
dmn: GRADE_DMN,
|
dmn: GRADE_DMN,
|
||||||
|
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
|
||||||
|
steps: ALTERNATIVE_STEPS,
|
||||||
},
|
},
|
||||||
skills: ALTERNATIVE_SKILLS,
|
skills: ALTERNATIVE_SKILLS,
|
||||||
viewTemplates: [{ name: 'case-overview', html: ALTERNATIVE_CASE }],
|
viewTemplates: [{ name: 'case-overview', html: ALTERNATIVE_CASE }],
|
||||||
|
|||||||
@@ -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({
|
export const ALTERNATIVE_CASE = viewShell({
|
||||||
title: '替代选型 · 案例全景',
|
title: '替代选型 · 案例全景',
|
||||||
kicker: 'A2 · 案例活动(loop)',
|
kicker: 'A2 · 替代选型 · 案例活动',
|
||||||
tone: 'case',
|
tone: 'case',
|
||||||
|
icon: 'search',
|
||||||
|
stamp: { kind: 'live', text: '案例在办 · 非定稿' },
|
||||||
|
sign: 'A2 替代选型 · 本页每拍按当下局面重画',
|
||||||
body:
|
body:
|
||||||
kvCard('本单标的', [
|
statsBand([
|
||||||
['原型号', 'originalPn', 'mono'],
|
['初筛候选', 'mine:quote_alternative.shortlist'],
|
||||||
['芯片类型', 'chipType'],
|
['已对标', 'mine:quote_alternative.benchmark'],
|
||||||
['当前阶段', 'activeStage', 'tag'],
|
|
||||||
['当下可为', 'enabledCount'],
|
['当下可为', 'enabledCount'],
|
||||||
['目标缺口', 'gapCount'],
|
['目标缺口', 'gapCount'],
|
||||||
]) +
|
]) +
|
||||||
stagesCard('阶段(CMMN)', 'case:stages', '阶段推进到 <b>Enabled</b> 为止;触达世界的计划项须人工启动。') +
|
section('初筛清单(本单撒网所得)',
|
||||||
stagesCard('当下可为', 'case:enabled') +
|
tableOnly('mine:quote_alternative.shortlist', [
|
||||||
stagesCard('目标缺口', 'case:gaps', '缺口决定下一步是调人还是立监听。') +
|
['原型号', 'original_pn', 'mono'],
|
||||||
noteCard(
|
['候选型号', 'candidate_pn', 'mono'],
|
||||||
'跨人卡',
|
['品牌', 'brand'],
|
||||||
'选型常要向 BD 补客户口径 —— 那是<b>跨任务征答</b>:卡以系统消息送达对方的消息要素、<b>就地作答</b>,' +
|
['生命周期', 'lifecycle'],
|
||||||
'不占对方的任务清单,也不开放本任务的对话流。它是一次性的解答请求,不是责任转移。',
|
]) +
|
||||||
|
noteOnly(
|
||||||
|
'先撒网后核验,常要两三轮:检索与聚合平台拉来的候选先落这一张,<b>逐条人工核验之前都不算数</b>。' +
|
||||||
|
'生命周期一栏是初筛就要看的第一道险 —— EOL/停产的当场淘汰;' +
|
||||||
|
'不足 2~3 颗就放宽参数或换角度重新撒网。这张表空着,是本单还没撒网,不是没找着。',
|
||||||
|
),
|
||||||
|
{ text: '常要两三轮' },
|
||||||
|
) +
|
||||||
|
section('替代对标表(本单交付件)',
|
||||||
|
tableOnly('mine:quote_alternative.benchmark', [
|
||||||
|
['候选型号', 'candidate_pn', 'mono'],
|
||||||
|
['替代等级', 'grade'],
|
||||||
|
['参数差异项', 'param_diff'],
|
||||||
|
['差异风险', 'risk'],
|
||||||
|
['验证建议', 'verify_advice'],
|
||||||
|
['FAE 签字', 'signed_by'],
|
||||||
|
]) +
|
||||||
|
noteOnly(
|
||||||
|
'这是要签字交回发起方的东西:<b>差异没核清的不放行</b> —— 「差异风险」空着的行,' +
|
||||||
|
'就是此刻拦着放行的那几行。替代等级由判定表出(P2P / 功能兼容 / 其他),不是模型自己说了算;' +
|
||||||
|
'等级定评估深度:P2P 核确免改动,其余逐项评软件、外围电路与 layout。' +
|
||||||
|
'每项数据一律以<b>原厂官网规格书</b>为准。',
|
||||||
|
),
|
||||||
|
{ text: '差异没核清不放行' },
|
||||||
|
) +
|
||||||
|
section('案例阶段(CMMN 生命周期)',
|
||||||
|
stagesOnly('case:stages') +
|
||||||
|
noteOnly(
|
||||||
|
'阶段的生命周期态由语义引擎推进到 <b>Enabled</b> 为止;凡触达世界或产出关键交付的计划项' +
|
||||||
|
'必须人工启动 —— <b>Enabled ≠ Active</b>。选型是<b>一事一议</b>:撒几轮网、哪颗值得深评、' +
|
||||||
|
'要不要换角度重来,事前排不出谱,所以调度权交给 AI,系统只保证边框:预算、停滞熔断、放行归人。',
|
||||||
|
),
|
||||||
|
{ text: '推进到 Enabled 为止' },
|
||||||
|
) +
|
||||||
|
section('当下可为(AI 的合法选项)',
|
||||||
|
stagesOnly('case:enabled') +
|
||||||
|
noteOnly(
|
||||||
|
'AI 只能在这个集合里选下一步:<b>可以越谱,不可越权</b>。标「待启动」的是触达世界或' +
|
||||||
|
'产出关键交付的项 —— 要按一下再动。这一栏空着不是它偷懒,是当下确实无可为:' +
|
||||||
|
'要么在等人回话,要么在等外面的回音。',
|
||||||
|
),
|
||||||
|
{ text: '本拍的合法选项' },
|
||||||
|
) +
|
||||||
|
section('目标缺口(离交付还差什么)',
|
||||||
|
stagesOnly('case:gaps') +
|
||||||
|
noteOnly(
|
||||||
|
'缺口只有人知道 → 调人。选型常要向 BD 补客户口径,那是<b>跨任务征答</b>:卡以系统消息送达' +
|
||||||
|
'对方、<b>就地作答</b>,不占对方的任务清单,也不开放本任务的对话流 —— 一次性的解答请求,' +
|
||||||
|
'不是责任转移。缺口在世界侧 → 立监听(<b>时限必配</b>)。' +
|
||||||
|
'当下可为 ∩ 目标所需 = 空,就是<b>依赖断点</b>。这一栏空着,是必做的事都办完了。',
|
||||||
|
),
|
||||||
|
{ text: '空 = 必做的都办完了' },
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||||
<case id="archive">
|
<case id="archive">
|
||||||
<caseFileModel>
|
<caseFileModel>
|
||||||
<caseFileItem id="result" name="报价结果"/>
|
<caseFileItem id="quoteNo" name="报价单号(开案即有)"/>
|
||||||
<caseFileItem id="history" name="历史价格情报"/>
|
<caseFileItem id="archived_total" name="已归档条数"/>
|
||||||
<caseFileItem id="profile" name="客户画像"/>
|
|
||||||
<caseFileItem id="stats" name="报价落地率统计"/>
|
|
||||||
</caseFileModel>
|
</caseFileModel>
|
||||||
<casePlanModel id="plan" name="情报归档" autoComplete="true">
|
<casePlanModel id="plan" name="情报归档" autoComplete="true">
|
||||||
<sentry id="s_result">
|
<sentry id="s_archived">
|
||||||
<onPart id="op_rs" caseFileItemRef="result"><standardEvent>create</standardEvent></onPart>
|
<onPart id="op_ar" caseFileItemRef="archived_total"><standardEvent>update</standardEvent></onPart>
|
||||||
</sentry>
|
<ifPart id="if_ar"><condition>archived_total > 0</condition></ifPart>
|
||||||
<sentry id="s_logged">
|
|
||||||
<onPart id="op_lg" caseFileItemRef="history"><standardEvent>update</standardEvent></onPart>
|
|
||||||
<ifPart id="if_lg"><condition>reasonLogged = true</condition></ifPart>
|
|
||||||
</sentry>
|
</sentry>
|
||||||
|
|
||||||
<stage id="S1" name="回写沉淀" autoComplete="true">
|
<!-- 一步落三张表:它们互不依赖,同一拍写完 -->
|
||||||
<entryCriterion id="ec_s1" sentryRef="s_result"/>
|
<humanTask id="T_archive" name="情报归档:落历史价、客户画像、落地率统计">
|
||||||
<humanTask id="T_log" name="人工登记结果(拒绝原因 / 竞对价位)">
|
<itemControl><repetitionRule id="r_ar"/></itemControl>
|
||||||
<itemControl><manualActivationRule id="m_lg"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<humanTask id="T_profile" name="人工更新客户画像">
|
|
||||||
<entryCriterion id="ec_pf" sentryRef="s_logged"/>
|
|
||||||
<itemControl><manualActivationRule id="m_pf"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<humanTask id="T_stats" name="人工统计落地率">
|
|
||||||
<itemControl><manualActivationRule id="m_st"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_archived" name="情报已回写"/>
|
|
||||||
|
|
||||||
<humanTask id="T_release" name="人工归档闭环验收放行">
|
|
||||||
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
|
||||||
</humanTask>
|
</humanTask>
|
||||||
|
|
||||||
<timerEventListener id="TL_half" name="时限过半">
|
<humanTask id="T_release" name="封卷放行">
|
||||||
<timerExpression>PT12H</timerExpression>
|
<entryCriterion id="ec_rel" sentryRef="s_archived"/>
|
||||||
</timerEventListener>
|
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||||
|
</humanTask>
|
||||||
</casePlanModel>
|
</casePlanModel>
|
||||||
</case>
|
</case>
|
||||||
</definitions>`;
|
</definitions>`;
|
||||||
|
|
||||||
export const NARRATIVE = `§0 开案:一单报价有了结果(成交 / 拒绝 / 超时)即开案。
|
export const NARRATIVE = `§0 开案:一单报价有了结果(成交 / 拒绝 / 超时)即开案。
|
||||||
这件事不产生对外交付,它产生的是**下一单的本钱** —— 报价落地率、客户画像、竞对价位。
|
这一单的价值**全在下一单**:今天记下的拒绝原因,是下次定价时的判据;
|
||||||
|
今天记下的成交价,是下次的基准。所以归档不是收尾的仪式,是情报生产。
|
||||||
|
|
||||||
S1 回写沉淀:先登记结果 —— 拒绝的要写清原因与竞对价位,**「客户没回」不是原因**,
|
**一步就能办完** —— 三张表互不依赖,同一拍写完:
|
||||||
问不出来就写「未获反馈」,不许编一个听着合理的理由。
|
· \`market.price_history\`:这一单报了什么价、什么时候报的、结果如何、拒绝原因是什么;
|
||||||
再按结果更新客户画像标签与等级(利润模型 P-01 的客户等级因子取的就是它),
|
· \`customer.profile\`:客户画像该更新什么(价格敏感度、决策周期、常用条款);
|
||||||
并把这一单计入当月落地率统计(TypeA 命中率、D+3 回复率一并算)。
|
· \`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。
|
||||||
|
终局唯放行:你不能自己宣布完成。`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { ActivityBase, ActivityOutcome } from '@sdk';
|
|||||||
|
|
||||||
export default defineActivity({
|
export default defineActivity({
|
||||||
name: 'quote-archive',
|
name: 'quote-archive',
|
||||||
paradigm: 'flow',
|
|
||||||
|
|
||||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
||||||
const history = await base.ledger.read('shared.history_price');
|
const history = await base.ledger.read('shared.history_price');
|
||||||
|
|||||||
@@ -6,17 +6,19 @@
|
|||||||
*/
|
*/
|
||||||
import type { ActivityManifest } from '@contracts';
|
import type { ActivityManifest } from '@contracts';
|
||||||
import { ARCHIVE_SKILLS } from './skills.ts';
|
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 { activityIdSchema } from '@contracts';
|
||||||
import { ARCHIVE_LOGGED, ARCHIVE_RELEASE } from './views/report.ts';
|
import { ARCHIVE_LOGGED, ARCHIVE_RELEASE } from './views/report.ts';
|
||||||
|
|
||||||
const ACTIVITY_ID = activityIdSchema.parse('quote-archive');
|
const ACTIVITY_ID = activityIdSchema.parse('quote-archive');
|
||||||
|
/** 本活动自己的数据域 —— 三张沉淀表落 `shared`,只有归档结论落这儿 */
|
||||||
|
const DOMAIN = 'quote_archive';
|
||||||
|
|
||||||
export const manifest: ActivityManifest = {
|
export const manifest: ActivityManifest = {
|
||||||
identity: {
|
identity: {
|
||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
name: 'A7 · 情报归档',
|
name: 'A7 · 情报归档',
|
||||||
packageVersion: '0.1.6',
|
packageVersion: '2.1.0',
|
||||||
category: '统一报价',
|
category: '统一报价',
|
||||||
sdkVersion: '0.1.0-m5',
|
sdkVersion: '0.1.0-m5',
|
||||||
checksum: 'dev-checksum-quote-archive',
|
checksum: 'dev-checksum-quote-archive',
|
||||||
@@ -28,9 +30,10 @@ export const manifest: ActivityManifest = {
|
|||||||
input: {
|
input: {
|
||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['customerCode', 'quoteNo'],
|
required: ['rfqRef', 'customerCode', 'quoteNo'],
|
||||||
properties: {
|
properties: {
|
||||||
/** 归档写 (pn, quoted_at) 这一行,两个主键都由调用方给定 */
|
/** 归档写 (pn, quoted_at) 这一行,两个主键都由调用方给定 */
|
||||||
|
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
|
||||||
pn: { type: 'string' },
|
pn: { type: 'string' },
|
||||||
quotedAt: { type: 'string' },
|
quotedAt: { type: 'string' },
|
||||||
customerCode: { type: 'string' },
|
customerCode: { type: 'string' },
|
||||||
@@ -51,15 +54,28 @@ export const manifest: ActivityManifest = {
|
|||||||
],
|
],
|
||||||
selective: [],
|
selective: [],
|
||||||
},
|
},
|
||||||
|
/*
|
||||||
|
* 必交付物**必须给 `ledger` 地址**(2026-07-31 修)。
|
||||||
|
* 不给的话预检退回 `scope[key]` 去找 —— key 是 `benchmark-table` 这样的短横线别名,
|
||||||
|
* 任务视图里永远没有这个键,于是「必交付物缺一不得提交放行」永远拒,
|
||||||
|
* 活办完了也放不了行,连拒五拍熔断(实测 A2 死于此)。
|
||||||
|
*/
|
||||||
requiredDeliverables: [
|
requiredDeliverables: [
|
||||||
{ key: 'history-archive', label: '历史价格情报(归档)' },
|
{ key: 'history-archive', label: '历史价格情报(归档)', ledger: 'market.price_history' },
|
||||||
{ key: 'landing-stats', label: '报价落地率统计' },
|
{ key: 'landing-stats', label: '报价落地率统计', ledger: 'shared.landing_stats' },
|
||||||
],
|
],
|
||||||
|
/*
|
||||||
|
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。
|
||||||
|
* 上一版的名字(reasonLogged / profileUpdated)**台账里根本没有** —— S-FEEL 拿未绑定的名字
|
||||||
|
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。
|
||||||
|
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
|
||||||
|
*/
|
||||||
acceptanceCriteria: [
|
acceptanceCriteria: [
|
||||||
{ key: 'reason-logged', label: '拒绝原因 / 竞对价位已回写', level: 'L1', predicate: 'reasonLogged = true' },
|
{ key: 'reason-logged', label: '拒绝原因 / 竞对价位已回写', level: 'L1', predicate: 'reason_logged_total > 0' },
|
||||||
{ key: 'profile-updated', label: '客户画像标签已更新', level: 'L2', predicate: 'profileUpdated = true' },
|
{ key: 'profile-updated', label: '客户画像标签已更新', level: 'L2', predicate: 'profile_updated_total > 0' },
|
||||||
{ key: 'bd-release', label: 'BD 放行', level: 'L5' },
|
{ key: 'bd-release', label: 'BD 放行', level: 'L5' },
|
||||||
],
|
],
|
||||||
|
revisableAtRelease: [],
|
||||||
prohibitions: [{ key: 'no-blank-archive', statement: '不得空归档 —— 每一单不成交的报价也是情报资产' }],
|
prohibitions: [{ key: 'no-blank-archive', statement: '不得空归档 —— 每一单不成交的报价也是情报资产' }],
|
||||||
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 2 },
|
authorization: { discretion: [], preauthorizedActions: [], humanCallBudget: 2 },
|
||||||
resources: {
|
resources: {
|
||||||
@@ -77,8 +93,9 @@ export const manifest: ActivityManifest = {
|
|||||||
ledger: [
|
ledger: [
|
||||||
{
|
{
|
||||||
// 共享域:跨活动的沉淀事实(AM 11.3 暗耦合禁令的两条合法路径之一)
|
// 共享域:跨活动的沉淀事实(AM 11.3 暗耦合禁令的两条合法路径之一)
|
||||||
domain: 'shared',
|
domain: 'market',
|
||||||
object: 'history_price',
|
object: 'price_history',
|
||||||
|
kind: 'master',
|
||||||
label: '历史价格情报',
|
label: '历史价格情报',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
||||||
@@ -88,8 +105,9 @@ export const manifest: ActivityManifest = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
domain: 'shared',
|
domain: 'customer',
|
||||||
object: 'customer',
|
object: 'profile',
|
||||||
|
kind: 'master',
|
||||||
label: '客户资料库',
|
label: '客户资料库',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' },
|
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' },
|
||||||
@@ -100,6 +118,7 @@ export const manifest: ActivityManifest = {
|
|||||||
{
|
{
|
||||||
domain: 'shared',
|
domain: 'shared',
|
||||||
object: 'landing_stats',
|
object: 'landing_stats',
|
||||||
|
kind: 'derived',
|
||||||
label: '报价落地率统计',
|
label: '报价落地率统计',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'period', type: 'text', primaryKey: true, masked: false, label: '月份' },
|
{ 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回复率' },
|
{ 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: [],
|
listeners: [],
|
||||||
// 7 天闭环(MVP示例 §1.7)
|
// 7 天闭环(MVP示例 §1.7)
|
||||||
@@ -120,6 +159,8 @@ export const manifest: ActivityManifest = {
|
|||||||
cmmnXml: CMMN_XML,
|
cmmnXml: CMMN_XML,
|
||||||
narrative: NARRATIVE,
|
narrative: NARRATIVE,
|
||||||
dmn: { drd: [], tables: [] },
|
dmn: { drd: [], tables: [] },
|
||||||
|
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
|
||||||
|
steps: ARCHIVE_STEPS,
|
||||||
},
|
},
|
||||||
skills: ARCHIVE_SKILLS,
|
skills: ARCHIVE_SKILLS,
|
||||||
viewTemplates: [
|
viewTemplates: [
|
||||||
|
|||||||
@@ -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<readonly [string, string, string]>): string {
|
||||||
|
return `<ul class="steps">${rows
|
||||||
|
.map(
|
||||||
|
([no, term, gloss]) =>
|
||||||
|
`<li><span class="n">${no}</span><span><span class="t">${term}</span> <span class="d">${gloss}</span></span></li>`,
|
||||||
|
)
|
||||||
|
.join('')}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 局面一:S1 回写 —— 人此刻要判的是「这条原因,下一单读不读得懂」 */
|
||||||
export const ARCHIVE_LOGGED = viewShell({
|
export const ARCHIVE_LOGGED = viewShell({
|
||||||
title: '结果登记',
|
title: '结果登记',
|
||||||
kicker: 'A7 · 情报归档',
|
kicker: 'A7 · 情报归档 · S1 回写',
|
||||||
tone: 'archive',
|
tone: 'archive',
|
||||||
|
icon: 'database',
|
||||||
|
stamp: { kind: 'live', text: '回写中' },
|
||||||
|
sign: 'A7 情报归档 · 结果登记(共享域)',
|
||||||
body:
|
body:
|
||||||
kvCard('本单结果', [
|
statsBand([
|
||||||
['型号 PN', 'pn', 'mono'],
|
['报价日期', 'quoted_at'],
|
||||||
['报价日期', 'quotedAt', 'mono'],
|
|
||||||
['拒绝原因', 'lostReason'],
|
|
||||||
['竞对价位', 'competitor_price'],
|
['竞对价位', 'competitor_price'],
|
||||||
|
['客户等级', 'grade'],
|
||||||
]) +
|
]) +
|
||||||
noteCard('为什么要登记', '拒绝原因与竞对价位是<b>下一单的判据</b>:它们回写历史价格情报,供 P-03 的「距上次报价 / 行情稳定」与 P-01 的客户等级因子取用。这一单的败仗,是下一单的地形图。'),
|
section('本单登记的结果',
|
||||||
|
tableOnly('mine:shared.history_price', [
|
||||||
|
['型号 PN', 'pn', 'mono'],
|
||||||
|
['报价日期', 'quoted_at', 'mono'],
|
||||||
|
['拒绝原因', 'lost_reason'],
|
||||||
|
['竞对价位', 'competitor_price'],
|
||||||
|
]) +
|
||||||
|
noteOnly(
|
||||||
|
'<b>「客户没回」不是原因。</b>问不出来就写「未获反馈」—— 不许编一个听着合理的理由:' +
|
||||||
|
'编出来的原因会一路流进下一单的判据里,而那时已经没人记得它是编的。',
|
||||||
|
),
|
||||||
|
{ text: '只列本单写下的行' },
|
||||||
|
) +
|
||||||
|
section('什么样的原因才算数',
|
||||||
|
ladder([
|
||||||
|
['1', '竞对报价多少、差几个百分点', '要数,不要形容词'],
|
||||||
|
['2', '输在价格、交期,还是资质 / 认证', '把败因归到一个维度上'],
|
||||||
|
['3', '客户是否明说了可接受区间', '下一单的起点就藏在这句话里'],
|
||||||
|
]) +
|
||||||
|
noteOnly(
|
||||||
|
'<b>「价格高」不是原因,是结论。</b>判据要能被下一单<b>机器读取</b>:' +
|
||||||
|
'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(
|
||||||
|
'归档做得好不好,<b>不在这一单看得出来</b> —— 在下一次报价能不能一眼看到「上次为什么丢」看得出来。',
|
||||||
|
),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 局面二:闭环待放行 —— 签完这一笔,整条工作流走到终点 */
|
||||||
export const ARCHIVE_RELEASE = viewShell({
|
export const ARCHIVE_RELEASE = viewShell({
|
||||||
title: '情报归档 · 闭环待放行',
|
title: '情报归档 · 闭环待放行',
|
||||||
kicker: 'A7 · 成果预览',
|
kicker: 'A7 · 情报归档 · 成果待签',
|
||||||
tone: 'ok',
|
tone: 'case',
|
||||||
|
icon: 'archive',
|
||||||
|
stamp: { kind: 'live', text: '待签字封卷' },
|
||||||
|
sign: 'A7 情报归档 · 闭环成果(待放行)',
|
||||||
body:
|
body:
|
||||||
tableCard('历史价格情报回写', 'mine:shared.history_price', [
|
statsBand([
|
||||||
['型号', 'pn', 'mono'],
|
['统计周期', 'period'],
|
||||||
['报价日期', 'quoted_at', 'mono'],
|
|
||||||
['拒绝原因', 'lost_reason'],
|
|
||||||
]) +
|
|
||||||
kvCard('画像与落地率', [
|
|
||||||
['客户编码', 'customer_code', 'mono'],
|
|
||||||
['画像标签', 'profile_tags'],
|
|
||||||
['统计周期', 'period', 'mono'],
|
|
||||||
['落地率', 'landing_rate'],
|
['落地率', 'landing_rate'],
|
||||||
|
['TypeA 命中', 'type_a_hit'],
|
||||||
|
['D+3 回复率', 'reply_rate_d3'],
|
||||||
]) +
|
]) +
|
||||||
noteCard(
|
section('历史价格情报回写',
|
||||||
'闭环的含义',
|
tableOnly('mine:shared.history_price', [
|
||||||
'放行后这条工作流走到<b>终点节点</b>,状态收敛为「已完成」。' +
|
['型号 PN', 'pn', 'mono'],
|
||||||
'终态封卷触发<b>蒸馏钩子</b>:从任务记忆归纳出活动记忆的候选条目(带证据链),' +
|
['报价日期', 'quoted_at', 'mono'],
|
||||||
'经回放与人放行才成为下一版业务模型的一部分 —— <b>记忆不载权力</b>。',
|
['拒绝原因', '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(
|
||||||
|
'三张表都写在<b>共享域</b>:它们是跨活动的沉淀事实,不属于这一单 ——' +
|
||||||
|
'这也是活动之间除了流程边以外,唯一一条合法的信息通路。',
|
||||||
|
),
|
||||||
|
{ text: '驾驶舱取它' },
|
||||||
|
) +
|
||||||
|
section('放行前要过的三道判据',
|
||||||
|
ladder([
|
||||||
|
['1', '拒绝原因 / 竞对价位已回写', 'L1 机检 · reasonLogged = true'],
|
||||||
|
['2', '客户画像标签已更新', 'L2 机检 · profileUpdated = true'],
|
||||||
|
['3', 'BD 放行', 'L5 人签 · 签了才算数'],
|
||||||
|
]),
|
||||||
|
{ text: '两机检 · 一人签' },
|
||||||
|
) +
|
||||||
|
section('封卷之后会发生什么',
|
||||||
|
verdict('放行即闭环:这条工作流就此收敛为「已完成」', 'wait', '终态封卷触发蒸馏钩子') +
|
||||||
|
noteOnly(
|
||||||
|
'蒸馏从任务记忆归纳出<b>活动记忆</b>的候选条目(带证据链),经回放与人放行才成为' +
|
||||||
|
'下一版业务模型的一部分 —— <b>记忆不载权力</b>。这一单丢了,但它把下一单的地形描出来了。',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* 活动包的自测 —— **只准 import @sdk / @contracts 与白名单纯库**(编码规范 §5.1 / ADR-004)。
|
* A1 询价受理的包内自测 —— **只准 import @sdk / @contracts / @sfeel**
|
||||||
* 安装两审那一侧的验收在 `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,
|
* (编码规范 §5.1 / ADR-004)。安装两审那一侧的验收在
|
||||||
* 没有代码通道,depcruise 双向隔离在守着。
|
* `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,没有代码通道,
|
||||||
|
* depcruise 双向隔离在守着。
|
||||||
|
*
|
||||||
|
* 案例活动没有 `run`,所以自测不跑回合(那是 Loop 的事)。这里测的是**清单本身**:
|
||||||
|
* ① 清单合规 + 判据等级不谎报;
|
||||||
|
* ② 案例模型双表示同在、触达世界的项标了人工激活;
|
||||||
|
* ③ 两张判定表逐行求值(能算的不问 LLM);
|
||||||
|
* ④ **声明与契约对得上** —— 这一段最容易在改动中悄悄错位:
|
||||||
|
* 必交付物的台账地址、验收谓词读的列、出口载荷要的标量,三处必须都有台账列承载。
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { activityManifestSchema } from '@contracts';
|
import { activityManifestSchema } from '@contracts';
|
||||||
import { evaluateUnaryTest } from '@sfeel';
|
import { evaluateUnaryTest } from '@sfeel';
|
||||||
import { createMockHost } from '@sdk';
|
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';
|
import activity from '../src/index.ts';
|
||||||
|
|
||||||
describe('清单本身合规', () => {
|
describe('清单本身合规', () => {
|
||||||
@@ -21,7 +29,7 @@ describe('清单本身合规', () => {
|
|||||||
|
|
||||||
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
|
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
|
||||||
for (const c of manifest.contract.acceptanceCriteria) {
|
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.prohibitions.length).toBeGreaterThan(0);
|
||||||
expect(manifest.contract.authorization.humanCallBudget).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)—— 双表示同版本', () => {
|
describe('案例模型(AM 03/12.2)—— 双表示同版本', () => {
|
||||||
@@ -37,119 +51,342 @@ describe('案例模型(AM 03/12.2)—— 双表示同版本', () => {
|
|||||||
|
|
||||||
it('CMMN 与说明书同在,不允许分叉', () => {
|
it('CMMN 与说明书同在,不允许分叉', () => {
|
||||||
expect(model.cmmnXml).toContain('<casePlanModel');
|
expect(model.cmmnXml).toContain('<casePlanModel');
|
||||||
expect(model.narrative.length).toBeGreaterThan(80);
|
expect(model.narrative.length).toBeGreaterThan(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('触达世界的项恒需人工激活(AM 4.1 原理一)', () => {
|
it('触达世界的项标了人工激活(AM 4.1 原理一)', () => {
|
||||||
// 解析归一、追问补全、查历史价 —— 三件都碰外界,一件也不许自动跑
|
/*
|
||||||
for (const id of ['T_normalize', 'T_ask_back', 'T_history']) {
|
* 只剩放行这一件。定位客户与归一 RFQ 两步**发卡但不落地**:
|
||||||
|
* 补全卡与执行卡都是「问一句」,回来的值仍旧只进草稿层,
|
||||||
|
* 真正触达世界的只有放行那一下(铁律③)。
|
||||||
|
*/
|
||||||
|
for (const id of ['T_release']) {
|
||||||
expect(cmmn.manualActivation, `${id} 应为人工激活`).toContain(id);
|
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('</humanTask>')), `${id} 少了 manualActivationRule`).toContain(
|
||||||
|
'<manualActivationRule',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不用阶段 / 里程碑 / 裁量项 —— 它们换不来业务上说得出的好处', () => {
|
||||||
|
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
|
||||||
|
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('常来回的项标了可重复 —— 认客户与归一都会走好几轮', () => {
|
||||||
|
for (const id of ['S1_customer', 'S2_normalize']) {
|
||||||
|
expect(cmmn.repetition, `${id} 应可重复`).toContain(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('末尾一张验收单 —— 完成只出自放行通过', () => {
|
it('末尾一张验收单 —— 完成只出自放行通过', () => {
|
||||||
expect(model.cmmnXml).toContain('id="T_release"');
|
expect(model.cmmnXml).toContain('id="T_release"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('能算的交 DMN,不交 Agent —— 打标是 decisionTask', () => {
|
it('能算的交 DMN,不交 Agent —— 打标是 decisionTask', () => {
|
||||||
expect(model.cmmnXml).toContain('decisionRef="p03-type"');
|
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 里有自己的测试) */
|
/** 极小取值:只为断言用,不重实现引擎的解析器(那一份在 loop-engine 里有自己的测试) */
|
||||||
function parseCmmnLite(xml: string): { manualActivation: string[] } {
|
/** 计划项 id 按出现次序 —— 断言「图上有几步就是几项」 */
|
||||||
const ids: string[] = [];
|
function items(xml: string): string[] {
|
||||||
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
|
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
|
||||||
if (m[0].includes('manualActivationRule')) ids.push(m[1]!);
|
|
||||||
}
|
|
||||||
return { manualActivation: ids };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('三分类规则 P-03 —— 能算的不问 LLM', () => {
|
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
|
||||||
const table = P03_DMN.tables[0]!;
|
const manualActivation: string[] = [];
|
||||||
|
const repetition: string[] = [];
|
||||||
|
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/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 */
|
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
|
||||||
const decide = (scope: Record<string, unknown>) => {
|
function decideBy(table: typeof P03_DMN, scope: Record<string, unknown>) {
|
||||||
for (const rule of table.rules) {
|
for (const rule of table.rules) {
|
||||||
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
|
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 };
|
if (hit) return { outputs: rule.then, rule: rule.id };
|
||||||
}
|
}
|
||||||
return undefined;
|
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<string, unknown>) => decideBy(P03_DMN, scope);
|
||||||
|
|
||||||
it('历史价 ≤90 天且行情稳 → A · 直接快答', () => {
|
it('历史价 ≤90 天且行情稳 → A · 直接快答', () => {
|
||||||
expect(decide({ historyDays: 30, marketStable: true, lifecycle: '在产' })).toEqual({
|
expect(decide({ history_days: 30, market_stable: true, lifecycle: '在产' })).toMatchObject({
|
||||||
type: 'A',
|
outputs: ['A', '直接快答'],
|
||||||
play: '直接快答',
|
|
||||||
rule: 'p03-a',
|
rule: 'p03-a',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('无历史 / 过期 / 波动 → B · 重估后报', () => {
|
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)', () => {
|
it('EOL / 缺货 / 要求替代 → C · 转案处理(**优先于 A**:新历史价也不该让 EOL 直通)', () => {
|
||||||
expect(decide({ historyDays: 10, marketStable: true, lifecycle: 'EOL' })).toMatchObject({ type: 'C', rule: 'p03-c' });
|
expect(decide({ history_days: 10, market_stable: true, lifecycle: 'EOL' })).toMatchObject({ rule: 'p03-c' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('同输入必同结论 —— 零方差、零 token', () => {
|
it('同输入必同结论 —— 零方差、零 token', () => {
|
||||||
const scope = { historyDays: 30, marketStable: true, lifecycle: '在产' };
|
const scope = { history_days: 30, market_stable: true, lifecycle: '在产' };
|
||||||
expect(decide(scope)).toEqual(decide(scope));
|
expect(decide(scope)).toEqual(decide(scope));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('代码入口 —— 零环境能力,一切经基座六件', () => {
|
/**
|
||||||
const contract = {
|
* 声明与契约的对齐 —— 三处引用同一批台账列,错位了不会报错,只会在放行那一刻
|
||||||
activityId: 'quote-intake',
|
* 「判据取不到值 / 出口取不到字段」,而两者都长得像业务问题。所以在这儿钉死。
|
||||||
version: '0.1.0',
|
*/
|
||||||
data: { customerCode: 'C001', rfqRef: 'mail:m-1001' },
|
describe('声明与契约对得上', () => {
|
||||||
message: undefined,
|
const columns = new Set(
|
||||||
outputPorts: { mandatory: ['intake'], selective: [] },
|
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
|
||||||
prohibitions: [],
|
);
|
||||||
discretion: [],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
it('全部打标后提交放行', async () => {
|
it('必交付物的台账地址都声明过', () => {
|
||||||
const host = createMockHost({
|
for (const d of manifest.contract.requiredDeliverables) {
|
||||||
contract,
|
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
|
||||||
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('尚有未打标即等待,不提交放行', async () => {
|
/*
|
||||||
const host = createMockHost({
|
* 除了台账列,谓词还能读一类**基座数出来的读数**:`域_对象_rows` = 本单在那张表上
|
||||||
contract,
|
* 写下了几行。它不是列(活动写不了它),正因如此才拦得住「两边都由活动自报」的假对账。
|
||||||
ledger: { 'quote_intake.record': [{ customer_code: 'C001', pn: 'LM358DR' }], 'quote_intake.type_table': [] },
|
* 这里照样要核到底:对象没声明过,`quote_intake_recrod_rows` 这种手滑仍要当场炸。
|
||||||
});
|
*/
|
||||||
expect(await host.run(activity)).toMatchObject({ kind: 'await' });
|
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 () => {
|
it('出口载荷的标量字段都有台账列承载(§7.5;踩过:两枚去向旗没列 → G1 全流死路)', () => {
|
||||||
const host = createMockHost({ contract });
|
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||||
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
|
for (const port of manifest.contract.output.mandatory) {
|
||||||
|
const properties = (port.payload as { properties?: Record<string, { type?: string }> }).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 () => {
|
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
|
||||||
const host = createMockHost({ contract });
|
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
|
||||||
await expect(
|
for (const skill of manifest.assets.skills) {
|
||||||
host.base.interaction.issueWorkOrder({ mode: 'execution', title: 'x', basis: [], ask: 'y', fields: [] }),
|
for (const fragment of skill.crystallized) {
|
||||||
).rejects.toThrow(/裸调用/);
|
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('监听时限必配 —— 无死等', async () => {
|
describe('报告模版 —— 活动持有,容器无脚本执行权(§9.3)', () => {
|
||||||
const host = createMockHost({ contract });
|
it('四个局面各一张,不在一张里堆条件分支', () => {
|
||||||
await expect(host.base.tools.listen({ eventType: 'data', keyFilter: {}, deadline: '' })).rejects.toThrow(/时限必配/);
|
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(/<script|\son[a-z]+=|https?:\/\//i);
|
||||||
|
expect(template.html, template.name).toContain('<style>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 放行那张报告要摆出**去向是怎么算出来的** —— 人签字前得看见这一单要往哪走,
|
||||||
|
* 更要看见凭哪一条走。
|
||||||
|
*
|
||||||
|
* 从前这里断言的是两枚去向旗(`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('<div><script>x()</script></div>')).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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<string, unknown> = {
|
||||||
|
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<string, unknown>) => ({
|
||||||
|
...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<string, unknown>): boolean {
|
||||||
|
try {
|
||||||
|
return evaluateCondition(expression, scope);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,85 +1,716 @@
|
|||||||
/**
|
/**
|
||||||
* A1 询价受理的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
* A1 询价受理的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
||||||
*
|
*
|
||||||
* 2026-07-29 由步序声明(flow)改写而来:活动不再分范式,一律案例模型 + Agent 临场调度。
|
* 业务出处:`docs/MVP示例工作流.html` §1.1 的那张步骤流程图,原样照搬:
|
||||||
* 原来的六步在这里各归其位 —— 解析归一与追问补全同属「收料归一」阶段(常来回几轮,
|
*
|
||||||
* 所以带 repetitionRule);查历史价与三分类打标属「定性分级」;末尾一张验收单。
|
* ● 接流程起点 → S1 接收登记 → D1 判断完整度
|
||||||
|
* ├─ 不完整 → S2 追问补全信息 ─┐
|
||||||
|
* └─ 完整 ───────────────────┴→ S3 逐条解析归一
|
||||||
|
* → S4 查历史价 → S5 逐条定状况标 → S6 定去向与派单 → G1
|
||||||
|
*
|
||||||
|
* **最长路径就是这六步**(常路五步 —— 字段齐就不走 S2),末尾加一张受理放行(终局唯放行)。
|
||||||
|
*
|
||||||
|
* ── 建模纪律:图上有几步就是几项 ──────────────────────────────────────
|
||||||
|
*
|
||||||
|
* 这份模型**重写过一次**。上一版自作主张加了两个阶段(收料归一 / 定性分级)、两个里程碑、
|
||||||
|
* 一个裁量项、一个用户事件监听器,12 个计划项去装 6 步的活。业务上一分没多,
|
||||||
|
* 卡壳的花样却多了一堆:阶段被当成写的落点(收了阶段,里面的项就永远入不了场)、
|
||||||
|
* 里程碑要不要收、项该先 start 还是先干、编出不存在的项 id……
|
||||||
|
*
|
||||||
|
* 实测账很清楚:**每一条「行动被拒」都是结构上的,没有一条是业务判断上的** ——
|
||||||
|
* 模型在跟 harness 较劲,不是在办事;一单烧掉二三十万 token 才收口,还常常收不了口。
|
||||||
|
*
|
||||||
|
* 所以:**图上有几步就是几项,不多建一件**。阶段、里程碑、裁量项都是 CMMN 有的东西,
|
||||||
|
* 但「有」不等于「该用」—— 它们要能换来业务上说得出的好处,否则就只是给 Agent 添的坑。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||||
<case id="intake">
|
<case id="intake">
|
||||||
|
<!--
|
||||||
|
案卷项的 id **就是任务视图里的键名** —— 前提的 onPart 认的是「这个键有值了」。
|
||||||
|
起单入参(subject)与台账列(line_total / missing_key_total / history_days /
|
||||||
|
untyped_total / need_sourcing)都会摊平到任务视图顶层,所以照它们命名。
|
||||||
|
名字对不上就等于前提永不满足:整案第一步都动不了(踩过)。
|
||||||
|
|
||||||
|
开案即有的只有那封信(客户邮箱 / 主题 / 邮件内容 / 邮件附件)。**询价单号不在其列** ——
|
||||||
|
它是 S1 从信里认出来的,认出来才写得动台账(所有台账对象都以 rfq_ref 为主键)。
|
||||||
|
-->
|
||||||
<caseFileModel>
|
<caseFileModel>
|
||||||
<caseFileItem id="rfq" name="询价来函"/>
|
<caseFileItem id="subject" name="来件主题(开案即有)"/>
|
||||||
<caseFileItem id="records" name="统一受理记录"/>
|
<!-- ① 定位客户:认出来(或人建出来)才算数 -->
|
||||||
<caseFileItem id="history" name="历史价格情报"/>
|
<caseFileItem id="customer_total" name="客户已确认"/>
|
||||||
<caseFileItem id="verdict" name="受理结论"/>
|
<caseFileItem id="customer_new_total" name="新客户(需建档)"/>
|
||||||
|
<caseFileItem id="customer_asked_total" name="已就建档问过人"/>
|
||||||
|
<caseFileItem id="line_total" name="登记出的条目数"/>
|
||||||
|
<!-- ② 归一 RFQ:必填缺 = PN / 年用量;选填缺 = 品牌 / 备注 -->
|
||||||
|
<!--
|
||||||
|
缺必填的行数由**基座**按台账声明的 required 现数(quote_intake_record_missing)——
|
||||||
|
不读活动自报的 missing_key_total:那个数要模型自己重算,漏算一次这道门就永远开不了
|
||||||
|
(连跑十轮里三轮死在这)。自报的两个数照旧写,它们是给报告和人看的。
|
||||||
|
-->
|
||||||
|
<caseFileItem id="quote_intake_record_missing" name="缺必填的行数(基座现数)"/>
|
||||||
|
<caseFileItem id="missing_key_total" name="缺必填字段条数(PN/年用量)"/>
|
||||||
|
<caseFileItem id="missing_minor_total" name="缺选填字段条数(品牌/备注)"/>
|
||||||
|
<caseFileItem id="asked_total" name="已就缺口问过人的次数"/>
|
||||||
|
<caseFileItem id="answered_total" name="人答完并已落表的次数"/>
|
||||||
|
<!-- ③ 受理需求交人审定:这一单**经过人的手**了没有 -->
|
||||||
|
<caseFileItem id="confirmed_total" name="受理需求已经人审定"/>
|
||||||
|
<caseFileItem id="history_days" name="历史价判据"/>
|
||||||
|
<caseFileItem id="untyped_total" name="未打标条数"/>
|
||||||
|
<caseFileItem id="need_sourcing" name="去向旗"/>
|
||||||
</caseFileModel>
|
</caseFileModel>
|
||||||
<casePlanModel id="plan" name="询价受理" autoComplete="true">
|
<casePlanModel id="plan" name="询价受理" autoComplete="true">
|
||||||
<sentry id="s_rfq">
|
<!-- D1 判断完整度在图上是一个菱形,在这里是两条前提:先认客户,再看归一齐没齐 -->
|
||||||
<onPart id="op_rfq" caseFileItemRef="rfq"><standardEvent>create</standardEvent></onPart>
|
<!--
|
||||||
|
① 客户认出来了 —— 归一那一步的入场前提。
|
||||||
|
先认客户再解析正文,不是先后顺序的洁癖:归一出来的每一条都要落在
|
||||||
|
某个客户名下(record.customer_code),客户没定位就写不动那一列。
|
||||||
|
-->
|
||||||
|
<sentry id="s_customer">
|
||||||
|
<onPart id="op_cu" caseFileItemRef="customer_total"><standardEvent>update</standardEvent></onPart>
|
||||||
|
<ifPart id="if_cu"><condition>customer_total > 0 and (customer_new_total = 0 or customer_asked_total > 0)</condition></ifPart>
|
||||||
</sentry>
|
</sentry>
|
||||||
<sentry id="s_incomplete">
|
<!--
|
||||||
<onPart id="op_ic" caseFileItemRef="records"><standardEvent>update</standardEvent></onPart>
|
② 拆完了 —— 交人审定那一步的入场前提。只看「有没有拆出行来」:
|
||||||
<ifPart id="if_ic"><condition>missingKeyFields > 0</condition></ifPart>
|
缺不缺字段不影响该不该给人看,缺口是**卡上要说清的事**,不是拦住这张卡的理由。
|
||||||
|
-->
|
||||||
|
<sentry id="s_parsed">
|
||||||
|
<onPart id="op_ps" caseFileItemRef="line_total"><standardEvent>update</standardEvent></onPart>
|
||||||
|
<ifPart id="if_ps"><condition>line_total > 0</condition></ifPart>
|
||||||
</sentry>
|
</sentry>
|
||||||
|
<!--
|
||||||
|
③ **人审定过了** —— 后面查历史价/打标的入场前提。
|
||||||
|
必填(PN/年用量)缺一条都不算完(它是验收标准);而**这一单必须经过人的手**:
|
||||||
|
confirmed_total 由「人把受理需求那张卡交回来」这件事产生 —— 详见 S3_confirm 的注释。
|
||||||
|
|
||||||
|
从前这里写的是「missing_minor_total = 0 or answered_total > 0」:
|
||||||
|
**只在缺东西的时候才叫人**。于是一封字段齐全的询价进来,这个条件当场为真,
|
||||||
|
引擎在下一拍开头把归一那一步自己收了 —— 人一眼没看过受理需求,
|
||||||
|
下一次被叫到已经是终局的验收放行(用户 2026-08-01 当场指出:
|
||||||
|
「我都没确认需求 它就让我验收了」)。**确认不是缺口的补丁,是这一步本身。**
|
||||||
|
-->
|
||||||
<sentry id="s_complete">
|
<sentry id="s_complete">
|
||||||
<onPart id="op_cp" caseFileItemRef="records"><standardEvent>update</standardEvent></onPart>
|
<onPart id="op_cp" caseFileItemRef="confirmed_total"><standardEvent>update</standardEvent></onPart>
|
||||||
<ifPart id="if_cp"><condition>missingKeyFields = 0</condition></ifPart>
|
<ifPart id="if_cp"><condition>line_total > 0 and quote_intake_record_missing = 0 and confirmed_total > 0</condition></ifPart>
|
||||||
</sentry>
|
</sentry>
|
||||||
|
<sentry id="s_basis">
|
||||||
|
<onPart id="op_bs" caseFileItemRef="history_days"><standardEvent>update</standardEvent></onPart>
|
||||||
|
</sentry>
|
||||||
|
<!--
|
||||||
|
打完标才进得了定去向 —— 条件读**基座按值现数**的三类条数,不读活动自报的
|
||||||
|
untyped_total(那是它自己写的数,写错一次这道门就形同虚设)。
|
||||||
|
-->
|
||||||
<sentry id="s_typed">
|
<sentry id="s_typed">
|
||||||
<onPart id="op_tp" caseFileItemRef="records"><standardEvent>update</standardEvent></onPart>
|
<onPart id="op_tp" caseFileItemRef="untyped_total"><standardEvent>update</standardEvent></onPart>
|
||||||
<ifPart id="if_tp"><condition>untyped = 0</condition></ifPart>
|
<ifPart id="if_tp"><condition>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</condition></ifPart>
|
||||||
|
</sentry>
|
||||||
|
<!--
|
||||||
|
── 放行门的入场条件(2026-08-02 用户实测逼出来的)────────────────────────
|
||||||
|
|
||||||
|
从前这道哨兵**只有 onPart 没有 ifPart**:意思是「只要有人往 need_sourcing
|
||||||
|
写过一笔就开门」—— 写个 false 也算。实测的下场:模型跳过 S4/S5/S6,
|
||||||
|
在 verdict 上顺手写了 need_sourcing: false,放行门当场 Available,
|
||||||
|
它 start 完就提交;而受理记录三行一个标都没打、分类表一行都没写。
|
||||||
|
提交门正确地拦住了,可**该拦的地方本不该放它进来** —— 一道只认「有人写过」
|
||||||
|
的门,等于没有门。
|
||||||
|
|
||||||
|
现在认的是**办完了没有**,且三个数全由基座现数(活动自报的数改一下就骗过去了):
|
||||||
|
逐条打了标、去向定过了。route_decided_total 是 S6 显式写下的记账数
|
||||||
|
(旗是布尔,分不清「没写」与「写了 false」)。
|
||||||
|
-->
|
||||||
|
<sentry id="s_routed">
|
||||||
|
<onPart id="op_rt" caseFileItemRef="need_sourcing"><standardEvent>update</standardEvent></onPart>
|
||||||
|
<ifPart id="if_rt"><condition>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</condition></ifPart>
|
||||||
</sentry>
|
</sentry>
|
||||||
|
|
||||||
<stage id="S1" name="收料归一" autoComplete="true">
|
<!--
|
||||||
<entryCriterion id="ec_s1" sentryRef="s_rfq"/>
|
① 定位客户信息 —— 拿发件邮箱去客户主数据里认人。
|
||||||
<humanTask id="T_normalize" name="人工逐条解析归一">
|
认得出就取它的客户编码;认不出说明这是个**新客户**,发补全卡请人建档
|
||||||
<itemControl><manualActivationRule id="m_nm"/><repetitionRule id="r_nm"/></itemControl>
|
(公司名、客户编码、联系人)—— 建档是人的事,活动不替他编一个客户出来。
|
||||||
</humanTask>
|
-->
|
||||||
<humanTask id="T_ask_back" name="人工追问补全信息">
|
<humanTask id="S1_customer" name="定位客户信息:按发件邮箱认客户,认不出请人建档">
|
||||||
<entryCriterion id="ec_ask" sentryRef="s_incomplete"/>
|
<itemControl><repetitionRule id="r_s1"/></itemControl>
|
||||||
<itemControl><manualActivationRule id="m_ab"/><repetitionRule id="r_ab"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_complete" name="八字段齐"/>
|
|
||||||
|
|
||||||
<stage id="S2" name="定性分级" autoComplete="true">
|
|
||||||
<entryCriterion id="ec_s2" sentryRef="s_complete"/>
|
|
||||||
<humanTask id="T_history" name="人工查历史价">
|
|
||||||
<itemControl><manualActivationRule id="m_hi"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<decisionTask id="D_classify" name="逐条打标 A/B/C" decisionRef="p03-type"/>
|
|
||||||
<planningTable id="pt_s2">
|
|
||||||
<humanTask id="T_lifecycle" name="人工核生命周期"/>
|
|
||||||
</planningTable>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_typed" name="全条已打标"/>
|
|
||||||
|
|
||||||
<humanTask id="T_release" name="人工受理结论验收放行">
|
|
||||||
<entryCriterion id="ec_rel" sentryRef="s_typed"/>
|
|
||||||
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
|
||||||
</humanTask>
|
</humanTask>
|
||||||
|
|
||||||
<timerEventListener id="TL_half" name="时限过半">
|
<!--
|
||||||
<timerExpression>PT12H</timerExpression>
|
② 归一客户 RFQ 输入 —— 解析邮件正文,逐条落成受理记录。
|
||||||
</timerEventListener>
|
PN / 年用量**必填**,其余**选填**;缺哪几格照实记进两个缺口数。
|
||||||
<userEventListener id="UL_amend" name="客户改需求"/>
|
**这一步不叫人**:拆料是 AI 的活,拆完交给下一步给人看。
|
||||||
|
-->
|
||||||
|
<humanTask id="S2_normalize" name="归一客户RFQ输入:解析正文逐条落表">
|
||||||
|
<entryCriterion id="ec_s2" sentryRef="s_customer"/>
|
||||||
|
<itemControl><repetitionRule id="r_s2"/></itemControl>
|
||||||
|
</humanTask>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
③ 受理需求交人审定 —— **阶段2 那张执行页,一单必经一次**。
|
||||||
|
目标件 「A1-询价受理需求.html」 的落款写着「AI 拟稿,你审定;可就地修改后再确认」,
|
||||||
|
页脚是【修改】与【确认受理记录】—— 它从来就是一张**人签的页**,不是缺东西时才弹的追问。
|
||||||
|
|
||||||
|
**manualActivation + 不给 doneWhen**:两样缺一不可。
|
||||||
|
触达世界的项一律不给 doneWhen —— 给了就等于给引擎一把替人签字的钥匙;
|
||||||
|
而这一步的成果(人看过、改过、确认了)按定义不可能由引擎自己判出来。
|
||||||
|
-->
|
||||||
|
<humanTask id="S3_confirm" name="受理需求交人审定:整份清单可就地改,确认后才往下走">
|
||||||
|
<entryCriterion id="ec_s3" sentryRef="s_parsed"/>
|
||||||
|
<itemControl><manualActivationRule id="m_s3"/></itemControl>
|
||||||
|
</humanTask>
|
||||||
|
|
||||||
|
<!-- S4 查历史价:取齐 P-03 的三项判据 -->
|
||||||
|
<humanTask id="S4_history" name="查历史价:取齐距上次报价天数/行情/生命周期">
|
||||||
|
<entryCriterion id="ec_s4" sentryRef="s_complete"/>
|
||||||
|
</humanTask>
|
||||||
|
|
||||||
|
<!-- S5 逐条定状况标:决策表求值,不是临场判断 -->
|
||||||
|
<decisionTask id="S5_classify" name="逐条定状况标:按三分类规则P-03打标" decisionRef="p03-type">
|
||||||
|
<entryCriterion id="ec_s5" sentryRef="s_basis"/>
|
||||||
|
</decisionTask>
|
||||||
|
|
||||||
|
<!-- S6 定去向与派单:两枚去向旗 + 开替代评估请求/询价请求 -->
|
||||||
|
<humanTask id="S6_route" name="定去向与派单:立两枚旗,开替代评估请求/询价请求">
|
||||||
|
<entryCriterion id="ec_s6" sentryRef="s_typed"/>
|
||||||
|
</humanTask>
|
||||||
|
|
||||||
|
<!-- 受理放行:终局唯放行 —— 触达世界,必须人工激活 -->
|
||||||
|
<humanTask id="T_release" name="受理结论验收放行">
|
||||||
|
<entryCriterion id="ec_rel" sentryRef="s_routed"/>
|
||||||
|
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||||
|
</humanTask>
|
||||||
</casePlanModel>
|
</casePlanModel>
|
||||||
</case>
|
</case>
|
||||||
</definitions>`;
|
</definitions>`;
|
||||||
|
|
||||||
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 定性分级:先查历史价(同客户同料近一年有没有报过、隔了多久、行情稳不稳),
|
**这一单要经过人两次,不多不少**:S3 人审定受理需求(阶段2 那张执行页,
|
||||||
再按三分类规则 P-03 逐条打标 —— A 类历史价直用、B 类需重新核价、C 类需选型或寻源。
|
字段齐不齐都要发)、放行时人签字。**别把 S3 当成缺东西时的追问** ——
|
||||||
拿不准的一律降 B,不许臆断 A:错标 A 会让一条本该重新核价的料带着旧价一路走到报价单上。
|
不缺也发:拆错一格只有人看得出来,而那些错往后每一步都跟着跑。
|
||||||
生命周期核查是裁量项 —— 遇到疑似 EOL 的料随手拉一次。
|
|
||||||
|
|
||||||
§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'] },
|
||||||
|
];
|
||||||
|
|||||||
@@ -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 { defineActivity } from '@sdk';
|
||||||
import type { ActivityBase, ActivityOutcome } from '@sdk';
|
|
||||||
|
|
||||||
const INTAKE = 'quote_intake.record';
|
export default defineActivity({ name: 'quote-intake' });
|
||||||
const TYPE_TABLE = 'quote_intake.type_table';
|
|
||||||
|
|
||||||
export default defineActivity({
|
|
||||||
name: 'quote-intake',
|
|
||||||
paradigm: 'flow',
|
|
||||||
|
|
||||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
|
||||||
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<Record<string, unknown>>, tag: string): number {
|
|
||||||
return rows.filter((r) => r['type_tag'] === tag).length;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,182 +1,730 @@
|
|||||||
/**
|
/**
|
||||||
* 活动包清单 —— 统一报价 · 客户RFQ受理(受理归一)。
|
* 活动包清单 —— 统一报价 A1 · 询价受理。
|
||||||
*
|
*
|
||||||
* 业务素材出处:docs/MVP示例工作流.html §1.1;按 README《归一化对照表》映射为:
|
* 业务素材出处:`docs/MVP示例工作流.html` §1.1。归一化映射:
|
||||||
* SOP 节点 → **标准活动(flow)**;作业指导书 S1…S6 → **步序声明**(四种步型);
|
* SOP 节点 → **案例活动**(CMMN 子集 + 说明书 + DMN);
|
||||||
* 步内判断 D1 完整度 → **条件谓词**;三分类规则 P-03 → **DMN 决策表**;
|
* 作业指导书 S1…S6 → 案例说明书的六段(见 `case-model.ts` 的建模口径注);
|
||||||
* SLA(受理<15min、打标<45min)→ Harness 时限与预算。
|
* D1 判完整度 → **DMN 判定表 F-01**(《询价受理规范》);
|
||||||
|
* 三分类规则 P-03 → **DMN 判定表**;
|
||||||
|
* SLA(受理<15min、判定<45min)→ Harness 时限与预算。
|
||||||
|
*
|
||||||
|
* 「清单机器可读,系统不执行代码即知包为何物」—— 这份清单就是这个活动的全部。
|
||||||
*/
|
*/
|
||||||
import type { ActivityManifest, DmnModel } from '@contracts';
|
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 { 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';
|
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');
|
const ACTIVITY_ID = activityIdSchema.parse('quote-intake');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 三分类规则 P-03 —— 把「装得下的判断」从 LLM 手里拿走。
|
* 两张判定表 —— **能算的不问 LLM**:同输入必同结论、零 token、命中行留痕。
|
||||||
* 由纯工具步 `quote-intake.classify` 逐条求值,命中规则行留痕。
|
*
|
||||||
|
* F-01 受理字段完整度(《询价受理规范》):三要素齐才放行,缺关键字段退回补全。
|
||||||
|
* P-03 三分类:A 历史价≤90天且行情稳 / B 无历史·过期·波动 / C EOL·缺货·要替代。
|
||||||
|
*
|
||||||
|
* 两张都是 `FIRST`:规则从上往下,第一条命中即出结论 —— 顺序本身就是业务优先级,
|
||||||
|
* 所以 C 的那一行必须在最前面(EOL 的料哪怕历史价很新,也不该判成 A 直接快答)。
|
||||||
*/
|
*/
|
||||||
export const P03_DMN: DmnModel = {
|
export const F01_DMN: DmnModel['tables'][number] = {
|
||||||
drd: [{ decision: 'p03-type', requires: [], inputs: ['historyDays', 'marketStable', 'lifecycle'] }],
|
id: 'f01-completeness',
|
||||||
tables: [{
|
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',
|
id: 'p03-type',
|
||||||
label: '三分类规则 P-03',
|
label: '三分类规则 P-03',
|
||||||
hitPolicy: 'FIRST' as const,
|
hitPolicy: 'FIRST',
|
||||||
inputs: [
|
inputs: [
|
||||||
{ name: 'historyDays', expression: 'historyDays' },
|
{ name: 'history_days', expression: 'history_days' },
|
||||||
{ name: 'marketStable', expression: 'marketStable' },
|
{ name: 'market_stable', expression: 'market_stable' },
|
||||||
{ name: 'lifecycle', expression: 'lifecycle' },
|
{ name: 'lifecycle', expression: 'lifecycle' },
|
||||||
],
|
],
|
||||||
outputs: [{ name: 'type' }, { name: 'play' }],
|
outputs: [{ name: 'type_tag' }, { name: 'play' }],
|
||||||
rules: [
|
rules: [
|
||||||
{ id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: ['C', '转案处理'], annotation: 'EOL/缺货/要替代 → C' },
|
{ id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: ['C', '转案处理'], annotation: 'EOL/缺货/要替代 → C' },
|
||||||
{ id: 'p03-a', when: ['<= 90', 'true', '-'], then: ['A', '直接快答'], annotation: '历史价≤90天且行情稳 → A' },
|
{ id: 'p03-a', when: ['<= 90', 'true', '-'], then: ['A', '直接快答'], annotation: '历史价≤90天且行情稳 → A' },
|
||||||
{ id: 'p03-b', when: ['-', '-', '-'], then: ['B', '重估后报'], annotation: '无历史/过期/波动 → B' },
|
{ 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 = {
|
export const manifest: ActivityManifest = {
|
||||||
identity: {
|
identity: {
|
||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
name: '客户RFQ受理',
|
name: 'A1 · 询价受理',
|
||||||
packageVersion: '0.1.9',
|
packageVersion: '40.0.0',
|
||||||
category: '统一报价',
|
category: '统一报价',
|
||||||
sdkVersion: '0.1.0-m5',
|
sdkVersion: '0.1.0-m5',
|
||||||
checksum: 'dev-checksum-quote-intake',
|
checksum: 'dev-checksum-quote-intake',
|
||||||
},
|
},
|
||||||
|
|
||||||
/* 契约七件 */
|
/* ── 契约七件 ────────────────────────────────────────────────── */
|
||||||
contract: {
|
contract: {
|
||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
version: '0.1.0',
|
version: '1.0.0',
|
||||||
input: {
|
input: {
|
||||||
|
/*
|
||||||
|
* 入参就是**那封信** —— 客户邮箱 / 主题 / 邮件内容 / 邮件附件,前三样必填。
|
||||||
|
*
|
||||||
|
* 从前这里收的是「客户编码 + 询价单号」,那是**受理之后**才有的东西:
|
||||||
|
* 一封真的询价来函不会带着我们内部的客户码,单号也常常压根没有。
|
||||||
|
* 把它们摆成必填的后果实测过:流程包起单时通道抽不出这两个键,基座当场发补全卡,
|
||||||
|
* 整单 0 拍停在那儿等人 —— 而信本身是完整的。认客户、认单号是 S1 的活,不是门口的闸。
|
||||||
|
*
|
||||||
|
* 起单仍是宽进:门口不拒缺项(缺 required 由基座发补全卡,不是活动的事)。
|
||||||
|
*/
|
||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['customerCode', 'rfqRef'],
|
required: ['customerEmail', 'subject', 'body'],
|
||||||
properties: {
|
properties: {
|
||||||
customerCode: { type: 'string' },
|
customerEmail: { type: 'string', title: '客户邮箱' },
|
||||||
rfqRef: { type: 'string' },
|
subject: { type: 'string', title: '主题' },
|
||||||
subject: { type: 'string' },
|
/** 询价原文 + BOM 转述 —— 归一这一步拆解的对象 */
|
||||||
/** 询价原文 —— 归一步拆解的对象 */
|
body: { type: 'string', title: '邮件内容' },
|
||||||
body: { type: 'string' },
|
/*
|
||||||
|
* **真文件**(`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,
|
messageAllowed: true,
|
||||||
messageHint: '客户口头急单等特殊性写这里',
|
messageHint: '客户口头急单、老客户先受理后补单等特殊性写这里',
|
||||||
|
/*
|
||||||
|
* **这一单是被一封信叫起来的** —— 声明入参里哪几个字段合起来是那封来件,
|
||||||
|
* 基座在起单那一刻就把它归进任务的沟通记录(工作台那一屏),原件与附件一并存下。
|
||||||
|
*
|
||||||
|
* 为什么是声明而不是跑起来再认一遍:「入参的 subject 就是来件主题」是写清单时
|
||||||
|
* 就知道的事实,没有临场判断的成分 —— 交给模型每单认一遍,是拿一整拍的 token 换一个常量。
|
||||||
|
* 而且人一点开这单,沟通记录里就已经躺着那封信,不必等模型跑几拍。
|
||||||
|
*/
|
||||||
|
inboundComm: {
|
||||||
|
channel: 'mail',
|
||||||
|
partyField: 'customerEmail',
|
||||||
|
subjectField: 'subject',
|
||||||
|
bodyField: 'body',
|
||||||
|
attachmentsField: 'attachments',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
/*
|
||||||
|
* 出口载荷**从落地事实推导**(§7.5):标量字段必须有台账列承载。
|
||||||
|
* 两枚去向旗就是下游两道网关唯一读的东西 —— 没有 `need_alternative` /
|
||||||
|
* `need_sourcing` 这两列,整条流出了受理口就没路可走(踩过:G1 全流死路)。
|
||||||
|
*/
|
||||||
mandatory: [
|
mandatory: [
|
||||||
{
|
{
|
||||||
name: 'intake',
|
name: 'intake',
|
||||||
payload: {
|
payload: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['records'],
|
required: ['records', 'needAlternative', 'needSourcing'],
|
||||||
properties: { records: { type: 'array' } },
|
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: [
|
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: [
|
acceptanceCriteria: [
|
||||||
{ key: 'fields-complete', label: '八字段齐(PN/EAU/交期为必要三要素)', level: 'L1', predicate: 'missingKeyFields = 0' },
|
/* ① 定位客户 —— 认得出老客户或人建了新客户,这一单才落得到某个客户名下 */
|
||||||
{ key: 'typed', label: '每条都已打标 A/B/C', level: 'L2', predicate: 'untyped = 0' },
|
{ key: 'customer-known', label: '客户已确认:发件邮箱认出为老客户,或已为新客户建好档', level: 'L1', predicate: 'customer_total > 0' },
|
||||||
{ key: 'downgrade-safe', label: '吃不准降 B,不许臆断 A', level: 'L3' },
|
/*
|
||||||
{ key: 'owner-release', label: 'BD 放行', level: 'L5' },
|
* 条目数**与真实行数对得上**。判据不读活动自报的那一个数就完了 ——
|
||||||
|
* `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: [
|
prohibitions: [
|
||||||
|
{ key: 'no-guess-field', statement: '原文没写的字段不得凭常识补,缺就记缺' },
|
||||||
{ key: 'no-guess-a', statement: '吃不准的一律降 B,不得臆断为 A' },
|
{ key: 'no-guess-a', statement: '吃不准的一律降 B,不得臆断为 A' },
|
||||||
|
{ key: 'no-unit-convert', statement: '不得自作主张换算用量口径(月用量×12 是业务判断,须问客户)' },
|
||||||
{ key: 'no-external-write', statement: '不得在放行前写任何外部系统' },
|
{ key: 'no-external-write', statement: '不得在放行前写任何外部系统' },
|
||||||
],
|
],
|
||||||
authorization: {
|
authorization: {
|
||||||
discretion: [{ key: 'ask-back', statement: '缺次要字段可受理并追问,缺 PN/数量/交期必须退回补全' }],
|
discretion: [
|
||||||
|
{ key: 'ask-back', statement: '缺次要字段可受理并追问;缺 PN/EAU/交期必须退回补全' },
|
||||||
|
{ key: 'lifecycle-check', statement: '疑似 EOL 的料可随手拉一次生命周期核查' },
|
||||||
|
],
|
||||||
|
/* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨),写了也不会被执行 */
|
||||||
preauthorizedActions: [],
|
preauthorizedActions: [],
|
||||||
humanCallBudget: 4,
|
humanCallBudget: 4,
|
||||||
},
|
},
|
||||||
resources: {
|
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),
|
skills: INTAKE_SKILLS.map((s) => s.id),
|
||||||
models: ['deepseek-v4-flash'],
|
models: ['deepseek-v4-flash'],
|
||||||
roles: ['BD'],
|
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: {
|
declarations: {
|
||||||
models: ['deepseek-v4-flash'],
|
models: ['deepseek-v4-flash'],
|
||||||
tools: [{ name: 'crm.history-price-read' }],
|
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-intake.classify' }, { name: 'mail.send' }],
|
||||||
ledger: [
|
ledger: [
|
||||||
|
/*
|
||||||
|
* 客户主数据 —— **A1 只声明它要用的那几列**,全量结构由平台播种
|
||||||
|
* (`seed-mvp-data.ts` 的四域)。声明合并是「最大化合并、只增不破坏」,
|
||||||
|
* 两边并存不冲突;`kind: 'master'` 必须与平台一致,否则治理台会把它归错类。
|
||||||
|
*
|
||||||
|
* 为什么受理活动要写主数据:第一件事就是认人 —— 认得出取它的编码,
|
||||||
|
* 认不出请人建档。建出来的那一行是**给下一单用的**:下次同一个邮箱来信,
|
||||||
|
* 第一步就认得出,不必再问一遍。
|
||||||
|
*/
|
||||||
{
|
{
|
||||||
domain: INTAKE_DOMAIN,
|
domain: 'customer',
|
||||||
object: 'record',
|
object: 'profile',
|
||||||
label: '统一受理记录',
|
kind: 'master',
|
||||||
|
label: '客户档案',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' },
|
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
|
||||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
{ name: 'name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
|
||||||
{ 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: '缺关键字段数' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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',
|
object: 'type_table',
|
||||||
|
kind: 'record',
|
||||||
|
caseKey: 'rfq_ref',
|
||||||
label: '料号分类表',
|
label: '料号分类表',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||||
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false },
|
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
||||||
{ name: 'basis', type: 'text', primaryKey: false, masked: false, label: '分级依据' },
|
// 打标的三项判据必须落表:P-03 从这里读,不落就无米下锅
|
||||||
{ name: 'history_hit', type: 'boolean', primaryKey: false, masked: false, label: '历史价命中' },
|
|
||||||
// 打标的三个判据必须落表:S5 的 DMN 从这里读,不落就无米下锅
|
|
||||||
{ name: 'history_days', type: 'integer', primaryKey: false, masked: false, label: '距上次报价天数' },
|
{ name: 'history_days', type: 'integer', primaryKey: false, masked: false, label: '距上次报价天数' },
|
||||||
{ name: 'market_stable', type: 'boolean', 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: '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: [
|
listeners: [
|
||||||
{
|
{
|
||||||
|
/** 客户改需求:整单要回炉 —— 静态监听,时限必配(悬空监听安装时拒绝) */
|
||||||
origin: 'static',
|
origin: 'static',
|
||||||
eventType: 'human',
|
eventType: 'data',
|
||||||
keyFilter: { kind: 'elicitation' },
|
keyFilter: { kind: 'rfq-amend' },
|
||||||
semantic: 'complete-step',
|
/*
|
||||||
// 时限必配 —— 悬空监听安装时拒绝
|
* 语义只有两种:`observe`(记一笔观察,交 Agent 裁量)与 `complete-step`(驱动步完成)。
|
||||||
|
* 客户改需求要的是「整单回炉」,那是 Agent 看到这条观察后自己决定重入哪一段 ——
|
||||||
|
* 监听没有「重入」这一档,也不该有:重入是业务判断,不是事件语义。
|
||||||
|
*/
|
||||||
|
semantic: 'observe',
|
||||||
deadline: 'P1D',
|
deadline: 'P1D',
|
||||||
escalation: ['nudge', 'escalate'],
|
escalation: ['nudge', 'escalate'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
harness: {
|
harness: {
|
||||||
memoryWindow: 20,
|
memoryWindow: 20,
|
||||||
// SLA:受理<15min、打标<45min —— 停滞检测按此设
|
// SLA:判定<45min —— 停滞检测按此设;受理<15min 那一档做催办
|
||||||
stallAfter: 'PT45M',
|
stallAfter: 'PT45M',
|
||||||
nudge: [{ after: 'PT15M', action: 'nudge' }],
|
nudge: [{ after: 'PT15M', action: 'nudge' }],
|
||||||
retry: { times: 2, backoff: 'PT30S' },
|
retry: { times: 2, backoff: 'PT30S' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
/* 资产初始版:flow = 步序声明 */
|
/* ── 资产初始版 ──────────────────────────────────────────────── */
|
||||||
assets: {
|
assets: {
|
||||||
/* 资产初始版:案例模型(2026-07-29 起活动只有这一种业务模型) */
|
|
||||||
businessModel: {
|
businessModel: {
|
||||||
cmmnXml: CMMN_XML,
|
cmmnXml: CMMN_XML,
|
||||||
narrative: NARRATIVE,
|
narrative: NARRATIVE,
|
||||||
dmn: P03_DMN,
|
dmn: INTAKE_DMN,
|
||||||
|
/* 步骤剧本 —— 引擎按它组织每一拍的上下文,并自己收掉成果已在的步骤 */
|
||||||
|
steps: INTAKE_STEPS,
|
||||||
|
/* 阶段 —— 只给人看的进度刻度:三段激活态 + 放行收口 = 卡上的 x/4 */
|
||||||
|
phases: INTAKE_PHASES,
|
||||||
},
|
},
|
||||||
skills: INTAKE_SKILLS,
|
skills: INTAKE_SKILLS,
|
||||||
// 三张模版对应三种局面(事态全景 / 进展 / 成果预览),由步序声明按位置推送
|
// 四张模版对应四个局面;在办与归档同一张(§9.3 第 2 条)
|
||||||
viewTemplates: [
|
viewTemplates: [
|
||||||
{ name: 'intake-overview', html: INTAKE_OVERVIEW },
|
{ name: 'intake-overview', html: INTAKE_OVERVIEW },
|
||||||
{ name: 'intake-classified', html: INTAKE_CLASSIFIED },
|
{ name: 'intake-classified', html: INTAKE_CLASSIFIED },
|
||||||
@@ -186,16 +734,111 @@ export const manifest: ActivityManifest = {
|
|||||||
distillHooks: [
|
distillHooks: [
|
||||||
{
|
{
|
||||||
on: 'terminal',
|
on: 'terminal',
|
||||||
prompt: '归纳本单受理归一的经验:字段缺口形态、打标依据、被驳回的理由。',
|
prompt: '归纳本单受理归一的经验:字段缺口形态、打标依据、去向判断被驳回的理由。',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
/* 样例集 —— 首装审以静态审 + 样例集替代回放审 */
|
/* ── 样例集 —— 首装审以静态审 + 样例集替代回放审 ───────────────── */
|
||||||
|
/*
|
||||||
|
* 样例就是**真客户会发的那种信**(2026-07-31 用户定)。
|
||||||
|
*
|
||||||
|
* 从前两个样例都写着「我司客户编码 C001」—— 那是我们内部的编码,
|
||||||
|
* 客户凭什么知道?真实来函里人只会说**自己是哪个公司的谁**。
|
||||||
|
* 拿带编码的样例去试,等于把「定位客户」这一步的难处提前替它做掉了:
|
||||||
|
* 认不出客户、要请人建档的那条路根本走不到,而那恰恰是这一状态的主路。
|
||||||
|
*/
|
||||||
samples: [
|
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'] },
|
expect: { ports: ['intake'] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
* + 结晶片段脚本(可选:反复出现的确定性子序列)。
|
* + 结晶片段脚本(可选:反复出现的确定性子序列)。
|
||||||
*
|
*
|
||||||
* **载方法,不载权力**:这里提到的工具都在本活动白名单内;技能不扩权、不改约。
|
* **载方法,不载权力**:这里提到的工具都在本活动白名单内;技能不扩权、不改约。
|
||||||
|
* 渐进披露:常驻上下文只有 `id label:applicability`,正文 Agent 说要才加载 ——
|
||||||
|
* 一次把所有正文塞进去,上下文会被方法论淹掉,真正的局面反而看不见。
|
||||||
*/
|
*/
|
||||||
import { skillIdSchema } from '@contracts';
|
import { skillIdSchema } from '@contracts';
|
||||||
import type { Skill } from '@contracts';
|
import type { Skill } from '@contracts';
|
||||||
@@ -20,17 +22,21 @@ export const INTAKE_SKILLS: Skill[] = [
|
|||||||
'目标价常带货币符号或「目标价/target」,交期常带「周/weeks/wks/交期/lead」。',
|
'目标价常带货币符号或「目标价/target」,交期常带「周/weeks/wks/交期/lead」。',
|
||||||
'',
|
'',
|
||||||
'三条纪律:',
|
'三条纪律:',
|
||||||
'① **原文没写的不要补**。宁可标 missingKeyFields,也不要按常识填一个「合理值」——',
|
'① **原文没写的不要补**。宁可把 missing_key_fields 记上,也不要按常识填一个「合理值」——',
|
||||||
' 受理阶段编出来的数,会一路流到定价和报价单上,越往后越难发现。',
|
' 受理阶段编出来的数,会一路流到定价和报价单上,越往后越难发现。',
|
||||||
'② **单位归一但不换算口径**:12K → 12000 可以;「月用量 1000」不要自作主张乘 12,',
|
'② **单位归一但不换算口径**:12K → 12000 可以;「月用量 1000」不要自作主张乘 12,',
|
||||||
' 那是业务判断,该走补全卡问客户。',
|
' 那是业务判断,该走补全卡问客户。',
|
||||||
'③ **一条需求一行**。同一 PN 不同数量档是两行,不是一行的两个值。',
|
'③ **一条需求一行**。同一 PN 不同数量档是两行,不是一行的两个值。',
|
||||||
'',
|
'',
|
||||||
'拆不动就交人:缺 PN / 数量 / 交期任一,把 missingKeyFields 加一,让 D1 判断把补全卡发出去。',
|
'写台账时一拍写完:`ledger.write` 的 rows 收数组,一份 BOM 几十条也是一次写完 ——',
|
||||||
|
'一行一拍等于把同一件事拆成几十次模型调用,每次都要重读一遍上下文。',
|
||||||
|
'',
|
||||||
|
'写完顺手把 `quote_intake.verdict` 的 line_total 与 missing_key_total 填上:',
|
||||||
|
'那两个数是完整度判定表 F-01 的入参,也是验收的机检判据 —— 不写,下一步无从判起。',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
crystallized: [],
|
crystallized: [],
|
||||||
knowledgeRefs: ['《询价受理规范》'],
|
knowledgeRefs: ['《询价受理规范》F-01'],
|
||||||
version: '1.0.0',
|
version: '2.0.0',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: skillIdSchema.parse('intake.classify-basis'),
|
id: skillIdSchema.parse('intake.classify-basis'),
|
||||||
@@ -39,15 +45,48 @@ export const INTAKE_SKILLS: Skill[] = [
|
|||||||
method: [
|
method: [
|
||||||
'P-03 只认三个入参:距上次报价天数、行情是否稳定、生命周期状态。三者齐了,结论是**算出来**的,不是判出来的。',
|
'P-03 只认三个入参:距上次报价天数、行情是否稳定、生命周期状态。三者齐了,结论是**算出来**的,不是判出来的。',
|
||||||
'',
|
'',
|
||||||
'所以打标前唯一该做的事是**把判据取齐**:调历史价情报工具,拿回这三项。',
|
'所以打标前唯一该做的事是**把判据取齐**:调 `crm.history-price-read`,',
|
||||||
'取不到(新料号无历史)不是错误——工具会回「无历史」,那本身就是判据:',
|
'**参数 pns 收数组** —— 这一单所有 PN 一次传进去。一条一条调,每调一次就多烧一拍模型,',
|
||||||
'距上次报价记为极大值 → 命中 B 类「重估后报」,这是对的,新料号本就该重估。',
|
'而拿回来的东西一模一样。',
|
||||||
'',
|
'',
|
||||||
'不要绕过决策表自己判。规则表是可回放、可追责的;临场判断不是。',
|
'取不到不是错误:新料号无历史,工具回 historyDays=9999、hit=false。',
|
||||||
|
'**那本身就是判据** —— 距上次报价极大 → 命中 B 类「重估后报」,这是对的:新料号本就该重估。',
|
||||||
|
'',
|
||||||
|
'判据逐条写进 `quote_intake.type_table`(history_days / market_stable / lifecycle),',
|
||||||
|
'再调 `quote-intake.classify` 一次把这几行传进去拿回 A/B/C。',
|
||||||
|
'**不要绕过决策表自己判**:规则表可回放、可追责;临场判断不是。',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
// 结晶片段:反复出现的确定性子序列 —— 取历史价这一步每单都一样
|
// 结晶片段:取历史价这一步每单都一样 —— 确定性子序列,不必每次让模型重想
|
||||||
crystallized: [{ tool: 'crm.history-price-read', input: { pn: 'pn' } }],
|
crystallized: [{ tool: 'crm.history-price-read', input: { pns: 'pns' } }],
|
||||||
knowledgeRefs: ['三分类规则 P-03'],
|
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',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
/**
|
||||||
|
* A1 两份执行页的样式表 —— **从 PRD 目标件原样搬过来**,不是我照着复述的。
|
||||||
|
*
|
||||||
|
* 出处:`~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` 的整段 `<style>`,
|
||||||
|
* 外加 `A1-询价受理记录.html` 里归档件独有的那几件(分类分布条 / 类徽 / 判定表 /
|
||||||
|
* 结论横幅 / 落地动作 / 完成标)。**一个数值都没改**。
|
||||||
|
*
|
||||||
|
* 为什么是搬而不是抄:前三轮我用自己那套外壳「照着做」,块搬齐了、版面是两套 ——
|
||||||
|
* 章节头成了彩色方块编号、多出摘要与元信息带、字号用 clamp。人一眼就看出不是那份件。
|
||||||
|
* 目标件是**像素级的答案**,搬过来才不会在复述里走样。
|
||||||
|
*
|
||||||
|
* 选择器保留 `.proto-root .app-shell` 前缀,所以模版正文也要照原型那样包两层壳 ——
|
||||||
|
* 少一层壳,整张样式表一条都不生效(踩过)。
|
||||||
|
*/
|
||||||
|
export const PRD_CSS = `
|
||||||
|
/* ============================================================================
|
||||||
|
JCAI 员工面工作台 · A1 受理归一的过程成果「询价受理需求」——报告本体
|
||||||
|
立场:**AI 已从 RFQ 邮件抽取归一**,人在这里审核并就地修订。
|
||||||
|
两态:读态(AI 稿 + 红/灰占位提示缺项) ⇄ 编辑态(全字段可改,保存 / 取消)
|
||||||
|
核心是「有效 PN 清单」;清单之上四件受理要素:客户公司 / 联系人 / 客户属性 / 有效 PN。
|
||||||
|
|
||||||
|
令牌与既有组件取自 workbench/src/app/(proto)/app.css,数值未改。
|
||||||
|
⚠️ 修正一处原型 bug:app.css:2062-2064 的 '.jc-rp-sign span'(权重 0,1,1)
|
||||||
|
盖过了 '.jc-rp-ava'(0,1,0),把头像的 display:grid 覆盖成 block、字色改灰,
|
||||||
|
导致落款头像里的字掉到圆外。本文件把该规则收窄为 '.jc-rp-sign > div > span'。
|
||||||
|
新增件:四要素卡 .jc-four/.jc-fcard · 占位胶囊 .jc-ph · 分段控件 .jc-seg ·
|
||||||
|
来源条 .jc-src · 录入表 .jc-tbl-in
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body { background: #e6eaf1; -webkit-font-smoothing: antialiased; }
|
||||||
|
|
||||||
|
.proto-root .app-shell {
|
||||||
|
--brand: #3370ff; --brand-deep: #245bdb;
|
||||||
|
--brand-soft: #e1eaff; --brand-wash: #f0f4ff;
|
||||||
|
--ai-grad: linear-gradient(135deg, #3370ff, #7f3bf5);
|
||||||
|
--ink: #1f2329; --ink-2: #41464e; --ink-3: #646a73; --ink-4: #8f959e;
|
||||||
|
--line: #dee0e3; --line-soft: #e8eaed; --soft: #f8f9fb;
|
||||||
|
--jcai-bg: #e6eaf1; --cardline: #e1e6ec; --card-shadow: 0 1px 2px rgba(31, 35, 41, .05);
|
||||||
|
--ok: #278717; --ok-soft: #e8f8e4;
|
||||||
|
--warn: #de7802; --warn-accent: #ff8800; --warn-soft: #fff4e5;
|
||||||
|
--danger: #d83931; --danger-soft: #feeae9;
|
||||||
|
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||||
|
--shadow-brand: 0 6px 16px rgba(51, 112, 255, .22);
|
||||||
|
--t-fast: .16s cubic-bezier(.3, 0, .2, 1);
|
||||||
|
--t-med: .26s cubic-bezier(.3, 0, .2, 1);
|
||||||
|
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: var(--jcai-bg); min-height: 100vh; color: var(--ink);
|
||||||
|
}
|
||||||
|
.proto-root :where(button, input, select, a, [tabindex]):focus-visible {
|
||||||
|
outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 8px;
|
||||||
|
}
|
||||||
|
.proto-root ::selection { background: var(--brand-soft); }
|
||||||
|
|
||||||
|
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes jcBlink { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
|
||||||
|
@keyframes jcStamp { from { opacity: 0; transform: rotate(-6deg) scale(1.7); } to { opacity: .9; transform: rotate(-6deg) scale(1); } }
|
||||||
|
|
||||||
|
/* 放开宽度:1160px 是「9 列全展开、一个字都不裁」的最小容器宽度(见下方列 min-width 之和) */
|
||||||
|
.jc-stage { max-width: 1160px; margin: 0 auto; padding: 22px 16px 52px; }
|
||||||
|
.proto-root .jc-mono { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }
|
||||||
|
.proto-root .jc-pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand); display: inline-block; animation: jcBlink 1.1s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* ── 报告外壳 ───────────────────────────────────────────────────────── */
|
||||||
|
.proto-root .jc-report { background: #fff; border: 1px solid var(--cardline); border-radius: 14px; box-shadow: var(--card-shadow); overflow: hidden; animation: rise var(--t-med) both; }
|
||||||
|
.proto-root .jc-rp-head { position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 12px; padding: 16px 20px 13px; border-bottom: 2px solid var(--ink); }
|
||||||
|
/* 题头下缘粗细双线 = 正式件版式 */
|
||||||
|
.proto-root .jc-rp-head::after { content: ""; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: var(--line); }
|
||||||
|
/* 题头右上的品牌光晕:与工作台标题区同一签名件 */
|
||||||
|
.proto-root .jc-rp-head::before { content: ""; position: absolute; right: -70px; top: -96px; width: 240px; height: 240px; border-radius: 999px; background: radial-gradient(circle, rgba(51,112,255,.10), transparent 70%); pointer-events: none; }
|
||||||
|
.proto-root .jc-rp-head > * { position: relative; z-index: 1; }
|
||||||
|
.proto-root .jc-rp-badge { flex: none; width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand); }
|
||||||
|
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
|
||||||
|
.proto-root .jc-rp-tit em { display: block; font-style: normal; font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); font-family: var(--mono); }
|
||||||
|
.proto-root .jc-rp-tit h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; color: var(--ink); letter-spacing: -.01em; line-height: 1.3; }
|
||||||
|
.proto-root .jc-rp-wait { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-top: 4px; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 4px 12px; color: var(--brand-deep); background: var(--brand-wash); border: 1px solid var(--brand-soft); }
|
||||||
|
.proto-root .jc-rp-wait.warn { color: var(--warn); background: var(--warn-soft); border-color: #ffd9a8; }
|
||||||
|
.proto-root .jc-rp-wait.edit { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||||
|
.proto-root .jc-rp-stamp { flex: none; margin-top: 2px; transform: rotate(-6deg); border: 2px solid currentColor; border-radius: 9px; padding: 4px 8px 3px 12px; font-size: 13px; font-weight: 900; letter-spacing: .28em; color: var(--ok); opacity: .9; animation: jcStamp .32s cubic-bezier(.2, 1.4, .4, 1) both; }
|
||||||
|
.proto-root .jc-rp-bd { padding: 18px 20px 12px; }
|
||||||
|
.proto-root .jc-rp-part { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 9px; margin: 20px 0 11px; }
|
||||||
|
.proto-root .jc-rp-part:first-child { margin-top: 0; }
|
||||||
|
.proto-root .jc-rp-part > b { flex: none; font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-3); }
|
||||||
|
.proto-root .jc-rp-part > .ln { flex: 1; height: 1px; background: var(--line-soft); }
|
||||||
|
.proto-root .jc-rp-part > em { flex: none; font-style: normal; font-size: 10px; font-weight: 700; color: var(--ink-4); font-family: var(--mono); background: var(--soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; }
|
||||||
|
.proto-root .jc-rp-part > em.warn { color: var(--warn); background: var(--warn-soft); }
|
||||||
|
|
||||||
|
/* 落款 —— ⚠️ 选择器收窄到 > div > *,别再命中头像那个 span */
|
||||||
|
.proto-root .jc-rp-foot { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 13px 20px 15px; border-top: 2px solid var(--line); background: #fbfcfd; }
|
||||||
|
.proto-root .jc-rp-sign { display: flex; align-items: center; gap: 10px; flex: 1 1 200px; min-width: 0; }
|
||||||
|
.proto-root .jc-rp-ava { flex: 0 0 auto; width: 30px; height: 30px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: var(--brand); }
|
||||||
|
.proto-root .jc-rp-sign > div { min-width: 0; }
|
||||||
|
.proto-root .jc-rp-sign > div > b { display: block; font-size: 12px; font-weight: 700; color: var(--ink-2); }
|
||||||
|
.proto-root .jc-rp-sign > div > span { display: block; margin-top: 1px; font-size: 10.5px; color: var(--ink-4); }
|
||||||
|
.proto-root .jc-rp-acts { display: flex; align-items: center; gap: 8px; margin-left: auto; flex: none; }
|
||||||
|
.proto-root .jc-hintline { font-size: 11px; font-weight: 700; color: var(--warn); line-height: 1.5; }
|
||||||
|
|
||||||
|
.proto-root .jc-btn { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 8px 14px; font-size: 12px; font-weight: 600; font-family: inherit; background: var(--brand); color: #fff; cursor: pointer; transition: all var(--t-fast); }
|
||||||
|
.proto-root .jc-btn:hover:not(:disabled) { background: var(--brand-deep); }
|
||||||
|
.proto-root .jc-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.proto-root .jc-btn-ghost { background: #fff; color: var(--ink-2); border: 1px solid var(--line); }
|
||||||
|
.proto-root .jc-btn-ghost:hover:not(:disabled) { background: var(--brand-wash); color: var(--brand); border-color: var(--brand-soft); }
|
||||||
|
.proto-root .jc-btn-primary { background: var(--ai-grad); }
|
||||||
|
.proto-root .jc-btn-primary:hover:not(:disabled) { filter: brightness(1.07); box-shadow: var(--shadow-brand); transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* ── 来源信件:客户 RFQ 邮件原文(复用原型 jc-letter 件,青色 = 收件) ── */
|
||||||
|
.proto-root .jc-letter { position: relative; border: 1px solid var(--cardline); border-left: 3px solid #0aa294; border-radius: 12px; padding: 14px 16px 15px; background: #fff; box-shadow: var(--card-shadow); }
|
||||||
|
.proto-root .jc-letter-h { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 11px; }
|
||||||
|
.proto-root .jc-letter-dir { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; background: #e4f8f5; color: #0a7f74; }
|
||||||
|
.proto-root .jc-letter-h .jc-chip { font-size: 10.5px; padding: 3px 9px; border-radius: 999px; background: var(--brand-wash); color: var(--brand-deep); border: 1px solid var(--brand-soft); }
|
||||||
|
.proto-root .jc-letter-h > code { margin-left: auto; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||||
|
.proto-root .jc-letter-meta { display: flex; flex-direction: column; gap: 4px; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-2); }
|
||||||
|
.proto-root .jc-letter-meta p { margin: 0; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.proto-root .jc-letter-meta i { flex: none; font-style: normal; width: 44px; font-size: 10px; font-weight: 800; letter-spacing: .06em; color: var(--ink-4); }
|
||||||
|
.proto-root .jc-letter-meta code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||||
|
.proto-root .jc-letter-sub { margin: 0 0 9px; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }
|
||||||
|
/* 正文:原文照录,pre-wrap 保原始换行;不截断、不折叠 */
|
||||||
|
.proto-root .jc-letter-body { margin: 0; font-size: 12.5px; line-height: 1.72; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.proto-root .jc-letter-body b { color: var(--ink); font-weight: 700; }
|
||||||
|
.proto-root .jc-atts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 11px; border-top: 1px dashed var(--line-soft); }
|
||||||
|
.proto-root .jc-att { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border: 1px solid var(--brand-soft); border-radius: 8px; background: var(--brand-wash); font-size: 11.5px; color: var(--brand-deep); }
|
||||||
|
.proto-root .jc-att svg { flex: none; color: var(--brand); }
|
||||||
|
.proto-root .jc-att code { font-family: var(--mono); font-size: 10.5px; }
|
||||||
|
.proto-root .jc-att span { color: var(--ink-4); }
|
||||||
|
/* 抽取指向:从原文到下方要素的一句衔接 */
|
||||||
|
.proto-root .jc-derive { display: flex; align-items: flex-start; gap: 8px; margin-top: 11px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); font-size: 11.5px; line-height: 1.7; color: var(--brand-deep); }
|
||||||
|
.proto-root .jc-derive svg { flex: none; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ── 四要素卡 ───────────────────────────────────────────────────────────
|
||||||
|
色调字典:每张卡带一套 --tone / --tone-soft / --tone-line,组件只读变量。
|
||||||
|
卡 3、卡 4 的色调随数据变(老客户绿 / 新客户橙;齐了蓝 / 有缺橙),
|
||||||
|
所以这条要素带本身就是一行可读的状态,不只是四个格子。 */
|
||||||
|
.proto-root .jc-four { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 11px; }
|
||||||
|
.proto-root .jc-fcard {
|
||||||
|
position: relative; overflow: hidden; display: flex; flex-direction: column; justify-content: space-between; gap: 10px; min-width: 0;
|
||||||
|
padding: 14px 14px 14px; border: 1px solid var(--cardline); border-radius: 13px; background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 8px 20px -14px rgba(31,35,41,.35);
|
||||||
|
transition: transform var(--t-fast), box-shadow var(--t-fast), border-color var(--t-fast);
|
||||||
|
--tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft);
|
||||||
|
}
|
||||||
|
/* 顶缘色棱 + 右上角光晕:卡的身份色,一眼分得清四件是四件 */
|
||||||
|
.proto-root .jc-fcard::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 3px; background: var(--tone); }
|
||||||
|
.proto-root .jc-fcard::after { content: ""; position: absolute; right: -46px; top: -52px; width: 132px; height: 132px; border-radius: 999px; background: radial-gradient(circle, var(--tone-soft), transparent 70%); pointer-events: none; }
|
||||||
|
.proto-root .jc-fcard > * { position: relative; z-index: 1; }
|
||||||
|
.proto-root .jc-fcard:hover { transform: translateY(-2px); border-color: var(--tone-line); box-shadow: 0 2px 4px rgba(31,35,41,.05), 0 14px 26px -16px rgba(31,35,41,.45); }
|
||||||
|
.proto-root .jc-fcard.t-teal { --tone: #0aa294; --tone-soft: rgba(10,162,148,.15); --tone-line: #9fe0d9; }
|
||||||
|
.proto-root .jc-fcard.t-ok { --tone: var(--ok); --tone-soft: rgba(39,135,23,.14); --tone-line: #bfedb4; }
|
||||||
|
.proto-root .jc-fcard.t-warn { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.12); --tone-line: #ffd9a8; }
|
||||||
|
|
||||||
|
.proto-root .jc-fcard-k { display: flex; align-items: center; gap: 6px; font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||||
|
.proto-root .jc-fcard-k > i { flex: none; width: 19px; height: 19px; border-radius: 6px; display: inline-grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
|
||||||
|
.proto-root .jc-fcard-b { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||||
|
.proto-root .jc-fcard-t { min-width: 0; flex: 1; }
|
||||||
|
/* 一律换行,不省略号——宁可多占一行,也不丢字 */
|
||||||
|
.proto-root .jc-fcard-t > b { display: block; font-size: 14px; font-weight: 800; color: var(--ink); line-height: 1.35; overflow-wrap: anywhere; }
|
||||||
|
.proto-root .jc-fcard-t > span { display: block; margin-top: 2px; font-size: 10.5px; color: var(--ink-4); line-height: 1.55; overflow-wrap: anywhere; }
|
||||||
|
/* 公司标:圆角方 + AI 渐变 + 外圈光环(方=组织) */
|
||||||
|
.proto-root .jc-logo { flex: none; width: 38px; height: 38px; border-radius: 12px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; letter-spacing: .04em; color: #fff; background: var(--ai-grad); box-shadow: var(--shadow-brand), 0 0 0 3px rgba(51,112,255,.10); }
|
||||||
|
/* 人像:圆 + 青色系(圆=人,与方形的组织标一眼分开) */
|
||||||
|
.proto-root .jc-face { flex: none; width: 38px; height: 38px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; line-height: 1; color: #0a7f74; background: #e4f8f5; border: 1px solid #9fe0d9; box-shadow: 0 0 0 3px rgba(10,162,148,.10); }
|
||||||
|
/* 邮箱做成可点的胶囊,复制按钮嵌在里面——比一行裸文字有抓手 */
|
||||||
|
/* 邮箱:软底胶囊、无描边——有描边会读成输入框,而读态这里不是可填的 */
|
||||||
|
.proto-root .jc-mailrow { display: flex; align-items: center; gap: 4px; margin-top: 5px; min-width: 0; max-width: 100%; padding: 3px 4px 3px 9px; border: 0; border-radius: 8px; background: var(--tone-soft); transition: background var(--t-fast); }
|
||||||
|
.proto-root .jc-mailrow:hover { background: rgba(10,162,148,.2); }
|
||||||
|
/* 编辑态里它包着输入框,软底会变成「框中框」,去掉 */
|
||||||
|
.proto-root .jc-report.editing .jc-mailrow { background: transparent; padding: 0; margin-top: 6px; }
|
||||||
|
.proto-root .jc-mailrow > code { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; line-height: 1.5; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||||
|
.proto-root .jc-copy { flex: none; width: 22px; height: 22px; display: inline-grid; place-items: center; border: 0; border-radius: 6px; background: #fff; color: var(--ink-4); cursor: pointer; box-shadow: 0 1px 2px rgba(31,35,41,.08); transition: all var(--t-fast); }
|
||||||
|
.proto-root .jc-copy:hover { color: #0aa294; }
|
||||||
|
.proto-root .jc-copy.ok { color: var(--ok); background: var(--ok-soft); box-shadow: none; }
|
||||||
|
/* 客户属性徽章 */
|
||||||
|
.proto-root .jc-tagbig { display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; padding: 7px 14px; border-radius: 999px; font-size: 13.5px; font-weight: 800; line-height: 1; color: var(--tone); background: var(--tone-soft); border: 1px solid var(--tone-line); }
|
||||||
|
.proto-root .jc-fcard-note { font-size: 10.5px; color: var(--ink-4); line-height: 1.55; }
|
||||||
|
.proto-root .jc-fcard-note b { color: var(--ink-2); font-weight: 700; }
|
||||||
|
.proto-root .jc-fcard-note b.bad { color: var(--tone); font-weight: 800; }
|
||||||
|
/* 成交迷你柱:把「台账 6 笔成交」从一句话变成看得见的节奏 */
|
||||||
|
.proto-root .jc-spark { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
|
||||||
|
.proto-root .jc-spark > i { flex: 1 1 0; min-width: 4px; max-width: 15px; border-radius: 3px 3px 1px 1px; background: var(--tone); opacity: .3; transition: opacity var(--t-fast), height var(--t-med); }
|
||||||
|
.proto-root .jc-spark > i:last-child { opacity: 1; }
|
||||||
|
.proto-root .jc-spark:hover > i { opacity: .55; }
|
||||||
|
.proto-root .jc-spark:hover > i:last-child { opacity: 1; }
|
||||||
|
.proto-root .jc-spark.empty { align-items: stretch; }
|
||||||
|
.proto-root .jc-spark.empty > i { height: 100%; opacity: 1; background: repeating-linear-gradient(45deg, var(--tone-soft) 0 5px, transparent 5px 10px); border: 1px dashed var(--tone-line); border-radius: 5px; }
|
||||||
|
/* 有效 PN 计数 */
|
||||||
|
.proto-root .jc-count { display: flex; align-items: baseline; gap: 6px; }
|
||||||
|
.proto-root .jc-count > b { font-family: var(--mono); font-size: 32px; font-weight: 800; line-height: .95; color: var(--ink); letter-spacing: -.04em; }
|
||||||
|
.proto-root .jc-count > span { font-size: 11px; color: var(--ink-4); }
|
||||||
|
/* 分段刻度条:一格 = 一条 PN,缺必填的那格直接标红——比一根实心进度条信息量大 */
|
||||||
|
.proto-root .jc-segmeter { display: flex; gap: 3px; }
|
||||||
|
.proto-root .jc-segmeter > i { flex: 1; height: 7px; border-radius: 2px; background: var(--ok); transition: background var(--t-fast); }
|
||||||
|
.proto-root .jc-segmeter > i.bad { background: var(--danger); }
|
||||||
|
.proto-root .jc-lockhint { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; color: var(--ink-4); }
|
||||||
|
|
||||||
|
/* ── 占位胶囊:必填=红虚线,选填=灰虚线;**都带文字**,不靠颜色单打 ── */
|
||||||
|
.proto-root .jc-ph { display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700; line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed; }
|
||||||
|
.proto-root .jc-ph.req { color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||||
|
.proto-root .jc-ph.opt { color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||||
|
|
||||||
|
/* ── 分段控件(新客户 / 老客户)── */
|
||||||
|
.proto-root .jc-seg { display: inline-flex; padding: 2px; gap: 2px; border: 1px solid var(--cardline); border-radius: 9px; background: var(--soft); align-self: flex-start; }
|
||||||
|
.proto-root .jc-seg button { border: 0; border-radius: 7px; padding: 6px 13px; font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-3); background: transparent; cursor: pointer; transition: all var(--t-fast); }
|
||||||
|
.proto-root .jc-seg button:hover:not(.on) { color: var(--brand); }
|
||||||
|
.proto-root .jc-seg button.on { background: #fff; color: var(--ink); box-shadow: 0 1px 3px rgba(31,35,41,.12); }
|
||||||
|
|
||||||
|
/* ── 表:读态与编辑态共用骨架 ─────────────────────────────────────── */
|
||||||
|
.proto-root .jc-tbl-wrap { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: 10px; }
|
||||||
|
.proto-root .jc-tbl-in { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
|
||||||
|
.proto-root .jc-tbl-in th { background: var(--soft); text-align: left; padding: 8px 9px; font-size: 10.5px; font-weight: 800; letter-spacing: .03em; color: var(--ink-3); white-space: nowrap; border-bottom: 1px solid var(--line-soft); }
|
||||||
|
.proto-root .jc-tbl-in th.req::after { content: "*"; margin-left: 1px; color: var(--danger); font-size: 10px; }
|
||||||
|
.proto-root .jc-tbl-in td { padding: 4px 5px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||||
|
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
|
||||||
|
.proto-root .jc-tbl-in tbody tr:hover td { background: #f7f9fd; }
|
||||||
|
/* 缺必填的整行浅红打底 + 行首红标——不用找红胶囊,一眼扫到是哪几行 */
|
||||||
|
.proto-root .jc-tbl-in tr.miss td { background: #fffbfb; }
|
||||||
|
.proto-root .jc-tbl-in tbody tr.miss:hover td { background: #fff5f5; }
|
||||||
|
.proto-root .jc-tbl-in td.jc-rn { position: relative; width: 38px; padding: 4px 6px 4px 13px; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); }
|
||||||
|
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); font-weight: 800; }
|
||||||
|
/* 行首红标只在表态用 ::after —— 卡片态的 ::after 被「第 N 条」占着,不能抢 */
|
||||||
|
@media (min-width: 961px) {
|
||||||
|
.proto-root .jc-tbl-in tr.miss td.jc-rn::after { content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger); }
|
||||||
|
}
|
||||||
|
.proto-root .jc-rndot { display: inline-block; width: 5px; height: 5px; margin-left: 4px; border-radius: 999px; background: var(--danger); vertical-align: 2px; }
|
||||||
|
/* 读态单元格 */
|
||||||
|
.proto-root .jc-vcell { display: block; padding: 6px 6px; font-size: 12px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
|
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
|
||||||
|
.proto-root .jc-vcell.num { font-family: var(--mono); font-size: 11.5px; text-align: right; }
|
||||||
|
/* 编辑态单元格:安静的电子表格 */
|
||||||
|
.proto-root .jc-cell { width: 100%; min-width: 64px; border: 1px solid var(--line); border-radius: 6px; padding: 6px 7px; font: inherit; font-size: 12px; color: var(--ink); background: #fff; transition: border-color var(--t-fast), box-shadow var(--t-fast); }
|
||||||
|
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11px; }
|
||||||
|
.proto-root .jc-cell.num { text-align: right; font-family: var(--mono); font-size: 11.5px; }
|
||||||
|
.proto-root .jc-cell::placeholder { color: var(--ink-4); }
|
||||||
|
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
|
||||||
|
.proto-root .jc-cell.req-empty { border-color: #f3b6b3; background: #fffafa; }
|
||||||
|
.proto-root .jc-cell.req-empty::placeholder { color: var(--danger); }
|
||||||
|
/* 备注这类长文本用文本域:能换行、随内容长高,不裁字 */
|
||||||
|
.proto-root textarea.jc-cell { display: block; resize: vertical; overflow: hidden; min-height: 31px; line-height: 1.55; }
|
||||||
|
|
||||||
|
/* 编辑态的整表提示带 */
|
||||||
|
.proto-root .jc-editbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; padding: 9px 12px; border-radius: 9px; background: var(--brand-wash); border: 1px solid var(--brand-soft); font-size: 11.5px; color: var(--brand-deep); }
|
||||||
|
.proto-root .jc-editbar svg { flex: none; }
|
||||||
|
.proto-root .jc-editbar .sp { flex: 1 1 auto; }
|
||||||
|
|
||||||
|
/* 剔除行说明 */
|
||||||
|
.proto-root .jc-drop { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px; padding: 9px 12px; border-radius: 9px; border: 1px dashed var(--line); background: var(--soft); font-size: 11.5px; color: var(--ink-3); line-height: 1.6; }
|
||||||
|
.proto-root .jc-drop svg { flex: none; margin-top: 2px; color: var(--ink-4); }
|
||||||
|
.proto-root .jc-drop code { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||||
|
|
||||||
|
.proto-root .jc-callout { display: flex; gap: 8px; align-items: flex-start; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; line-height: 1.75; }
|
||||||
|
.proto-root .jc-callout p { margin: 0; }
|
||||||
|
.proto-root .jc-callout svg { flex: none; margin-top: 2px; }
|
||||||
|
.proto-root .jc-callout.t-mute { background: var(--soft); color: var(--ink-2) }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.proto-root .jc-pulse-dot { animation: none; }
|
||||||
|
.proto-root .jc-report, .proto-root .jc-rp-stamp { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════════════════
|
||||||
|
响应式:宁可另起一行,也不损失完整度
|
||||||
|
≥961px 六列全展开的表(列 min-width 之和 878px,容器有余量,绝不裁字、绝不横滚)
|
||||||
|
≤960px 表 → 卡片:一条 PN 一张卡、一行一字段(标签 / 值),无横滚、无省略
|
||||||
|
≤560px 字段行再拆成上下两行(标签在上、值在下)
|
||||||
|
══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
@media (max-width: 1040px) { .proto-root .jc-four { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 560px) { .proto-root .jc-four { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.proto-root .jc-tbl-wrap { overflow: visible; border: 0; border-radius: 0; background: transparent; }
|
||||||
|
.proto-root .jc-tbl-in,
|
||||||
|
.proto-root .jc-tbl-in tbody,
|
||||||
|
.proto-root .jc-tbl-in tr,
|
||||||
|
.proto-root .jc-tbl-in td { display: block; width: 100%; }
|
||||||
|
.proto-root .jc-tbl-in thead { display: none; }
|
||||||
|
|
||||||
|
/* 一条 PN = 一张卡 */
|
||||||
|
.proto-root .jc-tbl-in tr { margin-bottom: 10px; padding: 4px 13px 9px; border: 1px solid var(--line-soft); border-radius: 11px; background: #fff; box-shadow: var(--card-shadow); }
|
||||||
|
.proto-root .jc-tbl-in tr:last-child { margin-bottom: 0; }
|
||||||
|
.proto-root .jc-tbl-in tbody tr:hover td { background: transparent; }
|
||||||
|
.proto-root .jc-tbl-in tr.miss { border-color: #f3b6b3; background: #fffbfb; }
|
||||||
|
|
||||||
|
/* 卡头:第 N 条 */
|
||||||
|
.proto-root .jc-tbl-in td.jc-rn { padding: 8px 0 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line-soft); font-family: inherit; font-size: 12px; font-weight: 800; color: var(--ink-2); }
|
||||||
|
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 "; }
|
||||||
|
.proto-root .jc-tbl-in td.jc-rn::after { content: " 条"; }
|
||||||
|
.proto-root .jc-tbl-in tr.miss td.jc-rn { color: var(--danger); }
|
||||||
|
|
||||||
|
/* 字段行:左标签 + 右值,值超长自动折行 */
|
||||||
|
.proto-root .jc-tbl-in td:not(.jc-rn) { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line-soft); }
|
||||||
|
.proto-root .jc-tbl-in tr td:last-child { border-bottom: 0; padding-bottom: 2px; }
|
||||||
|
.proto-root .jc-tbl-in td:not(.jc-rn)::before {
|
||||||
|
content: attr(data-h); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; color: var(--ink-4); line-height: 1.5;
|
||||||
|
}
|
||||||
|
.proto-root .jc-tbl-in td[data-req="1"]:not(.jc-rn)::before { content: attr(data-h) " *"; }
|
||||||
|
.proto-root .jc-tbl-in .jc-vcell { padding: 0; overflow-wrap: anywhere; }
|
||||||
|
.proto-root .jc-tbl-in .jc-vcell.num { text-align: left; }
|
||||||
|
.proto-root .jc-tbl-in .jc-cell.num { text-align: left; }
|
||||||
|
.proto-root .jc-tbl-in .jc-cell { min-width: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窄屏:落款三件各占一行,动作按钮撑满一行 */
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.proto-root .jc-rp-acts { margin-left: 0; width: 100%; flex-wrap: wrap; }
|
||||||
|
.proto-root .jc-rp-acts .jc-btn { flex: 1 1 auto; justify-content: center; }
|
||||||
|
.proto-root .jc-hintline { flex: 1 1 100%; }
|
||||||
|
.proto-root .jc-tbl-in td:not(.jc-rn) { grid-template-columns: 1fr; gap: 3px; align-items: start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ══ 以下取自 A1-询价受理记录.html(归档件独有的件)══ */
|
||||||
|
/* ── 料号分类:分布条 + 图例 ───────────────────────────────────────── */
|
||||||
|
.proto-root .jc-dist { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 13px 15px; border: 1px solid var(--line-soft); border-radius: 12px; background: linear-gradient(180deg, #fbfcfe, #fff); }
|
||||||
|
.proto-root .jc-distbar { flex: 1 1 320px; min-width: 260px; display: flex; gap: 3px; }
|
||||||
|
.proto-root .jc-distbar > i { height: 30px; border-radius: 4px; display: grid; place-items: center; font-size: 10.5px; font-weight: 800; color: #fff; font-family: var(--mono); }
|
||||||
|
.proto-root .jc-distbar > i.a { background: var(--ok); }
|
||||||
|
.proto-root .jc-distbar > i.b { background: var(--brand); }
|
||||||
|
.proto-root .jc-distbar > i.c { background: var(--warn-accent); }
|
||||||
|
.proto-root .jc-distlg { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
||||||
|
.proto-root .jc-distlg > span { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--ink-3); }
|
||||||
|
.proto-root .jc-distlg em { width: 10px; height: 10px; border-radius: 3px; }
|
||||||
|
.proto-root .jc-distlg em.a { background: var(--ok); } .proto-root .jc-distlg em.b { background: var(--brand); } .proto-root .jc-distlg em.c { background: var(--warn-accent); }
|
||||||
|
.proto-root .jc-distlg b { font-family: var(--mono); font-weight: 800; color: var(--ink); }
|
||||||
|
|
||||||
|
/* 类别徽 A / B / C */
|
||||||
|
.proto-root .jc-tbadge { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; font-family: var(--mono); font-size: 11px; font-weight: 800; line-height: 1; color: #fff; }
|
||||||
|
.proto-root .jc-tbadge.a { background: var(--ok); } .proto-root .jc-tbadge.b { background: var(--brand); } .proto-root .jc-tbadge.c { background: var(--warn-accent); }
|
||||||
|
|
||||||
|
/* ── 结论与去向 ─────────────────────────────────────────────────────────
|
||||||
|
G2 是**分支网关**:三条互斥条件按序求值,只命中一条、只落一个活动。
|
||||||
|
所以这里不是三张并列的去向卡,而是一张**判定表 + 一条结论**。 */
|
||||||
|
/* 判定表:一行一条规则,命中行上色留痕(DMN 口径) */
|
||||||
|
.proto-root .jc-dmn { border: 1px solid var(--line-soft); border-radius: 12px; overflow: hidden; }
|
||||||
|
.proto-root .jc-dmn-h, .proto-root .jc-dmn-r { display: grid; grid-template-columns: 40px minmax(0, 1fr) 170px 200px; align-items: center; gap: 12px; padding: 9px 14px; }
|
||||||
|
.proto-root .jc-dmn-h { background: var(--soft); border-bottom: 1px solid var(--line-soft); font-size: 10px; font-weight: 800; letter-spacing: .08em; color: var(--ink-4); }
|
||||||
|
.proto-root .jc-dmn-r { font-size: 12px; color: var(--ink-4); background: #fff; }
|
||||||
|
.proto-root .jc-dmn-r + .jc-dmn-r { border-top: 1px solid var(--line-soft); }
|
||||||
|
.proto-root .jc-dmn-i { font-family: var(--mono); font-size: 11px; font-weight: 800; }
|
||||||
|
.proto-root .jc-dmn-c { line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
|
.proto-root .jc-dmn-t { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; }
|
||||||
|
.proto-root .jc-dmn-t code { font-family: var(--mono); font-size: 10.5px; font-weight: 800; color: var(--ink-4); background: var(--soft); border: 1px solid var(--line); border-radius: 5px; padding: 2px 6px; }
|
||||||
|
.proto-root .jc-dmn-v { font-size: 11px; line-height: 1.55; }
|
||||||
|
/* 命中行:上色 + 左缘棱 + 加粗 */
|
||||||
|
.proto-root .jc-dmn-r.hit { position: relative; background: var(--tone-soft); color: var(--ink-2); }
|
||||||
|
.proto-root .jc-dmn-r.hit::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone); }
|
||||||
|
.proto-root .jc-dmn-r.hit .jc-dmn-i { color: var(--tone); }
|
||||||
|
.proto-root .jc-dmn-r.hit .jc-dmn-c { color: var(--ink); }
|
||||||
|
.proto-root .jc-dmn-r.hit .jc-dmn-c b { color: var(--tone); font-weight: 800; }
|
||||||
|
.proto-root .jc-dmn-r.hit .jc-dmn-t { color: var(--ink); }
|
||||||
|
.proto-root .jc-dmn-r.hit .jc-dmn-t code { color: #fff; background: var(--tone); border-color: var(--tone); }
|
||||||
|
.proto-root .jc-dmn-v.y { display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone); }
|
||||||
|
.proto-root .jc-dmn-v.n { color: var(--ink-4); }
|
||||||
|
/* 色调随命中类别 */
|
||||||
|
.proto-root .jc-dmn.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
|
||||||
|
.proto-root .jc-dmn.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
|
||||||
|
.proto-root .jc-dmn.k-c { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
|
||||||
|
|
||||||
|
/* 结论横幅:命中条件 → 唯一去向 */
|
||||||
|
.proto-root .jc-verdict {
|
||||||
|
position: relative; overflow: hidden; display: flex; align-items: stretch; flex-wrap: wrap; gap: 16px;
|
||||||
|
margin-top: 11px; padding: 16px 18px; border: 1px solid var(--tone-line); border-radius: 13px;
|
||||||
|
background: linear-gradient(105deg, var(--tone-soft), #fff 62%);
|
||||||
|
box-shadow: 0 1px 2px rgba(31,35,41,.05), 0 10px 24px -16px rgba(31,35,41,.4);
|
||||||
|
--tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8;
|
||||||
|
}
|
||||||
|
.proto-root .jc-verdict.k-a { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
|
||||||
|
.proto-root .jc-verdict.k-b { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
|
||||||
|
.proto-root .jc-verdict::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--tone); }
|
||||||
|
.proto-root .jc-verdict > * { position: relative; z-index: 1; }
|
||||||
|
.proto-root .jc-vd-l { flex: 1 1 300px; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.proto-root .jc-vd-k { font-size: 9.5px; font-weight: 800; letter-spacing: .12em; color: var(--tone); }
|
||||||
|
.proto-root .jc-vd-cond { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 15px; font-weight: 800; color: var(--ink); }
|
||||||
|
.proto-root .jc-vd-why { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||||
|
.proto-root .jc-vd-why code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||||
|
.proto-root .jc-vd-arrow { flex: none; align-self: center; color: var(--tone); }
|
||||||
|
.proto-root .jc-vd-r { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-radius: 11px; background: #fff; border: 1px solid var(--tone-line); }
|
||||||
|
.proto-root .jc-vd-node { display: flex; align-items: center; gap: 8px; font-size: 16px; font-weight: 800; color: var(--ink); }
|
||||||
|
.proto-root .jc-vd-node code { font-family: var(--mono); font-size: 12px; font-weight: 800; color: #fff; background: var(--tone); border-radius: 6px; padding: 3px 8px; }
|
||||||
|
.proto-root .jc-vd-who { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--ink-2); }
|
||||||
|
.proto-root .jc-vd-who > i { width: 20px; height: 20px; border-radius: 999px; display: inline-grid; place-items: center; font-style: normal; font-size: 10px; font-weight: 700; color: #fff; background: var(--tone); }
|
||||||
|
.proto-root .jc-vd-next { margin: 0; padding-top: 8px; border-top: 1px dashed var(--line-soft); font-size: 10.5px; line-height: 1.65; color: var(--ink-4); }
|
||||||
|
.proto-root .jc-vd-next b { color: var(--ink-2); font-weight: 700; }
|
||||||
|
/* 命中分支携带的条目 */
|
||||||
|
.proto-root .jc-pnlist { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.proto-root .jc-pnlist > li { display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 999px; background: #fff; border: 1px solid var(--tone-line); font-size: 11px; color: var(--ink-2); overflow-wrap: anywhere; }
|
||||||
|
.proto-root .jc-pnlist > li > i { flex: none; width: 5px; height: 5px; border-radius: 999px; background: var(--tone); }
|
||||||
|
.proto-root .jc-pnlist code { font-family: var(--mono); font-size: 10.5px; color: var(--ink); }
|
||||||
|
|
||||||
|
/* ── 落地动作 ───────────────────────────────────────────────────────── */
|
||||||
|
.proto-root .jc-acts { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--line-soft); border-radius: 11px; overflow: hidden; }
|
||||||
|
.proto-root .jc-act { display: flex; align-items: flex-start; gap: 9px; padding: 10px 13px; font-size: 12px; color: var(--ink-2); line-height: 1.6; background: #fff; }
|
||||||
|
.proto-root .jc-act + .jc-act { border-top: 1px solid var(--line-soft); }
|
||||||
|
.proto-root .jc-act > i { flex: none; width: 18px; height: 18px; margin-top: 1px; border-radius: 999px; display: inline-grid; place-items: center; color: #fff; background: var(--ok); }
|
||||||
|
.proto-root .jc-act b { font-weight: 700; color: var(--ink); }
|
||||||
|
.proto-root .jc-act span { color: var(--ink-4); }
|
||||||
|
.proto-root .jc-act code { font-family: var(--mono); font-size: 10.5px; color: var(--brand-deep); background: var(--brand-wash); border-radius: 4px; padding: 1px 5px; }
|
||||||
|
|
||||||
|
|
||||||
|
.proto-root .jc-done-tag { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; flex: none; padding: 6px 13px; border-radius: 999px; font-size: 11.5px; font-weight: 800; color: var(--ok); background: #fff; border: 1px solid #bfedb4; }
|
||||||
|
.proto-root .jc-vcell.dim { color: var(--ink-4); }
|
||||||
|
.proto-root .jc-tbl-in td.jc-ty { width: 44px; text-align: center; }
|
||||||
|
.proto-root .jc-tbl-in td.jc-ty { width: auto; text-align: left; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标精灵 —— 与样式表同一出处,两份目标件的 `<symbol>` 取并集。
|
||||||
|
* 沙箱里取不到图标字体,所以图标一律内联;`<use href="#i-xxx">` 引的就是这一坨。
|
||||||
|
*/
|
||||||
|
export const PRD_SPRITE = `<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><symbol id="i-alert" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></symbol><symbol id="i-arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol><symbol id="i-boxes" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol><symbol id="i-building" viewBox="0 0 24 24"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></symbol><symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol><symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol><symbol id="i-file-check" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 15 2 2 4-4"/></symbol><symbol id="i-file-text" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></symbol><symbol id="i-git-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol><symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></symbol><symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></symbol><symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol><symbol id="i-mail-open" viewBox="0 0 24 24"><path d="M21.2 8.4c.5.38.8.97.8 1.6v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V10a2 2 0 0 1 .8-1.6l8-6a2 2 0 0 1 2.4 0z"/><path d="m22 10-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 10"/></symbol><symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></symbol><symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol><symbol id="i-sheet" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/></symbol><symbol id="i-shield-check" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></symbol><symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></symbol><symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol></defs></svg>`;
|
||||||
@@ -1,77 +1,665 @@
|
|||||||
/**
|
/**
|
||||||
* A1 视图模版 —— 三种局面:事态全景 / 打标判据 / 成果预览(AM 07)。
|
* A1 询价受理的 HTML 报告模版 —— **从 PRD 目标件搬过来的那两份件**。
|
||||||
* 外壳与卡片件来自 `@sdk`:六个活动共用一套面貌,不各自发明。
|
*
|
||||||
|
* 出处(逐块照搬,样式表见 `prd-style.ts`,一个数值都没改):
|
||||||
|
* · `~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理需求.html` → `INTAKE_OVERVIEW`
|
||||||
|
* (阶段2 · 待处理页里嵌的那份);
|
||||||
|
* · `~/coding/JCAI_PRD/docs/活动执行页/A1-询价受理记录.html` → `INTAKE_RELEASE`
|
||||||
|
* (阶段3 · 工作成果里定稿归档的那份)。
|
||||||
|
*
|
||||||
|
* ── 为什么是搬,不是照着做 ─────────────────────────────────────────────
|
||||||
|
* 前三轮我拿自己那套 SDK 外壳「照着做」:块搬齐了,版面却是两套 —— 章节头成了
|
||||||
|
* 彩色方块编号、多出一段摘要与一条元信息带、字号用 clamp 响应式。三次报「25 块到位」,
|
||||||
|
* 而那是**结构断言**(某个 class 在不在),对版面一点约束都没有。人一眼就看出不是那份件。
|
||||||
|
* 目标件是像素级的答案:搬过来,才不会在复述里走样。
|
||||||
|
*
|
||||||
|
* ── 搬的时候,哪些东西必须换掉 ─────────────────────────────────────────
|
||||||
|
* 原型是**带脚本的演示页**:数据写死在 script 里,由 JS 造表、算计数、切编辑态。
|
||||||
|
* 而这里的容器是 sandbox iframe,**一行脚本也跑不了**;填数只有三件事 ——
|
||||||
|
* `data-slot` 取值、`data-rows` 逐行展开、`data-class-of` 按值上色。所以:
|
||||||
|
* · 写死的行 → `data-rows` 模版,一行一条从台账草稿展开;
|
||||||
|
* · JS 算的计数 → 台账里的汇总列,`data-slot` 取;
|
||||||
|
* · JS 造的「必填/选填」占位胶囊 → CSS `:empty::after`(基座对空值留空,见 `data-blank`);
|
||||||
|
* · JS 算的**命中行**与结论横幅 → 基座按契约的出口谓词求值后注入 `case:route*`
|
||||||
|
* (与放行时发口的是同一次判定,不是这屏自己又算了一遍);
|
||||||
|
* · 编辑态 → **就长在件里**(2026-08-01 改)。每个可改的格子渲两份:只读那份与
|
||||||
|
* `<input class="jc-cell">` 孪生件,由根节点上的 `editing` 二选一显示。
|
||||||
|
* 待处理那一屏摆的**只有这份件**,所以改数据也只能在件上改;
|
||||||
|
* 工作成果不加那个 class,同一份 HTML 就是归档件。
|
||||||
|
* 收值在外层(`allow-same-origin` 读得到 DOM),iframe 里仍然一行脚本都没有。
|
||||||
|
* · 「修改 / 确认」按钮 → 不做:提交那一下在**对话区的卡**上,
|
||||||
|
* 判据与作业指导书也在那儿 —— 工作区只放件本身。
|
||||||
|
*
|
||||||
|
* ── 搬不过来的两处,如实空着 ───────────────────────────────────────────
|
||||||
|
* · 圆标里的首字母(IT / EV / 冯)—— 得算,而模版不许算;换成同形状里的图标。
|
||||||
|
* · 「近 6 笔成交」迷你柱 —— `market.price_history` 有列没数据(种子不写、CRM 是桩),
|
||||||
|
* 没有写入方。画六根编出来的柱子是撒谎,画一条空轨像组件坏了,所以那一格不画,
|
||||||
|
* 把「为什么没有」写在卡脚。
|
||||||
|
*
|
||||||
|
* ── 取值路径三纪律(编错一个字,屏上就是一片「—」)────────────────────
|
||||||
|
* ① 顶层只取汇总列与单值列;逐行列摊到顶层是「后写的赢」,摆到题头上是抽样不是概览。
|
||||||
|
* ② 逐条数据一律走 `mine:域.对象` —— `域.对象` 是正式版全表,别单的行也在里面。
|
||||||
|
* ③ 列名逐字照 `manifest.ts` 的 `declarations.ledger` 抄,不照记忆写。
|
||||||
*/
|
*/
|
||||||
import { kvCard, noteCard, tableCard, viewShell } from '@sdk';
|
import { PRD_CSS, PRD_SPRITE } from './prd-style.ts';
|
||||||
|
|
||||||
export const INTAKE_OVERVIEW = viewShell({
|
/* ==================== 搬运期补的几条样式 ==================== */
|
||||||
title: 'RFQ 受理全景',
|
|
||||||
kicker: 'A1 · 统一受理',
|
/**
|
||||||
tone: 'brand',
|
* 原型用 JS 干、这里只能用 CSS 干的那几件 —— **补在原样式表之后,不改原值**。
|
||||||
|
*
|
||||||
|
* ① 空格子的「必填 / 选填」胶囊:原型是 JS 判空后插一个 `jc-ph`;
|
||||||
|
* 这里改成 `:empty::after`,靠基座对空值留空(`data-blank`)触发,胶囊样式仍用原型的数值。
|
||||||
|
* ② 缺必填的整行浅红 + 行首红条:原型给 `tr.miss`,这里用 `:has()` 认。
|
||||||
|
* ③ 类徽 / 分布条 / 判定表命中行按值上色:原型由 JS 拼 class,
|
||||||
|
* 这里由基座把值消毒成 `v-*` 追加进 class(`data-class-of`)—— 判断在样式表里,不在填数里。
|
||||||
|
*/
|
||||||
|
const BIND_CSS = `
|
||||||
|
/* ① 空格子自己说话 —— 值真的空才轮到这两条(基座 data-blank) */
|
||||||
|
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
|
||||||
|
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
|
||||||
|
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
|
||||||
|
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
|
||||||
|
}
|
||||||
|
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
|
||||||
|
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
|
||||||
|
/* 归档件里空格子就是「客户没给」,不再摆「你还能填」的胶囊 */
|
||||||
|
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); }
|
||||||
|
|
||||||
|
/* ② 缺必填的整行 —— 原型是 tr.miss,这里由 :has() 认 */
|
||||||
|
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
|
||||||
|
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
|
||||||
|
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; position: relative; }
|
||||||
|
@media (min-width: 1001px) {
|
||||||
|
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
|
||||||
|
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 序号由 CSS 计数器排 —— 模版里没有循环变量,行是基座复制出来的 */
|
||||||
|
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
|
||||||
|
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
|
||||||
|
/* 窄屏原型给的是 ::before「第 」+ ::after「 条」;这里 ::before 已被计数器占用,
|
||||||
|
把两句合进 ::before,并把原型那句 ::after 收掉 —— 否则窄屏上写着「第 9 条 条」 */
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.proto-root .jc-tbl-in td.jc-rn::before { content: "第 " counter(rn) " 条"; }
|
||||||
|
.proto-root .jc-tbl-in td.jc-rn::after { content: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ④ 就地改:每个可改的格子渲了两份 —— 只读那份与输入那份,这里二选一。
|
||||||
|
**默认锁着**:开关是那张表上的 on(外层按下「编辑」才加),
|
||||||
|
一屏两张表各管各的。判断在样式表里,不在填数里,件本身仍是同一份。 */
|
||||||
|
/* 默认锁着:只读那份在,输入孪生件收起来 —— **件首先是给人看的** */
|
||||||
|
.proto-root .jc-cell { display: none; }
|
||||||
|
/* 开闸是给那张表的容器加 「on」(外层按下「编辑」才加)—— 一屏两张表各管各的 */
|
||||||
|
.proto-root .jc-tbl-in.on td > .jc-vcell,
|
||||||
|
.proto-root .jc-tbl-in.on td > .jc-tbadge { display: none; }
|
||||||
|
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
|
||||||
|
/* 类那一列平时只有一枚 24px 的徽,编辑态要放得下一个字 */
|
||||||
|
.proto-root .jc-tbl-in.on td.jc-ty, .proto-root .jc-tbl-in.on th:nth-child(2) { width: 56px; min-width: 56px; }
|
||||||
|
.proto-root .jc-tbl-in.on td.num > .jc-cell, .proto-root .jc-tbl-in.on .jc-cell.num { text-align: right; }
|
||||||
|
/* 编辑态下必填空格子:红框 + 红 placeholder(原型的 req-empty 同款,只是由 :placeholder-shown 认) */
|
||||||
|
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
|
||||||
|
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
|
||||||
|
|
||||||
|
/* ⑥ **铺满工作区**(2026-08-02 用户定:「报告不要做成单独的嵌入层」)。
|
||||||
|
原型那份是独立网页,jc-stage 给了 1160px 上限与 22/16/52px 的四周留白 ——
|
||||||
|
摆进工作区就成了「画布上浮着的一张小卡」:左右各空一条,底下还坠着 52px。
|
||||||
|
件在这里**就是这一屏的正文**,不是嵌在里面的另一个页面:去掉上限与横向留白,
|
||||||
|
外框那圈圆角边也一并卸掉(工作区自己已经是一张卡,两层框叠着像贴纸)。 */
|
||||||
|
.jc-stage { max-width: none; padding: 0 0 4px; }
|
||||||
|
.proto-root .jc-report { border: 0; border-radius: 0; box-shadow: none; }
|
||||||
|
|
||||||
|
/* ⑤ 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」。
|
||||||
|
整块只在这一屏可改时才出现(外层给根节点加 「editable」);
|
||||||
|
归档件里它一个字都不该有 —— 那时件是件,不是表单。 */
|
||||||
|
.proto-root .jc-editgate { display: none; }
|
||||||
|
html.editable .proto-root .jc-editgate {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px; padding: 8px 12px; border-radius: 9px;
|
||||||
|
background: var(--soft); border: 1px solid var(--line-soft);
|
||||||
|
font-size: 11.5px; color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.proto-root .jc-editgate-t { display: inline-flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; }
|
||||||
|
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
|
||||||
|
.proto-root .jc-egbtn {
|
||||||
|
flex: none; display: inline-flex; align-items: center; gap: 4px; cursor: pointer;
|
||||||
|
border: 1px solid var(--line); border-radius: 7px; padding: 5px 11px;
|
||||||
|
font: inherit; font-size: 11.5px; font-weight: 700; color: var(--ink-2); background: #fff;
|
||||||
|
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
|
||||||
|
.proto-root .jc-egbtn.save { color: #fff; background: var(--brand); border-color: var(--brand); }
|
||||||
|
.proto-root .jc-egbtn.save:hover { background: var(--brand-deep); border-color: var(--brand-deep); }
|
||||||
|
/* 锁着的时候只有「编辑」;开了换成「取消 / 保存」—— 三个钮同时摆着,人不知道现在算哪一态 */
|
||||||
|
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
|
||||||
|
.proto-root .jc-editgate.on { background: var(--brand-wash); border-color: var(--brand-soft); color: var(--brand-deep); }
|
||||||
|
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
|
||||||
|
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
|
||||||
|
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
|
||||||
|
/* 那条「怎么改」的提示只在开了闸之后说 —— 锁着的时候它是句废话 */
|
||||||
|
.proto-root .jc-editbar { display: none; }
|
||||||
|
.proto-root .jc-editgate.on + .jc-editbar { display: flex; }
|
||||||
|
|
||||||
|
/* ⑦ 增删行 —— 手改是兜 AI 的错那一手,而错不止「这一格填错了」:
|
||||||
|
「这一行根本不该在」与「漏了一行」是同一类错,只给改格子就没有出口。 */
|
||||||
|
/* 序号那一格一列两用:只读时是行号(计数器画的),编辑时是「撤掉这一行」的 × */
|
||||||
|
.proto-root .jc-rowdel { display: none; }
|
||||||
|
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
|
||||||
|
.proto-root .jc-tbl-in.on .jc-rowdel {
|
||||||
|
display: block; width: 20px; height: 20px; margin: 0 auto; cursor: pointer;
|
||||||
|
border: 1px solid var(--line); border-radius: 6px; padding: 0;
|
||||||
|
font: inherit; font-size: 13px; line-height: 1; color: var(--ink-3); background: #fff;
|
||||||
|
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||||
|
}
|
||||||
|
.proto-root .jc-tbl-in.on .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: #fff6f6; }
|
||||||
|
/* 撤掉的行**不当场抹掉** —— 划着摆在那儿,人看得见自己删了哪一行,也才有得后悔 */
|
||||||
|
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
|
||||||
|
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
|
||||||
|
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
|
||||||
|
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
|
||||||
|
/* 表尾那条「加一行」—— 与闸同款,只在这一屏可改且开了闸时现身 */
|
||||||
|
.proto-root .jc-addrow { display: none; }
|
||||||
|
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
|
||||||
|
background: var(--soft); border: 1px dashed var(--line);
|
||||||
|
}
|
||||||
|
.proto-root .jc-addrow em { font-style: normal; font-size: 11px; color: var(--ink-3); }
|
||||||
|
|
||||||
|
/* ③ 按值上色:基座把值消毒成 v-* 追加进 class */
|
||||||
|
/* 刻度条按缺口:missing_key_fields 是条数,0 = 完整(绿,原型的默认色),非 0 = 缺必填(红) */
|
||||||
|
.proto-root .jc-segmeter > i.v-1, .proto-root .jc-segmeter > i.v-2, .proto-root .jc-segmeter > i.v-3 { background: var(--danger); }
|
||||||
|
/* 刻度条按状况标:归档屏那张卡上,一格即一条料的类别 */
|
||||||
|
.proto-root .jc-segmeter > i.v-a { background: var(--ok); }
|
||||||
|
.proto-root .jc-segmeter > i.v-b { background: var(--brand); }
|
||||||
|
.proto-root .jc-segmeter > i.v-c { background: var(--warn-accent); }
|
||||||
|
.proto-root .jc-tbadge { background: var(--ink-4); }
|
||||||
|
.proto-root .jc-tbadge.v-a { background: var(--ok); }
|
||||||
|
.proto-root .jc-tbadge.v-b { background: var(--brand); }
|
||||||
|
.proto-root .jc-tbadge.v-c { background: var(--warn-accent); }
|
||||||
|
/* 分布条:三段按条数分宽。原型是 JS 写 inline style="flex:N",而模版不许算 ——
|
||||||
|
改由 data-class-of 把**条数本身**投影成 v-<N>,这里备一把梯子让 CSS 认。
|
||||||
|
条数 0 的那段整段不出:摆一条零宽的彩带不如不摆。 */
|
||||||
|
/* 原型那段是 grid + place-items:center;这里段里多了个 ::before 前缀,
|
||||||
|
grid 会把前缀和数字排成上下两行 —— 改成一行居中的 flex */
|
||||||
|
.proto-root .jc-distbar > i { flex: 24; min-width: 34px; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.proto-root .jc-distbar > i.v-0, .proto-root .jc-distbar > i:not([class*=" v-"]) { display: none; }
|
||||||
|
.proto-root .jc-distbar > i.v-1 { flex: 1 } .proto-root .jc-distbar > i.v-2 { flex: 2 }
|
||||||
|
.proto-root .jc-distbar > i.v-3 { flex: 3 } .proto-root .jc-distbar > i.v-4 { flex: 4 }
|
||||||
|
.proto-root .jc-distbar > i.v-5 { flex: 5 } .proto-root .jc-distbar > i.v-6 { flex: 6 }
|
||||||
|
.proto-root .jc-distbar > i.v-7 { flex: 7 } .proto-root .jc-distbar > i.v-8 { flex: 8 }
|
||||||
|
.proto-root .jc-distbar > i.v-9 { flex: 9 } .proto-root .jc-distbar > i.v-10 { flex: 10 }
|
||||||
|
.proto-root .jc-distbar > i.v-11 { flex: 11 } .proto-root .jc-distbar > i.v-12 { flex: 12 }
|
||||||
|
.proto-root .jc-distbar > i.v-13 { flex: 13 } .proto-root .jc-distbar > i.v-14 { flex: 14 }
|
||||||
|
.proto-root .jc-distbar > i.v-15 { flex: 15 } .proto-root .jc-distbar > i.v-16 { flex: 16 }
|
||||||
|
.proto-root .jc-distbar > i.v-17 { flex: 17 } .proto-root .jc-distbar > i.v-18 { flex: 18 }
|
||||||
|
.proto-root .jc-distbar > i.v-19 { flex: 19 } .proto-root .jc-distbar > i.v-20 { flex: 20 }
|
||||||
|
/* 段里那句「A · 5」的前缀 —— 类别是这一段的固有属性,不必从数据里取 */
|
||||||
|
.proto-root .jc-distbar > i.a::before { content: "A · "; }
|
||||||
|
.proto-root .jc-distbar > i.b::before { content: "B · "; }
|
||||||
|
.proto-root .jc-distbar > i.c::before { content: "C · "; }
|
||||||
|
/* 判定表命中行:基座把命中的那一条注入成 v-r1/r2/r3,壳上带 class,行自己认 */
|
||||||
|
.proto-root .jc-dmn.v-r1 { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.11); }
|
||||||
|
.proto-root .jc-dmn.v-r2 { --tone: var(--brand); --tone-soft: rgba(51,112,255,.09); }
|
||||||
|
.proto-root .jc-dmn.v-r3 { --tone: var(--ok); --tone-soft: rgba(39,135,23,.10); }
|
||||||
|
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 {
|
||||||
|
position: relative; background: var(--tone-soft); color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1::before, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2::before, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3::before {
|
||||||
|
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--tone);
|
||||||
|
}
|
||||||
|
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-c, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-c, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-c { color: var(--ink); }
|
||||||
|
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-i, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-i, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-i { color: var(--tone); }
|
||||||
|
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-c b, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-c b, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-c b { color: var(--tone); font-weight: 800; }
|
||||||
|
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-t, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-t, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-t { color: var(--ink); }
|
||||||
|
.proto-root .jc-dmn.v-r1 .jc-dmn-r.r1 .jc-dmn-t code, .proto-root .jc-dmn.v-r2 .jc-dmn-r.r2 .jc-dmn-t code, .proto-root .jc-dmn.v-r3 .jc-dmn-r.r3 .jc-dmn-t code {
|
||||||
|
color: #fff; background: var(--tone); border-color: var(--tone);
|
||||||
|
}
|
||||||
|
/* 判定那一列:两句话都写在模版里,哪一句现身**由壳上那个 v-rN 决定** ——
|
||||||
|
模版仍然不许算,而「命中 / 未求值」这层判断留在样式表。
|
||||||
|
一条都没命中(算不出去向)时壳上没有 v-rN,两句都不出:不冒充判过。 */
|
||||||
|
.proto-root .jc-dmn .jc-dmn-v > .y, .proto-root .jc-dmn .jc-dmn-v > .n { display: none; }
|
||||||
|
.proto-root .jc-dmn.v-r1 .r1 .jc-dmn-v > .y, .proto-root .jc-dmn.v-r2 .r2 .jc-dmn-v > .y, .proto-root .jc-dmn.v-r3 .r3 .jc-dmn-v > .y {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px; font-weight: 800; color: var(--tone);
|
||||||
|
}
|
||||||
|
.proto-root .jc-dmn.v-r1 .r2 .jc-dmn-v > .n, .proto-root .jc-dmn.v-r1 .r3 .jc-dmn-v > .n,
|
||||||
|
.proto-root .jc-dmn.v-r2 .r1 .jc-dmn-v > .n, .proto-root .jc-dmn.v-r2 .r3 .jc-dmn-v > .n,
|
||||||
|
.proto-root .jc-dmn.v-r3 .r1 .jc-dmn-v > .n, .proto-root .jc-dmn.v-r3 .r2 .jc-dmn-v > .n { display: inline; color: var(--ink-4); }
|
||||||
|
.proto-root .jc-verdict.v-r1 { --tone: var(--warn-accent); --tone-soft: rgba(255,136,0,.14); --tone-line: #ffd9a8; }
|
||||||
|
.proto-root .jc-verdict.v-r2 { --tone: var(--brand); --tone-soft: rgba(51,112,255,.13); --tone-line: var(--brand-soft); }
|
||||||
|
.proto-root .jc-verdict.v-r3 { --tone: var(--ok); --tone-soft: rgba(39,135,23,.13); --tone-line: #bfedb4; }
|
||||||
|
/* 圆标里放图标不放首字母 —— 首字母得算,而画一个算错的缩写不如画一个说得清类别的图标 */
|
||||||
|
.proto-root .jc-logo svg, .proto-root .jc-face svg, .proto-root .jc-rp-ava svg { width: 17px; height: 17px; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
/** 一枚图标 —— 与原型同一写法:`use` 引精灵里的 symbol */
|
||||||
|
const ico = (name: string, size = 11): string =>
|
||||||
|
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><use href="#i-${name}"/></svg>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一份件的外壳 —— **照原型的三层壳**:`.proto-root > .app-shell > .jc-stage > article.jc-report`。
|
||||||
|
* 少一层,整张样式表(选择器带 `.proto-root .app-shell` 前缀)一条都不生效。
|
||||||
|
*/
|
||||||
|
const doc = (input: {
|
||||||
|
badge: string;
|
||||||
|
kicker: string;
|
||||||
|
title: string;
|
||||||
|
titleRef: string;
|
||||||
|
/** 题头右上:在办亮灯(jc-rp-wait);办结那一枚印章由归档态自己带 */
|
||||||
|
state: string;
|
||||||
|
body: string;
|
||||||
|
foot: string;
|
||||||
|
}): string => `<style>${PRD_CSS}${BIND_CSS}</style>
|
||||||
|
${PRD_SPRITE}
|
||||||
|
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
|
||||||
|
<article class="jc-report">
|
||||||
|
<header class="jc-rp-head">
|
||||||
|
<div class="jc-rp-badge">${ico(input.badge, 17)}</div>
|
||||||
|
<div class="jc-rp-tit">
|
||||||
|
<em>${input.kicker}</em>
|
||||||
|
<h3>${input.title} <span data-slot="${input.titleRef}"></span></h3>
|
||||||
|
</div>
|
||||||
|
${input.state}
|
||||||
|
</header>
|
||||||
|
<div class="jc-rp-bd">${input.body}</div>
|
||||||
|
<footer class="jc-rp-foot">${input.foot}</footer>
|
||||||
|
</article>
|
||||||
|
</div></div></div>`;
|
||||||
|
|
||||||
|
/** 章节头 —— 原型的 `jc-rp-part`:小标签 + 细线 + 右端小签 */
|
||||||
|
const part = (label: string, aside = '', tone = ''): string =>
|
||||||
|
`<div class="jc-rp-part"><b>${label}</b><i class="ln"></i>${aside === '' ? '' : `<em class="${tone}">${aside}</em>`}</div>`;
|
||||||
|
|
||||||
|
/** 落款 —— 署名取任务负责人(基座填);右端那枚签写「这份件在流里的位置」,不写状态 */
|
||||||
|
const sign = (note: string, tag = ''): string =>
|
||||||
|
`<div class="jc-rp-sign">
|
||||||
|
<span class="jc-rp-ava">${ico('user', 15)}</span>
|
||||||
|
<div><b data-slot="case:owner"></b><span>${note}</span></div>
|
||||||
|
</div>${tag === '' ? '' : `<span class="jc-done-tag">${ico('git-branch', 13)}${tag}</span>`}`;
|
||||||
|
|
||||||
|
/* ==================== 共用件 ==================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 来源信件 —— 原型的 `jc-letter`。正文 `pre-wrap` 原样照录:摘要过的原文就不是原文了。
|
||||||
|
* 收件签旁那枚归属签(通道入站 → 归了谁)与右端入站时刻都由基座填 ——
|
||||||
|
* 让活动自报「归属解析 → 冯兵」,等于让被指派的人自己说自己是谁。
|
||||||
|
*/
|
||||||
|
const LETTER = `<article class="jc-letter">
|
||||||
|
<div class="jc-letter-h">
|
||||||
|
<span class="jc-letter-dir">${ico('mail-open', 12)}收件</span>
|
||||||
|
<span class="jc-chip"><b data-slot="case:origin"></b> · 归属解析 → <b data-slot="case:owner"></b></span>
|
||||||
|
<code data-slot="case:openedAt"></code>
|
||||||
|
</div>
|
||||||
|
<div class="jc-letter-meta">
|
||||||
|
<p><i>发件人</i><span data-slot="contact_name"></span><code data-slot="customerEmail"></code></p>
|
||||||
|
</div>
|
||||||
|
<h4 class="jc-letter-sub" data-slot="subject"></h4>
|
||||||
|
<p class="jc-letter-body" data-slot="body"></p>
|
||||||
|
<div class="jc-atts" data-rows="attachments">
|
||||||
|
<span class="jc-att">${ico('sheet', 13)}<code data-slot="name"></code><span data-slot="note"></span></span>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 受理要素四卡 —— 原型的 `jc-four`。
|
||||||
|
* `kind` 决定第四张卡下面那条刻度条按什么上色:过程屏按缺口(红格 = 必填未补),
|
||||||
|
* 归档屏按状况标(A 绿 / B 蓝 / C 橙)。**一格一条料**,每格都对得上清单里的一行。
|
||||||
|
*/
|
||||||
|
const four = (kind: 'gap' | 'type'): string => `<div class="jc-four">
|
||||||
|
<div class="jc-fcard">
|
||||||
|
<span class="jc-fcard-k"><i>${ico('building')}</i>客户公司</span>
|
||||||
|
<div class="jc-fcard-b">
|
||||||
|
<span class="jc-logo">${ico('building', 17)}</span>
|
||||||
|
<div class="jc-fcard-t"><b data-slot="customer_name"></b><span data-slot="customer_kind"></span></div>
|
||||||
|
</div>
|
||||||
|
<span class="jc-fcard-note">已命中客户资料库 <code class="jc-mono" data-slot="customer_code"></code></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="jc-fcard t-teal">
|
||||||
|
<span class="jc-fcard-k"><i>${ico('user')}</i>联系人</span>
|
||||||
|
<div class="jc-fcard-b">
|
||||||
|
<span class="jc-face">${ico('user', 17)}</span>
|
||||||
|
<div class="jc-fcard-t">
|
||||||
|
<b data-slot="contact_name"></b>
|
||||||
|
<div class="jc-mailrow"><code data-slot="customerEmail"></code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="jc-fcard-note">采购口 · 本单对外发件收件人</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="jc-fcard t-ok">
|
||||||
|
<span class="jc-fcard-k"><i>${ico('history')}</i>客户属性</span>
|
||||||
|
<span class="jc-tagbig">${ico('shield-check', 13)}<span data-slot="customer_kind"></span></span>
|
||||||
|
<span class="jc-fcard-note">
|
||||||
|
成交台账尚未接入 —— 历史价由下游定价环节现查;
|
||||||
|
新客户走首单口径,<b>不得取锚点</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="jc-fcard${kind === 'type' ? ' t-ok' : ''}">
|
||||||
|
<span class="jc-fcard-k"><i>${ico('boxes')}</i>有效 PN</span>
|
||||||
|
<div class="jc-count"><b data-slot="line_total"></b><span>/ <b data-slot="bom_total"></b> 行 BOM</span></div>
|
||||||
|
<div class="jc-segmeter" data-rows="mine:quote_intake.record">
|
||||||
|
<i data-class-of="${kind === 'type' ? 'type_tag' : 'missing_key_fields'}"></i>
|
||||||
|
</div>
|
||||||
|
<span class="jc-fcard-note">
|
||||||
|
${
|
||||||
|
kind === 'type'
|
||||||
|
? '一格一条料 · 按状况标着色 · <b data-slot="line_total"></b> 条已交下游'
|
||||||
|
: '一格一条料 · 红格是必填未补 <b data-slot="missing_key_total"></b> 条'
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有效 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 `<td class="${cls}" data-h="${head}"><span class="jc-vcell ${style}" data-slot="${key}" data-col="${key}" data-blank></span></td>`;
|
||||||
|
};
|
||||||
|
const req = mode === 'final' ? '' : 'req';
|
||||||
|
/*
|
||||||
|
* 序号那一格在编辑态换成「撤掉这一行」的 × —— 一列两用,不为它单开一列。
|
||||||
|
* 只读时它是行号(CSS 计数器画的),编辑时人要的是「这一行不该在」的那个动作。
|
||||||
|
*/
|
||||||
|
const del = `<td class="jc-rn"><button type="button" class="jc-rowdel"
|
||||||
|
data-edit-del="quote_intake.record" title="撤掉这一行">×</button></td>`;
|
||||||
|
return `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width:36px;min-width:36px"></th>
|
||||||
|
${mode === 'final' ? '<th style="width:44px;min-width:44px">类</th>' : ''}
|
||||||
|
<th class="${req}" style="min-width:178px">PN</th>
|
||||||
|
<th style="min-width:88px">品类</th>
|
||||||
|
<th class="${req}" style="min-width:78px">年用量</th>
|
||||||
|
<th style="min-width:140px">制造商</th>
|
||||||
|
<th style="min-width:84px">目标价</th>
|
||||||
|
<th style="min-width:200px;width:100%">备注</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody data-rows="mine:quote_intake.record" data-edit="quote_intake.record"><tr>
|
||||||
|
${del}
|
||||||
|
${mode === 'final' ? '<td class="jc-ty" data-h="分类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag" data-col="type_tag"></span></td>' : ''}
|
||||||
|
${cell('pn', 'PN', 'req', 'mono')}
|
||||||
|
${cell('category', '品类', 'opt')}
|
||||||
|
${cell('eau', '年用量', 'req', 'mono')}
|
||||||
|
${cell('brand', '制造商', 'opt')}
|
||||||
|
${cell('target_price', '目标价', 'opt', 'num')}
|
||||||
|
${cell('remark', '备注', 'opt')}
|
||||||
|
</tr></tbody>
|
||||||
|
</table></div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 编辑态那条提示带 —— 原型的 `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 => `<div class="jc-editgate" data-edit-for="${address}">
|
||||||
|
<span class="jc-editgate-t">${ico('lock', 12)}${what}<b>已锁定</b> —— 看的时候不误碰,要改按右边</span>
|
||||||
|
<button type="button" class="jc-egbtn open" data-edit-open="${address}">${ico('pen', 12)}编辑</button>
|
||||||
|
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
|
||||||
|
<button type="button" class="jc-egbtn save" data-edit-save="${address}">${ico('check', 12)}保存</button>
|
||||||
|
</div>
|
||||||
|
<div class="jc-editbar">${ico('pen', 14)}
|
||||||
|
<span>改哪一格点哪一格:<b>整份清单每一格都能覆盖</b> —— AI 抽错的直接改,缺的补上;
|
||||||
|
多出来的行按右边的 × 撤掉,漏了的用底下的「加一行」补。
|
||||||
|
红框是必填未补。改动只落任务草稿层,<b>保存不等于放行</b>。</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表尾那条「加一行」—— **只在编辑态现身**(样式表按根节点的 `editing` 决定)。
|
||||||
|
*
|
||||||
|
* 为什么人得能加行:手改本来就是**兜 AI 的错**的那一手,而错不止「这一格填错了」——
|
||||||
|
* 「BOM 里有而它没抽出来」是同一类错,只给改格子就没有出口。
|
||||||
|
*/
|
||||||
|
const addRowBar = (address: string): string => `<div class="jc-addrow">
|
||||||
|
<button type="button" class="jc-egbtn ghost" data-edit-add="${address}">${ico('plus', 12)}加一行</button>
|
||||||
|
<em>照末行的样子添一行空的;主键(型号 PN)填了才存得下,不填这一行不落库。</em>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ── 这里**去掉了「本轮不报」那条横幅**(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 = `<div class="jc-dist">
|
||||||
|
<div class="jc-distbar">
|
||||||
|
<i class="a" data-class-of="quote_intake_record_type_tag_a" data-slot="quote_intake_record_type_tag_a"></i>
|
||||||
|
<i class="b" data-class-of="quote_intake_record_type_tag_b" data-slot="quote_intake_record_type_tag_b"></i>
|
||||||
|
<i class="c" data-class-of="quote_intake_record_type_tag_c" data-slot="quote_intake_record_type_tag_c"></i>
|
||||||
|
</div>
|
||||||
|
<div class="jc-distlg">
|
||||||
|
<span><em class="a"></em>A 类 · 行情完备 <b data-slot="quote_intake_record_type_tag_a"></b></span>
|
||||||
|
<span><em class="b"></em>B 类 · 需寻源询价 <b data-slot="quote_intake_record_type_tag_b"></b></span>
|
||||||
|
<span><em class="c"></em>C 类 · 需替代选型 <b data-slot="quote_intake_record_type_tag_c"></b></span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
/* ==================== 局面一 · 受理中(阶段2 待处理页嵌的那份)==================== */
|
||||||
|
|
||||||
|
export const INTAKE_OVERVIEW = doc({
|
||||||
|
badge: 'file-text',
|
||||||
|
kicker: '统一报价 · A1 受理归一 · 过程成果',
|
||||||
|
title: '询价受理需求',
|
||||||
|
titleRef: 'rfq_ref',
|
||||||
|
state:
|
||||||
|
'<span class="jc-rp-wait warn">缺必填 <b data-slot="missing_key_total"></b> · 缺选填 <b data-slot="missing_minor_total"></b></span>',
|
||||||
body:
|
body:
|
||||||
kvCard('询价来源', [
|
part('来源 · 客户 RFQ 邮件', '<span data-slot="case:openedAt"></span> 入站', 'jc-mono') +
|
||||||
['客户编码', 'customer_code', 'mono'],
|
LETTER +
|
||||||
['询价单号', 'rfqRef', 'mono'],
|
`<div class="jc-derive">${ico('info', 14)}
|
||||||
['主题', 'subject'],
|
<span>下方受理要素与 PN 清单,均由 AI 从这封来函与附件 BOM 抽取归一而来 —— 正文里的口径要求
|
||||||
]) +
|
(币种 / 贸易条款 / 交期单位 / 认证 / 预授权替代 / 本轮跳哪条)已逐条落到对应字段与备注;
|
||||||
kvCard('归一结果', [
|
附件那一行写的是<b>基座读取的结果</b>,读得出文本才作数。</span>
|
||||||
['型号 PN', 'pn', 'mono'],
|
</div>` +
|
||||||
['厂牌', 'brand'],
|
part('受理要素', 'F-01 完整度自检') +
|
||||||
['年用量 EAU', 'eau'],
|
four('gap') +
|
||||||
['目标价', 'target_price'],
|
part('有效 PN 清单', '<b data-slot="line_total"></b> 条 · 已剔除 <b data-slot="skipped_total"></b>') +
|
||||||
['交期', 'lead_time'],
|
editGate('quote_intake.record', '有效 PN 清单') +
|
||||||
['缺关键字段', 'missing_key_fields'],
|
listTable('draft') +
|
||||||
]) +
|
addRowBar('quote_intake.record') +
|
||||||
noteCard('这一屏在说什么', '归一由模型从询价原文抽取,逐条落<b>任务级草稿层</b>:本任务可见、正式版不可见、不产生数据事件。放行前的一切都还改得动。'),
|
`<div class="jc-drop">${ico('info', 13)}
|
||||||
|
<span>带 * 的两列是<b>必填</b>(PN / 年用量):缺任一按 F-01 退回补全,不许放行;
|
||||||
|
其余列缺了标灰「选填」—— 先受理、后追问,<b>不为它们拦单</b>,但那一问不能省。
|
||||||
|
<b>原文没写的一律不补</b>:受理阶段编出来的数会一路流到定价和报价单上,越往后越难发现。</span>
|
||||||
|
</div>`,
|
||||||
|
foot: sign('AI 拟稿,你审定 —— 要改哪一格,在「待处理」那张单的需求表上就地改', '统一报价 · A1 受理归一'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const INTAKE_CLASSIFIED = viewShell({
|
/* ==================== 局面三 · 已分类 ==================== */
|
||||||
title: '料号分类与判据',
|
|
||||||
kicker: 'A1 · 三分类 P-03',
|
export const INTAKE_CLASSIFIED = doc({
|
||||||
tone: 'ok',
|
badge: 'shield-check',
|
||||||
|
kicker: '统一报价 · A1 受理归一 · 三分类 P-03',
|
||||||
|
title: '料号分类表',
|
||||||
|
titleRef: 'rfq_ref',
|
||||||
|
state: '<span class="jc-rp-wait">已分级 · 未打标 <b data-slot="untyped_total"></b></span>',
|
||||||
body:
|
body:
|
||||||
kvCard('分类结论', [
|
part('料号分类', '三分类规则 P-03 · 逐条判定') +
|
||||||
['型号 PN', 'pn', 'mono'],
|
DIST +
|
||||||
['Type 标', 'type_tag', 'tag'],
|
part('逐条分级(判据 → 结论)', '按序求值 · 首个命中即出') +
|
||||||
['分级依据', 'basis'],
|
editGate('quote_intake.type_table', '料号分类表') +
|
||||||
]) +
|
`<div class="jc-tbl-wrap"><table class="jc-tbl-in">
|
||||||
kvCard('打标判据(DMN 入参)', [
|
<thead><tr>
|
||||||
['距上次报价', 'history_days'],
|
<th style="width:36px;min-width:36px"></th>
|
||||||
['行情稳定', 'market_stable'],
|
<th style="min-width:178px">型号 PN</th>
|
||||||
['生命周期', 'lifecycle'],
|
<th style="min-width:120px">距上次报价(天)</th>
|
||||||
['历史价命中', 'history_hit'],
|
<th style="min-width:88px">行情稳定</th>
|
||||||
]) +
|
<th style="min-width:96px">生命周期</th>
|
||||||
noteCard(
|
<th style="width:44px;min-width:44px">类</th>
|
||||||
'为什么是确定性的',
|
<th style="min-width:200px;width:100%">分级依据</th>
|
||||||
'三分类规则 P-03 是<b>决策表</b>不是模型判断:同样的判据必得同样的结论,零方差零 token,命中的规则行随出参留痕。' +
|
</tr></thead>
|
||||||
'A = 直接快答;B = 重估后报(转寻源);C = 转案处理(转替代选型)。',
|
<tbody data-rows="mine:quote_intake.type_table" data-edit="quote_intake.type_table"><tr>
|
||||||
),
|
<td class="jc-rn"></td>
|
||||||
|
<td data-h="型号 PN"><span class="jc-vcell mono" data-slot="pn" data-col="pn"></span></td>
|
||||||
|
<td data-h="距上次报价"><span class="jc-vcell num" data-slot="history_days" data-col="history_days"></span></td>
|
||||||
|
<td data-h="行情稳定"><span class="jc-vcell" data-slot="market_stable" data-col="market_stable"></span></td>
|
||||||
|
<td data-h="生命周期"><span class="jc-vcell" data-slot="lifecycle" data-col="lifecycle"></span></td>
|
||||||
|
<td class="jc-ty" data-h="分类"><span class="jc-tbadge" data-class-of="type_tag" data-slot="type_tag" data-col="type_tag"></span></td>
|
||||||
|
<td data-h="分级依据"><span class="jc-vcell" data-slot="basis" data-col="basis"></span></td>
|
||||||
|
</tr></tbody>
|
||||||
|
</table></div>` +
|
||||||
|
`<div class="jc-drop">${ico('info', 13)}
|
||||||
|
<span>三分类规则 P-03 是<b>决策表</b>,不是临场判断:同样的输入必得同样的结论,凭哪一条命中都留了痕。
|
||||||
|
<b>A</b> = 历史价 ≤ 90 天且行情稳;<b>B</b> = 无历史 / 过期 / 波动;<b>C</b> = EOL / 缺货 / 客户要求替代。
|
||||||
|
规则从上往下第一条命中即出 —— C 那一行排在最前,EOL 的料哪怕历史价很新也不判 A。
|
||||||
|
<b>拿不准的一律降 B</b>:错标一个 A,就是让一条本该重新核价的料带着旧价走到报价单上。</span>
|
||||||
|
</div>`,
|
||||||
|
foot: sign('状况标由判定表 P-03 求值 —— 判据与结论逐条可回核', '统一报价 · A1 受理归一'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const INTAKE_RELEASE = viewShell({
|
/* ==================== 局面四 · 待放行(阶段3 工作成果里的那份)==================== */
|
||||||
title: '统一受理记录 · 待放行',
|
|
||||||
kicker: 'A1 · 成果预览',
|
/**
|
||||||
tone: 'case',
|
* G2 分支网关 —— 三条互斥条件按序求值,只命中一条、只落一个活动。
|
||||||
|
*
|
||||||
|
* **命中哪一条不是这屏自己算的**:契约给每个出口写了判据,基座在放行那一刻按序求值,
|
||||||
|
* 把结果注入 `case:route*`(与发口用的是同一次判定)。这里只把它摆出来:
|
||||||
|
* 壳上带 `v-r1/r2/r3`,对应那一行自己上色。
|
||||||
|
* 摆成三张并列的去向卡就丢了「按序求值、只落一个」那层意思,所以照原型做成判定表。
|
||||||
|
*/
|
||||||
|
const dmnRow = (n: 1 | 2 | 3, cond: string, node: string, to: string, hit: string): string =>
|
||||||
|
`<div class="jc-dmn-r r${n}">
|
||||||
|
<span class="jc-dmn-i">${n}</span>
|
||||||
|
<span class="jc-dmn-c">${cond}</span>
|
||||||
|
<span class="jc-dmn-t"><code>${node}</code>${to}</span>
|
||||||
|
<span class="jc-dmn-v"><b class="y">${ico('check', 13)}命中 · ${hit}</b><b class="n">未命中 · 排他网关只落一口</b></span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const G2 = `<div class="jc-dmn" data-class-of="case:routeKey">
|
||||||
|
<div class="jc-dmn-h"><span>序</span><span>条件(按序求值)</span><span>去向</span><span>判定</span></div>
|
||||||
|
${dmnRow(1, '存在 <b>C 类</b>(需替代选型)', 'A2', '替代选型', 'C 类 <b data-slot="quote_intake_record_type_tag_c"></b> 条')}
|
||||||
|
${dmnRow(2, '无 C 类、存在 <b>B 类</b>(需寻源询价)', 'A3', '渠道寻源', 'B 类 <b data-slot="quote_intake_record_type_tag_b"></b> 条')}
|
||||||
|
${dmnRow(3, '全部为 <b>A 类</b>(行情完备)', 'A4', '成本定价', 'A 类 <b data-slot="quote_intake_record_type_tag_a"></b> 条')}
|
||||||
|
</div>
|
||||||
|
<div class="jc-verdict" data-class-of="case:routeKey">
|
||||||
|
<div class="jc-vd-l">
|
||||||
|
<span class="jc-vd-k">本次判定 · 按序求值,首个命中即出</span>
|
||||||
|
<div class="jc-vd-cond"><span data-slot="case:routeTo"></span></div>
|
||||||
|
<p class="jc-vd-why">去向是<b>分类表算出来的</b>:三条规则按序求值、首个命中即出,
|
||||||
|
在你签字那一刻现算。所以它不是谁点出来的一次手感 ——
|
||||||
|
<b>要改去向,就在上面的清单里改料号的类,这一格会跟着变。</b></p>
|
||||||
|
</div>
|
||||||
|
<span class="jc-vd-arrow">${ico('arrow-right', 26)}</span>
|
||||||
|
<div class="jc-vd-r">
|
||||||
|
<span class="jc-vd-k">转入活动</span>
|
||||||
|
<div class="jc-vd-node"><span data-slot="case:routeTo"></span></div>
|
||||||
|
<span class="jc-vd-who"><i>${ico('user', 11)}</i>下游负责人按运营分派落定</span>
|
||||||
|
<p class="jc-vd-next">放行即落地:草稿层合并进正式版、intake 口的数据事件此刻才发射,
|
||||||
|
这一口的载荷随之交给下游。<b>门前的都还改得动,门后的都算数了。</b></p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
export const INTAKE_RELEASE = doc({
|
||||||
|
badge: 'file-check',
|
||||||
|
kicker: '统一报价 · A1 受理归一 · 交付成果',
|
||||||
|
title: '询价受理记录',
|
||||||
|
titleRef: 'rfq_ref',
|
||||||
|
/*
|
||||||
|
* 题头这枚灯**在办与归档同一句话才算数**:这份件放行前嵌在放行卡上、放行后原样进
|
||||||
|
* 「工作成果」。写「待放行」进了档案就成了谎,写「受理完成」在放行前就是替人签了字 ——
|
||||||
|
* 所以只写这份件本身是什么:归一了几条、三分类已定。
|
||||||
|
*/
|
||||||
|
state: '<span class="jc-rp-wait">受理记录 · <b data-slot="line_total"></b> 条 · 三分类已定</span>',
|
||||||
body:
|
body:
|
||||||
tableCard(
|
/*
|
||||||
'本单交付',
|
* **来件原文留在归档件里**(2026-08-02 用户指出:「成果里为啥没有邮箱原文了」)。
|
||||||
'mine:quote_intake.record',
|
*
|
||||||
[
|
* 待处理与工作成果本来就是**同一份件**,差别只在编辑态开不开。
|
||||||
['型号', 'pn', 'mono'],
|
* 归档件少一块来件,就等于把「凭什么这么受理」的出处从档案里抽走了 ——
|
||||||
['厂牌', 'brand'],
|
* 三个月后回看这一单,九条料是怎么来的、客户那句「本轮跳过」在哪儿说的,
|
||||||
['年用量', 'eau'],
|
* 全都无从对起。件要自己说得清自己。
|
||||||
['目标价', 'target_price'],
|
*/
|
||||||
['交期', 'lead_time'],
|
part('来源 · 客户 RFQ 邮件', '<span data-slot="case:openedAt"></span> 入站', 'jc-mono') +
|
||||||
],
|
LETTER +
|
||||||
'只列<b>本单</b>写下的行 —— 别单放行过的记录不混进来。',
|
part('受理要素', 'F-01 完整度自检 · 通过', 'ok') +
|
||||||
) +
|
four('type') +
|
||||||
kvCard('分流去向', [
|
part('料号分类', '三分类规则 P-03 · 逐条判定') +
|
||||||
['Type 标', 'type_tag', 'tag'],
|
DIST +
|
||||||
['依据', 'basis'],
|
part('有效 PN 清单', '<b data-slot="line_total"></b> 条 · 已剔除 <b data-slot="skipped_total"></b>') +
|
||||||
]) +
|
editGate('quote_intake.record', '有效 PN 清单') +
|
||||||
noteCard(
|
listTable('final') +
|
||||||
'放行意味着什么',
|
addRowBar('quote_intake.record') +
|
||||||
'放行通过<b>即刻落地</b>:草稿层合并入正式版、数据事件此刻才发射,G2 网关按 Type 分流。' +
|
part('结论与去向', '分支判定 · 起单 ×1', 'ok') +
|
||||||
'放行是草稿态与事实态之间的那道门 —— 门前的都还改得动,门后的都算数了。',
|
G2 +
|
||||||
),
|
/*
|
||||||
|
* ── 这里**去掉了两张请求单表与那条对账说明**(2026-08-02 用户当场指出)──────
|
||||||
|
*
|
||||||
|
* 原来摆的是「开出的替代评估请求(交 A2)」「开出的询价请求(交 A3)」两张明细表,
|
||||||
|
* 外加一句「这两条等式在你签字前由系统当场对账,对不上直接拒收」。
|
||||||
|
*
|
||||||
|
* 两个理由:
|
||||||
|
* · 那句话**已经不成立** —— `route-alt` / `route-rfq` 两条对账判据同日删掉了
|
||||||
|
* (它们是内部一致性,该由引擎自己看住,不占放行卡)。件上还写着「系统当场对账」,
|
||||||
|
* 就是在说一件不再发生的事;
|
||||||
|
* · 这份件是**受理记录**:它答的是「这一单受理成了什么样」。派出去的两类单是
|
||||||
|
* **下游的输入**,不是这一单的成果 —— 要看它们,去 A2 / A3 那两条流水线上看。
|
||||||
|
* 摆在这儿只是把同一份数据在两处各画一遍。
|
||||||
|
*
|
||||||
|
* 「这一签下去开出了几张单」仍看得见:放行之后验收卡那一屏(落地结果)按表点名,
|
||||||
|
* 询价请求 / 替代评估请求各几行都在上面 —— 那才是它该出现的时刻与位置。
|
||||||
|
*/
|
||||||
|
part('放行后即执行的落地动作', '门前 → 门后') +
|
||||||
|
`<div class="jc-acts">
|
||||||
|
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||||
|
<div><b>草稿层合并进正式版</b> <span>本任务写下的受理记录、分类表、结论与两类请求单一并落账;
|
||||||
|
人工补全与就地修订的那几处随之落定。</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||||
|
<div><b>受理结果此刻才对外发出</b> <span>连同
|
||||||
|
<b data-slot="line_total"></b> 条有效料号与它们的分类一并交出 —— 放行之前,世界上什么都没有发生。</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="jc-act"><i>${ico('check', 11)}</i>
|
||||||
|
<div><b>按分类算出的去向落下游一单</b> <span>本次转入
|
||||||
|
<b data-slot="case:routeTo"></b>,已进对方的任务清单;另两条去向本次不起单。</span></div>
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
/*
|
||||||
|
* ── 这里**去掉了「受理结论」那张表**(2026-08-02 用户当场指出:「这个也多余」)──
|
||||||
|
*
|
||||||
|
* 摆的是 `bom_total / skipped_total / skipped_note / summary` 四格,而
|
||||||
|
* 前三格与同日删掉的那条「本轮不报」横幅说的是同一件事,
|
||||||
|
* 第四格是一句 AI 复述的总结 —— 底下的清单、分布条、去向横幅把该说的都说了,
|
||||||
|
* 再加一句转述只是让人多读一行。
|
||||||
|
*
|
||||||
|
* 一份件里同一个数出现两次,人得停下来对一遍两处一不一样 —— 那是白花的注意力。
|
||||||
|
*/
|
||||||
|
foot: sign('AI 拟稿 · 待审定放行 —— 要改哪一格,在这张放行单的成果表上就地改', '统一报价 · A1 受理归一 → 交下游'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||||
<case id="negotiate">
|
<case id="negotiate">
|
||||||
<caseFileModel>
|
<caseFileModel>
|
||||||
<caseFileItem id="draft" name="报价草案"/>
|
<caseFileItem id="quote_no" name="报价单号(开案即有)"/>
|
||||||
<caseFileItem id="quote" name="正式报价单"/>
|
<caseFileItem id="quote_total" name="报价行条数"/>
|
||||||
<caseFileItem id="reply" name="客户回音"/>
|
|
||||||
</caseFileModel>
|
</caseFileModel>
|
||||||
<casePlanModel id="plan" name="报价议价" autoComplete="true">
|
<casePlanModel id="plan" name="报价议价" autoComplete="true">
|
||||||
<sentry id="s_draft">
|
<sentry id="s_composed">
|
||||||
<onPart id="op_df" caseFileItemRef="draft"><standardEvent>create</standardEvent></onPart>
|
<onPart id="op_cp" caseFileItemRef="quote_total"><standardEvent>update</standardEvent></onPart>
|
||||||
</sentry>
|
<ifPart id="if_cp"><condition>quote_total > 0</condition></ifPart>
|
||||||
<sentry id="s_quote_ready">
|
|
||||||
<onPart id="op_qr" caseFileItemRef="quote"><standardEvent>update</standardEvent></onPart>
|
|
||||||
<ifPart id="if_qr"><condition>missingQuoteFields = 0</condition></ifPart>
|
|
||||||
</sentry>
|
|
||||||
<sentry id="s_sent">
|
|
||||||
<onPart id="op_st" caseFileItemRef="quote"><standardEvent>update</standardEvent></onPart>
|
|
||||||
<ifPart id="if_st"><condition>sent = true</condition></ifPart>
|
|
||||||
</sentry>
|
</sentry>
|
||||||
|
|
||||||
<stage id="S1" name="成单发出" autoComplete="true">
|
<!-- 落正式报价单:条款齐全才叫一份能发出去的件 -->
|
||||||
<entryCriterion id="ec_s1" sentryRef="s_draft"/>
|
<humanTask id="T_compose" name="落正式报价单:条款齐全,逐条落台账">
|
||||||
<humanTask id="T_compose" name="人工生成正式报价单">
|
<itemControl><repetitionRule id="r_cp"/></itemControl>
|
||||||
<itemControl><manualActivationRule id="m_cp"/><repetitionRule id="r_cp"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<humanTask id="T_send" name="人工发出报价单">
|
|
||||||
<entryCriterion id="ec_send" sentryRef="s_quote_ready"/>
|
|
||||||
<itemControl><manualActivationRule id="m_sd"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_sent" name="报价已发出"/>
|
|
||||||
|
|
||||||
<stage id="S2" name="跟进议价" autoComplete="true">
|
|
||||||
<entryCriterion id="ec_s2" sentryRef="s_sent"/>
|
|
||||||
<humanTask id="T_followup" name="人工按 D+3/D+7 跟进">
|
|
||||||
<itemControl><manualActivationRule id="m_fu"/><repetitionRule id="r_fu"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<planningTable id="pt_s2">
|
|
||||||
<humanTask id="T_revise" name="人工议价调整"/>
|
|
||||||
</planningTable>
|
|
||||||
</stage>
|
|
||||||
|
|
||||||
<humanTask id="T_release" name="人工议价结果验收放行">
|
|
||||||
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
|
||||||
</humanTask>
|
</humanTask>
|
||||||
|
|
||||||
<timerEventListener id="TL_d3" name="D+3 跟进">
|
<!-- 发出并等回音:**触达外界**,必须人工激活 -->
|
||||||
<timerExpression>P3D</timerExpression>
|
<humanTask id="T_send" name="发出报价并等客户回音">
|
||||||
</timerEventListener>
|
<entryCriterion id="ec_sd" sentryRef="s_composed"/>
|
||||||
<timerEventListener id="TL_d7" name="D+7 跟进">
|
<itemControl><manualActivationRule id="m_sd"/><repetitionRule id="r_sd"/></itemControl>
|
||||||
<timerExpression>P7D</timerExpression>
|
</humanTask>
|
||||||
</timerEventListener>
|
|
||||||
<userEventListener id="UL_reply" name="客户回音"/>
|
<humanTask id="T_release" name="议价结果验收放行">
|
||||||
|
<entryCriterion id="ec_rel" sentryRef="s_composed"/>
|
||||||
|
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||||
|
</humanTask>
|
||||||
</casePlanModel>
|
</casePlanModel>
|
||||||
</case>
|
</case>
|
||||||
</definitions>`;
|
</definitions>`;
|
||||||
|
|
||||||
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)—— 下游三条边读的就是它,不写整条流走不下去。`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const QUOTE = 'quote_negotiate.formal_quote';
|
|||||||
|
|
||||||
export default defineActivity({
|
export default defineActivity({
|
||||||
name: 'quote-negotiate',
|
name: 'quote-negotiate',
|
||||||
paradigm: 'flow',
|
|
||||||
|
|
||||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
||||||
const lines = await base.ledger.read(QUOTE);
|
const lines = await base.ledger.read(QUOTE);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { ActivityManifest } from '@contracts';
|
import type { ActivityManifest } from '@contracts';
|
||||||
import { NEGOTIATE_SKILLS } from './skills.ts';
|
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 { activityIdSchema } from '@contracts';
|
||||||
import { QUOTE_COMPOSED, QUOTE_RELEASE } from './views/report.ts';
|
import { QUOTE_COMPOSED, QUOTE_RELEASE } from './views/report.ts';
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ export const manifest: ActivityManifest = {
|
|||||||
identity: {
|
identity: {
|
||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
name: 'A6 · 报价议价',
|
name: 'A6 · 报价议价',
|
||||||
packageVersion: '0.1.5',
|
packageVersion: '2.1.0',
|
||||||
category: '统一报价',
|
category: '统一报价',
|
||||||
sdkVersion: '0.1.0-m5',
|
sdkVersion: '0.1.0-m5',
|
||||||
checksum: 'dev-checksum-quote-negotiate',
|
checksum: 'dev-checksum-quote-negotiate',
|
||||||
@@ -30,8 +30,8 @@ export const manifest: ActivityManifest = {
|
|||||||
input: {
|
input: {
|
||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['customerCode', 'items'],
|
required: ['rfqRef', 'customerCode', 'items'],
|
||||||
properties: { customerCode: { type: 'string' }, items: { type: 'array' } },
|
properties: { rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' }, customerCode: { type: 'string' }, items: { type: 'array' } },
|
||||||
},
|
},
|
||||||
messageAllowed: true,
|
messageAllowed: true,
|
||||||
},
|
},
|
||||||
@@ -42,23 +42,67 @@ export const manifest: ActivityManifest = {
|
|||||||
{
|
{
|
||||||
group: 'negotiation',
|
group: 'negotiation',
|
||||||
ports: [
|
ports: [
|
||||||
{ name: 'won', payload: { type: 'object' }, description: '成交 → 转订单履约(ORM)' },
|
/*
|
||||||
{ name: 'revise', payload: { type: 'object' }, description: '有异议 → 返回调整 A4' },
|
* 载荷里**必须带 `outcome`** —— 流程包 E10/E11/E12 三条出边读的就是它
|
||||||
{ name: 'lost', payload: { type: 'object' }, description: '拒绝/超时 → 转 A7 情报归档' },
|
* (`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: [
|
requiredDeliverables: [
|
||||||
{ key: 'formal-quote', label: '正式报价单(PDF · 中英双语)' },
|
{ key: 'formal-quote', label: '正式报价单(PDF · 中英双语)', ledger: `${DOMAIN}.formal_quote` },
|
||||||
{ key: 'followup', label: 'D+3/D+7 跟进提醒' },
|
// 跟进不单独立表 —— 建了几条记在议价结论的 followup_total 上
|
||||||
|
{ key: 'followup', label: 'D+3/D+7 跟进提醒', ledger: `${DOMAIN}.verdict` },
|
||||||
],
|
],
|
||||||
|
/*
|
||||||
|
* 机检谓词读的是 `verdict` 那张**汇总表**的列(照 A1 的口径)。
|
||||||
|
* 上一版的名字(missingQuoteFields / validityDays / followupSet)**台账里根本没有** —— S-FEEL 拿未绑定的名字
|
||||||
|
* 比大小求值为假,判据永远达不了标,submit 门永远拒,连拒五拍熔断。
|
||||||
|
* 判据的名字必须落在台账上,否则「机检」是一句空话(2026-07-31 销账)。
|
||||||
|
*/
|
||||||
acceptanceCriteria: [
|
acceptanceCriteria: [
|
||||||
{ key: 'quote-complete', label: '单价/交期/SPQ/MOQ/贸易条款/有效期齐备', level: 'L1', predicate: 'missingQuoteFields = 0' },
|
{ key: 'quote-complete', label: '单价/交期/SPQ/MOQ/贸易条款/有效期齐备', level: 'L1', predicate: 'missing_field_total = 0' },
|
||||||
{ key: 'validity', label: '按报价有效期 P-04 注明有效期', level: 'L2', predicate: 'validityDays > 0' },
|
{ key: 'validity', label: '按报价有效期 P-04 注明有效期', level: 'L2', predicate: 'validity_days > 0' },
|
||||||
{ key: 'followup-set', label: '已建 D+3/D+7 跟进 —— 没有跟进的报价等于没报', level: 'L2', predicate: 'followupSet = true' },
|
{ key: 'followup-set', label: '已建 D+3/D+7 跟进 —— 没有跟进的报价等于没报', level: 'L2', predicate: 'followup_total >= 2' },
|
||||||
{ key: 'bd-release', label: 'BD 放行', level: 'L5' },
|
{ key: 'bd-release', label: 'BD 放行', level: 'L5' },
|
||||||
],
|
],
|
||||||
|
revisableAtRelease: [],
|
||||||
prohibitions: [
|
prohibitions: [
|
||||||
{ key: 'no-break-line', statement: '客户议价超出授权范围的,回 A5 重批,不私自破线' },
|
{ key: 'no-break-line', statement: '客户议价超出授权范围的,回 A5 重批,不私自破线' },
|
||||||
{ key: 'no-send-before-release', statement: '报价单在放行前不得发出 —— 写类动作集中在放行时落地' },
|
{ key: 'no-send-before-release', statement: '报价单在放行前不得发出 —— 写类动作集中在放行时落地' },
|
||||||
@@ -85,9 +129,12 @@ export const manifest: ActivityManifest = {
|
|||||||
{
|
{
|
||||||
domain: DOMAIN,
|
domain: DOMAIN,
|
||||||
object: 'formal_quote',
|
object: 'formal_quote',
|
||||||
|
kind: 'record',
|
||||||
|
caseKey: 'rfq_ref',
|
||||||
label: '正式报价单',
|
label: '正式报价单',
|
||||||
fields: [
|
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: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
||||||
{ name: 'unit_price', type: 'number', primaryKey: false, masked: false, label: '单价CNY' },
|
{ name: 'unit_price', type: 'number', primaryKey: false, masked: false, label: '单价CNY' },
|
||||||
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
|
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
|
||||||
@@ -98,8 +145,9 @@ export const manifest: ActivityManifest = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
domain: 'shared',
|
domain: 'market',
|
||||||
object: 'history_price',
|
object: 'price_history',
|
||||||
|
kind: 'master',
|
||||||
label: '历史价格情报(共享域)',
|
label: '历史价格情报(共享域)',
|
||||||
fields: [
|
fields: [
|
||||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
{ 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 },
|
{ 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: [
|
listeners: [
|
||||||
{
|
{
|
||||||
@@ -134,6 +204,8 @@ export const manifest: ActivityManifest = {
|
|||||||
cmmnXml: CMMN_XML,
|
cmmnXml: CMMN_XML,
|
||||||
narrative: NARRATIVE,
|
narrative: NARRATIVE,
|
||||||
dmn: { drd: [], tables: [] },
|
dmn: { drd: [], tables: [] },
|
||||||
|
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
|
||||||
|
steps: NEGOTIATE_STEPS,
|
||||||
},
|
},
|
||||||
skills: NEGOTIATE_SKILLS,
|
skills: NEGOTIATE_SKILLS,
|
||||||
viewTemplates: [
|
viewTemplates: [
|
||||||
|
|||||||
@@ -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<readonly [string, string, string]>): string {
|
||||||
|
return `<ul class="steps">${rows
|
||||||
|
.map(
|
||||||
|
([no, term, gloss]) =>
|
||||||
|
`<li><span class="n">${no}</span><span><span class="t">${term}</span> <span class="d">${gloss}</span></span></li>`,
|
||||||
|
)
|
||||||
|
.join('')}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键值格 —— 报价单的条款是**一份单据的正面**,六样并排比一张六列横向滚动的表好读。
|
||||||
|
* `@sdk` 的 `kvCard` 自带一个「·」号章节,与本页「一/二/三」的编号排不到一起,
|
||||||
|
* 所以这里只借外壳已有的 `.kv` 版式,章节仍交给 `section` 统一编号。
|
||||||
|
*/
|
||||||
|
function kvGrid(items: ReadonlyArray<readonly [string, string, ('mono' | 'tag')?]>): string {
|
||||||
|
return `<div class="kv">${items
|
||||||
|
.map(([label, path, style]) => {
|
||||||
|
const cls = style === undefined ? '' : ` class="${style === 'mono' ? 'mono' : 'tag'}"`;
|
||||||
|
return `<div><em>${label}</em><b${cls} data-slot="${path}"></b></div>`;
|
||||||
|
})
|
||||||
|
.join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 局面一:S1 成单 —— 人此刻要核的是「六样齐没齐、这一版能不能发」 */
|
||||||
export const QUOTE_COMPOSED = viewShell({
|
export const QUOTE_COMPOSED = viewShell({
|
||||||
title: '正式报价单',
|
title: '正式报价单(草稿)',
|
||||||
kicker: 'A6 · 报价议价',
|
kicker: 'A6 · 报价议价 · S1 成单',
|
||||||
tone: 'brand',
|
tone: 'brand',
|
||||||
|
icon: 'handshake',
|
||||||
|
stamp: { kind: 'live', text: '成单中' },
|
||||||
|
sign: 'A6 报价议价 · 正式报价单(本单草稿)',
|
||||||
body:
|
body:
|
||||||
kvCard('报价抬头', [
|
/* `items` 是起单入参的数组,基座渲染数组时给的是条数 —— 正好是「这一单要报几条」 */
|
||||||
['报价单号', 'quoteNo', 'mono'],
|
statsBand([
|
||||||
['客户编码', 'customerCode', 'mono'],
|
['报价条目', 'items'],
|
||||||
['型号 PN', 'pn', 'mono'],
|
['单价 CNY', 'unit_price'],
|
||||||
]) +
|
|
||||||
kvCard('报价条款', [
|
|
||||||
['单价', 'unit_price'],
|
|
||||||
['交期', 'lead_time'],
|
['交期', 'lead_time'],
|
||||||
['最小包装量 SPQ', 'spq'],
|
|
||||||
['最小起订量 MOQ', 'moq'],
|
|
||||||
['贸易条款', 'trade_terms'],
|
|
||||||
['有效期', 'valid_until'],
|
['有效期', 'valid_until'],
|
||||||
]) +
|
]) +
|
||||||
noteCard('这一步还没碰外界', '报价单此刻只是<b>草稿</b>。发出动作是<b>写类</b>的,过程中只登记为「动作意向」——入参冻结、等放行,放行前一封信都不会发出去。'),
|
section('报价条款六样 —— 缺一不可',
|
||||||
});
|
kvGrid([
|
||||||
|
['单价 CNY', 'unit_price'],
|
||||||
export const QUOTE_RELEASE = viewShell({
|
['交期', 'lead_time'],
|
||||||
title: '正式报价单 · 放行即发出',
|
['最小包装量 SPQ', 'spq'],
|
||||||
kicker: 'A6 · 成果预览',
|
['最小起订量 MOQ', 'moq'],
|
||||||
tone: 'danger',
|
['贸易条款', 'trade_terms'],
|
||||||
body:
|
['有效期', 'valid_until'],
|
||||||
tableCard(
|
]) +
|
||||||
'本单报价',
|
noteOnly(
|
||||||
'mine:quote_negotiate.formal_quote',
|
'单价与交期<b>照审批通过的草案抄</b>,不做任何「顺手优化」—— 改价要重新过审批点。' +
|
||||||
[
|
'有效期按行情波动定,不要一律写「长期有效」:那等于把汇率与料价风险自己扛了。' +
|
||||||
|
'拿不准的项<b>宁可空着走补全</b>,不要填一个看起来合理的值。',
|
||||||
|
),
|
||||||
|
{ text: '缺一样多一轮' },
|
||||||
|
) +
|
||||||
|
section('本单已成形的报价行',
|
||||||
|
tableOnly('mine:quote_negotiate.formal_quote', [
|
||||||
['报价单号', 'quote_no', 'mono'],
|
['报价单号', 'quote_no', 'mono'],
|
||||||
['型号', 'pn', 'mono'],
|
['型号 PN', 'pn', 'mono'],
|
||||||
['单价', 'unit_price'],
|
['单价', 'unit_price'],
|
||||||
['交期', 'lead_time'],
|
['交期', 'lead_time'],
|
||||||
],
|
['有效期', 'valid_until'],
|
||||||
|
]),
|
||||||
|
{ text: '只列本单写下的行' },
|
||||||
) +
|
) +
|
||||||
noteCard(
|
section('议价的节拍 —— 一轮一版',
|
||||||
'按下放行会发生什么',
|
ladder([
|
||||||
'① <b>落地预检</b>先跑:交付物齐备、动作意向可执行、授权在契约边界内 —— 预检不过不发卡。' +
|
['1', '写成正式报价单', '中英双语,六样齐备'],
|
||||||
'② 放行通过<b>即刻落地</b>:台账回写 + 出站发送(报价单发给客户),动作附幂等键。' +
|
['2', '人工按下发出', '触达客户的一下,系统不替人做这个决定'],
|
||||||
'③ <b>落地收敛</b>后输出口才发射 —— 下游只见落地完成的任务;已成动作留存<b>不回滚</b>。' +
|
['3', 'D+3 提醒到点', '没有跟进的报价等于没报'],
|
||||||
'④ 出口三选一(成交 / 有异议 / 拒绝)<b>恰发一口</b>,随放行一并确认,G4 网关按它分流。',
|
['4', 'D+7 提醒到点', '第二记催办,仍无回音即等超时'],
|
||||||
|
['5', '客户回音 → 议价调整', '改价、改交期、换料都是新一版,重新走发出'],
|
||||||
|
]),
|
||||||
|
{ text: '往来看得出先后' },
|
||||||
|
) +
|
||||||
|
section('案例当下进程', stagesOnly('case:stages'), { text: 'Enabled ≠ Active' }) +
|
||||||
|
section('这一步还没碰外界',
|
||||||
|
verdict('报价单此刻只是草稿 —— 一封信都还没发出去', 'wait', '发出这一下必须人工按') +
|
||||||
|
noteOnly(
|
||||||
|
'发出是<b>写类动作</b>:过程中只登记为「动作意向」—— 入参冻结、等放行,放行前不触达世界。' +
|
||||||
|
'预授权是逐项列举的:催办模板可即时发,<b>报价单不行</b>。',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 局面二:待放行 —— 按下的那一刻信就发出去,这是最后一次能改的机会 */
|
||||||
|
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(
|
||||||
|
'这一节<b>不是本单成果</b>,是共享域里同一料号的历次报价(含既往单)—— 摆在这里只做一件事:' +
|
||||||
|
'给这一轮的价一个参照系。报价日期在列上,先后按日期对;它由 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(
|
||||||
|
'三个出口对应三种后续,随放行<b>恰选一口</b>,下游网关读的就是它;<b>不选口不许放行</b> ——' +
|
||||||
|
'含糊的结果比没结果更坏。「拒绝」不是失败终态:原因回写历史情报后,下一单的三分类与利润模型会用上它。',
|
||||||
|
),
|
||||||
|
{ text: '下游只读这一口' },
|
||||||
|
) +
|
||||||
|
section('按下放行会发生什么',
|
||||||
|
verdict('放行即落地:台账回写 + 报价单出站发送', 'wait', '已成动作留存,不回滚') +
|
||||||
|
noteOnly(
|
||||||
|
'① <b>落地预检</b>先跑:交付物齐备、动作意向可执行、授权在契约边界内 —— 预检不过不发卡;' +
|
||||||
|
'② 放行通过<b>即刻落地</b>,出站动作附幂等键;' +
|
||||||
|
'③ <b>落地收敛</b>之后输出口才发射 —— 下游只见落地完成的任务。',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,81 +1,137 @@
|
|||||||
/**
|
/**
|
||||||
* A4 成本定价的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
* A4 成本定价的案例模型 —— **算价归判定表,超限那一下归人**(2026-07-31 照 A1 的状态机模式重做)。
|
||||||
*
|
*
|
||||||
* 2026-07-29 由步序声明(flow)改写而来。原六步各归其位:取价基准与逐条定价属「算价」,
|
* 与 A2/A3 的分工:那两个是「一次专家判断」,派一张执行单收回结论就够了;
|
||||||
* 偏离率与底价门禁属「自检」(两支 DMN:P-01 利润模型、P-02 底价门禁),
|
* 定价不同 —— 它**有确定性规则可依**(P-01 利润模型、P-02 底价门禁两支 DMN),
|
||||||
* 超限条目调人决策是**触达客户利益的一下**,必须人工激活;末尾一张验收单。
|
* 算价这件事该算不该问。所以它照 A1 的样子分步建模,而不是甩一张单给人。
|
||||||
|
*
|
||||||
|
* 三项:
|
||||||
|
* T_price —— 逐条算价并落草案(拟报单价 / 毛利 / 偏离度 / 门禁处置);
|
||||||
|
* T_approve —— **只在门禁判超限时入场**:触达客户利益的一下,人工激活,派决策卡;
|
||||||
|
* T_release —— 放行(人工激活)。
|
||||||
|
*
|
||||||
|
* 「超限才叫人」是这个活动的要害:每条都叫人,人就成了橡皮图章;一条都不叫,
|
||||||
|
* 底价门禁就是摆设。所以那一项挂前提(`s_over`),由判定表的结论决定它入不入场。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||||
<case id="pricing">
|
<case id="pricing">
|
||||||
|
<!--
|
||||||
|
案卷项的 id **就是任务视图里的键名**:
|
||||||
|
draft_total 是草案条数(算完没算完),over_total 是超限条数(要不要叫人)。
|
||||||
|
两个都由 T_price 写进台账,摊平到顶层后驱动前提。
|
||||||
|
-->
|
||||||
<caseFileModel>
|
<caseFileModel>
|
||||||
<caseFileItem id="records" name="待定价条目"/>
|
<caseFileItem id="pn" name="型号(开案即有)"/>
|
||||||
<caseFileItem id="basis" name="取价基准"/>
|
<caseFileItem id="draft_total" name="草案条数"/>
|
||||||
<caseFileItem id="draft" name="报价草案"/>
|
<caseFileItem id="over_total" name="超限条数"/>
|
||||||
<caseFileItem id="floors" name="底价表"/>
|
|
||||||
</caseFileModel>
|
</caseFileModel>
|
||||||
<casePlanModel id="plan" name="成本定价" autoComplete="true">
|
<casePlanModel id="plan" name="成本定价" autoComplete="true">
|
||||||
<sentry id="s_records">
|
|
||||||
<onPart id="op_rc" caseFileItemRef="records"><standardEvent>create</standardEvent></onPart>
|
|
||||||
</sentry>
|
|
||||||
<sentry id="s_basis">
|
|
||||||
<onPart id="op_bs" caseFileItemRef="basis"><standardEvent>create</standardEvent></onPart>
|
|
||||||
</sentry>
|
|
||||||
<sentry id="s_priced">
|
<sentry id="s_priced">
|
||||||
<onPart id="op_pd" caseFileItemRef="draft"><standardEvent>update</standardEvent></onPart>
|
<onPart id="op_pr" caseFileItemRef="draft_total"><standardEvent>update</standardEvent></onPart>
|
||||||
<ifPart id="if_pd"><condition>unpriced = 0</condition></ifPart>
|
<ifPart id="if_pr"><condition>draft_total > 0</condition></ifPart>
|
||||||
</sentry>
|
</sentry>
|
||||||
<sentry id="s_breach">
|
<!-- 超限才叫人:门禁判出有超限条目,这一项才入场 -->
|
||||||
<onPart id="op_br" caseFileItemRef="draft"><standardEvent>update</standardEvent></onPart>
|
<sentry id="s_over">
|
||||||
<ifPart id="if_br"><condition>breaches > 0</condition></ifPart>
|
<onPart id="op_ov" caseFileItemRef="over_total"><standardEvent>update</standardEvent></onPart>
|
||||||
|
<ifPart id="if_ov"><condition>over_total > 0</condition></ifPart>
|
||||||
</sentry>
|
</sentry>
|
||||||
|
|
||||||
<stage id="S1" name="算价" autoComplete="true">
|
<!-- 算价:判定表说了算,不是临场判 -->
|
||||||
<entryCriterion id="ec_s1" sentryRef="s_records"/>
|
<humanTask id="T_price" name="逐条算价:取基准、算毛利与偏离度、过底价门禁">
|
||||||
<humanTask id="T_basis" name="人工取价基准">
|
<itemControl><repetitionRule id="r_pr"/></itemControl>
|
||||||
<itemControl><manualActivationRule id="m_bs"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<decisionTask id="D_price" name="逐条定价" decisionRef="p01-margin">
|
|
||||||
<entryCriterion id="ec_price" sentryRef="s_basis"/>
|
|
||||||
</decisionTask>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_priced" name="全条已定价"/>
|
|
||||||
|
|
||||||
<stage id="S2" name="自检" autoComplete="true">
|
|
||||||
<entryCriterion id="ec_s2" sentryRef="s_priced"/>
|
|
||||||
<decisionTask id="D_floor" name="底价门禁" decisionRef="p02-floor"/>
|
|
||||||
<humanTask id="T_breach" name="人工裁超限条目(回调还是注明理由)">
|
|
||||||
<entryCriterion id="ec_breach" sentryRef="s_breach"/>
|
|
||||||
<itemControl><manualActivationRule id="m_br"/><repetitionRule id="r_br"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<planningTable id="pt_s2">
|
|
||||||
<humanTask id="T_refresh_floor" name="人工刷新底价"/>
|
|
||||||
</planningTable>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_gated" name="门禁已过"/>
|
|
||||||
|
|
||||||
<humanTask id="T_release" name="人工报价草案验收放行">
|
|
||||||
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
|
||||||
</humanTask>
|
</humanTask>
|
||||||
|
|
||||||
<timerEventListener id="TL_half" name="时限过半">
|
<!-- 超限审批:触达客户利益的一下,必须人工激活 -->
|
||||||
<timerExpression>PT8H</timerExpression>
|
<humanTask id="T_approve" name="超限条目决策:低于底价的那几条要人拍板">
|
||||||
</timerEventListener>
|
<entryCriterion id="ec_ap" sentryRef="s_over"/>
|
||||||
|
<itemControl><manualActivationRule id="m_ap"/><repetitionRule id="r_ap"/></itemControl>
|
||||||
|
</humanTask>
|
||||||
|
|
||||||
|
<!-- 放行:触达世界,必须人工激活 -->
|
||||||
|
<humanTask id="T_release" name="报价草案验收放行">
|
||||||
|
<entryCriterion id="ec_rel" sentryRef="s_priced"/>
|
||||||
|
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||||
|
</humanTask>
|
||||||
</casePlanModel>
|
</casePlanModel>
|
||||||
</case>
|
</case>
|
||||||
</definitions>`;
|
</definitions>`;
|
||||||
|
|
||||||
export const NARRATIVE = `§0 开案:拿到一批待定价条目即开案。成品是**一份全条目带偏离度的报价草案** ——
|
export const NARRATIVE = `§0 开案:拿到一批待定价条目即开案。成品是**一份全条目带偏离度的报价草案** ——
|
||||||
每条有拟报单价与毛利率,且都过了底价门禁。
|
人签的不是一个数字,是**这个数字的来路**:基准从哪来、毛利怎么算、偏离多少、门禁怎么判。
|
||||||
|
|
||||||
S1 算价:先取价基准(历史成交价 / 供应商回价 / 替代料对标价,按可得性择优),
|
**能算的不问人**:P-01(利润模型)与 P-02(底价门禁)两支判定表说了算,你不临场拍价。
|
||||||
再按利润模型 P-01 逐条定价 —— 客户等级、数量档、料号类型、行情共同决定加价率。
|
你的活是把判据取齐、把结论落进台账;**只有超限的那几条才叫人**。
|
||||||
基准取不到的条目不许拍脑袋定价,回头找寻源或标注「无基准」。
|
|
||||||
|
|
||||||
S2 自检:算出偏离度(基准价 ↔ 拟报价),按底价门禁 P-02 逐条判。
|
【T_price 逐条算价】基准价取自历史价情报(A3 的比价结论 / CRM 历史价)。新料号无历史时
|
||||||
**偏离 ±20% 以内自动放过;超限的一律调人** —— 要么回调价格,要么写明为什么这样报。
|
基准记 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。终局唯放行:你不能自己宣布完成。`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const DRAFT = 'quote_pricing.draft';
|
|||||||
|
|
||||||
export default defineActivity({
|
export default defineActivity({
|
||||||
name: 'quote-pricing',
|
name: 'quote-pricing',
|
||||||
paradigm: 'flow',
|
|
||||||
|
|
||||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
||||||
const items = await base.ledger.read(DRAFT);
|
const items = await base.ledger.read(DRAFT);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { ActivityManifest, DmnModel } from '@contracts';
|
import type { ActivityManifest, DmnModel } from '@contracts';
|
||||||
import { PRICING_SKILLS } from './skills.ts';
|
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 { activityIdSchema } from '@contracts';
|
||||||
import { PRICING_BASIS, PRICING_PRICED, PRICING_RELEASE } from './views/report.ts';
|
import { PRICING_BASIS, PRICING_PRICED, PRICING_RELEASE } from './views/report.ts';
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export const manifest: ActivityManifest = {
|
|||||||
identity: {
|
identity: {
|
||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
name: 'A4 · 成本定价',
|
name: 'A4 · 成本定价',
|
||||||
packageVersion: '0.1.6',
|
packageVersion: '2.0.0',
|
||||||
category: '统一报价',
|
category: '统一报价',
|
||||||
sdkVersion: '0.1.0-m5',
|
sdkVersion: '0.1.0-m5',
|
||||||
checksum: 'dev-checksum-quote-pricing',
|
checksum: 'dev-checksum-quote-pricing',
|
||||||
@@ -74,8 +74,9 @@ export const manifest: ActivityManifest = {
|
|||||||
input: {
|
input: {
|
||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['customerCode', 'records'],
|
required: ['rfqRef', 'customerCode', 'records'],
|
||||||
properties: {
|
properties: {
|
||||||
|
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
|
||||||
customerCode: { type: 'string' },
|
customerCode: { type: 'string' },
|
||||||
records: { type: 'array' },
|
records: { type: 'array' },
|
||||||
// B/C 类由 A3/A2 交回的底价与替代方案;A 类直通时为空
|
// B/C 类由 A3/A2 交回的底价与替代方案;A 类直通时为空
|
||||||
@@ -90,21 +91,36 @@ export const manifest: ActivityManifest = {
|
|||||||
name: 'draft',
|
name: 'draft',
|
||||||
payload: {
|
payload: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['items'],
|
required: ['rfqRef', 'items'],
|
||||||
properties: { items: { type: 'array' } },
|
properties: { rfqRef: { type: 'string' }, items: { type: 'array' } },
|
||||||
},
|
},
|
||||||
description: '报价草案(逐条带偏离度)',
|
description: '报价草案(逐条带偏离度)',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
selective: [],
|
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: [
|
acceptanceCriteria: [
|
||||||
{ key: 'all-priced', label: '每条都有拟报单价与毛利率', level: 'L1', predicate: 'unpriced = 0' },
|
{ key: 'all-priced', label: '每条都有拟报单价与毛利率', level: 'L1', predicate: 'unpriced_total = 0' },
|
||||||
// 底价门禁沉为 L2 机检 —— 「约束力收缩」的终点
|
// 底价门禁沉为 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' },
|
{ key: 'pm-release', label: 'BD 放行', level: 'L5' },
|
||||||
],
|
],
|
||||||
|
revisableAtRelease: [],
|
||||||
prohibitions: [
|
prohibitions: [
|
||||||
{ key: 'no-silent-override', statement: '不得静默绕开 P-02 的结论;要破线只能走「人 · 决策」' },
|
{ key: 'no-silent-override', statement: '不得静默绕开 P-02 的结论;要破线只能走「人 · 决策」' },
|
||||||
{ key: 'no-stale-floor', statement: '底价超一周未更新的,先刷底价再判' },
|
{ key: 'no-stale-floor', statement: '底价超一周未更新的,先刷底价再判' },
|
||||||
@@ -115,7 +131,13 @@ export const manifest: ActivityManifest = {
|
|||||||
humanCallBudget: 3,
|
humanCallBudget: 3,
|
||||||
},
|
},
|
||||||
resources: {
|
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),
|
skills: PRICING_SKILLS.map((s) => s.id),
|
||||||
models: ['deepseek-v4-flash'],
|
models: ['deepseek-v4-flash'],
|
||||||
roles: ['BD'],
|
roles: ['BD'],
|
||||||
@@ -125,13 +147,16 @@ export const manifest: ActivityManifest = {
|
|||||||
|
|
||||||
declarations: {
|
declarations: {
|
||||||
models: ['deepseek-v4-flash'],
|
models: ['deepseek-v4-flash'],
|
||||||
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-pricing.basis' }],
|
tools: [{ name: 'crm.history-price-read' }],
|
||||||
ledger: [
|
ledger: [
|
||||||
{
|
{
|
||||||
domain: DOMAIN,
|
domain: DOMAIN,
|
||||||
object: 'draft',
|
object: 'draft',
|
||||||
|
kind: 'record',
|
||||||
|
caseKey: 'rfq_ref',
|
||||||
label: '报价草案',
|
label: '报价草案',
|
||||||
fields: [
|
fields: [
|
||||||
|
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
|
||||||
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '拟报单价' },
|
{ name: 'quoted_price', type: 'number', primaryKey: false, masked: false, label: '拟报单价' },
|
||||||
{ name: 'margin', 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: '门禁处置' },
|
{ 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: [
|
listeners: [
|
||||||
{
|
{
|
||||||
@@ -166,6 +212,8 @@ export const manifest: ActivityManifest = {
|
|||||||
cmmnXml: CMMN_XML,
|
cmmnXml: CMMN_XML,
|
||||||
narrative: NARRATIVE,
|
narrative: NARRATIVE,
|
||||||
dmn: P01_DMN,
|
dmn: P01_DMN,
|
||||||
|
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
|
||||||
|
steps: PRICING_STEPS,
|
||||||
},
|
},
|
||||||
skills: PRICING_SKILLS,
|
skills: PRICING_SKILLS,
|
||||||
viewTemplates: [
|
viewTemplates: [
|
||||||
|
|||||||
@@ -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<readonly [string, string, string]>): string {
|
||||||
|
return `<ul class="steps">${rows
|
||||||
|
.map(
|
||||||
|
([no, term, gloss]) =>
|
||||||
|
`<li><span class="n">${no}</span><span><span class="t">${term}</span> <span class="d">${gloss}</span></span></li>`,
|
||||||
|
)
|
||||||
|
.join('')}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 局面一:S1 算价刚开场 —— 人此刻要核的是「这一单跟谁比、落在哪一档」 */
|
||||||
export const PRICING_BASIS = viewShell({
|
export const PRICING_BASIS = viewShell({
|
||||||
title: '取价基准',
|
title: '取价基准',
|
||||||
kicker: 'A4 · 成本定价',
|
kicker: 'A4 · 成本定价 · S1 算价',
|
||||||
tone: 'brand',
|
tone: 'brand',
|
||||||
|
icon: 'coins',
|
||||||
|
stamp: { kind: 'live', text: '算价中' },
|
||||||
|
sign: 'A4 成本定价 · 取价基准(本单草稿)',
|
||||||
body:
|
body:
|
||||||
kvCard('本单标的', [
|
/*
|
||||||
['客户编码', 'customerCode', 'mono'],
|
* 指标带摆的是**利润模型 P-01 的三个入参 + 这一单的工作量**,不是所有字段。
|
||||||
['型号 PN', 'pn', 'mono'],
|
* 三个档位键取的是清单里 P-01 声明的决策入参名(customerGrade / materialType /
|
||||||
['物料类型', 'materialType', 'tag'],
|
* volumeTier)—— 决策表照这三个名字取值,模版就照这三个名字摆,不另起炉灶。
|
||||||
]) +
|
* `records` 是起单入参的数组,基座渲染数组时给的是条数,正好是「这一单要定几条价」。
|
||||||
kvCard('价格基准', [
|
*/
|
||||||
['上次成交价', 'last_price'],
|
statsBand([
|
||||||
|
['待定价条目', 'records'],
|
||||||
['客户等级', 'customerGrade'],
|
['客户等级', 'customerGrade'],
|
||||||
|
['料号类型', 'materialType'],
|
||||||
['数量档', 'volumeTier'],
|
['数量档', 'volumeTier'],
|
||||||
]) +
|
]) +
|
||||||
noteCard('基准从哪来', '基准价取自历史价情报(CRM)。新料号无历史时基准记 0,由底价门禁按<b>无基准</b>处置 —— 不凭空编一个偏离率出来。'),
|
section('基准从哪来 —— 四级优先级',
|
||||||
|
ladder([
|
||||||
|
['1', '本客户近期成交价', '最贴近这一单的真实议价空间'],
|
||||||
|
['2', '同型号他客户成交价', '价位可比,但议价空间要留余量'],
|
||||||
|
['3', '供应商底价表', '成本侧的锚,不含客户侧信息'],
|
||||||
|
['4', '无基准', '新料号、久未报价的老料号都会落到这里'],
|
||||||
|
]) +
|
||||||
|
noteOnly(
|
||||||
|
'<b>无基准不是异常,它本身就是一种基准。</b>此时偏离率记 0 —— 没有可偏离的对象,' +
|
||||||
|
'由底价门禁按「无基准」处置。不要拿一个不相干的价去凑一个偏离率出来:' +
|
||||||
|
'那比没有基准更危险,因为它<b>看起来像有依据</b>。',
|
||||||
|
),
|
||||||
|
{ 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({
|
export const PRICING_PRICED = viewShell({
|
||||||
title: '定价与底价门禁',
|
title: '定价与底价门禁',
|
||||||
kicker: 'A4 · DMN 两表',
|
kicker: 'A4 · 成本定价 · S2 自检',
|
||||||
tone: 'ok',
|
tone: 'brand',
|
||||||
|
icon: 'gauge',
|
||||||
|
stamp: { kind: 'live', text: '自检中' },
|
||||||
|
sign: 'A4 成本定价 · 定价自检(判定表 P-01 / P-02)',
|
||||||
body:
|
body:
|
||||||
kvCard('定价结论', [
|
/* 标签一律照台账声明的列名(基准价 / 拟报单价 / 毛利率 / 偏离度 / 门禁处置):
|
||||||
|
同一个数在指标带上叫一个名、在下面的表里叫另一个名,签字的人就得先花一秒确认它俩是不是一回事 */
|
||||||
|
statsBand([
|
||||||
|
['基准价', 'last_price'],
|
||||||
['拟报单价', 'quoted_price'],
|
['拟报单价', 'quoted_price'],
|
||||||
['目标毛利', 'margin'],
|
['毛利率', 'margin'],
|
||||||
['相对基准偏离', 'deviation'],
|
['偏离度', 'deviation'],
|
||||||
['门禁处置', 'disposition', 'tag'],
|
|
||||||
]) +
|
]) +
|
||||||
noteCard(
|
section('逐条定价结论',
|
||||||
'两张决策表各管一段',
|
tableOnly('mine:quote_pricing.draft', [
|
||||||
'<b>利润模型 P-01</b>:客户等级 × 物料类型 × 数量档 → 目标毛利区间。' +
|
['型号 PN', 'pn', 'mono'],
|
||||||
'<b>底价门禁 P-02</b>:偏离底价 ±20% 即止 —— 超限的不是「拦下」,而是转人裁断(回调还是注明理由)。' +
|
['基准价', '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(
|
||||||
|
'自上而下取<b>第一条命中</b>的规则,同输入必得同结论、零方差零 token,命中的规则行随出参留痕。' +
|
||||||
|
'不要为了让门禁「过」而回头改毛利参数 —— 那是把判据改成结论,决策表也就失去了它唯一的价值。',
|
||||||
|
),
|
||||||
|
{ text: '首条命中' },
|
||||||
|
) +
|
||||||
|
section('偏离怎么判的 —— 底价门禁 P-02',
|
||||||
|
ladder([
|
||||||
|
['1', '拟报价 ≥ 底价', '直接放行'],
|
||||||
|
['2', '低于底价 ≤ 20%', 'PM 附理由放行'],
|
||||||
|
['3', '低于底价 超 20%', '拦截 · BOSS 拍板'],
|
||||||
|
]) +
|
||||||
|
noteOnly(
|
||||||
|
'门禁不是「拦下」而是<b>分流</b>:超限的一律调人 —— 要么回调价格,要么写明为什么这样报,' +
|
||||||
|
'两条路都合法,但都要留下理由。这条留痕将来会成为客户等级与利润模型的迭代素材。',
|
||||||
|
),
|
||||||
|
{ text: '±20% 即止' },
|
||||||
|
) +
|
||||||
|
section('本单门禁结论',
|
||||||
|
/*
|
||||||
|
* 结论章里嵌 `data-slot`:处置与偏离都是**这一单算出来的事实**,
|
||||||
|
* 写死一句「已通过」就是替判定表下结论。橙档在此的意思是「还没定,等人处置」。
|
||||||
|
*/
|
||||||
|
verdict('底价门禁 P-02 处置:<b data-slot="disposition"></b>', 'wait', '偏离度 <b data-slot="deviation"></b>') +
|
||||||
|
noteOnly(
|
||||||
|
'禁区两条随本单同行:<b>不得静默绕开 P-02 的结论</b> —— 要破线只能走「人 · 决策」;' +
|
||||||
|
'<b>底价超一周未更新的,先刷底价再判</b> —— 拿旧底价判新行情等于没判。',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 局面三:待放行 —— 验收单上人要签的那份成果报告 */
|
||||||
export const PRICING_RELEASE = viewShell({
|
export const PRICING_RELEASE = viewShell({
|
||||||
title: '报价草案 · 待放行',
|
title: '报价草案 · 待放行',
|
||||||
kicker: 'A4 · 成果预览',
|
kicker: 'A4 · 成本定价 · 成果待签',
|
||||||
tone: 'case',
|
tone: 'case',
|
||||||
|
icon: 'shield-check',
|
||||||
|
stamp: { kind: 'live', text: '待签字放行' },
|
||||||
|
sign: 'A4 成本定价 · 报价草案(待放行)',
|
||||||
body:
|
body:
|
||||||
tableCard(
|
statsBand([
|
||||||
'本单报价草案',
|
['拟报单价', 'quoted_price'],
|
||||||
'mine:quote_pricing.draft',
|
['毛利率', 'margin'],
|
||||||
[
|
['偏离度', 'deviation'],
|
||||||
['型号', 'pn', 'mono'],
|
['门禁处置', 'disposition'],
|
||||||
|
]) +
|
||||||
|
section('本单交付:报价草案',
|
||||||
|
tableOnly('mine:quote_pricing.draft', [
|
||||||
|
['型号 PN', 'pn', 'mono'],
|
||||||
['基准价', 'last_price'],
|
['基准价', 'last_price'],
|
||||||
['拟报价', 'quoted_price'],
|
['拟报单价', 'quoted_price'],
|
||||||
['毛利', 'margin'],
|
['毛利率', 'margin'],
|
||||||
['偏离', 'deviation'],
|
['偏离度', 'deviation'],
|
||||||
['门禁', 'disposition'],
|
['门禁处置', 'disposition'],
|
||||||
],
|
]) +
|
||||||
'逐条列出,便于放行前逐条核对。',
|
noteOnly('只列<b>本单</b>写下的行 —— 别单放行过的记录不混进来。逐条列出,便于放行前逐条核对。'),
|
||||||
|
{ text: '本单草稿' },
|
||||||
) +
|
) +
|
||||||
noteCard(
|
section('放行前要过的三道判据',
|
||||||
'放行之后',
|
ladder([
|
||||||
'放行通过 → 草稿合并入正式版 → 进 <b>AP 定价审批</b>(卡到 PM 工位)。' +
|
['1', '每条都有拟报单价与毛利率', 'L1 机检 · unpriced = 0'],
|
||||||
'审批驳回按声明走向<b>回 A4 重开新实例</b> —— 新任务 ID、留痕关联,不复活旧任务。',
|
['2', '偏离底价 ≤ ±20%,超限者已回调或注明理由', 'L2 机检 · 底价门禁 P-02'],
|
||||||
|
['3', 'BD 放行', 'L5 人签 · 签了才算数'],
|
||||||
|
]),
|
||||||
|
{ text: '两机检 · 一人签' },
|
||||||
|
) +
|
||||||
|
section('放行之后会发生什么',
|
||||||
|
verdict('放行即落地:草稿合并入正式版,草案提交 A5 定价审批', 'wait', '审批点卡在 PM 工位') +
|
||||||
|
noteOnly(
|
||||||
|
'审批驳回按<b>声明</b>走向,不由临场决定:<b>回 A4 重开新实例</b> —— 新任务 ID、留痕关联,' +
|
||||||
|
'不复活旧任务。所以这一屏上还改得动的每一格,过了这道门就要花一整个新实例才改得回来。',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,120 +1,132 @@
|
|||||||
/**
|
/**
|
||||||
* A3 渠道寻源的案例模型 —— **双表示同版本**:CMMN XML(机器面)与案例说明书(叙事面)。
|
* A3 渠道寻源的案例模型 —— **一张执行单办完**(2026-07-31 用户定,与 A2 同一口径)。
|
||||||
* 「说明书是 XML 的叙事投影,迭代时一起放行」(AM 3.1)。
|
*
|
||||||
|
* 上一版按四种询价场景(BOM 早期 / BOM 后期 / 现货 / 样品)把流程铺开成一串计划项。
|
||||||
|
* 场景之分在业务上是真的,可它**不改变系统要管的事**:发函、收回价、比价、给结论。
|
||||||
|
* 而这四步在一次寻源里是连着做的 —— 采购不会办完「发函」就交回来等系统叫他。
|
||||||
|
* 把它切成四项,换来的只是 Agent 每一步都要跟 harness 对一次账。
|
||||||
|
*
|
||||||
|
* 现在只有两项:
|
||||||
|
* T_source —— 派一张执行单给采购(卡面两张可编辑表:供应商回价 / 寻源比价),他填完提交;
|
||||||
|
* T_release —— 放行(触达世界,人工激活)。
|
||||||
|
*
|
||||||
|
* 场景之分不进模型,进**说明书**:采购自己知道 BOM 早期该广撒网、现货该问库存。
|
||||||
|
* 那是他的判断,不是系统的步。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
|
||||||
<case id="sourcing">
|
<case id="sourcing">
|
||||||
|
<!--
|
||||||
|
案卷项的 id **就是任务视图里的键名**。只认两样:
|
||||||
|
起单入参(pn)与「比价表写了几行」(compare_total)——
|
||||||
|
后者既是放行前提,也是这一单唯一的机检判据。
|
||||||
|
-->
|
||||||
<caseFileModel>
|
<caseFileModel>
|
||||||
<caseFileItem id="inquiry" name="询价请求"/>
|
<caseFileItem id="pn" name="型号(开案即有)"/>
|
||||||
<caseFileItem id="supplier_quote" name="供应商回价单"/>
|
<caseFileItem id="compare_total" name="比价表条数"/>
|
||||||
<caseFileItem id="compare_table" name="寻源比价表"/>
|
|
||||||
</caseFileModel>
|
</caseFileModel>
|
||||||
<casePlanModel id="plan" name="渠道寻源" autoComplete="true">
|
<casePlanModel id="plan" name="渠道寻源" autoComplete="true">
|
||||||
<sentry id="s_inquiry">
|
<sentry id="s_compared">
|
||||||
<onPart id="op_inquiry" caseFileItemRef="inquiry"><standardEvent>create</standardEvent></onPart>
|
<onPart id="op_cp" caseFileItemRef="compare_total"><standardEvent>update</standardEvent></onPart>
|
||||||
</sentry>
|
<ifPart id="if_cp"><condition>compare_total > 0</condition></ifPart>
|
||||||
<sentry id="s_need_quote">
|
|
||||||
<onPart id="op_need" caseFileItemRef="inquiry"><standardEvent>update</standardEvent></onPart>
|
|
||||||
<ifPart id="if_need"><condition>needNewQuote = true</condition></ifPart>
|
|
||||||
</sentry>
|
|
||||||
<sentry id="s_channels">
|
|
||||||
<onPart id="op_ch" caseFileItemRef="inquiry"><standardEvent>update</standardEvent></onPart>
|
|
||||||
<ifPart id="if_ch"><condition>channelsPicked >= 2</condition></ifPart>
|
|
||||||
</sentry>
|
|
||||||
<sentry id="s_quotes_in">
|
|
||||||
<onPart id="op_q" caseFileItemRef="supplier_quote"><standardEvent>create</standardEvent></onPart>
|
|
||||||
</sentry>
|
|
||||||
<sentry id="s_ready">
|
|
||||||
<onPart id="op_r" caseFileItemRef="compare_table"><standardEvent>create</standardEvent></onPart>
|
|
||||||
</sentry>
|
</sentry>
|
||||||
|
|
||||||
<stage id="S1" name="接需核史" autoComplete="true">
|
<!-- 唯一的活:派一张执行单给采购,他填完提交 -->
|
||||||
<entryCriterion id="ec_s1" sentryRef="s_inquiry"/>
|
<humanTask id="T_source" name="渠道寻源:派执行单给采购,收回供应商回价与比价结论">
|
||||||
<humanTask id="T_history" name="人工查历史报价">
|
<itemControl><repetitionRule id="r_src"/></itemControl>
|
||||||
<itemControl><manualActivationRule id="m_hist"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<decisionTask id="D_strategy" name="判定采用策略" decisionRef="sourcing-strategy">
|
|
||||||
<itemControl><manualActivationRule id="m_str"/></itemControl>
|
|
||||||
</decisionTask>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_strategy" name="策略已定"/>
|
|
||||||
|
|
||||||
<stage id="S2" name="选渠定策" autoComplete="true">
|
|
||||||
<entryCriterion id="ec_s2" sentryRef="s_need_quote"/>
|
|
||||||
<humanTask id="T_pick" name="人工按场景圈渠道">
|
|
||||||
<itemControl><manualActivationRule id="m_pick"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<planningTable id="pt_s2">
|
|
||||||
<humanTask id="T_verify_channel" name="人工渠道资质核实"/>
|
|
||||||
</planningTable>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_channels" name="渠道圈定"/>
|
|
||||||
|
|
||||||
<stage id="S3" name="发询比价" autoComplete="true">
|
|
||||||
<entryCriterion id="ec_s3" sentryRef="s_channels"/>
|
|
||||||
<humanTask id="T_send" name="人工批量发询">
|
|
||||||
<itemControl><manualActivationRule id="m_send"/><repetitionRule id="r_send"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<humanTask id="T_log_quote" name="人工登记回价">
|
|
||||||
<entryCriterion id="ec_log" sentryRef="s_quotes_in"/>
|
|
||||||
<itemControl><manualActivationRule id="m_log"/><repetitionRule id="r_log"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<humanTask id="T_verify_price" name="人工价格验证">
|
|
||||||
<itemControl><manualActivationRule id="m_vp"/><repetitionRule id="r_vp"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<planningTable id="pt_s3">
|
|
||||||
<humanTask id="T_chase" name="人工追价补询"/>
|
|
||||||
</planningTable>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_quotes" name="报价确认"/>
|
|
||||||
|
|
||||||
<stage id="S4" name="反馈交付" autoComplete="true">
|
|
||||||
<entryCriterion id="ec_s4" sentryRef="s_ready"/>
|
|
||||||
<humanTask id="T_fill_table" name="人工填寻源比价表">
|
|
||||||
<itemControl><manualActivationRule id="m_fill"/></itemControl>
|
|
||||||
</humanTask>
|
|
||||||
<planningTable id="pt_s4">
|
|
||||||
<humanTask id="T_order" name="人工下单与跟踪"/>
|
|
||||||
</planningTable>
|
|
||||||
</stage>
|
|
||||||
<milestone id="M_delivered" name="交付完成"/>
|
|
||||||
|
|
||||||
<humanTask id="T_knowledge" name="人工知识入库">
|
|
||||||
<itemControl><manualActivationRule id="m_kb"/></itemControl>
|
|
||||||
</humanTask>
|
</humanTask>
|
||||||
|
|
||||||
<timerEventListener id="TL_deadline" name="时限预警">
|
<!-- 放行:触达世界,必须人工激活 -->
|
||||||
<timerExpression>P4D</timerExpression>
|
<humanTask id="T_release" name="寻源结论验收放行">
|
||||||
</timerEventListener>
|
<entryCriterion id="ec_rel" sentryRef="s_compared"/>
|
||||||
<timerEventListener id="TL_quote_expiry" name="回价到期">
|
<itemControl><manualActivationRule id="m_rl"/></itemControl>
|
||||||
<timerExpression>P7D</timerExpression>
|
</humanTask>
|
||||||
</timerEventListener>
|
|
||||||
</casePlanModel>
|
</casePlanModel>
|
||||||
</case>
|
</case>
|
||||||
</definitions>`;
|
</definitions>`;
|
||||||
|
|
||||||
export const NARRATIVE = `§0 开案:本案例覆盖四种询价场景 —— BOM早期、BOM后期、现货询价、样品询价。
|
export const NARRATIVE = `§0 开案:A1 判出一条料要寻源(无近期历史价、或行情波动),这一单就到你手上。
|
||||||
场景不同,供应商选择尺度与价格验证规则各异,但都遵循同一主线:
|
成品只有一样:**一份能拿去定价的比价结论** —— 几家报了什么价、交期多久、推荐哪一家、为什么。
|
||||||
PM 提交询价请求 → 查历史报价判定可否直采 →(须询价时)按场景圈渠道 → 发询验证 → 填入寻源比价表反馈。
|
|
||||||
|
|
||||||
S1 接需核史:系统带出在供供应商与历史报价。先核历史,按场景规则逐 PN 判定是否可直接采用 ——
|
**这一单只有一步**:把寻源这件事派给采购(执行工单),他填完你收进台账,然后提交放行。
|
||||||
可直采则跳过询价直接进入反馈交付;须新询价则进入选渠阶段。
|
四种询价场景(BOM 早期 / BOM 后期 / 现货 / 样品)的打法差别是**采购的判断**,
|
||||||
· 查历史报价:未达 3 次询价但有效期已过的,标记须重询。
|
不是系统要管的步 —— 你在 ask 里说清是哪种场景就够了,别替他把流程铺开。
|
||||||
· 判定采用策略:BOM早期有效期内直采、过期浮动≤10%直采、10-20%取均价、>20%重询;
|
|
||||||
BOM后期历史最低(≥3次)直采,否则降 20% 谈;现货/样品一律重询。
|
|
||||||
|
|
||||||
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 验收。
|
||||||
|
终局唯放行:你不能自己宣布完成。`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const COMPARE = 'quote_sourcing.compare_table';
|
|||||||
|
|
||||||
export default defineActivity({
|
export default defineActivity({
|
||||||
name: 'quote-sourcing',
|
name: 'quote-sourcing',
|
||||||
paradigm: 'loop',
|
|
||||||
|
|
||||||
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
async run(base: ActivityBase): Promise<ActivityOutcome> {
|
||||||
const quotes = await base.ledger.read(QUOTES);
|
const quotes = await base.ledger.read(QUOTES);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { ActivityManifest, DmnModel } from '@contracts';
|
|||||||
import { SOURCING_SKILLS } from './skills.ts';
|
import { SOURCING_SKILLS } from './skills.ts';
|
||||||
import { SOURCING_CASE } from './views/report.ts';
|
import { SOURCING_CASE } from './views/report.ts';
|
||||||
import { activityIdSchema } from '@contracts';
|
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 DOMAIN = 'quote_sourcing';
|
||||||
const ACTIVITY_ID = activityIdSchema.parse('quote-sourcing');
|
const ACTIVITY_ID = activityIdSchema.parse('quote-sourcing');
|
||||||
@@ -45,7 +45,7 @@ export const manifest: ActivityManifest = {
|
|||||||
identity: {
|
identity: {
|
||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
name: 'A3 · 渠道寻源',
|
name: 'A3 · 渠道寻源',
|
||||||
packageVersion: '0.1.3',
|
packageVersion: '2.0.0',
|
||||||
category: '统一报价',
|
category: '统一报价',
|
||||||
sdkVersion: '0.1.0-m5',
|
sdkVersion: '0.1.0-m5',
|
||||||
checksum: 'dev-checksum-quote-sourcing',
|
checksum: 'dev-checksum-quote-sourcing',
|
||||||
@@ -57,8 +57,9 @@ export const manifest: ActivityManifest = {
|
|||||||
input: {
|
input: {
|
||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['pn', 'scenario'],
|
required: ['rfqRef', 'pn', 'scenario'],
|
||||||
properties: {
|
properties: {
|
||||||
|
rfqRef: { type: 'string', title: '询价单号(单标识,贯穿全流程)' },
|
||||||
pn: { type: 'string' },
|
pn: { type: 'string' },
|
||||||
scenario: { type: 'string' },
|
scenario: { type: 'string' },
|
||||||
quantity: { type: 'integer' },
|
quantity: { type: 'integer' },
|
||||||
@@ -78,13 +79,28 @@ export const manifest: ActivityManifest = {
|
|||||||
],
|
],
|
||||||
selective: [],
|
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: [
|
acceptanceCriteria: [
|
||||||
{ key: 'each-has-quote', label: '每料有有效报价或明确无货', level: 'L2', predicate: 'unquoted = 0' },
|
{ key: 'each-has-quote', label: '每料有有效报价或明确无货', level: 'L2', predicate: 'unquoted_total = 0' },
|
||||||
{ key: 'channels-enough', label: 'BOM后期每料 2~3 家渠道', level: 'L2', predicate: 'channelsPicked >= 2' },
|
{ key: 'channels-enough', label: 'BOM后期每料 2~3 家渠道', level: 'L2', predicate: 'supplier_total >= 2' },
|
||||||
{ key: 'cross-check', label: '价格验证按场景规则做过', level: 'L3' },
|
{ key: 'cross-check', label: '价格验证按场景规则做过', level: 'L3' },
|
||||||
{ key: 'prm-release', label: '采购放行', level: 'L5' },
|
{ key: 'prm-release', label: '采购放行', level: 'L5' },
|
||||||
],
|
],
|
||||||
|
revisableAtRelease: [],
|
||||||
prohibitions: [
|
prohibitions: [
|
||||||
{ key: 'no-stockless-trader', statement: '现货场景不得选无库存转单商' },
|
{ key: 'no-stockless-trader', statement: '现货场景不得选无库存转单商' },
|
||||||
{ key: 'no-unverified', statement: '不实回价或远低行情的,必须换渠道补询,不得直接采用' },
|
{ key: 'no-unverified', statement: '不实回价或远低行情的,必须换渠道补询,不得直接采用' },
|
||||||
@@ -110,8 +126,11 @@ export const manifest: ActivityManifest = {
|
|||||||
{
|
{
|
||||||
domain: DOMAIN,
|
domain: DOMAIN,
|
||||||
object: 'supplier_quote',
|
object: 'supplier_quote',
|
||||||
|
kind: 'record',
|
||||||
|
caseKey: 'rfq_ref',
|
||||||
label: '供应商回价单',
|
label: '供应商回价单',
|
||||||
fields: [
|
fields: [
|
||||||
|
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
||||||
{ name: 'supplier', type: 'text', primaryKey: true, masked: false, label: '供应商' },
|
{ name: 'supplier', type: 'text', primaryKey: true, masked: false, label: '供应商' },
|
||||||
{ name: 'price', type: 'number', primaryKey: false, masked: false, label: '回价' },
|
{ name: 'price', type: 'number', primaryKey: false, masked: false, label: '回价' },
|
||||||
@@ -124,14 +143,39 @@ export const manifest: ActivityManifest = {
|
|||||||
{
|
{
|
||||||
domain: DOMAIN,
|
domain: DOMAIN,
|
||||||
object: 'compare_table',
|
object: 'compare_table',
|
||||||
|
kind: 'record',
|
||||||
|
caseKey: 'rfq_ref',
|
||||||
label: '寻源比价表',
|
label: '寻源比价表',
|
||||||
fields: [
|
fields: [
|
||||||
|
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
|
||||||
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
{ name: 'pn', type: 'text', primaryKey: true, masked: false },
|
||||||
{ name: 'supplier', type: 'text', primaryKey: false, masked: false },
|
{ name: 'supplier', type: 'text', primaryKey: false, masked: false },
|
||||||
{ name: 'price', type: 'number', primaryKey: false, masked: false },
|
{ name: 'price', type: 'number', primaryKey: false, masked: false },
|
||||||
{ name: 'conclusion', type: 'text', primaryKey: false, masked: false, label: '结论' },
|
{ 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: [
|
listeners: [
|
||||||
{
|
{
|
||||||
@@ -167,6 +211,8 @@ export const manifest: ActivityManifest = {
|
|||||||
// 双表示同版本:XML 是机器面,说明书是叙事面 —— 不允许分叉
|
// 双表示同版本:XML 是机器面,说明书是叙事面 —— 不允许分叉
|
||||||
narrative: NARRATIVE,
|
narrative: NARRATIVE,
|
||||||
dmn: STRATEGY_DMN,
|
dmn: STRATEGY_DMN,
|
||||||
|
/* 步骤剧本 —— 与 A1 同一套:doneWhen 真了引擎自己收步,不问模型 */
|
||||||
|
steps: SOURCING_STEPS,
|
||||||
},
|
},
|
||||||
skills: SOURCING_SKILLS,
|
skills: SOURCING_SKILLS,
|
||||||
viewTemplates: [{ name: 'case-overview', html: SOURCING_CASE }],
|
viewTemplates: [{ name: 'case-overview', html: SOURCING_CASE }],
|
||||||
|
|||||||
@@ -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({
|
export const SOURCING_CASE = viewShell({
|
||||||
title: '渠道寻源 · 案例全景',
|
title: '渠道寻源 · 案例全景',
|
||||||
kicker: 'A3 · 案例活动(loop)',
|
kicker: 'A3 · 渠道寻源 · 案例活动',
|
||||||
tone: 'case',
|
tone: 'case',
|
||||||
|
icon: 'mail',
|
||||||
|
stamp: { kind: 'live', text: '案例在办 · 非定稿' },
|
||||||
|
sign: 'A3 渠道寻源 · 本页每拍按当下局面重画',
|
||||||
body:
|
body:
|
||||||
kvCard('本单标的', [
|
statsBand([
|
||||||
['型号 PN', 'pn', 'mono'],
|
['已收回价', 'mine:quote_sourcing.supplier_quote'],
|
||||||
['场景', 'scenario'],
|
['比价表行数', 'mine:quote_sourcing.compare_table'],
|
||||||
['当前阶段', 'activeStage', 'tag'],
|
|
||||||
['当下可为', 'enabledCount'],
|
['当下可为', 'enabledCount'],
|
||||||
['目标缺口', 'gapCount'],
|
['目标缺口', 'gapCount'],
|
||||||
]) +
|
]) +
|
||||||
stagesCard('阶段(CMMN)', 'case:stages', '阶段的生命周期态由语义引擎推进到 <b>Enabled</b> 为止;凡触达世界或产出关键交付的计划项必须人工启动 —— <b>Enabled ≠ Active</b>。') +
|
section('供应商回价(本单登记)',
|
||||||
stagesCard('当下可为', 'case:enabled', 'AI 只能在这个集合里选下一步:<b>可以越谱,不可越权</b>。') +
|
tableOnly('mine:quote_sourcing.supplier_quote', [
|
||||||
stagesCard('目标缺口', 'case:gaps', '缺口只有人知道 → 调人;缺口在世界侧 → 立监听(必配时限)。当下可为 ∩ 目标所需 = 空,就是<b>依赖断点</b>。') +
|
['型号 PN', 'pn', 'mono'],
|
||||||
noteCard('这个活动为什么是 loop', '寻源是<b>一事一议</b>:几家供应商、谁先回、要不要追加一轮,事前排不出谱。调度权交给 AI,系统只保证边框 —— 预算、停滞熔断、放行归人。'),
|
['供应商', 'supplier'],
|
||||||
|
['渠道等级', 'grade'],
|
||||||
|
['回价', 'price', 'mono'],
|
||||||
|
['交期', 'lead_time'],
|
||||||
|
['有效期', 'valid_until', 'mono'],
|
||||||
|
]) +
|
||||||
|
noteOnly(
|
||||||
|
'回价<b>逐单登记、一一对应不可缺项</b>:发出去几家就该收回几条,没回音的也要留一条写明' +
|
||||||
|
'「无货 / 未应答」—— 少一条,下游读到的就是「没这回事」。渠道等级是场景尺度落到纸面的地方:' +
|
||||||
|
'BOM后期按主 → 备选 → 贸易商圈 2~3 家、<b>贸易商尽量不用</b>,现货只选有库存的。' +
|
||||||
|
'「有效期」是回价到期监听盯的那一列,过期即触发重询。表空着,是本单还没登记回价。',
|
||||||
|
),
|
||||||
|
{ text: '一料一渠一行' },
|
||||||
|
) +
|
||||||
|
section('寻源比价表(本单交付件)',
|
||||||
|
tableOnly('mine:quote_sourcing.compare_table', [
|
||||||
|
['型号 PN', 'pn', 'mono'],
|
||||||
|
['供应商', 'supplier'],
|
||||||
|
['回价', 'price', 'mono'],
|
||||||
|
['结论', 'conclusion'],
|
||||||
|
]) +
|
||||||
|
noteOnly(
|
||||||
|
'这是交回 PM 的成果本体:一料一结论,多渠道横比定底。<b>不实回价或远低行情的必须换渠道补询</b>,' +
|
||||||
|
'不得直接采用;现货场景不得选无库存转单商。采用策略由判定表出(直采 / 取均价 / 重询),' +
|
||||||
|
'不是模型自己说了算。表空着,是本单还没走到反馈交付。',
|
||||||
|
),
|
||||||
|
{ text: '交回 PM 的成果' },
|
||||||
|
) +
|
||||||
|
section('案例阶段(CMMN 生命周期)',
|
||||||
|
stagesOnly('case:stages') +
|
||||||
|
noteOnly(
|
||||||
|
'阶段的生命周期态由语义引擎推进到 <b>Enabled</b> 为止;凡触达世界或产出关键交付的计划项' +
|
||||||
|
'必须人工启动 —— <b>Enabled ≠ Active</b>。寻源是<b>一事一议</b>:几家供应商、谁先回、' +
|
||||||
|
'要不要追加一轮,事前排不出谱,所以调度权交给 AI,系统只保证边框:预算、停滞熔断、放行归人。',
|
||||||
|
),
|
||||||
|
{ text: '推进到 Enabled 为止' },
|
||||||
|
) +
|
||||||
|
section('当下可为(AI 的合法选项)',
|
||||||
|
stagesOnly('case:enabled') +
|
||||||
|
noteOnly(
|
||||||
|
'AI 只能在这个集合里选下一步:<b>可以越谱,不可越权</b>。发询价信是<b>触达世界</b>的动作 ——' +
|
||||||
|
'标「待启动」的项要人按一下再动,信发出去就收不回来。这一栏空着不是它偷懒,' +
|
||||||
|
'是当下确实无可为:要么在等人回话,要么在等供应商的回音。',
|
||||||
|
),
|
||||||
|
{ text: '本拍的合法选项' },
|
||||||
|
) +
|
||||||
|
section('目标缺口(离交付还差什么)',
|
||||||
|
stagesOnly('case:gaps') +
|
||||||
|
noteOnly(
|
||||||
|
'缺口只有人知道 → 调人;缺口在世界侧(等供应商回价)→ 立监听,<b>时限必配</b> ——' +
|
||||||
|
'没有时限的等待就是无限期挂起,而回价的时限本就写死在询价信里。' +
|
||||||
|
'当下可为 ∩ 目标所需 = 空,就是<b>依赖断点</b>。这一栏空着,是必做的事都办完了。',
|
||||||
|
),
|
||||||
|
{ text: '空 = 必做的都办完了' },
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export { ActivityNotInstalledError, InstallRejectedError, ToolNotDeclaredError }
|
|||||||
export type { Clock, IdGenerator, LedgerDeclarationApplier, ModelBindingProbe, RegistryStore, TemplateGuard, ToolContractProbe, } from './port.ts';
|
export type { Clock, IdGenerator, LedgerDeclarationApplier, ModelBindingProbe, RegistryStore, TemplateGuard, ToolContractProbe, } from './port.ts';
|
||||||
export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts';
|
export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts';
|
||||||
export { createRegistryService } 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 { 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 { createRegistryStore } from './internal/adapter/registry-store.ts';
|
||||||
export { activityAssetTable, activityPackageTable, assetIncrementTable } from './internal/adapter/registry-schema.ts';
|
export { activityAssetTable, activityPackageTable, assetIncrementTable } from './internal/adapter/registry-schema.ts';
|
||||||
|
|||||||
+33
-1
@@ -54,6 +54,8 @@ export type IssuedChatCard = {
|
|||||||
};
|
};
|
||||||
export type ReplyOutcome = {
|
export type ReplyOutcome = {
|
||||||
readonly order: WorkOrder;
|
readonly order: WorkOrder;
|
||||||
|
/** 这张卡挂在哪一单 —— 装配层要拿它把「人就地改的那几行」写回草稿层 */
|
||||||
|
readonly taskId: TaskId;
|
||||||
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
|
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
|
||||||
readonly producesCompletion: boolean;
|
readonly producesCompletion: boolean;
|
||||||
/** 驳回:环内回转,任务继续 */
|
/** 驳回:环内回转,任务继续 */
|
||||||
@@ -78,9 +80,39 @@ export type HumanToolService = {
|
|||||||
}): Promise<IssuedWorkOrder>;
|
}): Promise<IssuedWorkOrder>;
|
||||||
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
|
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
|
||||||
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
|
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
|
||||||
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard>;
|
/**
|
||||||
|
* 回传带上 `taskId` —— **数据录入卡的确认要当场写草稿**,而写库要知道写给哪一单。
|
||||||
|
* 由本模块自己写会让 human-tool 依赖 ledger(越层),所以把事实交出去,由组装层落笔。
|
||||||
|
*/
|
||||||
|
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{
|
||||||
|
card: ChatCard;
|
||||||
|
taskId: TaskId;
|
||||||
|
}>;
|
||||||
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
|
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
|
||||||
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
|
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
|
||||||
|
/**
|
||||||
|
* 给已发出的验收工单补上选择组(AM 1.2 恰选一口)。
|
||||||
|
*
|
||||||
|
* 为什么分两步:卡由 `release.submitForRelease` 发 —— 它守的是预检那道门,
|
||||||
|
* 不该也不必知道「这个活动有哪些出口」。出口是契约的事实,由 app 从 manifest 现读后贴上。
|
||||||
|
*/
|
||||||
|
attachPortChoice(workOrderId: string, choice: {
|
||||||
|
group: string;
|
||||||
|
ports: ReadonlyArray<{
|
||||||
|
name: string;
|
||||||
|
label?: string | undefined;
|
||||||
|
}>;
|
||||||
|
}): Promise<void>;
|
||||||
|
/**
|
||||||
|
* **算出来的去向**上卡 —— 不给人选,只告诉他走哪一口、凭哪一条。
|
||||||
|
* 去向是台账那几个数的函数;摆成选择题,等于把可回放的判定换成一次手感。
|
||||||
|
*/
|
||||||
|
attachRoutedPort(workOrderId: string, routed: {
|
||||||
|
group: string;
|
||||||
|
name: string;
|
||||||
|
label?: string | undefined;
|
||||||
|
because: string;
|
||||||
|
}): Promise<void>;
|
||||||
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||||
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
||||||
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type CardStore = {
|
|||||||
} | undefined>;
|
} | undefined>;
|
||||||
listWorkOrders(taskId: TaskId, openOnly: boolean): Promise<WorkOrder[]>;
|
listWorkOrders(taskId: TaskId, openOnly: boolean): Promise<WorkOrder[]>;
|
||||||
markWorkOrderDone(id: string, done: NonNullable<WorkOrder['done']>): Promise<void>;
|
markWorkOrderDone(id: string, done: NonNullable<WorkOrder['done']>): Promise<void>;
|
||||||
|
/** 局部改卡面 —— 只增不改是对**已回传**的卡;未答的卡还可补齐契约事实(如选择组) */
|
||||||
|
patchWorkOrder(id: string, patch: Partial<WorkOrder>): Promise<void>;
|
||||||
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
|
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
|
||||||
getChatCard(id: string): Promise<{
|
getChatCard(id: string): Promise<{
|
||||||
taskId: TaskId;
|
taskId: TaskId;
|
||||||
|
|||||||
+20
-1
@@ -50,17 +50,36 @@ export type InteractionService = {
|
|||||||
/** 视图流控:活动按局面推结构化块 */
|
/** 视图流控:活动按局面推结构化块 */
|
||||||
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
|
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
|
||||||
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
|
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
|
||||||
pushViewHtml(taskId: TaskId, html: string): Promise<void>;
|
/**
|
||||||
|
* 推一帧 HTML。`template` **要一起存下来** —— 件是投影,而投影得能按当下数据**重渲**。
|
||||||
|
*
|
||||||
|
* 只存渲染好的 HTML,那一帧就成了快照:人在件上改完几格,由数据算出来的东西
|
||||||
|
* (类徽 A/B/C、分布条、结论横幅那条去向)全停在渲染那一刻 ——
|
||||||
|
* 屏上一半新一半旧,那不是没更新,是界面在撒谎。记住模版名,才重得出来。
|
||||||
|
*/
|
||||||
|
pushViewHtml(taskId: TaskId, html: string, template?: string): Promise<void>;
|
||||||
/** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */
|
/** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */
|
||||||
viewOf(taskId: TaskId): Promise<{
|
viewOf(taskId: TaskId): Promise<{
|
||||||
blocks?: readonly ViewBlock[];
|
blocks?: readonly ViewBlock[];
|
||||||
html?: string;
|
html?: string;
|
||||||
|
template?: string;
|
||||||
} | undefined>;
|
} | undefined>;
|
||||||
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
|
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
|
||||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
|
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
|
||||||
phaseOf(taskId: TaskId): Promise<InteractionPhase>;
|
phaseOf(taskId: TaskId): Promise<InteractionPhase>;
|
||||||
/** SSE:单连接、按信封目标失效缓存 */
|
/** SSE:单连接、按信封目标失效缓存 */
|
||||||
subscribe(staffId: StaffId, onPush: (envelope: PushEnvelope) => void): Subscription;
|
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;
|
invalidate(target: 'task' | 'workflow' | 'desk', id: string): void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ export type InteractionStore = {
|
|||||||
putView(taskId: TaskId, view: {
|
putView(taskId: TaskId, view: {
|
||||||
blocks?: readonly ViewBlock[];
|
blocks?: readonly ViewBlock[];
|
||||||
html?: string;
|
html?: string;
|
||||||
|
template?: string;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
getView(taskId: TaskId): Promise<{
|
getView(taskId: TaskId): Promise<{
|
||||||
blocks?: readonly ViewBlock[];
|
blocks?: readonly ViewBlock[];
|
||||||
html?: string;
|
html?: string;
|
||||||
|
template?: string;
|
||||||
} | undefined>;
|
} | undefined>;
|
||||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
|
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
|
||||||
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
|
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ export type DraftWrite = {
|
|||||||
export type ReleaseWriteResult = {
|
export type ReleaseWriteResult = {
|
||||||
readonly mergedRows: number;
|
readonly mergedRows: number;
|
||||||
readonly emittedEvents: number;
|
readonly emittedEvents: number;
|
||||||
|
/**
|
||||||
|
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
|
||||||
|
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
|
||||||
|
* 非试跑单恒为 0。
|
||||||
|
*/
|
||||||
|
readonly simulatedRows: number;
|
||||||
/**
|
/**
|
||||||
* 合并进正式版的那些行(键 + 值)。
|
* 合并进正式版的那些行(键 + 值)。
|
||||||
* 「落地收敛,输出口才发射」——发射的载荷就该是**这些已落地的事实**,
|
* 「落地收敛,输出口才发射」——发射的载荷就该是**这些已落地的事实**,
|
||||||
@@ -78,6 +84,22 @@ export type LedgerService = {
|
|||||||
}>>;
|
}>>;
|
||||||
systemWrite(write: SystemWrite): Promise<void>;
|
systemWrite(write: SystemWrite): Promise<void>;
|
||||||
draftWrite(write: DraftWrite): Promise<void>;
|
draftWrite(write: DraftWrite): Promise<void>;
|
||||||
|
/**
|
||||||
|
* **撤掉本单草稿层的一行** —— 写错了得有退路(2026-08-01 加)。
|
||||||
|
*
|
||||||
|
* 不是第四种写:它只作用在**本单草稿层**,正式版一个字都动不了,也不抛事件 ——
|
||||||
|
* 撤的是「我这一单还没过门的那一笔」。放行门的位置没变。
|
||||||
|
*
|
||||||
|
* 没有它的后果实测过:客户说本轮跳过的那颗料被写进了有效清单,条目数报 9 而表里躺着 10,
|
||||||
|
* 判据 `record_rows = line_total` 正确地拦住,而模型**改不动** ——
|
||||||
|
* 它唯一能改的是那个数(改了 skipped_total 又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||||
|
* 门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。
|
||||||
|
*/
|
||||||
|
draftDrop(drop: {
|
||||||
|
taskId: TaskId;
|
||||||
|
address: ObjectAddress;
|
||||||
|
key: Record<string, unknown>;
|
||||||
|
}): Promise<boolean>;
|
||||||
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
|
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
|
||||||
/** 正式版查询 —— 跨任务只见过门的事实 */
|
/** 正式版查询 —— 跨任务只见过门的事实 */
|
||||||
query(query: LedgerQuery): Promise<LedgerPage>;
|
query(query: LedgerQuery): Promise<LedgerPage>;
|
||||||
|
|||||||
+32
-1
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* module: ledger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
* 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 { MaterializedObject, SchemaChange } from './internal/domain/schema-merge.ts';
|
||||||
import type { DraftEntry } from './internal/domain/task-view.ts';
|
import type { DraftEntry } from './internal/domain/task-view.ts';
|
||||||
export type Clock = {
|
export type Clock = {
|
||||||
@@ -14,6 +14,20 @@ export type IdGenerator = {
|
|||||||
export type EventPublisher = {
|
export type EventPublisher = {
|
||||||
publish(envelope: EnvelopeDraft): Promise<void>;
|
publish(envelope: EnvelopeDraft): Promise<void>;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* 这一单是不是**试跑** —— app 注入(设计台的两条试跑路径都算,见 `container.ts`)。
|
||||||
|
*
|
||||||
|
* 台账在 L1,认不出「谁起的单」;而放行写要不要落主数据,取决的恰恰是这件事。
|
||||||
|
* 所以由 app 把这一个布尔判断喂进来 —— 与 `EventPublisher` 同一个套路:
|
||||||
|
* 模块只声明「我需要知道什么」,不声明「去哪儿问」。
|
||||||
|
*
|
||||||
|
* 为什么只挡主数据、不挡单据:单据按单标识分行,一次试跑写一行自己的,
|
||||||
|
* 清运行数据就没了;主数据是**按业务实体**分行的档案,试跑往里建一位客户,
|
||||||
|
* 「新客户」那条样例就此再也试不出来 —— 它成了老客户。
|
||||||
|
*/
|
||||||
|
export type SimulationProbe = {
|
||||||
|
isSimulated(taskId: string): Promise<boolean>;
|
||||||
|
};
|
||||||
export type ObjectRef = {
|
export type ObjectRef = {
|
||||||
readonly domain: string;
|
readonly domain: string;
|
||||||
readonly object: string;
|
readonly object: string;
|
||||||
@@ -26,9 +40,18 @@ export type LedgerStore = {
|
|||||||
/** 已物化对象的列与主键;未物化返回 undefined */
|
/** 已物化对象的列与主键;未物化返回 undefined */
|
||||||
describe(ref: ObjectRef): Promise<MaterializedObject | undefined>;
|
describe(ref: ObjectRef): Promise<MaterializedObject | undefined>;
|
||||||
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
|
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
|
||||||
|
/** 记下对象性质(单据/主数据/派生)与单标识 —— 随声明走,不随建表走 */
|
||||||
|
setObjectMeta(ref: ObjectRef, kind: LedgerObjectKind, caseKey: string | undefined): Promise<void>;
|
||||||
listObjects(): Promise<Array<ObjectRef & {
|
listObjects(): Promise<Array<ObjectRef & {
|
||||||
label?: string | undefined;
|
label?: string | undefined;
|
||||||
}>>;
|
}>>;
|
||||||
|
/**
|
||||||
|
* 哪些对象是**主数据** —— 放行写要据此把试跑单的主数据挡在正式表外。
|
||||||
|
*
|
||||||
|
* 单独一个方法而不是让调用方 `listObjects` 自己筛:性质记在 `__object_meta` 里,
|
||||||
|
* 一次 where 就够;`describe` 读的是 information_schema,那里没有性质这一维。
|
||||||
|
*/
|
||||||
|
masterRefs(): Promise<ReadonlyArray<ObjectRef>>;
|
||||||
/** 执行「只增不破坏」的变更计划 */
|
/** 执行「只增不破坏」的变更计划 */
|
||||||
applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>;
|
applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>;
|
||||||
select(query: LedgerQuery): Promise<{
|
select(query: LedgerQuery): Promise<{
|
||||||
@@ -46,5 +69,13 @@ export type DraftStore = {
|
|||||||
list(taskId: string): Promise<Array<DraftEntry & ObjectRef>>;
|
list(taskId: string): Promise<Array<DraftEntry & ObjectRef>>;
|
||||||
listForObject(taskId: string, ref: ObjectRef): Promise<DraftEntry[]>;
|
listForObject(taskId: string, ref: ObjectRef): Promise<DraftEntry[]>;
|
||||||
put(taskId: string, ref: ObjectRef, entry: DraftEntry): Promise<void>;
|
put(taskId: string, ref: ObjectRef, entry: DraftEntry): Promise<void>;
|
||||||
|
/**
|
||||||
|
* 撤掉本单草稿层的**一行** —— 写错了得有退路(2026-08-01 加)。
|
||||||
|
*
|
||||||
|
* 只动 `taskId` 那一份,正式版碰不到。没有它的后果实测过:客户说本轮跳过的那颗料
|
||||||
|
* 被写进了有效清单,条目数报 9 而表里躺着 10 —— 判据正确地拦住,而模型改不动
|
||||||
|
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||||
|
*/
|
||||||
|
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
|
||||||
clear(taskId: string): Promise<void>;
|
clear(taskId: string): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export type ModelCall = {
|
|||||||
readonly prompt: string;
|
readonly prompt: string;
|
||||||
readonly temperature?: number;
|
readonly temperature?: number;
|
||||||
readonly maxOutputTokens?: number;
|
readonly maxOutputTokens?: number;
|
||||||
|
/**
|
||||||
|
* 压推理:`off` 不许想、`low` 少想;缺省由着模型。
|
||||||
|
* 只对画像里声明了 `reasoningControl` 的厂家生效 —— 没姿势的厂家如实忽略。
|
||||||
|
* 规划型调用(loop 单拍)该关:隐藏思考按秒计,而它的判断本就交给了判定表与工具。
|
||||||
|
*/
|
||||||
|
readonly reasoning?: 'off' | 'low';
|
||||||
/** 出参 schema(JSON Schema);给定即要求结构化出参并校验 */
|
/** 出参 schema(JSON Schema);给定即要求结构化出参并校验 */
|
||||||
readonly outputSchema?: Record<string, unknown>;
|
readonly outputSchema?: Record<string, unknown>;
|
||||||
/** 录制/回放的定位:任务 × 步序号(§7.3) */
|
/** 录制/回放的定位:任务 × 步序号(§7.3) */
|
||||||
|
|||||||
+229
-2
@@ -8,7 +8,8 @@ import type { LoopModel, TaskId } from '@contracts';
|
|||||||
import type { CaseModel, PlanItem, PlanItemType } from './internal/domain/cmmn-parse.ts';
|
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 { CaseRuntime, CaseState, EnabledEntry, EnabledSet, ItemRuntime, ItemState, Navigation, TerminalAuthority } from './internal/domain/lifecycle.ts';
|
||||||
import type { DecisionResult, DecisionTrace, Gap } from './internal/domain/dmn.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 { CmmnParseError } from './internal/domain/cmmn-parse.ts';
|
||||||
export { IllegalTransitionError } from './internal/domain/lifecycle.ts';
|
export { IllegalTransitionError } from './internal/domain/lifecycle.ts';
|
||||||
export declare class ForbiddenTerminalError extends Error {
|
export declare class ForbiddenTerminalError extends Error {
|
||||||
@@ -28,6 +29,48 @@ export type AgentAction =
|
|||||||
readonly tool: string;
|
readonly tool: string;
|
||||||
readonly input: Record<string, unknown>;
|
readonly input: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 写台账草稿 —— **活动过程写的唯一形态**(铁律②:登记那一刻世界上什么都没变)。
|
||||||
|
*
|
||||||
|
* 一拍可写多行:逐条把一份 BOM 解析成几十行,是**一件事**,不该拆成几十拍
|
||||||
|
* 各烧一次模型。地址必须在活动清单声明过的台账对象之内(越域写与越权调工具同罪)。
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
readonly kind: 'write';
|
||||||
|
readonly itemId: string;
|
||||||
|
/**
|
||||||
|
* 台账地址「域.对象」—— **一个字符串,不是两个字段**。
|
||||||
|
*
|
||||||
|
* 系统上下都用这一种写法(可写台账字典、`requiredDeliverables[].ledger` 都是它),
|
||||||
|
* 拆成 domain + object 只会让模型把整个地址塞进其中一格 —— 实测第一拍就这么错。
|
||||||
|
*/
|
||||||
|
readonly address: string;
|
||||||
|
readonly rows: ReadonlyArray<{
|
||||||
|
readonly key: Record<string, string | number>;
|
||||||
|
readonly patch: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* **撤掉自己写错的那一行** —— 写错了得有退路(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<Record<string, string | number>>;
|
||||||
|
readonly reason: string;
|
||||||
|
}
|
||||||
/** 调人:四卡之一 —— 调人 = 立监听听人 */
|
/** 调人:四卡之一 —— 调人 = 立监听听人 */
|
||||||
| {
|
| {
|
||||||
readonly kind: 'human';
|
readonly kind: 'human';
|
||||||
@@ -35,6 +78,119 @@ export type AgentAction =
|
|||||||
readonly card: Record<string, unknown>;
|
readonly card: Record<string, unknown>;
|
||||||
readonly workOrder: 'execution' | 'acceptance';
|
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<Record<string, string>>;
|
||||||
|
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<Record<string, string>>;
|
||||||
|
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 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */
|
/** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */
|
||||||
| {
|
| {
|
||||||
readonly kind: 'listen';
|
readonly kind: 'listen';
|
||||||
@@ -66,10 +222,38 @@ export type AgentAction =
|
|||||||
readonly satisfied: boolean;
|
readonly satisfied: boolean;
|
||||||
readonly rationale: string;
|
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 kind: 'submit';
|
||||||
readonly rationale: string;
|
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 cardId: string;
|
||||||
readonly rationale: 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 = {
|
export type TurnOutcome = {
|
||||||
readonly runtime: CaseRuntime;
|
readonly runtime: CaseRuntime;
|
||||||
readonly enabled: EnabledSet;
|
readonly enabled: EnabledSet;
|
||||||
@@ -96,6 +315,14 @@ export type LoopEngineService = {
|
|||||||
/** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */
|
/** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */
|
||||||
load(taskId: TaskId, model: LoopModel): Promise<CaseRuntime>;
|
load(taskId: TaskId, model: LoopModel): Promise<CaseRuntime>;
|
||||||
runtime(taskId: TaskId): Promise<CaseRuntime | undefined>;
|
runtime(taskId: TaskId): Promise<CaseRuntime | undefined>;
|
||||||
|
/**
|
||||||
|
* 进度刻度:这一单在第几阶段(见 `internal/domain/phase.ts`)。
|
||||||
|
*
|
||||||
|
* 由 loop 出而不是让调用方自己算:阶段声明与计划项状态**同在一行运行时里**,
|
||||||
|
* 调用方要自己算就得先把模型和运行时都捞出来 —— 那是把内部结构摊给外面。
|
||||||
|
* 活动没声明阶段就回 `undefined`:界面照此不画进度条,不编一个出来。
|
||||||
|
*/
|
||||||
|
phaseOf(taskId: TaskId, terminal: boolean): Promise<PhaseProgress | undefined>;
|
||||||
/** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */
|
/** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */
|
||||||
publish(taskId: TaskId): Promise<EnabledSet>;
|
publish(taskId: TaskId): Promise<EnabledSet>;
|
||||||
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
|
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
|
||||||
|
|||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* module: loop-engine —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
* 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 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, } from './contract.ts';
|
export { AutomaticOnWorldTouchingError, CmmnParseError, ForbiddenTerminalError, IllegalTransitionError, TURN_ACTION_CAP, } from './contract.ts';
|
||||||
export type { AgentPort, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
|
export type { AgentPort, CardGateway, Clock, ListenerRegistrar, LoopStateStore, MemoryRecorder, Sfeel, TaskContext, TaskDataGateway, ToolGateway, } from './port.ts';
|
||||||
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
|
export type { TurnRunnerDeps } from './internal/usecase/turn-runner.ts';
|
||||||
export { createTurnRunner } 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 { 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 { deduceGaps, evaluateTable, evaluationOrder, isBindingConclusion } from './internal/domain/dmn.ts';
|
||||||
export { createLoopStateStore } from './internal/adapter/loop-state-store.ts';
|
export { createLoopStateStore } from './internal/adapter/loop-state-store.ts';
|
||||||
export { loopStateTable } from './internal/adapter/loop-state-schema.ts';
|
export { loopStateTable } from './internal/adapter/loop-state-schema.ts';
|
||||||
|
|||||||
+200
-7
@@ -4,7 +4,7 @@
|
|||||||
import type { LoopModel, StaffId, TaskId } from '@contracts';
|
import type { LoopModel, StaffId, TaskId } from '@contracts';
|
||||||
import type { CaseModel } from './internal/domain/cmmn-parse.ts';
|
import type { CaseModel } from './internal/domain/cmmn-parse.ts';
|
||||||
import type { CaseRuntime, EnabledSet } from './internal/domain/lifecycle.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 = {
|
export type Clock = {
|
||||||
now(): Date;
|
now(): Date;
|
||||||
};
|
};
|
||||||
@@ -27,13 +27,53 @@ export type Sfeel = {
|
|||||||
/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */
|
/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */
|
||||||
export type TaskDataGateway = {
|
export type TaskDataGateway = {
|
||||||
view(taskId: TaskId): Promise<Record<string, unknown>>;
|
view(taskId: TaskId): Promise<Record<string, unknown>>;
|
||||||
|
/**
|
||||||
|
* 本活动**声明过**的台账对象(域.对象 → 列名与中文标)。
|
||||||
|
*
|
||||||
|
* 两用:喂给 Agent(不知道能写哪儿就只会瞎猜列名),以及校验 `write` 行动 ——
|
||||||
|
* 没声明过的地址一律拒写。越域写与越权调工具同罪,都得在结构上拦住。
|
||||||
|
*/
|
||||||
|
writable(taskId: TaskId): Promise<Array<{
|
||||||
|
domain: string;
|
||||||
|
object: string;
|
||||||
|
label: string;
|
||||||
|
keys: string[];
|
||||||
|
fields: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
}>;
|
||||||
|
}>>;
|
||||||
|
/**
|
||||||
|
* 写草稿,并回报**这一笔到底有没有改变什么**。
|
||||||
|
*
|
||||||
|
* 值和上次一模一样的写是空转。不回报,Agent 就看不出自己在原地打转 ——
|
||||||
|
* 实测:同一行 verdict 被反复重写十几拍,每拍烧一次模型,预算就这么淌完。
|
||||||
|
*/
|
||||||
draftWrite(input: {
|
draftWrite(input: {
|
||||||
taskId: TaskId;
|
taskId: TaskId;
|
||||||
domain: string;
|
domain: string;
|
||||||
object: string;
|
object: string;
|
||||||
key: Record<string, string | number>;
|
key: Record<string, string | number>;
|
||||||
patch: Record<string, unknown>;
|
patch: Record<string, unknown>;
|
||||||
}): Promise<void>;
|
}): Promise<{
|
||||||
|
changed: boolean;
|
||||||
|
}>;
|
||||||
|
/**
|
||||||
|
* **撤掉本单草稿层的一行** —— 写错了得有退路。
|
||||||
|
*
|
||||||
|
* 回 `false` = 草稿层里根本没有这一行(多半是主键写法对不上,
|
||||||
|
* `DAG…` 与 `DAG…-R0` 是两行)。如实说比静默成功有用:静默成功会让模型
|
||||||
|
* 以为撤掉了,下一拍判据照旧不过,它又去撤一次。
|
||||||
|
*/
|
||||||
|
draftDrop(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
domain: string;
|
||||||
|
object: string;
|
||||||
|
key: Record<string, string | number>;
|
||||||
|
}): Promise<{
|
||||||
|
dropped: boolean;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Agent 回路 —— app 注入(Mastra Agent 在 adapter,§6.1)。
|
* Agent 回路 —— app 注入(Mastra Agent 在 adapter,§6.1)。
|
||||||
@@ -46,7 +86,32 @@ export type AgentPort = {
|
|||||||
narrative: string;
|
narrative: string;
|
||||||
enabled: EnabledSet;
|
enabled: EnabledSet;
|
||||||
scope: Record<string, unknown>;
|
scope: Record<string, unknown>;
|
||||||
}): Promise<AgentAction>;
|
/**
|
||||||
|
* 这一单能调什么 —— **声明 × 目录**编译出来的工具清单(`compileToolBus`)。
|
||||||
|
*
|
||||||
|
* 不给它这份清单,它就只知道说明书里提过的工具名,连入参长什么样都得猜 ——
|
||||||
|
* 实测的后果是:该调工具的那一步改成了发一张卡问人(「请为以下三个PN查询历史价格」),
|
||||||
|
* 明明系统里就有这件工具。工具是声明式的,清单得摆在它眼前。
|
||||||
|
*/
|
||||||
|
tools: Array<{
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
effect: 'read' | 'write';
|
||||||
|
}>;
|
||||||
|
/** 本活动可写的台账地址与列名 —— 不给它字典,它只会照想象编列名 */
|
||||||
|
writable: Array<{
|
||||||
|
domain: string;
|
||||||
|
object: string;
|
||||||
|
label: string;
|
||||||
|
keys: string[];
|
||||||
|
fields: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}): Promise<AgentTurn>;
|
||||||
};
|
};
|
||||||
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
|
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
|
||||||
export type ToolGateway = {
|
export type ToolGateway = {
|
||||||
@@ -61,6 +126,16 @@ export type ToolGateway = {
|
|||||||
error?: {
|
error?: {
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* 已转人代办,等人回填(§6.2)——「人是工具的一种实现」。
|
||||||
|
*
|
||||||
|
* 系统里没有这件工具时,不是失败:基座已开了一张执行卡并立了监听。
|
||||||
|
* 这一拍该**挂起等人**,不该把计划项判 Failed —— 判 Failed 等于告诉 Agent
|
||||||
|
* 「这条路走不通,换一条」,可这条路明明正在走,只是走的人换成了人。
|
||||||
|
*/
|
||||||
|
awaitingHuman?: {
|
||||||
|
readonly workOrderId: string;
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
isWriteEffect(tool: string): Promise<boolean>;
|
isWriteEffect(tool: string): Promise<boolean>;
|
||||||
registerIntent(input: {
|
registerIntent(input: {
|
||||||
@@ -69,6 +144,13 @@ export type ToolGateway = {
|
|||||||
input: Record<string, unknown>;
|
input: Record<string, unknown>;
|
||||||
label: string;
|
label: string;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
|
/** 这一单能调什么(声明 × 目录)—— 单拍路径与开环路径吃同一份清单 */
|
||||||
|
busOf(activityId: string): Promise<Array<{
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
effect: 'read' | 'write';
|
||||||
|
}>>;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict ——
|
* 在途验收(提交放行的持久痕):卡发出即记,裁决到达再记 verdict ——
|
||||||
@@ -80,6 +162,39 @@ export type PendingAcceptance = {
|
|||||||
readonly selectedPorts?: Record<string, string>;
|
readonly selectedPorts?: Record<string, string>;
|
||||||
};
|
};
|
||||||
export type CardGateway = {
|
export type CardGateway = {
|
||||||
|
/** 数据录入卡 —— 人确认那一下由基座直接写草稿层,不回到 Agent 手里 */
|
||||||
|
entry(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
ownerId: StaffId;
|
||||||
|
humanCallCap: number;
|
||||||
|
ask: string;
|
||||||
|
ledger: string;
|
||||||
|
key: Readonly<Record<string, string>>;
|
||||||
|
fields: ReadonlyArray<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}>;
|
||||||
|
/** 同一次确认还要落的另几张表 —— 建一位新客户 = 公司档 + 联系人档 */
|
||||||
|
also?: ReadonlyArray<{
|
||||||
|
title: string;
|
||||||
|
ledger: string;
|
||||||
|
key: Readonly<Record<string, string>>;
|
||||||
|
fields: ReadonlyArray<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
basis?: ReadonlyArray<{
|
||||||
|
k: string;
|
||||||
|
v: string;
|
||||||
|
}>;
|
||||||
|
}): Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
issue(input: {
|
issue(input: {
|
||||||
taskId: TaskId;
|
taskId: TaskId;
|
||||||
ownerId: StaffId;
|
ownerId: StaffId;
|
||||||
@@ -89,6 +204,56 @@ export type CardGateway = {
|
|||||||
}): Promise<{
|
}): Promise<{
|
||||||
id: string;
|
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 = {
|
export type ListenerRegistrar = {
|
||||||
register(input: {
|
register(input: {
|
||||||
@@ -107,12 +272,21 @@ export type ListenerRegistrar = {
|
|||||||
* 零 LLM 即可成屏;模型只决定「下一步做什么」,不负责画面。
|
* 零 LLM 即可成屏;模型只决定「下一步做什么」,不负责画面。
|
||||||
*/
|
*/
|
||||||
export type ViewGateway = {
|
export type ViewGateway = {
|
||||||
|
/**
|
||||||
|
* 推一帧画面。**回报推没推上** —— 从前是 `Promise<void>` 且模版查不到就静默 return:
|
||||||
|
* 引擎每拍推一个硬编码的 `case-overview`,而 A1 声明的四张模版一个都不叫这个名字,
|
||||||
|
* 于是**每一帧都被悄悄丢掉**,工作区从头到尾空白,日志里也没有一个字。
|
||||||
|
* 静默失败比报错坏得多:它让「没画面」看起来像「活动还没产出」。
|
||||||
|
*/
|
||||||
push(input: {
|
push(input: {
|
||||||
taskId: TaskId;
|
taskId: TaskId;
|
||||||
activityId: string;
|
activityId: string;
|
||||||
template: string;
|
template: string;
|
||||||
scope: Record<string, unknown>;
|
scope: Record<string, unknown>;
|
||||||
}): Promise<void>;
|
}): Promise<{
|
||||||
|
pushed: boolean;
|
||||||
|
templates: string[];
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* 技能 —— app 注入 `skill-engine`(L4)。
|
* 技能 —— app 注入 `skill-engine`(L4)。
|
||||||
@@ -134,7 +308,7 @@ export type SkillGateway = {
|
|||||||
export type MemoryRecorder = {
|
export type MemoryRecorder = {
|
||||||
append(input: {
|
append(input: {
|
||||||
taskId: TaskId;
|
taskId: TaskId;
|
||||||
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation';
|
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation' | 'narration';
|
||||||
title: string;
|
title: string;
|
||||||
detail?: Record<string, unknown>;
|
detail?: Record<string, unknown>;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
@@ -143,10 +317,19 @@ export type MemoryRecorder = {
|
|||||||
layer: string;
|
layer: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
}>>;
|
}>>;
|
||||||
/** 本任务自己的最近轨迹(窗口大小由活动声明的 memoryWindow 定)—— 喂回叙事面,Agent 每拍不失忆 */
|
/**
|
||||||
window(taskId: TaskId): Promise<Array<{
|
* 本任务自己的最近轨迹 —— 喂回叙事面,Agent 每拍不失忆。
|
||||||
|
*
|
||||||
|
* 不给 `size` 就用活动声明的 `memoryWindow`(叙事面按它给;窗口越大越贵)。
|
||||||
|
* **熔断检测要另开一个更大的窗**:它数的是「同一堵墙撞了几次」,
|
||||||
|
* 而一次提交被拒会连带写下交代、评估、推画面好几条 —— 20 条的叙事窗里
|
||||||
|
* 至多装得下三四次撞墙,于是撞十次也数不到 5(实测:同一条判据拒回 10 次没熔断,
|
||||||
|
* 另一单拒回 33 次,40 万 token 烧到 exhausted 才停)。
|
||||||
|
*/
|
||||||
|
window(taskId: TaskId, size?: number): Promise<Array<{
|
||||||
kind: string;
|
kind: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
detail?: Record<string, unknown> | undefined;
|
||||||
}>>;
|
}>>;
|
||||||
};
|
};
|
||||||
export type TaskContext = {
|
export type TaskContext = {
|
||||||
@@ -155,4 +338,14 @@ export type TaskContext = {
|
|||||||
ownerId: StaffId;
|
ownerId: StaffId;
|
||||||
humanCallCap: number;
|
humanCallCap: number;
|
||||||
}>;
|
}>;
|
||||||
|
/**
|
||||||
|
* 这一单的**机检判据名**(归一化后的 key 与 label 都在里头)。
|
||||||
|
*
|
||||||
|
* 给 `judge` 行动当门用:L1/L2 是拿 S-FEEL 对台账求值的,Agent 裁不动它 ——
|
||||||
|
* 判据投影压根不读 Agent 对机检项的裁定。可从前引擎照收不误,还往轨迹里
|
||||||
|
* 写一句「语义性前提评估:XXX → 满足」:模型于是以为这条已经过了,
|
||||||
|
* 转头就 submit,被门以同一条判据拒回;再裁、再拒,连拒五拍熔断(实测 A2 死于此)。
|
||||||
|
* 轨迹里那句「满足」是**假话** —— 界面不撒谎,对 Agent 也一样。
|
||||||
|
*/
|
||||||
|
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export type SubmitForReleaseInput = {
|
|||||||
readonly criteria: readonly CriterionCheck[];
|
readonly criteria: readonly CriterionCheck[];
|
||||||
/** 报告可修订:AI 拟值已填的表单字段 */
|
/** 报告可修订:AI 拟值已填的表单字段 */
|
||||||
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
||||||
|
/**
|
||||||
|
* 验收页的正文块 —— 与执行工单同一套件,可含**绑台账的可编辑表**。
|
||||||
|
* 验收是最重的一道门:人得能在成果本身上改,不只是改几个整单级字段。
|
||||||
|
*/
|
||||||
|
readonly blocks?: ReadonlyArray<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
export type SubmitOutcome = {
|
export type SubmitOutcome = {
|
||||||
readonly report: PrecheckReport;
|
readonly report: PrecheckReport;
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export type LedgerGateway = {
|
|||||||
releaseWrite(taskId: TaskId): Promise<{
|
releaseWrite(taskId: TaskId): Promise<{
|
||||||
mergedRows: number;
|
mergedRows: number;
|
||||||
emittedEvents: number;
|
emittedEvents: number;
|
||||||
|
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
|
||||||
|
simulatedRows: number;
|
||||||
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
|
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
|
||||||
merged: ReadonlyArray<{
|
merged: ReadonlyArray<{
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -79,12 +81,27 @@ export type CardGateway = {
|
|||||||
label: string;
|
label: string;
|
||||||
level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5';
|
level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5';
|
||||||
pass: boolean;
|
pass: boolean;
|
||||||
|
settled?: boolean | undefined;
|
||||||
|
rationale?: string | undefined;
|
||||||
}>;
|
}>;
|
||||||
actions: ReadonlyArray<{
|
actions: ReadonlyArray<{
|
||||||
label: string;
|
label: string;
|
||||||
effect: 'read' | 'write';
|
effect: 'read' | 'write';
|
||||||
detail: string;
|
detail: string;
|
||||||
}>;
|
}>;
|
||||||
|
/**
|
||||||
|
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
||||||
|
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
||||||
|
* 不做多余属性检查,于是编译不报、运行时静默丢 —— 卡面 `fields` 恒空(踩过)。
|
||||||
|
*/
|
||||||
|
fields?: ReadonlyArray<Record<string, unknown>>;
|
||||||
|
/**
|
||||||
|
* 验收页的正文块 —— 绑台账的可编辑成果表走这里。
|
||||||
|
* **端口不声明它,值就在装配层被丢掉**(与 `fields` 同一个坑,又踩了一次:
|
||||||
|
* 契约声明了、submit 传了、release 转了,到装配层那个显式对象里被抹平,
|
||||||
|
* 编译不报、运行时静默丢 —— 卡面 blocks 恒空)。
|
||||||
|
*/
|
||||||
|
blocks?: ReadonlyArray<Record<string, unknown>>;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export type Task = {
|
|||||||
/** 起单来源:工作流节点;登记触发的直起单为 undefined */
|
/** 起单来源:工作流节点;登记触发的直起单为 undefined */
|
||||||
readonly workflowId?: string;
|
readonly workflowId?: string;
|
||||||
readonly nodeId?: string;
|
readonly nodeId?: string;
|
||||||
|
/** `testbed` = 设计台试跑 —— 认得出才拔得掉(仿真不该在别人工作台上过夜) */
|
||||||
|
readonly origin?: string;
|
||||||
readonly ownerId: StaffId;
|
readonly ownerId: StaffId;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly runtime: TaskRuntimeState;
|
readonly runtime: TaskRuntimeState;
|
||||||
@@ -52,6 +54,7 @@ export type StartTaskInput = {
|
|||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly workflowId?: string;
|
readonly workflowId?: string;
|
||||||
readonly nodeId?: string;
|
readonly nodeId?: string;
|
||||||
|
readonly origin?: string;
|
||||||
/** 输入:data(按 schema 校验,缺项不得启动)+ message(委托消息,可为空) */
|
/** 输入:data(按 schema 校验,缺项不得启动)+ message(委托消息,可为空) */
|
||||||
readonly data: Record<string, unknown>;
|
readonly data: Record<string, unknown>;
|
||||||
readonly message?: string;
|
readonly message?: string;
|
||||||
|
|||||||
+29
-2
@@ -26,10 +26,34 @@ export type ActivityLoader = {
|
|||||||
envelope: Record<string, unknown>;
|
envelope: Record<string, unknown>;
|
||||||
/** 输入 data 的 JSON Schema —— 缺项不得启动 */
|
/** 输入 data 的 JSON Schema —— 缺项不得启动 */
|
||||||
inputSchema: Record<string, unknown>;
|
inputSchema: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* 这一单是被一封信叫起来的 —— 活动清单声明的**来件字段映射**。
|
||||||
|
* 声明了,起单那一刻就把来件归进沟通记录(工作台那一屏)。
|
||||||
|
*/
|
||||||
|
inboundComm?: {
|
||||||
|
channel: string;
|
||||||
|
partyField: string;
|
||||||
|
subjectField?: string | undefined;
|
||||||
|
bodyField?: string | undefined;
|
||||||
|
attachmentsField?: string | undefined;
|
||||||
|
};
|
||||||
businessModel: unknown;
|
businessModel: unknown;
|
||||||
stallAfter: string;
|
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<void>;
|
||||||
|
};
|
||||||
export type SchemaValidator = {
|
export type SchemaValidator = {
|
||||||
validate(schema: Record<string, unknown>, value: unknown): {
|
validate(schema: Record<string, unknown>, value: unknown): {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -76,8 +100,11 @@ export type LandingGateway = {
|
|||||||
export type MemoryRecorder = {
|
export type MemoryRecorder = {
|
||||||
append(input: {
|
append(input: {
|
||||||
taskId: TaskId;
|
taskId: TaskId;
|
||||||
/** control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧 */
|
/**
|
||||||
kind: 'engine-transition' | 'terminal' | 'observation' | 'control';
|
* control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧。
|
||||||
|
* trigger = 触发事件:这一单被什么叫起来的 —— 不是机器产出,也不是谁的动作。
|
||||||
|
*/
|
||||||
|
kind: 'engine-transition' | 'terminal' | 'observation' | 'control' | 'trigger';
|
||||||
title: string;
|
title: string;
|
||||||
detail?: Record<string, unknown>;
|
detail?: Record<string, unknown>;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
|
|||||||
+5
-3
@@ -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 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 { 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 type { ToolInvokerDeps } from './internal/usecase/tool-invoker.ts';
|
||||||
export { createToolInvoker, toolReplayKey } 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 type { ToolboxServiceDeps } from './internal/usecase/toolbox-service.ts';
|
||||||
export { createToolboxService } 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 { 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 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 { 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 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';
|
export { replayToolTable, toolAllowlistTable, toolTable } from './internal/adapter/toolbox-schema.ts';
|
||||||
|
|||||||
@@ -43,6 +43,26 @@ export type ToolReplayStore = {
|
|||||||
* 工具执行 —— 实现对系统:server 侧执行代码,运行时受限,
|
* 工具执行 —— 实现对系统:server 侧执行代码,运行时受限,
|
||||||
* **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。
|
* **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 工具转人台(§6.2)—— 人是工具的一种实现:发出去的卡、答回来的出参都记在这。
|
||||||
|
* 内容地址 `callKey` 保证同一次调用只问人一次。
|
||||||
|
*/
|
||||||
|
export type ToolHandoffStore = {
|
||||||
|
find(taskId: string, callKey: string): Promise<ToolHandoff | undefined>;
|
||||||
|
open(handoff: Omit<ToolHandoff, 'state' | 'output'>): Promise<void>;
|
||||||
|
/** 人答完:值即出参 */
|
||||||
|
answer(workOrderId: string, output: Record<string, unknown>): Promise<ToolHandoff | undefined>;
|
||||||
|
};
|
||||||
|
export type ToolHandoff = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly taskId: string;
|
||||||
|
readonly tool: string;
|
||||||
|
readonly callKey: string;
|
||||||
|
readonly input: Record<string, unknown>;
|
||||||
|
readonly workOrderId: string;
|
||||||
|
readonly state: 'pending' | 'answered';
|
||||||
|
readonly output?: Record<string, unknown> | undefined;
|
||||||
|
};
|
||||||
export type ToolRuntime = {
|
export type ToolRuntime = {
|
||||||
execute(input: {
|
execute(input: {
|
||||||
readonly contract: ToolContract;
|
readonly contract: ToolContract;
|
||||||
|
|||||||
+2
-2
@@ -10,6 +10,6 @@ export { defineActivity } from './define.ts';
|
|||||||
export type { ActivityMemoryLayer, ChatCardDraft, FormFieldDraft, LedgerKey, LedgerRow, ViewBlockLike, WorkOrderDraft, } from './types.ts';
|
export type { ActivityMemoryLayer, ChatCardDraft, FormFieldDraft, LedgerKey, LedgerRow, ViewBlockLike, WorkOrderDraft, } from './types.ts';
|
||||||
export type { HostOptions, HostRecording, MockHost } from './host.ts';
|
export type { HostOptions, HostRecording, MockHost } from './host.ts';
|
||||||
export { createMockHost } from './host.ts';
|
export { createMockHost } from './host.ts';
|
||||||
export type { Tone } from './view.ts';
|
export type { ColStyle, FactTone, Stamp, Tone } from './view.ts';
|
||||||
export { kvCard, noteCard, stagesCard, tableCard, viewShell } 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";
|
export declare const SDK_VERSION = "0.1.0-m5";
|
||||||
|
|||||||
+343
-4
@@ -116,9 +116,9 @@ export declare const listGrantsRoute: {
|
|||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
kind: z.ZodLiteral<"governance">;
|
kind: z.ZodLiteral<"governance">;
|
||||||
function: z.ZodEnum<{
|
function: z.ZodEnum<{
|
||||||
|
trigger: "trigger";
|
||||||
data: "data";
|
data: "data";
|
||||||
task: "task";
|
task: "task";
|
||||||
trigger: "trigger";
|
|
||||||
system: "system";
|
system: "system";
|
||||||
tool: "tool";
|
tool: "tool";
|
||||||
activity: "activity";
|
activity: "activity";
|
||||||
@@ -155,9 +155,9 @@ export declare const setGrantsRoute: {
|
|||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
kind: z.ZodLiteral<"governance">;
|
kind: z.ZodLiteral<"governance">;
|
||||||
function: z.ZodEnum<{
|
function: z.ZodEnum<{
|
||||||
|
trigger: "trigger";
|
||||||
data: "data";
|
data: "data";
|
||||||
task: "task";
|
task: "task";
|
||||||
trigger: "trigger";
|
|
||||||
system: "system";
|
system: "system";
|
||||||
tool: "tool";
|
tool: "tool";
|
||||||
activity: "activity";
|
activity: "activity";
|
||||||
@@ -187,9 +187,9 @@ export declare const setGrantsRoute: {
|
|||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
kind: z.ZodLiteral<"governance">;
|
kind: z.ZodLiteral<"governance">;
|
||||||
function: z.ZodEnum<{
|
function: z.ZodEnum<{
|
||||||
|
trigger: "trigger";
|
||||||
data: "data";
|
data: "data";
|
||||||
task: "task";
|
task: "task";
|
||||||
trigger: "trigger";
|
|
||||||
system: "system";
|
system: "system";
|
||||||
tool: "tool";
|
tool: "tool";
|
||||||
activity: "activity";
|
activity: "activity";
|
||||||
@@ -508,6 +508,7 @@ export declare const govTaskLogRoute: {
|
|||||||
id: z.ZodString;
|
id: z.ZodString;
|
||||||
at: z.ZodString;
|
at: z.ZodString;
|
||||||
sideLabel: z.ZodString;
|
sideLabel: z.ZodString;
|
||||||
|
action: z.ZodString;
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -1835,12 +1836,20 @@ export declare const installActivityRoute: {
|
|||||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
||||||
messageHint: z.ZodOptional<z.ZodString>;
|
messageHint: z.ZodOptional<z.ZodString>;
|
||||||
|
inboundComm: z.ZodOptional<z.ZodObject<{
|
||||||
|
channel: z.ZodDefault<z.ZodString>;
|
||||||
|
partyField: z.ZodString;
|
||||||
|
subjectField: z.ZodOptional<z.ZodString>;
|
||||||
|
bodyField: z.ZodOptional<z.ZodString>;
|
||||||
|
attachmentsField: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
output: z.ZodObject<{
|
output: z.ZodObject<{
|
||||||
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
when: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
group: z.ZodString;
|
group: z.ZodString;
|
||||||
@@ -1848,6 +1857,7 @@ export declare const installActivityRoute: {
|
|||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
when: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -1855,6 +1865,9 @@ export declare const installActivityRoute: {
|
|||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
|
ledgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
editableAtRelease: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
acceptanceCriteria: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
acceptanceCriteria: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
@@ -1869,6 +1882,11 @@ export declare const installActivityRoute: {
|
|||||||
predicate: z.ZodOptional<z.ZodString>;
|
predicate: z.ZodOptional<z.ZodString>;
|
||||||
modelRef: z.ZodOptional<z.ZodString>;
|
modelRef: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
|
revisableAtRelease: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
key: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
prohibitions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
prohibitions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
statement: z.ZodString;
|
statement: z.ZodString;
|
||||||
@@ -1911,6 +1929,12 @@ export declare const installActivityRoute: {
|
|||||||
domain: z.ZodString;
|
domain: z.ZodString;
|
||||||
object: z.ZodString;
|
object: z.ZodString;
|
||||||
label: z.ZodOptional<z.ZodString>;
|
label: z.ZodOptional<z.ZodString>;
|
||||||
|
kind: z.ZodDefault<z.ZodEnum<{
|
||||||
|
record: "record";
|
||||||
|
master: "master";
|
||||||
|
derived: "derived";
|
||||||
|
}>>;
|
||||||
|
caseKey: z.ZodOptional<z.ZodString>;
|
||||||
fields: z.ZodArray<z.ZodObject<{
|
fields: z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
type: z.ZodEnum<{
|
type: z.ZodEnum<{
|
||||||
@@ -1925,6 +1949,10 @@ export declare const installActivityRoute: {
|
|||||||
label: z.ZodOptional<z.ZodString>;
|
label: z.ZodOptional<z.ZodString>;
|
||||||
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
||||||
masked: z.ZodDefault<z.ZodBoolean>;
|
masked: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
expect: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
auto: z.ZodOptional<z.ZodBoolean>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
@@ -2001,6 +2029,20 @@ export declare const installActivityRoute: {
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
steps: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
item: z.ZodString;
|
||||||
|
intent: z.ZodString;
|
||||||
|
guide: z.ZodString;
|
||||||
|
writes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
|
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
doneWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
items: z.ZodArray<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
skills: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
skills: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "SkillId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "SkillId", "out">;
|
||||||
@@ -2139,7 +2181,13 @@ export declare const listActivitiesRoute: {
|
|||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
type: z.ZodString;
|
type: z.ZodString;
|
||||||
required: z.ZodBoolean;
|
required: z.ZodBoolean;
|
||||||
|
label: z.ZodOptional<z.ZodString>;
|
||||||
|
format: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
|
samples: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
input: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
outputPorts: z.ZodArray<z.ZodObject<{
|
outputPorts: z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
@@ -2184,6 +2232,12 @@ export declare const listLedgerObjectsRoute: {
|
|||||||
domain: z.ZodString;
|
domain: z.ZodString;
|
||||||
object: z.ZodString;
|
object: z.ZodString;
|
||||||
label: z.ZodOptional<z.ZodString>;
|
label: z.ZodOptional<z.ZodString>;
|
||||||
|
kind: z.ZodOptional<z.ZodEnum<{
|
||||||
|
record: "record";
|
||||||
|
master: "master";
|
||||||
|
derived: "derived";
|
||||||
|
}>>;
|
||||||
|
caseKey: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
@@ -2355,6 +2409,30 @@ export declare const testRunRoute: {
|
|||||||
}>;
|
}>;
|
||||||
}, z.core.$strip>;
|
}, 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<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||||
|
message: z.ZodDefault<z.ZodString>;
|
||||||
|
ownerId: z.ZodDefault<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
taskId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* 停止仿真 —— **把这一单连根拔掉**:工作流、它的活动任务、工单、实例、trace 全部销毁。
|
* 停止仿真 —— **把这一单连根拔掉**:工作流、它的活动任务、工单、实例、trace 全部销毁。
|
||||||
* 仿真不是生产:试跑留下的东西不该在库里、在别人工作台上过夜。
|
* 仿真不是生产:试跑留下的东西不该在库里、在别人工作台上过夜。
|
||||||
@@ -2362,8 +2440,10 @@ export declare const testRunRoute: {
|
|||||||
export declare const testStopRoute: {
|
export declare const testStopRoute: {
|
||||||
readonly method: "POST";
|
readonly method: "POST";
|
||||||
readonly path: "/api/gov/testbed/stop";
|
readonly path: "/api/gov/testbed/stop";
|
||||||
|
/** 两种试法各有其拔法:流程包给 workflowId,单活动给 taskId;都不给 = 拔掉所有试跑残留 */
|
||||||
readonly body: z.ZodObject<{
|
readonly body: z.ZodObject<{
|
||||||
workflowId: z.ZodString;
|
workflowId: z.ZodDefault<z.ZodString>;
|
||||||
|
taskId: z.ZodDefault<z.ZodString>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
destroyed: z.ZodBoolean;
|
destroyed: z.ZodBoolean;
|
||||||
@@ -2371,12 +2451,271 @@ export declare const testStopRoute: {
|
|||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
/** 当前在跑的仿真(测试台一次只跑一条)—— 刷新页面也认得回来 */
|
/** 当前在跑的仿真(测试台一次只跑一条)—— 刷新页面也认得回来 */
|
||||||
|
/**
|
||||||
|
* 当前这一台仪器上的试跑单(测试台一次只跑一条)—— 刷新页面也认得回来。
|
||||||
|
*
|
||||||
|
* **返回还在跑的那一单,以及刚跑完的那一单** —— 两者要分得开:
|
||||||
|
* `live` 为真才锁旋钮(在跑的不许换台);为假表示它已收尾,观测面照旧摆着结果给人看,
|
||||||
|
* 但可以再起一单了。从前不分,已终结的单照样被当成「在跑」——
|
||||||
|
* 跑完之后旋钮永久锁住,人只能刷新整页才起得了下一单(实测)。
|
||||||
|
*/
|
||||||
export declare const testCurrentRoute: {
|
export declare const testCurrentRoute: {
|
||||||
readonly method: "GET";
|
readonly method: "GET";
|
||||||
readonly path: "/api/gov/testbed/current";
|
readonly path: "/api/gov/testbed/current";
|
||||||
readonly output: z.ZodObject<{
|
readonly output: z.ZodObject<{
|
||||||
workflowId: z.ZodOptional<z.ZodString>;
|
workflowId: z.ZodOptional<z.ZodString>;
|
||||||
subject: z.ZodOptional<z.ZodString>;
|
subject: z.ZodOptional<z.ZodString>;
|
||||||
|
taskId: z.ZodOptional<z.ZodString>;
|
||||||
|
activityId: z.ZodOptional<z.ZodString>;
|
||||||
|
live: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
terminal: z.ZodOptional<z.ZodString>;
|
||||||
|
}, 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<z.ZodEnum<{
|
||||||
|
released: "released";
|
||||||
|
blocked: "blocked";
|
||||||
|
stalled: "stalled";
|
||||||
|
exhausted: "exhausted";
|
||||||
|
terminated: "terminated";
|
||||||
|
}>>;
|
||||||
|
reason: z.ZodOptional<z.ZodString>;
|
||||||
|
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.ZodObject<{
|
||||||
|
at: z.ZodString;
|
||||||
|
kind: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
detail: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
drafts: z.ZodArray<z.ZodObject<{
|
||||||
|
address: z.ZodString;
|
||||||
|
key: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
|
patch: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
cards: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
mode: z.ZodEnum<{
|
||||||
|
execution: "execution";
|
||||||
|
acceptance: "acceptance";
|
||||||
|
}>;
|
||||||
|
title: z.ZodString;
|
||||||
|
done: z.ZodBoolean;
|
||||||
|
result: z.ZodOptional<z.ZodString>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
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<z.ZodString>;
|
||||||
|
note: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
export type UploadedFile = z.infer<typeof uploadFileRoute.output>;
|
||||||
|
/** 取回原件 —— 存的是真字节,人点得开才算真留住了 */
|
||||||
|
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<z.ZodString>;
|
||||||
|
workflowId: z.ZodOptional<z.ZodString>;
|
||||||
|
terminal: z.ZodOptional<z.ZodString>;
|
||||||
|
reason: z.ZodOptional<z.ZodString>;
|
||||||
|
used: z.ZodObject<{
|
||||||
|
steps: z.ZodOptional<z.ZodNumber>;
|
||||||
|
tokens: z.ZodOptional<z.ZodNumber>;
|
||||||
|
humanCalls: z.ZodOptional<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
caps: z.ZodObject<{
|
||||||
|
steps: z.ZodOptional<z.ZodNumber>;
|
||||||
|
tokens: z.ZodOptional<z.ZodNumber>;
|
||||||
|
humanCalls: z.ZodOptional<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
trailCount: z.ZodNumber;
|
||||||
|
draftCount: z.ZodNumber;
|
||||||
|
cardCount: z.ZodNumber;
|
||||||
|
startedAt: z.ZodOptional<z.ZodString>;
|
||||||
|
archivedAt: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type TestLogRow = z.infer<typeof testLogRowSchema>;
|
||||||
|
export declare const listTestLogsRoute: {
|
||||||
|
readonly method: "GET";
|
||||||
|
readonly path: "/api/gov/testbed/logs";
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
runs: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
kind: z.ZodEnum<{
|
||||||
|
activity: "activity";
|
||||||
|
package: "package";
|
||||||
|
}>;
|
||||||
|
subject: z.ZodString;
|
||||||
|
target: z.ZodString;
|
||||||
|
taskId: z.ZodOptional<z.ZodString>;
|
||||||
|
workflowId: z.ZodOptional<z.ZodString>;
|
||||||
|
terminal: z.ZodOptional<z.ZodString>;
|
||||||
|
reason: z.ZodOptional<z.ZodString>;
|
||||||
|
used: z.ZodObject<{
|
||||||
|
steps: z.ZodOptional<z.ZodNumber>;
|
||||||
|
tokens: z.ZodOptional<z.ZodNumber>;
|
||||||
|
humanCalls: z.ZodOptional<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
caps: z.ZodObject<{
|
||||||
|
steps: z.ZodOptional<z.ZodNumber>;
|
||||||
|
tokens: z.ZodOptional<z.ZodNumber>;
|
||||||
|
humanCalls: z.ZodOptional<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
trailCount: z.ZodNumber;
|
||||||
|
draftCount: z.ZodNumber;
|
||||||
|
cardCount: z.ZodNumber;
|
||||||
|
startedAt: z.ZodOptional<z.ZodString>;
|
||||||
|
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<z.ZodString>;
|
||||||
|
workflowId: z.ZodOptional<z.ZodString>;
|
||||||
|
terminal: z.ZodOptional<z.ZodString>;
|
||||||
|
reason: z.ZodOptional<z.ZodString>;
|
||||||
|
used: z.ZodObject<{
|
||||||
|
steps: z.ZodOptional<z.ZodNumber>;
|
||||||
|
tokens: z.ZodOptional<z.ZodNumber>;
|
||||||
|
humanCalls: z.ZodOptional<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
caps: z.ZodObject<{
|
||||||
|
steps: z.ZodOptional<z.ZodNumber>;
|
||||||
|
tokens: z.ZodOptional<z.ZodNumber>;
|
||||||
|
humanCalls: z.ZodOptional<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
trailCount: z.ZodNumber;
|
||||||
|
draftCount: z.ZodNumber;
|
||||||
|
cardCount: z.ZodNumber;
|
||||||
|
startedAt: z.ZodOptional<z.ZodString>;
|
||||||
|
archivedAt: z.ZodString;
|
||||||
|
trail: z.ZodArray<z.ZodObject<{
|
||||||
|
at: z.ZodString;
|
||||||
|
kind: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
detail: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
drafts: z.ZodArray<z.ZodObject<{
|
||||||
|
address: z.ZodString;
|
||||||
|
key: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
|
patch: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
cards: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
mode: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
done: z.ZodBoolean;
|
||||||
|
result: z.ZodOptional<z.ZodString>;
|
||||||
|
}, 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>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
/** 全链 Trace:一条工作流的 span 树(节点 → 步骤 / 工具 / 模型 / 人卡) */
|
/** 全链 Trace:一条工作流的 span 树(节点 → 步骤 / 工具 / 模型 / 人卡) */
|
||||||
|
|||||||
+758
-2
File diff suppressed because it is too large
Load Diff
+614
@@ -70,6 +70,7 @@ export declare const formFieldSchema: z.ZodObject<{
|
|||||||
group: z.ZodOptional<z.ZodString>;
|
group: z.ZodOptional<z.ZodString>;
|
||||||
hint: z.ZodOptional<z.ZodString>;
|
hint: z.ZodOptional<z.ZodString>;
|
||||||
ai: z.ZodOptional<z.ZodString>;
|
ai: z.ZodOptional<z.ZodString>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
unit: z.ZodOptional<z.ZodString>;
|
unit: z.ZodOptional<z.ZodString>;
|
||||||
required: z.ZodOptional<z.ZodBoolean>;
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||||
@@ -144,6 +145,8 @@ export declare const viewBlockSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||||
editable: z.ZodOptional<z.ZodBoolean>;
|
editable: z.ZodOptional<z.ZodBoolean>;
|
||||||
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
|
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
linkDonut: z.ZodOptional<z.ZodObject<{
|
linkDonut: z.ZodOptional<z.ZodObject<{
|
||||||
col: z.ZodNumber;
|
col: z.ZodNumber;
|
||||||
title: z.ZodOptional<z.ZodString>;
|
title: z.ZodOptional<z.ZodString>;
|
||||||
@@ -379,6 +382,8 @@ export declare const deliverableSchema: z.ZodObject<{
|
|||||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||||
editable: z.ZodOptional<z.ZodBoolean>;
|
editable: z.ZodOptional<z.ZodBoolean>;
|
||||||
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
|
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
linkDonut: z.ZodOptional<z.ZodObject<{
|
linkDonut: z.ZodOptional<z.ZodObject<{
|
||||||
col: z.ZodNumber;
|
col: z.ZodNumber;
|
||||||
title: z.ZodOptional<z.ZodString>;
|
title: z.ZodOptional<z.ZodString>;
|
||||||
@@ -641,6 +646,8 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
|||||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||||
editable: z.ZodOptional<z.ZodBoolean>;
|
editable: z.ZodOptional<z.ZodBoolean>;
|
||||||
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
|
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
linkDonut: z.ZodOptional<z.ZodObject<{
|
linkDonut: z.ZodOptional<z.ZodObject<{
|
||||||
col: z.ZodNumber;
|
col: z.ZodNumber;
|
||||||
title: z.ZodOptional<z.ZodString>;
|
title: z.ZodOptional<z.ZodString>;
|
||||||
@@ -837,11 +844,17 @@ export declare const executionWorkOrderSchema: z.ZodObject<{
|
|||||||
group: z.ZodOptional<z.ZodString>;
|
group: z.ZodOptional<z.ZodString>;
|
||||||
hint: z.ZodOptional<z.ZodString>;
|
hint: z.ZodOptional<z.ZodString>;
|
||||||
ai: z.ZodOptional<z.ZodString>;
|
ai: z.ZodOptional<z.ZodString>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
unit: z.ZodOptional<z.ZodString>;
|
unit: z.ZodOptional<z.ZodString>;
|
||||||
required: z.ZodOptional<z.ZodBoolean>;
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
submitLabel: z.ZodOptional<z.ZodString>;
|
submitLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
gate: z.ZodOptional<z.ZodObject<{
|
||||||
|
ready: z.ZodBoolean;
|
||||||
|
why: z.ZodString;
|
||||||
|
blanks: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
@@ -873,6 +886,8 @@ export declare const acceptanceCriterionCheckSchema: z.ZodObject<{
|
|||||||
L5: "L5";
|
L5: "L5";
|
||||||
}>;
|
}>;
|
||||||
pass: z.ZodBoolean;
|
pass: z.ZodBoolean;
|
||||||
|
settled: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
rationale: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type AcceptanceCriterionCheck = z.infer<typeof acceptanceCriterionCheckSchema>;
|
export type AcceptanceCriterionCheck = z.infer<typeof acceptanceCriterionCheckSchema>;
|
||||||
/** 待落地动作意向:预检全过才上卡,卡面呈现的即已验证的动作清单(OS 7.3) */
|
/** 待落地动作意向:预检全过才上卡,卡面呈现的即已验证的动作清单(OS 7.3) */
|
||||||
@@ -900,6 +915,8 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
|||||||
L5: "L5";
|
L5: "L5";
|
||||||
}>;
|
}>;
|
||||||
pass: z.ZodBoolean;
|
pass: z.ZodBoolean;
|
||||||
|
settled: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
rationale: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
@@ -932,10 +949,221 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
|||||||
group: z.ZodOptional<z.ZodString>;
|
group: z.ZodOptional<z.ZodString>;
|
||||||
hint: z.ZodOptional<z.ZodString>;
|
hint: z.ZodOptional<z.ZodString>;
|
||||||
ai: z.ZodOptional<z.ZodString>;
|
ai: z.ZodOptional<z.ZodString>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
unit: z.ZodOptional<z.ZodString>;
|
unit: z.ZodOptional<z.ZodString>;
|
||||||
required: z.ZodOptional<z.ZodBoolean>;
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
|
blocks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"callout">;
|
||||||
|
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<z.ZodString>;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
k: z.ZodString;
|
||||||
|
v: z.ZodString;
|
||||||
|
fromItem: z.ZodOptional<z.ZodObject<{
|
||||||
|
item: z.ZodString;
|
||||||
|
field: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"badges">;
|
||||||
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
label: z.ZodString;
|
||||||
|
on: z.ZodBoolean;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"table">;
|
||||||
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
cols: z.ZodArray<z.ZodString>;
|
||||||
|
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||||
|
editable: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
|
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
linkDonut: z.ZodOptional<z.ZodObject<{
|
||||||
|
col: z.ZodNumber;
|
||||||
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
unit: z.ZodOptional<z.ZodString>;
|
||||||
|
map: z.ZodArray<z.ZodObject<{
|
||||||
|
match: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
tone: z.ZodEnum<{
|
||||||
|
ok: "ok";
|
||||||
|
warn: "warn";
|
||||||
|
danger: "danger";
|
||||||
|
brand: "brand";
|
||||||
|
muted: "muted";
|
||||||
|
}>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"bars">;
|
||||||
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
max: z.ZodOptional<z.ZodNumber>;
|
||||||
|
redline: z.ZodOptional<z.ZodNumber>;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
label: z.ZodString;
|
||||||
|
value: z.ZodNumber;
|
||||||
|
text: z.ZodOptional<z.ZodString>;
|
||||||
|
tone: z.ZodOptional<z.ZodEnum<{
|
||||||
|
ok: "ok";
|
||||||
|
warn: "warn";
|
||||||
|
danger: "danger";
|
||||||
|
brand: "brand";
|
||||||
|
muted: "muted";
|
||||||
|
}>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"donut">;
|
||||||
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
unit: z.ZodOptional<z.ZodString>;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
label: z.ZodString;
|
||||||
|
value: z.ZodNumber;
|
||||||
|
tone: z.ZodEnum<{
|
||||||
|
ok: "ok";
|
||||||
|
warn: "warn";
|
||||||
|
danger: "danger";
|
||||||
|
brand: "brand";
|
||||||
|
muted: "muted";
|
||||||
|
}>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"mail">;
|
||||||
|
dir: z.ZodEnum<{
|
||||||
|
out: "out";
|
||||||
|
in: "in";
|
||||||
|
}>;
|
||||||
|
statusLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
editable: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
from: z.ZodString;
|
||||||
|
fromAddr: z.ZodOptional<z.ZodString>;
|
||||||
|
to: z.ZodString;
|
||||||
|
cc: z.ZodOptional<z.ZodString>;
|
||||||
|
subject: z.ZodString;
|
||||||
|
time: z.ZodString;
|
||||||
|
body: z.ZodString;
|
||||||
|
attachments: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
meta: z.ZodOptional<z.ZodString>;
|
||||||
|
file: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
kind: z.ZodLiteral<"html">;
|
||||||
|
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.ZodArray<z.ZodString>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
kind: z.ZodLiteral<"word">;
|
||||||
|
name: z.ZodString;
|
||||||
|
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
||||||
|
t: z.ZodEnum<{
|
||||||
|
h1: "h1";
|
||||||
|
h2: "h2";
|
||||||
|
p: "p";
|
||||||
|
}>;
|
||||||
|
text: z.ZodString;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"ul">;
|
||||||
|
items: z.ZodArray<z.ZodString>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
tag: z.ZodString;
|
||||||
|
icon: z.ZodOptional<z.ZodString>;
|
||||||
|
tone: z.ZodOptional<z.ZodEnum<{
|
||||||
|
ok: "ok";
|
||||||
|
warn: "warn";
|
||||||
|
danger: "danger";
|
||||||
|
brand: "brand";
|
||||||
|
muted: "muted";
|
||||||
|
}>>;
|
||||||
|
title: z.ZodString;
|
||||||
|
code: z.ZodOptional<z.ZodString>;
|
||||||
|
swap: z.ZodOptional<z.ZodObject<{
|
||||||
|
from: z.ZodString;
|
||||||
|
to: z.ZodString;
|
||||||
|
hint: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
big: z.ZodString;
|
||||||
|
bigLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
meta: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
k: z.ZodString;
|
||||||
|
v: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
from: z.ZodOptional<z.ZodString>;
|
||||||
|
time: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"waiting">;
|
||||||
|
label: z.ZodString;
|
||||||
|
hint: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"files">;
|
||||||
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
label: z.ZodString;
|
||||||
|
desc: z.ZodString;
|
||||||
|
file: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
kind: z.ZodLiteral<"html">;
|
||||||
|
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.ZodArray<z.ZodString>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
kind: z.ZodLiteral<"word">;
|
||||||
|
name: z.ZodString;
|
||||||
|
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
||||||
|
t: z.ZodEnum<{
|
||||||
|
h1: "h1";
|
||||||
|
h2: "h2";
|
||||||
|
p: "p";
|
||||||
|
}>;
|
||||||
|
text: z.ZodString;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"ul">;
|
||||||
|
items: z.ZodArray<z.ZodString>;
|
||||||
|
}, 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.ZodObject<{
|
portChoice: z.ZodOptional<z.ZodObject<{
|
||||||
group: z.ZodString;
|
group: z.ZodString;
|
||||||
ports: z.ZodArray<z.ZodObject<{
|
ports: z.ZodArray<z.ZodObject<{
|
||||||
@@ -943,6 +1171,22 @@ export declare const acceptanceWorkOrderSchema: z.ZodObject<{
|
|||||||
label: z.ZodOptional<z.ZodString>;
|
label: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
|
landing: z.ZodOptional<z.ZodObject<{
|
||||||
|
rows: z.ZodNumber;
|
||||||
|
simulated: z.ZodDefault<z.ZodNumber>;
|
||||||
|
tables: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
label: z.ZodString;
|
||||||
|
rows: z.ZodNumber;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
ports: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
carried: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
routedPort: z.ZodOptional<z.ZodObject<{
|
||||||
|
group: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
label: z.ZodOptional<z.ZodString>;
|
||||||
|
because: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
passLabel: z.ZodOptional<z.ZodString>;
|
passLabel: z.ZodOptional<z.ZodString>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
@@ -1009,6 +1253,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||||
editable: z.ZodOptional<z.ZodBoolean>;
|
editable: z.ZodOptional<z.ZodBoolean>;
|
||||||
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
|
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
linkDonut: z.ZodOptional<z.ZodObject<{
|
linkDonut: z.ZodOptional<z.ZodObject<{
|
||||||
col: z.ZodNumber;
|
col: z.ZodNumber;
|
||||||
title: z.ZodOptional<z.ZodString>;
|
title: z.ZodOptional<z.ZodString>;
|
||||||
@@ -1205,11 +1451,17 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
group: z.ZodOptional<z.ZodString>;
|
group: z.ZodOptional<z.ZodString>;
|
||||||
hint: z.ZodOptional<z.ZodString>;
|
hint: z.ZodOptional<z.ZodString>;
|
||||||
ai: z.ZodOptional<z.ZodString>;
|
ai: z.ZodOptional<z.ZodString>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
unit: z.ZodOptional<z.ZodString>;
|
unit: z.ZodOptional<z.ZodString>;
|
||||||
required: z.ZodOptional<z.ZodBoolean>;
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
submitLabel: z.ZodOptional<z.ZodString>;
|
submitLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
gate: z.ZodOptional<z.ZodObject<{
|
||||||
|
ready: z.ZodBoolean;
|
||||||
|
why: z.ZodString;
|
||||||
|
blanks: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
title: z.ZodString;
|
title: z.ZodString;
|
||||||
@@ -1242,6 +1494,8 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
L5: "L5";
|
L5: "L5";
|
||||||
}>;
|
}>;
|
||||||
pass: z.ZodBoolean;
|
pass: z.ZodBoolean;
|
||||||
|
settled: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
rationale: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
@@ -1274,10 +1528,221 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
group: z.ZodOptional<z.ZodString>;
|
group: z.ZodOptional<z.ZodString>;
|
||||||
hint: z.ZodOptional<z.ZodString>;
|
hint: z.ZodOptional<z.ZodString>;
|
||||||
ai: z.ZodOptional<z.ZodString>;
|
ai: z.ZodOptional<z.ZodString>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
unit: z.ZodOptional<z.ZodString>;
|
unit: z.ZodOptional<z.ZodString>;
|
||||||
required: z.ZodOptional<z.ZodBoolean>;
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
cols: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>]>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
|
blocks: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"callout">;
|
||||||
|
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<z.ZodString>;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
k: z.ZodString;
|
||||||
|
v: z.ZodString;
|
||||||
|
fromItem: z.ZodOptional<z.ZodObject<{
|
||||||
|
item: z.ZodString;
|
||||||
|
field: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"badges">;
|
||||||
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
label: z.ZodString;
|
||||||
|
on: z.ZodBoolean;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"table">;
|
||||||
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
cols: z.ZodArray<z.ZodString>;
|
||||||
|
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
||||||
|
editable: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
editableCols: z.ZodOptional<z.ZodArray<z.ZodNumber>>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
|
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
linkDonut: z.ZodOptional<z.ZodObject<{
|
||||||
|
col: z.ZodNumber;
|
||||||
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
unit: z.ZodOptional<z.ZodString>;
|
||||||
|
map: z.ZodArray<z.ZodObject<{
|
||||||
|
match: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
tone: z.ZodEnum<{
|
||||||
|
ok: "ok";
|
||||||
|
warn: "warn";
|
||||||
|
danger: "danger";
|
||||||
|
brand: "brand";
|
||||||
|
muted: "muted";
|
||||||
|
}>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"bars">;
|
||||||
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
max: z.ZodOptional<z.ZodNumber>;
|
||||||
|
redline: z.ZodOptional<z.ZodNumber>;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
label: z.ZodString;
|
||||||
|
value: z.ZodNumber;
|
||||||
|
text: z.ZodOptional<z.ZodString>;
|
||||||
|
tone: z.ZodOptional<z.ZodEnum<{
|
||||||
|
ok: "ok";
|
||||||
|
warn: "warn";
|
||||||
|
danger: "danger";
|
||||||
|
brand: "brand";
|
||||||
|
muted: "muted";
|
||||||
|
}>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"donut">;
|
||||||
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
unit: z.ZodOptional<z.ZodString>;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
label: z.ZodString;
|
||||||
|
value: z.ZodNumber;
|
||||||
|
tone: z.ZodEnum<{
|
||||||
|
ok: "ok";
|
||||||
|
warn: "warn";
|
||||||
|
danger: "danger";
|
||||||
|
brand: "brand";
|
||||||
|
muted: "muted";
|
||||||
|
}>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"mail">;
|
||||||
|
dir: z.ZodEnum<{
|
||||||
|
out: "out";
|
||||||
|
in: "in";
|
||||||
|
}>;
|
||||||
|
statusLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
editable: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
from: z.ZodString;
|
||||||
|
fromAddr: z.ZodOptional<z.ZodString>;
|
||||||
|
to: z.ZodString;
|
||||||
|
cc: z.ZodOptional<z.ZodString>;
|
||||||
|
subject: z.ZodString;
|
||||||
|
time: z.ZodString;
|
||||||
|
body: z.ZodString;
|
||||||
|
attachments: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
meta: z.ZodOptional<z.ZodString>;
|
||||||
|
file: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
kind: z.ZodLiteral<"html">;
|
||||||
|
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.ZodArray<z.ZodString>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
kind: z.ZodLiteral<"word">;
|
||||||
|
name: z.ZodString;
|
||||||
|
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
||||||
|
t: z.ZodEnum<{
|
||||||
|
h1: "h1";
|
||||||
|
h2: "h2";
|
||||||
|
p: "p";
|
||||||
|
}>;
|
||||||
|
text: z.ZodString;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"ul">;
|
||||||
|
items: z.ZodArray<z.ZodString>;
|
||||||
|
}, 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<z.ZodString>;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
tag: z.ZodString;
|
||||||
|
icon: z.ZodOptional<z.ZodString>;
|
||||||
|
tone: z.ZodOptional<z.ZodEnum<{
|
||||||
|
ok: "ok";
|
||||||
|
warn: "warn";
|
||||||
|
danger: "danger";
|
||||||
|
brand: "brand";
|
||||||
|
muted: "muted";
|
||||||
|
}>>;
|
||||||
|
title: z.ZodString;
|
||||||
|
code: z.ZodOptional<z.ZodString>;
|
||||||
|
swap: z.ZodOptional<z.ZodObject<{
|
||||||
|
from: z.ZodString;
|
||||||
|
to: z.ZodString;
|
||||||
|
hint: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
big: z.ZodString;
|
||||||
|
bigLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
meta: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
k: z.ZodString;
|
||||||
|
v: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
from: z.ZodOptional<z.ZodString>;
|
||||||
|
time: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"waiting">;
|
||||||
|
label: z.ZodString;
|
||||||
|
hint: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"files">;
|
||||||
|
title: z.ZodOptional<z.ZodString>;
|
||||||
|
items: z.ZodArray<z.ZodObject<{
|
||||||
|
label: z.ZodString;
|
||||||
|
desc: z.ZodString;
|
||||||
|
file: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
||||||
|
kind: z.ZodLiteral<"html">;
|
||||||
|
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.ZodArray<z.ZodString>>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
kind: z.ZodLiteral<"word">;
|
||||||
|
name: z.ZodString;
|
||||||
|
blocks: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
||||||
|
t: z.ZodEnum<{
|
||||||
|
h1: "h1";
|
||||||
|
h2: "h2";
|
||||||
|
p: "p";
|
||||||
|
}>;
|
||||||
|
text: z.ZodString;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
t: z.ZodLiteral<"ul">;
|
||||||
|
items: z.ZodArray<z.ZodString>;
|
||||||
|
}, 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.ZodObject<{
|
portChoice: z.ZodOptional<z.ZodObject<{
|
||||||
group: z.ZodString;
|
group: z.ZodString;
|
||||||
ports: z.ZodArray<z.ZodObject<{
|
ports: z.ZodArray<z.ZodObject<{
|
||||||
@@ -1285,6 +1750,22 @@ export declare const workOrderSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
label: z.ZodOptional<z.ZodString>;
|
label: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
|
landing: z.ZodOptional<z.ZodObject<{
|
||||||
|
rows: z.ZodNumber;
|
||||||
|
simulated: z.ZodDefault<z.ZodNumber>;
|
||||||
|
tables: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
label: z.ZodString;
|
||||||
|
rows: z.ZodNumber;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
ports: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
carried: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
routedPort: z.ZodOptional<z.ZodObject<{
|
||||||
|
group: z.ZodString;
|
||||||
|
name: z.ZodString;
|
||||||
|
label: z.ZodOptional<z.ZodString>;
|
||||||
|
because: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
passLabel: z.ZodOptional<z.ZodString>;
|
passLabel: z.ZodOptional<z.ZodString>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "WorkOrderId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
@@ -1355,6 +1836,7 @@ export declare const elicitationCardSchema: z.ZodObject<{
|
|||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
placeholder: z.ZodOptional<z.ZodString>;
|
placeholder: z.ZodOptional<z.ZodString>;
|
||||||
|
value: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
done: z.ZodOptional<z.ZodObject<{
|
done: z.ZodOptional<z.ZodObject<{
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
@@ -1366,6 +1848,49 @@ export declare const elicitationCardSchema: z.ZodObject<{
|
|||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type ElicitationCard = z.infer<typeof elicitationCardSchema>;
|
export type ElicitationCard = z.infer<typeof elicitationCardSchema>;
|
||||||
|
export declare const dataEntryCardSchema: z.ZodObject<{
|
||||||
|
k: z.ZodLiteral<"entry">;
|
||||||
|
ask: z.ZodString;
|
||||||
|
basis: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
k: z.ZodString;
|
||||||
|
v: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
ledgerLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
key: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
|
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
fields: z.ZodArray<z.ZodObject<{
|
||||||
|
key: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
value: z.ZodOptional<z.ZodString>;
|
||||||
|
required: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
placeholder: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
autoNote: z.ZodOptional<z.ZodString>;
|
||||||
|
also: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
title: z.ZodString;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
ledgerLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
key: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
|
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
fields: z.ZodArray<z.ZodObject<{
|
||||||
|
key: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
value: z.ZodOptional<z.ZodString>;
|
||||||
|
required: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
placeholder: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
autoNote: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
done: z.ZodOptional<z.ZodObject<{
|
||||||
|
at: z.ZodISODateTime;
|
||||||
|
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
values: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
|
at: z.ZodISODateTime;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type DataEntryCard = z.infer<typeof dataEntryCardSchema>;
|
||||||
/** 评价卡:任务评价(评分 1–5 + 改进建议) */
|
/** 评价卡:任务评价(评分 1–5 + 改进建议) */
|
||||||
export declare const ratingCardSchema: z.ZodObject<{
|
export declare const ratingCardSchema: z.ZodObject<{
|
||||||
k: z.ZodLiteral<"rating">;
|
k: z.ZodLiteral<"rating">;
|
||||||
@@ -1408,6 +1933,47 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
k: z.ZodLiteral<"entry">;
|
||||||
|
ask: z.ZodString;
|
||||||
|
basis: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
k: z.ZodString;
|
||||||
|
v: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
ledgerLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
key: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
|
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
fields: z.ZodArray<z.ZodObject<{
|
||||||
|
key: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
value: z.ZodOptional<z.ZodString>;
|
||||||
|
required: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
placeholder: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
autoNote: z.ZodOptional<z.ZodString>;
|
||||||
|
also: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
title: z.ZodString;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
ledgerLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
key: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
|
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
fields: z.ZodArray<z.ZodObject<{
|
||||||
|
key: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
value: z.ZodOptional<z.ZodString>;
|
||||||
|
required: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
placeholder: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
autoNote: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
done: z.ZodOptional<z.ZodObject<{
|
||||||
|
at: z.ZodISODateTime;
|
||||||
|
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
values: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
|
at: z.ZodISODateTime;
|
||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
k: z.ZodLiteral<"elicitation">;
|
k: z.ZodLiteral<"elicitation">;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
@@ -1419,6 +1985,7 @@ export declare const chatCardSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
placeholder: z.ZodOptional<z.ZodString>;
|
placeholder: z.ZodOptional<z.ZodString>;
|
||||||
|
value: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
done: z.ZodOptional<z.ZodObject<{
|
done: z.ZodOptional<z.ZodObject<{
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
@@ -1471,6 +2038,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
control: "control";
|
control: "control";
|
||||||
}>;
|
}>;
|
||||||
text: z.ZodString;
|
text: z.ZodString;
|
||||||
|
openComms: z.ZodOptional<z.ZodBoolean>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
@@ -1497,6 +2065,47 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
k: z.ZodLiteral<"entry">;
|
||||||
|
ask: z.ZodString;
|
||||||
|
basis: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
k: z.ZodString;
|
||||||
|
v: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
ledgerLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
key: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
|
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
fields: z.ZodArray<z.ZodObject<{
|
||||||
|
key: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
value: z.ZodOptional<z.ZodString>;
|
||||||
|
required: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
placeholder: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
autoNote: z.ZodOptional<z.ZodString>;
|
||||||
|
also: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
title: z.ZodString;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
ledgerLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
key: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
|
keyLabels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
|
fields: z.ZodArray<z.ZodObject<{
|
||||||
|
key: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
value: z.ZodOptional<z.ZodString>;
|
||||||
|
required: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
placeholder: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
autoNote: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
done: z.ZodOptional<z.ZodObject<{
|
||||||
|
at: z.ZodISODateTime;
|
||||||
|
by: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
values: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
id: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
|
at: z.ZodISODateTime;
|
||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
k: z.ZodLiteral<"elicitation">;
|
k: z.ZodLiteral<"elicitation">;
|
||||||
ask: z.ZodString;
|
ask: z.ZodString;
|
||||||
@@ -1508,6 +2117,7 @@ export declare const chatMessageSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
placeholder: z.ZodOptional<z.ZodString>;
|
placeholder: z.ZodOptional<z.ZodString>;
|
||||||
|
value: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
done: z.ZodOptional<z.ZodObject<{
|
done: z.ZodOptional<z.ZodObject<{
|
||||||
at: z.ZodISODateTime;
|
at: z.ZodISODateTime;
|
||||||
@@ -1560,6 +2170,10 @@ export declare const chatCardReplySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
values: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
values: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
||||||
reason: z.ZodOptional<z.ZodString>;
|
reason: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
|
k: z.ZodLiteral<"entry">;
|
||||||
|
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
|
values: z.ZodRecord<z.ZodString, z.ZodString>;
|
||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
k: z.ZodLiteral<"rating">;
|
k: z.ZodLiteral<"rating">;
|
||||||
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
cardId: z.core.$ZodBranded<z.ZodString, "CardId", "out">;
|
||||||
|
|||||||
@@ -16,12 +16,20 @@ export declare const contractInputSchema: z.ZodObject<{
|
|||||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
||||||
messageHint: z.ZodOptional<z.ZodString>;
|
messageHint: z.ZodOptional<z.ZodString>;
|
||||||
|
inboundComm: z.ZodOptional<z.ZodObject<{
|
||||||
|
channel: z.ZodDefault<z.ZodString>;
|
||||||
|
partyField: z.ZodString;
|
||||||
|
subjectField: z.ZodOptional<z.ZodString>;
|
||||||
|
bodyField: z.ZodOptional<z.ZodString>;
|
||||||
|
attachmentsField: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type ContractInput = z.infer<typeof contractInputSchema>;
|
export type ContractInput = z.infer<typeof contractInputSchema>;
|
||||||
export declare const outputPortSchema: z.ZodObject<{
|
export declare const outputPortSchema: z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
when: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type OutputPort = z.infer<typeof outputPortSchema>;
|
export type OutputPort = z.infer<typeof outputPortSchema>;
|
||||||
export declare const outputPortsSchema: z.ZodObject<{
|
export declare const outputPortsSchema: z.ZodObject<{
|
||||||
@@ -29,6 +37,7 @@ export declare const outputPortsSchema: z.ZodObject<{
|
|||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
when: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
group: z.ZodString;
|
group: z.ZodString;
|
||||||
@@ -36,6 +45,7 @@ export declare const outputPortsSchema: z.ZodObject<{
|
|||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
when: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -44,6 +54,9 @@ export declare const requiredDeliverableSchema: z.ZodObject<{
|
|||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
|
ledgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
editableAtRelease: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type RequiredDeliverable = z.infer<typeof requiredDeliverableSchema>;
|
export type RequiredDeliverable = z.infer<typeof requiredDeliverableSchema>;
|
||||||
export declare const acceptanceCriterionSchema: z.ZodObject<{
|
export declare const acceptanceCriterionSchema: z.ZodObject<{
|
||||||
@@ -109,12 +122,20 @@ export declare const sevenPartContractSchema: z.ZodObject<{
|
|||||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
||||||
messageHint: z.ZodOptional<z.ZodString>;
|
messageHint: z.ZodOptional<z.ZodString>;
|
||||||
|
inboundComm: z.ZodOptional<z.ZodObject<{
|
||||||
|
channel: z.ZodDefault<z.ZodString>;
|
||||||
|
partyField: z.ZodString;
|
||||||
|
subjectField: z.ZodOptional<z.ZodString>;
|
||||||
|
bodyField: z.ZodOptional<z.ZodString>;
|
||||||
|
attachmentsField: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
output: z.ZodObject<{
|
output: z.ZodObject<{
|
||||||
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
when: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
group: z.ZodString;
|
group: z.ZodString;
|
||||||
@@ -122,6 +143,7 @@ export declare const sevenPartContractSchema: z.ZodObject<{
|
|||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
when: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -129,6 +151,9 @@ export declare const sevenPartContractSchema: z.ZodObject<{
|
|||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
|
ledgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
editableAtRelease: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
acceptanceCriteria: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
acceptanceCriteria: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
@@ -143,6 +168,11 @@ export declare const sevenPartContractSchema: z.ZodObject<{
|
|||||||
predicate: z.ZodOptional<z.ZodString>;
|
predicate: z.ZodOptional<z.ZodString>;
|
||||||
modelRef: z.ZodOptional<z.ZodString>;
|
modelRef: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
|
revisableAtRelease: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
key: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
prohibitions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
prohibitions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
statement: z.ZodString;
|
statement: z.ZodString;
|
||||||
|
|||||||
+43
@@ -49,12 +49,51 @@ export declare const fieldDeclarationSchema: z.ZodObject<{
|
|||||||
label: z.ZodOptional<z.ZodString>;
|
label: z.ZodOptional<z.ZodString>;
|
||||||
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
||||||
masked: z.ZodDefault<z.ZodBoolean>;
|
masked: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
expect: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
auto: z.ZodOptional<z.ZodBoolean>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>;
|
export type FieldDeclaration = z.infer<typeof fieldDeclarationSchema>;
|
||||||
|
/**
|
||||||
|
* 台账对象的**性质** —— 这条数据归谁管、活多久、谁有权改它。
|
||||||
|
*
|
||||||
|
* 从前台账只有「地址 + 列」,没有这一维,于是**没人需要回答「这张表是什么」**——
|
||||||
|
* 后果不是不好看,是主键写歪了还没人发现: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<typeof ledgerObjectKindSchema>;
|
||||||
|
export declare const LEDGER_KIND_LABEL: Readonly<Record<LedgerObjectKind, string>>;
|
||||||
|
export declare const LEDGER_KIND_DESC: Readonly<Record<LedgerObjectKind, string>>;
|
||||||
export declare const objectDeclarationSchema: z.ZodObject<{
|
export declare const objectDeclarationSchema: z.ZodObject<{
|
||||||
domain: z.ZodString;
|
domain: z.ZodString;
|
||||||
object: z.ZodString;
|
object: z.ZodString;
|
||||||
label: z.ZodOptional<z.ZodString>;
|
label: z.ZodOptional<z.ZodString>;
|
||||||
|
kind: z.ZodDefault<z.ZodEnum<{
|
||||||
|
record: "record";
|
||||||
|
master: "master";
|
||||||
|
derived: "derived";
|
||||||
|
}>>;
|
||||||
|
caseKey: z.ZodOptional<z.ZodString>;
|
||||||
fields: z.ZodArray<z.ZodObject<{
|
fields: z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
type: z.ZodEnum<{
|
type: z.ZodEnum<{
|
||||||
@@ -69,6 +108,10 @@ export declare const objectDeclarationSchema: z.ZodObject<{
|
|||||||
label: z.ZodOptional<z.ZodString>;
|
label: z.ZodOptional<z.ZodString>;
|
||||||
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
||||||
masked: z.ZodDefault<z.ZodBoolean>;
|
masked: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
expect: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
auto: z.ZodOptional<z.ZodBoolean>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>;
|
export type ObjectDeclaration = z.infer<typeof objectDeclarationSchema>;
|
||||||
|
|||||||
+143
@@ -42,6 +42,12 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{
|
|||||||
domain: z.ZodString;
|
domain: z.ZodString;
|
||||||
object: z.ZodString;
|
object: z.ZodString;
|
||||||
label: z.ZodOptional<z.ZodString>;
|
label: z.ZodOptional<z.ZodString>;
|
||||||
|
kind: z.ZodDefault<z.ZodEnum<{
|
||||||
|
record: "record";
|
||||||
|
master: "master";
|
||||||
|
derived: "derived";
|
||||||
|
}>>;
|
||||||
|
caseKey: z.ZodOptional<z.ZodString>;
|
||||||
fields: z.ZodArray<z.ZodObject<{
|
fields: z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
type: z.ZodEnum<{
|
type: z.ZodEnum<{
|
||||||
@@ -56,6 +62,10 @@ export declare const manifestDeclarationsSchema: z.ZodObject<{
|
|||||||
label: z.ZodOptional<z.ZodString>;
|
label: z.ZodOptional<z.ZodString>;
|
||||||
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
||||||
masked: z.ZodDefault<z.ZodBoolean>;
|
masked: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
expect: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
auto: z.ZodOptional<z.ZodBoolean>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
@@ -154,6 +164,50 @@ export declare const dmnModelSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type DmnModel = z.infer<typeof dmnModelSchema>;
|
export type DmnModel = z.infer<typeof dmnModelSchema>;
|
||||||
|
/**
|
||||||
|
* **步骤剧本** —— 一个计划项一条,把「这一步是什么状态」写成机器读得懂的东西。
|
||||||
|
*
|
||||||
|
* 为什么要有它(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<z.ZodArray<z.ZodString>>;
|
||||||
|
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
|
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
doneWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type PlanStep = z.infer<typeof planStepSchema>;
|
||||||
|
/**
|
||||||
|
* 阶段 —— **给人看的进度刻度**,引擎一眼都不看。
|
||||||
|
*
|
||||||
|
* 计划项是给引擎的:一件事拆到能判「干完没有」的粒度,A1 有六项。
|
||||||
|
* 可六格进度条对人没有意义 —— 「查历史价 / 打标 / 定去向」在业务上是**一件事**
|
||||||
|
* (判这单该怎么走),人关心的是「到哪一步了」,不是引擎拆了几项。
|
||||||
|
* 所以另立一层:几个计划项合成一个阶段,进度条按阶段走。
|
||||||
|
*
|
||||||
|
* **只声明激活态的阶段**。结束态不在这里 —— 每个活动都以放行收口(铁律③),
|
||||||
|
* 那是系统的规矩不是活动的阶段;进度条自己在末尾加这一格。
|
||||||
|
*/
|
||||||
|
export declare const casePhaseSchema: z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
items: z.ZodArray<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type CasePhase = z.infer<typeof casePhaseSchema>;
|
||||||
export declare const loopModelSchema: z.ZodObject<{
|
export declare const loopModelSchema: z.ZodObject<{
|
||||||
cmmnXml: z.ZodString;
|
cmmnXml: z.ZodString;
|
||||||
narrative: z.ZodString;
|
narrative: z.ZodString;
|
||||||
@@ -187,6 +241,20 @@ export declare const loopModelSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
steps: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
item: z.ZodString;
|
||||||
|
intent: z.ZodString;
|
||||||
|
guide: z.ZodString;
|
||||||
|
writes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
|
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
doneWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
items: z.ZodArray<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type LoopModel = z.infer<typeof loopModelSchema>;
|
export type LoopModel = z.infer<typeof loopModelSchema>;
|
||||||
/**
|
/**
|
||||||
@@ -227,6 +295,20 @@ export declare const businessModelSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
steps: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
item: z.ZodString;
|
||||||
|
intent: z.ZodString;
|
||||||
|
guide: z.ZodString;
|
||||||
|
writes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
|
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
doneWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
items: z.ZodArray<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
export type BusinessModel = z.infer<typeof businessModelSchema>;
|
export type BusinessModel = z.infer<typeof businessModelSchema>;
|
||||||
export declare const skillSchema: z.ZodObject<{
|
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>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
steps: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
item: z.ZodString;
|
||||||
|
intent: z.ZodString;
|
||||||
|
guide: z.ZodString;
|
||||||
|
writes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
|
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
doneWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
items: z.ZodArray<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
skills: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
skills: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "SkillId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "SkillId", "out">;
|
||||||
@@ -331,12 +427,20 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
messageAllowed: z.ZodDefault<z.ZodBoolean>;
|
||||||
messageHint: z.ZodOptional<z.ZodString>;
|
messageHint: z.ZodOptional<z.ZodString>;
|
||||||
|
inboundComm: z.ZodOptional<z.ZodObject<{
|
||||||
|
channel: z.ZodDefault<z.ZodString>;
|
||||||
|
partyField: z.ZodString;
|
||||||
|
subjectField: z.ZodOptional<z.ZodString>;
|
||||||
|
bodyField: z.ZodOptional<z.ZodString>;
|
||||||
|
attachmentsField: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
output: z.ZodObject<{
|
output: z.ZodObject<{
|
||||||
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
mandatory: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
when: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
selective: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
group: z.ZodString;
|
group: z.ZodString;
|
||||||
@@ -344,6 +448,7 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
payload: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
description: z.ZodOptional<z.ZodString>;
|
description: z.ZodOptional<z.ZodString>;
|
||||||
|
when: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
@@ -351,6 +456,9 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
label: z.ZodString;
|
label: z.ZodString;
|
||||||
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
schema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
||||||
|
ledger: z.ZodOptional<z.ZodString>;
|
||||||
|
ledgers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
editableAtRelease: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
acceptanceCriteria: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
acceptanceCriteria: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
@@ -365,6 +473,11 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
predicate: z.ZodOptional<z.ZodString>;
|
predicate: z.ZodOptional<z.ZodString>;
|
||||||
modelRef: z.ZodOptional<z.ZodString>;
|
modelRef: z.ZodOptional<z.ZodString>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
|
revisableAtRelease: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
key: z.ZodString;
|
||||||
|
label: z.ZodString;
|
||||||
|
ledger: z.ZodString;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
prohibitions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
prohibitions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
key: z.ZodString;
|
key: z.ZodString;
|
||||||
statement: z.ZodString;
|
statement: z.ZodString;
|
||||||
@@ -407,6 +520,12 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
domain: z.ZodString;
|
domain: z.ZodString;
|
||||||
object: z.ZodString;
|
object: z.ZodString;
|
||||||
label: z.ZodOptional<z.ZodString>;
|
label: z.ZodOptional<z.ZodString>;
|
||||||
|
kind: z.ZodDefault<z.ZodEnum<{
|
||||||
|
record: "record";
|
||||||
|
master: "master";
|
||||||
|
derived: "derived";
|
||||||
|
}>>;
|
||||||
|
caseKey: z.ZodOptional<z.ZodString>;
|
||||||
fields: z.ZodArray<z.ZodObject<{
|
fields: z.ZodArray<z.ZodObject<{
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
type: z.ZodEnum<{
|
type: z.ZodEnum<{
|
||||||
@@ -421,6 +540,10 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
label: z.ZodOptional<z.ZodString>;
|
label: z.ZodOptional<z.ZodString>;
|
||||||
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
primaryKey: z.ZodDefault<z.ZodBoolean>;
|
||||||
masked: z.ZodDefault<z.ZodBoolean>;
|
masked: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
required: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
tally: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||||
|
expect: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
auto: z.ZodOptional<z.ZodBoolean>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
listeners: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
@@ -497,6 +620,20 @@ export declare const activityManifestSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
steps: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
item: z.ZodString;
|
||||||
|
intent: z.ZodString;
|
||||||
|
guide: z.ZodString;
|
||||||
|
writes: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
reads: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
view: z.ZodOptional<z.ZodString>;
|
||||||
|
tools: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
|
doneWhen: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
phases: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||||
|
name: z.ZodString;
|
||||||
|
items: z.ZodArray<z.ZodString>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
skills: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
skills: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
id: z.core.$ZodBranded<z.ZodString, "SkillId", "out">;
|
id: z.core.$ZodBranded<z.ZodString, "SkillId", "out">;
|
||||||
@@ -552,6 +689,9 @@ export declare const staticAuditFindingSchema: z.ZodObject<{
|
|||||||
"dynamic-eval": "dynamic-eval";
|
"dynamic-eval": "dynamic-eval";
|
||||||
"ledger-type-conflict": "ledger-type-conflict";
|
"ledger-type-conflict": "ledger-type-conflict";
|
||||||
"acceptance-level-misreported": "acceptance-level-misreported";
|
"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<{
|
severity: z.ZodEnum<{
|
||||||
error: "error";
|
error: "error";
|
||||||
@@ -590,6 +730,9 @@ export declare const installVerdictSchema: z.ZodObject<{
|
|||||||
"dynamic-eval": "dynamic-eval";
|
"dynamic-eval": "dynamic-eval";
|
||||||
"ledger-type-conflict": "ledger-type-conflict";
|
"ledger-type-conflict": "ledger-type-conflict";
|
||||||
"acceptance-level-misreported": "acceptance-level-misreported";
|
"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<{
|
severity: z.ZodEnum<{
|
||||||
error: "error";
|
error: "error";
|
||||||
|
|||||||
+4
-4
@@ -41,9 +41,9 @@ export declare const GRANT_ACTION_LABEL: Readonly<Record<GrantAction, string>>;
|
|||||||
/** 治理台九功能 —— 授权客体之一 */
|
/** 治理台九功能 —— 授权客体之一 */
|
||||||
export declare const GOVERNANCE_FUNCTIONS: readonly ["task", "staff", "process", "trigger", "activity", "tool", "data", "memory", "system"];
|
export declare const GOVERNANCE_FUNCTIONS: readonly ["task", "staff", "process", "trigger", "activity", "tool", "data", "memory", "system"];
|
||||||
export declare const governanceFunctionSchema: z.ZodEnum<{
|
export declare const governanceFunctionSchema: z.ZodEnum<{
|
||||||
|
trigger: "trigger";
|
||||||
data: "data";
|
data: "data";
|
||||||
task: "task";
|
task: "task";
|
||||||
trigger: "trigger";
|
|
||||||
system: "system";
|
system: "system";
|
||||||
tool: "tool";
|
tool: "tool";
|
||||||
activity: "activity";
|
activity: "activity";
|
||||||
@@ -61,9 +61,9 @@ export declare const grantObjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
kind: z.ZodLiteral<"governance">;
|
kind: z.ZodLiteral<"governance">;
|
||||||
function: z.ZodEnum<{
|
function: z.ZodEnum<{
|
||||||
|
trigger: "trigger";
|
||||||
data: "data";
|
data: "data";
|
||||||
task: "task";
|
task: "task";
|
||||||
trigger: "trigger";
|
|
||||||
system: "system";
|
system: "system";
|
||||||
tool: "tool";
|
tool: "tool";
|
||||||
activity: "activity";
|
activity: "activity";
|
||||||
@@ -86,9 +86,9 @@ export declare const grantSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
kind: z.ZodLiteral<"governance">;
|
kind: z.ZodLiteral<"governance">;
|
||||||
function: z.ZodEnum<{
|
function: z.ZodEnum<{
|
||||||
|
trigger: "trigger";
|
||||||
data: "data";
|
data: "data";
|
||||||
task: "task";
|
task: "task";
|
||||||
trigger: "trigger";
|
|
||||||
system: "system";
|
system: "system";
|
||||||
tool: "tool";
|
tool: "tool";
|
||||||
activity: "activity";
|
activity: "activity";
|
||||||
@@ -152,9 +152,9 @@ export declare const sessionSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>, z.ZodObject<{
|
}, z.core.$strip>, z.ZodObject<{
|
||||||
kind: z.ZodLiteral<"governance">;
|
kind: z.ZodLiteral<"governance">;
|
||||||
function: z.ZodEnum<{
|
function: z.ZodEnum<{
|
||||||
|
trigger: "trigger";
|
||||||
data: "data";
|
data: "data";
|
||||||
task: "task";
|
task: "task";
|
||||||
trigger: "trigger";
|
|
||||||
system: "system";
|
system: "system";
|
||||||
tool: "tool";
|
tool: "tool";
|
||||||
activity: "activity";
|
activity: "activity";
|
||||||
|
|||||||
+2
-1
@@ -60,12 +60,13 @@ export declare const workflowStateSchema: z.ZodEnum<{
|
|||||||
}>;
|
}>;
|
||||||
export type WorkflowState = z.infer<typeof workflowStateSchema>;
|
export type WorkflowState = z.infer<typeof workflowStateSchema>;
|
||||||
export declare const WORKFLOW_STATE_LABEL: Readonly<Record<WorkflowState, string>>;
|
export declare const WORKFLOW_STATE_LABEL: Readonly<Record<WorkflowState, string>>;
|
||||||
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<{
|
export declare const attributionSideSchema: z.ZodEnum<{
|
||||||
machine: "machine";
|
machine: "machine";
|
||||||
human: "human";
|
human: "human";
|
||||||
"human-verdict": "human-verdict";
|
"human-verdict": "human-verdict";
|
||||||
"world-wait": "world-wait";
|
"world-wait": "world-wait";
|
||||||
|
trigger: "trigger";
|
||||||
}>;
|
}>;
|
||||||
export type AttributionSide = z.infer<typeof attributionSideSchema>;
|
export type AttributionSide = z.infer<typeof attributionSideSchema>;
|
||||||
export declare const ATTRIBUTION_SIDE_LABEL: Readonly<Record<AttributionSide, string>>;
|
export declare const ATTRIBUTION_SIDE_LABEL: Readonly<Record<AttributionSide, string>>;
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"seed": "node dist/seed.js",
|
"seed": "node dist/seed.js",
|
||||||
"migrate": "node dist/migrate.js",
|
"migrate": "node dist/migrate.js",
|
||||||
"set-password": "node dist/set-password.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": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai-compatible": "^3.0.14",
|
"@ai-sdk/openai-compatible": "^3.0.14",
|
||||||
|
|||||||
@@ -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<string, unknown>,
|
||||||
|
judged: ReadonlyMap<string, boolean>,
|
||||||
|
): 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<string, unknown>,
|
||||||
|
judged: ReadonlyMap<string, boolean>,
|
||||||
|
): 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<string, unknown> = { line_total: 3, missing_key_total: 0, untyped_total: 0 };
|
||||||
|
|
||||||
|
describe('裁量判据读 Agent 的裁定 —— 不再一律判 false', () => {
|
||||||
|
it('**没人裁过 ≠ 通过** —— 一条没裁时 L3/L4 仍是未达标', () => {
|
||||||
|
const judged = new Map<string, boolean>();
|
||||||
|
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, unknown>): 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>) => {
|
||||||
|
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<string, unknown>) => {
|
||||||
|
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<string, unknown>) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>; patch: Record<string, unknown> };
|
||||||
|
|
||||||
|
/** 与 `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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>];
|
||||||
|
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<string, unknown>,
|
||||||
|
];
|
||||||
|
expect(out['rows']).toEqual([['2']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空值铺成空字符串 —— 界面自己标红「待填写」,不写「无」这种话', () => {
|
||||||
|
const [out] = fill(DRAFTS, [block]) as [Record<string, unknown>];
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string> = { pn: 'text', eau: 'integer', target_price: 'number', lead_time: 'text' };
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,61 +1,67 @@
|
|||||||
/**
|
/**
|
||||||
* 四域十九对象 —— 声明即物化的输入。守三件事:数量与域别齐、主键齐、按名可取。
|
* 主数据四域 —— 客户 / 项目 / 供应商 / 市场。
|
||||||
|
*
|
||||||
|
* 这份测试从前断言的是「四域十九对象」,那个四域是 project / customer / intel / supplier,
|
||||||
|
* 装的是**退役占位槽**的声明,里面混着一单一份的流转单据(受理记录、比价表、报价草案)。
|
||||||
|
* 2026-07-31 重分:单据归各活动自己的域,这个文件只留**主数据**,按常规 CRM / ERP 分四域。
|
||||||
|
*
|
||||||
|
* 守的是三条,每条都能机检:
|
||||||
|
* · 只有这四个域,不多不少 —— 多一个就说明有人把单据塞进主数据了;
|
||||||
|
* · 每张表都是 `master` 且**不带单标识** —— 带了就是单据归错类(安装静态审也会拦);
|
||||||
|
* · 主键是**业务实体**,不是单。
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('主数据四域', () => {
|
||||||
it('四域十九对象,不多不少 —— 定价审批表载体是审批流,不入台账', () => {
|
it('只有客户 / 项目 / 供应商 / 市场四个域', () => {
|
||||||
expect(MVP_OBJECTS).toHaveLength(19);
|
expect([...MASTER_DOMAINS]).toEqual(['customer', 'project', 'supplier', 'market']);
|
||||||
const count = (domain: string) => MVP_OBJECTS.filter((o) => o.domain === domain).length;
|
expect([...new Set(MASTER_OBJECTS.map((o) => o.domain))].sort()).toEqual([...MASTER_DOMAINS].sort());
|
||||||
expect(count('project')).toBe(13);
|
|
||||||
expect(count('customer')).toBe(1);
|
|
||||||
expect(count('intel')).toBe(3);
|
|
||||||
expect(count('supplier')).toBe(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('每个对象至少一个主键字段 —— 只增不破坏靠主键立身', () => {
|
it('四个域各自都有表 —— 空占位也得立得住', () => {
|
||||||
for (const o of MVP_OBJECTS) {
|
for (const d of MASTER_DOMAINS) {
|
||||||
expect(o.fields.some((f) => f.primaryKey), `${o.domain}.${o.object}`).toBe(true);
|
expect(MASTER_OBJECTS.filter((o) => o.domain === d).length, `${d} 域一张表都没有`).toBeGreaterThan(0);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 受理结论是**一单一行**,主键只有询价单号。
|
* 这一条是分类的要害:主数据按**业务实体**分行,不按单。
|
||||||
*
|
* 带上单标识就说明它其实是一单一份的单据 —— 归错类,治理方式全跟着错。
|
||||||
* 这条断言守的是一个会静默出错的设计:出口的标量字段取自 `landed.flat`,
|
|
||||||
* 而 `flat` 是把这一单所有落地行 reduce 展开成一个平对象。两枚去向旗若挂在
|
|
||||||
* 按料号分行的对象上,六个料号就有六行同名列、最后合并的那行赢 ——
|
|
||||||
* 而归并规则是「任一条命中 C 即需选型」,网关会照着错值分叉且无人察觉。
|
|
||||||
*/
|
*/
|
||||||
it('受理结论一单一行 —— 去向旗不能挂在按料号分行的对象上', () => {
|
it('每张表都是 master 且不带单标识', () => {
|
||||||
const verdict = MVP_OBJECTS.find((o) => o.object === 'intake_verdict');
|
for (const o of MASTER_OBJECTS) {
|
||||||
expect(verdict, '缺 project.intake_verdict').toBeDefined();
|
expect(o.kind, `${o.domain}.${o.object}`).toBe('master');
|
||||||
expect(verdict?.domain).toBe('project');
|
expect(o.caseKey, `${o.domain}.${o.object} 不该有单标识`).toBeUndefined();
|
||||||
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('declarationsFor 按名取子集,名不存在即抛', () => {
|
it('每张表都有主键 —— 没主键的台账合并与去重无从谈起', () => {
|
||||||
expect(declarationsFor(['project.intake_record', 'intel.history_price'])).toHaveLength(2);
|
for (const o of MASTER_OBJECTS) {
|
||||||
expect(() => declarationsFor(['project.nope'])).toThrow('未知对象');
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { 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', () => {
|
const source = readFileSync(fileURLToPath(new URL('../seed-mvp-slots.ts', import.meta.url)), 'utf8');
|
||||||
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');
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
/** 被真活动包顶替掉的六个槽位 —— 顺序即 SOP 次序 */
|
||||||
* 占位骨架的形状(2026-07-29 范式统一后):**案例模型**——
|
const RETIRED = [
|
||||||
* SOP 每件事一个人工任务 + 末尾一张收口验收。两条都是踩过坑才立的:
|
'rfq-intake', // A1 → quote-intake
|
||||||
* · 触达世界的项必须人工激活 —— 占位尤其不许替人自动做事
|
'alt-selection', // A2 → quote-alternative
|
||||||
* · 没有收口验收 → 活动永远不 release →「等人却没东西给人答」
|
'sourcing-inquiry', // A3 → quote-sourcing
|
||||||
*/
|
'cost-pricing', // A4 → quote-pricing
|
||||||
it('占位骨架:案例模型双表示齐备,项全人工激活,末尾一张收口验收', () => {
|
'quote-negotiation', // A6 → quote-negotiate
|
||||||
for (const m of MVP_SLOTS) {
|
'intel-archive', // A7 → quote-archive
|
||||||
const model = m.assets.businessModel;
|
] as const;
|
||||||
const id = m.identity.activityId;
|
|
||||||
expect(model.cmmnXml, `${id} 缺 CMMN`).toContain('<casePlanModel');
|
describe('槽位退役', () => {
|
||||||
expect(model.narrative.length, `${id} 缺说明书`).toBeGreaterThan(60);
|
it('六个槽位一个不漏地在退役名单里', () => {
|
||||||
expect(model.cmmnXml, `${id} 缺收口验收`).toContain('id="T_release"');
|
for (const id of RETIRED) {
|
||||||
// 每个 humanTask 都带人工激活规则 —— 一个自动跑的占位项都不许有
|
expect(source, `${id} 不在退役名单里 —— 下次播种它会装回来`).toContain(`'${id}'`);
|
||||||
for (const task of model.cmmnXml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
|
|
||||||
expect(task[0], `${id} 的 ${task[1]} 应人工激活`).toContain('manualActivationRule');
|
|
||||||
}
|
|
||||||
// 占位不做业务判断,所以不带决策表
|
|
||||||
expect(model.dmn.tables, `${id} 占位期不该有 DMN`).toHaveLength(0);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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<string, unknown> }).properties ?? {};
|
* 留着它,下一个人很容易「顺手再装回去」,重影就回来了。
|
||||||
expect(Object.keys(p1)).toEqual(expect.arrayContaining(['needAlternative', 'needSourcing']));
|
*/
|
||||||
const a2 = MVP_SLOTS.find((m) => m.identity.activityId === 'alt-selection')!;
|
it('占位清单已从源码里移除', () => {
|
||||||
const p2 = (a2.contract.output.mandatory[0]!.payload as { properties?: Record<string, unknown> }).properties ?? {};
|
expect(source).not.toContain('MVP_SLOTS');
|
||||||
expect(Object.keys(p2)).toContain('needSourcing');
|
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 的判据', () => {
|
it('卸不掉如实报出来,不掀翻播种', () => {
|
||||||
const a6 = MVP_SLOTS.find((m) => m.identity.activityId === 'quote-negotiation')!;
|
expect(source).toContain('卸不掉');
|
||||||
const groups = a6.contract.output.selective;
|
expect(source).toContain('catch');
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<Record<string, unknown>>)[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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,8 +9,9 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { StaffId, TaskId } from '@contracts';
|
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 type { BusTool } from '@modules/toolbox';
|
||||||
|
import { toolCatalog } from '../agent-runner.ts';
|
||||||
import { buildMastraTools } from '../tool-bus.ts';
|
import { buildMastraTools } from '../tool-bus.ts';
|
||||||
import type { ToolBusPorts, ToolBusTrace } from '../tool-bus.ts';
|
import type { ToolBusPorts, ToolBusTrace } from '../tool-bus.ts';
|
||||||
|
|
||||||
@@ -38,10 +39,11 @@ type Calls = {
|
|||||||
cards: Array<{ workOrder: string; card: Record<string, unknown> }>;
|
cards: Array<{ workOrder: string; card: Record<string, unknown> }>;
|
||||||
listens: Array<{ keyFilter: Record<string, string> }>;
|
listens: Array<{ keyFilter: Record<string, string> }>;
|
||||||
generated: Array<{ model: string; label: string; prompt: string }>;
|
generated: Array<{ model: string; label: string; prompt: string }>;
|
||||||
|
written: Array<{ address: string; rows: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function harness(writeTools: string[] = ['mail.send']) {
|
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 = {
|
const ports: ToolBusPorts = {
|
||||||
invoke: async (input) => {
|
invoke: async (input) => {
|
||||||
calls.invoked.push({ tool: input.tool, input: input.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 });
|
calls.listens.push({ keyFilter: input.keyFilter });
|
||||||
return { id: 'lsn-1' };
|
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) => {
|
generate: async (input) => {
|
||||||
calls.generated.push({ model: input.model, label: input.label, prompt: input.prompt });
|
calls.generated.push({ model: input.model, label: input.label, prompt: input.prompt });
|
||||||
return { text: '{"label":"C"}', object: { label: 'C', rationale: '据规则' } };
|
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 五件(系统内化)', () => {
|
describe('装配:LLM 五件(系统内化)', () => {
|
||||||
it('经模型网关调,用活动声明的用途名;提示词归系统', async () => {
|
it('经模型网关调,用活动声明的用途名;提示词归系统', async () => {
|
||||||
const h = harness();
|
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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:表头 + 两条料 —— 字符串走共享串表,数字直接落 `<v>` */
|
||||||
|
function bomXlsx(): Uint8Array {
|
||||||
|
const strings = ['型号', '年用量', 'LM358DR', 'NE555P'];
|
||||||
|
const sharedXml =
|
||||||
|
`<?xml version="1.0"?><sst count="4" uniqueCount="4">` +
|
||||||
|
strings.map((s) => `<si><t>${s}</t></si>`).join('') +
|
||||||
|
`</sst>`;
|
||||||
|
const sheetXml =
|
||||||
|
`<?xml version="1.0"?><worksheet><sheetData>` +
|
||||||
|
`<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c></row>` +
|
||||||
|
`<row r="2"><c r="A2" t="s"><v>2</v></c><c r="B2"><v>12000</v></c></row>` +
|
||||||
|
`<row r="3"><c r="A3" t="s"><v>3</v></c><c r="B3"><v>5000</v></c></row>` +
|
||||||
|
`<row r="4"/>` +
|
||||||
|
`</sheetData></worksheet>`;
|
||||||
|
return makeZip([
|
||||||
|
{ name: '[Content_Types].xml', body: enc('<?xml version="1.0"?><Types/>') },
|
||||||
|
{ 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('<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>直存</t></is></c></row></sheetData></worksheet>'),
|
||||||
|
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('<worksheet><sheetData/></worksheet>') }]);
|
||||||
|
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('<sst><si><t>A&B <100></t></si></sst>') },
|
||||||
|
{
|
||||||
|
name: 'xl/worksheets/sheet1.xml',
|
||||||
|
body: enc('<worksheet><sheetData><row r="1"><c r="A1" t="s"><v>0</v></c></row></sheetData></worksheet>'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(extractText('e.xlsx', '', zip).text).toBe('A&B <100>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Record<string, unknown>>) =>
|
||||||
|
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<string, string>, count: number, names: string[]) => {
|
||||||
|
const prefix = others.map((k) => given[k] ?? '').filter((v) => v !== '').join('-');
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,19 +8,38 @@
|
|||||||
* 见 docs/debt.md · M5-2:§6.1 规定这里用 Mastra `Agent`;当前直接经 ModelGateway,
|
* 见 docs/debt.md · M5-2:§6.1 规定这里用 Mastra `Agent`;当前直接经 ModelGateway,
|
||||||
* 端口不变,换实现时 usecase 不动。
|
* 端口不变,换实现时 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 { ModelGateway } from '@modules/llm';
|
||||||
import type { TaskId } from '@contracts';
|
import type { TaskId } from '@contracts';
|
||||||
|
|
||||||
/** Agent 只能选三型行动之一(外加规划 / 弃用 / 评估 / 提交)—— schema 把它框住 */
|
/** 一个行动项 —— 三型行动之一(外加规划 / 弃用 / 评估 / 完成 / 提交),schema 把它框住 */
|
||||||
const ACTION_SCHEMA = {
|
const ACTION_ITEM_SCHEMA = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['kind'],
|
required: ['kind'],
|
||||||
properties: {
|
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' },
|
itemId: { type: 'string' },
|
||||||
tool: { type: 'string' },
|
tool: { type: 'string' },
|
||||||
input: { type: 'object' },
|
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'] },
|
workOrder: { enum: ['execution', 'acceptance'] },
|
||||||
/*
|
/*
|
||||||
* 卡面结构由 schema 框住 —— 「调用必附上下文,**裸调用不得发出**」(AM 06)。
|
* 卡面结构由 schema 框住 —— 「调用必附上下文,**裸调用不得发出**」(AM 06)。
|
||||||
@@ -35,8 +54,115 @@ const ACTION_SCHEMA = {
|
|||||||
reason: { type: 'string' },
|
reason: { type: 'string' },
|
||||||
basis: { type: 'array' },
|
basis: { type: 'array' },
|
||||||
fields: { 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'] },
|
eventType: { enum: ['data', 'time'] },
|
||||||
keyFilter: { type: 'object' },
|
keyFilter: { type: 'object' },
|
||||||
deadline: { type: 'string' },
|
deadline: { type: 'string' },
|
||||||
@@ -47,6 +173,43 @@ const ACTION_SCHEMA = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} 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 = {
|
export type AgentRunnerDeps = {
|
||||||
readonly gateway: ModelGateway;
|
readonly gateway: ModelGateway;
|
||||||
/** 活动声明的自定义模型名 */
|
/** 活动声明的自定义模型名 */
|
||||||
@@ -55,35 +218,332 @@ export type AgentRunnerDeps = {
|
|||||||
readonly onUsage: (taskId: TaskId, tokens: number) => Promise<void>;
|
readonly onUsage: (taskId: TaskId, tokens: number) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 可写台账摊成字典:地址 · 中文标 · 主键 · 列名 —— 模型照这个填,不照想象编 */
|
||||||
|
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<string, unknown>): Record<string, unknown> {
|
||||||
|
return Object.fromEntries(Object.entries(scope).filter(([k]) => k.startsWith('mine:')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 其余作用域:**摊平字段与起单入参在前,`域.对象` 整表殿后**。
|
||||||
|
*
|
||||||
|
* 顺序就是生死:这一段进提示词前会按字符截断,而整表里躺着**历史所有已放行单**的行,
|
||||||
|
* 只会随单量无限长 —— 排在前面,截断刀落下时被砍掉的是这一单自己的摊平字段(实测
|
||||||
|
* 入参膨胀到 13k token 的那一拍就是这么来的)。整表殿后,砍也先砍旧账。
|
||||||
|
*/
|
||||||
|
function restPart(scope: Record<string, unknown>, options?: { withoutAttachments?: boolean }): Record<string, unknown> {
|
||||||
|
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, unknown>): 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<Record<string, string>> | 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<Record<string, string>> = [];
|
||||||
|
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<string, unknown>): Record<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>): Record<string, unknown> {
|
||||||
|
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<string, unknown>; effect: 'read' | 'write' }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具清单摊成提示词一段:id · 效应 · 说明 · 入参键名。
|
||||||
|
*
|
||||||
|
* 只给键名不给完整 Schema:整份 JSON Schema 铺进去会把上下文吃掉一大半,
|
||||||
|
* 而模型真正需要知道的是「这件工具收哪几个参数」。填错了缝里会校验并如实回报。
|
||||||
|
*/
|
||||||
|
export function toolCatalog(tools: BusListing): string {
|
||||||
|
if (tools.length === 0) return '';
|
||||||
|
const keysOf = (input: Record<string, unknown>): 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<string, unknown>)
|
||||||
|
.map(([key, raw]) => {
|
||||||
|
const spec = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||||
|
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): {
|
export function createAgentRunner(deps: AgentRunnerDeps): {
|
||||||
decide(input: { taskId: TaskId; narrative: string; enabled: EnabledSet; scope: Record<string, unknown> }): Promise<AgentAction>;
|
decide(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
narrative: string;
|
||||||
|
enabled: EnabledSet;
|
||||||
|
scope: Record<string, unknown>;
|
||||||
|
writable: WritableLedger;
|
||||||
|
tools: BusListing;
|
||||||
|
}): Promise<AgentTurn>;
|
||||||
} {
|
} {
|
||||||
return {
|
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 = [
|
const prompt = [
|
||||||
'你是这一单任务的案例工作者。下面是语义引擎发布的**当下可为**与**目标缺口**。',
|
'你是这一单任务的案例工作者。下面是语义引擎发布的**当下可为**与**目标缺口**。',
|
||||||
'你只能选一个行动,且必须落在三型行动之内:调工具 / 调人 / 立监听(外加 start、plan、disable、judge、submit)。',
|
'这一拍你给**一串行动**(actions 数组,1-6 个,按序执行):write(写台账草稿)/',
|
||||||
'规矩:①触达世界的动作只能 tool(写类会被登记为动作意向,放行时才落地)',
|
'tool(调工具)/ ask(补全卡)/ decide(决策卡)/ human(调人)/ listen(立监听),外加 start(启动须手动启动的项)、',
|
||||||
'④调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、',
|
'plan(规划裁量项)、disable(此单不走这条)、judge(评估语义性前提)、finish(宣告一项完成)、submit(提交放行)。',
|
||||||
' basis 给出判断依据(键值对数组)、fields 列出要对方回填的字段名。',
|
/*
|
||||||
' 只写「请处理」的卡收卡人无从下手,等于白发一张。',
|
* **写错了有退路**(2026-08-01 加 drop)。没有它的时候实测过一次死胡同:
|
||||||
'②缺口只有人知道 → human;缺口在世界侧 → listen(必须给 deadline,ISO-8601)',
|
* 客户说本轮跳过的那颗料被写进了有效清单,判据正确地拦住,而模型改不动 ——
|
||||||
'③成果已齐才 submit。只输出 JSON。',
|
* 唯一能改的是那个数,改了别的数又不对,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}`,
|
`【案例说明书与活动记忆】\n${narrative}`,
|
||||||
'',
|
'',
|
||||||
`【当下可为(Enabled 集)】\n${
|
`【当下可为(Enabled 集)】\n${
|
||||||
enabled.entries.map((e) => `- ${e.id} ${e.name}(${e.type}${e.requiresManualStart ? ' · 须你启动' : ''}${e.discretionary ? ' · 裁量项' : ''})`).join('\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('、') || '(无)'}`,
|
`【目标缺口】\n${enabled.gaps.join('、') || '(无)'}`,
|
||||||
enabled.judgements.length === 0
|
enabled.judgements.length === 0
|
||||||
? ''
|
? ''
|
||||||
: `【待你评估的语义性前提】\n${enabled.judgements.map((j) => `- ${j.criterion}:${j.statement}`).join('\n')}`,
|
: `【待你评估的语义性前提】\n${enabled.judgements.map((j) => `- ${j.criterion}:${j.statement}`).join('\n')}`,
|
||||||
breakpoint ? '【注意】当下可为 ∩ 目标所需 = 空 —— 这是依赖断点,请调人或立监听。' : '',
|
breakpoint && enabled.inProgress.length === 0
|
||||||
`【任务数据】\n${JSON.stringify(scope).slice(0, 4000)}`,
|
? '【注意】当下可为与进行中都空 —— 这是依赖断点,请调人或立监听。'
|
||||||
|
: '',
|
||||||
|
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 !== '')
|
.filter((line) => line !== '')
|
||||||
.join('\n');
|
.join('\n');
|
||||||
@@ -93,12 +553,24 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
|
|||||||
const result = await deps.gateway.generate({
|
const result = await deps.gateway.generate({
|
||||||
model,
|
model,
|
||||||
prompt,
|
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,
|
taskId,
|
||||||
label: 'loop-turn',
|
label: 'loop-turn',
|
||||||
});
|
});
|
||||||
await deps.onUsage(taskId, result.usage.inputTokens + result.usage.outputTokens);
|
await deps.onUsage(taskId, result.usage.inputTokens + result.usage.outputTokens);
|
||||||
return normalize(result.object, enabled);
|
return normalizeTurn(result.object, enabled, result.text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
/*
|
/*
|
||||||
* 模型这一轮问不出结果(端点不可达、鉴权失败、超时…)。
|
* 模型这一轮问不出结果(端点不可达、鉴权失败、超时…)。
|
||||||
@@ -110,6 +582,9 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
|
|||||||
*/
|
*/
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
return {
|
return {
|
||||||
|
// 出错也要对人交代一句:不然他只看见工位上多了一张卡,不知道为什么
|
||||||
|
say: { text: `这一轮我没能决断:${detail}`, tag: 'conclusion' as const },
|
||||||
|
actions: [{
|
||||||
kind: 'human',
|
kind: 'human',
|
||||||
itemId: enabled.entries[0]?.id ?? 'breakpoint',
|
itemId: enabled.entries[0]?.id ?? 'breakpoint',
|
||||||
workOrder: 'execution',
|
workOrder: 'execution',
|
||||||
@@ -122,18 +597,83 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
|
|||||||
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
|
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
|
||||||
fields: ['decision'],
|
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<string, unknown>;
|
||||||
|
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<string, unknown> => 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;不合规一律退化为「立监听等人」而不是乱动世界 */
|
/** 模型出参归一为 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(
|
function fillCard(
|
||||||
card: Record<string, unknown>,
|
card: Record<string, unknown>,
|
||||||
enabled: EnabledSet,
|
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<string, unknown>;
|
||||||
|
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<string, unknown> => 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<string, unknown>;
|
||||||
|
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, unknown>): 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<string, string | number>; patch: Record<string, unknown> }> {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const rows: Array<{ key: Record<string, string | number>; patch: Record<string, unknown> }> = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
if (typeof item !== 'object' || item === null) continue;
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
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<string, string | number> = {};
|
||||||
|
for (const [k, v] of Object.entries(key as Record<string, unknown>)) {
|
||||||
|
typedKey[k] = typeof v === 'number' ? v : String(v);
|
||||||
|
}
|
||||||
|
rows.push({ key: typedKey, patch: patch as Record<string, unknown> });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型出参归一为 AgentAction —— 导出是为了让「宽进到什么程度」有测试守着。
|
||||||
|
* 宽进的每一条都对应一次实测踩坑;不合规的行动仍由引擎拒,这里不放行为。
|
||||||
|
*/
|
||||||
|
export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?: string): AgentAction {
|
||||||
const raw = (object ?? {}) as Record<string, unknown>;
|
const raw = (object ?? {}) as Record<string, unknown>;
|
||||||
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 ?? '');
|
const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? '');
|
||||||
|
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
@@ -180,6 +859,121 @@ function normalize(object: unknown, enabled: EnabledSet): AgentAction {
|
|||||||
tool: String(raw['tool'] ?? ''),
|
tool: String(raw['tool'] ?? ''),
|
||||||
input: (raw['input'] ?? {}) as Record<string, unknown>,
|
input: (raw['input'] ?? {}) as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
|
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<string, string | number> => 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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, string> = {};
|
||||||
|
if (typeof keyRaw === 'object' && keyRaw !== null) {
|
||||||
|
for (const [k, v] of Object.entries(keyRaw as Record<string, unknown>)) 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<string, unknown> => typeof g === 'object' && g !== null)
|
||||||
|
.map((g) => {
|
||||||
|
const k: Record<string, string> = {};
|
||||||
|
const kr = g['key'] ?? g['entryKey'];
|
||||||
|
if (typeof kr === 'object' && kr !== null) {
|
||||||
|
for (const [a, b] of Object.entries(kr as Record<string, unknown>)) 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<string, unknown>;
|
||||||
|
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':
|
case 'human':
|
||||||
return {
|
return {
|
||||||
kind: 'human',
|
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',
|
deadline: typeof raw['deadline'] === 'string' && /^P/.test(raw['deadline']) ? raw['deadline'] : 'P3D',
|
||||||
};
|
};
|
||||||
case 'submit':
|
case 'finish':
|
||||||
return { kind: 'submit', rationale: String(raw['rationale'] ?? '判据已收敛') };
|
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<string, unknown>;
|
||||||
|
const revisable = normalizeRevisable(raw['fields'] ?? nested['fields']);
|
||||||
|
return {
|
||||||
|
kind: 'submit',
|
||||||
|
rationale: String(raw['rationale'] ?? '判据已收敛'),
|
||||||
|
...(revisable.length === 0 ? {} : { fields: revisable }),
|
||||||
|
};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
// 看不懂就等人,不擅自触达世界
|
/*
|
||||||
|
* 看不懂就等人,不擅自触达世界。
|
||||||
|
*
|
||||||
|
* 但**要把模型的原话摆在卡上**:只写「模型未给出合规的行动」,收卡的人无从判断
|
||||||
|
* 是模型跑偏了、还是行动型少了一种、还是提示词没交代清楚 —— 而这三种的处置完全不同。
|
||||||
|
* 界面不撒谎的另一半是:不确定的时候,把手上的原始证据交出去。
|
||||||
|
*/
|
||||||
return {
|
return {
|
||||||
kind: 'human',
|
kind: 'human',
|
||||||
itemId,
|
itemId,
|
||||||
card: { title: '需要你判断下一步', reason: '模型未给出合规的行动' },
|
card: {
|
||||||
|
title: '需要你判断下一步',
|
||||||
|
reason: '模型未给出合规的行动',
|
||||||
|
modelReply: rawText === undefined || rawText === '' ? '(空)' : rawText.slice(0, 1200),
|
||||||
|
kindSeen: kind === '' ? '(未给 kind)' : kind,
|
||||||
|
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
|
||||||
|
fields: ['decision'],
|
||||||
|
},
|
||||||
workOrder: 'execution',
|
workOrder: 'execution',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import { Pool } from 'pg';
|
|||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
import { evaluateCondition } from '@sfeel';
|
import { evaluateCondition } from '@sfeel';
|
||||||
import type { Db } from '@shared/db.ts';
|
import type { Db } from '@shared/db.ts';
|
||||||
|
import type { TaskId } from '@contracts';
|
||||||
|
|
||||||
import type { EventService, TriggerMatcher } from '@modules/event';
|
import type { EventService, TriggerMatcher } from '@modules/event';
|
||||||
import { createEventService, createEventStore } from '@modules/event';
|
import { createEventService, createEventStore } from '@modules/event';
|
||||||
import type { LedgerService } from '@modules/ledger';
|
import type { LedgerService } from '@modules/ledger';
|
||||||
|
import type { LoopEngineService } from '@modules/loop-engine';
|
||||||
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
|
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
|
||||||
import type { OrgService, InFlightWorkProbe } from '@modules/org';
|
import type { OrgService, InFlightWorkProbe } from '@modules/org';
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +41,7 @@ import { createActivityMemoryStore, createMemoryService, createTaskMemoryStore }
|
|||||||
import type { ToolboxService, ToolHandler, ToolInvoker } from '@modules/toolbox';
|
import type { ToolboxService, ToolHandler, ToolInvoker } from '@modules/toolbox';
|
||||||
import {
|
import {
|
||||||
createSchemaValidator,
|
createSchemaValidator,
|
||||||
|
createToolHandoffStore,
|
||||||
createToolInvoker,
|
createToolInvoker,
|
||||||
createToolRegistry,
|
createToolRegistry,
|
||||||
createToolReplayStore,
|
createToolReplayStore,
|
||||||
@@ -103,6 +106,12 @@ export type Container = {
|
|||||||
readonly registry: ActivityRegistryService;
|
readonly registry: ActivityRegistryService;
|
||||||
readonly release: ReleaseService;
|
readonly release: ReleaseService;
|
||||||
readonly task: TaskService;
|
readonly task: TaskService;
|
||||||
|
/** 案例引擎 —— 工作台按它算阶段进度;推进照旧走 task,不从这里走 */
|
||||||
|
readonly loop: LoopEngineService;
|
||||||
|
/** 视图闸 —— 人在件上改完数据,按同一张模版用新数据重渲一帧(`views.repush`) */
|
||||||
|
readonly views: { repush(taskId: TaskId): Promise<boolean> };
|
||||||
|
/** 此刻这一单的判据 —— 按当下数据现算(发卡那一刻算死的话,人补一格判据不会动) */
|
||||||
|
readonly checks: { of(taskId: TaskId): Promise<ReadonlyArray<{ key: string; label: string; level: string; pass: boolean; settled?: boolean; rationale?: string }>> };
|
||||||
readonly process: ProcessService;
|
readonly process: ProcessService;
|
||||||
readonly trigger: TriggerService;
|
readonly trigger: TriggerService;
|
||||||
readonly minute: MinuteService;
|
readonly minute: MinuteService;
|
||||||
@@ -161,6 +170,14 @@ const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unkno
|
|||||||
export const firstPartyToolHandlers = new Map<string, ToolHandler>(FIRST_PARTY_TOOLS);
|
export const firstPartyToolHandlers = new Map<string, ToolHandler>(FIRST_PARTY_TOOLS);
|
||||||
|
|
||||||
export async function createContainer(config: AppConfig): Promise<Container> {
|
export async function createContainer(config: AppConfig): Promise<Container> {
|
||||||
|
/*
|
||||||
|
* 广播器**先建** —— 装配期就要往它上面接线。
|
||||||
|
*
|
||||||
|
* 它从前是在 `return` 里现造的,于是装配期谁也拿不到它:
|
||||||
|
* 对话流推送、视图推送、发消息这三条**全都没有广播**,
|
||||||
|
* 前端只好靠人点刷新(用户实测报的:任务列表 / 消息气泡 / 工作区 / 对话区都不自己动)。
|
||||||
|
*/
|
||||||
|
const broadcaster = createBroadcaster();
|
||||||
const pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
|
const pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
|
||||||
/*
|
/*
|
||||||
* 空闲连接被服务端掐断(重启、`pg_terminate_backend`、网络抖动)会以 'error' 事件抛到池上;
|
* 空闲连接被服务端掐断(重启、`pg_terminate_backend`、网络抖动)会以 'error' 事件抛到池上;
|
||||||
@@ -233,6 +250,25 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
events: { publish: async (envelope) => void (await event.publish(envelope)) },
|
events: { publish: async (envelope) => void (await event.publish(envelope)) },
|
||||||
clock: systemClock,
|
clock: systemClock,
|
||||||
ids,
|
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);
|
const instances = createLlmInstanceRepository(db);
|
||||||
@@ -278,6 +314,8 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
secrets,
|
secrets,
|
||||||
clock: systemClock,
|
clock: systemClock,
|
||||||
});
|
});
|
||||||
|
/** 工具转人台(§6.2):系统里没有这件工具时,请人代办 —— 人答回的值就是这次调用的出参 */
|
||||||
|
const toolHandoff = createToolHandoffStore(db);
|
||||||
const toolInvoker = createToolInvoker({
|
const toolInvoker = createToolInvoker({
|
||||||
registry: toolRegistry,
|
registry: toolRegistry,
|
||||||
runtime: toolRuntime,
|
runtime: toolRuntime,
|
||||||
@@ -334,9 +372,41 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
ids,
|
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({
|
const humanTool = createHumanToolService({
|
||||||
cards: createCardStore(db),
|
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: {
|
memory: {
|
||||||
append: async (input) =>
|
append: async (input) =>
|
||||||
void (await memory.append({
|
void (await memory.append({
|
||||||
@@ -346,6 +416,17 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
publishHumanEvent: async (input) => {
|
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<string, unknown>);
|
||||||
|
}
|
||||||
await event.publish({
|
await event.publish({
|
||||||
type: 'human',
|
type: 'human',
|
||||||
correlation: {
|
correlation: {
|
||||||
@@ -461,7 +542,19 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
deliverable: input.deliverable as never,
|
deliverable: input.deliverable as never,
|
||||||
criteria: [...input.criteria],
|
criteria: [...input.criteria],
|
||||||
actions: input.actions.map((a) => ({ ...a })),
|
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 };
|
return { id: issued.order.id };
|
||||||
@@ -537,6 +630,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
memory,
|
memory,
|
||||||
toolbox,
|
toolbox,
|
||||||
toolInvoker,
|
toolInvoker,
|
||||||
|
toolHandoff,
|
||||||
humanTool,
|
humanTool,
|
||||||
interaction,
|
interaction,
|
||||||
message,
|
message,
|
||||||
@@ -581,7 +675,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
broadcaster: createBroadcaster(),
|
broadcaster,
|
||||||
pool,
|
pool,
|
||||||
db,
|
db,
|
||||||
org,
|
org,
|
||||||
@@ -599,6 +693,10 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
registry,
|
registry,
|
||||||
release,
|
release,
|
||||||
task: engines.task,
|
task: engines.task,
|
||||||
|
loop: engines.loop,
|
||||||
|
/** 件是投影 —— 人在件上改完数据,得能按同一张模版用新数据重渲一帧(见 `views.repush`) */
|
||||||
|
views: engines.views,
|
||||||
|
checks: engines.checks,
|
||||||
process: engines.process,
|
process: engines.process,
|
||||||
trigger: engines.trigger,
|
trigger: engines.trigger,
|
||||||
minute: deskElements.minute,
|
minute: deskElements.minute,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 装活动包 —— 独立 CLI(`pnpm --filter @app/api install-activities`)。
|
||||||
|
*
|
||||||
|
* 为什么不复用 `db:seed`:播种把**全套配置**都重写一遍(模型实例、工具、流程包、触发器)。
|
||||||
|
* 开发活动时一天要装十几次,每次都顺手改一遍治理台配置太重 —— 更要紧的是,
|
||||||
|
* 播种会按环境变量重绑模型实例,而开发机上那把密钥常常只存在治理台里、不在环境里。
|
||||||
|
* 装活动就只装活动:读 `activities/<pkg>/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();
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ import { loadConfig } from './config.ts';
|
|||||||
import { createContainer } from './container.ts';
|
import { createContainer } from './container.ts';
|
||||||
import { createServer } from './server.ts';
|
import { createServer } from './server.ts';
|
||||||
import { createConsumers } from './workers/consumers.ts';
|
import { createConsumers } from './workers/consumers.ts';
|
||||||
import { startDispatcher } from './workers/dispatcher.ts';
|
import { dispatchOnce, startDispatcher } from './workers/dispatcher.ts';
|
||||||
import { startTaskAdvancer } from './workers/task-advancer.ts';
|
import { advanceOnce, startTaskAdvancer } from './workers/task-advancer.ts';
|
||||||
|
import { registerPulse } from './workers/pulse.ts';
|
||||||
|
|
||||||
export async function boot(): Promise<void> {
|
export async function boot(): Promise<void> {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -16,11 +17,20 @@ export async function boot(): Promise<void> {
|
|||||||
await container.org.ensureSeeded();
|
await container.org.ensureSeeded();
|
||||||
|
|
||||||
// 派发器:投递记录即队列,按目标 ID 取 advisory lock 串行消费(ADR-008)
|
// 派发器:投递记录即队列,按目标 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);
|
const advancer = startTaskAdvancer({ container }, config.dispatchIntervalMs);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 人一答完就踢一脚 —— **顺序有意义**:先把事件投递下去(任务才醒),再推它一拍。
|
||||||
|
* 反过来登记的话,推进那一轮跑在投递之前,任务还没醒,白跑一轮,
|
||||||
|
* 人还是要等下一个 2 秒。
|
||||||
|
*/
|
||||||
|
registerPulse(() => dispatchOnce(dispatcherDeps));
|
||||||
|
registerPulse(() => advanceOnce({ container }));
|
||||||
|
|
||||||
const app = createServer(container);
|
const app = createServer(container);
|
||||||
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||||
process.stdout.write(`JCAI api · http://localhost:${info.port} · env=${config.nodeEnv}\n`);
|
process.stdout.write(`JCAI api · http://localhost:${info.port} · env=${config.nodeEnv}\n`);
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|||||||
import { Agent } from '@mastra/core/agent';
|
import { Agent } from '@mastra/core/agent';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { TaskId } from '@contracts';
|
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 { ModelGateway } from '@modules/llm';
|
||||||
import type { BusTool } from '@modules/toolbox';
|
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 { buildMastraTools } from './tool-bus.ts';
|
||||||
import type { ToolBusContext, ToolBusPorts } 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:评估的是哪条语义性前提'),
|
criterion: z.string().optional().describe('judge:评估的是哪条语义性前提'),
|
||||||
satisfied: z.boolean().optional().describe('judge:满足与否'),
|
satisfied: z.boolean().optional().describe('judge:满足与否'),
|
||||||
rationale: z.string().optional().describe('结论理由 —— 留痕要看,写实'),
|
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 = {
|
export type MastraAgentRunnerDeps = {
|
||||||
@@ -54,10 +60,18 @@ export type MastraAgentRunnerDeps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
||||||
decide(input: { taskId: TaskId; narrative: string; enabled: EnabledSet; scope: Record<string, unknown> }): Promise<AgentAction>;
|
decide(input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
narrative: string;
|
||||||
|
enabled: EnabledSet;
|
||||||
|
scope: Record<string, unknown>;
|
||||||
|
writable: WritableLedger;
|
||||||
|
/** 开环路径的工具清单来自 `busOf`(它自己会取),这里只为满足同一个端口 */
|
||||||
|
tools?: unknown;
|
||||||
|
}): Promise<AgentTurn>;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
async decide({ taskId, narrative, enabled, scope }) {
|
async decide({ taskId, narrative, enabled, scope, writable }) {
|
||||||
const modelName = await deps.modelOf(taskId);
|
const modelName = await deps.modelOf(taskId);
|
||||||
const base = await deps.contextOf(taskId);
|
const base = await deps.contextOf(taskId);
|
||||||
const bus = await deps.busOf(taskId);
|
const bus = await deps.busOf(taskId);
|
||||||
@@ -75,8 +89,16 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
|||||||
'③ **终局唯放行**:成果齐了就 submit(提交放行),由人验收 —— 你不能自己宣布完成。',
|
'③ **终局唯放行**:成果齐了就 submit(提交放行),由人验收 —— 你不能自己宣布完成。',
|
||||||
'④ 干完活必须给出这一拍的结论(见输出格式);拿不准就 idle 并说清卡在哪。',
|
'④ 干完活必须给出这一拍的结论(见输出格式);拿不准就 idle 并说清卡在哪。',
|
||||||
'',
|
'',
|
||||||
|
'⑥ **每一拍都要带一句 say**:对负责人说人话(他正看着这一单;只干活不说话,他的对话区是空的)。',
|
||||||
|
'⑤ **成果就是台账里的行**:干出来的结论用 ledger.write 逐行写进台账(草稿层),',
|
||||||
|
' 不写就等于没做 —— 验收判据读的是台账,出口载荷也从台账推。只准写字典里的地址与列名。',
|
||||||
|
'',
|
||||||
`【案例说明书与记忆】\n${narrative}`,
|
`【案例说明书与记忆】\n${narrative}`,
|
||||||
].join('\n');
|
'',
|
||||||
|
ledgerDictionary(writable),
|
||||||
|
]
|
||||||
|
.filter((line) => line !== '')
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
const endpoint = await deps.gateway.resolve(modelName);
|
const endpoint = await deps.gateway.resolve(modelName);
|
||||||
const provider = createOpenAICompatible({
|
const provider = createOpenAICompatible({
|
||||||
@@ -99,11 +121,16 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
|||||||
.map((e) => `- ${e.id} ${e.name}(${e.type}${e.requiresManualStart ? ' · 须你启动' : ''}${e.discretionary ? ' · 裁量项' : ''})`)
|
.map((e) => `- ${e.id} ${e.name}(${e.type}${e.requiresManualStart ? ' · 须你启动' : ''}${e.discretionary ? ' · 裁量项' : ''})`)
|
||||||
.join('\n') || '(空)'
|
.join('\n') || '(空)'
|
||||||
}`,
|
}`,
|
||||||
|
enabled.inProgress.length === 0
|
||||||
|
? ''
|
||||||
|
: `【进行中(已开工,未交成果)】\n${enabled.inProgress.map((e) => `- ${e.id} ${e.name}`).join('\n')}`,
|
||||||
`【目标缺口】\n${enabled.gaps.join('、') || '(无)'}`,
|
`【目标缺口】\n${enabled.gaps.join('、') || '(无)'}`,
|
||||||
enabled.judgements.length === 0
|
enabled.judgements.length === 0
|
||||||
? ''
|
? ''
|
||||||
: `【待你评估的语义性前提】\n${enabled.judgements.map((j) => `- ${j.criterion}:${j.statement}`).join('\n')}`,
|
: `【待你评估的语义性前提】\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)}`,
|
`【任务数据】\n${JSON.stringify(scope).slice(0, 4000)}`,
|
||||||
'',
|
'',
|
||||||
'干这一拍该干的活(可以连续调工具),最后给出结论。',
|
'干这一拍该干的活(可以连续调工具),最后给出结论。',
|
||||||
@@ -122,11 +149,16 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
|||||||
if (tokens > 0) await deps.onUsage(taskId, tokens);
|
if (tokens > 0) await deps.onUsage(taskId, tokens);
|
||||||
|
|
||||||
// 调过人就必须收在 asked:卡已发、监听已立,引擎只收尾挂起(再发一张就是重复调人)
|
// 调过人就必须收在 asked:卡已发、监听已立,引擎只收尾挂起(再发一张就是重复调人)
|
||||||
|
const object = (result as { object?: unknown }).object;
|
||||||
|
const said = sayOf(object);
|
||||||
if (trace.askedCardId !== undefined) {
|
if (trace.askedCardId !== undefined) {
|
||||||
return { kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人,等回传' };
|
return {
|
||||||
|
actions: [{ kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人,等回传' }],
|
||||||
|
...(said === undefined ? {} : { say: said }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
// 开环一拍 = Mastra 在拍内自主连环调工具,拍末只交一个收束行动 —— 包成单元素串
|
||||||
return normalizeTurn((result as { object?: unknown }).object, enabled);
|
return { actions: [normalizeTurn(object, enabled)], ...(said === undefined ? {} : { say: said }) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
/*
|
/*
|
||||||
* 这一拍问不出结果(端点不可达、鉴权失败、超时、工具连环炸)——
|
* 这一拍问不出结果(端点不可达、鉴权失败、超时、工具连环炸)——
|
||||||
@@ -137,10 +169,12 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
|||||||
* 不要再叠一张「模型出错」的卡上去。
|
* 不要再叠一张「模型出错」的卡上去。
|
||||||
*/
|
*/
|
||||||
if (trace.askedCardId !== undefined) {
|
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);
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
return {
|
return {
|
||||||
|
say: { text: `这一轮我没能决断:${detail}`, tag: 'conclusion' as const },
|
||||||
|
actions: [{
|
||||||
kind: 'human',
|
kind: 'human',
|
||||||
itemId: enabled.entries[0]?.id ?? 'breakpoint',
|
itemId: enabled.entries[0]?.id ?? 'breakpoint',
|
||||||
workOrder: 'execution',
|
workOrder: 'execution',
|
||||||
@@ -154,12 +188,27 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
|||||||
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
|
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
|
||||||
fields: ['decision'],
|
fields: ['decision'],
|
||||||
},
|
},
|
||||||
|
}],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 拍末那句交代 —— 每一拍都该有;没给就没有,不替它编 */
|
||||||
|
function sayOf(object: unknown): { text: string; tag?: 'guidance' | 'criterion' | 'conclusion'; view?: string } | undefined {
|
||||||
|
const raw = (object ?? {}) as Record<string, unknown>;
|
||||||
|
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 留痕),绝不乱动世界 */
|
/** 拍末结论归一为 AgentAction;给不出结论就当空拍(idle → judge 留痕),绝不乱动世界 */
|
||||||
function normalizeTurn(object: unknown, enabled: EnabledSet): AgentAction {
|
function normalizeTurn(object: unknown, enabled: EnabledSet): AgentAction {
|
||||||
const raw = (object ?? {}) as Record<string, unknown>;
|
const raw = (object ?? {}) as Record<string, unknown>;
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ const RUN_TABLES = [
|
|||||||
'sys.chat_card',
|
'sys.chat_card',
|
||||||
'sys.chat_message',
|
'sys.chat_message',
|
||||||
'sys.system_message',
|
'sys.system_message',
|
||||||
|
/*
|
||||||
|
* 沟通记录与上传件是**跑出来的**,而且两张表都没有指向 task 的外键 ——
|
||||||
|
* `truncate ... cascade` 带不走它们(实测漏了 53 条沟通记录、1 个上传件)。
|
||||||
|
* 漏下的是指向已删任务的孤儿:那封信还在沟通记录里躺着,点开却没有单。
|
||||||
|
*/
|
||||||
|
'sys.task_comm',
|
||||||
|
'sys.upload',
|
||||||
'sys.node_instance',
|
'sys.node_instance',
|
||||||
// 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿
|
// 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿
|
||||||
'sys.trace_span',
|
'sys.trace_span',
|
||||||
@@ -48,19 +55,40 @@ const RUN_TABLES = [
|
|||||||
/* 纪要是人记的,但开发期同属「跑出来的东西」—— 清库要清得干净,不留半场旧会 */
|
/* 纪要是人记的,但开发期同属「跑出来的东西」—— 清库要清得干净,不留半场旧会 */
|
||||||
'sys.minute',
|
'sys.minute',
|
||||||
'sys.minute_resolution',
|
'sys.minute_resolution',
|
||||||
|
/* 试跑日志是试跑的存档:单没了,存档留着只会是一堆点不进去的空目录 */
|
||||||
|
'sys.testbed_run',
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function resetRuns(): Promise<void> {
|
export async function resetRuns(): Promise<void> {
|
||||||
const pool = new Pool({ connectionString: loadConfig().databaseUrl, max: 2 });
|
const pool = new Pool({ connectionString: loadConfig().databaseUrl, max: 2 });
|
||||||
try {
|
try {
|
||||||
// 台账的业务表是动态建的,名字问库不写死
|
/*
|
||||||
|
* 台账的业务表是动态建的,名字问库不写死。
|
||||||
|
*
|
||||||
|
* **主数据留着**(2026-07-31):客户 / 项目 / 供应商 / 市场是先于活动存在的档案,
|
||||||
|
* 活动装了卸了它都在 —— 它属于「配置好的」那一半,不是「跑出来的」。
|
||||||
|
* 从前这里把整个 ledger schema 一并 truncate,后果是每清一次运行数据,
|
||||||
|
* 样例客户就没了,「老客户来函」那条样例第二天就试不出来(而它本该是常在的夹具)。
|
||||||
|
* `__object_meta` 同理:性质登记随声明走,不随跑单走;抹了它,
|
||||||
|
* 治理台上全部表都成了「无人声明」,主数据那一区直接空掉。
|
||||||
|
* 单据(record)、派生(derived)、没登记性质的,照旧清 —— 那些确是跑出来的。
|
||||||
|
*/
|
||||||
const ledger = await pool.query<{ name: string }>(
|
const ledger = await pool.query<{ name: string }>(
|
||||||
`select quote_ident(schemaname) || '.' || quote_ident(tablename) as name
|
`select quote_ident(t.schemaname) || '.' || quote_ident(t.tablename) as name
|
||||||
from pg_tables where schemaname = 'ledger'`,
|
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)];
|
const all = [...RUN_TABLES, ...ledger.rows.map((r) => r.name)];
|
||||||
await pool.query(`truncate table ${all.join(', ')} restart identity cascade`);
|
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 {
|
} finally {
|
||||||
await pool.end();
|
await pool.end();
|
||||||
}
|
}
|
||||||
|
|||||||
+781
-12
@@ -3,18 +3,20 @@
|
|||||||
*
|
*
|
||||||
* 「web 是纯呈现与输入面」:这里出的每一样都是服务端投影,前端零过程状态、刷新即重建(§9.2)。
|
* 「web 是纯呈现与输入面」:这里出的每一样都是服务端投影,前端零过程状态、刷新即重建(§9.2)。
|
||||||
*/
|
*/
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
import type { Hono } from 'hono';
|
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 {
|
import {
|
||||||
ATTRIBUTION_SIDE_LABEL,
|
|
||||||
answerMessageRoute,
|
answerMessageRoute,
|
||||||
chatCardReplySchema,
|
chatCardReplySchema,
|
||||||
confirmTriggerPendingRoute,
|
confirmTriggerPendingRoute,
|
||||||
listMessagesRoute,
|
listMessagesRoute,
|
||||||
markMessageReadRoute,
|
markMessageReadRoute,
|
||||||
listTasksRoute,
|
listTasksRoute,
|
||||||
|
markCommReadRoute,
|
||||||
replyChatCardRoute,
|
replyChatCardRoute,
|
||||||
replyWorkOrderRoute,
|
replyWorkOrderRoute,
|
||||||
|
saveTableRoute,
|
||||||
sayRoute,
|
sayRoute,
|
||||||
taskDetailRoute,
|
taskDetailRoute,
|
||||||
staffIdSchema,
|
staffIdSchema,
|
||||||
@@ -23,8 +25,525 @@ import {
|
|||||||
} from '@contracts';
|
} from '@contracts';
|
||||||
import { ReplyRejectedError } from '@modules/human-tool';
|
import { ReplyRejectedError } from '@modules/human-tool';
|
||||||
import { advanceOrder } from '@modules/process';
|
import { advanceOrder } from '@modules/process';
|
||||||
|
import { displayLog } from './log-lines.ts';
|
||||||
|
import { pulse } from '../workers/pulse.ts';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
|
|
||||||
|
/** drizzle 的 execute 出参在不同驱动下形状不一 —— 收在一处判 */
|
||||||
|
function rowsOf<T>(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 type { AppEnv } from '../http-env.ts';
|
||||||
|
import { coerceToColumn, missingReadings } from '../wiring/engines.ts';
|
||||||
|
|
||||||
|
|
||||||
|
/** 一张表收上来的样子:`{ 台账列名: 值 }`,一行一条 —— 主键列在里面,但只用来认行 */
|
||||||
|
type EditedRow = Readonly<Record<string, string>>;
|
||||||
|
|
||||||
|
/** 写回的收成:改了几行、新增几行、撤了几行、哪几格归不了型 */
|
||||||
|
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<WriteBack> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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<string, unknown> = {};
|
||||||
|
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<WriteBack> {
|
||||||
|
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<Record<string, unknown>> | undefined,
|
||||||
|
patch: Readonly<Record<string, unknown>>,
|
||||||
|
): 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<Record<string, string>>,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
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<Record<string, unknown>> }));
|
||||||
|
const same = page.rows.filter((r) => others.every((k) => String(r[k] ?? '') === (given[k] ?? '')));
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
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<string, string>();
|
||||||
|
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<string, number>();
|
||||||
|
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<string, string>();
|
||||||
|
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<string, string>();
|
||||||
|
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<number> {
|
||||||
|
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<Record<string, readonly EditedRow[]>>;
|
||||||
|
drops?: Readonly<Record<string, readonly EditedRow[]>> | undefined;
|
||||||
|
},
|
||||||
|
): Promise<number> {
|
||||||
|
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<Record<string, readonly EditedRow[]>>,
|
||||||
|
): 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<Record<string, string>> },
|
||||||
|
): Promise<number> {
|
||||||
|
const blocks = (order as { blocks?: ReadonlyArray<Record<string, unknown>> }).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<string, string> = {};
|
||||||
|
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<ViewBlock, { t: 'table' }> => 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<string, unknown[]> = {};
|
||||||
|
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<Record<string, string | number>>,
|
||||||
|
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<AppEnv>, container: Container): void {
|
export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||||
/** 当前登录者;缺会话即 401 */
|
/** 当前登录者;缺会话即 401 */
|
||||||
@@ -53,6 +572,13 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 阶段进度 —— 一行运行时读出来(阶段声明与计划项状态同在那一行)。
|
||||||
|
* 每张卡一次读:任务清单是「我手上开着的单」,量级是个位数,与上面
|
||||||
|
* 已有的按卡取工作流/流程包同一个形状。活动没声明阶段就没有这一格。
|
||||||
|
*/
|
||||||
|
const phase = await container.loop.phaseOf(task.id, task.terminal !== undefined).catch(() => undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
activity: task.title,
|
activity: task.title,
|
||||||
@@ -61,6 +587,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
...(task.terminal === undefined ? {} : { terminal: task.terminal }),
|
...(task.terminal === undefined ? {} : { terminal: task.terminal }),
|
||||||
position,
|
position,
|
||||||
total,
|
total,
|
||||||
|
...(phase === undefined ? {} : { phase }),
|
||||||
workflowSubject: subject,
|
workflowSubject: subject,
|
||||||
workflowState,
|
workflowState,
|
||||||
startedAt: task.startedAt,
|
startedAt: task.startedAt,
|
||||||
@@ -149,6 +676,78 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
container.interaction.viewOf(taskId),
|
container.interaction.viewOf(taskId),
|
||||||
]);
|
]);
|
||||||
const cards = await container.humanTool.chatCards(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<string, unknown>;
|
||||||
|
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);
|
const chain = await toChain(task);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -218,21 +817,60 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
stages: [],
|
stages: [],
|
||||||
deliverables,
|
deliverables,
|
||||||
results,
|
results,
|
||||||
workOrders,
|
comms,
|
||||||
|
workOrders: gated,
|
||||||
chat: merged,
|
chat: merged,
|
||||||
view: pushed?.blocks !== undefined ? [...pushed.blocks] : view,
|
view: pushed?.blocks !== undefined ? [...pushed.blocks] : view,
|
||||||
...(pushed?.html === undefined ? {} : { viewHtml: pushed.html }),
|
...(pushed?.html === undefined ? {} : { viewHtml: pushed.html }),
|
||||||
log: lines.map((l) => ({
|
log: await displayLog(lines, container.org),
|
||||||
id: l.id,
|
|
||||||
at: l.at,
|
|
||||||
side: l.side,
|
|
||||||
sideLabel: ATTRIBUTION_SIDE_LABEL[l.side],
|
|
||||||
title: l.title,
|
|
||||||
...(l.summary === undefined ? {} : { summary: l.summary }),
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **就地改完按下「保存」** —— 写回本单草稿层,并**按新数据把件重渲一遍**。
|
||||||
|
*
|
||||||
|
* 两步缺一不可:
|
||||||
|
* ① 落库 —— 不落,人改的东西只活在浏览器里,提交前一刷新就没了;
|
||||||
|
* ② 重渲 —— 件是**服务端投影**:只读那几格是他自己敲的,而由数据算出来的东西
|
||||||
|
* (类徽 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** —— 前端提交校验用的也是它 */
|
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
|
||||||
app.post(replyWorkOrderRoute.path, async (c) => {
|
app.post(replyWorkOrderRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = await me(c.get('account'));
|
||||||
@@ -247,6 +885,20 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const outcome = await container.humanTool.replyWorkOrder(parsed.data, staff.id);
|
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 });
|
return c.json({ ok: true, producesCompletion: outcome.producesCompletion });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ReplyRejectedError) {
|
if (error instanceof ReplyRejectedError) {
|
||||||
@@ -274,7 +926,58 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
return c.json(body, 400);
|
return c.json(body, 400);
|
||||||
}
|
}
|
||||||
try {
|
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<string, string> = {};
|
||||||
|
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 });
|
return c.json({ ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ReplyRejectedError) {
|
if (error instanceof ReplyRejectedError) {
|
||||||
@@ -287,6 +990,72 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** 自由输入 —— 人事件的第二形态:写入任务记忆,loop 作为观察;不驱动 flow 步 */
|
/** 自由输入 —— 人事件的第二形态:写入任务记忆,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) => {
|
app.post(sayRoute.path, async (c) => {
|
||||||
const staff = await me(c.get('account'));
|
const staff = await me(c.get('account'));
|
||||||
if (staff === undefined) {
|
if (staff === undefined) {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
* 「治理动作本身留痕入事件流 —— 治理也被治理。」
|
* 「治理动作本身留痕入事件流 —— 治理也被治理。」
|
||||||
*/
|
*/
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { extractText } from '../upload-extract.ts';
|
||||||
import type { Hono } from 'hono';
|
import type { Hono } from 'hono';
|
||||||
import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';
|
import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';
|
||||||
import {
|
import {
|
||||||
ATTRIBUTION_SIDE_LABEL,
|
|
||||||
browseLedgerRoute,
|
browseLedgerRoute,
|
||||||
controlTaskRoute,
|
controlTaskRoute,
|
||||||
govMetricsRoute,
|
govMetricsRoute,
|
||||||
@@ -26,9 +26,17 @@ import {
|
|||||||
listTriggersRoute,
|
listTriggersRoute,
|
||||||
registerToolRoute,
|
registerToolRoute,
|
||||||
saveProcessRoute,
|
saveProcessRoute,
|
||||||
|
testActivityStateRoute,
|
||||||
testCurrentRoute,
|
testCurrentRoute,
|
||||||
|
testLogDetailRoute,
|
||||||
|
testRunActivityRoute,
|
||||||
testRunRoute,
|
testRunRoute,
|
||||||
testStopRoute,
|
testStopRoute,
|
||||||
|
listTestLogsRoute,
|
||||||
|
deleteTestLogRoute,
|
||||||
|
clearTestLogsRoute,
|
||||||
|
downloadFileRoute,
|
||||||
|
uploadFileRoute,
|
||||||
traceOfWorkflowRoute,
|
traceOfWorkflowRoute,
|
||||||
setProcessStateRoute,
|
setProcessStateRoute,
|
||||||
reassignTaskRoute,
|
reassignTaskRoute,
|
||||||
@@ -63,6 +71,7 @@ import {
|
|||||||
} from '@modules/process';
|
} from '@modules/process';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
import type { AppEnv } from '../http-env.ts';
|
import type { AppEnv } from '../http-env.ts';
|
||||||
|
import { displayLog } from './log-lines.ts';
|
||||||
|
|
||||||
/** `db.execute` 的行读取 —— Drizzle 的返回型随驱动变,窄化收在这一处 */
|
/** `db.execute` 的行读取 —— Drizzle 的返回型随驱动变,窄化收在这一处 */
|
||||||
function rowsOf<T>(result: unknown): T[] {
|
function rowsOf<T>(result: unknown): T[] {
|
||||||
@@ -78,8 +87,180 @@ function rowsOf<T>(result: unknown): T[] {
|
|||||||
* 用 `source->>'ref' = 'testbed'` 认门:**只动测试台自己起的单**,
|
* 用 `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<void> {
|
||||||
|
if (input.taskIds.length === 0) return;
|
||||||
|
try {
|
||||||
|
const trail: { at: string; kind: string; title: string; detail?: Record<string, unknown> }[] = [];
|
||||||
|
const rows: { address: string; key: Record<string, unknown>; patch: Record<string, unknown> }[] = [];
|
||||||
|
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<string, number>;
|
||||||
|
caps: Record<string, number>;
|
||||||
|
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<string, unknown> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
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<number> {
|
async function destroySimulations(container: Container, workflowId?: string): Promise<number> {
|
||||||
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 }>(
|
const wfs = await container.db.execute<{ id: string }>(
|
||||||
sql`select id from sys.workflow where source->>'ref' = 'testbed'${scope}`,
|
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 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);
|
const taskIds = rowsOf<{ id: string }>(tasks).map((r) => r.id);
|
||||||
taskCount += taskIds.length;
|
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) {
|
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.work_order where task_id = ${taskId}`);
|
||||||
await container.db.execute(sql`delete from sys.chat_card 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<AppEnv>, container: Contai
|
|||||||
if (denied) return c.json(denied, denyStatus(denied));
|
if (denied) return c.json(denied, denyStatus(denied));
|
||||||
const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId')));
|
const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId')));
|
||||||
return c.json({
|
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<AppEnv>, container: Contai
|
|||||||
data,
|
data,
|
||||||
...(Object.keys(assignments).length === 0 ? {} : { assignments }),
|
...(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 });
|
return c.json({ workflowId: record.id, state: record.state });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 起不来的原因要如实说(不合调用接口 / 无生效版 / 岗位空缺),别让人对着空白猜
|
// 起不来的原因要如实说(不合调用接口 / 无生效版 / 岗位空缺),别让人对着空白猜
|
||||||
@@ -391,19 +598,272 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' };
|
const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' };
|
||||||
return c.json(body, 400);
|
return c.json(body, 400);
|
||||||
}
|
}
|
||||||
const tasks = await destroySimulations(container, parsed.data.workflowId);
|
const { workflowId, taskId } = parsed.data;
|
||||||
return c.json({ destroyed: true, tasks });
|
/*
|
||||||
|
* 两种试法各有其拔法。都不给 = 拔掉所有试跑残留(换一种试法之前先清场):
|
||||||
|
* 一次只跑一条,不然读数屏上是哪一单的数就说不清了。
|
||||||
|
*/
|
||||||
|
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) => {
|
app.get(testCurrentRoute.path, async (c) => {
|
||||||
const denied = await governProcess(c.get('account'));
|
const denied = await governProcess(c.get('account'));
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
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];
|
const flow = rowsOf<{ id: string; subject: string; state: string }>(flows)[0];
|
||||||
return c.json(hit === undefined ? {} : { workflowId: hit.id, subject: hit.subject });
|
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<TestLogDbRow>(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<TestLogDbRow>(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<TestLogDbRow & { trail: unknown; drafts: unknown; cards: unknown }>(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<TestLogDbRow & { trail: unknown; drafts: unknown; cards: unknown }>(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 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
|
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
|
||||||
@@ -786,7 +1246,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
const faces = await Promise.all(
|
const faces = await Promise.all(
|
||||||
cards.map(async (card) => {
|
cards.map(async (card) => {
|
||||||
const manifest = await container.registry.manifestOf(card.activityId);
|
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 {
|
const data = manifest.contract.input.data as {
|
||||||
required?: string[];
|
required?: string[];
|
||||||
properties?: Record<string, FieldSpec>;
|
properties?: Record<string, FieldSpec>;
|
||||||
@@ -806,7 +1266,13 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
name,
|
name,
|
||||||
type: typeOf(spec),
|
type: typeOf(spec),
|
||||||
required: required.includes(name),
|
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 就能衔接下游
|
// 必发口与选择组一并列出:调用方只凭口名与 schema 就能衔接下游
|
||||||
outputPorts: [
|
outputPorts: [
|
||||||
...manifest.contract.output.mandatory,
|
...manifest.contract.output.mandatory,
|
||||||
@@ -897,6 +1363,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
|||||||
app.get(listLedgerObjectsRoute.path, async (c) => {
|
app.get(listLedgerObjectsRoute.path, async (c) => {
|
||||||
const denied = await readData(c.get('account'));
|
const denied = await readData(c.get('account'));
|
||||||
if (denied) return c.json(denied, denyStatus(denied));
|
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() });
|
return c.json({ objects: await container.ledger.listObjects() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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<DisplayLogLine[]> {
|
||||||
|
const ids = [...new Set(lines.flatMap((l) => (l.actorId === undefined ? [] : [l.actorId])))];
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
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 }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* 真活动包的安装 —— **经安装通道读文件,不经代码通道**(ADR-004 / 编码规范 §5.1)。
|
||||||
|
*
|
||||||
|
* 活动包在系统外开发:`activities/<pkg>/` 里写清单与资产,`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/<pkg>/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<void> {
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+182
-173
@@ -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 { objectDeclarationSchema } from '@contracts';
|
||||||
import type { LedgerFieldType, ObjectDeclaration } from '@contracts';
|
import type { LedgerFieldType, ObjectDeclaration } from '@contracts';
|
||||||
@@ -16,184 +29,180 @@ const f = (name: string, type: LedgerFieldType, label: string, primaryKey = fals
|
|||||||
masked: false,
|
masked: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const obj = (domain: string, object: string, label: string, fields: ReturnType<typeof f>[]) =>
|
/** 主数据一律 `kind: 'master'`:按业务实体分行,没有单标识 */
|
||||||
objectDeclarationSchema.parse({ domain, object, label, fields });
|
const master = (domain: string, object: string, label: string, fields: ReturnType<typeof f>[]) =>
|
||||||
|
objectDeclarationSchema.parse({ domain, object, label, kind: 'master', fields });
|
||||||
|
|
||||||
export const MVP_OBJECTS: readonly ObjectDeclaration[] = [
|
export const MASTER_OBJECTS: readonly ObjectDeclaration[] = [
|
||||||
/* ── project:每一单的流转单据(位置签 输入/过程/输出) ─────────────── */
|
/* ── 客户:谁在买 ──────────────────────────────────────────────────── */
|
||||||
obj('project', 'rfq_file', 'RFQ/BOM文件', [
|
master('customer', 'profile', '客户档案', [
|
||||||
f('rfq_ref', 'text', '询价单号', true),
|
f('customer_code', 'text', '客户编码', true),
|
||||||
f('pn', 'text', 'PN', true),
|
f('name', 'text', '客户名称'),
|
||||||
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),
|
|
||||||
f('grade', 'text', '客户等级'),
|
f('grade', 'text', '客户等级'),
|
||||||
f('tags', 'text', '画像标签'),
|
f('industry', 'text', '所属行业'),
|
||||||
f('payment_terms', 'text', '付款条款'),
|
f('region', 'text', '区域'),
|
||||||
f('industry', 'text', '行业属性'),
|
f('owner', 'text', '负责 BD'),
|
||||||
f('risk_level', 'text', '风险等级'),
|
f('payment_terms', 'text', '付款条件'),
|
||||||
f('annual_purchase', 'number', '年采购额'),
|
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', '历史价格情报', [
|
master('project', 'profile', '项目档案', [
|
||||||
f('pn', 'text', 'PN', true),
|
f('project_code', 'text', '项目编码', true),
|
||||||
f('quoted_at', 'timestamp', '报价日期', true),
|
f('customer_code', 'text', '所属客户'),
|
||||||
f('brand', 'text', '厂牌'),
|
f('name', 'text', '项目名称'),
|
||||||
f('unit_price_cny', 'number', '单价CNY'),
|
/* BOM 早期 / BOM 后期 / 量产 —— A3 的询价打法就按这个分 */
|
||||||
f('lead_time', 'text', '交期'),
|
f('stage', 'text', '项目阶段'),
|
||||||
f('moq', 'text', 'MOQ'),
|
f('annual_volume', 'integer', '年用量预估'),
|
||||||
f('valid_until', 'date', '有效期'),
|
f('start_at', 'date', '立项日期'),
|
||||||
|
f('owner', 'text', '负责人'),
|
||||||
|
f('status', 'text', '状态'),
|
||||||
]),
|
]),
|
||||||
obj('intel', 'chip_spec', '芯片规格书', [
|
master('project', 'bom', '项目 BOM', [
|
||||||
f('pn', 'text', 'PN', true),
|
f('project_code', 'text', '项目编码', true),
|
||||||
f('key_params', 'text', '关键参数'),
|
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('package', 'text', '封装'),
|
||||||
f('version_date', 'date', '版本日期'),
|
/* Active / NRND / EOL —— A1 三分类与 A2 选型都读它 */
|
||||||
]),
|
|
||||||
obj('intel', 'public_select', '公网选型库', [
|
|
||||||
f('alt_relation', 'text', '替代关系', true),
|
|
||||||
f('param_compare', 'text', '参数对比'),
|
|
||||||
f('spec_ref', 'text', '规格书'),
|
|
||||||
f('lifecycle', 'text', '生命周期'),
|
f('lifecycle', 'text', '生命周期'),
|
||||||
f('price_ref', 'text', '价格参考'),
|
f('rohs', 'boolean', 'RoHS'),
|
||||||
|
f('datasheet_url', 'text', '规格书链接'),
|
||||||
]),
|
]),
|
||||||
|
master('market', 'price_history', '历史价格情报', [
|
||||||
/* ── supplier:供应商信息 ─────────────────────────────────────────── */
|
f('pn', 'text', '料号', true),
|
||||||
obj('supplier', 'channel_dir', '供应商渠道目录', [
|
f('quoted_at', 'date', '报价日期', true),
|
||||||
f('supplier', 'text', '供应商', true),
|
f('customer_code', 'text', '客户编码'),
|
||||||
f('channel_type', 'text', '渠道类型'),
|
f('unit_price', 'number', '成交/报出单价'),
|
||||||
f('brand_lines', 'text', '品牌线'),
|
f('lead_time', 'text', '交期'),
|
||||||
f('contact', 'text', '联系人'),
|
f('lost_reason', 'text', '未成交原因'),
|
||||||
|
f('competitor_price', 'number', '竞对价位'),
|
||||||
]),
|
]),
|
||||||
obj('supplier', 'dead_stock', '呆滞库存表', [
|
master('market', 'quote_index', '市场行情', [
|
||||||
f('pn', 'text', 'PN', true),
|
f('pn', 'text', '料号', true),
|
||||||
f('stock_qty', 'number', '库存量'),
|
f('observed_at', 'date', '观测日期', true),
|
||||||
f('stock_age', 'text', '库龄'),
|
f('source', 'text', '来源'),
|
||||||
f('cost_price', 'number', '成本价'),
|
f('price', 'number', '市场价'),
|
||||||
|
f('stock', 'integer', '可见库存'),
|
||||||
|
f('trend', 'text', '走势'),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
export function declarationsFor(objects: readonly `${string}.${string}`[]): ObjectDeclaration[] {
|
/** 四域的呈现次序 —— 谁在买 → 为什么买 → 从哪买 → 买什么 */
|
||||||
return objects.map((key) => {
|
export const MASTER_DOMAINS = ['customer', 'project', 'supplier', 'market'] as const;
|
||||||
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;
|
*
|
||||||
});
|
* 主数据四域建出来是空的,而 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;
|
||||||
|
|||||||
@@ -30,15 +30,20 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
|||||||
version: 1,
|
version: 1,
|
||||||
state: 'active',
|
state: 'active',
|
||||||
callInterface: {
|
callInterface: {
|
||||||
|
/*
|
||||||
|
* 起单入参 = **那封信**,与 A1 的契约一致(2026-07-31 跟着改)。
|
||||||
|
* 从前这里要 `customerCode` + `rfqRef`,而 A1 早已改成收一封原始来函 ——
|
||||||
|
* 两边对不上的后果实测过:通道抽不出这两个键,基座当场发补全卡,
|
||||||
|
* 整条流 0 拍停在那儿等人,而信本身是完整的。
|
||||||
|
*/
|
||||||
input: {
|
input: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['customerCode', 'rfqRef'],
|
required: ['customerEmail', 'subject', 'body'],
|
||||||
properties: {
|
properties: {
|
||||||
customerCode: { type: 'string' },
|
customerEmail: { type: 'string' },
|
||||||
rfqRef: { type: 'string' },
|
|
||||||
subject: { type: 'string' },
|
subject: { type: 'string' },
|
||||||
body: { type: 'string' },
|
body: { type: 'string' },
|
||||||
quotedAt: { type: 'string' },
|
attachments: { type: 'array' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
output: { type: 'object', properties: { landingRate: { type: 'number' } } },
|
output: { type: 'object', properties: { landingRate: { type: 'number' } } },
|
||||||
@@ -50,10 +55,16 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
|||||||
*/
|
*/
|
||||||
nodes: [
|
nodes: [
|
||||||
{ id: 'start', kind: 'start', label: '客户RFQ询价', layout: { row: 0, col: 0, x: 30, y: 70 } },
|
{ 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),
|
* **六个节点全部绑真活动包**(2026-07-31)—— 从前只有 A1 是真的,其余绑的是
|
||||||
activityNode('A3', '寻源询价', 'sourcing-inquiry', 3, 0, 470, 60),
|
* `alt-selection` / `sourcing-inquiry` / `cost-pricing` / `quote-negotiation` /
|
||||||
activityNode('A4', '成本定价', 'cost-pricing', 4, 1, 110, 310),
|
* `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',
|
id: 'A5',
|
||||||
kind: 'approval',
|
kind: 'approval',
|
||||||
@@ -68,8 +79,8 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
|||||||
nudge: { steps: [{ after: 'PT20M', action: 'nudge' }], deadline: 'PT30M' },
|
nudge: { steps: [{ after: 'PT20M', action: 'nudge' }], deadline: 'PT30M' },
|
||||||
layout: { row: 1, col: 5, x: 290, y: 310 },
|
layout: { row: 1, col: 5, x: 290, y: 310 },
|
||||||
},
|
},
|
||||||
activityNode('A6', '报价议价', 'quote-negotiation', 6, 1, 470, 310),
|
activityNode('A6', '报价议价', 'quote-negotiate', 6, 1, 470, 310),
|
||||||
activityNode('A7', '情报归档', 'intel-archive', 7, 2, 630, 410),
|
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_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 } },
|
{ 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: [],
|
anchors: [],
|
||||||
nodes: [
|
nodes: [
|
||||||
{ id: 'start', kind: 'start', label: '客户RFQ询价', x: 40, y: 80, lx: 54, ly: 112 },
|
{ 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: 'A2', kind: 'activity', label: '替代选型', code: 'A2', x: 300, y: 70 },
|
||||||
{ id: 'A3', kind: 'activity', label: '寻源询价', code: 'A3', x: 480, y: 70 },
|
{ id: 'A3', kind: 'activity', label: '寻源询价', code: 'A3', x: 480, y: 70 },
|
||||||
{ id: 'A4', kind: 'activity', label: '成本定价', code: 'A4', x: 120, y: 320 },
|
{ 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"))',
|
rule: 'channelId = "ch-rfq-mail" and (contains(subject, "询价") or contains(subject, "RFQ"))',
|
||||||
eventType: 'data',
|
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 自动渲染 */
|
/** 登记触发器:人确认后起单,「登记人即来源」—— 表单由调用接口 schema 自动渲染 */
|
||||||
|
|||||||
@@ -1,619 +1,49 @@
|
|||||||
/**
|
/**
|
||||||
* 六个活动槽位(docs/MVP示例工作流.html §1.1–1.7)—— **契约就绪、实现留白**。
|
* 六个活动槽位的**退役名单** —— 槽位时代到此为止(2026-07-31 清账)。
|
||||||
*
|
*
|
||||||
* 槽位 = 活动的契约面:身份 / 七件契约(入参、出口)/ 声明(域对象、工具名)/ 业务模型骨架。
|
* 这个文件从前装着六份「契约就绪、实现留白」的占位清单:编排层跑通期,
|
||||||
* 与工具的解耦就在声明:`declarations.tools` 只写**名**,注册、连接值、白名单全在治理侧。
|
* 靠它们让流走得动。六个真活动包(`activities/quote-*`)全部转正之后,
|
||||||
* A5 定价审批是**内置审批点**(审批不是任务,零开发、不入库),不在槽位之列。
|
* 占位清单就该走 —— 留着它们不是「多一个备份」,是**三处会骗人的重影**:
|
||||||
*
|
*
|
||||||
* 实现待底座完备后逐个开发:届时以新包版本经安装通道换入,槽位契约不动。
|
* · 活动库与试跑台上并排两个同名活动,选错一个就试了个空壳;
|
||||||
|
* · 它们声明的 `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 type { Container } from './container.ts';
|
||||||
import { declarationsFor } from './seed-mvp-data.ts';
|
|
||||||
|
|
||||||
/**
|
/** 已退役的槽位 —— 真活动包顶替它之后,库里那一份也该走 */
|
||||||
* 槽位占位入口 —— **不是实现**:装载它只会得到「未实现」的诚实报错。
|
const RETIRED_SLOTS = [
|
||||||
* mock 只许在外界:这里绝不发既定载荷装作跑过(README 平移规则)。
|
'rfq-intake',
|
||||||
*/
|
'alt-selection',
|
||||||
export const SLOT_ARTIFACT = [
|
'sourcing-inquiry',
|
||||||
'// 活动槽位:契约已就绪,实现待底座完备后逐个开发。',
|
'cost-pricing',
|
||||||
"throw new Error('活动尚未实现 —— 这是槽位,契约见治理台活动库');",
|
'quote-negotiation',
|
||||||
].join('\n');
|
'intel-archive',
|
||||||
|
] as const;
|
||||||
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) =>
|
|
||||||
` <humanTask id="T${i + 1}" name="${label}">\n` +
|
|
||||||
` <itemControl><manualActivationRule id="m${i + 1}"/></itemControl>\n` +
|
|
||||||
` </humanTask>`,
|
|
||||||
)
|
|
||||||
.join('\n');
|
|
||||||
return {
|
|
||||||
cmmnXml:
|
|
||||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
||||||
`<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">\n` +
|
|
||||||
` <case id="slot">\n` +
|
|
||||||
` <caseFileModel>\n` +
|
|
||||||
` <caseFileItem id="input" name="调用入参"/>\n` +
|
|
||||||
` <caseFileItem id="draft" name="草稿台账"/>\n` +
|
|
||||||
` </caseFileModel>\n` +
|
|
||||||
` <casePlanModel id="plan" name="${name}" autoComplete="true">\n` +
|
|
||||||
` <sentry id="s_in">\n` +
|
|
||||||
` <onPart id="op_in" caseFileItemRef="input"><standardEvent>create</standardEvent></onPart>\n` +
|
|
||||||
` </sentry>\n` +
|
|
||||||
` <stage id="S1" name="办理" autoComplete="true">\n` +
|
|
||||||
` <entryCriterion id="ec_s1" sentryRef="s_in"/>\n` +
|
|
||||||
items.replace(/^ {6}/gm, ' ') +
|
|
||||||
`\n </stage>\n` +
|
|
||||||
` <milestone id="M_done" name="事已办完"/>\n` +
|
|
||||||
` <humanTask id="T_release" name="收口验收">\n` +
|
|
||||||
` <itemControl><manualActivationRule id="m_rel"/></itemControl>\n` +
|
|
||||||
` </humanTask>\n` +
|
|
||||||
` <timerEventListener id="TL_half" name="时限过半">\n` +
|
|
||||||
` <timerExpression>PT12H</timerExpression>\n` +
|
|
||||||
` </timerEventListener>\n` +
|
|
||||||
` </casePlanModel>\n` +
|
|
||||||
` </case>\n` +
|
|
||||||
`</definitions>`,
|
|
||||||
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 },
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function installSlots(container: Container): Promise<void> {
|
export async function installSlots(container: Container): Promise<void> {
|
||||||
for (const manifest of MVP_SLOTS) {
|
|
||||||
const { activityId, packageVersion, name } = manifest.identity;
|
|
||||||
|
|
||||||
/*
|
for (const activityId of RETIRED_SLOTS) {
|
||||||
* 先问库里有没有这一版 —— 因为「安装恒为新增、版本并存」:同版再装是 no-op,
|
const installed = await container.registry
|
||||||
* manifest 一个字不动。不问就报「已装入」等于**撒谎**:改了槽位内容重播种,
|
.get(activityId)
|
||||||
* 屏幕说装好了,库里还是旧的,人拿着旧行为查新代码(实测踩到,那次是卡面没换)。
|
.then((card) => card !== undefined)
|
||||||
*/
|
|
||||||
const already = await container.registry
|
|
||||||
.manifestOf(activityId, packageVersion)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
if (!installed) continue;
|
||||||
const verdict = await container.registry.install(manifest, SLOT_ARTIFACT);
|
try {
|
||||||
const errors = verdict.staticFindings.filter((finding) => finding.severity === 'error');
|
await container.registry.uninstall(activityId);
|
||||||
process.stdout.write(
|
process.stdout.write(` 槽位 ${activityId} 已退役卸载(真活动包顶替)\n`);
|
||||||
` 槽位 ${name}${already ? ` v${packageVersion} 已在库,未改动 —— 要换内容请升包版` : ` v${packageVersion} 已装`}` +
|
} catch (error) {
|
||||||
` · 静态审 ${verdict.staticFindings.length} 条` +
|
process.stdout.write(
|
||||||
(errors.length > 0 ? ` —— ERROR: ${errors.map((e) => e.message).join('; ')}` : '') +
|
` 槽位 ${activityId} 卸不掉:${error instanceof Error ? error.message : String(error)}\n`,
|
||||||
'\n',
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* 活动与工具的解耦就在②③之间:槽位只声明工具**名**,注册、连接值、白名单全在这里。
|
* 活动与工具的解耦就在②③之间:槽位只声明工具**名**,注册、连接值、白名单全在这里。
|
||||||
*/
|
*/
|
||||||
import type { Container } from './container.ts';
|
import type { Container } from './container.ts';
|
||||||
|
import { installRealActivities } from './seed-activities.ts';
|
||||||
import { installSlots } from './seed-mvp-slots.ts';
|
import { installSlots } from './seed-mvp-slots.ts';
|
||||||
import { seedMvpFlow } from './seed-mvp-flow.ts';
|
import { seedMvpFlow } from './seed-mvp-flow.ts';
|
||||||
|
|
||||||
@@ -16,16 +17,27 @@ import { seedMvpFlow } from './seed-mvp-flow.ts';
|
|||||||
const HISTORY_TOOL = {
|
const HISTORY_TOOL = {
|
||||||
name: 'crm.history-price-read',
|
name: 'crm.history-price-read',
|
||||||
displayName: '历史价查询',
|
displayName: '历史价查询',
|
||||||
version: '1.0.0',
|
version: '1.1.0',
|
||||||
description: '读取历史价格情报:某 PN 最近一次报价距今天数、行情是否平稳、生命周期状态',
|
description:
|
||||||
input: { type: 'object', required: ['pn'], properties: { pn: { type: 'string' } } },
|
'读取历史价格情报:某 PN 最近一次报价距今天数、行情是否平稳、生命周期状态、上次成交单价。' +
|
||||||
|
'一次可查一批 —— 传 pns 数组,出参走 rows(一单几十条料不必一条一调)。',
|
||||||
|
input: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
pn: { type: 'string', description: '单条查询' },
|
||||||
|
pns: { type: 'array', items: { type: 'string' }, description: '批量查询(优先于 pn)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
output: {
|
output: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
pn: { type: 'string' },
|
||||||
historyDays: { type: 'integer' },
|
historyDays: { type: 'integer' },
|
||||||
marketStable: { type: 'boolean' },
|
marketStable: { type: 'boolean' },
|
||||||
lifecycle: { type: 'string' },
|
lifecycle: { type: 'string' },
|
||||||
hit: { type: 'boolean' },
|
hit: { type: 'boolean' },
|
||||||
|
lastPrice: { type: 'number' },
|
||||||
|
rows: { type: 'array', description: '批量查询的逐条结果' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
effect: 'read' as const,
|
effect: 'read' as const,
|
||||||
@@ -53,6 +65,57 @@ const MAIL_TOOL = {
|
|||||||
connectionKeys: ['endpoint'],
|
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<Record<string, readonly string[]>> = {
|
const ALLOWLIST: Readonly<Record<string, readonly string[]>> = {
|
||||||
'rfq-intake': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name],
|
// A1 已是真活动:白名单里摘掉占位工具 —— 它一在,模型就会拿它糊弄过去
|
||||||
|
'quote-intake': [HISTORY_TOOL.name],
|
||||||
'alt-selection': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name],
|
'alt-selection': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name],
|
||||||
'sourcing-inquiry': [HISTORY_TOOL.name, MAIL_TOOL.name, PLACEHOLDER_TOOL.name],
|
'sourcing-inquiry': [HISTORY_TOOL.name, MAIL_TOOL.name, PLACEHOLDER_TOOL.name],
|
||||||
'cost-pricing': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name],
|
'cost-pricing': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name],
|
||||||
@@ -147,13 +211,20 @@ export async function seedMvp(container: Container): Promise<void> {
|
|||||||
log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`);
|
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({
|
const verdict = await container.toolbox.register({
|
||||||
contract,
|
contract,
|
||||||
samples: [
|
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' },
|
expectShape: { type: 'object' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -167,11 +238,17 @@ export async function seedMvp(container: Container): Promise<void> {
|
|||||||
for (const [activityId, tools] of Object.entries(ALLOWLIST)) {
|
for (const [activityId, tools] of Object.entries(ALLOWLIST)) {
|
||||||
await container.toolbox.setAllowlist(activityId, tools);
|
await container.toolbox.setAllowlist(activityId, tools);
|
||||||
}
|
}
|
||||||
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 占位纯工具 1,逐槽位授权)');
|
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 2,逐活动授权)');
|
||||||
|
|
||||||
/* ③ 六个活动槽位经安装通道装入 —— 声明物化把四域十八对象建出来 */
|
/*
|
||||||
|
* ③ 活动装入 —— 两批:
|
||||||
|
* · 真活动包经**安装通道读文件**装(系统外开发、安装即用);
|
||||||
|
* · 尚未开发的槽位照旧从常量表装(契约就绪、实现留白),让编排层能整条走通。
|
||||||
|
* 两批都走同一道安装两审,声明物化顺带把台账表建出来。
|
||||||
|
*/
|
||||||
|
await installRealActivities(container);
|
||||||
await installSlots(container);
|
await installSlots(container);
|
||||||
log('③ 六个活动槽位已装入(契约就绪,实现留白)');
|
log('③ 活动已装入(真活动包 1 · 槽位 6:契约就绪、实现留白)');
|
||||||
|
|
||||||
/* ④ 流程包 1:1 + 触发器 + 通道 */
|
/* ④ 流程包 1:1 + 触发器 + 通道 */
|
||||||
await seedMvpFlow(container);
|
await seedMvpFlow(container);
|
||||||
|
|||||||
@@ -56,6 +56,28 @@ export function createServer(container: Container): Hono<AppEnv> {
|
|||||||
return undefined;
|
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) => {
|
app.onError((error, c) => {
|
||||||
const body: ApiError = {
|
const body: ApiError = {
|
||||||
code: 'internal',
|
code: 'internal',
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ export type ToolBusPorts = {
|
|||||||
keyFilter: Record<string, string>;
|
keyFilter: Record<string, string>;
|
||||||
deadline: string;
|
deadline: string;
|
||||||
}) => Promise<{ id: string }>;
|
}) => Promise<{ id: string }>;
|
||||||
|
/** 台账工具:写草稿(基座六件之 ledger)—— 过程写的唯一形态 */
|
||||||
|
readonly draftWrite: (input: {
|
||||||
|
taskId: TaskId;
|
||||||
|
/** 台账地址「域.对象」—— 与单拍路径的 `write` 行动同一种写法 */
|
||||||
|
address: string;
|
||||||
|
rows: ReadonlyArray<{ key: Record<string, string | number>; patch: Record<string, unknown> }>;
|
||||||
|
}) => Promise<{ written: number }>;
|
||||||
/** LLM 工具:经模型网关 */
|
/** LLM 工具:经模型网关 */
|
||||||
readonly generate: (input: {
|
readonly generate: (input: {
|
||||||
model: string;
|
model: string;
|
||||||
@@ -110,6 +117,18 @@ function toZod(schema: Record<string, unknown> | undefined): z.ZodTypeAny {
|
|||||||
return describe(z.unknown());
|
return describe(z.unknown());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 只收 `{key, patch}` 都是对象的行 —— 形状不对的丢掉,不猜(猜错就往台账落一行来路不明的数) */
|
||||||
|
function isDraftRow(row: unknown): row is { key: Record<string, string | number>; patch: Record<string, unknown> } {
|
||||||
|
if (typeof row !== 'object' || row === null) return false;
|
||||||
|
const candidate = row as Record<string, unknown>;
|
||||||
|
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 五件的提示词:系统内化的能力,提示词也归系统 —— 活动不写、也改不了 */
|
/** LLM 五件的提示词:系统内化的能力,提示词也归系统 —— 活动不写、也改不了 */
|
||||||
function llmPrompt(id: string, args: Record<string, unknown>): { system: string; prompt: string } {
|
function llmPrompt(id: string, args: Record<string, unknown>): { system: string; prompt: string } {
|
||||||
const j = (v: unknown) => JSON.stringify(v ?? null);
|
const j = (v: unknown) => JSON.stringify(v ?? null);
|
||||||
@@ -185,6 +204,24 @@ export function buildMastraTools(
|
|||||||
return { asked: true, cardId: card.id };
|
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') {
|
if (tool.kind === 'llm') {
|
||||||
const { system, prompt } = llmPrompt(tool.id, args);
|
const { system, prompt } = llmPrompt(tool.id, args);
|
||||||
const result = await ports.generate({
|
const result = await ports.generate({
|
||||||
|
|||||||
@@ -9,23 +9,60 @@ import type { ToolHandler } from '@modules/toolbox';
|
|||||||
/**
|
/**
|
||||||
* 查历史价 —— 外部系统(CRM)的**录制回放替身**。
|
* 查历史价 —— 外部系统(CRM)的**录制回放替身**。
|
||||||
* README:mock 只允许出现在外界;系统内部机制一律真跑。
|
* README:mock 只允许出现在外界;系统内部机制一律真跑。
|
||||||
|
*
|
||||||
|
* **一次可查一批**(2026-07-30 加 `pns`):一单几十条料,一条一调就是几十拍模型 ——
|
||||||
|
* 每拍都要重读一遍上下文,而拿回来的东西一模一样。契约演进只增不破坏:
|
||||||
|
* `pn` 仍收单条(A4 定价那侧照旧),新增的 `pns` 是可选入参,出参多一个 `rows`。
|
||||||
*/
|
*/
|
||||||
const historyPriceRead: ToolHandler = async ({ input }) => {
|
const HISTORY_SAMPLES: Record<string, { historyDays: number; marketStable: boolean; lifecycle: string; lastPrice: number }> = {
|
||||||
const pn = String(input['pn'] ?? '');
|
|
||||||
// 录制样本:演示用的历史价情报(真 CRM 接入时换 endpoint,契约不变)
|
// 录制样本:演示用的历史价情报(真 CRM 接入时换 endpoint,契约不变)
|
||||||
// 历史价情报含**上次成交单价** —— 没有价的「历史价工具」是取不出价格基准的
|
// 历史价情报含**上次成交单价** —— 没有价的「历史价工具」是取不出价格基准的
|
||||||
const table: Record<string, { historyDays: number; marketStable: boolean; lifecycle: string; lastPrice: number }> = {
|
LM358DR: { historyDays: 30, marketStable: true, lifecycle: '在产', lastPrice: 8.4 },
|
||||||
LM358DR: { historyDays: 30, marketStable: true, lifecycle: '在产', lastPrice: 8.4 },
|
NE555P: { historyDays: 240, marketStable: false, lifecycle: '在产', lastPrice: 3.25 },
|
||||||
NE555P: { historyDays: 240, marketStable: false, lifecycle: '在产', lastPrice: 3.25 },
|
TL072CDR: { historyDays: 15, marketStable: true, lifecycle: 'EOL', lastPrice: 21.6 },
|
||||||
TL072CDR: { historyDays: 15, marketStable: true, lifecycle: 'EOL', lastPrice: 21.6 },
|
|
||||||
};
|
/*
|
||||||
const hit = table[pn];
|
* ── Itelma MD14.1 的九条 —— **老客户测试案例的情报底账** ────────────────
|
||||||
if (hit === undefined) {
|
*
|
||||||
// 无历史 → 走 B(重估后报);这不是错误
|
* 数字不是编的:逐条照 `JCAI_PRD/docs/活动执行页/A1-询价受理记录.html` 那份
|
||||||
// 无历史 → 走 B(重估后报);没有价格基准,基准价回 0 由下游按「无基准」处置
|
* 验收目标里写明的台账价与判据抄下来,天数按来件日 07-10 折算。
|
||||||
return { ok: true, output: { historyDays: 9999, marketStable: false, lifecycle: '未知', hit: false, lastPrice: 0 } };
|
* 抄下来是为了让**三分类真判得出** 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 的决策表 —— 与活动包里的资产同一份规则 */
|
||||||
|
/*
|
||||||
|
* 三分类规则 P-03 的决策表 —— 与活动包里的资产同一份规则。
|
||||||
|
* 出参键名用 `type_tag` 与台账列同名:模型拿回结果直接 write,不必自己改名
|
||||||
|
* (改名这一步交给模型做,它会漏)。`basis` 一并给出,分级依据不必再编。
|
||||||
|
*/
|
||||||
const P03_RULES = [
|
const P03_RULES = [
|
||||||
{ id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: { type: 'C', play: '转案处理' } },
|
{ id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: { type_tag: 'C', play: '转案处理', basis: 'EOL/缺货/要求替代 → C' } },
|
||||||
{ id: 'p03-a', when: ['<= 90', 'true', '-'], then: { type: 'A', play: '直接快答' } },
|
{ id: 'p03-a', when: ['<= 90', 'true', '-'], then: { type_tag: 'A', play: '直接快答', basis: '历史价≤90天且行情稳 → A' } },
|
||||||
{ id: 'p03-b', when: ['-', '-', '-'], then: { type: 'B', play: '重估后报' } },
|
{ id: 'p03-b', when: ['-', '-', '-'], then: { type_tag: 'B', play: '重估后报', basis: '无历史/过期/波动 → B' } },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 逐条打标 —— **纯工具**:无外效应、无连接配置、活动私有作用域。
|
* 逐条打标 —— **纯工具**:无外效应、无连接配置、活动私有作用域。
|
||||||
* 「能算的不问 LLM」:同输入必同结论,零方差零 token;命中规则行随出参留痕。
|
* 「能算的不问 LLM」:同输入必同结论,零方差零 token;命中规则行随出参留痕。
|
||||||
|
*
|
||||||
|
* **一次可打一批**(`rows`):判据是逐条的,结论也是逐条的,但求值不必逐拍 ——
|
||||||
|
* 一单几十条料分几十拍打标,烧掉的全是重复上下文。
|
||||||
*/
|
*/
|
||||||
const classify: ToolHandler = async ({ input }) => {
|
const classifyOne = (values: readonly unknown[]) => {
|
||||||
const values = [input['historyDays'], input['marketStable'], input['lifecycle']];
|
|
||||||
for (const rule of P03_RULES) {
|
for (const rule of P03_RULES) {
|
||||||
const hit = rule.when.every((cell, i) => evaluateUnaryTest(cell, values[i], {}));
|
if (rule.when.every((cell, i) => evaluateUnaryTest(cell, values[i], {}))) {
|
||||||
if (hit) return { ok: true, output: { ...rule.then, rule: rule.id } };
|
return { ...rule.then, rule: rule.id };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { ok: false, error: { code: 'no-match', retryable: false, message: '三分类规则无命中行' } };
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 判据三件的取值:下划线与驼峰两认 —— 台账列是下划线,模型常写驼峰 */
|
||||||
|
const basisOf = (row: Record<string, unknown>) => [
|
||||||
|
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<Record<string, unknown>> = [];
|
||||||
|
for (const raw of batch) {
|
||||||
|
const row = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||||
|
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<string, number>;
|
||||||
|
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:品牌 × 类型 × 客户等级 × 数量档 → 目标毛利区间 */
|
/** 利润模型 P-01:品牌 × 类型 × 客户等级 × 数量档 → 目标毛利区间 */
|
||||||
|
|||||||
@@ -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<string, Uint8Array> {
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
const files = new Map<string, Uint8Array>();
|
||||||
|
|
||||||
|
// 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, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 共享字符串表:`<si>` 一条一个字符串,里面可能被拆成多个 `<t>`(富文本) */
|
||||||
|
function sharedStrings(xml: string): string[] {
|
||||||
|
return [...xml.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) =>
|
||||||
|
[...(m[1] ?? '').matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A1 → 列号 0;AB7 → 列号 27。行号不用,行由 `<row>` 自己划 */
|
||||||
|
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"` 取内联 `<t>`、其余取 `<v>` 原样
|
||||||
|
* (数字、日期序列号都在这一类)。日期不转换:转错了比不转更糟,原值至少可以追。
|
||||||
|
*/
|
||||||
|
function sheetToRows(xml: string, shared: readonly string[]): string[][] {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
for (const rowMatch of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
|
||||||
|
const cells: string[] = [];
|
||||||
|
for (const cell of (rowMatch[1] ?? '').matchAll(/<c([^>]*)>([\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 = /<v>([\s\S]*?)<\/v>/.exec(inner)?.[1] ?? '';
|
||||||
|
let text: string;
|
||||||
|
if (type === 's') text = shared[Number(v)] ?? '';
|
||||||
|
else if (type === 'inlineStr') {
|
||||||
|
text = [...inner.matchAll(/<t[^>]*>([\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} 行(制表符分列)`);
|
||||||
|
}
|
||||||
+1456
-40
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 唤醒脉冲 —— **人答完了就立刻接着跑**,不必等下一轮轮询。
|
||||||
|
*
|
||||||
|
* 两个 worker 都是 2 秒一轮(派发器投递事件、推进器推 running 的单)。
|
||||||
|
* 平时够用:后台自己转,快两秒慢两秒没人看着。可**人刚点完一张卡**那一下不同 ——
|
||||||
|
* 他正盯着屏幕等反应。实测点「确认写入」到引擎真转起来要 **6.8 秒**
|
||||||
|
* (投递等一轮 + 推进等一轮),这 6.8 秒里界面上一动不动:
|
||||||
|
* 办事条只认 `runtime === 'running'`,而那时它还是 `awaiting-event`。
|
||||||
|
* 人看到的是「我点了,没反应」——于是再点一次。
|
||||||
|
*
|
||||||
|
* 所以给路由一个踢一脚的口子:回传落库后 `void pulse()`,两个 worker 就地各跑一轮。
|
||||||
|
* **不 await**:推进一拍要调模型,秒级;回传该立刻返回,推进在后台接着办。
|
||||||
|
*
|
||||||
|
* 合并而不排队:正在跑就记一个「还要再来一轮」,跑完补一轮就够 ——
|
||||||
|
* 连点五下不该叠出五轮推进。
|
||||||
|
*/
|
||||||
|
type Tick = () => Promise<unknown>;
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,24 @@ export type AdvancerOptions = {
|
|||||||
readonly maxTicks?: number;
|
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<number> {
|
export async function advanceOnce(options: AdvancerOptions): Promise<number> {
|
||||||
const { container } = options;
|
const { container } = options;
|
||||||
const rows = await container.db
|
const rows = await container.db
|
||||||
@@ -41,16 +59,30 @@ export async function advanceOnce(options: AdvancerOptions): Promise<number> {
|
|||||||
advanced += 1;
|
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' });
|
container.broadcaster.publish({ kind: 'task', id: row.id, reason: 'advanced' });
|
||||||
if (outcome.task.workflowId !== undefined) {
|
if (outcome.task.workflowId !== undefined) {
|
||||||
container.broadcaster.publish({ kind: 'workflow', id: outcome.task.workflowId, reason: 'task-advanced' });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 进入等待或终结就停手 —— 挂起在等的任务由事件唤醒,不空转
|
// 进入等待或终结就停手 —— 挂起在等的任务由事件唤醒,不空转
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
依赖: @contracts;@shared/db;port 注入:toolbox(L2)、llm(L1)、ledger(L1)、interaction(L3)
|
依赖: @contracts;@shared/db;port 注入:toolbox(L2)、llm(L1)、ledger(L1)、interaction(L3)
|
||||||
被依赖: loop-engine, flow-engine, skill-engine, task, process, app(治理台 · 活动库管理)
|
被依赖: 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):
|
不变量(OS 11.2 / 11.3,AM 11.3):
|
||||||
- **安装两审**:静态审有 error 即不上架;「旧成不坏」是**生效硬门**,
|
- **安装两审**:静态审有 error 即不上架;「旧成不坏」是**生效硬门**,
|
||||||
「旧败能成」只入重演报告、不作门槛;首装无历史即免回放审
|
「旧败能成」只入重演报告、不作门槛;首装无历史即免回放审
|
||||||
|
|||||||
@@ -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<typeof acceptanceLevelFindings>[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) => `<caseFileItem id="${id}" name="x"/>`).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<typeof deliverableAddressFindings>[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<typeof ledgerKindFindings>[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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,8 +32,11 @@ export type {
|
|||||||
export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts';
|
export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts';
|
||||||
export { createRegistryService } 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 {
|
export {
|
||||||
acceptanceLevelFindings,
|
acceptanceLevelFindings,
|
||||||
|
deliverableAddressFindings,
|
||||||
|
ledgerKindFindings,
|
||||||
auditPassed,
|
auditPassed,
|
||||||
danglingListeners,
|
danglingListeners,
|
||||||
loopFindings,
|
loopFindings,
|
||||||
|
|||||||
@@ -28,6 +28,26 @@ export type ExternalChecks = {
|
|||||||
readonly sdkCompatible: boolean;
|
readonly sdkCompatible: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两条清单审读**只读清单的一小块** —— 参数就照那一小块声明。
|
||||||
|
* 收窄不是为了测试好写:它把「这条审读看的是什么」写进类型里,
|
||||||
|
* 传全份清单照旧过(结构子类型),而读了什么一目了然。
|
||||||
|
*/
|
||||||
|
export type AcceptanceAuditInput = {
|
||||||
|
readonly contract: Pick<ActivityManifest['contract'], 'acceptanceCriteria'> & {
|
||||||
|
readonly input: Pick<ActivityManifest['contract']['input'], 'data'>;
|
||||||
|
};
|
||||||
|
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
|
||||||
|
readonly assets: {
|
||||||
|
readonly businessModel: Pick<ActivityManifest['assets']['businessModel'], 'cmmnXml'>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type LedgerAuditInput = { readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'> };
|
||||||
|
export type DeliverableAuditInput = {
|
||||||
|
readonly contract: Pick<ActivityManifest['contract'], 'requiredDeliverables'>;
|
||||||
|
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
|
||||||
|
};
|
||||||
|
|
||||||
/** ISO-8601 duration,与 event 的 deadlineMillis 同一子集 */
|
/** ISO-8601 duration,与 event 的 deadlineMillis 同一子集 */
|
||||||
const DURATION = /^P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)$|^P\d+D$/;
|
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 必须给出可机检的表达式 */
|
/** 验收等级不得谎报:L4 不得标注为 L1–L3;L1/L2 必须给出可机检的表达式 */
|
||||||
export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAuditFinding[] {
|
export function acceptanceLevelFindings(manifest: AcceptanceAuditInput): StaticAuditFinding[] {
|
||||||
const out: StaticAuditFinding[] = [];
|
const out: StaticAuditFinding[] = [];
|
||||||
|
const declared = boundNames(manifest);
|
||||||
for (const [index, criterion] of manifest.contract.acceptanceCriteria.entries()) {
|
for (const [index, criterion] of manifest.contract.acceptanceCriteria.entries()) {
|
||||||
const at = `contract.acceptanceCriteria[${index}]`;
|
const at = `contract.acceptanceCriteria[${index}]`;
|
||||||
if ((criterion.level === 'L1' || criterion.level === 'L2') && criterion.predicate === undefined && criterion.modelRef === undefined) {
|
if ((criterion.level === 'L1' || criterion.level === 'L2') && criterion.predicate === undefined && criterion.modelRef === undefined) {
|
||||||
@@ -74,6 +95,209 @@ export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAudit
|
|||||||
at,
|
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<string>();
|
||||||
|
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<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
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<string, unknown> }).properties;
|
||||||
|
if (props !== undefined) for (const key of Object.keys(props)) out.add(key);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -93,6 +317,39 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] {
|
|||||||
at: 'assets.businessModel.narrative',
|
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;
|
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}` });
|
out.push({ code: 'sdk-incompatible', severity: 'error', message: `SDK 版本不兼容:${manifest.identity.sdkVersion}` });
|
||||||
}
|
}
|
||||||
for (const tool of external.missingTools) {
|
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) {
|
for (const model of external.unboundModels) {
|
||||||
out.push({ code: 'model-name-unbound', severity: 'error', message: `模型名未绑定四元:${model}` });
|
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(...danglingListeners(manifest));
|
||||||
|
out.push(...deliverableAddressFindings(manifest));
|
||||||
|
out.push(...ledgerKindFindings(manifest));
|
||||||
out.push(...acceptanceLevelFindings(manifest));
|
out.push(...acceptanceLevelFindings(manifest));
|
||||||
out.push(...loopFindings(manifest));
|
out.push(...loopFindings(manifest));
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
|
|||||||
}
|
}
|
||||||
const cards: ActivityCard[] = [];
|
const cards: ActivityCard[] = [];
|
||||||
for (const [activityId, versions] of byActivity) {
|
for (const [activityId, versions] of byActivity) {
|
||||||
|
/*
|
||||||
|
* 全部版本都已卸载的活动**不在架上**。
|
||||||
|
*
|
||||||
|
* 卸载只把版本状态置为 `uninstalled`(版本行留着,在途任务按启动时版本跑完)——
|
||||||
|
* 但活动库不该照旧摆着它:实测踩过,一个被真活动包顶替的槽位卸载后仍在库里,
|
||||||
|
* 与顶替它的那个**同名并排**,试跑台上选错一个就试了个空壳。
|
||||||
|
* 「卸了还摆着」就是界面撒谎。
|
||||||
|
*/
|
||||||
|
if (versions.every((v) => v.state === 'uninstalled')) continue;
|
||||||
const manifest = await deps.store.manifest(activityId);
|
const manifest = await deps.store.manifest(activityId);
|
||||||
if (manifest === undefined) continue;
|
if (manifest === undefined) continue;
|
||||||
cards.push({
|
cards.push({
|
||||||
@@ -158,6 +167,8 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
|
|||||||
for (const pkg of await deps.store.listPackages(activityId)) {
|
for (const pkg of await deps.store.listPackages(activityId)) {
|
||||||
await deps.store.setState(activityId, pkg.version, 'uninstalled');
|
await deps.store.setState(activityId, pkg.version, 'uninstalled');
|
||||||
}
|
}
|
||||||
|
// 一个版本都没装了,就谈不上「可用」—— 两处状态得对得上,不然治理台会摆一个假的绿灯
|
||||||
|
await deps.store.setEnabled(activityId, false);
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadEnvelope(activityId) {
|
async loadEnvelope(activityId) {
|
||||||
|
|||||||
@@ -28,19 +28,20 @@ describe('planConsumption —— 三级消费', () => {
|
|||||||
triggerHits: ['tr1'],
|
triggerHits: ['tr1'],
|
||||||
ownedTaskId: 't1',
|
ownedTaskId: 't1',
|
||||||
hasOwner: true,
|
hasOwner: true,
|
||||||
|
fromChannel: true,
|
||||||
});
|
});
|
||||||
expect(plan.taskTargets).toEqual(['t1']);
|
expect(plan.taskTargets).toEqual(['t1']);
|
||||||
expect(plan.triggerTargets).toEqual(['tr1']);
|
expect(plan.triggerTargets).toEqual(['tr1']);
|
||||||
expect(plan.messageFallback).toBe(false);
|
expect(plan.messageFallback).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('皆未命中且无主 → 消息兜底', () => {
|
it('**入站件**皆未命中且无主 → 消息兜底(系统自己发的不算,见下面那组)', () => {
|
||||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false });
|
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false, fromChannel: true });
|
||||||
expect(plan.messageFallback).toBe(true);
|
expect(plan.messageFallback).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('有主但零监听命中 → 仅记录,不兜底、不降级为无主', () => {
|
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.messageFallback).toBe(false);
|
||||||
expect(plan.recordOnlyTask).toBe('t7');
|
expect(plan.recordOnlyTask).toBe('t7');
|
||||||
expect(plan.taskTargets).toEqual([]);
|
expect(plan.taskTargets).toEqual([]);
|
||||||
@@ -54,6 +55,7 @@ describe('planConsumption —— 三级消费', () => {
|
|||||||
triggerHits: [],
|
triggerHits: [],
|
||||||
ownedTaskId: undefined,
|
ownedTaskId: undefined,
|
||||||
hasOwner: true,
|
hasOwner: true,
|
||||||
|
fromChannel: true,
|
||||||
});
|
});
|
||||||
expect(plan.messageFallback).toBe(false);
|
expect(plan.messageFallback).toBe(false);
|
||||||
expect(plan.recordOnlyTask).toBeUndefined();
|
expect(plan.recordOnlyTask).toBeUndefined();
|
||||||
@@ -65,12 +67,19 @@ describe('planConsumption —— 三级消费', () => {
|
|||||||
triggerHits: [],
|
triggerHits: [],
|
||||||
ownedTaskId: 't7',
|
ownedTaskId: 't7',
|
||||||
hasOwner: true,
|
hasOwner: true,
|
||||||
|
fromChannel: true,
|
||||||
});
|
});
|
||||||
expect(plan.recordOnlyTask).toBeUndefined();
|
expect(plan.recordOnlyTask).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('无主但触发命中 → 不兜底', () => {
|
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);
|
expect(plan.messageFallback).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -96,3 +105,36 @@ describe('deadlineMillis —— 监听时限必配', () => {
|
|||||||
expect(() => deadlineMillis('PT0S')).toThrow(/大于零/);
|
expect(() => deadlineMillis('PT0S')).toThrow(/大于零/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **消息兜底只兜从外面进来的件**(2026-08-02 用户:「消息怎么总莫名其妙的出内容」)。
|
||||||
|
*
|
||||||
|
* 「无主入站待认领」是件具体的事:一封信进来了,没匹配上任何单也没触发任何流,
|
||||||
|
* 得有个人去认领它。而系统自己发的事件不是这种东西 —— 台账写一行会发 `data` 事件,
|
||||||
|
* 没人监听它是**常态**,不是待办。
|
||||||
|
*
|
||||||
|
* 不分这一刀的实测下场:装一次活动包(主数据 systemWrite)就往三个人的收件箱里
|
||||||
|
* 塞了十几条「无主入站待认领:(无主题) … 原件:无」—— 没有主题、没有原件、
|
||||||
|
* 没人能对它做任何事。那不是待认领的件,是一条内部广播。
|
||||||
|
*/
|
||||||
|
describe('消息兜底:只兜入站件', () => {
|
||||||
|
const plan = (over: Partial<Parameters<typeof planConsumption>[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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -66,6 +66,22 @@ export function planConsumption(input: {
|
|||||||
* 把它们判成无主,等于把每次起单都当成「没人认领的入站件」塞进别人收件箱。
|
* 把它们判成无主,等于把每次起单都当成「没人认领的入站件」塞进别人收件箱。
|
||||||
*/
|
*/
|
||||||
readonly hasOwner: boolean;
|
readonly hasOwner: boolean;
|
||||||
|
/**
|
||||||
|
* 这条事件**是从外面进来的件**吗 —— 只有它才谈得上「无人认领」。
|
||||||
|
*
|
||||||
|
* 「无主入站待认领」是一件具体的事:一封信进来了、没匹配上任何单也没触发任何流,
|
||||||
|
* **得有个人去认领它**。而系统自己发的事件不是这种东西:
|
||||||
|
* 台账写一行会发 `data` 事件、引擎起一单会发工作流事件 ——
|
||||||
|
* 没人监听它们是**常态**,不是待办。
|
||||||
|
*
|
||||||
|
* 不分这一刀的下场实测过(用户 2026-08-02:「消息怎么总莫名其妙的出内容」):
|
||||||
|
* 装一次活动包(主数据 `systemWrite`)就往三个人的收件箱里塞了十几条
|
||||||
|
* 「无主入站待认领:(无主题) —— 事件 6d17debb… 未命中任何任务监听与触发器。原件:无」。
|
||||||
|
* 没有主题、没有原件、没人能对它做任何事 —— 那不是待认领的件,是一条内部广播。
|
||||||
|
*
|
||||||
|
* 判据取**来源**不取类型:同样是 `data`,从通道进来的是件,台账写出来的是事实。
|
||||||
|
*/
|
||||||
|
readonly fromChannel: boolean;
|
||||||
}): ConsumptionPlan {
|
}): ConsumptionPlan {
|
||||||
const taskTargets = fanOutTargets(input.listenerHits);
|
const taskTargets = fanOutTargets(input.listenerHits);
|
||||||
const triggerTargets = [...new Set(input.triggerHits)];
|
const triggerTargets = [...new Set(input.triggerHits)];
|
||||||
@@ -73,8 +89,8 @@ export function planConsumption(input: {
|
|||||||
return {
|
return {
|
||||||
taskTargets,
|
taskTargets,
|
||||||
triggerTargets,
|
triggerTargets,
|
||||||
// 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主**的件(OS 6.3)
|
// 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主的入站件**(OS 6.3)
|
||||||
messageFallback: nothingHit && !input.hasOwner,
|
messageFallback: nothingHit && !input.hasOwner && input.fromChannel,
|
||||||
recordOnlyTask:
|
recordOnlyTask:
|
||||||
input.ownedTaskId !== undefined && !taskTargets.includes(input.ownedTaskId)
|
input.ownedTaskId !== undefined && !taskTargets.includes(input.ownedTaskId)
|
||||||
? input.ownedTaskId
|
? input.ownedTaskId
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export function createEventService(deps: EventServiceDeps): EventService {
|
|||||||
triggerHits,
|
triggerHits,
|
||||||
ownedTaskId,
|
ownedTaskId,
|
||||||
hasOwner: envelope.correlation.target !== undefined,
|
hasOwner: envelope.correlation.target !== undefined,
|
||||||
|
// 只有**从通道进来的件**谈得上「无人认领」—— 台账写一行发的 data 事件不是件
|
||||||
|
fromChannel: envelope.source.kind === 'channel',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 扇出:每个命中目标一份独立投递,信封「关联 = 单目标」保持单目标
|
// 扇出:每个命中目标一份独立投递,信封「关联 = 单目标」保持单目标
|
||||||
|
|||||||
@@ -228,3 +228,50 @@ describe('完成只出自放行通过', () => {
|
|||||||
expect(allCriteriaGreen(acceptance({ criteria: [{ label: 'x', level: 'L2', pass: false }] }))).toBe(false);
|
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<string, string>, at: number): Record<string, string> => {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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<ReturnType<typeof service>['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<ReturnType<typeof service>['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<WorkOrder, { mode: 'acceptance' }>,
|
||||||
|
'id' | 'at' | 'mode'
|
||||||
|
>;
|
||||||
|
await expect(svc.issueAcceptance(context, bare)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -89,6 +89,8 @@ export type IssuedChatCard = {
|
|||||||
|
|
||||||
export type ReplyOutcome = {
|
export type ReplyOutcome = {
|
||||||
readonly order: WorkOrder;
|
readonly order: WorkOrder;
|
||||||
|
/** 这张卡挂在哪一单 —— 装配层要拿它把「人就地改的那几行」写回草稿层 */
|
||||||
|
readonly taskId: TaskId;
|
||||||
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
|
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
|
||||||
readonly producesCompletion: boolean;
|
readonly producesCompletion: boolean;
|
||||||
/** 驳回:环内回转,任务继续 */
|
/** 驳回:环内回转,任务继续 */
|
||||||
@@ -110,10 +112,33 @@ export type HumanToolService = {
|
|||||||
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
|
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
|
||||||
|
|
||||||
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
|
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
|
||||||
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard>;
|
/**
|
||||||
|
* 回传带上 `taskId` —— **数据录入卡的确认要当场写草稿**,而写库要知道写给哪一单。
|
||||||
|
* 由本模块自己写会让 human-tool 依赖 ledger(越层),所以把事实交出去,由组装层落笔。
|
||||||
|
*/
|
||||||
|
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{ card: ChatCard; taskId: TaskId }>;
|
||||||
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
|
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
|
||||||
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
|
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给已发出的验收工单补上选择组(AM 1.2 恰选一口)。
|
||||||
|
*
|
||||||
|
* 为什么分两步:卡由 `release.submitForRelease` 发 —— 它守的是预检那道门,
|
||||||
|
* 不该也不必知道「这个活动有哪些出口」。出口是契约的事实,由 app 从 manifest 现读后贴上。
|
||||||
|
*/
|
||||||
|
attachPortChoice(
|
||||||
|
workOrderId: string,
|
||||||
|
choice: { group: string; ports: ReadonlyArray<{ name: string; label?: string | undefined }> },
|
||||||
|
): Promise<void>;
|
||||||
|
/**
|
||||||
|
* **算出来的去向**上卡 —— 不给人选,只告诉他走哪一口、凭哪一条。
|
||||||
|
* 去向是台账那几个数的函数;摆成选择题,等于把可回放的判定换成一次手感。
|
||||||
|
*/
|
||||||
|
attachRoutedPort(
|
||||||
|
workOrderId: string,
|
||||||
|
routed: { group: string; name: string; label?: string | undefined; because: string },
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||||
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
||||||
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user