forked from ChaiHao/BPOS-MVP
人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的: 件上就地改 · 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名, 于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 —— 而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。 · **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。 删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)—— 「没回传就是删了」会把一次不完整的回传变成一次删表。 · **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。 · 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。 判据与门 · 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来, 人填好的目标价它照样判「客户未提供」的毛病由此消掉。 · 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。 · 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开, 于是一单没打标就进了放行门。三个数全由基座现数。 · 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。 **指向两扇打不开的门,比不指还糟。** 界面不撒谎 · 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。 · 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、 往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。 · 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。 · 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。 · 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。 A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。 五道门禁全绿(971 passed)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
@@ -13,7 +13,8 @@
|
||||
"seed": "node dist/seed.js",
|
||||
"migrate": "node dist/migrate.js",
|
||||
"set-password": "node dist/set-password.js",
|
||||
"reset-runs": "node dist/reset-runs.js"
|
||||
"reset-runs": "node dist/reset-runs.js",
|
||||
"install-activities": "node dist/install-activities.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^3.0.14",
|
||||
|
||||
@@ -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 实体还原 —— `&lt;` 之流不能原样端给模型', () => {
|
||||
it('转义字符还原成本来的字', () => {
|
||||
const zip = makeZip([
|
||||
// 表里真实存的形态:`A&B <100>` 在 XML 里写作 `A&B <100>`
|
||||
{ name: 'xl/sharedStrings.xml', body: enc('<sst><si><t>A&B <100></t></si></sst>') },
|
||||
{
|
||||
name: 'xl/worksheets/sheet1.xml',
|
||||
body: enc('<worksheet><sheetData><row r="1"><c r="A1" t="s"><v>0</v></c></row></sheetData></worksheet>'),
|
||||
},
|
||||
]);
|
||||
expect(extractText('e.xlsx', '', zip).text).toBe('A&B <100>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* **件上就地改** —— 人手改的那几格,是不是落得下去、按不按得下提交。
|
||||
*
|
||||
* 两件事在这儿测,都是用户实测逼出来的(2026-08-02,同一单):
|
||||
*
|
||||
* ① **按地址收,不按「当下那张卡的第几块」收**。
|
||||
* 载荷从前是 `{workOrderId, values:{'table:0': …}}`:列名由那张工单的 `blocks[0]`
|
||||
* 给出,于是「这份件能不能改」被绑在了「此刻开着的那张卡长什么样」上。
|
||||
* 实测:一单在放行门连拒五拍熔断,引擎发的是一张**没有 blocks 的熔断卡**;
|
||||
* 屏上摆着的正是受理记录那份件,闸在、编辑进得去、保存按得下 ——
|
||||
* 前端找不到匹配的块,**一格都没往下送**。而人要补的恰恰是把这一单
|
||||
* 卡在那儿的那个必填格。
|
||||
*
|
||||
* ② **必填还空着就按不下提交**。同一单里,受理需求那张卡上一条料缺年用量(必填),
|
||||
* 人照样交了回来;这一单于是一路走到放行门被 L1 拦住,模型连拒五拍熔断。
|
||||
* 同一件事在提交那一刻拦下便宜得多 —— 那时缺的是哪一格就摆在他眼前。
|
||||
*
|
||||
* 测的是生产那两段本身(`writesOf` / `gateOf`),不在测里抄一份实现:
|
||||
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { gateOf, rowChanged, writesOf } from '../routes/desk.ts';
|
||||
import { missingReadings } from '../wiring/engines.ts';
|
||||
|
||||
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
|
||||
const RECORD = {
|
||||
domain: 'quote_intake',
|
||||
object: 'record',
|
||||
fields: [
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true },
|
||||
{ name: 'pn', type: 'text', primaryKey: true, label: '型号 PN', required: true },
|
||||
{ name: 'eau', type: 'number', primaryKey: false, label: '年用量 EAU', required: true },
|
||||
{ name: 'brand', type: 'text', primaryKey: false, label: '制造商', expect: true },
|
||||
],
|
||||
};
|
||||
const VERDICT = {
|
||||
domain: 'quote_intake',
|
||||
object: 'verdict',
|
||||
fields: [{ name: 'rfq_ref', type: 'text', primaryKey: true }],
|
||||
};
|
||||
|
||||
describe('件上按保存:按地址收', () => {
|
||||
it('**地址在声明里就写** —— 当下开着的是哪张卡、带没带块,一概不问', () => {
|
||||
const got = writesOf(
|
||||
[RECORD, VERDICT],
|
||||
{ 'quote_intake.record': [{ pn: 'NE555P', eau: '30000' }] },
|
||||
);
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0]?.declared.object).toBe('record');
|
||||
expect(got[0]?.lines).toEqual([{ pn: 'NE555P', eau: '30000' }]);
|
||||
});
|
||||
|
||||
it('一次收好几张表 —— 需求表与分类表在同一份件上,各写各的', () => {
|
||||
const got = writesOf([RECORD, VERDICT], {
|
||||
'quote_intake.record': [{ pn: 'A', eau: '1' }],
|
||||
'quote_intake.verdict': [{ rfq_ref: 'R1' }],
|
||||
});
|
||||
expect(got.map((x) => x.declared.object)).toEqual(['record', 'verdict']);
|
||||
});
|
||||
|
||||
it('**活动没声明过的地址不写** —— 载荷从浏览器来,门得在服务端', () => {
|
||||
expect(writesOf([RECORD], { 'customer.profile': [{ name: '改我' }] })).toEqual([]);
|
||||
});
|
||||
|
||||
it('地址不成形的不写 —— 「域.对象」两段,多一段少一段都不认', () => {
|
||||
expect(writesOf([RECORD], { record: [{ pn: 'A' }] })).toEqual([]);
|
||||
expect(writesOf([RECORD], { 'a.b.c': [{ pn: 'A' }] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('提交闸:必填没填就交不了', () => {
|
||||
const gate = (rows: Array<Record<string, unknown>>) =>
|
||||
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
|
||||
|
||||
it('一格不缺 → 交得了', () => {
|
||||
expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000 }]).ready).toBe(true);
|
||||
});
|
||||
|
||||
it('**缺必填 → 交不了,并说清差在哪一行哪一格**', () => {
|
||||
const got = gate([
|
||||
{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000 },
|
||||
{ rfq_ref: 'R1', pn: 'DAG2400L321504XZB01-R0', eau: null },
|
||||
]);
|
||||
expect(got.ready).toBe(false);
|
||||
expect(got.why).toContain('1 条必填');
|
||||
expect(got.blanks).toEqual(['R1/DAG2400L321504XZB01-R0 缺年用量 EAU']);
|
||||
});
|
||||
|
||||
it('**选填缺不拦单** —— 那一问不能省,但它不是交不了的理由', () => {
|
||||
expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: '' }]).ready).toBe(true);
|
||||
});
|
||||
|
||||
it('空串与 null 同等看待 —— 模型用 `""` 表示「原文没给」', () => {
|
||||
expect(gate([{ rfq_ref: 'R1', pn: 'NE555P', eau: '' }]).ready).toBe(false);
|
||||
});
|
||||
|
||||
it('**与放行门的 L1 同一个数** —— 两处各算各的,就会出现「卡上说能交、放行门说不行」', () => {
|
||||
const rows = [{ rfq_ref: 'R1', pn: 'A', eau: 1 }, { rfq_ref: 'R1', pn: 'B', eau: null }];
|
||||
const readings = missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]);
|
||||
// L1 判据读的就是这个键:`quote_intake_record_missing = 0`
|
||||
expect(readings['quote_intake_record_missing']).toBe(1);
|
||||
expect(gateOf(readings, [RECORD]).ready).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('只写真改了的那几行', () => {
|
||||
const row = { rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: 'TI' };
|
||||
|
||||
it('**一个字没改就不算一次修订** —— 件上按保存,整张表都回传上来,它不知道人动了哪一格', () => {
|
||||
expect(rowChanged(row, { eau: 30000, brand: 'TI' })).toBe(false);
|
||||
});
|
||||
|
||||
it('改了一格就算 —— 只按值比,不按有没有回传比', () => {
|
||||
expect(rowChanged(row, { eau: 45000, brand: 'TI' })).toBe(true);
|
||||
});
|
||||
|
||||
it('**数字与它的字符串写法相等** —— 台账里躺的是 30000,人手填回来的是 "30000"', () => {
|
||||
expect(rowChanged(row, { eau: '30000' })).toBe(false);
|
||||
});
|
||||
|
||||
it('空与没有相等 —— 空格子回传成 `""`,而台账里那一列压根没写过', () => {
|
||||
expect(rowChanged(row, { remark: '' })).toBe(false);
|
||||
expect(rowChanged({ ...row, remark: null }, { remark: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('台账里还没有这一行 → 是新增,当然算改', () => {
|
||||
expect(rowChanged(undefined, { eau: 30000 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **增删行**(2026-08-02 用户:「编辑列表应该还能增删行」)。
|
||||
*
|
||||
* 手改本来就是**兜 AI 的错**的那一手,而错不止「这一格填错了」:
|
||||
* 「这一行根本不该在」(客户点名本轮跳过的料被写进了有效清单)与「漏了一行」
|
||||
* 是同一类错 —— 只给改格子,前两种就没有出口,那一单会一路走到放行门被判据拦住,
|
||||
* 而人明明一眼就看出问题在哪(实测那一单 13 次拒回、234k token 才熔断)。
|
||||
*/
|
||||
describe('增删行:删要说出来,不靠「没回传就是删了」', () => {
|
||||
it('**删走 `drops`,与改走 `tables` 分开** —— 两份都按地址过同一道声明门', () => {
|
||||
const drops = writesOf([RECORD], { 'quote_intake.record': [{ pn: 'NE555P', rfq_ref: 'R1' }] });
|
||||
expect(drops).toHaveLength(1);
|
||||
expect(drops[0]?.lines).toEqual([{ pn: 'NE555P', rfq_ref: 'R1' }]);
|
||||
});
|
||||
|
||||
it('删的地址也得在声明里 —— 载荷从浏览器来,撤行比改格子更该有门', () => {
|
||||
expect(writesOf([RECORD], { 'customer.profile': [{ name: '撤我' }] })).toEqual([]);
|
||||
});
|
||||
|
||||
it('**回传里没提到的行不算删** —— 一次不完整的回传不该变成一次删表', () => {
|
||||
// 三行的表只回传一行:那是「这次只说了这一行」,不是「另外两行撤掉」
|
||||
const got = writesOf([RECORD], { 'quote_intake.record': [{ pn: 'A' }] });
|
||||
expect(got[0]?.lines).toHaveLength(1); // 删与不删由 drops 说了算,tables 不承载删的语义
|
||||
});
|
||||
|
||||
it('新增行照样过归型与必填 —— 加一行只填了 PN,闸照旧拦着', () => {
|
||||
const readings = missingReadings(
|
||||
{ 'mine:quote_intake.record': [{ rfq_ref: 'R1', pn: '老料', eau: 1 }, { rfq_ref: 'R1', pn: '人加的新料' }] },
|
||||
[RECORD],
|
||||
);
|
||||
const got = gateOf(readings, [RECORD]);
|
||||
expect(got.ready).toBe(false);
|
||||
expect(got.blanks).toEqual(['R1/人加的新料 缺年用量 EAU']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **自增主键由基座生成**(2026-08-02 用户:「联系人ID自增啊,不用填」)。
|
||||
*
|
||||
* 联系人编码不承载业务含义,只是「这家客户下的第几位」。让人填是白占一格
|
||||
* 他答不上来的表单(他哪知道已经有几位);让模型编更糟 —— 编重了就是覆盖别人那一行,
|
||||
* 而主数据覆盖错了后面每一单都跟着错。
|
||||
*/
|
||||
describe('自增主键:前缀-两位序号', () => {
|
||||
/** 与 `routes/desk.ts` 的 `autoKeys` 同一口径 —— 改一处这里要跟着改 */
|
||||
const gen = (others: string[], given: Record<string, string>, count: number, names: string[]) => {
|
||||
const prefix = others.map((k) => given[k] ?? '').filter((v) => v !== '').join('-');
|
||||
const out: Record<string, string> = {};
|
||||
for (const [i, n] of names.entries()) {
|
||||
const seq = String(count + 1 + i).padStart(2, '0');
|
||||
out[n] = prefix === '' ? seq : `${prefix}-${seq}`;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
it('**照种子里那个写法**:第一位联系人是 `客户编码-01`', () => {
|
||||
expect(gen(['customer_code'], { customer_code: 'CU-9001' }, 0, ['contact_code'])).toEqual({
|
||||
contact_code: 'CU-9001-01',
|
||||
});
|
||||
});
|
||||
|
||||
it('这家客户已经有两位 → 新的是 -03,不跟谁撞', () => {
|
||||
expect(gen(['customer_code'], { customer_code: 'CU-0142' }, 2, ['contact_code'])['contact_code']).toBe('CU-0142-03');
|
||||
});
|
||||
|
||||
it('同一单里连加两位 → 各自拿到 -01 / -02(后一位不覆盖前一位)', () => {
|
||||
expect(Object.values(gen(['customer_code'], { customer_code: 'CU-1' }, 0, ['a', 'b']))).toEqual([
|
||||
'CU-1-01',
|
||||
'CU-1-02',
|
||||
]);
|
||||
});
|
||||
|
||||
it('没有其余主键(它自己就是唯一那把键)→ 退回纯序号', () => {
|
||||
expect(gen([], {}, 4, ['seq'])).toEqual({ seq: '05' });
|
||||
});
|
||||
});
|
||||
@@ -8,19 +8,38 @@
|
||||
* 见 docs/debt.md · M5-2:§6.1 规定这里用 Mastra `Agent`;当前直接经 ModelGateway,
|
||||
* 端口不变,换实现时 usecase 不动。
|
||||
*/
|
||||
import type { AgentAction, EnabledSet } from '@modules/loop-engine';
|
||||
import type { AgentAction, AgentTurn, EnabledSet } from '@modules/loop-engine';
|
||||
import { TURN_ACTION_CAP } from '@modules/loop-engine';
|
||||
import type { ModelGateway } from '@modules/llm';
|
||||
import type { TaskId } from '@contracts';
|
||||
|
||||
/** Agent 只能选三型行动之一(外加规划 / 弃用 / 评估 / 提交)—— schema 把它框住 */
|
||||
const ACTION_SCHEMA = {
|
||||
/** 一个行动项 —— 三型行动之一(外加规划 / 弃用 / 评估 / 完成 / 提交),schema 把它框住 */
|
||||
const ACTION_ITEM_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['kind'],
|
||||
properties: {
|
||||
kind: { enum: ['start', 'plan', 'disable', 'judge', 'tool', 'human', 'listen', 'submit'] },
|
||||
kind: {
|
||||
enum: ['start', 'plan', 'disable', 'judge', 'tool', 'write', 'drop', 'ask', 'entry', 'decide', 'human', 'listen', 'finish', 'submit'],
|
||||
},
|
||||
itemId: { type: 'string' },
|
||||
tool: { type: 'string' },
|
||||
input: { type: 'object' },
|
||||
/* 写台账草稿:地址 + 多行(键 + 补丁)—— 一拍写完一份 BOM,不一行一拍 */
|
||||
address: { type: 'string', description: '台账地址「域.对象」—— 照可写台账字典原样抄一个,不要拆开' },
|
||||
rows: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['key', 'patch'],
|
||||
properties: { key: { type: 'object' }, patch: { type: 'object' } },
|
||||
},
|
||||
},
|
||||
/* 撤草稿:同一个 address,外加要撤的那几行主键 —— 写错了得有退路 */
|
||||
keys: {
|
||||
type: 'array',
|
||||
description: 'drop 用:要撤掉的那几行的主键,与写进去时**一字不差**',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
workOrder: { enum: ['execution', 'acceptance'] },
|
||||
/*
|
||||
* 卡面结构由 schema 框住 —— 「调用必附上下文,**裸调用不得发出**」(AM 06)。
|
||||
@@ -35,8 +54,115 @@ const ACTION_SCHEMA = {
|
||||
reason: { type: 'string' },
|
||||
basis: { type: 'array' },
|
||||
fields: { type: 'array' },
|
||||
/*
|
||||
* 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。
|
||||
*
|
||||
* 执行工单本来就收 `blocks`(契约里一直有),可这份 schema 里没有它 ——
|
||||
* 结构化出参只吐 schema 里声明过的键,于是模型写了也会被整个丢掉。
|
||||
* 实测:说明书让它派一张「带可编辑需求表」的执行单,它照做了,
|
||||
* 而人收到的卡上一张表都没有,只剩一句「请向客户补全这两颗料的缺口」。
|
||||
*
|
||||
* 最要紧的是 `table` + `editable: true`:空格子界面会自己标红「待填写」。
|
||||
*/
|
||||
blocks: {
|
||||
type: 'array',
|
||||
description: '卡面正文块:table(可 editable)/ kv / callout / ul —— 把数据摆到表单旁边',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['t'],
|
||||
properties: {
|
||||
t: { enum: ['table', 'kv', 'callout', 'ul'] },
|
||||
title: { type: 'string' },
|
||||
cols: { type: 'array', items: { type: 'string' }, description: 'table:列名' },
|
||||
rows: {
|
||||
type: 'array',
|
||||
items: { type: 'array', items: { type: 'string' } },
|
||||
description: 'table:一行一条,**没有的值给空字符串**(界面自己标红待填写);绑了 ledger 就不必给',
|
||||
},
|
||||
/*
|
||||
* 绑台账:**行让基座去铺**,别自己抄一遍。
|
||||
*
|
||||
* 人要在卡上改的是那份清单本身;而把整份塞进这一份回复里有两处死结 ——
|
||||
* 回复有长度上限,铺长了半途截断整拍作废(实测);
|
||||
* 那些行台账里已经有了,再抄一遍是拿 token 换一次抄错的机会。
|
||||
*/
|
||||
ledger: { type: 'string', description: 'table:绑台账地址(域.对象),行由基座按本单草稿铺满' },
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'table:与 cols 一一对应的台账列名 —— 绑了 ledger 就必须给',
|
||||
},
|
||||
editable: { type: 'boolean', description: 'table:让人当场改' },
|
||||
editableCols: { type: 'array', items: { type: 'integer' }, description: '只让改这几列(下标)' },
|
||||
items: { type: 'array', description: 'kv:[{k,v}];ul:字符串数组' },
|
||||
tone: { type: 'string', description: 'callout:语气' },
|
||||
text: { type: 'string', description: 'callout:正文' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
/* 补全卡 / 决策卡共用这一格:问一句(两者都是对话卡族,不是执行工单) */
|
||||
ask: { type: 'string', description: '补全卡:一句话说清缺口;决策卡:一句话说清这是个什么岔路' },
|
||||
/*
|
||||
* 决策卡的选项 —— **至少两个**。`consequence` 是「选了它接下来会发生什么」,
|
||||
* 人靠它做判断;不写等于让人猜,那就跟没给选项差不多。
|
||||
*/
|
||||
options: {
|
||||
type: 'array',
|
||||
description: '决策卡:一条路一项,至少两项',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['value', 'label'],
|
||||
properties: {
|
||||
value: { type: 'string', description: '机器认的值(回传时就是它)' },
|
||||
label: { type: 'string', description: '按钮上的话' },
|
||||
detail: { type: 'string' },
|
||||
consequence: { type: 'string', description: '选了它接下来会发生什么' },
|
||||
recommended: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
allowCustom: { type: 'boolean', description: '决策卡:允许人给选项之外的处置(附理由)' },
|
||||
/*
|
||||
* 两用一格:
|
||||
* · 补全卡(ask)—— `{key,label,hint}`,人从空白填起;
|
||||
* · 提交放行(submit)—— `{key,label,ai,ledger}`,验收报告上让人改的那几个值。
|
||||
*
|
||||
* `ai` / `ledger` **必须写进 schema**:结构化出参只吐 schema 里有的键,
|
||||
* 光在提示词里交代不算 —— 实测模型照着 schema 吐,拟值与落回地址被整个丢掉,
|
||||
* 验收报告于是只能看不能改。
|
||||
*/
|
||||
/* 数据录入卡:落哪张表、哪个主键 —— 人确认那一下基座照它写 */
|
||||
ledger: { type: 'string', description: '数据录入卡:落表地址「域.对象」' },
|
||||
entryKey: { type: 'object', description: '数据录入卡:主键值,如 {"customer_code":"C-XXX"}' },
|
||||
also: {
|
||||
type: 'array',
|
||||
description: '数据录入卡:同一次确认还要落的另几张表,每段 {title, ledger, key, fields} —— 建客户就是公司档 + 联系人档',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
fields: {
|
||||
type: 'array',
|
||||
description: '补全卡:要对方填的格子;数据录入卡:要写的那一行;提交放行:验收报告上让人改的值',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string' },
|
||||
label: { type: 'string' },
|
||||
hint: { type: 'string', description: '补全卡:格子里的灰字提示' },
|
||||
value: {
|
||||
type: 'string',
|
||||
description:
|
||||
'补全卡 / 数据录入卡:**预填值** —— 你从来件里读得出的先替人填上(公司名、联系人姓名等),' +
|
||||
'人只需核对一眼、补上你填不出的那个。读不出来的别给:填错了人还得先擦掉再写。',
|
||||
},
|
||||
required: { type: 'boolean', description: '数据录入卡:必填(空着不让确认)' },
|
||||
ai: { type: 'string', description: '提交放行:AI 拟值 —— 验收报告「已填好」的那个值' },
|
||||
ledger: { type: 'string', description: '提交放行:改完落回哪个台账地址(域.对象)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
basis: { type: 'array', description: '依据快照:键值对数组' },
|
||||
eventType: { enum: ['data', 'time'] },
|
||||
keyFilter: { type: 'object' },
|
||||
deadline: { type: 'string' },
|
||||
@@ -47,6 +173,43 @@ const ACTION_SCHEMA = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 一拍的出参:一串行动 + 一句交代。
|
||||
*
|
||||
* 行动收**数组**是按实测账改的:一拍只许一个行动时,每个 write / finish / start
|
||||
* 都单烧一次完整模型调用 —— A1 六步的活恰好 21 拍,其中 19 拍是该约束下的最短路径。
|
||||
* 交代(say/tag/view)在拍级不在行动级:它是这一拍的附带,不与行动抢位置。
|
||||
*/
|
||||
const TURN_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['say', 'actions'],
|
||||
properties: {
|
||||
/*
|
||||
* **一轮一句,不是一拍一句**(2026-08-01 用户定)。
|
||||
*
|
||||
* 这句话只在**要把控制权交出去的那一拍**才会进对话区(发卡 / 立监听 / 提交放行);
|
||||
* 埋头办事的那些拍它照旧留进工作日志,人看不到。所以别写工序播报
|
||||
* (「我这就把标写进分类表,收掉这一步」)—— 那种话攒五条也没告诉人一件事。
|
||||
* 要写就写**这一轮办完了什么、结论是什么、为什么现在要找他**。
|
||||
*/
|
||||
say: {
|
||||
type: 'string',
|
||||
description:
|
||||
'对负责人说的一句人话。**只在发卡/立监听/提交放行那一拍才会摆到他面前**:' +
|
||||
'写这一轮的结论与为什么现在需要他,别播报「我这就去写哪张表、收哪一步」。',
|
||||
},
|
||||
tag: { enum: ['guidance', 'criterion', 'conclusion'] },
|
||||
view: { type: 'string', description: '可选:换成哪张局面模版(活动清单里的名字)' },
|
||||
actions: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: TURN_ACTION_CAP,
|
||||
description: '这一拍按序执行的行动串;要看工具出参才能定的事,别写在工具后面',
|
||||
items: ACTION_ITEM_SCHEMA,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type AgentRunnerDeps = {
|
||||
readonly gateway: ModelGateway;
|
||||
/** 活动声明的自定义模型名 */
|
||||
@@ -55,35 +218,332 @@ export type AgentRunnerDeps = {
|
||||
readonly onUsage: (taskId: TaskId, tokens: number) => Promise<void>;
|
||||
};
|
||||
|
||||
/** 可写台账摊成字典:地址 · 中文标 · 主键 · 列名 —— 模型照这个填,不照想象编 */
|
||||
export type WritableLedger = Array<{
|
||||
domain: string;
|
||||
object: string;
|
||||
label: string;
|
||||
keys: string[];
|
||||
fields: Array<{ name: string; label: string; type?: string }>;
|
||||
}>;
|
||||
|
||||
export function ledgerDictionary(writable: WritableLedger): string {
|
||||
if (writable.length === 0) return '';
|
||||
return [
|
||||
'【可写台账(只准写这些地址与列名)】',
|
||||
...writable.map(
|
||||
(w) =>
|
||||
`- ${w.domain}.${w.object} ${w.label}|主键:${w.keys.join('+') || '(无)'}` +
|
||||
// 列型也摆出来:数值列写不进空串(空 = 不写这一列),它得知道
|
||||
`|列:${w.fields
|
||||
.map((f) => `${f.name}${f.label === f.name ? '' : `(${f.label})`}${f.type === undefined || f.type === 'text' ? '' : `:${f.type}`}`)
|
||||
.join('、')}`,
|
||||
),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** 本单自己写下的行 —— `mine:` 前缀那一份(与视图模版同一约定) */
|
||||
function minePart(scope: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(scope).filter(([k]) => k.startsWith('mine:')));
|
||||
}
|
||||
|
||||
/**
|
||||
* 其余作用域:**摊平字段与起单入参在前,`域.对象` 整表殿后**。
|
||||
*
|
||||
* 顺序就是生死:这一段进提示词前会按字符截断,而整表里躺着**历史所有已放行单**的行,
|
||||
* 只会随单量无限长 —— 排在前面,截断刀落下时被砍掉的是这一单自己的摊平字段(实测
|
||||
* 入参膨胀到 13k token 的那一拍就是这么来的)。整表殿后,砍也先砍旧账。
|
||||
*/
|
||||
function restPart(scope: Record<string, unknown>, options?: { withoutAttachments?: boolean }): Record<string, unknown> {
|
||||
const rest = Object.entries(scope).filter(
|
||||
([k]) =>
|
||||
!k.startsWith('mine:') &&
|
||||
!k.startsWith('focus:') &&
|
||||
!(options?.withoutAttachments === true && k === 'attachments'),
|
||||
);
|
||||
const flat = rest.filter(([k]) => !k.includes('.'));
|
||||
const tables = rest.filter(([k]) => k.includes('.'));
|
||||
return Object.fromEntries([...flat, ...tables]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件单独一段 —— **它是这一单的料本身,不许被截断刀砍**。
|
||||
*
|
||||
* 表能拆的先替它拆好(`rows`);拆不出的保留原文,并如实说明为什么。
|
||||
* 一件都没有就不摆这一段:空标题只会占位置。
|
||||
*/
|
||||
export function attachmentPart(scope: Record<string, unknown>): string {
|
||||
const list = scope['attachments'];
|
||||
if (!Array.isArray(list) || list.length === 0) return '';
|
||||
const shaped = withSheetRows(scope)['attachments'];
|
||||
return `【随件附件(基座已读过一遍;rows 是替你拆好的表,键就是表头)】\n${JSON.stringify(shaped)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件里的**表**:制表符分列的原文 → **一行一个对象**,键就是表头。
|
||||
*
|
||||
* 为什么非做不可:模型拆制表符会数错格子。连着两个 `\t` 是中间那一格空着,
|
||||
* 而它常把右边的值往前挪 —— 实测同一行栽过两次:BOM 上白纸黑字写着
|
||||
* 「Антенна · 浙江嘉康 · (空) EAU」,拆出来成了「集成电路 · (空) · 30000」:
|
||||
* 品类错了、制造商丢了、年用量凭空多了一个。**数格子是能算的事,不该问 LLM。**
|
||||
*
|
||||
* 只认「首行是表头、列数对得上」的规整表;对不上就原样留着文本,
|
||||
* 让模型自己看 —— 宁可不帮忙,不要把一份读歪的表塞给它当事实。
|
||||
*/
|
||||
export function sheetRows(text: string): Array<Record<string, string>> | undefined {
|
||||
const lines = text.split('\n').filter((l) => l.trim() !== '');
|
||||
if (lines.length < 2) return undefined;
|
||||
const header = (lines[0] ?? '').split('\t');
|
||||
if (header.length < 2) return undefined;
|
||||
const rows: Array<Record<string, string>> = [];
|
||||
for (const line of lines.slice(1)) {
|
||||
const cells = line.split('\t');
|
||||
// 列数不齐就不认这张表 —— 半张读歪的表比没有更坏
|
||||
if (cells.length !== header.length) return undefined;
|
||||
rows.push(Object.fromEntries(header.map((h, i) => [h.trim() === '' ? `列${i + 1}` : h.trim(), (cells[i] ?? '').trim()])));
|
||||
}
|
||||
return rows.length === 0 ? undefined : rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 起单入参里的附件:凡是读得出规整表的,**在文本旁边再给一份拆好的行**。
|
||||
* 文本照旧留着(口径要求、注释这类不在表里的东西还得从原文看)。
|
||||
*/
|
||||
export function withSheetRows(scope: Record<string, unknown>): Record<string, unknown> {
|
||||
const list = scope['attachments'];
|
||||
if (!Array.isArray(list)) return scope;
|
||||
let changed = false;
|
||||
const attachments = list.map((raw) => {
|
||||
if (typeof raw !== 'object' || raw === null) return raw;
|
||||
const item = raw as Record<string, unknown>;
|
||||
if (typeof item['text'] !== 'string' || item['rows'] !== undefined) return raw;
|
||||
const rows = sheetRows(item['text']);
|
||||
if (rows === undefined) return raw;
|
||||
changed = true;
|
||||
/*
|
||||
* 拆出来了就**把原文换下来**:两份并排会让这一件的体量翻倍,
|
||||
* 而它进的那一段是按字符截的 —— 塞胖了,拆好的行连同别的字段一起被砍掉
|
||||
* (实测:加了 rows 之后三轮里那一行照错不误,因为它压根没进提示词)。
|
||||
* 附件的原文本来就只是这张表;正文里的口径要求另在 `body` 里,不受影响。
|
||||
*/
|
||||
const { text, ...rest } = item;
|
||||
return { ...rest, sheet: `已拆成 ${rows.length} 行(原文 ${String(text).length} 字,拆好的在 rows 里)`, rows };
|
||||
});
|
||||
return changed ? { ...scope, attachments } : scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* **这一步声明要读的那几张表** —— 剧本的 `reads`,由引擎打上 `focus:` 前缀。
|
||||
*
|
||||
* 它单独成段、**不参与那把截断刀**:整表段是按字符截的,而截断是盲的 ——
|
||||
* 砍掉的可能恰是这一步唯一要用的那张(A1 第一步的 `customer.contact` 就这么丢过,
|
||||
* 老客户当场被当成新客户)。声明了读什么,就一定看得见什么。
|
||||
*/
|
||||
function focusPart(scope: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(scope)
|
||||
.filter(([k]) => k.startsWith('focus:'))
|
||||
.map(([k, v]) => [k.slice('focus:'.length), v]),
|
||||
);
|
||||
}
|
||||
|
||||
/** 这一单能调什么 —— 声明 × 目录编译出来的清单 */
|
||||
export type BusListing = Array<{ id: string; description: string; input: Record<string, unknown>; effect: 'read' | 'write' }>;
|
||||
|
||||
/**
|
||||
* 工具清单摊成提示词一段:id · 效应 · 说明 · 入参键名。
|
||||
*
|
||||
* 只给键名不给完整 Schema:整份 JSON Schema 铺进去会把上下文吃掉一大半,
|
||||
* 而模型真正需要知道的是「这件工具收哪几个参数」。填错了缝里会校验并如实回报。
|
||||
*/
|
||||
export function toolCatalog(tools: BusListing): string {
|
||||
if (tools.length === 0) return '';
|
||||
const keysOf = (input: Record<string, unknown>): string => {
|
||||
const properties = input['properties'];
|
||||
if (typeof properties !== 'object' || properties === null) return '(无入参)';
|
||||
const required = new Set(Array.isArray(input['required']) ? (input['required'] as unknown[]).map(String) : []);
|
||||
return Object.entries(properties as Record<string, unknown>)
|
||||
.map(([key, raw]) => {
|
||||
const spec = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
const type = String(spec['type'] ?? 'string');
|
||||
return `${key}:${type}${required.has(key) ? '*' : ''}`;
|
||||
})
|
||||
.join(', ');
|
||||
};
|
||||
return [
|
||||
'【可调工具(只有这些;写类调了不会当场生效,登记为动作意向,放行才落地)】',
|
||||
...tools.map((t) => `- ${t.id}(${t.effect === 'write' ? '写类' : '读类'}):${t.description}\n 入参:${keysOf(t.input)}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function createAgentRunner(deps: AgentRunnerDeps): {
|
||||
decide(input: { taskId: TaskId; narrative: string; enabled: EnabledSet; scope: Record<string, unknown> }): Promise<AgentAction>;
|
||||
decide(input: {
|
||||
taskId: TaskId;
|
||||
narrative: string;
|
||||
enabled: EnabledSet;
|
||||
scope: Record<string, unknown>;
|
||||
writable: WritableLedger;
|
||||
tools: BusListing;
|
||||
}): Promise<AgentTurn>;
|
||||
} {
|
||||
return {
|
||||
async decide({ taskId, narrative, enabled, scope }) {
|
||||
async decide({ taskId, narrative, enabled, scope, writable, tools }) {
|
||||
// 交集为空即断点 —— 引擎已经算好了,这里只把它讲给模型听
|
||||
const breakpoint = enabled.entries.length === 0;
|
||||
// 断点 = 无路可走:手上还开着活就不算(否则会把没干完的活丢下去等事件)
|
||||
const breakpoint = enabled.entries.length === 0 && enabled.inProgress.length === 0;
|
||||
|
||||
const prompt = [
|
||||
'你是这一单任务的案例工作者。下面是语义引擎发布的**当下可为**与**目标缺口**。',
|
||||
'你只能选一个行动,且必须落在三型行动之内:调工具 / 调人 / 立监听(外加 start、plan、disable、judge、submit)。',
|
||||
'规矩:①触达世界的动作只能 tool(写类会被登记为动作意向,放行时才落地)',
|
||||
'④调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、',
|
||||
' basis 给出判断依据(键值对数组)、fields 列出要对方回填的字段名。',
|
||||
' 只写「请处理」的卡收卡人无从下手,等于白发一张。',
|
||||
'②缺口只有人知道 → human;缺口在世界侧 → listen(必须给 deadline,ISO-8601)',
|
||||
'③成果已齐才 submit。只输出 JSON。',
|
||||
'这一拍你给**一串行动**(actions 数组,1-6 个,按序执行):write(写台账草稿)/',
|
||||
'tool(调工具)/ ask(补全卡)/ decide(决策卡)/ human(调人)/ listen(立监听),外加 start(启动须手动启动的项)、',
|
||||
'plan(规划裁量项)、disable(此单不走这条)、judge(评估语义性前提)、finish(宣告一项完成)、submit(提交放行)。',
|
||||
/*
|
||||
* **写错了有退路**(2026-08-01 加 drop)。没有它的时候实测过一次死胡同:
|
||||
* 客户说本轮跳过的那颗料被写进了有效清单,判据正确地拦住,而模型改不动 ——
|
||||
* 唯一能改的是那个数,改了别的数又不对,13 次拒回、23 拍、234k token 才熔断。
|
||||
*/
|
||||
'',
|
||||
'**写错一行就撤掉它**:`drop`(address 同 write,keys 给那几行的主键,reason 一句话)——',
|
||||
'撤的只是本单草稿层那一笔,正式版一个字都动不了。行数判据对不上时先看拒回里那份主键名单:',
|
||||
'多的那一行就在名单里对着,撤掉它比改数字对 —— **数是台账的结论,别为了过判据去改它**。',
|
||||
'主键要与写进去时一字不差(补个后缀就是另一行),撤不到基座会如实回绝。',
|
||||
'',
|
||||
'**一拍连做,别拆拍**:拍是按模型调用计费的 —— 「写记录、写结算、finish、start 下一项、接着写」',
|
||||
'一口气排进同一串;每多拆一拍就多烧一遍完整上下文。三条排串纪律:',
|
||||
'· tool 的出参你要下一拍才看得见 —— 要看结果才能定的事,别排在 tool 后面(排了也会被作废);',
|
||||
'· ask / human / listen / submit 会让本拍挂起 —— 只能是串里最后一个;',
|
||||
'· 串按序执行,谁被拒就停在谁那儿,前面的已生效。',
|
||||
'',
|
||||
'**出参要瘦** —— 你每多写一个字,人就多等一分:',
|
||||
'· write 的 patch **只带有值的列**:没有值的列一个都别写(草稿是合并语义,不写 = 不动;',
|
||||
' 写一串 "":"" 只是把回答拖长)。没变的行也别重写。',
|
||||
'· say 一句话说清即可,不复述台账内容。JSON 紧凑输出,不要缩进排版。',
|
||||
'',
|
||||
/*
|
||||
* **说话的节奏 = 一轮一句**(2026-08-01 用户定)。
|
||||
* 从前每拍一句,人在对话区看到的是连着五条工序播报;那一段本该由
|
||||
* 「AI 工作中」的办事条覆盖 —— 埋头干活时不必每动一下就报一次。
|
||||
*/
|
||||
'**说话按轮,不按拍**:设定目标 →(看现状 → 定做法 → 动手)× N 轮 → 交代一次。',
|
||||
'你的 say 只在**要把控制权交出去的那一拍**才会摆到人面前 ——',
|
||||
'发卡问人(ask)、派工单(human)、摆岔路(decide)、立监听等外部(listen)、提交放行(submit)。',
|
||||
'别的拍照旧留进工作日志,人看不到,所以:',
|
||||
'· 埋头办事的拍,say 写给日志看就行,**不必也不该**播报「我这就去写哪张表、收哪一步」;',
|
||||
'· 到了要找人的那一拍,say 写**这一轮办完了什么、结论是什么、为什么现在需要他** ——',
|
||||
' 他看到的就这一句加一张卡,前面几拍的过程他不看也不该看。',
|
||||
'',
|
||||
'规矩:',
|
||||
'① **你的成果就是台账里的行**。看懂材料、拆出条目、算出结论之后,用 write 把它们写进台账 ——',
|
||||
' 不写就等于没做:验收判据读的是台账,出口载荷也从台账推。一次 rows 可以写多行,',
|
||||
' 一份 BOM 一拍写完,不要一行一拍。写的是**草稿层**,放行前世界上什么都没变。',
|
||||
' `address` 照「可写台账」里的地址原样抄(形如 `域.对象`,别拆开);',
|
||||
' 只准写字典列出的地址与列名,编出来的列会被当场拒。**原文没有的值就别写这一列**',
|
||||
' (空着 = 这一列没有值);数值列(:number/:integer)尤其不能写空串。',
|
||||
' write 的 `itemId` 是**记账**:填正在干的那一项(它会亮进「进行中」);',
|
||||
' 挂不上(项已完成等)写也照落 —— 写的授权在台账声明,不在计划项。',
|
||||
' tool / ask 的 `itemId` 则要落在**当下可为**里;阶段(stage)只能 start,',
|
||||
' 标了「须你启动」的项要先 start 再调工具。',
|
||||
'② **系统能干的活不要交给人**:调工具就在「可调工具」里挑,照它的入参键名填 ——',
|
||||
' 把一件工具做得到的事发成一张卡问人,是白占人的时间(实测踩过:该调历史价工具,',
|
||||
' 却发了一张「请查这三个 PN 的历史价」的卡)。触达世界的动作也只能 tool',
|
||||
' (写类会被登记为动作意向,放行那一刻才真执行)。',
|
||||
'③ 调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、',
|
||||
' basis 给出判断依据(键值对数组)、fields 列出要对方回填的字段名。',
|
||||
' 只写「请处理」的卡收卡人无从下手,等于白发一张。**人答回的值在轨迹里,',
|
||||
' 看到了要用 write 落进台账** —— 系统不会替你写。',
|
||||
'④ **缺字段要人补 → ask(补全卡)**:缺口清单摆在对话区、带一张表单,人就地填齐。',
|
||||
' **能填的先替他填上**:格子给 `value` 预填(公司名、联系人姓名这类来件里写着的),',
|
||||
' 人核对一眼比从头敲快得多;你填不出来的(如内部客户编码)留空,别硬凑。',
|
||||
/*
|
||||
* 形状里**必须带 `value`** —— 这一条实测栽过:
|
||||
* 活动剧本让模型"能填的先替人填上",还给了带 value 的 JSON 骨架;
|
||||
* 模型把骨架里的 ask 一字不差抄了过来,`value` 却全是空的 ——
|
||||
* 因为**系统提示词这一行写死的形状里没有它**,活动剧本盖不过系统提示词。
|
||||
* 形状是模型真正照抄的那一份,漏一个键就等于那个键不存在。
|
||||
*/
|
||||
' 形状:{kind:"ask", itemId, ask:"一句话说清缺口", fields:[{key,label,hint?,value?}], basis:[{k,v}]}。',
|
||||
' `value` = 预填值:来件里读得出的先替人填上(公司名、联系人姓名),读不出的留空。',
|
||||
'④·2 **要往台账写一行、而那一行该由人过目 → entry(数据录入卡)**:',
|
||||
' 与补全卡的分别在**谁写库**:补全卡收回来的值要你自己再 write 一遍;',
|
||||
' 数据录入卡是**人按下确认那一刻基座直接写**,你不经手 —— 建档这类事该用它,',
|
||||
' 主数据建错一次后面每单都跟着错,那一笔该由人亲手落下。',
|
||||
' 形状:{kind:"entry", itemId, ask, ledger:"域.对象", entryKey:{主键列:值},',
|
||||
' fields:[{key,label,value?,required?}], basis:[{k,v}]}。',
|
||||
' **能填的先填上**(value),填不出的留空 —— 界面会标「待填写」;',
|
||||
' 缺了这一格这张档就是残的,就标 required:true。',
|
||||
'④" **机器判不了的判据 → judge**:验收判据里 L3/L4 那类(「吃不准降 B」「合乎业务常理」)',
|
||||
' 机检判不了,只能你当场裁:{kind:"judge", criterion:"照判据名原样写", satisfied:true/false, rationale:"凭什么"}。',
|
||||
' **提交放行前必须裁完** —— 除「人放行」外还有判据没达标时,submit 会被当场拒。',
|
||||
'④\' **岔路口要人定 → decide(决策卡)**:把几条路摆出来,人点一个 —— 不用敲字。',
|
||||
' 补全卡问**事实**(缺哪几个字段,你填);决策卡问**处置**(往哪走,你选)。',
|
||||
' 两条路都合法、选哪条是业务判断时,发这一张;别拿补全卡去问「你想怎么办」。',
|
||||
' 形状:{kind:"decide", itemId, ask, options:[{value,label,consequence?,recommended?}], basis:[{k,v}]}。',
|
||||
' `fields` 一个都不给会被拒 —— 空表单等于没问;一个缺口一个格子,label 写人话。',
|
||||
' 不要拿执行工单(human)去问几个字段 —— 那是「派一件活」,收卡的人得进待处理、',
|
||||
' 办完再回来,重得不成比例。human 留给真要人办一件事或裁断一次的场合。',
|
||||
' 缺口在世界侧 → listen(必须给 deadline,ISO-8601)。',
|
||||
'⑤ **一项干完了要自己宣告**:finish(itemId + rationale)。write / tool 只是干活,',
|
||||
' 不会自动把那一项收掉 —— 因为一项常要好几个动作才交得出成果(先逐条写记录、',
|
||||
' 再结算总数;先调工具取判据、再把判据写进台账)。都做完了再 finish。',
|
||||
'⑥ **每一拍都要带一句 `say`** —— 你是在跟负责人**当面说话**,不是在播报进度。',
|
||||
' 他正看着这一单:只写台账不说话,他的对话区从头到尾是空的,只看得见「多了一个任务」。',
|
||||
' 怎么说:',
|
||||
' · **先说这对这一单意味着什么**,再说你做了什么 —— 他关心的是单子,不是你的步骤;',
|
||||
' · **一两句、口语**。像同事扭头跟你说一声,不是写工作日报;',
|
||||
' · **别报内部词**:关键字段 / 次要字段 / 归一化阶段 / 计划项 / 条数 / 写草稿 ——',
|
||||
' 这些是系统的说法。说料号、交期、价格、客户,说他明天要拿去跟人讲的那些话;',
|
||||
' · **数字只在有意义时才报**。「三颗料都查到了历史价」有用;「写草稿 3 行」没用;',
|
||||
' · **有风险、要他拿主意、或者你打算叫人,先说出来** —— 别等他自己从表里看出来;',
|
||||
' · 别每拍一个腔调复读(「已完成X,现在进行Y」)。事情不同,话就该不同。',
|
||||
' 反例:「已登记3条需求,缺关键字段0条,直接进入归一化阶段」—— 这是报表不是说话。',
|
||||
' 正例:「三颗料都拆出来了,交期和用量都齐;NE555P 客户没给目标价,我先照历史价估。」',
|
||||
' `say` 不占额外的拍,与这一拍的行动一起给;局面变了就用 `view` 换一张画面。',
|
||||
'⑦ 成果已齐才 submit(提交放行,人验收)。submit 可带 `fields` —— 验收报告上',
|
||||
' **让人当场改**的那几个值:{key,label,ai:"你拟的值",ledger:"域.对象"}。',
|
||||
' `ai` 是「AI 已填好」的那个值,`ledger` 是他改完落回哪儿;缺一样修订就落不下去。',
|
||||
'只输出 JSON,形如 {"say":"…","tag":"conclusion","view":"…","actions":[{"kind":"write",…},{"kind":"finish",…}]}。',
|
||||
'',
|
||||
`【案例说明书与活动记忆】\n${narrative}`,
|
||||
'',
|
||||
`【当下可为(Enabled 集)】\n${
|
||||
enabled.entries.map((e) => `- ${e.id} ${e.name}(${e.type}${e.requiresManualStart ? ' · 须你启动' : ''}${e.discretionary ? ' · 裁量项' : ''})`).join('\n') || '(空)'
|
||||
}`,
|
||||
enabled.inProgress.length === 0
|
||||
? ''
|
||||
: `【进行中(你已开工,还没交出成果)】\n${enabled.inProgress
|
||||
.map((e) => `- ${e.id} ${e.name}(${e.type})`)
|
||||
.join('\n')}\n这些项要么接着干(write / tool),要么干完了就 finish —— 别丢下它们去等事件。`,
|
||||
`【目标缺口】\n${enabled.gaps.join('、') || '(无)'}`,
|
||||
enabled.judgements.length === 0
|
||||
? ''
|
||||
: `【待你评估的语义性前提】\n${enabled.judgements.map((j) => `- ${j.criterion}:${j.statement}`).join('\n')}`,
|
||||
breakpoint ? '【注意】当下可为 ∩ 目标所需 = 空 —— 这是依赖断点,请调人或立监听。' : '',
|
||||
`【任务数据】\n${JSON.stringify(scope).slice(0, 4000)}`,
|
||||
breakpoint && enabled.inProgress.length === 0
|
||||
? '【注意】当下可为与进行中都空 —— 这是依赖断点,请调人或立监听。'
|
||||
: '',
|
||||
toolCatalog(tools),
|
||||
ledgerDictionary(writable),
|
||||
/*
|
||||
* 任务数据分两半摆,**本单的摆在前面**。
|
||||
*
|
||||
* `mine:域.对象` 是这一单自己写下的行;`域.对象` 是正式版全表 ——
|
||||
* 里面有别单放行过的记录。不分开摆,Agent 会拿别单的数据当自己的结论讲出来
|
||||
* (实测:一单的交代里说的全是另外两单的分类结果)。
|
||||
*/
|
||||
`【本单已写下的行(mine:*)——「我的成果」只看这一份】\n${JSON.stringify(minePart(scope)).slice(0, 3000)}`,
|
||||
// 这一步声明要读的表:摆在整表段**之前**,且不参与下面那把截断刀
|
||||
Object.keys(focusPart(scope)).length === 0
|
||||
? ''
|
||||
: `【这一步要读的台账(剧本 reads 声明的,已为你取好)】\n${JSON.stringify(focusPart(scope))}`,
|
||||
/*
|
||||
* 附件**单独成段,且不参与那把截断刀**。
|
||||
*
|
||||
* 它是这一单的料本身 —— 被砍掉就等于让模型闭着眼拆条。
|
||||
* 表能拆的先替它拆好(`rows`,键就是表头):数制表符是能算的事,
|
||||
* 交给模型只会数错格子(实测:同一行的品类、制造商、年用量三样一起错位)。
|
||||
* 这一段与整表段分开摆,正是因为整表段按字符截 —— 混在一起,拆好的行会被砍掉。
|
||||
*/
|
||||
attachmentPart(scope),
|
||||
`【任务数据(域.对象 是全表,含别单已放行的记录 —— 别当自己的)】\n${JSON.stringify(restPart(scope, { withoutAttachments: true })).slice(0, 1800)}`,
|
||||
]
|
||||
.filter((line) => line !== '')
|
||||
.join('\n');
|
||||
@@ -93,12 +553,24 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
|
||||
const result = await deps.gateway.generate({
|
||||
model,
|
||||
prompt,
|
||||
outputSchema: ACTION_SCHEMA,
|
||||
outputSchema: TURN_SCHEMA,
|
||||
// 规划型出参要的是稳,不是花样 —— 低温少绕弯,解码就是墙上时钟
|
||||
temperature: 0.2,
|
||||
/*
|
||||
* 关思考 —— 三档都实测过(A1 同一单):
|
||||
* 由着想:拍 9-56s,最慢拍 9.8k 字隐藏思考,八成五的时间在解码没人读的字;
|
||||
* 'low':这家厂商上没用 —— 推理仍 5-9.7k 字、拍 24-45s,和不压一样;
|
||||
* 'off':拍 2-4s(十倍差)。
|
||||
* off 暴露过一次业务误判(把缺目标价当关键字段拦单)—— 治法是把规则写死进
|
||||
* 说明书(「关键字段只有 PN/EAU/交期 三个」),不是给模型留思考额度:
|
||||
* 这个架构里判断本就归判定表与说明书,模型只做编排。
|
||||
*/
|
||||
reasoning: 'off',
|
||||
taskId,
|
||||
label: 'loop-turn',
|
||||
});
|
||||
await deps.onUsage(taskId, result.usage.inputTokens + result.usage.outputTokens);
|
||||
return normalize(result.object, enabled);
|
||||
return normalizeTurn(result.object, enabled, result.text);
|
||||
} catch (error) {
|
||||
/*
|
||||
* 模型这一轮问不出结果(端点不可达、鉴权失败、超时…)。
|
||||
@@ -110,6 +582,9 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
|
||||
*/
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
// 出错也要对人交代一句:不然他只看见工位上多了一张卡,不知道为什么
|
||||
say: { text: `这一轮我没能决断:${detail}`, tag: 'conclusion' as const },
|
||||
actions: [{
|
||||
kind: 'human',
|
||||
itemId: enabled.entries[0]?.id ?? 'breakpoint',
|
||||
workOrder: 'execution',
|
||||
@@ -122,18 +597,83 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
|
||||
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
|
||||
fields: ['decision'],
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 认得的行动型 —— 与 ACTION_ITEM_SCHEMA 的 enum 同一份名单 */
|
||||
const KNOWN_KINDS = new Set([
|
||||
'start', 'plan', 'disable', 'judge', 'tool', 'write', 'drop', 'ask', 'human', 'listen', 'finish', 'submit',
|
||||
'decide',
|
||||
// 数据录入卡(第五类人类工具):人确认那一下由基座直接写库,不回到 Agent 手里
|
||||
'entry',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 一拍的产出 = 一串行动 + 一句交代。
|
||||
*
|
||||
* 三种来路都认:`{actions:[…]}`(规范形)、裸行动对象(旧形 —— 包成单元素串)、
|
||||
* 顶层就是数组。串里**不认识的项丢掉**(认识不认识由 KNOWN_KINDS 定);
|
||||
* 整串一个都不认识才退化为单张「转人裁断」卡(把模型原话摆上去)——
|
||||
* 不能让串里一个写坏的项在批中途给人甩一张卡。
|
||||
*
|
||||
* 交代**不是一型行动**:一开始把它做成 `kind: 'say'`,结果它占一整拍又不推进局面,
|
||||
* 预算下的模型理所当然跳过它 —— 对话区照旧空白(实测)。所以它是拍级的附带字段。
|
||||
*/
|
||||
export function normalizeTurn(object: unknown, enabled: EnabledSet, rawText?: string): AgentTurn {
|
||||
const raw = (Array.isArray(object) ? { actions: object } : (object ?? {})) as Record<string, unknown>;
|
||||
const text = String(raw['say'] ?? raw['text'] ?? '').trim();
|
||||
const tag = raw['tag'];
|
||||
const view = String(raw['view'] ?? '').trim();
|
||||
|
||||
const source = Array.isArray(raw['actions']) ? raw['actions'] : [raw];
|
||||
const recognized = source
|
||||
.filter((a): a is Record<string, unknown> => typeof a === 'object' && a !== null)
|
||||
.filter((a) => KNOWN_KINDS.has(String(a['kind'] ?? a['action'] ?? '')));
|
||||
const actions =
|
||||
recognized.length > 0
|
||||
? recognized.slice(0, TURN_ACTION_CAP).map((a) => normalizeAction(a, enabled, rawText))
|
||||
: [normalizeAction(source[0] ?? {}, enabled, rawText)];
|
||||
|
||||
return {
|
||||
actions,
|
||||
...(text === ''
|
||||
? {}
|
||||
: {
|
||||
say: {
|
||||
text,
|
||||
...(tag === 'guidance' || tag === 'criterion' || tag === 'conclusion' ? { tag } : {}),
|
||||
...(view === '' ? {} : { view }),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 模型出参归一为 AgentAction;不合规一律退化为「立监听等人」而不是乱动世界 */
|
||||
/**
|
||||
* 卡面兜底:模型没填全就用局面补。
|
||||
* 「调用必附上下文」是硬规矩 —— 与其发一张「请处理」,不如把当下可为与缺口摆上去,
|
||||
* 收卡的人至少知道自己站在哪儿。
|
||||
*/
|
||||
/**
|
||||
* 依据快照的兜底 —— 模型没给 `basis` 时用**当下局面**顶上。
|
||||
*
|
||||
* 与 `fillCard` 同一个道理:宁可信息略多,不发一张裸卡。
|
||||
* 摆的是这一步是什么、当下可为哪些、还缺什么 —— 都是人判断时确实用得上的,
|
||||
* 不是为了凑数把随便什么塞进去。
|
||||
*/
|
||||
function basisFromScene(enabled: EnabledSet, itemId: string): ReadonlyArray<{ k: string; v: string }> {
|
||||
const item = [...enabled.entries, ...enabled.inProgress].find((e) => e.id === itemId);
|
||||
return [
|
||||
{ k: '这一步', v: item?.name ?? itemId },
|
||||
{ k: '当下可为', v: enabled.entries.map((e) => e.name).join('、') || '(空)' },
|
||||
{ k: '目标缺口', v: enabled.gaps.join('、') || '(无)' },
|
||||
];
|
||||
}
|
||||
|
||||
function fillCard(
|
||||
card: Record<string, unknown>,
|
||||
enabled: EnabledSet,
|
||||
@@ -155,9 +695,148 @@ function fillCard(
|
||||
};
|
||||
}
|
||||
|
||||
function normalize(object: unknown, enabled: EnabledSet): AgentAction {
|
||||
/**
|
||||
* 验收报告的**可修订字段**归一 —— 只收有 key 的;`ai` 与 `ledger` 原样带过去。
|
||||
* 少了 `ai` 就没有「已填好」可改;少了 `ledger`,人改了也落不回台账。
|
||||
* 这里不替它补:补出来的地址一定是猜的,而猜错会把值写到别的对象上。
|
||||
*/
|
||||
function normalizeRevisable(raw: unknown): Array<{ key: string; label: string; ai?: string; ledger?: string }> {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: Array<{ key: string; label: string; ai?: string; ledger?: string }> = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'object' || item === null) continue;
|
||||
const f = item as Record<string, unknown>;
|
||||
const key = String(f['key'] ?? '').trim();
|
||||
if (key === '') continue;
|
||||
const ai = f['ai'] === undefined || f['ai'] === null ? undefined : String(f['ai']);
|
||||
const ledger = String(f['ledger'] ?? '').trim();
|
||||
out.push({
|
||||
key,
|
||||
label: String(f['label'] ?? key),
|
||||
...(ai === undefined ? {} : { ai }),
|
||||
...(ledger === '' ? {} : { ledger }),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 决策卡的选项归一 —— 只收有 `value` 的;`label` 缺省用 value 顶上。
|
||||
* `value` 是回传时认的那个值:没有它,人点了也对不上是哪条路。
|
||||
*/
|
||||
function normalizeOptions(raw: unknown): ReadonlyArray<{
|
||||
value: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
consequence?: string;
|
||||
recommended?: boolean;
|
||||
}> {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((o): o is Record<string, unknown> => typeof o === 'object' && o !== null && 'value' in o)
|
||||
.map((o) => ({
|
||||
value: String(o['value']),
|
||||
label: String(o['label'] ?? o['value']),
|
||||
...(o['detail'] === undefined ? {} : { detail: String(o['detail']) }),
|
||||
...(o['consequence'] === undefined ? {} : { consequence: String(o['consequence']) }),
|
||||
...(o['recommended'] === undefined ? {} : { recommended: o['recommended'] === true }),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 补全卡的格子归一 —— 只收有 key 的;`label` 缺省用 key 顶上(宁可显示字段名,不显示空白)。
|
||||
* 一个都收不到就是空数组,引擎那边会拒这一拍:「人不知道该填什么」比发一张空表单好。
|
||||
*/
|
||||
function normalizeFields(raw: unknown): Array<{ key: string; label: string; hint?: string; value?: string; required?: boolean }> {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: Array<{ key: string; label: string; hint?: string; value?: string; required?: boolean }> = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item === 'string' && item !== '') {
|
||||
out.push({ key: item, label: item });
|
||||
continue;
|
||||
}
|
||||
if (typeof item !== 'object' || item === null) continue;
|
||||
const f = item as Record<string, unknown>;
|
||||
const key = String(f['key'] ?? '').trim();
|
||||
if (key === '') continue;
|
||||
const hint = String(f['hint'] ?? f['placeholder'] ?? '').trim();
|
||||
/*
|
||||
* **预填值要跟着过来** —— 这一行是它一路上最后一个、也是最隐蔽的丢失点。
|
||||
*
|
||||
* 这个函数**逐字段重建**对象(不是原样透传),所以它没挑的键就等于不存在。
|
||||
* 实测查了一路:模型出参 schema 有 value、系统提示词的形状有 value、
|
||||
* 活动剧本给了带 value 的骨架、卡片契约有 value、engines 也搬了 value ——
|
||||
* 五处都对,而人拿到的格子还是空的,因为这里把它剥掉了。
|
||||
* 逐字段重建的函数每加一个键都得在这儿补一笔,这是它的代价。
|
||||
*/
|
||||
const value = String(f['value'] ?? '').trim();
|
||||
out.push({
|
||||
key,
|
||||
label: String(f['label'] ?? key),
|
||||
...(hint === '' ? {} : { hint }),
|
||||
...(value === '' ? {} : { value }),
|
||||
// 数据录入卡用它:必填空着不让确认(别的卡型忽略这个键)
|
||||
...(f['required'] === true ? { required: true } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 台账地址归一 —— 模型会用好几种写法说同一件事。
|
||||
*
|
||||
* 实测:字典里写的是 `quote_intake.record`(一个 token),而它会把整串塞进 `object`,
|
||||
* 或者干脆拆成空的 domain + object。宽进一点不是纵容:地址对不对,装配层那道门会验;
|
||||
* 这里只负责把「同一个意思的几种写法」收成一种,省掉一整拍无谓的驳回。
|
||||
*/
|
||||
function addressOf(raw: Record<string, unknown>): string {
|
||||
const address = String(raw['address'] ?? '').trim();
|
||||
if (address !== '') return address;
|
||||
const domain = String(raw['domain'] ?? '').trim();
|
||||
const object = String(raw['object'] ?? '').trim();
|
||||
// 整串塞进了其中一格
|
||||
if (domain.includes('.')) return domain;
|
||||
if (object.includes('.')) return object;
|
||||
return domain === '' || object === '' ? `${domain}${object}` : `${domain}.${object}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写行归一 —— 只收 `{key, patch}` 都是对象的行。
|
||||
* 形状不对的行**丢掉而不是猜**:猜错会往台账里落一行谁也说不清来路的数据;
|
||||
* 全丢完 rows 就是空,引擎那边会拒这一拍并把理由喂回来,比落错数干净。
|
||||
*/
|
||||
function normalizeRows(raw: unknown): Array<{ key: Record<string, string | number>; patch: Record<string, unknown> }> {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const rows: Array<{ key: Record<string, string | number>; patch: Record<string, unknown> }> = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'object' || item === null) continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const key = row['key'];
|
||||
const patch = row['patch'];
|
||||
if (typeof key !== 'object' || key === null || Array.isArray(key)) continue;
|
||||
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) continue;
|
||||
const typedKey: Record<string, string | number> = {};
|
||||
for (const [k, v] of Object.entries(key as Record<string, unknown>)) {
|
||||
typedKey[k] = typeof v === 'number' ? v : String(v);
|
||||
}
|
||||
rows.push({ key: typedKey, patch: patch as Record<string, unknown> });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型出参归一为 AgentAction —— 导出是为了让「宽进到什么程度」有测试守着。
|
||||
* 宽进的每一条都对应一次实测踩坑;不合规的行动仍由引擎拒,这里不放行为。
|
||||
*/
|
||||
export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?: string): AgentAction {
|
||||
const raw = (object ?? {}) as Record<string, unknown>;
|
||||
const kind = String(raw['kind'] ?? '');
|
||||
/*
|
||||
* `kind` 也认 `action` —— 实测里模型把这一格叫 `action`(其余字段一字不差全对),
|
||||
* 整个行动因此判成不合规,白发一张「请判断下一步」的卡给人。
|
||||
* 同义词宽进不是纵容:行动型本身仍由 enum 框死,合不合规照验;
|
||||
* 这里只是不让一个词的叫法差异吃掉一整拍。
|
||||
*/
|
||||
const kind = String(raw['kind'] ?? raw['action'] ?? '');
|
||||
const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? '');
|
||||
|
||||
switch (kind) {
|
||||
@@ -180,6 +859,121 @@ function normalize(object: unknown, enabled: EnabledSet): AgentAction {
|
||||
tool: String(raw['tool'] ?? ''),
|
||||
input: (raw['input'] ?? {}) as Record<string, unknown>,
|
||||
};
|
||||
case 'write':
|
||||
return { kind: 'write', itemId, address: addressOf(raw), rows: normalizeRows(raw['rows']) };
|
||||
|
||||
/*
|
||||
* 撤草稿 —— 主键那一份照收,别替它归一:`DAG…` 与 `DAG…-R0` 就是两行,
|
||||
* 替它「猜哪个才是」等于替它决定撤掉哪一行数据。撤不到基座会如实回绝。
|
||||
*/
|
||||
case 'drop':
|
||||
return {
|
||||
kind: 'drop',
|
||||
itemId,
|
||||
address: addressOf(raw),
|
||||
keys: Array.isArray(raw['keys'])
|
||||
? raw['keys'].filter((k): k is Record<string, string | number> => typeof k === 'object' && k !== null)
|
||||
: [],
|
||||
reason: String(raw['reason'] ?? raw['rationale'] ?? '写错了,撤掉'),
|
||||
};
|
||||
case 'ask': {
|
||||
/*
|
||||
* 格子与问题**两处都认**:`fields` / `ask` 在顶层,或落在 `card` 里。
|
||||
* 提示词里执行工单的写法是 `card.fields`,模型很自然把补全卡也那么写 ——
|
||||
* 实测它就是这么写的,于是每一拍都被「fields 为空」拒回来。
|
||||
* 宽进不是纵容:空不空引擎照验,这里只是不让一个位置差异吃掉一整拍。
|
||||
*/
|
||||
const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record<string, unknown>;
|
||||
return {
|
||||
kind: 'ask',
|
||||
itemId,
|
||||
ask: String(raw['ask'] ?? nested['ask'] ?? ''),
|
||||
fields: normalizeFields(raw['fields'] ?? nested['fields']),
|
||||
...(Array.isArray(raw['basis'])
|
||||
? {
|
||||
basis: raw['basis']
|
||||
.filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b)
|
||||
.map((b) => ({ k: String(b.k), v: String(b.v) })),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
case 'entry': {
|
||||
/*
|
||||
* 数据录入卡 —— 与补全卡同款的宽进(顶层或 `card` 里都认)。
|
||||
* `basis` 缺了照样用当下局面补:裸卡基座拒发,而少写一个字段不该吃掉整拍。
|
||||
*/
|
||||
const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record<string, unknown>;
|
||||
const given = Array.isArray(raw['basis'])
|
||||
? raw['basis']
|
||||
.filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b)
|
||||
.map((b) => ({ k: String(b.k), v: String(b.v) }))
|
||||
: [];
|
||||
const keyRaw = raw['entryKey'] ?? raw['key'] ?? nested['entryKey'] ?? nested['key'];
|
||||
const key: Record<string, string> = {};
|
||||
if (typeof keyRaw === 'object' && keyRaw !== null) {
|
||||
for (const [k, v] of Object.entries(keyRaw as Record<string, unknown>)) key[k] = String(v);
|
||||
}
|
||||
/*
|
||||
* **同一次确认还要落的另几张表**(2026-08-02):建一位新客户 = 公司档 + 联系人档。
|
||||
* 形状与主段一样(title / ledger / key / fields),缺哪一样就整段丢掉 ——
|
||||
* 半张段落进卡面只会让人对着一组填不下去的格子。
|
||||
*/
|
||||
const alsoRaw = raw['also'] ?? nested['also'];
|
||||
const also = (Array.isArray(alsoRaw) ? alsoRaw : [])
|
||||
.filter((g): g is Record<string, unknown> => typeof g === 'object' && g !== null)
|
||||
.map((g) => {
|
||||
const k: Record<string, string> = {};
|
||||
const kr = g['key'] ?? g['entryKey'];
|
||||
if (typeof kr === 'object' && kr !== null) {
|
||||
for (const [a, b] of Object.entries(kr as Record<string, unknown>)) k[a] = String(b);
|
||||
}
|
||||
return {
|
||||
title: String(g['title'] ?? ''),
|
||||
ledger: String(g['ledger'] ?? ''),
|
||||
key: k,
|
||||
fields: normalizeFields(g['fields']),
|
||||
};
|
||||
})
|
||||
.filter((g) => g.ledger !== '' && g.title !== '' && g.fields.length > 0);
|
||||
return {
|
||||
kind: 'entry',
|
||||
itemId,
|
||||
ask: String(raw['ask'] ?? nested['ask'] ?? ''),
|
||||
ledger: String(raw['ledger'] ?? nested['ledger'] ?? ''),
|
||||
key,
|
||||
fields: normalizeFields(raw['fields'] ?? nested['fields']),
|
||||
...(also.length === 0 ? {} : { also }),
|
||||
basis: given.length > 0 ? given : basisFromScene(enabled, itemId),
|
||||
};
|
||||
}
|
||||
case 'decide': {
|
||||
// 与补全卡同款的宽进:选项落在顶层或 `card` 里都认(位置差异不该吃掉一整拍)
|
||||
const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record<string, unknown>;
|
||||
const given = Array.isArray(raw['basis'])
|
||||
? raw['basis']
|
||||
.filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b)
|
||||
.map((b) => ({ k: String(b.k), v: String(b.v) }))
|
||||
: [];
|
||||
/*
|
||||
* **依据缺了就用当下局面补**,不要空手发出去。
|
||||
*
|
||||
* 「调用必附上下文,裸调用不得发出」是基座的硬规矩(AM 06):`basis` 为空即拒发。
|
||||
* 而模型很容易只写 ask + options 就交卷 —— 实测:连拒 6 拍直接熔断,
|
||||
* 一单就此停在那儿等人,而它想问的那句话本身完全合理。
|
||||
* 拒得对,但让一个「少写一个字段」的失误吃掉整单是不划算的:
|
||||
* 执行工单那一路早就这么办了(`fillCard`),决策卡照同一个规矩来。
|
||||
*/
|
||||
const basis = given.length > 0 ? given : basisFromScene(enabled, itemId);
|
||||
return {
|
||||
kind: 'decide',
|
||||
itemId,
|
||||
ask: String(raw['ask'] ?? nested['ask'] ?? ''),
|
||||
options: normalizeOptions(raw['options'] ?? nested['options']),
|
||||
...(raw['allowCustom'] === undefined ? {} : { allowCustom: raw['allowCustom'] === true }),
|
||||
basis,
|
||||
};
|
||||
}
|
||||
case 'human':
|
||||
return {
|
||||
kind: 'human',
|
||||
@@ -197,14 +991,37 @@ function normalize(object: unknown, enabled: EnabledSet): AgentAction {
|
||||
// 时限必配 —— 模型没给就按三天兜底,绝不留幽灵等待
|
||||
deadline: typeof raw['deadline'] === 'string' && /^P/.test(raw['deadline']) ? raw['deadline'] : 'P3D',
|
||||
};
|
||||
case 'submit':
|
||||
return { kind: 'submit', rationale: String(raw['rationale'] ?? '判据已收敛') };
|
||||
case 'finish':
|
||||
return { kind: 'finish', itemId, rationale: String(raw['rationale'] ?? '这一项的成果已交出') };
|
||||
case 'submit': {
|
||||
// 验收报告的可修订字段:顶层 fields 或 card.fields 都认(见补全卡那一处的同款注释)
|
||||
const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record<string, unknown>;
|
||||
const revisable = normalizeRevisable(raw['fields'] ?? nested['fields']);
|
||||
return {
|
||||
kind: 'submit',
|
||||
rationale: String(raw['rationale'] ?? '判据已收敛'),
|
||||
...(revisable.length === 0 ? {} : { fields: revisable }),
|
||||
};
|
||||
}
|
||||
default:
|
||||
// 看不懂就等人,不擅自触达世界
|
||||
/*
|
||||
* 看不懂就等人,不擅自触达世界。
|
||||
*
|
||||
* 但**要把模型的原话摆在卡上**:只写「模型未给出合规的行动」,收卡的人无从判断
|
||||
* 是模型跑偏了、还是行动型少了一种、还是提示词没交代清楚 —— 而这三种的处置完全不同。
|
||||
* 界面不撒谎的另一半是:不确定的时候,把手上的原始证据交出去。
|
||||
*/
|
||||
return {
|
||||
kind: 'human',
|
||||
itemId,
|
||||
card: { title: '需要你判断下一步', reason: '模型未给出合规的行动' },
|
||||
card: {
|
||||
title: '需要你判断下一步',
|
||||
reason: '模型未给出合规的行动',
|
||||
modelReply: rawText === undefined || rawText === '' ? '(空)' : rawText.slice(0, 1200),
|
||||
kindSeen: kind === '' ? '(未给 kind)' : kind,
|
||||
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
|
||||
fields: ['decision'],
|
||||
},
|
||||
workOrder: 'execution',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { evaluateCondition } from '@sfeel';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { TaskId } from '@contracts';
|
||||
|
||||
import type { EventService, TriggerMatcher } from '@modules/event';
|
||||
import { createEventService, createEventStore } from '@modules/event';
|
||||
import type { LedgerService } from '@modules/ledger';
|
||||
import type { LoopEngineService } from '@modules/loop-engine';
|
||||
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
|
||||
import type { OrgService, InFlightWorkProbe } from '@modules/org';
|
||||
import {
|
||||
@@ -39,6 +41,7 @@ import { createActivityMemoryStore, createMemoryService, createTaskMemoryStore }
|
||||
import type { ToolboxService, ToolHandler, ToolInvoker } from '@modules/toolbox';
|
||||
import {
|
||||
createSchemaValidator,
|
||||
createToolHandoffStore,
|
||||
createToolInvoker,
|
||||
createToolRegistry,
|
||||
createToolReplayStore,
|
||||
@@ -103,6 +106,12 @@ export type Container = {
|
||||
readonly registry: ActivityRegistryService;
|
||||
readonly release: ReleaseService;
|
||||
readonly task: TaskService;
|
||||
/** 案例引擎 —— 工作台按它算阶段进度;推进照旧走 task,不从这里走 */
|
||||
readonly loop: LoopEngineService;
|
||||
/** 视图闸 —— 人在件上改完数据,按同一张模版用新数据重渲一帧(`views.repush`) */
|
||||
readonly views: { repush(taskId: TaskId): Promise<boolean> };
|
||||
/** 此刻这一单的判据 —— 按当下数据现算(发卡那一刻算死的话,人补一格判据不会动) */
|
||||
readonly checks: { of(taskId: TaskId): Promise<ReadonlyArray<{ key: string; label: string; level: string; pass: boolean; settled?: boolean; rationale?: string }>> };
|
||||
readonly process: ProcessService;
|
||||
readonly trigger: TriggerService;
|
||||
readonly minute: MinuteService;
|
||||
@@ -161,6 +170,14 @@ const sfeelEvaluator = (expression: string, scope: Readonly<Record<string, unkno
|
||||
export const firstPartyToolHandlers = new Map<string, ToolHandler>(FIRST_PARTY_TOOLS);
|
||||
|
||||
export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
/*
|
||||
* 广播器**先建** —— 装配期就要往它上面接线。
|
||||
*
|
||||
* 它从前是在 `return` 里现造的,于是装配期谁也拿不到它:
|
||||
* 对话流推送、视图推送、发消息这三条**全都没有广播**,
|
||||
* 前端只好靠人点刷新(用户实测报的:任务列表 / 消息气泡 / 工作区 / 对话区都不自己动)。
|
||||
*/
|
||||
const broadcaster = createBroadcaster();
|
||||
const pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
|
||||
/*
|
||||
* 空闲连接被服务端掐断(重启、`pg_terminate_backend`、网络抖动)会以 'error' 事件抛到池上;
|
||||
@@ -233,6 +250,25 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
events: { publish: async (envelope) => void (await event.publish(envelope)) },
|
||||
clock: systemClock,
|
||||
ids,
|
||||
/*
|
||||
* 试跑判定 —— 设计台有**两条**试跑路径,认漏一条就等于没挡:
|
||||
* 单活动试跑:起的是不挂工作流的单,标 `task.origin = 'testbed'`
|
||||
* 流程包试跑:节点任务挂在工作流上,标在**流**身上 `workflow.source->>'ref'`
|
||||
* 一条 SQL 两条都认。认不出就当生产单(宁可少挡也不误挡:误挡会让真单丢主数据)。
|
||||
*/
|
||||
simulation: {
|
||||
isSimulated: async (taskId) => {
|
||||
const res = await pool.query<{ simulated: boolean }>(
|
||||
`select exists (
|
||||
select 1 from sys.task t
|
||||
left join sys.workflow w on w.id = t.workflow_id
|
||||
where t.id = $1 and (t.origin = 'testbed' or w.source->>'ref' = 'testbed')
|
||||
) as simulated`,
|
||||
[taskId],
|
||||
);
|
||||
return res.rows[0]?.simulated === true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const instances = createLlmInstanceRepository(db);
|
||||
@@ -278,6 +314,8 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
secrets,
|
||||
clock: systemClock,
|
||||
});
|
||||
/** 工具转人台(§6.2):系统里没有这件工具时,请人代办 —— 人答回的值就是这次调用的出参 */
|
||||
const toolHandoff = createToolHandoffStore(db);
|
||||
const toolInvoker = createToolInvoker({
|
||||
registry: toolRegistry,
|
||||
runtime: toolRuntime,
|
||||
@@ -334,9 +372,41 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
ids,
|
||||
});
|
||||
|
||||
/*
|
||||
* **对话流与视图推送接进广播** —— `interaction.subscribe` 从前一个订阅者都没有:
|
||||
* AI 说的每一句、推的每一帧画面都广播给了空气,前端只好靠人点刷新。
|
||||
*
|
||||
* 这里只做转接,不带业务载荷:推送说「这个任务动了」,画面照旧由服务端投影重建
|
||||
* (§9.2 前端零过程状态)。收件人是谁不在信封里,所以按任务广播 ——
|
||||
* 前端按任务键失效,负责人那一屏自然重取。
|
||||
*/
|
||||
interaction.onPush((envelope) => {
|
||||
// invalidate 信封自己说目标(task / workflow / desk);其余信封都以任务为轴
|
||||
if (envelope.kind === 'invalidate') {
|
||||
broadcaster.publish({
|
||||
kind: envelope.target === 'workflow' ? 'workflow' : 'task',
|
||||
id: envelope.id,
|
||||
reason: 'invalidate',
|
||||
});
|
||||
return;
|
||||
}
|
||||
broadcaster.publish({ kind: 'task', id: envelope.taskId, reason: envelope.kind });
|
||||
});
|
||||
|
||||
const humanTool = createHumanToolService({
|
||||
cards: createCardStore(db),
|
||||
outbox: { sendElicitation: (input) => message.sendElicitation(input) },
|
||||
outbox: {
|
||||
/*
|
||||
* 跨人卡以系统消息送达对方 —— 送到了就**广播给那个人**:
|
||||
* 他的消息气泡该立刻多一条,而不是等他自己点刷新。
|
||||
* 发消息不经事件投递(那条路才有广播),所以这里得自己说一声。
|
||||
*/
|
||||
sendElicitation: async (input) => {
|
||||
const sent = await message.sendElicitation(input);
|
||||
broadcaster.publish({ kind: 'staff', id: input.staffId, reason: 'elicitation' });
|
||||
return sent;
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
append: async (input) =>
|
||||
void (await memory.append({
|
||||
@@ -346,6 +416,17 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
},
|
||||
events: {
|
||||
publishHumanEvent: async (input) => {
|
||||
/*
|
||||
* 转人代办的回传**就是工具出参**:先落转人台,再抛事件。
|
||||
*
|
||||
* 顺序不能反 —— 事件会唤起下一拍,那一拍 invoke 必须立刻取到答案;
|
||||
* 先抛后写就会空跑一拍(拍里还是「等人」,而人其实已经答了)。
|
||||
* 卡不是转人卡时 `answer` 查无此行,静默无事发生。
|
||||
*/
|
||||
const values = input.payload['values'];
|
||||
if (typeof values === 'object' && values !== null && !Array.isArray(values)) {
|
||||
await toolHandoff.answer(input.cardId, values as Record<string, unknown>);
|
||||
}
|
||||
await event.publish({
|
||||
type: 'human',
|
||||
correlation: {
|
||||
@@ -461,7 +542,19 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
deliverable: input.deliverable as never,
|
||||
criteria: [...input.criteria],
|
||||
actions: input.actions.map((a) => ({ ...a })),
|
||||
fields: [],
|
||||
/*
|
||||
* 可修订字段照传 —— 从前这里写死 `fields: []`,是这条链上的最后一处丢值:
|
||||
* 契约声明了、submit 传了、release 转了,到这儿被一句空数组抹平,
|
||||
* 验收报告于是只能看不能改(第 12 章③)。
|
||||
*/
|
||||
fields: (input.fields ?? []).map((f) => ({ ...f })) as never,
|
||||
/*
|
||||
* 正文块照传 —— 与 `fields` 同一个坑:这个显式对象是这条链的最后一关,
|
||||
* 少写一行,上游传了一路的东西就在这里静默落地(卡面 blocks 恒空,踩过两次)。
|
||||
*/
|
||||
...(input.blocks === undefined || input.blocks.length === 0
|
||||
? {}
|
||||
: { blocks: input.blocks.map((b) => ({ ...b })) as never }),
|
||||
},
|
||||
);
|
||||
return { id: issued.order.id };
|
||||
@@ -537,6 +630,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
memory,
|
||||
toolbox,
|
||||
toolInvoker,
|
||||
toolHandoff,
|
||||
humanTool,
|
||||
interaction,
|
||||
message,
|
||||
@@ -581,7 +675,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
|
||||
return {
|
||||
config,
|
||||
broadcaster: createBroadcaster(),
|
||||
broadcaster,
|
||||
pool,
|
||||
db,
|
||||
org,
|
||||
@@ -599,6 +693,10 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
registry,
|
||||
release,
|
||||
task: engines.task,
|
||||
loop: engines.loop,
|
||||
/** 件是投影 —— 人在件上改完数据,得能按同一张模版用新数据重渲一帧(见 `views.repush`) */
|
||||
views: engines.views,
|
||||
checks: engines.checks,
|
||||
process: engines.process,
|
||||
trigger: engines.trigger,
|
||||
minute: deskElements.minute,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 装活动包 —— 独立 CLI(`pnpm --filter @app/api install-activities`)。
|
||||
*
|
||||
* 为什么不复用 `db:seed`:播种把**全套配置**都重写一遍(模型实例、工具、流程包、触发器)。
|
||||
* 开发活动时一天要装十几次,每次都顺手改一遍治理台配置太重 —— 更要紧的是,
|
||||
* 播种会按环境变量重绑模型实例,而开发机上那把密钥常常只存在治理台里、不在环境里。
|
||||
* 装活动就只装活动:读 `activities/<pkg>/dist/{manifest.json,index.js}` 过安装两审。
|
||||
*
|
||||
* 「安装恒新增」照旧:改了包内容必须升 `identity.packageVersion`,同版重装是 no-op。
|
||||
*/
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createContainer } from './container.ts';
|
||||
import { installRealActivities } from './seed-activities.ts';
|
||||
|
||||
const container = await createContainer(loadConfig(process.env));
|
||||
try {
|
||||
await installRealActivities(container);
|
||||
} finally {
|
||||
await container.close();
|
||||
}
|
||||
@@ -7,8 +7,9 @@ import { loadConfig } from './config.ts';
|
||||
import { createContainer } from './container.ts';
|
||||
import { createServer } from './server.ts';
|
||||
import { createConsumers } from './workers/consumers.ts';
|
||||
import { startDispatcher } from './workers/dispatcher.ts';
|
||||
import { startTaskAdvancer } from './workers/task-advancer.ts';
|
||||
import { dispatchOnce, startDispatcher } from './workers/dispatcher.ts';
|
||||
import { advanceOnce, startTaskAdvancer } from './workers/task-advancer.ts';
|
||||
import { registerPulse } from './workers/pulse.ts';
|
||||
|
||||
export async function boot(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
@@ -16,11 +17,20 @@ export async function boot(): Promise<void> {
|
||||
await container.org.ensureSeeded();
|
||||
|
||||
// 派发器:投递记录即队列,按目标 ID 取 advisory lock 串行消费(ADR-008)
|
||||
const dispatcher = startDispatcher({ container, consumers: createConsumers() }, config.dispatchIntervalMs);
|
||||
const dispatcherDeps = { container, consumers: createConsumers() };
|
||||
const dispatcher = startDispatcher(dispatcherDeps, config.dispatchIntervalMs);
|
||||
|
||||
// 过程在后端:运行中的任务由推进器一拍一拍推,不依赖浏览器在线
|
||||
const advancer = startTaskAdvancer({ container }, config.dispatchIntervalMs);
|
||||
|
||||
/*
|
||||
* 人一答完就踢一脚 —— **顺序有意义**:先把事件投递下去(任务才醒),再推它一拍。
|
||||
* 反过来登记的话,推进那一轮跑在投递之前,任务还没醒,白跑一轮,
|
||||
* 人还是要等下一个 2 秒。
|
||||
*/
|
||||
registerPulse(() => dispatchOnce(dispatcherDeps));
|
||||
registerPulse(() => advanceOnce({ container }));
|
||||
|
||||
const app = createServer(container);
|
||||
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||
process.stdout.write(`JCAI api · http://localhost:${info.port} · env=${config.nodeEnv}\n`);
|
||||
|
||||
@@ -21,9 +21,11 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { Agent } from '@mastra/core/agent';
|
||||
import { z } from 'zod';
|
||||
import type { TaskId } from '@contracts';
|
||||
import type { AgentAction, EnabledSet } from '@modules/loop-engine';
|
||||
import type { AgentAction, AgentTurn, EnabledSet } from '@modules/loop-engine';
|
||||
import type { ModelGateway } from '@modules/llm';
|
||||
import type { BusTool } from '@modules/toolbox';
|
||||
import { ledgerDictionary } from './agent-runner.ts';
|
||||
import type { WritableLedger } from './agent-runner.ts';
|
||||
import { buildMastraTools } from './tool-bus.ts';
|
||||
import type { ToolBusContext, ToolBusPorts } from './tool-bus.ts';
|
||||
|
||||
@@ -35,6 +37,10 @@ const TURN_RESULT = z.object({
|
||||
criterion: z.string().optional().describe('judge:评估的是哪条语义性前提'),
|
||||
satisfied: z.boolean().optional().describe('judge:满足与否'),
|
||||
rationale: z.string().optional().describe('结论理由 —— 留痕要看,写实'),
|
||||
/** 对负责人说的一句人话 —— 每一拍都要带(他正看着这一单) */
|
||||
say: z.string().optional().describe('对负责人说的一句人话:这一拍干了什么、拿到什么结论'),
|
||||
tag: z.enum(['guidance', 'criterion', 'conclusion']).optional(),
|
||||
view: z.string().optional().describe('可选:换成哪张局面模版'),
|
||||
});
|
||||
|
||||
export type MastraAgentRunnerDeps = {
|
||||
@@ -54,10 +60,18 @@ export type MastraAgentRunnerDeps = {
|
||||
};
|
||||
|
||||
export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
||||
decide(input: { taskId: TaskId; narrative: string; enabled: EnabledSet; scope: Record<string, unknown> }): Promise<AgentAction>;
|
||||
decide(input: {
|
||||
taskId: TaskId;
|
||||
narrative: string;
|
||||
enabled: EnabledSet;
|
||||
scope: Record<string, unknown>;
|
||||
writable: WritableLedger;
|
||||
/** 开环路径的工具清单来自 `busOf`(它自己会取),这里只为满足同一个端口 */
|
||||
tools?: unknown;
|
||||
}): Promise<AgentTurn>;
|
||||
} {
|
||||
return {
|
||||
async decide({ taskId, narrative, enabled, scope }) {
|
||||
async decide({ taskId, narrative, enabled, scope, writable }) {
|
||||
const modelName = await deps.modelOf(taskId);
|
||||
const base = await deps.contextOf(taskId);
|
||||
const bus = await deps.busOf(taskId);
|
||||
@@ -75,8 +89,16 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
||||
'③ **终局唯放行**:成果齐了就 submit(提交放行),由人验收 —— 你不能自己宣布完成。',
|
||||
'④ 干完活必须给出这一拍的结论(见输出格式);拿不准就 idle 并说清卡在哪。',
|
||||
'',
|
||||
'⑥ **每一拍都要带一句 say**:对负责人说人话(他正看着这一单;只干活不说话,他的对话区是空的)。',
|
||||
'⑤ **成果就是台账里的行**:干出来的结论用 ledger.write 逐行写进台账(草稿层),',
|
||||
' 不写就等于没做 —— 验收判据读的是台账,出口载荷也从台账推。只准写字典里的地址与列名。',
|
||||
'',
|
||||
`【案例说明书与记忆】\n${narrative}`,
|
||||
].join('\n');
|
||||
'',
|
||||
ledgerDictionary(writable),
|
||||
]
|
||||
.filter((line) => line !== '')
|
||||
.join('\n');
|
||||
|
||||
const endpoint = await deps.gateway.resolve(modelName);
|
||||
const provider = createOpenAICompatible({
|
||||
@@ -99,11 +121,16 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
||||
.map((e) => `- ${e.id} ${e.name}(${e.type}${e.requiresManualStart ? ' · 须你启动' : ''}${e.discretionary ? ' · 裁量项' : ''})`)
|
||||
.join('\n') || '(空)'
|
||||
}`,
|
||||
enabled.inProgress.length === 0
|
||||
? ''
|
||||
: `【进行中(已开工,未交成果)】\n${enabled.inProgress.map((e) => `- ${e.id} ${e.name}`).join('\n')}`,
|
||||
`【目标缺口】\n${enabled.gaps.join('、') || '(无)'}`,
|
||||
enabled.judgements.length === 0
|
||||
? ''
|
||||
: `【待你评估的语义性前提】\n${enabled.judgements.map((j) => `- ${j.criterion}:${j.statement}`).join('\n')}`,
|
||||
enabled.entries.length === 0 ? '【注意】当下可为 ∩ 目标所需 = 空 —— 这是依赖断点,调人或说清卡在哪。' : '',
|
||||
enabled.entries.length === 0 && enabled.inProgress.length === 0
|
||||
? '【注意】当下可为与进行中都空 —— 这是依赖断点,调人或说清卡在哪。'
|
||||
: '',
|
||||
`【任务数据】\n${JSON.stringify(scope).slice(0, 4000)}`,
|
||||
'',
|
||||
'干这一拍该干的活(可以连续调工具),最后给出结论。',
|
||||
@@ -122,11 +149,16 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
||||
if (tokens > 0) await deps.onUsage(taskId, tokens);
|
||||
|
||||
// 调过人就必须收在 asked:卡已发、监听已立,引擎只收尾挂起(再发一张就是重复调人)
|
||||
const object = (result as { object?: unknown }).object;
|
||||
const said = sayOf(object);
|
||||
if (trace.askedCardId !== undefined) {
|
||||
return { kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人,等回传' };
|
||||
return {
|
||||
actions: [{ kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人,等回传' }],
|
||||
...(said === undefined ? {} : { say: said }),
|
||||
};
|
||||
}
|
||||
|
||||
return normalizeTurn((result as { object?: unknown }).object, enabled);
|
||||
// 开环一拍 = Mastra 在拍内自主连环调工具,拍末只交一个收束行动 —— 包成单元素串
|
||||
return { actions: [normalizeTurn(object, enabled)], ...(said === undefined ? {} : { say: said }) };
|
||||
} catch (error) {
|
||||
/*
|
||||
* 这一拍问不出结果(端点不可达、鉴权失败、超时、工具连环炸)——
|
||||
@@ -137,10 +169,12 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
||||
* 不要再叠一张「模型出错」的卡上去。
|
||||
*/
|
||||
if (trace.askedCardId !== undefined) {
|
||||
return { kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人(其后模型出错)' };
|
||||
return { actions: [{ kind: 'asked', cardId: trace.askedCardId, rationale: '本回合已调人(其后模型出错)' }] };
|
||||
}
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
say: { text: `这一轮我没能决断:${detail}`, tag: 'conclusion' as const },
|
||||
actions: [{
|
||||
kind: 'human',
|
||||
itemId: enabled.entries[0]?.id ?? 'breakpoint',
|
||||
workOrder: 'execution',
|
||||
@@ -154,12 +188,27 @@ export function createMastraAgentRunner(deps: MastraAgentRunnerDeps): {
|
||||
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
|
||||
fields: ['decision'],
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 拍末那句交代 —— 每一拍都该有;没给就没有,不替它编 */
|
||||
function sayOf(object: unknown): { text: string; tag?: 'guidance' | 'criterion' | 'conclusion'; view?: string } | undefined {
|
||||
const raw = (object ?? {}) as Record<string, unknown>;
|
||||
const text = String(raw['say'] ?? '').trim();
|
||||
if (text === '') return undefined;
|
||||
const tag = raw['tag'];
|
||||
const view = String(raw['view'] ?? '').trim();
|
||||
return {
|
||||
text,
|
||||
...(tag === 'guidance' || tag === 'criterion' || tag === 'conclusion' ? { tag } : {}),
|
||||
...(view === '' ? {} : { view }),
|
||||
};
|
||||
}
|
||||
|
||||
/** 拍末结论归一为 AgentAction;给不出结论就当空拍(idle → judge 留痕),绝不乱动世界 */
|
||||
function normalizeTurn(object: unknown, enabled: EnabledSet): AgentAction {
|
||||
const raw = (object ?? {}) as Record<string, unknown>;
|
||||
|
||||
@@ -23,6 +23,13 @@ const RUN_TABLES = [
|
||||
'sys.chat_card',
|
||||
'sys.chat_message',
|
||||
'sys.system_message',
|
||||
/*
|
||||
* 沟通记录与上传件是**跑出来的**,而且两张表都没有指向 task 的外键 ——
|
||||
* `truncate ... cascade` 带不走它们(实测漏了 53 条沟通记录、1 个上传件)。
|
||||
* 漏下的是指向已删任务的孤儿:那封信还在沟通记录里躺着,点开却没有单。
|
||||
*/
|
||||
'sys.task_comm',
|
||||
'sys.upload',
|
||||
'sys.node_instance',
|
||||
// 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿
|
||||
'sys.trace_span',
|
||||
@@ -48,19 +55,40 @@ const RUN_TABLES = [
|
||||
/* 纪要是人记的,但开发期同属「跑出来的东西」—— 清库要清得干净,不留半场旧会 */
|
||||
'sys.minute',
|
||||
'sys.minute_resolution',
|
||||
/* 试跑日志是试跑的存档:单没了,存档留着只会是一堆点不进去的空目录 */
|
||||
'sys.testbed_run',
|
||||
];
|
||||
|
||||
export async function resetRuns(): Promise<void> {
|
||||
const pool = new Pool({ connectionString: loadConfig().databaseUrl, max: 2 });
|
||||
try {
|
||||
// 台账的业务表是动态建的,名字问库不写死
|
||||
/*
|
||||
* 台账的业务表是动态建的,名字问库不写死。
|
||||
*
|
||||
* **主数据留着**(2026-07-31):客户 / 项目 / 供应商 / 市场是先于活动存在的档案,
|
||||
* 活动装了卸了它都在 —— 它属于「配置好的」那一半,不是「跑出来的」。
|
||||
* 从前这里把整个 ledger schema 一并 truncate,后果是每清一次运行数据,
|
||||
* 样例客户就没了,「老客户来函」那条样例第二天就试不出来(而它本该是常在的夹具)。
|
||||
* `__object_meta` 同理:性质登记随声明走,不随跑单走;抹了它,
|
||||
* 治理台上全部表都成了「无人声明」,主数据那一区直接空掉。
|
||||
* 单据(record)、派生(derived)、没登记性质的,照旧清 —— 那些确是跑出来的。
|
||||
*/
|
||||
const ledger = await pool.query<{ name: string }>(
|
||||
`select quote_ident(schemaname) || '.' || quote_ident(tablename) as name
|
||||
from pg_tables where schemaname = 'ledger'`,
|
||||
`select quote_ident(t.schemaname) || '.' || quote_ident(t.tablename) as name
|
||||
from pg_tables t
|
||||
left join ledger.__object_meta m on m.domain || '__' || m.object = t.tablename
|
||||
where t.schemaname = 'ledger'
|
||||
and t.tablename <> '__object_meta'
|
||||
and (m.kind is null or m.kind <> 'master')`,
|
||||
);
|
||||
const all = [...RUN_TABLES, ...ledger.rows.map((r) => r.name)];
|
||||
await pool.query(`truncate table ${all.join(', ')} restart identity cascade`);
|
||||
process.stdout.write(`已清运行数据:${all.length} 张表(配置与组织数据原样保留)\n`);
|
||||
const kept = await pool.query<{ n: string }>(
|
||||
`select count(*)::text as n from ledger.__object_meta where kind = 'master'`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`已清运行数据:${all.length} 张表(配置与组织数据原样保留;主数据 ${kept.rows[0]?.n ?? '0'} 张表未动)\n`,
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
+781
-12
@@ -3,18 +3,20 @@
|
||||
*
|
||||
* 「web 是纯呈现与输入面」:这里出的每一样都是服务端投影,前端零过程状态、刷新即重建(§9.2)。
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, ChainNode, ChatMessage, Deliverable, FileDoc, TaskCard, ViewBlock } from '@contracts';
|
||||
import type { ApiError, ChainNode, ChatMessage, Deliverable, FileDoc, TaskCard, TaskId, ViewBlock, WorkOrder } from '@contracts';
|
||||
import {
|
||||
ATTRIBUTION_SIDE_LABEL,
|
||||
answerMessageRoute,
|
||||
chatCardReplySchema,
|
||||
confirmTriggerPendingRoute,
|
||||
listMessagesRoute,
|
||||
markMessageReadRoute,
|
||||
listTasksRoute,
|
||||
markCommReadRoute,
|
||||
replyChatCardRoute,
|
||||
replyWorkOrderRoute,
|
||||
saveTableRoute,
|
||||
sayRoute,
|
||||
taskDetailRoute,
|
||||
staffIdSchema,
|
||||
@@ -23,8 +25,525 @@ import {
|
||||
} from '@contracts';
|
||||
import { ReplyRejectedError } from '@modules/human-tool';
|
||||
import { advanceOrder } from '@modules/process';
|
||||
import { displayLog } from './log-lines.ts';
|
||||
import { pulse } from '../workers/pulse.ts';
|
||||
import type { Container } from '../container.ts';
|
||||
|
||||
/** drizzle 的 execute 出参在不同驱动下形状不一 —— 收在一处判 */
|
||||
function rowsOf<T>(result: unknown): T[] {
|
||||
if (Array.isArray(result)) return result as T[];
|
||||
const rows = (result as { rows?: unknown }).rows;
|
||||
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||
}
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { coerceToColumn, missingReadings } from '../wiring/engines.ts';
|
||||
|
||||
|
||||
/** 一张表收上来的样子:`{ 台账列名: 值 }`,一行一条 —— 主键列在里面,但只用来认行 */
|
||||
type EditedRow = Readonly<Record<string, string>>;
|
||||
|
||||
/** 写回的收成:改了几行、新增几行、撤了几行、哪几格归不了型 */
|
||||
type WriteBack = { rows: number; added: number; dropped: string[]; bad: string[] };
|
||||
|
||||
const nothing = (): WriteBack => ({ rows: 0, added: 0, dropped: [], bad: [] });
|
||||
const merge = (into: WriteBack, one: WriteBack): void => {
|
||||
into.rows += one.rows;
|
||||
into.added += one.added;
|
||||
into.dropped.push(...one.dropped);
|
||||
into.bad.push(...one.bad);
|
||||
};
|
||||
|
||||
/**
|
||||
* **把人改过的那几行落进本单草稿层** —— 两条路(件上按保存 / 卡上按提交)共用这一段。
|
||||
*
|
||||
* 主键列取台账声明的主键 —— 人改主键那一格等于换了一行,所以**主键不参与写回**:
|
||||
* 用它认行,不用它改行(改错一个字符就会凭空多出一行,踩过)。
|
||||
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
|
||||
*/
|
||||
async function writeEditedRows(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
declared: { domain: string; object: string; fields: ReadonlyArray<{ name: string; type: string; primaryKey: boolean }> },
|
||||
lines: readonly EditedRow[],
|
||||
): Promise<WriteBack> {
|
||||
const { domain, object } = declared;
|
||||
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
const out = nothing();
|
||||
// 这一单现有的行 —— 三处要用:补主键(件上没摆全的那几列)、认出哪几行真改了、认出哪几行是新增
|
||||
const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object);
|
||||
/*
|
||||
* **件上压根没摆的那几列主键**(A1 的 `rfq_ref` 就是:一单一个单号,不占版面)——
|
||||
* 本单现有行里那一列若全表一致,就是这一单的那个值,新增行照它填。
|
||||
* 不一致(一单跨了两个单号)就取不出来:那时新增行认不出自己是谁,如实跳过,不猜。
|
||||
*/
|
||||
const constant = (column: string): string | undefined => {
|
||||
const seen = new Set(drafts.map((d) => String(d.key[column] ?? '')));
|
||||
const only = [...seen][0];
|
||||
return seen.size === 1 && only !== undefined && only !== '' ? only : undefined;
|
||||
};
|
||||
|
||||
for (const cells of lines) {
|
||||
/*
|
||||
* 主键**只用来认行,不参与改行**:人改了主键那一格等于换了一行,
|
||||
* 落下去就是凭空多一条(实测过同一颗料在表里成了两行)。
|
||||
* 表里没摆全主键的(如单号那一列不在件上),从草稿现有的行里补 —— 认不出就跳过。
|
||||
*/
|
||||
const key: Record<string, string> = {};
|
||||
let complete = true;
|
||||
for (const k of keys) {
|
||||
const v = cells[k];
|
||||
if (v === undefined || v.trim() === '') complete = false;
|
||||
else key[k] = v;
|
||||
}
|
||||
/*
|
||||
* **人手改的值与 AI 写的值过同一道归型门**(`coerceToColumn`)。
|
||||
*
|
||||
* 不过这道门的代价实测过:夹具往「目标价」那一格填了「客户已确认」,
|
||||
* 基座原样落进草稿,到 `releaseWrite` 才被 PG 掀桌
|
||||
* (`invalid input syntax for type double precision`)—— 而那是**人签完字之后**,
|
||||
* 整单落 `blocked`。最坏的失败点就是这里:前面全对,最后一步炸。
|
||||
* 归得了就归、空值不写、归不了**跳过那一格**(别的格照落):
|
||||
* 人填错一格不该把他改对的另外八格一起丢掉。
|
||||
*/
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [name, value] of Object.entries(cells)) {
|
||||
if (keys.includes(name)) continue;
|
||||
const type = declared.fields.find((f) => f.name === name)?.type ?? 'text';
|
||||
const coerced = coerceToColumn(value, type);
|
||||
if (coerced.kind === 'value') patch[name] = coerced.value;
|
||||
else if (coerced.kind === 'reject') out.bad.push(`${name}=${value}`);
|
||||
}
|
||||
if (!complete) {
|
||||
/*
|
||||
* 主键没摆全 —— 两种情形,先按行认,认不出再按列补:
|
||||
* · 老行:件上给了 PN,单号那一列没摆 → 拿 PN 去本单现有行里找,取它的单号;
|
||||
* · **新增行**:PN 是新的,现有行里一个都对不上 → 那几列取全表一致的那个值。
|
||||
* 少了后一半,人在件上加的行会**静默丢掉**:主键凑不齐,这里一 continue 就没了,
|
||||
* 而屏上那一行还在 —— 界面说加上了,台账里没有。
|
||||
*/
|
||||
const known = drafts.find((d) => keys.every((k) => key[k] === undefined || String(d.key[k]) === key[k]));
|
||||
for (const k of keys) {
|
||||
if (key[k] !== undefined) continue;
|
||||
const v = known === undefined ? constant(k) : String(known.key[k] ?? '');
|
||||
if (v !== undefined && v !== '') key[k] = v;
|
||||
}
|
||||
}
|
||||
if (Object.keys(key).length !== keys.length) continue;
|
||||
const was = drafts.find((d) => keys.every((k) => String(d.key[k] ?? '') === key[k]));
|
||||
if (!rowChanged(was === undefined ? undefined : { ...was.key, ...was.patch }, patch)) continue;
|
||||
await container.ledger.draftWrite({ taskId, address: { domain, object }, key, patch });
|
||||
if (was === undefined) out.added += 1;
|
||||
else out.rows += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **把人在件上删掉的那几行撤出本单草稿层**。
|
||||
*
|
||||
* 撤的只是这一单还没过门的那一笔(`draftDrop`):正式版一个字都动不了,也不抛事件 ——
|
||||
* 与 Agent 的 `drop` 同一条路子。人手删行是**兜 AI 的错**的一手:
|
||||
* 客户点名本轮跳过的料被写进了有效清单,只给他改格子是改不掉的
|
||||
* (改数又与另一个数打架 —— 实测那一单 13 次拒回、234k token 才熔断)。
|
||||
*
|
||||
* 主键摆不全的一律不撤:**宁可少撤一行,不要撤错一行**。
|
||||
*/
|
||||
async function dropEditedRows(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
declared: { domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> },
|
||||
lines: readonly EditedRow[],
|
||||
): Promise<WriteBack> {
|
||||
const { domain, object } = declared;
|
||||
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
const out = nothing();
|
||||
const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object);
|
||||
|
||||
for (const cells of lines) {
|
||||
// 按件上给出的那几列主键找本单的行 —— 找不到(或不止一行)就不动
|
||||
const hit = drafts.filter((d) =>
|
||||
keys.every((k) => cells[k] === undefined || cells[k]?.trim() === '' || String(d.key[k] ?? '') === cells[k]),
|
||||
);
|
||||
const one = hit.length === 1 ? hit[0] : undefined;
|
||||
if (one === undefined) continue;
|
||||
const gone = await container.ledger.draftDrop({ taskId, address: { domain, object }, key: one.key });
|
||||
if (gone) out.dropped.push(Object.values(one.key).map(String).join('/'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **这一行真改了吗** —— 件上按一次保存,整张表的格子都回传上来(它不知道人动了哪一格)。
|
||||
*
|
||||
* 照单全写会留一条「就地修订成果:4 行」的痕,而人只改了一格 —— 日志是给人看的,
|
||||
* 那句话就成了假的(用户实测点出来的:改一格,日志说改了四行)。
|
||||
* 比的是**摊平后的整行**:主键在里面也无妨(它按定义不参与改行)。
|
||||
* 值一律转字符串再比 —— 台账里躺的是数字 30000,人手填回来的是 "30000",
|
||||
* 归型之后两者相等,可类型不同;拿类型去比会把「没改」判成「改了」。
|
||||
*/
|
||||
export function rowChanged(
|
||||
existing: Readonly<Record<string, unknown>> | undefined,
|
||||
patch: Readonly<Record<string, unknown>>,
|
||||
): boolean {
|
||||
if (existing === undefined) return true; // 台账里还没有这一行 —— 那是新增,当然算改
|
||||
return Object.entries(patch).some(([k, v]) => String(existing[k] ?? '') !== String(v ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* **自增主键由基座生成**(2026-08-02 用户:「联系人ID自增啊,不用填」)。
|
||||
*
|
||||
* 声明里标了 `auto` 的主键列不进卡面(见 `entryGroup`),值在写入这一刻补上:
|
||||
* **取同组其余主键的值作前缀,后面跟两位序号** —— `CU-9001-01`、`-02`……
|
||||
* 序号数的是同组已有的行(正式版 + 本单草稿),同一单里加两位联系人也不撞。
|
||||
* 没有其余主键(它自己就是唯一那把键)时退回纯序号。
|
||||
*
|
||||
* 为什么不让人填:他哪知道这家客户已经有几位联系人。让模型编更糟 ——
|
||||
* 编重了就是覆盖别人那一行,而主数据覆盖错了后面每一单都跟着错。
|
||||
*/
|
||||
async function autoKeys(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
declared: LedgerDeclaration | undefined,
|
||||
given: Readonly<Record<string, string>>,
|
||||
): Promise<Record<string, string>> {
|
||||
const auto = (declared?.fields ?? []).filter((f) => f.primaryKey && f.auto === true).map((f) => f.name);
|
||||
if (declared === undefined || auto.length === 0) return {};
|
||||
const others = declared.fields.filter((f) => f.primaryKey && f.auto !== true).map((f) => f.name);
|
||||
const prefix = others
|
||||
.map((k) => given[k] ?? '')
|
||||
.filter((v) => v !== '')
|
||||
.join('-');
|
||||
/*
|
||||
* 同组已有几行 —— 正式版与本单草稿都要数。只数正式版的话,
|
||||
* 同一单里连加两位联系人会拿到同一个号,后一位把前一位覆盖掉。
|
||||
*/
|
||||
const page = await container.ledger
|
||||
.taskView(taskId, {
|
||||
address: { domain: declared.domain, object: declared.object },
|
||||
where: {},
|
||||
orderBy: [],
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
})
|
||||
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
|
||||
const same = page.rows.filter((r) => others.every((k) => String(r[k] ?? '') === (given[k] ?? '')));
|
||||
const out: Record<string, string> = {};
|
||||
for (const [i, name] of auto.entries()) {
|
||||
const seq = String(same.length + 1 + i).padStart(2, '0');
|
||||
out[name] = prefix === '' ? seq : `${prefix}-${seq}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **办结之后:这一签下去,世界上发生了什么**(2026-08-02 用户定)。
|
||||
*
|
||||
* 放行卡上摆的是签字前要核的东西(判据);签完之后人关心的换成了另一件事 ——
|
||||
* 落了几行、进了哪几张表、转去哪儿了。从前两件事共用一屏判据表,
|
||||
* 签完再回看,那一摞对勾说的还是签之前的话。
|
||||
*
|
||||
* 事实取自放行那一刻的**结构化留痕**(`ledger-write` / `release-verdict` 的 detail),
|
||||
* 不是把日志那句话再解析一遍 —— 措辞一改解析就失灵,而没人会发现。
|
||||
*/
|
||||
async function landingOf(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
activityId: string,
|
||||
): Promise<
|
||||
| { rows: number; simulated: number; tables: Array<{ label: string; rows: number }>; ports: string[]; carried: string[] }
|
||||
| undefined
|
||||
> {
|
||||
const entries = await container.memory.history(taskId);
|
||||
const wrote = entries.filter((e) => e.kind === 'ledger-write' && e.detail?.['tables'] !== undefined).at(-1);
|
||||
const fired = entries.filter((e) => e.kind === 'release-verdict' && e.detail?.['ports'] !== undefined).at(-1);
|
||||
if (wrote === undefined && fired === undefined) return undefined;
|
||||
|
||||
const objects = await container.ledger.listObjects().catch(() => []);
|
||||
const manifest = await container.registry.manifestOf(activityId).catch(() => undefined);
|
||||
/*
|
||||
* **按交付物点名,不按表点名**(2026-08-02 用户当场指出:「料号分类表和受理结论
|
||||
* 不都是包含在询价受理记录的结构里的吗,为啥单独陈列?」)。
|
||||
*
|
||||
* 一份成果常常不止一张表:A1 交出的是一份询价受理记录,而它由受理记录、
|
||||
* 料号分类表、受理结论三张表承载 —— 人拿到的是**一份**,不是三份。
|
||||
* 清单里 `ledgers` 说了哪几张表归哪份成果,这里照它并;
|
||||
* 不归任何一份成果的(客户档案这类主数据)按自己的表名摆。
|
||||
*/
|
||||
const owner = new Map<string, string>();
|
||||
for (const d of manifest?.contract.requiredDeliverables ?? []) {
|
||||
for (const at of d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger])) owner.set(at, d.label);
|
||||
}
|
||||
const merged = new Map<string, number>();
|
||||
for (const raw of Array.isArray(wrote?.detail?.['tables']) ? (wrote.detail['tables'] as unknown[]) : []) {
|
||||
const t = (typeof raw === 'object' && raw !== null ? raw : {}) as { address?: unknown; rows?: unknown };
|
||||
const address = String(t.address ?? '');
|
||||
const [domain, object] = address.split('.');
|
||||
// 摆的是人读名:先看它归哪份成果,再退回表的登记名,最后才退回地址
|
||||
const label = owner.get(address) ?? objects.find((o) => o.domain === domain && o.object === object)?.label ?? address;
|
||||
if (label === '') continue;
|
||||
merged.set(label, (merged.get(label) ?? 0) + Number(t.rows ?? 0));
|
||||
}
|
||||
const tables = [...merged].map(([label, rows]) => ({ label, rows }));
|
||||
|
||||
/* 出口也摆人读名:契约里每个口都写了 `description`,那才是给人看的那一句 */
|
||||
const named = new Map<string, string>();
|
||||
for (const p of manifest?.contract.output.mandatory ?? []) named.set(p.name, p.description ?? p.name);
|
||||
for (const g of manifest?.contract.output.selective ?? []) {
|
||||
for (const p of g.ports) named.set(p.name, p.description ?? p.name);
|
||||
}
|
||||
const ports = (Array.isArray(fired?.detail?.['ports']) ? (fired.detail['ports'] as unknown[]) : [])
|
||||
.map((p) => named.get(String(p)) ?? String(p))
|
||||
.filter((p) => p !== '');
|
||||
/*
|
||||
* **带过去了什么**(2026-08-02 用户定:「流向下一个活动带了什么数据」)。
|
||||
*
|
||||
* 发射的载荷就是**已落地的那些事实**(见 `release-service` 的 `landedFacts`),
|
||||
* 摆的是列的中文名 —— 下游活动读到的正是这几个字段。
|
||||
* 只摆名字不摆值:值在成果件里,这一屏答的是「带了哪些东西过去」。
|
||||
*/
|
||||
const colLabel = new Map<string, string>();
|
||||
for (const d of manifest?.declarations.ledger ?? []) {
|
||||
for (const f of d.fields) if (f.label !== undefined && f.label !== '') colLabel.set(f.name, f.label);
|
||||
}
|
||||
const carried = (Array.isArray(fired?.detail?.['carried']) ? (fired.detail['carried'] as unknown[]) : [])
|
||||
.map((k) => colLabel.get(String(k)) ?? String(k))
|
||||
.filter((k) => k !== '');
|
||||
|
||||
return {
|
||||
rows: Number(wrote?.detail?.['rows'] ?? 0),
|
||||
simulated: Number(wrote?.detail?.['simulated'] ?? 0),
|
||||
tables,
|
||||
ports,
|
||||
carried,
|
||||
};
|
||||
}
|
||||
|
||||
/** 这一单归哪个活动、它声明过哪几张表 —— 授权面就是这份声明 */
|
||||
async function declarationsOf(container: Container, taskId: TaskId) {
|
||||
const task = await container.task.get(taskId).catch(() => undefined);
|
||||
if (task === undefined) return undefined;
|
||||
const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined);
|
||||
return manifest?.declarations.ledger;
|
||||
}
|
||||
|
||||
/**
|
||||
* 就地修订留一条痕 —— 改了几行、加了几行、撤了哪几行、哪几格没归成型。
|
||||
*
|
||||
* **撤掉的那几行要点名**:那一条不只是给人看的账,也是**下一拍模型的行动指南** ——
|
||||
* 它看不见这一屏,只看得见轨迹。不说清「这一行是人撤的」,它会照着
|
||||
* 「条目数 9 而表里 8 行」判成「少写了一行」,转头把人刚删掉的那行写回去。
|
||||
*/
|
||||
async function noteEdits(container: Container, taskId: TaskId, got: WriteBack): Promise<number> {
|
||||
const touched = got.rows + got.added + got.dropped.length;
|
||||
if (touched === 0 && got.bad.length === 0) return touched;
|
||||
const parts: string[] = [];
|
||||
if (got.rows > 0) parts.push(`改了 ${got.rows} 行`);
|
||||
if (got.added > 0) parts.push(`新增 ${got.added} 行`);
|
||||
if (got.dropped.length > 0) parts.push(`撤掉 ${got.dropped.length} 行(${got.dropped.slice(0, 5).join('、')})`);
|
||||
if (got.bad.length > 0) parts.push(`${got.bad.length} 格填的值与列型对不上,已跳过(${got.bad.slice(0, 3).join('、')})`);
|
||||
await container.memory.append({
|
||||
taskId,
|
||||
kind: 'human-input',
|
||||
title:
|
||||
`就地修订成果:${parts.join(';')}` +
|
||||
(got.dropped.length === 0 ? '' : ' —— 撤掉的是人定的,别再写回去;条目数跟着改对'),
|
||||
detail: {
|
||||
rows: got.rows,
|
||||
...(got.added === 0 ? {} : { added: got.added }),
|
||||
...(got.dropped.length === 0 ? {} : { dropped: got.dropped }),
|
||||
...(got.bad.length === 0 ? {} : { rejected: got.bad }),
|
||||
},
|
||||
// 版本戳:这条不由活动产出,与引擎那几条同一口径
|
||||
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
||||
});
|
||||
return touched;
|
||||
}
|
||||
|
||||
/**
|
||||
* **件上按「保存」** —— 按**地址**写回,不经过「当下那张卡的第几块」(2026-08-02)。
|
||||
*
|
||||
* 件里每张可改的表挂着 `data-edit="域.对象"`、每个格子挂着 `data-col="台账列名"`,
|
||||
* 地址与列名本来就在件里 —— 前端照原样收上来即可。
|
||||
*
|
||||
* 从前这条路借道工单的 `blocks[i]`:列名由那张卡给出,于是**「这份件能不能改」
|
||||
* 被绑在了「此刻开着的那张卡长什么样」上**。实测的下场:一单在放行门连拒五拍熔断,
|
||||
* 引擎发的是一张没有 `blocks` 的熔断卡,而屏上摆着的正是受理记录那份件 ——
|
||||
* 闸在、编辑进得去、保存按得下,前端却找不到匹配的块,**一格都没往下送**。
|
||||
* 人要补的恰恰是把这一单卡在那儿的那个必填格,而屏上什么反应都没有。
|
||||
*
|
||||
* 授权面改由**活动清单的台账声明**把关:不在声明里的地址一概不写 ——
|
||||
* 比「当下那张卡碰巧带了哪一块」既严格又稳定。
|
||||
*/
|
||||
async function applyViewTableEdits(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
payload: {
|
||||
tables: Readonly<Record<string, readonly EditedRow[]>>;
|
||||
drops?: Readonly<Record<string, readonly EditedRow[]>> | undefined;
|
||||
},
|
||||
): Promise<number> {
|
||||
const declarations = await declarationsOf(container, taskId);
|
||||
if (declarations === undefined) return 0;
|
||||
const got = nothing();
|
||||
/*
|
||||
* **先撤后写** —— 顺序有意义:人可能把一行删掉、又在同一次保存里加回一行同主键的。
|
||||
* 反过来做的话,新写的那一行会被紧接着的撤掉抹平,屏上有而台账里没有。
|
||||
*/
|
||||
for (const { declared, lines } of writesOf(declarations, payload.drops ?? {})) {
|
||||
merge(got, await dropEditedRows(container, taskId, declared, lines));
|
||||
}
|
||||
for (const { declared, lines } of writesOf(declarations, payload.tables)) {
|
||||
merge(got, await writeEditedRows(container, taskId, declared, lines));
|
||||
}
|
||||
return noteEdits(container, taskId, got);
|
||||
}
|
||||
|
||||
/** 台账声明的最小形状 —— 这一段只用到这几样 */
|
||||
type LedgerDeclaration = {
|
||||
domain: string;
|
||||
object: string;
|
||||
fields: ReadonlyArray<{
|
||||
name: string;
|
||||
type: string;
|
||||
primaryKey: boolean;
|
||||
label?: string | undefined;
|
||||
required?: boolean | undefined;
|
||||
/** 基座生成的内部流水号 —— 不问人、不让模型编(见 `autoKeys`) */
|
||||
auto?: boolean | undefined;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* **哪几张表准写** —— 授权面是活动清单的台账声明,不是当下那张卡带了哪一块。
|
||||
*
|
||||
* 地址对不上号(不是「域.对象」)、活动没声明过的地址,一概不写:
|
||||
* 载荷是从浏览器来的,门得在这儿,不能靠件里写着什么就信什么。
|
||||
*/
|
||||
export function writesOf(
|
||||
declarations: readonly LedgerDeclaration[],
|
||||
tables: Readonly<Record<string, readonly EditedRow[]>>,
|
||||
): Array<{ declared: LedgerDeclaration; lines: readonly EditedRow[] }> {
|
||||
const out: Array<{ declared: LedgerDeclaration; lines: readonly EditedRow[] }> = [];
|
||||
for (const [address, lines] of Object.entries(tables)) {
|
||||
const [domain, object, extra] = address.split('.');
|
||||
if (domain === undefined || object === undefined || extra !== undefined) continue;
|
||||
const declared = declarations.find((l) => l.domain === domain && l.object === object);
|
||||
if (declared === undefined) continue; // 活动没声明这张表 —— 不是它的地盘,不写
|
||||
out.push({ declared, lines });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **卡上按「提交」时带着的那张表** —— 回传里第一行是列名,其后一行一条。
|
||||
*
|
||||
* 与数据录入卡同一条规矩:该由人亲手落下的,别在中间加一道传话。
|
||||
* 从前这张表的回传只进 AI 的轨迹、由它自己 write 回去 —— 中间隔一道,
|
||||
* 它可以抄错、可以顺手改一笔(实测:人填 30000 它写回 30;人留空表示「客户不给」,
|
||||
* 它填了隔壁那条料的价)。验收页上更要紧:那一改本来就是**人对成果的修订**,
|
||||
* 过一遍 AI 就不叫修订了。
|
||||
*
|
||||
* 只认**绑了 `ledger` + `fields`** 的表:这条路上列名确实由卡给出(卡上摆的是
|
||||
* `ViewBlocks`,格子里没有 `data-col`),所以位置对应仍留在这儿。
|
||||
*/
|
||||
async function applyBoundTableEdits(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
order: WorkOrder,
|
||||
reply: { values: Readonly<Record<string, string>> },
|
||||
): Promise<number> {
|
||||
const blocks = (order as { blocks?: ReadonlyArray<Record<string, unknown>> }).blocks;
|
||||
if (blocks === undefined || blocks.length === 0) return 0;
|
||||
const declarations = await declarationsOf(container, taskId);
|
||||
if (declarations === undefined) return 0;
|
||||
|
||||
const got = nothing();
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
if (block['t'] !== 'table' || typeof block['ledger'] !== 'string' || !Array.isArray(block['fields'])) continue;
|
||||
const raw = reply.values[`table:${index}`];
|
||||
if (raw === undefined || raw.trim() === '') continue;
|
||||
let table: unknown;
|
||||
try {
|
||||
table = JSON.parse(raw);
|
||||
} catch {
|
||||
continue; // 解析不了就当没改 —— 宁可少写一次,不要把一份坏数据落进草稿
|
||||
}
|
||||
if (!Array.isArray(table) || table.length < 2) continue;
|
||||
const [domain, object] = String(block['ledger']).split('.');
|
||||
if (domain === undefined || object === undefined) continue;
|
||||
const declared = declarations.find((l) => l.domain === domain && l.object === object);
|
||||
if (declared === undefined) continue;
|
||||
const fields = (block['fields'] as unknown[]).map((f) => String(f));
|
||||
|
||||
const lines: EditedRow[] = [];
|
||||
for (const line of (table as unknown[]).slice(1)) {
|
||||
if (!Array.isArray(line)) continue;
|
||||
const cells: Record<string, string> = {};
|
||||
fields.forEach((f, i) => {
|
||||
cells[f] = String((line as unknown[])[i] ?? '');
|
||||
});
|
||||
lines.push(cells);
|
||||
}
|
||||
merge(got, await writeEditedRows(container, taskId, declared, lines));
|
||||
}
|
||||
return noteEdits(container, taskId, got);
|
||||
}
|
||||
|
||||
/**
|
||||
* **提交闸:必填还空着就交不了**(2026-08-02 用户定)。
|
||||
*
|
||||
* 「必填」由**台账声明**说了算(`fields[].required`),缺几格由基座数(草稿层现读)——
|
||||
* 与放行门那条 L1 机检 `域_对象_missing = 0` **同一个数、同一套算法**
|
||||
* (都走 `missingReadings`)。同源是要紧的:两处各算各的,就会出现
|
||||
* 「卡上说能交、放行门说不行」——那种矛盾人是判不出对错的。
|
||||
*
|
||||
* 只对**绑了台账表**的执行卡出闸:算得出才拦,算不出(卡上没绑表)就不给闸,
|
||||
* 按钮照常能按 —— **没有依据的时候不拦人**。
|
||||
*/
|
||||
async function submitGate(
|
||||
container: Container,
|
||||
taskId: TaskId,
|
||||
order: WorkOrder,
|
||||
): Promise<{ ready: boolean; why: string; blanks: string[] } | undefined> {
|
||||
if (order.mode !== 'execution' || order.done !== undefined) return undefined;
|
||||
const addresses = (order.blocks ?? [])
|
||||
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
|
||||
.map((b) => b.ledger as string);
|
||||
if (addresses.length === 0) return undefined;
|
||||
const declarations = await declarationsOf(container, taskId);
|
||||
if (declarations === undefined) return undefined;
|
||||
const watched = declarations.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
|
||||
if (watched.length === 0) return undefined;
|
||||
|
||||
// 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`)
|
||||
const mine: Record<string, unknown[]> = {};
|
||||
for (const draft of await container.ledger.taskDrafts(taskId)) {
|
||||
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
|
||||
}
|
||||
return gateOf(missingReadings(mine, watched), watched);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读数 → 闸。**数与名单都来自基座的读数**,这里只负责说人话。
|
||||
*
|
||||
* 一格不缺就是 `ready: true`(闸不出现);缺了就把「几条 / 哪几行」原样摆出来 ——
|
||||
* 只给一个按不下的灰按钮,人只能猜自己哪儿做错了。
|
||||
*/
|
||||
export function gateOf(
|
||||
readings: Readonly<Record<string, string | number>>,
|
||||
watched: ReadonlyArray<{ domain: string; object: string }>,
|
||||
): { ready: boolean; why: string; blanks: string[] } {
|
||||
let short = 0;
|
||||
const blanks: string[] = [];
|
||||
for (const { domain, object } of watched) {
|
||||
short += Number(readings[`${domain}_${object}_missing`] ?? 0);
|
||||
const keys = readings[`${domain}_${object}_missing_keys`];
|
||||
if (typeof keys === 'string' && keys !== '') blanks.push(...keys.split(';'));
|
||||
}
|
||||
return short === 0
|
||||
? { ready: true, why: '', blanks: [] }
|
||||
: { ready: false, why: `还有 ${short} 条必填没填 —— 在工作区的件上补齐才能提交`, blanks };
|
||||
}
|
||||
|
||||
export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
/** 当前登录者;缺会话即 401 */
|
||||
@@ -53,6 +572,13 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 阶段进度 —— 一行运行时读出来(阶段声明与计划项状态同在那一行)。
|
||||
* 每张卡一次读:任务清单是「我手上开着的单」,量级是个位数,与上面
|
||||
* 已有的按卡取工作流/流程包同一个形状。活动没声明阶段就没有这一格。
|
||||
*/
|
||||
const phase = await container.loop.phaseOf(task.id, task.terminal !== undefined).catch(() => undefined);
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
activity: task.title,
|
||||
@@ -61,6 +587,7 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
...(task.terminal === undefined ? {} : { terminal: task.terminal }),
|
||||
position,
|
||||
total,
|
||||
...(phase === undefined ? {} : { phase }),
|
||||
workflowSubject: subject,
|
||||
workflowState,
|
||||
startedAt: task.startedAt,
|
||||
@@ -149,6 +676,78 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
container.interaction.viewOf(taskId),
|
||||
]);
|
||||
const cards = await container.humanTool.chatCards(taskId);
|
||||
/*
|
||||
* **闸与判据都现算** —— 人在件上补一格、撤一行,按下保存,这一屏跟着重取,
|
||||
* 闸该跟着开、判据该跟着变。发卡那一刻算死是不行的:
|
||||
* 那时缺的还缺着,而他补的动作正是为了消掉它们。
|
||||
*
|
||||
* 验收卡那一摞判据从前是发卡时钉死的:人在成果件上把目标价填好,
|
||||
* 卡上照旧写着「缺目标价」—— 用户实测点出来的(「这个价格是我填写的,但是它判错了」)。
|
||||
* 现在与提交门读同一段(`criterionCheck`),两处不会各说各话。
|
||||
*/
|
||||
const live = workOrders.some((o) => o.mode === 'acceptance' && o.done === undefined)
|
||||
? await container.checks.of(taskId).catch(() => undefined)
|
||||
: undefined;
|
||||
const landed = workOrders.some((o) => o.mode === 'acceptance' && o.done !== undefined)
|
||||
? await landingOf(container, taskId, task.activityId).catch(() => undefined)
|
||||
: undefined;
|
||||
const gated = await Promise.all(
|
||||
workOrders.map(async (order) => {
|
||||
if (order.mode === 'acceptance' && order.done === undefined && live !== undefined) {
|
||||
// L5 照旧不上卡:那张卡本身就是那道关,列进去等于摆一条恒为「未达标」的项
|
||||
return { ...order, criteria: live.filter((c) => c.level !== 'L5') };
|
||||
}
|
||||
// 办结的验收卡:判据换成落地结果 —— 签完之后人关心的是「发生了什么」
|
||||
if (order.mode === 'acceptance' && order.done !== undefined && landed !== undefined) {
|
||||
return { ...order, landing: landed };
|
||||
}
|
||||
const gate = await submitGate(container, taskId, order);
|
||||
return gate === undefined ? order : { ...order, gate };
|
||||
}),
|
||||
);
|
||||
/*
|
||||
* 沟通记录 —— 一单往来的原件。附件那一格照抄基座读出来的 `note`(读出多少字 /
|
||||
* 为什么读不出),**不改写**:界面上说「已上传 ✓」而活动其实看不见里面,是撒谎。
|
||||
*/
|
||||
const commRows = rowsOf<{
|
||||
id: string;
|
||||
at: Date | string;
|
||||
direction: string;
|
||||
channel: string;
|
||||
party: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
attachments: unknown;
|
||||
note: string;
|
||||
read_at: unknown;
|
||||
}>(
|
||||
await container.db.execute(sql`
|
||||
select id, at, direction, channel, party, subject, body, attachments, note, read_at
|
||||
from sys.task_comm where task_id = ${taskId} order by at`),
|
||||
);
|
||||
const comms = commRows.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.at instanceof Date ? r.at.toISOString() : String(r.at),
|
||||
direction: r.direction === 'out' ? ('out' as const) : ('in' as const),
|
||||
channel: r.channel,
|
||||
party: r.party,
|
||||
subject: r.subject,
|
||||
body: r.body,
|
||||
// read_at 为空 = 未读;只由人按按钮改,不随打开页签自动清
|
||||
unread: r.read_at === null || r.read_at === undefined,
|
||||
attachments: (Array.isArray(r.attachments) ? r.attachments : []).map((a) => {
|
||||
const one = (typeof a === 'object' && a !== null ? a : {}) as Record<string, unknown>;
|
||||
const text = one['text'];
|
||||
return {
|
||||
name: String(one['name'] ?? '未命名'),
|
||||
...(typeof one['size'] === 'number' ? { size: one['size'] } : {}),
|
||||
...(typeof one['ref'] === 'string' ? { ref: one['ref'] } : {}),
|
||||
...(typeof one['note'] === 'string' ? { note: one['note'] } : {}),
|
||||
readable: typeof text === 'string' && text !== '',
|
||||
};
|
||||
}),
|
||||
note: r.note,
|
||||
}));
|
||||
const chain = await toChain(task);
|
||||
|
||||
/*
|
||||
@@ -218,21 +817,60 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
stages: [],
|
||||
deliverables,
|
||||
results,
|
||||
workOrders,
|
||||
comms,
|
||||
workOrders: gated,
|
||||
chat: merged,
|
||||
view: pushed?.blocks !== undefined ? [...pushed.blocks] : view,
|
||||
...(pushed?.html === undefined ? {} : { viewHtml: pushed.html }),
|
||||
log: lines.map((l) => ({
|
||||
id: l.id,
|
||||
at: l.at,
|
||||
side: l.side,
|
||||
sideLabel: ATTRIBUTION_SIDE_LABEL[l.side],
|
||||
title: l.title,
|
||||
...(l.summary === undefined ? {} : { summary: l.summary }),
|
||||
})),
|
||||
log: await displayLog(lines, container.org),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **就地改完按下「保存」** —— 写回本单草稿层,并**按新数据把件重渲一遍**。
|
||||
*
|
||||
* 两步缺一不可:
|
||||
* ① 落库 —— 不落,人改的东西只活在浏览器里,提交前一刷新就没了;
|
||||
* ② 重渲 —— 件是**服务端投影**:只读那几格是他自己敲的,而由数据算出来的东西
|
||||
* (类徽 A/B/C、分布条、结论横幅那条去向)全停在上一次渲染那一刻。
|
||||
* 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」——
|
||||
* 一半新一半旧,那不是没更新,是界面在撒谎。
|
||||
*
|
||||
* **工单不办结**:保存只是保存。放行门的位置一点没变 ——
|
||||
* 写的仍是草稿层,合并进正式版依旧只发生在人签字那一刻。
|
||||
*/
|
||||
app.post(saveTableRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
||||
const parsed = saveTableRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '保存载荷不合规' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const task = await container.task.get(taskId);
|
||||
if (task.ownerId !== staff.id) {
|
||||
const body: ApiError = { code: 'forbidden', message: '这一单不归你办' };
|
||||
return c.json(body, 403);
|
||||
}
|
||||
/*
|
||||
* **这一单还在办**才收得下改动 —— 终态之后草稿层已经并进正式版,再改就是改历史。
|
||||
* 门只有这一道:改哪张表由**活动声明**把关(见 `applyViewTableEdits`),
|
||||
* 不再问「此刻开着的是哪张卡」—— 那道门实测把人真正要补的那一格挡在了外面。
|
||||
*/
|
||||
if (task.terminal !== undefined) {
|
||||
const body: ApiError = { code: 'conflict', message: '这一单已收尾,改不动了' };
|
||||
return c.json(body, 409);
|
||||
}
|
||||
const rows = await applyViewTableEdits(container, taskId, parsed.data);
|
||||
// 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值
|
||||
if (rows > 0) await container.views.repush(taskId);
|
||||
return c.json({ ok: true, rows });
|
||||
});
|
||||
|
||||
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
|
||||
app.post(replyWorkOrderRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
@@ -247,6 +885,20 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
}
|
||||
try {
|
||||
const outcome = await container.humanTool.replyWorkOrder(parsed.data, staff.id);
|
||||
/*
|
||||
* **绑台账的可编辑表:人改了什么,基座当场写回草稿层**(2026-08-01)。
|
||||
*
|
||||
* 与数据录入卡同一条规矩:该由人亲手落下的,别在中间加一道传话。
|
||||
* 从前这张表的回传只进 AI 的轨迹,由它自己 write 回去 —— 中间隔一道,
|
||||
* 它可以抄错、可以顺手改一笔(实测:人填 30000,它写回 30;
|
||||
* 人留空表示「客户不给」,它填了隔壁那条料的价)。
|
||||
* 验收页上更要紧:那一改本来就是**人对成果的修订**,过一遍 AI 就不叫修订了。
|
||||
*
|
||||
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
|
||||
*/
|
||||
await applyBoundTableEdits(container, outcome.taskId, outcome.order, parsed.data);
|
||||
// 人答完了就接着跑,别让他对着不动的屏幕等两轮轮询(见 workers/pulse.ts)
|
||||
void pulse();
|
||||
return c.json({ ok: true, producesCompletion: outcome.producesCompletion });
|
||||
} catch (error) {
|
||||
if (error instanceof ReplyRejectedError) {
|
||||
@@ -274,7 +926,58 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
return c.json(body, 400);
|
||||
}
|
||||
try {
|
||||
await container.humanTool.replyChatCard(parsed.data, staff.id);
|
||||
const { card, taskId: cardTaskId } = await container.humanTool.replyChatCard(parsed.data, staff.id);
|
||||
/*
|
||||
* **数据录入卡:确认那一下就是写入**(2026-07-31 用户定)。
|
||||
*
|
||||
* 别的卡是「把值交给 AI,AI 再决定写什么」——中间隔一道传话,
|
||||
* 它可以抄错、可以顺手改一笔,而人不知道自己填的东西最后落成了什么。
|
||||
* 这一类不同:卡上写明落哪张表、哪个主键,人按下确认,基座**当场**落进草稿层,
|
||||
* AI 不经手。建客户档案这类事就该这样 —— 主数据建错一次,后面每一单都跟着错。
|
||||
*
|
||||
* 仍守铁律②:写的是**草稿层**,放行那一刻才并进正式版 —— 人的确认不越过放行门。
|
||||
*/
|
||||
if (parsed.data.k === 'entry' && card.k === 'entry') {
|
||||
/*
|
||||
* **一张卡可以落好几张表**(2026-08-02:建一位新客户 = 公司档 + 联系人档)。
|
||||
* 值按段隔开(`段号.列名`,主段 `0.`)—— 两张表都有 `name` 这一列,
|
||||
* 平铺就会互相盖掉,而盖掉之后没人看得出来。
|
||||
*/
|
||||
const groups = [
|
||||
{ ledger: card.ledger, key: card.key },
|
||||
...(card.also ?? []).map((g) => ({ ledger: g.ledger, key: g.key })),
|
||||
];
|
||||
const declarations = (await declarationsOf(container, cardTaskId)) ?? [];
|
||||
for (const [at, group] of groups.entries()) {
|
||||
const [domain, object] = group.ledger.split('.');
|
||||
if (domain === undefined || object === undefined) continue;
|
||||
const patch: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(parsed.data.values)) {
|
||||
const mark = `${at}.`;
|
||||
if (k.startsWith(mark)) patch[k.slice(mark.length)] = v;
|
||||
// 老形状(没段号的平键)只喂主段 —— 一张卡只有一段时它仍旧管用
|
||||
else if (at === 0 && !/^\d+\./.test(k)) patch[k] = v;
|
||||
}
|
||||
if (Object.keys(patch).length === 0) continue;
|
||||
const declared = declarations.find((l) => l.domain === domain && l.object === object);
|
||||
const key = { ...group.key, ...(await autoKeys(container, cardTaskId, declared, group.key)) };
|
||||
await container.ledger.draftWrite({ taskId: cardTaskId, address: { domain, object }, key, patch });
|
||||
}
|
||||
{
|
||||
/*
|
||||
* 这里**不再另记一条** —— `replyChatCard` 已经把这一下记全了
|
||||
* (谁、写入哪张表、几个格子各是什么)。从前两处各记一条,
|
||||
* 同一次点击在日志里出现两行:「柴浩 确认录入 customer.profile」
|
||||
* 紧跟着「entry 卡回传」,读起来像人点了两次。
|
||||
*/
|
||||
}
|
||||
}
|
||||
/*
|
||||
* 人答完了就接着跑 —— **录入卡尤其要紧**:它是人按下确认那一刻,
|
||||
* 眼睛正盯着屏幕。等两轮轮询(实测 6.8 秒)那段时间对话区一动不动,
|
||||
* 人以为没点上,会再点一次。见 `workers/pulse.ts`。
|
||||
*/
|
||||
void pulse();
|
||||
return c.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ReplyRejectedError) {
|
||||
@@ -287,6 +990,72 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
});
|
||||
|
||||
/** 自由输入 —— 人事件的第二形态:写入任务记忆,loop 作为观察;不驱动 flow 步 */
|
||||
/**
|
||||
* 标记沟通记录已读 / 未读 —— **只认按钮**。
|
||||
*
|
||||
* 不做「打开页签即已读」:那等于替人宣布他看过了,而他可能只是路过。
|
||||
* 反向也留着(已读改回未读):看了一半想留个记号是常事。
|
||||
* 只许改**自己负责的那一单**的记录 —— 别人的单轮不到你替他标已读。
|
||||
*/
|
||||
app.post(markCommReadRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
const parsed = markCommReadRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '回传不合协议', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const commId = c.req.param('commId');
|
||||
const rows = rowsOf<{ task_id: string; subject: string; read_at: unknown }>(
|
||||
await container.db.execute(sql`select task_id, subject, read_at from sys.task_comm where id = ${commId}`),
|
||||
);
|
||||
const taskId = rows[0]?.task_id;
|
||||
if (taskId === undefined) {
|
||||
const body: ApiError = { code: 'not-found', message: '沟通记录不存在' };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
const task = await container.task.get(taskIdSchema.parse(taskId));
|
||||
if (task === undefined || task.ownerId !== staff.id) {
|
||||
const body: ApiError = { code: 'forbidden', message: '这一单不归你,标不了它的沟通记录' };
|
||||
return c.json(body, 403);
|
||||
}
|
||||
/*
|
||||
* **第一次看过要留痕**(2026-07-31 用户定)——「谁在什么时候看了这封信」
|
||||
* 是回看这一单时最想知道的事之一:客户抱怨没人理他,日志里得答得出
|
||||
* 「07-31 13:34 柴浩看过了」。只记**第一次**:反复标来标去不是事实的变化,
|
||||
* 是人在做记号,记满一屏反而把真事实淹了。
|
||||
*/
|
||||
const firstRead = parsed.data.read && (rows[0]?.read_at === null || rows[0]?.read_at === undefined);
|
||||
await container.db.execute(
|
||||
parsed.data.read
|
||||
? sql`update sys.task_comm set read_at = now() where id = ${commId}`
|
||||
: sql`update sys.task_comm set read_at = null where id = ${commId}`,
|
||||
);
|
||||
if (firstRead) {
|
||||
await container.memory.append({
|
||||
taskId: taskIdSchema.parse(taskId),
|
||||
kind: 'human-input',
|
||||
// 名字**不拼进标题**:主语由日志统一按 `by` 换成名字 —— 拼死在这里,
|
||||
// 改个显示口径就得改留痕,而留痕是只增不改的
|
||||
title: '看过了邮件',
|
||||
/*
|
||||
* 主题落 `summary` 而不是塞进标题 —— 日志一行是「谁干了什么」,
|
||||
* 副行才是「哪一件」。塞进标题会让这一行长到把别的条目挤扁,
|
||||
* 而人扫日志时先看的是动作,确认是哪封信是第二眼的事。
|
||||
*/
|
||||
detail: { commId, by: staff.id, act: 'read-comm', summary: rows[0]?.subject ?? '' },
|
||||
// 版本戳:这条不由活动产出,与引擎那几条同一口径
|
||||
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
||||
});
|
||||
}
|
||||
// 气泡计数长在任务投影上 —— 标完要让那一屏自己动,不能等人再点一次
|
||||
container.broadcaster.publish({ kind: 'task', id: taskId, reason: 'comm-read' });
|
||||
return c.json({ ok: true, unread: !parsed.data.read });
|
||||
});
|
||||
|
||||
app.post(sayRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* 「治理动作本身留痕入事件流 —— 治理也被治理。」
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { extractText } from '../upload-extract.ts';
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';
|
||||
import {
|
||||
ATTRIBUTION_SIDE_LABEL,
|
||||
browseLedgerRoute,
|
||||
controlTaskRoute,
|
||||
govMetricsRoute,
|
||||
@@ -26,9 +26,17 @@ import {
|
||||
listTriggersRoute,
|
||||
registerToolRoute,
|
||||
saveProcessRoute,
|
||||
testActivityStateRoute,
|
||||
testCurrentRoute,
|
||||
testLogDetailRoute,
|
||||
testRunActivityRoute,
|
||||
testRunRoute,
|
||||
testStopRoute,
|
||||
listTestLogsRoute,
|
||||
deleteTestLogRoute,
|
||||
clearTestLogsRoute,
|
||||
downloadFileRoute,
|
||||
uploadFileRoute,
|
||||
traceOfWorkflowRoute,
|
||||
setProcessStateRoute,
|
||||
reassignTaskRoute,
|
||||
@@ -63,6 +71,7 @@ import {
|
||||
} from '@modules/process';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
import { displayLog } from './log-lines.ts';
|
||||
|
||||
/** `db.execute` 的行读取 —— Drizzle 的返回型随驱动变,窄化收在这一处 */
|
||||
function rowsOf<T>(result: unknown): T[] {
|
||||
@@ -78,8 +87,180 @@ function rowsOf<T>(result: unknown): T[] {
|
||||
* 用 `source->>'ref' = 'testbed'` 认门:**只动测试台自己起的单**,
|
||||
* 生产单一个字都不碰(它们的来源是触发器或纪要登记)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 归档一次试跑 —— **销毁之前拍下快照**(见 `0023_testbed_log.sql` 的表注)。
|
||||
*
|
||||
* 拔掉运行数据这条不变;拔之前把观测面存下来:逐拍轨迹、台账草稿、发过的卡、
|
||||
* 预算读数与终态。开发活动靠的就是「上一版跑了几拍、卡在哪一步」的比对。
|
||||
*
|
||||
* 归档失败**不挡销毁**:清场是人明确要的动作,不能因为存不下账就把仪器占着。
|
||||
* 失败如实写进 stderr —— 静默吞掉会让人以为存了,回头找不着。
|
||||
*/
|
||||
async function archiveRun(
|
||||
container: Container,
|
||||
input: {
|
||||
kind: 'activity' | 'package';
|
||||
taskIds: readonly string[];
|
||||
workflowId?: string;
|
||||
subject?: string;
|
||||
target?: string;
|
||||
/** 流程包一次试跑的终态取流自己的,不是头一个节点任务的 */
|
||||
terminal?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (input.taskIds.length === 0) return;
|
||||
try {
|
||||
const trail: { at: string; kind: string; title: string; detail?: Record<string, unknown> }[] = [];
|
||||
const rows: { address: string; key: Record<string, unknown>; patch: Record<string, unknown> }[] = [];
|
||||
const cards: { id: string; mode: string; title: string; done: boolean; result?: string }[] = [];
|
||||
const used = { steps: 0, tokens: 0, humanCalls: 0 };
|
||||
const caps = { steps: 0, tokens: 0, humanCalls: 0 };
|
||||
let head: { subject: string; target: string; terminal?: string; reason?: string; startedAt: string } | undefined;
|
||||
|
||||
for (const taskId of input.taskIds) {
|
||||
const task = await container.task.get(taskId as never);
|
||||
const [memory, drafts, orders] = await Promise.all([
|
||||
container.memory.window(taskId as never, 400),
|
||||
container.ledger.taskDrafts(taskId as never),
|
||||
container.humanTool.allWorkOrders(taskId as never),
|
||||
]);
|
||||
// 多节点合成一条:每条痕带上是哪一节点的,不然合起来读不出先后归属
|
||||
const tag = input.taskIds.length > 1 ? `${task.title} · ` : '';
|
||||
for (const m of memory) {
|
||||
trail.push({
|
||||
at: m.at,
|
||||
kind: m.kind,
|
||||
title: `${tag}${m.title}`,
|
||||
...(m.detail === undefined ? {} : { detail: m.detail }),
|
||||
});
|
||||
}
|
||||
for (const d of drafts) rows.push({ address: `${d.domain}.${d.object}`, key: d.key, patch: d.patch });
|
||||
for (const o of orders) {
|
||||
cards.push({
|
||||
id: o.id,
|
||||
mode: o.mode,
|
||||
title: `${tag}${o.title}`,
|
||||
done: o.done !== undefined,
|
||||
...(o.done?.result === undefined ? {} : { result: String(o.done.result) }),
|
||||
});
|
||||
}
|
||||
used.steps += task.used.steps;
|
||||
used.tokens += task.used.tokens;
|
||||
used.humanCalls += task.used.humanCalls;
|
||||
caps.steps += task.caps.steps;
|
||||
caps.tokens += task.caps.tokens;
|
||||
caps.humanCalls += task.caps.humanCalls;
|
||||
head ??= {
|
||||
subject: input.subject ?? task.title,
|
||||
target: input.target ?? task.activityId,
|
||||
...(input.terminal ?? task.terminal) === undefined ? {} : { terminal: input.terminal ?? task.terminal },
|
||||
...(task.reason === undefined ? {} : { reason: task.reason }),
|
||||
startedAt: task.startedAt,
|
||||
};
|
||||
}
|
||||
if (head === undefined) return;
|
||||
trail.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
||||
|
||||
await container.db.execute(sql`
|
||||
insert into sys.testbed_run
|
||||
(id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps, trail, drafts, cards, started_at)
|
||||
values (
|
||||
${crypto.randomUUID()}, ${input.kind}, ${head.subject}, ${head.target},
|
||||
${input.taskIds[0] ?? null}, ${input.workflowId ?? null}, ${head.terminal ?? null}, ${head.reason ?? null},
|
||||
${JSON.stringify(used)}::jsonb, ${JSON.stringify(caps)}::jsonb,
|
||||
${JSON.stringify(trail)}::jsonb, ${JSON.stringify(rows)}::jsonb, ${JSON.stringify(cards)}::jsonb,
|
||||
${head.startedAt}
|
||||
)`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`[testbed] 归档失败(照旧销毁):${error instanceof Error ? error.message : String(error)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 试跑日志的库行 —— snake 出库、camel 出网,映射收在一处 */
|
||||
type TestLogDbRow = {
|
||||
id: string;
|
||||
kind: string;
|
||||
subject: string;
|
||||
target: string;
|
||||
task_id: string | null;
|
||||
workflow_id: string | null;
|
||||
terminal: string | null;
|
||||
reason: string | null;
|
||||
used: Record<string, number>;
|
||||
caps: Record<string, number>;
|
||||
trail_count: number;
|
||||
draft_count: number;
|
||||
card_count: number;
|
||||
started_at: Date | string | null;
|
||||
archived_at: Date | string;
|
||||
};
|
||||
|
||||
const iso = (v: Date | string | null): string | undefined =>
|
||||
v === null ? undefined : v instanceof Date ? v.toISOString() : String(v);
|
||||
|
||||
function toTestLogRow(r: TestLogDbRow): Record<string, unknown> {
|
||||
return {
|
||||
id: r.id,
|
||||
kind: r.kind === 'package' ? 'package' : 'activity',
|
||||
subject: r.subject,
|
||||
target: r.target,
|
||||
...(r.task_id === null ? {} : { taskId: r.task_id }),
|
||||
...(r.workflow_id === null ? {} : { workflowId: r.workflow_id }),
|
||||
...(r.terminal === null ? {} : { terminal: r.terminal }),
|
||||
...(r.reason === null ? {} : { reason: r.reason }),
|
||||
used: r.used ?? {},
|
||||
caps: r.caps ?? {},
|
||||
trailCount: Number(r.trail_count ?? 0),
|
||||
draftCount: Number(r.draft_count ?? 0),
|
||||
cardCount: Number(r.card_count ?? 0),
|
||||
...(iso(r.started_at) === undefined ? {} : { startedAt: iso(r.started_at) }),
|
||||
archivedAt: iso(r.archived_at) ?? new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 一单任务的全部运行痕 —— 拔试跑单要拔干净:留在别人工作台上的卡最讨嫌 */
|
||||
async function destroyTask(container: Container, taskId: string): Promise<void> {
|
||||
for (const table of [
|
||||
'work_order',
|
||||
'chat_card',
|
||||
'chat_message',
|
||||
'task_memory',
|
||||
'task_memory_seal',
|
||||
'task_view',
|
||||
'flow_state',
|
||||
'loop_state',
|
||||
'action_intent',
|
||||
'listener',
|
||||
'tool_handoff',
|
||||
]) {
|
||||
await container.db.execute(sql`delete from sys.${sql.raw(table)} where task_id = ${taskId}`);
|
||||
}
|
||||
// 草稿层是任务级的:试跑写下的草稿不该留在库里冒充别单的读己之写
|
||||
await container.db.execute(sql`delete from ledger.__draft where task_id = ${taskId}`);
|
||||
await container.db.execute(sql`delete from sys.trace_span where task_id = ${taskId}`);
|
||||
await container.db.execute(sql`delete from sys.task where id = ${taskId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拔掉**单活动试跑**的残留 —— 认的是 `origin = 'testbed'` 且不挂工作流的那些单。
|
||||
* 不挂工作流是关键:挂着的那批归 `destroySimulations` 按流拔,两边不重不漏。
|
||||
*/
|
||||
async function destroyActivityRuns(container: Container, taskId?: string): Promise<number> {
|
||||
const scope = taskId === undefined || taskId === '' ? sql`` : sql` and id = ${taskId}`;
|
||||
const rows = await container.db.execute<{ id: string }>(
|
||||
sql`select id from sys.task where origin = 'testbed' and workflow_id is null${scope}`,
|
||||
);
|
||||
const ids = rowsOf<{ id: string }>(rows).map((r) => r.id);
|
||||
for (const id of ids) {
|
||||
await archiveRun(container, { kind: 'activity', taskIds: [id] });
|
||||
await destroyTask(container, id);
|
||||
}
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
async function destroySimulations(container: Container, workflowId?: string): Promise<number> {
|
||||
const scope = workflowId === undefined ? sql`` : sql` and id = ${workflowId}`;
|
||||
const scope = workflowId === undefined || workflowId === '' ? sql`` : sql` and id = ${workflowId}`;
|
||||
const wfs = await container.db.execute<{ id: string }>(
|
||||
sql`select id from sys.workflow where source->>'ref' = 'testbed'${scope}`,
|
||||
);
|
||||
@@ -91,6 +272,18 @@ async function destroySimulations(container: Container, workflowId?: string): Pr
|
||||
const tasks = await container.db.execute<{ id: string }>(sql`select id from sys.task where workflow_id = ${id}`);
|
||||
const taskIds = rowsOf<{ id: string }>(tasks).map((r) => r.id);
|
||||
taskCount += taskIds.length;
|
||||
// 一次试跑 = 一条账:名下节点任务合成一条,先归档再拔
|
||||
const head = rowsOf<{ subject: string; package_id: string; state: string }>(
|
||||
await container.db.execute<{ subject: string; package_id: string; state: string }>(
|
||||
sql`select subject, package_id, state from sys.workflow where id = ${id}`,
|
||||
),
|
||||
)[0];
|
||||
await archiveRun(container, {
|
||||
kind: 'package',
|
||||
taskIds,
|
||||
workflowId: id,
|
||||
...(head === undefined ? {} : { subject: head.subject, target: head.package_id, terminal: head.state }),
|
||||
});
|
||||
for (const taskId of taskIds) {
|
||||
await container.db.execute(sql`delete from sys.work_order where task_id = ${taskId}`);
|
||||
await container.db.execute(sql`delete from sys.chat_card where task_id = ${taskId}`);
|
||||
@@ -292,7 +485,14 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId')));
|
||||
return c.json({
|
||||
log: lines.map((l) => ({ id: l.id, at: l.at, sideLabel: ATTRIBUTION_SIDE_LABEL[l.side], title: l.title })),
|
||||
// 与工作台同一份日志、同一套口径:章是四字动作,内容带主语
|
||||
log: (await displayLog(lines, container.org)).map((l) => ({
|
||||
id: l.id,
|
||||
at: l.at,
|
||||
sideLabel: l.sideLabel,
|
||||
action: l.action,
|
||||
title: l.title,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -371,6 +571,13 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
data,
|
||||
...(Object.keys(assignments).length === 0 ? {} : { assignments }),
|
||||
});
|
||||
/*
|
||||
* 起单要说一声 —— 试跑起出来的是**真任务**:它这一刻就落到了负责人的工作台上、
|
||||
* 进了运营台的在途口径。不广播,那两处要等到下一次自己重取才看得见
|
||||
* (而工作台没有轮询,等于不刷新)。`system` 一并发:测试台自己的当前态也变了。
|
||||
*/
|
||||
container.broadcaster.publish({ kind: 'workflow', id: record.id, reason: 'testbed-start' });
|
||||
container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-start' });
|
||||
return c.json({ workflowId: record.id, state: record.state });
|
||||
} catch (error) {
|
||||
// 起不来的原因要如实说(不合调用接口 / 无生效版 / 岗位空缺),别让人对着空白猜
|
||||
@@ -391,19 +598,272 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const tasks = await destroySimulations(container, parsed.data.workflowId);
|
||||
return c.json({ destroyed: true, tasks });
|
||||
const { workflowId, taskId } = parsed.data;
|
||||
/*
|
||||
* 两种试法各有其拔法。都不给 = 拔掉所有试跑残留(换一种试法之前先清场):
|
||||
* 一次只跑一条,不然读数屏上是哪一单的数就说不清了。
|
||||
*/
|
||||
const byFlow = taskId === '' ? await destroySimulations(container, workflowId) : 0;
|
||||
const byTask = workflowId === '' ? await destroyActivityRuns(container, taskId) : 0;
|
||||
/*
|
||||
* **销毁尤其要说一声**:被拔掉的任务此刻还挂在负责人的工作台列表上、还占着运营台的
|
||||
* 在途口径。不广播,那些面上会留着一张点进去就 404 的鬼卡片,直到人自己刷新。
|
||||
* 用 `system`:销毁是跨面的全局变更(任务没了、工作流没了、trace 也没了),
|
||||
* 按单个 id 广播反而说不清 —— 说没就是全没了。
|
||||
*/
|
||||
if (byFlow + byTask > 0 || workflowId !== '' || taskId !== '') {
|
||||
container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-destroyed' });
|
||||
}
|
||||
return c.json({ destroyed: true, tasks: byFlow + byTask });
|
||||
});
|
||||
|
||||
/** 当前在跑的仿真 —— 刷新页面也认得回来(测试台一次只跑一条) */
|
||||
app.get(testCurrentRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const rows = await container.db.execute<{ id: string; subject: string }>(
|
||||
sql`select id, subject from sys.workflow where source->>'ref' = 'testbed' order by started_at desc limit 1`,
|
||||
/*
|
||||
* **在跑与跑完要分得开**(`live`)。
|
||||
*
|
||||
* 从前只答「最近一条试跑单是哪个」,不问它有没有收尾 —— 于是跑完之后
|
||||
* 这台仪器还认为自己被占着:旋钮永久锁住,人只能刷新整页才起得了下一单(实测)。
|
||||
* 现在照旧把最近那一单交出去(观测面要摆结果给人看),但如实说明它还在不在跑。
|
||||
*/
|
||||
const flows = await container.db.execute<{ id: string; subject: string; state: string }>(
|
||||
sql`select id, subject, state from sys.workflow
|
||||
where source->>'ref' = 'testbed' order by started_at desc limit 1`,
|
||||
);
|
||||
const hit = rowsOf<{ id: string; subject: string }>(rows)[0];
|
||||
return c.json(hit === undefined ? {} : { workflowId: hit.id, subject: hit.subject });
|
||||
const flow = rowsOf<{ id: string; subject: string; state: string }>(flows)[0];
|
||||
if (flow !== undefined) {
|
||||
const liveFlow = flow.state === 'running' || flow.state === 'waiting';
|
||||
return c.json({
|
||||
workflowId: flow.id,
|
||||
subject: flow.subject,
|
||||
live: liveFlow,
|
||||
...(liveFlow ? {} : { terminal: flow.state }),
|
||||
});
|
||||
}
|
||||
|
||||
// 没有试跑的流,再看单活动试跑(它不挂工作流,认的是 origin)
|
||||
const solo = await container.db.execute<{ id: string; activity_id: string; title: string; terminal: string | null }>(
|
||||
sql`select id, activity_id, title, terminal from sys.task
|
||||
where origin = 'testbed' and workflow_id is null order by started_at desc limit 1`,
|
||||
);
|
||||
const one = rowsOf<{ id: string; activity_id: string; title: string; terminal: string | null }>(solo)[0];
|
||||
if (one === undefined) return c.json({ live: false });
|
||||
return c.json({
|
||||
taskId: one.id,
|
||||
activityId: one.activity_id,
|
||||
subject: one.title,
|
||||
live: one.terminal === null,
|
||||
...(one.terminal === null ? {} : { terminal: one.terminal }),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 仿真测试台 · 试跑**一个活动** —— 起一单不挂工作流的真任务。
|
||||
*
|
||||
* 真任务:照旧过装载两审、Harness 预算、案例模型推进、放行门。
|
||||
* 「试跑不等于假跑」—— 假跑试不出任何东西;它与生产单的区别只有一个
|
||||
* `origin='testbed'`,好让人随时连根拔掉。
|
||||
*/
|
||||
app.post(testRunActivityRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = testRunActivityRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
const { activityId, data, message, ownerId } = parsed.data;
|
||||
/*
|
||||
* 派给谁:显式指定优先,否则**派给操作者自己**。
|
||||
* 绝不默认派到别人工作台上 —— 试跑单是自己要看的,不是给同事添的活。
|
||||
*/
|
||||
const owner = ownerId !== '' ? ownerId : me?.id;
|
||||
if (owner === undefined) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '定不了负责人:既没指派,也认不出操作者' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
try {
|
||||
// 一次只跑一条:先清场(流与单活动两种残留都拔)
|
||||
await destroySimulations(container);
|
||||
await destroyActivityRuns(container);
|
||||
|
||||
const manifest = await container.registry.manifestOf(activityId);
|
||||
const task = await container.task.start({
|
||||
activityId,
|
||||
ownerId: staffIdSchema.parse(owner),
|
||||
title: manifest.identity.name,
|
||||
origin: 'testbed',
|
||||
data,
|
||||
...(message === '' ? {} : { message }),
|
||||
});
|
||||
container.broadcaster.publish({ kind: 'task', id: task.id, reason: 'testbed-start' });
|
||||
container.broadcaster.publish({ kind: 'system', id: 'testbed', reason: 'testbed-start' });
|
||||
return c.json({ taskId: task.id, title: task.title });
|
||||
} catch (error) {
|
||||
// 起不来的原因要如实说(活动未装 / 入参不合契约 / 模型未绑),别让人对着空白猜
|
||||
const body: ApiError = { code: 'validation-failed', message: error instanceof Error ? error.message : String(error) };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 单活动试跑的观测面 —— 一屏看穿这一单。
|
||||
*
|
||||
* 流程包那侧看 trace 树(节点 → 步骤);活动这侧要看的是**活动内部**:
|
||||
* 每一拍干了什么、拿回了什么、被拒的理由、成果落到台账哪一行、卡问了谁什么。
|
||||
* 拿 trace 树看不出「第三拍为什么被拒」—— 那句话在任务轨迹里。
|
||||
*/
|
||||
app.get(testActivityStateRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
||||
const task = await container.task.get(taskId);
|
||||
|
||||
const memory = await container.memory.window(taskId, 200);
|
||||
const drafts = await container.ledger.taskDrafts(taskId);
|
||||
const orders = await container.humanTool.allWorkOrders(taskId);
|
||||
|
||||
return c.json({
|
||||
taskId: task.id,
|
||||
activityId: task.activityId,
|
||||
title: task.title,
|
||||
runtime: task.runtime,
|
||||
...(task.terminal === undefined ? {} : { terminal: task.terminal }),
|
||||
...(task.reason === undefined ? {} : { reason: task.reason }),
|
||||
used: { steps: task.used.steps, tokens: task.used.tokens, humanCalls: task.used.humanCalls },
|
||||
caps: { steps: task.caps.steps, tokens: task.caps.tokens, humanCalls: task.caps.humanCalls },
|
||||
trail: memory.map((m) => ({
|
||||
at: m.at,
|
||||
kind: m.kind,
|
||||
title: m.title,
|
||||
...(m.detail === undefined ? {} : { detail: m.detail }),
|
||||
})),
|
||||
drafts: drafts.map((d) => ({ address: `${d.domain}.${d.object}`, key: d.key, patch: d.patch })),
|
||||
cards: orders.map((o) => ({
|
||||
id: o.id,
|
||||
mode: o.mode,
|
||||
title: o.title,
|
||||
done: o.done !== undefined,
|
||||
...(o.done?.result === undefined ? {} : { result: String(o.done.result) }),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
/* ── 上传件:附件要能真正进活动 ─────────────────────────────────── */
|
||||
|
||||
/** 上限 8MB —— 超了当场拒,不静默截断(截断过的文件比没有更害人) */
|
||||
const UPLOAD_CAP = 8 * 1024 * 1024;
|
||||
|
||||
app.post(uploadFileRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = uploadFileRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '上传入参不合协议', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const { name, contentType, dataBase64 } = parsed.data;
|
||||
const bytes = new Uint8Array(Buffer.from(dataBase64, 'base64'));
|
||||
if (bytes.length === 0) {
|
||||
const body: ApiError = { code: 'validation-failed', message: `${name} 解出来是 0 字节 —— base64 不合法或文件是空的` };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
if (bytes.length > UPLOAD_CAP) {
|
||||
const body: ApiError = {
|
||||
code: 'validation-failed',
|
||||
message: `${name} 有 ${(bytes.length / 1024 / 1024).toFixed(1)}MB,超过 8MB 上限 —— 这么大的件该走通道进来,不该从界面上传`,
|
||||
};
|
||||
return c.json(body, 400);
|
||||
}
|
||||
// 读文本就在入口这一步做完:活动那一侧读得懂的只有文字,读不出就如实记 note
|
||||
const got = extractText(name, contentType, bytes);
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
const ref = crypto.randomUUID();
|
||||
await container.db.execute(sql`
|
||||
insert into sys.upload (id, name, content_type, size, bytes, text, note, uploaded_by)
|
||||
values (${ref}, ${name}, ${contentType}, ${bytes.length}, ${Buffer.from(bytes)},
|
||||
${got.text ?? null}, ${got.note}, ${me?.id ?? null})`);
|
||||
return c.json({
|
||||
ref,
|
||||
name,
|
||||
contentType,
|
||||
size: bytes.length,
|
||||
...(got.text === undefined ? {} : { text: got.text }),
|
||||
note: got.note,
|
||||
});
|
||||
});
|
||||
|
||||
app.get(downloadFileRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const rows = await container.db.execute<{ name: string; content_type: string; bytes: Buffer }>(
|
||||
sql`select name, content_type, bytes from sys.upload where id = ${c.req.param('id')}`,
|
||||
);
|
||||
const hit = rowsOf<{ name: string; content_type: string; bytes: Buffer }>(rows)[0];
|
||||
if (hit === undefined) {
|
||||
const body: ApiError = { code: 'not-found', message: '这个文件不在了' };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
return c.json({
|
||||
name: hit.name,
|
||||
contentType: hit.content_type,
|
||||
dataBase64: Buffer.from(hit.bytes).toString('base64'),
|
||||
});
|
||||
});
|
||||
|
||||
/* ── 试跑日志:历次试跑的存档,按实例管理 ────────────────────────── */
|
||||
|
||||
/** 列表只给读数与计数 —— 一次试跑的轨迹上百条,列表页不需要正文 */
|
||||
app.get(listTestLogsRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const rows = await container.db.execute<TestLogDbRow>(sql`
|
||||
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
|
||||
jsonb_array_length(trail) as trail_count,
|
||||
jsonb_array_length(drafts) as draft_count,
|
||||
jsonb_array_length(cards) as card_count,
|
||||
started_at, archived_at
|
||||
from sys.testbed_run order by archived_at desc limit 200`);
|
||||
return c.json({ runs: rowsOf<TestLogDbRow>(rows).map(toTestLogRow) });
|
||||
});
|
||||
|
||||
app.get(testLogDetailRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const rows = await container.db.execute<TestLogDbRow & { trail: unknown; drafts: unknown; cards: unknown }>(sql`
|
||||
select id, kind, subject, target, task_id, workflow_id, terminal, reason, used, caps,
|
||||
jsonb_array_length(trail) as trail_count,
|
||||
jsonb_array_length(drafts) as draft_count,
|
||||
jsonb_array_length(cards) as card_count,
|
||||
trail, drafts, cards, started_at, archived_at
|
||||
from sys.testbed_run where id = ${c.req.param('id')}`);
|
||||
const hit = rowsOf<TestLogDbRow & { trail: unknown; drafts: unknown; cards: unknown }>(rows)[0];
|
||||
if (hit === undefined) {
|
||||
const body: ApiError = { code: 'not-found', message: '这条试跑日志不存在(可能已被清掉)' };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
return c.json({ ...toTestLogRow(hit), trail: hit.trail ?? [], drafts: hit.drafts ?? [], cards: hit.cards ?? [] });
|
||||
});
|
||||
|
||||
app.delete(deleteTestLogRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const rows = await container.db.execute<{ id: string }>(
|
||||
sql`delete from sys.testbed_run where id = ${c.req.param('id')} returning id`,
|
||||
);
|
||||
return c.json({ deleted: rowsOf<{ id: string }>(rows).length });
|
||||
});
|
||||
|
||||
app.delete(clearTestLogsRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const rows = await container.db.execute<{ id: string }>(sql`delete from sys.testbed_run returning id`);
|
||||
return c.json({ deleted: rowsOf<{ id: string }>(rows).length });
|
||||
});
|
||||
|
||||
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
|
||||
@@ -786,7 +1246,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
const faces = await Promise.all(
|
||||
cards.map(async (card) => {
|
||||
const manifest = await container.registry.manifestOf(card.activityId);
|
||||
type FieldSpec = { type?: string; items?: { type?: string } };
|
||||
type FieldSpec = { type?: string; items?: { type?: string }; title?: string; format?: string };
|
||||
const data = manifest.contract.input.data as {
|
||||
required?: string[];
|
||||
properties?: Record<string, FieldSpec>;
|
||||
@@ -806,7 +1266,13 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
name,
|
||||
type: typeOf(spec),
|
||||
required: required.includes(name),
|
||||
// 清单里写了中文名就用中文名 —— 表单上摆 `customerEmail` 是给机器看的
|
||||
...(spec?.title === undefined ? {} : { label: spec.title }),
|
||||
// `format: 'file'` = 这一格收的是**真文件**,界面该给文件选择器而不是文本框
|
||||
...(spec?.format === undefined ? {} : { format: spec.format }),
|
||||
})),
|
||||
// 包自带的样例集:试跑一个活动时照它预填(对着空表单编,编出来的多半跑不通)
|
||||
samples: manifest.samples.map((sample) => ({ name: sample.name, input: sample.input })),
|
||||
// 必发口与选择组一并列出:调用方只凭口名与 schema 就能衔接下游
|
||||
outputPorts: [
|
||||
...manifest.contract.output.mandatory,
|
||||
@@ -897,6 +1363,12 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
app.get(listLedgerObjectsRoute.path, async (c) => {
|
||||
const denied = await readData(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
/*
|
||||
* 性质随 `listObjects` **一次查询带回**(0026_object_meta)——
|
||||
* 这里从前是拿 12 个活动的清单现场重建的:`manifestOf` 是 select *,
|
||||
* 拉该活动全部版本的清单加产物再整份 zod parse,光 quote-intake 就 798kB、26 版,
|
||||
* 一次请求约 1.5MB JSON。列表口不该做这种事。
|
||||
*/
|
||||
return c.json({ objects: await container.ledger.listObjects() });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 日志行的呈现口径 —— **主语进内容**(2026-07-31 用户定)。
|
||||
*
|
||||
* 日志一行读起来该是「谁 · 做了什么 · 什么内容」:
|
||||
*
|
||||
* 16:25 [触发事件] 系统:一封邮件触发了这条流程,系统指定你负责「A1 · 询价受理」。
|
||||
* 16:26 [阶段结论] 任务助手:这封邮件来自青岛海洋电子,是新客户…
|
||||
* 16:30 [员工确认] 柴浩:写入 customer.profile
|
||||
*
|
||||
* 四字动作与归侧由 `memory` 的域函数算出(纯函数,可重算);
|
||||
* **只有人名算不出来** —— 域里零 IO,不认识人。所以域交出 `actorId`,
|
||||
* 在这一层换成名字:换不到就退回「员工」,不编名字(界面不撒谎)。
|
||||
*
|
||||
* 放在 routes 而不是 memory 模块里:这是**呈现**口径,不是事实口径。
|
||||
* 事实库里存的是 id,谁改了名、谁离职了,回看旧单都该按当时的 id 找当下的名字。
|
||||
*/
|
||||
import type { LogLine } from '@modules/memory';
|
||||
import { staffIdSchema } from '@contracts';
|
||||
import type { OrgService } from '@modules/org';
|
||||
|
||||
export type DisplayLogLine = {
|
||||
readonly id: string;
|
||||
readonly at: string;
|
||||
readonly side: LogLine['side'];
|
||||
readonly sideLabel: string;
|
||||
/** 谁干的 —— 日志只认三档:负责人(绿)· 智能体(蓝)· 系统(灰) */
|
||||
readonly who: LogLine['who'];
|
||||
readonly action: string;
|
||||
readonly title: string;
|
||||
readonly summary?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 把 `actorId` 换成名字并拼进内容。
|
||||
*
|
||||
* 一次任务里出现的人极少(通常 0–2 个),所以按**出现过的 id** 逐个查,
|
||||
* 不整表捞:整表捞在任务详情这条热路径上是白花的一次全表查询。
|
||||
*/
|
||||
export async function displayLog(lines: readonly LogLine[], org: OrgService): Promise<DisplayLogLine[]> {
|
||||
const ids = [...new Set(lines.flatMap((l) => (l.actorId === undefined ? [] : [l.actorId])))];
|
||||
const names = new Map<string, string>();
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const parsed = staffIdSchema.safeParse(id);
|
||||
if (!parsed.success) return;
|
||||
const staff = await org.getStaff(parsed.data).catch(() => undefined);
|
||||
if (staff !== undefined) names.set(id, staff.name);
|
||||
}),
|
||||
);
|
||||
|
||||
return lines.map((l) => {
|
||||
// 人做的那些:换得到名字就用名字,换不到就写「员工」—— 不编,也不留空
|
||||
const actor = l.actor !== '' ? l.actor : (l.actorId === undefined ? '员工' : (names.get(l.actorId) ?? '员工'));
|
||||
return {
|
||||
id: l.id,
|
||||
at: l.at,
|
||||
side: l.side,
|
||||
sideLabel: l.sideLabel,
|
||||
who: l.who,
|
||||
action: l.action,
|
||||
title: `${actor}:${l.title}`,
|
||||
...(l.summary === undefined ? {} : { summary: l.summary }),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 真活动包的安装 —— **经安装通道读文件,不经代码通道**(ADR-004 / 编码规范 §5.1)。
|
||||
*
|
||||
* 活动包在系统外开发:`activities/<pkg>/` 里写清单与资产,`pnpm activities:build`
|
||||
* 产出 `dist/{manifest.json,index.js}`。api 只读这两个文件 —— 与 activities 之间
|
||||
* 只有安装通道,没有 import(depcruise 的 `apps-not-into-activities` 在守)。
|
||||
*
|
||||
* 这就是「系统外开发、安装即用」的机器形态:装的时候过两审(静态审读清单、
|
||||
* 回放审重演历史),清单里的台账声明顺带把表物化出来,资产随包播种。
|
||||
*
|
||||
* 装不上不掀翻播种:如实报出来。活动包是可选件 —— 没构建产物就是没装,
|
||||
* 不是数据库坏了(而假装装上了才是真的坏)。
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { activityManifestSchema } from '@contracts';
|
||||
import type { Container } from './container.ts';
|
||||
import { MASTER_OBJECTS, MASTER_SAMPLE_ROWS } from './seed-mvp-data.ts';
|
||||
|
||||
/** 已开发完成、要随播种装上的真活动包(目录名 = 包目录,不是 activityId) */
|
||||
/*
|
||||
* 真活动包 —— 从 `activities/<pkg>/dist/` 读文件装入,**不经 import**。
|
||||
* A2/A3 于 2026-07-31 转正:它们的案例模型已简化为「一张执行单办完」,
|
||||
* 剧本与 A1 同一套机制,不再是治理台里那两个 viewTemplates 为空的占位槽。
|
||||
*/
|
||||
const REAL_PACKAGES = [
|
||||
'quote-intake',
|
||||
'quote-alternative',
|
||||
'quote-sourcing',
|
||||
'quote-pricing',
|
||||
'quote-negotiate',
|
||||
'quote-archive',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 活动包根目录。
|
||||
*
|
||||
* 播种以 `apps/api` 为 cwd 跑(`pnpm --filter @app/api seed`),所以默认往上两级找;
|
||||
* 容器化或换布局时用 `JCAI_ACTIVITIES_DIR` 顶掉 —— 路径是部署事实,不该写死在代码里。
|
||||
*/
|
||||
function activitiesRoot(): string {
|
||||
const override = process.env['JCAI_ACTIVITIES_DIR'];
|
||||
return override === undefined || override.trim() === ''
|
||||
? resolve(process.cwd(), '..', '..', 'activities')
|
||||
: resolve(override);
|
||||
}
|
||||
|
||||
export async function installRealActivities(container: Container): Promise<void> {
|
||||
const log = (line: string) => process.stdout.write(line + '\n');
|
||||
|
||||
/*
|
||||
* **主数据先于活动物化**(2026-07-31)。
|
||||
*
|
||||
* 客户 / 项目 / 供应商 / 市场四域不挂在任何活动名下:主数据不是谁的产出,
|
||||
* 它先于活动存在、活动退役了它还在,活动只是读它做判断、提议更新它。
|
||||
* 挂在活动清单下的后果刚踩过 —— 那个活动一退役,它声明的表就成了没人认领的空壳。
|
||||
*
|
||||
* 放在装活动**之前**:活动声明里若也提到同一张表(它要写的那几列),
|
||||
* 声明合并是「最大化合并、只增不破坏」,平台给的全量结构先落地,活动只补自己那部分。
|
||||
*/
|
||||
const applied = await container.ledger.applyDeclarations(MASTER_OBJECTS);
|
||||
const conflicts = applied.flatMap((r) => r.conflicts);
|
||||
log(
|
||||
` 主数据四域已物化:${MASTER_OBJECTS.length} 张表` +
|
||||
// 冲突要如实报:静默吞掉的话,表结构和声明对不上而没人知道
|
||||
(conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`),
|
||||
);
|
||||
/*
|
||||
* 一位样例客户 —— 让「老客户认得出」那条路试得出来(见 `MASTER_SAMPLE_ROWS` 的注)。
|
||||
* 按主键写,重复播种是覆盖不是堆叠;写正式版(不是草稿层),
|
||||
* 因为 A1 第一步查的就是正式版 —— 草稿只有本任务自己看得见。
|
||||
*/
|
||||
for (const row of MASTER_SAMPLE_ROWS) {
|
||||
await container.ledger.systemWrite({
|
||||
address: { domain: row.address.domain, object: row.address.object },
|
||||
key: { ...row.key },
|
||||
values: { ...row.values },
|
||||
cause: { kind: 'governance', ref: 'seed:master-sample' },
|
||||
});
|
||||
}
|
||||
log(` 样例客户已就位:${MASTER_SAMPLE_ROWS[1]?.values.email ?? ''}(样例 2 用它试老客户那条路)`);
|
||||
|
||||
const root = activitiesRoot();
|
||||
|
||||
for (const dir of REAL_PACKAGES) {
|
||||
const dist = join(root, dir, 'dist');
|
||||
let manifestJson: string;
|
||||
let entryJs: string;
|
||||
try {
|
||||
[manifestJson, entryJs] = await Promise.all([
|
||||
readFile(join(dist, 'manifest.json'), 'utf8'),
|
||||
readFile(join(dist, 'index.js'), 'utf8'),
|
||||
]);
|
||||
} catch {
|
||||
log(` 活动包 ${dir} 没有构建产物(${dist})—— 先跑 pnpm activities:build;本次跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = activityManifestSchema.safeParse(JSON.parse(manifestJson));
|
||||
if (!parsed.success) {
|
||||
// 清单不合协议就别装:装进去的残清单会在起单那一刻才炸,那时更难查
|
||||
log(` 活动包 ${dir} 的清单不合协议:${parsed.error.issues.map((i) => `${i.path.join('.')} ${i.message}`).join('; ')}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
* 安装恒新增、版本并存:同版本重装是 no-op(静默丢失 —— 踩过)。
|
||||
* 所以改了内容必须在包里升 `identity.packageVersion`,不然播种看着成功、内容还是旧的。
|
||||
*/
|
||||
try {
|
||||
const verdict = await container.registry.install(parsed.data, entryJs);
|
||||
const version = parsed.data.identity.packageVersion;
|
||||
/*
|
||||
* **装的时候把声明的工具一并授权**(2026-07-31 补)。
|
||||
*
|
||||
* 从前授权只在 `seed-mvp` 里发 —— 那是治理台的整套配置播种。
|
||||
* 于是经安装通道装进来的真活动包一件工具也调不动:A4 的说明书让它去取历史价,
|
||||
* 清单里也白纸黑字声明了 `crm.history-price-read`,运行时却拒回
|
||||
* 「工具不在本活动白名单」,连拒五拍熔断(实测 A4 死于此)。
|
||||
* 「系统外开发、安装即用」里的「即用」,包含它声明过的那些工具能调得动;
|
||||
* 声明了却不授权,等于装了一个结构上办不成事的活动。
|
||||
*
|
||||
* 授权面**就是清单声明**,不多给一件:越权在结构上仍然不可能。
|
||||
*/
|
||||
const declared = parsed.data.declarations.tools.map((t) => t.name);
|
||||
await container.toolbox.setAllowlist(parsed.data.identity.activityId, declared);
|
||||
log(
|
||||
` 活动包已装:${parsed.data.identity.name}(${parsed.data.identity.activityId}@${version})` +
|
||||
(declared.length === 0 ? '' : ` · 已授权工具 ${declared.length} 件`) +
|
||||
(verdict.staticFindings.length === 0
|
||||
? ''
|
||||
: ` —— 静态审提示:${verdict.staticFindings.map((f) => `[${f.severity}] ${f.message}`).join('; ')}`),
|
||||
);
|
||||
} catch (error) {
|
||||
log(` 活动包 ${dir} 未过安装两审:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
-173
@@ -1,9 +1,22 @@
|
||||
/**
|
||||
* 四域十九对象(docs/MVP示例工作流.html · 动态数据 + 静态底座;定价审批表落审批机制,不入台账)。
|
||||
* 主数据四域 —— **客户 / 项目 / 供应商 / 市场**(2026-07-31 用户定,照常规 CRM & ERP 的分法)。
|
||||
*
|
||||
* 划分原则(产品决策,2026-07-27):**流转单据(过程)归 project;沉淀资产按内容归
|
||||
* customer / intel / supplier**。声明只写活动**要用**的字段;物化走安装通道的声明合并
|
||||
* (最大化合并、只增不破坏),这里不建表 —— 建表是 ledger 的事。
|
||||
* 为什么主数据要单独立域、由**平台**播种,而不是跟着某个活动走:
|
||||
*
|
||||
* 台账原来只按「谁写的」分域(`quote_intake.*` / `quote_alternative.*` …)。那是**实现轴**:
|
||||
* 六个活动各有一张 verdict,一单的故事散在六处;而真正需要治理的那几张
|
||||
* (客户档案、历史价)被挤进一个叫 `shared` 的抽屉 —— 那个名字描述的是机制(跨活动共享),
|
||||
* 不是性质(主数据)。用机制命名,抽屉必然变杂物间。
|
||||
*
|
||||
* 更要紧的是**所有权**:主数据不是任何一个活动的产出。客户档案先于活动存在、
|
||||
* 活动退役了它还在;活动只是**读它做判断、提议更新它**。
|
||||
* 所以它由平台播种物化,不挂在谁的清单下 —— 活动装了卸了,主数据都在。
|
||||
*
|
||||
* 与三类性质的关系(见 `ledgerObjectKindSchema`):这里全是 `master`。
|
||||
* 单据(`record`)归各活动自己的域;派生指标(`derived`)另说 —— 它本不该手写。
|
||||
*
|
||||
* **现在是空占位**:结构先立起来,还没有活动往里写。
|
||||
* 表建出来但一行数据都没有 —— 治理台「主数据」那一区能看见它们等着被接上。
|
||||
*/
|
||||
import { objectDeclarationSchema } from '@contracts';
|
||||
import type { LedgerFieldType, ObjectDeclaration } from '@contracts';
|
||||
@@ -16,184 +29,180 @@ const f = (name: string, type: LedgerFieldType, label: string, primaryKey = fals
|
||||
masked: false,
|
||||
});
|
||||
|
||||
const obj = (domain: string, object: string, label: string, fields: ReturnType<typeof f>[]) =>
|
||||
objectDeclarationSchema.parse({ domain, object, label, fields });
|
||||
/** 主数据一律 `kind: 'master'`:按业务实体分行,没有单标识 */
|
||||
const master = (domain: string, object: string, label: string, fields: ReturnType<typeof f>[]) =>
|
||||
objectDeclarationSchema.parse({ domain, object, label, kind: 'master', fields });
|
||||
|
||||
export const MVP_OBJECTS: readonly ObjectDeclaration[] = [
|
||||
/* ── project:每一单的流转单据(位置签 输入/过程/输出) ─────────────── */
|
||||
obj('project', 'rfq_file', 'RFQ/BOM文件', [
|
||||
f('rfq_ref', 'text', '询价单号', true),
|
||||
f('pn', 'text', 'PN', true),
|
||||
f('qty_eau', 'text', '数量/EAU'),
|
||||
f('brand', 'text', '厂牌'),
|
||||
f('analog_flag', 'boolean', 'Analog'),
|
||||
f('aec_cert', 'text', 'AEC认证'),
|
||||
f('target_price', 'number', '目标价'),
|
||||
f('lead_time', 'text', '交期'),
|
||||
]),
|
||||
obj('project', 'intake_record', '统一受理记录', [
|
||||
f('customer_code', 'text', '客户码', true),
|
||||
f('pn', 'text', 'PN', true),
|
||||
f('brand', 'text', '厂牌'),
|
||||
f('eau', 'text', 'EAU'),
|
||||
f('type_tag', 'text', 'Type标'),
|
||||
f('target_price', 'number', '目标价'),
|
||||
f('status', 'text', '状态'),
|
||||
]),
|
||||
obj('project', 'pn_class', '料号分类表', [
|
||||
f('pn', 'text', 'PN', true),
|
||||
f('type_tag', 'text', 'Type标'),
|
||||
f('basis', 'text', '分级依据'),
|
||||
f('history_hit', 'boolean', '历史价命中'),
|
||||
f('review_note', 'text', '复核结论'),
|
||||
]),
|
||||
/*
|
||||
* 受理结论 —— **一单一行**(主键只有询价单号),不是一料一行。
|
||||
*
|
||||
* 它承载 A1 出口的两枚去向旗。为什么必须单独一个对象、不能挂在按料号分行的
|
||||
* 「统一受理记录」上:出口载荷的标量字段取自 `landed.flat`,而 `flat` 是把这一单
|
||||
* **所有**落地行 reduce 展开成一个平对象(`wiring/engines.ts`)。旗挂在按料号分的行上,
|
||||
* 六个料号就有六行同名列,**最后合并的那行赢** —— 而归并规则是「任一条命中 C 即需选型」,
|
||||
* last-write-wins 会静默给出错的去向,网关照着错值分叉,没人看得出来。
|
||||
*
|
||||
* 一单一行就没有这个歧义:G1 / G2 读的是同一行的同一个值。
|
||||
* 逐料号的判定仍写「料号分类表」,这里只放归并后的结论。
|
||||
*/
|
||||
obj('project', 'intake_verdict', '受理结论', [
|
||||
f('rfq_ref', 'text', '询价单号', true),
|
||||
f('need_alternative', 'boolean', '需选型'),
|
||||
f('need_sourcing', 'boolean', '需寻源'),
|
||||
f('c_count', 'number', 'C类条数'),
|
||||
f('status', 'text', '状态'),
|
||||
]),
|
||||
obj('project', 'alt_request', '替代评估请求', [
|
||||
f('material', 'text', '初选物料', true),
|
||||
f('chip_type', 'text', '芯片类型'),
|
||||
f('spec_params', 'text', '参数规格'),
|
||||
f('scenario', 'text', '应用场景'),
|
||||
f('requester', 'text', '需求人'),
|
||||
]),
|
||||
obj('project', 'inquiry_request', '询价请求', [
|
||||
f('pn', 'text', 'PN', true),
|
||||
f('brand', 'text', 'Brand'),
|
||||
f('era', 'text', 'ERA'),
|
||||
f('project_stage', 'text', '项目阶段'),
|
||||
f('accept_alt', 'boolean', '接受替代'),
|
||||
f('unit_usage', 'text', '单机用量'),
|
||||
f('tp', 'number', 'TP'),
|
||||
f('scenario', 'text', '场景'),
|
||||
]),
|
||||
obj('project', 'supplier_floor', '供应商底价表', [
|
||||
f('pn', 'text', 'PN', true),
|
||||
f('supplier', 'text', '供应商', true),
|
||||
f('floor_price', 'number', '底价'),
|
||||
f('moq', 'text', 'MOQ'),
|
||||
f('lead_time', 'text', '交期'),
|
||||
]),
|
||||
obj('project', 'alt_benchmark', '替代对标表', [
|
||||
f('original_pn', 'text', '原型号', true),
|
||||
f('candidate_pn', 'text', '候选型号', true),
|
||||
f('alt_grade', 'text', '替代等级'),
|
||||
f('param_diff', 'text', '参数差异项'),
|
||||
f('diff_risk', 'text', '差异风险'),
|
||||
f('mod_eval', 'text', '改动评估'),
|
||||
f('verify_advice', 'text', '验证建议'),
|
||||
]),
|
||||
obj('project', 'sourcing_compare', '寻源比价表', [
|
||||
f('pn', 'text', 'PN', true),
|
||||
f('supplier', 'text', '供应商', true),
|
||||
f('quote_price', 'number', '报价'),
|
||||
f('moq', 'text', 'MOQ'),
|
||||
f('lead_time', 'text', '交期'),
|
||||
f('channel_grade', 'text', '渠道等级'),
|
||||
f('condition', 'text', '成色'),
|
||||
f('verdict', 'text', '结论'),
|
||||
]),
|
||||
obj('project', 'quote_draft', '报价草案', [
|
||||
f('pn', 'text', 'PN', true),
|
||||
f('proposed_price', 'number', '拟报单价'),
|
||||
f('margin', 'number', '毛利率'),
|
||||
f('deviation', 'number', '偏离度'),
|
||||
]),
|
||||
obj('project', 'formal_quote', '正式报价单', [
|
||||
f('pn', 'text', 'PN', true),
|
||||
f('unit_price_cny', 'number', '单价CNY'),
|
||||
f('lead_time', 'text', '交期'),
|
||||
f('spq', 'text', 'SPQ'),
|
||||
f('moq', 'text', 'MOQ'),
|
||||
f('trade_terms', 'text', '贸易条款'),
|
||||
f('valid_until', 'date', '有效期'),
|
||||
]),
|
||||
obj('project', 'followup', '跟进提醒', [
|
||||
f('quote_no', 'text', '报价单号', true),
|
||||
f('customer_code', 'text', '客户'),
|
||||
f('d3', 'date', 'D+3'),
|
||||
f('d7', 'date', 'D+7'),
|
||||
f('reply_status', 'text', '回复状态'),
|
||||
]),
|
||||
obj('project', 'landing_stats', '报价落地率统计', [
|
||||
f('period', 'text', '期', true),
|
||||
f('landing_rate', 'number', '落地率'),
|
||||
f('type_a_hit', 'number', 'TypeA命中'),
|
||||
f('d3_reply_rate', 'number', 'D+3回复率'),
|
||||
f('return_rate', 'number', '退回率'),
|
||||
]),
|
||||
|
||||
/* ── customer:客户信息 ────────────────────────────────────────────── */
|
||||
obj('customer', 'profile', '客户资料库', [
|
||||
f('customer_code', 'text', '客户码', true),
|
||||
export const MASTER_OBJECTS: readonly ObjectDeclaration[] = [
|
||||
/* ── 客户:谁在买 ──────────────────────────────────────────────────── */
|
||||
master('customer', 'profile', '客户档案', [
|
||||
f('customer_code', 'text', '客户编码', true),
|
||||
f('name', 'text', '客户名称'),
|
||||
f('grade', 'text', '客户等级'),
|
||||
f('tags', 'text', '画像标签'),
|
||||
f('payment_terms', 'text', '付款条款'),
|
||||
f('industry', 'text', '行业属性'),
|
||||
f('risk_level', 'text', '风险等级'),
|
||||
f('annual_purchase', 'number', '年采购额'),
|
||||
f('industry', 'text', '所属行业'),
|
||||
f('region', 'text', '区域'),
|
||||
f('owner', 'text', '负责 BD'),
|
||||
f('payment_terms', 'text', '付款条件'),
|
||||
f('credit_limit', 'number', '信用额度'),
|
||||
/* 画像标签:价格敏感度、决策周期、常用条款 —— A7 归档时回写的就是这一列 */
|
||||
f('profile_tags', 'text', '画像标签'),
|
||||
f('status', 'text', '状态'),
|
||||
]),
|
||||
master('customer', 'contact', '客户联系人', [
|
||||
f('customer_code', 'text', '客户编码', true),
|
||||
f('contact_code', 'text', '联系人编码', true),
|
||||
f('name', 'text', '姓名'),
|
||||
f('title', 'text', '职务'),
|
||||
/* 邮箱与电话是个人信息 —— 主数据里必须标脱敏,治理面照此裁剪 */
|
||||
{ ...f('email', 'text', '邮箱'), masked: true },
|
||||
{ ...f('phone', 'text', '电话'), masked: true },
|
||||
]),
|
||||
master('customer', 'price_terms', '客户价格条款', [
|
||||
f('customer_code', 'text', '客户编码', true),
|
||||
f('category', 'text', '品类', true),
|
||||
f('target_margin', 'number', '目标毛利'),
|
||||
f('discount', 'number', '折扣'),
|
||||
f('valid_until', 'date', '有效期至'),
|
||||
]),
|
||||
|
||||
/* ── intel:市场与销售情报 ─────────────────────────────────────────── */
|
||||
obj('intel', 'history_price', '历史价格情报', [
|
||||
f('pn', 'text', 'PN', true),
|
||||
f('quoted_at', 'timestamp', '报价日期', true),
|
||||
f('brand', 'text', '厂牌'),
|
||||
f('unit_price_cny', 'number', '单价CNY'),
|
||||
f('lead_time', 'text', '交期'),
|
||||
f('moq', 'text', 'MOQ'),
|
||||
f('valid_until', 'date', '有效期'),
|
||||
/* ── 项目:为什么买 —— 客户的产品项目,反复询价的载体 ───────────────── */
|
||||
master('project', 'profile', '项目档案', [
|
||||
f('project_code', 'text', '项目编码', true),
|
||||
f('customer_code', 'text', '所属客户'),
|
||||
f('name', 'text', '项目名称'),
|
||||
/* BOM 早期 / BOM 后期 / 量产 —— A3 的询价打法就按这个分 */
|
||||
f('stage', 'text', '项目阶段'),
|
||||
f('annual_volume', 'integer', '年用量预估'),
|
||||
f('start_at', 'date', '立项日期'),
|
||||
f('owner', 'text', '负责人'),
|
||||
f('status', 'text', '状态'),
|
||||
]),
|
||||
obj('intel', 'chip_spec', '芯片规格书', [
|
||||
f('pn', 'text', 'PN', true),
|
||||
f('key_params', 'text', '关键参数'),
|
||||
master('project', 'bom', '项目 BOM', [
|
||||
f('project_code', 'text', '项目编码', true),
|
||||
f('pn', 'text', '料号', true),
|
||||
f('qty_per_unit', 'number', '单机用量'),
|
||||
f('eau', 'integer', '年需求量'),
|
||||
f('designed_in', 'boolean', '已导入设计'),
|
||||
f('status', 'text', '状态'),
|
||||
]),
|
||||
|
||||
/* ── 供应商:从哪买 ───────────────────────────────────────────────── */
|
||||
master('supplier', 'profile', '供应商档案', [
|
||||
f('supplier_code', 'text', '供应商编码', true),
|
||||
f('name', 'text', '供应商名称'),
|
||||
/* 原厂 / 授权代理 / 贸易商 —— 「现货场景不得选无库存转单商」判据读的就是它 */
|
||||
f('type', 'text', '供应商类型'),
|
||||
f('region', 'text', '区域'),
|
||||
f('rating', 'text', '评级'),
|
||||
f('payment_terms', 'text', '账期'),
|
||||
f('status', 'text', '状态'),
|
||||
]),
|
||||
master('supplier', 'line_card', '代理线卡', [
|
||||
f('supplier_code', 'text', '供应商编码', true),
|
||||
f('brand', 'text', '品牌', true),
|
||||
f('authorized', 'boolean', '是否授权'),
|
||||
f('lead_time_typ', 'text', '常规交期'),
|
||||
]),
|
||||
master('supplier', 'contact', '供应商联系人', [
|
||||
f('supplier_code', 'text', '供应商编码', true),
|
||||
f('contact_code', 'text', '联系人编码', true),
|
||||
f('name', 'text', '姓名'),
|
||||
{ ...f('email', 'text', '邮箱'), masked: true },
|
||||
{ ...f('phone', 'text', '电话'), masked: true },
|
||||
]),
|
||||
|
||||
/* ── 市场:买什么、行情如何 ──────────────────────────────────────────
|
||||
料号主数据放这一域:ERP 里它常自成一域(物料主数据),
|
||||
但这门生意关心的是它**在市场上什么状态**(生命周期、有没有货、什么价),
|
||||
跟行情摆在一起更顺手。要独立成域也说得通,记在这儿备考。 */
|
||||
master('market', 'part', '料号主数据', [
|
||||
f('pn', 'text', '料号', true),
|
||||
f('brand', 'text', '品牌'),
|
||||
f('category', 'text', '品类'),
|
||||
f('package', 'text', '封装'),
|
||||
f('version_date', 'date', '版本日期'),
|
||||
]),
|
||||
obj('intel', 'public_select', '公网选型库', [
|
||||
f('alt_relation', 'text', '替代关系', true),
|
||||
f('param_compare', 'text', '参数对比'),
|
||||
f('spec_ref', 'text', '规格书'),
|
||||
/* Active / NRND / EOL —— A1 三分类与 A2 选型都读它 */
|
||||
f('lifecycle', 'text', '生命周期'),
|
||||
f('price_ref', 'text', '价格参考'),
|
||||
f('rohs', 'boolean', 'RoHS'),
|
||||
f('datasheet_url', 'text', '规格书链接'),
|
||||
]),
|
||||
|
||||
/* ── supplier:供应商信息 ─────────────────────────────────────────── */
|
||||
obj('supplier', 'channel_dir', '供应商渠道目录', [
|
||||
f('supplier', 'text', '供应商', true),
|
||||
f('channel_type', 'text', '渠道类型'),
|
||||
f('brand_lines', 'text', '品牌线'),
|
||||
f('contact', 'text', '联系人'),
|
||||
master('market', 'price_history', '历史价格情报', [
|
||||
f('pn', 'text', '料号', true),
|
||||
f('quoted_at', 'date', '报价日期', true),
|
||||
f('customer_code', 'text', '客户编码'),
|
||||
f('unit_price', 'number', '成交/报出单价'),
|
||||
f('lead_time', 'text', '交期'),
|
||||
f('lost_reason', 'text', '未成交原因'),
|
||||
f('competitor_price', 'number', '竞对价位'),
|
||||
]),
|
||||
obj('supplier', 'dead_stock', '呆滞库存表', [
|
||||
f('pn', 'text', 'PN', true),
|
||||
f('stock_qty', 'number', '库存量'),
|
||||
f('stock_age', 'text', '库龄'),
|
||||
f('cost_price', 'number', '成本价'),
|
||||
master('market', 'quote_index', '市场行情', [
|
||||
f('pn', 'text', '料号', true),
|
||||
f('observed_at', 'date', '观测日期', true),
|
||||
f('source', 'text', '来源'),
|
||||
f('price', 'number', '市场价'),
|
||||
f('stock', 'integer', '可见库存'),
|
||||
f('trend', 'text', '走势'),
|
||||
]),
|
||||
];
|
||||
|
||||
export function declarationsFor(objects: readonly `${string}.${string}`[]): ObjectDeclaration[] {
|
||||
return objects.map((key) => {
|
||||
const [domain, object] = key.split('.');
|
||||
const hit = MVP_OBJECTS.find((o) => o.domain === domain && o.object === object);
|
||||
if (hit === undefined) throw new Error(`未知对象:${key} —— 先在 MVP_OBJECTS 登记`);
|
||||
return hit;
|
||||
});
|
||||
}
|
||||
/** 四域的呈现次序 —— 谁在买 → 为什么买 → 从哪买 → 买什么 */
|
||||
export const MASTER_DOMAINS = ['customer', 'project', 'supplier', 'market'] as const;
|
||||
|
||||
/**
|
||||
* **样例客户** —— 让「老客户」那条路试得出来。
|
||||
*
|
||||
* 主数据四域建出来是空的,而 A1 的第一个状态有两条路:
|
||||
* 认得出客户就直接过、认不出才请人建档。库里一个客户都没有的话,
|
||||
* 两条样例跑起来一模一样 —— **第二条路根本试不到**。
|
||||
*
|
||||
* 所以随主数据一起播一位客户:样例 2 的来信就用他的邮箱发。
|
||||
* 只播这一位,且是**幂等的按主键写**:它是让样例跑得起来的最小夹具,
|
||||
* 不是往主数据里塞演示数据 —— 真实客户由 A1 建档时一位一位落进来。
|
||||
*/
|
||||
export const MASTER_SAMPLE_ROWS = [
|
||||
/*
|
||||
* **Itelma** —— 老客户测试案例的那一位(照 `JCAI_PRD/docs/活动执行页/` 两份目标件)。
|
||||
* 俄罗斯车规电子、项目制;客户码 CU-0142 就是需求件上写的「已命中客户资料库」那一条。
|
||||
*/
|
||||
{
|
||||
address: { domain: 'customer', object: 'profile' },
|
||||
key: { customer_code: 'CU-0142' },
|
||||
values: {
|
||||
customer_code: 'CU-0142',
|
||||
name: 'Itelma',
|
||||
grade: 'A',
|
||||
industry: '车规电子',
|
||||
region: '俄罗斯',
|
||||
payment_terms: 'EXW · CNY 含税',
|
||||
profile_tags: '项目制 · 按项目批量询价 · 认证要求严(AEC-Q200)',
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
{
|
||||
address: { domain: 'customer', object: 'contact' },
|
||||
key: { customer_code: 'CU-0142', contact_code: 'CU-0142-01' },
|
||||
values: {
|
||||
customer_code: 'CU-0142',
|
||||
contact_code: 'CU-0142-01',
|
||||
name: 'Ekaterina Volkova',
|
||||
title: '采购部',
|
||||
// **认人的钥匙**:老客户样例的来信就从这个邮箱发,A1 第一步照它认得出
|
||||
email: 'e.volkova@itelma.ru',
|
||||
},
|
||||
},
|
||||
/*
|
||||
* 项目档案 —— 这一单询的是 MD14.1 这个项目的料。
|
||||
* 播它是因为需求件上「项目制」那句话得有出处:客户属性说得出「按项目询价」,
|
||||
* 库里就该有那个项目,否则那句话是装出来的。
|
||||
*/
|
||||
{
|
||||
address: { domain: 'project', object: 'profile' },
|
||||
key: { project_code: 'MD14.1' },
|
||||
values: {
|
||||
project_code: 'MD14.1',
|
||||
customer_code: 'CU-0142',
|
||||
name: 'MD14.1 车身控制器',
|
||||
stage: 'BOM 后期',
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -30,15 +30,20 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
version: 1,
|
||||
state: 'active',
|
||||
callInterface: {
|
||||
/*
|
||||
* 起单入参 = **那封信**,与 A1 的契约一致(2026-07-31 跟着改)。
|
||||
* 从前这里要 `customerCode` + `rfqRef`,而 A1 早已改成收一封原始来函 ——
|
||||
* 两边对不上的后果实测过:通道抽不出这两个键,基座当场发补全卡,
|
||||
* 整条流 0 拍停在那儿等人,而信本身是完整的。
|
||||
*/
|
||||
input: {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'rfqRef'],
|
||||
required: ['customerEmail', 'subject', 'body'],
|
||||
properties: {
|
||||
customerCode: { type: 'string' },
|
||||
rfqRef: { type: 'string' },
|
||||
customerEmail: { type: 'string' },
|
||||
subject: { type: 'string' },
|
||||
body: { type: 'string' },
|
||||
quotedAt: { type: 'string' },
|
||||
attachments: { type: 'array' },
|
||||
},
|
||||
},
|
||||
output: { type: 'object', properties: { landingRate: { type: 'number' } } },
|
||||
@@ -50,10 +55,16 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
*/
|
||||
nodes: [
|
||||
{ id: 'start', kind: 'start', label: '客户RFQ询价', layout: { row: 0, col: 0, x: 30, y: 70 } },
|
||||
activityNode('A1', '客户RFQ受理', 'rfq-intake', 1, 0, 110, 60),
|
||||
activityNode('A2', '替代选型', 'alt-selection', 2, 0, 290, 60),
|
||||
activityNode('A3', '寻源询价', 'sourcing-inquiry', 3, 0, 470, 60),
|
||||
activityNode('A4', '成本定价', 'cost-pricing', 4, 1, 110, 310),
|
||||
/*
|
||||
* **六个节点全部绑真活动包**(2026-07-31)—— 从前只有 A1 是真的,其余绑的是
|
||||
* `alt-selection` / `sourcing-inquiry` / `cost-pricing` / `quote-negotiation` /
|
||||
* `intel-archive` 这些槽位 id。槽位退役之后再不改这里,整条流会指向一堆装不出来的活动。
|
||||
* 编排以**这份代码**为准,不以示例文档为准。
|
||||
*/
|
||||
activityNode('A1', '询价受理', 'quote-intake', 1, 0, 110, 60),
|
||||
activityNode('A2', '替代选型', 'quote-alternative', 2, 0, 290, 60),
|
||||
activityNode('A3', '寻源询价', 'quote-sourcing', 3, 0, 470, 60),
|
||||
activityNode('A4', '成本定价', 'quote-pricing', 4, 1, 110, 310),
|
||||
{
|
||||
id: 'A5',
|
||||
kind: 'approval',
|
||||
@@ -68,8 +79,8 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
nudge: { steps: [{ after: 'PT20M', action: 'nudge' }], deadline: 'PT30M' },
|
||||
layout: { row: 1, col: 5, x: 290, y: 310 },
|
||||
},
|
||||
activityNode('A6', '报价议价', 'quote-negotiation', 6, 1, 470, 310),
|
||||
activityNode('A7', '情报归档', 'intel-archive', 7, 2, 630, 410),
|
||||
activityNode('A6', '报价议价', 'quote-negotiate', 6, 1, 470, 310),
|
||||
activityNode('A7', '情报归档', 'quote-archive', 7, 2, 630, 410),
|
||||
{ id: 'end_won', kind: 'end', label: '转订单履约', layout: { row: 1, col: 7, x: 630, y: 320 } },
|
||||
{ id: 'end_arch', kind: 'end', label: '归档闭环', layout: { row: 2, col: 8, x: 790, y: 420 } },
|
||||
],
|
||||
@@ -191,7 +202,7 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
|
||||
anchors: [],
|
||||
nodes: [
|
||||
{ id: 'start', kind: 'start', label: '客户RFQ询价', x: 40, y: 80, lx: 54, ly: 112 },
|
||||
{ id: 'A1', kind: 'activity', label: '客户RFQ受理', code: 'A1', x: 120, y: 70 },
|
||||
{ id: 'A1', kind: 'activity', label: '询价受理', code: 'A1', x: 120, y: 70 },
|
||||
{ id: 'A2', kind: 'activity', label: '替代选型', code: 'A2', x: 300, y: 70 },
|
||||
{ id: 'A3', kind: 'activity', label: '寻源询价', code: 'A3', x: 480, y: 70 },
|
||||
{ id: 'A4', kind: 'activity', label: '成本定价', code: 'A4', x: 120, y: 320 },
|
||||
@@ -307,6 +318,21 @@ const TRIGGER_EVENT = triggerConfigSchema.parse({
|
||||
rule: 'channelId = "ch-rfq-mail" and (contains(subject, "询价") or contains(subject, "RFQ"))',
|
||||
eventType: 'data',
|
||||
},
|
||||
/*
|
||||
* 装配映射 —— **每个键都得显式映**(2026-07-31 补,起单一直缺字段的根因)。
|
||||
*
|
||||
* 事件触发的 scope **不打底**:`assembleInput` 只在 `kind: 'registration'` 时
|
||||
* 把 scope 整个铺进去,事件那一路从空对象起步、只认 mapping。
|
||||
* 所以「同名就会自动带过去」是错觉 —— 实测 subject / body 与邮件载荷同名,
|
||||
* 照样报「缺 subject、body」,整条流第一个节点就停在补全卡上。
|
||||
* 目标键是 A1 契约的那四样(那封信),源是通道抽出来的邮件载荷字段名。
|
||||
*/
|
||||
mapping: [
|
||||
{ target: 'customerEmail', source: { from: 'expression', sfeel: 'from' } },
|
||||
{ target: 'subject', source: { from: 'expression', sfeel: 'subject' } },
|
||||
{ target: 'body', source: { from: 'expression', sfeel: 'body' } },
|
||||
{ target: 'attachments', source: { from: 'expression', sfeel: 'attachments' } },
|
||||
],
|
||||
});
|
||||
|
||||
/** 登记触发器:人确认后起单,「登记人即来源」—— 表单由调用接口 schema 自动渲染 */
|
||||
|
||||
@@ -1,619 +1,49 @@
|
||||
/**
|
||||
* 六个活动槽位(docs/MVP示例工作流.html §1.1–1.7)—— **契约就绪、实现留白**。
|
||||
* 六个活动槽位的**退役名单** —— 槽位时代到此为止(2026-07-31 清账)。
|
||||
*
|
||||
* 槽位 = 活动的契约面:身份 / 七件契约(入参、出口)/ 声明(域对象、工具名)/ 业务模型骨架。
|
||||
* 与工具的解耦就在声明:`declarations.tools` 只写**名**,注册、连接值、白名单全在治理侧。
|
||||
* A5 定价审批是**内置审批点**(审批不是任务,零开发、不入库),不在槽位之列。
|
||||
* 这个文件从前装着六份「契约就绪、实现留白」的占位清单:编排层跑通期,
|
||||
* 靠它们让流走得动。六个真活动包(`activities/quote-*`)全部转正之后,
|
||||
* 占位清单就该走 —— 留着它们不是「多一个备份」,是**三处会骗人的重影**:
|
||||
*
|
||||
* 实现待底座完备后逐个开发:届时以新包版本经安装通道换入,槽位契约不动。
|
||||
* · 活动库与试跑台上并排两个同名活动,选错一个就试了个空壳;
|
||||
* · 它们声明的 `project.*` / `intel.*` / `customer.*` / `supplier.*` 台账,
|
||||
* 与真活动包的 `quote_*.*` 是**同一批业务对象的两套地址** ——
|
||||
* 数据域那一屏上「统一受理记录」「替代对标表」「报价草案」各有两份,
|
||||
* 人分不出该看哪一个(实测 12 对重影,旧的那 19 张表一行数据都没有);
|
||||
* · 工具授权面也各留一份,治理台的授权清单跟着翻倍。
|
||||
*
|
||||
* **2026-07-29:活动不再分范式,一律案例模型**(用户定)。flow 那一路连同 flow-engine
|
||||
* 整个退役,所以占位骨架也从「机器步落台账 + 一张验收单」改成**案例骨架**:
|
||||
* 一个「办理」阶段挂着 SOP 每一件事(全部人工激活)+ 一张收口验收。
|
||||
* 代价写在明处:案例由 Agent 每拍决策,**没有模型可用时占位活动一步也走不动** ——
|
||||
* 这是范式统一的直接后果,不是缺陷;真实现时以新包版本换入真案例模型。
|
||||
* 所以这里只剩一件事:**把它们请出去**。名单里的活动播种时一律卸载;
|
||||
* 卸不掉就如实说(多半是还有在途任务引着它),不掀翻播种。
|
||||
* 物化出来的表不由这里删 —— 台账的规矩是「只增不破坏」,
|
||||
* 清空壳表是一次性的人工决定(2026-07-31 已在开发库执行),不该写进播种。
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { activityManifestSchema } from '@contracts';
|
||||
import type { ActivityManifest } from '@contracts';
|
||||
import type { Container } from './container.ts';
|
||||
import { declarationsFor } from './seed-mvp-data.ts';
|
||||
|
||||
/**
|
||||
* 槽位占位入口 —— **不是实现**:装载它只会得到「未实现」的诚实报错。
|
||||
* mock 只许在外界:这里绝不发既定载荷装作跑过(README 平移规则)。
|
||||
*/
|
||||
export const SLOT_ARTIFACT = [
|
||||
'// 活动槽位:契约已就绪,实现待底座完备后逐个开发。',
|
||||
"throw new Error('活动尚未实现 —— 这是槽位,契约见治理台活动库');",
|
||||
].join('\n');
|
||||
|
||||
const checksum = createHash('sha256').update(SLOT_ARTIFACT).digest('hex');
|
||||
|
||||
/**
|
||||
* 占位期的业务模型骨架 —— **案例骨架**:SOP 的每件事一个人工任务,末尾一张收口验收。
|
||||
*
|
||||
* 为什么全部 `manualActivationRule`:这些事都触达世界(问客户、查外部、写台账),
|
||||
* AM 4.1 原理一说得明白 —— 触达世界的项 Enabled ≠ Active,要人按一下才动。
|
||||
* 占位期尤其如此:占位不替活动做任何判断,它连「该不该做这件事」都不替人定。
|
||||
*
|
||||
* 收口验收单是活动 release 的唯一出口:没有它,人把前面的事都办完,任务仍不结案。
|
||||
*/
|
||||
const placeholderSkeleton = (sop: readonly string[], name: string) => {
|
||||
const items = sop
|
||||
.map(
|
||||
(label, i) =>
|
||||
` <humanTask id="T${i + 1}" name="${label}">\n` +
|
||||
` <itemControl><manualActivationRule id="m${i + 1}"/></itemControl>\n` +
|
||||
` </humanTask>`,
|
||||
)
|
||||
.join('\n');
|
||||
return {
|
||||
cmmnXml:
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n` +
|
||||
`<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">\n` +
|
||||
` <case id="slot">\n` +
|
||||
` <caseFileModel>\n` +
|
||||
` <caseFileItem id="input" name="调用入参"/>\n` +
|
||||
` <caseFileItem id="draft" name="草稿台账"/>\n` +
|
||||
` </caseFileModel>\n` +
|
||||
` <casePlanModel id="plan" name="${name}" autoComplete="true">\n` +
|
||||
` <sentry id="s_in">\n` +
|
||||
` <onPart id="op_in" caseFileItemRef="input"><standardEvent>create</standardEvent></onPart>\n` +
|
||||
` </sentry>\n` +
|
||||
` <stage id="S1" name="办理" autoComplete="true">\n` +
|
||||
` <entryCriterion id="ec_s1" sentryRef="s_in"/>\n` +
|
||||
items.replace(/^ {6}/gm, ' ') +
|
||||
`\n </stage>\n` +
|
||||
` <milestone id="M_done" name="事已办完"/>\n` +
|
||||
` <humanTask id="T_release" name="收口验收">\n` +
|
||||
` <itemControl><manualActivationRule id="m_rel"/></itemControl>\n` +
|
||||
` </humanTask>\n` +
|
||||
` <timerEventListener id="TL_half" name="时限过半">\n` +
|
||||
` <timerExpression>PT12H</timerExpression>\n` +
|
||||
` </timerEventListener>\n` +
|
||||
` </casePlanModel>\n` +
|
||||
` </case>\n` +
|
||||
`</definitions>`,
|
||||
narrative:
|
||||
`§0 开案:${name}的**契约已就绪、实现留白**。这份说明书是槽位占位,不是业务方法 ——\n` +
|
||||
`它只交代这件事按 MVP 文档分几步办(${sop.join(' → ')}),不替活动做任何判断。\n\n` +
|
||||
`办理:每一件事都要人按一下才动(触达世界的项恒需人工激活)。\n` +
|
||||
`占位期没有自动步:占位若替人落一行台账,下游读到的就是一个编出来的事实。\n\n` +
|
||||
`§Z 结案:事办完、收口验收签字放行即结案。真实现时以新包版本换入真案例模型,\n` +
|
||||
`这份说明书连同 CMMN 一并被替换 —— 契约面不动。`,
|
||||
dmn: { drd: [], tables: [] },
|
||||
};
|
||||
};
|
||||
|
||||
const identity = (activityId: string, name: string) => ({
|
||||
activityId,
|
||||
name,
|
||||
/*
|
||||
* 包版本 —— 改了槽位携带的东西就必须往上走。
|
||||
*
|
||||
* 「安装恒为新增、版本并存」是硬不变量:同 (activityId, version) 再装是 no-op,
|
||||
* 库里的 manifest 一个字都不会动。所以 0.1.0 期间把卡面从空卡改成自述卡,
|
||||
* 重播种会「静默成功」—— 打印「已装入」,库里还是旧的(实测踩到)。
|
||||
*
|
||||
* 0.2.0:步骤卡面自述(标题 / 要你答 / 步序依据)。
|
||||
* 0.3.0:人工调用预算按各槽位自己的步数给 —— 共用 3 会让 6 步的 A1 走到第 3 步就
|
||||
* `exhausted` 终态、结构上永远走不完(实测)。授权面申报值同源。
|
||||
* 0.4.0:flow 骨架末步追加**收口验收**—— 没有它活动永远不 release,
|
||||
* 六步答完就卡在「等人却没东西给人答」(实测)。
|
||||
* 0.5.0:A1/A2 声明「受理结论」对象(两枚去向旗有处落地);A2 入参加 rfqRef。
|
||||
* 0.6.0:占位期骨架改**机器步落台账 + 一张验收单**(用户定:只留验收要人签)。
|
||||
* 六个槽位一律 flow 范式;预算固定 humanCalls: 2。
|
||||
* 0.6.1:修验收步的出参映射 —— 键是**台账列名**(小写蛇形),不是回传字段名。
|
||||
* 0.7.0:A6 的三个去向改**选择组**(契约 0.2.0)—— outcome 由放行选口给出,G4 才判得了。
|
||||
* 0.8.0:对齐 Mastra 数据流 —— A2 的入参 `material` 更名 `pn`(它就是 PN):
|
||||
* 边不再带映射,数据按字段名流动,上游发的名就得是下游收的名。
|
||||
* 0.9.0:范式统一(用户定)—— 业务模型从步序声明改为**案例模型**,
|
||||
* 身份里的 `paradigm` 字段随之消失;占位不再自动落台账。
|
||||
* 在途任务按启动时版本跑完,不受影响。
|
||||
*/
|
||||
packageVersion: '0.9.0',
|
||||
category: '统一报价',
|
||||
sdkVersion: '0.1.0-m5',
|
||||
checksum,
|
||||
});
|
||||
|
||||
const harness = { memoryWindow: 20, stallAfter: 'PT45M', nudge: [] };
|
||||
|
||||
/** 七件契约里逐槽位相同的空段 —— 槽位期没有必交付物/验收细则,实现期再补 */
|
||||
const contractRest = { requiredDeliverables: [], acceptanceCriteria: [], prohibitions: [] };
|
||||
|
||||
/**
|
||||
* 各槽位的步/项清单 —— **预算与骨架共用同一个数组**,数不上不会漂移。
|
||||
* flow 是声明式人类步(一步一张工单);loop 的项由 Agent 临场推进,
|
||||
* 最坏情况一项问一次人,所以也按项数给预算。
|
||||
*/
|
||||
const STEPS = {
|
||||
rfqIntake: ['接收登记', '追问补全信息', '逐条解析归一', '查历史价', '逐条定状况标', '定去向与派单'],
|
||||
altSelection: ['替代对标表', '初筛清单'],
|
||||
sourcingInquiry: ['寻源比价表', '供应商回价单'],
|
||||
costPricing: ['取价基准', '逐条定价'],
|
||||
quoteNegotiation: ['正式报价单'],
|
||||
intelArchive: ['结果登记', '画像更新', '落地率统计'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 预算 —— `humanCalls` 必须**够这个槽位实际要问人的次数**。
|
||||
*
|
||||
* 曾经六个槽位共用 `humanCalls: 3`,而那时 A1 有 6 个人类步:走到第 3 步就 `exhausted`
|
||||
* 终态、工作流转 abnormal,活动**结构上永远走不完**(实测:used 3 / cap 3,停在 A1)。
|
||||
* 现在占位期是**机器步 + 一张验收单**,只问人一次,所以固定给 2(1 张验收 + 1 次余量)。
|
||||
* 这条约束本身不随形态变:实现期活动自己声明真实预算时,实际要问人的次数 ≤ humanCalls。
|
||||
*/
|
||||
const budgetFor = () => ({
|
||||
steps: 40,
|
||||
tokens: 200_000,
|
||||
activeMinutes: 60,
|
||||
humanCalls: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* 授权面申报的人工调用预算 —— 与运行时真正吃的那个数**同源**。
|
||||
* 运行时只认 `resources.budget.humanCalls`(见 `wiring/engines.ts` 的 `caps`);
|
||||
* `authorization.humanCallBudget` 只给治理台显示。两者不一致,治理台就会摆一个假数。
|
||||
*/
|
||||
const authorizationFor = () => ({
|
||||
discretion: [],
|
||||
preauthorizedActions: [],
|
||||
humanCallBudget: budgetFor().humanCalls,
|
||||
});
|
||||
|
||||
export const MVP_SLOTS: readonly ActivityManifest[] = [
|
||||
/* ── A1 客户RFQ受理(SOP · BD) ──────────────────────────────────── */
|
||||
activityManifestSchema.parse({
|
||||
identity: identity('rfq-intake', '客户RFQ受理'),
|
||||
contract: {
|
||||
activityId: 'rfq-intake',
|
||||
version: '0.1.0',
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['customerCode', 'rfqRef'],
|
||||
properties: {
|
||||
customerCode: { type: 'string' },
|
||||
rfqRef: { type: 'string' },
|
||||
subject: { type: 'string' },
|
||||
body: { type: 'string' },
|
||||
quotedAt: { type: 'string' },
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
},
|
||||
output: {
|
||||
mandatory: [
|
||||
{
|
||||
name: 'intake',
|
||||
description: '统一受理记录(打标)+ 两枚去向旗',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['records', 'needAlternative', 'needSourcing'],
|
||||
properties: {
|
||||
records: { type: 'array' },
|
||||
pn: { type: 'string' },
|
||||
type_tag: { type: 'string' },
|
||||
needAlternative: { type: 'boolean' },
|
||||
needSourcing: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: authorizationFor(),
|
||||
resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['BD'], budget: budgetFor() },
|
||||
},
|
||||
declarations: {
|
||||
models: [],
|
||||
listeners: [],
|
||||
harness,
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
ledger: declarationsFor([
|
||||
'project.rfq_file',
|
||||
'project.intake_record',
|
||||
'project.pn_class',
|
||||
// 受理结论:两枚去向旗落在这里(一单一行,G1/G2 读同一个值)
|
||||
'project.intake_verdict',
|
||||
'project.alt_request',
|
||||
'project.inquiry_request',
|
||||
'intel.history_price',
|
||||
'customer.profile',
|
||||
]),
|
||||
},
|
||||
assets: {
|
||||
businessModel: placeholderSkeleton(STEPS.rfqIntake, '客户RFQ受理'),
|
||||
skills: [],
|
||||
viewTemplates: [],
|
||||
cardSchemas: {},
|
||||
distillHooks: [],
|
||||
},
|
||||
samples: [
|
||||
{
|
||||
name: '契约样例:受理一单',
|
||||
input: { customerCode: 'C001', rfqRef: 'RFQ-001' },
|
||||
expect: { ports: ['intake'] },
|
||||
},
|
||||
],
|
||||
entry: { file: 'index.js', checksum },
|
||||
}),
|
||||
|
||||
/* ── A2 替代选型(Case · FAE) ──────────────────────────────────── */
|
||||
activityManifestSchema.parse({
|
||||
identity: identity('alt-selection', '替代选型'),
|
||||
contract: {
|
||||
activityId: 'alt-selection',
|
||||
// 0.3.0:material 更名 pn(对齐 Mastra 名流动 —— 上游 A1 发的就是 pn)
|
||||
version: '0.3.0',
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['pn', 'rfqRef'],
|
||||
properties: {
|
||||
pn: { type: 'string' },
|
||||
chip_type: { type: 'string' },
|
||||
spec_params: { type: 'string' },
|
||||
scenario: { type: 'string' },
|
||||
needSourcing: { type: 'boolean' },
|
||||
/*
|
||||
* 询价单号。声明它是为了**不偷渡**:边上映射一个契约没声明的字段,
|
||||
* 闭合校验的类型检查会因 `targetType === undefined` 直接跳过 —— 不报错,
|
||||
* 但那是默许不是正当,而且下一个读代码的人无从知道 A2 还收这个字段。
|
||||
*/
|
||||
rfqRef: { type: 'string' },
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
},
|
||||
output: {
|
||||
mandatory: [
|
||||
{
|
||||
name: 'benchmark',
|
||||
description: '替代对标表(已签字)+ needSourcing 原样带出(G2 在本路径读它)',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rows', 'needSourcing'],
|
||||
properties: { rows: { type: 'array' }, needSourcing: { type: 'boolean' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: authorizationFor(),
|
||||
resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['FAE'], budget: budgetFor() },
|
||||
},
|
||||
declarations: {
|
||||
models: [],
|
||||
listeners: [],
|
||||
harness,
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
// 受理结论同在声明里:A2 要把 needSourcing 回写**同一行**,去向只有一个出处
|
||||
ledger: declarationsFor([
|
||||
'intel.chip_spec',
|
||||
'intel.public_select',
|
||||
'project.alt_benchmark',
|
||||
'project.intake_verdict',
|
||||
]),
|
||||
},
|
||||
assets: {
|
||||
businessModel: placeholderSkeleton(STEPS.altSelection, '替代选型'),
|
||||
skills: [],
|
||||
viewTemplates: [],
|
||||
cardSchemas: {},
|
||||
distillHooks: [],
|
||||
},
|
||||
samples: [
|
||||
{
|
||||
name: '契约样例:一件替代评估',
|
||||
input: { pn: 'LM358DR' },
|
||||
expect: { ports: ['benchmark'] },
|
||||
},
|
||||
],
|
||||
entry: { file: 'index.js', checksum },
|
||||
}),
|
||||
|
||||
/* ── A3 寻源询价(Case · PRM) ──────────────────────────────────── */
|
||||
activityManifestSchema.parse({
|
||||
identity: identity('sourcing-inquiry', '寻源询价'),
|
||||
contract: {
|
||||
activityId: 'sourcing-inquiry',
|
||||
version: '0.1.0',
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['pn'],
|
||||
properties: {
|
||||
pn: { type: 'string' },
|
||||
brand: { type: 'string' },
|
||||
era: { type: 'string' },
|
||||
project_stage: { type: 'string' },
|
||||
accept_alt: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
},
|
||||
output: {
|
||||
mandatory: [
|
||||
{
|
||||
name: 'compare',
|
||||
description: '寻源比价表 + 供应商底价表',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rows', 'floors'],
|
||||
properties: { rows: { type: 'array' }, floors: { type: 'array' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: authorizationFor(),
|
||||
resources: {
|
||||
tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }],
|
||||
skills: [],
|
||||
models: [],
|
||||
roles: ['PRM'],
|
||||
budget: budgetFor(),
|
||||
},
|
||||
},
|
||||
declarations: {
|
||||
models: [],
|
||||
listeners: [],
|
||||
harness,
|
||||
tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }],
|
||||
ledger: declarationsFor([
|
||||
'supplier.channel_dir',
|
||||
'supplier.dead_stock',
|
||||
'project.sourcing_compare',
|
||||
'project.supplier_floor',
|
||||
]),
|
||||
},
|
||||
assets: {
|
||||
businessModel: placeholderSkeleton(STEPS.sourcingInquiry, '渠道寻源'),
|
||||
skills: [],
|
||||
viewTemplates: [],
|
||||
cardSchemas: {},
|
||||
distillHooks: [],
|
||||
},
|
||||
samples: [
|
||||
{
|
||||
name: '契约样例:一单询价',
|
||||
input: { pn: 'LM358DR' },
|
||||
expect: { ports: ['compare'] },
|
||||
},
|
||||
],
|
||||
entry: { file: 'index.js', checksum },
|
||||
}),
|
||||
|
||||
/* ── A4 成本定价(SOP · BD) ────────────────────────────────────── */
|
||||
activityManifestSchema.parse({
|
||||
identity: identity('cost-pricing', '成本定价'),
|
||||
contract: {
|
||||
activityId: 'cost-pricing',
|
||||
version: '0.1.0',
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['records'],
|
||||
properties: {
|
||||
records: { type: 'array' },
|
||||
floors: { type: 'array' },
|
||||
benchmark: { type: 'array' },
|
||||
customerCode: { type: 'string' },
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
},
|
||||
output: {
|
||||
mandatory: [
|
||||
{
|
||||
name: 'draft',
|
||||
description: '报价草案:逐条定价,先过底价门禁再提审',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['rows'],
|
||||
properties: { rows: { type: 'array' }, quote_no: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: authorizationFor(),
|
||||
resources: { tools: [{ name: 'crm.history-price-read' }], skills: [], models: [], roles: ['BD'], budget: budgetFor() },
|
||||
},
|
||||
declarations: {
|
||||
models: [],
|
||||
listeners: [],
|
||||
harness,
|
||||
tools: [{ name: 'crm.history-price-read' }],
|
||||
ledger: declarationsFor(['intel.history_price', 'customer.profile', 'project.quote_draft']),
|
||||
},
|
||||
assets: {
|
||||
businessModel: placeholderSkeleton(STEPS.costPricing, '成本定价'),
|
||||
skills: [],
|
||||
viewTemplates: [],
|
||||
cardSchemas: {},
|
||||
distillHooks: [],
|
||||
},
|
||||
samples: [
|
||||
{
|
||||
name: '契约样例:一批草案',
|
||||
input: { records: [] },
|
||||
expect: { ports: ['draft'] },
|
||||
},
|
||||
],
|
||||
entry: { file: 'index.js', checksum },
|
||||
}),
|
||||
|
||||
/* ── A6 报价议价(Case · BD) ───────────────────────────────────── */
|
||||
activityManifestSchema.parse({
|
||||
identity: identity('quote-negotiation', '报价议价'),
|
||||
contract: {
|
||||
activityId: 'quote-negotiation',
|
||||
version: '0.2.0',
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['rows'],
|
||||
properties: { rows: { type: 'array' }, quote_no: { type: 'string' }, customerCode: { type: 'string' } },
|
||||
},
|
||||
messageAllowed: true,
|
||||
},
|
||||
output: {
|
||||
mandatory: [
|
||||
{
|
||||
name: 'formal',
|
||||
description: '正式报价单(已送出)+ 跟进提醒',
|
||||
payload: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
quote_no: { type: 'string' },
|
||||
customer_code: { type: 'string' },
|
||||
rows: { type: 'array' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
/*
|
||||
* 三个去向是**选择组**,不是必交口里的一个字段:人在放行时恰选一口,
|
||||
* 那一口就是这次运行的 outcome(引擎按选口注入),G4 判的正是它。
|
||||
* 从前写成 formal 口的必填 outcome + selective: [] —— 引擎只在选择组
|
||||
* 恰发一口时才注入 outcome,没有选择组就永远没有 outcome,G4 三条边全不成立。
|
||||
*/
|
||||
selective: [
|
||||
{
|
||||
group: '议价结果',
|
||||
ports: [
|
||||
{ name: 'won', description: '成交 —— 转订单履约', payload: { type: 'object' } },
|
||||
{ name: 'revise', description: '有异议 —— 退回重新定价', payload: { type: 'object' } },
|
||||
{ name: 'lost', description: '拒绝或超时 —— 归档闭环', payload: { type: 'object' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: authorizationFor(),
|
||||
resources: {
|
||||
tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }],
|
||||
skills: [],
|
||||
models: [],
|
||||
roles: ['BD'],
|
||||
budget: budgetFor(),
|
||||
},
|
||||
},
|
||||
declarations: {
|
||||
models: [],
|
||||
listeners: [],
|
||||
harness,
|
||||
tools: [{ name: 'crm.history-price-read' }, { name: 'mail.send' }],
|
||||
ledger: declarationsFor(['project.formal_quote', 'project.followup']),
|
||||
},
|
||||
assets: {
|
||||
businessModel: placeholderSkeleton(STEPS.quoteNegotiation, '报价议价'),
|
||||
skills: [],
|
||||
viewTemplates: [],
|
||||
cardSchemas: {},
|
||||
distillHooks: [],
|
||||
},
|
||||
samples: [
|
||||
{
|
||||
name: '契约样例:一单议价',
|
||||
input: { rows: [] },
|
||||
expect: { ports: ['formal'] },
|
||||
},
|
||||
],
|
||||
entry: { file: 'index.js', checksum },
|
||||
}),
|
||||
|
||||
/* ── A7 情报归档(SOP · BD) ────────────────────────────────────── */
|
||||
activityManifestSchema.parse({
|
||||
identity: identity('intel-archive', '情报归档'),
|
||||
contract: {
|
||||
activityId: 'intel-archive',
|
||||
version: '0.1.0',
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['outcome'],
|
||||
properties: {
|
||||
outcome: { type: 'string' },
|
||||
quote_no: { type: 'string' },
|
||||
customer_code: { type: 'string' },
|
||||
quotedAt: { type: 'string' },
|
||||
},
|
||||
},
|
||||
messageAllowed: true,
|
||||
},
|
||||
output: {
|
||||
mandatory: [
|
||||
{
|
||||
name: 'stats',
|
||||
description: '不成交也留资产:情报、客户画像、落地率',
|
||||
payload: {
|
||||
type: 'object',
|
||||
required: ['landing_rate'],
|
||||
properties: { landing_rate: { type: 'number' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selective: [],
|
||||
},
|
||||
...contractRest,
|
||||
authorization: authorizationFor(),
|
||||
resources: { tools: [], skills: [], models: [], roles: ['BD'], budget: budgetFor() },
|
||||
},
|
||||
declarations: {
|
||||
models: [],
|
||||
listeners: [],
|
||||
harness,
|
||||
tools: [],
|
||||
ledger: declarationsFor(['intel.history_price', 'customer.profile', 'project.landing_stats']),
|
||||
},
|
||||
assets: {
|
||||
businessModel: placeholderSkeleton(STEPS.intelArchive, '情报归档'),
|
||||
skills: [],
|
||||
viewTemplates: [],
|
||||
cardSchemas: {},
|
||||
distillHooks: [],
|
||||
},
|
||||
samples: [
|
||||
{
|
||||
name: '契约样例:一次归档',
|
||||
input: { outcome: 'lost' },
|
||||
expect: { ports: ['stats'] },
|
||||
},
|
||||
],
|
||||
entry: { file: 'index.js', checksum },
|
||||
}),
|
||||
];
|
||||
/** 已退役的槽位 —— 真活动包顶替它之后,库里那一份也该走 */
|
||||
const RETIRED_SLOTS = [
|
||||
'rfq-intake',
|
||||
'alt-selection',
|
||||
'sourcing-inquiry',
|
||||
'cost-pricing',
|
||||
'quote-negotiation',
|
||||
'intel-archive',
|
||||
] as const;
|
||||
|
||||
export async function installSlots(container: Container): Promise<void> {
|
||||
for (const manifest of MVP_SLOTS) {
|
||||
const { activityId, packageVersion, name } = manifest.identity;
|
||||
|
||||
/*
|
||||
* 先问库里有没有这一版 —— 因为「安装恒为新增、版本并存」:同版再装是 no-op,
|
||||
* manifest 一个字不动。不问就报「已装入」等于**撒谎**:改了槽位内容重播种,
|
||||
* 屏幕说装好了,库里还是旧的,人拿着旧行为查新代码(实测踩到,那次是卡面没换)。
|
||||
*/
|
||||
const already = await container.registry
|
||||
.manifestOf(activityId, packageVersion)
|
||||
.then(() => true)
|
||||
for (const activityId of RETIRED_SLOTS) {
|
||||
const installed = await container.registry
|
||||
.get(activityId)
|
||||
.then((card) => card !== undefined)
|
||||
.catch(() => false);
|
||||
|
||||
const verdict = await container.registry.install(manifest, SLOT_ARTIFACT);
|
||||
const errors = verdict.staticFindings.filter((finding) => finding.severity === 'error');
|
||||
process.stdout.write(
|
||||
` 槽位 ${name}${already ? ` v${packageVersion} 已在库,未改动 —— 要换内容请升包版` : ` v${packageVersion} 已装`}` +
|
||||
` · 静态审 ${verdict.staticFindings.length} 条` +
|
||||
(errors.length > 0 ? ` —— ERROR: ${errors.map((e) => e.message).join('; ')}` : '') +
|
||||
'\n',
|
||||
);
|
||||
if (!installed) continue;
|
||||
try {
|
||||
await container.registry.uninstall(activityId);
|
||||
process.stdout.write(` 槽位 ${activityId} 已退役卸载(真活动包顶替)\n`);
|
||||
} catch (error) {
|
||||
process.stdout.write(
|
||||
` 槽位 ${activityId} 卸不掉:${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* 活动与工具的解耦就在②③之间:槽位只声明工具**名**,注册、连接值、白名单全在这里。
|
||||
*/
|
||||
import type { Container } from './container.ts';
|
||||
import { installRealActivities } from './seed-activities.ts';
|
||||
import { installSlots } from './seed-mvp-slots.ts';
|
||||
import { seedMvpFlow } from './seed-mvp-flow.ts';
|
||||
|
||||
@@ -16,16 +17,27 @@ import { seedMvpFlow } from './seed-mvp-flow.ts';
|
||||
const HISTORY_TOOL = {
|
||||
name: 'crm.history-price-read',
|
||||
displayName: '历史价查询',
|
||||
version: '1.0.0',
|
||||
description: '读取历史价格情报:某 PN 最近一次报价距今天数、行情是否平稳、生命周期状态',
|
||||
input: { type: 'object', required: ['pn'], properties: { pn: { type: 'string' } } },
|
||||
version: '1.1.0',
|
||||
description:
|
||||
'读取历史价格情报:某 PN 最近一次报价距今天数、行情是否平稳、生命周期状态、上次成交单价。' +
|
||||
'一次可查一批 —— 传 pns 数组,出参走 rows(一单几十条料不必一条一调)。',
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pn: { type: 'string', description: '单条查询' },
|
||||
pns: { type: 'array', items: { type: 'string' }, description: '批量查询(优先于 pn)' },
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pn: { type: 'string' },
|
||||
historyDays: { type: 'integer' },
|
||||
marketStable: { type: 'boolean' },
|
||||
lifecycle: { type: 'string' },
|
||||
hit: { type: 'boolean' },
|
||||
lastPrice: { type: 'number' },
|
||||
rows: { type: 'array', description: '批量查询的逐条结果' },
|
||||
},
|
||||
},
|
||||
effect: 'read' as const,
|
||||
@@ -53,6 +65,57 @@ const MAIL_TOOL = {
|
||||
connectionKeys: ['endpoint'],
|
||||
};
|
||||
|
||||
/**
|
||||
* 逐条打标 —— **纯工具**(活动私有作用域,`ownerActivityId` 定死是谁的)。
|
||||
*
|
||||
* 它是三分类规则 P-03 的**求值器**:同输入必同结论、零方差零 token、命中规则行随出参留痕。
|
||||
* 「能算的不问 LLM」的具体形态就是这样一枚工具 —— 不是让模型照着规则表判,
|
||||
* 而是把规则表做成工具,模型只负责把判据取齐。
|
||||
*
|
||||
* 出参键名与台账列同名(`type_tag` / `basis`):拿回来直接 write,不必让模型改名 ——
|
||||
* 那一步交给模型做,它会漏。
|
||||
*/
|
||||
const CLASSIFY_TOOL = {
|
||||
name: 'quote-intake.classify',
|
||||
displayName: '料号三分类打标',
|
||||
version: '1.0.0',
|
||||
description:
|
||||
'按三分类规则 P-03 逐条打标(A 历史价≤90天且行情稳 / B 无历史·过期·波动 / C EOL·缺货·要替代)。' +
|
||||
'一次可打一批 —— 传 rows(每行含 pn 与三项判据),出参走 results。',
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rows: {
|
||||
type: 'array',
|
||||
description: '批量:每行 { pn, history_days, market_stable, lifecycle }',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
history_days: { type: 'integer', description: '单条:距上次报价天数' },
|
||||
market_stable: { type: 'boolean', description: '单条:行情是否稳定' },
|
||||
lifecycle: { type: 'string', description: '单条:生命周期状态' },
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
results: { type: 'array', description: '批量结果:每行 { pn, type_tag, play, basis, rule }' },
|
||||
type_tag: { type: 'string' },
|
||||
play: { type: 'string' },
|
||||
basis: { type: 'string' },
|
||||
rule: { type: 'string', description: '命中的规则行 —— 留痕靠它' },
|
||||
},
|
||||
},
|
||||
effect: 'read' as const,
|
||||
errors: [{ code: 'no-match', retryable: false, message: '三分类规则无命中行' }],
|
||||
idempotent: true,
|
||||
timeoutMs: 5_000,
|
||||
retries: 0,
|
||||
connectionKeys: [] as string[],
|
||||
// 纯工具:活动私有作用域 —— 别的活动调不到,白名单也给不了
|
||||
pure: true,
|
||||
ownerActivityId: 'quote-intake',
|
||||
};
|
||||
|
||||
/**
|
||||
* 活动槽位的占位产出 —— **编排层跑通期专用的纯工具**。
|
||||
*
|
||||
@@ -108,7 +171,8 @@ const PLACEHOLDER_TOOL = {
|
||||
|
||||
/** 白名单:逐槽位授权 —— 谁能调什么,治理台说了算,活动代码里看不到这张表 */
|
||||
const ALLOWLIST: Readonly<Record<string, readonly string[]>> = {
|
||||
'rfq-intake': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name],
|
||||
// A1 已是真活动:白名单里摘掉占位工具 —— 它一在,模型就会拿它糊弄过去
|
||||
'quote-intake': [HISTORY_TOOL.name],
|
||||
'alt-selection': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name],
|
||||
'sourcing-inquiry': [HISTORY_TOOL.name, MAIL_TOOL.name, PLACEHOLDER_TOOL.name],
|
||||
'cost-pricing': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name],
|
||||
@@ -147,13 +211,20 @@ export async function seedMvp(container: Container): Promise<void> {
|
||||
log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`);
|
||||
|
||||
/* ② 功能工具注册两审 + 逐槽位白名单 */
|
||||
for (const contract of [HISTORY_TOOL, MAIL_TOOL, PLACEHOLDER_TOOL]) {
|
||||
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL]) {
|
||||
const verdict = await container.toolbox.register({
|
||||
contract,
|
||||
samples: [
|
||||
{
|
||||
// 样例集一份喂三件工具:并集覆盖各家必填
|
||||
input: { pn: 'LM358DR', to: 'x@y.com', subject: '询价' },
|
||||
// 样例集一份喂四件工具:并集覆盖各家必填
|
||||
input: {
|
||||
pn: 'LM358DR',
|
||||
to: 'x@y.com',
|
||||
subject: '询价',
|
||||
history_days: 30,
|
||||
market_stable: true,
|
||||
lifecycle: '在产',
|
||||
},
|
||||
expectShape: { type: 'object' },
|
||||
},
|
||||
],
|
||||
@@ -167,11 +238,17 @@ export async function seedMvp(container: Container): Promise<void> {
|
||||
for (const [activityId, tools] of Object.entries(ALLOWLIST)) {
|
||||
await container.toolbox.setAllowlist(activityId, tools);
|
||||
}
|
||||
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 占位纯工具 1,逐槽位授权)');
|
||||
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 2,逐活动授权)');
|
||||
|
||||
/* ③ 六个活动槽位经安装通道装入 —— 声明物化把四域十八对象建出来 */
|
||||
/*
|
||||
* ③ 活动装入 —— 两批:
|
||||
* · 真活动包经**安装通道读文件**装(系统外开发、安装即用);
|
||||
* · 尚未开发的槽位照旧从常量表装(契约就绪、实现留白),让编排层能整条走通。
|
||||
* 两批都走同一道安装两审,声明物化顺带把台账表建出来。
|
||||
*/
|
||||
await installRealActivities(container);
|
||||
await installSlots(container);
|
||||
log('③ 六个活动槽位已装入(契约就绪,实现留白)');
|
||||
log('③ 活动已装入(真活动包 1 · 槽位 6:契约就绪、实现留白)');
|
||||
|
||||
/* ④ 流程包 1:1 + 触发器 + 通道 */
|
||||
await seedMvpFlow(container);
|
||||
|
||||
@@ -56,6 +56,28 @@ export function createServer(container: Container): Hono<AppEnv> {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
/**
|
||||
* 治理面的写操作 → **一律广播一次 `system`**(2026-07-30)。
|
||||
*
|
||||
* 从前 `kind:'system'` 只在类型里声明过,**全仓一处也没发过** —— 于是治理台的
|
||||
* 全部配置面(活动库 / 工具箱 / 大模型 / 流程包 / 数据域 / 驾驶舱指标 / 触发器)
|
||||
* 只有自己那一次 mutation 之后的本地失效才刷新:换一个浏览器标签、或另一个人改了配置,
|
||||
* 这边纹丝不动,直到手动刷新。
|
||||
*
|
||||
* 放在中间件而不是逐个路由里,是因为治理写路由有二十几条、还会长 ——
|
||||
* 挨个加一行的写法,漏一条就是一处**看不见的**不同步(这类漏最难发现:
|
||||
* 它不报错,只是画面比事实旧)。这里一处拦住,新增路由自动生效。
|
||||
*
|
||||
* 只在**成功**(2xx)之后发;推送不带载荷,前端照旧回服务端重取。
|
||||
*/
|
||||
app.use('/api/gov/*', async (c, next) => {
|
||||
await next();
|
||||
const mutating = c.req.method !== 'GET' && c.req.method !== 'HEAD';
|
||||
if (mutating && c.res.status >= 200 && c.res.status < 300) {
|
||||
container.broadcaster.publish({ kind: 'system', id: 'gov', reason: `${c.req.method} ${c.req.path}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.onError((error, c) => {
|
||||
const body: ApiError = {
|
||||
code: 'internal',
|
||||
|
||||
@@ -48,6 +48,13 @@ export type ToolBusPorts = {
|
||||
keyFilter: Record<string, string>;
|
||||
deadline: string;
|
||||
}) => Promise<{ id: string }>;
|
||||
/** 台账工具:写草稿(基座六件之 ledger)—— 过程写的唯一形态 */
|
||||
readonly draftWrite: (input: {
|
||||
taskId: TaskId;
|
||||
/** 台账地址「域.对象」—— 与单拍路径的 `write` 行动同一种写法 */
|
||||
address: string;
|
||||
rows: ReadonlyArray<{ key: Record<string, string | number>; patch: Record<string, unknown> }>;
|
||||
}) => Promise<{ written: number }>;
|
||||
/** LLM 工具:经模型网关 */
|
||||
readonly generate: (input: {
|
||||
model: string;
|
||||
@@ -110,6 +117,18 @@ function toZod(schema: Record<string, unknown> | undefined): z.ZodTypeAny {
|
||||
return describe(z.unknown());
|
||||
}
|
||||
|
||||
/** 只收 `{key, patch}` 都是对象的行 —— 形状不对的丢掉,不猜(猜错就往台账落一行来路不明的数) */
|
||||
function isDraftRow(row: unknown): row is { key: Record<string, string | number>; patch: Record<string, unknown> } {
|
||||
if (typeof row !== 'object' || row === null) return false;
|
||||
const candidate = row as Record<string, unknown>;
|
||||
const key = candidate['key'];
|
||||
const patch = candidate['patch'];
|
||||
return (
|
||||
typeof key === 'object' && key !== null && !Array.isArray(key) &&
|
||||
typeof patch === 'object' && patch !== null && !Array.isArray(patch)
|
||||
);
|
||||
}
|
||||
|
||||
/** LLM 五件的提示词:系统内化的能力,提示词也归系统 —— 活动不写、也改不了 */
|
||||
function llmPrompt(id: string, args: Record<string, unknown>): { system: string; prompt: string } {
|
||||
const j = (v: unknown) => JSON.stringify(v ?? null);
|
||||
@@ -185,6 +204,24 @@ export function buildMastraTools(
|
||||
return { asked: true, cardId: card.id };
|
||||
}
|
||||
|
||||
if (tool.kind === 'ledger') {
|
||||
/*
|
||||
* 台账草稿写 —— 地址与列名的校验在装配层的实现里(它认识活动清单),
|
||||
* 不合规抛错,由 Mastra 把错误原文回给模型,让它这一拍就改对。
|
||||
*/
|
||||
const rows = Array.isArray(args['rows']) ? args['rows'] : [];
|
||||
try {
|
||||
const done = await ports.draftWrite({
|
||||
taskId: context.taskId,
|
||||
address: String(args['address'] ?? ''),
|
||||
rows: rows.filter(isDraftRow),
|
||||
});
|
||||
return { ok: true, written: done.written };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
if (tool.kind === 'llm') {
|
||||
const { system, prompt } = llmPrompt(tool.id, args);
|
||||
const result = await ports.generate({
|
||||
|
||||
@@ -9,23 +9,60 @@ import type { ToolHandler } from '@modules/toolbox';
|
||||
/**
|
||||
* 查历史价 —— 外部系统(CRM)的**录制回放替身**。
|
||||
* README:mock 只允许出现在外界;系统内部机制一律真跑。
|
||||
*
|
||||
* **一次可查一批**(2026-07-30 加 `pns`):一单几十条料,一条一调就是几十拍模型 ——
|
||||
* 每拍都要重读一遍上下文,而拿回来的东西一模一样。契约演进只增不破坏:
|
||||
* `pn` 仍收单条(A4 定价那侧照旧),新增的 `pns` 是可选入参,出参多一个 `rows`。
|
||||
*/
|
||||
const historyPriceRead: ToolHandler = async ({ input }) => {
|
||||
const pn = String(input['pn'] ?? '');
|
||||
const HISTORY_SAMPLES: Record<string, { historyDays: number; marketStable: boolean; lifecycle: string; lastPrice: number }> = {
|
||||
// 录制样本:演示用的历史价情报(真 CRM 接入时换 endpoint,契约不变)
|
||||
// 历史价情报含**上次成交单价** —— 没有价的「历史价工具」是取不出价格基准的
|
||||
const table: Record<string, { historyDays: number; marketStable: boolean; lifecycle: string; lastPrice: number }> = {
|
||||
LM358DR: { historyDays: 30, marketStable: true, lifecycle: '在产', lastPrice: 8.4 },
|
||||
NE555P: { historyDays: 240, marketStable: false, lifecycle: '在产', lastPrice: 3.25 },
|
||||
TL072CDR: { historyDays: 15, marketStable: true, lifecycle: 'EOL', lastPrice: 21.6 },
|
||||
};
|
||||
const hit = table[pn];
|
||||
if (hit === undefined) {
|
||||
// 无历史 → 走 B(重估后报);这不是错误
|
||||
// 无历史 → 走 B(重估后报);没有价格基准,基准价回 0 由下游按「无基准」处置
|
||||
return { ok: true, output: { historyDays: 9999, marketStable: false, lifecycle: '未知', hit: false, lastPrice: 0 } };
|
||||
LM358DR: { historyDays: 30, marketStable: true, lifecycle: '在产', lastPrice: 8.4 },
|
||||
NE555P: { historyDays: 240, marketStable: false, lifecycle: '在产', lastPrice: 3.25 },
|
||||
TL072CDR: { historyDays: 15, marketStable: true, lifecycle: 'EOL', lastPrice: 21.6 },
|
||||
|
||||
/*
|
||||
* ── Itelma MD14.1 的九条 —— **老客户测试案例的情报底账** ────────────────
|
||||
*
|
||||
* 数字不是编的:逐条照 `JCAI_PRD/docs/活动执行页/A1-询价受理记录.html` 那份
|
||||
* 验收目标里写明的台账价与判据抄下来,天数按来件日 07-10 折算。
|
||||
* 抄下来是为了让**三分类真判得出** 5A / 3B / 1C —— 而那一分类决定这一单
|
||||
* 走哪个出口(有 C 类 → 去 A2 选型)。情报造得随意一点,整条流就走岔了。
|
||||
*
|
||||
* 没录进来的两条(TVM6C160K203R 压敏电阻、DAG2400L321504XZB01-R0 定制天线)
|
||||
* **是故意不录的**:台账里本来就没有它们的历史价,查不到会回 historyDays=9999,
|
||||
* 命中 B 类「重估后报」—— 那正是验收目标上写的结论。
|
||||
*/
|
||||
'PESD2ETH-XR': { historyDays: 59, marketStable: true, lifecycle: '在产', lastPrice: 0.225 },
|
||||
BSCH00160808R12J00: { historyDays: 25, marketStable: true, lifecycle: '在产', lastPrice: 0.057 },
|
||||
// 过期 121 天:天数一过 90 就落 B,必须重询锁价
|
||||
BWVF005050201R0T00: { historyDays: 121, marketStable: true, lifecycle: '在产', lastPrice: 0.088 },
|
||||
CJTB0101R6N: { historyDays: 43, marketStable: true, lifecycle: '在产', lastPrice: 0.195 },
|
||||
'SGM2202-ADJYN6G/TR': { historyDays: 38, marketStable: true, lifecycle: '在产', lastPrice: 0.47 },
|
||||
'UMW AO6604': { historyDays: 29, marketStable: true, lifecycle: '在产', lastPrice: 0.27 },
|
||||
// 原厂交期 16 周顶不住首批,客户已预授权 Analog —— 生命周期记「要求替代」,P-03 判 C
|
||||
BBPY00201209601Y00: { historyDays: 47, marketStable: true, lifecycle: '要求替代', lastPrice: 0.061 },
|
||||
};
|
||||
|
||||
/** 无历史 → 走 B(重估后报);**这不是错误**,新料号本就该重估,极大天数本身就是判据 */
|
||||
const NO_HISTORY = { historyDays: 9999, marketStable: false, lifecycle: '未知', hit: false, lastPrice: 0 };
|
||||
|
||||
const lookupHistory = (pn: string) => {
|
||||
const hit = HISTORY_SAMPLES[pn];
|
||||
return hit === undefined ? { ...NO_HISTORY } : { ...hit, hit: true };
|
||||
};
|
||||
|
||||
const historyPriceRead: ToolHandler = async ({ input }) => {
|
||||
const batch = input['pns'];
|
||||
if (Array.isArray(batch)) {
|
||||
const rows = batch.map((raw) => {
|
||||
const pn = String(raw ?? '');
|
||||
return { pn, ...lookupHistory(pn) };
|
||||
});
|
||||
return { ok: true, output: { rows } };
|
||||
}
|
||||
return { ok: true, output: { ...hit, hit: true } };
|
||||
const pn = String(input['pn'] ?? '');
|
||||
return { ok: true, output: { pn, ...lookupHistory(pn) } };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -44,23 +81,79 @@ const basis: ToolHandler = async ({ input }) => {
|
||||
};
|
||||
|
||||
/** 三分类规则 P-03 的决策表 —— 与活动包里的资产同一份规则 */
|
||||
/*
|
||||
* 三分类规则 P-03 的决策表 —— 与活动包里的资产同一份规则。
|
||||
* 出参键名用 `type_tag` 与台账列同名:模型拿回结果直接 write,不必自己改名
|
||||
* (改名这一步交给模型做,它会漏)。`basis` 一并给出,分级依据不必再编。
|
||||
*/
|
||||
const P03_RULES = [
|
||||
{ id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: { type: 'C', play: '转案处理' } },
|
||||
{ id: 'p03-a', when: ['<= 90', 'true', '-'], then: { type: 'A', play: '直接快答' } },
|
||||
{ id: 'p03-b', when: ['-', '-', '-'], then: { type: 'B', play: '重估后报' } },
|
||||
{ id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: { type_tag: 'C', play: '转案处理', basis: 'EOL/缺货/要求替代 → C' } },
|
||||
{ id: 'p03-a', when: ['<= 90', 'true', '-'], then: { type_tag: 'A', play: '直接快答', basis: '历史价≤90天且行情稳 → A' } },
|
||||
{ id: 'p03-b', when: ['-', '-', '-'], then: { type_tag: 'B', play: '重估后报', basis: '无历史/过期/波动 → B' } },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 逐条打标 —— **纯工具**:无外效应、无连接配置、活动私有作用域。
|
||||
* 「能算的不问 LLM」:同输入必同结论,零方差零 token;命中规则行随出参留痕。
|
||||
*
|
||||
* **一次可打一批**(`rows`):判据是逐条的,结论也是逐条的,但求值不必逐拍 ——
|
||||
* 一单几十条料分几十拍打标,烧掉的全是重复上下文。
|
||||
*/
|
||||
const classify: ToolHandler = async ({ input }) => {
|
||||
const values = [input['historyDays'], input['marketStable'], input['lifecycle']];
|
||||
const classifyOne = (values: readonly unknown[]) => {
|
||||
for (const rule of P03_RULES) {
|
||||
const hit = rule.when.every((cell, i) => evaluateUnaryTest(cell, values[i], {}));
|
||||
if (hit) return { ok: true, output: { ...rule.then, rule: rule.id } };
|
||||
if (rule.when.every((cell, i) => evaluateUnaryTest(cell, values[i], {}))) {
|
||||
return { ...rule.then, rule: rule.id };
|
||||
}
|
||||
}
|
||||
return { ok: false, error: { code: 'no-match', retryable: false, message: '三分类规则无命中行' } };
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** 判据三件的取值:下划线与驼峰两认 —— 台账列是下划线,模型常写驼峰 */
|
||||
const basisOf = (row: Record<string, unknown>) => [
|
||||
row['history_days'] ?? row['historyDays'],
|
||||
row['market_stable'] ?? row['marketStable'],
|
||||
row['lifecycle'],
|
||||
];
|
||||
|
||||
const classify: ToolHandler = async ({ input }) => {
|
||||
const batch = input['rows'];
|
||||
if (Array.isArray(batch)) {
|
||||
const results: Array<Record<string, unknown>> = [];
|
||||
for (const raw of batch) {
|
||||
const row = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
const decided = classifyOne(basisOf(row));
|
||||
if (decided === undefined) {
|
||||
// 一条判不出就整批不出结论:半份分类表比没有更难查
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'no-match', retryable: false, message: `三分类规则无命中行:${String(row['pn'] ?? '(无 PN)')}` },
|
||||
};
|
||||
}
|
||||
results.push({ pn: String(row['pn'] ?? ''), ...decided });
|
||||
}
|
||||
/*
|
||||
* **条数由判定器自己数**(2026-07-31 实测逼出来的)。
|
||||
*
|
||||
* 三类条数是验收判据的入参(route-alt / route-rfq 拿它跟派单数对账)。
|
||||
* 从前只回逐条结论,条数由模型在脑子里数 —— 实测数错过:判定器给的是 5A/3B/1C,
|
||||
* 模型报的是 7A/2B/1C,而派单也按它自己那个数开(2 张询价请求)。
|
||||
* 于是等式 `rfq_request_total = type_b_total` 两边都是它自报的,**2 = 2 顺利过关**,
|
||||
* 而真实的第三条 B 类料一张单也没开 —— 靠自报两边的对账,防的是疏忽,防不了口径。
|
||||
*
|
||||
* 数数是能算的事,算完摆在出参里,模型照抄即可。
|
||||
*/
|
||||
const counts = { A: 0, B: 0, C: 0 } as Record<string, number>;
|
||||
for (const r of results) {
|
||||
const tag = String(r['type_tag'] ?? '');
|
||||
if (tag in counts) counts[tag] = (counts[tag] ?? 0) + 1;
|
||||
}
|
||||
return { ok: true, output: { results, counts, total: results.length } };
|
||||
}
|
||||
const decided = classifyOne(basisOf(input));
|
||||
if (decided === undefined) {
|
||||
return { ok: false, error: { code: 'no-match', retryable: false, message: '三分类规则无命中行' } };
|
||||
}
|
||||
return { ok: true, output: decided };
|
||||
};
|
||||
|
||||
/** 利润模型 P-01:品牌 × 类型 × 客户等级 × 数量档 → 目标毛利区间 */
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 上传件**读成文本** —— 附件要能真正喂进活动,不能只剩一个文件名。
|
||||
*
|
||||
* 活动那一侧读得懂的只有文字:S1 要把 BOM 拆成一行一条需求,拿到的必须是行与列,
|
||||
* 不是一串二进制。所以入口这一步只干一件事:**把文件读成人和模型都看得懂的文本**,
|
||||
* 读不出来就如实说读不出来(界面不撒谎),绝不返回一段猜出来的内容。
|
||||
*
|
||||
* 不引第三方库:xlsx 本质是一个 ZIP 包着几份 XML,Node 自带的 `zlib.inflateRawSync`
|
||||
* 就能解;为一件读表的活拉进一整个电子表格库,是拿依赖换五十行代码。
|
||||
*
|
||||
* 这一份是**纯计算**:进字节、出文本,不碰库不碰网,所以能逐条测。
|
||||
*/
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
|
||||
/** 读的结果 —— text 缺席不是错误,是「这类文件读不出文本」这件事实 */
|
||||
export type Extracted = {
|
||||
readonly text?: string;
|
||||
/** 一句人话:读出了什么 / 为什么读不出。会原样摆到界面上,也会进活动的入参 */
|
||||
readonly note: string;
|
||||
};
|
||||
|
||||
/** 纯文本族:直接按 UTF-8 解 */
|
||||
const TEXT_EXT = ['txt', 'csv', 'tsv', 'md', 'json', 'xml', 'html', 'htm', 'log', 'yaml', 'yml'];
|
||||
|
||||
const extOf = (name: string): string => {
|
||||
const i = name.lastIndexOf('.');
|
||||
return i === -1 ? '' : name.slice(i + 1).toLowerCase();
|
||||
};
|
||||
|
||||
/** 表格读出来上限:再大的表也不该整份灌进提示词(超出就截,并在 note 里说清楚) */
|
||||
const TEXT_CAP = 20_000;
|
||||
|
||||
export function extractText(name: string, contentType: string, bytes: Uint8Array): Extracted {
|
||||
const ext = extOf(name);
|
||||
if (ext === 'xlsx' || contentType.includes('spreadsheetml')) return fromXlsx(bytes);
|
||||
if (TEXT_EXT.includes(ext) || contentType.startsWith('text/') || contentType === 'application/json') {
|
||||
const text = new TextDecoder('utf-8').decode(bytes);
|
||||
return cap(text, `按 UTF-8 文本读出 ${text.length} 字`);
|
||||
}
|
||||
return {
|
||||
note: `${ext === '' ? '这个文件' : `.${ext} 文件`}读不出文本 —— 只有文件名与大小进得了活动,明细请贴进正文`,
|
||||
};
|
||||
}
|
||||
|
||||
function cap(text: string, how: string): Extracted {
|
||||
if (text.length <= TEXT_CAP) return { text, note: how };
|
||||
return { text: text.slice(0, TEXT_CAP), note: `${how},超出 ${TEXT_CAP} 字的部分已截断` };
|
||||
}
|
||||
|
||||
/* ==================== xlsx = ZIP + 几份 XML ==================== */
|
||||
|
||||
/**
|
||||
* 最小 ZIP 读取 —— 只认这一件事需要的部分:
|
||||
* 从尾部找中央目录结束记录(EOCD)→ 逐条读中央目录 → 按偏移取本地头 → 取数据段。
|
||||
*
|
||||
* 只支持 store(0)与 deflate(8)两种压缩法 —— xlsx 出厂就这两种。
|
||||
* 认不出的条目跳过而不是抛:一份表里混了一条读不了的条目,不该让整份表读不出来。
|
||||
*/
|
||||
function readZip(bytes: Uint8Array): Map<string, Uint8Array> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const files = new Map<string, Uint8Array>();
|
||||
|
||||
// EOCD 的签名 0x06054b50,从尾部往回找(注释段最长 64K)
|
||||
let eocd = -1;
|
||||
for (let i = bytes.length - 22; i >= 0 && i >= bytes.length - 22 - 65_536; i--) {
|
||||
if (view.getUint32(i, true) === 0x06054b50) {
|
||||
eocd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocd === -1) return files;
|
||||
|
||||
const count = view.getUint16(eocd + 10, true);
|
||||
let p = view.getUint32(eocd + 16, true);
|
||||
const dec = new TextDecoder('utf-8');
|
||||
|
||||
for (let n = 0; n < count; n++) {
|
||||
if (p + 46 > bytes.length || view.getUint32(p, true) !== 0x02014b50) break;
|
||||
const method = view.getUint16(p + 10, true);
|
||||
const compressed = view.getUint32(p + 20, true);
|
||||
const nameLen = view.getUint16(p + 28, true);
|
||||
const extraLen = view.getUint16(p + 30, true);
|
||||
const commentLen = view.getUint16(p + 32, true);
|
||||
const localAt = view.getUint32(p + 42, true);
|
||||
const entryName = dec.decode(bytes.subarray(p + 46, p + 46 + nameLen));
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
|
||||
// 本地头的可变段长度与中央目录的未必一致,必须照本地头自己的来
|
||||
if (localAt + 30 > bytes.length || view.getUint32(localAt, true) !== 0x04034b50) continue;
|
||||
const lNameLen = view.getUint16(localAt + 26, true);
|
||||
const lExtraLen = view.getUint16(localAt + 28, true);
|
||||
const from = localAt + 30 + lNameLen + lExtraLen;
|
||||
const raw = bytes.subarray(from, from + compressed);
|
||||
if (method === 0) files.set(entryName, raw);
|
||||
else if (method === 8) {
|
||||
try {
|
||||
files.set(entryName, new Uint8Array(inflateRawSync(raw)));
|
||||
} catch {
|
||||
/* 这一条解不开就跳过 —— 别让一条坏条目把整份表拖垮 */
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** XML 实体还原 —— 只有这五个是 XML 预定义的 */
|
||||
function unescapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
/** 共享字符串表:`<si>` 一条一个字符串,里面可能被拆成多个 `<t>`(富文本) */
|
||||
function sharedStrings(xml: string): string[] {
|
||||
return [...xml.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) =>
|
||||
[...(m[1] ?? '').matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''),
|
||||
);
|
||||
}
|
||||
|
||||
/** A1 → 列号 0;AB7 → 列号 27。行号不用,行由 `<row>` 自己划 */
|
||||
function columnOf(ref: string): number {
|
||||
const letters = /^([A-Z]+)/.exec(ref)?.[1] ?? 'A';
|
||||
let n = 0;
|
||||
for (const ch of letters) n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张表读成 TSV —— 给模型看的东西,行列对齐比什么都重要。
|
||||
*
|
||||
* 单元格三种取值:`t="s"` 查共享串表、`t="inlineStr"` 取内联 `<t>`、其余取 `<v>` 原样
|
||||
* (数字、日期序列号都在这一类)。日期不转换:转错了比不转更糟,原值至少可以追。
|
||||
*/
|
||||
function sheetToRows(xml: string, shared: readonly string[]): string[][] {
|
||||
const rows: string[][] = [];
|
||||
for (const rowMatch of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
|
||||
const cells: string[] = [];
|
||||
for (const cell of (rowMatch[1] ?? '').matchAll(/<c([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||
const attrs = cell[1] ?? '';
|
||||
const inner = cell[2] ?? '';
|
||||
const type = /t="([^"]+)"/.exec(attrs)?.[1];
|
||||
const at = columnOf(/r="([A-Z]+\d+)"/.exec(attrs)?.[1] ?? 'A1');
|
||||
const v = /<v>([\s\S]*?)<\/v>/.exec(inner)?.[1] ?? '';
|
||||
let text: string;
|
||||
if (type === 's') text = shared[Number(v)] ?? '';
|
||||
else if (type === 'inlineStr') {
|
||||
text = [...inner.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => unescapeXml(t[1] ?? '')).join('');
|
||||
} else text = unescapeXml(v);
|
||||
while (cells.length < at) cells.push('');
|
||||
cells.push(text);
|
||||
}
|
||||
// 整行皆空的不留:表尾常挂着一片格式化过的空行
|
||||
if (cells.some((c) => c !== '')) rows.push(cells);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function fromXlsx(bytes: Uint8Array): Extracted {
|
||||
const zip = readZip(bytes);
|
||||
if (zip.size === 0) return { note: '这份 .xlsx 打不开(不是合法的 ZIP 包)' };
|
||||
const dec = new TextDecoder('utf-8');
|
||||
const sharedRaw = zip.get('xl/sharedStrings.xml');
|
||||
const shared = sharedRaw === undefined ? [] : sharedStrings(dec.decode(sharedRaw));
|
||||
|
||||
const sheets = [...zip.keys()].filter((k) => /^xl\/worksheets\/sheet\d+\.xml$/.test(k)).sort();
|
||||
if (sheets.length === 0) return { note: '这份 .xlsx 里没有工作表' };
|
||||
|
||||
const parts: string[] = [];
|
||||
let total = 0;
|
||||
for (const key of sheets) {
|
||||
const raw = zip.get(key);
|
||||
if (raw === undefined) continue;
|
||||
const rows = sheetToRows(dec.decode(raw), shared);
|
||||
if (rows.length === 0) continue;
|
||||
total += rows.length;
|
||||
parts.push(sheets.length > 1 ? `# ${key.replace(/^xl\/worksheets\//, '')}` : '');
|
||||
parts.push(rows.map((r) => r.join('\t')).join('\n'));
|
||||
}
|
||||
if (total === 0) return { note: '这份 .xlsx 是空的(没有一行有内容)' };
|
||||
return cap(parts.filter((s) => s !== '').join('\n'), `读出 ${sheets.length} 张表、共 ${total} 行(制表符分列)`);
|
||||
}
|
||||
+1456
-40
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 唤醒脉冲 —— **人答完了就立刻接着跑**,不必等下一轮轮询。
|
||||
*
|
||||
* 两个 worker 都是 2 秒一轮(派发器投递事件、推进器推 running 的单)。
|
||||
* 平时够用:后台自己转,快两秒慢两秒没人看着。可**人刚点完一张卡**那一下不同 ——
|
||||
* 他正盯着屏幕等反应。实测点「确认写入」到引擎真转起来要 **6.8 秒**
|
||||
* (投递等一轮 + 推进等一轮),这 6.8 秒里界面上一动不动:
|
||||
* 办事条只认 `runtime === 'running'`,而那时它还是 `awaiting-event`。
|
||||
* 人看到的是「我点了,没反应」——于是再点一次。
|
||||
*
|
||||
* 所以给路由一个踢一脚的口子:回传落库后 `void pulse()`,两个 worker 就地各跑一轮。
|
||||
* **不 await**:推进一拍要调模型,秒级;回传该立刻返回,推进在后台接着办。
|
||||
*
|
||||
* 合并而不排队:正在跑就记一个「还要再来一轮」,跑完补一轮就够 ——
|
||||
* 连点五下不该叠出五轮推进。
|
||||
*/
|
||||
type Tick = () => Promise<unknown>;
|
||||
|
||||
const ticks: Tick[] = [];
|
||||
let running = false;
|
||||
let again = false;
|
||||
|
||||
/** 由 `main.ts` 在启动时登记(顺序有意义:先投递、后推进) */
|
||||
export function registerPulse(tick: Tick): void {
|
||||
ticks.push(tick);
|
||||
}
|
||||
|
||||
/** 测试用:清空登记,免得用例之间互相串 */
|
||||
export function resetPulse(): void {
|
||||
ticks.length = 0;
|
||||
running = false;
|
||||
again = false;
|
||||
}
|
||||
|
||||
export async function pulse(): Promise<void> {
|
||||
if (running) {
|
||||
again = true;
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
try {
|
||||
do {
|
||||
again = false;
|
||||
for (const tick of ticks) {
|
||||
try {
|
||||
await tick();
|
||||
} catch (error) {
|
||||
// 一个 worker 出错不该拖垮另一个 —— 它下一轮定时还会再跑
|
||||
process.stderr.write(`[pulse] ${error instanceof Error ? error.message : String(error)}\n`);
|
||||
}
|
||||
}
|
||||
} while (again);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,24 @@ export type AdvancerOptions = {
|
||||
readonly maxTicks?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 这一拍值不值得告诉在线的浏览器。
|
||||
*
|
||||
* 判据是「**推动了**」,不是「状态换了名字」—— 一条案例活动会连跑十几拍,
|
||||
* 全程 `runtime='running'` 一动不动,变的是别的东西:AI 又说了一句、台账多了三行、
|
||||
* 画面换了一张、预算读数涨了。只看状态名的话,整个跑动过程**一次广播都没有**,
|
||||
* 前端只好靠人点刷新(用户实测报的:任务列表 / 消息气泡 / 工作区 / 对话区都不自己动)。
|
||||
*
|
||||
* 空拍不广播:既不再瞒着人,也不会每两秒给在线浏览器发一次噪声。
|
||||
*/
|
||||
export function worthBroadcasting(
|
||||
before: { runtime: string; terminal?: string } | undefined,
|
||||
outcome: { progressed: boolean; task: { runtime: string; terminal?: string } },
|
||||
): boolean {
|
||||
if (outcome.progressed) return true;
|
||||
return outcome.task.runtime !== before?.runtime || outcome.task.terminal !== before?.terminal;
|
||||
}
|
||||
|
||||
export async function advanceOnce(options: AdvancerOptions): Promise<number> {
|
||||
const { container } = options;
|
||||
const rows = await container.db
|
||||
@@ -41,16 +59,30 @@ export async function advanceOnce(options: AdvancerOptions): Promise<number> {
|
||||
advanced += 1;
|
||||
|
||||
/*
|
||||
* 推进器改了状态就**广播**:任务从在办走到终态、或挂起去等人,
|
||||
* 都是在线的人该立刻看见的事。
|
||||
* 从前只有派发器(事件投递)广播,而任务是被**这里**推到终态的 ——
|
||||
* 于是「办完的活不会自己挪到已完成」,得刷页面才认(用户实测报的)。
|
||||
* **推动了就广播**,不只在状态换名字时广播。
|
||||
*
|
||||
* 原来的条件是「runtime 或 terminal 变了」—— 而一条案例活动会连跑十几拍,
|
||||
* 全程 `runtime='running'` 一动不动,变的是别的东西:AI 又说了一句、
|
||||
* 台账多了三行、画面换了一张、预算读数涨了。于是整个跑动过程**一次广播都没有**,
|
||||
* 前端只好靠人点刷新(用户实测报的:任务列表 / 消息气泡 / 工作区 / 对话区都不自己动)。
|
||||
*
|
||||
* `progressed` 正是「这一拍干了事」的信号:干了就说一声,空拍不说 ——
|
||||
* 既不再瞒着人,也不会每两秒给在线浏览器发一次噪声。
|
||||
*/
|
||||
if (outcome.task.runtime !== before?.runtime || outcome.task.terminal !== before?.terminal) {
|
||||
if (worthBroadcasting(before, outcome)) {
|
||||
container.broadcaster.publish({ kind: 'task', id: row.id, reason: 'advanced' });
|
||||
if (outcome.task.workflowId !== undefined) {
|
||||
container.broadcaster.publish({ kind: 'workflow', id: outcome.task.workflowId, reason: 'task-advanced' });
|
||||
}
|
||||
/*
|
||||
* **收尾这一下动的不只是任务。** 放行那一刻草稿合并进正式版、动作意向落地、
|
||||
* 输出口发射 —— 数据域多出真行、驾驶舱的完成数变了、活动记忆也可能长一条。
|
||||
* 那几屏的键都在 `gov` 下、不以任务为轴,只按任务广播它们一个都收不到
|
||||
* (实测:一单放行完,数据域里还是空的,直到手动刷新)。
|
||||
*/
|
||||
if (outcome.task.terminal !== undefined) {
|
||||
container.broadcaster.publish({ kind: 'system', id: 'terminal', reason: outcome.task.terminal });
|
||||
}
|
||||
}
|
||||
|
||||
// 进入等待或终结就停手 —— 挂起在等的任务由事件唤醒,不空转
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
依赖: @contracts;@shared/db;port 注入:toolbox(L2)、llm(L1)、ledger(L1)、interaction(L3)
|
||||
被依赖: loop-engine, flow-engine, skill-engine, task, process, app(治理台 · 活动库管理)
|
||||
|
||||
近变(2026-07-30):`list()` **不再列出全部版本都已卸载的活动**,`uninstall` 同时置
|
||||
`enabled=false`。卸载只把版本状态置 `uninstalled`(版本行留着,在途任务按启动版跑完),
|
||||
但活动库照旧摆着它就是撒谎 —— 实测:被真活动包顶替的槽位卸载后仍在架上,
|
||||
与顶替它的那个**同名并排**,试跑台上选错一个就试了个空壳。
|
||||
|
||||
不变量(OS 11.2 / 11.3,AM 11.3):
|
||||
- **安装两审**:静态审有 error 即不上架;「旧成不坏」是**生效硬门**,
|
||||
「旧败能成」只入重演报告、不作门槛;首装无历史即免回放审
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 判据谓词的名字必须在台账上 —— 安装期就挡下,别等跑到放行那一刻才炸。
|
||||
*
|
||||
* 这条门是拿血换来的:A2/A3/A4/A6/A7 五个活动的机检判据全都引用了
|
||||
* 台账里根本不存在的名字(`candidatesConfirmed` / `unquoted` / `unpriced`
|
||||
* / `missingQuoteFields` / `reasonLogged` ……)。S-FEEL 对未绑定的名字求值为假,
|
||||
* 于是这些判据**永远达不了标**:Agent 把活办得再干净也过不了 submit 门,
|
||||
* 连拒五拍熔断 —— MVP 整条流三轮都卡死在 A2,而清单本身语法挑不出毛病。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ActivityManifest } from '@contracts';
|
||||
|
||||
/* 两条审读的入参已在 domain 收窄到字段级 —— 这里直接用它,不拼假清单 */
|
||||
type PredicateInput = Parameters<typeof acceptanceLevelFindings>[0];
|
||||
type LedgerTable = ActivityManifest['declarations']['ledger'][number];
|
||||
type Deliverable = ActivityManifest['contract']['requiredDeliverables'][number];
|
||||
import { acceptanceLevelFindings } from '../internal/domain/static-audit.ts';
|
||||
|
||||
/** 一份最小可用清单 —— 只摆本测试关心的三处:判据 / 台账 / 案卷 */
|
||||
function manifest(over: {
|
||||
predicate?: string | undefined;
|
||||
level?: 'L1' | 'L2' | 'L3';
|
||||
fields?: string[];
|
||||
caseItems?: string[];
|
||||
inputProps?: string[];
|
||||
}): PredicateInput {
|
||||
const fields = over.fields ?? ['line_total'];
|
||||
const caseItems = over.caseItems ?? [];
|
||||
const inputProps = over.inputProps ?? [];
|
||||
return {
|
||||
contract: {
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
properties: Object.fromEntries(inputProps.map((k) => [k, { type: 'string' }])),
|
||||
},
|
||||
},
|
||||
acceptanceCriteria: [
|
||||
{
|
||||
key: 'k',
|
||||
label: '测试判据',
|
||||
level: over.level ?? 'L2',
|
||||
...(over.predicate === undefined ? {} : { predicate: over.predicate }),
|
||||
},
|
||||
],
|
||||
},
|
||||
declarations: {
|
||||
ledger: [
|
||||
{
|
||||
domain: 'd',
|
||||
object: 'record',
|
||||
kind: 'record' as const,
|
||||
caseKey: 'rfq_ref',
|
||||
label: '表',
|
||||
fields: fields.map((name) => ({
|
||||
name,
|
||||
type: 'integer' as const,
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
assets: {
|
||||
businessModel: {
|
||||
cmmnXml: caseItems.map((id) => `<caseFileItem id="${id}" name="x"/>`).join(''),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const codes = (m: PredicateInput) => acceptanceLevelFindings(m).map((f) => f.code);
|
||||
|
||||
describe('判据谓词的名字必须绑得上', () => {
|
||||
it('台账有这一列 —— 放行', () => {
|
||||
expect(codes(manifest({ predicate: 'line_total > 0' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('台账没有这个名字 —— 拒装', () => {
|
||||
const found = acceptanceLevelFindings(manifest({ predicate: 'candidatesConfirmed >= 2' }));
|
||||
expect(found.map((f) => f.code)).toContain('acceptance-predicate-unbound');
|
||||
// 报错要指名道姓:说清是哪个名字绑不上,不然改的人还得自己猜
|
||||
expect(found[0]?.message).toContain('candidatesConfirmed');
|
||||
});
|
||||
|
||||
it('案卷项也算绑得上 —— 案卷项 id 就是任务视图里的键名', () => {
|
||||
expect(codes(manifest({ predicate: 'benchmark_total > 0', caseItems: ['benchmark_total'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('起单入参也算绑得上', () => {
|
||||
expect(codes(manifest({ predicate: 'eau > 0', inputProps: ['eau'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('两个名字里错一个,也要报出那一个', () => {
|
||||
const found = acceptanceLevelFindings(
|
||||
manifest({ predicate: 'alt_request_total = type_c_total', fields: ['alt_request_total'] }),
|
||||
);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]?.message).toContain('type_c_total');
|
||||
});
|
||||
|
||||
it('保留字与字面量不当作台账名', () => {
|
||||
expect(codes(manifest({ predicate: 'need_sourcing = true and line_total > 0', fields: ['need_sourcing', 'line_total'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('字符串字面量里的词不当作台账名', () => {
|
||||
expect(codes(manifest({ predicate: 'disposition = "照原价报"', fields: ['disposition'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('L3 不机检 —— 没谓词也不报', () => {
|
||||
expect(codes(manifest({ level: 'L3', predicate: undefined }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('L2 没谓词照旧报等级谎报,且不误报绑定', () => {
|
||||
const found = codes(manifest({ level: 'L2', predicate: undefined }));
|
||||
expect(found).toEqual(['acceptance-level-misreported']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 必交付物必须给产出地址 —— 不给的话放行门永远拒。
|
||||
*
|
||||
* 预检数的是「本单在这张表上写了几行」;地址不给,它退回拿 key 去任务视图里找,
|
||||
* 而 key 是 `benchmark-table` 这样的短横线别名 —— 视图里永远没有这个键。
|
||||
* A2/A3/A4/A6/A7 五个活动全没给,活办完了也放不了行(实测 A2 死于此)。
|
||||
*/
|
||||
import { deliverableAddressFindings } from '../internal/domain/static-audit.ts';
|
||||
|
||||
type DeliverableInput = Parameters<typeof deliverableAddressFindings>[0];
|
||||
|
||||
const LEDGER: LedgerTable[] = [
|
||||
{ domain: 'quote_alternative', object: 'benchmark', kind: 'record' as const, caseKey: 'rfq_ref', label: '表', fields: [] },
|
||||
];
|
||||
|
||||
function withDeliverables(items: Deliverable[]): DeliverableInput {
|
||||
return { contract: { requiredDeliverables: items }, declarations: { ledger: LEDGER } };
|
||||
}
|
||||
|
||||
describe('必交付物的产出地址', () => {
|
||||
it('地址指向真实台账表 —— 放行', () => {
|
||||
const found = deliverableAddressFindings(
|
||||
withDeliverables([{ key: 'benchmark-table', label: '对标表', ledger: 'quote_alternative.benchmark' }]),
|
||||
);
|
||||
expect(found).toEqual([]);
|
||||
});
|
||||
|
||||
it('没给地址 —— 拒装', () => {
|
||||
const found = deliverableAddressFindings(withDeliverables([{ key: 'benchmark-table', label: '对标表' }]));
|
||||
expect(found.map((f) => f.code)).toEqual(['deliverable-address-unbound']);
|
||||
expect(found[0]?.message).toContain('对标表');
|
||||
});
|
||||
|
||||
it('地址指向不存在的表 —— 拒装,并把声明过的表摆出来', () => {
|
||||
const found = deliverableAddressFindings(
|
||||
withDeliverables([{ key: 'k', label: '对标表', ledger: 'quote_alternative.nope' }]),
|
||||
);
|
||||
expect(found.map((f) => f.code)).toEqual(['deliverable-address-unbound']);
|
||||
expect(found[0]?.message).toContain('quote_alternative.benchmark');
|
||||
});
|
||||
|
||||
it('多件里错一件,只报那一件', () => {
|
||||
const found = deliverableAddressFindings(
|
||||
withDeliverables([
|
||||
{ key: 'a', label: '对标表', ledger: 'quote_alternative.benchmark' },
|
||||
{ key: 'b', label: '初筛清单' },
|
||||
]),
|
||||
);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]?.message).toContain('初筛清单');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 台账性质要核得住 —— 这条门是拿一次真实的数据丢失换来的。
|
||||
*
|
||||
* A4 的报价草案本该一单一份,主键却只有 `pn`:第二单报同一个料号就 upsert 覆盖上一单。
|
||||
* 跑了四轮 A4,`quote_pricing.draft` 只剩最后一轮的 3 行;
|
||||
* 而主键带单号的 `quote_intake.record` 是 61 行 / 20 单,一单不少。
|
||||
* **作者没决定这张表是单据还是主数据,主键就写歪了** —— 而清单语法挑不出毛病。
|
||||
*/
|
||||
import { ledgerKindFindings } from '../internal/domain/static-audit.ts';
|
||||
|
||||
type LedgerInput = Parameters<typeof ledgerKindFindings>[0];
|
||||
type Kind = 'record' | 'master' | 'derived';
|
||||
|
||||
function table(over: { kind?: Kind; caseKey?: string; pk?: string[]; cols?: string[] }): LedgerInput {
|
||||
const cols = over.cols ?? ['rfq_ref', 'pn'];
|
||||
const pk = over.pk ?? ['rfq_ref', 'pn'];
|
||||
return {
|
||||
declarations: {
|
||||
ledger: [
|
||||
{
|
||||
domain: 'd',
|
||||
object: 'o',
|
||||
label: '表',
|
||||
kind: over.kind ?? 'record',
|
||||
...(over.caseKey === undefined ? {} : { caseKey: over.caseKey }),
|
||||
fields: cols.map((name) => ({
|
||||
name,
|
||||
type: 'text' as const,
|
||||
primaryKey: pk.includes(name),
|
||||
masked: false,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const kindCodes = (m: LedgerInput) => ledgerKindFindings(m).map((f) => f.code);
|
||||
|
||||
describe('台账性质', () => {
|
||||
it('单据:指明单标识且它在主键里 —— 放行', () => {
|
||||
expect(kindCodes(table({ kind: 'record', caseKey: 'rfq_ref' }))).toEqual([]);
|
||||
});
|
||||
|
||||
/* 这一条就是 A4 那次数据丢失的机检形态 */
|
||||
it('单据没指明单标识 —— 拒装', () => {
|
||||
const found = ledgerKindFindings(table({ kind: 'record', cols: ['pn'], pk: ['pn'] }));
|
||||
expect(found.map((f) => f.code)).toContain('ledger-kind-invalid');
|
||||
expect(found[0]?.message).toContain('caseKey');
|
||||
});
|
||||
|
||||
it('单标识不在主键里 —— 拒装(单标识不进主键等于没有)', () => {
|
||||
const found = ledgerKindFindings(table({ kind: 'record', caseKey: 'rfq_ref', pk: ['pn'] }));
|
||||
expect(found.map((f) => f.code)).toEqual(['ledger-kind-invalid']);
|
||||
expect(found[0]?.message).toContain('不是主键的一部分');
|
||||
});
|
||||
|
||||
it('单标识不在自己字段里 —— 拒装', () => {
|
||||
const found = ledgerKindFindings(table({ kind: 'record', caseKey: 'nope' }));
|
||||
expect(found[0]?.message).toContain('不在它自己的字段里');
|
||||
});
|
||||
|
||||
it('主数据:按业务实体分行,不要单标识 —— 放行', () => {
|
||||
expect(kindCodes(table({ kind: 'master', cols: ['customer_code'], pk: ['customer_code'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('主数据却带着单标识 —— 拒装:它多半其实是单据,归错了类', () => {
|
||||
const found = ledgerKindFindings(table({ kind: 'master', caseKey: 'rfq_ref' }));
|
||||
expect(found.map((f) => f.code)).toEqual(['ledger-kind-invalid']);
|
||||
expect(found[0]?.message).toContain('归错了类');
|
||||
});
|
||||
|
||||
it('派生:按周期分行,不要单标识 —— 放行', () => {
|
||||
expect(kindCodes(table({ kind: 'derived', cols: ['period'], pk: ['period'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('一个主键列都没有 —— 拒装:合并与去重无从谈起', () => {
|
||||
const found = ledgerKindFindings(table({ kind: 'master', cols: ['a'], pk: [] }));
|
||||
expect(found[0]?.message).toContain('主键列');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 行数读数 `域_对象_rows` —— 基座数出来的那一边。
|
||||
*
|
||||
* 它不是活动写得出的列,却必须是**绑得上的名字**:判据的两边不能都由活动自报,
|
||||
* 否则活动数错一次就两边一起错、一起「对上」(实测:条目数报 9、表里躺着 10,
|
||||
* 派单数与三类条数同源同错,`2 = 2` 顺利过关)。
|
||||
* 但也不能一律放行 —— 对象名手滑写错,照旧要在装的时候拒。
|
||||
*/
|
||||
describe('行数读数认得出,写错的照旧拒装', () => {
|
||||
it('声明过的表 → `域_对象_rows` 绑得上', () => {
|
||||
expect(codes(manifest({ predicate: 'd_record_rows = line_total' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('没声明过的表 → 照旧拒装(手滑一个字母,判据就永远达不了标)', () => {
|
||||
expect(codes(manifest({ predicate: 'd_recrod_rows = line_total' }))).toContain('acceptance-predicate-unbound');
|
||||
});
|
||||
});
|
||||
@@ -32,8 +32,11 @@ export type {
|
||||
export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts';
|
||||
export { createRegistryService } from './internal/usecase/registry-service.ts';
|
||||
|
||||
export type { AcceptanceAuditInput, DeliverableAuditInput } from './internal/domain/static-audit.ts';
|
||||
export {
|
||||
acceptanceLevelFindings,
|
||||
deliverableAddressFindings,
|
||||
ledgerKindFindings,
|
||||
auditPassed,
|
||||
danglingListeners,
|
||||
loopFindings,
|
||||
|
||||
@@ -28,6 +28,26 @@ export type ExternalChecks = {
|
||||
readonly sdkCompatible: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 两条清单审读**只读清单的一小块** —— 参数就照那一小块声明。
|
||||
* 收窄不是为了测试好写:它把「这条审读看的是什么」写进类型里,
|
||||
* 传全份清单照旧过(结构子类型),而读了什么一目了然。
|
||||
*/
|
||||
export type AcceptanceAuditInput = {
|
||||
readonly contract: Pick<ActivityManifest['contract'], 'acceptanceCriteria'> & {
|
||||
readonly input: Pick<ActivityManifest['contract']['input'], 'data'>;
|
||||
};
|
||||
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
|
||||
readonly assets: {
|
||||
readonly businessModel: Pick<ActivityManifest['assets']['businessModel'], 'cmmnXml'>;
|
||||
};
|
||||
};
|
||||
export type LedgerAuditInput = { readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'> };
|
||||
export type DeliverableAuditInput = {
|
||||
readonly contract: Pick<ActivityManifest['contract'], 'requiredDeliverables'>;
|
||||
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
|
||||
};
|
||||
|
||||
/** ISO-8601 duration,与 event 的 deadlineMillis 同一子集 */
|
||||
const DURATION = /^P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)$|^P\d+D$/;
|
||||
|
||||
@@ -62,8 +82,9 @@ export function danglingListeners(manifest: ActivityManifest): StaticAuditFindin
|
||||
}
|
||||
|
||||
/** 验收等级不得谎报:L4 不得标注为 L1–L3;L1/L2 必须给出可机检的表达式 */
|
||||
export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAuditFinding[] {
|
||||
export function acceptanceLevelFindings(manifest: AcceptanceAuditInput): StaticAuditFinding[] {
|
||||
const out: StaticAuditFinding[] = [];
|
||||
const declared = boundNames(manifest);
|
||||
for (const [index, criterion] of manifest.contract.acceptanceCriteria.entries()) {
|
||||
const at = `contract.acceptanceCriteria[${index}]`;
|
||||
if ((criterion.level === 'L1' || criterion.level === 'L2') && criterion.predicate === undefined && criterion.modelRef === undefined) {
|
||||
@@ -74,6 +95,209 @@ export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAudit
|
||||
at,
|
||||
});
|
||||
}
|
||||
/*
|
||||
* **谓词里的名字必须在台账上**(2026-07-31 立)。
|
||||
*
|
||||
* A2 的判据写的是 `candidatesConfirmed >= 2`,而台账里从来没有这个名字 ——
|
||||
* S-FEEL 拿未绑定的名字比大小求值为假,于是这条判据**永远达不了标**:
|
||||
* 活办得再干净也过不了 submit 门,Agent 连拒五拍熔断,整条流卡死在 A2。
|
||||
* A3/A4/A6/A7 全是同一个毛病,一个都没跑通过。
|
||||
*
|
||||
* 这种错光看清单是看不出来的(谓词本身语法没问题),只有跑到放行那一刻才炸,
|
||||
* 而那时人已经签完字了。所以它必须在**安装期**挡下:装不上,好过装上了跑死。
|
||||
*/
|
||||
for (const name of predicateNames(criterion.predicate)) {
|
||||
if (declared.has(name)) continue;
|
||||
out.push({
|
||||
code: 'acceptance-predicate-unbound',
|
||||
severity: 'error',
|
||||
message:
|
||||
`判据「${criterion.label}」的谓词引用了 \`${name}\`,而台账与案卷里都没有这个名字 —— ` +
|
||||
'S-FEEL 对未绑定的名字求值为假,这条判据将永远达不了标,放行门永远拒。' +
|
||||
'把它改成活动真写得出的列(通常是汇总表里的 _total 计数列)。',
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 必交付物的**产出地址**必须指向真实存在的台账表(2026-07-31 立)。
|
||||
*
|
||||
* 预检数的是「本单在这张表上写了几行」。地址不给,它退回拿 key 去任务视图里找 ——
|
||||
* 而 key 是 `benchmark-table` 这样的短横线别名,视图里永远没有这个键,
|
||||
* 于是「必交付物缺一不得提交放行」**永远拒**:活办完了也放不了行,连拒五拍熔断。
|
||||
* 地址写错(指向不存在的表)是同一个下场,一并挡在安装期。
|
||||
*/
|
||||
export function deliverableAddressFindings(manifest: DeliverableAuditInput): StaticAuditFinding[] {
|
||||
const out: StaticAuditFinding[] = [];
|
||||
const tables = new Set(manifest.declarations.ledger.map((t) => `${t.domain}.${t.object}`));
|
||||
for (const [index, item] of manifest.contract.requiredDeliverables.entries()) {
|
||||
const at = `contract.requiredDeliverables[${index}]`;
|
||||
if (item.ledger === undefined || item.ledger.trim() === '') {
|
||||
out.push({
|
||||
code: 'deliverable-address-unbound',
|
||||
severity: 'error',
|
||||
message:
|
||||
`必交付物「${item.label}」没给 \`ledger\` 产出地址 —— ` +
|
||||
'预检会退回拿 key 去任务视图里找,而 key 是短横线别名、视图里没有这个键,' +
|
||||
'这一件将永远算「未产出」,放行门永远拒。给它写上产出落在哪张表。',
|
||||
at,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!tables.has(item.ledger)) {
|
||||
out.push({
|
||||
code: 'deliverable-address-unbound',
|
||||
severity: 'error',
|
||||
message:
|
||||
`必交付物「${item.label}」的产出地址 \`${item.ledger}\` 不是本活动声明的台账表 —— ` +
|
||||
`声明过的是:${[...tables].join('、') || '(一张都没有)'}`,
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 台账对象的性质要**核得住**(2026-07-31 立)。
|
||||
*
|
||||
* 起因是一次真实的数据丢失:A4 的报价草案 `quote_pricing.draft` 本该一单一份,
|
||||
* 主键却只有 `pn` —— 第二单报同一个料号就 upsert 覆盖了上一单。
|
||||
* 跑了四轮 A4,表里只剩最后一轮的 3 行;而主键带单号的 `quote_intake.record`
|
||||
* 是 61 行 / 20 单,一单不少。**作者没决定这张表是单据还是主数据,主键就写歪了。**
|
||||
*
|
||||
* 光加一个 `kind` 字段治不了 —— 声明能填错。所以每一类各核一条:
|
||||
* · `record` 必须指明单标识,且它得真在主键里(否则「一单一份」是空话);
|
||||
* · `master` 不该有单标识(有了说明它其实是单据,归错类了);
|
||||
* · 任何表都得有主键 —— 没主键的台账合并语义无从谈起。
|
||||
*/
|
||||
export function ledgerKindFindings(manifest: LedgerAuditInput): StaticAuditFinding[] {
|
||||
const out: StaticAuditFinding[] = [];
|
||||
for (const [index, table] of manifest.declarations.ledger.entries()) {
|
||||
const at = `declarations.ledger[${index}]`;
|
||||
const where = `${table.domain}.${table.object}`;
|
||||
const pk = table.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
const names = new Set(table.fields.map((f) => f.name));
|
||||
|
||||
if (pk.length === 0) {
|
||||
out.push({
|
||||
code: 'ledger-kind-invalid',
|
||||
severity: 'error',
|
||||
message: `台账 \`${where}\` 一个主键列都没有 —— 合并与去重都无从谈起,写进去的行会越堆越多且认不出重复`,
|
||||
at,
|
||||
});
|
||||
}
|
||||
|
||||
if (table.kind === 'record') {
|
||||
if (table.caseKey === undefined) {
|
||||
out.push({
|
||||
code: 'ledger-kind-invalid',
|
||||
severity: 'error',
|
||||
message:
|
||||
`单据表 \`${where}\` 没指明单标识(\`caseKey\`)—— 一单一份就成了空话:` +
|
||||
'主键里没有「单」,第二单碰到同一个业务键会直接覆盖上一单(实测丢过数据)。' +
|
||||
'指明哪一列是单标识,或者它其实是主数据就改 `kind: \'master\'`。',
|
||||
at,
|
||||
});
|
||||
} else if (!names.has(table.caseKey)) {
|
||||
out.push({
|
||||
code: 'ledger-kind-invalid',
|
||||
severity: 'error',
|
||||
message: `单据表 \`${where}\` 的单标识 \`${table.caseKey}\` 不在它自己的字段里`,
|
||||
at,
|
||||
});
|
||||
} else if (!pk.includes(table.caseKey)) {
|
||||
out.push({
|
||||
code: 'ledger-kind-invalid',
|
||||
severity: 'error',
|
||||
message:
|
||||
`单据表 \`${where}\` 的单标识 \`${table.caseKey}\` **不是主键的一部分**(现主键:${pk.join(', ')})—— ` +
|
||||
'单标识不进主键,等于没有单标识:同一个业务键的第二单照样覆盖第一单。',
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (table.kind !== 'record' && table.caseKey !== undefined) {
|
||||
out.push({
|
||||
code: 'ledger-kind-invalid',
|
||||
severity: 'error',
|
||||
message:
|
||||
`\`${where}\` 标成 ${table.kind} 却带着单标识 \`${table.caseKey}\` —— ` +
|
||||
'跨单的数据不该按单分行;它多半其实是单据,`kind` 归错了类。',
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** S-FEEL 保留字与字面量 —— 它们不是台账名,不该被当成未绑定 */
|
||||
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
|
||||
|
||||
/**
|
||||
* 从谓词里摘出**标识符**(粗筛,宁可漏报不可误报)。
|
||||
* 只认「字母开头、由字母数字下划线点组成」的片段,去掉数字、字符串字面量与保留字;
|
||||
* 带点的取根名(`a.b` 的绑定看 `a`)。
|
||||
*/
|
||||
function predicateNames(predicate: string | undefined): string[] {
|
||||
if (predicate === undefined || predicate.trim() === '') return [];
|
||||
const withoutStrings = predicate.replace(/"[^"]*"/g, ' ').replace(/'[^']*'/g, ' ');
|
||||
const found = withoutStrings.match(/[A-Za-z_][A-Za-z0-9_.]*/g) ?? [];
|
||||
const out = new Set<string>();
|
||||
for (const raw of found) {
|
||||
const root = raw.split('.')[0] ?? raw;
|
||||
if (SFEEL_WORDS.has(root.toLowerCase())) continue;
|
||||
out.add(root);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个活动**求值时能绑到的名字**:台账所有列名 + 案卷项 id + 起单入参的字段名。
|
||||
* 任务视图正是由这三样摊平出来的,所以判据能读到的也就这三样。
|
||||
*/
|
||||
function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
|
||||
const out = new Set<string>();
|
||||
for (const table of manifest.declarations.ledger) {
|
||||
for (const field of table.fields) out.add(field.name);
|
||||
out.add(table.object);
|
||||
/*
|
||||
* **基座数出来的行数读数** `域_对象_rows` —— 活动写不了它,正因如此它才有用:
|
||||
* 判据的两边不能都由活动自报。实测过一次「对账过关而账是错的」——
|
||||
* 条目数报 9、表里躺着 10 行,三类条数与派单数同源同错,`2 = 2` 顺利过关。
|
||||
* 它只对**本活动声明过的表**成立,所以在这儿按声明生成:
|
||||
* 对象名手滑写错,照旧当场拒装。
|
||||
*/
|
||||
out.add(`${table.domain}_${table.object}_rows`);
|
||||
/*
|
||||
* 另两类基座读数,与 `_rows` 同一个道理 —— **能由数据算出来的,别让活动自报**:
|
||||
* · `域_对象_missing` —— 缺必填几行(按 `fields[].required` 现数);
|
||||
* · `域_对象_<列>_<值>` —— 按值现数几行(取值域由 `fields[].tally` 声明)。
|
||||
* 后一类是三分类条数的落点:人在放行页改了类标,分布条、去向谓词、派单对账
|
||||
* **全都跟着重算** —— 手工修改本来就是为了纠正 AI 的判断,改了却不影响结果,
|
||||
* 那个兜底功能就是假的。
|
||||
*/
|
||||
if (table.fields.some((f) => f.required === true)) out.add(`${table.domain}_${table.object}_missing`);
|
||||
if (table.fields.some((f) => f.expect === true)) out.add(`${table.domain}_${table.object}_gaps`);
|
||||
for (const field of table.fields) {
|
||||
for (const value of field.tally ?? []) {
|
||||
const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, '');
|
||||
if (slug !== '') out.add(`${table.domain}_${table.object}_${field.name}_${slug}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
|
||||
const m = /id="([^"]+)"/.exec(id);
|
||||
if (m?.[1] !== undefined) out.add(m[1]);
|
||||
}
|
||||
const data = manifest.contract.input.data;
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
const props = (data as { properties?: Record<string, unknown> }).properties;
|
||||
if (props !== undefined) for (const key of Object.keys(props)) out.add(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -93,6 +317,39 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] {
|
||||
at: 'assets.businessModel.narrative',
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* 剧本与阶段引的计划项 id **必须在 CMMN 里真存在** —— 对不上就在装的时候拒。
|
||||
*
|
||||
* 拼错一个 id 的后果是**静默失效**,而且两处症状都不像「id 写错了」:
|
||||
* · 剧本对不上 → 那一步永远拿不到自己的做法与工具,模型每拍读整本说明书;
|
||||
* · 阶段对不上 → 那一格永远收不完,进度条卡在那儿不动,人以为单卡住了。
|
||||
* 从 XML 里认 id 用的是属性正则:这里不解析 CMMN(解析在引擎那侧),
|
||||
* 只要认得出「有没有这个 id」就够。
|
||||
*/
|
||||
const declared = new Set([...model.cmmnXml.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]));
|
||||
for (const step of model.steps ?? []) {
|
||||
if (!declared.has(step.item)) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: `剧本引的计划项在 CMMN 里不存在:${step.item}`,
|
||||
at: 'assets.businessModel.steps',
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const phase of model.phases ?? []) {
|
||||
for (const item of phase.items) {
|
||||
if (!declared.has(item)) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: `阶段「${phase.name}」引的计划项在 CMMN 里不存在:${item}`,
|
||||
at: 'assets.businessModel.phases',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -106,8 +363,18 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
|
||||
out.push({ code: 'sdk-incompatible', severity: 'error', message: `SDK 版本不兼容:${manifest.identity.sdkVersion}` });
|
||||
}
|
||||
for (const tool of external.missingTools) {
|
||||
// 工具缺口只阻塞上架,不阻塞开发(OS 7.1 契约先行)
|
||||
out.push({ code: 'tool-contract-missing', severity: 'error', message: `工具调用契约未注册:${tool}` });
|
||||
/*
|
||||
* 工具缺口是**警告,不是拒装**(2026-07-30 降级)。
|
||||
*
|
||||
* 「设计活动时只考虑需要什么工具,不关心系统里有没有实现」——
|
||||
* 声明了系统里没有,那是系统的缺口,不是活动的错误。运行时已有出路:
|
||||
* 未命中即**转人代办**(§6.2,人是工具的一种实现),这一步照样做得下去。
|
||||
* 从前判 error 拒装,等于要求「先把工具全实现完才允许装活动」——
|
||||
* 与声明式工具正面冲突,也让契约先行成了空话。
|
||||
*
|
||||
* 仍然如实报出来:治理台看得见「这件活动有几件工具要靠人顶着」。
|
||||
*/
|
||||
out.push({ code: 'tool-contract-missing', severity: 'warn', message: `工具调用契约未注册,运行时转人代办:${tool}` });
|
||||
}
|
||||
for (const model of external.unboundModels) {
|
||||
out.push({ code: 'model-name-unbound', severity: 'error', message: `模型名未绑定四元:${model}` });
|
||||
@@ -130,6 +397,8 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
|
||||
}
|
||||
|
||||
out.push(...danglingListeners(manifest));
|
||||
out.push(...deliverableAddressFindings(manifest));
|
||||
out.push(...ledgerKindFindings(manifest));
|
||||
out.push(...acceptanceLevelFindings(manifest));
|
||||
out.push(...loopFindings(manifest));
|
||||
|
||||
|
||||
@@ -132,6 +132,15 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
|
||||
}
|
||||
const cards: ActivityCard[] = [];
|
||||
for (const [activityId, versions] of byActivity) {
|
||||
/*
|
||||
* 全部版本都已卸载的活动**不在架上**。
|
||||
*
|
||||
* 卸载只把版本状态置为 `uninstalled`(版本行留着,在途任务按启动时版本跑完)——
|
||||
* 但活动库不该照旧摆着它:实测踩过,一个被真活动包顶替的槽位卸载后仍在库里,
|
||||
* 与顶替它的那个**同名并排**,试跑台上选错一个就试了个空壳。
|
||||
* 「卸了还摆着」就是界面撒谎。
|
||||
*/
|
||||
if (versions.every((v) => v.state === 'uninstalled')) continue;
|
||||
const manifest = await deps.store.manifest(activityId);
|
||||
if (manifest === undefined) continue;
|
||||
cards.push({
|
||||
@@ -158,6 +167,8 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
|
||||
for (const pkg of await deps.store.listPackages(activityId)) {
|
||||
await deps.store.setState(activityId, pkg.version, 'uninstalled');
|
||||
}
|
||||
// 一个版本都没装了,就谈不上「可用」—— 两处状态得对得上,不然治理台会摆一个假的绿灯
|
||||
await deps.store.setEnabled(activityId, false);
|
||||
},
|
||||
|
||||
async loadEnvelope(activityId) {
|
||||
|
||||
@@ -28,19 +28,20 @@ describe('planConsumption —— 三级消费', () => {
|
||||
triggerHits: ['tr1'],
|
||||
ownedTaskId: 't1',
|
||||
hasOwner: true,
|
||||
fromChannel: true,
|
||||
});
|
||||
expect(plan.taskTargets).toEqual(['t1']);
|
||||
expect(plan.triggerTargets).toEqual(['tr1']);
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
});
|
||||
|
||||
it('皆未命中且无主 → 消息兜底', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false });
|
||||
it('**入站件**皆未命中且无主 → 消息兜底(系统自己发的不算,见下面那组)', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false, fromChannel: true });
|
||||
expect(plan.messageFallback).toBe(true);
|
||||
});
|
||||
|
||||
it('有主但零监听命中 → 仅记录,不兜底、不降级为无主', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7', hasOwner: true });
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7', hasOwner: true, fromChannel: true });
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
expect(plan.recordOnlyTask).toBe('t7');
|
||||
expect(plan.taskTargets).toEqual([]);
|
||||
@@ -54,6 +55,7 @@ describe('planConsumption —— 三级消费', () => {
|
||||
triggerHits: [],
|
||||
ownedTaskId: undefined,
|
||||
hasOwner: true,
|
||||
fromChannel: true,
|
||||
});
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
expect(plan.recordOnlyTask).toBeUndefined();
|
||||
@@ -65,12 +67,19 @@ describe('planConsumption —— 三级消费', () => {
|
||||
triggerHits: [],
|
||||
ownedTaskId: 't7',
|
||||
hasOwner: true,
|
||||
fromChannel: true,
|
||||
});
|
||||
expect(plan.recordOnlyTask).toBeUndefined();
|
||||
});
|
||||
|
||||
it('无主但触发命中 → 不兜底', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: ['tr1'], ownedTaskId: undefined, hasOwner: false });
|
||||
const plan = planConsumption({
|
||||
listenerHits: [],
|
||||
triggerHits: ['tr1'],
|
||||
ownedTaskId: undefined,
|
||||
hasOwner: false,
|
||||
fromChannel: true,
|
||||
});
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -96,3 +105,36 @@ describe('deadlineMillis —— 监听时限必配', () => {
|
||||
expect(() => deadlineMillis('PT0S')).toThrow(/大于零/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **消息兜底只兜从外面进来的件**(2026-08-02 用户:「消息怎么总莫名其妙的出内容」)。
|
||||
*
|
||||
* 「无主入站待认领」是件具体的事:一封信进来了,没匹配上任何单也没触发任何流,
|
||||
* 得有个人去认领它。而系统自己发的事件不是这种东西 —— 台账写一行会发 `data` 事件,
|
||||
* 没人监听它是**常态**,不是待办。
|
||||
*
|
||||
* 不分这一刀的实测下场:装一次活动包(主数据 systemWrite)就往三个人的收件箱里
|
||||
* 塞了十几条「无主入站待认领:(无主题) … 原件:无」—— 没有主题、没有原件、
|
||||
* 没人能对它做任何事。那不是待认领的件,是一条内部广播。
|
||||
*/
|
||||
describe('消息兜底:只兜入站件', () => {
|
||||
const plan = (over: Partial<Parameters<typeof planConsumption>[0]> = {}) =>
|
||||
planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false, fromChannel: true, ...over });
|
||||
|
||||
it('一封信进来、谁都没接住 → 该有人认领', () => {
|
||||
expect(plan().messageFallback).toBe(true);
|
||||
});
|
||||
|
||||
it('**台账写一行发的事件谁都没接住 → 什么也不做**(它不是件)', () => {
|
||||
expect(plan({ fromChannel: false }).messageFallback).toBe(false);
|
||||
});
|
||||
|
||||
it('接住了就不兜底 —— 监听命中或触发命中,任一即可', () => {
|
||||
expect(plan({ listenerHits: [{ taskId: 't1' }] }).messageFallback).toBe(false);
|
||||
expect(plan({ triggerHits: ['tr1'] }).messageFallback).toBe(false);
|
||||
});
|
||||
|
||||
it('有主的件不兜底 —— 那是「主不是任务」,不是没人认领', () => {
|
||||
expect(plan({ hasOwner: true }).messageFallback).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,22 @@ export function planConsumption(input: {
|
||||
* 把它们判成无主,等于把每次起单都当成「没人认领的入站件」塞进别人收件箱。
|
||||
*/
|
||||
readonly hasOwner: boolean;
|
||||
/**
|
||||
* 这条事件**是从外面进来的件**吗 —— 只有它才谈得上「无人认领」。
|
||||
*
|
||||
* 「无主入站待认领」是一件具体的事:一封信进来了、没匹配上任何单也没触发任何流,
|
||||
* **得有个人去认领它**。而系统自己发的事件不是这种东西:
|
||||
* 台账写一行会发 `data` 事件、引擎起一单会发工作流事件 ——
|
||||
* 没人监听它们是**常态**,不是待办。
|
||||
*
|
||||
* 不分这一刀的下场实测过(用户 2026-08-02:「消息怎么总莫名其妙的出内容」):
|
||||
* 装一次活动包(主数据 `systemWrite`)就往三个人的收件箱里塞了十几条
|
||||
* 「无主入站待认领:(无主题) —— 事件 6d17debb… 未命中任何任务监听与触发器。原件:无」。
|
||||
* 没有主题、没有原件、没人能对它做任何事 —— 那不是待认领的件,是一条内部广播。
|
||||
*
|
||||
* 判据取**来源**不取类型:同样是 `data`,从通道进来的是件,台账写出来的是事实。
|
||||
*/
|
||||
readonly fromChannel: boolean;
|
||||
}): ConsumptionPlan {
|
||||
const taskTargets = fanOutTargets(input.listenerHits);
|
||||
const triggerTargets = [...new Set(input.triggerHits)];
|
||||
@@ -73,8 +89,8 @@ export function planConsumption(input: {
|
||||
return {
|
||||
taskTargets,
|
||||
triggerTargets,
|
||||
// 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主**的件(OS 6.3)
|
||||
messageFallback: nothingHit && !input.hasOwner,
|
||||
// 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主的入站件**(OS 6.3)
|
||||
messageFallback: nothingHit && !input.hasOwner && input.fromChannel,
|
||||
recordOnlyTask:
|
||||
input.ownedTaskId !== undefined && !taskTargets.includes(input.ownedTaskId)
|
||||
? input.ownedTaskId
|
||||
|
||||
@@ -51,6 +51,8 @@ export function createEventService(deps: EventServiceDeps): EventService {
|
||||
triggerHits,
|
||||
ownedTaskId,
|
||||
hasOwner: envelope.correlation.target !== undefined,
|
||||
// 只有**从通道进来的件**谈得上「无人认领」—— 台账写一行发的 data 事件不是件
|
||||
fromChannel: envelope.source.kind === 'channel',
|
||||
});
|
||||
|
||||
// 扇出:每个命中目标一份独立投递,信封「关联 = 单目标」保持单目标
|
||||
|
||||
@@ -228,3 +228,50 @@ describe('完成只出自放行通过', () => {
|
||||
expect(allCriteriaGreen(acceptance({ criteria: [{ label: 'x', level: 'L2', pass: false }] }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **一张录入卡可以落好几张表**(2026-08-02 用户:「新建客户 联系人和公司都需要确认」)。
|
||||
*
|
||||
* 建一位新客户落的是两条主数据:公司档 + 联系人档。从前卡上只有公司那一半,
|
||||
* 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。
|
||||
* 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉,
|
||||
* **而盖掉之后没人看得出来**。
|
||||
*/
|
||||
describe('录入卡分段:段号.列名', () => {
|
||||
/** 与 `routes/desk.ts` 那段拆分同一口径 —— 改一处这里要跟着改 */
|
||||
const split = (values: Record<string, string>, at: number): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
const mark = `${at}.`;
|
||||
if (k.startsWith(mark)) out[k.slice(mark.length)] = v;
|
||||
else if (at === 0 && !/^\d+\./.test(k)) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const values = {
|
||||
'0.customer_code': 'CU-9001',
|
||||
'0.name': '上海精密电子有限公司',
|
||||
'1.customer_code': 'CU-9001',
|
||||
'1.contact_code': 'CT-01',
|
||||
'1.name': '陈刚',
|
||||
'1.email': 'chen@sh-elec.com',
|
||||
};
|
||||
|
||||
it('**同名列不互相盖** —— 公司叫「上海精密电子」,联系人叫「陈刚」', () => {
|
||||
expect(split(values, 0)['name']).toBe('上海精密电子有限公司');
|
||||
expect(split(values, 1)['name']).toBe('陈刚');
|
||||
});
|
||||
|
||||
it('每段只拿自己那几格 —— 联系人编码不该落进公司档', () => {
|
||||
expect(split(values, 0)['contact_code']).toBeUndefined();
|
||||
expect(Object.keys(split(values, 1)).sort()).toEqual(['contact_code', 'customer_code', 'email', 'name']);
|
||||
});
|
||||
|
||||
it('**老形状(没段号的平键)仍旧管用** —— 一张卡只有一段时照旧落主段', () => {
|
||||
expect(split({ customer_code: 'CU-1', name: '老写法' }, 0)).toEqual({ customer_code: 'CU-1', name: '老写法' });
|
||||
});
|
||||
|
||||
it('老形状不喂给后面的段 —— 它说不清自己是哪一张表的', () => {
|
||||
expect(split({ customer_code: 'CU-1', name: '老写法' }, 1)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 调人预算 —— 拦的是「又去问人」,不是「办完了要交差」。
|
||||
*
|
||||
* 这条是拿一次熔断换来的:A4 的预算是 3 次,两张决策卡加一张补全卡用完;
|
||||
* 三条料的价全算好了、门禁也过了,提交放行却被「调人预算触顶 3/3」拒回 ——
|
||||
* 再提再拒,连拒五拍熔断。活动可以完全合规地把自己饿死在终点线上。
|
||||
*
|
||||
* 放行不是 Agent 选择的打扰:铁律③终局唯放行,这一单**必须**有人签字才收得了口。
|
||||
* 所以验收单不计预算;补全卡、决策卡、执行工单照算。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ChatCard, DeliverableId, StaffId, TaskId, WorkOrder } from '@contracts';
|
||||
import { cardIdSchema } from '@contracts';
|
||||
import type { CardStore, HumanEventPublisher, MemoryRecorder, MessageOutbox } from '../port.ts';
|
||||
import type { IssueContext } from '../contract.ts';
|
||||
import { createHumanToolService } from '../internal/usecase/human-tool-service.ts';
|
||||
import { HumanCallBudgetExceededError } from '../contract.ts';
|
||||
|
||||
const TASK = 'task-1' as TaskId;
|
||||
const OWNER = 'staff-1' as StaffId;
|
||||
|
||||
/** 记下「发出去了什么」的假台账 —— 已用次数由测试给定,其余按端口如实实现 */
|
||||
function cardStore(used: number, sent: string[]): CardStore {
|
||||
return {
|
||||
saveWorkOrder: async (_taskId, order) => {
|
||||
sent.push(`order:${order.mode}`);
|
||||
},
|
||||
getWorkOrder: async () => undefined,
|
||||
listWorkOrders: async () => [],
|
||||
markWorkOrderDone: async () => undefined,
|
||||
patchWorkOrder: async () => undefined,
|
||||
saveChatCard: async (_taskId, card) => {
|
||||
sent.push(`card:${card.k}`);
|
||||
},
|
||||
getChatCard: async () => undefined,
|
||||
listChatCards: async () => [],
|
||||
updateChatCard: async () => undefined,
|
||||
closeChatCard: async () => undefined,
|
||||
humanCallCount: async () => used,
|
||||
openCardsOf: async () => [],
|
||||
};
|
||||
}
|
||||
|
||||
const outbox: MessageOutbox = { sendElicitation: async () => ({ id: 'm-1' }) };
|
||||
const memory: MemoryRecorder = { append: async () => undefined };
|
||||
const events: HumanEventPublisher = { publishHumanEvent: async () => undefined };
|
||||
|
||||
function service(used: number) {
|
||||
const sent: string[] = [];
|
||||
let seq = 0;
|
||||
const svc = createHumanToolService({
|
||||
cards: cardStore(used, sent),
|
||||
outbox,
|
||||
memory,
|
||||
events,
|
||||
clock: { now: () => new Date('2026-07-31T00:00:00.000Z') },
|
||||
ids: {
|
||||
next: () => {
|
||||
seq += 1;
|
||||
return `id-${seq}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
return { svc, sent };
|
||||
}
|
||||
|
||||
const context: IssueContext = { taskId: TASK, ownerId: OWNER, humanCallCap: 3 };
|
||||
|
||||
const executionDraft = {
|
||||
title: '请补齐字段',
|
||||
basis: [{ k: '缺口', v: 'PN' }],
|
||||
ask: '请向客户追问',
|
||||
fields: [{ key: 'pn', label: 'PN', kind: 'text' as const, required: true }],
|
||||
} satisfies Parameters<ReturnType<typeof service>['svc']['issueExecution']>[1];
|
||||
|
||||
const acceptanceDraft = {
|
||||
title: '收口验收',
|
||||
basis: [{ k: '条目数', v: '3' }],
|
||||
deliverable: 'd-1' as DeliverableId,
|
||||
criteria: [{ label: '草案完整', level: 'L1' as const, pass: true }],
|
||||
fields: [],
|
||||
} satisfies Parameters<ReturnType<typeof service>['svc']['issueAcceptance']>[1];
|
||||
|
||||
const decisionCard: ChatCard = {
|
||||
id: cardIdSchema.parse('c-1'),
|
||||
at: '2026-07-31T00:00:00.000Z',
|
||||
k: 'decision',
|
||||
ask: '选一个处置',
|
||||
basis: [{ k: '偏离', v: '-30%' }],
|
||||
options: [
|
||||
{ value: 'a', label: '照原价报' },
|
||||
{ value: 'b', label: '按底价报' },
|
||||
],
|
||||
allowCustom: false,
|
||||
};
|
||||
|
||||
describe('调人预算', () => {
|
||||
it('没到顶 —— 执行单发得出去', async () => {
|
||||
const { svc, sent } = service(2);
|
||||
await svc.issueExecution(context, executionDraft);
|
||||
expect(sent).toEqual(['order:execution']);
|
||||
});
|
||||
|
||||
it('到顶了 —— 执行单拒发', async () => {
|
||||
const { svc } = service(3);
|
||||
await expect(svc.issueExecution(context, executionDraft)).rejects.toBeInstanceOf(HumanCallBudgetExceededError);
|
||||
});
|
||||
|
||||
it('到顶了 —— 决策卡拒发', async () => {
|
||||
const { svc } = service(3);
|
||||
await expect(svc.issueChatCard(context, decisionCard)).rejects.toBeInstanceOf(HumanCallBudgetExceededError);
|
||||
});
|
||||
|
||||
it('到顶了 —— **验收放行照发**:终局唯放行,不该跟提问抢额度', async () => {
|
||||
const { svc, sent } = service(3);
|
||||
await svc.issueAcceptance(context, acceptanceDraft);
|
||||
expect(sent).toEqual(['order:acceptance']);
|
||||
});
|
||||
|
||||
it('超顶很多也照发验收单 —— 已经办完的活不该因为问过几次人就交不了差', async () => {
|
||||
const { svc, sent } = service(99);
|
||||
await svc.issueAcceptance(context, acceptanceDraft);
|
||||
expect(sent).toEqual(['order:acceptance']);
|
||||
});
|
||||
|
||||
it('验收单免的是预算,不是三规矩 —— 裸卡照旧拒发', async () => {
|
||||
const { svc } = service(0);
|
||||
const bare = { ...acceptanceDraft, basis: [] } satisfies Omit<
|
||||
Extract<WorkOrder, { mode: 'acceptance' }>,
|
||||
'id' | 'at' | 'mode'
|
||||
>;
|
||||
await expect(svc.issueAcceptance(context, bare)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,8 @@ export type IssuedChatCard = {
|
||||
|
||||
export type ReplyOutcome = {
|
||||
readonly order: WorkOrder;
|
||||
/** 这张卡挂在哪一单 —— 装配层要拿它把「人就地改的那几行」写回草稿层 */
|
||||
readonly taskId: TaskId;
|
||||
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
|
||||
readonly producesCompletion: boolean;
|
||||
/** 驳回:环内回转,任务继续 */
|
||||
@@ -110,10 +112,33 @@ export type HumanToolService = {
|
||||
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
|
||||
|
||||
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
|
||||
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard>;
|
||||
/**
|
||||
* 回传带上 `taskId` —— **数据录入卡的确认要当场写草稿**,而写库要知道写给哪一单。
|
||||
* 由本模块自己写会让 human-tool 依赖 ledger(越层),所以把事实交出去,由组装层落笔。
|
||||
*/
|
||||
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{ card: ChatCard; taskId: TaskId }>;
|
||||
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
|
||||
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
|
||||
|
||||
/**
|
||||
* 给已发出的验收工单补上选择组(AM 1.2 恰选一口)。
|
||||
*
|
||||
* 为什么分两步:卡由 `release.submitForRelease` 发 —— 它守的是预检那道门,
|
||||
* 不该也不必知道「这个活动有哪些出口」。出口是契约的事实,由 app 从 manifest 现读后贴上。
|
||||
*/
|
||||
attachPortChoice(
|
||||
workOrderId: string,
|
||||
choice: { group: string; ports: ReadonlyArray<{ name: string; label?: string | undefined }> },
|
||||
): Promise<void>;
|
||||
/**
|
||||
* **算出来的去向**上卡 —— 不给人选,只告诉他走哪一口、凭哪一条。
|
||||
* 去向是台账那几个数的函数;摆成选择题,等于把可回放的判定换成一次手感。
|
||||
*/
|
||||
attachRoutedPort(
|
||||
workOrderId: string,
|
||||
routed: { group: string; name: string; label?: string | undefined; because: string },
|
||||
): Promise<void>;
|
||||
|
||||
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
||||
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||
|
||||
@@ -54,6 +54,20 @@ export function createCardStore(db: Db): CardStore {
|
||||
await db.update(workOrderTable).set({ done }).where(eq(workOrderTable.id, id));
|
||||
},
|
||||
|
||||
/*
|
||||
* 局部改卡面:整卡存在 `payload` 里,所以读回来合并再写回。
|
||||
* 只对**未回传**的卡开放(调用方保证)—— 已答的卡是留痕,只增不改。
|
||||
*/
|
||||
async patchWorkOrder(id, patch) {
|
||||
const rows = await db.select().from(workOrderTable).where(eq(workOrderTable.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (row === undefined) return;
|
||||
await db
|
||||
.update(workOrderTable)
|
||||
.set({ payload: { ...(row.payload as Record<string, unknown>), ...patch } })
|
||||
.where(eq(workOrderTable.id, id));
|
||||
},
|
||||
|
||||
async saveChatCard(taskId, card, recipientId) {
|
||||
await db.insert(chatCardTable).values({
|
||||
id: card.id,
|
||||
|
||||
@@ -164,6 +164,29 @@ export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardVi
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 数据录入卡 —— **确认那一下就是写入**,所以门要在这儿把住:
|
||||
* 必填空着不让确认(缺一格这张档就是残的,而它是主数据);
|
||||
* 已确认不得再改(只增不改:要改就再发一张,改动自己也是一条留痕)。
|
||||
*/
|
||||
if (card.k === 'entry' && reply.k === 'entry') {
|
||||
if (card.done !== undefined) return [{ code: 'already-answered', message: '该录入已确认,只增不改' }];
|
||||
/*
|
||||
* **一张卡可以有好几段**(公司档 + 联系人档):值按 `段号.列名` 隔开 ——
|
||||
* 两张表都有 `name` 这一列,平铺就会互相盖掉。
|
||||
* 老形状(平键)照旧认,只喂主段:一张卡只有一段时它仍旧管用。
|
||||
*/
|
||||
const groups = [{ fields: card.fields }, ...(card.also ?? [])];
|
||||
const blank = groups.flatMap((g, at) =>
|
||||
g.fields
|
||||
.filter((f) => f.required && (reply.values[`${at}.${f.key}`] ?? (at === 0 ? reply.values[f.key] : undefined) ?? '').trim() === '')
|
||||
.map((f) => f.label),
|
||||
);
|
||||
if (blank.length > 0) {
|
||||
out.push({ code: 'required-blank', message: `必填还空着:${blank.join('、')}` });
|
||||
}
|
||||
}
|
||||
|
||||
if (card.k === 'elicitation' && reply.k === 'elicitation') {
|
||||
if (card.done !== undefined) return [{ code: 'already-answered', message: '该补全已答,只增不改' }];
|
||||
// 「没法填」旁路:要么填值,要么给理由 —— 两条路必走其一
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ChatCardReply,
|
||||
ExecutionWorkOrder,
|
||||
StaffId,
|
||||
TaskId,
|
||||
WorkOrder,
|
||||
WorkOrderReply,
|
||||
} from '@contracts';
|
||||
@@ -38,9 +39,35 @@ export type HumanToolServiceDeps = {
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
/**
|
||||
* 录入卡上那一格叫什么 —— 键是 `段号.列名`(一张卡可以落好几张表)。
|
||||
* 按段号找回它属于哪一段;认不出的原样留着(宁可笼统,不可编)。
|
||||
*/
|
||||
function entryLabel(card: Extract<ChatCard, { k: 'entry' }>, key: string): string {
|
||||
const at = /^(\d+)\.(.+)$/.exec(key);
|
||||
const groups = [{ title: '', fields: card.fields }, ...(card.also ?? [])];
|
||||
if (at === null) return card.fields.find((f) => f.key === key)?.label ?? key;
|
||||
const group = groups[Number(at[1])];
|
||||
const name = at[2] ?? key;
|
||||
const label = group?.fields.find((f) => f.key === name)?.label ?? name;
|
||||
return group === undefined || group.title === '' ? label : `${group.title}·${label}`;
|
||||
}
|
||||
|
||||
export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolService {
|
||||
/** 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(AM 10.1) */
|
||||
async function assertBudget(context: IssueContext): Promise<void> {
|
||||
/**
|
||||
* 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(AM 10.1)。
|
||||
*
|
||||
* **验收放行那一张不计预算**(2026-07-31 加)。
|
||||
*
|
||||
* 预算管的是「打扰」——Agent 自己选择去问人的那些次数:补全卡、决策卡、执行工单。
|
||||
* 而放行不是它选的:铁律③终局唯放行,这一单**必须**由人签字才能收口。
|
||||
* 把它和提问算在同一个额度里,后果是活动可以合规地把自己饿死 ——
|
||||
* 实测 A4:预算 3 次,两张决策卡加一张补全卡用完,活全干完了,
|
||||
* 提交放行被「调人预算触顶 3/3」拒回,再提再拒,连拒五拍熔断。
|
||||
* 该拦的是「又去问人」,不是「办完了要交差」。
|
||||
*/
|
||||
async function assertBudget(context: IssueContext, mode?: WorkOrder['mode']): Promise<void> {
|
||||
if (mode === 'acceptance') return;
|
||||
const used = await deps.cards.humanCallCount(context.taskId);
|
||||
if (used >= context.humanCallCap) {
|
||||
throw new HumanCallBudgetExceededError(context.taskId, used, context.humanCallCap);
|
||||
@@ -61,7 +88,12 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
taskId: context.taskId,
|
||||
kind: 'card-sent',
|
||||
title,
|
||||
detail: { kind, id, arrival, recipient },
|
||||
/*
|
||||
* `payload` 里那一个键(对话卡的 `k` / 工单的 `mode`)要一并入痕 ——
|
||||
* 少了它,日志只知道「发了一张卡」,说不出发的是补全卡还是录入卡,
|
||||
* 四字动作那一格就只能写「发出卡片」这种等于没说的词。
|
||||
*/
|
||||
detail: { kind, id, arrival, recipient, ...payload },
|
||||
});
|
||||
if (arrival === 'task-chat') return { arrival };
|
||||
// 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」
|
||||
@@ -79,7 +111,7 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
async function issueOrder(context: IssueContext, order: WorkOrder): Promise<IssuedWorkOrder> {
|
||||
const violations = checkWorkOrder(order);
|
||||
if (violations.length > 0) throw new BareCallError(violations);
|
||||
await assertBudget(context);
|
||||
await assertBudget(context, order.mode);
|
||||
|
||||
const recipient = context.recipientId ?? context.ownerId;
|
||||
await deps.cards.saveWorkOrder(context.taskId, order, recipient);
|
||||
@@ -171,8 +203,8 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
await deps.memory.append({
|
||||
taskId: found.taskId,
|
||||
kind: reply.mode === 'acceptance' ? 'release-verdict' : 'card-reply',
|
||||
title: reply.mode === 'acceptance' ? `放行裁决:${reply.verdict}` : `执行工单回传:${found.order.title}`,
|
||||
detail: { by, values, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) },
|
||||
title: reply.mode === 'acceptance' ? `裁决:${reply.verdict}` : found.order.title,
|
||||
detail: { by, values, mode: reply.mode, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) },
|
||||
});
|
||||
// 回传经统一入口以人事件抛回任务
|
||||
await deps.events.publishHumanEvent({
|
||||
@@ -186,15 +218,28 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
|
||||
return {
|
||||
order,
|
||||
taskId: found.taskId,
|
||||
producesCompletion: producesCompletion(reply),
|
||||
...(reply.mode === 'acceptance' && reply.verdict !== 'pass'
|
||||
? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } }
|
||||
: {}),
|
||||
selectedPorts: reply.mode === 'acceptance' ? reply.selectedPorts : {},
|
||||
/*
|
||||
* 出口:**算出来的优先**。
|
||||
*
|
||||
* 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了
|
||||
* (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数,
|
||||
* 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。
|
||||
*/
|
||||
selectedPorts:
|
||||
reply.mode !== 'acceptance'
|
||||
? {}
|
||||
: (order as AcceptanceWorkOrder).routedPort !== undefined
|
||||
? { [(order as AcceptanceWorkOrder).routedPort!.group]: (order as AcceptanceWorkOrder).routedPort!.name }
|
||||
: reply.selectedPorts,
|
||||
};
|
||||
},
|
||||
|
||||
async replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard> {
|
||||
async replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{ card: ChatCard; taskId: TaskId }> {
|
||||
const found = await deps.cards.getChatCard(reply.cardId);
|
||||
if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '卡不存在' }]);
|
||||
|
||||
@@ -205,14 +250,45 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
const updated = applyChatReply(found.card, reply, by, at);
|
||||
await deps.cards.updateChatCard(updated);
|
||||
|
||||
/*
|
||||
* 标题写**内容**,不写「entry 卡回传」这种行话 —— 日志里动作已有自己一格
|
||||
* (四字章:员工确认 / 员工补全 …),标题再复述一遍卡的类型就是白占地方。
|
||||
* 录入卡尤其要说清落到哪张表:人回看时问的是「我那一下写了什么」。
|
||||
*/
|
||||
/*
|
||||
* **日志里不摆台账地址与列名**(2026-08-02 用户指出:
|
||||
* 「柴浩:写入 customer.profile / customer_code=chchch;name=青岛海洋电子有限公司」
|
||||
* —— 这为啥没换成纯描述)。
|
||||
*
|
||||
* `customer.profile` 与 `customer_code` 是系统内的名字;人回看时问的是
|
||||
* **「我那一下办了什么」**,答案该是「建了客户档案 · 青岛海洋电子有限公司」。
|
||||
* 卡上本来就有人话:`ask` 是问他的那句,`fields[].label` 是每一格的中文名 ——
|
||||
* 用它们,不用地址与列名。
|
||||
*/
|
||||
const title = found.card.ask.split('\n')[0] ?? found.card.ask;
|
||||
await deps.memory.append({
|
||||
taskId: found.taskId,
|
||||
kind: 'card-reply',
|
||||
title: `${found.card.k} 卡回传`,
|
||||
detail: { by, ...reply },
|
||||
title,
|
||||
detail: {
|
||||
by,
|
||||
...reply,
|
||||
...(reply.k === 'entry' && found.card.k === 'entry'
|
||||
? {
|
||||
/*
|
||||
* 列名换成卡上那个中文 label;认不出的原样留着(宁可笼统,不可编)。
|
||||
* 值的键是 `段号.列名`(一张卡可以落好几张表)—— 按段号找回它属于哪一段,
|
||||
* 不然「联系人姓名」会显示成公司档那个同名的「公司名」。
|
||||
*/
|
||||
summary: Object.entries(reply.values)
|
||||
.map(([k, v]) => `${found.card.k === 'entry' ? entryLabel(found.card, k) : k}:${v}`)
|
||||
.join(' · '),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
await deps.events.publishHumanEvent({ taskId: found.taskId, cardId: reply.cardId, payload: { ...reply } });
|
||||
return updated;
|
||||
return { card: updated, taskId: found.taskId };
|
||||
},
|
||||
|
||||
/** 驳回重答:新卡实例(附驳回理由),原卡闭合 —— 每次请求与回答都是独立消息,只增不改 */
|
||||
@@ -239,6 +315,26 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
return { card: fresh, arrival: arrivalOf(found.recipientId, found.recipientId) } satisfies IssuedChatCard;
|
||||
},
|
||||
|
||||
async attachRoutedPort(workOrderId, routed) {
|
||||
await deps.cards.patchWorkOrder(workOrderId, {
|
||||
routedPort: {
|
||||
group: routed.group,
|
||||
name: routed.name,
|
||||
...(routed.label === undefined ? {} : { label: routed.label }),
|
||||
because: routed.because,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async attachPortChoice(workOrderId, choice) {
|
||||
await deps.cards.patchWorkOrder(workOrderId, {
|
||||
portChoice: {
|
||||
group: choice.group,
|
||||
ports: choice.ports.map((p) => ({ name: p.name, ...(p.label === undefined ? {} : { label: p.label }) })),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async openWorkOrders(taskId) {
|
||||
return deps.cards.listWorkOrders(taskId, true);
|
||||
},
|
||||
@@ -270,6 +366,9 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s
|
||||
done: { at, by, values: reply.values, ...(reply.reason === undefined ? {} : { reason: reply.reason }) },
|
||||
};
|
||||
}
|
||||
if (card.k === 'entry' && reply.k === 'entry') {
|
||||
return { ...card, done: { at, by, values: reply.values } };
|
||||
}
|
||||
if (card.k === 'rating' && reply.k === 'rating') {
|
||||
return { ...card, done: { at, by, score: reply.score, ...(reply.note === undefined ? {} : { note: reply.note }) } };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export type CardStore = {
|
||||
getWorkOrder(id: string): Promise<{ taskId: TaskId; order: WorkOrder; recipientId: StaffId } | undefined>;
|
||||
listWorkOrders(taskId: TaskId, openOnly: boolean): Promise<WorkOrder[]>;
|
||||
markWorkOrderDone(id: string, done: NonNullable<WorkOrder['done']>): Promise<void>;
|
||||
/** 局部改卡面 —— 只增不改是对**已回传**的卡;未答的卡还可补齐契约事实(如选择组) */
|
||||
patchWorkOrder(id: string, patch: Partial<WorkOrder>): Promise<void>;
|
||||
|
||||
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
|
||||
getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>;
|
||||
|
||||
@@ -18,12 +18,29 @@
|
||||
`MemoryRecorder`(app 注入 memory)、`Audience`(app 注入 org)
|
||||
被依赖: release, loop-engine, flow-engine, task, app(SSE 路由)
|
||||
|
||||
近变(2026-07-30):新增 `onPush(listener)` —— **系统级转接**,装配层接走全部推送转给前端 SSE。
|
||||
从前只有 `subscribe(staffId, …)`,而它一个订阅者都没有:AI 说的每一句、
|
||||
推的每一帧画面都广播给了空气,前端只好靠人点刷新。不复用 `subscribe` 是因为
|
||||
转接是系统的事,为它编一个假员工 id 是撒谎;不按人筛也不漏 —— 推送不带业务载荷,
|
||||
谁该看见由前端自己那几把查询决定。
|
||||
对话流投影另加 `narration` 一类(AI 对人说的话)。
|
||||
|
||||
不变量(OS 4.1 / 09,AM 07):
|
||||
- **输入单入口**:`inputControl()` 恒为 system —— 输入侧不参与转交;
|
||||
视图区不得产生回环输入,浏览性交互由前端容器本地处理
|
||||
- **控制权按态转交**:活动在跑(active / awaiting-*)归活动,其余归系统;
|
||||
转交随引擎事件自动发生,活动不感知交接
|
||||
- **视图区无脚本执行权**:含 `<script>` / `on*` 事件处理器 / 外部资源的模版一律 `TemplateRejectedError`
|
||||
- **填数只做取值与转义**:`data-slot="路径"` 换文本、`data-rows="路径"` 逐行复制;
|
||||
无表达式、无分支、无循环 —— 模版一旦能算,「无脚本执行权」就形同虚设。
|
||||
两个表现档,都把判断留在样式表、不留在填数里:
|
||||
· `data-blank` 让空值**真的留空**(不填「—」),好让模版自己的 CSS
|
||||
去标必填/选填缺口(`td:empty::after` / `tr:has(td.req:empty)`);
|
||||
· `data-class-of="路径"` 把该值消毒成 `v-<值>` 追加进 class(只放行
|
||||
ASCII 字母数字与横杠、封顶 24 字;一个合规字符都没有就不给 class)——
|
||||
「A 绿 / B 蓝 / C 橙」这类按值分档的表现,靠它投影,不靠模版算。
|
||||
**两个档互不依赖**:`data-class-of` 不以 `data-slot` 为前提,
|
||||
纯色块(格里不写字,只按值上色)照样上得了色
|
||||
- **前端零过程状态**:对话流与视图都是服务端投影,刷新即重建;SSE 只推失效与内容,不推状态机
|
||||
- **时间线即任务记忆的可读投影**:自由输入同时写 `memory` 与抛人事件,无静默通道;
|
||||
AI 发言与系统事件**不另设写入口**,一律由 `projectChat` 从任务记忆筛出 ——
|
||||
|
||||
@@ -16,7 +16,7 @@ const entry = (kind: string, over: Partial<NarrativeEntry> = {}): NarrativeEntry
|
||||
|
||||
describe('projectChat —— 对话流与工作日志同源不同筛选', () => {
|
||||
it('引擎自转的条目一律不进对话区', () => {
|
||||
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'card-reply', 'control'];
|
||||
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'control'];
|
||||
expect(projectChat(noise.map((k) => entry(k)))).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -54,9 +54,174 @@ describe('projectChat —— 对话流与工作日志同源不同筛选', () =>
|
||||
expect(projectChat([entry('card-sent', { detail: { kind: 'chat-card', id: 'c-1' } })])).toEqual([]);
|
||||
});
|
||||
|
||||
it('放行裁决与终态投成阶段结论', () => {
|
||||
const out = projectChat([entry('release-verdict', { id: 'r' }), entry('terminal', { id: 't' })]);
|
||||
/*
|
||||
* **顺利收尾一条都不出**(2026-08-02 用户当场指出:「终态:released 删除」)。
|
||||
*
|
||||
* 从前这一段连出三条:「裁决:pass」「落地收敛,发射输出口:intake」「终态:released」——
|
||||
* 一件事说三遍。2026-08-01 先砍成一条(留终态);同日验收卡办结后换成了「落地结果」
|
||||
* (落了几行、进了哪几张表、转去哪儿了),那一条也就**没话可说了** ——
|
||||
* 在一件已经交代清楚的事后面补一句「结束了」,是废话;何况它摆的还是英文状态名。
|
||||
*/
|
||||
it('**顺利收尾:对话区一条都不出** —— 落地结果那一屏已经说全了', () => {
|
||||
const out = projectChat([
|
||||
entry('release-verdict', { id: 'r', title: '裁决:pass' }),
|
||||
entry('release-verdict', { id: 'r2', title: '落地收敛,发射输出口:intake' }),
|
||||
entry('terminal', { id: 't', title: '终态:released' }),
|
||||
]);
|
||||
expect(out).toHaveLength(0);
|
||||
});
|
||||
|
||||
/*
|
||||
* **没顺利收尾的仍要出声** —— 那时落地结果那一屏根本不会出现,
|
||||
* 而人需要知道这一单停在哪儿了。措辞取共用字典,不摆 `blocked` 这种词。
|
||||
*/
|
||||
it('卡住 / 退回 / 预算耗尽:出一条,且说人话', () => {
|
||||
for (const [state, said] of [
|
||||
['blocked', '这一单受阻待人'],
|
||||
['exhausted', '这一单预算耗尽'],
|
||||
['stalled', '这一单停滞熔断'],
|
||||
] as const) {
|
||||
const out = projectChat([entry('terminal', { id: 't', title: `终态:${state}` })]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe(said);
|
||||
expect(out[0]?.k === 'ai' ? out[0].tag : undefined).toBe('conclusion');
|
||||
}
|
||||
});
|
||||
|
||||
it('认不出的状态原样留着 —— 宁可笼统,不可编', () => {
|
||||
const out = projectChat([entry('terminal', { id: 't', title: '终态:什么新状态' })]);
|
||||
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe('终态:什么新状态');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **一个气泡:结论 + 功能卡**(2026-08-01 用户定的形状)。
|
||||
*
|
||||
* 交代与卡本是同一轮的两半 —— 结论说「这一轮办完了什么、为什么找你」,
|
||||
* 卡是「你要办的那件事」。各占一个气泡,每轮就是两条:一条话、
|
||||
* 一条只有标题加个按钮的卡,而卡的标题多半是那句话的缩写。
|
||||
*/
|
||||
describe('同一轮的交代与卡并成一条', () => {
|
||||
const say = (id: string, text: string) => entry('narration', { id, title: text });
|
||||
const card = (id: string, title: string, wo: string) =>
|
||||
entry('card-sent', { id, title, detail: { kind: 'work-order', id: wo } });
|
||||
|
||||
it('刚说过话就发卡 → 一个气泡,话是正文、卡挂在上面', () => {
|
||||
const out = projectChat([say('n', '九条料都拆好了,缺两格,请核对确认'), card('c', '受理需求确认', 'wo-1')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe('九条料都拆好了,缺两格,请核对确认');
|
||||
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-1');
|
||||
});
|
||||
|
||||
it('并进去之后**标记跟着卡走** —— 验收卡要 criterion,裁决表单才渲得出来', () => {
|
||||
const out = projectChat([say('n', '判据我都裁过了,交你签'), card('c', '收口验收', 'wo-2')]);
|
||||
expect(out[0]?.k === 'ai' ? out[0].tag : undefined).toBe('criterion');
|
||||
});
|
||||
|
||||
it('**没说话就自成一条** —— 卡不能不出现', () => {
|
||||
const out = projectChat([card('c', '受理需求确认', 'wo-3')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-3');
|
||||
});
|
||||
|
||||
it('一句话只领一张卡 —— 同拍两张卡的第二张自成一条,不覆盖第一张', () => {
|
||||
const out = projectChat([say('n', '两件事要你办'), card('a', '卡一', 'wo-a'), card('b', '卡二', 'wo-b')]);
|
||||
expect(out.map((m) => (m.k === 'ai' ? m.ref : undefined))).toEqual(['wo-a', 'wo-b']);
|
||||
});
|
||||
|
||||
it('隔着一条系统事件就不并 —— 那是上一轮的话,不是这张卡的结论', () => {
|
||||
const out = projectChat([
|
||||
say('n', '我去问客户'),
|
||||
entry('observation', { id: 'o', title: '客户回信了' }),
|
||||
card('c', '受理需求确认', 'wo-4'),
|
||||
]);
|
||||
expect(out).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('收尾那条结论不认领卡 —— 它是这一单的句号,不是派工', () => {
|
||||
// 用**没顺利收尾**的那种:released 起同日不再出声(见上一组)
|
||||
const out = projectChat([entry('terminal', { id: 't', title: '终态:blocked' }), card('c', '卡', 'wo-5')]);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.every((m) => m.k === 'ai' && m.tag === 'conclusion')).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **结论口吻的交代照样并卡**。
|
||||
*
|
||||
* 挡住并卡的判断一度写成 `tag !== 'conclusion'` —— 而交代本身常常就是结论口吻
|
||||
* (「判据我都裁过了,交你签」),模型给它标 conclusion 完全正当。
|
||||
* 实测:第一轮并对了、收口那一轮没并 —— 同一屏两种样子。
|
||||
* 不许并卡的只有**收尾那一句**(terminal),按来路认,不按语气签认。
|
||||
*/
|
||||
it('模型把交代标成 conclusion,卡照样并进去', () => {
|
||||
const out = projectChat([
|
||||
entry('narration', { id: 'n', title: '判据我都裁过了,交你签', detail: { tag: 'conclusion' } }),
|
||||
card('c', '收口验收', 'wo-6'),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-6');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **AI 会不会说话** —— 这一类从前没有,后果是对话区从头到尾空白。
|
||||
*
|
||||
* 实测:A1 跑十几拍、写了七行台账、调了两次工具,负责人的对话区一个字也没有 ——
|
||||
* 他只看得见「多了一个任务」,不知道机器干到哪儿、凭什么这么判。
|
||||
* 「人只在责任点与放行点出现」不等于「过程中人什么都看不见」。
|
||||
*/
|
||||
describe('narration —— AI 对人说的话进对话区', () => {
|
||||
const entry = (kind: string, title: string, detail?: Record<string, unknown>) => ({
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
at: '2026-07-30T01:00:00.000Z',
|
||||
kind,
|
||||
title,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
});
|
||||
|
||||
it('交代进展投成 AI 发言', () => {
|
||||
const out = projectChat([entry('narration', '三条已归一,缺 1 个交期')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ k: 'ai', text: '三条已归一,缺 1 个交期', tag: 'guidance' });
|
||||
});
|
||||
|
||||
it('语气签照活动标的来;标不出按「指导」计', () => {
|
||||
expect(projectChat([entry('narration', 'x', { tag: 'conclusion' })])[0]).toMatchObject({ tag: 'conclusion' });
|
||||
expect(projectChat([entry('narration', 'x', { tag: '我编的签' })])[0]).toMatchObject({ tag: 'guidance' });
|
||||
});
|
||||
|
||||
it('机器踪迹照旧不进对话区 —— 它不是日志的副本', () => {
|
||||
const out = projectChat([
|
||||
entry('action', '写草稿:quote_intake.record 3 行'),
|
||||
entry('engine-transition', 'manualStart:S_intake'),
|
||||
entry('result', 'crm.history-price-read 返回'),
|
||||
entry('thought', '装配技能:intake.read-rfq@2.0.0'),
|
||||
]);
|
||||
expect(out).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('回执与内部观察(PRD:页面交互是三类唤醒事件之一)', () => {
|
||||
it('执行工单回传投成人事件回执 —— 人办完一件事,对话流上要有一句人话的痕', () => {
|
||||
// 认的是 `detail.mode`,不是标题前缀:措辞是给人看的、随时会改,改了这条不该失灵
|
||||
const out = projectChat([
|
||||
entry('card-reply', { id: 'r1', title: '补全三条料的品牌', detail: { mode: 'execution' } }),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ k: 'system', type: 'human' });
|
||||
expect((out[0] as { text: string }).text).toBe('「补全三条料的品牌」已办结,AI 接着往下推。');
|
||||
});
|
||||
|
||||
it('对话卡回传不投 —— 卡自己会翻成已答态,再来一条回执是重影', () => {
|
||||
expect(projectChat([entry('card-reply', { id: 'r2', title: '补齐品牌', detail: { k: 'elicitation' } })])).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('引擎内部观察(internal 标)进日志不进对话', () => {
|
||||
expect(projectChat([entry('observation', { id: 'o9', title: 'S1 已在进行中 —— 无需再启动', detail: { internal: true } })])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('裸「事件到达(human):卡号」不投 —— 对人只是一串 UUID,回执已用人话说过', () => {
|
||||
expect(projectChat([entry('observation', { id: 'oa', title: '事件到达(human):abcd-123', detail: { kind: 'human' } })])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,3 +108,173 @@ describe('整键优先 —— 台账地址本身带点号', () => {
|
||||
expect(out).toContain('<td>LM358DR</td>');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `data-blank` —— 「这一格客户没给」与「这一格填了个破折号」是两件事。
|
||||
*
|
||||
* 报告里的必填 / 选填胶囊、缺必填的整行标底,全靠这一格**真的空**才生效
|
||||
* (`td:empty::after` / `tr:has(td.req:empty)`)。填了「—」就永远标不出来。
|
||||
*/
|
||||
describe('data-blank:空值真的留空', () => {
|
||||
it('空串 / null / 缺席一律留空,属性随槽位一并消费', () => {
|
||||
const html = '<td class="req" data-blank data-slot="eau"></td>';
|
||||
expect(renderView(html, { eau: '' })).toBe('<td class="req"></td>');
|
||||
expect(renderView(html, { eau: null })).toBe('<td class="req"></td>');
|
||||
expect(renderView(html, {})).toBe('<td class="req"></td>');
|
||||
});
|
||||
|
||||
it('有值照填 —— 0 与 false 都是值,不算空', () => {
|
||||
const html = '<td class="req" data-blank data-slot="v"></td>';
|
||||
expect(renderView(html, { v: 30_000 })).toBe('<td class="req">30000</td>');
|
||||
expect(renderView(html, { v: 0 })).toBe('<td class="req">0</td>');
|
||||
expect(renderView(html, { v: false })).toBe('<td class="req">否</td>');
|
||||
});
|
||||
|
||||
it('不带 data-blank 的照旧填「—」—— 老模版行为不动', () => {
|
||||
expect(renderView('<b data-slot="x"></b>', { x: '' })).toBe('<b>—</b>');
|
||||
});
|
||||
|
||||
it('逐行展开时每行各判各的空', () => {
|
||||
const out = renderView('<tbody data-rows="rs"><tr><td class="req" data-blank data-slot="eau"></td></tr></tbody>', {
|
||||
rs: [{ eau: 30_000 }, { eau: '' }],
|
||||
});
|
||||
expect(out).toBe('<tbody><tr><td class="req">30000</td></tr><tr><td class="req"></td></tr></tbody>');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `data-class-of` —— **按值上色**,模版仍然不许算。
|
||||
*
|
||||
* 「A 绿 / B 橙 / C 紫」这类分档表现,从前只能三个值一个色 —— 那等于假装读懂了值。
|
||||
* 把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。
|
||||
* 属性位是唯一一处让值进属性的地方,所以消毒必须硬。
|
||||
*/
|
||||
describe('data-class-of:按值上色', () => {
|
||||
it('值消毒成 v-<值> 追加进 class,属性随之消费', () => {
|
||||
const html = '<td class="pill" data-class-of="type_tag" data-slot="type_tag"></td>';
|
||||
expect(renderView(html, { type_tag: 'A' })).toBe('<td class="pill v-a">A</td>');
|
||||
expect(renderView(html, { type_tag: 'C' })).toBe('<td class="pill v-c">C</td>');
|
||||
});
|
||||
|
||||
it('没有 class 属性就新建一个', () => {
|
||||
expect(renderView('<i data-class-of="k" data-slot="k"></i>', { k: 'B' })).toBe('<i class="v-b">B</i>');
|
||||
});
|
||||
|
||||
it('布尔与数字都投影得出来 —— 是/否两态也要能分色', () => {
|
||||
expect(renderView('<b data-class-of="f" data-slot="f"></b>', { f: true })).toBe('<b class="v-true">是</b>');
|
||||
expect(renderView('<b data-class-of="n" data-slot="n"></b>', { n: 0 })).toBe('<b class="v-0">0</b>');
|
||||
});
|
||||
|
||||
it('**值只放行 ASCII 字母数字与横杠**:引号、尖括号、空格全剔掉,注不进属性', () => {
|
||||
const out = renderView('<b class="x" data-class-of="k" data-slot="k"></b>', { k: '" onclick=alert(1) <b>' });
|
||||
// class 里只剩白名单字符;文本位照旧转义 —— 两处都注不进去
|
||||
expect(out).toBe('<b class="x v-onclickalert1b">" onclick=alert(1) <b></b>');
|
||||
expect(out).not.toContain('onclick=alert(1)"');
|
||||
});
|
||||
|
||||
it('一个合规字符都没有(中文 / 空值)就不给 class —— 宁可没颜色,不给错钩子', () => {
|
||||
expect(renderView('<b class="x" data-class-of="k" data-slot="k"></b>', { k: '老客户' })).toBe('<b class="x">老客户</b>');
|
||||
expect(renderView('<b class="x" data-class-of="k" data-slot="k"></b>', {})).toBe('<b class="x">—</b>');
|
||||
});
|
||||
|
||||
it('逐行展开时每行按自己的值上色', () => {
|
||||
const out = renderView('<tbody data-rows="rs"><tr><td data-class-of="t" data-slot="t"></td></tr></tbody>', {
|
||||
rs: [{ t: 'A' }, { t: 'C' }],
|
||||
});
|
||||
expect(out).toBe('<tbody><tr><td class="v-a">A</td></tr><tr><td class="v-c">C</td></tr></tbody>');
|
||||
});
|
||||
|
||||
/*
|
||||
* **上色不以填字为条件** —— 这一组是补的账。
|
||||
*
|
||||
* 从前只认「带 data-slot 的元素」,于是分布条那种纯色块
|
||||
* (`<i data-class-of="type_tag"></i>`,一格一条料、格里不写字)整条静默失色:
|
||||
* 属性还留在成品上,一个 class 都没长出来。屏上是一排灰格子 ——
|
||||
* 而灰格子看着像「还没算出来」,不像「没上色」,查了两遍才认出来。
|
||||
* 上面每一条老用例都同时写了 data-slot,所以谁都没拦住它。
|
||||
*/
|
||||
it('**只上色不填字**:没有 data-slot 也照样长出 class', () => {
|
||||
expect(renderView('<i data-class-of="t"></i>', { t: 'B' })).toBe('<i class="v-b"></i>');
|
||||
expect(renderView('<i class="seg" data-class-of="t"></i>', { t: 'C' })).toBe('<i class="seg v-c"></i>');
|
||||
});
|
||||
|
||||
it('只上色的格子逐行展开,每格按自己那一行的值', () => {
|
||||
const out = renderView('<div class="bar" data-rows="rs"><i data-class-of="t"></i></div>', {
|
||||
rs: [{ t: 'A' }, { t: 'B' }, { t: 'C' }],
|
||||
});
|
||||
expect(out).toBe('<div class="bar"><i class="v-a"></i><i class="v-b"></i><i class="v-c"></i></div>');
|
||||
});
|
||||
|
||||
it('值取不到就把属性摘掉了事 —— 不留半成品属性到成品上', () => {
|
||||
expect(renderView('<i class="seg" data-class-of="nope"></i>', {})).toBe('<i class="seg"></i>');
|
||||
expect(renderView('<i data-class-of="k"></i>', { k: '老客户' })).toBe('<i></i>');
|
||||
});
|
||||
|
||||
it('壳上的 data-class-of 按外层作用域上色,行内的仍按各自那一行', () => {
|
||||
const out = renderView('<div data-class-of="hit" data-rows="rs"><i data-class-of="t"></i></div>', {
|
||||
hit: 'r1',
|
||||
rs: [{ t: 'A' }, { t: 'C' }],
|
||||
});
|
||||
expect(out).toBe('<div class="v-r1"><i class="v-a"></i><i class="v-c"></i></div>');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `data-edit` + `data-col` —— **就地改**:可改的格子渲两份,只读那份与输入孪生件。
|
||||
*
|
||||
* 为什么两份都渲、而不是渲成输入:同一份件既要在待处理页给人改,
|
||||
* 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉。
|
||||
* 显示哪一份由样式表按根节点上的 `editing` 决定,填数这一侧仍然只做取值。
|
||||
*/
|
||||
describe('data-edit / data-col:可改格子的输入孪生件', () => {
|
||||
const ROWS = '<tbody data-rows="rs" data-edit="d.o"><tr><td><span class="jc-vcell mono" data-slot="pn" data-col="pn"></span></td></tr></tbody>';
|
||||
|
||||
it('容器标了 data-edit,格子就多一个带值的 input', () => {
|
||||
const out = renderView(ROWS, { rs: [{ pn: 'AO6604' }] });
|
||||
expect(out).toContain('<span class="jc-vcell mono">AO6604</span>');
|
||||
expect(out).toContain('<input class="jc-cell mono" data-col="pn" data-row="0" value="AO6604"/>');
|
||||
});
|
||||
|
||||
it('没标 data-edit 的表一个 input 都不长 —— 归档件就是件,不是表单', () => {
|
||||
const out = renderView(ROWS.replace(' data-edit="d.o"', ''), { rs: [{ pn: 'AO6604' }] });
|
||||
expect(out).not.toContain('<input');
|
||||
});
|
||||
|
||||
it('行号钉在每一行上 —— 写回时按它对行', () => {
|
||||
const out = renderView(ROWS, { rs: [{ pn: 'A' }, { pn: 'B' }, { pn: 'C' }] });
|
||||
expect(out.match(/data-row="\d"/g)).toEqual(['data-row="0"', 'data-row="1"', 'data-row="2"']);
|
||||
});
|
||||
|
||||
it('空值的 input 真空着 —— 空的输入框显示自己的 placeholder,那正是「这一格还没有」', () => {
|
||||
const out = renderView(ROWS, { rs: [{ pn: '' }] });
|
||||
expect(out).toContain('value=""');
|
||||
});
|
||||
|
||||
it('**值进属性也要转义** —— 属性位插值只此一处,引号进去就是逃逸', () => {
|
||||
const out = renderView(ROWS, { rs: [{ pn: '" onfocus=alert(1) x="' }] });
|
||||
expect(out).toContain('value="" onfocus=alert(1) x=""');
|
||||
expect(out).not.toMatch(/value="" onfocus/);
|
||||
});
|
||||
|
||||
it('排版类跟着走(mono / num),别的类不带过去 —— 同一列两态字体要一致', () => {
|
||||
const num = ROWS.replace('jc-vcell mono', 'jc-vcell num req');
|
||||
expect(renderView(num, { rs: [{ pn: '30000' }] })).toContain('<input class="jc-cell num"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('渲染两遍不叠加 —— 交出去的是干净 HTML', () => {
|
||||
it('第二遍渲染不会再长一个 input(属性已被消费)', () => {
|
||||
const once = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
|
||||
rs: [{ pn: 'X' }],
|
||||
});
|
||||
expect((once.match(/<input/g) ?? []).length).toBe(1);
|
||||
expect(renderView(once, {})).toBe(once);
|
||||
});
|
||||
|
||||
it('模版里仍然没有脚本 —— 输入件是纯 HTML,没有事件处理器', () => {
|
||||
const out = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
|
||||
rs: [{ pn: 'X' }],
|
||||
});
|
||||
expect(out).not.toMatch(/<script|\son[a-z]+=/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,9 +40,16 @@ export type InteractionService = {
|
||||
/** 视图流控:活动按局面推结构化块 */
|
||||
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
|
||||
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
|
||||
pushViewHtml(taskId: TaskId, html: string): Promise<void>;
|
||||
/**
|
||||
* 推一帧 HTML。`template` **要一起存下来** —— 件是投影,而投影得能按当下数据**重渲**。
|
||||
*
|
||||
* 只存渲染好的 HTML,那一帧就成了快照:人在件上改完几格,由数据算出来的东西
|
||||
* (类徽 A/B/C、分布条、结论横幅那条去向)全停在渲染那一刻 ——
|
||||
* 屏上一半新一半旧,那不是没更新,是界面在撒谎。记住模版名,才重得出来。
|
||||
*/
|
||||
pushViewHtml(taskId: TaskId, html: string, template?: string): Promise<void>;
|
||||
/** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */
|
||||
viewOf(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string } | undefined>;
|
||||
viewOf(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string; template?: string } | undefined>;
|
||||
|
||||
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
|
||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
|
||||
@@ -50,6 +57,17 @@ export type InteractionService = {
|
||||
|
||||
/** SSE:单连接、按信封目标失效缓存 */
|
||||
subscribe(staffId: StaffId, onPush: (envelope: PushEnvelope) => void): Subscription;
|
||||
/**
|
||||
* **系统级转接**:装配层把所有推送接出去(转给前端的 SSE 广播),不按人筛。
|
||||
*
|
||||
* 为什么不复用 `subscribe`:那个要一个收件人,而转接是系统的事 ——
|
||||
* 为它编一个假员工 id 是撒谎。不按人筛也不漏:推送**不带业务载荷**
|
||||
* (只说「哪个任务动了」),谁该看见由前端自己那几把查询决定(§9.2 前端零过程状态)。
|
||||
*
|
||||
* 没有这个口子的后果实测过:AI 说的每一句、推的每一帧画面都广播给了空气 ——
|
||||
* `subscribe` 一个订阅者都没有,前端只好靠人点刷新。
|
||||
*/
|
||||
onPush(listener: (envelope: PushEnvelope) => void): Subscription;
|
||||
/** 供上层广播失效信号 */
|
||||
invalidate(target: 'task' | 'workflow' | 'desk', id: string): void;
|
||||
};
|
||||
|
||||
@@ -21,6 +21,8 @@ export const taskViewTable = sys.table('task_view', {
|
||||
taskId: text('task_id').primaryKey(),
|
||||
blocks: jsonb('blocks'),
|
||||
html: text('html'),
|
||||
/** 这一帧用的模版名 —— 数据变了要按同一张重渲(件是投影,不是快照) */
|
||||
template: text('template'),
|
||||
phase: text('phase').notNull().default('activating'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
@@ -28,11 +28,13 @@ export function createInteractionStore(db: Db): InteractionStore {
|
||||
.orderBy(asc(chatMessageTable.at));
|
||||
return rows.map((row) => chatMessageSchema.parse(row.payload));
|
||||
},
|
||||
async putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }) {
|
||||
async putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string; template?: string }) {
|
||||
const values = {
|
||||
taskId,
|
||||
blocks: view.blocks === undefined ? null : [...view.blocks],
|
||||
html: view.html ?? null,
|
||||
// 模版名一起存:件是投影 —— 数据变了要按同一张重渲(见迁移 0028 的注释)
|
||||
template: view.template ?? null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
await db
|
||||
@@ -40,7 +42,7 @@ export function createInteractionStore(db: Db): InteractionStore {
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: taskViewTable.taskId,
|
||||
set: { blocks: values.blocks, html: values.html, updatedAt: values.updatedAt },
|
||||
set: { blocks: values.blocks, html: values.html, template: values.template, updatedAt: values.updatedAt },
|
||||
});
|
||||
},
|
||||
async getView(taskId: TaskId) {
|
||||
@@ -49,6 +51,7 @@ export function createInteractionStore(db: Db): InteractionStore {
|
||||
return {
|
||||
...(row.blocks === null ? {} : { blocks: row.blocks as ViewBlock[] }),
|
||||
...(row.html === null ? {} : { html: row.html }),
|
||||
...(row.template === null ? {} : { template: row.template }),
|
||||
};
|
||||
},
|
||||
async setPhase(taskId: TaskId, phase: InteractionPhase) {
|
||||
|
||||
@@ -7,14 +7,26 @@
|
||||
* 否则对话区就成了机器踪迹的副本,失去「非结构化消息」的定位。
|
||||
*
|
||||
* 筛四类:
|
||||
* narration → **AI 发言**:一轮办完时对人说的那一句。
|
||||
* 这一类从前没有,后果是对话区从头到尾空白 —— A1 跑十几拍写了七行台账、
|
||||
* 调了两次工具,而人在对话区一个字看不到,只看得见「多了一个任务」。
|
||||
* 机器踪迹(engine-transition / action / result)照旧不进:
|
||||
* 对话区不是日志的副本,它是**AI 对人说的话**。
|
||||
* observation → 系统事件(灰头像)—— 三类唤醒事件里的外部到达
|
||||
* card-sent → AI 发言 + 指涉工单(卡在对话流上出现,点开即定位到视图区)
|
||||
* release-verdict→ AI 发言 · 阶段结论
|
||||
* terminal → AI 发言 · 阶段结论
|
||||
* card-sent → **并进上一条 AI 发言**,合成「结论 + 功能卡」一个气泡;
|
||||
* 没有可并的就自成一条(卡不能不出现)
|
||||
* terminal → AI 发言 · 阶段结论(收尾只此一条)
|
||||
* human-input 不投:人的话已由 `say` 直接落成消息,再投一遍就是重影。
|
||||
* release-verdict 不投:裁决是人自己刚按下的,发了哪几个口是编排层的事实 —— 都在工作日志里。
|
||||
*
|
||||
* ── 密度(2026-08-01 用户定)─────────────────────────────────────────
|
||||
* **一轮一个气泡**:设定目标 →(看现状 → 定做法 → 动手)× N 轮 → 交代一次,
|
||||
* 可以是仅结论,也可以是结论 + 功能卡两部分。埋头办事的拍不出气泡 ——
|
||||
* 那一段由「AI 工作中」的办事条覆盖(哪些拍算收口见 `turn-runner` 的 `handsOff`)。
|
||||
* 这一层做的是后半段:把同一轮的交代与卡并成一条,并把收尾那三条引擎回声收成一条。
|
||||
*/
|
||||
import type { ChatMessage, EventType } from '@contracts';
|
||||
import { cardIdSchema, workOrderIdSchema } from '@contracts';
|
||||
import type { ChatMessage, EventType, TerminalState } from '@contracts';
|
||||
import { cardIdSchema, TERMINAL_STATE_LABEL, workOrderIdSchema } from '@contracts';
|
||||
|
||||
/** 记忆条目的只读切面 —— 投影只要这几样,不牵入 memory 的完整类型 */
|
||||
export type NarrativeEntry = {
|
||||
@@ -40,31 +52,130 @@ function workOrderRef(detail: Record<string, unknown> | undefined): string | und
|
||||
return detail?.['kind'] === 'work-order' && typeof detail['id'] === 'string' ? detail['id'] : undefined;
|
||||
}
|
||||
|
||||
/** 发言的语气签:活动自己标,标不出按「进展」计 —— 三种签在前端各有其样式 */
|
||||
const TAGS: readonly string[] = ['guidance', 'criterion', 'conclusion'];
|
||||
function tagOf(detail: Record<string, unknown> | undefined): 'guidance' | 'criterion' | 'conclusion' {
|
||||
const tag = detail?.['tag'];
|
||||
return typeof tag === 'string' && TAGS.includes(tag) ? (tag as 'guidance' | 'criterion' | 'conclusion') : 'guidance';
|
||||
}
|
||||
|
||||
export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
|
||||
const out: ChatMessage[] = [];
|
||||
/*
|
||||
* 上一条是不是**收尾那句**(terminal)—— 只有它不许认领后面的卡。
|
||||
*
|
||||
* 不能拿 `tag === 'conclusion'` 当这个判断:交代本身常常就是结论口吻
|
||||
* (「判据我都裁过了,交你签」),模型给它标 conclusion 完全正当。
|
||||
* 实测栽过一次:收口那一轮的交代标了 conclusion,于是并卡被这条挡下,
|
||||
* 验收卡又自成一条 —— 第一轮并对了、第二轮没并,同一屏两种样子。
|
||||
*/
|
||||
let lastIsTerminal = false;
|
||||
for (const e of entries) {
|
||||
const id = cardIdSchema.parse(e.id);
|
||||
if (e.kind === 'observation') {
|
||||
out.push({ k: 'system', id, at: e.at, type: eventTypeOf(e.detail), text: e.title });
|
||||
if (e.kind === 'narration') {
|
||||
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: tagOf(e.detail) });
|
||||
lastIsTerminal = false;
|
||||
continue;
|
||||
}
|
||||
/*
|
||||
* 触发事件与系统观察在对话区**长一个样**(都是灰头像的系统气泡)——
|
||||
* 分家分在归侧与日志上,不在对话区:人在对话流里要的是「发生了什么」,
|
||||
* 不是「这条属于哪一类」。
|
||||
*/
|
||||
if (e.kind === 'trigger' || e.kind === 'observation') {
|
||||
// 引擎内部观察(已在进行中 / 批截断…)进日志不进对话 —— 对话区不是机器踪迹的副本
|
||||
if (e.detail?.['internal'] === true) continue;
|
||||
/*
|
||||
* 裸「事件到达(human):卡号」也不进:对人它只是一串 UUID,而这件事
|
||||
* 已由**回执**(card-reply 投影)用人话说过一遍 —— 同一件事不说两遍。
|
||||
* 认的是 `detail.kind`(结构化标记),不是标题前缀:措辞改一次,
|
||||
* 按前缀认的过滤就静默失灵,而失灵的样子是「对话区多出一串 UUID」。
|
||||
*/
|
||||
if (e.detail?.['kind'] === 'human') continue;
|
||||
out.push({
|
||||
k: 'system',
|
||||
id,
|
||||
at: e.at,
|
||||
type: eventTypeOf(e.detail),
|
||||
text: e.title,
|
||||
// 起单那句开场白带「查看邮件」—— 其余系统事件没有去处,不加这个键
|
||||
...(e.detail?.['openComms'] === true ? { openComms: true } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
/*
|
||||
* 认的是 `detail.mode`,**不是标题前缀**(2026-07-31 改)——
|
||||
* 从前判的是 `title.startsWith('执行工单回传:')`,而标题措辞是给人看的、随时会改;
|
||||
* 改一次这条投影就静默失灵:人办结了工单,对话区一句话也不出,像没发生过。
|
||||
* 结构化标记改不动,措辞怎么改都不影响。
|
||||
*/
|
||||
if (e.kind === 'card-reply' && e.detail?.['mode'] === 'execution') {
|
||||
/*
|
||||
* 页面交互的**回执**(PRD:三类唤醒事件之一)——人在视图区办结了一件工单,
|
||||
* 对话流上要有一句人话的痕:「工单已办结,AI 接着往下推」。
|
||||
* 没有它,人办完一件事回到对话区,看到的还是办之前那句话 —— 像没发生过。
|
||||
*/
|
||||
out.push({
|
||||
k: 'system',
|
||||
id,
|
||||
at: e.at,
|
||||
type: 'human',
|
||||
text: `「${e.title}」已办结,AI 接着往下推。`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (e.kind === 'card-sent') {
|
||||
const ref = workOrderRef(e.detail);
|
||||
// 跨人卡进对方的消息要素,不在本任务对话流上露面
|
||||
if (ref === undefined) continue;
|
||||
out.push({
|
||||
k: 'ai',
|
||||
id,
|
||||
at: e.at,
|
||||
text: e.title,
|
||||
// 验收件请人裁决 = 判据;执行件派工 = 指导
|
||||
tag: e.title.includes('验收') ? 'criterion' : 'guidance',
|
||||
ref: workOrderIdSchema.parse(ref),
|
||||
});
|
||||
// 验收件请人裁决 = 判据;执行件派工 = 指导
|
||||
const tag = e.title.includes('验收') ? ('criterion' as const) : ('guidance' as const);
|
||||
/*
|
||||
* **一个气泡:结论 + 功能卡**(2026-08-01 用户定的形状)。
|
||||
*
|
||||
* 交代与卡本是同一轮的两半 —— 结论说「这一轮办完了什么、为什么找你」,
|
||||
* 卡是「你要办的那件事」。从前各占一个气泡,于是每轮都是两条:
|
||||
* 一条话、一条只有标题加个按钮的卡,而卡的标题多半是那句话的缩写。
|
||||
*
|
||||
* 这一拍刚说过话(紧邻的上一条就是它,turn-runner 保证交代先于行动),
|
||||
* 就把卡挂到那句话上,不另起一条。没说话就自己成一条 —— 卡不能不出现。
|
||||
*/
|
||||
const last = out[out.length - 1];
|
||||
if (last?.k === 'ai' && last.ref === undefined && !lastIsTerminal) {
|
||||
out[out.length - 1] = { ...last, tag, ref: workOrderIdSchema.parse(ref) };
|
||||
continue;
|
||||
}
|
||||
out.push({ k: 'ai', id, at: e.at, text: e.title, tag, ref: workOrderIdSchema.parse(ref) });
|
||||
continue;
|
||||
}
|
||||
if (e.kind === 'release-verdict' || e.kind === 'terminal') {
|
||||
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: 'conclusion' });
|
||||
/*
|
||||
* **收尾只出一条**(2026-08-01)。
|
||||
*
|
||||
* 从前这一段连出三条:「裁决:pass」「落地收敛,发射输出口:intake、to-alternative」
|
||||
* 「终态:released」—— 一件事说三遍,而且前两条是引擎内部话:
|
||||
* 裁决是**人自己刚按下的**,不必读回给他听;发了哪几个口是编排层的事实,
|
||||
* 它在工作日志里(那一屏本来就是给排障看的),不该占对话区一条。
|
||||
* 留下的是终态那一条:这一单结没结、怎么结的 —— 那才是他要知道的。
|
||||
*/
|
||||
if (e.kind === 'terminal') {
|
||||
/*
|
||||
* **顺利收尾的那一条不再出声**(2026-08-02 用户当场指出:「终态:released 删除」)。
|
||||
*
|
||||
* 两个毛病:①摆的是 `终态:released` —— 系统内的英文状态名,界面不摆这种词;
|
||||
* ②**它已经没有话可说了** —— 同日起,人签完字之后验收卡那一屏换成了「落地结果」
|
||||
* (落了几行、进了哪几张表、转去哪儿了),该说的它全说了,而且说得具体得多。
|
||||
* 再补一条「结束了」,是在一件已经交代清楚的事后面加一句废话。
|
||||
*
|
||||
* **没顺利收尾的仍要出声**:卡住了、被退回、预算耗尽 —— 那时人需要知道
|
||||
* 「这一单停在哪儿了」,而落地结果那一屏根本不会出现。措辞取共用的
|
||||
* `TERMINAL_STATE_LABEL`(与任务卡、日志同一本字典),不在这儿另抄一份。
|
||||
*/
|
||||
const state = /(?:终态[::]\s*)?([a-z]+)\s*$/.exec(e.title)?.[1];
|
||||
const label = state === undefined ? undefined : TERMINAL_STATE_LABEL[state as TerminalState];
|
||||
if (state !== 'released') {
|
||||
out.push({ k: 'ai', id, at: e.at, text: label === undefined ? e.title : `这一单${label}`, tag: 'conclusion' });
|
||||
lastIsTerminal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -48,6 +48,27 @@ export function displayValue(value: unknown): string {
|
||||
}
|
||||
|
||||
const SLOT = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g;
|
||||
/** `data-blank`:这一格空着的时候**真的留空**,不填「—」 */
|
||||
const BLANK = /\s*\bdata-blank(?:\s*=\s*"[^"]*")?/g;
|
||||
/** `data-class-of="路径"`:把该路径的值消毒成一个 class 后缀 —— **按值上色**用 */
|
||||
const CLASS_OF = /\s*\bdata-class-of\s*=\s*"([^"]+)"/;
|
||||
|
||||
/**
|
||||
* 值 → class 后缀。**只留 ASCII 字母数字与横杠,全部小写,封顶 24 字**;
|
||||
* 一个字符都不合规就不给 class(宁可没有颜色,不给一个错的钩子)。
|
||||
*
|
||||
* 为什么要有它:模版不许算,于是「A 绿 / B 橙 / C 紫」这类**按值分档的表现**
|
||||
* 从前一概做不了 —— 只能三个值一个色,那等于假装读懂了值。
|
||||
* 而把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。
|
||||
* 消毒是硬的:属性位不开放插值,这里是唯一一处值进属性,所以只放行白名单字符。
|
||||
*/
|
||||
export function classToken(value: unknown): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
const text = String(typeof value === 'boolean' ? (value ? 'true' : 'false') : value)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '');
|
||||
return text.slice(0, 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把作用域填进模版的槽位。
|
||||
@@ -55,13 +76,72 @@ const SLOT = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*)>([\s
|
||||
* 填充**消费槽位**:填完就把 `data-slot` 摘掉 —— 一来第二遍不会再拿外层作用域
|
||||
* 把行内已填好的值冲掉,二来交出去的是干净 HTML,不是带模板属性的半成品。
|
||||
* 未命中的槽位填「—」而不是留原样:模版里的占位文字漏到成品上比空着更糟。
|
||||
*
|
||||
* ── 例外:`data-blank` ────────────────────────────────────────────────
|
||||
* 表格里「这一格客户没给」和「这一格填了个破折号」是两件事。带 `data-blank`
|
||||
* 的槽位空值就**留空**,交给模版自己的 CSS 去标(`td:empty::after` 出「必填 / 选填」
|
||||
* 胶囊、`tr:has(td.req:empty)` 整行标底)—— 判断仍在样式里,不在这里:
|
||||
* 填数还是只做取值与转义两件事,没有表达式、没有分支。
|
||||
*/
|
||||
export function renderTemplate(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return html.replace(SLOT, (_all, tag: string, before: string, path: string, after: string) => {
|
||||
const attrs = `${before}${after}`.replace(/\s+/g, ' ').trimEnd();
|
||||
const text = escapeHtml(displayValue(resolveSlot(scope, path)));
|
||||
const filled = html.replace(SLOT, (_all, tag: string, before: string, path: string, after: string) => {
|
||||
const raw = `${before}${after}`;
|
||||
const blank = /\bdata-blank\b/.test(raw);
|
||||
const attrs = paintClass(raw.replace(BLANK, ''), scope).replace(/\s+/g, ' ').trimEnd();
|
||||
const value = resolveSlot(scope, path);
|
||||
const empty = value === undefined || value === null || value === '';
|
||||
const text = blank && empty ? '' : escapeHtml(displayValue(value));
|
||||
return `<${tag}${attrs === '' ? '' : ` ${attrs.trim()}`}>${text}</${tag}>`;
|
||||
});
|
||||
/*
|
||||
* **值进 value 属性的那一种** —— `<input … data-slot="pn" data-value/>`。
|
||||
*
|
||||
* 上一遍认的是「开合标签之间填字」,而 input 是空元素,没有「之间」。
|
||||
* 这一份是可改格子的输入孪生件(见 `twinInputs`):值得进 `value=`,
|
||||
* 空值就真空着 —— 空的 input 会显示自己的 placeholder,那正是「这一格还没有」。
|
||||
* 属性位插值只此一处,所以转义照文本位那四个字符来,引号也在其中。
|
||||
*/
|
||||
const withValues = filled.replace(
|
||||
/<input\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*?)\bdata-value\b([^>]*)>/g,
|
||||
(_all, before: string, path: string, mid: string, after: string) => {
|
||||
const value = resolveSlot(scope, path);
|
||||
const empty = value === undefined || value === null || value === '';
|
||||
// 末尾那个自闭合斜杠会被 `after` 一并捞进来 —— 不摘掉就写出 `/ value="…"`
|
||||
const attrs = `${before}${mid}${after}`.replace(/\/\s*$/, '').replace(/\s+/g, ' ').trim();
|
||||
return `<input ${attrs} value="${empty ? '' : escapeHtml(displayValue(value))}"/>`;
|
||||
},
|
||||
);
|
||||
/*
|
||||
* **只上色、不填字的那些格子** —— 剩下的 `data-class-of` 单独走一遍。
|
||||
*
|
||||
* 上一遍只认「带 data-slot 的元素」,于是分布条那种**纯色块**
|
||||
* (`<i data-class-of="type_tag"></i>`,一格一条料、格里不写字)整条静默失色:
|
||||
* 属性还在,一个 class 都没长出来,屏上是一排灰格子 —— 而灰格子看着像
|
||||
* 「还没算出来」,不像「没上色」。两遍都栽过同一个坑,所以把两件事分开:
|
||||
* 取值填字是一件事,按值上色是另一件事,后者不该以前者为条件。
|
||||
*/
|
||||
return withValues.replace(
|
||||
/<([a-zA-Z][\w-]*)\b([^>]*?\bdata-class-of\s*=\s*"[^"]+"[^>]*)>/g,
|
||||
(_all, tag: string, attrs: string) => {
|
||||
const painted = paintClass(attrs, scope).replace(/\s+/g, ' ').trimEnd();
|
||||
return `<${tag}${painted === '' ? '' : ` ${painted.trim()}`}>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `data-class-of="路径"` → 把该路径的值消毒后追加成 `v-<值>`,并**消费掉属性本身**。
|
||||
* 消费是要紧的:交出去的是干净 HTML,行模版复制第二遍时也不会再追加一次。
|
||||
*/
|
||||
function paintClass(attrs: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
const classOf = CLASS_OF.exec(attrs);
|
||||
if (classOf === null) return attrs;
|
||||
const rest = attrs.replace(CLASS_OF, '');
|
||||
const token = classToken(resolveSlot(scope, classOf[1] ?? ''));
|
||||
if (token === '') return rest;
|
||||
return /\bclass\s*=\s*"[^"]*"/.test(rest)
|
||||
? rest.replace(/\bclass\s*=\s*"([^"]*)"/, (_m, cls: string) => `class="${cls} v-${token}"`)
|
||||
: `${rest} class="v-${token}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,19 +152,57 @@ const ROWS = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-rows\s*=\s*"([^"]+)"([^>]*)>([\s
|
||||
|
||||
export function renderRows(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return html.replace(ROWS, (_all, tag: string, before: string, path: string, after: string, inner: string) => {
|
||||
const attrs = `${before}${after}`.replace(/\s+/g, ' ').trim();
|
||||
const raw = `${before}${after}`;
|
||||
const attrs = raw.replace(/\s+/g, ' ').trim();
|
||||
const open = `<${tag}${attrs === '' ? '' : ` ${attrs}`}>`;
|
||||
const value = resolveSlot(scope, path);
|
||||
const rows = Array.isArray(value) ? value : [];
|
||||
/*
|
||||
* 这一组行**可就地改**吗 —— 容器上的 `data-edit="域.对象"` 说了算。
|
||||
* 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。
|
||||
*/
|
||||
const editable = /\bdata-edit\s*=\s*"([^"]+)"/.exec(raw) !== null;
|
||||
const body = rows
|
||||
.map((row) =>
|
||||
renderTemplate(inner, (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>),
|
||||
)
|
||||
.map((row, index) => {
|
||||
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
|
||||
return renderTemplate(editable ? twinInputs(inner, index) : inner, one);
|
||||
})
|
||||
.join('');
|
||||
return `${open}${body}</${tag}>`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 带 `data-col` 的取值元素:`<span … data-slot="pn" data-col="PN">` */
|
||||
const EDITABLE_CELL = /<(span|b)\b([^>]*?\bdata-col\s*=\s*"([^"]+)"[^>]*)><\/\1>/g;
|
||||
|
||||
/**
|
||||
* **给可改的格子配一个输入孪生件** —— 只读那一份原样留着,旁边多一个 `<input>`,
|
||||
* 两者由样式表二选一显示(根节点带不带 `editing`)。
|
||||
*
|
||||
* 为什么是「都渲出来」而不是「渲成输入」:同一份件既要在待处理页给人改,
|
||||
* 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉;
|
||||
* 而容器是 `sandbox="allow-same-origin"` 的无脚本 iframe,外层**读得到也改得动**
|
||||
* 里面的 DOM:开编辑就是给根节点加个 class,收值就是把 input 读出来。
|
||||
* 模版这一侧仍然只做取值 —— 判断(显示哪一份)留在样式表。
|
||||
*
|
||||
* `data-col` 写的是**台账列名**,不是表头文字:写回时基座按列名对格,
|
||||
* 表头改个说法不该让写回错位。
|
||||
*/
|
||||
function twinInputs(inner: string, rowIndex: number): string {
|
||||
return inner.replace(EDITABLE_CELL, (all, _tag: string, attrs: string, col: string) => {
|
||||
const slot = /\bdata-slot\s*=\s*"([^"]+)"/.exec(attrs)?.[1];
|
||||
if (slot === undefined) return all;
|
||||
// 只读那一份的排版类(mono / num)照搬给输入件 —— 同一列两态字体要一致
|
||||
const style = (/\bclass\s*=\s*"([^"]*)"/.exec(attrs)?.[1] ?? '')
|
||||
.split(/\s+/)
|
||||
.filter((c) => c === 'mono' || c === 'num')
|
||||
.join(' ');
|
||||
const input = `<input class="jc-cell${style === '' ? '' : ` ${style}`}" data-col="${col}" data-row="${rowIndex}" data-slot="${slot}" data-value/>`;
|
||||
// 只读那一份把 `data-col` 交出去 —— 它的活已经干完(告诉这里是哪一列),留着只是噪音
|
||||
return `${all.replace(/\s*\bdata-col\s*=\s*"[^"]*"/, '')}${input}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
|
||||
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return renderTemplate(renderRows(html, scope), scope);
|
||||
|
||||
@@ -19,7 +19,8 @@ export type InteractionServiceDeps = {
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
type Subscriber = { staffId: StaffId; onPush: (envelope: PushEnvelope) => void };
|
||||
/** 订阅者:`staffId` 缺省 = 系统级转接(装配层接走全部推送,转给前端 SSE) */
|
||||
type Subscriber = { staffId: StaffId | undefined; onPush: (envelope: PushEnvelope) => void };
|
||||
|
||||
export function createInteractionService(deps: InteractionServiceDeps): InteractionService {
|
||||
// SSE 订阅者表是**连接级**状态,不是过程状态:进程内持有,刷新即从投影重建(§9.2)
|
||||
@@ -70,10 +71,11 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
|
||||
},
|
||||
|
||||
/** 视图模版过净化门:含脚本 / 事件处理器 / 外部资源一律拒推 */
|
||||
async pushViewHtml(taskId, html) {
|
||||
async pushViewHtml(taskId, html, template) {
|
||||
const violations = checkTemplate(html);
|
||||
if (violations.length > 0) throw new TemplateRejectedError(violations);
|
||||
await deps.store.putView(taskId, { html });
|
||||
// 模版名一起存:件是投影,数据变了要能按同一张模版重渲(见 contract 的注释)
|
||||
await deps.store.putView(taskId, { html, ...(template === undefined ? {} : { template }) });
|
||||
broadcast({ kind: 'view-html', taskId, html });
|
||||
},
|
||||
|
||||
@@ -98,6 +100,13 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
|
||||
return { close: () => void subscribers.delete(subscriber) } satisfies Subscription;
|
||||
},
|
||||
|
||||
/** 系统级转接 —— 装配层接走全部推送,转给前端 SSE(见 contract 的注释) */
|
||||
onPush(listener) {
|
||||
const bridge: Subscriber = { staffId: undefined, onPush: listener };
|
||||
subscribers.add(bridge);
|
||||
return { close: () => void subscribers.delete(bridge) } satisfies Subscription;
|
||||
},
|
||||
|
||||
invalidate(target, id) {
|
||||
broadcast({ kind: 'invalidate', target, id });
|
||||
},
|
||||
|
||||
@@ -11,8 +11,8 @@ export type IdGenerator = { next(): string };
|
||||
export type InteractionStore = {
|
||||
appendMessage(taskId: TaskId, message: ChatMessage): Promise<void>;
|
||||
listMessages(taskId: TaskId): Promise<ChatMessage[]>;
|
||||
putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }): Promise<void>;
|
||||
getView(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string } | undefined>;
|
||||
putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string; template?: string }): Promise<void>;
|
||||
getView(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string; template?: string } | undefined>;
|
||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
|
||||
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
|
||||
};
|
||||
|
||||
@@ -25,8 +25,17 @@
|
||||
不变量:
|
||||
- **写三分是三个方法**:`systemWrite` / `draftWrite` / `releaseWrite` —— 无第四条写入路径,
|
||||
调用方拿不到带 flag 的「绕门」形态(§12.5#9)
|
||||
- `draftDrop` **不是第四种写**:它只撤本单草稿层那一笔 —— 正式版一个字都动不了,
|
||||
也不抛事件,放行门的位置没变。写错了得有退路:实测过一次死胡同 ——
|
||||
客户说本轮跳过的那颗料被写进有效清单,判据正确地拦住而模型改不动
|
||||
(唯一能改的是那个数,改了别的数又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||
**门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。**
|
||||
撤不到如实回 `false`(主键写法对不上是常见原因)—— 静默成功会让调用方以为撤掉了
|
||||
- 正式版写入(system / release)**必**产生数据事件;草稿写**必不**产生
|
||||
- `releaseWrite` 先过数据冲突检查:任一冲突即抛 `DraftConflictError`,不做部分合并
|
||||
- **试跑单的主数据不进正式表**:`SimulationProbe` 说这一单是试跑时,
|
||||
`kind = 'master'` 的草稿行不 upsert、不删、不抛数据事件,但仍进 `merged`(出口载荷取它);
|
||||
`simulatedRows` 单独计数,不混进 `mergedRows`。单据(`record`)不受影响 —— 试跑仍是真跑
|
||||
- 合并只增不破坏:`planSchemaChanges` 永不产出删列 / 缩窄;主键一经建立不得由声明变更
|
||||
- 同名异型取能兼容双方的最宽类型(integer→number→text;date→timestamp→text);
|
||||
`json` 与标量语义互斥 —— 拦下,交数据管理裁决
|
||||
@@ -34,7 +43,8 @@
|
||||
- `ledger` schema 的唯一写者是本模块 adapter;SQL 拼接只在 `ledger-store.ts`,
|
||||
标识符过 `IDENT` 正则 + `quoteIdent`
|
||||
|
||||
影响半径: index/contract/port 未变 → 对外零影响
|
||||
影响半径: contract 加 `ReleaseWriteResult.simulatedRows`(加性)· port 加 `LedgerStore.masterRefs`
|
||||
与 `SimulationProbe`(app 注入)→ 下游只有 release 需同步读新字段
|
||||
|
||||
近变(2026-07-28):**人读名落库** —— 物化时把声明里的 `label` 写成 PG 的表/列注释,
|
||||
`describe` 与 `listObjects` 一并读回(`MaterializedObject.label` /
|
||||
|
||||
@@ -20,14 +20,25 @@ let published: EnvelopeDraft[] = [];
|
||||
|
||||
const DOMAIN = 'it_quote';
|
||||
const OBJECT = 'intake';
|
||||
/** 试跑闸门那一组用的主数据对象(与单据对象同库同任务,才测得出「只挡主数据」) */
|
||||
const MASTER_DOMAIN = 'it_customer';
|
||||
const MASTER_OBJECT = 'profile';
|
||||
const T1 = 't-it-1' as TaskId;
|
||||
const T2 = 't-it-2' as TaskId;
|
||||
const T3 = 't-it-3' as TaskId;
|
||||
const T4 = 't-it-4' as TaskId;
|
||||
const T5 = 't-it-5' as TaskId;
|
||||
const T6 = 't-it-6' as TaskId;
|
||||
|
||||
/** 哪几单算试跑 —— 由用例现填,替代 app 里那条查 `task.origin` 的 SQL */
|
||||
const SIMULATED = new Set<string>();
|
||||
|
||||
const declaration = (fields: ObjectDeclaration['fields']): ObjectDeclaration => ({
|
||||
domain: DOMAIN,
|
||||
object: OBJECT,
|
||||
// 台账层不关心性质,给个最常见的值即可(分类由安装静态审核)
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
fields,
|
||||
});
|
||||
|
||||
@@ -49,13 +60,21 @@ beforeAll(async () => {
|
||||
},
|
||||
clock: { now: () => new Date('2026-07-27T12:00:00Z') },
|
||||
ids: { next: () => `it-${(counter += 1)}` },
|
||||
// 这一组用例测的是真库读写,不是试跑闸门 —— 一律当生产单
|
||||
simulation: { isSimulated: async (taskId) => SIMULATED.has(taskId) },
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (pool !== undefined) {
|
||||
await pool.query(`drop table if exists ledger."${DOMAIN}__${OBJECT}"`).catch(() => undefined);
|
||||
await pool.query(`delete from ledger.__draft where task_id in ($1, $2)`, [T1, T2]).catch(() => undefined);
|
||||
await pool.query(`drop table if exists ledger."${MASTER_DOMAIN}__${MASTER_OBJECT}"`).catch(() => undefined);
|
||||
await pool
|
||||
.query(`delete from ledger.__object_meta where domain in ($1, $2)`, [DOMAIN, MASTER_DOMAIN])
|
||||
.catch(() => undefined);
|
||||
await pool
|
||||
.query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6]])
|
||||
.catch(() => undefined);
|
||||
await pool.end();
|
||||
}
|
||||
await database?.stop();
|
||||
@@ -172,7 +191,12 @@ describe('ledger · 真实 PG', () => {
|
||||
expect(official.rows[0]).toMatchObject({ grade: 'C' });
|
||||
|
||||
// 草稿已清空:再放行是空操作
|
||||
expect(await ledger.releaseWrite(T1)).toEqual({ mergedRows: 0, emittedEvents: 0, merged: [] });
|
||||
expect(await ledger.releaseWrite(T1)).toEqual({
|
||||
mergedRows: 0,
|
||||
emittedEvents: 0,
|
||||
simulatedRows: 0,
|
||||
merged: [],
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
itDb()('数据冲突检查:他单已改同一字段 → 放行写打回,不做部分合并', async () => {
|
||||
@@ -253,3 +277,97 @@ describe('ledger · 真实 PG', () => {
|
||||
expect(official.rows[0]).toMatchObject({ grade: 'B', note: '二版' });
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
/**
|
||||
* 试跑单不落主数据 —— 挡的是「落到世界」,不是挡这一单自己看得见。
|
||||
*
|
||||
* 判据四条,缺一条这道闸都算白装:
|
||||
* ① 主数据没进正式表(否则「新客户」样例第二次跑就成了老客户)
|
||||
* ② 同一单里的**单据照落**(试跑仍是真跑,只挡主数据这一类)
|
||||
* ③ 被挡下的行仍进 `merged`(出口载荷取的是它,拦了就断了下游)
|
||||
* ④ 行数如实分开报(放行痕上不许把没进表的算进「合并 N 行」)
|
||||
*/
|
||||
describe('ledger · 试跑闸门', () => {
|
||||
itDb()('试跑单:主数据挡在正式表外,单据照落,出口载荷不缺行', async () => {
|
||||
await ledger.applyDeclarations([
|
||||
{
|
||||
domain: MASTER_DOMAIN,
|
||||
object: MASTER_OBJECT,
|
||||
kind: 'master',
|
||||
fields: [
|
||||
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false },
|
||||
{ name: 'name', type: 'text', primaryKey: false, masked: false },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
SIMULATED.add(T5);
|
||||
// 同一单里两笔:一笔主数据、一笔单据
|
||||
await ledger.draftWrite({
|
||||
taskId: T5,
|
||||
address: { domain: MASTER_DOMAIN, object: MASTER_OBJECT },
|
||||
key: { customer_code: 'C-SIM' },
|
||||
patch: { customer_code: 'C-SIM', name: '试跑建的客户' },
|
||||
});
|
||||
await ledger.draftWrite({
|
||||
taskId: T5,
|
||||
address: { domain: DOMAIN, object: OBJECT },
|
||||
key: { pn: 'SIM-PN' },
|
||||
patch: { grade: 'A' },
|
||||
});
|
||||
|
||||
const result = await ledger.releaseWrite(T5);
|
||||
|
||||
// ④ 行数分开报
|
||||
expect(result.simulatedRows).toBe(1);
|
||||
expect(result.mergedRows).toBe(1);
|
||||
|
||||
// ① 主数据一行没进
|
||||
const master = await ledger.query({
|
||||
address: { domain: MASTER_DOMAIN, object: MASTER_OBJECT },
|
||||
where: { customer_code: 'C-SIM' },
|
||||
orderBy: [],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(master.rows).toEqual([]);
|
||||
|
||||
// ② 单据照落
|
||||
const record = await ledger.query({
|
||||
address: { domain: DOMAIN, object: OBJECT },
|
||||
where: { pn: 'SIM-PN' },
|
||||
orderBy: [],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(record.rows[0]).toMatchObject({ grade: 'A' });
|
||||
|
||||
// ③ 出口载荷两行都在
|
||||
expect(result.merged.map((m) => `${m.domain}.${m.object}`).sort()).toEqual(
|
||||
[`${DOMAIN}.${OBJECT}`, `${MASTER_DOMAIN}.${MASTER_OBJECT}`].sort(),
|
||||
);
|
||||
expect(result.merged.find((m) => m.domain === MASTER_DOMAIN)?.values).toMatchObject({ name: '试跑建的客户' });
|
||||
}, 60_000);
|
||||
|
||||
itDb()('生产单:同一张主数据表照写不误 —— 闸门只对试跑生效', async () => {
|
||||
await ledger.draftWrite({
|
||||
taskId: T6,
|
||||
address: { domain: MASTER_DOMAIN, object: MASTER_OBJECT },
|
||||
key: { customer_code: 'C-REAL' },
|
||||
patch: { customer_code: 'C-REAL', name: '真单建的客户' },
|
||||
});
|
||||
|
||||
const result = await ledger.releaseWrite(T6);
|
||||
expect(result.simulatedRows).toBe(0);
|
||||
expect(result.mergedRows).toBe(1);
|
||||
|
||||
const master = await ledger.query({
|
||||
address: { domain: MASTER_DOMAIN, object: MASTER_OBJECT },
|
||||
where: { customer_code: 'C-REAL' },
|
||||
orderBy: [],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(master.rows[0]).toMatchObject({ name: '真单建的客户' });
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,8 @@ const f = (
|
||||
const decl = (fields: ReturnType<typeof f>[]): ObjectDeclaration => ({
|
||||
domain: 'quote',
|
||||
object: 'intake',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
fields,
|
||||
});
|
||||
|
||||
@@ -125,7 +127,7 @@ describe('mergeDeclarations —— 最大化合并', () => {
|
||||
|
||||
it('拒绝混入别的对象', () => {
|
||||
expect(() =>
|
||||
mergeDeclarations([decl([f('a', 'text')]), { domain: 'quote', object: 'other', fields: [f('a', 'text')] }]),
|
||||
mergeDeclarations([decl([f('a', 'text')]), { domain: 'quote', object: 'other', kind: 'record' as const, caseKey: 'rfq_ref', fields: [f('a', 'text')] }]),
|
||||
).toThrow(/只接受同一对象/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,12 @@ export type DraftWrite = {
|
||||
export type ReleaseWriteResult = {
|
||||
readonly mergedRows: number;
|
||||
readonly emittedEvents: number;
|
||||
/**
|
||||
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
|
||||
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
|
||||
* 非试跑单恒为 0。
|
||||
*/
|
||||
readonly simulatedRows: number;
|
||||
/**
|
||||
* 合并进正式版的那些行(键 + 值)。
|
||||
* 「落地收敛,输出口才发射」——发射的载荷就该是**这些已落地的事实**,
|
||||
@@ -96,6 +102,18 @@ export type LedgerService = {
|
||||
|
||||
systemWrite(write: SystemWrite): Promise<void>;
|
||||
draftWrite(write: DraftWrite): Promise<void>;
|
||||
/**
|
||||
* **撤掉本单草稿层的一行** —— 写错了得有退路(2026-08-01 加)。
|
||||
*
|
||||
* 不是第四种写:它只作用在**本单草稿层**,正式版一个字都动不了,也不抛事件 ——
|
||||
* 撤的是「我这一单还没过门的那一笔」。放行门的位置没变。
|
||||
*
|
||||
* 没有它的后果实测过:客户说本轮跳过的那颗料被写进了有效清单,条目数报 9 而表里躺着 10,
|
||||
* 判据 `record_rows = line_total` 正确地拦住,而模型**改不动** ——
|
||||
* 它唯一能改的是那个数(改了 skipped_total 又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||
* 门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。
|
||||
*/
|
||||
draftDrop(drop: { taskId: TaskId; address: ObjectAddress; key: Record<string, unknown> }): Promise<boolean>;
|
||||
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
|
||||
|
||||
/** 正式版查询 —— 跨任务只见过门的事实 */
|
||||
|
||||
@@ -100,12 +100,39 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
|
||||
return materialized;
|
||||
},
|
||||
|
||||
/*
|
||||
* 记下对象性质 —— **每次应用声明都写**,不只建表那一次。
|
||||
* 挂在 create-table 分支上是错的(踩过):表早就建好了,那条分支不走,
|
||||
* meta 永远是空的,治理台上全部表都成了「无人声明」。
|
||||
*/
|
||||
async setObjectMeta(ref, kind, caseKey) {
|
||||
await runner.query(
|
||||
`insert into ledger.__object_meta (domain, object, kind, case_key) values ($1,$2,$3,$4)
|
||||
on conflict (domain, object) do update set kind = excluded.kind, case_key = excluded.case_key`,
|
||||
[ref.domain, ref.object, kind, caseKey ?? null],
|
||||
);
|
||||
},
|
||||
|
||||
async listObjects() {
|
||||
// 表注释即人读名 —— 与表名同一次查询取回,治理台不必逐个 describe 才知道叫什么
|
||||
const res = await runner.query<{ table_name: string; label: string | null }>(
|
||||
const res = await runner.query<{
|
||||
table_name: string;
|
||||
label: string | null;
|
||||
kind: string | null;
|
||||
case_key: string | null;
|
||||
}>(
|
||||
/*
|
||||
* 性质与人读名**同一次查询取回**:治理台要按性质分区,而性质写在活动清单里 ——
|
||||
* 若让路由每次去把清单捞回来重建,那是 12 个活动 × 全版本清单 ≈ 1.5MB 的 JSON
|
||||
* 解析(实测热连接 73ms、并发八次 350ms,慢请求还占住浏览器连接连累别的页)。
|
||||
* 物化的东西记在物化侧:表建出来那一刻性质就定了。
|
||||
*/
|
||||
`select t.table_name,
|
||||
obj_description(($1 || '.' || quote_ident(t.table_name))::regclass) as label
|
||||
obj_description(($1 || '.' || quote_ident(t.table_name))::regclass) as label,
|
||||
m.kind, m.case_key
|
||||
from information_schema.tables t
|
||||
left join ledger.__object_meta m
|
||||
on m.domain || '__' || m.object = t.table_name
|
||||
where t.table_schema = $1 and t.table_name not like '\\_\\_%' order by t.table_name`,
|
||||
[LEDGER_SCHEMA],
|
||||
);
|
||||
@@ -117,11 +144,21 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
|
||||
domain: r.table_name.slice(0, idx),
|
||||
object: r.table_name.slice(idx + 2),
|
||||
...(r.label === null ? {} : { label: r.label }),
|
||||
...(r.kind === null ? {} : { kind: r.kind }),
|
||||
...(r.case_key === null ? {} : { caseKey: r.case_key }),
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
|
||||
/** 主数据清单 —— 放行写拿它挡试跑单;性质在 `__object_meta`,一次 where 取回 */
|
||||
async masterRefs() {
|
||||
const res = await runner.query<{ domain: string; object: string }>(
|
||||
`select domain, object from ledger.__object_meta where kind = 'master'`,
|
||||
);
|
||||
return res.rows.map((r) => ({ domain: r.domain, object: r.object }));
|
||||
},
|
||||
|
||||
async applyChanges(ref, changes) {
|
||||
const table = `${LEDGER_SCHEMA}.${quoteIdent(tableNameOf(ref))}`;
|
||||
/*
|
||||
@@ -141,6 +178,7 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
|
||||
? `, primary key (${change.object.primaryKey.map(quoteIdent).join(', ')})`
|
||||
: '';
|
||||
await runner.query(`create table if not exists ${table} (${cols.join(', ')}${pk})`);
|
||||
|
||||
/*
|
||||
* 人读名落**表/列注释** —— 声明里带的 label 不能在建表这一步丢掉:
|
||||
* 丢了,治理台就只剩 `project__intake_record` 这种键给人看。
|
||||
@@ -341,6 +379,21 @@ export function createDraftStore(runner: Queryable): DraftStore {
|
||||
],
|
||||
);
|
||||
},
|
||||
/**
|
||||
* 撤掉本单草稿层的**一行** —— 写错了得有退路。
|
||||
*
|
||||
* 只删 `task_id` 那一份:正式版一个字都动不了。草稿层本来就是这一单的过程事实,
|
||||
* 过程里写错一行是常事;没有退路的后果实测过 ——
|
||||
* 客户说本轮跳过的那颗料被写进了有效清单,条目数报 9 而表里躺着 10,
|
||||
* 判据 `record_rows = line_total` 正确地拦住,而模型**改不动**:
|
||||
* 唯一能改的是那个数(改了 skipped_total 就不对),于是 13 次拒回、23 拍、234k token。
|
||||
*/
|
||||
async drop(taskId, ref, key) {
|
||||
await runner.query(
|
||||
`delete from ${DRAFT_TABLE} where task_id = $1 and domain = $2 and object = $3 and key_hash = $4`,
|
||||
[taskId, ref.domain, ref.object, keyOf(key)],
|
||||
);
|
||||
},
|
||||
async clear(taskId) {
|
||||
await runner.query(`delete from ${DRAFT_TABLE} where task_id = $1`, [taskId]);
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 同名字段类型不一致时,以**能兼容双方的最宽类型**落地(放宽即增,不破坏),静态审提示、不阻塞;
|
||||
* **语义互斥**不可兼容的极端情形才拦下,交数据管理裁决。
|
||||
*/
|
||||
import type { FieldDeclaration, LedgerFieldType, ObjectDeclaration } from '@contracts';
|
||||
import type { FieldDeclaration, LedgerFieldType, LedgerObjectKind, ObjectDeclaration } from '@contracts';
|
||||
|
||||
/* ── 类型格:widen(a, b) = 能同时容纳 a 与 b 的最窄类型 ────────────────── */
|
||||
|
||||
@@ -74,6 +74,9 @@ export type MergedObject = {
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
readonly label: string | undefined;
|
||||
/** 对象性质与单标识 —— 随表物化,治理台按它分区(见 0026_object_meta) */
|
||||
readonly kind: LedgerObjectKind;
|
||||
readonly caseKey: string | undefined;
|
||||
readonly fields: readonly MergedField[];
|
||||
readonly primaryKey: readonly string[];
|
||||
};
|
||||
@@ -172,6 +175,13 @@ export function mergeDeclarations(declarations: readonly ObjectDeclaration[]): M
|
||||
domain: first.domain,
|
||||
object: first.object,
|
||||
label: declarations.find((d) => d.label !== undefined)?.label,
|
||||
/*
|
||||
* 性质取**首份非默认的声明**:平台声明主数据在前(`master`),
|
||||
* 活动若也声明同一张表,它只补自己要写的那几列、kind 用的是缺省 `record` ——
|
||||
* 若拿最后一份覆盖,主数据会被活动的缺省值改回单据。
|
||||
*/
|
||||
kind: declarations.find((d) => d.kind !== 'record')?.kind ?? 'record',
|
||||
caseKey: declarations.find((d) => d.caseKey !== undefined)?.caseKey,
|
||||
fields,
|
||||
primaryKey,
|
||||
},
|
||||
|
||||
@@ -17,7 +17,15 @@ import type {
|
||||
SystemWrite,
|
||||
} from '../../contract.ts';
|
||||
import { DraftConflictError, ObjectNotMaterializedError } from '../../contract.ts';
|
||||
import type { Clock, DraftStore, EventPublisher, IdGenerator, LedgerStore, ObjectRef } from '../../port.ts';
|
||||
import type {
|
||||
Clock,
|
||||
DraftStore,
|
||||
EventPublisher,
|
||||
IdGenerator,
|
||||
LedgerStore,
|
||||
ObjectRef,
|
||||
SimulationProbe,
|
||||
} from '../../port.ts';
|
||||
import { mergeDeclarations, planIsNonDestructive, planSchemaChanges } from '../domain/schema-merge.ts';
|
||||
import { detectConflicts, keyOf, overlay, pickKey } from '../domain/task-view.ts';
|
||||
|
||||
@@ -27,6 +35,8 @@ export type LedgerServiceDeps = {
|
||||
readonly events: EventPublisher;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
/** 试跑判定 —— 放行写据此把主数据挡在正式表外(见 port.ts `SimulationProbe`) */
|
||||
readonly simulation: SimulationProbe;
|
||||
};
|
||||
|
||||
function groupDeclarations(declarations: readonly ObjectDeclaration[]): Map<string, ObjectDeclaration[]> {
|
||||
@@ -97,6 +107,11 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
||||
continue;
|
||||
}
|
||||
if (plan.changes.length > 0) await deps.store.applyChanges(ref, plan.changes);
|
||||
/*
|
||||
* 性质每次都记 —— 它随声明走,不随建表走。
|
||||
* 声明合并已经定了口径(平台的 master 优先于活动的缺省 record),这里照写。
|
||||
*/
|
||||
await deps.store.setObjectMeta(ref, merge.merged.kind, merge.merged.caseKey);
|
||||
results.push({
|
||||
merged: merge.merged,
|
||||
notices: [...merge.notices, ...plan.notices],
|
||||
@@ -148,13 +163,51 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 撤掉本单草稿层的一行 —— **只撤自己写过的**。
|
||||
*
|
||||
* 回 `false` 表示草稿层里根本没有这一行:那多半是主键写法对不上
|
||||
* (`DAG…` 与 `DAG…-R0` 是两行),如实说出来比静默成功有用 ——
|
||||
* 静默成功会让模型以为撤掉了,下一拍判据照旧不过,它又去撤一次。
|
||||
*/
|
||||
async draftDrop(drop) {
|
||||
const mine = await deps.drafts.listForObject(drop.taskId, drop.address);
|
||||
const schema = await requireSchema(drop.address);
|
||||
const keys = schema.primaryKey.length > 0 ? schema.primaryKey : Object.keys(drop.key);
|
||||
const wanted = pickKey({ ...drop.key }, keys);
|
||||
const hit = mine.some((d) => keys.every((k) => String((d.key as Record<string, unknown>)[k] ?? '') === String(wanted[k] ?? '')));
|
||||
if (!hit) return false;
|
||||
await deps.drafts.drop(drop.taskId, drop.address, wanted);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** ③ 放行写:冲突检查 → 原子合并 → 此刻抛事件 */
|
||||
async releaseWrite(taskId): Promise<ReleaseWriteResult> {
|
||||
const conflicts = await this.checkConflicts(taskId);
|
||||
if (conflicts.length > 0) throw new DraftConflictError(conflicts);
|
||||
|
||||
const drafts = await deps.drafts.list(taskId);
|
||||
if (drafts.length === 0) return { mergedRows: 0, emittedEvents: 0, merged: [] };
|
||||
if (drafts.length === 0) return { mergedRows: 0, emittedEvents: 0, simulatedRows: 0, merged: [] };
|
||||
|
||||
/*
|
||||
* **试跑单不落主数据** —— 挡在这一处,是因为这里是草稿层通往世界的唯一一道口子。
|
||||
*
|
||||
* 试跑照旧是真跑:过装载两审、过预算、过放行门,单据照写。挡的只有主数据 ——
|
||||
* 它按业务实体分行,试跑往 `customer.profile` 建一位客户,那位客户就永久留在库里,
|
||||
* 「新客户来函」那条样例第二次跑就成了老客户,这条路从此试不出来。
|
||||
* 单据不挡:它按单标识分行,一次试跑写自己那一行,清运行数据就干净了。
|
||||
*
|
||||
* 被挡下的行仍然进 `merged`(出口载荷取的就是它)—— 拦的是**落到世界**,
|
||||
* 不是拦这一单自己看得见:活动缓存里它一直在,这一单该怎么跑还怎么跑。
|
||||
*/
|
||||
const simulated = await deps.simulation.isSimulated(taskId);
|
||||
const masters = new Set<string>();
|
||||
if (simulated) {
|
||||
for (const ref of await deps.store.masterRefs()) masters.add(`${ref.domain}.${ref.object}`);
|
||||
}
|
||||
const heldBack = (draft: { domain: string; object: string }): boolean =>
|
||||
simulated && masters.has(`${draft.domain}.${draft.object}`);
|
||||
let simulatedRows = 0;
|
||||
|
||||
const pending: EnvelopeDraft[] = [];
|
||||
/*
|
||||
@@ -171,6 +224,23 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
||||
await deps.store.transaction(async (store, draftStore) => {
|
||||
for (const draft of drafts) {
|
||||
const ref: ObjectRef = { domain: draft.domain, object: draft.object };
|
||||
if (heldBack(draft)) {
|
||||
simulatedRows += 1;
|
||||
if (draft.tombstone) continue; // 试跑不删主数据,连一行也不许少
|
||||
/*
|
||||
* 值取「表里现有的 + 这一单改的」——没真写进去,读不回 PG 归型后的那一份,
|
||||
* 所以这里的值是草稿的原样(人的回传天生是字符串)。如实记在这:
|
||||
* 主数据不进出口载荷,下游拿不到它,归不归型都影响不到谁。
|
||||
*/
|
||||
const current = (await store.selectByKeys(ref, [draft.key]))[0];
|
||||
landed.push({
|
||||
domain: draft.domain,
|
||||
object: draft.object,
|
||||
key: draft.key,
|
||||
values: { ...(current ?? {}), ...draft.patch },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (draft.tombstone) {
|
||||
await store.deleteRow(ref, draft.key);
|
||||
pending.push(
|
||||
@@ -203,7 +273,12 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
||||
});
|
||||
|
||||
for (const envelope of pending) await deps.events.publish(envelope);
|
||||
return { mergedRows: drafts.length, emittedEvents: pending.length, merged: landed };
|
||||
return {
|
||||
mergedRows: drafts.length - simulatedRows,
|
||||
emittedEvents: pending.length,
|
||||
simulatedRows,
|
||||
merged: landed,
|
||||
};
|
||||
},
|
||||
|
||||
async query(query: LedgerQuery): Promise<LedgerPage> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* module: ledger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow } from '@contracts';
|
||||
import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow, LedgerObjectKind } from '@contracts';
|
||||
import type { MaterializedObject, SchemaChange } from './internal/domain/schema-merge.ts';
|
||||
import type { DraftEntry } from './internal/domain/task-view.ts';
|
||||
|
||||
@@ -11,6 +11,19 @@ export type IdGenerator = { next(): string };
|
||||
/** 数据事件的发射口 —— app 注入 `event` 模块的出口(§5.2) */
|
||||
export type EventPublisher = { publish(envelope: EnvelopeDraft): Promise<void> };
|
||||
|
||||
/**
|
||||
* 这一单是不是**试跑** —— app 注入(设计台的两条试跑路径都算,见 `container.ts`)。
|
||||
*
|
||||
* 台账在 L1,认不出「谁起的单」;而放行写要不要落主数据,取决的恰恰是这件事。
|
||||
* 所以由 app 把这一个布尔判断喂进来 —— 与 `EventPublisher` 同一个套路:
|
||||
* 模块只声明「我需要知道什么」,不声明「去哪儿问」。
|
||||
*
|
||||
* 为什么只挡主数据、不挡单据:单据按单标识分行,一次试跑写一行自己的,
|
||||
* 清运行数据就没了;主数据是**按业务实体**分行的档案,试跑往里建一位客户,
|
||||
* 「新客户」那条样例就此再也试不出来 —— 它成了老客户。
|
||||
*/
|
||||
export type SimulationProbe = { isSimulated(taskId: string): Promise<boolean> };
|
||||
|
||||
export type ObjectRef = { readonly domain: string; readonly object: string };
|
||||
|
||||
/**
|
||||
@@ -21,7 +34,16 @@ export type LedgerStore = {
|
||||
/** 已物化对象的列与主键;未物化返回 undefined */
|
||||
describe(ref: ObjectRef): Promise<MaterializedObject | undefined>;
|
||||
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
|
||||
/** 记下对象性质(单据/主数据/派生)与单标识 —— 随声明走,不随建表走 */
|
||||
setObjectMeta(ref: ObjectRef, kind: LedgerObjectKind, caseKey: string | undefined): Promise<void>;
|
||||
listObjects(): Promise<Array<ObjectRef & { label?: string | undefined }>>;
|
||||
/**
|
||||
* 哪些对象是**主数据** —— 放行写要据此把试跑单的主数据挡在正式表外。
|
||||
*
|
||||
* 单独一个方法而不是让调用方 `listObjects` 自己筛:性质记在 `__object_meta` 里,
|
||||
* 一次 where 就够;`describe` 读的是 information_schema,那里没有性质这一维。
|
||||
*/
|
||||
masterRefs(): Promise<ReadonlyArray<ObjectRef>>;
|
||||
/** 执行「只增不破坏」的变更计划 */
|
||||
applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>;
|
||||
|
||||
@@ -39,5 +61,13 @@ export type DraftStore = {
|
||||
list(taskId: string): Promise<Array<DraftEntry & ObjectRef>>;
|
||||
listForObject(taskId: string, ref: ObjectRef): Promise<DraftEntry[]>;
|
||||
put(taskId: string, ref: ObjectRef, entry: DraftEntry): Promise<void>;
|
||||
/**
|
||||
* 撤掉本单草稿层的**一行** —— 写错了得有退路(2026-08-01 加)。
|
||||
*
|
||||
* 只动 `taskId` 那一份,正式版碰不到。没有它的后果实测过:客户说本轮跳过的那颗料
|
||||
* 被写进了有效清单,条目数报 9 而表里躺着 10 —— 判据正确地拦住,而模型改不动
|
||||
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||
*/
|
||||
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
|
||||
clear(taskId: string): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
被依赖: toolbox, memory, loop-engine, flow-engine, skill-engine, task, activity-registry,
|
||||
report(叙述性小结)、app(治理台 · 系统管理路由)
|
||||
|
||||
近变(2026-07-30):`ModelCall.reasoning?: 'off' | 'low'` —— **压推理**。
|
||||
混合推理模型的隐藏思考按秒计:实测 A1 一拍可见出参 1538 字、推理正文 9808 字,
|
||||
48.6s 里八成五在解码没人读的字;关掉后同一单每拍 1.5-4.9s(十倍差)。
|
||||
厂商姿势收在 provider-profile 的 `reasoningControl`(DeepSeek:off →
|
||||
`thinking:{type:'disabled'}`,low → `reasoning_effort:'low'` —— 探针核过;
|
||||
low 在该厂身上实测无效,推理长度不降);没声明姿势的厂家如实忽略。
|
||||
回放指纹已带上 reasoning(关思考录的带不被开思考的重放命中)。
|
||||
|
||||
不变量:
|
||||
- **一切模型调用经 `ModelGateway`** —— 绕过它,回放审就对它失明(§12.5#10)
|
||||
- `replay` 模式**绝不回退实调**:缺录即 `ReplayMissError`
|
||||
|
||||
@@ -26,3 +26,19 @@ describe('Provider 画像', () => {
|
||||
expect(resolveBaseUrl('openai-compatible', undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('压推理的厂商姿势', () => {
|
||||
/*
|
||||
* 混合推理模型的隐藏思考按秒计:实测一拍可见出参 1538 字、推理 9808 字,
|
||||
* 48.6s 里八成五在解码没人读的思考。姿势收在画像里 —— 新厂家只改画像,不加 if。
|
||||
*/
|
||||
it('DeepSeek:off 关思考,low 降档 —— 参数名探针核过', () => {
|
||||
const p = profileOf('deepseek');
|
||||
expect(p.reasoningControl?.off).toEqual({ thinking: { type: 'disabled' } });
|
||||
expect(p.reasoningControl?.low).toEqual({ reasoning_effort: 'low' });
|
||||
});
|
||||
|
||||
it('没声明姿势的厂家:调用方的意愿如实忽略,不假装生效', () => {
|
||||
expect(profileOf('openai').reasoningControl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,12 @@ export type ModelCall = {
|
||||
readonly prompt: string;
|
||||
readonly temperature?: number;
|
||||
readonly maxOutputTokens?: number;
|
||||
/**
|
||||
* 压推理:`off` 不许想、`low` 少想;缺省由着模型。
|
||||
* 只对画像里声明了 `reasoningControl` 的厂家生效 —— 没姿势的厂家如实忽略。
|
||||
* 规划型调用(loop 单拍)该关:隐藏思考按秒计,而它的判断本就交给了判定表与工具。
|
||||
*/
|
||||
readonly reasoning?: 'off' | 'low';
|
||||
/** 出参 schema(JSON Schema);给定即要求结构化出参并校验 */
|
||||
readonly outputSchema?: Record<string, unknown>;
|
||||
/** 录制/回放的定位:任务 × 步序号(§7.3) */
|
||||
|
||||
@@ -33,6 +33,17 @@ export function createProviderInvoker(): ProviderInvoker {
|
||||
.filter((line): line is string => line !== undefined && line !== '')
|
||||
.join('\n\n');
|
||||
|
||||
/*
|
||||
* providerOptions 是**合并体**:JSON 模式与压推理都往请求体里加参,得进同一个对象 ——
|
||||
* 两处各自 spread 会互相覆盖(踩不得)。
|
||||
*/
|
||||
// AI SDK 的 providerOptions 值型是 JSONObject —— 从 generateText 的形参上取,不额外引包
|
||||
type ProviderBody = NonNullable<Parameters<typeof generateText>[0]['providerOptions']>[string];
|
||||
const bodyExtra: ProviderBody = {};
|
||||
if (useJsonMode) bodyExtra['response_format'] = { type: 'json_object' };
|
||||
const knob = call.reasoning === undefined ? undefined : profile.reasoningControl?.[call.reasoning];
|
||||
if (knob !== undefined) Object.assign(bodyExtra, knob as ProviderBody);
|
||||
|
||||
const started = Date.now();
|
||||
const result = await generateText({
|
||||
model: provider(binding.model),
|
||||
@@ -40,7 +51,7 @@ export function createProviderInvoker(): ProviderInvoker {
|
||||
prompt: call.prompt,
|
||||
...(call.temperature === undefined ? {} : { temperature: call.temperature }),
|
||||
...(call.maxOutputTokens === undefined ? {} : { maxOutputTokens: call.maxOutputTokens }),
|
||||
...(useJsonMode ? { providerOptions: { [binding.provider]: { response_format: { type: 'json_object' } } } } : {}),
|
||||
...(Object.keys(bodyExtra).length === 0 ? {} : { providerOptions: { [binding.provider]: bodyExtra } }),
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -84,15 +95,51 @@ export function parseJsonLoose(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
const start = candidate.search(/[[{]/);
|
||||
const end = Math.max(candidate.lastIndexOf('}'), candidate.lastIndexOf(']'));
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
return JSON.parse(candidate.slice(start, end + 1));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return firstBalanced(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文本里取**第一个配平的** JSON 对象 / 数组。
|
||||
*
|
||||
* 从前这里取的是「第一个 `{` 到**最后一个** `}`」。模型一次吐两个对象时
|
||||
* (它把这一拍和下一拍一起写了出来,中间空一行),那一刀切出来的是
|
||||
* `{…}\n\n{…}` —— 非法 JSON,整段作废,于是基座判「模型未给出合规的行动」,
|
||||
* 发一张兜底卡把人叫来。实测栽过:模型给的 9 颗料判据完全正确、
|
||||
* 分类器都要调了,只因为它多写了一个对象,整拍白跑、人被无谓地打扰了一次。
|
||||
*
|
||||
* 现在按括号配平扫第一个完整对象,后面多出来的当噪声丢掉 ——
|
||||
* 多写的那一份本就是下一拍的事,这一拍不该替它执行。
|
||||
* 扫的时候要认字符串与转义,否则值里带 `}` 的会把括号计数带偏。
|
||||
*/
|
||||
function firstBalanced(text: string): unknown {
|
||||
const start = text.search(/[[{]/);
|
||||
if (start < 0) return undefined;
|
||||
const open = text[start];
|
||||
const close = open === '{' ? '}' : ']';
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === '\\') escaped = true;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = true;
|
||||
else if (ch === open) depth += 1;
|
||||
else if (ch === close) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
try {
|
||||
return JSON.parse(text.slice(start, i + 1));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ export type ProviderProfile = {
|
||||
readonly structuredOutput: 'json-object' | 'prompt-only';
|
||||
/** 推理模型:回包里带 reasoning,答案在正文,推理另置(不进出参、只作留痕) */
|
||||
readonly emitsReasoning: boolean;
|
||||
/**
|
||||
* 「压推理」的厂商姿势 —— 调用方说 `reasoning: 'off' | 'low'`,画像译成请求体参数。
|
||||
*
|
||||
* 为什么值得一个画像字段:混合推理模型的隐藏思考按 token 计费、按解码计秒 ——
|
||||
* 实测 A1 一拍可见出参 1538 字、推理正文 9808 字,48.6 秒里八成五在解码没人读的思考;
|
||||
* 探针实测 DeepSeek `thinking:{type:'disabled'}` 后同一问题 89→9 出参 token、时延减半。
|
||||
* 没有姿势的厂家(undefined)只能由着它想 —— 调用方的意愿如实忽略,不假装生效。
|
||||
*/
|
||||
readonly reasoningControl?: Readonly<Record<'off' | 'low', Record<string, unknown>>>;
|
||||
readonly label: string;
|
||||
};
|
||||
|
||||
@@ -43,6 +52,11 @@ export const PROVIDER_PROFILES: Readonly<Record<LlmProvider, ProviderProfile>> =
|
||||
defaultBaseUrl: 'https://api.deepseek.com',
|
||||
structuredOutput: 'json-object',
|
||||
emitsReasoning: true,
|
||||
// 探针核过(2026-07-30):disabled 归零推理;effort 只认 low/medium/high/xhigh/max
|
||||
reasoningControl: {
|
||||
off: { thinking: { type: 'disabled' } },
|
||||
low: { reasoning_effort: 'low' },
|
||||
},
|
||||
label: 'DeepSeek',
|
||||
},
|
||||
anthropic: {
|
||||
|
||||
Binary file not shown.
@@ -24,7 +24,18 @@ export function createLlmService(deps: LlmServiceDeps): LlmService {
|
||||
},
|
||||
|
||||
async upsert(input: UpsertLlmInstance): Promise<LlmInstanceView> {
|
||||
const existing = input.id === undefined ? undefined : await deps.instances.findById(input.id);
|
||||
/*
|
||||
* 找「原来那条」要**按 id 也按名字**。
|
||||
*
|
||||
* 落库是 `onConflictDoUpdate(target: name)` —— 名字才是那张表的身份。
|
||||
* 从前只按 id 查:播种走的是无 id 的按名 upsert,于是 `existing` 恒为 undefined、
|
||||
* `apiKeyCipher` 解析成 undefined、落库写成 null ——
|
||||
* **一次不带密钥的播种就把治理台里存好的密钥擦掉了**,而界面上只会显示「未绑定」。
|
||||
* 「只写不读:apiKey 留空即保持原密文」(ADR-009 #4)这句话,得在两条路上都成立。
|
||||
*/
|
||||
const existing =
|
||||
(input.id === undefined ? undefined : await deps.instances.findById(input.id)) ??
|
||||
(await deps.instances.findByName(input.name));
|
||||
// 只写不读:apiKey 留空即保持原密文(ADR-009 #4)
|
||||
const apiKeyCipher =
|
||||
input.apiKey !== undefined && input.apiKey !== ''
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
`TurnOutcome` `DecisionResult` `DecisionTrace` `Gap` `ItemState` `CaseState`
|
||||
- 错误:`CmmnParseError` `IllegalTransitionError` `ForbiddenTerminalError`
|
||||
`AutomaticOnWorldTouchingError`
|
||||
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway` `AgentPort` `ToolGateway`
|
||||
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway`(含 `writable` 授权面)`AgentPort` `ToolGateway`
|
||||
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext`
|
||||
- 用例工厂:`createTurnRunner(deps): LoopEngineService`
|
||||
- 纯函数:`parseCmmn` `parseXml` `publishEnabled` `navigate` `evaluateCriterion`
|
||||
`transitionItem` `occur` `reenter` `autoCompleteReady` `canTransitionItem`
|
||||
`transitionItem` `occur` `achieveMilestones` `reenter` `autoCompleteReady` `canTransitionItem`
|
||||
`mayCompleteCase` `mayTerminateCase` `evaluateTable` `evaluationOrder` `deduceGaps`
|
||||
- adapter:`createLoopStateStore`;Drizzle 表:`loopStateTable`
|
||||
|
||||
@@ -36,6 +36,64 @@
|
||||
- **结论不可静默覆写**:`isBindingConclusion` 标出约束性输入
|
||||
- **三型行动**:每步行动属于且仅属于「调工具 / 调人 / 立监听」之一;
|
||||
调人 = 立监听听人;AI 对世界只有触达(动作,落在放行)与等待(监听)两种姿势
|
||||
- **一拍一串行动**(2026-07-30,`AgentTurn.actions`,封顶 `TURN_ACTION_CAP` = 6):
|
||||
拍是按模型调用计费的 —— 一拍只许一个行动时,每个 write / finish / start 都单烧一遍
|
||||
完整上下文,实测 A1 六步的活恰好 21 拍(其中 19 拍是该约束下的最短路径)。
|
||||
收成一串后同一单 8 拍收口、token 减半。执行纪律引擎强制:按序执行,谁被拒停在谁那儿
|
||||
(**前缀已生效不回滚**,理由喂回下一拍);`tool` 之后截断(出参没看见,后面的行动是
|
||||
闭着眼计划的);挂起型(ask/human/listen/submit)落地即收拍;超封顶丢弃并留痕。
|
||||
- **写台账草稿是过程写的唯一形态**(2026-07-30 新增 `write` 行动,铁律②)。
|
||||
从前这条路**根本不存在**:`TaskDataGateway.draftWrite` 有端口无调用方,
|
||||
Agent 干出来的结论一处也放不下 —— 后果不是少个功能,而是必交付物恒零行、
|
||||
验收判据求值取不到值、出口载荷推不出字段:跑得再热闹,放行那刻什么也交不出。
|
||||
- 一拍可写**多行**:一份 BOM 一次写完,不一行一拍各烧一次模型;
|
||||
- 地址与列名的把关在装配层(它认识活动清单)—— 未声明的地址与列一律拒写,
|
||||
越域写与越权调工具同罪;拒了抛错,由回合收成「行动被拒」喂回下一拍,引擎不崩;
|
||||
- **写的授权面是台账声明,不是计划项**(2026-07-30):`itemId` 只是记账,
|
||||
挂得上就挂(项活性化),挂不上(已完成 / 人工激活未 start / 阶段 / 不存在)照写不拦,
|
||||
留痕注明「未挂到计划项」。从前绑死在项生命周期上,死锁过一单:S5 收完才发现打标
|
||||
没写全,所有能挂的项都 Completed —— 每拍补写都被拒,连拒五拍熔断,而数据本身合规;
|
||||
- 写完把该项收了(未 Active 的就地过一下 Active)。**这不是绕开「Enabled ≠ Active」**:
|
||||
那条纪律管的是触达世界的项,而草稿写触达不到任何东西 —— 为它单烧一次模型去按那一下是纯仪式。
|
||||
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
|
||||
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
|
||||
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
|
||||
又常驻「当下可为」与「目标缺口」,让 Agent 每拍都看见自己按不动的东西。
|
||||
现在引擎在发布前自己收(达成留痕);**无前提的里程碑不自达成** —— 那是建模漏了前提。
|
||||
- **事件监听器不是交付物,是等待岗**(2026-07-30):不进 `gaps`,也不算 autoComplete 的必做项
|
||||
(从前只排除 `timerEventListener`,一个 `userEventListener` 就让案子永远判不齐)。
|
||||
- **一拍 = 一个行动 + 一句交代**(2026-07-30 `AgentTurn`)。Agent 从前**不会说话** ——
|
||||
会写台账、调工具、调人、立监听,但没有一型行动是「对人说一句」。
|
||||
实测:A1 跑十几拍、写七行台账、调两次工具,负责人的对话区从头到尾空白,
|
||||
他只看得见「多了一个任务」。「人只在责任点与放行点出现」不等于「过程中人什么都看不见」。
|
||||
- 交代落 `narration`(它进对话区),与 `thought`(给自己留的痕)分开;
|
||||
- **不是一型行动,是每一拍的附带**:先做成 `kind:'say'` 试过 —— 它占一整拍又不推进局面,
|
||||
预算下的 Agent 理所当然跳过它,对话区照旧空白。让说话与干活抢拍,
|
||||
等于把「必须说」设计成了「有空才说」;
|
||||
- **先交代再动手**:人看到的是「我要去查这三个 PN 的历史价」,紧接着才是结果;
|
||||
- `say.view` 可顺手换局面模版 —— 局面变了,人既要听到为什么,也要看到新画面。
|
||||
- **卡死了交回给人**(2026-07-30 `stalledTurns` + `STALL_LIMIT=5`)。预算会烧完,
|
||||
但那是二三十万 token 之后的事;这期间界面上只是「进行中」一动不动 —— 那不是在办,是卡死了。
|
||||
连续 5 拍行动被拒就不再问模型(再问也是同一个答案),发执行卡把**拒绝理由原话**摆给人:
|
||||
是它跑偏、是行动型少了一种、还是案例模型建得不对 —— 三种处置完全不同,人得看得见证据。
|
||||
- **补全卡是对话卡,不是执行工单**(`ask` 行动 / `CardGateway.ask`)。
|
||||
「按缺口清单追问补全」要的是:缺口清单摆在对话区、带一张表单,人就地填齐。
|
||||
从前 Agent **发不出对话卡**(只能选执行 / 验收两种工单),SOP 上画着补全卡的那一步
|
||||
只能拿执行工单顶 —— 形似而神非:派一张工单去问几个字段,收卡的人得进待处理、
|
||||
办完再回来,重得不成比例。一个格子都没有即拒发(空表单等于没问)。
|
||||
- **决策卡也是对话卡**(2026-07-31 `decide` 行动 / `CardGateway.decide`)。
|
||||
与补全卡的分工:补全卡问**事实**(缺哪几个字段,你填);决策卡问**处置**(往哪走,你选)。
|
||||
从前决策卡**只有 UI 没有 Agent 侧** —— 卡面在对话区画得出来,行动型里却发不出它,
|
||||
于是所有岔路口都被写成补全卡:人拿到一张要自己敲字的表单,而不是两个可点的选项。
|
||||
少于两个选项即拒发(一个选项的「选择题」是通知,不是决策)。
|
||||
- **视图推送回报推没推上**(`ViewGateway.push` → `{pushed, templates}`)。
|
||||
从前引擎每拍推一个**硬编码**的 `case-overview`,而活动声明的模版未必叫这个名字
|
||||
(A1 的四张一个都不叫)—— 模版查不到就静默 return,**每一帧都被悄悄丢掉**,
|
||||
工作区从头到尾空白,日志里连一个字都没有。静默失败让「没画面」看起来像「没产出」。
|
||||
现在:要的那张没有就退到活动声明的第一张,换不上则如实记痕。
|
||||
- **人答了什么要看得见**:`MemoryRecorder.window` 带回 `detail`,回传值(`detail.values`)
|
||||
随轨迹进叙事面。不给的话「执行工单回传」这一行读起来像事情办完了,而人填了哪几个值
|
||||
Agent 一个字看不见 —— 既写不进台账,也只能再问一遍。
|
||||
- **终局唯放行与 Harness**:引擎不把案计划置 Completed —— 那是 release 的事;
|
||||
`mayCompleteCase` 只对 release 为真,`mayTerminateCase` 只对 harness / owner / governance 为真
|
||||
- **提交放行 = 发验收卡**(2026-07-28 三修①):submit / 判齐(autoCompleteReady)
|
||||
|
||||
@@ -180,19 +180,38 @@ describe('publishEnabled —— 引擎推进到 Enabled 为止', () => {
|
||||
describe('navigate —— 导航即交集,断点即人环', () => {
|
||||
it('有交集即择优推进', () => {
|
||||
const nav = navigate(
|
||||
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], judgements: [], gaps: ['缺报价'], activeStages: [] },
|
||||
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], inProgress: [], judgements: [], gaps: ['缺报价'], activeStages: [] },
|
||||
new Set(['a']),
|
||||
);
|
||||
expect(nav.kind).toBe('proceed');
|
||||
});
|
||||
|
||||
it('交集为空即断点 —— 调人或立监听的判定时机', () => {
|
||||
const nav = navigate({ entries: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
|
||||
it('交集为空**且手上没开着的活**才是断点 —— 调人或立监听的判定时机', () => {
|
||||
const nav = navigate({ entries: [], inProgress: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
|
||||
expect(nav).toEqual({ kind: 'breakpoint', gaps: ['缺客户信用'] });
|
||||
});
|
||||
|
||||
/*
|
||||
* 断点的定义是「无路可走」。手上还有开着的活就不算 —— 从前只看 Enabled 集,
|
||||
* 局面推进开以后手上全是 Active,每拍都判成断点,而断点的处置是「调人或立监听」:
|
||||
* 环于是把没干完的活丢下,去等一个不会来的事件。
|
||||
*/
|
||||
it('Enabled 为空但有进行中的项 → 接着干,不是断点', () => {
|
||||
const nav = navigate(
|
||||
{
|
||||
entries: [],
|
||||
inProgress: [{ id: 'w', name: '正在查历史价', type: 'humanTask', requiresManualStart: false, discretionary: false }],
|
||||
judgements: [],
|
||||
gaps: ['缺分类表'],
|
||||
activeStages: [],
|
||||
},
|
||||
new Set(['nothing']),
|
||||
);
|
||||
expect(nav).toEqual({ kind: 'proceed', candidates: [{ id: 'w', name: '正在查历史价', type: 'humanTask', requiresManualStart: false, discretionary: false }] });
|
||||
});
|
||||
|
||||
it('无缺口即收敛', () => {
|
||||
expect(navigate({ entries: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
|
||||
expect(navigate({ entries: [], inProgress: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 收步判据要连着**此刻的读数**一起摆给模型 —— 「差在哪」不能靠猜。
|
||||
*
|
||||
* 这条是拿一次熔断换来的:A1 的 S2 收步判据是
|
||||
* `missing_key_total + missing_minor_total = 0`,而草稿里 missing_key_total 是 1。
|
||||
* 模型看不见这个数,以为缺口已经补齐了,就把同一批行再写一遍「确认一下」——
|
||||
* 撞上空转检查被拒;拒了它还是不知道差在哪,再写一遍……连拒五拍熔断。
|
||||
* 把读数摆出来,这一步就从死胡同变成一句话能办的事。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readingsOf } from '../internal/domain/lifecycle.ts';
|
||||
|
||||
describe('收步判据的读数', () => {
|
||||
it('把判据读的那几个值摆出来', () => {
|
||||
const out = readingsOf('missing_key_total + missing_minor_total = 0', {
|
||||
missing_key_total: 1,
|
||||
missing_minor_total: 0,
|
||||
});
|
||||
expect(out).toContain('missing_key_total=1');
|
||||
expect(out).toContain('missing_minor_total=0');
|
||||
});
|
||||
|
||||
it('只摆判据读的,不摆整个任务视图', () => {
|
||||
const out = readingsOf('line_total > 0', { line_total: 3, subject: '询价', pn: 'LM358DR' });
|
||||
expect(out).toContain('line_total=3');
|
||||
expect(out).not.toContain('subject');
|
||||
expect(out).not.toContain('pn');
|
||||
});
|
||||
|
||||
it('没写过的名字如实说没写过 —— 它正是这一步推不动的原因', () => {
|
||||
expect(readingsOf('benchmark_total > 0', {})).toContain('benchmark_total=(没写过)');
|
||||
});
|
||||
|
||||
it('保留字不当台账名去查', () => {
|
||||
const out = readingsOf('need_sourcing = true and line_total > 0', { need_sourcing: false, line_total: 2 });
|
||||
expect(out).toContain('need_sourcing=false');
|
||||
expect(out).not.toContain('true=');
|
||||
expect(out).not.toContain('and=');
|
||||
});
|
||||
|
||||
it('字符串字面量里的词不当台账名', () => {
|
||||
expect(readingsOf('disposition = "照原价报"', { disposition: '通过' })).toBe('(现在:disposition=通过)');
|
||||
});
|
||||
|
||||
it('没有名字可摆时给空串 —— 不往提示词里塞空括号', () => {
|
||||
expect(readingsOf('1 = 1', {})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 判据名归一要削掉**引擎自己贴的等级标记**。
|
||||
*
|
||||
* 提交被拒时门印的是「…没达标 —— 某判据(L3)」,紧接着让模型「照判据名原样写」;
|
||||
* 模型照做,把 `(L3)` 一起抄进 criterion —— 裁定的键与清单里的判据名对不上,
|
||||
* 裁了等于没裁,再提交再被拒,连拒五拍熔断(实测 A2 死于此)。
|
||||
*/
|
||||
import { criterionKey } from '../internal/domain/lifecycle.ts';
|
||||
|
||||
describe('判据名归一', () => {
|
||||
it('模型抄进等级标记也要认得出是同一条', () => {
|
||||
expect(criterionKey('每项数据来源可溯(以原厂官网规格书为准)(L3)')).toBe(
|
||||
criterionKey('每项数据来源可溯(以原厂官网规格书为准)'),
|
||||
);
|
||||
});
|
||||
|
||||
it('五个等级都削', () => {
|
||||
for (const level of ['L1', 'L2', 'L3', 'L4', 'L5']) {
|
||||
expect(criterionKey(`某判据(${level})`)).toBe(criterionKey('某判据'));
|
||||
}
|
||||
});
|
||||
|
||||
it('全角括号的等级标记也削 —— 模型爱把半角敲成全角', () => {
|
||||
expect(criterionKey('某判据(L3)')).toBe(criterionKey('某判据'));
|
||||
});
|
||||
|
||||
it('只削末尾那一个,不碰判据名里本来就有的括号', () => {
|
||||
expect(criterionKey('吃不准降 B(不许臆断 A)')).toBe('吃不准降b(不许臆断a)');
|
||||
});
|
||||
|
||||
it('照旧折全角、去空白 —— 老毛病不许回来', () => {
|
||||
expect(criterionKey('吃不准降B,不许臆断A')).toBe(criterionKey('吃不准降 B,不许臆断 A'));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 同一句交代不进两遍对话区。
|
||||
*
|
||||
* 实测试跑 2e091d25:「有两颗料客户没写清楚……你看是我起草一封信去问,还是你直接找对方?」
|
||||
* 一字不差连着吐了三拍(先 start 那一项、又 start 了一次、才发出决策卡)——
|
||||
* 人在对话区看到同一个问题被问三遍,像卡带。
|
||||
*
|
||||
* 判定放在这儿单测:去空白后逐字比、只比紧邻的上一句。
|
||||
* 近义句不拦(那是两次不同的交代),隔了几拍说回同一句也不拦(局面绕回来了)。
|
||||
*/
|
||||
function isRepeatSay(say: string, trail: ReadonlyArray<{ kind: string; title: string }>): boolean {
|
||||
const now = say.trim().replace(/\s+/g, '');
|
||||
const last = [...trail].reverse().find((t) => t.kind === 'narration');
|
||||
return last !== undefined && last.title.trim().replace(/\s+/g, '') === now;
|
||||
}
|
||||
|
||||
describe('对话区不结巴', () => {
|
||||
const n = (title: string) => ({ kind: 'narration', title });
|
||||
|
||||
it('跟上一句一字不差 —— 压下', () => {
|
||||
expect(isRepeatSay('缺两颗料的信息,要我起草封信吗?', [n('缺两颗料的信息,要我起草封信吗?')])).toBe(true);
|
||||
});
|
||||
|
||||
it('只差空白也算同一句 —— 模型抄自己时空格常飘', () => {
|
||||
expect(isRepeatSay('缺 两颗料 的信息', [n('缺两颗料的信息')])).toBe(true);
|
||||
});
|
||||
|
||||
it('中间隔着别的痕,比的仍是**上一句叙事**', () => {
|
||||
const trail = [n('缺两颗料的信息'), { kind: 'action', title: '写草稿:x 1 行' }, { kind: 'engine-transition', title: 'manualStart:S2' }];
|
||||
expect(isRepeatSay('缺两颗料的信息', trail)).toBe(true);
|
||||
});
|
||||
|
||||
it('近义但不同的句子 —— 放行,那是两次不同的交代', () => {
|
||||
expect(isRepeatSay('历史价都查到了,接下来打标。', [n('三颗料的历史价都查到了,接下来打标。')])).toBe(false);
|
||||
});
|
||||
|
||||
it('隔了一句别的又说回同一句 —— 放行,局面可能真绕回来了', () => {
|
||||
expect(isRepeatSay('缺两颗料的信息', [n('缺两颗料的信息'), n('客户回话了')])).toBe(false);
|
||||
});
|
||||
|
||||
it('一条叙事都还没有 —— 放行', () => {
|
||||
expect(isRepeatSay('第一句话', [{ kind: 'action', title: '写草稿' }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
|
||||
*
|
||||
* 行数类判据拒回时只给两个数,模型看得见「10 ≠ 9」,却不知道多的是哪一行 ——
|
||||
* 它只能猜:改数、重写、再提,三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
|
||||
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
|
||||
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
|
||||
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
|
||||
* 三种一摆主键就都是一句话能办的事。
|
||||
*/
|
||||
describe('行数判据拒回:连主键名单一起摆', () => {
|
||||
it('谓词读了 _rows,就把那张表本单写下的行摆出来', () => {
|
||||
const out = readingsOf('quote_intake_record_rows = line_total', {
|
||||
quote_intake_record_rows: 10,
|
||||
line_total: 9,
|
||||
quote_intake_record_keys: 'LM358DR、TL072CDR、NE555P',
|
||||
});
|
||||
expect(out).toContain('quote_intake_record_rows=10');
|
||||
expect(out).toContain('line_total=9');
|
||||
expect(out).toContain('是这几行:LM358DR、TL072CDR、NE555P');
|
||||
});
|
||||
|
||||
it('两张表都读了 → 两份名单都摆,差集自己对得出来', () => {
|
||||
const out = readingsOf('quote_intake_type_table_rows = quote_intake_record_rows', {
|
||||
quote_intake_type_table_rows: 2,
|
||||
quote_intake_record_rows: 3,
|
||||
quote_intake_type_table_keys: 'A、B',
|
||||
quote_intake_record_keys: 'A、B、C',
|
||||
});
|
||||
expect(out).toContain('是这几行:A、B;');
|
||||
expect(out).toContain('是这几行:A、B、C');
|
||||
});
|
||||
|
||||
it('那张表一行没写就没有名单 —— 不摆一句空的「是这几行:」', () => {
|
||||
const out = readingsOf('quote_intake_alt_request_rows = type_c_total', {
|
||||
quote_intake_alt_request_rows: 0,
|
||||
type_c_total: 0,
|
||||
});
|
||||
expect(out).toBe('(现在:quote_intake_alt_request_rows=0、type_c_total=0)');
|
||||
});
|
||||
|
||||
it('不读 _rows 的判据照旧只摆读数 —— 名单不是每条判据都要的', () => {
|
||||
expect(readingsOf('missing_key_total = 0', { missing_key_total: 1, quote_intake_record_keys: 'X' })).toBe(
|
||||
'(现在:missing_key_total=1)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 阶段进度 —— 进度条的刻度。判据是「这一阶段的计划项**全收完**才算过」。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CasePhase } from '@contracts';
|
||||
import { CLOSING_PHASE, phaseProgress } from '../internal/domain/phase.ts';
|
||||
import type { ItemRuntime, ItemState } from '../internal/domain/lifecycle.ts';
|
||||
|
||||
/** A1 的三段:定位客户 / 归一受理 / 判定去向;放行那一格由进度条自己加 */
|
||||
const PHASES: CasePhase[] = [
|
||||
{ name: '定位客户', items: ['S1_customer'] },
|
||||
{ name: '归一受理', items: ['S2_normalize'] },
|
||||
{ name: '判定去向', items: ['S4_history', 'S5_classify', 'S6_route'] },
|
||||
];
|
||||
|
||||
const items = (states: Record<string, ItemState>): ItemRuntime[] =>
|
||||
Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
|
||||
|
||||
const ALL = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify', 'S6_route', 'T_release'];
|
||||
const at = (done: readonly string[]): ItemRuntime[] =>
|
||||
items(Object.fromEntries(ALL.map((id) => [id, done.includes(id) ? 'Completed' : 'Available'])));
|
||||
|
||||
describe('阶段进度 —— 三段激活态 + 一格收口 = x/4', () => {
|
||||
it('一格都没收:停在第一阶段', () => {
|
||||
expect(phaseProgress(PHASES, at([]), false)).toEqual({ index: 1, total: 4, name: '定位客户' });
|
||||
});
|
||||
|
||||
it('逐段推进:收完一段才进下一段', () => {
|
||||
expect(phaseProgress(PHASES, at(['S1_customer']), false)).toMatchObject({ index: 2, name: '归一受理' });
|
||||
expect(phaseProgress(PHASES, at(['S1_customer', 'S2_normalize']), false)).toMatchObject({
|
||||
index: 3,
|
||||
name: '判定去向',
|
||||
});
|
||||
});
|
||||
|
||||
it('一段里的项没收齐就不算过 —— 三项收两项仍停在这一段', () => {
|
||||
const done = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify'];
|
||||
expect(phaseProgress(PHASES, at(done), false)).toMatchObject({ index: 3, name: '判定去向' });
|
||||
});
|
||||
|
||||
it('三段都过、还没收口:进最后一格(等放行),但不是终态', () => {
|
||||
const done = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify', 'S6_route'];
|
||||
expect(phaseProgress(PHASES, at(done), false)).toEqual({ index: 4, total: 4, name: CLOSING_PHASE });
|
||||
});
|
||||
|
||||
it('终态直接给最后一格 —— 不去数计划项', () => {
|
||||
// 放行完成那一瞬 T_release 还没收:去数项的话会显示「还差一格」,而单已经收口了
|
||||
expect(phaseProgress(PHASES, at([]), true)).toEqual({ index: 4, total: 4, name: CLOSING_PHASE });
|
||||
});
|
||||
|
||||
it('被跳过的裁量项(Terminated)算过 —— 它不会再收,等它等到天亮', () => {
|
||||
const mixed = items({
|
||||
S1_customer: 'Completed',
|
||||
S2_normalize: 'Completed',
|
||||
S4_history: 'Completed',
|
||||
S5_classify: 'Terminated',
|
||||
S6_route: 'Completed',
|
||||
T_release: 'Available',
|
||||
});
|
||||
expect(phaseProgress(PHASES, mixed, false)).toMatchObject({ index: 4, name: CLOSING_PHASE });
|
||||
});
|
||||
|
||||
it('没声明阶段就不给刻度 —— 界面照此不画进度条,不编一个出来', () => {
|
||||
expect(phaseProgress(undefined, at([]), false)).toBeUndefined();
|
||||
expect(phaseProgress([], at([]), false)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ import type {
|
||||
TerminalAuthority,
|
||||
} from './internal/domain/lifecycle.ts';
|
||||
import type { DecisionResult, DecisionTrace, Gap } from './internal/domain/dmn.ts';
|
||||
import type { PhaseProgress } from './internal/domain/phase.ts';
|
||||
|
||||
export type {
|
||||
CaseModel,
|
||||
@@ -31,6 +32,7 @@ export type {
|
||||
ItemState,
|
||||
LoopModel,
|
||||
Navigation,
|
||||
PhaseProgress,
|
||||
PlanItem,
|
||||
PlanItemType,
|
||||
TerminalAuthority,
|
||||
@@ -57,8 +59,154 @@ export class AutomaticOnWorldTouchingError extends Error {
|
||||
export type AgentAction =
|
||||
/** 调工具:系统调用与 LLM 工具 */
|
||||
| { readonly kind: 'tool'; readonly itemId: string; readonly tool: string; readonly input: Record<string, unknown> }
|
||||
/**
|
||||
* 写台账草稿 —— **活动过程写的唯一形态**(铁律②:登记那一刻世界上什么都没变)。
|
||||
*
|
||||
* 一拍可写多行:逐条把一份 BOM 解析成几十行,是**一件事**,不该拆成几十拍
|
||||
* 各烧一次模型。地址必须在活动清单声明过的台账对象之内(越域写与越权调工具同罪)。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'write';
|
||||
readonly itemId: string;
|
||||
/**
|
||||
* 台账地址「域.对象」—— **一个字符串,不是两个字段**。
|
||||
*
|
||||
* 系统上下都用这一种写法(可写台账字典、`requiredDeliverables[].ledger` 都是它),
|
||||
* 拆成 domain + object 只会让模型把整个地址塞进其中一格 —— 实测第一拍就这么错。
|
||||
*/
|
||||
readonly address: string;
|
||||
readonly rows: ReadonlyArray<{
|
||||
readonly key: Record<string, string | number>;
|
||||
readonly patch: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
/**
|
||||
* **撤掉自己写错的那一行** —— 写错了得有退路(2026-08-01 加)。
|
||||
*
|
||||
* 只作用在**本单草稿层**:正式版一个字都动不了,也不抛事件 ——
|
||||
* 撤的是「我这一单还没过门的那一笔」,放行门的位置没变。
|
||||
*
|
||||
* 起因是实测的一次死胡同:客户明说本轮跳过的那颗料被写进了有效清单,
|
||||
* 条目数报 9(对)而表里躺着 10,判据 `record_rows = line_total` 正确地拦住 ——
|
||||
* 而模型**改不动**:它唯一能改的是那个数,改了 `skipped_total` 又不对。
|
||||
* 13 次拒回、23 拍、234k token 才熔断。**门拦住一件办错的事却不给改正的手段,
|
||||
* 那扇门就只是个死胡同。**
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'drop';
|
||||
readonly itemId: string;
|
||||
/** 台账地址「域.对象」—— 与 write 同一种写法 */
|
||||
readonly address: string;
|
||||
/** 要撤的那几行的主键 —— 认不出的行如实回「草稿层里没有这一行」 */
|
||||
readonly keys: ReadonlyArray<Record<string, string | number>>;
|
||||
readonly reason: string;
|
||||
}
|
||||
/** 调人:四卡之一 —— 调人 = 立监听听人 */
|
||||
| { readonly kind: 'human'; readonly itemId: string; readonly card: Record<string, unknown>; readonly workOrder: 'execution' | 'acceptance' }
|
||||
/**
|
||||
* **发补全卡** —— 对话卡族之一,与执行工单是两种东西。
|
||||
*
|
||||
* 「按缺口清单追问补全」(A1-S2)要的就是它:缺口清单摆在对话区、带一张表单,
|
||||
* 人就地填齐。而执行工单是「派一件活给你办」—— 派一张工单去问几个字段,
|
||||
* 收卡的人得进待处理、办完再回来,重得不成比例。
|
||||
*
|
||||
* 从前 Agent **发不出对话卡**:行动型里只有 `human` 且只能选执行 / 验收两种工单,
|
||||
* 于是 SOP 上画着「补全卡」的那一步只能拿执行工单顶 —— 形似而神非。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'ask';
|
||||
readonly itemId: string;
|
||||
/** 问什么 —— 一句话说清缺口 */
|
||||
readonly ask: string;
|
||||
/**
|
||||
* 要对方填的格子。`hint` 是格子里的灰字提示;
|
||||
* **`value` 是预填值** —— AI 从来件里读得出的先替人填上(公司名、联系人姓名),
|
||||
* 人只需核对一眼、补上填不出的那个,而不是对着一排空格从头敲。
|
||||
* 读不出来的就别给:填错了人还得先擦掉再写,比空着更费事。
|
||||
*/
|
||||
readonly fields: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly hint?: string;
|
||||
readonly value?: string;
|
||||
}>;
|
||||
/** 依据快照:哪几条缺哪几个字段 —— 调用必附上下文,裸调用拒发 */
|
||||
readonly basis?: ReadonlyArray<{ readonly k: string; readonly v: string }>;
|
||||
}
|
||||
/**
|
||||
* 发**数据录入卡** —— 人类工具的第五类:**人按下确认那一刻就是写入**。
|
||||
*
|
||||
* 与补全卡(`ask`)的分工:那张收的是「答案」,你拿到之后还要自己 `write` 一遍;
|
||||
* 这张收的是「这一行数据」—— 卡上写明落哪张表、哪个主键,人确认,基座当场落草稿层,
|
||||
* **你不经手**。建档这类事该用它:主数据建错一次,后面每一单都跟着错,
|
||||
* 那一笔该由人亲手落下,不该经你转述一遍。
|
||||
*
|
||||
* 你的活是**把能填的先填好**(`value`),填不出的留空 —— 界面会标「待填写」。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'entry';
|
||||
readonly itemId: string;
|
||||
readonly ask: string;
|
||||
/** 落哪张表:`域.对象` */
|
||||
readonly ledger: string;
|
||||
/** 主键值 —— 确认时按它定位那一行 */
|
||||
readonly key: Readonly<Record<string, string>>;
|
||||
readonly fields: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly value?: string;
|
||||
readonly required?: boolean;
|
||||
}>;
|
||||
/**
|
||||
* **同一次确认还要落的另几张表**(2026-08-02)。
|
||||
*
|
||||
* 建一位新客户落的是**两条主数据**:公司档 + 联系人档。只把公司那一半摆上卡、
|
||||
* 联系人由你自己写,等于让人确认了一半 —— 而联系人档的 `contact_code`
|
||||
* 恰恰是你编出来的那一个。分成两张卡也能办,但那是把一件事拆成两次打断。
|
||||
*/
|
||||
readonly also?: ReadonlyArray<{
|
||||
/** 这一段是什么 —— 卡上那行小标题(如「联系人档案」) */
|
||||
readonly title: string;
|
||||
readonly ledger: string;
|
||||
readonly key: Readonly<Record<string, string>>;
|
||||
readonly fields: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly value?: string;
|
||||
readonly required?: boolean;
|
||||
}>;
|
||||
}>;
|
||||
readonly basis?: ReadonlyArray<{ readonly k: string; readonly v: string }>;
|
||||
}
|
||||
/**
|
||||
* 发**决策卡** —— 对话卡族的第二种:把一个岔路口摆给人,人点一个选项。
|
||||
*
|
||||
* 与补全卡的分工:补全卡问的是**事实**(缺哪几个字段,你填);
|
||||
* 决策卡问的是**处置**(这一步该往哪走,你选)。A1 的
|
||||
* 「字段没齐 —— 要我系统外去问客户,还是替你草拟一封邮件」正是后者:
|
||||
* 两条路都合法,选哪条是业务判断,不该由模型替人决定。
|
||||
*
|
||||
* 从前决策卡**只有 UI 没有 Agent 侧**:卡面在对话区画得出来,而行动型里发不出它 ——
|
||||
* 于是所有岔路口都被写成补全卡,人拿到的是一张要自己敲字的表单,而不是两个可点的选项。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'decide';
|
||||
readonly itemId: string;
|
||||
/** 问什么 —— 一句话说清这是个什么岔路 */
|
||||
readonly ask: string;
|
||||
/** 选项:一条路一项。`consequence` 写清选了它会发生什么 */
|
||||
readonly options: ReadonlyArray<{
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly detail?: string;
|
||||
readonly consequence?: string;
|
||||
readonly recommended?: boolean;
|
||||
}>;
|
||||
/** 依据快照:凭什么问这一问 —— 调用必附上下文,裸调用拒发 */
|
||||
readonly basis?: ReadonlyArray<{ readonly k: string; readonly v: string }>;
|
||||
/** 允许人给出选项之外的处置(附理由) */
|
||||
readonly allowCustom?: boolean;
|
||||
}
|
||||
/** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */
|
||||
| { readonly kind: 'listen'; readonly itemId: string; readonly eventType: 'data' | 'time'; readonly keyFilter: Record<string, string>; readonly deadline: string }
|
||||
/** 启动一个 Enabled 项(manualStart,留痕) */
|
||||
@@ -69,8 +217,35 @@ export type AgentAction =
|
||||
| { readonly kind: 'disable'; readonly itemId: string; readonly reason: string }
|
||||
/** 评估语义性前提(评估留痕,可回放可归因) */
|
||||
| { readonly kind: 'judge'; readonly criterion: string; readonly satisfied: boolean; readonly rationale: string }
|
||||
/** 判定成果已齐,提交放行 */
|
||||
| { readonly kind: 'submit'; readonly rationale: string }
|
||||
/**
|
||||
* 这一项干完了 —— **完成由 Agent 自己宣告,不是某个动作的副作用**。
|
||||
*
|
||||
* 从前 `tool` / `write` 一做完就把计划项收了。可一个计划项常常要好几个动作才交得出成果
|
||||
* (先逐条写记录、再结算总数;先调工具取判据、再把判据写进台账)——
|
||||
* 一收就再也动不了它,于是那一项该产出的东西永远缺着,而环在原地打转。
|
||||
* 实测:20 拍烧掉 120k token,卡在「当下可为 ∩ 目标所需 = 空」。
|
||||
*
|
||||
* 宣告完成不等于成果被认可:终局仍唯放行,人验收才算数。
|
||||
*/
|
||||
| { readonly kind: 'finish'; readonly itemId: string; readonly rationale: string }
|
||||
/**
|
||||
* 判定成果已齐,提交放行。
|
||||
*
|
||||
* `fields` = 验收报告上**让人当场改**的那几个值(PRD:「AI 已填好、可编辑」)——
|
||||
* `ai` 是活动拟的值,`ledger` 是改完落回哪个台账地址(`域.对象`)。
|
||||
* 不给 `fields`,验收报告就只能看不能改;给了 `ai` 不给 `ledger`,人改了也只留在
|
||||
* 工单留痕里,落地用的还是活动的原值 —— 两样都得有,修订才落得下去。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'submit';
|
||||
readonly rationale: string;
|
||||
readonly fields?: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly ai?: string;
|
||||
readonly ledger?: string;
|
||||
}>;
|
||||
}
|
||||
/**
|
||||
* 本回合**已在工具循环里调过人**:卡已发、监听已立(工具总线执行的)。
|
||||
* 引擎只收尾把这一拍置 awaiting —— 不能再发一张,否则负责人工位上堆两张同样的卡。
|
||||
@@ -78,6 +253,44 @@ export type AgentAction =
|
||||
*/
|
||||
| { readonly kind: 'asked'; readonly cardId: string; readonly rationale: string };
|
||||
|
||||
/**
|
||||
* **对人说的一句话** —— 交代进展,并可顺手换一张局面画面。
|
||||
*
|
||||
* 它**不是一型行动,是每一拍的附带** —— 这一点是改过的:一开始把它做成独立行动,
|
||||
* 结果它占一整拍又不推进局面,预算下的 Agent 理所当然跳过它,对话区照旧空白。
|
||||
* 让说话与干活抢拍,是把「必须说」设计成了「有空才说」。
|
||||
*
|
||||
* 现在:一拍 = 一个行动 + 一句交代。说话不花额外的拍,也就没有不说的理由。
|
||||
* `view` 是可选的局面模版名 —— 局面变了,人既要听到一句为什么,也要看到新画面。
|
||||
*/
|
||||
export type Narration = {
|
||||
readonly text: string;
|
||||
/** 语气签:指导 / 判据 / 结论 —— 前端各有其样式 */
|
||||
readonly tag?: 'guidance' | 'criterion' | 'conclusion';
|
||||
readonly view?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 一拍的产出:**一串行动**(按序执行)+ 对人交代一句。
|
||||
*
|
||||
* 为什么是一串不是一件:一拍只许一个行动时,每个 write、每个 finish、每个 start
|
||||
* 都单烧一次完整模型调用(说明书 + 轨迹整段重读)。实测 A1 六步的活恰好花 21 拍 ——
|
||||
* 其中 19 拍是这个约束下的**最短路径**,浪费不在模型跑偏,在架构按拍计费。
|
||||
* 收成一串后,「写记录、写结算、finish、start 下一项」一口气给,一单 5-8 拍收得了口。
|
||||
*
|
||||
* 执行纪律(引擎强制,不靠模型自觉):
|
||||
* · 按序执行,谁被拒就停在谁那儿 —— 已执行的前缀落笔留痕,理由喂回下一拍;
|
||||
* · `tool` 之后截断 —— 出参没看见,后面的行动是闭着眼计划的;
|
||||
* · `ask` / `decide` / `human` / `listen` / `submit` 让本拍挂起 —— 只能是最后一个;
|
||||
* · 一串封顶 `TURN_ACTION_CAP` 件,超出的直接丢弃(留痕)。
|
||||
*/
|
||||
export const TURN_ACTION_CAP = 6;
|
||||
|
||||
export type AgentTurn = {
|
||||
readonly actions: readonly AgentAction[];
|
||||
readonly say?: Narration;
|
||||
};
|
||||
|
||||
export type TurnOutcome = {
|
||||
readonly runtime: CaseRuntime;
|
||||
readonly enabled: EnabledSet;
|
||||
@@ -94,6 +307,14 @@ export type LoopEngineService = {
|
||||
/** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */
|
||||
load(taskId: TaskId, model: LoopModel): Promise<CaseRuntime>;
|
||||
runtime(taskId: TaskId): Promise<CaseRuntime | undefined>;
|
||||
/**
|
||||
* 进度刻度:这一单在第几阶段(见 `internal/domain/phase.ts`)。
|
||||
*
|
||||
* 由 loop 出而不是让调用方自己算:阶段声明与计划项状态**同在一行运行时里**,
|
||||
* 调用方要自己算就得先把模型和运行时都捞出来 —— 那是把内部结构摊给外面。
|
||||
* 活动没声明阶段就回 `undefined`:界面照此不画进度条,不编一个出来。
|
||||
*/
|
||||
phaseOf(taskId: TaskId, terminal: boolean): Promise<PhaseProgress | undefined>;
|
||||
/** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */
|
||||
publish(taskId: TaskId): Promise<EnabledSet>;
|
||||
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
export type {
|
||||
AgentAction,
|
||||
AgentTurn,
|
||||
Narration,
|
||||
CaseModel,
|
||||
CaseRuntime,
|
||||
CaseState,
|
||||
@@ -26,6 +28,7 @@ export {
|
||||
CmmnParseError,
|
||||
ForbiddenTerminalError,
|
||||
IllegalTransitionError,
|
||||
TURN_ACTION_CAP,
|
||||
} from './contract.ts';
|
||||
|
||||
export type {
|
||||
@@ -47,9 +50,12 @@ export { createTurnRunner } from './internal/usecase/turn-runner.ts';
|
||||
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
|
||||
export {
|
||||
assertItemTransition,
|
||||
achieveMilestones,
|
||||
autoCompleteReady,
|
||||
canTransitionCase,
|
||||
canTransitionItem,
|
||||
criterionKey,
|
||||
readingsOf,
|
||||
CASE_STATES,
|
||||
evaluateCriterion,
|
||||
initialRuntime,
|
||||
|
||||
@@ -139,6 +139,17 @@ export type Redlight = { readonly criterion: string; readonly statement: string
|
||||
|
||||
export type EnabledSet = {
|
||||
readonly entries: readonly EnabledEntry[];
|
||||
/**
|
||||
* **进行中的项** —— 已开工、还没交出成果的那些。
|
||||
*
|
||||
* Enabled 集回答的是「什么可以**新**开始」;进行中的项按定义不在里面。
|
||||
* 可局面一旦推进开,手上的活全是 Active —— 于是 Agent 看到的「当下可为」是空的,
|
||||
* 而它正干着的两件事一个也看不见。实测的后果:它开始编计划项 id、反复被拓扑拒回来,
|
||||
* 15 拍烧掉 83k token,最后立个监听睡过去 —— 手上明明还有半件活没干完。
|
||||
*
|
||||
* 所以进行中的项必须一并发布:要么接着干(write / tool),要么 finish。
|
||||
*/
|
||||
readonly inProgress: readonly EnabledEntry[];
|
||||
/** 待裁量评估的语义性前提 */
|
||||
readonly judgements: readonly Redlight[];
|
||||
/** 目标缺口:未绿的判据 */
|
||||
@@ -196,6 +207,16 @@ export function publishEnabled(
|
||||
|
||||
return {
|
||||
entries,
|
||||
// 进行中 = Active 的项(阶段也算:它开着,Agent 才知道自己站在哪一段里)
|
||||
inProgress: model.items
|
||||
.filter((item) => byId.get(item.id)?.state === 'Active')
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
requiresManualStart: item.manualActivation,
|
||||
discretionary: item.discretionary,
|
||||
})),
|
||||
judgements,
|
||||
gaps: unmetCriteria,
|
||||
activeStages: runtime.items
|
||||
@@ -219,7 +240,16 @@ export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Na
|
||||
if (enabled.gaps.length === 0) return { kind: 'converged' };
|
||||
const intersection =
|
||||
required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id));
|
||||
if (intersection.length === 0) return { kind: 'breakpoint', gaps: enabled.gaps };
|
||||
/*
|
||||
* 断点的定义是「**无路可走**」—— 手上还有开着的活就不算断点。
|
||||
* 从前只看 Enabled 集:局面推进开以后手上全是 Active,每一拍都被判成断点,
|
||||
* 而断点的处置是「调人或立监听」—— 于是环把没干完的活丢下,去等一个不会来的事件。
|
||||
*/
|
||||
if (intersection.length === 0) {
|
||||
return enabled.inProgress.length > 0
|
||||
? { kind: 'proceed', candidates: enabled.inProgress }
|
||||
: { kind: 'breakpoint', gaps: enabled.gaps };
|
||||
}
|
||||
return { kind: 'proceed', candidates: intersection };
|
||||
}
|
||||
|
||||
@@ -229,7 +259,12 @@ export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Na
|
||||
export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean {
|
||||
if (!model.autoComplete) return false;
|
||||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||||
const required = model.items.filter((i) => !i.discretionary && i.type !== 'timerEventListener');
|
||||
/*
|
||||
* 事件监听器**不是交付物**,是等待岗:等来了就完成,等不来也不该拦着结案。
|
||||
* 从前只排除 timerEventListener —— 于是一个 `userEventListener`(如「客户改需求」)
|
||||
* 就让 autoComplete 永远为假:必做项全干完了,案子还是判不齐。
|
||||
*/
|
||||
const required = model.items.filter((i) => !i.discretionary && !i.type.endsWith('EventListener'));
|
||||
const allDone = required.every((i) => {
|
||||
const state = byId.get(i.id)?.state;
|
||||
return state === 'Completed' || state === 'Terminated' || state === 'Disabled';
|
||||
@@ -255,6 +290,43 @@ export function mayTerminateCase(by: TerminalAuthority): boolean {
|
||||
}
|
||||
|
||||
/** 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed */
|
||||
/**
|
||||
* 前提达成即达成的里程碑 —— **不需要谁去按**。
|
||||
*
|
||||
* CMMN 的里程碑语义就是「一个前提被满足」,它没有执行体、也没有人去启动它。
|
||||
* 从前引擎只在 `onEvent(ref = 里程碑 id)` 时才 occur,而系统里没有任何东西
|
||||
* 会发出以里程碑命名的事件 —— 结果每个里程碑都永远停在 Available:
|
||||
* 既堵住 autoComplete(它是必做项),又常驻在「当下可为」与「目标缺口」里,
|
||||
* 让 Agent 每拍都看到一堆自己按不动的东西。
|
||||
*
|
||||
* 返回达成了的那些 id(调用方据此留痕)与推进后的运行时。
|
||||
*/
|
||||
export function achieveMilestones(
|
||||
model: CaseModel,
|
||||
runtime: CaseRuntime,
|
||||
scope: Readonly<Record<string, unknown>>,
|
||||
changedRefs: ReadonlySet<string>,
|
||||
evaluate: ConditionEvaluator,
|
||||
): { runtime: CaseRuntime; achieved: string[] } {
|
||||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||||
let next = runtime;
|
||||
const achieved: string[] = [];
|
||||
|
||||
for (const item of model.items) {
|
||||
if (item.type !== 'milestone') continue;
|
||||
if (byId.get(item.id)?.state !== 'Available') continue;
|
||||
// 无前提的里程碑不自达成:那是建模漏了前提,不是「一开案就达成」
|
||||
if (item.entryCriteria.length === 0) continue;
|
||||
const met = item.entryCriteria.every(
|
||||
(criterion) => evaluateCriterion(criterion, scope, changedRefs, evaluate).kind === 'satisfied',
|
||||
);
|
||||
if (!met) continue;
|
||||
next = occur(next, item.id);
|
||||
achieved.push(item.id);
|
||||
}
|
||||
return { runtime: next, achieved };
|
||||
}
|
||||
|
||||
export function occur(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||||
return {
|
||||
...runtime,
|
||||
@@ -279,3 +351,77 @@ export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判据名归一 —— 让「模型抄的那一句」和「清单里写的那一句」对得上。
|
||||
*
|
||||
* `judge` 的 criterion 是让模型「照判据名原样写」的一句中文,可它抄的时候会顺手
|
||||
* 把半角标点敲成全角、把空格吃掉:清单里写的是「吃不准降 B,不许臆断 A」,
|
||||
* 它交回来的是「吃不准降B,不许臆断A」—— 逐字比对当然对不上,那条判据于是永远未达标,
|
||||
* 提交放行一遍遍被拒,直到预算烧完(实测 32 次被拒 → exhausted)。
|
||||
*
|
||||
* 归一只做两件安全的事:**去掉所有空白** + **全角标点折成半角**。
|
||||
* 不做同义词匹配、不做模糊比对 —— 那会把两条不同的判据混成一条,比对不上更糟。
|
||||
*/
|
||||
export function criterionKey(raw: string): string {
|
||||
const folded = raw.replace(/[!-~]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0));
|
||||
const tight = folded.replace(/\s+/g, '').replace(/[、,]/g, ',').toLowerCase();
|
||||
/*
|
||||
* 末尾的等级标记要削掉 —— 那是**引擎自己贴上去的**,不是判据名的一部分。
|
||||
*
|
||||
* 提交被拒时,门给的话是「…还有 1 条判据没达标 —— 每项数据来源可溯(…)(L3)」,
|
||||
* 紧接着又说「judge 的 criterion 照判据名原样写」。模型照做了 ——
|
||||
* 它把 `(L3)` 一起抄了进来,于是这条裁定的键与清单里的判据名对不上,
|
||||
* 裁了等于没裁:再提交、再被拒、再裁……连拒五拍熔断(实测 A2 死于此)。
|
||||
*
|
||||
* 这不是模糊匹配:削的是引擎自己印上去的那一小段,不碰判据名本身。
|
||||
*/
|
||||
return tight.replace(/\(l[1-5]\)$/, '');
|
||||
}
|
||||
|
||||
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
|
||||
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
|
||||
|
||||
/**
|
||||
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
|
||||
*
|
||||
* 只给判据读的那几个,不是整个任务视图:视图有上百个键,全摆出来等于没摆。
|
||||
* 没绑上的名字如实写「(没写过)」—— 未绑定的名字在 S-FEEL 里求值为假,
|
||||
* 那正是这一步推不动的原因,藏起来只会让模型继续瞎猜。
|
||||
*/
|
||||
export function readingsOf(predicate: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
const names = new Set<string>();
|
||||
for (const raw of predicate.replace(/"[^"]*"/g, ' ').match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
|
||||
if (!SFEEL_WORDS.has(raw.toLowerCase())) names.add(raw);
|
||||
}
|
||||
if (names.size === 0) return '';
|
||||
const readings = [...names].map((name) => {
|
||||
const value = scope[name];
|
||||
if (value === undefined || value === null) return `${name}=(没写过)`;
|
||||
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
return `${name}=${text.length <= 120 ? text : `${text.slice(0, 120)}…`}`;
|
||||
});
|
||||
/*
|
||||
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
|
||||
*
|
||||
* 行数类判据(`quote_intake_record_rows = line_total`)拒回时只给两个数,
|
||||
* 模型看得见「10 ≠ 9」,却不知道多的是哪一行 —— 它只能猜:改数、重写、再提,
|
||||
* 三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
|
||||
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
|
||||
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
|
||||
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
|
||||
* 三种一摆主键就都是一句话能办的事:多的那个 PN 就在名单里对着。
|
||||
*
|
||||
* **只摆基座数得出来的**:本单在那张表写下的行、它们的主键。不替模型判断谁多谁少 ——
|
||||
* 名单摆出来,该删该补它自己看得出来;判断留给它,事实由基座给。
|
||||
*/
|
||||
const rowKeys = [...names]
|
||||
.filter((n) => n.endsWith('_rows') || n.endsWith('_missing'))
|
||||
.map((n) => {
|
||||
const listed = scope[n.endsWith('_rows') ? `${n.slice(0, -'_rows'.length)}_keys` : `${n}_keys`];
|
||||
if (typeof listed !== 'string' || listed === '') return '';
|
||||
return n.endsWith('_rows') ? `${n} 是这几行:${listed}` : `${n} 缺在这几行:${listed}`;
|
||||
})
|
||||
.filter((s) => s !== '');
|
||||
return `(现在:${readings.join('、')}${rowKeys.length === 0 ? '' : `;${rowKeys.join(';')}`})`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 阶段进度 —— **给人看的刻度**,纯函数、零 IO(编码规范 §5.3)。
|
||||
*
|
||||
* 计划项是给引擎的粒度(A1 六项);阶段是给人的粒度(三段 + 收口)。
|
||||
* 两层分开,是因为它们回答的是两个问题:
|
||||
* · 计划项:这一件事干完没有 —— 引擎要能判,所以拆得细;
|
||||
* · 阶段:这一单到哪儿了 —— 人要能扫一眼看懂,所以合得粗。
|
||||
* 拿计划项当进度条,人看到的是「6 格里过了 3 格」,而那 6 格他一个也说不出是什么。
|
||||
*/
|
||||
import type { CasePhase } from '@contracts';
|
||||
import type { ItemRuntime } from './lifecycle.ts';
|
||||
|
||||
export type PhaseProgress = {
|
||||
/** 第几阶段,1 起;全部阶段过完但还没收口 = 最后一格(结束态) */
|
||||
readonly index: number;
|
||||
/** 总格数 = 声明的阶段数 **+ 1**(结束态) */
|
||||
readonly total: number;
|
||||
/** 这一格叫什么 */
|
||||
readonly name: string;
|
||||
};
|
||||
|
||||
/** 结束态的名字 —— 每个活动都以放行收口(铁律③),不由活动自己起名 */
|
||||
export const CLOSING_PHASE = '放行收口';
|
||||
|
||||
/**
|
||||
* 算这一单在第几阶段。
|
||||
*
|
||||
* 判据是「这一阶段的计划项**全部收完**」——
|
||||
* · 收完(Completed / Terminated,后者是被跳过的裁量项)算过;
|
||||
* · 没收完就停在这一阶段,哪怕其中一项已经在办。
|
||||
* 从前往后找第一个没过的阶段:那就是当下这一格。全过了 = 进结束态。
|
||||
*
|
||||
* `terminal` 为真(这一单已收口)时直接给最后一格 —— 终态就是终态,
|
||||
* 不必再去数计划项:放行完成之后 T_release 才收,中间那一瞬会显示成「还差一格」。
|
||||
*/
|
||||
export function phaseProgress(
|
||||
phases: readonly CasePhase[] | undefined,
|
||||
items: readonly ItemRuntime[],
|
||||
terminal: boolean,
|
||||
): PhaseProgress | undefined {
|
||||
if (phases === undefined || phases.length === 0) return undefined;
|
||||
const total = phases.length + 1;
|
||||
if (terminal) return { index: total, total, name: CLOSING_PHASE };
|
||||
|
||||
const settled = new Set(items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id));
|
||||
for (const [at, phase] of phases.entries()) {
|
||||
if (!phase.items.every((id) => settled.has(id))) return { index: at + 1, total, name: phase.name };
|
||||
}
|
||||
return { index: total, total, name: CLOSING_PHASE };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
import type { LoopModel, StaffId, TaskId } from '@contracts';
|
||||
import type { CaseModel } from './internal/domain/cmmn-parse.ts';
|
||||
import type { CaseRuntime, EnabledSet } from './internal/domain/lifecycle.ts';
|
||||
import type { AgentAction } from './contract.ts';
|
||||
import type { AgentTurn } from './contract.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
|
||||
@@ -32,13 +32,47 @@ export type Sfeel = {
|
||||
/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */
|
||||
export type TaskDataGateway = {
|
||||
view(taskId: TaskId): Promise<Record<string, unknown>>;
|
||||
/**
|
||||
* 本活动**声明过**的台账对象(域.对象 → 列名与中文标)。
|
||||
*
|
||||
* 两用:喂给 Agent(不知道能写哪儿就只会瞎猜列名),以及校验 `write` 行动 ——
|
||||
* 没声明过的地址一律拒写。越域写与越权调工具同罪,都得在结构上拦住。
|
||||
*/
|
||||
writable(taskId: TaskId): Promise<
|
||||
Array<{
|
||||
domain: string;
|
||||
object: string;
|
||||
label: string;
|
||||
keys: string[];
|
||||
fields: Array<{ name: string; label: string; type?: string }>;
|
||||
}>
|
||||
>;
|
||||
/**
|
||||
* 写草稿,并回报**这一笔到底有没有改变什么**。
|
||||
*
|
||||
* 值和上次一模一样的写是空转。不回报,Agent 就看不出自己在原地打转 ——
|
||||
* 实测:同一行 verdict 被反复重写十几拍,每拍烧一次模型,预算就这么淌完。
|
||||
*/
|
||||
draftWrite(input: {
|
||||
taskId: TaskId;
|
||||
domain: string;
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
}): Promise<{ changed: boolean }>;
|
||||
/**
|
||||
* **撤掉本单草稿层的一行** —— 写错了得有退路。
|
||||
*
|
||||
* 回 `false` = 草稿层里根本没有这一行(多半是主键写法对不上,
|
||||
* `DAG…` 与 `DAG…-R0` 是两行)。如实说比静默成功有用:静默成功会让模型
|
||||
* 以为撤掉了,下一拍判据照旧不过,它又去撤一次。
|
||||
*/
|
||||
draftDrop(input: {
|
||||
taskId: TaskId;
|
||||
domain: string;
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
}): Promise<{ dropped: boolean }>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -52,7 +86,23 @@ export type AgentPort = {
|
||||
narrative: string;
|
||||
enabled: EnabledSet;
|
||||
scope: Record<string, unknown>;
|
||||
}): Promise<AgentAction>;
|
||||
/**
|
||||
* 这一单能调什么 —— **声明 × 目录**编译出来的工具清单(`compileToolBus`)。
|
||||
*
|
||||
* 不给它这份清单,它就只知道说明书里提过的工具名,连入参长什么样都得猜 ——
|
||||
* 实测的后果是:该调工具的那一步改成了发一张卡问人(「请为以下三个PN查询历史价格」),
|
||||
* 明明系统里就有这件工具。工具是声明式的,清单得摆在它眼前。
|
||||
*/
|
||||
tools: Array<{ id: string; description: string; input: Record<string, unknown>; effect: 'read' | 'write' }>;
|
||||
/** 本活动可写的台账地址与列名 —— 不给它字典,它只会照想象编列名 */
|
||||
writable: Array<{
|
||||
domain: string;
|
||||
object: string;
|
||||
label: string;
|
||||
keys: string[];
|
||||
fields: Array<{ name: string; label: string; type?: string }>;
|
||||
}>;
|
||||
}): Promise<AgentTurn>;
|
||||
};
|
||||
|
||||
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
|
||||
@@ -61,9 +111,21 @@ export type ToolGateway = {
|
||||
ok: boolean;
|
||||
output?: Record<string, unknown>;
|
||||
error?: { message: string };
|
||||
/**
|
||||
* 已转人代办,等人回填(§6.2)——「人是工具的一种实现」。
|
||||
*
|
||||
* 系统里没有这件工具时,不是失败:基座已开了一张执行卡并立了监听。
|
||||
* 这一拍该**挂起等人**,不该把计划项判 Failed —— 判 Failed 等于告诉 Agent
|
||||
* 「这条路走不通,换一条」,可这条路明明正在走,只是走的人换成了人。
|
||||
*/
|
||||
awaitingHuman?: { readonly workOrderId: string };
|
||||
}>;
|
||||
isWriteEffect(tool: string): Promise<boolean>;
|
||||
registerIntent(input: { taskId: TaskId; tool: string; input: Record<string, unknown>; label: string }): Promise<void>;
|
||||
/** 这一单能调什么(声明 × 目录)—— 单拍路径与开环路径吃同一份清单 */
|
||||
busOf(activityId: string): Promise<
|
||||
Array<{ id: string; description: string; input: Record<string, unknown>; effect: 'read' | 'write' }>
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -77,6 +139,24 @@ export type PendingAcceptance = {
|
||||
};
|
||||
|
||||
export type CardGateway = {
|
||||
/** 数据录入卡 —— 人确认那一下由基座直接写草稿层,不回到 Agent 手里 */
|
||||
entry(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
ask: string;
|
||||
ledger: string;
|
||||
key: Readonly<Record<string, string>>;
|
||||
fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>;
|
||||
/** 同一次确认还要落的另几张表 —— 建一位新客户 = 公司档 + 联系人档 */
|
||||
also?: ReadonlyArray<{
|
||||
title: string;
|
||||
ledger: string;
|
||||
key: Readonly<Record<string, string>>;
|
||||
fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>;
|
||||
}>;
|
||||
basis?: ReadonlyArray<{ k: string; v: string }>;
|
||||
}): Promise<{ id: string }>;
|
||||
issue(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
@@ -84,6 +164,42 @@ export type CardGateway = {
|
||||
workOrder: 'execution' | 'acceptance';
|
||||
card: Record<string, unknown>;
|
||||
}): Promise<{ id: string }>;
|
||||
/**
|
||||
* 发**补全卡**(对话卡族)—— 缺口清单摆在对话区、带一张表单,人就地填齐。
|
||||
*
|
||||
* 与执行工单分开是因为它们是两件事:工单是「派一件活给你办」,
|
||||
* 派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
|
||||
* SOP 上画着「补全卡」的那一步(A1-S2),要的就是这一件。
|
||||
*/
|
||||
ask(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
ask: string;
|
||||
fields: ReadonlyArray<{ key: string; label: string; hint?: string }>;
|
||||
basis?: ReadonlyArray<{ k: string; v: string }>;
|
||||
}): Promise<{ id: string }>;
|
||||
/**
|
||||
* 发**决策卡**(对话卡族)—— 岔路口摆给人**点一个**,不是让人敲字。
|
||||
*
|
||||
* 与补全卡的分工:补全卡问事实(缺哪几个字段,你填);决策卡问处置(往哪走,你选)。
|
||||
* 两条路都合法、选哪条是业务判断的时候,该发的是这一件。
|
||||
*/
|
||||
decide(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
ask: string;
|
||||
options: ReadonlyArray<{
|
||||
value: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
consequence?: string;
|
||||
recommended?: boolean;
|
||||
}>;
|
||||
basis?: ReadonlyArray<{ k: string; v: string }>;
|
||||
allowCustom?: boolean;
|
||||
}): Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export type ListenerRegistrar = {
|
||||
@@ -102,12 +218,18 @@ export type ListenerRegistrar = {
|
||||
* 零 LLM 即可成屏;模型只决定「下一步做什么」,不负责画面。
|
||||
*/
|
||||
export type ViewGateway = {
|
||||
/**
|
||||
* 推一帧画面。**回报推没推上** —— 从前是 `Promise<void>` 且模版查不到就静默 return:
|
||||
* 引擎每拍推一个硬编码的 `case-overview`,而 A1 声明的四张模版一个都不叫这个名字,
|
||||
* 于是**每一帧都被悄悄丢掉**,工作区从头到尾空白,日志里也没有一个字。
|
||||
* 静默失败比报错坏得多:它让「没画面」看起来像「活动还没产出」。
|
||||
*/
|
||||
push(input: {
|
||||
taskId: TaskId;
|
||||
activityId: string;
|
||||
template: string;
|
||||
scope: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
}): Promise<{ pushed: boolean; templates: string[] }>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -124,16 +246,37 @@ export type SkillGateway = {
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation';
|
||||
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation' | 'narration';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
/** 装载:按定位选摘活动记忆入 Agent 上下文(渐进披露) */
|
||||
loadActivityMemory(activityId: string, limit: number): Promise<Array<{ layer: string; statement: string }>>;
|
||||
/** 本任务自己的最近轨迹(窗口大小由活动声明的 memoryWindow 定)—— 喂回叙事面,Agent 每拍不失忆 */
|
||||
window(taskId: TaskId): Promise<Array<{ kind: string; title: string }>>;
|
||||
/**
|
||||
* 本任务自己的最近轨迹 —— 喂回叙事面,Agent 每拍不失忆。
|
||||
*
|
||||
* 不给 `size` 就用活动声明的 `memoryWindow`(叙事面按它给;窗口越大越贵)。
|
||||
* **熔断检测要另开一个更大的窗**:它数的是「同一堵墙撞了几次」,
|
||||
* 而一次提交被拒会连带写下交代、评估、推画面好几条 —— 20 条的叙事窗里
|
||||
* 至多装得下三四次撞墙,于是撞十次也数不到 5(实测:同一条判据拒回 10 次没熔断,
|
||||
* 另一单拒回 33 次,40 万 token 烧到 exhausted 才停)。
|
||||
*/
|
||||
window(
|
||||
taskId: TaskId,
|
||||
size?: number,
|
||||
): Promise<Array<{ kind: string; title: string; detail?: Record<string, unknown> | undefined }>>;
|
||||
};
|
||||
|
||||
export type TaskContext = {
|
||||
of(taskId: TaskId): Promise<{ activityId: string; ownerId: StaffId; humanCallCap: number }>;
|
||||
/**
|
||||
* 这一单的**机检判据名**(归一化后的 key 与 label 都在里头)。
|
||||
*
|
||||
* 给 `judge` 行动当门用:L1/L2 是拿 S-FEEL 对台账求值的,Agent 裁不动它 ——
|
||||
* 判据投影压根不读 Agent 对机检项的裁定。可从前引擎照收不误,还往轨迹里
|
||||
* 写一句「语义性前提评估:XXX → 满足」:模型于是以为这条已经过了,
|
||||
* 转头就 submit,被门以同一条判据拒回;再裁、再拒,连拒五拍熔断(实测 A2 死于此)。
|
||||
* 轨迹里那句「满足」是**假话** —— 界面不撒谎,对 Agent 也一样。
|
||||
*/
|
||||
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
|
||||
};
|
||||
|
||||
@@ -86,15 +86,20 @@ describe('structuredLog —— 零 LLM,可重算', () => {
|
||||
const entries = [
|
||||
entry('thought', { seq: 1 }),
|
||||
entry('action', { seq: 2 }),
|
||||
entry('card-sent', { seq: 3 }),
|
||||
entry('card-reply', { seq: 4 }),
|
||||
entry('release-verdict', { seq: 5 }),
|
||||
// 机器产出侧留一条**该进**的(交代进展)—— 动作那一类整类退出后,
|
||||
// 没有它这一侧就空了,「归侧分栏」那条用例也就断言不到东西
|
||||
entry('narration', { seq: 3 }),
|
||||
entry('card-sent', { seq: 4 }),
|
||||
entry('card-reply', { seq: 5 }),
|
||||
entry('release-verdict', { seq: 6 }),
|
||||
];
|
||||
|
||||
it('只取骨架条目,思考不入日志', () => {
|
||||
it('只取骨架条目:思考与动作都不入日志', () => {
|
||||
const lines = structuredLog(entries);
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines.some((l) => l.title.startsWith('思考'))).toBe(false);
|
||||
// 动作(调工具 / 写草稿 / 技能结晶)是机制,不是产出 —— 整类不进
|
||||
expect(lines.some((l) => l.title.startsWith('action'))).toBe(false);
|
||||
});
|
||||
|
||||
it('两次生成完全一致 —— 口径与源恒一致', () => {
|
||||
@@ -201,11 +206,73 @@ describe('归侧:治理干预', () => {
|
||||
entry('control', { seq: 1, title: '控制事件:halt' }),
|
||||
entry('engine-transition', { seq: 2, title: '步 1 完成' }),
|
||||
]);
|
||||
expect(lines.map((l) => l.title)).toEqual(['治理:控制事件:halt', '迁移:步 1 完成']);
|
||||
/*
|
||||
* 前缀**全部退休**(2026-07-31 第二轮):「治理:」「迁移:」这些是拿前缀
|
||||
* 兼职「这是什么动作」。动作现在有自己一格(四字章),内容只留内容。
|
||||
* 两者仍分得出,而且分得更清楚 ——
|
||||
* 动作:治理干预 vs 状态推进;主语:人(按下的那位)vs 系统。
|
||||
*/
|
||||
expect(lines.map((l) => l.title)).toEqual(['控制事件:halt', '步 1 完成']);
|
||||
expect(lines.map((l) => l.action)).toEqual(['治理干预', '状态推进']);
|
||||
expect(lines.map((l) => l.actor)).toEqual(['', '系统']); // '' = 人,名字由上层按 actorId 换
|
||||
expect(lines.map((l) => l.sideLabel)).toEqual(['人裁决', '机器产出']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 日志的呈现口径(2026-07-31 用户定):**类型是四字短语,内容有主语**。
|
||||
*
|
||||
* 「四字」不是排版洁癖:它逼着每一格只说一件事。从前这一格摆归侧
|
||||
* (机器产出 / 系统等待),那答的是「算谁的工时」——同一个「系统等待」下面
|
||||
* 既有派工单也有发录入卡,人扫一眼说不出这一步到底干了什么。
|
||||
*/
|
||||
describe('日志:四字动作 + 主语', () => {
|
||||
it('动作与对话区同词,人点确认那一下叫「员工确认」', () => {
|
||||
const lines = structuredLog([
|
||||
entry('trigger', { seq: 1, title: '一封邮件触发了这条流程' }),
|
||||
entry('narration', { seq: 2, title: '这封邮件来自青岛海洋电子', detail: { tag: 'conclusion' } }),
|
||||
entry('card-sent', { seq: 3, title: '这是位新客户,请核对建档信息', detail: { k: 'entry' } }),
|
||||
entry('card-reply', { seq: 4, title: '写入 customer.profile', detail: { k: 'entry', by: 's-1' } }),
|
||||
entry('card-sent', { seq: 5, title: '补齐三条料的品牌', detail: { k: 'elicitation' } }),
|
||||
entry('card-sent', { seq: 6, title: '请去询三家', detail: { mode: 'execution' } }),
|
||||
entry('human-input', { seq: 7, title: '看过了邮件', detail: { act: 'read-comm', by: 's-1' } }),
|
||||
]);
|
||||
|
||||
expect(lines.map((l) => l.action)).toEqual([
|
||||
'触发事件',
|
||||
'阶段结论',
|
||||
'数据录入',
|
||||
'员工确认', // ← 用户点名的那一个
|
||||
'请你补全',
|
||||
'派出工单',
|
||||
'员工阅件',
|
||||
]);
|
||||
// 每一条都有四个字 —— 少一个字多一个字,那一列就参差不齐
|
||||
for (const line of lines) expect(line.action).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('主语三种:任务助手 / 系统 / 具体某人(域里只给 id,名字上层换)', () => {
|
||||
const lines = structuredLog([
|
||||
entry('trigger', { seq: 1 }),
|
||||
entry('narration', { seq: 2 }),
|
||||
entry('card-reply', { seq: 3, detail: { k: 'entry', by: 's-7' } }),
|
||||
]);
|
||||
expect(lines.map((l) => l.actor)).toEqual(['系统', '任务助手', '']);
|
||||
expect(lines.map((l) => l.actorId)).toEqual([undefined, undefined, 's-7']);
|
||||
});
|
||||
|
||||
it('内容不带主语、不带前缀 —— 主语由上层拼,前缀早该退休', () => {
|
||||
const lines = structuredLog([
|
||||
entry('card-sent', { seq: 1, title: '这是位新客户', detail: { k: 'entry' } }),
|
||||
entry('ledger-write', { seq: 2, title: '放行写合并 3 行' }),
|
||||
entry('terminal', { seq: 3, title: 'released' }),
|
||||
]);
|
||||
// 系统名换人话:「放行写合并」是引擎自己的说法,「released」更是机器状态
|
||||
expect(lines.map((l) => l.title)).toEqual(['这是位新客户', '成果落账,共 3 行', '这一单已放行完成']);
|
||||
expect(lines.map((l) => l.action)).toEqual(['数据录入', '写入台账', '任务收尾']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('确定性蒸馏 —— 零 LLM 也留得下事实', () => {
|
||||
it('终态、人兜底、驳回各归纳一条,且都带证据链', () => {
|
||||
const drafts = deterministicDistill([
|
||||
@@ -229,3 +296,247 @@ describe('确定性蒸馏 —— 零 LLM 也留得下事实', () => {
|
||||
expect(deterministicDistill([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 工作日志是**给人看的** —— 引擎自己的机械动作不该占版面(2026-07-31 用户定)。
|
||||
*
|
||||
* 从前每条留痕都往日志里摆:「装配技能 intake.read-rfq@2.0.0」「装载案例模型(6 个计划项)」
|
||||
* 每单一模一样,说不出这一单的任何事;工具调用那一条更是废话 ——
|
||||
* 人要的是**产出结果**,而出参另有一条 result 记着。
|
||||
*/
|
||||
describe('工作日志只留人看得懂的', () => {
|
||||
it('装配技能 / 装载案例模型 —— 机械动作不进日志', () => {
|
||||
const lines = structuredLog([
|
||||
entry('thought', { seq: 1, title: '装配技能:intake.read-rfq@2.0.0' }),
|
||||
entry('engine-transition', { seq: 2, title: '装载案例模型:客户RFQ受理(6 个计划项)' }),
|
||||
entry('engine-transition', { seq: 3, title: '完成:定位客户信息' }),
|
||||
]);
|
||||
// 「完成:」这类前缀由动作那一格接管,内容只留内容
|
||||
expect(lines.map((l) => l.title)).toEqual(['定位客户信息']);
|
||||
expect(lines.map((l) => l.action)).toEqual(['完成一项']);
|
||||
});
|
||||
|
||||
/*
|
||||
* **工具那一整类都不进**(2026-08-02 用户定,翻了上一版口径)。
|
||||
*
|
||||
* 上一版留着「出参」那一条,说「人要的是结果不是过程」。可出参本身也是过程:
|
||||
* 「crm.history-price-read 返回」「行动被拒:…」—— 前者是一句机器话,
|
||||
* 后者是 AI 与引擎较劲的踪迹,人既看不懂也帮不上忙。
|
||||
* **成果在台账里、在成果报告里**;工作日志要的是「每一步办完了什么」。
|
||||
* 排障的人照旧看得到 —— 那些痕一条没少,只是不进这一屏。
|
||||
*/
|
||||
it('工具的调用与出参**都不进** —— 工作日志给人看的是每一步的成果', () => {
|
||||
const lines = structuredLog([
|
||||
entry('action', { seq: 1, title: 'crm.history-price-read', detail: { tool: 'crm.history-price-read' } }),
|
||||
entry('result', { seq: 2, title: 'crm.history-price-read 返回' }),
|
||||
entry('result', { seq: 3, title: '行动被拒:台账地址没声明过' }),
|
||||
entry('engine-transition', { seq: 4, title: '完成:查历史价' }),
|
||||
]);
|
||||
expect(lines.map((l) => l.title)).toEqual(['查历史价']);
|
||||
});
|
||||
|
||||
/*
|
||||
* **日志里不许出现系统内的英文名**(2026-08-02 用户定)。
|
||||
* 引擎留痕时顺手写了内部标识:`manualStart:S3_confirm`、`终态:released`、
|
||||
* `落地收敛,发射输出口:intake、to-alternative` —— 那是给排障看的机器话,
|
||||
* 摆在人的日志上等于没写。
|
||||
*/
|
||||
it('系统名换成人话:开始办 / 已放行完成 / 成果落账', () => {
|
||||
const lines = structuredLog([
|
||||
entry('engine-transition', { seq: 1, title: 'manualStart:T_release' }),
|
||||
entry('release-verdict', { seq: 2, title: '裁决:pass' }),
|
||||
entry('release-verdict', { seq: 3, title: '落地收敛,发射输出口:intake、to-alternative' }),
|
||||
entry('engine-transition', { seq: 4, title: '去向已算出:intake-route → to-alternative(…_type_tag_c > 0)' }),
|
||||
entry('engine-transition', { seq: 5, title: '落地收敛:合并 23 行,发射输出口 intake、to-alternative' }),
|
||||
entry('terminal', { seq: 6, title: '终态:released' }),
|
||||
]);
|
||||
/*
|
||||
* 一条英文都不许漏到人的日志上 —— 计划项号、口名、判据表达式都是给排障看的。
|
||||
* 那两条「落地收敛」不在这份名单里:**它们是同一件事的复述**,
|
||||
* 说得出实情的那一条(放行写合并,带「几行没进正式表」)已经把话说全了。
|
||||
*/
|
||||
expect(lines.map((l) => l.title)).toEqual([
|
||||
'开始办这一步',
|
||||
'验收通过',
|
||||
'去向已算出,随放行转入下游',
|
||||
'这一单已放行完成',
|
||||
]);
|
||||
expect(lines.every((l) => !/[A-Za-z_]{4,}/.test(l.title))).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* 这一条**翻过来了**(2026-07-31 第二轮,用户定)。
|
||||
* 原来的口径是「写台账那一条照进 —— 它本身就是产出」;
|
||||
* 可产出是**台账里那几行**,人在工作成果与台账里看得见,
|
||||
* 「写入草稿:quote_intake.record 3 行(3 行有变化)」说的是引擎写了一笔,
|
||||
* 「几行有变化」更是引擎自己的账 —— 对人说不出任何事。
|
||||
*/
|
||||
it('写台账那一条也**不进** —— 产出是台账里那几行,不是「我写了一笔」', () => {
|
||||
const lines = structuredLog([
|
||||
entry('action', {
|
||||
seq: 1,
|
||||
title: '写草稿:quote_intake.record 3 行',
|
||||
detail: { ledger: 'quote_intake.record', rows: 3 },
|
||||
}),
|
||||
// 「行动被拒」记在 result —— 同样不进(2026-08-02):那是 AI 与引擎较劲的踪迹
|
||||
entry('result', { seq: 2, title: '行动被拒:台账地址没声明过' }),
|
||||
]);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
|
||||
/* 系统事件本来一条都不在日志里 —— 而那恰是人最想回看的「这单怎么起来的」 */
|
||||
it('系统事件进日志;引擎给自己看的内部观察不进', () => {
|
||||
const lines = structuredLog([
|
||||
entry('observation', { seq: 1, title: '一封邮件触发了这条流程,系统指定你负责' }),
|
||||
entry('observation', { seq: 2, title: 'S2 已在进行中 —— 无需再启动', detail: { internal: true } }),
|
||||
]);
|
||||
expect(lines.map((l) => l.title)).toEqual(['一封邮件触发了这条流程,系统指定你负责']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 触发事件自成一侧 —— 它既不是机器产出也不是员工动作。
|
||||
*
|
||||
* 从前起单那句记成 `observation`,归侧推成「机器产出」——
|
||||
* 而机器什么也没产出:一封信到了、系统把这一环指给了你,那是**外面发生的事**。
|
||||
* 混进机器那一侧还会把自主率算歪:分母里多了一条谁也没干的事。
|
||||
*/
|
||||
describe('触发事件', () => {
|
||||
it('归侧是 trigger,不是 machine', () => {
|
||||
expect(deriveSide('trigger')).toBe('trigger');
|
||||
expect(deriveSide('observation')).toBe('machine');
|
||||
});
|
||||
|
||||
it('进结构化日志,且不加前缀 —— 它本来就是一句人话', () => {
|
||||
const lines = structuredLog([
|
||||
entry('trigger', { seq: 1, title: '一封邮件触发了这条流程,系统指定你负责「A1 · 客户RFQ受理」。' }),
|
||||
]);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.title).toBe('一封邮件触发了这条流程,系统指定你负责「A1 · 客户RFQ受理」。');
|
||||
expect(lines[0]?.sideLabel).toBe('触发事件');
|
||||
});
|
||||
|
||||
/* 自主率的分子分母只认机器与员工 —— 触发既不该进分子,也不该进分母 */
|
||||
it('不进自主率的分子分母', () => {
|
||||
const split = splitBySide(
|
||||
structuredLog([
|
||||
entry('trigger', { seq: 1, title: '一封邮件触发了这条流程' }),
|
||||
entry('narration', { seq: 2, title: 'AI 干了一件事' }),
|
||||
]),
|
||||
);
|
||||
expect(split.machine).toHaveLength(1);
|
||||
expect(split.human).toHaveLength(0);
|
||||
expect(split.trigger).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **日志要看得出人和 AI 分别干了啥**(2026-08-02 用户定)。
|
||||
*
|
||||
* 引擎流转从前一律署「系统」,可这一类里混着两种事:
|
||||
* 「完成一项」「开始办」是**AI 报的**(它 finish / start,引擎照办);
|
||||
* 「起单」「自动收步」是引擎自己做主的。全署系统,人就看不出这一单里
|
||||
* AI 到底干了什么、自己干了什么 —— 而那正是他翻这一屏想知道的事。
|
||||
*/
|
||||
describe('日志分得清谁干的', () => {
|
||||
it('AI 报的完成 / 开始办记在「任务助手」名下', () => {
|
||||
const lines = structuredLog([
|
||||
entry('engine-transition', { seq: 1, title: '完成:定位客户信息' }),
|
||||
entry('engine-transition', { seq: 2, title: '开始办:受理需求交人审定', detail: { by: 'agent' } }),
|
||||
]);
|
||||
expect(lines.map((l) => l.actor)).toEqual(['任务助手', '任务助手']);
|
||||
});
|
||||
|
||||
it('引擎自己做主的仍署「系统」—— 起单不是谁办的,是流程走到了这儿', () => {
|
||||
const lines = structuredLog([
|
||||
entry('engine-transition', { seq: 1, title: '起单:A1 · 询价受理' }),
|
||||
entry('engine-transition', { seq: 2, title: '自动收步:查历史价' }),
|
||||
]);
|
||||
expect(lines.map((l) => l.actor)).toEqual(['系统', '系统']);
|
||||
});
|
||||
|
||||
it('**那一步的名字要留着** —— 只说「开始办这一步」等于没说办的是哪一步', () => {
|
||||
const lines = structuredLog([entry('engine-transition', { seq: 1, title: '开始办:受理需求交人审定' })]);
|
||||
expect(lines[0]?.title).toBe('开始办:受理需求交人审定');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **日志只有三色**(2026-08-02 用户定):负责人绿 · 智能体蓝 · 系统灰。
|
||||
*
|
||||
* 归侧 `side` 有五种,那是**度量**的分法(自主率要把「等外面」与「机器干的」分开);
|
||||
* 而人扫这一屏只问一件事:**这一步是我办的、AI 办的,还是流程自己走到的**。
|
||||
* 拿五种去上色,屏上就是五种深浅,而那五种对读的人不构成区别。
|
||||
*/
|
||||
describe('日志三档:谁干的', () => {
|
||||
it('负责人办的归 owner —— 回卡、就地修订、放行裁决', () => {
|
||||
const lines = structuredLog([
|
||||
entry('card-reply', { seq: 1, title: '补齐了两颗料的制造商', detail: { by: 's-1' } }),
|
||||
entry('human-input', { seq: 2, title: '就地修订成果:9 行', detail: { by: 's-1' } }),
|
||||
entry('release-verdict', { seq: 3, title: '裁决:pass', detail: { by: 's-1' } }),
|
||||
]);
|
||||
expect(lines.map((l) => l.who)).toEqual(['owner', 'owner', 'owner']);
|
||||
});
|
||||
|
||||
it('AI 办的归 agent —— 完成一项、开始办、交代进展、派卡', () => {
|
||||
const lines = structuredLog([
|
||||
entry('engine-transition', { seq: 1, title: '完成:查历史价' }),
|
||||
entry('narration', { seq: 2, title: '九条料都拆好了', detail: { tag: 'conclusion' } }),
|
||||
entry('card-sent', { seq: 3, title: '受理需求审定', detail: { mode: 'execution' } }),
|
||||
]);
|
||||
expect(lines.map((l) => l.who)).toEqual(['agent', 'agent', 'agent']);
|
||||
});
|
||||
|
||||
it('流程自己走到的归 system —— 起单与触发不是谁办的', () => {
|
||||
const lines = structuredLog([
|
||||
entry('engine-transition', { seq: 1, title: '起单:A1 · 询价受理' }),
|
||||
entry('trigger', { seq: 2, title: '一封邮件触发了这条流程' }),
|
||||
entry('terminal', { seq: 3, title: '终态:released' }),
|
||||
]);
|
||||
expect(lines.map((l) => l.who)).toEqual(['system', 'system', 'system']);
|
||||
});
|
||||
|
||||
it('**只有这三档** —— 多一档就是屏上多一种颜色,而那一种说不出新东西', () => {
|
||||
const lines = structuredLog([
|
||||
entry('trigger', { seq: 1, title: '起' }),
|
||||
entry('engine-transition', { seq: 2, title: '完成:一步' }),
|
||||
entry('human-input', { seq: 3, title: '改了一格', detail: { by: 's-1' } }),
|
||||
]);
|
||||
expect([...new Set(lines.map((l) => l.who))].sort()).toEqual(['agent', 'owner', 'system']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('放行那一刻的日志尾巴', () => {
|
||||
/** 留痕那一侧三条都在(排障分得清谁做的);日志这一侧只说一遍 */
|
||||
const tail = () =>
|
||||
structuredLog([
|
||||
entry('release-verdict', { seq: 1, title: '裁决:pass', detail: { by: 's-1' } }),
|
||||
entry('ledger-write', { seq: 2, title: '放行写合并 10 行(试跑:另有 1 行主数据只留在活动缓存,未落正式表)' }),
|
||||
entry('release-verdict', { seq: 3, title: '落地收敛,发射输出口:intake、to-sourcing' }),
|
||||
entry('engine-transition', { seq: 4, title: '落地收敛:合并 10 行,发射输出口 intake、to-sourcing' }),
|
||||
entry('terminal', { seq: 5, title: '终态:released' }),
|
||||
]);
|
||||
|
||||
it('**「落账了」只说一遍** —— 四行说同一件事,人读的是日志不是留痕', () => {
|
||||
const said = tail().map((l) => l.title);
|
||||
expect(said.filter((t) => t.includes('落账'))).toHaveLength(1);
|
||||
expect(said).toEqual(['验收通过', '成果落账,共 10 行(试跑:另有 1 行主数据只留在活动缓存,未落正式表)', '这一单已放行完成']);
|
||||
});
|
||||
|
||||
it('留的是**说得出实情的那一条** —— 几行没进正式表,只有它讲得出来', () => {
|
||||
expect(tail()[1]?.title).toContain('未落正式表');
|
||||
});
|
||||
|
||||
it('放行合并归系统,不归 AI —— 记在它名下会让人以为是它自作主张写了正式表', () => {
|
||||
expect(tail()[1]?.who).toBe('system');
|
||||
});
|
||||
|
||||
it('**人工修订归人** —— 同一类留痕,带了他的 id 就是他改的', () => {
|
||||
const [line] = structuredLog([
|
||||
entry('ledger-write', { seq: 1, title: '人工修订:目标价 → 0.31(落 quote_intake.record)', detail: { by: 's-9' } }),
|
||||
]);
|
||||
expect(line?.who).toBe('owner');
|
||||
expect(line?.actorId).toBe('s-9');
|
||||
expect(line?.title).toBe('你改的那一笔已落账:目标价 → 0.31(落 quote_intake.record)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,19 +15,62 @@ export type LogLine = {
|
||||
readonly at: string;
|
||||
readonly side: AttributionSide;
|
||||
readonly sideLabel: string;
|
||||
/**
|
||||
* **这一条是谁干的** —— 日志只认三档(2026-08-02 用户定):
|
||||
* `owner` 负责人(绿)· `agent` 智能体(蓝)· `system` 系统(灰)。
|
||||
*
|
||||
* 归侧 `side` 有五种(machine / human / human-verdict / world-wait / trigger),
|
||||
* 那是**度量**的分法(自主率要把「等外面」与「机器干的」分开);
|
||||
* 而人扫这一屏时只问一件事:**这一步是我办的、AI 办的,还是流程自己走到的**。
|
||||
* 拿五种去上色,屏上就是五种深浅,而那五种对读的人不构成区别。
|
||||
*/
|
||||
readonly who: 'owner' | 'agent' | 'system';
|
||||
/**
|
||||
* **类型章:四字短语**,与对话区同词同粒度(2026-07-31 用户定)。
|
||||
*
|
||||
* 从前这一格摆的是归侧(机器产出 / 系统等待 …)—— 那是**度量的口径**,
|
||||
* 答的是「这一步该算谁的工时」;人翻日志想知道的是「这一步是什么动作」。
|
||||
* 两者不是一回事:同一个「系统等待」下面既有派工单也有发录入卡。
|
||||
* 归侧仍在(`side` / `sideLabel`),度量与配色照旧靠它,只是不再占着这一格。
|
||||
*/
|
||||
readonly action: string;
|
||||
/** 主语:任务助手 / 系统;人做的那些留空,由上层照 `actorId` 换成名字 */
|
||||
readonly actor: string;
|
||||
/** 人做的那一条是谁做的 —— 域里不认识人名(纯函数零 IO),只交出 id */
|
||||
readonly actorId: string | undefined;
|
||||
/** 内容本身,**不含主语** —— 主语由上层拼在前头(名字要查库才知道) */
|
||||
readonly title: string;
|
||||
readonly summary: string | undefined;
|
||||
readonly eventRef: string | undefined;
|
||||
readonly position: string | undefined;
|
||||
};
|
||||
|
||||
/** 骨架:只有这些种类进结构化日志(OS 8.1 列举) */
|
||||
/**
|
||||
* 骨架:哪些种类进结构化日志(OS 8.1 列举)。
|
||||
*
|
||||
* **工作日志是给人看的**(2026-07-31 用户定)——「装配技能 xxx@2.0.0」
|
||||
* 「装载案例模型(6 个计划项)」这类是引擎自己的机械动作,人看了不知道该拿它怎么办;
|
||||
* 工具调用的过程同理:**人要的是产出结果,不是调用细节**。
|
||||
* 所以骨架之外还有一道 `machinery()` 过滤(见下),两道一起才干净。
|
||||
*
|
||||
* `observation` 是**新进来的**:系统事件(一封信触发了这条流、邮件确认、
|
||||
* 人把来件标了已读)本来一条都不在日志里 —— 而那恰恰是人最想回看的
|
||||
* 「这单怎么起来的、谁在什么时候看过」。
|
||||
*
|
||||
* `action` **整类退出**(2026-07-31 第二轮,用户定)。它只有三个来源,全是机制:
|
||||
* 调工具、写草稿、技能结晶。「写入草稿:quote_intake.record 3 行(3 行有变化)」
|
||||
* 对人说不出任何事 —— 写了什么在工作成果与台账里看得见,「几行有变化」是引擎的账。
|
||||
* 从前只滤掉带 `tool` 的那一半,留着写草稿的另一半,理由是「它本身就是产出」;
|
||||
* 可产出是**台账里那几行**,不是「我写了一笔」这个动作。
|
||||
* 「为什么不行」不在这一类里:行动被拒、提交被拒、换画面失败都记在 `result`,照进。
|
||||
*/
|
||||
const SKELETON: ReadonlySet<EntryKind> = new Set<EntryKind>([
|
||||
'trigger',
|
||||
'observation',
|
||||
'engine-transition',
|
||||
'action',
|
||||
'result',
|
||||
'card-sent',
|
||||
'card-reply',
|
||||
'narration',
|
||||
'ledger-write',
|
||||
'action-intent',
|
||||
'release-verdict',
|
||||
@@ -36,32 +79,268 @@ const SKELETON: ReadonlySet<EntryKind> = new Set<EntryKind>([
|
||||
'control',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 内容取原文 —— **不加前缀**。
|
||||
*
|
||||
* 从前每种各挂一个前缀(`发出:` `回传:` `写入:` `终态:` …),那是在用前缀
|
||||
* 兼职「这是什么动作」。现在动作有了自己的一格(`action`),前缀就成了重复:
|
||||
* 「[数据录入] 发出:这是位新客户…」——「发出」两个字白占地方。
|
||||
* 主语也不在这儿拼:名字要查库,域里查不了(纯函数零 IO)。
|
||||
*/
|
||||
const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
|
||||
observation: (e) => `观察:${e.title}`,
|
||||
thought: (e) => `思考:${e.title}`,
|
||||
action: (e) => `调用:${e.title}`,
|
||||
result: (e) => `返回:${e.title}`,
|
||||
'engine-transition': (e) => `迁移:${e.title}`,
|
||||
evaluation: (e) => `求值:${e.title}`,
|
||||
'card-sent': (e) => `发出:${e.title}`,
|
||||
'card-reply': (e) => `回传:${e.title}`,
|
||||
'ledger-write': (e) => `写入:${e.title}`,
|
||||
'action-intent': (e) => `动作意向:${e.title}`,
|
||||
'release-verdict': (e) => `放行裁决:${e.title}`,
|
||||
terminal: (e) => `终态:${e.title}`,
|
||||
'human-input': (e) => `留言:${e.title}`,
|
||||
control: (e) => `治理:${e.title}`,
|
||||
observation: (e) => e.title,
|
||||
thought: (e) => e.title,
|
||||
action: (e) => actionEntryOf(e).body,
|
||||
result: (e) => e.title,
|
||||
'engine-transition': (e) => plain(transitionOf(e.title).body),
|
||||
evaluation: (e) => e.title,
|
||||
'card-sent': (e) => e.title,
|
||||
'card-reply': (e) => e.title,
|
||||
'ledger-write': (e) => plain(e.title),
|
||||
'action-intent': (e) => e.title,
|
||||
'release-verdict': (e) => plain(e.title),
|
||||
terminal: (e) => plain(e.title),
|
||||
'human-input': (e) => e.title,
|
||||
narration: (e) => e.title,
|
||||
control: (e) => e.title,
|
||||
trigger: (e) => e.title,
|
||||
};
|
||||
|
||||
/**
|
||||
* 四字动作 —— **对话区说什么词,这里就是什么词**。
|
||||
*
|
||||
* 对齐的出处是 `chat-bubbles.tsx` 的三张表:AI 的类型章(`TAG_UI`)、
|
||||
* 系统气泡的章(`SYS_TAG`)、各卡卡头的类型名。同一件事在两屏用两个说法,
|
||||
* 人得在脑子里做一次翻译 —— 而这本来是同一件事。
|
||||
*/
|
||||
const CARD_ACTION: Readonly<Record<string, string>> = {
|
||||
elicitation: '请你补全',
|
||||
decision: '需要你定',
|
||||
entry: '数据录入',
|
||||
rating: '任务评价',
|
||||
execution: '派出工单',
|
||||
acceptance: '请你验收',
|
||||
};
|
||||
|
||||
/** 人回传:一律「员工 X X」句式 —— 点确认那一下就是**员工确认**(用户定) */
|
||||
const REPLY_ACTION: Readonly<Record<string, string>> = {
|
||||
entry: '员工确认',
|
||||
elicitation: '员工补全',
|
||||
decision: '员工决策',
|
||||
rating: '员工评价',
|
||||
execution: '员工办结',
|
||||
acceptance: '放行裁决',
|
||||
};
|
||||
|
||||
/** 叙述的三种签,与对话区 `TAG_UI` / `SAY_UI` 同词 */
|
||||
const NARRATION_ACTION: Readonly<Record<string, string>> = {
|
||||
conclusion: '阶段结论',
|
||||
guidance: '交代进展',
|
||||
criterion: '请你验收',
|
||||
};
|
||||
|
||||
/**
|
||||
* **日志里不许出现系统内的英文名**(2026-08-02 用户定)。
|
||||
*
|
||||
* 工作日志是给人看的。而引擎留痕时顺手把内部标识写了进去:
|
||||
* `manualStart:S3_confirm`、`终态:released`、`发射输出口:intake、to-alternative`——
|
||||
* 那是给排障的人看的机器话,摆在人的日志上等于没写。
|
||||
* 这里逐条换成人话;认不出的原样留着(宁可笼统,不可编)。
|
||||
*/
|
||||
const PLAIN: ReadonlyArray<{ readonly re: RegExp; readonly to: string }> = [
|
||||
/*
|
||||
* **计划项号对人是天书**:`manualStart:S3_confirm`、`开始办:T_release` ——
|
||||
* 那一格里唯一的信息是「引擎开始办某一步了」,而哪一步下一行的「完成一项」会说清。
|
||||
* 与其摆一个 id,不如只说这件事本身。
|
||||
*/
|
||||
// 老留痕里还有 `manualStart:S3_confirm` 这种(新的已经写名字了)—— 认不出名字就只说这件事
|
||||
{ re: /^manualStart[::]\s*[A-Za-z0-9_]+$/, to: '开始办这一步' },
|
||||
{ re: /^manualStart[::]\s*/, to: '开始办:' },
|
||||
// 去向那一条:口名与判据表达式都是给排障看的;人要知道的是「转到哪儿」
|
||||
{ re: /^去向已算出[::].*→\s*([a-z-]+).*$/, to: '去向已算出,随放行转入下游' },
|
||||
// 终态有两种写法:带「终态:」前缀的,与裸状态名 —— 两种都得认,漏一种就露一个英文
|
||||
{ re: /^(终态[::]\s*)?released$/, to: '这一单已放行完成' },
|
||||
{ re: /^(终态[::]\s*)?blocked$/, to: '这一单卡住了,没能落地' },
|
||||
{ re: /^(终态[::]\s*)?rejected$/, to: '这一单被退回了' },
|
||||
{ re: /^(终态[::]\s*)?exhausted$/, to: '这一单预算用尽,停下了' },
|
||||
{ re: /^裁决[::]\s*pass$/, to: '验收通过' },
|
||||
{ re: /^裁决[::]\s*redo$/, to: '验收退回重做' },
|
||||
{ re: /^放行写合并\s*/, to: '成果落账,共 ' },
|
||||
{ re: /^人工修订[::]\s*/, to: '你改的那一笔已落账:' },
|
||||
];
|
||||
|
||||
/** 把一行里的系统名换成人话 —— 认不出就原样留着 */
|
||||
function plain(title: string): string {
|
||||
for (const p of PLAIN) if (p.re.test(title)) return title.replace(p.re, p.to);
|
||||
return title;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引擎流转的措辞由引擎自己拼(`起单:xxx` / `完成:xxx` …)。
|
||||
* 认出前缀是为了两件事:动作那一格说得具体,以及**把前缀从内容里摘掉** ——
|
||||
* 章上已经写着「任务起单」,内容再来一遍「起单:」就是同一句话说两遍。
|
||||
* 认不出的一律「状态推进」,内容原样留着:宁可笼统,不可编。
|
||||
*/
|
||||
const TRANSITION: ReadonlyArray<{ readonly prefix: string; readonly action: string }> = [
|
||||
{ prefix: '起单:', action: '任务起单' },
|
||||
{ prefix: '完成:', action: '完成一项' },
|
||||
{ prefix: '自动收步:', action: '自动收步' },
|
||||
{ prefix: '立监听:', action: '立起监听' },
|
||||
{ prefix: '驳回重入:', action: '驳回重入' },
|
||||
];
|
||||
|
||||
function transitionOf(title: string): { action: string; body: string } {
|
||||
for (const t of TRANSITION) {
|
||||
if (title.startsWith(t.prefix)) return { action: t.action, body: title.slice(t.prefix.length) };
|
||||
}
|
||||
return { action: '状态推进', body: title };
|
||||
}
|
||||
|
||||
/** action 条目有两种:写草稿与调工具 —— 靠 detail 上的 `ledger` 记号分,不靠措辞 */
|
||||
function actionEntryOf(e: MemoryEntry): { action: string; body: string } {
|
||||
if (e.detail?.['ledger'] === undefined) return { action: '调用工具', body: e.title };
|
||||
const p = '写草稿:';
|
||||
return { action: '写入草稿', body: e.title.startsWith(p) ? e.title.slice(p.length) : e.title };
|
||||
}
|
||||
|
||||
function actionOf(e: MemoryEntry): string {
|
||||
const d = e.detail ?? {};
|
||||
const cardKey = String(d['k'] ?? d['mode'] ?? '');
|
||||
switch (e.kind) {
|
||||
case 'trigger':
|
||||
return '触发事件';
|
||||
case 'observation':
|
||||
return '系统事件';
|
||||
case 'engine-transition':
|
||||
return transitionOf(e.title).action;
|
||||
case 'action':
|
||||
return actionEntryOf(e).action;
|
||||
case 'narration':
|
||||
return NARRATION_ACTION[String(d['tag'] ?? '')] ?? '阶段结论';
|
||||
case 'card-sent':
|
||||
return CARD_ACTION[cardKey] ?? '发出卡片';
|
||||
case 'card-reply':
|
||||
return REPLY_ACTION[cardKey] ?? '员工回传';
|
||||
// 人在界面上的动作:点确认 = 员工确认,看来件 = 员工阅件
|
||||
case 'human-input':
|
||||
return d['act'] === 'read-comm' ? '员工阅件' : '员工确认';
|
||||
case 'ledger-write':
|
||||
return '写入台账';
|
||||
case 'result':
|
||||
return '工具产出';
|
||||
case 'action-intent':
|
||||
return '动作意向';
|
||||
case 'release-verdict':
|
||||
return '放行裁决';
|
||||
case 'terminal':
|
||||
return '任务收尾';
|
||||
case 'control':
|
||||
return '治理干预';
|
||||
case 'thought':
|
||||
return '模型思考';
|
||||
case 'evaluation':
|
||||
return '判据求值';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主语 —— 日志每一行都要有主语,不然是一串没头脑的流水账。
|
||||
*
|
||||
* 三种:**任务助手**(AI 干的)、**系统**(基座干的:起单、事件到达、收尾)、
|
||||
* 以及**具体某个人**。人名要查库,域里查不了,所以这里只交出 `actorId`,
|
||||
* 由上层换成名字;换不到就退回「员工」,不编名字。
|
||||
*/
|
||||
function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined; who: 'owner' | 'agent' | 'system' } {
|
||||
const by = e.detail?.['by'];
|
||||
const id = typeof by === 'string' && by !== '' ? by : undefined;
|
||||
switch (e.kind) {
|
||||
case 'trigger':
|
||||
case 'observation':
|
||||
case 'terminal':
|
||||
return { actor: '系统', actorId: undefined, who: 'system' };
|
||||
/*
|
||||
* **AI 办完的事要记在 AI 名下**(2026-08-02 用户:日志「要能看出人和 AI 分别干了啥」)。
|
||||
*
|
||||
* 引擎流转从前一律署「系统」,可这一类里混着两种事:
|
||||
* · 「完成一项」「开始办」——**AI 报的**(它 finish / start,引擎照办);
|
||||
* · 「起单」「自动收步」「驳回重入」—— 引擎自己做主的。
|
||||
* 全署系统,人就看不出这一单里 AI 到底干了什么、自己干了什么 ——
|
||||
* 而那正是他翻这一屏想知道的事。留痕上带 `by: 'agent'` 的从这里认。
|
||||
*/
|
||||
case 'engine-transition':
|
||||
return e.detail?.['by'] === 'agent' || /^(完成|开始办)/.test(e.title)
|
||||
? { actor: '任务助手', actorId: undefined, who: 'agent' }
|
||||
: { actor: '系统', actorId: undefined, who: 'system' };
|
||||
case 'card-reply':
|
||||
case 'human-input':
|
||||
case 'release-verdict':
|
||||
case 'control':
|
||||
return { actor: '', actorId: id, who: 'owner' };
|
||||
/*
|
||||
* 台账写入有两种,主语不同:**放行合并**是基座在人签完字之后做的一次落账
|
||||
* (记在 AI 名下会让人以为是它自作主张写了正式表);**人工修订**是人自己改的
|
||||
* (留痕带上了他的 id)。认 id,不认措辞。
|
||||
*/
|
||||
case 'ledger-write':
|
||||
return id === undefined ? { actor: '系统', actorId: undefined, who: 'system' } : { actor: '', actorId: id, who: 'owner' };
|
||||
default:
|
||||
return { actor: '任务助手', actorId: undefined, who: 'agent' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 结构化日志:零 LLM,可重算,每条自带谁 · 何时 · 依据 · 留痕指针 */
|
||||
/**
|
||||
* **机械动作** —— 进得了骨架,但人看了没有用处的那些。
|
||||
*
|
||||
* 判据是「这一条能不能让人做出判断」:
|
||||
* · 装配技能 / 装载案例模型 —— 引擎每单必做,一模一样,说不出这一单的任何事;
|
||||
* · `internal` 标的观察(已在进行中 / 同拍余下作废)—— 引擎给自己看的踪迹;
|
||||
* · 人事件的到达 —— 一串卡号,而这件事上一行已经用人话说过了。
|
||||
*
|
||||
* (动作那一整类 `action` 不在这儿滤 —— 它压根不进骨架,见上面 SKELETON。)
|
||||
*
|
||||
* 不用「白名单标题」那一路:标题措辞随时会改,改一次过滤就失灵而没人发现。
|
||||
* 这里认的是 kind + detail 上的结构化标记,措辞怎么变都不影响。
|
||||
*/
|
||||
/**
|
||||
* **同一件事的复述** —— 人签完字那一刻,基座与引擎各留了一条痕,说的是同一件事。
|
||||
*
|
||||
* 实测一单的日志尾巴是这样的(用户 2026-08-02 提的「日志是给人看的」):
|
||||
* · 成果落账,共 10 行(试跑:另有 1 行只留在活动缓存) ← 放行写合并,**带得住实情**
|
||||
* · 成果已落账,交给下游 ← 发射输出口,同一件事
|
||||
* · 成果落账,共 10 行,已交下游 ← 引擎流转,还是同一件事
|
||||
* · 这一单已放行完成 ← 终态
|
||||
* 四行里有三行在说「落账了」。留痕那一侧三条都该在(排障要分得清是谁做的),
|
||||
* 但**日志这一侧只该说一遍**:留合并那一条(它是唯一说得出「几行没进正式表」的),
|
||||
* 另外两条的信息它全包含。
|
||||
*/
|
||||
const RESTATED: ReadonlyArray<RegExp> = [/^落地收敛[::]\s*合并/, /^落地收敛,发射输出口/];
|
||||
|
||||
function machinery(e: MemoryEntry): boolean {
|
||||
if (e.detail?.['internal'] === true) return true;
|
||||
if (RESTATED.some((re) => re.test(e.title))) return true;
|
||||
if (e.kind === 'thought') return true;
|
||||
if (e.kind === 'engine-transition' && e.title.startsWith('装载案例模型')) return true;
|
||||
/*
|
||||
* **人事件的到达**:对人它只是「事件到达(human):d455688a-…」一串卡号,
|
||||
* 而这件事上一行已经用人话说过了(「员工确认:写入 customer.profile」)。
|
||||
* 同一次点击记两行,后一行还是 UUID —— 对话区早就滤掉了它,日志该同一口径。
|
||||
* 数据/时间事件的到达留着:那些是外面来的,人确实要知道什么时候到的。
|
||||
*/
|
||||
if (e.kind === 'observation' && e.detail?.['kind'] === 'human') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] {
|
||||
return entries
|
||||
.filter((e) => SKELETON.has(e.kind))
|
||||
.filter((e) => SKELETON.has(e.kind) && !machinery(e))
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
at: e.at,
|
||||
side: e.side,
|
||||
sideLabel: ATTRIBUTION_SIDE_LABEL[e.side],
|
||||
action: actionOf(e),
|
||||
...actorOf(e),
|
||||
title: TEMPLATE[e.kind](e),
|
||||
summary: typeof e.detail?.['summary'] === 'string' ? e.detail['summary'] : undefined,
|
||||
eventRef: e.eventRef,
|
||||
@@ -76,6 +355,7 @@ export function splitBySide(lines: readonly LogLine[]): Record<AttributionSide,
|
||||
human: [],
|
||||
'human-verdict': [],
|
||||
'world-wait': [],
|
||||
trigger: [],
|
||||
};
|
||||
for (const line of lines) out[line.side].push(line);
|
||||
return out;
|
||||
@@ -84,7 +364,7 @@ export function splitBySide(lines: readonly LogLine[]): Record<AttributionSide,
|
||||
/* ── 度量八指标(OS 8.3):只读投影,零埋点,统计即查询 ─────────────── */
|
||||
|
||||
export type Metrics = {
|
||||
/** 自主率 = 机器产出 ÷(机器产出 + 人产出);裁决与等待不进分母 */
|
||||
/** 自主率 = 机器产出 ÷(机器产出 + 员工动作);裁决与等待不进分母 */
|
||||
readonly autonomy: number;
|
||||
/** 纠正率 = 被纠正批次 ÷ 提交批次 */
|
||||
readonly correction: number;
|
||||
|
||||
@@ -21,8 +21,25 @@ export const ENTRY_KINDS = [
|
||||
'release-verdict',
|
||||
'terminal',
|
||||
'human-input',
|
||||
/**
|
||||
* **AI 对人说的话** —— 活动主动交代进展的那一句(「三条已归一,缺 1 个交期」)。
|
||||
*
|
||||
* 与 `thought` 分开:thought 是给自己留的痕(装配了哪些技能、为什么这么判),
|
||||
* narration 是**给人看的**,它进对话区。混成一类的后果是要么把机器踪迹全倒进对话区,
|
||||
* 要么人在对话区里一个字也看不到 —— 后者实测发生过:A1 跑了十几拍,
|
||||
* 对话区从头到尾空白,人只看得见「多了一个任务」。
|
||||
*/
|
||||
'narration',
|
||||
/** 治理干预:控制事件 / 恢复 / 改派 —— 人对过程的裁断,不是引擎自转 */
|
||||
'control',
|
||||
/**
|
||||
* **触发事件**(2026-07-31 用户定)—— 这一单是被什么叫起来的。
|
||||
*
|
||||
* 从前它记成 `observation`,归侧推成「机器产出」—— 而机器什么也没产出:
|
||||
* 一封信到了、系统把这一环指给了你,这是**外面发生的事**,
|
||||
* 不是 AI 干的活,也不该进自主率的分子分母。它自成一类。
|
||||
*/
|
||||
'trigger',
|
||||
] as const;
|
||||
export type EntryKind = (typeof ENTRY_KINDS)[number];
|
||||
|
||||
@@ -66,6 +83,9 @@ export type MemoryEntry = {
|
||||
*/
|
||||
export function deriveSide(kind: EntryKind): AttributionSide {
|
||||
switch (kind) {
|
||||
// 触发事件自成一侧:既不是机器产出也不是人做的,它是这一单的起因
|
||||
case 'trigger':
|
||||
return 'trigger';
|
||||
case 'observation':
|
||||
case 'thought':
|
||||
case 'action':
|
||||
@@ -74,6 +94,7 @@ export function deriveSide(kind: EntryKind): AttributionSide {
|
||||
case 'evaluation':
|
||||
case 'ledger-write':
|
||||
case 'action-intent':
|
||||
case 'narration': // 交代进展是机器产出:它是 AI 干活的一部分,不是人的时间
|
||||
return 'machine';
|
||||
case 'card-reply':
|
||||
case 'human-input':
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 提交放行 → 落地 的**用例链**(OS 7.3):这条链断过,而域测测不出来。
|
||||
*
|
||||
* 断的样子:活路径绕过 `submitForRelease` 直接发验收卡 → 意向永停 `drafted` →
|
||||
* `land()` 只执行 `prechecked` → **写类动作登记完永远不执行**,而每一步单看都"正常"。
|
||||
* 所以这里测的不是某个函数,是三件事的**接缝**:
|
||||
* ① 提交把意向从 drafted 推到 prechecked;
|
||||
* ② 卡面拿到的是**已验证**的动作清单(不是空数组);
|
||||
* ③ 落地只执行 prechecked,且用的幂等键与登记时是同一个。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ActionIntent } from '@contracts';
|
||||
import { taskIdSchema, staffIdSchema } from '@contracts';
|
||||
import { createReleaseService } from '../internal/usecase/release-service.ts';
|
||||
import type { ReleaseServiceDeps } from '../internal/usecase/release-service.ts';
|
||||
import { idempotencyKey } from '../internal/domain/precheck.ts';
|
||||
|
||||
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||
const OWNER = staffIdSchema.parse('22222222-2222-4222-8222-222222222222');
|
||||
|
||||
type Harness = {
|
||||
deps: ReleaseServiceDeps;
|
||||
intents: ActionIntent[];
|
||||
executed: Array<{ tool: string; idempotencyKey: string }>;
|
||||
cards: Array<{ criteria: unknown; actions: ReadonlyArray<{ label: string }>; fields: unknown }>;
|
||||
emitted: string[];
|
||||
};
|
||||
|
||||
function harness(over: { conflicts?: Array<{ field: string; key: Record<string, string> }>; invocable?: boolean } = {}): Harness {
|
||||
const intents: ActionIntent[] = [];
|
||||
const executed: Harness['executed'] = [];
|
||||
const cards: Harness['cards'] = [];
|
||||
const emitted: string[] = [];
|
||||
let seq = 0;
|
||||
|
||||
const deps: ReleaseServiceDeps = {
|
||||
intents: {
|
||||
save: async (intent) => void intents.push(intent),
|
||||
list: async () => intents,
|
||||
setState: async (id, state) => {
|
||||
const at = intents.findIndex((i) => i.id === id);
|
||||
if (at >= 0) intents[at] = { ...intents[at]!, state };
|
||||
},
|
||||
},
|
||||
ledger: {
|
||||
checkConflicts: async () => over.conflicts ?? [],
|
||||
releaseWrite: async () => ({
|
||||
mergedRows: 2,
|
||||
emittedEvents: 2,
|
||||
simulatedRows: 0,
|
||||
merged: [{ domain: 'd', object: 'o', key: { pn: 'X1' }, values: { type_tag: 'A' } }],
|
||||
}),
|
||||
},
|
||||
tools: {
|
||||
isInvocable: async () => ({ ok: over.invocable ?? true, ...(over.invocable === false ? { reason: '已停用' } : {}) }),
|
||||
validateInput: async () => ({ ok: true, errors: [] }),
|
||||
validateTarget: async () => undefined,
|
||||
execute: async (input) => {
|
||||
executed.push({ tool: input.tool, idempotencyKey: input.idempotencyKey });
|
||||
return { id: input.idempotencyKey, ok: true, retryable: false };
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
issueAcceptance: async (input) => {
|
||||
cards.push({ criteria: input.criteria, actions: input.actions, fields: input.fields });
|
||||
return { id: 'wo-1' };
|
||||
},
|
||||
issueLandingFailure: async () => ({ id: 'wo-fail' }),
|
||||
},
|
||||
tasks: {
|
||||
activityIdOf: async () => 'quote-intake',
|
||||
outputPortsOf: async () => ({ mandatory: ['intake'], selective: [] }),
|
||||
},
|
||||
ports: { emit: async (input) => void emitted.push(input.port) },
|
||||
memory: { append: async () => undefined },
|
||||
clock: { now: () => new Date('2026-07-30T00:00:00.000Z') },
|
||||
ids: { next: () => `id-${++seq}` },
|
||||
};
|
||||
return { deps, intents, executed, cards, emitted };
|
||||
}
|
||||
|
||||
const writeIntent = (id: string): Omit<ActionIntent, 'taskId' | 'state'> => ({
|
||||
id,
|
||||
tool: 'mail.send',
|
||||
input: { to: 'a@b.c' },
|
||||
idempotencyKey: idempotencyKey(TASK, id),
|
||||
label: `发报价邮件 ${id}`,
|
||||
detail: '对外发出正式报价单',
|
||||
});
|
||||
|
||||
const submit = (h: Harness) =>
|
||||
createReleaseService(h.deps).submitForRelease({
|
||||
taskId: TASK,
|
||||
ownerId: OWNER,
|
||||
humanCallCap: 4,
|
||||
deliverableId: 'intake-record',
|
||||
title: '收口验收',
|
||||
basis: [{ k: '提交理由', v: '必做项已齐' }],
|
||||
deliverables: [{ key: 'intake-record', label: '统一受理记录', present: true }],
|
||||
criteria: [{ key: 'typed', label: '每条都已打标', level: 'L2', pass: true }],
|
||||
});
|
||||
|
||||
describe('提交放行:预检 → 意向 prechecked → 已验证动作上卡', () => {
|
||||
it('登记的写类意向被推到 prechecked —— 不推,放行时就一个都不会执行', async () => {
|
||||
const h = harness();
|
||||
const release = createReleaseService(h.deps);
|
||||
await release.registerIntent(TASK, writeIntent('i-1'));
|
||||
expect(h.intents[0]?.state).toBe('drafted');
|
||||
|
||||
await submit(h);
|
||||
expect(h.intents[0]?.state).toBe('prechecked');
|
||||
});
|
||||
|
||||
it('卡面的动作清单 = 已验证的那份,不是空数组', async () => {
|
||||
const h = harness();
|
||||
const release = createReleaseService(h.deps);
|
||||
await release.registerIntent(TASK, writeIntent('i-1'));
|
||||
await submit(h);
|
||||
|
||||
expect(h.cards).toHaveLength(1);
|
||||
expect(h.cards[0]?.actions.map((a) => a.label)).toEqual(['发报价邮件 i-1']);
|
||||
// 判据是调用方按契约逐条标注的,不是硬编码单条 L5
|
||||
expect(h.cards[0]?.criteria).toEqual([{ label: '每条都已打标', level: 'L2', pass: true }]);
|
||||
});
|
||||
|
||||
/*
|
||||
* 「有没有产出」的证据必须是**本单自己写下的行**。装配层数的是 `taskDrafts`,
|
||||
* 不是任务视图 —— 视图 = 正式版 + 本单草稿,拿它去数,别单放行过的记录就成了自己的交付。
|
||||
* 实测踩过:一单什么都没写就提交放行,三件必交付物全判「有」,卡照发人照签;
|
||||
* 这道门于是只在第一单有效。这里守的是 release 侧的另一半:**说缺就必须拒**。
|
||||
*/
|
||||
it('必交付物缺一即拒,且**不发卡** —— 打回环内修正', async () => {
|
||||
const h = harness();
|
||||
const release = createReleaseService(h.deps);
|
||||
await expect(
|
||||
release.submitForRelease({
|
||||
taskId: TASK,
|
||||
ownerId: OWNER,
|
||||
humanCallCap: 4,
|
||||
deliverableId: 'intake-record',
|
||||
title: '收口验收',
|
||||
basis: [{ k: '理由', v: 'x' }],
|
||||
deliverables: [{ key: 'type-table', label: '料号分类表', present: false }],
|
||||
criteria: [],
|
||||
}),
|
||||
).rejects.toThrow(/料号分类表/);
|
||||
expect(h.cards).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('预检不过即拒,且不发卡、意向留在 drafted', async () => {
|
||||
const h = harness({ invocable: false });
|
||||
const release = createReleaseService(h.deps);
|
||||
await release.registerIntent(TASK, writeIntent('i-1'));
|
||||
await expect(submit(h)).rejects.toThrow(/已停用/);
|
||||
expect(h.cards).toHaveLength(0);
|
||||
expect(h.intents[0]?.state).toBe('drafted');
|
||||
});
|
||||
|
||||
it('他单改了同一字段 → 数据冲突,打回重拟', async () => {
|
||||
const h = harness({ conflicts: [{ field: 'target_price', key: { pn: 'X1' } }] });
|
||||
await expect(submit(h)).rejects.toThrow(/target_price/);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 可修订字段要**真的上卡**。
|
||||
*
|
||||
* `SubmitForReleaseInput.fields` 声明了却从来没被接线:调用方一路传下来的字段
|
||||
* 在 `issueAcceptance` 那一步被丢在地上,卡面 `fields` 恒空 —— 验收报告只能看不能改,
|
||||
* 人工修订形同虚设(第 12 章③)。**声明了不接线比不声明更坏:调用方以为传到了。**
|
||||
*/
|
||||
describe('可修订字段上卡 —— 验收报告「AI 已填好、可编辑」', () => {
|
||||
it('传了就上卡,带 AI 拟值与落回地址', async () => {
|
||||
const h = harness();
|
||||
await createReleaseService(h.deps).submitForRelease({
|
||||
taskId: TASK,
|
||||
ownerId: OWNER,
|
||||
humanCallCap: 4,
|
||||
deliverableId: 'intake-record',
|
||||
title: '收口验收',
|
||||
basis: [{ k: '提交理由', v: '齐了' }],
|
||||
deliverables: [{ key: 'intake-record', label: '统一受理记录', present: true }],
|
||||
criteria: [],
|
||||
fields: [{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' }],
|
||||
});
|
||||
expect(h.cards[0]?.fields).toEqual([
|
||||
{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('没传就不上 —— 只看不改也是一种合法形态,不硬塞空表单', async () => {
|
||||
const h = harness();
|
||||
await submit(h);
|
||||
expect(h.cards[0]?.fields).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('落地:只执行 prechecked,幂等键与登记时同一个', () => {
|
||||
it('走完提交再落地 —— 动作真的执行了', async () => {
|
||||
const h = harness();
|
||||
const release = createReleaseService(h.deps);
|
||||
await release.registerIntent(TASK, writeIntent('i-1'));
|
||||
await submit(h);
|
||||
|
||||
const outcome = await release.land({ taskId: TASK, ownerId: OWNER, humanCallCap: 4, selectedPorts: {} });
|
||||
expect(h.executed).toHaveLength(1);
|
||||
// 两侧同一把钥匙:登记时算的键,落地时用的键
|
||||
expect(h.executed[0]?.idempotencyKey).toBe(idempotencyKey(TASK, 'i-1'));
|
||||
expect(h.intents[0]?.state).toBe('landed');
|
||||
expect(outcome.emittedPorts).toEqual(['intake']);
|
||||
});
|
||||
|
||||
it('没过提交(意向仍 drafted)→ 落地一个都不执行 —— 这正是断链时的症状', async () => {
|
||||
const h = harness();
|
||||
const release = createReleaseService(h.deps);
|
||||
await release.registerIntent(TASK, writeIntent('i-1'));
|
||||
|
||||
await release.land({ taskId: TASK, ownerId: OWNER, humanCallCap: 4, selectedPorts: {} });
|
||||
expect(h.executed).toHaveLength(0);
|
||||
expect(h.intents[0]?.state).toBe('drafted');
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,11 @@ export type SubmitForReleaseInput = {
|
||||
readonly criteria: readonly CriterionCheck[];
|
||||
/** 报告可修订:AI 拟值已填的表单字段 */
|
||||
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
||||
/**
|
||||
* 验收页的正文块 —— 与执行工单同一套件,可含**绑台账的可编辑表**。
|
||||
* 验收是最重的一道门:人得能在成果本身上改,不只是改几个整单级字段。
|
||||
*/
|
||||
readonly blocks?: ReadonlyArray<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type SubmitOutcome = {
|
||||
|
||||
@@ -50,7 +50,16 @@ export function summarize(
|
||||
/* ── 必交付物与判据 ─────────────────────────────────────────────────── */
|
||||
|
||||
export type DeliverableCheck = { readonly key: string; readonly label: string; readonly present: boolean };
|
||||
export type CriterionCheck = { readonly key: string; readonly label: string; readonly level: AcceptanceLevel; readonly pass: boolean };
|
||||
export type CriterionCheck = {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly level: AcceptanceLevel;
|
||||
readonly pass: boolean;
|
||||
/** 裁量项(L3/L4)裁过没有 —— 机检项与 L5 不带这一维 */
|
||||
readonly settled?: boolean | undefined;
|
||||
/** 裁断的理由:裁成「不满足」时,这句话才是签字的人要看的东西 */
|
||||
readonly rationale?: string | undefined;
|
||||
};
|
||||
|
||||
/** 必交付物**缺一不得提交放行**(AM 01) */
|
||||
export function missingDeliverables(checks: readonly DeliverableCheck[]): DeliverableCheck[] {
|
||||
|
||||
@@ -132,9 +132,31 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
|
||||
title: input.title,
|
||||
basis: input.basis,
|
||||
deliverable: input.deliverableId,
|
||||
criteria: input.criteria.map((c) => ({ label: c.label, level: c.level, pass: c.pass })),
|
||||
criteria: input.criteria.map((c) => ({
|
||||
label: c.label,
|
||||
level: c.level,
|
||||
pass: c.pass,
|
||||
// 裁量项那一维原样带上卡:裁过没有、凭什么 —— 丢一样,人就只能凭感觉签
|
||||
...(c.settled === undefined ? {} : { settled: c.settled }),
|
||||
...(c.rationale === undefined ? {} : { rationale: c.rationale }),
|
||||
})),
|
||||
// 卡面呈现的即**已验证**的动作清单
|
||||
actions: report.verified.map((v) => ({ label: v.label, effect: v.effect, detail: v.detail })),
|
||||
/*
|
||||
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们。
|
||||
*
|
||||
* 这一行从前**没有**:`SubmitForReleaseInput.fields` 声明了却从来没被用过,
|
||||
* 调用方一路传下来的字段在这里被丢在地上,于是卡面 `fields` 恒空 ——
|
||||
* 报告只能看不能改,人工修订形同虚设(第 12 章③)。
|
||||
* 声明了不接线比不声明更坏:调用方以为传到了。
|
||||
*/
|
||||
...(input.fields === undefined || input.fields.length === 0 ? {} : { fields: input.fields }),
|
||||
/*
|
||||
* 验收页的正文块 —— 绑台账的可编辑表就走这里。
|
||||
* 人在这一屏上改的是**成果本身**(那份清单),不是旁边几个整单级字段;
|
||||
* 改完由基座写回草稿层,放行时随之合并 —— AI 不经手。
|
||||
*/
|
||||
...(input.blocks === undefined || input.blocks.length === 0 ? {} : { blocks: input.blocks }),
|
||||
});
|
||||
|
||||
return { report, workOrderId: card.id };
|
||||
@@ -146,10 +168,31 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
|
||||
|
||||
// 放行写:草稿层合并入正式版(原子,过冲突检查),此刻才产生数据事件
|
||||
const merged = await deps.ledger.releaseWrite(input.taskId);
|
||||
/*
|
||||
* **落地结果要留成结构化的**(2026-08-02 用户:「完成验收后反而要把落地结果展示出来」)。
|
||||
*
|
||||
* 从前只有标题那一句话,人要知道「这一签下去到底发生了什么」,得去读日志、
|
||||
* 再自己把「合并 9 行」拆成哪几张表各几行。那是让人替系统做归并。
|
||||
* 逐表计数落进 `detail`,验收卡办结之后照它摆一屏 —— 摆的是事实,不是转述。
|
||||
*/
|
||||
const byTable = new Map<string, number>();
|
||||
for (const row of merged.merged) {
|
||||
const at = `${row.domain}.${row.object}`;
|
||||
byTable.set(at, (byTable.get(at) ?? 0) + 1);
|
||||
}
|
||||
await deps.memory.append({
|
||||
taskId: input.taskId,
|
||||
kind: 'ledger-write',
|
||||
title: `放行写合并 ${merged.mergedRows} 行`,
|
||||
// 试跑挡下的那几行要写在痕上 —— 只报「合并 N 行」而其中几行没进表,那句话是假的
|
||||
title:
|
||||
merged.simulatedRows === 0
|
||||
? `放行写合并 ${merged.mergedRows} 行`
|
||||
: `放行写合并 ${merged.mergedRows} 行(试跑:另有 ${merged.simulatedRows} 行主数据只留在活动缓存,未落正式表)`,
|
||||
detail: {
|
||||
rows: merged.mergedRows,
|
||||
simulated: merged.simulatedRows,
|
||||
tables: [...byTable].map(([address, rows]) => ({ address, rows })),
|
||||
},
|
||||
});
|
||||
|
||||
// 外部动作意向执行,附幂等键;已成动作留存不回滚
|
||||
@@ -238,6 +281,13 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
|
||||
taskId: input.taskId,
|
||||
kind: 'release-verdict',
|
||||
title: `落地收敛,发射输出口:${emitted.join('、') || '(无)'}`,
|
||||
/*
|
||||
* 发了哪几口、**带了哪些字段过去** —— 验收卡办结后照它摆
|
||||
* 「这一单转去哪儿了、带了什么」(2026-08-02 用户定)。
|
||||
* 载荷就是已落地的事实,所以这里记的是它的键;值不记 —— 值在成果件里,
|
||||
* 那一屏答的是「带了哪些东西」,不是把数据再抄一遍。
|
||||
*/
|
||||
detail: { ports: [...emitted], carried: Object.keys(landedFacts).slice(0, 40) },
|
||||
});
|
||||
|
||||
return { verdict, emittedPorts: emitted, ledgerRowsMerged: merged.mergedRows, merged: merged.merged };
|
||||
|
||||
@@ -19,6 +19,8 @@ export type LedgerGateway = {
|
||||
releaseWrite(taskId: TaskId): Promise<{
|
||||
mergedRows: number;
|
||||
emittedEvents: number;
|
||||
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
|
||||
simulatedRows: number;
|
||||
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
|
||||
merged: ReadonlyArray<{
|
||||
domain: string;
|
||||
@@ -61,8 +63,32 @@ export type CardGateway = {
|
||||
title: string;
|
||||
basis: ReadonlyArray<{ k: string; v: string }>;
|
||||
deliverable: string;
|
||||
criteria: ReadonlyArray<{ label: string; level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5'; pass: boolean }>;
|
||||
/*
|
||||
* `settled` / `rationale` 是**裁量项**那一维:裁过没有、裁断的理由。
|
||||
* 「裁了但办不到」与「压根没裁」在卡上不能长成同一个红叉 —— 前者是把判断交给人,
|
||||
* 后者是活没干完;而理由那一句正是签字的人要看的东西。
|
||||
*/
|
||||
criteria: ReadonlyArray<{
|
||||
label: string;
|
||||
level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5';
|
||||
pass: boolean;
|
||||
settled?: boolean | undefined;
|
||||
rationale?: string | undefined;
|
||||
}>;
|
||||
actions: ReadonlyArray<{ label: string; effect: 'read' | 'write'; detail: string }>;
|
||||
/**
|
||||
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
||||
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
||||
* 不做多余属性检查,于是编译不报、运行时静默丢 —— 卡面 `fields` 恒空(踩过)。
|
||||
*/
|
||||
fields?: ReadonlyArray<Record<string, unknown>>;
|
||||
/**
|
||||
* 验收页的正文块 —— 绑台账的可编辑成果表走这里。
|
||||
* **端口不声明它,值就在装配层被丢掉**(与 `fields` 同一个坑,又踩了一次:
|
||||
* 契约声明了、submit 传了、release 转了,到装配层那个显式对象里被抹平,
|
||||
* 编译不报、运行时静默丢 —— 卡面 blocks 恒空)。
|
||||
*/
|
||||
blocks?: ReadonlyArray<Record<string, unknown>>;
|
||||
}): Promise<{ id: string }>;
|
||||
issueLandingFailure(input: {
|
||||
taskId: TaskId;
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
port 注入:memory / event / message / interaction / ledger
|
||||
被依赖: process, org(停用前置检查的在途任务), report, app
|
||||
|
||||
近变(2026-07-30):`Task` / `StartTaskInput` 加 `origin?`(`testbed` = 设计台试跑)+
|
||||
`sys.task.origin` 列。**认得出试跑单才拔得掉** —— 工作流那侧靠
|
||||
`source->>'ref'='testbed'` 认,任务这侧从前没有对应记号:
|
||||
「试跑一个活动」起出来的单(不挂工作流)与登记直起单长得一模一样。
|
||||
|
||||
不变量(OS 04 / 4.3,AM 10 / 12.1):
|
||||
- **受理归一**(2026-07-30 宽进,替代旧 AM 12.1「缺项不得启动」):输入可以不定或为空 ——
|
||||
缺必填不拒单,任务落成 `awaiting-human`,缺的字段做成补全卡(`IntakeDesk` 端口)发给
|
||||
|
||||
@@ -76,6 +76,7 @@ function harness(): Harness {
|
||||
asked.push({ fields: input.fields.map((f) => ({ key: f.key, label: f.label })) });
|
||||
},
|
||||
},
|
||||
comms: { record: async () => undefined },
|
||||
clock: { now: () => new Date('2026-07-30T00:00:00.000Z') },
|
||||
ids: { next: () => `t-${(seq += 1)}` },
|
||||
};
|
||||
|
||||
@@ -92,6 +92,7 @@ function harness(land: () => Promise<never> | Promise<{ converged: boolean; emit
|
||||
notifier: { notify: async () => undefined },
|
||||
interaction: { setPhase: async () => undefined },
|
||||
intake: { askCompletion: async () => undefined },
|
||||
comms: { record: async () => undefined },
|
||||
clock: { now: () => new Date('2026-07-29T00:00:02.000Z') },
|
||||
ids: { next: () => 'x' },
|
||||
};
|
||||
|
||||
@@ -52,6 +52,8 @@ export type Task = {
|
||||
/** 起单来源:工作流节点;登记触发的直起单为 undefined */
|
||||
readonly workflowId?: string;
|
||||
readonly nodeId?: string;
|
||||
/** `testbed` = 设计台试跑 —— 认得出才拔得掉(仿真不该在别人工作台上过夜) */
|
||||
readonly origin?: string;
|
||||
readonly ownerId: StaffId;
|
||||
readonly title: string;
|
||||
readonly runtime: TaskRuntimeState;
|
||||
@@ -73,6 +75,7 @@ export type StartTaskInput = {
|
||||
readonly title: string;
|
||||
readonly workflowId?: string;
|
||||
readonly nodeId?: string;
|
||||
readonly origin?: string;
|
||||
/** 输入:data(按 schema 校验,缺项不得启动)+ message(委托消息,可为空) */
|
||||
readonly data: Record<string, unknown>;
|
||||
readonly message?: string;
|
||||
|
||||
@@ -11,6 +11,11 @@ export const taskTable = sys.table(
|
||||
activityId: text('activity_id').notNull(),
|
||||
workflowId: text('workflow_id'),
|
||||
nodeId: text('node_id'),
|
||||
/**
|
||||
* 起单来源:`testbed` = 设计台试跑,其余留空。
|
||||
* 认得出试跑单,才拔得掉 —— 仿真不是生产,它不该在别人工作台上过夜。
|
||||
*/
|
||||
origin: text('origin'),
|
||||
/** 恰有一名负责人 */
|
||||
ownerId: text('owner_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
@@ -34,6 +39,7 @@ export const taskTable = sys.table(
|
||||
(t) => [
|
||||
index('task_owner_idx').on(t.ownerId, t.runtime),
|
||||
index('task_workflow_idx').on(t.workflowId),
|
||||
index('task_origin_idx').on(t.origin),
|
||||
index('task_terminal_idx').on(t.terminal),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ function toTask(row: Row): Task {
|
||||
id: row.id as TaskId,
|
||||
activityId: row.activityId,
|
||||
...(row.workflowId === null ? {} : { workflowId: row.workflowId }),
|
||||
...(row.origin === null ? {} : { origin: row.origin }),
|
||||
...(row.nodeId === null ? {} : { nodeId: row.nodeId }),
|
||||
ownerId: row.ownerId as StaffId,
|
||||
title: row.title,
|
||||
@@ -43,6 +44,7 @@ export function createTaskStore(db: Db): TaskStore {
|
||||
id: task.id,
|
||||
activityId: task.activityId,
|
||||
workflowId: task.workflowId ?? null,
|
||||
origin: task.origin ?? null,
|
||||
nodeId: task.nodeId ?? null,
|
||||
ownerId: task.ownerId,
|
||||
title: task.title,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { StartTaskInput, Task, TaskService, TickOutcome } from '../../contr
|
||||
import { TaskAlreadyTerminalError, TaskNotFoundError } from '../../contract.ts';
|
||||
import type {
|
||||
ActivityLoader,
|
||||
CommRecorder,
|
||||
Clock,
|
||||
IdGenerator,
|
||||
IntakeDesk,
|
||||
@@ -45,6 +46,8 @@ export type TaskServiceDeps = {
|
||||
* 并武装 human 监听等答复 —— 任务先「等人」,补齐了才开跑。
|
||||
*/
|
||||
readonly intake: IntakeDesk;
|
||||
/** 沟通记录 —— 这一单是被一封信叫起来的话,起单那一刻把来件归档 */
|
||||
readonly comms: CommRecorder;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
@@ -101,6 +104,7 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
activityId: input.activityId,
|
||||
...(input.workflowId === undefined ? {} : { workflowId: input.workflowId }),
|
||||
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
|
||||
...(input.origin === undefined ? {} : { origin: input.origin }),
|
||||
ownerId: input.ownerId,
|
||||
title: input.title,
|
||||
runtime: missing.length > 0 ? 'awaiting-human' : 'running',
|
||||
@@ -118,6 +122,44 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
// message 计入初始观察、全程留痕;**不得扩大授权边界**
|
||||
detail: { data: input.data, message: input.message, envelope: loaded.envelope },
|
||||
});
|
||||
/*
|
||||
* **来件先归档,再开跑** —— 顺序有意义:人一点开这一单,沟通记录里就已经躺着
|
||||
* 那封信;等模型跑几拍之后再补,那几十秒里人看到的是「这单从哪来的?不知道」。
|
||||
* 声明了才归(`contract.input.inboundComm`);没声明的活动这一步整个不发生。
|
||||
*/
|
||||
const inbound = loaded.inboundComm;
|
||||
if (inbound !== undefined) {
|
||||
const pick = (field: string | undefined): unknown => (field === undefined ? undefined : input.data[field]);
|
||||
const attachments = pick(inbound.attachmentsField);
|
||||
await deps.comms.record({
|
||||
taskId: task.id,
|
||||
direction: 'in',
|
||||
channel: inbound.channel,
|
||||
party: String(pick(inbound.partyField) ?? ''),
|
||||
subject: String(pick(inbound.subjectField) ?? task.title),
|
||||
body: String(pick(inbound.bodyField) ?? ''),
|
||||
attachments: Array.isArray(attachments) ? attachments : [],
|
||||
note: '开案来件 —— 这一单就是被它叫起来的',
|
||||
});
|
||||
/*
|
||||
* **对话流的第一句话由系统说**(2026-07-31 用户定)。
|
||||
*
|
||||
* 从前人点开这一单,看到的第一句是 AI 的解读 —— 而他还不知道
|
||||
* 「这单从哪来的、为什么派给我」。那两件事不该由 AI 顺口交代:
|
||||
* 它们是**系统的事实**(一封信触发了这条流,系统把这一环指给了你),
|
||||
* AI 说出来反而像它在自我介绍。
|
||||
*
|
||||
* 带一个「查看邮件」的去处:原件就在沟通记录里,一步跳过去。
|
||||
* **点不点都不挡路** —— 这条消息之后 AI 照常开始解读,不等人按。
|
||||
*/
|
||||
await deps.memory.append({
|
||||
taskId: task.id,
|
||||
// **触发事件**:这一单是被这封信叫起来的 —— 不是机器产出,也不是谁的动作
|
||||
kind: 'trigger',
|
||||
title: `一封邮件触发了这条流程,系统指定你负责「${task.title}」。`,
|
||||
detail: { eventType: 'data', openComms: true },
|
||||
});
|
||||
}
|
||||
await deps.engine.load(task.id, loaded.businessModel);
|
||||
await deps.interaction.setPhase(task.id, 'active');
|
||||
if (missing.length > 0) {
|
||||
@@ -163,6 +205,23 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
return { task: finished, verdict, progressed: false };
|
||||
}
|
||||
|
||||
/*
|
||||
* **先把「在办事」写进投影,再开跑**(2026-07-31 修)。
|
||||
*
|
||||
* 一拍要跑好几秒 —— 模型在算。而这一列从前是**整拍跑完才写**(见下面第 224 行),
|
||||
* 于是被人唤醒的那一单,这几秒里投影仍停在 `awaiting-event`:
|
||||
* 人点完数据录入卡,对话区四秒一动不动(实测 4.3 秒),他以为没点上、再点一次。
|
||||
* 引擎明明在算,投影却说在等外部 —— 界面不撒谎,那这一列就得在开跑前写。
|
||||
*
|
||||
* `setPhase` 顺带广播(interaction 的 onPush → broadcaster),浏览器立刻重取。
|
||||
* 一拍跑完照旧按 outcome 写回真实状态;若这一拍抛了,单停在 running,
|
||||
* 推进器下一轮会再推它一次 —— 那本来就是该做的事(重试),不是卡死。
|
||||
*/
|
||||
if (task.runtime !== 'running') {
|
||||
await deps.store.update(taskId, { runtime: 'running' });
|
||||
await deps.interaction.setPhase(taskId, 'active');
|
||||
}
|
||||
|
||||
const startedAt = deps.clock.now().getTime();
|
||||
const outcome = await deps.engine.advance(taskId);
|
||||
// 步计量:loop 的一次决策循环 / flow 的一个声明步。
|
||||
@@ -246,11 +305,20 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
const task = await require(taskId);
|
||||
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
|
||||
|
||||
/*
|
||||
* 投递痕只记**这一条**(标题格式与对话流投影的过滤约定一致:human 的不进对话区)。
|
||||
* 从前 loop 的 onEvent 还会再记一条「事件到达(kind):ref」—— 同一次回传在日志里
|
||||
* 三行几乎一样(卡回传 + 两层各一条到达),人看着像系统在结巴。
|
||||
*/
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: `事件到达:${event.kind}/${event.ref}`,
|
||||
detail: event.payload,
|
||||
title: `事件到达(${event.kind}):${event.ref}`,
|
||||
/*
|
||||
* `kind` 要进 detail:对话区与工作日志都靠它认这是哪一类到达。
|
||||
* 从前只有 payload,两处只能去抠标题前缀 —— 标题是给人看的、随时会改。
|
||||
*/
|
||||
detail: { ...event.payload, kind: event.kind },
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -25,11 +25,36 @@ export type ActivityLoader = {
|
||||
envelope: Record<string, unknown>;
|
||||
/** 输入 data 的 JSON Schema —— 缺项不得启动 */
|
||||
inputSchema: Record<string, unknown>;
|
||||
/**
|
||||
* 这一单是被一封信叫起来的 —— 活动清单声明的**来件字段映射**。
|
||||
* 声明了,起单那一刻就把来件归进沟通记录(工作台那一屏)。
|
||||
*/
|
||||
inboundComm?: {
|
||||
channel: string;
|
||||
partyField: string;
|
||||
subjectField?: string | undefined;
|
||||
bodyField?: string | undefined;
|
||||
attachmentsField?: string | undefined;
|
||||
};
|
||||
businessModel: unknown;
|
||||
stallAfter: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** 沟通记录 —— 一单往来的原件存档(工作台「沟通记录」那一屏读它) */
|
||||
export type CommRecorder = {
|
||||
record(input: {
|
||||
taskId: TaskId;
|
||||
direction: 'in' | 'out';
|
||||
channel: string;
|
||||
party: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
attachments: readonly unknown[];
|
||||
note: string;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
export type SchemaValidator = {
|
||||
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
|
||||
};
|
||||
@@ -76,8 +101,11 @@ export type LandingGateway = {
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
/** control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧 */
|
||||
kind: 'engine-transition' | 'terminal' | 'observation' | 'control';
|
||||
/**
|
||||
* control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧。
|
||||
* trigger = 触发事件:这一单被什么叫起来的 —— 不是机器产出,也不是谁的动作。
|
||||
*/
|
||||
kind: 'engine-transition' | 'terminal' | 'observation' | 'control' | 'trigger';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user