- Fix episodic memory time filter to use UTC (datetime.fromtimestamp with tz=timezone.utc) to match Neo4j stored UTC timestamps - Add POST /v1/memory/analytics/generate_cache endpoint for cache generation via API Key Modified files: - api/app/services/memory_explicit_service.py - api/app/controllers/service/user_memory_api_controller.py
478 lines
18 KiB
Python
478 lines
18 KiB
Python
"""
|
||
显性记忆服务
|
||
|
||
处理显性记忆相关的业务逻辑,包括情景记忆和语义记忆的查询。
|
||
"""
|
||
|
||
from typing import Any, Dict, Optional
|
||
|
||
from app.core.logging_config import get_logger
|
||
from app.services.memory_base_service import MemoryBaseService
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
|
||
class MemoryExplicitService(MemoryBaseService):
|
||
"""显性记忆服务类"""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
logger.info("MemoryExplicitService initialized")
|
||
|
||
async def get_explicit_memory_overview(
|
||
self,
|
||
end_user_id: str
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
获取显性记忆总览信息
|
||
|
||
返回两部分:
|
||
1. 情景记忆(episodic_memories)- 来自MemorySummary节点
|
||
2. 语义记忆(semantic_memories)- 来自ExtractedEntity节点(is_explicit_memory=true)
|
||
|
||
Args:
|
||
end_user_id: 终端用户ID
|
||
|
||
Returns:
|
||
{
|
||
"total": int,
|
||
"episodic_memories": [
|
||
{
|
||
"id": str,
|
||
"title": str,
|
||
"content": str,
|
||
"created_at": int
|
||
}
|
||
],
|
||
"semantic_memories": [
|
||
{
|
||
"id": str,
|
||
"name": str,
|
||
"entity_type": str,
|
||
"core_definition": str
|
||
}
|
||
]
|
||
}
|
||
"""
|
||
try:
|
||
logger.info(f"开始查询 end_user_id={end_user_id} 的显性记忆总览(情景记忆+语义记忆)")
|
||
|
||
# ========== 1. 查询情景记忆(MemorySummary节点) ==========
|
||
episodic_query = """
|
||
MATCH (s:MemorySummary)
|
||
WHERE s.end_user_id = $end_user_id
|
||
RETURN elementId(s) AS id,
|
||
s.name AS title,
|
||
s.content AS content,
|
||
s.created_at AS created_at
|
||
ORDER BY s.created_at DESC
|
||
"""
|
||
|
||
episodic_result = await self.neo4j_connector.execute_query(
|
||
episodic_query,
|
||
end_user_id=end_user_id
|
||
)
|
||
|
||
# 处理情景记忆数据
|
||
episodic_memories = []
|
||
if episodic_result:
|
||
for record in episodic_result:
|
||
summary_id = record["id"]
|
||
title = record.get("title") or "未命名"
|
||
content = record.get("content") or ""
|
||
created_at_str = record.get("created_at")
|
||
|
||
# 使用基类方法转换时间戳
|
||
created_at_timestamp = self.parse_timestamp(created_at_str)
|
||
|
||
# 注意:总览接口不返回 emotion 字段
|
||
episodic_memories.append({
|
||
"id": summary_id,
|
||
"title": title,
|
||
"content": content,
|
||
"created_at": created_at_timestamp
|
||
})
|
||
|
||
# ========== 2. 查询语义记忆(ExtractedEntity节点) ==========
|
||
semantic_query = """
|
||
MATCH (e:ExtractedEntity)
|
||
WHERE e.end_user_id = $end_user_id
|
||
AND e.is_explicit_memory = true
|
||
RETURN elementId(e) AS id,
|
||
e.name AS name,
|
||
e.entity_type AS entity_type,
|
||
e.description AS core_definition
|
||
ORDER BY e.name ASC
|
||
"""
|
||
|
||
semantic_result = await self.neo4j_connector.execute_query(
|
||
semantic_query,
|
||
end_user_id=end_user_id
|
||
)
|
||
|
||
# 处理语义记忆数据
|
||
semantic_memories = []
|
||
if semantic_result:
|
||
for record in semantic_result:
|
||
entity_id = record["id"]
|
||
name = record.get("name") or "未命名"
|
||
entity_type = record.get("entity_type") or "未分类"
|
||
core_definition = record.get("core_definition") or ""
|
||
|
||
# 注意:总览接口不返回 detailed_notes 和 created_at 字段
|
||
semantic_memories.append({
|
||
"id": entity_id,
|
||
"name": name,
|
||
"entity_type": entity_type,
|
||
"core_definition": core_definition
|
||
})
|
||
|
||
# ========== 3. 返回结果 ==========
|
||
total_count = len(episodic_memories) + len(semantic_memories)
|
||
|
||
logger.info(
|
||
f"成功获取 end_user_id={end_user_id} 的显性记忆总览,"
|
||
f"情景记忆={len(episodic_memories)} 条,语义记忆={len(semantic_memories)} 条,"
|
||
f"总计 {total_count} 条"
|
||
)
|
||
|
||
return {
|
||
"total": total_count,
|
||
"episodic_memories": episodic_memories,
|
||
"semantic_memories": semantic_memories
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取显性记忆总览时出错: {str(e)}", exc_info=True)
|
||
raise
|
||
|
||
|
||
async def get_episodic_memory_list(
|
||
self,
|
||
end_user_id: str,
|
||
page: int,
|
||
pagesize: int,
|
||
start_date: Optional[int] = None,
|
||
end_date: Optional[int] = None,
|
||
episodic_type: str = "all",
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
获取情景记忆分页列表
|
||
|
||
Args:
|
||
end_user_id: 终端用户ID
|
||
page: 页码
|
||
pagesize: 每页数量
|
||
start_date: 开始时间戳(毫秒),可选
|
||
end_date: 结束时间戳(毫秒),可选
|
||
episodic_type: 情景类型筛选
|
||
|
||
Returns:
|
||
{
|
||
"total": int, # 该用户情景记忆总数(不受筛选影响)
|
||
"items": [...], # 当前页数据
|
||
"page": {
|
||
"page": int,
|
||
"pagesize": int,
|
||
"total": int, # 筛选后总数
|
||
"hasnext": bool
|
||
}
|
||
}
|
||
"""
|
||
try:
|
||
logger.info(
|
||
f"情景记忆分页查询: end_user_id={end_user_id}, "
|
||
f"start_date={start_date}, end_date={end_date}, "
|
||
f"episodic_type={episodic_type}, page={page}, pagesize={pagesize}"
|
||
)
|
||
|
||
# 1. 查询情景记忆总数(不受筛选条件限制)
|
||
total_all_query = """
|
||
MATCH (s:MemorySummary)
|
||
WHERE s.end_user_id = $end_user_id
|
||
RETURN count(s) AS total
|
||
"""
|
||
total_all_result = await self.neo4j_connector.execute_query(
|
||
total_all_query, end_user_id=end_user_id
|
||
)
|
||
total_all = total_all_result[0]["total"] if total_all_result else 0
|
||
|
||
# 2. 构建筛选条件
|
||
where_clauses = ["s.end_user_id = $end_user_id"]
|
||
params = {"end_user_id": end_user_id}
|
||
|
||
# 时间戳筛选(毫秒时间戳转为 UTC ISO 字符串,使用 Neo4j datetime() 精确比较)
|
||
if start_date is not None and end_date is not None:
|
||
from datetime import datetime, timezone
|
||
start_dt = datetime.fromtimestamp(start_date / 1000, tz=timezone.utc)
|
||
end_dt = datetime.fromtimestamp(end_date / 1000, tz=timezone.utc)
|
||
# 开始时间取当天 UTC 00:00:00,结束时间取当天 UTC 23:59:59.999999
|
||
start_iso = start_dt.strftime("%Y-%m-%dT") + "00:00:00.000000"
|
||
end_iso = end_dt.strftime("%Y-%m-%dT") + "23:59:59.999999"
|
||
|
||
where_clauses.append("datetime(s.created_at) >= datetime($start_iso) AND datetime(s.created_at) <= datetime($end_iso)")
|
||
params["start_iso"] = start_iso
|
||
params["end_iso"] = end_iso
|
||
|
||
# 类型筛选下推到 Cypher(兼容中英文)
|
||
if episodic_type != "all":
|
||
type_mapping = {
|
||
"conversation": "对话",
|
||
"project_work": "项目/工作",
|
||
"learning": "学习",
|
||
"decision": "决策",
|
||
"important_event": "重要事件"
|
||
}
|
||
chinese_type = type_mapping.get(episodic_type)
|
||
if chinese_type:
|
||
where_clauses.append(
|
||
"(s.memory_type = $episodic_type OR s.memory_type = $chinese_type)"
|
||
)
|
||
params["episodic_type"] = episodic_type
|
||
params["chinese_type"] = chinese_type
|
||
else:
|
||
where_clauses.append("s.memory_type = $episodic_type")
|
||
params["episodic_type"] = episodic_type
|
||
|
||
where_str = " AND ".join(where_clauses)
|
||
|
||
# 3. 查询筛选后的总数
|
||
count_query = f"""
|
||
MATCH (s:MemorySummary)
|
||
WHERE {where_str}
|
||
RETURN count(s) AS total
|
||
"""
|
||
count_result = await self.neo4j_connector.execute_query(count_query, **params)
|
||
filtered_total = count_result[0]["total"] if count_result else 0
|
||
|
||
# 4. 查询分页数据
|
||
skip = (page - 1) * pagesize
|
||
data_query = f"""
|
||
MATCH (s:MemorySummary)
|
||
WHERE {where_str}
|
||
RETURN elementId(s) AS id,
|
||
s.name AS title,
|
||
s.memory_type AS memory_type,
|
||
s.content AS content,
|
||
s.created_at AS created_at
|
||
ORDER BY s.created_at DESC
|
||
SKIP $skip LIMIT $limit
|
||
"""
|
||
params["skip"] = skip
|
||
params["limit"] = pagesize
|
||
|
||
result = await self.neo4j_connector.execute_query(data_query, **params)
|
||
|
||
# 5. 处理结果
|
||
items = []
|
||
if result:
|
||
for record in result:
|
||
raw_created_at = record.get("created_at")
|
||
created_at_timestamp = self.parse_timestamp(raw_created_at)
|
||
items.append({
|
||
"id": record["id"],
|
||
"title": record.get("title") or "未命名",
|
||
"memory_type": record.get("memory_type") or "其他",
|
||
"content": record.get("content") or "",
|
||
"created_at": created_at_timestamp
|
||
})
|
||
|
||
# 6. 构建返回结果
|
||
return {
|
||
"total": total_all,
|
||
"items": items,
|
||
"page": {
|
||
"page": page,
|
||
"pagesize": pagesize,
|
||
"total": filtered_total,
|
||
"hasnext": (page * pagesize) < filtered_total
|
||
}
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"情景记忆分页查询出错: {str(e)}", exc_info=True)
|
||
raise
|
||
|
||
async def get_semantic_memory_list(
|
||
self,
|
||
end_user_id: str
|
||
) -> list:
|
||
"""
|
||
获取语义记忆全量列表
|
||
|
||
Args:
|
||
end_user_id: 终端用户ID
|
||
|
||
Returns:
|
||
[
|
||
{
|
||
"id": str,
|
||
"name": str,
|
||
"entity_type": str,
|
||
"core_definition": str
|
||
}
|
||
]
|
||
"""
|
||
try:
|
||
logger.info(f"语义记忆列表查询: end_user_id={end_user_id}")
|
||
|
||
semantic_query = """
|
||
MATCH (e:ExtractedEntity)
|
||
WHERE e.end_user_id = $end_user_id
|
||
AND e.is_explicit_memory = true
|
||
RETURN elementId(e) AS id,
|
||
e.name AS name,
|
||
e.entity_type AS entity_type,
|
||
e.description AS core_definition
|
||
ORDER BY e.name ASC
|
||
"""
|
||
|
||
result = await self.neo4j_connector.execute_query(
|
||
semantic_query, end_user_id=end_user_id
|
||
)
|
||
|
||
items = []
|
||
if result:
|
||
for record in result:
|
||
items.append({
|
||
"id": record["id"],
|
||
"name": record.get("name") or "未命名",
|
||
"entity_type": record.get("entity_type") or "未分类",
|
||
"core_definition": record.get("core_definition") or ""
|
||
})
|
||
|
||
logger.info(f"语义记忆列表查询成功: end_user_id={end_user_id}, total={len(items)}")
|
||
|
||
return items
|
||
|
||
except Exception as e:
|
||
logger.error(f"语义记忆列表查询出错: {str(e)}", exc_info=True)
|
||
raise
|
||
|
||
async def get_explicit_memory_details(
|
||
self,
|
||
end_user_id: str,
|
||
memory_id: str
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
获取显性记忆详情
|
||
|
||
根据 memory_id 查询情景记忆或语义记忆的详细信息。
|
||
先尝试查询情景记忆,如果找不到再查询语义记忆。
|
||
|
||
Args:
|
||
end_user_id: 终端用户ID
|
||
memory_id: 记忆ID(可以是情景记忆或语义记忆的ID)
|
||
|
||
Returns:
|
||
情景记忆返回:
|
||
{
|
||
"memory_type": "episodic",
|
||
"title": str,
|
||
"content": str,
|
||
"emotion": Dict,
|
||
"created_at": int
|
||
}
|
||
|
||
语义记忆返回:
|
||
{
|
||
"memory_type": "semantic",
|
||
"name": str,
|
||
"core_definition": str,
|
||
"detailed_notes": str,
|
||
"created_at": int
|
||
}
|
||
|
||
Raises:
|
||
ValueError: 当记忆不存在时
|
||
"""
|
||
try:
|
||
logger.info(f"开始查询显性记忆详情: end_user_id={end_user_id}, memory_id={memory_id}")
|
||
|
||
# ========== 1. 先尝试查询情景记忆 ==========
|
||
episodic_query = """
|
||
MATCH (s:MemorySummary)
|
||
WHERE elementId(s) = $memory_id AND s.end_user_id = $end_user_id
|
||
RETURN s.name AS title,
|
||
s.content AS content,
|
||
s.created_at AS created_at
|
||
"""
|
||
|
||
episodic_result = await self.neo4j_connector.execute_query(
|
||
episodic_query,
|
||
memory_id=memory_id,
|
||
end_user_id=end_user_id
|
||
)
|
||
|
||
if episodic_result and len(episodic_result) > 0:
|
||
record = episodic_result[0]
|
||
title = record.get("title") or "未命名"
|
||
content = record.get("content") or ""
|
||
created_at_str = record.get("created_at")
|
||
|
||
# 使用基类方法转换时间戳
|
||
created_at_timestamp = self.parse_timestamp(created_at_str)
|
||
|
||
# 使用基类方法获取情绪信息
|
||
emotion = await self.extract_episodic_emotion(
|
||
summary_id=memory_id,
|
||
end_user_id=end_user_id
|
||
)
|
||
|
||
logger.info(f"成功获取情景记忆详情: memory_id={memory_id}")
|
||
return {
|
||
"memory_type": "episodic",
|
||
"title": title,
|
||
"content": content,
|
||
"emotion": emotion,
|
||
"created_at": created_at_timestamp
|
||
}
|
||
|
||
# ========== 2. 如果不是情景记忆,尝试查询语义记忆 ==========
|
||
semantic_query = """
|
||
MATCH (e:ExtractedEntity)
|
||
WHERE elementId(e) = $memory_id
|
||
AND e.end_user_id = $end_user_id
|
||
AND e.is_explicit_memory = true
|
||
RETURN e.name AS name,
|
||
e.description AS core_definition,
|
||
e.example AS detailed_notes,
|
||
e.created_at AS created_at
|
||
"""
|
||
|
||
semantic_result = await self.neo4j_connector.execute_query(
|
||
semantic_query,
|
||
memory_id=memory_id,
|
||
end_user_id=end_user_id
|
||
)
|
||
|
||
if semantic_result and len(semantic_result) > 0:
|
||
record = semantic_result[0]
|
||
name = record.get("name") or "未命名"
|
||
core_definition = record.get("core_definition") or ""
|
||
detailed_notes = record.get("detailed_notes") or ""
|
||
created_at_str = record.get("created_at")
|
||
|
||
# 使用基类方法转换时间戳
|
||
created_at_timestamp = self.parse_timestamp(created_at_str)
|
||
|
||
logger.info(f"成功获取语义记忆详情: memory_id={memory_id}")
|
||
return {
|
||
"memory_type": "semantic",
|
||
"name": name,
|
||
"core_definition": core_definition,
|
||
"detailed_notes": detailed_notes,
|
||
"created_at": created_at_timestamp
|
||
}
|
||
|
||
# ========== 3. 两种记忆都找不到 ==========
|
||
logger.warning(f"记忆不存在: memory_id={memory_id}, end_user_id={end_user_id}")
|
||
raise ValueError(f"记忆不存在: memory_id={memory_id}")
|
||
|
||
except ValueError:
|
||
# 重新抛出 ValueError(记忆不存在)
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"获取显性记忆详情时出错: {str(e)}", exc_info=True)
|
||
raise
|