diff --git a/README.md b/README.md index fbf6134..61b2f27 100644 --- a/README.md +++ b/README.md @@ -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`;其他人的详情禁止查询,正常对话使用摘要 - 只有好感度数值、其它关系字段和可靠长期画像均为空的用户不会被列出,避免提示词噪声 ### 配置选项 diff --git a/src/main/kotlin/profile/UserProfileContextRenderer.kt b/src/main/kotlin/profile/UserProfileContextRenderer.kt index 3e862a5..49bfac6 100644 --- a/src/main/kotlin/profile/UserProfileContextRenderer.kt +++ b/src/main/kotlin/profile/UserProfileContextRenderer.kt @@ -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() diff --git a/src/main/kotlin/tools/QueryUserProfileAgent.kt b/src/main/kotlin/tools/QueryUserProfileAgent.kt index 43c318b..0093506 100644 --- a/src/main/kotlin/tools/QueryUserProfileAgent.kt +++ b/src/main/kotlin/tools/QueryUserProfileAgent.kt @@ -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, ): List = items.sortedWith( diff --git a/src/test/kotlin/tools/QueryUserProfileAgentTest.kt b/src/test/kotlin/tools/QueryUserProfileAgentTest.kt index 92885d6..6f705b2 100644 --- a/src/test/kotlin/tools/QueryUserProfileAgentTest.kt +++ b/src/test/kotlin/tools/QueryUserProfileAgentTest.kt @@ -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(