fix(loop-engine): 配方少写的行要出声,撤掉的行要点名

**M1**:主键算不出来那一行从前是静默 `continue` —— 既不计数也不留痕。
表里少了行,人和判据都查不出为什么。现在这一笔结束时点明少了几行、主键列是哪几个。

**M2**:`prune` 撤行从前只说「撤掉 M 行」。2026-08-02 为人工撤行立过
「撤要说出来、要点名哪几行」的规矩(不说清,下一拍的读者会把人刚删的写回去),
配方这一侧跟上:点名撤了哪几行,封顶 8 个,**并去掉单号那一段** ——
人正在这一单里看,单号是脚下的地面,不是区分行的信息(2026-08-25 同一条口径,
基座那一侧是 `rowLabel`)。为此 `TaskDataGateway.writable` 补一个可选的 `caseKey`。

3 测(少写出声 / 撤行点名 / 名单不带单号)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
xcs
2026-08-29 08:43:53 +08:00
co-authored by Claude Opus 5
parent 588d5c2dd4
commit 642bdcef3b
4 changed files with 75 additions and 8 deletions
+2
View File
@@ -1022,6 +1022,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
object: d.object, object: d.object,
label: d.label ?? `${d.domain}.${d.object}`, label: d.label ?? `${d.domain}.${d.object}`,
keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name), keys: d.fields.filter((f) => f.primaryKey).map((f) => f.name),
/* 单标识列 —— 配方留痕点名行时按它削掉单号那一段(与 `rowLabel` 同一条口径) */
...(d.caseKey === undefined ? {} : { caseKey: d.caseKey }),
fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })), fields: d.fields.map((f) => ({ name: f.name, label: f.label ?? f.name, type: f.type, refersTo: f.refersTo })),
createOnly: d.createOnly === true, createOnly: d.createOnly === true,
})); }));
@@ -62,7 +62,7 @@ function harness(seed: Record<string, unknown> = {}, override: { analyze?: Analy
view: () => Promise.resolve(view()), view: () => Promise.resolve(view()),
writable: () => writable: () =>
Promise.resolve([ Promise.resolve([
{ domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], fields: [] }, { domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], caseKey: 'eval_ref', fields: [] },
{ domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] }, { domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] },
]), ]),
/** /**
@@ -173,6 +173,31 @@ describe('配方执行器', () => {
expect(drafts.size).toBe(0); expect(drafts.size).toBe(0);
}); });
/*
* **少写了几行要出声**(2026-08-29 终审 M1):从前主键算不出来是静默 `continue` ——
* 既不计数也不留痕。表里少了行,人和判据都查不出为什么。
*/
it('主键算不出来:留一句痕点名少了几行、主键列是哪几个', async () => {
const { runner, notes, ctx } = harness({});
await runner.run(
{
do: [
{
kind: 'project',
into: 'sub_eval.part',
from: { rows: [{ pn: 'A' }, { pn: '' }] },
fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } },
},
],
},
ctx,
);
const line = notes.find((n) => n.includes('主键算不出来'));
expect(line).toBeDefined();
expect(line).toContain('1 行因主键算不出来没写');
expect(line).toContain('eval_ref、internal_pn');
});
it('when 为假就跳过这一笔', async () => { it('when 为假就跳过这一笔', async () => {
const { runner, drafts, ctx } = harness({}); const { runner, drafts, ctx } = harness({});
await runner.run({ do: [{ kind: 'project', when: 'never', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } }] }, ctx); await runner.run({ do: [{ kind: 'project', when: 'never', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } }] }, ctx);
@@ -202,8 +227,8 @@ describe('配方执行器', () => {
expect(second.wrote).toBe(0); expect(second.wrote).toBe(0);
}); });
it('prune:这一笔没投影出来的行,从草稿层撤掉', async () => { it('prune:这一笔没投影出来的行,从草稿层撤掉,**并点名撤了哪几行**(M2)', async () => {
const { runner, drafts, ctx } = harness({}); const { runner, drafts, notes, ctx } = harness({});
await runner.run({ do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx); await runner.run({ do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx);
expect(drafts.size).toBe(2); expect(drafts.size).toBe(2);
const out = await runner.run( const out = await runner.run(
@@ -212,6 +237,10 @@ describe('配方执行器', () => {
); );
expect(out.dropped).toBe(1); expect(out.dropped).toBe(1);
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']); expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']);
/* 点名,且**去掉单号那一段** —— 人正在这一单里看,`W05-1` 是脚下的地面(2026-08-25 那条口径) */
const line = notes.find((n) => n.includes('撤掉 1 行'));
expect(line).toContain('撤掉 1 行(B)');
expect(line).not.toContain('W05-1');
}); });
it('say:进工作日志,归系统', async () => { it('say:进工作日志,归系统', async () => {
@@ -54,6 +54,23 @@ export type AutoOutcome = {
const keyText = (key: Readonly<Record<string, string>>): string => Object.values(key).join('|'); const keyText = (key: Readonly<Record<string, string>>): string => Object.values(key).join('|');
/**
* 摆给人看的行标识 —— **去掉单号那一段**(2026-08-25 柴浩:「只保留斜杠右边」):
* 人正在这一单里看,单号是脚下的地面,不是区分行的信息。与基座的 `rowLabel` 同一条口径。
* 去完一段都不剩(主键只有单号)就退回整段。
*/
function rowName(key: Readonly<Record<string, string>>, caseKey: string | undefined): string {
const rest = Object.entries(key)
.filter(([column]) => column !== caseKey)
.map(([, value]) => value);
return (rest.length > 0 ? rest : Object.values(key)).join('/');
}
/** 点名封顶 8 个 —— 再多就不是「点名」而是刷屏(拒回主键名单封顶 12 是同一条理由) */
function nameList(names: readonly string[]): string {
return names.length <= 8 ? names.join('、') : `${names.slice(0, 8).join('、')}…等 ${names.length} 行`;
}
/** 这份配方会不会发卡 —— 「卡答过了就收步」那道门要问它 */ /** 这份配方会不会发卡 —— 「卡答过了就收步」那道门要问它 */
export function recipeIssuesCard(recipe: AutoRecipe): boolean { export function recipeIssuesCard(recipe: AutoRecipe): boolean {
return recipe.do.some((action) => action.kind === 'card'); return recipe.do.some((action) => action.kind === 'card');
@@ -112,6 +129,10 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
let localWrote = 0; let localWrote = 0;
/** 「只建不改」的表里已经在库、这一笔不用建的行数 */ /** 「只建不改」的表里已经在库、这一笔不用建的行数 */
let localExisting = 0; let localExisting = 0;
/** 主键算不出来、这一笔没写下去的行数 —— 不出声的话表里少了行谁也查不出为什么(M1) */
let localUnkeyed = 0;
/** 撤掉的是哪几行 —— 「撤要说出来、要点名」(2026-08-02 为人工撤行立的规矩,配方这一侧跟上,M2) */
const dropNames: string[] = [];
for (const { row, outer } of expandSource(action.from, base)) { for (const { row, outer } of expandSource(action.from, base)) {
const computed: Record<string, unknown> = {}; const computed: Record<string, unknown> = {};
for (const [name, expr] of Object.entries(action.fields)) { for (const [name, expr] of Object.entries(action.fields)) {
@@ -119,8 +140,11 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
} }
const key: Record<string, string> = {}; const key: Record<string, string> = {};
for (const column of decl.keys) key[column] = String(computed[column] ?? ''); for (const column of decl.keys) key[column] = String(computed[column] ?? '');
/* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾 */ /* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾。**但要数一笔**(M1) */
if (decl.keys.some((column) => key[column] === '')) continue; if (decl.keys.some((column) => key[column] === '')) {
localUnkeyed += 1;
continue;
}
const patch: Record<string, unknown> = {}; const patch: Record<string, unknown> = {};
for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value; for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value;
/* /*
@@ -160,10 +184,13 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
for (const column of decl.keys) key[column] = String(stale[column] ?? ''); for (const column of decl.keys) key[column] = String(stale[column] ?? '');
if (kept.has(keyText(key))) continue; if (kept.has(keyText(key))) continue;
const { dropped: gone } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key }); const { dropped: gone } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key });
if (gone) localDropped += 1; if (gone) {
localDropped += 1;
dropNames.push(rowName(key, decl.caseKey));
}
} }
} }
if (localWrote > 0 || localDropped > 0 || localExisting > 0) { if (localWrote > 0 || localDropped > 0 || localExisting > 0 || localUnkeyed > 0) {
/* /*
* **kind: 'observation' + `internal: true`**(2026-08-29 终审 I5,控制端裁定 F2)。 * **kind: 'observation' + `internal: true`**(2026-08-29 终审 I5,控制端裁定 F2)。
* `kind` 必须是 `observation`:`result` 不在 `task-log.ts` 的 `SKELETON` 里,进不了工作日志(R10)。 * `kind` 必须是 `observation`:`result` 不在 `task-log.ts` 的 `SKELETON` 里,进不了工作日志(R10)。
@@ -178,7 +205,10 @@ export function createAutoRunner(deps: AutoRunnerDeps): {
title: title:
`${decl.label}:写下 ${localWrote} 行` + `${decl.label}:写下 ${localWrote} 行` +
(localExisting === 0 ? '' : `,${localExisting} 行已在库、这一笔不用建`) + (localExisting === 0 ? '' : `,${localExisting} 行已在库、这一笔不用建`) +
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`), /* 撤了要点名(M2):不点名的话,下一拍的读者(人或模型)对不上是哪几行没了 */
(localDropped === 0 ? '' : `,撤掉 ${localDropped} 行(${nameList(dropNames)})`) +
/* 少写了几行要出声(M1):静默 continue 的那几行,表里少了谁也查不出为什么 */
(localUnkeyed === 0 ? '' : `,${localUnkeyed} 行因主键算不出来没写(主键列:${decl.keys.join('、')})`),
detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into, internal: true }, detail: { auto: `${context.stepId}#${index + 1}`, ledger: action.into, internal: true },
}); });
} }
+6
View File
@@ -44,6 +44,12 @@ export type TaskDataGateway = {
object: string; object: string;
label: string; label: string;
keys: string[]; keys: string[];
/**
* 单标识列(`declarations.ledger[].caseKey`)—— **摆给人看的行标识要去掉这一段**
* (2026-08-25 柴浩:「COV-…/CU-0142-01 只保留斜杠右边」):人正在这一单里看,
* 单号是脚下的地面,不是区分行的信息。基座那一侧同款做法见 `rowLabel`。
*/
caseKey?: string | undefined;
fields: Array<{ name: string; label: string; type?: string }>; fields: Array<{ name: string; label: string; type?: string }>;
}> }>
>; >;