package top.jie65535.mirai.profile import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import top.jie65535.mirai.JChatGPT import top.jie65535.mirai.config.PluginConfig import top.jie65535.mirai.data.ChatHistoryStore import top.jie65535.mirai.llm.LargeLanguageModels import top.jie65535.mirai.util.RetryBackoff import java.io.File import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap object UserProfileAnalysisService { private const val MAX_CONVERSATION_CONFLICT_RETRIES = 3 private val runningUsers = ConcurrentHashMap.newKeySet() private val runningGroups = ConcurrentHashMap.newKeySet() private val runningCompactions = ConcurrentHashMap.newKeySet() private val userLocks = ProfileUserLockManager() private val runGate = ProfileAnalysisRunGate() fun newRunToken(): ProfileAnalysisRunToken = runGate.newToken() fun stopAll(): ProfileAnalysisStopReport { val report = ProfileAnalysisStopReport( userTasks = runningUsers.size, groupTasks = runningGroups.size, compactionTasks = runningCompactions.size, ) runGate.stopCurrentRuns() return report } suspend fun listPendingHistoryGroupIds(): List { check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" } check(UserProfileStore.isAvailable) { "用户画像数据库不可用" } return withContext(Dispatchers.IO) { val historyBounds = ProfileHistoryReader(resolveHistoryFile()).listGroupTimeBounds() val cursors = UserProfileStore.loadGroupCursors() .associateBy { cursor -> cursor.botId to cursor.groupId } historyBounds.asSequence() .filter { bounds -> isGroupAnalysisPending(bounds, cursors[bounds.botId to bounds.groupId]) } .map(ProfileHistoryReader.GroupTimeBounds::groupId) .toList() } } suspend fun analyze( userId: Long, maxBatches: Int, runToken: ProfileAnalysisRunToken = newRunToken(), onProgress: suspend (ProfileAnalysisProgress) -> Unit = {}, ): ProfileAnalysisReport { require(userId > 0) { "userId 必须是正数" } require(maxBatches > 0) { "maxBatches 必须是正数" } check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" } check(UserProfileStore.isAvailable) { "用户画像数据库不可用" } if (!runningUsers.add(userId)) { return ProfileAnalysisReport( userId = userId, processedBatches = 0, processedMessages = 0, appliedOperations = 0, skippedOperations = 0, usage = ProfileTokenUsage(), profile = UserProfileStore.load(userId), caughtUp = false, alreadyRunning = true, ) } try { return userLocks.withUserLocks(listOf(userId)) { analyzeExclusive(userId, maxBatches, runToken, onProgress) } } finally { runningUsers.remove(userId) } } suspend fun compact( userId: Long, runToken: ProfileAnalysisRunToken = newRunToken(), ): ProfileCompactionReport { require(userId > 0) { "userId 必须是正数" } check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" } check(UserProfileStore.isAvailable) { "用户画像数据库不可用" } if (!runningCompactions.add(userId)) { val profile = withContext(Dispatchers.IO) { UserProfileStore.load(userId) } ?: throw IllegalArgumentException("用户 $userId 尚无画像") return unchangedCompactionReport(profile, alreadyRunning = true) } try { return userLocks.withUserLocks(listOf(userId)) locked@{ val profile = withContext(Dispatchers.IO) { UserProfileStore.load(userId) } ?: throw IllegalArgumentException("用户 $userId 尚无画像") if (!runGate.canContinue(runToken)) { return@locked unchangedCompactionReport(profile, stopped = true) } if (profile.items.isEmpty()) { return@locked unchangedCompactionReport(profile) } val endpoint = checkNotNull(LargeLanguageModels.profile) { "画像分析模型未配置" } val model: ProfileCompactionModel = ProfileModelClient(endpoint) val supportStats = withContext(Dispatchers.IO) { UserProfileStore.loadSupportStats(userId) } val (result, plan) = compactWithRetry(model, profile, supportStats) if (plan.reduction.profile.version != profile.version) { val batch = compactionBatch(profile, result.rawResponse) withContext(Dispatchers.IO) { UserProfileStore.commitCompaction(plan, batch, result.usage) } ProfileOperationLogger.log( context = "source=COMPACTION user=$userId", reductions = listOf(plan.reduction), ) } ProfileCompactionReport( userId = userId, beforeItems = profile.items.size, afterItems = plan.reduction.profile.items.size, mergedGroups = plan.mergedGroups, rewrittenItems = plan.rewrittenItems, deletedItems = plan.deletedItems, summaryChanged = plan.reduction.profile.summary != profile.summary, skippedOperations = plan.skippedOperations.size, usage = result.usage, profile = plan.reduction.profile, ) } } finally { runningCompactions.remove(userId) } } suspend fun analyzeGroup( groupId: Long, maxBatches: Int, runToken: ProfileAnalysisRunToken = newRunToken(), onProgress: suspend (GroupProfileAnalysisProgress) -> Unit = {}, ): GroupProfileAnalysisReport { require(groupId > 0) { "groupId 必须是正数" } require(maxBatches > 0) { "maxBatches 必须是正数" } check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" } check(UserProfileStore.isAvailable) { "用户画像数据库不可用" } if (!runningGroups.add(groupId)) { return GroupProfileAnalysisReport( botId = null, groupId = groupId, processedBatches = 0, processedMessages = 0, analyzedUsers = 0, appliedOperations = 0, skippedOperations = 0, usage = ProfileTokenUsage(), cursorTime = 0, snapshotEndTime = 0, caughtUp = false, alreadyRunning = true, ) } try { return analyzeGroupExclusive(groupId, maxBatches, runToken, onProgress) } finally { runningGroups.remove(groupId) } } private suspend fun analyzeExclusive( userId: Long, maxBatches: Int, runToken: ProfileAnalysisRunToken, onProgress: suspend (ProfileAnalysisProgress) -> Unit, ): ProfileAnalysisReport { val endpoint = checkNotNull(LargeLanguageModels.profile) { "画像分析模型未配置,请设置 profileModelApi/profileModelToken,或配置可继承的聊天模型接入点" } val model: ProfileModel = ProfileModelClient(endpoint) val historyFile = resolveHistoryFile() val reader = withContext(Dispatchers.IO) { ProfileHistoryReader(historyFile) } val bounds = withContext(Dispatchers.IO) { reader.findUserTimeBounds(userId) } ?: return emptyReport(userId) var profile = withContext(Dispatchers.IO) { UserProfileStore.load(userId) } ?: UserProfileSnapshot( userId = userId, cursorTime = bounds.startTime, snapshotEndTime = bounds.endTime, ) if (profile.cursorTime >= profile.snapshotEndTime && bounds.endTime > profile.snapshotEndTime) { profile = profile.copy(snapshotEndTime = bounds.endTime) } var processedBatches = 0 var processedMessages = 0 var appliedOperations = 0 var skippedOperations = 0 var totalUsage = ProfileTokenUsage() var caughtUp = false while (processedBatches < maxBatches && runGate.canContinue(runToken)) { val batch = withContext(Dispatchers.IO) { reader.loadNextBatch( userId = userId, startTime = profile.cursorTime, snapshotEndTime = profile.snapshotEndTime, targetMessageLimit = PluginConfig.profileBatchTargetMessages.coerceAtLeast(1), maxEpisodes = PluginConfig.profileBatchMaxEpisodes.coerceAtLeast(1), episodeGapSeconds = PluginConfig.profileEpisodeGapMinutes.coerceAtLeast(0) * 60, contextBeforeMessages = PluginConfig.profileContextBeforeMessages.coerceAtLeast(0), contextAfterMessages = PluginConfig.profileContextAfterMessages.coerceAtLeast(0), contextCoreMessages = PluginConfig.profileContextCoreMessages.coerceAtLeast(1), maxMessageChars = PluginConfig.profileMaxMessageChars.coerceAtLeast(80), ) } if (batch == null) { caughtUp = true break } val (result, reduction) = analyzeWithRetry(model, profile, batch) withContext(Dispatchers.IO) { UserProfileStore.commit( reduction = reduction, batch = batch, usage = result.usage, source = ProfileRevisionSource.BACKFILL, ) } ProfileOperationLogger.log( context = "source=BACKFILL batch=[${batch.startTime},${batch.endTime})", reductions = listOf(reduction), ) profile = reduction.profile processedBatches++ processedMessages += batch.messages.size appliedOperations += reduction.operations.size skippedOperations += reduction.skippedOperations.size totalUsage += result.usage onProgress( ProfileAnalysisProgress( batchIndex = processedBatches, startTime = batch.startTime, endTime = batch.endTime, messageCount = batch.messages.size, operationCount = reduction.operations.size, skippedOperationCount = reduction.skippedOperations.size, usage = result.usage, ) ) } if (!caughtUp && profile.cursorTime >= profile.snapshotEndTime) caughtUp = true val stopped = processedBatches < maxBatches && !caughtUp && !runGate.canContinue(runToken) return ProfileAnalysisReport( userId = userId, processedBatches = processedBatches, processedMessages = processedMessages, appliedOperations = appliedOperations, skippedOperations = skippedOperations, usage = totalUsage, profile = withContext(Dispatchers.IO) { UserProfileStore.load(userId) } ?: profile, caughtUp = caughtUp, stopped = stopped, ) } private suspend fun analyzeGroupExclusive( groupId: Long, maxBatches: Int, runToken: ProfileAnalysisRunToken, onProgress: suspend (GroupProfileAnalysisProgress) -> Unit, ): GroupProfileAnalysisReport { val reader = withContext(Dispatchers.IO) { ProfileHistoryReader(resolveHistoryFile()) } val bounds = withContext(Dispatchers.IO) { reader.findGroupTimeBounds(groupId) } ?: return emptyGroupReport(groupId) var cursor = withContext(Dispatchers.IO) { UserProfileStore.loadGroupCursor(bounds.botId, groupId) } ?: GroupProfileCursor( botId = bounds.botId, groupId = groupId, cursorTime = bounds.startTime, snapshotEndTime = bounds.endTime, ) if (cursor.cursorTime >= cursor.snapshotEndTime && bounds.endTime > cursor.snapshotEndTime) { cursor = cursor.copy(snapshotEndTime = bounds.endTime) } var processedBatches = 0 var processedMessages = 0 var analyzedUsers = 0 var appliedOperations = 0 var skippedOperations = 0 var totalUsage = ProfileTokenUsage() var caughtUp = cursor.cursorTime >= cursor.snapshotEndTime val model: ConversationProfileModel by lazy { val endpoint = checkNotNull(LargeLanguageModels.profile) { "画像分析模型未配置,请设置 profileModelApi/profileModelToken,或配置可继承的聊天模型接入点" } ProfileModelClient(endpoint) } while (processedBatches < maxBatches && !caughtUp && runGate.canContinue(runToken)) { val batch = withContext(Dispatchers.IO) { reader.loadNextConversationBatch( botId = cursor.botId, groupId = groupId, startTime = cursor.cursorTime, snapshotEndTime = cursor.snapshotEndTime, messageLimit = PluginConfig.profileAutoConversationMessageLimit.coerceAtLeast(1), maxMessageChars = PluginConfig.profileMaxMessageChars.coerceAtLeast(80), ) } if (batch == null) { cursor = cursor.copy( cursorTime = cursor.snapshotEndTime, updatedAt = System.currentTimeMillis(), ) withContext(Dispatchers.IO) { UserProfileStore.saveGroupCursor(cursor) } caughtUp = true break } val report = analyzeConversationBatch( batch = batch, minAuthoredTextChars = PluginConfig.profileAutoMinAuthoredTextChars, model = model, retryMax = PluginConfig.profileRetryMax, summaryMaxLength = PluginConfig.profileSummaryMaxLength, onRetryFailure = { message, cause -> JChatGPT.logger.warning(message, cause) }, onCommittedOperations = ProfileOperationLogger::log, ) cursor = cursor.copy( cursorTime = batch.endTime, updatedAt = System.currentTimeMillis(), ) withContext(Dispatchers.IO) { UserProfileStore.saveGroupCursor(cursor) } val usage = report?.usage ?: ProfileTokenUsage() processedBatches++ processedMessages += batch.messages.size analyzedUsers += report?.analyzedUsers ?: 0 appliedOperations += report?.appliedOperations ?: 0 skippedOperations += report?.skippedOperations ?: 0 totalUsage += usage caughtUp = cursor.cursorTime >= cursor.snapshotEndTime onProgress( GroupProfileAnalysisProgress( batchIndex = processedBatches, startTime = batch.startTime, endTime = batch.endTime, messageCount = batch.messages.size, analyzedUsers = report?.analyzedUsers ?: 0, appliedOperations = report?.appliedOperations ?: 0, skippedOperations = report?.skippedOperations ?: 0, usage = usage, ) ) } return GroupProfileAnalysisReport( botId = cursor.botId, groupId = groupId, processedBatches = processedBatches, processedMessages = processedMessages, analyzedUsers = analyzedUsers, appliedOperations = appliedOperations, skippedOperations = skippedOperations, usage = totalUsage, cursorTime = cursor.cursorTime, snapshotEndTime = cursor.snapshotEndTime, caughtUp = caughtUp, stopped = processedBatches < maxBatches && !caughtUp && !runGate.canContinue(runToken), ) } suspend fun analyzeConversation( botId: Long, groupId: Long, startTime: Int, endTime: Int, minAuthoredTextChars: Int, ): ConversationProfileAnalysisReport? { require(startTime < endTime) { "startTime must be before endTime" } check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" } check(UserProfileStore.isAvailable) { "用户画像数据库不可用" } val endpoint = checkNotNull(LargeLanguageModels.profile) { "画像分析模型未配置" } val model: ConversationProfileModel = ProfileModelClient(endpoint) val reader = withContext(Dispatchers.IO) { ProfileHistoryReader(resolveLiveHistoryFile()) } val batch = withContext(Dispatchers.IO) { reader.loadConversationBatch( botId = botId, groupId = groupId, startTime = startTime, endTime = endTime, messageLimit = PluginConfig.profileAutoConversationMessageLimit.coerceIn(20, 500), maxMessageChars = PluginConfig.profileMaxMessageChars.coerceAtLeast(80), ) } ?: return null return analyzeConversationBatch( batch = batch, minAuthoredTextChars = minAuthoredTextChars, model = model, retryMax = PluginConfig.profileRetryMax, summaryMaxLength = PluginConfig.profileSummaryMaxLength, onRetryFailure = { message, cause -> JChatGPT.logger.warning(message, cause) }, onCommittedOperations = ProfileOperationLogger::log, ) } internal suspend fun analyzeConversationBatch( batch: ConversationProfileBatch, minAuthoredTextChars: Int, model: ConversationProfileModel, retryMax: Int, summaryMaxLength: Int, onRetryFailure: (String, Throwable) -> Unit = { _, _ -> }, onCommittedOperations: (String, Collection) -> Unit = { _, _ -> }, ): ConversationProfileAnalysisReport? { check(UserProfileStore.isAvailable) { "用户画像数据库不可用" } val eligibleUserIds = batch.authoredTextCharsByUser .filterValues { it >= minAuthoredTextChars.coerceAtLeast(1) } .keys if (eligibleUserIds.isEmpty()) return null var profiles = userLocks.withUserLocks(eligibleUserIds) { withContext(Dispatchers.IO) { if (UserProfileStore.isConversationProcessed(batch.inputHash)) null else loadConversationProfiles(eligibleUserIds) } } ?: return null var conflictRetries = 0 var totalUsage = ProfileTokenUsage() while (true) { val (result, reductions) = analyzeConversationWithRetry( model = model, profiles = profiles, batch = batch, eligibleUserIds = eligibleUserIds, retryMax = retryMax, summaryMaxLength = summaryMaxLength, onRetryFailure = onRetryFailure, ) totalUsage += result.usage val commitOutcome = userLocks.withUserLocks(eligibleUserIds) { withContext(Dispatchers.IO) { if (UserProfileStore.isConversationProcessed(batch.inputHash)) { ConversationCommitOutcome.AlreadyProcessed } else { val latestProfiles = loadConversationProfiles(eligibleUserIds) if (hasProfileVersionConflict(profiles, latestProfiles)) { ConversationCommitOutcome.Conflict(latestProfiles) } else { UserProfileStore.commitConversation( reductions = reductions.map { reduction -> reduction to batch.forUser(reduction.profile.userId) }, usage = totalUsage, ) ConversationCommitOutcome.Committed } } } } when (commitOutcome) { ConversationCommitOutcome.AlreadyProcessed -> return null ConversationCommitOutcome.Committed -> { onCommittedOperations( "source=CONVERSATION bot=${batch.botId} group=${batch.groupId} " + "batch=[${batch.startTime},${batch.endTime})", reductions, ) return ConversationProfileAnalysisReport( analyzedUsers = eligibleUserIds.size, processedMessages = batch.messages.size, appliedOperations = reductions.sumOf { it.operations.size }, skippedOperations = reductions.sumOf { it.skippedOperations.size }, usage = totalUsage, ) } is ConversationCommitOutcome.Conflict -> { conflictRetries++ if (conflictRetries > MAX_CONVERSATION_CONFLICT_RETRIES) { throw IllegalStateException( "群 ${batch.groupId} 会话画像提交连续冲突 $conflictRetries 次,未提交结果" ) } profiles = commitOutcome.latestProfiles } } } } private fun loadConversationProfiles(userIds: Set): Map = userIds.associateWith { userId -> UserProfileStore.load(userId) ?: UserProfileSnapshot( userId = userId, cursorTime = 0, snapshotEndTime = 0, ) } private fun hasProfileVersionConflict( expectedProfiles: Map, latestProfiles: Map, ): Boolean = expectedProfiles.any { (userId, expected) -> latestProfiles[userId]?.version != expected.version } private suspend fun analyzeConversationWithRetry( model: ConversationProfileModel, profiles: Map, batch: ConversationProfileBatch, eligibleUserIds: Set, retryMax: Int, summaryMaxLength: Int, onRetryFailure: (String, Throwable) -> Unit, ): Pair> { val attempts = retryMax.coerceIn(0, 3) + 1 val retryBackoff = RetryBackoff.fromConfig() var lastFailure: Throwable? = null repeat(attempts) { attempt -> try { val result = model.analyzeConversation(profiles, batch, eligibleUserIds) val reductions = ConversationProfileReducer.reduce( profiles = profiles, batch = batch, eligibleUserIds = eligibleUserIds, response = result.response, model = model.modelName, promptVersion = ProfilePromptStore.PROMPT_VERSION, summaryMaxLength = summaryMaxLength.coerceAtLeast(100), ) return result to reductions } catch (cause: Exception) { if (cause is CancellationException) throw cause lastFailure = cause handleRetryFailure( attempt = attempt, attempts = attempts, message = "群 ${batch.groupId} 会话画像 [${batch.startTime}, ${batch.endTime}) " + "第 ${attempt + 1}/$attempts 次分析失败", cause = cause, retryBackoff = retryBackoff, logFailure = onRetryFailure, ) } } throw IllegalStateException( "会话画像 [${batch.startTime}, ${batch.endTime}) 连续 $attempts 次分析失败,未提交任何结果", lastFailure, ) } private suspend fun analyzeWithRetry( model: ProfileModel, profile: UserProfileSnapshot, batch: ProfileHistoryBatch, advanceBackfillCursor: Boolean = true, ): Pair { val attempts = PluginConfig.profileRetryMax.coerceIn(0, 3) + 1 val retryBackoff = RetryBackoff.fromConfig() var lastFailure: Throwable? = null repeat(attempts) { attempt -> try { val result = model.analyze(profile, batch) val reduction = UserProfileReducer.reduce( current = profile, batch = batch, response = result.response, model = model.modelName, promptVersion = ProfilePromptStore.PROMPT_VERSION, summaryMaxLength = PluginConfig.profileSummaryMaxLength.coerceAtLeast(100), advanceBackfillCursor = advanceBackfillCursor, ) logSkippedOperations( "用户 ${batch.userId} 画像批次 [${batch.startTime}, ${batch.endTime})", reduction.skippedOperations, ) return result to reduction } catch (cause: Exception) { if (cause is CancellationException) throw cause lastFailure = cause handleRetryFailure( attempt = attempt, attempts = attempts, message = "用户 ${batch.userId} 画像批次 [${batch.startTime}, ${batch.endTime}) " + "第 ${attempt + 1}/$attempts 次分析失败", cause = cause, retryBackoff = retryBackoff, logFailure = JChatGPT.logger::warning, ) } } throw IllegalStateException( "画像批次 [${batch.startTime}, ${batch.endTime}) 连续 $attempts 次分析失败,水位线未推进", lastFailure, ) } private suspend fun compactWithRetry( model: ProfileCompactionModel, profile: UserProfileSnapshot, supportStats: Map, ): Pair { val attempts = PluginConfig.profileRetryMax.coerceIn(0, 3) + 1 val retryBackoff = RetryBackoff.fromConfig() var lastFailure: Throwable? = null repeat(attempts) { attempt -> try { val result = model.compact(profile, supportStats) val plan = UserProfileCompactor.reduce( current = profile, supportStats = supportStats, response = result.response, model = model.modelName, promptVersion = ProfilePromptStore.COMPACTION_PROMPT_VERSION, summaryMaxLength = PluginConfig.profileSummaryMaxLength.coerceAtLeast(100), ) return result to plan } catch (cause: Exception) { if (cause is CancellationException) throw cause lastFailure = cause handleRetryFailure( attempt = attempt, attempts = attempts, message = "用户 ${profile.userId} 画像压缩第 ${attempt + 1}/$attempts 次失败", cause = cause, retryBackoff = retryBackoff, logFailure = JChatGPT.logger::warning, ) } } throw IllegalStateException( "用户 ${profile.userId} 画像压缩连续 $attempts 次失败,未提交任何结果", lastFailure, ) } private suspend fun handleRetryFailure( attempt: Int, attempts: Int, message: String, cause: Throwable, retryBackoff: RetryBackoff, logFailure: (String, Throwable) -> Unit, ) { if (attempt + 1 >= attempts) { logFailure("$message,已无剩余尝试", cause) return } val retryDelayMillis = retryBackoff.delayMillis(attempt + 1) logFailure("$message,将在 ${retryDelayMillis}ms 后重试", cause) if (retryDelayMillis > 0) delay(retryDelayMillis) } private fun compactionBatch(profile: UserProfileSnapshot, rawResponse: String): ProfileHistoryBatch { val digest = MessageDigest.getInstance("SHA-256") .digest("${profile.userId}|${profile.version}|$rawResponse".toByteArray(Charsets.UTF_8)) .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } val startTime = profile.items.minOfOrNull(UserProfileItem::firstSeenAt) ?: profile.cursorTime val lastConfirmedAt = profile.items.maxOfOrNull(UserProfileItem::lastConfirmedAt) ?: startTime val endTime = if (lastConfirmedAt == Int.MAX_VALUE) lastConfirmedAt else lastConfirmedAt + 1 return ProfileHistoryBatch( userId = profile.userId, startTime = startTime, endTime = endTime, messages = emptyList(), aliases = mapOf(profile.userId to "TARGET"), inputHash = "compact-$digest", ) } private fun resolveHistoryFile(): File { val configured = PluginConfig.profileHistoryDatabasePath.trim() return if (configured.isNotEmpty()) { File(configured).absoluteFile } else { checkNotNull(ChatHistoryStore.databaseFileOrNull) { "聊天记录数据库不可用" } } } private fun resolveLiveHistoryFile(): File = checkNotNull(ChatHistoryStore.databaseFileOrNull) { "聊天记录数据库不可用" } private fun emptyReport(userId: Long) = ProfileAnalysisReport( userId = userId, processedBatches = 0, processedMessages = 0, appliedOperations = 0, skippedOperations = 0, usage = ProfileTokenUsage(), profile = null, caughtUp = true, ) private fun emptyGroupReport(groupId: Long) = GroupProfileAnalysisReport( botId = null, groupId = groupId, processedBatches = 0, processedMessages = 0, analyzedUsers = 0, appliedOperations = 0, skippedOperations = 0, usage = ProfileTokenUsage(), cursorTime = 0, snapshotEndTime = 0, caughtUp = true, ) private fun unchangedCompactionReport( profile: UserProfileSnapshot, alreadyRunning: Boolean = false, stopped: Boolean = false, ) = ProfileCompactionReport( userId = profile.userId, beforeItems = profile.items.size, afterItems = profile.items.size, mergedGroups = 0, rewrittenItems = 0, deletedItems = 0, summaryChanged = false, skippedOperations = 0, usage = ProfileTokenUsage(), profile = profile, alreadyRunning = alreadyRunning, stopped = stopped, ) private operator fun ProfileTokenUsage.plus(other: ProfileTokenUsage) = ProfileTokenUsage( promptTokens = promptTokens + other.promptTokens, completionTokens = completionTokens + other.completionTokens, cachedTokens = cachedTokens + other.cachedTokens, ) private fun logSkippedOperations(context: String, skipped: List) { if (skipped.isEmpty()) return JChatGPT.logger.warning( "$context 跳过 ${skipped.size} 项无效建议:" + skipped.take(8).joinToString(";") + if (skipped.size > 8) ";其余 ${skipped.size - 8} 项已省略" else "" ) } private sealed class ConversationCommitOutcome { object Committed : ConversationCommitOutcome() object AlreadyProcessed : ConversationCommitOutcome() data class Conflict( val latestProfiles: Map, ) : ConversationCommitOutcome() } } internal fun isGroupAnalysisPending( bounds: ProfileHistoryReader.GroupTimeBounds, cursor: GroupProfileCursor?, ): Boolean = cursor == null || cursor.cursorTime < maxOf(cursor.snapshotEndTime, bounds.endTime)