From 0dd221a80fea5b73335eb166a8c0d1a63ac7d726 Mon Sep 17 00:00:00 2001 From: jie65535 Date: Wed, 5 Aug 2026 00:05:01 +0800 Subject: [PATCH] profile: preserve valid text and evidence ranges --- .../kotlin/profile/ProfileContentRules.kt | 5 ++ .../kotlin/profile/ProfileMessageRenderer.kt | 55 ++++++++++++++-- src/main/kotlin/profile/UserProfileReducer.kt | 6 +- .../profile/ProfileMessageRendererTest.kt | 61 ++++++++++++++++++ .../kotlin/profile/UserProfileReducerTest.kt | 63 +++++++++++++++++++ 5 files changed, 182 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/profile/ProfileContentRules.kt b/src/main/kotlin/profile/ProfileContentRules.kt index afa1f2d..6ecef55 100644 --- a/src/main/kotlin/profile/ProfileContentRules.kt +++ b/src/main/kotlin/profile/ProfileContentRules.kt @@ -7,6 +7,7 @@ object ProfileContentRules { require(content.length <= MAX_CONTENT_LENGTH) { "$label.content 超过 $MAX_CONTENT_LENGTH 字符" } require(!overclaimPattern.containsMatchIn(content)) { "$label.content 包含夸张身份或能力判断" } require(!itemReferencePattern.containsMatchIn(content)) { "$label.content 包含临时画像条目编号" } + require(!batchScopedPattern.containsMatchIn(content)) { "$label.content 包含批次化处理措辞" } return content } @@ -14,6 +15,7 @@ object ProfileContentRules { val summary = raw.trim() require(summary.length <= maxLength) { "画像摘要超过 $maxLength 字符" } require(!itemReferencePattern.containsMatchIn(summary)) { "画像摘要包含临时画像条目编号" } + require(!batchScopedPattern.containsMatchIn(summary)) { "画像摘要包含批次化处理措辞" } return summary } @@ -21,7 +23,10 @@ object ProfileContentRules { .lowercase() .replace(Regex("[\\s\\p{Punct},。;、!?()【】‘’“”]+"), "") + fun containsBatchScopedText(value: String): Boolean = batchScopedPattern.containsMatchIn(value) + private const val MAX_CONTENT_LENGTH = 120 private val overclaimPattern = Regex("深厚|扎实|精通|专家|导师|领袖|天才|极强|全栈|核心成员|公认") private val itemReferencePattern = Regex("(? = runCatching { @@ -95,7 +95,7 @@ object ProfileMessageRenderer { ?.let { renderJsonMessages(it, aliases) } .orEmpty() .replace(Regex("[\\r\\n]+"), " ") - .take(160) + .takeUtf16Safely(160) return "[引用 $author: $original]" } @@ -107,7 +107,7 @@ object ProfileMessageRenderer { val sender = node.string("senderName") ?: "未知用户" val chain = node["messageChain"] as? JsonArray append(' ').append(sender).append(": ") - append(chain?.let { renderJsonMessages(it, aliases) }.orEmpty().take(200)) + append(chain?.let { renderJsonMessages(it, aliases) }.orEmpty().takeUtf16Safely(200)) } if (nodes.size > 20) append(" ...[转发内容截断]") } @@ -161,17 +161,60 @@ object ProfileMessageRenderer { val author = aliases[message.source.fromId] ?: "其他用户" val quoted = renderChain(message.source.originalMessage, aliases) .replace(Regex("[\\r\\n]+"), " ") - .take(160) + .takeUtf16Safely(160) "[引用 $author: $quoted]" } is ForwardMessage -> buildString { append("[转发消息]") message.nodeList.take(20).forEach { node -> append(" ").append(node.senderName).append(": ") - append(renderChain(node.messageChain, aliases).replace(Regex("[\\r\\n]+"), " ").take(200)) + append( + renderChain(node.messageChain, aliases) + .replace(Regex("[\\r\\n]+"), " ") + .takeUtf16Safely(200) + ) } if (message.nodeList.size > 20) append(" ...[转发内容截断]") } else -> message.content } + + private fun String.takeUtf16Safely(maxLength: Int): String { + require(maxLength >= 0) { "maxLength must not be negative" } + if (length <= maxLength) return this + val endIndex = if (maxLength > 0 && + Character.isHighSurrogate(this[maxLength - 1]) && + Character.isLowSurrogate(this[maxLength]) + ) { + maxLength - 1 + } else { + maxLength + } + return substring(0, endIndex) + } + + private fun String.replaceUnpairedSurrogates(): String { + var output: StringBuilder? = null + var index = 0 + while (index < length) { + val current = this[index] + when { + Character.isHighSurrogate(current) && + index + 1 < length && Character.isLowSurrogate(this[index + 1]) -> { + output?.append(current)?.append(this[index + 1]) + index += 2 + } + Character.isSurrogate(current) -> { + if (output == null) output = StringBuilder(length).append(this, 0, index) + output.append('\uFFFD') + index++ + } + else -> { + output?.append(current) + index++ + } + } + } + return output?.toString() ?: this + } } diff --git a/src/main/kotlin/profile/UserProfileReducer.kt b/src/main/kotlin/profile/UserProfileReducer.kt index 200f1ca..d30b0d5 100644 --- a/src/main/kotlin/profile/UserProfileReducer.kt +++ b/src/main/kotlin/profile/UserProfileReducer.kt @@ -126,7 +126,8 @@ object UserProfileReducer { content = content, confidence = confidence, relatedUserId = relatedUserId, - lastConfirmedAt = evidenceTime, + firstSeenAt = minOf(old.firstSeenAt, firstEvidenceTime), + lastConfirmedAt = maxOf(old.lastConfirmedAt, evidenceTime), ) items[old.id] = updated updated.toApplied(operation.action, operation.evidenceRefs) @@ -136,7 +137,8 @@ object UserProfileReducer { val old = requireExistingItem(index, operation, current, items) val updated = old.copy( confidence = operation.confidence ?: old.confidence, - lastConfirmedAt = evidenceTime, + firstSeenAt = minOf(old.firstSeenAt, firstEvidenceTime), + lastConfirmedAt = maxOf(old.lastConfirmedAt, evidenceTime), ) items[old.id] = updated updated.toApplied(operation.action, operation.evidenceRefs) diff --git a/src/test/kotlin/profile/ProfileMessageRendererTest.kt b/src/test/kotlin/profile/ProfileMessageRendererTest.kt index dee14bf..bfb0231 100644 --- a/src/test/kotlin/profile/ProfileMessageRendererTest.kt +++ b/src/test/kotlin/profile/ProfileMessageRendererTest.kt @@ -33,6 +33,67 @@ class ProfileMessageRendererTest { assertEquals("12345678...[截断]", ProfileMessageRenderer.render(record, emptyMap(), 8)) } + @Test + fun doesNotSplitSurrogatePairsAtTruncationBoundaries() { + val emoji = "\uD83D\uDE00" + val finalText = "a".repeat(7) + emoji + "tail" + assertEquals( + "a".repeat(7) + "...[截断]", + ProfileMessageRenderer.render( + record("""[{"type":"PlainText","content":"$finalText"}]"""), + emptyMap(), + 8, + ), + ) + + val quotedText = "q".repeat(159) + emoji + "tail" + assertEquals( + "[引用 U1: ${"q".repeat(159)}]", + ProfileMessageRenderer.render( + record( + """ + [{"type":"QuoteReply","source":{"fromId":200,"originalMessage":[ + {"type":"PlainText","content":"$quotedText"} + ]}}] + """.trimIndent() + ), + mapOf(200L to "U1"), + 1_000, + ), + ) + + val forwardedText = "f".repeat(199) + emoji + "tail" + assertEquals( + "[转发消息] sender: ${"f".repeat(199)}", + ProfileMessageRenderer.render( + record( + """ + [{"type":"ForwardMessage","nodeList":[{ + "senderName":"sender", + "messageChain":[{"type":"PlainText","content":"$forwardedText"}] + }]}] + """.trimIndent() + ), + emptyMap(), + 1_000, + ), + ) + } + + @Test + fun replacesUnpairedSurrogatesBeforeBuildingModelPrompts() { + val loneHighSurrogate = "\uD83D" + + assertEquals( + "prefix\uFFFDsuffix", + ProfileMessageRenderer.render( + record("""[{"type":"PlainText","content":"prefix${loneHighSurrogate}suffix"}]"""), + emptyMap(), + 1_000, + ), + ) + } + private fun record(code: String) = ChatMessageRecord( botId = 1, fromId = 100, diff --git a/src/test/kotlin/profile/UserProfileReducerTest.kt b/src/test/kotlin/profile/UserProfileReducerTest.kt index c382702..8391dae 100644 --- a/src/test/kotlin/profile/UserProfileReducerTest.kt +++ b/src/test/kotlin/profile/UserProfileReducerTest.kt @@ -291,6 +291,69 @@ class UserProfileReducerTest { assertEquals("原摘要", reduction.profile.summary) } + @Test + fun rejectsBatchScopedPersistentText() { + val current = emptyProfile().copy(summary = "原摘要") + val batch = batchOf(message(ref = 1, fromId = TARGET, text = "喵喵喵")) + + val reduction = UserProfileReducer.reduce( + current = current, + batch = batch, + response = ProfileModelResponse( + operations = listOf( + ProfileModelOperation( + action = ProfileOperationAction.ADD, + category = ProfileCategory.EXPRESSION_STYLE, + content = "本批多次使用拟猫化措辞", + confidence = ProfileConfidence.LOW, + evidenceRefs = listOf(1), + ) + ), + summary = "本批主要使用拟猫化措辞。", + ), + model = "test-model", + promptVersion = "test-prompt", + summaryMaxLength = 500, + ) + + assertTrue(reduction.operations.isEmpty()) + assertTrue(reduction.skippedOperations.single().contains("批次化")) + assertEquals("原摘要", reduction.profile.summary) + } + + @Test + fun keepsEvidenceRangeMonotonicWhenOlderBatchUpdatesExistingItem() { + val old = existingItem().copy( + firstSeenAt = 150, + lastConfirmedAt = 180, + ) + val current = emptyProfile().copy(items = listOf(old), 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.UPDATE, + itemRef = "P1", + content = "关注 Kotlin 开发", + evidenceRefs = listOf(1), + ) + ), + summary = "关注 Kotlin 开发。", + ), + model = "test-model", + promptVersion = "test-prompt", + summaryMaxLength = 500, + ) + + val updated = reduction.profile.items.single() + assertEquals(121, updated.firstSeenAt) + assertEquals(180, updated.lastConfirmedAt) + } + @Test fun removesTemporaryAliasesFromPersistedRelationshipText() { val batch = batchOf(message(ref = 1, fromId = TARGET, text = "先找到下家再离职"))