外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Property 50: 行业分区内容完备性校验 的属性化测试(Knowledge_Base,Req 14.1, 14.4)。
|
||||
*
|
||||
* 属性陈述:对任意行业分区,合法分区必包含 Indicator、权重模板、Redline、典型案例、
|
||||
* 追问话术全部五类内容;对任意缺少其中任一类内容的新增分区请求,System 必拒绝创建、
|
||||
* 返回指明缺失内容类别的校验错误,并保持已有分区不变。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 50: 行业分区内容完备性校验
|
||||
*
|
||||
* Validates: Requirements 14.1, 14.4
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import type {
|
||||
AskPrompt,
|
||||
CaseStudy,
|
||||
IndustryPartition,
|
||||
WeightTemplateEntry,
|
||||
} from '../../domain/knowledge.js';
|
||||
import type { Indicator, Redline, Template } from '../../domain/model.js';
|
||||
import {
|
||||
IncompletePartitionError,
|
||||
KnowledgeBaseStore,
|
||||
PARTITION_CATEGORY_VALUES,
|
||||
findMissingCategories,
|
||||
type PartitionCategory,
|
||||
} from '../index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:非空集合(保证某类别"齐备")。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const indicatorArb: fc.Arbitrary<Indicator> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
weight: fc.double({ min: 0, max: 100, noNaN: true }),
|
||||
enabled: fc.boolean(),
|
||||
scoringRules: fc.constant([]),
|
||||
evidenceRequired: fc.string({ maxLength: 8 }),
|
||||
askPrompt: fc.string({ maxLength: 8 }),
|
||||
});
|
||||
|
||||
const weightTemplateArb: fc.Arbitrary<WeightTemplateEntry> = fc.record({
|
||||
targetId: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
weight: fc.double({ min: 0, max: 100, noNaN: true }),
|
||||
});
|
||||
|
||||
const redlineArb: fc.Arbitrary<Redline> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
triggerCondition: fc.string({ maxLength: 8 }),
|
||||
consequence: fc.string({ maxLength: 8 }),
|
||||
enabled: fc.boolean(),
|
||||
});
|
||||
|
||||
const caseArb: fc.Arbitrary<CaseStudy> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
title: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
summary: fc.string({ maxLength: 8 }),
|
||||
});
|
||||
|
||||
const askPromptArb: fc.Arbitrary<AskPrompt> = fc.record({
|
||||
indicatorId: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
prompt: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
});
|
||||
|
||||
const templatesArb: fc.Arbitrary<Template[]> = fc.constant([]);
|
||||
|
||||
/** 行业标识生成器(非空、规范化后稳定)。 */
|
||||
const industryArb: fc.Arbitrary<string> = fc
|
||||
.string({ minLength: 1, maxLength: 10 })
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
/**
|
||||
* 生成一个五类内容齐备的合法行业分区。
|
||||
* 每个集合至少含 1 项,确保 findMissingCategories 视其为齐备。
|
||||
*/
|
||||
function completePartitionArb(
|
||||
industry: fc.Arbitrary<string> = industryArb,
|
||||
): fc.Arbitrary<IndustryPartition> {
|
||||
return fc.record({
|
||||
industryId: industry,
|
||||
indicators: fc.array(indicatorArb, { minLength: 1, maxLength: 3 }),
|
||||
weightTemplates: fc.array(weightTemplateArb, { minLength: 1, maxLength: 3 }),
|
||||
redlines: fc.array(redlineArb, { minLength: 1, maxLength: 3 }),
|
||||
cases: fc.array(caseArb, { minLength: 1, maxLength: 3 }),
|
||||
askPrompts: fc.array(askPromptArb, { minLength: 1, maxLength: 3 }),
|
||||
templates: templatesArb,
|
||||
});
|
||||
}
|
||||
|
||||
/** 类别 → 承载字段名 的映射,用于针对性置空。 */
|
||||
const CATEGORY_FIELD: Record<PartitionCategory, keyof IndustryPartition> = {
|
||||
Indicator: 'indicators',
|
||||
权重模板: 'weightTemplates',
|
||||
Redline: 'redlines',
|
||||
典型案例: 'cases',
|
||||
追问话术: 'askPrompts',
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Property 50
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 50: 行业分区内容完备性校验 (Req 14.1, 14.4)', () => {
|
||||
// Feature: outsourcing-risk-assessment, Property 50: 行业分区内容完备性校验
|
||||
it('五类齐备的合法分区必被接受', () => {
|
||||
fc.assert(
|
||||
fc.property(completePartitionArb(), (partition) => {
|
||||
const store = new KnowledgeBaseStore();
|
||||
expect(findMissingCategories(partition)).toEqual([]);
|
||||
expect(() => store.addPartition(partition)).not.toThrow();
|
||||
expect(store.has(partition.industryId)).toBe(true);
|
||||
expect(store.size).toBe(1);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
// Feature: outsourcing-risk-assessment, Property 50: 行业分区内容完备性校验
|
||||
it('缺任一类的分区请求必被拒绝并指明缺失类别', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
completePartitionArb(),
|
||||
// 至少置空一类(非空的类别子集)。
|
||||
fc
|
||||
.subarray([...PARTITION_CATEGORY_VALUES], { minLength: 1 })
|
||||
.map((arr) => arr as PartitionCategory[]),
|
||||
(complete, categoriesToEmpty) => {
|
||||
const broken: IndustryPartition = { ...complete };
|
||||
for (const category of categoriesToEmpty) {
|
||||
broken[CATEGORY_FIELD[category]] = [] as never;
|
||||
}
|
||||
|
||||
const store = new KnowledgeBaseStore();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
store.addPartition(broken);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
|
||||
// 必拒绝并抛出 IncompletePartitionError。
|
||||
expect(thrown).toBeInstanceOf(IncompletePartitionError);
|
||||
const error = thrown as IncompletePartitionError;
|
||||
// 错误必指明全部被置空(缺失)的类别。
|
||||
expect(new Set(error.missingCategories)).toEqual(
|
||||
new Set(categoriesToEmpty),
|
||||
);
|
||||
for (const category of categoriesToEmpty) {
|
||||
expect(error.userMessage).toContain(category);
|
||||
}
|
||||
// 拒绝创建:分区未被登记。
|
||||
expect(store.has(broken.industryId)).toBe(false);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
// Feature: outsourcing-risk-assessment, Property 50: 行业分区内容完备性校验
|
||||
it('校验失败时保持已有分区不变', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
completePartitionArb(),
|
||||
completePartitionArb(),
|
||||
fc
|
||||
.subarray([...PARTITION_CATEGORY_VALUES], { minLength: 1 })
|
||||
.map((arr) => arr as PartitionCategory[]),
|
||||
(existing, candidate, categoriesToEmpty) => {
|
||||
// 先登记一个合法分区作为既有状态。
|
||||
const store = new KnowledgeBaseStore([existing]);
|
||||
const snapshotBefore = store.snapshot();
|
||||
const sizeBefore = store.size;
|
||||
|
||||
// 构造一个缺类别的新增请求(确保与既有分区行业不同以排除覆盖语义)。
|
||||
const broken: IndustryPartition = {
|
||||
...candidate,
|
||||
industryId: `${candidate.industryId}#new`,
|
||||
};
|
||||
for (const category of categoriesToEmpty) {
|
||||
broken[CATEGORY_FIELD[category]] = [] as never;
|
||||
}
|
||||
|
||||
expect(() => store.addPartition(broken)).toThrow(
|
||||
IncompletePartitionError,
|
||||
);
|
||||
|
||||
// 已有分区不变:数量不变、内容快照不变、未引入新分区。
|
||||
expect(store.size).toBe(sizeBefore);
|
||||
expect(store.has(existing.industryId)).toBe(true);
|
||||
expect(store.has(broken.industryId)).toBe(false);
|
||||
expect(store.snapshot()).toEqual(snapshotBefore);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 集成测试:知识库行业分区运行时扩展(Knowledge_Base × Scoring_Engine,Req 14.2)。
|
||||
*
|
||||
* 验证目标(Req 14.2):
|
||||
* WHERE 操作者角色为 Administrator, WHEN 运行时新增一个行业分区,
|
||||
* THE System SHALL 在不修改 Scoring_Engine 源代码(亦无需重编译)的前提下
|
||||
* 使该行业分区的 Template 可被 System 加载并被评分引擎消费。
|
||||
*
|
||||
* 测试方式(配置驱动 / config-driven):
|
||||
* - 以既有知识库为起点,由 Administrator 经 RBAC 守卫在运行时 addPartition 新增分区;
|
||||
* - 直接复用**未经任何修改**的 Scoring_Engine 导出函数
|
||||
* (scoreIndicator / scoreDimension / computeRiskScore)消费新分区 Template 的配置;
|
||||
* - 断言无需触碰评分引擎源码即可对新增行业的 Template 完成端到端评分。
|
||||
*
|
||||
* 本测试不导入任何"行业专用"的评分逻辑——评分引擎仅依赖 Template 承载的结构化配置,
|
||||
* 因此新增行业分区不会、也不需要改动 Scoring_Engine。
|
||||
*
|
||||
* Validates: Requirements 14.2
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { BusinessType, Industry, RiskLevel } from '../../domain/common.js';
|
||||
import type { IndustryPartition } from '../../domain/knowledge.js';
|
||||
import type {
|
||||
Dimension,
|
||||
Indicator,
|
||||
RiskModel,
|
||||
Template,
|
||||
} from '../../domain/model.js';
|
||||
import { KnowledgeBaseStore } from '../index.js';
|
||||
import {
|
||||
computeRiskScore,
|
||||
scoreDimension,
|
||||
scoreIndicator,
|
||||
type RiskLevelResolver,
|
||||
} from '../../scoring/index.js';
|
||||
import { isAdministrator, requireRole, type Actor } from '../../rbac/index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 构造器:合法且五类齐备的行业分区(含一个可被评分引擎消费的 Template)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 覆盖 Risk_Level 1-5 的评分规则(满足 Indicator 合法性)。 */
|
||||
function fullScoringRules() {
|
||||
return ([1, 2, 3, 4, 5] as const).map((level) => ({
|
||||
level,
|
||||
label: `L${level}`,
|
||||
description: `level ${level}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildIndicator(id: string, weight: number, enabled = true): Indicator {
|
||||
return {
|
||||
id,
|
||||
name: `指标-${id}`,
|
||||
weight,
|
||||
enabled,
|
||||
scoringRules: fullScoringRules(),
|
||||
evidenceRequired: '证据',
|
||||
askPrompt: '请补充信息',
|
||||
};
|
||||
}
|
||||
|
||||
function buildDimension(id: string, weight: number, indicators: Indicator[]): Dimension {
|
||||
return { id, name: `维度-${id}`, weight, enabled: true, indicators };
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定行业构造一个 Template,并据其打包成五类齐备的合法行业分区。
|
||||
* Template 的 riskModelConfig 即评分引擎所消费的结构化配置(配置驱动扩展的载体)。
|
||||
*/
|
||||
function buildPartitionWithTemplate(
|
||||
industry: Industry,
|
||||
businessType: BusinessType = '岗位外包',
|
||||
): IndustryPartition {
|
||||
const dimensions: Dimension[] = [
|
||||
buildDimension('d1', 60, [
|
||||
buildIndicator('d1-i1', 70),
|
||||
buildIndicator('d1-i2', 30),
|
||||
]),
|
||||
buildDimension('d2', 40, [buildIndicator('d2-i1', 100)]),
|
||||
];
|
||||
|
||||
const template: Template = {
|
||||
id: `tmpl-${industry}`,
|
||||
name: `${industry}-默认模板`,
|
||||
businessType,
|
||||
industry,
|
||||
isDefault: true,
|
||||
riskModelConfig: {
|
||||
name: `${industry}-风险模型`,
|
||||
businessType,
|
||||
dimensions,
|
||||
redlines: [
|
||||
{
|
||||
id: `rl-${industry}`,
|
||||
triggerCondition: '触发条件',
|
||||
consequence: '一票否决',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
industryId: industry,
|
||||
indicators: dimensions.flatMap((d) => d.indicators),
|
||||
weightTemplates: [{ targetId: 'd1', weight: 60 }],
|
||||
redlines: template.riskModelConfig.redlines,
|
||||
cases: [{ id: 'c1', title: '案例', summary: '要点' }],
|
||||
askPrompts: [{ indicatorId: 'd1-i1', prompt: '请补充' }],
|
||||
templates: [template],
|
||||
};
|
||||
}
|
||||
|
||||
/** 由 Template 的 riskModelConfig 实例化运行时 RiskModel(不涉及评分引擎源码)。 */
|
||||
function instantiateRiskModel(template: Template): RiskModel {
|
||||
return {
|
||||
id: `model-${template.id}`,
|
||||
name: template.riskModelConfig.name,
|
||||
businessType: template.riskModelConfig.businessType,
|
||||
dimensions: template.riskModelConfig.dimensions,
|
||||
redlines: template.riskModelConfig.redlines,
|
||||
};
|
||||
}
|
||||
|
||||
/** 恒定 Risk_Level 解析器(用于端点与一般用例)。 */
|
||||
function constantResolver(level: RiskLevel): RiskLevelResolver {
|
||||
return () => level;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 集成测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('集成:知识库行业分区运行时扩展(Req 14.2)', () => {
|
||||
const administrator: Actor = { id: 'admin-1', role: 'Administrator' };
|
||||
|
||||
it('Administrator 运行时新增行业分区后,其 Template 可被加载', () => {
|
||||
// 既有知识库:仅含一个默认行业分区。
|
||||
const store = new KnowledgeBaseStore([
|
||||
buildPartitionWithTemplate('默认'),
|
||||
]);
|
||||
const sizeBefore = store.size;
|
||||
const newIndustry = '医疗器械'; // 既有配置中不存在的全新行业。
|
||||
expect(store.has(newIndustry)).toBe(false);
|
||||
|
||||
// 运行时新增:仅 Administrator 可执行(RBAC 守卫,Req 12 / 14.2 前置条件)。
|
||||
expect(isAdministrator(administrator.role)).toBe(true);
|
||||
requireRole('Administrator', administrator);
|
||||
store.addPartition(buildPartitionWithTemplate(newIndustry));
|
||||
|
||||
// 新分区及其 Template 可被加载。
|
||||
expect(store.size).toBe(sizeBefore + 1);
|
||||
const partition = store.get(newIndustry);
|
||||
expect(partition).toBeDefined();
|
||||
expect(partition?.templates).toHaveLength(1);
|
||||
const template = partition?.templates[0];
|
||||
expect(template?.industry).toBe(newIndustry);
|
||||
expect(template?.riskModelConfig.dimensions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('未经修改的 Scoring_Engine 直接消费运行时新增 Template 的配置完成评分', () => {
|
||||
const store = new KnowledgeBaseStore();
|
||||
const newIndustry = '跨境电商';
|
||||
requireRole('Administrator', administrator);
|
||||
store.addPartition(buildPartitionWithTemplate(newIndustry));
|
||||
|
||||
const template = store.get(newIndustry)?.templates[0];
|
||||
expect(template).toBeDefined();
|
||||
|
||||
// 关键断言:用原样导入的评分引擎函数消费"运行时新增分区"的 Template 配置,
|
||||
// 无需新增/修改任何评分引擎源码即可得到合法 Risk_Score。
|
||||
const riskModel = instantiateRiskModel(template!);
|
||||
const score = computeRiskScore(riskModel, constantResolver(3));
|
||||
expect(Number.isInteger(score)).toBe(true);
|
||||
expect(score).toBeGreaterThanOrEqual(0);
|
||||
expect(score).toBeLessThanOrEqual(100);
|
||||
|
||||
// 评分项/维度层函数同样可直接消费新分区指标配置。
|
||||
const firstDimension = riskModel.dimensions[0]!;
|
||||
const firstIndicator = firstDimension.indicators[0]!;
|
||||
expect(scoreIndicator(firstIndicator, 4)).toBe(4 * firstIndicator.weight);
|
||||
expect(scoreDimension(firstDimension, constantResolver(2))).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('新增行业 Template 满足评分引擎端点不变式(全1→0,全5→100)', () => {
|
||||
const store = new KnowledgeBaseStore();
|
||||
const newIndustry = '新能源';
|
||||
requireRole('Administrator', administrator);
|
||||
store.addPartition(buildPartitionWithTemplate(newIndustry));
|
||||
|
||||
const riskModel = instantiateRiskModel(store.get(newIndustry)!.templates[0]!);
|
||||
// 端点行为与具体行业/权重无关,证明评分引擎配置驱动、对新行业开箱即用。
|
||||
expect(computeRiskScore(riskModel, constantResolver(1))).toBe(0);
|
||||
expect(computeRiskScore(riskModel, constantResolver(5))).toBe(100);
|
||||
});
|
||||
|
||||
it('多次运行时扩展互不影响,各新增行业 Template 均可独立加载并评分', () => {
|
||||
const store = new KnowledgeBaseStore([buildPartitionWithTemplate('默认')]);
|
||||
const industries: Industry[] = ['制造业', '物流', '金融科技'];
|
||||
|
||||
for (const industry of industries) {
|
||||
requireRole('Administrator', administrator);
|
||||
store.addPartition(buildPartitionWithTemplate(industry));
|
||||
}
|
||||
|
||||
// 既有默认分区 + 三个运行时新增分区。
|
||||
expect(store.size).toBe(1 + industries.length);
|
||||
for (const industry of industries) {
|
||||
const template = store.get(industry)?.templates[0];
|
||||
expect(template?.industry).toBe(industry);
|
||||
const score = computeRiskScore(
|
||||
instantiateRiskModel(template!),
|
||||
constantResolver(3),
|
||||
);
|
||||
expect(score).toBeGreaterThanOrEqual(0);
|
||||
expect(score).toBeLessThanOrEqual(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 行业分区五类必备内容类别(Req 14.1, 14.4)。
|
||||
*
|
||||
* 合法行业分区须同时具备以下全部五类内容;缺任一类即视为不完备,拒绝创建。
|
||||
*/
|
||||
|
||||
import type { IndustryPartition } from '../domain/knowledge.js';
|
||||
|
||||
/**
|
||||
* 行业分区内容类别标识(面向用户的可读名称,用于校验错误中指明缺失类别)。
|
||||
*/
|
||||
export type PartitionCategory =
|
||||
| 'Indicator'
|
||||
| '权重模板'
|
||||
| 'Redline'
|
||||
| '典型案例'
|
||||
| '追问话术';
|
||||
|
||||
/** 五类必备内容类别的全部取值(顺序对齐 Req 14.1 表述)。 */
|
||||
export const PARTITION_CATEGORY_VALUES = [
|
||||
'Indicator',
|
||||
'权重模板',
|
||||
'Redline',
|
||||
'典型案例',
|
||||
'追问话术',
|
||||
] as const satisfies readonly PartitionCategory[];
|
||||
|
||||
/**
|
||||
* 各类别到其在 {@link IndustryPartition} 中承载字段的映射。
|
||||
*
|
||||
* 校验时据此读取对应集合:集合为空(或缺失)即认定该类别缺失。
|
||||
*/
|
||||
const CATEGORY_FIELDS: ReadonlyArray<{
|
||||
readonly category: PartitionCategory;
|
||||
readonly field: keyof IndustryPartition;
|
||||
}> = [
|
||||
{ category: 'Indicator', field: 'indicators' },
|
||||
{ category: '权重模板', field: 'weightTemplates' },
|
||||
{ category: 'Redline', field: 'redlines' },
|
||||
{ category: '典型案例', field: 'cases' },
|
||||
{ category: '追问话术', field: 'askPrompts' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 找出某行业分区缺失的内容类别(Req 14.1, 14.4)。
|
||||
*
|
||||
* 判定规则:对应字段不是数组、或为空数组,即视为该类别缺失。
|
||||
*
|
||||
* @returns 缺失类别列表(顺序对齐 {@link PARTITION_CATEGORY_VALUES});为空表示五类齐备。
|
||||
*/
|
||||
export function findMissingCategories(
|
||||
partition: IndustryPartition,
|
||||
): PartitionCategory[] {
|
||||
const missing: PartitionCategory[] = [];
|
||||
for (const { category, field } of CATEGORY_FIELDS) {
|
||||
const value = partition[field];
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
missing.push(category);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某行业分区是否五类内容齐备(不缺任一类)。
|
||||
*/
|
||||
export function isPartitionComplete(partition: IndustryPartition): boolean {
|
||||
return findMissingCategories(partition).length === 0;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 知识库模块错误类型。
|
||||
*
|
||||
* 错误处理遵循统一原则:输入校验前置、错误可解释、失败不破坏既有状态。
|
||||
*/
|
||||
|
||||
import type { Industry } from '../domain/common.js';
|
||||
import type { PartitionCategory } from './category.js';
|
||||
|
||||
/**
|
||||
* 新增行业分区缺少五类必备内容中的任一类时抛出(Req 14.4)。
|
||||
*
|
||||
* System 据此拒绝创建该行业分区、返回指明缺失内容类别的校验错误,
|
||||
* 并保持 Knowledge_Base 中已有分区不变。
|
||||
*/
|
||||
export class IncompletePartitionError extends Error {
|
||||
/** 触发错误的行业标识。 */
|
||||
readonly industryId: Industry;
|
||||
/** 缺失的内容类别列表(至少一项)。 */
|
||||
readonly missingCategories: readonly PartitionCategory[];
|
||||
/** 面向操作者的可读提示,明确指出缺失的类别。 */
|
||||
readonly userMessage: string;
|
||||
|
||||
constructor(industryId: Industry, missingCategories: readonly PartitionCategory[]) {
|
||||
const categoryList = missingCategories.join('、');
|
||||
const userMessage = `行业分区「${industryId}」缺少必备内容类别:${categoryList},拒绝创建`;
|
||||
super(userMessage);
|
||||
this.name = 'IncompletePartitionError';
|
||||
this.industryId = industryId;
|
||||
this.missingCategories = [...missingCategories];
|
||||
this.userMessage = userMessage;
|
||||
// 维持原型链(编译目标低于 ES2015 时的兼容保障)。
|
||||
Object.setPrototypeOf(this, IncompletePartitionError.prototype);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Knowledge_Base 分行业知识库模块(Req 14)。
|
||||
*
|
||||
* 职责(本任务覆盖 Req 14.1, 14.4):
|
||||
* - 按行业标识分区存储 Indicator、权重模板、Redline、典型案例、追问话术五类内容
|
||||
* ({@link KnowledgeBaseStore})。
|
||||
* - 行业分区五类内容完备性校验({@link findMissingCategories} / {@link isPartitionComplete})。
|
||||
* - 新增分区缺任一类时拒绝创建、返回指明缺失类别的校验错误,且保持已有分区不变
|
||||
* ({@link IncompletePartitionError})。
|
||||
*/
|
||||
|
||||
export * from './category.js';
|
||||
export * from './errors.js';
|
||||
export * from './store.js';
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 分行业知识库存储:按行业标识分区存储五类内容并校验完备性(Req 14.1, 14.4)。
|
||||
*
|
||||
* - 按行业标识分区存储 Indicator、权重模板、Redline、典型案例、追问话术五类内容(Req 14.1)。
|
||||
* - 新增分区缺任一类内容时拒绝创建、抛出指明缺失类别的校验错误,
|
||||
* 且保持已有分区不变(Req 14.4,{@link IncompletePartitionError})。
|
||||
*/
|
||||
|
||||
import type { Industry } from '../domain/common.js';
|
||||
import type { IndustryPartition, KnowledgeBase } from '../domain/knowledge.js';
|
||||
import { findMissingCategories } from './category.js';
|
||||
import { IncompletePartitionError } from './errors.js';
|
||||
|
||||
/** 规范化行业标识用于分区键匹配(首尾空白不敏感)。 */
|
||||
function normalizeIndustryId(industryId: Industry): string {
|
||||
return industryId.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分行业知识库存储(Knowledge_Base,Req 14.1, 14.4)。
|
||||
*
|
||||
* 以行业标识为键登记 {@link IndustryPartition};新增分区前强制完备性校验,
|
||||
* 校验不通过则拒绝并保持已有分区不变。
|
||||
*/
|
||||
export class KnowledgeBaseStore {
|
||||
/** 以规范化行业标识为键的分区表。 */
|
||||
private readonly partitions = new Map<string, IndustryPartition>();
|
||||
|
||||
/**
|
||||
* @param initial 初始登记的行业分区集合(默认空)。
|
||||
* @throws {IncompletePartitionError} 当任一初始分区缺少五类必备内容时。
|
||||
*/
|
||||
constructor(initial: readonly IndustryPartition[] = []) {
|
||||
for (const partition of initial) {
|
||||
this.addPartition(partition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增一个行业分区(Req 14.1, 14.4)。
|
||||
*
|
||||
* 先做五类内容完备性校验:缺任一类则拒绝创建、抛出
|
||||
* {@link IncompletePartitionError}(指明缺失类别),且不修改任何已有分区。
|
||||
* 校验通过后按行业标识登记该分区(同一行业再次新增将覆盖其内容)。
|
||||
*
|
||||
* @throws {IncompletePartitionError} 当该分区缺少五类必备内容中的任一类时。
|
||||
*/
|
||||
addPartition(partition: IndustryPartition): void {
|
||||
const missing = findMissingCategories(partition);
|
||||
if (missing.length > 0) {
|
||||
// 校验失败:拒绝创建,不改动 this.partitions(保持已有分区不变)。
|
||||
throw new IncompletePartitionError(partition.industryId, missing);
|
||||
}
|
||||
this.partitions.set(normalizeIndustryId(partition.industryId), partition);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否存在与给定行业标识匹配的分区。
|
||||
*/
|
||||
has(industryId: Industry): boolean {
|
||||
return this.partitions.has(normalizeIndustryId(industryId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按行业标识获取分区;不存在时返回 undefined。
|
||||
*/
|
||||
get(industryId: Industry): IndustryPartition | undefined {
|
||||
return this.partitions.get(normalizeIndustryId(industryId));
|
||||
}
|
||||
|
||||
/** 已登记分区数量。 */
|
||||
get size(): number {
|
||||
return this.partitions.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出当前知识库的不可变快照(Req 14.1)。
|
||||
*/
|
||||
snapshot(): KnowledgeBase {
|
||||
return { partitions: [...this.partitions.values()] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user