人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
@@ -0,0 +1,222 @@
/**
* 模型出参归一 —— **宽进到什么程度**,这里说死。
*
* 每一条宽进都对应一次实测踩坑:模型把字段叫成另一个名字、把地址整串塞进一格、
* 把行数组写成半个形状。这些差异吃掉的是一整拍(有的还白发一张卡给人),
* 而它们全都不涉及「行动合不合规」—— 合规照旧由引擎拒。
*
* 反过来也要守住:看不懂就转人,**不许猜着往世界上动手**。
*/
import { describe, expect, it } from 'vitest';
import type { EnabledSet } from '@modules/loop-engine';
import { normalizeAction, normalizeTurn } from '../agent-runner.ts';
const ENABLED: EnabledSet = {
entries: [{ id: 'T1', name: '干活', type: 'humanTask', requiresManualStart: false, discretionary: false }],
inProgress: [],
judgements: [],
gaps: ['缺记录'],
activeStages: [],
};
describe('行动型的叫法', () => {
it('`kind` 也认 `action` —— 实测模型这么写过,其余字段一字不差', () => {
const action = normalizeAction(
{ action: 'write', itemId: 'T1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
ENABLED,
);
expect(action.kind).toBe('write');
});
it('两个都给时以 `kind` 为准 —— 契约字段优先,别名只是兜底', () => {
expect(normalizeAction({ kind: 'submit', action: 'write', rationale: 'r' }, ENABLED).kind).toBe('submit');
});
});
describe('台账地址的写法', () => {
const rows = [{ key: { pn: 'X' }, patch: { eau: 1 } }];
const addressOf = (raw: Record<string, unknown>) => {
const action = normalizeAction({ kind: 'write', itemId: 'T1', rows, ...raw }, ENABLED);
return action.kind === 'write' ? action.address : '(不是 write)';
};
it('规范写法:address 一个字符串', () => {
expect(addressOf({ address: 'quote_intake.record' })).toBe('quote_intake.record');
});
it('整串塞进 object 里(实测第一拍就这么错)', () => {
expect(addressOf({ object: 'quote_intake.record' })).toBe('quote_intake.record');
});
it('整串塞进 domain 里', () => {
expect(addressOf({ domain: 'quote_intake.record' })).toBe('quote_intake.record');
});
it('老老实实拆成两格也认', () => {
expect(addressOf({ domain: 'quote_intake', object: 'record' })).toBe('quote_intake.record');
});
it('什么都没给 → 空地址,交引擎当场拒(不猜)', () => {
expect(addressOf({})).toBe('');
});
});
describe('写行的形状', () => {
const rowsOf = (raw: unknown) => {
const action = normalizeAction({ kind: 'write', itemId: 'T1', address: 'd.o', rows: raw }, ENABLED);
return action.kind === 'write' ? action.rows : [];
};
it('形状不对的行**丢掉而不是猜** —— 猜错就往台账落一行来路不明的数', () => {
expect(rowsOf([{ key: { pn: 'X' }, patch: { eau: 1 } }, { pn: 'Y', eau: 2 }, null, 7])).toEqual([
{ key: { pn: 'X' }, patch: { eau: 1 } },
]);
});
it('主键值归一为字符串或数字 —— 键要能稳定比对', () => {
expect(rowsOf([{ key: { pn: 'X', qty: 3, flag: true }, patch: {} }])).toEqual([
{ key: { pn: 'X', qty: 3, flag: 'true' }, patch: {} },
]);
});
});
describe('看不懂就转人,不猜着动手', () => {
it('行动型不认识 → 发执行卡,并把**模型原话**摆上去', () => {
const action = normalizeAction({ kind: '我自己想的行动' }, ENABLED, '{"kind":"我自己想的行动"}');
expect(action.kind).toBe('human');
if (action.kind !== 'human') return;
expect(action.card['reason']).toBe('模型未给出合规的行动');
// 只写「不合规」的话,收卡的人分不清是模型跑偏、行动型少了一种、还是提示词没交代清楚
expect(String(action.card['modelReply'])).toContain('我自己想的行动');
expect(action.card['kindSeen']).toBe('我自己想的行动');
});
it('立监听没给时限 → 兜底 P3D,绝不留幽灵等待', () => {
const action = normalizeAction({ kind: 'listen', itemId: 'T1', eventType: 'data' }, ENABLED);
expect(action.kind === 'listen' && action.deadline).toBe('P3D');
});
});
/**
* 补全卡的格子在哪儿 —— **两处都认**。
*
* 提示词里执行工单的写法是 `card.fields`,模型很自然把补全卡也那么写(实测就是),
* 于是每一拍都被「fields 为空」拒回来。空不空由引擎验,这里只负责不让一个位置差异吃掉一整拍。
*/
describe('补全卡的格子', () => {
const fieldsOf = (raw: Record<string, unknown>) => {
const a = normalizeAction({ kind: 'ask', itemId: 'T1', ...raw }, ENABLED);
return a.kind === 'ask' ? a.fields : [];
};
it('顶层 fields', () => {
expect(fieldsOf({ ask: '补一下', fields: [{ key: 'lead_time', label: '交期' }] })).toEqual([
{ key: 'lead_time', label: '交期' },
]);
});
it('落在 card 里也认(实测模型这么写过)', () => {
expect(fieldsOf({ card: { ask: '补一下', fields: [{ key: 'eau', label: '年用量' }] } })).toEqual([
{ key: 'eau', label: '年用量' },
]);
});
it('写成一串字段名也认 —— label 用 key 顶上,宁可显示字段名不显示空白', () => {
expect(fieldsOf({ ask: 'x', fields: ['eau', 'lead_time'] })).toEqual([
{ key: 'eau', label: 'eau' },
{ key: 'lead_time', label: 'lead_time' },
]);
});
it('没有 key 的格子丢掉 —— 没有 key 就没法回填', () => {
expect(fieldsOf({ ask: 'x', fields: [{ label: '只有标签' }, { key: 'ok', label: '行' }] })).toEqual([
{ key: 'ok', label: '行' },
]);
});
it('一个都收不到就是空数组,交引擎拒(不替它编格子)', () => {
expect(fieldsOf({ ask: 'x' })).toEqual([]);
});
});
/**
* 验收报告的**可修订字段** —— PRD:「一张工单 → 卡组一(HTML 报告 · AI 已填好、可编辑)」。
*
* 两样都得有才落得下去:`ai` 是「已填好」的那个值,`ledger` 是改完落回哪个台账地址。
* 少了 ai 就只能看不能改;少了 ledger,人改了也只留在工单留痕里,落地用的还是活动的原值。
*/
describe('提交放行带的可修订字段', () => {
const fieldsOf = (raw: Record<string, unknown>) => {
const a = normalizeAction({ kind: 'submit', rationale: 'r', ...raw }, ENABLED);
return a.kind === 'submit' ? (a.fields ?? []) : [];
};
it('带 ai 与 ledger 一起过来', () => {
expect(
fieldsOf({ fields: [{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' }] }),
).toEqual([{ key: 'need_sourcing', label: '需要寻源', ai: 'true', ledger: 'quote_intake.verdict' }]);
});
it('落在 card 里也认', () => {
expect(fieldsOf({ card: { fields: [{ key: 'x', label: 'X', ai: '1' }] } })).toEqual([
{ key: 'x', label: 'X', ai: '1' },
]);
});
it('**不替它补 ledger** —— 猜出来的地址会把值写到别的对象上', () => {
expect(fieldsOf({ fields: [{ key: 'x', label: 'X', ai: '1' }] })[0]).not.toHaveProperty('ledger');
});
it('没给字段就不带 —— 验收报告只看不改也是一种合法形态', () => {
const a = normalizeAction({ kind: 'submit', rationale: 'r' }, ENABLED);
expect(a.kind === 'submit' && a.fields).toBeUndefined();
});
});
/**
* 一拍出参归一 —— 三种来路都认:{actions:[…]}(规范形)、裸行动对象(旧形)、顶层数组。
* 串里不认识的项丢掉;整串一个都不认识才退化为单张转人卡 ——
* 不能让串中途一个写坏的项给人甩一张卡。
*/
describe('一拍一串行动的归一', () => {
it('规范形:actions 数组 + 拍级 say', () => {
const turn = normalizeTurn(
{
say: '登记完毕,顺手收场',
tag: 'conclusion',
actions: [
{ kind: 'write', itemId: 'T1', address: 'quote_intake.record', rows: [{ key: { pn: 'X' }, patch: { eau: 1 } }] },
{ kind: 'finish', itemId: 'T1', rationale: '齐了' },
],
},
ENABLED,
);
expect(turn.actions.map((a) => a.kind)).toEqual(['write', 'finish']);
expect(turn.say?.text).toBe('登记完毕,顺手收场');
});
it('旧形:裸行动对象包成单元素串 —— 换了拍形,旧模型输出不作废', () => {
const turn = normalizeTurn({ kind: 'submit', rationale: 'r', say: '交卷' }, ENABLED);
expect(turn.actions.map((a) => a.kind)).toEqual(['submit']);
expect(turn.say?.text).toBe('交卷');
});
it('串里的垃圾项丢掉,认识的留下 —— 不在批中途甩卡', () => {
const turn = normalizeTurn(
{ actions: [{ kind: '胡编的' }, { kind: 'finish', itemId: 'T1' }, null, 7] },
ENABLED,
);
expect(turn.actions.map((a) => a.kind)).toEqual(['finish']);
});
it('整串一个都不认识 → 单张转人卡,模型原话摆上去', () => {
const turn = normalizeTurn({ actions: [{ kind: '胡编的' }] }, ENABLED, '{"kind":"胡编的"}');
expect(turn.actions).toHaveLength(1);
expect(turn.actions[0]?.kind).toBe('human');
});
it('超出封顶截到 6 个 —— 引擎侧还会再守一道', () => {
const many = Array.from({ length: 9 }, (_, i) => ({ kind: 'finish', itemId: `T${i}` }));
expect(normalizeTurn({ actions: many }, ENABLED).actions).toHaveLength(6);
});
});