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

手改是**兜 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
@@ -16,7 +16,7 @@ const entry = (kind: string, over: Partial<NarrativeEntry> = {}): NarrativeEntry
describe('projectChat —— 对话流与工作日志同源不同筛选', () => {
it('引擎自转的条目一律不进对话区', () => {
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'card-reply', 'control'];
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'control'];
expect(projectChat(noise.map((k) => entry(k)))).toEqual([]);
});
@@ -54,9 +54,174 @@ describe('projectChat —— 对话流与工作日志同源不同筛选', () =>
expect(projectChat([entry('card-sent', { detail: { kind: 'chat-card', id: 'c-1' } })])).toEqual([]);
});
it('放行裁决与终态投成阶段结论', () => {
const out = projectChat([entry('release-verdict', { id: 'r' }), entry('terminal', { id: 't' })]);
/*
* **顺利收尾一条都不出**(2026-08-02 用户当场指出:「终态:released 删除」)。
*
* 从前这一段连出三条:「裁决:pass」「落地收敛,发射输出口:intake」「终态:released」——
* 一件事说三遍。2026-08-01 先砍成一条(留终态);同日验收卡办结后换成了「落地结果」
* (落了几行、进了哪几张表、转去哪儿了),那一条也就**没话可说了** ——
* 在一件已经交代清楚的事后面补一句「结束了」,是废话;何况它摆的还是英文状态名。
*/
it('**顺利收尾:对话区一条都不出** —— 落地结果那一屏已经说全了', () => {
const out = projectChat([
entry('release-verdict', { id: 'r', title: '裁决:pass' }),
entry('release-verdict', { id: 'r2', title: '落地收敛,发射输出口:intake' }),
entry('terminal', { id: 't', title: '终态:released' }),
]);
expect(out).toHaveLength(0);
});
/*
* **没顺利收尾的仍要出声** —— 那时落地结果那一屏根本不会出现,
* 而人需要知道这一单停在哪儿了。措辞取共用字典,不摆 `blocked` 这种词。
*/
it('卡住 / 退回 / 预算耗尽:出一条,且说人话', () => {
for (const [state, said] of [
['blocked', '这一单受阻待人'],
['exhausted', '这一单预算耗尽'],
['stalled', '这一单停滞熔断'],
] as const) {
const out = projectChat([entry('terminal', { id: 't', title: `终态:${state}` })]);
expect(out).toHaveLength(1);
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe(said);
expect(out[0]?.k === 'ai' ? out[0].tag : undefined).toBe('conclusion');
}
});
it('认不出的状态原样留着 —— 宁可笼统,不可编', () => {
const out = projectChat([entry('terminal', { id: 't', title: '终态:什么新状态' })]);
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe('终态:什么新状态');
});
});
/**
* **一个气泡:结论 + 功能卡**(2026-08-01 用户定的形状)。
*
* 交代与卡本是同一轮的两半 —— 结论说「这一轮办完了什么、为什么找你」,
* 卡是「你要办的那件事」。各占一个气泡,每轮就是两条:一条话、
* 一条只有标题加个按钮的卡,而卡的标题多半是那句话的缩写。
*/
describe('同一轮的交代与卡并成一条', () => {
const say = (id: string, text: string) => entry('narration', { id, title: text });
const card = (id: string, title: string, wo: string) =>
entry('card-sent', { id, title, detail: { kind: 'work-order', id: wo } });
it('刚说过话就发卡 → 一个气泡,话是正文、卡挂在上面', () => {
const out = projectChat([say('n', '九条料都拆好了,缺两格,请核对确认'), card('c', '受理需求确认', 'wo-1')]);
expect(out).toHaveLength(1);
expect(out[0]?.k === 'ai' ? out[0].text : undefined).toBe('九条料都拆好了,缺两格,请核对确认');
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-1');
});
it('并进去之后**标记跟着卡走** —— 验收卡要 criterion,裁决表单才渲得出来', () => {
const out = projectChat([say('n', '判据我都裁过了,交你签'), card('c', '收口验收', 'wo-2')]);
expect(out[0]?.k === 'ai' ? out[0].tag : undefined).toBe('criterion');
});
it('**没说话就自成一条** —— 卡不能不出现', () => {
const out = projectChat([card('c', '受理需求确认', 'wo-3')]);
expect(out).toHaveLength(1);
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-3');
});
it('一句话只领一张卡 —— 同拍两张卡的第二张自成一条,不覆盖第一张', () => {
const out = projectChat([say('n', '两件事要你办'), card('a', '卡一', 'wo-a'), card('b', '卡二', 'wo-b')]);
expect(out.map((m) => (m.k === 'ai' ? m.ref : undefined))).toEqual(['wo-a', 'wo-b']);
});
it('隔着一条系统事件就不并 —— 那是上一轮的话,不是这张卡的结论', () => {
const out = projectChat([
say('n', '我去问客户'),
entry('observation', { id: 'o', title: '客户回信了' }),
card('c', '受理需求确认', 'wo-4'),
]);
expect(out).toHaveLength(3);
});
it('收尾那条结论不认领卡 —— 它是这一单的句号,不是派工', () => {
// 用**没顺利收尾**的那种:released 起同日不再出声(见上一组)
const out = projectChat([entry('terminal', { id: 't', title: '终态:blocked' }), card('c', '卡', 'wo-5')]);
expect(out).toHaveLength(2);
expect(out.every((m) => m.k === 'ai' && m.tag === 'conclusion')).toBe(true);
});
/*
* **结论口吻的交代照样并卡**。
*
* 挡住并卡的判断一度写成 `tag !== 'conclusion'` —— 而交代本身常常就是结论口吻
* (「判据我都裁过了,交你签」),模型给它标 conclusion 完全正当。
* 实测:第一轮并对了、收口那一轮没并 —— 同一屏两种样子。
* 不许并卡的只有**收尾那一句**(terminal),按来路认,不按语气签认。
*/
it('模型把交代标成 conclusion,卡照样并进去', () => {
const out = projectChat([
entry('narration', { id: 'n', title: '判据我都裁过了,交你签', detail: { tag: 'conclusion' } }),
card('c', '收口验收', 'wo-6'),
]);
expect(out).toHaveLength(1);
expect(out[0]?.k === 'ai' ? out[0].ref : undefined).toBe('wo-6');
});
});
/**
* **AI 会不会说话** —— 这一类从前没有,后果是对话区从头到尾空白。
*
* 实测:A1 跑十几拍、写了七行台账、调了两次工具,负责人的对话区一个字也没有 ——
* 他只看得见「多了一个任务」,不知道机器干到哪儿、凭什么这么判。
* 「人只在责任点与放行点出现」不等于「过程中人什么都看不见」。
*/
describe('narration —— AI 对人说的话进对话区', () => {
const entry = (kind: string, title: string, detail?: Record<string, unknown>) => ({
id: '11111111-1111-4111-8111-111111111111',
at: '2026-07-30T01:00:00.000Z',
kind,
title,
...(detail === undefined ? {} : { detail }),
});
it('交代进展投成 AI 发言', () => {
const out = projectChat([entry('narration', '三条已归一,缺 1 个交期')]);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ k: 'ai', text: '三条已归一,缺 1 个交期', tag: 'guidance' });
});
it('语气签照活动标的来;标不出按「指导」计', () => {
expect(projectChat([entry('narration', 'x', { tag: 'conclusion' })])[0]).toMatchObject({ tag: 'conclusion' });
expect(projectChat([entry('narration', 'x', { tag: '我编的签' })])[0]).toMatchObject({ tag: 'guidance' });
});
it('机器踪迹照旧不进对话区 —— 它不是日志的副本', () => {
const out = projectChat([
entry('action', '写草稿:quote_intake.record 3 行'),
entry('engine-transition', 'manualStart:S_intake'),
entry('result', 'crm.history-price-read 返回'),
entry('thought', '装配技能:intake.read-rfq@2.0.0'),
]);
expect(out).toHaveLength(0);
});
});
describe('回执与内部观察(PRD:页面交互是三类唤醒事件之一)', () => {
it('执行工单回传投成人事件回执 —— 人办完一件事,对话流上要有一句人话的痕', () => {
// 认的是 `detail.mode`,不是标题前缀:措辞是给人看的、随时会改,改了这条不该失灵
const out = projectChat([
entry('card-reply', { id: 'r1', title: '补全三条料的品牌', detail: { mode: 'execution' } }),
]);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({ k: 'system', type: 'human' });
expect((out[0] as { text: string }).text).toBe('「补全三条料的品牌」已办结,AI 接着往下推。');
});
it('对话卡回传不投 —— 卡自己会翻成已答态,再来一条回执是重影', () => {
expect(projectChat([entry('card-reply', { id: 'r2', title: '补齐品牌', detail: { k: 'elicitation' } })])).toHaveLength(
0,
);
});
it('引擎内部观察(internal 标)进日志不进对话', () => {
expect(projectChat([entry('observation', { id: 'o9', title: 'S1 已在进行中 —— 无需再启动', detail: { internal: true } })])).toHaveLength(0);
});
it('裸「事件到达(human):卡号」不投 —— 对人只是一串 UUID,回执已用人话说过', () => {
expect(projectChat([entry('observation', { id: 'oa', title: '事件到达(human):abcd-123', detail: { kind: 'human' } })])).toHaveLength(0);
});
});
@@ -108,3 +108,173 @@ describe('整键优先 —— 台账地址本身带点号', () => {
expect(out).toContain('<td>LM358DR</td>');
});
});
/**
* `data-blank` —— 「这一格客户没给」与「这一格填了个破折号」是两件事。
*
* 报告里的必填 / 选填胶囊、缺必填的整行标底,全靠这一格**真的空**才生效
* (`td:empty::after` / `tr:has(td.req:empty)`)。填了「—」就永远标不出来。
*/
describe('data-blank:空值真的留空', () => {
it('空串 / null / 缺席一律留空,属性随槽位一并消费', () => {
const html = '<td class="req" data-blank data-slot="eau"></td>';
expect(renderView(html, { eau: '' })).toBe('<td class="req"></td>');
expect(renderView(html, { eau: null })).toBe('<td class="req"></td>');
expect(renderView(html, {})).toBe('<td class="req"></td>');
});
it('有值照填 —— 0 与 false 都是值,不算空', () => {
const html = '<td class="req" data-blank data-slot="v"></td>';
expect(renderView(html, { v: 30_000 })).toBe('<td class="req">30000</td>');
expect(renderView(html, { v: 0 })).toBe('<td class="req">0</td>');
expect(renderView(html, { v: false })).toBe('<td class="req">否</td>');
});
it('不带 data-blank 的照旧填「—」—— 老模版行为不动', () => {
expect(renderView('<b data-slot="x"></b>', { x: '' })).toBe('<b>—</b>');
});
it('逐行展开时每行各判各的空', () => {
const out = renderView('<tbody data-rows="rs"><tr><td class="req" data-blank data-slot="eau"></td></tr></tbody>', {
rs: [{ eau: 30_000 }, { eau: '' }],
});
expect(out).toBe('<tbody><tr><td class="req">30000</td></tr><tr><td class="req"></td></tr></tbody>');
});
});
/**
* `data-class-of` —— **按值上色**,模版仍然不许算。
*
* 「A 绿 / B 橙 / C 紫」这类分档表现,从前只能三个值一个色 —— 那等于假装读懂了值。
* 把值投影成 class 仍然只是取值:没有表达式、没有分支,判断留在样式表里。
* 属性位是唯一一处让值进属性的地方,所以消毒必须硬。
*/
describe('data-class-of:按值上色', () => {
it('值消毒成 v-<值> 追加进 class,属性随之消费', () => {
const html = '<td class="pill" data-class-of="type_tag" data-slot="type_tag"></td>';
expect(renderView(html, { type_tag: 'A' })).toBe('<td class="pill v-a">A</td>');
expect(renderView(html, { type_tag: 'C' })).toBe('<td class="pill v-c">C</td>');
});
it('没有 class 属性就新建一个', () => {
expect(renderView('<i data-class-of="k" data-slot="k"></i>', { k: 'B' })).toBe('<i class="v-b">B</i>');
});
it('布尔与数字都投影得出来 —— 是/否两态也要能分色', () => {
expect(renderView('<b data-class-of="f" data-slot="f"></b>', { f: true })).toBe('<b class="v-true">是</b>');
expect(renderView('<b data-class-of="n" data-slot="n"></b>', { n: 0 })).toBe('<b class="v-0">0</b>');
});
it('**值只放行 ASCII 字母数字与横杠**:引号、尖括号、空格全剔掉,注不进属性', () => {
const out = renderView('<b class="x" data-class-of="k" data-slot="k"></b>', { k: '" onclick=alert(1) <b>' });
// class 里只剩白名单字符;文本位照旧转义 —— 两处都注不进去
expect(out).toBe('<b class="x v-onclickalert1b">&quot; onclick=alert(1) &lt;b&gt;</b>');
expect(out).not.toContain('onclick=alert(1)"');
});
it('一个合规字符都没有(中文 / 空值)就不给 class —— 宁可没颜色,不给错钩子', () => {
expect(renderView('<b class="x" data-class-of="k" data-slot="k"></b>', { k: '老客户' })).toBe('<b class="x">老客户</b>');
expect(renderView('<b class="x" data-class-of="k" data-slot="k"></b>', {})).toBe('<b class="x">—</b>');
});
it('逐行展开时每行按自己的值上色', () => {
const out = renderView('<tbody data-rows="rs"><tr><td data-class-of="t" data-slot="t"></td></tr></tbody>', {
rs: [{ t: 'A' }, { t: 'C' }],
});
expect(out).toBe('<tbody><tr><td class="v-a">A</td></tr><tr><td class="v-c">C</td></tr></tbody>');
});
/*
* **上色不以填字为条件** —— 这一组是补的账。
*
* 从前只认「带 data-slot 的元素」,于是分布条那种纯色块
* (`<i data-class-of="type_tag"></i>`,一格一条料、格里不写字)整条静默失色:
* 属性还留在成品上,一个 class 都没长出来。屏上是一排灰格子 ——
* 而灰格子看着像「还没算出来」,不像「没上色」,查了两遍才认出来。
* 上面每一条老用例都同时写了 data-slot,所以谁都没拦住它。
*/
it('**只上色不填字**:没有 data-slot 也照样长出 class', () => {
expect(renderView('<i data-class-of="t"></i>', { t: 'B' })).toBe('<i class="v-b"></i>');
expect(renderView('<i class="seg" data-class-of="t"></i>', { t: 'C' })).toBe('<i class="seg v-c"></i>');
});
it('只上色的格子逐行展开,每格按自己那一行的值', () => {
const out = renderView('<div class="bar" data-rows="rs"><i data-class-of="t"></i></div>', {
rs: [{ t: 'A' }, { t: 'B' }, { t: 'C' }],
});
expect(out).toBe('<div class="bar"><i class="v-a"></i><i class="v-b"></i><i class="v-c"></i></div>');
});
it('值取不到就把属性摘掉了事 —— 不留半成品属性到成品上', () => {
expect(renderView('<i class="seg" data-class-of="nope"></i>', {})).toBe('<i class="seg"></i>');
expect(renderView('<i data-class-of="k"></i>', { k: '老客户' })).toBe('<i></i>');
});
it('壳上的 data-class-of 按外层作用域上色,行内的仍按各自那一行', () => {
const out = renderView('<div data-class-of="hit" data-rows="rs"><i data-class-of="t"></i></div>', {
hit: 'r1',
rs: [{ t: 'A' }, { t: 'C' }],
});
expect(out).toBe('<div class="v-r1"><i class="v-a"></i><i class="v-c"></i></div>');
});
});
/**
* `data-edit` + `data-col` —— **就地改**:可改的格子渲两份,只读那份与输入孪生件。
*
* 为什么两份都渲、而不是渲成输入:同一份件既要在待处理页给人改,
* 又要在工作成果里定稿归档 —— 两处是同一份 HTML。渲两套等于存两份、也就会分叉。
* 显示哪一份由样式表按根节点上的 `editing` 决定,填数这一侧仍然只做取值。
*/
describe('data-edit / data-col:可改格子的输入孪生件', () => {
const ROWS = '<tbody data-rows="rs" data-edit="d.o"><tr><td><span class="jc-vcell mono" data-slot="pn" data-col="pn"></span></td></tr></tbody>';
it('容器标了 data-edit,格子就多一个带值的 input', () => {
const out = renderView(ROWS, { rs: [{ pn: 'AO6604' }] });
expect(out).toContain('<span class="jc-vcell mono">AO6604</span>');
expect(out).toContain('<input class="jc-cell mono" data-col="pn" data-row="0" value="AO6604"/>');
});
it('没标 data-edit 的表一个 input 都不长 —— 归档件就是件,不是表单', () => {
const out = renderView(ROWS.replace(' data-edit="d.o"', ''), { rs: [{ pn: 'AO6604' }] });
expect(out).not.toContain('<input');
});
it('行号钉在每一行上 —— 写回时按它对行', () => {
const out = renderView(ROWS, { rs: [{ pn: 'A' }, { pn: 'B' }, { pn: 'C' }] });
expect(out.match(/data-row="\d"/g)).toEqual(['data-row="0"', 'data-row="1"', 'data-row="2"']);
});
it('空值的 input 真空着 —— 空的输入框显示自己的 placeholder,那正是「这一格还没有」', () => {
const out = renderView(ROWS, { rs: [{ pn: '' }] });
expect(out).toContain('value=""');
});
it('**值进属性也要转义** —— 属性位插值只此一处,引号进去就是逃逸', () => {
const out = renderView(ROWS, { rs: [{ pn: '" onfocus=alert(1) x="' }] });
expect(out).toContain('value="&quot; onfocus=alert(1) x=&quot;"');
expect(out).not.toMatch(/value="" onfocus/);
});
it('排版类跟着走(mono / num),别的类不带过去 —— 同一列两态字体要一致', () => {
const num = ROWS.replace('jc-vcell mono', 'jc-vcell num req');
expect(renderView(num, { rs: [{ pn: '30000' }] })).toContain('<input class="jc-cell num"');
});
});
describe('渲染两遍不叠加 —— 交出去的是干净 HTML', () => {
it('第二遍渲染不会再长一个 input(属性已被消费)', () => {
const once = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
rs: [{ pn: 'X' }],
});
expect((once.match(/<input/g) ?? []).length).toBe(1);
expect(renderView(once, {})).toBe(once);
});
it('模版里仍然没有脚本 —— 输入件是纯 HTML,没有事件处理器', () => {
const out = renderView('<tbody data-rows="rs" data-edit="d.o"><tr><td><span data-slot="pn" data-col="pn"></span></td></tr></tbody>', {
rs: [{ pn: 'X' }],
});
expect(out).not.toMatch(/<script|\son[a-z]+=/i);
});
});