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-mvp.ts
T
xcsandClaude Opus 5 0aa2fc11b0 资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑
**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。

**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
  收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算

**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」

**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。

验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
2026-08-24 10:26:45 +08:00

334 lines
13 KiB
TypeScript

/**
* 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'],
};
/** 出站邮件 —— **写类**:过程中登记为动作意向,放行后由系统执行落地 */
const MAIL_TOOL = {
...HISTORY_TOOL,
name: 'mail.send',
displayName: '发送邮件',
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'],
};
/**
* 逐条打标 —— **纯工具**(活动私有作用域,`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<Record<string, readonly string[]>> = {
// 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<void> {
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: '询价',
history_days: 30,
market_stable: true,
lifecycle: '在产',
},
expectShape: { type: 'object' },
},
],
// 占位工具是纯工具,没有连接目标 —— 与真工具同门过两审,但不给它端点
...(contract.connectionKeys.length === 0 ? {} : { connectionValues: { endpoint: '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);
}