This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/activities/quote-intake/src/manifest.ts
T
ChaiHaoandClaude Opus 5 259192f736 人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 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
2026-08-02 13:41:44 +08:00

848 lines
49 KiB
TypeScript

/**
* 活动包清单 —— 统一报价 A1 · 询价受理。
*
* 业务素材出处:`docs/MVP示例工作流.html` §1.1。归一化映射:
* SOP 节点 → **案例活动**(CMMN 子集 + 说明书 + DMN);
* 作业指导书 S1…S6 → 案例说明书的六段(见 `case-model.ts` 的建模口径注);
* D1 判完整度 → **DMN 判定表 F-01**(《询价受理规范》);
* 三分类规则 P-03 → **DMN 判定表**;
* SLA(受理<15min、判定<45min)→ Harness 时限与预算。
*
* 「清单机器可读,系统不执行代码即知包为何物」—— 这份清单就是这个活动的全部。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { activityIdSchema } from '@contracts';
import { INTAKE_SKILLS } from './skills.ts';
import { CMMN_XML, INTAKE_PHASES, INTAKE_STEPS, NARRATIVE } from './case-model.ts';
import { INTAKE_CLASSIFIED, INTAKE_OVERVIEW, INTAKE_RELEASE } from './views/report.ts';
const D = 'quote_intake';
const ACTIVITY_ID = activityIdSchema.parse('quote-intake');
/**
* 两张判定表 —— **能算的不问 LLM**:同输入必同结论、零 token、命中行留痕。
*
* F-01 受理字段完整度(《询价受理规范》):三要素齐才放行,缺关键字段退回补全。
* P-03 三分类:A 历史价≤90天且行情稳 / B 无历史·过期·波动 / C EOL·缺货·要替代。
*
* 两张都是 `FIRST`:规则从上往下,第一条命中即出结论 —— 顺序本身就是业务优先级,
* 所以 C 的那一行必须在最前面(EOL 的料哪怕历史价很新,也不该判成 A 直接快答)。
*/
export const F01_DMN: DmnModel['tables'][number] = {
id: 'f01-completeness',
label: '受理字段完整度 F-01',
hitPolicy: 'FIRST',
inputs: [
{ name: 'missing_key_total', expression: 'missing_key_total' },
{ name: 'missing_minor_total', expression: 'missing_minor_total' },
],
outputs: [{ name: 'completeness' }, { name: 'disposition' }],
rules: [
{ id: 'f01-2', when: ['> 0', '-'], then: ['缺PN/数量/交期', '退回补全'], annotation: '三要素缺任一 → 必须追问补全' },
{ id: 'f01-3', when: ['0', '> 0'], then: ['缺次要字段', '受理并追问'], annotation: '只缺厂牌/包装/备注 → 先受理' },
{ id: 'f01-1', when: ['0', '-'], then: ['八字段齐', '受理转寻源'], annotation: '齐 → 直接往下走' },
],
};
export const P03_DMN: DmnModel['tables'][number] = {
id: 'p03-type',
label: '三分类规则 P-03',
hitPolicy: 'FIRST',
inputs: [
{ name: 'history_days', expression: 'history_days' },
{ name: 'market_stable', expression: 'market_stable' },
{ name: 'lifecycle', expression: 'lifecycle' },
],
outputs: [{ name: 'type_tag' }, { name: 'play' }],
rules: [
{ id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: ['C', '转案处理'], annotation: 'EOL/缺货/要替代 → C' },
{ id: 'p03-a', when: ['<= 90', 'true', '-'], then: ['A', '直接快答'], annotation: '历史价≤90天且行情稳 → A' },
{ id: 'p03-b', when: ['-', '-', '-'], then: ['B', '重估后报'], annotation: '无历史/过期/波动 → B' },
],
};
export const INTAKE_DMN: DmnModel = {
drd: [
{ decision: 'f01-completeness', requires: [], inputs: ['missing_key_total', 'missing_minor_total'] },
{ decision: 'p03-type', requires: [], inputs: ['history_days', 'market_stable', 'lifecycle'] },
],
tables: [F01_DMN, P03_DMN],
};
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'A1 · 询价受理',
packageVersion: '40.0.0',
category: '统一报价',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-quote-intake',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '1.0.0',
input: {
/*
* 入参就是**那封信** —— 客户邮箱 / 主题 / 邮件内容 / 邮件附件,前三样必填。
*
* 从前这里收的是「客户编码 + 询价单号」,那是**受理之后**才有的东西:
* 一封真的询价来函不会带着我们内部的客户码,单号也常常压根没有。
* 把它们摆成必填的后果实测过:流程包起单时通道抽不出这两个键,基座当场发补全卡,
* 整单 0 拍停在那儿等人 —— 而信本身是完整的。认客户、认单号是 S1 的活,不是门口的闸。
*
* 起单仍是宽进:门口不拒缺项(缺 required 由基座发补全卡,不是活动的事)。
*/
data: {
type: 'object',
required: ['customerEmail', 'subject', 'body'],
properties: {
customerEmail: { type: 'string', title: '客户邮箱' },
subject: { type: 'string', title: '主题' },
/** 询价原文 + BOM 转述 —— 归一这一步拆解的对象 */
body: { type: 'string', title: '邮件内容' },
/*
* **真文件**(`format: 'file'`):界面给文件选择器,基座在入口把它读成文本。
* 每一件是 `{ name, contentType, size, ref, text?, note }` ——
* `text` 有值就是读得出内容(xlsx 读成制表符分列的行);
* 读不出的类型 `text` 缺席、`note` 说明为什么。**说明书里写死了怎么用**。
*/
attachments: {
type: 'array',
format: 'file',
title: '邮件附件',
items: {
type: 'object',
properties: {
name: { type: 'string', title: '文件名' },
contentType: { type: 'string', title: '类型' },
size: { type: 'integer', title: '字节数' },
ref: { type: 'string', title: '存档号' },
text: { type: 'string', title: '读出来的文本' },
note: { type: 'string', title: '读取说明' },
},
},
},
},
},
messageAllowed: true,
messageHint: '客户口头急单、老客户先受理后补单等特殊性写这里',
/*
* **这一单是被一封信叫起来的** —— 声明入参里哪几个字段合起来是那封来件,
* 基座在起单那一刻就把它归进任务的沟通记录(工作台那一屏),原件与附件一并存下。
*
* 为什么是声明而不是跑起来再认一遍:「入参的 subject 就是来件主题」是写清单时
* 就知道的事实,没有临场判断的成分 —— 交给模型每单认一遍,是拿一整拍的 token 换一个常量。
* 而且人一点开这单,沟通记录里就已经躺着那封信,不必等模型跑几拍。
*/
inboundComm: {
channel: 'mail',
partyField: 'customerEmail',
subjectField: 'subject',
bodyField: 'body',
attachmentsField: 'attachments',
},
},
output: {
/*
* 出口载荷**从落地事实推导**(§7.5):标量字段必须有台账列承载。
* 两枚去向旗就是下游两道网关唯一读的东西 —— 没有 `need_alternative` /
* `need_sourcing` 这两列,整条流出了受理口就没路可走(踩过:G1 全流死路)。
*/
mandatory: [
{
name: 'intake',
payload: {
type: 'object',
required: ['records', 'needAlternative', 'needSourcing'],
properties: {
records: { type: 'array', title: '询价受理记录(已打标)' },
needAlternative: { type: 'boolean', title: '需要选型' },
needSourcing: { type: 'boolean', title: '需要寻源' },
},
},
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',
},
],
},
],
},
/*
* 必交付物**带可机检依据**:`ledger` 是台账地址,预检自己数行数。
* 不给地址,「缺一不得提交放行」就只能靠活动自报 —— 自报等于没有门。
*/
requiredDeliverables: [
{
key: 'intake-record',
label: '询价受理记录',
ledger: `${D}.record`,
/*
* 验收页上让 BD 就地改的那几列 —— **数据是他该管的**。
*
* **`type_tag` 在其列**(2026-08-01 用户实测逼出来的):三分类是 P-03 算的,
* 但算得对不对该由人说了算 —— 他改一颗料的类,就是在推翻一次判定,那是他的职权。
* 从前它不在这份名单里,于是人在件上把三条全改成 A、按了保存,
* **改动被整个丢掉**(写回按这份名单摆格子,不在名单里的列压根不进载荷)——
* 屏上类徽照旧、去向照旧写着「去选型」。他以为自己改了,其实什么也没发生。
*
* **去向仍不在其列**:那是 `type_tag` 的函数(见 output.selective 的 `when`)——
* 改类就够了,去向随之重算;再让人单独选一次去向,等于给同一件事开两个入口。
*/
editableAtRelease: ['pn', 'customer_pn', 'category', 'eau', 'brand', 'target_price', 'remark', 'type_tag'],
/** 这份成果由哪几张表承载 —— 分类表是判据来源、结论是结论栏,都是它的内部结构 */
ledgers: [`${D}.record`, `${D}.type_table`, `${D}.verdict`],
},
/*
* ── **料号分类表与受理结论不是两份成果**(2026-08-02 用户当场指出:
* 「不都是包含在询价受理记录的结构里的吗,为啥单独陈列?」)────────────────
*
* 从前它们各占一条 `requiredDeliverables`,于是人在成果面上看到三样东西 ——
* 而在业务上受理这件事**只交出一份东西**:一份能往下走的询价受理记录。
* 分类表是它的判据来源、受理结论是它的结论栏,两者都是**同一份成果的内部结构**。
*
* 现在合成一条,`ledgers` 说清它由哪几张表承载:落地那一屏按交付物点名,
* 三张表的行数并进「询价受理记录」一行,不再各报各的。
*/
],
/*
* 验收判据逐条标真实等级。L1/L2 必带 predicate —— 当场对任务视图 S-FEEL 求值,
* 不带就等于把「机检」写成了摆设(安装期 acceptance-level-misreported 挡这个)。
*
* 谓词读的是**汇总列**(line_total / missing_key_total / untyped_total),
* 不是逐行列:任务视图把草稿行摊平到顶层是「后写的赢」,拿逐行的
* missing_key_fields 求值只会验到最后写的那一行 —— 那不叫机检,叫抽样。
* 所以汇总列的名字与逐行列**故意不同名**,免得互相盖掉。
*/
acceptanceCriteria: [
/* ① 定位客户 —— 认得出老客户或人建了新客户,这一单才落得到某个客户名下 */
{ key: 'customer-known', label: '客户已确认:发件邮箱认出为老客户,或已为新客户建好档', level: 'L1', predicate: 'customer_total > 0' },
/*
* 条目数**与真实行数对得上**。判据不读活动自报的那一个数就完了 ——
* `quote_intake_record_rows` 是基座数出来的本单行数(草稿层),两边同时成立才算。
* 实测拦的是这个:PN 抄漏了后缀,补写时又成了新的一行,表里 10 行而 line_total 记着 9;
* 从前两边都由活动自己报,自然对得上,人签完字,下游拿到一份多一行的交付物。
*/
{
key: 'lines-present',
label: '来件已逐条归一:说了几条,受理记录里就躺着几行',
level: 'L1',
predicate: 'line_total > 0 and quote_intake_record_rows = line_total',
},
/*
* ② 必填与选填**分量不同**(2026-07-31 用户定):
* · 必填(PN / 年用量)**是验收标准** —— 不齐不许放行;
* · 选填(品牌 / 备注)**只提醒** —— 缺了照样放行,但那一问不能省。
* 所以选填这条判据不看「补齐没有」,看**问过没有**:
* 客户不肯给品牌是客户的事,没去问过是我们的事。
*/
/*
* **读基座数出来的数,不读活动自报的**(2026-08-01 改)。
*
* 从前是 `missing_key_total = 0` —— 那个数由模型自己重算。人在卡上补了年用量、
* 基座已经写回草稿层,而模型漏算一次这条判据就永远绿不了:连跑十轮里三轮死在这,
* 每次都是同一条连拒五拍熔断。`quote_intake_record_missing` 由基座按
* 台账声明的 `required` 现数(缺在哪几行也一并摆进拒回),模型改不动也不必改 ——
* 它只要把那一格补上。**一条只接受「是」而模型又左右不了的判据,是死胡同。**
*/
{
key: 'required-complete',
label: '必填齐:每一条都有型号 PN 与年用量 EAU',
level: 'L1',
predicate: 'quote_intake_record_missing = 0',
},
/*
* ── 这一条为什么**不是机检**(2026-08-01,实测逼下来的)──────────────
*
* 它原本是 L2:`missing_minor_total = 0 or asked_total > 0`。两个数都由活动自己写,
* 于是这条判据对模型来说不是「去把那一问办了」,而是**「把这个数改小就能过」**。
* 实测就是这么走的:NE555P 只缺目标价,模型如实记了 missing_minor_total = 1,
* 提交被这条判据连拒四次,第五拍它把那个数**改回了 0** —— 判据当场变绿、单子放行,
* 而台账上从此写着「缺选填 0 条」,那一格空白再没人提起。
*
* **机检判据只许读基座数得出来的数**(如 `域_对象_rows`);读活动自报的数,
* 它就不是门,是一句「改哪个数能过」的提示。而「问过客户没有」是过程事实,
* 基座此刻数不出来(卡上不带计划项号,数不清哪张卡是为这件事发的)——
* 那就**别假装机器判得了**:降到 L4 交人,并把「缺选填几条」摆到放行页的指标带上,
* 签字的人一眼看得见。要它重回机检,得先让基座能按用途数卡(见 debt A1-3)。
*/
/*
* **「问过」不是结论,是过程**(2026-08-02 用户实测:「我这次没给目标价,
* 你 L4 咋又达标了?」)。
*
* 原文是「要么**问过客户**,要么放行时确认可以先受理」—— 而 A1 每一单必发一张
* 受理需求审定卡,「问过」于是恒真:这条判据永远能裁成绿,等于没有这道门。
* (上一版剧本还明写「发过卡问过就 satisfied=true」,是亲手把它焊死在绿上。)
*
* 结论只有两种:**缺口补齐了**,或者**还缺着、交给签字的人定**。
* 后者正是这条判据的价值 —— 把「还缺这几格」连同理由摆到他面前,
* 而不是替他宣布「问过了就算数」。裁成不满足照样能提交,门不因此拦人。
*/
/*
* **前半句由基座数,后半句才交人**(2026-08-02 用户实测逼出来的)。
*
* 「已补齐」是 `quote_intake_record_gaps = 0` —— 基座照台账声明的 `expect`
* 现数(草稿层实时),模型算不算得对都不影响它。从前整条只读裁断,
* 于是它凭记忆写理由:用户在件上把目标价填好了,它照样裁「未满足」,
* 理由写「NE555P 缺目标价,客户未提供」。用户原话:「这个价格是我填写的,
* 但是它判错了」。复现的那一单更离谱 —— 两行年用量都填着,它写
* 「共 4 处选填缺口」,而 EAU 压根是必填不是选填。
*
* 等级仍是 L4:缺口真还在的时候,「先受理还是退回」确实只有人能定。
* 谓词为真时引擎当它已达标、把那句理由一并丢掉(见 `criterionCheck`)。
*/
{
key: 'optional-asked',
label: '选填缺口(客户料号/品类/制造商/目标价)已补齐,或由你放行时确认可以先受理',
level: 'L4',
predicate: 'quote_intake_record_gaps = 0',
},
/*
* **这条判据读基座数出来的数**(2026-08-02 实测逼出来的,「机检只许读基座数得出来的数」
* 第三次被违反)。从前读活动自写的 `untyped_total` —— 那个数它改一下就骗过去了。
*
* 现在比的是:**打了标的条数(基座按 `type_tag` 的取值域现数)= 受理记录行数**。
* 两边都由基座出,模型碰不着。
*/
{
key: 'typed',
label: '每条都按三分类规则 P-03 打了标:A 直接报 / B 重估后报 / C 找替代',
level: 'L2',
predicate:
'quote_intake_record_rows > 0 and quote_intake_record_type_tag_a + quote_intake_record_type_tag_b + quote_intake_record_type_tag_c = quote_intake_record_rows',
},
/*
* ── 这里**去掉了四条**(2026-08-02 用户当场指出:「这些判据都删了吧」)──────
*
* `downgrade-safe`(L3 吃不准降 B)· `route-alt`(C 类都开了替代评估请求)·
* `route-rfq`(B 类都开了询价请求)· `typed-on-record`(每条记录都带状况标)。
*
* 四条都是**内部一致性**:打标依据对不对、几类料开了几张单、两张表行数齐不齐。
* 它们该由引擎自己看住(数对不上就是活没干完,拒回让模型改),
* 而**放行卡是给签字的人看的**——他要判的是「这一单受理得对不对」,
* 不是替引擎复核一遍它自己的账。一屏九条,真正要他过目的那几条反而淹了。
*
* 落地那一侧不因此变松:派单条数与打标覆盖仍在剧本的收步判据里
* (S5/S6 的 `doneWhen` 读同一批基座读数),错了照旧走不到放行门。
* 「几张单开出去了」改在**验收之后**如实呈现(落地结果),
* 那才是人真正关心的:这一签下去,世界上发生了什么。
*/
/*
* ── 这里**去掉了 `route-judgement`**(L4,2026-08-01 用户当场指出)──────
*
* 原文是「去向判断合乎业务常理(客户特殊要求、急单等)」。
* **去向已经不是判断了**:三个出口各写了 `when`,放行那一刻基座按序求值、
* 首个命中即出(`type_c_total > 0` …),人签的字确认的正是算出来的那一口。
* 再挂一条 L4 让模型裁一次「这个去向合不合理」,是让它给一个自己没做的决定盖章:
* 裁「满足」是废话,裁「不满足」也改不动任何东西 —— 判据读的那几个数才改得动。
* 要改去向,改的是三类条数;那已经由 route-alt / route-rfq 两条机检看着了。
*
* 一条判据得能**指向一个可做的动作**,否则它只是一道要人按一下的手续。
*/
/*
* L5 是**责任关**,不是质量关:它声明「这份交付物必须由人放行」,
* 由 `T_release` 的人工激活 + 终局唯放行落实。
* 卡面上不摆它 —— **那张卡本身就是这道关**(基座统一滤,见 `engines.ts`)。
*/
{ key: 'owner-release', label: 'BD 放行:签下去这一单即正式受理并转入下游', level: 'L5' },
],
/*
* 放行时人**可以改什么** —— 2026-08-01 改口径:**去向不在其列**。
*
* 从前这里摆的是两枚去向旗,让 BD 在放行卡上当场改。那是把「这一单往哪走」
* 交给了一次手感 —— 而它本是**分类表算得出来的事实**(有没有 C 类、有没有 B 类),
* 现在由契约的出口谓词按序求值定下(见 output.selective 的 `when`),
* 卡上只如实告诉人走哪一口、凭哪一条。可回放的判定不该退化成一次不可回放的点击。
*
* 那人在验收页上改什么?**改成果本身** —— 那份受理记录清单就绑在卡上,
* 九行逐格可改,改完由基座写回草稿层、随放行合并(AI 不经手)。
* 数据是他该管的;去向不是。
*/
revisableAtRelease: [],
prohibitions: [
{ key: 'no-guess-field', statement: '原文没写的字段不得凭常识补,缺就记缺' },
{ key: 'no-guess-a', statement: '吃不准的一律降 B,不得臆断为 A' },
{ key: 'no-unit-convert', statement: '不得自作主张换算用量口径(月用量×12 是业务判断,须问客户)' },
{ key: 'no-external-write', statement: '不得在放行前写任何外部系统' },
],
authorization: {
discretion: [
{ key: 'ask-back', statement: '缺次要字段可受理并追问;缺 PN/EAU/交期必须退回补全' },
{ key: 'lifecycle-check', statement: '疑似 EOL 的料可随手拉一次生命周期核查' },
],
/* 空着不是省事:这个字段全仓没有一个消费者(债 ⑨),写了也不会被执行 */
preauthorizedActions: [],
humanCallBudget: 4,
},
resources: {
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-intake.classify' }, { name: 'mail.send' }],
skills: INTAKE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['BD'],
/*
* SLA:受理<15min、判定<45min → activeMinutes 60 封顶;打扰上限 4。
*
* token 预算按**实测**给:一份三条的 BOM 走完全程约 20 拍、18 万 token
* (每拍的提示词都带着案例说明书 + 轨迹,越往后越长)。原来给 20 万,
* 正好卡在收口前一两拍被熔断 —— 预算给得太紧,等于让 Harness 去杀正在干的活。
* 条目多的单会更贵,这里留一倍余量。
*/
budget: { steps: 60, tokens: 400_000, activeMinutes: 60, humanCalls: 4 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'crm.history-price-read' }, { name: 'quote-intake.classify' }, { name: 'mail.send' }],
ledger: [
/*
* 客户主数据 —— **A1 只声明它要用的那几列**,全量结构由平台播种
* (`seed-mvp-data.ts` 的四域)。声明合并是「最大化合并、只增不破坏」,
* 两边并存不冲突;`kind: 'master'` 必须与平台一致,否则治理台会把它归错类。
*
* 为什么受理活动要写主数据:第一件事就是认人 —— 认得出取它的编码,
* 认不出请人建档。建出来的那一行是**给下一单用的**:下次同一个邮箱来信,
* 第一步就认得出,不必再问一遍。
*/
{
domain: 'customer',
object: 'profile',
kind: 'master',
label: '客户档案',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
{ name: 'name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
],
},
{
domain: 'customer',
object: 'contact',
kind: 'master',
label: '客户联系人',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
/*
* **自增,不问人**(2026-08-02 用户:「联系人ID自增啊,不用填」)——
* 它不承载业务含义,只是「这家客户下的第几位」。人哪知道已经有几位;
* 让模型编更糟,编重了就是覆盖别人那一行。基座在写入那一刻按
* `客户编码-NN` 生成(见 `FieldDeclaration.auto`)。
*/
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, auto: true, label: '联系人编码' },
{ name: 'name', type: 'text', primaryKey: false, masked: false, label: '姓名' },
/* 邮箱是**认人的钥匙**,也是个人信息 —— 与平台声明一致标脱敏 */
{ name: 'email', type: 'text', primaryKey: false, masked: true, label: '邮箱' },
],
},
{
domain: D,
object: 'record',
kind: 'record',
caseKey: 'rfq_ref',
label: '询价受理记录',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
/* 客户自己的物料编码 —— 回价单必须按它对齐,否则客户那边对不上账 */
{ name: 'customer_pn', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户料号' },
{ name: 'category', type: 'text', primaryKey: false, masked: false, expect: true, label: '品类' },
{ name: 'brand', type: 'text', primaryKey: false, masked: false, expect: true, label: '制造商' },
/*
* **年用量是必填** —— 标了 `required`,基座就数得出「缺必填的有几行」
* (读数 `quote_intake_record_missing` / `_missing_keys`)。
*
* 为什么非要基座来数(2026-08-01,连跑十轮里三轮死在这):
* 「必填齐」是 L1 机检,从前读的是活动自己写的 `missing_key_total` ——
* 人在卡上补了年用量、基座已经写回草稿层,而那个汇总列还要模型自己重算;
* 它算漏一次,这条判据就永远绿不了,连拒五拍熔断。
* PN 不必标:它是主键,没有 PN 那一行压根不存在。
*/
{ name: 'eau', type: 'integer', primaryKey: false, masked: false, required: true, label: '年用量 EAU' },
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, expect: true, label: '目标价' },
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
{ name: 'packaging', type: 'text', primaryKey: false, masked: false, label: '包装' },
{ name: 'quality_req', type: 'text', primaryKey: false, masked: false, label: '品质要求' },
/*
* 备注**不算选填缺口**(2026-08-02):`expect` 的意思是「客户该给而没给的」——
* 备注是我方写上去的注,一行没有备注不是缺了什么。
* 标着它,`_gaps` 几乎恒大于 0,那条 L4 判据于是永远绿不了 —— 门就废了。
*/
{ name: 'remark', type: 'text', primaryKey: false, masked: false, label: '备注' },
/*
* **分类列:取值域给出来,条数由基座现数** —— `quote_intake_record_type_tag_a/b/c`。
*
* 三分类条数从前是活动自己写的 `type_a_total` 之流,而分布条、放行时的去向谓词、
* 验收判据读的全是它们。于是人在放行页把九条料全改成 A:类徽变了,
* 分布条还是 5/3/1、去向还是「去选型」—— 逐行变了,汇总数没人重算。
* **手工修改的全部意义就是纠正 AI 的判断**:改了类标而流向不跟着变,那个兜底就是假的。
*/
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '状况标', tally: ['A', 'B', 'C'] },
{ name: 'status', type: 'text', primaryKey: false, masked: false, label: '状态' },
{ name: 'missing_key_fields', type: 'integer', primaryKey: false, masked: false, label: '本条缺关键字段数' },
],
},
{
domain: D,
object: 'type_table',
kind: 'record',
caseKey: 'rfq_ref',
label: '料号分类表',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' },
// 打标的三项判据必须落表:P-03 从这里读,不落就无米下锅
{ name: 'history_days', type: 'integer', primaryKey: false, masked: false, label: '距上次报价天数' },
{ name: 'market_stable', type: 'boolean', primaryKey: false, masked: false, label: '行情稳定' },
{ name: 'lifecycle', type: 'text', primaryKey: false, masked: false, label: '生命周期' },
{ name: 'history_hit', type: 'boolean', primaryKey: false, masked: false, label: '历史价命中' },
{ name: 'last_price', type: 'number', primaryKey: false, masked: false, label: '上次成交单价' },
{ name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: '状况标' },
{ name: 'basis', type: 'text', primaryKey: false, masked: false, label: '分级依据' },
],
},
{
/*
* 受理结论 —— **一单一行**。它承担三件别处放不下的事:
* ① 两枚去向旗(下游两道网关唯一读的东西);
* ② 三个汇总数(验收 L1/L2 机检谓词的入参);
* ③ 一句人话结论(验收单上给人看的)。
*/
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'rfq_ref',
label: '受理结论',
fields: [
{ name: 'rfq_ref', type: 'text', primaryKey: true, masked: false, label: '询价单号' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, label: '客户码' },
/*
* 客户名与新老 —— **报告上那两格,和 A4 的一条硬约束**。
*
* 名字:认出来的老客户不必重写 `customer.profile`(那是主数据),
* 于是报告要显示「这一单是哪家公司」就无处可取 —— 落一列在结论上,
* 一份件自己说得出自己是谁,不必去连主数据表。
* 新老:新客户走首单口径,**A4 不得取历史价锚点**。这是下游的判据,不是装饰。
*/
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称' },
/*
* 联系人姓名 —— 报告「联系人」那张卡上摆的那个人,**本单对外发件的收件人**。
*
* 它本来在 `customer.contact.name`(主数据)里,但报告取不到:老客户不重写主数据,
* 于是本单的草稿里根本没有那一行;而顶层同名字段又与 `customer.profile.name`
* 撞名(「后写的赢」),摆上去随时可能变成公司名 —— 一张写着公司名的联系人卡
* 比空着更坏。跟 customer_name 同一笔写下,一份件自己说得出这单是跟谁在谈。
*/
{ name: 'contact_name', type: 'text', primaryKey: false, masked: false, label: '联系人姓名' },
{ name: 'customer_kind', type: 'text', primaryKey: false, masked: false, label: '客户属性(老客户/新客户)' },
/* ① 定位客户那一步的收步判据 —— 认出老客户或人建了新客户,都记 1 */
{ name: 'customer_total', type: 'integer', primaryKey: false, masked: false, label: '客户已确认' },
/*
* 「是不是新客户」与「问过人没有」—— 这两个数是**建档必须经过人**的机检形态。
* 实测:剧本写了「别自己编客户编码」,模型照样从邮箱域名编出 CUST-SZ-MACH
* 直接建了档。说明书管不住,得让收步条件管:新客户没问过人就收不了步。
*/
{ name: 'customer_new_total', type: 'integer', primaryKey: false, masked: false, label: '新客户(需建档)' },
{ name: 'customer_asked_total', type: 'integer', primaryKey: false, masked: false, label: '已就建档问过人' },
{ name: 'line_total', type: 'integer', primaryKey: false, masked: false, label: '有效条目数' },
/*
* 来件一共几条、本轮不报几条 —— 「有效 PN 9 / 10 行 BOM」那句话的出处。
* 没有这两个数,「9 条」是个孤零零的数字:人看不出漏没漏,
* 而客户点名跳过的那一条到底有没有被看见,也就无从对账。
*/
{ name: 'bom_total', type: 'integer', primaryKey: false, masked: false, label: '来件条目数' },
{ name: 'skipped_total', type: 'integer', primaryKey: false, masked: false, label: '本轮不报条数' },
{ name: 'skipped_note', type: 'text', primaryKey: false, masked: false, label: '不报的是哪几条·为什么' },
/*
* ② 「问过没有」—— 选填缺口靠**问过**来解,不靠补齐。
* 用户定的口径:必填项是验收标准,选填项只提醒,但两种缺失都必须经过人。
*/
{ name: 'asked_total', type: 'integer', primaryKey: false, masked: false, label: '已就缺口问过人的次数' },
/*
* **问过 ≠ 答过**。收步判据从前只看 `asked_total`:卡一发出去它就为真,
* 于是人填完提交、引擎在下一拍开头就把这一步收了 —— 模型根本没轮到把答案写回台账,
* 那一格白填(实测:人补的制造商落了空,而台账里那一列仍旧是空的)。
* 收步要等的是**答案落了表**,所以另记一个数。
*/
{ name: 'answered_total', type: 'integer', primaryKey: false, masked: false, label: '人答完并已落表的次数' },
/*
* **这一单经过人的手了没有** —— 阶段2 那张受理需求执行页签回来才记 1。
*
* 它存在的理由是一次实测:从前「经过人」是靠 `missing_minor_total = 0 or
* answered_total > 0` 表达的 —— **只在缺东西的时候才叫人**。于是字段齐全的
* 一封询价进来,这个条件在拆完那一刻就为真,引擎自己把归一那步收了,
* 人一眼没看过受理需求,下一次被叫到已经是终局验收
* (用户 2026-08-01:「我都没确认需求 它就让我验收了」)。
* 确认不是缺口的补丁,是一道**恒在**的门,所以它得有自己的一个数。
*/
{ name: 'confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '受理需求已经人审定' },
{ name: 'missing_key_total', type: 'integer', primaryKey: false, masked: false, label: '缺必填字段条数(PN/年用量)' },
{ name: 'missing_minor_total', type: 'integer', primaryKey: false, masked: false, label: '缺选填字段条数(品牌/备注)' },
{ name: 'untyped_total', type: 'integer', primaryKey: false, masked: false, label: '未打标条数' },
/*
* 分类与派单的条数 —— 「去向旗与状况标对得上」这件事**必须机检**。
* 它本是 L4 交人判的:实测里人(我)盲签了通过,而 C 类那条的替代评估请求
* 根本没开 —— 旗立着、单没开,下游 A2 收到一个空活。
* 算术不该靠人的注意力:把条数落成列,判据就能自己对账。
*/
{ name: 'type_a_total', type: 'integer', primaryKey: false, masked: false, label: 'A 类条数' },
{ name: 'type_b_total', type: 'integer', primaryKey: false, masked: false, label: 'B 类条数' },
{ name: 'type_c_total', type: 'integer', primaryKey: false, masked: false, label: 'C 类条数' },
/*
* **去向定过了没有**(2026-08-02):从前这儿是 `alt_request_total` /
* `rfq_request_total` 两个派单计数 —— 而 A1 已经不开单了(见下面那段注释),
* 那两个数没了对象。收步仍要一个「这一步办过了」的凭据:
* 旗是布尔(合法值含 false),分不清「没写」与「写了 false」,所以另记一个计数。
*/
{ name: 'route_decided_total', type: 'integer', primaryKey: false, masked: false, label: '已定去向' },
{ name: 'need_alternative', type: 'boolean', primaryKey: false, masked: false, label: '需要选型' },
{ name: 'need_sourcing', type: 'boolean', primaryKey: false, masked: false, label: '需要寻源' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '受理结论' },
],
},
/*
* ── 这里**去掉了 `alt_request` / `rfq_request` 两张表**(2026-08-02 用户当场定)──
*
* 原话:「替代评估请求 / 询价请求这两项数据不在这里产出;随令牌流向下一个节点的
* 只有工作成果验收内的内容;进入选型替代后,活动会自己归一需要的数据。」
*
* 从前 A1 逐条开这两张单交给 A2 / A3 —— 那是**替下游把它的输入先摆好**,
* 而下游本来就要照自己的口径归一一遍:同一份事实在两个活动里各存一份,
* 改一处就分叉,而分叉之后没人看得出以哪一份为准。
* A1 交出去的是**受理记录**(带状况标与两枚去向旗),下游读它即可。
*
* 连带退休的:两条派单对账判据(同日删)、`alt_request_total` / `rfq_request_total`
* 两个计数列、报告里那两张明细表、S6 的开单动作。
*/
],
listeners: [
{
/** 客户改需求:整单要回炉 —— 静态监听,时限必配(悬空监听安装时拒绝) */
origin: 'static',
eventType: 'data',
keyFilter: { kind: 'rfq-amend' },
/*
* 语义只有两种:`observe`(记一笔观察,交 Agent 裁量)与 `complete-step`(驱动步完成)。
* 客户改需求要的是「整单回炉」,那是 Agent 看到这条观察后自己决定重入哪一段 ——
* 监听没有「重入」这一档,也不该有:重入是业务判断,不是事件语义。
*/
semantic: 'observe',
deadline: 'P1D',
escalation: ['nudge', 'escalate'],
},
],
harness: {
memoryWindow: 20,
// SLA:判定<45min —— 停滞检测按此设;受理<15min 那一档做催办
stallAfter: 'PT45M',
nudge: [{ after: 'PT15M', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: INTAKE_DMN,
/* 步骤剧本 —— 引擎按它组织每一拍的上下文,并自己收掉成果已在的步骤 */
steps: INTAKE_STEPS,
/* 阶段 —— 只给人看的进度刻度:三段激活态 + 放行收口 = 卡上的 x/4 */
phases: INTAKE_PHASES,
},
skills: INTAKE_SKILLS,
// 四张模版对应四个局面;在办与归档同一张(§9.3 第 2 条)
viewTemplates: [
{ name: 'intake-overview', html: INTAKE_OVERVIEW },
{ name: 'intake-classified', html: INTAKE_CLASSIFIED },
{ name: 'intake-release', html: INTAKE_RELEASE },
],
cardSchemas: {},
distillHooks: [
{
on: 'terminal',
prompt: '归纳本单受理归一的经验:字段缺口形态、打标依据、去向判断被驳回的理由。',
},
],
},
/* ── 样例集 —— 首装审以静态审 + 样例集替代回放审 ───────────────── */
/*
* 样例就是**真客户会发的那种信**(2026-07-31 用户定)。
*
* 从前两个样例都写着「我司客户编码 C001」—— 那是我们内部的编码,
* 客户凭什么知道?真实来函里人只会说**自己是哪个公司的谁**。
* 拿带编码的样例去试,等于把「定位客户」这一步的难处提前替它做掉了:
* 认不出客户、要请人建档的那条路根本走不到,而那恰恰是这一状态的主路。
*/
samples: [
{
/*
* **新客户** —— 这个邮箱**不在** `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'] },
},
],
entry: { file: 'index.js', checksum: 'dev-entry-quote-intake' },
};