forked from ChaiHao/BPOS-MVP
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({
|
||||
|
||||
Reference in New Issue
Block a user