Extract data constants and document-scope logic from the monolithic engine: - compliance-scope-data.ts (133 LOC): score weights + answer multipliers - compliance-scope-triggers.ts (823 LOC): 50 hard trigger rules (data table) - compliance-scope-documents.ts (497 LOC): document scope, risk flags, gaps, actions, reasoning - compliance-scope-engine.ts (406 LOC): core class with scoring + trigger evaluation All logic files stay under the 500 LOC cap. The triggers file exceeds it as a pure declarative data table with no logic. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
import type {
|
|
ComplianceDepthLevel,
|
|
ComplianceScores,
|
|
ScopeProfilingAnswer,
|
|
ScopeDecision,
|
|
HardTriggerRule,
|
|
TriggeredHardTrigger,
|
|
RequiredDocument,
|
|
RiskFlag,
|
|
ScopeGap,
|
|
NextAction,
|
|
ScopeReasoning,
|
|
} from './compliance-scope-types'
|
|
import type { CompanyProfile, MachineBuilderProfile } from './types'
|
|
import {
|
|
getDepthLevelNumeric,
|
|
maxDepthLevel,
|
|
createEmptyScopeDecision,
|
|
} from './compliance-scope-types'
|
|
|
|
// Re-export data constants so existing barrel imports keep working
|
|
export { QUESTION_SCORE_WEIGHTS, ANSWER_MULTIPLIERS } from './compliance-scope-data'
|
|
export { HARD_TRIGGER_RULES } from './compliance-scope-triggers'
|
|
|
|
import { QUESTION_SCORE_WEIGHTS, ANSWER_MULTIPLIERS } from './compliance-scope-data'
|
|
import { HARD_TRIGGER_RULES } from './compliance-scope-triggers'
|
|
import {
|
|
parseEmployeeCount,
|
|
getLevelFromScore,
|
|
getMaxTriggerLevel,
|
|
buildDocumentScope,
|
|
evaluateRiskFlags,
|
|
calculateGaps,
|
|
buildNextActions,
|
|
buildReasoning,
|
|
} from './compliance-scope-documents'
|
|
|
|
// ============================================================================
|
|
// COMPLIANCE SCOPE ENGINE
|
|
// ============================================================================
|
|
|
|
export class ComplianceScopeEngine {
|
|
/**
|
|
* Haupteinstiegspunkt: Evaluiert alle Profiling-Antworten und produziert eine ScopeDecision
|
|
* Optional: companyProfile fuer machineBuilder-basierte IACE Triggers
|
|
*/
|
|
evaluate(answers: ScopeProfilingAnswer[], companyProfile?: CompanyProfile | null): ScopeDecision {
|
|
const decision = createEmptyScopeDecision()
|
|
|
|
// 1. Scores berechnen
|
|
decision.scores = this.calculateScores(answers)
|
|
|
|
// 2. Hard Triggers prüfen (inkl. IACE machineBuilder Triggers)
|
|
decision.triggeredHardTriggers = this.evaluateHardTriggers(answers, companyProfile)
|
|
|
|
// 3. Finales Level bestimmen
|
|
decision.determinedLevel = this.determineLevel(
|
|
decision.scores,
|
|
decision.triggeredHardTriggers
|
|
)
|
|
|
|
// 4. Dokumenten-Scope aufbauen
|
|
decision.requiredDocuments = this.buildDocumentScope(
|
|
decision.determinedLevel,
|
|
decision.triggeredHardTriggers,
|
|
answers
|
|
)
|
|
|
|
// 5. Risk Flags ermitteln
|
|
decision.riskFlags = this.evaluateRiskFlags(answers, decision.determinedLevel)
|
|
|
|
// 6. Gaps berechnen
|
|
decision.gaps = this.calculateGaps(answers, decision.determinedLevel)
|
|
|
|
// 7. Next Actions ableiten
|
|
decision.nextActions = this.buildNextActions(
|
|
decision.requiredDocuments,
|
|
decision.gaps
|
|
)
|
|
|
|
// 8. Reasoning (Audit Trail) aufbauen
|
|
decision.reasoning = this.buildReasoning(
|
|
decision.scores,
|
|
decision.triggeredHardTriggers,
|
|
decision.determinedLevel,
|
|
decision.requiredDocuments
|
|
)
|
|
|
|
decision.evaluatedAt = new Date().toISOString()
|
|
|
|
return decision
|
|
}
|
|
|
|
/**
|
|
* Berechnet Risk-, Complexity- und Assurance-Scores aus den Profiling-Antworten
|
|
*/
|
|
calculateScores(answers: ScopeProfilingAnswer[]): ComplianceScores {
|
|
let riskSum = 0
|
|
let complexitySum = 0
|
|
let assuranceSum = 0
|
|
let riskWeightSum = 0
|
|
let complexityWeightSum = 0
|
|
let assuranceWeightSum = 0
|
|
|
|
for (const answer of answers) {
|
|
const weights = QUESTION_SCORE_WEIGHTS[answer.questionId]
|
|
if (!weights) continue
|
|
|
|
const multiplier = this.getAnswerMultiplier(answer)
|
|
|
|
riskSum += weights.risk * multiplier
|
|
complexitySum += weights.complexity * multiplier
|
|
assuranceSum += weights.assurance * multiplier
|
|
|
|
riskWeightSum += weights.risk
|
|
complexityWeightSum += weights.complexity
|
|
assuranceWeightSum += weights.assurance
|
|
}
|
|
|
|
const riskScore =
|
|
riskWeightSum > 0 ? (riskSum / riskWeightSum) * 10 : 0
|
|
const complexityScore =
|
|
complexityWeightSum > 0 ? (complexitySum / complexityWeightSum) * 10 : 0
|
|
const assuranceScore =
|
|
assuranceWeightSum > 0 ? (assuranceSum / assuranceWeightSum) * 10 : 0
|
|
|
|
const composite = riskScore * 0.4 + complexityScore * 0.3 + assuranceScore * 0.3
|
|
|
|
return {
|
|
risk_score: Math.round(riskScore * 10) / 10,
|
|
complexity_score: Math.round(complexityScore * 10) / 10,
|
|
assurance_need: Math.round(assuranceScore * 10) / 10,
|
|
composite_score: Math.round(composite * 10) / 10,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bestimmt den Multiplikator für eine Antwort (0.0 - 1.0)
|
|
*/
|
|
private getAnswerMultiplier(answer: ScopeProfilingAnswer): number {
|
|
const { questionId, value } = answer
|
|
|
|
// Boolean
|
|
if (typeof value === 'boolean') {
|
|
return value ? 1.0 : 0.0
|
|
}
|
|
|
|
// Number
|
|
if (typeof value === 'number') {
|
|
return this.normalizeNumericAnswer(questionId, value)
|
|
}
|
|
|
|
// Single choice
|
|
if (typeof value === 'string') {
|
|
const multipliers = ANSWER_MULTIPLIERS[questionId]
|
|
if (multipliers && multipliers[value] !== undefined) {
|
|
return multipliers[value]
|
|
}
|
|
return 0.5 // Fallback
|
|
}
|
|
|
|
// Multi choice
|
|
if (Array.isArray(value)) {
|
|
if (value.length === 0) return 0.0
|
|
return Math.min(value.length / 5, 1.0)
|
|
}
|
|
|
|
return 0.0
|
|
}
|
|
|
|
/**
|
|
* Normalisiert numerische Antworten
|
|
*/
|
|
private normalizeNumericAnswer(_questionId: string, value: number): number {
|
|
if (value <= 0) return 0.0
|
|
if (value >= 1000) return 1.0
|
|
return Math.log10(value + 1) / 3
|
|
}
|
|
|
|
/**
|
|
* Evaluiert Hard Trigger Rules
|
|
* Optional: companyProfile fuer machineBuilder-basierte IACE Triggers
|
|
*/
|
|
evaluateHardTriggers(answers: ScopeProfilingAnswer[], companyProfile?: CompanyProfile | null): TriggeredHardTrigger[] {
|
|
const triggered: TriggeredHardTrigger[] = []
|
|
const answerMap = new Map(answers.map((a) => [a.questionId, a.value]))
|
|
|
|
for (const rule of HARD_TRIGGER_RULES) {
|
|
const isTriggered = this.checkTriggerCondition(rule, answerMap, answers, companyProfile)
|
|
|
|
if (isTriggered) {
|
|
triggered.push({
|
|
ruleId: rule.id,
|
|
category: rule.category,
|
|
description: rule.description,
|
|
legalReference: rule.legalReference,
|
|
minimumLevel: rule.minimumLevel,
|
|
requiresDSFA: rule.requiresDSFA,
|
|
mandatoryDocuments: rule.mandatoryDocuments,
|
|
})
|
|
}
|
|
}
|
|
|
|
return triggered
|
|
}
|
|
|
|
/**
|
|
* Liest einen Wert aus dem MachineBuilderProfile anhand eines Feldnamens
|
|
*/
|
|
private getMachineBuilderValue(mb: MachineBuilderProfile, field: string): unknown {
|
|
return (mb as Record<string, unknown>)[field]
|
|
}
|
|
|
|
/**
|
|
* Prüft, ob eine Trigger-Regel erfüllt ist
|
|
*/
|
|
checkTriggerCondition(
|
|
rule: HardTriggerRule,
|
|
answerMap: Map<string, any>,
|
|
answers: ScopeProfilingAnswer[],
|
|
companyProfile?: CompanyProfile | null,
|
|
): boolean {
|
|
// IACE machineBuilder-basierte Triggers
|
|
if (rule.questionId.startsWith('machineBuilder.')) {
|
|
const mb = companyProfile?.machineBuilder
|
|
if (!mb) return false
|
|
|
|
const fieldName = rule.questionId.replace('machineBuilder.', '')
|
|
const fieldValue = this.getMachineBuilderValue(mb, fieldName)
|
|
if (fieldValue === undefined) return false
|
|
|
|
let baseCondition = false
|
|
switch (rule.condition) {
|
|
case 'EQUALS':
|
|
baseCondition = fieldValue === rule.conditionValue
|
|
break
|
|
case 'CONTAINS':
|
|
if (Array.isArray(fieldValue)) {
|
|
baseCondition = fieldValue.includes(rule.conditionValue)
|
|
}
|
|
break
|
|
default:
|
|
baseCondition = fieldValue === rule.conditionValue
|
|
}
|
|
|
|
if (!baseCondition) return false
|
|
|
|
const combine = (rule as any).combineWithMachineBuilder
|
|
if (combine) {
|
|
const combineVal = this.getMachineBuilderValue(mb, combine.field)
|
|
if (combine.value !== undefined && combineVal !== combine.value) return false
|
|
if (combine.includes !== undefined) {
|
|
if (!Array.isArray(combineVal) || !combineVal.includes(combine.includes)) return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// Standard answer-based triggers
|
|
const value = answerMap.get(rule.questionId)
|
|
if (value === undefined) return false
|
|
|
|
let baseCondition = false
|
|
|
|
switch (rule.condition) {
|
|
case 'EQUALS':
|
|
baseCondition = value === rule.conditionValue
|
|
break
|
|
case 'CONTAINS':
|
|
if (Array.isArray(value)) {
|
|
baseCondition = value.includes(rule.conditionValue)
|
|
} else if (typeof value === 'string') {
|
|
baseCondition = value.includes(rule.conditionValue)
|
|
}
|
|
break
|
|
case 'IN':
|
|
if (Array.isArray(rule.conditionValue)) {
|
|
baseCondition = rule.conditionValue.includes(value)
|
|
}
|
|
break
|
|
case 'GREATER_THAN':
|
|
if (typeof value === 'number' && typeof rule.conditionValue === 'number') {
|
|
baseCondition = value > rule.conditionValue
|
|
} else if (typeof value === 'string') {
|
|
const parsed = parseEmployeeCount(value)
|
|
baseCondition = parsed > (rule.conditionValue as number)
|
|
}
|
|
break
|
|
case 'NOT_EQUALS':
|
|
baseCondition = value !== rule.conditionValue
|
|
break
|
|
}
|
|
|
|
if (!baseCondition) return false
|
|
|
|
// Exclude-Bedingung
|
|
if (rule.excludeWhen) {
|
|
const exVal = answerMap.get(rule.excludeWhen.questionId)
|
|
if (Array.isArray(rule.excludeWhen.value)
|
|
? rule.excludeWhen.value.includes(exVal)
|
|
: exVal === rule.excludeWhen.value) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Require-Bedingung
|
|
if (rule.requireWhen) {
|
|
const reqVal = answerMap.get(rule.requireWhen.questionId)
|
|
if (Array.isArray(rule.requireWhen.value)
|
|
? !rule.requireWhen.value.includes(reqVal)
|
|
: reqVal !== rule.requireWhen.value) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Combined checks
|
|
if (rule.combineWithArt9) {
|
|
const art9 = answerMap.get('data_art9')
|
|
if (!art9 || (Array.isArray(art9) && art9.length === 0)) return false
|
|
}
|
|
|
|
if (rule.combineWithMinors) {
|
|
const minors = answerMap.get('data_minors')
|
|
if (minors !== true) return false
|
|
}
|
|
|
|
if (rule.combineWithAI) {
|
|
const ai = answerMap.get('proc_ai_usage')
|
|
if (!ai || (Array.isArray(ai) && (ai.length === 0 || ai.includes('keine')))) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if (rule.combineWithEmployeeMonitoring) {
|
|
const empMon = answerMap.get('proc_employee_monitoring')
|
|
if (empMon !== true) return false
|
|
}
|
|
|
|
if (rule.combineWithADM) {
|
|
const adm = answerMap.get('proc_adm_scoring')
|
|
if (adm !== true) return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Bestimmt das finale Compliance-Level basierend auf Scores und Triggers
|
|
*/
|
|
determineLevel(
|
|
scores: ComplianceScores,
|
|
triggers: TriggeredHardTrigger[]
|
|
): ComplianceDepthLevel {
|
|
const levelFromScore = getLevelFromScore(scores.composite_score)
|
|
const maxTriggerLevel = getMaxTriggerLevel(triggers)
|
|
return maxDepthLevel(levelFromScore, maxTriggerLevel)
|
|
}
|
|
|
|
// Delegate to extracted helpers (keep public API surface identical)
|
|
|
|
buildDocumentScope(
|
|
level: ComplianceDepthLevel,
|
|
triggers: TriggeredHardTrigger[],
|
|
answers: ScopeProfilingAnswer[]
|
|
): RequiredDocument[] {
|
|
return buildDocumentScope(level, triggers, answers)
|
|
}
|
|
|
|
evaluateRiskFlags(
|
|
answers: ScopeProfilingAnswer[],
|
|
level: ComplianceDepthLevel
|
|
): RiskFlag[] {
|
|
return evaluateRiskFlags(answers, level, (rule, answerMap, ans) =>
|
|
this.checkTriggerCondition(rule, answerMap, ans))
|
|
}
|
|
|
|
calculateGaps(
|
|
answers: ScopeProfilingAnswer[],
|
|
level: ComplianceDepthLevel
|
|
): ScopeGap[] {
|
|
return calculateGaps(answers, level)
|
|
}
|
|
|
|
buildNextActions(
|
|
requiredDocuments: RequiredDocument[],
|
|
gaps: ScopeGap[]
|
|
): NextAction[] {
|
|
return buildNextActions(requiredDocuments, gaps)
|
|
}
|
|
|
|
buildReasoning(
|
|
scores: ComplianceScores,
|
|
triggers: TriggeredHardTrigger[],
|
|
level: ComplianceDepthLevel,
|
|
docs: RequiredDocument[]
|
|
): ScopeReasoning[] {
|
|
return buildReasoning(scores, triggers, level, docs)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// SINGLETON EXPORT
|
|
// ============================================================================
|
|
|
|
export const complianceScopeEngine = new ComplianceScopeEngine()
|