/** * MVP 底座的治理台配置 —— 四件事一次做完(README《MVP 完成判据》第 1 条的机器形态): * * ① 绑定 LLM 实例(四元绑定;活动只见逻辑名) * ② 注册功能工具并逐活动给白名单(授权在白名单与契约,不在活动代码) * ③ **经安装通道**装入六个活动槽位 —— 声明顺带把四域十八对象物化出来 * ④ 编排「统一报价」流程包 v1 并发布 + 触发器与邮件通道 * * 活动与工具的解耦就在②③之间:槽位只声明工具**名**,注册、连接值、白名单全在这里。 */ import type { Container } from './container.ts'; import { installRealActivities } from './seed-activities.ts'; import { installSlots } from './seed-mvp-slots.ts'; import { seedMvpFlow } from './seed-mvp-flow.ts'; /** 第一方功能工具:查历史价 —— MVP 用录制回放替身代真 CRM(README:mock 只在外界) */ const HISTORY_TOOL = { name: 'crm.history-price-read', displayName: '历史价查询', version: '1.1.0', description: '读取历史价格情报:某 PN 最近一次报价距今天数、行情是否平稳、生命周期状态、上次成交单价。' + '一次可查一批 —— 传 pns 数组,出参走 rows(一单几十条料不必一条一调)。', input: { type: 'object', properties: { pn: { type: 'string', description: '单条查询' }, pns: { type: 'array', items: { type: 'string' }, description: '批量查询(优先于 pn)' }, }, }, output: { type: 'object', properties: { pn: { type: 'string' }, historyDays: { type: 'integer' }, marketStable: { type: 'boolean' }, lifecycle: { type: 'string' }, hit: { type: 'boolean' }, lastPrice: { type: 'number' }, rows: { type: 'array', description: '批量查询的逐条结果' }, }, }, effect: 'read' as const, errors: [{ code: 'not-found', retryable: false, message: '无历史价' }], idempotent: true, timeoutMs: 10_000, retries: 2, connectionKeys: ['endpoint'], }; /** * 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地;真发经 mail 模块(ADR-013)。 * * 2.0.0(2026-08-25):`to` 改数组,加 cc / bcc / html / attachments(uploadRef)/ inReplyTo / accountId / dryRun。 * 契约演进只增不删:v1 的 `body`(纯文本正文)与出参 `queued` 保留。 * `connectionKeys: ['account']` —— 从哪个信箱发:'default' = 默认账号,或某个账号 id。 * `dryRun`:注册的试运行会**真调处理器**,样例带 dryRun 只校验不发。 */ export const MAIL_TOOL = { ...HISTORY_TOOL, name: 'mail.send', displayName: '发送邮件', version: '2.0.0', description: '从系统配置的邮箱发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界。' + 'to 是收件人数组;正文给 body(纯文本)或 html,至少一个;附件给上传件的 uploadRef;回信带 inReplyTo(来信的 Message-ID)。', input: { type: 'object', required: ['to', 'subject'], properties: { to: { type: 'array', items: { type: 'string' }, description: '收件人邮箱' }, cc: { type: 'array', items: { type: 'string' }, description: '抄送' }, bcc: { type: 'array', items: { type: 'string' }, description: '密送' }, subject: { type: 'string' }, body: { type: 'string', description: '纯文本正文(与 html 至少一个)' }, html: { type: 'string', description: 'HTML 正文' }, attachments: { type: 'array', items: { type: 'object', required: ['uploadRef'], properties: { uploadRef: { type: 'string' }, name: { type: 'string' } } }, description: '附件:上传件的 ref', }, inReplyTo: { type: 'string', description: '回信时带上来信的 Message-ID,收件人那边才能串成一个线程' }, accountId: { type: 'string', description: '指定发件账号;缺省用连接配置里的账号 / 默认账号' }, dryRun: { type: 'boolean', description: '只校验不发(注册试运行用)' }, }, }, output: { type: 'object', properties: { queued: { type: 'boolean', description: 'v1 遗留:真发时恒 true' }, outboundId: { type: 'string' }, messageId: { type: 'string' }, state: { type: 'string' }, dryRun: { type: 'boolean' }, }, }, effect: 'write' as const, errors: [ { code: 'send-failed', retryable: true, message: '发信失败(厂商原话见 message)' }, { code: 'no-account', retryable: false, message: '没有可用的邮箱账号' }, { code: 'invalid', retryable: false, message: '入参不合法' }, ], idempotent: true, connectionKeys: ['account'], }; /** * 逐条打标 —— **纯工具**(活动私有作用域,`ownerActivityId` 定死是谁的)。 * * 它是三分类规则 P-03 的**求值器**:同输入必同结论、零方差零 token、命中规则行随出参留痕。 * 「能算的不问 LLM」的具体形态就是这样一枚工具 —— 不是让模型照着规则表判, * 而是把规则表做成工具,模型只负责把判据取齐。 * * 出参键名与台账列同名(`type_tag` / `basis`):拿回来直接 write,不必让模型改名 —— * 那一步交给模型做,它会漏。 */ const CLASSIFY_TOOL = { name: 'quote-intake.classify', displayName: '料号三分类打标', version: '1.0.0', description: '按三分类规则 P-03 逐条打标(A 历史价≤90天且行情稳 / B 无历史·过期·波动 / C EOL·缺货·要替代)。' + '一次可打一批 —— 传 rows(每行含 pn 与三项判据),出参走 results。', input: { type: 'object', properties: { rows: { type: 'array', description: '批量:每行 { pn, history_days, market_stable, lifecycle }', items: { type: 'object' }, }, history_days: { type: 'integer', description: '单条:距上次报价天数' }, market_stable: { type: 'boolean', description: '单条:行情是否稳定' }, lifecycle: { type: 'string', description: '单条:生命周期状态' }, }, }, output: { type: 'object', properties: { results: { type: 'array', description: '批量结果:每行 { pn, type_tag, play, basis, rule }' }, type_tag: { type: 'string' }, play: { type: 'string' }, basis: { type: 'string' }, rule: { type: 'string', description: '命中的规则行 —— 留痕靠它' }, }, }, effect: 'read' as const, errors: [{ code: 'no-match', retryable: false, message: '三分类规则无命中行' }], idempotent: true, timeoutMs: 5_000, retries: 0, connectionKeys: [] as string[], // 纯工具:活动私有作用域 —— 别的活动调不到,白名单也给不了 pure: true, ownerActivityId: 'quote-intake', }; /** * 活动槽位的占位产出 —— **编排层跑通期专用的纯工具**。 * * 六个活动只有契约面。出口载荷只从落地事实推导,不写台账就发不出口、 * 网关判不了、流走不出第一个活动。这枚工具把契约要求的字段落成一行,只为让编排层能被走通。 * 它**不含任何业务判断**(实现见 `tools/first-party.ts` 的 `slotPlaceholder`); * 真开发某个活动时,那个活动的步序整个换掉,这枚工具从它的白名单里摘掉。 */ const PLACEHOLDER_TOOL = { name: 'slot.placeholder', displayName: '槽位占位产出', version: '0.1.0', description: '活动槽位占位产出:把契约要求的字段落成一行(不做业务判断;真实现后摘除)', input: { type: 'object', properties: { rfqRef: { type: 'string' }, customerCode: { type: 'string' }, pn: { type: 'string' }, supplier: { type: 'string' }, quotedAt: { type: 'string' }, needAlternative: { type: 'boolean' }, needSourcing: { type: 'boolean' }, }, }, output: { type: 'object', properties: { placeholder: { type: 'boolean' }, status: { type: 'string' }, rfqRef: { type: 'string' }, customerCode: { type: 'string' }, pn: { type: 'string' }, supplier: { type: 'string' }, period: { type: 'string' }, needAlternative: { type: 'boolean' }, needSourcing: { type: 'boolean' }, floorPrice: { type: 'number' }, quotePrice: { type: 'number' }, proposedPrice: { type: 'number' }, unitPrice: { type: 'number' }, landingRate: { type: 'number' }, }, }, // 纯工具:无外效应、无连接配置 —— 同输入必同出参 effect: 'read' as const, errors: [], idempotent: true, timeoutMs: 5_000, retries: 0, connectionKeys: [] as string[], }; /** * E04 覆盖度重算 —— **纯工具**(活动私有作用域):决策链节点比对,同输入必同结论。 * 出参键名与台账列同名(`coverage_score`),模型拿回结果直接 write,不必改名。 */ const COVERAGE_SCORE_TOOL = { name: 'crm-relationship-coverage.recompute-score', displayName: '关系覆盖度重算', version: '1.1.0', description: '按决策链节点比对重算关系覆盖度:节点算已覆盖要同时满足有联系人对应/标了关键人/亲密度≥3。' + '出参给覆盖率与缺口节点列表,权重按 BT 层级(bt-03 高层 > bt-02 技术 > bt-01 采购)。' + '一次可查一批 —— 传 customers 数组,出参走 results(逐家客户各调一次会多截断一拍)。', input: { type: 'object', properties: { customers: { type: 'array', description: '批量(优先):[{customerCode, decisionNodes:[{node,btLevel,required}], contacts:[{contactCode,decisionNode,keyPerson,influence,relationship}]}]', }, customerCode: { type: 'string', description: '单条查询' }, decisionNodes: { type: 'array', description: '单条:[{node, btLevel, required}]' }, contacts: { type: 'array', description: '单条:[{contactCode, decisionNode, keyPerson, influence, relationship}]' }, }, }, output: { type: 'object', properties: { customerCode: { type: 'string' }, coverageScore: { type: 'integer' }, coveredNodes: { type: 'array' }, gapNodes: { type: 'array' }, results: { type: 'array', description: '批量查询的逐家结果:每项 {customerCode, coverageScore, coveredNodes, gapNodes}' }, }, }, effect: 'read' as const, errors: [] as Array<{ code: string; retryable: boolean; message: string }>, idempotent: true, timeoutMs: 5_000, retries: 0, connectionKeys: [] as string[], pure: true, ownerActivityId: 'crm-relationship-coverage', }; /** * E04 关键人排序 —— **纯工具**(活动私有作用域):影响力 × 亲密度缺口,批量算。 * 出参键名与台账列同名(`priority`/`relationship_gap`/`rank_score`),模型照抄即可。 */ const RANK_KEY_PERSONS_TOOL = { name: 'crm-relationship-coverage.rank-key-persons', displayName: '关键人经营优先级排序', version: '1.0.0', description: '按影响力 × 亲密度缺口批量排序关键人 —— 传 contacts 数组,一次算完,不逐人临场拍。' + '出参给每人的亲密度缺口、排序分与优先级档(high/medium/low)。', input: { type: 'object', properties: { contacts: { type: 'array', description: '[{contactCode, influence, relationship}]' }, }, }, output: { type: 'object', properties: { ranked: { type: 'array', description: '[{contactCode, relationshipGap, rankScore, priority}],已按 rankScore 降序' }, counts: { type: 'object' }, }, }, effect: 'read' as const, errors: [] as Array<{ code: string; retryable: boolean; message: string }>, idempotent: true, timeoutMs: 5_000, retries: 0, connectionKeys: [] as string[], pure: true, ownerActivityId: 'crm-relationship-coverage', }; /** 白名单:逐槽位授权 —— 谁能调什么,治理台说了算,活动代码里看不到这张表 */ const ALLOWLIST: Readonly> = { // A1 已是真活动:白名单里摘掉占位工具 —— 它一在,模型就会拿它糊弄过去 'quote-intake': [HISTORY_TOOL.name], 'alt-selection': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name], 'sourcing-inquiry': [HISTORY_TOOL.name, MAIL_TOOL.name, PLACEHOLDER_TOOL.name], 'cost-pricing': [HISTORY_TOOL.name, PLACEHOLDER_TOOL.name], 'quote-negotiation': [HISTORY_TOOL.name, MAIL_TOOL.name, PLACEHOLDER_TOOL.name], 'intel-archive': [PLACEHOLDER_TOOL.name], }; export async function seedMvp(container: Container): Promise { const log = (line: string) => process.stdout.write(line + '\n'); /* * ① LLM 实例:四元绑定(Provider / URL / Key / Model),活动只见自定义模型名。 * 密钥**只从环境读**,不入源码、不入包、不入留痕(OS 7.3 凭证隔离 / ADR-009)。 */ /* * 密钥**只从环境读**;读不到就**不绑**(2026-07-30):从前塞一个 `sk-placeholder` * 冒充已绑定,治理台上写着「密钥已绑定」,一自检就是 `api key ... is invalid` —— * 界面撒谎比缺功能更坏。没钥匙就如实显示未绑定,人去抽屉里补。 */ const apiKey = process.env['JCAI_DEEPSEEK_API_KEY'] ?? process.env['JCAI_LLM_API_KEY']; for (const [name, model] of [ ['deepseek-v4-flash', 'deepseek-v4-flash'], ['deepseek-v4-pro', 'deepseek-v4-pro'], ] as const) { await container.llm.upsert({ name, provider: 'deepseek', baseUrl: process.env['JCAI_LLM_BASE_URL'] ?? '', model, ...(apiKey === undefined ? {} : { apiKey }), // 系统用途不预设:两条都能用,系统叙述回落到第一条可用的(pickSystemUse) systemUse: false, enabled: true, }); } log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`); /* ② 功能工具注册两审 + 逐槽位白名单 */ for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL]) { const verdict = await container.toolbox.register({ contract, samples: [ { // 样例集一份喂四件工具:并集覆盖各家必填 input: { pn: 'LM358DR', to: ['x@y.com'], subject: '询价', body: '样例正文', dryRun: true, history_days: 30, market_stable: true, lifecycle: '在产', }, expectShape: { type: 'object' }, }, ], // 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点;mail.send 的连接目标是「从哪个信箱发」 ...(contract.connectionKeys.length === 0 ? {} : { connectionValues: Object.fromEntries(contract.connectionKeys.map((k) => [k, k === 'account' ? 'default' : 'replay://crm'])) }), }); if (!verdict.accepted) { log(' 工具 ' + contract.name + ' 未过两审:' + verdict.staticFindings.map((f) => f.message).join('; ')); } } for (const [activityId, tools] of Object.entries(ALLOWLIST)) { await container.toolbox.setAllowlist(activityId, tools); } log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 4,逐活动授权)'); /* * ③ 活动装入 —— 两批: * · 真活动包经**安装通道读文件**装(系统外开发、安装即用); * · 尚未开发的槽位照旧从常量表装(契约就绪、实现留白),让编排层能整条走通。 * 两批都走同一道安装两审,声明物化顺带把台账表建出来。 */ await installRealActivities(container); await installSlots(container); log('③ 活动已装入(真活动包 1 · 槽位 6:契约就绪、实现留白)'); /* ④ 流程包 1:1 + 触发器 + 通道 */ await seedMvpFlow(container); }