feat(personalization): ProfileAdaptationService — propose-only learned adaptation

This commit is contained in:
2026-06-08 10:42:19 +04:00
parent 0c7fa70f8d
commit 90d76e7bd1
4 changed files with 200 additions and 0 deletions
@@ -57,6 +57,7 @@ import com.correx.core.toolintent.rules.ExecInterpreterRule
import com.correx.core.toolintent.rules.NetworkHostRule
import com.correx.core.toolintent.rules.PathContainmentRule
import com.correx.apps.server.freestyle.FreestyleDriver
import com.correx.apps.server.personalization.ProfileAdaptationService
import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
import com.correx.core.events.types.ArtifactId
import com.correx.infrastructure.InfrastructureModule
@@ -315,6 +316,14 @@ fun main() {
} else {
null
}
val profileAdaptationService: ProfileAdaptationService? =
if (correxConfig.personalization.enabled && correxConfig.personalization.learn) {
ProfileAdaptationService(
decisionJournalRepository = decisionJournalRepository,
summarize = { _ -> "# no changes\n# (stub: wire real LLM summarizer)" },
)
} else null
val configArtifactKinds = loadConfigArtifactKinds(correxConfig)
val artifactKindRegistry = DefaultArtifactKindRegistry().also { reg ->
configArtifactKinds.forEach { reg.register(it) }
@@ -348,6 +357,7 @@ fun main() {
projectMemory = projectMemory,
freestyleDriver = freestyleDriver,
operatorProfile = operatorProfile,
profileAdaptationService = profileAdaptationService,
)
module.start()
log.info("==============================")
@@ -79,6 +79,8 @@ class ServerModule(
private val freestyleDriver: FreestyleDriver? = null,
// Operator profile snapshot loaded at server start. Null when personalization is disabled.
private val operatorProfile: OperatorProfile? = null,
// Propose learned profile updates at session end. Null when learn=false or no profile.
private val profileAdaptationService: com.correx.apps.server.personalization.ProfileAdaptationService? = null,
) {
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
orchestrator = orchestrator,
@@ -178,6 +180,13 @@ class ServerModule(
}
// Distil this run's decisions into durable project memory on completion.
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
operatorProfile?.let { profile ->
profileAdaptationService?.let { svc ->
runCatching { svc.proposeAdaptation(sessionId, profile) }
.onFailure { log.warn("Profile adaptation failed: {}", it.message) }
}
}
}.onFailure { ex ->
log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex)
eventStore.append(
@@ -0,0 +1,63 @@
package com.correx.apps.server.personalization
import com.correx.core.config.OperatorProfile
import com.correx.core.config.ProfileLoader
import com.correx.core.events.types.SessionId
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.journal.model.DecisionKind
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption
class ProfileAdaptationService(
private val decisionJournalRepository: DefaultDecisionJournalRepository,
private val summarize: suspend (String) -> String,
private val proposedPath: Path = ProfileLoader.profilePath().resolveSibling("profile.proposed.toml"),
) {
private val log = LoggerFactory.getLogger(ProfileAdaptationService::class.java)
suspend fun proposeAdaptation(sessionId: SessionId, currentProfile: OperatorProfile) {
val journal = decisionJournalRepository.getJournal(sessionId)
val relevantKinds = setOf(
DecisionKind.STEERING,
DecisionKind.PREEMPT,
DecisionKind.FAILURE,
DecisionKind.APPROVAL,
)
val relevantRecords = journal.records.filter { it.kind in relevantKinds }
if (relevantRecords.isEmpty()) return
val prompt = buildString {
appendLine("You are analysing an operator's session to propose updates to their correx profile.")
appendLine("Current profile `about`:")
appendLine(currentProfile.about.ifBlank { "(empty)" })
appendLine()
appendLine(
"Current preferences: approval_mode=${currentProfile.preferences.approvalMode}, " +
"conventions=${currentProfile.preferences.conventions}",
)
appendLine()
appendLine("Session decisions that reveal operator preferences (steering, corrections, failures):")
relevantRecords.forEach { appendLine("- [${it.kind}] ${it.summary}") }
appendLine()
appendLine(
"Propose a concise update to the profile as a TOML snippet. " +
"Only include fields that should change. If nothing should change, respond with: # no changes",
)
}
val proposed = runCatching { summarize(prompt) }.getOrElse {
log.warn("Profile adaptation summarization failed: {}", it.message)
return
}
if (proposed.trimStart().startsWith("# no changes")) return
runCatching {
Files.writeString(proposedPath, proposed, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)
log.info("Profile adaptation proposed — review and merge: {}", proposedPath)
}.onFailure { log.warn("Could not write proposed profile: {}", it.message) }
}
}
@@ -0,0 +1,118 @@
package com.correx.apps.server.personalization
import com.correx.core.config.OperatorProfile
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.types.EventId
import com.correx.core.events.types.SessionId
import com.correx.core.journal.DecisionJournalProjector
import com.correx.core.journal.DefaultDecisionJournalReducer
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import java.util.UUID
class ProfileAdaptationServiceTest {
private fun buildRepository(eventStore: InMemoryEventStore): DefaultDecisionJournalRepository =
DefaultDecisionJournalRepository(
DefaultEventReplayer(
eventStore,
DecisionJournalProjector(DefaultDecisionJournalReducer()),
),
)
private fun sessionId() = SessionId(UUID.randomUUID().toString())
/** Seed a steering event so the journal has at least one relevant record. */
private suspend fun seedSteeringEvent(eventStore: InMemoryEventStore, sessionId: SessionId) {
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 = "Use Kotlin idiomatic style",
),
),
)
}
@Test
fun `no relevant records — proposeAdaptation returns early, no file written`(@TempDir tempDir: Path): Unit =
runBlocking {
val eventStore = InMemoryEventStore()
val repository = buildRepository(eventStore)
val proposedPath = tempDir.resolve("profile.proposed.toml")
val service = ProfileAdaptationService(
decisionJournalRepository = repository,
summarize = { "about = \"updated\"" },
proposedPath = proposedPath,
)
service.proposeAdaptation(sessionId(), OperatorProfile())
assertFalse(Files.exists(proposedPath), "No file should be written when there are no relevant records")
}
@Test
fun `relevant records but summarize returns no changes — no file written`(@TempDir tempDir: Path): Unit =
runBlocking {
val eventStore = InMemoryEventStore()
val sessionId = sessionId()
seedSteeringEvent(eventStore, sessionId)
val repository = buildRepository(eventStore)
val proposedPath = tempDir.resolve("profile.proposed.toml")
val service = ProfileAdaptationService(
decisionJournalRepository = repository,
summarize = { "# no changes\n# nothing to update" },
proposedPath = proposedPath,
)
service.proposeAdaptation(sessionId, OperatorProfile())
assertFalse(Files.exists(proposedPath), "No file should be written when summarize returns '# no changes'")
}
@Test
fun `relevant records and real delta — file written at proposedPath`(@TempDir tempDir: Path): Unit =
runBlocking {
val eventStore = InMemoryEventStore()
val sessionId = sessionId()
seedSteeringEvent(eventStore, sessionId)
val repository = buildRepository(eventStore)
val proposedPath = tempDir.resolve("profile.proposed.toml")
val deltaContent = "about = \"prefers Kotlin idiomatic style\""
val service = ProfileAdaptationService(
decisionJournalRepository = repository,
summarize = { deltaContent },
proposedPath = proposedPath,
)
service.proposeAdaptation(sessionId, OperatorProfile())
assertTrue(Files.exists(proposedPath), "Proposed file should be written when a real delta is returned")
assertTrue(
Files.readString(proposedPath).contains("prefers Kotlin idiomatic style"),
"Proposed file should contain the delta content",
)
}
}