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:
@@ -23,6 +23,11 @@
|
||||
依赖: @contracts;@shared/db;port 注入:toolbox(L2)、llm(L1)、ledger(L1)、interaction(L3)
|
||||
被依赖: loop-engine, flow-engine, skill-engine, task, process, app(治理台 · 活动库管理)
|
||||
|
||||
近变(2026-07-30):`list()` **不再列出全部版本都已卸载的活动**,`uninstall` 同时置
|
||||
`enabled=false`。卸载只把版本状态置 `uninstalled`(版本行留着,在途任务按启动版跑完),
|
||||
但活动库照旧摆着它就是撒谎 —— 实测:被真活动包顶替的槽位卸载后仍在架上,
|
||||
与顶替它的那个**同名并排**,试跑台上选错一个就试了个空壳。
|
||||
|
||||
不变量(OS 11.2 / 11.3,AM 11.3):
|
||||
- **安装两审**:静态审有 error 即不上架;「旧成不坏」是**生效硬门**,
|
||||
「旧败能成」只入重演报告、不作门槛;首装无历史即免回放审
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 判据谓词的名字必须在台账上 —— 安装期就挡下,别等跑到放行那一刻才炸。
|
||||
*
|
||||
* 这条门是拿血换来的:A2/A3/A4/A6/A7 五个活动的机检判据全都引用了
|
||||
* 台账里根本不存在的名字(`candidatesConfirmed` / `unquoted` / `unpriced`
|
||||
* / `missingQuoteFields` / `reasonLogged` ……)。S-FEEL 对未绑定的名字求值为假,
|
||||
* 于是这些判据**永远达不了标**:Agent 把活办得再干净也过不了 submit 门,
|
||||
* 连拒五拍熔断 —— MVP 整条流三轮都卡死在 A2,而清单本身语法挑不出毛病。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ActivityManifest } from '@contracts';
|
||||
|
||||
/* 两条审读的入参已在 domain 收窄到字段级 —— 这里直接用它,不拼假清单 */
|
||||
type PredicateInput = Parameters<typeof acceptanceLevelFindings>[0];
|
||||
type LedgerTable = ActivityManifest['declarations']['ledger'][number];
|
||||
type Deliverable = ActivityManifest['contract']['requiredDeliverables'][number];
|
||||
import { acceptanceLevelFindings } from '../internal/domain/static-audit.ts';
|
||||
|
||||
/** 一份最小可用清单 —— 只摆本测试关心的三处:判据 / 台账 / 案卷 */
|
||||
function manifest(over: {
|
||||
predicate?: string | undefined;
|
||||
level?: 'L1' | 'L2' | 'L3';
|
||||
fields?: string[];
|
||||
caseItems?: string[];
|
||||
inputProps?: string[];
|
||||
}): PredicateInput {
|
||||
const fields = over.fields ?? ['line_total'];
|
||||
const caseItems = over.caseItems ?? [];
|
||||
const inputProps = over.inputProps ?? [];
|
||||
return {
|
||||
contract: {
|
||||
input: {
|
||||
data: {
|
||||
type: 'object',
|
||||
properties: Object.fromEntries(inputProps.map((k) => [k, { type: 'string' }])),
|
||||
},
|
||||
},
|
||||
acceptanceCriteria: [
|
||||
{
|
||||
key: 'k',
|
||||
label: '测试判据',
|
||||
level: over.level ?? 'L2',
|
||||
...(over.predicate === undefined ? {} : { predicate: over.predicate }),
|
||||
},
|
||||
],
|
||||
},
|
||||
declarations: {
|
||||
ledger: [
|
||||
{
|
||||
domain: 'd',
|
||||
object: 'record',
|
||||
kind: 'record' as const,
|
||||
caseKey: 'rfq_ref',
|
||||
label: '表',
|
||||
fields: fields.map((name) => ({
|
||||
name,
|
||||
type: 'integer' as const,
|
||||
primaryKey: false,
|
||||
masked: false,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
assets: {
|
||||
businessModel: {
|
||||
cmmnXml: caseItems.map((id) => `<caseFileItem id="${id}" name="x"/>`).join(''),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const codes = (m: PredicateInput) => acceptanceLevelFindings(m).map((f) => f.code);
|
||||
|
||||
describe('判据谓词的名字必须绑得上', () => {
|
||||
it('台账有这一列 —— 放行', () => {
|
||||
expect(codes(manifest({ predicate: 'line_total > 0' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('台账没有这个名字 —— 拒装', () => {
|
||||
const found = acceptanceLevelFindings(manifest({ predicate: 'candidatesConfirmed >= 2' }));
|
||||
expect(found.map((f) => f.code)).toContain('acceptance-predicate-unbound');
|
||||
// 报错要指名道姓:说清是哪个名字绑不上,不然改的人还得自己猜
|
||||
expect(found[0]?.message).toContain('candidatesConfirmed');
|
||||
});
|
||||
|
||||
it('案卷项也算绑得上 —— 案卷项 id 就是任务视图里的键名', () => {
|
||||
expect(codes(manifest({ predicate: 'benchmark_total > 0', caseItems: ['benchmark_total'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('起单入参也算绑得上', () => {
|
||||
expect(codes(manifest({ predicate: 'eau > 0', inputProps: ['eau'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('两个名字里错一个,也要报出那一个', () => {
|
||||
const found = acceptanceLevelFindings(
|
||||
manifest({ predicate: 'alt_request_total = type_c_total', fields: ['alt_request_total'] }),
|
||||
);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]?.message).toContain('type_c_total');
|
||||
});
|
||||
|
||||
it('保留字与字面量不当作台账名', () => {
|
||||
expect(codes(manifest({ predicate: 'need_sourcing = true and line_total > 0', fields: ['need_sourcing', 'line_total'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('字符串字面量里的词不当作台账名', () => {
|
||||
expect(codes(manifest({ predicate: 'disposition = "照原价报"', fields: ['disposition'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('L3 不机检 —— 没谓词也不报', () => {
|
||||
expect(codes(manifest({ level: 'L3', predicate: undefined }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('L2 没谓词照旧报等级谎报,且不误报绑定', () => {
|
||||
const found = codes(manifest({ level: 'L2', predicate: undefined }));
|
||||
expect(found).toEqual(['acceptance-level-misreported']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 必交付物必须给产出地址 —— 不给的话放行门永远拒。
|
||||
*
|
||||
* 预检数的是「本单在这张表上写了几行」;地址不给,它退回拿 key 去任务视图里找,
|
||||
* 而 key 是 `benchmark-table` 这样的短横线别名 —— 视图里永远没有这个键。
|
||||
* A2/A3/A4/A6/A7 五个活动全没给,活办完了也放不了行(实测 A2 死于此)。
|
||||
*/
|
||||
import { deliverableAddressFindings } from '../internal/domain/static-audit.ts';
|
||||
|
||||
type DeliverableInput = Parameters<typeof deliverableAddressFindings>[0];
|
||||
|
||||
const LEDGER: LedgerTable[] = [
|
||||
{ domain: 'quote_alternative', object: 'benchmark', kind: 'record' as const, caseKey: 'rfq_ref', label: '表', fields: [] },
|
||||
];
|
||||
|
||||
function withDeliverables(items: Deliverable[]): DeliverableInput {
|
||||
return { contract: { requiredDeliverables: items }, declarations: { ledger: LEDGER } };
|
||||
}
|
||||
|
||||
describe('必交付物的产出地址', () => {
|
||||
it('地址指向真实台账表 —— 放行', () => {
|
||||
const found = deliverableAddressFindings(
|
||||
withDeliverables([{ key: 'benchmark-table', label: '对标表', ledger: 'quote_alternative.benchmark' }]),
|
||||
);
|
||||
expect(found).toEqual([]);
|
||||
});
|
||||
|
||||
it('没给地址 —— 拒装', () => {
|
||||
const found = deliverableAddressFindings(withDeliverables([{ key: 'benchmark-table', label: '对标表' }]));
|
||||
expect(found.map((f) => f.code)).toEqual(['deliverable-address-unbound']);
|
||||
expect(found[0]?.message).toContain('对标表');
|
||||
});
|
||||
|
||||
it('地址指向不存在的表 —— 拒装,并把声明过的表摆出来', () => {
|
||||
const found = deliverableAddressFindings(
|
||||
withDeliverables([{ key: 'k', label: '对标表', ledger: 'quote_alternative.nope' }]),
|
||||
);
|
||||
expect(found.map((f) => f.code)).toEqual(['deliverable-address-unbound']);
|
||||
expect(found[0]?.message).toContain('quote_alternative.benchmark');
|
||||
});
|
||||
|
||||
it('多件里错一件,只报那一件', () => {
|
||||
const found = deliverableAddressFindings(
|
||||
withDeliverables([
|
||||
{ key: 'a', label: '对标表', ledger: 'quote_alternative.benchmark' },
|
||||
{ key: 'b', label: '初筛清单' },
|
||||
]),
|
||||
);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]?.message).toContain('初筛清单');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 台账性质要核得住 —— 这条门是拿一次真实的数据丢失换来的。
|
||||
*
|
||||
* A4 的报价草案本该一单一份,主键却只有 `pn`:第二单报同一个料号就 upsert 覆盖上一单。
|
||||
* 跑了四轮 A4,`quote_pricing.draft` 只剩最后一轮的 3 行;
|
||||
* 而主键带单号的 `quote_intake.record` 是 61 行 / 20 单,一单不少。
|
||||
* **作者没决定这张表是单据还是主数据,主键就写歪了** —— 而清单语法挑不出毛病。
|
||||
*/
|
||||
import { ledgerKindFindings } from '../internal/domain/static-audit.ts';
|
||||
|
||||
type LedgerInput = Parameters<typeof ledgerKindFindings>[0];
|
||||
type Kind = 'record' | 'master' | 'derived';
|
||||
|
||||
function table(over: { kind?: Kind; caseKey?: string; pk?: string[]; cols?: string[] }): LedgerInput {
|
||||
const cols = over.cols ?? ['rfq_ref', 'pn'];
|
||||
const pk = over.pk ?? ['rfq_ref', 'pn'];
|
||||
return {
|
||||
declarations: {
|
||||
ledger: [
|
||||
{
|
||||
domain: 'd',
|
||||
object: 'o',
|
||||
label: '表',
|
||||
kind: over.kind ?? 'record',
|
||||
...(over.caseKey === undefined ? {} : { caseKey: over.caseKey }),
|
||||
fields: cols.map((name) => ({
|
||||
name,
|
||||
type: 'text' as const,
|
||||
primaryKey: pk.includes(name),
|
||||
masked: false,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const kindCodes = (m: LedgerInput) => ledgerKindFindings(m).map((f) => f.code);
|
||||
|
||||
describe('台账性质', () => {
|
||||
it('单据:指明单标识且它在主键里 —— 放行', () => {
|
||||
expect(kindCodes(table({ kind: 'record', caseKey: 'rfq_ref' }))).toEqual([]);
|
||||
});
|
||||
|
||||
/* 这一条就是 A4 那次数据丢失的机检形态 */
|
||||
it('单据没指明单标识 —— 拒装', () => {
|
||||
const found = ledgerKindFindings(table({ kind: 'record', cols: ['pn'], pk: ['pn'] }));
|
||||
expect(found.map((f) => f.code)).toContain('ledger-kind-invalid');
|
||||
expect(found[0]?.message).toContain('caseKey');
|
||||
});
|
||||
|
||||
it('单标识不在主键里 —— 拒装(单标识不进主键等于没有)', () => {
|
||||
const found = ledgerKindFindings(table({ kind: 'record', caseKey: 'rfq_ref', pk: ['pn'] }));
|
||||
expect(found.map((f) => f.code)).toEqual(['ledger-kind-invalid']);
|
||||
expect(found[0]?.message).toContain('不是主键的一部分');
|
||||
});
|
||||
|
||||
it('单标识不在自己字段里 —— 拒装', () => {
|
||||
const found = ledgerKindFindings(table({ kind: 'record', caseKey: 'nope' }));
|
||||
expect(found[0]?.message).toContain('不在它自己的字段里');
|
||||
});
|
||||
|
||||
it('主数据:按业务实体分行,不要单标识 —— 放行', () => {
|
||||
expect(kindCodes(table({ kind: 'master', cols: ['customer_code'], pk: ['customer_code'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('主数据却带着单标识 —— 拒装:它多半其实是单据,归错了类', () => {
|
||||
const found = ledgerKindFindings(table({ kind: 'master', caseKey: 'rfq_ref' }));
|
||||
expect(found.map((f) => f.code)).toEqual(['ledger-kind-invalid']);
|
||||
expect(found[0]?.message).toContain('归错了类');
|
||||
});
|
||||
|
||||
it('派生:按周期分行,不要单标识 —— 放行', () => {
|
||||
expect(kindCodes(table({ kind: 'derived', cols: ['period'], pk: ['period'] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('一个主键列都没有 —— 拒装:合并与去重无从谈起', () => {
|
||||
const found = ledgerKindFindings(table({ kind: 'master', cols: ['a'], pk: [] }));
|
||||
expect(found[0]?.message).toContain('主键列');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 行数读数 `域_对象_rows` —— 基座数出来的那一边。
|
||||
*
|
||||
* 它不是活动写得出的列,却必须是**绑得上的名字**:判据的两边不能都由活动自报,
|
||||
* 否则活动数错一次就两边一起错、一起「对上」(实测:条目数报 9、表里躺着 10,
|
||||
* 派单数与三类条数同源同错,`2 = 2` 顺利过关)。
|
||||
* 但也不能一律放行 —— 对象名手滑写错,照旧要在装的时候拒。
|
||||
*/
|
||||
describe('行数读数认得出,写错的照旧拒装', () => {
|
||||
it('声明过的表 → `域_对象_rows` 绑得上', () => {
|
||||
expect(codes(manifest({ predicate: 'd_record_rows = line_total' }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('没声明过的表 → 照旧拒装(手滑一个字母,判据就永远达不了标)', () => {
|
||||
expect(codes(manifest({ predicate: 'd_recrod_rows = line_total' }))).toContain('acceptance-predicate-unbound');
|
||||
});
|
||||
});
|
||||
@@ -32,8 +32,11 @@ export type {
|
||||
export type { RegistryServiceDeps } from './internal/usecase/registry-service.ts';
|
||||
export { createRegistryService } from './internal/usecase/registry-service.ts';
|
||||
|
||||
export type { AcceptanceAuditInput, DeliverableAuditInput } from './internal/domain/static-audit.ts';
|
||||
export {
|
||||
acceptanceLevelFindings,
|
||||
deliverableAddressFindings,
|
||||
ledgerKindFindings,
|
||||
auditPassed,
|
||||
danglingListeners,
|
||||
loopFindings,
|
||||
|
||||
@@ -28,6 +28,26 @@ export type ExternalChecks = {
|
||||
readonly sdkCompatible: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 两条清单审读**只读清单的一小块** —— 参数就照那一小块声明。
|
||||
* 收窄不是为了测试好写:它把「这条审读看的是什么」写进类型里,
|
||||
* 传全份清单照旧过(结构子类型),而读了什么一目了然。
|
||||
*/
|
||||
export type AcceptanceAuditInput = {
|
||||
readonly contract: Pick<ActivityManifest['contract'], 'acceptanceCriteria'> & {
|
||||
readonly input: Pick<ActivityManifest['contract']['input'], 'data'>;
|
||||
};
|
||||
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
|
||||
readonly assets: {
|
||||
readonly businessModel: Pick<ActivityManifest['assets']['businessModel'], 'cmmnXml'>;
|
||||
};
|
||||
};
|
||||
export type LedgerAuditInput = { readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'> };
|
||||
export type DeliverableAuditInput = {
|
||||
readonly contract: Pick<ActivityManifest['contract'], 'requiredDeliverables'>;
|
||||
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
|
||||
};
|
||||
|
||||
/** ISO-8601 duration,与 event 的 deadlineMillis 同一子集 */
|
||||
const DURATION = /^P(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)$|^P\d+D$/;
|
||||
|
||||
@@ -62,8 +82,9 @@ export function danglingListeners(manifest: ActivityManifest): StaticAuditFindin
|
||||
}
|
||||
|
||||
/** 验收等级不得谎报:L4 不得标注为 L1–L3;L1/L2 必须给出可机检的表达式 */
|
||||
export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAuditFinding[] {
|
||||
export function acceptanceLevelFindings(manifest: AcceptanceAuditInput): StaticAuditFinding[] {
|
||||
const out: StaticAuditFinding[] = [];
|
||||
const declared = boundNames(manifest);
|
||||
for (const [index, criterion] of manifest.contract.acceptanceCriteria.entries()) {
|
||||
const at = `contract.acceptanceCriteria[${index}]`;
|
||||
if ((criterion.level === 'L1' || criterion.level === 'L2') && criterion.predicate === undefined && criterion.modelRef === undefined) {
|
||||
@@ -74,6 +95,209 @@ export function acceptanceLevelFindings(manifest: ActivityManifest): StaticAudit
|
||||
at,
|
||||
});
|
||||
}
|
||||
/*
|
||||
* **谓词里的名字必须在台账上**(2026-07-31 立)。
|
||||
*
|
||||
* A2 的判据写的是 `candidatesConfirmed >= 2`,而台账里从来没有这个名字 ——
|
||||
* S-FEEL 拿未绑定的名字比大小求值为假,于是这条判据**永远达不了标**:
|
||||
* 活办得再干净也过不了 submit 门,Agent 连拒五拍熔断,整条流卡死在 A2。
|
||||
* A3/A4/A6/A7 全是同一个毛病,一个都没跑通过。
|
||||
*
|
||||
* 这种错光看清单是看不出来的(谓词本身语法没问题),只有跑到放行那一刻才炸,
|
||||
* 而那时人已经签完字了。所以它必须在**安装期**挡下:装不上,好过装上了跑死。
|
||||
*/
|
||||
for (const name of predicateNames(criterion.predicate)) {
|
||||
if (declared.has(name)) continue;
|
||||
out.push({
|
||||
code: 'acceptance-predicate-unbound',
|
||||
severity: 'error',
|
||||
message:
|
||||
`判据「${criterion.label}」的谓词引用了 \`${name}\`,而台账与案卷里都没有这个名字 —— ` +
|
||||
'S-FEEL 对未绑定的名字求值为假,这条判据将永远达不了标,放行门永远拒。' +
|
||||
'把它改成活动真写得出的列(通常是汇总表里的 _total 计数列)。',
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 必交付物的**产出地址**必须指向真实存在的台账表(2026-07-31 立)。
|
||||
*
|
||||
* 预检数的是「本单在这张表上写了几行」。地址不给,它退回拿 key 去任务视图里找 ——
|
||||
* 而 key 是 `benchmark-table` 这样的短横线别名,视图里永远没有这个键,
|
||||
* 于是「必交付物缺一不得提交放行」**永远拒**:活办完了也放不了行,连拒五拍熔断。
|
||||
* 地址写错(指向不存在的表)是同一个下场,一并挡在安装期。
|
||||
*/
|
||||
export function deliverableAddressFindings(manifest: DeliverableAuditInput): StaticAuditFinding[] {
|
||||
const out: StaticAuditFinding[] = [];
|
||||
const tables = new Set(manifest.declarations.ledger.map((t) => `${t.domain}.${t.object}`));
|
||||
for (const [index, item] of manifest.contract.requiredDeliverables.entries()) {
|
||||
const at = `contract.requiredDeliverables[${index}]`;
|
||||
if (item.ledger === undefined || item.ledger.trim() === '') {
|
||||
out.push({
|
||||
code: 'deliverable-address-unbound',
|
||||
severity: 'error',
|
||||
message:
|
||||
`必交付物「${item.label}」没给 \`ledger\` 产出地址 —— ` +
|
||||
'预检会退回拿 key 去任务视图里找,而 key 是短横线别名、视图里没有这个键,' +
|
||||
'这一件将永远算「未产出」,放行门永远拒。给它写上产出落在哪张表。',
|
||||
at,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!tables.has(item.ledger)) {
|
||||
out.push({
|
||||
code: 'deliverable-address-unbound',
|
||||
severity: 'error',
|
||||
message:
|
||||
`必交付物「${item.label}」的产出地址 \`${item.ledger}\` 不是本活动声明的台账表 —— ` +
|
||||
`声明过的是:${[...tables].join('、') || '(一张都没有)'}`,
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 台账对象的性质要**核得住**(2026-07-31 立)。
|
||||
*
|
||||
* 起因是一次真实的数据丢失:A4 的报价草案 `quote_pricing.draft` 本该一单一份,
|
||||
* 主键却只有 `pn` —— 第二单报同一个料号就 upsert 覆盖了上一单。
|
||||
* 跑了四轮 A4,表里只剩最后一轮的 3 行;而主键带单号的 `quote_intake.record`
|
||||
* 是 61 行 / 20 单,一单不少。**作者没决定这张表是单据还是主数据,主键就写歪了。**
|
||||
*
|
||||
* 光加一个 `kind` 字段治不了 —— 声明能填错。所以每一类各核一条:
|
||||
* · `record` 必须指明单标识,且它得真在主键里(否则「一单一份」是空话);
|
||||
* · `master` 不该有单标识(有了说明它其实是单据,归错类了);
|
||||
* · 任何表都得有主键 —— 没主键的台账合并语义无从谈起。
|
||||
*/
|
||||
export function ledgerKindFindings(manifest: LedgerAuditInput): StaticAuditFinding[] {
|
||||
const out: StaticAuditFinding[] = [];
|
||||
for (const [index, table] of manifest.declarations.ledger.entries()) {
|
||||
const at = `declarations.ledger[${index}]`;
|
||||
const where = `${table.domain}.${table.object}`;
|
||||
const pk = table.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
||||
const names = new Set(table.fields.map((f) => f.name));
|
||||
|
||||
if (pk.length === 0) {
|
||||
out.push({
|
||||
code: 'ledger-kind-invalid',
|
||||
severity: 'error',
|
||||
message: `台账 \`${where}\` 一个主键列都没有 —— 合并与去重都无从谈起,写进去的行会越堆越多且认不出重复`,
|
||||
at,
|
||||
});
|
||||
}
|
||||
|
||||
if (table.kind === 'record') {
|
||||
if (table.caseKey === undefined) {
|
||||
out.push({
|
||||
code: 'ledger-kind-invalid',
|
||||
severity: 'error',
|
||||
message:
|
||||
`单据表 \`${where}\` 没指明单标识(\`caseKey\`)—— 一单一份就成了空话:` +
|
||||
'主键里没有「单」,第二单碰到同一个业务键会直接覆盖上一单(实测丢过数据)。' +
|
||||
'指明哪一列是单标识,或者它其实是主数据就改 `kind: \'master\'`。',
|
||||
at,
|
||||
});
|
||||
} else if (!names.has(table.caseKey)) {
|
||||
out.push({
|
||||
code: 'ledger-kind-invalid',
|
||||
severity: 'error',
|
||||
message: `单据表 \`${where}\` 的单标识 \`${table.caseKey}\` 不在它自己的字段里`,
|
||||
at,
|
||||
});
|
||||
} else if (!pk.includes(table.caseKey)) {
|
||||
out.push({
|
||||
code: 'ledger-kind-invalid',
|
||||
severity: 'error',
|
||||
message:
|
||||
`单据表 \`${where}\` 的单标识 \`${table.caseKey}\` **不是主键的一部分**(现主键:${pk.join(', ')})—— ` +
|
||||
'单标识不进主键,等于没有单标识:同一个业务键的第二单照样覆盖第一单。',
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (table.kind !== 'record' && table.caseKey !== undefined) {
|
||||
out.push({
|
||||
code: 'ledger-kind-invalid',
|
||||
severity: 'error',
|
||||
message:
|
||||
`\`${where}\` 标成 ${table.kind} 却带着单标识 \`${table.caseKey}\` —— ` +
|
||||
'跨单的数据不该按单分行;它多半其实是单据,`kind` 归错了类。',
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** S-FEEL 保留字与字面量 —— 它们不是台账名,不该被当成未绑定 */
|
||||
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
|
||||
|
||||
/**
|
||||
* 从谓词里摘出**标识符**(粗筛,宁可漏报不可误报)。
|
||||
* 只认「字母开头、由字母数字下划线点组成」的片段,去掉数字、字符串字面量与保留字;
|
||||
* 带点的取根名(`a.b` 的绑定看 `a`)。
|
||||
*/
|
||||
function predicateNames(predicate: string | undefined): string[] {
|
||||
if (predicate === undefined || predicate.trim() === '') return [];
|
||||
const withoutStrings = predicate.replace(/"[^"]*"/g, ' ').replace(/'[^']*'/g, ' ');
|
||||
const found = withoutStrings.match(/[A-Za-z_][A-Za-z0-9_.]*/g) ?? [];
|
||||
const out = new Set<string>();
|
||||
for (const raw of found) {
|
||||
const root = raw.split('.')[0] ?? raw;
|
||||
if (SFEEL_WORDS.has(root.toLowerCase())) continue;
|
||||
out.add(root);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个活动**求值时能绑到的名字**:台账所有列名 + 案卷项 id + 起单入参的字段名。
|
||||
* 任务视图正是由这三样摊平出来的,所以判据能读到的也就这三样。
|
||||
*/
|
||||
function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
|
||||
const out = new Set<string>();
|
||||
for (const table of manifest.declarations.ledger) {
|
||||
for (const field of table.fields) out.add(field.name);
|
||||
out.add(table.object);
|
||||
/*
|
||||
* **基座数出来的行数读数** `域_对象_rows` —— 活动写不了它,正因如此它才有用:
|
||||
* 判据的两边不能都由活动自报。实测过一次「对账过关而账是错的」——
|
||||
* 条目数报 9、表里躺着 10 行,三类条数与派单数同源同错,`2 = 2` 顺利过关。
|
||||
* 它只对**本活动声明过的表**成立,所以在这儿按声明生成:
|
||||
* 对象名手滑写错,照旧当场拒装。
|
||||
*/
|
||||
out.add(`${table.domain}_${table.object}_rows`);
|
||||
/*
|
||||
* 另两类基座读数,与 `_rows` 同一个道理 —— **能由数据算出来的,别让活动自报**:
|
||||
* · `域_对象_missing` —— 缺必填几行(按 `fields[].required` 现数);
|
||||
* · `域_对象_<列>_<值>` —— 按值现数几行(取值域由 `fields[].tally` 声明)。
|
||||
* 后一类是三分类条数的落点:人在放行页改了类标,分布条、去向谓词、派单对账
|
||||
* **全都跟着重算** —— 手工修改本来就是为了纠正 AI 的判断,改了却不影响结果,
|
||||
* 那个兜底功能就是假的。
|
||||
*/
|
||||
if (table.fields.some((f) => f.required === true)) out.add(`${table.domain}_${table.object}_missing`);
|
||||
if (table.fields.some((f) => f.expect === true)) out.add(`${table.domain}_${table.object}_gaps`);
|
||||
for (const field of table.fields) {
|
||||
for (const value of field.tally ?? []) {
|
||||
const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, '');
|
||||
if (slug !== '') out.add(`${table.domain}_${table.object}_${field.name}_${slug}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of manifest.assets.businessModel.cmmnXml.match(/caseFileItem\s+id="([^"]+)"/g) ?? []) {
|
||||
const m = /id="([^"]+)"/.exec(id);
|
||||
if (m?.[1] !== undefined) out.add(m[1]);
|
||||
}
|
||||
const data = manifest.contract.input.data;
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
const props = (data as { properties?: Record<string, unknown> }).properties;
|
||||
if (props !== undefined) for (const key of Object.keys(props)) out.add(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -93,6 +317,39 @@ export function loopFindings(manifest: ActivityManifest): StaticAuditFinding[] {
|
||||
at: 'assets.businessModel.narrative',
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* 剧本与阶段引的计划项 id **必须在 CMMN 里真存在** —— 对不上就在装的时候拒。
|
||||
*
|
||||
* 拼错一个 id 的后果是**静默失效**,而且两处症状都不像「id 写错了」:
|
||||
* · 剧本对不上 → 那一步永远拿不到自己的做法与工具,模型每拍读整本说明书;
|
||||
* · 阶段对不上 → 那一格永远收不完,进度条卡在那儿不动,人以为单卡住了。
|
||||
* 从 XML 里认 id 用的是属性正则:这里不解析 CMMN(解析在引擎那侧),
|
||||
* 只要认得出「有没有这个 id」就够。
|
||||
*/
|
||||
const declared = new Set([...model.cmmnXml.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]));
|
||||
for (const step of model.steps ?? []) {
|
||||
if (!declared.has(step.item)) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: `剧本引的计划项在 CMMN 里不存在:${step.item}`,
|
||||
at: 'assets.businessModel.steps',
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const phase of model.phases ?? []) {
|
||||
for (const item of phase.items) {
|
||||
if (!declared.has(item)) {
|
||||
out.push({
|
||||
code: 'schema-invalid',
|
||||
severity: 'error',
|
||||
message: `阶段「${phase.name}」引的计划项在 CMMN 里不存在:${item}`,
|
||||
at: 'assets.businessModel.phases',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -106,8 +363,18 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
|
||||
out.push({ code: 'sdk-incompatible', severity: 'error', message: `SDK 版本不兼容:${manifest.identity.sdkVersion}` });
|
||||
}
|
||||
for (const tool of external.missingTools) {
|
||||
// 工具缺口只阻塞上架,不阻塞开发(OS 7.1 契约先行)
|
||||
out.push({ code: 'tool-contract-missing', severity: 'error', message: `工具调用契约未注册:${tool}` });
|
||||
/*
|
||||
* 工具缺口是**警告,不是拒装**(2026-07-30 降级)。
|
||||
*
|
||||
* 「设计活动时只考虑需要什么工具,不关心系统里有没有实现」——
|
||||
* 声明了系统里没有,那是系统的缺口,不是活动的错误。运行时已有出路:
|
||||
* 未命中即**转人代办**(§6.2,人是工具的一种实现),这一步照样做得下去。
|
||||
* 从前判 error 拒装,等于要求「先把工具全实现完才允许装活动」——
|
||||
* 与声明式工具正面冲突,也让契约先行成了空话。
|
||||
*
|
||||
* 仍然如实报出来:治理台看得见「这件活动有几件工具要靠人顶着」。
|
||||
*/
|
||||
out.push({ code: 'tool-contract-missing', severity: 'warn', message: `工具调用契约未注册,运行时转人代办:${tool}` });
|
||||
}
|
||||
for (const model of external.unboundModels) {
|
||||
out.push({ code: 'model-name-unbound', severity: 'error', message: `模型名未绑定四元:${model}` });
|
||||
@@ -130,6 +397,8 @@ export function staticAudit(manifest: ActivityManifest, external: ExternalChecks
|
||||
}
|
||||
|
||||
out.push(...danglingListeners(manifest));
|
||||
out.push(...deliverableAddressFindings(manifest));
|
||||
out.push(...ledgerKindFindings(manifest));
|
||||
out.push(...acceptanceLevelFindings(manifest));
|
||||
out.push(...loopFindings(manifest));
|
||||
|
||||
|
||||
@@ -132,6 +132,15 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
|
||||
}
|
||||
const cards: ActivityCard[] = [];
|
||||
for (const [activityId, versions] of byActivity) {
|
||||
/*
|
||||
* 全部版本都已卸载的活动**不在架上**。
|
||||
*
|
||||
* 卸载只把版本状态置为 `uninstalled`(版本行留着,在途任务按启动时版本跑完)——
|
||||
* 但活动库不该照旧摆着它:实测踩过,一个被真活动包顶替的槽位卸载后仍在库里,
|
||||
* 与顶替它的那个**同名并排**,试跑台上选错一个就试了个空壳。
|
||||
* 「卸了还摆着」就是界面撒谎。
|
||||
*/
|
||||
if (versions.every((v) => v.state === 'uninstalled')) continue;
|
||||
const manifest = await deps.store.manifest(activityId);
|
||||
if (manifest === undefined) continue;
|
||||
cards.push({
|
||||
@@ -158,6 +167,8 @@ export function createRegistryService(deps: RegistryServiceDeps): ActivityRegist
|
||||
for (const pkg of await deps.store.listPackages(activityId)) {
|
||||
await deps.store.setState(activityId, pkg.version, 'uninstalled');
|
||||
}
|
||||
// 一个版本都没装了,就谈不上「可用」—— 两处状态得对得上,不然治理台会摆一个假的绿灯
|
||||
await deps.store.setEnabled(activityId, false);
|
||||
},
|
||||
|
||||
async loadEnvelope(activityId) {
|
||||
|
||||
@@ -28,19 +28,20 @@ describe('planConsumption —— 三级消费', () => {
|
||||
triggerHits: ['tr1'],
|
||||
ownedTaskId: 't1',
|
||||
hasOwner: true,
|
||||
fromChannel: true,
|
||||
});
|
||||
expect(plan.taskTargets).toEqual(['t1']);
|
||||
expect(plan.triggerTargets).toEqual(['tr1']);
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
});
|
||||
|
||||
it('皆未命中且无主 → 消息兜底', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false });
|
||||
it('**入站件**皆未命中且无主 → 消息兜底(系统自己发的不算,见下面那组)', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false, fromChannel: true });
|
||||
expect(plan.messageFallback).toBe(true);
|
||||
});
|
||||
|
||||
it('有主但零监听命中 → 仅记录,不兜底、不降级为无主', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7', hasOwner: true });
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7', hasOwner: true, fromChannel: true });
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
expect(plan.recordOnlyTask).toBe('t7');
|
||||
expect(plan.taskTargets).toEqual([]);
|
||||
@@ -54,6 +55,7 @@ describe('planConsumption —— 三级消费', () => {
|
||||
triggerHits: [],
|
||||
ownedTaskId: undefined,
|
||||
hasOwner: true,
|
||||
fromChannel: true,
|
||||
});
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
expect(plan.recordOnlyTask).toBeUndefined();
|
||||
@@ -65,12 +67,19 @@ describe('planConsumption —— 三级消费', () => {
|
||||
triggerHits: [],
|
||||
ownedTaskId: 't7',
|
||||
hasOwner: true,
|
||||
fromChannel: true,
|
||||
});
|
||||
expect(plan.recordOnlyTask).toBeUndefined();
|
||||
});
|
||||
|
||||
it('无主但触发命中 → 不兜底', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: ['tr1'], ownedTaskId: undefined, hasOwner: false });
|
||||
const plan = planConsumption({
|
||||
listenerHits: [],
|
||||
triggerHits: ['tr1'],
|
||||
ownedTaskId: undefined,
|
||||
hasOwner: false,
|
||||
fromChannel: true,
|
||||
});
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -96,3 +105,36 @@ describe('deadlineMillis —— 监听时限必配', () => {
|
||||
expect(() => deadlineMillis('PT0S')).toThrow(/大于零/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **消息兜底只兜从外面进来的件**(2026-08-02 用户:「消息怎么总莫名其妙的出内容」)。
|
||||
*
|
||||
* 「无主入站待认领」是件具体的事:一封信进来了,没匹配上任何单也没触发任何流,
|
||||
* 得有个人去认领它。而系统自己发的事件不是这种东西 —— 台账写一行会发 `data` 事件,
|
||||
* 没人监听它是**常态**,不是待办。
|
||||
*
|
||||
* 不分这一刀的实测下场:装一次活动包(主数据 systemWrite)就往三个人的收件箱里
|
||||
* 塞了十几条「无主入站待认领:(无主题) … 原件:无」—— 没有主题、没有原件、
|
||||
* 没人能对它做任何事。那不是待认领的件,是一条内部广播。
|
||||
*/
|
||||
describe('消息兜底:只兜入站件', () => {
|
||||
const plan = (over: Partial<Parameters<typeof planConsumption>[0]> = {}) =>
|
||||
planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false, fromChannel: true, ...over });
|
||||
|
||||
it('一封信进来、谁都没接住 → 该有人认领', () => {
|
||||
expect(plan().messageFallback).toBe(true);
|
||||
});
|
||||
|
||||
it('**台账写一行发的事件谁都没接住 → 什么也不做**(它不是件)', () => {
|
||||
expect(plan({ fromChannel: false }).messageFallback).toBe(false);
|
||||
});
|
||||
|
||||
it('接住了就不兜底 —— 监听命中或触发命中,任一即可', () => {
|
||||
expect(plan({ listenerHits: [{ taskId: 't1' }] }).messageFallback).toBe(false);
|
||||
expect(plan({ triggerHits: ['tr1'] }).messageFallback).toBe(false);
|
||||
});
|
||||
|
||||
it('有主的件不兜底 —— 那是「主不是任务」,不是没人认领', () => {
|
||||
expect(plan({ hasOwner: true }).messageFallback).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,22 @@ export function planConsumption(input: {
|
||||
* 把它们判成无主,等于把每次起单都当成「没人认领的入站件」塞进别人收件箱。
|
||||
*/
|
||||
readonly hasOwner: boolean;
|
||||
/**
|
||||
* 这条事件**是从外面进来的件**吗 —— 只有它才谈得上「无人认领」。
|
||||
*
|
||||
* 「无主入站待认领」是一件具体的事:一封信进来了、没匹配上任何单也没触发任何流,
|
||||
* **得有个人去认领它**。而系统自己发的事件不是这种东西:
|
||||
* 台账写一行会发 `data` 事件、引擎起一单会发工作流事件 ——
|
||||
* 没人监听它们是**常态**,不是待办。
|
||||
*
|
||||
* 不分这一刀的下场实测过(用户 2026-08-02:「消息怎么总莫名其妙的出内容」):
|
||||
* 装一次活动包(主数据 `systemWrite`)就往三个人的收件箱里塞了十几条
|
||||
* 「无主入站待认领:(无主题) —— 事件 6d17debb… 未命中任何任务监听与触发器。原件:无」。
|
||||
* 没有主题、没有原件、没人能对它做任何事 —— 那不是待认领的件,是一条内部广播。
|
||||
*
|
||||
* 判据取**来源**不取类型:同样是 `data`,从通道进来的是件,台账写出来的是事实。
|
||||
*/
|
||||
readonly fromChannel: boolean;
|
||||
}): ConsumptionPlan {
|
||||
const taskTargets = fanOutTargets(input.listenerHits);
|
||||
const triggerTargets = [...new Set(input.triggerHits)];
|
||||
@@ -73,8 +89,8 @@ export function planConsumption(input: {
|
||||
return {
|
||||
taskTargets,
|
||||
triggerTargets,
|
||||
// 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主**的件(OS 6.3)
|
||||
messageFallback: nothingHit && !input.hasOwner,
|
||||
// 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主的入站件**(OS 6.3)
|
||||
messageFallback: nothingHit && !input.hasOwner && input.fromChannel,
|
||||
recordOnlyTask:
|
||||
input.ownedTaskId !== undefined && !taskTargets.includes(input.ownedTaskId)
|
||||
? input.ownedTaskId
|
||||
|
||||
@@ -51,6 +51,8 @@ export function createEventService(deps: EventServiceDeps): EventService {
|
||||
triggerHits,
|
||||
ownedTaskId,
|
||||
hasOwner: envelope.correlation.target !== undefined,
|
||||
// 只有**从通道进来的件**谈得上「无人认领」—— 台账写一行发的 data 事件不是件
|
||||
fromChannel: envelope.source.kind === 'channel',
|
||||
});
|
||||
|
||||
// 扇出:每个命中目标一份独立投递,信封「关联 = 单目标」保持单目标
|
||||
|
||||
@@ -228,3 +228,50 @@ describe('完成只出自放行通过', () => {
|
||||
expect(allCriteriaGreen(acceptance({ criteria: [{ label: 'x', level: 'L2', pass: false }] }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **一张录入卡可以落好几张表**(2026-08-02 用户:「新建客户 联系人和公司都需要确认」)。
|
||||
*
|
||||
* 建一位新客户落的是两条主数据:公司档 + 联系人档。从前卡上只有公司那一半,
|
||||
* 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。
|
||||
* 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉,
|
||||
* **而盖掉之后没人看得出来**。
|
||||
*/
|
||||
describe('录入卡分段:段号.列名', () => {
|
||||
/** 与 `routes/desk.ts` 那段拆分同一口径 —— 改一处这里要跟着改 */
|
||||
const split = (values: Record<string, string>, at: number): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
const mark = `${at}.`;
|
||||
if (k.startsWith(mark)) out[k.slice(mark.length)] = v;
|
||||
else if (at === 0 && !/^\d+\./.test(k)) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const values = {
|
||||
'0.customer_code': 'CU-9001',
|
||||
'0.name': '上海精密电子有限公司',
|
||||
'1.customer_code': 'CU-9001',
|
||||
'1.contact_code': 'CT-01',
|
||||
'1.name': '陈刚',
|
||||
'1.email': 'chen@sh-elec.com',
|
||||
};
|
||||
|
||||
it('**同名列不互相盖** —— 公司叫「上海精密电子」,联系人叫「陈刚」', () => {
|
||||
expect(split(values, 0)['name']).toBe('上海精密电子有限公司');
|
||||
expect(split(values, 1)['name']).toBe('陈刚');
|
||||
});
|
||||
|
||||
it('每段只拿自己那几格 —— 联系人编码不该落进公司档', () => {
|
||||
expect(split(values, 0)['contact_code']).toBeUndefined();
|
||||
expect(Object.keys(split(values, 1)).sort()).toEqual(['contact_code', 'customer_code', 'email', 'name']);
|
||||
});
|
||||
|
||||
it('**老形状(没段号的平键)仍旧管用** —— 一张卡只有一段时照旧落主段', () => {
|
||||
expect(split({ customer_code: 'CU-1', name: '老写法' }, 0)).toEqual({ customer_code: 'CU-1', name: '老写法' });
|
||||
});
|
||||
|
||||
it('老形状不喂给后面的段 —— 它说不清自己是哪一张表的', () => {
|
||||
expect(split({ customer_code: 'CU-1', name: '老写法' }, 1)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 调人预算 —— 拦的是「又去问人」,不是「办完了要交差」。
|
||||
*
|
||||
* 这条是拿一次熔断换来的:A4 的预算是 3 次,两张决策卡加一张补全卡用完;
|
||||
* 三条料的价全算好了、门禁也过了,提交放行却被「调人预算触顶 3/3」拒回 ——
|
||||
* 再提再拒,连拒五拍熔断。活动可以完全合规地把自己饿死在终点线上。
|
||||
*
|
||||
* 放行不是 Agent 选择的打扰:铁律③终局唯放行,这一单**必须**有人签字才收得了口。
|
||||
* 所以验收单不计预算;补全卡、决策卡、执行工单照算。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ChatCard, DeliverableId, StaffId, TaskId, WorkOrder } from '@contracts';
|
||||
import { cardIdSchema } from '@contracts';
|
||||
import type { CardStore, HumanEventPublisher, MemoryRecorder, MessageOutbox } from '../port.ts';
|
||||
import type { IssueContext } from '../contract.ts';
|
||||
import { createHumanToolService } from '../internal/usecase/human-tool-service.ts';
|
||||
import { HumanCallBudgetExceededError } from '../contract.ts';
|
||||
|
||||
const TASK = 'task-1' as TaskId;
|
||||
const OWNER = 'staff-1' as StaffId;
|
||||
|
||||
/** 记下「发出去了什么」的假台账 —— 已用次数由测试给定,其余按端口如实实现 */
|
||||
function cardStore(used: number, sent: string[]): CardStore {
|
||||
return {
|
||||
saveWorkOrder: async (_taskId, order) => {
|
||||
sent.push(`order:${order.mode}`);
|
||||
},
|
||||
getWorkOrder: async () => undefined,
|
||||
listWorkOrders: async () => [],
|
||||
markWorkOrderDone: async () => undefined,
|
||||
patchWorkOrder: async () => undefined,
|
||||
saveChatCard: async (_taskId, card) => {
|
||||
sent.push(`card:${card.k}`);
|
||||
},
|
||||
getChatCard: async () => undefined,
|
||||
listChatCards: async () => [],
|
||||
updateChatCard: async () => undefined,
|
||||
closeChatCard: async () => undefined,
|
||||
humanCallCount: async () => used,
|
||||
openCardsOf: async () => [],
|
||||
};
|
||||
}
|
||||
|
||||
const outbox: MessageOutbox = { sendElicitation: async () => ({ id: 'm-1' }) };
|
||||
const memory: MemoryRecorder = { append: async () => undefined };
|
||||
const events: HumanEventPublisher = { publishHumanEvent: async () => undefined };
|
||||
|
||||
function service(used: number) {
|
||||
const sent: string[] = [];
|
||||
let seq = 0;
|
||||
const svc = createHumanToolService({
|
||||
cards: cardStore(used, sent),
|
||||
outbox,
|
||||
memory,
|
||||
events,
|
||||
clock: { now: () => new Date('2026-07-31T00:00:00.000Z') },
|
||||
ids: {
|
||||
next: () => {
|
||||
seq += 1;
|
||||
return `id-${seq}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
return { svc, sent };
|
||||
}
|
||||
|
||||
const context: IssueContext = { taskId: TASK, ownerId: OWNER, humanCallCap: 3 };
|
||||
|
||||
const executionDraft = {
|
||||
title: '请补齐字段',
|
||||
basis: [{ k: '缺口', v: 'PN' }],
|
||||
ask: '请向客户追问',
|
||||
fields: [{ key: 'pn', label: 'PN', kind: 'text' as const, required: true }],
|
||||
} satisfies Parameters<ReturnType<typeof service>['svc']['issueExecution']>[1];
|
||||
|
||||
const acceptanceDraft = {
|
||||
title: '收口验收',
|
||||
basis: [{ k: '条目数', v: '3' }],
|
||||
deliverable: 'd-1' as DeliverableId,
|
||||
criteria: [{ label: '草案完整', level: 'L1' as const, pass: true }],
|
||||
fields: [],
|
||||
} satisfies Parameters<ReturnType<typeof service>['svc']['issueAcceptance']>[1];
|
||||
|
||||
const decisionCard: ChatCard = {
|
||||
id: cardIdSchema.parse('c-1'),
|
||||
at: '2026-07-31T00:00:00.000Z',
|
||||
k: 'decision',
|
||||
ask: '选一个处置',
|
||||
basis: [{ k: '偏离', v: '-30%' }],
|
||||
options: [
|
||||
{ value: 'a', label: '照原价报' },
|
||||
{ value: 'b', label: '按底价报' },
|
||||
],
|
||||
allowCustom: false,
|
||||
};
|
||||
|
||||
describe('调人预算', () => {
|
||||
it('没到顶 —— 执行单发得出去', async () => {
|
||||
const { svc, sent } = service(2);
|
||||
await svc.issueExecution(context, executionDraft);
|
||||
expect(sent).toEqual(['order:execution']);
|
||||
});
|
||||
|
||||
it('到顶了 —— 执行单拒发', async () => {
|
||||
const { svc } = service(3);
|
||||
await expect(svc.issueExecution(context, executionDraft)).rejects.toBeInstanceOf(HumanCallBudgetExceededError);
|
||||
});
|
||||
|
||||
it('到顶了 —— 决策卡拒发', async () => {
|
||||
const { svc } = service(3);
|
||||
await expect(svc.issueChatCard(context, decisionCard)).rejects.toBeInstanceOf(HumanCallBudgetExceededError);
|
||||
});
|
||||
|
||||
it('到顶了 —— **验收放行照发**:终局唯放行,不该跟提问抢额度', async () => {
|
||||
const { svc, sent } = service(3);
|
||||
await svc.issueAcceptance(context, acceptanceDraft);
|
||||
expect(sent).toEqual(['order:acceptance']);
|
||||
});
|
||||
|
||||
it('超顶很多也照发验收单 —— 已经办完的活不该因为问过几次人就交不了差', async () => {
|
||||
const { svc, sent } = service(99);
|
||||
await svc.issueAcceptance(context, acceptanceDraft);
|
||||
expect(sent).toEqual(['order:acceptance']);
|
||||
});
|
||||
|
||||
it('验收单免的是预算,不是三规矩 —— 裸卡照旧拒发', async () => {
|
||||
const { svc } = service(0);
|
||||
const bare = { ...acceptanceDraft, basis: [] } satisfies Omit<
|
||||
Extract<WorkOrder, { mode: 'acceptance' }>,
|
||||
'id' | 'at' | 'mode'
|
||||
>;
|
||||
await expect(svc.issueAcceptance(context, bare)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,8 @@ export type IssuedChatCard = {
|
||||
|
||||
export type ReplyOutcome = {
|
||||
readonly order: WorkOrder;
|
||||
/** 这张卡挂在哪一单 —— 装配层要拿它把「人就地改的那几行」写回草稿层 */
|
||||
readonly taskId: TaskId;
|
||||
/** 完成只出自放行通过 —— 这里为真,任务才可收敛终态 */
|
||||
readonly producesCompletion: boolean;
|
||||
/** 驳回:环内回转,任务继续 */
|
||||
@@ -110,10 +112,33 @@ export type HumanToolService = {
|
||||
issueChatCard(context: IssueContext, card: ChatCard): Promise<IssuedChatCard>;
|
||||
|
||||
replyWorkOrder(reply: WorkOrderReply, by: StaffId): Promise<ReplyOutcome>;
|
||||
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard>;
|
||||
/**
|
||||
* 回传带上 `taskId` —— **数据录入卡的确认要当场写草稿**,而写库要知道写给哪一单。
|
||||
* 由本模块自己写会让 human-tool 依赖 ledger(越层),所以把事实交出去,由组装层落笔。
|
||||
*/
|
||||
replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{ card: ChatCard; taskId: TaskId }>;
|
||||
/** 驳回重答:新卡实例,原卡闭合 —— 只增不改 */
|
||||
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
|
||||
|
||||
/**
|
||||
* 给已发出的验收工单补上选择组(AM 1.2 恰选一口)。
|
||||
*
|
||||
* 为什么分两步:卡由 `release.submitForRelease` 发 —— 它守的是预检那道门,
|
||||
* 不该也不必知道「这个活动有哪些出口」。出口是契约的事实,由 app 从 manifest 现读后贴上。
|
||||
*/
|
||||
attachPortChoice(
|
||||
workOrderId: string,
|
||||
choice: { group: string; ports: ReadonlyArray<{ name: string; label?: string | undefined }> },
|
||||
): Promise<void>;
|
||||
/**
|
||||
* **算出来的去向**上卡 —— 不给人选,只告诉他走哪一口、凭哪一条。
|
||||
* 去向是台账那几个数的函数;摆成选择题,等于把可回放的判定换成一次手感。
|
||||
*/
|
||||
attachRoutedPort(
|
||||
workOrderId: string,
|
||||
routed: { group: string; name: string; label?: string | undefined; because: string },
|
||||
): Promise<void>;
|
||||
|
||||
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
||||
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||
|
||||
@@ -54,6 +54,20 @@ export function createCardStore(db: Db): CardStore {
|
||||
await db.update(workOrderTable).set({ done }).where(eq(workOrderTable.id, id));
|
||||
},
|
||||
|
||||
/*
|
||||
* 局部改卡面:整卡存在 `payload` 里,所以读回来合并再写回。
|
||||
* 只对**未回传**的卡开放(调用方保证)—— 已答的卡是留痕,只增不改。
|
||||
*/
|
||||
async patchWorkOrder(id, patch) {
|
||||
const rows = await db.select().from(workOrderTable).where(eq(workOrderTable.id, id)).limit(1);
|
||||
const row = rows[0];
|
||||
if (row === undefined) return;
|
||||
await db
|
||||
.update(workOrderTable)
|
||||
.set({ payload: { ...(row.payload as Record<string, unknown>), ...patch } })
|
||||
.where(eq(workOrderTable.id, id));
|
||||
},
|
||||
|
||||
async saveChatCard(taskId, card, recipientId) {
|
||||
await db.insert(chatCardTable).values({
|
||||
id: card.id,
|
||||
|
||||
@@ -164,6 +164,29 @@ export function checkChatCardReply(card: ChatCard, reply: ChatCardReply): CardVi
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 数据录入卡 —— **确认那一下就是写入**,所以门要在这儿把住:
|
||||
* 必填空着不让确认(缺一格这张档就是残的,而它是主数据);
|
||||
* 已确认不得再改(只增不改:要改就再发一张,改动自己也是一条留痕)。
|
||||
*/
|
||||
if (card.k === 'entry' && reply.k === 'entry') {
|
||||
if (card.done !== undefined) return [{ code: 'already-answered', message: '该录入已确认,只增不改' }];
|
||||
/*
|
||||
* **一张卡可以有好几段**(公司档 + 联系人档):值按 `段号.列名` 隔开 ——
|
||||
* 两张表都有 `name` 这一列,平铺就会互相盖掉。
|
||||
* 老形状(平键)照旧认,只喂主段:一张卡只有一段时它仍旧管用。
|
||||
*/
|
||||
const groups = [{ fields: card.fields }, ...(card.also ?? [])];
|
||||
const blank = groups.flatMap((g, at) =>
|
||||
g.fields
|
||||
.filter((f) => f.required && (reply.values[`${at}.${f.key}`] ?? (at === 0 ? reply.values[f.key] : undefined) ?? '').trim() === '')
|
||||
.map((f) => f.label),
|
||||
);
|
||||
if (blank.length > 0) {
|
||||
out.push({ code: 'required-blank', message: `必填还空着:${blank.join('、')}` });
|
||||
}
|
||||
}
|
||||
|
||||
if (card.k === 'elicitation' && reply.k === 'elicitation') {
|
||||
if (card.done !== undefined) return [{ code: 'already-answered', message: '该补全已答,只增不改' }];
|
||||
// 「没法填」旁路:要么填值,要么给理由 —— 两条路必走其一
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ChatCardReply,
|
||||
ExecutionWorkOrder,
|
||||
StaffId,
|
||||
TaskId,
|
||||
WorkOrder,
|
||||
WorkOrderReply,
|
||||
} from '@contracts';
|
||||
@@ -38,9 +39,35 @@ export type HumanToolServiceDeps = {
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
/**
|
||||
* 录入卡上那一格叫什么 —— 键是 `段号.列名`(一张卡可以落好几张表)。
|
||||
* 按段号找回它属于哪一段;认不出的原样留着(宁可笼统,不可编)。
|
||||
*/
|
||||
function entryLabel(card: Extract<ChatCard, { k: 'entry' }>, key: string): string {
|
||||
const at = /^(\d+)\.(.+)$/.exec(key);
|
||||
const groups = [{ title: '', fields: card.fields }, ...(card.also ?? [])];
|
||||
if (at === null) return card.fields.find((f) => f.key === key)?.label ?? key;
|
||||
const group = groups[Number(at[1])];
|
||||
const name = at[2] ?? key;
|
||||
const label = group?.fields.find((f) => f.key === name)?.label ?? name;
|
||||
return group === undefined || group.title === '' ? label : `${group.title}·${label}`;
|
||||
}
|
||||
|
||||
export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolService {
|
||||
/** 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(AM 10.1) */
|
||||
async function assertBudget(context: IssueContext): Promise<void> {
|
||||
/**
|
||||
* 调人预算(打扰上限)由基座在**环外**强制 —— 活动不得自查自守(AM 10.1)。
|
||||
*
|
||||
* **验收放行那一张不计预算**(2026-07-31 加)。
|
||||
*
|
||||
* 预算管的是「打扰」——Agent 自己选择去问人的那些次数:补全卡、决策卡、执行工单。
|
||||
* 而放行不是它选的:铁律③终局唯放行,这一单**必须**由人签字才能收口。
|
||||
* 把它和提问算在同一个额度里,后果是活动可以合规地把自己饿死 ——
|
||||
* 实测 A4:预算 3 次,两张决策卡加一张补全卡用完,活全干完了,
|
||||
* 提交放行被「调人预算触顶 3/3」拒回,再提再拒,连拒五拍熔断。
|
||||
* 该拦的是「又去问人」,不是「办完了要交差」。
|
||||
*/
|
||||
async function assertBudget(context: IssueContext, mode?: WorkOrder['mode']): Promise<void> {
|
||||
if (mode === 'acceptance') return;
|
||||
const used = await deps.cards.humanCallCount(context.taskId);
|
||||
if (used >= context.humanCallCap) {
|
||||
throw new HumanCallBudgetExceededError(context.taskId, used, context.humanCallCap);
|
||||
@@ -61,7 +88,12 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
taskId: context.taskId,
|
||||
kind: 'card-sent',
|
||||
title,
|
||||
detail: { kind, id, arrival, recipient },
|
||||
/*
|
||||
* `payload` 里那一个键(对话卡的 `k` / 工单的 `mode`)要一并入痕 ——
|
||||
* 少了它,日志只知道「发了一张卡」,说不出发的是补全卡还是录入卡,
|
||||
* 四字动作那一格就只能写「发出卡片」这种等于没说的词。
|
||||
*/
|
||||
detail: { kind, id, arrival, recipient, ...payload },
|
||||
});
|
||||
if (arrival === 'task-chat') return { arrival };
|
||||
// 跨人卡以系统消息送达对方,就地作答 —— 任务要素永远只装「我负责的任务」
|
||||
@@ -79,7 +111,7 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
async function issueOrder(context: IssueContext, order: WorkOrder): Promise<IssuedWorkOrder> {
|
||||
const violations = checkWorkOrder(order);
|
||||
if (violations.length > 0) throw new BareCallError(violations);
|
||||
await assertBudget(context);
|
||||
await assertBudget(context, order.mode);
|
||||
|
||||
const recipient = context.recipientId ?? context.ownerId;
|
||||
await deps.cards.saveWorkOrder(context.taskId, order, recipient);
|
||||
@@ -171,8 +203,8 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
await deps.memory.append({
|
||||
taskId: found.taskId,
|
||||
kind: reply.mode === 'acceptance' ? 'release-verdict' : 'card-reply',
|
||||
title: reply.mode === 'acceptance' ? `放行裁决:${reply.verdict}` : `执行工单回传:${found.order.title}`,
|
||||
detail: { by, values, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) },
|
||||
title: reply.mode === 'acceptance' ? `裁决:${reply.verdict}` : found.order.title,
|
||||
detail: { by, values, mode: reply.mode, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) },
|
||||
});
|
||||
// 回传经统一入口以人事件抛回任务
|
||||
await deps.events.publishHumanEvent({
|
||||
@@ -186,15 +218,28 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
|
||||
return {
|
||||
order,
|
||||
taskId: found.taskId,
|
||||
producesCompletion: producesCompletion(reply),
|
||||
...(reply.mode === 'acceptance' && reply.verdict !== 'pass'
|
||||
? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } }
|
||||
: {}),
|
||||
selectedPorts: reply.mode === 'acceptance' ? reply.selectedPorts : {},
|
||||
/*
|
||||
* 出口:**算出来的优先**。
|
||||
*
|
||||
* 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了
|
||||
* (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数,
|
||||
* 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。
|
||||
*/
|
||||
selectedPorts:
|
||||
reply.mode !== 'acceptance'
|
||||
? {}
|
||||
: (order as AcceptanceWorkOrder).routedPort !== undefined
|
||||
? { [(order as AcceptanceWorkOrder).routedPort!.group]: (order as AcceptanceWorkOrder).routedPort!.name }
|
||||
: reply.selectedPorts,
|
||||
};
|
||||
},
|
||||
|
||||
async replyChatCard(reply: ChatCardReply, by: StaffId): Promise<ChatCard> {
|
||||
async replyChatCard(reply: ChatCardReply, by: StaffId): Promise<{ card: ChatCard; taskId: TaskId }> {
|
||||
const found = await deps.cards.getChatCard(reply.cardId);
|
||||
if (found === undefined) throw new ReplyRejectedError([{ code: 'not-found', message: '卡不存在' }]);
|
||||
|
||||
@@ -205,14 +250,45 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
const updated = applyChatReply(found.card, reply, by, at);
|
||||
await deps.cards.updateChatCard(updated);
|
||||
|
||||
/*
|
||||
* 标题写**内容**,不写「entry 卡回传」这种行话 —— 日志里动作已有自己一格
|
||||
* (四字章:员工确认 / 员工补全 …),标题再复述一遍卡的类型就是白占地方。
|
||||
* 录入卡尤其要说清落到哪张表:人回看时问的是「我那一下写了什么」。
|
||||
*/
|
||||
/*
|
||||
* **日志里不摆台账地址与列名**(2026-08-02 用户指出:
|
||||
* 「柴浩:写入 customer.profile / customer_code=chchch;name=青岛海洋电子有限公司」
|
||||
* —— 这为啥没换成纯描述)。
|
||||
*
|
||||
* `customer.profile` 与 `customer_code` 是系统内的名字;人回看时问的是
|
||||
* **「我那一下办了什么」**,答案该是「建了客户档案 · 青岛海洋电子有限公司」。
|
||||
* 卡上本来就有人话:`ask` 是问他的那句,`fields[].label` 是每一格的中文名 ——
|
||||
* 用它们,不用地址与列名。
|
||||
*/
|
||||
const title = found.card.ask.split('\n')[0] ?? found.card.ask;
|
||||
await deps.memory.append({
|
||||
taskId: found.taskId,
|
||||
kind: 'card-reply',
|
||||
title: `${found.card.k} 卡回传`,
|
||||
detail: { by, ...reply },
|
||||
title,
|
||||
detail: {
|
||||
by,
|
||||
...reply,
|
||||
...(reply.k === 'entry' && found.card.k === 'entry'
|
||||
? {
|
||||
/*
|
||||
* 列名换成卡上那个中文 label;认不出的原样留着(宁可笼统,不可编)。
|
||||
* 值的键是 `段号.列名`(一张卡可以落好几张表)—— 按段号找回它属于哪一段,
|
||||
* 不然「联系人姓名」会显示成公司档那个同名的「公司名」。
|
||||
*/
|
||||
summary: Object.entries(reply.values)
|
||||
.map(([k, v]) => `${found.card.k === 'entry' ? entryLabel(found.card, k) : k}:${v}`)
|
||||
.join(' · '),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
await deps.events.publishHumanEvent({ taskId: found.taskId, cardId: reply.cardId, payload: { ...reply } });
|
||||
return updated;
|
||||
return { card: updated, taskId: found.taskId };
|
||||
},
|
||||
|
||||
/** 驳回重答:新卡实例(附驳回理由),原卡闭合 —— 每次请求与回答都是独立消息,只增不改 */
|
||||
@@ -239,6 +315,26 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
return { card: fresh, arrival: arrivalOf(found.recipientId, found.recipientId) } satisfies IssuedChatCard;
|
||||
},
|
||||
|
||||
async attachRoutedPort(workOrderId, routed) {
|
||||
await deps.cards.patchWorkOrder(workOrderId, {
|
||||
routedPort: {
|
||||
group: routed.group,
|
||||
name: routed.name,
|
||||
...(routed.label === undefined ? {} : { label: routed.label }),
|
||||
because: routed.because,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async attachPortChoice(workOrderId, choice) {
|
||||
await deps.cards.patchWorkOrder(workOrderId, {
|
||||
portChoice: {
|
||||
group: choice.group,
|
||||
ports: choice.ports.map((p) => ({ name: p.name, ...(p.label === undefined ? {} : { label: p.label }) })),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async openWorkOrders(taskId) {
|
||||
return deps.cards.listWorkOrders(taskId, true);
|
||||
},
|
||||
@@ -270,6 +366,9 @@ function applyChatReply(card: ChatCard, reply: ChatCardReply, by: StaffId, at: s
|
||||
done: { at, by, values: reply.values, ...(reply.reason === undefined ? {} : { reason: reply.reason }) },
|
||||
};
|
||||
}
|
||||
if (card.k === 'entry' && reply.k === 'entry') {
|
||||
return { ...card, done: { at, by, values: reply.values } };
|
||||
}
|
||||
if (card.k === 'rating' && reply.k === 'rating') {
|
||||
return { ...card, done: { at, by, score: reply.score, ...(reply.note === undefined ? {} : { note: reply.note }) } };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export type CardStore = {
|
||||
getWorkOrder(id: string): Promise<{ taskId: TaskId; order: WorkOrder; recipientId: StaffId } | undefined>;
|
||||
listWorkOrders(taskId: TaskId, openOnly: boolean): Promise<WorkOrder[]>;
|
||||
markWorkOrderDone(id: string, done: NonNullable<WorkOrder['done']>): Promise<void>;
|
||||
/** 局部改卡面 —— 只增不改是对**已回传**的卡;未答的卡还可补齐契约事实(如选择组) */
|
||||
patchWorkOrder(id: string, patch: Partial<WorkOrder>): Promise<void>;
|
||||
|
||||
saveChatCard(taskId: TaskId, card: ChatCard, recipientId: StaffId): Promise<void>;
|
||||
getChatCard(id: string): Promise<{ taskId: TaskId; card: ChatCard; recipientId: StaffId } | undefined>;
|
||||
|
||||
@@ -18,12 +18,29 @@
|
||||
`MemoryRecorder`(app 注入 memory)、`Audience`(app 注入 org)
|
||||
被依赖: release, loop-engine, flow-engine, task, app(SSE 路由)
|
||||
|
||||
近变(2026-07-30):新增 `onPush(listener)` —— **系统级转接**,装配层接走全部推送转给前端 SSE。
|
||||
从前只有 `subscribe(staffId, …)`,而它一个订阅者都没有:AI 说的每一句、
|
||||
推的每一帧画面都广播给了空气,前端只好靠人点刷新。不复用 `subscribe` 是因为
|
||||
转接是系统的事,为它编一个假员工 id 是撒谎;不按人筛也不漏 —— 推送不带业务载荷,
|
||||
谁该看见由前端自己那几把查询决定。
|
||||
对话流投影另加 `narration` 一类(AI 对人说的话)。
|
||||
|
||||
不变量(OS 4.1 / 09,AM 07):
|
||||
- **输入单入口**:`inputControl()` 恒为 system —— 输入侧不参与转交;
|
||||
视图区不得产生回环输入,浏览性交互由前端容器本地处理
|
||||
- **控制权按态转交**:活动在跑(active / awaiting-*)归活动,其余归系统;
|
||||
转交随引擎事件自动发生,活动不感知交接
|
||||
- **视图区无脚本执行权**:含 `<script>` / `on*` 事件处理器 / 外部资源的模版一律 `TemplateRejectedError`
|
||||
- **填数只做取值与转义**:`data-slot="路径"` 换文本、`data-rows="路径"` 逐行复制;
|
||||
无表达式、无分支、无循环 —— 模版一旦能算,「无脚本执行权」就形同虚设。
|
||||
两个表现档,都把判断留在样式表、不留在填数里:
|
||||
· `data-blank` 让空值**真的留空**(不填「—」),好让模版自己的 CSS
|
||||
去标必填/选填缺口(`td:empty::after` / `tr:has(td.req:empty)`);
|
||||
· `data-class-of="路径"` 把该值消毒成 `v-<值>` 追加进 class(只放行
|
||||
ASCII 字母数字与横杠、封顶 24 字;一个合规字符都没有就不给 class)——
|
||||
「A 绿 / B 蓝 / C 橙」这类按值分档的表现,靠它投影,不靠模版算。
|
||||
**两个档互不依赖**:`data-class-of` 不以 `data-slot` 为前提,
|
||||
纯色块(格里不写字,只按值上色)照样上得了色
|
||||
- **前端零过程状态**:对话流与视图都是服务端投影,刷新即重建;SSE 只推失效与内容,不推状态机
|
||||
- **时间线即任务记忆的可读投影**:自由输入同时写 `memory` 与抛人事件,无静默通道;
|
||||
AI 发言与系统事件**不另设写入口**,一律由 `projectChat` 从任务记忆筛出 ——
|
||||
|
||||
@@ -16,7 +16,7 @@ const entry = (kind: string, over: Partial<NarrativeEntry> = {}): NarrativeEntry
|
||||
|
||||
describe('projectChat —— 对话流与工作日志同源不同筛选', () => {
|
||||
it('引擎自转的条目一律不进对话区', () => {
|
||||
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'card-reply', 'control'];
|
||||
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'control'];
|
||||
expect(projectChat(noise.map((k) => entry(k)))).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -54,9 +54,174 @@ describe('projectChat —— 对话流与工作日志同源不同筛选', () =>
|
||||
expect(projectChat([entry('card-sent', { detail: { kind: 'chat-card', id: 'c-1' } })])).toEqual([]);
|
||||
});
|
||||
|
||||
it('放行裁决与终态投成阶段结论', () => {
|
||||
const out = projectChat([entry('release-verdict', { id: 'r' }), entry('terminal', { id: 't' })]);
|
||||
/*
|
||||
* **顺利收尾一条都不出**(2026-08-02 用户当场指出:「终态:released 删除」)。
|
||||
*
|
||||
* 从前这一段连出三条:「裁决:pass」「落地收敛,发射输出口:intake」「终态:released」——
|
||||
* 一件事说三遍。2026-08-01 先砍成一条(留终态);同日验收卡办结后换成了「落地结果」
|
||||
* (落了几行、进了哪几张表、转去哪儿了),那一条也就**没话可说了** ——
|
||||
* 在一件已经交代清楚的事后面补一句「结束了」,是废话;何况它摆的还是英文状态名。
|
||||
*/
|
||||
it('**顺利收尾:对话区一条都不出** —— 落地结果那一屏已经说全了', () => {
|
||||
const out = projectChat([
|
||||
entry('release-verdict', { id: 'r', title: '裁决:pass' }),
|
||||
entry('release-verdict', { id: 'r2', title: '落地收敛,发射输出口:intake' }),
|
||||
entry('terminal', { id: 't', title: '终态:released' }),
|
||||
]);
|
||||
expect(out).toHaveLength(0);
|
||||
});
|
||||
|
||||
/*
|
||||
* **没顺利收尾的仍要出声** —— 那时落地结果那一屏根本不会出现,
|
||||
* 而人需要知道这一单停在哪儿了。措辞取共用字典,不摆 `blocked` 这种词。
|
||||
*/
|
||||
it('卡住 / 退回 / 预算耗尽:出一条,且说人话', () => {
|
||||
for (const [state, said] of [
|
||||
['blocked', '这一单受阻待人'],
|
||||
['exhausted', '这一单预算耗尽'],
|
||||
['stalled', '这一单停滞熔断'],
|
||||
] as const) {
|
||||
const out = projectChat([entry('terminal', { id: 't', title: `终态:${state}` })]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe(said);
|
||||
expect(out[0]?.k === 'ai' ? out[0].tag : undefined).toBe('conclusion');
|
||||
}
|
||||
});
|
||||
|
||||
it('认不出的状态原样留着 —— 宁可笼统,不可编', () => {
|
||||
const out = projectChat([entry('terminal', { id: 't', title: '终态:什么新状态' })]);
|
||||
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe('终态:什么新状态');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **一个气泡:结论 + 功能卡**(2026-08-01 用户定的形状)。
|
||||
*
|
||||
* 交代与卡本是同一轮的两半 —— 结论说「这一轮办完了什么、为什么找你」,
|
||||
* 卡是「你要办的那件事」。各占一个气泡,每轮就是两条:一条话、
|
||||
* 一条只有标题加个按钮的卡,而卡的标题多半是那句话的缩写。
|
||||
*/
|
||||
describe('同一轮的交代与卡并成一条', () => {
|
||||
const say = (id: string, text: string) => entry('narration', { id, title: text });
|
||||
const card = (id: string, title: string, wo: string) =>
|
||||
entry('card-sent', { id, title, detail: { kind: 'work-order', id: wo } });
|
||||
|
||||
it('刚说过话就发卡 → 一个气泡,话是正文、卡挂在上面', () => {
|
||||
const out = projectChat([say('n', '九条料都拆好了,缺两格,请核对确认'), card('c', '受理需求确认', 'wo-1')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe('九条料都拆好了,缺两格,请核对确认');
|
||||
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-1');
|
||||
});
|
||||
|
||||
it('并进去之后**标记跟着卡走** —— 验收卡要 criterion,裁决表单才渲得出来', () => {
|
||||
const out = projectChat([say('n', '判据我都裁过了,交你签'), card('c', '收口验收', 'wo-2')]);
|
||||
expect(out[0]?.k === 'ai' ? out[0].tag : undefined).toBe('criterion');
|
||||
});
|
||||
|
||||
it('**没说话就自成一条** —— 卡不能不出现', () => {
|
||||
const out = projectChat([card('c', '受理需求确认', 'wo-3')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-3');
|
||||
});
|
||||
|
||||
it('一句话只领一张卡 —— 同拍两张卡的第二张自成一条,不覆盖第一张', () => {
|
||||
const out = projectChat([say('n', '两件事要你办'), card('a', '卡一', 'wo-a'), card('b', '卡二', 'wo-b')]);
|
||||
expect(out.map((m) => (m.k === 'ai' ? m.ref : undefined))).toEqual(['wo-a', 'wo-b']);
|
||||
});
|
||||
|
||||
it('隔着一条系统事件就不并 —— 那是上一轮的话,不是这张卡的结论', () => {
|
||||
const out = projectChat([
|
||||
say('n', '我去问客户'),
|
||||
entry('observation', { id: 'o', title: '客户回信了' }),
|
||||
card('c', '受理需求确认', 'wo-4'),
|
||||
]);
|
||||
expect(out).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('收尾那条结论不认领卡 —— 它是这一单的句号,不是派工', () => {
|
||||
// 用**没顺利收尾**的那种:released 起同日不再出声(见上一组)
|
||||
const out = projectChat([entry('terminal', { id: 't', title: '终态:blocked' }), card('c', '卡', 'wo-5')]);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.every((m) => m.k === 'ai' && m.tag === 'conclusion')).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* **结论口吻的交代照样并卡**。
|
||||
*
|
||||
* 挡住并卡的判断一度写成 `tag !== 'conclusion'` —— 而交代本身常常就是结论口吻
|
||||
* (「判据我都裁过了,交你签」),模型给它标 conclusion 完全正当。
|
||||
* 实测:第一轮并对了、收口那一轮没并 —— 同一屏两种样子。
|
||||
* 不许并卡的只有**收尾那一句**(terminal),按来路认,不按语气签认。
|
||||
*/
|
||||
it('模型把交代标成 conclusion,卡照样并进去', () => {
|
||||
const out = projectChat([
|
||||
entry('narration', { id: 'n', title: '判据我都裁过了,交你签', detail: { tag: 'conclusion' } }),
|
||||
card('c', '收口验收', 'wo-6'),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-6');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **AI 会不会说话** —— 这一类从前没有,后果是对话区从头到尾空白。
|
||||
*
|
||||
* 实测:A1 跑十几拍、写了七行台账、调了两次工具,负责人的对话区一个字也没有 ——
|
||||
* 他只看得见「多了一个任务」,不知道机器干到哪儿、凭什么这么判。
|
||||
* 「人只在责任点与放行点出现」不等于「过程中人什么都看不见」。
|
||||
*/
|
||||
describe('narration —— AI 对人说的话进对话区', () => {
|
||||
const entry = (kind: string, title: string, detail?: Record<string, unknown>) => ({
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
at: '2026-07-30T01:00:00.000Z',
|
||||
kind,
|
||||
title,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
});
|
||||
|
||||
it('交代进展投成 AI 发言', () => {
|
||||
const out = projectChat([entry('narration', '三条已归一,缺 1 个交期')]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ k: 'ai', text: '三条已归一,缺 1 个交期', tag: 'guidance' });
|
||||
});
|
||||
|
||||
it('语气签照活动标的来;标不出按「指导」计', () => {
|
||||
expect(projectChat([entry('narration', 'x', { tag: 'conclusion' })])[0]).toMatchObject({ tag: 'conclusion' });
|
||||
expect(projectChat([entry('narration', 'x', { tag: '我编的签' })])[0]).toMatchObject({ tag: 'guidance' });
|
||||
});
|
||||
|
||||
it('机器踪迹照旧不进对话区 —— 它不是日志的副本', () => {
|
||||
const out = projectChat([
|
||||
entry('action', '写草稿:quote_intake.record 3 行'),
|
||||
entry('engine-transition', 'manualStart:S_intake'),
|
||||
entry('result', 'crm.history-price-read 返回'),
|
||||
entry('thought', '装配技能:intake.read-rfq@2.0.0'),
|
||||
]);
|
||||
expect(out).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('回执与内部观察(PRD:页面交互是三类唤醒事件之一)', () => {
|
||||
it('执行工单回传投成人事件回执 —— 人办完一件事,对话流上要有一句人话的痕', () => {
|
||||
// 认的是 `detail.mode`,不是标题前缀:措辞是给人看的、随时会改,改了这条不该失灵
|
||||
const out = projectChat([
|
||||
entry('card-reply', { id: 'r1', title: '补全三条料的品牌', detail: { mode: 'execution' } }),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ k: 'system', type: 'human' });
|
||||
expect((out[0] as { text: string }).text).toBe('「补全三条料的品牌」已办结,AI 接着往下推。');
|
||||
});
|
||||
|
||||
it('对话卡回传不投 —— 卡自己会翻成已答态,再来一条回执是重影', () => {
|
||||
expect(projectChat([entry('card-reply', { id: 'r2', title: '补齐品牌', detail: { k: 'elicitation' } })])).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('引擎内部观察(internal 标)进日志不进对话', () => {
|
||||
expect(projectChat([entry('observation', { id: 'o9', title: 'S1 已在进行中 —— 无需再启动', detail: { internal: true } })])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('裸「事件到达(human):卡号」不投 —— 对人只是一串 UUID,回执已用人话说过', () => {
|
||||
expect(projectChat([entry('observation', { id: 'oa', title: '事件到达(human):abcd-123', detail: { kind: 'human' } })])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,3 +108,173 @@ describe('整键优先 —— 台账地址本身带点号', () => {
|
||||
expect(out).toContain('<td>LM358DR</td>');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `data-blank` —— 「这一格客户没给」与「这一格填了个破折号」是两件事。
|
||||
*
|
||||
* 报告里的必填 / 选填胶囊、缺必填的整行标底,全靠这一格**真的空**才生效
|
||||
* (`td:empty::after` / `tr:has(td.req:empty)`)。填了「—」就永远标不出来。
|
||||
*/
|
||||
describe('data-blank:空值真的留空', () => {
|
||||
it('空串 / null / 缺席一律留空,属性随槽位一并消费', () => {
|
||||
const html = '<td class="req" data-blank data-slot="eau"></td>';
|
||||
expect(renderView(html, { eau: '' })).toBe('<td class="req"></td>');
|
||||
expect(renderView(html, { eau: null })).toBe('<td class="req"></td>');
|
||||
expect(renderView(html, {})).toBe('<td class="req"></td>');
|
||||
});
|
||||
|
||||
it('有值照填 —— 0 与 false 都是值,不算空', () => {
|
||||
const html = '<td class="req" data-blank data-slot="v"></td>';
|
||||
expect(renderView(html, { v: 30_000 })).toBe('<td class="req">30000</td>');
|
||||
expect(renderView(html, { v: 0 })).toBe('<td class="req">0</td>');
|
||||
expect(renderView(html, { v: false })).toBe('<td class="req">否</td>');
|
||||
});
|
||||
|
||||
it('不带 data-blank 的照旧填「—」—— 老模版行为不动', () => {
|
||||
expect(renderView('<b data-slot="x"></b>', { x: '' })).toBe('<b>—</b>');
|
||||
});
|
||||
|
||||
it('逐行展开时每行各判各的空', () => {
|
||||
const out = renderView('<tbody data-rows="rs"><tr><td class="req" data-blank data-slot="eau"></td></tr></tbody>', {
|
||||
rs: [{ eau: 30_000 }, { eau: '' }],
|
||||
});
|
||||
expect(out).toBe('<tbody><tr><td class="req">30000</td></tr><tr><td class="req"></td></tr></tbody>');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `data-class-of` —— **按值上色**,模版仍然不许算。
|
||||
*
|
||||
* 「A 绿 / B 橙 / C 紫」这类分档表现,从前只能三个值一个色 —— 那等于假装读懂了值。
|
||||
* 把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。
|
||||
* 属性位是唯一一处让值进属性的地方,所以消毒必须硬。
|
||||
*/
|
||||
describe('data-class-of:按值上色', () => {
|
||||
it('值消毒成 v-<值> 追加进 class,属性随之消费', () => {
|
||||
const html = '<td class="pill" data-class-of="type_tag" data-slot="type_tag"></td>';
|
||||
expect(renderView(html, { type_tag: 'A' })).toBe('<td class="pill v-a">A</td>');
|
||||
expect(renderView(html, { type_tag: 'C' })).toBe('<td class="pill v-c">C</td>');
|
||||
});
|
||||
|
||||
it('没有 class 属性就新建一个', () => {
|
||||
expect(renderView('<i data-class-of="k" data-slot="k"></i>', { k: 'B' })).toBe('<i class="v-b">B</i>');
|
||||
});
|
||||
|
||||
it('布尔与数字都投影得出来 —— 是/否两态也要能分色', () => {
|
||||
expect(renderView('<b data-class-of="f" data-slot="f"></b>', { f: true })).toBe('<b class="v-true">是</b>');
|
||||
expect(renderView('<b data-class-of="n" data-slot="n"></b>', { n: 0 })).toBe('<b class="v-0">0</b>');
|
||||
});
|
||||
|
||||
it('**值只放行 ASCII 字母数字与横杠**:引号、尖括号、空格全剔掉,注不进属性', () => {
|
||||
const out = renderView('<b class="x" data-class-of="k" data-slot="k"></b>', { k: '" onclick=alert(1) <b>' });
|
||||
// class 里只剩白名单字符;文本位照旧转义 —— 两处都注不进去
|
||||
expect(out).toBe('<b class="x v-onclickalert1b">" onclick=alert(1) <b></b>');
|
||||
expect(out).not.toContain('onclick=alert(1)"');
|
||||
});
|
||||
|
||||
it('一个合规字符都没有(中文 / 空值)就不给 class —— 宁可没颜色,不给错钩子', () => {
|
||||
expect(renderView('<b class="x" data-class-of="k" data-slot="k"></b>', { k: '老客户' })).toBe('<b class="x">老客户</b>');
|
||||
expect(renderView('<b class="x" data-class-of="k" data-slot="k"></b>', {})).toBe('<b class="x">—</b>');
|
||||
});
|
||||
|
||||
it('逐行展开时每行按自己的值上色', () => {
|
||||
const out = renderView('<tbody data-rows="rs"><tr><td data-class-of="t" data-slot="t"></td></tr></tbody>', {
|
||||
rs: [{ t: 'A' }, { t: 'C' }],
|
||||
});
|
||||
expect(out).toBe('<tbody><tr><td class="v-a">A</td></tr><tr><td class="v-c">C</td></tr></tbody>');
|
||||
});
|
||||
|
||||
/*
|
||||
* **上色不以填字为条件** —— 这一组是补的账。
|
||||
*
|
||||
* 从前只认「带 data-slot 的元素」,于是分布条那种纯色块
|
||||
* (`<i data-class-of="type_tag"></i>`,一格一条料、格里不写字)整条静默失色:
|
||||
* 属性还留在成品上,一个 class 都没长出来。屏上是一排灰格子 ——
|
||||
* 而灰格子看着像「还没算出来」,不像「没上色」,查了两遍才认出来。
|
||||
* 上面每一条老用例都同时写了 data-slot,所以谁都没拦住它。
|
||||
*/
|
||||
it('**只上色不填字**:没有 data-slot 也照样长出 class', () => {
|
||||
expect(renderView('<i data-class-of="t"></i>', { t: 'B' })).toBe('<i class="v-b"></i>');
|
||||
expect(renderView('<i class="seg" data-class-of="t"></i>', { t: 'C' })).toBe('<i class="seg v-c"></i>');
|
||||
});
|
||||
|
||||
it('只上色的格子逐行展开,每格按自己那一行的值', () => {
|
||||
const out = renderView('<div class="bar" data-rows="rs"><i data-class-of="t"></i></div>', {
|
||||
rs: [{ t: 'A' }, { t: 'B' }, { t: 'C' }],
|
||||
});
|
||||
expect(out).toBe('<div class="bar"><i class="v-a"></i><i class="v-b"></i><i class="v-c"></i></div>');
|
||||
});
|
||||
|
||||
it('值取不到就把属性摘掉了事 —— 不留半成品属性到成品上', () => {
|
||||
expect(renderView('<i class="seg" data-class-of="nope"></i>', {})).toBe('<i class="seg"></i>');
|
||||
expect(renderView('<i data-class-of="k"></i>', { k: '老客户' })).toBe('<i></i>');
|
||||
});
|
||||
|
||||
it('壳上的 data-class-of 按外层作用域上色,行内的仍按各自那一行', () => {
|
||||
const out = renderView('<div data-class-of="hit" data-rows="rs"><i data-class-of="t"></i></div>', {
|
||||
hit: 'r1',
|
||||
rs: [{ t: 'A' }, { t: 'C' }],
|
||||
});
|
||||
expect(out).toBe('<div class="v-r1"><i class="v-a"></i><i class="v-c"></i></div>');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `data-edit` + `data-col` —— **就地改**:可改的格子渲两份,只读那份与输入孪生件。
|
||||
*
|
||||
* 为什么两份都渲、而不是渲成输入:同一份件既要在待处理页给人改,
|
||||
* 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉。
|
||||
* 显示哪一份由样式表按根节点上的 `editing` 决定,填数这一侧仍然只做取值。
|
||||
*/
|
||||
describe('data-edit / data-col:可改格子的输入孪生件', () => {
|
||||
const ROWS = '<tbody data-rows="rs" data-edit="d.o"><tr><td><span class="jc-vcell mono" data-slot="pn" data-col="pn"></span></td></tr></tbody>';
|
||||
|
||||
it('容器标了 data-edit,格子就多一个带值的 input', () => {
|
||||
const out = renderView(ROWS, { rs: [{ pn: 'AO6604' }] });
|
||||
expect(out).toContain('<span class="jc-vcell mono">AO6604</span>');
|
||||
expect(out).toContain('<input class="jc-cell mono" data-col="pn" data-row="0" value="AO6604"/>');
|
||||
});
|
||||
|
||||
it('没标 data-edit 的表一个 input 都不长 —— 归档件就是件,不是表单', () => {
|
||||
const out = renderView(ROWS.replace(' data-edit="d.o"', ''), { rs: [{ pn: 'AO6604' }] });
|
||||
expect(out).not.toContain('<input');
|
||||
});
|
||||
|
||||
it('行号钉在每一行上 —— 写回时按它对行', () => {
|
||||
const out = renderView(ROWS, { rs: [{ pn: 'A' }, { pn: 'B' }, { pn: 'C' }] });
|
||||
expect(out.match(/data-row="\d"/g)).toEqual(['data-row="0"', 'data-row="1"', 'data-row="2"']);
|
||||
});
|
||||
|
||||
it('空值的 input 真空着 —— 空的输入框显示自己的 placeholder,那正是「这一格还没有」', () => {
|
||||
const out = renderView(ROWS, { rs: [{ pn: '' }] });
|
||||
expect(out).toContain('value=""');
|
||||
});
|
||||
|
||||
it('**值进属性也要转义** —— 属性位插值只此一处,引号进去就是逃逸', () => {
|
||||
const out = renderView(ROWS, { rs: [{ pn: '" onfocus=alert(1) x="' }] });
|
||||
expect(out).toContain('value="" onfocus=alert(1) x=""');
|
||||
expect(out).not.toMatch(/value="" onfocus/);
|
||||
});
|
||||
|
||||
it('排版类跟着走(mono / num),别的类不带过去 —— 同一列两态字体要一致', () => {
|
||||
const num = ROWS.replace('jc-vcell mono', 'jc-vcell num req');
|
||||
expect(renderView(num, { rs: [{ pn: '30000' }] })).toContain('<input class="jc-cell num"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('渲染两遍不叠加 —— 交出去的是干净 HTML', () => {
|
||||
it('第二遍渲染不会再长一个 input(属性已被消费)', () => {
|
||||
const once = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
|
||||
rs: [{ pn: 'X' }],
|
||||
});
|
||||
expect((once.match(/<input/g) ?? []).length).toBe(1);
|
||||
expect(renderView(once, {})).toBe(once);
|
||||
});
|
||||
|
||||
it('模版里仍然没有脚本 —— 输入件是纯 HTML,没有事件处理器', () => {
|
||||
const out = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
|
||||
rs: [{ pn: 'X' }],
|
||||
});
|
||||
expect(out).not.toMatch(/<script|\son[a-z]+=/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,9 +40,16 @@ export type InteractionService = {
|
||||
/** 视图流控:活动按局面推结构化块 */
|
||||
pushView(taskId: TaskId, blocks: readonly ViewBlock[]): Promise<void>;
|
||||
/** 视图流控:活动推 HTML 模版 —— 过净化门,不过即抛 */
|
||||
pushViewHtml(taskId: TaskId, html: string): Promise<void>;
|
||||
/**
|
||||
* 推一帧 HTML。`template` **要一起存下来** —— 件是投影,而投影得能按当下数据**重渲**。
|
||||
*
|
||||
* 只存渲染好的 HTML,那一帧就成了快照:人在件上改完几格,由数据算出来的东西
|
||||
* (类徽 A/B/C、分布条、结论横幅那条去向)全停在渲染那一刻 ——
|
||||
* 屏上一半新一半旧,那不是没更新,是界面在撒谎。记住模版名,才重得出来。
|
||||
*/
|
||||
pushViewHtml(taskId: TaskId, html: string, template?: string): Promise<void>;
|
||||
/** 视图区当前呈现什么 —— 只读投影,刷新即重建(前端零过程状态) */
|
||||
viewOf(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string } | undefined>;
|
||||
viewOf(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string; template?: string } | undefined>;
|
||||
|
||||
/** 控制权转交 —— 随引擎的激活 / 完成事件自动发生 */
|
||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<ControlHolder>;
|
||||
@@ -50,6 +57,17 @@ export type InteractionService = {
|
||||
|
||||
/** SSE:单连接、按信封目标失效缓存 */
|
||||
subscribe(staffId: StaffId, onPush: (envelope: PushEnvelope) => void): Subscription;
|
||||
/**
|
||||
* **系统级转接**:装配层把所有推送接出去(转给前端的 SSE 广播),不按人筛。
|
||||
*
|
||||
* 为什么不复用 `subscribe`:那个要一个收件人,而转接是系统的事 ——
|
||||
* 为它编一个假员工 id 是撒谎。不按人筛也不漏:推送**不带业务载荷**
|
||||
* (只说「哪个任务动了」),谁该看见由前端自己那几把查询决定(§9.2 前端零过程状态)。
|
||||
*
|
||||
* 没有这个口子的后果实测过:AI 说的每一句、推的每一帧画面都广播给了空气 ——
|
||||
* `subscribe` 一个订阅者都没有,前端只好靠人点刷新。
|
||||
*/
|
||||
onPush(listener: (envelope: PushEnvelope) => void): Subscription;
|
||||
/** 供上层广播失效信号 */
|
||||
invalidate(target: 'task' | 'workflow' | 'desk', id: string): void;
|
||||
};
|
||||
|
||||
@@ -21,6 +21,8 @@ export const taskViewTable = sys.table('task_view', {
|
||||
taskId: text('task_id').primaryKey(),
|
||||
blocks: jsonb('blocks'),
|
||||
html: text('html'),
|
||||
/** 这一帧用的模版名 —— 数据变了要按同一张重渲(件是投影,不是快照) */
|
||||
template: text('template'),
|
||||
phase: text('phase').notNull().default('activating'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
@@ -28,11 +28,13 @@ export function createInteractionStore(db: Db): InteractionStore {
|
||||
.orderBy(asc(chatMessageTable.at));
|
||||
return rows.map((row) => chatMessageSchema.parse(row.payload));
|
||||
},
|
||||
async putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }) {
|
||||
async putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string; template?: string }) {
|
||||
const values = {
|
||||
taskId,
|
||||
blocks: view.blocks === undefined ? null : [...view.blocks],
|
||||
html: view.html ?? null,
|
||||
// 模版名一起存:件是投影 —— 数据变了要按同一张重渲(见迁移 0028 的注释)
|
||||
template: view.template ?? null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
await db
|
||||
@@ -40,7 +42,7 @@ export function createInteractionStore(db: Db): InteractionStore {
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: taskViewTable.taskId,
|
||||
set: { blocks: values.blocks, html: values.html, updatedAt: values.updatedAt },
|
||||
set: { blocks: values.blocks, html: values.html, template: values.template, updatedAt: values.updatedAt },
|
||||
});
|
||||
},
|
||||
async getView(taskId: TaskId) {
|
||||
@@ -49,6 +51,7 @@ export function createInteractionStore(db: Db): InteractionStore {
|
||||
return {
|
||||
...(row.blocks === null ? {} : { blocks: row.blocks as ViewBlock[] }),
|
||||
...(row.html === null ? {} : { html: row.html }),
|
||||
...(row.template === null ? {} : { template: row.template }),
|
||||
};
|
||||
},
|
||||
async setPhase(taskId: TaskId, phase: InteractionPhase) {
|
||||
|
||||
@@ -7,14 +7,26 @@
|
||||
* 否则对话区就成了机器踪迹的副本,失去「非结构化消息」的定位。
|
||||
*
|
||||
* 筛四类:
|
||||
* narration → **AI 发言**:一轮办完时对人说的那一句。
|
||||
* 这一类从前没有,后果是对话区从头到尾空白 —— A1 跑十几拍写了七行台账、
|
||||
* 调了两次工具,而人在对话区一个字看不到,只看得见「多了一个任务」。
|
||||
* 机器踪迹(engine-transition / action / result)照旧不进:
|
||||
* 对话区不是日志的副本,它是**AI 对人说的话**。
|
||||
* observation → 系统事件(灰头像)—— 三类唤醒事件里的外部到达
|
||||
* card-sent → AI 发言 + 指涉工单(卡在对话流上出现,点开即定位到视图区)
|
||||
* release-verdict→ AI 发言 · 阶段结论
|
||||
* terminal → AI 发言 · 阶段结论
|
||||
* card-sent → **并进上一条 AI 发言**,合成「结论 + 功能卡」一个气泡;
|
||||
* 没有可并的就自成一条(卡不能不出现)
|
||||
* terminal → AI 发言 · 阶段结论(收尾只此一条)
|
||||
* human-input 不投:人的话已由 `say` 直接落成消息,再投一遍就是重影。
|
||||
* release-verdict 不投:裁决是人自己刚按下的,发了哪几个口是编排层的事实 —— 都在工作日志里。
|
||||
*
|
||||
* ── 密度(2026-08-01 用户定)─────────────────────────────────────────
|
||||
* **一轮一个气泡**:设定目标 →(看现状 → 定做法 → 动手)× N 轮 → 交代一次,
|
||||
* 可以是仅结论,也可以是结论 + 功能卡两部分。埋头办事的拍不出气泡 ——
|
||||
* 那一段由「AI 工作中」的办事条覆盖(哪些拍算收口见 `turn-runner` 的 `handsOff`)。
|
||||
* 这一层做的是后半段:把同一轮的交代与卡并成一条,并把收尾那三条引擎回声收成一条。
|
||||
*/
|
||||
import type { ChatMessage, EventType } from '@contracts';
|
||||
import { cardIdSchema, workOrderIdSchema } from '@contracts';
|
||||
import type { ChatMessage, EventType, TerminalState } from '@contracts';
|
||||
import { cardIdSchema, TERMINAL_STATE_LABEL, workOrderIdSchema } from '@contracts';
|
||||
|
||||
/** 记忆条目的只读切面 —— 投影只要这几样,不牵入 memory 的完整类型 */
|
||||
export type NarrativeEntry = {
|
||||
@@ -40,31 +52,130 @@ function workOrderRef(detail: Record<string, unknown> | undefined): string | und
|
||||
return detail?.['kind'] === 'work-order' && typeof detail['id'] === 'string' ? detail['id'] : undefined;
|
||||
}
|
||||
|
||||
/** 发言的语气签:活动自己标,标不出按「进展」计 —— 三种签在前端各有其样式 */
|
||||
const TAGS: readonly string[] = ['guidance', 'criterion', 'conclusion'];
|
||||
function tagOf(detail: Record<string, unknown> | undefined): 'guidance' | 'criterion' | 'conclusion' {
|
||||
const tag = detail?.['tag'];
|
||||
return typeof tag === 'string' && TAGS.includes(tag) ? (tag as 'guidance' | 'criterion' | 'conclusion') : 'guidance';
|
||||
}
|
||||
|
||||
export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
|
||||
const out: ChatMessage[] = [];
|
||||
/*
|
||||
* 上一条是不是**收尾那句**(terminal)—— 只有它不许认领后面的卡。
|
||||
*
|
||||
* 不能拿 `tag === 'conclusion'` 当这个判断:交代本身常常就是结论口吻
|
||||
* (「判据我都裁过了,交你签」),模型给它标 conclusion 完全正当。
|
||||
* 实测栽过一次:收口那一轮的交代标了 conclusion,于是并卡被这条挡下,
|
||||
* 验收卡又自成一条 —— 第一轮并对了、第二轮没并,同一屏两种样子。
|
||||
*/
|
||||
let lastIsTerminal = false;
|
||||
for (const e of entries) {
|
||||
const id = cardIdSchema.parse(e.id);
|
||||
if (e.kind === 'observation') {
|
||||
out.push({ k: 'system', id, at: e.at, type: eventTypeOf(e.detail), text: e.title });
|
||||
if (e.kind === 'narration') {
|
||||
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: tagOf(e.detail) });
|
||||
lastIsTerminal = false;
|
||||
continue;
|
||||
}
|
||||
/*
|
||||
* 触发事件与系统观察在对话区**长一个样**(都是灰头像的系统气泡)——
|
||||
* 分家分在归侧与日志上,不在对话区:人在对话流里要的是「发生了什么」,
|
||||
* 不是「这条属于哪一类」。
|
||||
*/
|
||||
if (e.kind === 'trigger' || e.kind === 'observation') {
|
||||
// 引擎内部观察(已在进行中 / 批截断…)进日志不进对话 —— 对话区不是机器踪迹的副本
|
||||
if (e.detail?.['internal'] === true) continue;
|
||||
/*
|
||||
* 裸「事件到达(human):卡号」也不进:对人它只是一串 UUID,而这件事
|
||||
* 已由**回执**(card-reply 投影)用人话说过一遍 —— 同一件事不说两遍。
|
||||
* 认的是 `detail.kind`(结构化标记),不是标题前缀:措辞改一次,
|
||||
* 按前缀认的过滤就静默失灵,而失灵的样子是「对话区多出一串 UUID」。
|
||||
*/
|
||||
if (e.detail?.['kind'] === 'human') continue;
|
||||
out.push({
|
||||
k: 'system',
|
||||
id,
|
||||
at: e.at,
|
||||
type: eventTypeOf(e.detail),
|
||||
text: e.title,
|
||||
// 起单那句开场白带「查看邮件」—— 其余系统事件没有去处,不加这个键
|
||||
...(e.detail?.['openComms'] === true ? { openComms: true } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
/*
|
||||
* 认的是 `detail.mode`,**不是标题前缀**(2026-07-31 改)——
|
||||
* 从前判的是 `title.startsWith('执行工单回传:')`,而标题措辞是给人看的、随时会改;
|
||||
* 改一次这条投影就静默失灵:人办结了工单,对话区一句话也不出,像没发生过。
|
||||
* 结构化标记改不动,措辞怎么改都不影响。
|
||||
*/
|
||||
if (e.kind === 'card-reply' && e.detail?.['mode'] === 'execution') {
|
||||
/*
|
||||
* 页面交互的**回执**(PRD:三类唤醒事件之一)——人在视图区办结了一件工单,
|
||||
* 对话流上要有一句人话的痕:「工单已办结,AI 接着往下推」。
|
||||
* 没有它,人办完一件事回到对话区,看到的还是办之前那句话 —— 像没发生过。
|
||||
*/
|
||||
out.push({
|
||||
k: 'system',
|
||||
id,
|
||||
at: e.at,
|
||||
type: 'human',
|
||||
text: `「${e.title}」已办结,AI 接着往下推。`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (e.kind === 'card-sent') {
|
||||
const ref = workOrderRef(e.detail);
|
||||
// 跨人卡进对方的消息要素,不在本任务对话流上露面
|
||||
if (ref === undefined) continue;
|
||||
out.push({
|
||||
k: 'ai',
|
||||
id,
|
||||
at: e.at,
|
||||
text: e.title,
|
||||
// 验收件请人裁决 = 判据;执行件派工 = 指导
|
||||
tag: e.title.includes('验收') ? 'criterion' : 'guidance',
|
||||
ref: workOrderIdSchema.parse(ref),
|
||||
});
|
||||
// 验收件请人裁决 = 判据;执行件派工 = 指导
|
||||
const tag = e.title.includes('验收') ? ('criterion' as const) : ('guidance' as const);
|
||||
/*
|
||||
* **一个气泡:结论 + 功能卡**(2026-08-01 用户定的形状)。
|
||||
*
|
||||
* 交代与卡本是同一轮的两半 —— 结论说「这一轮办完了什么、为什么找你」,
|
||||
* 卡是「你要办的那件事」。从前各占一个气泡,于是每轮都是两条:
|
||||
* 一条话、一条只有标题加个按钮的卡,而卡的标题多半是那句话的缩写。
|
||||
*
|
||||
* 这一拍刚说过话(紧邻的上一条就是它,turn-runner 保证交代先于行动),
|
||||
* 就把卡挂到那句话上,不另起一条。没说话就自己成一条 —— 卡不能不出现。
|
||||
*/
|
||||
const last = out[out.length - 1];
|
||||
if (last?.k === 'ai' && last.ref === undefined && !lastIsTerminal) {
|
||||
out[out.length - 1] = { ...last, tag, ref: workOrderIdSchema.parse(ref) };
|
||||
continue;
|
||||
}
|
||||
out.push({ k: 'ai', id, at: e.at, text: e.title, tag, ref: workOrderIdSchema.parse(ref) });
|
||||
continue;
|
||||
}
|
||||
if (e.kind === 'release-verdict' || e.kind === 'terminal') {
|
||||
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: 'conclusion' });
|
||||
/*
|
||||
* **收尾只出一条**(2026-08-01)。
|
||||
*
|
||||
* 从前这一段连出三条:「裁决:pass」「落地收敛,发射输出口:intake、to-alternative」
|
||||
* 「终态:released」—— 一件事说三遍,而且前两条是引擎内部话:
|
||||
* 裁决是**人自己刚按下的**,不必读回给他听;发了哪几个口是编排层的事实,
|
||||
* 它在工作日志里(那一屏本来就是给排障看的),不该占对话区一条。
|
||||
* 留下的是终态那一条:这一单结没结、怎么结的 —— 那才是他要知道的。
|
||||
*/
|
||||
if (e.kind === 'terminal') {
|
||||
/*
|
||||
* **顺利收尾的那一条不再出声**(2026-08-02 用户当场指出:「终态:released 删除」)。
|
||||
*
|
||||
* 两个毛病:①摆的是 `终态:released` —— 系统内的英文状态名,界面不摆这种词;
|
||||
* ②**它已经没有话可说了** —— 同日起,人签完字之后验收卡那一屏换成了「落地结果」
|
||||
* (落了几行、进了哪几张表、转去哪儿了),该说的它全说了,而且说得具体得多。
|
||||
* 再补一条「结束了」,是在一件已经交代清楚的事后面加一句废话。
|
||||
*
|
||||
* **没顺利收尾的仍要出声**:卡住了、被退回、预算耗尽 —— 那时人需要知道
|
||||
* 「这一单停在哪儿了」,而落地结果那一屏根本不会出现。措辞取共用的
|
||||
* `TERMINAL_STATE_LABEL`(与任务卡、日志同一本字典),不在这儿另抄一份。
|
||||
*/
|
||||
const state = /(?:终态[::]\s*)?([a-z]+)\s*$/.exec(e.title)?.[1];
|
||||
const label = state === undefined ? undefined : TERMINAL_STATE_LABEL[state as TerminalState];
|
||||
if (state !== 'released') {
|
||||
out.push({ k: 'ai', id, at: e.at, text: label === undefined ? e.title : `这一单${label}`, tag: 'conclusion' });
|
||||
lastIsTerminal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -48,6 +48,27 @@ export function displayValue(value: unknown): string {
|
||||
}
|
||||
|
||||
const SLOT = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g;
|
||||
/** `data-blank`:这一格空着的时候**真的留空**,不填「—」 */
|
||||
const BLANK = /\s*\bdata-blank(?:\s*=\s*"[^"]*")?/g;
|
||||
/** `data-class-of="路径"`:把该路径的值消毒成一个 class 后缀 —— **按值上色**用 */
|
||||
const CLASS_OF = /\s*\bdata-class-of\s*=\s*"([^"]+)"/;
|
||||
|
||||
/**
|
||||
* 值 → class 后缀。**只留 ASCII 字母数字与横杠,全部小写,封顶 24 字**;
|
||||
* 一个字符都不合规就不给 class(宁可没有颜色,不给一个错的钩子)。
|
||||
*
|
||||
* 为什么要有它:模版不许算,于是「A 绿 / B 橙 / C 紫」这类**按值分档的表现**
|
||||
* 从前一概做不了 —— 只能三个值一个色,那等于假装读懂了值。
|
||||
* 而把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。
|
||||
* 消毒是硬的:属性位不开放插值,这里是唯一一处值进属性,所以只放行白名单字符。
|
||||
*/
|
||||
export function classToken(value: unknown): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
const text = String(typeof value === 'boolean' ? (value ? 'true' : 'false') : value)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '');
|
||||
return text.slice(0, 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把作用域填进模版的槽位。
|
||||
@@ -55,13 +76,72 @@ const SLOT = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*)>([\s
|
||||
* 填充**消费槽位**:填完就把 `data-slot` 摘掉 —— 一来第二遍不会再拿外层作用域
|
||||
* 把行内已填好的值冲掉,二来交出去的是干净 HTML,不是带模板属性的半成品。
|
||||
* 未命中的槽位填「—」而不是留原样:模版里的占位文字漏到成品上比空着更糟。
|
||||
*
|
||||
* ── 例外:`data-blank` ────────────────────────────────────────────────
|
||||
* 表格里「这一格客户没给」和「这一格填了个破折号」是两件事。带 `data-blank`
|
||||
* 的槽位空值就**留空**,交给模版自己的 CSS 去标(`td:empty::after` 出「必填 / 选填」
|
||||
* 胶囊、`tr:has(td.req:empty)` 整行标底)—— 判断仍在样式里,不在这里:
|
||||
* 填数还是只做取值与转义两件事,没有表达式、没有分支。
|
||||
*/
|
||||
export function renderTemplate(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return html.replace(SLOT, (_all, tag: string, before: string, path: string, after: string) => {
|
||||
const attrs = `${before}${after}`.replace(/\s+/g, ' ').trimEnd();
|
||||
const text = escapeHtml(displayValue(resolveSlot(scope, path)));
|
||||
const filled = html.replace(SLOT, (_all, tag: string, before: string, path: string, after: string) => {
|
||||
const raw = `${before}${after}`;
|
||||
const blank = /\bdata-blank\b/.test(raw);
|
||||
const attrs = paintClass(raw.replace(BLANK, ''), scope).replace(/\s+/g, ' ').trimEnd();
|
||||
const value = resolveSlot(scope, path);
|
||||
const empty = value === undefined || value === null || value === '';
|
||||
const text = blank && empty ? '' : escapeHtml(displayValue(value));
|
||||
return `<${tag}${attrs === '' ? '' : ` ${attrs.trim()}`}>${text}</${tag}>`;
|
||||
});
|
||||
/*
|
||||
* **值进 value 属性的那一种** —— `<input … data-slot="pn" data-value/>`。
|
||||
*
|
||||
* 上一遍认的是「开合标签之间填字」,而 input 是空元素,没有「之间」。
|
||||
* 这一份是可改格子的输入孪生件(见 `twinInputs`):值得进 `value=`,
|
||||
* 空值就真空着 —— 空的 input 会显示自己的 placeholder,那正是「这一格还没有」。
|
||||
* 属性位插值只此一处,所以转义照文本位那四个字符来,引号也在其中。
|
||||
*/
|
||||
const withValues = filled.replace(
|
||||
/<input\b([^>]*?)\bdata-slot\s*=\s*"([^"]+)"([^>]*?)\bdata-value\b([^>]*)>/g,
|
||||
(_all, before: string, path: string, mid: string, after: string) => {
|
||||
const value = resolveSlot(scope, path);
|
||||
const empty = value === undefined || value === null || value === '';
|
||||
// 末尾那个自闭合斜杠会被 `after` 一并捞进来 —— 不摘掉就写出 `/ value="…"`
|
||||
const attrs = `${before}${mid}${after}`.replace(/\/\s*$/, '').replace(/\s+/g, ' ').trim();
|
||||
return `<input ${attrs} value="${empty ? '' : escapeHtml(displayValue(value))}"/>`;
|
||||
},
|
||||
);
|
||||
/*
|
||||
* **只上色、不填字的那些格子** —— 剩下的 `data-class-of` 单独走一遍。
|
||||
*
|
||||
* 上一遍只认「带 data-slot 的元素」,于是分布条那种**纯色块**
|
||||
* (`<i data-class-of="type_tag"></i>`,一格一条料、格里不写字)整条静默失色:
|
||||
* 属性还在,一个 class 都没长出来,屏上是一排灰格子 —— 而灰格子看着像
|
||||
* 「还没算出来」,不像「没上色」。两遍都栽过同一个坑,所以把两件事分开:
|
||||
* 取值填字是一件事,按值上色是另一件事,后者不该以前者为条件。
|
||||
*/
|
||||
return withValues.replace(
|
||||
/<([a-zA-Z][\w-]*)\b([^>]*?\bdata-class-of\s*=\s*"[^"]+"[^>]*)>/g,
|
||||
(_all, tag: string, attrs: string) => {
|
||||
const painted = paintClass(attrs, scope).replace(/\s+/g, ' ').trimEnd();
|
||||
return `<${tag}${painted === '' ? '' : ` ${painted.trim()}`}>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `data-class-of="路径"` → 把该路径的值消毒后追加成 `v-<值>`,并**消费掉属性本身**。
|
||||
* 消费是要紧的:交出去的是干净 HTML,行模版复制第二遍时也不会再追加一次。
|
||||
*/
|
||||
function paintClass(attrs: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
const classOf = CLASS_OF.exec(attrs);
|
||||
if (classOf === null) return attrs;
|
||||
const rest = attrs.replace(CLASS_OF, '');
|
||||
const token = classToken(resolveSlot(scope, classOf[1] ?? ''));
|
||||
if (token === '') return rest;
|
||||
return /\bclass\s*=\s*"[^"]*"/.test(rest)
|
||||
? rest.replace(/\bclass\s*=\s*"([^"]*)"/, (_m, cls: string) => `class="${cls} v-${token}"`)
|
||||
: `${rest} class="v-${token}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,19 +152,57 @@ const ROWS = /<([a-zA-Z][\w-]*)\b([^>]*?)\bdata-rows\s*=\s*"([^"]+)"([^>]*)>([\s
|
||||
|
||||
export function renderRows(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return html.replace(ROWS, (_all, tag: string, before: string, path: string, after: string, inner: string) => {
|
||||
const attrs = `${before}${after}`.replace(/\s+/g, ' ').trim();
|
||||
const raw = `${before}${after}`;
|
||||
const attrs = raw.replace(/\s+/g, ' ').trim();
|
||||
const open = `<${tag}${attrs === '' ? '' : ` ${attrs}`}>`;
|
||||
const value = resolveSlot(scope, path);
|
||||
const rows = Array.isArray(value) ? value : [];
|
||||
/*
|
||||
* 这一组行**可就地改**吗 —— 容器上的 `data-edit="域.对象"` 说了算。
|
||||
* 是的话每一行多渲一份输入孪生件,并把行号钉在行上(写回时按它对行)。
|
||||
*/
|
||||
const editable = /\bdata-edit\s*=\s*"([^"]+)"/.exec(raw) !== null;
|
||||
const body = rows
|
||||
.map((row) =>
|
||||
renderTemplate(inner, (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>),
|
||||
)
|
||||
.map((row, index) => {
|
||||
const one = (typeof row === 'object' && row !== null ? row : { value: row }) as Record<string, unknown>;
|
||||
return renderTemplate(editable ? twinInputs(inner, index) : inner, one);
|
||||
})
|
||||
.join('');
|
||||
return `${open}${body}</${tag}>`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 带 `data-col` 的取值元素:`<span … data-slot="pn" data-col="PN">` */
|
||||
const EDITABLE_CELL = /<(span|b)\b([^>]*?\bdata-col\s*=\s*"([^"]+)"[^>]*)><\/\1>/g;
|
||||
|
||||
/**
|
||||
* **给可改的格子配一个输入孪生件** —— 只读那一份原样留着,旁边多一个 `<input>`,
|
||||
* 两者由样式表二选一显示(根节点带不带 `editing`)。
|
||||
*
|
||||
* 为什么是「都渲出来」而不是「渲成输入」:同一份件既要在待处理页给人改,
|
||||
* 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉;
|
||||
* 而容器是 `sandbox="allow-same-origin"` 的无脚本 iframe,外层**读得到也改得动**
|
||||
* 里面的 DOM:开编辑就是给根节点加个 class,收值就是把 input 读出来。
|
||||
* 模版这一侧仍然只做取值 —— 判断(显示哪一份)留在样式表。
|
||||
*
|
||||
* `data-col` 写的是**台账列名**,不是表头文字:写回时基座按列名对格,
|
||||
* 表头改个说法不该让写回错位。
|
||||
*/
|
||||
function twinInputs(inner: string, rowIndex: number): string {
|
||||
return inner.replace(EDITABLE_CELL, (all, _tag: string, attrs: string, col: string) => {
|
||||
const slot = /\bdata-slot\s*=\s*"([^"]+)"/.exec(attrs)?.[1];
|
||||
if (slot === undefined) return all;
|
||||
// 只读那一份的排版类(mono / num)照搬给输入件 —— 同一列两态字体要一致
|
||||
const style = (/\bclass\s*=\s*"([^"]*)"/.exec(attrs)?.[1] ?? '')
|
||||
.split(/\s+/)
|
||||
.filter((c) => c === 'mono' || c === 'num')
|
||||
.join(' ');
|
||||
const input = `<input class="jc-cell${style === '' ? '' : ` ${style}`}" data-col="${col}" data-row="${rowIndex}" data-slot="${slot}" data-value/>`;
|
||||
// 只读那一份把 `data-col` 交出去 —— 它的活已经干完(告诉这里是哪一列),留着只是噪音
|
||||
return `${all.replace(/\s*\bdata-col\s*=\s*"[^"]*"/, '')}${input}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
|
||||
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
return renderTemplate(renderRows(html, scope), scope);
|
||||
|
||||
@@ -19,7 +19,8 @@ export type InteractionServiceDeps = {
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
type Subscriber = { staffId: StaffId; onPush: (envelope: PushEnvelope) => void };
|
||||
/** 订阅者:`staffId` 缺省 = 系统级转接(装配层接走全部推送,转给前端 SSE) */
|
||||
type Subscriber = { staffId: StaffId | undefined; onPush: (envelope: PushEnvelope) => void };
|
||||
|
||||
export function createInteractionService(deps: InteractionServiceDeps): InteractionService {
|
||||
// SSE 订阅者表是**连接级**状态,不是过程状态:进程内持有,刷新即从投影重建(§9.2)
|
||||
@@ -70,10 +71,11 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
|
||||
},
|
||||
|
||||
/** 视图模版过净化门:含脚本 / 事件处理器 / 外部资源一律拒推 */
|
||||
async pushViewHtml(taskId, html) {
|
||||
async pushViewHtml(taskId, html, template) {
|
||||
const violations = checkTemplate(html);
|
||||
if (violations.length > 0) throw new TemplateRejectedError(violations);
|
||||
await deps.store.putView(taskId, { html });
|
||||
// 模版名一起存:件是投影,数据变了要能按同一张模版重渲(见 contract 的注释)
|
||||
await deps.store.putView(taskId, { html, ...(template === undefined ? {} : { template }) });
|
||||
broadcast({ kind: 'view-html', taskId, html });
|
||||
},
|
||||
|
||||
@@ -98,6 +100,13 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
|
||||
return { close: () => void subscribers.delete(subscriber) } satisfies Subscription;
|
||||
},
|
||||
|
||||
/** 系统级转接 —— 装配层接走全部推送,转给前端 SSE(见 contract 的注释) */
|
||||
onPush(listener) {
|
||||
const bridge: Subscriber = { staffId: undefined, onPush: listener };
|
||||
subscribers.add(bridge);
|
||||
return { close: () => void subscribers.delete(bridge) } satisfies Subscription;
|
||||
},
|
||||
|
||||
invalidate(target, id) {
|
||||
broadcast({ kind: 'invalidate', target, id });
|
||||
},
|
||||
|
||||
@@ -11,8 +11,8 @@ export type IdGenerator = { next(): string };
|
||||
export type InteractionStore = {
|
||||
appendMessage(taskId: TaskId, message: ChatMessage): Promise<void>;
|
||||
listMessages(taskId: TaskId): Promise<ChatMessage[]>;
|
||||
putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string }): Promise<void>;
|
||||
getView(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string } | undefined>;
|
||||
putView(taskId: TaskId, view: { blocks?: readonly ViewBlock[]; html?: string; template?: string }): Promise<void>;
|
||||
getView(taskId: TaskId): Promise<{ blocks?: readonly ViewBlock[]; html?: string; template?: string } | undefined>;
|
||||
setPhase(taskId: TaskId, phase: InteractionPhase): Promise<void>;
|
||||
phaseOf(taskId: TaskId): Promise<InteractionPhase | undefined>;
|
||||
};
|
||||
|
||||
@@ -25,8 +25,17 @@
|
||||
不变量:
|
||||
- **写三分是三个方法**:`systemWrite` / `draftWrite` / `releaseWrite` —— 无第四条写入路径,
|
||||
调用方拿不到带 flag 的「绕门」形态(§12.5#9)
|
||||
- `draftDrop` **不是第四种写**:它只撤本单草稿层那一笔 —— 正式版一个字都动不了,
|
||||
也不抛事件,放行门的位置没变。写错了得有退路:实测过一次死胡同 ——
|
||||
客户说本轮跳过的那颗料被写进有效清单,判据正确地拦住而模型改不动
|
||||
(唯一能改的是那个数,改了别的数又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||
**门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。**
|
||||
撤不到如实回 `false`(主键写法对不上是常见原因)—— 静默成功会让调用方以为撤掉了
|
||||
- 正式版写入(system / release)**必**产生数据事件;草稿写**必不**产生
|
||||
- `releaseWrite` 先过数据冲突检查:任一冲突即抛 `DraftConflictError`,不做部分合并
|
||||
- **试跑单的主数据不进正式表**:`SimulationProbe` 说这一单是试跑时,
|
||||
`kind = 'master'` 的草稿行不 upsert、不删、不抛数据事件,但仍进 `merged`(出口载荷取它);
|
||||
`simulatedRows` 单独计数,不混进 `mergedRows`。单据(`record`)不受影响 —— 试跑仍是真跑
|
||||
- 合并只增不破坏:`planSchemaChanges` 永不产出删列 / 缩窄;主键一经建立不得由声明变更
|
||||
- 同名异型取能兼容双方的最宽类型(integer→number→text;date→timestamp→text);
|
||||
`json` 与标量语义互斥 —— 拦下,交数据管理裁决
|
||||
@@ -34,7 +43,8 @@
|
||||
- `ledger` schema 的唯一写者是本模块 adapter;SQL 拼接只在 `ledger-store.ts`,
|
||||
标识符过 `IDENT` 正则 + `quoteIdent`
|
||||
|
||||
影响半径: index/contract/port 未变 → 对外零影响
|
||||
影响半径: contract 加 `ReleaseWriteResult.simulatedRows`(加性)· port 加 `LedgerStore.masterRefs`
|
||||
与 `SimulationProbe`(app 注入)→ 下游只有 release 需同步读新字段
|
||||
|
||||
近变(2026-07-28):**人读名落库** —— 物化时把声明里的 `label` 写成 PG 的表/列注释,
|
||||
`describe` 与 `listObjects` 一并读回(`MaterializedObject.label` /
|
||||
|
||||
@@ -20,14 +20,25 @@ let published: EnvelopeDraft[] = [];
|
||||
|
||||
const DOMAIN = 'it_quote';
|
||||
const OBJECT = 'intake';
|
||||
/** 试跑闸门那一组用的主数据对象(与单据对象同库同任务,才测得出「只挡主数据」) */
|
||||
const MASTER_DOMAIN = 'it_customer';
|
||||
const MASTER_OBJECT = 'profile';
|
||||
const T1 = 't-it-1' as TaskId;
|
||||
const T2 = 't-it-2' as TaskId;
|
||||
const T3 = 't-it-3' as TaskId;
|
||||
const T4 = 't-it-4' as TaskId;
|
||||
const T5 = 't-it-5' as TaskId;
|
||||
const T6 = 't-it-6' as TaskId;
|
||||
|
||||
/** 哪几单算试跑 —— 由用例现填,替代 app 里那条查 `task.origin` 的 SQL */
|
||||
const SIMULATED = new Set<string>();
|
||||
|
||||
const declaration = (fields: ObjectDeclaration['fields']): ObjectDeclaration => ({
|
||||
domain: DOMAIN,
|
||||
object: OBJECT,
|
||||
// 台账层不关心性质,给个最常见的值即可(分类由安装静态审核)
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
fields,
|
||||
});
|
||||
|
||||
@@ -49,13 +60,21 @@ beforeAll(async () => {
|
||||
},
|
||||
clock: { now: () => new Date('2026-07-27T12:00:00Z') },
|
||||
ids: { next: () => `it-${(counter += 1)}` },
|
||||
// 这一组用例测的是真库读写,不是试跑闸门 —— 一律当生产单
|
||||
simulation: { isSimulated: async (taskId) => SIMULATED.has(taskId) },
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (pool !== undefined) {
|
||||
await pool.query(`drop table if exists ledger."${DOMAIN}__${OBJECT}"`).catch(() => undefined);
|
||||
await pool.query(`delete from ledger.__draft where task_id in ($1, $2)`, [T1, T2]).catch(() => undefined);
|
||||
await pool.query(`drop table if exists ledger."${MASTER_DOMAIN}__${MASTER_OBJECT}"`).catch(() => undefined);
|
||||
await pool
|
||||
.query(`delete from ledger.__object_meta where domain in ($1, $2)`, [DOMAIN, MASTER_DOMAIN])
|
||||
.catch(() => undefined);
|
||||
await pool
|
||||
.query(`delete from ledger.__draft where task_id = any($1)`, [[T1, T2, T3, T4, T5, T6]])
|
||||
.catch(() => undefined);
|
||||
await pool.end();
|
||||
}
|
||||
await database?.stop();
|
||||
@@ -172,7 +191,12 @@ describe('ledger · 真实 PG', () => {
|
||||
expect(official.rows[0]).toMatchObject({ grade: 'C' });
|
||||
|
||||
// 草稿已清空:再放行是空操作
|
||||
expect(await ledger.releaseWrite(T1)).toEqual({ mergedRows: 0, emittedEvents: 0, merged: [] });
|
||||
expect(await ledger.releaseWrite(T1)).toEqual({
|
||||
mergedRows: 0,
|
||||
emittedEvents: 0,
|
||||
simulatedRows: 0,
|
||||
merged: [],
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
itDb()('数据冲突检查:他单已改同一字段 → 放行写打回,不做部分合并', async () => {
|
||||
@@ -253,3 +277,97 @@ describe('ledger · 真实 PG', () => {
|
||||
expect(official.rows[0]).toMatchObject({ grade: 'B', note: '二版' });
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
/**
|
||||
* 试跑单不落主数据 —— 挡的是「落到世界」,不是挡这一单自己看得见。
|
||||
*
|
||||
* 判据四条,缺一条这道闸都算白装:
|
||||
* ① 主数据没进正式表(否则「新客户」样例第二次跑就成了老客户)
|
||||
* ② 同一单里的**单据照落**(试跑仍是真跑,只挡主数据这一类)
|
||||
* ③ 被挡下的行仍进 `merged`(出口载荷取的是它,拦了就断了下游)
|
||||
* ④ 行数如实分开报(放行痕上不许把没进表的算进「合并 N 行」)
|
||||
*/
|
||||
describe('ledger · 试跑闸门', () => {
|
||||
itDb()('试跑单:主数据挡在正式表外,单据照落,出口载荷不缺行', async () => {
|
||||
await ledger.applyDeclarations([
|
||||
{
|
||||
domain: MASTER_DOMAIN,
|
||||
object: MASTER_OBJECT,
|
||||
kind: 'master',
|
||||
fields: [
|
||||
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false },
|
||||
{ name: 'name', type: 'text', primaryKey: false, masked: false },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
SIMULATED.add(T5);
|
||||
// 同一单里两笔:一笔主数据、一笔单据
|
||||
await ledger.draftWrite({
|
||||
taskId: T5,
|
||||
address: { domain: MASTER_DOMAIN, object: MASTER_OBJECT },
|
||||
key: { customer_code: 'C-SIM' },
|
||||
patch: { customer_code: 'C-SIM', name: '试跑建的客户' },
|
||||
});
|
||||
await ledger.draftWrite({
|
||||
taskId: T5,
|
||||
address: { domain: DOMAIN, object: OBJECT },
|
||||
key: { pn: 'SIM-PN' },
|
||||
patch: { grade: 'A' },
|
||||
});
|
||||
|
||||
const result = await ledger.releaseWrite(T5);
|
||||
|
||||
// ④ 行数分开报
|
||||
expect(result.simulatedRows).toBe(1);
|
||||
expect(result.mergedRows).toBe(1);
|
||||
|
||||
// ① 主数据一行没进
|
||||
const master = await ledger.query({
|
||||
address: { domain: MASTER_DOMAIN, object: MASTER_OBJECT },
|
||||
where: { customer_code: 'C-SIM' },
|
||||
orderBy: [],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(master.rows).toEqual([]);
|
||||
|
||||
// ② 单据照落
|
||||
const record = await ledger.query({
|
||||
address: { domain: DOMAIN, object: OBJECT },
|
||||
where: { pn: 'SIM-PN' },
|
||||
orderBy: [],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(record.rows[0]).toMatchObject({ grade: 'A' });
|
||||
|
||||
// ③ 出口载荷两行都在
|
||||
expect(result.merged.map((m) => `${m.domain}.${m.object}`).sort()).toEqual(
|
||||
[`${DOMAIN}.${OBJECT}`, `${MASTER_DOMAIN}.${MASTER_OBJECT}`].sort(),
|
||||
);
|
||||
expect(result.merged.find((m) => m.domain === MASTER_DOMAIN)?.values).toMatchObject({ name: '试跑建的客户' });
|
||||
}, 60_000);
|
||||
|
||||
itDb()('生产单:同一张主数据表照写不误 —— 闸门只对试跑生效', async () => {
|
||||
await ledger.draftWrite({
|
||||
taskId: T6,
|
||||
address: { domain: MASTER_DOMAIN, object: MASTER_OBJECT },
|
||||
key: { customer_code: 'C-REAL' },
|
||||
patch: { customer_code: 'C-REAL', name: '真单建的客户' },
|
||||
});
|
||||
|
||||
const result = await ledger.releaseWrite(T6);
|
||||
expect(result.simulatedRows).toBe(0);
|
||||
expect(result.mergedRows).toBe(1);
|
||||
|
||||
const master = await ledger.query({
|
||||
address: { domain: MASTER_DOMAIN, object: MASTER_OBJECT },
|
||||
where: { customer_code: 'C-REAL' },
|
||||
orderBy: [],
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(master.rows[0]).toMatchObject({ name: '真单建的客户' });
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,8 @@ const f = (
|
||||
const decl = (fields: ReturnType<typeof f>[]): ObjectDeclaration => ({
|
||||
domain: 'quote',
|
||||
object: 'intake',
|
||||
kind: 'record',
|
||||
caseKey: 'rfq_ref',
|
||||
fields,
|
||||
});
|
||||
|
||||
@@ -125,7 +127,7 @@ describe('mergeDeclarations —— 最大化合并', () => {
|
||||
|
||||
it('拒绝混入别的对象', () => {
|
||||
expect(() =>
|
||||
mergeDeclarations([decl([f('a', 'text')]), { domain: 'quote', object: 'other', fields: [f('a', 'text')] }]),
|
||||
mergeDeclarations([decl([f('a', 'text')]), { domain: 'quote', object: 'other', kind: 'record' as const, caseKey: 'rfq_ref', fields: [f('a', 'text')] }]),
|
||||
).toThrow(/只接受同一对象/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,12 @@ export type DraftWrite = {
|
||||
export type ReleaseWriteResult = {
|
||||
readonly mergedRows: number;
|
||||
readonly emittedEvents: number;
|
||||
/**
|
||||
* **试跑单被挡下的主数据行数** —— 合到出参里去,是因为界面不撒谎:
|
||||
* 放行痕上写「合并 N 行」而其中几行根本没进表,那句话就是假的。
|
||||
* 非试跑单恒为 0。
|
||||
*/
|
||||
readonly simulatedRows: number;
|
||||
/**
|
||||
* 合并进正式版的那些行(键 + 值)。
|
||||
* 「落地收敛,输出口才发射」——发射的载荷就该是**这些已落地的事实**,
|
||||
@@ -96,6 +102,18 @@ export type LedgerService = {
|
||||
|
||||
systemWrite(write: SystemWrite): Promise<void>;
|
||||
draftWrite(write: DraftWrite): Promise<void>;
|
||||
/**
|
||||
* **撤掉本单草稿层的一行** —— 写错了得有退路(2026-08-01 加)。
|
||||
*
|
||||
* 不是第四种写:它只作用在**本单草稿层**,正式版一个字都动不了,也不抛事件 ——
|
||||
* 撤的是「我这一单还没过门的那一笔」。放行门的位置没变。
|
||||
*
|
||||
* 没有它的后果实测过:客户说本轮跳过的那颗料被写进了有效清单,条目数报 9 而表里躺着 10,
|
||||
* 判据 `record_rows = line_total` 正确地拦住,而模型**改不动** ——
|
||||
* 它唯一能改的是那个数(改了 skipped_total 又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||
* 门拦住一件办错的事却不给改正的手段,那扇门就只是个死胡同。
|
||||
*/
|
||||
draftDrop(drop: { taskId: TaskId; address: ObjectAddress; key: Record<string, unknown> }): Promise<boolean>;
|
||||
releaseWrite(taskId: TaskId): Promise<ReleaseWriteResult>;
|
||||
|
||||
/** 正式版查询 —— 跨任务只见过门的事实 */
|
||||
|
||||
@@ -100,12 +100,39 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
|
||||
return materialized;
|
||||
},
|
||||
|
||||
/*
|
||||
* 记下对象性质 —— **每次应用声明都写**,不只建表那一次。
|
||||
* 挂在 create-table 分支上是错的(踩过):表早就建好了,那条分支不走,
|
||||
* meta 永远是空的,治理台上全部表都成了「无人声明」。
|
||||
*/
|
||||
async setObjectMeta(ref, kind, caseKey) {
|
||||
await runner.query(
|
||||
`insert into ledger.__object_meta (domain, object, kind, case_key) values ($1,$2,$3,$4)
|
||||
on conflict (domain, object) do update set kind = excluded.kind, case_key = excluded.case_key`,
|
||||
[ref.domain, ref.object, kind, caseKey ?? null],
|
||||
);
|
||||
},
|
||||
|
||||
async listObjects() {
|
||||
// 表注释即人读名 —— 与表名同一次查询取回,治理台不必逐个 describe 才知道叫什么
|
||||
const res = await runner.query<{ table_name: string; label: string | null }>(
|
||||
const res = await runner.query<{
|
||||
table_name: string;
|
||||
label: string | null;
|
||||
kind: string | null;
|
||||
case_key: string | null;
|
||||
}>(
|
||||
/*
|
||||
* 性质与人读名**同一次查询取回**:治理台要按性质分区,而性质写在活动清单里 ——
|
||||
* 若让路由每次去把清单捞回来重建,那是 12 个活动 × 全版本清单 ≈ 1.5MB 的 JSON
|
||||
* 解析(实测热连接 73ms、并发八次 350ms,慢请求还占住浏览器连接连累别的页)。
|
||||
* 物化的东西记在物化侧:表建出来那一刻性质就定了。
|
||||
*/
|
||||
`select t.table_name,
|
||||
obj_description(($1 || '.' || quote_ident(t.table_name))::regclass) as label
|
||||
obj_description(($1 || '.' || quote_ident(t.table_name))::regclass) as label,
|
||||
m.kind, m.case_key
|
||||
from information_schema.tables t
|
||||
left join ledger.__object_meta m
|
||||
on m.domain || '__' || m.object = t.table_name
|
||||
where t.table_schema = $1 and t.table_name not like '\\_\\_%' order by t.table_name`,
|
||||
[LEDGER_SCHEMA],
|
||||
);
|
||||
@@ -117,11 +144,21 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
|
||||
domain: r.table_name.slice(0, idx),
|
||||
object: r.table_name.slice(idx + 2),
|
||||
...(r.label === null ? {} : { label: r.label }),
|
||||
...(r.kind === null ? {} : { kind: r.kind }),
|
||||
...(r.case_key === null ? {} : { caseKey: r.case_key }),
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
|
||||
/** 主数据清单 —— 放行写拿它挡试跑单;性质在 `__object_meta`,一次 where 取回 */
|
||||
async masterRefs() {
|
||||
const res = await runner.query<{ domain: string; object: string }>(
|
||||
`select domain, object from ledger.__object_meta where kind = 'master'`,
|
||||
);
|
||||
return res.rows.map((r) => ({ domain: r.domain, object: r.object }));
|
||||
},
|
||||
|
||||
async applyChanges(ref, changes) {
|
||||
const table = `${LEDGER_SCHEMA}.${quoteIdent(tableNameOf(ref))}`;
|
||||
/*
|
||||
@@ -141,6 +178,7 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
|
||||
? `, primary key (${change.object.primaryKey.map(quoteIdent).join(', ')})`
|
||||
: '';
|
||||
await runner.query(`create table if not exists ${table} (${cols.join(', ')}${pk})`);
|
||||
|
||||
/*
|
||||
* 人读名落**表/列注释** —— 声明里带的 label 不能在建表这一步丢掉:
|
||||
* 丢了,治理台就只剩 `project__intake_record` 这种键给人看。
|
||||
@@ -341,6 +379,21 @@ export function createDraftStore(runner: Queryable): DraftStore {
|
||||
],
|
||||
);
|
||||
},
|
||||
/**
|
||||
* 撤掉本单草稿层的**一行** —— 写错了得有退路。
|
||||
*
|
||||
* 只删 `task_id` 那一份:正式版一个字都动不了。草稿层本来就是这一单的过程事实,
|
||||
* 过程里写错一行是常事;没有退路的后果实测过 ——
|
||||
* 客户说本轮跳过的那颗料被写进了有效清单,条目数报 9 而表里躺着 10,
|
||||
* 判据 `record_rows = line_total` 正确地拦住,而模型**改不动**:
|
||||
* 唯一能改的是那个数(改了 skipped_total 就不对),于是 13 次拒回、23 拍、234k token。
|
||||
*/
|
||||
async drop(taskId, ref, key) {
|
||||
await runner.query(
|
||||
`delete from ${DRAFT_TABLE} where task_id = $1 and domain = $2 and object = $3 and key_hash = $4`,
|
||||
[taskId, ref.domain, ref.object, keyOf(key)],
|
||||
);
|
||||
},
|
||||
async clear(taskId) {
|
||||
await runner.query(`delete from ${DRAFT_TABLE} where task_id = $1`, [taskId]);
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 同名字段类型不一致时,以**能兼容双方的最宽类型**落地(放宽即增,不破坏),静态审提示、不阻塞;
|
||||
* **语义互斥**不可兼容的极端情形才拦下,交数据管理裁决。
|
||||
*/
|
||||
import type { FieldDeclaration, LedgerFieldType, ObjectDeclaration } from '@contracts';
|
||||
import type { FieldDeclaration, LedgerFieldType, LedgerObjectKind, ObjectDeclaration } from '@contracts';
|
||||
|
||||
/* ── 类型格:widen(a, b) = 能同时容纳 a 与 b 的最窄类型 ────────────────── */
|
||||
|
||||
@@ -74,6 +74,9 @@ export type MergedObject = {
|
||||
readonly domain: string;
|
||||
readonly object: string;
|
||||
readonly label: string | undefined;
|
||||
/** 对象性质与单标识 —— 随表物化,治理台按它分区(见 0026_object_meta) */
|
||||
readonly kind: LedgerObjectKind;
|
||||
readonly caseKey: string | undefined;
|
||||
readonly fields: readonly MergedField[];
|
||||
readonly primaryKey: readonly string[];
|
||||
};
|
||||
@@ -172,6 +175,13 @@ export function mergeDeclarations(declarations: readonly ObjectDeclaration[]): M
|
||||
domain: first.domain,
|
||||
object: first.object,
|
||||
label: declarations.find((d) => d.label !== undefined)?.label,
|
||||
/*
|
||||
* 性质取**首份非默认的声明**:平台声明主数据在前(`master`),
|
||||
* 活动若也声明同一张表,它只补自己要写的那几列、kind 用的是缺省 `record` ——
|
||||
* 若拿最后一份覆盖,主数据会被活动的缺省值改回单据。
|
||||
*/
|
||||
kind: declarations.find((d) => d.kind !== 'record')?.kind ?? 'record',
|
||||
caseKey: declarations.find((d) => d.caseKey !== undefined)?.caseKey,
|
||||
fields,
|
||||
primaryKey,
|
||||
},
|
||||
|
||||
@@ -17,7 +17,15 @@ import type {
|
||||
SystemWrite,
|
||||
} from '../../contract.ts';
|
||||
import { DraftConflictError, ObjectNotMaterializedError } from '../../contract.ts';
|
||||
import type { Clock, DraftStore, EventPublisher, IdGenerator, LedgerStore, ObjectRef } from '../../port.ts';
|
||||
import type {
|
||||
Clock,
|
||||
DraftStore,
|
||||
EventPublisher,
|
||||
IdGenerator,
|
||||
LedgerStore,
|
||||
ObjectRef,
|
||||
SimulationProbe,
|
||||
} from '../../port.ts';
|
||||
import { mergeDeclarations, planIsNonDestructive, planSchemaChanges } from '../domain/schema-merge.ts';
|
||||
import { detectConflicts, keyOf, overlay, pickKey } from '../domain/task-view.ts';
|
||||
|
||||
@@ -27,6 +35,8 @@ export type LedgerServiceDeps = {
|
||||
readonly events: EventPublisher;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
/** 试跑判定 —— 放行写据此把主数据挡在正式表外(见 port.ts `SimulationProbe`) */
|
||||
readonly simulation: SimulationProbe;
|
||||
};
|
||||
|
||||
function groupDeclarations(declarations: readonly ObjectDeclaration[]): Map<string, ObjectDeclaration[]> {
|
||||
@@ -97,6 +107,11 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
||||
continue;
|
||||
}
|
||||
if (plan.changes.length > 0) await deps.store.applyChanges(ref, plan.changes);
|
||||
/*
|
||||
* 性质每次都记 —— 它随声明走,不随建表走。
|
||||
* 声明合并已经定了口径(平台的 master 优先于活动的缺省 record),这里照写。
|
||||
*/
|
||||
await deps.store.setObjectMeta(ref, merge.merged.kind, merge.merged.caseKey);
|
||||
results.push({
|
||||
merged: merge.merged,
|
||||
notices: [...merge.notices, ...plan.notices],
|
||||
@@ -148,13 +163,51 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 撤掉本单草稿层的一行 —— **只撤自己写过的**。
|
||||
*
|
||||
* 回 `false` 表示草稿层里根本没有这一行:那多半是主键写法对不上
|
||||
* (`DAG…` 与 `DAG…-R0` 是两行),如实说出来比静默成功有用 ——
|
||||
* 静默成功会让模型以为撤掉了,下一拍判据照旧不过,它又去撤一次。
|
||||
*/
|
||||
async draftDrop(drop) {
|
||||
const mine = await deps.drafts.listForObject(drop.taskId, drop.address);
|
||||
const schema = await requireSchema(drop.address);
|
||||
const keys = schema.primaryKey.length > 0 ? schema.primaryKey : Object.keys(drop.key);
|
||||
const wanted = pickKey({ ...drop.key }, keys);
|
||||
const hit = mine.some((d) => keys.every((k) => String((d.key as Record<string, unknown>)[k] ?? '') === String(wanted[k] ?? '')));
|
||||
if (!hit) return false;
|
||||
await deps.drafts.drop(drop.taskId, drop.address, wanted);
|
||||
return true;
|
||||
},
|
||||
|
||||
/** ③ 放行写:冲突检查 → 原子合并 → 此刻抛事件 */
|
||||
async releaseWrite(taskId): Promise<ReleaseWriteResult> {
|
||||
const conflicts = await this.checkConflicts(taskId);
|
||||
if (conflicts.length > 0) throw new DraftConflictError(conflicts);
|
||||
|
||||
const drafts = await deps.drafts.list(taskId);
|
||||
if (drafts.length === 0) return { mergedRows: 0, emittedEvents: 0, merged: [] };
|
||||
if (drafts.length === 0) return { mergedRows: 0, emittedEvents: 0, simulatedRows: 0, merged: [] };
|
||||
|
||||
/*
|
||||
* **试跑单不落主数据** —— 挡在这一处,是因为这里是草稿层通往世界的唯一一道口子。
|
||||
*
|
||||
* 试跑照旧是真跑:过装载两审、过预算、过放行门,单据照写。挡的只有主数据 ——
|
||||
* 它按业务实体分行,试跑往 `customer.profile` 建一位客户,那位客户就永久留在库里,
|
||||
* 「新客户来函」那条样例第二次跑就成了老客户,这条路从此试不出来。
|
||||
* 单据不挡:它按单标识分行,一次试跑写自己那一行,清运行数据就干净了。
|
||||
*
|
||||
* 被挡下的行仍然进 `merged`(出口载荷取的就是它)—— 拦的是**落到世界**,
|
||||
* 不是拦这一单自己看得见:活动缓存里它一直在,这一单该怎么跑还怎么跑。
|
||||
*/
|
||||
const simulated = await deps.simulation.isSimulated(taskId);
|
||||
const masters = new Set<string>();
|
||||
if (simulated) {
|
||||
for (const ref of await deps.store.masterRefs()) masters.add(`${ref.domain}.${ref.object}`);
|
||||
}
|
||||
const heldBack = (draft: { domain: string; object: string }): boolean =>
|
||||
simulated && masters.has(`${draft.domain}.${draft.object}`);
|
||||
let simulatedRows = 0;
|
||||
|
||||
const pending: EnvelopeDraft[] = [];
|
||||
/*
|
||||
@@ -171,6 +224,23 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
||||
await deps.store.transaction(async (store, draftStore) => {
|
||||
for (const draft of drafts) {
|
||||
const ref: ObjectRef = { domain: draft.domain, object: draft.object };
|
||||
if (heldBack(draft)) {
|
||||
simulatedRows += 1;
|
||||
if (draft.tombstone) continue; // 试跑不删主数据,连一行也不许少
|
||||
/*
|
||||
* 值取「表里现有的 + 这一单改的」——没真写进去,读不回 PG 归型后的那一份,
|
||||
* 所以这里的值是草稿的原样(人的回传天生是字符串)。如实记在这:
|
||||
* 主数据不进出口载荷,下游拿不到它,归不归型都影响不到谁。
|
||||
*/
|
||||
const current = (await store.selectByKeys(ref, [draft.key]))[0];
|
||||
landed.push({
|
||||
domain: draft.domain,
|
||||
object: draft.object,
|
||||
key: draft.key,
|
||||
values: { ...(current ?? {}), ...draft.patch },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (draft.tombstone) {
|
||||
await store.deleteRow(ref, draft.key);
|
||||
pending.push(
|
||||
@@ -203,7 +273,12 @@ export function createLedgerService(deps: LedgerServiceDeps): LedgerService {
|
||||
});
|
||||
|
||||
for (const envelope of pending) await deps.events.publish(envelope);
|
||||
return { mergedRows: drafts.length, emittedEvents: pending.length, merged: landed };
|
||||
return {
|
||||
mergedRows: drafts.length - simulatedRows,
|
||||
emittedEvents: pending.length,
|
||||
simulatedRows,
|
||||
merged: landed,
|
||||
};
|
||||
},
|
||||
|
||||
async query(query: LedgerQuery): Promise<LedgerPage> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* module: ledger —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
*/
|
||||
import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow } from '@contracts';
|
||||
import type { EnvelopeDraft, LedgerKey, LedgerQuery, LedgerRow, LedgerObjectKind } from '@contracts';
|
||||
import type { MaterializedObject, SchemaChange } from './internal/domain/schema-merge.ts';
|
||||
import type { DraftEntry } from './internal/domain/task-view.ts';
|
||||
|
||||
@@ -11,6 +11,19 @@ export type IdGenerator = { next(): string };
|
||||
/** 数据事件的发射口 —— app 注入 `event` 模块的出口(§5.2) */
|
||||
export type EventPublisher = { publish(envelope: EnvelopeDraft): Promise<void> };
|
||||
|
||||
/**
|
||||
* 这一单是不是**试跑** —— app 注入(设计台的两条试跑路径都算,见 `container.ts`)。
|
||||
*
|
||||
* 台账在 L1,认不出「谁起的单」;而放行写要不要落主数据,取决的恰恰是这件事。
|
||||
* 所以由 app 把这一个布尔判断喂进来 —— 与 `EventPublisher` 同一个套路:
|
||||
* 模块只声明「我需要知道什么」,不声明「去哪儿问」。
|
||||
*
|
||||
* 为什么只挡主数据、不挡单据:单据按单标识分行,一次试跑写一行自己的,
|
||||
* 清运行数据就没了;主数据是**按业务实体**分行的档案,试跑往里建一位客户,
|
||||
* 「新客户」那条样例就此再也试不出来 —— 它成了老客户。
|
||||
*/
|
||||
export type SimulationProbe = { isSimulated(taskId: string): Promise<boolean> };
|
||||
|
||||
export type ObjectRef = { readonly domain: string; readonly object: string };
|
||||
|
||||
/**
|
||||
@@ -21,7 +34,16 @@ export type LedgerStore = {
|
||||
/** 已物化对象的列与主键;未物化返回 undefined */
|
||||
describe(ref: ObjectRef): Promise<MaterializedObject | undefined>;
|
||||
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
|
||||
/** 记下对象性质(单据/主数据/派生)与单标识 —— 随声明走,不随建表走 */
|
||||
setObjectMeta(ref: ObjectRef, kind: LedgerObjectKind, caseKey: string | undefined): Promise<void>;
|
||||
listObjects(): Promise<Array<ObjectRef & { label?: string | undefined }>>;
|
||||
/**
|
||||
* 哪些对象是**主数据** —— 放行写要据此把试跑单的主数据挡在正式表外。
|
||||
*
|
||||
* 单独一个方法而不是让调用方 `listObjects` 自己筛:性质记在 `__object_meta` 里,
|
||||
* 一次 where 就够;`describe` 读的是 information_schema,那里没有性质这一维。
|
||||
*/
|
||||
masterRefs(): Promise<ReadonlyArray<ObjectRef>>;
|
||||
/** 执行「只增不破坏」的变更计划 */
|
||||
applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>;
|
||||
|
||||
@@ -39,5 +61,13 @@ export type DraftStore = {
|
||||
list(taskId: string): Promise<Array<DraftEntry & ObjectRef>>;
|
||||
listForObject(taskId: string, ref: ObjectRef): Promise<DraftEntry[]>;
|
||||
put(taskId: string, ref: ObjectRef, entry: DraftEntry): Promise<void>;
|
||||
/**
|
||||
* 撤掉本单草稿层的**一行** —— 写错了得有退路(2026-08-01 加)。
|
||||
*
|
||||
* 只动 `taskId` 那一份,正式版碰不到。没有它的后果实测过:客户说本轮跳过的那颗料
|
||||
* 被写进了有效清单,条目数报 9 而表里躺着 10 —— 判据正确地拦住,而模型改不动
|
||||
* (唯一能改的是那个数,改了别的判据又不对),13 次拒回、23 拍、234k token 才熔断。
|
||||
*/
|
||||
drop(taskId: string, ref: ObjectRef, key: LedgerKey): Promise<void>;
|
||||
clear(taskId: string): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
被依赖: toolbox, memory, loop-engine, flow-engine, skill-engine, task, activity-registry,
|
||||
report(叙述性小结)、app(治理台 · 系统管理路由)
|
||||
|
||||
近变(2026-07-30):`ModelCall.reasoning?: 'off' | 'low'` —— **压推理**。
|
||||
混合推理模型的隐藏思考按秒计:实测 A1 一拍可见出参 1538 字、推理正文 9808 字,
|
||||
48.6s 里八成五在解码没人读的字;关掉后同一单每拍 1.5-4.9s(十倍差)。
|
||||
厂商姿势收在 provider-profile 的 `reasoningControl`(DeepSeek:off →
|
||||
`thinking:{type:'disabled'}`,low → `reasoning_effort:'low'` —— 探针核过;
|
||||
low 在该厂身上实测无效,推理长度不降);没声明姿势的厂家如实忽略。
|
||||
回放指纹已带上 reasoning(关思考录的带不被开思考的重放命中)。
|
||||
|
||||
不变量:
|
||||
- **一切模型调用经 `ModelGateway`** —— 绕过它,回放审就对它失明(§12.5#10)
|
||||
- `replay` 模式**绝不回退实调**:缺录即 `ReplayMissError`
|
||||
|
||||
@@ -26,3 +26,19 @@ describe('Provider 画像', () => {
|
||||
expect(resolveBaseUrl('openai-compatible', undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('压推理的厂商姿势', () => {
|
||||
/*
|
||||
* 混合推理模型的隐藏思考按秒计:实测一拍可见出参 1538 字、推理 9808 字,
|
||||
* 48.6s 里八成五在解码没人读的思考。姿势收在画像里 —— 新厂家只改画像,不加 if。
|
||||
*/
|
||||
it('DeepSeek:off 关思考,low 降档 —— 参数名探针核过', () => {
|
||||
const p = profileOf('deepseek');
|
||||
expect(p.reasoningControl?.off).toEqual({ thinking: { type: 'disabled' } });
|
||||
expect(p.reasoningControl?.low).toEqual({ reasoning_effort: 'low' });
|
||||
});
|
||||
|
||||
it('没声明姿势的厂家:调用方的意愿如实忽略,不假装生效', () => {
|
||||
expect(profileOf('openai').reasoningControl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,12 @@ export type ModelCall = {
|
||||
readonly prompt: string;
|
||||
readonly temperature?: number;
|
||||
readonly maxOutputTokens?: number;
|
||||
/**
|
||||
* 压推理:`off` 不许想、`low` 少想;缺省由着模型。
|
||||
* 只对画像里声明了 `reasoningControl` 的厂家生效 —— 没姿势的厂家如实忽略。
|
||||
* 规划型调用(loop 单拍)该关:隐藏思考按秒计,而它的判断本就交给了判定表与工具。
|
||||
*/
|
||||
readonly reasoning?: 'off' | 'low';
|
||||
/** 出参 schema(JSON Schema);给定即要求结构化出参并校验 */
|
||||
readonly outputSchema?: Record<string, unknown>;
|
||||
/** 录制/回放的定位:任务 × 步序号(§7.3) */
|
||||
|
||||
@@ -33,6 +33,17 @@ export function createProviderInvoker(): ProviderInvoker {
|
||||
.filter((line): line is string => line !== undefined && line !== '')
|
||||
.join('\n\n');
|
||||
|
||||
/*
|
||||
* providerOptions 是**合并体**:JSON 模式与压推理都往请求体里加参,得进同一个对象 ——
|
||||
* 两处各自 spread 会互相覆盖(踩不得)。
|
||||
*/
|
||||
// AI SDK 的 providerOptions 值型是 JSONObject —— 从 generateText 的形参上取,不额外引包
|
||||
type ProviderBody = NonNullable<Parameters<typeof generateText>[0]['providerOptions']>[string];
|
||||
const bodyExtra: ProviderBody = {};
|
||||
if (useJsonMode) bodyExtra['response_format'] = { type: 'json_object' };
|
||||
const knob = call.reasoning === undefined ? undefined : profile.reasoningControl?.[call.reasoning];
|
||||
if (knob !== undefined) Object.assign(bodyExtra, knob as ProviderBody);
|
||||
|
||||
const started = Date.now();
|
||||
const result = await generateText({
|
||||
model: provider(binding.model),
|
||||
@@ -40,7 +51,7 @@ export function createProviderInvoker(): ProviderInvoker {
|
||||
prompt: call.prompt,
|
||||
...(call.temperature === undefined ? {} : { temperature: call.temperature }),
|
||||
...(call.maxOutputTokens === undefined ? {} : { maxOutputTokens: call.maxOutputTokens }),
|
||||
...(useJsonMode ? { providerOptions: { [binding.provider]: { response_format: { type: 'json_object' } } } } : {}),
|
||||
...(Object.keys(bodyExtra).length === 0 ? {} : { providerOptions: { [binding.provider]: bodyExtra } }),
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -84,15 +95,51 @@ export function parseJsonLoose(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
const start = candidate.search(/[[{]/);
|
||||
const end = Math.max(candidate.lastIndexOf('}'), candidate.lastIndexOf(']'));
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
return JSON.parse(candidate.slice(start, end + 1));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return firstBalanced(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文本里取**第一个配平的** JSON 对象 / 数组。
|
||||
*
|
||||
* 从前这里取的是「第一个 `{` 到**最后一个** `}`」。模型一次吐两个对象时
|
||||
* (它把这一拍和下一拍一起写了出来,中间空一行),那一刀切出来的是
|
||||
* `{…}\n\n{…}` —— 非法 JSON,整段作废,于是基座判「模型未给出合规的行动」,
|
||||
* 发一张兜底卡把人叫来。实测栽过:模型给的 9 颗料判据完全正确、
|
||||
* 分类器都要调了,只因为它多写了一个对象,整拍白跑、人被无谓地打扰了一次。
|
||||
*
|
||||
* 现在按括号配平扫第一个完整对象,后面多出来的当噪声丢掉 ——
|
||||
* 多写的那一份本就是下一拍的事,这一拍不该替它执行。
|
||||
* 扫的时候要认字符串与转义,否则值里带 `}` 的会把括号计数带偏。
|
||||
*/
|
||||
function firstBalanced(text: string): unknown {
|
||||
const start = text.search(/[[{]/);
|
||||
if (start < 0) return undefined;
|
||||
const open = text[start];
|
||||
const close = open === '{' ? '}' : ']';
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === '\\') escaped = true;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = true;
|
||||
else if (ch === open) depth += 1;
|
||||
else if (ch === close) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
try {
|
||||
return JSON.parse(text.slice(start, i + 1));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ export type ProviderProfile = {
|
||||
readonly structuredOutput: 'json-object' | 'prompt-only';
|
||||
/** 推理模型:回包里带 reasoning,答案在正文,推理另置(不进出参、只作留痕) */
|
||||
readonly emitsReasoning: boolean;
|
||||
/**
|
||||
* 「压推理」的厂商姿势 —— 调用方说 `reasoning: 'off' | 'low'`,画像译成请求体参数。
|
||||
*
|
||||
* 为什么值得一个画像字段:混合推理模型的隐藏思考按 token 计费、按解码计秒 ——
|
||||
* 实测 A1 一拍可见出参 1538 字、推理正文 9808 字,48.6 秒里八成五在解码没人读的思考;
|
||||
* 探针实测 DeepSeek `thinking:{type:'disabled'}` 后同一问题 89→9 出参 token、时延减半。
|
||||
* 没有姿势的厂家(undefined)只能由着它想 —— 调用方的意愿如实忽略,不假装生效。
|
||||
*/
|
||||
readonly reasoningControl?: Readonly<Record<'off' | 'low', Record<string, unknown>>>;
|
||||
readonly label: string;
|
||||
};
|
||||
|
||||
@@ -43,6 +52,11 @@ export const PROVIDER_PROFILES: Readonly<Record<LlmProvider, ProviderProfile>> =
|
||||
defaultBaseUrl: 'https://api.deepseek.com',
|
||||
structuredOutput: 'json-object',
|
||||
emitsReasoning: true,
|
||||
// 探针核过(2026-07-30):disabled 归零推理;effort 只认 low/medium/high/xhigh/max
|
||||
reasoningControl: {
|
||||
off: { thinking: { type: 'disabled' } },
|
||||
low: { reasoning_effort: 'low' },
|
||||
},
|
||||
label: 'DeepSeek',
|
||||
},
|
||||
anthropic: {
|
||||
|
||||
Binary file not shown.
@@ -24,7 +24,18 @@ export function createLlmService(deps: LlmServiceDeps): LlmService {
|
||||
},
|
||||
|
||||
async upsert(input: UpsertLlmInstance): Promise<LlmInstanceView> {
|
||||
const existing = input.id === undefined ? undefined : await deps.instances.findById(input.id);
|
||||
/*
|
||||
* 找「原来那条」要**按 id 也按名字**。
|
||||
*
|
||||
* 落库是 `onConflictDoUpdate(target: name)` —— 名字才是那张表的身份。
|
||||
* 从前只按 id 查:播种走的是无 id 的按名 upsert,于是 `existing` 恒为 undefined、
|
||||
* `apiKeyCipher` 解析成 undefined、落库写成 null ——
|
||||
* **一次不带密钥的播种就把治理台里存好的密钥擦掉了**,而界面上只会显示「未绑定」。
|
||||
* 「只写不读:apiKey 留空即保持原密文」(ADR-009 #4)这句话,得在两条路上都成立。
|
||||
*/
|
||||
const existing =
|
||||
(input.id === undefined ? undefined : await deps.instances.findById(input.id)) ??
|
||||
(await deps.instances.findByName(input.name));
|
||||
// 只写不读:apiKey 留空即保持原密文(ADR-009 #4)
|
||||
const apiKeyCipher =
|
||||
input.apiKey !== undefined && input.apiKey !== ''
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
`TurnOutcome` `DecisionResult` `DecisionTrace` `Gap` `ItemState` `CaseState`
|
||||
- 错误:`CmmnParseError` `IllegalTransitionError` `ForbiddenTerminalError`
|
||||
`AutomaticOnWorldTouchingError`
|
||||
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway` `AgentPort` `ToolGateway`
|
||||
- 端口类型:`LoopStateStore` `Sfeel` `TaskDataGateway`(含 `writable` 授权面)`AgentPort` `ToolGateway`
|
||||
`CardGateway` `ListenerRegistrar` `MemoryRecorder` `TaskContext`
|
||||
- 用例工厂:`createTurnRunner(deps): LoopEngineService`
|
||||
- 纯函数:`parseCmmn` `parseXml` `publishEnabled` `navigate` `evaluateCriterion`
|
||||
`transitionItem` `occur` `reenter` `autoCompleteReady` `canTransitionItem`
|
||||
`transitionItem` `occur` `achieveMilestones` `reenter` `autoCompleteReady` `canTransitionItem`
|
||||
`mayCompleteCase` `mayTerminateCase` `evaluateTable` `evaluationOrder` `deduceGaps`
|
||||
- adapter:`createLoopStateStore`;Drizzle 表:`loopStateTable`
|
||||
|
||||
@@ -36,6 +36,64 @@
|
||||
- **结论不可静默覆写**:`isBindingConclusion` 标出约束性输入
|
||||
- **三型行动**:每步行动属于且仅属于「调工具 / 调人 / 立监听」之一;
|
||||
调人 = 立监听听人;AI 对世界只有触达(动作,落在放行)与等待(监听)两种姿势
|
||||
- **一拍一串行动**(2026-07-30,`AgentTurn.actions`,封顶 `TURN_ACTION_CAP` = 6):
|
||||
拍是按模型调用计费的 —— 一拍只许一个行动时,每个 write / finish / start 都单烧一遍
|
||||
完整上下文,实测 A1 六步的活恰好 21 拍(其中 19 拍是该约束下的最短路径)。
|
||||
收成一串后同一单 8 拍收口、token 减半。执行纪律引擎强制:按序执行,谁被拒停在谁那儿
|
||||
(**前缀已生效不回滚**,理由喂回下一拍);`tool` 之后截断(出参没看见,后面的行动是
|
||||
闭着眼计划的);挂起型(ask/human/listen/submit)落地即收拍;超封顶丢弃并留痕。
|
||||
- **写台账草稿是过程写的唯一形态**(2026-07-30 新增 `write` 行动,铁律②)。
|
||||
从前这条路**根本不存在**:`TaskDataGateway.draftWrite` 有端口无调用方,
|
||||
Agent 干出来的结论一处也放不下 —— 后果不是少个功能,而是必交付物恒零行、
|
||||
验收判据求值取不到值、出口载荷推不出字段:跑得再热闹,放行那刻什么也交不出。
|
||||
- 一拍可写**多行**:一份 BOM 一次写完,不一行一拍各烧一次模型;
|
||||
- 地址与列名的把关在装配层(它认识活动清单)—— 未声明的地址与列一律拒写,
|
||||
越域写与越权调工具同罪;拒了抛错,由回合收成「行动被拒」喂回下一拍,引擎不崩;
|
||||
- **写的授权面是台账声明,不是计划项**(2026-07-30):`itemId` 只是记账,
|
||||
挂得上就挂(项活性化),挂不上(已完成 / 人工激活未 start / 阶段 / 不存在)照写不拦,
|
||||
留痕注明「未挂到计划项」。从前绑死在项生命周期上,死锁过一单:S5 收完才发现打标
|
||||
没写全,所有能挂的项都 Completed —— 每拍补写都被拒,连拒五拍熔断,而数据本身合规;
|
||||
- 写完把该项收了(未 Active 的就地过一下 Active)。**这不是绕开「Enabled ≠ Active」**:
|
||||
那条纪律管的是触达世界的项,而草稿写触达不到任何东西 —— 为它单烧一次模型去按那一下是纯仪式。
|
||||
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
|
||||
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
|
||||
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
|
||||
又常驻「当下可为」与「目标缺口」,让 Agent 每拍都看见自己按不动的东西。
|
||||
现在引擎在发布前自己收(达成留痕);**无前提的里程碑不自达成** —— 那是建模漏了前提。
|
||||
- **事件监听器不是交付物,是等待岗**(2026-07-30):不进 `gaps`,也不算 autoComplete 的必做项
|
||||
(从前只排除 `timerEventListener`,一个 `userEventListener` 就让案子永远判不齐)。
|
||||
- **一拍 = 一个行动 + 一句交代**(2026-07-30 `AgentTurn`)。Agent 从前**不会说话** ——
|
||||
会写台账、调工具、调人、立监听,但没有一型行动是「对人说一句」。
|
||||
实测:A1 跑十几拍、写七行台账、调两次工具,负责人的对话区从头到尾空白,
|
||||
他只看得见「多了一个任务」。「人只在责任点与放行点出现」不等于「过程中人什么都看不见」。
|
||||
- 交代落 `narration`(它进对话区),与 `thought`(给自己留的痕)分开;
|
||||
- **不是一型行动,是每一拍的附带**:先做成 `kind:'say'` 试过 —— 它占一整拍又不推进局面,
|
||||
预算下的 Agent 理所当然跳过它,对话区照旧空白。让说话与干活抢拍,
|
||||
等于把「必须说」设计成了「有空才说」;
|
||||
- **先交代再动手**:人看到的是「我要去查这三个 PN 的历史价」,紧接着才是结果;
|
||||
- `say.view` 可顺手换局面模版 —— 局面变了,人既要听到为什么,也要看到新画面。
|
||||
- **卡死了交回给人**(2026-07-30 `stalledTurns` + `STALL_LIMIT=5`)。预算会烧完,
|
||||
但那是二三十万 token 之后的事;这期间界面上只是「进行中」一动不动 —— 那不是在办,是卡死了。
|
||||
连续 5 拍行动被拒就不再问模型(再问也是同一个答案),发执行卡把**拒绝理由原话**摆给人:
|
||||
是它跑偏、是行动型少了一种、还是案例模型建得不对 —— 三种处置完全不同,人得看得见证据。
|
||||
- **补全卡是对话卡,不是执行工单**(`ask` 行动 / `CardGateway.ask`)。
|
||||
「按缺口清单追问补全」要的是:缺口清单摆在对话区、带一张表单,人就地填齐。
|
||||
从前 Agent **发不出对话卡**(只能选执行 / 验收两种工单),SOP 上画着补全卡的那一步
|
||||
只能拿执行工单顶 —— 形似而神非:派一张工单去问几个字段,收卡的人得进待处理、
|
||||
办完再回来,重得不成比例。一个格子都没有即拒发(空表单等于没问)。
|
||||
- **决策卡也是对话卡**(2026-07-31 `decide` 行动 / `CardGateway.decide`)。
|
||||
与补全卡的分工:补全卡问**事实**(缺哪几个字段,你填);决策卡问**处置**(往哪走,你选)。
|
||||
从前决策卡**只有 UI 没有 Agent 侧** —— 卡面在对话区画得出来,行动型里却发不出它,
|
||||
于是所有岔路口都被写成补全卡:人拿到一张要自己敲字的表单,而不是两个可点的选项。
|
||||
少于两个选项即拒发(一个选项的「选择题」是通知,不是决策)。
|
||||
- **视图推送回报推没推上**(`ViewGateway.push` → `{pushed, templates}`)。
|
||||
从前引擎每拍推一个**硬编码**的 `case-overview`,而活动声明的模版未必叫这个名字
|
||||
(A1 的四张一个都不叫)—— 模版查不到就静默 return,**每一帧都被悄悄丢掉**,
|
||||
工作区从头到尾空白,日志里连一个字都没有。静默失败让「没画面」看起来像「没产出」。
|
||||
现在:要的那张没有就退到活动声明的第一张,换不上则如实记痕。
|
||||
- **人答了什么要看得见**:`MemoryRecorder.window` 带回 `detail`,回传值(`detail.values`)
|
||||
随轨迹进叙事面。不给的话「执行工单回传」这一行读起来像事情办完了,而人填了哪几个值
|
||||
Agent 一个字看不见 —— 既写不进台账,也只能再问一遍。
|
||||
- **终局唯放行与 Harness**:引擎不把案计划置 Completed —— 那是 release 的事;
|
||||
`mayCompleteCase` 只对 release 为真,`mayTerminateCase` 只对 harness / owner / governance 为真
|
||||
- **提交放行 = 发验收卡**(2026-07-28 三修①):submit / 判齐(autoCompleteReady)
|
||||
|
||||
@@ -180,19 +180,38 @@ describe('publishEnabled —— 引擎推进到 Enabled 为止', () => {
|
||||
describe('navigate —— 导航即交集,断点即人环', () => {
|
||||
it('有交集即择优推进', () => {
|
||||
const nav = navigate(
|
||||
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], judgements: [], gaps: ['缺报价'], activeStages: [] },
|
||||
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], inProgress: [], judgements: [], gaps: ['缺报价'], activeStages: [] },
|
||||
new Set(['a']),
|
||||
);
|
||||
expect(nav.kind).toBe('proceed');
|
||||
});
|
||||
|
||||
it('交集为空即断点 —— 调人或立监听的判定时机', () => {
|
||||
const nav = navigate({ entries: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
|
||||
it('交集为空**且手上没开着的活**才是断点 —— 调人或立监听的判定时机', () => {
|
||||
const nav = navigate({ entries: [], inProgress: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
|
||||
expect(nav).toEqual({ kind: 'breakpoint', gaps: ['缺客户信用'] });
|
||||
});
|
||||
|
||||
/*
|
||||
* 断点的定义是「无路可走」。手上还有开着的活就不算 —— 从前只看 Enabled 集,
|
||||
* 局面推进开以后手上全是 Active,每拍都判成断点,而断点的处置是「调人或立监听」:
|
||||
* 环于是把没干完的活丢下,去等一个不会来的事件。
|
||||
*/
|
||||
it('Enabled 为空但有进行中的项 → 接着干,不是断点', () => {
|
||||
const nav = navigate(
|
||||
{
|
||||
entries: [],
|
||||
inProgress: [{ id: 'w', name: '正在查历史价', type: 'humanTask', requiresManualStart: false, discretionary: false }],
|
||||
judgements: [],
|
||||
gaps: ['缺分类表'],
|
||||
activeStages: [],
|
||||
},
|
||||
new Set(['nothing']),
|
||||
);
|
||||
expect(nav).toEqual({ kind: 'proceed', candidates: [{ id: 'w', name: '正在查历史价', type: 'humanTask', requiresManualStart: false, discretionary: false }] });
|
||||
});
|
||||
|
||||
it('无缺口即收敛', () => {
|
||||
expect(navigate({ entries: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
|
||||
expect(navigate({ entries: [], inProgress: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 收步判据要连着**此刻的读数**一起摆给模型 —— 「差在哪」不能靠猜。
|
||||
*
|
||||
* 这条是拿一次熔断换来的:A1 的 S2 收步判据是
|
||||
* `missing_key_total + missing_minor_total = 0`,而草稿里 missing_key_total 是 1。
|
||||
* 模型看不见这个数,以为缺口已经补齐了,就把同一批行再写一遍「确认一下」——
|
||||
* 撞上空转检查被拒;拒了它还是不知道差在哪,再写一遍……连拒五拍熔断。
|
||||
* 把读数摆出来,这一步就从死胡同变成一句话能办的事。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readingsOf } from '../internal/domain/lifecycle.ts';
|
||||
|
||||
describe('收步判据的读数', () => {
|
||||
it('把判据读的那几个值摆出来', () => {
|
||||
const out = readingsOf('missing_key_total + missing_minor_total = 0', {
|
||||
missing_key_total: 1,
|
||||
missing_minor_total: 0,
|
||||
});
|
||||
expect(out).toContain('missing_key_total=1');
|
||||
expect(out).toContain('missing_minor_total=0');
|
||||
});
|
||||
|
||||
it('只摆判据读的,不摆整个任务视图', () => {
|
||||
const out = readingsOf('line_total > 0', { line_total: 3, subject: '询价', pn: 'LM358DR' });
|
||||
expect(out).toContain('line_total=3');
|
||||
expect(out).not.toContain('subject');
|
||||
expect(out).not.toContain('pn');
|
||||
});
|
||||
|
||||
it('没写过的名字如实说没写过 —— 它正是这一步推不动的原因', () => {
|
||||
expect(readingsOf('benchmark_total > 0', {})).toContain('benchmark_total=(没写过)');
|
||||
});
|
||||
|
||||
it('保留字不当台账名去查', () => {
|
||||
const out = readingsOf('need_sourcing = true and line_total > 0', { need_sourcing: false, line_total: 2 });
|
||||
expect(out).toContain('need_sourcing=false');
|
||||
expect(out).not.toContain('true=');
|
||||
expect(out).not.toContain('and=');
|
||||
});
|
||||
|
||||
it('字符串字面量里的词不当台账名', () => {
|
||||
expect(readingsOf('disposition = "照原价报"', { disposition: '通过' })).toBe('(现在:disposition=通过)');
|
||||
});
|
||||
|
||||
it('没有名字可摆时给空串 —— 不往提示词里塞空括号', () => {
|
||||
expect(readingsOf('1 = 1', {})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 判据名归一要削掉**引擎自己贴的等级标记**。
|
||||
*
|
||||
* 提交被拒时门印的是「…没达标 —— 某判据(L3)」,紧接着让模型「照判据名原样写」;
|
||||
* 模型照做,把 `(L3)` 一起抄进 criterion —— 裁定的键与清单里的判据名对不上,
|
||||
* 裁了等于没裁,再提交再被拒,连拒五拍熔断(实测 A2 死于此)。
|
||||
*/
|
||||
import { criterionKey } from '../internal/domain/lifecycle.ts';
|
||||
|
||||
describe('判据名归一', () => {
|
||||
it('模型抄进等级标记也要认得出是同一条', () => {
|
||||
expect(criterionKey('每项数据来源可溯(以原厂官网规格书为准)(L3)')).toBe(
|
||||
criterionKey('每项数据来源可溯(以原厂官网规格书为准)'),
|
||||
);
|
||||
});
|
||||
|
||||
it('五个等级都削', () => {
|
||||
for (const level of ['L1', 'L2', 'L3', 'L4', 'L5']) {
|
||||
expect(criterionKey(`某判据(${level})`)).toBe(criterionKey('某判据'));
|
||||
}
|
||||
});
|
||||
|
||||
it('全角括号的等级标记也削 —— 模型爱把半角敲成全角', () => {
|
||||
expect(criterionKey('某判据(L3)')).toBe(criterionKey('某判据'));
|
||||
});
|
||||
|
||||
it('只削末尾那一个,不碰判据名里本来就有的括号', () => {
|
||||
expect(criterionKey('吃不准降 B(不许臆断 A)')).toBe('吃不准降b(不许臆断a)');
|
||||
});
|
||||
|
||||
it('照旧折全角、去空白 —— 老毛病不许回来', () => {
|
||||
expect(criterionKey('吃不准降B,不许臆断A')).toBe(criterionKey('吃不准降 B,不许臆断 A'));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 同一句交代不进两遍对话区。
|
||||
*
|
||||
* 实测试跑 2e091d25:「有两颗料客户没写清楚……你看是我起草一封信去问,还是你直接找对方?」
|
||||
* 一字不差连着吐了三拍(先 start 那一项、又 start 了一次、才发出决策卡)——
|
||||
* 人在对话区看到同一个问题被问三遍,像卡带。
|
||||
*
|
||||
* 判定放在这儿单测:去空白后逐字比、只比紧邻的上一句。
|
||||
* 近义句不拦(那是两次不同的交代),隔了几拍说回同一句也不拦(局面绕回来了)。
|
||||
*/
|
||||
function isRepeatSay(say: string, trail: ReadonlyArray<{ kind: string; title: string }>): boolean {
|
||||
const now = say.trim().replace(/\s+/g, '');
|
||||
const last = [...trail].reverse().find((t) => t.kind === 'narration');
|
||||
return last !== undefined && last.title.trim().replace(/\s+/g, '') === now;
|
||||
}
|
||||
|
||||
describe('对话区不结巴', () => {
|
||||
const n = (title: string) => ({ kind: 'narration', title });
|
||||
|
||||
it('跟上一句一字不差 —— 压下', () => {
|
||||
expect(isRepeatSay('缺两颗料的信息,要我起草封信吗?', [n('缺两颗料的信息,要我起草封信吗?')])).toBe(true);
|
||||
});
|
||||
|
||||
it('只差空白也算同一句 —— 模型抄自己时空格常飘', () => {
|
||||
expect(isRepeatSay('缺 两颗料 的信息', [n('缺两颗料的信息')])).toBe(true);
|
||||
});
|
||||
|
||||
it('中间隔着别的痕,比的仍是**上一句叙事**', () => {
|
||||
const trail = [n('缺两颗料的信息'), { kind: 'action', title: '写草稿:x 1 行' }, { kind: 'engine-transition', title: 'manualStart:S2' }];
|
||||
expect(isRepeatSay('缺两颗料的信息', trail)).toBe(true);
|
||||
});
|
||||
|
||||
it('近义但不同的句子 —— 放行,那是两次不同的交代', () => {
|
||||
expect(isRepeatSay('历史价都查到了,接下来打标。', [n('三颗料的历史价都查到了,接下来打标。')])).toBe(false);
|
||||
});
|
||||
|
||||
it('隔了一句别的又说回同一句 —— 放行,局面可能真绕回来了', () => {
|
||||
expect(isRepeatSay('缺两颗料的信息', [n('缺两颗料的信息'), n('客户回话了')])).toBe(false);
|
||||
});
|
||||
|
||||
it('一条叙事都还没有 —— 放行', () => {
|
||||
expect(isRepeatSay('第一句话', [{ kind: 'action', title: '写草稿' }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
|
||||
*
|
||||
* 行数类判据拒回时只给两个数,模型看得见「10 ≠ 9」,却不知道多的是哪一行 ——
|
||||
* 它只能猜:改数、重写、再提,三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
|
||||
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
|
||||
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
|
||||
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
|
||||
* 三种一摆主键就都是一句话能办的事。
|
||||
*/
|
||||
describe('行数判据拒回:连主键名单一起摆', () => {
|
||||
it('谓词读了 _rows,就把那张表本单写下的行摆出来', () => {
|
||||
const out = readingsOf('quote_intake_record_rows = line_total', {
|
||||
quote_intake_record_rows: 10,
|
||||
line_total: 9,
|
||||
quote_intake_record_keys: 'LM358DR、TL072CDR、NE555P',
|
||||
});
|
||||
expect(out).toContain('quote_intake_record_rows=10');
|
||||
expect(out).toContain('line_total=9');
|
||||
expect(out).toContain('是这几行:LM358DR、TL072CDR、NE555P');
|
||||
});
|
||||
|
||||
it('两张表都读了 → 两份名单都摆,差集自己对得出来', () => {
|
||||
const out = readingsOf('quote_intake_type_table_rows = quote_intake_record_rows', {
|
||||
quote_intake_type_table_rows: 2,
|
||||
quote_intake_record_rows: 3,
|
||||
quote_intake_type_table_keys: 'A、B',
|
||||
quote_intake_record_keys: 'A、B、C',
|
||||
});
|
||||
expect(out).toContain('是这几行:A、B;');
|
||||
expect(out).toContain('是这几行:A、B、C');
|
||||
});
|
||||
|
||||
it('那张表一行没写就没有名单 —— 不摆一句空的「是这几行:」', () => {
|
||||
const out = readingsOf('quote_intake_alt_request_rows = type_c_total', {
|
||||
quote_intake_alt_request_rows: 0,
|
||||
type_c_total: 0,
|
||||
});
|
||||
expect(out).toBe('(现在:quote_intake_alt_request_rows=0、type_c_total=0)');
|
||||
});
|
||||
|
||||
it('不读 _rows 的判据照旧只摆读数 —— 名单不是每条判据都要的', () => {
|
||||
expect(readingsOf('missing_key_total = 0', { missing_key_total: 1, quote_intake_record_keys: 'X' })).toBe(
|
||||
'(现在:missing_key_total=1)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 阶段进度 —— 进度条的刻度。判据是「这一阶段的计划项**全收完**才算过」。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CasePhase } from '@contracts';
|
||||
import { CLOSING_PHASE, phaseProgress } from '../internal/domain/phase.ts';
|
||||
import type { ItemRuntime, ItemState } from '../internal/domain/lifecycle.ts';
|
||||
|
||||
/** A1 的三段:定位客户 / 归一受理 / 判定去向;放行那一格由进度条自己加 */
|
||||
const PHASES: CasePhase[] = [
|
||||
{ name: '定位客户', items: ['S1_customer'] },
|
||||
{ name: '归一受理', items: ['S2_normalize'] },
|
||||
{ name: '判定去向', items: ['S4_history', 'S5_classify', 'S6_route'] },
|
||||
];
|
||||
|
||||
const items = (states: Record<string, ItemState>): ItemRuntime[] =>
|
||||
Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
|
||||
|
||||
const ALL = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify', 'S6_route', 'T_release'];
|
||||
const at = (done: readonly string[]): ItemRuntime[] =>
|
||||
items(Object.fromEntries(ALL.map((id) => [id, done.includes(id) ? 'Completed' : 'Available'])));
|
||||
|
||||
describe('阶段进度 —— 三段激活态 + 一格收口 = x/4', () => {
|
||||
it('一格都没收:停在第一阶段', () => {
|
||||
expect(phaseProgress(PHASES, at([]), false)).toEqual({ index: 1, total: 4, name: '定位客户' });
|
||||
});
|
||||
|
||||
it('逐段推进:收完一段才进下一段', () => {
|
||||
expect(phaseProgress(PHASES, at(['S1_customer']), false)).toMatchObject({ index: 2, name: '归一受理' });
|
||||
expect(phaseProgress(PHASES, at(['S1_customer', 'S2_normalize']), false)).toMatchObject({
|
||||
index: 3,
|
||||
name: '判定去向',
|
||||
});
|
||||
});
|
||||
|
||||
it('一段里的项没收齐就不算过 —— 三项收两项仍停在这一段', () => {
|
||||
const done = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify'];
|
||||
expect(phaseProgress(PHASES, at(done), false)).toMatchObject({ index: 3, name: '判定去向' });
|
||||
});
|
||||
|
||||
it('三段都过、还没收口:进最后一格(等放行),但不是终态', () => {
|
||||
const done = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify', 'S6_route'];
|
||||
expect(phaseProgress(PHASES, at(done), false)).toEqual({ index: 4, total: 4, name: CLOSING_PHASE });
|
||||
});
|
||||
|
||||
it('终态直接给最后一格 —— 不去数计划项', () => {
|
||||
// 放行完成那一瞬 T_release 还没收:去数项的话会显示「还差一格」,而单已经收口了
|
||||
expect(phaseProgress(PHASES, at([]), true)).toEqual({ index: 4, total: 4, name: CLOSING_PHASE });
|
||||
});
|
||||
|
||||
it('被跳过的裁量项(Terminated)算过 —— 它不会再收,等它等到天亮', () => {
|
||||
const mixed = items({
|
||||
S1_customer: 'Completed',
|
||||
S2_normalize: 'Completed',
|
||||
S4_history: 'Completed',
|
||||
S5_classify: 'Terminated',
|
||||
S6_route: 'Completed',
|
||||
T_release: 'Available',
|
||||
});
|
||||
expect(phaseProgress(PHASES, mixed, false)).toMatchObject({ index: 4, name: CLOSING_PHASE });
|
||||
});
|
||||
|
||||
it('没声明阶段就不给刻度 —— 界面照此不画进度条,不编一个出来', () => {
|
||||
expect(phaseProgress(undefined, at([]), false)).toBeUndefined();
|
||||
expect(phaseProgress([], at([]), false)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ import type {
|
||||
TerminalAuthority,
|
||||
} from './internal/domain/lifecycle.ts';
|
||||
import type { DecisionResult, DecisionTrace, Gap } from './internal/domain/dmn.ts';
|
||||
import type { PhaseProgress } from './internal/domain/phase.ts';
|
||||
|
||||
export type {
|
||||
CaseModel,
|
||||
@@ -31,6 +32,7 @@ export type {
|
||||
ItemState,
|
||||
LoopModel,
|
||||
Navigation,
|
||||
PhaseProgress,
|
||||
PlanItem,
|
||||
PlanItemType,
|
||||
TerminalAuthority,
|
||||
@@ -57,8 +59,154 @@ export class AutomaticOnWorldTouchingError extends Error {
|
||||
export type AgentAction =
|
||||
/** 调工具:系统调用与 LLM 工具 */
|
||||
| { readonly kind: 'tool'; readonly itemId: string; readonly tool: string; readonly input: Record<string, unknown> }
|
||||
/**
|
||||
* 写台账草稿 —— **活动过程写的唯一形态**(铁律②:登记那一刻世界上什么都没变)。
|
||||
*
|
||||
* 一拍可写多行:逐条把一份 BOM 解析成几十行,是**一件事**,不该拆成几十拍
|
||||
* 各烧一次模型。地址必须在活动清单声明过的台账对象之内(越域写与越权调工具同罪)。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'write';
|
||||
readonly itemId: string;
|
||||
/**
|
||||
* 台账地址「域.对象」—— **一个字符串,不是两个字段**。
|
||||
*
|
||||
* 系统上下都用这一种写法(可写台账字典、`requiredDeliverables[].ledger` 都是它),
|
||||
* 拆成 domain + object 只会让模型把整个地址塞进其中一格 —— 实测第一拍就这么错。
|
||||
*/
|
||||
readonly address: string;
|
||||
readonly rows: ReadonlyArray<{
|
||||
readonly key: Record<string, string | number>;
|
||||
readonly patch: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
/**
|
||||
* **撤掉自己写错的那一行** —— 写错了得有退路(2026-08-01 加)。
|
||||
*
|
||||
* 只作用在**本单草稿层**:正式版一个字都动不了,也不抛事件 ——
|
||||
* 撤的是「我这一单还没过门的那一笔」,放行门的位置没变。
|
||||
*
|
||||
* 起因是实测的一次死胡同:客户明说本轮跳过的那颗料被写进了有效清单,
|
||||
* 条目数报 9(对)而表里躺着 10,判据 `record_rows = line_total` 正确地拦住 ——
|
||||
* 而模型**改不动**:它唯一能改的是那个数,改了 `skipped_total` 又不对。
|
||||
* 13 次拒回、23 拍、234k token 才熔断。**门拦住一件办错的事却不给改正的手段,
|
||||
* 那扇门就只是个死胡同。**
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'drop';
|
||||
readonly itemId: string;
|
||||
/** 台账地址「域.对象」—— 与 write 同一种写法 */
|
||||
readonly address: string;
|
||||
/** 要撤的那几行的主键 —— 认不出的行如实回「草稿层里没有这一行」 */
|
||||
readonly keys: ReadonlyArray<Record<string, string | number>>;
|
||||
readonly reason: string;
|
||||
}
|
||||
/** 调人:四卡之一 —— 调人 = 立监听听人 */
|
||||
| { readonly kind: 'human'; readonly itemId: string; readonly card: Record<string, unknown>; readonly workOrder: 'execution' | 'acceptance' }
|
||||
/**
|
||||
* **发补全卡** —— 对话卡族之一,与执行工单是两种东西。
|
||||
*
|
||||
* 「按缺口清单追问补全」(A1-S2)要的就是它:缺口清单摆在对话区、带一张表单,
|
||||
* 人就地填齐。而执行工单是「派一件活给你办」—— 派一张工单去问几个字段,
|
||||
* 收卡的人得进待处理、办完再回来,重得不成比例。
|
||||
*
|
||||
* 从前 Agent **发不出对话卡**:行动型里只有 `human` 且只能选执行 / 验收两种工单,
|
||||
* 于是 SOP 上画着「补全卡」的那一步只能拿执行工单顶 —— 形似而神非。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'ask';
|
||||
readonly itemId: string;
|
||||
/** 问什么 —— 一句话说清缺口 */
|
||||
readonly ask: string;
|
||||
/**
|
||||
* 要对方填的格子。`hint` 是格子里的灰字提示;
|
||||
* **`value` 是预填值** —— AI 从来件里读得出的先替人填上(公司名、联系人姓名),
|
||||
* 人只需核对一眼、补上填不出的那个,而不是对着一排空格从头敲。
|
||||
* 读不出来的就别给:填错了人还得先擦掉再写,比空着更费事。
|
||||
*/
|
||||
readonly fields: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly hint?: string;
|
||||
readonly value?: string;
|
||||
}>;
|
||||
/** 依据快照:哪几条缺哪几个字段 —— 调用必附上下文,裸调用拒发 */
|
||||
readonly basis?: ReadonlyArray<{ readonly k: string; readonly v: string }>;
|
||||
}
|
||||
/**
|
||||
* 发**数据录入卡** —— 人类工具的第五类:**人按下确认那一刻就是写入**。
|
||||
*
|
||||
* 与补全卡(`ask`)的分工:那张收的是「答案」,你拿到之后还要自己 `write` 一遍;
|
||||
* 这张收的是「这一行数据」—— 卡上写明落哪张表、哪个主键,人确认,基座当场落草稿层,
|
||||
* **你不经手**。建档这类事该用它:主数据建错一次,后面每一单都跟着错,
|
||||
* 那一笔该由人亲手落下,不该经你转述一遍。
|
||||
*
|
||||
* 你的活是**把能填的先填好**(`value`),填不出的留空 —— 界面会标「待填写」。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'entry';
|
||||
readonly itemId: string;
|
||||
readonly ask: string;
|
||||
/** 落哪张表:`域.对象` */
|
||||
readonly ledger: string;
|
||||
/** 主键值 —— 确认时按它定位那一行 */
|
||||
readonly key: Readonly<Record<string, string>>;
|
||||
readonly fields: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly value?: string;
|
||||
readonly required?: boolean;
|
||||
}>;
|
||||
/**
|
||||
* **同一次确认还要落的另几张表**(2026-08-02)。
|
||||
*
|
||||
* 建一位新客户落的是**两条主数据**:公司档 + 联系人档。只把公司那一半摆上卡、
|
||||
* 联系人由你自己写,等于让人确认了一半 —— 而联系人档的 `contact_code`
|
||||
* 恰恰是你编出来的那一个。分成两张卡也能办,但那是把一件事拆成两次打断。
|
||||
*/
|
||||
readonly also?: ReadonlyArray<{
|
||||
/** 这一段是什么 —— 卡上那行小标题(如「联系人档案」) */
|
||||
readonly title: string;
|
||||
readonly ledger: string;
|
||||
readonly key: Readonly<Record<string, string>>;
|
||||
readonly fields: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly value?: string;
|
||||
readonly required?: boolean;
|
||||
}>;
|
||||
}>;
|
||||
readonly basis?: ReadonlyArray<{ readonly k: string; readonly v: string }>;
|
||||
}
|
||||
/**
|
||||
* 发**决策卡** —— 对话卡族的第二种:把一个岔路口摆给人,人点一个选项。
|
||||
*
|
||||
* 与补全卡的分工:补全卡问的是**事实**(缺哪几个字段,你填);
|
||||
* 决策卡问的是**处置**(这一步该往哪走,你选)。A1 的
|
||||
* 「字段没齐 —— 要我系统外去问客户,还是替你草拟一封邮件」正是后者:
|
||||
* 两条路都合法,选哪条是业务判断,不该由模型替人决定。
|
||||
*
|
||||
* 从前决策卡**只有 UI 没有 Agent 侧**:卡面在对话区画得出来,而行动型里发不出它 ——
|
||||
* 于是所有岔路口都被写成补全卡,人拿到的是一张要自己敲字的表单,而不是两个可点的选项。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'decide';
|
||||
readonly itemId: string;
|
||||
/** 问什么 —— 一句话说清这是个什么岔路 */
|
||||
readonly ask: string;
|
||||
/** 选项:一条路一项。`consequence` 写清选了它会发生什么 */
|
||||
readonly options: ReadonlyArray<{
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly detail?: string;
|
||||
readonly consequence?: string;
|
||||
readonly recommended?: boolean;
|
||||
}>;
|
||||
/** 依据快照:凭什么问这一问 —— 调用必附上下文,裸调用拒发 */
|
||||
readonly basis?: ReadonlyArray<{ readonly k: string; readonly v: string }>;
|
||||
/** 允许人给出选项之外的处置(附理由) */
|
||||
readonly allowCustom?: boolean;
|
||||
}
|
||||
/** 立监听:AI 不能调用世界,对世界只有两种合法姿势 —— 触达它(动作)与等它(监听) */
|
||||
| { readonly kind: 'listen'; readonly itemId: string; readonly eventType: 'data' | 'time'; readonly keyFilter: Record<string, string>; readonly deadline: string }
|
||||
/** 启动一个 Enabled 项(manualStart,留痕) */
|
||||
@@ -69,8 +217,35 @@ export type AgentAction =
|
||||
| { readonly kind: 'disable'; readonly itemId: string; readonly reason: string }
|
||||
/** 评估语义性前提(评估留痕,可回放可归因) */
|
||||
| { readonly kind: 'judge'; readonly criterion: string; readonly satisfied: boolean; readonly rationale: string }
|
||||
/** 判定成果已齐,提交放行 */
|
||||
| { readonly kind: 'submit'; readonly rationale: string }
|
||||
/**
|
||||
* 这一项干完了 —— **完成由 Agent 自己宣告,不是某个动作的副作用**。
|
||||
*
|
||||
* 从前 `tool` / `write` 一做完就把计划项收了。可一个计划项常常要好几个动作才交得出成果
|
||||
* (先逐条写记录、再结算总数;先调工具取判据、再把判据写进台账)——
|
||||
* 一收就再也动不了它,于是那一项该产出的东西永远缺着,而环在原地打转。
|
||||
* 实测:20 拍烧掉 120k token,卡在「当下可为 ∩ 目标所需 = 空」。
|
||||
*
|
||||
* 宣告完成不等于成果被认可:终局仍唯放行,人验收才算数。
|
||||
*/
|
||||
| { readonly kind: 'finish'; readonly itemId: string; readonly rationale: string }
|
||||
/**
|
||||
* 判定成果已齐,提交放行。
|
||||
*
|
||||
* `fields` = 验收报告上**让人当场改**的那几个值(PRD:「AI 已填好、可编辑」)——
|
||||
* `ai` 是活动拟的值,`ledger` 是改完落回哪个台账地址(`域.对象`)。
|
||||
* 不给 `fields`,验收报告就只能看不能改;给了 `ai` 不给 `ledger`,人改了也只留在
|
||||
* 工单留痕里,落地用的还是活动的原值 —— 两样都得有,修订才落得下去。
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'submit';
|
||||
readonly rationale: string;
|
||||
readonly fields?: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly ai?: string;
|
||||
readonly ledger?: string;
|
||||
}>;
|
||||
}
|
||||
/**
|
||||
* 本回合**已在工具循环里调过人**:卡已发、监听已立(工具总线执行的)。
|
||||
* 引擎只收尾把这一拍置 awaiting —— 不能再发一张,否则负责人工位上堆两张同样的卡。
|
||||
@@ -78,6 +253,44 @@ export type AgentAction =
|
||||
*/
|
||||
| { readonly kind: 'asked'; readonly cardId: string; readonly rationale: string };
|
||||
|
||||
/**
|
||||
* **对人说的一句话** —— 交代进展,并可顺手换一张局面画面。
|
||||
*
|
||||
* 它**不是一型行动,是每一拍的附带** —— 这一点是改过的:一开始把它做成独立行动,
|
||||
* 结果它占一整拍又不推进局面,预算下的 Agent 理所当然跳过它,对话区照旧空白。
|
||||
* 让说话与干活抢拍,是把「必须说」设计成了「有空才说」。
|
||||
*
|
||||
* 现在:一拍 = 一个行动 + 一句交代。说话不花额外的拍,也就没有不说的理由。
|
||||
* `view` 是可选的局面模版名 —— 局面变了,人既要听到一句为什么,也要看到新画面。
|
||||
*/
|
||||
export type Narration = {
|
||||
readonly text: string;
|
||||
/** 语气签:指导 / 判据 / 结论 —— 前端各有其样式 */
|
||||
readonly tag?: 'guidance' | 'criterion' | 'conclusion';
|
||||
readonly view?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 一拍的产出:**一串行动**(按序执行)+ 对人交代一句。
|
||||
*
|
||||
* 为什么是一串不是一件:一拍只许一个行动时,每个 write、每个 finish、每个 start
|
||||
* 都单烧一次完整模型调用(说明书 + 轨迹整段重读)。实测 A1 六步的活恰好花 21 拍 ——
|
||||
* 其中 19 拍是这个约束下的**最短路径**,浪费不在模型跑偏,在架构按拍计费。
|
||||
* 收成一串后,「写记录、写结算、finish、start 下一项」一口气给,一单 5-8 拍收得了口。
|
||||
*
|
||||
* 执行纪律(引擎强制,不靠模型自觉):
|
||||
* · 按序执行,谁被拒就停在谁那儿 —— 已执行的前缀落笔留痕,理由喂回下一拍;
|
||||
* · `tool` 之后截断 —— 出参没看见,后面的行动是闭着眼计划的;
|
||||
* · `ask` / `decide` / `human` / `listen` / `submit` 让本拍挂起 —— 只能是最后一个;
|
||||
* · 一串封顶 `TURN_ACTION_CAP` 件,超出的直接丢弃(留痕)。
|
||||
*/
|
||||
export const TURN_ACTION_CAP = 6;
|
||||
|
||||
export type AgentTurn = {
|
||||
readonly actions: readonly AgentAction[];
|
||||
readonly say?: Narration;
|
||||
};
|
||||
|
||||
export type TurnOutcome = {
|
||||
readonly runtime: CaseRuntime;
|
||||
readonly enabled: EnabledSet;
|
||||
@@ -94,6 +307,14 @@ export type LoopEngineService = {
|
||||
/** 装载:XML 进语义引擎,说明书进 Agent 上下文;版本全部入信封 */
|
||||
load(taskId: TaskId, model: LoopModel): Promise<CaseRuntime>;
|
||||
runtime(taskId: TaskId): Promise<CaseRuntime | undefined>;
|
||||
/**
|
||||
* 进度刻度:这一单在第几阶段(见 `internal/domain/phase.ts`)。
|
||||
*
|
||||
* 由 loop 出而不是让调用方自己算:阶段声明与计划项状态**同在一行运行时里**,
|
||||
* 调用方要自己算就得先把模型和运行时都捞出来 —— 那是把内部结构摊给外面。
|
||||
* 活动没声明阶段就回 `undefined`:界面照此不画进度条,不编一个出来。
|
||||
*/
|
||||
phaseOf(taskId: TaskId, terminal: boolean): Promise<PhaseProgress | undefined>;
|
||||
/** 发布 Enabled 集与红绿灯 —— 引擎推进的终点 */
|
||||
publish(taskId: TaskId): Promise<EnabledSet>;
|
||||
/** 跑一个回合:定位 → 择向 → 决断 → 行动 → 写数据 */
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
export type {
|
||||
AgentAction,
|
||||
AgentTurn,
|
||||
Narration,
|
||||
CaseModel,
|
||||
CaseRuntime,
|
||||
CaseState,
|
||||
@@ -26,6 +28,7 @@ export {
|
||||
CmmnParseError,
|
||||
ForbiddenTerminalError,
|
||||
IllegalTransitionError,
|
||||
TURN_ACTION_CAP,
|
||||
} from './contract.ts';
|
||||
|
||||
export type {
|
||||
@@ -47,9 +50,12 @@ export { createTurnRunner } from './internal/usecase/turn-runner.ts';
|
||||
export { parseCmmn, parseXml, PLAN_ITEM_TYPES } from './internal/domain/cmmn-parse.ts';
|
||||
export {
|
||||
assertItemTransition,
|
||||
achieveMilestones,
|
||||
autoCompleteReady,
|
||||
canTransitionCase,
|
||||
canTransitionItem,
|
||||
criterionKey,
|
||||
readingsOf,
|
||||
CASE_STATES,
|
||||
evaluateCriterion,
|
||||
initialRuntime,
|
||||
|
||||
@@ -139,6 +139,17 @@ export type Redlight = { readonly criterion: string; readonly statement: string
|
||||
|
||||
export type EnabledSet = {
|
||||
readonly entries: readonly EnabledEntry[];
|
||||
/**
|
||||
* **进行中的项** —— 已开工、还没交出成果的那些。
|
||||
*
|
||||
* Enabled 集回答的是「什么可以**新**开始」;进行中的项按定义不在里面。
|
||||
* 可局面一旦推进开,手上的活全是 Active —— 于是 Agent 看到的「当下可为」是空的,
|
||||
* 而它正干着的两件事一个也看不见。实测的后果:它开始编计划项 id、反复被拓扑拒回来,
|
||||
* 15 拍烧掉 83k token,最后立个监听睡过去 —— 手上明明还有半件活没干完。
|
||||
*
|
||||
* 所以进行中的项必须一并发布:要么接着干(write / tool),要么 finish。
|
||||
*/
|
||||
readonly inProgress: readonly EnabledEntry[];
|
||||
/** 待裁量评估的语义性前提 */
|
||||
readonly judgements: readonly Redlight[];
|
||||
/** 目标缺口:未绿的判据 */
|
||||
@@ -196,6 +207,16 @@ export function publishEnabled(
|
||||
|
||||
return {
|
||||
entries,
|
||||
// 进行中 = Active 的项(阶段也算:它开着,Agent 才知道自己站在哪一段里)
|
||||
inProgress: model.items
|
||||
.filter((item) => byId.get(item.id)?.state === 'Active')
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
requiresManualStart: item.manualActivation,
|
||||
discretionary: item.discretionary,
|
||||
})),
|
||||
judgements,
|
||||
gaps: unmetCriteria,
|
||||
activeStages: runtime.items
|
||||
@@ -219,7 +240,16 @@ export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Na
|
||||
if (enabled.gaps.length === 0) return { kind: 'converged' };
|
||||
const intersection =
|
||||
required.size === 0 ? enabled.entries : enabled.entries.filter((e) => required.has(e.id));
|
||||
if (intersection.length === 0) return { kind: 'breakpoint', gaps: enabled.gaps };
|
||||
/*
|
||||
* 断点的定义是「**无路可走**」—— 手上还有开着的活就不算断点。
|
||||
* 从前只看 Enabled 集:局面推进开以后手上全是 Active,每一拍都被判成断点,
|
||||
* 而断点的处置是「调人或立监听」—— 于是环把没干完的活丢下,去等一个不会来的事件。
|
||||
*/
|
||||
if (intersection.length === 0) {
|
||||
return enabled.inProgress.length > 0
|
||||
? { kind: 'proceed', candidates: enabled.inProgress }
|
||||
: { kind: 'breakpoint', gaps: enabled.gaps };
|
||||
}
|
||||
return { kind: 'proceed', candidates: intersection };
|
||||
}
|
||||
|
||||
@@ -229,7 +259,12 @@ export function navigate(enabled: EnabledSet, required: ReadonlySet<string>): Na
|
||||
export function autoCompleteReady(model: CaseModel, runtime: CaseRuntime): boolean {
|
||||
if (!model.autoComplete) return false;
|
||||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||||
const required = model.items.filter((i) => !i.discretionary && i.type !== 'timerEventListener');
|
||||
/*
|
||||
* 事件监听器**不是交付物**,是等待岗:等来了就完成,等不来也不该拦着结案。
|
||||
* 从前只排除 timerEventListener —— 于是一个 `userEventListener`(如「客户改需求」)
|
||||
* 就让 autoComplete 永远为假:必做项全干完了,案子还是判不齐。
|
||||
*/
|
||||
const required = model.items.filter((i) => !i.discretionary && !i.type.endsWith('EventListener'));
|
||||
const allDone = required.every((i) => {
|
||||
const state = byId.get(i.id)?.state;
|
||||
return state === 'Completed' || state === 'Terminated' || state === 'Disabled';
|
||||
@@ -255,6 +290,43 @@ export function mayTerminateCase(by: TerminalAuthority): boolean {
|
||||
}
|
||||
|
||||
/** 里程碑与事件监听器只有一条主迁移:Available →(occur)→ Completed */
|
||||
/**
|
||||
* 前提达成即达成的里程碑 —— **不需要谁去按**。
|
||||
*
|
||||
* CMMN 的里程碑语义就是「一个前提被满足」,它没有执行体、也没有人去启动它。
|
||||
* 从前引擎只在 `onEvent(ref = 里程碑 id)` 时才 occur,而系统里没有任何东西
|
||||
* 会发出以里程碑命名的事件 —— 结果每个里程碑都永远停在 Available:
|
||||
* 既堵住 autoComplete(它是必做项),又常驻在「当下可为」与「目标缺口」里,
|
||||
* 让 Agent 每拍都看到一堆自己按不动的东西。
|
||||
*
|
||||
* 返回达成了的那些 id(调用方据此留痕)与推进后的运行时。
|
||||
*/
|
||||
export function achieveMilestones(
|
||||
model: CaseModel,
|
||||
runtime: CaseRuntime,
|
||||
scope: Readonly<Record<string, unknown>>,
|
||||
changedRefs: ReadonlySet<string>,
|
||||
evaluate: ConditionEvaluator,
|
||||
): { runtime: CaseRuntime; achieved: string[] } {
|
||||
const byId = new Map(runtime.items.map((i) => [i.id, i]));
|
||||
let next = runtime;
|
||||
const achieved: string[] = [];
|
||||
|
||||
for (const item of model.items) {
|
||||
if (item.type !== 'milestone') continue;
|
||||
if (byId.get(item.id)?.state !== 'Available') continue;
|
||||
// 无前提的里程碑不自达成:那是建模漏了前提,不是「一开案就达成」
|
||||
if (item.entryCriteria.length === 0) continue;
|
||||
const met = item.entryCriteria.every(
|
||||
(criterion) => evaluateCriterion(criterion, scope, changedRefs, evaluate).kind === 'satisfied',
|
||||
);
|
||||
if (!met) continue;
|
||||
next = occur(next, item.id);
|
||||
achieved.push(item.id);
|
||||
}
|
||||
return { runtime: next, achieved };
|
||||
}
|
||||
|
||||
export function occur(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||||
return {
|
||||
...runtime,
|
||||
@@ -279,3 +351,77 @@ export function reenter(runtime: CaseRuntime, itemId: string): CaseRuntime {
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判据名归一 —— 让「模型抄的那一句」和「清单里写的那一句」对得上。
|
||||
*
|
||||
* `judge` 的 criterion 是让模型「照判据名原样写」的一句中文,可它抄的时候会顺手
|
||||
* 把半角标点敲成全角、把空格吃掉:清单里写的是「吃不准降 B,不许臆断 A」,
|
||||
* 它交回来的是「吃不准降B,不许臆断A」—— 逐字比对当然对不上,那条判据于是永远未达标,
|
||||
* 提交放行一遍遍被拒,直到预算烧完(实测 32 次被拒 → exhausted)。
|
||||
*
|
||||
* 归一只做两件安全的事:**去掉所有空白** + **全角标点折成半角**。
|
||||
* 不做同义词匹配、不做模糊比对 —— 那会把两条不同的判据混成一条,比对不上更糟。
|
||||
*/
|
||||
export function criterionKey(raw: string): string {
|
||||
const folded = raw.replace(/[!-~]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0));
|
||||
const tight = folded.replace(/\s+/g, '').replace(/[、,]/g, ',').toLowerCase();
|
||||
/*
|
||||
* 末尾的等级标记要削掉 —— 那是**引擎自己贴上去的**,不是判据名的一部分。
|
||||
*
|
||||
* 提交被拒时,门给的话是「…还有 1 条判据没达标 —— 每项数据来源可溯(…)(L3)」,
|
||||
* 紧接着又说「judge 的 criterion 照判据名原样写」。模型照做了 ——
|
||||
* 它把 `(L3)` 一起抄了进来,于是这条裁定的键与清单里的判据名对不上,
|
||||
* 裁了等于没裁:再提交、再被拒、再裁……连拒五拍熔断(实测 A2 死于此)。
|
||||
*
|
||||
* 这不是模糊匹配:削的是引擎自己印上去的那一小段,不碰判据名本身。
|
||||
*/
|
||||
return tight.replace(/\(l[1-5]\)$/, '');
|
||||
}
|
||||
|
||||
/** S-FEEL 保留字 —— 摆读数时不该把它们当成台账名去查 */
|
||||
const SFEEL_WORDS = new Set(['true', 'false', 'null', 'and', 'or', 'not', 'in', 'between']);
|
||||
|
||||
/**
|
||||
* 收步判据里那几个名字**此刻的读数** —— 「差在哪」要连着判据一起摆出来。
|
||||
*
|
||||
* 只给判据读的那几个,不是整个任务视图:视图有上百个键,全摆出来等于没摆。
|
||||
* 没绑上的名字如实写「(没写过)」—— 未绑定的名字在 S-FEEL 里求值为假,
|
||||
* 那正是这一步推不动的原因,藏起来只会让模型继续瞎猜。
|
||||
*/
|
||||
export function readingsOf(predicate: string, scope: Readonly<Record<string, unknown>>): string {
|
||||
const names = new Set<string>();
|
||||
for (const raw of predicate.replace(/"[^"]*"/g, ' ').match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) {
|
||||
if (!SFEEL_WORDS.has(raw.toLowerCase())) names.add(raw);
|
||||
}
|
||||
if (names.size === 0) return '';
|
||||
const readings = [...names].map((name) => {
|
||||
const value = scope[name];
|
||||
if (value === undefined || value === null) return `${name}=(没写过)`;
|
||||
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
return `${name}=${text.length <= 120 ? text : `${text.slice(0, 120)}…`}`;
|
||||
});
|
||||
/*
|
||||
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
|
||||
*
|
||||
* 行数类判据(`quote_intake_record_rows = line_total`)拒回时只给两个数,
|
||||
* 模型看得见「10 ≠ 9」,却不知道多的是哪一行 —— 它只能猜:改数、重写、再提,
|
||||
* 三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
|
||||
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
|
||||
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
|
||||
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
|
||||
* 三种一摆主键就都是一句话能办的事:多的那个 PN 就在名单里对着。
|
||||
*
|
||||
* **只摆基座数得出来的**:本单在那张表写下的行、它们的主键。不替模型判断谁多谁少 ——
|
||||
* 名单摆出来,该删该补它自己看得出来;判断留给它,事实由基座给。
|
||||
*/
|
||||
const rowKeys = [...names]
|
||||
.filter((n) => n.endsWith('_rows') || n.endsWith('_missing'))
|
||||
.map((n) => {
|
||||
const listed = scope[n.endsWith('_rows') ? `${n.slice(0, -'_rows'.length)}_keys` : `${n}_keys`];
|
||||
if (typeof listed !== 'string' || listed === '') return '';
|
||||
return n.endsWith('_rows') ? `${n} 是这几行:${listed}` : `${n} 缺在这几行:${listed}`;
|
||||
})
|
||||
.filter((s) => s !== '');
|
||||
return `(现在:${readings.join('、')}${rowKeys.length === 0 ? '' : `;${rowKeys.join(';')}`})`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 阶段进度 —— **给人看的刻度**,纯函数、零 IO(编码规范 §5.3)。
|
||||
*
|
||||
* 计划项是给引擎的粒度(A1 六项);阶段是给人的粒度(三段 + 收口)。
|
||||
* 两层分开,是因为它们回答的是两个问题:
|
||||
* · 计划项:这一件事干完没有 —— 引擎要能判,所以拆得细;
|
||||
* · 阶段:这一单到哪儿了 —— 人要能扫一眼看懂,所以合得粗。
|
||||
* 拿计划项当进度条,人看到的是「6 格里过了 3 格」,而那 6 格他一个也说不出是什么。
|
||||
*/
|
||||
import type { CasePhase } from '@contracts';
|
||||
import type { ItemRuntime } from './lifecycle.ts';
|
||||
|
||||
export type PhaseProgress = {
|
||||
/** 第几阶段,1 起;全部阶段过完但还没收口 = 最后一格(结束态) */
|
||||
readonly index: number;
|
||||
/** 总格数 = 声明的阶段数 **+ 1**(结束态) */
|
||||
readonly total: number;
|
||||
/** 这一格叫什么 */
|
||||
readonly name: string;
|
||||
};
|
||||
|
||||
/** 结束态的名字 —— 每个活动都以放行收口(铁律③),不由活动自己起名 */
|
||||
export const CLOSING_PHASE = '放行收口';
|
||||
|
||||
/**
|
||||
* 算这一单在第几阶段。
|
||||
*
|
||||
* 判据是「这一阶段的计划项**全部收完**」——
|
||||
* · 收完(Completed / Terminated,后者是被跳过的裁量项)算过;
|
||||
* · 没收完就停在这一阶段,哪怕其中一项已经在办。
|
||||
* 从前往后找第一个没过的阶段:那就是当下这一格。全过了 = 进结束态。
|
||||
*
|
||||
* `terminal` 为真(这一单已收口)时直接给最后一格 —— 终态就是终态,
|
||||
* 不必再去数计划项:放行完成之后 T_release 才收,中间那一瞬会显示成「还差一格」。
|
||||
*/
|
||||
export function phaseProgress(
|
||||
phases: readonly CasePhase[] | undefined,
|
||||
items: readonly ItemRuntime[],
|
||||
terminal: boolean,
|
||||
): PhaseProgress | undefined {
|
||||
if (phases === undefined || phases.length === 0) return undefined;
|
||||
const total = phases.length + 1;
|
||||
if (terminal) return { index: total, total, name: CLOSING_PHASE };
|
||||
|
||||
const settled = new Set(items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id));
|
||||
for (const [at, phase] of phases.entries()) {
|
||||
if (!phase.items.every((id) => settled.has(id))) return { index: at + 1, total, name: phase.name };
|
||||
}
|
||||
return { index: total, total, name: CLOSING_PHASE };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
import type { LoopModel, StaffId, TaskId } from '@contracts';
|
||||
import type { CaseModel } from './internal/domain/cmmn-parse.ts';
|
||||
import type { CaseRuntime, EnabledSet } from './internal/domain/lifecycle.ts';
|
||||
import type { AgentAction } from './contract.ts';
|
||||
import type { AgentTurn } from './contract.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
|
||||
@@ -32,13 +32,47 @@ export type Sfeel = {
|
||||
/** 任务数据 —— app 注入 `ledger`(L1):引擎对**任务视图**求值,不依赖事件 */
|
||||
export type TaskDataGateway = {
|
||||
view(taskId: TaskId): Promise<Record<string, unknown>>;
|
||||
/**
|
||||
* 本活动**声明过**的台账对象(域.对象 → 列名与中文标)。
|
||||
*
|
||||
* 两用:喂给 Agent(不知道能写哪儿就只会瞎猜列名),以及校验 `write` 行动 ——
|
||||
* 没声明过的地址一律拒写。越域写与越权调工具同罪,都得在结构上拦住。
|
||||
*/
|
||||
writable(taskId: TaskId): Promise<
|
||||
Array<{
|
||||
domain: string;
|
||||
object: string;
|
||||
label: string;
|
||||
keys: string[];
|
||||
fields: Array<{ name: string; label: string; type?: string }>;
|
||||
}>
|
||||
>;
|
||||
/**
|
||||
* 写草稿,并回报**这一笔到底有没有改变什么**。
|
||||
*
|
||||
* 值和上次一模一样的写是空转。不回报,Agent 就看不出自己在原地打转 ——
|
||||
* 实测:同一行 verdict 被反复重写十几拍,每拍烧一次模型,预算就这么淌完。
|
||||
*/
|
||||
draftWrite(input: {
|
||||
taskId: TaskId;
|
||||
domain: string;
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
patch: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
}): Promise<{ changed: boolean }>;
|
||||
/**
|
||||
* **撤掉本单草稿层的一行** —— 写错了得有退路。
|
||||
*
|
||||
* 回 `false` = 草稿层里根本没有这一行(多半是主键写法对不上,
|
||||
* `DAG…` 与 `DAG…-R0` 是两行)。如实说比静默成功有用:静默成功会让模型
|
||||
* 以为撤掉了,下一拍判据照旧不过,它又去撤一次。
|
||||
*/
|
||||
draftDrop(input: {
|
||||
taskId: TaskId;
|
||||
domain: string;
|
||||
object: string;
|
||||
key: Record<string, string | number>;
|
||||
}): Promise<{ dropped: boolean }>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -52,7 +86,23 @@ export type AgentPort = {
|
||||
narrative: string;
|
||||
enabled: EnabledSet;
|
||||
scope: Record<string, unknown>;
|
||||
}): Promise<AgentAction>;
|
||||
/**
|
||||
* 这一单能调什么 —— **声明 × 目录**编译出来的工具清单(`compileToolBus`)。
|
||||
*
|
||||
* 不给它这份清单,它就只知道说明书里提过的工具名,连入参长什么样都得猜 ——
|
||||
* 实测的后果是:该调工具的那一步改成了发一张卡问人(「请为以下三个PN查询历史价格」),
|
||||
* 明明系统里就有这件工具。工具是声明式的,清单得摆在它眼前。
|
||||
*/
|
||||
tools: Array<{ id: string; description: string; input: Record<string, unknown>; effect: 'read' | 'write' }>;
|
||||
/** 本活动可写的台账地址与列名 —— 不给它字典,它只会照想象编列名 */
|
||||
writable: Array<{
|
||||
domain: string;
|
||||
object: string;
|
||||
label: string;
|
||||
keys: string[];
|
||||
fields: Array<{ name: string; label: string; type?: string }>;
|
||||
}>;
|
||||
}): Promise<AgentTurn>;
|
||||
};
|
||||
|
||||
/** 工具 / 人类工具 / 监听 —— 三型行动的落点 */
|
||||
@@ -61,9 +111,21 @@ export type ToolGateway = {
|
||||
ok: boolean;
|
||||
output?: Record<string, unknown>;
|
||||
error?: { message: string };
|
||||
/**
|
||||
* 已转人代办,等人回填(§6.2)——「人是工具的一种实现」。
|
||||
*
|
||||
* 系统里没有这件工具时,不是失败:基座已开了一张执行卡并立了监听。
|
||||
* 这一拍该**挂起等人**,不该把计划项判 Failed —— 判 Failed 等于告诉 Agent
|
||||
* 「这条路走不通,换一条」,可这条路明明正在走,只是走的人换成了人。
|
||||
*/
|
||||
awaitingHuman?: { readonly workOrderId: string };
|
||||
}>;
|
||||
isWriteEffect(tool: string): Promise<boolean>;
|
||||
registerIntent(input: { taskId: TaskId; tool: string; input: Record<string, unknown>; label: string }): Promise<void>;
|
||||
/** 这一单能调什么(声明 × 目录)—— 单拍路径与开环路径吃同一份清单 */
|
||||
busOf(activityId: string): Promise<
|
||||
Array<{ id: string; description: string; input: Record<string, unknown>; effect: 'read' | 'write' }>
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -77,6 +139,24 @@ export type PendingAcceptance = {
|
||||
};
|
||||
|
||||
export type CardGateway = {
|
||||
/** 数据录入卡 —— 人确认那一下由基座直接写草稿层,不回到 Agent 手里 */
|
||||
entry(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
ask: string;
|
||||
ledger: string;
|
||||
key: Readonly<Record<string, string>>;
|
||||
fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>;
|
||||
/** 同一次确认还要落的另几张表 —— 建一位新客户 = 公司档 + 联系人档 */
|
||||
also?: ReadonlyArray<{
|
||||
title: string;
|
||||
ledger: string;
|
||||
key: Readonly<Record<string, string>>;
|
||||
fields: ReadonlyArray<{ key: string; label: string; value?: string; required?: boolean }>;
|
||||
}>;
|
||||
basis?: ReadonlyArray<{ k: string; v: string }>;
|
||||
}): Promise<{ id: string }>;
|
||||
issue(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
@@ -84,6 +164,42 @@ export type CardGateway = {
|
||||
workOrder: 'execution' | 'acceptance';
|
||||
card: Record<string, unknown>;
|
||||
}): Promise<{ id: string }>;
|
||||
/**
|
||||
* 发**补全卡**(对话卡族)—— 缺口清单摆在对话区、带一张表单,人就地填齐。
|
||||
*
|
||||
* 与执行工单分开是因为它们是两件事:工单是「派一件活给你办」,
|
||||
* 派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
|
||||
* SOP 上画着「补全卡」的那一步(A1-S2),要的就是这一件。
|
||||
*/
|
||||
ask(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
ask: string;
|
||||
fields: ReadonlyArray<{ key: string; label: string; hint?: string }>;
|
||||
basis?: ReadonlyArray<{ k: string; v: string }>;
|
||||
}): Promise<{ id: string }>;
|
||||
/**
|
||||
* 发**决策卡**(对话卡族)—— 岔路口摆给人**点一个**,不是让人敲字。
|
||||
*
|
||||
* 与补全卡的分工:补全卡问事实(缺哪几个字段,你填);决策卡问处置(往哪走,你选)。
|
||||
* 两条路都合法、选哪条是业务判断的时候,该发的是这一件。
|
||||
*/
|
||||
decide(input: {
|
||||
taskId: TaskId;
|
||||
ownerId: StaffId;
|
||||
humanCallCap: number;
|
||||
ask: string;
|
||||
options: ReadonlyArray<{
|
||||
value: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
consequence?: string;
|
||||
recommended?: boolean;
|
||||
}>;
|
||||
basis?: ReadonlyArray<{ k: string; v: string }>;
|
||||
allowCustom?: boolean;
|
||||
}): Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export type ListenerRegistrar = {
|
||||
@@ -102,12 +218,18 @@ export type ListenerRegistrar = {
|
||||
* 零 LLM 即可成屏;模型只决定「下一步做什么」,不负责画面。
|
||||
*/
|
||||
export type ViewGateway = {
|
||||
/**
|
||||
* 推一帧画面。**回报推没推上** —— 从前是 `Promise<void>` 且模版查不到就静默 return:
|
||||
* 引擎每拍推一个硬编码的 `case-overview`,而 A1 声明的四张模版一个都不叫这个名字,
|
||||
* 于是**每一帧都被悄悄丢掉**,工作区从头到尾空白,日志里也没有一个字。
|
||||
* 静默失败比报错坏得多:它让「没画面」看起来像「活动还没产出」。
|
||||
*/
|
||||
push(input: {
|
||||
taskId: TaskId;
|
||||
activityId: string;
|
||||
template: string;
|
||||
scope: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
}): Promise<{ pushed: boolean; templates: string[] }>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -124,16 +246,37 @@ export type SkillGateway = {
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation';
|
||||
kind: 'engine-transition' | 'evaluation' | 'thought' | 'action' | 'result' | 'observation' | 'narration';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
/** 装载:按定位选摘活动记忆入 Agent 上下文(渐进披露) */
|
||||
loadActivityMemory(activityId: string, limit: number): Promise<Array<{ layer: string; statement: string }>>;
|
||||
/** 本任务自己的最近轨迹(窗口大小由活动声明的 memoryWindow 定)—— 喂回叙事面,Agent 每拍不失忆 */
|
||||
window(taskId: TaskId): Promise<Array<{ kind: string; title: string }>>;
|
||||
/**
|
||||
* 本任务自己的最近轨迹 —— 喂回叙事面,Agent 每拍不失忆。
|
||||
*
|
||||
* 不给 `size` 就用活动声明的 `memoryWindow`(叙事面按它给;窗口越大越贵)。
|
||||
* **熔断检测要另开一个更大的窗**:它数的是「同一堵墙撞了几次」,
|
||||
* 而一次提交被拒会连带写下交代、评估、推画面好几条 —— 20 条的叙事窗里
|
||||
* 至多装得下三四次撞墙,于是撞十次也数不到 5(实测:同一条判据拒回 10 次没熔断,
|
||||
* 另一单拒回 33 次,40 万 token 烧到 exhausted 才停)。
|
||||
*/
|
||||
window(
|
||||
taskId: TaskId,
|
||||
size?: number,
|
||||
): Promise<Array<{ kind: string; title: string; detail?: Record<string, unknown> | undefined }>>;
|
||||
};
|
||||
|
||||
export type TaskContext = {
|
||||
of(taskId: TaskId): Promise<{ activityId: string; ownerId: StaffId; humanCallCap: number }>;
|
||||
/**
|
||||
* 这一单的**机检判据名**(归一化后的 key 与 label 都在里头)。
|
||||
*
|
||||
* 给 `judge` 行动当门用:L1/L2 是拿 S-FEEL 对台账求值的,Agent 裁不动它 ——
|
||||
* 判据投影压根不读 Agent 对机检项的裁定。可从前引擎照收不误,还往轨迹里
|
||||
* 写一句「语义性前提评估:XXX → 满足」:模型于是以为这条已经过了,
|
||||
* 转头就 submit,被门以同一条判据拒回;再裁、再拒,连拒五拍熔断(实测 A2 死于此)。
|
||||
* 轨迹里那句「满足」是**假话** —— 界面不撒谎,对 Agent 也一样。
|
||||
*/
|
||||
machineCriteria(taskId: TaskId): Promise<ReadonlySet<string>>;
|
||||
};
|
||||
|
||||
@@ -86,15 +86,20 @@ describe('structuredLog —— 零 LLM,可重算', () => {
|
||||
const entries = [
|
||||
entry('thought', { seq: 1 }),
|
||||
entry('action', { seq: 2 }),
|
||||
entry('card-sent', { seq: 3 }),
|
||||
entry('card-reply', { seq: 4 }),
|
||||
entry('release-verdict', { seq: 5 }),
|
||||
// 机器产出侧留一条**该进**的(交代进展)—— 动作那一类整类退出后,
|
||||
// 没有它这一侧就空了,「归侧分栏」那条用例也就断言不到东西
|
||||
entry('narration', { seq: 3 }),
|
||||
entry('card-sent', { seq: 4 }),
|
||||
entry('card-reply', { seq: 5 }),
|
||||
entry('release-verdict', { seq: 6 }),
|
||||
];
|
||||
|
||||
it('只取骨架条目,思考不入日志', () => {
|
||||
it('只取骨架条目:思考与动作都不入日志', () => {
|
||||
const lines = structuredLog(entries);
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines.some((l) => l.title.startsWith('思考'))).toBe(false);
|
||||
// 动作(调工具 / 写草稿 / 技能结晶)是机制,不是产出 —— 整类不进
|
||||
expect(lines.some((l) => l.title.startsWith('action'))).toBe(false);
|
||||
});
|
||||
|
||||
it('两次生成完全一致 —— 口径与源恒一致', () => {
|
||||
@@ -201,11 +206,73 @@ describe('归侧:治理干预', () => {
|
||||
entry('control', { seq: 1, title: '控制事件:halt' }),
|
||||
entry('engine-transition', { seq: 2, title: '步 1 完成' }),
|
||||
]);
|
||||
expect(lines.map((l) => l.title)).toEqual(['治理:控制事件:halt', '迁移:步 1 完成']);
|
||||
/*
|
||||
* 前缀**全部退休**(2026-07-31 第二轮):「治理:」「迁移:」这些是拿前缀
|
||||
* 兼职「这是什么动作」。动作现在有自己一格(四字章),内容只留内容。
|
||||
* 两者仍分得出,而且分得更清楚 ——
|
||||
* 动作:治理干预 vs 状态推进;主语:人(按下的那位)vs 系统。
|
||||
*/
|
||||
expect(lines.map((l) => l.title)).toEqual(['控制事件:halt', '步 1 完成']);
|
||||
expect(lines.map((l) => l.action)).toEqual(['治理干预', '状态推进']);
|
||||
expect(lines.map((l) => l.actor)).toEqual(['', '系统']); // '' = 人,名字由上层按 actorId 换
|
||||
expect(lines.map((l) => l.sideLabel)).toEqual(['人裁决', '机器产出']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 日志的呈现口径(2026-07-31 用户定):**类型是四字短语,内容有主语**。
|
||||
*
|
||||
* 「四字」不是排版洁癖:它逼着每一格只说一件事。从前这一格摆归侧
|
||||
* (机器产出 / 系统等待),那答的是「算谁的工时」——同一个「系统等待」下面
|
||||
* 既有派工单也有发录入卡,人扫一眼说不出这一步到底干了什么。
|
||||
*/
|
||||
describe('日志:四字动作 + 主语', () => {
|
||||
it('动作与对话区同词,人点确认那一下叫「员工确认」', () => {
|
||||
const lines = structuredLog([
|
||||
entry('trigger', { seq: 1, title: '一封邮件触发了这条流程' }),
|
||||
entry('narration', { seq: 2, title: '这封邮件来自青岛海洋电子', detail: { tag: 'conclusion' } }),
|
||||
entry('card-sent', { seq: 3, title: '这是位新客户,请核对建档信息', detail: { k: 'entry' } }),
|
||||
entry('card-reply', { seq: 4, title: '写入 customer.profile', detail: { k: 'entry', by: 's-1' } }),
|
||||
entry('card-sent', { seq: 5, title: '补齐三条料的品牌', detail: { k: 'elicitation' } }),
|
||||
entry('card-sent', { seq: 6, title: '请去询三家', detail: { mode: 'execution' } }),
|
||||
entry('human-input', { seq: 7, title: '看过了邮件', detail: { act: 'read-comm', by: 's-1' } }),
|
||||
]);
|
||||
|
||||
expect(lines.map((l) => l.action)).toEqual([
|
||||
'触发事件',
|
||||
'阶段结论',
|
||||
'数据录入',
|
||||
'员工确认', // ← 用户点名的那一个
|
||||
'请你补全',
|
||||
'派出工单',
|
||||
'员工阅件',
|
||||
]);
|
||||
// 每一条都有四个字 —— 少一个字多一个字,那一列就参差不齐
|
||||
for (const line of lines) expect(line.action).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('主语三种:任务助手 / 系统 / 具体某人(域里只给 id,名字上层换)', () => {
|
||||
const lines = structuredLog([
|
||||
entry('trigger', { seq: 1 }),
|
||||
entry('narration', { seq: 2 }),
|
||||
entry('card-reply', { seq: 3, detail: { k: 'entry', by: 's-7' } }),
|
||||
]);
|
||||
expect(lines.map((l) => l.actor)).toEqual(['系统', '任务助手', '']);
|
||||
expect(lines.map((l) => l.actorId)).toEqual([undefined, undefined, 's-7']);
|
||||
});
|
||||
|
||||
it('内容不带主语、不带前缀 —— 主语由上层拼,前缀早该退休', () => {
|
||||
const lines = structuredLog([
|
||||
entry('card-sent', { seq: 1, title: '这是位新客户', detail: { k: 'entry' } }),
|
||||
entry('ledger-write', { seq: 2, title: '放行写合并 3 行' }),
|
||||
entry('terminal', { seq: 3, title: 'released' }),
|
||||
]);
|
||||
// 系统名换人话:「放行写合并」是引擎自己的说法,「released」更是机器状态
|
||||
expect(lines.map((l) => l.title)).toEqual(['这是位新客户', '成果落账,共 3 行', '这一单已放行完成']);
|
||||
expect(lines.map((l) => l.action)).toEqual(['数据录入', '写入台账', '任务收尾']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('确定性蒸馏 —— 零 LLM 也留得下事实', () => {
|
||||
it('终态、人兜底、驳回各归纳一条,且都带证据链', () => {
|
||||
const drafts = deterministicDistill([
|
||||
@@ -229,3 +296,247 @@ describe('确定性蒸馏 —— 零 LLM 也留得下事实', () => {
|
||||
expect(deterministicDistill([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 工作日志是**给人看的** —— 引擎自己的机械动作不该占版面(2026-07-31 用户定)。
|
||||
*
|
||||
* 从前每条留痕都往日志里摆:「装配技能 intake.read-rfq@2.0.0」「装载案例模型(6 个计划项)」
|
||||
* 每单一模一样,说不出这一单的任何事;工具调用那一条更是废话 ——
|
||||
* 人要的是**产出结果**,而出参另有一条 result 记着。
|
||||
*/
|
||||
describe('工作日志只留人看得懂的', () => {
|
||||
it('装配技能 / 装载案例模型 —— 机械动作不进日志', () => {
|
||||
const lines = structuredLog([
|
||||
entry('thought', { seq: 1, title: '装配技能:intake.read-rfq@2.0.0' }),
|
||||
entry('engine-transition', { seq: 2, title: '装载案例模型:客户RFQ受理(6 个计划项)' }),
|
||||
entry('engine-transition', { seq: 3, title: '完成:定位客户信息' }),
|
||||
]);
|
||||
// 「完成:」这类前缀由动作那一格接管,内容只留内容
|
||||
expect(lines.map((l) => l.title)).toEqual(['定位客户信息']);
|
||||
expect(lines.map((l) => l.action)).toEqual(['完成一项']);
|
||||
});
|
||||
|
||||
/*
|
||||
* **工具那一整类都不进**(2026-08-02 用户定,翻了上一版口径)。
|
||||
*
|
||||
* 上一版留着「出参」那一条,说「人要的是结果不是过程」。可出参本身也是过程:
|
||||
* 「crm.history-price-read 返回」「行动被拒:…」—— 前者是一句机器话,
|
||||
* 后者是 AI 与引擎较劲的踪迹,人既看不懂也帮不上忙。
|
||||
* **成果在台账里、在成果报告里**;工作日志要的是「每一步办完了什么」。
|
||||
* 排障的人照旧看得到 —— 那些痕一条没少,只是不进这一屏。
|
||||
*/
|
||||
it('工具的调用与出参**都不进** —— 工作日志给人看的是每一步的成果', () => {
|
||||
const lines = structuredLog([
|
||||
entry('action', { seq: 1, title: 'crm.history-price-read', detail: { tool: 'crm.history-price-read' } }),
|
||||
entry('result', { seq: 2, title: 'crm.history-price-read 返回' }),
|
||||
entry('result', { seq: 3, title: '行动被拒:台账地址没声明过' }),
|
||||
entry('engine-transition', { seq: 4, title: '完成:查历史价' }),
|
||||
]);
|
||||
expect(lines.map((l) => l.title)).toEqual(['查历史价']);
|
||||
});
|
||||
|
||||
/*
|
||||
* **日志里不许出现系统内的英文名**(2026-08-02 用户定)。
|
||||
* 引擎留痕时顺手写了内部标识:`manualStart:S3_confirm`、`终态:released`、
|
||||
* `落地收敛,发射输出口:intake、to-alternative` —— 那是给排障看的机器话,
|
||||
* 摆在人的日志上等于没写。
|
||||
*/
|
||||
it('系统名换成人话:开始办 / 已放行完成 / 成果落账', () => {
|
||||
const lines = structuredLog([
|
||||
entry('engine-transition', { seq: 1, title: 'manualStart:T_release' }),
|
||||
entry('release-verdict', { seq: 2, title: '裁决:pass' }),
|
||||
entry('release-verdict', { seq: 3, title: '落地收敛,发射输出口:intake、to-alternative' }),
|
||||
entry('engine-transition', { seq: 4, title: '去向已算出:intake-route → to-alternative(…_type_tag_c > 0)' }),
|
||||
entry('engine-transition', { seq: 5, title: '落地收敛:合并 23 行,发射输出口 intake、to-alternative' }),
|
||||
entry('terminal', { seq: 6, title: '终态:released' }),
|
||||
]);
|
||||
/*
|
||||
* 一条英文都不许漏到人的日志上 —— 计划项号、口名、判据表达式都是给排障看的。
|
||||
* 那两条「落地收敛」不在这份名单里:**它们是同一件事的复述**,
|
||||
* 说得出实情的那一条(放行写合并,带「几行没进正式表」)已经把话说全了。
|
||||
*/
|
||||
expect(lines.map((l) => l.title)).toEqual([
|
||||
'开始办这一步',
|
||||
'验收通过',
|
||||
'去向已算出,随放行转入下游',
|
||||
'这一单已放行完成',
|
||||
]);
|
||||
expect(lines.every((l) => !/[A-Za-z_]{4,}/.test(l.title))).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
* 这一条**翻过来了**(2026-07-31 第二轮,用户定)。
|
||||
* 原来的口径是「写台账那一条照进 —— 它本身就是产出」;
|
||||
* 可产出是**台账里那几行**,人在工作成果与台账里看得见,
|
||||
* 「写入草稿:quote_intake.record 3 行(3 行有变化)」说的是引擎写了一笔,
|
||||
* 「几行有变化」更是引擎自己的账 —— 对人说不出任何事。
|
||||
*/
|
||||
it('写台账那一条也**不进** —— 产出是台账里那几行,不是「我写了一笔」', () => {
|
||||
const lines = structuredLog([
|
||||
entry('action', {
|
||||
seq: 1,
|
||||
title: '写草稿:quote_intake.record 3 行',
|
||||
detail: { ledger: 'quote_intake.record', rows: 3 },
|
||||
}),
|
||||
// 「行动被拒」记在 result —— 同样不进(2026-08-02):那是 AI 与引擎较劲的踪迹
|
||||
entry('result', { seq: 2, title: '行动被拒:台账地址没声明过' }),
|
||||
]);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
|
||||
/* 系统事件本来一条都不在日志里 —— 而那恰是人最想回看的「这单怎么起来的」 */
|
||||
it('系统事件进日志;引擎给自己看的内部观察不进', () => {
|
||||
const lines = structuredLog([
|
||||
entry('observation', { seq: 1, title: '一封邮件触发了这条流程,系统指定你负责' }),
|
||||
entry('observation', { seq: 2, title: 'S2 已在进行中 —— 无需再启动', detail: { internal: true } }),
|
||||
]);
|
||||
expect(lines.map((l) => l.title)).toEqual(['一封邮件触发了这条流程,系统指定你负责']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 触发事件自成一侧 —— 它既不是机器产出也不是员工动作。
|
||||
*
|
||||
* 从前起单那句记成 `observation`,归侧推成「机器产出」——
|
||||
* 而机器什么也没产出:一封信到了、系统把这一环指给了你,那是**外面发生的事**。
|
||||
* 混进机器那一侧还会把自主率算歪:分母里多了一条谁也没干的事。
|
||||
*/
|
||||
describe('触发事件', () => {
|
||||
it('归侧是 trigger,不是 machine', () => {
|
||||
expect(deriveSide('trigger')).toBe('trigger');
|
||||
expect(deriveSide('observation')).toBe('machine');
|
||||
});
|
||||
|
||||
it('进结构化日志,且不加前缀 —— 它本来就是一句人话', () => {
|
||||
const lines = structuredLog([
|
||||
entry('trigger', { seq: 1, title: '一封邮件触发了这条流程,系统指定你负责「A1 · 客户RFQ受理」。' }),
|
||||
]);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.title).toBe('一封邮件触发了这条流程,系统指定你负责「A1 · 客户RFQ受理」。');
|
||||
expect(lines[0]?.sideLabel).toBe('触发事件');
|
||||
});
|
||||
|
||||
/* 自主率的分子分母只认机器与员工 —— 触发既不该进分子,也不该进分母 */
|
||||
it('不进自主率的分子分母', () => {
|
||||
const split = splitBySide(
|
||||
structuredLog([
|
||||
entry('trigger', { seq: 1, title: '一封邮件触发了这条流程' }),
|
||||
entry('narration', { seq: 2, title: 'AI 干了一件事' }),
|
||||
]),
|
||||
);
|
||||
expect(split.machine).toHaveLength(1);
|
||||
expect(split.human).toHaveLength(0);
|
||||
expect(split.trigger).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **日志要看得出人和 AI 分别干了啥**(2026-08-02 用户定)。
|
||||
*
|
||||
* 引擎流转从前一律署「系统」,可这一类里混着两种事:
|
||||
* 「完成一项」「开始办」是**AI 报的**(它 finish / start,引擎照办);
|
||||
* 「起单」「自动收步」是引擎自己做主的。全署系统,人就看不出这一单里
|
||||
* AI 到底干了什么、自己干了什么 —— 而那正是他翻这一屏想知道的事。
|
||||
*/
|
||||
describe('日志分得清谁干的', () => {
|
||||
it('AI 报的完成 / 开始办记在「任务助手」名下', () => {
|
||||
const lines = structuredLog([
|
||||
entry('engine-transition', { seq: 1, title: '完成:定位客户信息' }),
|
||||
entry('engine-transition', { seq: 2, title: '开始办:受理需求交人审定', detail: { by: 'agent' } }),
|
||||
]);
|
||||
expect(lines.map((l) => l.actor)).toEqual(['任务助手', '任务助手']);
|
||||
});
|
||||
|
||||
it('引擎自己做主的仍署「系统」—— 起单不是谁办的,是流程走到了这儿', () => {
|
||||
const lines = structuredLog([
|
||||
entry('engine-transition', { seq: 1, title: '起单:A1 · 询价受理' }),
|
||||
entry('engine-transition', { seq: 2, title: '自动收步:查历史价' }),
|
||||
]);
|
||||
expect(lines.map((l) => l.actor)).toEqual(['系统', '系统']);
|
||||
});
|
||||
|
||||
it('**那一步的名字要留着** —— 只说「开始办这一步」等于没说办的是哪一步', () => {
|
||||
const lines = structuredLog([entry('engine-transition', { seq: 1, title: '开始办:受理需求交人审定' })]);
|
||||
expect(lines[0]?.title).toBe('开始办:受理需求交人审定');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **日志只有三色**(2026-08-02 用户定):负责人绿 · 智能体蓝 · 系统灰。
|
||||
*
|
||||
* 归侧 `side` 有五种,那是**度量**的分法(自主率要把「等外面」与「机器干的」分开);
|
||||
* 而人扫这一屏只问一件事:**这一步是我办的、AI 办的,还是流程自己走到的**。
|
||||
* 拿五种去上色,屏上就是五种深浅,而那五种对读的人不构成区别。
|
||||
*/
|
||||
describe('日志三档:谁干的', () => {
|
||||
it('负责人办的归 owner —— 回卡、就地修订、放行裁决', () => {
|
||||
const lines = structuredLog([
|
||||
entry('card-reply', { seq: 1, title: '补齐了两颗料的制造商', detail: { by: 's-1' } }),
|
||||
entry('human-input', { seq: 2, title: '就地修订成果:9 行', detail: { by: 's-1' } }),
|
||||
entry('release-verdict', { seq: 3, title: '裁决:pass', detail: { by: 's-1' } }),
|
||||
]);
|
||||
expect(lines.map((l) => l.who)).toEqual(['owner', 'owner', 'owner']);
|
||||
});
|
||||
|
||||
it('AI 办的归 agent —— 完成一项、开始办、交代进展、派卡', () => {
|
||||
const lines = structuredLog([
|
||||
entry('engine-transition', { seq: 1, title: '完成:查历史价' }),
|
||||
entry('narration', { seq: 2, title: '九条料都拆好了', detail: { tag: 'conclusion' } }),
|
||||
entry('card-sent', { seq: 3, title: '受理需求审定', detail: { mode: 'execution' } }),
|
||||
]);
|
||||
expect(lines.map((l) => l.who)).toEqual(['agent', 'agent', 'agent']);
|
||||
});
|
||||
|
||||
it('流程自己走到的归 system —— 起单与触发不是谁办的', () => {
|
||||
const lines = structuredLog([
|
||||
entry('engine-transition', { seq: 1, title: '起单:A1 · 询价受理' }),
|
||||
entry('trigger', { seq: 2, title: '一封邮件触发了这条流程' }),
|
||||
entry('terminal', { seq: 3, title: '终态:released' }),
|
||||
]);
|
||||
expect(lines.map((l) => l.who)).toEqual(['system', 'system', 'system']);
|
||||
});
|
||||
|
||||
it('**只有这三档** —— 多一档就是屏上多一种颜色,而那一种说不出新东西', () => {
|
||||
const lines = structuredLog([
|
||||
entry('trigger', { seq: 1, title: '起' }),
|
||||
entry('engine-transition', { seq: 2, title: '完成:一步' }),
|
||||
entry('human-input', { seq: 3, title: '改了一格', detail: { by: 's-1' } }),
|
||||
]);
|
||||
expect([...new Set(lines.map((l) => l.who))].sort()).toEqual(['agent', 'owner', 'system']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('放行那一刻的日志尾巴', () => {
|
||||
/** 留痕那一侧三条都在(排障分得清谁做的);日志这一侧只说一遍 */
|
||||
const tail = () =>
|
||||
structuredLog([
|
||||
entry('release-verdict', { seq: 1, title: '裁决:pass', detail: { by: 's-1' } }),
|
||||
entry('ledger-write', { seq: 2, title: '放行写合并 10 行(试跑:另有 1 行主数据只留在活动缓存,未落正式表)' }),
|
||||
entry('release-verdict', { seq: 3, title: '落地收敛,发射输出口:intake、to-sourcing' }),
|
||||
entry('engine-transition', { seq: 4, title: '落地收敛:合并 10 行,发射输出口 intake、to-sourcing' }),
|
||||
entry('terminal', { seq: 5, title: '终态:released' }),
|
||||
]);
|
||||
|
||||
it('**「落账了」只说一遍** —— 四行说同一件事,人读的是日志不是留痕', () => {
|
||||
const said = tail().map((l) => l.title);
|
||||
expect(said.filter((t) => t.includes('落账'))).toHaveLength(1);
|
||||
expect(said).toEqual(['验收通过', '成果落账,共 10 行(试跑:另有 1 行主数据只留在活动缓存,未落正式表)', '这一单已放行完成']);
|
||||
});
|
||||
|
||||
it('留的是**说得出实情的那一条** —— 几行没进正式表,只有它讲得出来', () => {
|
||||
expect(tail()[1]?.title).toContain('未落正式表');
|
||||
});
|
||||
|
||||
it('放行合并归系统,不归 AI —— 记在它名下会让人以为是它自作主张写了正式表', () => {
|
||||
expect(tail()[1]?.who).toBe('system');
|
||||
});
|
||||
|
||||
it('**人工修订归人** —— 同一类留痕,带了他的 id 就是他改的', () => {
|
||||
const [line] = structuredLog([
|
||||
entry('ledger-write', { seq: 1, title: '人工修订:目标价 → 0.31(落 quote_intake.record)', detail: { by: 's-9' } }),
|
||||
]);
|
||||
expect(line?.who).toBe('owner');
|
||||
expect(line?.actorId).toBe('s-9');
|
||||
expect(line?.title).toBe('你改的那一笔已落账:目标价 → 0.31(落 quote_intake.record)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,19 +15,62 @@ export type LogLine = {
|
||||
readonly at: string;
|
||||
readonly side: AttributionSide;
|
||||
readonly sideLabel: string;
|
||||
/**
|
||||
* **这一条是谁干的** —— 日志只认三档(2026-08-02 用户定):
|
||||
* `owner` 负责人(绿)· `agent` 智能体(蓝)· `system` 系统(灰)。
|
||||
*
|
||||
* 归侧 `side` 有五种(machine / human / human-verdict / world-wait / trigger),
|
||||
* 那是**度量**的分法(自主率要把「等外面」与「机器干的」分开);
|
||||
* 而人扫这一屏时只问一件事:**这一步是我办的、AI 办的,还是流程自己走到的**。
|
||||
* 拿五种去上色,屏上就是五种深浅,而那五种对读的人不构成区别。
|
||||
*/
|
||||
readonly who: 'owner' | 'agent' | 'system';
|
||||
/**
|
||||
* **类型章:四字短语**,与对话区同词同粒度(2026-07-31 用户定)。
|
||||
*
|
||||
* 从前这一格摆的是归侧(机器产出 / 系统等待 …)—— 那是**度量的口径**,
|
||||
* 答的是「这一步该算谁的工时」;人翻日志想知道的是「这一步是什么动作」。
|
||||
* 两者不是一回事:同一个「系统等待」下面既有派工单也有发录入卡。
|
||||
* 归侧仍在(`side` / `sideLabel`),度量与配色照旧靠它,只是不再占着这一格。
|
||||
*/
|
||||
readonly action: string;
|
||||
/** 主语:任务助手 / 系统;人做的那些留空,由上层照 `actorId` 换成名字 */
|
||||
readonly actor: string;
|
||||
/** 人做的那一条是谁做的 —— 域里不认识人名(纯函数零 IO),只交出 id */
|
||||
readonly actorId: string | undefined;
|
||||
/** 内容本身,**不含主语** —— 主语由上层拼在前头(名字要查库才知道) */
|
||||
readonly title: string;
|
||||
readonly summary: string | undefined;
|
||||
readonly eventRef: string | undefined;
|
||||
readonly position: string | undefined;
|
||||
};
|
||||
|
||||
/** 骨架:只有这些种类进结构化日志(OS 8.1 列举) */
|
||||
/**
|
||||
* 骨架:哪些种类进结构化日志(OS 8.1 列举)。
|
||||
*
|
||||
* **工作日志是给人看的**(2026-07-31 用户定)——「装配技能 xxx@2.0.0」
|
||||
* 「装载案例模型(6 个计划项)」这类是引擎自己的机械动作,人看了不知道该拿它怎么办;
|
||||
* 工具调用的过程同理:**人要的是产出结果,不是调用细节**。
|
||||
* 所以骨架之外还有一道 `machinery()` 过滤(见下),两道一起才干净。
|
||||
*
|
||||
* `observation` 是**新进来的**:系统事件(一封信触发了这条流、邮件确认、
|
||||
* 人把来件标了已读)本来一条都不在日志里 —— 而那恰恰是人最想回看的
|
||||
* 「这单怎么起来的、谁在什么时候看过」。
|
||||
*
|
||||
* `action` **整类退出**(2026-07-31 第二轮,用户定)。它只有三个来源,全是机制:
|
||||
* 调工具、写草稿、技能结晶。「写入草稿:quote_intake.record 3 行(3 行有变化)」
|
||||
* 对人说不出任何事 —— 写了什么在工作成果与台账里看得见,「几行有变化」是引擎的账。
|
||||
* 从前只滤掉带 `tool` 的那一半,留着写草稿的另一半,理由是「它本身就是产出」;
|
||||
* 可产出是**台账里那几行**,不是「我写了一笔」这个动作。
|
||||
* 「为什么不行」不在这一类里:行动被拒、提交被拒、换画面失败都记在 `result`,照进。
|
||||
*/
|
||||
const SKELETON: ReadonlySet<EntryKind> = new Set<EntryKind>([
|
||||
'trigger',
|
||||
'observation',
|
||||
'engine-transition',
|
||||
'action',
|
||||
'result',
|
||||
'card-sent',
|
||||
'card-reply',
|
||||
'narration',
|
||||
'ledger-write',
|
||||
'action-intent',
|
||||
'release-verdict',
|
||||
@@ -36,32 +79,268 @@ const SKELETON: ReadonlySet<EntryKind> = new Set<EntryKind>([
|
||||
'control',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 内容取原文 —— **不加前缀**。
|
||||
*
|
||||
* 从前每种各挂一个前缀(`发出:` `回传:` `写入:` `终态:` …),那是在用前缀
|
||||
* 兼职「这是什么动作」。现在动作有了自己的一格(`action`),前缀就成了重复:
|
||||
* 「[数据录入] 发出:这是位新客户…」——「发出」两个字白占地方。
|
||||
* 主语也不在这儿拼:名字要查库,域里查不了(纯函数零 IO)。
|
||||
*/
|
||||
const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
|
||||
observation: (e) => `观察:${e.title}`,
|
||||
thought: (e) => `思考:${e.title}`,
|
||||
action: (e) => `调用:${e.title}`,
|
||||
result: (e) => `返回:${e.title}`,
|
||||
'engine-transition': (e) => `迁移:${e.title}`,
|
||||
evaluation: (e) => `求值:${e.title}`,
|
||||
'card-sent': (e) => `发出:${e.title}`,
|
||||
'card-reply': (e) => `回传:${e.title}`,
|
||||
'ledger-write': (e) => `写入:${e.title}`,
|
||||
'action-intent': (e) => `动作意向:${e.title}`,
|
||||
'release-verdict': (e) => `放行裁决:${e.title}`,
|
||||
terminal: (e) => `终态:${e.title}`,
|
||||
'human-input': (e) => `留言:${e.title}`,
|
||||
control: (e) => `治理:${e.title}`,
|
||||
observation: (e) => e.title,
|
||||
thought: (e) => e.title,
|
||||
action: (e) => actionEntryOf(e).body,
|
||||
result: (e) => e.title,
|
||||
'engine-transition': (e) => plain(transitionOf(e.title).body),
|
||||
evaluation: (e) => e.title,
|
||||
'card-sent': (e) => e.title,
|
||||
'card-reply': (e) => e.title,
|
||||
'ledger-write': (e) => plain(e.title),
|
||||
'action-intent': (e) => e.title,
|
||||
'release-verdict': (e) => plain(e.title),
|
||||
terminal: (e) => plain(e.title),
|
||||
'human-input': (e) => e.title,
|
||||
narration: (e) => e.title,
|
||||
control: (e) => e.title,
|
||||
trigger: (e) => e.title,
|
||||
};
|
||||
|
||||
/**
|
||||
* 四字动作 —— **对话区说什么词,这里就是什么词**。
|
||||
*
|
||||
* 对齐的出处是 `chat-bubbles.tsx` 的三张表:AI 的类型章(`TAG_UI`)、
|
||||
* 系统气泡的章(`SYS_TAG`)、各卡卡头的类型名。同一件事在两屏用两个说法,
|
||||
* 人得在脑子里做一次翻译 —— 而这本来是同一件事。
|
||||
*/
|
||||
const CARD_ACTION: Readonly<Record<string, string>> = {
|
||||
elicitation: '请你补全',
|
||||
decision: '需要你定',
|
||||
entry: '数据录入',
|
||||
rating: '任务评价',
|
||||
execution: '派出工单',
|
||||
acceptance: '请你验收',
|
||||
};
|
||||
|
||||
/** 人回传:一律「员工 X X」句式 —— 点确认那一下就是**员工确认**(用户定) */
|
||||
const REPLY_ACTION: Readonly<Record<string, string>> = {
|
||||
entry: '员工确认',
|
||||
elicitation: '员工补全',
|
||||
decision: '员工决策',
|
||||
rating: '员工评价',
|
||||
execution: '员工办结',
|
||||
acceptance: '放行裁决',
|
||||
};
|
||||
|
||||
/** 叙述的三种签,与对话区 `TAG_UI` / `SAY_UI` 同词 */
|
||||
const NARRATION_ACTION: Readonly<Record<string, string>> = {
|
||||
conclusion: '阶段结论',
|
||||
guidance: '交代进展',
|
||||
criterion: '请你验收',
|
||||
};
|
||||
|
||||
/**
|
||||
* **日志里不许出现系统内的英文名**(2026-08-02 用户定)。
|
||||
*
|
||||
* 工作日志是给人看的。而引擎留痕时顺手把内部标识写了进去:
|
||||
* `manualStart:S3_confirm`、`终态:released`、`发射输出口:intake、to-alternative`——
|
||||
* 那是给排障的人看的机器话,摆在人的日志上等于没写。
|
||||
* 这里逐条换成人话;认不出的原样留着(宁可笼统,不可编)。
|
||||
*/
|
||||
const PLAIN: ReadonlyArray<{ readonly re: RegExp; readonly to: string }> = [
|
||||
/*
|
||||
* **计划项号对人是天书**:`manualStart:S3_confirm`、`开始办:T_release` ——
|
||||
* 那一格里唯一的信息是「引擎开始办某一步了」,而哪一步下一行的「完成一项」会说清。
|
||||
* 与其摆一个 id,不如只说这件事本身。
|
||||
*/
|
||||
// 老留痕里还有 `manualStart:S3_confirm` 这种(新的已经写名字了)—— 认不出名字就只说这件事
|
||||
{ re: /^manualStart[::]\s*[A-Za-z0-9_]+$/, to: '开始办这一步' },
|
||||
{ re: /^manualStart[::]\s*/, to: '开始办:' },
|
||||
// 去向那一条:口名与判据表达式都是给排障看的;人要知道的是「转到哪儿」
|
||||
{ re: /^去向已算出[::].*→\s*([a-z-]+).*$/, to: '去向已算出,随放行转入下游' },
|
||||
// 终态有两种写法:带「终态:」前缀的,与裸状态名 —— 两种都得认,漏一种就露一个英文
|
||||
{ re: /^(终态[::]\s*)?released$/, to: '这一单已放行完成' },
|
||||
{ re: /^(终态[::]\s*)?blocked$/, to: '这一单卡住了,没能落地' },
|
||||
{ re: /^(终态[::]\s*)?rejected$/, to: '这一单被退回了' },
|
||||
{ re: /^(终态[::]\s*)?exhausted$/, to: '这一单预算用尽,停下了' },
|
||||
{ re: /^裁决[::]\s*pass$/, to: '验收通过' },
|
||||
{ re: /^裁决[::]\s*redo$/, to: '验收退回重做' },
|
||||
{ re: /^放行写合并\s*/, to: '成果落账,共 ' },
|
||||
{ re: /^人工修订[::]\s*/, to: '你改的那一笔已落账:' },
|
||||
];
|
||||
|
||||
/** 把一行里的系统名换成人话 —— 认不出就原样留着 */
|
||||
function plain(title: string): string {
|
||||
for (const p of PLAIN) if (p.re.test(title)) return title.replace(p.re, p.to);
|
||||
return title;
|
||||
}
|
||||
|
||||
/**
|
||||
* 引擎流转的措辞由引擎自己拼(`起单:xxx` / `完成:xxx` …)。
|
||||
* 认出前缀是为了两件事:动作那一格说得具体,以及**把前缀从内容里摘掉** ——
|
||||
* 章上已经写着「任务起单」,内容再来一遍「起单:」就是同一句话说两遍。
|
||||
* 认不出的一律「状态推进」,内容原样留着:宁可笼统,不可编。
|
||||
*/
|
||||
const TRANSITION: ReadonlyArray<{ readonly prefix: string; readonly action: string }> = [
|
||||
{ prefix: '起单:', action: '任务起单' },
|
||||
{ prefix: '完成:', action: '完成一项' },
|
||||
{ prefix: '自动收步:', action: '自动收步' },
|
||||
{ prefix: '立监听:', action: '立起监听' },
|
||||
{ prefix: '驳回重入:', action: '驳回重入' },
|
||||
];
|
||||
|
||||
function transitionOf(title: string): { action: string; body: string } {
|
||||
for (const t of TRANSITION) {
|
||||
if (title.startsWith(t.prefix)) return { action: t.action, body: title.slice(t.prefix.length) };
|
||||
}
|
||||
return { action: '状态推进', body: title };
|
||||
}
|
||||
|
||||
/** action 条目有两种:写草稿与调工具 —— 靠 detail 上的 `ledger` 记号分,不靠措辞 */
|
||||
function actionEntryOf(e: MemoryEntry): { action: string; body: string } {
|
||||
if (e.detail?.['ledger'] === undefined) return { action: '调用工具', body: e.title };
|
||||
const p = '写草稿:';
|
||||
return { action: '写入草稿', body: e.title.startsWith(p) ? e.title.slice(p.length) : e.title };
|
||||
}
|
||||
|
||||
function actionOf(e: MemoryEntry): string {
|
||||
const d = e.detail ?? {};
|
||||
const cardKey = String(d['k'] ?? d['mode'] ?? '');
|
||||
switch (e.kind) {
|
||||
case 'trigger':
|
||||
return '触发事件';
|
||||
case 'observation':
|
||||
return '系统事件';
|
||||
case 'engine-transition':
|
||||
return transitionOf(e.title).action;
|
||||
case 'action':
|
||||
return actionEntryOf(e).action;
|
||||
case 'narration':
|
||||
return NARRATION_ACTION[String(d['tag'] ?? '')] ?? '阶段结论';
|
||||
case 'card-sent':
|
||||
return CARD_ACTION[cardKey] ?? '发出卡片';
|
||||
case 'card-reply':
|
||||
return REPLY_ACTION[cardKey] ?? '员工回传';
|
||||
// 人在界面上的动作:点确认 = 员工确认,看来件 = 员工阅件
|
||||
case 'human-input':
|
||||
return d['act'] === 'read-comm' ? '员工阅件' : '员工确认';
|
||||
case 'ledger-write':
|
||||
return '写入台账';
|
||||
case 'result':
|
||||
return '工具产出';
|
||||
case 'action-intent':
|
||||
return '动作意向';
|
||||
case 'release-verdict':
|
||||
return '放行裁决';
|
||||
case 'terminal':
|
||||
return '任务收尾';
|
||||
case 'control':
|
||||
return '治理干预';
|
||||
case 'thought':
|
||||
return '模型思考';
|
||||
case 'evaluation':
|
||||
return '判据求值';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主语 —— 日志每一行都要有主语,不然是一串没头脑的流水账。
|
||||
*
|
||||
* 三种:**任务助手**(AI 干的)、**系统**(基座干的:起单、事件到达、收尾)、
|
||||
* 以及**具体某个人**。人名要查库,域里查不了,所以这里只交出 `actorId`,
|
||||
* 由上层换成名字;换不到就退回「员工」,不编名字。
|
||||
*/
|
||||
function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined; who: 'owner' | 'agent' | 'system' } {
|
||||
const by = e.detail?.['by'];
|
||||
const id = typeof by === 'string' && by !== '' ? by : undefined;
|
||||
switch (e.kind) {
|
||||
case 'trigger':
|
||||
case 'observation':
|
||||
case 'terminal':
|
||||
return { actor: '系统', actorId: undefined, who: 'system' };
|
||||
/*
|
||||
* **AI 办完的事要记在 AI 名下**(2026-08-02 用户:日志「要能看出人和 AI 分别干了啥」)。
|
||||
*
|
||||
* 引擎流转从前一律署「系统」,可这一类里混着两种事:
|
||||
* · 「完成一项」「开始办」——**AI 报的**(它 finish / start,引擎照办);
|
||||
* · 「起单」「自动收步」「驳回重入」—— 引擎自己做主的。
|
||||
* 全署系统,人就看不出这一单里 AI 到底干了什么、自己干了什么 ——
|
||||
* 而那正是他翻这一屏想知道的事。留痕上带 `by: 'agent'` 的从这里认。
|
||||
*/
|
||||
case 'engine-transition':
|
||||
return e.detail?.['by'] === 'agent' || /^(完成|开始办)/.test(e.title)
|
||||
? { actor: '任务助手', actorId: undefined, who: 'agent' }
|
||||
: { actor: '系统', actorId: undefined, who: 'system' };
|
||||
case 'card-reply':
|
||||
case 'human-input':
|
||||
case 'release-verdict':
|
||||
case 'control':
|
||||
return { actor: '', actorId: id, who: 'owner' };
|
||||
/*
|
||||
* 台账写入有两种,主语不同:**放行合并**是基座在人签完字之后做的一次落账
|
||||
* (记在 AI 名下会让人以为是它自作主张写了正式表);**人工修订**是人自己改的
|
||||
* (留痕带上了他的 id)。认 id,不认措辞。
|
||||
*/
|
||||
case 'ledger-write':
|
||||
return id === undefined ? { actor: '系统', actorId: undefined, who: 'system' } : { actor: '', actorId: id, who: 'owner' };
|
||||
default:
|
||||
return { actor: '任务助手', actorId: undefined, who: 'agent' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 结构化日志:零 LLM,可重算,每条自带谁 · 何时 · 依据 · 留痕指针 */
|
||||
/**
|
||||
* **机械动作** —— 进得了骨架,但人看了没有用处的那些。
|
||||
*
|
||||
* 判据是「这一条能不能让人做出判断」:
|
||||
* · 装配技能 / 装载案例模型 —— 引擎每单必做,一模一样,说不出这一单的任何事;
|
||||
* · `internal` 标的观察(已在进行中 / 同拍余下作废)—— 引擎给自己看的踪迹;
|
||||
* · 人事件的到达 —— 一串卡号,而这件事上一行已经用人话说过了。
|
||||
*
|
||||
* (动作那一整类 `action` 不在这儿滤 —— 它压根不进骨架,见上面 SKELETON。)
|
||||
*
|
||||
* 不用「白名单标题」那一路:标题措辞随时会改,改一次过滤就失灵而没人发现。
|
||||
* 这里认的是 kind + detail 上的结构化标记,措辞怎么变都不影响。
|
||||
*/
|
||||
/**
|
||||
* **同一件事的复述** —— 人签完字那一刻,基座与引擎各留了一条痕,说的是同一件事。
|
||||
*
|
||||
* 实测一单的日志尾巴是这样的(用户 2026-08-02 提的「日志是给人看的」):
|
||||
* · 成果落账,共 10 行(试跑:另有 1 行只留在活动缓存) ← 放行写合并,**带得住实情**
|
||||
* · 成果已落账,交给下游 ← 发射输出口,同一件事
|
||||
* · 成果落账,共 10 行,已交下游 ← 引擎流转,还是同一件事
|
||||
* · 这一单已放行完成 ← 终态
|
||||
* 四行里有三行在说「落账了」。留痕那一侧三条都该在(排障要分得清是谁做的),
|
||||
* 但**日志这一侧只该说一遍**:留合并那一条(它是唯一说得出「几行没进正式表」的),
|
||||
* 另外两条的信息它全包含。
|
||||
*/
|
||||
const RESTATED: ReadonlyArray<RegExp> = [/^落地收敛[::]\s*合并/, /^落地收敛,发射输出口/];
|
||||
|
||||
function machinery(e: MemoryEntry): boolean {
|
||||
if (e.detail?.['internal'] === true) return true;
|
||||
if (RESTATED.some((re) => re.test(e.title))) return true;
|
||||
if (e.kind === 'thought') return true;
|
||||
if (e.kind === 'engine-transition' && e.title.startsWith('装载案例模型')) return true;
|
||||
/*
|
||||
* **人事件的到达**:对人它只是「事件到达(human):d455688a-…」一串卡号,
|
||||
* 而这件事上一行已经用人话说过了(「员工确认:写入 customer.profile」)。
|
||||
* 同一次点击记两行,后一行还是 UUID —— 对话区早就滤掉了它,日志该同一口径。
|
||||
* 数据/时间事件的到达留着:那些是外面来的,人确实要知道什么时候到的。
|
||||
*/
|
||||
if (e.kind === 'observation' && e.detail?.['kind'] === 'human') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] {
|
||||
return entries
|
||||
.filter((e) => SKELETON.has(e.kind))
|
||||
.filter((e) => SKELETON.has(e.kind) && !machinery(e))
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
at: e.at,
|
||||
side: e.side,
|
||||
sideLabel: ATTRIBUTION_SIDE_LABEL[e.side],
|
||||
action: actionOf(e),
|
||||
...actorOf(e),
|
||||
title: TEMPLATE[e.kind](e),
|
||||
summary: typeof e.detail?.['summary'] === 'string' ? e.detail['summary'] : undefined,
|
||||
eventRef: e.eventRef,
|
||||
@@ -76,6 +355,7 @@ export function splitBySide(lines: readonly LogLine[]): Record<AttributionSide,
|
||||
human: [],
|
||||
'human-verdict': [],
|
||||
'world-wait': [],
|
||||
trigger: [],
|
||||
};
|
||||
for (const line of lines) out[line.side].push(line);
|
||||
return out;
|
||||
@@ -84,7 +364,7 @@ export function splitBySide(lines: readonly LogLine[]): Record<AttributionSide,
|
||||
/* ── 度量八指标(OS 8.3):只读投影,零埋点,统计即查询 ─────────────── */
|
||||
|
||||
export type Metrics = {
|
||||
/** 自主率 = 机器产出 ÷(机器产出 + 人产出);裁决与等待不进分母 */
|
||||
/** 自主率 = 机器产出 ÷(机器产出 + 员工动作);裁决与等待不进分母 */
|
||||
readonly autonomy: number;
|
||||
/** 纠正率 = 被纠正批次 ÷ 提交批次 */
|
||||
readonly correction: number;
|
||||
|
||||
@@ -21,8 +21,25 @@ export const ENTRY_KINDS = [
|
||||
'release-verdict',
|
||||
'terminal',
|
||||
'human-input',
|
||||
/**
|
||||
* **AI 对人说的话** —— 活动主动交代进展的那一句(「三条已归一,缺 1 个交期」)。
|
||||
*
|
||||
* 与 `thought` 分开:thought 是给自己留的痕(装配了哪些技能、为什么这么判),
|
||||
* narration 是**给人看的**,它进对话区。混成一类的后果是要么把机器踪迹全倒进对话区,
|
||||
* 要么人在对话区里一个字也看不到 —— 后者实测发生过:A1 跑了十几拍,
|
||||
* 对话区从头到尾空白,人只看得见「多了一个任务」。
|
||||
*/
|
||||
'narration',
|
||||
/** 治理干预:控制事件 / 恢复 / 改派 —— 人对过程的裁断,不是引擎自转 */
|
||||
'control',
|
||||
/**
|
||||
* **触发事件**(2026-07-31 用户定)—— 这一单是被什么叫起来的。
|
||||
*
|
||||
* 从前它记成 `observation`,归侧推成「机器产出」—— 而机器什么也没产出:
|
||||
* 一封信到了、系统把这一环指给了你,这是**外面发生的事**,
|
||||
* 不是 AI 干的活,也不该进自主率的分子分母。它自成一类。
|
||||
*/
|
||||
'trigger',
|
||||
] as const;
|
||||
export type EntryKind = (typeof ENTRY_KINDS)[number];
|
||||
|
||||
@@ -66,6 +83,9 @@ export type MemoryEntry = {
|
||||
*/
|
||||
export function deriveSide(kind: EntryKind): AttributionSide {
|
||||
switch (kind) {
|
||||
// 触发事件自成一侧:既不是机器产出也不是人做的,它是这一单的起因
|
||||
case 'trigger':
|
||||
return 'trigger';
|
||||
case 'observation':
|
||||
case 'thought':
|
||||
case 'action':
|
||||
@@ -74,6 +94,7 @@ export function deriveSide(kind: EntryKind): AttributionSide {
|
||||
case 'evaluation':
|
||||
case 'ledger-write':
|
||||
case 'action-intent':
|
||||
case 'narration': // 交代进展是机器产出:它是 AI 干活的一部分,不是人的时间
|
||||
return 'machine';
|
||||
case 'card-reply':
|
||||
case 'human-input':
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 提交放行 → 落地 的**用例链**(OS 7.3):这条链断过,而域测测不出来。
|
||||
*
|
||||
* 断的样子:活路径绕过 `submitForRelease` 直接发验收卡 → 意向永停 `drafted` →
|
||||
* `land()` 只执行 `prechecked` → **写类动作登记完永远不执行**,而每一步单看都"正常"。
|
||||
* 所以这里测的不是某个函数,是三件事的**接缝**:
|
||||
* ① 提交把意向从 drafted 推到 prechecked;
|
||||
* ② 卡面拿到的是**已验证**的动作清单(不是空数组);
|
||||
* ③ 落地只执行 prechecked,且用的幂等键与登记时是同一个。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ActionIntent } from '@contracts';
|
||||
import { taskIdSchema, staffIdSchema } from '@contracts';
|
||||
import { createReleaseService } from '../internal/usecase/release-service.ts';
|
||||
import type { ReleaseServiceDeps } from '../internal/usecase/release-service.ts';
|
||||
import { idempotencyKey } from '../internal/domain/precheck.ts';
|
||||
|
||||
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||
const OWNER = staffIdSchema.parse('22222222-2222-4222-8222-222222222222');
|
||||
|
||||
type Harness = {
|
||||
deps: ReleaseServiceDeps;
|
||||
intents: ActionIntent[];
|
||||
executed: Array<{ tool: string; idempotencyKey: string }>;
|
||||
cards: Array<{ criteria: unknown; actions: ReadonlyArray<{ label: string }>; fields: unknown }>;
|
||||
emitted: string[];
|
||||
};
|
||||
|
||||
function harness(over: { conflicts?: Array<{ field: string; key: Record<string, string> }>; invocable?: boolean } = {}): Harness {
|
||||
const intents: ActionIntent[] = [];
|
||||
const executed: Harness['executed'] = [];
|
||||
const cards: Harness['cards'] = [];
|
||||
const emitted: string[] = [];
|
||||
let seq = 0;
|
||||
|
||||
const deps: ReleaseServiceDeps = {
|
||||
intents: {
|
||||
save: async (intent) => void intents.push(intent),
|
||||
list: async () => intents,
|
||||
setState: async (id, state) => {
|
||||
const at = intents.findIndex((i) => i.id === id);
|
||||
if (at >= 0) intents[at] = { ...intents[at]!, state };
|
||||
},
|
||||
},
|
||||
ledger: {
|
||||
checkConflicts: async () => over.conflicts ?? [],
|
||||
releaseWrite: async () => ({
|
||||
mergedRows: 2,
|
||||
emittedEvents: 2,
|
||||
simulatedRows: 0,
|
||||
merged: [{ domain: 'd', object: 'o', key: { pn: 'X1' }, values: { type_tag: 'A' } }],
|
||||
}),
|
||||
},
|
||||
tools: {
|
||||
isInvocable: async () => ({ ok: over.invocable ?? true, ...(over.invocable === false ? { reason: '已停用' } : {}) }),
|
||||
validateInput: async () => ({ ok: true, errors: [] }),
|
||||
validateTarget: async () => undefined,
|
||||
execute: async (input) => {
|
||||
executed.push({ tool: input.tool, idempotencyKey: input.idempotencyKey });
|
||||
return { id: input.idempotencyKey, ok: true, retryable: false };
|
||||
},
|
||||
},
|
||||
cards: {
|
||||
issueAcceptance: async (input) => {
|
||||
cards.push({ criteria: input.criteria, actions: input.actions, fields: input.fields });
|
||||
return { id: 'wo-1' };
|
||||
},
|
||||
issueLandingFailure: async () => ({ id: 'wo-fail' }),
|
||||
},
|
||||
tasks: {
|
||||
activityIdOf: async () => 'quote-intake',
|
||||
outputPortsOf: async () => ({ mandatory: ['intake'], selective: [] }),
|
||||
},
|
||||
ports: { emit: async (input) => void emitted.push(input.port) },
|
||||
memory: { append: async () => undefined },
|
||||
clock: { now: () => new Date('2026-07-30T00:00:00.000Z') },
|
||||
ids: { next: () => `id-${++seq}` },
|
||||
};
|
||||
return { deps, intents, executed, cards, emitted };
|
||||
}
|
||||
|
||||
const writeIntent = (id: string): Omit<ActionIntent, 'taskId' | 'state'> => ({
|
||||
id,
|
||||
tool: 'mail.send',
|
||||
input: { to: 'a@b.c' },
|
||||
idempotencyKey: idempotencyKey(TASK, id),
|
||||
label: `发报价邮件 ${id}`,
|
||||
detail: '对外发出正式报价单',
|
||||
});
|
||||
|
||||
const submit = (h: Harness) =>
|
||||
createReleaseService(h.deps).submitForRelease({
|
||||
taskId: TASK,
|
||||
ownerId: OWNER,
|
||||
humanCallCap: 4,
|
||||
deliverableId: 'intake-record',
|
||||
title: '收口验收',
|
||||
basis: [{ k: '提交理由', v: '必做项已齐' }],
|
||||
deliverables: [{ key: 'intake-record', label: '统一受理记录', present: true }],
|
||||
criteria: [{ key: 'typed', label: '每条都已打标', level: 'L2', pass: true }],
|
||||
});
|
||||
|
||||
describe('提交放行:预检 → 意向 prechecked → 已验证动作上卡', () => {
|
||||
it('登记的写类意向被推到 prechecked —— 不推,放行时就一个都不会执行', async () => {
|
||||
const h = harness();
|
||||
const release = createReleaseService(h.deps);
|
||||
await release.registerIntent(TASK, writeIntent('i-1'));
|
||||
expect(h.intents[0]?.state).toBe('drafted');
|
||||
|
||||
await submit(h);
|
||||
expect(h.intents[0]?.state).toBe('prechecked');
|
||||
});
|
||||
|
||||
it('卡面的动作清单 = 已验证的那份,不是空数组', async () => {
|
||||
const h = harness();
|
||||
const release = createReleaseService(h.deps);
|
||||
await release.registerIntent(TASK, writeIntent('i-1'));
|
||||
await submit(h);
|
||||
|
||||
expect(h.cards).toHaveLength(1);
|
||||
expect(h.cards[0]?.actions.map((a) => a.label)).toEqual(['发报价邮件 i-1']);
|
||||
// 判据是调用方按契约逐条标注的,不是硬编码单条 L5
|
||||
expect(h.cards[0]?.criteria).toEqual([{ label: '每条都已打标', level: 'L2', pass: true }]);
|
||||
});
|
||||
|
||||
/*
|
||||
* 「有没有产出」的证据必须是**本单自己写下的行**。装配层数的是 `taskDrafts`,
|
||||
* 不是任务视图 —— 视图 = 正式版 + 本单草稿,拿它去数,别单放行过的记录就成了自己的交付。
|
||||
* 实测踩过:一单什么都没写就提交放行,三件必交付物全判「有」,卡照发人照签;
|
||||
* 这道门于是只在第一单有效。这里守的是 release 侧的另一半:**说缺就必须拒**。
|
||||
*/
|
||||
it('必交付物缺一即拒,且**不发卡** —— 打回环内修正', async () => {
|
||||
const h = harness();
|
||||
const release = createReleaseService(h.deps);
|
||||
await expect(
|
||||
release.submitForRelease({
|
||||
taskId: TASK,
|
||||
ownerId: OWNER,
|
||||
humanCallCap: 4,
|
||||
deliverableId: 'intake-record',
|
||||
title: '收口验收',
|
||||
basis: [{ k: '理由', v: 'x' }],
|
||||
deliverables: [{ key: 'type-table', label: '料号分类表', present: false }],
|
||||
criteria: [],
|
||||
}),
|
||||
).rejects.toThrow(/料号分类表/);
|
||||
expect(h.cards).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('预检不过即拒,且不发卡、意向留在 drafted', async () => {
|
||||
const h = harness({ invocable: false });
|
||||
const release = createReleaseService(h.deps);
|
||||
await release.registerIntent(TASK, writeIntent('i-1'));
|
||||
await expect(submit(h)).rejects.toThrow(/已停用/);
|
||||
expect(h.cards).toHaveLength(0);
|
||||
expect(h.intents[0]?.state).toBe('drafted');
|
||||
});
|
||||
|
||||
it('他单改了同一字段 → 数据冲突,打回重拟', async () => {
|
||||
const h = harness({ conflicts: [{ field: 'target_price', key: { pn: 'X1' } }] });
|
||||
await expect(submit(h)).rejects.toThrow(/target_price/);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 可修订字段要**真的上卡**。
|
||||
*
|
||||
* `SubmitForReleaseInput.fields` 声明了却从来没被接线:调用方一路传下来的字段
|
||||
* 在 `issueAcceptance` 那一步被丢在地上,卡面 `fields` 恒空 —— 验收报告只能看不能改,
|
||||
* 人工修订形同虚设(第 12 章③)。**声明了不接线比不声明更坏:调用方以为传到了。**
|
||||
*/
|
||||
describe('可修订字段上卡 —— 验收报告「AI 已填好、可编辑」', () => {
|
||||
it('传了就上卡,带 AI 拟值与落回地址', async () => {
|
||||
const h = harness();
|
||||
await createReleaseService(h.deps).submitForRelease({
|
||||
taskId: TASK,
|
||||
ownerId: OWNER,
|
||||
humanCallCap: 4,
|
||||
deliverableId: 'intake-record',
|
||||
title: '收口验收',
|
||||
basis: [{ k: '提交理由', v: '齐了' }],
|
||||
deliverables: [{ key: 'intake-record', label: '统一受理记录', present: true }],
|
||||
criteria: [],
|
||||
fields: [{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' }],
|
||||
});
|
||||
expect(h.cards[0]?.fields).toEqual([
|
||||
{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('没传就不上 —— 只看不改也是一种合法形态,不硬塞空表单', async () => {
|
||||
const h = harness();
|
||||
await submit(h);
|
||||
expect(h.cards[0]?.fields).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('落地:只执行 prechecked,幂等键与登记时同一个', () => {
|
||||
it('走完提交再落地 —— 动作真的执行了', async () => {
|
||||
const h = harness();
|
||||
const release = createReleaseService(h.deps);
|
||||
await release.registerIntent(TASK, writeIntent('i-1'));
|
||||
await submit(h);
|
||||
|
||||
const outcome = await release.land({ taskId: TASK, ownerId: OWNER, humanCallCap: 4, selectedPorts: {} });
|
||||
expect(h.executed).toHaveLength(1);
|
||||
// 两侧同一把钥匙:登记时算的键,落地时用的键
|
||||
expect(h.executed[0]?.idempotencyKey).toBe(idempotencyKey(TASK, 'i-1'));
|
||||
expect(h.intents[0]?.state).toBe('landed');
|
||||
expect(outcome.emittedPorts).toEqual(['intake']);
|
||||
});
|
||||
|
||||
it('没过提交(意向仍 drafted)→ 落地一个都不执行 —— 这正是断链时的症状', async () => {
|
||||
const h = harness();
|
||||
const release = createReleaseService(h.deps);
|
||||
await release.registerIntent(TASK, writeIntent('i-1'));
|
||||
|
||||
await release.land({ taskId: TASK, ownerId: OWNER, humanCallCap: 4, selectedPorts: {} });
|
||||
expect(h.executed).toHaveLength(0);
|
||||
expect(h.intents[0]?.state).toBe('drafted');
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,11 @@ export type SubmitForReleaseInput = {
|
||||
readonly criteria: readonly CriterionCheck[];
|
||||
/** 报告可修订:AI 拟值已填的表单字段 */
|
||||
readonly fields?: ReadonlyArray<Record<string, unknown>>;
|
||||
/**
|
||||
* 验收页的正文块 —— 与执行工单同一套件,可含**绑台账的可编辑表**。
|
||||
* 验收是最重的一道门:人得能在成果本身上改,不只是改几个整单级字段。
|
||||
*/
|
||||
readonly blocks?: ReadonlyArray<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type SubmitOutcome = {
|
||||
|
||||
@@ -50,7 +50,16 @@ export function summarize(
|
||||
/* ── 必交付物与判据 ─────────────────────────────────────────────────── */
|
||||
|
||||
export type DeliverableCheck = { readonly key: string; readonly label: string; readonly present: boolean };
|
||||
export type CriterionCheck = { readonly key: string; readonly label: string; readonly level: AcceptanceLevel; readonly pass: boolean };
|
||||
export type CriterionCheck = {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly level: AcceptanceLevel;
|
||||
readonly pass: boolean;
|
||||
/** 裁量项(L3/L4)裁过没有 —— 机检项与 L5 不带这一维 */
|
||||
readonly settled?: boolean | undefined;
|
||||
/** 裁断的理由:裁成「不满足」时,这句话才是签字的人要看的东西 */
|
||||
readonly rationale?: string | undefined;
|
||||
};
|
||||
|
||||
/** 必交付物**缺一不得提交放行**(AM 01) */
|
||||
export function missingDeliverables(checks: readonly DeliverableCheck[]): DeliverableCheck[] {
|
||||
|
||||
@@ -132,9 +132,31 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
|
||||
title: input.title,
|
||||
basis: input.basis,
|
||||
deliverable: input.deliverableId,
|
||||
criteria: input.criteria.map((c) => ({ label: c.label, level: c.level, pass: c.pass })),
|
||||
criteria: input.criteria.map((c) => ({
|
||||
label: c.label,
|
||||
level: c.level,
|
||||
pass: c.pass,
|
||||
// 裁量项那一维原样带上卡:裁过没有、凭什么 —— 丢一样,人就只能凭感觉签
|
||||
...(c.settled === undefined ? {} : { settled: c.settled }),
|
||||
...(c.rationale === undefined ? {} : { rationale: c.rationale }),
|
||||
})),
|
||||
// 卡面呈现的即**已验证**的动作清单
|
||||
actions: report.verified.map((v) => ({ label: v.label, effect: v.effect, detail: v.detail })),
|
||||
/*
|
||||
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们。
|
||||
*
|
||||
* 这一行从前**没有**:`SubmitForReleaseInput.fields` 声明了却从来没被用过,
|
||||
* 调用方一路传下来的字段在这里被丢在地上,于是卡面 `fields` 恒空 ——
|
||||
* 报告只能看不能改,人工修订形同虚设(第 12 章③)。
|
||||
* 声明了不接线比不声明更坏:调用方以为传到了。
|
||||
*/
|
||||
...(input.fields === undefined || input.fields.length === 0 ? {} : { fields: input.fields }),
|
||||
/*
|
||||
* 验收页的正文块 —— 绑台账的可编辑表就走这里。
|
||||
* 人在这一屏上改的是**成果本身**(那份清单),不是旁边几个整单级字段;
|
||||
* 改完由基座写回草稿层,放行时随之合并 —— AI 不经手。
|
||||
*/
|
||||
...(input.blocks === undefined || input.blocks.length === 0 ? {} : { blocks: input.blocks }),
|
||||
});
|
||||
|
||||
return { report, workOrderId: card.id };
|
||||
@@ -146,10 +168,31 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
|
||||
|
||||
// 放行写:草稿层合并入正式版(原子,过冲突检查),此刻才产生数据事件
|
||||
const merged = await deps.ledger.releaseWrite(input.taskId);
|
||||
/*
|
||||
* **落地结果要留成结构化的**(2026-08-02 用户:「完成验收后反而要把落地结果展示出来」)。
|
||||
*
|
||||
* 从前只有标题那一句话,人要知道「这一签下去到底发生了什么」,得去读日志、
|
||||
* 再自己把「合并 9 行」拆成哪几张表各几行。那是让人替系统做归并。
|
||||
* 逐表计数落进 `detail`,验收卡办结之后照它摆一屏 —— 摆的是事实,不是转述。
|
||||
*/
|
||||
const byTable = new Map<string, number>();
|
||||
for (const row of merged.merged) {
|
||||
const at = `${row.domain}.${row.object}`;
|
||||
byTable.set(at, (byTable.get(at) ?? 0) + 1);
|
||||
}
|
||||
await deps.memory.append({
|
||||
taskId: input.taskId,
|
||||
kind: 'ledger-write',
|
||||
title: `放行写合并 ${merged.mergedRows} 行`,
|
||||
// 试跑挡下的那几行要写在痕上 —— 只报「合并 N 行」而其中几行没进表,那句话是假的
|
||||
title:
|
||||
merged.simulatedRows === 0
|
||||
? `放行写合并 ${merged.mergedRows} 行`
|
||||
: `放行写合并 ${merged.mergedRows} 行(试跑:另有 ${merged.simulatedRows} 行主数据只留在活动缓存,未落正式表)`,
|
||||
detail: {
|
||||
rows: merged.mergedRows,
|
||||
simulated: merged.simulatedRows,
|
||||
tables: [...byTable].map(([address, rows]) => ({ address, rows })),
|
||||
},
|
||||
});
|
||||
|
||||
// 外部动作意向执行,附幂等键;已成动作留存不回滚
|
||||
@@ -238,6 +281,13 @@ export function createReleaseService(deps: ReleaseServiceDeps): ReleaseService {
|
||||
taskId: input.taskId,
|
||||
kind: 'release-verdict',
|
||||
title: `落地收敛,发射输出口:${emitted.join('、') || '(无)'}`,
|
||||
/*
|
||||
* 发了哪几口、**带了哪些字段过去** —— 验收卡办结后照它摆
|
||||
* 「这一单转去哪儿了、带了什么」(2026-08-02 用户定)。
|
||||
* 载荷就是已落地的事实,所以这里记的是它的键;值不记 —— 值在成果件里,
|
||||
* 那一屏答的是「带了哪些东西」,不是把数据再抄一遍。
|
||||
*/
|
||||
detail: { ports: [...emitted], carried: Object.keys(landedFacts).slice(0, 40) },
|
||||
});
|
||||
|
||||
return { verdict, emittedPorts: emitted, ledgerRowsMerged: merged.mergedRows, merged: merged.merged };
|
||||
|
||||
@@ -19,6 +19,8 @@ export type LedgerGateway = {
|
||||
releaseWrite(taskId: TaskId): Promise<{
|
||||
mergedRows: number;
|
||||
emittedEvents: number;
|
||||
/** 试跑单被挡在正式表外的主数据行数(非试跑恒为 0)—— 放行痕要如实说 */
|
||||
simulatedRows: number;
|
||||
/** 合并进正式版的行 —— 输出口发射的载荷取自这里 */
|
||||
merged: ReadonlyArray<{
|
||||
domain: string;
|
||||
@@ -61,8 +63,32 @@ export type CardGateway = {
|
||||
title: string;
|
||||
basis: ReadonlyArray<{ k: string; v: string }>;
|
||||
deliverable: string;
|
||||
criteria: ReadonlyArray<{ label: string; level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5'; pass: boolean }>;
|
||||
/*
|
||||
* `settled` / `rationale` 是**裁量项**那一维:裁过没有、裁断的理由。
|
||||
* 「裁了但办不到」与「压根没裁」在卡上不能长成同一个红叉 —— 前者是把判断交给人,
|
||||
* 后者是活没干完;而理由那一句正是签字的人要看的东西。
|
||||
*/
|
||||
criteria: ReadonlyArray<{
|
||||
label: string;
|
||||
level: 'L1' | 'L2' | 'L3' | 'L4' | 'L5';
|
||||
pass: boolean;
|
||||
settled?: boolean | undefined;
|
||||
rationale?: string | undefined;
|
||||
}>;
|
||||
actions: ReadonlyArray<{ label: string; effect: 'read' | 'write'; detail: string }>;
|
||||
/**
|
||||
* 可修订字段 —— 验收报告「AI 已填好、可编辑」改的就是它们(带 `ai` 拟值与 `ledger` 落回地址)。
|
||||
* **端口不声明它,值就在装配层被丢掉**:上游一路传下来,而 TypeScript 对条件展开
|
||||
* 不做多余属性检查,于是编译不报、运行时静默丢 —— 卡面 `fields` 恒空(踩过)。
|
||||
*/
|
||||
fields?: ReadonlyArray<Record<string, unknown>>;
|
||||
/**
|
||||
* 验收页的正文块 —— 绑台账的可编辑成果表走这里。
|
||||
* **端口不声明它,值就在装配层被丢掉**(与 `fields` 同一个坑,又踩了一次:
|
||||
* 契约声明了、submit 传了、release 转了,到装配层那个显式对象里被抹平,
|
||||
* 编译不报、运行时静默丢 —— 卡面 blocks 恒空)。
|
||||
*/
|
||||
blocks?: ReadonlyArray<Record<string, unknown>>;
|
||||
}): Promise<{ id: string }>;
|
||||
issueLandingFailure(input: {
|
||||
taskId: TaskId;
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
port 注入:memory / event / message / interaction / ledger
|
||||
被依赖: process, org(停用前置检查的在途任务), report, app
|
||||
|
||||
近变(2026-07-30):`Task` / `StartTaskInput` 加 `origin?`(`testbed` = 设计台试跑)+
|
||||
`sys.task.origin` 列。**认得出试跑单才拔得掉** —— 工作流那侧靠
|
||||
`source->>'ref'='testbed'` 认,任务这侧从前没有对应记号:
|
||||
「试跑一个活动」起出来的单(不挂工作流)与登记直起单长得一模一样。
|
||||
|
||||
不变量(OS 04 / 4.3,AM 10 / 12.1):
|
||||
- **受理归一**(2026-07-30 宽进,替代旧 AM 12.1「缺项不得启动」):输入可以不定或为空 ——
|
||||
缺必填不拒单,任务落成 `awaiting-human`,缺的字段做成补全卡(`IntakeDesk` 端口)发给
|
||||
|
||||
@@ -76,6 +76,7 @@ function harness(): Harness {
|
||||
asked.push({ fields: input.fields.map((f) => ({ key: f.key, label: f.label })) });
|
||||
},
|
||||
},
|
||||
comms: { record: async () => undefined },
|
||||
clock: { now: () => new Date('2026-07-30T00:00:00.000Z') },
|
||||
ids: { next: () => `t-${(seq += 1)}` },
|
||||
};
|
||||
|
||||
@@ -92,6 +92,7 @@ function harness(land: () => Promise<never> | Promise<{ converged: boolean; emit
|
||||
notifier: { notify: async () => undefined },
|
||||
interaction: { setPhase: async () => undefined },
|
||||
intake: { askCompletion: async () => undefined },
|
||||
comms: { record: async () => undefined },
|
||||
clock: { now: () => new Date('2026-07-29T00:00:02.000Z') },
|
||||
ids: { next: () => 'x' },
|
||||
};
|
||||
|
||||
@@ -52,6 +52,8 @@ export type Task = {
|
||||
/** 起单来源:工作流节点;登记触发的直起单为 undefined */
|
||||
readonly workflowId?: string;
|
||||
readonly nodeId?: string;
|
||||
/** `testbed` = 设计台试跑 —— 认得出才拔得掉(仿真不该在别人工作台上过夜) */
|
||||
readonly origin?: string;
|
||||
readonly ownerId: StaffId;
|
||||
readonly title: string;
|
||||
readonly runtime: TaskRuntimeState;
|
||||
@@ -73,6 +75,7 @@ export type StartTaskInput = {
|
||||
readonly title: string;
|
||||
readonly workflowId?: string;
|
||||
readonly nodeId?: string;
|
||||
readonly origin?: string;
|
||||
/** 输入:data(按 schema 校验,缺项不得启动)+ message(委托消息,可为空) */
|
||||
readonly data: Record<string, unknown>;
|
||||
readonly message?: string;
|
||||
|
||||
@@ -11,6 +11,11 @@ export const taskTable = sys.table(
|
||||
activityId: text('activity_id').notNull(),
|
||||
workflowId: text('workflow_id'),
|
||||
nodeId: text('node_id'),
|
||||
/**
|
||||
* 起单来源:`testbed` = 设计台试跑,其余留空。
|
||||
* 认得出试跑单,才拔得掉 —— 仿真不是生产,它不该在别人工作台上过夜。
|
||||
*/
|
||||
origin: text('origin'),
|
||||
/** 恰有一名负责人 */
|
||||
ownerId: text('owner_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
@@ -34,6 +39,7 @@ export const taskTable = sys.table(
|
||||
(t) => [
|
||||
index('task_owner_idx').on(t.ownerId, t.runtime),
|
||||
index('task_workflow_idx').on(t.workflowId),
|
||||
index('task_origin_idx').on(t.origin),
|
||||
index('task_terminal_idx').on(t.terminal),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ function toTask(row: Row): Task {
|
||||
id: row.id as TaskId,
|
||||
activityId: row.activityId,
|
||||
...(row.workflowId === null ? {} : { workflowId: row.workflowId }),
|
||||
...(row.origin === null ? {} : { origin: row.origin }),
|
||||
...(row.nodeId === null ? {} : { nodeId: row.nodeId }),
|
||||
ownerId: row.ownerId as StaffId,
|
||||
title: row.title,
|
||||
@@ -43,6 +44,7 @@ export function createTaskStore(db: Db): TaskStore {
|
||||
id: task.id,
|
||||
activityId: task.activityId,
|
||||
workflowId: task.workflowId ?? null,
|
||||
origin: task.origin ?? null,
|
||||
nodeId: task.nodeId ?? null,
|
||||
ownerId: task.ownerId,
|
||||
title: task.title,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { StartTaskInput, Task, TaskService, TickOutcome } from '../../contr
|
||||
import { TaskAlreadyTerminalError, TaskNotFoundError } from '../../contract.ts';
|
||||
import type {
|
||||
ActivityLoader,
|
||||
CommRecorder,
|
||||
Clock,
|
||||
IdGenerator,
|
||||
IntakeDesk,
|
||||
@@ -45,6 +46,8 @@ export type TaskServiceDeps = {
|
||||
* 并武装 human 监听等答复 —— 任务先「等人」,补齐了才开跑。
|
||||
*/
|
||||
readonly intake: IntakeDesk;
|
||||
/** 沟通记录 —— 这一单是被一封信叫起来的话,起单那一刻把来件归档 */
|
||||
readonly comms: CommRecorder;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
@@ -101,6 +104,7 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
activityId: input.activityId,
|
||||
...(input.workflowId === undefined ? {} : { workflowId: input.workflowId }),
|
||||
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
|
||||
...(input.origin === undefined ? {} : { origin: input.origin }),
|
||||
ownerId: input.ownerId,
|
||||
title: input.title,
|
||||
runtime: missing.length > 0 ? 'awaiting-human' : 'running',
|
||||
@@ -118,6 +122,44 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
// message 计入初始观察、全程留痕;**不得扩大授权边界**
|
||||
detail: { data: input.data, message: input.message, envelope: loaded.envelope },
|
||||
});
|
||||
/*
|
||||
* **来件先归档,再开跑** —— 顺序有意义:人一点开这一单,沟通记录里就已经躺着
|
||||
* 那封信;等模型跑几拍之后再补,那几十秒里人看到的是「这单从哪来的?不知道」。
|
||||
* 声明了才归(`contract.input.inboundComm`);没声明的活动这一步整个不发生。
|
||||
*/
|
||||
const inbound = loaded.inboundComm;
|
||||
if (inbound !== undefined) {
|
||||
const pick = (field: string | undefined): unknown => (field === undefined ? undefined : input.data[field]);
|
||||
const attachments = pick(inbound.attachmentsField);
|
||||
await deps.comms.record({
|
||||
taskId: task.id,
|
||||
direction: 'in',
|
||||
channel: inbound.channel,
|
||||
party: String(pick(inbound.partyField) ?? ''),
|
||||
subject: String(pick(inbound.subjectField) ?? task.title),
|
||||
body: String(pick(inbound.bodyField) ?? ''),
|
||||
attachments: Array.isArray(attachments) ? attachments : [],
|
||||
note: '开案来件 —— 这一单就是被它叫起来的',
|
||||
});
|
||||
/*
|
||||
* **对话流的第一句话由系统说**(2026-07-31 用户定)。
|
||||
*
|
||||
* 从前人点开这一单,看到的第一句是 AI 的解读 —— 而他还不知道
|
||||
* 「这单从哪来的、为什么派给我」。那两件事不该由 AI 顺口交代:
|
||||
* 它们是**系统的事实**(一封信触发了这条流,系统把这一环指给了你),
|
||||
* AI 说出来反而像它在自我介绍。
|
||||
*
|
||||
* 带一个「查看邮件」的去处:原件就在沟通记录里,一步跳过去。
|
||||
* **点不点都不挡路** —— 这条消息之后 AI 照常开始解读,不等人按。
|
||||
*/
|
||||
await deps.memory.append({
|
||||
taskId: task.id,
|
||||
// **触发事件**:这一单是被这封信叫起来的 —— 不是机器产出,也不是谁的动作
|
||||
kind: 'trigger',
|
||||
title: `一封邮件触发了这条流程,系统指定你负责「${task.title}」。`,
|
||||
detail: { eventType: 'data', openComms: true },
|
||||
});
|
||||
}
|
||||
await deps.engine.load(task.id, loaded.businessModel);
|
||||
await deps.interaction.setPhase(task.id, 'active');
|
||||
if (missing.length > 0) {
|
||||
@@ -163,6 +205,23 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
return { task: finished, verdict, progressed: false };
|
||||
}
|
||||
|
||||
/*
|
||||
* **先把「在办事」写进投影,再开跑**(2026-07-31 修)。
|
||||
*
|
||||
* 一拍要跑好几秒 —— 模型在算。而这一列从前是**整拍跑完才写**(见下面第 224 行),
|
||||
* 于是被人唤醒的那一单,这几秒里投影仍停在 `awaiting-event`:
|
||||
* 人点完数据录入卡,对话区四秒一动不动(实测 4.3 秒),他以为没点上、再点一次。
|
||||
* 引擎明明在算,投影却说在等外部 —— 界面不撒谎,那这一列就得在开跑前写。
|
||||
*
|
||||
* `setPhase` 顺带广播(interaction 的 onPush → broadcaster),浏览器立刻重取。
|
||||
* 一拍跑完照旧按 outcome 写回真实状态;若这一拍抛了,单停在 running,
|
||||
* 推进器下一轮会再推它一次 —— 那本来就是该做的事(重试),不是卡死。
|
||||
*/
|
||||
if (task.runtime !== 'running') {
|
||||
await deps.store.update(taskId, { runtime: 'running' });
|
||||
await deps.interaction.setPhase(taskId, 'active');
|
||||
}
|
||||
|
||||
const startedAt = deps.clock.now().getTime();
|
||||
const outcome = await deps.engine.advance(taskId);
|
||||
// 步计量:loop 的一次决策循环 / flow 的一个声明步。
|
||||
@@ -246,11 +305,20 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
const task = await require(taskId);
|
||||
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
|
||||
|
||||
/*
|
||||
* 投递痕只记**这一条**(标题格式与对话流投影的过滤约定一致:human 的不进对话区)。
|
||||
* 从前 loop 的 onEvent 还会再记一条「事件到达(kind):ref」—— 同一次回传在日志里
|
||||
* 三行几乎一样(卡回传 + 两层各一条到达),人看着像系统在结巴。
|
||||
*/
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: `事件到达:${event.kind}/${event.ref}`,
|
||||
detail: event.payload,
|
||||
title: `事件到达(${event.kind}):${event.ref}`,
|
||||
/*
|
||||
* `kind` 要进 detail:对话区与工作日志都靠它认这是哪一类到达。
|
||||
* 从前只有 payload,两处只能去抠标题前缀 —— 标题是给人看的、随时会改。
|
||||
*/
|
||||
detail: { ...event.payload, kind: event.kind },
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -25,11 +25,36 @@ export type ActivityLoader = {
|
||||
envelope: Record<string, unknown>;
|
||||
/** 输入 data 的 JSON Schema —— 缺项不得启动 */
|
||||
inputSchema: Record<string, unknown>;
|
||||
/**
|
||||
* 这一单是被一封信叫起来的 —— 活动清单声明的**来件字段映射**。
|
||||
* 声明了,起单那一刻就把来件归进沟通记录(工作台那一屏)。
|
||||
*/
|
||||
inboundComm?: {
|
||||
channel: string;
|
||||
partyField: string;
|
||||
subjectField?: string | undefined;
|
||||
bodyField?: string | undefined;
|
||||
attachmentsField?: string | undefined;
|
||||
};
|
||||
businessModel: unknown;
|
||||
stallAfter: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** 沟通记录 —— 一单往来的原件存档(工作台「沟通记录」那一屏读它) */
|
||||
export type CommRecorder = {
|
||||
record(input: {
|
||||
taskId: TaskId;
|
||||
direction: 'in' | 'out';
|
||||
channel: string;
|
||||
party: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
attachments: readonly unknown[];
|
||||
note: string;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
export type SchemaValidator = {
|
||||
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
|
||||
};
|
||||
@@ -76,8 +101,11 @@ export type LandingGateway = {
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
/** control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧 */
|
||||
kind: 'engine-transition' | 'terminal' | 'observation' | 'control';
|
||||
/**
|
||||
* control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧。
|
||||
* trigger = 触发事件:这一单被什么叫起来的 —— 不是机器产出,也不是谁的动作。
|
||||
*/
|
||||
kind: 'engine-transition' | 'terminal' | 'observation' | 'control' | 'trigger';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
|
||||
@@ -10,11 +10,14 @@
|
||||
`EffectLevel` `RegisterToolInput` `RegistrationVerdict` `AuditFinding` `Preauthorization`
|
||||
- 错误:`ToolNotFoundError` `ToolNotAllowedError` `ToolDisabledError` `ToolReplayMissError`
|
||||
`RegistrationRejectedError`
|
||||
- 端口类型:`ToolRegistry` `ToolRuntime` `ToolReplayStore` `SchemaValidator` `SecretBox` `Clock`
|
||||
- 端口类型:`ToolRegistry` `ToolRuntime` `ToolReplayStore` `ToolHandoffStore` `SchemaValidator`
|
||||
`SecretBox` `Clock`;数据类型 `ToolHandoff` `HandoffField`
|
||||
- 用例工厂:`createToolboxService` `createToolInvoker`
|
||||
- 纯函数:`dispositionOf` `checkEvolution` `staticAudit` `auditPassed` `toolReplayKey`
|
||||
`callKeyOf` `handoffFields` `coerceBySchema`(未命中转人三件)
|
||||
- adapter:`createToolRegistry` `createToolReplayStore` `createSchemaValidator` `createToolRuntime`
|
||||
- Drizzle 表:`toolTable` `toolAllowlistTable` `replayToolTable`
|
||||
`createToolHandoffStore`
|
||||
- Drizzle 表:`toolTable` `toolAllowlistTable` `replayToolTable` `toolHandoffTable`
|
||||
|
||||
层级: L2(编码规范 §4)
|
||||
依赖: @contracts;@shared/db;port:`SecretBox`(app 注入,与 llm 同一实现)、`ToolRuntime`
|
||||
@@ -27,6 +30,9 @@
|
||||
声明了却查无此工具 / 已停用如实报,不静默给空壳。三路同一张清单:
|
||||
`function`(功能工具,经工具调用一缝:白名单/录制回放/写类只登记意向)、
|
||||
`human`(**人类工具**,系统内化、零声明即得 —— 调人是 Harness 给的能力)、
|
||||
`ledger`(**台账工具** `ledger.write`,2026-07-30:写草稿是基座六件之 ledger,
|
||||
零声明即得 —— 活动干完的活总得有地方放;`effect` 记 `read` 不是笔误,草稿层不触达世界,
|
||||
不该被当写类工具去登记动作意向)、
|
||||
`llm`(**LLM 五件**:抽取/归类/摘要/裁断/改写,经模型网关一缝;
|
||||
仅当活动声明了模型用途名才上总线 —— 没声明却会问模型是越权,用量也无从归因)
|
||||
- **一名两用**(2026-07-29):`name` 是**机器名**(全局唯一逻辑名,活动按它声明白名单、
|
||||
@@ -43,6 +49,23 @@
|
||||
- **注册两审**:静态审有 error 即不上架;试运行审要求样例集非空且全过
|
||||
- **凭证隔离**:连接值加密落库,运行时只把**申报过的连接键**交给处理器,不透传其它环境值
|
||||
- 停用后调用返回结构化错误(`ToolDisabledError`),不静默成功
|
||||
- **未命中转人:人是工具的一种实现**(2026-07-30,§6.2)。`invoke` 遇未注册**不再抛** ——
|
||||
返回 `tool-unregistered`;有契约无实现返回 `tool-unimplemented`。两者都不是失败,
|
||||
是「这一步该由人做」:app 据此开一张执行卡,人填回的表单值**就是这次调用的出参**。
|
||||
- 两分不可合:未注册的卡面只有工具名 + 本次入参 + 一格自由文本(系统不知道该问什么,
|
||||
就不编造字段);有契约无实现的卡面 = 契约 `guide` 段 + 入参 + 按出参 Schema 生成的表单。
|
||||
- `guide`(ask/steps/standard)是**面向人**的作业指导,与面向 AI 的 `description` 分开写,
|
||||
随包版本冻结 —— 不临场让模型编步骤。
|
||||
- 一次调用只发一张卡:`callKeyOf` 以工具名 + **键序无关**的入参指纹作内容地址;
|
||||
同 `(taskId, callKey)` 唯一。没有它,Loop 每拍都会为同一件事再堆一张卡。
|
||||
- 人填的字符串按出参 Schema 归型;**归不了型不硬转**,原样交回 ——
|
||||
硬转出 `NaN` / `{}` 会被活动当真值往下走,错得看不见。
|
||||
- **未授权仍抛** `ToolNotAllowedError`:越权是治理配置问题,不得靠转人掩盖。
|
||||
|
||||
近变(2026-07-30):新增**工具转人台** —— `ToolHandoffStore` 端口 + `tool_handoff` 表 +
|
||||
`tool.guide` 列 + 域内三件纯函数;`ToolInvoker.invoke` 未注册由抛改为回错误码
|
||||
(**行为变更**:调用方从前靠 catch,现在要看 `error.code`)。
|
||||
发卡 / 立监听 / 记答案在 app 装配层 —— toolbox 是 L2,不得认识 L3 的 human-tool。
|
||||
|
||||
近变(2026-07-28):模块面未变,**试运行审的行为修正** ——
|
||||
此前 `register` 给试运行传空连接,靠 `endpoint` 干活的工具永远「无实现」过不了门,
|
||||
@@ -51,4 +74,6 @@
|
||||
app 侧配套:未注册第一方处理器的工具走通用 HTTP 实现(`tools/http-tool.ts`),
|
||||
导入的自定义工具由此可真调、可真审。
|
||||
|
||||
影响半径: index/contract/port 未变 → 对外零影响
|
||||
影响半径: index/port 加法式扩容(新端口类型 + 三件纯函数 + 一张表);
|
||||
`invoke` 对未注册工具的**行为**变了(抛 → 回 `tool-unregistered`)——
|
||||
下游只有 app 的 `toolGateway` 一处消费,已同步改为转人。
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 未命中转人(§6.2)—— **人是工具的一种实现**,人填回的值就是这次调用的出参。
|
||||
*
|
||||
* 这里锁三件容易悄悄错的事:
|
||||
* ① 入参指纹要**稳定**(键序无关)—— 不稳就等于没有幂等,每拍再发一张卡;
|
||||
* ② 出参 Schema 决定问人哪几格 —— 没 Schema 时只准问「结果是什么」,不许编字段;
|
||||
* ③ 人填的字符串按声明归型,归不了就**原样交回**,不许硬转出 NaN。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { callKeyOf, coerceBySchema, handoffFields } from '../internal/domain/handoff.ts';
|
||||
|
||||
describe('入参指纹:同一次调用只问人一次', () => {
|
||||
it('键序不同、内容相同 → 同一把键', () => {
|
||||
expect(callKeyOf('rfq.parse', { a: 1, b: { y: 2, x: 3 } })).toBe(callKeyOf('rfq.parse', { b: { x: 3, y: 2 }, a: 1 }));
|
||||
});
|
||||
|
||||
it('内容变了就是另一次调用', () => {
|
||||
expect(callKeyOf('rfq.parse', { pn: 'X1' })).not.toBe(callKeyOf('rfq.parse', { pn: 'X2' }));
|
||||
});
|
||||
|
||||
it('同入参不同工具不得共用答案', () => {
|
||||
expect(callKeyOf('a.do', { pn: 'X1' })).not.toBe(callKeyOf('b.do', { pn: 'X1' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('出参 Schema → 回填表单', () => {
|
||||
it('没 Schema(连契约都没有)只给一格自由文本 —— 不编造字段骗人填', () => {
|
||||
expect(handoffFields(undefined)).toEqual([
|
||||
{ key: 'result', label: '这件事的结果(照实填写)', kind: 'textarea', required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('按 properties 逐格生成,类型决定控件,required 照声明', () => {
|
||||
const fields = handoffFields({
|
||||
properties: {
|
||||
target_price: { type: 'number', title: '目标价' },
|
||||
note: { type: 'string' },
|
||||
rows: { type: 'array' },
|
||||
},
|
||||
required: ['target_price'],
|
||||
});
|
||||
expect(fields).toEqual([
|
||||
{ key: 'target_price', label: '目标价', kind: 'number', required: true },
|
||||
{ key: 'note', label: 'note', kind: 'text', required: false },
|
||||
{ key: 'rows', label: 'rows', kind: 'textarea', required: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('按声明归型:归不了不硬转', () => {
|
||||
it('数值 / 布尔 / JSON 各按声明归位', () => {
|
||||
const output = {
|
||||
properties: { qty: { type: 'integer' }, urgent: { type: 'boolean' }, rows: { type: 'array' }, memo: { type: 'string' } },
|
||||
};
|
||||
expect(coerceBySchema(output, { qty: '120', urgent: 'true', rows: '[{"pn":"X1"}]', memo: '照原样' })).toEqual({
|
||||
qty: 120,
|
||||
urgent: true,
|
||||
rows: [{ pn: 'X1' }],
|
||||
memo: '照原样',
|
||||
});
|
||||
});
|
||||
|
||||
it('该是数字却填了一句话 → 原样交回,不给 NaN', () => {
|
||||
expect(coerceBySchema({ properties: { qty: { type: 'number' } } }, { qty: '问过供应商再说' })).toEqual({
|
||||
qty: '问过供应商再说',
|
||||
});
|
||||
});
|
||||
|
||||
it('该是数组却填了自然语言 → 原样交回,不给空对象', () => {
|
||||
expect(coerceBySchema({ properties: { rows: { type: 'array' } } }, { rows: '三家都没报' })).toEqual({
|
||||
rows: '三家都没报',
|
||||
});
|
||||
});
|
||||
|
||||
it('没 Schema 就原样透传 —— 系统不猜人的意思', () => {
|
||||
expect(coerceBySchema(undefined, { result: '已线下问清' })).toEqual({ result: '已线下问清' });
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* 工具总线的声明面 —— **Agent 能调什么由声明定死**,不由它临场想(domain 测试零 mock)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compileToolBus, HUMAN_TOOL, LLM_TOOLS } from '../internal/domain/tool-bus.ts';
|
||||
import { compileToolBus, HUMAN_TOOL, LEDGER_TOOL, LLM_TOOLS } from '../internal/domain/tool-bus.ts';
|
||||
import type { CatalogEntry } from '../internal/domain/tool-bus.ts';
|
||||
|
||||
const entry = (over: Partial<CatalogEntry> & { name: string }): CatalogEntry => ({
|
||||
@@ -47,11 +47,20 @@ describe('compileToolBus —— 声明 × 目录', () => {
|
||||
});
|
||||
|
||||
/* 人类工具是 Harness 给的能力,不是活动自带的本事 —— 每个活动天然可以调人 */
|
||||
it('人类工具恒在:一件工具都没声明也能调人', () => {
|
||||
/*
|
||||
* 系统内化两件恒在:调人与写台账草稿。
|
||||
* 前者是「AI 对世界只有触达与等待两种姿势」的等待面;后者是基座六件之 ledger ——
|
||||
* 活动干完的活总得有地方放,不给就等于让它干完即忘。
|
||||
*/
|
||||
it('人类工具与台账工具恒在:一件工具都没声明也能调人、也能写草稿', () => {
|
||||
const report = compileToolBus({ declaredTools: [], catalog, declaredModels: [] });
|
||||
expect(report.tools).toHaveLength(1);
|
||||
expect(report.tools[0]).toEqual(HUMAN_TOOL);
|
||||
expect(report.tools[0]!.kind).toBe('human');
|
||||
expect(report.tools.map((t) => t.id)).toEqual([HUMAN_TOOL.id, LEDGER_TOOL.id]);
|
||||
expect(report.tools.map((t) => t.kind)).toEqual(['human', 'ledger']);
|
||||
});
|
||||
|
||||
it('台账工具的效应是 read —— 草稿不触达世界,不该被当写类工具去登记动作意向', () => {
|
||||
const report = compileToolBus({ declaredTools: [], catalog, declaredModels: [] });
|
||||
expect(report.tools.find((t) => t.id === LEDGER_TOOL.id)?.effect).toBe('read');
|
||||
});
|
||||
|
||||
/* 没声明模型的活动突然会「问模型」= 越权,且用量无从归因 */
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 工具未命中的两分(§6.2)—— 这条缝断过,而断的样子很像「正常报错」。
|
||||
*
|
||||
* 从前 `invoke` 遇到未注册的工具直接抛 `ToolNotFoundError`:回合当场崩,
|
||||
* 人在工作台上什么也看不到 —— 名义上「工具未注册」,实际上是**这一单一步走不动**。
|
||||
*
|
||||
* 现在两分:
|
||||
* · 未注册 → 返回 `tool-unregistered`,由 app 转人(人是工具的一种实现);
|
||||
* · 未授权 → **仍然抛**。越权是治理配置的问题,不能靠转人掩盖过去。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ToolNotAllowedError, ToolNotFoundError } from '../contract.ts';
|
||||
import type { ToolContract } from '../internal/domain/tool-contract.ts';
|
||||
import { createToolInvoker } from '../internal/usecase/tool-invoker.ts';
|
||||
import type { ToolInvokerDeps } from '../internal/usecase/tool-invoker.ts';
|
||||
|
||||
const CONTRACT: ToolContract = {
|
||||
name: 'rfq.parse',
|
||||
displayName: 'RFQ 解析',
|
||||
version: '1.0.0',
|
||||
description: '从来件里抽出料号与数量',
|
||||
input: { type: 'object' },
|
||||
output: { type: 'object', properties: { rows: { type: 'array' } } },
|
||||
effect: 'read',
|
||||
errors: [],
|
||||
idempotent: true,
|
||||
timeoutMs: 3000,
|
||||
retries: 1,
|
||||
connectionKeys: [],
|
||||
};
|
||||
|
||||
function deps(over: { known?: ToolContract; allowlist?: string[]; handler?: boolean } = {}): ToolInvokerDeps {
|
||||
return {
|
||||
registry: {
|
||||
list: async () => (over.known === undefined ? [] : [over.known]),
|
||||
get: async (name) => (over.known?.name === name ? over.known : undefined),
|
||||
isEnabled: async () => true,
|
||||
upsert: async () => undefined,
|
||||
setEnabled: async () => undefined,
|
||||
connectionCipher: async () => undefined,
|
||||
setAllowlist: async () => undefined,
|
||||
allowlistOf: async () => over.allowlist ?? ['rfq.parse'],
|
||||
referencedBy: async () => [],
|
||||
},
|
||||
runtime: {
|
||||
execute: async () =>
|
||||
over.handler === true
|
||||
? { ok: true, output: { rows: [] } }
|
||||
: { ok: false, error: { code: 'tool-unimplemented', retryable: false, message: '工具无实现:rfq.parse' } },
|
||||
},
|
||||
replay: { get: async () => undefined, put: async () => undefined },
|
||||
schemas: { validate: () => ({ ok: true, errors: [] }) },
|
||||
secrets: { seal: (v) => v, open: (v) => v },
|
||||
clock: { now: () => new Date('2026-07-30T00:00:00.000Z') },
|
||||
mode: 'live',
|
||||
};
|
||||
}
|
||||
|
||||
const call = { tool: 'rfq.parse', input: { mail: 'x' }, activityId: 'quote-intake' };
|
||||
|
||||
describe('未注册:不抛,如实回一个可分辨的错误码', () => {
|
||||
it('invoke 返回 tool-unregistered —— 让调用方去转人,而不是让回合崩', async () => {
|
||||
const result = await createToolInvoker(deps()).invoke(call);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error?.code).toBe('tool-unregistered');
|
||||
expect(result.error?.message).toContain('rfq.parse');
|
||||
});
|
||||
|
||||
it('有契约但系统侧没有实现 → tool-unimplemented(与「连契约都没有」两分)', async () => {
|
||||
const result = await createToolInvoker(deps({ known: CONTRACT })).invoke(call);
|
||||
expect(result.error?.code).toBe('tool-unimplemented');
|
||||
});
|
||||
|
||||
it('有契约有实现照常执行 —— 转人只在真没有实现时发生', async () => {
|
||||
const result = await createToolInvoker(deps({ known: CONTRACT, handler: true })).invoke(call);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toEqual({ rows: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('未授权仍抛:越权不得靠转人掩盖', () => {
|
||||
it('不在白名单 → ToolNotAllowedError', async () => {
|
||||
await expect(createToolInvoker(deps({ known: CONTRACT, allowlist: [] })).invoke(call)).rejects.toBeInstanceOf(
|
||||
ToolNotAllowedError,
|
||||
);
|
||||
});
|
||||
|
||||
it('disposition 仍要求契约在场 —— 没有契约就无从判定动作性质', async () => {
|
||||
await expect(createToolInvoker(deps()).disposition(call, [])).rejects.toBeInstanceOf(ToolNotFoundError);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,8 @@ export type {
|
||||
IdGenerator,
|
||||
SchemaValidator,
|
||||
SecretBox,
|
||||
ToolHandoff,
|
||||
ToolHandoffStore,
|
||||
ToolRegistry,
|
||||
ToolReplayStore,
|
||||
ToolRuntime,
|
||||
@@ -45,14 +47,18 @@ export { createToolboxService } from './internal/usecase/toolbox-service.ts';
|
||||
export { checkEvolution, dispositionOf, EFFECT_LEVEL_LABEL, EFFECT_LEVELS } from './internal/domain/tool-contract.ts';
|
||||
/* 工具总线的声明面(纯逻辑):声明 × 目录 → 一张清单;人类工具与 LLM 五件系统内化 */
|
||||
export type { BusTool, CatalogEntry, ToolBusInput, ToolBusReport } from './internal/domain/tool-bus.ts';
|
||||
export { compileToolBus, HUMAN_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts';
|
||||
export { compileToolBus, HUMAN_TOOL, LEDGER_TOOL, LLM_TOOLS } from './internal/domain/tool-bus.ts';
|
||||
export { auditPassed, staticAudit } from './internal/domain/registration-audit.ts';
|
||||
/* 未命中转人(§6.2):入参指纹 / 出参 Schema → 回填表单 / 按声明归型 */
|
||||
export type { HandoffField } from './internal/domain/handoff.ts';
|
||||
export { callKeyOf, coerceBySchema, handoffFields } from './internal/domain/handoff.ts';
|
||||
|
||||
export type { ToolHandler } from './internal/adapter/toolbox-store.ts';
|
||||
export {
|
||||
createSchemaValidator,
|
||||
createToolRegistry,
|
||||
createToolReplayStore,
|
||||
createToolHandoffStore,
|
||||
createToolRuntime,
|
||||
isAllowed,
|
||||
} from './internal/adapter/toolbox-store.ts';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* toolbox 的 sys schema 表(编码规范 §7 / ADR-002 / ADR-009)。
|
||||
*/
|
||||
import { boolean, index, integer, jsonb, primaryKey, text, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { boolean, index, integer, jsonb, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sys } from '@shared/db.ts';
|
||||
|
||||
export const toolTable = sys.table(
|
||||
@@ -21,6 +21,8 @@ export const toolTable = sys.table(
|
||||
retries: integer('retries').notNull().default(1),
|
||||
connectionKeys: jsonb('connection_keys').notNull().default([]),
|
||||
capabilities: jsonb('capabilities'),
|
||||
/** 面向人的作业指导段 —— 转人时照抄上卡,不临场让模型编步骤(§6.2) */
|
||||
guide: jsonb('guide'),
|
||||
pure: boolean('pure').notNull().default(false),
|
||||
ownerActivityId: text('owner_activity_id'),
|
||||
/** 凭证隔离:值加密落库,不入包、不入活动、不入留痕明文(ADR-009) */
|
||||
@@ -48,3 +50,36 @@ export const replayToolTable = sys.table('replay_tool', {
|
||||
result: jsonb('result').notNull(),
|
||||
recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 工具转人台(§6.2 未命中转人)—— **人是工具的一种实现**。
|
||||
*
|
||||
* 系统里没有这件工具(无契约或有契约无实现)时,不报错、不空转:开一张执行卡请人来做,
|
||||
* 人填回的表单值**就是这次工具调用的出参**。这张表记的就是「这次调用交给谁、答没答、答了什么」。
|
||||
*
|
||||
* `callKey` 是内容地址(工具 + 入参指纹):同一个调用重问一次拿的是同一份答案 ——
|
||||
* 没有它,Loop 每拍都会为同一件事再发一张卡。
|
||||
*/
|
||||
export const toolHandoffTable = sys.table(
|
||||
'tool_handoff',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
tool: text('tool').notNull(),
|
||||
/** 内容地址:工具 + 入参指纹 —— 同调用同答案 */
|
||||
callKey: text('call_key').notNull(),
|
||||
input: jsonb('input').notNull(),
|
||||
/** 请人做的那张执行卡 */
|
||||
workOrderId: text('work_order_id').notNull(),
|
||||
/** pending = 已发卡等答;answered = 人已填,出参可取 */
|
||||
state: text('state').notNull().default('pending'),
|
||||
/** 人填回的值 = 工具出参 */
|
||||
output: jsonb('output'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
answeredAt: timestamp('answered_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('tool_handoff_call_key').on(t.taskId, t.callKey),
|
||||
index('tool_handoff_order_idx').on(t.workOrderId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5,10 +5,10 @@ import { Ajv } from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { SchemaValidator, ToolRegistry, ToolReplayStore, ToolRuntime } from '../../port.ts';
|
||||
import type { SchemaValidator, ToolHandoff, ToolHandoffStore, ToolRegistry, ToolReplayStore, ToolRuntime } from '../../port.ts';
|
||||
import type { ToolCall, ToolResult } from '../../contract.ts';
|
||||
import type { EffectLevel, ToolContract } from '../domain/tool-contract.ts';
|
||||
import { replayToolTable, toolAllowlistTable, toolTable } from './toolbox-schema.ts';
|
||||
import { replayToolTable, toolAllowlistTable, toolHandoffTable, toolTable } from './toolbox-schema.ts';
|
||||
|
||||
type Row = typeof toolTable.$inferSelect;
|
||||
|
||||
@@ -28,6 +28,7 @@ function toContract(row: Row): ToolContract {
|
||||
retries: row.retries,
|
||||
connectionKeys: row.connectionKeys as string[],
|
||||
...(row.capabilities === null ? {} : { capabilities: row.capabilities as ToolContract['capabilities'] }),
|
||||
...(row.guide === null ? {} : { guide: row.guide as ToolContract['guide'] }),
|
||||
pure: row.pure,
|
||||
...(row.ownerActivityId === null ? {} : { ownerActivityId: row.ownerActivityId }),
|
||||
};
|
||||
@@ -62,6 +63,7 @@ export function createToolRegistry(db: Db): ToolRegistry {
|
||||
retries: contract.retries,
|
||||
connectionKeys: contract.connectionKeys,
|
||||
capabilities: contract.capabilities ?? null,
|
||||
guide: contract.guide ?? null,
|
||||
pure: contract.pure ?? false,
|
||||
ownerActivityId: contract.ownerActivityId ?? null,
|
||||
connectionCipher,
|
||||
@@ -84,6 +86,7 @@ export function createToolRegistry(db: Db): ToolRegistry {
|
||||
retries: values.retries,
|
||||
connectionKeys: values.connectionKeys,
|
||||
capabilities: values.capabilities,
|
||||
guide: values.guide,
|
||||
pure: values.pure,
|
||||
ownerActivityId: values.ownerActivityId,
|
||||
// 未传凭证即保持原值(只写不读)
|
||||
@@ -222,3 +225,51 @@ export async function isAllowed(db: Db, activityId: string, tool: string): Promi
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** 转人台 —— 同任务同 callKey 唯一(重问拿同一份答案,不再发第二张卡) */
|
||||
export function createToolHandoffStore(db: Db): ToolHandoffStore {
|
||||
const toHandoff = (row: typeof toolHandoffTable.$inferSelect): ToolHandoff => ({
|
||||
id: row.id,
|
||||
taskId: row.taskId,
|
||||
tool: row.tool,
|
||||
callKey: row.callKey,
|
||||
input: row.input as Record<string, unknown>,
|
||||
workOrderId: row.workOrderId,
|
||||
state: row.state === 'answered' ? 'answered' : 'pending',
|
||||
...(row.output === null ? {} : { output: row.output as Record<string, unknown> }),
|
||||
});
|
||||
|
||||
return {
|
||||
async find(taskId, callKey) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(toolHandoffTable)
|
||||
.where(and(eq(toolHandoffTable.taskId, taskId), eq(toolHandoffTable.callKey, callKey)))
|
||||
.limit(1);
|
||||
return rows[0] === undefined ? undefined : toHandoff(rows[0]);
|
||||
},
|
||||
async open(handoff) {
|
||||
await db
|
||||
.insert(toolHandoffTable)
|
||||
.values({
|
||||
id: handoff.id,
|
||||
taskId: handoff.taskId,
|
||||
tool: handoff.tool,
|
||||
callKey: handoff.callKey,
|
||||
input: handoff.input,
|
||||
workOrderId: handoff.workOrderId,
|
||||
state: 'pending',
|
||||
})
|
||||
// 并发下两拍同时开同一张:先到的赢,后到的静默让位(同 callKey 只该有一张卡)
|
||||
.onConflictDoNothing();
|
||||
},
|
||||
async answer(workOrderId, output) {
|
||||
const rows = await db
|
||||
.update(toolHandoffTable)
|
||||
.set({ state: 'answered', output, answeredAt: new Date() })
|
||||
.where(eq(toolHandoffTable.workOrderId, workOrderId))
|
||||
.returning();
|
||||
return rows[0] === undefined ? undefined : toHandoff(rows[0]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 未命中转人的纯逻辑(§6.2)—— **人是工具的一种实现**。
|
||||
*
|
||||
* 系统里没有这件工具时,不报错、不空转:开一张执行卡请人做,人填回的表单值
|
||||
* 就是这次调用的出参。这里只放三件纯计算 —— 发卡、存台、立监听都在 app 装配层。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 入参指纹:同一次调用只问人一次,重问拿同一份答案。
|
||||
*
|
||||
* 没有它,Loop 每拍都会为同一件事再发一张卡 —— 负责人工位上堆起一摞一模一样的
|
||||
* 「请代办」,谁也不知道该答哪张。
|
||||
*
|
||||
* 键必须**稳定**:对象键先排序再序列化,不然 `{a,b}` 与 `{b,a}` 会被当成两次不同的调用。
|
||||
* 这里用的是纯 TS 的 FNV-1a(domain 不得触 node:crypto);它是内容地址,不是安全摘要 ——
|
||||
* 单任务内十几次调用的碰撞概率可忽略,而长度也一并进键,进一步压掉碰撞。
|
||||
*/
|
||||
export function callKeyOf(tool: string, input: Readonly<Record<string, unknown>>): string {
|
||||
const text = canonicalize(input);
|
||||
return `${tool}#${text.length}#${fnv1a64(text)}`;
|
||||
}
|
||||
|
||||
function canonicalize(value: unknown): string {
|
||||
if (value === undefined) return 'null';
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(',')}}`;
|
||||
}
|
||||
|
||||
/** FNV-1a,分高低两半各 32 位算,拼成 16 位十六进制 —— 纯运算,无依赖 */
|
||||
function fnv1a64(text: string): string {
|
||||
let lo = 0x811c9dc5;
|
||||
let hi = 0x01000193;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.charCodeAt(i);
|
||||
lo = Math.imul(lo ^ code, 0x01000193) >>> 0;
|
||||
hi = Math.imul(hi ^ (code + i), 0x85ebca6b) >>> 0;
|
||||
}
|
||||
return `${lo.toString(16).padStart(8, '0')}${hi.toString(16).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
export type HandoffField = {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly kind: 'text' | 'textarea' | 'number';
|
||||
readonly required: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 出参 Schema → 回填表单。人照着填的这几格,就是这次调用的返回值。
|
||||
*
|
||||
* 没有 Schema(连契约都没有)只给一格自由文本 —— 系统不知道该问什么,
|
||||
* 就如实只问「结果是什么」,不编造字段骗人填。
|
||||
*/
|
||||
export function handoffFields(output: Readonly<Record<string, unknown>> | undefined): HandoffField[] {
|
||||
const properties = output?.['properties'];
|
||||
if (typeof properties !== 'object' || properties === null) {
|
||||
return [{ key: 'result', label: '这件事的结果(照实填写)', kind: 'textarea', required: true }];
|
||||
}
|
||||
const declared = output?.['required'];
|
||||
const required = Array.isArray(declared) ? declared.map(String) : [];
|
||||
return Object.entries(properties as Record<string, unknown>).map(([key, raw]) => {
|
||||
const def = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
const type = String(def['type'] ?? 'string');
|
||||
return {
|
||||
key,
|
||||
label: String(def['title'] ?? def['description'] ?? key),
|
||||
kind:
|
||||
type === 'number' || type === 'integer'
|
||||
? ('number' as const)
|
||||
: type === 'string'
|
||||
? ('text' as const)
|
||||
: ('textarea' as const),
|
||||
required: required.includes(key),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 人填的是字符串,活动等的是出参 Schema 声明的类型 —— 按声明归型。
|
||||
*
|
||||
* 归不了型(该是数组却填了一句自然语言)**不硬转、不丢**:原样交回去。
|
||||
* 硬转出一个 `NaN` 或 `{}`,活动会当成真值继续往下走 —— 那比一个明摆着不合型的
|
||||
* 字符串更坏,因为错得看不见。
|
||||
*/
|
||||
export function coerceBySchema(
|
||||
output: Readonly<Record<string, unknown>> | undefined,
|
||||
values: Readonly<Record<string, unknown>>,
|
||||
): Record<string, unknown> {
|
||||
const properties = output?.['properties'];
|
||||
if (typeof properties !== 'object' || properties === null) return { ...values };
|
||||
const props = properties as Record<string, unknown>;
|
||||
const typed: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const raw = props[key];
|
||||
const def = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
const type = String(def['type'] ?? 'string');
|
||||
if (typeof value !== 'string' || type === 'string') {
|
||||
typed[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (type === 'number' || type === 'integer') {
|
||||
const n = Number(value);
|
||||
typed[key] = value.trim() !== '' && Number.isFinite(n) ? n : value;
|
||||
continue;
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
typed[key] = value === 'true' ? true : value === 'false' ? false : value;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
typed[key] = JSON.parse(value);
|
||||
} catch {
|
||||
typed[key] = value;
|
||||
}
|
||||
}
|
||||
return typed;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export type BusTool = {
|
||||
/** 入参契约(JSON Schema)—— 装配层据此生成 zod,模型据此填参 */
|
||||
readonly input: Record<string, unknown>;
|
||||
readonly output?: Record<string, unknown>;
|
||||
readonly kind: 'function' | 'human' | 'llm';
|
||||
readonly kind: 'function' | 'human' | 'llm' | 'ledger';
|
||||
/**
|
||||
* 效应:`read` 当场调当场回;`write` **不当场落地** ——
|
||||
* 过程中只登记动作意向,放行那一刻才由系统执行(OS 06「过程不落地」)。
|
||||
@@ -74,6 +74,42 @@ export const HUMAN_TOOL: BusTool = {
|
||||
effect: 'read',
|
||||
};
|
||||
|
||||
/**
|
||||
* **台账工具**(系统内化):写草稿。
|
||||
*
|
||||
* 基座六件里的 `ledger` —— 活动过程写的唯一形态(铁律②:写只有草稿与意向,
|
||||
* 登记那一刻世界上什么都没变)。它和调人一样零声明即得:每个活动都要往自己的
|
||||
* 工作台上记东西,这是 Harness 给的能力,不是活动自带的本事。
|
||||
*
|
||||
* `effect` 记 `read` 不是笔误:草稿层**不触达世界**,不该被当成写类工具去登记动作意向 ——
|
||||
* 那条路是留给真正会改变外部系统的动作的。
|
||||
*/
|
||||
export const LEDGER_TOOL: BusTool = {
|
||||
id: 'ledger.write',
|
||||
description:
|
||||
'写台账草稿:把这一步的成果按「域.对象」逐行记下来(草稿层,放行前世界上什么都没变)。' +
|
||||
'一次可写多行 —— 一份 BOM 一次写完。只准写本活动清单声明过的地址与列名。',
|
||||
input: {
|
||||
type: 'object',
|
||||
required: ['address', 'rows'],
|
||||
properties: {
|
||||
address: { type: 'string', description: '台账地址「域.对象」—— 照可写台账字典原样抄,不要拆开' },
|
||||
rows: {
|
||||
type: 'array',
|
||||
description: '要写的行:key 是主键列,patch 是其余列',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['key', 'patch'],
|
||||
properties: { key: { type: 'object' }, patch: { type: 'object' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
output: { type: 'object', properties: { written: { type: 'integer' } } },
|
||||
kind: 'ledger',
|
||||
effect: 'read',
|
||||
};
|
||||
|
||||
/**
|
||||
* **LLM 工具**(系统内化,五件通用):大模型驱动的通用能力,不是「一个模型实例」。
|
||||
*
|
||||
@@ -207,6 +243,8 @@ export function compileToolBus(input: ToolBusInput): ToolBusReport {
|
||||
}
|
||||
|
||||
tools.push(HUMAN_TOOL);
|
||||
// 台账草稿写:零声明即得(基座六件之 ledger)—— 不给它,活动干完的活没地方放
|
||||
tools.push(LEDGER_TOOL);
|
||||
if (input.declaredModels.length > 0) tools.push(...LLM_TOOLS);
|
||||
|
||||
return { tools, missing, disabled };
|
||||
|
||||
@@ -21,6 +21,23 @@ export type ToolContract = {
|
||||
readonly version: string;
|
||||
/** 面向 AI 的适用说明:何时用、语义、边界 */
|
||||
readonly description: string;
|
||||
/**
|
||||
* 面向**人**的作业指导段 —— 系统里没有这件工具(未注册)时,转人执行卡上的指导书从这里来。
|
||||
*
|
||||
* 与 `description` 分开写:那一份是给 AI 判「该不该调」的,这一份是给人看「怎么做」的。
|
||||
* 声明式、可审、随包版本冻结 —— 不临场让模型编一段步骤,那样每次说法都不一样,
|
||||
* 人照着做出来的东西也就不一样。
|
||||
*/
|
||||
readonly guide?:
|
||||
| {
|
||||
/** 一句话说清要人做什么 */
|
||||
readonly ask: string;
|
||||
/** 分步要点 —— 执行卡的作业指导书 */
|
||||
readonly steps?: readonly string[] | undefined;
|
||||
/** 什么算做完 */
|
||||
readonly standard?: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
readonly input: Record<string, unknown>;
|
||||
readonly output: Record<string, unknown>;
|
||||
readonly effect: EffectLevel;
|
||||
|
||||
@@ -38,9 +38,17 @@ export function toolReplayKey(call: ToolCall): string {
|
||||
}
|
||||
|
||||
export function createToolInvoker(deps: ToolInvokerDeps): ToolInvoker {
|
||||
/**
|
||||
* 取契约。**未注册不抛** —— 返回 undefined,由调用方转人(§6.2)。
|
||||
*
|
||||
* 「设计活动时只考虑需要什么工具,不关心系统里有没有实现」:声明了系统里没有,
|
||||
* 那是**系统的缺口**,不是活动的错误。抛出去只会让回合崩,人什么也看不到;
|
||||
* 转人则是「这一步系统还干不了,请你来干」—— 活照样往下走。
|
||||
* 未授权仍抛:越权是治理配置问题,不能靠转人掩盖过去。
|
||||
*/
|
||||
async function loadContract(call: ToolCall) {
|
||||
const contract = await deps.registry.get(call.tool);
|
||||
if (contract === undefined) throw new ToolNotFoundError(call.tool);
|
||||
if (contract === undefined) return undefined;
|
||||
|
||||
// 白名单:纯工具是活动私有作用域,功能工具按活动白名单
|
||||
if (contract.pure === true) {
|
||||
@@ -62,6 +70,15 @@ export function createToolInvoker(deps: ToolInvokerDeps): ToolInvoker {
|
||||
}
|
||||
|
||||
const contract = await loadContract(call);
|
||||
if (contract === undefined) {
|
||||
// 系统里连契约都没有:转人时卡面只有工具名与本次输入,如实说明
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'tool-unregistered', retryable: false, message: `系统里没有这件工具:${call.tool}` },
|
||||
latencyMs: 0,
|
||||
mode: deps.mode,
|
||||
};
|
||||
}
|
||||
if (!(await deps.registry.isEnabled(call.tool))) throw new ToolDisabledError(call.tool);
|
||||
|
||||
const inputCheck = deps.schemas.validate(contract.input, call.input);
|
||||
@@ -106,6 +123,7 @@ export function createToolInvoker(deps: ToolInvokerDeps): ToolInvoker {
|
||||
|
||||
async disposition(call, preauthorized): Promise<CallDisposition> {
|
||||
const contract = await loadContract(call);
|
||||
if (contract === undefined) throw new ToolNotFoundError(call.tool);
|
||||
return dispositionOf(contract, call.operation ?? call.tool, preauthorized);
|
||||
},
|
||||
|
||||
@@ -113,7 +131,8 @@ export function createToolInvoker(deps: ToolInvokerDeps): ToolInvoker {
|
||||
|
||||
async validate(call) {
|
||||
const contract = await loadContract(call);
|
||||
if (contract.capabilities?.validate !== true) return undefined;
|
||||
// 无契约就无从预校验 —— 预检那边会以「工具不可用」记一条 finding,不在这里造结论
|
||||
if (contract?.capabilities?.validate !== true) return undefined;
|
||||
return run({ ...call, operation: 'validate' });
|
||||
},
|
||||
|
||||
|
||||
@@ -34,6 +34,28 @@ export type ToolReplayStore = {
|
||||
* 工具执行 —— 实现对系统:server 侧执行代码,运行时受限,
|
||||
* **只可访问注册申报的连接目标,无其他环境能力**(OS 7.3)。
|
||||
*/
|
||||
/**
|
||||
* 工具转人台(§6.2)—— 人是工具的一种实现:发出去的卡、答回来的出参都记在这。
|
||||
* 内容地址 `callKey` 保证同一次调用只问人一次。
|
||||
*/
|
||||
export type ToolHandoffStore = {
|
||||
find(taskId: string, callKey: string): Promise<ToolHandoff | undefined>;
|
||||
open(handoff: Omit<ToolHandoff, 'state' | 'output'>): Promise<void>;
|
||||
/** 人答完:值即出参 */
|
||||
answer(workOrderId: string, output: Record<string, unknown>): Promise<ToolHandoff | undefined>;
|
||||
};
|
||||
|
||||
export type ToolHandoff = {
|
||||
readonly id: string;
|
||||
readonly taskId: string;
|
||||
readonly tool: string;
|
||||
readonly callKey: string;
|
||||
readonly input: Record<string, unknown>;
|
||||
readonly workOrderId: string;
|
||||
readonly state: 'pending' | 'answered';
|
||||
readonly output?: Record<string, unknown> | undefined;
|
||||
};
|
||||
|
||||
export type ToolRuntime = {
|
||||
execute(input: {
|
||||
readonly contract: ToolContract;
|
||||
|
||||
Reference in New Issue
Block a user