外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
This commit is contained in:
@@ -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_Engine(Req 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_Adapter,Req 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_Adapter,Req 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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[]>;
|
||||
}
|
||||
Reference in New Issue
Block a user