Archived
feat(w05): 差异评估两枚纯工具 —— 逐参数四态比对与推荐定级(R-2026.07)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FJcX8A6TWp4GRs41TYryEh
This commit is contained in:
@@ -332,6 +332,154 @@ const slotPlaceholder: ToolHandler = async ({ input }) => {
|
||||
};
|
||||
};
|
||||
|
||||
/* ── 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';
|
||||
requirement?: string;
|
||||
evidenceRef?: string;
|
||||
};
|
||||
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 | undefined, c: Parsed): boolean | undefined {
|
||||
if (requirement === undefined || requirement.trim() === '' || c.kind === 'unknown' || c.kind === 'text') return undefined;
|
||||
const s = requirement.trim();
|
||||
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[o.trim().toLowerCase()];
|
||||
const rc = LIFECYCLE_ORDER[c.trim().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: `不满足客户约束 ${p.requirement ?? ''}` };
|
||||
}
|
||||
if (compareResult === 'unknown') return { compareResult, differenceStatus: 'unknown', riskLevel: 'medium', note: '一侧未披露:标 UNKNOWN,挂验证任务或补来源' };
|
||||
if (compareResult === 'equal' || compareResult === 'covers') {
|
||||
if (hard && (p.evidenceRef === undefined || p.evidenceRef.trim() === '')) {
|
||||
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],
|
||||
@@ -343,4 +491,6 @@ 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