feat: event-source router CHAT and STEERING conversation turns
Adds ChatTurnEvent (role USER|ROUTER) so router conversation history survives restart and is rebuilt from the event log via projection, restoring Hard Invariant #1 for the CHAT path. Both CHAT and STEERING now emit user + router turn events; STEERING additionally keeps its existing SteeringNoteAddedEvent so the structured directive record is preserved alongside the conversation turns. RouterFacade drops the in-memory ConcurrentHashMap of histories and rebuilds state through the repository after each emission. Reducer appends turns to conversationHistory. ChatTurnEvent is registered in eventModule.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@SerialName("ChatTurn")
|
||||
data class ChatTurnEvent(
|
||||
val sessionId: SessionId,
|
||||
val turnId: String,
|
||||
val role: ChatTurnRole,
|
||||
val content: String,
|
||||
val timestampMs: Long,
|
||||
) : EventPayload
|
||||
|
||||
@Serializable
|
||||
enum class ChatTurnRole {
|
||||
USER,
|
||||
ROUTER,
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||
import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.InferenceFailedEvent
|
||||
@@ -71,6 +72,7 @@ val eventModule = SerializersModule {
|
||||
subclass(RetryAttemptedEvent::class)
|
||||
subclass(RiskAssessedEvent::class)
|
||||
subclass(ChatSessionStartedEvent::class)
|
||||
subclass(ChatTurnEvent::class)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package com.correx.core.router
|
||||
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.ChatTurnRole
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.CausationId
|
||||
import com.correx.core.events.types.CorrelationId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.InferenceRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
@@ -16,11 +16,8 @@ import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.ResponseFormat
|
||||
import com.correx.core.router.model.RouterConfig
|
||||
import com.correx.core.router.model.RouterResponse
|
||||
import com.correx.core.router.model.RouterTurn
|
||||
import com.correx.core.router.model.TurnRole
|
||||
import kotlinx.datetime.Clock
|
||||
import java.util.*
|
||||
import java.util.concurrent.*
|
||||
|
||||
interface RouterFacade {
|
||||
suspend fun onUserInput(
|
||||
@@ -39,22 +36,19 @@ class DefaultRouterFacade(
|
||||
private val validateSteering: (suspend (String) -> String?)? = null,
|
||||
) : RouterFacade {
|
||||
|
||||
private val histories = ConcurrentHashMap<SessionId, MutableList<RouterTurn>>()
|
||||
|
||||
override suspend fun onUserInput(
|
||||
sessionId: SessionId,
|
||||
input: String,
|
||||
mode: ChatMode,
|
||||
): RouterResponse {
|
||||
val state = routerRepository.getRouterState(sessionId)
|
||||
// Emit USER turn event
|
||||
emitChatTurn(sessionId, input, ChatTurnRole.USER)
|
||||
|
||||
val history = histories.getOrPut(sessionId) { mutableListOf() }
|
||||
history.add(RouterTurn(role = TurnRole.USER, content = input, timestamp = Clock.System.now()))
|
||||
// Rebuild state with user turn appended
|
||||
val stateWithUserTurn = routerRepository.getRouterState(sessionId)
|
||||
val effectiveStageId = stateWithUserTurn.currentStageId ?: StageId.NONE
|
||||
|
||||
val stateWithHistory = state.copy(conversationHistory = history.toList())
|
||||
val effectiveStageId = state.currentStageId ?: StageId.NONE
|
||||
|
||||
val contextPack = routerContextBuilder.build(stateWithHistory, config.tokenBudget)
|
||||
val contextPack = routerContextBuilder.build(stateWithUserTurn, config.tokenBudget)
|
||||
val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General))
|
||||
val inferenceRequest = InferenceRequest(
|
||||
requestId = InferenceRequestId(UUID.randomUUID().toString()),
|
||||
@@ -67,36 +61,65 @@ class DefaultRouterFacade(
|
||||
val inferenceResponse = provider.infer(inferenceRequest)
|
||||
val content = inferenceResponse.text
|
||||
|
||||
history.add(RouterTurn(role = TurnRole.ROUTER, content = content, timestamp = Clock.System.now()))
|
||||
// Emit ROUTER turn event
|
||||
emitChatTurn(sessionId, content, ChatTurnRole.ROUTER)
|
||||
|
||||
if (mode == ChatMode.STEERING) {
|
||||
val validationError = validateSteering?.invoke(content)
|
||||
if (validationError == null) {
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = SteeringNoteAddedEvent(
|
||||
sessionId = sessionId,
|
||||
content = content,
|
||||
stageId = effectiveStageId.takeIf { it != StageId.NONE },
|
||||
),
|
||||
),
|
||||
)
|
||||
emitSteeringNote(sessionId, content, effectiveStageId)
|
||||
}
|
||||
}
|
||||
|
||||
return RouterResponse(content = content, steeringEmitted = (mode == ChatMode.STEERING))
|
||||
}
|
||||
|
||||
private suspend fun emitChatTurn(sessionId: SessionId, content: String, role: ChatTurnRole) {
|
||||
val turnId = UUID.randomUUID().toString()
|
||||
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = ChatTurnEvent(
|
||||
sessionId = sessionId,
|
||||
turnId = turnId,
|
||||
role = role,
|
||||
content = content,
|
||||
timestampMs = nowMs,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = SteeringNoteAddedEvent(
|
||||
sessionId = sessionId,
|
||||
content = content,
|
||||
stageId = effectiveStageId.takeIf { it != StageId.NONE },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ChatMode {
|
||||
CHAT,
|
||||
STEERING,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.correx.core.router
|
||||
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
@@ -10,8 +11,11 @@ import com.correx.core.events.events.WorkflowFailedEvent
|
||||
import com.correx.core.events.events.WorkflowStartedEvent
|
||||
import com.correx.core.router.model.RouterL2Entry
|
||||
import com.correx.core.router.model.RouterState
|
||||
import com.correx.core.router.model.RouterTurn
|
||||
import com.correx.core.router.model.StageOutcomeKind
|
||||
import com.correx.core.router.model.TurnRole
|
||||
import com.correx.core.router.model.WorkflowStatus
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
interface RouterReducer {
|
||||
val initial: RouterState
|
||||
@@ -37,6 +41,7 @@ class DefaultRouterReducer : RouterReducer {
|
||||
is OrchestrationResumedEvent -> handleOrchestrationResumed(state)
|
||||
is StageCompletedEvent -> handleStageCompleted(state, event)
|
||||
is StageFailedEvent -> handleStageFailed(state, event)
|
||||
is ChatTurnEvent -> handleChatTurn(state, event)
|
||||
else -> state
|
||||
}
|
||||
}
|
||||
@@ -102,4 +107,20 @@ class DefaultRouterReducer : RouterReducer {
|
||||
currentStageId = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleChatTurn(state: RouterState, event: StoredEvent): RouterState {
|
||||
val payload = event.payload as ChatTurnEvent
|
||||
val turnRole = when (payload.role) {
|
||||
com.correx.core.events.events.ChatTurnRole.USER -> TurnRole.USER
|
||||
com.correx.core.events.events.ChatTurnRole.ROUTER -> TurnRole.ROUTER
|
||||
}
|
||||
val turn = RouterTurn(
|
||||
role = turnRole,
|
||||
content = payload.content,
|
||||
timestamp = Instant.fromEpochMilliseconds(payload.timestampMs),
|
||||
)
|
||||
return state.copy(
|
||||
conversationHistory = state.conversationHistory + turn
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user