外包风险评估系统:领域引擎+前端+服务端持久化与生产部署

- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解
- 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护)
- 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理
- 专业图标体系替换全部emoji、看板美化
- 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC
- 444单测+204前端测试+51 e2e
This commit is contained in:
freedakgmail
2026-06-13 01:06:39 +08:00
commit c670b9e454
404 changed files with 61820 additions and 0 deletions
@@ -0,0 +1,311 @@
/**
* External_Data_Adapter 外部数据适配集成测试(Req 15.1, 15.5)。
*
* 覆盖两条端到端路径:
*
* 1. 成功取数路径(Req 15.1, 15.2):以 mock {@link DataSourceAdapter} 在超时上限内
* 成功返回数据点,经 {@link fetchExternalData} / {@link fetchWithFallback} 验证每个
* 数据点 Data_Provenance 恒为"外部数据"、Confidence 落在 [0,1],且能回填客户风险
* 相关 Indicator 取值。
*
* 2. 可插拔扩展路径(Req 15.5):以一个最小适配器注册表(Map<sourceId, adapter>)注册
* 一个全新的外部数据源适配器,再将其取数结果驱动 Scoring_Engine 的 `scoreDimension`
* / `scoreIndicator` 评分。整个过程仅 import 现有 scoringEngine.ts 而**不修改其源代码**
* 以此证明新增数据源无需改动 Scoring_EngineReq 15.5)。
*
* Feature: outsourcing-risk-assessment
* Validates: Requirements 15.1, 15.5
*/
import { describe, expect, it } from 'vitest';
import {
EXTERNAL_DATA_PROVENANCE,
annotateExternalDataPoints,
fetchExternalData,
fetchWithFallback,
type DataPoint,
type DataSourceAdapter,
type DataSourceQuery,
type RawExternalDataPoint,
} from '../index.js';
import {
scoreDimension,
scoreIndicator,
type RiskLevelResolver,
} from '../../scoring/index.js';
import type { Dimension, Indicator } from '../../domain/model.js';
import type { RiskLevel } from '../../domain/common.js';
// ----------------------------------------------------------------------------
// 测试替身:可配置的 mock 数据源适配器。
// ----------------------------------------------------------------------------
/**
* 构造一个 mock 适配器:在调用时按给定原始数据点产出已标注的数据点(成功路径)。
* 标注统一经 {@link annotateExternalDataPoints},保证 Req 15.2 的来源/置信不变式。
*/
function makeMockAdapter(
sourceId: string,
raws: readonly RawExternalDataPoint[],
sourceName?: string,
): DataSourceAdapter {
const base = {
sourceId,
fetch: (_query: DataSourceQuery): Promise<DataPoint[]> =>
Promise.resolve(annotateExternalDataPoints(raws)),
};
return sourceName === undefined ? base : { ...base, sourceName };
}
/** 最小适配器注册表:以 sourceId 为键插拔注册外部数据源(Req 15.5)。 */
class AdapterRegistry {
private readonly adapters = new Map<string, DataSourceAdapter>();
register(adapter: DataSourceAdapter): void {
this.adapters.set(adapter.sourceId, adapter);
}
get(sourceId: string): DataSourceAdapter {
const adapter = this.adapters.get(sourceId);
if (adapter === undefined) {
throw new Error(`未注册的数据源: ${sourceId}`);
}
return adapter;
}
get size(): number {
return this.adapters.size;
}
}
// ----------------------------------------------------------------------------
// 测试夹具:客户风险维度与指标,以及由外部数据点构造的风险等级解析器。
// ----------------------------------------------------------------------------
/** 构造一个启用的客户风险 Indicator(评分规则细节与本集成测试无关,留空)。 */
function makeIndicator(id: string, name: string, weight: number): Indicator {
return {
id,
name,
weight,
enabled: true,
scoringRules: [],
evidenceRequired: '',
askPrompt: '',
};
}
/** 将外部数值取值夹取为 1-5 的 Risk_Level。 */
function toRiskLevel(value: number): RiskLevel {
const clamped = Math.min(5, Math.max(1, Math.round(value)));
return clamped as RiskLevel;
}
/**
* 由外部数据点构造 Scoring_Engine 所需的 RiskLevelResolver。
*
* 这是适配层与评分引擎之间的"接缝":外部数据经此函数转换为引擎可消费的
* 风险等级解析器,引擎本身无需感知数据来源(Req 15.5)。
*/
function buildResolver(points: readonly DataPoint[]): RiskLevelResolver {
const levelByIndicator = new Map<string, RiskLevel>();
for (const point of points) {
if (point.indicatorId !== undefined && typeof point.value === 'number') {
levelByIndicator.set(point.indicatorId, toRiskLevel(point.value));
}
}
return (indicator: Indicator): RiskLevel =>
levelByIndicator.get(indicator.id) ?? 1;
}
// ----------------------------------------------------------------------------
// 路径一:mock 数据源验证成功取数路径(Req 15.1, 15.2)。
// ----------------------------------------------------------------------------
describe('外部数据适配集成 - 成功取数路径 (Req 15.1)', () => {
const creditRaws: readonly RawExternalDataPoint[] = [
{
field: '信用评级',
value: 3,
category: '企业资信',
confidence: 0.9,
indicatorId: 'cust.credit',
},
{
field: '征信查询次数',
value: 2,
category: '征信',
confidence: 1.4, // 越界值,应被规整至 1
indicatorId: 'cust.creditInquiry',
},
];
it('mock 适配器在超时内成功取数, 每个数据点标注为"外部数据"且 Confidence ∈ [0,1]', async () => {
const adapter = makeMockAdapter('mock-credit', creditRaws, '资信 Mock');
const query: DataSourceQuery = {
subjectName: '示例外包供应商',
categories: ['企业资信', '征信'],
indicatorIds: ['cust.credit', 'cust.creditInquiry'],
};
const outcome = await fetchExternalData(adapter, query);
expect(outcome.status).toBe('success');
if (outcome.status !== 'success') {
throw new Error('期望成功取数');
}
expect(outcome.dataPoints).toHaveLength(2);
for (const point of outcome.dataPoints) {
expect(point.provenance).toBe(EXTERNAL_DATA_PROVENANCE);
expect(point.confidence).toBeGreaterThanOrEqual(0);
expect(point.confidence).toBeLessThanOrEqual(1);
}
});
it('成功取数经 fetchWithFallback 回填指标取值且不触发降级', async () => {
const adapter = makeMockAdapter('mock-credit', creditRaws);
const query: DataSourceQuery = {
subjectName: '示例外包供应商',
indicatorIds: ['cust.credit', 'cust.creditInquiry'],
};
const result = await fetchWithFallback({ adapter, query });
expect(result.outcome.status).toBe('success');
expect(result.usedFallback).toBe(false);
expect(result.dataPoints).toHaveLength(2);
for (const point of result.dataPoints) {
expect(point.provenance).toBe(EXTERNAL_DATA_PROVENANCE);
}
// 数据点确实按请求的 Indicator 标识回填。
const ids = result.dataPoints.map((point) => point.indicatorId);
expect(ids).toContain('cust.credit');
expect(ids).toContain('cust.creditInquiry');
});
});
// ----------------------------------------------------------------------------
// 路径二:注册新适配器, 无需改 Scoring_Engine 源码即可使用(Req 15.5)。
// ----------------------------------------------------------------------------
describe('外部数据适配集成 - 可插拔扩展无需改 Scoring_Engine (Req 15.5)', () => {
/** 客户风险维度:两个启用指标。 */
function makeCustomerDimension(): Dimension {
return {
id: 'dim.customer',
name: '客户风险',
weight: 100,
enabled: true,
indicators: [
makeIndicator('cust.credit', '信用评级', 6),
makeIndicator('cust.litigation', '涉诉风险', 4),
],
};
}
it('用已注册适配器取数, 其结果可直接驱动 scoreDimension 评分', async () => {
const registry = new AdapterRegistry();
registry.register(
makeMockAdapter('mock-credit', [
{
field: '信用评级',
value: 3,
category: '企业资信',
confidence: 0.8,
indicatorId: 'cust.credit',
},
{
field: '涉诉风险',
value: 2,
category: '涉诉',
confidence: 0.7,
indicatorId: 'cust.litigation',
},
]),
);
const dimension = makeCustomerDimension();
const query: DataSourceQuery = {
subjectName: '供应商A',
indicatorIds: ['cust.credit', 'cust.litigation'],
};
const outcome = await fetchExternalData(registry.get('mock-credit'), query);
expect(outcome.status).toBe('success');
if (outcome.status !== 'success') {
throw new Error('期望成功取数');
}
// 评分引擎源码未改动: 仅用其导出的纯函数 + 适配层产出的 resolver。
const resolver = buildResolver(outcome.dataPoints);
const score = scoreDimension(dimension, resolver);
// cust.credit: level 3 × weight 6 = 18; cust.litigation: level 2 × weight 4 = 8 → 26。
expect(score).toBe(26);
});
it('注册一个全新数据源适配器后, 同一评分流程无需改动即可使用 (Req 15.5)', async () => {
const registry = new AdapterRegistry();
// 先注册一个既有适配器。
registry.register(makeMockAdapter('mock-credit', []));
const sizeBefore = registry.size;
// 注册一个此前不存在的"工商数据"适配器 —— 仅实现接口并注册, 无任何引擎改动。
const businessRegistryAdapter = makeMockAdapter(
'mock-business-registry',
[
{
field: '注册资本异常',
value: 4,
category: '工商',
confidence: 0.95,
indicatorId: 'cust.credit',
},
{
field: '失信记录',
value: 5,
category: '失信',
confidence: 0.99,
indicatorId: 'cust.litigation',
},
],
'工商数据 Mock',
);
registry.register(businessRegistryAdapter);
expect(registry.size).toBe(sizeBefore + 1);
const dimension = makeCustomerDimension();
const query: DataSourceQuery = {
subjectName: '供应商B',
indicatorIds: ['cust.credit', 'cust.litigation'],
};
// 取出新注册的适配器, 复用与既有适配器完全相同的取数 + 评分链路。
const outcome = await fetchExternalData(
registry.get('mock-business-registry'),
query,
);
expect(outcome.status).toBe('success');
if (outcome.status !== 'success') {
throw new Error('期望成功取数');
}
for (const point of outcome.dataPoints) {
expect(point.provenance).toBe(EXTERNAL_DATA_PROVENANCE);
}
const resolver = buildResolver(outcome.dataPoints);
// 单指标得分: cust.credit level 4 × weight 6 = 24。
const creditIndicator = dimension.indicators[0];
expect(creditIndicator).toBeDefined();
if (creditIndicator === undefined) {
throw new Error('缺少 cust.credit 指标');
}
expect(scoreIndicator(creditIndicator, resolver(creditIndicator))).toBe(24);
// 维度得分: cust.credit 4×6=24; cust.litigation 5×4=20 → 44。
expect(scoreDimension(dimension, resolver)).toBe(44);
});
});
@@ -0,0 +1,121 @@
/**
* Property 51: 外部数据点来源与置信标注 的属性化测试(External_Data_AdapterReq 15.2)。
*
* 属性陈述:对任意成功获取的外部数据点,经来源标注后其 Data_Provenance 必为"外部数据"
* 且 Confidence 落在 [0, 1] 内并保留两位小数。
*
* 本测试以智能生成器构造任意原始外部数据点 RawExternalDataPoint
* - 原始 confidence 跨越 [0, 1] 内、负值、>1、极端值与边界值(0 / 1 / 0.005 等),
* 充分覆盖 normalizeConfidence 的夹取与两位小数规整分支;
* - field / value / category / 可选 indicatorId 取遍各形态,使输入空间贴近真实适配器产出。
*
* 对单点 annotateExternalDataPoint 与批量 annotateExternalDataPoints 均断言:
* - provenance === EXTERNAL_DATA_PROVENANCE"外部数据"
* - confidence ∈ [0, 1]
* - confidence 为两位小数(confidence × 100 在浮点容差内为整数)
*
* Feature: outsourcing-risk-assessment, Property 51: 外部数据点来源与置信标注
* Validates: Requirements 15.2
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
annotateExternalDataPoint,
annotateExternalDataPoints,
EXTERNAL_DATA_CATEGORY_VALUES,
EXTERNAL_DATA_PROVENANCE,
type ExternalDataCategory,
type ExternalDataValue,
type RawExternalDataPoint,
} from '../index.js';
// ----------------------------------------------------------------------------
// 生成器:构造任意原始外部数据点。
// ----------------------------------------------------------------------------
/** 数据点取值:标量(string | number | boolean | null)。 */
const valueArb: fc.Arbitrary<ExternalDataValue> = fc.oneof(
fc.string(),
fc.double({ noNaN: true, noDefaultInfinity: true }),
fc.boolean(),
fc.constant(null),
);
/** 外部数据类别:取自规定的五类。 */
const categoryArb: fc.Arbitrary<ExternalDataCategory> = fc.constantFrom(
...EXTERNAL_DATA_CATEGORY_VALUES,
);
/**
* 原始置信度:覆盖 [0,1] 内、越界(负值 / >1)、边界值与极端值,
* 充分检验 normalizeConfidence 的夹取与两位小数规整。
*/
const rawConfidenceArb: fc.Arbitrary<number> = fc.oneof(
fc.double({ min: 0, max: 1, noNaN: true }),
fc.double({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
fc.constantFrom(0, 1, 0.005, 0.125, 0.999, -0.5, 1.5, 42),
);
/** 原始外部数据点生成器(indicatorId 可选)。 */
const rawDataPointArb: fc.Arbitrary<RawExternalDataPoint> = fc
.record(
{
field: fc.string({ minLength: 1, maxLength: 16 }),
value: valueArb,
category: categoryArb,
confidence: rawConfidenceArb,
indicatorId: fc.option(fc.string({ minLength: 1, maxLength: 12 }), {
nil: undefined,
}),
},
{ requiredKeys: ['field', 'value', 'category', 'confidence'] },
)
.map((r) => {
// exactOptionalPropertyTypes:仅在存在时附加 indicatorId。
if (r.indicatorId === undefined) {
const { field, value, category, confidence } = r;
return { field, value, category, confidence };
}
return r as RawExternalDataPoint;
});
/** 两位小数判定:value × 100 在浮点容差内为整数。 */
function isTwoDecimals(value: number): boolean {
return Math.abs(value * 100 - Math.round(value * 100)) < 1e-9;
}
/** 单点不变式断言:provenance 为"外部数据"confidence ∈ [0,1] 且两位小数。 */
function assertAnnotated(point: { provenance: string; confidence: number }): void {
expect(point.provenance).toBe(EXTERNAL_DATA_PROVENANCE);
expect(point.confidence).toBeGreaterThanOrEqual(0);
expect(point.confidence).toBeLessThanOrEqual(1);
expect(isTwoDecimals(point.confidence)).toBe(true);
}
describe('Property 51: 外部数据点来源与置信标注 (Req 15.2)', () => {
it('单点标注后 provenance 恒为"外部数据"且 confidence ∈ [0,1] 两位小数', () => {
fc.assert(
fc.property(rawDataPointArb, (raw) => {
assertAnnotated(annotateExternalDataPoint(raw));
}),
{ numRuns: 100 },
);
});
it('批量标注的每个数据点均满足来源与置信不变式', () => {
fc.assert(
fc.property(
fc.array(rawDataPointArb, { minLength: 0, maxLength: 12 }),
(raws) => {
const points = annotateExternalDataPoints(raws);
expect(points).toHaveLength(raws.length);
for (const point of points) {
assertAnnotated(point);
}
},
),
{ numRuns: 100 },
);
});
});
@@ -0,0 +1,227 @@
/**
* Property 52: 外部数据失败降级回退 的属性化测试(External_Data_AdapterReq 15.3, 15.4)。
*
* 属性陈述:对任意外部数据源连接失败、超过约定时长未返回(超时)或返回错误响应的情形,
* 受影响数据点必回退到用户输入并标注 Data_Provenance="用户输入"Req 15.3);若回退后
* 用户输入仍不完整(缺失),则必标注为"智能体假设"并继续执行评估、不中断流程(Req 15.4)。
*
* 本测试以智能生成器构造三类失败场景的适配器(连接失败 / 超时 / 错误响应),并为每个被
* 请求键随机决定其用户输入是否存在且非缺失、以及智能体假设兜底是否存在。断言:
* - 取数结果恒为 status==="failed"(外部数据不可用),usedFallback===true
* - 用户输入存在且非缺失的键 → provenance==="用户输入"Req 15.3);
* - 用户输入缺失的键 → provenance==="智能体假设"Req 15.4);
* - 无论何种失败场景,fetchWithFallback 恒返回完整解析结果、不抛出、不中断流程(Req 15.4);
* - 失败场景下没有任何数据点被标注为"外部数据"。
*
* 另以纯函数 resolveWithFallback 在"外部取数失败=空外部数据点"前提下覆盖更广输入空间,
* 验证优先级链路"外部数据 → 用户输入 → 智能体假设"的解析正确性。
*
* Feature: outsourcing-risk-assessment, Property 52: 外部数据失败降级回退
* Validates: Requirements 15.3, 15.4
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
fetchWithFallback,
resolveWithFallback,
EXTERNAL_DATA_TIMEOUT_MS,
type DataPoint,
type DataSourceAdapter,
type ExternalDataValue,
type FallbackInput,
} from '../index.js';
// ----------------------------------------------------------------------------
// 生成器。
// ----------------------------------------------------------------------------
/** 外部取数失败的三类成因(Req 15.3)。 */
type FailureKind = '连接失败' | '超时' | '错误响应';
const failureKindArb: fc.Arbitrary<FailureKind> = fc.constantFrom(
'连接失败',
'超时',
'错误响应',
);
/** 非缺失取值:非空白字符串 / 数值 / 布尔(含 false、0 等有效值)。 */
const nonMissingValueArb: fc.Arbitrary<ExternalDataValue> = fc.oneof(
fc.string({ minLength: 1, maxLength: 16 }).filter((s) => s.trim() !== ''),
fc.double({ noNaN: true, noDefaultInfinity: true }),
fc.boolean(),
);
/** 任意取值:在非缺失基础上额外包含 null 与空白串(用于假设候选)。 */
const anyValueArb: fc.Arbitrary<ExternalDataValue> = fc.oneof(
nonMissingValueArb,
fc.constant(null),
fc.constant(' '),
);
/** 对齐键:非空白字符串。 */
const keyArb: fc.Arbitrary<string> = fc
.string({ minLength: 1, maxLength: 10 })
.filter((s) => s.trim() !== '');
/** 每个键的回退方案:用户输入是否存在(非缺失)、假设兜底是否存在及其取值。 */
interface KeyPlan {
userPresent: boolean;
userValue: ExternalDataValue;
assumptionPresent: boolean;
assumptionValue: ExternalDataValue;
}
const keyPlanArb: fc.Arbitrary<KeyPlan> = fc.record({
userPresent: fc.boolean(),
userValue: nonMissingValueArb,
assumptionPresent: fc.boolean(),
assumptionValue: anyValueArb,
});
/** 完整场景:唯一键集合、各键回退方案、失败成因。 */
interface Scenario {
keys: string[];
plans: KeyPlan[];
failureKind: FailureKind;
}
const scenarioArb: fc.Arbitrary<Scenario> = fc
.uniqueArray(keyArb, { minLength: 1, maxLength: 8 })
.chain((keys) =>
fc.record({
keys: fc.constant(keys),
plans: fc.array(keyPlanArb, {
minLength: keys.length,
maxLength: keys.length,
}),
failureKind: failureKindArb,
}),
);
// ----------------------------------------------------------------------------
// 失败适配器构造。
// ----------------------------------------------------------------------------
/** 构造对应失败成因的适配器;超时场景返回永不结算的 Promise,由超时包装器拒绝。 */
function makeFailingAdapter(kind: FailureKind): DataSourceAdapter {
return {
sourceId: 'test-failing-source',
fetch: (): Promise<DataPoint[]> => {
if (kind === '超时') {
// 永不结算 → 由 withTimeout 在 timeoutMs 后以超时拒绝。
return new Promise<DataPoint[]>(() => {
/* never settles */
});
}
if (kind === '连接失败') {
return Promise.reject(new Error('ECONNREFUSED 模拟连接失败'));
}
return Promise.reject(new Error('HTTP 500 模拟错误响应'));
},
};
}
/** 由场景构造用户输入与假设候选数组。 */
function buildFallbacks(scenario: Scenario): {
userInputs: FallbackInput[];
assumptions: FallbackInput[];
} {
const userInputs: FallbackInput[] = [];
const assumptions: FallbackInput[] = [];
scenario.keys.forEach((key, i) => {
const plan = scenario.plans[i]!;
if (plan.userPresent) {
userInputs.push({ field: key, value: plan.userValue });
}
if (plan.assumptionPresent) {
assumptions.push({ field: key, value: plan.assumptionValue });
}
});
return { userInputs, assumptions };
}
// ----------------------------------------------------------------------------
// 属性。
// ----------------------------------------------------------------------------
describe('Property 52: 外部数据失败降级回退 (Req 15.3, 15.4)', () => {
it('任意失败/超时/错误响应场景:受影响点回退用户输入,仍缺失则标智能体假设,且不中断', async () => {
await fc.assert(
fc.asyncProperty(scenarioArb, async (scenario) => {
const adapter = makeFailingAdapter(scenario.failureKind);
const { userInputs, assumptions } = buildFallbacks(scenario);
// 超时场景使用极短超时上限以保证测试快速;其余场景立即拒绝,上限无影响。
const timeoutMs =
scenario.failureKind === '超时' ? 5 : EXTERNAL_DATA_TIMEOUT_MS;
// 恒不抛出 / 不中断(Req 15.4):asyncProperty 等待其正常结算。
const result = await fetchWithFallback({
adapter,
query: { subjectName: '测试主体' },
userInputs,
assumptions,
requestedKeys: scenario.keys,
timeoutMs,
});
// 外部数据不可用:取数失败且发生回退。
expect(result.outcome.status).toBe('failed');
expect(result.usedFallback).toBe(true);
expect(result.dataPoints).toHaveLength(scenario.keys.length);
const byField = new Map(result.dataPoints.map((p) => [p.field, p]));
scenario.keys.forEach((key, i) => {
const plan = scenario.plans[i]!;
const point = byField.get(key);
expect(point).toBeDefined();
// 失败场景下不应出现"外部数据"来源。
expect(point!.provenance).not.toBe('外部数据');
if (plan.userPresent) {
// 受影响数据点回退到用户输入(Req 15.3)。
expect(point!.provenance).toBe('用户输入');
expect(point!.value).toBe(plan.userValue);
} else {
// 回退后仍缺失 → 标注智能体假设并继续(Req 15.4)。
expect(point!.provenance).toBe('智能体假设');
}
});
}),
{ numRuns: 100 },
);
});
it('纯函数解析:空外部数据点时按 用户输入→智能体假设 优先级解析(Req 15.3, 15.4', () => {
fc.assert(
fc.property(scenarioArb, (scenario) => {
const { userInputs, assumptions } = buildFallbacks(scenario);
// 外部取数失败 → 外部数据点为空。
const resolved = resolveWithFallback(
[],
scenario.keys,
userInputs,
assumptions,
);
expect(resolved).toHaveLength(scenario.keys.length);
const byField = new Map(resolved.map((p) => [p.field, p]));
scenario.keys.forEach((key, i) => {
const plan = scenario.plans[i]!;
const point = byField.get(key);
expect(point).toBeDefined();
expect(point!.provenance).not.toBe('外部数据');
if (plan.userPresent) {
expect(point!.provenance).toBe('用户输入');
} else {
expect(point!.provenance).toBe('智能体假设');
}
// 来源恒为三态之一。
expect(['用户输入', '外部数据', '智能体假设']).toContain(
point!.provenance,
);
});
}),
{ numRuns: 100 },
);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* 外部数据成功取数的来源标注(Req 15.2)。
*
* 将适配器产出的原始数据点统一标注为 Data_Provenance="外部数据",并经
* {@link normalizeConfidence} 将 Confidence 规整至 [0, 1] 区间、保留两位小数。
* 所有数据源经此单一出口产出数据点,保证 Req 15.2 在全部适配器上的一致性。
*/
import { normalizeConfidence } from '../domain/provenance.js';
import {
EXTERNAL_DATA_PROVENANCE,
type DataPoint,
type RawExternalDataPoint,
} from './types.js';
/**
* 为单个原始外部数据点施加成功取数的来源标注(Req 15.2)。
*
* 不变式:返回数据点的 `provenance` 恒为"外部数据"`confidence` 落在 [0, 1] 内
* 并保留两位小数。该函数为纯函数、无副作用。
*
* @param raw 适配器产出的原始数据点。
* @returns 完成来源标注与置信度规整的数据点。
* @throws {RangeError} 当 `raw.confidence` 非有限数(NaN / ±Infinity)时。
*/
export function annotateExternalDataPoint(raw: RawExternalDataPoint): DataPoint {
const point: DataPoint = {
field: raw.field,
value: raw.value,
category: raw.category,
provenance: EXTERNAL_DATA_PROVENANCE,
confidence: normalizeConfidence(raw.confidence),
};
// exactOptionalPropertyTypes:仅在存在时附加可选字段,避免赋值 undefined。
if (raw.indicatorId !== undefined) {
return { ...point, indicatorId: raw.indicatorId };
}
return point;
}
/**
* 批量为原始外部数据点施加成功取数的来源标注(Req 15.2)。
*
* @param raws 原始数据点集合。
* @returns 完成来源标注的数据点集合(顺序与输入一致)。
*/
export function annotateExternalDataPoints(
raws: readonly RawExternalDataPoint[],
): DataPoint[] {
return raws.map(annotateExternalDataPoint);
}
+457
View File
@@ -0,0 +1,457 @@
/**
* External_Data_Adapter 超时 / 失败降级回退(Req 15.3, 15.4)。
*
* 本模块在适配器成功取数(Req 15.2)之上补齐降级回退链路:
*
* - 连接失败 / 超过 10 秒未返回 / 返回错误响应(Req 15.3):受影响数据点回退到
* 用户输入并标注 Data_Provenance="用户输入"。
* - 回退后用户输入仍不完整(Req 15.4):将缺失数据点标注为"智能体假设",
* 并继续执行评估、不中断流程。
*
* 设计要点:
* - {@link withTimeout} 为通用超时包装器,超过 {@link EXTERNAL_DATA_TIMEOUT_MS}
* 即视为失败(Req 15.1 的 10 秒约定)。
* - {@link fetchExternalData} 捕获连接失败 / 超时 / 错误响应并归一为 {@link FetchOutcome}
* 恒不抛出,保证主流程不被中断(Req 15.4)。
* - {@link resolveWithFallback} 为纯函数,按"外部数据 → 用户输入 → 智能体假设"
* 的优先级为每个被请求项解析最终数据点。
* - {@link fetchWithFallback} 将取数与解析串联,对外提供一站式入口。
*/
import {
markAsAssumption,
normalizeConfidence,
} from '../domain/provenance.js';
import type { Confidence, DataProvenance } from '../domain/common.js';
import {
EXTERNAL_DATA_TIMEOUT_MS,
type DataPoint,
type DataSourceAdapter,
type DataSourceQuery,
type ExternalDataCategory,
type ExternalDataValue,
} from './types.js';
/** 用户输入回退的来源标注常量(Req 15.3)。与 DataProvenance 三态之一对齐。 */
export const USER_INPUT_PROVENANCE: DataProvenance = '用户输入';
/** 用户输入回退缺省置信度(评估者直接录入,默认视为确定)。 */
export const DEFAULT_USER_INPUT_CONFIDENCE = 1 as const;
/** "智能体假设"兜底缺省置信度(缺失项的默认取值,默认视为不确定)。 */
export const DEFAULT_ASSUMPTION_CONFIDENCE = 0 as const;
/**
* 计时器全局函数的最小类型门面。
*
* 本工程未引入 DOM / Node 的全局类型库,故以受限的 `globalThis` 视图获取
* `setTimeout` / `clearTimeout`,避免对环境类型库的依赖。
*/
const timers = globalThis as unknown as {
setTimeout: (handler: () => void, ms: number) => TimerHandle;
clearTimeout: (handle: TimerHandle) => void;
};
/** 计时器句柄的不透明类型(可选携带 unref)。 */
interface TimerHandle {
unref?: () => void;
}
/**
* 外部数据取数失败原因(Req 15.3)。
* - 超时:超过约定时长未返回。
* - 失败:连接失败或返回错误响应(二者降级处理一致,统一归类)。
*/
export type FetchFailureReason = '超时' | '失败';
/**
* 外部数据取数结果(恒不抛出,保证主流程不中断,Req 15.4)。
* - success:成功取得已完成来源标注的数据点(Req 15.2)。
* - failed:连接失败 / 超时 / 错误响应,附失败原因与原始错误(Req 15.3)。
*/
export type FetchOutcome =
| { readonly status: 'success'; readonly dataPoints: DataPoint[] }
| {
readonly status: 'failed';
readonly reason: FetchFailureReason;
readonly error: unknown;
};
/**
* 回退输入项:用户输入或智能体假设的候选取值。
*
* 以 `indicatorId`(若存在)否则 `field` 作为与外部数据点对齐的键。
*/
export interface FallbackInput {
/** 数据点字段名 / 指标键。 */
field: string;
/** 关联的 Indicator 标识;存在时优先作为对齐键。 */
indicatorId?: string;
/** 数据点所属类别,可选。 */
category?: ExternalDataCategory;
/** 取值;null 或空白字符串视为缺失。 */
value: ExternalDataValue;
/** 置信度(将被规整至 [0,1] 两位小数);省略时取对应缺省值。 */
confidence?: number;
}
/**
* 经降级回退解析后的最终数据点。
*
* 不变式:`provenance` 为三态之一,`confidence` 落在 [0,1] 内并保留两位小数。
*/
export interface ResolvedDataPoint {
/** 数据点字段名 / 指标键。 */
field: string;
/** 取值。 */
value: ExternalDataValue;
/** 数据点所属类别,可选。 */
category?: ExternalDataCategory;
/** 来源标注:外部数据 / 用户输入 / 智能体假设。 */
provenance: DataProvenance;
/** 置信度,取值 [0,1]、保留两位小数。 */
confidence: Confidence;
/** 关联的 Indicator 标识,可选。 */
indicatorId?: string;
}
/** {@link fetchWithFallback} 的参数。 */
export interface FetchWithFallbackOptions {
/** 外部数据源适配器。 */
adapter: DataSourceAdapter;
/** 查询请求。 */
query: DataSourceQuery;
/** 用户输入回退候选(Req 15.3)。 */
userInputs?: readonly FallbackInput[];
/** 智能体假设兜底候选(Req 15.4);缺省时缺失项取值为 null。 */
assumptions?: readonly FallbackInput[];
/**
* 待解析的键集合(indicatorId 或 field)。
* 省略时回退至 `query.indicatorIds`,再回退至外部/用户/假设候选键的并集。
*/
requestedKeys?: readonly string[];
/** "智能体假设"兜底缺省置信度,默认 {@link DEFAULT_ASSUMPTION_CONFIDENCE}。 */
assumptionConfidence?: number;
/** 单次请求超时上限(毫秒),默认 {@link EXTERNAL_DATA_TIMEOUT_MS}10 秒)。 */
timeoutMs?: number;
}
/** {@link fetchWithFallback} 的返回结果。 */
export interface FetchWithFallbackResult {
/** 解析后的最终数据点集合(按解析键顺序)。 */
dataPoints: ResolvedDataPoint[];
/** 外部取数结果(成功 / 失败原因)。 */
outcome: FetchOutcome;
/** 是否发生降级回退(外部取数失败或存在非"外部数据"来源的数据点)。 */
usedFallback: boolean;
}
/**
* 外部数据取数超时错误(Req 15.1 的 10 秒约定)。
*/
export class ExternalDataTimeoutError extends Error {
/** 触发超时的时长(毫秒)。 */
readonly timeoutMs: number;
constructor(timeoutMs: number) {
super(`外部数据取数超过 ${timeoutMs} 毫秒未返回,触发降级回退`);
this.name = 'ExternalDataTimeoutError';
this.timeoutMs = timeoutMs;
}
}
/**
* 判断回退取值是否缺失:null 或空白字符串视为缺失(Req 15.4)。
*
* 注意:布尔 false 与数值 0 为有效取值,不视为缺失。
*
* @param value 待判断取值。
* @returns 当且仅当取值缺失时返回 true。
*/
export function isMissingValue(value: ExternalDataValue): boolean {
return value === null || (typeof value === 'string' && value.trim() === '');
}
/**
* 通用超时包装器:在 `timeoutMs` 内未结算则以 {@link ExternalDataTimeoutError} 拒绝(Req 15.1)。
*
* 一旦底层操作先行结算(成功或失败),计时器立即清除;超时优先结算后底层操作的
* 后续结算被忽略,避免重复结算。
*
* @param operation 产出 Promise 的取数操作(惰性,便于在调用内统一捕获同步抛出)。
* @param timeoutMs 超时上限(毫秒),默认 {@link EXTERNAL_DATA_TIMEOUT_MS}。
* @returns 底层操作的结果。
* @throws {ExternalDataTimeoutError} 超过 `timeoutMs` 未返回时。
*/
export function withTimeout<T>(
operation: () => Promise<T>,
timeoutMs: number = EXTERNAL_DATA_TIMEOUT_MS,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false;
const timer = timers.setTimeout(() => {
if (!settled) {
settled = true;
reject(new ExternalDataTimeoutError(timeoutMs));
}
}, timeoutMs);
// 避免计时器在 Node 环境阻止进程退出。
timer.unref?.();
// 以 Promise.resolve 包裹,统一捕获 operation() 同步抛出的情形。
Promise.resolve()
.then(operation)
.then(
(value) => {
if (!settled) {
settled = true;
timers.clearTimeout(timer);
resolve(value);
}
},
(error: unknown) => {
if (!settled) {
settled = true;
timers.clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
}
},
);
});
}
/**
* 执行外部取数并归一化失败(Req 15.1, 15.3)。
*
* 在超时上限内尝试 `adapter.fetch`;连接失败 / 超时 / 错误响应均被捕获并归一为
* `status: 'failed'` 的 {@link FetchOutcome}。该函数恒不抛出,保证主流程不中断(Req 15.4)。
*
* @param adapter 外部数据源适配器。
* @param query 查询请求。
* @param timeoutMs 超时上限(毫秒),默认 {@link EXTERNAL_DATA_TIMEOUT_MS}。
* @returns 取数结果(成功数据点或失败原因)。
*/
export async function fetchExternalData(
adapter: DataSourceAdapter,
query: DataSourceQuery,
timeoutMs: number = EXTERNAL_DATA_TIMEOUT_MS,
): Promise<FetchOutcome> {
try {
const dataPoints = await withTimeout(() => adapter.fetch(query), timeoutMs);
return { status: 'success', dataPoints };
} catch (error) {
const reason: FetchFailureReason =
error instanceof ExternalDataTimeoutError ? '超时' : '失败';
return { status: 'failed', reason, error };
}
}
/** 计算数据点 / 回退输入的对齐键:indicatorId 优先,否则 field。 */
function keyOf(point: {
field: string;
indicatorId?: string;
}): string {
return point.indicatorId ?? point.field;
}
/** 由外部数据点构造解析结果(来源恒为"外部数据")。 */
function fromExternal(point: DataPoint): ResolvedDataPoint {
const resolved: ResolvedDataPoint = {
field: point.field,
value: point.value,
provenance: point.provenance,
confidence: point.confidence,
};
return withOptional(resolved, point.category, point.indicatorId);
}
/** 由回退输入构造解析结果,施加指定来源与置信度。 */
function fromFallback(
input: FallbackInput,
provenance: DataProvenance,
defaultConfidence: number,
): ResolvedDataPoint {
const resolved: ResolvedDataPoint = {
field: input.field,
value: input.value,
provenance,
confidence: normalizeConfidence(input.confidence ?? defaultConfidence),
};
return withOptional(resolved, input.category, input.indicatorId);
}
/** 构造缺失项的"智能体假设"兜底数据点(Req 15.4)。 */
function assumedFor(
key: string,
assumption: FallbackInput | undefined,
assumptionConfidence: number,
): ResolvedDataPoint {
// 即便存在前序来源,markAsAssumption 也保证结果恒为"智能体假设"(Req 3.7 单调永久)。
const provenance = markAsAssumption(USER_INPUT_PROVENANCE);
const base: ResolvedDataPoint = {
field: assumption?.field ?? key,
value: assumption?.value ?? null,
provenance,
confidence: normalizeConfidence(
assumption?.confidence ?? assumptionConfidence,
),
};
return withOptional(base, assumption?.category, assumption?.indicatorId ?? key);
}
/** exactOptionalPropertyTypes:仅在存在时附加可选字段,避免赋值 undefined。 */
function withOptional(
point: ResolvedDataPoint,
category: ExternalDataCategory | undefined,
indicatorId: string | undefined,
): ResolvedDataPoint {
let result = point;
if (category !== undefined) {
result = { ...result, category };
}
if (indicatorId !== undefined) {
result = { ...result, indicatorId };
}
return result;
}
/**
* 按"外部数据 → 用户输入 → 智能体假设"优先级为每个被请求键解析最终数据点(Req 15.3, 15.4)。
*
* 解析规则(对每个 key):
* 1. 若外部数据点存在且取值非缺失 → 采用"外部数据"。
* 2. 否则若用户输入存在且取值非缺失 → 回退"用户输入"Req 15.3)。
* 3. 否则 → 标注"智能体假设"并采用假设候选取值(无候选则取 null),继续不中断(Req 15.4)。
*
* 该函数为纯函数、无副作用,输出顺序与 `keys` 一致。
*
* @param externalPoints 外部成功取得的数据点(取数失败时传空数组)。
* @param keys 待解析的键集合(indicatorId 或 field)。
* @param userInputs 用户输入回退候选。
* @param assumptions 智能体假设兜底候选。
* @param assumptionConfidence "智能体假设"缺省置信度。
* @returns 解析后的数据点集合。
*/
export function resolveWithFallback(
externalPoints: readonly DataPoint[],
keys: readonly string[],
userInputs: readonly FallbackInput[] = [],
assumptions: readonly FallbackInput[] = [],
assumptionConfidence: number = DEFAULT_ASSUMPTION_CONFIDENCE,
): ResolvedDataPoint[] {
const externalByKey = indexBy(externalPoints, keyOf);
const userByKey = indexBy(userInputs, keyOf);
const assumptionByKey = indexBy(assumptions, keyOf);
return keys.map((key) => {
const external = externalByKey.get(key);
if (external !== undefined && !isMissingValue(external.value)) {
return fromExternal(external);
}
const userInput = userByKey.get(key);
if (userInput !== undefined && !isMissingValue(userInput.value)) {
return fromFallback(
userInput,
USER_INPUT_PROVENANCE,
DEFAULT_USER_INPUT_CONFIDENCE,
);
}
return assumedFor(key, assumptionByKey.get(key), assumptionConfidence);
});
}
/** 以键函数构造 Map;同键后者覆盖前者。 */
function indexBy<T extends { field: string; indicatorId?: string }>(
items: readonly T[],
key: (item: T) => string,
): Map<string, T> {
const map = new Map<string, T>();
for (const item of items) {
map.set(key(item), item);
}
return map;
}
/**
* 取数 + 降级回退一站式入口(Req 15.3, 15.4)。
*
* 流程:
* 1. 经 {@link fetchExternalData} 在超时上限内取数;失败不抛出(Req 15.3)。
* 2. 解析待处理键集合:`requestedKeys` → `query.indicatorIds` → 候选键并集。
* 3. 经 {@link resolveWithFallback} 按优先级解析最终数据点(Req 15.3, 15.4)。
*
* 无论外部取数成功与否,本函数恒返回完整解析结果、不中断流程(Req 15.4)。
*
* @param options 取数与回退参数。
* @returns 解析结果、外部取数结果与是否回退标志。
*/
export async function fetchWithFallback(
options: FetchWithFallbackOptions,
): Promise<FetchWithFallbackResult> {
const {
adapter,
query,
userInputs = [],
assumptions = [],
requestedKeys,
assumptionConfidence = DEFAULT_ASSUMPTION_CONFIDENCE,
timeoutMs = EXTERNAL_DATA_TIMEOUT_MS,
} = options;
const outcome = await fetchExternalData(adapter, query, timeoutMs);
const externalPoints = outcome.status === 'success' ? outcome.dataPoints : [];
const keys = resolveKeys(
requestedKeys,
query,
externalPoints,
userInputs,
assumptions,
);
const dataPoints = resolveWithFallback(
externalPoints,
keys,
userInputs,
assumptions,
assumptionConfidence,
);
const usedFallback =
outcome.status === 'failed' ||
dataPoints.some((point) => point.provenance !== '外部数据');
return { dataPoints, outcome, usedFallback };
}
/**
* 解析待处理键集合:`requestedKeys` 优先,其次 `query.indicatorIds`
* 最后取外部 / 用户 / 假设候选键的并集(保持首次出现顺序、去重)。
*/
function resolveKeys(
requestedKeys: readonly string[] | undefined,
query: DataSourceQuery,
externalPoints: readonly DataPoint[],
userInputs: readonly FallbackInput[],
assumptions: readonly FallbackInput[],
): string[] {
if (requestedKeys !== undefined) {
return dedupe(requestedKeys);
}
if (query.indicatorIds !== undefined && query.indicatorIds.length > 0) {
return dedupe(query.indicatorIds);
}
return dedupe([
...externalPoints.map(keyOf),
...userInputs.map(keyOf),
...assumptions.map(keyOf),
]);
}
/** 去重并保持首次出现顺序。 */
function dedupe(keys: readonly string[]): string[] {
return [...new Set(keys)];
}
+15
View File
@@ -0,0 +1,15 @@
/**
* External_Data_Adapter 外部数据适配层模块(Req 15)。
*
* 职责:
* - 定义可插拔的 {@link DataSourceAdapter} 接口,新增数据源无需改动
* Scoring_Engine 源代码(Req 15.1, 15.5)。
* - 为成功取得的数据点统一标注 Data_Provenance="外部数据" 与 [0,1] Confidence
* {@link annotateExternalDataPoint} / {@link annotateExternalDataPoints}Req 15.2)。
* - 超时 / 失败 / 错误响应的降级回退:回退用户输入("用户输入"),仍缺失则标注
* "智能体假设"并继续不中断({@link fetchWithFallback} / {@link resolveWithFallback}Req 15.3, 15.4)。
*/
export * from './types.js';
export * from './annotate.js';
export * from './fallback.js';
+134
View File
@@ -0,0 +1,134 @@
/**
* External_Data_Adapter 适配层核心类型(Req 15)。
*
* 定义可插拔的 {@link DataSourceAdapter} 接口及其查询/数据点契约。设计目标是
* 让新增外部数据源(企业资信 / 征信 / 涉诉 / 失信 / 工商)只需实现该接口并注册,
* 而无需改动 Scoring_Engine 源代码(Req 15.5)。
*
* 契约要点:
* - 单次请求遵循 10 秒超时约定({@link EXTERNAL_DATA_TIMEOUT_MS}Req 15.1)。
* - 成功取数的每个数据点其 Data_Provenance 恒为"外部数据"且 Confidence 落在
* [0, 1] 内(Req 15.2);该标注由 {@link annotateExternalDataPoint} 统一施加。
*
* 注意:超时 / 失败 / 错误响应的降级回退(Req 15.3, 15.4)见 fallback.ts
* 本模块仅定义接口与成功路径的来源标注。
*/
import type { Confidence, DataProvenance } from '../domain/common.js';
/**
* 外部数据单次请求的超时上限(毫秒,Req 15.1)。
* 超过该时长未返回视为失败,触发降级回退(回退逻辑见 fallback.ts)。
*/
export const EXTERNAL_DATA_TIMEOUT_MS = 10_000 as const;
/**
* 成功取数的来源标注常量(Req 15.2)。
* 与 DataProvenance 三态之一"外部数据"对齐。
*/
export const EXTERNAL_DATA_PROVENANCE: DataProvenance = '外部数据';
/**
* 外部数据类别(Req 15.1):企业资信、征信、涉诉、失信、工商。
*/
export type ExternalDataCategory =
| '企业资信'
| '征信'
| '涉诉'
| '失信'
| '工商';
/** ExternalDataCategory 的全部取值,便于运行时校验与遍历。 */
export const EXTERNAL_DATA_CATEGORY_VALUES = [
'企业资信',
'征信',
'涉诉',
'失信',
'工商',
] as const;
/**
* 外部数据点的取值类型。结构化原值在被引擎消费前一般规整为标量。
*/
export type ExternalDataValue = string | number | boolean | null;
/**
* 外部数据查询请求。描述待查询主体与期望取得的数据类别。
*/
export interface DataSourceQuery {
/** 被查询主体(企业)名称。 */
subjectName: string;
/** 被查询主体唯一标识(如统一社会信用代码),可选。 */
subjectId?: string;
/** 期望取得的数据类别;省略表示由适配器决定其支持的全部类别。 */
categories?: readonly ExternalDataCategory[];
/** 关联的客户风险相关 Indicator 标识集合,用于回填指标取值(Req 15.1)。 */
indicatorIds?: readonly string[];
}
/**
* 适配器返回的原始数据点(尚未施加来源标注)。
*
* 适配器实现可仅产出原始数据点,再经 {@link annotateExternalDataPoint} 统一
* 标注 Data_Provenance="外部数据" 与规整后的 Confidence,从而保证 Req 15.2
* 在所有数据源上的一致性。
*/
export interface RawExternalDataPoint {
/** 数据点字段名 / 指标键。 */
field: string;
/** 数据点取值。 */
value: ExternalDataValue;
/** 数据点所属类别。 */
category: ExternalDataCategory;
/** 原始置信度(将由 normalizeConfidence 夹取至 [0,1] 并保留两位小数)。 */
confidence: number;
/** 关联的客户风险相关 Indicator 标识,可选。 */
indicatorId?: string;
}
/**
* 成功取得并完成来源标注的外部数据点(Req 15.2)。
*
* 不变式:`provenance` 恒为"外部数据"`confidence` 落在 [0, 1] 内并保留两位小数。
*/
export interface DataPoint {
/** 数据点字段名 / 指标键。 */
field: string;
/** 数据点取值。 */
value: ExternalDataValue;
/** 数据点所属类别。 */
category: ExternalDataCategory;
/** 来源标注,成功取数恒为"外部数据"Req 15.2)。 */
provenance: DataProvenance;
/** 置信度,取值 [0,1]、保留两位小数(Req 15.2)。 */
confidence: Confidence;
/** 关联的客户风险相关 Indicator 标识,可选。 */
indicatorId?: string;
}
/**
* 可插拔的外部数据源适配器接口(Req 15.1, 15.5)。
*
* 每个外部数据源实现该接口并以唯一 `sourceId` 注册即可被 System 使用,
* 新增数据源无需改动 Scoring_Engine 源代码(Req 15.5)。
*
* 契约:
* - `fetch` 单次请求应遵循 {@link EXTERNAL_DATA_TIMEOUT_MS}10 秒)超时约定(Req 15.1)。
* - 成功返回的每个数据点须满足来源标注不变式(Req 15.2),推荐经
* {@link annotateExternalDataPoint} 产出以保证一致性。
*/
export interface DataSourceAdapter {
/** 适配器唯一标识,用于注册与插拔。 */
readonly sourceId: string;
/** 适配器可读名称,可选。 */
readonly sourceName?: string;
/** 该适配器支持的数据类别,可选;用于路由查询。 */
readonly supportedCategories?: readonly ExternalDataCategory[];
/**
* 取数:依据查询返回已完成来源标注的数据点集合。
*
* @param query 查询请求。
* @returns 成功取得并标注的数据点集合(Req 15.1, 15.2)。
*/
fetch(query: DataSourceQuery): Promise<DataPoint[]>;
}