Files
JChatGPT/src/main/kotlin/profile/UserProfileAnalysisService.kt
T

770 lines
33 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Long>()
private val runningGroups = ConcurrentHashMap.newKeySet<Long>()
private val runningCompactions = ConcurrentHashMap.newKeySet<Long>()
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<Long> {
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<ProfileReduction>) -> 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<Long>): Map<Long, UserProfileSnapshot> =
userIds.associateWith { userId ->
UserProfileStore.load(userId) ?: UserProfileSnapshot(
userId = userId,
cursorTime = 0,
snapshotEndTime = 0,
)
}
private fun hasProfileVersionConflict(
expectedProfiles: Map<Long, UserProfileSnapshot>,
latestProfiles: Map<Long, UserProfileSnapshot>,
): Boolean = expectedProfiles.any { (userId, expected) ->
latestProfiles[userId]?.version != expected.version
}
private suspend fun analyzeConversationWithRetry(
model: ConversationProfileModel,
profiles: Map<Long, UserProfileSnapshot>,
batch: ConversationProfileBatch,
eligibleUserIds: Set<Long>,
retryMax: Int,
summaryMaxLength: Int,
onRetryFailure: (String, Throwable) -> Unit,
): Pair<ConversationProfileModelResult, List<ProfileReduction>> {
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<ProfileModelResult, ProfileReduction> {
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<String, ProfileItemSupportStats>,
): Pair<ProfileCompactionModelResult, ProfileCompactionPlan> {
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<String>) {
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<Long, UserProfileSnapshot>,
) : ConversationCommitOutcome()
}
}
internal fun isGroupAnalysisPending(
bounds: ProfileHistoryReader.GroupTimeBounds,
cursor: GroupProfileCursor?,
): Boolean = cursor == null || cursor.cursorTime < maxOf(cursor.snapshotEndTime, bounds.endTime)