From c931d39d20ff8652be90e45cc603f4f3118225d5 Mon Sep 17 00:00:00 2001 From: jie65535 Date: Wed, 5 Aug 2026 14:39:29 +0800 Subject: [PATCH] profile: refine query output and live batching --- README.md | 5 +- src/main/kotlin/config/PluginConfig.kt | 2 +- .../kotlin/profile/ProfileHistoryReader.kt | 77 +++++++++++++++++ .../profile/UserProfileAnalysisService.kt | 3 +- .../kotlin/tools/QueryUserProfileAgent.kt | 58 +++++++------ .../profile/ProfileHistoryReaderTest.kt | 83 +++++++++++++++++++ .../kotlin/tools/QueryUserProfileAgentTest.kt | 30 ++++++- 7 files changed, 226 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 7a267f8..04fa411 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ profileRetryMax: 2 profileSummaryMaxLength: 500 # 缓存会话闭合后自动维护画像;整段会话只调用一次模型 profileAutoUpdateEnabled: true -# 一次自动归纳最多读取最近150条会话消息 +# 群画像历史推进的目标消息数;实时自动维护按自然空窗读取完整连续会话 profileAutoConversationMessageLimit: 150 # 本人有效文本少于此字符数时不调用画像模型 profileAutoMinAuthoredTextChars: 20 @@ -236,7 +236,8 @@ searchHistoryMaxRecords: 5000 ### 渐进式历史画像 画像维护不需要群友执行命令。Bot 成功完成一轮群聊后,系统沿用上下文缓存的超时时间进行防抖;期间再次 -触发会合并为同一会话,缓存真正闭合时还会纳入 Bot 回复后的群聊消息。后台读取最近至多 150 条消息,只把 +触发会合并为同一会话,缓存真正闭合时还会纳入 Bot 回复后的群聊消息。后台从闭合点向前回溯到 20 分钟自然空窗, +连续会话不再按 150 条截断,但最多保留 800 条、70000 字或 24 小时。分析只把 本人有效文本达到门槛的账号列为候选,然后用一次模型调用同时比较所有候选人的当前画像并返回按用户分组的 `ADD / UPDATE / CONFIRM / DELETE` 操作。没有可靠变化的参与者仍会被标记为已检查,但不会生成空洞画像。 diff --git a/src/main/kotlin/config/PluginConfig.kt b/src/main/kotlin/config/PluginConfig.kt index 84255b2..5277767 100644 --- a/src/main/kotlin/config/PluginConfig.kt +++ b/src/main/kotlin/config/PluginConfig.kt @@ -135,7 +135,7 @@ object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("是否在群聊会话结束后静默自动维护相关用户画像") val profileAutoUpdateEnabled: Boolean by value(true) - @ValueDescription("自动画像分析一次闭合会话最多读取多少条最近消息") + @ValueDescription("群画像历史推进的目标消息数;实时自动维护改按自然空窗读取完整连续会话") val profileAutoConversationMessageLimit: Int by value(150) @ValueDescription("用户在会话中至少包含多少个本人文本字符才调用画像模型") diff --git a/src/main/kotlin/profile/ProfileHistoryReader.kt b/src/main/kotlin/profile/ProfileHistoryReader.kt index 772d12b..b66c5b0 100644 --- a/src/main/kotlin/profile/ProfileHistoryReader.kt +++ b/src/main/kotlin/profile/ProfileHistoryReader.kt @@ -328,6 +328,49 @@ class ProfileHistoryReader(private val databaseFile: File) { } } + fun loadLatestConversationBatch( + botId: Long, + groupId: Long, + startTime: Int, + endTime: Int, + maxMessageChars: Int, + idleGapSeconds: Int = ProfileConversationWindowDefaults.IDLE_GAP_SECONDS, + maxMessages: Int = ProfileConversationWindowDefaults.MAX_MESSAGES, + maxContentChars: Int = ProfileConversationWindowDefaults.MAX_CONTENT_CHARS, + maxSpanSeconds: Int = ProfileConversationWindowDefaults.MAX_PACKED_SPAN_SECONDS, + ): ConversationProfileBatch? { + require(startTime < endTime) { "startTime must be before endTime" } + val hardMessageLimit = maxMessages.coerceAtLeast(1) + return openReadConnection().use { connection -> + val recentRecords = queryConversationMessages( + connection = connection, + botId = botId, + groupId = groupId, + startTime = startTime, + endTime = endTime, + limit = hardMessageLimit.safeIncrement(), + ) + val records = selectLatestContinuousConversation( + records = recentRecords, + idleGapSeconds = idleGapSeconds.coerceAtLeast(0), + maxMessages = hardMessageLimit, + maxContentChars = maxContentChars.coerceAtLeast(1), + maxSpanSeconds = maxSpanSeconds.coerceAtLeast(1), + maxMessageChars = maxMessageChars, + ) + if (records.isEmpty()) return@use null + createConversationBatch( + botId = botId, + groupId = groupId, + startTime = records.first().time, + endTime = endTime, + records = records, + maxMessageChars = maxMessageChars, + episodeGapSeconds = idleGapSeconds.coerceAtLeast(0), + ) + } + } + fun loadNextConversationBatch( botId: Long, groupId: Long, @@ -429,6 +472,40 @@ class ProfileHistoryReader(private val databaseFile: File) { return records.last().time.safeNextSecond() } + private fun selectLatestContinuousConversation( + records: List, + idleGapSeconds: Int, + maxMessages: Int, + maxContentChars: Int, + maxSpanSeconds: Int, + maxMessageChars: Int, + ): List { + if (records.isEmpty()) return emptyList() + + val latestTime = records.last().time + var newerTime = latestTime + var startIndex = records.lastIndex + var selectedMessages = 0 + var selectedChars = 0L + + for (index in records.lastIndex downTo 0) { + val record = records[index] + if (selectedMessages > 0) { + val startsEarlierConversation = newerTime.toLong() - record.time > idleGapSeconds + val exceedsHardSize = selectedMessages >= maxMessages || + selectedChars + estimatePromptChars(record, maxMessageChars) > maxContentChars.toLong() + val exceedsSpan = latestTime.toLong() - record.time > maxSpanSeconds + if (startsEarlierConversation || exceedsHardSize || exceedsSpan) break + } + + startIndex = index + selectedMessages++ + selectedChars += estimatePromptChars(record, maxMessageChars) + newerTime = record.time + } + return records.subList(startIndex, records.size) + } + private fun estimatePromptChars(record: ChatMessageRecord, maxMessageChars: Int): Int = record.code.length.coerceAtMost(maxMessageChars.coerceAtLeast(80)) diff --git a/src/main/kotlin/profile/UserProfileAnalysisService.kt b/src/main/kotlin/profile/UserProfileAnalysisService.kt index 45301a4..8acf830 100644 --- a/src/main/kotlin/profile/UserProfileAnalysisService.kt +++ b/src/main/kotlin/profile/UserProfileAnalysisService.kt @@ -434,12 +434,11 @@ object UserProfileAnalysisService { val model: ConversationProfileModel = ProfileModelClient(endpoint) val reader = withContext(Dispatchers.IO) { ProfileHistoryReader(resolveLiveHistoryFile()) } val batch = withContext(Dispatchers.IO) { - reader.loadConversationBatch( + reader.loadLatestConversationBatch( botId = botId, groupId = groupId, startTime = startTime, endTime = endTime, - messageLimit = PluginConfig.profileAutoConversationMessageLimit.coerceIn(20, 500), maxMessageChars = PluginConfig.profileMaxMessageChars.coerceAtLeast(80), ) } ?: return null diff --git a/src/main/kotlin/tools/QueryUserProfileAgent.kt b/src/main/kotlin/tools/QueryUserProfileAgent.kt index c695629..43c318b 100644 --- a/src/main/kotlin/tools/QueryUserProfileAgent.kt +++ b/src/main/kotlin/tools/QueryUserProfileAgent.kt @@ -21,6 +21,7 @@ import top.jie65535.mirai.config.PluginConfig import top.jie65535.mirai.data.ContactSnapshotStore import top.jie65535.mirai.data.PluginData import top.jie65535.mirai.profile.ProfileCategory +import top.jie65535.mirai.profile.ProfileConfidence import top.jie65535.mirai.profile.ProfileItemSupportStats import top.jie65535.mirai.profile.ProfilePersistentText import top.jie65535.mirai.profile.UserProfileItem @@ -163,23 +164,25 @@ class QueryUserProfileAgent : BaseAgent( appendLine("条目:") selectProfileItems(profile.items) .forEach { item -> - append("- ") - append(item.category.label()).append('/').append(item.confidence.name.lowercase()) - append(" · ").append(formatDate(item.firstSeenAt)) - if (item.lastConfirmedAt != item.firstSeenAt) { - append("~").append(formatDate(item.lastConfirmedAt)) - } - append(" · ").append(formatSupportStats(supportStats[item.id])) - item.relatedUserId?.let { append(" · related=").append(it) } - append(":") - appendLine(ProfilePersistentText.itemForDisplay( - item.content, - relationship = item.category == ProfileCategory.RELATIONSHIP_NOTE, - )) + append("- ").appendLine(formatProfileItem(item, supportStats[item.id])) } } }.trim() + internal fun formatProfileItem( + item: UserProfileItem, + supportStats: ProfileItemSupportStats?, + ): String = buildString { + item.relatedUserId?.let { append("与用户 ").append(it).append(":") } + append(ProfilePersistentText.itemForDisplay( + item.content, + relationship = item.category == ProfileCategory.RELATIONSHIP_NOTE, + )) + append("〔").append(item.category.label()).append(',').append(item.confidence.label()) + .append(';').append(formatSupportStats(supportStats)) + .append(";最近确认于 ").append(formatDate(item.lastConfirmedAt)).append("〕") + } + private suspend fun loadPublicProfile(userId: Long, event: MessageEvent): UserProfile? { val key = "${event.bot.id}:$userId" val now = System.currentTimeMillis() @@ -221,7 +224,22 @@ class QueryUserProfileAgent : BaseAgent( if (fields.isNotEmpty()) appendLine("公开资料卡:${fields.joinToString(";")}") } - private fun ProfileCategory.label(): String = name.lowercase() + private fun ProfileCategory.label(): String = when (this) { + ProfileCategory.NOTABLE_FACT -> "事实" + ProfileCategory.INTEREST -> "兴趣" + ProfileCategory.EXPERTISE_SIGNAL -> "能力" + ProfileCategory.THINKING_STYLE -> "思考" + ProfileCategory.EXPRESSION_STYLE -> "表达" + ProfileCategory.SOCIAL_MODE -> "社交" + ProfileCategory.PREFERENCE -> "偏好" + ProfileCategory.RELATIONSHIP_NOTE -> "关系" + } + + private fun ProfileConfidence.label(): String = when (this) { + ProfileConfidence.LOW -> "低置信" + ProfileConfidence.MEDIUM -> "中置信" + ProfileConfidence.HIGH -> "高置信" + } private fun formatTime(epochSecond: Int): String = TIME_FORMATTER.format(Instant.ofEpochSecond(epochSecond.toLong())) @@ -229,16 +247,8 @@ class QueryUserProfileAgent : BaseAgent( private fun formatDate(epochSecond: Int): String = DATE_FORMATTER.format(Instant.ofEpochSecond(epochSecond.toLong())) - internal fun formatSupportStats(stats: ProfileItemSupportStats?): String = buildString { - append("支持=").append(stats?.count ?: 0).append("次") - stats?.takeIf { it.count > 0 }?.let { - append("(").append(formatDate(it.firstSupportedAt)) - if (it.lastSupportedAt != it.firstSupportedAt) { - append("~").append(formatDate(it.lastSupportedAt)) - } - append(")") - } - } + internal fun formatSupportStats(stats: ProfileItemSupportStats?): String = + "${stats?.count ?: 0}次支持" companion object { private const val PUBLIC_PROFILE_TIMEOUT_MS = 5_000L diff --git a/src/test/kotlin/profile/ProfileHistoryReaderTest.kt b/src/test/kotlin/profile/ProfileHistoryReaderTest.kt index c60e167..7bba0a3 100644 --- a/src/test/kotlin/profile/ProfileHistoryReaderTest.kt +++ b/src/test/kotlin/profile/ProfileHistoryReaderTest.kt @@ -312,6 +312,89 @@ class ProfileHistoryReaderTest { } } + @Test + fun loadsLatestContinuousConversationAcrossFormerMessageLimit() { + val directory = Files.createTempDirectory("jchatgpt-profile-latest-conversation-test-") + val database = directory.resolve("history.sqlite") + try { + DriverManager.getConnection("jdbc:sqlite:${database.absolutePathString()}").use { connection -> + connection.createStatement().use { statement -> + statement.executeUpdate( + """ + CREATE TABLE message_record( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bot_id INTEGER NOT NULL, + from_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + ids TEXT, + internal_ids TEXT, + time INTEGER NOT NULL, + kind INTEGER NOT NULL, + code TEXT NOT NULL, + recalled INTEGER NOT NULL DEFAULT 0 + ) + """.trimIndent() + ) + } + connection.prepareStatement( + "INSERT INTO message_record(" + + "bot_id, from_id, target_id, time, kind, code, recalled" + + ") VALUES (1, ?, 50, ?, ?, ?, 0)" + ).use { statement -> + fun insert(time: Int, text: String) { + statement.setLong(1, TARGET) + statement.setInt(2, time) + statement.setInt(3, MessageSourceKind.GROUP.ordinal) + statement.setString(4, """[{"type":"PlainText","content":"$text"}]""") + statement.executeUpdate() + } + + insert(100, "空窗前的旧消息") + repeat(160) { index -> insert(2_000 + index, "连续会话消息 $index") } + repeat(4) { index -> insert(2_159, "同秒高频消息 $index") } + } + } + + val reader = ProfileHistoryReader(database.toFile()) + val conversation = assertNotNull( + reader.loadLatestConversationBatch( + botId = 1, + groupId = 50, + startTime = 0, + endTime = 3_000, + maxMessageChars = 200, + idleGapSeconds = 30, + maxMessages = 800, + maxContentChars = 100_000, + maxSpanSeconds = 5_000, + ) + ) + assertEquals(164, conversation.messages.size) + assertEquals(2_000, conversation.startTime) + assertEquals(2_000, conversation.messages.first().record.time) + assertEquals(2_159, conversation.messages.last().record.time) + assertTrue(conversation.messages.all { it.episodeIndex == 1 }) + + val hardLimited = assertNotNull( + reader.loadLatestConversationBatch( + botId = 1, + groupId = 50, + startTime = 0, + endTime = 3_000, + maxMessageChars = 200, + idleGapSeconds = 30, + maxMessages = 3, + maxContentChars = 100_000, + maxSpanSeconds = 5_000, + ) + ) + assertEquals(3, hardLimited.messages.size) + assertEquals(listOf(2_159, 2_159, 2_159), hardLimited.messages.map { it.record.time }) + } finally { + directory.toFile().deleteRecursively() + } + } + companion object { private const val TARGET = 100L private const val OTHER = 200L diff --git a/src/test/kotlin/tools/QueryUserProfileAgentTest.kt b/src/test/kotlin/tools/QueryUserProfileAgentTest.kt index 5cb5069..92885d6 100644 --- a/src/test/kotlin/tools/QueryUserProfileAgentTest.kt +++ b/src/test/kotlin/tools/QueryUserProfileAgentTest.kt @@ -23,7 +23,7 @@ class QueryUserProfileAgentTest { } @Test - fun formatsSupportCountAndEvidenceRange() { + fun formatsSupportCountWithoutRepeatingTheEvidenceRange() { val output = QueryUserProfileAgent().formatSupportStats( ProfileItemSupportStats( count = 4, @@ -32,8 +32,32 @@ class QueryUserProfileAgentTest { ) ) - assertEquals("支持=4次(2025-01-01~2025-07-03)", output) - assertEquals("支持=0次", QueryUserProfileAgent().formatSupportStats(null)) + assertEquals("4次支持", output) + assertEquals("0次支持", QueryUserProfileAgent().formatSupportStats(null)) + } + + @Test + fun formatsItemContentBeforeCompactMetadataWithOneConfirmationDate() { + val output = QueryUserProfileAgent().formatProfileItem( + item = UserProfileItem( + id = "work", + category = ProfileCategory.NOTABLE_FACT, + content = "从事 Java 底层开发", + confidence = ProfileConfidence.HIGH, + firstSeenAt = 1_735_689_600, + lastConfirmedAt = 1_751_472_000, + ), + supportStats = ProfileItemSupportStats( + count = 4, + firstSupportedAt = 1_735_689_600, + lastSupportedAt = 1_751_472_000, + ), + ) + + assertEquals( + "从事 Java 底层开发〔事实,高置信;4次支持;最近确认于 2025-07-03〕", + output, + ) } private fun item(content: String, firstSeenAt: Int) = UserProfileItem(