Archived
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:
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"messageId": "rfq-llm-01@x.com",
|
||||||
|
"from": "buyer@x.com",
|
||||||
|
"to": [
|
||||||
|
"rfq@we.com"
|
||||||
|
],
|
||||||
|
"cc": [],
|
||||||
|
"subject": "【询价】客户 C900 RFQ-20260728LLM 求报价",
|
||||||
|
"body": "客户编码 C900,询价单号 RFQ-20260728LLM。\n需求:LM358DR,厂牌 TI,年用量 12000 片,目标价 8.20 元,交期 10 周。",
|
||||||
|
"receivedAt": "2026-07-28T01:00:00.000Z",
|
||||||
|
"attachments": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"messageId": "rfq-llm-02@x.com",
|
||||||
|
"from": "buyer@x.com",
|
||||||
|
"to": [
|
||||||
|
"rfq@we.com"
|
||||||
|
],
|
||||||
|
"cc": [],
|
||||||
|
"subject": "【询价】客户 C900 RFQ-20260728LLM 求报价",
|
||||||
|
"body": "客户编码 C900,询价单号 RFQ-20260728LLM。\n需求:LM358DR,厂牌 TI,年用量 12000 片,目标价 8.20 元,交期 10 周。",
|
||||||
|
"receivedAt": "2026-07-28T01:00:00.000Z",
|
||||||
|
"attachments": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"messageId": "rfq-llm-03@x.com",
|
||||||
|
"from": "buyer@x.com",
|
||||||
|
"to": [
|
||||||
|
"rfq@we.com"
|
||||||
|
],
|
||||||
|
"cc": [],
|
||||||
|
"subject": "【询价】客户 C901 RFQ-20260728M1 求报价",
|
||||||
|
"body": "客户编码 C901,询价单号 RFQ-20260728M1。\n需求:LM358DR,厂牌 TI,年用量 12000 片,目标价 8.20 元,交期 10 周。",
|
||||||
|
"receivedAt": "2026-07-28T02:00:00.000Z",
|
||||||
|
"attachments": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"messageId": "rfq-llm-04@x.com",
|
||||||
|
"from": "buyer@x.com",
|
||||||
|
"to": [
|
||||||
|
"rfq@we.com"
|
||||||
|
],
|
||||||
|
"cc": [],
|
||||||
|
"subject": "【询价】客户 C902 RFQ-20260728M2 求报价",
|
||||||
|
"body": "客户编码 C902,询价单号 RFQ-20260728M2。\n需求:LM358DR,厂牌 TI,年用量 12000 片,目标价 8.20 元,交期 10 周。",
|
||||||
|
"receivedAt": "2026-07-28T03:00:00.000Z",
|
||||||
|
"attachments": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"messageId": "rfq-llm-05@x.com",
|
||||||
|
"from": "buyer@acme.com",
|
||||||
|
"to": [
|
||||||
|
"rfq@we.com"
|
||||||
|
],
|
||||||
|
"cc": [],
|
||||||
|
"subject": "【询价】客户 C903 RFQ-20260728M3 求报价",
|
||||||
|
"body": "您好,\n客户编码 C903,询价单号 RFQ-20260728M3。\n需求如下:\nLM358DR 厂牌 TI 年用量 12000 片 目标价 8.20 元 交期 10 周\n请本周内回复,谢谢。",
|
||||||
|
"receivedAt": "2026-07-28T04:00:00.000Z",
|
||||||
|
"attachments": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"messageId": "rfq-llm-06@x.com",
|
||||||
|
"from": "buyer@acme.com",
|
||||||
|
"to": [
|
||||||
|
"rfq@we.com"
|
||||||
|
],
|
||||||
|
"cc": [],
|
||||||
|
"subject": "【询价】客户 C904 RFQ-20260728M4 求报价",
|
||||||
|
"body": "您好,\n客户编码 C904,询价单号 RFQ-20260728M4。\n需求如下:\nLM358DR 厂牌 TI 年用量 12000 片 目标价 8.20 元 交期 10 周\n请本周内回复,谢谢。",
|
||||||
|
"receivedAt": "2026-07-28T05:00:00.000Z",
|
||||||
|
"attachments": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"messageId": "rfq-llm-07@x.com",
|
||||||
|
"from": "buyer@acme.com",
|
||||||
|
"to": [
|
||||||
|
"rfq@we.com"
|
||||||
|
],
|
||||||
|
"cc": [],
|
||||||
|
"subject": "【询价】客户 C905 RFQ-20260728M5 求报价",
|
||||||
|
"body": "您好,\n客户编码 C905,询价单号 RFQ-20260728M5。\n需求如下:\nLM358DR 厂牌 TI 年用量 12000 片 目标价 8.20 元 交期 10 周\n请本周内回复,谢谢。",
|
||||||
|
"receivedAt": "2026-07-28T06:00:00.000Z",
|
||||||
|
"attachments": []
|
||||||
|
}
|
||||||
@@ -49,9 +49,10 @@ describe('步序声明(AM 12.3)', () => {
|
|||||||
|
|
||||||
it('条件跳步是任务数据上的确定性谓词,由引擎求值', () => {
|
it('条件跳步是任务数据上的确定性谓词,由引擎求值', () => {
|
||||||
const askBack = model.steps.find((s) => s.name === 'S2_ask_back');
|
const askBack = model.steps.find((s) => s.name === 'S2_ask_back');
|
||||||
expect(askBack?.when).toBe('missingKeyFields > 0');
|
expect(askBack?.when).toBe('missing_key_fields > 0');
|
||||||
expect(evaluate(askBack!.when!, { missingKeyFields: 2 })).toBe(true);
|
// 谓词读的是**落表后的字段名** —— 作用域来自台账,不是模型出参的驼峰名
|
||||||
expect(evaluate(askBack!.when!, { missingKeyFields: 0 })).toBe(false);
|
expect(evaluate(askBack!.when!, { missing_key_fields: 2 })).toBe(true);
|
||||||
|
expect(evaluate(askBack!.when!, { missing_key_fields: 0 })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('人类工具步都配了时限 —— 无死等', () => {
|
it('人类工具步都配了时限 —— 无死等', () => {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const manifest: ActivityManifest = {
|
|||||||
identity: {
|
identity: {
|
||||||
activityId: ACTIVITY_ID,
|
activityId: ACTIVITY_ID,
|
||||||
name: 'A1 · RFQ 需求分析',
|
name: 'A1 · RFQ 需求分析',
|
||||||
packageVersion: '0.1.3',
|
packageVersion: '0.1.5',
|
||||||
category: '统一报价',
|
category: '统一报价',
|
||||||
paradigm: 'flow',
|
paradigm: 'flow',
|
||||||
sdkVersion: '0.1.0-m5',
|
sdkVersion: '0.1.0-m5',
|
||||||
@@ -59,6 +59,8 @@ export const manifest: ActivityManifest = {
|
|||||||
customerCode: { type: 'string' },
|
customerCode: { type: 'string' },
|
||||||
rfqRef: { type: 'string' },
|
rfqRef: { type: 'string' },
|
||||||
subject: { type: 'string' },
|
subject: { type: 'string' },
|
||||||
|
/** 询价原文 —— 归一步拆解的对象 */
|
||||||
|
body: { type: 'string' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
messageAllowed: true,
|
messageAllowed: true,
|
||||||
@@ -124,6 +126,7 @@ export const manifest: ActivityManifest = {
|
|||||||
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' },
|
{ name: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' },
|
||||||
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
|
{ name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' },
|
||||||
{ name: 'status', type: 'text', primaryKey: false, masked: false, label: '状态' },
|
{ name: 'status', type: 'text', primaryKey: false, masked: false, label: '状态' },
|
||||||
|
{ name: 'missing_key_fields', type: 'integer', primaryKey: false, masked: false, label: '缺关键字段数' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -171,17 +174,34 @@ export const manifest: ActivityManifest = {
|
|||||||
kind: 'llm',
|
kind: 'llm',
|
||||||
name: 'S3_normalize',
|
name: 'S3_normalize',
|
||||||
label: 'S3 逐条解析归一',
|
label: 'S3 逐条解析归一',
|
||||||
input: { rfqRef: 'rfqRef', customerCode: 'customerCode' },
|
input: { rfqRef: 'rfqRef', customerCode: 'customerCode', subject: 'subject', body: 'body' },
|
||||||
|
/*
|
||||||
|
* 出参路径要对得上**自己声明的 outputSchema**:模型回的是
|
||||||
|
* `{ records: [...], missingKeyFields }`,不是一堆扁平字段。
|
||||||
|
* MVP 逐条办的第一条 = `records.0`;`customerCode` 不在模型出参里,
|
||||||
|
* 它来自起单入参,由作用域补上。
|
||||||
|
*/
|
||||||
output: {
|
output: {
|
||||||
domain: INTAKE_DOMAIN,
|
domain: INTAKE_DOMAIN,
|
||||||
object: 'record',
|
object: 'record',
|
||||||
key: { customer_code: 'customerCode', pn: 'pn' },
|
key: { customer_code: 'customerCode', pn: 'records.0.pn' },
|
||||||
fields: { pn: 'pn', brand: 'brand', eau: 'eau' },
|
fields: {
|
||||||
|
pn: 'records.0.pn',
|
||||||
|
brand: 'records.0.brand',
|
||||||
|
eau: 'records.0.eau',
|
||||||
|
target_price: 'records.0.targetPrice',
|
||||||
|
lead_time: 'records.0.leadTime',
|
||||||
|
// 完整度判定要有据可依:D1 的谓词读的就是它
|
||||||
|
missing_key_fields: 'missingKeyFields',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
model: 'quote-extractor',
|
model: 'quote-extractor',
|
||||||
|
// 出参形状由网关按 outputSchema 统一附上,模板只讲**这件事本身**
|
||||||
template:
|
template:
|
||||||
'按《询价受理规范》把这份 RFQ 拆解为逐条需求,抄录 PN/数量/EAU/厂牌/目标价/交期。' +
|
'按《询价受理规范》把下面这封询价拆解为逐条需求,逐条抄录 ' +
|
||||||
'只输出 JSON。RFQ:{{rfqRef}}',
|
'pn(型号)/ brand(厂牌)/ eau(年用量,整数)/ targetPrice(目标价,数字)/ leadTime(交期)。' +
|
||||||
|
'原文没写的字段留空,不要编造;每缺一个关键字段(pn / eau / leadTime)就把 missingKeyFields 加一。\n\n' +
|
||||||
|
'询价单号:{{rfqRef}}\n客户编码:{{customerCode}}\n主题:{{subject}}\n正文:\n{{body}}',
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['records'],
|
required: ['records'],
|
||||||
@@ -210,7 +230,7 @@ export const manifest: ActivityManifest = {
|
|||||||
name: 'S2_ask_back',
|
name: 'S2_ask_back',
|
||||||
label: 'S2 追问补全信息',
|
label: 'S2 追问补全信息',
|
||||||
// D1 判断完整度:缺 PN/数量/交期三要素才走补全 —— 条件必谓词,由引擎求值
|
// D1 判断完整度:缺 PN/数量/交期三要素才走补全 —— 条件必谓词,由引擎求值
|
||||||
when: 'missingKeyFields > 0',
|
when: 'missing_key_fields > 0',
|
||||||
input: {},
|
input: {},
|
||||||
workOrder: 'execution',
|
workOrder: 'execution',
|
||||||
card: { title: '缺关键字段,请向客户追问补全', fields: ['pn', 'eau', 'leadTime'] },
|
card: { title: '缺关键字段,请向客户追问补全', fields: ['pn', 'eau', 'leadTime'] },
|
||||||
|
|||||||
@@ -66,9 +66,17 @@ export type LlmStepGateway = {
|
|||||||
}): Promise<{
|
}): Promise<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
object?: unknown;
|
object?: unknown;
|
||||||
|
text?: string;
|
||||||
error?: 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) */
|
/** 人类工具步 —— app 注入 `human-tool`(L3) */
|
||||||
export type CardGateway = {
|
export type CardGateway = {
|
||||||
issue(input: {
|
issue(input: {
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export type ModelResult = {
|
|||||||
/** outputSchema 给定时的解析结果 */
|
/** outputSchema 给定时的解析结果 */
|
||||||
readonly object?: unknown;
|
readonly object?: unknown;
|
||||||
readonly usage: ModelUsage;
|
readonly usage: ModelUsage;
|
||||||
|
/**
|
||||||
|
* 推理模型的思考正文 —— **过程,不是结论**。
|
||||||
|
* 出参只认 `text` / `object`;这一段单独留着,供任务记忆归档与断点复盘,
|
||||||
|
* 不参与 schema 校验、不进业务字段。
|
||||||
|
*/
|
||||||
|
readonly reasoning?: string;
|
||||||
/** 行为可归因:模型与版本记入留痕(OS 4.1) */
|
/** 行为可归因:模型与版本记入留痕(OS 4.1) */
|
||||||
readonly binding: {
|
readonly binding: {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -71,6 +77,7 @@ export type UpsertLlmInstance = {
|
|||||||
readonly id?: string | undefined;
|
readonly id?: string | undefined;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly provider: LlmProvider;
|
readonly provider: LlmProvider;
|
||||||
|
/** 留空 = 取该 provider 的默认端点 */
|
||||||
readonly baseUrl: string;
|
readonly baseUrl: string;
|
||||||
readonly model: string;
|
readonly model: string;
|
||||||
/** 只写不读:留空表示保持原值 */
|
/** 只写不读:留空表示保持原值 */
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export type ProviderInvoker = {
|
|||||||
}): Promise<{
|
}): Promise<{
|
||||||
text: string;
|
text: string;
|
||||||
object?: unknown;
|
object?: unknown;
|
||||||
|
reasoning?: string;
|
||||||
usage: ModelUsage;
|
usage: ModelUsage;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -271,7 +271,7 @@ export declare const upsertLlmInstanceRoute: {
|
|||||||
anthropic: "anthropic";
|
anthropic: "anthropic";
|
||||||
qwen: "qwen";
|
qwen: "qwen";
|
||||||
}>;
|
}>;
|
||||||
baseUrl: z.ZodString;
|
baseUrl: z.ZodDefault<z.ZodString>;
|
||||||
model: z.ZodString;
|
model: z.ZodString;
|
||||||
apiKey: z.ZodOptional<z.ZodString>;
|
apiKey: z.ZodOptional<z.ZodString>;
|
||||||
systemUse: z.ZodDefault<z.ZodBoolean>;
|
systemUse: z.ZodDefault<z.ZodBoolean>;
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ export const firstPartyToolHandlers = new Map<string, ToolHandler>(FIRST_PARTY_T
|
|||||||
|
|
||||||
export async function createContainer(config: AppConfig): Promise<Container> {
|
export async function createContainer(config: AppConfig): Promise<Container> {
|
||||||
const pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
|
const pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
|
||||||
|
/*
|
||||||
|
* 空闲连接被服务端掐断(重启、`pg_terminate_backend`、网络抖动)会以 'error' 事件抛到池上;
|
||||||
|
* **没有处理器就是未捕获异常,整个进程随之退出** —— 一次运维操作能把 API 带崩。
|
||||||
|
* 池会自行剔除坏连接并在下次取用时重连,这里只负责让它别掀桌子。
|
||||||
|
*/
|
||||||
|
pool.on('error', (error: Error) => {
|
||||||
|
process.stderr.write(`[pg] 空闲连接异常(已剔除,下次取用自动重连):${error.message}\n`);
|
||||||
|
});
|
||||||
const db = drizzle(pool);
|
const db = drizzle(pool);
|
||||||
const ids = createIdGenerator();
|
const ids = createIdGenerator();
|
||||||
const secrets = createSecretBox(loadSecretKey());
|
const secrets = createSecretBox(loadSecretKey());
|
||||||
|
|||||||
@@ -102,17 +102,37 @@ export async function seedQuote(container: Container): Promise<void> {
|
|||||||
process.stdout.write(line + '\n');
|
process.stdout.write(line + '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ① LLM 实例:四元绑定(活动只见自定义模型名) */
|
/*
|
||||||
|
* ① LLM 实例:四元绑定(活动只见自定义模型名)。
|
||||||
|
*
|
||||||
|
* 密钥**只从环境读**,不入源码、不入包、不入留痕(OS 7.3 凭证隔离 / ADR-009);
|
||||||
|
* 落库即密封,治理台也只回「是否已绑定」。URL 留空 → 取 provider 画像的默认端点。
|
||||||
|
*/
|
||||||
|
const deepseekKey = process.env['JCAI_DEEPSEEK_API_KEY'] ?? process.env['JCAI_LLM_API_KEY'] ?? 'sk-placeholder';
|
||||||
|
|
||||||
|
// 两档 DeepSeek:flash 快而省,pro 强而慢 —— 活动按自己的场景声明引用哪一档
|
||||||
|
for (const [name, model, systemUse] of [
|
||||||
|
['deepseek-v4-flash', 'deepseek-v4-flash', false],
|
||||||
|
['deepseek-v4-pro', 'deepseek-v4-pro', false],
|
||||||
|
] as const) {
|
||||||
|
await container.llm.upsert({ name, provider: 'deepseek', baseUrl: '', model, apiKey: deepseekKey, systemUse, enabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 活动声明里引用的逻辑名是 `quote-extractor` —— 它是**用途名**,不是型号名。
|
||||||
|
* 把它指到 flash 档:受理归一是抽取活,快档足够;要换档只改这一处绑定,活动一行不动。
|
||||||
|
* 系统用途(叙述性小结 / 汇报 / 蒸馏)也走它。
|
||||||
|
*/
|
||||||
await container.llm.upsert({
|
await container.llm.upsert({
|
||||||
name: 'quote-extractor',
|
name: 'quote-extractor',
|
||||||
provider: 'openai-compatible',
|
provider: 'deepseek',
|
||||||
baseUrl: process.env['JCAI_LLM_BASE_URL'] ?? 'https://api.deepseek.com/v1',
|
baseUrl: process.env['JCAI_LLM_BASE_URL'] ?? '',
|
||||||
model: process.env['JCAI_LLM_MODEL'] ?? 'deepseek-chat',
|
model: process.env['JCAI_LLM_MODEL'] ?? 'deepseek-v4-flash',
|
||||||
apiKey: process.env['JCAI_LLM_API_KEY'] ?? 'sk-placeholder',
|
apiKey: deepseekKey,
|
||||||
systemUse: true,
|
systemUse: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
log('① LLM 实例已绑定:quote-extractor');
|
log('① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro · quote-extractor(→ flash,系统用途)');
|
||||||
|
|
||||||
/* ② 工具注册两审 + 白名单 */
|
/* ② 工具注册两审 + 白名单 */
|
||||||
const tools = [
|
const tools = [
|
||||||
@@ -248,6 +268,9 @@ export async function seedQuote(container: Container): Promise<void> {
|
|||||||
mapping: [
|
mapping: [
|
||||||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||||||
{ target: 'rfqRef', source: { from: 'initiation', path: 'rfqRef' } },
|
{ target: 'rfqRef', source: { from: 'initiation', path: 'rfqRef' } },
|
||||||
|
// 主题与正文要一路带到 A1:归一步拆的就是它们
|
||||||
|
{ target: 'subject', source: { from: 'initiation', path: 'subject' } },
|
||||||
|
{ target: 'body', source: { from: 'initiation', path: 'body' } },
|
||||||
],
|
],
|
||||||
reopen: false,
|
reopen: false,
|
||||||
},
|
},
|
||||||
@@ -372,6 +395,8 @@ export async function seedQuote(container: Container): Promise<void> {
|
|||||||
{ target: 'customerCode', source: { from: 'expression', sfeel: 'keys.customerCode' } },
|
{ target: 'customerCode', source: { from: 'expression', sfeel: 'keys.customerCode' } },
|
||||||
{ target: 'rfqRef', source: { from: 'expression', sfeel: 'keys.rfqRef' } },
|
{ target: 'rfqRef', source: { from: 'expression', sfeel: 'keys.rfqRef' } },
|
||||||
{ target: 'subject', source: { from: 'expression', sfeel: 'subject' } },
|
{ target: 'subject', source: { from: 'expression', sfeel: 'subject' } },
|
||||||
|
// 正文是**归一步唯一的原始素材**:只给单号,模型没有可拆解的东西
|
||||||
|
{ target: 'body', source: { from: 'expression', sfeel: 'body' } },
|
||||||
],
|
],
|
||||||
spec: { kind: 'event', rule: 'contains(subject, "询价") or contains(subject, "RFQ")', eventType: 'data' },
|
spec: { kind: 'event', rule: 'contains(subject, "询价") or contains(subject, "RFQ")', eventType: 'data' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
evaluate: sfeel.condition,
|
evaluate: sfeel.condition,
|
||||||
data: taskData,
|
data: taskData,
|
||||||
tools: { ...toolGateway, invoke: (i) => toolGateway.invoke(i) },
|
tools: { ...toolGateway, invoke: (i) => toolGateway.invoke(i) },
|
||||||
|
schemas: deps.schemas,
|
||||||
llm: {
|
llm: {
|
||||||
async generate(input) {
|
async generate(input) {
|
||||||
// LLM 在 flow 里只以「被调用的工具」出现,不是调度者
|
// LLM 在 flow 里只以「被调用的工具」出现,不是调度者
|
||||||
@@ -296,7 +297,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
|||||||
stepNo: input.stepNo,
|
stepNo: input.stepNo,
|
||||||
label: 'flow-step',
|
label: 'flow-step',
|
||||||
});
|
});
|
||||||
return { ok: result.object !== undefined, ...(result.object === undefined ? { error: '出参不合 schema' } : { object: result.object }) };
|
// 解析与校验归引擎(它才知道这一步声明了什么);这里只如实上交结果与原文
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
...(result.object === undefined ? {} : { object: result.object }),
|
||||||
|
text: result.text,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,22 @@ export type DispatcherDeps = {
|
|||||||
readonly batchSize?: number;
|
readonly batchSize?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 目标 ID → advisory lock 键(稳定散列,同一目标恒同一把锁) */
|
/**
|
||||||
function lockKey(target: Delivery['target']): number {
|
* 目标 ID → advisory lock 键(稳定散列,同一目标恒同一把锁)。
|
||||||
const text = `${target.kind}:${target.id}`;
|
*
|
||||||
|
* **推进器与派发器必须共用这一把**:一边由事件投递驱动推进任务、一边由推进器轮询推进,
|
||||||
|
* 用的不是同一把锁就等于没锁 —— 实测会同一步跑两遍、同一张卡发两张、
|
||||||
|
* 同一行草稿写出两条互相打架的版本。
|
||||||
|
*/
|
||||||
|
export function targetLockKey(kind: string, id: string): number {
|
||||||
|
const text = `${kind}:${id}`;
|
||||||
let hash = 5381;
|
let hash = 5381;
|
||||||
for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
|
for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
|
||||||
return hash;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lockKey = (target: Delivery['target']): number => targetLockKey(target.kind, String(target.id));
|
||||||
|
|
||||||
export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
|
export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
|
||||||
const { container } = deps;
|
const { container } = deps;
|
||||||
const batch = await claimPendingDeliveries(container.db, deps.batchSize ?? 20);
|
const batch = await claimPendingDeliveries(container.db, deps.batchSize ?? 20);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { and, eq, sql } from 'drizzle-orm';
|
import { and, eq, sql } from 'drizzle-orm';
|
||||||
import { taskTable } from '@modules/task';
|
import { taskTable } from '@modules/task';
|
||||||
import type { Container } from '../container.ts';
|
import type { Container } from '../container.ts';
|
||||||
|
import { targetLockKey } from './dispatcher.ts';
|
||||||
|
|
||||||
export type AdvancerOptions = {
|
export type AdvancerOptions = {
|
||||||
readonly container: Container;
|
readonly container: Container;
|
||||||
@@ -27,19 +28,33 @@ export async function advanceOnce(options: AdvancerOptions): Promise<number> {
|
|||||||
|
|
||||||
let advanced = 0;
|
let advanced = 0;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
for (let tick = 0; tick < (options.maxTicks ?? 3); tick += 1) {
|
/*
|
||||||
try {
|
* 与派发器共用同一把 advisory lock:事件投递也会推进同一个任务。
|
||||||
const outcome = await container.task.tick(row.id as never);
|
* 抢不到就跳过 —— 那边正推着,这边再推就是两条线同时改一份运行时状态。
|
||||||
advanced += 1;
|
*/
|
||||||
// 进入等待或终结就停手 —— 挂起在等的任务由事件唤醒,不空转
|
const key = targetLockKey('task', row.id);
|
||||||
if (outcome.task.runtime !== 'running' || outcome.task.terminal !== undefined) break;
|
const locked = await container.db.execute<{ ok: boolean }>(
|
||||||
if (!outcome.progressed) break;
|
sql`select pg_try_advisory_lock(${key}) as ok`,
|
||||||
} catch (error) {
|
);
|
||||||
process.stderr.write(
|
if (locked.rows[0]?.ok !== true) continue;
|
||||||
`[task-advancer] ${row.id}:${error instanceof Error ? error.message : String(error)}\n`,
|
|
||||||
);
|
try {
|
||||||
break;
|
for (let tick = 0; tick < (options.maxTicks ?? 3); tick += 1) {
|
||||||
|
try {
|
||||||
|
const outcome = await container.task.tick(row.id as never);
|
||||||
|
advanced += 1;
|
||||||
|
// 进入等待或终结就停手 —— 挂起在等的任务由事件唤醒,不空转
|
||||||
|
if (outcome.task.runtime !== 'running' || outcome.task.terminal !== undefined) break;
|
||||||
|
if (!outcome.progressed) break;
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(
|
||||||
|
`[task-advancer] ${row.id}:${error instanceof Error ? error.message : String(error)}\n`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
await container.db.execute(sql`select pg_advisory_unlock(${key})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return advanced;
|
return advanced;
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ export function createChannelService(deps: ChannelServiceDeps): ChannelService {
|
|||||||
draft: {
|
draft: {
|
||||||
type: 'data',
|
type: 'data',
|
||||||
correlation: { cause: { kind: 'inbound', ref: mail.messageId }, keys: extracted },
|
correlation: { cause: { kind: 'inbound', ref: mail.messageId }, keys: extracted },
|
||||||
payload: { data: { ...extracted, subject: mail.subject, from: mail.from }, originRef },
|
/*
|
||||||
|
* 载荷带**原文**:信封只带原件指针够不够,取决于下游要不要读内容 ——
|
||||||
|
* 归一步要拆解的正是这段文本,只给一个 .eml 指针它无从下手。
|
||||||
|
*/
|
||||||
|
payload: { data: { ...extracted, subject: mail.subject, body: mail.body, from: mail.from }, originRef },
|
||||||
occurredAt: receivedAt.toISOString(),
|
occurredAt: receivedAt.toISOString(),
|
||||||
source: { kind: 'channel', detail: config.name },
|
source: { kind: 'channel', detail: config.name },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
lastStepIsAcceptance,
|
lastStepIsAcceptance,
|
||||||
markStep,
|
markStep,
|
||||||
parallelGroupComplete,
|
parallelGroupComplete,
|
||||||
|
resolvePath,
|
||||||
} from '../internal/domain/step-sequence.ts';
|
} from '../internal/domain/step-sequence.ts';
|
||||||
|
|
||||||
const llmStep = (over: Record<string, unknown> = {}): FlowStep =>
|
const llmStep = (over: Record<string, unknown> = {}): FlowStep =>
|
||||||
@@ -125,3 +126,30 @@ describe('声明校验', () => {
|
|||||||
expect(lastStepIsAcceptance([llmStep()])).toBe(false);
|
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> {
|
export function lastSelectedPorts(position: FlowPosition): Record<string, string> {
|
||||||
return position.steps[position.steps.length - 1]?.selectedPorts ?? {};
|
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,
|
FlowStateStore,
|
||||||
ListenerRegistrar,
|
ListenerRegistrar,
|
||||||
LlmStepGateway,
|
LlmStepGateway,
|
||||||
|
SchemaValidator,
|
||||||
MemoryRecorder,
|
MemoryRecorder,
|
||||||
PredicateEvaluator,
|
PredicateEvaluator,
|
||||||
TaskContext,
|
TaskContext,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
initialPosition,
|
initialPosition,
|
||||||
markStep,
|
markStep,
|
||||||
nextAction,
|
nextAction,
|
||||||
|
resolvePath,
|
||||||
} from '../domain/step-sequence.ts';
|
} from '../domain/step-sequence.ts';
|
||||||
|
|
||||||
export type FlowRunnerDeps = {
|
export type FlowRunnerDeps = {
|
||||||
@@ -35,6 +37,7 @@ export type FlowRunnerDeps = {
|
|||||||
readonly data: TaskDataGateway;
|
readonly data: TaskDataGateway;
|
||||||
readonly tools: ToolStepInvoker;
|
readonly tools: ToolStepInvoker;
|
||||||
readonly llm: LlmStepGateway;
|
readonly llm: LlmStepGateway;
|
||||||
|
readonly schemas: SchemaValidator;
|
||||||
readonly cards: CardGateway;
|
readonly cards: CardGateway;
|
||||||
readonly listeners: ListenerRegistrar;
|
readonly listeners: ListenerRegistrar;
|
||||||
readonly memory: MemoryRecorder;
|
readonly memory: MemoryRecorder;
|
||||||
@@ -54,13 +57,14 @@ export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService {
|
|||||||
if (step.output === undefined) return;
|
if (step.output === undefined) return;
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
for (const [field, path] of Object.entries(step.output.fields)) {
|
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 scope = await deps.data.view(taskId);
|
||||||
const key: Record<string, string | number> = {};
|
const key: Record<string, string | number> = {};
|
||||||
for (const [column, path] of Object.entries(step.output.key)) {
|
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;
|
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> {
|
function inputOf(step: FlowStep, scope: Record<string, unknown>): Record<string, unknown> {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const [key, path] of Object.entries(step.input)) {
|
for (const [key, path] of Object.entries(step.input)) out[key] = resolvePath(scope, path);
|
||||||
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);
|
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +142,27 @@ export function createFlowRunner(deps: FlowRunnerDeps): FlowEngineService {
|
|||||||
stepNo: index,
|
stepNo: index,
|
||||||
});
|
});
|
||||||
if (!result.ok) return handleFailure(taskId, step, index, position, attempt, result.error ?? 'LLM 步失败');
|
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);
|
await writeOutput(taskId, step, output);
|
||||||
const next = markStep(position, index, { state: 'done', attempt, output });
|
const next = markStep(position, index, { state: 'done', attempt, output });
|
||||||
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
await deps.state.save(taskId, (await loaded(taskId)).model, next);
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ export type LlmStepGateway = {
|
|||||||
outputSchema: Record<string, unknown>;
|
outputSchema: Record<string, unknown>;
|
||||||
taskId: TaskId;
|
taskId: TaskId;
|
||||||
stepNo: number;
|
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) */
|
/** 人类工具步 —— app 注入 `human-tool`(L3) */
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/** Provider 画像 —— domain 测试零 mock(OS 02 系统管理) */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { LLM_PROVIDERS } from '@contracts';
|
||||||
|
import { PROVIDER_PROFILES, profileOf, resolveBaseUrl } from '../internal/domain/provider-profile.ts';
|
||||||
|
|
||||||
|
describe('Provider 画像', () => {
|
||||||
|
it('枚举里每个 provider 都有画像 —— 选了 provider 就该知道怎么调它', () => {
|
||||||
|
for (const provider of LLM_PROVIDERS) expect(PROVIDER_PROFILES[provider]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DeepSeek:默认端点 + 支持 json_object + 推理模型', () => {
|
||||||
|
const deepseek = profileOf('deepseek');
|
||||||
|
expect(deepseek.defaultBaseUrl).toBe('https://api.deepseek.com');
|
||||||
|
expect(deepseek.structuredOutput).toBe('json-object');
|
||||||
|
// v4 系列回包带 reasoning_content:那是过程不是结论,不能混进出参
|
||||||
|
expect(deepseek.emitsReasoning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('URL 留空回落到默认端点;填了就用填的', () => {
|
||||||
|
expect(resolveBaseUrl('deepseek', undefined)).toBe('https://api.deepseek.com');
|
||||||
|
expect(resolveBaseUrl('deepseek', ' ')).toBe('https://api.deepseek.com');
|
||||||
|
expect(resolveBaseUrl('deepseek', 'https://proxy.internal/v1')).toBe('https://proxy.internal/v1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('openai-compatible 没有默认端点 —— 它本就是「随便哪个兼容端点」,必须自填', () => {
|
||||||
|
expect(resolveBaseUrl('openai-compatible', undefined)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,12 @@ export type ModelResult = {
|
|||||||
/** outputSchema 给定时的解析结果 */
|
/** outputSchema 给定时的解析结果 */
|
||||||
readonly object?: unknown;
|
readonly object?: unknown;
|
||||||
readonly usage: ModelUsage;
|
readonly usage: ModelUsage;
|
||||||
|
/**
|
||||||
|
* 推理模型的思考正文 —— **过程,不是结论**。
|
||||||
|
* 出参只认 `text` / `object`;这一段单独留着,供任务记忆归档与断点复盘,
|
||||||
|
* 不参与 schema 校验、不进业务字段。
|
||||||
|
*/
|
||||||
|
readonly reasoning?: string;
|
||||||
/** 行为可归因:模型与版本记入留痕(OS 4.1) */
|
/** 行为可归因:模型与版本记入留痕(OS 4.1) */
|
||||||
readonly binding: { readonly name: string; readonly provider: LlmProvider; readonly model: string };
|
readonly binding: { readonly name: string; readonly provider: LlmProvider; readonly model: string };
|
||||||
readonly mode: GatewayMode;
|
readonly mode: GatewayMode;
|
||||||
@@ -85,6 +91,7 @@ export type UpsertLlmInstance = {
|
|||||||
readonly id?: string | undefined;
|
readonly id?: string | undefined;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly provider: LlmProvider;
|
readonly provider: LlmProvider;
|
||||||
|
/** 留空 = 取该 provider 的默认端点 */
|
||||||
readonly baseUrl: string;
|
readonly baseUrl: string;
|
||||||
readonly model: string;
|
readonly model: string;
|
||||||
/** 只写不读:留空表示保持原值 */
|
/** 只写不读:留空表示保持原值 */
|
||||||
|
|||||||
@@ -7,34 +7,53 @@
|
|||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||||
import type { ProviderInvoker } from '../../port.ts';
|
import type { ProviderInvoker } from '../../port.ts';
|
||||||
|
import { profileOf, schemaInstruction } from '../domain/provider-profile.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MVP 的全部 Provider 都以 OpenAI 兼容端点接入(README:「绑定 LLM 实例(任一 OpenAI 兼容端点)」)。
|
* MVP 的全部 Provider 都以 OpenAI 兼容端点接入(README:「绑定 LLM 实例(任一 OpenAI 兼容端点)」)。
|
||||||
* 枚举是为了治理台的呈现与日后分化,当下共用一个工厂。
|
* 各家的差异(默认端点 / 结构化出参姿势 / 是否回推理)集中在 `provider-profile`,
|
||||||
|
* 这里只按画像行事 —— 不写一族 if-else,新增 provider 只改画像。
|
||||||
*/
|
*/
|
||||||
export function createProviderInvoker(): ProviderInvoker {
|
export function createProviderInvoker(): ProviderInvoker {
|
||||||
return {
|
return {
|
||||||
async invoke({ binding, apiKey, call }) {
|
async invoke({ binding, apiKey, call }) {
|
||||||
|
const profile = profileOf(binding.provider);
|
||||||
const provider = createOpenAICompatible({
|
const provider = createOpenAICompatible({
|
||||||
name: binding.provider,
|
name: binding.provider,
|
||||||
baseURL: binding.baseUrl,
|
baseURL: binding.baseUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 端点支持就**直接要 JSON**,别指望从散文里捞;不支持的仍靠提示 + 提取兜底
|
||||||
|
const wantsJson = call.outputSchema !== undefined;
|
||||||
|
const useJsonMode = wantsJson && profile.structuredOutput === 'json-object';
|
||||||
|
|
||||||
|
// 出参形状讲给模型听:一处附上,所有步受益(活动作者不必各写一遍)
|
||||||
|
const system = [call.system, wantsJson ? schemaInstruction(call.outputSchema!) : undefined]
|
||||||
|
.filter((line): line is string => line !== undefined && line !== '')
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const result = await generateText({
|
const result = await generateText({
|
||||||
model: provider(binding.model),
|
model: provider(binding.model),
|
||||||
...(call.system === undefined ? {} : { system: call.system }),
|
...(system === '' ? {} : { system }),
|
||||||
prompt: call.prompt,
|
prompt: call.prompt,
|
||||||
...(call.temperature === undefined ? {} : { temperature: call.temperature }),
|
...(call.temperature === undefined ? {} : { temperature: call.temperature }),
|
||||||
...(call.maxOutputTokens === undefined ? {} : { maxOutputTokens: call.maxOutputTokens }),
|
...(call.maxOutputTokens === undefined ? {} : { maxOutputTokens: call.maxOutputTokens }),
|
||||||
|
...(useJsonMode ? { providerOptions: { [binding.provider]: { response_format: { type: 'json_object' } } } } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const object = call.outputSchema === undefined ? undefined : parseJsonLoose(result.text);
|
/*
|
||||||
|
* 推理模型的 `reasoning` 是**过程**不是结论:
|
||||||
|
* 出参只认正文,推理另置为留痕素材 —— 混进出参会让 schema 校验对着思考过程报错。
|
||||||
|
*/
|
||||||
|
const reasoning = profile.emitsReasoning ? extractReasoning(result) : undefined;
|
||||||
|
const object = wantsJson ? parseJsonLoose(result.text) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: result.text,
|
text: result.text,
|
||||||
...(object === undefined ? {} : { object }),
|
...(object === undefined ? {} : { object }),
|
||||||
|
...(reasoning === undefined || reasoning === '' ? {} : { reasoning }),
|
||||||
usage: {
|
usage: {
|
||||||
inputTokens: result.usage.inputTokens ?? 0,
|
inputTokens: result.usage.inputTokens ?? 0,
|
||||||
outputTokens: result.usage.outputTokens ?? 0,
|
outputTokens: result.usage.outputTokens ?? 0,
|
||||||
@@ -45,6 +64,16 @@ export function createProviderInvoker(): ProviderInvoker {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 推理正文:AI SDK 把它归到 reasoning 部件;拿不到就算了,不为它中断调用 */
|
||||||
|
function extractReasoning(result: { reasoning?: unknown | undefined; reasoningText?: string | undefined }): string | undefined {
|
||||||
|
if (typeof result.reasoningText === 'string') return result.reasoningText;
|
||||||
|
if (!Array.isArray(result.reasoning)) return undefined;
|
||||||
|
const parts = result.reasoning
|
||||||
|
.map((r) => (typeof r === 'object' && r !== null && 'text' in r ? String((r as { text: unknown }).text) : ''))
|
||||||
|
.filter((t) => t !== '');
|
||||||
|
return parts.length === 0 ? undefined : parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 结构化出参:模型常把 JSON 包在 ```json 围栏里。这里只做**提取**,
|
* 结构化出参:模型常把 JSON 包在 ```json 围栏里。这里只做**提取**,
|
||||||
* schema 校验归调用方(ADR-003:运行时校验用 ajv)。
|
* schema 校验归调用方(ADR-003:运行时校验用 ajv)。
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Provider 画像 —— 纯数据、零 IO(编码规范 §5.3)。
|
||||||
|
*
|
||||||
|
* 四元绑定里 Provider 是**枚举**,不是自由文本:选定 provider 就等于选定了它的
|
||||||
|
* 默认端点与调用姿势。把这些差异集中在一处,adapter 只按画像行事、不写 if-else 家族。
|
||||||
|
*
|
||||||
|
* 「活动只见自定义模型名」——本文件的一切都在系统侧,活动既不触厂商也不触密钥(OS 02)。
|
||||||
|
*/
|
||||||
|
import type { LlmProvider } from '@contracts';
|
||||||
|
|
||||||
|
export type ProviderProfile = {
|
||||||
|
/** 默认端点:治理台建实例时不填 URL 就用它 */
|
||||||
|
readonly defaultBaseUrl: string;
|
||||||
|
/**
|
||||||
|
* 结构化出参的姿势:
|
||||||
|
* `json-object` —— 端点支持 `response_format: {type:'json_object'}`,直接要 JSON;
|
||||||
|
* `prompt-only` —— 端点不支持,只能在提示里要求并对文本做提取。
|
||||||
|
*/
|
||||||
|
readonly structuredOutput: 'json-object' | 'prompt-only';
|
||||||
|
/** 推理模型:回包里带 reasoning,答案在正文,推理另置(不进出参、只作留痕) */
|
||||||
|
readonly emitsReasoning: boolean;
|
||||||
|
readonly label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeepSeek:OpenAI 兼容端点,支持 `json_object`;v4 系列是推理模型,
|
||||||
|
* 回包带 `reasoning_content` —— 那是**过程**不是结论,不能混进出参。
|
||||||
|
*/
|
||||||
|
export const PROVIDER_PROFILES: Readonly<Record<LlmProvider, ProviderProfile>> = {
|
||||||
|
'openai-compatible': {
|
||||||
|
defaultBaseUrl: '',
|
||||||
|
structuredOutput: 'prompt-only',
|
||||||
|
emitsReasoning: false,
|
||||||
|
label: 'OpenAI 兼容端点',
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
defaultBaseUrl: 'https://api.openai.com/v1',
|
||||||
|
structuredOutput: 'json-object',
|
||||||
|
emitsReasoning: false,
|
||||||
|
label: 'OpenAI',
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
defaultBaseUrl: 'https://api.deepseek.com',
|
||||||
|
structuredOutput: 'json-object',
|
||||||
|
emitsReasoning: true,
|
||||||
|
label: 'DeepSeek',
|
||||||
|
},
|
||||||
|
anthropic: {
|
||||||
|
defaultBaseUrl: 'https://api.anthropic.com/v1',
|
||||||
|
structuredOutput: 'prompt-only',
|
||||||
|
emitsReasoning: false,
|
||||||
|
label: 'Anthropic',
|
||||||
|
},
|
||||||
|
qwen: {
|
||||||
|
defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||||
|
structuredOutput: 'json-object',
|
||||||
|
emitsReasoning: false,
|
||||||
|
label: '通义千问',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function profileOf(provider: LlmProvider): ProviderProfile {
|
||||||
|
return PROVIDER_PROFILES[provider];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建实例时 URL 留空:回落到该 provider 的默认端点;openai-compatible 没有默认,必须自填 */
|
||||||
|
export function resolveBaseUrl(provider: LlmProvider, given: string | undefined): string {
|
||||||
|
const trimmed = (given ?? '').trim();
|
||||||
|
return trimmed === '' ? profileOf(provider).defaultBaseUrl : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 出参形状要**讲给模型听**。
|
||||||
|
*
|
||||||
|
* 声明了 `outputSchema` 却只在提示里写「只输出 JSON」,模型只能猜键名 ——
|
||||||
|
* 实测 DeepSeek 会回 `{"items": []}` 而声明要的是 `{"records": [...]}`,
|
||||||
|
* 于是每一次都判「出参不合 schema」。把 schema 附进提示是**系统的事**,
|
||||||
|
* 不该让每个活动作者各写一遍、各写各的措辞。
|
||||||
|
*
|
||||||
|
* 只附结构(必填 + 属性),不附描述性长文:提示越短越稳。
|
||||||
|
*/
|
||||||
|
export function schemaInstruction(schema: Record<string, unknown>): string {
|
||||||
|
return [
|
||||||
|
'出参必须是**单个 JSON 对象**,且严格符合下面的 JSON Schema。',
|
||||||
|
'键名照抄 schema,不得改写、不得增删顶层键;不确定的值留空或给合理默认,不要编造。',
|
||||||
|
'不要输出 Markdown 围栏、不要输出解释文字。',
|
||||||
|
`JSON Schema:\n${JSON.stringify(schema)}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type { LlmInstanceView } from '@contracts';
|
|||||||
import type { LlmService, UpsertLlmInstance } from '../../contract.ts';
|
import type { LlmService, UpsertLlmInstance } from '../../contract.ts';
|
||||||
import type { Clock, IdGenerator, LlmInstanceRepository, ProviderInvoker, SecretBox } from '../../port.ts';
|
import type { Clock, IdGenerator, LlmInstanceRepository, ProviderInvoker, SecretBox } from '../../port.ts';
|
||||||
import { pickSystemUse, toPublicView, validateBinding } from '../domain/model-binding.ts';
|
import { pickSystemUse, toPublicView, validateBinding } from '../domain/model-binding.ts';
|
||||||
|
import { resolveBaseUrl } from '../domain/provider-profile.ts';
|
||||||
import type { ModelBinding } from '../domain/model-binding.ts';
|
import type { ModelBinding } from '../domain/model-binding.ts';
|
||||||
|
|
||||||
export type LlmServiceDeps = {
|
export type LlmServiceDeps = {
|
||||||
@@ -34,7 +35,9 @@ export function createLlmService(deps: LlmServiceDeps): LlmService {
|
|||||||
id: existing?.id ?? input.id ?? deps.ids.next(),
|
id: existing?.id ?? input.id ?? deps.ids.next(),
|
||||||
name: input.name,
|
name: input.name,
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
baseUrl: input.baseUrl,
|
// URL 留空即取该 provider 的默认端点:选了 deepseek 就不必再背一遍它的地址
|
||||||
|
// URL 留空即取该 provider 的默认端点:选了 deepseek 就不必再背一遍它的地址
|
||||||
|
baseUrl: resolveBaseUrl(input.provider, input.baseUrl),
|
||||||
model: input.model,
|
model: input.model,
|
||||||
apiKeyCipher,
|
apiKeyCipher,
|
||||||
systemUse: input.systemUse,
|
systemUse: input.systemUse,
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ export function createModelGateway(deps: ModelGatewayDeps): ModelGateway {
|
|||||||
return {
|
return {
|
||||||
text: raw.text,
|
text: raw.text,
|
||||||
...(raw.object === undefined ? {} : { object: raw.object }),
|
...(raw.object === undefined ? {} : { object: raw.object }),
|
||||||
|
// 推理正文过网关但**不进录制**:录的是语义入参与结论,思考过程不是回放的判据
|
||||||
|
...(raw.reasoning === undefined ? {} : { reasoning: raw.reasoning }),
|
||||||
usage,
|
usage,
|
||||||
binding: { name: binding.name, provider: binding.provider, model: binding.model },
|
binding: { name: binding.name, provider: binding.provider, model: binding.model },
|
||||||
mode: deps.mode,
|
mode: deps.mode,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export type ProviderInvoker = {
|
|||||||
readonly binding: ModelBinding;
|
readonly binding: ModelBinding;
|
||||||
readonly apiKey: string;
|
readonly apiKey: string;
|
||||||
readonly call: ModelCall;
|
readonly call: ModelCall;
|
||||||
}): Promise<{ text: string; object?: unknown; usage: ModelUsage }>;
|
}): Promise<{ text: string; object?: unknown; reasoning?: string; usage: ModelUsage }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { ModelCall, ModelResult };
|
export type { ModelCall, ModelResult };
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ export const upsertLlmInstanceRoute = {
|
|||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
provider: llmProviderSchema,
|
provider: llmProviderSchema,
|
||||||
baseUrl: z.string().min(1),
|
/** 留空 = 取该 provider 的默认端点(openai-compatible 没有默认,必须自填) */
|
||||||
|
baseUrl: z.string().default(''),
|
||||||
model: z.string().min(1),
|
model: z.string().min(1),
|
||||||
/** 只写不读:留空表示保持原值 */
|
/** 只写不读:留空表示保持原值 */
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user