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

- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+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,126 @@
/**
* Property 3: 低置信触发候选确认 的属性化测试(ClassifierReq 1.4, 1.5)。
*
* 属性陈述:对任意分类结果,当某判定的 Confidence 低于 0.6(行业判定附加条件:行业标记
* 不为"未识别")时,System 必返回按 Confidence 降序排列、数量至多 3 项的候选列表并置
* 确认标志为真;否则不触发确认。
*
* 本测试以智能生成器构造任意有效项目描述(有效字符数 ≥ 10 且非纯空白,满足 Req 1.6
* 前置以避免 InsufficientInputError),其中混入业务类型与行业关键词片段,使识别打分
* 真实跨越"无命中 / 部分命中 / 多类命中"的输入空间,从而覆盖高/低置信两类分支。
*
* 对每个分类结果断言:
* 业务类型(Req 1.4):
* - 置信度 < 0.6 → needsBusinessTypeConfirm 为真,候选非空、≤3 项且按置信度降序;
* - 否则 needsBusinessTypeConfirm 为假,候选列表为空。
* 行业(Req 1.5):
* - 置信度 < 0.6 且行业 ≠ "未识别" → needsIndustryConfirm 为真,候选非空、≤3 项且降序;
* - 否则 needsIndustryConfirm 为假,候选列表为空。
*
* Feature: outsourcing-risk-assessment, Property 3: 低置信触发候选确认
* Validates: Requirements 1.4, 1.5
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
classify,
countValidChars,
CONFIRMATION_CONFIDENCE_THRESHOLD,
MAX_CANDIDATES,
type ScoredCandidate,
} from '../classifier.js';
import { BUSINESS_TYPE_KEYWORDS, INDUSTRY_KEYWORDS } from '../keywords.js';
import { INDUSTRY_UNRECOGNIZED } from '../../domain/common.js';
// ----------------------------------------------------------------------------
// 生成器:构造任意有效项目描述。
//
// 输入空间设计:
// - 关键词片段池汇集全部业务类型与行业关键词,使描述高概率命中识别词典,
// 覆盖单类命中、多类命中、跨业务/行业混合命中等分支(驱动高/低置信两类结果)。
// - 随机自由文本片段引入噪声与无命中情形(行业可能落到"未识别",业务类型可能低置信)。
// - 末尾补足填充字符,保证有效字符数 ≥ 10(满足 Req 1.6 前置,避免抛错)。
// ----------------------------------------------------------------------------
/** 全部识别关键词词条,作为可拼接的描述片段池。 */
const KEYWORD_TERMS: string[] = [
...Object.values(BUSINESS_TYPE_KEYWORDS).flatMap((kws) => kws.map((k) => k.term)),
...Object.values(INDUSTRY_KEYWORDS).flatMap((kws) => kws.map((k) => k.term)),
];
/** 片段:关键词词条或任意自由文本(含中英文与空白)。 */
const fragmentArb: fc.Arbitrary<string> = fc.oneof(
fc.constantFrom(...KEYWORD_TERMS),
fc.string({ minLength: 0, maxLength: 12 }),
fc.constantFrom('外包', '用工', '服务', '项目', '客户', '的', '', '。', ' '),
);
/**
* 有效项目描述:拼接若干片段后补足填充字符使有效字符数 ≥ 10。
* 保证生成的描述恒为合法输入(不触发 InsufficientInputError)。
*/
const validDescriptionArb: fc.Arbitrary<string> = fc
.array(fragmentArb, { minLength: 1, maxLength: 8 })
.map((fragments) => {
let desc = fragments.join('');
while (countValidChars(desc) < 10) {
desc += '项';
}
return desc;
});
/** 断言候选列表按置信度由高到低排序(允许相等)。 */
function expectSortedDesc<T extends string>(
candidates: ScoredCandidate<T>[],
): void {
for (let i = 1; i < candidates.length; i++) {
expect(candidates[i - 1]!.confidence).toBeGreaterThanOrEqual(
candidates[i]!.confidence,
);
}
}
describe('Property 3: 低置信触发候选确认 (Req 1.4, 1.5)', () => {
it('低置信判定返回降序、≤3 项候选并置确认标志;否则不触发确认', () => {
fc.assert(
fc.property(validDescriptionArb, (description) => {
const result = classify(description);
// --- 业务类型(Req 1.4---
const businessLow =
result.businessTypeConfidence < CONFIRMATION_CONFIDENCE_THRESHOLD;
expect(result.needsBusinessTypeConfirm).toBe(businessLow);
if (businessLow) {
// 触发确认:候选非空、至多 3 项、按置信度降序。
expect(result.businessTypeCandidates.length).toBeGreaterThan(0);
expect(result.businessTypeCandidates.length).toBeLessThanOrEqual(
MAX_CANDIDATES,
);
expectSortedDesc(result.businessTypeCandidates);
} else {
// 不触发确认:候选列表为空。
expect(result.businessTypeCandidates).toHaveLength(0);
}
// --- 行业(Req 1.5---
const industryLow =
result.industry !== INDUSTRY_UNRECOGNIZED &&
result.industryConfidence < CONFIRMATION_CONFIDENCE_THRESHOLD;
expect(result.needsIndustryConfirm).toBe(industryLow);
if (industryLow) {
// 触发确认:候选非空、至多 3 项、按置信度降序。
expect(result.industryCandidates.length).toBeGreaterThan(0);
expect(result.industryCandidates.length).toBeLessThanOrEqual(
MAX_CANDIDATES,
);
expectSortedDesc(result.industryCandidates);
} else {
// 不触发确认(含行业为"未识别"或高置信):候选列表为空。
expect(result.industryCandidates).toHaveLength(0);
}
}),
{ numRuns: 100 },
);
});
});