Files
JX-BPOS/apps/api/src/app/seed-quote.ts
T
ChaiHaoandClaude Opus 5 94f31f267b 调优:并发推进的真根因 + loop 的三处行为修正
E2E 跑 loop 时 A3 一口气发了八张一模一样的「请处理」卡。逐层查下去,四处:

① **并发推进的真根因**:`pg_try_advisory_lock` 是**会话级**的,而我们经连接池取连接 ——
   加锁与解锁多半落在不同连接上,解锁解不掉自己那把;同一连接被复用时又因可重入而直接放行。
   两头都不对,于是同一任务被并发推进(现象:同一步跑两遍、同一张卡发两张、
   同一行草稿写出两条互相打架的版本)。前一轮「共用同一把锁」的修法方向对、器械错。
   改用**进程内按目标互斥**(MVP 单进程,确定且够用),推进器与派发器共用一把;
   多进程部署时再叠事务级 `pg_try_advisory_xact_lock`(随事务释放,不依赖连接归属)。
   实测:同一单从 8 张卡降到 1 张,2 回合后落到等待。

② **非法行动会把回合打死**:AI 提了不合法的生命周期迁移(Available → Disabled),
   `applyAction` 抛出、运行时没落笔、推进器一遍遍重来。引擎拒绝它是对的
   ——「语义忠实:引擎不得私改迁移拓扑」——但不该因此崩掉。
   改为:拒绝 + 留痕 + 把理由喂回下一回合的上下文。AI 提出非法行动是预期内的事。

③ **副作用抢在校验之前**:卡在迁移合法性判定**之前**就发出去了,于是每个被拒的回合
   都白发一张卡 —— 卡收不回,负责人工作台上堆起一摞没人知道怎么答的「请处理」。
   human / tool 两个分支改为**先验迁移再动手**。

④ **裸调用**:模型只回一个「请处理」。按「调用必附上下文,裸调用不得发出」(AM 06)
   把卡面结构写进出参 schema(title / ask / basis / fields 必填),提示里讲明要求,
   归一时用当下局面兜底;卡片装配也修了 —— 结构化 basis 优先,嵌套对象转 JSON,
   不再出现一行 `[object Object]`。
   实测卡面:「请确认 NE555P 历史报价并判定采用策略」+ 三个回填字段。

gates: tsc / eslint / depcruise(336 模块)/ api-report / 429 测试 全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-27 11:31:41 +08:00

437 lines
20 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' } },
// 归档写的是 (pn, quoted_at) 这一行 —— 两个主键都得传到,它自己造不出来
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
{ target: 'quotedAt', source: { from: 'initiation', path: 'quotedAt' } },
],
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' } },
/*
* 单据日期:取入站时刻。
* MVP 简化 —— 真实系统里「报价日期」应取报价单**实际发出**的时刻(出站回执),
* 而不是询价到达的时刻;两者在一单之内通常只差几天,不影响历史价情报的可用性。
* 之所以要有它:`shared.history_price` 的主键是 (pn, quoted_at),
* 归档若拿不到日期就写不进去 —— 主键只增不破坏,不能为了省事去改它。
*/
{ target: 'quotedAt', source: { from: 'expression', sfeel: 'occurredAt' } },
],
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)');
}