Archived
差异比对三刀:客户约束不再一票否决 · 入参由基座从台账取 · 「下限~上限」一律读成范围
①**两颗一模一样的输入电压范围判成 MISMATCH**(用户送来 0bddd862):客户写「45V 以内」→ requirement `>= 45`,
而原 PN 与候选上限都是 40V;`judgePair` 只看候选过不过客户线、不看原 PN 自己过不过 ——
需求边界与在用料的差距记在了候选头上,硬约束 MISMATCH → 整颗不推荐。差异评估比的是「换上去会不会比现在差」:
候选与原 PN 相等 / 盖住、且客户线原 PN 也过不去 → 仍按比对结果判(MATCH,风险中),备注点名
「需求边界,请回头核对需求」;候选更窄且过不了线仍 MISMATCH(3 形有测)。
②**入参由基座从台账取,模型只报料号与客户约束**(用户:「这个做差异评估比对的 harness 肯定有问题」):
同一单痕里模型念出「输入上限 32V 低于客户 40V」—— 那是 S3 剧本示例的原话、不是这单的数;
工具入参只存哈希,喂了哪几对值事后查不到;写回时还把工具的 `equal` 改成 `less`。两头都靠模型抄、两头都没有基座核。
改三层:工具运行时把 `taskId / activityId` 交给处理器(`ToolHandler` 入参加两个可选键);
`compare-parameters` 给 `originalInternalPn + candidates[].candidateInternalPn(+ requirements)`,
参数行从 `parts.parameter`、封装 / 生命周期从 `parts.profile`、证据引用从 `parts.document` 由工具现读任务视图
(本单草稿也算),出参每项带原值与证据,写盘照抄;老形状(自带 pairs)照接,试运行走它;
container 用 `compareParametersWith(ledger)` 顶掉静态名单那份;≤800 字的工具入参整份进 `result` 痕(审得了才谈得上认真)。
**基座能读到的事实,不让模型转述给工具。**
③**「下限~上限」一律是范围**(用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚」):实测解析器真有洞 ——
单位写进值里(`4.5V~40V` / `-40℃~+85℃`)被读成单个数,Unicode 减号、`…`、`≥45 / 40 以内` 读成文本。
分隔符认 ~ ~ - – — … 至 到 to、单位可夹在值里、单边界读成半开范围;出参每项 `readAs:{original,candidate}`
写明「范围 4.5~40 / 定值 5 / 下界 ≥ 45 / 文本」—— 比对依据可见,模型照它写影响、不自己重新解释值;
契约描述与 S3 / P-08 剧本明写读法(示例用中性数 1~10,不用真数据里的 4.5~40 —— 模型会把示例当这单的值)。
工具 1.0.0 → 1.3.0(注册一名一行、upsert 换版本,handler 在进程内按名绑,装完稳定版重建即生效);
W05 包 1.6.2 → 1.6.4:S3 第一拍只报料号与约束、示例只留占位形,第三拍 compare_result 照抄,S4 重比只改 requirements。
13 工具测 + 包内 89。在途单要人在审核卡上「重做」并写明意见才会重比。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
* C 国产 DCDC 不推荐(输入上限 32V < 客户 40V,硬约束 MISMATCH)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FIRST_PARTY_TOOLS } from '../tools/first-party.ts';
|
||||
import { FIRST_PARTY_TOOLS, compareParametersWith, describeParsed, parseParamValue } from '../tools/first-party.ts';
|
||||
import type { ParameterLedger } from '../tools/first-party.ts';
|
||||
import type { TaskId } from '@contracts';
|
||||
|
||||
const handler = (name: string) => {
|
||||
const found = FIRST_PARTY_TOOLS.find(([n]) => n === name);
|
||||
@@ -113,6 +115,34 @@ describe('compare-parameters:逐参数四态', () => {
|
||||
expect(c.counts).toMatchObject({ mismatch: 1, hardMismatch: 1, match: 1 });
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-27 晚,用户送来 0bddd862:客户写「输入电压在 45V 以内」→ requirement ">= 45",而原 PN 与候选的输入
|
||||
* 上限都是 40V。从前客户约束一票否决:两颗一模一样却判硬约束 MISMATCH、整颗不推荐。差异评估比的是
|
||||
* 「换上去会不会比现在差」—— 原 PN 自己也过不了的客户线,是需求边界的事,不是替代差异。
|
||||
*/
|
||||
it('候选与原 PN 相等、客户约束两颗都不满足 → 仍是 MATCH(风险中、备注点名需求边界),不记在候选头上', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [
|
||||
{
|
||||
candidateInternalPn: 'LM2596S-ADJ',
|
||||
pairs: [
|
||||
pair('输入电压范围', '4.5~40', '4.5~40', 'hard', { unit: 'V', requirement: '>= 45' }),
|
||||
// 候选盖住原范围、客户线两颗都过不去 —— 同理
|
||||
pair('工作温度', '-40~85', '-40~125', 'hard', { unit: '℃', requirement: '-55~125' }),
|
||||
// 候选比原 PN 窄且过不了客户线 —— 仍是 MISMATCH
|
||||
pair('输出电流', '3', '2', 'hard', { unit: 'A', requirement: '>= 3' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const c = (out['results'] as Result[])[0]!;
|
||||
expect(c.items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', riskLevel: 'medium' });
|
||||
expect((c.items[0] as Item & { note: string }).note).toMatch(/需求边界/);
|
||||
expect(c.items[1]).toMatchObject({ compareResult: 'covers', differenceStatus: 'match' });
|
||||
expect(c.items[2]).toMatchObject({ compareResult: 'differs', differenceStatus: 'mismatch', riskLevel: 'high' });
|
||||
expect(c.counts).toMatchObject({ match: 2, mismatch: 1, hardMismatch: 1 });
|
||||
});
|
||||
|
||||
it('硬约束相等但没有证据引用 → 不判 MATCH,降为 CONDITIONAL(硬约束项未经证据支持不得判 MATCH)', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [{ candidateInternalPn: 'X', pairs: [{ parameterName: '封装', originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard' }] }],
|
||||
@@ -132,6 +162,123 @@ describe('compare-parameters:逐参数四态', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* **比对入参由基座从台账取**(2026-08-27 晚,用户:「要让它认真比对每一条,然后再输出结论」):
|
||||
* 模型只报料号与客户约束;参数 / 封装 / 生命周期 / 证据引用由工具从 parts.* 现读。
|
||||
*/
|
||||
describe('compare-parameters:按料号从台账取参数', () => {
|
||||
const rows: Record<string, Array<Record<string, unknown>>> = {
|
||||
'parts.parameter|LM2596S-5.0': [
|
||||
{ internal_pn: 'LM2596S-5.0', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard' },
|
||||
{ internal_pn: 'LM2596S-5.0', param_name: '输出电压', param_value: '5.0', param_unit: 'V', constraint_type: 'hard' },
|
||||
{ internal_pn: 'LM2596S-5.0', param_name: '使能阈值', param_value: '1.4', param_unit: 'V', constraint_type: 'hard' },
|
||||
],
|
||||
'parts.parameter|LM2596S-ADJ': [
|
||||
{ internal_pn: 'LM2596S-ADJ', param_name: '输入电压范围', param_value: '4.5~40', param_unit: 'V', constraint_type: 'hard' },
|
||||
{ internal_pn: 'LM2596S-ADJ', param_name: '输出电压', param_value: '1.23~37', param_unit: 'V', constraint_type: 'hard' },
|
||||
// 候选多出来的参数不进比对(原 PN 定义比对集),只在 extraCandidateParams 里点名
|
||||
{ internal_pn: 'LM2596S-ADJ', param_name: '反馈电压', param_value: '1.23', param_unit: 'V', constraint_type: 'soft' },
|
||||
],
|
||||
'parts.profile|LM2596S-5.0': [{ internal_pn: 'LM2596S-5.0', package: 'TO-263-5', lifecycle_status: 'nrnd' }],
|
||||
'parts.profile|LM2596S-ADJ': [{ internal_pn: 'LM2596S-ADJ', package: 'TO-263-5', lifecycle_status: 'active' }],
|
||||
'parts.document|LM2596S-ADJ': [{ internal_pn: 'LM2596S-ADJ', document_type: 'datasheet', document_version: 'Rev H (2023-09)', document_ref: 'https://x/lm2596.pdf' }],
|
||||
};
|
||||
const seen: string[] = [];
|
||||
const ledger: ParameterLedger = {
|
||||
rows: async (taskId, address, where) => {
|
||||
seen.push(`${taskId ?? '-'}:${address.domain}.${address.object}|${where['internal_pn']}`);
|
||||
return rows[`${address.domain}.${address.object}|${where['internal_pn']}`] ?? [];
|
||||
},
|
||||
};
|
||||
const tool = compareParametersWith(ledger);
|
||||
const run = async (input: Record<string, unknown>, taskId?: TaskId) => {
|
||||
const out = await tool({ input, connection: {}, operation: undefined, idempotencyKey: undefined, taskId });
|
||||
return out;
|
||||
};
|
||||
|
||||
it('只给料号 + 客户约束 → 每一对都是台账原值;封装 / 生命周期从档案来;证据引用从资料记录来;读的是**这一单**的任务视图', async () => {
|
||||
const out = await run({ originalInternalPn: 'LM2596S-5.0', candidates: [{ candidateInternalPn: 'LM2596S-ADJ', requirements: { 输入电压范围: '>= 45' } }] }, 't-1' as TaskId);
|
||||
expect(out.ok).toBe(true);
|
||||
const r = (out.output!['results'] as Array<Record<string, unknown>>)[0]!;
|
||||
expect(r['source']).toBe('ledger');
|
||||
const items = r['items'] as Array<Record<string, unknown>>;
|
||||
const byName = Object.fromEntries(items.map((i) => [i['parameterName'] as string, i]));
|
||||
expect(byName['输入电压范围']).toMatchObject({ originalValue: '4.5~40', candidateValue: '4.5~40', unit: 'V', constraintType: 'hard', requirement: '>= 45', evidenceRef: 'P-08 · P-10 Rev H (2023-09)', differenceStatus: 'match' });
|
||||
expect(byName['输出电压']).toMatchObject({ originalValue: '5.0', candidateValue: '1.23~37', compareResult: 'configurable', differenceStatus: 'conditional' });
|
||||
// 原 PN 有、候选没有 → 候选值空 → UNKNOWN
|
||||
expect(byName['使能阈值']).toMatchObject({ originalValue: '1.4', candidateValue: '', differenceStatus: 'unknown' });
|
||||
expect(byName['封装']).toMatchObject({ originalValue: 'TO-263-5', candidateValue: 'TO-263-5', constraintType: 'hard', differenceStatus: 'match' });
|
||||
expect(byName['生命周期']).toMatchObject({ originalValue: 'nrnd', candidateValue: 'active', constraintType: 'soft', compareResult: 'covers', differenceStatus: 'match' });
|
||||
expect(r['extraCandidateParams']).toEqual(['反馈电压']);
|
||||
expect(r['counts']).toMatchObject({ match: 3, conditional: 1, unknown: 1, mismatch: 0 });
|
||||
expect(seen.every((s) => s.startsWith('t-1:'))).toBe(true);
|
||||
});
|
||||
|
||||
it('原 PN 台账里没有 → 如实报错,不比一半;没给 originalInternalPn → 拒', async () => {
|
||||
const missing = await run({ originalInternalPn: 'NOPE', candidates: [{ candidateInternalPn: 'LM2596S-ADJ' }] });
|
||||
expect(missing.ok).toBe(false);
|
||||
expect(missing.error?.message).toMatch(/NOPE/);
|
||||
const noOriginal = await run({ candidates: [{ candidateInternalPn: 'LM2596S-ADJ' }] });
|
||||
expect(noOriginal.ok).toBe(false);
|
||||
expect(noOriginal.error?.message).toMatch(/originalInternalPn/);
|
||||
});
|
||||
|
||||
it('自带 pairs 的老形状照旧接(试运行 / 老调用方),source 标 pairs', async () => {
|
||||
const out = await run({ candidates: [{ candidateInternalPn: 'X', pairs: [pair('输出电压', '5.0', '5.0', 'hard')] }] });
|
||||
expect(out.ok).toBe(true);
|
||||
const r = (out.output!['results'] as Array<Record<string, unknown>>)[0]!;
|
||||
expect(r['source']).toBe('pairs');
|
||||
expect((r['items'] as Array<Record<string, unknown>>)[0]).toMatchObject({ originalValue: '5.0', candidateValue: '5.0', differenceStatus: 'match' });
|
||||
});
|
||||
|
||||
it('没有台账的那份(静态名单)遇到不带 pairs 的入参 → 如实说这条通道读不到台账', async () => {
|
||||
const out = await compareParametersWith()({ input: { originalInternalPn: 'A', candidates: [{ candidateInternalPn: 'B' }] }, connection: {}, operation: undefined, idempotencyKey: undefined });
|
||||
expect(out.ok).toBe(false);
|
||||
expect(out.error?.message).toMatch(/读不到台账/);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 2026-08-28 用户:「4.5~40 / -40~+85 这种我怕模型搞不清楚,harness 里强调一下是范围值」。
|
||||
* 解析器把各种写法都认成范围,出参 readAs 把「读作什么」摆回去 —— 比对依据可见,模型不必自己再解释一遍值。
|
||||
*/
|
||||
describe('值的读法:「下限~上限」一律是范围', () => {
|
||||
it('分隔符 / 正负号 / 单位写进值里 / Unicode 减号 / 省略号 / 中文「至到」都认成范围;单边界读成半开范围;定值一个数', () => {
|
||||
const range = (v: string) => parseParamValue(v);
|
||||
for (const v of ['4.5~40', '4.5~40', '4.5-40', '4.5 ~ 40', '4.5V~40V', '4.5V ~ 40V (典型)', '4.5…40', '4.5...40', '4.5至40', '4.5到40', '4.5 to 40']) {
|
||||
expect(range(v), v).toEqual({ kind: 'range', lo: 4.5, hi: 40 });
|
||||
}
|
||||
for (const v of ['-40~+85', '-40~85', '−40~+85', '-40℃~+85℃', '−40 ℃ … +85 ℃', '-40 - 85', '-40 to +85']) {
|
||||
expect(range(v), v).toEqual({ kind: 'range', lo: -40, hi: 85 });
|
||||
}
|
||||
expect(range('≥45')).toEqual({ kind: 'range', lo: 45, hi: Number.POSITIVE_INFINITY });
|
||||
expect(range('45 以上')).toEqual({ kind: 'range', lo: 45, hi: Number.POSITIVE_INFINITY });
|
||||
expect(range('<= 40')).toEqual({ kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: 40 });
|
||||
expect(range('40V 以内')).toEqual({ kind: 'range', lo: Number.NEGATIVE_INFINITY, hi: 40 });
|
||||
expect(range('40')).toEqual({ kind: 'number', value: 40 });
|
||||
expect(range('5.0(固定)')).toEqual({ kind: 'number', value: 5 });
|
||||
expect(range('150 kHz')).toEqual({ kind: 'number', value: 150 });
|
||||
expect(range('TO-263-5')).toEqual({ kind: 'text', value: 'to-263-5' });
|
||||
});
|
||||
|
||||
it('readAs 说人话:范围 / 定值 / 单边界 / 文本;纯文本(封装)不啰嗦,像范围却没读成的才提示怎么写', () => {
|
||||
expect(describeParsed(parseParamValue('-40~+85'))).toBe('范围 -40~85');
|
||||
expect(describeParsed(parseParamValue('5.0'))).toBe('定值 5');
|
||||
expect(describeParsed(parseParamValue('≥45'))).toBe('下界 ≥ 45');
|
||||
expect(describeParsed(parseParamValue('TO-263-5'))).toBe('文本「to-263-5」');
|
||||
expect(describeParsed(parseParamValue('约 4.5 ~ 40'))).toMatch(/请写成 下限~上限/);
|
||||
});
|
||||
|
||||
it('两颗都写 4.5V~40V(单位在值里)→ 读成同一段范围 → equal / MATCH,出参 readAs 摆出「范围 4.5~40」', async () => {
|
||||
const out = await call('part-substitution-assessment.compare-parameters', {
|
||||
candidates: [{ candidateInternalPn: 'X', pairs: [pair('输入电压范围', '4.5V~40V', '4.5 ~ 40 V', 'hard', { unit: 'V' }), pair('工作温度', '-40℃~+85℃', '−40~+85', 'hard', { unit: '℃' })] }],
|
||||
});
|
||||
const items = (out['results'] as Array<{ items: Array<Item & { readAs: { original: string; candidate: string } }> }>)[0]!.items;
|
||||
expect(items[0]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', readAs: { original: '范围 4.5~40', candidate: '范围 4.5~40' } });
|
||||
expect(items[1]).toMatchObject({ compareResult: 'equal', differenceStatus: 'match', readAs: { original: '范围 -40~85', candidate: '范围 -40~85' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('recommend:推荐等级四档', () => {
|
||||
it('硬约束 MISMATCH → 不推荐;有 UNKNOWN → 条件推荐;只有 configurable → 备选;全 MATCH → 推荐', async () => {
|
||||
const out = await call('part-substitution-assessment.recommend', {
|
||||
|
||||
@@ -67,7 +67,8 @@ import type { ProcessService } from '@modules/process';
|
||||
import type { TriggerService } from '@modules/trigger';
|
||||
import type { MinuteService } from '@modules/minute';
|
||||
import type { ReportService } from '@modules/report';
|
||||
import { FIRST_PARTY_TOOLS } from './tools/first-party.ts';
|
||||
import { FIRST_PARTY_TOOLS, compareParametersWith } from './tools/first-party.ts';
|
||||
import type { ParameterLedger } from './tools/first-party.ts';
|
||||
import { httpToolHandler } from './tools/http-tool.ts';
|
||||
import { createMastraSpanFactory } from './mastra-spans.ts';
|
||||
import { createTraceService, createTraceStore } from '@modules/trace';
|
||||
@@ -416,8 +417,23 @@ export async function createContainer(config: AppConfig, overrides: ContainerOve
|
||||
const toolRegistry = createToolRegistry(db);
|
||||
/** 第一方工具实现 —— `mail.send` 真发信,处理器在这儿闭包持 mail(放行时经 ToolInvoker 执行) */
|
||||
// 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去
|
||||
/*
|
||||
* 差异评估的比对工具**从台账取参数**(2026-08-27 晚):静态名单里那份只认 pairs;这里用 ledger 顶掉 ——
|
||||
* 有 taskId 读任务视图(正式版 + 本单草稿),没有(试运行)只读正式版。只读。
|
||||
*/
|
||||
const parameterLedger: ParameterLedger = {
|
||||
rows: async (taskId, address, where) => {
|
||||
const query = { address, where, orderBy: [], limit: 500, offset: 0 };
|
||||
const page = taskId === undefined ? await ledger.query(query) : await ledger.taskView(taskId, query);
|
||||
return page.rows;
|
||||
},
|
||||
};
|
||||
const toolRuntime = createToolRuntime(
|
||||
new Map<string, ToolHandler>([...FIRST_PARTY_TOOLS, ['mail.send', mailSendTool(mail)]]),
|
||||
new Map<string, ToolHandler>([
|
||||
...FIRST_PARTY_TOOLS,
|
||||
['part-substitution-assessment.compare-parameters', compareParametersWith(parameterLedger)],
|
||||
['mail.send', mailSendTool(mail)],
|
||||
]),
|
||||
httpToolHandler,
|
||||
);
|
||||
const schemas = createSchemaValidator();
|
||||
|
||||
@@ -20,17 +20,29 @@ export const COMPARE_PARAMETERS_TOOL = {
|
||||
...PURE,
|
||||
name: 'part-substitution-assessment.compare-parameters',
|
||||
displayName: '替代料逐参数比对',
|
||||
version: '1.0.0',
|
||||
/*
|
||||
* 1.1.0(2026-08-27):客户约束原 PN 自己也不满足、候选与原 PN 相等或盖住时不再判 mismatch —— 那是需求边界,不是替代差异。
|
||||
* 1.2.0(同日):**比对入参由基座从台账取** —— 给 originalInternalPn + candidates[].candidateInternalPn,参数 / 封装 / 生命周期 /
|
||||
* 证据引用从 parts.* 现读,模型只译客户约束(requirements);老形状(自带 pairs)照旧接。出参每项带 originalValue / candidateValue / evidenceRef。
|
||||
*/
|
||||
version: '1.3.0',
|
||||
description:
|
||||
'按硬/软约束逐参数比对原 PN 与候选 PN,输出四态 difference_status(match/mismatch/unknown/conditional)、' +
|
||||
'compare_result、risk_level 与各候选的计数。一次可比一批候选(candidates 数组),每对参数可带客户约束 requirement(如 ">= 40")与证据引用 evidenceRef;' +
|
||||
'硬约束没有证据引用不判 match。规则版本 R-2026.07。',
|
||||
'**值的读法**:「下限~上限」一律是范围(4.5~40 = 4.5 到 40 的整段;-40~+85 = -40 到 85 的整段,不是两个数),' +
|
||||
'分隔符认 ~ - … 至 到 to,单位写进值里也认;「≥45 / 45 以上」「≤40 / 40 以内」是单边界;只有一个数是定值。出参每项 readAs 摆出两边各读成了什么。' +
|
||||
'compare_result、risk_level 与各候选的计数。**推荐用法**:只给 originalInternalPn 与 candidates[].candidateInternalPn,' +
|
||||
'参数行从 parts.parameter、封装与生命周期从 parts.profile、证据引用从 parts.document 由工具现读(本单草稿层也算),模型不抄值;' +
|
||||
'candidates[].requirements 是客户给了边界的参数 {参数名: ">= 40" / "-40~85"}。也接老形状(candidates[].pairs 自带值)。' +
|
||||
'硬约束没有证据引用不判 match。候选不比原 PN 差(相等 / 盖住)而客户约束原 PN 自己也不满足的,不算替代差异:仍按比对结果判、备注点名需求边界。规则版本 R-2026.07。',
|
||||
input: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
originalInternalPn: { type: 'string', description: '原 PN(内部料号);按料号从台账取参数时必填' },
|
||||
candidates: {
|
||||
type: 'array',
|
||||
description: '[{candidateInternalPn, pairs:[{parameterName, originalValue, candidateValue, unit?, constraintType:"hard"|"soft", requirement?, evidenceRef?}]}]',
|
||||
description:
|
||||
'[{candidateInternalPn, requirements?: {参数名: 约束}, evidenceRef?}](从台账取值) 或 ' +
|
||||
'[{candidateInternalPn, pairs:[{parameterName, originalValue, candidateValue, unit?, constraintType:"hard"|"soft", requirement?, evidenceRef?}]}](自带值)',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -40,7 +52,7 @@ export const COMPARE_PARAMETERS_TOOL = {
|
||||
ruleVersion: { type: 'string' },
|
||||
results: {
|
||||
type: 'array',
|
||||
description: '[{candidateInternalPn, items:[{parameterName, unit, constraintType, compareResult, differenceStatus, riskLevel, note}], counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}}]',
|
||||
description: '[{candidateInternalPn, source:"ledger"|"pairs", items:[{parameterName, originalValue, candidateValue, readAs:{original,candidate}, unit, constraintType, requirement, evidenceRef, compareResult, differenceStatus, riskLevel, note}], counts:{match,mismatch,unknown,conditional,hardMismatch,softMismatch,configurable}, extraCandidateParams?}]',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 运行时受限:只拿得到**注册申报的连接目标**,没有其它环境能力 —— 这是 `ToolRuntime` 保证的。
|
||||
*/
|
||||
import { evaluateUnaryTest } from '@sfeel';
|
||||
import type { TaskId } from '@contracts';
|
||||
import type { ToolHandler } from '@modules/toolbox';
|
||||
|
||||
/**
|
||||
@@ -375,11 +376,25 @@ type CompareCounts = { match: number; mismatch: number; unknown: number; conditi
|
||||
|
||||
const UNKNOWN_WORDS = /^(未披露|未知|未给出|unknown|n\/?a|tbd|待定|-|—|–)$/i;
|
||||
const NUM = '[-+]?\\d+(?:\\.\\d+)?';
|
||||
const RANGE_RE = new RegExp(`^(${NUM})\\s*(?:~|~|-|–|—|至|to)\\s*(${NUM})`, 'i');
|
||||
/** 数字后面允许跟一段单位(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(/\s+/g, ' ');
|
||||
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) {
|
||||
@@ -387,11 +402,29 @@ export function parseParamValue(raw: unknown): Parsed {
|
||||
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') {
|
||||
@@ -439,9 +472,26 @@ function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStat
|
||||
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) {
|
||||
// 客户约束不满足:比对结果照实留(narrower/differs),结论一律 MISMATCH 高风险
|
||||
return { compareResult: compareResult === 'unknown' ? 'differs' : compareResult, differenceStatus: 'mismatch', riskLevel: 'high', note: `不满足客户约束 ${text(p.requirement)}` };
|
||||
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') {
|
||||
@@ -458,22 +508,147 @@ function judgePair(p: ParamPair): { compareResult: CompareResult; differenceStat
|
||||
: { 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 } };
|
||||
/**
|
||||
* 比对工具读台账用的口 —— 按地址与等值条件取行(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 表同一份规则。
|
||||
|
||||
@@ -866,7 +866,16 @@ export function createTurnRunner(deps: TurnRunnerDeps): LoopEngineService {
|
||||
? ''
|
||||
: ` —— 这是同一份入参的第 ${sameCalls + 1} 次调用,出参与上次一字不差:别再调了,按这个数往下办`),
|
||||
// inputHash 是「同一份入参重调」检查的账 —— 存短哈希,不存整份入参(轨迹要进提示词)
|
||||
detail: { output: result.output, error: result.error?.message, inputHash: toolInputHash(action.input) },
|
||||
/*
|
||||
* **小入参原样留痕**(2026-08-27 晚):比对工具的入参从前只存哈希,模型到底喂了哪几对值事后没法审
|
||||
* (实测它把剧本示例的值当这单的值念出来)。入参不长(≤ 800 字)就整份存 —— 审得了才谈得上「认真比对每一条」。
|
||||
*/
|
||||
detail: {
|
||||
output: result.output,
|
||||
error: result.error?.message,
|
||||
inputHash: toolInputHash(action.input),
|
||||
...(JSON.stringify(action.input).length <= 800 ? { input: action.input } : {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
// 失败才动状态(Failed 是拓扑里的一档;没挂上项就没有项可标);成了就留在 Active 等 Agent 宣告完成
|
||||
|
||||
@@ -77,3 +77,7 @@
|
||||
影响半径: index/port 加法式扩容(新端口类型 + 三件纯函数 + 一张表);
|
||||
`invoke` 对未注册工具的**行为**变了(抛 → 回 `tool-unregistered`)——
|
||||
下游只有 app 的 `toolGateway` 一处消费,已同步改为转人。
|
||||
|
||||
- **处理器拿得到 `taskId` / `activityId`**(2026-08-27):`ToolHandler` 入参多两个可选键,运行时从 `ToolCall` 透传。
|
||||
读台账的第一方工具(差异评估的 `compare-parameters`)靠它读**本单的任务视图**;试运行 / 老调用方没有,工具退回纯入参那条路。
|
||||
这是「基座能读到的事实,不让模型转述给工具」的接口面 —— 工具仍只读,不经它写台账。
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Ajv } from 'ajv';
|
||||
import addFormats from 'ajv-formats';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { TaskId } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { SchemaValidator, ToolHandoff, ToolHandoffStore, ToolRegistry, ToolReplayStore, ToolRuntime } from '../../port.ts';
|
||||
import type { ToolCall, ToolResult } from '../../contract.ts';
|
||||
@@ -175,6 +176,13 @@ export type ToolHandler = (input: {
|
||||
readonly connection: Readonly<Record<string, string>>;
|
||||
readonly operation: string | undefined;
|
||||
readonly idempotencyKey: string | undefined;
|
||||
/**
|
||||
* 调用发生在哪一单 / 哪个活动(2026-08-27 晚):**读台账的工具**靠 taskId 认「本单的草稿层」——
|
||||
* 比对入参由基座从台账取,不再由模型抄给工具(抄错、抄示例都拦不住,且抄了什么不留痕)。
|
||||
* 试运行 / 老调用方没有这两样,工具自己退回纯入参那条路。
|
||||
*/
|
||||
readonly taskId?: TaskId | undefined;
|
||||
readonly activityId?: string | undefined;
|
||||
}) => Promise<{ ok: boolean; output?: Record<string, unknown>; error?: { code: string; retryable: boolean; message: string } }>;
|
||||
|
||||
/**
|
||||
@@ -209,6 +217,8 @@ export function createToolRuntime(
|
||||
connection: scoped,
|
||||
operation: call.operation,
|
||||
idempotencyKey,
|
||||
taskId: call.taskId,
|
||||
activityId: call.activityId,
|
||||
}),
|
||||
timeout,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user