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-TMP-REPO/apps/api/src/app/seed-quote.ts
T

353 lines
15 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: 2,
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' } } },
},
/*
* **当前只展开第一个活动**(v2)。
* 后续活动一个一个加回来:每加一个,补它的节点、边映射与出口口载荷,
* 版本随之上抬 —— 在途的旧单仍跑各自起单时那一版,不受影响。
*/
nodes: [
{ id: 'start', kind: 'start', label: '客户RFQ询价', layout: { row: 0, col: 0 } },
activityNode('A1', '客户RFQ受理', 'quote-intake', 'BD', 'terminate', 1),
{ id: 'end', kind: 'end', label: '受理闭环', layout: { row: 0, col: 2 } },
],
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-end', from: 'A1', to: 'end', mapping: [], label: '统一受理记录', 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(`④ 流程包「统一报价」v${pkg.version} 已发布 —— ${pkg.nodes.length} 节点,边映射闭合校验通过`);
}
/* 事件触发器「客户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' } },
/*
* 单据日期:取入站时刻。
* MVP 简化 —— 真实系统里「报价日期」应取报价单**实际发出**的时刻(出站回执),
* 而不是询价到达的时刻;两者在一单之内通常只差几天,不影响历史价情报的可用性。
* 之所以要有它:`shared.history_price` 的主键是 (pn, quoted_at),
* 归档若拿不到日期就写不进去 —— 主键只增不破坏,不能为了省事去改它。
*/
{ target: 'quotedAt', source: { from: 'expression', sfeel: 'occurredAt' } },
],
// 按**通道**收口:只有投进「公司询价邮箱」的才自动起单;
// 同一封信投到「询价待确认邮箱」不命中,落人工确认那条路
spec: {
kind: 'event',
rule: 'channelId = "ch-rfq-mail" and (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)');
/*
* ⑥ 人工确认通道 + 登记触发器 —— 三类触发里的**登记触发**(OS 3.1)。
*
* 同一封 RFQ,两条起单路径:
* · 走「公司询价邮箱」→ 事件触发器命中 → **自动起单**;
* · 走「询价待确认邮箱」→ 无触发器命中 → 按「宁交人,不错投」落**消息兜底**到
* 默认认领岗位(PM)→ 由人确认后**登记起单**,「登记人即来源」。
* 后者是给需要人过一道目的场景用的:同样的信,起不起单由人定。
*/
const pm = positions.find((p) => p.code === 'PM');
await container.channel.upsert({
id: 'ch-rfq-review',
name: '询价待确认邮箱',
kind: 'mail',
address: 'rfq-review@we.com',
enabled: true,
// 归属键规则与正式通道一致:识别照做,只是不自动起单
extract: [
{ key: 'customerCode', regex: '\\bC\\d{3}\\b', source: 'both' },
{ key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+', source: 'both' },
],
mapping: [],
...(pm === undefined ? {} : { fallbackPosition: pm.id }),
addressWindowMs: 7 * 86_400_000,
});
await container.trigger.upsert(
triggerConfigSchema.parse({
id: 'trg-rfq-manual',
name: '人工登记起单(统一报价)',
enabled: true,
processPackageId: 'unified-quote',
// 登记触发以**登记表单**为输入,不做字段映射:表单由调用接口 schema 自动渲染
mapping: [],
spec: { kind: 'registration' },
}),
);
log(' 人工确认通道「询价待确认邮箱」+ 登记触发器已配置(默认认领岗位:PM)');
}