From 2d20202b1d9c262f0087e59c91997e72560fb4f0 Mon Sep 17 00:00:00 2001 From: xcs Date: Sat, 29 Aug 2026 08:36:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20=E9=85=8D=E6=96=B9=E7=95=99?= =?UTF-8?q?=E7=97=95=E6=81=A2=E5=A4=8D=20internal,=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=8C=89=20detail.auto=20=E5=8F=A6=E5=BC=80?= =?UTF-8?q?=E4=BE=8B=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `internal` 是 `machinery()`(工作日志)与 `chat-projection`(对话区)**共用的一个旗**。 上一轮为了让配方留痕进工作日志,把 project / analyze 那两笔的 `internal` 整个去掉了 —— 等于同时对两个投影开门:「芯片主数据:写下 2 行」「已把 8 条参数交给模型逐条判定」 这类机械痕一笔一个气泡跑进了对话区,而这个仓花了很大代价立起 「对话区一轮一句,不是一拍一句」「引擎内部观察进日志不进对话」。 治法与 `detail.say` 同款(控制端裁定 F2):配方那几笔照旧标 `internal`(对话区据此挡住), `machinery()` 这一侧给 `typeof detail.auto === 'string'` 开一道例外 —— **两件事各有各的开关**。`say` 与「办不下去」那两笔本来就该进对话区,保持不标 internal。 既有那几条「留痕真的走到工作日志」的用例全绿(它们现在喂的正是 internal:true 的形状, 恰好验这条新例外);另补 8 条:auto-runner 侧断言四类痕各自标不标 internal、 chat-projection 侧断言机械痕一个气泡不冒 / say 与失败照进、 task-log 侧断言例外只认结构化的 auto 键(不是字符串就不算)。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet --- .../__tests__/chat-projection.test.ts | 29 +++++++++++++++++++ .../loop-engine/__tests__/auto-runner.test.ts | 16 ++++++++-- .../internal/usecase/auto-runner.ts | 20 ++++++++----- .../memory/__tests__/task-memory.test.ts | 24 +++++++++++++++ .../memory/internal/domain/task-log.ts | 17 ++++++++++- 5 files changed, 95 insertions(+), 11 deletions(-) diff --git a/apps/api/src/modules/interaction/__tests__/chat-projection.test.ts b/apps/api/src/modules/interaction/__tests__/chat-projection.test.ts index e01708b..d32e27d 100644 --- a/apps/api/src/modules/interaction/__tests__/chat-projection.test.ts +++ b/apps/api/src/modules/interaction/__tests__/chat-projection.test.ts @@ -266,3 +266,32 @@ describe('回执与内部观察(PRD:页面交互是三类唤醒事件之一)', ( expect(projectChat([entry('observation', { id: 'oa', title: '事件到达(human):abcd-123', detail: { kind: 'human' } })])).toHaveLength(0); }); }); + +/** + * **配方的机械痕不进对话区**(2026-08-29 终审 I5,控制端裁定 F2)。 + * + * 上一轮为了让配方留痕进工作日志,把 `internal` 整个去掉了 —— 而 `internal` 是 + * `machinery()`(工作日志)与本文件(对话区)**共用的一个旗**,去掉它等于同时对两个投影开门: + * 「芯片主数据:写下 2 行」「已把 8 条参数交给模型逐条判定」这类一笔一个气泡跑进了对话区, + * 而这个仓花了很大代价立起「对话区一轮一句,不是一拍一句」。 + * 现在配方那几笔照旧标 `internal`(这里挡住),工作日志那一侧另开 `detail.auto` 的例外。 + * 形状取自 `auto-runner.ts` 真实写下的 detail。 + */ +describe('配方留痕:进工作日志,不进对话区(I5)', () => { + it('project / analyze 那两笔标着 internal —— 一个气泡都不该冒出来', () => { + const out = projectChat([ + entry('observation', { id: 'p1', title: '芯片主数据:写下 2 行', detail: { auto: 'S0_bind#1', ledger: 'parts.profile', internal: true } }), + entry('observation', { id: 'p2', title: '已把 8 条参数交给模型逐条判定,拿回 8 条结论', detail: { auto: 'S3#2', model: 'm', internal: true } }), + entry('observation', { id: 'p3', title: '这一步已经发过卡,不再发第二张', detail: { auto: 'S1#2', internal: true } }), + ]); + expect(out).toEqual([]); + }); + + it('say 与「办不下去」那两笔不标 internal —— 它们是说给人听的,照进', () => { + const out = projectChat([ + entry('observation', { id: 's1', title: '已核对 3 颗料,2 颗要建档', detail: { auto: 'S0#3' } }), + entry('observation', { id: 's2', title: '配方第 2 笔办不下去:台账地址不在本活动的声明里 —— 交给你处理', detail: { auto: 'S1#2' } }), + ]); + expect(out.map((m) => m.k)).toEqual(['system', 'system']); + }); +}); diff --git a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts index d52e9b5..6c6b7a2 100644 --- a/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts +++ b/apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts @@ -425,16 +425,17 @@ describe('card 与 submit', () => { * (没被 `SKELETON` / `machinery()` 滤掉)且 `who` 是 `system`。 */ describe('配方留痕真的到得了工作日志(spec §5.9 验收 · R10)', () => { - it('say 那一笔:进日志且归系统', async () => { + it('say 那一笔:进日志且归系统,**不标 internal**(它是人话,该进对话区)', async () => { const { runner, appended, ctx } = harness({ n: 2 }); await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx); expect(appended).toHaveLength(1); const lines = structuredLog([asLogEntry(appended[0]!)]); expect(lines).toHaveLength(1); expect(lines[0]?.who).toBe('system'); + expect(appended[0]?.detail?.['internal']).toBeUndefined(); }); - it('project 那一笔:进日志且归系统', async () => { + it('project 那一笔:进日志且归系统,**而且标着 internal(对话区那一侧据此挡住)**', async () => { const { runner, appended, ctx } = harness({}); await runner.run( { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b', role: 'original' } }] }, @@ -444,6 +445,13 @@ describe('配方留痕真的到得了工作日志(spec §5.9 验收 · R10)', () const lines = structuredLog([asLogEntry(appended[0]!)]); expect(lines).toHaveLength(1); expect(lines[0]?.who).toBe('system'); + /* + * **两件事各有各的开关**(2026-08-29 终审 I5):`internal` 同时是对话区的开关 —— + * 去掉它,这类机械痕会一笔一个气泡跑进对话区(见 `chat-projection.test.ts` 对应那条)。 + * 进不进工作日志由 `machinery()` 对 `detail.auto` 开的例外说了算,上面那一行已经验了。 + */ + expect(appended[0]?.detail?.['internal']).toBe(true); + expect(appended[0]?.detail?.['auto']).toBe('S1#1'); }); it('analyze 那一笔:进日志且归系统', async () => { @@ -470,15 +478,17 @@ describe('配方留痕真的到得了工作日志(spec §5.9 验收 · R10)', () const lines = structuredLog([asLogEntry(appended[0]!)]); expect(lines).toHaveLength(1); expect(lines[0]?.who).toBe('system'); + expect(appended[0]?.detail?.['internal']).toBe(true); }); - it('出错兜底那一笔:进日志且归系统', async () => { + it('出错兜底那一笔:进日志且归系统,**不标 internal**(交人的话得让人看见)', async () => { const { runner, appended, ctx } = harness({}); await runner.run({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }, ctx); expect(appended).toHaveLength(1); const lines = structuredLog([asLogEntry(appended[0]!)]); expect(lines).toHaveLength(1); expect(lines[0]?.who).toBe('system'); + expect(appended[0]?.detail?.['internal']).toBeUndefined(); }); }); diff --git a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts index 7e8b337..f37646c 100644 --- a/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts +++ b/apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts @@ -157,9 +157,12 @@ export function createAutoRunner(deps: AutoRunnerDeps): { } if (localWrote > 0 || localDropped > 0 || localExisting > 0) { /* - * **kind: 'observation'、不再标 internal**(2026-08-29,R10)—— 从前 `kind: 'result'` - * 连骨架都进不了;就算 kind 改对,`internal: true` 也会被 `machinery()` 挡下。 - * 这一笔是「系统做了什么」,恰是人回看时最想看的东西,不该当排障内部痕藏起来。 + * **kind: 'observation' + `internal: true`**(2026-08-29 终审 I5,控制端裁定 F2)。 + * `kind` 必须是 `observation`:`result` 不在 `task-log.ts` 的 `SKELETON` 里,进不了工作日志(R10)。 + * `internal` 必须留着:它是**对话区**那一侧的开关 —— 去掉之后这类机械痕 + * (「芯片主数据:写下 2 行」)会一笔一个气泡跑进对话区,而对话区的口径是 + * 「一轮一句,不是一拍一句」。工作日志那一侧由 `machinery()` 对 `detail.auto` + * 开的例外放行(与 `detail.say` 同款)——**两件事各有各的开关**。 */ await deps.memory.append({ taskId: context.taskId, @@ -168,7 +171,7 @@ export function createAutoRunner(deps: AutoRunnerDeps): { `${decl.label}:写下 ${localWrote} 行` + (localExisting === 0 ? '' : `,${localExisting} 行已在库、这一笔不用建`) + (localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`), - detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into }, + detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into, internal: true }, }); } return { wrote: localWrote, dropped: localDropped }; @@ -193,12 +196,12 @@ export function createAutoRunner(deps: AutoRunnerDeps): { /* `into` 既是出参里那个数组字段的名字,也是拍内作用域的键 —— 一处声明,两处认 */ const rows = object[action.into]; results[`result:${action.into}`] = Array.isArray(rows) ? rows : []; - /* kind: 'observation'、不再标 internal —— 理由同上(project 分支那条注释) */ + /* kind: 'observation' + internal —— 理由同上(project 分支那条注释) */ await deps.memory.append({ taskId: context.taskId, kind: 'observation', title: `已把 ${payload.length} 条参数交给模型逐条判定,拿回 ${results[`result:${action.into}`]?.length ?? 0} 条结论`, - detail: { auto: `${context.stepId}#${index + 1}`, model: action.model }, + detail: { auto: `${context.stepId}#${index + 1}`, model: action.model, internal: true }, }); /* 出参只活在拍内作用域里,不进草稿层 —— 下一笔要能看见它,所以这里也算「脏」 */ scope = { ...scope, ...results }; @@ -296,7 +299,10 @@ export function createAutoRunner(deps: AutoRunnerDeps): { if (done.stop) break; } catch (err) { const message = err instanceof Error ? err.message : String(err); - /* kind: 'observation'、去掉那个恒为 false 的 internal 键 —— 理由同上 */ + /* + * **失败这一笔不标 internal** —— 它是说给人听的(「交给你处理」), + * 该出现在对话区。`say` 同理(它本来就是人话)。(2026-08-29 终审 I5) + */ await deps.memory.append({ taskId: ctx.taskId, kind: 'observation', diff --git a/apps/api/src/modules/memory/__tests__/task-memory.test.ts b/apps/api/src/modules/memory/__tests__/task-memory.test.ts index 9a0a856..7346ebb 100644 --- a/apps/api/src/modules/memory/__tests__/task-memory.test.ts +++ b/apps/api/src/modules/memory/__tests__/task-memory.test.ts @@ -608,6 +608,30 @@ describe('压下的交代不该消失', () => { it('其余内部痕(排障用的原文留档)照旧不摆', () => { expect(structuredLog([entry('observation', { detail: { internal: true, raw: '{坏 JSON}' } })])).toHaveLength(0); }); + + /* + * **配方那几笔另开一道例外**(2026-08-29 终审 I5,控制端裁定 F2)。 + * `internal` 是 `machinery()`(工作日志)与 `chat-projection`(对话区)**共用的一个旗** —— + * 上一轮为了让配方留痕进工作日志把它整个去掉了,副作用是那些机械痕一笔一个气泡 + * 跑进了对话区。现在配方那几笔照旧标 `internal`(对话区据此挡住), + * 工作日志这一侧按 `detail.auto` 放行(与 `detail.say` 同款)。 + */ + it('**带 auto 的内部痕进日志** —— 配方做的事正是人回看时最想看的', () => { + const line = structuredLog([ + entry('observation', { + seq: 11, + title: '芯片主数据:写下 2 行', + detail: { internal: true, auto: 'S0_bind#1', ledger: 'parts.profile' }, + }), + ])[0]; + expect(line?.title).toBe('芯片主数据:写下 2 行'); + // 配方不是 AI 判的,也不是人办的 —— 归系统 + expect(line?.who).toBe('system'); + }); + + it('auto 不是字符串就不算例外 —— 只认结构化的那个键,不靠有没有这个词', () => { + expect(structuredLog([entry('observation', { detail: { internal: true, auto: true } })])).toHaveLength(0); + }); }); /** diff --git a/apps/api/src/modules/memory/internal/domain/task-log.ts b/apps/api/src/modules/memory/internal/domain/task-log.ts index 6be8deb..c7f5b64 100644 --- a/apps/api/src/modules/memory/internal/domain/task-log.ts +++ b/apps/api/src/modules/memory/internal/domain/task-log.ts @@ -356,7 +356,22 @@ function machinery(e: MemoryEntry): boolean { * 现在带 `say` 的放行(它是人话,正是要留的那样东西); * 其余内部痕(重问原文这类给排障用的)照旧不摆。 */ - if (e.detail?.['internal'] === true && typeof e.detail?.['say'] !== 'string') return true; + /* + * **`internal` 是两个投影共用的一个旗**(2026-08-29 终审 I5,控制端裁定 F2): + * `machinery()` 拿它决定「进不进工作日志」,`chat-projection` 拿同一个键决定「进不进对话区」。 + * 上一轮为了让配方留痕进工作日志,把它整个去掉了 —— 副作用是「芯片主数据:写下 2 行」 + * 「已把 8 条参数交给模型逐条判定」这类**机械痕跑进了对话区**,一笔一个气泡, + * 而这个仓花了很大代价立起「对话区一轮一句,不是一拍一句」。 + * 治法与 `say` 同款:给 `detail.auto` 开一个例外 —— 配方那几笔照旧标 `internal` + * (对话区据此挡住),工作日志这一侧按 `auto` 放行。**两件事各有各的开关。** + */ + if ( + e.detail?.['internal'] === true && + typeof e.detail?.['say'] !== 'string' && + typeof e.detail?.['auto'] !== 'string' + ) { + return true; + } if (RESTATED.some((re) => re.test(e.title))) return true; if (e.kind === 'thought') return true; if (e.kind === 'engine-transition' && e.title.startsWith('装载案例模型')) return true;