mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
profile: harden batch analysis and compaction
This commit is contained in:
@@ -32,7 +32,6 @@ 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.ProfilePromptStore
|
||||
import top.jie65535.mirai.profile.UserProfileStore
|
||||
import kotlin.random.Random
|
||||
|
||||
@@ -72,7 +71,6 @@ object JChatGPT : KotlinPlugin(
|
||||
.onFailure { logger.error("初始化用户画像数据库失败,画像分析将暂时禁用", it) }
|
||||
|
||||
LargeLanguageModels.reload()
|
||||
ProfilePromptStore.reload()
|
||||
PluginCommands.register()
|
||||
keyword = PluginConfig.callKeyword.takeIf(String::isNotEmpty)?.let(::Regex)
|
||||
|
||||
|
||||
@@ -18,9 +18,12 @@ import top.jie65535.mirai.data.PluginData
|
||||
import top.jie65535.mirai.data.SkillStore
|
||||
import top.jie65535.mirai.data.TokenUsageStore
|
||||
import top.jie65535.mirai.llm.LargeLanguageModels
|
||||
import top.jie65535.mirai.profile.GroupProfileAnalysisReport
|
||||
import top.jie65535.mirai.profile.ProfileAnalysisReport
|
||||
import top.jie65535.mirai.profile.ProfileAutoMaintenance
|
||||
import top.jie65535.mirai.profile.ProfilePromptStore
|
||||
import top.jie65535.mirai.profile.ProfileCategory
|
||||
import top.jie65535.mirai.profile.ProfileCompactionReport
|
||||
import top.jie65535.mirai.profile.ProfilePersistentText
|
||||
import top.jie65535.mirai.profile.UserProfileAnalysisService
|
||||
import top.jie65535.mirai.profile.UserProfileSnapshot
|
||||
import top.jie65535.mirai.profile.UserProfileStore
|
||||
@@ -38,7 +41,6 @@ object PluginCommands : CompositeCommand(
|
||||
PluginConfig.reload()
|
||||
PluginData.reload()
|
||||
LargeLanguageModels.reload()
|
||||
ProfilePromptStore.reload()
|
||||
if (!PluginConfig.profileEnabled || !PluginConfig.profileAutoUpdateEnabled) {
|
||||
ProfileAutoMaintenance.clear()
|
||||
}
|
||||
@@ -48,7 +50,7 @@ object PluginCommands : CompositeCommand(
|
||||
|
||||
@SubCommand
|
||||
suspend fun CommandSender.profileAnalyze(userId: Long, batches: Int = 1) {
|
||||
require(batches in 1..50) { "batches 必须在 1 到 50 之间" }
|
||||
require(batches > 0) { "batches 必须是正数" }
|
||||
sendMessage("已启动用户 $userId 的画像分析,本次最多推进 $batches 个批次。")
|
||||
JChatGPT.launch {
|
||||
try {
|
||||
@@ -57,6 +59,7 @@ object PluginCommands : CompositeCommand(
|
||||
"PROFILE_BATCH user=$userId batch=${progress.batchIndex}/$batches " +
|
||||
"range=${progress.startTime}-${progress.endTime} " +
|
||||
"messages=${progress.messageCount} operations=${progress.operationCount} " +
|
||||
"skipped=${progress.skippedOperationCount} " +
|
||||
"tokens=${progress.usage.promptTokens}/${progress.usage.completionTokens} " +
|
||||
"cached=${progress.usage.cachedTokens}"
|
||||
)
|
||||
@@ -75,6 +78,36 @@ object PluginCommands : CompositeCommand(
|
||||
}
|
||||
}
|
||||
|
||||
@SubCommand
|
||||
suspend fun CommandSender.profileAnalyzeGroup(groupId: Long, batches: Int = 1) {
|
||||
require(batches > 0) { "batches 必须是正数" }
|
||||
sendMessage("已启动群 $groupId 的批量画像分析,本次最多推进 $batches 个批次。")
|
||||
JChatGPT.launch {
|
||||
try {
|
||||
val report = UserProfileAnalysisService.analyzeGroup(groupId, batches) { progress ->
|
||||
JChatGPT.logger.info(
|
||||
"PROFILE_GROUP_BATCH group=$groupId batch=${progress.batchIndex}/$batches " +
|
||||
"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}"
|
||||
)
|
||||
}
|
||||
when {
|
||||
report.alreadyRunning -> sendMessage("群 $groupId 已有画像分析任务在运行。")
|
||||
report.botId == null -> sendMessage("聊天记录中没有找到群 $groupId 的消息。")
|
||||
else -> sendMessage(formatGroupProfileReport(report))
|
||||
}
|
||||
} catch (cause: CancellationException) {
|
||||
throw cause
|
||||
} catch (cause: Exception) {
|
||||
JChatGPT.logger.error("群 $groupId 批量画像分析失败", cause)
|
||||
sendMessage("群 $groupId 批量画像分析失败:${cause.message ?: cause::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SubCommand
|
||||
suspend fun CommandSender.profileShow(userId: Long) {
|
||||
if (!UserProfileStore.isAvailable) {
|
||||
@@ -85,6 +118,22 @@ object PluginCommands : CompositeCommand(
|
||||
sendMessage(profile?.let(::formatProfile) ?: "用户 $userId 尚无画像。")
|
||||
}
|
||||
|
||||
@SubCommand
|
||||
suspend fun CommandSender.profileCompact(userId: Long) {
|
||||
sendMessage("已启动用户 $userId 的画像压缩反思。")
|
||||
JChatGPT.launch {
|
||||
try {
|
||||
val report = UserProfileAnalysisService.compact(userId)
|
||||
sendMessage(formatProfileCompactionReport(report))
|
||||
} catch (cause: CancellationException) {
|
||||
throw cause
|
||||
} catch (cause: Exception) {
|
||||
JChatGPT.logger.error("用户 $userId 画像压缩失败", cause)
|
||||
sendMessage("用户 $userId 画像压缩失败:${cause.message ?: cause::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SubCommand
|
||||
suspend fun CommandSender.skills() {
|
||||
val all = SkillStore.all
|
||||
@@ -269,7 +318,7 @@ object PluginCommands : CompositeCommand(
|
||||
private fun formatProfileReport(report: ProfileAnalysisReport): String = buildString {
|
||||
appendLine(
|
||||
"画像分析完成:${report.processedBatches} 批,${report.processedMessages} 条上下文消息," +
|
||||
"${report.appliedOperations} 项变更"
|
||||
"${report.appliedOperations} 项变更,跳过 ${report.skippedOperations} 项无效建议"
|
||||
)
|
||||
appendLine(
|
||||
"Token:输入 ${formatNumber(report.usage.promptTokens)},输出 " +
|
||||
@@ -280,6 +329,36 @@ object PluginCommands : CompositeCommand(
|
||||
append(formatProfile(checkNotNull(report.profile)))
|
||||
}.trim()
|
||||
|
||||
private fun formatGroupProfileReport(report: GroupProfileAnalysisReport): String = buildString {
|
||||
appendLine(
|
||||
"群 ${report.groupId} 画像分析完成:${report.processedBatches} 批," +
|
||||
"${report.processedMessages} 条消息,${report.analyzedUsers} 用户人次," +
|
||||
"${report.appliedOperations} 项变更,跳过 ${report.skippedOperations} 项无效建议"
|
||||
)
|
||||
appendLine(
|
||||
"Token:输入 ${formatNumber(report.usage.promptTokens)},输出 " +
|
||||
"${formatNumber(report.usage.completionTokens)},缓存命中 " +
|
||||
formatNumber(report.usage.cachedTokens)
|
||||
)
|
||||
appendLine("群历史覆盖至 ${formatProfileTime(report.cursorTime)}")
|
||||
append("状态:${if (report.caughtUp) "已追平当前快照" else "可继续推进"}")
|
||||
}.trim()
|
||||
|
||||
private fun formatProfileCompactionReport(report: ProfileCompactionReport): String = buildString {
|
||||
appendLine(
|
||||
"画像压缩完成:${report.beforeItems} -> ${report.afterItems} 条," +
|
||||
"合并 ${report.mergedGroups} 组,改写 ${report.rewrittenItems} 条,删除 ${report.deletedItems} 条," +
|
||||
"摘要${if (report.summaryChanged) "已重写" else "未变"}," +
|
||||
"跳过 ${report.skippedOperations} 项不安全建议"
|
||||
)
|
||||
appendLine(
|
||||
"Token:输入 ${formatNumber(report.usage.promptTokens)},输出 " +
|
||||
"${formatNumber(report.usage.completionTokens)},缓存命中 " +
|
||||
formatNumber(report.usage.cachedTokens)
|
||||
)
|
||||
append(formatProfile(report.profile))
|
||||
}.trim()
|
||||
|
||||
private fun formatProfile(profile: UserProfileSnapshot): String = buildString {
|
||||
appendLine("用户 ${profile.userId} · 画像 v${profile.version}")
|
||||
if (profile.cursorTime <= 0) {
|
||||
@@ -287,16 +366,33 @@ object PluginCommands : CompositeCommand(
|
||||
} else {
|
||||
appendLine("历史回顾覆盖至 ${formatProfileTime(profile.cursorTime)}")
|
||||
}
|
||||
appendLine("摘要:${profile.summary.ifBlank { "(暂无)" }}")
|
||||
val summary = ProfilePersistentText.summaryForDisplay(profile.summary)
|
||||
appendLine("摘要:${summary.ifBlank { "(暂无)" }}")
|
||||
if (profile.items.isEmpty()) {
|
||||
append("条目:(暂无)")
|
||||
} else {
|
||||
appendLine("条目:")
|
||||
profile.items.forEach { item ->
|
||||
append("- [").append(item.category.name.lowercase()).append('/')
|
||||
.append(item.confidence.name.lowercase()).append("] ")
|
||||
.append(item.content)
|
||||
item.relatedUserId?.let { append("(关联用户 ").append(it).append(')') }
|
||||
.append(item.confidence.name.lowercase()).append(" · 记录于 ")
|
||||
.append(formatProfileDate(item.firstSeenAt))
|
||||
if (item.lastConfirmedAt != item.firstSeenAt) {
|
||||
append(",确认至 ").append(formatProfileDate(item.lastConfirmedAt))
|
||||
}
|
||||
append("] ")
|
||||
val relatedUserId = item.relatedUserId
|
||||
if (item.category == ProfileCategory.RELATIONSHIP_NOTE && relatedUserId != null) {
|
||||
val relatedName = PluginData.userFavorability[relatedUserId]?.name.orEmpty()
|
||||
if (relatedName.isBlank()) {
|
||||
append("与用户 ").append(relatedUserId).append(":")
|
||||
} else {
|
||||
append('与').append(relatedName).append('(').append(relatedUserId).append("):")
|
||||
}
|
||||
}
|
||||
append(ProfilePersistentText.itemForDisplay(
|
||||
item.content,
|
||||
relationship = item.category == ProfileCategory.RELATIONSHIP_NOTE,
|
||||
))
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
@@ -304,6 +400,9 @@ object PluginCommands : CompositeCommand(
|
||||
|
||||
private fun formatProfileTime(epochSecond: Int): String =
|
||||
PROFILE_TIME_FORMATTER.format(Instant.ofEpochSecond(epochSecond.toLong()))
|
||||
|
||||
private fun formatProfileDate(epochSecond: Int): String =
|
||||
PROFILE_DATE_FORMATTER.format(Instant.ofEpochSecond(epochSecond.toLong()))
|
||||
}
|
||||
|
||||
// 常量定义
|
||||
@@ -311,3 +410,6 @@ private const val TOP_LIMIT = 5
|
||||
private val PROFILE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter
|
||||
.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
private val PROFILE_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter
|
||||
.ofPattern("yyyy-MM-dd")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
|
||||
@@ -75,12 +75,6 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("画像分析模型额外请求体JSON。留空时继承聊天模型额外请求体")
|
||||
val profileModelExtraBody: String by value("")
|
||||
|
||||
@ValueDescription("画像分析提示词文件路径,相对于插件配置目录")
|
||||
val profilePromptFile: String by value("ProfilePrompt.md")
|
||||
|
||||
@ValueDescription("闭合群聊多人画像提示词文件路径,相对于插件配置目录")
|
||||
val profileConversationPromptFile: String by value("ProfileConversationPrompt.md")
|
||||
|
||||
@ValueDescription("画像分析使用的聊天记录SQLite路径。留空时使用插件自己的chat-history.sqlite;本地实验可填写历史库绝对路径")
|
||||
val profileHistoryDatabasePath: String by value("")
|
||||
|
||||
|
||||
@@ -10,29 +10,21 @@ object ConversationProfileReducer {
|
||||
promptVersion: String,
|
||||
summaryMaxLength: Int,
|
||||
): List<ProfileReduction> {
|
||||
val responsesByUserId = response.users.associate { userResponse ->
|
||||
val userId = batch.aliasToUserId[userResponse.userAlias]
|
||||
?: throw IllegalArgumentException("模型返回了不存在的用户别名 ${userResponse.userAlias}")
|
||||
require(userId in eligibleUserIds) {
|
||||
"模型返回了非候选用户别名 ${userResponse.userAlias}"
|
||||
}
|
||||
require(userResponse.operations.size <= MAX_OPERATIONS_PER_USER) {
|
||||
"模型为 ${userResponse.userAlias} 返回了超过 $MAX_OPERATIONS_PER_USER 项画像操作"
|
||||
}
|
||||
userId to userResponse
|
||||
}
|
||||
require(responsesByUserId.size == response.users.size) {
|
||||
"模型对同一用户返回了多组结果"
|
||||
val responsesByUserId = linkedMapOf<Long, MutableList<ConversationProfileUserResponse>>()
|
||||
response.users.forEach { userResponse ->
|
||||
val userId = batch.aliasToUserId[userResponse.userAlias] ?: return@forEach
|
||||
if (userId !in eligibleUserIds) return@forEach
|
||||
responsesByUserId.getOrPut(userId, ::mutableListOf) += userResponse
|
||||
}
|
||||
|
||||
return eligibleUserIds.sorted().map { userId ->
|
||||
val userResponse = responsesByUserId[userId]
|
||||
val userResponses = responsesByUserId[userId].orEmpty()
|
||||
UserProfileReducer.reduce(
|
||||
current = checkNotNull(profiles[userId]) { "缺少用户 $userId 的当前画像" },
|
||||
batch = batch.forUser(userId),
|
||||
response = ProfileModelResponse(
|
||||
operations = userResponse?.operations.orEmpty(),
|
||||
summary = userResponse?.summary.orEmpty(),
|
||||
operations = userResponses.flatMap(ConversationProfileUserResponse::operations).distinct(),
|
||||
summary = userResponses.lastOrNull { it.summary.isNotBlank() }?.summary.orEmpty(),
|
||||
),
|
||||
model = model,
|
||||
promptVersion = promptVersion,
|
||||
@@ -41,6 +33,4 @@ object ConversationProfileReducer {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_OPERATIONS_PER_USER = 4
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package top.jie65535.mirai.profile
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
object EvidenceReferenceListSerializer : KSerializer<List<Int>> {
|
||||
private val delegate = ListSerializer(Int.serializer())
|
||||
|
||||
override val descriptor: SerialDescriptor = delegate.descriptor
|
||||
|
||||
override fun deserialize(decoder: Decoder): List<Int> {
|
||||
val jsonDecoder = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
|
||||
val array = jsonDecoder.decodeJsonElement() as? JsonArray ?: return listOf(INVALID_REFERENCE)
|
||||
return array.map { element ->
|
||||
val raw = (element as? JsonPrimitive)?.content?.trim() ?: return@map INVALID_REFERENCE
|
||||
val numeric = if (raw.startsWith("e:", ignoreCase = true)) raw.substring(2) else raw
|
||||
numeric.toIntOrNull()?.takeIf { it > 0 } ?: INVALID_REFERENCE
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: List<Int>) {
|
||||
delegate.serialize(encoder, value)
|
||||
}
|
||||
|
||||
private const val INVALID_REFERENCE = 0
|
||||
}
|
||||
@@ -78,6 +78,7 @@ object ProfileAutoMaintenance {
|
||||
JChatGPT.logger.info(
|
||||
"PROFILE_AUTO group=${conversation.groupId} users=${report.analyzedUsers} " +
|
||||
"messages=${report.processedMessages} operations=${report.appliedOperations} " +
|
||||
"skipped=${report.skippedOperations} " +
|
||||
"tokens=${report.usage.promptTokens}/${report.usage.completionTokens}"
|
||||
)
|
||||
} catch (cause: CancellationException) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package top.jie65535.mirai.profile
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class ProfileCompactionDeleteReason {
|
||||
@SerialName("one_off")
|
||||
ONE_OFF,
|
||||
|
||||
@SerialName("over_specific")
|
||||
OVER_SPECIFIC,
|
||||
|
||||
@SerialName("transient")
|
||||
TRANSIENT,
|
||||
|
||||
@SerialName("not_profile")
|
||||
NOT_PROFILE,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProfileCompactionMerge(
|
||||
@SerialName("item_refs")
|
||||
val itemRefs: List<String>,
|
||||
val content: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProfileCompactionRewrite(
|
||||
@SerialName("item_ref")
|
||||
val itemRef: String,
|
||||
val content: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProfileCompactionDelete(
|
||||
@SerialName("item_ref")
|
||||
val itemRef: String,
|
||||
val reason: ProfileCompactionDeleteReason,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProfileCompactionResponse(
|
||||
val merges: List<ProfileCompactionMerge> = emptyList(),
|
||||
val rewrites: List<ProfileCompactionRewrite> = emptyList(),
|
||||
val deletes: List<ProfileCompactionDelete> = emptyList(),
|
||||
val summary: String = "",
|
||||
)
|
||||
|
||||
data class ProfileItemSupportStats(
|
||||
val count: Int,
|
||||
val firstSupportedAt: Int,
|
||||
val lastSupportedAt: Int,
|
||||
)
|
||||
|
||||
data class ProfileCompactionModelResult(
|
||||
val response: ProfileCompactionResponse,
|
||||
val rawResponse: String,
|
||||
val usage: ProfileTokenUsage,
|
||||
)
|
||||
|
||||
data class ProfileCompactionPlan(
|
||||
val reduction: ProfileReduction,
|
||||
val supportReassignments: Map<String, String>,
|
||||
val mergedGroups: Int,
|
||||
val rewrittenItems: Int,
|
||||
val deletedItems: Int,
|
||||
val skippedOperations: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class ProfileCompactionReport(
|
||||
val userId: Long,
|
||||
val beforeItems: Int,
|
||||
val afterItems: Int,
|
||||
val mergedGroups: Int,
|
||||
val rewrittenItems: Int,
|
||||
val deletedItems: Int,
|
||||
val summaryChanged: Boolean,
|
||||
val skippedOperations: Int,
|
||||
val usage: ProfileTokenUsage,
|
||||
val profile: UserProfileSnapshot,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package top.jie65535.mirai.profile
|
||||
|
||||
object ProfileContentRules {
|
||||
fun validate(raw: String, label: String): String {
|
||||
val content = raw.trim()
|
||||
require(content.isNotEmpty()) { "$label 缺少 content" }
|
||||
require(content.length <= MAX_CONTENT_LENGTH) { "$label.content 超过 $MAX_CONTENT_LENGTH 字符" }
|
||||
require(!overclaimPattern.containsMatchIn(content)) { "$label.content 包含夸张身份或能力判断" }
|
||||
require(!itemReferencePattern.containsMatchIn(content)) { "$label.content 包含临时画像条目编号" }
|
||||
return content
|
||||
}
|
||||
|
||||
fun validateSummary(raw: String, maxLength: Int): String {
|
||||
val summary = raw.trim()
|
||||
require(summary.length <= maxLength) { "画像摘要超过 $maxLength 字符" }
|
||||
require(!itemReferencePattern.containsMatchIn(summary)) { "画像摘要包含临时画像条目编号" }
|
||||
return summary
|
||||
}
|
||||
|
||||
fun normalizedKey(value: String): String = value
|
||||
.lowercase()
|
||||
.replace(Regex("[\\s\\p{Punct},。;、!?()【】‘’“”]+"), "")
|
||||
|
||||
private const val MAX_CONTENT_LENGTH = 120
|
||||
private val overclaimPattern = Regex("深厚|扎实|精通|专家|导师|领袖|天才|极强|全栈|核心成员|公认")
|
||||
private val itemReferencePattern = Regex("(?<![A-Za-z0-9_])P[1-9]\\d*(?![A-Za-z0-9_])")
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import java.sql.ResultSet
|
||||
|
||||
class ProfileHistoryReader(private val databaseFile: File) {
|
||||
data class TimeBounds(val startTime: Int, val endTime: Int)
|
||||
data class GroupTimeBounds(val botId: Long, val startTime: Int, val endTime: Int)
|
||||
|
||||
private data class Episode(
|
||||
val index: Int,
|
||||
@@ -43,6 +44,30 @@ class ProfileHistoryReader(private val databaseFile: File) {
|
||||
}
|
||||
}
|
||||
|
||||
fun findGroupTimeBounds(groupId: Long): GroupTimeBounds? = openReadConnection().use { connection ->
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
SELECT bot_id, MIN(time) AS min_time, MAX(time) AS max_time
|
||||
FROM message_record
|
||||
WHERE kind = ? AND recalled = 0 AND target_id = ?
|
||||
GROUP BY bot_id
|
||||
ORDER BY max_time DESC, bot_id ASC
|
||||
LIMIT 1
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setInt(1, MessageSourceKind.GROUP.ordinal)
|
||||
statement.setLong(2, groupId)
|
||||
statement.executeQuery().use { results ->
|
||||
if (!results.next()) return@use null
|
||||
GroupTimeBounds(
|
||||
botId = results.getLong("bot_id"),
|
||||
startTime = results.getInt("min_time"),
|
||||
endTime = results.getInt("max_time").safeNextSecond(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadNextBatch(
|
||||
userId: Long,
|
||||
startTime: Int,
|
||||
@@ -159,6 +184,40 @@ class ProfileHistoryReader(private val databaseFile: File) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadNextConversationBatch(
|
||||
botId: Long,
|
||||
groupId: Long,
|
||||
startTime: Int,
|
||||
snapshotEndTime: Int,
|
||||
messageLimit: Int,
|
||||
maxMessageChars: Int,
|
||||
): ConversationProfileBatch? {
|
||||
require(startTime <= snapshotEndTime) { "startTime must not be after snapshotEndTime" }
|
||||
if (startTime == snapshotEndTime) return null
|
||||
return openReadConnection().use { connection ->
|
||||
val firstPage = queryOldestConversationMessages(
|
||||
connection = connection,
|
||||
botId = botId,
|
||||
groupId = groupId,
|
||||
startTime = startTime,
|
||||
endTime = snapshotEndTime,
|
||||
limit = messageLimit.coerceAtLeast(1),
|
||||
)
|
||||
if (firstPage.isEmpty()) return@use null
|
||||
|
||||
val endTime = firstPage.last().time.safeNextSecond()
|
||||
val records = queryOldestConversationMessages(
|
||||
connection = connection,
|
||||
botId = botId,
|
||||
groupId = groupId,
|
||||
startTime = startTime,
|
||||
endTime = endTime,
|
||||
limit = Int.MAX_VALUE,
|
||||
)
|
||||
createConversationBatch(botId, groupId, startTime, endTime, records, maxMessageChars)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryTargetMessages(
|
||||
connection: Connection,
|
||||
userId: Long,
|
||||
@@ -218,6 +277,35 @@ class ProfileHistoryReader(private val databaseFile: File) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryOldestConversationMessages(
|
||||
connection: Connection,
|
||||
botId: Long,
|
||||
groupId: Long,
|
||||
startTime: Int,
|
||||
endTime: Int,
|
||||
limit: Int,
|
||||
): List<ChatMessageRecord> {
|
||||
return connection.prepareStatement(
|
||||
"""
|
||||
SELECT id, bot_id, from_id, target_id, ids, internal_ids,
|
||||
time, kind, code, recalled
|
||||
FROM message_record
|
||||
WHERE bot_id = ? AND target_id = ? AND kind = ? AND recalled = 0
|
||||
AND time >= ? AND time < ?
|
||||
ORDER BY time ASC, id ASC
|
||||
LIMIT ?
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setLong(1, botId)
|
||||
statement.setLong(2, groupId)
|
||||
statement.setInt(3, MessageSourceKind.GROUP.ordinal)
|
||||
statement.setInt(4, startTime)
|
||||
statement.setInt(5, endTime)
|
||||
statement.setInt(6, limit)
|
||||
statement.executeQuery().use(::readRecords)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createConversationBatch(
|
||||
botId: Long,
|
||||
groupId: Long,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package top.jie65535.mirai.profile
|
||||
|
||||
object ProfileItemReferences {
|
||||
data class Entry(
|
||||
val reference: String,
|
||||
val item: UserProfileItem,
|
||||
)
|
||||
|
||||
fun entries(profile: UserProfileSnapshot): List<Entry> = profile.items.mapIndexed { index, item ->
|
||||
Entry(reference = "P${index + 1}", item = item)
|
||||
}
|
||||
|
||||
fun resolve(profile: UserProfileSnapshot, rawReference: String?): UserProfileItem? {
|
||||
val index = rawReference
|
||||
?.trim()
|
||||
?.let(REFERENCE_PATTERN::matchEntire)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.toIntOrNull()
|
||||
?: return null
|
||||
return profile.items.getOrNull(index - 1)
|
||||
}
|
||||
|
||||
private val REFERENCE_PATTERN = Regex("P([1-9]\\d*)")
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import kotlinx.serialization.json.Json
|
||||
import top.jie65535.mirai.llm.LargeLanguageModels
|
||||
import top.jie65535.mirai.llm.ModelService
|
||||
|
||||
internal val profileResponseJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
interface ProfileModel {
|
||||
val modelName: String
|
||||
|
||||
@@ -30,13 +35,19 @@ interface ConversationProfileModel {
|
||||
): ConversationProfileModelResult
|
||||
}
|
||||
|
||||
interface ProfileCompactionModel {
|
||||
val modelName: String
|
||||
|
||||
suspend fun compact(
|
||||
profile: UserProfileSnapshot,
|
||||
supportStats: Map<String, ProfileItemSupportStats>,
|
||||
): ProfileCompactionModelResult
|
||||
}
|
||||
|
||||
class ProfileModelClient(
|
||||
private val endpoint: LargeLanguageModels.ProfileEndpoint,
|
||||
) : ProfileModel, ConversationProfileModel {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = false
|
||||
explicitNulls = false
|
||||
}
|
||||
) : ProfileModel, ConversationProfileModel, ProfileCompactionModel {
|
||||
private val json = profileResponseJson
|
||||
|
||||
override val modelName: String
|
||||
get() = endpoint.model
|
||||
@@ -115,15 +126,54 @@ class ProfileModelClient(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun compact(
|
||||
profile: UserProfileSnapshot,
|
||||
supportStats: Map<String, ProfileItemSupportStats>,
|
||||
): ProfileCompactionModelResult {
|
||||
val content = StringBuilder()
|
||||
var lastUsage: Usage? = null
|
||||
var cacheUsage: ModelService.CacheUsage? = null
|
||||
endpoint.service.chatCompletions(
|
||||
ChatCompletionRequest(
|
||||
model = ModelId(endpoint.model),
|
||||
temperature = endpoint.temperature,
|
||||
responseFormat = ChatResponseFormat.JsonObject,
|
||||
streamOptions = StreamOptions(includeUsage = true),
|
||||
messages = listOf(
|
||||
ChatMessage.System(ProfilePromptStore.compactionSystemPrompt),
|
||||
ChatMessage.User(ProfilePromptStore.buildCompactionUserPrompt(profile, supportStats)),
|
||||
),
|
||||
)
|
||||
) { cacheUsage = it }.collect { chunk ->
|
||||
chunk.choices.firstOrNull()?.delta?.content?.let(content::append)
|
||||
chunk.usage?.let { lastUsage = it }
|
||||
}
|
||||
|
||||
val raw = content.toString().replace(THINK_REGEX, "").trim()
|
||||
return ProfileCompactionModelResult(
|
||||
response = json.decodeFromString(extractObject(raw)),
|
||||
rawResponse = raw,
|
||||
usage = ProfileTokenUsage(
|
||||
promptTokens = lastUsage?.promptTokens ?: 0,
|
||||
completionTokens = lastUsage?.completionTokens ?: 0,
|
||||
cachedTokens = cacheUsage?.hitTokens ?: 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseResponse(raw: String): ProfileModelResponse {
|
||||
return parseObject(raw)
|
||||
}
|
||||
|
||||
private inline fun <reified T> parseObject(raw: String): T {
|
||||
return json.decodeFromString(extractObject(raw))
|
||||
}
|
||||
|
||||
private fun extractObject(raw: String): String {
|
||||
val unfenced = raw
|
||||
.removePrefix("```json").removePrefix("```")
|
||||
.removeSuffix("```").trim()
|
||||
val objectText = if (unfenced.startsWith('{') && unfenced.endsWith('}')) {
|
||||
return if (unfenced.startsWith('{') && unfenced.endsWith('}')) {
|
||||
unfenced
|
||||
} else {
|
||||
val start = unfenced.indexOf('{')
|
||||
@@ -131,7 +181,6 @@ class ProfileModelClient(
|
||||
if (start < 0 || end <= start) throw SerializationException("模型响应中没有完整 JSON object")
|
||||
unfenced.substring(start, end + 1)
|
||||
}
|
||||
return json.decodeFromString(objectText)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package top.jie65535.mirai.profile
|
||||
|
||||
object ProfilePersistentText {
|
||||
fun normalizeSummary(raw: String, batch: ProfileHistoryBatch): String {
|
||||
var result = raw
|
||||
batch.aliases[batch.userId]?.let { result = replaceAlias(result, it, "该用户") }
|
||||
result = replaceAlias(result, "TARGET", "该用户")
|
||||
return normalize(result) { alias ->
|
||||
when (alias) {
|
||||
"TARGET" -> "该用户"
|
||||
"BOT" -> "机器人"
|
||||
else -> "其他用户"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun normalizeItemContent(
|
||||
raw: String,
|
||||
batch: ProfileHistoryBatch,
|
||||
relatedUserId: Long?,
|
||||
): String {
|
||||
var result = raw
|
||||
batch.aliases[batch.userId]?.let { result = replaceAlias(result, it, "本人") }
|
||||
result = replaceAlias(result, "TARGET", "本人")
|
||||
relatedUserId
|
||||
?.let(batch.aliases::get)
|
||||
?.let { result = replaceAlias(result, it, "对方") }
|
||||
return normalize(result) { alias -> if (alias == "BOT") "机器人" else "其他用户" }
|
||||
}
|
||||
|
||||
fun summaryForDisplay(raw: String): String = normalize(raw) { alias ->
|
||||
when (alias) {
|
||||
"TARGET" -> "该用户"
|
||||
"BOT" -> "机器人"
|
||||
else -> "其他用户"
|
||||
}
|
||||
}
|
||||
|
||||
fun itemForDisplay(raw: String, relationship: Boolean): String = normalize(raw) { alias ->
|
||||
when (alias) {
|
||||
"TARGET" -> "本人"
|
||||
"BOT" -> "机器人"
|
||||
else -> if (relationship) "对方" else "其他用户"
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalize(raw: String, replacement: (String) -> String): String = INTERNAL_ALIAS_PATTERN
|
||||
.replace(raw.trim()) { match -> replacement(match.value) }
|
||||
.replace(WHITESPACE_PATTERN, " ")
|
||||
.replace(CJK_SPACE_PATTERN, "")
|
||||
|
||||
private fun replaceAlias(source: String, alias: String, replacement: String): String {
|
||||
if (alias.isBlank()) return source
|
||||
val pattern = Regex("(?<![A-Za-z0-9_])${Regex.escape(alias)}(?![A-Za-z0-9_])")
|
||||
return pattern.replace(source, replacement)
|
||||
}
|
||||
|
||||
private val INTERNAL_ALIAS_PATTERN = Regex(
|
||||
"(?<![A-Za-z0-9_])(?:TARGET|BOT|U\\d+|R\\d+)(?![A-Za-z0-9_])"
|
||||
)
|
||||
private val WHITESPACE_PATTERN = Regex("\\s+")
|
||||
private val CJK_SPACE_PATTERN = Regex(
|
||||
"(?<=[\\p{IsHan},。;:!?()])\\s+(?=[\\p{IsHan},。;:!?()])"
|
||||
)
|
||||
}
|
||||
@@ -1,52 +1,19 @@
|
||||
package top.jie65535.mirai.profile
|
||||
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.config.PluginConfig
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
object ProfilePromptStore {
|
||||
const val PROMPT_VERSION = "profile-v3.3"
|
||||
const val PROMPT_VERSION = "profile-v5"
|
||||
const val COMPACTION_PROMPT_VERSION = "profile-compact-v3"
|
||||
|
||||
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
|
||||
var systemPrompt: String = DEFAULT_SYSTEM_PROMPT
|
||||
private set
|
||||
|
||||
var conversationSystemPrompt: String = DEFAULT_CONVERSATION_SYSTEM_PROMPT
|
||||
private set
|
||||
|
||||
fun reload() {
|
||||
systemPrompt = loadPrompt(
|
||||
configuredPath = PluginConfig.profilePromptFile,
|
||||
defaultPrompt = DEFAULT_SYSTEM_PROMPT,
|
||||
label = "定向画像",
|
||||
)
|
||||
conversationSystemPrompt = loadPrompt(
|
||||
configuredPath = PluginConfig.profileConversationPromptFile,
|
||||
defaultPrompt = DEFAULT_CONVERSATION_SYSTEM_PROMPT,
|
||||
label = "多人会话画像",
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadPrompt(configuredPath: String, defaultPrompt: String, label: String): String {
|
||||
if (configuredPath.isBlank()) return defaultPrompt
|
||||
val file = JChatGPT.resolveConfigFile(configuredPath)
|
||||
return try {
|
||||
when {
|
||||
file.exists() && file.readText().isNotBlank() -> file.readText()
|
||||
else -> defaultPrompt.also { default ->
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(default)
|
||||
}
|
||||
}
|
||||
} catch (cause: Throwable) {
|
||||
JChatGPT.logger.warning("载入${label}提示词失败,暂时使用内置提示词", cause)
|
||||
defaultPrompt
|
||||
}
|
||||
}
|
||||
val systemPrompt: String = DEFAULT_SYSTEM_PROMPT
|
||||
val conversationSystemPrompt: String = DEFAULT_CONVERSATION_SYSTEM_PROMPT
|
||||
val compactionSystemPrompt: String = DEFAULT_COMPACTION_SYSTEM_PROMPT
|
||||
|
||||
fun buildUserPrompt(profile: UserProfileSnapshot, batch: ProfileHistoryBatch): String = buildString {
|
||||
appendLine("## 目标")
|
||||
@@ -59,11 +26,14 @@ object ProfilePromptStore {
|
||||
if (profile.items.isEmpty()) {
|
||||
appendLine("(尚无画像条目)")
|
||||
} else {
|
||||
profile.items.forEach { item ->
|
||||
append("[P:").append(item.id).append("] ")
|
||||
ProfileItemReferences.entries(profile).forEach { (reference, item) ->
|
||||
append('[').append(reference).append("] ")
|
||||
append(item.category.wireName()).append(" | ")
|
||||
append(item.confidence.wireName()).append(" | ")
|
||||
append(item.content)
|
||||
append(ProfilePersistentText.itemForDisplay(
|
||||
item.content,
|
||||
relationship = item.category == ProfileCategory.RELATIONSHIP_NOTE,
|
||||
))
|
||||
item.relatedUserId?.let { related ->
|
||||
append(" | related=").append(batch.aliases[related] ?: "历史用户")
|
||||
}
|
||||
@@ -72,7 +42,7 @@ object ProfilePromptStore {
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
appendLine("当前短摘要: ${profile.summary.ifBlank { "(空)" }}")
|
||||
appendLine("当前短摘要: ${ProfilePersistentText.summaryForDisplay(profile.summary).ifBlank { "(空)" }}")
|
||||
appendLine()
|
||||
|
||||
appendLine("## 本批参与者别名")
|
||||
@@ -104,7 +74,8 @@ object ProfilePromptStore {
|
||||
eligibleUserIds: Set<Long>,
|
||||
): String = buildString {
|
||||
appendLine("## 任务")
|
||||
appendLine("分析这一段已经闭合的群聊,一次性更新所有出现可靠长期信息的候选用户画像。")
|
||||
appendLine("分析这一段已经闭合的群聊,一次性更新所有出现可靠画像信息的候选用户画像。")
|
||||
appendLine("候选用户别名: ${eligibleUserIds.mapNotNull(batch.aliases::get).sorted().joinToString(", ")}")
|
||||
appendLine("会话时间范围: [${formatTime(batch.startTime)}, ${formatTime(batch.endTime)})")
|
||||
appendLine("别名只在本批有效,不要输出 QQ 号或数据库消息 ID。")
|
||||
appendLine()
|
||||
@@ -117,11 +88,14 @@ object ProfilePromptStore {
|
||||
if (profile == null || profile.items.isEmpty()) {
|
||||
appendLine("(尚无画像条目)")
|
||||
} else {
|
||||
profile.items.forEach { item ->
|
||||
append("[P:").append(item.id).append("] ")
|
||||
ProfileItemReferences.entries(profile).forEach { (reference, item) ->
|
||||
append('[').append(reference).append("] ")
|
||||
append(item.category.wireName()).append(" | ")
|
||||
append(item.confidence.wireName()).append(" | ")
|
||||
append(item.content)
|
||||
append(ProfilePersistentText.itemForDisplay(
|
||||
item.content,
|
||||
relationship = item.category == ProfileCategory.RELATIONSHIP_NOTE,
|
||||
))
|
||||
item.relatedUserId?.let { related ->
|
||||
append(" | related=").append(batch.aliases[related] ?: "历史用户")
|
||||
}
|
||||
@@ -130,7 +104,8 @@ object ProfilePromptStore {
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
appendLine("当前短摘要: ${profile?.summary?.ifBlank { "(空)" } ?: "(空)"}")
|
||||
val summary = profile?.summary?.let(ProfilePersistentText::summaryForDisplay).orEmpty()
|
||||
appendLine("当前短摘要: ${summary.ifBlank { "(空)" }}")
|
||||
}
|
||||
appendLine()
|
||||
|
||||
@@ -145,6 +120,44 @@ object ProfilePromptStore {
|
||||
}
|
||||
}
|
||||
|
||||
fun buildCompactionUserPrompt(
|
||||
profile: UserProfileSnapshot,
|
||||
supportStats: Map<String, ProfileItemSupportStats>,
|
||||
): String = buildString {
|
||||
appendLine("## 待整理画像")
|
||||
appendLine("当前条目数: ${profile.items.size}")
|
||||
appendLine("当前摘要: ${ProfilePersistentText.summaryForDisplay(profile.summary).ifBlank { "(空)" }}")
|
||||
appendLine()
|
||||
|
||||
val relatedReferences = profile.items.asSequence()
|
||||
.mapNotNull(UserProfileItem::relatedUserId)
|
||||
.distinct()
|
||||
.sorted()
|
||||
.mapIndexed { index, userId -> userId to "R${index + 1}" }
|
||||
.toMap()
|
||||
ProfileItemReferences.entries(profile).forEach { (reference, item) ->
|
||||
val supports = supportStats[item.id]
|
||||
append('[').append(reference).append("] ")
|
||||
append(item.category.wireName()).append(" | ")
|
||||
append(item.confidence.wireName()).append(" | ")
|
||||
append(ProfilePersistentText.itemForDisplay(
|
||||
item.content,
|
||||
relationship = item.category == ProfileCategory.RELATIONSHIP_NOTE,
|
||||
))
|
||||
item.relatedUserId?.let { related ->
|
||||
append(" | related_group=").append(relatedReferences.getValue(related))
|
||||
}
|
||||
append(" | supports=").append(supports?.count ?: 0)
|
||||
supports?.takeIf { it.count > 0 }?.let {
|
||||
append(" | support_range=").append(formatTime(it.firstSupportedAt))
|
||||
.append(" ~ ").append(formatTime(it.lastSupportedAt))
|
||||
}
|
||||
append(" | item_range=").append(formatTime(item.firstSeenAt))
|
||||
.append(" ~ ").append(formatTime(item.lastConfirmedAt))
|
||||
appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTime(epochSecond: Int): String =
|
||||
dateTimeFormatter.format(Instant.ofEpochSecond(epochSecond.toLong()))
|
||||
|
||||
@@ -154,10 +167,10 @@ object ProfilePromptStore {
|
||||
private const val DEFAULT_SYSTEM_PROMPT = """你是保守、严谨的群聊人物画像归纳器。
|
||||
|
||||
你会收到一个目标用户的当前画像,以及一个带完整发言者、时间、回复引用和相邻消息的原始群聊批次。
|
||||
你的任务是判断本批信息是否应当 ADD、UPDATE、CONFIRM 或 DELETE 长期画像条目;没有可靠变化时返回空 operations。
|
||||
你的任务是判断本批信息是否应当 ADD、UPDATE、CONFIRM 或 DELETE 画像条目;没有可靠变化时返回空 operations。
|
||||
|
||||
画像只描述:
|
||||
- notable_fact:本人明确披露且半年后仍有助于认识此人的事实
|
||||
- notable_fact:本人明确披露的稳定事实,或当前仍有认识价值的阶段状态、近期事件
|
||||
- interest:持续关注或参与的领域
|
||||
- expertise_signal:反复表现出的具体知识或解决问题能力,不授予专家头衔
|
||||
- thinking_style:分析、判断和解决问题的方式
|
||||
@@ -174,18 +187,22 @@ object ProfilePromptStore {
|
||||
5. 一次技术回答或同一话题中的连续补充只算一个语境。新增 expertise_signal、thinking_style、expression_style、social_mode 或 relationship_note,必须由至少两个独立对话片段中的一致表现支持;单个片段最多用于 CONFIRM 已有条目。本人明确自述的 notable_fact 和 preference 不受此限制。
|
||||
6. 每个条目只表达一个主题。禁止把不同时间、不同领域的内容拼成一个所谓稳定特点。
|
||||
7. 禁止使用“精通、专家、导师、领袖、天才、极强、全栈、核心成员”等拔高表述。
|
||||
8. ADD 不填写 item_id;UPDATE、CONFIRM、DELETE 必须填写当前画像中的 item_id。
|
||||
9. relationship_note 必须填写本批存在的 related_user_alias,只描述互动方式,不推断现实亲疏。
|
||||
8. ADD 不填写 item_ref;UPDATE、CONFIRM、DELETE 必须填写当前画像中的 P 编号作为 item_ref。
|
||||
9. relationship_note 必须填写本批存在的 related_user_alias;content 只描述互动方式,不重复人物别名,不推断现实亲疏。
|
||||
10. 新证据与旧画像无关时不要勉强更新。未输出的旧条目由程序自动保留。
|
||||
11. DELETE 仅用于新证据明确证明旧条目归因错误或已被本人否定,不能因为本批没提到就删除。
|
||||
12. summary 是更新后的日常短摘要,必须自然、克制,不写证据编号、时间、QQ 号、进度或内部条目 ID。
|
||||
12. summary 是应用 operations 并保留所有未操作旧条目之后,对完整画像的综合人物摘要,不是本批聊天摘要,也不是本批新增条目的复述。
|
||||
13. 综合摘要应优先概括最有代表性的身份/能力、兴趣/偏好、思考/表达/社交方式等不同维度;已有多个维度时不得只写最后编辑的一项。轻微新增或单纯确认不改变整体形象时,原样保留当前短摘要。
|
||||
14. summary 必须自然、克制,不写证据编号、QQ 号、内部条目 ID、逐条清单或具体关系流水。
|
||||
15. evidence_refs 只输出 JSON 整数,例如 [128, 129];不要输出 "e:128" 这样的字符串,也不要引用输入中不存在的编号。
|
||||
16. 对求职、健康、经济状况、近期进度等有时效的信息,content 和 summary 不得悬空使用“本月、最近、目前、当前、正在”;必须依据证据时间改写成“截至 YYYY-MM-DD”或“YYYY-MM-DD 提及”的绝对时间表达。
|
||||
|
||||
只输出一个 JSON object,不要 Markdown 代码围栏,不要解释。格式:
|
||||
{
|
||||
"operations": [
|
||||
{
|
||||
"action": "ADD|UPDATE|CONFIRM|DELETE",
|
||||
"item_id": "UPDATE/CONFIRM/DELETE 时填写,ADD 为 null",
|
||||
"item_ref": "UPDATE/CONFIRM/DELETE 时填写 P1 这样的编号,ADD 为 null",
|
||||
"category": "notable_fact|interest|expertise_signal|thinking_style|expression_style|social_mode|preference|relationship_note",
|
||||
"content": "ADD/UPDATE 时填写的单一、克制结论;其余操作可为 null",
|
||||
"confidence": "low|medium|high",
|
||||
@@ -199,10 +216,10 @@ object ProfilePromptStore {
|
||||
|
||||
private const val DEFAULT_CONVERSATION_SYSTEM_PROMPT = """你是保守、严谨的群聊人物画像归纳器。
|
||||
|
||||
你会收到一段已经闭合的真实群聊、候选用户别名,以及他们各自的当前画像。你的任务是一次性判断这段对话是否足以 ADD、UPDATE、CONFIRM 或 DELETE 各候选用户的长期画像条目。没有可靠变化的用户不要输出,每名用户最多输出 4 个真正有长期价值的操作。
|
||||
你会收到一段已经闭合的真实群聊、候选用户别名,以及他们各自的当前画像。你的任务是一次性判断这段对话是否足以 ADD、UPDATE、CONFIRM 或 DELETE 各候选用户的画像条目。没有可靠变化的用户不要输出,只输出稳定事实或当前仍有认识价值的阶段状态、近期事件。
|
||||
|
||||
画像只描述:
|
||||
- notable_fact:本人明确披露且半年后仍有助于认识此人的事实
|
||||
- notable_fact:本人明确披露的稳定事实,或当前仍有认识价值的阶段状态、近期事件
|
||||
- interest:持续关注或参与的领域
|
||||
- expertise_signal:反复表现出的具体知识或解决问题能力,不授予专家头衔
|
||||
- thinking_style:分析、判断和解决问题的方式
|
||||
@@ -219,13 +236,18 @@ object ProfilePromptStore {
|
||||
5. 一次明确自述可以支持 notable_fact 或 preference。新增 expertise_signal、thinking_style、expression_style、social_mode 或 relationship_note,必须在本会话中有多条分离的本人证据;证据不足时宁可不写。
|
||||
6. 每个条目只表达一个主题,禁止把不同人的特点或不同领域拼接在一起。
|
||||
7. 禁止使用“精通、专家、导师、领袖、天才、极强、全栈、核心成员”等拔高表述。
|
||||
8. ADD 不填写 item_id;UPDATE、CONFIRM、DELETE 必须填写该用户当前画像中的 item_id,不能引用其他用户的条目。
|
||||
9. relationship_note 必须填写本批存在的 related_user_alias,只描述互动方式,不推断现实亲疏。
|
||||
8. ADD 不填写 item_ref;UPDATE、CONFIRM、DELETE 必须填写该用户当前画像中的 P 编号作为 item_ref,不能引用其他用户的条目。
|
||||
9. relationship_note 必须填写本批存在的 related_user_alias;content 只描述互动方式,不重复人物别名,不推断现实亲疏。
|
||||
10. 未输出的用户和旧条目由程序自动保留。DELETE 仅用于新证据明确证明旧条目归因错误或已被本人否定。
|
||||
11. summary 是该用户更新后的日常短摘要,必须自然、克制,不写证据编号、时间、QQ 号、进度或内部 ID。
|
||||
12. 不生成或修改好感度、代号、主观印象和标签;这些属于另一套 Bot 关系状态。
|
||||
13. 本批只是一段会话。除非当前画像已有同类条目且本批在确认它,否则不得使用“长期、持续、一贯、总是、通常”等跨时间措辞;只能描述本批确实支持的事实、关注点或表现。
|
||||
14. 对尚无同类旧条目的用户,thinking_style、expression_style、social_mode、expertise_signal 和 relationship_note 必须有多个彼此分离的本人证据才可新增,并保持 low 或 medium 可信度;同一问答链中的连续补充不算多次独立表现。
|
||||
11. summary 是应用该用户 operations 并保留所有未操作旧条目之后,对其完整画像的综合人物摘要,不是本批聊天摘要,也不是本批新增条目的复述。
|
||||
12. 综合摘要应优先概括最有代表性的身份/能力、兴趣/偏好、思考/表达/社交方式等不同维度;已有多个维度时不得只写最后编辑的一项。轻微新增或单纯确认不改变整体形象时,原样保留当前短摘要。
|
||||
13. summary 必须自然、克制,不写证据编号、QQ 号、内部 ID、逐条清单或具体关系流水。
|
||||
14. 不生成或修改好感度、代号、主观印象和标签;这些属于另一套 Bot 关系状态。
|
||||
15. 本批只是一段会话。除非当前画像已有同类条目且本批在确认它,否则不得使用“长期、持续、一贯、总是、通常”等跨时间措辞;只能描述本批确实支持的事实、关注点或表现。
|
||||
16. 对尚无同类旧条目的用户,thinking_style、expression_style、social_mode、expertise_signal 和 relationship_note 必须有多个彼此分离的本人证据才可新增,并保持 low 或 medium 可信度;同一问答链中的连续补充不算多次独立表现。
|
||||
17. evidence_refs 只输出 JSON 整数,例如 [128, 129];不要输出 "e:128" 这样的字符串,也不要引用输入中不存在的编号。
|
||||
18. 对求职、健康、经济状况、近期进度等有时效的信息,content 和 summary 不得悬空使用“本月、最近、目前、当前、正在”;必须依据证据时间改写成“截至 YYYY-MM-DD”或“YYYY-MM-DD 提及”的绝对时间表达。
|
||||
19. users 只能输出“候选用户别名”中明确列出的用户;消息里出现但不在候选名单中的上下文用户不要输出。
|
||||
|
||||
只输出一个 JSON object,不要 Markdown 代码围栏,不要解释。格式:
|
||||
{
|
||||
@@ -235,7 +257,7 @@ object ProfilePromptStore {
|
||||
"operations": [
|
||||
{
|
||||
"action": "ADD|UPDATE|CONFIRM|DELETE",
|
||||
"item_id": "UPDATE/CONFIRM/DELETE 时填写,ADD 为 null",
|
||||
"item_ref": "UPDATE/CONFIRM/DELETE 时填写 P1 这样的编号,ADD 为 null",
|
||||
"category": "notable_fact|interest|expertise_signal|thinking_style|expression_style|social_mode|preference|relationship_note",
|
||||
"content": "ADD/UPDATE 时填写的单一、克制结论;其余操作可为 null",
|
||||
"confidence": "low|medium|high",
|
||||
@@ -247,5 +269,31 @@ object ProfilePromptStore {
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
private const val DEFAULT_COMPACTION_SYSTEM_PROMPT = """你是保守的用户画像编辑器。你会收到一份已有画像,条目本身不是新的事实证据;supports 只表示程序保存的历史支持次数。
|
||||
|
||||
你的任务仅是减少重复和噪声,不得补充输入中不存在的新事实:
|
||||
1. merges 用于合并语义高度重叠、粒度过细的条目。item_refs 至少两个,必须同类别;relationship_note 还必须具有相同 related_group。content 写合并后的单一概括,不拼接无关主题。
|
||||
2. rewrites 用于把一个条目改写得更概括、自然,不改变事实含义、类别、关联对象和置信度。对“正在、本月、最近、目前”等有时效的旧表述,应依据 item_range 改写成带 YYYY-MM-DD 的绝对时间表达。
|
||||
3. deletes 仅删除 low/medium 置信、supports 不超过 1,且明显属于过细例子或根本不应成为画像的内容。
|
||||
4. 同一 P 编号最多出现在一个操作中。未提及的条目自动保留。拿不准时不要操作。
|
||||
5. 禁止输出 TARGET、BOT、U 编号、R 编号、QQ 号、昵称、P 编号或 UUID 到 content/summary。
|
||||
6. summary 必须基于所有操作完成后的全部保留条目,综合最有代表性的多个维度;不是本次 merges/rewrites/deletes 的变更摘要,也不得只描述最后编辑的条目。若当前摘要已经综合且整理没有改变整体人物形象,原样保留;若当前摘要明显偏向单条或遗漏主要维度,即使没有条目操作也应重写。
|
||||
7. summary 应自然、克制,不写逐条清单或具体关系流水;有时效的信息必须带绝对日期,不得保留悬空相对表达。
|
||||
|
||||
只输出一个 JSON object,不要 Markdown 代码围栏,不要解释。格式:
|
||||
{
|
||||
"merges": [
|
||||
{"item_refs": ["P1", "P2"], "content": "合并后的单一结论"}
|
||||
],
|
||||
"rewrites": [
|
||||
{"item_ref": "P3", "content": "更概括但不增加事实的结论"}
|
||||
],
|
||||
"deletes": [
|
||||
{"item_ref": "P4", "reason": "one_off|over_specific|transient|not_profile"}
|
||||
],
|
||||
"summary": "整理后的短摘要"
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ import top.jie65535.mirai.config.PluginConfig
|
||||
import top.jie65535.mirai.data.ChatHistoryStore
|
||||
import top.jie65535.mirai.llm.LargeLanguageModels
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object UserProfileAnalysisService {
|
||||
private val runningUsers = ConcurrentHashMap.newKeySet<Long>()
|
||||
private val runningGroups = ConcurrentHashMap.newKeySet<Long>()
|
||||
private val concurrencyLimiter = Semaphore(1)
|
||||
|
||||
suspend fun analyze(
|
||||
@@ -32,6 +34,7 @@ object UserProfileAnalysisService {
|
||||
processedBatches = 0,
|
||||
processedMessages = 0,
|
||||
appliedOperations = 0,
|
||||
skippedOperations = 0,
|
||||
usage = ProfileTokenUsage(),
|
||||
profile = UserProfileStore.load(userId),
|
||||
caughtUp = false,
|
||||
@@ -48,6 +51,90 @@ object UserProfileAnalysisService {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun compact(userId: Long): ProfileCompactionReport {
|
||||
require(userId > 0) { "userId 必须是正数" }
|
||||
check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" }
|
||||
check(UserProfileStore.isAvailable) { "用户画像数据库不可用" }
|
||||
|
||||
return concurrencyLimiter.withPermit {
|
||||
val profile = withContext(Dispatchers.IO) { UserProfileStore.load(userId) }
|
||||
?: throw IllegalArgumentException("用户 $userId 尚无画像")
|
||||
if (profile.items.isEmpty()) {
|
||||
return@withPermit ProfileCompactionReport(
|
||||
userId = userId,
|
||||
beforeItems = profile.items.size,
|
||||
afterItems = profile.items.size,
|
||||
mergedGroups = 0,
|
||||
rewrittenItems = 0,
|
||||
deletedItems = 0,
|
||||
summaryChanged = false,
|
||||
skippedOperations = 0,
|
||||
usage = ProfileTokenUsage(),
|
||||
profile = profile,
|
||||
)
|
||||
}
|
||||
|
||||
val endpoint = checkNotNull(LargeLanguageModels.profile) { "画像分析模型未配置" }
|
||||
val model: ProfileCompactionModel = ProfileModelClient(endpoint)
|
||||
val supportStats = withContext(Dispatchers.IO) { UserProfileStore.loadSupportStats(userId) }
|
||||
val (result, plan) = compactWithRetry(model, profile, supportStats)
|
||||
if (plan.reduction.profile.version != profile.version) {
|
||||
val batch = compactionBatch(profile, result.rawResponse)
|
||||
withContext(Dispatchers.IO) {
|
||||
UserProfileStore.commitCompaction(plan, batch, result.usage)
|
||||
}
|
||||
}
|
||||
ProfileCompactionReport(
|
||||
userId = userId,
|
||||
beforeItems = profile.items.size,
|
||||
afterItems = plan.reduction.profile.items.size,
|
||||
mergedGroups = plan.mergedGroups,
|
||||
rewrittenItems = plan.rewrittenItems,
|
||||
deletedItems = plan.deletedItems,
|
||||
summaryChanged = plan.reduction.profile.summary != profile.summary,
|
||||
skippedOperations = plan.skippedOperations.size,
|
||||
usage = result.usage,
|
||||
profile = plan.reduction.profile,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun analyzeGroup(
|
||||
groupId: Long,
|
||||
maxBatches: Int,
|
||||
onProgress: suspend (GroupProfileAnalysisProgress) -> Unit = {},
|
||||
): GroupProfileAnalysisReport {
|
||||
require(groupId > 0) { "groupId 必须是正数" }
|
||||
require(maxBatches > 0) { "maxBatches 必须是正数" }
|
||||
check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" }
|
||||
check(UserProfileStore.isAvailable) { "用户画像数据库不可用" }
|
||||
|
||||
if (!runningGroups.add(groupId)) {
|
||||
return GroupProfileAnalysisReport(
|
||||
botId = null,
|
||||
groupId = groupId,
|
||||
processedBatches = 0,
|
||||
processedMessages = 0,
|
||||
analyzedUsers = 0,
|
||||
appliedOperations = 0,
|
||||
skippedOperations = 0,
|
||||
usage = ProfileTokenUsage(),
|
||||
cursorTime = 0,
|
||||
snapshotEndTime = 0,
|
||||
caughtUp = false,
|
||||
alreadyRunning = true,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return concurrencyLimiter.withPermit {
|
||||
analyzeGroupExclusive(groupId, maxBatches, onProgress)
|
||||
}
|
||||
} finally {
|
||||
runningGroups.remove(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun analyzeExclusive(
|
||||
userId: Long,
|
||||
maxBatches: Int,
|
||||
@@ -75,6 +162,7 @@ object UserProfileAnalysisService {
|
||||
var processedBatches = 0
|
||||
var processedMessages = 0
|
||||
var appliedOperations = 0
|
||||
var skippedOperations = 0
|
||||
var totalUsage = ProfileTokenUsage()
|
||||
var caughtUp = false
|
||||
|
||||
@@ -111,6 +199,7 @@ object UserProfileAnalysisService {
|
||||
processedBatches++
|
||||
processedMessages += batch.messages.size
|
||||
appliedOperations += reduction.operations.size
|
||||
skippedOperations += reduction.skippedOperations.size
|
||||
totalUsage += result.usage
|
||||
onProgress(
|
||||
ProfileAnalysisProgress(
|
||||
@@ -119,6 +208,7 @@ object UserProfileAnalysisService {
|
||||
endTime = batch.endTime,
|
||||
messageCount = batch.messages.size,
|
||||
operationCount = reduction.operations.size,
|
||||
skippedOperationCount = reduction.skippedOperations.size,
|
||||
usage = result.usage,
|
||||
)
|
||||
)
|
||||
@@ -130,12 +220,117 @@ object UserProfileAnalysisService {
|
||||
processedBatches = processedBatches,
|
||||
processedMessages = processedMessages,
|
||||
appliedOperations = appliedOperations,
|
||||
skippedOperations = skippedOperations,
|
||||
usage = totalUsage,
|
||||
profile = withContext(Dispatchers.IO) { UserProfileStore.load(userId) } ?: profile,
|
||||
caughtUp = caughtUp,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun analyzeGroupExclusive(
|
||||
groupId: Long,
|
||||
maxBatches: Int,
|
||||
onProgress: suspend (GroupProfileAnalysisProgress) -> Unit,
|
||||
): GroupProfileAnalysisReport {
|
||||
val reader = withContext(Dispatchers.IO) { ProfileHistoryReader(resolveHistoryFile()) }
|
||||
val bounds = withContext(Dispatchers.IO) { reader.findGroupTimeBounds(groupId) }
|
||||
?: return emptyGroupReport(groupId)
|
||||
val endpoint = checkNotNull(LargeLanguageModels.profile) {
|
||||
"画像分析模型未配置,请设置 profileModelApi/profileModelToken,或配置可继承的聊天模型接入点"
|
||||
}
|
||||
val model: ConversationProfileModel = ProfileModelClient(endpoint)
|
||||
var cursor = withContext(Dispatchers.IO) {
|
||||
UserProfileStore.loadGroupCursor(bounds.botId, groupId)
|
||||
} ?: GroupProfileCursor(
|
||||
botId = bounds.botId,
|
||||
groupId = groupId,
|
||||
cursorTime = bounds.startTime,
|
||||
snapshotEndTime = bounds.endTime,
|
||||
)
|
||||
if (cursor.cursorTime >= cursor.snapshotEndTime && bounds.endTime > cursor.snapshotEndTime) {
|
||||
cursor = cursor.copy(snapshotEndTime = bounds.endTime)
|
||||
}
|
||||
|
||||
var processedBatches = 0
|
||||
var processedMessages = 0
|
||||
var analyzedUsers = 0
|
||||
var appliedOperations = 0
|
||||
var skippedOperations = 0
|
||||
var totalUsage = ProfileTokenUsage()
|
||||
var caughtUp = cursor.cursorTime >= cursor.snapshotEndTime
|
||||
|
||||
while (processedBatches < maxBatches && !caughtUp) {
|
||||
val batch = withContext(Dispatchers.IO) {
|
||||
reader.loadNextConversationBatch(
|
||||
botId = cursor.botId,
|
||||
groupId = groupId,
|
||||
startTime = cursor.cursorTime,
|
||||
snapshotEndTime = cursor.snapshotEndTime,
|
||||
messageLimit = PluginConfig.profileAutoConversationMessageLimit.coerceAtLeast(1),
|
||||
maxMessageChars = PluginConfig.profileMaxMessageChars.coerceAtLeast(80),
|
||||
)
|
||||
}
|
||||
if (batch == null) {
|
||||
cursor = cursor.copy(
|
||||
cursorTime = cursor.snapshotEndTime,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
)
|
||||
withContext(Dispatchers.IO) { UserProfileStore.saveGroupCursor(cursor) }
|
||||
caughtUp = true
|
||||
break
|
||||
}
|
||||
|
||||
val report = analyzeConversationBatch(
|
||||
batch = batch,
|
||||
minAuthoredTextChars = PluginConfig.profileAutoMinAuthoredTextChars,
|
||||
model = model,
|
||||
retryMax = PluginConfig.profileRetryMax,
|
||||
summaryMaxLength = PluginConfig.profileSummaryMaxLength,
|
||||
onRetryFailure = { message, cause -> JChatGPT.logger.warning(message, cause) },
|
||||
)
|
||||
cursor = cursor.copy(
|
||||
cursorTime = batch.endTime,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
)
|
||||
withContext(Dispatchers.IO) { UserProfileStore.saveGroupCursor(cursor) }
|
||||
|
||||
val usage = report?.usage ?: ProfileTokenUsage()
|
||||
processedBatches++
|
||||
processedMessages += batch.messages.size
|
||||
analyzedUsers += report?.analyzedUsers ?: 0
|
||||
appliedOperations += report?.appliedOperations ?: 0
|
||||
skippedOperations += report?.skippedOperations ?: 0
|
||||
totalUsage += usage
|
||||
caughtUp = cursor.cursorTime >= cursor.snapshotEndTime
|
||||
onProgress(
|
||||
GroupProfileAnalysisProgress(
|
||||
batchIndex = processedBatches,
|
||||
startTime = batch.startTime,
|
||||
endTime = batch.endTime,
|
||||
messageCount = batch.messages.size,
|
||||
analyzedUsers = report?.analyzedUsers ?: 0,
|
||||
appliedOperations = report?.appliedOperations ?: 0,
|
||||
skippedOperations = report?.skippedOperations ?: 0,
|
||||
usage = usage,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return GroupProfileAnalysisReport(
|
||||
botId = cursor.botId,
|
||||
groupId = groupId,
|
||||
processedBatches = processedBatches,
|
||||
processedMessages = processedMessages,
|
||||
analyzedUsers = analyzedUsers,
|
||||
appliedOperations = appliedOperations,
|
||||
skippedOperations = skippedOperations,
|
||||
usage = totalUsage,
|
||||
cursorTime = cursor.cursorTime,
|
||||
snapshotEndTime = cursor.snapshotEndTime,
|
||||
caughtUp = caughtUp,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun analyzeConversation(
|
||||
botId: Long,
|
||||
groupId: Long,
|
||||
@@ -217,6 +412,7 @@ object UserProfileAnalysisService {
|
||||
analyzedUsers = eligibleUserIds.size,
|
||||
processedMessages = batch.messages.size,
|
||||
appliedOperations = reductions.sumOf { it.operations.size },
|
||||
skippedOperations = reductions.sumOf { it.skippedOperations.size },
|
||||
usage = result.usage,
|
||||
)
|
||||
}
|
||||
@@ -281,6 +477,10 @@ object UserProfileAnalysisService {
|
||||
summaryMaxLength = PluginConfig.profileSummaryMaxLength.coerceAtLeast(100),
|
||||
advanceBackfillCursor = advanceBackfillCursor,
|
||||
)
|
||||
logSkippedOperations(
|
||||
"用户 ${batch.userId} 画像批次 [${batch.startTime}, ${batch.endTime})",
|
||||
reduction.skippedOperations,
|
||||
)
|
||||
return result to reduction
|
||||
} catch (cause: Exception) {
|
||||
if (cause is CancellationException) throw cause
|
||||
@@ -298,6 +498,57 @@ object UserProfileAnalysisService {
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun compactWithRetry(
|
||||
model: ProfileCompactionModel,
|
||||
profile: UserProfileSnapshot,
|
||||
supportStats: Map<String, ProfileItemSupportStats>,
|
||||
): Pair<ProfileCompactionModelResult, ProfileCompactionPlan> {
|
||||
val attempts = PluginConfig.profileRetryMax.coerceIn(0, 3) + 1
|
||||
var lastFailure: Throwable? = null
|
||||
repeat(attempts) { attempt ->
|
||||
try {
|
||||
val result = model.compact(profile, supportStats)
|
||||
val plan = UserProfileCompactor.reduce(
|
||||
current = profile,
|
||||
supportStats = supportStats,
|
||||
response = result.response,
|
||||
model = model.modelName,
|
||||
promptVersion = ProfilePromptStore.COMPACTION_PROMPT_VERSION,
|
||||
summaryMaxLength = PluginConfig.profileSummaryMaxLength.coerceAtLeast(100),
|
||||
)
|
||||
return result to plan
|
||||
} catch (cause: Exception) {
|
||||
if (cause is CancellationException) throw cause
|
||||
lastFailure = cause
|
||||
JChatGPT.logger.warning(
|
||||
"用户 ${profile.userId} 画像压缩第 ${attempt + 1}/$attempts 次失败",
|
||||
cause,
|
||||
)
|
||||
}
|
||||
}
|
||||
throw IllegalStateException(
|
||||
"用户 ${profile.userId} 画像压缩连续 $attempts 次失败,未提交任何结果",
|
||||
lastFailure,
|
||||
)
|
||||
}
|
||||
|
||||
private fun compactionBatch(profile: UserProfileSnapshot, rawResponse: String): ProfileHistoryBatch {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest("${profile.userId}|${profile.version}|$rawResponse".toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||
val startTime = profile.items.minOfOrNull(UserProfileItem::firstSeenAt) ?: profile.cursorTime
|
||||
val lastConfirmedAt = profile.items.maxOfOrNull(UserProfileItem::lastConfirmedAt) ?: startTime
|
||||
val endTime = if (lastConfirmedAt == Int.MAX_VALUE) lastConfirmedAt else lastConfirmedAt + 1
|
||||
return ProfileHistoryBatch(
|
||||
userId = profile.userId,
|
||||
startTime = startTime,
|
||||
endTime = endTime,
|
||||
messages = emptyList(),
|
||||
aliases = mapOf(profile.userId to "TARGET"),
|
||||
inputHash = "compact-$digest",
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveHistoryFile(): File {
|
||||
val configured = PluginConfig.profileHistoryDatabasePath.trim()
|
||||
return if (configured.isNotEmpty()) {
|
||||
@@ -315,14 +566,37 @@ object UserProfileAnalysisService {
|
||||
processedBatches = 0,
|
||||
processedMessages = 0,
|
||||
appliedOperations = 0,
|
||||
skippedOperations = 0,
|
||||
usage = ProfileTokenUsage(),
|
||||
profile = null,
|
||||
caughtUp = true,
|
||||
)
|
||||
|
||||
private fun emptyGroupReport(groupId: Long) = GroupProfileAnalysisReport(
|
||||
botId = null,
|
||||
groupId = groupId,
|
||||
processedBatches = 0,
|
||||
processedMessages = 0,
|
||||
analyzedUsers = 0,
|
||||
appliedOperations = 0,
|
||||
skippedOperations = 0,
|
||||
usage = ProfileTokenUsage(),
|
||||
cursorTime = 0,
|
||||
snapshotEndTime = 0,
|
||||
caughtUp = true,
|
||||
)
|
||||
|
||||
private operator fun ProfileTokenUsage.plus(other: ProfileTokenUsage) = ProfileTokenUsage(
|
||||
promptTokens = promptTokens + other.promptTokens,
|
||||
completionTokens = completionTokens + other.completionTokens,
|
||||
cachedTokens = cachedTokens + other.cachedTokens,
|
||||
)
|
||||
|
||||
private fun logSkippedOperations(context: String, skipped: List<String>) {
|
||||
if (skipped.isEmpty()) return
|
||||
JChatGPT.logger.warning(
|
||||
"$context 跳过 ${skipped.size} 项无效建议:" + skipped.take(8).joinToString(";") +
|
||||
if (skipped.size > 8) ";其余 ${skipped.size - 8} 项已省略" else ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package top.jie65535.mirai.profile
|
||||
|
||||
object UserProfileCompactor {
|
||||
fun reduce(
|
||||
current: UserProfileSnapshot,
|
||||
supportStats: Map<String, ProfileItemSupportStats>,
|
||||
response: ProfileCompactionResponse,
|
||||
model: String,
|
||||
promptVersion: String,
|
||||
summaryMaxLength: Int,
|
||||
): ProfileCompactionPlan {
|
||||
val items = current.items.associateBy { it.id }.toMutableMap()
|
||||
val usedItemIds = mutableSetOf<String>()
|
||||
val supportReassignments = mutableMapOf<String, String>()
|
||||
val applied = mutableListOf<AppliedProfileOperation>()
|
||||
val skippedOperations = mutableListOf<String>()
|
||||
var mergedGroups = 0
|
||||
var rewrittenItems = 0
|
||||
var deletedItems = 0
|
||||
|
||||
response.merges.forEachIndexed { index, merge ->
|
||||
try {
|
||||
require(merge.itemRefs.size >= 2) { "至少需要两个 item_refs" }
|
||||
require(merge.itemRefs.distinct().size == merge.itemRefs.size) { "包含重复 item_ref" }
|
||||
val sourceItems = merge.itemRefs.map { ref -> resolve(current, ref, "merges[$index]") }
|
||||
require(sourceItems.none { it.id in usedItemIds }) { "重复操作了画像条目" }
|
||||
val category = sourceItems.first().category
|
||||
val relatedUserId = sourceItems.first().relatedUserId
|
||||
require(sourceItems.all { it.category == category }) { "只能合并同类别条目" }
|
||||
require(sourceItems.all { it.relatedUserId == relatedUserId }) {
|
||||
"只能合并指向同一用户的关系条目"
|
||||
}
|
||||
val target = sourceItems.minWith(compareBy<UserProfileItem>({ it.firstSeenAt }, { it.id }))
|
||||
val content = normalizeAndValidate(
|
||||
merge.content,
|
||||
relationship = category == ProfileCategory.RELATIONSHIP_NOTE,
|
||||
label = "merges[$index]",
|
||||
)
|
||||
val updated = target.copy(
|
||||
content = content,
|
||||
confidence = sourceItems.minBy { it.confidence.ordinal }.confidence,
|
||||
firstSeenAt = sourceItems.minOf { it.firstSeenAt },
|
||||
lastConfirmedAt = sourceItems.maxOf { it.lastConfirmedAt },
|
||||
)
|
||||
usedItemIds += sourceItems.map(UserProfileItem::id)
|
||||
sourceItems.forEach { source -> items.remove(source.id) }
|
||||
items[updated.id] = updated
|
||||
applied += updated.toApplied(ProfileOperationAction.UPDATE)
|
||||
sourceItems.filter { it.id != updated.id }.forEach { source ->
|
||||
supportReassignments[source.id] = updated.id
|
||||
applied += source.toApplied(ProfileOperationAction.DELETE)
|
||||
}
|
||||
mergedGroups++
|
||||
} catch (cause: IllegalArgumentException) {
|
||||
skippedOperations += "merges[$index]: ${cause.message}"
|
||||
}
|
||||
}
|
||||
response.rewrites.forEachIndexed { index, rewrite ->
|
||||
try {
|
||||
val old = resolve(current, rewrite.itemRef, "rewrites[$index]")
|
||||
require(old.id !in usedItemIds) { "重复操作了画像条目" }
|
||||
val content = normalizeAndValidate(
|
||||
rewrite.content,
|
||||
relationship = old.category == ProfileCategory.RELATIONSHIP_NOTE,
|
||||
label = "rewrites[$index]",
|
||||
)
|
||||
if (ProfileContentRules.normalizedKey(content) == ProfileContentRules.normalizedKey(old.content)) {
|
||||
return@forEachIndexed
|
||||
}
|
||||
usedItemIds += old.id
|
||||
val updated = old.copy(
|
||||
content = content,
|
||||
)
|
||||
items[old.id] = updated
|
||||
applied += updated.toApplied(ProfileOperationAction.UPDATE)
|
||||
rewrittenItems++
|
||||
} catch (cause: IllegalArgumentException) {
|
||||
skippedOperations += "rewrites[$index]: ${cause.message}"
|
||||
}
|
||||
}
|
||||
response.deletes.forEachIndexed { index, delete ->
|
||||
try {
|
||||
val old = resolve(current, delete.itemRef, "deletes[$index]")
|
||||
require(old.id !in usedItemIds) { "重复操作了画像条目" }
|
||||
require(old.confidence != ProfileConfidence.HIGH) { "不能自动删除 high 置信条目" }
|
||||
val supports = supportStats[old.id]?.count ?: 0
|
||||
require(supports <= MAX_DELETE_SUPPORTS) {
|
||||
"画像条目已有 $supports 次支持,不能自动删除"
|
||||
}
|
||||
usedItemIds += old.id
|
||||
items.remove(old.id)
|
||||
applied += old.toApplied(ProfileOperationAction.DELETE)
|
||||
deletedItems++
|
||||
} catch (cause: IllegalArgumentException) {
|
||||
skippedOperations += "deletes[$index]: ${cause.message}"
|
||||
}
|
||||
}
|
||||
val summary = if (skippedOperations.isNotEmpty()) {
|
||||
current.summary
|
||||
} else {
|
||||
runCatching {
|
||||
ProfileContentRules.validateSummary(
|
||||
ProfilePersistentText.summaryForDisplay(response.summary),
|
||||
summaryMaxLength,
|
||||
)
|
||||
}.getOrElse { cause ->
|
||||
skippedOperations += "summary: ${cause.message}"
|
||||
current.summary
|
||||
}.ifBlank { current.summary }
|
||||
}
|
||||
val summaryChanged = summary != current.summary
|
||||
val profile = if (applied.isEmpty() && !summaryChanged) current else current.copy(
|
||||
summary = summary,
|
||||
version = current.version + 1,
|
||||
reliable = items.isNotEmpty(),
|
||||
model = model,
|
||||
promptVersion = promptVersion,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
items = items.values.sortedWith(
|
||||
compareBy<UserProfileItem>({ it.category.ordinal }, { it.firstSeenAt }, { it.id })
|
||||
),
|
||||
)
|
||||
return ProfileCompactionPlan(
|
||||
reduction = ProfileReduction(profile, applied),
|
||||
supportReassignments = supportReassignments,
|
||||
mergedGroups = mergedGroups,
|
||||
rewrittenItems = rewrittenItems,
|
||||
deletedItems = deletedItems,
|
||||
skippedOperations = skippedOperations,
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolve(profile: UserProfileSnapshot, reference: String, label: String): UserProfileItem =
|
||||
ProfileItemReferences.resolve(profile, reference)
|
||||
?: throw IllegalArgumentException("$label 指向不存在的画像条目 $reference")
|
||||
|
||||
private fun normalizeAndValidate(raw: String, relationship: Boolean, label: String): String =
|
||||
ProfileContentRules.validate(
|
||||
ProfilePersistentText.itemForDisplay(raw, relationship),
|
||||
label,
|
||||
)
|
||||
|
||||
private fun UserProfileItem.toApplied(action: ProfileOperationAction) = AppliedProfileOperation(
|
||||
action = action,
|
||||
itemId = id,
|
||||
category = category,
|
||||
content = content,
|
||||
confidence = confidence,
|
||||
relatedUserId = relatedUserId,
|
||||
evidenceRefs = emptyList(),
|
||||
)
|
||||
|
||||
private const val MAX_DELETE_SUPPORTS = 1
|
||||
}
|
||||
@@ -11,7 +11,9 @@ object UserProfileContextRenderer {
|
||||
summaryMaxChars: Int,
|
||||
): String {
|
||||
val profilesByUserId = profiles
|
||||
.filter { it.reliable && it.summary.isNotBlank() }
|
||||
.filter { profile ->
|
||||
profile.reliable && ProfilePersistentText.summaryForDisplay(profile.summary).isNotBlank()
|
||||
}
|
||||
.associateBy { it.userId }
|
||||
val userIds = activeUserIds.filter { userId ->
|
||||
userId in profilesByUserId || favorabilityByUserId[userId]?.hasVisibleContext() == true
|
||||
@@ -21,7 +23,7 @@ object UserProfileContextRenderer {
|
||||
val maxChars = summaryMaxChars.coerceAtLeast(50)
|
||||
return buildString {
|
||||
appendLine("## 你对相关群友的认识")
|
||||
appendLine("好感度、代号和主观印象代表你的关系状态;长期认识来自可修正的历史归纳。仅在当前话题相关时自然运用,不要逐条复述或提及信息来源。")
|
||||
appendLine("好感度、代号和主观印象代表你的关系状态;画像认识来自可修正的历史归纳。仅在当前话题相关时自然运用,不要逐条复述或提及信息来源。")
|
||||
userIds.forEach { userId ->
|
||||
val profile = profilesByUserId[userId]
|
||||
val favorability = favorabilityByUserId[userId]
|
||||
@@ -34,8 +36,10 @@ object UserProfileContextRenderer {
|
||||
if (info.tags.isNotEmpty()) append(" | 标签:").append(info.tags.joinToString("、"))
|
||||
if (info.impression.isNotBlank()) append(" | 主观印象:").append(info.impression.normalized())
|
||||
}
|
||||
profile?.let {
|
||||
append(" | 长期认识:").append(it.summary.normalized().take(maxChars))
|
||||
profile?.summary?.let(ProfilePersistentText::summaryForDisplay)
|
||||
?.takeIf(String::isNotBlank)?.let { summary ->
|
||||
append(" | 画像认识:")
|
||||
.append(summary.normalized().take(maxChars))
|
||||
}
|
||||
|
||||
profile?.items?.asSequence()
|
||||
@@ -46,8 +50,11 @@ object UserProfileContextRenderer {
|
||||
?.take(2)
|
||||
?.forEach { item ->
|
||||
val relatedId = checkNotNull(item.relatedUserId)
|
||||
val relatedName = displayNames[relatedId].orEmpty().ifBlank { relatedId.toString() }
|
||||
append(";与").append(relatedName).append(":").append(item.content.normalized())
|
||||
val relatedName = favorabilityByUserId[relatedId]?.name.orEmpty().ifBlank {
|
||||
displayNames[relatedId].orEmpty().ifBlank { relatedId.toString() }
|
||||
}
|
||||
append(";与").append(relatedName).append(":")
|
||||
.append(ProfilePersistentText.itemForDisplay(item.content, relationship = true).normalized())
|
||||
}
|
||||
appendLine()
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ enum class ProfileOperationAction {
|
||||
enum class ProfileRevisionSource {
|
||||
BACKFILL,
|
||||
CONVERSATION,
|
||||
COMPACTION,
|
||||
}
|
||||
|
||||
data class UserProfileItem(
|
||||
@@ -82,14 +83,15 @@ data class UserProfileSnapshot(
|
||||
@Serializable
|
||||
data class ProfileModelOperation(
|
||||
val action: ProfileOperationAction,
|
||||
@SerialName("item_id")
|
||||
val itemId: String? = null,
|
||||
@SerialName("item_ref")
|
||||
val itemRef: String? = null,
|
||||
val category: ProfileCategory? = null,
|
||||
val content: String? = null,
|
||||
val confidence: ProfileConfidence? = null,
|
||||
@SerialName("related_user_alias")
|
||||
val relatedUserAlias: String? = null,
|
||||
@SerialName("evidence_refs")
|
||||
@Serializable(with = EvidenceReferenceListSerializer::class)
|
||||
val evidenceRefs: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -202,6 +204,7 @@ data class AppliedProfileOperation(
|
||||
data class ProfileReduction(
|
||||
val profile: UserProfileSnapshot,
|
||||
val operations: List<AppliedProfileOperation>,
|
||||
val skippedOperations: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class ProfileAnalysisProgress(
|
||||
@@ -210,6 +213,7 @@ data class ProfileAnalysisProgress(
|
||||
val endTime: Int,
|
||||
val messageCount: Int,
|
||||
val operationCount: Int,
|
||||
val skippedOperationCount: Int,
|
||||
val usage: ProfileTokenUsage,
|
||||
)
|
||||
|
||||
@@ -217,14 +221,50 @@ data class ConversationProfileAnalysisReport(
|
||||
val analyzedUsers: Int,
|
||||
val processedMessages: Int,
|
||||
val appliedOperations: Int,
|
||||
val skippedOperations: Int,
|
||||
val usage: ProfileTokenUsage,
|
||||
)
|
||||
|
||||
data class GroupProfileCursor(
|
||||
val botId: Long,
|
||||
val groupId: Long,
|
||||
val cursorTime: Int,
|
||||
val snapshotEndTime: Int,
|
||||
val updatedAt: Long = 0,
|
||||
)
|
||||
|
||||
data class GroupProfileAnalysisProgress(
|
||||
val batchIndex: Int,
|
||||
val startTime: Int,
|
||||
val endTime: Int,
|
||||
val messageCount: Int,
|
||||
val analyzedUsers: Int,
|
||||
val appliedOperations: Int,
|
||||
val skippedOperations: Int,
|
||||
val usage: ProfileTokenUsage,
|
||||
)
|
||||
|
||||
data class GroupProfileAnalysisReport(
|
||||
val botId: Long?,
|
||||
val groupId: Long,
|
||||
val processedBatches: Int,
|
||||
val processedMessages: Int,
|
||||
val analyzedUsers: Int,
|
||||
val appliedOperations: Int,
|
||||
val skippedOperations: Int,
|
||||
val usage: ProfileTokenUsage,
|
||||
val cursorTime: Int,
|
||||
val snapshotEndTime: Int,
|
||||
val caughtUp: Boolean,
|
||||
val alreadyRunning: Boolean = false,
|
||||
)
|
||||
|
||||
data class ProfileAnalysisReport(
|
||||
val userId: Long,
|
||||
val processedBatches: Int,
|
||||
val processedMessages: Int,
|
||||
val appliedOperations: Int,
|
||||
val skippedOperations: Int,
|
||||
val usage: ProfileTokenUsage,
|
||||
val profile: UserProfileSnapshot?,
|
||||
val caughtUp: Boolean,
|
||||
|
||||
@@ -3,8 +3,6 @@ package top.jie65535.mirai.profile
|
||||
import java.util.UUID
|
||||
|
||||
object UserProfileReducer {
|
||||
private val overclaimPattern = Regex("深厚|扎实|精通|专家|导师|领袖|天才|极强|全栈|核心成员|公认")
|
||||
|
||||
fun reduce(
|
||||
current: UserProfileSnapshot,
|
||||
batch: ProfileHistoryBatch,
|
||||
@@ -14,108 +12,34 @@ object UserProfileReducer {
|
||||
summaryMaxLength: Int,
|
||||
advanceBackfillCursor: Boolean = true,
|
||||
): ProfileReduction {
|
||||
require(response.summary.length <= summaryMaxLength) {
|
||||
"画像摘要超过 ${summaryMaxLength} 字符"
|
||||
}
|
||||
|
||||
val items = current.items.associateBy { it.id }.toMutableMap()
|
||||
val applied = mutableListOf<AppliedProfileOperation>()
|
||||
val skipped = mutableListOf<String>()
|
||||
|
||||
response.operations.forEachIndexed { index, operation ->
|
||||
val evidence = operation.evidenceRefs.distinct().map { ref ->
|
||||
batch.evidenceByRef[ref]
|
||||
?: throw IllegalArgumentException("operations[$index] 引用了不存在的证据 e:$ref")
|
||||
}
|
||||
require(evidence.isNotEmpty()) { "operations[$index] 缺少证据" }
|
||||
require(evidence.any { it.record.fromId == batch.userId }) {
|
||||
"operations[$index] 没有目标用户自己的发言证据"
|
||||
}
|
||||
|
||||
val evidenceTime = evidence
|
||||
.asSequence()
|
||||
.filter { it.record.fromId == batch.userId }
|
||||
.maxOf { it.record.time }
|
||||
val firstEvidenceTime = evidence
|
||||
.asSequence()
|
||||
.filter { it.record.fromId == batch.userId }
|
||||
.minOf { it.record.time }
|
||||
|
||||
when (operation.action) {
|
||||
ProfileOperationAction.ADD -> {
|
||||
require(operation.itemId.isNullOrBlank()) {
|
||||
"operations[$index] ADD 不能指定 item_id"
|
||||
}
|
||||
val category = requireNotNull(operation.category) {
|
||||
"operations[$index] ADD 缺少 category"
|
||||
}
|
||||
val content = validateContent(index, operation.content)
|
||||
val confidence = requireNotNull(operation.confidence) {
|
||||
"operations[$index] ADD 缺少 confidence"
|
||||
}
|
||||
val relatedUserId = resolveRelatedUser(index, category, operation.relatedUserAlias, batch)
|
||||
val duplicate = items.values.any {
|
||||
it.category == category && normalize(it.content) == normalize(content)
|
||||
}
|
||||
if (duplicate) return@forEachIndexed
|
||||
|
||||
val item = UserProfileItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
category = category,
|
||||
content = content,
|
||||
confidence = confidence,
|
||||
relatedUserId = relatedUserId,
|
||||
firstSeenAt = firstEvidenceTime,
|
||||
lastConfirmedAt = evidenceTime,
|
||||
)
|
||||
items[item.id] = item
|
||||
applied += item.toApplied(operation.action, operation.evidenceRefs)
|
||||
}
|
||||
|
||||
ProfileOperationAction.UPDATE -> {
|
||||
val old = requireExistingItem(index, operation, items)
|
||||
val category = operation.category ?: old.category
|
||||
val content = validateContent(index, operation.content)
|
||||
val confidence = operation.confidence ?: old.confidence
|
||||
val relatedUserId = resolveRelatedUser(
|
||||
index,
|
||||
category,
|
||||
operation.relatedUserAlias,
|
||||
batch,
|
||||
fallback = old.relatedUserId,
|
||||
)
|
||||
val updated = old.copy(
|
||||
category = category,
|
||||
content = content,
|
||||
confidence = confidence,
|
||||
relatedUserId = relatedUserId,
|
||||
lastConfirmedAt = evidenceTime,
|
||||
)
|
||||
items[old.id] = updated
|
||||
applied += updated.toApplied(operation.action, operation.evidenceRefs)
|
||||
}
|
||||
|
||||
ProfileOperationAction.CONFIRM -> {
|
||||
val old = requireExistingItem(index, operation, items)
|
||||
val updated = old.copy(
|
||||
confidence = operation.confidence ?: old.confidence,
|
||||
lastConfirmedAt = evidenceTime,
|
||||
)
|
||||
items[old.id] = updated
|
||||
applied += updated.toApplied(operation.action, operation.evidenceRefs)
|
||||
}
|
||||
|
||||
ProfileOperationAction.DELETE -> {
|
||||
val old = requireExistingItem(index, operation, items)
|
||||
items.remove(old.id)
|
||||
applied += old.toApplied(operation.action, operation.evidenceRefs)
|
||||
}
|
||||
try {
|
||||
applyOperation(index, operation, current, batch, items)?.let(applied::add)
|
||||
} catch (cause: IllegalArgumentException) {
|
||||
skipped += cause.message ?: "operations[$index] 不符合画像协议"
|
||||
}
|
||||
}
|
||||
|
||||
val summary = if (applied.isEmpty()) {
|
||||
val hasSummaryRelevantChange = applied.any { it.action != ProfileOperationAction.CONFIRM }
|
||||
val summary = if (
|
||||
applied.isEmpty() || skipped.isNotEmpty() ||
|
||||
(!hasSummaryRelevantChange && current.summary.isNotBlank())
|
||||
) {
|
||||
current.summary
|
||||
} else {
|
||||
response.summary.trim().ifEmpty { current.summary }
|
||||
try {
|
||||
ProfileContentRules.validateSummary(
|
||||
ProfilePersistentText.normalizeSummary(response.summary, batch),
|
||||
summaryMaxLength,
|
||||
).ifEmpty { current.summary }
|
||||
} catch (cause: IllegalArgumentException) {
|
||||
skipped += "summary: ${cause.message}"
|
||||
current.summary
|
||||
}
|
||||
}
|
||||
val changed = applied.isNotEmpty() || summary != current.summary
|
||||
val profile = current.copy(
|
||||
@@ -130,28 +54,129 @@ object UserProfileReducer {
|
||||
compareBy<UserProfileItem>({ it.category.ordinal }, { it.firstSeenAt }, { it.id })
|
||||
),
|
||||
)
|
||||
return ProfileReduction(profile, applied)
|
||||
return ProfileReduction(profile, applied, skipped)
|
||||
}
|
||||
|
||||
private fun validateContent(index: Int, raw: String?): String {
|
||||
val content = raw?.trim().orEmpty()
|
||||
require(content.isNotEmpty()) { "operations[$index] 缺少 content" }
|
||||
require(content.length <= 120) { "operations[$index].content 超过 120 字符" }
|
||||
require(!overclaimPattern.containsMatchIn(content)) {
|
||||
"operations[$index].content 包含夸张身份或能力判断"
|
||||
private fun applyOperation(
|
||||
index: Int,
|
||||
operation: ProfileModelOperation,
|
||||
current: UserProfileSnapshot,
|
||||
batch: ProfileHistoryBatch,
|
||||
items: MutableMap<String, UserProfileItem>,
|
||||
): AppliedProfileOperation? {
|
||||
val evidence = operation.evidenceRefs.distinct().map { ref ->
|
||||
batch.evidenceByRef[ref]
|
||||
?: throw IllegalArgumentException("operations[$index] 引用了不存在的证据 e:$ref")
|
||||
}
|
||||
return content
|
||||
require(evidence.isNotEmpty()) { "operations[$index] 缺少证据" }
|
||||
require(evidence.any { it.record.fromId == batch.userId }) {
|
||||
"operations[$index] 没有目标用户自己的发言证据"
|
||||
}
|
||||
|
||||
val targetEvidence = evidence.filter { it.record.fromId == batch.userId }
|
||||
val evidenceTime = targetEvidence.maxOf { it.record.time }
|
||||
val firstEvidenceTime = targetEvidence.minOf { it.record.time }
|
||||
|
||||
return when (operation.action) {
|
||||
ProfileOperationAction.ADD -> {
|
||||
require(operation.itemRef.isNullOrBlank()) {
|
||||
"operations[$index] ADD 不能指定 item_ref"
|
||||
}
|
||||
val category = requireNotNull(operation.category) {
|
||||
"operations[$index] ADD 缺少 category"
|
||||
}
|
||||
val confidence = requireNotNull(operation.confidence) {
|
||||
"operations[$index] ADD 缺少 confidence"
|
||||
}
|
||||
val relatedUserId = resolveRelatedUser(index, category, operation.relatedUserAlias, batch)
|
||||
val content = validateContent(index, operation.content, batch, relatedUserId)
|
||||
val duplicate = items.values.any {
|
||||
it.category == category &&
|
||||
ProfileContentRules.normalizedKey(it.content) == ProfileContentRules.normalizedKey(content)
|
||||
}
|
||||
if (duplicate) return null
|
||||
|
||||
val item = UserProfileItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
category = category,
|
||||
content = content,
|
||||
confidence = confidence,
|
||||
relatedUserId = relatedUserId,
|
||||
firstSeenAt = firstEvidenceTime,
|
||||
lastConfirmedAt = evidenceTime,
|
||||
)
|
||||
items[item.id] = item
|
||||
item.toApplied(operation.action, operation.evidenceRefs)
|
||||
}
|
||||
|
||||
ProfileOperationAction.UPDATE -> {
|
||||
val old = requireExistingItem(index, operation, current, items)
|
||||
val category = operation.category ?: old.category
|
||||
val confidence = operation.confidence ?: old.confidence
|
||||
val relatedUserId = resolveRelatedUser(
|
||||
index,
|
||||
category,
|
||||
operation.relatedUserAlias,
|
||||
batch,
|
||||
fallback = old.relatedUserId,
|
||||
)
|
||||
val content = validateContent(index, operation.content, batch, relatedUserId)
|
||||
val updated = old.copy(
|
||||
category = category,
|
||||
content = content,
|
||||
confidence = confidence,
|
||||
relatedUserId = relatedUserId,
|
||||
lastConfirmedAt = evidenceTime,
|
||||
)
|
||||
items[old.id] = updated
|
||||
updated.toApplied(operation.action, operation.evidenceRefs)
|
||||
}
|
||||
|
||||
ProfileOperationAction.CONFIRM -> {
|
||||
val old = requireExistingItem(index, operation, current, items)
|
||||
val updated = old.copy(
|
||||
confidence = operation.confidence ?: old.confidence,
|
||||
lastConfirmedAt = evidenceTime,
|
||||
)
|
||||
items[old.id] = updated
|
||||
updated.toApplied(operation.action, operation.evidenceRefs)
|
||||
}
|
||||
|
||||
ProfileOperationAction.DELETE -> {
|
||||
val old = requireExistingItem(index, operation, current, items)
|
||||
items.remove(old.id)
|
||||
old.toApplied(operation.action, operation.evidenceRefs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateContent(
|
||||
index: Int,
|
||||
raw: String?,
|
||||
batch: ProfileHistoryBatch,
|
||||
relatedUserId: Long?,
|
||||
): String {
|
||||
return ProfileContentRules.validate(
|
||||
ProfilePersistentText.normalizeItemContent(raw.orEmpty(), batch, relatedUserId),
|
||||
"operations[$index]",
|
||||
)
|
||||
}
|
||||
|
||||
private fun requireExistingItem(
|
||||
index: Int,
|
||||
operation: ProfileModelOperation,
|
||||
referenceProfile: UserProfileSnapshot,
|
||||
items: Map<String, UserProfileItem>,
|
||||
): UserProfileItem {
|
||||
val itemId = operation.itemId?.takeIf { it.isNotBlank() }
|
||||
?: throw IllegalArgumentException("operations[$index] ${operation.action} 缺少 item_id")
|
||||
return items[itemId]
|
||||
?: throw IllegalArgumentException("operations[$index] 指向不存在的画像条目 $itemId")
|
||||
val itemRef = operation.itemRef?.takeIf { it.isNotBlank() }
|
||||
val referencedItem = ProfileItemReferences.resolve(referenceProfile, itemRef)
|
||||
?: throw IllegalArgumentException(
|
||||
"operations[$index] ${operation.action} 指向不存在的画像条目 ${itemRef ?: "(缺少 item_ref)"}"
|
||||
)
|
||||
return items[referencedItem.id]
|
||||
?: throw IllegalArgumentException(
|
||||
"operations[$index] ${operation.action} 重复操作了画像条目 $itemRef"
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveRelatedUser(
|
||||
@@ -186,7 +211,4 @@ object UserProfileReducer {
|
||||
evidenceRefs = evidenceRefs.distinct(),
|
||||
)
|
||||
|
||||
private fun normalize(value: String): String = value
|
||||
.lowercase()
|
||||
.replace(Regex("[\\s\\p{Punct},。;、!?()【】‘’“”]+"), "")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import java.sql.DriverManager
|
||||
import java.sql.ResultSet
|
||||
|
||||
object UserProfileStore {
|
||||
private const val SCHEMA_VERSION = 2
|
||||
private const val SCHEMA_VERSION = 3
|
||||
private const val BUSY_TIMEOUT_MS = 30_000
|
||||
|
||||
private val lifecycleLock = Any()
|
||||
@@ -98,6 +98,38 @@ object UserProfileStore {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSupportStats(userId: Long): Map<String, ProfileItemSupportStats> {
|
||||
check(initialized) { "用户画像数据库尚未初始化" }
|
||||
return openReadConnection().use { connection ->
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
SELECT item_id, COUNT(*) AS support_count,
|
||||
MIN(start_time) AS first_supported_at,
|
||||
MAX(end_time) AS last_supported_at
|
||||
FROM profile_support
|
||||
WHERE user_id = ?
|
||||
GROUP BY item_id
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setLong(1, userId)
|
||||
statement.executeQuery().use { results ->
|
||||
buildMap {
|
||||
while (results.next()) {
|
||||
put(
|
||||
results.getString("item_id"),
|
||||
ProfileItemSupportStats(
|
||||
count = results.getInt("support_count"),
|
||||
firstSupportedAt = results.getInt("first_supported_at"),
|
||||
lastSupportedAt = results.getInt("last_supported_at"),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun commit(
|
||||
reduction: ProfileReduction,
|
||||
batch: ProfileHistoryBatch,
|
||||
@@ -110,6 +142,17 @@ object UserProfileStore {
|
||||
usage: ProfileTokenUsage,
|
||||
) = commitAll(reductions, usage, ProfileRevisionSource.CONVERSATION)
|
||||
|
||||
fun commitCompaction(
|
||||
plan: ProfileCompactionPlan,
|
||||
batch: ProfileHistoryBatch,
|
||||
usage: ProfileTokenUsage,
|
||||
) = commitAll(
|
||||
entries = listOf(plan.reduction to batch),
|
||||
usage = usage,
|
||||
source = ProfileRevisionSource.COMPACTION,
|
||||
supportReassignments = plan.supportReassignments,
|
||||
)
|
||||
|
||||
fun isConversationProcessed(inputHash: String): Boolean {
|
||||
check(initialized) { "用户画像数据库尚未初始化" }
|
||||
return openReadConnection().use { connection ->
|
||||
@@ -122,10 +165,61 @@ object UserProfileStore {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadGroupCursor(botId: Long, groupId: Long): GroupProfileCursor? {
|
||||
check(initialized) { "用户画像数据库尚未初始化" }
|
||||
return openReadConnection().use { connection ->
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
SELECT bot_id, group_id, cursor_time, snapshot_end_time, updated_at
|
||||
FROM profile_group_cursor
|
||||
WHERE bot_id = ? AND group_id = ?
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setLong(1, botId)
|
||||
statement.setLong(2, groupId)
|
||||
statement.executeQuery().use { results ->
|
||||
if (!results.next()) return@use null
|
||||
GroupProfileCursor(
|
||||
botId = results.getLong("bot_id"),
|
||||
groupId = results.getLong("group_id"),
|
||||
cursorTime = results.getInt("cursor_time"),
|
||||
snapshotEndTime = results.getInt("snapshot_end_time"),
|
||||
updatedAt = results.getLong("updated_at"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveGroupCursor(cursor: GroupProfileCursor) {
|
||||
check(initialized) { "用户画像数据库尚未初始化" }
|
||||
withWriteConnection { connection ->
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
INSERT INTO profile_group_cursor(
|
||||
bot_id, group_id, cursor_time, snapshot_end_time, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(bot_id, group_id) DO UPDATE SET
|
||||
cursor_time = excluded.cursor_time,
|
||||
snapshot_end_time = excluded.snapshot_end_time,
|
||||
updated_at = excluded.updated_at
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
statement.setLong(1, cursor.botId)
|
||||
statement.setLong(2, cursor.groupId)
|
||||
statement.setInt(3, cursor.cursorTime)
|
||||
statement.setInt(4, cursor.snapshotEndTime)
|
||||
statement.setLong(5, cursor.updatedAt)
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun commitAll(
|
||||
entries: List<Pair<ProfileReduction, ProfileHistoryBatch>>,
|
||||
usage: ProfileTokenUsage,
|
||||
source: ProfileRevisionSource,
|
||||
supportReassignments: Map<String, String> = emptyMap(),
|
||||
) {
|
||||
check(initialized) { "用户画像数据库尚未初始化" }
|
||||
if (entries.isEmpty()) return
|
||||
@@ -142,6 +236,20 @@ object UserProfileStore {
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
if (supportReassignments.isNotEmpty()) {
|
||||
val userId = entries.single().first.profile.userId
|
||||
connection.prepareStatement(
|
||||
"UPDATE profile_support SET item_id = ? WHERE user_id = ? AND item_id = ?"
|
||||
).use { statement ->
|
||||
supportReassignments.forEach { (sourceItemId, targetItemId) ->
|
||||
statement.setString(1, targetItemId)
|
||||
statement.setLong(2, userId)
|
||||
statement.setString(3, sourceItemId)
|
||||
statement.addBatch()
|
||||
}
|
||||
statement.executeBatch()
|
||||
}
|
||||
}
|
||||
connection.commit()
|
||||
} catch (cause: Throwable) {
|
||||
connection.rollback()
|
||||
@@ -216,30 +324,32 @@ object UserProfileStore {
|
||||
statement.executeBatch()
|
||||
}
|
||||
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
INSERT INTO profile_support(
|
||||
item_id, user_id, group_ids, start_time, end_time,
|
||||
input_hash, action, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
reduction.operations.forEach { operation ->
|
||||
val evidence = operation.evidenceRefs.mapNotNull(batch.evidenceByRef::get)
|
||||
val startTime = evidence.minOf { it.record.time }
|
||||
val endTime = evidence.maxOf { it.record.time }.safeNextSecond()
|
||||
val groupIds = evidence.map { it.record.targetId }.distinct().sorted().joinToString(",")
|
||||
statement.setString(1, operation.itemId)
|
||||
statement.setLong(2, profile.userId)
|
||||
statement.setString(3, groupIds)
|
||||
statement.setInt(4, startTime)
|
||||
statement.setInt(5, endTime)
|
||||
statement.setString(6, batch.inputHash)
|
||||
statement.setString(7, operation.action.name)
|
||||
statement.setLong(8, System.currentTimeMillis())
|
||||
statement.addBatch()
|
||||
if (source != ProfileRevisionSource.COMPACTION) {
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
INSERT INTO profile_support(
|
||||
item_id, user_id, group_ids, start_time, end_time,
|
||||
input_hash, action, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
).use { statement ->
|
||||
reduction.operations.forEach { operation ->
|
||||
val evidence = operation.evidenceRefs.mapNotNull(batch.evidenceByRef::get)
|
||||
val startTime = evidence.minOf { it.record.time }
|
||||
val endTime = evidence.maxOf { it.record.time }.safeNextSecond()
|
||||
val groupIds = evidence.map { it.record.targetId }.distinct().sorted().joinToString(",")
|
||||
statement.setString(1, operation.itemId)
|
||||
statement.setLong(2, profile.userId)
|
||||
statement.setString(3, groupIds)
|
||||
statement.setInt(4, startTime)
|
||||
statement.setInt(5, endTime)
|
||||
statement.setString(6, batch.inputHash)
|
||||
statement.setString(7, operation.action.name)
|
||||
statement.setLong(8, System.currentTimeMillis())
|
||||
statement.addBatch()
|
||||
}
|
||||
statement.executeBatch()
|
||||
}
|
||||
statement.executeBatch()
|
||||
}
|
||||
|
||||
connection.prepareStatement(
|
||||
@@ -376,6 +486,18 @@ object UserProfileStore {
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
statement.executeUpdate(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS profile_group_cursor(
|
||||
bot_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
cursor_time INTEGER NOT NULL,
|
||||
snapshot_end_time INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(bot_id, group_id)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
connection.prepareStatement(
|
||||
"INSERT INTO user_profile_meta(key, value) VALUES ('schema_version', ?) " +
|
||||
|
||||
Reference in New Issue
Block a user