diff --git a/README.md b/README.md index 9e6f19b..e94d1ee 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ AI 可以自动调用多种工具来完成复杂任务: ### 渐进式历史画像(实验) - 日常使用无需画像命令:群聊缓存会话闭合后,一次模型调用会静默归纳其中所有有实质发言的参与者 - `/jgpt profileAnalyze [batches]` - 分析用户画像,多个 ID 用逗号分隔 -- `/jgpt profileAnalyzeGroup [batches]` - 分析群画像,多个 ID 用逗号分隔 +- `/jgpt profileAnalyzeGroup [groupIds] [batches]` - 分析群画像,多个 ID 用逗号分隔;不传群号时并发推进历史库中的全部群 - `/jgpt profileShow ` - 查看用户画像 - `/jgpt profileCompact [userIds]` - 压缩画像,不传 ID 时处理全部用户 - `/jgpt profileStop` - 当前批次完成后停止画像任务 @@ -231,7 +231,9 @@ searchHistoryMaxRecords: 5000 模型只使用单次请求内有效的临时编号,不接触画像条目的内部 UUID;不合规建议会被跳过,不阻断其他有效更新。 临时用户别名不会写入最终画像。`profileCompact` 可独立清理重复或低价值条目,且不推进历史水位线。 -旧历史可通过 `profileAnalyze` 或 `profileAnalyzeGroup` 手动分批推进。 +旧历史可通过 `profileAnalyze` 或 `profileAnalyzeGroup` 手动分批推进。执行不带参数的 `profileAnalyzeGroup` 时, +插件会从画像历史库枚举所有含有效群消息的群,并为每个群同时启动一个分析任务,不设置额外的应用层并发上限; +显式传入群号时仍只分析指定群。全量模式仅发送启动和最终汇总,逐群进度与结果写入日志,避免回执刷屏。 下一次正常群聊会自动携带触发者和最近发言者的认识。现有好感度、Bot 代号、标签和主观印象会与证据驱动的 长期画像按同一个人合并渲染,并明确给出长期画像条目数;私聊也会携带对方的可靠画像摘要和条目数。 diff --git a/src/main/kotlin/command/PluginCommands.kt b/src/main/kotlin/command/PluginCommands.kt index 0b396de..67b3904 100644 --- a/src/main/kotlin/command/PluginCommands.kt +++ b/src/main/kotlin/command/PluginCommands.kt @@ -33,6 +33,7 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.util.concurrent.atomic.AtomicInteger object PluginCommands : CompositeCommand( JChatGPT, "jgpt", description = "J OpenAI ChatGPT" @@ -85,11 +86,28 @@ object PluginCommands : CompositeCommand( } @SubCommand - suspend fun CommandSender.profileAnalyzeGroup(groupIds: String, batches: Int = 1) { + suspend fun CommandSender.profileAnalyzeGroup(groupIds: String = "", batches: Int = 1) { require(batches > 0) { "batches 必须是正数" } - val parsedGroupIds = parseProfileGroupIds(groupIds) + val analyzeAllGroups = groupIds.isBlank() + val parsedGroupIds = if (analyzeAllGroups) { + UserProfileAnalysisService.listHistoryGroupIds() + } else { + parseProfileGroupIds(groupIds) + } + if (parsedGroupIds.isEmpty()) { + sendMessage("聊天记录中没有可分析的群消息。") + return + } val runToken = UserProfileAnalysisService.newRunToken() - sendMessage("已启动 ${parsedGroupIds.size} 个群的画像分析,每群最多推进 $batches 个批次。") + sendMessage( + "已启动 ${parsedGroupIds.size} 个群的画像分析,每群最多推进 $batches 个批次。" + + if (analyzeAllGroups) " 全量模式不设并发上限。" else "" + ) + val completedGroups = AtomicInteger() + val successfulGroups = AtomicInteger() + val alreadyRunningGroups = AtomicInteger() + val missingGroups = AtomicInteger() + val failedGroups = AtomicInteger() parsedGroupIds.forEach { groupId -> JChatGPT.launch { try { @@ -104,15 +122,50 @@ object PluginCommands : CompositeCommand( ) } when { - report.alreadyRunning -> sendMessage("群 $groupId 已有画像分析任务在运行。") - report.botId == null -> sendMessage("聊天记录中没有找到群 $groupId 的消息。") - else -> sendMessage(formatGroupProfileReport(report)) + report.alreadyRunning -> { + alreadyRunningGroups.incrementAndGet() + if (analyzeAllGroups) { + JChatGPT.logger.info("群 $groupId 已有画像分析任务在运行") + } else { + sendMessage("群 $groupId 已有画像分析任务在运行。") + } + } + + report.botId == null -> { + missingGroups.incrementAndGet() + if (analyzeAllGroups) { + JChatGPT.logger.warning("聊天记录中没有找到群 $groupId 的消息") + } else { + sendMessage("聊天记录中没有找到群 $groupId 的消息。") + } + } + + else -> { + successfulGroups.incrementAndGet() + val resultMessage = formatGroupProfileReport(report) + if (analyzeAllGroups) { + JChatGPT.logger.info(resultMessage) + } else { + sendMessage(resultMessage) + } + } } } catch (cause: CancellationException) { throw cause } catch (cause: Exception) { + failedGroups.incrementAndGet() JChatGPT.logger.error("群 $groupId 批量画像分析失败", cause) - sendMessage("群 $groupId 批量画像分析失败:${cause.message ?: cause::class.simpleName}") + if (!analyzeAllGroups) { + sendMessage("群 $groupId 批量画像分析失败:${cause.message ?: cause::class.simpleName}") + } + } finally { + if (analyzeAllGroups && completedGroups.incrementAndGet() == parsedGroupIds.size) { + sendMessage( + "全量群画像分析完成:成功 ${successfulGroups.get()}," + + "已在运行 ${alreadyRunningGroups.get()}," + + "无历史 ${missingGroups.get()},失败 ${failedGroups.get()}。" + ) + } } } } diff --git a/src/main/kotlin/profile/ProfileHistoryReader.kt b/src/main/kotlin/profile/ProfileHistoryReader.kt index 81f25a4..cc2b06b 100644 --- a/src/main/kotlin/profile/ProfileHistoryReader.kt +++ b/src/main/kotlin/profile/ProfileHistoryReader.kt @@ -70,6 +70,25 @@ class ProfileHistoryReader(private val databaseFile: File) { } } + fun listGroupIds(): List = openReadConnection().use { connection -> + connection.prepareStatement( + """ + SELECT target_id, MAX(time) AS last_message_time + FROM message_record + WHERE kind = ? AND recalled = 0 AND target_id > 0 + GROUP BY target_id + ORDER BY last_message_time DESC, target_id ASC + """.trimIndent() + ).use { statement -> + statement.setInt(1, MessageSourceKind.GROUP.ordinal) + statement.executeQuery().use { results -> + buildList { + while (results.next()) add(results.getLong("target_id")) + } + } + } + } + fun loadNextBatch( userId: Long, startTime: Int, diff --git a/src/main/kotlin/profile/UserProfileAnalysisService.kt b/src/main/kotlin/profile/UserProfileAnalysisService.kt index 090b26f..bc8bd75 100644 --- a/src/main/kotlin/profile/UserProfileAnalysisService.kt +++ b/src/main/kotlin/profile/UserProfileAnalysisService.kt @@ -32,6 +32,14 @@ object UserProfileAnalysisService { return report } + suspend fun listHistoryGroupIds(): List { + check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" } + check(UserProfileStore.isAvailable) { "用户画像数据库不可用" } + return withContext(Dispatchers.IO) { + ProfileHistoryReader(resolveHistoryFile()).listGroupIds() + } + } + suspend fun analyze( userId: Long, maxBatches: Int, diff --git a/src/test/kotlin/profile/ProfileHistoryReaderTest.kt b/src/test/kotlin/profile/ProfileHistoryReaderTest.kt index 6a012ce..da164fc 100644 --- a/src/test/kotlin/profile/ProfileHistoryReaderTest.kt +++ b/src/test/kotlin/profile/ProfileHistoryReaderTest.kt @@ -37,14 +37,22 @@ class ProfileHistoryReaderTest { connection.prepareStatement( "INSERT INTO message_record(" + "bot_id, from_id, target_id, time, kind, code, recalled" + - ") VALUES (1, ?, ?, ?, ?, ?, 0)" + ") VALUES (1, ?, ?, ?, ?, ?, ?)" ).use { statement -> - fun insert(fromId: Long, groupId: Long, time: Int, text: String) { + fun insert( + fromId: Long, + groupId: Long, + time: Int, + text: String, + kind: MessageSourceKind = MessageSourceKind.GROUP, + recalled: Int = 0, + ) { statement.setLong(1, fromId) statement.setLong(2, groupId) statement.setInt(3, time) - statement.setInt(4, MessageSourceKind.GROUP.ordinal) + statement.setInt(4, kind.ordinal) statement.setString(5, """[{"type":"PlainText","content":"$text"}]""") + statement.setInt(6, recalled) statement.executeUpdate() } insert(TARGET, 10, 100, "目标发言一") @@ -54,10 +62,13 @@ class ProfileHistoryReaderTest { insert(TARGET, 20, 130, "同一秒的另一群发言") insert(OTHER, 20, 140, "后续上下文") insert(TARGET, 10, 200, "下一批目标发言") + insert(TARGET, 30, 300, "私聊消息", kind = MessageSourceKind.FRIEND) + insert(TARGET, 40, 400, "已撤回群消息", recalled = 1) } } val reader = ProfileHistoryReader(database.toFile()) + assertEquals(listOf(10L, 20L), reader.listGroupIds()) val bounds = assertNotNull(reader.findUserTimeBounds(TARGET)) assertEquals(100, bounds.startTime) assertEquals(201, bounds.endTime)