Archived
人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 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:
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* 活动包的自测 —— **只准 import @sdk / @contracts 与白名单纯库**(编码规范 §5.1 / ADR-004)。
|
||||
* 安装两审那一侧的验收在 `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,
|
||||
* 没有代码通道,depcruise 双向隔离在守着。
|
||||
* A1 询价受理的包内自测 —— **只准 import @sdk / @contracts / @sfeel**
|
||||
* (编码规范 §5.1 / ADR-004)。安装两审那一侧的验收在
|
||||
* `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道,没有代码通道,
|
||||
* depcruise 双向隔离在守着。
|
||||
*
|
||||
* 案例活动没有 `run`,所以自测不跑回合(那是 Loop 的事)。这里测的是**清单本身**:
|
||||
* ① 清单合规 + 判据等级不谎报;
|
||||
* ② 案例模型双表示同在、触达世界的项标了人工激活;
|
||||
* ③ 两张判定表逐行求值(能算的不问 LLM);
|
||||
* ④ **声明与契约对得上** —— 这一段最容易在改动中悄悄错位:
|
||||
* 必交付物的台账地址、验收谓词读的列、出口载荷要的标量,三处必须都有台账列承载。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activityManifestSchema } from '@contracts';
|
||||
import { evaluateUnaryTest } from '@sfeel';
|
||||
import { createMockHost } from '@sdk';
|
||||
import { manifest, P03_DMN } from '../src/manifest.ts';
|
||||
import { F01_DMN, INTAKE_DMN, P03_DMN, manifest } from '../src/manifest.ts';
|
||||
import activity from '../src/index.ts';
|
||||
|
||||
describe('清单本身合规', () => {
|
||||
@@ -21,7 +29,7 @@ describe('清单本身合规', () => {
|
||||
|
||||
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef).toBeDefined();
|
||||
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,6 +37,12 @@ describe('清单本身合规', () => {
|
||||
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
|
||||
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('每件必交付物都有台账地址 —— 没地址,「缺一不得提交放行」只能靠活动自报', () => {
|
||||
for (const d of manifest.contract.requiredDeliverables) {
|
||||
expect(d.ledger, d.key).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('案例模型(AM 03/12.2)—— 双表示同版本', () => {
|
||||
@@ -37,119 +51,342 @@ describe('案例模型(AM 03/12.2)—— 双表示同版本', () => {
|
||||
|
||||
it('CMMN 与说明书同在,不允许分叉', () => {
|
||||
expect(model.cmmnXml).toContain('<casePlanModel');
|
||||
expect(model.narrative.length).toBeGreaterThan(80);
|
||||
expect(model.narrative.length).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
it('触达世界的项恒需人工激活(AM 4.1 原理一)', () => {
|
||||
// 解析归一、追问补全、查历史价 —— 三件都碰外界,一件也不许自动跑
|
||||
for (const id of ['T_normalize', 'T_ask_back', 'T_history']) {
|
||||
it('触达世界的项标了人工激活(AM 4.1 原理一)', () => {
|
||||
/*
|
||||
* 只剩放行这一件。定位客户与归一 RFQ 两步**发卡但不落地**:
|
||||
* 补全卡与执行卡都是「问一句」,回来的值仍旧只进草稿层,
|
||||
* 真正触达世界的只有放行那一下(铁律③)。
|
||||
*/
|
||||
for (const id of ['T_release']) {
|
||||
expect(cmmn.manualActivation, `${id} 应为人工激活`).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* **图上有几步就是几项**。上一版加了两个阶段、两个里程碑、一个裁量项、
|
||||
* 一个用户事件监听器 —— 12 个计划项装 6 步的活,业务一分没多,卡壳的花样多了一堆:
|
||||
* 每一条「行动被拒」都是结构上的,没有一条是业务判断上的(实测)。
|
||||
* 阶段 / 里程碑 / 裁量项是 CMMN 有的东西,但「有」不等于「该用」。
|
||||
*/
|
||||
/*
|
||||
* 2026-07-31 用户重定前两态:
|
||||
* ① `S1_customer` 定位客户信息 —— 按发件邮箱认人,认不出发补全卡请人建档;
|
||||
* ② `S2_normalize` 归一客户 RFQ 输入 —— 解析正文逐条落表。
|
||||
* 旧的 S1_register / S2_ask_back / S3_normalize 三项合并进这两态。
|
||||
*
|
||||
* 2026-08-01 补回 `S3_confirm`(**人的门,不是 AI 的步**):
|
||||
* 追问从前是「归一里缺字段时的动作」,于是不缺字段就不叫人 —— 一封齐整的询价
|
||||
* 从起单一路自动跑到终局验收,人一眼没看过受理需求(用户当场指出)。
|
||||
* 阶段2 那张执行页(`A1-询价受理需求.html`,页脚是【修改】【确认受理记录】)
|
||||
* 从来就是一张人签的页,所以它得是**一项**:人工激活、无 doneWhen、一单必经一次。
|
||||
*/
|
||||
it('计划项照状态机一步一项:两态 + 人审定 + 后续三步 + 放行,不多建一件', () => {
|
||||
expect(items(model.cmmnXml)).toEqual([
|
||||
'S1_customer',
|
||||
'S2_normalize',
|
||||
'S3_confirm',
|
||||
'S4_history',
|
||||
'S5_classify',
|
||||
'S6_route',
|
||||
'T_release',
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
* **触达世界的项必须人工激活** —— 引擎不许自己按下它们。
|
||||
* 受理需求那张卡交到人手上、放行那一签发出去,都是外面能看见的动作。
|
||||
*/
|
||||
it('两处人的门都标了人工激活:受理需求审定 · 受理放行', () => {
|
||||
for (const id of ['S3_confirm', 'T_release']) {
|
||||
const block = model.cmmnXml.slice(model.cmmnXml.indexOf(`id="${id}"`));
|
||||
expect(block.slice(0, block.indexOf('</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('末尾一张验收单 —— 完成只出自放行通过', () => {
|
||||
expect(model.cmmnXml).toContain('id="T_release"');
|
||||
});
|
||||
|
||||
it('能算的交 DMN,不交 Agent —— 打标是 decisionTask', () => {
|
||||
expect(model.cmmnXml).toContain('decisionRef="p03-type"');
|
||||
expect(P03_DMN.tables.some((t) => t.id === 'p03-type')).toBe(true);
|
||||
expect(INTAKE_DMN.tables.some((t) => t.id === 'p03-type')).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* 作业指导**一处一份**:逐步的做法在剧本(`steps[].guide`),整单的事在说明书。
|
||||
* 从前两处各写一份六步,实测分叉过 —— 说明书里写着 S1_register / S2_ask_back /
|
||||
* S3_normalize 三个案例模型里根本不存在的计划项,模型照它报 itemId,一报一个被拒。
|
||||
* 所以这里改测三件事:每一项都有剧本、剧本说得出做法、说明书**不许再点名计划项**。
|
||||
*/
|
||||
it('每个计划项都有剧本 —— 轮到哪一步才摊开哪一步', () => {
|
||||
const ids = items(model.cmmnXml);
|
||||
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
|
||||
for (const step of model.steps ?? []) {
|
||||
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
|
||||
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
|
||||
}
|
||||
});
|
||||
|
||||
it('说明书不点名计划项 —— 点了就会与案例模型分叉(踩过)', () => {
|
||||
// 认的是**计划项 id 的形状**(S数字_字母 / T_字母),不是「S1 → S2」那种路线简写
|
||||
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z]+|T_[a-z]+)\b/g)].map((m) => m[0]);
|
||||
const ids = new Set(items(model.cmmnXml));
|
||||
for (const name of named) {
|
||||
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('说明书只讲整单的事:成品、怎么说话、怎么收口', () => {
|
||||
for (const topic of ['开案', '要说话', '一拍连做', '结案']) {
|
||||
expect(model.narrative, topic).toContain(topic);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** 极小取值:只为断言用,不重实现引擎的解析器(那一份在 loop-engine 里有自己的测试) */
|
||||
function parseCmmnLite(xml: string): { manualActivation: string[] } {
|
||||
const ids: string[] = [];
|
||||
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[\s\S]*?<\/humanTask>/g)) {
|
||||
if (m[0].includes('manualActivationRule')) ids.push(m[1]!);
|
||||
}
|
||||
return { manualActivation: ids };
|
||||
/** 计划项 id 按出现次序 —— 断言「图上有几步就是几项」 */
|
||||
function items(xml: string): string[] {
|
||||
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
|
||||
}
|
||||
|
||||
describe('三分类规则 P-03 —— 能算的不问 LLM', () => {
|
||||
const table = P03_DMN.tables[0]!;
|
||||
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
|
||||
const manualActivation: string[] = [];
|
||||
const repetition: string[] = [];
|
||||
for (const m of xml.matchAll(/<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 */
|
||||
const decide = (scope: Record<string, unknown>) => {
|
||||
for (const rule of table.rules) {
|
||||
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
|
||||
if (hit) return { type: rule.then[0], play: rule.then[1], rule: rule.id };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
/** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */
|
||||
function decideBy(table: typeof P03_DMN, scope: Record<string, unknown>) {
|
||||
for (const rule of table.rules) {
|
||||
const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope));
|
||||
if (hit) return { outputs: rule.then, rule: rule.id };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('受理字段完整度 F-01 —— 三要素齐才放行', () => {
|
||||
it('缺 PN/EAU/交期任一 → 退回补全', () => {
|
||||
expect(decideBy(F01_DMN, { missing_key_total: 2, missing_minor_total: 0 })).toMatchObject({
|
||||
outputs: ['缺PN/数量/交期', '退回补全'],
|
||||
rule: 'f01-2',
|
||||
});
|
||||
});
|
||||
|
||||
it('只缺次要字段 → 受理并追问(不卡住)', () => {
|
||||
expect(decideBy(F01_DMN, { missing_key_total: 0, missing_minor_total: 3 })).toMatchObject({
|
||||
outputs: ['缺次要字段', '受理并追问'],
|
||||
rule: 'f01-3',
|
||||
});
|
||||
});
|
||||
|
||||
it('八字段齐 → 往下走', () => {
|
||||
expect(decideBy(F01_DMN, { missing_key_total: 0, missing_minor_total: 0 })).toMatchObject({ rule: 'f01-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('三分类规则 P-03 —— 能算的不问 LLM', () => {
|
||||
const decide = (scope: Record<string, unknown>) => decideBy(P03_DMN, scope);
|
||||
|
||||
it('历史价 ≤90 天且行情稳 → A · 直接快答', () => {
|
||||
expect(decide({ historyDays: 30, marketStable: true, lifecycle: '在产' })).toEqual({
|
||||
type: 'A',
|
||||
play: '直接快答',
|
||||
expect(decide({ history_days: 30, market_stable: true, lifecycle: '在产' })).toMatchObject({
|
||||
outputs: ['A', '直接快答'],
|
||||
rule: 'p03-a',
|
||||
});
|
||||
});
|
||||
|
||||
it('无历史 / 过期 / 波动 → B · 重估后报', () => {
|
||||
expect(decide({ historyDays: 200, marketStable: false, lifecycle: '在产' })?.type).toBe('B');
|
||||
expect(decide({ history_days: 9999, market_stable: false, lifecycle: '未知' })?.outputs[0]).toBe('B');
|
||||
});
|
||||
|
||||
it('EOL / 缺货 / 要求替代 → C · 转案处理(优先于 A)', () => {
|
||||
expect(decide({ historyDays: 10, marketStable: true, lifecycle: 'EOL' })).toMatchObject({ type: 'C', rule: 'p03-c' });
|
||||
it('EOL / 缺货 / 要求替代 → C · 转案处理(**优先于 A**:新历史价也不该让 EOL 直通)', () => {
|
||||
expect(decide({ history_days: 10, market_stable: true, lifecycle: 'EOL' })).toMatchObject({ rule: 'p03-c' });
|
||||
});
|
||||
|
||||
it('同输入必同结论 —— 零方差、零 token', () => {
|
||||
const scope = { historyDays: 30, marketStable: true, lifecycle: '在产' };
|
||||
const scope = { history_days: 30, market_stable: true, lifecycle: '在产' };
|
||||
expect(decide(scope)).toEqual(decide(scope));
|
||||
});
|
||||
});
|
||||
|
||||
describe('代码入口 —— 零环境能力,一切经基座六件', () => {
|
||||
const contract = {
|
||||
activityId: 'quote-intake',
|
||||
version: '0.1.0',
|
||||
data: { customerCode: 'C001', rfqRef: 'mail:m-1001' },
|
||||
message: undefined,
|
||||
outputPorts: { mandatory: ['intake'], selective: [] },
|
||||
prohibitions: [],
|
||||
discretion: [],
|
||||
} as const;
|
||||
/**
|
||||
* 声明与契约的对齐 —— 三处引用同一批台账列,错位了不会报错,只会在放行那一刻
|
||||
* 「判据取不到值 / 出口取不到字段」,而两者都长得像业务问题。所以在这儿钉死。
|
||||
*/
|
||||
describe('声明与契约对得上', () => {
|
||||
const columns = new Set(
|
||||
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
|
||||
);
|
||||
|
||||
it('全部打标后提交放行', async () => {
|
||||
const host = createMockHost({
|
||||
contract,
|
||||
ledger: {
|
||||
'quote_intake.record': [
|
||||
{ customer_code: 'C001', pn: 'LM358DR', brand: 'TI', eau: 5000, type_tag: 'A' },
|
||||
{ customer_code: 'C001', pn: 'NE555', brand: 'TI', eau: 2000, type_tag: 'B' },
|
||||
],
|
||||
'quote_intake.type_table': [{ pn: 'LM358DR', type_tag: 'A' }],
|
||||
},
|
||||
});
|
||||
const outcome = await host.run(activity);
|
||||
expect(outcome).toMatchObject({ kind: 'submit', deliverable: 'intake-record' });
|
||||
expect(host.recording.views).toHaveLength(1);
|
||||
expect(host.recording.says.join()).toContain('A 1 条直通定价');
|
||||
it('必交付物的台账地址都声明过', () => {
|
||||
for (const d of manifest.contract.requiredDeliverables) {
|
||||
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('尚有未打标即等待,不提交放行', async () => {
|
||||
const host = createMockHost({
|
||||
contract,
|
||||
ledger: { 'quote_intake.record': [{ customer_code: 'C001', pn: 'LM358DR' }], 'quote_intake.type_table': [] },
|
||||
});
|
||||
expect(await host.run(activity)).toMatchObject({ kind: 'await' });
|
||||
/*
|
||||
* 除了台账列,谓词还能读一类**基座数出来的读数**:`域_对象_rows` = 本单在那张表上
|
||||
* 写下了几行。它不是列(活动写不了它),正因如此才拦得住「两边都由活动自报」的假对账。
|
||||
* 这里照样要核到底:对象没声明过,`quote_intake_recrod_rows` 这种手滑仍要当场炸。
|
||||
*/
|
||||
const rowReadings = new Set([
|
||||
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
|
||||
/*
|
||||
* `域_对象_missing` = 本单那张表缺必填的有几行(按声明的 `fields[].required` 现数)。
|
||||
* 与 `_rows` 同类:活动写不了它,所以它才拦得住「两边都由活动自报」的假对账 ——
|
||||
* 「必填齐」那条判据从前读活动自报的 `missing_key_total`,模型漏算一次就永远绿不了。
|
||||
*/
|
||||
...manifest.declarations.ledger
|
||||
.filter((d) => d.fields.some((f) => f.required === true))
|
||||
.map((d) => `${d.domain}_${d.object}_missing`),
|
||||
/*
|
||||
* `域_对象_<列>_<值>` = 本单那张表按值现数的条数(取值域由 `fields[].tally` 声明)。
|
||||
* 与 `_rows` / `_missing` 同类:活动写不了它 —— 所以人在放行页改了类标,
|
||||
* 分布条、去向谓词、派单对账**全都跟着重算**。手工修改本来就是为了纠正 AI 的判断。
|
||||
*/
|
||||
...manifest.declarations.ledger.flatMap((d) =>
|
||||
d.fields.flatMap((f) =>
|
||||
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
it('L1/L2 谓词读的列都有台账承载 —— 否则机检永远取不到值', () => {
|
||||
for (const c of manifest.contract.acceptanceCriteria) {
|
||||
if (c.level !== 'L1' && c.level !== 'L2') continue;
|
||||
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
|
||||
// S-FEEL 的保留字不是台账名 —— 漏掉它,`a = 0 or b > 0` 会被当成引用了一列叫 `or`
|
||||
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
|
||||
if (rowReadings.has(identifier)) continue;
|
||||
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('视图模版含脚本时宿主拒推 —— 容器无脚本执行权', async () => {
|
||||
const host = createMockHost({ contract });
|
||||
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
|
||||
it('出口载荷的标量字段都有台账列承载(§7.5;踩过:两枚去向旗没列 → G1 全流死路)', () => {
|
||||
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
|
||||
for (const port of manifest.contract.output.mandatory) {
|
||||
const properties = (port.payload as { properties?: Record<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 () => {
|
||||
const host = createMockHost({ contract });
|
||||
await expect(
|
||||
host.base.interaction.issueWorkOrder({ mode: 'execution', title: 'x', basis: [], ask: 'y', fields: [] }),
|
||||
).rejects.toThrow(/裸调用/);
|
||||
it('技能引用的工具都在白名单声明内 —— 载方法不载权力', () => {
|
||||
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
|
||||
for (const skill of manifest.assets.skills) {
|
||||
for (const fragment of skill.crystallized) {
|
||||
expect(declared.has(fragment.tool), `${skill.id} 用了 ${fragment.tool}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('监听时限必配 —— 无死等', async () => {
|
||||
const host = createMockHost({ contract });
|
||||
await expect(host.base.tools.listen({ eventType: 'data', keyFilter: {}, deadline: '' })).rejects.toThrow(/时限必配/);
|
||||
describe('报告模版 —— 活动持有,容器无脚本执行权(§9.3)', () => {
|
||||
it('四个局面各一张,不在一张里堆条件分支', () => {
|
||||
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual([
|
||||
'intake-overview',
|
||||
'intake-classified',
|
||||
'intake-release',
|
||||
]);
|
||||
});
|
||||
|
||||
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
|
||||
for (const template of manifest.assets.viewTemplates) {
|
||||
expect(template.html, template.name).not.toMatch(/<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user