mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
profile: preserve valid text and evidence ranges
This commit is contained in:
@@ -7,6 +7,7 @@ object ProfileContentRules {
|
|||||||
require(content.length <= MAX_CONTENT_LENGTH) { "$label.content 超过 $MAX_CONTENT_LENGTH 字符" }
|
require(content.length <= MAX_CONTENT_LENGTH) { "$label.content 超过 $MAX_CONTENT_LENGTH 字符" }
|
||||||
require(!overclaimPattern.containsMatchIn(content)) { "$label.content 包含夸张身份或能力判断" }
|
require(!overclaimPattern.containsMatchIn(content)) { "$label.content 包含夸张身份或能力判断" }
|
||||||
require(!itemReferencePattern.containsMatchIn(content)) { "$label.content 包含临时画像条目编号" }
|
require(!itemReferencePattern.containsMatchIn(content)) { "$label.content 包含临时画像条目编号" }
|
||||||
|
require(!batchScopedPattern.containsMatchIn(content)) { "$label.content 包含批次化处理措辞" }
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ object ProfileContentRules {
|
|||||||
val summary = raw.trim()
|
val summary = raw.trim()
|
||||||
require(summary.length <= maxLength) { "画像摘要超过 $maxLength 字符" }
|
require(summary.length <= maxLength) { "画像摘要超过 $maxLength 字符" }
|
||||||
require(!itemReferencePattern.containsMatchIn(summary)) { "画像摘要包含临时画像条目编号" }
|
require(!itemReferencePattern.containsMatchIn(summary)) { "画像摘要包含临时画像条目编号" }
|
||||||
|
require(!batchScopedPattern.containsMatchIn(summary)) { "画像摘要包含批次化处理措辞" }
|
||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +23,10 @@ object ProfileContentRules {
|
|||||||
.lowercase()
|
.lowercase()
|
||||||
.replace(Regex("[\\s\\p{Punct},。;、!?()【】‘’“”]+"), "")
|
.replace(Regex("[\\s\\p{Punct},。;、!?()【】‘’“”]+"), "")
|
||||||
|
|
||||||
|
fun containsBatchScopedText(value: String): Boolean = batchScopedPattern.containsMatchIn(value)
|
||||||
|
|
||||||
private const val MAX_CONTENT_LENGTH = 120
|
private const val MAX_CONTENT_LENGTH = 120
|
||||||
private val overclaimPattern = Regex("深厚|扎实|精通|专家|导师|领袖|天才|极强|全栈|核心成员|公认")
|
private val overclaimPattern = Regex("深厚|扎实|精通|专家|导师|领袖|天才|极强|全栈|核心成员|公认")
|
||||||
private val itemReferencePattern = Regex("(?<![A-Za-z0-9_])P[1-9]\\d*(?![A-Za-z0-9_])")
|
private val itemReferencePattern = Regex("(?<![A-Za-z0-9_])P[1-9]\\d*(?![A-Za-z0-9_])")
|
||||||
|
private val batchScopedPattern = Regex("本批|本轮(?:分析|整理|对话|更新)|此次对话|本次对话|这段对话")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ object ProfileMessageRenderer {
|
|||||||
renderChain(record.toMessageChain(), aliases)
|
renderChain(record.toMessageChain(), aliases)
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
"[消息内容解析失败]"
|
"[消息内容解析失败]"
|
||||||
}.replace(Regex("[\\r\\n]+"), " ").trim()
|
}.replace(Regex("[\\r\\n]+"), " ").trim().replaceUnpairedSurrogates()
|
||||||
|
|
||||||
if (content.length <= maxChars) return content.ifEmpty { "[无文本消息]" }
|
if (content.length <= maxChars) return content.ifEmpty { "[无文本消息]" }
|
||||||
return content.take(maxChars).trimEnd() + "...[截断]"
|
return content.takeUtf16Safely(maxChars).trimEnd() + "...[截断]"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun referencedUserIds(record: ChatMessageRecord): Set<Long> = runCatching {
|
fun referencedUserIds(record: ChatMessageRecord): Set<Long> = runCatching {
|
||||||
@@ -95,7 +95,7 @@ object ProfileMessageRenderer {
|
|||||||
?.let { renderJsonMessages(it, aliases) }
|
?.let { renderJsonMessages(it, aliases) }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
.replace(Regex("[\\r\\n]+"), " ")
|
.replace(Regex("[\\r\\n]+"), " ")
|
||||||
.take(160)
|
.takeUtf16Safely(160)
|
||||||
return "[引用 $author: $original]"
|
return "[引用 $author: $original]"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ object ProfileMessageRenderer {
|
|||||||
val sender = node.string("senderName") ?: "未知用户"
|
val sender = node.string("senderName") ?: "未知用户"
|
||||||
val chain = node["messageChain"] as? JsonArray
|
val chain = node["messageChain"] as? JsonArray
|
||||||
append(' ').append(sender).append(": ")
|
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(" ...[转发内容截断]")
|
if (nodes.size > 20) append(" ...[转发内容截断]")
|
||||||
}
|
}
|
||||||
@@ -161,17 +161,60 @@ object ProfileMessageRenderer {
|
|||||||
val author = aliases[message.source.fromId] ?: "其他用户"
|
val author = aliases[message.source.fromId] ?: "其他用户"
|
||||||
val quoted = renderChain(message.source.originalMessage, aliases)
|
val quoted = renderChain(message.source.originalMessage, aliases)
|
||||||
.replace(Regex("[\\r\\n]+"), " ")
|
.replace(Regex("[\\r\\n]+"), " ")
|
||||||
.take(160)
|
.takeUtf16Safely(160)
|
||||||
"[引用 $author: $quoted]"
|
"[引用 $author: $quoted]"
|
||||||
}
|
}
|
||||||
is ForwardMessage -> buildString {
|
is ForwardMessage -> buildString {
|
||||||
append("[转发消息]")
|
append("[转发消息]")
|
||||||
message.nodeList.take(20).forEach { node ->
|
message.nodeList.take(20).forEach { node ->
|
||||||
append(" ").append(node.senderName).append(": ")
|
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(" ...[转发内容截断]")
|
if (message.nodeList.size > 20) append(" ...[转发内容截断]")
|
||||||
}
|
}
|
||||||
else -> message.content
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ object UserProfileReducer {
|
|||||||
content = content,
|
content = content,
|
||||||
confidence = confidence,
|
confidence = confidence,
|
||||||
relatedUserId = relatedUserId,
|
relatedUserId = relatedUserId,
|
||||||
lastConfirmedAt = evidenceTime,
|
firstSeenAt = minOf(old.firstSeenAt, firstEvidenceTime),
|
||||||
|
lastConfirmedAt = maxOf(old.lastConfirmedAt, evidenceTime),
|
||||||
)
|
)
|
||||||
items[old.id] = updated
|
items[old.id] = updated
|
||||||
updated.toApplied(operation.action, operation.evidenceRefs)
|
updated.toApplied(operation.action, operation.evidenceRefs)
|
||||||
@@ -136,7 +137,8 @@ object UserProfileReducer {
|
|||||||
val old = requireExistingItem(index, operation, current, items)
|
val old = requireExistingItem(index, operation, current, items)
|
||||||
val updated = old.copy(
|
val updated = old.copy(
|
||||||
confidence = operation.confidence ?: old.confidence,
|
confidence = operation.confidence ?: old.confidence,
|
||||||
lastConfirmedAt = evidenceTime,
|
firstSeenAt = minOf(old.firstSeenAt, firstEvidenceTime),
|
||||||
|
lastConfirmedAt = maxOf(old.lastConfirmedAt, evidenceTime),
|
||||||
)
|
)
|
||||||
items[old.id] = updated
|
items[old.id] = updated
|
||||||
updated.toApplied(operation.action, operation.evidenceRefs)
|
updated.toApplied(operation.action, operation.evidenceRefs)
|
||||||
|
|||||||
@@ -33,6 +33,67 @@ class ProfileMessageRendererTest {
|
|||||||
assertEquals("12345678...[截断]", ProfileMessageRenderer.render(record, emptyMap(), 8))
|
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(
|
private fun record(code: String) = ChatMessageRecord(
|
||||||
botId = 1,
|
botId = 1,
|
||||||
fromId = 100,
|
fromId = 100,
|
||||||
|
|||||||
@@ -291,6 +291,69 @@ class UserProfileReducerTest {
|
|||||||
assertEquals("原摘要", reduction.profile.summary)
|
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
|
@Test
|
||||||
fun removesTemporaryAliasesFromPersistedRelationshipText() {
|
fun removesTemporaryAliasesFromPersistedRelationshipText() {
|
||||||
val batch = batchOf(message(ref = 1, fromId = TARGET, text = "先找到下家再离职"))
|
val batch = batchOf(message(ref = 1, fromId = TARGET, text = "先找到下家再离职"))
|
||||||
|
|||||||
Reference in New Issue
Block a user