feat(personalization): OperatorProfileBoundEvent + bind-at-start + inject about context
Records operator profile as a snapshot event at session start (invariants #8/#9), populates SessionState.boundProfile via reducer, and injects the about text as a pinned L0 SYSTEM context entry in every stage. Gated by PersonalizationConfig.enabled.
This commit is contained in:
@@ -11,6 +11,8 @@ import com.correx.core.artifacts.kind.JsonSchema
|
|||||||
import com.correx.core.artifactstore.ArtifactStore
|
import com.correx.core.artifactstore.ArtifactStore
|
||||||
import com.correx.core.config.ConfigLoader
|
import com.correx.core.config.ConfigLoader
|
||||||
import com.correx.core.config.CorrexConfig
|
import com.correx.core.config.CorrexConfig
|
||||||
|
import com.correx.core.config.OperatorProfile
|
||||||
|
import com.correx.core.config.ProfileLoader
|
||||||
import com.correx.core.config.ProviderConfig
|
import com.correx.core.config.ProviderConfig
|
||||||
import com.correx.core.context.builder.DefaultContextPackBuilder
|
import com.correx.core.context.builder.DefaultContextPackBuilder
|
||||||
import com.correx.core.context.compression.DefaultContextCompressor
|
import com.correx.core.context.compression.DefaultContextCompressor
|
||||||
@@ -94,6 +96,9 @@ fun main() {
|
|||||||
logStoresInfo(eventStore, artifactStore)
|
logStoresInfo(eventStore, artifactStore)
|
||||||
|
|
||||||
val correxConfig = ConfigLoader.load()
|
val correxConfig = ConfigLoader.load()
|
||||||
|
val operatorProfile: OperatorProfile? = if (correxConfig.personalization.enabled) {
|
||||||
|
ProfileLoader.load()
|
||||||
|
} else null
|
||||||
val eventDispatcher = EventDispatcher(eventStore)
|
val eventDispatcher = EventDispatcher(eventStore)
|
||||||
|
|
||||||
// Managed path: [[models]] present → correx spawns llama-server at boot
|
// Managed path: [[models]] present → correx spawns llama-server at boot
|
||||||
@@ -342,6 +347,7 @@ fun main() {
|
|||||||
narrationMaxPerRun = routerConfig.narration.maxPerRun,
|
narrationMaxPerRun = routerConfig.narration.maxPerRun,
|
||||||
projectMemory = projectMemory,
|
projectMemory = projectMemory,
|
||||||
freestyleDriver = freestyleDriver,
|
freestyleDriver = freestyleDriver,
|
||||||
|
operatorProfile = operatorProfile,
|
||||||
)
|
)
|
||||||
module.start()
|
module.start()
|
||||||
log.info("==============================")
|
log.info("==============================")
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import com.correx.core.approvals.ApprovalProjector
|
|||||||
import com.correx.core.approvals.DefaultApprovalReducer
|
import com.correx.core.approvals.DefaultApprovalReducer
|
||||||
import com.correx.core.approvals.DefaultApprovalRepository
|
import com.correx.core.approvals.DefaultApprovalRepository
|
||||||
import com.correx.core.artifactstore.ArtifactStore
|
import com.correx.core.artifactstore.ArtifactStore
|
||||||
|
import com.correx.core.config.OperatorProfile
|
||||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.EventMetadata
|
import com.correx.core.events.events.EventMetadata
|
||||||
import com.correx.core.events.events.NewEvent
|
import com.correx.core.events.events.NewEvent
|
||||||
|
import com.correx.core.events.events.OperatorProfileBoundEvent
|
||||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||||
import com.correx.core.events.events.WorkflowFailedEvent
|
import com.correx.core.events.events.WorkflowFailedEvent
|
||||||
import com.correx.core.events.stores.EventStore
|
import com.correx.core.events.stores.EventStore
|
||||||
@@ -75,6 +77,8 @@ class ServerModule(
|
|||||||
// Driver for the two-phase freestyle workflow: locks the plan and runs phase 2.
|
// Driver for the two-phase freestyle workflow: locks the plan and runs phase 2.
|
||||||
// Null on non-freestyle paths; injected by Main when freestyle is configured.
|
// Null on non-freestyle paths; injected by Main when freestyle is configured.
|
||||||
private val freestyleDriver: FreestyleDriver? = null,
|
private val freestyleDriver: FreestyleDriver? = null,
|
||||||
|
// Operator profile snapshot loaded at server start. Null when personalization is disabled.
|
||||||
|
private val operatorProfile: OperatorProfile? = null,
|
||||||
) {
|
) {
|
||||||
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
||||||
orchestrator = orchestrator,
|
orchestrator = orchestrator,
|
||||||
@@ -143,6 +147,29 @@ class ServerModule(
|
|||||||
pm.retrieveAndSeed(sessionId, pm.repoRoot())
|
pm.retrieveAndSeed(sessionId, pm.repoRoot())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Bind operator profile snapshot as an event so replay reads the recorded
|
||||||
|
// fact rather than re-reading the live file (invariants #8/#9).
|
||||||
|
operatorProfile?.let { profile ->
|
||||||
|
eventStore.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(java.util.UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = OperatorProfileBoundEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
about = profile.about,
|
||||||
|
approvalMode = profile.preferences.approvalMode,
|
||||||
|
preferredModels = profile.preferences.preferredModels,
|
||||||
|
conventions = profile.preferences.conventions,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
runCatching {
|
runCatching {
|
||||||
orchestrator.run(sessionId, graph, sessionConfig)
|
orchestrator.run(sessionId, graph, sessionConfig)
|
||||||
// After a successful freestyle planning run, lock the plan and execute phase 2.
|
// After a successful freestyle planning run, lock the plan and execute phase 2.
|
||||||
|
|||||||
@@ -17,3 +17,13 @@ data class SessionWorkspaceBoundEvent(
|
|||||||
val workspaceRoot: String,
|
val workspaceRoot: String,
|
||||||
val allowedPaths: List<String>,
|
val allowedPaths: List<String>,
|
||||||
) : EventPayload
|
) : EventPayload
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("OperatorProfileBound")
|
||||||
|
data class OperatorProfileBoundEvent(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val about: String,
|
||||||
|
val approvalMode: String,
|
||||||
|
val preferredModels: List<String>,
|
||||||
|
val conventions: List<String>,
|
||||||
|
) : EventPayload
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.correx.core.events.events.ArtifactValidatingEvent
|
|||||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||||
import com.correx.core.events.events.ChatTurnEvent
|
import com.correx.core.events.events.ChatTurnEvent
|
||||||
import com.correx.core.events.events.RouterNarrationEvent
|
import com.correx.core.events.events.RouterNarrationEvent
|
||||||
|
import com.correx.core.events.events.OperatorProfileBoundEvent
|
||||||
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
||||||
import com.correx.core.events.events.ContextTruncatedEvent
|
import com.correx.core.events.events.ContextTruncatedEvent
|
||||||
import com.correx.core.events.events.EventPayload
|
import com.correx.core.events.events.EventPayload
|
||||||
@@ -91,6 +92,7 @@ val eventModule = SerializersModule {
|
|||||||
subclass(ChatTurnEvent::class)
|
subclass(ChatTurnEvent::class)
|
||||||
subclass(RouterNarrationEvent::class)
|
subclass(RouterNarrationEvent::class)
|
||||||
subclass(SessionWorkspaceBoundEvent::class)
|
subclass(SessionWorkspaceBoundEvent::class)
|
||||||
|
subclass(OperatorProfileBoundEvent::class)
|
||||||
subclass(L3MemoryRetrievedEvent::class)
|
subclass(L3MemoryRetrievedEvent::class)
|
||||||
subclass(ContextTruncatedEvent::class)
|
subclass(ContextTruncatedEvent::class)
|
||||||
subclass(ExecutionPlanLockedEvent::class)
|
subclass(ExecutionPlanLockedEvent::class)
|
||||||
|
|||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package com.correx.core.events.serialization
|
||||||
|
|
||||||
|
import com.correx.core.events.events.EventPayload
|
||||||
|
import com.correx.core.events.events.OperatorProfileBoundEvent
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class OperatorProfileBoundEventSerializationTest {
|
||||||
|
|
||||||
|
private val sample = OperatorProfileBoundEvent(
|
||||||
|
sessionId = SessionId("sess-1"),
|
||||||
|
about = "A seasoned backend engineer who prefers concise, idiomatic Kotlin.",
|
||||||
|
approvalMode = "auto",
|
||||||
|
preferredModels = listOf("llama-3-70b", "qwen2.5-72b"),
|
||||||
|
conventions = listOf("no var", "no mutable state"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trips as polymorphic EventPayload`() {
|
||||||
|
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||||
|
assertTrue(encoded.contains("\"type\":\"OperatorProfileBound\""), "SerialName must be present: $encoded")
|
||||||
|
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||||
|
assertEquals(sample, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decodes hand-written OperatorProfileBound JSON`() {
|
||||||
|
val json = """
|
||||||
|
{"type":"OperatorProfileBound","sessionId":"s-99","about":"senior dev","approvalMode":"suggest","preferredModels":["model-a"],"conventions":["no null"]}
|
||||||
|
""".trimIndent()
|
||||||
|
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||||
|
assertTrue(decoded is OperatorProfileBoundEvent)
|
||||||
|
decoded as OperatorProfileBoundEvent
|
||||||
|
assertEquals("s-99", decoded.sessionId.value)
|
||||||
|
assertEquals("senior dev", decoded.about)
|
||||||
|
assertEquals("suggest", decoded.approvalMode)
|
||||||
|
assertEquals(listOf("model-a"), decoded.preferredModels)
|
||||||
|
assertEquals(listOf("no null"), decoded.conventions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trips with empty lists`() {
|
||||||
|
val empty = OperatorProfileBoundEvent(
|
||||||
|
sessionId = SessionId("s-0"),
|
||||||
|
about = "",
|
||||||
|
approvalMode = "",
|
||||||
|
preferredModels = emptyList(),
|
||||||
|
conventions = emptyList(),
|
||||||
|
)
|
||||||
|
val encoded = eventJson.encodeToString(EventPayload.serializer(), empty)
|
||||||
|
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||||
|
assertEquals(empty, decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-1
@@ -333,8 +333,23 @@ abstract class SessionOrchestrator(
|
|||||||
)
|
)
|
||||||
val repoMapEntries = buildRepoMapEntries(sessionId)
|
val repoMapEntries = buildRepoMapEntries(sessionId)
|
||||||
val needsEntries = buildNeedsArtifactEntries(sessionId, stageConfig)
|
val needsEntries = buildNeedsArtifactEntries(sessionId, stageConfig)
|
||||||
|
val profileEntries = session.state.boundProfile?.about
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { about ->
|
||||||
|
listOf(
|
||||||
|
ContextEntry(
|
||||||
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
|
layer = ContextLayer.L0,
|
||||||
|
content = "## Operator profile\n$about",
|
||||||
|
sourceType = "operatorProfile",
|
||||||
|
sourceId = "operator-profile",
|
||||||
|
tokenEstimate = about.length / 4,
|
||||||
|
role = EntryRole.SYSTEM,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} ?: emptyList()
|
||||||
var accumulatedEntries =
|
var accumulatedEntries =
|
||||||
systemPrompt + journalEntries + repoMapEntries + needsEntries +
|
systemPrompt + profileEntries + journalEntries + repoMapEntries + needsEntries +
|
||||||
schemaEntries + promptEntries + steeringEntries
|
schemaEntries + promptEntries + steeringEntries
|
||||||
val contextPack = contextPackBuilder.build(
|
val contextPack = contextPackBuilder.build(
|
||||||
id = ContextPackId(UUID.randomUUID().toString()),
|
id = ContextPackId(UUID.randomUUID().toString()),
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.correx.core.sessions
|
||||||
|
|
||||||
|
data class BoundProfile(
|
||||||
|
val about: String,
|
||||||
|
val approvalMode: String,
|
||||||
|
val preferredModels: List<String>,
|
||||||
|
val conventions: List<String>,
|
||||||
|
)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.correx.core.sessions
|
package com.correx.core.sessions
|
||||||
|
|
||||||
|
import com.correx.core.events.events.OperatorProfileBoundEvent
|
||||||
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
||||||
import com.correx.core.events.events.StageCompletedEvent
|
import com.correx.core.events.events.StageCompletedEvent
|
||||||
import com.correx.core.events.events.StageFailedEvent
|
import com.correx.core.events.events.StageFailedEvent
|
||||||
@@ -39,11 +40,23 @@ class DefaultSessionReducer : SessionReducer {
|
|||||||
else -> state.boundWorkspace
|
else -> state.boundWorkspace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val boundProfile = when (payload) {
|
||||||
|
is OperatorProfileBoundEvent ->
|
||||||
|
BoundProfile(
|
||||||
|
about = payload.about,
|
||||||
|
approvalMode = payload.approvalMode,
|
||||||
|
preferredModels = payload.preferredModels,
|
||||||
|
conventions = payload.conventions,
|
||||||
|
)
|
||||||
|
else -> state.boundProfile
|
||||||
|
}
|
||||||
|
|
||||||
return state.copy(
|
return state.copy(
|
||||||
status = newStatus,
|
status = newStatus,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
updatedAt = event.metadata.timestamp,
|
updatedAt = event.metadata.timestamp,
|
||||||
boundWorkspace = boundWorkspace,
|
boundWorkspace = boundWorkspace,
|
||||||
|
boundProfile = boundProfile,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ data class SessionState(
|
|||||||
val updatedAt: Instant? = null,
|
val updatedAt: Instant? = null,
|
||||||
val invalidTransitions: Int = 0,
|
val invalidTransitions: Int = 0,
|
||||||
val boundWorkspace: BoundWorkspace? = null,
|
val boundWorkspace: BoundWorkspace? = null,
|
||||||
|
val boundProfile: BoundProfile? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.correx.core.sessions
|
||||||
|
|
||||||
|
import com.correx.core.events.events.EventMetadata
|
||||||
|
import com.correx.core.events.events.OperatorProfileBoundEvent
|
||||||
|
import com.correx.core.events.events.SessionWorkspaceBoundEvent
|
||||||
|
import com.correx.core.events.events.StoredEvent
|
||||||
|
import com.correx.core.events.types.EventId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class DefaultSessionReducerTest {
|
||||||
|
|
||||||
|
private val reducer = DefaultSessionReducer()
|
||||||
|
private val sessionId = SessionId("s-test")
|
||||||
|
private val initialState = SessionState(status = SessionStatus.ACTIVE)
|
||||||
|
|
||||||
|
private fun stored(payload: com.correx.core.events.events.EventPayload) = StoredEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId("e-1"),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Instant.parse("2026-01-01T00:00:00Z"),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
sequence = 1L,
|
||||||
|
sessionSequence = 1L,
|
||||||
|
payload = payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `OperatorProfileBoundEvent populates boundProfile`() {
|
||||||
|
val event = stored(
|
||||||
|
OperatorProfileBoundEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
about = "Expert Kotlin engineer",
|
||||||
|
approvalMode = "auto",
|
||||||
|
preferredModels = listOf("model-x"),
|
||||||
|
conventions = listOf("no var"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = reducer.reduce(initialState, event)
|
||||||
|
|
||||||
|
val profile = result.boundProfile
|
||||||
|
assertEquals("Expert Kotlin engineer", profile?.about)
|
||||||
|
assertEquals("auto", profile?.approvalMode)
|
||||||
|
assertEquals(listOf("model-x"), profile?.preferredModels)
|
||||||
|
assertEquals(listOf("no var"), profile?.conventions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `boundProfile is null by default`() {
|
||||||
|
assertNull(initialState.boundProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unrelated event leaves boundProfile unchanged`() {
|
||||||
|
val state = initialState.copy(
|
||||||
|
boundProfile = BoundProfile(
|
||||||
|
about = "original",
|
||||||
|
approvalMode = "",
|
||||||
|
preferredModels = emptyList(),
|
||||||
|
conventions = emptyList(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val event = stored(
|
||||||
|
SessionWorkspaceBoundEvent(
|
||||||
|
sessionId = sessionId,
|
||||||
|
workspaceRoot = "/ws",
|
||||||
|
allowedPaths = listOf("/ws"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = reducer.reduce(state, event)
|
||||||
|
|
||||||
|
assertEquals("original", result.boundProfile?.about)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user