Archived
人手改的那一手真正落得下去;界面不撒谎那几处补齐
手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的: 件上就地改 · 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名, 于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 —— 而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。 · **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。 删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)—— 「没回传就是删了」会把一次不完整的回传变成一次删表。 · **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。 · 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。 判据与门 · 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来, 人填好的目标价它照样判「客户未提供」的毛病由此消掉。 · 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。 · 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开, 于是一单没打标就进了放行门。三个数全由基座现数。 · 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。 **指向两扇打不开的门,比不指还糟。** 界面不撒谎 · 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。 · 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、 往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。 · 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。 · 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。 · 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。 A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。 五道门禁全绿(971 passed)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
// 进入等待或终结就停手 —— 挂起在等的任务由事件唤醒,不空转
|
||||
|
||||
Reference in New Issue
Block a user