Archived
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
This commit is contained in:
@@ -4,7 +4,6 @@
|
||||
* 运行时受限:只拿得到**注册申报的连接目标**,没有其它环境能力 —— 这是 `ToolRuntime` 保证的。
|
||||
*/
|
||||
import { evaluateUnaryTest } from '@sfeel';
|
||||
import type { TaskId } from '@contracts';
|
||||
import type { ToolHandler } from '@modules/toolbox';
|
||||
|
||||
/**
|
||||
@@ -333,338 +332,24 @@ const slotPlaceholder: ToolHandler = async ({ input }) => {
|
||||
};
|
||||
};
|
||||
|
||||
/* ── ACT-WORK-05 差异评估:逐参数比对 —— 纯工具(活动私有作用域)─────────────────
|
||||
/* ── ACT-WORK-05 差异评估:逐参数比对与推荐定级 —— 已退役(2026-08-29)──────────
|
||||
*
|
||||
* 「能算的不问 LLM」:同输入必同结论。规则与活动包 manifest 里的 D01/D02 两张 DMN 同一份;
|
||||
* 这里是求值器,那里是随包冻结的文档。`RULE_VERSION` 随结论落账(rule_version)。
|
||||
* 【两枚工具的墓志铭】`part-substitution-assessment.compare-parameters`(逐参数四态判定:
|
||||
* MATCH / MISMATCH / UNKNOWN / CONDITIONAL,按硬/软约束与客户需求边界比对原 PN 与候选料)
|
||||
* 与 `part-substitution-assessment.recommend`(按四态命中条数定推荐等级:推荐 / 条件推荐 /
|
||||
* 备选 / 不推荐)曾经跑在这里,与活动包 manifest 里冻结的 D01(逐参数四态)/ D02(推荐等级)
|
||||
* 两张 DMN 是同一份规则的两处真源。
|
||||
*
|
||||
* 比对语义(按硬/软约束):
|
||||
* equal / covers(候选范围 ⊇ 原范围) → match,低风险;
|
||||
* configurable(原是定值、候选是含它的可调范围)→ conditional(要改外围);
|
||||
* narrower / differs / partial → 硬:mismatch 高风险;软:conditional 中风险;
|
||||
* 任一侧未披露 → unknown 中风险;
|
||||
* 硬约束 equal/covers 但没给 evidenceRef → conditional(硬约束未经证据支持不得判 MATCH);
|
||||
* requirement(客户约束,如 ">= 40")候选不满足 → 一律 mismatch 高风险。
|
||||
* 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 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+)?';
|
||||
/** 数字后面允许跟一段单位(V / ℃ / °C / kHz / % / mA…)—— 「4.5V~40V」「-40℃~+85℃」这种把单位写进值里的形状要认成范围 */
|
||||
const UNIT = '(?:\\s*[A-Za-z℃°%μµΩ][A-Za-z℃°%μµΩ0-9/]*)?';
|
||||
const RANGE_RE = new RegExp(`^(${NUM})${UNIT}\\s*(?:~|~|-|–|—|…|\\.\\.\\.|至|到|to)\\s*(${NUM})`, 'i');
|
||||
const NUM_RE = new RegExp(`^(${NUM})`);
|
||||
/** 单边界:「≥45」「>= 45」「45 以上」→ [45, ∞);「≤40」「<= 40」「40 以内 / 以下」→ (-∞, 40] */
|
||||
const GE_RE = new RegExp(`^(?:>=|≥)\\s*(${NUM})|^(${NUM})${UNIT}\\s*(?:以上|及以上|起)$`);
|
||||
const LE_RE = new RegExp(`^(?:<=|≤)\\s*(${NUM})|^(${NUM})${UNIT}\\s*(?:以内|以下|及以下|以内)$`);
|
||||
|
||||
/**
|
||||
* 参数值怎么读(2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚,harness 里强调一下是范围值」):
|
||||
* 「下限~上限」一律是**范围**——分隔符认 ~ ~ - – — … ... 至 到 to,正负号认 + - 与 Unicode 减号(−),
|
||||
* 单位写进值里(4.5V~40V / -40℃~+85℃)也认;「≥45 / 45 以上」「≤40 / 40 以内」是单边界,读成半开范围;
|
||||
* 只有一个数是定值;读不出数的按文本原样比。出参会把「读作什么」摆回去,模型与人都看得见。
|
||||
*/
|
||||
export function parseParamValue(raw: unknown): Parsed {
|
||||
const s = String(raw ?? '')
|
||||
.trim()
|
||||
.replace(/[\u2212\u2010\u2011\u2012]/g, '-') // Unicode 减号 / 连字号 → ASCII 减号
|
||||
.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 ge = GE_RE.exec(s);
|
||||
if (ge !== null) return { kind: 'range', lo: Number(ge[1] ?? ge[2]), hi: Number.POSITIVE_INFINITY };
|
||||
const le = LE_RE.exec(s);
|
||||
if (le !== null) return { kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: Number(le[1] ?? le[2]) };
|
||||
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() };
|
||||
}
|
||||
|
||||
/** 「读作什么」—— 回给模型与人看的一句话,不是给机器再解析的 */
|
||||
export function describeParsed(p: Parsed): string {
|
||||
if (p.kind === 'range') {
|
||||
if (p.lo === Number.NEGATIVE_INFINITY) return `上界 ≤ ${p.hi}`;
|
||||
if (p.hi === Number.POSITIVE_INFINITY) return `下界 ≥ ${p.lo}`;
|
||||
return `范围 ${p.lo}~${p.hi}`;
|
||||
}
|
||||
if (p.kind === 'number') return `定值 ${p.value}`;
|
||||
if (p.kind === 'unknown') return '未披露';
|
||||
// 带数字又带范围分隔符却没读成范围的(如「4.5V 到 40 伏」),提示怎么写;纯文本(封装 TO-263-5)不啰嗦
|
||||
const looksLikeRange = /\d[^\d]{0,6}(?:[~~…]|至|到|\bto\b)[^\d]{0,6}\d/.test(p.value) || /^[^\d]+\d.*(?:[~~…]|至|到)/.test(p.value);
|
||||
return `文本「${p.value}」${looksLikeRange ? '(没读成范围;本意是范围请写成 下限~上限,单位放单位列)' : ''}`;
|
||||
}
|
||||
|
||||
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);
|
||||
/*
|
||||
* **客户约束原 PN 自己也不满足时,不是替代差异**(2026-08-27 晚,用户送来 0bddd862:「输入电压范围两个
|
||||
* 不是一样的吗为什么不匹配」):客户写「输入电压在 45V 以内」,模型译成 `>= 45`,而原 PN 与候选的输入上限
|
||||
* 都是 40V —— 从前这里一票否决,候选与在用料一模一样却被记成硬约束 MISMATCH、整颗不推荐。
|
||||
* 差异评估比的是「换上去会不会比现在差」:候选与原 PN 相等 / 盖住原范围,且这条客户边界**原 PN 一样过不去**,
|
||||
* 那是需求边界与在用料之间的差距(该回到需求核对 / 验证),不该记在候选头上。
|
||||
* 候选比原 PN 窄 / 不同而过不了客户线,仍是 MISMATCH;原 PN 一侧算不出(未披露 / 文本)也仍按候选过不了线判。
|
||||
*/
|
||||
if (req === false) {
|
||||
const parity = compareResult === 'equal' || compareResult === 'covers';
|
||||
const originalFailsToo = parity && satisfiesRequirement(p.requirement, o) === false;
|
||||
if (!originalFailsToo) {
|
||||
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
|
||||
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` };
|
||||
}
|
||||
const boundary = `客户约束 ${text(p.requirement)} 原 PN 与候选都不满足 —— 是需求边界与在用料的差距,不是替代差异,请回头核对需求`;
|
||||
if (hard && text(p.evidenceRef) === '') {
|
||||
return { compareResult, differenceStatus: 'conditional', riskLevel: 'medium', note: `硬约束缺证据引用:未经证据支持不判 MATCH;${boundary}` };
|
||||
}
|
||||
return { compareResult, differenceStatus: 'match', riskLevel: 'medium', note: `${compareResult === 'covers' ? '候选范围盖住原范围' : '与原 PN 一致'};${boundary}` };
|
||||
}
|
||||
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: '软约束不一致,须评估影响' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 比对工具读台账用的口 —— 按地址与等值条件取行(taskId 有就取任务视图 = 正式版 + 本单草稿,没有就只读正式版)。
|
||||
* 只读、不写;由 app 用 ledger 服务实装,测试给假的。
|
||||
*/
|
||||
export type ParameterLedger = {
|
||||
rows(taskId: TaskId | undefined, address: { domain: string; object: string }, where: Record<string, string>): Promise<ReadonlyArray<Record<string, unknown>>>;
|
||||
};
|
||||
|
||||
type CandidateInput = {
|
||||
candidateInternalPn: string;
|
||||
pairs?: ParamPair[] | null;
|
||||
/** 客户给了边界的参数:{ 参数名: ">= 40" / "-40~85" } —— 客户约束在需求原文里,归模型译;值不归它 */
|
||||
requirements?: Record<string, unknown> | null;
|
||||
evidenceRef?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* **比对入参由基座从台账取,模型只报料号与客户约束**(2026-08-27 晚,用户:「这个做差异评估比对的 harness 肯定有问题,
|
||||
* 要让它认真比对每一条」)。从前 pairs 由模型从 `substitution.difference_item` 抄给工具:实测它把剧本示例里的
|
||||
* 「4.5~32 / >= 40」当成这单的值念出来,而抄了什么**不留痕**(只存 inputHash),事后没法审。
|
||||
* 现在给 originalInternalPn + candidateInternalPn,参数行从 `parts.parameter` 现读、封装与生命周期从 `parts.profile` 现读、
|
||||
* 证据引用没给就从 `parts.document` 取 —— 每一对都是台账里的原值;出参把 originalValue / candidateValue / evidenceRef
|
||||
* 一并摆出来,写盘照抄即可。老形状(自带 pairs)照旧接,试运行与老调用方不受影响。
|
||||
*/
|
||||
async function pairsFromLedger(ledger: ParameterLedger, taskId: TaskId | undefined, original: string, candidate: string, cand: CandidateInput) {
|
||||
const PARAM = { domain: 'parts', object: 'parameter' };
|
||||
const PROFILE = { domain: 'parts', object: 'profile' };
|
||||
const DOC = { domain: 'parts', object: 'document' };
|
||||
const [oParams, cParams, oProfiles, cProfiles] = await Promise.all([
|
||||
ledger.rows(taskId, PARAM, { internal_pn: original }),
|
||||
ledger.rows(taskId, PARAM, { internal_pn: candidate }),
|
||||
ledger.rows(taskId, PROFILE, { internal_pn: original }),
|
||||
ledger.rows(taskId, PROFILE, { internal_pn: candidate }),
|
||||
]);
|
||||
const oProfile = oProfiles[0];
|
||||
const cProfile = cProfiles[0];
|
||||
if (oParams.length === 0 && oProfile === undefined) {
|
||||
return { error: `原 PN ${original} 在台账里没有主数据也没有参数行 —— 先把它建进 parts.profile / parts.parameter(数据工单),再比` };
|
||||
}
|
||||
if (cParams.length === 0 && cProfile === undefined) {
|
||||
return { error: `候选 ${candidate} 在台账里没有主数据也没有参数行 —— 先建进 parts.profile / parts.parameter,再比` };
|
||||
}
|
||||
let evidenceRef = text(cand.evidenceRef);
|
||||
if (evidenceRef === '') {
|
||||
const docs = await ledger.rows(taskId, DOC, { internal_pn: candidate });
|
||||
const sheet = docs.find((d) => text(d['document_type']) === 'datasheet' && text(d['document_ref']) !== '') ?? docs.find((d) => text(d['document_ref']) !== '');
|
||||
if (sheet !== undefined) evidenceRef = `P-08 · P-10 ${text(sheet['document_version'])}`.trim();
|
||||
}
|
||||
const requirements = cand.requirements !== null && typeof cand.requirements === 'object' ? cand.requirements : {};
|
||||
const requirementOf = (name: string) => text(requirements[name]);
|
||||
const byName = new Map(cParams.map((r) => [text(r['param_name']), r] as const));
|
||||
const sorted = [...oParams].sort((a, b) => text(a['param_name']).localeCompare(text(b['param_name']), 'zh-Hans-CN'));
|
||||
const pairs: ParamPair[] = sorted
|
||||
.filter((o) => text(o['param_name']) !== '')
|
||||
.map((o) => {
|
||||
const name = text(o['param_name']);
|
||||
const c = byName.get(name);
|
||||
return {
|
||||
parameterName: name,
|
||||
originalValue: text(o['param_value']),
|
||||
candidateValue: c === undefined ? '' : text(c['param_value']),
|
||||
unit: text(o['param_unit']) || (c === undefined ? '' : text(c['param_unit'])),
|
||||
constraintType: text(o['constraint_type']) === 'soft' ? ('soft' as const) : ('hard' as const),
|
||||
requirement: requirementOf(name),
|
||||
evidenceRef,
|
||||
};
|
||||
});
|
||||
// 封装(硬)与生命周期(软)不是参数,住在主数据上 —— 两颗都有档案时才比
|
||||
if (oProfile !== undefined && cProfile !== undefined) {
|
||||
if (text(oProfile['package']) !== '' || text(cProfile['package']) !== '') {
|
||||
pairs.push({ parameterName: '封装', originalValue: text(oProfile['package']), candidateValue: text(cProfile['package']), unit: '', constraintType: 'hard', requirement: requirementOf('封装'), evidenceRef });
|
||||
}
|
||||
if (text(oProfile['lifecycle_status']) !== '' || text(cProfile['lifecycle_status']) !== '') {
|
||||
pairs.push({ parameterName: '生命周期', originalValue: text(oProfile['lifecycle_status']), candidateValue: text(cProfile['lifecycle_status']), unit: '', constraintType: 'soft', requirement: requirementOf('生命周期'), evidenceRef });
|
||||
}
|
||||
}
|
||||
const known = new Set(pairs.map((p) => p.parameterName));
|
||||
const extraCandidateParams = [...byName.keys()].filter((n) => n !== '' && !known.has(n));
|
||||
return { pairs, extraCandidateParams };
|
||||
}
|
||||
|
||||
export function compareParametersWith(ledger?: ParameterLedger): ToolHandler {
|
||||
return async ({ input, taskId }) => {
|
||||
const original = text(input['originalInternalPn']);
|
||||
const candidates = Array.isArray(input['candidates']) ? (input['candidates'] as CandidateInput[]) : [];
|
||||
const results: Array<Record<string, unknown>> = [];
|
||||
for (const cand of candidates) {
|
||||
const candidatePn = text(cand.candidateInternalPn);
|
||||
let pairs: ParamPair[];
|
||||
let source: 'pairs' | 'ledger';
|
||||
let extraCandidateParams: string[] = [];
|
||||
if (Array.isArray(cand.pairs) && cand.pairs.length > 0) {
|
||||
pairs = cand.pairs;
|
||||
source = 'pairs';
|
||||
} else {
|
||||
if (ledger === undefined) {
|
||||
return { ok: false, error: { code: 'invalid-input', retryable: false, message: `候选 ${candidatePn} 没给 pairs,而这条通道读不到台账 —— 给 pairs,或经任务里调用` } };
|
||||
}
|
||||
if (original === '') {
|
||||
return { ok: false, error: { code: 'invalid-input', retryable: false, message: '缺 originalInternalPn:按料号从台账取参数,得先说原 PN 是谁' } };
|
||||
}
|
||||
const fetched = await pairsFromLedger(ledger, taskId, original, candidatePn, cand);
|
||||
if ('error' in fetched) return { ok: false, error: { code: 'not-found', retryable: false, message: fetched.error } };
|
||||
pairs = fetched.pairs;
|
||||
extraCandidateParams = fetched.extraCandidateParams;
|
||||
source = 'ledger';
|
||||
}
|
||||
const counts: CompareCounts = { match: 0, mismatch: 0, unknown: 0, conditional: 0, hardMismatch: 0, softMismatch: 0, configurable: 0 };
|
||||
const items = 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,
|
||||
originalValue: text(p.originalValue),
|
||||
candidateValue: text(p.candidateValue),
|
||||
// 两边各读成了什么(范围 / 定值 / 单边界 / 文本)—— 比对的依据摆出来,「4.5~40」是范围不是两个数
|
||||
readAs: { original: describeParsed(parseParamValue(p.originalValue)), candidate: describeParsed(parseParamValue(p.candidateValue)) },
|
||||
unit: p.unit ?? '',
|
||||
constraintType: p.constraintType,
|
||||
requirement: text(p.requirement),
|
||||
evidenceRef: text(p.evidenceRef),
|
||||
...j,
|
||||
};
|
||||
});
|
||||
results.push({
|
||||
candidateInternalPn: candidatePn,
|
||||
source,
|
||||
items,
|
||||
counts,
|
||||
...(extraCandidateParams.length === 0 ? {} : { extraCandidateParams }),
|
||||
});
|
||||
}
|
||||
return { ok: true, output: { ruleVersion: SUBSTITUTION_RULE_VERSION, results } };
|
||||
};
|
||||
}
|
||||
|
||||
/** 不带台账的那份(pairs 模式):静态名单里挂它,app 用 `compareParametersWith(ledger)` 顶掉 */
|
||||
const compareParameters: ToolHandler = compareParametersWith();
|
||||
|
||||
/**
|
||||
* 推荐定级(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],
|
||||
@@ -677,6 +362,4 @@ export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> =
|
||||
['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],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user