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.NetworkHostRule
|
||||||
import com.correx.core.toolintent.rules.PathContainmentRule
|
import com.correx.core.toolintent.rules.PathContainmentRule
|
||||||
import com.correx.apps.server.freestyle.FreestyleDriver
|
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.artifacts.kind.DefaultArtifactKindRegistry
|
||||||
import com.correx.core.events.types.ArtifactId
|
import com.correx.core.events.types.ArtifactId
|
||||||
import com.correx.infrastructure.InfrastructureModule
|
import com.correx.infrastructure.InfrastructureModule
|
||||||
@@ -315,6 +316,14 @@ fun main() {
|
|||||||
} else {
|
} else {
|
||||||
null
|
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 configArtifactKinds = loadConfigArtifactKinds(correxConfig)
|
||||||
val artifactKindRegistry = DefaultArtifactKindRegistry().also { reg ->
|
val artifactKindRegistry = DefaultArtifactKindRegistry().also { reg ->
|
||||||
configArtifactKinds.forEach { reg.register(it) }
|
configArtifactKinds.forEach { reg.register(it) }
|
||||||
@@ -348,6 +357,7 @@ fun main() {
|
|||||||
projectMemory = projectMemory,
|
projectMemory = projectMemory,
|
||||||
freestyleDriver = freestyleDriver,
|
freestyleDriver = freestyleDriver,
|
||||||
operatorProfile = operatorProfile,
|
operatorProfile = operatorProfile,
|
||||||
|
profileAdaptationService = profileAdaptationService,
|
||||||
)
|
)
|
||||||
module.start()
|
module.start()
|
||||||
log.info("==============================")
|
log.info("==============================")
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ class ServerModule(
|
|||||||
private val freestyleDriver: FreestyleDriver? = null,
|
private val freestyleDriver: FreestyleDriver? = null,
|
||||||
// Operator profile snapshot loaded at server start. Null when personalization is disabled.
|
// Operator profile snapshot loaded at server start. Null when personalization is disabled.
|
||||||
private val operatorProfile: OperatorProfile? = null,
|
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(
|
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
||||||
orchestrator = orchestrator,
|
orchestrator = orchestrator,
|
||||||
@@ -178,6 +180,13 @@ class ServerModule(
|
|||||||
}
|
}
|
||||||
// Distil this run's decisions into durable project memory on completion.
|
// Distil this run's decisions into durable project memory on completion.
|
||||||
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
|
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 ->
|
}.onFailure { ex ->
|
||||||
log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex)
|
log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex)
|
||||||
eventStore.append(
|
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