feat(personalization): ProfileAdaptationService — propose-only learned adaptation
This commit is contained in:
@@ -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(
|
||||
|
||||
+63
@@ -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) }
|
||||
}
|
||||
}
|
||||
+118
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user