profile: refine query output and live batching

This commit is contained in:
2026-08-05 14:39:29 +08:00
parent 795b6620c6
commit c931d39d20
7 changed files with 226 additions and 32 deletions
+3 -2
View File
@@ -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` 操作。没有可靠变化的参与者仍会被标记为已检查,但不会生成空洞画像。
+1 -1
View File
@@ -135,7 +135,7 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("是否在群聊会话结束后静默自动维护相关用户画像")
val profileAutoUpdateEnabled: Boolean by value(true)
@ValueDescription("自动画像分析一次闭合会话最多读取多少条最近消息")
@ValueDescription("群画像历史推进的目标消息数;实时自动维护改按自然空窗读取完整连续会话")
val profileAutoConversationMessageLimit: Int by value(150)
@ValueDescription("用户在会话中至少包含多少个本人文本字符才调用画像模型")
@@ -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<ChatMessageRecord>,
idleGapSeconds: Int,
maxMessages: Int,
maxContentChars: Int,
maxSpanSeconds: Int,
maxMessageChars: Int,
): List<ChatMessageRecord> {
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))
@@ -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
+34 -24
View File
@@ -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
@@ -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
@@ -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(