Archived
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:
@@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }>;
|
||||||
}>
|
}>
|
||||||
>;
|
>;
|
||||||
|
|||||||
Reference in New Issue
Block a user