From 3c69bdeff4a4cdc0cffb087ba02d5bc3515f8caf Mon Sep 17 00:00:00 2001 From: jie65535 Date: Wed, 5 Aug 2026 20:42:44 +0800 Subject: [PATCH] conversation: deduplicate profile context --- .../conversation/ConversationContext.kt | 95 ++++++++++++------ .../kotlin/conversation/ConversationEngine.kt | 33 ++++++- .../conversation/UserProfileInjectionState.kt | 54 +++++++++++ .../profile/UserProfileContextRenderer.kt | 97 ++++++++++++------- .../UserProfileInjectionStateTest.kt | 95 ++++++++++++++++++ .../profile/UserProfileContextRendererTest.kt | 16 +++ 6 files changed, 325 insertions(+), 65 deletions(-) create mode 100644 src/main/kotlin/conversation/UserProfileInjectionState.kt create mode 100644 src/test/kotlin/conversation/UserProfileInjectionStateTest.kt diff --git a/src/main/kotlin/conversation/ConversationContext.kt b/src/main/kotlin/conversation/ConversationContext.kt index 68bc892..7cc2c78 100644 --- a/src/main/kotlin/conversation/ConversationContext.kt +++ b/src/main/kotlin/conversation/ConversationContext.kt @@ -48,6 +48,7 @@ internal data class ConversationCache( val lastActivityAt: Int, val replyIndex: ReplyIndex, val imageIndex: ImageIndex, + val profileInjectionState: UserProfileInjectionState, ) { fun isExpired(ttlSeconds: Int): Boolean = OffsetDateTime.now().toEpochSecond().toInt() - lastActivityAt > ttlSeconds @@ -153,7 +154,7 @@ internal object ConversationContext { return prompt.toString() } - fun getHistory(event: MessageEvent): String { + fun getHistory(event: MessageEvent, profileInjectionState: UserProfileInjectionState): String { val imageIndex = activeImageIndex(event.subject.id) if (!JChatGPT.includeHistory) { return formatRecordContent(event.message, event.subject, imageIndex) @@ -162,10 +163,20 @@ internal object ConversationContext { .minusMinutes(PluginConfig.historyWindowMin.toLong()) .toEpochSecond() .toInt() - return getAfterHistory(beforeTimestamp, event, PluginConfig.historyMessageLimit) + return getAfterHistory( + time = beforeTimestamp, + event = event, + profileInjectionState = profileInjectionState, + limit = PluginConfig.historyMessageLimit, + ) } - fun getAfterHistory(time: Int, event: MessageEvent, limit: Int? = null): String { + fun getAfterHistory( + time: Int, + event: MessageEvent, + profileInjectionState: UserProfileInjectionState, + limit: Int? = null, + ): String { if (!JChatGPT.includeHistory) return "" val history = try { ChatHistoryStore.query( @@ -191,7 +202,7 @@ internal object ConversationContext { val replyIndex = activeReplyIndex(event.subject.id) val imageIndex = activeImageIndex(event.subject.id) if (event is GroupMessageEvent) { - appendUserProfileContext(result, history, event) + appendUserProfileContext(result, history, event, profileInjectionState) result.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号;正文[图片n]/[表情包n]中的n为识图或图片编辑编号)") history.forEach { record -> val showSender = lastUserId != record.fromId @@ -201,7 +212,7 @@ internal object ConversationContext { lastTime = record.time.toLong() } } else { - appendPrivateUserContext(result, event) + appendPrivateUserContext(result, event, profileInjectionState) result.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号;正文[图片n]/[表情包n]中的n为识图或图片编辑编号)") history.forEach { record -> val showSender = lastUserId != record.fromId @@ -273,6 +284,7 @@ internal object ConversationContext { target: StringBuilder, history: List, event: GroupMessageEvent, + profileInjectionState: UserProfileInjectionState, ) { if (!PluginConfig.profileAutoInjectEnabled && !PluginConfig.enableFavorabilitySystem) return val candidateIds = buildList { @@ -283,15 +295,21 @@ internal object ConversationContext { .distinct() .take(PluginConfig.profileAutoInjectMaxUsers.coerceIn(1, 10)) .toList() - val profiles = if (PluginConfig.profileEnabled && PluginConfig.profileAutoInjectEnabled && - UserProfileStore.isAvailable - ) { + val shouldLoadProfiles = PluginConfig.profileEnabled && PluginConfig.profileAutoInjectEnabled + val uncertainProfileIds = mutableSetOf() + val profiles = if (shouldLoadProfiles && UserProfileStore.isAvailable) { candidateIds.mapNotNull { userId -> runCatching { UserProfileStore.load(userId) } - .onFailure { JChatGPT.logger.warning("读取用户画像失败: user=$userId", it) } + .onFailure { + uncertainProfileIds += userId + JChatGPT.logger.warning("读取用户画像失败: user=$userId", it) + } .getOrNull() } - } else emptyList() + } else { + if (shouldLoadProfiles) uncertainProfileIds += candidateIds + emptyList() + } val favorability = if (PluginConfig.enableFavorabilitySystem) { candidateIds.mapNotNull { id -> PluginData.userFavorability[id]?.let { id to it } }.toMap() } else emptyMap() @@ -301,26 +319,41 @@ internal object ConversationContext { val names = candidateIds.associateWith { id -> event.group[id]?.nameCardOrNick ?: snapshotNames[id] ?: id.toString() } + val renderedEntries = UserProfileContextRenderer.renderEntries( + profiles = profiles, + favorabilityByUserId = favorability, + displayNames = names, + activeUserIds = candidateIds.toSet(), + summaryMaxChars = PluginConfig.profileAutoInjectSummaryMaxChars, + ) + val comparableCandidateIds = candidateIds.filter { userId -> + userId !in uncertainProfileIds || !profileInjectionState.hasSeen(userId) + } target.append( - UserProfileContextRenderer.render( - profiles = profiles, - favorabilityByUserId = favorability, - displayNames = names, - activeUserIds = candidateIds.toSet(), - summaryMaxChars = PluginConfig.profileAutoInjectSummaryMaxChars, + profileInjectionState.renderChanges( + candidateUserIds = comparableCandidateIds, + renderedEntries = renderedEntries, + sectionTitle = "你对相关群友的认识", ) ) } - private fun appendPrivateUserContext(target: StringBuilder, event: MessageEvent) { + private fun appendPrivateUserContext( + target: StringBuilder, + event: MessageEvent, + profileInjectionState: UserProfileInjectionState, + ) { if (!PluginConfig.profileAutoInjectEnabled && !PluginConfig.enableFavorabilitySystem) return val userId = event.sender.id - val profiles = if (PluginConfig.profileEnabled && PluginConfig.profileAutoInjectEnabled && - UserProfileStore.isAvailable - ) { + val shouldLoadProfile = PluginConfig.profileEnabled && PluginConfig.profileAutoInjectEnabled + var profileReadUncertain = shouldLoadProfile && !UserProfileStore.isAvailable + val profiles = if (shouldLoadProfile && UserProfileStore.isAvailable) { listOfNotNull( runCatching { UserProfileStore.load(userId) } - .onFailure { JChatGPT.logger.warning("读取用户画像失败: user=$userId", it) } + .onFailure { + profileReadUncertain = true + JChatGPT.logger.warning("读取用户画像失败: user=$userId", it) + } .getOrNull() ) } else emptyList() @@ -330,13 +363,21 @@ internal object ConversationContext { val snapshotName = runCatching { ContactSnapshotStore.loadDisplayName(event.bot.id, null, userId) }.getOrNull() + val renderedEntries = UserProfileContextRenderer.renderEntries( + profiles = profiles, + favorabilityByUserId = favorability, + displayNames = mapOf(userId to (snapshotName ?: event.senderName)), + activeUserIds = setOf(userId), + summaryMaxChars = PluginConfig.profileAutoInjectSummaryMaxChars, + ) target.append( - UserProfileContextRenderer.render( - profiles = profiles, - favorabilityByUserId = favorability, - displayNames = mapOf(userId to (snapshotName ?: event.senderName)), - activeUserIds = setOf(userId), - summaryMaxChars = PluginConfig.profileAutoInjectSummaryMaxChars, + profileInjectionState.renderChanges( + candidateUserIds = if (profileReadUncertain && profileInjectionState.hasSeen(userId)) { + emptyList() + } else { + listOf(userId) + }, + renderedEntries = renderedEntries, sectionTitle = "你对对方的认识", ) ) diff --git a/src/main/kotlin/conversation/ConversationEngine.kt b/src/main/kotlin/conversation/ConversationEngine.kt index f1d5dce..e1e767e 100644 --- a/src/main/kotlin/conversation/ConversationEngine.kt +++ b/src/main/kotlin/conversation/ConversationEngine.kt @@ -146,16 +146,22 @@ internal object ConversationEngine { JChatGPT.logger.info("使用缓存的对话上下文,包含 ${cache.history.size} 条互动消息") cache.history } else mutableListOf() + val profileInjectionState = cache?.profileInjectionState?.takeIf { reuseCache } + ?: UserProfileInjectionState() if (history.isEmpty() || cache == null) { val prompt = ConversationContext.getSystemPrompt(currentEvent) if (PluginConfig.logPrompt) JChatGPT.logger.info("Prompt: $prompt") history += ChatMessage(ChatRole.System, prompt) - val historyText = ConversationContext.getHistory(currentEvent) + val historyText = ConversationContext.getHistory(currentEvent, profileInjectionState) JChatGPT.logger.info("注入聊天记录:\n$historyText") history += ChatMessage.User(historyText) } else { - val newMessages = ConversationContext.getAfterHistory(cache.lastActivityAt, currentEvent) + val newMessages = ConversationContext.getAfterHistory( + time = cache.lastActivityAt, + event = currentEvent, + profileInjectionState = profileInjectionState, + ) JChatGPT.logger.info("补充聊天记录:\n$newMessages") history += ChatMessage.User( if (resumedWait == null) { @@ -281,6 +287,7 @@ internal object ConversationEngine { startedAt = startedAt, event = currentEvent, pendingTrigger = pendingEvent != null, + profileInjectionState = profileInjectionState, ) ) done = false @@ -288,7 +295,13 @@ internal object ConversationEngine { if (PluginConfig.enableContextCache) { ConversationContext.saveCache( subjectId, - ConversationCache(history, startedAt, replyIndex, imageIndex), + ConversationCache( + history = history, + lastActivityAt = startedAt, + replyIndex = replyIndex, + imageIndex = imageIndex, + profileInjectionState = profileInjectionState, + ), ) JChatGPT.logger.debug("已保存对话上下文到缓存") } @@ -311,6 +324,7 @@ internal object ConversationEngine { startedAt = startedAt, event = currentEvent, pendingTrigger = true, + profileInjectionState = profileInjectionState, ) ) done = false @@ -492,6 +506,7 @@ internal object ConversationEngine { startedAt: Int, event: MessageEvent, pendingTrigger: Boolean, + profileInjectionState: UserProfileInjectionState, ): String = buildString { appendLine("## 系统提示") append("本次运行最多还剩").append(remainingRounds).appendLine("轮。") @@ -499,8 +514,16 @@ internal object ConversationEngine { appendLine("如果没有什么要做的,可以提前结束。") if (pendingTrigger) appendLine("运行期间收到了新的显式触发,请优先处理水位后的新消息。") appendLine("当前时间:${dateTimeFormatter.format(OffsetDateTime.now())}") - val messages = ConversationContext.getAfterHistory(startedAt, event).ifEmpty { - if (pendingTrigger && !JChatGPT.includeHistory) ConversationContext.getHistory(event) else "" + val messages = ConversationContext.getAfterHistory( + time = startedAt, + event = event, + profileInjectionState = profileInjectionState, + ).ifEmpty { + if (pendingTrigger && !JChatGPT.includeHistory) { + ConversationContext.getHistory(event, profileInjectionState) + } else { + "" + } } if (messages.isNotEmpty()) append("## 以下是上次运行至今的新消息\n\n$messages") } diff --git a/src/main/kotlin/conversation/UserProfileInjectionState.kt b/src/main/kotlin/conversation/UserProfileInjectionState.kt new file mode 100644 index 0000000..abe88d7 --- /dev/null +++ b/src/main/kotlin/conversation/UserProfileInjectionState.kt @@ -0,0 +1,54 @@ +package top.jie65535.mirai.conversation + +internal class UserProfileInjectionState { + private val renderedByUserId = mutableMapOf() + private var guidanceInjected = false + + fun hasSeen(userId: Long): Boolean = renderedByUserId.containsKey(userId) + + fun renderChanges( + candidateUserIds: Collection, + renderedEntries: Map, + sectionTitle: String, + ): String { + val changedEntries = mutableListOf() + val clearedUserIds = mutableListOf() + + candidateUserIds.distinct().forEach { userId -> + val current = renderedEntries[userId] + if (!renderedByUserId.containsKey(userId)) { + renderedByUserId[userId] = current + if (current != null) changedEntries += current + return@forEach + } + + if (renderedByUserId[userId] == current) return@forEach + renderedByUserId[userId] = current + if (current == null) clearedUserIds += userId else changedEntries += current + } + + if (changedEntries.isEmpty() && clearedUserIds.isEmpty()) return "" + + val firstInjection = !guidanceInjected + guidanceInjected = true + return buildString { + append("## ").append(sectionTitle) + if (!firstInjection) append("(更新)") + appendLine() + if (firstInjection) { + appendLine( + "好感度、代号和主观印象代表你的关系状态;画像认识来自可修正的历史归纳。" + + "仅在当前话题相关时自然运用,不要逐条复述或提及信息来源。" + ) + } else { + appendLine("以下仅列出新增或发生变化的条目;同一用户以本段为准,未列出的认识保持不变。") + } + changedEntries.forEach(::append) + clearedUserIds.forEach { userId -> + append("- 用户(").append(userId) + .appendLine("):当前已无可用的关系状态或画像认识,请忽略此前对应信息。") + } + appendLine() + } + } +} diff --git a/src/main/kotlin/profile/UserProfileContextRenderer.kt b/src/main/kotlin/profile/UserProfileContextRenderer.kt index 6ccab61..3e862a5 100644 --- a/src/main/kotlin/profile/UserProfileContextRenderer.kt +++ b/src/main/kotlin/profile/UserProfileContextRenderer.kt @@ -11,6 +11,30 @@ object UserProfileContextRenderer { summaryMaxChars: Int, sectionTitle: String = "你对相关群友的认识", ): String { + val entries = renderEntries( + profiles = profiles, + favorabilityByUserId = favorabilityByUserId, + displayNames = displayNames, + activeUserIds = activeUserIds, + summaryMaxChars = summaryMaxChars, + ) + if (entries.isEmpty()) return "" + + return buildString { + append("## ").appendLine(sectionTitle) + appendLine(CONTEXT_GUIDANCE) + entries.values.forEach(::append) + appendLine() + } + } + + internal fun renderEntries( + profiles: List, + favorabilityByUserId: Map, + displayNames: Map, + activeUserIds: Set, + summaryMaxChars: Int, + ): Map { val profilesByUserId = profiles .filter { profile -> profile.reliable && @@ -20,50 +44,53 @@ object UserProfileContextRenderer { val userIds = activeUserIds.filter { userId -> userId in profilesByUserId || favorabilityByUserId[userId]?.hasVisibleContext() == true } - if (userIds.isEmpty()) return "" + if (userIds.isEmpty()) return emptyMap() val maxChars = summaryMaxChars.coerceAtLeast(50) - return buildString { - append("## ").appendLine(sectionTitle) - appendLine("好感度、代号和主观印象代表你的关系状态;画像认识来自可修正的历史归纳。仅在当前话题相关时自然运用,不要逐条复述或提及信息来源。") + return buildMap { userIds.forEach { userId -> val profile = profilesByUserId[userId] val favorability = favorabilityByUserId[userId] val name = favorability?.name.orEmpty().ifBlank { displayNames[userId].orEmpty().ifBlank { userId.toString() } } - append("- ").append(name).append('(').append(userId).append(')') - favorability?.takeIf { it.hasVisibleContext() }?.let { info -> - append(" | 好感度").append(if (info.value >= 0) "+" else "").append(info.value) - if (info.tags.isNotEmpty()) append(" | 标签:").append(info.tags.joinToString("、")) - if (info.impression.isNotBlank()) append(" | 主观印象:").append(info.impression.normalized()) - } - profile?.summary?.let(ProfilePersistentText::summaryForDisplay) - ?.takeIf(String::isNotBlank)?.let { summary -> - append(" | 画像认识") - append("(").append(profile.items.size).append("条):") - .append(summary.normalized().take(maxChars)) - } ?: profile?.takeIf { it.items.isNotEmpty() }?.let { - append(" | 画像认识:已有").append(it.items.size).append("条记录,可用 queryUserProfile 查询详情") - } + put(userId, buildString { + append("- ").append(name).append('(').append(userId).append(')') + favorability?.takeIf { it.hasVisibleContext() }?.let { info -> + append(" | 好感度").append(if (info.value >= 0) "+" else "").append(info.value) + if (info.tags.isNotEmpty()) append(" | 标签:").append(info.tags.joinToString("、")) + if (info.impression.isNotBlank()) append(" | 主观印象:").append(info.impression.normalized()) + } + profile?.summary?.let(ProfilePersistentText::summaryForDisplay) + ?.takeIf(String::isNotBlank)?.let { summary -> + append(" | 画像认识") + append("(").append(profile.items.size).append("条):") + .append(summary.normalized().take(maxChars)) + } ?: profile?.takeIf { it.items.isNotEmpty() }?.let { + append(" | 画像认识:已有").append(it.items.size) + .append("条记录,可用 queryUserProfile 查询详情") + } - profile?.items?.asSequence() - ?.filter { item -> - item.category == ProfileCategory.RELATIONSHIP_NOTE && - item.relatedUserId != null && item.relatedUserId in activeUserIds - } - ?.take(2) - ?.forEach { item -> - val relatedId = checkNotNull(item.relatedUserId) - val relatedName = favorabilityByUserId[relatedId]?.name.orEmpty().ifBlank { - displayNames[relatedId].orEmpty().ifBlank { relatedId.toString() } + profile?.items?.asSequence() + ?.filter { item -> + item.category == ProfileCategory.RELATIONSHIP_NOTE && + item.relatedUserId != null && item.relatedUserId in activeUserIds } - append(";与").append(relatedName).append(":") - .append(ProfilePersistentText.itemForDisplay(item.content, relationship = true).normalized()) - } - appendLine() + ?.take(2) + ?.forEach { item -> + val relatedId = checkNotNull(item.relatedUserId) + val relatedName = favorabilityByUserId[relatedId]?.name.orEmpty().ifBlank { + displayNames[relatedId].orEmpty().ifBlank { relatedId.toString() } + } + append(";与").append(relatedName).append(":") + .append( + ProfilePersistentText.itemForDisplay(item.content, relationship = true) + .normalized() + ) + } + appendLine() + }) } - appendLine() } } @@ -71,4 +98,8 @@ object UserProfileContextRenderer { value != 0 || name.isNotBlank() || tags.isNotEmpty() || impression.isNotBlank() private fun String.normalized(): String = trim().replace(Regex("\\s+"), " ") + + private const val CONTEXT_GUIDANCE = + "好感度、代号和主观印象代表你的关系状态;画像认识来自可修正的历史归纳。" + + "仅在当前话题相关时自然运用,不要逐条复述或提及信息来源。" } diff --git a/src/test/kotlin/conversation/UserProfileInjectionStateTest.kt b/src/test/kotlin/conversation/UserProfileInjectionStateTest.kt new file mode 100644 index 0000000..c396e29 --- /dev/null +++ b/src/test/kotlin/conversation/UserProfileInjectionStateTest.kt @@ -0,0 +1,95 @@ +package top.jie65535.mirai.conversation + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class UserProfileInjectionStateTest { + @Test + fun injectsVisibleEntriesOnce() { + val state = UserProfileInjectionState() + val entries = mapOf( + 100L to "- 小明(100) | 好感度+3\n", + 200L to "- 小王(200) | 画像认识:熟悉 Kotlin\n", + ) + + val initial = state.renderChanges(entries.keys, entries, "你对相关群友的认识") + + assertContains(initial, "## 你对相关群友的认识") + assertContains(initial, entries.getValue(100L).trim()) + assertContains(initial, entries.getValue(200L).trim()) + assertEquals("", state.renderChanges(entries.keys, entries, "你对相关群友的认识")) + } + + @Test + fun emitsOnlyNewAndChangedUsers() { + val state = UserProfileInjectionState() + state.renderChanges( + candidateUserIds = listOf(100L, 200L), + renderedEntries = mapOf( + 100L to "- 小明(100) | 好感度+3\n", + 200L to "- 小王(200) | 好感度+1\n", + ), + sectionTitle = "你对相关群友的认识", + ) + + val update = state.renderChanges( + candidateUserIds = listOf(100L, 200L, 300L), + renderedEntries = mapOf( + 100L to "- 小明(100) | 好感度+3\n", + 200L to "- 小王(200) | 好感度+5\n", + 300L to "- 小李(300) | 主观印象:表达直接\n", + ), + sectionTitle = "你对相关群友的认识", + ) + + assertContains(update, "## 你对相关群友的认识(更新)") + assertContains(update, "小王(200) | 好感度+5") + assertContains(update, "小李(300) | 主观印象:表达直接") + assertFalse(update.contains("小明(100)")) + } + + @Test + fun explicitlyClearsPreviouslyVisibleContext() { + val state = UserProfileInjectionState() + state.renderChanges( + candidateUserIds = listOf(100L), + renderedEntries = mapOf(100L to "- 小明(100) | 好感度-2\n"), + sectionTitle = "你对相关群友的认识", + ) + + val update = state.renderChanges( + candidateUserIds = listOf(100L), + renderedEntries = emptyMap(), + sectionTitle = "你对相关群友的认识", + ) + + assertContains(update, "用户(100)") + assertContains(update, "请忽略此前对应信息") + assertEquals( + "", + state.renderChanges(listOf(100L), emptyMap(), "你对相关群友的认识"), + ) + } + + @Test + fun delaysGuidanceUntilAnInvisibleUserGetsContext() { + val state = UserProfileInjectionState() + + assertEquals( + "", + state.renderChanges(listOf(100L), emptyMap(), "你对对方的认识"), + ) + + val firstVisible = state.renderChanges( + candidateUserIds = listOf(100L), + renderedEntries = mapOf(100L to "- 小明(100) | 画像认识:熟悉数据库\n"), + sectionTitle = "你对对方的认识", + ) + + assertContains(firstVisible, "## 你对对方的认识\n") + assertFalse(firstVisible.contains("(更新)")) + assertContains(firstVisible, "仅在当前话题相关时自然运用") + } +} diff --git a/src/test/kotlin/profile/UserProfileContextRendererTest.kt b/src/test/kotlin/profile/UserProfileContextRendererTest.kt index 669a45e..256927e 100644 --- a/src/test/kotlin/profile/UserProfileContextRendererTest.kt +++ b/src/test/kotlin/profile/UserProfileContextRendererTest.kt @@ -3,6 +3,7 @@ package top.jie65535.mirai.profile import top.jie65535.mirai.data.FavorabilityInfo import kotlin.test.Test import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertFalse class UserProfileContextRendererTest { @@ -80,6 +81,21 @@ class UserProfileContextRendererTest { assertContains(rendered, "主观印象:长期活跃的老群友") } + @Test + fun perUserEntriesIgnoreFavorabilityFieldsHiddenFromTheModel() { + fun render(reasons: List) = UserProfileContextRenderer.renderEntries( + profiles = emptyList(), + favorabilityByUserId = mapOf( + 100L to FavorabilityInfo(userId = 100, value = 5, reasons = reasons) + ), + displayNames = mapOf(100L to "小明"), + activeUserIds = setOf(100L), + summaryMaxChars = 300, + ) + + assertEquals(render(listOf("旧原因")), render(listOf("更新但不展示的原因"))) + } + private fun relationship(id: String, relatedUserId: Long, content: String) = UserProfileItem( id = id, category = ProfileCategory.RELATIONSHIP_NOTE,