From 0c7fa70f8dbcc03686c9e9d628a6ae2475d11dd7 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 8 Jun 2026 10:38:47 +0400 Subject: [PATCH] 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. --- .../kotlin/com/correx/apps/server/Main.kt | 6 ++ .../com/correx/apps/server/ServerModule.kt | 27 ++++++ .../core/events/events/SessionEvents.kt | 10 +++ .../events/serialization/Serialization.kt | 2 + ...ratorProfileBoundEventSerializationTest.kt | 56 +++++++++++++ .../orchestration/SessionOrchestrator.kt | 17 +++- .../com/correx/core/sessions/BoundProfile.kt | 8 ++ .../core/sessions/DefaultSessionReducer.kt | 13 +++ .../com/correx/core/sessions/SessionState.kt | 1 + .../sessions/DefaultSessionReducerTest.kt | 82 +++++++++++++++++++ 10 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/OperatorProfileBoundEventSerializationTest.kt create mode 100644 core/sessions/src/main/kotlin/com/correx/core/sessions/BoundProfile.kt create mode 100644 core/sessions/src/test/kotlin/com/correx/core/sessions/DefaultSessionReducerTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 5fa683fb..631c1649 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -11,6 +11,8 @@ import com.correx.core.artifacts.kind.JsonSchema import com.correx.core.artifactstore.ArtifactStore import com.correx.core.config.ConfigLoader 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.context.builder.DefaultContextPackBuilder import com.correx.core.context.compression.DefaultContextCompressor @@ -94,6 +96,9 @@ fun main() { logStoresInfo(eventStore, artifactStore) val correxConfig = ConfigLoader.load() + val operatorProfile: OperatorProfile? = if (correxConfig.personalization.enabled) { + ProfileLoader.load() + } else null val eventDispatcher = EventDispatcher(eventStore) // Managed path: [[models]] present → correx spawns llama-server at boot @@ -342,6 +347,7 @@ fun main() { narrationMaxPerRun = routerConfig.narration.maxPerRun, projectMemory = projectMemory, freestyleDriver = freestyleDriver, + operatorProfile = operatorProfile, ) module.start() log.info("==============================") diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index f5e292b4..5d7c085e 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -10,9 +10,11 @@ import com.correx.core.approvals.ApprovalProjector import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalRepository 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.EventMetadata 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.WorkflowFailedEvent 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. // Null on non-freestyle paths; injected by Main when freestyle is configured. 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( orchestrator = orchestrator, @@ -143,6 +147,29 @@ class ServerModule( 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 { orchestrator.run(sessionId, graph, sessionConfig) // After a successful freestyle planning run, lock the plan and execute phase 2. diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt index 817525fd..ebcd6ab2 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt @@ -17,3 +17,13 @@ data class SessionWorkspaceBoundEvent( val workspaceRoot: String, val allowedPaths: List, ) : EventPayload + +@Serializable +@SerialName("OperatorProfileBound") +data class OperatorProfileBoundEvent( + val sessionId: SessionId, + val about: String, + val approvalMode: String, + val preferredModels: List, + val conventions: List, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 2b43625f..92d6d593 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -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.RouterNarrationEvent +import com.correx.core.events.events.OperatorProfileBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.ContextTruncatedEvent import com.correx.core.events.events.EventPayload @@ -91,6 +92,7 @@ val eventModule = SerializersModule { subclass(ChatTurnEvent::class) subclass(RouterNarrationEvent::class) subclass(SessionWorkspaceBoundEvent::class) + subclass(OperatorProfileBoundEvent::class) subclass(L3MemoryRetrievedEvent::class) subclass(ContextTruncatedEvent::class) subclass(ExecutionPlanLockedEvent::class) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/OperatorProfileBoundEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/OperatorProfileBoundEventSerializationTest.kt new file mode 100644 index 00000000..a735000f --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/OperatorProfileBoundEventSerializationTest.kt @@ -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) + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 95e5e9d5..e735cdd3 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -333,8 +333,23 @@ abstract class SessionOrchestrator( ) val repoMapEntries = buildRepoMapEntries(sessionId) 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 = - systemPrompt + journalEntries + repoMapEntries + needsEntries + + systemPrompt + profileEntries + journalEntries + repoMapEntries + needsEntries + schemaEntries + promptEntries + steeringEntries val contextPack = contextPackBuilder.build( id = ContextPackId(UUID.randomUUID().toString()), diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundProfile.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundProfile.kt new file mode 100644 index 00000000..f39c06cd --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundProfile.kt @@ -0,0 +1,8 @@ +package com.correx.core.sessions + +data class BoundProfile( + val about: String, + val approvalMode: String, + val preferredModels: List, + val conventions: List, +) diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt index 2d09a601..238de513 100644 --- a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt @@ -1,5 +1,6 @@ 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.StageCompletedEvent import com.correx.core.events.events.StageFailedEvent @@ -39,11 +40,23 @@ class DefaultSessionReducer : SessionReducer { 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( status = newStatus, createdAt = createdAt, updatedAt = event.metadata.timestamp, boundWorkspace = boundWorkspace, + boundProfile = boundProfile, ) } } diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt index c5039e99..7d74e242 100644 --- a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt @@ -8,4 +8,5 @@ data class SessionState( val updatedAt: Instant? = null, val invalidTransitions: Int = 0, val boundWorkspace: BoundWorkspace? = null, + val boundProfile: BoundProfile? = null, ) diff --git a/core/sessions/src/test/kotlin/com/correx/core/sessions/DefaultSessionReducerTest.kt b/core/sessions/src/test/kotlin/com/correx/core/sessions/DefaultSessionReducerTest.kt new file mode 100644 index 00000000..6d8db80c --- /dev/null +++ b/core/sessions/src/test/kotlin/com/correx/core/sessions/DefaultSessionReducerTest.kt @@ -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) + } +}