feat(profile):对用户关系和分析系统进行重构,采用结构化数据和异步更新

此提交完全重写了用户关系和分析系统,创建了一个更强大、详细和响应式的框架。旧系统已被弃用,取而代之的是一个集中式的`UserRelationships`模型。

主要变更:

1.  ‌**增强数据库模型(`UserRelationships`):**‌
    - 添加`impression_text`用于长期、叙述式印象。
    - 引入`key_facts`(JSON)存储结构化数据如生日、工作和位置。
    - 添加`relationship_stage`跟踪关系进展(如陌生人、朋友、挚友)。
    - 添加`first_met_time`和`last_impression_update`的时间戳。

2.  ‌**重设计`UserProfileTool`:**‌
    - 工具的用途被限定为仅捕捉重要新信息,防止用于小聊。
    - 更新现在在后台异步处理,确保机器人回复不被延迟。
    - 引入`key_info_type`和`key_info_value`参数供LLM提交结构化事实。

3.  ‌**复杂的印象和情感逻辑:**‌
    - 关系追踪LLM现在分析最近聊天历史生成更丰富、更上下文的印象。
    - 用渐进的`affection_change`(最大±0.03)取代直接情感分数设置,使关系发展更真实。

4.  ‌**数据源整合:**‌
    - `RelationshipFetcher`重构为仅依赖`UserRelationships`表作为唯一数据源。
    - 简化`get_user_relationship` API并移除其缓存,确保分析的实时数据访问。

破坏性变更:`UserProfileTool`已重设计,新增参数(`key_info_type`、`key_info_value`)并改变用途。移除`affection_score`参数。此外,`get_user_relationship`数据库API签名简化为仅接受`user_id`。
This commit is contained in:
tt-P607
2025-12-03 16:53:40 +08:00
parent 39c52490d9
commit 2671a6e7e5
9 changed files with 1233 additions and 202 deletions

View File

@@ -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,
)

View File

@@ -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 语句

View File

@@ -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"),
)

View File

@@ -352,7 +352,6 @@ class UnifiedMemoryManager:
- 如果是纯闲聊("你好""哈哈"、表情)→ 现有记忆充足
- 如果涉及具体话题(人物、事件、知识)→ 考虑检索长期记忆
- 如果用户提到过去的经历或需要回忆 → 需要检索长期记忆
3. **倾向于检索**:当不确定时,倾向于设置 `is_sufficient: false`,让系统检索长期记忆以提供更丰富的上下文。
**输出格式JSON**
```json

View File

@@ -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},有人也会用这些昵称称呼你。"

View File

@@ -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"]

View File

@@ -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

View File

@@ -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-10.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**但默认是090%以上的对话好感度应该不变**
- 好感度是长期积累的结果,不是短短几句话就能改变的
- **绝对不变(=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" # 陌生人