From abd371b51c6b0b4e4a0af50a4f7ba777ad8656c8 Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Mon, 27 Jul 2026 10:25:57 +0800 Subject: [PATCH] =?UTF-8?q?DeepSeek=20provider=20+=20=E5=88=A4=E6=8D=AE=20?= =?UTF-8?q?=E2=91=A2=20=E6=9C=BA=E5=99=A8=E8=B7=AF=E5=BE=84=E8=B7=91?= =?UTF-8?q?=E9=80=9A(=E9=9B=B6=E4=BA=BA=E5=B7=A5=E5=85=9C=E5=BA=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../attachments/inbound/rfq-llm-01@x.com.eml | 12 +++ .../attachments/inbound/rfq-llm-02@x.com.eml | 12 +++ .../attachments/inbound/rfq-llm-03@x.com.eml | 12 +++ .../attachments/inbound/rfq-llm-04@x.com.eml | 12 +++ .../attachments/inbound/rfq-llm-05@x.com.eml | 12 +++ .../attachments/inbound/rfq-llm-06@x.com.eml | 12 +++ .../attachments/inbound/rfq-llm-07@x.com.eml | 12 +++ .../quote-intake/__tests__/activity.test.ts | 7 +- activities/quote-intake/src/manifest.ts | 34 +++++-- .../api/src/modules/flow-engine/port.d.ts | 8 ++ .../apps/api/src/modules/llm/contract.d.ts | 7 ++ api-report/apps/api/src/modules/llm/port.d.ts | 1 + .../contracts/src/api/governance.d.ts | 2 +- apps/api/src/app/container.ts | 8 ++ apps/api/src/app/seed-quote.ts | 37 ++++++-- apps/api/src/app/wiring/engines.ts | 8 +- apps/api/src/app/workers/dispatcher.ts | 14 ++- apps/api/src/app/workers/task-advancer.ts | 39 +++++--- .../internal/usecase/channel-service.ts | 6 +- .../__tests__/step-sequence.test.ts | 28 ++++++ .../internal/domain/step-sequence.ts | 18 ++++ .../internal/usecase/flow-runner.ts | 37 ++++++-- apps/api/src/modules/flow-engine/port.ts | 8 +- .../llm/__tests__/provider-profile.test.ts | 28 ++++++ apps/api/src/modules/llm/contract.ts | 7 ++ .../llm/internal/adapter/ai-sdk-provider.ts | 35 +++++++- .../llm/internal/domain/provider-profile.ts | 89 +++++++++++++++++++ .../llm/internal/usecase/llm-service.ts | 5 +- .../llm/internal/usecase/model-gateway.ts | 2 + apps/api/src/modules/llm/port.ts | 2 +- packages/contracts/src/api/governance.ts | 3 +- 31 files changed, 467 insertions(+), 50 deletions(-) create mode 100644 .data/attachments/inbound/rfq-llm-01@x.com.eml create mode 100644 .data/attachments/inbound/rfq-llm-02@x.com.eml create mode 100644 .data/attachments/inbound/rfq-llm-03@x.com.eml create mode 100644 .data/attachments/inbound/rfq-llm-04@x.com.eml create mode 100644 .data/attachments/inbound/rfq-llm-05@x.com.eml create mode 100644 .data/attachments/inbound/rfq-llm-06@x.com.eml create mode 100644 .data/attachments/inbound/rfq-llm-07@x.com.eml create mode 100644 apps/api/src/modules/llm/__tests__/provider-profile.test.ts create mode 100644 apps/api/src/modules/llm/internal/domain/provider-profile.ts diff --git a/.data/attachments/inbound/rfq-llm-01@x.com.eml b/.data/attachments/inbound/rfq-llm-01@x.com.eml new file mode 100644 index 0000000..4188640 --- /dev/null +++ b/.data/attachments/inbound/rfq-llm-01@x.com.eml @@ -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": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-llm-02@x.com.eml b/.data/attachments/inbound/rfq-llm-02@x.com.eml new file mode 100644 index 0000000..599a252 --- /dev/null +++ b/.data/attachments/inbound/rfq-llm-02@x.com.eml @@ -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": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-llm-03@x.com.eml b/.data/attachments/inbound/rfq-llm-03@x.com.eml new file mode 100644 index 0000000..88ea454 --- /dev/null +++ b/.data/attachments/inbound/rfq-llm-03@x.com.eml @@ -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": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-llm-04@x.com.eml b/.data/attachments/inbound/rfq-llm-04@x.com.eml new file mode 100644 index 0000000..0acbeee --- /dev/null +++ b/.data/attachments/inbound/rfq-llm-04@x.com.eml @@ -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": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-llm-05@x.com.eml b/.data/attachments/inbound/rfq-llm-05@x.com.eml new file mode 100644 index 0000000..06e2814 --- /dev/null +++ b/.data/attachments/inbound/rfq-llm-05@x.com.eml @@ -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": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-llm-06@x.com.eml b/.data/attachments/inbound/rfq-llm-06@x.com.eml new file mode 100644 index 0000000..fe6677e --- /dev/null +++ b/.data/attachments/inbound/rfq-llm-06@x.com.eml @@ -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": [] +} \ No newline at end of file diff --git a/.data/attachments/inbound/rfq-llm-07@x.com.eml b/.data/attachments/inbound/rfq-llm-07@x.com.eml new file mode 100644 index 0000000..6d2b405 --- /dev/null +++ b/.data/attachments/inbound/rfq-llm-07@x.com.eml @@ -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": [] +} \ No newline at end of file diff --git a/activities/quote-intake/__tests__/activity.test.ts b/activities/quote-intake/__tests__/activity.test.ts index f0896e8..914db70 100644 --- a/activities/quote-intake/__tests__/activity.test.ts +++ b/activities/quote-intake/__tests__/activity.test.ts @@ -49,9 +49,10 @@ describe('步序声明(AM 12.3)', () => { it('条件跳步是任务数据上的确定性谓词,由引擎求值', () => { const askBack = model.steps.find((s) => s.name === 'S2_ask_back'); - expect(askBack?.when).toBe('missingKeyFields > 0'); - expect(evaluate(askBack!.when!, { missingKeyFields: 2 })).toBe(true); - expect(evaluate(askBack!.when!, { missingKeyFields: 0 })).toBe(false); + expect(askBack?.when).toBe('missing_key_fields > 0'); + // 谓词读的是**落表后的字段名** —— 作用域来自台账,不是模型出参的驼峰名 + expect(evaluate(askBack!.when!, { missing_key_fields: 2 })).toBe(true); + expect(evaluate(askBack!.when!, { missing_key_fields: 0 })).toBe(false); }); it('人类工具步都配了时限 —— 无死等', () => { diff --git a/activities/quote-intake/src/manifest.ts b/activities/quote-intake/src/manifest.ts index 7a85960..ef132dc 100644 --- a/activities/quote-intake/src/manifest.ts +++ b/activities/quote-intake/src/manifest.ts @@ -40,7 +40,7 @@ export const manifest: ActivityManifest = { identity: { activityId: ACTIVITY_ID, name: 'A1 · RFQ 需求分析', - packageVersion: '0.1.3', + packageVersion: '0.1.5', category: '统一报价', paradigm: 'flow', sdkVersion: '0.1.0-m5', @@ -59,6 +59,8 @@ export const manifest: ActivityManifest = { customerCode: { type: 'string' }, rfqRef: { type: 'string' }, subject: { type: 'string' }, + /** 询价原文 —— 归一步拆解的对象 */ + body: { type: 'string' }, }, }, messageAllowed: true, @@ -124,6 +126,7 @@ export const manifest: ActivityManifest = { { name: 'target_price', type: 'number', 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: 'missing_key_fields', type: 'integer', primaryKey: false, masked: false, label: '缺关键字段数' }, ], }, { @@ -171,17 +174,34 @@ export const manifest: ActivityManifest = { kind: 'llm', name: 'S3_normalize', label: 'S3 逐条解析归一', - input: { rfqRef: 'rfqRef', customerCode: 'customerCode' }, + input: { rfqRef: 'rfqRef', customerCode: 'customerCode', subject: 'subject', body: 'body' }, + /* + * 出参路径要对得上**自己声明的 outputSchema**:模型回的是 + * `{ records: [...], missingKeyFields }`,不是一堆扁平字段。 + * MVP 逐条办的第一条 = `records.0`;`customerCode` 不在模型出参里, + * 它来自起单入参,由作用域补上。 + */ output: { domain: INTAKE_DOMAIN, object: 'record', - key: { customer_code: 'customerCode', pn: 'pn' }, - fields: { pn: 'pn', brand: 'brand', eau: 'eau' }, + key: { customer_code: 'customerCode', pn: 'records.0.pn' }, + 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', + // 出参形状由网关按 outputSchema 统一附上,模板只讲**这件事本身** 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: { type: 'object', required: ['records'], @@ -210,7 +230,7 @@ export const manifest: ActivityManifest = { name: 'S2_ask_back', label: 'S2 追问补全信息', // D1 判断完整度:缺 PN/数量/交期三要素才走补全 —— 条件必谓词,由引擎求值 - when: 'missingKeyFields > 0', + when: 'missing_key_fields > 0', input: {}, workOrder: 'execution', card: { title: '缺关键字段,请向客户追问补全', fields: ['pn', 'eau', 'leadTime'] }, diff --git a/api-report/apps/api/src/modules/flow-engine/port.d.ts b/api-report/apps/api/src/modules/flow-engine/port.d.ts index e73700f..45389e6 100644 --- a/api-report/apps/api/src/modules/flow-engine/port.d.ts +++ b/api-report/apps/api/src/modules/flow-engine/port.d.ts @@ -66,9 +66,17 @@ export type LlmStepGateway = { }): Promise<{ ok: boolean; object?: unknown; + text?: string; error?: string; }>; }; +/** 出参校验 —— app 注入 ajv:LLM 步的出参**必过声明的 schema**(AM 12.3) */ +export type SchemaValidator = { + validate(schema: Record, value: unknown): { + ok: boolean; + errors: string[]; + }; +}; /** 人类工具步 —— app 注入 `human-tool`(L3) */ export type CardGateway = { issue(input: { diff --git a/api-report/apps/api/src/modules/llm/contract.d.ts b/api-report/apps/api/src/modules/llm/contract.d.ts index 57be0dd..8683e97 100644 --- a/api-report/apps/api/src/modules/llm/contract.d.ts +++ b/api-report/apps/api/src/modules/llm/contract.d.ts @@ -49,6 +49,12 @@ export type ModelResult = { /** outputSchema 给定时的解析结果 */ readonly object?: unknown; readonly usage: ModelUsage; + /** + * 推理模型的思考正文 —— **过程,不是结论**。 + * 出参只认 `text` / `object`;这一段单独留着,供任务记忆归档与断点复盘, + * 不参与 schema 校验、不进业务字段。 + */ + readonly reasoning?: string; /** 行为可归因:模型与版本记入留痕(OS 4.1) */ readonly binding: { readonly name: string; @@ -71,6 +77,7 @@ export type UpsertLlmInstance = { readonly id?: string | undefined; readonly name: string; readonly provider: LlmProvider; + /** 留空 = 取该 provider 的默认端点 */ readonly baseUrl: string; readonly model: string; /** 只写不读:留空表示保持原值 */ diff --git a/api-report/apps/api/src/modules/llm/port.d.ts b/api-report/apps/api/src/modules/llm/port.d.ts index 7034856..15b2f87 100644 --- a/api-report/apps/api/src/modules/llm/port.d.ts +++ b/api-report/apps/api/src/modules/llm/port.d.ts @@ -48,6 +48,7 @@ export type ProviderInvoker = { }): Promise<{ text: string; object?: unknown; + reasoning?: string; usage: ModelUsage; }>; }; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 653c9bd..358653d 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -271,7 +271,7 @@ export declare const upsertLlmInstanceRoute: { anthropic: "anthropic"; qwen: "qwen"; }>; - baseUrl: z.ZodString; + baseUrl: z.ZodDefault; model: z.ZodString; apiKey: z.ZodOptional; systemUse: z.ZodDefault; diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index 8263f93..c2281fb 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -150,6 +150,14 @@ export const firstPartyToolHandlers = new Map(FIRST_PARTY_T export async function createContainer(config: AppConfig): Promise { 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 ids = createIdGenerator(); const secrets = createSecretBox(loadSecretKey()); diff --git a/apps/api/src/app/seed-quote.ts b/apps/api/src/app/seed-quote.ts index d1da641..1845c56 100644 --- a/apps/api/src/app/seed-quote.ts +++ b/apps/api/src/app/seed-quote.ts @@ -102,17 +102,37 @@ export async function seedQuote(container: Container): Promise { 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({ name: 'quote-extractor', - provider: 'openai-compatible', - baseUrl: process.env['JCAI_LLM_BASE_URL'] ?? 'https://api.deepseek.com/v1', - model: process.env['JCAI_LLM_MODEL'] ?? 'deepseek-chat', - apiKey: process.env['JCAI_LLM_API_KEY'] ?? 'sk-placeholder', + provider: 'deepseek', + baseUrl: process.env['JCAI_LLM_BASE_URL'] ?? '', + model: process.env['JCAI_LLM_MODEL'] ?? 'deepseek-v4-flash', + apiKey: deepseekKey, systemUse: true, enabled: true, }); - log('① LLM 实例已绑定:quote-extractor'); + log('① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro · quote-extractor(→ flash,系统用途)'); /* ② 工具注册两审 + 白名单 */ const tools = [ @@ -248,6 +268,9 @@ export async function seedQuote(container: Container): Promise { mapping: [ { target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }, { target: 'rfqRef', source: { from: 'initiation', path: 'rfqRef' } }, + // 主题与正文要一路带到 A1:归一步拆的就是它们 + { target: 'subject', source: { from: 'initiation', path: 'subject' } }, + { target: 'body', source: { from: 'initiation', path: 'body' } }, ], reopen: false, }, @@ -372,6 +395,8 @@ export async function seedQuote(container: Container): Promise { { target: 'customerCode', source: { from: 'expression', sfeel: 'keys.customerCode' } }, { target: 'rfqRef', source: { from: 'expression', sfeel: 'keys.rfqRef' } }, { 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' }, }); diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 26a6fbd..95b78e1 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -284,6 +284,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { evaluate: sfeel.condition, data: taskData, tools: { ...toolGateway, invoke: (i) => toolGateway.invoke(i) }, + schemas: deps.schemas, llm: { async generate(input) { // LLM 在 flow 里只以「被调用的工具」出现,不是调度者 @@ -296,7 +297,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { stepNo: input.stepNo, 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) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } diff --git a/apps/api/src/app/workers/dispatcher.ts b/apps/api/src/app/workers/dispatcher.ts index e904af2..2d54fec 100644 --- a/apps/api/src/app/workers/dispatcher.ts +++ b/apps/api/src/app/workers/dispatcher.ts @@ -22,14 +22,22 @@ export type DispatcherDeps = { readonly batchSize?: number; }; -/** 目标 ID → advisory lock 键(稳定散列,同一目标恒同一把锁) */ -function lockKey(target: Delivery['target']): number { - const text = `${target.kind}:${target.id}`; +/** + * 目标 ID → advisory lock 键(稳定散列,同一目标恒同一把锁)。 + * + * **推进器与派发器必须共用这一把**:一边由事件投递驱动推进任务、一边由推进器轮询推进, + * 用的不是同一把锁就等于没锁 —— 实测会同一步跑两遍、同一张卡发两张、 + * 同一行草稿写出两条互相打架的版本。 + */ +export function targetLockKey(kind: string, id: string): number { + const text = `${kind}:${id}`; let hash = 5381; for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0; return hash; } +const lockKey = (target: Delivery['target']): number => targetLockKey(target.kind, String(target.id)); + export async function dispatchOnce(deps: DispatcherDeps): Promise { const { container } = deps; const batch = await claimPendingDeliveries(container.db, deps.batchSize ?? 20); diff --git a/apps/api/src/app/workers/task-advancer.ts b/apps/api/src/app/workers/task-advancer.ts index b9d5a3d..01e4ae3 100644 --- a/apps/api/src/app/workers/task-advancer.ts +++ b/apps/api/src/app/workers/task-advancer.ts @@ -8,6 +8,7 @@ import { and, eq, sql } from 'drizzle-orm'; import { taskTable } from '@modules/task'; import type { Container } from '../container.ts'; +import { targetLockKey } from './dispatcher.ts'; export type AdvancerOptions = { readonly container: Container; @@ -27,19 +28,33 @@ export async function advanceOnce(options: AdvancerOptions): Promise { let advanced = 0; for (const row of rows) { - 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; + /* + * 与派发器共用同一把 advisory lock:事件投递也会推进同一个任务。 + * 抢不到就跳过 —— 那边正推着,这边再推就是两条线同时改一份运行时状态。 + */ + const key = targetLockKey('task', row.id); + const locked = await container.db.execute<{ ok: boolean }>( + sql`select pg_try_advisory_lock(${key}) as ok`, + ); + if (locked.rows[0]?.ok !== true) continue; + + try { + 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; diff --git a/apps/api/src/modules/channel/internal/usecase/channel-service.ts b/apps/api/src/modules/channel/internal/usecase/channel-service.ts index ea87e3b..3cd44cc 100644 --- a/apps/api/src/modules/channel/internal/usecase/channel-service.ts +++ b/apps/api/src/modules/channel/internal/usecase/channel-service.ts @@ -77,7 +77,11 @@ export function createChannelService(deps: ChannelServiceDeps): ChannelService { draft: { type: 'data', 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(), source: { kind: 'channel', detail: config.name }, }, diff --git a/apps/api/src/modules/flow-engine/__tests__/step-sequence.test.ts b/apps/api/src/modules/flow-engine/__tests__/step-sequence.test.ts index 3ca5b2b..2535f0f 100644 --- a/apps/api/src/modules/flow-engine/__tests__/step-sequence.test.ts +++ b/apps/api/src/modules/flow-engine/__tests__/step-sequence.test.ts @@ -10,6 +10,7 @@ import { lastStepIsAcceptance, markStep, parallelGroupComplete, + resolvePath, } from '../internal/domain/step-sequence.ts'; const llmStep = (over: Record = {}): 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(); + }); +}); diff --git a/apps/api/src/modules/flow-engine/internal/domain/step-sequence.ts b/apps/api/src/modules/flow-engine/internal/domain/step-sequence.ts index 78e8c86..4a58382 100644 --- a/apps/api/src/modules/flow-engine/internal/domain/step-sequence.ts +++ b/apps/api/src/modules/flow-engine/internal/domain/step-sequence.ts @@ -156,3 +156,21 @@ export function lastVerdictPassed(position: FlowPosition): boolean { export function lastSelectedPorts(position: FlowPosition): Record { return position.steps[position.steps.length - 1]?.selectedPorts ?? {}; } + +/** + * 取值路径:点号分段下行,数组下标按数字段走(`records.0.pn`)。 + * + * **入参与出参共用这一份**:声明里写的路径是同一种写法,不该一边能走点号、 + * 另一边只认扁平键 —— 那会让「模型按 schema 回了嵌套结构」这种最常见的情形 + * 悄悄写出一条空记录。 + */ +export function resolvePath(source: unknown, path: string): unknown { + return path.split('.').reduce((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)[seg]; + }, source); +} diff --git a/apps/api/src/modules/flow-engine/internal/usecase/flow-runner.ts b/apps/api/src/modules/flow-engine/internal/usecase/flow-runner.ts index 797beb0..8177b48 100644 --- a/apps/api/src/modules/flow-engine/internal/usecase/flow-runner.ts +++ b/apps/api/src/modules/flow-engine/internal/usecase/flow-runner.ts @@ -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 = {}; 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 = {}; 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): Record { const out: Record = {}; - for (const [key, path] of Object.entries(step.input)) { - out[key] = path.split('.').reduce((acc, seg) => { - if (acc === null || acc === undefined || typeof acc !== 'object') return undefined; - return (acc as Record)[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; + + /* + * 「出参必过 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; await writeOutput(taskId, step, output); const next = markStep(position, index, { state: 'done', attempt, output }); await deps.state.save(taskId, (await loaded(taskId)).model, next); diff --git a/apps/api/src/modules/flow-engine/port.ts b/apps/api/src/modules/flow-engine/port.ts index 82a9eff..2135071 100644 --- a/apps/api/src/modules/flow-engine/port.ts +++ b/apps/api/src/modules/flow-engine/port.ts @@ -55,7 +55,13 @@ export type LlmStepGateway = { outputSchema: Record; 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, value: unknown): { ok: boolean; errors: string[] }; }; /** 人类工具步 —— app 注入 `human-tool`(L3) */ diff --git a/apps/api/src/modules/llm/__tests__/provider-profile.test.ts b/apps/api/src/modules/llm/__tests__/provider-profile.test.ts new file mode 100644 index 0000000..f3ec537 --- /dev/null +++ b/apps/api/src/modules/llm/__tests__/provider-profile.test.ts @@ -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(''); + }); +}); diff --git a/apps/api/src/modules/llm/contract.ts b/apps/api/src/modules/llm/contract.ts index 3169360..ceb0ca8 100644 --- a/apps/api/src/modules/llm/contract.ts +++ b/apps/api/src/modules/llm/contract.ts @@ -65,6 +65,12 @@ export type ModelResult = { /** outputSchema 给定时的解析结果 */ readonly object?: unknown; readonly usage: ModelUsage; + /** + * 推理模型的思考正文 —— **过程,不是结论**。 + * 出参只认 `text` / `object`;这一段单独留着,供任务记忆归档与断点复盘, + * 不参与 schema 校验、不进业务字段。 + */ + readonly reasoning?: string; /** 行为可归因:模型与版本记入留痕(OS 4.1) */ readonly binding: { readonly name: string; readonly provider: LlmProvider; readonly model: string }; readonly mode: GatewayMode; @@ -85,6 +91,7 @@ export type UpsertLlmInstance = { readonly id?: string | undefined; readonly name: string; readonly provider: LlmProvider; + /** 留空 = 取该 provider 的默认端点 */ readonly baseUrl: string; readonly model: string; /** 只写不读:留空表示保持原值 */ diff --git a/apps/api/src/modules/llm/internal/adapter/ai-sdk-provider.ts b/apps/api/src/modules/llm/internal/adapter/ai-sdk-provider.ts index 61d169c..1d4cefc 100644 --- a/apps/api/src/modules/llm/internal/adapter/ai-sdk-provider.ts +++ b/apps/api/src/modules/llm/internal/adapter/ai-sdk-provider.ts @@ -7,34 +7,53 @@ import { generateText } from 'ai'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import type { ProviderInvoker } from '../../port.ts'; +import { profileOf, schemaInstruction } from '../domain/provider-profile.ts'; /** * MVP 的全部 Provider 都以 OpenAI 兼容端点接入(README:「绑定 LLM 实例(任一 OpenAI 兼容端点)」)。 - * 枚举是为了治理台的呈现与日后分化,当下共用一个工厂。 + * 各家的差异(默认端点 / 结构化出参姿势 / 是否回推理)集中在 `provider-profile`, + * 这里只按画像行事 —— 不写一族 if-else,新增 provider 只改画像。 */ export function createProviderInvoker(): ProviderInvoker { return { async invoke({ binding, apiKey, call }) { + const profile = profileOf(binding.provider); const provider = createOpenAICompatible({ name: binding.provider, baseURL: binding.baseUrl, 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 result = await generateText({ model: provider(binding.model), - ...(call.system === undefined ? {} : { system: call.system }), + ...(system === '' ? {} : { system }), prompt: call.prompt, ...(call.temperature === undefined ? {} : { temperature: call.temperature }), ...(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 { text: result.text, ...(object === undefined ? {} : { object }), + ...(reasoning === undefined || reasoning === '' ? {} : { reasoning }), usage: { inputTokens: result.usage.inputTokens ?? 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 围栏里。这里只做**提取**, * schema 校验归调用方(ADR-003:运行时校验用 ajv)。 diff --git a/apps/api/src/modules/llm/internal/domain/provider-profile.ts b/apps/api/src/modules/llm/internal/domain/provider-profile.ts new file mode 100644 index 0000000..d823d90 --- /dev/null +++ b/apps/api/src/modules/llm/internal/domain/provider-profile.ts @@ -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> = { + '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 { + return [ + '出参必须是**单个 JSON 对象**,且严格符合下面的 JSON Schema。', + '键名照抄 schema,不得改写、不得增删顶层键;不确定的值留空或给合理默认,不要编造。', + '不要输出 Markdown 围栏、不要输出解释文字。', + `JSON Schema:\n${JSON.stringify(schema)}`, + ].join('\n'); +} diff --git a/apps/api/src/modules/llm/internal/usecase/llm-service.ts b/apps/api/src/modules/llm/internal/usecase/llm-service.ts index ca4d872..b15db8d 100644 --- a/apps/api/src/modules/llm/internal/usecase/llm-service.ts +++ b/apps/api/src/modules/llm/internal/usecase/llm-service.ts @@ -5,6 +5,7 @@ import type { LlmInstanceView } from '@contracts'; import type { LlmService, UpsertLlmInstance } from '../../contract.ts'; import type { Clock, IdGenerator, LlmInstanceRepository, ProviderInvoker, SecretBox } from '../../port.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'; export type LlmServiceDeps = { @@ -34,7 +35,9 @@ export function createLlmService(deps: LlmServiceDeps): LlmService { id: existing?.id ?? input.id ?? deps.ids.next(), name: input.name, provider: input.provider, - baseUrl: input.baseUrl, + // URL 留空即取该 provider 的默认端点:选了 deepseek 就不必再背一遍它的地址 + // URL 留空即取该 provider 的默认端点:选了 deepseek 就不必再背一遍它的地址 + baseUrl: resolveBaseUrl(input.provider, input.baseUrl), model: input.model, apiKeyCipher, systemUse: input.systemUse, diff --git a/apps/api/src/modules/llm/internal/usecase/model-gateway.ts b/apps/api/src/modules/llm/internal/usecase/model-gateway.ts index 149d031..920d903 100644 --- a/apps/api/src/modules/llm/internal/usecase/model-gateway.ts +++ b/apps/api/src/modules/llm/internal/usecase/model-gateway.ts @@ -71,6 +71,8 @@ export function createModelGateway(deps: ModelGatewayDeps): ModelGateway { return { text: raw.text, ...(raw.object === undefined ? {} : { object: raw.object }), + // 推理正文过网关但**不进录制**:录的是语义入参与结论,思考过程不是回放的判据 + ...(raw.reasoning === undefined ? {} : { reasoning: raw.reasoning }), usage, binding: { name: binding.name, provider: binding.provider, model: binding.model }, mode: deps.mode, diff --git a/apps/api/src/modules/llm/port.ts b/apps/api/src/modules/llm/port.ts index 8ed1f06..9391fdb 100644 --- a/apps/api/src/modules/llm/port.ts +++ b/apps/api/src/modules/llm/port.ts @@ -43,7 +43,7 @@ export type ProviderInvoker = { readonly binding: ModelBinding; readonly apiKey: string; readonly call: ModelCall; - }): Promise<{ text: string; object?: unknown; usage: ModelUsage }>; + }): Promise<{ text: string; object?: unknown; reasoning?: string; usage: ModelUsage }>; }; export type { ModelCall, ModelResult }; diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index 850815d..1512e49 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -96,7 +96,8 @@ export const upsertLlmInstanceRoute = { id: z.string().optional(), name: z.string().min(1), provider: llmProviderSchema, - baseUrl: z.string().min(1), + /** 留空 = 取该 provider 的默认端点(openai-compatible 没有默认,必须自填) */ + baseUrl: z.string().default(''), model: z.string().min(1), /** 只写不读:留空表示保持原值 */ apiKey: z.string().optional(),