profile: harden batch analysis and compaction

This commit is contained in:
2026-08-03 02:53:59 +08:00
parent aa67305d80
commit f102264a55
28 changed files with 2232 additions and 346 deletions
-2
View File
@@ -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)
+110 -8
View File
@@ -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())
-6
View File
@@ -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*)")
}
+56 -7
View File
@@ -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},。;:!?()])"
)
}
+110 -62
View File
@@ -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_idUPDATE、CONFIRM、DELETE 必须填写当前画像中的 item_id
9. relationship_note 必须填写本批存在的 related_user_alias,只描述互动方式,不推断现实亲疏。
8. ADD 不填写 item_refUPDATE、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_idUPDATE、CONFIRM、DELETE 必须填写该用户当前画像中的 item_id,不能引用其他用户的条目。
9. relationship_note 必须填写本批存在的 related_user_alias,只描述互动方式,不推断现实亲疏。
8. ADD 不填写 item_refUPDATE、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()
}
+42 -2
View File
@@ -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,
+132 -110
View File
@@ -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},。;、!?()【】‘’“”]+"), "")
}
+146 -24
View File
@@ -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', ?) " +
@@ -4,7 +4,7 @@ import net.mamoe.mirai.message.data.MessageSourceKind
import top.jie65535.mirai.data.ChatMessageRecord
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ConversationProfileReducerTest {
@@ -59,36 +59,36 @@ class ConversationProfileReducerTest {
}
@Test
fun rejectsEvidenceAuthoredByAnotherUser() {
val failure = assertFailsWith<IllegalArgumentException> {
ConversationProfileReducer.reduce(
profiles = USERS.associateWith(::emptyProfile),
batch = batch(),
eligibleUserIds = USERS,
response = ConversationProfileModelResponse(
users = listOf(
ConversationProfileUserResponse(
userAlias = "U1",
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.NOTABLE_FACT,
content = "关注本地大模型",
confidence = ProfileConfidence.MEDIUM,
evidenceRefs = listOf(2),
)
),
summary = "关注本地大模型。",
)
fun skipsEvidenceAuthoredByAnotherUser() {
val reductions = ConversationProfileReducer.reduce(
profiles = USERS.associateWith(::emptyProfile),
batch = batch(),
eligibleUserIds = USERS,
response = ConversationProfileModelResponse(
users = listOf(
ConversationProfileUserResponse(
userAlias = "U1",
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.NOTABLE_FACT,
content = "关注本地大模型",
confidence = ProfileConfidence.MEDIUM,
evidenceRefs = listOf(2),
)
),
summary = "关注本地大模型。",
)
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
}
)
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
assertTrue(failure.message.orEmpty().contains("目标用户"))
val userReduction = reductions.single { it.profile.userId == USER_A }
assertTrue(userReduction.operations.isEmpty())
assertTrue(userReduction.skippedOperations.single().contains("目标用户"))
}
@Test
@@ -120,12 +120,12 @@ class ConversationProfileReducerTest {
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.CONFIRM,
itemId = oldItem.id,
itemRef = "P1",
confidence = ProfileConfidence.MEDIUM,
evidenceRefs = listOf(1),
)
),
summary = "关注 Kotlin 开发",
summary = "本批再次提到 Kotlin。",
)
)
),
@@ -138,14 +138,14 @@ class ConversationProfileReducerTest {
assertEquals(2, updated.version)
assertEquals("existing-item", updated.items.single().id)
assertEquals(ProfileConfidence.MEDIUM, updated.items.single().confidence)
assertTrue(
ProfilePromptStore.buildConversationUserPrompt(profiles, batch(), USERS)
.contains("[P:existing-item]")
)
assertEquals(existing.summary, updated.summary)
val prompt = ProfilePromptStore.buildConversationUserPrompt(profiles, batch(), USERS)
assertTrue(prompt.contains("[P1]"))
assertFalse(prompt.contains(oldItem.id))
}
@Test
fun rejectsMoreThanFourOperationsForOneUser() {
fun acceptsMoreThanFourOperationsForOneUser() {
val operations = (1..5).map { index ->
ProfileModelOperation(
action = ProfileOperationAction.ADD,
@@ -156,27 +156,126 @@ class ConversationProfileReducerTest {
)
}
val failure = assertFailsWith<IllegalArgumentException> {
ConversationProfileReducer.reduce(
profiles = USERS.associateWith(::emptyProfile),
batch = batch(),
eligibleUserIds = USERS,
response = ConversationProfileModelResponse(
users = listOf(
ConversationProfileUserResponse(
userAlias = "U1",
operations = operations,
summary = "包含过多事实。",
)
val reductions = ConversationProfileReducer.reduce(
profiles = USERS.associateWith(::emptyProfile),
batch = batch(),
eligibleUserIds = USERS,
response = ConversationProfileModelResponse(
users = listOf(
ConversationProfileUserResponse(
userAlias = "U1",
operations = operations,
summary = "包含多项事实。",
)
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
}
)
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
assertTrue(failure.message.orEmpty().contains("超过 4 项"))
assertEquals(5, reductions.single { it.profile.userId == USER_A }.operations.size)
}
@Test
fun mergesDuplicateUserResultGroupsAndDeduplicatesOperations() {
val operation = ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.NOTABLE_FACT,
content = "日常使用 Kotlin 开发",
confidence = ProfileConfidence.MEDIUM,
evidenceRefs = listOf(1),
)
val reductions = ConversationProfileReducer.reduce(
profiles = USERS.associateWith(::emptyProfile),
batch = batch(),
eligibleUserIds = USERS,
response = ConversationProfileModelResponse(
users = listOf(
ConversationProfileUserResponse(
userAlias = "U1",
operations = listOf(operation),
summary = "使用 Kotlin。",
),
ConversationProfileUserResponse(
userAlias = "U1",
operations = listOf(
operation,
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.PREFERENCE,
content = "偏好 Kotlin",
confidence = ProfileConfidence.LOW,
evidenceRefs = listOf(1),
),
),
summary = "使用并偏好 Kotlin。",
),
)
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
val reduction = reductions.single { it.profile.userId == USER_A }
assertEquals(2, reduction.operations.size)
assertEquals(2, reduction.profile.items.size)
assertEquals("使用并偏好 Kotlin。", reduction.profile.summary)
}
@Test
fun ignoresModelResultsForNonCandidateContextUsers() {
val contextUser = 400L
val batch = batch().copy(aliases = batch().aliases + (contextUser to "U4"))
val reductions = ConversationProfileReducer.reduce(
profiles = USERS.associateWith(::emptyProfile),
batch = batch,
eligibleUserIds = USERS,
response = ConversationProfileModelResponse(
users = listOf(
ConversationProfileUserResponse(
userAlias = "U4",
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.INTEREST,
content = "关注 Kotlin",
confidence = ProfileConfidence.LOW,
evidenceRefs = listOf(1),
)
),
summary = "关注 Kotlin。",
),
ConversationProfileUserResponse(
userAlias = "U1",
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.NOTABLE_FACT,
content = "日常使用 Kotlin 开发",
confidence = ProfileConfidence.MEDIUM,
evidenceRefs = listOf(1),
)
),
summary = "日常使用 Kotlin 开发。",
),
)
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
assertEquals(1, reductions.single { it.profile.userId == USER_A }.operations.size)
assertTrue(reductions.single { it.profile.userId == USER_B }.operations.isEmpty())
val prompt = ProfilePromptStore.buildConversationUserPrompt(
profiles = USERS.associateWith(::emptyProfile),
batch = batch,
eligibleUserIds = USERS,
)
assertTrue(prompt.contains("候选用户别名: U1, U2"))
}
private fun batch() = ConversationProfileBatch(
@@ -49,6 +49,7 @@ class ProfileHistoryReaderTest {
}
insert(TARGET, 10, 100, "目标发言一")
insert(OTHER, 10, 110, "用于理解语境的回复")
insert(OTHER, 10, 110, "同一秒的补充回复")
insert(TARGET, 10, 130, "目标发言二")
insert(TARGET, 20, 130, "同一秒的另一群发言")
insert(OTHER, 20, 140, "后续上下文")
@@ -110,6 +111,35 @@ class ProfileHistoryReaderTest {
assertTrue(conversation.authoredTextCharsByUser.getValue(TARGET) > 0)
assertTrue(conversation.messages.all { it.record.targetId == 10L })
assertTrue(conversation.messages.all { it.record.time in 90 until 150 })
val groupBounds = assertNotNull(reader.findGroupTimeBounds(10))
assertEquals(1, groupBounds.botId)
assertEquals(100, groupBounds.startTime)
assertEquals(201, groupBounds.endTime)
val firstGroupBatch = assertNotNull(
reader.loadNextConversationBatch(
botId = groupBounds.botId,
groupId = 10,
startTime = groupBounds.startTime,
snapshotEndTime = groupBounds.endTime,
messageLimit = 2,
maxMessageChars = 200,
)
)
assertEquals(111, firstGroupBatch.endTime)
assertEquals(listOf(100, 110, 110), firstGroupBatch.messages.map { it.record.time })
val secondGroupBatch = assertNotNull(
reader.loadNextConversationBatch(
botId = groupBounds.botId,
groupId = 10,
startTime = firstGroupBatch.endTime,
snapshotEndTime = groupBounds.endTime,
messageLimit = 2,
maxMessageChars = 200,
)
)
assertEquals(201, secondGroupBatch.endTime)
assertEquals(listOf(130, 200), secondGroupBatch.messages.map { it.record.time })
} finally {
directory.toFile().deleteRecursively()
}
@@ -0,0 +1,40 @@
package top.jie65535.mirai.profile
import kotlinx.serialization.decodeFromString
import kotlin.test.Test
import kotlin.test.assertEquals
class ProfileModelResponseParsingTest {
@Test
fun ignoresLegacyFieldsAndNormalizesDisplayedEvidenceReferences() {
val response = profileResponseJson.decodeFromString<ProfileModelResponse>(
"""
{
"operations": [
{
"action": "ADD",
"item_id": null,
"category": "interest",
"content": "关注 Kotlin",
"confidence": "low",
"evidence_refs": [127, "e:128", "129"]
}
],
"summary": "关注 Kotlin。",
"unexpected": true
}
""".trimIndent()
)
assertEquals(listOf(127, 128, 129), response.operations.single().evidenceRefs)
}
@Test
fun preservesMalformedEvidenceAsAnInvalidReferenceForOperationValidation() {
val response = profileResponseJson.decodeFromString<ProfileModelResponse>(
"""{"operations":[{"action":"CONFIRM","item_ref":"P1","evidence_refs":["invalid"]}]}"""
)
assertEquals(listOf(0), response.operations.single().evidenceRefs)
}
}
@@ -11,6 +11,7 @@ import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class UserProfileAnalysisServiceTest {
@Test
@@ -22,13 +23,14 @@ class UserProfileAnalysisServiceTest {
assertEquals(1, model.calls)
assertEquals(2, report.analyzedUsers)
assertEquals(2, report.appliedOperations)
assertEquals(0, report.skippedOperations)
assertEquals("日常使用 Kotlin 开发", UserProfileStore.load(USER_A)?.items?.single()?.content)
assertEquals("持续关注本地大模型", UserProfileStore.load(USER_B)?.items?.single()?.content)
assertEquals(true, UserProfileStore.isConversationProcessed(INPUT_HASH))
}
@Test
fun rejectsWronglyAttributedEvidenceWithoutPartialCommit() = withProfileStore {
fun skipsWronglyAttributedEvidenceAndMarksConversationProcessed() = withProfileStore {
val model = FakeConversationProfileModel {
result(
responseFor(
@@ -39,14 +41,14 @@ class UserProfileAnalysisServiceTest {
)
}
assertFailsWith<IllegalStateException> {
analyze(batch(), model, retryMax = 1)
}
val report = assertNotNull(analyze(batch(), model, retryMax = 1))
assertEquals(2, model.calls)
assertNull(UserProfileStore.load(USER_A))
assertNull(UserProfileStore.load(USER_B))
assertFalse(UserProfileStore.isConversationProcessed(INPUT_HASH))
assertEquals(1, model.calls)
assertEquals(0, report.appliedOperations)
assertEquals(1, report.skippedOperations)
assertNotNull(UserProfileStore.load(USER_A))
assertNotNull(UserProfileStore.load(USER_B))
assertTrue(UserProfileStore.isConversationProcessed(INPUT_HASH))
}
@Test
@@ -0,0 +1,295 @@
package top.jie65535.mirai.profile
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class UserProfileCompactorTest {
@Test
fun mergesRewritesAndDeletesWithinConservativeBounds() {
val profile = profile()
val plan = UserProfileCompactor.reduce(
current = profile,
supportStats = mapOf("style-item" to supportStats(1)),
response = ProfileCompactionResponse(
merges = listOf(
ProfileCompactionMerge(
itemRefs = listOf("P1", "P2"),
content = "关注并实际体验国内外大语言模型",
)
),
rewrites = listOf(
ProfileCompactionRewrite(
itemRef = "P4",
content = "从事使用 C# 的上位机开发",
)
),
deletes = listOf(
ProfileCompactionDelete(
itemRef = "P3",
reason = ProfileCompactionDeleteReason.OVER_SPECIFIC,
)
),
summary = "该用户从事上位机开发,并关注大语言模型。",
),
model = "test-model",
promptVersion = "compact-v1",
summaryMaxLength = 500,
)
assertEquals(2, plan.reduction.profile.items.size)
assertEquals(profile.cursorTime, plan.reduction.profile.cursorTime)
assertEquals(profile.snapshotEndTime, plan.reduction.profile.snapshotEndTime)
assertEquals(profile.version + 1, plan.reduction.profile.version)
assertEquals(1, plan.mergedGroups)
assertEquals(1, plan.rewrittenItems)
assertEquals(1, plan.deletedItems)
assertEquals(mapOf("model-b" to "model-a"), plan.supportReassignments)
assertEquals(4, plan.reduction.operations.size)
val merged = plan.reduction.profile.items.single { it.category == ProfileCategory.INTEREST }
assertEquals("model-a", merged.id)
assertEquals(ProfileConfidence.LOW, merged.confidence)
assertEquals("关注并实际体验国内外大语言模型", merged.content)
assertFalse(plan.reduction.profile.items.any { it.id == "style-item" })
}
@Test
fun skipsMergingDifferentCategories() {
val profile = profile()
val plan = UserProfileCompactor.reduce(
current = profile,
supportStats = emptyMap(),
response = ProfileCompactionResponse(
merges = listOf(
ProfileCompactionMerge(
itemRefs = listOf("P2", "P3"),
content = "关注模型并使用口语化表达",
)
)
),
model = "test-model",
promptVersion = "compact-v1",
summaryMaxLength = 500,
)
assertTrue(plan.reduction.operations.isEmpty())
assertEquals(profile.version, plan.reduction.profile.version)
assertTrue(plan.skippedOperations.single().contains("同类别"))
}
@Test
fun skipsDeletingSupportedOrHighItemsButAllowsLowSupportMedium() {
val supported = delete(profile(), "P3", mapOf("style-item" to supportStats(2)))
assertTrue(supported.reduction.operations.isEmpty())
assertTrue(supported.skippedOperations.single().contains("2 次支持"))
val high = delete(profile(), "P4", mapOf("fact-item" to supportStats(1)))
assertTrue(high.reduction.operations.isEmpty())
assertTrue(high.skippedOperations.single().contains("high"))
val medium = delete(profile(), "P2", mapOf("model-b" to supportStats(1)))
assertEquals(1, medium.deletedItems)
assertFalse(medium.reduction.profile.items.any { it.id == "model-b" })
}
@Test
fun compactionPromptUsesTemporaryReferencesAndSupportCounts() {
val profile = profile()
val prompt = ProfilePromptStore.buildCompactionUserPrompt(
profile,
mapOf("model-a" to supportStats(3)),
)
assertTrue(prompt.contains("[P1]"))
assertTrue(prompt.contains("supports=3"))
assertTrue(prompt.contains("item_range="))
profile.items.forEach { assertFalse(prompt.contains(it.id)) }
}
@Test
fun profilePromptsRequireAbsoluteDatesForTimeSensitiveFacts() {
assertTrue(ProfilePromptStore.systemPrompt.contains("截至 YYYY-MM-DD"))
assertTrue(ProfilePromptStore.conversationSystemPrompt.contains("截至 YYYY-MM-DD"))
assertTrue(ProfilePromptStore.compactionSystemPrompt.contains("item_range"))
}
@Test
fun promptsDefineSummaryAsAWholeProfileSynthesis() {
assertTrue(ProfilePromptStore.systemPrompt.contains("不是本批聊天摘要"))
assertTrue(ProfilePromptStore.conversationSystemPrompt.contains("不是本批聊天摘要"))
assertTrue(ProfilePromptStore.compactionSystemPrompt.contains("全部保留条目"))
assertTrue(ProfilePromptStore.compactionSystemPrompt.contains("不得只描述最后编辑的条目"))
assertTrue(ProfilePromptStore.compactionSystemPrompt.contains("即使没有条目操作也应重写"))
}
@Test
fun removesRelatedGroupAliasFromRewrittenRelationship() {
val relation = item(
id = "relation-item",
category = ProfileCategory.RELATIONSHIP_NOTE,
content = "经常讨论技术方案",
confidence = ProfileConfidence.LOW,
firstSeenAt = 10,
).copy(relatedUserId = 200)
val profile = profile().copy(items = listOf(relation))
val plan = UserProfileCompactor.reduce(
current = profile,
supportStats = emptyMap(),
response = ProfileCompactionResponse(
rewrites = listOf(
ProfileCompactionRewrite("P1", "经常与 R1 讨论技术方案并互相提供建议")
),
summary = profile.summary,
),
model = "test-model",
promptVersion = "compact-v1",
summaryMaxLength = 500,
)
assertEquals("经常与对方讨论技术方案并互相提供建议", plan.reduction.profile.items.single().content)
assertFalse(plan.reduction.profile.items.single().content.contains("R1"))
}
@Test
fun ignoresNoopRewriteWithoutChangingVersion() {
val profile = profile()
val plan = UserProfileCompactor.reduce(
current = profile,
supportStats = emptyMap(),
response = ProfileCompactionResponse(
rewrites = listOf(
ProfileCompactionRewrite("P1", "关注 Qwen、DeepSeek 等模型")
)
),
model = "test-model",
promptVersion = "compact-v1",
summaryMaxLength = 500,
)
assertTrue(plan.reduction.operations.isEmpty())
assertEquals(0, plan.rewrittenItems)
assertEquals(profile.version, plan.reduction.profile.version)
}
@Test
fun allowsSummaryOnlyCompactionAcrossTheWholeProfile() {
val profile = profile().copy(summary = "最近一次只讨论了 C#。")
val plan = UserProfileCompactor.reduce(
current = profile,
supportStats = emptyMap(),
response = ProfileCompactionResponse(
summary = "该用户从事上位机开发,关注大语言模型,表达直接。",
),
model = "test-model",
promptVersion = "compact-v3",
summaryMaxLength = 500,
)
assertTrue(plan.reduction.operations.isEmpty())
assertEquals(profile.version + 1, plan.reduction.profile.version)
assertEquals(
"该用户从事上位机开发,关注大语言模型,表达直接。",
plan.reduction.profile.summary,
)
}
@Test
fun processesMoreThanTwelveOperationsOfOneType() {
val items = (1..15).map { index ->
item(
id = "item-$index",
category = ProfileCategory.EXPRESSION_STYLE,
content = "旧表达方式 $index",
confidence = ProfileConfidence.LOW,
firstSeenAt = index,
)
}
val profile = profile().copy(items = items)
val plan = UserProfileCompactor.reduce(
current = profile,
supportStats = emptyMap(),
response = ProfileCompactionResponse(
rewrites = items.indices.map { index ->
ProfileCompactionRewrite("P${index + 1}", "概括后的表达方式 ${index + 1}")
},
summary = profile.summary,
),
model = "test-model",
promptVersion = "compact-v1",
summaryMaxLength = 500,
)
assertEquals(15, plan.rewrittenItems)
assertTrue(plan.skippedOperations.isEmpty())
}
@Test
fun keepsOldSummaryWhenNewSummaryContainsTemporaryReference() {
val profile = profile()
val plan = UserProfileCompactor.reduce(
current = profile,
supportStats = emptyMap(),
response = ProfileCompactionResponse(
rewrites = listOf(ProfileCompactionRewrite("P1", "关注多个国内外大语言模型")),
summary = "P1 已经概括多个模型兴趣。",
),
model = "test-model",
promptVersion = "compact-v1",
summaryMaxLength = 500,
)
assertEquals(profile.summary, plan.reduction.profile.summary)
assertTrue(plan.skippedOperations.single().contains("临时画像条目编号"))
}
private fun delete(
profile: UserProfileSnapshot,
itemRef: String,
stats: Map<String, ProfileItemSupportStats>,
) = UserProfileCompactor.reduce(
current = profile,
supportStats = stats,
response = ProfileCompactionResponse(
deletes = listOf(
ProfileCompactionDelete(itemRef, ProfileCompactionDeleteReason.ONE_OFF)
)
),
model = "test-model",
promptVersion = "compact-v1",
summaryMaxLength = 500,
)
private fun profile() = UserProfileSnapshot(
userId = 100,
summary = "关注模型并从事软件开发。",
version = 8,
cursorTime = 300,
snapshotEndTime = 1_000,
reliable = true,
items = listOf(
item("model-a", ProfileCategory.INTEREST, "关注 Qwen、DeepSeek 等模型", ProfileConfidence.LOW, 10),
item("model-b", ProfileCategory.INTEREST, "关注开源大模型部署和性能", ProfileConfidence.MEDIUM, 20),
item("style-item", ProfileCategory.EXPRESSION_STYLE, "会使用“我去”表示惊讶", ProfileConfidence.LOW, 30),
item("fact-item", ProfileCategory.NOTABLE_FACT, "从事上位机开发,使用 C#", ProfileConfidence.HIGH, 40),
),
)
private fun item(
id: String,
category: ProfileCategory,
content: String,
confidence: ProfileConfidence,
firstSeenAt: Int,
) = UserProfileItem(
id = id,
category = category,
content = content,
confidence = confidence,
firstSeenAt = firstSeenAt,
lastConfirmedAt = firstSeenAt + 100,
)
private fun supportStats(count: Int) = ProfileItemSupportStats(count, 1, 2)
}
@@ -15,7 +15,7 @@ class UserProfileContextRendererTest {
snapshotEndTime = 2,
reliable = true,
items = listOf(
relationship("visible", 200, "经常互相讨论技术方案"),
relationship("visible", 200, "经常与 U12 讨论技术方案"),
relationship("hidden", 300, "曾共同讨论游戏"),
),
)
@@ -38,8 +38,9 @@ class UserProfileContextRendererTest {
assertContains(rendered, "小明代号(100)")
assertContains(rendered, "好感度+12")
assertContains(rendered, "长期认识:长期关注 Kotlin 开发")
assertContains(rendered, "与小王:经常互相讨论技术方案")
assertContains(rendered, "画像认识:长期关注 Kotlin 开发")
assertContains(rendered, "与小王:经常与对方讨论技术方案")
assertFalse(rendered.contains("U12"))
assertFalse(rendered.contains("小李"))
assertFalse(rendered.contains("曾共同讨论游戏"))
}
@@ -59,7 +60,7 @@ class UserProfileContextRendererTest {
assertContains(rendered, "小明(100)")
assertContains(rendered, "好感度-8")
assertContains(rendered, "主观印象:偶尔喜欢抬杠")
assertFalse(rendered.contains("长期认识:"))
assertFalse(rendered.contains("画像认识:"))
}
@Test
@@ -88,4 +89,5 @@ class UserProfileContextRendererTest {
firstSeenAt = 1,
lastConfirmedAt = 1,
)
}
+201 -23
View File
@@ -4,7 +4,7 @@ import net.mamoe.mirai.message.data.MessageSourceKind
import top.jie65535.mirai.data.ChatMessageRecord
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class UserProfileReducerTest {
@@ -43,32 +43,33 @@ class UserProfileReducerTest {
}
@Test
fun rejectsItemSupportedOnlyByAnotherUser() {
fun skipsItemSupportedOnlyByAnotherUser() {
val batch = batchOf(message(ref = 1, fromId = OTHER, text = "我平时会写 Kotlin"))
val failure = assertFailsWith<IllegalArgumentException> {
UserProfileReducer.reduce(
current = emptyProfile(),
batch = batch,
response = ProfileModelResponse(
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.NOTABLE_FACT,
content = "从事 Kotlin 开发",
confidence = ProfileConfidence.HIGH,
evidenceRefs = listOf(1),
)
),
summary = "从事 Kotlin 开发。",
val reduction = UserProfileReducer.reduce(
current = emptyProfile(),
batch = batch,
response = ProfileModelResponse(
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.NOTABLE_FACT,
content = "从事 Kotlin 开发",
confidence = ProfileConfidence.HIGH,
evidenceRefs = listOf(1),
)
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
}
summary = "从事 Kotlin 开发。",
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
assertTrue(failure.message.orEmpty().contains("目标用户"))
assertTrue(reduction.operations.isEmpty())
assertTrue(reduction.profile.items.isEmpty())
assertTrue(reduction.skippedOperations.single().contains("目标用户"))
assertEquals(batch.endTime, reduction.profile.cursorTime)
}
@Test
@@ -120,12 +121,189 @@ class UserProfileReducerTest {
assertEquals(1, reduction.profile.version)
}
@Test
fun resolvesPromptLocalItemReferenceWithoutExposingStoredId() {
val item = existingItem()
val current = emptyProfile().copy(items = listOf(item), reliable = true)
val batch = batchOf(message(ref = 1, fromId = TARGET, text = "我现在仍然关注 Kotlin"))
val reduction = UserProfileReducer.reduce(
current = current,
batch = batch,
response = ProfileModelResponse(
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.CONFIRM,
itemRef = "P1",
confidence = ProfileConfidence.HIGH,
evidenceRefs = listOf(1),
)
),
summary = "TARGET 仍然关注 Kotlin。",
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
assertEquals(item.id, reduction.profile.items.single().id)
assertEquals(ProfileConfidence.HIGH, reduction.profile.items.single().confidence)
assertEquals("该用户仍然关注 Kotlin。", reduction.profile.summary)
val prompt = ProfilePromptStore.buildUserPrompt(current, batch)
assertTrue(prompt.contains("[P1]"))
assertFalse(prompt.contains(item.id))
}
@Test
fun keepsComprehensiveSummaryWhenBatchOnlyConfirmsOneItem() {
val interest = existingItem()
val work = UserProfileItem(
id = "work-item",
category = ProfileCategory.NOTABLE_FACT,
content = "从事上位机开发",
confidence = ProfileConfidence.HIGH,
firstSeenAt = 70,
lastConfirmedAt = 70,
)
val current = emptyProfile().copy(
summary = "从事上位机开发,并持续关注 Kotlin 生态。",
reliable = true,
items = listOf(interest, work),
)
val batch = batchOf(message(ref = 1, fromId = TARGET, text = "我还在关注 Kotlin"))
val reduction = UserProfileReducer.reduce(
current = current,
batch = batch,
response = ProfileModelResponse(
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.CONFIRM,
itemRef = "P1",
evidenceRefs = listOf(1),
)
),
summary = "仍在关注 Kotlin。",
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
assertEquals(current.summary, reduction.profile.summary)
}
@Test
fun skipsUnknownPromptLocalItemReference() {
val current = emptyProfile().copy(items = listOf(existingItem()), reliable = true)
val batch = batchOf(message(ref = 1, fromId = TARGET, text = "我仍然关注 Kotlin"))
val reduction = UserProfileReducer.reduce(
current = current,
batch = batch,
response = ProfileModelResponse(
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.CONFIRM,
itemRef = "P2",
evidenceRefs = listOf(1),
)
),
summary = "关注 Kotlin。",
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
assertEquals(current.items, reduction.profile.items)
assertTrue(reduction.operations.isEmpty())
assertTrue(reduction.skippedOperations.single().contains("P2"))
}
@Test
fun appliesSafeOperationAndSkipsInvalidEvidenceWithoutRewritingSummary() {
val current = emptyProfile().copy(summary = "原摘要")
val batch = batchOf(message(ref = 1, fromId = TARGET, text = "我平时会写 Kotlin"))
val reduction = UserProfileReducer.reduce(
current = current,
batch = batch,
response = ProfileModelResponse(
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.NOTABLE_FACT,
content = "日常使用 Kotlin 开发",
confidence = ProfileConfidence.MEDIUM,
evidenceRefs = listOf(1),
),
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.INTEREST,
content = "关注本地大模型",
confidence = ProfileConfidence.LOW,
evidenceRefs = listOf(323),
),
),
summary = "使用 Kotlin 并关注本地大模型。",
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
assertEquals(1, reduction.operations.size)
assertEquals("日常使用 Kotlin 开发", reduction.profile.items.single().content)
assertTrue(reduction.skippedOperations.single().contains("e:323"))
assertEquals("原摘要", reduction.profile.summary)
}
@Test
fun removesTemporaryAliasesFromPersistedRelationshipText() {
val batch = batchOf(message(ref = 1, fromId = TARGET, text = "先找到下家再离职"))
val reduction = UserProfileReducer.reduce(
current = emptyProfile(),
batch = batch,
response = ProfileModelResponse(
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.RELATIONSHIP_NOTE,
content = "TARGET 在 U1 提及离职时会建议 U1 先找到下家",
confidence = ProfileConfidence.LOW,
relatedUserAlias = "U1",
evidenceRefs = listOf(1),
)
),
summary = "TARGET 会给 U1 提供务实建议。",
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
assertEquals("本人在对方提及离职时会建议对方先找到下家", reduction.profile.items.single().content)
assertEquals("该用户会给其他用户提供务实建议。", reduction.profile.summary)
assertFalse(reduction.profile.items.single().content.contains("U1"))
}
private fun emptyProfile() = UserProfileSnapshot(
userId = TARGET,
cursorTime = 100,
snapshotEndTime = 1_000,
)
private fun existingItem() = UserProfileItem(
id = "b287070d-b7c0-4d50-a18c-fa8348932048",
category = ProfileCategory.INTEREST,
content = "关注 Kotlin 开发",
confidence = ProfileConfidence.MEDIUM,
firstSeenAt = 80,
lastConfirmedAt = 80,
)
private fun batchOf(vararg messages: ProfilePromptMessage) = ProfileHistoryBatch(
userId = TARGET,
startTime = 100,
+123 -1
View File
@@ -136,6 +136,14 @@ class UserProfileStoreTest {
}
}
assertTrue("source" in columns)
val tables = connection.createStatement().use { statement ->
statement.executeQuery("SELECT name FROM sqlite_master WHERE type = 'table'").use { results ->
buildSet {
while (results.next()) add(results.getString("name"))
}
}
}
assertTrue("profile_group_cursor" in tables)
val version = connection.createStatement().use { statement ->
statement.executeQuery(
"SELECT value FROM user_profile_meta WHERE key = 'schema_version'"
@@ -144,7 +152,7 @@ class UserProfileStoreTest {
results.getString(1)
}
}
assertEquals("2", version)
assertEquals("3", version)
}
} finally {
UserProfileStore.close()
@@ -152,6 +160,31 @@ class UserProfileStoreTest {
}
}
@Test
fun persistsGroupAnalysisCursor() {
val directory = Files.createTempDirectory("jchatgpt-profile-group-cursor-test-")
try {
UserProfileStore.init(directory.toFile())
val cursor = GroupProfileCursor(
botId = 1,
groupId = 300,
cursorTime = 200,
snapshotEndTime = 1_000,
updatedAt = 123,
)
UserProfileStore.saveGroupCursor(cursor)
assertEquals(cursor, UserProfileStore.loadGroupCursor(1, 300))
val advanced = cursor.copy(cursorTime = 400, updatedAt = 456)
UserProfileStore.saveGroupCursor(advanced)
assertEquals(advanced, UserProfileStore.loadGroupCursor(1, 300))
} finally {
UserProfileStore.close()
directory.toFile().deleteRecursively()
}
}
@Test
fun commitsAllConversationProfilesInOneTransactionAndCountsUsageOnce() {
val directory = Files.createTempDirectory("jchatgpt-profile-conversation-commit-test-")
@@ -198,6 +231,81 @@ class UserProfileStoreTest {
}
}
@Test
fun commitsCompactionWithoutNewEvidenceAndReassignsExistingSupport() {
val directory = Files.createTempDirectory("jchatgpt-profile-compaction-commit-test-")
try {
UserProfileStore.init(directory.toFile())
val batch = batchFor(100, "initial-hash")
val first = itemFor("item-1")
val second = itemFor("item-2").copy(content = "关注 Kotlin 生态")
val initialProfile = UserProfileSnapshot(
userId = 100,
summary = "关注 Kotlin。",
version = 1,
cursorTime = 200,
snapshotEndTime = 1_000,
reliable = true,
model = "test-model",
promptVersion = "test-prompt",
items = listOf(first, second),
)
UserProfileStore.commit(
reduction = ProfileReduction(
profile = initialProfile,
operations = listOf(
applied(first, ProfileOperationAction.ADD, listOf(1)),
applied(second, ProfileOperationAction.ADD, listOf(1)),
),
),
batch = batch,
usage = ProfileTokenUsage(),
)
val merged = first.copy(content = "关注 Kotlin 及其生态")
val compactedProfile = initialProfile.copy(
summary = "关注 Kotlin 及其生态。",
version = 2,
items = listOf(merged),
)
val plan = ProfileCompactionPlan(
reduction = ProfileReduction(
profile = compactedProfile,
operations = listOf(
applied(merged, ProfileOperationAction.UPDATE, emptyList()),
applied(second, ProfileOperationAction.DELETE, emptyList()),
),
),
supportReassignments = mapOf(second.id to first.id),
mergedGroups = 1,
rewrittenItems = 0,
deletedItems = 0,
)
UserProfileStore.commitCompaction(
plan = plan,
batch = ProfileHistoryBatch(
userId = 100,
startTime = 150,
endTime = 151,
messages = emptyList(),
aliases = mapOf(100L to "TARGET"),
inputHash = "compact-hash",
),
usage = ProfileTokenUsage(50, 10, 0),
)
val loaded = assertNotNull(UserProfileStore.load(100))
assertEquals(200, loaded.cursorTime)
assertEquals(listOf(merged), loaded.items)
assertEquals(2, UserProfileStore.loadSupportStats(100).getValue(first.id).count)
assertTrue(second.id !in UserProfileStore.loadSupportStats(100))
assertNotNull(UserProfileStore.lastRevisionAt(100, ProfileRevisionSource.COMPACTION))
} finally {
UserProfileStore.close()
directory.toFile().deleteRecursively()
}
}
private fun batchFor(userId: Long, inputHash: String) = ProfileHistoryBatch(
userId = userId,
startTime = 100,
@@ -231,4 +339,18 @@ class UserProfileStoreTest {
firstSeenAt = 150,
lastConfirmedAt = 150,
)
private fun applied(
item: UserProfileItem,
action: ProfileOperationAction,
evidenceRefs: List<Int>,
) = AppliedProfileOperation(
action = action,
itemId = item.id,
category = item.category,
content = item.content,
confidence = item.confidence,
relatedUserId = item.relatedUserId,
evidenceRefs = evidenceRefs,
)
}