feat: surface risk rationale to operator in approval prompt
Carry the full RiskSummary (level, signals, rationale) inline on ApprovalRequestedEvent instead of discarding it after assessment. The two server-side DTO builders (live ApprovalCoordinator path and replay DomainEventMapper path) now render real risk level, recommended action, signal factors, and the [code] rationale lines into the approval WebSocket message, with a consistent enum-name fallback when no assessment ran (tool path). RiskSummary/RiskSignal made @Serializable for event embedding; shared RiskSummary.toDto() lives next to RiskSummaryDto.
This commit is contained in:
@@ -4,6 +4,7 @@ import com.correx.apps.server.protocol.ClientMessage
|
||||
import com.correx.apps.server.protocol.ProtocolSerializer
|
||||
import com.correx.apps.server.protocol.RiskSummaryDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.protocol.toDto
|
||||
import com.correx.core.approvals.ApprovalOutcome
|
||||
import com.correx.core.approvals.ApprovalStatus
|
||||
import com.correx.core.approvals.Tier
|
||||
@@ -11,6 +12,7 @@ import com.correx.core.approvals.model.ApprovalContext
|
||||
import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.config.ApprovalConfig
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
@@ -60,10 +62,11 @@ open class ApprovalCoordinator(
|
||||
sessionId = event.sessionId,
|
||||
requestId = event.requestId,
|
||||
tier = event.tier,
|
||||
riskSummary = RiskSummaryDto(
|
||||
riskSummary = event.riskSummary?.toDto() ?: RiskSummaryDto(
|
||||
level = event.tier.name,
|
||||
factors = emptyList(),
|
||||
recommendedAction = "Review and approve or reject",
|
||||
recommendedAction = RiskAction.PROMPT_USER.name,
|
||||
rationale = emptyList(),
|
||||
),
|
||||
toolName = event.toolName,
|
||||
preview = event.preview,
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.correx.apps.server.bridge
|
||||
import com.correx.apps.server.protocol.PauseReason
|
||||
import com.correx.apps.server.protocol.RiskSummaryDto
|
||||
import com.correx.apps.server.protocol.ServerMessage
|
||||
import com.correx.apps.server.protocol.toDto
|
||||
import com.correx.core.artifactstore.ArtifactStore
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
@@ -23,6 +24,7 @@ import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||
import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
@@ -219,7 +221,12 @@ private fun mapApprovalRequested(
|
||||
sessionId = p.sessionId,
|
||||
requestId = p.requestId,
|
||||
tier = p.tier,
|
||||
riskSummary = RiskSummaryDto("unknown", emptyList(), ""),
|
||||
riskSummary = p.riskSummary?.toDto() ?: RiskSummaryDto(
|
||||
level = p.tier.name,
|
||||
factors = emptyList(),
|
||||
recommendedAction = RiskAction.PROMPT_USER.name,
|
||||
rationale = emptyList(),
|
||||
),
|
||||
toolName = p.toolName,
|
||||
preview = p.preview,
|
||||
sequence = seq,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.correx.apps.server.protocol
|
||||
|
||||
import com.correx.core.events.risk.RiskSignal
|
||||
import com.correx.core.events.risk.RiskSummary
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@@ -7,6 +9,7 @@ data class RiskSummaryDto(
|
||||
val level: String,
|
||||
val factors: List<String>,
|
||||
val recommendedAction: String,
|
||||
val rationale: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -75,3 +78,17 @@ data class WorkflowDto(
|
||||
val workflowId: String,
|
||||
val description: String,
|
||||
)
|
||||
|
||||
internal fun RiskSummary.toDto(): RiskSummaryDto = RiskSummaryDto(
|
||||
level = level.name,
|
||||
factors = signals.map { signal ->
|
||||
when (signal) {
|
||||
is RiskSignal.CycleWithoutExit -> "Cycle without exit: ${signal.cycleId}"
|
||||
is RiskSignal.RepeatedFailure -> "Repeated failure: ${signal.reason} (${signal.count}×)"
|
||||
is RiskSignal.ValidationErrors -> "Validation errors: ${signal.errorCount}"
|
||||
is RiskSignal.InferenceTimeout -> "Inference timeout: ${signal.elapsedMs}ms"
|
||||
}
|
||||
},
|
||||
recommendedAction = recommendedAction.name,
|
||||
rationale = rationale,
|
||||
)
|
||||
|
||||
+28
@@ -12,6 +12,10 @@ import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.StoredEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.risk.RiskLevel
|
||||
import com.correx.core.events.risk.RiskSignal
|
||||
import com.correx.core.events.risk.RiskSummary
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.RiskSummaryId
|
||||
@@ -207,4 +211,28 @@ class ApprovalCoordinatorWiringTest {
|
||||
coord.handleResponse(msg, otherSessionId)
|
||||
assertNull(coord.lookupSession(requestId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onApprovalRequested with non-null riskSummary registers session without error`(): Unit = runBlocking {
|
||||
val gateway = RecordingGateway()
|
||||
val coord = ApprovalCoordinator(gateway, ApprovalConfig(timeoutMs = 300_000L), scope)
|
||||
val riskSummary = RiskSummary(
|
||||
level = RiskLevel.HIGH,
|
||||
signals = listOf(RiskSignal.ValidationErrors(errorCount = 2)),
|
||||
recommendedAction = RiskAction.PROMPT_USER,
|
||||
rationale = listOf("[ERR001] Schema mismatch"),
|
||||
)
|
||||
val event = ApprovalRequestedEvent(
|
||||
requestId = requestId,
|
||||
tier = Tier.T3,
|
||||
validationReportId = ValidationReportId("vr-1"),
|
||||
riskSummaryId = null,
|
||||
riskSummary = riskSummary,
|
||||
sessionId = sessionId,
|
||||
stageId = null,
|
||||
projectId = null,
|
||||
)
|
||||
coord.onApprovalRequested(event)
|
||||
assertEquals(sessionId, coord.lookupSession(requestId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ import com.correx.core.events.events.TransitionExecutedEvent
|
||||
import com.correx.core.events.events.WorkflowCompletedEvent
|
||||
import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
import com.correx.core.events.risk.RiskLevel
|
||||
import com.correx.core.events.risk.RiskSignal
|
||||
import com.correx.core.events.risk.RiskSummary
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.EventId
|
||||
@@ -348,7 +352,7 @@ class DomainEventMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ApprovalRequestedEvent maps to ApprovalRequired`(): Unit = runTest {
|
||||
fun `ApprovalRequestedEvent maps to ApprovalRequired with null riskSummary falls back to tier name`(): Unit = runTest {
|
||||
val requestId = ApprovalRequestId("req-1")
|
||||
val event = storedEvent(
|
||||
ApprovalRequestedEvent(
|
||||
@@ -356,6 +360,7 @@ class DomainEventMapperTest {
|
||||
tier = Tier.T3,
|
||||
validationReportId = ValidationReportId("vr-1"),
|
||||
riskSummaryId = null,
|
||||
riskSummary = null,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
projectId = null,
|
||||
@@ -367,6 +372,42 @@ class DomainEventMapperTest {
|
||||
assertNull(result.toolName)
|
||||
assertNull(result.preview)
|
||||
assertEquals(event.sequence, result.sequence)
|
||||
assertEquals("T3", result.riskSummary.level)
|
||||
assertEquals(emptyList<String>(), result.riskSummary.rationale)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ApprovalRequestedEvent maps to ApprovalRequired with riskSummary propagates rationale`(): Unit = runTest {
|
||||
val requestId = ApprovalRequestId("req-2")
|
||||
val riskSummary = RiskSummary(
|
||||
level = RiskLevel.HIGH,
|
||||
signals = listOf(
|
||||
RiskSignal.ValidationErrors(errorCount = 3),
|
||||
RiskSignal.RepeatedFailure(reason = "parse error", count = 2),
|
||||
),
|
||||
recommendedAction = RiskAction.PROMPT_USER,
|
||||
rationale = listOf("[ERR001] Schema mismatch", "[ERR002] Missing required field"),
|
||||
)
|
||||
val event = storedEvent(
|
||||
ApprovalRequestedEvent(
|
||||
requestId = requestId,
|
||||
tier = Tier.T3,
|
||||
validationReportId = ValidationReportId("vr-2"),
|
||||
riskSummaryId = null,
|
||||
riskSummary = riskSummary,
|
||||
sessionId = sessionId,
|
||||
stageId = stageId,
|
||||
projectId = null,
|
||||
),
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L) as ServerMessage.ApprovalRequired
|
||||
assertEquals(requestId, result.requestId)
|
||||
assertEquals("HIGH", result.riskSummary.level)
|
||||
assertEquals("PROMPT_USER", result.riskSummary.recommendedAction)
|
||||
assertEquals(listOf("[ERR001] Schema mismatch", "[ERR002] Missing required field"), result.riskSummary.rationale)
|
||||
assertEquals(2, result.riskSummary.factors.size)
|
||||
assertTrue(result.riskSummary.factors[0].contains("Validation errors"))
|
||||
assertTrue(result.riskSummary.factors[1].contains("Repeated failure"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user