diff --git a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt index 038e45ad..2af5e826 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/risk/RiskSummary.kt @@ -4,4 +4,5 @@ data class RiskSummary( val level: RiskLevel, val signals: List, val recommendedAction: RiskAction, + val rationale: List = emptyList(), ) diff --git a/core/risk/src/main/kotlin/com/correx/core/risk/DefaultRiskAssessor.kt b/core/risk/src/main/kotlin/com/correx/core/risk/DefaultRiskAssessor.kt index 02aea6cc..b3632548 100644 --- a/core/risk/src/main/kotlin/com/correx/core/risk/DefaultRiskAssessor.kt +++ b/core/risk/src/main/kotlin/com/correx/core/risk/DefaultRiskAssessor.kt @@ -11,15 +11,14 @@ class DefaultRiskAssessor : RiskAssessor { val (report, state, inferenceState) = context val signals = mutableListOf() - val errorCount = report.sections.sumOf { s -> - s.issues.count { it.severity == ValidationSeverity.ERROR } - } + val allIssues = report.sections.flatMap { it.issues } + + val errorCount = allIssues.count { it.severity == ValidationSeverity.ERROR } if (errorCount > 0) { signals += RiskSignal.ValidationErrors(errorCount) } - report.sections.flatMap { it.issues } - .firstOrNull { it.code.contains("CYCLE", ignoreCase = true) } + allIssues.firstOrNull { it.code.contains("CYCLE", ignoreCase = true) } ?.let { signals += RiskSignal.CycleWithoutExit(it.code) } state.retryPolicy?.let { policy -> @@ -37,12 +36,23 @@ class DefaultRiskAssessor : RiskAssessor { ?.let { signals += RiskSignal.InferenceTimeout(it.latencyMs ?: 0L) } } - val level = signals.fold(RiskLevel.LOW) { acc, signal -> maxOf(acc, signal.toRiskLevel()) } + val validationDrivenLevel = when { + allIssues.any { it.severity == ValidationSeverity.ERROR } -> RiskLevel.HIGH + allIssues.any { it.severity == ValidationSeverity.WARNING } -> RiskLevel.MEDIUM + allIssues.any { it.severity == ValidationSeverity.INFO } -> RiskLevel.LOW + else -> RiskLevel.LOW + } + + var level = signals.fold(RiskLevel.LOW) { acc, signal -> maxOf(acc, signal.toRiskLevel()) } + level = maxOf(level, validationDrivenLevel) + + val rationale = allIssues.map { "[${it.code}] ${it.message}" } return RiskSummary( level = level, signals = signals, recommendedAction = level.toRiskAction(), + rationale = rationale, ) } } diff --git a/core/risk/src/main/kotlin/com/correx/core/risk/NoOpRiskAssessor.kt b/core/risk/src/main/kotlin/com/correx/core/risk/NoOpRiskAssessor.kt index 9fb0f911..0457b356 100644 --- a/core/risk/src/main/kotlin/com/correx/core/risk/NoOpRiskAssessor.kt +++ b/core/risk/src/main/kotlin/com/correx/core/risk/NoOpRiskAssessor.kt @@ -14,5 +14,6 @@ class NoOpRiskAssessor : RiskAssessor { level = RiskLevel.LOW, signals = emptyList(), recommendedAction = RiskAction.PROCEED, + rationale = emptyList(), ) } diff --git a/core/risk/src/main/kotlin/com/correx/core/risk/TierMapping.kt b/core/risk/src/main/kotlin/com/correx/core/risk/TierMapping.kt index cb42fbdb..080c7460 100644 --- a/core/risk/src/main/kotlin/com/correx/core/risk/TierMapping.kt +++ b/core/risk/src/main/kotlin/com/correx/core/risk/TierMapping.kt @@ -20,7 +20,7 @@ internal fun RiskLevel.toRiskAction(): RiskAction = when (this) { } internal fun RiskSignal.toRiskLevel(): RiskLevel = when (this) { - is RiskSignal.ValidationErrors -> RiskLevel.MEDIUM + is RiskSignal.ValidationErrors -> RiskLevel.HIGH is RiskSignal.CycleWithoutExit -> RiskLevel.MEDIUM is RiskSignal.InferenceTimeout -> RiskLevel.MEDIUM is RiskSignal.RepeatedFailure -> RiskLevel.HIGH diff --git a/core/risk/src/test/kotlin/com/correx/core/risk/DefaultRiskAssessorTest.kt b/core/risk/src/test/kotlin/com/correx/core/risk/DefaultRiskAssessorTest.kt index 57d0c51f..9121aefe 100644 --- a/core/risk/src/test/kotlin/com/correx/core/risk/DefaultRiskAssessorTest.kt +++ b/core/risk/src/test/kotlin/com/correx/core/risk/DefaultRiskAssessorTest.kt @@ -51,7 +51,7 @@ class DefaultRiskAssessorTest { } @Test - fun `validation errors produce ValidationErrors signal at MEDIUM`() { + fun `validation errors produce ValidationErrors signal at HIGH`() { val report = ValidationReport( sections = listOf( ValidationSection( @@ -68,12 +68,32 @@ class DefaultRiskAssessorTest { val summary = assessor.assess(ctx(report = report)) - assertEquals(RiskLevel.MEDIUM, summary.level) + assertEquals(RiskLevel.HIGH, summary.level) assertEquals(RiskAction.PROMPT_USER, summary.recommendedAction) val signal = summary.signals.filterIsInstance().single() assertEquals(2, signal.errorCount) } + @Test + fun `validation warnings produce MEDIUM risk when no errors present`() { + val report = ValidationReport( + sections = listOf( + ValidationSection( + name = "schema", + issues = listOf( + ValidationIssue(code = "MISSING_CONTEXT", message = "context incomplete", + severity = ValidationSeverity.WARNING), + ), + ), + ), + ) + + val summary = assessor.assess(ctx(report = report)) + + assertEquals(RiskLevel.MEDIUM, summary.level) + assertEquals(RiskAction.PROMPT_USER, summary.recommendedAction) + } + @Test fun `cycle issue in validation report produces CycleWithoutExit signal at MEDIUM`() { val report = ValidationReport( @@ -172,6 +192,38 @@ class DefaultRiskAssessorTest { assertTrue(summary.signals.filterIsInstance().isEmpty()) } + @Test + fun `rationale contains formatted issue codes and messages`() { + val report = ValidationReport( + sections = listOf( + ValidationSection( + name = "schema", + issues = listOf( + ValidationIssue(code = "MISSING_FIELD", message = "field required", + severity = ValidationSeverity.ERROR), + ValidationIssue(code = "INVALID_TYPE", message = "type mismatch", + severity = ValidationSeverity.WARNING), + ), + ), + ), + ) + + val summary = assessor.assess(ctx(report = report)) + + assertTrue(summary.rationale.contains("[MISSING_FIELD] field required")) + assertTrue(summary.rationale.contains("[INVALID_TYPE] type mismatch")) + assertEquals(2, summary.rationale.size) + } + + @Test + fun `empty validation report yields empty rationale`() { + val report = ValidationReport(sections = emptyList()) + + val summary = assessor.assess(ctx(report = report)) + + assertTrue(summary.rationale.isEmpty()) + } + @Test fun `multiple signals fold to highest risk level`() { val report = ValidationReport(