feat: L3 memory retrieval on the router read path (record-only)
When a user sends input, embed it, query L3 across all sessions, dedup against in-session turns, and record the retrieval as an event so replay is deterministic (invariant #9). Hits land in RouterState; context injection follows in a later slice. - Add L3MemoryRetrievedEvent + L3RetrievedHit (registered in eventModule) - RouterState.lastRetrievedMemory + reducer case; RouterTurn carries turnId - RouterConfig.retrievalK (default 5) - Harden L3 write path: runCatching + visible logging, cancellation re-thrown; event append stays ahead of the L3 write - Warn prominently when the non-durable in_memory L3 backend is selected
This commit is contained in:
@@ -19,3 +19,21 @@ enum class ChatTurnRole {
|
||||
USER,
|
||||
ROUTER,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@SerialName("L3MemoryRetrieved")
|
||||
data class L3MemoryRetrievedEvent(
|
||||
val sessionId: SessionId,
|
||||
val queryTurnId: String,
|
||||
val hits: List<L3RetrievedHit>,
|
||||
val timestampMs: Long,
|
||||
) : EventPayload
|
||||
|
||||
@Serializable
|
||||
data class L3RetrievedHit(
|
||||
val entryId: String,
|
||||
val sourceSessionId: SessionId,
|
||||
val sourceTurnId: String,
|
||||
val text: String,
|
||||
val score: Float,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ 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.L3MemoryRetrievedEvent
|
||||
import com.correx.core.events.events.InferenceCompletedEvent
|
||||
import com.correx.core.events.events.InferenceFailedEvent
|
||||
import com.correx.core.events.events.InferenceStartedEvent
|
||||
@@ -73,6 +74,7 @@ val eventModule = SerializersModule {
|
||||
subclass(RiskAssessedEvent::class)
|
||||
subclass(ChatSessionStartedEvent::class)
|
||||
subclass(ChatTurnEvent::class)
|
||||
subclass(L3MemoryRetrievedEvent::class)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ dependencies {
|
||||
implementation project(':core:context')
|
||||
implementation project(':core:inference')
|
||||
implementation project(':core:sessions')
|
||||
implementation "org.slf4j:slf4j-api:2.0.16"
|
||||
}
|
||||
|
||||
tasks.named("koverVerify").configure {
|
||||
|
||||
@@ -3,6 +3,8 @@ 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.L3MemoryRetrievedEvent
|
||||
import com.correx.core.events.events.L3RetrievedHit
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||
import com.correx.core.events.stores.EventStore
|
||||
@@ -17,10 +19,15 @@ import com.correx.core.inference.ModelCapability
|
||||
import com.correx.core.inference.ResponseFormat
|
||||
import com.correx.core.router.l3.L3MemoryEntry
|
||||
import com.correx.core.router.l3.L3MemoryStore
|
||||
import com.correx.core.router.l3.L3Query
|
||||
import com.correx.core.router.model.RouterConfig
|
||||
import com.correx.core.router.model.RouterResponse
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.datetime.Clock
|
||||
import java.util.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.UUID
|
||||
|
||||
private val log = LoggerFactory.getLogger(DefaultRouterFacade::class.java)
|
||||
|
||||
interface RouterFacade {
|
||||
suspend fun onUserInput(
|
||||
@@ -46,13 +53,57 @@ class DefaultRouterFacade(
|
||||
input: String,
|
||||
mode: ChatMode,
|
||||
): RouterResponse {
|
||||
// Emit USER turn event
|
||||
emitChatTurn(sessionId, input, ChatTurnRole.USER)
|
||||
// Emit USER turn event and get the turnId for retrieval dedup
|
||||
val userTurnId = emitChatTurn(sessionId, input, ChatTurnRole.USER)
|
||||
|
||||
// Rebuild state with user turn appended
|
||||
val stateWithUserTurn = routerRepository.getRouterState(sessionId)
|
||||
val effectiveStageId = stateWithUserTurn.currentStageId ?: StageId.NONE
|
||||
|
||||
// L3 retrieval — non-fatal; does NOT alter what contextBuilder receives in this slice
|
||||
val retrieved = runCatching {
|
||||
val queryVector = embedder.embed(input)
|
||||
l3MemoryStore.query(L3Query(vector = queryVector, k = config.retrievalK))
|
||||
}.also { result ->
|
||||
result.exceptionOrNull()?.let { e ->
|
||||
if (e is CancellationException) throw e
|
||||
log.warn("L3 retrieval failed for session {}: {}", sessionId, e.message)
|
||||
}
|
||||
}.getOrElse { emptyList() }
|
||||
|
||||
val inSessionTurnIds = stateWithUserTurn.conversationHistory.map { it.turnId }.toSet()
|
||||
val deduped = retrieved.filter { it.entry.turnId !in inSessionTurnIds }
|
||||
|
||||
if (deduped.isNotEmpty()) {
|
||||
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 = L3MemoryRetrievedEvent(
|
||||
sessionId = sessionId,
|
||||
queryTurnId = userTurnId,
|
||||
hits = deduped.map { hit ->
|
||||
L3RetrievedHit(
|
||||
entryId = hit.entry.id,
|
||||
sourceSessionId = hit.entry.sessionId,
|
||||
sourceTurnId = hit.entry.turnId,
|
||||
text = hit.entry.text,
|
||||
score = hit.score,
|
||||
)
|
||||
},
|
||||
timestampMs = nowMs,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val contextPack = routerContextBuilder.build(stateWithUserTurn, config.tokenBudget)
|
||||
val provider = inferenceRouter.route(effectiveStageId, setOf(ModelCapability.General))
|
||||
val inferenceRequest = InferenceRequest(
|
||||
@@ -79,7 +130,7 @@ class DefaultRouterFacade(
|
||||
return RouterResponse(content = content, steeringEmitted = (mode == ChatMode.STEERING))
|
||||
}
|
||||
|
||||
private suspend fun emitChatTurn(sessionId: SessionId, content: String, role: ChatTurnRole) {
|
||||
private suspend fun emitChatTurn(sessionId: SessionId, content: String, role: ChatTurnRole): String {
|
||||
val turnId = UUID.randomUUID().toString()
|
||||
val nowMs = Clock.System.now().toEpochMilliseconds()
|
||||
eventStore.append(
|
||||
@@ -102,17 +153,26 @@ class DefaultRouterFacade(
|
||||
),
|
||||
)
|
||||
|
||||
val vector = embedder.embed(content)
|
||||
l3MemoryStore.store(
|
||||
L3MemoryEntry(
|
||||
id = turnId,
|
||||
sessionId = sessionId,
|
||||
turnId = turnId,
|
||||
text = content,
|
||||
vector = vector,
|
||||
timestampMs = nowMs,
|
||||
runCatching {
|
||||
val vector = embedder.embed(content)
|
||||
l3MemoryStore.store(
|
||||
L3MemoryEntry(
|
||||
id = turnId,
|
||||
sessionId = sessionId,
|
||||
turnId = turnId,
|
||||
text = content,
|
||||
vector = vector,
|
||||
timestampMs = nowMs,
|
||||
)
|
||||
)
|
||||
)
|
||||
}.also { result ->
|
||||
result.exceptionOrNull()?.let { e ->
|
||||
if (e is CancellationException) throw e
|
||||
log.warn("L3 write failed for turn {}: {}", turnId, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
return turnId
|
||||
}
|
||||
|
||||
private suspend fun emitSteeringNote(sessionId: SessionId, content: String, effectiveStageId: StageId) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.correx.core.router
|
||||
|
||||
import com.correx.core.events.events.ChatTurnEvent
|
||||
import com.correx.core.events.events.L3MemoryRetrievedEvent
|
||||
import com.correx.core.events.events.OrchestrationPausedEvent
|
||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||
import com.correx.core.events.events.StageCompletedEvent
|
||||
@@ -42,6 +43,7 @@ class DefaultRouterReducer : RouterReducer {
|
||||
is StageCompletedEvent -> handleStageCompleted(state, event)
|
||||
is StageFailedEvent -> handleStageFailed(state, event)
|
||||
is ChatTurnEvent -> handleChatTurn(state, event)
|
||||
is L3MemoryRetrievedEvent -> state.copy(lastRetrievedMemory = payload.hits)
|
||||
else -> state
|
||||
}
|
||||
}
|
||||
@@ -118,6 +120,7 @@ class DefaultRouterReducer : RouterReducer {
|
||||
role = turnRole,
|
||||
content = payload.content,
|
||||
timestamp = Instant.fromEpochMilliseconds(payload.timestampMs),
|
||||
turnId = payload.turnId,
|
||||
)
|
||||
return state.copy(
|
||||
conversationHistory = state.conversationHistory + turn
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
data class RouterConfig(
|
||||
val conversationKeepLast: Int = 6,
|
||||
val retrievalK: Int = 5,
|
||||
val tokenBudget: TokenBudget = TokenBudget(limit = 4096),
|
||||
val generationConfig: GenerationConfig = GenerationConfig(
|
||||
temperature = 0.7,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.correx.core.router.model
|
||||
|
||||
import com.correx.core.events.events.L3RetrievedHit
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.datetime.Instant
|
||||
@@ -40,6 +41,7 @@ data class RouterTurn(
|
||||
val role: TurnRole,
|
||||
val content: String,
|
||||
val timestamp: Instant,
|
||||
val turnId: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -49,4 +51,5 @@ data class RouterState(
|
||||
val currentStageId: StageId? = null,
|
||||
val l2Memory: List<RouterL2Entry> = emptyList(),
|
||||
val conversationHistory: List<RouterTurn> = emptyList(),
|
||||
val lastRetrievedMemory: List<L3RetrievedHit> = emptyList(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user