feat(loop-engine): analyze 动词 —— 系统组装入参、声明出参 schema、系统把结果落表

AI 在这里只是一个纯函数:不给工具、不给记忆、不多轮。
行数不由它决定 —— 出参是拿主键去 lookup 的一张查表,它漏一条那一格就是空,
required 立刻把它变成 _missing > 0,提交闸与放行门当场拦住。
出参不成形就地重问一次,再不成抛出去交人。

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:54:14 +08:00
co-authored by Claude Opus 5
parent b0a3cc3a55
commit fe8de5319c
7 changed files with 228 additions and 4 deletions
@@ -6,8 +6,10 @@ 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');
type AnalyzeStub = { run: (i: { payload: readonly unknown[]; model: string; instruction: string }) => Promise<{ object: Record<string, unknown> }> };
/** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */
function harness(seed: Record<string, unknown> = {}) {
function harness(seed: Record<string, unknown> = {}, override: { analyze?: AnalyzeStub } = {}) {
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> => {
@@ -42,7 +44,7 @@ function harness(seed: Record<string, unknown> = {}) {
unanswered: () => Promise.resolve([]),
},
memory: { append: (m: { title: string }) => { notes.push(m.title); return Promise.resolve(); } },
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
analyze: override.analyze ?? { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
sfeel: {
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
if (expr === 'never') return false;
@@ -147,3 +149,100 @@ describe('配方执行器', () => {
expect(out.failedAt?.message).toContain('nope.nope');
});
});
describe('analyze 动词', () => {
it('入参由声明组装,出参落进 result: 供后一笔 each 消费', async () => {
let seen: readonly unknown[] = [];
const { runner, drafts, ctx } = harness(
{
'parts.type_param': [
{ type_id: 1, param_name: '输入电压范围', constraint_type: 'hard' },
{ type_id: 1, param_name: '输出电流', constraint_type: 'soft' },
],
typeId: 1,
},
{
analyze: {
run: (i) => {
seen = i.payload;
return Promise.resolve({
object: {
items: [
{ param_name: '输入电压范围', difference_status: 'match' },
{ param_name: '输出电流', difference_status: 'unknown' },
],
},
});
},
},
},
);
await runner.run(
{
do: [
{
kind: 'analyze',
model: 'deepseek-v4-flash',
into: 'items',
instruction: '逐条判四态',
input: {
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: { param_name: { col: 'param_name' }, constraint_type: { col: 'constraint_type' } },
},
output: { type: 'object' },
},
{
kind: 'project',
into: 'parts.parameter',
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
fields: {
internal_pn: 'XL2596S-5.0',
param_name: { col: 'param_name' },
/* **行由字典决定,AI 出参只是查表** —— 它漏一条,那一格就是空 */
param_value: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status' },
},
},
],
},
ctx,
);
expect(seen).toEqual([
{ param_name: '输入电压范围', constraint_type: 'hard' },
{ param_name: '输出电流', constraint_type: 'soft' },
]);
const values = [...drafts.values()].filter((d) => d.object === 'parameter').map((d) => d.patch['param_value']);
expect(values.sort()).toEqual(['match', 'unknown']);
});
it('AI 漏了一条:那一行的格子是空的 —— 行数不由它决定', async () => {
const { runner, drafts, ctx } = harness(
{ 'parts.type_param': [{ type_id: 1, param_name: 'A' }, { type_id: 1, param_name: 'B' }], typeId: 1 },
{ analyze: { run: () => Promise.resolve({ object: { items: [{ param_name: 'A', difference_status: 'match' }] } }) } },
);
await runner.run(
{
do: [
{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { param_name: { col: 'param_name' } } }, output: { type: 'object' } },
{ kind: 'project', into: 'parts.parameter', from: { each: 'parts.type_param' }, fields: { internal_pn: 'P', param_name: { col: 'param_name' }, param_value: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status' } } },
],
},
ctx,
);
const rows = [...drafts.values()].filter((d) => d.object === 'parameter');
expect(rows).toHaveLength(2);
expect(rows.find((r) => r.key['param_name'] === 'B')?.patch['param_value']).toBe('');
});
it('有卡在等人交回时不调 AI —— 只算、只写', async () => {
let called = 0;
const { runner, ctx } = harness(
{ 'parts.type_param': [{ type_id: 1, param_name: 'A' }] },
{ analyze: { run: () => { called += 1; return Promise.resolve({ object: { items: [] } }); } } },
);
await runner.run(
{ do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { p: { col: 'param_name' } } }, output: { type: 'object' } }] },
{ ...ctx, quiet: true },
);
expect(called).toBe(0);
});
});