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-TMP-REPO/apps/api/src/app/tools/first-party.ts
T
xcsandClaude Opus 5 716f17ee2a chore(w05): 两枚工具与两张判定表退役,技能戳删除 —— 规则只许有一处真源
四态与推荐等级现在活在 S2 配方的 when 链里(spec §3.5)。
再留一份 DMN 或一枚工具,就是同一件事两处各存一份,改一处就分叉而没人看得出以哪份为准。
技能戳是给模型看的,而这个活动全程没有 agent。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 21:20:56 +08:00

366 lines
19 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.
/**
* 第一方功能工具与纯工具的实现 —— 「实现对系统」(编码规范 §3;语义出处 OS 7.3)。
*
* 运行时受限:只拿得到**注册申报的连接目标**,没有其它环境能力 —— 这是 `ToolRuntime` 保证的。
*/
import { evaluateUnaryTest } from '@sfeel';
import type { ToolHandler } from '@modules/toolbox';
/**
* 查历史价 —— 外部系统(CRM)的**录制回放替身**。
* README:mock 只允许出现在外界;系统内部机制一律真跑。
*
* **一次可查一批**(2026-07-30 加 `pns`):一单几十条料,一条一调就是几十拍模型 ——
* 每拍都要重读一遍上下文,而拿回来的东西一模一样。契约演进只增不破坏:
* `pn` 仍收单条(A4 定价那侧照旧),新增的 `pns` 是可选入参,出参多一个 `rows`。
*/
const HISTORY_SAMPLES: Record<string, { historyDays: number; marketStable: boolean; lifecycle: string; lastPrice: number }> = {
// 录制样本:演示用的历史价情报(真 CRM 接入时换 endpoint,契约不变)
// 历史价情报含**上次成交单价** —— 没有价的「历史价工具」是取不出价格基准的
LM358DR: { historyDays: 30, marketStable: true, lifecycle: '在产', lastPrice: 8.4 },
NE555P: { historyDays: 240, marketStable: false, lifecycle: '在产', lastPrice: 3.25 },
TL072CDR: { historyDays: 15, marketStable: true, lifecycle: 'EOL', lastPrice: 21.6 },
/*
* ── Itelma MD14.1 的九条 —— **老客户测试案例的情报底账** ────────────────
*
* 数字不是编的:逐条照 `JCAI_PRD/docs/活动执行页/A1-询价受理记录.html` 那份
* 验收目标里写明的台账价与判据抄下来,天数按来件日 07-10 折算。
* 抄下来是为了让**三分类真判得出** 5A / 3B / 1C —— 而那一分类决定这一单
* 走哪个出口(有 C 类 → 去 A2 选型)。情报造得随意一点,整条流就走岔了。
*
* 没录进来的两条(TVM6C160K203R 压敏电阻、DAG2400L321504XZB01-R0 定制天线)
* **是故意不录的**:台账里本来就没有它们的历史价,查不到会回 historyDays=9999,
* 命中 B 类「重估后报」—— 那正是验收目标上写的结论。
*/
'PESD2ETH-XR': { historyDays: 59, marketStable: true, lifecycle: '在产', lastPrice: 0.225 },
BSCH00160808R12J00: { historyDays: 25, marketStable: true, lifecycle: '在产', lastPrice: 0.057 },
// 过期 121 天:天数一过 90 就落 B,必须重询锁价
BWVF005050201R0T00: { historyDays: 121, marketStable: true, lifecycle: '在产', lastPrice: 0.088 },
CJTB0101R6N: { historyDays: 43, marketStable: true, lifecycle: '在产', lastPrice: 0.195 },
'SGM2202-ADJYN6G/TR': { historyDays: 38, marketStable: true, lifecycle: '在产', lastPrice: 0.47 },
'UMW AO6604': { historyDays: 29, marketStable: true, lifecycle: '在产', lastPrice: 0.27 },
// 原厂交期 16 周顶不住首批,客户已预授权 Analog —— 生命周期记「要求替代」,P-03 判 C
BBPY00201209601Y00: { historyDays: 47, marketStable: true, lifecycle: '要求替代', lastPrice: 0.061 },
};
/** 无历史 → 走 B(重估后报);**这不是错误**,新料号本就该重估,极大天数本身就是判据 */
const NO_HISTORY = { historyDays: 9999, marketStable: false, lifecycle: '未知', hit: false, lastPrice: 0 };
const lookupHistory = (pn: string) => {
const hit = HISTORY_SAMPLES[pn];
return hit === undefined ? { ...NO_HISTORY } : { ...hit, hit: true };
};
const historyPriceRead: ToolHandler = async ({ input }) => {
const batch = input['pns'];
if (Array.isArray(batch)) {
const rows = batch.map((raw) => {
const pn = String(raw ?? '');
return { pn, ...lookupHistory(pn) };
});
return { ok: true, output: { rows } };
}
const pn = String(input['pn'] ?? '');
return { ok: true, output: { pn, ...lookupHistory(pn) } };
};
/**
* 取价基准 —— **纯工具**:报价相对基准价的偏离率。
* 「能算的不问 LLM」:同输入必同结论。无基准价(新料号)时偏离率记 0,
* 由底价门禁 P-02 按「无基准」放行 —— 不能凭空编一个偏离率出来。
*/
const basis: ToolHandler = async ({ input }) => {
const lastPrice = Number(input['lastPrice'] ?? 0);
const quotedPrice = Number(input['quotedPrice'] ?? 0);
if (!Number.isFinite(lastPrice) || lastPrice <= 0) {
return { ok: true, output: { deviation: 0, basis: 'none', lastPrice: 0 } };
}
const deviation = (quotedPrice - lastPrice) / lastPrice;
return { ok: true, output: { deviation: Math.round(deviation * 10_000) / 10_000, basis: 'history', lastPrice } };
};
/** 三分类规则 P-03 的决策表 —— 与活动包里的资产同一份规则 */
/*
* 三分类规则 P-03 的决策表 —— 与活动包里的资产同一份规则。
* 出参键名用 `type_tag` 与台账列同名:模型拿回结果直接 write,不必自己改名
* (改名这一步交给模型做,它会漏)。`basis` 一并给出,分级依据不必再编。
*/
const P03_RULES = [
{ id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: { type_tag: 'C', play: '转案处理', basis: 'EOL/缺货/要求替代 → C' } },
{ id: 'p03-a', when: ['<= 90', 'true', '-'], then: { type_tag: 'A', play: '直接快答', basis: '历史价≤90天且行情稳 → A' } },
{ id: 'p03-b', when: ['-', '-', '-'], then: { type_tag: 'B', play: '重估后报', basis: '无历史/过期/波动 → B' } },
] as const;
/**
* 逐条打标 —— **纯工具**:无外效应、无连接配置、活动私有作用域。
* 「能算的不问 LLM」:同输入必同结论,零方差零 token;命中规则行随出参留痕。
*
* **一次可打一批**(`rows`):判据是逐条的,结论也是逐条的,但求值不必逐拍 ——
* 一单几十条料分几十拍打标,烧掉的全是重复上下文。
*/
const classifyOne = (values: readonly unknown[]) => {
for (const rule of P03_RULES) {
if (rule.when.every((cell, i) => evaluateUnaryTest(cell, values[i], {}))) {
return { ...rule.then, rule: rule.id };
}
}
return undefined;
};
/** 判据三件的取值:下划线与驼峰两认 —— 台账列是下划线,模型常写驼峰 */
const basisOf = (row: Record<string, unknown>) => [
row['history_days'] ?? row['historyDays'],
row['market_stable'] ?? row['marketStable'],
row['lifecycle'],
];
const classify: ToolHandler = async ({ input }) => {
const batch = input['rows'];
if (Array.isArray(batch)) {
const results: Array<Record<string, unknown>> = [];
for (const raw of batch) {
const row = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
const decided = classifyOne(basisOf(row));
if (decided === undefined) {
// 一条判不出就整批不出结论:半份分类表比没有更难查
return {
ok: false,
error: { code: 'no-match', retryable: false, message: `三分类规则无命中行:${String(row['pn'] ?? '(无 PN)')}` },
};
}
results.push({ pn: String(row['pn'] ?? ''), ...decided });
}
/*
* **条数由判定器自己数**(2026-07-31 实测逼出来的)。
*
* 三类条数是验收判据的入参(route-alt / route-rfq 拿它跟派单数对账)。
* 从前只回逐条结论,条数由模型在脑子里数 —— 实测数错过:判定器给的是 5A/3B/1C,
* 模型报的是 7A/2B/1C,而派单也按它自己那个数开(2 张询价请求)。
* 于是等式 `rfq_request_total = type_b_total` 两边都是它自报的,**2 = 2 顺利过关**,
* 而真实的第三条 B 类料一张单也没开 —— 靠自报两边的对账,防的是疏忽,防不了口径。
*
* 数数是能算的事,算完摆在出参里,模型照抄即可。
*/
const counts = { A: 0, B: 0, C: 0 } as Record<string, number>;
for (const r of results) {
const tag = String(r['type_tag'] ?? '');
if (tag in counts) counts[tag] = (counts[tag] ?? 0) + 1;
}
return { ok: true, output: { results, counts, total: results.length } };
}
const decided = classifyOne(basisOf(input));
if (decided === undefined) {
return { ok: false, error: { code: 'no-match', retryable: false, message: '三分类规则无命中行' } };
}
return { ok: true, output: decided };
};
/** 利润模型 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'] ?? '') },
};
};
/**
* E04 覆盖度重算 —— **纯工具**:决策链节点比对,同输入必同结论。
*
* 节点算「已覆盖」要同时满足三条:该节点上有联系人对应(`decisionNode` 匹配)、
* 那个人标了 `keyPerson=true`、亲密度 `relationship>=3`。少一条都算空白 ——
* 认得这个人不等于覆盖了这个人。权重按 BT 层级(bt-03 高层 > bt-02 技术 > bt-01 采购)。
*
* **一次可查一批**(`customers` 数组):同一批候选客户逐一临场调,每调一次就多截断
* 一拍(工具调用后同拍其余动作作废)——一单 N 家候选就要 N 拍,批量传入只需一拍。
* 契约演进只增不破坏:仍收单条 `{customerCode,decisionNodes,contacts}` 走 legacy 路径,
* 批量给 `customers` 时优先。
*/
const BT_WEIGHT: Record<string, number> = { 'bt-01': 1, 'bt-02': 2, 'bt-03': 3 };
type DecisionNode = { node: string; btLevel: string; required: boolean };
type CoverageContact = { contactCode: string; decisionNode: string; keyPerson: boolean; influence: string; relationship: number };
type CustomerCoverageInput = { customerCode: string; decisionNodes: DecisionNode[]; contacts: CoverageContact[] };
const scoreOne = (customerCode: string, nodes: DecisionNode[], contacts: CoverageContact[]) => {
const required = nodes.filter((n) => n.required === true);
const coveredNodes: string[] = [];
const gapNodes: string[] = [];
let weightTotal = 0;
let weightCovered = 0;
for (const n of required) {
const weight = BT_WEIGHT[n.btLevel] ?? 1;
weightTotal += weight;
const covered = contacts.some((c) => c.decisionNode === n.node && c.keyPerson === true && c.relationship >= 3);
if (covered) {
coveredNodes.push(n.node);
weightCovered += weight;
} else {
gapNodes.push(n.node);
}
}
const coverageScore = weightTotal === 0 ? 0 : Math.round((weightCovered / weightTotal) * 100);
return { customerCode, coverageScore, coveredNodes, gapNodes };
};
const recomputeScore: ToolHandler = async ({ input }) => {
const batch = input['customers'];
if (Array.isArray(batch)) {
const results = (batch as CustomerCoverageInput[]).map((c) =>
scoreOne(c.customerCode, Array.isArray(c.decisionNodes) ? c.decisionNodes : [], Array.isArray(c.contacts) ? c.contacts : []),
);
return { ok: true, output: { results } };
}
const nodes = Array.isArray(input['decisionNodes']) ? (input['decisionNodes'] as DecisionNode[]) : [];
const contacts = Array.isArray(input['contacts']) ? (input['contacts'] as CoverageContact[]) : [];
return { ok: true, output: scoreOne(String(input['customerCode'] ?? ''), nodes, contacts) };
};
/**
* E04 关键人排序 —— **纯工具**:影响力 × 亲密度缺口,批量算,不逐人临场拍。
*
* `relationshipGap = 5 - relationship`(亲密度越低缺口越大);
* `rankScore = 影响力权重 × relationshipGap`;阈值 `>=9→high, >=4→medium, else→low`
* (业务可调常量,不是这一单的临场判断)。
*/
const INFLUENCE_WEIGHT: Record<string, number> = { high: 3, medium: 2, low: 1 };
const rankKeyPersons: ToolHandler = async ({ input }) => {
const contacts = Array.isArray(input['contacts'])
? (input['contacts'] as Array<{ contactCode: string; influence: string; relationship: number }>)
: [];
const ranked = contacts
.map((c) => {
const weight = INFLUENCE_WEIGHT[c.influence] ?? 1;
const relationshipGap = Math.max(0, 5 - Number(c.relationship ?? 0));
const rankScore = weight * relationshipGap;
const priority = rankScore >= 9 ? 'high' : rankScore >= 4 ? 'medium' : 'low';
return { contactCode: c.contactCode, relationshipGap, rankScore, priority };
})
.sort((a, b) => b.rankScore - a.rankScore);
const counts = { high: 0, medium: 0, low: 0 } as Record<string, number>;
for (const r of ranked) counts[r.priority] = (counts[r.priority] ?? 0) + 1;
return { ok: true, output: { ranked, counts } };
};
/* ── 活动槽位的占位产出(编排层跑通期专用) ─────────────────────────────
*
* 六个活动都只有契约面、没有实现。而出口载荷只从**落地事实**推导,
* 所以不写台账就发不出口、网关判不了、流走不出第一个活动。
* 这枚工具就是为此:把该活动契约要求的字段落成一行,让编排层能被整条走通。
*
* **它不做任何业务判断。** 不打标、不定价、不比价 —— 那些是活动自己的内容,
* 真实现时这一步整个换掉,不是在这儿慢慢长成活动。
* 拿得到的值原样穿过去(上游给了就用),拿不到就给一个**明确是占位**的默认,
* 并在行上写 `status: '占位'`,查台账的人一眼看得出这行不是真业务数据。
*
* 人在收口验收时填的值会覆盖同一行同一列(步序在后,后写赢)——
* 占位期要走某个分支,就在验收时说,不必改代码。
*/
const slotPlaceholder: ToolHandler = async ({ input }) => {
const text = (key: string, fallback: string) => {
const v = input[key];
return typeof v === 'string' && v !== '' ? v : fallback;
};
const flag = (key: string) => input[key] === true || input[key] === 'true';
return {
ok: true,
output: {
/** 占位标记 —— 落到行上,也留在出参里供留痕 */
placeholder: true,
status: '占位',
/* 主键类:上游给了就用,没给就用一个显眼的占位号 */
rfqRef: text('rfqRef', 'RFQ-占位'),
customerCode: text('customerCode', 'C-占位'),
pn: text('pn', 'PN-占位'),
supplier: text('supplier', '供应商-占位'),
period: text('quotedAt', '期-占位').slice(0, 7),
/* 去向类:原样穿过,默认 false —— 占位不替活动做判断,要分支就在验收时说 */
needAlternative: flag('needAlternative'),
needSourcing: flag('needSourcing'),
/* 数值类:一律 0,不编造像真的数字 */
floorPrice: 0,
quotePrice: 0,
proposedPrice: 0,
unitPrice: 0,
landingRate: 0,
},
};
};
/* ── ACT-WORK-05 差异评估:逐参数比对与推荐定级 —— 已退役(2026-08-29)──────────
*
* 【两枚工具的墓志铭】`part-substitution-assessment.compare-parameters`(逐参数四态判定:
* MATCH / MISMATCH / UNKNOWN / CONDITIONAL,按硬/软约束与客户需求边界比对原 PN 与候选料)
* 与 `part-substitution-assessment.recommend`(按四态命中条数定推荐等级:推荐 / 条件推荐 /
* 备选 / 不推荐)曾经跑在这里,与活动包 manifest 里冻结的 D01(逐参数四态)/ D02(推荐等级)
* 两张 DMN 是同一份规则的两处真源。
*
* ADR-014(确定性步骤)落地后,这两步不再需要工具调用:四态判定是**整个活动唯一一次
* 模型调用**(`case-model.ts` 的 `S2_assess.auto` 用 `analyze` 动词,系统组装入参、模型只答
* 判断,不转述台账里已有的值),推荐等级是**能算的事**,由同一步 `do` 链里的投影按
* spec §3.5 的 S-FEEL when 链现算(`judgement_class` / `recommendation_level` 两条合成列)。
* `D01` / `D02` 两张 DMN 同批退役,理由相同 —— 再留一份 DMN 或一枚工具,就是同一件事
* 两处各存一份,改一处就分叉而没人看得出以哪份为准。
*
* 完整实现见 git 历史(本文件此段落之前的版本);规则表见活动包 spec §3.5,
* 求值逻辑见 `activities/part-substitution-assessment/src/case-model.ts`。
*/
export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> = [
['crm.history-price-read', historyPriceRead],
['quote-intake.classify', classify],
['quote-pricing.basis', basis],
['quote-pricing.margin', margin],
['quote-pricing.floor-gate', floorGate],
['quote-archive.profile', profile],
['quote-archive.landing-rate', landingRate],
['slot.placeholder', slotPlaceholder],
['crm-relationship-coverage.recompute-score', recomputeScore],
['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
];