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 代号、标签和主观印象会与证据驱动的
长期画像按同一个人合并渲染,并明确给出长期画像条目数;私聊也会携带对方的可靠画像摘要和条目数。
模型需要完整细节时可主动调用 `queryUserProfile`按 QQ 号、群名片、昵称或好友备注读取完整画像。两套画像数据
仍独立保存,自动画像不会修改好感度。
模型需要完整细节时可主动调用 `queryUserProfile`但只能读取当前对话触发者本人的完整画像;其他群友仅使用
上下文中已注入的摘要。两套画像数据仍独立保存,自动画像不会修改好感度。
`queryUserProfile` 解析出唯一联系人后才按需调用一次 `queryProfile()`,并在内存中缓存 10 分钟;周期联系人
同步不会触发该调用,因此群成员总量不会放大资料卡请求数。
`queryUserProfile` 会在工具层校验查询对象;请求他人画像时固定返回“出于隐私考虑,禁止查询他人画像详情”,
且不会读取画像数据库或资料卡。查询触发者本人时才按需调用一次 `queryProfile()`,并在内存中缓存 10 分钟;
周期联系人同步不会触发该调用,因此群成员总量不会放大资料卡请求数。
好友昵称、群名片、群角色、签名、年龄等联系人公开资料只作为人物识别提示,不直接视为画像证据。画像模型
仍必须依据对应用户本人在聊天历史中的发言,才能新增或确认长期画像条目。
@@ -505,7 +506,7 @@ fallbackCooldownMinutes: 5
12. **WeatherService** - 天气查询
13. **SearchChatHistory** - 在当前群聊或私聊中使用 SQLite 全文索引搜索聊天历史,支持发送者、时间范围和游标翻页
14. **GetChatHistoryContext** - 根据历史消息 ID 获取目标消息前后的对话上下文
15. **QueryUserProfile** - 按当前发送者、QQ 号、群名片、昵称或好友备注读取长期画像,并针对该联系人按需读取公开资料卡
15. **QueryUserProfile** - 仅读取当前对话触发者本人的长期画像,并按需读取公开资料卡;禁止查询他人详情
`SearchChatHistory` 的常用参数只有消息内容、发送者、时间范围和翻页游标;普通文字和可解析的 `@` 目标会合并检索,结果默认按最近消息返回,
单页大小和匹配策略由插件控制。`senderId` 与 `senderName` 可以同时提供,此时 QQ 号作为精确筛选条件;有 QQ 号时名称不参与筛选。
@@ -541,7 +542,7 @@ JChatGPT 维护对每位用户的画像,由好感度、Bot 自定义代号、
### 注入到上下文
- 群聊:合并列出当前相关群友的关系状态,以及可靠长期画像的摘要和条目数
- 私聊:合并注入对方的关系状态,以及可靠长期画像的摘要和条目数
- 模型需要画像明细时主动调用 `queryUserProfile`,查询始终返回完整条目,不必把全部条目常驻在上下文中
- 模型需要当前触发者本人的画像明细时主动调用 `queryUserProfile`;其他人的详情禁止查询,正常对话使用摘要
- 只有好感度数值、其它关系字段和可靠长期画像均为空的用户不会被列出,避免提示词噪声
### 配置选项
@@ -68,7 +68,7 @@ object UserProfileContextRenderer {
.append(summary.normalized().take(maxChars))
} ?: profile?.takeIf { it.items.isNotEmpty() }?.let {
append(" | 画像认识:已有").append(it.items.size)
.append("条记录,可用 queryUserProfile 查询详情")
.append("条记录(摘要暂缺)")
}
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.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull
import net.mamoe.mirai.contact.User
@@ -35,24 +33,8 @@ import java.util.concurrent.ConcurrentHashMap
class QueryUserProfileAgent : BaseAgent(
tool = Tool.function(
name = "queryUserProfile",
description = "查询你已归纳的用户画像,并按需读取该联系人的公开资料卡。用于了解某个群友/好友的长期信息、兴趣、表达方式、关系备注。默认查询当前对话发送者;在群聊中也可用QQ号、群名片或昵称查询别人",
parameters = Parameters.buildJsonObject {
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")
}
}
}
description = "查询本轮触发消息发送者的完整用户画像及公开资料卡",
parameters = Parameters.Empty,
)
) {
override val isEnabled: Boolean
@@ -62,8 +44,15 @@ class QueryUserProfileAgent : BaseAgent(
get() = "查询用户画像中..."
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
val userId = resolveUserId(args, event)
?: return "未找到唯一匹配用户。请提供 userId,或使用更明确的群名片、昵称、好友备注。"
val requestedUserId = if (hasLegacyProfileTarget(args)) {
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 snapshot = UserProfileStore.load(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+"), " ")
}
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(
items: List<UserProfileItem>,
): List<UserProfileItem> = items.sortedWith(
@@ -1,13 +1,70 @@
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.ProfileConfidence
import top.jie65535.mirai.profile.ProfileItemSupportStats
import top.jie65535.mirai.profile.UserProfileItem
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
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
fun returnsAllItemsInStableOrder() {
val items = listOf(