mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user