This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-MVP/apps/api/src/app/seed-quote.ts
T
ChaiHaoandClaude Opus 5 abd371b51c 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
2026-07-27 10:25:57 +08:00

426 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 统一报价的验收种子 —— README《MVP 完成判据》第 1 条的机器形态。
*
* 治理台要做的四件事,这里一次做完:
* ① 绑定 LLM 实例(任一 OpenAI 兼容端点)
* ② 注册功能工具并给活动白名单
* ③ **经安装通道**装入活动包(读 activities 下各包 dist 的清单与产物 —— 数据,不是 import)
* ④ 编排「统一报价」流程包 v1 并发布(边映射闭合校验通过)+ 配置事件触发器与邮件通道实例
*/
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { activityManifestSchema, processPackageSchema, triggerConfigSchema } from '@contracts';
import type { Container } from './container.ts';
/** 从仓库根找 activities/ —— 只读文件,不 import(ADR-004:安装通道无代码通道) */
function activitiesRoot(): string {
let dir = dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 8; i += 1) {
const candidate = join(dir, 'activities');
if (existsSync(candidate)) return candidate;
const parent = resolve(dir, '..');
if (parent === dir) break;
dir = parent;
}
throw new Error('找不到 activities/ —— 先跑 pnpm activities:build');
}
/** 第一方功能工具:查历史价 —— MVP 用录制回放替身代真 CRM(README:mock 只在外界) */
const HISTORY_TOOL = {
name: 'crm.history-price-read',
version: '1.0.0',
description: '读取历史价格情报:某 PN 最近一次报价距今天数、行情是否平稳、生命周期状态',
input: { type: 'object', required: ['pn'], properties: { pn: { type: 'string' } } },
output: {
type: 'object',
properties: {
historyDays: { type: 'integer' },
marketStable: { type: 'boolean' },
lifecycle: { type: 'string' },
hit: { type: 'boolean' },
},
},
effect: 'read' as const,
errors: [{ code: 'not-found', retryable: false, message: '无历史价' }],
idempotent: true,
timeoutMs: 10_000,
retries: 2,
connectionKeys: ['endpoint'],
};
/** 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地 */
const MAIL_TOOL = {
...HISTORY_TOOL,
name: 'mail.send',
description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界',
input: {
type: 'object',
required: ['to', 'subject'],
properties: { to: { type: 'string' }, subject: { type: 'string' }, body: { type: 'string' } },
},
output: { type: 'object', properties: { queued: { type: 'boolean' } } },
effect: 'write' as const,
idempotent: true,
connectionKeys: ['endpoint'],
};
/** 纯工具:无外效应、无连接配置、活动私有作用域 */
function pureTool(name: string, description: string, ownerActivityId: string, required: string[]) {
return {
...HISTORY_TOOL,
name,
description,
input: { type: 'object', required, properties: Object.fromEntries(required.map((k) => [k, {}])) },
output: { type: 'object', properties: {} },
effect: 'read' as const,
connectionKeys: [],
pure: true,
ownerActivityId,
};
}
/** 打标纯工具:三分类规则 P-03 的确定性求值 —— 无外效应,活动私有作用域 */
const CLASSIFY_TOOL = {
...HISTORY_TOOL,
name: 'quote-intake.classify',
description: '按三分类规则 P-03 逐条打标:输入历史天数/行情稳定/生命周期,输出 Type 与打法',
input: {
type: 'object',
required: ['historyDays'],
properties: { historyDays: {}, marketStable: {}, lifecycle: {} },
},
output: { type: 'object', properties: { type: { type: 'string' }, play: { type: 'string' } } },
connectionKeys: [],
pure: true,
ownerActivityId: 'quote-intake',
};
export async function seedQuote(container: Container): Promise<void> {
function log(line: string): void {
process.stdout.write(line + '\n');
}
/*
* ① 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: '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 实例已绑定:deepseek-v4-flash · deepseek-v4-pro · quote-extractor(→ flash,系统用途)');
/* ② 工具注册两审 + 白名单 */
const tools = [
HISTORY_TOOL,
MAIL_TOOL,
CLASSIFY_TOOL,
pureTool('quote-pricing.basis', '取价基准:报价相对历史成交价的偏离率,供底价门禁 P-02 求值', 'quote-pricing', ['quotedPrice']),
pureTool('quote-pricing.margin', '利润模型 P-01 的确定性求值:客户等级×物料类型×数量档 → 目标毛利区间', 'quote-pricing', ['customerGrade']),
pureTool('quote-pricing.floor-gate', '底价门禁 P-02 的确定性求值:偏离底价 ±20% 即止', 'quote-pricing', ['deviation']),
pureTool('quote-archive.profile', '客户画像更新:把成交行为写成标签,供 P-01 的客户等级因子取用', 'quote-archive', ['customerCode']),
pureTool('quote-archive.landing-rate', '落地率统计:放行通过的报价 ÷ 发出的报价', 'quote-archive', []),
];
for (const contract of tools) {
const verdict = await container.toolbox.register({
contract,
samples: [
{
// 样例集一份喂所有工具:并集覆盖各家必填,少一个键就有工具过不了两审
input: { pn: 'LM358DR', historyDays: 30, customerGrade: '普通客户', deviation: 0, to: 'x@y.com', subject: 't', customerCode: 'C001', quotedPrice: 9.2, lastPrice: 8.4 },
expectShape: { type: 'object' },
},
],
...(contract.connectionKeys.length > 0 ? { connectionValues: { endpoint: 'replay://crm' } } : {}),
});
if (!verdict.accepted) {
log(' 工具 ' + contract.name + ' 未过两审:' + verdict.staticFindings.map((f) => f.message).join('; '));
}
}
// 白名单按活动逐个给 —— 授权在白名单与契约,不在活动代码
await container.toolbox.setAllowlist('quote-intake', [HISTORY_TOOL.name, CLASSIFY_TOOL.name]);
await container.toolbox.setAllowlist('quote-pricing', [HISTORY_TOOL.name]);
await container.toolbox.setAllowlist('quote-negotiate', [HISTORY_TOOL.name, MAIL_TOOL.name]);
await container.toolbox.setAllowlist('quote-archive', []);
await container.toolbox.setAllowlist('quote-sourcing', [HISTORY_TOOL.name, MAIL_TOOL.name]);
await container.toolbox.setAllowlist('quote-alternative', [HISTORY_TOOL.name]);
log('② 功能工具已注册并入白名单(逐活动授权)');
/* ③ 经安装通道装入活动包 —— 静态审 + 样例集(首装无历史,免回放审) */
const root = activitiesRoot();
const packages = [
'quote-intake',
'quote-pricing',
'quote-negotiate',
'quote-archive',
'quote-sourcing',
'quote-alternative',
];
for (const dir of packages) {
const manifestRaw = JSON.parse(await readFile(join(root, dir, 'dist/manifest.json'), 'utf8'));
const manifest = activityManifestSchema.parse(manifestRaw);
const artifact = await readFile(join(root, dir, 'dist/index.js'), 'utf8');
const verdict = await container.registry.install(manifest, artifact);
log(' 装入 ' + manifest.identity.name + '(' + manifest.identity.paradigm + ',静态审 ' + String(verdict.staticFindings.length) + ' 条提示)');
}
log('③ 六个活动包已经安装通道装入');
/* ④ 编排「统一报价」流程包 v1 并发布 —— 七节点(SOP×5 + Case×2)+ 网关 + 审批点 */
const activityNode = (
id: string,
label: string,
activityId: string,
position: string,
failure: 'terminate' | 'tolerate',
col: number,
row = 0,
) => ({
id,
kind: 'activity' as const,
label,
activityId,
owner: { position, strategy: 'sole' as const },
failure: { kind: failure },
layout: { row, col },
});
const pkg = processPackageSchema.parse({
id: 'unified-quote',
name: '统一报价',
version: 1,
state: 'draft',
callInterface: {
input: {
type: 'object',
required: ['customerCode', 'rfqRef'],
properties: { customerCode: { type: 'string' }, rfqRef: { type: 'string' }, subject: { type: 'string' } },
},
output: { type: 'object', properties: { landingRate: { type: 'number' } } },
},
nodes: [
{ id: 'start', kind: 'start', label: '客户RFQ询价', layout: { row: 0, col: 0 } },
activityNode('A1', 'A1 RFQ需求分析', 'quote-intake', 'BD', 'terminate', 1),
// 已裁定 ②:G2 料号分流 = **并行网关**,三道并行、每边载荷按 Type 过滤
{ id: 'G2', kind: 'gateway', label: 'G2 料号分流', gateway: 'parallel', mode: 'fork', layout: { row: 0, col: 2 } },
// 某类条目为空的分支按「容忍」处置 —— A4 汇合处的必填映射不得依赖可空分支
activityNode('A3', 'A3 渠道寻源', 'quote-sourcing', 'PRM', 'tolerate', 3, -1),
activityNode('A2', 'A2 替代选型', 'quote-alternative', 'FAE', 'tolerate', 3, 1),
{ id: 'G2J', kind: 'gateway', label: 'G2 汇合', gateway: 'parallel', mode: 'join', layout: { row: 0, col: 4 } },
activityNode('A4', 'A4 成本定价', 'quote-pricing', 'BD', 'terminate', 5),
// 已裁定 ①:A5 定价审批 = **审批点**;G3 审批结果并入驳回走向(驳回 → 回 A4 重开新实例)
{
id: 'AP',
kind: 'approval',
label: 'AP 定价审批',
approver: { position: 'PM', strategy: 'sole' },
// 驳回走向的载荷映射不能空:重开的是**新的 A4 实例**,它照样要过 A4 的入参契约。
// 空映射 = 重开必失败,等于把「驳回回 A4」写成了一句空话。
reject: {
kind: 'reopen',
nodeId: 'A4',
mapping: [
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
{ target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } },
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
{ target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } },
{ target: 'customerGrade', source: { from: 'constant', value: '普通客户' } },
{ target: 'volumeTier', source: { from: 'constant', value: '大批量' } },
],
},
nudge: { deadline: 'PT30M', steps: [{ after: 'PT20M', action: 'nudge' }] },
layout: { row: 0, col: 6 },
},
activityNode('A6', 'A6 报价议价', 'quote-negotiate', 'BD', 'terminate', 7),
// G4 议价结果 = 异或网关:成交 / 有异议(回边 A4)/ 拒绝超时
{ id: 'G4', kind: 'gateway', label: 'G4 议价结果', gateway: 'exclusive', mode: 'fork', layout: { row: 0, col: 8 } },
activityNode('A7', 'A7 情报归档', 'quote-archive', 'BD', 'terminate', 9),
{ id: 'end', kind: 'end', label: '归档闭环', layout: { row: 0, col: 10 } },
],
edges: [
{
id: 'e-start-a1',
from: 'start',
to: 'A1',
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,
},
{ id: 'e-a1-g2', from: 'A1', to: 'G2', mapping: [], label: '统一受理记录', reopen: false },
// 三道并行:TYPE-A 直通定价 / TYPE-B 寻源 / TYPE-C 选型
// TYPE-A 直通:不经活动,直接进汇合 —— A4 因此只有一条并发入边,来源无歧义
{ id: 'e-g2-j', from: 'G2', to: 'G2J', label: 'TYPE-A 直通定价', mapping: [], reopen: false },
{
id: 'e-g2-a3',
from: 'G2',
to: 'A3',
label: 'TYPE-B 转寻源',
mapping: [
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
{ target: 'scenario', source: { from: 'constant', value: 'BOM后期' } },
],
reopen: false,
},
{
id: 'e-g2-a2',
from: 'G2',
to: 'A2',
label: 'TYPE-C 转选型',
mapping: [{ target: 'originalPn', source: { from: 'output-port', port: 'intake', path: 'pn' } }],
reopen: false,
},
// 汇合:容忍分支的输出**不进 A4 的必填映射**(静态校验会拦)
{ id: 'e-a3-j', from: 'A3', to: 'G2J', mapping: [], reopen: false },
{ id: 'e-a2-j', from: 'A2', to: 'G2J', mapping: [], reopen: false },
{
id: 'e-j-a4',
from: 'G2J',
to: 'A4',
mapping: [
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
// records 来自 A1 的输出口(受理归一的产物),不是起单入参 —— 起单时还没有这些行
{ target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } },
// 逐条办的料号:MVP 单条 RFQ,取受理归一出的那一条
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
// P-01 利润模型的三个因子:物料类型就是 A1 打的标(A/B/C),真实取值;
// 客户等级与数量档在 MVP 取默认档 —— 接 CRM 后改为读 shared.customer 与按 EAU 分档,
// P-01 决策表本就有兜底行('-' = 任意),默认档不会让求值失真。
{ target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } },
{ target: 'customerGrade', source: { from: 'constant', value: '普通客户' } },
{ target: 'volumeTier', source: { from: 'constant', value: '大批量' } },
],
reopen: false,
},
{ id: 'e-a4-ap', from: 'A4', to: 'AP', mapping: [], label: '报价草案提审', reopen: false },
{
id: 'e-ap-a6',
from: 'AP',
to: 'A6',
label: '通过',
mapping: [
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
{ target: 'items', source: { from: 'output-port', port: 'draft', path: 'items' } },
],
reopen: false,
},
{ id: 'e-a6-g4', from: 'A6', to: 'G4', mapping: [], reopen: false },
{ id: 'e-g4-end', from: 'G4', to: 'end', condition: 'outcome = "won"', label: '成交 → 转订单履约', reopen: false },
// 已裁定 ④:议价回环 = **流程层回边**,人裁决后重开新任务实例
{
id: 'e-g4-a4',
from: 'G4',
to: 'A4',
condition: 'outcome = "revise"',
label: '有异议 → 返回调整',
mapping: [
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
// records 来自 A1 的输出口(受理归一的产物),不是起单入参 —— 起单时还没有这些行
{ target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } },
// 逐条办的料号:MVP 单条 RFQ,取受理归一出的那一条
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
// P-01 利润模型的三个因子:物料类型就是 A1 打的标(A/B/C),真实取值;
// 客户等级与数量档在 MVP 取默认档 —— 接 CRM 后改为读 shared.customer 与按 EAU 分档,
// P-01 决策表本就有兜底行('-' = 任意),默认档不会让求值失真。
{ target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } },
{ target: 'customerGrade', source: { from: 'constant', value: '普通客户' } },
{ target: 'volumeTier', source: { from: 'constant', value: '大批量' } },
],
reopen: true,
},
{
id: 'e-g4-a7',
from: 'G4',
to: 'A7',
condition: 'outcome = "lost"',
label: '拒绝/超时 → 情报归档',
mapping: [
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
{ target: 'quoteNo', source: { from: 'initiation', path: 'rfqRef' } },
],
reopen: false,
},
{ id: 'e-a7-end', from: 'A7', to: 'end', mapping: [], reopen: false },
],
variables: [],
});
await container.process.savePackage(pkg);
const report = await container.process.validate(pkg);
if (!report.publishable) {
log(' 闭合校验未过,不得发布:' + report.violations.map((v) => v.message).join('; '));
} else {
await container.process.publish(pkg.id, pkg.version);
log('④ 流程包「统一报价」v1 已发布 —— 7 节点 + 3 网关 + 1 审批点,边映射闭合校验通过');
}
/* 事件触发器「客户RFQ询价」 */
const trigger = triggerConfigSchema.parse({
id: 'trg-rfq',
name: '客户RFQ询价',
enabled: true,
processPackageId: 'unified-quote',
mapping: [
{ 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' },
});
await container.trigger.upsert(trigger);
log(' 事件触发器「客户RFQ询价」已配置');
/* 邮件通道实例:归属键提取规则 + 载荷映射 + 默认认领岗位 */
const { positions } = await container.org.listStaff();
const op = positions.find((p) => p.code === 'OP');
await container.channel.upsert({
id: 'ch-rfq-mail',
name: '公司询价邮箱',
kind: 'mail',
address: 'rfq@we.com',
enabled: true,
extract: [
{ key: 'customerCode', regex: '\\bC\\d{3}\\b', source: 'both' },
{ key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+', source: 'both' },
{ key: 'quoteNo', regex: 'Q\\d{6}', source: 'both' },
],
mapping: [],
...(op === undefined ? {} : { fallbackPosition: op.id }),
addressWindowMs: 7 * 86_400_000,
});
log(' 邮件通道实例「公司询价邮箱」已配置(默认认领岗位:OP)');
}