M5 收官:六个活动包 + 统一报价全图 + 任务推进器

活动包(按 MVP示例工作流 归一化):
- quote-intake  A1 受理归一(flow):LLM 抽取 → 条件跳步补全 → 查历史价 → DMN 打标 → 验收
- quote-pricing A4 成本定价(flow):DMN 利润模型 P-01 + 底价门禁 P-02;超限条目调人决策
- quote-negotiate A6 报价议价(flow):写类动作登记意向、放行即落地;建 D+3 跟进监听;
  议价结果建模为选择组三口(成交/有异议/拒绝)—— 业务分支走输出口,不用异常终态编码
- quote-archive A7 情报归档(flow):写共享域(历史价格情报/客户资料库/落地率统计)
- quote-sourcing A3 渠道寻源(loop):CMMN 四阶段 + 里程碑 + 裁量项 + 两个时间监听;
  DMN 采用策略判定;案例说明书与 XML 同版本
- quote-alternative A2 替代选型(loop):CMMN 三阶段 + userEventListener(客户改需求);
  DMN 替代等级判定;禁区「差异没核清的不放行」有机检形态

流程包全图:7 节点 + 3 网关 + 1 审批点
- 已裁定①:A5 = 审批点,G3 并入驳回走向(驳回 → 回 A4 重开新实例)
- 已裁定②:G2 = 并行网关,A3/A2 失败走向 = 容忍
- 已裁定④:议价回环 = 流程层回边(reopen)

闭合校验在此拦下一个真实建模错误(A4 有两条并发入边映射同名字段),
按规范改拓扑:TYPE-A 直通也走汇合;同时把「回边不是并发汇合」写进判定 ——
reopen 边不参与隐式合并检查。

app:task-advancer worker —— 过程在后端,运行中的任务一拍一拍推,不依赖浏览器在线;
进入等待或终结即停手,不空转。第一方工具补齐 P-01/P-02/画像/落地率/出站发送。

已验证(真机):
- 六个活动包经安装通道装入,静态审各 0 条提示
- 流程包闭合校验通过并发布
- 台账自生长:11 张表由活动声明物化(含 shared 三张共享域表)
- 邮件入站 → 触发器 → 工作流 → A1 落到冯兵工位 → 推进器推 2 拍:
  装载 5 步声明 → 执行 LLM 步 → 按声明重试 2 次 → 转人兜底 → awaiting-event
  (LLM 端点是占位 key,失败路径因此走通了 —— 正是声明给定的兜底)

358 个测试通过,门禁五道全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 02:37:04 +08:00
co-authored by Claude Opus 5
parent 78dc03bb58
commit 2f01eda3fb
24 changed files with 1862 additions and 25 deletions
+5
View File
@@ -8,6 +8,7 @@ import { createContainer } from './container.ts';
import { createServer } from './server.ts';
import { createConsumers } from './workers/consumers.ts';
import { startDispatcher } from './workers/dispatcher.ts';
import { startTaskAdvancer } from './workers/task-advancer.ts';
export async function boot(): Promise<void> {
const config = loadConfig();
@@ -17,6 +18,9 @@ export async function boot(): Promise<void> {
// 派发器:投递记录即队列,按目标 ID 取 advisory lock 串行消费(ADR-008)
const dispatcher = startDispatcher({ container, consumers: createConsumers() }, config.dispatchIntervalMs);
// 过程在后端:运行中的任务由推进器一拍一拍推,不依赖浏览器在线
const advancer = startTaskAdvancer({ container }, config.dispatchIntervalMs);
const app = createServer(container);
serve({ fetch: app.fetch, port: config.port }, (info) => {
process.stdout.write(`JCAI api · http://localhost:${info.port} · env=${config.nodeEnv}\n`);
@@ -24,6 +28,7 @@ export async function boot(): Promise<void> {
const shutdown = async (): Promise<void> => {
dispatcher.stop();
advancer.stop();
await container.close();
process.exit(0);
};
+192 -21
View File
@@ -50,6 +50,37 @@ const HISTORY_TOOL = {
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,
@@ -84,28 +115,77 @@ export async function seedQuote(container: Container): Promise<void> {
log('① LLM 实例已绑定:quote-extractor');
/* ② 工具注册两审 + 白名单 */
for (const contract of [HISTORY_TOOL, CLASSIFY_TOOL]) {
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 }, expectShape: { type: 'object' } }],
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]);
log('② 功能工具已注册并入白名单');
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 manifestRaw = JSON.parse(await readFile(join(root, 'quote-intake/dist/manifest.json'), 'utf8'));
const manifest = activityManifestSchema.parse(manifestRaw);
const artifact = await readFile(join(root, 'quote-intake/dist/index.js'), 'utf8');
const verdict = await container.registry.install(manifest, artifact);
log('③ 活动包已安装:' + manifest.identity.name + '(静态审 ' + String(verdict.staticFindings.length) + ' 条提示)');
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 },
});
/* ④ 编排「统一报价」流程包 v1 并发布 */
const pkg = processPackageSchema.parse({
id: 'unified-quote',
name: '统一报价',
@@ -117,21 +197,33 @@ export async function seedQuote(container: Container): Promise<void> {
required: ['customerCode', 'rfqRef'],
properties: { customerCode: { type: 'string' }, rfqRef: { type: 'string' }, subject: { type: 'string' } },
},
output: { type: 'object', properties: { records: { type: 'array' } } },
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: 'A1',
kind: 'activity',
label: 'A1 RFQ需求分析',
activityId: 'quote-intake',
owner: { position: 'BD', strategy: 'sole' },
failure: { kind: 'terminate' },
message: '真实 RFQ 五花八门,先归一成台账化的统一受理记录,再逐条打标定车道。',
layout: { row: 0, col: 1 },
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 },
},
{ id: 'end', kind: 'end', label: '归档闭环', layout: { row: 0, col: 2 } },
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: [
{
@@ -144,7 +236,86 @@ export async function seedQuote(container: Container): Promise<void> {
],
reopen: false,
},
{ id: 'e-a1-end', from: 'A1', to: 'end', mapping: [], 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: [],
});
@@ -155,7 +326,7 @@ export async function seedQuote(container: Container): Promise<void> {
log(' 闭合校验未过,不得发布:' + report.violations.map((v) => v.message).join('; '));
} else {
await container.process.publish(pkg.id, pkg.version);
log('④ 流程包「统一报价」v1 已发布(边映射闭合校验通过)');
log('④ 流程包「统一报价」v1 已发布 —— 7 节点 + 3 网关 + 1 审批点,边映射闭合校验通过');
}
/* 事件触发器「客户RFQ询价」 */
+65
View File
@@ -46,7 +46,72 @@ const classify: ToolHandler = async ({ input }) => {
return { ok: false, error: { code: 'no-match', retryable: false, message: '三分类规则无命中行' } };
};
/** 利润模型 P-01:品牌 × 类型 × 客户等级 × 数量档 → 目标毛利区间 */
const P01_RULES = [
{ id: 'p01-1', when: ['"战略客户"', '"A"', '"大批量"'], then: { marginLow: 0.08, marginHigh: 0.1 } },
{ id: 'p01-2', when: ['"普通客户"', '"A"', '-'], then: { marginLow: 0.12, marginHigh: 0.15 } },
{ id: 'p01-3', when: ['-', '"B"', '-'], then: { marginLow: 0.15, marginHigh: 0.2 } },
{ id: 'p01-4', when: ['-', '"C"', '-'], then: { marginLow: 0.2, marginHigh: 0.5 } },
{ id: 'p01-default', when: ['-', '-', '-'], then: { marginLow: 0.12, marginHigh: 0.15 } },
] as const;
const margin: ToolHandler = async ({ input }) => {
const values = [input['customerGrade'], input['materialType'], input['volumeTier']];
for (const rule of P01_RULES) {
if (rule.when.every((cell, i) => evaluateUnaryTest(cell, values[i], {}))) {
const base = Number(input['basePrice'] ?? 0);
return {
ok: true,
output: { ...rule.then, rule: rule.id, quotedPrice: Number((base * (1 + rule.then.marginLow)).toFixed(4)) },
};
}
}
return { ok: false, error: { code: 'no-match', retryable: false, message: '利润模型无命中行' } };
};
/** 底价门禁 P-02:偏离底价 ±20% 即止 —— 结论是约束性输入,不可静默覆写 */
const floorGate: ToolHandler = async ({ input }) => {
const deviation = Number(input['deviation'] ?? 0);
if (deviation >= 0) return { ok: true, output: { disposition: '直接放行', passable: true, rule: 'p02-1' } };
if (deviation >= -0.2) return { ok: true, output: { disposition: 'PM 附理由放行', passable: true, rule: 'p02-2' } };
return { ok: true, output: { disposition: '拦截 · BOSS 拍板', passable: false, rule: 'p02-3' } };
};
/** 客户画像更新:把行为写成标签,供 P-01 的客户等级因子取用 */
const profile: ToolHandler = async ({ input }) => {
const reason = String(input['reason'] ?? '');
const tags: string[] = [];
if (/价|贵|低/.test(reason)) tags.push('价格敏感型');
if (/快|急/.test(reason)) tags.push('快速决策型');
if (tags.length === 0) tags.push('待观察');
return { ok: true, output: { tags: tags.join('/') } };
};
/** 落地率统计:放行通过的报价 ÷ 发出的报价 */
const landingRate: ToolHandler = async ({ input }) => {
const won = Number(input['won'] ?? 0);
const total = Number(input['total'] ?? 0);
return {
ok: true,
output: { landingRate: total === 0 ? 0 : Number((won / total).toFixed(4)), period: String(input['period'] ?? '') },
};
};
/** 出站发送 —— 写类:过程中登记为动作意向,放行后由系统执行落地(OS 7.3) */
const mailSend: ToolHandler = async ({ input, connection }) => {
// 运行时只拿得到申报过的连接目标;真发信由 channel 的出站面承担
return {
ok: true,
output: { queued: true, to: String(input['to'] ?? ''), via: connection['endpoint'] ?? 'simulated' },
};
};
export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> = [
['crm.history-price-read', historyPriceRead],
['quote-intake.classify', classify],
['quote-pricing.margin', margin],
['quote-pricing.floor-gate', floorGate],
['quote-archive.profile', profile],
['quote-archive.landing-rate', landingRate],
['mail.send', mailSend],
];
+63
View File
@@ -0,0 +1,63 @@
/**
* 任务推进器 —— 「过程在后端」的落点(编码规范 §9;语义出处 OS 09)。
*
* 「环不依赖浏览器在线 —— 负责人关掉页面,活动照常推进」:
* 运行中的任务由本 worker 一拍一拍推,直到进入等待(等人 / 等事件)或收敛终态。
* 预算与停滞熔断在 `task.tick()` 里**环外先判**,这里不重复把关。
*/
import { and, eq, sql } from 'drizzle-orm';
import { taskTable } from '@modules/task';
import type { Container } from '../container.ts';
export type AdvancerOptions = {
readonly container: Container;
/** 一轮最多推几个任务 */
readonly batchSize?: number;
/** 单个任务一轮最多推几拍 —— 防止一个任务占满 worker */
readonly maxTicks?: number;
};
export async function advanceOnce(options: AdvancerOptions): Promise<number> {
const { container } = options;
const rows = await container.db
.select({ id: taskTable.id })
.from(taskTable)
.where(and(eq(taskTable.runtime, 'running'), sql`${taskTable.terminal} is null`))
.limit(options.batchSize ?? 5);
let advanced = 0;
for (const row of rows) {
for (let tick = 0; tick < (options.maxTicks ?? 3); tick += 1) {
try {
const outcome = await container.task.tick(row.id as never);
advanced += 1;
// 进入等待或终结就停手 —— 挂起在等的任务由事件唤醒,不空转
if (outcome.task.runtime !== 'running' || outcome.task.terminal !== undefined) break;
if (!outcome.progressed) break;
} catch (error) {
process.stderr.write(
`[task-advancer] ${row.id}:${error instanceof Error ? error.message : String(error)}\n`,
);
break;
}
}
}
return advanced;
}
export function startTaskAdvancer(options: AdvancerOptions, intervalMs: number): { stop(): void } {
let stopped = false;
const timer = setInterval(() => {
if (stopped) return;
void advanceOnce(options).catch((error: unknown) => {
process.stderr.write(`[task-advancer] ${error instanceof Error ? error.message : String(error)}\n`);
});
}, intervalMs);
timer.unref();
return {
stop() {
stopped = true;
clearInterval(timer);
},
};
}