profile: add progressive user profiles

Add cache-expiry profile maintenance and contextual injection, reorganize runtime code by responsibility, remove automatic favorability decay, and prepare version 1.15.0.
This commit is contained in:
2026-08-02 21:45:34 +08:00
parent 2ed39e9fe8
commit 23299b2ae7
59 changed files with 4799 additions and 1340 deletions
@@ -0,0 +1,119 @@
package top.jie65535.mirai.profile
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import top.jie65535.mirai.llm.LargeLanguageModels
import top.jie65535.mirai.llm.ModelService
import java.io.File
import kotlin.test.Test
import kotlin.time.Duration.Companion.seconds
class ConversationProfileLiveExperiment {
@Test
fun analyzeConfiguredHistoricalConversation() = runBlocking {
val apiKey = System.getenv("PROFILE_EXPERIMENT_API_KEY") ?: return@runBlocking
val source = File(checkNotNull(System.getenv("PROFILE_EXPERIMENT_SOURCE")))
val botId = checkNotNull(System.getenv("PROFILE_EXPERIMENT_BOT_ID")).toLong()
val groupId = checkNotNull(System.getenv("PROFILE_EXPERIMENT_GROUP_ID")).toLong()
val startTime = checkNotNull(System.getenv("PROFILE_EXPERIMENT_START")).toInt()
val endTime = checkNotNull(System.getenv("PROFILE_EXPERIMENT_END")).toInt()
val output = File(checkNotNull(System.getenv("PROFILE_EXPERIMENT_OUTPUT")))
val modelName = checkNotNull(System.getenv("PROFILE_EXPERIMENT_MODEL"))
val baseUrl = checkNotNull(System.getenv("PROFILE_EXPERIMENT_API"))
val messageLimit = System.getenv("PROFILE_EXPERIMENT_MESSAGE_LIMIT")?.toInt() ?: 150
val firstChunkTimeout = System.getenv("PROFILE_EXPERIMENT_FIRST_CHUNK_TIMEOUT")?.toInt() ?: 240
val batch = checkNotNull(
ProfileHistoryReader(source).loadConversationBatch(
botId = botId,
groupId = groupId,
startTime = startTime,
endTime = endTime,
messageLimit = messageLimit,
maxMessageChars = 1_000,
)
)
val eligible = batch.authoredTextCharsByUser.filterValues { it >= 20 }.keys
check(eligible.isNotEmpty()) { "所选会话没有达到文本门槛的参与者" }
val profiles = eligible.associateWith { userId ->
UserProfileSnapshot(userId = userId, cursorTime = 0, snapshotEndTime = 0)
}
val service = ModelService(
baseUrl = baseUrl,
token = apiKey,
timeout = maxOf(180, firstChunkTimeout).seconds,
firstChunkTimeout = firstChunkTimeout.seconds,
)
try {
val client = ProfileModelClient(
LargeLanguageModels.ProfileEndpoint(service, modelName, temperature = 0.1)
)
val result = client.analyzeConversation(profiles, batch, eligible)
val reductions = ConversationProfileReducer.reduce(
profiles = profiles,
batch = batch,
eligibleUserIds = eligible,
response = result.response,
model = modelName,
promptVersion = ProfilePromptStore.PROMPT_VERSION,
summaryMaxLength = 500,
)
val names = batch.aliases.mapValues { (userId, alias) -> "$alias/$userId" }
val injection = UserProfileContextRenderer.render(
profiles = reductions.map { it.profile },
favorabilityByUserId = emptyMap(),
displayNames = names,
activeUserIds = eligible,
summaryMaxChars = 300,
)
output.parentFile?.mkdirs()
output.writeText(
buildString {
appendLine("# 多人会话画像真实数据实验")
appendLine()
appendLine("- group: $groupId")
appendLine("- range: [$startTime, $endTime)")
appendLine("- messages: ${batch.messages.size}")
appendLine("- eligible_users: ${eligible.size}")
appendLine("- operations: ${reductions.sumOf { it.operations.size }}")
appendLine("- tokens: ${result.usage.promptTokens}/${result.usage.completionTokens}, cached=${result.usage.cachedTokens}")
appendLine()
appendLine("## 模型结构化输出")
appendLine()
appendLine("```json")
appendLine(prettyJson.encodeToString(result.response))
appendLine("```")
appendLine()
appendLine("## 证据核对")
reductions.flatMap { reduction ->
reduction.operations.map { operation -> reduction.profile.userId to operation }
}.forEach { (userId, operation) ->
appendLine()
appendLine("- ${batch.aliases[userId]}/$userId: ${operation.action} ${operation.category} ${operation.content}")
operation.evidenceRefs.forEach { ref ->
val evidence = checkNotNull(batch.evidenceByRef[ref])
append(" - [e:").append(ref).append("][")
.append(batch.aliases[evidence.record.fromId]).append("] ")
.appendLine(evidence.text.replace('\n', ' '))
}
}
appendLine()
appendLine("## 实际注入文本")
appendLine()
appendLine("```")
append(injection)
appendLine("```")
},
Charsets.UTF_8,
)
} finally {
service.httpClient.close()
}
}
companion object {
private val prettyJson = Json { prettyPrint = true }
}
}
@@ -0,0 +1,223 @@
package top.jie65535.mirai.profile
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.assertTrue
class ConversationProfileReducerTest {
@Test
fun reducesMultipleUsersFromOneConversation() {
val batch = batch()
val profiles = USERS.associateWith(::emptyProfile)
val reductions = ConversationProfileReducer.reduce(
profiles = profiles,
batch = batch,
eligibleUserIds = USERS,
response = ConversationProfileModelResponse(
users = listOf(
ConversationProfileUserResponse(
userAlias = "U1",
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.NOTABLE_FACT,
content = "日常使用 Kotlin 开发",
confidence = ProfileConfidence.MEDIUM,
evidenceRefs = listOf(1),
)
),
summary = "日常使用 Kotlin 开发。",
),
ConversationProfileUserResponse(
userAlias = "U2",
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.INTEREST,
content = "关注本地大模型",
confidence = ProfileConfidence.MEDIUM,
evidenceRefs = listOf(2),
)
),
summary = "关注本地大模型。",
),
)
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
assertEquals(2, reductions.size)
assertEquals("日常使用 Kotlin 开发", reductions.single { it.profile.userId == USER_A }.profile.items.single().content)
assertEquals("关注本地大模型", reductions.single { it.profile.userId == USER_B }.profile.items.single().content)
assertTrue(reductions.all { it.profile.cursorTime == 0 })
}
@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 = "关注本地大模型。",
)
)
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
}
assertTrue(failure.message.orEmpty().contains("目标用户"))
}
@Test
fun confirmsAnExistingItemInsteadOfCreatingAnIndependentProfile() {
val oldItem = UserProfileItem(
id = "existing-item",
category = ProfileCategory.INTEREST,
content = "关注 Kotlin 开发",
confidence = ProfileConfidence.LOW,
firstSeenAt = 80,
lastConfirmedAt = 80,
)
val existing = emptyProfile(USER_A).copy(
summary = "关注 Kotlin 开发。",
version = 1,
reliable = true,
items = listOf(oldItem),
)
val profiles = mapOf(USER_A to existing, USER_B to emptyProfile(USER_B))
val reductions = ConversationProfileReducer.reduce(
profiles = profiles,
batch = batch(),
eligibleUserIds = USERS,
response = ConversationProfileModelResponse(
users = listOf(
ConversationProfileUserResponse(
userAlias = "U1",
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.CONFIRM,
itemId = oldItem.id,
confidence = ProfileConfidence.MEDIUM,
evidenceRefs = listOf(1),
)
),
summary = "关注 Kotlin 开发。",
)
)
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
val updated = reductions.single { it.profile.userId == USER_A }.profile
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]")
)
}
@Test
fun rejectsMoreThanFourOperationsForOneUser() {
val operations = (1..5).map { index ->
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.NOTABLE_FACT,
content = "事实 $index",
confidence = ProfileConfidence.LOW,
evidenceRefs = listOf(1),
)
}
val failure = assertFailsWith<IllegalArgumentException> {
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,
)
}
assertTrue(failure.message.orEmpty().contains("超过 4 项"))
}
private fun batch() = ConversationProfileBatch(
botId = BOT,
groupId = 10,
startTime = 100,
endTime = 200,
messages = listOf(
message(1, USER_A, "我平时用 Kotlin 写项目"),
message(2, USER_B, "我最近一直在研究本地大模型"),
),
aliases = mapOf(BOT to "BOT", USER_A to "U1", USER_B to "U2"),
inputHash = "conversation-hash",
)
private fun emptyProfile(userId: Long) = UserProfileSnapshot(
userId = userId,
cursorTime = 0,
snapshotEndTime = 0,
)
private fun message(ref: Int, fromId: Long, text: String) = ProfilePromptMessage(
record = ChatMessageRecord(
botId = BOT,
fromId = fromId,
targetId = 10,
ids = null,
internalIds = null,
time = 120 + ref,
kind = MessageSourceKind.GROUP,
code = text,
),
text = text,
evidenceRef = ref,
episodeIndex = 1,
)
companion object {
private const val BOT = 1L
private const val USER_A = 100L
private const val USER_B = 200L
private val USERS = setOf(USER_A, USER_B)
}
}
@@ -0,0 +1,122 @@
package top.jie65535.mirai.profile
import net.mamoe.mirai.message.data.MessageSourceKind
import java.nio.file.Files
import java.sql.DriverManager
import kotlin.io.path.absolutePathString
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class ProfileHistoryReaderTest {
@Test
fun keepsEqualTimestampTargetMessagesTogetherAndLoadsGroupContext() {
val directory = Files.createTempDirectory("jchatgpt-profile-history-test-")
val database = directory.resolve("history.sqlite")
try {
DriverManager.getConnection("jdbc:sqlite:${database.absolutePathString()}").use { connection ->
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
CREATE TABLE message_record(
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_id INTEGER NOT NULL,
from_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
ids TEXT,
internal_ids TEXT,
time INTEGER NOT NULL,
kind INTEGER NOT NULL,
code TEXT NOT NULL,
recalled INTEGER NOT NULL DEFAULT 0
)
""".trimIndent()
)
}
connection.prepareStatement(
"INSERT INTO message_record(" +
"bot_id, from_id, target_id, time, kind, code, recalled" +
") VALUES (1, ?, ?, ?, ?, ?, 0)"
).use { statement ->
fun insert(fromId: Long, groupId: Long, time: Int, text: String) {
statement.setLong(1, fromId)
statement.setLong(2, groupId)
statement.setInt(3, time)
statement.setInt(4, MessageSourceKind.GROUP.ordinal)
statement.setString(5, """[{"type":"PlainText","content":"$text"}]""")
statement.executeUpdate()
}
insert(TARGET, 10, 100, "目标发言一")
insert(OTHER, 10, 110, "用于理解语境的回复")
insert(TARGET, 10, 130, "目标发言二")
insert(TARGET, 20, 130, "同一秒的另一群发言")
insert(OTHER, 20, 140, "后续上下文")
insert(TARGET, 10, 200, "下一批目标发言")
}
}
val reader = ProfileHistoryReader(database.toFile())
val bounds = assertNotNull(reader.findUserTimeBounds(TARGET))
assertEquals(100, bounds.startTime)
assertEquals(201, bounds.endTime)
val first = assertNotNull(
reader.loadNextBatch(
userId = TARGET,
startTime = bounds.startTime,
snapshotEndTime = bounds.endTime,
targetMessageLimit = 2,
maxEpisodes = 1,
episodeGapSeconds = 60,
contextBeforeMessages = 2,
contextAfterMessages = 2,
contextCoreMessages = 20,
maxMessageChars = 200,
)
)
assertEquals(131, first.endTime)
assertEquals(3, first.evidenceByRef.values.count { it.record.fromId == TARGET })
assertTrue(first.messages.any { it.record.fromId == OTHER })
assertEquals("TARGET", first.aliases[TARGET])
val second = assertNotNull(
reader.loadNextBatch(
userId = TARGET,
startTime = first.endTime,
snapshotEndTime = bounds.endTime,
targetMessageLimit = 2,
maxEpisodes = 10,
episodeGapSeconds = 60,
contextBeforeMessages = 2,
contextAfterMessages = 2,
contextCoreMessages = 20,
maxMessageChars = 200,
)
)
assertEquals(201, second.endTime)
assertEquals(1, second.evidenceByRef.values.count { it.record.fromId == TARGET })
val conversation = assertNotNull(
reader.loadConversationBatch(
botId = 1,
groupId = 10,
startTime = 90,
endTime = 150,
messageLimit = 10,
maxMessageChars = 200,
)
)
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 })
} finally {
directory.toFile().deleteRecursively()
}
}
companion object {
private const val TARGET = 100L
private const val OTHER = 200L
}
}
@@ -0,0 +1,46 @@
package top.jie65535.mirai.profile
import net.mamoe.mirai.message.data.MessageSourceKind
import top.jie65535.mirai.data.ChatMessageRecord
import kotlin.test.Test
import kotlin.test.assertEquals
class ProfileMessageRendererTest {
@Test
fun rendersStoredJsonWithoutMiraiRuntime() {
val record = record(
"""
[
{"type":"QuoteReply","source":{"fromId":200,"originalMessage":[{"type":"PlainText","content":"我在公明工作"}]}},
{"type":"At","target":200},
{"type":"PlainText","content":" 这说的是你,不是我"},
{"type":"Image","isEmoji":false}
]
""".trimIndent()
)
assertEquals(
"[引用 U1: 我在公明工作]@U1 这说的是你,不是我[图片]",
ProfileMessageRenderer.render(record, mapOf(100L to "TARGET", 200L to "U1"), 500),
)
assertEquals(setOf(200L), ProfileMessageRenderer.referencedUserIds(record))
}
@Test
fun truncatesLongMessagesAfterRendering() {
val record = record("""[{"type":"PlainText","content":"1234567890"}]""")
assertEquals("12345678...[截断]", ProfileMessageRenderer.render(record, emptyMap(), 8))
}
private fun record(code: String) = ChatMessageRecord(
botId = 1,
fromId = 100,
targetId = 10,
ids = null,
internalIds = null,
time = 100,
kind = MessageSourceKind.GROUP,
code = code,
)
}
@@ -0,0 +1,91 @@
package top.jie65535.mirai.profile
import top.jie65535.mirai.data.FavorabilityInfo
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertFalse
class UserProfileContextRendererTest {
@Test
fun rendersCompactSummariesAndOnlyRelationshipsInsideCurrentConversation() {
val profile = UserProfileSnapshot(
userId = 100,
summary = "长期关注 Kotlin 开发,也经常讨论大模型应用。",
cursorTime = 1,
snapshotEndTime = 2,
reliable = true,
items = listOf(
relationship("visible", 200, "经常互相讨论技术方案"),
relationship("hidden", 300, "曾共同讨论游戏"),
),
)
val rendered = UserProfileContextRenderer.render(
profiles = listOf(profile),
favorabilityByUserId = mapOf(
100L to FavorabilityInfo(
userId = 100,
value = 12,
name = "小明代号",
tags = listOf("老群友"),
impression = "聊天很直接",
)
),
displayNames = mapOf(100L to "小明", 200L to "小王", 300L to "小李"),
activeUserIds = setOf(100, 200),
summaryMaxChars = 50,
)
assertContains(rendered, "小明代号(100)")
assertContains(rendered, "好感度+12")
assertContains(rendered, "长期认识:长期关注 Kotlin 开发")
assertContains(rendered, "与小王:经常互相讨论技术方案")
assertFalse(rendered.contains("小李"))
assertFalse(rendered.contains("曾共同讨论游戏"))
}
@Test
fun keepsFavorabilityVisibleWithoutAHistoricalProfile() {
val rendered = UserProfileContextRenderer.render(
profiles = emptyList(),
favorabilityByUserId = mapOf(
100L to FavorabilityInfo(userId = 100, value = -8, impression = "偶尔喜欢抬杠")
),
displayNames = mapOf(100L to "小明"),
activeUserIds = setOf(100),
summaryMaxChars = 300,
)
assertContains(rendered, "小明(100)")
assertContains(rendered, "好感度-8")
assertContains(rendered, "主观印象:偶尔喜欢抬杠")
assertFalse(rendered.contains("长期认识:"))
}
@Test
fun keepsImpressionVisibleWhenFavorabilityIsZero() {
val rendered = UserProfileContextRenderer.render(
profiles = emptyList(),
favorabilityByUserId = mapOf(
100L to FavorabilityInfo(userId = 100, value = 0, impression = "长期活跃的老群友")
),
displayNames = mapOf(100L to "小明"),
activeUserIds = setOf(100),
summaryMaxChars = 300,
)
assertContains(rendered, "小明(100)")
assertContains(rendered, "好感度+0")
assertContains(rendered, "主观印象:长期活跃的老群友")
}
private fun relationship(id: String, relatedUserId: Long, content: String) = UserProfileItem(
id = id,
category = ProfileCategory.RELATIONSHIP_NOTE,
content = content,
confidence = ProfileConfidence.MEDIUM,
relatedUserId = relatedUserId,
firstSeenAt = 1,
lastConfirmedAt = 1,
)
}
@@ -0,0 +1,158 @@
package top.jie65535.mirai.profile
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.assertTrue
class UserProfileReducerTest {
@Test
fun addsItemFromTargetAuthoredEvidence() {
val batch = batchOf(
message(ref = 1, fromId = TARGET, text = "我平时会写 Kotlin"),
message(ref = 2, fromId = OTHER, text = "确实"),
)
val current = emptyProfile()
val reduction = UserProfileReducer.reduce(
current = current,
batch = batch,
response = ProfileModelResponse(
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.INTEREST,
content = "持续关注 Kotlin 开发",
confidence = ProfileConfidence.MEDIUM,
evidenceRefs = listOf(1, 2),
)
),
summary = "关注 Kotlin 开发。",
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
assertEquals(1, reduction.profile.items.size)
assertEquals("持续关注 Kotlin 开发", reduction.profile.items.single().content)
assertEquals(batch.endTime, reduction.profile.cursorTime)
assertEquals("关注 Kotlin 开发。", reduction.profile.summary)
}
@Test
fun rejectsItemSupportedOnlyByAnotherUser() {
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 开发。",
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
}
assertTrue(failure.message.orEmpty().contains("目标用户"))
}
@Test
fun ignoresSummaryRewriteWhenThereIsNoValidOperation() {
val batch = batchOf(message(ref = 1, fromId = TARGET, text = "今天天气不错"))
val current = emptyProfile().copy(summary = "原摘要")
val reduction = UserProfileReducer.reduce(
current = current,
batch = batch,
response = ProfileModelResponse(summary = "凭空出现的新摘要"),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
)
assertEquals("原摘要", reduction.profile.summary)
assertEquals(0, reduction.profile.version)
assertEquals(batch.endTime, reduction.profile.cursorTime)
}
@Test
fun conversationUpdateDoesNotAdvanceHistoricalCursor() {
val batch = batchOf(message(ref = 1, fromId = TARGET, text = "我平时会写 Kotlin"))
val current = emptyProfile()
val reduction = UserProfileReducer.reduce(
current = current,
batch = batch,
response = ProfileModelResponse(
operations = listOf(
ProfileModelOperation(
action = ProfileOperationAction.ADD,
category = ProfileCategory.INTEREST,
content = "持续关注 Kotlin 开发",
confidence = ProfileConfidence.MEDIUM,
evidenceRefs = listOf(1),
)
),
summary = "关注 Kotlin 开发。",
),
model = "test-model",
promptVersion = "test-prompt",
summaryMaxLength = 500,
advanceBackfillCursor = false,
)
assertEquals(current.cursorTime, reduction.profile.cursorTime)
assertEquals(1, reduction.profile.version)
}
private fun emptyProfile() = UserProfileSnapshot(
userId = TARGET,
cursorTime = 100,
snapshotEndTime = 1_000,
)
private fun batchOf(vararg messages: ProfilePromptMessage) = ProfileHistoryBatch(
userId = TARGET,
startTime = 100,
endTime = 200,
messages = messages.toList(),
aliases = mapOf(TARGET to "TARGET", OTHER to "U1"),
inputHash = "hash",
)
private fun message(ref: Int, fromId: Long, text: String) = ProfilePromptMessage(
record = ChatMessageRecord(
botId = 1,
fromId = fromId,
targetId = 10,
ids = null,
internalIds = null,
time = 120 + ref,
kind = MessageSourceKind.GROUP,
code = text,
),
text = text,
evidenceRef = ref,
episodeIndex = 1,
)
companion object {
private const val TARGET = 100L
private const val OTHER = 200L
}
}
@@ -0,0 +1,234 @@
package top.jie65535.mirai.profile
import net.mamoe.mirai.message.data.MessageSourceKind
import top.jie65535.mirai.data.ChatMessageRecord
import java.nio.file.Files
import java.sql.DriverManager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class UserProfileStoreTest {
@Test
fun commitsProfileItemsRevisionAndWaterline() {
val directory = Files.createTempDirectory("jchatgpt-profile-test-")
try {
UserProfileStore.init(directory.toFile())
val batch = ProfileHistoryBatch(
userId = 100,
startTime = 100,
endTime = 200,
messages = listOf(
ProfilePromptMessage(
record = ChatMessageRecord(
botId = 1,
fromId = 100,
targetId = 300,
ids = null,
internalIds = null,
time = 150,
kind = MessageSourceKind.GROUP,
code = "message",
),
text = "message",
evidenceRef = 1,
episodeIndex = 1,
)
),
aliases = mapOf(100L to "TARGET"),
inputHash = "input-hash",
)
val item = UserProfileItem(
id = "item-1",
category = ProfileCategory.INTEREST,
content = "关注 Kotlin 开发",
confidence = ProfileConfidence.MEDIUM,
firstSeenAt = 150,
lastConfirmedAt = 150,
)
val profile = UserProfileSnapshot(
userId = 100,
summary = "关注 Kotlin 开发。",
version = 1,
cursorTime = 200,
snapshotEndTime = 1_000,
reliable = true,
model = "test-model",
promptVersion = "test-prompt",
updatedAt = 123,
items = listOf(item),
)
UserProfileStore.commit(
reduction = ProfileReduction(
profile = profile,
operations = listOf(
AppliedProfileOperation(
action = ProfileOperationAction.ADD,
itemId = item.id,
category = item.category,
content = item.content,
confidence = item.confidence,
relatedUserId = null,
evidenceRefs = listOf(1),
)
),
),
batch = batch,
usage = ProfileTokenUsage(100, 20, 50),
source = ProfileRevisionSource.CONVERSATION,
)
val loaded = assertNotNull(UserProfileStore.load(100))
assertEquals(200, loaded.cursorTime)
assertEquals(1_000, loaded.snapshotEndTime)
assertEquals("关注 Kotlin 开发。", loaded.summary)
assertEquals(item, loaded.items.single())
assertTrue(loaded.reliable)
assertNotNull(UserProfileStore.lastRevisionAt(100, ProfileRevisionSource.CONVERSATION))
assertTrue(UserProfileStore.isConversationProcessed("input-hash"))
} finally {
UserProfileStore.close()
directory.toFile().deleteRecursively()
}
}
@Test
fun migratesRevisionSourceColumnWithoutRebuildingExistingDatabase() {
val directory = Files.createTempDirectory("jchatgpt-profile-migration-test-")
val database = directory.resolve("user-profile.sqlite")
try {
DriverManager.getConnection("jdbc:sqlite:${database.toAbsolutePath()}").use { connection ->
connection.createStatement().use { statement ->
statement.executeUpdate(
"""
CREATE TABLE profile_revision(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
profile_version INTEGER NOT NULL,
start_time INTEGER NOT NULL,
end_time INTEGER NOT NULL,
input_hash TEXT NOT NULL,
operations_json TEXT NOT NULL,
summary TEXT NOT NULL,
model TEXT NOT NULL,
prompt_version TEXT NOT NULL,
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
cached_tokens INTEGER NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(user_id, start_time, end_time, input_hash)
)
""".trimIndent()
)
}
}
UserProfileStore.init(directory.toFile())
UserProfileStore.close()
DriverManager.getConnection("jdbc:sqlite:${database.toAbsolutePath()}").use { connection ->
val columns = connection.createStatement().use { statement ->
statement.executeQuery("PRAGMA table_info(profile_revision)").use { results ->
buildSet {
while (results.next()) add(results.getString("name"))
}
}
}
assertTrue("source" in columns)
val version = connection.createStatement().use { statement ->
statement.executeQuery(
"SELECT value FROM user_profile_meta WHERE key = 'schema_version'"
).use { results ->
assertTrue(results.next())
results.getString(1)
}
}
assertEquals("2", version)
}
} finally {
UserProfileStore.close()
directory.toFile().deleteRecursively()
}
}
@Test
fun commitsAllConversationProfilesInOneTransactionAndCountsUsageOnce() {
val directory = Files.createTempDirectory("jchatgpt-profile-conversation-commit-test-")
try {
UserProfileStore.init(directory.toFile())
val entries = listOf(100L, 200L).map { userId ->
val batch = batchFor(userId, "shared-conversation-hash")
val profile = UserProfileSnapshot(
userId = userId,
summary = "用户 $userId 的测试摘要",
version = 1,
cursorTime = 0,
snapshotEndTime = 0,
reliable = true,
model = "test-model",
promptVersion = "test-prompt",
items = listOf(itemFor("item-$userId")),
)
ProfileReduction(profile, emptyList()) to batch
}
UserProfileStore.commitConversation(entries, ProfileTokenUsage(100, 20, 50))
assertNotNull(UserProfileStore.load(100))
assertNotNull(UserProfileStore.load(200))
assertTrue(UserProfileStore.isConversationProcessed("shared-conversation-hash"))
val database = directory.resolve("user-profile.sqlite")
DriverManager.getConnection("jdbc:sqlite:${database.toAbsolutePath()}").use { connection ->
connection.createStatement().use { statement ->
statement.executeQuery(
"SELECT COUNT(*), SUM(prompt_tokens), SUM(completion_tokens) " +
"FROM profile_revision WHERE input_hash = 'shared-conversation-hash'"
).use { results ->
assertTrue(results.next())
assertEquals(2, results.getInt(1))
assertEquals(100, results.getInt(2))
assertEquals(20, results.getInt(3))
}
}
}
} finally {
UserProfileStore.close()
directory.toFile().deleteRecursively()
}
}
private fun batchFor(userId: Long, inputHash: String) = ProfileHistoryBatch(
userId = userId,
startTime = 100,
endTime = 200,
messages = listOf(
ProfilePromptMessage(
record = ChatMessageRecord(
botId = 1,
fromId = userId,
targetId = 300,
ids = null,
internalIds = null,
time = 150,
kind = MessageSourceKind.GROUP,
code = "message",
),
text = "message",
evidenceRef = 1,
episodeIndex = 1,
)
),
aliases = mapOf(userId to "TARGET"),
inputHash = inputHash,
)
private fun itemFor(id: String) = UserProfileItem(
id = id,
category = ProfileCategory.INTEREST,
content = "关注 Kotlin 开发",
confidence = ProfileConfidence.MEDIUM,
firstSeenAt = 150,
lastConfirmedAt = 150,
)
}