Files
breakpilot-compliance/admin-compliance/lib/sdk/compliance-scope-engine.ts
Sharang Parnerkar 911d872178 refactor(admin): split compliance-scope-engine.ts (1811 LOC) into focused modules
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>
2026-04-10 13:33:51 +02:00

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()