人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 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:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
@@ -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 { MVP_OBJECTS, declarationsFor } from '../seed-mvp-data.ts';
import { MASTER_DOMAINS, MASTER_OBJECTS } from '../seed-mvp-data.ts';
describe('MVP_OBJECTS', () => {
it('四域十九对象,不多不少 —— 定价审批表载体是审批流,不入台账', () => {
expect(MVP_OBJECTS).toHaveLength(19);
const count = (domain: string) => MVP_OBJECTS.filter((o) => o.domain === domain).length;
expect(count('project')).toBe(13);
expect(count('customer')).toBe(1);
expect(count('intel')).toBe(3);
expect(count('supplier')).toBe(2);
describe('主数据四域', () => {
it('只有客户 / 项目 / 供应商 / 市场四个域', () => {
expect([...MASTER_DOMAINS]).toEqual(['customer', 'project', 'supplier', 'market']);
expect([...new Set(MASTER_OBJECTS.map((o) => o.domain))].sort()).toEqual([...MASTER_DOMAINS].sort());
});
it('每个对象至少一个主键字段 —— 只增不破坏靠主键立身', () => {
for (const o of MVP_OBJECTS) {
expect(o.fields.some((f) => f.primaryKey), `${o.domain}.${o.object}`).toBe(true);
}
});
it('每个字段都有中文名 —— 界面上不许只见键不见名', () => {
for (const o of MVP_OBJECTS) {
expect(o.label, `${o.domain}.${o.object} 缺对象名`).toBeTruthy();
for (const f of o.fields) {
expect(f.label, `${o.domain}.${o.object}.${f.name} 缺字段名`).toBeTruthy();
}
it('四个域各自都有表 —— 空占位也得立得住', () => {
for (const d of MASTER_DOMAINS) {
expect(MASTER_OBJECTS.filter((o) => o.domain === d).length, `${d} 域一张表都没有`).toBeGreaterThan(0);
}
});
/*
* 受理结论是**一单一行**,主键只有询价单号。
*
* 这条断言守的是一个会静默出错的设计:出口的标量字段取自 `landed.flat`,
* 而 `flat` 是把这一单所有落地行 reduce 展开成一个平对象。两枚去向旗若挂在
* 按料号分行的对象上,六个料号就有六行同名列、最后合并的那行赢 ——
* 而归并规则是「任一条命中 C 即需选型」,网关会照着错值分叉且无人察觉。
* 这一条是分类的要害:主数据按**业务实体**分行,不按单。
* 带上单标识就说明它其实是一单一份的单据 —— 归错类,治理方式全跟着错。
*/
it('受理结论一单一行 —— 去向旗不能挂在按料号分行的对象上', () => {
const verdict = MVP_OBJECTS.find((o) => o.object === 'intake_verdict');
expect(verdict, '缺 project.intake_verdict').toBeDefined();
expect(verdict?.domain).toBe('project');
expect(verdict?.fields.filter((f) => f.primaryKey).map((f) => f.name)).toEqual(['rfq_ref']);
for (const flag of ['need_alternative', 'need_sourcing']) {
expect(verdict?.fields.find((f) => f.name === flag)?.type, flag).toBe('boolean');
}
// 反过来也要守:按料号分行的对象上不许出现去向旗
for (const o of MVP_OBJECTS) {
if (o.object === 'intake_verdict') continue;
const names = o.fields.map((f) => f.name);
expect(names, `${o.domain}.${o.object} 不该带去向旗`).not.toContain('need_alternative');
expect(names, `${o.domain}.${o.object} 不该带去向旗`).not.toContain('need_sourcing');
it('每张表都是 master 且不带单标识', () => {
for (const o of MASTER_OBJECTS) {
expect(o.kind, `${o.domain}.${o.object}`).toBe('master');
expect(o.caseKey, `${o.domain}.${o.object} 不该有单标识`).toBeUndefined();
}
});
it('declarationsFor 按名取子集,名不存在即抛', () => {
expect(declarationsFor(['project.intake_record', 'intel.history_price'])).toHaveLength(2);
expect(() => declarationsFor(['project.nope'])).toThrow('未知对象');
it('每张表都有主键 —— 没主键的台账合并与去重无从谈起', () => {
for (const o of MASTER_OBJECTS) {
const pk = o.fields.filter((f) => f.primaryKey).map((f) => f.name);
expect(pk.length, `${o.domain}.${o.object} 没有主键`).toBeGreaterThan(0);
}
});
/* 主键得是业务实体键 —— 单标识那几个名字出现在主键里,就是归错了类 */
it('主键里不出现单标识那几个名字', () => {
for (const o of MASTER_OBJECTS) {
const pk = o.fields.filter((f) => f.primaryKey).map((f) => f.name);
for (const caseish of ['rfq_ref', 'quote_no', 'task_id']) {
expect(pk, `${o.domain}.${o.object} 的主键里有单标识 ${caseish}`).not.toContain(caseish);
}
}
});
/* 个人信息必须标脱敏 —— 主数据里存联系人,治理面照 `masked` 裁剪 */
it('联系人的邮箱与电话标了脱敏', () => {
const contacts = MASTER_OBJECTS.filter((x) => x.object === 'contact');
expect(contacts.length).toBeGreaterThan(0);
for (const o of contacts) {
for (const name of ['email', 'phone']) {
const field = o.fields.find((x) => x.name === name);
expect(field?.masked, `${o.domain}.${o.object}.${name} 未标脱敏`).toBe(true);
}
}
});
});
@@ -1,73 +1,60 @@
/**
* 六个活动槽位 —— 契约就绪、实现留白。守:岗位/出口与 MVP 一致;
* 声明只有名(工具按名引用、域对象按名取)—— 活动与工具的解耦就靠这一条。
* A5 定价审批是**内置审批点**,零开发、不入库 —— 不在槽位之列。
* 槽位退役名单 —— 六个占位槽必须一个不漏地被请出去。
*
* 这份测试从前断言的是「MVP_SLOTS 有 5 份占位清单、各自长什么样」——
* 那批清单在 2026-07-31 全部退役:六个真活动包(`activities/quote-*`)转正后,
* 占位清单成了会骗人的重影:
* · 活动库与试跑台上并排两个同名活动,选错一个就试了个空壳;
* · 数据域里 12 对同名台账(`project.intake_record` vs `quote_intake.record` …),
* 人分不出该看哪一个 —— 而旧的那 19 张表一行数据都没有;
* · 工具授权面各留一份,治理台的授权清单跟着翻倍。
*
* 所以断言的对象换了:不再是「占位清单长什么样」,而是**「它们都在退役名单里」**。
* 名单漏一个,那个槽位下次播种又装回来,重影跟着回来。
*/
import { describe, expect, it } from 'vitest';
import { MVP_SLOTS, SLOT_ARTIFACT } from '../seed-mvp-slots.ts';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
describe('MVP_SLOTS', () => {
it('六槽位(A5 审批点内置,不入库)', () => {
const ids = MVP_SLOTS.map((m) => m.identity.activityId as string);
expect(MVP_SLOTS).toHaveLength(6);
expect(ids).toEqual(
expect.arrayContaining(['rfq-intake', 'alt-selection', 'sourcing-inquiry', 'cost-pricing', 'quote-negotiation', 'intel-archive']),
);
expect(ids).not.toContain('pricing-approval');
});
const source = readFileSync(fileURLToPath(new URL('../seed-mvp-slots.ts', import.meta.url)), 'utf8');
/*
* 占位骨架的形状(2026-07-29 范式统一后):**案例模型**——
* SOP 每件事一个人工任务 + 末尾一张收口验收。两条都是踩过坑才立的:
* · 触达世界的项必须人工激活 —— 占位尤其不许替人自动做事
* · 没有收口验收 → 活动永远不 release →「等人却没东西给人答」
*/
it('占位骨架:案例模型双表示齐备,项全人工激活,末尾一张收口验收', () => {
for (const m of MVP_SLOTS) {
const model = m.assets.businessModel;
const id = m.identity.activityId;
expect(model.cmmnXml, `${id} 缺 CMMN`).toContain('<casePlanModel');
expect(model.narrative.length, `${id} 缺说明书`).toBeGreaterThan(60);
expect(model.cmmnXml, `${id} 缺收口验收`).toContain('id="T_release"');
// 每个 humanTask 都带人工激活规则 —— 一个自动跑的占位项都不许有
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);
/** 被真活动包顶替掉的六个槽位 —— 顺序即 SOP 次序 */
const RETIRED = [
'rfq-intake', // A1 → quote-intake
'alt-selection', // A2 → quote-alternative
'sourcing-inquiry', // A3 → quote-sourcing
'cost-pricing', // A4 → quote-pricing
'quote-negotiation', // A6 → quote-negotiate
'intel-archive', // A7 → quote-archive
] as const;
describe('槽位退役', () => {
it('六个槽位一个不漏地在退役名单里', () => {
for (const id of RETIRED) {
expect(source, `${id} 不在退役名单里 —— 下次播种它会装回来`).toContain(`'${id}'`);
}
});
it('A1 出口带两枚去向旗;A2 透传 needSourcing —— 网关条件的数据来源', () => {
const a1 = MVP_SLOTS.find((m) => m.identity.activityId === 'rfq-intake')!;
const p1 = (a1.contract.output.mandatory[0]!.payload as { properties?: Record<string, unknown> }).properties ?? {};
expect(Object.keys(p1)).toEqual(expect.arrayContaining(['needAlternative', 'needSourcing']));
const a2 = MVP_SLOTS.find((m) => m.identity.activityId === 'alt-selection')!;
const p2 = (a2.contract.output.mandatory[0]!.payload as { properties?: Record<string, unknown> }).properties ?? {};
expect(Object.keys(p2)).toContain('needSourcing');
/*
* 占位清单必须**真的删掉**,不是留在文件里不引用 ——
* 留着它,下一个人很容易「顺手再装回去」,重影就回来了。
*/
it('占位清单已从源码里移除', () => {
expect(source).not.toContain('MVP_SLOTS');
expect(source).not.toContain('SLOT_ARTIFACT');
});
it('播种只做退役这一件事 —— 不再装任何槽位', () => {
expect(source).toContain('registry.uninstall');
expect(source).not.toContain('registry.install(');
});
/*
* 三个去向是**选择组**,不是必交口里的一个字段:人在放行时恰选一口,
* 那一口就是这次运行的 outcome(引擎按选口注入),G4 判的正是它。
* 曾写成 formal 口的必填 outcome + selective: [] —— 引擎只在选择组恰发一口时
* 才注入 outcome,没有选择组就结构上永远没有判据,G4 三条边全不成立(实测)。
* 卸不掉要如实说、且不掀翻播种(多半是还有在途任务引着那个活动)。
* 吞掉错误会让人以为清干净了,而治理台上那个重影还在。
*/
it('A6 三个去向是选择组 —— 放行恰选一口,选口即 G4 的判据', () => {
const a6 = MVP_SLOTS.find((m) => m.identity.activityId === 'quote-negotiation')!;
const groups = a6.contract.output.selective;
expect(groups).toHaveLength(1);
expect(groups[0]!.group).toBe('议价结果');
expect(groups[0]!.ports.map((p) => p.name)).toEqual(['won', 'revise', 'lost']);
// outcome 不再是必交口的必填字段 —— 它由选口给出,不靠活动自己写
const formal = a6.contract.output.mandatory[0]!.payload as { required?: string[] };
expect(formal.required ?? []).not.toContain('outcome');
});
it('槽位不携实现:入口是抛「未实现」的占位,工具声明只有名', () => {
expect(SLOT_ARTIFACT).toContain('未实现');
for (const m of MVP_SLOTS) {
for (const t of m.declarations.tools) expect(typeof t.name).toBe('string');
}
it('卸不掉如实报出来,不掀翻播种', () => {
expect(source).toContain('卸不掉');
expect(source).toContain('catch');
});
});
@@ -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 type { StaffId, TaskId } from '@contracts';
import { HUMAN_TOOL, LLM_TOOLS } from '@modules/toolbox';
import { HUMAN_TOOL, LEDGER_TOOL, LLM_TOOLS } from '@modules/toolbox';
import type { BusTool } from '@modules/toolbox';
import { toolCatalog } from '../agent-runner.ts';
import { buildMastraTools } from '../tool-bus.ts';
import type { ToolBusPorts, ToolBusTrace } from '../tool-bus.ts';
@@ -38,10 +39,11 @@ type Calls = {
cards: Array<{ workOrder: string; card: Record<string, unknown> }>;
listens: Array<{ keyFilter: Record<string, string> }>;
generated: Array<{ model: string; label: string; prompt: string }>;
written: Array<{ address: string; rows: number }>;
};
function harness(writeTools: string[] = ['mail.send']) {
const calls: Calls = { invoked: [], intents: [], cards: [], listens: [], generated: [] };
const calls: Calls = { invoked: [], intents: [], cards: [], listens: [], generated: [], written: [] };
const ports: ToolBusPorts = {
invoke: async (input) => {
calls.invoked.push({ tool: input.tool, input: input.input });
@@ -59,6 +61,10 @@ function harness(writeTools: string[] = ['mail.send']) {
calls.listens.push({ keyFilter: input.keyFilter });
return { id: 'lsn-1' };
},
draftWrite: async (input) => {
calls.written.push({ address: input.address, rows: input.rows.length });
return { written: input.rows.length };
},
generate: async (input) => {
calls.generated.push({ model: input.model, label: input.label, prompt: input.prompt });
return { text: '{"label":"C"}', object: { label: 'C', rationale: '据规则' } };
@@ -116,6 +122,48 @@ describe('装配:人类工具(系统内化)', () => {
});
});
describe('装配:台账工具(系统内化)', () => {
it('写草稿落草稿层,**不登记动作意向** —— 草稿不触达世界,不该走动作那条路', async () => {
const h = harness();
const tools = buildMastraTools([LEDGER_TOOL], h.ports, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace);
const out = await run(Object.values(tools)[0], {
address: 'quote_intake.record',
rows: [
{ key: { pn: 'LM358DR' }, patch: { eau: 12000 } },
{ key: { pn: 'NE555P' }, patch: { eau: 5000 } },
],
});
expect(h.calls.written).toEqual([{ address: 'quote_intake.record', rows: 2 }]);
expect(h.calls.intents).toHaveLength(0);
expect(out).toEqual({ ok: true, written: 2 });
});
it('行的形状不对 → 入参 schema 当场拦下,错误原文回给模型让它这一拍改对', async () => {
const h = harness();
const tools = buildMastraTools([LEDGER_TOOL], h.ports, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace);
const out = await run(Object.values(tools)[0], {
address: 'quote_intake.record',
rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }, { pn: 'Y', eau: 2 }, null],
});
// 一行不合规就整笔不写:半份数据比没数据更难查
expect(h.calls.written).toEqual([]);
expect(String(out['message'])).toContain('rows.1.key');
});
it('地址不合规(装配层抛错)如实回报,不掀翻这一拍', async () => {
const h = harness();
const rejecting: ToolBusPorts = {
...h.ports,
draftWrite: async () => {
throw new Error('台账地址未声明:other.thing');
},
};
const tools = buildMastraTools([LEDGER_TOOL], rejecting, { taskId: TASK, ownerId: OWNER, humanCallCap: 9, model: 'm' }, h.trace);
const out = await run(Object.values(tools)[0], { address: 'other.thing', rows: [{ key: {}, patch: {} }] });
expect(out).toEqual({ ok: false, error: '台账地址未声明:other.thing' });
});
});
describe('装配:LLM 五件(系统内化)', () => {
it('经模型网关调,用活动声明的用途名;提示词归系统', async () => {
const h = harness();
@@ -139,3 +187,28 @@ describe('装配:LLM 五件(系统内化)', () => {
);
});
});
/**
* 单拍路径的工具清单 —— **声明式工具的提示词面**。
*
* 从前单拍路径的提示词里**根本没有工具清单**(第 12 章⑧):Agent 只知道说明书里
* 顺口提过的工具名,入参长什么样得猜。实测的后果是它把该调工具的一步改成发卡问人
* (「请为以下三个 PN 查询历史价格」),而系统里明明就有这件工具 —— 白占人的时间。
*/
describe('工具清单进提示词', () => {
it('id · 效应 · 说明 · 入参键名都在,必填带星', () => {
const listing = toolCatalog([
{ id: 'crm.history-price-read', description: '读历史价', input: READ_TOOL.input, effect: 'read' },
{ id: 'mail.send', description: '发邮件', input: WRITE_TOOL.input, effect: 'write' },
]);
expect(listing).toContain('crm.history-price-read(读类):读历史价');
expect(listing).toContain('入参:pn:string*');
expect(listing).toContain('mail.send(写类)');
// 写类要如实说清「现在还没生效」,否则 Agent 会当成已经发出去了
expect(listing).toContain('登记为动作意向');
});
it('一件工具都没有就不摆这一段 —— 不给上下文塞空标题', () => {
expect(toolCatalog([])).toBe('');
});
});
@@ -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 实体还原 —— `&amp;lt;` 之流不能原样端给模型', () => {
it('转义字符还原成本来的字', () => {
const zip = makeZip([
// 表里真实存的形态:`A&B <100>` 在 XML 里写作 `A&amp;B &lt;100&gt;`
{ name: 'xl/sharedStrings.xml', body: enc('<sst><si><t>A&amp;B &lt;100&gt;</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' });
});
});