profile: schedule daily group updates

This commit is contained in:
2026-08-05 11:21:07 +08:00
parent f09645cab0
commit d140ba4fad
9 changed files with 333 additions and 0 deletions
+3
View File
@@ -34,6 +34,7 @@ import top.jie65535.mirai.data.SkillStore
import top.jie65535.mirai.data.TokenUsageStore
import top.jie65535.mirai.llm.LargeLanguageModels
import top.jie65535.mirai.profile.ProfileAutoMaintenance
import top.jie65535.mirai.profile.ProfileDailyMaintenance
import top.jie65535.mirai.profile.UserProfileStore
import kotlin.random.Random
@@ -77,6 +78,7 @@ object JChatGPT : KotlinPlugin(
.onFailure { logger.error("初始化用户画像数据库失败,画像分析将暂时禁用", it) }
LargeLanguageModels.reload()
ProfileDailyMaintenance.reload()
PluginCommands.register()
keyword = PluginConfig.callKeyword.takeIf(String::isNotEmpty)?.let(::Regex)
@@ -104,6 +106,7 @@ object JChatGPT : KotlinPlugin(
ConversationEngine.clear()
ConversationContext.clearAll()
ProfileAutoMaintenance.clear()
ProfileDailyMaintenance.clear()
ContactSnapshotRefresher.clear()
UserProfileStore.close()
ContactSnapshotStore.close()
@@ -26,6 +26,7 @@ import top.jie65535.mirai.profile.ProfileAnalysisReport
import top.jie65535.mirai.profile.ProfileAutoMaintenance
import top.jie65535.mirai.profile.ProfileCategory
import top.jie65535.mirai.profile.ProfileCompactionReport
import top.jie65535.mirai.profile.ProfileDailyMaintenance
import top.jie65535.mirai.profile.ProfilePersistentText
import top.jie65535.mirai.profile.UserProfileAnalysisService
import top.jie65535.mirai.profile.UserProfileSnapshot
@@ -46,6 +47,7 @@ object PluginCommands : CompositeCommand(
PluginConfig.reload()
PluginData.reload()
LargeLanguageModels.reload()
ProfileDailyMaintenance.reload()
if (!PluginConfig.profileEnabled || !PluginConfig.profileAutoUpdateEnabled) {
ProfileAutoMaintenance.clear()
}
+9
View File
@@ -99,6 +99,15 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("全量推进群画像时,群内至少有多少条尚未处理的消息才启动;显式指定群号不受限制")
val profileBulkGroupMinPendingMessages: Int by value(20)
@ValueDescription("是否每天定时推进已接近历史水位线的群画像")
val profileDailyGroupUpdateEnabled: Boolean by value(false)
@ValueDescription("每日群画像推进时间,使用服务器本地时区,格式 HH:mm")
val profileDailyGroupUpdateTime: String by value("04:30")
@ValueDescription("每日群画像只自动推进最早待处理消息距今不超过多少天的群,必须为正数")
val profileDailyGroupUpdateMaxPendingAgeDays: Int by value(7)
@ValueDescription("每个画像分析批次最多包含多少个离散对话片段;同一秒的消息仍会一起处理")
val profileBatchMaxEpisodes: Int by value(16)
@@ -0,0 +1,202 @@
package top.jie65535.mirai.profile
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.config.PluginConfig
import top.jie65535.mirai.llm.LargeLanguageModels
import java.time.Duration
import java.time.Instant
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.ResolverStyle
import java.time.temporal.ChronoField
import java.util.Locale
object ProfileDailyMaintenance {
private val lock = Any()
private var schedulerJob: Job? = null
fun reload() {
synchronized(lock) {
schedulerJob?.cancel()
schedulerJob = null
if (!PluginConfig.profileEnabled || !PluginConfig.profileDailyGroupUpdateEnabled) return@synchronized
if (!UserProfileStore.isAvailable) {
JChatGPT.logger.warning("用户画像数据库不可用,每日群画像推进未启动")
return@synchronized
}
val scheduledTime = parseProfileDailyUpdateTime(PluginConfig.profileDailyGroupUpdateTime)
if (scheduledTime == null) {
JChatGPT.logger.warning(
"每日群画像推进时间无效: '${PluginConfig.profileDailyGroupUpdateTime}',请使用 HH:mm 格式"
)
return@synchronized
}
val maxPendingAgeDays = PluginConfig.profileDailyGroupUpdateMaxPendingAgeDays
if (maxPendingAgeDays <= 0) {
JChatGPT.logger.warning("每日群画像最大积压天数必须为正数,当前值: $maxPendingAgeDays")
return@synchronized
}
val zoneId = ZoneId.systemDefault()
schedulerJob = JChatGPT.launch {
scheduleLoop(scheduledTime, zoneId, maxPendingAgeDays)
}
}
}
fun clear() {
synchronized(lock) {
schedulerJob?.cancel()
schedulerJob = null
}
}
private suspend fun scheduleLoop(
scheduledTime: LocalTime,
zoneId: ZoneId,
maxPendingAgeDays: Int,
) {
while (currentCoroutineContext().isActive) {
val now = ZonedDateTime.now(zoneId)
val nextRun = nextProfileDailyUpdateAt(now, scheduledTime)
val delayMillis = Duration.between(now.toInstant(), nextRun.toInstant()).toMillis().coerceAtLeast(1L)
JChatGPT.logger.info(
"每日群画像推进已计划: next=$nextRun maxPendingAgeDays=$maxPendingAgeDays"
)
delay(delayMillis)
try {
runOnce(maxPendingAgeDays)
} catch (cause: CancellationException) {
throw cause
} catch (cause: Exception) {
JChatGPT.logger.error("每日群画像推进失败,将在下一计划时间重试", cause)
}
}
}
private suspend fun runOnce(maxPendingAgeDays: Int) {
if (!PluginConfig.profileEnabled || !PluginConfig.profileDailyGroupUpdateEnabled) return
if (!UserProfileStore.isAvailable) {
JChatGPT.logger.warning("用户画像数据库不可用,跳过本次每日群画像推进")
return
}
if (LargeLanguageModels.profile == null) {
JChatGPT.logger.warning("画像分析模型未配置,跳过本次每日群画像推进")
return
}
val oldestAllowedTime = oldestAllowedProfilePendingTime(Instant.now().epochSecond, maxPendingAgeDays)
val groupIds = UserProfileAnalysisService.listRecentPendingHistoryGroupIds(oldestAllowedTime)
if (groupIds.isEmpty()) {
JChatGPT.logger.info("每日群画像推进无需运行:没有符合 $maxPendingAgeDays 天积压门槛的群")
return
}
JChatGPT.logger.info("每日群画像推进开始: groups=${groupIds.size} maxPendingAgeDays=$maxPendingAgeDays")
val runToken = UserProfileAnalysisService.newRunToken()
val outcomes = coroutineScope {
groupIds.map { groupId ->
async { analyzeGroup(groupId, runToken) }
}.awaitAll()
}
val reports = outcomes.mapNotNull(DailyGroupOutcome::report)
JChatGPT.logger.info(
"每日群画像推进完成: selected=${groupIds.size} " +
"success=${outcomes.count { it.status == DailyGroupStatus.SUCCESS }} " +
"alreadyRunning=${outcomes.count { it.status == DailyGroupStatus.ALREADY_RUNNING }} " +
"missing=${outcomes.count { it.status == DailyGroupStatus.MISSING_HISTORY }} " +
"stopped=${outcomes.count { it.status == DailyGroupStatus.STOPPED }} " +
"failed=${outcomes.count { it.status == DailyGroupStatus.FAILED }} " +
"batches=${reports.sumOf(GroupProfileAnalysisReport::processedBatches)} " +
"messages=${reports.sumOf(GroupProfileAnalysisReport::processedMessages)} " +
"operations=${reports.sumOf(GroupProfileAnalysisReport::appliedOperations)} " +
"tokens=${reports.sumOf { it.usage.promptTokens }}/${reports.sumOf { it.usage.completionTokens }} " +
"cached=${reports.sumOf { it.usage.cachedTokens }}"
)
}
private suspend fun analyzeGroup(
groupId: Long,
runToken: ProfileAnalysisRunToken,
): DailyGroupOutcome {
return try {
val report = UserProfileAnalysisService.analyzeGroup(
groupId = groupId,
maxBatches = Int.MAX_VALUE,
runToken = runToken,
) { progress ->
JChatGPT.logger.info(
"PROFILE_DAILY_BATCH group=$groupId batch=${progress.batchIndex} " +
"range=${progress.startTime}-${progress.endTime} " +
"messages=${progress.messageCount} users=${progress.analyzedUsers} " +
"operations=${progress.appliedOperations} skipped=${progress.skippedOperations} " +
"tokens=${progress.usage.promptTokens}/${progress.usage.completionTokens} " +
"cached=${progress.usage.cachedTokens}"
)
}
val status = when {
report.alreadyRunning -> DailyGroupStatus.ALREADY_RUNNING
report.botId == null -> DailyGroupStatus.MISSING_HISTORY
report.stopped -> DailyGroupStatus.STOPPED
else -> DailyGroupStatus.SUCCESS
}
DailyGroupOutcome(status, report.takeUnless { report.alreadyRunning || report.botId == null })
} catch (cause: CancellationException) {
throw cause
} catch (cause: Exception) {
JChatGPT.logger.error("$groupId 每日画像推进失败", cause)
DailyGroupOutcome(DailyGroupStatus.FAILED)
}
}
private data class DailyGroupOutcome(
val status: DailyGroupStatus,
val report: GroupProfileAnalysisReport? = null,
)
private enum class DailyGroupStatus {
SUCCESS,
ALREADY_RUNNING,
MISSING_HISTORY,
STOPPED,
FAILED,
}
}
private val PROFILE_DAILY_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatterBuilder()
.parseStrict()
.appendValue(ChronoField.HOUR_OF_DAY, 2)
.appendLiteral(':')
.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
.toFormatter(Locale.ROOT)
.withResolverStyle(ResolverStyle.STRICT)
internal fun parseProfileDailyUpdateTime(value: String): LocalTime? = runCatching {
LocalTime.parse(value.trim(), PROFILE_DAILY_TIME_FORMATTER)
}.getOrNull()
internal fun nextProfileDailyUpdateAt(now: ZonedDateTime, scheduledTime: LocalTime): ZonedDateTime {
val today = now.toLocalDate().atTime(scheduledTime).atZone(now.zone)
return if (today.isAfter(now)) today else now.toLocalDate().plusDays(1).atTime(scheduledTime).atZone(now.zone)
}
internal fun oldestAllowedProfilePendingTime(nowEpochSecond: Long, maxPendingAgeDays: Int): Int {
require(maxPendingAgeDays > 0) { "maxPendingAgeDays must be positive" }
return (nowEpochSecond - maxPendingAgeDays.toLong() * 24L * 60L * 60L)
.coerceIn(0L, Int.MAX_VALUE.toLong())
.toInt()
}
@@ -181,6 +181,37 @@ class ProfileHistoryReader(private val databaseFile: File) {
}
}
fun filterGroupRangesByOldestPendingMessageTime(
ranges: List<GroupTimeBounds>,
oldestAllowedTime: Int,
): List<GroupTimeBounds> {
if (ranges.isEmpty()) return emptyList()
require(oldestAllowedTime >= 0) { "oldestAllowedTime must not be negative" }
return openReadConnection().use { connection ->
connection.prepareStatement(
"""
SELECT MIN(time) AS oldest_time
FROM message_record
WHERE bot_id = ? AND target_id = ? AND kind = ? AND recalled = 0
AND time >= ? AND time < ?
""".trimIndent()
).use { statement ->
ranges.filter { range ->
statement.setLong(1, range.botId)
statement.setLong(2, range.groupId)
statement.setInt(3, MessageSourceKind.GROUP.ordinal)
statement.setInt(4, range.startTime)
statement.setInt(5, range.endTime)
statement.executeQuery().use { results ->
check(results.next()) { "读取群 ${range.groupId} 最早待处理消息失败" }
val oldestTime = results.getInt("oldest_time")
results.wasNull() || oldestTime >= oldestAllowedTime
}
}
}
}
}
fun loadNextBatch(
userId: Long,
startTime: Int,
@@ -54,6 +54,25 @@ object UserProfileAnalysisService {
}
}
suspend fun listRecentPendingHistoryGroupIds(oldestAllowedMessageTime: Int): List<Long> {
require(oldestAllowedMessageTime >= 0) { "oldestAllowedMessageTime must not be negative" }
check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" }
check(UserProfileStore.isAvailable) { "用户画像数据库不可用" }
return withContext(Dispatchers.IO) {
val reader = ProfileHistoryReader(resolveHistoryFile())
val historyBounds = reader.listGroupTimeBounds()
val cursors = UserProfileStore.loadGroupCursors()
.associateBy { cursor -> cursor.botId to cursor.groupId }
val pendingRanges = historyBounds.mapNotNull { bounds ->
pendingGroupAnalysisRange(bounds, cursors[bounds.botId to bounds.groupId])
}
reader.filterGroupRangesByOldestPendingMessageTime(
ranges = pendingRanges,
oldestAllowedTime = oldestAllowedMessageTime,
).map(ProfileHistoryReader.GroupTimeBounds::groupId)
}
}
suspend fun analyze(
userId: Long,
maxBatches: Int,
@@ -0,0 +1,46 @@
package top.jie65535.mirai.profile
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ProfileDailyMaintenanceTest {
@Test
fun parsesStrictDailyUpdateTime() {
assertEquals(LocalTime.of(4, 30), parseProfileDailyUpdateTime("04:30"))
assertEquals(LocalTime.of(23, 59), parseProfileDailyUpdateTime(" 23:59 "))
assertNull(parseProfileDailyUpdateTime("4:30"))
assertNull(parseProfileDailyUpdateTime("24:00"))
assertNull(parseProfileDailyUpdateTime("04:60"))
}
@Test
fun schedulesTodayOrTomorrowInServerTimeZone() {
val zone = ZoneId.of("Asia/Shanghai")
val scheduledTime = LocalTime.of(4, 30)
assertEquals(
ZonedDateTime.of(2026, 8, 5, 4, 30, 0, 0, zone),
nextProfileDailyUpdateAt(
ZonedDateTime.of(2026, 8, 5, 3, 0, 0, 0, zone),
scheduledTime,
),
)
assertEquals(
ZonedDateTime.of(2026, 8, 6, 4, 30, 0, 0, zone),
nextProfileDailyUpdateAt(
ZonedDateTime.of(2026, 8, 5, 4, 30, 0, 0, zone),
scheduledTime,
),
)
}
@Test
fun calculatesOldestAllowedPendingMessageTime() {
assertEquals(395_200, oldestAllowedProfilePendingTime(1_000_000, 7))
assertEquals(0, oldestAllowedProfilePendingTime(100, 7))
}
}
@@ -62,6 +62,8 @@ class ProfileHistoryReaderTest {
insert(1, 20, 110, kind = MessageSourceKind.FRIEND)
insert(2, 30, 100)
insert(2, 30, 110)
insert(1, 40, 100)
insert(1, 40, 1_000)
}
}
@@ -80,6 +82,13 @@ class ProfileHistoryReaderTest {
listOf(10L, 20L),
reader.filterGroupRangesByMinimumMessageCount(ranges, 1).map { it.groupId },
)
assertEquals(
listOf(10L, 30L),
reader.filterGroupRangesByOldestPendingMessageTime(
ranges = ranges + ProfileHistoryReader.GroupTimeBounds(1, 40, 90, 1_001),
oldestAllowedTime = 115,
).map { it.groupId },
)
} finally {
directory.toFile().deleteRecursively()
}