Archived
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:
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { taskIdSchema } from '@contracts';
|
||||
import { createAnalyzeGateway } from '../wiring/analyze-gateway.ts';
|
||||
|
||||
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
||||
const SCHEMA = { type: 'object', required: ['items'], properties: { items: { type: 'array' } } };
|
||||
const call = { taskId: TASK, model: 'deepseek-v4-flash', instruction: '逐条判四态', payload: [{ p: 'A' }], outputSchema: SCHEMA, label: 'S2#1' };
|
||||
|
||||
describe('analyze 网关', () => {
|
||||
it('出参是对象就直接回,入参 JSON 进 prompt、instruction 进 system', async () => {
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
const gateway = createAnalyzeGateway({
|
||||
generate: (c: Record<string, unknown>) => {
|
||||
seen.push(c);
|
||||
return Promise.resolve({ text: '', object: { items: [{ p: 'A' }] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
|
||||
},
|
||||
} as never);
|
||||
const out = await gateway.run(call);
|
||||
expect(out.object).toEqual({ items: [{ p: 'A' }] });
|
||||
expect(seen[0]?.['system']).toBe('逐条判四态');
|
||||
expect(String(seen[0]?.['prompt'])).toContain('"p": "A"');
|
||||
expect(seen[0]?.['reasoning']).toBe('off');
|
||||
});
|
||||
|
||||
it('出参不成形 → **就地重问一次**,第二次成了就用它', async () => {
|
||||
let n = 0;
|
||||
const gateway = createAnalyzeGateway({
|
||||
generate: () => {
|
||||
n += 1;
|
||||
return Promise.resolve(
|
||||
n === 1
|
||||
? { text: 'not json', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }
|
||||
: { text: '', object: { items: [] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' },
|
||||
);
|
||||
},
|
||||
} as never);
|
||||
const out = await gateway.run(call);
|
||||
expect(n).toBe(2);
|
||||
expect(out.object).toEqual({ items: [] });
|
||||
});
|
||||
|
||||
it('重问了还是不成形 → 抛,交人;**不重问第三次**', async () => {
|
||||
let n = 0;
|
||||
const gateway = createAnalyzeGateway({
|
||||
generate: () => {
|
||||
n += 1;
|
||||
return Promise.resolve({ text: 'nope', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
|
||||
},
|
||||
} as never);
|
||||
await expect(gateway.run(call)).rejects.toThrow(/出参不成形/);
|
||||
expect(n).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* `analyze` 动词的实现 —— **一次结构化调用,没有第二次自由**(spec §5.2)。
|
||||
*
|
||||
* 与开环 Agent 的分工是死的:那一件能调工具、能写台账;这一件只是个纯函数。
|
||||
* 出参不合 schema 就地重问一次(平台在 agent-runner 上早有这条路),再不成就抛 ——
|
||||
* 抛出去由配方执行器留痕交人,**不重试五遍、不退回模型自由发挥**。
|
||||
*/
|
||||
import type { ModelGateway } from '@modules/llm';
|
||||
import type { AnalyzeGateway } from '@modules/loop-engine';
|
||||
|
||||
const RETRY_HINT =
|
||||
'上一次的回答没有按要求给出 JSON。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码围栏。';
|
||||
|
||||
export function createAnalyzeGateway(llm: Pick<ModelGateway, 'generate'>): AnalyzeGateway {
|
||||
return {
|
||||
async run(input) {
|
||||
const prompt = JSON.stringify(input.payload, null, 2);
|
||||
const call = {
|
||||
model: input.model,
|
||||
system: input.instruction,
|
||||
prompt,
|
||||
/* 判断归判定表与规则,模型只做归类 —— 隐藏推理按秒计,这一档一律关 */
|
||||
reasoning: 'off' as const,
|
||||
temperature: 0.2,
|
||||
outputSchema: input.outputSchema as Record<string, unknown>,
|
||||
taskId: input.taskId,
|
||||
label: input.label,
|
||||
};
|
||||
const first = await llm.generate(call);
|
||||
if (typeof first.object === 'object' && first.object !== null) {
|
||||
return { object: first.object as Record<string, unknown> };
|
||||
}
|
||||
const second = await llm.generate({ ...call, system: `${input.instruction}\n\n${RETRY_HINT}` });
|
||||
if (typeof second.object === 'object' && second.object !== null) {
|
||||
return { object: second.object as Record<string, unknown> };
|
||||
}
|
||||
throw new Error(`${input.label}:模型出参不成形,重问一次仍不成 —— 交给你处理`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { idempotencyKey } from '@modules/release';
|
||||
|
||||
import { createLoopStateStore, createTurnRunner, criterionKey, readingsOf } from '@modules/loop-engine';
|
||||
import type { LoopEngineService } from '@modules/loop-engine';
|
||||
import { createAnalyzeGateway } from './analyze-gateway.ts';
|
||||
import { createSkillEngineService } from '@modules/skill-engine';
|
||||
import { createTaskService, createTaskStore } from '@modules/task';
|
||||
import type { TaskService } from '@modules/task';
|
||||
@@ -2289,7 +2290,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
},
|
||||
tasks: taskContext,
|
||||
clock,
|
||||
analyze: { run: () => Promise.reject(new Error('analyze 尚未装配(Task 5)')) },
|
||||
analyze: createAnalyzeGateway(deps.modelGateway),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user