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
@@ -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,
)
}