/** * 统一报价的验收种子 —— 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 { function log(line: string): void { process.stdout.write(line + '\n'); } /* ① LLM 实例:四元绑定(活动只见自定义模型名) */ 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', systemUse: true, enabled: true, }); log('① LLM 实例已绑定:quote-extractor'); /* ② 工具注册两审 + 白名单 */ const tools = [ HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, 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' }, 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' }, reject: { kind: 'reopen', nodeId: 'A4', mapping: [] }, 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' } }, ], 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: 'records' } }, { 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: 'records' } }], 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' } }, { target: 'records', source: { from: 'initiation', path: 'records' } }, ], 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' } }, { target: 'records', source: { from: 'initiation', path: 'records' } }, ], 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' } }, ], 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)'); }