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 631c1649..ce6f790d 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 @@ -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("==============================") 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 5d7c085e..53298bd4 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 @@ -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( diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/personalization/ProfileAdaptationService.kt b/apps/server/src/main/kotlin/com/correx/apps/server/personalization/ProfileAdaptationService.kt new file mode 100644 index 00000000..9cfbd22e --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/personalization/ProfileAdaptationService.kt @@ -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) } + } +} diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/personalization/ProfileAdaptationServiceTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/personalization/ProfileAdaptationServiceTest.kt new file mode 100644 index 00000000..060979b0 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/personalization/ProfileAdaptationServiceTest.kt @@ -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", + ) + } +}