profile: restrict detail queries to trigger sender

This commit is contained in:
2026-08-07 17:44:28 +08:00
parent f508373d02
commit 6e59622ebc
4 changed files with 94 additions and 30 deletions
+8 -7
View File
@@ -41,7 +41,7 @@ AI 可以自动调用多种工具来完成复杂任务:
- 记忆管理(添加和修改对话记忆) - 记忆管理(添加和修改对话记忆)
- 技能管理(沉淀、加载、迭代、删除可复用知识技能) - 技能管理(沉淀、加载、迭代、删除可复用知识技能)
- 聊天历史搜索(按关键词、发送者、时间范围检索群聊消息,需启用历史消息上下文) - 聊天历史搜索(按关键词、发送者、时间范围检索群聊消息,需启用历史消息上下文)
- 用户画像查询(按当前发送者、QQ号或群名片读取已归纳的长期画像) - 用户画像查询(仅允许读取当前对话触发者本人的长期画像详情
## 权限列表 ## 权限列表
@@ -282,11 +282,12 @@ searchHistoryMaxRecords: 5000
下一次正常群聊会自动携带触发者和最近发言者的认识。现有好感度、Bot 代号、标签和主观印象会与证据驱动的 下一次正常群聊会自动携带触发者和最近发言者的认识。现有好感度、Bot 代号、标签和主观印象会与证据驱动的
长期画像按同一个人合并渲染,并明确给出长期画像条目数;私聊也会携带对方的可靠画像摘要和条目数。 长期画像按同一个人合并渲染,并明确给出长期画像条目数;私聊也会携带对方的可靠画像摘要和条目数。
模型需要完整细节时可主动调用 `queryUserProfile`按 QQ 号、群名片、昵称或好友备注读取完整画像。两套画像数据 模型需要完整细节时可主动调用 `queryUserProfile`但只能读取当前对话触发者本人的完整画像;其他群友仅使用
仍独立保存,自动画像不会修改好感度。 上下文中已注入的摘要。两套画像数据仍独立保存,自动画像不会修改好感度。
`queryUserProfile` 解析出唯一联系人后才按需调用一次 `queryProfile()`,并在内存中缓存 10 分钟;周期联系人 `queryUserProfile` 会在工具层校验查询对象;请求他人画像时固定返回“出于隐私考虑,禁止查询他人画像详情”,
同步不会触发该调用,因此群成员总量不会放大资料卡请求数。 且不会读取画像数据库或资料卡。查询触发者本人时才按需调用一次 `queryProfile()`,并在内存中缓存 10 分钟;
周期联系人同步不会触发该调用,因此群成员总量不会放大资料卡请求数。
好友昵称、群名片、群角色、签名、年龄等联系人公开资料只作为人物识别提示,不直接视为画像证据。画像模型 好友昵称、群名片、群角色、签名、年龄等联系人公开资料只作为人物识别提示,不直接视为画像证据。画像模型
仍必须依据对应用户本人在聊天历史中的发言,才能新增或确认长期画像条目。 仍必须依据对应用户本人在聊天历史中的发言,才能新增或确认长期画像条目。
@@ -505,7 +506,7 @@ fallbackCooldownMinutes: 5
12. **WeatherService** - 天气查询 12. **WeatherService** - 天气查询
13. **SearchChatHistory** - 在当前群聊或私聊中使用 SQLite 全文索引搜索聊天历史,支持发送者、时间范围和游标翻页 13. **SearchChatHistory** - 在当前群聊或私聊中使用 SQLite 全文索引搜索聊天历史,支持发送者、时间范围和游标翻页
14. **GetChatHistoryContext** - 根据历史消息 ID 获取目标消息前后的对话上下文 14. **GetChatHistoryContext** - 根据历史消息 ID 获取目标消息前后的对话上下文
15. **QueryUserProfile** - 按当前发送者、QQ 号、群名片、昵称或好友备注读取长期画像,并针对该联系人按需读取公开资料卡 15. **QueryUserProfile** - 仅读取当前对话触发者本人的长期画像,并按需读取公开资料卡;禁止查询他人详情
`SearchChatHistory` 的常用参数只有消息内容、发送者、时间范围和翻页游标;普通文字和可解析的 `@` 目标会合并检索,结果默认按最近消息返回, `SearchChatHistory` 的常用参数只有消息内容、发送者、时间范围和翻页游标;普通文字和可解析的 `@` 目标会合并检索,结果默认按最近消息返回,
单页大小和匹配策略由插件控制。`senderId` 与 `senderName` 可以同时提供,此时 QQ 号作为精确筛选条件;有 QQ 号时名称不参与筛选。 单页大小和匹配策略由插件控制。`senderId` 与 `senderName` 可以同时提供,此时 QQ 号作为精确筛选条件;有 QQ 号时名称不参与筛选。
@@ -541,7 +542,7 @@ JChatGPT 维护对每位用户的画像,由好感度、Bot 自定义代号、
### 注入到上下文 ### 注入到上下文
- 群聊:合并列出当前相关群友的关系状态,以及可靠长期画像的摘要和条目数 - 群聊:合并列出当前相关群友的关系状态,以及可靠长期画像的摘要和条目数
- 私聊:合并注入对方的关系状态,以及可靠长期画像的摘要和条目数 - 私聊:合并注入对方的关系状态,以及可靠长期画像的摘要和条目数
- 模型需要画像明细时主动调用 `queryUserProfile`,查询始终返回完整条目,不必把全部条目常驻在上下文中 - 模型需要当前触发者本人的画像明细时主动调用 `queryUserProfile`;其他人的详情禁止查询,正常对话使用摘要
- 只有好感度数值、其它关系字段和可靠长期画像均为空的用户不会被列出,避免提示词噪声 - 只有好感度数值、其它关系字段和可靠长期画像均为空的用户不会被列出,避免提示词噪声
### 配置选项 ### 配置选项
@@ -68,7 +68,7 @@ object UserProfileContextRenderer {
.append(summary.normalized().take(maxChars)) .append(summary.normalized().take(maxChars))
} ?: profile?.takeIf { it.items.isNotEmpty() }?.let { } ?: profile?.takeIf { it.items.isNotEmpty() }?.let {
append(" | 画像认识:已有").append(it.items.size) append(" | 画像认识:已有").append(it.items.size)
.append("条记录,可用 queryUserProfile 查询详情") .append("条记录(摘要暂缺)")
} }
profile?.items?.asSequence() profile?.items?.asSequence()
+28 -22
View File
@@ -7,8 +7,6 @@ import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import net.mamoe.mirai.contact.User import net.mamoe.mirai.contact.User
@@ -35,24 +33,8 @@ import java.util.concurrent.ConcurrentHashMap
class QueryUserProfileAgent : BaseAgent( class QueryUserProfileAgent : BaseAgent(
tool = Tool.function( tool = Tool.function(
name = "queryUserProfile", name = "queryUserProfile",
description = "查询你已归纳的用户画像,并按需读取该联系人的公开资料卡。用于了解某个群友/好友的长期信息、兴趣、表达方式、关系备注。默认查询当前对话发送者;在群聊中也可用QQ号、群名片或昵称查询别人", description = "查询本轮触发消息发送者的完整用户画像及公开资料卡",
parameters = Parameters.buildJsonObject { parameters = Parameters.Empty,
put("type", "object")
putJsonObject("properties") {
putJsonObject("userId") {
put("type", "integer")
put("description", "要查询的用户QQ号;留空时查询当前发送者")
}
putJsonObject("name") {
put("type", "string")
put("description", "群名片、昵称或好友备注关键词;仅在未填写userId时用于群聊中匹配用户")
}
putJsonObject("includeItems") {
put("type", "boolean")
put("description", "是否返回画像条目明细,默认true")
}
}
}
) )
) { ) {
override val isEnabled: Boolean override val isEnabled: Boolean
@@ -62,8 +44,15 @@ class QueryUserProfileAgent : BaseAgent(
get() = "查询用户画像中..." get() = "查询用户画像中..."
override suspend fun execute(args: JsonObject?, event: MessageEvent): String { override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
val userId = resolveUserId(args, event) val requestedUserId = if (hasLegacyProfileTarget(args)) {
?: return "未找到唯一匹配用户。请提供 userId,或使用更明确的群名片、昵称、好友备注。" runCatching { resolveUserId(args, event) }.getOrNull()
} else {
null
}
if (!isOwnProfileQuery(args, event.sender.id, requestedUserId)) {
return PROFILE_QUERY_PRIVACY_REFUSAL
}
val userId = event.sender.id
val (profile, supportStats) = runCatching { val (profile, supportStats) = runCatching {
val snapshot = UserProfileStore.load(userId) val snapshot = UserProfileStore.load(userId)
snapshot to if (snapshot == null) emptyMap() else UserProfileStore.loadSupportStats(userId) snapshot to if (snapshot == null) emptyMap() else UserProfileStore.loadSupportStats(userId)
@@ -268,6 +257,23 @@ class QueryUserProfileAgent : BaseAgent(
private fun String.normalized(): String = trim().replace(Regex("\\s+"), " ") private fun String.normalized(): String = trim().replace(Regex("\\s+"), " ")
} }
internal const val PROFILE_QUERY_PRIVACY_REFUSAL = "出于隐私考虑,禁止查询他人画像详情"
internal fun isOwnProfileQuery(
args: JsonObject?,
senderId: Long,
requestedUserId: Long?,
): Boolean {
if (args?.keys?.any { it !in LEGACY_PROFILE_QUERY_ARGUMENTS } == true) return false
return !hasLegacyProfileTarget(args) || requestedUserId == senderId
}
private val LEGACY_PROFILE_QUERY_ARGUMENTS = setOf("userId", "name", "includeItems")
private val LEGACY_PROFILE_TARGET_ARGUMENTS = setOf("userId", "name")
private fun hasLegacyProfileTarget(args: JsonObject?): Boolean =
args?.keys?.any { it in LEGACY_PROFILE_TARGET_ARGUMENTS } == true
internal fun selectProfileItems( internal fun selectProfileItems(
items: List<UserProfileItem>, items: List<UserProfileItem>,
): List<UserProfileItem> = items.sortedWith( ): List<UserProfileItem> = items.sortedWith(
@@ -1,13 +1,70 @@
package top.jie65535.mirai.tools package top.jie65535.mirai.tools
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import top.jie65535.mirai.profile.ProfileCategory import top.jie65535.mirai.profile.ProfileCategory
import top.jie65535.mirai.profile.ProfileConfidence import top.jie65535.mirai.profile.ProfileConfidence
import top.jie65535.mirai.profile.ProfileItemSupportStats import top.jie65535.mirai.profile.ProfileItemSupportStats
import top.jie65535.mirai.profile.UserProfileItem import top.jie65535.mirai.profile.UserProfileItem
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class QueryUserProfileAgentTest { class QueryUserProfileAgentTest {
@Test
fun exposesNoTargetParametersToTheModel() {
val function = QueryUserProfileAgent().tool.function
val schema = requireNotNull(function.parameters).schema.jsonObject
assertEquals("查询本轮触发消息发送者的完整用户画像及公开资料卡。", function.description)
assertTrue(schema.getValue("properties").jsonObject.isEmpty())
}
@Test
fun onlyAllowsTheTriggeringUsersProfile() {
val senderId = 100L
assertTrue(isOwnProfileQuery(null, senderId, requestedUserId = null))
assertTrue(
isOwnProfileQuery(
buildJsonObject { put("includeItems", false) },
senderId,
requestedUserId = null,
)
)
assertTrue(
isOwnProfileQuery(
buildJsonObject { put("userId", senderId) },
senderId,
requestedUserId = senderId,
)
)
assertFalse(
isOwnProfileQuery(
buildJsonObject { put("userId", 200L) },
senderId,
requestedUserId = 200L,
)
)
assertFalse(
isOwnProfileQuery(
buildJsonObject { put("name", "小李") },
senderId,
requestedUserId = null,
)
)
assertFalse(
isOwnProfileQuery(
buildJsonObject { put("qq", 200L) },
senderId,
requestedUserId = null,
)
)
assertEquals("出于隐私考虑,禁止查询他人画像详情", PROFILE_QUERY_PRIVACY_REFUSAL)
}
@Test @Test
fun returnsAllItemsInStableOrder() { fun returnsAllItemsInStableOrder() {
val items = listOf( val items = listOf(