GAP / app /core /context /store.py
misonL's picture
Upload 52 files
e82bac2 verified
Raw
History Blame Contribute Delete
63.8 kB
# -*- coding: utf-8 -*-
"""
处理 SQLite 数据库交互,用于存储和管理对话上下文。
支持文件存储(持久化)和内存存储(临时)。
包含加载、保存、删除上下文,以及检查 TTL 和清理内存数据库的功能。
"""
import aiosqlite # 导入 aiosqlite 模块,用于异步 SQLite 操作
import logging # 导入日志模块
import os # 导入 os 模块 (在此文件中未使用,但保留可能有用)
import json # 导入 json 模块,用于序列化和反序列化上下文内容
import uuid # 导入 uuid 模块 (在此文件中未使用,但保留可能有用)
import asyncio # 导入 asyncio 库,用于异步操作和线程池
from datetime import datetime, timedelta, timezone # 导入日期、时间、时间差和时区处理
from contextlib import asynccontextmanager # 导入异步上下文管理器
from typing import Optional, List, Dict, Any, Tuple, Union, AsyncGenerator # 导入类型提示
from sqlalchemy import select, delete, update as sqlalchemy_update # <--- 修改导入
from sqlalchemy.ext.asyncio import AsyncSession # 导入 AsyncSession
from sqlalchemy.dialects.sqlite import insert as sqlite_insert # 用于 INSERT OR REPLACE
from app.core.context.converter import convert_messages # 导入消息转换函数 (新路径)
# 导入 Message Pydantic 模型,用于类型检查和数据结构定义
from app.api.models import Message
# 导入新的数据库模型
from app.core.database.models import DialogContext
# 导入数据库设置管理模块中的函数
from app.core.database.settings import get_ttl_days, set_ttl_days # (新路径)
# 导入应用配置中的内存数据库最大记录数限制
from app.config import MAX_CONTEXT_RECORDS_MEMORY, CONTEXT_STORAGE_MODE, CONTEXT_DB_PATH # 导入配置
logger = logging.getLogger('my_logger') # 获取日志记录器实例
# 注意:原文件中 get_proxy_key 和 is_valid_proxy_key 函数似乎与上下文存储关系不大,
# 且可能与 app.core.keys 中的功能重复或冲突。在添加注释时暂时保留,但建议审查其必要性。
async def get_proxy_key(key: str) -> Optional[Dict[str, Any]]:
"""
(可能需要审查/移除) 获取单个代理 Key 的信息 (似乎与 Key 管理功能重复)。
"""
if not key: return None # 如果 key 为空,返回 None
try:
# 延迟导入 db_utils
from app.core.database.utils import get_db_connection # (新路径)
async with get_db_connection() as conn: # 获取异步数据库连接
async with conn.cursor() as cursor: # 使用异步游标
# 从 proxy_keys 表查询信息 (注意:表名可能需要与 models.py 统一)
await cursor.execute("SELECT key, description, created_at, is_active FROM proxy_keys WHERE key = ?", (key,))
row = await cursor.fetchone() # 获取查询结果
# 将查询结果行转换为字典返回,如果未找到则返回 None
return dict(row) if row else None
except aiosqlite.Error as e: # 捕获数据库错误
logger.error(f"获取代理 Key {key[:8]}... 信息失败: {e}", exc_info=True) # 记录错误日志
return None # 出错时返回 None
async def is_valid_proxy_key(key: str) -> bool:
"""
(可能需要审查/移除) 检查代理 Key 是否有效且处于活动状态 (似乎与 Key 管理功能重复)。
"""
key_info = await get_proxy_key(key) # 调用上面的函数获取 Key 信息
# 返回 Key 是否存在且 is_active 字段为 True
return bool(key_info and key_info.get('is_active'))
async def save_context(proxy_key: str, contents: List[Dict[str, Any]]):
"""
异步保存或更新指定代理 Key (通常是 user_id) 的对话上下文到数据库。
如果记录已存在,则替换;如果不存在,则插入新记录。
同时会更新 `last_used` 时间戳。
在内存数据库模式下,会检查并清理超出数量限制的旧记录。
Args:
proxy_key (str): 用于标识上下文的键 (通常是 user_id)。
contents (List[Dict[str, Any]]): 要保存的 Gemini 格式的对话内容列表。
"""
# 检查输入是否有效
if not proxy_key or not contents:
logger.warning(f"尝试为 Key {proxy_key[:8]}... 保存空的上下文,已跳过。")
return
# 延迟导入数据库会话获取函数
from app.core.dependencies import get_db_session # 用于获取 AsyncSession
try:
contents_json = await asyncio.to_thread(json.dumps, contents, ensure_ascii=False)
except TypeError as json_err:
logger.error(f"序列化上下文为 JSON 时失败 (TypeError) (Key: {proxy_key[:8]}...): {json_err}", exc_info=True)
return
async for db in get_db_session(): # 获取 AsyncSession
try:
now_dt = datetime.now(timezone.utc)
# 使用 SQLAlchemy Core 的 insert 语句来实现 INSERT OR REPLACE (UPSERT)
stmt = sqlite_insert(DialogContext).values(
proxy_key=proxy_key,
contents=contents_json,
last_used_at=now_dt,
created_at=now_dt # 初始创建时 created_at 和 last_used_at 可以相同
)
# ON CONFLICT DO UPDATE 部分
update_dict = {
"contents": stmt.excluded.contents,
"last_used_at": stmt.excluded.last_used_at
}
stmt = stmt.on_conflict_do_update(
index_elements=[DialogContext.proxy_key],
set_=update_dict
)
await db.execute(stmt)
await db.commit()
logger.info(f"上下文已为 Key {proxy_key[:8]}... 使用 ORM 保存/更新。")
break # 成功后退出循环
except Exception as e:
logger.error(f"为 Key {proxy_key[:8]}... 使用 ORM 保存上下文失败: {e}", exc_info=True)
await db.rollback() # 确保回滚
break # 出错后也退出循环
finally:
# 注意:get_db_session 通常应该管理会话的关闭
pass
async def _is_context_expired(last_used_dt: datetime, ttl_delta: Optional[timedelta], proxy_key: str, db: AsyncSession) -> bool:
"""
(内部辅助函数) 检查给定的上次使用时间戳是否已过期。
如果过期,会尝试删除对应的上下文记录。
Args:
last_used_dt (datetime): 上下文的上次使用时间 (datetime 对象)。
ttl_delta (Optional[timedelta]): 上下文的生存时间间隔。如果为 None 或 0,表示永不过期。
proxy_key (str): 相关的代理 Key (用于日志记录和删除)。
db (AsyncSession): SQLAlchemy 异步数据库会话。
Returns:
bool: 如果上下文已过期或时间戳解析失败,返回 True;否则返回 False。
"""
if not ttl_delta:
return False
now_utc = datetime.now(timezone.utc)
# 确保 last_used_dt 是时区感知的 (aware)
if last_used_dt.tzinfo is None:
last_used_dt = last_used_dt.replace(tzinfo=timezone.utc) # 假设存储的是 naive UTC
if now_utc - last_used_dt > ttl_delta:
logger.info(f"Key {proxy_key[:8]}... 的上下文已超过 TTL ({ttl_delta}),将被视为过期。")
await delete_context_for_key(proxy_key, db) # 删除过期记录
return True
return False
async def delete_context_for_key(proxy_key: str, db: AsyncSession) -> bool:
"""
异步删除指定代理 Key (通常是 user_id) 的所有上下文记录。
Args:
proxy_key (str): 要删除上下文的键。
Returns:
bool: 如果成功删除(或记录本就不存在)返回 True,否则返回 False。
"""
if not proxy_key: return False
try:
stmt = delete(DialogContext).where(DialogContext.proxy_key == proxy_key)
result = await db.execute(stmt)
await db.commit()
if result.rowcount > 0:
logger.info(f"上下文已为 Key {proxy_key[:8]}... 使用 ORM 删除。")
return True
else:
logger.warning(f"尝试使用 ORM 删除 Key {proxy_key[:8]}... 的上下文,但未找到记录。")
return True # 认为删除成功(目标状态达成)
except Exception as e:
logger.error(f"使用 ORM 删除 Key {proxy_key[:8]}... 的上下文失败: {e}", exc_info=True)
await db.rollback()
return False
async def _deserialize_context_contents(contents_json: str, proxy_key: str, db: AsyncSession) -> Optional[List[Dict[str, Any]]]:
"""
(内部辅助函数) 异步反序列化存储在数据库中的上下文 JSON 字符串。
处理可能的 JSON 解析错误,并在出错时尝试删除损坏的数据。
Args:
contents_json (str): 从数据库读取的上下文内容的 JSON 字符串。
proxy_key (str): 相关的代理 Key (用于日志和删除操作)。
Returns:
Optional[List[Dict[str, Any]]]: 如果成功反序列化,返回 Python 列表;
如果反序列化失败,返回 None。
"""
try:
contents = await asyncio.to_thread(json.loads, contents_json)
logger.debug(f"上下文 JSON 已为 Key {proxy_key[:8]}... 反序列化。")
if isinstance(contents, list):
return contents
else:
logger.error(f"反序列化的上下文格式不正确 (期望列表,得到 {type(contents)}) (Key: {proxy_key[:8]}...)")
await delete_context_for_key(proxy_key, db) # 传递 db 会话
return None
except json.JSONDecodeError as e:
logger.error(f"反序列化存储的上下文时失败 (Key: {proxy_key[:8]}...): {e}", exc_info=True)
await delete_context_for_key(proxy_key, db) # 传递 db 会话
return None
except Exception as e:
logger.error(f"反序列化上下文时发生意外错误 (Key: {proxy_key[:8]}...): {e}", exc_info=True)
await delete_context_for_key(proxy_key, db) # 传递 db 会话
return None
async def load_context(proxy_key: str, db: AsyncSession) -> Optional[List[Dict[str, Any]]]:
"""
异步加载指定代理 Key (通常是 user_id) 的对话上下文。
会检查上下文的 TTL (生存时间),如果过期则删除并返回 None。
如果数据损坏无法解析,也会删除并返回 None。
Args:
proxy_key (str): 要加载上下文的键。
Returns:
Optional[List[Dict[str, Any]]]: 如果找到有效且未过期的上下文,返回 Gemini 格式的内容列表;
否则返回 None。
"""
if not proxy_key: return None
ttl_days = await get_ttl_days()
ttl_delta = timedelta(days=ttl_days) if ttl_days > 0 else None
try:
stmt = select(DialogContext).where(DialogContext.proxy_key == proxy_key)
result = await db.execute(stmt)
record: Optional[DialogContext] = result.scalar_one_or_none()
if not record or not record.contents:
logger.debug(f"未找到 Key {proxy_key[:8]}... 的上下文 (ORM)。")
return None
# TTL 检查
if await _is_context_expired(record.last_used_at, ttl_delta, proxy_key, db): # 传递 datetime 对象和 db
return None # _is_context_expired 内部会处理删除
return await _deserialize_context_contents(record.contents, proxy_key, db) # 传递 db
except Exception as e:
logger.error(f"为 Key {proxy_key[:8]}... 使用 ORM 加载上下文失败: {e}", exc_info=True)
# 不在此处回滚,因为这通常是只读操作,除非 _is_context_expired 中的删除失败
return None
async def get_context_info(proxy_key: str, db: AsyncSession) -> Optional[Dict[str, Any]]:
"""
异步获取指定代理 Key 上下文的元信息(内容长度和最后使用时间)。
Args:
proxy_key (str): 要查询的代理 Key。
Returns:
Optional[Dict[str, Any]]: 包含 'content_length' 和 'last_used' 的字典,如果未找到记录则返回 None。
"""
if not proxy_key: return None
try:
stmt = select(DialogContext.contents, DialogContext.last_used_at).where(DialogContext.proxy_key == proxy_key)
result = await db.execute(stmt)
row = result.one_or_none() # 使用 one_or_none() 获取单个记录或 None
if row:
# row 将是一个包含 'contents' 和 'last_used_at' 的 Row 对象
content_length = len(row.contents) if row.contents else 0
last_used_iso = row.last_used_at.isoformat() if row.last_used_at else None
return {"content_length": content_length, "last_used": last_used_iso}
else:
return None
except Exception as e:
logger.error(f"使用 ORM 获取 Key {proxy_key[:8]}... 的上下文信息失败: {e}", exc_info=True)
return None
async def list_all_context_keys_info(db: AsyncSession, user_key: Optional[str] = None, is_admin: bool = False) -> List[Dict[str, Any]]:
"""
异步获取存储的上下文的 Key 和元信息列表。
根据用户权限(是否为管理员)和提供的用户 Key 进行过滤。
Args:
user_key (Optional[str]): 当前请求用户的 Key (通常是 user_id)。默认为 None。
is_admin (bool): 当前用户是否具有管理员权限。默认为 False。
Returns:
List[Dict[str, Any]]: 包含上下文信息的字典列表。每个字典包含 'proxy_key', 'contents' (JSON 字符串),
'content_length', 'last_used'。
如果无权访问或出错,返回空列表。
"""
contexts_info = []
try:
stmt = select(
DialogContext.proxy_key,
DialogContext.contents,
DialogContext.last_used_at
)
if is_admin:
logger.info(f"管理员请求所有上下文信息 (ORM)...")
stmt = stmt.order_by(DialogContext.last_used_at.desc())
elif user_key:
logger.info(f"用户 {user_key[:8]}... 请求其上下文信息 (ORM)...")
stmt = stmt.where(DialogContext.proxy_key == user_key).order_by(DialogContext.last_used_at.desc())
else:
logger.warning(f"非管理员尝试列出上下文但未提供 user_key (ORM)。")
return []
result = await db.execute(stmt)
rows = result.all() # 获取所有 Row 对象
for row in rows:
contexts_info.append({
"proxy_key": row.proxy_key,
"contents": row.contents, # 保持为 JSON 字符串
"content_length": len(row.contents) if row.contents else 0,
"last_used": row.last_used_at.isoformat() if row.last_used_at else None
})
except Exception as e:
log_prefix = f"管理员" if is_admin else f"用户 {user_key[:8]}..." if user_key else "未知用户"
logger.error(f"{log_prefix} 使用 ORM 列出上下文信息失败: {e}", exc_info=True)
contexts_info = []
return contexts_info
# --- 上下文格式转换函数 (可能可以移到 converter.py) ---
# 注意:convert_openai_to_gemini_contents 和 convert_gemini_to_storage_format
# 的逻辑基于它们处理的特定格式。如果存储格式现在是纯 Gemini contents (List[Dict{'role', 'parts'}]),
# 那么这些转换函数在加载/保存路径上的作用需要重新评估。
# 假设 convert_messages 负责主要的 OpenAI <-> Gemini 转换。
def convert_openai_to_gemini_contents(history: List[Dict]) -> List[Dict]:
"""
(辅助函数) 将 OpenAI 格式的对话历史列表转换为 Gemini 的 contents 格式列表。
主要处理角色映射和将 content 字符串包装在 parts 列表中。
Args:
history (List[Dict]): OpenAI 格式的对话历史列表,每个字典包含 'role' 和 'content'。
Returns:
List[Dict]: Gemini 格式的 contents 列表,每个字典包含 'role' 和 'parts'。
"""
gemini_contents = [] # 初始化结果列表
for message in history: # 遍历输入的 OpenAI 消息
openai_role = message.get('role') # 获取角色
openai_content = message.get('content') # 获取内容
# 确保消息包含有效的角色和内容
if openai_role and openai_content is not None:
# --- 角色映射 ---
if openai_role == 'user':
gemini_role = 'user'
elif openai_role == 'assistant':
gemini_role = 'model'
elif openai_role == 'system':
# Gemini v1/v1.5 不直接支持 system 角色在 contents 中,通常映射为 user
# 或者在 convert_messages 中单独处理为 system_instruction
gemini_role = 'user' # 将 system 映射为 user
logger.debug("将 OpenAI 'system' 角色映射为 Gemini 'user' 角色。")
else:
logger.warning(f"跳过无效的 OpenAI 历史消息角色: {openai_role}") # 记录无效角色警告
continue # 跳过此消息
# --- 内容包装 ---
# 假设 content 总是字符串,将其包装在 'parts' 列表中
# TODO: 如果 OpenAI content 可能包含多部分(如图像),需要更复杂的处理逻辑
if isinstance(openai_content, str):
gemini_parts = [{'text': openai_content}] # 创建 parts 列表
else:
logger.warning(f"跳过非字符串类型的 OpenAI content: {type(openai_content)}")
continue # 跳过非字符串内容
# 添加到结果列表
gemini_contents.append({'role': gemini_role, 'parts': gemini_parts})
else: # 如果消息缺少角色或内容
logger.warning(f"跳过无效的 OpenAI 历史消息(缺少 role 或 content): {message}") # 记录无效消息警告
return gemini_contents # 返回转换后的列表
async def load_context_as_gemini(proxy_key: str, db: AsyncSession) -> Optional[List[Dict[str, Any]]]:
"""
异步加载指定代理 Key 的上下文,并将其直接转换为 Gemini 的 contents 格式返回。
处理 TTL 检查和数据反序列化。现在使用 ORM 和 AsyncSession。
Args:
proxy_key (str): 要加载上下文的代理 Key (通常是 user_id)。
db (AsyncSession): SQLAlchemy 异步数据库会话。
Returns:
Optional[List[Dict[str, Any]]]: Gemini 格式的 contents 列表,如果未找到、过期或加载/转换失败则返回 None。
"""
loaded_context = await load_context(proxy_key, db) # load_context 现在需要 db
if loaded_context is None:
return None
# 2. 假设 load_context 返回的是 Gemini 格式的列表 (根据 save_context 的逻辑)
# 如果 load_context 返回的是 OpenAI 格式,则需要调用转换函数
# if IS_OPENAI_FORMAT_STORAGE: # 假设有一个配置项判断存储格式
# conversion_result = convert_messages(loaded_context) # 调用转换函数
# if isinstance(conversion_result, list): # 检查转换是否出错
# logger.error(f"将加载的上下文从 OpenAI 转换为 Gemini 格式失败: {conversion_result}")
# return None
# gemini_context, _ = conversion_result # 获取转换后的 Gemini context
# return gemini_context
# else: # 假设存储的就是 Gemini 格式
# return loaded_context
# 当前 save_context 保存的是 Gemini 格式,所以直接返回加载的内容
# 但需要验证加载的内容是否真的是 List[Dict]
if isinstance(loaded_context, list):
# 可以添加更严格的验证,检查列表内元素的结构是否符合 Gemini Content 格式
return loaded_context
else:
logger.error(f"加载的上下文格式不正确 (期望列表,得到 {type(loaded_context)}) (Key: {proxy_key[:8]}...)")
# 格式错误,删除损坏数据
await delete_context_for_key(proxy_key, db) # delete_context_for_key 现在需要 db
return None
def convert_gemini_to_storage_format(request_content: Dict, response_content: Dict) -> List[Dict]:
"""
(辅助函数) 将 Gemini API 的请求内容 (用户回合) 和响应内容 (模型回合)
转换为用于数据库存储的格式 (目前使用类似 OpenAI 的格式)。
主要提取文本内容。
Args:
request_content (Dict): Gemini 格式的用户请求内容 (包含 role='user' 和 parts)。
response_content (Dict): Gemini 格式的模型响应内容 (包含 role='model' 和 parts)。
Returns:
List[Dict]: 包含两个字典的列表,分别代表用户回合和模型回合,格式为 {'role': 'user'/'assistant', 'content': 'text'}。
如果输入格式无效,可能返回空列表或只包含部分回合。
"""
storage_format = [] # 初始化存储格式列表
# --- 处理用户请求内容 ---
user_role = request_content.get('role')
user_parts = request_content.get('parts') # 获取 parts
if user_role == 'user' and user_parts is not None: # 检查 parts 是否存在 (可以是空列表)
# 直接存储原始的 role 和 parts
storage_format.append({'role': 'user', 'parts': user_parts})
else:
logger.warning(f"转换 Gemini 用户请求内容时发现无效格式或缺少 parts: {request_content}")
# --- 处理模型响应内容 ---
model_role = response_content.get('role')
model_parts = response_content.get('parts') # 获取 parts
if model_role == 'model' and model_parts is not None: # 检查 parts 是否存在
# 直接存储原始的 role 和 parts
storage_format.append({'role': 'model', 'parts': model_parts})
else:
logger.warning(f"转换 Gemini 模型响应内容时发现无效格式或缺少 parts: {response_content}")
return storage_format # 返回转换后的列表
# --- 内存数据库清理 ---
# 延迟导入 IS_MEMORY_DB
# from app.core.database.utils import IS_MEMORY_DB
async def update_ttl(context_key: str, ttl_seconds: int) -> Optional[bool]:
"""
(可能需要审查) 异步更新指定上下文记录的 TTL。
实际上是通过将 `last_used` 时间戳更新为当前时间来实现“刷新”TTL。
Args:
context_key (str): 要更新 TTL 的上下文键 (通常是 user_id)。
ttl_seconds (int): 新的 TTL 秒数 (此参数当前未使用,因为只是更新时间戳)。
Returns:
Optional[bool]: 如果成功更新时间戳返回 True,如果记录未找到返回 False,如果发生错误返回 None。
"""
if not context_key: return False # 如果 key 为空,返回 False
try:
# 延迟导入数据库连接函数
from app.core.database.utils import get_db_connection # (新路径)
async with get_db_connection() as conn: # 获取异步数据库连接
async with conn.cursor() as cursor: # 创建异步游标
# 获取当前 UTC 时间的 ISO 格式字符串
now_utc_iso = datetime.now(timezone.utc).isoformat()
# 执行 UPDATE 语句,将指定 key 的 last_used 更新为当前时间
await cursor.execute("UPDATE contexts SET last_used = ? WHERE proxy_key = ?", (now_utc_iso, context_key))
rowcount = cursor.rowcount # 获取受影响的行数
await conn.commit() # 提交事务
if rowcount > 0: # 如果更新了至少一行
logger.info(f"成功更新了 Key {context_key[:8]}... 的 last_used 时间戳。") # 记录成功日志
return True # 返回 True
else: # 如果没有行被更新 (记录不存在)
logger.warning(f"尝试更新 Key {context_key[:8]}... 的 last_used 时间戳,但未找到记录。") # 记录警告
return False # 返回 False
except aiosqlite.Error as e: # 捕获数据库错误
logger.error(f"更新 Key {context_key[:8]}... 的 last_used 时间戳失败: {e}", exc_info=True) # 记录错误日志
return None # 返回 None 表示出错
async def update_global_ttl(ttl_days: int) -> bool: # 参数名修改为 ttl_days
"""
异步更新全局上下文 TTL 的天数设置。
Args:
ttl_days (int): 新的全局 TTL 天数 (非负整数)。
Returns:
bool: 如果成功更新设置返回 True,否则返回 False。
"""
# 延迟导入数据库设置函数
from app.core.database.settings import set_ttl_days # (新路径)
try:
# 调用 set_ttl_days 函数来验证并保存设置
await set_ttl_days(ttl_days) # set_ttl_days 内部会处理验证和转换
logger.info(f"全局上下文 TTL 已更新为 {ttl_days} 天。") # 记录成功日志
return True # 返回 True
except ValueError as ve: # 捕获 set_ttl_days 可能抛出的 ValueError (如果输入无效)
logger.error(f"更新全局上下文 TTL failed: {ve}") # 记录错误
return False # 返回 False
except Exception as e: # 捕获其他可能的异常
logger.error(f"更新全局上下文 TTL 时发生意外错误: {e}", exc_info=True) # 记录错误
return False # 返回 False
async def get_all_contexts_with_ttl() -> Dict[str, Dict[str, Any]]:
"""
异步获取所有存储的上下文记录及其元信息,包括计算出的剩余 TTL 和内容摘要。
Returns:
Dict[str, Dict[str, Any]]: 一个字典,键是 proxy_key (user_id),值是包含以下信息的字典:
- 'ttl' (str): 剩余 TTL 的可读字符串 ("x天 y小时", "已过期", "永不", "N/A")。
- 'last_accessed' (str): 最后访问时间的格式化字符串 ("YYYY-MM-DD HH:MM:SS") 或 "N/A"。
- 'context_summary' (str): 上下文内容的摘要 (通常是第一条消息的前 100 个字符) 或 "N/A"。
"""
all_contexts_data = {} # 初始化结果字典
try:
# 延迟导入数据库工具和设置函数
from app.core.database.utils import get_db_connection # (新路径)
from app.core.database.settings import get_ttl_days # (新路径)
# 获取全局 TTL 设置 (天数)
global_ttl_days = await get_ttl_days()
# 计算全局 TTL 的秒数 (如果 TTL > 0)
global_ttl_seconds = global_ttl_days * 24 * 60 * 60 if global_ttl_days > 0 else 0
async with get_db_connection() as conn: # 获取异步数据库连接
conn.row_factory = aiosqlite.Row # 设置 row_factory 以便按列名访问
async with conn.cursor() as cursor: # 创建异步游标
# 查询所有上下文记录,按最后使用时间降序排序
await cursor.execute("SELECT proxy_key, contents, last_used FROM contexts ORDER BY last_used DESC")
rows = await cursor.fetchall() # 获取所有行
# 遍历查询结果
for row in rows:
proxy_key = row['proxy_key'] # 获取 proxy_key
contents_json = row['contents'] # 获取上下文内容的 JSON 字符串
last_used_str = row['last_used'] # 获取最后使用时间的 ISO 格式字符串
# --- 生成内容摘要 ---
context_summary = "N/A" # 初始化摘要为 "N/A"
if contents_json: # 如果内容不为空
try:
# 在线程中反序列化 JSON
contents_list = await asyncio.to_thread(json.loads, contents_json)
# 检查反序列化结果是否为非空列表
if contents_list and isinstance(contents_list, list) and len(contents_list) > 0:
# 尝试从第一条消息提取文本内容作为摘要
first_message = contents_list[0]
if isinstance(first_message, dict) and 'content' in first_message: # 处理 OpenAI 格式
content_text = str(first_message['content'])
context_summary = content_text[:100] + "..." if len(content_text) > 100 else content_text
elif isinstance(first_message, dict) and 'parts' in first_message and isinstance(first_message['parts'], list) and len(first_message['parts']) > 0: # 处理 Gemini 格式
first_part = first_message['parts'][0]
if isinstance(first_part, dict) and 'text' in first_part:
content_text = str(first_part['text'])
context_summary = content_text[:100] + "..." if len(content_text) > 100 else content_text
except json.JSONDecodeError: # 捕获 JSON 解析错误
logger.warning(f"无法解析 Key {proxy_key[:8]}... 的上下文内容 JSON。") # 记录警告
except Exception as e: # 捕获其他提取摘要时的错误
logger.warning(f"提取 Key {proxy_key[:8]}... 的上下文摘要时出错: {e}") # 记录警告
# --- 计算剩余 TTL ---
ttl_remaining_str = "N/A" # 初始化剩余 TTL 字符串为 "N/A"
if last_used_str and global_ttl_seconds > 0: # 仅在有上次使用时间且 TTL > 0 时计算
try:
# 解析 ISO 格式时间字符串,并确保为 UTC 时区
last_used_dt = datetime.fromisoformat(last_used_str.replace('Z', '+00:00')).replace(tzinfo=timezone.utc)
# 计算过期时间点
expiry_time = last_used_dt + timedelta(seconds=global_ttl_seconds)
now_utc = datetime.now(timezone.utc) # 获取当前 UTC 时间
# 计算剩余时间差
remaining_delta = expiry_time - now_utc
if remaining_delta.total_seconds() > 0: # 如果尚未过期
# 将剩余时间格式化为可读字符串
days = remaining_delta.days
hours, remainder = divmod(remaining_delta.seconds, 3600)
minutes, seconds = divmod(remainder, 60)
if days > 0:
ttl_remaining_str = f"{days}{hours}小时"
elif hours > 0:
ttl_remaining_str = f"{hours}小时 {minutes}分钟"
elif minutes > 0:
ttl_remaining_str = f"{minutes}分钟 {seconds}秒"
else:
ttl_remaining_str = f"{seconds}秒"
else: # 如果已过期
ttl_remaining_str = "已过期"
except ValueError: # 捕获时间戳解析错误
logger.warning(f"无法解析 Key {proxy_key[:8]}... 的 last_used 时间戳: {last_used_str}") # 记录警告
elif global_ttl_seconds <= 0: # 如果 TTL 设置为 0 或负数
ttl_remaining_str = "永不" # 表示永不过期
# --- 存储结果 ---
all_contexts_data[proxy_key] = {
"ttl": ttl_remaining_str, # 剩余 TTL 字符串
# 格式化最后访问时间为 "YYYY-MM-DD HH:MM:SS"
"last_accessed": last_used_str.split('.')[0].replace('T', ' ') if last_used_str else "N/A",
"context_summary": context_summary # 内容摘要
}
logger.info(f"成功获取了 {len(all_contexts_data)} 条上下文记录及其 TTL 信息。") # 记录成功日志
except aiosqlite.Error as e: # 捕获数据库错误
logger.error(f"获取所有上下文及其 TTL 信息 failed: {e}", exc_info=True) # 记录错误
except Exception as e: # 捕获其他意外错误
logger.error(f"处理所有上下文数据时发生意外错误: {e}", exc_info=True) # 记录错误
return all_contexts_data # 返回结果字典
# 导入配置模块以访问 CONTEXT_STORAGE_MODE
from app import config as app_config
class ContextStore:
"""
管理对话上下文的存储和检索。
支持内存和数据库两种存储模式。
"""
def __init__(self, storage_mode: str = app_config.CONTEXT_STORAGE_MODE, db_path: str = app_config.CONTEXT_DB_PATH):
"""
初始化 ContextStore。
Args:
storage_mode (str): 'memory' 或 'database'。
db_path (str): 数据库文件路径 (仅在 database 模式下使用)。
"""
self.storage_mode = storage_mode
self.db_path = db_path # 虽然旧函数可能使用,但新方法应通过 db session
if self.storage_mode == "memory":
self.memory_store: Dict[str, Dict[str, Any]] = {} # 存储结构: {context_key: {"content": ..., "expires_at": ..., "last_used": ...}}
self.memory_lock = asyncio.Lock() # 用于保护内存存储的异步锁
logger.info("上下文存储已初始化为内存模式。")
elif self.storage_mode == "database":
# 数据库模式的初始化在 get_db_session 和 init_db 中处理
logger.info(f"上下文存储已初始化为数据库模式 (路径: {self.db_path})。")
else:
raise ValueError(f"未知的上下文存储模式: {storage_mode}")
async def perform_memory_cleanup(self):
"""
(仅内存模式) 清理内存中存储的过期上下文条目。
"""
if self.storage_mode != "memory":
return
async with self.memory_lock:
now_iso = datetime.now(timezone.utc).isoformat()
keys_to_delete = []
for key, data in self.memory_store.items():
expires_at = data.get("expires_at")
if expires_at and expires_at < now_iso:
keys_to_delete.append(key)
for key in keys_to_delete:
del self.memory_store[key]
logger.info(f"内存中的上下文 Key '{key}' 已过期并被清理。")
if keys_to_delete:
logger.info(f"内存上下文清理完成,移除了 {len(keys_to_delete)} 个过期条目。")
else:
logger.debug("内存上下文清理完成,没有找到需要删除的过期条目。")
# 检查是否超出最大记录数限制 (如果定义了 MAX_CONTEXT_RECORDS_MEMORY)
if MAX_CONTEXT_RECORDS_MEMORY > 0 and len(self.memory_store) > MAX_CONTEXT_RECORDS_MEMORY:
num_to_prune = len(self.memory_store) - MAX_CONTEXT_RECORDS_MEMORY
# 按 last_used 排序,旧的在前
sorted_keys = sorted(self.memory_store.items(), key=lambda item: item[1].get('last_used', ''))
keys_to_prune = [item[0] for item in sorted_keys[:num_to_prune]]
for key in keys_to_prune:
del self.memory_store[key]
logger.info(f"ContextStore: 内存上下文存储超出最大记录数限制,已清理 {len(keys_to_prune)} 条最旧的记录。")
async def store_context(self, user_id: str, context_key: str, context_value: Any, ttl_seconds: Optional[int] = None):
"""
存储上下文信息。
对于数据库模式,现在使用 DialogContext 模型。
Args:
user_id (str): 用户标识。 (注意:在DialogContext中,proxy_key通常是user_id)
context_key (str): 上下文的唯一键 (通常是 user_id 或基于 user_id 的键)。
context_value (Any): 要存储的上下文内容 (通常是 Gemini contents 列表)。
ttl_seconds (Optional[int]): 此上下文的 TTL (秒)。如果为 None,则使用全局 TTL。
"""
if not context_key or context_value is None:
logger.warning(f"ContextStore: 尝试为 Key {context_key[:8]}... 保存空的上下文或内容,已跳过。")
return
now = datetime.now(timezone.utc)
# 确定此上下文的 TTL (用于内存模式)
# 数据库模式下,DialogContext.ttl_seconds 可以存储特定TTL,全局TTL由 get_ttl_days() 控制
final_ttl_seconds_for_memory: Optional[int] = ttl_seconds
if final_ttl_seconds_for_memory is None:
global_ttl_days = await get_ttl_days()
final_ttl_seconds_for_memory = global_ttl_days * 24 * 60 * 60 if global_ttl_days > 0 else None
expires_at_iso_for_memory: Optional[str] = None
if final_ttl_seconds_for_memory is not None and final_ttl_seconds_for_memory > 0:
expires_at_iso_for_memory = (now + timedelta(seconds=final_ttl_seconds_for_memory)).isoformat()
if self.storage_mode == "memory":
async with self.memory_lock:
self.memory_store[context_key] = {
"user_id": user_id,
"content": context_value,
"last_used": now.isoformat(),
"expires_at": expires_at_iso_for_memory,
"created_at": now.isoformat()
}
logger.info(f"ContextStore: 上下文已为 Key {context_key[:8]}... (用户 {user_id}) 存储到内存。")
if MAX_CONTEXT_RECORDS_MEMORY > 0 and len(self.memory_store) > MAX_CONTEXT_RECORDS_MEMORY:
num_to_prune = len(self.memory_store) - MAX_CONTEXT_RECORDS_MEMORY
sorted_keys = sorted(self.memory_store.items(), key=lambda item: item[1].get('last_used', ''))
keys_to_prune = [item[0] for item in sorted_keys[:num_to_prune]]
for key_to_prune in keys_to_prune:
del self.memory_store[key_to_prune]
logger.info(f"ContextStore: 内存上下文存储超出最大记录数限制,已清理 {len(keys_to_prune)} 条最旧的记录。")
elif self.storage_mode == "database":
from app.core.dependencies import get_db_session # 导入依赖
async for db in get_db_session():
try:
contents_json = await asyncio.to_thread(json.dumps, context_value, ensure_ascii=False)
# 使用 DialogContext 模型进行 UPSERT
stmt = sqlite_insert(DialogContext).values(
proxy_key=context_key, # 假设 context_key 是 DialogContext 的 proxy_key
contents=contents_json,
last_used_at=now,
created_at=now,
ttl_seconds=ttl_seconds # 存储特定 TTL (如果提供)
)
update_values = {
"contents": stmt.excluded.contents,
"last_used_at": stmt.excluded.last_used_at,
}
# 只有当新的 ttl_seconds 被提供时才更新它,否则保留旧值或NULL
if ttl_seconds is not None:
update_values["ttl_seconds"] = stmt.excluded.ttl_seconds
stmt = stmt.on_conflict_do_update(
index_elements=[DialogContext.proxy_key],
set_=update_values
)
await db.execute(stmt)
await db.commit()
logger.info(f"ContextStore: 上下文已为 Key {context_key[:8]}... (用户 {user_id}) 使用 DialogContext ORM 保存/更新。")
break
except Exception as e:
logger.error(f"ContextStore: 为 Key {context_key[:8]}... (用户 {user_id}) 保存上下文到数据库 (DialogContext) failed: {e}", exc_info=True)
await db.rollback()
break
else:
logger.error(f"ContextStore: 未知的存储模式 '{self.storage_mode}',无法存储上下文。")
async def retrieve_context(self, user_id: str, context_key: str) -> Optional[Any]:
"""
检索上下文信息。
Args:
user_id (str): 用户标识。
context_key (str): 上下文的唯一键。
Returns:
Optional[Any]: 存储的上下文内容,如果未找到或已过期则返回 None。
"""
now_iso = datetime.now(timezone.utc).isoformat()
if self.storage_mode == "memory":
async with self.memory_lock:
data = self.memory_store.get(context_key)
if data:
# 检查 user_id 是否匹配 (如果需要更严格的隔离)
# if data.get("user_id") != user_id:
# logger.warning(f"用户 {user_id[:8]}... 尝试访问不属于自己的内存上下文 Key '{context_key}'。")
# return None
if data.get("expires_at") and data["expires_at"] < now_iso:
logger.info(f"内存中的上下文 Key '{context_key}' 已过期,将被删除。")
del self.memory_store[context_key]
return None
data["last_used"] = now_iso # 更新最后使用时间
return data.get("content")
return None
elif self.storage_mode == "database":
from app.core.dependencies import get_db_session
async for db in get_db_session(): # Renamed session to db for clarity with top-level functions
try:
# 调用已重构的顶层 load_context 函数
# 假设 ContextStore 的 context_key 就是 DialogContext 的 proxy_key
retrieved_value = await load_context(proxy_key=context_key, db=db)
# 如果需要在加载时更新 last_used_at (load_context 目前不这样做)
if retrieved_value is not None:
update_stmt = sqlalchemy_update(DialogContext).\
where(DialogContext.proxy_key == context_key).\
values(last_used_at=datetime.now(timezone.utc))
await db.execute(update_stmt)
await db.commit()
return retrieved_value
except Exception as e:
logger.error(f"ContextStore: 从数据库检索 Key '{context_key}' (用户 {user_id}) 的上下文 (DialogContext) failed: {e}", exc_info=True)
await db.rollback() # Ensure rollback on error
return None
finally:
break # Ensure loop runs once
else:
logger.error(f"ContextStore: 未知的存储模式 '{self.storage_mode}',无法检索上下文。")
return None
async def delete_context(self, user_id: str, context_key: str) -> bool:
"""
删除指定的上下文。
Args:
user_id (str): 用户标识。
context_key (str): 要删除的上下文的键。
Returns:
bool: 如果成功删除或记录本就不存在则返回 True,否则返回 False。
"""
if self.storage_mode == "memory":
async with self.memory_lock:
if context_key in self.memory_store:
# 检查 user_id 是否匹配 (如果需要)
# if self.memory_store[context_key].get("user_id") != user_id:
# logger.warning(f"用户 {user_id[:8]}... 尝试删除不属于自己的内存上下文 Key '{context_key}'。")
# return False
del self.memory_store[context_key]
logger.info(f"内存中的上下文 Key '{context_key}' 已被用户 {user_id[:8]}... 删除。")
return True
return False # Key 不存在
elif self.storage_mode == "database":
from app.core.dependencies import get_db_session
async for db in get_db_session(): # Renamed session to db
try:
# 调用已重构的顶层 delete_context_for_key 函数
# 假设 ContextStore 的 context_key 就是 DialogContext 的 proxy_key
deleted = await delete_context_for_key(proxy_key=context_key, db=db)
# delete_context_for_key 内部已包含日志
return deleted
except Exception as e:
logger.error(f"ContextStore: 从数据库删除 Key '{context_key}' (用户 {user_id}) 的上下文 (DialogContext) failed: {e}", exc_info=True)
await db.rollback()
return False
finally:
break
else:
logger.error(f"ContextStore: 未知的存储模式 '{self.storage_mode}',无法删除上下文。")
return False
async def get_context_info_for_management(self, user_id: Optional[str] = None, is_admin: bool = False) -> List[Dict[str, Any]]:
"""
获取用于管理界面的上下文信息列表。
"""
contexts_info = []
now_utc = datetime.now(timezone.utc)
global_ttl_days = await get_ttl_days()
global_ttl_delta = timedelta(days=global_ttl_days) if global_ttl_days > 0 else None
if self.storage_mode == "memory":
async with self.memory_lock:
for key, data in self.memory_store.items():
if not is_admin and data.get("user_id") != user_id:
continue # 非管理员只能看到自己的
summary = "N/A"
if data.get("content"):
try:
first_message_content = ""
if isinstance(data["content"], list) and data["content"]:
first_message = data["content"][0]
if isinstance(first_message, dict) and 'parts' in first_message and first_message['parts']:
first_part = first_message['parts'][0]
if isinstance(first_part, dict) and 'text' in first_part:
first_message_content = str(first_part['text'])
elif isinstance(first_message, dict) and 'content' in first_message: # 兼容旧格式
first_message_content = str(first_message['content'])
summary = first_message_content[:100] + "..." if len(first_message_content) > 100 else first_message_content
except Exception:
pass
last_used_dt = datetime.fromisoformat(data["last_used"].replace('Z', '+00:00')) if data.get("last_used") else now_utc
expires_at_dt = datetime.fromisoformat(data["expires_at"].replace('Z', '+00:00')) if data.get("expires_at") else None
ttl_str = "永不"
if expires_at_dt:
if expires_at_dt < now_utc:
ttl_str = "已过期"
else:
remaining_delta = expires_at_dt - now_utc
days = remaining_delta.days
hours, remainder = divmod(remaining_delta.seconds, 3600)
minutes, _ = divmod(remainder, 60)
if days > 0: ttl_str = f"{days}{hours}小时"
elif hours > 0: ttl_str = f"{hours}小时{minutes}分钟"
else: ttl_str = f"{minutes}分钟"
elif global_ttl_delta : # 如果单条记录没有expires_at,但有全局TTL
effective_expiry = last_used_dt + global_ttl_delta
if effective_expiry < now_utc:
ttl_str = "已过期 (全局TTL)"
else:
remaining_delta = effective_expiry - now_utc
days = remaining_delta.days
hours, remainder = divmod(remaining_delta.seconds, 3600)
minutes, _ = divmod(remainder, 60)
if days > 0: ttl_str = f"{days}{hours}小时 (全局TTL)"
elif hours > 0: ttl_str = f"{hours}小时{minutes}分钟 (全局TTL)"
else: ttl_str = f"{minutes}分钟 (全局TTL)"
contexts_info.append({
"context_key": key, # 使用内存中的 key 作为 context_key
"user_id": data.get("user_id", "N/A"),
"created_at": data.get("created_at", "N/A"), # 已是 ISO 格式
"last_accessed_at": data.get("last_used", "N/A"), # 已是 ISO 格式
"ttl_seconds": (expires_at_dt - datetime.fromisoformat(data["created_at"])).total_seconds() if expires_at_dt and data.get("created_at") else "N/A",
"context_value_summary": summary,
"ttl_display": ttl_str,
"id": key # 对于内存模式,用 key 作为 id
})
# 按创建时间排序 (新的在前)
contexts_info.sort(key=lambda x: x.get('created_at', ''), reverse=True)
elif self.storage_mode == "database":
from app.core.dependencies import get_db_session
async for db in get_db_session():
try:
# 此函数现在直接使用 DialogContext 模型进行查询
stmt = select(DialogContext)
if not is_admin and user_id:
stmt = stmt.where(DialogContext.proxy_key == user_id)
stmt = stmt.order_by(DialogContext.last_used_at.desc())
result = await db.execute(stmt)
records = result.scalars().all()
for record in records:
summary = "N/A"
if record.contents:
try:
content_list = await asyncio.to_thread(json.loads, record.contents)
if content_list and isinstance(content_list, list) and content_list:
first_message = content_list[0]
if isinstance(first_message, dict) and 'parts' in first_message and first_message['parts']:
first_part = first_message['parts'][0]
if isinstance(first_part, dict) and 'text' in first_part:
summary = str(first_part['text'])[:100] + "..." if len(str(first_part['text'])) > 100 else str(first_part['text'])
elif isinstance(first_message, dict) and 'content' in first_message:
summary = str(first_message['content'])[:100] + "..." if len(str(first_message['content'])) > 100 else str(first_message['content'])
except Exception:
pass
ttl_str = "永不"
effective_ttl_seconds: Optional[int] = record.ttl_seconds
if effective_ttl_seconds is None and global_ttl_delta: # 使用全局 TTL
effective_ttl_seconds = int(global_ttl_delta.total_seconds())
if record.last_used_at and effective_ttl_seconds is not None and effective_ttl_seconds > 0:
last_used_aware = record.last_used_at if record.last_used_at.tzinfo else record.last_used_at.replace(tzinfo=timezone.utc)
expiry_time = last_used_aware + timedelta(seconds=effective_ttl_seconds)
if expiry_time < now_utc:
ttl_str = "已过期"
else:
remaining_delta = expiry_time - now_utc
days, rem_secs = divmod(remaining_delta.total_seconds(), 86400)
hours, rem_secs = divmod(rem_secs, 3600)
minutes, _ = divmod(rem_secs, 60)
if days > 0: ttl_str = f"{int(days)}{int(hours)}小时"
elif hours > 0: ttl_str = f"{int(hours)}小时{int(minutes)}分钟"
else: ttl_str = f"{int(minutes)}分钟"
contexts_info.append({
"id": record.id,
"user_id": record.proxy_key, # DialogContext 使用 proxy_key
"context_key": record.proxy_key, # 通常 context_key 就是 proxy_key
"created_at": record.created_at.isoformat() if record.created_at else "N/A",
"last_accessed_at": record.last_used_at.isoformat() if record.last_used_at else "N/A",
"ttl_seconds": record.ttl_seconds if record.ttl_seconds is not None else (global_ttl_delta.total_seconds() if global_ttl_delta else "N/A"),
"context_value_summary": summary,
"ttl_display": ttl_str
})
break
except Exception as e:
logger.error(f"ContextStore: 获取数据库上下文信息 (DialogContext) failed: {e}", exc_info=True)
await db.rollback()
return False
finally:
break # Exit loop on error
return contexts_info
async def delete_context_by_id(self, context_id: int, user_id: Optional[str] = None, is_admin: bool = False) -> bool:
"""
通过数据库 ID 删除上下文条目。
如果不是管理员,则会校验 user_id。
"""
if self.storage_mode == "memory":
# 内存模式下,我们通常通过 context_key (即 id) 删除
async with self.memory_lock:
key_to_delete = str(context_id) # 假设 id 就是 context_key
if key_to_delete in self.memory_store:
if not is_admin and self.memory_store[key_to_delete].get("user_id") != user_id:
logger.warning(f"用户 {user_id} 尝试删除不属于自己的内存上下文 ID {key_to_delete}")
return False
del self.memory_store[key_to_delete]
logger.info(f"内存上下文 ID '{key_to_delete}' 已被删除。")
return True
return False
elif self.storage_mode == "database":
from app.core.dependencies import get_db_session
async for db in get_db_session(): # Renamed session to db
try:
stmt = select(DialogContext).where(DialogContext.id == context_id)
if not is_admin and user_id:
# 假设 DialogContext 的 proxy_key 存储的是 user_id
stmt = stmt.where(DialogContext.proxy_key == user_id)
result = await db.execute(stmt)
record_to_delete = result.scalar_one_or_none()
if record_to_delete:
await db.delete(record_to_delete)
await db.commit()
logger.info(f"ContextStore: 数据库上下文 ID '{context_id}' (DialogContext) 已被删除。")
return True
logger.warning(f"ContextStore: 尝试删除数据库上下文 ID '{context_id}' (DialogContext),但未找到或权限不足。")
return False
except Exception as e:
logger.error(f"ContextStore: 通过 ID 删除数据库上下文 (DialogContext) failed: {e}", exc_info=True)
await db.rollback()
return False
finally:
break
return False
# _prune_memory_context 函数已移除,其功能由 ContextStore 内存模式处理或在数据库模式下不直接适用
async def update_ttl(context_key: str, ttl_seconds: int, db: AsyncSession) -> Optional[bool]: # 添加 db 参数
"""
异步更新指定 DialogContext 记录的 TTL (通过更新 last_used_at 和可选的 ttl_seconds 字段)。
"""
if not context_key: return False
try:
now_dt = datetime.now(timezone.utc)
values_to_update = {"last_used_at": now_dt}
if ttl_seconds is not None and ttl_seconds > 0 : # 仅当提供了有效的 ttl_seconds 时才更新它
values_to_update["ttl_seconds"] = ttl_seconds
stmt = (
sqlalchemy_update(DialogContext)
.where(DialogContext.proxy_key == context_key)
.values(**values_to_update)
.execution_options(synchronize_session=False) # 对于 ORM 更新,通常不需要
)
result = await db.execute(stmt)
await db.commit()
if result.rowcount > 0:
logger.info(f"成功使用 ORM 更新了 Key {context_key[:8]}... 的上下文 TTL 信息。")
return True
else:
logger.warning(f"尝试使用 ORM 更新 Key {context_key[:8]}... 的上下文 TTL,但未找到记录。")
return False
except Exception as e:
logger.error(f"使用 ORM 更新 Key {context_key[:8]}... 的上下文 TTL failed: {e}", exc_info=True)
await db.rollback()
return None
async def update_global_ttl(ttl_days: int) -> bool: # 这个函数操作的是 settings 表,保持不变
"""
异步更新全局上下文 TTL 的天数设置。
"""
from app.core.database.settings import set_ttl_days
try:
await set_ttl_days(ttl_days)
logger.info(f"全局上下文 TTL 已更新为 {ttl_days} 天。")
return True
except ValueError as ve:
logger.error(f"更新全局上下文 TTL failed: {ve}")
return False
except Exception as e:
logger.error(f"更新全局上下文 TTL 时发生意外错误: {e}", exc_info=True)
return False
async def get_all_contexts_with_ttl(db: AsyncSession) -> Dict[str, Dict[str, Any]]: # 添加 db 参数
"""
异步获取所有存储的 DialogContext 记录及其元信息,包括计算出的剩余 TTL 和内容摘要。
使用 SQLAlchemy ORM。
"""
all_contexts_data = {}
try:
global_ttl_days = await get_ttl_days() # 全局 TTL
stmt = select(DialogContext).order_by(DialogContext.last_used_at.desc())
result = await db.execute(stmt)
records = result.scalars().all()
for record in records:
proxy_key = record.proxy_key
contents_json = record.contents
last_used_dt = record.last_used_at # datetime object
record_ttl_seconds = record.ttl_seconds # 特定记录的 TTL (秒)
context_summary = "N/A"
if contents_json:
try:
contents_list = await asyncio.to_thread(json.loads, contents_json)
if contents_list and isinstance(contents_list, list) and len(contents_list) > 0:
first_message = contents_list[0]
if isinstance(first_message, dict) and 'parts' in first_message and \
isinstance(first_message['parts'], list) and len(first_message['parts']) > 0:
first_part = first_message['parts'][0]
if isinstance(first_part, dict) and 'text' in first_part:
content_text = str(first_part['text'])
context_summary = content_text[:100] + "..." if len(content_text) > 100 else content_text
elif isinstance(first_message, dict) and 'content' in first_message: # 兼容旧 OpenAI 格式
content_text = str(first_message['content'])
context_summary = content_text[:100] + "..." if len(content_text) > 100 else content_text
except Exception as e:
logger.warning(f"提取 Key {proxy_key[:8]}... 的上下文摘要时出错: {e}")
ttl_remaining_str = "N/A"
# 优先使用记录特定的 TTL,否则使用全局 TTL
effective_ttl_seconds: Optional[int] = None
if record_ttl_seconds is not None and record_ttl_seconds > 0:
effective_ttl_seconds = record_ttl_seconds
elif global_ttl_days > 0:
effective_ttl_seconds = global_ttl_days * 24 * 60 * 60
if last_used_dt and effective_ttl_seconds is not None and effective_ttl_seconds > 0:
# 确保 last_used_dt 是 aware
if last_used_dt.tzinfo is None:
last_used_dt = last_used_dt.replace(tzinfo=timezone.utc)
expiry_time = last_used_dt + timedelta(seconds=effective_ttl_seconds)
now_utc = datetime.now(timezone.utc)
remaining_delta = expiry_time - now_utc
if remaining_delta.total_seconds() > 0:
days = remaining_delta.days
hours, remainder = divmod(remaining_delta.seconds, 3600)
minutes, _ = divmod(remainder, 60)
if days > 0: ttl_remaining_str = f"{days}{hours}小时"
elif hours > 0: ttl_remaining_str = f"{hours}小时 {minutes}分钟"
else: ttl_remaining_str = f"{minutes}分钟"
else:
ttl_remaining_str = "已过期"
elif effective_ttl_seconds is None or effective_ttl_seconds <= 0 : # 永不过期
ttl_remaining_str = "永不"
all_contexts_data[proxy_key] = {
"ttl": ttl_remaining_str,
"last_accessed": last_used_dt.strftime("%Y-%m-%d %H:%M:%S %Z") if last_used_dt else "N/A",
"context_summary": context_summary
}
logger.info(f"成功使用 ORM 获取了 {len(all_contexts_data)} 条上下文记录及其 TTL 信息。")
except Exception as e:
logger.error(f"使用 ORM 获取所有上下文及其 TTL 信息 failed: {e}", exc_info=True)
return all_contexts_data