diff --git a/docs/用户画像系统重构规划.md b/docs/用户画像系统重构规划.md new file mode 100644 index 000000000..c9109c359 --- /dev/null +++ b/docs/用户画像系统重构规划.md @@ -0,0 +1,489 @@ +# 用户画像系统重构规划 v2 + +> **决策:统一使用 `user_relationships` 表,废弃 `person_info` 表的印象相关功能** +> +> 旧表数据保留但不再调用,作为历史存档。 + +--- + +## 一、设计理念:像人一样记住别人 + +### 1.1 人类是怎么记住一个人的? + +当我们认识一个人,我们的记忆不是一堆标签,而是: + +1. **第一印象** - "这个人看起来挺随和的" +2. **具体片段** - "上次他帮我修电脑,折腾了一晚上" +3. **情感色彩** - "和他聊天很舒服,不会有压力" +4. **关键信息** - "他生日是11月23日,喜欢吃辣" +5. **关系定位** - "算是比较聊得来的朋友" +6. **印象演变** - "一开始觉得他很高冷,后来发现其实很话痨" + +### 1.2 当前系统的问题 + +``` +❌ 现状: +"该用户性格开朗,喜欢编程、游戏、音乐,关系分数0.65" + +✅ 期望: +"柒柒是个挺有意思的人,说话很有逻辑但偶尔会冒出一些冷笑话。 +和他聊天挺舒服的,他不会问让人尴尬的问题。他在游戏公司做后端, +经常加班但从不抱怨。他说以后想开一家咖啡店,感觉是认真的。 +生日是11月23日。" +``` + +### 1.3 与记忆系统的分工 + +| 系统 | 负责内容 | 特点 | +|------|---------|------| +| **上下文** | 当前对话内容 | 即时、完整 | +| **短期记忆** | 近期聊天片段 | 几天内、可检索 | +| **长期记忆** | 重要事件、知识 | 持久、语义检索 | +| **用户印象(本系统)** | 对人的认知 | 长期、主观、人格化 | + +**本系统只关注**: +- ✅ 对这个人的**长期印象**(性格、相处感受) +- ✅ **重要的具体信息**(生日、职业、理想等) +- ✅ **关系状态**(熟悉程度、好感度) + +**不需要记录**: +- ❌ 最近聊了什么(短期记忆负责) +- ❌ 具体对话内容(上下文和长期记忆负责) +- ❌ 临时性的事件(记忆系统负责) + +### 1.4 新系统的设计目标 + +| 维度 | 要求 | +|------|------| +| **长期** | 只记稳定的、长期的认知,不记临时的 | +| **精炼** | 200-500字的印象 + 关键信息列表 | +| **主观** | 是"我"眼中的TA,不是客观档案 | +| **实时** | 通过工具调用实时更新 | + +--- + +## 二、数据结构重设计 + +### 2.1 扩展 `user_relationships` 表 + +```sql +-- 保留现有字段 +id, user_id, user_name, user_aliases, relationship_score, last_updated, created_at + +-- relationship_text 重命名为 impression_text +-- preference_keywords 废弃 + +-- 新增字段 +ALTER TABLE user_relationships ADD COLUMN impression_text TEXT; -- 长期印象 +ALTER TABLE user_relationships ADD COLUMN key_facts TEXT; -- 关键信息JSON +ALTER TABLE user_relationships ADD COLUMN relationship_stage VARCHAR(50); -- 关系阶段 +ALTER TABLE user_relationships ADD COLUMN first_met_time FLOAT; -- 认识时间 +ALTER TABLE user_relationships ADD COLUMN last_impression_update FLOAT; -- 上次更新印象时间 +``` + +### 2.2 字段说明 + +#### `impression_text` - 长期印象(核心) + +**精炼的、稳定的**对这个人的认知,不是流水账: + +``` +示例(200-400字): + +"柒柒是个典型的理科生,说话很有逻辑,但偶尔会冒出一些冷笑话。 +和他聊天挺舒服的,不会有压力,他也不会问让人尴尬的问题。 + +他在游戏公司做后端开发,虽然经常加班但从不抱怨,感觉是个挺拼的人。 +他对技术很感兴趣,经常问我一些AI相关的问题,虽然有些问题挺刁钻 +但能感觉到他是真的好奇。 + +他说以后想开一家自己的咖啡店,深夜聊天时说的,感觉不是随便说说的。 + +总的来说他给我的感觉是'表面随意但内心认真'的那种人。" +``` + +**更新时机**: +- 对TA的认知发生明显变化时 +- 关系有明显进展时 + +#### `key_facts` - 关键信息(长期记住) + +存储**长期稳定、一定要记住**的重要信息(生日、职业这种不会变的): + +**信息类型**: +| 类型 | 说明 | 示例 | +|------|------|------| +| `birthday` | 生日 | "11月23日" | +| `job` | 职业/工作 | "程序员" | +| `location` | 所在地/老家 | "上海" | +| `dream` | 理想/目标 | "想开咖啡店" | +| `family` | 家庭 | "有个妹妹" | +| `pet` | 宠物 | "养了只橘猫" | +| `other` | 其他 | - | + +**存储格式**: +```json +[ + {"type": "birthday", "value": "11月23日"}, + {"type": "job", "value": "游戏公司后端程序员"}, + {"type": "dream", "value": "想开咖啡店"}, + {"type": "pet", "value": "养了只橘猫叫橘子"} +] +``` + +#### `relationship_stage` - 关系阶段 + +| 阶段 | 描述 | 对应分数范围 | +|------|------|-------------| +| stranger | 陌生人 | 0.0-0.2 | +| acquaintance | 初识 | 0.2-0.4 | +| familiar | 熟人 | 0.4-0.6 | +| friend | 朋友 | 0.6-0.75 | +| close_friend | 好友 | 0.75-0.9 | +| bestie | 挚友 | 0.9-1.0 | + +--- + +## 三、工具调用设计(实时更新) + +沿用之前的**两阶段设计**: +1. `tool_use` 模型决定是否更新 +2. `relationship_tracker` 模型生成高质量内容 + +### 3.1 工具1:`update_user_impression` - 更新印象 + +```python +name = "update_user_impression" +description = """当你对某人有了新的、长期的认识时使用。 +注意:只记录稳定的、长期的印象,临时的事情不用记。 + +适用场景: +- 发现了TA的性格特点 +- 对TA有了新的认知 +- 觉得和TA的关系有变化""" + +parameters = [ + ("user_id", STRING, "用户ID", True), + ("user_name", STRING, "怎么称呼TA", True), + ("impression_update", STRING, "你对TA的新认识(简要描述即可,系统会用你的语气润色)", True), +] +``` + +### 3.2 工具2:`remember_user_info` - 记住重要信息 + +```python +name = "remember_user_info" +description = """当你知道了关于某人的长期重要信息时使用。 +比如:生日、职业、理想、家庭、宠物等不会经常变的信息。""" + +parameters = [ + ("user_id", STRING, "用户ID", True), + ("user_name", STRING, "怎么称呼TA", True), + ("info_type", STRING, "信息类型:birthday/job/location/dream/family/pet/other", True), + ("info_value", STRING, "具体内容", True), +] +``` + +### 3.3 更新流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ tool_use 模型判断:需要更新印象/记住信息吗? │ +└─────────────────────────────────────────────────────────────┘ + │ 是 + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 调用工具,传入简要信息 │ +│ - impression_update: "他是个很细心的人" │ +│ - 或 info_type: "birthday", info_value: "11月23日" │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ relationship_tracker 模型生成完整内容 │ +│ - 融合现有印象 + 新信息 │ +│ - 用人设语气润色 │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 写入 user_relationships 表 │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、读取和展示 + +### 4.1 构建关系信息时的呈现 + +修改 `RelationshipFetcher.build_relation_info()`: + +```python +async def build_relation_info(self, person_id, points_num=5): + """构建关于某人的关系信息(新版)""" + + person_info_manager = get_person_info_manager() + person_name = await person_info_manager.get_value(person_id, "person_name") + + # 从 user_relationships 表读取所有数据 + relationship = await get_user_relationship(platform, user_id, "bot") + + if not relationship: + return f"你完全不认识{person_name},这是你们第一次交流。" + + relation_parts = [] + + # 1. 基本信息:认识时间 + if relationship.first_met_time: + from datetime import datetime + know_time = datetime.fromtimestamp(relationship.first_met_time).strftime("%Y年%m月") + relation_parts.append(f"你从{know_time}开始认识{person_name},已经聊过很多次了") + + # 2. 别名 + if relationship.user_aliases: + aliases_str = relationship.user_aliases.replace(",", "、") + relation_parts.append(f"{person_name}的别名:{aliases_str}") + + # 3. 关系程度(保留好感度!) + if relationship.relationship_score is not None: + score = relationship.relationship_score + stage_desc = self._get_relationship_stage_description(relationship.relationship_stage or score) + relation_parts.append(f"你和{person_name}的关系:{stage_desc}(好感度{score:.2f})") + + # 4. 核心:长期印象(新字段) + if relationship.impression_text: + relation_parts.append(f"\n你对{person_name}的印象:\n{relationship.impression_text}") + + # 5. 喜好和兴趣(保留!) + if relationship.preference_keywords: + keywords_str = relationship.preference_keywords.replace(",", "、") + relation_parts.append(f"\n{person_name}的喜好和兴趣:{keywords_str}") + + # 6. 关键信息(新字段) + if relationship.key_facts: + facts = json.loads(relationship.key_facts) + if facts: + facts_lines = [self._format_fact(f) for f in facts] + relation_parts.append(f"\n你记住的关于{person_name}的重要信息:\n" + "\n".join(facts_lines)) + + # 构建最终输出 + if relation_parts: + return f"关于{person_name},你知道以下信息:\n" + "\n".join([f"• {p}" if not p.startswith("\n") else p for p in relation_parts]) + else: + return f"你完全不认识{person_name},这是你们第一次交流。" + +def _format_fact(self, fact: dict) -> str: + """格式化单条关键信息""" + type_names = { + "birthday": "生日", + "job": "工作", + "location": "所在地", + "dream": "理想", + "family": "家庭", + "pet": "宠物", + "other": "其他" + } + type_name = type_names.get(fact.get("type", "other"), "其他") + return f"• {type_name}:{fact.get('value', '')}" +``` + +### 4.2 展示效果示例 + +**完整的提示词插入内容**: + +``` +关于言柒,你知道以下信息: +• 你从2024年6月开始认识言柒,已经聊过很多次了 +• 言柒的别名:柒柒、小柒 +• 你和言柒的关系:很亲近的好友(好感度0.82) + +你对言柒的印象: +柒柒是个典型的理科生,说话很有逻辑,但偶尔会冒出一些冷笑话让人忍俊不禁。 +和他聊天挺舒服的,不会有压力,他也不会问让人尴尬的问题。 + +他在游戏公司做后端开发,虽然经常加班但从不抱怨,感觉是个挺拼的人。 +他对技术很感兴趣,经常问我一些AI相关的问题,虽然有些问题挺刁钻 +但能感觉到他是真的好奇而不是在刁难我。 + +他说以后想开一家自己的咖啡店,深夜聊天时说的,感觉不是随便说说的。 +总的来说他给我的感觉是"表面随意但内心认真"的那种人。 + +言柒的喜好和兴趣:群内梗文化、AI技术、编程、游戏 + +你记住的关于言柒的重要信息: +• 生日:11月23日 +• 工作:游戏公司后端程序员 +• 理想:想开咖啡店 +• 宠物:养了只橘猫叫橘子 +``` + +--- + +## 五、迁移计划 + +### 5.1 数据库变更 + +```sql +-- 1. 扩展 user_relationships 表 +ALTER TABLE user_relationships ADD COLUMN impression_text TEXT; -- 长期印象 +ALTER TABLE user_relationships ADD COLUMN key_facts TEXT; -- 关键信息JSON +ALTER TABLE user_relationships ADD COLUMN relationship_stage VARCHAR(50) DEFAULT 'stranger'; +ALTER TABLE user_relationships ADD COLUMN first_met_time FLOAT; +ALTER TABLE user_relationships ADD COLUMN last_impression_update FLOAT; + +-- 2. 保留的字段 +-- relationship_score 保留!很多地方在用 +-- preference_keywords 保留!喜好是印象的一部分 +-- relationship_text 可作为 impression_text 的初始值迁移 + +-- 3. person_info 表不删除,但停止写入印象相关字段 +``` + +### 5.2 代码变更清单 + +| 文件 | 变更内容 | +|------|---------| +| `models.py` | 扩展 UserRelationships 模型 | +| `user_profile_tool.py` | 重构为新的印象更新逻辑 | +| `relationship_fetcher.py` | 修改读取逻辑,只从 user_relationships 读 | +| `relationship_manager.py` | 废弃或精简,只保留必要的统计功能 | +| 新增 `user_fact_tool.py` | 记录具体信息的工具 | + +### 5.3 废弃的功能 + +以下功能将**停止使用**(代码保留但不调用): + +- `RelationshipManager.update_person_impression()` - 停止写入 person_info +- `RelationshipManager._update_impression()` - 停止使用 +- `person_info` 表的以下字段停止更新: + - `impression` + - `short_impression` + - `points` + - `forgotten_points` + - `attitude` + +--- + +## 六、实施步骤 + +### Phase 1:数据库扩展 ✅ 已完成 +- [x] 修改 `models.py`,添加新字段 + - `impression_text`: 长期印象 + - `key_facts`: 关键信息JSON + - `relationship_stage`: 关系阶段 + - `first_met_time`: 认识时间 + - `last_impression_update`: 上次更新时间 + +### Phase 2:核心工具重构 ✅ 已完成 +- [x] 重构 `UserProfileTool` + - [x] 新增 `_get_recent_chat_history()` 读取聊天记录 + - [x] 新增 `_generate_impression_with_affection()` 联动生成印象+好感度 + - [x] 新增 `_calculate_relationship_stage()` 自动计算关系阶段 +- [x] 创建 `UserFactTool` (remember_user_info 工具) +- [x] 编写印象生成提示词(包含好感度变化规则) + +### Phase 3:读取层适配 ✅ 已完成 +- [x] 修改 `RelationshipFetcher.build_relation_info()` + - [x] 读取新字段 `impression_text`, `key_facts`, `relationship_stage` + - [x] 新增辅助方法 `_get_stage_description()`, `_format_key_facts()` +- [x] 优先使用新字段,兼容旧字段 + +### Phase 4:清理与优化(待后续) +- [ ] 标记废弃的代码 +- [ ] 添加迁移说明 +- [ ] 性能测试 + +--- + +## 七、新旧系统提示词输出对比 + +### 7.1 旧系统输出(当前) + +``` +关于言柒,你知道以下信息: +• 用户言柒在QQ平台的昵称是柒柒 +• 你从2024年06月21日开始认识言柒 +• 你们已经交流过2357次 +• 最近一次交流是在08月21日 +• 你对言柒的态度是比较有好感 +• 你对ta的总体印象:一个挺有趣的理科男 +• 更详细的了解:这位用户是群里的'真相洞察者'与'梗文化解构师',善于在群聊中发现有趣的点并进行吐槽,说话直接但不会让人觉得刻薄。对技术尤其是AI领域比较感兴趣,经常会问一些技术相关的问题。 +• 你记得关于言柒的一些事情: + - 他说最近工作很累经常加班(2024-08-20) + - 他对AI很感兴趣想学习(2024-08-15) + - 他通过了新公司的面试很开心(2024-08-10) +• 言柒的别名有:柒柒、小柒 +• 你对言柒的整体认知:这位用户是群里的'真相洞察者'与'梗文化解构师'... +• 言柒的偏好和兴趣:群内梗文化、真相揭露、二次元文化、AI技术、编程 +• 你们的关系程度:非常亲密的好友(0.82) +``` + +**问题**: +- 数据来自两个表,有重复("总体印象"和"整体认知"说的差不多) +- 格式像数据库报表,不像人的认知 +- points 是碎片化的事件记录,不是对人的理解 + +--- + +### 7.2 新系统输出(改造后) + +``` +关于言柒,你知道以下信息: +• 你从2024年6月开始认识言柒,已经聊过很多次了 +• 言柒的别名:柒柒、小柒 +• 你和言柒的关系:很亲近的好友(好感度0.82) + +你对言柒的印象: +柒柒是个典型的理科生,说话很有逻辑,但偶尔会冒出一些冷笑话让人忍俊不禁。 +和他聊天挺舒服的,不会有压力,他也不会问让人尴尬的问题。 + +他在游戏公司做后端开发,虽然经常加班但从不抱怨,感觉是个挺拼的人。 +他对技术很感兴趣,经常问我一些AI相关的问题,虽然有些问题挺刁钻 +但能感觉到他是真的好奇而不是在刁难我。 + +他说以后想开一家自己的咖啡店,深夜聊天时说的,感觉不是随便说说的。 +总的来说他给我的感觉是"表面随意但内心认真"的那种人。 + +言柒的喜好和兴趣:群内梗文化、AI技术、编程、游戏 + +你记住的关于言柒的重要信息: +• 生日:11月23日 +• 工作:游戏公司后端程序员 +• 理想:想开咖啡店 +• 宠物:养了只橘猫叫橘子 +``` + +**改进点**: +- 只从 `user_relationships` 一个表读取,不再重复 +- **长期印象**(impression_text)是完整的叙事,像真的在描述一个认识的人 +- **好感度**(relationship_score)保留,给AI参考 +- **喜好**(preference_keywords)保留,是印象的一部分 +- **关键信息**(key_facts)是长期稳定的事实,不是临时事件 +- 临时事件("最近加班很累")不在这里,交给记忆系统 + +--- + +### 7.3 数据来源对照 + +| 输出内容 | 旧系统来源 | 新系统来源 | +|---------|-----------|-----------| +| 认识时间 | person_info.know_since | user_relationships.first_met_time | +| 别名 | person_info.nickname + user_relationships.user_aliases | user_relationships.user_aliases | +| 好感度 | person_info.attitude | **保留** user_relationships.relationship_score | +| 印象 | person_info.impression + user_relationships.relationship_text | **合并到** user_relationships.impression_text | +| 喜好 | user_relationships.preference_keywords | **保留** user_relationships.preference_keywords | +| 记忆点 | person_info.points | 移除(交给记忆系统) | +| 关键信息 | 无 | **新增** user_relationships.key_facts | + +--- + +## 附录:相关文件 + +| 文件 | 状态 | 说明 | +|------|------|------| +| `src/common/database/core/models.py` | 需修改 | 扩展 UserRelationships | +| `src/plugins/.../user_profile_tool.py` | 需重构 | 新的印象更新逻辑 | +| `src/person_info/relationship_fetcher.py` | 需修改 | 适配新数据结构 | +| `src/person_info/relationship_manager.py` | 废弃 | 印象功能停用 | +| `src/person_info/person_info.py` | 保留 | 只用于基础信息 | diff --git a/src/common/database/api/specialized.py b/src/common/database/api/specialized.py index 01b2372e2..5625c3795 100644 --- a/src/common/database/api/specialized.py +++ b/src/common/database/api/specialized.py @@ -401,26 +401,21 @@ async def get_usage_statistics( # ===== UserRelationships 业务API ===== -@cached(ttl=600, key_prefix="user_relationship") # 缓存10分钟 +# 注意:这个函数不使用缓存,因为用户画像工具会频繁更新,需要实时读取最新数据 async def get_user_relationship( - platform: str, user_id: str, - target_id: str, + **_kwargs, # 兼容旧调用,忽略platform和target_id ) -> UserRelationships | None: """获取用户关系 Args: - platform: 平台 user_id: 用户ID - target_id: 目标用户ID Returns: 用户关系实例 """ return await _user_relationships_crud.get_by( - platform=platform, user_id=user_id, - target_id=target_id, ) diff --git a/src/common/database/core/migration.py b/src/common/database/core/migration.py index 5b718224f..c1408c791 100644 --- a/src/common/database/core/migration.py +++ b/src/common/database/core/migration.py @@ -105,8 +105,7 @@ async def check_and_migrate_database(existing_engine=None): column = table.c[column_name] # 获取列类型的 SQL 表示 - # 使用 compile 方法获取正确的类型字符串 - type_compiler = dialect.type_compiler(dialect) + # 直接使用 compile 方法,它会自动选择正确的方言 column_type_sql = column.type.compile(dialect=dialect) # 构建 ALTER TABLE 语句 diff --git a/src/common/database/core/models.py b/src/common/database/core/models.py index 8fcf4145d..175173639 100644 --- a/src/common/database/core/models.py +++ b/src/common/database/core/models.py @@ -655,7 +655,16 @@ class UserPermissions(Base): class UserRelationships(Base): - """用户关系模型 - 存储用户与bot的关系数据""" + """用户关系模型 - 存储用户与bot的关系数据 + + 核心字段: + - relationship_text: 当前印象描述(用于兼容旧系统,逐步迁移到 impression_text) + - impression_text: 长期印象(新字段,自然叙事风格) + - preference_keywords: 用户偏好关键词 + - relationship_score: 好感度分数(0-1) + - key_facts: 关键信息JSON(生日、职业、理想等) + - relationship_stage: 关系阶段(stranger/acquaintance/friend/close_friend/bestie) + """ __tablename__ = "user_relationships" @@ -663,9 +672,22 @@ class UserRelationships(Base): user_id: Mapped[str] = mapped_column(get_string_field(100), nullable=False, unique=True, index=True) user_name: Mapped[str | None] = mapped_column(get_string_field(100), nullable=True) user_aliases: Mapped[str | None] = mapped_column(Text, nullable=True) # 用户别名,逗号分隔 - relationship_text: Mapped[str | None] = mapped_column(Text, nullable=True) + + # 印象相关(新旧兼容) + relationship_text: Mapped[str | None] = mapped_column(Text, nullable=True) # 旧字段,保持兼容 + impression_text: Mapped[str | None] = mapped_column(Text, nullable=True) # 新字段:长期印象(自然叙事) + + # 用户信息 preference_keywords: Mapped[str | None] = mapped_column(Text, nullable=True) # 用户偏好关键词,逗号分隔 - relationship_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.3) # 关系分数(0-1) + key_facts: Mapped[str | None] = mapped_column(Text, nullable=True) # 关键信息JSON(生日、职业等) + + # 关系状态 + relationship_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.3) # 好感度(0-1) + relationship_stage: Mapped[str | None] = mapped_column(get_string_field(50), nullable=True, default="stranger") # 关系阶段 + + # 时间记录 + first_met_time: Mapped[float | None] = mapped_column(Float, nullable=True) # 首次认识时间戳 + last_impression_update: Mapped[float | None] = mapped_column(Float, nullable=True) # 上次更新印象时间 last_updated: Mapped[float] = mapped_column(Float, nullable=False, default=time.time) created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow, nullable=False) @@ -673,4 +695,5 @@ class UserRelationships(Base): Index("idx_user_relationship_id", "user_id"), Index("idx_relationship_score", "relationship_score"), Index("idx_relationship_updated", "last_updated"), + Index("idx_relationship_stage", "relationship_stage"), ) diff --git a/src/memory_graph/unified_manager.py b/src/memory_graph/unified_manager.py index 5bfb7ce8b..51cee0d9a 100644 --- a/src/memory_graph/unified_manager.py +++ b/src/memory_graph/unified_manager.py @@ -352,7 +352,6 @@ class UnifiedMemoryManager: - 如果是纯闲聊("你好"、"哈哈"、表情)→ 现有记忆充足 - 如果涉及具体话题(人物、事件、知识)→ 考虑检索长期记忆 - 如果用户提到过去的经历或需要回忆 → 需要检索长期记忆 -3. **倾向于检索**:当不确定时,倾向于设置 `is_sufficient: false`,让系统检索长期记忆以提供更丰富的上下文。 **输出格式(JSON):** ```json diff --git a/src/person_info/relationship_fetcher.py b/src/person_info/relationship_fetcher.py index db1722310..e5df2c651 100644 --- a/src/person_info/relationship_fetcher.py +++ b/src/person_info/relationship_fetcher.py @@ -101,7 +101,11 @@ class RelationshipFetcher: del self.info_fetched_cache[person_id] async def build_relation_info(self, person_id, points_num=5): - """构建详细的人物关系信息,包含从数据库中查询的丰富关系描述""" + """构建详细的人物关系信息 + + 注意:现在只从 user_relationships 表读取印象和关系数据, + person_info 表只用于获取基础信息(用户名、平台等) + """ # 初始化log_prefix await self._initialize_log_prefix() @@ -109,128 +113,84 @@ class RelationshipFetcher: self._cleanup_expired_cache() person_info_manager = get_person_info_manager() + + # 仅从 person_info 获取基础信息(不获取印象相关字段) person_name = await person_info_manager.get_value(person_id, "person_name") - short_impression = await person_info_manager.get_value(person_id, "short_impression") - full_impression = await person_info_manager.get_value(person_id, "impression") - attitude = await person_info_manager.get_value(person_id, "attitude") or 50 - - nickname_str = await person_info_manager.get_value(person_id, "nickname") platform = await person_info_manager.get_value(person_id, "platform") - know_times = await person_info_manager.get_value(person_id, "know_times") or 0 - know_since = await person_info_manager.get_value(person_id, "know_since") - last_know = await person_info_manager.get_value(person_id, "last_know") - - # 获取用户特征点 - current_points = await person_info_manager.get_value(person_id, "points") or [] - forgotten_points = await person_info_manager.get_value(person_id, "forgotten_points") or [] - - # 确保 points 是列表类型(可能从数据库返回字符串) - if not isinstance(current_points, list): - current_points = [] - if not isinstance(forgotten_points, list): - forgotten_points = [] - - # 按时间排序并选择最有代表性的特征点 - all_points = current_points + forgotten_points - if all_points: - # 按权重和时效性综合排序 - all_points.sort( - key=lambda x: (float(x[1]) if len(x) > 1 else 0, float(x[2]) if len(x) > 2 else 0), reverse=True - ) - selected_points = all_points[:points_num] - points_text = "\n".join([f"- {point[0]}({point[2]})" for point in selected_points if len(point) > 2]) - else: - points_text = "" # 构建详细的关系描述 relation_parts = [] - # 1. 基本信息 - if nickname_str and person_name != nickname_str: - relation_parts.append(f"用户{person_name}在{platform}平台的昵称是{nickname_str}") - - # 2. 认识时间和频率 - if know_since: - from datetime import datetime - - know_time = datetime.fromtimestamp(know_since).strftime("%Y年%m月%d日") - relation_parts.append(f"你从{know_time}开始认识{person_name}") - - if know_times > 0: - relation_parts.append(f"你们已经交流过{int(know_times)}次") - - if last_know: - from datetime import datetime - - last_time = datetime.fromtimestamp(last_know).strftime("%m月%d日") - relation_parts.append(f"最近一次交流是在{last_time}") - - # 3. 态度和印象 - attitude_desc = self._get_attitude_description(attitude) - relation_parts.append(f"你对{person_name}的态度是{attitude_desc}") - - if short_impression: - relation_parts.append(f"你对ta的总体印象:{short_impression}") - - if full_impression: - relation_parts.append(f"更详细的了解:{full_impression}") - - # 4. 特征点和记忆 - if points_text: - relation_parts.append(f"你记得关于{person_name}的一些事情:\n{points_text}") - - # 5. 从UserRelationships表获取完整关系信息(新系统) + # 从 UserRelationships 表获取完整关系信息(这是唯一的印象数据来源) try: from src.common.database.api.specialized import get_user_relationship # 查询用户关系数据 user_id = str(await person_info_manager.get_value(person_id, "user_id")) - platform = str(await person_info_manager.get_value(person_id, "platform")) - # 使用优化后的API(带缓存) - relationship = await get_user_relationship( - platform=platform, - user_id=user_id, - target_id="bot", # 或者根据实际需要传入目标用户ID - ) + # 使用优化后的API(带缓存)- 只需要user_id + relationship = await get_user_relationship(user_id=user_id) if relationship: - # 将SQLAlchemy对象转换为字典以保持兼容性 - # 直接使用 __dict__ 访问,避免触发 SQLAlchemy 的描述符和 lazy loading - # 方案A已经确保所有字段在缓存前都已预加载,所以 __dict__ 中有完整数据 + # 将SQLAlchemy对象转换为字典 try: rel_data = { "user_aliases": relationship.__dict__.get("user_aliases"), "relationship_text": relationship.__dict__.get("relationship_text"), + "impression_text": relationship.__dict__.get("impression_text"), "preference_keywords": relationship.__dict__.get("preference_keywords"), + "key_facts": relationship.__dict__.get("key_facts"), "relationship_score": relationship.__dict__.get("relationship_score"), + "relationship_stage": relationship.__dict__.get("relationship_stage"), + "first_met_time": relationship.__dict__.get("first_met_time"), } except Exception as attr_error: logger.warning(f"访问relationship对象属性失败: {attr_error}") rel_data = {} - # 5.1 用户别名 + # 1. 用户别名 if rel_data.get("user_aliases"): aliases_list = [alias.strip() for alias in rel_data["user_aliases"].split(",") if alias.strip()] if aliases_list: aliases_str = "、".join(aliases_list) relation_parts.append(f"{person_name}的别名有:{aliases_str}") - # 5.2 关系印象文本(主观认知) - if rel_data.get("relationship_text"): - relation_parts.append(f"你对{person_name}的整体认知:{rel_data['relationship_text']}") + # 2. 关系阶段和好感度 + if rel_data.get("relationship_score") is not None: + score = rel_data["relationship_score"] + stage = rel_data.get("relationship_stage") or self._get_stage_from_score(score) + stage_desc = self._get_stage_description(stage) + relation_parts.append(f"你和{person_name}的关系:{stage_desc}(好感度{score:.2f})") - # 5.3 用户偏好关键词 + # 3. 认识时间 + if rel_data.get("first_met_time"): + from datetime import datetime + first_met = datetime.fromtimestamp(rel_data["first_met_time"]).strftime("%Y年%m月") + relation_parts.append(f"你们从{first_met}开始认识") + + # 4. 长期印象(优先使用新字段 impression_text,回退到 relationship_text) + impression = rel_data.get("impression_text") or rel_data.get("relationship_text") + if impression: + relation_parts.append(f"\n你对{person_name}的印象:\n{impression}") + + # 5. 用户偏好关键词 if rel_data.get("preference_keywords"): keywords_list = [kw.strip() for kw in rel_data["preference_keywords"].split(",") if kw.strip()] if keywords_list: keywords_str = "、".join(keywords_list) - relation_parts.append(f"{person_name}的偏好和兴趣:{keywords_str}") + relation_parts.append(f"\n{person_name}的偏好和兴趣:{keywords_str}") - # 5.4 关系亲密程度(好感分数) - if rel_data.get("relationship_score") is not None: - score_desc = self._get_relationship_score_description(rel_data["relationship_score"]) - relation_parts.append(f"你们的关系程度:{score_desc}({rel_data['relationship_score']:.2f})") + # 6. 关键信息 + if rel_data.get("key_facts"): + try: + import orjson + facts = orjson.loads(rel_data["key_facts"]) + if facts and isinstance(facts, list): + facts_lines = self._format_key_facts(facts, person_name) + if facts_lines: + relation_parts.append(f"\n你记住的关于{person_name}的重要信息:\n{facts_lines}") + except Exception: + pass except Exception as e: logger.error(f"查询UserRelationships表失败: {e}") @@ -383,6 +343,54 @@ class RelationshipFetcher: else: return "陌生人" + def _get_stage_from_score(self, score: float) -> str: + """根据好感度分数返回关系阶段""" + if score >= 0.9: + return "bestie" + elif score >= 0.75: + return "close_friend" + elif score >= 0.6: + return "friend" + elif score >= 0.4: + return "familiar" + elif score >= 0.2: + return "acquaintance" + else: + return "stranger" + + def _get_stage_description(self, stage: str) -> str: + """根据关系阶段返回描述性文字""" + stage_map = { + "stranger": "陌生人", + "acquaintance": "初识", + "familiar": "熟人", + "friend": "朋友", + "close_friend": "好友", + "bestie": "挚友", + } + return stage_map.get(stage, "未知关系") + + def _format_key_facts(self, facts: list, person_name: str) -> str: + """格式化关键信息列表""" + type_names = { + "birthday": "生日", + "job": "工作", + "location": "所在地", + "dream": "理想", + "family": "家庭", + "pet": "宠物", + "other": "其他" + } + lines = [] + for fact in facts: + if isinstance(fact, dict): + fact_type = fact.get("type", "other") + value = fact.get("value", "") + type_name = type_names.get(fact_type, "其他") + if value: + lines.append(f"• {type_name}:{value}") + return "\n".join(lines) + async def _build_fetch_query(self, person_id, target_message, chat_history): nickname_str = ",".join(global_config.bot.alias_names) name_block = f"你的名字是{global_config.bot.nickname},你的昵称有{nickname_str},有人也会用这些昵称称呼你。" diff --git a/src/plugins/built_in/affinity_flow_chatter/tools/__init__.py b/src/plugins/built_in/affinity_flow_chatter/tools/__init__.py index cce9d75ae..af91bf1ae 100644 --- a/src/plugins/built_in/affinity_flow_chatter/tools/__init__.py +++ b/src/plugins/built_in/affinity_flow_chatter/tools/__init__.py @@ -5,6 +5,7 @@ AffinityFlow Chatter 工具模块 """ from .chat_stream_impression_tool import ChatStreamImpressionTool +from .user_fact_tool import UserFactTool from .user_profile_tool import UserProfileTool -__all__ = ["ChatStreamImpressionTool", "UserProfileTool"] +__all__ = ["ChatStreamImpressionTool", "UserProfileTool", "UserFactTool"] diff --git a/src/plugins/built_in/affinity_flow_chatter/tools/user_fact_tool.py b/src/plugins/built_in/affinity_flow_chatter/tools/user_fact_tool.py new file mode 100644 index 000000000..0ad2612a0 --- /dev/null +++ b/src/plugins/built_in/affinity_flow_chatter/tools/user_fact_tool.py @@ -0,0 +1,170 @@ +""" +用户关键信息记录工具 + +用于记录用户的长期重要信息,如生日、职业、理想、宠物等。 +这些信息会存储在 user_relationships 表的 key_facts 字段中。 +""" + +import time +from typing import Any + +import orjson +from sqlalchemy import select + +from src.common.database.compatibility import get_db_session +from src.common.database.core.models import UserRelationships +from src.common.logger import get_logger +from src.plugin_system import BaseTool, ToolParamType + +logger = get_logger("user_fact_tool") + + +class UserFactTool(BaseTool): + """用户关键信息记录工具 + + 用于记录生日、职业、理想、宠物等长期重要信息。 + 注意:一般情况下使用 update_user_profile 工具即可同时记录印象和关键信息。 + 此工具仅在需要单独补充记录信息时使用。 + """ + + name = "remember_user_info" + description = """【备用工具】单独记录用户的重要个人信息。 +注意:大多数情况请直接使用 update_user_profile 工具(它可以同时更新印象和记录关键信息)。 +仅当你只想补充记录一条信息、不需要更新印象时才使用此工具。""" + + parameters = [ + ("target_user_id", ToolParamType.STRING, "目标用户的ID(必须)", True, None), + ("target_user_name", ToolParamType.STRING, "目标用户的名字/昵称(必须)", True, None), + ("info_type", ToolParamType.STRING, "信息类型:birthday(生日)/job(职业)/location(所在地)/dream(理想)/family(家庭)/pet(宠物)/other(其他)", True, None), + ("info_value", ToolParamType.STRING, "具体内容,如'11月23日'、'程序员'、'想开咖啡店'", True, None), + ] + available_for_llm = True + history_ttl = 5 + + async def execute(self, function_args: dict[str, Any]) -> dict[str, Any]: + """执行关键信息记录 + + Args: + function_args: 工具参数 + + Returns: + dict: 执行结果 + """ + try: + # 提取参数 + target_user_id = function_args.get("target_user_id") + target_user_name = function_args.get("target_user_name", target_user_id) + info_type = function_args.get("info_type", "other") + info_value = function_args.get("info_value", "") + + if not target_user_id: + return { + "type": "error", + "id": "remember_user_info", + "content": "错误:必须提供目标用户ID" + } + + if not info_value: + return { + "type": "error", + "id": "remember_user_info", + "content": "错误:必须提供要记录的信息内容" + } + + # 验证 info_type + valid_types = ["birthday", "job", "location", "dream", "family", "pet", "other"] + if info_type not in valid_types: + info_type = "other" + + # 更新数据库 + await self._add_key_fact(target_user_id, info_type, info_value) + + # 生成友好的类型名称 + type_names = { + "birthday": "生日", + "job": "职业", + "location": "所在地", + "dream": "理想", + "family": "家庭", + "pet": "宠物", + "other": "其他信息" + } + type_name = type_names.get(info_type, "信息") + + result_text = f"已记住 {target_user_name} 的{type_name}:{info_value}" + logger.info(f"记录用户关键信息: {target_user_id}, {info_type}={info_value}") + + return { + "type": "user_fact_recorded", + "id": target_user_id, + "content": result_text + } + + except Exception as e: + logger.error(f"记录用户关键信息失败: {e}") + return { + "type": "error", + "id": function_args.get("target_user_id", "unknown"), + "content": f"记录失败: {e!s}" + } + + async def _add_key_fact(self, user_id: str, info_type: str, info_value: str): + """添加或更新关键信息 + + Args: + user_id: 用户ID + info_type: 信息类型 + info_value: 信息内容 + """ + try: + current_time = time.time() + + async with get_db_session() as session: + stmt = select(UserRelationships).where(UserRelationships.user_id == user_id) + result = await session.execute(stmt) + existing = result.scalar_one_or_none() + + if existing: + # 解析现有的 key_facts + try: + facts = orjson.loads(existing.key_facts) if existing.key_facts else [] + except Exception: + facts = [] + + if not isinstance(facts, list): + facts = [] + + # 查找是否已有相同类型的信息 + found = False + for i, fact in enumerate(facts): + if isinstance(fact, dict) and fact.get("type") == info_type: + # 更新现有记录 + facts[i] = {"type": info_type, "value": info_value} + found = True + break + + if not found: + # 添加新记录 + facts.append({"type": info_type, "value": info_value}) + + # 更新数据库 + existing.key_facts = orjson.dumps(facts).decode("utf-8") + existing.last_updated = current_time + else: + # 创建新用户记录 + facts = [{"type": info_type, "value": info_value}] + new_profile = UserRelationships( + user_id=user_id, + user_name=user_id, + key_facts=orjson.dumps(facts).decode("utf-8"), + first_met_time=current_time, + last_updated=current_time + ) + session.add(new_profile) + + await session.commit() + logger.info(f"关键信息已保存: {user_id}, {info_type}={info_value}") + + except Exception as e: + logger.error(f"保存关键信息失败: {e}") + raise diff --git a/src/plugins/built_in/affinity_flow_chatter/tools/user_profile_tool.py b/src/plugins/built_in/affinity_flow_chatter/tools/user_profile_tool.py index 0ed7379db..7e04e4680 100644 --- a/src/plugins/built_in/affinity_flow_chatter/tools/user_profile_tool.py +++ b/src/plugins/built_in/affinity_flow_chatter/tools/user_profile_tool.py @@ -3,7 +3,10 @@ 采用两阶段设计: 1. 工具调用模型(tool_use)负责判断是否需要更新,传入基本信息 -2. 关系追踪模型(relationship_tracker)负责生成高质量的、有人设特色的印象内容 +2. 关系追踪模型(relationship_tracker)负责: + - 读取最近聊天记录 + - 生成高质量的、有人设特色的印象内容 + - 决定好感度变化(联动更新) """ import time @@ -11,40 +14,61 @@ from typing import Any from sqlalchemy import select +from src.chat.utils.chat_message_builder import build_readable_messages from src.common.database.compatibility import get_db_session from src.common.database.core.models import UserRelationships from src.common.logger import get_logger -from src.config.config import global_config, model_config +from src.config.config import global_config, model_config # type: ignore[attr-defined] from src.plugin_system import BaseTool, ToolParamType +# 默认好感度分数,用于配置未初始化时的回退 +DEFAULT_RELATIONSHIP_SCORE = 0.3 + logger = get_logger("user_profile_tool") +def _get_base_relationship_score() -> float: + """安全获取基础好感度分数""" + if global_config and global_config.affinity_flow: + return global_config.affinity_flow.base_relationship_score + return DEFAULT_RELATIONSHIP_SCORE + + class UserProfileTool(BaseTool): """用户画像更新工具 两阶段设计: - 第一阶段:tool_use模型判断是否更新,传入简要信息 - - 第二阶段:relationship_tracker模型生成有人设特色的印象描述 + - 第二阶段:relationship_tracker模型读取聊天记录,生成印象并决定好感度变化 """ name = "update_user_profile" - description = """当你通过聊天对某个人产生了新的认识或印象时使用此工具。 -调用时机:当你发现TA透露了新信息、展现了性格特点、表达了兴趣爱好,或你们的互动让你对TA有了新感受时。 -注意:impression_hint只需要简单描述你观察到的要点,系统会自动用你的人设风格来润色生成最终印象。""" + description = """记录你对某个人的重要认识。【不要频繁调用】 +只在以下情况使用: +1. TA首次告诉你【具体的个人信息】:生日日期、职业、所在城市、真实姓名等 → 必填 key_info_type 和 key_info_value +2. 你对TA产生了【显著的、值得长期记住的】新印象(不是每次聊天都要记) +3. 你们的关系有了【实质性变化】 + +【不要调用的情况】: +- 普通的日常对话、闲聊 +- 只是聊得开心但没有实质性新认识 +- TA只是表达了一下情绪或感受 +- 你已经记录过类似的印象 +此工具会在后台异步执行,不会阻塞你的回复。""" parameters = [ ("target_user_id", ToolParamType.STRING, "目标用户的ID(必须)", True, None), ("target_user_name", ToolParamType.STRING, "目标用户的名字/昵称(必须,用于生成印象时称呼)", True, None), ("user_aliases", ToolParamType.STRING, "TA的其他昵称或别名,多个用逗号分隔(可选)", False, None), - ("impression_hint", ToolParamType.STRING, "【简要描述】你观察到的关于TA的要点,如'很健谈,喜欢聊游戏,有点害羞'。系统会用你的人设风格润色(可选)", False, None), + ("impression_hint", ToolParamType.STRING, "【简要描述】你观察到的关于TA的要点,如'很健谈,喜欢聊游戏,有点害羞'(可选,只填显著的新认识)", False, None), ("preference_keywords", ToolParamType.STRING, "TA的兴趣爱好关键词,如'编程,游戏,音乐',用逗号分隔(可选)", False, None), - ("affection_score", ToolParamType.FLOAT, "你对TA的好感度(0.0-1.0)。0.3=普通认识,0.5=还不错的朋友,0.7=很喜欢,0.9=非常亲密。打分要保守(可选)", False, None), + ("key_info_type", ToolParamType.STRING, "【重要信息类型】birthday(生日日期)/job(职业)/location(城市)/dream(人生理想)/family(家庭成员)/pet(宠物名字)。只有TA告诉你这些【具体事实】时才填,不要用other!", False, None), + ("key_info_value", ToolParamType.STRING, "【重要信息内容】如'11月23日'、'程序员'、'北京'。必须是具体的事实信息!", False, None), ] available_for_llm = True history_ttl = 5 async def execute(self, function_args: dict[str, Any]) -> dict[str, Any]: - """执行用户画像更新 + """执行用户画像更新(异步后台执行,不阻塞回复) Args: function_args: 工具参数 @@ -52,6 +76,8 @@ class UserProfileTool(BaseTool): Returns: dict: 执行结果 """ + import asyncio + try: # 提取参数 target_user_id = function_args.get("target_user_id") @@ -67,61 +93,33 @@ class UserProfileTool(BaseTool): new_aliases = function_args.get("user_aliases", "") impression_hint = function_args.get("impression_hint", "") new_keywords = function_args.get("preference_keywords", "") - new_score = function_args.get("affection_score") - - # 从数据库获取现有用户画像 - existing_profile = await self._get_user_profile(target_user_id) + key_info_type = function_args.get("key_info_type", "") + key_info_value = function_args.get("key_info_value", "") # 如果LLM没有传入任何有效参数,返回提示 - if not any([new_aliases, impression_hint, new_keywords, new_score is not None]): + if not any([new_aliases, impression_hint, new_keywords, key_info_value]): return { "type": "info", "id": target_user_id, - "content": "提示:需要提供至少一项更新内容(别名、印象描述、偏好关键词或好感分数)" + "content": "提示:需要提供至少一项更新内容(别名、印象描述、偏好关键词或重要信息)" } - # 🎯 核心:使用relationship_tracker模型生成高质量印象 - final_impression = existing_profile.get("relationship_text", "") - if impression_hint: - final_impression = await self._generate_impression_with_personality( - target_user_name=str(target_user_name) if target_user_name else str(target_user_id), - impression_hint=str(impression_hint), - existing_impression=str(existing_profile.get("relationship_text", "")), - preference_keywords=str(new_keywords or existing_profile.get("preference_keywords", "")), - ) - - # 构建最终画像 - final_profile = { - "user_aliases": new_aliases if new_aliases else existing_profile.get("user_aliases", ""), - "relationship_text": final_impression, - "preference_keywords": new_keywords if new_keywords else existing_profile.get("preference_keywords", ""), - "relationship_score": new_score if new_score is not None else existing_profile.get("relationship_score", global_config.affinity_flow.base_relationship_score), - } - - # 确保分数在有效范围内 - final_profile["relationship_score"] = max(0.0, min(1.0, float(final_profile["relationship_score"]))) - - # 更新数据库 - await self._update_user_profile_in_db(target_user_id, final_profile) - - # 构建返回信息 - updates = [] - if final_profile.get("user_aliases"): - updates.append(f"别名: {final_profile['user_aliases']}") - if final_profile.get("relationship_text"): - updates.append(f"印象: {final_profile['relationship_text'][:80]}...") - if final_profile.get("preference_keywords"): - updates.append(f"偏好: {final_profile['preference_keywords']}") - if final_profile.get("relationship_score") is not None: - updates.append(f"好感分: {final_profile['relationship_score']:.2f}") - - result_text = f"已更新用户 {target_user_name} 的画像:\n" + "\n".join(updates) - logger.info(f"用户画像更新成功: {target_user_id}") + # 🎯 异步后台执行,不阻塞回复 + asyncio.create_task(self._background_update( + target_user_id=target_user_id, + target_user_name=str(target_user_name) if target_user_name else str(target_user_id), + new_aliases=new_aliases, + impression_hint=impression_hint, + new_keywords=new_keywords, + key_info_type=key_info_type, + key_info_value=key_info_value, + )) + # 立即返回,让回复继续 return { "type": "user_profile_update", "id": target_user_id, - "content": result_text + "content": f"正在后台更新对 {target_user_name} 的印象..." } except Exception as e: @@ -132,89 +130,374 @@ class UserProfileTool(BaseTool): "content": f"用户画像更新失败: {e!s}" } - async def _generate_impression_with_personality( + async def _background_update( + self, + target_user_id: str, + target_user_name: str, + new_aliases: str, + impression_hint: str, + new_keywords: str, + key_info_type: str = "", + key_info_value: str = "", + ): + """后台执行用户画像更新""" + try: + # 从数据库获取现有用户画像 + existing_profile = await self._get_user_profile(target_user_id) + + # 🎯 如果有关键信息,先保存(生日、职业等重要信息) + if key_info_value: + await self._add_key_fact(target_user_id, key_info_type or "other", key_info_value) + logger.info(f"[后台] 已记录关键信息: {target_user_id}, {key_info_type}={key_info_value}") + + # 获取最近的聊天记录 + chat_history_text = await self._get_recent_chat_history(target_user_id) + + # 🎯 核心:使用relationship_tracker模型生成印象并决定好感度变化 + final_impression = existing_profile.get("relationship_text", "") + affection_change = 0.0 # 好感度变化量 + + if impression_hint or chat_history_text: + impression_result = await self._generate_impression_with_affection( + target_user_name=target_user_name, + impression_hint=impression_hint, + existing_impression=str(existing_profile.get("relationship_text", "")), + preference_keywords=str(new_keywords or existing_profile.get("preference_keywords", "")), + chat_history=chat_history_text, + current_score=float(existing_profile.get("relationship_score", _get_base_relationship_score())), + ) + final_impression = impression_result.get("impression", final_impression) + affection_change = impression_result.get("affection_change", 0.0) + + # 计算新的好感度 + old_score = float(existing_profile.get("relationship_score", _get_base_relationship_score())) + new_score = old_score + affection_change + new_score = max(0.0, min(1.0, new_score)) # 确保在0-1范围内 + + # 构建最终画像 + final_profile = { + "user_aliases": new_aliases if new_aliases else existing_profile.get("user_aliases", ""), + "relationship_text": final_impression, + "preference_keywords": new_keywords if new_keywords else existing_profile.get("preference_keywords", ""), + "relationship_score": new_score, + } + + # 更新数据库 + await self._update_user_profile_in_db(target_user_id, final_profile) + + logger.info(f"[后台] 用户画像更新成功: {target_user_id}, 好感度变化: {affection_change:+.2f}") + + except Exception as e: + logger.error(f"[后台] 用户画像更新失败: {e}") + + async def _add_key_fact(self, user_id: str, info_type: str, info_value: str): + """添加或更新关键信息(生日、职业等) + + Args: + user_id: 用户ID + info_type: 信息类型(birthday/job/location/dream/family/pet/other) + info_value: 信息内容 + """ + import orjson + + try: + # 验证 info_type + valid_types = ["birthday", "job", "location", "dream", "family", "pet", "other"] + if info_type not in valid_types: + info_type = "other" + + current_time = time.time() + + async with get_db_session() as session: + stmt = select(UserRelationships).where(UserRelationships.user_id == user_id) + result = await session.execute(stmt) + existing = result.scalar_one_or_none() + + if existing: + # 解析现有的 key_facts + try: + facts = orjson.loads(existing.key_facts) if existing.key_facts else [] + except Exception: + facts = [] + + if not isinstance(facts, list): + facts = [] + + # 查找是否已有相同类型的信息 + found = False + for i, fact in enumerate(facts): + if isinstance(fact, dict) and fact.get("type") == info_type: + # 更新现有记录 + facts[i] = {"type": info_type, "value": info_value} + found = True + break + + if not found: + # 添加新记录 + facts.append({"type": info_type, "value": info_value}) + + # 更新数据库 + existing.key_facts = orjson.dumps(facts).decode("utf-8") + existing.last_updated = current_time + else: + # 创建新用户记录 + facts = [{"type": info_type, "value": info_value}] + new_profile = UserRelationships( + user_id=user_id, + user_name=user_id, + key_facts=orjson.dumps(facts).decode("utf-8"), + first_met_time=current_time, + last_updated=current_time + ) + session.add(new_profile) + + await session.commit() + + # 清除缓存,确保下次查询获取最新数据 + try: + from src.common.database.optimization.cache_manager import get_cache + cache = await get_cache() + cache_key = f"user_relationships:filter:[('user_id', '{user_id}')]" + await cache.delete(cache_key) + logger.debug(f"已清除用户关系缓存: {user_id}") + except Exception as cache_err: + logger.warning(f"清除缓存失败(不影响数据保存): {cache_err}") + + logger.info(f"关键信息已保存: {user_id}, {info_type}={info_value}") + + except Exception as e: + logger.error(f"保存关键信息失败: {e}") + # 不抛出异常,因为这是后台任务 + + async def _get_recent_chat_history(self, target_user_id: str, max_messages: int = 40) -> str: + """获取最近的聊天记录 + + Args: + target_user_id: 目标用户ID + max_messages: 最大消息数量 + + Returns: + str: 格式化的聊天记录文本 + """ + try: + # 从 chat_stream 获取上下文 + if not self.chat_stream: + logger.warning("chat_stream 未初始化,无法获取聊天记录") + return "" + + context = getattr(self.chat_stream, "context", None) + if not context: + logger.warning("chat_stream.context 不存在,无法获取聊天记录") + return "" + + # 获取最近的消息 - 使用正确的方法名 get_messages + messages = context.get_messages(limit=max_messages, include_unread=True) + if not messages: + return "" + + # 将 DatabaseMessages 对象转换为字典列表 + messages_dict = [] + for msg in messages: + try: + if hasattr(msg, 'to_dict'): + messages_dict.append(msg.to_dict()) + elif hasattr(msg, '__dict__'): + # 手动构建字典 + msg_dict = { + "time": getattr(msg, "time", 0), + "processed_plain_text": getattr(msg, "processed_plain_text", ""), + "display_message": getattr(msg, "display_message", ""), + } + # 处理 user_info + user_info = getattr(msg, "user_info", None) + if user_info: + msg_dict["user_info"] = { + "user_id": getattr(user_info, "user_id", ""), + "user_nickname": getattr(user_info, "user_nickname", ""), + } + # 处理 chat_info + chat_info = getattr(msg, "chat_info", None) + if chat_info: + msg_dict["chat_info"] = { + "platform": getattr(chat_info, "platform", ""), + } + messages_dict.append(msg_dict) + except Exception as e: + logger.warning(f"转换消息失败: {e}") + continue + + if not messages_dict: + return "" + + # 构建可读的消息文本 + readable_messages = await build_readable_messages( + messages=messages_dict, + replace_bot_name=True, + timestamp_mode="normal_no_YMD", + truncate=True + ) + + return readable_messages or "" + + except Exception as e: + logger.error(f"获取聊天记录失败: {e}") + return "" + + async def _generate_impression_with_affection( self, target_user_name: str, impression_hint: str, existing_impression: str, preference_keywords: str, - ) -> str: - """使用relationship_tracker模型生成有人设特色的印象描述 + chat_history: str, + current_score: float, + ) -> dict[str, Any]: + """使用relationship_tracker模型生成印象并决定好感度变化 Args: target_user_name: 目标用户的名字 impression_hint: 工具调用模型传入的简要观察 existing_impression: 现有的印象描述 preference_keywords: 用户的兴趣偏好 + chat_history: 最近的聊天记录 + current_score: 当前好感度分数 Returns: - str: 生成的印象描述 + dict: {"impression": str, "affection_change": float} """ try: + import orjson + from json_repair import repair_json from src.llm_models.utils_model import LLMRequest - # 获取人设信息 - bot_name = global_config.bot.nickname - personality_core = global_config.personality.personality_core - personality_side = global_config.personality.personality_side + # 获取人设信息(添加空值保护) + bot_name = global_config.bot.nickname if global_config and global_config.bot else "Bot" + personality_core = global_config.personality.personality_core if global_config and global_config.personality else "" + personality_side = global_config.personality.personality_side if global_config and global_config.personality else "" + reply_style = global_config.personality.reply_style if global_config and global_config.personality else "" # 构建提示词 - prompt = f"""你是{bot_name},现在要记录你对一个人的印象。 + # 根据是否有旧印象决定任务类型 + is_first_impression = not existing_impression or len(existing_impression) < 20 + + prompt = f"""你是{bot_name},现在要记录你对"{target_user_name}"的印象。 -## 你的人设 +## 你的核心人格 {personality_core} -## 你的性格特点 +## 你的性格侧面 {personality_side} -## 任务 -根据下面的观察要点,用你自己的语气和视角,写一段对"{target_user_name}"的印象描述。 +## 你的说话风格 +{reply_style} -## 观察到的要点 -{impression_hint} +## 你之前对{target_user_name}的印象 +{existing_impression if existing_impression else "(这是你第一次记录对TA的印象)"} -## TA的兴趣爱好 +## 最近的聊天记录 +{chat_history if chat_history else "(无聊天记录)"} + +## 这次观察到的新要点 +{impression_hint if impression_hint else "(无特别观察)"} + +## {target_user_name}的兴趣爱好 {preference_keywords if preference_keywords else "暂未了解"} -## 之前对TA的印象(如果有) -{existing_impression if existing_impression else "这是第一次记录对TA的印象"} +## 当前好感度 +{current_score:.2f} (范围0-1,0.3=普通认识,0.5=朋友,0.7=好友,0.9=挚友) -## 写作要求 -1. 用第一人称"我"来写,就像在写日记或者跟朋友聊天时描述一个人 -2. 用"{target_user_name}"或"TA"来称呼对方,不要用"该用户"、"此人" -3. 写出你真实的、主观的感受,可以带情绪和直觉判断 -4. 如果有之前的印象,可以结合新观察进行补充或修正 -5. 长度控制在50-150字,自然流畅 +## 任务 +1. 根据聊天记录判断{target_user_name}的性别(男用"他",女用"她",无法判断用名字) +2. {"写下你对这个人的第一印象" if is_first_impression else "在原有印象基础上,融入新的观察"} +3. 决定好感度是否需要变化(大多数情况不需要) -请直接输出印象描述,不要加任何前缀或解释:""" +## 印象写作要求 +- 用第一人称"我"来写 +- 根据判断的性别使用"他/她",或者直接用"{target_user_name}" +- {"第一印象可以短一些,50-150字,写下初步感受" if is_first_impression else "在原有印象基础上补充新认识,150-300字"} +- {"不要假装很熟,你们才刚认识" if is_first_impression else "体现出你们相处时间的积累"} +- 写出这个人的特点、性格、给你的感觉 - # 使用relationship_tracker模型 +## 好感度变化规则(极度严格!) +- 范围:-0.03 到 +0.03,**但默认是0,90%以上的对话好感度应该不变** +- 好感度是长期积累的结果,不是短短几句话就能改变的 +- **绝对不变(=0)的情况**: + - 普通聊天、日常问候、闲聊 + - 正常的交流,即使聊得很开心 + - 分享日常、讨论话题 + - 简单的互相关心 +- **可能微涨(+0.01)的情况**(很少见): + - 对方真正信任你,分享了很私密的心事或秘密 + - 在你困难时主动帮助 +- **可能涨(+0.02~0.03)的情况**(非常罕见): + - 真正触动内心的深度交流 + - 长期相处后的重要情感突破 +- 记住:聊得好≠好感度增加,好感是需要长时间培养的 + +请严格按照以下JSON格式输出: +{{ + "gender": "male/female/unknown", + "impression": "你对{target_user_name}的印象...", + "affection_change": 0, + "change_reason": "无变化/变化原因" +}}""" + + # 使用relationship_tracker模型(添加空值保护) + if not model_config or not model_config.model_task_config: + raise ValueError("model_config 未初始化") + llm = LLMRequest( model_set=model_config.model_task_config.relationship_tracker, - request_type="user_profile.impression_generator" + request_type="user_profile.impression_and_affection" ) response, _ = await llm.generate_response_async( prompt=prompt, temperature=0.7, - max_tokens=300, + max_tokens=600, ) - # 清理响应 - impression = response.strip() - - # 如果响应为空或太短,回退到原始hint - if not impression or len(impression) < 10: - logger.warning(f"印象生成结果过短,使用原始hint: {impression_hint}") - return impression_hint + # 解析响应 + response = response.strip() + try: + result = orjson.loads(repair_json(response)) + impression = result.get("impression", "") + affection_change = float(result.get("affection_change", 0)) + change_reason = result.get("change_reason", "") + detected_gender = result.get("gender", "unknown") - logger.info(f"成功生成有人设特色的印象描述,长度: {len(impression)}") - return impression + # 限制好感度变化范围(严格:-0.03 到 +0.03) + affection_change = max(-0.03, min(0.03, affection_change)) + + # 如果印象为空或太短,回退到hint + if not impression or len(impression) < 10: + logger.warning(f"印象生成结果过短,使用原始hint") + impression = impression_hint or existing_impression + + logger.info(f"印象更新: 用户性别判断={detected_gender}, 好感度变化={affection_change:+.3f}") + if change_reason: + logger.info(f"好感度变化原因: {change_reason}") + + return { + "impression": impression, + "affection_change": affection_change + } + + except Exception as parse_error: + logger.warning(f"解析JSON失败: {parse_error},尝试提取文本") + # 如果JSON解析失败,尝试直接使用响应作为印象 + return { + "impression": response if len(response) > 10 else (impression_hint or existing_impression), + "affection_change": 0.0 + } except Exception as e: - logger.error(f"生成印象描述失败,回退到原始hint: {e}") - # 失败时回退到工具调用模型传入的hint - return impression_hint + logger.error(f"生成印象和好感度失败: {e}") + # 失败时回退 + return { + "impression": impression_hint or existing_impression, + "affection_change": 0.0 + } async def _get_user_profile(self, user_id: str) -> dict[str, Any]: """从数据库获取用户现有画像 @@ -232,12 +515,18 @@ class UserProfileTool(BaseTool): profile = result.scalar_one_or_none() if profile: + # 优先使用新字段 impression_text,如果没有则用旧字段 relationship_text + impression = profile.impression_text or profile.relationship_text or "" return { "user_name": profile.user_name or user_id, "user_aliases": profile.user_aliases or "", - "relationship_text": profile.relationship_text or "", + "relationship_text": impression, # 兼容旧代码 + "impression_text": impression, "preference_keywords": profile.preference_keywords or "", - "relationship_score": float(profile.relationship_score) if profile.relationship_score is not None else global_config.affinity_flow.base_relationship_score, + "key_facts": profile.key_facts or "[]", + "relationship_score": float(profile.relationship_score) if profile.relationship_score is not None else _get_base_relationship_score(), + "relationship_stage": profile.relationship_stage or "stranger", + "first_met_time": profile.first_met_time, } else: # 用户不存在,返回默认值 @@ -245,8 +534,12 @@ class UserProfileTool(BaseTool): "user_name": user_id, "user_aliases": "", "relationship_text": "", + "impression_text": "", "preference_keywords": "", - "relationship_score": global_config.affinity_flow.base_relationship_score, + "key_facts": "[]", + "relationship_score": _get_base_relationship_score(), + "relationship_stage": "stranger", + "first_met_time": None, } except Exception as e: logger.error(f"获取用户画像失败: {e}") @@ -254,8 +547,12 @@ class UserProfileTool(BaseTool): "user_name": user_id, "user_aliases": "", "relationship_text": "", + "impression_text": "", "preference_keywords": "", - "relationship_score": global_config.affinity_flow.base_relationship_score, + "key_facts": "[]", + "relationship_score": _get_base_relationship_score(), + "relationship_stage": "stranger", + "first_met_time": None, } @@ -275,31 +572,81 @@ class UserProfileTool(BaseTool): result = await session.execute(stmt) existing = result.scalar_one_or_none() + # 根据好感度自动计算关系阶段 + score = profile.get("relationship_score", 0.3) + stage = self._calculate_relationship_stage(score) + if existing: # 更新现有记录 existing.user_aliases = profile.get("user_aliases", "") - existing.relationship_text = profile.get("relationship_text", "") + # 同时更新新旧两个印象字段,保持兼容 + impression = profile.get("relationship_text", "") + existing.relationship_text = impression + existing.impression_text = impression existing.preference_keywords = profile.get("preference_keywords", "") - existing.relationship_score = profile.get("relationship_score", global_config.affinity_flow.base_relationship_score) + existing.relationship_score = score + existing.relationship_stage = stage + existing.last_impression_update = current_time existing.last_updated = current_time + # 如果是首次认识,记录时间 + if not existing.first_met_time: + existing.first_met_time = current_time else: # 创建新记录 + impression = profile.get("relationship_text", "") new_profile = UserRelationships( user_id=user_id, user_name=user_id, user_aliases=profile.get("user_aliases", ""), - relationship_text=profile.get("relationship_text", ""), + relationship_text=impression, + impression_text=impression, preference_keywords=profile.get("preference_keywords", ""), - relationship_score=profile.get("relationship_score", global_config.affinity_flow.base_relationship_score), + relationship_score=score, + relationship_stage=stage, + first_met_time=current_time, + last_impression_update=current_time, last_updated=current_time ) session.add(new_profile) await session.commit() - logger.info(f"用户画像已更新到数据库: {user_id}") + + # 清除缓存,确保下次查询获取最新数据 + try: + from src.common.database.optimization.cache_manager import get_cache + cache = await get_cache() + cache_key = f"user_relationships:filter:[('user_id', '{user_id}')]" + await cache.delete(cache_key) + logger.debug(f"已清除用户关系缓存: {user_id}") + except Exception as cache_err: + logger.warning(f"清除缓存失败(不影响数据保存): {cache_err}") + + logger.info(f"用户画像已更新到数据库: {user_id}, 阶段: {stage}") except Exception as e: logger.error(f"更新用户画像到数据库失败: {e}") raise + def _calculate_relationship_stage(self, score: float) -> str: + """根据好感度分数计算关系阶段 + + Args: + score: 好感度分数(0-1) + + Returns: + str: 关系阶段 + """ + if score >= 0.9: + return "bestie" # 挚友 + elif score >= 0.75: + return "close_friend" # 好友 + elif score >= 0.6: + return "friend" # 朋友 + elif score >= 0.4: + return "familiar" # 熟人 + elif score >= 0.2: + return "acquaintance" # 初识 + else: + return "stranger" # 陌生人 +