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 df18882322 脚本 e2e 逼出的八条:闸看错了表 · 拒回喂指针 · 零行骗过闸与哨兵 · 早收不该算撞墙
凭证到位后 `w05` 五案例连跑六轮,逼出八条**浏览器走查逮不到**的缺陷 ——
脚本每张卡都先空手交一次、每个岔路口都按序代答,人手点的时候永远不会去试
「空着交上去会怎样」。末轮:跑到的四例全部 released,三例被拒 0
(第五例停在大模型账户余额不足,不是代码问题)。

平台侧(六条):
- L 数据工单的提交闸没牙 —— 卡没绑表就回落到必交付物,而数据工单要人补的是
  `parts.*` 主数据,不在交付物名单里:闸盯着一堆无关的表、一格不缺,空手交卷一路放行。
  契约新增 `ExecutionWorkOrder.gateLedgers`(这张卡该拦哪几张表,发卡的人说了算),
  `gateAddressesOf` 变三级取一:绑的表 > 卡自报 > 交付物。
- M `finish` 撞 doneWhen 为假的拒回只谈「行与列」,而缺的是一次**工具调用**;
  现在摆出判据原文 + 那几个名字此刻的读数,`tool_*_calls` 读到 0 的单独点名(`unmetWhy`)。
- N 人点了决策卡,模型每拍再发一张,连拒五拍熔断。把「他选的是 approve」写进拒回**没用** ——
  下一拍它又从头翻一次会滚走的窗口。改结构:`answeredCardFact` 把
  「这张卡已答过 → 他选的是什么」做成每拍都在的事实行。
- O 模型传 `requirement: null`,工具 `null.trim()` 当场崩(边界上一律经 `text()` 归一)。
- P 拒回里的 `count=(没写过)` 是 S-FEEL 函数名被当成台账名 —— 假线索模型会去追。
- Q/S 零行 = 零缺,闸与哨兵一起放过空手交卷的数据工单(`emptyGate`:卡自报的表一行都没有
  = 没完成);「早收」头一次只落观察、第二次起才算被拒(与「重发已答过的卡」同一把尺)。

活动侧(1.2.7 → 1.2.9):三张执行工单各自声明 `gateLedgers`;说明书③补「发卡前骨架必须先有行」、
⑤补这张卡的前提(一颗 absent 都不剩);哨兵 `parts_profile_rows >= created` 逐颗对账;
S0_bind 写明发录入卡那一拍别抢写需求行。

e2e 脚本加 `JCAI_E2E_BEARER` 直供旁路(服务账号 client_credentials 仍是正路)。
门禁 typecheck / lint / depcruise / test(2091)全绿;api:check 的差异仍只在 org/* 与 api/*
(工作树里先前那批鉴权改动),card.d.ts 是本轮的纯新增字段。

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

508 lines
28 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 差异评估:逐参数比对 —— 纯工具(活动私有作用域)─────────────────
*
* 「能算的不问 LLM」:同输入必同结论。规则与活动包 manifest 里的 D01/D02 两张 DMN 同一份;
* 这里是求值器,那里是随包冻结的文档。`RULE_VERSION` 随结论落账(rule_version)。
*
* 比对语义(按硬/软约束):
* equal / covers(候选范围 ⊇ 原范围) → match,低风险;
* configurable(原是定值、候选是含它的可调范围)→ conditional(要改外围);
* narrower / differs / partial → 硬:mismatch 高风险;软:conditional 中风险;
* 任一侧未披露 → unknown 中风险;
* 硬约束 equal/covers 但没给 evidenceRef → conditional(硬约束未经证据支持不得判 MATCH);
* requirement(客户约束,如 ">= 40")候选不满足 → 一律 mismatch 高风险。
*/
export const SUBSTITUTION_RULE_VERSION = 'R-2026.07';
type Parsed = { kind: 'number'; value: number } | { kind: 'range'; lo: number; hi: number } | { kind: 'text'; value: string } | { kind: 'unknown' };
type CompareResult = 'equal' | 'covers' | 'configurable' | 'narrower' | 'partial' | 'differs' | 'unknown';
type DifferenceStatus = 'match' | 'mismatch' | 'unknown' | 'conditional';
type RiskLevel = 'low' | 'medium' | 'high';
type ParamPair = {
parameterName: string;
originalValue: string;
candidateValue: string;
unit?: string;
constraintType: 'hard' | 'soft';
/**
* 这两格**模型爱给 null**(「这一项没有客户约束」它写 `requirement: null` 而不是省掉)——
* 工具入参是宽松描述式 schema,null 一路落到这里,`null.trim()` 当场崩,
* 整个工具调用记成「行动被拒」(2026-08-27 W05 e2e 实测,一单撞两次)。
* 类型如实写成可空,取值一律经 `text()`:**边界上的东西按它真会来的样子接**。
*/
requirement?: string | null;
evidenceRef?: string | null;
};
/** 边界上的字符串:null / undefined / 非字符串一律归成空串(模型给 null 是常态,见 ParamPair 上的注) */
function text(v: unknown): string {
return typeof v === 'string' ? v.trim() : '';
}
type CompareCounts = { match: number; mismatch: number; unknown: number; conditional: number; hardMismatch: number; softMismatch: number; configurable: number };
const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i;
const NUM = '[-+]?\\d+(?:\\.\\d+)?';
const RANGE_RE = new RegExp(`^(${NUM})\\s*(?:~|~|-|–|—|至|to)\\s*(${NUM})`, 'i');
const NUM_RE = new RegExp(`^(${NUM})`);
export function parseParamValue(raw: unknown): Parsed {
const s = String(raw ?? '').trim().replace(/\s+/g, ' ');
if (s === '' || UNKNOWN_WORDS.test(s)) return { kind: 'unknown' };
const r = RANGE_RE.exec(s);
if (r !== null) {
const lo = Number(r[1]);
const hi = Number(r[2]);
if (Number.isFinite(lo) && Number.isFinite(hi)) return { kind: 'range', lo: Math.min(lo, hi), hi: Math.max(lo, hi) };
}
const n = NUM_RE.exec(s);
if (n !== null && Number.isFinite(Number(n[1]))) return { kind: 'number', value: Number(n[1]) };
return { kind: 'text', value: s.toLowerCase() };
}
function compareParsed(o: Parsed, c: Parsed): CompareResult {
if (o.kind === 'unknown' || c.kind === 'unknown') return 'unknown';
if (o.kind === 'range' && c.kind === 'range') {
if (c.lo === o.lo && c.hi === o.hi) return 'equal';
if (c.lo <= o.lo && c.hi >= o.hi) return 'covers';
if (c.lo >= o.lo && c.hi <= o.hi) return 'narrower';
return c.hi < o.lo || c.lo > o.hi ? 'differs' : 'partial';
}
if (o.kind === 'number' && c.kind === 'number') return Math.abs(o.value - c.value) <= 1e-9 ? 'equal' : 'differs';
if (o.kind === 'number' && c.kind === 'range') return c.lo <= o.value && o.value <= c.hi ? 'configurable' : 'differs';
if (o.kind === 'range' && c.kind === 'number') return o.lo <= c.value && c.value <= o.hi ? 'narrower' : 'differs';
if (o.kind === 'text' && c.kind === 'text') return o.value === c.value ? 'equal' : 'differs';
return 'differs';
}
/** 客户约束:">= 40" / "<= 85" / "-40~85"(候选必须盖住);解析不了当没给 */
function satisfiesRequirement(requirement: string | null | undefined, c: Parsed): boolean | undefined {
const s = text(requirement);
if (s === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
const ge = new RegExp(`^(?:>=|≥)\\s*(${NUM})$`).exec(s);
const le = new RegExp(`^(?:<=|≤)\\s*(${NUM})$`).exec(s);
const hi = c.kind === 'range' ? c.hi : c.value;
const lo = c.kind === 'range' ? c.lo : c.value;
if (ge !== null) return hi >= Number(ge[1]);
if (le !== null) return lo <= Number(le[1]);
const req = parseParamValue(s);
if (req.kind === 'range') return lo <= req.lo && hi >= req.hi;
if (req.kind === 'number') return lo <= req.value && hi >= req.value;
return undefined;
}
/** 生命周期不是数值:候选比原料「更活」算 covers(替 NRND 料本就是为了这个),更差才是 differs */
const LIFECYCLE_ORDER: Record<string, number> = { active: 3, nrnd: 2, risk: 2, eol: 1, discontinued: 0 };
function compareLifecycle(o: string, c: string): CompareResult | undefined {
const ro = LIFECYCLE_ORDER[text(o).toLowerCase()];
const rc = LIFECYCLE_ORDER[text(c).toLowerCase()];
if (ro === undefined || rc === undefined) return undefined;
return rc === ro ? 'equal' : rc > ro ? 'covers' : 'differs';
}
function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStatus: DifferenceStatus; riskLevel: RiskLevel; note: string } {
const o = parseParamValue(p.originalValue);
const c = parseParamValue(p.candidateValue);
const hard = p.constraintType === 'hard';
let compareResult = compareParsed(o, c);
if (/生命周期|lifecycle/i.test(p.parameterName)) compareResult = compareLifecycle(p.originalValue, p.candidateValue) ?? compareResult;
const req = satisfiesRequirement(p.requirement, c);
if (req === false) {
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` };
}
if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' };
if (compareResult === 'equal' || compareResult === 'covers') {
if (hard && text(p.evidenceRef) === '') {
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '硬约束缺证据引用:未经证据支持不判 MATCH' };
}
return { compareResult, differenceStatus: 'match', riskLevel: 'low', note: compareResult === 'covers' ? '候选范围盖住原范围' : '一致' };
}
if (compareResult === 'configurable') return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '候选可调,须改外围把它配成原值' };
if (compareResult === 'partial') return { compareResult, differenceStatus: 'conditional', riskLevel: hard ? 'medium' : 'low', note: '范围部分重叠,须按应用边界确认' };
compareResult = compareResult === 'narrower' ? 'narrower' : 'differs';
return hard
? { compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: '硬约束不一致' }
: { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: '软约束不一致,须评估影响' };
}
const compareParameters: ToolHandler = async ({ input }) => {
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; pairs: ParamPair[] }>) : [];
const results = candidates.map((cand) => {
const counts: CompareCounts = { match: 0, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 };
const items = (Array.isArray(cand.pairs) ? cand.pairs : []).map((p) => {
const j = judgePair(p);
counts[j.differenceStatus] += 1;
if (j.differenceStatus === 'mismatch') counts[p.constraintType === 'hard' ? 'hardMismatch' : 'softMismatch'] += 1;
if (j.compareResult === 'configurable') counts.configurable += 1;
return { parameterName: p.parameterName, unit: p.unit ?? '', constraintType: p.constraintType, ...j };
});
return { candidateInternalPn: cand.candidateInternalPn, items, counts };
});
return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
};
/**
* 推荐定级(D02,FIRST):硬 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐(等验证闭环);
* 有 configurable(改外围配得成)→ 备选;其余 conditional → 条件推荐;全 MATCH → 推荐。与 manifest 里的 D02 表同一份规则。
*/
const recommend: ToolHandler = async ({ input }) => {
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as Array<{ candidateInternalPn: string; counts: Partial<CompareCounts> }>) : [];
const results = candidates.map((cand) => {
const n = (k: keyof CompareCounts) => Number(cand.counts?.[k] ?? 0);
if (n('hardMismatch') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'not_recommended', riskLevel: 'high', reason: `硬约束 MISMATCH ${n('hardMismatch')} 项:硬约束失败直接不推荐` };
if (n('unknown') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `UNKNOWN ${n('unknown')} 项待验证闭环,通过后可升级推荐` };
if (n('configurable') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'alternate', riskLevel: 'medium', reason: `${n('configurable')} 项须改外围配成原值(另有 ${n('conditional') - n('configurable')} 项条件差异):客户接受改设计则启用` };
if (n('conditional') > 0) return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'conditional', riskLevel: 'medium', reason: `CONDITIONAL ${n('conditional')} 项须按应用边界确认` };
return { candidateInternalPn: cand.candidateInternalPn, recommendationLevel: 'recommended', riskLevel: 'low', reason: `${n('match')} 项全部 MATCH` };
});
return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
};
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],
['part-substitution-assessment.compare-parameters', compareParameters],
['part-substitution-assessment.recommend', recommend],
];