Archived
A2-A7 全部补齐到验收线;顺手把几处「门拦住却不给路」的坑填了
活动:A2 选型 / A3 寻源 / A4 定价 / A6 议价 / A7 归档五件各补成真活动包 —— 契约宽进(input.required 空,归一在活动内)、台账必填声明(含行级 requiredWhen)、 入场哨兵与 L1/L2 判据一律读基座数、两份件(在办可编辑 + 办结静态快照)、 三案例常备、包内自测。e2e 各三案例 released、行动被拒 0、发口正确、提交闸每单真拦。 基座(共享面,改一处影响所有活动,故每处都带回归): · 重发判断走在站上场之前 —— 人答完那步常已收掉,readyToWork 先抛「不能再往它上面挂活」, 于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要时够不着,A1 基线当场熔断; · 决策卡补幂等兜底(与执行工单同一把尺):头两次无事发生,第三次起才算被拒; · 输出没成形先就地重问一次,原话进工作日志、不再摆上卡面; · 工具调用读数 tool_<名>_calls —— 「机检只读基座数」这条纪律第一次覆盖到工具; · 执行卡 steps/standard 补进出参 schema(从前能到卡面全靠厂商 JSON 模式宽松); · 件的就地更新改逐节点 morph,保存先把新值镜像进只读那份 —— 不闪、不等回声。 A1(基线,全程守住):措辞三档对齐(必填/建议/选填)、禁止拿历史价当客户目标价、 卡骨架从步骤剧本搬进说明书(压成一拍连做时剧本没摊开,会发出没有指导书的裸卡)。 契约两处**新增可选字段**(非破坏):FieldDeclaration.requiredWhen、工单 finalHtml; api-report 同批更新。docs/adr 与 docs/archive 的 13 份文件在工作树里不见了, 本次予以恢复而非提交删除。 门禁:typecheck / lint / depcruise / test(1366)全绿。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,13 +81,13 @@ describe('写行的形状', () => {
|
||||
});
|
||||
|
||||
describe('看不懂就转人,不猜着动手', () => {
|
||||
it('行动型不认识 → 发执行卡,并把**模型原话**摆上去', () => {
|
||||
const action = normalizeAction({ kind: '我自己想的行动' }, ENABLED, '{"kind":"我自己想的行动"}');
|
||||
it('行动型不认识 → 发执行卡,卡面只说人话 —— JSON 原话归工作日志,不上卡', () => {
|
||||
const action = normalizeAction({ kind: '我自己想的行动' }, ENABLED);
|
||||
expect(action.kind).toBe('human');
|
||||
if (action.kind !== 'human') return;
|
||||
expect(action.card['reason']).toBe('模型未给出合规的行动');
|
||||
// 只写「不合规」的话,收卡的人分不清是模型跑偏、行动型少了一种、还是提示词没交代清楚
|
||||
expect(String(action.card['modelReply'])).toContain('我自己想的行动');
|
||||
// 2026-08-03 实测:原话上卡,人看到的是一坨代码(「卡片里又是乱七八糟的东西」)
|
||||
expect(action.card['modelReply']).toBeUndefined();
|
||||
expect(String(action.card['reason'])).toContain('工作日志');
|
||||
expect(action.card['kindSeen']).toBe('我自己想的行动');
|
||||
});
|
||||
|
||||
@@ -98,44 +98,38 @@ describe('看不懂就转人,不猜着动手', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 补全卡的格子在哪儿 —— **两处都认**。
|
||||
*
|
||||
* 提示词里执行工单的写法是 `card.fields`,模型很自然把补全卡也那么写(实测就是),
|
||||
* 于是每一拍都被「fields 为空」拒回来。空不空由引擎验,这里只负责不让一个位置差异吃掉一整拍。
|
||||
* **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,执行卡完全就是
|
||||
* 包含补全语义的人类工具」)—— 归一层把旧输出里的 `ask` 原地转成零输入执行工单:
|
||||
* 问句上卡,字段名并进指导书(人补在工作区的件上),`fields` 不上卡。
|
||||
* 拒回只会让模型换着花样再发,归一才是把路铺平。
|
||||
*/
|
||||
describe('补全卡的格子', () => {
|
||||
const fieldsOf = (raw: Record<string, unknown>) => {
|
||||
const a = normalizeAction({ kind: 'ask', itemId: 'T1', ...raw }, ENABLED);
|
||||
return a.kind === 'ask' ? a.fields : [];
|
||||
};
|
||||
describe('补全卡退役:ask 原地转成零输入执行工单', () => {
|
||||
const norm = (raw: Record<string, unknown>) => normalizeAction({ kind: 'ask', itemId: 'T1', ...raw }, ENABLED);
|
||||
|
||||
it('顶层 fields', () => {
|
||||
expect(fieldsOf({ ask: '补一下', fields: [{ key: 'lead_time', label: '交期' }] })).toEqual([
|
||||
{ key: 'lead_time', label: '交期' },
|
||||
]);
|
||||
it('转成 human + execution,问句在 ask,字段名进指导书', () => {
|
||||
const a = norm({ ask: '缺交期,补一下', fields: [{ key: 'lead_time', label: '交期' }] });
|
||||
expect(a.kind).toBe('human');
|
||||
if (a.kind !== 'human') return;
|
||||
expect(a.workOrder).toBe('execution');
|
||||
const card = a.card as Record<string, unknown>;
|
||||
expect(card['ask']).toBe('缺交期,补一下');
|
||||
expect(JSON.stringify(card['steps'])).toContain('交期');
|
||||
// 零输入:fields 不上卡
|
||||
expect(card['fields']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('落在 card 里也认(实测模型这么写过)', () => {
|
||||
expect(fieldsOf({ card: { ask: '补一下', fields: [{ key: 'eau', label: '年用量' }] } })).toEqual([
|
||||
{ key: 'eau', label: '年用量' },
|
||||
]);
|
||||
it('fields 落在 card 里也认(旧模型这么写过)—— 一样进指导书', () => {
|
||||
const a = norm({ card: { ask: '补一下', fields: [{ key: 'eau', label: '年用量' }] } });
|
||||
expect(a.kind).toBe('human');
|
||||
if (a.kind !== 'human') return;
|
||||
expect(JSON.stringify((a.card as Record<string, unknown>)['steps'])).toContain('年用量');
|
||||
});
|
||||
|
||||
it('写成一串字段名也认 —— label 用 key 顶上,宁可显示字段名不显示空白', () => {
|
||||
expect(fieldsOf({ ask: 'x', fields: ['eau', 'lead_time'] })).toEqual([
|
||||
{ key: 'eau', label: 'eau' },
|
||||
{ key: 'lead_time', label: 'lead_time' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('没有 key 的格子丢掉 —— 没有 key 就没法回填', () => {
|
||||
expect(fieldsOf({ ask: 'x', fields: [{ label: '只有标签' }, { key: 'ok', label: '行' }] })).toEqual([
|
||||
{ key: 'ok', label: '行' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('一个都收不到就是空数组,交引擎拒(不替它编格子)', () => {
|
||||
expect(fieldsOf({ ask: 'x' })).toEqual([]);
|
||||
it('连问句都没有:兜一个标题,不吃掉整拍', () => {
|
||||
const a = norm({});
|
||||
expect(a.kind).toBe('human');
|
||||
if (a.kind !== 'human') return;
|
||||
expect(String((a.card as Record<string, unknown>)['title'])).not.toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,10 +203,16 @@ describe('一拍一串行动的归一', () => {
|
||||
expect(turn.actions.map((a) => a.kind)).toEqual(['finish']);
|
||||
});
|
||||
|
||||
it('整串一个都不认识 → 单张转人卡,模型原话摆上去', () => {
|
||||
const turn = normalizeTurn({ actions: [{ kind: '胡编的' }] }, ENABLED, '{"kind":"胡编的"}');
|
||||
it('整串一个都不认识 → 标 unrecognized(decide 先就地重问)+ 单张转人卡兜底', () => {
|
||||
const turn = normalizeTurn({ actions: [{ kind: '胡编的' }] }, ENABLED);
|
||||
expect(turn.actions).toHaveLength(1);
|
||||
expect(turn.actions[0]?.kind).toBe('human');
|
||||
expect(turn.unrecognized).toBe(true);
|
||||
});
|
||||
|
||||
it('认出了行动就不标 unrecognized —— 别把好拍拿去白重问一遍', () => {
|
||||
const turn = normalizeTurn({ actions: [{ kind: 'finish', itemId: 'T1' }] }, ENABLED);
|
||||
expect(turn.unrecognized).toBeUndefined();
|
||||
});
|
||||
|
||||
it('超出封顶截到 6 个 —— 引擎侧还会再守一道', () => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 输出不成形的就地重问(2026-08-03 用户实测送来的单逼出来的)。
|
||||
*
|
||||
* 病:关思考的模型偶发把一拍输出写坏(整串行动一个都不认识),从前一次没认出
|
||||
* 就直接发「需要你判断下一步」的兜底卡,还把 JSON 原话整段摆在卡面上 ——
|
||||
* 人打开工单看到的是一坨代码(用户原话:「卡片里又是乱七八糟的东西」)。
|
||||
*
|
||||
* 法:第一次没认出 → 原文经 `note` 存进工作日志(内部痕),附「只输出 JSON」
|
||||
* 就地再问一次;第二次仍不成形才交人,卡面只说人话、不摆原话。
|
||||
* 这里用假网关钉死三件事:重问只有一次、原文进痕、好输出不白重问。
|
||||
* (ModelGateway 是外界 —— LLM 厂商 —— 的缝,录制回放替身合规。)
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { EnabledSet } from '@modules/loop-engine';
|
||||
import type { ModelCall, ModelGateway, ModelResult } from '@modules/llm';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { createAgentRunner } from '../agent-runner.ts';
|
||||
|
||||
const ENABLED: EnabledSet = {
|
||||
entries: [{ id: 'T1', name: '干活', type: 'humanTask', requiresManualStart: false, discretionary: false }],
|
||||
inProgress: [],
|
||||
judgements: [],
|
||||
gaps: ['缺记录'],
|
||||
activeStages: [],
|
||||
};
|
||||
|
||||
/** 按序回放给定出参的假网关 —— 超出名单就重复最后一个 */
|
||||
function gatewayOf(outputs: Array<{ object: unknown; text: string }>): { gateway: ModelGateway; calls: ModelCall[] } {
|
||||
const calls: ModelCall[] = [];
|
||||
const gateway: ModelGateway = {
|
||||
mode: 'replay',
|
||||
resolve: () => Promise.reject(new Error('测试里用不到 resolve')),
|
||||
generate: (call) => {
|
||||
calls.push(call);
|
||||
const next = outputs[Math.min(calls.length - 1, outputs.length - 1)];
|
||||
if (next === undefined) return Promise.reject(new Error('名单空着'));
|
||||
const result: ModelResult = {
|
||||
text: next.text,
|
||||
object: next.object,
|
||||
usage: { inputTokens: 10, outputTokens: 5, latencyMs: 1 },
|
||||
binding: { name: 'fake', provider: 'deepseek', model: 'fake' },
|
||||
mode: 'replay',
|
||||
};
|
||||
return Promise.resolve(result);
|
||||
},
|
||||
withMode: () => gateway,
|
||||
};
|
||||
return { gateway, calls };
|
||||
}
|
||||
|
||||
function runnerOf(outputs: Array<{ object: unknown; text: string }>) {
|
||||
const { gateway, calls } = gatewayOf(outputs);
|
||||
const notes: Array<{ title: string; raw: string }> = [];
|
||||
const runner = createAgentRunner({
|
||||
gateway,
|
||||
modelOf: () => Promise.resolve('fake'),
|
||||
onUsage: () => Promise.resolve(),
|
||||
note: (_taskId, title, raw) => {
|
||||
notes.push({ title, raw });
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
const decide = () =>
|
||||
runner.decide({
|
||||
taskId: 'task-1' as TaskId,
|
||||
narrative: '(空)',
|
||||
enabled: ENABLED,
|
||||
scope: {},
|
||||
writable: [],
|
||||
tools: [],
|
||||
});
|
||||
return { decide, calls, notes };
|
||||
}
|
||||
|
||||
const GOOD = { object: { say: '收工', actions: [{ kind: 'finish', itemId: 'T1' }] }, text: '{"say":"收工"}' };
|
||||
const BAD = { object: { say: '', actions: [{ kind: '胡编的' }] }, text: '再连贯的散文也不是行动串' };
|
||||
|
||||
describe('输出不成形先就地重问,别一抖就叫人', () => {
|
||||
it('第一拍坏、重问好 → 两次调用、原文进痕一条、返回的是好行动', async () => {
|
||||
const { decide, calls, notes } = runnerOf([BAD, GOOD]);
|
||||
const turn = await decide();
|
||||
expect(calls).toHaveLength(2);
|
||||
// 重问那一次要把「只输出 JSON」的硬提示带上 —— 不带等于原样再撞一次
|
||||
expect(calls[1]?.prompt).toContain('不是合法的行动 JSON');
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0]?.raw).toContain('散文');
|
||||
expect(turn.actions[0]?.kind).toBe('finish');
|
||||
});
|
||||
|
||||
it('两拍都坏 → 就收手交人,不无限重问;卡面不摆模型原话', async () => {
|
||||
const { decide, calls } = runnerOf([BAD, BAD]);
|
||||
const turn = await decide();
|
||||
expect(calls).toHaveLength(2);
|
||||
const action = turn.actions[0];
|
||||
expect(action?.kind).toBe('human');
|
||||
if (action?.kind !== 'human') return;
|
||||
expect(action.card['modelReply']).toBeUndefined();
|
||||
expect(String(action.card['reason'])).toContain('工作日志');
|
||||
});
|
||||
|
||||
it('第一拍就是好的 → 只调一次,不白烧重问', async () => {
|
||||
const { decide, calls, notes } = runnerOf([GOOD]);
|
||||
const turn = await decide();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(notes).toHaveLength(0);
|
||||
expect(turn.actions[0]?.kind).toBe('finish');
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
* 所以归型放在写进草稿那一刻:归得了就归,归不了当场拒(理由喂回下一拍)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { coerceToColumn, localStamp, missingReadings, rowReadings, rowKeyList, tallyReadings } from '../wiring/engines.ts';
|
||||
import { coerceToColumn, localStamp, missingReadings, rowReadings, rowKeyList, tallyReadings, toolCallReadings } from '../wiring/engines.ts';
|
||||
|
||||
describe('空值 = 这一列没有值(不写)', () => {
|
||||
it('空串 / 全空白 / null / undefined 一律不写 —— 「原文没写的不要补」的数据面落点', () => {
|
||||
@@ -342,3 +342,42 @@ describe('按值现数:域_对象_列_值', () => {
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **工具调用读数** —— 「机检只读基座数」这条纪律第一次覆盖到工具(2026-08-03)。
|
||||
*
|
||||
* 起因:A2 判稳偶发压根不调行情工具就把料判成「稳」转 A,而自报的那一列写得漂亮,
|
||||
* 去向就此走岔(约十次一次)。工具出参本来就入痕,「调没调」是基座数得出来的事实。
|
||||
*/
|
||||
describe('工具调用读数', () => {
|
||||
const declared = [{ name: 'crm.history-price-read' }, { name: 'mail.send' }];
|
||||
|
||||
it('声明过的工具一律先铺 0 —— 没调过与调了 0 次在业务上是同一件事', () => {
|
||||
expect(toolCallReadings([], declared)).toEqual({
|
||||
tool_crm_history_price_read_calls: 0,
|
||||
tool_mail_send_calls: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('数成功返回的那几次,名字里的点号横杠一律换成下划线(S-FEEL 不认)', () => {
|
||||
const trail = [
|
||||
{ kind: 'result', title: 'crm.history-price-read 返回' },
|
||||
{ kind: 'result', title: 'crm.history-price-read 返回(3 行)' },
|
||||
{ kind: 'thought', title: 'crm.history-price-read 返回' },
|
||||
];
|
||||
expect(toolCallReadings(trail, declared).tool_crm_history_price_read_calls).toBe(2);
|
||||
});
|
||||
|
||||
it('**调失败不算查过** —— 调不通等于没拿到行情,不该拿来当依据', () => {
|
||||
const trail = [{ kind: 'result', title: 'crm.history-price-read 失败:端点不可达' }];
|
||||
expect(toolCallReadings(trail, declared).tool_crm_history_price_read_calls).toBe(0);
|
||||
});
|
||||
|
||||
it('没声明的工具不数 —— 声明面就是授权面,不在名单里的本来也数不着', () => {
|
||||
const trail = [{ kind: 'result', title: 'erp.write 返回' }];
|
||||
expect(Object.keys(toolCallReadings(trail, declared))).toEqual([
|
||||
'tool_crm_history_price_read_calls',
|
||||
'tool_mail_send_calls',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { gateOf, rowChanged, writesOf } from '../routes/desk.ts';
|
||||
import { gateAddressesOf, gateOf, rowChanged, writesOf } from '../routes/desk.ts';
|
||||
import { missingReadings } from '../wiring/engines.ts';
|
||||
|
||||
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
|
||||
@@ -103,6 +103,100 @@ describe('提交闸:必填没填就交不了', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* **行级必填 `requiredWhen`:必填钉在该轮到的那一行**(2026-08-02 用户:
|
||||
* 「工作区的 HTML 必填项必须填写了才能提交啊,不填写能算任务完成了吗?」)。
|
||||
*
|
||||
* 逐颗按序派单(A2 的 sel_stage 队列)撞上表级 required 的死结:定案三格整列必填,
|
||||
* 第一颗的卡会被第二颗(还没轮到、件上不可见)的空格拦死;整列不标,闸又没牙,
|
||||
* 空手就能交卷(用户实测点名)。requiredWhen 只在行上指定列等于指定值时才算必填。
|
||||
*/
|
||||
describe('行级必填:requiredWhen 只数该轮到的那一行', () => {
|
||||
const LIST = {
|
||||
domain: 'quote_alternative',
|
||||
object: 'list',
|
||||
fields: [
|
||||
{ name: 'rfq_ref', type: 'text', primaryKey: true },
|
||||
{ name: 'pn', type: 'text', primaryKey: true },
|
||||
{ name: 'alt_pn', type: 'text', primaryKey: false, label: '替代料 PN', requiredWhen: { column: 'sel_stage', value: 'NOW' } },
|
||||
{ name: 'alt_grade', type: 'text', primaryKey: false, label: '替代等级', requiredWhen: { column: 'sel_stage', value: 'NOW' } },
|
||||
],
|
||||
};
|
||||
const read = (rows: Array<Record<string, unknown>>) =>
|
||||
missingReadings({ 'mine:quote_alternative.list': rows }, [LIST]);
|
||||
|
||||
it('**排队(WAIT)的颗不拦当前的卡** —— 只有 NOW 行的空格算缺', () => {
|
||||
const got = read([
|
||||
{ rfq_ref: 'R1', pn: 'A', sel_stage: 'NOW', alt_pn: '', alt_grade: '' },
|
||||
{ rfq_ref: 'R1', pn: 'B', sel_stage: 'WAIT', alt_pn: '', alt_grade: '' },
|
||||
]);
|
||||
expect(got['quote_alternative_list_missing']).toBe(1);
|
||||
expect(got['quote_alternative_list_missing_keys']).toBe('R1/A 缺替代料 PN、替代等级');
|
||||
});
|
||||
|
||||
it('NOW 行定案齐了 → 缺 0,闸开;办结(DONE)的行不再数', () => {
|
||||
const got = read([
|
||||
{ rfq_ref: 'R1', pn: 'A', sel_stage: 'DONE', alt_pn: 'X', alt_grade: 'P2P' },
|
||||
{ rfq_ref: 'R1', pn: 'B', sel_stage: 'NOW', alt_pn: 'Y', alt_grade: 'FUNC' },
|
||||
]);
|
||||
expect(got['quote_alternative_list_missing']).toBe(0);
|
||||
});
|
||||
|
||||
it('**未果撤行之后 NOW 行不存在 → 缺 0** —— 撤行也是一个答案', () => {
|
||||
expect(read([{ rfq_ref: 'R1', pn: 'B', sel_stage: 'WAIT', alt_pn: '', alt_grade: '' }])['quote_alternative_list_missing']).toBe(0);
|
||||
});
|
||||
|
||||
it('不带 requiredWhen 的 required 照旧整列必填 —— 老声明一个字不用改', () => {
|
||||
const plain = {
|
||||
domain: 'd',
|
||||
object: 'o',
|
||||
fields: [
|
||||
{ name: 'k', type: 'text', primaryKey: true },
|
||||
{ name: 'v', type: 'text', primaryKey: false, label: '值', required: true },
|
||||
],
|
||||
};
|
||||
expect(missingReadings({ 'mine:d.o': [{ k: '1', v: '' }] }, [plain])['d_o_missing']).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* **执行卡零输入之后,闸看哪几张表**(2026-08-02 用户定:填写全去工作区)。
|
||||
*
|
||||
* 卡面不再绑 blocks,闸若只认卡上的表就整个失效 —— 缺着必填照样交得回来,
|
||||
* 一路走到放行门才被 L1 拦住,那正是当初立这道闸要治的病。
|
||||
* 回落断了页面上看不出来(按钮照常绿着),所以钉在这儿。
|
||||
*/
|
||||
describe('闸看哪几张表:卡上绑的优先,没绑回落到必交付物声明', () => {
|
||||
const table = (ledger: string) => ({ t: 'table' as const, ledger, cols: [], rows: [], fields: [] });
|
||||
|
||||
it('卡上绑了表 → 看绑的那几张,交付物声明不掺和', () => {
|
||||
expect(
|
||||
gateAddressesOf([table('quote_intake.record')], [{ ledgers: ['a.b', 'c.d'] }]),
|
||||
).toEqual(['quote_intake.record']);
|
||||
});
|
||||
|
||||
it('**卡上没绑表(零输入执行卡)→ 回落到必交付物的 ledgers 全表**', () => {
|
||||
expect(
|
||||
gateAddressesOf(undefined, [{ ledger: 'quote_intake.record', ledgers: ['quote_intake.record', 'quote_intake.verdict'] }]),
|
||||
).toEqual(['quote_intake.record', 'quote_intake.verdict']);
|
||||
});
|
||||
|
||||
it('老清单只给 ledger 单张 → 退它那一张;两边都空才真的没有依据', () => {
|
||||
expect(gateAddressesOf([], [{ ledger: 'quote_sourcing.quote_list' }])).toEqual(['quote_sourcing.quote_list']);
|
||||
expect(gateAddressesOf([], [{}])).toEqual([]);
|
||||
expect(gateAddressesOf(undefined, [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('非表块(文本段)不算绑表 —— 熔断卡带 basis 不带表,照样回落', () => {
|
||||
expect(
|
||||
gateAddressesOf(
|
||||
[{ t: 'p', text: '拒绝理由' } as never],
|
||||
[{ ledgers: ['quote_alternative.list', 'quote_alternative.verdict'] }],
|
||||
),
|
||||
).toEqual(['quote_alternative.list', 'quote_alternative.verdict']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('只写真改了的那几行', () => {
|
||||
const row = { rfq_ref: 'R1', pn: 'NE555P', eau: 30000, brand: 'TI' };
|
||||
|
||||
|
||||
@@ -54,6 +54,14 @@ const ACTION_ITEM_SCHEMA = {
|
||||
reason: { type: 'string' },
|
||||
basis: { type: 'array' },
|
||||
fields: { type: 'array' },
|
||||
/*
|
||||
* 作业指导书两件(2026-08-03 补进 schema):`steps` 一行一条 + `standard` 收口标准。
|
||||
* 它们从前不在这份 schema 里,能到卡面纯靠厂商 JSON 模式宽松放行 ——
|
||||
* 而本仓早有实测纪律:「结构化出参只吐 schema 里声明过的键,光在提示词里交代不算」
|
||||
* (ai/ledger 那两个键当年就是这么丢的)。指导书是执行卡的正文,不能靠运气。
|
||||
*/
|
||||
steps: { type: 'array', items: { type: 'string' }, description: '作业指导书:一行一条,纯文本(不写星号记号)' },
|
||||
standard: { type: 'string', description: '收口标准:办到什么程度算完,一句话' },
|
||||
/*
|
||||
* 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。
|
||||
*
|
||||
@@ -216,6 +224,12 @@ export type AgentRunnerDeps = {
|
||||
readonly modelOf: (taskId: TaskId) => Promise<string>;
|
||||
/** token 回报:计入任务预算 —— 不计,预算熔断就永远不会响 */
|
||||
readonly onUsage: (taskId: TaskId, tokens: number) => Promise<void>;
|
||||
/**
|
||||
* 留一条内部痕(进工作日志,不进对话区)。输出不成形就地重试时用它把
|
||||
* **模型原文**存档 —— 卡面不再摆 JSON 原话(人看着像乱码),原文归日志,
|
||||
* 想尸检的时候有处可查。可选:不给就只重试不留痕。
|
||||
*/
|
||||
readonly note?: (taskId: TaskId, title: string, raw: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/** 可写台账摊成字典:地址 · 中文标 · 主键 · 列名 —— 模型照这个填,不照想象编 */
|
||||
@@ -395,7 +409,7 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
|
||||
const prompt = [
|
||||
'你是这一单任务的案例工作者。下面是语义引擎发布的**当下可为**与**目标缺口**。',
|
||||
'这一拍你给**一串行动**(actions 数组,1-6 个,按序执行):write(写台账草稿)/',
|
||||
'tool(调工具)/ ask(补全卡)/ decide(决策卡)/ human(调人)/ listen(立监听),外加 start(启动须手动启动的项)、',
|
||||
'tool(调工具)/ decide(决策卡)/ human(调人)/ listen(立监听),外加 start(启动一项,可省)、',
|
||||
'plan(规划裁量项)、disable(此单不走这条)、judge(评估语义性前提)、finish(宣告一项完成)、submit(提交放行)。',
|
||||
/*
|
||||
* **写错了有退路**(2026-08-01 加 drop)。没有它的时候实测过一次死胡同:
|
||||
@@ -443,42 +457,42 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
|
||||
' 挂不上(项已完成等)写也照落 —— 写的授权在台账声明,不在计划项。',
|
||||
' tool / ask 的 `itemId` 则要落在**当下可为**里;阶段(stage)只能 start。',
|
||||
/*
|
||||
* **话说的范围要盖住门拦的范围**(2026-08-02 实测逼出来的)。
|
||||
* **「须你启动」不再是一堵墙**(2026-08-02 拆掉)。
|
||||
*
|
||||
* 从前这一句写的是「标了「须你启动」的项要先 start **再调工具**」——
|
||||
* 只说了调工具。而拦截那一侧(`readyToWork`)管的是 tool / ask / human /
|
||||
* decide / entry / finish 一整片。模型那一拍要发的是 `human`(派工单),
|
||||
* 照字面读这条规矩管不着它,于是漏了 start,撞上
|
||||
* 「S3_confirm 标了人工激活,要先 start 再干活」白烧一拍。
|
||||
*
|
||||
* **门拦住的事和话说的事必须是同一件** —— 话窄一格,模型就正好漏在那一格上。
|
||||
* 例外只有 write:它的 itemId 只是记账,挂不上照写不拦(见 turn-runner 的注释)。
|
||||
* 从前标了人工激活的项要求先发显式 `start`,不发就拒;还为「话没盖住门」
|
||||
* 修过一轮措辞(话窄一格,模型正好漏在那一格上)。措辞修全之后实测仍熔断:
|
||||
* A1 一单连拒五拍,五次全是「S3_confirm 要先 start」—— 提示词与拒回都写着,
|
||||
* 它就是学不稳。那道墙也保护不了任何东西:start 是模型自己的动作,
|
||||
* 同一串连发即过。现在**动它就算启动**(引擎就地过 Active),
|
||||
* 「须你启动」只剩它本来的意思:引擎不替你启动、也不给 doneWhen 替人收。
|
||||
*/
|
||||
' 标了「须你启动」的项,**动它之前先 start** —— 调工具、发卡(ask / human / decide / entry)、',
|
||||
' 宣告完成(finish)都算,一拍里 start 排在那个动作前面即可。',
|
||||
' 只有 write 例外:它的 itemId 只是记账,挂不上也照写。',
|
||||
' 标了「须你启动」的项,你**直接动它就算启动**(不必单发 start);',
|
||||
' 它的分量在另一头:引擎不会替你启动它,也不会自动收它 —— 收它要你自己 finish。',
|
||||
'② **系统能干的活不要交给人**:调工具就在「可调工具」里挑,照它的入参键名填 ——',
|
||||
' 把一件工具做得到的事发成一张卡问人,是白占人的时间(实测踩过:该调历史价工具,',
|
||||
' 却发了一张「请查这三个 PN 的历史价」的卡)。触达世界的动作也只能 tool',
|
||||
' (写类会被登记为动作意向,放行那一刻才真执行)。',
|
||||
'③ 调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、',
|
||||
' basis 给出判断依据(键值对数组)、fields 列出要对方回填的字段名。',
|
||||
' 只写「请处理」的卡收卡人无从下手,等于白发一张。**人答回的值在轨迹里,',
|
||||
' 看到了要用 write 落进台账** —— 系统不会替你写。',
|
||||
'④ **缺字段要人补 → ask(补全卡)**:缺口清单摆在对话区、带一张表单,人就地填齐。',
|
||||
' **能填的先替他填上**:格子给 `value` 预填(公司名、联系人姓名这类来件里写着的),',
|
||||
' 人核对一眼比从头敲快得多;你填不出来的(如内部客户编码)留空,别硬凑。',
|
||||
/*
|
||||
* 形状里**必须带 `value`** —— 这一条实测栽过:
|
||||
* 活动剧本让模型"能填的先替人填上",还给了带 value 的 JSON 骨架;
|
||||
* 模型把骨架里的 ask 一字不差抄了过来,`value` 却全是空的 ——
|
||||
* 因为**系统提示词这一行写死的形状里没有它**,活动剧本盖不过系统提示词。
|
||||
* 形状是模型真正照抄的那一份,漏一个键就等于那个键不存在。
|
||||
* ③ 的措辞是一次实测教训(2026-08-02):零输入改造时这里曾写成
|
||||
* 「执行工单不摆表单……**别再 write 一遍**」—— 一句全局禁令,把 A1 说明书里
|
||||
* 「人交回确认卡后要写 confirmed_total 记账」顶翻了:模型收到人的答复后
|
||||
* 不写不收,又觉得这卡没答成,重发同一张卡连拒五拍熔断(用户实测送来的单)。
|
||||
* **全局提示词只说通用真理**:值怎么处理(哪些基座已替写、哪些要记账)
|
||||
* 是各活动说明书的事,别在这儿下一刀切的禁令。
|
||||
*/
|
||||
' 形状:{kind:"ask", itemId, ask:"一句话说清缺口", fields:[{key,label,hint?,value?}], basis:[{k,v}]}。',
|
||||
' `value` = 预填值:来件里读得出的先替人填上(公司名、联系人姓名),读不出的留空。',
|
||||
'③ 调人(human)必须把卡填实:title 一句话说清要什么、ask 写明具体问题、',
|
||||
' basis 给出判断依据(键值对数组);执行工单不摆表单字段(填报在工作区的件上)。',
|
||||
' 只写「请处理」的卡收卡人无从下手,等于白发一张。**人答回的东西在轨迹里,',
|
||||
' 看到了要照说明书处理(该记账的记账)** —— 别当没答过再发一张。',
|
||||
/*
|
||||
* **补全卡(ask)已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
|
||||
* 执行卡完全就是包含补全语义的人类工具」)—— 缺信息也是发执行工单:
|
||||
* 指导书写清缺什么,人在工作区的件上补(保存即落草稿层)。
|
||||
* 旧输出里的 ask 会被基座原地转成执行工单,但别再用它。
|
||||
*/
|
||||
'④ **缺信息要人补 → human(执行工单)**:指导书写清缺哪几样、去哪儿补(工作区的件上),',
|
||||
' 人补完保存、回卡提交 —— 值由基座写回草稿层,你别再 write 一遍;该记账的记账。',
|
||||
'④·2 **要往台账写一行、而那一行该由人过目 → entry(数据录入卡)**:',
|
||||
' 与补全卡的分别在**谁写库**:补全卡收回来的值要你自己再 write 一遍;',
|
||||
' 数据录入卡是**人按下确认那一刻基座直接写**,你不经手 —— 建档这类事该用它,',
|
||||
' 主数据建错一次后面每单都跟着错,那一笔该由人亲手落下。',
|
||||
' 形状:{kind:"entry", itemId, ask, ledger:"域.对象", entryKey:{主键列:值},',
|
||||
@@ -489,8 +503,8 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
|
||||
' 机检判不了,只能你当场裁:{kind:"judge", criterion:"照判据名原样写", satisfied:true/false, rationale:"凭什么"}。',
|
||||
' **提交放行前必须裁完** —— 除「人放行」外还有判据没达标时,submit 会被当场拒。',
|
||||
'④\' **岔路口要人定 → decide(决策卡)**:把几条路摆出来,人点一个 —— 不用敲字。',
|
||||
' 补全卡问**事实**(缺哪几个字段,你填);决策卡问**处置**(往哪走,你选)。',
|
||||
' 两条路都合法、选哪条是业务判断时,发这一张;别拿补全卡去问「你想怎么办」。',
|
||||
' 执行工单问**事实与作业**(办完、补齐);决策卡问**处置**(往哪走,你选)。',
|
||||
' 两条路都合法、选哪条是业务判断时,发这一张;别拿执行工单去问「你想怎么办」。',
|
||||
' 形状:{kind:"decide", itemId, ask, options:[{value,label,consequence?,recommended?}], basis:[{k,v}]}。',
|
||||
' `fields` 一个都不给会被拒 —— 空表单等于没问;一个缺口一个格子,label 写人话。',
|
||||
' 不要拿执行工单(human)去问几个字段 —— 那是「派一件活」,收卡的人得进待处理、',
|
||||
@@ -564,27 +578,44 @@ export function createAgentRunner(deps: AgentRunnerDeps): {
|
||||
|
||||
const model = await deps.modelOf(taskId);
|
||||
try {
|
||||
const result = await deps.gateway.generate({
|
||||
model,
|
||||
prompt,
|
||||
outputSchema: TURN_SCHEMA,
|
||||
// 规划型出参要的是稳,不是花样 —— 低温少绕弯,解码就是墙上时钟
|
||||
temperature: 0.2,
|
||||
/*
|
||||
* 关思考 —— 三档都实测过(A1 同一单):
|
||||
* 由着想:拍 9-56s,最慢拍 9.8k 字隐藏思考,八成五的时间在解码没人读的字;
|
||||
* 'low':这家厂商上没用 —— 推理仍 5-9.7k 字、拍 24-45s,和不压一样;
|
||||
* 'off':拍 2-4s(十倍差)。
|
||||
* off 暴露过一次业务误判(把缺目标价当关键字段拦单)—— 治法是把规则写死进
|
||||
* 说明书(「关键字段只有 PN/EAU/交期 三个」),不是给模型留思考额度:
|
||||
* 这个架构里判断本就归判定表与说明书,模型只做编排。
|
||||
*/
|
||||
reasoning: 'off',
|
||||
taskId,
|
||||
label: 'loop-turn',
|
||||
});
|
||||
await deps.onUsage(taskId, result.usage.inputTokens + result.usage.outputTokens);
|
||||
return normalizeTurn(result.object, enabled, result.text);
|
||||
/*
|
||||
* **输出不成形先就地重试,别一抖就叫人**(2026-08-03 用户实测送来的单)。
|
||||
*
|
||||
* 关思考的模型偶发把这一拍的输出写坏(多个闭括号那种已由括号栈自修;
|
||||
* 这次是另一形状)。从前一次没认出就直接发「需要你判断下一步」的兜底卡,
|
||||
* 还把模型的 JSON 原话整段摆在卡面上 —— 人打开工单看到的是一坨代码
|
||||
* (用户原话:「卡片里又是乱七八糟的东西」)。
|
||||
* 现在:第一次没认出 → 原文存进工作日志(内部痕,留档好尸检),
|
||||
* 附一句「只输出 JSON」就地再问一次;第二次仍不成形才交人,
|
||||
* 而卡面只说人话(原文在日志里,不上卡)。
|
||||
*/
|
||||
let hint = '';
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
const result = await deps.gateway.generate({
|
||||
model,
|
||||
prompt: hint === '' ? prompt : `${prompt}\n\n${hint}`,
|
||||
outputSchema: TURN_SCHEMA,
|
||||
// 规划型出参要的是稳,不是花样 —— 低温少绕弯,解码就是墙上时钟
|
||||
temperature: 0.2,
|
||||
/*
|
||||
* 关思考 —— 三档都实测过(A1 同一单):
|
||||
* 由着想:拍 9-56s,最慢拍 9.8k 字隐藏思考,八成五的时间在解码没人读的字;
|
||||
* 'low':这家厂商上没用 —— 推理仍 5-9.7k 字、拍 24-45s,和不压一样;
|
||||
* 'off':拍 2-4s(十倍差)。
|
||||
* off 暴露过一次业务误判(把缺目标价当关键字段拦单)—— 治法是把规则写死进
|
||||
* 说明书(「关键字段只有 PN/EAU/交期 三个」),不是给模型留思考额度:
|
||||
* 这个架构里判断本就归判定表与说明书,模型只做编排。
|
||||
*/
|
||||
reasoning: 'off',
|
||||
taskId,
|
||||
label: 'loop-turn',
|
||||
});
|
||||
await deps.onUsage(taskId, result.usage.inputTokens + result.usage.outputTokens);
|
||||
const turn = normalizeTurn(result.object, enabled);
|
||||
if (!turn.unrecognized || attempt >= 2) return turn;
|
||||
await deps.note?.(taskId, '这一拍的输出没成形(不是合法的行动串),就地重问一次', (result.text ?? '').slice(0, 2000));
|
||||
hint = '【重要】你上一次的输出不是合法的行动 JSON,整拍作废了。只输出一个 JSON 对象,形如 {"say":"…","actions":[{"kind":"…",…}]},不要输出任何 JSON 之外的文字。';
|
||||
}
|
||||
} catch (error) {
|
||||
/*
|
||||
* 模型这一轮问不出结果(端点不可达、鉴权失败、超时…)。
|
||||
@@ -631,13 +662,14 @@ const KNOWN_KINDS = new Set([
|
||||
*
|
||||
* 三种来路都认:`{actions:[…]}`(规范形)、裸行动对象(旧形 —— 包成单元素串)、
|
||||
* 顶层就是数组。串里**不认识的项丢掉**(认识不认识由 KNOWN_KINDS 定);
|
||||
* 整串一个都不认识才退化为单张「转人裁断」卡(把模型原话摆上去)——
|
||||
* 整串一个都不认识才标 `unrecognized` 退化为单张「转人裁断」卡 ——
|
||||
* 不能让串里一个写坏的项在批中途给人甩一张卡。
|
||||
* 调用方(decide)看到 `unrecognized` 先就地重问一次,原文进工作日志;卡面不摆 JSON。
|
||||
*
|
||||
* 交代**不是一型行动**:一开始把它做成 `kind: 'say'`,结果它占一整拍又不推进局面,
|
||||
* 预算下的模型理所当然跳过它 —— 对话区照旧空白(实测)。所以它是拍级的附带字段。
|
||||
*/
|
||||
export function normalizeTurn(object: unknown, enabled: EnabledSet, rawText?: string): AgentTurn {
|
||||
export function normalizeTurn(object: unknown, enabled: EnabledSet): AgentTurn & { unrecognized?: true } {
|
||||
const raw = (Array.isArray(object) ? { actions: object } : (object ?? {})) as Record<string, unknown>;
|
||||
const text = String(raw['say'] ?? raw['text'] ?? '').trim();
|
||||
const tag = raw['tag'];
|
||||
@@ -649,11 +681,12 @@ export function normalizeTurn(object: unknown, enabled: EnabledSet, rawText?: st
|
||||
.filter((a) => KNOWN_KINDS.has(String(a['kind'] ?? a['action'] ?? '')));
|
||||
const actions =
|
||||
recognized.length > 0
|
||||
? recognized.slice(0, TURN_ACTION_CAP).map((a) => normalizeAction(a, enabled, rawText))
|
||||
: [normalizeAction(source[0] ?? {}, enabled, rawText)];
|
||||
? recognized.slice(0, TURN_ACTION_CAP).map((a) => normalizeAction(a, enabled))
|
||||
: [normalizeAction(source[0] ?? {}, enabled)];
|
||||
|
||||
return {
|
||||
actions,
|
||||
...(recognized.length === 0 ? { unrecognized: true as const } : {}),
|
||||
...(text === ''
|
||||
? {}
|
||||
: {
|
||||
@@ -842,7 +875,7 @@ function normalizeRows(raw: unknown): Array<{ key: Record<string, string | numbe
|
||||
* 模型出参归一为 AgentAction —— 导出是为了让「宽进到什么程度」有测试守着。
|
||||
* 宽进的每一条都对应一次实测踩坑;不合规的行动仍由引擎拒,这里不放行为。
|
||||
*/
|
||||
export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?: string): AgentAction {
|
||||
export function normalizeAction(object: unknown, enabled: EnabledSet): AgentAction {
|
||||
const raw = (object ?? {}) as Record<string, unknown>;
|
||||
/*
|
||||
* `kind` 也认 `action` —— 实测里模型把这一格叫 `action`(其余字段一字不差全对),
|
||||
@@ -892,26 +925,41 @@ export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?:
|
||||
};
|
||||
case 'ask': {
|
||||
/*
|
||||
* 格子与问题**两处都认**:`fields` / `ask` 在顶层,或落在 `card` 里。
|
||||
* 提示词里执行工单的写法是 `card.fields`,模型很自然把补全卡也那么写 ——
|
||||
* 实测它就是这么写的,于是每一拍都被「fields 为空」拒回来。
|
||||
* 宽进不是纵容:空不空引擎照验,这里只是不让一个位置差异吃掉一整拍。
|
||||
* **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
|
||||
* 执行卡完全就是包含补全语义的人类工具」)—— 「问人补信息」不再是
|
||||
* 一种单独的卡:发一张零输入的执行工单,指导书写清缺什么、
|
||||
* 人在工作区的件上补(保存即落草稿层)或办完确认。
|
||||
*
|
||||
* 这里做的是**归一而不是拒**:旧说明书、旧模型输出里的 `ask` 照收,
|
||||
* 原地转成执行工单 —— 拒回只会让模型换着花样再发一遍。
|
||||
* `fields` 不上卡(执行卡零输入),但字段名并进指导书,
|
||||
* 人知道要补的是哪几样、补在件上。
|
||||
*/
|
||||
const nested = (typeof raw['card'] === 'object' && raw['card'] !== null ? raw['card'] : {}) as Record<string, unknown>;
|
||||
const asked = String(raw['ask'] ?? nested['ask'] ?? '').trim();
|
||||
const wanted = normalizeFields(raw['fields'] ?? nested['fields'])
|
||||
.map((f) => ((f.label ?? '') === '' ? f.key : (f.label ?? '')))
|
||||
.filter((x) => x !== '');
|
||||
const basis = Array.isArray(raw['basis'])
|
||||
? raw['basis']
|
||||
.filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b)
|
||||
.map((b) => ({ k: String(b.k), v: String(b.v) }))
|
||||
: [];
|
||||
return {
|
||||
kind: 'ask',
|
||||
kind: 'human',
|
||||
itemId,
|
||||
ask: String(raw['ask'] ?? nested['ask'] ?? ''),
|
||||
fields: normalizeFields(raw['fields'] ?? nested['fields']),
|
||||
...(Array.isArray(raw['basis'])
|
||||
? {
|
||||
basis: raw['basis']
|
||||
.filter((b): b is { k: unknown; v: unknown } => typeof b === 'object' && b !== null && 'k' in b)
|
||||
.map((b) => ({ k: String(b.k), v: String(b.v) })),
|
||||
}
|
||||
: {}),
|
||||
workOrder: 'execution',
|
||||
card: {
|
||||
title: asked === '' ? '请补齐这一单缺的信息' : asked.slice(0, 60),
|
||||
ask: asked === '' ? '缺的信息见下面的依据:补在工作区的件上,补完回这张卡提交。' : asked,
|
||||
...(wanted.length === 0
|
||||
? {}
|
||||
: { steps: [`要补的是:${wanted.join('、')} —— 在工作区的件上就地填,保存后回这张卡提交;确实拿不到的,备注里写明缘由`] }),
|
||||
basis,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'entry': {
|
||||
/*
|
||||
* 数据录入卡 —— 与补全卡同款的宽进(顶层或 `card` 里都认)。
|
||||
@@ -1021,19 +1069,19 @@ export function normalizeAction(object: unknown, enabled: EnabledSet, rawText?:
|
||||
/*
|
||||
* 看不懂就等人,不擅自触达世界。
|
||||
*
|
||||
* 但**要把模型的原话摆在卡上**:只写「模型未给出合规的行动」,收卡的人无从判断
|
||||
* 是模型跑偏了、还是行动型少了一种、还是提示词没交代清楚 —— 而这三种的处置完全不同。
|
||||
* 界面不撒谎的另一半是:不确定的时候,把手上的原始证据交出去。
|
||||
* 卡面**只说人话,不摆模型的 JSON 原话**(2026-08-03 用户实测:原话上卡,
|
||||
* 人打开工单看到的是一坨代码 ——「卡片里又是乱七八糟的东西」)。
|
||||
* 原始证据仍然要留:decide 那一侧在重问之前已把原文存进工作日志(内部痕),
|
||||
* 想分辨是模型跑偏、行动型少了一种、还是提示词没交代清楚,去日志里看。
|
||||
*/
|
||||
return {
|
||||
kind: 'human',
|
||||
itemId,
|
||||
card: {
|
||||
title: '需要你判断下一步',
|
||||
reason: '模型未给出合规的行动',
|
||||
modelReply: rawText === undefined || rawText === '' ? '(空)' : rawText.slice(0, 1200),
|
||||
reason: '模型这一拍没给出合规的行动,就地重问一次仍未成形;它的原话在这一单的工作日志里,不摆到卡上',
|
||||
kindSeen: kind === '' ? '(未给 kind)' : kind,
|
||||
ask: '请判断下一步:接着走哪一项,或补齐缺口后让环继续。',
|
||||
ask: '请判断下一步:接着走哪一项,或直接办完当前这一步让环继续。',
|
||||
fields: ['decision'],
|
||||
},
|
||||
workOrder: 'execution',
|
||||
|
||||
@@ -497,8 +497,11 @@ async function applyBoundTableEdits(
|
||||
* (都走 `missingReadings`)。同源是要紧的:两处各算各的,就会出现
|
||||
* 「卡上说能交、放行门说不行」——那种矛盾人是判不出对错的。
|
||||
*
|
||||
* 只对**绑了台账表**的执行卡出闸:算得出才拦,算不出(卡上没绑表)就不给闸,
|
||||
* 按钮照常能按 —— **没有依据的时候不拦人**。
|
||||
* 看哪几张表:卡上绑了表就看绑的那几张;**卡上没绑表就回落到必交付物的台账声明**
|
||||
* (`requiredDeliverables` 的 `ledgers`/`ledger`)—— 执行卡零输入之后(2026-08-02
|
||||
* 用户定:「填写全去工作区」)卡面不再绑 blocks,闸若只认卡上的表就整个失效,
|
||||
* 缺着必填照样交得回来,一路走到放行门才被 L1 拦住(那正是当初立这道闸要治的病)。
|
||||
* 两条路都算不出(连交付物都没声明台账)才不给闸 —— **没有依据的时候不拦人**。
|
||||
*/
|
||||
async function submitGate(
|
||||
container: Container,
|
||||
@@ -506,13 +509,13 @@ async function submitGate(
|
||||
order: WorkOrder,
|
||||
): Promise<{ ready: boolean; why: string; blanks: string[] } | undefined> {
|
||||
if (order.mode !== 'execution' || order.done !== undefined) return undefined;
|
||||
const addresses = (order.blocks ?? [])
|
||||
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
|
||||
.map((b) => b.ledger as string);
|
||||
const task = await container.task.get(taskId).catch(() => undefined);
|
||||
if (task === undefined) return undefined;
|
||||
const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined);
|
||||
if (manifest === undefined) return undefined;
|
||||
const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables);
|
||||
if (addresses.length === 0) return undefined;
|
||||
const declarations = await declarationsOf(container, taskId);
|
||||
if (declarations === undefined) return undefined;
|
||||
const watched = declarations.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
|
||||
const watched = manifest.declarations.ledger.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
|
||||
if (watched.length === 0) return undefined;
|
||||
|
||||
// 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`)
|
||||
@@ -523,6 +526,23 @@ async function submitGate(
|
||||
return gateOf(missingReadings(mine, watched), watched);
|
||||
}
|
||||
|
||||
/**
|
||||
* 闸看哪几张表:卡上绑了表就看绑的那几张;没绑(执行卡零输入之后是常态)
|
||||
* 回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
|
||||
* 两边都空才真的没有依据。抽成纯函数是为了钉测 —— 回落断了闸就整个失效,
|
||||
* 而失效的闸在页面上看不出来(按钮照常绿着)。
|
||||
*/
|
||||
export function gateAddressesOf(
|
||||
blocks: ReadonlyArray<ViewBlock> | undefined,
|
||||
deliverables: ReadonlyArray<{ ledger?: string | undefined; ledgers?: ReadonlyArray<string> | undefined }>,
|
||||
): string[] {
|
||||
const bound = (blocks ?? [])
|
||||
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
|
||||
.map((b) => b.ledger as string);
|
||||
if (bound.length > 0) return bound;
|
||||
return deliverables.flatMap((d) => [...(d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger]))]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读数 → 闸。**数与名单都来自基座的读数**,这里只负责说人话。
|
||||
*
|
||||
@@ -913,6 +933,22 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
}
|
||||
}
|
||||
const outcome = await container.humanTool.replyWorkOrder(parsed.data, staff.id);
|
||||
/*
|
||||
* **办结那一刻,把当时的件原样存进这张工单**(2026-08-02 用户:「HTML UI、
|
||||
* 数据、内容、布局都要 1:1 地从待处理转换为静态报告放到工作成果」)。
|
||||
* 成果页渲的是这份快照 —— 不再渲「当下」的任务视图(随后续推进会变),
|
||||
* 更不退回卡壳。快照失败不拦办结(留痕即可,人已交卷)。
|
||||
*/
|
||||
try {
|
||||
const shot = await container.interaction.viewOf(outcome.taskId);
|
||||
if (shot?.html !== undefined) {
|
||||
await container.db.execute(
|
||||
sql`update sys.work_order set payload = jsonb_set(payload, '{finalHtml}', ${JSON.stringify(shot.html)}::jsonb) where id = ${parsed.data.workOrderId}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* 件还没推过就没有快照 —— 成果页会退回旧路,不拦人交卷 */
|
||||
}
|
||||
/*
|
||||
* **绑台账的可编辑表:人改了什么,基座当场写回草稿层**(2026-08-01)。
|
||||
*
|
||||
|
||||
@@ -246,6 +246,7 @@ export function missingReadings(
|
||||
primaryKey: boolean;
|
||||
required?: boolean | undefined;
|
||||
expect?: boolean | undefined;
|
||||
requiredWhen?: { column: string; value: string } | undefined;
|
||||
}>;
|
||||
}>,
|
||||
): Record<string, string | number> {
|
||||
@@ -260,17 +261,23 @@ export function missingReadings(
|
||||
* · `required` → `_missing` 缺必填几行(**不齐不许放行**,是验收标准);
|
||||
* · `expect` → `_gaps` 缺选填几行(不拦单,但**那一问不能省**)。
|
||||
* 缺口摆的是**中文列名**:这两个数会进拒回与卡面,给人看的地方不摆列名。
|
||||
*
|
||||
* `requiredWhen` 把必填钉在**该轮到的那一行**(2026-08-02,逐颗派单的闸):
|
||||
* 行上那一列不等于指定值,这一格就还没轮到,不算缺 —— 排队的颗不拦当前的卡。
|
||||
*/
|
||||
for (const [mark, suffix] of [
|
||||
['required', 'missing'],
|
||||
['expect', 'gaps'],
|
||||
] as const) {
|
||||
const watched = fields.filter((f) => f[mark] === true);
|
||||
const watched = fields.filter((f) => f[mark] === true || (mark === 'required' && f.requiredWhen !== undefined));
|
||||
if (watched.length === 0) continue;
|
||||
const bad: string[] = [];
|
||||
for (const row of rows) {
|
||||
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
|
||||
const gaps = watched.filter((f) => blank(one, f.name));
|
||||
const due = watched.filter(
|
||||
(f) => f.requiredWhen === undefined || String(one[f.requiredWhen.column] ?? '') === f.requiredWhen.value,
|
||||
);
|
||||
const gaps = due.filter((f) => blank(one, f.name));
|
||||
if (gaps.length === 0) continue;
|
||||
const id: Record<string, string> = {};
|
||||
for (const k of keys) if (!blank(one, k)) id[k] = String(one[k]);
|
||||
@@ -323,6 +330,40 @@ export function tallyReadings(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **这一单到底调没调那个工具** —— `{ 'tool_crm_history_price_read_calls': N }`。
|
||||
*
|
||||
* 「机检只读基座数」这条纪律此前只覆盖到台账(`_rows` / `_missing` / tally),
|
||||
* **覆盖不到工具**:一条判据若要说「转 A 的依据来自本单亲查的行情」,
|
||||
* 从前只能读活动自报的列 —— 而模型偶发压根没调工具就把料判成「行情稳」,
|
||||
* 自报的那一列照样写得漂亮(A2 实测约十次一次,去向就此走岔)。
|
||||
* 说明书已经写到头(「这一拍亲自查 alt_pn」「不许拿上游的数顶替」),
|
||||
* **措辞治不了的结构治**(设计律第六条)。
|
||||
*
|
||||
* 工具出参本来就入痕(`turn-runner` 每次调完记一条 `kind:'result'`、
|
||||
* 标题 `${tool} 返回`),所以「调没调」是**基座数得出来的事实**,不必让活动自报。
|
||||
*
|
||||
* 只数**成功返回**的那几次:调失败等于没查到行情,不该拿来当依据。
|
||||
* 名字取 `tool_<工具名去符号>_calls`(S-FEEL 不认点号与横杠);
|
||||
* 声明过的工具**一律先铺 0** —— 与 `_rows` 同一个理由:未绑定的名字参与比较求值为假,
|
||||
* 「没调过」与「调了 0 次」在业务上是同一件事,读数上也得是。
|
||||
*/
|
||||
export function toolCallReadings(
|
||||
trail: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
|
||||
declared: ReadonlyArray<{ readonly name: string }>,
|
||||
): Record<string, number> {
|
||||
const key = (tool: string): string => `tool_${tool.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`;
|
||||
const out: Record<string, number> = {};
|
||||
for (const { name } of declared) out[key(name)] = 0;
|
||||
for (const entry of trail) {
|
||||
if (entry.kind !== 'result') continue;
|
||||
for (const { name } of declared) {
|
||||
if (entry.title.startsWith(`${name} 返回`)) out[key(name)] = (out[key(name)] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function rowKeyList(
|
||||
mine: Readonly<Record<string, readonly unknown[]>>,
|
||||
declared: ReadonlyArray<{ domain: string; object: string; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> }>,
|
||||
@@ -752,8 +793,19 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
* 而判据要写成 `quote_intake_record_rows = line_total` 这样一句才有用。
|
||||
* 只数**本单自己写下的行**(草稿层),别单放行过的记录不算在内。
|
||||
*/
|
||||
const declared = (await deps.registry.manifestOf(task.activityId)).declarations.ledger;
|
||||
return { ...scope, ...rowReadings(mine, declared), ...rowKeyList(mine, declared), ...missingReadings(mine, declared), ...tallyReadings(mine, declared), ...mine };
|
||||
const manifest = await deps.registry.manifestOf(task.activityId);
|
||||
const declared = manifest.declarations.ledger;
|
||||
// 工具调用是**留痕里的事实**:判据要说「亲查过行情」,得有一边不是活动自报的
|
||||
const trail = await deps.memory.window(taskId, 400);
|
||||
return {
|
||||
...scope,
|
||||
...rowReadings(mine, declared),
|
||||
...rowKeyList(mine, declared),
|
||||
...missingReadings(mine, declared),
|
||||
...tallyReadings(mine, declared),
|
||||
...toolCallReadings(trail, manifest.declarations.tools),
|
||||
...mine,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 本活动声明过的台账对象 —— 清单 `declarations.ledger` 就是这份名单。
|
||||
@@ -1447,7 +1499,13 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
await cardSpan?.end({ output: { cardId: submitted.workOrderId, precheck: submitted.report.verified.length } });
|
||||
return { id: submitted.workOrderId };
|
||||
}
|
||||
const fields = declaredFields ?? [{ key: 'result', label: '结果', kind: 'textarea' as const, required: true }];
|
||||
/*
|
||||
* **执行卡不带表单字段**(2026-08-02 用户定:「填写全去工作区」)——
|
||||
* 填报的地方是工作区那份件(editGate + 孪生件 + 增删行),卡上只剩
|
||||
* 指导书、依据与提交钮。从前这里兜底一格必填「结果」textarea:
|
||||
* 活动没声明字段就替它造一个,人对着一张没处下笔的卡还得编一句话才按得下提交。
|
||||
*/
|
||||
const fields = declaredFields ?? [];
|
||||
/*
|
||||
* 卡面正文块 —— **人要在数据上作判断,数据就得摆在表单旁边**。
|
||||
*
|
||||
@@ -1859,7 +1917,24 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
},
|
||||
},
|
||||
})
|
||||
: createAgentRunner({ gateway: tracedGateway, onUsage: onAgentUsage, modelOf });
|
||||
: createAgentRunner({
|
||||
gateway: tracedGateway,
|
||||
onUsage: onAgentUsage,
|
||||
modelOf,
|
||||
/*
|
||||
* 输出不成形就地重问时,把模型原文存进工作日志(内部痕)——
|
||||
* 卡面从此不摆 JSON 原话,尸检去日志里看。stamp 与 memoryRecorder 同款兜底。
|
||||
*/
|
||||
note: async (taskId, title, raw) => {
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'result',
|
||||
title,
|
||||
detail: { internal: true, raw },
|
||||
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/* ── loop-engine ──────────────────────────────────────────────── */
|
||||
const loop = createTurnRunner({
|
||||
|
||||
@@ -269,3 +269,29 @@ describe('行数读数认得出,写错的照旧拒装', () => {
|
||||
expect(codes(manifest({ predicate: 'd_recrod_rows = line_total' }))).toContain('acceptance-predicate-unbound');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **工具调用读数也算绑得上**(2026-08-03)——
|
||||
* 基座按留痕数「这一单成功调了几次某工具」(`tool_<工具名去符号>_calls`),
|
||||
* 声明过的工具一律先铺 0。判据的两边不能都由活动自报:A2 实测约十次一次,
|
||||
* 判稳那一步压根没调行情工具就把料判成「稳」转 A,自报的依据列却写得漂亮。
|
||||
*/
|
||||
describe('工具调用读数绑得上', () => {
|
||||
const withTool = (predicate: string) =>
|
||||
acceptanceLevelFindings({
|
||||
contract: {
|
||||
acceptanceCriteria: [{ key: 'k', label: 'l', level: 'L2', predicate }],
|
||||
input: { data: { type: 'object', properties: {} } },
|
||||
},
|
||||
declarations: { ledger: [], tools: [{ name: 'crm.history-price-read' }] },
|
||||
assets: { businessModel: { cmmnXml: '' } },
|
||||
} as never);
|
||||
|
||||
it('声明过的工具 → `tool_..._calls` 绑得上', () => {
|
||||
expect(withTool('tool_crm_history_price_read_calls > 0')).toEqual([]);
|
||||
});
|
||||
|
||||
it('没声明的工具照旧拒装 —— 声明面就是授权面,写错一个字当场拦下', () => {
|
||||
expect(withTool('tool_erp_write_calls > 0').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,9 @@ export type AcceptanceAuditInput = {
|
||||
readonly contract: Pick<ActivityManifest['contract'], 'acceptanceCriteria'> & {
|
||||
readonly input: Pick<ActivityManifest['contract']['input'], 'data'>;
|
||||
};
|
||||
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'>;
|
||||
// tools 也要:判据能读「这一单调了几次某工具」(`tool_..._calls`,基座按留痕数)
|
||||
readonly declarations: Pick<ActivityManifest['declarations'], 'ledger'> &
|
||||
Partial<Pick<ActivityManifest['declarations'], 'tools'>>;
|
||||
readonly assets: {
|
||||
readonly businessModel: Pick<ActivityManifest['assets']['businessModel'], 'cmmnXml'>;
|
||||
};
|
||||
@@ -290,6 +292,19 @@ function boundNames(manifest: AcceptanceAuditInput): ReadonlySet<string> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
* **工具调用读数** `tool_<工具名去符号>_calls`(2026-08-03 起)——
|
||||
* 基座按留痕数「这一单成功调了几次某工具」,声明过的工具一律先铺 0。
|
||||
*
|
||||
* 它存在的理由与 `_rows` 一模一样:**判据的两边不能都由活动自报**。
|
||||
* A2 实测约十次一次 —— 判稳那一步压根没调行情工具就把料判成「稳」转 A,
|
||||
* 而自报的依据列写得漂漂亮亮,去向就此从寻源走成定价。
|
||||
* 有了这个名字,「转 A 的依据来自本单亲查的行情」才是一条真机检。
|
||||
*/
|
||||
// 旧调用方(与老测试)只传 ledger —— 缺 tools 就当没声明工具,别掀翻整份审计
|
||||
for (const tool of manifest.declarations.tools ?? []) {
|
||||
out.add(`tool_${tool.name.replace(/[^0-9a-zA-Z]+/g, '_')}_calls`);
|
||||
}
|
||||
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]);
|
||||
|
||||
@@ -68,8 +68,9 @@ describe('工单形态机检', () => {
|
||||
it('规范的执行工单通过', () => {
|
||||
expect(checkWorkOrder(execution())).toEqual([]);
|
||||
});
|
||||
it('执行工单 = 指导书 + 空表单,表单不得为空', () => {
|
||||
expect(checkWorkOrder(execution({ fields: [] })).map((v) => v.code)).toContain('fields-missing');
|
||||
it('**执行工单空表单是常态** —— 填写全在工作区的件上,零字段照发(2026-08-02 用户定)', () => {
|
||||
// 从前这儿拒发空表单;通道换到件上之后,那道拒发让活动照新规矩发卡被基座连拒到熔断(实测)
|
||||
expect(checkWorkOrder(execution({ fields: [] }))).toEqual([]);
|
||||
});
|
||||
it('radio 字段缺选项即拒', () => {
|
||||
const v = checkWorkOrder(execution({ fields: [{ key: 'x', label: 'X', kind: 'radio' }] }));
|
||||
|
||||
@@ -43,9 +43,14 @@ export function checkWorkOrder(order: WorkOrder): CardViolation[] {
|
||||
function checkExecution(order: ExecutionWorkOrder): CardViolation[] {
|
||||
const out: CardViolation[] = [];
|
||||
if (order.ask.trim() === '') out.push({ code: 'ask-missing', message: '执行工单必须给出工作要求' });
|
||||
if (order.fields.length === 0) {
|
||||
out.push({ code: 'fields-missing', message: '执行工单 = 指导书 + 空表单,表单不得为空' });
|
||||
}
|
||||
/*
|
||||
* **执行卡零输入**(2026-08-02 用户定:「执行卡片不要有任何填写的了,填写全去
|
||||
* 工作区」)—— 空表单是常态:填报在工作区的件上(editGate + 孪生件),
|
||||
* 卡上只剩指导书、依据与提交钮。从前这里拒发空表单(「执行工单 = 指导书 +
|
||||
* 空表单,表单不得为空」),那一版把表单当交付通道;通道换到件上之后,
|
||||
* 这道拒发就成了活动照新规矩发卡、基座按旧规矩连拒到熔断(实测 A1 五拍)。
|
||||
* 带字段的卡(如熔断卡的 decision)照旧合法 —— 只是不再强制。
|
||||
*/
|
||||
out.push(...checkFieldKeys(order.fields));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -199,18 +199,48 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
const values = reply.mode === 'acceptance' ? reply.values : reply.values;
|
||||
await deps.cards.markWorkOrderDone(reply.workOrderId, { at, by, result, values });
|
||||
|
||||
/*
|
||||
* 出口:**算出来的优先,而且在服务端算数**(2026-08-02 修)。
|
||||
*
|
||||
* 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了
|
||||
* (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数,
|
||||
* 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。
|
||||
*
|
||||
* 这段话从前只对**返回值**成立:抛回任务的人事件载荷是原始 reply,
|
||||
* 而放行落地读的恰是事件载荷 —— 于是「routedPort 顶上」只发生在没人看的那一份里,
|
||||
* 浏览器靠自己把算出的口塞进回传才活着(chat-bubbles 那段注释),
|
||||
* 换个客户端(脚本、API 调用)就死在「选择组未选出口」(A2 e2e 实测)。
|
||||
* **服务端说了算的事,不靠每个客户端自觉** —— 事件与返回值现在用同一份有效口。
|
||||
*/
|
||||
const routed = (found.order as AcceptanceWorkOrder).routedPort;
|
||||
const effectivePorts: Readonly<Record<string, string>> =
|
||||
reply.mode !== 'acceptance' ? {} : routed !== undefined ? { [routed.group]: routed.name } : reply.selectedPorts;
|
||||
|
||||
// 回传即留痕:谁、何时、答了什么、依据什么,随卡入任务记忆
|
||||
/*
|
||||
* **空答复要说成人话**(2026-08-02 用户实测送来的熔断单):执行卡零输入之后,
|
||||
* 人「看过、确认、提交」的回传 `values` 就是空的 —— 痕上一个值都没有,
|
||||
* 在模型眼里像「没答成」,于是把同一张卡再发一遍(连发五拍熔断)。
|
||||
* 空不是没答:**是人照原样确认了**(件上若改过,那几笔已由保存那一刻落进草稿层)。
|
||||
*/
|
||||
const blank = reply.mode === 'execution' && Object.keys(values).length === 0;
|
||||
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, mode: reply.mode, ...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}) },
|
||||
detail: {
|
||||
by,
|
||||
values,
|
||||
mode: reply.mode,
|
||||
...(blank ? { note: '人已办完并交回(卡上无表单,改动若有已在件上保存时落进草稿层)—— 接着办说明书里「人交回之后」的事' } : {}),
|
||||
...(reply.mode === 'acceptance' ? { comment: reply.comment } : {}),
|
||||
},
|
||||
});
|
||||
// 回传经统一入口以人事件抛回任务
|
||||
// 回传经统一入口以人事件抛回任务 —— 载荷带的是**有效口**,不是人碰巧发来的那份
|
||||
await deps.events.publishHumanEvent({
|
||||
taskId: found.taskId,
|
||||
cardId: reply.workOrderId,
|
||||
payload: { ...reply },
|
||||
payload: { ...reply, ...(reply.mode === 'acceptance' ? { selectedPorts: effectivePorts } : {}) },
|
||||
});
|
||||
|
||||
const updated = await deps.cards.getWorkOrder(reply.workOrderId);
|
||||
@@ -223,19 +253,7 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
...(reply.mode === 'acceptance' && reply.verdict !== 'pass'
|
||||
? { rejection: { kind: reply.verdict, comment: reply.comment ?? '' } }
|
||||
: {}),
|
||||
/*
|
||||
* 出口:**算出来的优先**。
|
||||
*
|
||||
* 契约给每个出口写了 `when`,放行那一刻这一口就已经由基座按序求值定下了
|
||||
* (`routedPort`),人回传里带不带都不作数 —— 去向是台账那几个数的函数,
|
||||
* 不是一道让人点的选择题。算不出来的组才退回 `selectedPorts`。
|
||||
*/
|
||||
selectedPorts:
|
||||
reply.mode !== 'acceptance'
|
||||
? {}
|
||||
: (order as AcceptanceWorkOrder).routedPort !== undefined
|
||||
? { [(order as AcceptanceWorkOrder).routedPort!.group]: (order as AcceptanceWorkOrder).routedPort!.name }
|
||||
: reply.selectedPorts,
|
||||
selectedPorts: effectivePorts,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -165,13 +165,21 @@ export function renderRows(html: string, scope: Readonly<Record<string, unknown>
|
||||
const body = rows
|
||||
.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);
|
||||
return renderTemplate(uniqueNames(editable ? twinInputs(inner, index) : inner, index), one);
|
||||
})
|
||||
.join('');
|
||||
return `${open}${body}</${tag}>`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* **行内表单组名唯一化** —— 行模版里手写的 radio 组(如 A2 替代等级三选)带着
|
||||
* 固定 `name`;行复制 N 份后同名 radio 在整份文档里成了一组,第 2 页点一下
|
||||
* 会把第 1 页已选的顶掉。每行的 name 缀上行号,组内互斥只在本行内发生。
|
||||
*/
|
||||
const uniqueNames = (inner: string, rowIndex: number): string =>
|
||||
inner.replace(/\bname\s*=\s*"([^"]+)"/g, (_m, n: string) => `name="${n}__r${rowIndex}"`);
|
||||
|
||||
/** 带 `data-col` 的取值元素:`<span … data-slot="pn" data-col="PN">` */
|
||||
const EDITABLE_CELL = /<(span|b)\b([^>]*?\bdata-col\s*=\s*"([^"]+)"[^>]*)><\/\1>/g;
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 结构化出参的提取 —— 两次实测栽跟头,各钉一条:
|
||||
*
|
||||
* ① 模型一次吐两个对象(把下一拍也写了):按第一个配平的取,后面当噪声。
|
||||
* ② **关思考的模型偶发多吐一个闭括号**(2026-08-02,A2 案例 0 实测):
|
||||
* `…"type_tag":"A"}}]}},{"kind":…` —— 该是 `}}]},`。从前只数花括号,
|
||||
* 多出的 `}` 把计数带到零,切出的半截连 `[` 都没关上,解析失败;
|
||||
* 整拍六个动作全对,却退化成一张「需要你判断下一步」的兜底卡。
|
||||
* 括号栈知道当下该关谁:对不上的闭括号当噪声丢(不发明内容,只丢一个多余字符)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseJsonLoose } from '../internal/adapter/ai-sdk-provider.ts';
|
||||
|
||||
describe('parseJsonLoose:模型出参的宽容提取', () => {
|
||||
it('干净 JSON 原样过', () => {
|
||||
expect(parseJsonLoose('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('```json 围栏剥掉', () => {
|
||||
expect(parseJsonLoose('```json\n{"a":1}\n```')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('一次吐两个对象:取第一个配平的,后面当噪声', () => {
|
||||
expect(parseJsonLoose('{"a":1}\n\n{"b":2}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('**多吐一个闭括号:当噪声丢,整拍不作废**(实测形状)', () => {
|
||||
const slip = '{"say":"归一好了","actions":[{"kind":"write","rows":[{"patch":{"type_tag":"A"}}]}},{"kind":"finish","itemId":"S1"}]}';
|
||||
const got = parseJsonLoose(slip) as { actions: Array<{ kind: string }> };
|
||||
expect(got.actions.map((a) => a.kind)).toEqual(['write', 'finish']);
|
||||
});
|
||||
|
||||
it('值里带括号的字符串不把栈带偏', () => {
|
||||
expect(parseJsonLoose('{"a":"x}y]z","b":2}')).toEqual({ a: 'x}y]z', b: 2 });
|
||||
});
|
||||
|
||||
it('转义引号照认', () => {
|
||||
expect(parseJsonLoose('{"a":"he said \\"}\\" ok"}')).toEqual({ a: 'he said "}" ok' });
|
||||
});
|
||||
|
||||
it('实在不是 JSON:undefined,不抛', () => {
|
||||
expect(parseJsonLoose('这不是 JSON')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -108,38 +108,55 @@ export function parseJsonLoose(text: string): unknown {
|
||||
* 发一张兜底卡把人叫来。实测栽过:模型给的 9 颗料判据完全正确、
|
||||
* 分类器都要调了,只因为它多写了一个对象,整拍白跑、人被无谓地打扰了一次。
|
||||
*
|
||||
* 现在按括号配平扫第一个完整对象,后面多出来的当噪声丢掉 ——
|
||||
* 多写的那一份本就是下一拍的事,这一拍不该替它执行。
|
||||
* 扫的时候要认字符串与转义,否则值里带 `}` 的会把括号计数带偏。
|
||||
* 扫法是**真正的括号栈**,不是单数花括号(2026-08-02 又栽一次才改的):
|
||||
* 关思考的模型偶发多吐一个闭括号(`…}}]}},{…` 该是 `…}}]},{…`),
|
||||
* 只数花括号的计数被它带到零,切出的半截连 `[` 都没关上 —— 解析失败,
|
||||
* 整拍(六个动作全对)退化成一张「需要你判断下一步」的兜底卡。
|
||||
* 栈知道当下该关的是哪个括号:**对不上的闭括号当噪声丢掉**(自修这类手滑;
|
||||
* 丢的只是一个多余字符,不发明任何内容),栈清空即取到第一个完整值,
|
||||
* 后面多出来的照旧当噪声 —— 多写的那一份本就是下一拍的事。
|
||||
* 字符串与转义照认,否则值里带 `}` 的会把栈带偏。
|
||||
*/
|
||||
function firstBalanced(text: string): unknown {
|
||||
const start = text.search(/[[{]/);
|
||||
if (start < 0) return undefined;
|
||||
const open = text[start];
|
||||
const close = open === '{' ? '}' : ']';
|
||||
let depth = 0;
|
||||
const stack: string[] = [];
|
||||
const kept: string[] = [];
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
const ch = text[i] as string;
|
||||
if (inString) {
|
||||
kept.push(ch);
|
||||
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) {
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
kept.push(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch === '{' || ch === '[') {
|
||||
stack.push(ch === '{' ? '}' : ']');
|
||||
kept.push(ch);
|
||||
continue;
|
||||
}
|
||||
if (ch === '}' || ch === ']') {
|
||||
if (stack[stack.length - 1] !== ch) continue; // 对不上的闭括号 = 噪声,丢
|
||||
stack.pop();
|
||||
kept.push(ch);
|
||||
if (stack.length === 0) {
|
||||
try {
|
||||
return JSON.parse(text.slice(start, i + 1));
|
||||
return JSON.parse(kept.join(''));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
kept.push(ch);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -50,11 +50,17 @@
|
||||
- 地址与列名的把关在装配层(它认识活动清单)—— 未声明的地址与列一律拒写,
|
||||
越域写与越权调工具同罪;拒了抛错,由回合收成「行动被拒」喂回下一拍,引擎不崩;
|
||||
- **写的授权面是台账声明,不是计划项**(2026-07-30):`itemId` 只是记账,
|
||||
挂得上就挂(项活性化),挂不上(已完成 / 人工激活未 start / 阶段 / 不存在)照写不拦,
|
||||
挂得上就挂(项活性化),挂不上(已完成 / 阶段 / 不存在)照写不拦,
|
||||
留痕注明「未挂到计划项」。从前绑死在项生命周期上,死锁过一单:S5 收完才发现打标
|
||||
没写全,所有能挂的项都 Completed —— 每拍补写都被拒,连拒五拍熔断,而数据本身合规;
|
||||
- 写完把该项收了(未 Active 的就地过一下 Active)。**这不是绕开「Enabled ≠ Active」**:
|
||||
那条纪律管的是触达世界的项,而草稿写触达不到任何东西 —— 为它单烧一次模型去按那一下是纯仪式。
|
||||
- 写完把该项收了(未 Active 的就地过一下 Active)。
|
||||
- **人工激活的项:模型显式动它 = 启动它**(2026-08-02 拆墙,`readyToWork`)。
|
||||
从前它要求先发显式 `start`,不发就拒;提示词与拒回都写全之后实测仍熔断
|
||||
(A1 一单连拒五拍,五次全是「S3_confirm 要先 start」)—— 那道墙保护不了任何东西:
|
||||
start 是**模型自己的**动作,同一串连发即过,拦下只是逼它多演一步学不稳的礼仪。
|
||||
人工激活真正的分量原样保留:**引擎不因哨兵满足替它启动**(哨兵只推到
|
||||
Available/Enabled),**也不给 doneWhen 替人收**(自动收步跳过人工激活项)——
|
||||
收它的只能是模型自己的 finish。`start` 仍是合法行动,只是可省。
|
||||
- **里程碑前提达成即达成**(2026-07-30,`achieveMilestones`):CMMN 的里程碑没有执行体,
|
||||
也没有谁去按它。从前只有 `onEvent(ref = 里程碑 id)` 会 occur,而系统里没有任何东西
|
||||
发这种事件 —— 于是里程碑永远 `Available`:既是必做项(堵死 autoComplete),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId } from '@contracts';
|
||||
import { createTurnRunner, handsOff, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts';
|
||||
import { createTurnRunner, echoTitleOf, handsOff, resendVerdict, sameGist, stalledTurns } from '../internal/usecase/turn-runner.ts';
|
||||
import type { TurnRunnerDeps } from '../internal/usecase/turn-runner.ts';
|
||||
import type { PendingAcceptance } from '../port.ts';
|
||||
import type { CaseModel } from '../internal/domain/cmmn-parse.ts';
|
||||
@@ -521,12 +521,14 @@ describe('干活前先站上场', () => {
|
||||
* 实测的死锁:模型收完 S5 才发现打标结果没写全,此时所有能挂的项都 Completed,
|
||||
* 唯一没完成的只剩标人工激活的 T_release —— 每拍补写都被「要先 start」拒回来,
|
||||
* 连拒五拍熔断交人,而那几行数据本身合法合规。
|
||||
* 2026-08-02 拆墙后,人工激活的项也**挂得上**了(动它就算启动):
|
||||
* 写照落不变,记账从「挂不上、留痕注明」变成「挂上、点亮进行中」—— 更诚实。
|
||||
*/
|
||||
it('标了人工激活的项:写照落、不挂项、项不动 —— 挂项是记账,不是授权', async () => {
|
||||
it('标了人工激活的项:写照落、挂上点亮 —— 授权面仍是台账声明', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: writeTo('GATED') });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.written).toHaveLength(1);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available');
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Active');
|
||||
expect(outcome.status).toBe('running');
|
||||
});
|
||||
|
||||
@@ -629,11 +631,18 @@ describe('宣告完成', () => {
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'FREE')?.state).toBe('Completed');
|
||||
});
|
||||
|
||||
it('标了人工激活的项仍要先 start —— 那条纪律管的是触达世界与关键交付', async () => {
|
||||
/*
|
||||
* **人工激活的项:动它就算启动**(2026-08-02 拆墙)。
|
||||
* 从前这儿钉的是「要先 start,不发就拒」;实测那道墙保护不了任何东西
|
||||
* (start 是模型自己的动作,同一串连发即过),却多出一种死法:A1 一单
|
||||
* 连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回都写着,
|
||||
* 模型就是学不稳。人工激活真正的分量在引擎那一侧:不替它启动、不替人收 ——
|
||||
* 那两条各有各的测钉着(哨兵不自动激活 / 自动收步跳过人工激活项)。
|
||||
*/
|
||||
it('标了人工激活的项:模型显式动它 = 启动它,就地过 Active 再收', async () => {
|
||||
const h = harness({ parsed: STAGE_MODEL, runtime: READY_RUNTIME2, action: finish('GATED') });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Available');
|
||||
expect(outcome.status).toBe('running'); // 拒了不崩,理由喂回下一拍
|
||||
expect(outcome.runtime.items.find((i) => i.id === 'GATED')?.state).toBe('Completed');
|
||||
});
|
||||
|
||||
it('编出来的项收不了', async () => {
|
||||
@@ -784,7 +793,7 @@ describe('说话按轮不按拍:只有交出控制权的那一拍才进对话区
|
||||
});
|
||||
|
||||
it('轮到人了才出气泡:问一句 / 派工单 / 摆岔路 / 等外部 / 提交放行', () => {
|
||||
for (const kind of ['ask', 'human', 'decide', 'listen', 'submit']) {
|
||||
for (const kind of ['human', 'decide', 'listen', 'submit']) {
|
||||
expect(handsOff([{ kind }]), kind).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -878,13 +887,11 @@ describe('画面不再静默丢帧', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 补全卡 —— 对话卡族,与执行工单是两件事。
|
||||
*
|
||||
* SOP 上 A1-S2「按缺口清单追问补全」画的就是它。从前 Agent **发不出对话卡**:
|
||||
* 行动型里只有 `human` 且只能选执行 / 验收两种工单,于是那一步只能拿执行工单顶 ——
|
||||
* 形似而神非:派一张工单去问几个字段,收卡的人得进待处理、办完再回来,重得不成比例。
|
||||
* **补全卡已全局退役**(2026-08-02 用户定)—— 引擎侧这个行动型不再发卡:
|
||||
* 归一层(agent-runner)会把 `ask` 原地转成零输入执行工单,所以正常到不了这里;
|
||||
* 别的调用方直发就如实拒并指对路(human + execution)。
|
||||
*/
|
||||
describe('补全卡', () => {
|
||||
describe('补全卡退役:引擎不再发 elicitation 卡', () => {
|
||||
const ASK = {
|
||||
kind: 'ask',
|
||||
itemId: 'i1',
|
||||
@@ -892,18 +899,9 @@ describe('补全卡', () => {
|
||||
fields: [{ key: 'NE555P_lead_time', label: 'NE555P 交期' }],
|
||||
};
|
||||
|
||||
it('发出去即挂起等填 —— 调人 = 立监听听人', async () => {
|
||||
it('直发 ask 被拒:一张卡都不发,理由指向执行工单', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: ASK });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(outcome.status).toBe('awaiting');
|
||||
expect(h.issued).toEqual([
|
||||
{ workOrder: 'elicitation', card: { ask: '这两条缺交期,麻烦补一下', fields: ['NE555P_lead_time'] } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('一个格子都没有就拒发 —— 空表单等于没问', async () => {
|
||||
const h = harness({ parsed: OPEN_MODEL, runtime: OPEN_RUNTIME, action: { ...ASK, fields: [] } });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(outcome.status).toBe('running'); // 拒了不崩,理由喂回下一拍
|
||||
});
|
||||
@@ -1225,6 +1223,30 @@ describe('办完的项:话要说对,按一下不报错', () => {
|
||||
expect(h.issued).toHaveLength(0);
|
||||
});
|
||||
|
||||
/*
|
||||
* **A1 基线熔断实测(2026-08-03)**:人答完之后那一步已经收掉,模型又发了一遍同一张卡。
|
||||
* 从前 `readyToWork` 走在重发判断之前 —— Completed 项当场抛「不能再往它上面挂活」,
|
||||
* 于是「重发已答过的卡 = 无事发生」这条唯一活路在最需要它的时候够不着,
|
||||
* 同一条拒回重复五遍熔断(13 拍 124k,发口一个都没有)。顺序换过来才叫墙要配路。
|
||||
*/
|
||||
it('重发**已答过**的卡,哪怕那一步已经收掉 → 无事发生,不撞「已办完」的墙', async () => {
|
||||
const h = harness({
|
||||
parsed: DONE_MODEL,
|
||||
runtime: DONE_RUNTIME,
|
||||
action: { kind: 'human', itemId: 'S3', workOrder: 'execution', card: { title: '请审定这份清单', ask: 'a' } },
|
||||
trail: [
|
||||
{ kind: 'card-sent', title: '请审定这份清单' },
|
||||
{ kind: 'card-reply', title: '请审定这份清单' },
|
||||
] as never,
|
||||
});
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
// 没发第二张卡(人不被打扰、humanCalls 不烧),也没被拒
|
||||
expect(h.issued).toHaveLength(0);
|
||||
expect(outcome.status).not.toBe('awaiting');
|
||||
// 收掉的项原样收着 —— 无事发生不该顺手把它推回场上
|
||||
expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed');
|
||||
});
|
||||
|
||||
it('**再 start 一次收掉的项是无事发生,不是非法迁移** —— 别让两堵墙对着撞', async () => {
|
||||
const h = harness({ parsed: DONE_MODEL, runtime: DONE_RUNTIME, action: { kind: 'start', itemId: 'S3' } });
|
||||
const outcome = await h.runner.runTurn(TASK);
|
||||
@@ -1233,3 +1255,44 @@ describe('办完的项:话要说对,按一下不报错', () => {
|
||||
expect(h.envelope.runtime.items.find((i) => i.id === 'S3')?.state).toBe('Completed');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* **重发已答过的卡:执行工单与决策卡同一把尺**(2026-08-03,A4 实测逼出来的)。
|
||||
*
|
||||
* 从前只有执行工单那一路有兜底,决策卡一道都没有 —— 同一个岔路口连发四张,
|
||||
* 每张扣一次调人预算,四次扣完整单 `exhausted`。卡族不同,病是同一个。
|
||||
* 三档由 `resendVerdict` 定:照发 / 无事发生(头两次)/ 第三次起当被拒(熔断看得见)。
|
||||
*/
|
||||
describe('同一张卡再发一遍的三档', () => {
|
||||
const sent = (title: string) => ({ kind: 'card-sent', title });
|
||||
const replied = (title: string) => ({ kind: 'card-reply', title });
|
||||
const echoed = (answered: string) => ({ kind: 'observation', title: echoTitleOf(answered) });
|
||||
|
||||
it('没发过这一问 → 照发', () => {
|
||||
expect(resendVerdict([sent('别的事'), replied('别的事')], '这一问').kind).toBe('send');
|
||||
});
|
||||
|
||||
it('发过但**人还没答** → 照发 —— 未答的重发由卡自己去重管,不归这里', () => {
|
||||
expect(resendVerdict([sent('这一问')], '这一问').kind).toBe('send');
|
||||
});
|
||||
|
||||
it('人答过了 → 头两次无事发生(卡不真发、预算不烧)', () => {
|
||||
const win = [sent('这一问'), replied('这一问')];
|
||||
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'echo', answered: '这一问' });
|
||||
expect(resendVerdict([...win, echoed('这一问')], '这一问').kind).toBe('echo');
|
||||
});
|
||||
|
||||
it('第三次起当被拒 —— 那时是真卡死了,熔断兜底要看得见它', () => {
|
||||
const win = [sent('这一问'), replied('这一问'), echoed('这一问'), echoed('这一问')];
|
||||
expect(resendVerdict(win, '这一问')).toEqual({ kind: 'reject', answered: '这一问' });
|
||||
});
|
||||
|
||||
it('问句只少个尾巴也算同一问 —— 变体重发同样烧预算(与 sameQuestion 同源)', () => {
|
||||
const win = [sent('请审定这份受理需求清单,确认无误后我就往下走'), replied('请审定这份受理需求清单,确认无误后我就往下走')];
|
||||
expect(resendVerdict(win, '请审定这份受理需求清单').kind).toBe('echo');
|
||||
});
|
||||
|
||||
it('空问句不拦 —— 认不出身份的卡不该被这道兜底吃掉', () => {
|
||||
expect(resendVerdict([sent(''), replied('')], ' ').kind).toBe('send');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,9 +101,16 @@ function payloadOf(detail: Record<string, unknown> | undefined, limit: number =
|
||||
* 人以为自己白点了一次。补全卡那一路早就有(`values`),决策卡是漏的那一半。
|
||||
*/
|
||||
const chosen = detail['value'];
|
||||
const note = detail['note'];
|
||||
if (typeof chosen === 'string' && chosen !== '') {
|
||||
const note = detail['note'];
|
||||
parts.push(`人选了:${chosen}${typeof note === 'string' && note !== '' ? `(${note})` : ''}`);
|
||||
} else if (typeof note === 'string' && note !== '') {
|
||||
/*
|
||||
* 没有选项也可能有话要带 —— 零输入执行卡的空答复痕靠这一句说清
|
||||
* 「人已办完并交回」,不然痕上一个值都没有,模型会当「没答成」再发一张卡
|
||||
* (2026-08-02 实测熔断单)。
|
||||
*/
|
||||
parts.push(note);
|
||||
}
|
||||
|
||||
const output = detail['output'];
|
||||
@@ -186,7 +193,47 @@ export function sameQuestion(a: string, b: string): boolean {
|
||||
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '').toLowerCase();
|
||||
const x = norm(a);
|
||||
const y = norm(b);
|
||||
return x !== '' && x === y;
|
||||
if (x === '' || y === '') return false;
|
||||
if (x === y) return true;
|
||||
/*
|
||||
* **前缀也算同一件事**(2026-08-02 用户实测送来的单):第一张
|
||||
* 「请审定这份受理需求清单,确认无误后我就往下走。」人答了;第二张
|
||||
* 「请审定这份受理需求清单」—— 只少了个尾巴,全等比对放它过去,
|
||||
* 又烧掉一次人环预算(那单 humanCalls 4/4 耗尽,放行卡都没发出去)。
|
||||
* 短的一方够长(≥6 字)且是长方前缀,就是同一问 —— 阈值保守:
|
||||
* 拦错一问比多问一问更坏,真的新问题不该被吃掉。
|
||||
*/
|
||||
const [short, long] = x.length <= y.length ? [x, y] : [y, x];
|
||||
return short.length >= 6 && long.startsWith(short);
|
||||
}
|
||||
|
||||
/** 「已答过、没有再发」那条观察痕的标题 —— 记与数是同一句话,不能各写各的 */
|
||||
export function echoTitleOf(answered: string): string {
|
||||
return `这张卡人已答过,没有再发(「${answered}」)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* **同一张卡该不该再发一遍** —— 执行工单与决策卡共用这一把尺(纯判断,好测)。
|
||||
*
|
||||
* 从前只有执行工单那一路有这道兜底,决策卡**一道都没有**:同一个岔路口连发四张,
|
||||
* 每张扣一次调人预算,四次扣完整单 `exhausted`(2026-08-03 A4 实测:人点完之后
|
||||
* 那一步被重念,剧本头一段写着「发一张决策卡」,它就照办再发一张)。
|
||||
* 卡族不同,病是同一个 —— **按钮按两下不该变成墙**,也不该变成四张卡。
|
||||
*
|
||||
* 三档:`send` 照发;`echo` 卡不真发(人不被打扰、humanCalls 不烧),
|
||||
* 只把「已答过」的事实摆回轨迹;第三次起 `reject` —— 那时是真卡死了,熔断要看得见它。
|
||||
*/
|
||||
export function resendVerdict(
|
||||
window: ReadonlyArray<{ readonly kind: string; readonly title: string }>,
|
||||
asked: string,
|
||||
): { readonly kind: 'send' } | { readonly kind: 'echo' | 'reject'; readonly answered: string } {
|
||||
const trimmed = asked.trim();
|
||||
if (trimmed === '') return { kind: 'send' };
|
||||
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
|
||||
const same = window.find((t) => t.kind === 'card-sent' && replied.has(t.title) && sameQuestion(t.title, trimmed));
|
||||
if (same === undefined) return { kind: 'send' };
|
||||
const echoed = window.filter((t) => t.kind === 'observation' && t.title === echoTitleOf(same.title)).length;
|
||||
return { kind: echoed >= 2 ? 'reject' : 'echo', answered: same.title };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,7 +267,7 @@ export function handsOff(actions: ReadonlyArray<{ kind: string }>): boolean {
|
||||
return actions.some((a) => HANDOFF_KINDS.has(a.kind));
|
||||
}
|
||||
|
||||
const HANDOFF_KINDS = new Set(['ask', 'human', 'decide', 'listen', 'submit']);
|
||||
const HANDOFF_KINDS = new Set(['human', 'decide', 'listen', 'submit']);
|
||||
|
||||
export function sameGist(a: string, b: string): boolean {
|
||||
const norm = (s: string): string => s.replace(/[\p{P}\p{S}\s]/gu, '');
|
||||
@@ -304,11 +351,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
/**
|
||||
* 干活的那一下:把计划项推到 Active,再由调用方收成 Completed。
|
||||
*
|
||||
* 拓扑不许 `Available → Completed`,所以中间那一步总得有人走。谁走,分情况:
|
||||
* · 项标了 `manualActivationRule` → **只能 Agent 自己 start**。那条纪律管的是
|
||||
* 触达世界与关键交付:按一下再动,为的是别让 AI 悄悄改了外面。
|
||||
* · 没标 → 引擎就地过一下。为一次读类调用或一次草稿写单烧一拍模型去按那一下,
|
||||
* 是纯仪式;实测里它还会卡死:模型反复挑同一个没 start 的项,每拍都被拓扑拒回来。
|
||||
* 拓扑不许 `Available → Completed`,所以中间那一步总得有人走 —— **引擎就地过一下**,
|
||||
* 人工激活的项也一样(2026-08-02 拆墙)。从前人工激活的项要求模型先发一个显式
|
||||
* `start`,不发就拒;可那道墙保护不了任何东西:start 是**模型自己的**动作,
|
||||
* 同一串里连发即过,拦下只是逼它多演一步礼仪 —— 而礼仪学不稳就是死法:
|
||||
* 实测 A1 一单连拒五拍熔断,五次全是「S3_confirm 要先 start」,提示词与拒回
|
||||
* 都写着这句话,它就是学不会。人工激活真正要保的两样一样不少:
|
||||
* 引擎不因哨兵满足**替它启动**(那在 sentry 那一侧),也不给 doneWhen
|
||||
* **替人收步**(那在自动收步那一侧)。「模型显式动它」本身就是启动的意思表示,
|
||||
* 与 finish 就地过 Active、start 已收项无事发生同族 —— 状态机的礼仪不该长成墙。
|
||||
*
|
||||
* 项不存在就如实报,并把合法的选项摆出来 —— 模型会编 id(实测编过),
|
||||
* 只回一句「(不存在)」它下一拍还是照编。
|
||||
@@ -357,9 +408,6 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
`要接着办就挂当下这一步(可选:${live.join('、') || '无'});这一单该收口了就直接 submit`,
|
||||
);
|
||||
}
|
||||
if (item.manualActivation) {
|
||||
throw new Error(`${itemId} 标了人工激活(触达世界或关键交付),要先 start 再干活 —— 当下是 ${state}`);
|
||||
}
|
||||
return transitionItem(runtime, itemId, 'Active');
|
||||
}
|
||||
|
||||
@@ -667,8 +715,15 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 拒了不掀翻回合,草稿也确实没被改脏(写进去的值与原值相同)。
|
||||
*/
|
||||
if (changed === 0) {
|
||||
/*
|
||||
* 「换一件事做」四个字不够指方向(2026-08-02 实测:A2 一单连撞四拍,
|
||||
* 每拍都把同一行原样再写一遍)—— 拒回的话是模型下一拍的行动指南,
|
||||
* 得说清「这些值**已经在**台账里」意味着什么:多半是该办下一件事了。
|
||||
*/
|
||||
throw new Error(
|
||||
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同,局面没有推进 —— 换一件事做`,
|
||||
`写了等于没写:${action.address} 这 ${action.rows.length} 行的值与现有草稿完全相同 —— ` +
|
||||
`这些值**已经在台账里了**,不必再写。该办的多半是下一件事:还没写的列/表、` +
|
||||
`该 finish 的项,或按剧本走下一步;把同样的值再写一遍只会再被拒一次`,
|
||||
);
|
||||
}
|
||||
await deps.memory.append({
|
||||
@@ -755,11 +810,19 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* Active → Active 是非法迁移,当场拒;模型只会原样再试一遍,连拒五拍熔断。
|
||||
* 而它想做的事完全合法 —— 一个计划项本来就可以先问一句、再派一张单。
|
||||
*
|
||||
* `readyToWork` 是同一件事的宽容写法:已在场就原样返回,人工激活未 start 才拦。
|
||||
* `readyToWork` 是同一件事的宽容写法:已在场就原样返回,没在场就地站上
|
||||
* (2026-08-02 起人工激活的项同样如此 —— 动它就算启动)。
|
||||
*/
|
||||
const { parsed: cardModel } = await loaded(taskId);
|
||||
const advanced = readyToWork(cardModel, runtime, action.itemId);
|
||||
/*
|
||||
* **重发的判断要走在站上场之前**(2026-08-03,A1 熔断实测逼出来的顺序)。
|
||||
*
|
||||
* 原先先 `readyToWork` 再判重发:而人答完之后那一步常常已经**收掉**了,
|
||||
* `readyToWork` 对 Completed 项直接抛「这一步已经办完,不能再往它上面挂活」——
|
||||
* 于是「重发已答过的卡 = 无事发生」这条唯一的活路**在最需要它的时候够不着**,
|
||||
* 模型原样重发、连拒五拍熔断(A1 基线实测:13 拍 124k,同一条拒回重复 5 遍)。
|
||||
* 重发一张已答过的卡本来就不需要那一项在场 —— 它什么也不做。
|
||||
* 「墙要配路」(设计律第一条):先给路,给不了路再谈墙。
|
||||
*
|
||||
* **同一件事不许问第二遍**(2026-08-01,实测账)。
|
||||
*
|
||||
* 熔断管的是「连续被拒」,管不了这种:每一拍的行动都合法,只是**问的是同一件事**。
|
||||
@@ -775,19 +838,44 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
const asked = String(face['title'] ?? face['ask'] ?? '').trim();
|
||||
if (asked !== '') {
|
||||
const window = await deps.memory.window(taskId, STALL_WINDOW);
|
||||
const sent = window.filter((t) => t.kind === 'card-sent');
|
||||
const replied = new Set(window.filter((t) => t.kind === 'card-reply').map((t) => t.title));
|
||||
const same = sent.find((t) => replied.has(t.title) && sameQuestion(t.title, asked));
|
||||
if (same !== undefined) {
|
||||
throw new Error(
|
||||
`这件事刚问过,人也答过了(「${same.title}」)—— 别再发一张一样的卡。` +
|
||||
'答案就在你的轨迹里(card-reply 那一条的 values)。' +
|
||||
'**他把格子留空提交回来,就是对方给不了** —— 那也是一个答案:' +
|
||||
'如实记账(问过了 / 答过了),缺口照实留着,把这一步收掉往下走;' +
|
||||
'真有别的东西要问,换一件事、换一个问法。',
|
||||
);
|
||||
const verdict = resendVerdict(window, asked);
|
||||
if (verdict.kind !== 'send') {
|
||||
const same = { title: verdict.answered };
|
||||
/*
|
||||
* **重发已答过的卡:头两次当无事发生,不当被拒**(2026-08-02 用户实测
|
||||
* 送来的熔断单)。从前这里直接拒 —— 拒回的话把「答案在轨迹里」说得
|
||||
* 再清楚,模型偶发就是读不进:零输入执行卡的空答复在它眼里像「没答成」,
|
||||
* 于是原样重发、被拒、再重发,五拍熔断,人被无谓叫来。
|
||||
* 拒回喂的是对抗,观察喂的是事实 —— 与 start 已收项无事发生、
|
||||
* finish 已收项无事发生同族:**按钮按两下不该变成墙**。
|
||||
* 卡不真发(人不被打扰、humanCalls 不烧),只把「已答过」的事实
|
||||
* 连着下一步指路摆回去;第三次起仍按被拒算 —— 那时是真卡死了,
|
||||
* 熔断兜底要看得见它。
|
||||
*/
|
||||
const echoTitle = echoTitleOf(same.title);
|
||||
if (verdict.kind === 'reject') {
|
||||
throw new Error(
|
||||
`这件事刚问过,人也答过了(「${same.title}」)—— 别再发一张一样的卡。` +
|
||||
'答案就在你的轨迹里(card-reply 那一条)。**空答复 = 人照原样确认了**,' +
|
||||
'不是没答:接着办说明书里「人交回之后」的那几件事,把这一步收掉往下走;' +
|
||||
'真有别的东西要问,换一件事、换一个问法。',
|
||||
);
|
||||
}
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: echoTitle,
|
||||
detail: {
|
||||
internal: true,
|
||||
note: '人已答过这张卡(空答复 = 照原样确认)。别再发了 —— 接着办说明书里「人交回之后」的那几件事,办完把这一步 finish 掉。',
|
||||
},
|
||||
});
|
||||
// 什么也没做,局面原样交回 —— 这一拍不该顺手把那一项推上场
|
||||
return { runtime, status: 'running' };
|
||||
}
|
||||
}
|
||||
const { parsed: cardModel } = await loaded(taskId);
|
||||
const advanced = readyToWork(cardModel, runtime, action.itemId);
|
||||
const card = await deps.cards.issue({
|
||||
taskId,
|
||||
ownerId: context.ownerId,
|
||||
@@ -806,32 +894,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
|
||||
case 'ask': {
|
||||
/*
|
||||
* 发补全卡 = 立监听听人:卡发到对话区,本拍就此挂起等填。
|
||||
*
|
||||
* **先验迁移,再发卡**:迁移非法时抛在前面,世界上不会留下一张已发出的卡 ——
|
||||
* 卡收不回,负责人的对话区就堆起一摞谁也不知道该怎么答的表单。
|
||||
* **补全卡已全局退役**(2026-08-02 用户定:「彻底全局退役补全卡,
|
||||
* 执行卡完全就是包含补全语义的人类工具」)。归一层(agent-runner)已把
|
||||
* `ask` 原地转成零输入执行工单,这个分支正常到不了 —— 真到了
|
||||
* (别的调用方直发)就如实拒,并指对路。
|
||||
*/
|
||||
if (action.fields.length === 0) {
|
||||
throw new Error(
|
||||
'补全卡要有格子:一个缺口一个格子,形状 fields:[{key,label,hint?}] —— 空表单等于没问',
|
||||
);
|
||||
}
|
||||
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
|
||||
const card = await deps.cards.ask({
|
||||
taskId,
|
||||
ownerId: context.ownerId,
|
||||
humanCallCap: context.humanCallCap,
|
||||
ask: action.ask,
|
||||
fields: action.fields,
|
||||
...(action.basis === undefined ? {} : { basis: action.basis }),
|
||||
});
|
||||
await deps.listeners.register({
|
||||
taskId,
|
||||
eventType: 'human',
|
||||
keyFilter: { cardId: card.id },
|
||||
deadline: 'P3D',
|
||||
});
|
||||
return { runtime: advanced, status: 'awaiting' };
|
||||
throw new Error('补全卡已退役:缺信息发零输入执行工单(human + workOrder:"execution"),指导书写清缺什么,人在工作区的件上补');
|
||||
}
|
||||
|
||||
case 'entry': {
|
||||
@@ -875,7 +943,38 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
* 以及先验迁移再发卡(与补全卡同理 —— 卡发出去收不回)。
|
||||
*/
|
||||
if (action.options.length < 2) {
|
||||
throw new Error('决策卡至少两个选项:只有一条路就不是决策,直接干或者发补全卡');
|
||||
throw new Error('决策卡至少两个选项:只有一条路就不是决策,直接干或者派一张执行工单');
|
||||
}
|
||||
/*
|
||||
* 同款顺序:**先判重发,再站上场**(见 human 那一路的账 —— 人选完之后
|
||||
* 那一步常已收掉,先站上场就会撞在「已办完」的墙上,活路够不着)。
|
||||
*
|
||||
* **人已经选过的岔路口不再发第二张**(2026-08-03,A4 实测:同一张连发四次,
|
||||
* 四次调人预算烧光落 `exhausted`)。与执行工单同一把尺 —— 决策卡的身份是问句(`ask`),
|
||||
* 它入痕时记的正是 `ask`(`issueChatCard` → `card-sent` 标题)。
|
||||
*/
|
||||
const window = await deps.memory.window(taskId, STALL_WINDOW);
|
||||
const verdict = resendVerdict(window, action.ask);
|
||||
if (verdict.kind !== 'send') {
|
||||
const echoTitle = echoTitleOf(verdict.answered);
|
||||
if (verdict.kind === 'reject') {
|
||||
throw new Error(
|
||||
`这个岔路口人已经选过了(「${verdict.answered}」)—— 别再发一张一样的决策卡。` +
|
||||
'他选了什么就在你的轨迹里(card-reply 那一条,detail 带着选中的值):' +
|
||||
'照那个选择往下办,把这一步收掉;真有**另一个**岔路口要人定,换一件事、换一个问法。',
|
||||
);
|
||||
}
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: echoTitle,
|
||||
detail: {
|
||||
internal: true,
|
||||
note: '人已经在这个岔路口选过了(选中的值在 card-reply 那一条)。别再发 —— 照他的选择接着办,办完把这一步 finish 掉。',
|
||||
},
|
||||
});
|
||||
// 什么也没做,局面原样交回
|
||||
return { runtime, status: 'running' };
|
||||
}
|
||||
const advanced = readyToWork((await loaded(taskId)).parsed, runtime, action.itemId);
|
||||
const card = await deps.cards.decide({
|
||||
@@ -931,11 +1030,12 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
/*
|
||||
* 宣告完成。完成不等于成果被认可:终局仍唯放行,人验收才算数。
|
||||
*
|
||||
* 没站上场的普通项**就地过 Active 再收**(readyToWork 同一套):这一项的活
|
||||
* 没站上场的项**就地过 Active 再收**(readyToWork 同一套):这一项的活
|
||||
* 常常在同一串里已经干了(write 落了行),只是记账没挂上 —— 为「按一下」
|
||||
* 单烧一拍模型是纯仪式(实测:finish S6 被「没在进行中」拒,下一拍
|
||||
* start S6 + finish 原样重来,白花一拍)。标了人工激活的仍要先 start:
|
||||
* 那条纪律管的是触达世界与关键交付,readyToWork 会替它把门。
|
||||
* start S6 + finish 原样重来,白花一拍)。人工激活的项也一样(2026-08-02
|
||||
* 拆墙,见 readyToWork 注释)—— 但它仍旧**没有 doneWhen、引擎不自动收**,
|
||||
* 收它的只能是模型自己的这一下 finish。
|
||||
*/
|
||||
const { parsed: finishModel } = await loaded(taskId);
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user