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

手改是**兜 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
@@ -180,19 +180,38 @@ describe('publishEnabled —— 引擎推进到 Enabled 为止', () => {
describe('navigate —— 导航即交集,断点即人环', () => {
it('有交集即择优推进', () => {
const nav = navigate(
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], judgements: [], gaps: ['缺报价'], activeStages: [] },
{ entries: [{ id: 'a', name: 'A', type: 'humanTask', requiresManualStart: true, discretionary: false }], inProgress: [], judgements: [], gaps: ['缺报价'], activeStages: [] },
new Set(['a']),
);
expect(nav.kind).toBe('proceed');
});
it('交集为空即断点 —— 调人或立监听的判定时机', () => {
const nav = navigate({ entries: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
it('交集为空**且手上没开着的活**才是断点 —— 调人或立监听的判定时机', () => {
const nav = navigate({ entries: [], inProgress: [], judgements: [], gaps: ['缺客户信用'], activeStages: [] }, new Set(['a']));
expect(nav).toEqual({ kind: 'breakpoint', gaps: ['缺客户信用'] });
});
/*
* 断点的定义是「无路可走」。手上还有开着的活就不算 —— 从前只看 Enabled 集,
* 局面推进开以后手上全是 Active,每拍都判成断点,而断点的处置是「调人或立监听」:
* 环于是把没干完的活丢下,去等一个不会来的事件。
*/
it('Enabled 为空但有进行中的项 → 接着干,不是断点', () => {
const nav = navigate(
{
entries: [],
inProgress: [{ id: 'w', name: '正在查历史价', type: 'humanTask', requiresManualStart: false, discretionary: false }],
judgements: [],
gaps: ['缺分类表'],
activeStages: [],
},
new Set(['nothing']),
);
expect(nav).toEqual({ kind: 'proceed', candidates: [{ id: 'w', name: '正在查历史价', type: 'humanTask', requiresManualStart: false, discretionary: false }] });
});
it('无缺口即收敛', () => {
expect(navigate({ entries: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
expect(navigate({ entries: [], inProgress: [], judgements: [], gaps: [], activeStages: [] }, new Set()).kind).toBe('converged');
});
});
@@ -0,0 +1,176 @@
/**
* 收步判据要连着**此刻的读数**一起摆给模型 —— 「差在哪」不能靠猜。
*
* 这条是拿一次熔断换来的:A1 的 S2 收步判据是
* `missing_key_total + missing_minor_total = 0`,而草稿里 missing_key_total 是 1。
* 模型看不见这个数,以为缺口已经补齐了,就把同一批行再写一遍「确认一下」——
* 撞上空转检查被拒;拒了它还是不知道差在哪,再写一遍……连拒五拍熔断。
* 把读数摆出来,这一步就从死胡同变成一句话能办的事。
*/
import { describe, expect, it } from 'vitest';
import { readingsOf } from '../internal/domain/lifecycle.ts';
describe('收步判据的读数', () => {
it('把判据读的那几个值摆出来', () => {
const out = readingsOf('missing_key_total + missing_minor_total = 0', {
missing_key_total: 1,
missing_minor_total: 0,
});
expect(out).toContain('missing_key_total=1');
expect(out).toContain('missing_minor_total=0');
});
it('只摆判据读的,不摆整个任务视图', () => {
const out = readingsOf('line_total > 0', { line_total: 3, subject: '询价', pn: 'LM358DR' });
expect(out).toContain('line_total=3');
expect(out).not.toContain('subject');
expect(out).not.toContain('pn');
});
it('没写过的名字如实说没写过 —— 它正是这一步推不动的原因', () => {
expect(readingsOf('benchmark_total > 0', {})).toContain('benchmark_total=(没写过)');
});
it('保留字不当台账名去查', () => {
const out = readingsOf('need_sourcing = true and line_total > 0', { need_sourcing: false, line_total: 2 });
expect(out).toContain('need_sourcing=false');
expect(out).not.toContain('true=');
expect(out).not.toContain('and=');
});
it('字符串字面量里的词不当台账名', () => {
expect(readingsOf('disposition = "照原价报"', { disposition: '通过' })).toBe('(现在:disposition=通过)');
});
it('没有名字可摆时给空串 —— 不往提示词里塞空括号', () => {
expect(readingsOf('1 = 1', {})).toBe('');
});
});
/**
* 判据名归一要削掉**引擎自己贴的等级标记**。
*
* 提交被拒时门印的是「…没达标 —— 某判据(L3)」,紧接着让模型「照判据名原样写」;
* 模型照做,把 `(L3)` 一起抄进 criterion —— 裁定的键与清单里的判据名对不上,
* 裁了等于没裁,再提交再被拒,连拒五拍熔断(实测 A2 死于此)。
*/
import { criterionKey } from '../internal/domain/lifecycle.ts';
describe('判据名归一', () => {
it('模型抄进等级标记也要认得出是同一条', () => {
expect(criterionKey('每项数据来源可溯(以原厂官网规格书为准)(L3)')).toBe(
criterionKey('每项数据来源可溯(以原厂官网规格书为准)'),
);
});
it('五个等级都削', () => {
for (const level of ['L1', 'L2', 'L3', 'L4', 'L5']) {
expect(criterionKey(`某判据(${level})`)).toBe(criterionKey('某判据'));
}
});
it('全角括号的等级标记也削 —— 模型爱把半角敲成全角', () => {
expect(criterionKey('某判据(L3)')).toBe(criterionKey('某判据'));
});
it('只削末尾那一个,不碰判据名里本来就有的括号', () => {
expect(criterionKey('吃不准降 B(不许臆断 A)')).toBe('吃不准降b(不许臆断a)');
});
it('照旧折全角、去空白 —— 老毛病不许回来', () => {
expect(criterionKey('吃不准降B,不许臆断A')).toBe(criterionKey('吃不准降 B,不许臆断 A'));
});
});
/**
* 同一句交代不进两遍对话区。
*
* 实测试跑 2e091d25:「有两颗料客户没写清楚……你看是我起草一封信去问,还是你直接找对方?」
* 一字不差连着吐了三拍(先 start 那一项、又 start 了一次、才发出决策卡)——
* 人在对话区看到同一个问题被问三遍,像卡带。
*
* 判定放在这儿单测:去空白后逐字比、只比紧邻的上一句。
* 近义句不拦(那是两次不同的交代),隔了几拍说回同一句也不拦(局面绕回来了)。
*/
function isRepeatSay(say: string, trail: ReadonlyArray<{ kind: string; title: string }>): boolean {
const now = say.trim().replace(/\s+/g, '');
const last = [...trail].reverse().find((t) => t.kind === 'narration');
return last !== undefined && last.title.trim().replace(/\s+/g, '') === now;
}
describe('对话区不结巴', () => {
const n = (title: string) => ({ kind: 'narration', title });
it('跟上一句一字不差 —— 压下', () => {
expect(isRepeatSay('缺两颗料的信息,要我起草封信吗?', [n('缺两颗料的信息,要我起草封信吗?')])).toBe(true);
});
it('只差空白也算同一句 —— 模型抄自己时空格常飘', () => {
expect(isRepeatSay('缺 两颗料 的信息', [n('缺两颗料的信息')])).toBe(true);
});
it('中间隔着别的痕,比的仍是**上一句叙事**', () => {
const trail = [n('缺两颗料的信息'), { kind: 'action', title: '写草稿:x 1 行' }, { kind: 'engine-transition', title: 'manualStart:S2' }];
expect(isRepeatSay('缺两颗料的信息', trail)).toBe(true);
});
it('近义但不同的句子 —— 放行,那是两次不同的交代', () => {
expect(isRepeatSay('历史价都查到了,接下来打标。', [n('三颗料的历史价都查到了,接下来打标。')])).toBe(false);
});
it('隔了一句别的又说回同一句 —— 放行,局面可能真绕回来了', () => {
expect(isRepeatSay('缺两颗料的信息', [n('缺两颗料的信息'), n('客户回话了')])).toBe(false);
});
it('一条叙事都还没有 —— 放行', () => {
expect(isRepeatSay('第一句话', [{ kind: 'action', title: '写草稿' }])).toBe(false);
});
});
/**
* **行数对不上时,把是哪几行也摆出来**(2026-08-01,连跑三次熔断逼出来的)。
*
* 行数类判据拒回时只给两个数,模型看得见「10 ≠ 9」,却不知道多的是哪一行 ——
* 它只能猜:改数、重写、再提,三条路都不对,连拒五拍熔断。实测三种形态全是这一类:
* · 记录写了 10 行而条目数报 9(同一颗料两个 PN 写法,或把客户说跳过的那条也写了);
* · 分类表 0 行而记录 10 行(它说「打完标了」,其实一行没写);
* · 替代请求 1 张而 C 类 0 条(给那颗料开了单,却没把它标成 C)。
* 三种一摆主键就都是一句话能办的事。
*/
describe('行数判据拒回:连主键名单一起摆', () => {
it('谓词读了 _rows,就把那张表本单写下的行摆出来', () => {
const out = readingsOf('quote_intake_record_rows = line_total', {
quote_intake_record_rows: 10,
line_total: 9,
quote_intake_record_keys: 'LM358DR、TL072CDR、NE555P',
});
expect(out).toContain('quote_intake_record_rows=10');
expect(out).toContain('line_total=9');
expect(out).toContain('是这几行:LM358DR、TL072CDR、NE555P');
});
it('两张表都读了 → 两份名单都摆,差集自己对得出来', () => {
const out = readingsOf('quote_intake_type_table_rows = quote_intake_record_rows', {
quote_intake_type_table_rows: 2,
quote_intake_record_rows: 3,
quote_intake_type_table_keys: 'A、B',
quote_intake_record_keys: 'A、B、C',
});
expect(out).toContain('是这几行:A、B;');
expect(out).toContain('是这几行:A、B、C');
});
it('那张表一行没写就没有名单 —— 不摆一句空的「是这几行:」', () => {
const out = readingsOf('quote_intake_alt_request_rows = type_c_total', {
quote_intake_alt_request_rows: 0,
type_c_total: 0,
});
expect(out).toBe('(现在:quote_intake_alt_request_rows=0、type_c_total=0)');
});
it('不读 _rows 的判据照旧只摆读数 —— 名单不是每条判据都要的', () => {
expect(readingsOf('missing_key_total = 0', { missing_key_total: 1, quote_intake_record_keys: 'X' })).toBe(
'(现在:missing_key_total=1)',
);
});
});
@@ -0,0 +1,67 @@
/**
* 阶段进度 —— 进度条的刻度。判据是「这一阶段的计划项**全收完**才算过」。
*/
import { describe, expect, it } from 'vitest';
import type { CasePhase } from '@contracts';
import { CLOSING_PHASE, phaseProgress } from '../internal/domain/phase.ts';
import type { ItemRuntime, ItemState } from '../internal/domain/lifecycle.ts';
/** A1 的三段:定位客户 / 归一受理 / 判定去向;放行那一格由进度条自己加 */
const PHASES: CasePhase[] = [
{ name: '定位客户', items: ['S1_customer'] },
{ name: '归一受理', items: ['S2_normalize'] },
{ name: '判定去向', items: ['S4_history', 'S5_classify', 'S6_route'] },
];
const items = (states: Record<string, ItemState>): ItemRuntime[] =>
Object.entries(states).map(([id, state]) => ({ id, state, instance: 1 }));
const ALL = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify', 'S6_route', 'T_release'];
const at = (done: readonly string[]): ItemRuntime[] =>
items(Object.fromEntries(ALL.map((id) => [id, done.includes(id) ? 'Completed' : 'Available'])));
describe('阶段进度 —— 三段激活态 + 一格收口 = x/4', () => {
it('一格都没收:停在第一阶段', () => {
expect(phaseProgress(PHASES, at([]), false)).toEqual({ index: 1, total: 4, name: '定位客户' });
});
it('逐段推进:收完一段才进下一段', () => {
expect(phaseProgress(PHASES, at(['S1_customer']), false)).toMatchObject({ index: 2, name: '归一受理' });
expect(phaseProgress(PHASES, at(['S1_customer', 'S2_normalize']), false)).toMatchObject({
index: 3,
name: '判定去向',
});
});
it('一段里的项没收齐就不算过 —— 三项收两项仍停在这一段', () => {
const done = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify'];
expect(phaseProgress(PHASES, at(done), false)).toMatchObject({ index: 3, name: '判定去向' });
});
it('三段都过、还没收口:进最后一格(等放行),但不是终态', () => {
const done = ['S1_customer', 'S2_normalize', 'S4_history', 'S5_classify', 'S6_route'];
expect(phaseProgress(PHASES, at(done), false)).toEqual({ index: 4, total: 4, name: CLOSING_PHASE });
});
it('终态直接给最后一格 —— 不去数计划项', () => {
// 放行完成那一瞬 T_release 还没收:去数项的话会显示「还差一格」,而单已经收口了
expect(phaseProgress(PHASES, at([]), true)).toEqual({ index: 4, total: 4, name: CLOSING_PHASE });
});
it('被跳过的裁量项(Terminated)算过 —— 它不会再收,等它等到天亮', () => {
const mixed = items({
S1_customer: 'Completed',
S2_normalize: 'Completed',
S4_history: 'Completed',
S5_classify: 'Terminated',
S6_route: 'Completed',
T_release: 'Available',
});
expect(phaseProgress(PHASES, mixed, false)).toMatchObject({ index: 4, name: CLOSING_PHASE });
});
it('没声明阶段就不给刻度 —— 界面照此不画进度条,不编一个出来', () => {
expect(phaseProgress(undefined, at([]), false)).toBeUndefined();
expect(phaseProgress([], at([]), false)).toBeUndefined();
});
});
File diff suppressed because it is too large Load Diff