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