feat(loop-engine): 配方执行器 —— project / prune / say,每一笔之前刷读数

三条硬规矩:每一笔之前刷读数(后一笔要数前一笔刚写下的行,不刷新就恒为 0);
值没变的写静默跳过(配方不是模型,原地打转不必被看见);
出错交人不重试不退回模型(配方抛错是包的 bug,退回模型就是让它去凑)。
主键算不出来的那一行先不写 —— 空主键写下去是一行永远对不上的垃圾。

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 03:41:34 +08:00
co-authored by Claude Opus 5
parent 58ff4f1b90
commit b0a3cc3a55
8 changed files with 375 additions and 1 deletions
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest';
import type { AutoRecipe } from '@contracts';
import { taskIdSchema, staffIdSchema } from '@contracts';
import { createAutoRunner } from '../internal/usecase/auto-runner.ts';
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
const OWNER = staffIdSchema.parse('22222222-2222-4222-8222-222222222222');
/** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */
function harness(seed: Record<string, unknown> = {}) {
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
const notes: string[] = [];
const view = (): Record<string, unknown> => {
const mine: Record<string, unknown[]> = {};
for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch });
return { ...seed, ...mine };
};
const deps = {
data: {
view: () => Promise.resolve(view()),
writable: () =>
Promise.resolve([
{ domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], fields: [] },
{ domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] },
]),
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }) => {
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
const before = JSON.stringify(drafts.get(id)?.patch ?? null);
drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: i.patch });
return Promise.resolve({ changed: before !== JSON.stringify(i.patch) });
},
draftDrop: (i: { domain: string; object: string; key: Record<string, string> }) => {
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
return Promise.resolve({ dropped: drafts.delete(id) });
},
},
cards: {
issue: () => Promise.resolve({ id: 'card-1' }),
decide: () => Promise.resolve({ id: 'card-2' }),
entry: () => Promise.resolve({ id: 'card-3' }),
ask: () => Promise.resolve({ id: 'card-4' }),
unanswered: () => Promise.resolve([]),
},
memory: { append: (m: { title: string }) => { notes.push(m.title); return Promise.resolve(); } },
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
sfeel: {
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
if (expr === 'never') return false;
if (expr === 'always') return true;
if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== '';
throw new Error(`没教过这句:${expr}`);
},
expression: () => undefined,
unaryTest: () => false,
},
};
const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
// 假端口只实现用得到的那几个方法,形状对不上 AutoRunnerDeps 的全貌 —— 测试文件里 `as never` 是既有做法
return { runner: createAutoRunner(deps as never), drafts, notes, ctx };
}
describe('配方执行器', () => {
it('project once:写一行,主键与其余列分开', async () => {
const { runner, drafts, ctx } = harness({ pn: 'LM2596S-5.0' });
const recipe: AutoRecipe = {
let: { evalRef: 'W05-1' },
do: [
{
kind: 'project',
into: 'sub_eval.part',
from: { once: true },
fields: { eval_ref: { in: 'evalRef' }, internal_pn: { in: 'pn' }, role: 'original' },
},
],
};
const out = await runner.run(recipe, ctx);
expect(out.wrote).toBe(1);
expect([...drafts.values()][0]).toEqual({
domain: 'sub_eval',
object: 'part',
key: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0' },
patch: { role: 'original' },
});
});
it('主键算不出来的那一行**先不写** —— 空主键写下去就是一行永远对不上的垃圾', async () => {
const { runner, drafts, ctx } = harness({});
const out = await runner.run(
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: { in: 'nope' }, role: 'x' } }] },
ctx,
);
expect(out.wrote).toBe(0);
expect(drafts.size).toBe(0);
});
it('when 为假就跳过这一笔', async () => {
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);
expect(drafts.size).toBe(0);
});
it('**每一笔之前刷读数**:第二笔 each 得看得见第一笔刚写下的行', async () => {
const { runner, drafts, 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' } } },
{ kind: 'project', into: 'parts.parameter', from: { each: 'mine:sub_eval.part' }, fields: { internal_pn: { col: 'internal_pn' }, param_name: '输入电压范围', param_value: '' } },
],
},
ctx,
);
const params = [...drafts.values()].filter((d) => d.object === 'parameter');
expect(params.map((p) => p.key['internal_pn']).sort()).toEqual(['A', 'B']);
});
it('值没变的写是空转:**不留痕、不计数**(配方不是模型,原地打转不必被看见)', async () => {
const { runner, ctx } = harness({});
const recipe: AutoRecipe = { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b', role: 'original' } }] };
await runner.run(recipe, ctx);
const second = await runner.run(recipe, ctx);
expect(second.wrote).toBe(0);
});
it('prune:这一笔没投影出来的行,从草稿层撤掉', async () => {
const { runner, drafts, 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);
expect(drafts.size).toBe(2);
const out = await runner.run(
{ do: [{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] },
ctx,
);
expect(out.dropped).toBe(1);
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']);
});
it('say:进工作日志,归系统', async () => {
const { runner, notes, ctx } = harness({ n: 2 });
await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx);
expect(notes).toContain('已核对 2 颗料');
});
it('地址不在台账声明里 → 当场交人,不重试', async () => {
const { runner, ctx } = harness({});
const out = await runner.run({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }, ctx);
expect(out.failedAt?.index).toBe(0);
expect(out.failedAt?.message).toContain('nope.nope');
});
});