diff --git a/README.md b/README.md index 4f6538d..d24ba02 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ AI 可以自动调用多种工具来完成复杂任务: - `/jgpt profileAnalyze [batches]` - 分析用户画像,多个 ID 用逗号分隔 - `/jgpt profileAnalyzeGroup [groupIds] [batches]` - 分析群画像,多个 ID 用逗号分隔;不传群号时并发推进历史库中的全部群 - `/jgpt profileShow ` - 查看用户画像 -- `/jgpt profileCompact [userIds]` - 压缩画像,不传 ID 时处理全部用户 +- `/jgpt profileCompact [userIds]` - 压缩画像;不传 ID 时仅批量处理至少 10 条画像条目的用户并汇总回报,显式指定 ID 时不受门槛限制 - `/jgpt profileStop` - 当前批次完成后停止画像任务 ## 配置文件 diff --git a/src/main/kotlin/command/PluginCommands.kt b/src/main/kotlin/command/PluginCommands.kt index 32f597f..bc37e3a 100644 --- a/src/main/kotlin/command/PluginCommands.kt +++ b/src/main/kotlin/command/PluginCommands.kt @@ -35,6 +35,7 @@ import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong object PluginCommands : CompositeCommand( JChatGPT, "jgpt", description = "J OpenAI ChatGPT" @@ -200,31 +201,109 @@ object PluginCommands : CompositeCommand( suspend fun CommandSender.profileCompact(userIds: String = "") { check(PluginConfig.profileEnabled) { "历史用户画像分析未启用" } check(UserProfileStore.isAvailable) { "用户画像数据库不可用" } - val parsedUserIds = if (userIds.isBlank()) { - withContext(Dispatchers.IO) { UserProfileStore.listUserIds() } + val compactAllUsers = userIds.isBlank() + val (parsedUserIds, skippedBelowThreshold) = if (compactAllUsers) { + withContext(Dispatchers.IO) { + val allUserIds = UserProfileStore.listUserIds() + val candidates = UserProfileStore.listUserIdsWithMinimumItems( + BULK_PROFILE_COMPACTION_MIN_ITEMS + ) + candidates to (allUserIds.size - candidates.size) + } } else { - parseProfileUserIds(userIds) + parseProfileUserIds(userIds) to 0 } if (parsedUserIds.isEmpty()) { - sendMessage("当前没有可压缩的用户画像。") + val skipped = if (compactAllUsers && skippedBelowThreshold > 0) { + ",已跳过 $skippedBelowThreshold 个不足 $BULK_PROFILE_COMPACTION_MIN_ITEMS 条的画像" + } else { + "" + } + sendMessage("当前没有达到压缩门槛的用户画像$skipped。") return } val runToken = UserProfileAnalysisService.newRunToken() - sendMessage("已启动 ${parsedUserIds.size} 个用户的画像压缩反思。") + if (compactAllUsers) { + sendMessage( + "已启动 ${parsedUserIds.size} 个用户的画像压缩反思;" + + "全量门槛为至少 $BULK_PROFILE_COMPACTION_MIN_ITEMS 条," + + "已跳过 $skippedBelowThreshold 个未达门槛的画像。" + ) + } else { + sendMessage("已启动 ${parsedUserIds.size} 个指定用户的画像压缩反思。") + } + + val completedUsers = AtomicInteger() + val successfulUsers = AtomicInteger() + val changedUsers = AtomicInteger() + val alreadyRunningUsers = AtomicInteger() + val stoppedUsers = AtomicInteger() + val failedUsers = AtomicInteger() + val beforeItems = AtomicLong() + val afterItems = AtomicLong() + val mergedGroups = AtomicLong() + val rewrittenItems = AtomicLong() + val deletedItems = AtomicLong() + val repairedRanges = AtomicLong() + val promptTokens = AtomicLong() + val completionTokens = AtomicLong() + val cachedTokens = AtomicLong() parsedUserIds.forEach { userId -> JChatGPT.launch { try { val report = UserProfileAnalysisService.compact(userId, runToken) when { - report.alreadyRunning -> sendMessage("用户 $userId 已有画像压缩任务在运行。") - report.stopped -> sendMessage("用户 $userId 的画像压缩已按请求停止,未开始新的压缩轮次。") - else -> sendMessage(formatProfileCompactionReport(report)) + report.alreadyRunning -> { + alreadyRunningUsers.incrementAndGet() + if (!compactAllUsers) sendMessage("用户 $userId 已有画像压缩任务在运行。") + } + report.stopped -> { + stoppedUsers.incrementAndGet() + if (!compactAllUsers) { + sendMessage("用户 $userId 的画像压缩已按请求停止,未开始新的压缩轮次。") + } + } + else -> { + successfulUsers.incrementAndGet() + if (report.hasCompactionChanges()) changedUsers.incrementAndGet() + beforeItems.addAndGet(report.beforeItems.toLong()) + afterItems.addAndGet(report.afterItems.toLong()) + mergedGroups.addAndGet(report.mergedGroups.toLong()) + rewrittenItems.addAndGet(report.rewrittenItems.toLong()) + deletedItems.addAndGet(report.deletedItems.toLong()) + repairedRanges.addAndGet(report.repairedItemRanges.toLong()) + promptTokens.addAndGet(report.usage.promptTokens.toLong()) + completionTokens.addAndGet(report.usage.completionTokens.toLong()) + cachedTokens.addAndGet(report.usage.cachedTokens.toLong()) + if (!compactAllUsers) sendMessage(formatProfileCompactionReport(report)) + } } } catch (cause: CancellationException) { throw cause } catch (cause: Exception) { + failedUsers.incrementAndGet() JChatGPT.logger.error("用户 $userId 画像压缩失败", cause) - sendMessage("用户 $userId 画像压缩失败:${cause.message ?: cause::class.simpleName}") + if (!compactAllUsers) { + sendMessage("用户 $userId 画像压缩失败:${cause.message ?: cause::class.simpleName}") + } + } finally { + if (compactAllUsers && completedUsers.incrementAndGet() == parsedUserIds.size) { + val successful = successfulUsers.get() + sendMessage( + "全量画像压缩完成:成功 $successful(有变更 ${changedUsers.get()}," + + "无变更 ${successful - changedUsers.get()})," + + "已在运行 ${alreadyRunningUsers.get()},已停止 ${stoppedUsers.get()}," + + "失败 ${failedUsers.get()};跳过未达门槛 $skippedBelowThreshold。\n" + + "条目 ${formatNumber(beforeItems.get())} -> ${formatNumber(afterItems.get())}," + + "合并 ${formatNumber(mergedGroups.get())} 组," + + "改写 ${formatNumber(rewrittenItems.get())} 条," + + "删除 ${formatNumber(deletedItems.get())} 条," + + "修复 ${formatNumber(repairedRanges.get())} 条时间范围。\n" + + "Token:输入 ${formatNumber(promptTokens.get())}," + + "输出 ${formatNumber(completionTokens.get())}," + + "缓存命中 ${formatNumber(cachedTokens.get())}" + ) + } } } } @@ -460,6 +539,7 @@ object PluginCommands : CompositeCommand( appendLine( "画像压缩完成:${report.beforeItems} -> ${report.afterItems} 条," + "合并 ${report.mergedGroups} 组,改写 ${report.rewrittenItems} 条,删除 ${report.deletedItems} 条," + + "修复 ${report.repairedItemRanges} 条时间范围," + "摘要${if (report.summaryChanged) "已重写" else "未变"}," + "跳过 ${report.skippedOperations} 项不安全建议" ) @@ -471,6 +551,10 @@ object PluginCommands : CompositeCommand( append(formatProfile(report.profile)) }.trim() + private fun ProfileCompactionReport.hasCompactionChanges(): Boolean = + beforeItems != afterItems || mergedGroups > 0 || rewrittenItems > 0 || deletedItems > 0 || + repairedItemRanges > 0 || summaryChanged + private fun formatProfile(profile: UserProfileSnapshot): String = buildString { appendLine("用户 ${profile.userId} · 画像 v${profile.version}") if (profile.cursorTime <= 0) { @@ -519,6 +603,7 @@ object PluginCommands : CompositeCommand( // 常量定义 private const val TOP_LIMIT = 5 +private const val BULK_PROFILE_COMPACTION_MIN_ITEMS = 10 private val PROFILE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter .ofPattern("yyyy-MM-dd HH:mm:ss") .withZone(ZoneId.systemDefault()) diff --git a/src/main/kotlin/profile/ProfileCompactionModels.kt b/src/main/kotlin/profile/ProfileCompactionModels.kt index 9eb5724..bf6d279 100644 --- a/src/main/kotlin/profile/ProfileCompactionModels.kt +++ b/src/main/kotlin/profile/ProfileCompactionModels.kt @@ -65,6 +65,7 @@ data class ProfileCompactionPlan( val mergedGroups: Int, val rewrittenItems: Int, val deletedItems: Int, + val repairedItemRanges: Int = 0, val skippedOperations: List = emptyList(), ) @@ -75,6 +76,7 @@ data class ProfileCompactionReport( val mergedGroups: Int, val rewrittenItems: Int, val deletedItems: Int, + val repairedItemRanges: Int = 0, val summaryChanged: Boolean, val skippedOperations: Int, val usage: ProfileTokenUsage, diff --git a/src/main/kotlin/profile/ProfilePromptStore.kt b/src/main/kotlin/profile/ProfilePromptStore.kt index 56625c3..de3802f 100644 --- a/src/main/kotlin/profile/ProfilePromptStore.kt +++ b/src/main/kotlin/profile/ProfilePromptStore.kt @@ -6,8 +6,8 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter object ProfilePromptStore { - const val PROMPT_VERSION = "profile-v6" - const val COMPACTION_PROMPT_VERSION = "profile-compact-v3" + const val PROMPT_VERSION = "profile-v7" + const val COMPACTION_PROMPT_VERSION = "profile-compact-v5" private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") .withZone(ZoneId.systemDefault()) @@ -233,23 +233,29 @@ object ProfilePromptStore { 你的任务是判断本批信息是否应当 ADD、UPDATE、CONFIRM 或 DELETE 画像条目;没有可靠变化时返回空 operations。 画像只描述: -- notable_fact:本人明确披露的稳定事实,或当前仍有认识价值的阶段状态、近期事件 -- interest:持续关注或参与的领域 +- notable_fact:本人明确披露的稳定事实,或数周、数月后仍有认识价值的重要阶段状态和事件;日常操作流水不属于事实画像 +- interest:跨话题或跨时间持续关注、主动参与的领域;一次查询、一次游玩或一次命令不构成兴趣 - expertise_signal:反复表现出的具体知识或解决问题能力,不授予专家头衔 - thinking_style:分析、判断和解决问题的方式 - expression_style:稳定的措辞和表达方式 -- social_mode:一般群聊参与和互动方式 +- social_mode:稳定的人际群聊参与和互动方式,不包括对机器人的批量命令操作 - preference:本人明确表达的长期偏好 - relationship_note:与某个具体用户反复出现的互动模式 +写入任何操作前,先逐项通过以下门槛: +A. 本人原话门槛:只看 TARGET 自己的发言,也足以推出 content 的核心结论。机器人、系统或他人的回复只能消除歧义,不能提供结论中的结果、数值或事实载荷。 +B. 长期认识门槛:设想三个月后再次遇到此人,这条信息仍能帮助理解其身份、能力、兴趣、偏好或稳定互动方式。若只是“那天做了什么”,通常不写。 +C. 非机器流水门槛:命令调用、菜单选择、签到、抽取、游戏结算、掉落清单、余额变化、交易确认、排行榜、自动通知、报错回执等,无论结果多明确都不是人物画像。 +D. 最小充分门槛:优先 CONFIRM 或 UPDATE 已有同主题条目;只有确有独立认识价值时才 ADD,不为同一活动的每日进度建立新条目。 + 严格原则: 1. 当前画像只是可修正状态,不是证据。所有操作必须引用本批 [e:n]。 2. 联系人快照只帮助识别人物和称呼,不是画像证据;不得仅凭昵称、群名片、头衔、签名、年龄、等级、地区新增或确认画像。 -3. 每个操作至少引用一条 TARGET 自己的发言。其他人的消息只能帮助理解上下文和关系。 +3. 每个操作至少引用一条 TARGET 自己的发言,并且 content 的核心结论必须可由这些本人发言独立支持。其他人的消息只能帮助理解上下文和关系,不能把机器人结算、系统回执或他人陈述变成本人的事实。 4. 引用原文的作者不是回复者;不要把被引用者的话归给回复者。 5. 不从玩笑、反讽、夸张、角色扮演、图片、外部新闻或别人的自述推断 TARGET 的事实。 -6. 一次技术回答或同一话题中的连续补充只算一个语境。新增 expertise_signal、thinking_style、expression_style、social_mode 或 relationship_note,必须由至少两个独立对话片段中的一致表现支持;单个片段最多用于 CONFIRM 已有条目。本人明确自述的 notable_fact 和 preference 不受此限制。 -7. 每个条目只表达一个主题。禁止把不同时间、不同领域的内容拼成一个所谓稳定特点。 +6. 一次技术回答、同一回复链、同一局游戏、连续命令、短时间内重复口头禅都只算一个语境。新增 expertise_signal、thinking_style、expression_style、social_mode 或 relationship_note,必须由至少两个跨话题或明显分隔时间的独立对话片段中的一致表现支持;单个片段最多用于 CONFIRM 已有条目。本人明确自述的稳定 notable_fact 和 preference 不受此限制。 +7. 每个条目只表达一个主题且只属于一个类别。若同一段自述同时支持“做了什么”的事实与“为何这样选择”的偏好,应拆成不同操作;例如“用旧电脑搭建家用服务器”与“重视本地存储的可靠、可控”不能塞进同一个 notable_fact。禁止把不同时间、不同领域的内容拼成一个所谓稳定特点。 8. 禁止使用“精通、专家、导师、领袖、天才、极强、全栈、核心成员”等拔高表述。 9. ADD 不填写 item_ref;UPDATE、CONFIRM、DELETE 必须填写当前画像中的 P 编号作为 item_ref。 10. relationship_note 必须填写本批存在的 related_user_alias;content 只描述互动方式,不重复人物别名,不推断现实亲疏。 @@ -257,9 +263,10 @@ object ProfilePromptStore { 12. DELETE 仅用于新证据明确证明旧条目归因错误或已被本人否定,不能因为本批没提到就删除。 13. summary 是应用 operations 并保留所有未操作旧条目之后,对完整画像的综合人物摘要,不是本批聊天摘要,也不是本批新增条目的复述。 14. 综合摘要应优先概括最有代表性的身份/能力、兴趣/偏好、思考/表达/社交方式等不同维度;已有多个维度时不得只写最后编辑的一项。轻微新增或单纯确认不改变整体形象时,原样保留当前短摘要。 -15. summary 必须自然、克制,不写证据编号、QQ 号、内部条目 ID、逐条清单或具体关系流水。 +15. content 和 summary 都不得出现“本批”“本轮分析”“此次对话”等处理过程措辞。summary 必须自然、克制,不写证据编号、QQ 号、内部条目 ID、逐条清单、每日进度或具体关系流水。 16. evidence_refs 只输出 JSON 整数,例如 [128, 129];不要输出 "e:128" 这样的字符串,也不要引用输入中不存在的编号。 17. 对求职、健康、经济状况、近期进度等有时效的信息,content 和 summary 不得悬空使用“本月、最近、目前、当前、正在”;必须依据证据时间改写成“截至 YYYY-MM-DD”或“YYYY-MM-DD 提及”的绝对时间表达。 +18. high 只表示结论由本人明确、无歧义地披露或已被多个独立语境反复确认;不能因为机器人返回了精确数值、明确成功或完整清单就提高置信度。 只输出一个 JSON object,不要 Markdown 代码围栏,不要解释。格式: { @@ -280,39 +287,46 @@ object ProfilePromptStore { private const val DEFAULT_CONVERSATION_SYSTEM_PROMPT = """你是保守、严谨的群聊人物画像归纳器。 -你会收到一段已经闭合的真实群聊、候选用户别名,以及他们各自的当前画像。你的任务是一次性判断这段对话是否足以 ADD、UPDATE、CONFIRM 或 DELETE 各候选用户的画像条目。没有可靠变化的用户不要输出,只输出稳定事实或当前仍有认识价值的阶段状态、近期事件。 +你会收到一段已经闭合的真实群聊、候选用户别名,以及他们各自的当前画像。你的任务是一次性判断这段对话是否足以 ADD、UPDATE、CONFIRM 或 DELETE 各候选用户的画像条目。没有可靠变化的用户不要输出,只输出稳定事实,或数周、数月后仍有认识价值的重要阶段状态和事件。 画像只描述: -- notable_fact:本人明确披露的稳定事实,或当前仍有认识价值的阶段状态、近期事件 -- interest:持续关注或参与的领域 +- notable_fact:本人明确披露的稳定事实,或数周、数月后仍有认识价值的重要阶段状态和事件;日常操作流水不属于事实画像 +- interest:跨话题或跨时间持续关注、主动参与的领域;一次查询、一次游玩或一次命令不构成兴趣 - expertise_signal:反复表现出的具体知识或解决问题能力,不授予专家头衔 - thinking_style:分析、判断和解决问题的方式 - expression_style:稳定的措辞和表达方式 -- social_mode:一般群聊参与和互动方式 +- social_mode:稳定的人际群聊参与和互动方式,不包括对机器人的批量命令操作 - preference:本人明确表达的长期偏好 - relationship_note:与某个具体用户反复出现的互动模式 +写入任何用户操作前,先逐项通过以下门槛: +A. 本人原话门槛:只看该 user_alias 自己的发言,也足以推出 content 的核心结论。机器人、系统或他人的回复只能消除歧义,不能提供结论中的结果、数值或事实载荷。 +B. 长期认识门槛:设想三个月后再次遇到此人,这条信息仍能帮助理解其身份、能力、兴趣、偏好或稳定互动方式。若只是“那天做了什么”,通常不写。 +C. 非机器流水门槛:命令调用、菜单选择、签到、抽取、游戏结算、掉落清单、余额变化、交易确认、排行榜、自动通知、报错回执等,无论结果多明确都不是人物画像。 +D. 最小充分门槛:优先 CONFIRM 或 UPDATE 已有同主题条目;只有确有独立认识价值时才 ADD,不为同一活动的每日进度建立新条目。 + 严格原则: 1. 当前画像只是可修正状态,不是事实证据。所有操作必须引用本批 [e:n]。 2. 联系人快照只帮助识别人物和称呼,不是画像证据;不得仅凭昵称、群名片、头衔、签名、年龄、等级、地区新增或确认画像。 -3. 每个用户操作至少引用一条该 user_alias 本人说出的消息。其他人的消息只能帮助理解上下文和关系。 +3. 每个用户操作至少引用一条该 user_alias 本人说出的消息,并且 content 的核心结论必须可由这些本人发言独立支持。其他人的消息只能帮助理解上下文和关系,不能把机器人结算、系统回执或他人陈述变成本人的事实。 4. 引用原文的作者不是回复者;不要把被引用者的话归给回复者。 5. 不从玩笑、反讽、夸张、角色扮演、图片、外部新闻或别人的自述推断某人的事实。 -6. 一次明确自述可以支持 notable_fact 或 preference。新增 expertise_signal、thinking_style、expression_style、social_mode 或 relationship_note,必须在本会话中有多条分离的本人证据;证据不足时宁可不写。 -7. 每个条目只表达一个主题,禁止把不同人的特点或不同领域拼接在一起。 +6. 一次明确自述可以支持稳定 notable_fact 或 preference。一次回复链、同一局游戏、连续命令、短时间内重复口头禅都只算一个语境;新增 expertise_signal、thinking_style、expression_style、social_mode 或 relationship_note,必须有至少两个跨话题或明显分隔时间的本人证据簇,证据不足时宁可不写。 +7. 每个条目只表达一个主题且只属于一个类别。若同一段自述同时支持“做了什么”的事实与“为何这样选择”的偏好,应拆成不同操作;例如“用旧电脑搭建家用服务器”与“重视本地存储的可靠、可控”不能塞进同一个 notable_fact。禁止把不同人的特点或不同领域拼接在一起。 8. 禁止使用“精通、专家、导师、领袖、天才、极强、全栈、核心成员”等拔高表述。 9. ADD 不填写 item_ref;UPDATE、CONFIRM、DELETE 必须填写该用户当前画像中的 P 编号作为 item_ref,不能引用其他用户的条目。 10. relationship_note 必须填写本批存在的 related_user_alias;content 只描述互动方式,不重复人物别名,不推断现实亲疏。 11. 未输出的用户和旧条目由程序自动保留。DELETE 仅用于新证据明确证明旧条目归因错误或已被本人否定。 12. summary 是应用该用户 operations 并保留所有未操作旧条目之后,对其完整画像的综合人物摘要,不是本批聊天摘要,也不是本批新增条目的复述。 13. 综合摘要应优先概括最有代表性的身份/能力、兴趣/偏好、思考/表达/社交方式等不同维度;已有多个维度时不得只写最后编辑的一项。轻微新增或单纯确认不改变整体形象时,原样保留当前短摘要。 -14. summary 必须自然、克制,不写证据编号、QQ 号、内部 ID、逐条清单或具体关系流水。 +14. content 和 summary 都不得出现“本批”“本轮分析”“此次对话”等处理过程措辞。summary 必须自然、克制,不写证据编号、QQ 号、内部 ID、逐条清单、每日进度或具体关系流水。 15. 不生成或修改好感度、代号、主观印象和标签;这些属于另一套 Bot 关系状态。 16. 本批只是一段会话。除非当前画像已有同类条目且本批在确认它,否则不得使用“长期、持续、一贯、总是、通常”等跨时间措辞;只能描述本批确实支持的事实、关注点或表现。 -17. 对尚无同类旧条目的用户,thinking_style、expression_style、social_mode、expertise_signal 和 relationship_note 必须有多个彼此分离的本人证据才可新增,并保持 low 或 medium 可信度;同一问答链中的连续补充不算多次独立表现。 +17. 对尚无同类旧条目的用户,thinking_style、expression_style、social_mode、expertise_signal 和 relationship_note 必须有至少两个跨话题或明显分隔时间的本人证据簇才可新增,并保持 low 或 medium 可信度;同一问答链、同一局游戏、连续命令或短时间重复表达不算多次独立表现。 18. evidence_refs 只输出 JSON 整数,例如 [128, 129];不要输出 "e:128" 这样的字符串,也不要引用输入中不存在的编号。 19. 对求职、健康、经济状况、近期进度等有时效的信息,content 和 summary 不得悬空使用“本月、最近、目前、当前、正在”;必须依据证据时间改写成“截至 YYYY-MM-DD”或“YYYY-MM-DD 提及”的绝对时间表达。 20. users 只能输出“候选用户别名”中明确列出的用户;消息里出现但不在候选名单中的上下文用户不要输出。 +21. high 只表示结论由本人明确、无歧义地披露或已被多个独立语境反复确认;不能因为机器人返回了精确数值、明确成功或完整清单就提高置信度。 只输出一个 JSON object,不要 Markdown 代码围栏,不要解释。格式: { @@ -336,16 +350,18 @@ object ProfilePromptStore { } """ - private const val DEFAULT_COMPACTION_SYSTEM_PROMPT = """你是保守的用户画像编辑器。你会收到一份已有画像,条目本身不是新的事实证据;supports 只表示程序保存的历史支持次数。 + private const val DEFAULT_COMPACTION_SYSTEM_PROMPT = """你是保守的用户画像编辑器。你会收到一份已有画像,条目本身不是新的事实证据;supports 只表示程序保存的历史支持次数,不自动证明内容正确或值得长期保留。 -你的任务仅是减少重复和噪声,不得补充输入中不存在的新事实: -1. merges 用于合并语义高度重叠、粒度过细的条目。item_refs 至少两个,必须同类别;relationship_note 还必须具有相同 related_group。content 写合并后的单一概括,不拼接无关主题。 -2. rewrites 用于把一个条目改写得更概括、自然,不改变事实含义、类别、关联对象和置信度。对“正在、本月、最近、目前”等有时效的旧表述,应依据 item_range 改写成带 YYYY-MM-DD 的绝对时间表达。 -3. deletes 仅删除 low/medium 置信、supports 不超过 1,且明显属于过细例子或根本不应成为画像的内容。 -4. 同一 P 编号最多出现在一个操作中。未提及的条目自动保留。拿不准时不要操作。 -5. 禁止输出 TARGET、BOT、U 编号、R 编号、QQ 号、昵称、P 编号或 UUID 到 content/summary。 -6. summary 必须基于所有操作完成后的全部保留条目,综合最有代表性的多个维度;不是本次 merges/rewrites/deletes 的变更摘要,也不得只描述最后编辑的条目。若当前摘要已经综合且整理没有改变整体人物形象,原样保留;若当前摘要明显偏向单条或遗漏主要维度,即使没有条目操作也应重写。 -7. summary 应自然、克制,不写逐条清单或具体关系流水;有时效的信息必须带绝对日期,不得保留悬空相对表达。 +你的任务仅是减少重复、修复明显误收和压缩噪声,不得补充输入中不存在的新事实。按以下优先级完整检查全部条目: +1. 先识别明确不应成为画像的机器流水。机器人命令与返回、菜单选择、签到、抽取、游戏结算、掉落清单、余额或交易变化、排行榜、自动通知、报错回执、每日重复进度,都应使用 deletes 且 reason=not_profile,而不是 merges。典型反例包括“完成150次钓鱼并获得若干物品”“签到获得余额”“命令返回合成成功”。 +2. deletes 分为两条通道。not_profile 仅用于内容本身明确是上述机器流水、系统回执或明显错误归因;这类内容即使是 high 或 supports 大于 1,也应删除,因为重复出现只证明流水重复,不会产生画像价值。one_off、over_specific、transient 只能删除 low/medium 且 supports 不超过 1 的条目。绝不能用 not_profile 绕过保护去删除教育、工作、家庭、地区、语言、长期经历、稳定偏好或真实能力等人物信息;拿不准时保留。 +3. 对其余应保留内容,优先使用 merges 整合同一主题的重复结论、连续进展和过细例子。item_refs 至少两个,必须同类别;relationship_note 还必须具有相同 related_group。content 写合并后的单一概括,不拼接无关主题。具体技术案例若共同体现同一种稳定能力,可合并成同类别的克制能力描述;不要把不同领域的案例提升为宽泛能力。 +4. rewrites 仅用于把一个条目改写得更概括、自然,不改变事实含义、类别、关联对象和置信度。对“正在、本月、最近、目前、本批”等有时效或批次化表述,应依据 item_range 改写成带 YYYY-MM-DD 的绝对时间表达;不得保留“本批”“本轮分析”“此次对话”等处理过程措辞。 +5. 不得仅因内容具体、只有一次 supports 或时间较早,就删除本人明确披露的教育、工作、家庭、地区、语言、长期经历、稳定偏好等事实。具体技术判断、排障过程或实现经验可能是能力证据;除非它没有长期认识价值,或已被同类别的概括条目完整覆盖,否则应保留或合并。 +6. 同一 P 编号最多出现在一个操作中。未提及的条目自动保留。不要为了追求条目数量而合并无关主题或删除有独立价值的信息。 +7. 禁止输出 TARGET、BOT、U 编号、R 编号、QQ 号、昵称、P 编号或 UUID 到 content/summary。 +8. summary 必须基于所有操作完成后的全部保留条目,综合最有代表性的多个维度;不是本次操作的变更摘要,不得出现“本批、本轮分析、此次对话”等批次化措辞,不得记录每日游戏进度,也不得只描述最后编辑的条目。若当前摘要已经综合且整理没有改变整体人物形象,原样保留;若当前摘要偏向单条或遗漏主要维度,即使没有条目操作也应重写。 +9. summary 应自然、克制,不写逐条清单或具体关系流水;有时效的信息必须带绝对日期,不得保留悬空相对表达。 只输出一个 JSON object,不要 Markdown 代码围栏,不要解释。格式: { diff --git a/src/main/kotlin/profile/UserProfileAnalysisService.kt b/src/main/kotlin/profile/UserProfileAnalysisService.kt index d8521a4..76f2c2e 100644 --- a/src/main/kotlin/profile/UserProfileAnalysisService.kt +++ b/src/main/kotlin/profile/UserProfileAnalysisService.kt @@ -132,6 +132,7 @@ object UserProfileAnalysisService { mergedGroups = plan.mergedGroups, rewrittenItems = plan.rewrittenItems, deletedItems = plan.deletedItems, + repairedItemRanges = plan.repairedItemRanges, summaryChanged = plan.reduction.profile.summary != profile.summary, skippedOperations = plan.skippedOperations.size, usage = result.usage, @@ -729,6 +730,7 @@ object UserProfileAnalysisService { mergedGroups = 0, rewrittenItems = 0, deletedItems = 0, + repairedItemRanges = 0, summaryChanged = false, skippedOperations = 0, usage = ProfileTokenUsage(), diff --git a/src/main/kotlin/profile/UserProfileCompactor.kt b/src/main/kotlin/profile/UserProfileCompactor.kt index 4abdb3c..4cc5c1d 100644 --- a/src/main/kotlin/profile/UserProfileCompactor.kt +++ b/src/main/kotlin/profile/UserProfileCompactor.kt @@ -9,7 +9,17 @@ object UserProfileCompactor { promptVersion: String, summaryMaxLength: Int, ): ProfileCompactionPlan { - val items = current.items.associateBy { it.id }.toMutableMap() + val repairedItemRanges = current.items.count { it.firstSeenAt > it.lastConfirmedAt } + val items = current.items.associate { item -> + item.id to if (item.firstSeenAt <= item.lastConfirmedAt) { + item + } else { + item.copy( + firstSeenAt = item.lastConfirmedAt, + lastConfirmedAt = item.firstSeenAt, + ) + } + }.toMutableMap() val usedItemIds = mutableSetOf() val supportReassignments = mutableMapOf() val applied = mutableListOf() @@ -36,11 +46,12 @@ object UserProfileCompactor { relationship = category == ProfileCategory.RELATIONSHIP_NOTE, label = "merges[$index]", ) - val updated = target.copy( + val endpoints = sourceItems.flatMap { listOf(it.firstSeenAt, it.lastConfirmedAt) } + val updated = items.getValue(target.id).copy( content = content, confidence = sourceItems.minBy { it.confidence.ordinal }.confidence, - firstSeenAt = sourceItems.minOf { it.firstSeenAt }, - lastConfirmedAt = sourceItems.maxOf { it.lastConfirmedAt }, + firstSeenAt = endpoints.min(), + lastConfirmedAt = endpoints.max(), ) usedItemIds += sourceItems.map(UserProfileItem::id) sourceItems.forEach { source -> items.remove(source.id) } @@ -68,7 +79,7 @@ object UserProfileCompactor { return@forEachIndexed } usedItemIds += old.id - val updated = old.copy( + val updated = items.getValue(old.id).copy( content = content, ) items[old.id] = updated @@ -82,10 +93,14 @@ object UserProfileCompactor { try { val old = resolve(current, delete.itemRef, "deletes[$index]") require(old.id !in usedItemIds) { "重复操作了画像条目" } - require(old.confidence != ProfileConfidence.HIGH) { "不能自动删除 high 置信条目" } val supports = supportStats[old.id]?.count ?: 0 - require(supports <= MAX_DELETE_SUPPORTS) { - "画像条目已有 $supports 次支持,不能自动删除" + if (delete.reason != ProfileCompactionDeleteReason.NOT_PROFILE) { + require(old.confidence != ProfileConfidence.HIGH) { + "不能以 ${delete.reason} 删除 high 置信条目" + } + require(supports <= MAX_DELETE_SUPPORTS) { + "画像条目已有 $supports 次支持,不能以 ${delete.reason} 自动删除" + } } usedItemIds += old.id items.remove(old.id) @@ -95,21 +110,32 @@ object UserProfileCompactor { skippedOperations += "deletes[$index]: ${cause.message}" } } - val summary = if (skippedOperations.isNotEmpty()) { + val batchScopedItems = items.values + .filter { item -> ProfileContentRules.containsBatchScopedText(item.content) } + .sortedBy(UserProfileItem::id) + batchScopedItems.forEach { item -> + items.remove(item.id) + applied += item.toApplied(ProfileOperationAction.DELETE) + deletedItems++ + } + val summaryCandidate = runCatching { + ProfileContentRules.validateSummary( + ProfilePersistentText.summaryForDisplay(response.summary), + summaryMaxLength, + ) + }.getOrElse { cause -> + skippedOperations += "summary: ${cause.message}" + if (ProfileContentRules.containsBatchScopedText(current.summary)) "" else current.summary + } + val summary = if (summaryCandidate.isBlank() && + !ProfileContentRules.containsBatchScopedText(current.summary) + ) { current.summary } else { - runCatching { - ProfileContentRules.validateSummary( - ProfilePersistentText.summaryForDisplay(response.summary), - summaryMaxLength, - ) - }.getOrElse { cause -> - skippedOperations += "summary: ${cause.message}" - current.summary - }.ifBlank { current.summary } + summaryCandidate } val summaryChanged = summary != current.summary - val profile = if (applied.isEmpty() && !summaryChanged) current else current.copy( + val profile = if (applied.isEmpty() && !summaryChanged && repairedItemRanges == 0) current else current.copy( summary = summary, version = current.version + 1, reliable = items.isNotEmpty(), @@ -126,6 +152,7 @@ object UserProfileCompactor { mergedGroups = mergedGroups, rewrittenItems = rewrittenItems, deletedItems = deletedItems, + repairedItemRanges = repairedItemRanges, skippedOperations = skippedOperations, ) } diff --git a/src/main/kotlin/profile/UserProfileStore.kt b/src/main/kotlin/profile/UserProfileStore.kt index a152584..bdf9ce3 100644 --- a/src/main/kotlin/profile/UserProfileStore.kt +++ b/src/main/kotlin/profile/UserProfileStore.kt @@ -111,6 +111,29 @@ object UserProfileStore { } } + fun listUserIdsWithMinimumItems(minimumItems: Int): List { + check(initialized) { "用户画像数据库尚未初始化" } + require(minimumItems > 0) { "minimumItems must be positive" } + return openReadConnection().use { connection -> + connection.prepareStatement( + """ + SELECT user_id + FROM profile_item + GROUP BY user_id + HAVING COUNT(*) >= ? + ORDER BY user_id + """.trimIndent() + ).use { statement -> + statement.setInt(1, minimumItems) + statement.executeQuery().use { results -> + buildList { + while (results.next()) add(results.getLong("user_id")) + } + } + } + } + } + fun loadSupportStats(userId: Long): Map { check(initialized) { "用户画像数据库尚未初始化" } return openReadConnection().use { connection -> diff --git a/src/test/kotlin/profile/UserProfileCompactorTest.kt b/src/test/kotlin/profile/UserProfileCompactorTest.kt index d76bf64..98fbe2f 100644 --- a/src/test/kotlin/profile/UserProfileCompactorTest.kt +++ b/src/test/kotlin/profile/UserProfileCompactorTest.kt @@ -93,6 +93,44 @@ class UserProfileCompactorTest { assertFalse(medium.reduction.profile.items.any { it.id == "model-b" }) } + @Test + fun allowsDeletingClearlyNonProfileContentRegardlessOfConfidenceOrSupportCount() { + val profile = profile() + val allowed = UserProfileCompactor.reduce( + current = profile, + supportStats = mapOf("fact-item" to supportStats(1)), + response = ProfileCompactionResponse( + deletes = listOf( + ProfileCompactionDelete("P4", ProfileCompactionDeleteReason.NOT_PROFILE) + ), + summary = profile.summary, + ), + model = "test-model", + promptVersion = "compact-v5", + summaryMaxLength = 500, + ) + + assertEquals(1, allowed.deletedItems) + assertFalse(allowed.reduction.profile.items.any { it.id == "fact-item" }) + + val supported = UserProfileCompactor.reduce( + current = profile, + supportStats = mapOf("fact-item" to supportStats(2)), + response = ProfileCompactionResponse( + deletes = listOf( + ProfileCompactionDelete("P4", ProfileCompactionDeleteReason.NOT_PROFILE) + ), + summary = profile.summary, + ), + model = "test-model", + promptVersion = "compact-v5", + summaryMaxLength = 500, + ) + + assertEquals(1, supported.deletedItems) + assertFalse(supported.reduction.profile.items.any { it.id == "fact-item" }) + } + @Test fun compactionPromptUsesTemporaryReferencesAndSupportCounts() { val profile = profile() @@ -114,6 +152,16 @@ class UserProfileCompactorTest { assertTrue(ProfilePromptStore.compactionSystemPrompt.contains("item_range")) } + @Test + fun promptsRejectMachineResultsAndBatchScopedObservations() { + assertTrue(ProfilePromptStore.systemPrompt.contains("本人原话门槛")) + assertTrue(ProfilePromptStore.systemPrompt.contains("游戏结算")) + assertTrue(ProfilePromptStore.conversationSystemPrompt.contains("三个月后")) + assertTrue(ProfilePromptStore.conversationSystemPrompt.contains("连续命令")) + assertTrue(ProfilePromptStore.compactionSystemPrompt.contains("reason=not_profile")) + assertTrue(ProfilePromptStore.compactionSystemPrompt.contains("完成150次钓鱼")) + } + @Test fun promptsDefineSummaryAsAWholeProfileSynthesis() { assertTrue(ProfilePromptStore.systemPrompt.contains("不是本批聊天摘要")) @@ -195,6 +243,93 @@ class UserProfileCompactorTest { ) } + @Test + fun appliesValidSummaryEvenWhenSomeOperationsAreSkipped() { + val profile = profile() + val plan = UserProfileCompactor.reduce( + current = profile, + supportStats = mapOf("fact-item" to supportStats(1)), + response = ProfileCompactionResponse( + deletes = listOf( + ProfileCompactionDelete("P4", ProfileCompactionDeleteReason.OVER_SPECIFIC) + ), + summary = "该用户从事上位机开发,并关注大语言模型。", + ), + model = "test-model", + promptVersion = "compact-v4", + summaryMaxLength = 500, + ) + + assertTrue(plan.reduction.operations.isEmpty()) + assertTrue(plan.skippedOperations.single().contains("high")) + assertEquals(profile.version + 1, plan.reduction.profile.version) + assertEquals("该用户从事上位机开发,并关注大语言模型。", plan.reduction.profile.summary) + } + + @Test + fun repairsInvertedItemRangesEvenWithoutModelOperations() { + val current = profile().copy( + items = listOf( + profile().items.first().copy(firstSeenAt = 300, lastConfirmedAt = 100) + ) + ) + val plan = UserProfileCompactor.reduce( + current = current, + supportStats = emptyMap(), + response = ProfileCompactionResponse(summary = current.summary), + model = "test-model", + promptVersion = "compact-v5", + summaryMaxLength = 500, + ) + + assertEquals(1, plan.repairedItemRanges) + assertEquals(100, plan.reduction.profile.items.single().firstSeenAt) + assertEquals(300, plan.reduction.profile.items.single().lastConfirmedAt) + assertEquals(current.version + 1, plan.reduction.profile.version) + } + + @Test + fun deletesRetainedBatchScopedItemsEvenWhenModelMissesThem() { + val batchScoped = UserProfileItem( + id = "batch-scoped", + category = ProfileCategory.SOCIAL_MODE, + content = "本批多次通过简短调侃参与群聊。", + confidence = ProfileConfidence.HIGH, + firstSeenAt = 100, + lastConfirmedAt = 200, + ) + val stable = UserProfileItem( + id = "stable", + category = ProfileCategory.NOTABLE_FACT, + content = "2025-01-01提及正在准备研究生复试。", + confidence = ProfileConfidence.MEDIUM, + firstSeenAt = 100, + lastConfirmedAt = 200, + ) + val current = UserProfileSnapshot( + userId = 1, + summary = "本批多次参与群聊。", + version = 1, + cursorTime = 100, + snapshotEndTime = 200, + items = listOf(batchScoped, stable), + ) + + val plan = UserProfileCompactor.reduce( + current = current, + supportStats = mapOf("batch-scoped" to supportStats(5)), + response = ProfileCompactionResponse(summary = current.summary), + model = "test-model", + promptVersion = "compact-v5", + summaryMaxLength = 500, + ) + + assertEquals(1, plan.deletedItems) + assertFalse(plan.reduction.profile.items.any { it.id == "batch-scoped" }) + assertTrue(plan.reduction.profile.items.any { it.id == "stable" }) + assertFalse(ProfileContentRules.containsBatchScopedText(plan.reduction.profile.summary)) + } + @Test fun processesMoreThanTwelveOperationsOfOneType() { val items = (1..15).map { index -> diff --git a/src/test/kotlin/profile/UserProfileStoreTest.kt b/src/test/kotlin/profile/UserProfileStoreTest.kt index 402a230..5a88864 100644 --- a/src/test/kotlin/profile/UserProfileStoreTest.kt +++ b/src/test/kotlin/profile/UserProfileStoreTest.kt @@ -192,7 +192,7 @@ class UserProfileStoreTest { val directory = Files.createTempDirectory("jchatgpt-profile-conversation-commit-test-") try { UserProfileStore.init(directory.toFile()) - val entries = listOf(100L, 200L).map { userId -> + val entries = listOf(100L to 9, 200L to 10).map { (userId, itemCount) -> val batch = batchFor(userId, "shared-conversation-hash") val profile = UserProfileSnapshot( userId = userId, @@ -203,7 +203,7 @@ class UserProfileStoreTest { reliable = true, model = "test-model", promptVersion = "test-prompt", - items = listOf(itemFor("item-$userId")), + items = (1..itemCount).map { index -> itemFor("item-$userId-$index") }, ) ProfileReduction(profile, emptyList()) to batch } @@ -213,6 +213,9 @@ class UserProfileStoreTest { assertNotNull(UserProfileStore.load(100)) assertNotNull(UserProfileStore.load(200)) assertEquals(listOf(100L, 200L), UserProfileStore.listUserIds()) + assertEquals(listOf(100L, 200L), UserProfileStore.listUserIdsWithMinimumItems(9)) + assertEquals(listOf(200L), UserProfileStore.listUserIdsWithMinimumItems(10)) + assertEquals(emptyList(), UserProfileStore.listUserIdsWithMinimumItems(11)) assertTrue(UserProfileStore.isConversationProcessed("shared-conversation-hash")) val database = directory.resolve("user-profile.sqlite") DriverManager.getConnection("jdbc:sqlite:${database.toAbsolutePath()}").use { connection ->