DeepSeek provider + 判据 ③ 机器路径跑通(零人工兜底)

Provider 侧:
  · 新增 `provider-profile`(纯数据):各家的默认端点 / 结构化出参姿势 / 是否回推理
    集中一处,adapter 只按画像行事 —— 新增 provider 只改画像,不写一族 if-else。
    DeepSeek:`https://api.deepseek.com`,支持 json_object,v4 系列是推理模型。
  · 建实例时 URL 可留空 → 取该 provider 的默认端点(openai-compatible 本就是
    「随便哪个兼容端点」,没有默认,必须自填)。
  · 推理正文归 `reasoning` 单列:那是**过程**不是结论,不进出参、不参与 schema 校验、
    不入录制(录的是语义入参与结论)。
  · 两档实例 deepseek-v4-flash / deepseek-v4-pro 已建;`quote-extractor` 是**用途名**,
    指向 flash 档 —— 换档只改这一处绑定,活动一行不动。密钥只从环境读、落库即密封,
    治理台只回「是否已绑定」(实测:库里是密文,视图无明文字段,三个实例探活全通)。

沿路修掉五处真问题:

① **同一任务被并发推进**:推进器与派发器各推各的,用的不是同一把锁 ——
   实测同一步跑两遍、同一张卡发两张、同一行草稿写出两条互相打架的版本。
   两者改用同一把 advisory lock(键的推导提取共用)。
② **LLM 步出参从不校验**:声明了 outputSchema,解析不出来就静默当 `{}` 写下去,
   毛病要到下游缺字段时才炸、还炸在别处。改为「出参必过 schema」(AM 12.3),
   不过即按声明的失败走向,并把模型原文记进留痕,好让人看见它到底回了什么。
③ **出参路径不认点号**:入参能走 `a.b.0.c`,出参只认扁平键 ——
   模型按 schema 回嵌套结构(最常见的情形)就写出一条空记录。两边共用一份解析。
④ **归一步拿不到原文**:通道载荷只带指针不带正文、start→A1 边也没把主题正文传下去,
   模型只拿到一个单号,于是回空 records。通道载荷带原文,边映射一路带到 A1。
⑤ **出参形状没讲给模型听**:声明了 schema 却只在提示里写「只输出 JSON」,
   模型只能猜键名(实测回 `{"items":[]}` 而声明要 `{"records":[...]}`)。
   改由网关按 outputSchema 统一附上说明 —— 这是系统的事,不该每个活动作者各写一遍。

另修:连接池没有 error 处理器,空闲连接被服务端掐断会让整个进程退出(一次运维操作
就能把 API 带崩)。

结果:一封 RFQ 入站 → 模型从正文抽出 pn/brand/eau/目标价/交期 →
D1 判定跳过补全 → 查历史价 → DMN 三分类 Type A → 验收放行卡,**全程零人工兜底**。

gates: tsc / eslint / depcruise(319 模块)/ api-report / 409 测试 全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 10:25:57 +08:00
co-authored by Claude Opus 5
parent bfe7828380
commit abd371b51c
31 changed files with 467 additions and 50 deletions
@@ -10,6 +10,7 @@ import {
lastStepIsAcceptance,
markStep,
parallelGroupComplete,
resolvePath,
} from '../internal/domain/step-sequence.ts';
const llmStep = (over: Record<string, unknown> = {}): FlowStep =>
@@ -125,3 +126,30 @@ describe('声明校验', () => {
expect(lastStepIsAcceptance([llmStep()])).toBe(false);
});
});
describe('取值路径 —— 入参出参共用一份', () => {
const shaped = {
records: [
{ pn: 'LM358DR', brand: 'TI', eau: 12000 },
{ pn: 'NE555P', brand: 'TI', eau: 3000 },
],
missingKeyFields: 0,
flat: 'x',
};
it('数组下标按数字段下行 —— 模型按 schema 回嵌套结构是最常见的情形', () => {
expect(resolvePath(shaped, 'records.0.pn')).toBe('LM358DR');
expect(resolvePath(shaped, 'records.1.brand')).toBe('TI');
});
it('扁平键照旧', () => {
expect(resolvePath(shaped, 'missingKeyFields')).toBe(0);
expect(resolvePath(shaped, 'flat')).toBe('x');
});
it('走不通的路径回 undefined,不抛 —— 缺字段由下游的契约校验如实报出来', () => {
expect(resolvePath(shaped, 'records.9.pn')).toBeUndefined();
expect(resolvePath(shaped, 'nope.deep.deeper')).toBeUndefined();
expect(resolvePath(undefined, 'a.b')).toBeUndefined();
});
});
@@ -156,3 +156,21 @@ export function lastVerdictPassed(position: FlowPosition): boolean {
export function lastSelectedPorts(position: FlowPosition): Record<string, string> {
return position.steps[position.steps.length - 1]?.selectedPorts ?? {};
}
/**
* 取值路径:点号分段下行,数组下标按数字段走(`records.0.pn`)。
*
* **入参与出参共用这一份**:声明里写的路径是同一种写法,不该一边能走点号、
* 另一边只认扁平键 —— 那会让「模型按 schema 回了嵌套结构」这种最常见的情形
* 悄悄写出一条空记录。
*/
export function resolvePath(source: unknown, path: string): unknown {
return path.split('.').reduce<unknown>((acc, seg) => {
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
if (Array.isArray(acc)) {
const index = Number(seg);
return Number.isInteger(index) ? acc[index] : undefined;
}
return (acc as Record<string, unknown>)[seg];
}, source);
}
@@ -10,6 +10,7 @@ import type {
FlowStateStore,
ListenerRegistrar,
LlmStepGateway,
SchemaValidator,
MemoryRecorder,
PredicateEvaluator,
TaskContext,
@@ -27,6 +28,7 @@ import {
initialPosition,
markStep,
nextAction,
resolvePath,
} from '../domain/step-sequence.ts';
export type FlowRunnerDeps = {
@@ -35,6 +37,7 @@ export type FlowRunnerDeps = {
readonly data: TaskDataGateway;
readonly tools: ToolStepInvoker;
readonly llm: LlmStepGateway;
readonly schemas: SchemaValidator;
readonly cards: CardGateway;
readonly listeners: ListenerRegistrar;
readonly memory: MemoryRecorder;
@@ -54,13 +57,14 @@ export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService {
if (step.output === undefined) return;
const patch: Record<string, unknown> = {};
for (const [field, path] of Object.entries(step.output.fields)) {
patch[field] = output[path] ?? output[field];
// 路径优先(模型常按 schema 回嵌套结构),取不到再退回扁平键
patch[field] = resolvePath(output, path) ?? output[path] ?? output[field];
}
// 写哪一行:主键取自出参,取不到再回作用域(起单入参往往就带着主键)
const scope = await deps.data.view(taskId);
const key: Record<string, string | number> = {};
for (const [column, path] of Object.entries(step.output.key)) {
const value = output[path] ?? scope[path];
const value = resolvePath(output, path) ?? output[path] ?? resolvePath(scope, path);
// 主键值只能是标量;取不到就不填,让台账的非空约束在合并时如实报出来
if (typeof value === 'string' || typeof value === 'number') key[column] = value;
}
@@ -81,12 +85,7 @@ export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService {
function inputOf(step: FlowStep, scope: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, path] of Object.entries(step.input)) {
out[key] = path.split('.').reduce<unknown>((acc, seg) => {
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
return (acc as Record<string, unknown>)[seg];
}, scope);
}
for (const [key, path] of Object.entries(step.input)) out[key] = resolvePath(scope, path);
return out;
}
@@ -143,7 +142,27 @@ export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService {
stepNo: index,
});
if (!result.ok) return handleFailure(taskId, step, index, position, attempt, result.error ?? 'LLM 步失败');
const output = (result.object ?? {}) as Record<string, unknown>;
/*
* 「出参必过 schema 校验」(AM 12.3)。
* 解析不出来或不合 schema = **这一步失败了**,走声明的失败走向;
* 从前是静默当成 `{}` 写下去 —— 一条空记录、一行不报错,
* 真正的毛病要到下游缺字段时才炸,而且炸在别处。
*/
const parsed = result.object;
const check = deps.schemas.validate(step.outputSchema, parsed);
if (parsed === undefined || !check.ok) {
const detail = parsed === undefined ? '出参不是 JSON' : check.errors.join('; ');
await deps.memory.append({
taskId,
kind: 'result',
title: `步 ${index + 1} 出参不合 schema:${detail}`,
detail: { step: step.name, text: (result.text ?? '').slice(0, 500) },
});
return handleFailure(taskId, step, index, position, attempt, `出参不合 schema:${detail}`);
}
const output = parsed as Record<string, unknown>;
await writeOutput(taskId, step, output);
const next = markStep(position, index, { state: 'done', attempt, output });
await deps.state.save(taskId, (await loaded(taskId)).model, next);
+7 -1
View File
@@ -55,7 +55,13 @@ export type LlmStepGateway = {
outputSchema: Record<string, unknown>;
taskId: TaskId;
stepNo: number;
}): Promise<{ ok: boolean; object?: unknown; error?: string }>;
/** 出参原文 —— 解析失败时要能说清「模型到底回了什么」 */
}): Promise<{ ok: boolean; object?: unknown; text?: string; error?: string }>;
};
/** 出参校验 —— app 注入 ajv:LLM 步的出参**必过声明的 schema**(AM 12.3) */
export type SchemaValidator = {
validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] };
};
/** 人类工具步 —— app 注入 `human-tool`(L3) */